Commit Graph
81 Commits
Author SHA1 Message Date
prosolis ddc4164228 Note that the seeded 'local' row reappears after a migration
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 07:53:06 -07:00
prosolis 84ee6bfb9c Phase 17: run the migration, and fix the guard that locked out its own backup
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
2026-07-27 07:52:16 -07:00
prosolis 151df4565b Phase 17: a script to move the local user's writing onto a real account
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
2026-07-27 07:45:26 -07:00
prosolis 1b4a5f26df Phase 16 is live: record what deploying found
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 07:34:36 -07:00
prosolis e2f967c92b Deploy: drop the interim basic-auth gate now that Petal signs people in
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
2026-07-27 07:33:01 -07:00
prosolis 6d71276513 Auth: pass the OIDC issuer through untouched, trailing slash and all
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
2026-07-27 07:27:54 -07:00
prosolis 1cf207d73f Phase 16: Petal authenticates for itself
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
2026-07-27 07:21:32 -07:00
prosolis 42d857a878 millenia: nightly encrypted backup, supervision, and hardened Piper units
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.
2026-07-27 06:35:34 -07:00
prosolis 33e49ddb62 Encrypted data volume for the VPS, and two bugs the rehearsal caught
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.
2026-07-27 06:27:55 -07:00
prosolis 1d76ab1c82 Guard against Petal starting on an unmounted data volume
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.
2026-07-27 06:25:52 -07:00
prosolis 623bd02b9c Reach vLLM over headscale with a forwarder, and fix millenia's Piper
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.
2026-07-27 06:15:00 -07:00
prosolis d01a0f1f0a Document the Phase 15 deploy and ungate the Spanish pair
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.
2026-07-26 23:27:06 -07:00
prosolis 5b221cc7a3 Gate the VPS instance behind Traefik basic auth until Phase 16
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.
2026-07-26 23:21:43 -07:00
prosolis 2363ef2d37 Make Piper's synthesis path configurable (TTS_PATH)
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.
2026-07-26 23:15:01 -07:00
prosolis df6bc4989c Run the container as the host uid that owns the ./data bind mount
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.
2026-07-26 23:11:00 -07:00
prosolis 8410b6315b Phase 15: containerize Petal for the parodia.dev VPS
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.
2026-07-26 23:08:22 -07:00
prosolis dae1213c68 Ratify the language-learning direction and expand it into Phases 15-22
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.
2026-07-26 22:24:04 -07:00
prosolis 316b6b305d Plan: DreamDict answers the Portuguese dictionary question
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.
2026-07-26 21:48:51 -07:00
prosolis 023882a722 Add multi-user plan for review
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.
2026-07-26 21:44:16 -07:00
prosolis 6901cdbbe4 Multi-user groundwork: request-scoped user identity
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.
2026-07-26 21:42:37 -07:00
prosolis 61b3c6cd62 Disable thinking on the vLLM backend
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.
2026-07-26 21:25:56 -07:00
prosolis 78ed1dd281 Writing passport: evidence of process instead of an AI score
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
2026-07-19 11:45:08 -07:00
prosolis 49e84278d5 Merge feat/calm-suggestions: drop no-op suggestions + suppress fickle re-edits
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-27 00:18:47 -07:00
prosolis 9d2501a625 Suppress fickle re-edits of sentences the user already settled
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
2026-06-27 00:18:23 -07:00
prosolis 08b801e752 Drop no-op suggestions where replacement equals original
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
2026-06-27 00:18:10 -07:00
prosolis dd1dd7879b Merge feat/spelling-popover: right-click corrections + robust word resolution 2026-06-26 22:23:42 -07:00
prosolis cb8f132d43 Spelling popover: right-click corrections + robust word resolution
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
2026-06-26 20:54:47 -07:00
prosolis cea25a3ebc Merge feat/mechanics-deterministic-pass: deterministic mechanics suggestion family
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
2026-06-26 20:44:00 -07:00
prosolis 96f68a91ee Add deterministic mechanics suggestion family (rule-based, no LLM)
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
2026-06-26 20:43:37 -07:00
prosolis 3867cca2fa Merge feat/petals-toggle: status-bar toggle to disable falling petals
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 17:27:25 -07:00
prosolis 8be852ddf2 Add a status-bar toggle to disable the falling petals
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
2026-06-26 17:27:00 -07:00
prosolis 4d544f29b5 Merge feat/collocation-and-vocab-garden: code-review follow-ups
httputil extraction + error-leak fix, vocab doc_id/field/tx validation,
/api body cap, StatusBar data-driven, slide-over a11y + speech cleanup.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 17:00:44 -07:00
prosolis 8c6bc1604b Code-review follow-ups: httputil, validation caps, a11y
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
2026-06-26 16:59:23 -07:00
prosolis 4161830da6 Code-review fixes for collocation coach + vocab garden
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
2026-06-26 16:41:28 -07:00
prosolis 8aa437ec82 Phase 12 + 13: collocation coach + vocabulary garden
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
2026-06-26 15:50:25 -07:00
prosolis 20442d1356 Merge feat/bedtime-proverbs: Chinese sleep proverbs in the bedtime nag
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 15:17:50 -07:00
prosolis 375e0dde4c Companion: add three Chinese sleep proverbs to the bedtime nag
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
2026-06-26 15:17:33 -07:00
prosolis 7824b68ea1 Merge feat/night-mode-bedtime: Phase 14 bedtime nag + night mode
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 15:10:55 -07:00
prosolis 631279bd3a TTS: auto-detect Chinese vs English so callers can pass the selection
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
2026-06-26 15:10:36 -07:00
prosolis 9576340391 Phase 14: bedtime nag + night mode (dark theme + falling stars)
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
2026-06-26 15:10:36 -07:00
prosolis aebdc2679a Merge fix/checkpoint-token-truncation: stop 502s on long-doc checkpoints
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 14:16:13 -07:00
prosolis 8bd2509bc2 Checkpoint: raise output cap to 4096 + salvage truncated JSON
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
2026-06-26 14:16:07 -07:00
prosolis 0f2f753efa Merge fix/checkpoint-paste-retry: auto-retry failed checkpoints, release slot on failure
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 13:11:16 -07:00
prosolis 46db0a3e16 Checkpoint: auto-retry after a failed pass; release rate-limit slot on failure
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
2026-06-26 13:09:12 -07:00
prosolis 1bd38b20e8 Merge feat/suggestion-rail: right-margin comment rail + Mandarin explanations
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 12:23:22 -07:00
prosolis 82e2bcc777 Suggestions: right-margin comment rail + Mandarin explanations
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
2026-06-26 12:23:02 -07:00
prosolis 035dcf5d25 Merge feat/tts-piper: natural read-aloud via local Piper TTS (EN+ZH)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 11:35:07 -07:00
prosolis 5120b1e5a2 Read-aloud: natural neural voice via local Piper TTS
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
2026-06-26 11:34:52 -07:00
prosolis adc0cdff1c Add butterfly/parrot/wiggle-dog companions; scale mascot + tips
- 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
2026-06-26 11:08:55 -07:00
prosolis 9d6698dcc6 Merge fix/suggestion-renag-anchoring: stop re-nagging resolved suggestions + typographic anchoring
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 10:40:30 -07:00
prosolis 6783ce7a51 Suggestions: stop re-nagging resolved edits + fix typographic anchoring
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
2026-06-26 10:35:57 -07:00
prosolis 3ac6382696 Merge feat/writer-power-ups: editor power-ups (find/replace, read-aloud, backup, phonetic)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:47:50 -07:00
prosolis db737fa612 Editor: Find & Replace, read-aloud, backup, typography, phonetic, org niceties
Writer power-ups (Phase 11), plus the selection-bubble vs copy/paste fix.

- Find & Replace (Ctrl/Cmd+F): SearchHighlight decoration extension +
  FindReplace bar (match-case, replace-all back-to-front, scroll without
  popping the selection bubble).
- Read-aloud (Web Speech, offline) on the word card and selection bubble.
- Keyboard/touch access to the ESL helpers: Ctrl/Cmd+D look up word at caret,
  Ctrl/Cmd+J rewrite selection, touch long-press lookup. Refactored the
  right-click handler into a shared openWordLookup(pos).
- Whole-corpus backup: GET /api/docs/export-all zips every doc (md/docx),
  de-dupes filenames, dated name; sidebar download links. TestExportAll.
- Smart typography input rules (curly quotes/em-dash/ellipsis), ASCII-only so
  CJK is untouched.
- Duplicate doc, sidebar sort (Recent/Title/Longest), toolbar outline popover.
- English phonetic (chosen over pinyin for an English learner): ECDICT-built
  phonetic.json.gz (46,579 words) + Result.Phonetic + WordCard IPA line;
  scripts/build_phonetic.py (full build + --seed fallback).
- Selection bubble no longer blocks copy/paste: deferred to pointer-up and made
  click-through except on its buttons.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:47:26 -07:00
prosolis 6e6e4edce7 Editor: font-size presets + image insert/export support
Two editor features that were in flight alongside the sound work:
- FontSize TipTap extension (rides on textStyle) with Small/Normal/Large/
  Title presets in the toolbar; StatusBar + CSS support
- Image handling: internal/images handler, upload route + config, client
  API, EditorCore wiring, and md/html/docx export support for images

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:05:40 -07:00
prosolis 58c326d0cf Notification sounds: hand-picked mp3 palette + error/milestone cues
Replace the synthesized WAV blips with curated mp3 effects:
- error ("haiya") plays on LLM-down/timeout and save failures, debounced
  and on-transition-only, with a reassuring bilingual bubble
- milestone (Chinese fanfare) now fires every 100 words
- all other notifications draw from a rotating pop pool that never repeats
  the last sound, so batches of suggestions/tips stay lively

playPop() replaces the per-suggestion-type sound map; thread llmDown through
PetalCompanion -> useCompanion for the error cue. Old synth WAVs removed.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:05:05 -07:00
prosolis 045ec19b92 Petals: 2D sprite blossoms instead of shader blobs
The procedural WebGL petals read as faint smudges. Replace with a 2D
canvas that pre-renders real notched sakura petal sprites (tip→base
gradient, soft central sheen, transparent edges) and blits falling,
rotating, swaying instances. Count scales with viewport; pauses when
hidden; skipped under prefers-reduced-motion.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:47:13 -07:00
prosolis f6fa73b17b Petals: saturated blossom tints + glossy shading
The pale pastel tints washed out to grey over the cream canvas. Use
saturated cherry-blossom colors, add a luminous centre + soft base
shadow per petal, and render at full opacity so they read as colorful.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:41:53 -07:00
prosolis 8cf78130bf Merge: ambient WebGL petals + cute notification sounds
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:34:46 -07:00
prosolis 2487e73551 Ambient WebGL petals + cute notification sounds
Add a fixed, GPU-driven petal layer that drifts soft blossoms over the
page (sparse, translucent, paused when hidden, skipped under
prefers-reduced-motion). Add a synthesized cute sound palette — bubble
pop, water droplet, soft bell, sparkle, cheer — generated as embedded
WAV assets (scripts/gen_sounds.py) and played through a quiet Web Audio
bus with soft-clipping and a persisted mute toggle in the status bar.

Sounds fire per suggestion type when fresh advice arrives (staggered,
old pending advice stays silent) and on companion bubbles by tone.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:34:23 -07:00
prosolis 0a1ea225dd Merge: companion rules-based writing advice + tests 2026-06-26 07:16:45 -07:00
prosolis 69ecb79da1 Companion: rules-based, context-aware writing advice
Replace the kitten's random generic tips with a deterministic, LLM-free
prose checker that reads the actual document and surfaces one gentle,
bilingual note at a time — quoting a slice of her own sentence so the
advice is clearly about *this* draft.

Rules (all conservative, tuned for a Mandarin-native ESL writer):
run-ons, comma splices, unclear antecedents, Oxford comma, a/an,
uncountable plurals, capitalize languages/days/months, subject-verb
agreement, plural-after-number, double determiner, "there is" + plural,
its/it's, than/then, comma-after-transition, doubled words, lowercase
"i", sentence capitals, and spacing around punctuation.

Each rule is paired with false-positive guards (e.g. "a university",
"After dinner, I went home and read", existing Oxford lists) and pinned
by a 45-case Vitest suite (npm test).

Also: bubbles now linger proportional to how much there is to read
(zh+en) and pause-on-hover, so denser advice no longer vanishes mid-read.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:16:23 -07:00
prosolis f3c4fe2c22 Merge Phase 10: organization & polish (search, tags, touch, warm failures)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 06:30:21 -07:00
prosolis 9e141e4169 Phase 10: organization & polish — cross-doc search, tags, touch, warm failures
Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in
sync by triggers, back-filled from existing docs). GET /api/search uses the FTS
index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words
resolve); snippets are built in Go with rune-aware boundaries and sentinel
highlights.

Tags: user-scoped tags + document_tags join (both cascade), idempotent
create/assign, per-tag doc counts. Doc list and search carry each doc's tags
(one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten
DocList with chips + filter bar + search.

Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse-
pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion
cards.

Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual
StatusBar note (writing still saves locally).

Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update-
reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 06:29:56 -07:00
prosolis 60eba25fee Phase 9: ESL superpowers — Chinese gloss + tone-rewrite
Inline Chinese gloss (offline) and a "say it more naturally" / tone-rewrite,
the two ESL features for the Mandarin-speaking writer.

Gloss: embedded English→Chinese dictionary (gloss.json.gz, 57k common words
built from ECDICT via scripts/build_gloss.py). lexicon gains Gloss()/Result.Gloss
and a lightweight GET /api/gloss/{word}; the right-click WordCard leads with the
中文; GlossTip shows it on a 350ms hover (reuses wordAt, so CJK is never glossed).
Offline + instant, works with the LLM down.

Rewrite: selecting text pops a SelectionBubble (更自然 + the tone vocabulary);
picking a style calls POST /api/docs/:id/rewrite (llm.RunRewrite, stateless,
owner-scoped) and shows a RewritePreview (original→rewrite, accept/cancel/retry).
Accept applies it in-editor.

Tests added in lexicon and suggestions. go build/vet/test, tsc, vite all clean;
live smoke vs a fake vLLM verified gloss + rewrite + 400/404/502 paths.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 00:07:26 -07:00
prosolis 8e1111d768 Phase 8: version history + export (trust foundation)
Version history: new document_versions table (migration 0003) holding
full-body snapshots that cascade with the doc. Throttled auto-snapshots
on save (>=3min apart, max 40/doc, pruned), explicit manual restore
points, and a pre_restore safety copy taken before each restore so
restoring is itself undoable. Endpoints under /api/docs/:id/versions,
all owner-scoped. Empties and bare renames never snapshot.

Export: pure-Go Tiptap-JSON -> Markdown / HTML / plain-text / docx
(no cgo/pandoc, single-binary intact), CJK-safe with RFC 5987
filenames. docx is a hand-built OOXML zip. PDF is handled client-side
via the browser print dialog + an @media print stylesheet so CJK
renders with the reader's own fonts.

Frontend: ExportMenu (downloads + Print/PDF) and HistoryPanel
(snapshot list, preview, restore) wired into the title row; bilingual
zh-first to match chrome. Restore remounts the editor via editorEpoch.

Stop saving empty docs: blank Untitled drafts now reuse-on-create and
self-discard when navigated away from (refs avoid stale closures).

Tests: versions_test.go, export_test.go (incl. valid-zip docx).
go build/vet/test, tsc, vite build all clean; live end-to-end smoke
verified snapshot/throttle/restore/export.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 23:44:15 -07:00
prosolis cf7720ea77 Merge: editor tone, word lookup, expanded stats
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 23:23:34 -07:00
prosolis 4c288834c0 Editor: document tone, right-click word lookup, expanded stats
Four enhancements to make the editor fit real school usage:

- Per-document tone (academic/professional/casual/humorous/creative/
  persuasive/general): new documents.tone column (migration 0002), threaded
  through the docs API, a bilingual ToneSelect dropdown on the title row, and
  injected into the grammar-checkpoint LLM prompt so advice fits the register.
  The voice pass stays tone-agnostic.

- Right-click word lookup: a new offline `lexicon` package serves definitions
  (Wordset, modern ESL-friendly glosses) and synonyms (WordNet synsets first,
  then frequency+stopword-ranked Moby for breadth) from gzipped embedded data,
  behind /api/word/{word} with light morphology. The WordCard popover shows the
  definition and tappable synonym pills that swap the word in place.

- Expanded writing stats: clicking the word count opens a StatsPanel with page
  count, sentences, paragraphs, reading time, average word length, word variety,
  and Flesch-Kincaid reading level — all computed client-side.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 23:22:55 -07:00
prosolis 95123e8c49 Grow the companion, fix idle fallback, add update banner
Companion: bump the corner mascot ~12% (128→144) and make animation
resolution robust — fall back to the idle clip when a mood has no clip of
its own, and pin the always-asleep cat to one pose so its Lottie never
reloads. This stops a bare emoji from replacing the cute companion on idle.

Update notifications: serve a build id at /api/version (a hash of the
embedded dist/index.html, which changes on every frontend deploy). The
client records its load-time id, polls every 90s (and on tab focus), and
floats a Mandarin-first "new version available" banner offering a refresh
when the id changes.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 22:45:23 -07:00
prosolis a634994d25 Companion: double the mascot size + drop the sleepy-face nap
- Mascot badge 64→128px (Lottie 56→112, emoji fallback 32→64); zzz glyph
  scaled + repositioned to match. It read too small on screen.
- Awake companions (no sleeping clip, e.g. Happy Dog) no longer show a 😴
  face or drifting "z" when the engine naps them — they hold their idle pose
  via a renderMood redirect. Only the always-asleep cat (real sleep clip)
  keeps the zzz, preserving that gag.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 22:35:38 -07:00
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
prosolis 183219b5de Companion: selectable companions with a picker
Click the mascot to open a bilingual picker and switch between the Sleepy Cat
(always asleep, still talks in her sleep) and the Happy Dog (awake/bouncy).
Choice persists in localStorage.

- companions.ts is the roster; add one by dropping a pure-vector Lottie JSON
  in animations/ and appending a COMPANIONS entry.
- napping = companion.alwaysAsleep || mood === 'sleeping' drives the sway/zzz.
- Removed the old single MOOD_ANIMATIONS map (animations/index.ts).

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 22:07:48 -07:00
prosolis 97f99b27e4 Companion: wire real Lottie assets — the always-sleeping cat
- LottiePlayer now auto-crops each asset to its content bbox (union getBBox
  across 6 frames -> square viewBox), so stock files with empty artboard
  padding fill the badge instead of floating tiny in the middle.
- Wire sleeping-cat.json to every mood: she's always asleep in the corner yet
  still mumbles tips and cheers through the bubble. napping hardcoded true so
  she keeps the calm sway + drifting zzz even while "talking".
- happy-dog.json parked in the folder as an awake-mascot alternate.
- Enable resolveJsonModule for the JSON imports.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 22:03:02 -07:00
prosolis 8a32dde587 Add companion kitten — reactive corner mascot
A cozy bottom-right mascot that reacts to the writing session: cheers on
accepted suggestions and word-count milestones, drops Mandarin-first writing
tips, suggests screen breaks after long stretches, and naps when idle.

- useCompanion: library-agnostic behavior engine (priority + cooldown paced
  so it never nags); tips.ts holds all copy, bilingual zh-first.
- LottiePlayer wraps lottie-web's light build (offline, no eval/CDN) so it
  bundles into the Go binary. Ships with an emoji-kitten placeholder per mood;
  dropping a Lottie cat JSON into animations/index.ts is the only swap needed.
- Speech bubble uses the CJK-first font stack (spec Note #17).

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 21:43:58 -07:00
prosolis 534f7262ab Phase 6: design polish — accept confetti + distraction-free mode
- Accept confetti: CSS-only 4-dot burst spawned at the accepted card,
  trajectory via inline --dx/--dy, auto-cleared after 720ms.
- Distraction-free mode: editor focus collapses the doc-list sidebar
  (width→0 slide), canvas re-centers full-width; Escape or a click
  outside the canvas (header/gutter/status bar) restores it.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 21:21:46 -07:00
prosolis 0fa70979a0 Phase 5: voice consistency pass
Tier-1 voice-consistency pass: whole-document LLM review surfacing passages
that read tonally out of place (formal/over-polished/paraphrased-too-closely),
as honey-decorated `voice` flags with no correction (awareness-only).

- internal/llm/voice.go: RunVoice sends the whole document (no TruncateDoc),
  MaxTokens 2048, 20s per-doc floor (VoiceInterval). Standalone voice prompt
  in prompts.go (not bundled with the grammar checkpoint, per spec).
- internal/suggestions: POST /api/docs/:id/voice. replacePending is now
  family-scoped (pendingScope) so grammar and voice never clobber each other's
  pending flags; both passes return the unified pending set. check/voice share
  one runPass helper. TestVoicePassCoexists covers both directions.
- Frontend: api.voiceDoc, useCheckpoint voicing/runVoice, honey "Check my
  voice" toolbar pill, breathing honey dot in StatusBar.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 21:16:53 -07:00
prosolis 3c5f3ecb96 Phase 4: Ask Petal SSE chat
Conversational follow-up on a suggestion, streamed token-by-token.

Backend (interface-only; handlers never touch a concrete LLM client):
- internal/llm/chat.go: StreamAskPetal with conversational sampling
  (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop "\n\n\n"),
  reusing 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
  (whole-doc fallback when unlocated) and injects it server-side.
  Streams event: token / event: done SSE frames with JSON-encoded data
  so token newlines can't break framing; real http.Flusher per chunk.
  LLM-unreachable -> 502 before SSE headers; unknown suggestion -> 404.

Frontend:
- streamSuggestionChat: fetch + ReadableStream SSE parser (not
  EventSource, needs POST), abortable.
- AskPetal.tsx: whole conversation in component state (no persistence,
  cleared on close), Petal's first bubble pre-seeded with the
  explanation, rose/lavender bubbles, CJK font stack on the bubbles
  only (Note #17), streaming caret.
- SuggestionCard "Ask Petal" pill pins the card open while chatting
  (hover-close suppressed, click-away closes) and widens it to 340px.

Tests: chat_test.go covers streamed-text concat + done event,
server-side context injection on the system message, sampling params,
404, and surroundingParagraph. go build/vet/test clean, tsc clean,
vite build OK. Live SSE smoke-tested against a fake streaming vLLM:
tokens flushed individually through the chi middleware stack, done
terminator, 502 on LLM-down, 404 on unknown suggestion.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 21:05:39 -07:00
prosolis 3f7e705028 llm/ollama: disable thinking so reasoning models return content
Qwen 3.5 — the spec's recommended model — is a reasoning model. With
thinking on, Ollama streams its chain-of-thought into a separate
`thinking` field and hits num_predict before emitting any answer into
`content`, so Complete() got an empty string and the checkpoint failed
with "no JSON object in model output". Sending `"think": false` on every
/api/chat request fixes it; non-thinking models (qwen2.5) ignore the flag.

Validated end-to-end on deployment hardware (Ollama, qwen3.5:9b): the
grammar checkpoint now caught all five ESL errors in a 3-sentence sample
with correct JSON and string-anchoring, ~8.5s warm.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 20:53:01 -07:00
prosolis a4069d5755 Phase 3: LLM grammar checkpoint
Backend (internal/llm): backend-agnostic LLMClient interface + factory
with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete +
Stream. prompts.go holds the checkpoint and Ask Petal templates;
checkpoint.go salvages JSON from model output (brace-matched), enforces a
per-doc 30s RateLimiter, and truncates the doc to a latency cap.

internal/suggestions: POST /api/docs/:id/check runs a checkpoint and
replaces the doc's pending suggestions in one tx (accepted/rejected kept
as history); GET /api/docs/:id/suggestions lists pending;
POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks
return the current set rather than erroring.

Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard
tokens); SuggestionHighlight renders ProseMirror decorations re-anchored
by the `original` string on every doc change (not stored marks), with
precise textblock-offset→PM-position mapping; SuggestionCard shows the
type tag + diff + explanation and applies the replacement in-editor on
accept; breathing rose checkpoint dot in the StatusBar; fade-float +
breathe animations.

Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit
over httptest with a stub client. Smoke-tested end-to-end against a fake
vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 20:45:30 -07:00
prosolis 5e00cdce88 Phase 2: document CRUD + auto-save
Backend internal/docs: chi sub-router (list/create/get/update/delete)
mounted at /api/docs, scoped to the local user. Create uses RETURNING;
update is a COALESCE partial-update so rename and full editor save share
one PUT. JSON 404/400 errors; handlers_test.go walks the lifecycle.

Frontend: api/client.ts, useAutoSave (1.5s debounce + saveNow flush
before doc switch), EditorCore (Tiptap StarterKit/Underline/TextAlign/
Placeholder/CharacterCount) + Toolbar, DocList/DocListItem, StatusBar,
and an App.tsx that orchestrates load/select/create/delete with
optimistic sidebar patching. content + content_text + word_count are
emitted together on every edit. .petal-prose styling (Lora body, Nunito
headings).

Verified: tsc clean, vite build, go build, full CRUD smoke test incl.
CJK title round-trip and SPA serve.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 20:30:39 -07:00
prosolis 9c98e97030 Phase 1: data layer (SQLite, migrations, models, seed) 2026-06-25 20:25:14 -07:00
prosolis e72d48d64e Phase 0: project scaffold
- Go module + chi server with embedded SPA serving and /api/health
- internal/config env loader (local-dev defaults; auth/copyleaks deferred)
- React 19 + Vite 6 + Tailwind v4 frontend with full Petal design tokens
- Frontend embedded into the binary via web/embed.go (go:embed all:dist)
- README dev workflow, .env.example, BUILD_PLAN progress tracker
- Verified end-to-end: binary serves health, embedded SPA, and SPA fallback
2026-06-25 20:20:04 -07:00