#The intelligence layer (steps 10–12)
Two rules shape all of it. The model labels; it never predicts — cadence is medians over observed intervals, computed on the client, and crossing that line would give different answers on Tuesday than on Monday. It is an accelerant, never a dependency — every failure degrades to pure statistics and manual entry, and the client treats a null answer as ordinary.
#The proxy (functions/)
Nine callable Cloud Functions on Node 22 — resolveEntity, classifyStaple,
captureTasks, suggestSeasonal, readReceipt, readPantry,
transcribePhoto, assistantChat and reviewPass.
transcribePhoto is the one call that reaches into Storage itself: the
client names a photo (hid/noteId/photoId, validated), the function
checks the caller's household membership against the household document with
the Admin SDK, downloads the object, and reads it — the bytes never ride the
request, which keeps the payload phone-sized and the bucket free of CORS
configuration. assistantChat is the one
call that carries prior turns: it runs Sonnet (claude-sonnet-5) rather than
the shared Opus constant, may run up to three server-side web searches
(web_search_20260209), and answers with an action schema that can add, or
annotate a to-do it was shown, and nothing else. When the request names the
open note's photos it fetches up to three of them from Storage exactly as
transcription does — membership proven first, bytes never on the request —
and attaches them as images; a photo that fails to download is skipped,
because this layer degrades before it errors. When the request names its
household (hid, validated and membership-proven like the photos), the turn
also gets a read_note client tool: the model asks for a note by the title
the snapshot showed, the function fetches the household's notes once (lazily,
archived excluded, and cut to the look-back the request carries as
notesSince — notesWithin in noteLookup.ts, reading a note with no
updatedAt as edited when it was made, the client reader's own default, so
the tool can find every title the snapshot showed and no note the snapshot
withheld; a client from before the field sends nothing and gets no window),
matches by folded-title equality
(functions/src/noteLookup.ts — the fold is foldForSearch's twin, and it
is equality, never the search box), and hands back title and body cut at
8000 characters (READ_NOTE_CHARS), the same cap the open note lives under,
ending in a "[cut here — N more characters …]" line when the note was longer
(cutNoteText, the twin of the client's cutNote), so the model can say how
much it is missing and knows not to offer a rewrite. At most eight notes
per turn (READ_NOTES_CAP); past the cap the tool answers with a refusal the
model can read. Client tools loop inside askStream — the model's tool-use
stop grows the transcript with its results and the SAME turn continues —
bounded at four rounds for the assistant: the prompt asks for batched reads,
so two a round meets the budget, and the rounds do not scale with it because
each is a model call paid in the person's seconds. The budget's end is told to the model, not thrown
at the person: a stop past the cap gets "budget exhausted" as its results
and one closing round to answer from what it has, so only a model that
stops for tools even then fails the turn; server tools still never loop
(pause_turn remains a failure by design). A request without hid (a client from before
the field existed) simply gets no tool and runs as before. It is
also the one STREAMING callable, and the one whose answer is PROSE. Every
other call constrains its output to a JSON schema; the assistant's reply is
ordinary text, and its actions arrive through a strict client tool,
take_actions, whose input is the old answer's actions array
(ASSISTANT_ACTIONS_SCHEMA) validated by the API on the way in. It was one
schema-constrained object with reply first, streamed by an extractor that
read the reply's characters out of the forming JSON — and free prose
generated inside a grammar mask was garbling inside words. See DECISIONS,
"The reply is prose; the actions are a tool call". take_actions is
unbudgeted (ClientTools.unbudgeted): serviced in any round, never counted,
and text written around it is one reply, whereas a read_note starts a new
round whose text replaces the last — the reply is the last round that said
anything (collectReply, functions/src/reply.ts), which is also what each
streamed SNAPSHOT is (sendChunk, only when the caller asked to stream;
whole-text each time, never deltas — idempotent chunks a flaky network can
duplicate or replay without scrambling the display). Beside the reply the
stream carries an ACTIVITY line for everything the model does — a search,
named once its query has finished streaming; a note read, or asked for and
not found; its actions arriving; the read budget closing — an error chunk
naming the failure in the client's own vocabulary before the callable
throws, and a done chunk with the stop reason and counts (the union is in
index.ts, mirrored in src/data/intelligence.ts). A reply with no letter
or digit in it and no actions is refused as empty rather than stored — ":"
was once persisted as an answer. max_tokens on prose is a warning, not a
failure: text cut short is still text, and the drawer says it was cut short.
The client replaces its preview bubble with each snapshot, keeps the
activity lines as a log for the turn, guards both with a per-send turn
counter AND a per-turn settled flag (so neither a newer turn's stream nor
this turn's own stream past its deadline can write into the bubble), and
gives up at 105s (over the function's 90s ceiling), aborting the stream it
was reading. The callable's final value is {reply, actions} as it always
was.
The Anthropic key lives in Secret Manager and is bound per function; it is never
in the repository or in client config. Calls run at write time and are cached on
the document that needed them — nothing calls the model while a list renders.
enforceAppCheck: true is not optional here. The platform rejects an unattested
call before any of this code runs, so an unauthenticated request costs nothing;
without it, anyone who reads the client bundle has a free inference endpoint in
front of a billable key.
Attestation bounds who may call; a daily budget bounds how much. Every
callable, after validating its request and before fetching or asking
anything, spends units against usage/{uid} through charge
(functions/src/budget.ts): a Firestore transaction, so two calls landing at
once cannot both read the same balance and both pass. The price list is
relative, not a tariff — a labelling pass is one unit, vision three, a chat
turn two, the review pass twenty-five — against DAILY_UNITS (300) per person
per UTC day, and under that MONTHLY_UNITS (3000) per UTC month, ten full
days' worth: the day fits a heavy day of typing, photos and chat several times
over and lets the dearest call run a dozen times, not a thousand; the month
stops a phone that hits the daily cap every day. applyCharge checks the day
first, so the message names the month only when today is untouched. Settings
shows both as meters (UsageMeters, fed by useUsage through the free
usageReport callable): a percentage and when each allowance comes back, from
meterPercent and describeReset in src/lib/usage/meter.ts. Absent, not
empty, while the layer is off or has not answered. Exhaustion
is resource-exhausted with {reason: 'budget'} in the error's details —
the same code a rate limit carries, because the platform's set is closed. The
labelling seams read it as null like every other failure and the app carries
on with statistics and manual entry; the assistant drawer, where a person is
watching, reads the reason (failureCodeOf, src/lib/chat/failure.ts) and
says the allowance is used up and when it comes back, rather than "busy, try
again in a minute". The decision (applyCharge) is pure and unit-tested; the
document is server-only by rule.
Responses are constrained with output_config.format, so the caller receives
validated JSON rather than prose it has to parse. A safety decline arrives as
stop_reason: "refusal" and is handled as an ordinary outcome.
#Entity resolution (spec 7a)
Getting this wrong is the silent killer: history fragments across six spellings of one item, nothing accumulates enough intervals to predict anything, and the cadence engine looks broken when the real failure is upstream.
src/lib/resolve/ is pure and runs first, cheapest layer to dearest:
- the raw string has resolved somewhere before,
- it normalizes to an existing
canonicalName, - trigram similarity at or above
AUTO_MERGE_SIMILARITY(0.82), - otherwise the model, with a shortlist.
Substring containment never auto-merges, at any score. "milk" is a substring of "2% milk", so every string metric scores that pair high — and scores "almond milk" identically high. One is more detail about the same thing, the other is a separate consumption stream. A test asserts the metric genuinely cannot separate them, which is the evidence for the rule.
The shortlist also includes every candidate sharing a head noun, even below the similarity floor. "whole milk" scores only 0.42 against "2% milk", and its existence is the evidence that this household distinguishes milk variants — which is what makes 2% a third staple rather than more detail about the first. A shortlist filtered by similarity alone would hide exactly that.
Only confidence: high merges without asking, and only onto a staple that was
on the shortlist. Resolution is fire-and-forget: an item is written the instant
it is typed with stapleId: null, and an unresolved item is a fully functional
list item — which is also what happens offline, the normal case in a shop.
Every raw string that resolves onto a staple is appended to sourceNames, and
every purchase carries its own sourceName, so a bad merge can be reconstructed
and undone.
#Classification (spec 7b)
Written once onto staple.prior, then left alone — re-classifying later would
move predictions for reasons the user cannot see. The cadence engine already
blends it with decreasing weight and drops it entirely at eight intervals.
The classifier's quantity test is not "can you buy this in bulk" but does buying
N multiply the time until you need more — which is why AA batteries are never
despite being countable and non-perishable, and toilet paper is prompt.
Fields the user has pinned by hand are never overwritten. applyClassification
skips any field carrying an overriddenBy marker.
#Availability
INTELLIGENCE_AVAILABLE gates every call on an App Check site key being
configured. Without one, the functions reject every request, so calling them
would be one guaranteed-failing round trip per item typed. Not configured means
not available, and not available is a supported state.