Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "brew",
"source": "./",
"description": "Claude Code skill for brewing coffee on a Fellow Aiden via the brew MCP server — pairs with the MCP server in this repo.",
"version": "0.2.0"
"version": "0.3.1"
}
]
}
82 changes: 82 additions & 0 deletions skills/brew-log/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
name: brew-log
description: Use when the user wants to log how a brewed coffee tasted on the Fellow Aiden — they describe the cup ("too sour", "hollow", "great", "more bitter than I'd like") after drinking. Also triggers on `/brew-log`.
---

# `/brew-log` — log a tasting & coach the palate

Turn *"I'm drinking it and it's a bit sharp"* into a structured, queryable tasting record.
You talk naturally; the skill parses your words into axes + flavor tags, stores both the
prose and the structure (via the MCP `log_tasting` tool), and optionally offers a recipe
tweak. Over time this feeds `/brew`'s palate pre-flight ("you tend to find Geishas sour").

## When to use

- The user describes how a brewed cup tasted (after drinking).
- The user says `/brew-log`.
- A post-brew "how was it?" moment.

## Flow

1. **Identify the brew.** Read `coffee://journal` — the most recent entry for the active
bag is almost always the one. If it's ambiguous (multiple recent, or they mean an older
cup), ask which.
2. **Coach the tasting.** Nudge for a 1–5 rating if they haven't given one. Help them
articulate — offer relatable, *researched* calibration ("sharper than lemon, or
softer?"; "is the bitterness like dark chocolate or like burnt?"), grounded in the SCA
flavor wheel. **Keep it light and optional** — never force structure on someone who
just wants to say "it's good."
3. **Parse to axes + tags.** Map the prose to the five signed axes and the flavor-wheel tags.
4. **Persist.** Call `mcp__brew__log_tasting` with the axes, `flavor_tags`, `note_text`
(their words verbatim), and `rating`. Omit `entry_id` to apply to the active bag's most
recent brew — the tool snapshots the bag's bean dimensions automatically. **Always go
through `log_tasting`** — don't hand-roll a raw journal PATCH, or the bean-dimension
snapshot (which palate learning depends on) won't be written.
5. **Offer a fix (optional).** If the cup was off, propose an `update_profile` diff *for
this bag now* using the fault→lever table below. One change at a time; name it
("bumping grind 4 → 3 for next time").

## Axis vocabulary (the parse target)

Signed **−2 … +2**, where **0 = balanced/just right**:

| Axis | −2 | +2 |
|---|---|---|
| `acidity` | flat / dull | sharp / sour |
| `bitterness` | none | bitter / harsh |
| `body` | thin / watery | heavy / syrupy |
| `sweetness` | low | high / candy-like |
| `strength` | weak | strong / intense |

Only set the axes the user actually spoke to; leave the rest unset (don't invent).

## Fault → lever (source of truth — Barista Hustle Coffee Compass)

| Taste | Reading | Primary lever | Secondary |
|---|---|---|---|
| sour / thin (acidity +, sweetness −) | under-extracted | **grind finer** | ↑ bloom temp, ↑ pulse temp |
| bitter / dry (bitterness +) | over-extracted | **grind coarser** | ↓ pulse temp |
| weak / watery (strength −) | low strength | **tighten ratio** (16.5 → 16.0) | — |
| too strong (strength +) | high strength | **loosen ratio** (16.0 → 17.0) | — |
| balanced | locked | save as the bag's default | — |

Extraction faults (sour/bitter) move **grind/temp**; strength faults move **ratio**.

## Water (BWT magnesium-forward filter)

If a cup reads **sharp / sour / thin**, fix it with a **weaker ratio (~1:17)** or by adding
buffer to the water — **never grind coarser "because of the water."** Magnesium-forward
water doesn't over-extract; sharpness is an alkalinity/ratio issue. (Grinding *finer* for a
genuinely under-extracted cup is still correct — that's extraction, not the water.)

## Red flags

- Grinding coarser for sharpness "because the water is magnesium-forward" → **stop**, that's
unfounded; weaken the ratio instead.
- Forcing the user to rate every axis → **stop**, capture only what they said; coaching is an
offer, not a quiz.
- Writing tasting data via a raw `PATCH /journal/{id}` instead of `log_tasting` → **stop**,
that skips the bean-dimension snapshot palate learning needs.
- Inventing axis values the user didn't express → **stop**, leave them unset.
- Stacking multiple recipe changes at once → **stop**, one lever per iteration so the next
cup is a clean signal.
29 changes: 24 additions & 5 deletions skills/brew/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,34 @@ settings, and the device shows the dose once you pick millilitres).

## Flow

1. **Identify** the bean (photo/text).
1. **Identify** the bean (photo/text), incl. dimensions: varietal, process, roast, origin.
2. **Check `coffee://profiles`** for an existing match — roaster + coffee-name
substring (e.g. "Intermission" + "Spring Bloom"). User profiles live in the
`Custom` folder. If one matches, **reuse it** and say so; skip the rest. Only
create fresh if there's no match or the user asks for a new one.
3. **Clone a template drop** from `coffee://profiles` via the decision tree in
`references/profile-heuristics.md`.
4. **Apply nudges**: light-natural/Geisha overrides (below) + roast-delta.
5. **Derive the two grind numbers** — single-serve and batch (below).
6. **`create_profile`** with `profile_type=0`, °C temps, and the grind-span title.
7. **Print the recipe card.** Stop.
4. **Palate pre-flight** — call `query_palate(varietal, process, roast_level, origin,
altitude_masl)` for the user's tasting history of *similar* beans (see below).
5. **Apply nudges**: light-natural/Geisha overrides (below) + roast-delta, **plus the
palate correction** from step 4 if its confidence is meaningful.
6. **Derive the two grind numbers** — single-serve and batch (below).
7. **`create_profile`** with `profile_type=0`, °C temps, and the grind-span title.
8. **Print the recipe card.** Stop.

### Palate pre-flight (step 4)

`query_palate` returns `{tendency, confidence, n, neighbours}` — `tendency` is the
similarity-weighted mean of the signed tasting axes (−2…+2) across the user's similar
past brews. If `n ≥ 2` and confidence is meaningful, translate the tendency into a
template nudge via the fault→lever mapping and **say why**:

> *"Your last 3 Geisha naturals averaged sour (+1.5 acidity) — starting one grind finer
> and ratio 16.0 instead of the template's 16.5."*

Lever directions: **acidity +** (trends sour) → grind finer / ratio tighter; **bitterness +**
(trends bitter) → grind coarser; **strength +/−** → ratio looser/tighter. If `n = 0` or
confidence is low, proceed from the template **silently** — never invent history.

## Non-negotiable MCP values

Expand Down Expand Up @@ -206,6 +223,8 @@ This skill creates the profile and stops. Handled elsewhere:
`brew_now`** as part of `/brew` → **stop**, all out of scope; this skill ends at the
recipe card.
- Skipping the `coffee://profiles` dedup check → **stop**, reuse before creating.
- About to finalize a profile without calling `query_palate` for a bean whose dimensions
you know → **stop**, check palate history first and pre-correct (step 4).
- Inventing pulse temps / ratios from nothing → **stop**, clone a Fellow drop first.
- Using the table's "light" grind (5 SS) for a Geisha / delicate natural → **stop**,
those grind finer — Fellow's own call is ~4 SS.
Expand Down
6 changes: 6 additions & 0 deletions src/brew/bags/model/api/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ class BagCreateAPIRequest(BaseModel):
profile_snapshot: dict[str, Any]
roast_date: date | None = None
profile_id: str | None = None
varietal: str | None = None
process: str | None = None
altitude_masl: int | None = Field(default=None, ge=0)


class BagUpdateAPIRequest(BaseModel):
Expand All @@ -23,3 +26,6 @@ class BagUpdateAPIRequest(BaseModel):
roast_level: str | None = Field(default=None, min_length=1)
profile_id: str | None = None
profile_snapshot: dict[str, Any] | None = None
varietal: str | None = None
process: str | None = None
altitude_masl: int | None = Field(default=None, ge=0)
3 changes: 3 additions & 0 deletions src/brew/bags/model/api/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ class BagAPIResponse(BaseModel):
finished_at: datetime | None
profile_id: str | None
profile_snapshot: dict[str, Any]
varietal: str | None
process: str | None
altitude_masl: int | None
9 changes: 9 additions & 0 deletions src/brew/bags/model/bag.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class Bag:
finished_at: datetime | None
profile_id: str | None
profile_snapshot: dict[str, Any]
varietal: str | None = None
process: str | None = None
altitude_masl: int | None = None


@dataclass(frozen=True)
Expand All @@ -36,6 +39,9 @@ class BagCreate:
profile_snapshot: dict[str, Any]
roast_date: date | None = None
profile_id: str | None = None
varietal: str | None = None
process: str | None = None
altitude_masl: int | None = None


@dataclass(frozen=True)
Expand All @@ -47,3 +53,6 @@ class BagUpdate:
roast_level: str | None = None
profile_id: str | None = None
profile_snapshot: dict[str, Any] | None = None
varietal: str | None = None
process: str | None = None
altitude_masl: int | None = None
59 changes: 34 additions & 25 deletions src/brew/bags/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,36 @@ def _row_to_bag(row: aiosqlite.Row) -> Bag:
finished_at=_parse_datetime(row["finished_at"]) if row["finished_at"] else None,
profile_id=row["profile_id"],
profile_snapshot=json.loads(row["profile_snapshot"]),
varietal=row["varietal"],
process=row["process"],
altitude_masl=row["altitude_masl"],
)


def _build_update_clauses(update: BagUpdate) -> tuple[list[str], list[object]]:
# column -> value; None values are skipped. Keeps one flat builder (no per-group
# helper) while staying under the complexity cap.
columns: list[tuple[str, object | None]] = [
("name", update.name),
("origin", update.origin),
("roaster", update.roaster),
("roast_date", update.roast_date.isoformat() if update.roast_date else None),
("roast_level", update.roast_level),
("profile_id", update.profile_id),
("profile_snapshot", json.dumps(update.profile_snapshot) if update.profile_snapshot is not None else None),
("varietal", update.varietal),
("process", update.process),
("altitude_masl", update.altitude_masl),
]
sets: list[str] = []
params: list[object] = []
for column, value in columns:
if value is not None:
sets.append(f"{column} = ?")
params.append(value)
return sets, params


class BagSqliteRepository:
def __init__(self, conn: aiosqlite.Connection) -> None:
self._conn = conn
Expand All @@ -70,8 +97,9 @@ async def create(self, create: BagCreate) -> Bag:
INSERT INTO bags (
id, name, origin, roaster, roast_date, roast_level,
initial_grams, remaining_grams, is_active, opened_at,
finished_at, profile_id, profile_snapshot
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?)
finished_at, profile_id, profile_snapshot,
varietal, process, altitude_masl
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?, ?, ?)
""",
(
bag_id,
Expand All @@ -85,6 +113,9 @@ async def create(self, create: BagCreate) -> Bag:
opened_at,
create.profile_id,
json.dumps(create.profile_snapshot),
create.varietal,
create.process,
create.altitude_masl,
),
)
await self._conn.commit()
Expand Down Expand Up @@ -142,29 +173,7 @@ async def list(
return [_row_to_bag(row) for row in rows]

async def update(self, bag_id: str, update: BagUpdate) -> bool:
sets: list[str] = []
params: list[object] = []
if update.name is not None:
sets.append("name = ?")
params.append(update.name)
if update.origin is not None:
sets.append("origin = ?")
params.append(update.origin)
if update.roaster is not None:
sets.append("roaster = ?")
params.append(update.roaster)
if update.roast_date is not None:
sets.append("roast_date = ?")
params.append(update.roast_date.isoformat())
if update.roast_level is not None:
sets.append("roast_level = ?")
params.append(update.roast_level)
if update.profile_id is not None:
sets.append("profile_id = ?")
params.append(update.profile_id)
if update.profile_snapshot is not None:
sets.append("profile_snapshot = ?")
params.append(json.dumps(update.profile_snapshot))
sets, params = _build_update_clauses(update)

if not sets:
return await self.get(bag_id) is not None
Expand Down
5 changes: 4 additions & 1 deletion src/brew/bags/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
opened_at TEXT NOT NULL,
finished_at TEXT,
profile_id TEXT,
profile_snapshot TEXT NOT NULL
profile_snapshot TEXT NOT NULL,
varietal TEXT,
process TEXT,
altitude_masl INTEGER
)
""",
]
15 changes: 15 additions & 0 deletions src/brew/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,18 @@ async def init_db(conn: aiosqlite.Connection, schemas: list[list[str]]) -> None:
for ddl in schema:
await conn.execute(ddl)
await conn.commit()


async def add_missing_columns(conn: aiosqlite.Connection, table: str, columns: dict[str, str]) -> None:
"""Additively ALTER in any columns not already present on `table`.

SQLite has no `ADD COLUMN IF NOT EXISTS`, so we diff against PRAGMA table_info.
`columns` maps column name -> the DDL type/constraint fragment, e.g.
{"varietal": "TEXT", "acidity": "INTEGER CHECK (acidity IS NULL OR acidity BETWEEN -2 AND 2)"}.
"""
cursor = await conn.execute(f"PRAGMA table_info({table})")
existing = {row["name"] for row in await cursor.fetchall()}
for name, ddl in columns.items():
if name not in existing:
await conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
await conn.commit()
6 changes: 6 additions & 0 deletions src/brew/journal/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from brew.journal.palate import PalateQuery
from brew.journal.service import JournalService


def get_journal_service() -> JournalService:
msg = "Must be overridden — wired in app lifespan"
raise NotImplementedError(msg)


def get_palate_query() -> PalateQuery:
msg = "Must be overridden — wired in app lifespan"
raise NotImplementedError(msg)
Loading
Loading