Plain Speak — implementation notes (part 4)
Part 4 of the Plain Speak page: the implementation notes for anyone working on the code — where the rewrite is computed, the shape of the output, how a failure is handled, and the tests that pin the behaviour.
What it is
This is part 4 of the Plain Speak page — the internals, for a reader who has the repository open.
Where to find it
Agent- and developer-facing: read from the code rather than from the app.
How it behaves
Limits and known gaps
Plain Speak v1 is intentionally minimal. The following are explicitly not in scope and may show up in later versions:
- No project-level or session-level overrides — only global. The daily cap is global to your Omniscio install, and the rule prompt is locked to the built-in default (not editable as of 2026-05-30 — see Prompt: locked to the built-in default). You can't configure a different rewrite style for project A vs. project B, and (as of 2026-05-14) there is no per-session opt-out — the only knobs are the global master enable and the global daily cap.
- Skipped messages are not surfaced. When Plain Speak skips a message (under 80 characters, or the defensive
ORIGINAL_ONLYbackstop fires), the original markdown renders normally with no visible "we considered rewriting this and decided not to" indicator. The skip is recorded in the decisions log but not on the message itself.
For agents
Implementation notes
For agents working in the repo:
- Pipeline dispatch.
overlay-evaluator.tsis a thin dispatcher: it lazily imports the pipeline map and callsresolvePipeline(DEFAULT_PIPELINE_ID)fromcascade/pipeline-map.ts, then delegatesrun(args)to the matchedOverlayPipeline. It no longer reads anyaiManager.overlayPipelinesetting — the per-user pipeline picker was removed (F009), so the pipeline is always the default. TheEvaluateOverlayArgs/EvaluateOverlayResultcontract lives inoverlay-evaluator-types.ts. The pipelines are a static map inpipeline-map.ts(OVERLAY_PIPELINES), NOT a self-registering registry —resolvePipelineis a plain lookup that falls back toDEFAULT_PIPELINE_IDfor an unknown/undefined id. Pipeline IDs are canonicalized incascade/pipeline-ids.ts+src/shared/ai-manager-types.ts(OVERLAY_PIPELINE_IDS,DEFAULT_PIPELINE_ID) so the Zod schema and the dispatcher share one source of truth (there is no renderer dropdown — the pipeline is a hidden fallback). - Adding a new pipeline. (1) implement
OverlayPipelinefromcascade/pipeline-types.tsin a newcascade/pipeline-<id>.tsfile; (2) add the new id tocascade/pipeline-ids.tsandOVERLAY_PIPELINE_IDSinsrc/shared/ai-manager-types.ts; (3) add one entry to theOVERLAY_PIPELINESmap incascade/pipeline-map.ts. There is no self-registration barrel and noregisterPipeline()call — the map is the single mount point. (Pipelines are a hidden fallback with no user-facing label — the settings picker was dropped on 2026-07-19.) - Reducing the transcript before generation. An
OverlayPipelinemay declare an optionalreduceRows(rows: EligibleRow[]): EligibleRow[]hook. When present, the dispatcher inoverlay-evaluator.tsapplies it toargs.transcriptRowsBEFORE invokingpipeline.run()and re-derivesargs.transcriptfrom the reduced set viaformatTranscriptFromRows(). This keeps the reducer out of every pipeline'srun()body so a pipeline can be "cascade + a different reducer" by reusingcascadePipeline.rundirectly — seepipeline-cascade-v4.ts(targetPlusLastOpreducer) for the canonical example. Reducers receive eligibility-filtered rows (system rows +metadata.systemInjectedoperator messages already dropped byselectEligibleRows()incontext-builder.ts) so no further filtering is needed inside the reducer. The defaultcascadeitself now uses ablendRowsreducer —[firstOp (original ask), lastOp (live follow-up), target (latest agent)], deduped — so the reframe doctrine can track a short follow-up ("is it merged?", "give me the link") without the whole thread burying it. It keeps BOTH the original ask AND the live follow-up, which is what distinguishes it from v4'stargetPlusLastOp(that drops the original). The dispatcher rebuilds the reduced rows as plain[operator]/[agent]lines — the format the prompt documents. - Cascade stages. The cascade lives under
cascade/: V3 draft (stage-v3-generator.ts), V7 critic (stage-v7-critic.ts), V8 regex post-processor (stage-v8-postprocess.ts— pure functions, no LLM), V9 word-count fixer (stage-v9-wordfix.ts, calls Haiku 4.5 conditionally), V10 structural guardrail (stage-v10-microchecks.ts, calls gpt-oss-20b via OpenRouter), then the section reviewer (stage-section-reviewer.ts, calls Qwen 3 32B — the round-3 winner). The reviewer is delete-only: it asks the model which OPTIONAL sections (Response / Recommended Action / Questions / TLDR) fail to earn their slot and blanks each to the(none)sentinel viareplaceSection, never rewriting surviving prose (rewriting is why round-2's draft-then-cut failed). It never cuts Latest, carries a link-guard (never cut a link-bearing section when the live ask is FOR a link), runs LAST among the LLM stages — after the structural fallback, before the jargon scrub — and is default-on with anAMC_DISABLE_OVERLAY_SECTION_REVIEWER=1kill switch. cascade-v4 inherits it (sharedrun()); qwen-single / sonnet-single do not. Invariants: plain-speak-cascade-contract.md. Stages V7/V9/V10/reviewer are best-effort: any non-abort error is logged and the prior stage's output is kept. V3 is required; if it fails, the cascade pipeline falls back to a single Qwen 3 32B call and surfaces the failure on that. The V8 stage also exportsnormalizeQuestionShape(rewrite)— a deterministic final pass that runs after V10 in the cascade and after V3 in the qwen-single pipeline. It parses the## Questionssection, drops any question that lacks ≥2 lettered options (- A. ...,- B. ...), strips orphan- Or:/ prose bullets between question units, and renumbers numbered headers. This is the pipeline-boundary backstop the QuestionWidget parser depends on so every Q renders in-widget with no stranded prose bullets — even when an upstream LLM ignores the prompt's<question_shape>rules. - Reasoning-leak structural-validation backstop (5-layer defense). Qwen 3 32B (the V3 generator and V7 critic) is a reasoning model; under token pressure the OpenRouter adapter can leak chain-of-thought into
message.contentdespitereasoning: { exclude: true }, and a 2,000–4,000-char reasoning dump fits under the overlay'sMAX_OVERLAY_CHARScap so the existing truncation gate doesn't catch it. Five coordinated layers prevent the leak from reaching the user's bubble: (1) V7 budget cap —MAX_TOKENS = 2048(down from 8192) so the model has to commit to writing the rewrite within reach instead of burning the budget on hidden reasoning; (2) V7 structural gate — everyV7CriticResultcarries arejected: booleanset whenensureLeadingHeading(trimmed)returnsnullon the trimmed reply, and the pipeline keeps the V3 draft when it fires; (3) pipeline-final backstop — after V10 returns,ensureLeadingHeading(rewrite)runs once more on the final rewrite and falls back to the V3 draft on failure (this catches any malformed output that survives V8/V9/V10 transformation); (4) V10fallbackDraftarg — when V10's own validation rejects its output it now falls back to the V3 draft, not the rewrite it was handed (which may itself be malformed); (5) renderer empty-string fallback —sanitizePlainSpeakOverlayinsrc/renderer/src/lib/sanitize-plain-speak.tsreturns''(collapsing the overlay so the original bubble shows) rather than rendering raw text if all four backend backstops somehow let malformed content through. See postmortem. - The renderer hides any "none" section — for ALL five sections, not just Response/Questions.
sanitizePlainSpeakOverlay(sanitize-plain-speak.ts) drops a section whose body is a nothing-phrase, recognising it in any format — wrapping()/[], surrounding*/_, trailing punctuation, case-insensitive — plus action-style synonyms (no action/no action needed/no action required/no further action[ needed]/nothing to do). This is the consumer of the cascade's(none)sentinel:cascade-prompts.tsinstructs the model to emit the literal token(none)for an empty body, and the section reviewer blanks a cut section to(none)(stage-section-reviewer.ts, comment: "the renderer hides(none)sections") — so the renderer MUST strip it, including the mandatoryRecommended Action/Latest/TLDR. The separate "too short to be useful" trim (<8chars) stays scoped to the optional sections, so a terse-but-realRecommended Action: Archivestill shows (short ≠ none). When every section is a nothing-phrase the card sanitizes to''and the overlay hides so the original message shows (overlay-gating contractevery-overlay-ui-gate-site/drops-nothing-phrase-sections). Locked bysanitize-plain-speak.test.ts. - View state for the Plain Speak ⇄ Original toggle lives in
src/renderer/src/stores/ai-manager-store.tson aoverlayViewByMessageId: Map<string, 'plain' | 'original'>slice withsetOverlayView/toggleOverlayView/getOverlayViewactions. Every mutation creates a newMapreference so Zustand selectors fire correctly. Default for an unknown message id is'original'(flipped 2026-07-19 — a message shows its real content first; every reader of the map uses the same'original'fallback so they can't drift). - The default-view setting stays live across renderers. The resolved default (Plain Speak vs Original for an untouched message) comes from
resolveDefaultOverlayView(rules), whererulesisuseAiManagerStore's copy ofaiManager.rulesfollows theSETTINGS_CHANGEDpush via a listener insrc/renderer/src/app/useAiManagerPushListeners.tsthat applies the purepickAiManagerFromSettingsPushthrough the store'ssetRules— so flipping Show Plain Speak first anywhere (another window, the phone/mobile-web client, the CLI, a settings import) updates every open view immediately, with no restart. Before this,rulesrefreshed only at app-mount and on a Plain Speak panel save on that same renderer, so an out-of-band change left other views on the stale default. Locked byplain-speak-overlay-gating-contract.mdai-manager-rules-store. - The toggle pill is rendered by
PlainSpeakTogglePill, which lives in its own filesrc/renderer/src/components/ui/PlainSpeakTogglePill.tsx(extracted fromMessageBubble.tsxon 2026-05-30) and is imported intosrc/renderer/src/components/ui/MessageBubble/MessageBubble.tsx. It sits in the agent message header row as a direct child of the same flex container that holds the timestamp (noml-autowrapper — that would fling it to the far-right edge on desktop, visually disconnected from the time). Gated on!isOperator && overlayTextso it only renders for agent messages that have a Plain Speak rewrite applied. Inside the button, the<span>carrying the "Plain Speak" / "Original" label has classhidden md:inlineso the text only shows on desktop; mobile renders icon-only (theArrowLeftRightSVG stays visible on both viewports). The accent-tinted background andaria-label/aria-pressedattributes apply on both viewports so the pill is still visibly clickable on mobile and a11y-equivalent. Reads/writes the store via auseCallback-memoized primitive selector keyed onmessageId(this preservesMessageBubble'sReact.memo). The pill's class string is thePLAIN_SPEAK_TOGGLE_PILLconstant insrc/renderer/src/lib/styles.ts.AutoResponseBadge(operator-message-only, mutually exclusive with the Plain Speak pill) keeps itsml-autoright-aligned wrapper as a separate sibling — that one's right-alignment is intentional and out of scope for the Plain Speak pill placement contract. - Right-click → Plain Speak settings.
PlainSpeakTogglePill'sonContextMenuhandlerpreventDefault()s the native menu,stopPropagation()s so the event never bubbles to an ancestor row/panel click handler, and opens a one-item menu built from the sharedContextMenuShell.tsxprimitive (a portal + keyboard/viewport-clamp shell extracted fromImageContextMenu.tsxso both menus share it). The single Plain Speak settings item callsnavigateToSettingsProject({ section: 'plain-speak' }), closing the menu before navigating (the panel unmounts on nav, and a bubbled click could otherwise re-activate the session and undo the navigation). Right-click never toggles the view; left-click and the V hotkey are unchanged. Pinned bytests/unit/components/plain-speak-toggle-pill.test.tsx(left-click toggles / right-click does not, native-menu suppression, deep-link + menu-close, non-bubbling). - Dwell-preservation hook (now a dormant no-op — default view is
'original'as of 2026-07-19, so there is no plain-first swap to suppress; kept as belt-and-suspenders).src/renderer/src/hooks/usePreSetOverlayView.tsis wired into the agent branch ofMessageBubbleand subscribes touseAiManagerStoreto catch the null→set transition ofoverlayByMessageId.get(messageId). The effect early-returns if!isAgent || overlayText !== null(so it only watches the first arrival on agent rows) and the subscriber inside it short-circuits unless: (a) the new state has a defined overlay AND the prev state did not (the null→set edge), (b)overlayViewByMessageId.has(messageId)is false (no explicit user choice), (c)useSessionStore.getState().activeSessionId === sessionId, (d)document.visibilityState === 'visible', (e) the bubble'sgetBoundingClientRect()intersects the viewport (width+height > 0, top<viewportH, bottom>0, left<viewportW, right>0). Only when all five hold does it callsetOverlayView(messageId, 'original'). The hook returns aReact.RefObject<HTMLDivElement>thatMessageBubblemerges with the parent-passedsummaryRefvia auseCallback'd callback ref (setBubbleRef) so a single DOM node feeds both refs without refactoringsummaryRefcall sites. The subscriber fires synchronously inside Zustand'ssetStatebetween the store mutation and React's commit, so the per-message view is already'original'whenPlainEnglishOverlayreads it — no one-frame Plain Speak flash. Test file:tests/unit/hooks/use-pre-set-overlay-view.test.tsx(10 scenarios — happy path + each gate independently + post-arrival user toggle + unmount cleanup). - The body component is
src/renderer/src/features/sessions/PlainEnglishOverlay.tsxand is body-only — no toggle button lives inside the overlay. It reads the current view fromuseAiManagerStore.overlayViewByMessageIdvia the sameuseCallbackselector pattern and swapstext(rewrite) vsoriginalContent(raw markdown). The overlay is a transparent pass-through on the rendering axis for BOTH views (2026-05-26 Rev 31): it accepts an optionaloverviewMode?: 'inline' | 'summary-first' | 'external-pill' | 'prose-only'prop (default'external-pill') AND an optionalliteContenthandle, and forwards both verbatim toAgentMarkdownregardless of whether the user has flipped to Plain or Original.MessageBubblepasses the sameoverviewMode+liteContentthe non-overlay branch uses, so flipping to Original matches the regular agent-message render path exactly: settled turns hide the per-action "> N actions —" pills AND the intermediate progress prose (already summarised by the siblingTotalsPillBubble), streaming turns keep them inline. The Plain Speak rewrite text itself contains no activity segments, so the props are no-ops for the'plain'view. Prior to Rev 31, the overlay overrodeoverviewModeto'inline'+liteContenttoundefinedon the Original branch as defensive code; that override caused intermediate "thinking prods" prose to leak into Original view — see postmortem § "2026-05-26 — Bug D". - The V hotkey is wired in
src/renderer/src/hooks/keyboard-shortcuts/conversation.ts(handleTogglePlainSpeak, registered in that file'sconversationHandlersmap). It serves TWO surfaces, in this order: first an open INBOX ALERT CARD, whose view is keyed on the ALERT's own id (selectActiveInboxAlertId+alertPlainSpeakCard, the same gate the card's own pill is drawn from) — an alert selection puts no session on screen, so the transcript walk below can never reach it, and before this branch existed V was a silent no-op on every agent-raised inbox card; then the active session'sconversationCache, walked from the end backward, looking for the latest message whosesource === 'agent'AND whose id is inoverlayByMessageId. When found, it callstoggleOverlayView(id)and returns; if no eligible message exists (e.g. a streaming turn with no overlay yet, or a session with no rewrites), V is a silent no-op. The action is registered insrc/shared/keybindings.tsastogglePlainSpeakwithdefaultBindings: ['V'],passthroughInputs: false(won't fire while typing in the composer or any other input/textarea/contenteditable), andcategory: 'global'so it surfaces in the?cheat-sheet alongside P, H, T. - The gating + enable-flag state machine is the test-locked single source of truth in
plain-speak-overlay-gating-contract.md— the resolved-enable rule (overlay.enabled && per-session toggle, never the Inbox Pilot router flag and never a legacy pause flag), the overlay-pending gate lifecycle, the skip-on-error bad-set, the 5-min backstop, and the narrowneeds_you-only suppression, each invariant naming its enforcing test. READ it before changing any overlay gate or enable flag. - The "while rewriting" gate is an in-memory
Set<sessionId>insrc/main/services/ai-manager/overlay-pending.ts. Set-based on purpose: a DB-based "is overlay in_flight?" gate lost a sync-vs-microtask race (see comment block at the top of the file). The gate is marked synchronously insideevaluate(...)BEFORE the firstawaitso it's already set whennotifyNeedsYouruns in the same tick asnotifyTurnEnded. - Status-indicator override (sidebar dot, panel border-left, StatusDot label/tooltip, unified-inbox dot color): the five helpers in
src/renderer/src/lib/utils.ts(getStatusBgColor,getStatusTextColor,getStatusBorderLeftColor,getStatusLabel,getStatusTooltip) take an optionalisOverlayPending?: booleanthird argument. A newpickStatusRow()helper short-circuits toSTATUS_DISPLAY.runningwheneverisOverlayPending === true && status === 'needs_you', regardless ofpendingAction. Call sites read the per-session boolean fromuseAiManagerStore((s) => s.overlayPendingSessions.has(sessionId))(a primitive selector so each row re-renders only when ITS state flips) and pass it through. - Mark + clear sites live in
src/main/services/ai-manager/index.ts—evaluatesets the gate and the outerrelease()closure clears it as a safety net;runOverlayclears it explicitly before firing the deferrednotifyNeedsYouso the gate doesn't suppress the re-fire it triggered itself. - The notification consumer is
src/main/services/notification-service.ts—notifyNeedsYouearly-returns whenisOverlayPending(sessionId)istrue. The gate sits after the pipeline-lane and arm gates (those win first) and before the chime and OS toast. - Renderer-side: the inbox/sidebar exclusion is mirrored in
useAiManagerStore'soverlayPendingSessionsSet, with a 5-minute defensive timeout that DB-recovers any applied overlay text BEFORE clearing the pending gate (so a missedDECISION_WRITTENpush doesn't surface a row without its Plain Speak pill). The 5-minute cap is wide enough that real slow Qwen/OpenRouter cascades — empirically 60–180 s under load — don't trip it prematurely. Six consumers read the Set:computeOrderedInboxGroupsinsrc/renderer/src/features/dashboard/inbox-helpers.ts— keeps overlay-pendingneeds_yousessions out of the unified inbox's Needs You group.computeProjectSessionCountsinsrc/renderer/src/lib/utils.ts— re-routes overlay-pendingneeds_youfromattentiontorunningso the project-icon amber count badge does not tick up. Snooze/scheduled sessions zero-count regardless of the overlay flag (those exclusions take precedence). Callers MUST pass the Set:ProjectsSidebar.tsx,MobileProjectsList.tsx,MobileProjectDrawer.tsx.needsYouSessionsandfilteredSessionsinSessionsSidebar.tsx— desktop project sub-sidebar. Overlay-pendingneeds_youis excluded from the Needs You bucket and kept visible in the regular list.needsYouSessionsandfilteredSessionsinMobileSessionsList.tsx— mobile project sub-sidebar; same predicate as desktop.bucketSessioninAICoachingSubSidebar.tsx—needs_youoverlay-pending sessions bucket as'active'(green Active group), not'needsYou'.
- The literal-message gate (the GATE 4.5 block in
evaluate()insrc/main/services/ai-manager/index.ts) sits AFTER GATE 4 (per-session toggles) and BEFORE GATE 5 (api-key guard). It matchessession.pendingActionagainst two flavors:'user_stopped'(audit reason"user-interrupted turn") for partial mid-stream cutoffs, and'rate_limited'/'auth_error'/'api_error'(audit reason"<pendingAction> turn") for messages where the agent's reply IS the literal error notice and a paraphrased card would obscure it. When any match fires it callswriteSkipped(db, sessionId, 'overlay', <reason>, firedAt), setsoverlayOn = false, and short-circuits toreturnonly when router is also off. Router (Inbox Pilot) is intentionally untouched — it can still classify these turns intono_action/unstuck/ etc. The gate runs BEFOREmarkOverlayPending, so the OVERLAY_PENDING gate never engages and the chime/badge/inbox/OS-toast fire immediately for the interrupt or error. - Test coverage:
- Gate semantics:
tests/unit/services/ai-manager/overlay-pending.test.ts - Notification consumer with mocked gate + the sync-mark race scenario:
tests/unit/services/notification-service-overlay-gate.test.ts - Deferred re-fire end-to-end with REAL
notification-service:tests/unit/services/ai-manager/run-overlay-deferred-notify-real-service.test.ts(covers both directions — gate cleared → fires, gate set → suppressed) - Pipeline-mocked deferred re-fire:
tests/unit/services/ai-manager/run-overlay-deferred-notify.test.ts - User-interrupted-turn skip (overlay does NOT fire on
user_stopped, OVERLAY_PENDING gate does NOT mark, router still fires, audit row reasoning is"user-interrupted turn", other pendingAction values are unaffected):tests/unit/services/ai-manager/evaluate-overlay-skip-reasons.test.ts(theuser_stoppedrow of the merged SKIP_REASONS suite — wasevaluate-skip-on-user-stopped.test.ts, merged 2026-07-09) - V8 deterministic post-processor pure functions (
parseSections,fixActionVerb,stripTldrMarkdown,joinSentences,stripCommitSHAs,bulletCap,truncateBullets,replaceSection, end-to-endpostProcess):tests/unit/ai-manager/cascade/stage-v8-postprocess.test.ts - Jargon scrub final pass (
scrubJargon— each jargon class → its plain phrase, real-branch vs slash-word-pair, the backtick bare-ID blind spot, the "the agent"→"the session" voice fix, and preservation of links / keyboard shortcuts / benign labels; idempotent; no-op on clean input):tests/unit/ai-manager/cascade/jargon-scrub.test.ts - Dispatcher behaviour (qwen-single forwarded to
llmProviderService.chat, abort/timeout/cap, cost from Qwen price table):tests/unit/ai-manager/overlay-evaluator.test.ts - Settings schema accepts each
OVERLAY_PIPELINE_IDSvalue, rejects unknown ids, treatsoverlayPipelineas optional:updateSettingsSchema — aiManager.overlayPipelineblock intests/unit/ipc-schemas.test.ts
- Gate semantics:
Related
- Plain Speak — part 1.
- Plain Speak part 3 — the cost controls and privacy rules this code implements.
- plain-speak-feedback.md — the reporting path back from a bad rewrite.
Last verified 2026-10-04