Files
Hankan/server
MechaCat02 66f92247d2 feat(tutor): the real endpoint, streamed, with the system prompt cached
POST /api/tutor takes the assembled system prompt, the transcript the
client owns, and the new message, and streams tokens back. It holds
nothing between requests, so a dropped connection costs one turn rather
than the conversation.

The seam moved first: Sample took the assembled prompt as messages[0] with
role user. It now has an explicit system field, which is what lets the
backend put it in the API's system parameter as a cached block. The gate
is ~12k characters and is byte-identical for as long as the learner stays
in one unit, so every turn after the first reads the prefix at a fraction
of the input price. That is the single biggest cost lever in the design,
and it was unreachable through the old shape.

prompt/tutor-system.md still ships unchanged; only where the string is
placed changed.

SSE has three rules that are silent when broken, and all three are
handled: every event ends with a blank line, payloads are JSON-encoded
because a raw newline in Korean text would break the framing, and a `:`
heartbeat every 15s keeps intermediaries from timing the stream out.
Cache-Control is set on the returned Response rather than inside
streamSSE, which writes its own and would overwrite it; `no-transform` is
there because compression, not buffering, is what usually makes SSE look
like it hangs behind a proxy.

The client uses fetch + getReader, not EventSource — EventSource cannot
POST, and the body is {system, history, message}. Aborting closes the
connection, the server aborts upstream, and a cancelled turn stops
billing. With no server configured the app falls back to the stub, so the
offline build is untouched.

backends/anthropic.ts is the default. backends/agent-sdk.ts is deliberately
unimplemented and documents why the plain API was chosen over PORT.md's
Agent SDK — chiefly that its prompt accepts only user-role messages, so
the transcript would have to be flattened into a single turn.

The endpoint's own tests use a mock backend and need no API key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:58:01 +02:00
..
2026-09-08 19:13:53 +02:00

server/ — not in this pass

PORT.md steps 46 land here: the sync API, the tutor SSE endpoint, and the Docker Compose deployment beside the Pi's existing Postgres and Caddy.

Nothing here yet, on purpose. Steps 13 give a fully working offline app with no server at all, and that is the version that gets used first.

What is already shaped for it:

  • every syncable table carries updated_at, and seeded rows are pinned to 0 (see app/src/db/writes.ts) — the artifact's clobbering bug cannot be expressed;
  • change_seq is deliberately absent: it is server-assigned and arrives with the sync layer;
  • the dictionary is fetched by URL from app/src/domain/dictionary.ts, so per-band deltas can come from the Pi instead of the bundle by changing a base URL;
  • the tutor goes through one Sample function (app/src/domain/stub-tutor.ts). The real endpoint implements the same contract — onText receives cumulative text, and an aborted turn stops billing — so only that file changes.

When the SSE endpoint lands, Caddy needs flush_interval -1 on the proxy or the stream buffers and the tutor appears to hang.