From 46c73e8e394e9967fe5d54935f907338ce8a7041 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:01:48 +0000 Subject: [PATCH 01/11] Add "My Words" collaborative editor (v1, standalone) 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 Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U --- frontend/src/api/googleDocsEditorAPI.ts | 15 + frontend/src/api/wordEditorAPI.ts | 19 ++ frontend/src/components/navbar/index.tsx | 1 + frontend/src/contexts/editorContext.tsx | 5 + frontend/src/contexts/pageContext.tsx | 1 + frontend/src/editor/editor.tsx | 105 ++++++ frontend/src/editor/index.tsx | 60 +++- frontend/src/pages/app/index.tsx | 3 + .../pages/my-words/__tests__/corpus.test.ts | 115 +++++++ frontend/src/pages/my-words/corpus.ts | Bin 0 -> 5967 bytes frontend/src/pages/my-words/index.tsx | 319 ++++++++++++++++++ frontend/src/pages/my-words/styles.module.css | 132 ++++++++ frontend/src/types.d.ts | 12 + 13 files changed, 782 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/my-words/__tests__/corpus.test.ts create mode 100644 frontend/src/pages/my-words/corpus.ts create mode 100644 frontend/src/pages/my-words/index.tsx create mode 100644 frontend/src/pages/my-words/styles.module.css diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index 88573f2a..774d1291 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -231,6 +231,21 @@ export const googleDocsEditorAPI: EditorAPI = { throw new Error('Phrase not found'); } }, + + /** Full document text, used for the corpus and the `view` tool. */ + async getDocText(): Promise { + const ctx = await window.GoogleAppsScript.getDocContext(); + return `${ctx.beforeCursor || ''}${ctx.selectedText || ''}${ctx.afterCursor || ''}`; + }, + + // 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 { + return Promise.reject( + new Error('applyEdit is not implemented for Google Docs yet'), + ); + }, }; /** diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index 81202381..3d9bdcc9 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -212,4 +212,23 @@ export const wordEditorAPI: EditorAPI = { } }); }, + + /** Full document text, used for the corpus and the `view` tool. */ + async getDocText(): Promise { + return Word.run(async (context: Word.RequestContext) => { + const body = context.document.body; + context.load(body, 'text'); + await context.sync(); + return body.text.replace(/\r/g, '\n'); + }); + }, + + // TODO(my-words): implement for Word via Office.js body.search + replace + // (str_replace) and range.insertText (insert). Deferred — v1 targets the + // standalone editor only. + applyEdit(_edit: DocEdit): Promise { + return Promise.reject( + new Error('applyEdit is not implemented for Word yet'), + ); + }, }; diff --git a/frontend/src/components/navbar/index.tsx b/frontend/src/components/navbar/index.tsx index 05147e89..660e726c 100644 --- a/frontend/src/components/navbar/index.tsx +++ b/frontend/src/components/navbar/index.tsx @@ -24,6 +24,7 @@ const pageNames: Page[] = [ { name: PageName.Draft, title: 'Draft', hint: 'Generate suggestions' }, { name: PageName.Revise, title: 'Revise', hint: 'Improve your text' }, { name: PageName.Chat, title: 'Chat', hint: 'Ask about your doc' }, + { name: PageName.MyWords, title: 'My Words', hint: 'Shape your own words' }, ]; export default function Navbar() { diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx index 5fc98452..34202b71 100644 --- a/frontend/src/contexts/editorContext.tsx +++ b/frontend/src/contexts/editorContext.tsx @@ -18,4 +18,9 @@ export const EditorContext = createContext({ console.warn('selectPhrase is not implemented yet'); return new Promise((resolve) => resolve()); }, + getDocText: () => Promise.resolve(''), + applyEdit: () => { + console.warn('applyEdit is not implemented yet'); + return Promise.resolve(); + }, }); diff --git a/frontend/src/contexts/pageContext.tsx b/frontend/src/contexts/pageContext.tsx index 0c9f902e..bce6bcb6 100644 --- a/frontend/src/contexts/pageContext.tsx +++ b/frontend/src/contexts/pageContext.tsx @@ -5,6 +5,7 @@ export enum PageName { Chat = 'chat', Draft = 'draft', TagLinker = 'tag-linker', + MyWords = 'my-words', } export enum OverallMode { diff --git a/frontend/src/editor/editor.tsx b/frontend/src/editor/editor.tsx index ffee53b5..0e41f320 100644 --- a/frontend/src/editor/editor.tsx +++ b/frontend/src/editor/editor.tsx @@ -6,21 +6,122 @@ import { type InitialEditorStateType, LexicalComposer, } from '@lexical/react/LexicalComposer'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, $getRoot, $getSelection, $isRangeSelection, + $isTextNode, + $setSelection, type ElementNode, type LexicalNode, + type TextNode, } from 'lexical'; +import { useEffect } from 'react'; import classes from './editor.module.css'; +/** + * Imperative handle the "My Words" page uses to read and edit the standalone + * Lexical document. Mirrors the host-agnostic operations on EditorAPI; Word and + * Google Docs implement the same shape with their native APIs. + */ +export interface EditorControls { + getText: () => string; + /** Replace the whole document with plain text (paragraphs split on \n). */ + setText: (text: string) => void; + /** Select the first occurrence of `phrase` within a single paragraph. */ + selectPhrase: (phrase: string) => boolean; +} + +/** + * Lives inside LexicalComposer so it can grab the editor instance and hand a + * small imperative control surface back up to the EditorScreen. + */ +function ControlsPlugin({ + onReady, +}: { + onReady?: (controls: EditorControls) => void; +}) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!onReady) return; + + const controls: EditorControls = { + getText: () => + editor + .getEditorState() + .read(() => $getRoot().getTextContent()), + + setText: (text: string) => { + editor.update(() => { + const root = $getRoot(); + root.clear(); + for (const line of text.split('\n')) { + const paragraph = $createParagraphNode(); + if (line.length > 0) { + paragraph.append($createTextNode(line)); + } + root.append(paragraph); + } + }); + }, + + selectPhrase: (phrase: string) => { + let found = false; + editor.update(() => { + const textNodes: TextNode[] = []; + const collect = (node: LexicalNode) => { + if ($isTextNode(node)) { + textNodes.push(node); + } else if ('getChildren' in node) { + for (const child of ( + node as ElementNode + ).getChildren()) { + collect(child); + } + } + }; + collect($getRoot()); + + const needle = phrase.toLowerCase(); + for (const node of textNodes) { + const idx = node + .getTextContent() + .toLowerCase() + .indexOf(needle); + if (idx === -1) continue; + const selection = $createRangeSelection(); + selection.anchor.set(node.getKey(), idx, 'text'); + selection.focus.set( + node.getKey(), + idx + phrase.length, + 'text', + ); + $setSelection(selection); + found = true; + return; + } + }); + return found; + }, + }; + + onReady(controls); + }, [editor, onReady]); + + return null; +} + function $getDocContext(): DocContext { // Initialize default empty context const docContext: DocContext = { @@ -156,11 +257,13 @@ function LexicalEditor({ initialState, storageKey = 'doc', preamble, + onReady, }: { updateDocContext: (docContext: DocContext) => void; initialState: InitialEditorStateType | null; storageKey?: string; preamble?: JSX.Element; + onReady?: (controls: EditorControls) => void; }) { return ( + + diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 1ce35295..72db2bad 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -1,11 +1,11 @@ -import { useRef, useState, StrictMode, useMemo } from 'react'; +import { useCallback, useRef, useState, StrictMode, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; import { OverallMode, overallModeAtom } from '@/contexts/pageContext'; import * as SidebarInner from '@/pages/app'; import type { Auth0ContextInterface } from '@auth0/auth0-react'; import { useAtomValue, useSetAtom } from 'jotai'; -import LexicalEditor from './editor'; +import LexicalEditor, { type EditorControls } from './editor'; import './styles.css'; import classes from './styles.module.css'; import { EditorContext } from '@/contexts/editorContext'; @@ -33,6 +33,12 @@ export function EditorScreen({ afterCursor: '', }); + // Imperative handle into the Lexical document, populated once it mounts. + const controlsRef = useRef(null); + const handleEditorReady = useCallback((controls: EditorControls) => { + controlsRef.current = controls; + }, []); + // Since this is a list, a useState would have worked as well const selectionChangeHandlers = useRef<(() => void)[]>([]); @@ -78,9 +84,52 @@ export function EditorScreen({ else console.warn('Handler not found'); }, - selectPhrase(_text) { - console.warn('selectPhrase is not implemented yet'); - return new Promise((resolve) => resolve()); + selectPhrase(text) { + const found = controlsRef.current?.selectPhrase(text) ?? false; + return found + ? Promise.resolve() + : Promise.reject(new Error('Phrase not found')); + }, + getDocText: (): Promise => { + return Promise.resolve(controlsRef.current?.getText() ?? ''); + }, + applyEdit: (edit: DocEdit): Promise => { + const controls = controlsRef.current; + if (!controls) { + return Promise.reject(new Error('Editor is not ready yet')); + } + const current = controls.getText(); + + let next: string; + if (edit.type === 'str_replace') { + const idx = current.indexOf(edit.oldStr); + if (idx === -1) { + throw new Error( + `Could not find the text to replace: "${edit.oldStr}"`, + ); + } + next = + current.slice(0, idx) + + edit.newStr + + current.slice(idx + edit.oldStr.length); + } else if (edit.after !== undefined && edit.after !== '') { + const idx = current.indexOf(edit.after); + if (idx === -1) { + throw new Error( + `Could not find the anchor text: "${edit.after}"`, + ); + } + const at = idx + edit.after.length; + next = current.slice(0, at) + edit.text + current.slice(at); + } else { + // No anchor: insert at the current cursor / after the selection. + const { beforeCursor, selectedText, afterCursor } = + docContextRef.current; + next = beforeCursor + selectedText + edit.text + afterCursor; + } + + controls.setText(next); + return Promise.resolve(); }, }), []); @@ -131,6 +180,7 @@ export function EditorScreen({ updateDocContext={docUpdated} storageKey={getStorageKey()} preamble={editorPreamble} + onReady={handleEditorReady} /> {isDemo ? (
diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index 64f1ffe4..1120cac5 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -20,6 +20,7 @@ import { import { OnboardingCarousel } from '../carousel/OnboardingCarousel'; import Chat from '../chat'; import Draft from '../draft'; +import MyWords from '../my-words'; import Revise from '../revise'; import classes from './styles.module.css'; import Navbar from '@/components/navbar'; @@ -317,6 +318,8 @@ function AppInner() { return ; case PageName.Draft: return ; + case PageName.MyWords: + return ; } return null; } diff --git a/frontend/src/pages/my-words/__tests__/corpus.test.ts b/frontend/src/pages/my-words/__tests__/corpus.test.ts new file mode 100644 index 00000000..adb7a23d --- /dev/null +++ b/frontend/src/pages/my-words/__tests__/corpus.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCorpus, GLUE_WORDS, validateText } from '../corpus'; + +const corpusOf = (text: string) => buildCorpus({ docText: text }); + +describe('buildCorpus', () => { + it('collects words from all sources, dropping punctuation', () => { + const corpus = buildCorpus({ + docText: 'The cat sat.', + scratchpad: 'a quiet morning', + userMessages: ['I like dogs too'], + }); + expect(corpus.wordSet.has('cat')).toBe(true); + expect(corpus.wordSet.has('morning')).toBe(true); + expect(corpus.wordSet.has('dogs')).toBe(true); + expect(corpus.wordSet.has('.')).toBe(false); + }); + + it('does not let phrases span across separate sources', () => { + // "sat" ends docText and "dog" begins scratchpad; their adjacency is an + // artifact of concatenation and must not count as a corpus phrase. + const corpus = buildCorpus({ docText: 'cat sat', scratchpad: 'dog run' }); + expect(validateText('cat sat', corpus).ok).toBe(true); + expect(validateText('sat dog', corpus).ok).toBe(false); + }); +}); + +describe('validateText — phrase-level rule', () => { + it('accepts a verbatim phrase lifted from the corpus', () => { + const corpus = corpusOf('The quick brown fox jumped over the lazy dog.'); + expect(validateText('the quick brown fox', corpus).ok).toBe(true); + expect(validateText('lazy dog', corpus).ok).toBe(true); + }); + + it('accepts two corpus phrases bridged by a glue word', () => { + const corpus = corpusOf('I value honesty. I also value hard work.'); + // "honesty" and "hard work" are both in the corpus, bridged by "and". + expect(validateText('honesty and hard work', corpus).ok).toBe(true); + }); + + it('accepts punctuation inserted freely between lifted content', () => { + const corpus = corpusOf('honesty hard work'); + expect(validateText('honesty, hard work', corpus).ok).toBe(true); + expect(validateText('honesty: hard work!', corpus).ok).toBe(true); + }); + + it('rejects a new adjacency of two corpus words with no bridge', () => { + // "big" and "dog" both appear, but never adjacent and not glue-bridged. + const corpus = corpusOf('the big cat and the small dog'); + expect(validateText('big dog', corpus).ok).toBe(false); + // The glue-bridged version is allowed. + expect(validateText('big and dog', corpus).ok).toBe(true); + }); + + it('rejects a multi-word run that is not contiguous in the corpus', () => { + // Each bigram exists ("a b", "b c") but the trigram "a b c" never does. + const corpus = corpusOf('alpha beta. beta gamma.'); + expect(validateText('alpha beta', corpus).ok).toBe(true); + expect(validateText('beta gamma', corpus).ok).toBe(true); + expect(validateText('alpha beta gamma', corpus).ok).toBe(false); + }); + + it('rejects a novel content word the writer never used', () => { + const corpus = corpusOf('I enjoy writing essays.'); + const result = validateText('I enjoy painting', corpus); + expect(result.ok).toBe(false); + expect(result.offending).toContain('painting'); + }); + + it('allows a punctuation-only / glue-only edit', () => { + const corpus = corpusOf('anything at all'); + expect(validateText('.', corpus).ok).toBe(true); + expect(validateText('and', corpus).ok).toBe(true); + expect(validateText(' , ; — ', corpus).ok).toBe(true); + }); + + it('is case-insensitive when matching', () => { + const corpus = corpusOf('Reproducible Research Matters'); + expect(validateText('reproducible research', corpus).ok).toBe(true); + expect(validateText('REPRODUCIBLE RESEARCH', corpus).ok).toBe(true); + }); + + it('matches words with internal apostrophes and straightens curly quotes', () => { + const corpus = corpusOf("I don't think that's wise"); + expect(validateText("don't", corpus).ok).toBe(true); + // curly apostrophe in the proposed text should still match. + expect(validateText('don’t', corpus).ok).toBe(true); + }); + + it('reports a segmentation that labels lifted / glue / punct parts', () => { + const corpus = corpusOf('honesty hard work'); + const result = validateText('honesty and hard work, please', corpus); + // "please" is novel content => not ok. + expect(result.ok).toBe(false); + const kinds = result.segments.map((s) => s.kind); + expect(kinds).toContain('lifted'); + expect(kinds).toContain('glue'); + expect(kinds).toContain('punct'); + }); + + it('treats an empty proposal as trivially valid', () => { + const corpus = corpusOf('whatever'); + expect(validateText('', corpus).ok).toBe(true); + }); +}); + +describe('GLUE_WORDS', () => { + it('includes basic articles/conjunctions but excludes content connectives', () => { + expect(GLUE_WORDS.has('and')).toBe(true); + expect(GLUE_WORDS.has('the')).toBe(true); + expect(GLUE_WORDS.has('because')).toBe(false); + expect(GLUE_WORDS.has('however')).toBe(false); + }); +}); diff --git a/frontend/src/pages/my-words/corpus.ts b/frontend/src/pages/my-words/corpus.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab1429e23c347855c23fb1c1227e110fa77b6b24 GIT binary patch literal 5967 zcmbtY+j85;5zQ<46&>yaL7D=+kD;wqDY7c1T3@zgIo?E3)vBIJog3Uq@pRlkQ)bIdZ>pIrJ-wi{+Q~Bco!hKl>#~~AyspSp za<_yXM`P>E(v+&wve%Xk5G)2~d`jn7wX0U9T=IjPLS@?1y7m>#H5F!2>6{kMuEo&S znM%7McG#BeS{7nU8V3-i&z!2V)kfv`!Etq;p=_n9>Af|XrnUB7Ewvv_=+>H2=e(gU ziyh5(w5iLis#RrdNvh0AkzcE#m{3+&50AXA$S!DE)G)v?HDFz>4BWSQU1-9$P3<%# zrKL-)GT?)6D~s4pf4K0Y8TrZ~a8IV35OBy%hHdLAr3=95*&&X=8GmOqp4u1<$6=SR zF0`t!`-Hq@VNq{#O;L%|%xr=S7f_v zB_?B$sGRw$^*$9>Tx@~=116*cGf*j+1ta%Vhxg=wI`YV+g~Hp~CiM-a*};fugp|ut zIE?UG4p3#68UQ+^G(e~yV3SmSi?GXVN2+jI0g-0K-BSDCEP9;Iths4PV&xrp2?GY!w-E?4n>_AY+waT@W zj!u^;4dyyiwbz3Q4ORdHBXk2CNEW?)BOSRZpH?t5Lm z4667Y9Rr~);YxDd?@gy|D*L^m8cz8CQa)gvKe3{lVm8v|R~F~8+B*3_vCAhqR($yD z?BglBkX4nH4-*X9@S)_%;e03ekRAElnram;plit&j{5AEZl0x`dSp z&l?{uXiB-B*GtR>%Tch(%Nxhx_8lCZ(J=IwA^k!_77h6#DlQ)N2NVDmW1vSqOgmFE+I3^ zlYJ~eZlCfidxw4oz?|Cdm-M*#bbiL@IX6#32G36eKcrPx;F6!_n6e<-Og^$3)-LYg z%rpL?AZXCj%?i>4_*Xl8D}5h3B|&vid&7!_rV&^9u8ZR6uEeZ0K#ESwIHu{sYYZ~w zk#tSbZ3QM z=%#yhH2&rKpa1atb<-v4=Zhji(--P8q1HU}0rVM&yw#4|JO(3{BNm^Mvf4qjJ1|JH z7E%5YP3-(1fp^kLOgp1V!ZZdhbY-M1_}}XVXHpy=RtZ1Jl!egtB3a`WCtt%iq8Y;^YXg(pwZlMZ zRCcIhUh~M-9#Ihi;9yn&jMKxPYlAMvL!O*v4xR@v2iFdVhm_v2L^3apLz~n03WW?z zxEURgJUO!fATA@3MIaX^$)hl(wd|5vJDTC$&}~^MGE4+~!pTGVDQHby<_f(Zqj@~& z#AeT!$$Cy4aUCe>+-pFEoHw4P*h2yg7KVQq22Qu6{u~^@Tm~4V zxi-U1cx+(`Ffd8BBRc+$w)97K9lMO^v8e=lWl{W}(33tjhoC1%AM*9Qb5x^Gu``K@ zPbb{mTaq!&F|GNtmhR9DF0Y0o6YhTde1SW$hcbB>XxvT z`oNlR+ch1}fJVAfp7*<6tg>L9n7?M zpYR3;FPr#?-8Sh@I?bkP(7#6^Nb%}BHm=ckY&yg|BwoN5`QL4&-ClkBTlX`vQH zX+a+CbHJH|tL(Ulc4dl!^ct@_TG9#8?u|EdQnF8;4nzxIr z=`7#v!Qg-K8|xcj8>;XH#;*Ad-R=jKhzQHz4{`)b?$sx>qkY2)gHe=*Ca;OKs)Ih_ z-H;60JaIKJhdn5u%qecQEOt9)BZ6Q}*`LEjhQnsk9sfTc!g=lYU0|=8!kFR36Mn?k p8vA1E;C!%mN6fqT07K}#y)Jg0M$E^--ys?%ym&gCbe2d5{x4~wt+fCE literal 0 HcmV?d00001 diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx new file mode 100644 index 00000000..c9b6c2fc --- /dev/null +++ b/frontend/src/pages/my-words/index.tsx @@ -0,0 +1,319 @@ +import { + generateText, + jsonSchema, + type ModelMessage, + stepCountIs, + tool, +} from 'ai'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { AiOutlineSend } from 'react-icons/ai'; + +import { OPENAI_MODEL, openai } from '@/api/openai'; +import { EditorContext } from '@/contexts/editorContext'; +import { buildCorpus, validateText } from './corpus'; +import classes from './styles.module.css'; + +const SYSTEM_PROMPT = `You are a writing collaborator working under one strict rule: you may edit the writer's document, but you may NEVER introduce your own words or phrases. + +Every word you place in the document must come from the writer's own corpus — the document, their scratchpad, and their messages to you — joined only by punctuation and a small closed set of glue words (a, an, the, and, or, of, to, in, on, ...). The harness enforces this: any str_replace or insert whose text is not lifted from the corpus is REJECTED and returned to you with an explanation. + +How to work: +- Use \`view\` to read the current document before editing. +- Use \`str_replace\` and \`insert\` to weave the writer's existing phrases into clearer prose. Reuse their exact wording; only add punctuation and glue words. +- When you need words you don't have, do NOT invent them. Ask the writer a short question, or use \`highlight\` to point at the passage you're asking about. +- Take short turns. Your spoken replies must be one or two sentences — the writer sees them as fleeting captions, not a chat log. + +Prefer asking the writer for their words over guessing. Never pad your replies.`; + +/** A turn's worth of lightweight signals about what the writer just did. */ +function buildActivityNote(opts: { + scratchpad: string; + scratchpadChanged: boolean; + selectedText: string; +}): string | null { + const parts: string[] = []; + if (opts.scratchpadChanged && opts.scratchpad.trim().length > 0) { + parts.push(`The writer's scratchpad now reads:\n"""\n${opts.scratchpad}\n"""`); + } + if (opts.selectedText.trim().length > 0) { + parts.push(`The writer has selected this passage: "${opts.selectedText}"`); + } + return parts.length > 0 ? parts.join('\n\n') : null; +} + +export default function MyWords() { + const editorAPI = useContext(EditorContext); + + // The writer's own material. + const [scratchpad, setScratchpad] = useState(''); + const [sentMessages, setSentMessages] = useState([]); + + // Ephemeral AI caption — replaced each turn, never kept as scrollback. + const [aiUtterance, setAiUtterance] = useState( + "Tell me what you're trying to say, and I'll help you shape it in your own words.", + ); + const [isThinking, setIsThinking] = useState(false); + const [input, setInput] = useState(''); + + // Refs read by the tool loop / activity tracking (always latest values). + const scratchpadRef = useRef(scratchpad); + scratchpadRef.current = scratchpad; + const sentMessagesRef = useRef(sentMessages); + sentMessagesRef.current = sentMessages; + + // Running model transcript (assistant turns + tool calls/results). + const modelMessagesRef = useRef([]); + // What we last told the model, so signals stay lightweight (only deltas). + const lastSentScratchpadRef = useRef(''); + const selectedTextRef = useRef(''); + + // Track the document selection so we can surface it as an activity signal. + useEffect(() => { + const handler = () => { + void editorAPI + .getDocContext() + .then((ctx) => { + selectedTextRef.current = ctx.selectedText ?? ''; + }) + .catch(() => {}); + }; + editorAPI.addSelectionChangeHandler(handler); + return () => editorAPI.removeSelectionChangeHandler(handler); + }, [editorAPI]); + + const runTurn = useCallback(async () => { + setIsThinking(true); + + // Snapshot the writer's material for this turn. The document is read + // fresh inside each tool call, so edits the AI makes stay consistent. + const scratchpadNow = scratchpadRef.current; + const messagesNow = sentMessagesRef.current; + + const makeCorpus = async () => + buildCorpus({ + docText: await editorAPI.getDocText(), + scratchpad: scratchpadNow, + userMessages: messagesNow, + }); + + const tools = { + view: tool({ + description: + 'Read the current full text of the document being edited.', + inputSchema: jsonSchema>({ + type: 'object', + properties: {}, + additionalProperties: false, + }), + execute: async () => { + const text = await editorAPI.getDocText(); + return text.trim().length > 0 ? text : '(the document is empty)'; + }, + }), + str_replace: tool({ + description: + "Replace the first occurrence of old_str with new_str. new_str must be lifted from the writer's corpus (plus glue words/punctuation).", + inputSchema: jsonSchema<{ old_str: string; new_str: string }>({ + type: 'object', + properties: { + old_str: { + type: 'string', + description: 'Exact existing text to replace.', + }, + new_str: { + type: 'string', + description: + "Replacement text, drawn from the writer's words.", + }, + }, + required: ['old_str', 'new_str'], + additionalProperties: false, + }), + execute: async ({ old_str, new_str }) => { + const check = validateText(new_str, await makeCorpus()); + if (!check.ok) { + return `REJECTED: "${check.offending}" is not in the writer's words. Use only their phrases (plus glue words/punctuation), or ask them for the words you need.`; + } + try { + await editorAPI.applyEdit({ + type: 'str_replace', + oldStr: old_str, + newStr: new_str, + }); + return 'Applied.'; + } catch (e) { + return `Could not apply: ${(e as Error).message}`; + } + }, + }), + insert: tool({ + description: + "Insert text. If `after` is given, insert it right after that existing text; otherwise insert at the cursor. The text must be lifted from the writer's corpus (plus glue words/punctuation).", + inputSchema: jsonSchema<{ after?: string; text: string }>({ + type: 'object', + properties: { + after: { + type: 'string', + description: + 'Existing text to insert after (optional).', + }, + text: { + type: 'string', + description: + "Text to insert, drawn from the writer's words.", + }, + }, + required: ['text'], + additionalProperties: false, + }), + execute: async ({ after, text }) => { + const check = validateText(text, await makeCorpus()); + if (!check.ok) { + return `REJECTED: "${check.offending}" is not in the writer's words. Use only their phrases (plus glue words/punctuation), or ask them for the words you need.`; + } + try { + await editorAPI.applyEdit({ type: 'insert', after, text }); + return 'Applied.'; + } catch (e) { + return `Could not apply: ${(e as Error).message}`; + } + }, + }), + highlight: tool({ + description: + 'Select a passage in the document to point at it while asking the writer about it.', + inputSchema: jsonSchema<{ phrase: string }>({ + type: 'object', + properties: { + phrase: { + type: 'string', + description: 'Existing text to highlight.', + }, + }, + required: ['phrase'], + additionalProperties: false, + }), + execute: async ({ phrase }) => { + try { + await editorAPI.selectPhrase(phrase); + return 'Highlighted.'; + } catch { + return `Could not find "${phrase}" in the document.`; + } + }, + }), + }; + + try { + const result = await generateText({ + model: openai.chat(OPENAI_MODEL), + system: SYSTEM_PROMPT, + messages: modelMessagesRef.current, + tools, + stopWhen: stepCountIs(8), + }); + modelMessagesRef.current = [ + ...modelMessagesRef.current, + ...result.response.messages, + ]; + setAiUtterance(result.text.trim() || 'Done — take a look.'); + } catch (e) { + setAiUtterance(`⚠️ ${(e as Error).message}`); + } finally { + setIsThinking(false); + } + }, [editorAPI]); + + const send = useCallback(async () => { + const text = input.trim(); + if (!text || isThinking) return; + + // The writer's message becomes part of their corpus and the transcript. + setSentMessages((prev) => [...prev, text]); + setInput(''); + + const note = buildActivityNote({ + scratchpad: scratchpadRef.current, + scratchpadChanged: + scratchpadRef.current !== lastSentScratchpadRef.current, + selectedText: selectedTextRef.current, + }); + lastSentScratchpadRef.current = scratchpadRef.current; + + const content = note ? `${note}\n\n---\n\n${text}` : text; + modelMessagesRef.current = [ + ...modelMessagesRef.current, + { role: 'user', content }, + ]; + + await runTurn(); + }, [input, isThinking, runTurn]); + + return ( +
+
+ {isThinking ? ( + + + + + + ) : ( + aiUtterance + )} +
+ + +