Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

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_ONLY backstop 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.ts is a thin dispatcher: it lazily imports the pipeline map and calls resolvePipeline(DEFAULT_PIPELINE_ID) from cascade/pipeline-map.ts, then delegates run(args) to the matched OverlayPipeline. It no longer reads any aiManager.overlayPipeline setting — the per-user pipeline picker was removed (F009), so the pipeline is always the default. The EvaluateOverlayArgs / EvaluateOverlayResult contract lives in overlay-evaluator-types.ts. The pipelines are a static map in pipeline-map.ts (OVERLAY_PIPELINES), NOT a self-registering registry — resolvePipeline is a plain lookup that falls back to DEFAULT_PIPELINE_ID for an unknown/undefined id. Pipeline IDs are canonicalized in cascade/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 OverlayPipeline from cascade/pipeline-types.ts in a new cascade/pipeline-<id>.ts file; (2) add the new id to cascade/pipeline-ids.ts and OVERLAY_PIPELINE_IDS in src/shared/ai-manager-types.ts; (3) add one entry to the OVERLAY_PIPELINES map in cascade/pipeline-map.ts. There is no self-registration barrel and no registerPipeline() 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 OverlayPipeline may declare an optional reduceRows(rows: EligibleRow[]): EligibleRow[] hook. When present, the dispatcher in overlay-evaluator.ts applies it to args.transcriptRows BEFORE invoking pipeline.run() and re-derives args.transcript from the reduced set via formatTranscriptFromRows(). This keeps the reducer out of every pipeline's run() body so a pipeline can be "cascade + a different reducer" by reusing cascadePipeline.run directly — see pipeline-cascade-v4.ts (targetPlusLastOp reducer) for the canonical example. Reducers receive eligibility-filtered rows (system rows + metadata.systemInjected operator messages already dropped by selectEligibleRows() in context-builder.ts) so no further filtering is needed inside the reducer. The default cascade itself now uses a blendRows reducer — [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's targetPlusLastOp (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 via replaceSection, 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 an AMC_DISABLE_OVERLAY_SECTION_REVIEWER=1 kill switch. cascade-v4 inherits it (shared run()); 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 exports normalizeQuestionShape(rewrite) — a deterministic final pass that runs after V10 in the cascade and after V3 in the qwen-single pipeline. It parses the ## Questions section, 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.content despite reasoning: { exclude: true }, and a 2,000–4,000-char reasoning dump fits under the overlay's MAX_OVERLAY_CHARS cap 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 — every V7CriticResult carries a rejected: boolean set when ensureLeadingHeading(trimmed) returns null on 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) V10 fallbackDraft arg — 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 — sanitizePlainSpeakOverlay in src/renderer/src/lib/sanitize-plain-speak.ts returns '' (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.ts instructs 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 mandatory Recommended Action / Latest / TLDR. The separate "too short to be useful" trim (<8 chars) stays scoped to the optional sections, so a terse-but-real Recommended Action: Archive still 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 contract every-overlay-ui-gate-site/drops-nothing-phrase-sections). Locked by sanitize-plain-speak.test.ts.
  • View state for the Plain Speak ⇄ Original toggle lives in src/renderer/src/stores/ai-manager-store.ts on a overlayViewByMessageId: Map<string, 'plain' | 'original'> slice with setOverlayView / toggleOverlayView / getOverlayView actions. Every mutation creates a new Map reference 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), where rules is useAiManagerStore's copy of aiManager. rules follows the SETTINGS_CHANGED push via a listener in src/renderer/src/app/useAiManagerPushListeners.ts that applies the pure pickAiManagerFromSettingsPush through the store's setRules — 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, rules refreshed 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 by plain-speak-overlay-gating-contract.md ai-manager-rules-store.
  • The toggle pill is rendered by PlainSpeakTogglePill, which lives in its own file src/renderer/src/components/ui/PlainSpeakTogglePill.tsx (extracted from MessageBubble.tsx on 2026-05-30) and is imported into src/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 (no ml-auto wrapper — that would fling it to the far-right edge on desktop, visually disconnected from the time). Gated on !isOperator && overlayText so 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 class hidden md:inline so the text only shows on desktop; mobile renders icon-only (the ArrowLeftRight SVG stays visible on both viewports). The accent-tinted background and aria-label / aria-pressed attributes apply on both viewports so the pill is still visibly clickable on mobile and a11y-equivalent. Reads/writes the store via a useCallback-memoized primitive selector keyed on messageId (this preserves MessageBubble's React.memo). The pill's class string is the PLAIN_SPEAK_TOGGLE_PILL constant in src/renderer/src/lib/styles.ts. AutoResponseBadge (operator-message-only, mutually exclusive with the Plain Speak pill) keeps its ml-auto right-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's onContextMenu handler preventDefault()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 shared ContextMenuShell.tsx primitive (a portal + keyboard/viewport-clamp shell extracted from ImageContextMenu.tsx so both menus share it). The single Plain Speak settings item calls navigateToSettingsProject({ 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 by tests/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.ts is wired into the agent branch of MessageBubble and subscribes to useAiManagerStore to catch the null→set transition of overlayByMessageId.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's getBoundingClientRect() intersects the viewport (width+height > 0, top<viewportH, bottom>0, left<viewportW, right>0). Only when all five hold does it call setOverlayView(messageId, 'original'). The hook returns a React.RefObject<HTMLDivElement> that MessageBubble merges with the parent-passed summaryRef via a useCallback'd callback ref (setBubbleRef) so a single DOM node feeds both refs without refactoring summaryRef call sites. The subscriber fires synchronously inside Zustand's setState between the store mutation and React's commit, so the per-message view is already 'original' when PlainEnglishOverlay reads 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.tsx and is body-only — no toggle button lives inside the overlay. It reads the current view from useAiManagerStore.overlayViewByMessageId via the same useCallback selector pattern and swaps text (rewrite) vs originalContent (raw markdown). The overlay is a transparent pass-through on the rendering axis for BOTH views (2026-05-26 Rev 31): it accepts an optional overviewMode?: 'inline' | 'summary-first' | 'external-pill' | 'prose-only' prop (default 'external-pill') AND an optional liteContent handle, and forwards both verbatim to AgentMarkdown regardless of whether the user has flipped to Plain or Original. MessageBubble passes the same overviewMode + liteContent the 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 sibling TotalsPillBubble), 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 overrode overviewMode to 'inline' + liteContent to undefined on 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's conversationHandlers map). 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's conversationCache, walked from the end backward, looking for the latest message whose source === 'agent' AND whose id is in overlayByMessageId. When found, it calls toggleOverlayView(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 in src/shared/keybindings.ts as togglePlainSpeak with defaultBindings: ['V'], passthroughInputs: false (won't fire while typing in the composer or any other input/textarea/contenteditable), and category: '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 narrow needs_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> in src/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 inside evaluate(...) BEFORE the first await so it's already set when notifyNeedsYou runs in the same tick as notifyTurnEnded.
  • 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 optional isOverlayPending?: boolean third argument. A new pickStatusRow() helper short-circuits to STATUS_DISPLAY.running whenever isOverlayPending === true && status === 'needs_you', regardless of pendingAction. Call sites read the per-session boolean from useAiManagerStore((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 — evaluate sets the gate and the outer release() closure clears it as a safety net; runOverlay clears it explicitly before firing the deferred notifyNeedsYou so the gate doesn't suppress the re-fire it triggered itself.
  • The notification consumer is src/main/services/notification-service.ts — notifyNeedsYou early-returns when isOverlayPending(sessionId) is true. 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's overlayPendingSessions Set, with a 5-minute defensive timeout that DB-recovers any applied overlay text BEFORE clearing the pending gate (so a missed DECISION_WRITTEN push 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:
    • computeOrderedInboxGroups in src/renderer/src/features/dashboard/inbox-helpers.ts — keeps overlay-pending needs_you sessions out of the unified inbox's Needs You group.
    • computeProjectSessionCounts in src/renderer/src/lib/utils.ts — re-routes overlay-pending needs_you from attention to running so 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.
    • needsYouSessions and filteredSessions in SessionsSidebar.tsx — desktop project sub-sidebar. Overlay-pending needs_you is excluded from the Needs You bucket and kept visible in the regular list.
    • needsYouSessions and filteredSessions in MobileSessionsList.tsx — mobile project sub-sidebar; same predicate as desktop.
    • bucketSession in AICoachingSubSidebar.tsx — needs_you overlay-pending sessions bucket as 'active' (green Active group), not 'needsYou'.
  • The literal-message gate (the GATE 4.5 block in evaluate() in src/main/services/ai-manager/index.ts) sits AFTER GATE 4 (per-session toggles) and BEFORE GATE 5 (api-key guard). It matches session.pendingAction against 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 calls writeSkipped(db, sessionId, 'overlay', <reason>, firedAt), sets overlayOn = false, and short-circuits to return only when router is also off. Router (Inbox Pilot) is intentionally untouched — it can still classify these turns into no_action / unstuck / etc. The gate runs BEFORE markOverlayPending, 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 (the user_stopped row of the merged SKIP_REASONS suite — was evaluate-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-end postProcess): 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_IDS value, rejects unknown ids, treats overlayPipeline as optional: updateSettingsSchema — aiManager.overlayPipeline block in tests/unit/ipc-schemas.test.ts

Related

Last verified 2026-10-04