Claire's writing — 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary
words and an image — now belongs to her account rather than to the pre-auth
'local' user, and the VPS is canonical. millenia was left running and
untouched as a frozen fallback; it diverges the moment either side is
written to, so it wants retiring rather than syncing.
The plan's stated prerequisite, that she log in once so her subject exists,
turned out to be false. Authentik's hashed_user_id sub is the user's uid,
derived from her id and the instance secret, so it can be read in advance —
which means the data moves first and she signs in to find her writing
already there, instead of to an empty Petal that fills in later.
The fix here is to the liveness guard, and it is the second attempt at it.
PRAGMA locking_mode = EXCLUSIVE goes on holding its lock after being set
back to NORMAL — SQLite only lets go on that connection's next database
access — so against a real WAL database the script locked itself out of its
own VACUUM INTO backup. It passed locally because the test database had
come out of VACUUM INTO and so was never in WAL mode: the fixture didn't
look like production, the same way the stub identity provider's slashless
issuer didn't. The probe now runs on its own connection and closes it.
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
The destination is an OIDC subject id, which the app cannot know — it
belongs to the identity provider — so this runs deliberately, with Petal
stopped and a backup taken, rather than as a startup migration. documents,
tags, vocab_words and images carry user_id directly; versions, suggestions
and tag assignments hang off their parents and follow, which is why it has
to be one transaction with foreign keys off. Sessions for the old identity
are deleted rather than moved: a session is proof someone signed in, and
nobody ever signed in as 'local'.
Dry run by default, VACUUM INTO backup first, and it verifies every row it
expected to move actually moved — and that the source is left owning
nothing — before committing.
The 'is the app stopped?' guard took two attempts. BEGIN EXCLUSIVE, the
obvious check, sails past a running-but-idle Petal because in WAL mode it
only conflicts with another writer, which is precisely the case worth
catching. PRAGMA locking_mode = EXCLUSIVE conflicts with any connection at
all, since it locks the shared-memory index every WAL reader maps.
Sequencing this also turned up a crash waiting to happen: the image
backfill claims unowned files for 'local', which no longer exists after a
migration, and the resulting foreign-key error is fatal inside images.New.
Petal would have crash-looped the first time it started on a migrated
database. It now skips a missing owner, which costs nothing — the
migration moves the image rows itself.
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
The gate existed because Petal authenticated nobody and a public hostname
was therefore a public, writable API. It no longer is: every /api route
answers 401 without a session, so the only thing an anonymous visitor
reaches is the app shell and its redirect to Authentik. The separate
unauthenticated /api/health router goes with it — it only existed to escape
the middleware. A second password in front of a real login is one more
thing to lose.
Also records the two problems this deployment actually hit, since both fail
before the login page appears and neither is obvious from the error: the
issuer's trailing slash is significant, and a provider created through the
API rather than the admin UI comes up with an empty grant_types.
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
Deploying against the real Authentik turned this up immediately: its issuer
ends in a slash, OIDC requires the discovered issuer to match the configured
one byte-for-byte, and trimming it made discovery fail every time. The stub
in the tests happened to advertise a slashless issuer, so the whole suite
passed while the only provider Petal actually talks to could not be reached.
The stub now takes its issuer as a knob, and a regression test runs the flow
against one that ends in a slash.
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
Petal is now an OIDC client in its own right rather than trusting a header
from the proxy. The Phase-0 Resolver seam was the only integration point:
main.go picks the session store when Authentik is configured and the static
local user otherwise, and no handler or query moved for either.
internal/auth gains three pieces. session.go issues an opaque cookie token
and stores only its SHA-256, so a database copy yields nothing usable; the
30-day expiry slides on every request, throttled to one write an hour, and
logout deletes the row rather than just the cookie. oidc.go runs the
authorization-code flow with state, nonce and PKCE, and discovers the
provider lazily and on retry — an Authentik outage should block new logins
without stopping Petal booting or invalidating live sessions. users.go
provisions accounts from the token's claims and gates them on an allowlist
that matches emails as well as subject ids, since a subject is an opaque
uuid that doesn't exist until someone has already logged in once.
Migration 0010 lands sessions, images and users.pair_lang together. The
images table closes the capability-URL hole the Phase-0 audit flagged: a
hash was previously enough to fetch anyone's picture. Rows are keyed
(name, user_id) so one file can have several owners and deduplication
survives; a stranger gets 404 rather than 403, the cache header drops to
private, and files already on disk are claimed at startup or every image
already pasted into a document would 404.
On the frontend a single 401 interceptor feeds a warm bilingual sign-in
overlay, drawn over a still-visible editor because nothing has been taken
away. Behind it is the part that matters: a save that comes back 401
stashes its body to localStorage before anything else and stops the
auto-save loop, and reopening that document after signing in merges the
draft back and saves it. An expired session must not cost writing.
Writing the round-trip test against a stub identity provider turned up a
real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer,
which runs after the redirect has written the response header, so the
clearing Set-Cookie was silently dropped and they lingered for their full
ten minutes.
Also swaps the emoji favicon for a drawn sakura, which renders as Petal's
own rose palette everywhere instead of whatever each platform's font
decides, and doubles as the app tile in Authentik.
Migration 0010 verified against a VACUUM INTO copy of the live millenia
database: counts intact, FTS still matching, the one existing image
claimed.
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
The canonical instance -- the one with her actual writing -- turned out
to be the least protected thing in the estate:
- No scheduled backup at all; the newest snapshot was a month old. Now
petal-backup.timer: VACUUM INTO, gzip, age-encrypt with the parodia
public recipient, push to the VPS over headscale with a size check,
prune both ends. Persistent=true because the box is not on 24/7.
Neither machine can decrypt what it holds; the identity is offline.
- Petal ran as a bare ./petal with PPID 1, so a crash or reboot left it
down until somebody noticed. Now petal.service, verified by kill -9.
- The Piper units retried forever without ever failing: RestartSec=3
against systemd's default 10s window means the burst limit is never
reached, which is how a dead service logged 26,800+ restarts over a
day while read-aloud silently fell back to browser speech.
StartLimitIntervalSec=300 makes a broken Piper show up in --failed.
backup-petal.sh now handles both deployment shapes (compose exec on the
VPS, local binary on millenia) and encrypts before anything leaves the
host. The VPS no longer uses it -- Petal rides parodia-backup there.
Petal's data directory (petal.db, uploaded images, and the TTS cache --
which is synthesized audio of her sentences) now lives on a LUKS volume.
LUKS-on-a-file rather than gocryptfs because Petal is SQLite in WAL mode:
WAL needs a shared-memory index mapped consistently across processes, and
FUSE has a long history of subtle mmap/locking differences. A block
device with ext4 behaves exactly like a disk to SQLite.
The key sits on the same host, which is a deliberate availability
tradeoff and is documented as such: it stops a decommissioned disk or a
raw block-device read, not anyone holding the whole VM image.
Two bugs found by rehearsing a reboot rather than trusting the setup:
- Mounting over a directory HIDES its contents, it does not remove them.
The first run left the original plaintext petal.db and WAL sitting on
the unencrypted root filesystem, invisible under the mount -- exactly
what the exercise was meant to eliminate. Now shredded before the
mount, with a refusal if the mountpoint will not come up empty.
- systemd-cryptsetup was not installed on the host, so /etc/crypttab was
being ignored entirely and the volume would never have unlocked at
boot. The script now refuses to run without the generator present.
The mountpoint directory exists whether or not the encrypted volume is
mounted, so a boot where the unlock failed would start Petal against an
empty unencrypted directory and serve a blank database -- the failure
mode that looks like data loss. .volume-ok lives on the encrypted
filesystem and is bind-mounted with create_host_path:false, so its
absence is a container start failure instead of a silent empty DB.
Rebinding vllm-chat was the expensive option: Petal, Gogobee and Open
WebUI all point at 127.0.0.1:8000, and Open WebUI keeps its endpoint in
its own database rather than in env, so moving the bind address meant
editing three consumers and reloading a 35B AWQ model. The socat unit
adds a second listener on the headscale address instead -- local callers
untouched, no downtime, and the only new exposure is on the VPN. Bound
to 100.64.0.2 specifically, never 0.0.0.0: the far end is a public host.
Verified: a grammar checkpoint from petal.parodia.dev returns real
suggestions in ~3s over the VPN.
Also documents two things found on millenia that were invisible from
outside it:
- Piper had been dead since the Jul 26 reboot, 26,800+ failed restarts,
with read-aloud silently falling back to browser Web Speech. An OS
upgrade moved /usr/bin/python3 from 3.13 to 3.14, and venv/bin/python3
is a symlink to the system interpreter, so lib/python3.13/site-packages
went invisible -- sys.path had no site-packages at all. Recreating the
venv lands Piper 1.6.0, which is what TTS_PATH exists for.
- The canonical instance has no automated backup (newest snapshot a
month old) and runs unsupervised with PPID 1. Both written down; the
backup one is pending the encryption-at-rest decision.
Backups on the VPS now ride parodia-backup (age-encrypted, offsite, S3),
using VACUUM INTO rather than that script's iterdump helper -- iterdump
does not reproduce an FTS5 virtual table, so a restore would have come
back with cross-document search silently missing.
deploy/README.md becomes the real runbook: the VPS stack, Traefik, the
headscale LLM link, the interim edge gate, backups and restore. The
millenia Piper notes move to an appendix -- that instance still runs
them, and it is still canonical.
Two items are called out as outstanding rather than done, because both
need access to millenia: vLLM is not bound to its headscale interface,
so no AI pass works from the VPS yet, and parodia's ssh key is not
authorized there, so backups are VPS-local only -- which is not a backup
in the sense that matters. Each has its one-command fix written down.
Also folds in DreamDict gaining Spanish: es was explicitly gated on that
dataset existing, so it moves from "Later / not now" to a normal
follow-on pair after pt-PT, and the Phase 20 provider seam should cover
it from the start.
Petal authenticates nobody yet -- StaticResolver hands every request the
same local user -- so on a public host the whole API is open: anyone who
finds the hostname can read and write documents and fill the disk with
image uploads. Traefik holds the door until the OIDC flow exists.
/api/health keeps its own higher-priority router with no middleware, so
the acceptance criterion (public health endpoint, reachable by the
monitoring on this box) still holds. Both the middleware and that router
are deleted when Phase 16 lands.
piper-tts 1.6.0 moved synthesis from POST / to POST /synthesize, with an
identical request body; the VPS sidecars run 1.6.0 and returned 405 to
every read-aloud request, while millenia's older server still expects /.
Rather than pinning both deployments to one Piper release, the path is
configuration -- default "/" keeps millenia working untouched, and the
compose stack sets /synthesize. The container healthcheck moves with it,
since it was probing the old route too.
The image's own petal user (uid 10001) has no claim on a bind-mounted
host directory, so SQLite came up with "unable to open database file
(14)" and the container restart-looped. Run as the stack directory's
owner instead of chowning ./data to 10001 -- the backup script gzips
snapshots in place from the host, so that account needs write access to
the same directory. Still non-root.
Deploy plumbing so Petal can run on the public VPS behind the Traefik
already on that box, with vLLM reached over headscale.
- Dockerfile: node build -> go build -> alpine runtime. CGO stays off
(modernc SQLite is pure Go), so the runtime layer exists only for
ffmpeg (read-aloud transcodes Piper's WAV) and tzdata (the companion's
bedtime nag and night mode read the local clock). Runs as uid 10001
with /data as the single writable mount.
- docker-compose.yml: Traefik labels following this host's convention
(external `traefik` network, `web-secure` entrypoint, `default` cert
resolver). Petal publishes no host port. ./data is a bind mount, not a
named volume, so the nightly backup and a restore are reachable from
the host.
- Piper runs as two sibling containers rather than host systemd units.
The plan assumed Piper was already installed on the VPS; it is not,
the host has no lingering user session to keep user units alive, and
containers keep the TTS ports on an internal network unreachable from
anywhere but Petal. One image, voice chosen per service, model cached
in a shared volume -- so the pt-PT voice is a new service, not a new
image.
- db.Backup + a `-backup` flag: VACUUM INTO, not a file copy. Petal runs
in WAL mode, so the newest committed pages may live in petal.db-wal;
copying the three files separately can capture a torn mid-checkpoint
state. VACUUM INTO reads one coherent snapshot without taking a write
lock, and emits a single file with no -wal/-shm companions. Refuses an
existing destination so a failed run can't destroy the last good
backup.
- deploy/backup-petal.sh: nightly snapshot, compress, push to millenia
over headscale with a post-transfer size check, prune both sides.
- deploy/petal.env.example: LLM_TIMEOUT raised 30s -> 90s for the
WAN+VPN round trip, since the voice and collocation passes send a
whole document and the timeout is a hard deadline on Complete.
SUGGESTIONS.md (new): the product rationale. Every user gets one
(English + X) pair, X in {zh, pt-PT, fr, maybe es} - bilingual UI in the
pair, type in either language, direction inferred without a detector
(both-dictionaries spellcheck, show-both gloss on collision). Langpacks
keyed by X. LLM-minimalism as a standing principle: the LLM never gates
essential functionality; grammar-lite rules and an embedded
miscollocation list are planned as code-first layers beneath the LLM
families.
MULTIUSER_PLAN.md: all OPEN decisions settled - in-app OIDC (the
parodia.dev VPS hosting plan decides it), 30-day sliding sessions,
allowlist, migration-by-script, image-store ownership fixed alongside
auth, DreamDict imported as a package reading dict.db (module rename
prereq lives in the dreamdict repo), zh stays on ECDICT until compared.
BUILD_PLAN.md: the deferred bucket becomes checkboxed Phases 15-22
(deploy plumbing / OIDC / local-user migration / client-state
namespacing / langpack extraction / DreamDict provider / pt-PT pair /
learning loop + code-first layers) with standing rules: isolation tests
in the same commit as any user-scoped endpoint, LLM-minimalism, and
bilingual aesthetic as acceptance criteria.
OPEN #6 assumed the pt-PT gloss was gated on finding a dataset of
ECDICT's quality. It isn't — dreamdict already covers en, fr, pt-PT and
zh, and its API maps almost 1:1 onto lexicon.Result.
Replaces the data question with an integration one (OPEN #6a): HTTP
service, build-time extraction into Petal's embedded gz format, or
importing the dictionary package and opening dict.db read-only. Argues
for the third — same CGO-free sqlite driver Petal already depends on, no
runtime service, and it deletes ~11.6 MB of embedded data plus the
ECDICT build scripts.
Adds the migration caution that matters most: the zh path is in daily
use, so wire pt-PT/fr first (nothing to regress) and leave zh on ECDICT
until CC-CEDICT gloss quality has been compared on her real lookups.
Phase 0 (identity plumbing) is done; this writes down what real
multi-user still needs and, more importantly, which decisions are
genuinely open. Six OPEN items are flagged for a reviewer to push back
on — chief among them whether Petal authenticates via Traefik
forward-auth or becomes an OIDC client itself, which everything else
depends on.
Petal ran as a single hardcoded user, with db.LocalUserID named directly
at ~35 query sites. That made the caller's identity a compile-time
constant scattered across every package — nothing a real login could
replace without touching all of them.
New internal/auth moves it into the request context:
- Middleware(Resolver) resolves the caller once per API request
- handlers read auth.UserID(r.Context()) instead of naming a user
- Resolver is the seam an Authentik session check drops into
- StaticResolver(db.LocalUserID) keeps Petal single-user today
Behavior is unchanged. UserID returns "" rather than panicking when the
middleware is absent, so a mis-wired route fails closed: every query is
WHERE user_id = ?, which then matches nothing.
main.go splits /api into a public group (/health, /version) and an
authenticated group for everything else — a monitoring probe must not
need a session.
Two pre-existing access-control gaps fixed while threading, both
harmless with one user and not with two:
- setStatus (accept/dismiss) updated a suggestion by bare id with no
ownership check at all
- listForDoc/fetchPending read a document's suggestions by doc_id
alone; a suggestion quotes the sentence it corrects, so that leaked
the source prose
Both now scope through documents.user_id.
Tests: internal/auth covers the context round-trip, the absent-context
case, and both 401 paths. Two-user isolation suites in docs and
suggestions mount the same routers twice behind two resolvers over one
database and assert a stranger gets 404 on every id-taking path, sees
nothing in list/search, and leaves the owner's data untouched.
Those suites earned their keep immediately: docs.fetch gained a userID
parameter but kept binding db.LocalUserID in the query. Unused
parameters are legal Go, so it compiled clean, vet was silent, and every
existing test passed while the lookup stayed unscoped.
Still global, out of scope and flagged in BUILD_PLAN.md: the image store
has no per-user association, and frontend localStorage keys are
per-browser rather than per-account.
Qwen3-family models reason by default and prepend a plain-text preamble
ahead of the answer — not a <think> block, so it cannot be stripped
after the fact. Every Petal pass parses a JSON object out of the
completion, so an unsuppressed preamble fails the parse outright.
Send chat_template_kwargs {"enable_thinking": false} on every request,
matching the unconditional think:false the Ollama backend already sends.
She's submitting work that gets run through an AI detector and wants to
pre-check she won't be wrongly flagged. Petal should not answer that with
a detector of its own: they misfire badly on non-native English (Stanford
2023 found >50% of TOEFL essays flagged as AI vs. near-zero for native
writers), so a percentage aimed at an ESL writer is worse than nothing —
it either scares her off her own voice or gives false comfort.
So the artifact is provenance, not a verdict. Petal already snapshots
every ~3 minutes; this turns that history into a standalone printable
report: session breakdown, word-count growth, span, active time. No score
is emitted anywhere.
Two schema additions back it. preserve_history opts a document out of the
40-snapshot prune cap — right for recovery, wrong for provenance, where
you want the whole span including the oldest rows. content_hash/prev_hash
chain each snapshot to the one before it, so a history edited or thinned
after the fact fails verification. Pruning legitimately severs links, so a
link break reports as "gaps" unless preserve_history is on; only a hash
that fails against its own contents is unconditionally "broken".
The chart's x axis is snapshot order, not wall-clock, and that is the load
-bearing decision. On a linear time axis an essay written in three
sittings across three days renders as three vertical cliffs separated by
empty space — visually identical to text pasted in three chunks, i.e. the
report would have argued the opposite of the truth. Breaks are compressed
into explicitly labelled gutters instead. TestChartGivesWidthToWriting
pins it.
The report volunteers its largest single word-count jump and states its
own limits: it cannot show who was at the keyboard, or whether typed text
was composed or copied in. Overclaiming would be self-defeating — a reader
who catches it overstating discounts all of it.
HTML rather than server-rendered PDF, as with the other exports: a CJK-safe
PDF needs an embedded Unicode font or a headless browser. Print styles are
there so the browser's Save as PDF is the handoff path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Suppression keyed on the exact original->replacement pair, which the
model routinely sidestepped: it reverses an accepted edit (reverse
pair), re-polishes its own accepted output (new original == accepted
replacement), and the editor's smart-quote churn ("..." -> '...')
defeated even a byte-exact match. Result: a few sentences got nudged
back and forth pass after pass.
Replace the exact-pair actionedKeys with a suppressor that compares
under a normalization folding all quote variants and collapsing
whitespace, and drops a fresh suggestion when it re-touches an
already-settled span: same edit re-proposed, an original the user
already accepted/dismissed, the model re-touching its own accepted
output, or a multi-word sub-clause contained in an accepted span.
Tradeoff: once a sentence is accepted/dismissed it won't be re-flagged
until its text changes — stability over marginal improvement, the right
call for the calm ESL persona.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
The checkpoint model sometimes "flags" a correct sentence and echoes it
verbatim as the replacement, producing a card whose before/after are
identical and whose explanation says it's already fine — a suggestion
that suggests nothing. ParseCheckpoint already dropped empty-original
items; also drop these no-ops. Awareness-only families (voice) carry an
empty replacement, so the guard only fires on the edit families.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Resolve the misspelled word from the click/right-click position and the
checker rather than from the .petal-misspelling DOM span: clicking a word
moves the caret into it, which fires the decoration rebuild that
deliberately un-underlines the caret word, so the span is already gone by
the time the handler runs. Factored into openMisspellAt(pos), which only
opens when the checker actually flags the word, and is gated to clicks
inside .petal-prose so a click on a floating card doesn't resolve a word
hidden behind it.
Right-click now offers spelling corrections first on a misspelled word
(the familiar "did you mean" gesture), falling back to word lookup
otherwise.
AskPetal: focus the input with preventScroll so opening the card doesn't
jump the document to the top.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Reuse the companion prose.ts rules as the single deterministic detector;
applyable rules feed 'mechanics' suggestion cards via a persist-only
endpoint, awareness rules stay companion bubbles. Mechanics wins span
collisions with LLM cards. Migration 0008 adds the type.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Reuse the companion's prose.ts rules engine as the single source of
deterministic detection instead of duplicating it. Applyable rules now
also emit exact-span fixes (original -> replacement) that surface as
suggestion cards; awareness-only rules (run-ons, splices, ...) stay
companion bubbles. The companion hides fix-bearing hints so a span is
never both a bubble and a card.
Spans are widened to a distinctive phrase ("a old" -> "an old",
"She have" -> "She has") so they re-anchor by string in the editor; a
lone lowercase "i" stays awareness-only since a single char can't anchor.
Backend: detection lives client-side, so the new persist-only
POST /docs/{id}/mechanics endpoint receives findings and stores them as
the 'mechanics' family with their exact offsets. It honours
actioned-suppression, leaves the LLM families untouched, and a checkpoint
no longer wipes it. fetchPending dedupes spans with mechanics winning any
collision against an LLM card (its span is exact). Migration 0008 adds the
'mechanics' suggestion type.
Client renders the mechanics fixes immediately (no LLM wait) and the cards
use a calm sage "Tidy-up" accent.
Verified end-to-end in a real browser on millenia: detect -> persist ->
render -> accept applies the fix.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Some people find the drifting ambient petals distracting rather than
cozy, so make the whole PetalFall layer opt-out-able from a 🌸 toggle
sitting just left of the sound toggle in the status bar.
Mirrors the existing sound-mute pattern: a tiny localStorage-backed
pub/sub (effects/petals.ts) drives a PetalsToggle twin of SoundToggle.
PetalFall subscribes to the setting, stops animating and wipes the
canvas when off, and the choice persists across reloads. Works in both
day (petals) and night (stars) modes.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Backend:
- Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/
ServerError); drop the triple-duplicated helpers in docs, suggestions,
vocab. ServerError now logs the real error and returns a generic 500 so
raw DB/internal errors never reach the client.
- vocab capture: validate doc_id ownership (blank -> none, unknown -> 400
instead of a leaked FK 500); rune-safe clamp word/gloss/definition/
phonetic/example.
- vocab review(): wrap the read-modify-write in a transaction (TOCTOU).
- /api request-size cap via MaxBytesReader middleware (2 MiB), exempting
/api/images (own 10 MiB limit).
Frontend:
- StatusBar: drive the checking/voicing/collocating indicators from one
array; llmDown uses !anyBusy.
- Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore)
on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label.
- speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount.
Tests: add doc_id-validation and field-clamp coverage; full suite green.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Correctness:
- useCheckpoint: clear the busy flag unconditionally so overlapping
explicit passes don't strand each other's spinner; explicit actions
now also supersede a queued auto-check and clear the stranded
"checking" dot. Deduped runVoice/runCollocation into runExplicitPass.
- EditorCore: token-guard the auto-capture so a late capture can't
resurrect a removed word; move toggleSaveWord side effects out of the
setWordInfo updater (StrictMode double-fire); fix sentenceAround offset
desync via shared exampleAt (textBetween + parentOffset, single resolve);
optimistic saved state so the heart doesn't flash unsaved.
- vocab capture: normalize word to lower+trim (matches lexicon) so
"Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList.
- GardenPanel: Promise.allSettled so a /due failure doesn't blank the
whole garden; scrim click during review ends the review (mirrors Esc);
gate footer on !error; O(1) due lookup via a Set.
Features requested in review:
- Definition-only review card: add vocab_words.definition (migration
0007) as an English fallback meaning, threaded through capture and used
by review/garden when there's no Chinese gloss.
- Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't
push a word out of rotation for years.
Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback,
TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
+ collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
(SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
collocating/runCollocation in useCheckpoint, StatusBar dot
Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (vocab_words, SM-2-lite
columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)),
scheduler.go (Leitner ladder 1/3/7/16/35 then geometric; gentle
"again", no streak-shaming), handlers (capture-upsert/list/due/
review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
footer; opened from a global 🌷 header button
Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Extends the BEDTIME rotation with classic Chinese proverbs on sleep. The zh
lines are faithful, natural-reading renderings of the proverbs' sense (the
verified classical 原文 isn't recoverable; the source English lives in old
collections) — swap in exact originals later if available. The "spouse" line
is kept non-gendered on purpose.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
speak()'s lang now defaults to detectLang(text) (any Han/kana -> zh-CN,
else en-US) instead of always en-US, so reading a Chinese passage picks the
Piper zh voice rather than spelling out characters with the English voice.
Explicit lang still overrides.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Companion warmth:
- ENCOURAGEMENTS grown 5->10; new BEDTIME lines (warm/playful, zh-first)
- useCompanion heartbeat gains a bedtime branch (>=11pm, while actively
writing) gated by a 30min cooldown; new 'bedtime' BubbleTone paces it
Night mode (auto at ~11pm, local clock):
- lib/night.ts centralizes isBedtime() + the window, shared with the nag
- useNightMode toggles a `petal-night` class on <html>; index.css re-points
only the palette tokens, so every Tailwind color utility flips via var()
with no component edits (600ms dusk fade; print stays white)
- PetalFall gains a `night` prop: chunky cartoon power stars (makeCartoonStar,
5 candy colors) mixed ~70/30 with twinkle sparkles; each star spins at its
own rate (~0.5-2.2 rad/s, random direction), falls straight down, shimmers
Verified: go vet/test, tsc, vitest 51/51, vite build; real-browser
screenshots/video (clock mocked to 23:30).
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
The grammar checkpoint capped num_predict at 1024, but qwen3.5:9b ignores
the prompt's "up to 5 issues" and emits ~17-20 suggestions (~2000 tokens)
on a 300+ word doc. The output hit the cap mid-array (done_reason=length),
the JSON never closed, and ParseCheckpoint found no parseable object -> a
502 in ~20s on every long doc (1024 tok @ ~50 tok/s, not a network
timeout). The repeated failures made the writing-assist helper look
permanently asleep.
Fix:
- Raise the cap to checkpointMaxTokens=4096. It is a ceiling, not a
target -- the model stops at its JSON close, so shorter docs are
unaffected; only genuinely long outputs use the headroom.
- Make ParseCheckpoint salvage the completed {...} suggestion objects
from a truncated array (refactor extractJSONObject onto a shared
firstBalancedObject scanner), so an over-long doc degrades to partial
feedback instead of a hard 502.
Verified live on millenia: 'The Missing Key' (382 words) now returns 200
with 17 suggestions in ~38s, previously 502 every time.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
A paste fires exactly one grammar checkpoint, and a failed one never retried
until the next keystroke — stranding the writer on "Petal's helper is resting"
after a paste. Long docs make it worse: their 15-25s checks have a wide window
to catch a transient 502 from the shared Ollama (co-tenant apps load other
models and evict the 9B). A failed pass also burned the per-document rate-limit
slot, so a retry within 30s hit the throttle path and got an empty set back.
- llm.RateLimiter.Release rolls back a slot when its pass fails; Allow now
returns the recorded timestamp so Release only frees its own slot.
- suggestions.runPass releases the slot on LLM failure before returning 502.
- useCheckpoint auto-retries a failed checkpoint with backoff (3/12/35s),
keeping the breathing dot up and only flagging "resting" once retries exhaust.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).
When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.
Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Replace the browser's robotic Web Speech API (espeak on Chromium) as the
primary read-aloud path with server-side Piper neural TTS, served by petal
and kept fully offline on millenia next to Ollama.
- internal/tts: proxy short passages to Piper, transcode WAV -> mp3/opus via
ffmpeg, content-addressed disk cache (instant re-taps). Each Piper instance
loads one voice, so language routes to its own endpoint:{voice}. Unknown
language -> 404 so the client falls back to Web Speech. UTF-8-safe truncation
for multibyte (Chinese) text.
- config: TTS_ENDPOINT / TTS_ENDPOINT_ZH / TTS_VOICE_EN / TTS_VOICE_ZH /
TTS_CACHE_DIR / TTS_TIMEOUT / TTS_AUDIO_FORMAT. Route mounts only when
TTS_ENDPOINT is set; otherwise unchanged behavior.
- web/audio/speech.ts: speak() hits /api/tts first, falls back to Web Speech on
any failure; rapid-tap-safe via a request token. Call sites unchanged.
- deploy/: Piper user systemd units (EN :5005, ZH :5006), setup script, README.
English (en_US-amy-medium) and Chinese (zh_CN-huayan-medium) are both live.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
- Three new Lottie companions: Wiggle Dog (摇尾狗), Butterfly (蝴蝶),
Parrot (鹦鹉). Parrot is mirrored via a new `flip` flag on Companion,
threaded through LottiePlayer (scaleX(-1)) since the asset faces left.
- Mascot size now scales with the viewport: --petal-companion-size
clamp(10rem, 17vw, 20rem); art, emoji fallback, and the sleep "z" all
derive from it.
- Larger, more legible tip bubble (1.4rem zh / 1.15rem en, wider bubble).
- Bubbles linger longer for a bilingual ESL read: baseline 9s→14s,
cheer 6s→9s, per-char 45→55ms, cap 20s→32s.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Two fixes for the "accept Petal's change, then it nags about the same
sentence moments later" report:
- replacePending now suppresses any freshly-generated suggestion whose
original->replacement matches one the user already accepted or
dismissed for that doc. The model has no memory between passes, so
without this it re-proposes the identical edit on the next checkpoint.
- findRange anchored suggestions by exact string match, which missed
whenever the model echoed an `original` with plain ASCII (straight
quotes, --, ...) while the document held the Typography-converted
glyphs (curly quotes, em-dash, single-char ellipsis). The miss meant
no highlight AND a silent no-op on accept, which then fed the re-nag
above. foldTypography canonicalizes those variants (with a source
index map for length changes) so matching survives the mismatch.
Covered by a server-side regression test (accept+dismiss then re-check
returns nothing) and frontend unit tests for the fold and anchoring.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd