Planet4.Me docs
Open the app

#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 notesSincenotesWithin 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:

  1. the raw string has resolved somewhere before,
  2. it normalizes to an existing canonicalName,
  3. trigram similarity at or above AUTO_MERGE_SIMILARITY (0.82),
  4. 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.