Files
petal/BUILD_PLAN.md
prosolis 660f00d452 Phase 7: browser-side spell check (nspell, en-US)
Vendored Hunspell en.aff/en.dic into web/public/dictionaries/en/ (served as a
static asset + embedded in the binary, kept out of the JS bundle). dictionary-en
moved to a devDep — only used to source the files.

- useSpellChecker (App-level, loads once/session): fetches the dict, builds an
  nspell instance, replays a localStorage personal word list; addWord persists
  and bumps a version so consumers re-decorate. Ambient types in
  src/types/nspell.d.ts (the package ships none).
- SpellCheck Tiptap extension: misspellings as ProseMirror decorations (no
  stored marks), recomputed on edit / caret move / checker swap. Latin-only
  tokenizer so CJK is never flagged; skips short tokens + all-caps acronyms;
  exempts the caret word to avoid mid-typing jitter. Reuses mapOffset (now
  exported from SuggestionHighlight); wordAt resolves the exact span on click.
- MisspellCard: soft rose wavy underline, bilingual popover with up to 5 nspell
  corrections (click to replace) + add-to-dictionary. Closes on outside-pointer,
  edit, or doc switch.

Chinese spell check intentionally omitted — nspell is dictionary-based and
English-only; Chinese typos (homophone 别字) need an LLM, out of v1 scope.

tsc/vite/go build+vet clean; live server serves both dict files; nspell
behavior smoke-tested.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 22:18:21 -07:00

93 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Petal — Build Plan & Progress
Multi-session build. **Source of truth for what's done and what's next.** Update the checkboxes as work completes. `petal-spec.md` is the design spec; this file tracks execution.
## Decisions locked in (see also memory: petal-design-north-star)
- **Auth deferred** — no Authentik yet. Seed a single hardcoded local user (`id = "local"`); keep the `user_id` column so auth drops in later without a schema migration.
- **Copyleaks deferred** — needs a public webhook; skip Tier-2 plagiarism until there's a public endpoint. Tier-1 voice-consistency (local) is in scope.
- **Traefik/deploy deferred** — local dev first.
- **LLM**: Qwen 3.5 (256K context) on 64GB dual-GPU. Grammar checkpoint cap ~10K tokens (latency guard); voice pass sends whole document.
- **Reasoning models**: the Ollama client sends `"think": false` on every request. Qwen 3.5 is a reasoning model — left on, it streams chain-of-thought into a separate `thinking` field and exhausts `num_predict` before emitting any answer in `content` (empty response). Non-thinking models ignore the flag. (Validated on deployment hardware 2026-06-25.)
- **Suggestion anchoring**: resolve by `original` string in ProseMirror coords at render time; stored `from_pos`/`to_pos` are plaintext offsets for server-side use only. (Spec Note #6.)
- **Aesthetic is an acceptance criterion**: pretty, warm, Chinese-woman-friendly; CJK fonts first-class.
## Phases
### Phase 0 — Foundation / scaffold ✅
- [x] `git init`, `.gitignore`, remote → gitea.parodia.dev/drwily/petal
- [x] Go module (`go.mod`), directory skeleton per spec
- [x] `internal/config` env loading (local-dev defaults; auth/copyleaks fields kept for later)
- [x] Vite + React 19 + TS + Tailwind v4 scaffold in `web/` (design tokens in `@theme`, Google fonts)
- [x] Frontend embedded via `web/embed.go` (`go:embed all:dist`) + SPA handler in `cmd/server/main.go`
- [x] Dev workflow documented in README; `.env.example` added
- [x] Verified end-to-end: binary serves `/api/health` + embedded SPA + SPA fallback
### Phase 1 — Data layer ✅
- [x] SQLite (modernc) init + migrations (`internal/db/db.go`) — versioned `schema_migrations` runner, WAL + foreign keys, single writer conn
- [x] Models: User, Document, Suggestion (`internal/db/models.go`) — + type/status constants
- [x] Seed hardcoded `local` user (idempotent on startup)
- [x] Schema includes `voice` in suggestions type CHECK (full spec schema incl. plagiarism_reports, to avoid a later migration)
- [x] `db.Open` wired into `cmd/server/main.go`; `db_test.go` covers migrate/seed idempotency, CHECK constraint, FK cascade
### Phase 2 — Document CRUD + auto-save ← first "it works" milestone ✅
- [x] Doc handlers: list/create/get/update/delete (`internal/docs/handlers.go`) — chi sub-router mounted at `/api/docs`, all scoped to `local` user, partial-update via COALESCE so rename and full save share one PUT; `handlers_test.go` covers the lifecycle
- [x] Frontend DocList sidebar (create/rename/delete) — `DocList`/`DocListItem`, optimistic title/word-count patching
- [x] Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount) + inline `Toolbar` (B/I/U, H1/H2, bullets, align)
- [x] `useAutoSave` (1.5s debounce) → PUT /api/docs/:id, with `saveNow()` flush before doc switch/create
- [x] StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s)
- [x] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save — editor emits both + word_count together
### Phase 3 — LLM grammar checkpoint ✅
- [x] `LLMClient` interface + factory (`internal/llm/client.go`) — chat-model fallback in factory; doc/history truncation helpers
- [x] `vllm.go` (OpenAI-compat), `ollama.go` (native) — both behind interface; Complete + Stream; no client-level timeout (ctx deadline for Complete, open stream for SSE)
- [x] `checkpoint.go` (30s/doc `RateLimiter`), `prompts.go` — brace-matched JSON salvage from model output, empty-original drop, type normalization
- [x] `POST /api/docs/:id/check` (+ `GET /api/docs/:id/suggestions`, `POST /api/suggestions/:id/{accept,dismiss}`) in `internal/suggestions`; replaces pending set per check, leaves accepted/rejected as history; throttled checks return current set
- [x] `useCheckpoint` (4s debounce) + breathing rose checkpoint dot in StatusBar
- [x] `SuggestionHighlight` (ProseMirror **decorations**, re-anchored by `original` string on every doc change — not stored marks) + `SuggestionCard` (accept applies replacement in-editor then PATCHes; dismiss)
- [x] Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky (honey reserved for voice)
### Phase 4 — Ask Petal (conversational follow-up) ✅
- [x] `POST /api/suggestions/:id/chat` SSE streaming; server-side context injection — `internal/suggestions/chat.go` loads the suggestion + parent doc in one user-scoped query, extracts the `\n\n`-bounded paragraph around `from_pos` (falls back to truncated doc when `from_pos == -1`), injects it via `AskPetalSystemPrompt`, streams `event: token`/`event: done` SSE frames (JSON-encoded data so token newlines don't break framing). LLM-unreachable returns a clean 502 before any SSE headers; unknown suggestion 404s.
- [x] AskPetal component, token-by-token render, no persistence — `AskPetal.tsx` holds the whole conversation in component state (cleared on close), pre-seeds Petal's first bubble with the suggestion explanation, streams via `streamSuggestionChat` (fetch + ReadableStream, not EventSource). `SuggestionCard` gains an "Ask Petal ✨" pill; the card pins open (hover-close suppressed, click-away to dismiss) while the panel is expanded.
- [x] CJK font fallbacks on chat bubbles (spec Note #17) — bubbles + input use the `'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC'` stack (the user asks in Mandarin); applied to the chat surface only, not the serif editor body.
- `internal/llm/chat.go`: `StreamAskPetal` (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop `\n\n\n`) reusing the existing `AskPetalSystemPrompt` + `TrimHistory`. Backend stays interface-only; the SSE handler never touches a concrete client.
### Phase 5 — Voice consistency pass (Tier 1) ✅
- [x] `POST /api/docs/:id/voice`, whole-document (no `TruncateDoc`), explicit "Check my voice 🍯" toolbar action — `internal/llm/voice.go` (`RunVoice`, `VoiceInterval` 20s floor, MaxTokens 2048), `voiceSystemPrompt`/`VoiceMessages` in `prompts.go` (standalone — not bundled with the grammar checkpoint per spec)
- [x] `voice` suggestion type, honey decoration — type already in schema/CSS; voice flags carry `replacement: null` → stored `""`, `SuggestionCard` hides the diff row + Accept (Dismiss only)
- [x] **Family-scoped pending sets**: grammar and voice are independent passes sharing the suggestions table. `replacePending` now scopes its DELETE by family (`pendingScope`: grammar = `type != 'voice'`, voice = `type = 'voice'`), so neither pass wipes the other's pending flags. Both `/check` and `/voice` return the **unified** pending set (grammar + voice) so the client never drops one family's highlights when the other refreshes (also fixes a latent throttle-vs-success inconsistency).
- [x] Frontend: `api.voiceDoc`, `useCheckpoint` gains `voicing`/`runVoice` (shares the run-token guard), `Toolbar` honey "Check my voice 🍯" pill (loading→"Reading…"), `StatusBar` breathing honey dot "Reading your voice…". `check`/`voice` collapsed into a shared `runPass` server-side.
- Tests: `TestVoicePassCoexists` (grammar+voice coexist, unified response, null→"" replacement, voice re-run scoped). go build/vet/test clean, tsc clean, vite build OK; live smoke vs a fake vLLM (voice anchored at 62, grammar preserved, unified list `[grammar, voice]`).
- **Known limitation (carried from Phase 3)**: `findRange` anchors within a single textblock, so a voice passage spanning a paragraph break (`\n\n`) won't decorate. Model passages usually sit within one paragraph; multi-block anchoring is deferred.
### Phase 6 — Design system & polish ✅
- [x] Full pastel tokens, Nunito + Lora + JetBrains Mono — `@theme` tokens + Google Fonts (landed in Phase 0, in use throughout)
- [x] Shape language, shadows, transitions — `--radius-*`, `--shadow-soft`, global `200ms ease` on interactive elements
- [x] Signature animations (suggestion fade-float, accept confetti, breathing checkpoint dot) — fade-float + breathing dot already live; **accept confetti** added this phase: CSS-only 4-dot burst (`petal-confetti`/`@keyframes petal-confetti`, direction via `--dx`/`--dy` inline), spawned in `EditorCore.handleAccept` at the card position, auto-cleared after 720ms
- [x] Distraction-free mode — entered on editor focus (`EditorCore` `onFocus``App.setFocusMode`), the doc-list sidebar slides left + collapses to 0 width (`.petal-sidebar`/`.petal-sidebar-hidden`, 280ms), editor canvas re-centers full-width. Restored by Escape or a pointer-down outside the centered canvas (gutters, header, status bar via `handleChromeDown` + `canvasRef` containment check)
- [x] **Companion mascot** (`web/src/components/Companion/`) — cozy corner mascot that reacts to the writing session. `useCompanion` is the library-agnostic behavior engine (cheers on accept/milestones, Mandarin-first writing tips, screen-break reminders after a long stretch, idle naps + welcome-back); `PetalCompanion` renders it + a CJK-first speech bubble (zh prominent, en subtitle — Note #17). Animation via `lottie-web/build/player/lottie_light` (offline, no eval/CDN) behind a `LottiePlayer` wrapper that **auto-crops the asset to its content bbox** (unions getBBox across 6 frames → square viewBox) so stock files with empty artboard padding fill the badge. **Selectable companions** (`companions.ts` roster): clicking the mascot opens a bilingual picker ("选个小伙伴 · Choose a companion") to switch between **瞌睡猫 Sleepy Cat** (`sleeping-cat.json`, `alwaysAsleep` → every mood maps to the sleeping loop, so she snoozes yet still mumbles tips/cheers — a deliberate gag) and **开心狗 Happy Dog** (`happy-dog.json`, awake/bouncy; naps via 😴 emoji). Choice persists in `localStorage` (`petal.companion`). Add a companion = drop a pure-vector Lottie JSON in `animations/` + append a `COMPANIONS` entry. `napping = companion.alwaysAsleep || mood === 'sleeping'` drives the sway/zzz. Each asset is auto-cropped to its content bbox by `LottiePlayer`. App feeds it `editTick`/`acceptTick` + `wordCount`/`saveStatus`. All copy bilingual in `tips.ts`. (`resolveJsonModule` enabled in tsconfig for the JSON import.)
### Phase 7 — Spell check ✅
- [x] nspell browser-side (en-US), vendor dictionaries — Hunspell `en.aff`/`en.dic` (from `dictionary-en`, now a devDep) vendored into `web/public/dictionaries/en/` (+ upstream `LICENSE`); Vite copies them to `dist/`, the Go binary embeds them. ~550KB `.dic` stays out of the JS bundle, fetched as a static asset.
- [x] `useSpellChecker` hook (App-level, loads **once per session** not per doc) — `fetch`es aff+dic, builds an `nspell` instance, replays a personal word list from `localStorage` (`petal.spell.personal`); `addWord` persists + bumps a `version` so the checker's identity changes and consumers re-decorate. Exposes a minimal `SpellChecker` ({ `correct`, `suggest` }). Ambient types in `src/types/nspell.d.ts` (package ships none).
- [x] `SpellCheck` Tiptap extension — ProseMirror **decorations** (no stored marks, same as the AI-suggestion layer), recomputed on doc edit / caret move / checker swap. English-only tokenizer (`/[A-Za-z][A-Za-z']*/`) so **CJK is never tokenized → never flagged** (north-star: the user writes Mandarin + English); skips <2-char tokens and all-caps acronyms, trims edge apostrophes. Exempts the word under the caret (no jitter mid-typing). Reuses `mapOffset` (now exported from `SuggestionHighlight`) for atom-aware offset→PM-pos mapping. `wordAt(doc, pos)` resolves the exact span under a click (robust to duplicate misspellings).
- [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords``wordAt` opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via `insertContentAt`) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.
- Verified: tsc clean, vite build OK (dict in `dist/dictionaries/en/`), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (`helllo→hello`, `recieve→receive`, `写作` untokenized, `NASA` ok, `add()` persists).
### Deferred (post-v1-local)
- [ ] Authentik OIDC auth + session middleware
- [ ] Copyleaks Tier-2 + webhook HMAC
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
## Session log
- 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
- 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user.
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.
- 2026-06-25: **Phase 2 complete.** Backend `internal/docs`: chi sub-router (list/create/get/update/delete) mounted at `/api/docs`, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors; `handlers_test.go` walks the full lifecycle. Frontend: `api/client.ts`, `useAutoSave` (1.5s debounce + `saveNow` flush), `EditorCore` (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + `Toolbar`, `DocList`/`DocListItem`, `StatusBar`, rewritten `App.tsx` orchestrating load/select/create/delete with optimistic sidebar patching. `.petal-prose` styles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: **Phase 3 (LLM grammar checkpoint).**
- 2026-06-25: **Phase 3 complete.** Backend `internal/llm`: `LLMClient` interface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream), `prompts.go` (checkpoint + Ask Petal templates), `checkpoint.go` (brace-matched JSON salvage, per-doc 30s `RateLimiter`, doc/history truncation). `internal/suggestions`: `/api/docs/:id/check` + `:id/suggestions` + `/api/suggestions/:id/{accept,dismiss}`; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located by `strings.Index` (advisory only). Frontend: `useCheckpoint` (4s debounce, loads existing on doc open, run-token guards stale responses), `SuggestionHighlight` Tiptap extension rendering ProseMirror **decorations** re-anchored by `original` string on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms), `SuggestionCard` (type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified: `I has`→0:5, `two apple`→6:15) and 502 path when LLM unreachable. Next: **Phase 4 (Ask Petal SSE chat).**
- 2026-06-25: **Phase 5 complete.** Tier-1 voice-consistency pass. Backend: `internal/llm/voice.go` (`RunVoice` — whole document, no `TruncateDoc`, MaxTokens 2048, `VoiceInterval` 20s per-doc floor), standalone `voiceSystemPrompt`/`VoiceMessages` (not bundled with the grammar checkpoint). `internal/suggestions`: `POST /api/docs/:id/voice` route; `check`/`voice` collapsed into a shared `runPass(limiter, pass, scope)`; `pendingScope` makes `replacePending` family-aware (grammar deletes `type != 'voice'`, voice deletes `type = 'voice'`), so the two passes never clobber each other's pending flags; both endpoints now return the **unified** pending set (also fixed a latent throttle-returns-full-set vs success-returns-batch inconsistency). Frontend: `api.voiceDoc`, `useCheckpoint``voicing`/`runVoice` (shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill in `Toolbar` (→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" in `StatusBar`. Voice flags' `replacement: null` round-trips to `""`; `SuggestionCard` already hides the diff row + Accept for those. Tests: `TestVoicePassCoexists` (coexistence both directions, unified response, null→"" replacement). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM: grammar check → grammar flag; voice pass → unified `[grammar@0, voice@62 (empty replacement)]`, grammar preserved. Known limitation: `findRange` is single-textblock, so a voice passage crossing a `\n\n` paragraph break won't decorate (deferred). Next: **Phase 6 (design system & polish).**
- 2026-06-25: **Companion kitten added** (Phase 6 extra, per user request). A cozy corner mascot that gives feedback and gentle nudges. `web/src/components/Companion/`: `useCompanion` (behavior engine — cheer on accept/word-count milestones, Mandarin-first writing tips on a paced timer, screen-break reminder after ~25min continuous writing, idle nap after ~75s + welcome-back; priority/cooldown so it never nags), `tips.ts` (all copy bilingual, zh-first), `LottiePlayer` (wraps `lottie-web` **light** build — offline, no eval/CDN fetch, so it bundles into the Go binary), `PetalCompanion` (kitten + CJK-first speech bubble). **Ships working today with an emoji-kitten placeholder** (😺/😻/😴 per mood, CSS bob/nap/zzz); dropping a Lottie cat JSON into `animations/index.ts` is the only change to upgrade to real animation. Library decision: Lottie via `lottie-web` (not the React wrapper → no React 19 peer-dep friction; not dotLottie → no runtime CDN/wasm, stays offline-embeddable). App wires `editTick`/`acceptTick`/`wordCount`/`saveStatus`. tsc clean, vite build OK (light build trimmed ~34KB gzip vs full + removed eval warning), go build/vet/test clean. Verified headless: greeting bubble on load (“嗨~我在这儿陪你写作哦” + EN subtitle), heart-eyes celebrate + “我很喜欢这个改法 💕” on accept. **TODO (user):** source a Lottie cat asset to replace the emoji placeholder.
- 2026-06-25: **Phase 6 complete.** Design system & polish. Tokens/fonts/shape/transitions were already in place from Phase 0; this phase added the two missing signature pieces. **Accept confetti**: CSS-only burst (`.petal-confetti-dot` + `@keyframes petal-confetti`, each dot's trajectory from inline `--dx`/`--dy`), a `Confetti` component in `EditorCore` spawned at the accepted card's position on `handleAccept` and cleared after 720ms (timer cleaned up on unmount). **Distraction-free mode**: `EditorCore` gains an `onFocus``App` `focusMode` state; the doc-list sidebar is wrapped in `.petal-sidebar` and collapses via `.petal-sidebar-hidden` (width→0 + translateX + fade, 280ms) while the centered editor canvas re-centers into the full pane; restored by Escape (window keydown) or a pointer-down outside the canvas (`handleChromeDown` checks `canvasRef` containment; wired on the header, the editor scroll-gutter, and the status bar). tsc clean, vite build OK, go build/vet/test clean; binary boots and serves the rebuilt SPA with the new CSS embedded (confetti + sidebar-collapse classes verified in the served bundle). Next: **Phase 7 (browser-side spell check, nspell en-US).**
- 2026-06-25: **Phase 7 complete.** Browser-side spell check (nspell, en-US). Vendored Hunspell `en.aff`/`en.dic``web/public/dictionaries/en/` (`dictionary-en` moved to devDep; dict served as a static asset + embedded in the binary, kept out of the JS bundle). `useSpellChecker` (App-level, loads once/session) builds the nspell instance, replays a `localStorage` personal word list, `addWord` persists + bumps a version to re-decorate; `src/types/nspell.d.ts` supplies the missing types. `SpellCheck` extension renders misspellings as ProseMirror decorations (Latin-only tokenizer ⇒ CJK never flagged; skips short tokens/acronyms; exempts the caret word; reuses exported `mapOffset`; `wordAt` for click→span). `MisspellCard`: rose wavy underline, bilingual card with correction pills + add-to-dictionary. tsc/vite/go all clean; live server serves both dict files; nspell behavior smoke-tested. **All v1 phases (07) done.** Remaining work is the deferred post-v1 bucket (auth, Copyleaks, deploy). Next: per user — Chinese spell check is out of scope for nspell (en-only); see discussion.
- 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, reuses `AskPetalSystemPrompt`/`TrimHistory`), `internal/suggestions/chat.go` (`POST /api/suggestions/:id/chat` — one user-scoped join loads the suggestion + parent `content_text`, `surroundingParagraph` extracts the `\n\n`-bounded paragraph at `from_pos` with whole-doc fallback, streams `event: token`/`event: done` SSE frames with JSON-encoded data, `X-Accel-Buffering: no`, real `http.Flusher` per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: `streamSuggestionChat` (fetch + ReadableStream SSE parser, abortable), `AskPetal.tsx` (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), `SuggestionCard` "Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: `chat_test.go` (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, `surroundingParagraph` unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, `done` terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: **Phase 5 (voice consistency pass, Tier 1).**