Skip to content

feat: talk for the web (talk.pilgrimapp.org) — foundation + U1 spike - #1

Merged
momentmaker merged 31 commits into
mainfrom
feat/talk-web
Jun 14, 2026
Merged

momentmaker merged 31 commits into
mainfrom
feat/talk-web

Conversation

@momentmaker

Copy link
Copy Markdown
Member

Draft — foundation only. Not for merge until the feature is whole (Phase C–E after the U1 spike).

The on-device, in-browser voice-reflection web port of talk, modeled on the sibling meditate-cli/web. Built through the full dream → spec → review → plan → review → work pipeline.

  • Requirements: docs/brainstorms/2026-06-13-talk-web-requirements.md
  • Plan (spike-first, 12 units): docs/plans/2026-06-13-001-feat-talk-web-plan.md

What's in this PR (Phase B foundation + the U1 spike harness)

Unit What
U2 Vite + TS + xterm scaffold, rust-themed, targeted at talk.pilgrimapp.org
U3-A talk-wasm — a wasm-bindgen façade over pure talk-core (cleanup, append, palette, Settle, compose, question selection). Cross-compiles to wasm32; no listen/sherpa/libc dep leak
U4 terminal + REPL + render loop + rust theme (tones sourced from talk-wasm palette(); paints a static composed reflect screen)
U1 harness web/spike/ — the runnable sherpa-onnx WASM measurement tool (RTF, footprint, CSP-loadability, threading, egress)

It builds and paints a static rust reflect screen through the real two-pass render path. It does not transcribe yet — that's U6, deliberately gated on U1.

Verified

  • cargo test -p talk-core -p talk-wasm → 110 + 23 pass
  • cargo build --target wasm32-unknown-unknown -p talk-wasm → ok
  • cd web && npm test (vitest) → 19 pass · tsc --noEmit clean · vite build ok

Reviewed

A 10-persona ce-code-review ran on this diff: no P0s; 17 P1/P2/P3 findings auto-resolved (façade contract hardening, theme no-silent-mis-paint + palette-drift fix, loop.ts no-flash test, and making the U1 spike honest about its main-thread-only egress measurement).

The gate: U1 needs real devices

This build is spike-first by design. U1 must run on a desktop + a mid-range phone to select the build branch (base.en vs Moonshine on mobile · two-pass vs single-pass · single-threaded vs threaded host · Cache vs OPFS · the real CSP directive set). See web/spike/README.md. Once the go/no-go memo lands in the plan's Open Questions, Phase C resumes (U5 models, U6 ASR + U3-B pairing lift, U7 controls) → D (modes, journal) → E (zero-egress canary, chrome, Pages deploy).

🤖 Generated with Claude Code

momentmaker and others added 30 commits June 13, 2026 16:33
The reviewed requirements doc and the spike-first implementation plan for
talk-web (talk.pilgrimapp.org) — the on-device, in-browser voice-reflection
web port modeled on meditate-cli/web.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the meditate-cli/web toolchain (vite-plugin-wasm + top-level-await,
strict TS, vitest) recolored to talk's rust palette and targeted at
talk.pilgrimapp.org. build:wasm wiring points at scripts/build-wasm.sh (added
in U3). No CSP meta yet (U10 owns it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A wasm-bindgen façade (crates/talk-wasm, deps talk-core + wasm-bindgen=0.2.95
only) exposing the deterministic surface to JS: shapeEntry/deterministicLight,
appendEntry (CLI-identical journal/reflect markdown), palette bytes, the Settle
live/committing/settled machine + compose/composeClose/composeReleased, and
question selection. Panic-safe boundaries, hand-built JSON (no serde_json).
build-wasm.sh mirrors meditate (pinned wasm-bindgen, panic=abort, --target web).

Cross-compiles to wasm32; 110 talk-core + 16 talk-wasm tests pass; no listen/
sherpa/libc dep leak. The off-record-pairing lift (U3-B) is deferred to Phase C
with U6 — it touches the shipped CLI and only feeds the gated browser pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port terminal.ts (xterm + WebGL/DOM fallback), repl.ts (pure line editor) and
loop.ts (frameSequence synchronized output + shouldDraw) from meditate-web. New
theme.ts sources the rust core/dim/edge tones from talk-wasm palette() as the
single source of truth and maps LineKind→tone (Settled=core, Edge/Question=dim,
Chrome=edge). main.ts paints a static composed reflect screen via Settle.compose.

tsc clean; vite build OK; 14 vitest tests pass (ported repl + theme/WCAG-contrast
+ compose-render: settled brighter than edge/question).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A throwaway, runnable browser page (web/spike/, isolated from web/src/) to run
the U1 device spike that gates Phase C. Instruments all seven measurements:
live-edge + finalize RTF, cached footprint (storage.estimate), full-vs-chunked
crypto.subtle.digest on the ~199MB file, CSP-violation capture under a draft
strict CSP, crossOriginIsolated/SharedArrayBuffer/threads detection, the fetch
module-shape log, and a post-model-load egress log. The sherpa-onnx loader is
left at clearly-marked WIRE: seams (no fabricated measurements). README carries
the asset-setup steps, the desktop+phone checklist, and the go/no-go memo
template mapped to the plan's decision tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Façade (talk-wasm): explicit js_name on Settle commit/finalize; selectQuestion
guards mismatched parallel-array lengths; documented palette() length contract;
added tests for every push_json_string escape arm, upgrade/post-finalize
contracts, high-contrast palette, and compose/select JSON round-trips.

Web: parseComposed validates shape + try/catch, toneForKind gains a default
branch (no silent mis-paint on a new LineKind); style.css derives the accent
from the WASM palette via --accent-rgb set in main.ts (kills the hard-coded RGB
drift); theme.test null-narrows; new loop.test.ts ports the no-flash invariant;
pretest builds wasm so npm test works on a clean checkout.

Spike (U1 honesty): LOUD main-thread-only caveat on the egress/module-shape
metrics (worker fetches bypass them); memo CSP generated from the enforced meta
(fixes the blob' typo); chunked-digest + crossOriginIsolated caveats; capped
capturedFrames to ~60s; footprint reports a model-cache delta; dropped dead code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k-wasm (U3-B)

Extract the pure Commit/Revise/pause pairing state machine from
src/live.rs::apply_event into talk-core::pairing (Pairing/Decision) — no I/O,
no crossterm, no threads. live.rs now delegates the drop/disarm/straddle
decision to it and keeps only the CLI lexicon+cleanup transform; behavior is
preserved (the 5 pairing characterization tests pass post-refactor). Exposed
through a talk-wasm Pairing wrapper so the browser pipeline (U6) shares the
privacy invariant instead of re-implementing it in TS.

Invariant preserved (arm order load-bearing): Done first; Revise gated on
!commit_dropped (straddling Revise still upgrades); Commit-while-paused drops +
disarms the paired Revise; re-arm only on the next accepted Commit, never on
resume; finish-drain carries commit_dropped forward.

cargo test --workspace green (binary 91 + integration 21 + privacy 7 + core 121
+ wasm 29); wasm32 builds; bindings expose class Pairing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Engine-agnostic model acquisition. integrity.ts: Web Crypto sha256Hex + a
chunked incremental SHA-256 path, verify(), and MODEL_FILES — the 7 EXTRACTED
per-file pins ported verbatim from src/download/models.rs (re-pin against the
served WASM artifacts at hosting time). cache.ts: a CacheBackend seam (real
Cache API default, memory backend for tests; OPFS swap is one line) with
persist() opt-in, corrupt/missing → null. download.ts: per-file fetch with
streamed progress, AbortController cancel, verify-before-commit, refuse+retry on
mismatch, file-boundary resume, and the offline states (cached+offline ok,
uncached+offline blocked, mid-download paused→resume).

Dependencies injected (fetchFn, cache, isOnline) so the logic is unit-tested
without a real browser; the real Cache API + network are validated live. 58
vitest tests pass; tsc clean; vite build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
audio.ts: getUserMedia 16kHz mono + AudioWorklet (shipped web/public/mic-worklet.js)
with a ScriptProcessor fallback and the iOS resume quirk; the five mic-permission
states (pending/granted/denied/denied-suppressed/device-unavailable) each surfaced
distinctly. recognizer.ts: the Recognizer seam — WireSherpaRecognizer (the // WIRE:
stub where sherpa streaming Zipformer + Whisper base.en plug in, validated live) and
MockRecognizer (scripted partials→endpoint→finalize so the pipeline runs/tests now).
pipeline.ts: the thin driver — every event routed through the shared wasm Pairing.decide
before touching Settle, so off-record commits + paired revises are dropped and a
straddling revise still upgrades (privacy invariant lives in Rust, not TS); hallucination
gate on noSpeechProb; R3 idle/hangover/end-of-session finalize-or-drop. main.ts now
animates the live edge end-to-end via MockRecognizer (one-line swap to the real engine).

Recognizer + audio injected → unit-testable without a mic/engine. 86 vitest tests
pass; tsc clean; vite build emits the worklet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SessionControls: a DOM-free controller over a SessionActions seam — space=done,
p=pause/off-record (drives pipeline pause/resume), u=raw⇄clean, esc=cancel with
the confirm-cancel state (ephemeral cancels immediately), matching
live.rs::run_loop. mobile.ts ports isTouch + createChipBar and defines the
per-mode chip sets (reflect/journal/unburden session + journal-view) so touch
carries the same verbs as the keyboard. Clean/raw toggle flows through
Settle.compose show_raw; cleanup level per mode (journal=High, reflect/unburden=
Light) via cleanupForMode. main.ts wires onData→onKey and mounts the chip bar on
touch. Pipeline gains isPaused() to satisfy SessionActions.

104 vitest tests pass (chip↔key parity, confirm-cancel, clean/raw parity with
shapeEntry); tsc clean; vite build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ModeRouter owns the three experiences + the between-session picker. Reflect is
the front door: selects a curated question via talk-wasm selectQuestion (the
questions/*.toml bundled into web/src/questions via Vite ?raw), shown as text
(no TTS), with skip/re-roll before answering and thread accumulation by question
id. Journal is freeform; unburden is ephemeral — keeps nothing, plays a closure
(composeReleased/composeClose), wipes, returns to the picker. Persistence is a
clean EntryStore seam (in-memory now; U9 swaps the durable store with zero
router change) — unburden never reaches store.keep, so keeps-nothing is
structural. main.ts now boots into the reflect door, runs a MockRecognizer
session, lands the entry, and offers the picker.

116 vitest tests pass; tsc clean; vite build bundles the TOML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JournalStore: durable EntryStore over a versioned localStorage blob (corrupt-safe
load + migration, ported from meditate's store.ts) — entries by local date,
reflect threads by question id, full selection state. Drops into U8's EntryStore
seam with zero router change. Storage-failure states each surfaced distinctly
(quota→export-now, persist-denied→advisory, write-fail→retriable), never
swallowed; the in-memory mirror keeps the entry even on durable-write failure.
view.ts: journal IA (by date + by thread, empty state, continue-thread).
export.ts: markdown via talk-wasm appendEntry — byte-parity with ~/talk files
(tested), download + clipboard behind an injected sink with the one-time
OS-shared disclosure. Durability honesty: first-keep export prompt (once),
private-mode + eviction + shared-device warnings. modes.ts gains continueQuestion.

158 vitest tests pass; tsc clean; vite build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strict CSP via <meta> (default-src 'self'; connect-src 'self' + the one model
host; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; img data:;
base-uri/object-src 'none') with each relaxation justified; frame-ancestors is a
documented Pages residual for U12. Self-host audit clean — no third-party assets,
the model CDN is the only egress origin. Up-front one-time privacy assertion (R5)
and the taste-it live-edge preview (R10) on the boot path.

no-egress.spec.ts (Playwright): asserts ZERO network after the model-cache phase
+ a LIVE canary probe (a real request to a CSP-permitted, page.route-stubbed
origin) so the interceptor is proven to catch — 'or the test proves nothing' —
plus a CSP-blocks-external-connect test. Ran green in real chromium (2 passed).

tsc clean; 158 vitest; vite build under the CSP ok; CSP present in dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
boot.ts: a login-style 'last visit … on talk.pilgrimapp.org' banner over a short
MOTD with the soft install funnel (brew install talk), rust-toned, reduced-motion
aware, returned as tone-tagged lines. deeplink.ts: #q=<question-id> only, with the
SAFE_ID (^[a-z0-9-]{1,64}$) + control-byte-strip moat — over-long ids are rejected
(not truncated-then-accepted), unknown/invalid ids fall back to normal selection.
modes.ts gains startWithQuestion + findQuestionInPack so a valid #q opens that
reflect question. og.html is the rust OG-card source; a faithful placeholder
public/og.jpg ships so the OG meta + Pages build resolve (final artwork deferred).
No new external origins — U10's CSP + net-silence canary unaffected.

182 vitest tests pass; tsc clean; vite build copies og.jpg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pages.yml mirrors meditate's Pages deploy, adapted to talk's conventions:
triggers on web/** + crates/talk-{wasm,core}/**; rust 1.82 + wasm32 target;
cargo binstall --locked wasm-bindgen-cli@0.2.95 (kept in sync with
crates/talk-wasm/Cargo.toml, enforced by build-wasm.sh); node 20; npm ci &&
npm run build; upload web/dist; deploy-pages. All actions SHA-pinned (matching
ci.yml/release.yml). No committed CNAME — domain set in Pages settings. Header
documents the user-owned ops (DNS, Pages custom domain, CDN upload + exact-origin
CORS + Range).

web-ci.yml closes the review's no-web-CI gap: on PRs touching web/** or the wasm
crates, runs npm ci + build:wasm + tsc + vitest + the Playwright net-silence
canary. Both workflows parse + pass actionlint. A real Actions run is deferred to
first push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Privacy/correctness (ASR path):
- Pairing.decide() unknown kind now returns drop (was on-record applyPartial);
  TS adds a toVerdict() guard at the decide() call sites (#1)
- hallucination gate snapshots segment stats at endpoint so the heuristic
  (no_speech_prob-null) path no longer drops every pass-2 upgrade (#2)
- download retry gains exponential backoff + jitter (#3); per-file stall
  timeout (#15)
- journal store merges-before-save + a cross-tab storage listener, closing the
  concurrent-tab lost-update window (#4)

Contracts & reliability:
- MockRecognizer.reset() no longer emits (bypassed the Pairing guard); on()/reset()
  contract documented (#5); cache.put() returns a result instead of masquerading
  as DownloadFailed (#6); EntryStore.keep/noteServed widened to surface storage
  failures (#9); start()/continueQuestion() end the prior session (deep-link boot
  leak) (#10); AudioCapture stop-during-wiring guard + resume-suspended surfacing
  (#13,#14); store element-shape guard + quota→export-prompt honesty (#16,#17);
  Pipeline.pairing made private behind commitDropped() (#19)

Structure: CLOSE_PHRASES moved into talk-core (single source, wasm-exported)
(#8); DemoModeSession extracted behind a LiveSessionView interface (#20); dead
recordKept() removed (#7)

P3: terminal control-byte strip at the wasm→terminal seam (#23); command()
exhaustiveness guard (#24); listener/timer teardown on unload (#25,#27); JSON
parse-failure warnings (#26); merged import (#22); workflow checkout SHA aligned
to ci.yml (#11,#28)

Deferred: broad main.ts decomposition (#21); release.yml SHA alignment (out of
talk-web scope). cargo test 122+30; 197 vitest; e2e canary green; builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires a real browser-native Whisper engine into the existing Recognizer seam,
replacing the mock for the live app — no emsdk, no COOP/COEP (default asyncify
single-thread WASM / WebGPU both run on GitHub Pages), models from the HF CDN.

- transformers-worker.ts: WhisperForConditionalGeneration + TextStreamer in a Web
  Worker (Pattern A), WebGPU→WASM fallback, numThreads=1, streamed partials +
  completed final, HF download progress forwarded.
- transformers-recognizer.ts: implements Recognizer — Int16→Float32, energy VAD
  (700ms endpoint), debounced rolling-window partials → partial/endpoint/finalize,
  onModelStatus for the loading UI.
- live-session.ts: the engine+mic twin of DemoModeSession behind LiveSessionView,
  so the router/render are unchanged.
- main.ts: ?engine=mock|real (default real) + ?model= override; model/mic status
  on the status line; skips the sherpa U5 download when the real engine is active.
- index.html CSP widened to huggingface.co + cdn.jsdelivr.net (SPIKE — production
  self-hosts both on cdn.pilgrimapp.org and narrows back); canary boots ?engine=mock.

The TextStreamer per-token callback maps onto talk's live edge (Whisper streaming =
the jitter; completed generate = the settle). transformers code is isolated in the
lazy worker chunk (main entry unchanged); engine bytes load on first real session.

tsc clean; 211 vitest pass (14 new, fake-worker); vite build ok. Real transcription
+ perf (esp. mobile) are unverified until  in a browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The spike was wired but couldn't run end-to-end; fixes the chain that blocked
real transcription (validated live: on-device WebGPU Whisper transcribes the mic):

- CSP: allow HuggingFace weight-delivery hosts (*.huggingface.co LFS + *.hf.co
  Xet) — model .onnx files 302-redirect there; connect-src checks the final URL.
- worker: omit language/task for English-only Whisper checkpoints (.en) — they
  throw 'Cannot specify task/language for an English-only model'.
- main: bind ANY live session, not just DemoModeSession (the instanceof check
  left the real LiveModeSession unbound, so keys never reached it).
- sessions: gesture-gate the mic — start capture in begin() (called on the first
  keypress/click), not the constructor; getUserMedia/AudioContext need a user
  gesture or the AudioContext stays suspended and no frames arrive.
- main: a click anywhere also begins (robust when the terminal lacks keyboard
  focus, e.g. DevTools open).

tsc clean; 211 vitest pass; build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… bug)

frameSequence homed the cursor and overwrote in place but never erased what the
overwrite didn't cover, so a shorter frame (the picker drawn over a long session
transcript) or a shorter line left the previous frame's text behind — the
garbled, overlapping screen.

Erase each row to end-of-line (a shorter line can't keep a longer line's tail)
and erase to end-of-screen after the last row (a shorter frame can't keep a
taller frame's rows). Still no full clear (\x1b[2J) — EL+ED clear exactly what
the in-place overwrite leaves, so it stays flash-free.

5 loop tests pass (the no-2J invariant holds).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…b parity)

strip_sound_tags was written and tested but wired into nothing — so the live
engine's non-speech annotations ([BLANK_AUDIO], (Keyboard clicking)) settled as
kept text: '[BLANK_AUDIO].', '(Keyboard clicking).'. Wire it into the front of
deterministic_light, the one cleanup both the CLI (DeterministicFormatter) and
the web (wasm deterministicLight) share, so both strip them. A tag-only segment
cleans to '' (ensure_terminal leaves empty empty — no lone period).

Also extend SOUND_WORDS with the desk/ambient events Whisper emits while you
talk at a keyboard (keyboard, clicking, tapping, mouse, rustling, …); the
all-words-must-be-sound-words rule still protects real asides like '(I think)'.

123 talk-core tests pass; 16 web pipeline tests pass (incl. wasm-path parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cheat-sheet behind one keystroke: ? or / opens a help overlay listing exactly
what the current screen accepts (session / picker / journal / closing) plus the
always-available globals and the privacy promise. Any key closes it — and the
closing key is swallowed so it never also fires a command underneath (space
closes help without landing the entry). Surfaced inline on the picker + journal
hints (?  help) so it's discoverable.

A static reference, not a type-to-run palette — discoverability without adding a
new input mode to a calm reflection app.

tsc clean; 212 vitest pass; build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
onMicState only updated the status line while state !== 'granted', so the brief
'pending' message ('Waiting for microphone permission…') latched and the granted
transition never cleared it — even when the mic was already granted (it still
passes through 'pending' for one frame). Clear the line on granted; the session
view's own 'ready' / '[ Silence ]' indicator already shows it's listening.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…all, picker

Six fixes surfaced by running it live:

- boot no longer auto-dismisses on a timer. The 4.2s timer dropped the user into
  a session whose mic never started (getUserMedia needs a user gesture) — now the
  'press any key to begin' gate IS that gesture; boot holds until a key/click.
- esc works again. Letters reached onData but a bare Escape did not, so esc-cancel
  and esc-back silently did nothing. Route Escape through attachCustomKeyEventHandler,
  context-aware (close help · leave journal · dismiss closing · cancel session).
- 'speech model ready (webgpu)' no longer trails across every screen — the status
  line clears on ready (the header + session indicator already show readiness).
- 'downloading speech model…' only on a real first download; a cached load says
  'loading…' (a per-browser model-seen flag distinguishes them).
- install funnel detects the OS: Homebrew on macOS (corrected to the real tap
  walktalkmeditate/tap/talk — was the nonexistent 'brew install talk'), the crate
  elsewhere. Help overlay lists the detected command + both methods.
- picker gains a calm one-line description per mode (reflect/journal/unburden)
  with the keystroke highlighted, instead of three bare verbs.

tsc clean; 212 vitest pass; build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nfirmation)

The save confirmation auto-vanished after 2.5s, so finishing a voice note felt
like nothing was saved. It now holds until a keypress (CLI parity — the CLI's
show_close awaits a key) with a 'press any key to continue' hint. The unburden
release still fades on its own (a 'wait' flag on beginClosing distinguishes the
two moments, keeping the dwell path for the release).

tsc clean; 212 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntries

esc: space→done works (keys reach onData) but a bare Escape never did, and
xterm's key-event handler didn't catch it either. Listen at the document in the
CAPTURE phase instead — fires before xterm's textarea regardless of its focus
quirks, handled once (stopPropagation), cleaned up on unload. esc now cancels a
session (arms the discard prompt) and backs out of journal/help/closing.

journal paragraphs: the CLI applies shape_entry(High) to journal entries on save
(src/main.rs:383) but the web only space-joined finalClean — shapeEntry was
referenced in a comment and never called. Apply it in buildPayload with the mode's
level (journal → High paragraphizes, reflect → Light passes through), so kept +
exported journal entries paragraphize exactly like the CLI.

tsc clean; 212 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-named

Adds the individual half of export alongside the existing whole-vault export:

- journal view numbers each day + reflect thread; pressing the digit exports just
  that one. 'e' still exports everything.
- Obsidian-friendly filenames for individual exports: a journal day is its bare
  date (2026-06-14.md — the daily-note pattern), a reflect thread/entry is its
  question slug (avoidance-slug.md). The whole-vault export keeps talk-journal.md.
- new 'day' export scope folds one calendar day's journal entries into a single
  daily-note file (so multiple same-day entries land in one 2026-06-14.md, the way
  Obsidian expects), reusing the appendEntry markdown builder for byte-parity.

Granularity is per-DAY (not per-entry) for journal so same-day notes coexist in
one daily file and filenames never collide; per-THREAD for reflect.

tsc clean; 214 vitest pass (incl. new day/thread filename + scope tests); build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…entries)

The digit-export ([1]-[9]) capped at 9 and the journal view didn't scroll, so a
growing journal overflowed the screen and later groups were unreachable. Replace
digits with a selection cursor: ↑/↓ (or k/j) move the highlight, the viewport
scrolls to keep it visible (↑/↓ more indicators), x/enter exports the selected
day/thread. Works for any number of entries. e still exports everything.

tsc clean; 214 vitest pass; build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…export)

In the journal browser: d deletes the selected day/thread, D deletes everything —
both arm a y/n confirm first (deletes are irreversible; localStorage only). esc
cancels a pending confirm before it leaves the view. Export stays independent (no
auto-delete) so a silently-failed download can't lose data.

Store deletes write DIRECTLY against a fresh disk read rather than through save():
save() union-merges the on-disk blob to protect concurrent-tab adds, which would
otherwise resurrect a just-deleted entry. Added deleteDay/deleteThread/clearAll;
write failures surface like keep() does.

tsc clean; 219 vitest pass (incl. deletion + 'not resurrected by a later keep'
merge guard); build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… journaling)

Now that the journal has a selection cursor, 'c' continues the SELECTED unit
instead of always the most-recent thread: a reflect thread re-prompts its exact
question; a journal day resumes journaling (a fresh freeform entry, landing under
today). Replaces topThread/continueThreadView with continueSelectedUnit.

tsc clean; 219 vitest pass; build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A journal day has no question to resume and journal entries always land under
today, so c only continues a reflect thread now (no-op on a journal day). Updated
the hint + help to say 'continue a thread'.

tsc clean; 219 vitest pass; build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@momentmaker
momentmaker marked this pull request as ready for review June 14, 2026 23:40
The CSP was broadened to let transformers.js fetch the Whisper weights from
HuggingFace (the .en files 302-redirect to *.huggingface.co / *.hf.co LFS+Xet
hosts). Update the canary to that documented allowlist: it still asserts the EXACT
set of origins and still proves a disallowed origin is refused at runtime, but now
rejects only BLANKET wildcards (bare *, scheme-only, scheme://*) — the two HF
entries are scoped wildcards in the allowlist. Production self-hosts on
cdn.pilgrimapp.org and narrows back to 'self' + cdn (no HF, no wildcards).

e2e: 2/2 pass locally (chromium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@momentmaker
momentmaker merged commit f91803a into main Jun 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant