Skip to content

Voice spike for my-words, using OpenAI Realtime - #506

Merged
kcarnold merged 46 commits into
mainfrom
claude/voice-native-conversation-research-k7uirl
Jul 27, 2026
Merged

Voice spike for my-words, using OpenAI Realtime#506
kcarnold merged 46 commits into
mainfrom
claude/voice-native-conversation-research-k7uirl

Conversation

@kcarnold

@kcarnold kcarnold commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

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.

Supersedes the old description ("doesn't obey the own-words validation, it's just a demo"). That is no longer true — see below.

The own-words constraint is enforced, including for voice

corpus.ts is 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 validateOp against 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's userMessages slot, so words they just said aloud are shapeable — without it validateOp would 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:

  1. One applied edit per writer turn. The budget resets when the writer starts speaking, not on model-response boundaries (this session requests a follow-up after every tool result, so a per-response budget would reset immediately and gate nothing). Only a successful apply consumes it, so a rejected edit stays retryable.
  2. Speech during the veto window cancels the pending edit. The instinctive "no, wait" has to work; ~750ms is too short to reach for the ✕ chip. Implemented by holding the window's cancel rather than setting a flag, which would leak past the window and kill the next edit.
  3. No silent failure. Every path that ends without the edit landing emits exactly one writer-visible notice. Word-bank rejections happen for mundane reasons (a mis-transcribed word), and silence reads as broken rather than principled.
  4. Undo inverses captured at apply time, never re-derived, and freshness-checked, so a region the writer has since hand-edited is refused rather than clobbered.
  5. Highlighting is not an edit — never costs or is blocked by the turn budget, and carries no veto beat.

These are enforced in code rather than asked for in the prompt; prompt.ts spends its words on stance.

Shape

  • Six tools (view, str_replace, insert, move, highlight, undo), each taking target: 'document' | 'scratchpad'.
  • Scratchpad — a second surface holding the writer's ideas in their own words, which the agent edits with the same tools and the same validation. Persisted via EditorAPI's document-setting pair, so it travels with the file in Word.
  • Transport seamsession.ts is transport-agnostic; realtime.ts implements VoiceTransport over OpenAI Realtime. realtime.ts is 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.
  • Backend — one route, POST /api/openai/realtime/session, minting an ephemeral client secret. Audio and tool calls run browser → OpenAI directly.
  • Demo harnessmywords-demo.html with 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-words on the standalone editor or dev server.

The blocker for turning it on is metering. Voice usage does not reach the llm_usage table, and can't be captured at the proxy layer: audio runs browser → OpenAI over WebRTC, so no tokens pass through our server to tee(). Doing it properly means reading usage off the client's response.done events and reporting them back. A voice session is billable but invisible to /api/usage_summary until then.

Fixed in this branch along the way: the realtime mint route was unauthenticated and called openaiApiKey() directly, bypassing attributeRequest — 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 shared authorizedFetch.

Merge notes

Merged main and moved the scratchpad onto the getDocumentSetting/setDocumentSetting layer main introduced, deleting the bespoke loadScratchpad/saveScratchpad pair (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 EditorAPI independently, so each side's inline test mocks failed to satisfy the other's additions — added a shared noopEditorAPI fixture. And this branch's ai ^5→^7 bump outdated main's new generate.test.ts (MockLanguageModelV2 is gone; the v3 spec restructured finishReason and usage). Production is unaffected — ai v7 adapts v2 models via asLanguageModelV3.

Testing

  • Frontend: 200/204 vitest (63 in my-words), tsc clean, eslint . 0 errors, build succeeds
  • Backend: 106/106 vitest, tsc clean, build succeeds
  • The 4 failures are pre-existing on main, not from this branch: documentSettings.test.ts fails under Node 26, whose built-in localStorage global shadows jsdom's, so the @vitest-environment jsdom pragma doesn't take. Verified by running them on a pristine origin/main worktree. 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.

claude and others added 30 commits June 25, 2026 17:01
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
kcarnold and others added 9 commits July 6, 2026 14:04
… avoid failures if transcript doesn't get committed in time)
…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
kcarnold added 2 commits July 25, 2026 11:43
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
kcarnold marked this pull request as ready for review July 25, 2026 16:19
kcarnold and others added 5 commits July 27, 2026 15:54
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>
@kcarnold
kcarnold merged commit 9895aea into main Jul 27, 2026
9 of 10 checks passed
@kcarnold
kcarnold deleted the claude/voice-native-conversation-research-k7uirl branch July 27, 2026 22:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants