diff --git a/HANDOFF.md b/HANDOFF.md index e3315f4..c57aa23 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -135,7 +135,7 @@ Every turn flows through **one** path — now the typed run state machine under - **Memory-steered proof capture (#130):** `RORO_TRACE=1` can capture DECIDE prompt/task evidence, and `npm run verify:memory-steered` proves recalled memory reaches both DECIDE and `args.task` under the synthetic-marker proof path. -- **Floating default (#131):** Roro ships as the transparent 190x200 desktop pet by default; set +- **Floating default (#131):** Roro ships as the transparent 360x420 click-through desktop pet by default; set `RORO_FLOATING_WINDOW=0` only for the legacy full dev window. - **VoicePack catalog to shared (#137):** the voice-pack catalog moved to `src/shared/voicePacks.ts`; cosmetics no longer imports from `renderer/voice`. @@ -249,7 +249,7 @@ memory/embeddings). Add new expensive lessons there, not here. - **Commit / PR attribution:** follow the active tool's requested attribution only when the user or workflow asks for it; do not paste stale tool-specific footers into public PRs. - **Don't commit `.env`** (gitignored). Historical predecessor/demo checkouts are archived context only and must not be edited as part of Roro work. - **Verify before claiming done** — "the types check" is not done; "I observed it working" is. -- **Key commands:** `npm test`, `npm run lint`, `npx tsc --noEmit -p tsconfig.json`, `npm run release:doctor` (CI-safe release/signing doctor), `npm run package` (.app), `npm run verify:floating-geometry` (default 190x200 transparent pet shell), `npm run package:release` + `npm run verify:release-channel` (release-channel deferred-flag refusal), `npm run verify:packaged-memory` (packaged write/relaunch/recall), `npm run verify:packaged-live-memory-turn` (packaged relaunch + live Ollama turn uses recalled memory), `npm run verify:packaged-natural-memory-turn` (packaged natural-language teach/relaunch/recall), `npm run verify:packaged-real-codex` (opt-in packaged first task with the user's real authenticated/configured Codex CLI), `npm run verify:memory-steered` (synthetic-marker DECIDE/args.task proof), `npm run verify:memory-panel-rendered` (opt-in local GUI smoke for rendered Memory panel keyboard/focus behavior), `npm run verify:signing-readiness` (strict Developer-ID env/cert/tool doctor), `npm run verify:signing-auth` (notarytool Apple credential auth check), `npm run make` (+ distributables + signing), `npm run verify:release-artifact:dmg` (post-make DMG verifier), `npm run verify:release-artifact:signed` (post-make signed/notarized artifact verifier), `npm start` (dev — memory works here), `OLLAMA_AVAILABLE=1 npx vitest run crosslaunch.live` (live magic-moment smoke), `npm run eval:brain` (scorecard), `EVAL_SET=behavioral npm run eval:brain`. +- **Key commands:** `npm test`, `npm run lint`, `npx tsc --noEmit -p tsconfig.json`, `npm run release:doctor` (CI-safe release/signing doctor), `npm run package` (.app), `npm run verify:floating-geometry` (default 360x420 transparent click-through pet shell), `npm run package:release` + `npm run verify:release-channel` (release-channel deferred-flag refusal), `npm run verify:packaged-memory` (packaged write/relaunch/recall), `npm run verify:packaged-live-memory-turn` (packaged relaunch + live Ollama turn uses recalled memory), `npm run verify:packaged-natural-memory-turn` (packaged natural-language teach/relaunch/recall), `npm run verify:packaged-real-codex` (opt-in packaged first task with the user's real authenticated/configured Codex CLI), `npm run verify:memory-steered` (synthetic-marker DECIDE/args.task proof), `npm run verify:memory-panel-rendered` (opt-in local GUI smoke for rendered Memory panel keyboard/focus behavior), `npm run verify:signing-readiness` (strict Developer-ID env/cert/tool doctor), `npm run verify:signing-auth` (notarytool Apple credential auth check), `npm run make` (+ distributables + signing), `npm run verify:release-artifact:dmg` (post-make DMG verifier), `npm run verify:release-artifact:signed` (post-make signed/notarized artifact verifier), `npm start` (dev — memory works here), `OLLAMA_AVAILABLE=1 npx vitest run crosslaunch.live` (live magic-moment smoke), `npm run eval:brain` (scorecard), `EVAL_SET=behavioral npm run eval:brain`. - **State lives in:** memory + owner.json + packaged config → `app.getPath('userData')` (override `RORO_DB_DIR`). The agent's working repo resolves from explicit `RORO_WORKDIR`, then persisted `userData/config.json`, then the explicit `RORO_ALLOW_CWD=1` dev fallback. --- diff --git a/PUBLIC.md b/PUBLIC.md index 75feccd..5058b7c 100644 --- a/PUBLIC.md +++ b/PUBLIC.md @@ -77,8 +77,8 @@ required. The **two roles of the Developer-ID cert are now clear and separate:** > later turn. This narrows the extraction-quality engineering risk; it still does not replace the signed/notarized > clean-Mac gate or the non-founder "does this feel correct and useful?" test. > First-task preflight: `npm run verify:packaged-first-task` launches the real packaged app with persisted project -> config, deterministic fake Ollama, a fake Codex override, and the product preload bridge. It proves the default typed -> surface exposes the executor-readiness check, then can complete a first coding task through public `turnRun` and Codex +> config, deterministic fake Ollama, a fake Codex override, and the product preload bridge. It proves the framed dev typed +> surface (it runs `RORO_FLOATING_WINDOW=0`, not the default floating pet) exposes the executor-readiness check, then can complete a first coding task through public `turnRun` and Codex > events. It still does not replace real Codex auth, signed/notarized clean-Mac validation, or non-founder validation. > Real-Codex preflight: `npm run verify:packaged-real-codex` keeps the deterministic fake Ollama brain but removes the > fake Codex override, so the packaged app must discover and run the user's real authenticated/configured Codex CLI against a disposable diff --git a/README.md b/README.md index 6637801..1897352 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,9 @@ the cat resting bottom-left — the empty area passes clicks straight through to whatever is underneath. At rest it is just the cat: the input, the working chip, and error cards are transient reactions tethered beside the cat, summoned by ⌘⇧Space or a hover-revealed handle. Setup banners appear only when action is needed. -**Tap or hold the cat to pet it; drag to move it.** When an explicit voice dev -flag is enabled, right-click/M mute is available for the mic path. The cat's body +**Tap or hold the cat to pet it; drag to move it.** (The on-device voice stack — +including the mute/M/right-click path — now lives in `packages/voice`; there is no +mute control in the current app, and a future voice reintegration restores it.) The cat's body carries only affection + move — talk and tasking live off the body (see the current interaction contract at [`docs/INTERACTION.md`](docs/INTERACTION.md)). The floating window stays above normal windows and across macOS Spaces, diff --git a/RUN.md b/RUN.md index d97c13e..3f3509a 100644 --- a/RUN.md +++ b/RUN.md @@ -59,6 +59,13 @@ the old optional model-avatar seam was deleted outright in #140 (see `HANDOFF.md ## 5. Voice (internal dev only, fully on-device — no keys) +> **Extracted:** the on-device voice stack now lives ENTIRELY in `packages/voice`, +> outside the app's dependency graph — the current root app has **no `RORO_*_VOICE` +> reader and no Voice/Mute control** (`window.ts` builds no voice keys by +> construction). The flags + pipeline below describe how voice runs/mounts back from +> `packages/voice`; a plain root `npm start` enables none of them. See +> `packages/voice/README.md` for the reintegration seam. + Voice runs entirely on-device — Silero VAD (ear-perk), whisper STT (transcribe), and Kokoro TTS (speak) — behind dev flags, all default off and hidden from the v0 typed-only release. There is **no cloud and no key**. @@ -113,11 +120,14 @@ Claude) outside Roro. If the CLI is in a nonstandard location, set `RORO_CODEX_B npm start ``` -Roro now launches as a transparent, frameless 190x200 desktop pet by default and -keeps Roro above normal windows across macOS Spaces. The cat remains the center of -the surface, with the compact Ask pill for tasks and first-run setup banners only -when action is needed. **Tap or hold to pet, drag to move.** Right-click/M mute is -available only in voice-dev launches, when the mic can actually be muted. +Roro now launches as a transparent, frameless 360x420 click-through desktop pet by +default and keeps Roro above normal windows across macOS Spaces. At rest it is just +the cat — the empty area passes clicks straight through; the input, working chip, +and error/speech cards are transient reactions tethered beside the cat, summoned by +⌘⇧Space or a hover-revealed Ask·Memory·Project menu, and first-run setup banners +appear only when action is needed. **Tap or hold to pet, drag to move.** (The +mute/mic path lives with the on-device voice stack in `packages/voice`; there is no +mute control in the current app.) For the legacy full dev window with the larger prompt/timeline surface: @@ -130,7 +140,7 @@ RORO_FLOATING_WINDOW=0 npm start - `npx tsc --noEmit -p tsconfig.json` → 0 errors - `npx vitest run --no-file-parallelism` → full deterministic suite - `npm run verify:floating` → on-screen smoke for the floating Ask (needs a display) -- `npm run verify:floating-geometry` → default transparent 190x200 bottom-left floating shell +- `npm run verify:floating-geometry` → default transparent 360x420 click-through floating shell, cat bottom-left - `npm run verify:packaged-model-setup` → packaged Ollama-down/Recheck through one-click core model pull - `npm run verify:packaged-real-codex` → opt-in packaged first task using your real authenticated/configured Codex CLI - `npm run verify:typed-live-turn` → default typed prompt through public `turnRun` diff --git a/docs/INTERACTION.md b/docs/INTERACTION.md index bc5c98d..b2dbbda 100644 --- a/docs/INTERACTION.md +++ b/docs/INTERACTION.md @@ -2,22 +2,153 @@ Roro's v0 interaction model is job-first: the user chooses a project, types a task, watches the cat narrate the agent loop, and sees useful memory carried -forward across sessions. - -## Current Surface - -- Text is the default input. Voice remains a hidden developer seam behind flags. -- The floating window is the default product surface: at rest it is JUST the cat - (plus setup banners when needed). There is no always-visible Ask pill — the - input, the working chip, the cat's speech, and error cards are transient - reactions that appear tethered beside the cat only when summoned or relevant. - (S6 rewrites this section in full; this reflects the S4 resting-purity re-skin.) -- The legacy full dev window (`RORO_FLOATING_WINDOW=0`) keeps the larger prompt, - captions, memory panel, and action timeline visible for debugging. -- The cat body handles presence and affection only: tap or hold to pet, drag to - move. Tasking stays in the prompt/Ask surfaces. -- The app must show actionable readiness states before work: local brain status, - selected project, selected executor, and memory/keychain health. +forward across sessions. This file is the LOCKED contract for how the floating +pet behaves. It reflects the shipped "pure cat at rest" redesign +(`docs/plans/floating-redesign.md`); every claim below is cross-checked against +the renderer (`src/renderer/ask/floatingAsk.ts`, `src/renderer/reactions/*`) and +window (`src/main/window.ts`) code. + +## Resting state — just the cat + +At rest, roro is JUST the cat. The default product surface is a modest +transparent, click-through floating window (`FLOATING_LAYOUT.window`, 360×420) +with the cat resting in the bottom-left cell just above the Dock. There is no +pill, no dashboard, and no chrome bolted to the cat's feet. + +- Roughly 80% of the window is transparent dead space that passes clicks + straight through to whatever app is underneath. The window is created + click-through (`setIgnoreMouseEvents(true, { forward: true })`); a renderer + hit-test (`reactions/interactionRegions.ts` over the pure + `reactions/clickThrough.ts`) flips it to INTERACTIVE only while the cursor is + over the live cat rect (plus an ~8px pad) or a visible reaction surface, and + back to click-through the instant the cursor leaves. +- The only things that can appear at rest are the first-run readiness banners + (local brain download, memory/keychain health, choose-a-project) — never a + standing input affordance. + +Every other surface — the input, the cat's speech, the working/Stop control, +error cards, and the Ask·Memory·Project menu — is a TRANSIENT, cat-tethered, +edge-aware reaction that appears only when it has a reason to and clears itself. + +## The cat body — two verbs: pet and move + +The cat body carries presence and affection only. It has exactly two gestures, +disambiguated by surface + button + state (never by timing): + +- **Tap or hold (left button, on the cat) = pet.** Petting is affection only. It + is safe in every state, never punishes, and can never trigger an expensive or + destructive action. Tapping the cat NEVER summons the input or opens the menu. + This is the sacred, unchanged law. +- **Drag (on the cat) = move the window.** A press that begins over the cat + sprite and moves drags the whole window; a press in the transparent dead space + passes through to the app underneath and never pets or drags Roro + (`installFloatingWindowGesture` gates on `isOverCat`). + +A drag pins the window interactive for the duration of the gesture +(`pinInteractive` on pointerdown → `unpinInteractive` on pointerup) so moving the +window under the cursor can never flip it to click-through mid-drag. + +## Summoning the input + +Tasking lives OFF the cat body. There are two ways to summon the input, and only +these two: + +1. **⌘⇧Space** (`CommandOrControl+Shift+Space`, the `SUMMON_ACCELERATOR` global + shortcut → `CH.focusAsk`). This is a deliberate "I want to type a task": the + window becomes key and the input bubble opens focused. This poke of the cat is + a real interaction and resets the sleep timer. +2. **Hover the cat, then click Ask.** Dwelling the cursor over the cat region for + ~320ms (`HANDLE_DWELL_MS`) reveals the `#ask-handle` menu (Ask · Memory · + Project) tethered beside the cat. Clicking `#menu-ask` summons the input, + exactly like ⌘⇧Space. + +The hover-reveal is NOT a real interaction: the dwell tracker never pokes or pets +the cat and never touches the presence/sleep timer. The cat may be asleep and the +menu still fades in without waking him — consistent with "gaze never wakes the +cat". Clicking **Ask** IS a real interaction — it summons the input and wakes the +cat (one `poke`), exactly like ⌘⇧Space. Clicking **Memory** or **Project** is a +utility click: it opens the panel WITHOUT poking, petting, or waking the cat (the +tests assert no `poke`), so browsing what Roro remembers never disturbs him. The +menu sits beside the cat body (never on it), so it never competes with the pet +gesture, and it is a resting-state affordance only: summoning the input or tasking +hides it. + +## Tethered reactions (the transient surfaces) + +All reactions mount as pointer-events islands in a single full-window overlay +`#roro-stage` (created by the renderer in JS, not in `index.html`). Each island +is tagged `data-roro-island` so the click-through hit-test finds every surface +generically, and each is positioned by the pure edge-aware tether positioner +(`reactions/tether.ts`) so it blooms from the cat's head and flips near a screen +edge. The anchor is frozen at bloom-time, so a breathing or walking cat doesn't +drag the surface. + +### The Ask·Memory·Project menu + popovers (`#ask-handle`) + +The hover-revealed handle is a compact `#ask-handle` menu row with three items: + +- `#menu-ask` — summons the input (same as ⌘⇧Space). +- `#menu-memory` — toggles the tethered Memory popover (`[data-popover="memory"]`), + which hosts the forget panel (see + delete the facts Roro knows) and the + executor-facts proposals — the trust/"what Roro remembers" surface. +- `#menu-project` — toggles the tethered Project popover + (`[data-popover="project"]`), which hosts project settings (the working repo). + +In floating mode `#controls` lives inside the `display:none` `#overlay`, so the +Memory/Project panels mount into these popover hosts instead — this menu is the +ONLY reachable home for the correction/trust loop in the pet. Only one popover is +open at a time; opening one closes the other, and while a popover is open the menu +steps aside so the two never overlap (close via the popover's `×`). Opening a +Memory/Project popover does NOT poke or pet the cat — opening a utility surface is +not affection. + +### The input bubble (`#floating-ask` / `#ask-input`) + +The summoned input is the pure `askMachine`'s `expanded` state rendered as a +tethered bubble that blooms beside the cat. The `askMachine` (collapsed → +expanded → tasked) is unchanged; only its rendering shell moved from a fixed pill +to a stage island. Escape dismisses it; submitting a task advances to `tasked`. + +### The WorkingChip + Stop (`#working-chip` / `#working-stop`) + +While a task runs, a slim cat-tethered chip shows a live activity verb (from +`captions.summarizeEvent`, the same mapping the dev timeline uses) plus an inline +Stop. The Stop arms the instant a turn is accepted (ahead of `run.started`), and +its no-id → targeted cancel contract is unchanged. The chip is visible only for a +live, armed task. + +### The AlertCard (`#floating-error`) + +Failures render as a titled, dismissible card rather than a wall of red text. A +pure classifier (`errorCopy.ts` `classifyError`) turns the raw `run.failed` error +into a title + body: a blocked destructive command outside the workspace reads as +"Roro paused — that touches files outside the project" instead of raw executor +output. A dismissed failure is not re-raised for the rest of that turn. A +successful turn shows NO lingering "Done." banner (restraint / never-needy) — a +completed turn is conveyed by the cat's own animation. + +### The SpeechBubble + memory receipt (`#roro-speech`) + +The cat's answer/narration renders in a tethered speech bubble +(`reactions/speechBubble.ts`), fed by the same CaptionSink seam that feeds the +dev caption panel. When — and only when — memory was actually RECALLED during the +turn (a same-run `status` reporting a recalled fact OR episode, `factCount>0 || +episodeCount>0`), the bubble appends a subtle, truthful receipt: "used what I +remember about your setup" (`MEMORY_RECEIPT_TEXT`). (It attests recall, not that a +specific fact provably shaped the answer — it never claims more than the pipeline +can verify.) The gate is strict: the receipt attaches ONLY +to `run.completed.finalText` of a run in which memory was actually recalled (same +runId). A turn that recalled nothing, and a bare no-payload success, show no +receipt — claiming recall on every turn would be a lie that erodes trust. + +## The legacy dev window + +The full dev window (`RORO_FLOATING_WINDOW=0`) keeps the larger prompt, captions, +memory panel, and action timeline (`#overlay`, `#topbar`, `#prompt-form`) visible +for debugging. In that mode the Memory/Project panels stay in `#controls`; the +speech bubble is not mounted. `#roro-stage` and the floating Ask shell are still +created (the input machinery is mode-agnostic), but the tethered surfaces are +styled/visible only under `body.floating-window`. ## Turn Contract @@ -38,17 +169,25 @@ than parsing executor-specific output. - Do not ship visible controls for features that are cut from v0. - If memory is unavailable, show a keychain/memory diagnostic instead of silently storing plaintext. +- The memory receipt is truthful by construction: it appears only when memory was + actually recalled that turn (a same-run status reporting a recalled fact or + episode) and attaches to the run's own final answer — never as decoration, and + never claiming more than "memory was recalled." ## Gesture Design Laws (hard-won — see `LESSONS.md` "Interaction") - Disambiguate gestures by **surface + button + state, never by timing windows**. - Adding a verb means adding a menu item, never another timing threshold. + Adding a verb means adding a menu item (the Ask·Memory·Project handle), never + another timing threshold. The ~320ms hover dwell only REVEALS an affordance; it + is never how a gesture's meaning is decided. - An action's accidental-trigger probability must be **inversely proportional to its cost/irreversibility**. - The cat body is **always pettable and never punishes**: petting is safe in - every state and can never trigger an expensive or destructive action. -- Cursor gaze must **never wake the cat** — gaze follows the cursor, but only - real interactions (pet, talk, task) reset the activity/sleep timer. + every state and can never trigger an expensive or destructive action, and no + other verb is overloaded onto the cat's tap. +- Cursor gaze and the hover-reveal must **never wake the cat** — gaze follows the + cursor and the menu can fade in over a sleeping cat, but only real interactions + (pet, summon/talk, task) reset the activity/sleep timer. The older exploratory interaction research was deleted 2026-07-01 (git history, `11a40f4` `docs/superpowers/`); anything still governing was reconciled into diff --git a/docs/PRODUCT_PLAN.md b/docs/PRODUCT_PLAN.md index 25bfb7a..c3a10d1 100644 --- a/docs/PRODUCT_PLAN.md +++ b/docs/PRODUCT_PLAN.md @@ -112,8 +112,8 @@ Current interaction law: - tap or hold the cat: pet / affection animation - drag the cat: move the floating window -- right-click or `M`: mute / unmute -- floating Ask pill or shortcut: give Roro a task +- (no mute control in the current app: mute/`M`/right-click lived with the on-device voice stack, now extracted to `packages/voice`; `window.ts` has no `RORO_*_VOICE` reader, and the packaged onboarding smoke asserts Mute is hidden. A future voice reintegration restores it.) +- summon the input (⌘⇧Space, or hover the cat → the Ask·Memory·Project menu): give Roro a task - hover: Roro looks at the cursor - long idle: Roro chooses an autonomous idle behavior diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index fa304be..9f786bb 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -93,7 +93,7 @@ npm run verify:packaged-real-codex # opt-in; requires your real authenticated/co also installs a temporary unlocked user keychain for the run, then restores the original keychain defaults; this keeps the smoke deterministic while still exercising packaged `safeStorage` on the product `turnRun` path. -The smoke asserts the renderer loads from `file://.../Roro.app/Contents/Resources/app.asar`, the default typed surface +The smoke asserts the renderer loads from `file://.../Roro.app/Contents/Resources/app.asar`, the framed dev typed surface (it runs `RORO_FLOATING_WINDOW=0`, not the default floating pet) is active, no debug/private bridges are exposed, `getWorkdirConfig()` hydrates the persisted project, brain readiness is green, the public `getExecutorReadiness()` bridge resolves the fake Codex override, a typed submit reaches public `turnRun`, the action stream includes a memory status beat, fake Codex starts and completes a run, a file is written @@ -249,8 +249,8 @@ narration containing that value. `npm run verify:packaged-natural-memory-turn` also keeps live Ollama enabled, teaches a stated preference through packaged `turnRun`, waits for the extracted profile fact, relaunches, asks about it through a later turn, and asserts the floating Ask shows NO success banner after recall (the receipt state is empty) while a memory-status beat proves the -recall used memory. The natural-language packaged smoke uses the floating Ask smoke hook to inspect receipt state, but it -does not launch the packaged app in floating-window mode or prove the failure receipt is visibly rendered; use +recall used memory. The natural-language packaged smoke launches the default floating window and inspects receipt STATE via +the floating Ask smoke hook, but it does not prove the receipt is visibly rendered on screen; use `npm run verify:floating-live-turn` for the visible floating Ask path. The live mode proves packaged same-build encrypted recall can feed a live turn/narration after relaunch. It does **not** @@ -407,7 +407,7 @@ harness. The harness injects the same `run.started` / `run.failed` / `runEnd` ev would deliver, without starting a real coding agent or enabling the debug bridge. The smoke asserts the rendered DOM **and computed CSS visibility**, then writes `docs/verification/floating-ask.png`. It is opt-in (needs a display + a vite build) and not in CI. Checks: `#floating-ask` exists + starts -`collapsed` (a tethered stage island, not an always-visible pill); the Stop control (`#working-stop`) exists +`collapsed` (a tethered stage island summoned on demand, not standing chrome); the Stop control (`#working-stop`) exists and is not `armed`; summoning (⌘⇧Space, or the `#menu-ask` item in the hover-revealed `#ask-handle` menu) → `expanded` with the input actually visible; the memory profile bridge responds before teardown; Escape → `collapsed`; smoke submit → `tasked` with the trimmed task text retained and the `#working-chip` (verb + Stop) visible before `run.started`; diff --git a/docs/plans/floating-redesign.md b/docs/plans/floating-redesign.md index a0a18d3..a5897f3 100644 --- a/docs/plans/floating-redesign.md +++ b/docs/plans/floating-redesign.md @@ -31,8 +31,9 @@ memory/job is the payload. × ~360–440 tall — final size pinned in S1 against the cat sprite + tether math); drag = move the window (existing path, unchanged). The ~80% dead space passes clicks through to the app underneath. 5. **Truthful memory receipt.** On a successful turn, a speech bubble may say "used what I remember about - your setup" ONLY when a recalled fact demonstrably shaped the turn — never on every turn (that would be - a lie that erodes trust). Bare no-payload success shows nothing (restraint; matches today's no-Done- + your setup" ONLY when memory was actually recalled that turn (a same-run status with a recalled fact or + episode; it attests recall, not that a fact provably shaped the answer) — never on every turn (that would + be a lie that erodes trust). Bare no-payload success shows nothing (restraint; matches today's no-Done- banner behavior). ## What stays FROZEN (this is renderer + window chrome only) @@ -63,11 +64,12 @@ memory/job is the payload. - SpeechBubble (net-new; CaptionSink) — the cat's answer/narration + the truthful receipt. - AskPopover — re-skins askMachine 'expanded' into a tethered input. - WorkingChip+Stop — re-skins 'tasked' + #floating-stop into a slim side chip with a live activity verb - (activityForEvent) + inline Stop (unchanged armStop/disarmStop + cancel contract). + (reuses captions.summarizeEvent) + inline Stop (unchanged armStop/disarmStop + cancel contract). - AlertCard — re-skins the error path of #floating-error into a titled, dismissible card. -- **`floatingAsk.ts` orchestration lifts into a DOM-free controller** driven by a StageViews port; the stage - supplies the DOM. (Renderer-arch hat's askController.ts pattern — keeps the pure lifecycle, swaps the - shell.) The 444-line floatingAsk.test.ts LIFECYCLE assertions survive verbatim; only selector/text +- **`floatingAsk.ts` keeps its DOM shell** (the planned DOM-free controller + StageViews port was NOT needed + — SHIPPED simpler: `mountFloatingAsk` still builds the surfaces directly and mounts them into `#roro-stage` + via `mountIsland`, and `askMachine.ts` stays pure). The 444-line floatingAsk.test.ts LIFECYCLE assertions + survive verbatim; only selector/text assertions change. ## New architecture (main / window / click-through) @@ -121,11 +123,12 @@ depend on them). - **S3 — Stage + tether positioner + WorkingChip+Stop.** The #roro-stage root + tether.ts + the side chip. - **S4 — AskPopover + hover-handle summon + Ask·Memory·Project menu.** Re-skin askMachine into the tethered input; the hover-handle + menu; mount forgetPanel/proposals/projectSettings under the menu (their home in - floating mode). Delete the parked pill + #floating-ask/#floating-stop/#floating-error. Rewrite the - floatingAsk jsdom suite (lifecycle survives). + floating mode). Delete the parked pill (#ask-pill); rename #floating-stop → #working-stop (S3); + #floating-ask + #floating-error KEEP their ids but MIGRATE onto the stage as tethered islands (not deleted). + Rewrite the floatingAsk jsdom suite (lifecycle survives). - **S5 — SpeechBubble + truthful memory receipt.** The CaptionSink SpeechBubble; auto-fade; the - fact-shaped-the-turn gating for the "used memory" line. -- **S6 — Reconcile.** Rewrite the LOCKED INTERACTION.md (drop "the Ask pill"; document pure-cat-at-rest, + recall gating (same-run recall status + run.completed.finalText) for the "used memory" line. +- **S6 — Reconcile.** Rewrite the LOCKED INTERACTION.md (drop the old standing input chrome; document pure-cat-at-rest, tap=pet-preserved, hover-handle summon+menu, tethered reactions, click-through). Update the 3 floating CDP smokes (geometry: new size + click-through; ask/live-turn: selectors + the smoke hook). Full verify sweep + memory update. diff --git a/docs/plans/paw-on-the-pixel-HANDOFF.md b/docs/plans/paw-on-the-pixel-HANDOFF.md index 58a0312..b397eba 100644 --- a/docs/plans/paw-on-the-pixel-HANDOFF.md +++ b/docs/plans/paw-on-the-pixel-HANDOFF.md @@ -62,7 +62,7 @@ nohup env RORO_DEBUG_PORT=9223 npm start > /tmp/roro.log 2>&1 & disown ``` Scratchpad drivers (in `…/scratchpad/`, session-local — recreate if gone): -- `roro-cdp.mjs eval ""` — eval in the pet renderer (click the ask pill, set `#ask-input`, submit `#floating-ask`). +- `roro-cdp.mjs eval ""` — eval in the pet renderer (summon the input via ⌘⇧Space or `#menu-ask`, set `#ask-input`, submit `#floating-ask`). - `roro-paw-test.mjs ""` — drives an ask, captures the event stream, then reads the overlay window's `window.__roroLastPoint` (drawn point) + computes the global screen coord. **This is the main verification tool** — its coordinate output is how "(2479,101)" was confirmed. - `roro-overlay-shot.mjs` / `overlay-standalone.js` — draw the paw at a known point to inspect the render (note the screencapture caveat above). diff --git a/docs/plans/paw-on-the-pixel.md b/docs/plans/paw-on-the-pixel.md index 141ce3b..e144c2a 100644 --- a/docs/plans/paw-on-the-pixel.md +++ b/docs/plans/paw-on-the-pixel.md @@ -6,7 +6,7 @@ **Ask roro about something on your screen → it captures the screen locally → qwen2.5-VL grounds your phrase to a pixel → a paw + ring lands on that exact pixel while the cat looks toward it → roro says the short answer.** Point to ask, point to answer. ## 1. The flow (user experience) -1. **Summon + ask.** You hit ⌘⇧Space (or click the pill) and say/type *"where's the merge button?"* / *"what's wrong here?"* / *"what is this?"*. (Reuses the existing Ask input → `turnRun`.) +1. **Summon + ask.** You hit ⌘⇧Space (or hover the cat → the Ask menu) and say/type *"where's the merge button?"* / *"what's wrong here?"* / *"what is this?"*. (Reuses the existing Ask input → `turnRun`.) 2. **Consent tell.** roro shows its existing *"Taking one screen snapshot."* beat and its eyes open — one metered glance, never continuous. (Reuses `SCREEN_CAPTURE_STATUS_TEXT`.) 3. **Ground.** Locally, qwen2.5-VL locates your phrase → a tight box (or "I can't find that"). 4. **Point.** A translucent paw + a soft ring lands on the exact pixel over whatever app is there; the cat (in its own corner) turns to look toward it. If grounding is uncertain, the ring is **wide** ("around here") — never a confident wrong paw. It auto-fades after a few seconds; it never blocks or captures clicks. diff --git a/scripts/smoke-floating-menu.mjs b/scripts/smoke-floating-menu.mjs index 482a7d1..8cfa407 100644 --- a/scripts/smoke-floating-menu.mjs +++ b/scripts/smoke-floating-menu.mjs @@ -142,6 +142,15 @@ try { const rect = el.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; })()`); + // Is (the centre of) `selector` inside the click-through interactive region? Proves the REAL hit path — a + // dead-space click passes through, but a click on this element is caught by the window (decideAt → true). + const hitTested = (selector) => evalJs(`(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el || !window.__roroClickThrough) return false; + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return false; + return window.__roroClickThrough.decideAt(r.left + r.width / 2, r.top + r.height / 2); + })()`); const shot = async (name) => { mkdirSync(SHOT_DIR, { recursive: true }); const path = `${SHOT_DIR}/floating-menu-${name}.png`; @@ -224,12 +233,23 @@ try { check('(b) the Memory panel button is hit-tested interactive (decideAt → true)', memHit === true); await shot('b-memory'); - // ---- (c) click Project → the project settings panel appears tethered ---- - console.log('[smoke] (c) Project opens the working-repo surface…'); - await evalJs(`document.getElementById('menu-project').click()`); // opening Project closes Memory + // ---- (c) close Memory, RE-REVEAL the menu, then open Project via the VISIBLE + hit-tested item ---- + // The menu steps aside while a popover is open (setHandleShown(false)), so clicking #menu-project now would + // be clicking a HIDDEN element — passing without proving the real path. Instead: dismiss Memory, hover the + // cat to bring the menu back, and assert Project is VISIBLE + inside the click-through region before clicking. + console.log('[smoke] (c) re-reveal the menu, then Project opens the working-repo surface…'); + await evalJs(`document.querySelector('[data-popover="memory"] .roro-popover__dismiss').click()`); + await sleep(200); + check('(c) dismissing Memory hid the Memory popover', !(await isVisible('[data-popover="memory"]'))); + await hoverCat(); // re-reveal the resting menu via the real hover-dwell (never wakes the cat) + check('(c) re-hover re-revealed the menu', await isVisible('#ask-handle')); + check('(c) #menu-project is visible in the revealed menu', await isVisible('#menu-project')); + check('(c) #menu-project is hit-tested interactive (decideAt → true)', (await hitTested('#menu-project')) === true); + check('(c) re-revealing the menu did NOT wake the cat', + (await evalJs(`window.__roroCounts.poke`)) === 0 && (await evalJs(`window.__roroCounts.pet`)) === 0); + await evalJs(`document.getElementById('menu-project').click()`); await sleep(400); check('(c) the Project popover is visible', await isVisible('[data-popover="project"]')); - check('(c) opening Project closed the Memory popover', !(await isVisible('[data-popover="memory"]'))); check('(c) project settings is hosted inside the Project popover', await evalJs( `!!document.querySelector('[data-popover="project"] #project-settings-toggle') && !!document.querySelector('[data-popover="project"] #project-settings-panel')`)); @@ -242,10 +262,19 @@ try { check('(c) the Project panel button is hit-tested interactive (decideAt → true)', projHit === true); await shot('c-project'); - // ---- (d) click Ask → the input bubble summons ---- - console.log('[smoke] (d) Ask summons the input bubble…'); + // ---- (d) close Project, RE-REVEAL the menu, then summon via the VISIBLE + hit-tested Ask item ---- + // Same hidden-item trap as (c): closeProject() does not re-show the handle, so we dismiss Project, hover to + // bring the menu back, and assert Ask is VISIBLE + hit-tested before clicking it. + console.log('[smoke] (d) re-reveal the menu, then Ask summons the input bubble…'); await evalJs(`document.querySelector('[data-popover="project"] .roro-popover__dismiss').click()`); // close Project - await sleep(150); + await sleep(200); + check('(d) dismissing Project hid the Project popover', !(await isVisible('[data-popover="project"]'))); + await hoverCat(); // re-reveal the resting menu + check('(d) re-hover re-revealed the menu', await isVisible('#ask-handle')); + check('(d) #menu-ask is visible in the revealed menu', await isVisible('#menu-ask')); + check('(d) #menu-ask is hit-tested interactive (decideAt → true)', (await hitTested('#menu-ask')) === true); + check('(d) re-revealing the menu did NOT wake the cat (still 0 pokes before summon)', + (await evalJs(`window.__roroCounts.poke`)) === 0 && (await evalJs(`window.__roroCounts.pet`)) === 0); await evalJs(`document.getElementById('menu-ask').click()`); await sleep(300); check('(d) #floating-ask becomes expanded on Ask', await evalJs(`document.getElementById('floating-ask').classList.contains('expanded')`)); diff --git a/scripts/smoke-packaged-epipe.mjs b/scripts/smoke-packaged-epipe.mjs index 24e3b35..012674f 100644 --- a/scripts/smoke-packaged-epipe.mjs +++ b/scripts/smoke-packaged-epipe.mjs @@ -220,12 +220,17 @@ async function waitForRendererDom(cdp, child) { hasTopbar: !!document.querySelector('#topbar'), hasPromptForm: !!document.querySelector('#prompt-form'), bootstrapStatusBridge: typeof window.companion?.getBootstrapStatus, + // "Not blank" proof post-resting-purity: the renderer creates #roro-stage in JS (it is NOT in + // index.html), so its presence means the renderer actually EXECUTED + built its DOM. #topbar/ + // #prompt-form are static index.html and the bootstrap bridge is a preload seam — all three exist + // before bootstrap() runs, so gating on them alone raced the check (green off a blank frame). + stageMounted: !!document.getElementById('roro-stage'), }; })()`); - if (dom.hasBody && dom.hasTopbar && dom.hasPromptForm && dom.bootstrapStatusBridge === 'function') return dom; + if (dom.hasBody && dom.hasTopbar && dom.hasPromptForm && dom.stageMounted && dom.bootstrapStatusBridge === 'function') return dom; lastError = `readyState=${dom.readyState}, hasBody=${dom.hasBody}, hasTopbar=${dom.hasTopbar}, ` + - `hasPromptForm=${dom.hasPromptForm}, bootstrapStatusBridge=${dom.bootstrapStatusBridge}`; + `hasPromptForm=${dom.hasPromptForm}, stageMounted=${dom.stageMounted}, bootstrapStatusBridge=${dom.bootstrapStatusBridge}`; } catch (err) { lastError = err.message; } @@ -348,7 +353,7 @@ try { 'renderer URL is packaged file:// app.asar', dom.href.startsWith('file://') && dom.href.includes('/Roro.app/Contents/Resources/app.asar/'), ); - check('renderer body is not blank', typeof dom.bodyText === 'string' && dom.bodyText.includes('Roro')); + check('renderer executed + built its DOM (not a blank page)', dom.stageMounted === true); check('#topbar exists', dom.hasTopbar); check('#prompt-form exists', dom.hasPromptForm); check( diff --git a/scripts/smoke-packaged-first-task.mjs b/scripts/smoke-packaged-first-task.mjs index b48ba2e..1da576a 100644 --- a/scripts/smoke-packaged-first-task.mjs +++ b/scripts/smoke-packaged-first-task.mjs @@ -589,6 +589,9 @@ try { `(() => { if (!document.getElementById('prompt-form')) return false; if (!document.getElementById('workdir-banner')) return false; + // #roro-stage is created by the renderer in JS (not index.html); it mounts BEFORE the workdir banner, + // so gating on it proves the renderer executed before we snapshot (no blank-frame race). + if (!document.getElementById('roro-stage')) return false; if (document.body?.classList.contains('floating-window')) return false; return window.companion?.getWorkdirConfig?.() .then((config) => config?.source === 'config' && config?.workdir === ${JSON.stringify(projectDir)}) @@ -612,6 +615,7 @@ try { title: document.title, href: location.href, bodyText: document.body?.innerText?.slice(0, 800) ?? '', + stageMounted: !!document.getElementById('roro-stage'), bodyFloating: document.body?.classList.contains('floating-window') ?? false, promptVisible: visible('#prompt-form') && visible('#prompt-input') && visible('#send-btn'), workdirHidden: document.querySelector('#workdir-banner')?.hidden ?? null, @@ -637,8 +641,8 @@ try { initial.href.startsWith('file://') && initial.href.includes('/Roro.app/Contents/Resources/app.asar/'), JSON.stringify(initial), ); - check('renderer body is not blank', initial.bodyText.includes('Roro'), JSON.stringify(initial)); - check('packaged first-task smoke uses the default typed surface', initial.bodyFloating === false, JSON.stringify(initial)); + check('renderer executed + built its DOM (not a blank page)', initial.stageMounted === true, JSON.stringify(initial)); + check('packaged first-task smoke uses the framed dev typed surface (RORO_FLOATING_WINDOW=0, not the default floating pet)', initial.bodyFloating === false, JSON.stringify(initial)); check('typed prompt is visibly rendered and ready', initial.promptVisible && initial.sendDisabled === false && initial.cancelDisabled === true, JSON.stringify(initial)); check('typed prompt placeholder is product-facing', /ask roro to work/i.test(initial.placeholder), JSON.stringify(initial)); check('persisted project hides first-run workdir banner', initial.workdirHidden === true && initial.workdirVisible === false, JSON.stringify(initial)); diff --git a/scripts/smoke-packaged-model-setup.mjs b/scripts/smoke-packaged-model-setup.mjs index 2599096..4176dac 100644 --- a/scripts/smoke-packaged-model-setup.mjs +++ b/scripts/smoke-packaged-model-setup.mjs @@ -442,11 +442,14 @@ try { const banner = document.getElementById('bootstrap-banner'); const get = document.getElementById('bootstrap-get-ollama'); const refresh = document.getElementById('bootstrap-refresh'); - if (!banner || banner.hidden || !get || !refresh) return false; + // #roro-stage is created by the renderer in JS (not index.html); the bootstrap banner mounts AFTER it, + // so gating on both proves the renderer executed before we snapshot (no blank-frame race). + if (!banner || banner.hidden || !get || !refresh || !document.getElementById('roro-stage')) return false; const status = window.companion?.getBootstrapStatus; return { href: location.href, bodyText: document.body.innerText.slice(0, 1000), + stageMounted: !!document.getElementById('roro-stage'), bannerText: banner.textContent ?? '', getText: get.textContent ?? '', refreshText: refresh.textContent ?? '', @@ -468,7 +471,7 @@ try { down.href.startsWith('file://') && down.href.includes('/Roro.app/Contents/Resources/app.asar/'), JSON.stringify(down), ); - check('renderer body is not blank', down.bodyText.includes('Roro'), JSON.stringify(down)); + check('renderer executed + built its DOM (not a blank page)', down.stageMounted === true, JSON.stringify(down)); check('Ollama-unreachable banner is visible', /reachable yet/i.test(down.bannerText), down.bannerText); check('Get Ollama button is visible when daemon is down', /Get Ollama/i.test(down.getText), JSON.stringify(down)); check('Recheck button is visible when daemon is down', /check again/i.test(down.refreshText), JSON.stringify(down)); diff --git a/scripts/smoke-packaged-onboarding.mjs b/scripts/smoke-packaged-onboarding.mjs index 3fe4253..bc053d9 100644 --- a/scripts/smoke-packaged-onboarding.mjs +++ b/scripts/smoke-packaged-onboarding.mjs @@ -57,6 +57,13 @@ function smokeEnv(home, port) { delete env.RORO_ALLOW_CWD; delete env.RORO_DB_DIR; delete env.DOTENV_CONFIG_PATH; + // Exercise the framed window (floating is the product default now). This smoke asserts the framed + // onboarding UI — the Choose Project banner + Settings (#project-settings-toggle) visible in #controls. + // In the default floating window #controls lives inside the display:none #overlay and Settings moves into + // the hover-revealed Project popover (S4b), so those elements are present-but-hidden at rest. The floating + // pet's own surfaces are covered by the dedicated on-screen smokes (smoke-floating-*). Matches the + // sibling packaged UI smokes (model-setup, first-task). + env.RORO_FLOATING_WINDOW = '0'; return stripV0DeferredEnv(env); } @@ -214,6 +221,10 @@ async function inspectApp({ home, cwd, userDataDir, label }) { title: document.title, href: location.href, bodyText: document.body.innerText.slice(0, 800), + // "Not blank" proof post-resting-purity: #roro-stage is created by the renderer in JS (not + // index.html), so its presence means the renderer executed + built its DOM. It mounts BEFORE the + // workdir banner + project-settings toggle asserted below, so it is always present when they are. + stageMounted: !!document.getElementById('roro-stage'), hasTopbar: !!document.querySelector('#topbar'), poweredText: document.querySelector('#powered')?.textContent ?? '', hasOverlay: !!document.querySelector('#overlay'), @@ -317,7 +328,7 @@ try { 'packaged renderer URL is file:// app.asar', fresh.dom.href.startsWith('file://') && fresh.dom.href.includes('/Roro.app/Contents/Resources/app.asar/'), ); - check('renderer body is not blank', fresh.dom.bodyText.includes('Roro')); + check('renderer executed + built its DOM (not a blank page)', fresh.dom.stageMounted === true); check('#topbar exists', fresh.dom.hasTopbar); check('topbar uses product-local copy', /private local brain and memory/i.test(fresh.dom.poweredText)); check('topbar hides implementation names', !/ollama|pglite|pgvector|qwen/i.test(fresh.dom.poweredText)); @@ -364,7 +375,7 @@ try { 'configured packaged renderer URL is file:// app.asar', configured.dom.href.startsWith('file://') && configured.dom.href.includes('/Roro.app/Contents/Resources/app.asar/'), ); - check('configured renderer body is not blank', configured.dom.bodyText.includes('Roro')); + check('configured renderer executed + built its DOM (not a blank page)', configured.dom.stageMounted === true); check('configured bridge resolves', configured.bridge.ok); check('configured bridge reports source=config', configured.bridge.cfg?.source === 'config'); check('configured bridge returns persisted workdir', configured.bridge.cfg?.workdir === scratchRepo); diff --git a/src/index.css b/src/index.css index 6cd978c..705b52d 100644 --- a/src/index.css +++ b/src/index.css @@ -552,8 +552,8 @@ body.floating-window #floating-error.success { /* ---- SpeechBubble (S5): the cat voicing its answer/narration as a tethered #roro-stage island, blooming FROM the cat like the other reactions. position:absolute + left/top/transform-origin come from the tether placer; these rules carry only the look, the show/hide, the scale-in bloom, and the fade-out. The - `.roro-speech__receipt` row is the truthful memory receipt — subtle, secondary, only present when a - recalled fact shaped the turn. ---- */ + `.roro-speech__receipt` row is the truthful memory receipt — subtle, secondary, only present when memory + was actually recalled that turn. ---- */ #roro-speech { display: none; } diff --git a/src/renderer/ask/floatingAsk.test.ts b/src/renderer/ask/floatingAsk.test.ts index 173dc92..f84dcad 100644 --- a/src/renderer/ask/floatingAsk.test.ts +++ b/src/renderer/ask/floatingAsk.test.ts @@ -210,7 +210,7 @@ describe('floatingAsk shell (jsdom)', () => { expect(h.error.classList.contains('neutral')).toBe(true); }); - it('run.started arms the Stop pill; the universal runEnd collapses the Ask', async () => { + it('run.started arms the Stop control; the universal runEnd collapses the Ask', async () => { h.summon(); h.input.value = 'do it'; submit(h.form); diff --git a/src/renderer/bootstrap.ts b/src/renderer/bootstrap.ts index b504ad5..84eae67 100644 --- a/src/renderer/bootstrap.ts +++ b/src/renderer/bootstrap.ts @@ -7,7 +7,7 @@ // 4. mount the typed prompt + floating Ask surfaces // // Voice is CUT from v0 and lives ENTIRELY in packages/voice (outside the app's dependency graph). -// The typed prompt path is the only turn surface; the re-integration seam is src/shared/voiceBackend.ts +// Typed input (the dev prompt + the floating Ask) is the only turn surface — voice is cut; the re-integration seam is src/shared/voiceBackend.ts // (see packages/voice/README.md for how voice mounts back in). import { loadConfig } from './config'; @@ -63,8 +63,9 @@ export async function bootstrap(): Promise { const captions = new CaptionPanel(); const timeline = new ActionTimeline(); // S5: the cat's SPEECH BUBBLE + truthful memory receipt. A tethered #roro-stage island that voices the - // assistant text (the SAME CaptionSink seam that feeds the dev CaptionPanel) and, when a recalled fact - // demonstrably shaped the turn, appends a subtle truthful "used what I remember…" receipt. FLOATING mode + // assistant text (the SAME CaptionSink seam that feeds the dev CaptionPanel) and, when memory was actually + // recalled that turn (same-run status + run.completed.finalText), appends a subtle truthful "used what I + // remember…" receipt. FLOATING mode // only; COMPOSED with the CaptionPanel so ONE subscribeActionEvents feed drives both (the CaptionPanel is // display:none inside #overlay in floating — harmless). In dev mode the bubble is not mounted. const speech = config.floatingWindow @@ -82,7 +83,8 @@ export async function bootstrap(): Promise { getStatus: () => getCompanion()?.getBootstrapStatus?.() ?? Promise.resolve(null), }); - // Phase B: the floating Ask input + Stop pill (the typed magic-moment surface on the cat body). + // Phase B: the floating Ask input + the tethered WorkingChip/Stop (the typed magic-moment surface, tethered + // beside the cat — NEVER on the body; the body is pet-only). // Lives outside #overlay; only visible in floating mode. Its lifecycle rides the push stream. // S4b: it also owns the hover-revealed Ask·Memory·Project menu and the two tethered popover hosts the // Memory/Project panels mount into (in floating mode) — see the mount-host split below. diff --git a/src/renderer/events/runLifecycle.ts b/src/renderer/events/runLifecycle.ts index 995f072..e562d9b 100644 --- a/src/renderer/events/runLifecycle.ts +++ b/src/renderer/events/runLifecycle.ts @@ -2,7 +2,7 @@ // // Since `turnRun` now resolves at DISPATCH (not at completion), the renderer can no longer learn // "the run finished" from the invoke promise — it must read the push stream. This pure reducer maps -// run.started/completed/failed into {status, runId, stopArmed}; the floating Stop pill and the busy +// run.started/completed/failed into {status, runId, stopArmed}; the floating Stop control (#working-stop) and the busy // pose read it. (runState.ts keeps its tiny boolean for the voice/avatar back-compat consumers.) import type { ActionEvent } from '../../shared/events'; diff --git a/src/renderer/reactions/speechBubble.ts b/src/renderer/reactions/speechBubble.ts index c459b08..bb249f9 100644 --- a/src/renderer/reactions/speechBubble.ts +++ b/src/renderer/reactions/speechBubble.ts @@ -2,7 +2,7 @@ // // The cat voicing its answer/narration as a tethered #roro-stage island beside the cat, plus founder // decision 5's strategic payload: a subtle, TRUTHFUL "used what I remember about your setup" line that -// appears ONLY when a recalled fact demonstrably shaped the turn (never on every turn — that would be a +// appears ONLY when memory was actually recalled that turn (never on every turn — that would be a // lie that erodes trust; a bare no-payload success shows nothing, matching the locked no-Done-banner // contract). It is a CaptionSink (the answer text arrives via update('assistant', …), the same seam that // feeds the dev CaptionPanel) AND turn-aware (noteEvent observes the raw stream for the memory-recall beat,