Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/chat-document-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Chat: document context and own-words edits

Chat is a coach that can also propose edits to the document, under the My Words
constraint: it may only place the writer's own words. Plan and decisions:
[#583](https://github.com/AIToolsLab/writing-tools/issues/583). Code:
`frontend/src/pages/chat/` and the shared `frontend/src/docTools/`.

## What the model sees

The transcript is the model's own history (`ModelMessage[]`, tool calls
included), kept in `ChatContext`. Each writer turn is one user message with two
text parts: a `<document_context>` block the app attaches, then what the writer
typed (the only part their bubble shows). The block holds:

- the brief, if set;
- on the first turn, the whole document, paragraphs numbered as `view` numbers
them (numbers are tool coordinates; the model is told not to mention them);
- on later turns, an **edit note**: a paragraph diff (`docTools/editNote.ts`)
between what the model last saw and the document now, omitted when nothing
changed and reduced to "call `view`" past a size cap;
- the paragraph around the cursor or selection, marked.

"What the model last saw" (`ChatDocState.baseline`) moves on each send, each
`view`, and after an applied edit, so the note reports only the writer's changes.

## Proposals

On hosts with `applyEdit` (`canEdit`), Chat offers `str_replace`, `insert` and
`move` (`docTools/editTools.ts`). `chatTools.ts` gates each call in
`toolApproval`:

1. dry-run against the current document (`lowerOp`); a miss is denied;
2. the word-bank check (`validateOp`); a failure is denied with the offending
words as the reason;
3. otherwise `'user-approval'`: the stream stops and the card appears.

The card does not select anything in the document on its own: the writer may
be typing there, and a selection that appears under them is replaced by their
next keystroke. "Show in document" selects the target on request (trimming the
quote until it's found, since Word's search rejects long strings).

Denials go back to the model, never to the writer, saying where the text
breaks ("…have "alpha beta", but never followed by "gamma""). The check
(`docTools/corpus.ts`) accepts runs of content words lifted verbatim, or
stitched from pieces that overlap by at least two words, joined by punctuation
and a closed set of glue words (no connectives: "as" was dropped because it
can mean "because"). Word bank: the live
document, every paragraph the model has seen this conversation (so deleted
words can be reinstated), the writer's typed messages, and the brief, but not
the `<document_context>` blocks or assistant text.

The app writes every answer itself (`answerProposal`): the approval response
*plus* the tool result. Accept applies the edit first (no reveal beat) and
records the report; Decline and "set aside" (the writer sent a new message
with the card open) record `execution-denied` with a reason. The SDK skips
approvals that already have a result, so it only continues the conversation,
and the history stays valid whatever comes next. That matters because the SDK
resolves approvals only when they're the last message. `applyProposal` is the
only path that edits the document: the tools' `execute` throws, so if the SDK
ever did run one, the proposal shows as failed rather than being applied twice.

## Undo

Each applied edit stores its inverse splices (`appliedEdits`, by tool call).
Undo works in any order: `docTools/undo.ts` relocates a stale index to the one
place the edit's text still occurs, and refuses if it changed or occurs more
than once. A splice that removes nothing (putting back a paragraph a `move`
took out) has no text of its own to find, so it records the ~32 characters on
each side of its gap (`prefix`/`suffix`, like #589's `TextAnchor`, across
paragraph breaks so blank or short neighbors don't leave it ambiguous) and is
relocated by those, by the same rule. Blank paragraphs count literally for now:
adding or removing a blank line at the spot makes the undo refuse (see the
comment on `contextAt` in `undo.ts` for the alternative). An undo is not recorded as seen, so the next edit note reports it.

## Logging

`chatLog.edit` emits `edit_proposed`, `edit_auto_denied` (`reason`:
`word_bank` | `no_match`), `edit_accepted`, `edit_declined`, `edit_set_aside`,
`edit_failed`, `edit_undone`, `edit_undo_refused`. The op is in `proposal` and
the failing words in `offending`, both document-level in the consent gate, so
at the default (usage) level we see how often each happens but not the text.
8 changes: 8 additions & 0 deletions docs/my-words-interaction-design.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# My Words — Interaction Design Note

> **Historical (2026-10-31).** The text prototype described here (the
> Walkthrough/Propose strategies, `interaction/`, the `demo/` playback) has been
> removed. Propose's design — every edit staged behind the writer's verdict,
> which becomes the model's tool result — lives on in Chat's own-words edits:
> see [chat-document-tools.md](chat-document-tools.md). Voice remains, on the
> shared `frontend/src/docTools/`. Code paths below refer to the removed code
> (recoverable from git history).

**Status:** design exploration. Captures *why* the current My Words interaction
feels wrong and the direction to fix it. This is a design brief, not an
implementation diff — the implementation sketch at the end is a starting point,
Expand Down
5 changes: 4 additions & 1 deletion docs/my-words-voice-native-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ hands-free, genuinely collaborative), the platform options, and the questions
worth resolving before building.

**Companion to** `docs/my-words-interaction-design.md` (the text-prototype design
brief; branch `claude/mywords-interaction-design-cuvkl3`). That note argues the
brief; branch `claude/mywords-interaction-design-cuvkl3`). The text prototype
has since been removed in favour of Chat's own-words edits
([chat-document-tools.md](chat-document-tools.md)); references below to
`interaction/` are historical. That note argues the
*turn* should be one conversational move, then yield the floor. This note asks
what happens when the floor is made of *sound*.

Expand Down
9 changes: 5 additions & 4 deletions docs/my-words-voice-scratchpad.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ open to promote a convention into structure later if usage proves it out.

## Limitations / next

- Google Docs still has no `applyEdit`, so voice edits (and undo) don't work
on that host; `selectPhrase`-based reveals do.
- Google Docs still has no `applyEdit` (it's optional on `EditorAPI`; check
with `canEdit`), so My Words shows a "can't edit here" note on that host
instead of the session.
- Paragraph identity is positional. Stable IDs (Word's newer
`Paragraph.uniqueLocalId`) would end paragraph-number drift; numbers + text
anchors are good enough for the prototype.
Expand Down Expand Up @@ -183,8 +184,8 @@ from scratch. The first two are design work and are tracked as issues #555 and

| Piece | Where |
| --- | --- |
| Splice lowering, inversion, freshness | `frontend/src/pages/my-words/interaction/ops.ts` |
| Reveal/veto + splice adapter (per-host) | `frontend/src/pages/my-words/interaction/editor.ts` |
| Splice lowering, inversion, freshness | `frontend/src/docTools/ops.ts` |
| Reveal/veto + splice adapter (per-host) | `frontend/src/docTools/editor.ts` |
| Lexical node-incremental `applySplice` | `frontend/src/editor/editor.tsx` (+ `index.tsx`) |
| Word `delete_paragraph` | `frontend/src/api/wordEditorAPI.ts` |
| Voice tool routing, undo stack, control invariants | `frontend/src/pages/my-words/voice/session.ts` |
Expand Down
12 changes: 6 additions & 6 deletions docs/prompt-packs-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ A pack is a small JSON document. Format sketch (field names provisional):
}
```

Execution: the pack's `prompt` becomes the `instructions` of a tool loop (the
manual loop pattern proven in
`frontend/src/pages/my-words/interaction/liveResponder.ts` — tools carry
schemas but no `execute`; the app routes each call), restricted to the tools
the pack declares. The loop runs through the existing LLM proxy under the
Execution: the pack's `prompt` becomes the `instructions` of a tool loop
(Chat's, `frontend/src/pages/chat/`: `streamText` with `toolApproval`, where the
app gates and answers each consequential call itself — see
[chat-document-tools.md](chat-document-tools.md)), restricted to the tools the
pack declares. The loop runs through the existing LLM proxy under the
user's own session; `pack_id` joins `client_id` in the usage and log
attribution.

Expand Down Expand Up @@ -123,7 +123,7 @@ editor, under the covenant.

| Tool | Essential schema | Who needs it | Precedent | Notes |
| --- | --- | --- | --- | --- |
| `view` | `{} → numbered paragraphs` | nearly everything | my-words `liveResponder`; moving into Chat per [#583](https://github.com/AIToolsLab/writing-tools/issues/583) | Paragraph numbers are loop-internal coordinates, never shown to the writer (my-words rule). |
| `view` | `{} → numbered paragraphs` | nearly everything | Chat (`pages/chat/chatTools.ts`), per [#583](https://github.com/AIToolsLab/writing-tools/issues/583) | Paragraph numbers are loop-internal coordinates, never shown to the writer (my-words rule). |
| `highlight` | `{phrase} → ok \| not_found` | Say-Back, Prior Self (*quote*), Table Read, Marginalia, doctext-style citations | my-words `highlight`; `EditorAPI.selectPhrase`; `docTextLink` | Anchor with a W3C **`TextQuoteSelector`** (exact + prefix/suffix), not a bare needle — see [Reuse over invention](#reuse-over-invention). Fixes [#589](https://github.com/AIToolsLab/writing-tools/issues/589), [#591](https://github.com/AIToolsLab/writing-tools/issues/591), [#592](https://github.com/AIToolsLab/writing-tools/issues/592) once for every pack. |
| `annotate` | `{selector, note, kind} → ok` | Marginalia, Reader's Reply, Table Read, any `lens` pack | Word comment API; Google Docs comments blocked on a Drive scope ([#580](https://github.com/AIToolsLab/writing-tools/issues/580)) | Same selector model. Falls back to an in-pane annotation list where the host can't do margin comments. |
| `propose_edit` | `{selector, replace, rationale} → accepted \| rejected \| not_found` | Blind Rewrite, rewording flows, my-words ops | my-words `str_replace`/`insert`/`move` + propose strategy; rooms plan's patch-with-confirm | **Always** confirm-gated. The `rationale` renders on the confirm card — the writer sees why, not just what. |
Expand Down
3 changes: 0 additions & 3 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,3 @@ node_modules/
/blob-report/
/playwright/.cache/
/playwright/.auth/

# Generated demo recordings (reproduce with `npx playwright test mywords-demo`)
/mywords-videos/
6 changes: 3 additions & 3 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,9 @@ prompt or messages fields. Use the instructions option instead.` — which reach
the writer as a generation error on every request. Pass the system prompt as the
`instructions` option (a string) alongside `messages`; the SDK puts it back at
the head of the prompt itself. This bites hardest where a page keeps a
conversation in state: the chat transcript (`pages/chat`) holds only the
doc-context, greeting, user, and assistant messages, with `CHAT_INSTRUCTIONS`
kept outside it.
conversation in state: the chat transcript (`pages/chat`, see
`transcript.ts`) holds only user, assistant, and tool messages, with
`CHAT_INSTRUCTIONS` kept outside it.

### Rendering model output

Expand Down
23 changes: 0 additions & 23 deletions frontend/mywords-demo.html

This file was deleted.

74 changes: 73 additions & 1 deletion frontend/src/api/__tests__/generate.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { MockLanguageModelV4 } from 'ai/test';
import { convertArrayToReadableStream } from '@ai-sdk/provider-utils/test';
import { tool } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { GenerationError } from '../errors';
import { generateFullText, streamTextDeltas } from '../generate';
import { generateFullText, streamParts, streamTextDeltas } from '../generate';

/**
* The stream-part shape the installed `ai` expects, read off the mock instead of
Expand Down Expand Up @@ -71,6 +73,11 @@ const FINISH: StreamPart = {
},
};

const TOOL_CALL_FINISH: StreamPart = {
...FINISH,
finishReason: { unified: 'tool-calls', raw: undefined },
};

async function collect(deltas: AsyncIterable<string>): Promise<string[]> {
const out: string[] = [];
for await (const delta of deltas) out.push(delta);
Expand Down Expand Up @@ -182,3 +189,68 @@ describe('generateFullText', () => {
consoleError.mockRestore();
});
});

describe('streamParts', () => {
it('yields tool calls and returns the response messages', async () => {
const parts = streamParts({
model: modelStreaming([
...textParts('Let me look.'),
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'view',
input: '{}',
},
TOOL_CALL_FINISH,
]),
// No `execute`: the loop stops at the call, as a staged edit would.
tools: { view: tool({ inputSchema: z.object({}) }) },
prompt: 'hi',
});

const types: string[] = [];
let next = await parts.next();
while (!next.done) {
types.push(next.value.type);
next = await parts.next();
}

expect(types).toContain('tool-call');
expect(next.value).toEqual([
{
role: 'assistant',
content: [
{ type: 'text', text: 'Let me look.' },
expect.objectContaining({
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'view',
}),
],
},
]);
});

it('throws a GenerationError instead of yielding an error part', async () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const parts = streamParts({
model: modelStreaming([
{ type: 'stream-start', warnings: [] },
{ type: 'error', error: QUOTA_ERROR },
FINISH,
]),
prompt: 'hi',
});

const types: string[] = [];
await expect(
(async () => {
for await (const part of parts) types.push(part.type);
})(),
).rejects.toThrow(GenerationError);
expect(types).not.toContain('error');
consoleError.mockRestore();
});
});
47 changes: 35 additions & 12 deletions frontend/src/api/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,22 @@
* surrounding `try/catch` never runs. That is how a quota failure reached the
* writer as a blank panel.
*
* {@link streamTextDeltas} reads `fullStream` instead, so it sees the `error`
* {@link streamParts} reads the full `stream` instead, so it sees the `error`
* part and throws a {@link GenerationError} the moment one arrives. Pages should
* use these helpers rather than calling `streamText` directly; a page that calls
* `streamText` itself silently reintroduces the blank-panel bug.
*/
import { streamText } from 'ai';
import {
streamText,
type ModelMessage,
type TextStreamPart,
type ToolSet,
} from 'ai';
import { describeGenerationError, GenerationError } from './errors';

type StreamTextOptions = Parameters<typeof streamText>[0];
type StreamTextOptions<TOOLS extends ToolSet = ToolSet> = Parameters<
typeof streamText<TOOLS>
>[0];

/** Wrap whatever the stream (or the transport) failed with. */
function asGenerationError(raw: unknown): GenerationError {
Expand All @@ -27,28 +34,44 @@ function asGenerationError(raw: unknown): GenerationError {
}

/**
* Stream a generation, yielding text deltas as they arrive.
* Stream a generation, yielding every part as it arrives — text deltas, tool
* calls and results, approval requests, step boundaries; never an `error`
* part, which is thrown instead — and returning the response messages
* (assistant and tool messages, across all steps) for the caller to keep as
* transcript. For callers that only want text, see {@link streamTextDeltas}.
*
* Throws a {@link GenerationError} as soon as the model or transport fails —
* including mid-stream, after deltas have already been yielded, so a caller that
* renders partial text keeps what arrived and can show the error alongside it.
* including mid-stream, after parts have already been yielded, so a caller that
* renders partial output keeps what arrived and can show the error alongside it.
* Aborting via `abortSignal` ends the iteration; callers that abort on purpose
* should check their own signal before treating a throw as a real failure.
*/
export async function* streamTextDeltas(
options: StreamTextOptions,
): AsyncGenerator<string, void, void> {
export async function* streamParts<TOOLS extends ToolSet = ToolSet>(
options: StreamTextOptions<TOOLS>,
): AsyncGenerator<TextStreamPart<TOOLS>, ModelMessage[], void> {
const result = streamText(options);
// `stream`, not `textStream`: the latter drops `error` parts on the floor.
// (`stream` is ai@7's name for what used to be `fullStream`, which is still
// there as a deprecated alias.)
for await (const part of result.stream) {
if (part.type === 'text-delta') {
yield part.text;
} else if (part.type === 'error') {
if (part.type === 'error') {
// Breaking out of the loop cancels the underlying stream.
throw asGenerationError(part.error);
}
yield part;
}
return await result.responseMessages;
}

/**
* Stream a generation, yielding text deltas as they arrive. Same error
* behaviour as {@link streamParts}, which it filters.
*/
export async function* streamTextDeltas(
options: StreamTextOptions,
): AsyncGenerator<string, void, void> {
for await (const part of streamParts(options)) {
if (part.type === 'text-delta') yield part.text;
}
}

Expand Down
12 changes: 4 additions & 8 deletions frontend/src/api/googleDocsEditorAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,14 +330,10 @@ export const googleDocsEditorAPI: EditorAPI = {
return text.split('\n');
},

// TODO(my-words): bridge to Apps Script (selectPhrase + replaceSelection for
// str_replace; insertTextAtCursor for insert). The GDocs multi-tab corpus
// (getAllTabs) is the exciting follow-up. Deferred — v1 targets standalone.
applyEdit(_edit: DocEdit): Promise<void> {
return Promise.reject(
new Error('applyEdit is not implemented for Google Docs yet'),
);
},
// No `applyEdit` yet, so edit tools are not offered here (#590). TODO:
// bridge to Apps Script (selectPhrase + replaceSelection for str_replace;
// insertTextAtCursor for insert). The GDocs multi-tab corpus (getAllTabs)
// is the exciting follow-up.
};

/**
Expand Down
Loading
Loading