Voice spike for my-words, using OpenAI Realtime - #506
Merged
kcarnold merged 46 commits intoJul 27, 2026
Conversation
A new sidebar page where the AI can edit the document but may never originate words. Every word it places must be lifted from the writer's own corpus (document + scratchpad + their chat messages), joined only by punctuation and a small closed set of glue words. - corpus.ts: pure, unit-tested phrase-level validator (buildCorpus + validateText + GLUE_WORDS). Lifted spans must appear verbatim in the corpus; new content-word adjacencies are illegal unless glue-bridged. - my-words page: AI tool loop (view / str_replace / insert / highlight) via the ai SDK. Inserted text is validated against a freshly assembled corpus before being applied; rejections are fed back to the model so it retries or asks the writer. AI speech shows as an ephemeral caption with no scrollback; the scratchpad takes most of the height. The model gets lightweight activity signals rather than a full-document dump. - EditorAPI gains getDocText + applyEdit, implemented over Lexical in the standalone editor; Word/Google Docs get getDocText plus typed applyEdit TODO stubs so the same page can drive those hosts later. Wired into the existing page nav (pageContext, navbar, app). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
The Word task pane already renders the shared App + navbar with wordEditorAPI, so the "My Words" tab appears there once edits work. Implement str_replace and insert via Office.js body.search + range.insertText. Edits honor the document's Track Changes mode automatically, so they appear as accept/reject revisions when the user has Track Changes on. Limitation: Word's search is ~255 chars and does not cross paragraph breaks, so this covers sentence/phrase-level edits (how the AI works), not multi-paragraph spans. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
The `view` tool now prefixes each paragraph with its 1-based number, and `insert` accepts a `paragraph` number + `position` to place a new paragraph relative to it. This shared paragraph coordinate system is more robust than text anchoring — and on Word it sidesteps the ~255-char, single-paragraph search limit entirely (uses body.paragraphs + insertParagraph instead of search). - EditorAPI gains getParagraphs(); DocEdit's insert variant gains paragraph/position. - Implemented over Lexical (standalone), Word (body.paragraphs), and a split-based getParagraphs for Google Docs. - System prompt prefers paragraph-targeted inserts and short edits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
- Tool input schemas use zod (added as a direct dependency) instead of hand-written jsonSchema, for more robust tool-call argument handling. - str_replace / insert now return a lightweight description of the result: the new paragraph count plus a clipped 3-paragraph window around the edit, so the model can track paragraph-number shifts between steps. - str_replace is constrained to a short, single-paragraph span (in both the description and the failure message), since long cross-paragraph old_str cannot be matched in Word and is brittle elsewhere. - System prompt reframed: the AI is a non-directive writing tutor that leads with questions and reflection, while its edits only ever rearrange the writer's own words. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
Past user messages and the scratchpad are part of the corpus, but the model could easily treat chat turns as instructions rather than a word bank. Clarify on three fronts: - System prompt: frame the document + scratchpad + chat messages as one word bank to quote from freely, and state explicitly that the writer's messages are a source of words, not just instructions. - REJECTED tool result: remind the model it can lift from the document, the scratchpad, or anything the writer has said. - `view` now also returns the scratchpad (clearly labeled, unnumbered), so the model can see the full corpus on demand instead of relying only on delta activity notes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
The activity note previously pushed the full scratchpad into the
transcript on every change — redundant with the scratchpad now shown by
`view`, and it accumulated a fresh full copy per edit over a session.
Make the note a lightweight flag ("scratchpad changed — call view") and
let `view` be the single source of truth for current scratchpad content.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
A `view` result (or any tool result) is a message that persists in the transcript and is re-sent every subsequent turn — so pulling state via `view` is not lighter than pushing it; both land in context, and frequent views accumulate many stale full-document/scratchpad snapshots. Since the document is the source of truth in the editor, the transcript does not need to remember past states. Persist only the conversation (the writer's turns + the assistant's captions) and drop each turn's intermediate tool calls/results. The model re-reads current state with `view` within a turn when it needs it, and that dump is discarded afterward instead of piling up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
Externalize the design discussion on why the My Words interaction feels
heavy-handed rather than collaborative, and the direction to fix it:
- Diagnose the cause as the control loop (8 silent tool steps then one
caption), not the model — with Chat Completions reasoning loss and the
prescriptive dev message as secondary factors.
- Reframe a turn as a single conversational move then yield, grounded in
turn-taking, grounding/common-ground, mixed-initiative, workspace
awareness, repair, and revision-strategy literature.
- Record decisions: a speak tool, cheap/implicit acceptance with a
pre-staged ("named") next move for responsiveness, simplified history
(view = document only; scratchpad deltas as plain text), move/cut as
first-class, and a loosened dev message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166hxZ69ix9vCZkVYj8DK1t
Make the My Words turn loop a pluggable interaction model and build two that sit at opposite ends of the commitment axis, plus a self-playing demo that records comparable video against the real UI. Core seam (src/pages/my-words/interaction/): - types.ts: Responder (source of moves) + InteractionStrategy (what we do with them) seams, so strategies never touch the AI SDK directly. - liveResponder.ts: drives the model one step at a time with manual, no-execute tools, so the strategy — not the SDK — owns when an edit commits. Prompt loosened to stance; the word-bank rule stays enforced in code (validateText). - ops.ts: pure paragraph ops (incl. move) shared by apply and preview. - useInteraction.ts: React glue with a stable submit(). Two interaction models: - Walkthrough (optimistic): one edit lands per turn; the writer steers, and the next move is pre-named so a continuer carries it out. - Propose (pessimistic): each edit is staged behind an accept/decline gate; the writer's verdict becomes the model's tool result. Live page gains a Walkthrough/Propose toggle; view is document-only and the scratchpad reaches the model as text. Playback/video (demo/ + tests/mywords-demo.spec.ts): the real panel + strategy run against an in-memory editor and a scripted responder over one scenario authored for both models. `npm run build` then `npx playwright test mywords-demo` writes mywords-videos/*.webm. Under Propose the writer declines the last move, so the documents diverge. Design note updated (docs/my-words-interaction-design.md §5b). New unit tests for ops; full suite green (44 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166hxZ69ix9vCZkVYj8DK1t
Three fixes from running the loop against a live model: - Missing tool results: the model could emit several tool calls (e.g. parallel `highlight`s) in one step while we answered only the first, leaving the rest without results and breaking the next request. Disable parallel tool calls (providerOptions.openai.parallelToolCalls=false) and defensively answer every call in a step (primary gets the real outcome, extras a filler). - Paragraph numbers leaking to the writer: the internal [n] coordinate was surfacing in captions and in the move proposal summary. Tell the model those numbers are tool-only (never spoken), and drop the number from the writer-facing move description. - Blank paragraphs burning tokens: `view` numbered every paragraph including empties. Skip blanks while preserving each kept paragraph's real number, so insert/move targeting still lines up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166hxZ69ix9vCZkVYj8DK1t
Proposal awareness (where + co-location): - When Propose stages an edit, point at it in the document automatically (via selectPhrase) so the writer can see *where* before deciding — the source span for a replace, the destination paragraph for insert/move. - Make the summary concrete: name the other end in words (e.g. "Add X after <neighbor>", "Move X after <neighbor>") instead of "a new spot". - Co-locate the model's narration with the decision: the proposal card now shows the ask, the precise change, and Accept/Not-yet together at the bottom, rather than splitting narration (top) from the card. Track Changes: - getDocText/getParagraphs now read the "current" reviewed text (getReviewedText, WordApi 1.4) so tracked-change deletions are excluded — the AI only sees the writer's accepted words, not struck-through text. Guarded by a capability check with a raw-text fallback on older hosts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166hxZ69ix9vCZkVYj8DK1t
…o scratchpad
- Layout: the scratchpad ("your words") fills the top and grows; everything
conversational is one zone at the bottom next to the input — the model's
utterance (or a staged proposal) just above where the writer replies. No
more top caption bar.
- Captions feel lighter and fleeting: small muted text with a speech dot,
and rendered non-selectable (user-select: none) — the model adds no words
to the document, so its phrasing can't be copied. Same for the proposal's
spoken line.
- Sent messages are no longer a separate scroll area; they're appended to
the scratchpad, since the writer's messages are part of their word bank.
Corpus is built from the scratchpad alone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166hxZ69ix9vCZkVYj8DK1t
- str_replace can now carry an optional `paragraph` number (the [n] from view). When given, the search is scoped to that paragraph — disambiguates repeated text and dodges the host search-length limit. Wired through the EditOp/DocEdit types, the live tool schema, the pure applyOp, and both hosts (Word scopes to the paragraph's range; the standalone editor scopes in the paragraph array). - Apply now goes through applyOpAndReport: on success it feeds back a numbered window around where the change landed; on failure (e.g. the writer moved a paragraph and the cached number is stale) it returns the error plus the current nearby paragraphs so the model re-orients and retries instead of flying blind. Restores the windowed feedback the earlier describeChange had, and reuses it for the failure path. - Strategies retry up to 3 times, feeding rejections/failures back. Unit tests for scoped replace; full suite green (46 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166hxZ69ix9vCZkVYj8DK1t
Add a loadScratchpad/saveScratchpad seam to EditorAPI so each host persists natively: - Word: document.settings (travels with the .docx; plain text is well within its size budget). Saved via saveAsync. - Standalone editor, Google Docs, default context: localStorage (shared helper in api/scratchpadStore.ts). - Demo mock editor: in-memory no-ops (it seeds from its scenario). The My Words page loads the scratchpad once on mount and debounce-saves (800ms) on change, so it survives the frequent dev hot-reloads. Since sent messages are appended to the scratchpad, the writer's side of the conversation persists too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166hxZ69ix9vCZkVYj8DK1t
Landscape + literature review for turning the My Words prototype into a fluent, voice-native writing collaborator: the smart-vs-responsive tension (with benchmark evidence), a recommended brief-then-converse v1 architecture reusing the existing tool/validateText spine, writing-by-voice / conversational text-entry / co-writing literature, an audio-native take on collaboration, platform trade-offs, and open design questions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tnh9eW7WrwAGsDN62giHSA
…n-cuvkl3' into claude/voice-native-conversation-research-k7uirl
A throwaway standalone page proving we can hold a live voice conversation that has the document text in context and calls tools that read and edit it. - backend: POST /api/openai/realtime/session mints a short-lived ephemeral Realtime token (server key stays server-side), mirroring the OpenAI passthrough. - frontend: voice-spike.html + voice-spike/ page connects to OpenAI Realtime over WebRTC, inlines the current document into the session, and registers view/replace_text/insert_text/highlight tools wired to the real EditorAPI seam (MockEditor + applyEditOp). Semantic VAD for end-of-turn. No new deps. Out of scope: word-bank enforcement, the reasoning brief, LiveKit, turn-taking polish. See docs/my-words-voice-native-research.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tnh9eW7WrwAGsDN62giHSA
The GA Realtime API requires session.type and moved audio config under session.audio. Set type:'realtime', relocate turn_detection to audio.input.turn_detection, and enable input transcription. Also accept the GA-renamed response.output_audio_transcript.done alongside the old name. Fixes: Missing required parameter: 'session.type'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tnh9eW7WrwAGsDN62giHSA
A failed /v1/realtime/calls POST was throwing away OpenAI's response body, hiding the reason for the 400. Include it in the thrown error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tnh9eW7WrwAGsDN62giHSA
- Call audioEl.play() when the remote track arrives; setting srcObject after mount doesn't reliably honour the autoPlay attribute, so no audio was heard. - The model emits several tool calls within one response; firing response.create per call collided with the running response (conversation_already_has_active_ response). Track the active response and defer a single follow-up until it ends. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tnh9eW7WrwAGsDN62giHSA
… logs No audio was heard because play() fired from ontrack (long after the click) hit the browser autoplay block, and the rejection was swallowed. Instead, attach an empty MediaStream to the <audio> element and play() it right after getUserMedia (still gesture-activated); the model's track is added to that already-playing stream in ontrack, so it becomes audible without a second play(). Also surface connection/track/playback status in the log panel to diagnose future issues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tnh9eW7WrwAGsDN62giHSA
… avoid failures if transcript doesn't get committed in time)
…onversation-research-k7uirl
…lice Newlines in op text are now paragraph breaks: \n in replacement/insert text splits a paragraph, \n in old_str matches across a boundary and merges. Each EditOp lowers deterministically (lowerOp) to ParagraphSplice mutations, which invert by swapping remove/insert — so undo data is captured at apply time (applyOpLogged) and freshness-checked (spliceIsFresh) instead of re-derived. applyOp is rebuilt on the same lowering so preview/apply/undo cannot drift. Hosts gain the splice vocabulary: an internal delete_paragraph DocEdit (Word: Paragraph.delete(); never exposed as an agent tool) removes the undo-of-insert empty-paragraph residue, and MockEditor implements applySplice natively. view/report helpers (viewParagraphs, numberedWindow, appliedReport) become pure over string[] so a second surface can reuse them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…document The Lexical surface's applyEdit computed the whole new document string and setText() did root.clear() + full rebuild — destroying the cursor, collapsing undo to a wholesale replace, and (because getText() joins paragraphs with \n\n while setText splits on \n) inserting a spurious empty paragraph between every pair on unscoped edits. applyEdit now lowers to paragraph-range splices and $applySplice mutates only the affected paragraph nodes (common prefix/suffix spliceText for the single-text-node case, so even the edited paragraph keeps its node key). Untouched nodes keep their keys — the writer's cursor survives — and one update per splice keeps a split/merge a single undo entry. setText remains for initial load only. Headless-Lexical tests pin key preservation and paragraph- count invariance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… adapter Every edit now selects its landing site, waits a beat (750ms in-place, 1.8s for move — commitment latency scales with the cost of being wrong), and only then applies; during the beat a cancel callback can veto it (EditVetoed). An anchor miss skips the reveal but never blocks the edit. applyEditOp returns the splices it applied so callers can capture inverses for undo. applySpliceToEditor applies a splice on any host: natively where the host has applySplice, otherwise as DocEdit primitives — minimal in-paragraph replaces (common prefix/suffix trimmed, needle grown leftward so the FIRST occurrence is the intended one, dodging Word's search-length limit), paragraph inserts, and delete_paragraph for shrinkage. Tested for equivalence against the pure applySplice across split/merge/grow/shrink cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he UI All five tools take target: 'document' | 'scratchpad'; view gains a windowed read (around). The scratchpad is the writer's thinking space — their ideas in their own words, deliberately free to diverge from the document — reached with the same tools, validated against the same word-bank corpus (built from the pre-edit scratchpad, so ordering stays correct), and applied through the same splice machinery to the state the text tabs already persist. Edit reports are clipped windows, not full text. Each applied edit pushes its inverse splices onto a session undo stack; the new undo tool and a header button pop it after a freshness check, so a region the writer has since hand-edited is refused rather than clobbered. Scratchpad edits get the same reveal beat (panel highlight) and the ✕ chip vetoes either surface. VoiceScratchpad renders the conventions — # idea headings, - notes, [[idea words]] links that scroll to their heading, "quoted phrases" that highlight the document — as a per-line classifier over plain text (prompt guidance, not a schema); clicking drops into a plain textarea. The voice tab now leads with the scratchpad, keeps a compact "You said" strip, and tucks the raw log behind a Debug disclosure. agent.py carries the stance: tentative edits, undo without fuss, jot the writer's phrasing verbatim, suggest links aloud rather than authoring them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/CLAUDE.md Records what the voice scratchpad is (a second document, same tools, allowed to diverge), the commitment design (announce → reveal → veto window → apply → undo) and its grounding/repair/mixed-initiative reasoning, the related-work positioning (Rambler, InkSync, Dang et al., Graphologue/Sensecape, the prototype-mindmap lineage), and current limitations. frontend/CLAUDE.md claimed component tests can opt into jsdom, but jsdom isn't installed — point at the headless-API approach instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onversation-research-k7uirl # Conflicts: # backend/src/app.ts # frontend/src/api/openai.ts # frontend/src/editor/index.tsx
A few refinements to the interaction flow landed at the same time.
…onversation-research-k7uirl # Conflicts: # backend/src/app.ts # frontend/src/components/navbar/index.tsx # frontend/src/pages/app/index.tsx # frontend/vite.config.ts
kcarnold
marked this pull request as ready for review
July 25, 2026 16:19
Both branches widened `EditorAPI` independently, which made the merge more than textual: - main added document-scoped settings (`getDocumentSetting`/`setDocumentSetting`) for the writer's brief; this branch added `getDocText`/`getParagraphs`/ `applyEdit` and a bespoke `loadScratchpad`/`saveScratchpad` pair for My Words. Kept both for now — the scratchpad moves onto the generic layer next. - Tests on both sides spelled out the whole interface inline, so each side's mocks failed to satisfy the other's additions. Added `noopEditorAPI` (`src/api/__fixtures__/editorAPI.ts`) so a widening no longer breaks tests that have nothing to do with it, and gave the demo `MockEditor` real in-memory settings so the brief round-trips there. - This branch's `ai` ^5→^7 bump outdated main's new `generate.test.ts`: `ai/test` no longer exports `MockLanguageModelV2`, and the v3 spec restructured `finishReason` and `usage`. The stream-part type is now derived from the mock, because the top-level `@ai-sdk/provider` is still the v2 copy `@ai-sdk/openai` pulls in. Production is unaffected: `ai` v7 adapts v2 models via `asLanguageModelV3`. Pre-existing on main, not introduced here: the 4 `documentSettings.test.ts` failures under Node 26, whose built-in `localStorage` global shadows jsdom's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scratchpad had its own persistence pair on `EditorAPI` (`loadScratchpad`/`saveScratchpad`) with a per-host implementation. main's `getDocumentSetting`/`setDocumentSetting` is the same thing generalized, so the bespoke pair is now redundant — the Word implementation was in particular a literal copy of `getDocumentSetting`/`setDocumentSetting` with the key hardcoded. Drops the pair from `EditorAPI` and deletes `src/api/scratchpadStore.ts`. The setting key stays `mywords-scratchpad`, so Word documents keep the scratchpads they already hold: the old path wrote that same Office setting under that same name. Browser-backed hosts (standalone editor, Google Docs without the document-property bridge) do start over, because those keys are now namespaced per document — which is the point. The standalone editor previously shared one scratchpad across every document on the origin. Also flushes a pending save on unmount and `pagehide`, matching what `docBriefContext` does. The 800ms debounce alone lost the writer's last words if they switched tabs right after typing. Clears the `require-await` errors main's new eslint config surfaced in the My Words mocks; `eslint .` is now error-free. `spliceAdapter`'s fake host rejects rather than throwing synchronously, preserving the failure shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`POST /api/openai/realtime/session` never called `resolveUser`, and the
frontend sent no Authorization header, so the route was open: anyone who could
reach the server could mint an ephemeral OpenAI Realtime credential. It also
called `openaiApiKey()` directly, bypassing `attributeRequest` — so it skipped
demo-key routing for sessionless traffic and the fail-closed 401 the proxy
applies when auth is on.
It now runs the same gauntlet as a proxied generation: the beta allowlist (403),
then `attributeRequest` for who pays (401 when it can't attribute). A real
session spends the main key, a demo session the capped one. `mintEphemeralKey`
calls through the exported `authorizedFetch`, which already attaches the session
token and the first-party client id, rather than growing a second copy of it.
Metering is still absent and can't be fixed at this layer: audio runs browser →
OpenAI over WebRTC, so no tokens pass through the server to count. It needs
`usage` read off the client's `response.done` events and reported back. Until
then a voice session is billable but missing from `llm_usage` — so the My Words
registry entry gets `enabled: () => isFlagEnabled('my-words')`, keeping it out
of even the Labs menu.
Four tests cover the gate, each asserting no upstream mint happens when the
route refuses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A voice-native conversation mode for the My Words lab page, over OpenAI Realtime (WebRTC). The writer talks; the agent shapes their words in the real document.
The own-words constraint is enforced, including for voice
corpus.tsis the rule: the AI may edit the document but may never originate words. Anything it places must be lifted verbatim from the writer's corpus (document + scratchpad + their own chat/voice messages), joined only by punctuation and a closed set of glue words. Glue deliberately excludes content connectives ("because", "however") and the copula, which would let the model assert relationships the writer never made.The voice path runs every tool call through the same
validateOpagainst the live corpus, and applies it through the same durable edit path as the text tabs. The one voice-specific piece: the writer's rolling transcript feeds the corpus'suserMessagesslot, so words they just said aloud are shapeable — without itvalidateOpwould reject the model for using them.Control invariants, enforced in code
Five, in
voice/session.ts, each because the writer's sense of control fails a specific way without it — covered by__tests__/voiceControl.test.ts:cancelrather than setting a flag, which would leak past the window and kill the next edit.These are enforced in code rather than asked for in the prompt;
prompt.tsspends its words on stance.Shape
view,str_replace,insert,move,highlight,undo), each takingtarget: 'document' | 'scratchpad'.EditorAPI's document-setting pair, so it travels with the file in Word.session.tsis transport-agnostic;realtime.tsimplementsVoiceTransportover OpenAI Realtime.realtime.tsis deliberately not unit-tested (every bug it has had was an environment fact a mock reproduces wrongly by construction); the logic worth testing lives above it.POST /api/openai/realtime/session, minting an ephemeral client secret. Audio and tool calls run browser → OpenAI directly.mywords-demo.htmlwith scripted responders and scenarios, so the interaction can be exercised without a live model. Playwright spec included.Status: experiment WIP, not ready to enable
The page ships behind a flag (
enabled: () => isFlagEnabled('my-words')), so it's out of even the Labs menu. Reach it with?ff=my-wordson the standalone editor or dev server.The blocker for turning it on is metering. Voice usage does not reach the
llm_usagetable, and can't be captured at the proxy layer: audio runs browser → OpenAI over WebRTC, so no tokens pass through our server totee(). Doing it properly means readingusageoff the client'sresponse.doneevents and reporting them back. A voice session is billable but invisible to/api/usage_summaryuntil then.Fixed in this branch along the way: the realtime mint route was unauthenticated and called
openaiApiKey()directly, bypassingattributeRequest— so anyone who could reach the server could mint a credential against the main key, and it skipped both demo-key routing and the fail-closed 401. It now runs the same gauntlet as a proxied generation (beta allowlist → 403, attribution → 401), and the client authorizes via the sharedauthorizedFetch.Merge notes
Merged
mainand moved the scratchpad onto thegetDocumentSetting/setDocumentSettinglayermainintroduced, deleting the bespokeloadScratchpad/saveScratchpadpair (Word's implementation was a literal copy of the generic one with the key hardcoded). The setting key is unchanged, so existing Word documents keep their scratchpads; browser-backed hosts reset, because those keys are now namespaced per document — previously the standalone editor shared one scratchpad across every document on the origin.Also in the merge: both branches had widened
EditorAPIindependently, so each side's inline test mocks failed to satisfy the other's additions — added a sharednoopEditorAPIfixture. And this branch'sai^5→^7 bump outdatedmain's newgenerate.test.ts(MockLanguageModelV2is gone; the v3 spec restructuredfinishReasonandusage). Production is unaffected —aiv7 adapts v2 models viaasLanguageModelV3.Testing
my-words),tscclean,eslint .0 errors, build succeedstscclean, build succeedsmain, not from this branch:documentSettings.test.tsfails under Node 26, whose built-inlocalStorageglobal shadows jsdom's, so the@vitest-environment jsdompragma doesn't take. Verified by running them on a pristineorigin/mainworktree. Backend CLAUDE.md already specifies Node 24 LTS.Design docs
docs/my-words-voice-native-research.md,docs/my-words-voice-scratchpad.md,docs/my-words-interaction-design.md.