From 9a18fde0bfe5f70872e3f32d28d8c7d87ed8d0f4 Mon Sep 17 00:00:00 2001
From: nhyiramante1
Date: Wed, 17 Jun 2026 15:55:40 -0400
Subject: [PATCH 01/15] feat(frontend): remove Auth0, make Better Auth the
default (Phase 2)
Make Better Auth the default auth on every real-auth surface and delete Auth0.
Demo mode stays (Google Docs runs in demo). Token now persists across refreshes.
- authTokenStore.ts (new): guarded localStorage token persistence.
- useDeviceAuth.ts: persist/clear + hydrate-on-mount (validate stored token,
restore session without a fresh device flow).
- appAuthContext.tsx: drop the Auth0 adapter + opt-in gate; Better Auth is the
default, Demo for demo mode.
- types.d.ts + editor APIs: replace EditorAPI.doLogin/doLogout(auth0Client) with
a surface-specific openExternal(url) (Word: openBrowserWindow guarded;
standalone/GDocs: window.open). Delete dead Auth0 code.
- app/index.tsx: remove Auth0Provider; approval link uses editorAPI.openExternal;
drop Microsoft/Facebook provider icons (Google only).
- Remove popup.tsx/popup.html + their webpack entries; remove AUTH0 DefinePlugin
entries from both webpack configs; remove @auth0/auth0-react.
- privacypolicy.html: minimal wording + TODO for team review.
No OpenAI route protection; no Word/Google Docs behavior change beyond auth.
Co-Authored-By: Claude Opus 4.8
---
frontend/package-lock.json | 51 +------
frontend/package.json | 1 -
.../src/api/__tests__/authTokenStore.test.ts | 70 +++++++++
frontend/src/api/authTokenStore.ts | 39 +++++
frontend/src/api/googleDocsEditorAPI.ts | 83 +----------
frontend/src/api/wordEditorAPI.ts | 133 ++----------------
frontend/src/contexts/appAuthContext.tsx | 77 ++--------
frontend/src/contexts/editorContext.tsx | 5 +-
frontend/src/editor/index.tsx | 22 +--
frontend/src/hooks/useDeviceAuth.ts | 47 +++++--
frontend/src/index-gdocs.tsx | 4 +-
frontend/src/pages/app/index.tsx | 64 +++------
frontend/src/popup.html | 23 ---
frontend/src/popup.tsx | 62 --------
frontend/src/static/privacypolicy.html | 5 +-
frontend/src/types.d.ts | 8 +-
frontend/webpack.config.js | 14 --
frontend/webpack.google-docs.config.js | 2 -
18 files changed, 217 insertions(+), 493 deletions(-)
create mode 100644 frontend/src/api/__tests__/authTokenStore.test.ts
create mode 100644 frontend/src/api/authTokenStore.ts
delete mode 100644 frontend/src/popup.html
delete mode 100644 frontend/src/popup.tsx
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f3c9c397..9ba5ded4 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,7 +10,6 @@
"license": "MIT",
"dependencies": {
"@ai-sdk/openai": "^2.0.0",
- "@auth0/auth0-react": "^2.2.4",
"@lexical/react": "^0.16.1",
"@posthog/react": "^1.9.0",
"@react-hook/window-size": "^3.1.1",
@@ -271,30 +270,6 @@
"license": "MIT",
"peer": true
},
- "node_modules/@auth0/auth0-react": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.8.0.tgz",
- "integrity": "sha512-f3KOkq+TW7AC3T+ZAo9G0hNL339z15C9q00QDVrMGCzZAPyp8lvDHKcAs21d/u+GzhU5zmssvJTQggDR7JqxSA==",
- "license": "MIT",
- "dependencies": {
- "@auth0/auth0-spa-js": "^2.7.0"
- },
- "peerDependencies": {
- "react": "^16.11.0 || ^17 || ^18 || ^19",
- "react-dom": "^16.11.0 || ^17 || ^18 || ^19"
- }
- },
- "node_modules/@auth0/auth0-spa-js": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.8.0.tgz",
- "integrity": "sha512-Lu3dBius0CMRHNAWtw/RyIZH0b5B4jV9ZlVjpp5s7A11AO/XyABkNl0VW7Cz5ZHpAkXEba1CMnkxDG1/9LNIqg==",
- "license": "MIT",
- "dependencies": {
- "browser-tabs-lock": "^1.2.15",
- "dpop": "^2.1.1",
- "es-cookie": "~1.3.2"
- }
- },
"node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
@@ -9478,16 +9453,6 @@
"node": ">=8"
}
},
- "node_modules/browser-tabs-lock": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz",
- "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==",
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "lodash": ">=4.17.21"
- }
- },
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -11231,15 +11196,6 @@
"node": ">=10"
}
},
- "node_modules/dpop": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz",
- "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/panva"
- }
- },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -11475,12 +11431,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/es-cookie": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
- "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==",
- "license": "MIT"
- },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -15720,6 +15670,7 @@
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "dev": true,
"license": "MIT"
},
"node_modules/lodash.debounce": {
diff --git a/frontend/package.json b/frontend/package.json
index bb8102b5..b7f5f029 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -46,7 +46,6 @@
},
"dependencies": {
"@ai-sdk/openai": "^2.0.0",
- "@auth0/auth0-react": "^2.2.4",
"@lexical/react": "^0.16.1",
"@posthog/react": "^1.9.0",
"@react-hook/window-size": "^3.1.1",
diff --git a/frontend/src/api/__tests__/authTokenStore.test.ts b/frontend/src/api/__tests__/authTokenStore.test.ts
new file mode 100644
index 00000000..d77da524
--- /dev/null
+++ b/frontend/src/api/__tests__/authTokenStore.test.ts
@@ -0,0 +1,70 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { clearToken, loadToken, persistToken } from '../authTokenStore';
+
+// Minimal in-memory localStorage stub so the store can be tested in the node
+// environment (no jsdom dependency).
+function makeStorage() {
+ const map = new Map();
+ return {
+ getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
+ setItem: (k: string, v: string) => {
+ map.set(k, v);
+ },
+ removeItem: (k: string) => {
+ map.delete(k);
+ },
+ clear: () => map.clear(),
+ };
+}
+
+beforeEach(() => {
+ vi.stubGlobal('localStorage', makeStorage());
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('authTokenStore', () => {
+ it('persists and loads a token round-trip', () => {
+ expect(loadToken()).toBeNull();
+ persistToken('tok-123');
+ expect(loadToken()).toBe('tok-123');
+ });
+
+ it('clears the token', () => {
+ persistToken('tok-123');
+ clearToken();
+ expect(loadToken()).toBeNull();
+ });
+
+ it('degrades gracefully when setItem throws (no token persisted)', () => {
+ vi.stubGlobal('localStorage', {
+ ...makeStorage(),
+ setItem: () => {
+ throw new Error('storage disabled');
+ },
+ });
+ expect(() => persistToken('tok-123')).not.toThrow();
+ });
+
+ it('returns null when getItem throws', () => {
+ vi.stubGlobal('localStorage', {
+ ...makeStorage(),
+ getItem: () => {
+ throw new Error('storage disabled');
+ },
+ });
+ expect(loadToken()).toBeNull();
+ });
+
+ it('does not throw when removeItem throws', () => {
+ vi.stubGlobal('localStorage', {
+ ...makeStorage(),
+ removeItem: () => {
+ throw new Error('storage disabled');
+ },
+ });
+ expect(() => clearToken()).not.toThrow();
+ });
+});
diff --git a/frontend/src/api/authTokenStore.ts b/frontend/src/api/authTokenStore.ts
new file mode 100644
index 00000000..4386f27e
--- /dev/null
+++ b/frontend/src/api/authTokenStore.ts
@@ -0,0 +1,39 @@
+/**
+ * Persisted Better Auth token store.
+ *
+ * Keeps the device-flow access token across page refreshes so Better Auth can be the
+ * default auth without forcing a fresh interactive login on every reload.
+ *
+ * Every localStorage access is guarded: Office task panes and other embedded browsers
+ * can throw on storage access under some privacy/host settings. On failure we degrade to
+ * in-memory-only for the session (load returns null; persist/clear become no-ops) so the
+ * app keeps working.
+ *
+ * Security note: localStorage is XSS-exposed. This matches Auth0's prior
+ * `cacheLocation="localstorage"` posture, so it does not raise the existing risk level.
+ */
+const TOKEN_KEY = 'betterauth.token';
+
+export function persistToken(token: string): void {
+ try {
+ localStorage.setItem(TOKEN_KEY, token);
+ } catch {
+ // Storage unavailable — fall back to in-memory for this session.
+ }
+}
+
+export function loadToken(): string | null {
+ try {
+ return localStorage.getItem(TOKEN_KEY);
+ } catch {
+ return null;
+ }
+}
+
+export function clearToken(): void {
+ try {
+ localStorage.removeItem(TOKEN_KEY);
+ } catch {
+ // No-op if storage is unavailable.
+ }
+}
diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts
index 88573f2a..14d60a53 100644
--- a/frontend/src/api/googleDocsEditorAPI.ts
+++ b/frontend/src/api/googleDocsEditorAPI.ts
@@ -8,8 +8,6 @@
* all document operations here go through Apps Script on Google's servers.
*/
-import type { Auth0ContextInterface } from '@auth0/auth0-react';
-
// Declare the global GoogleAppsScript bridge (defined in sidebar.html)
declare global {
interface Window {
@@ -111,82 +109,11 @@ function stopPolling() {
*/
export const googleDocsEditorAPI: EditorAPI = {
/**
- * Handles login for Google Docs.
- * Since users are already authenticated with Google, we use their Google identity.
- * For Auth0 integration, we could implement a popup flow similar to Word.
+ * Open a URL in a new browser tab. Google Docs runs in demo mode, so this is only
+ * used if the device-flow approval page is ever surfaced here.
*/
- async doLogin(auth0Client: Auth0ContextInterface): Promise {
- // Option 1: Use Google identity directly (simpler)
- // The user is already logged into Google, so we can use their email
- // as the identifier and skip Auth0 entirely for Google Docs.
-
- // Option 2: Implement Auth0 popup flow (for consistency with Word)
- // This would require opening a popup window and handling the OAuth flow.
-
- // For now, we'll use a simplified approach that works with Google identity
- console.log(
- 'Google Docs login: Using Google identity. Auth0 integration pending.',
- );
-
- // If Auth0 is required, we could implement a similar popup flow:
- // 1. Open a popup to Auth0 login URL
- // 2. Have the popup redirect back with tokens
- // 3. Store tokens via Apps Script user properties
-
- // Placeholder: trigger Auth0 login if needed
- try {
- await auth0Client.loginWithRedirect({
- openUrl: (url: string) => {
- // Open in a new window since we can't do redirects in an iframe
- const popup = window.open(
- url,
- 'auth0-login',
- 'width=500,height=600',
- );
-
- // Poll for completion (the popup should post a message when done)
- const pollTimer = setInterval(() => {
- if (popup?.closed) {
- clearInterval(pollTimer);
- // Check if we're now logged in
- auth0Client.getAccessTokenSilently().catch(() => {
- console.log('Auth0 login was cancelled or failed');
- });
- }
- }, 500);
- },
- });
- } catch (error) {
- console.error('Auth0 login error:', error);
- }
- },
-
- /**
- * Handles logout for Google Docs.
- */
- async doLogout(auth0Client: Auth0ContextInterface): Promise {
- try {
- await auth0Client.logout({
- openUrl: (url: string) => {
- // Open logout URL in a popup
- const popup = window.open(
- url,
- 'auth0-logout',
- 'width=500,height=400',
- );
-
- // Close popup after a brief delay
- setTimeout(() => {
- popup?.close();
- }, 2000);
- },
- logoutParams: {
- returnTo: window.location.origin,
- },
- });
- } catch (error) {
- console.error('Auth0 logout error:', error);
- }
+ openExternal(url: string): void {
+ window.open(url, '_blank', 'noopener');
},
/**
@@ -246,7 +173,7 @@ export function isRunningInGoogleDocs(): boolean {
/**
* Gets the current user's email from Google.
- * This can be used as a fallback identifier if Auth0 is not configured.
+ * Used as the identifier for the Google Docs surface (which runs in demo mode).
*/
export async function getGoogleUserEmail(): Promise {
if (!isRunningInGoogleDocs()) {
diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts
index 81202381..92833327 100644
--- a/frontend/src/api/wordEditorAPI.ts
+++ b/frontend/src/api/wordEditorAPI.ts
@@ -1,127 +1,14 @@
-import type { Auth0ContextInterface } from '@auth0/auth0-react';
-
export const wordEditorAPI: EditorAPI = {
- async doLogin(auth0Client: Auth0ContextInterface): Promise {
- let dialog: Office.Dialog;
-
- // Strategy: the popup will pass its redirect-callback data here, so we can pass it on to handleRedirectCallback
- const processMessage = (
- args:
- | { message: string; origin: string | undefined }
- | { error: number },
- ) => {
- if ('error' in args) {
-
- console.error('Error:', args.error);
- if (dialog) dialog.close();
- return;
- }
- const messageFromDialog = JSON.parse(args.message);
- if (dialog) dialog.close();
-
- if (messageFromDialog.status === 'success') {
- // The dialog reported a successful login.
- try {
- auth0Client.handleRedirectCallback(
- messageFromDialog.urlWithAuthInfo as string,
- );
- } catch (error) {
-
- console.error(
- 'auth0Client.handleRedirectCallback Error:',
- error,
- );
- }
- } else {
-
- console.error('Login failed.', messageFromDialog);
- }
- };
-
- await auth0Client.loginWithRedirect({
- openUrl: (url: string) => {
- try {
- const redirect = encodeURIComponent(url);
- const bounceURL =
- location.protocol +
- '//' +
- location.hostname +
- (location.port ? ':' + location.port : '') +
- '/popup.html?redirect=' +
- redirect;
- // height and width are percentages of the size of the screen.
- // How MS use it: https://github.com/OfficeDev/Office-Add-in-samples/blob/main/Samples/auth/Office-Add-in-Microsoft-Graph-React/utilities/office-apis-helpers.ts#L38
- Office.context.ui.displayDialogAsync(
- bounceURL,
- { height: 45, width: 55 },
- (result) => {
- dialog = result.value;
- dialog.addEventHandler(
- Office.EventType.DialogMessageReceived,
- processMessage,
- );
- },
- );
- } catch (error) {
-
- console.error('Error opening URL:', error);
- }
- },
- });
- },
-
- async doLogout(auth0Client: Auth0ContextInterface): Promise {
- let dialog: Office.Dialog;
-
- // Strategy: the popup will pass its redirect-callback data here, so we can pass it on to handleRedirectCallback
- const processMessage = (
- args:
- | { message: string; origin: string | undefined }
- | { error: number },
- ) => {
- dialog.close();
- if ('error' in args) {
-
- console.error('Error:', args.error);
- return;
- }
- const messageFromDialog = JSON.parse(args.message);
-
- if (messageFromDialog.status === 'success') {
- // The dialog reported a successful logout.
- // It seems like we don't need to do anything here, since the auth0 client library has already cleared its cached credentials.
- } else {
-
- console.error('Logout failed.', messageFromDialog);
- }
- };
-
- await auth0Client.logout({
- openUrl: (url: string) => {
- const redirect = encodeURIComponent(url);
- const bounceURL =
- location.protocol +
- '//' +
- location.hostname +
- (location.port ? ':' + location.port : '') +
- '/popup.html?redirect=' +
- redirect;
- Office.context.ui.displayDialogAsync(
- bounceURL,
- { height: 45, width: 55 },
- (result) => {
- dialog = result.value;
- dialog.addEventHandler(
- Office.EventType.DialogMessageReceived,
- processMessage,
- );
- },
- );
- },
- logoutParams: {
- returnTo: `${location.origin}/popup.html?logout=true`,
- },
- });
+ // Open the device-flow approval page in the system browser. Guarded so an Office
+ // host that doesn't expose openBrowserWindow fails explainably rather than silently.
+ openExternal(url: string): void {
+ if (Office?.context?.ui?.openBrowserWindow) {
+ Office.context.ui.openBrowserWindow(url);
+ } else {
+ throw new Error(
+ 'External browser login is not supported in this Office host.',
+ );
+ }
},
addSelectionChangeHandler: (handler: () => void) => {
diff --git a/frontend/src/contexts/appAuthContext.tsx b/frontend/src/contexts/appAuthContext.tsx
index 5380096d..e8ae870b 100644
--- a/frontend/src/contexts/appAuthContext.tsx
+++ b/frontend/src/contexts/appAuthContext.tsx
@@ -1,24 +1,17 @@
/**
- * Provider-neutral auth session.
+ * App auth session.
*
- * `AppInner` consumes `useAppAuth()` instead of calling `useAuth0()` directly, so the
- * visible login state (loading / authenticated / user / buttons) is decoupled from any
- * one provider. Three adapters supply the same `AppAuthSession` shape:
+ * `AppInner` consumes `useAppAuth()` for all visible login state (loading / authenticated
+ * / user / buttons), decoupled from the auth implementation. Two providers supply the
+ * same `AppAuthSession` shape:
*
- * - Auth0AuthProvider (default everywhere; preserves current behavior)
- * - BetterAuthProvider (opt-in: standalone editor + ?auth=betterauth)
- * - DemoAuthProvider (OverallMode.demo)
+ * - BetterAuthProvider (default for every real-auth surface)
+ * - DemoAuthProvider (OverallMode.demo — Google Docs, no-backend dev)
*
* Hook-rule safety: we never call adapter hooks conditionally. `AppAuthProvider` chooses
* WHICH provider component to render; each component unconditionally calls its own hooks
* and is only mounted when selected.
- *
- * SCAFFOLDING: `Auth0AuthProvider`, `DemoAuthProvider`, and the `AppAuthProvider`
- * selector are temporary compatibility shims that let Auth0 and Better Auth coexist
- * during migration. Once Better Auth becomes the default, this collapses into a single
- * Better Auth-only provider and the selector/adapters can be removed.
*/
-import { useAuth0 } from '@auth0/auth0-react';
import { useAtomValue } from 'jotai';
import {
createContext,
@@ -26,19 +19,17 @@ import {
useMemo,
type ReactNode,
} from 'react';
-import { detectPlatform } from '@/api';
import { AccessTokenProvider } from '@/contexts/authTokenContext';
-import { EditorContext } from '@/contexts/editorContext';
import { OverallMode, overallModeAtom } from '@/contexts/pageContext';
import { useDeviceAuth } from '@/hooks/useDeviceAuth';
export interface AppAuthSession {
- provider: 'auth0' | 'betterauth' | 'demo';
- isLoading: boolean; // initial session/provider loading only
+ provider: 'betterauth' | 'demo';
+ isLoading: boolean; // initial session/provider loading (incl. token hydration)
isAuthorizing: boolean; // device polling in progress (NOT isLoading)
isAuthenticated: boolean; // token present AND user loaded
user?: { name?: string; email?: string };
- error?: Error; // provider-level error (e.g. Auth0 error screen)
+ error?: Error; // provider-level error
authorization?: {
status: 'pending' | 'polling' | 'error';
userCode?: string;
@@ -51,7 +42,7 @@ export interface AppAuthSession {
}
const DEFAULT_SESSION: AppAuthSession = {
- provider: 'auth0',
+ provider: 'betterauth',
isLoading: false,
isAuthorizing: false,
isAuthenticated: false,
@@ -67,43 +58,6 @@ const AppAuthContext = createContext(DEFAULT_SESSION);
export const useAppAuth = (): AppAuthSession => useContext(AppAuthContext);
-/** True only on the standalone editor with the explicit opt-in query param. */
-function isBetterAuthOptIn(): boolean {
- if (typeof window === 'undefined') return false;
- if (detectPlatform() !== 'standalone') return false;
- return new URLSearchParams(window.location.search).get('auth') === 'betterauth';
-}
-
-// --- Auth0 adapter -------------------------------------------------------------
-
-function Auth0AuthProvider({ children }: { children: ReactNode }) {
- const auth0 = useAuth0();
- const editorAPI = useContext(EditorContext);
-
- const session = useMemo(
- () => ({
- provider: 'auth0',
- isLoading: auth0.isLoading,
- isAuthorizing: false,
- isAuthenticated: auth0.isAuthenticated,
- user: auth0.user
- ? { name: auth0.user.name, email: auth0.user.email }
- : undefined,
- error: auth0.error,
- getAccessToken: auth0.getAccessTokenSilently,
- login: () => editorAPI.doLogin(auth0),
- logout: () => editorAPI.doLogout(auth0),
- }),
- [auth0, editorAPI],
- );
-
- return (
-
- {children}
-
- );
-}
-
// --- Better Auth adapter -------------------------------------------------------
function BetterAuthProvider({ children }: { children: ReactNode }) {
@@ -130,7 +84,9 @@ function BetterAuthProvider({ children }: { children: ReactNode }) {
return {
provider: 'betterauth',
- isLoading: false,
+ // Hydrating a persisted token on mount is initial session load, shown via the
+ // "Waiting" screen — NOT the device-code UI.
+ isLoading: device.status === 'hydrating',
isAuthorizing,
isAuthenticated: device.status === 'success' && !!device.token,
user: device.user,
@@ -182,17 +138,14 @@ function DemoAuthProvider({ children }: { children: ReactNode }) {
// --- Selector + token bridge ---------------------------------------------------
-/** Renders exactly one adapter provider by mode + opt-in. */
+/** Renders the demo provider in demo mode, Better Auth everywhere else. */
export function AppAuthProvider({ children }: { children: ReactNode }) {
const mode = useAtomValue(overallModeAtom);
if (mode === OverallMode.demo) {
return {children} ;
}
- if (isBetterAuthOptIn()) {
- return {children} ;
- }
- return {children} ;
+ return {children} ;
}
/** Feeds the selected session's getAccessToken into the existing token context. */
diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx
index 5fc98452..641f2880 100644
--- a/frontend/src/contexts/editorContext.tsx
+++ b/frontend/src/contexts/editorContext.tsx
@@ -2,8 +2,9 @@ import { createContext } from 'react';
// Provides editor API functionality through context
export const EditorContext = createContext({
- doLogin: async () => {},
- doLogout: async () => {},
+ openExternal: (url: string) => {
+ window.open(url, '_blank', 'noopener');
+ },
getDocContext: () =>
new Promise((resolve) =>
resolve({
diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx
index 1ce35295..0f5f65bd 100644
--- a/frontend/src/editor/index.tsx
+++ b/frontend/src/editor/index.tsx
@@ -3,7 +3,6 @@ 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 './styles.css';
@@ -44,25 +43,8 @@ export function EditorScreen({
};
const editorAPI: EditorAPI = useMemo(() => ({
- doLogin: async (auth0Client: Auth0ContextInterface) => {
- try {
- await auth0Client.loginWithPopup();
- } catch (error) {
-
- console.error('auth0Client.loginWithPopup Error:', error);
- }
- },
- doLogout: async (auth0Client: Auth0ContextInterface) => {
- try {
- await auth0Client.logout({
- logoutParams: {
- returnTo: `${location.origin}/editor.html`,
- },
- });
- } catch (error) {
-
- console.error('auth0Client.logout Error:', error);
- }
+ openExternal: (url: string) => {
+ window.open(url, '_blank', 'noopener');
},
getDocContext: async (): Promise => {
return Promise.resolve(docContextRef.current);
diff --git a/frontend/src/hooks/useDeviceAuth.ts b/frontend/src/hooks/useDeviceAuth.ts
index d5e17f54..9adf3005 100644
--- a/frontend/src/hooks/useDeviceAuth.ts
+++ b/frontend/src/hooks/useDeviceAuth.ts
@@ -1,9 +1,11 @@
/**
* React hook driving the interactive Better Auth device flow.
*
- * Owns the state machine and an in-memory access token (never persisted). Polling is
- * cancellable: reset(), logout(), unmount, or a fresh start() abort the in-flight loop
- * via an AbortController so a stale tick cannot deliver a token after the user leaves.
+ * Owns the state machine and the access token. The token is persisted (guarded
+ * localStorage) so a page refresh restores the session via hydrate-on-mount instead of
+ * forcing a fresh interactive login. Polling is cancellable: reset(), logout(), unmount,
+ * or a fresh start() abort the in-flight loop via an AbortController. The abort signal is
+ * the single source of truth for cancellation, so no mounted flag is needed.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import {
@@ -14,9 +16,11 @@ import {
requestDeviceCode,
signOut as signOutRequest,
} from '@/api/deviceAuth';
+import { clearToken, loadToken, persistToken } from '@/api/authTokenStore';
export type DeviceAuthStatus =
| 'idle'
+ | 'hydrating' // validating a persisted token on mount
| 'pending' // requesting the device code
| 'polling' // waiting for the user to approve in the browser
| 'success'
@@ -65,6 +69,7 @@ export function useDeviceAuth(): UseDeviceAuth {
const reset = useCallback(() => {
abortInFlight();
tokenRef.current = undefined;
+ clearToken();
setState(INITIAL);
}, [abortInFlight]);
@@ -132,6 +137,7 @@ export function useDeviceAuth(): UseDeviceAuth {
controller.signal,
);
tokenRef.current = result.accessToken;
+ persistToken(result.accessToken);
safeSet(controller, {
status: 'success',
token: result.accessToken,
@@ -154,23 +160,48 @@ export function useDeviceAuth(): UseDeviceAuth {
if (token) {
await signOutRequest(token);
}
- } catch {
+ } catch {
// Best-effort, ignore server sign-out failure. Clear local state regardless.
} finally {
- // Clear local state regardless of sign-out success, since the token is the only proof of auth.
+ // Clear local state regardless of sign-out success, since the token is the
+ // only proof of auth.
tokenRef.current = undefined;
+ clearToken();
setState(INITIAL);
}
}, [abortInFlight]);
useEffect(() => {
- // Abort any in-flight device flow when the component unmounts.
- // The signal is the source of truth so no mounted flag is needed.
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ // Hydrate: if a token was persisted, validate it instead of forcing a new login.
+ const stored = loadToken();
+ if (stored) {
+ setState({ status: 'hydrating' });
+ fetchUserInfo(stored, controller.signal)
+ .then((user) => {
+ tokenRef.current = stored;
+ safeSet(controller, {
+ status: 'success',
+ token: stored,
+ user,
+ });
+ })
+ .catch(() => {
+ if (controller.signal.aborted) return;
+ // Stale/invalid token — drop it and fall back to login.
+ clearToken();
+ safeSet(controller, { status: 'idle' });
+ });
+ }
+
+ // Abort any in-flight device flow / hydration when the component unmounts.
return () => {
abortRef.current?.abort();
abortRef.current = null;
};
- }, []);
+ }, [safeSet]);
return { ...state, start, reset, logout };
}
diff --git a/frontend/src/index-gdocs.tsx b/frontend/src/index-gdocs.tsx
index 7fc84bc2..2d8a34ec 100644
--- a/frontend/src/index-gdocs.tsx
+++ b/frontend/src/index-gdocs.tsx
@@ -4,7 +4,7 @@
* Unlike the Word add-in (index.tsx), this doesn't require Office.onReady.
* It initializes immediately when loaded in the Google Docs sidebar.
*
- * Uses demo mode to bypass Auth0 since users are already authenticated with Google.
+ * Uses demo mode (no interactive sign-in) since users are already authenticated with Google.
*/
import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
@@ -26,7 +26,7 @@ declare global {
}
}
-// Create a Jotai store with demo mode pre-set to bypass Auth0
+// Create a Jotai store with demo mode pre-set (no interactive sign-in)
const store = createStore();
store.set(overallModeAtom, OverallMode.demo);
diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx
index 64f1ffe4..1dc13aa6 100644
--- a/frontend/src/pages/app/index.tsx
+++ b/frontend/src/pages/app/index.tsx
@@ -1,15 +1,15 @@
-import { Auth0Provider } from '@auth0/auth0-react';
import { PostHogProvider, PostHogErrorBoundary } from '@posthog/react';
import { useWindowSize } from '@react-hook/window-size/throttled';
import { useAtomValue } from 'jotai';
-import { useState } from 'react';
-import { CgFacebook, CgGoogle, CgMicrosoft } from 'react-icons/cg';
+import { useContext, useState } from 'react';
+import { CgGoogle } from 'react-icons/cg';
import {
AppAuthProvider,
AppAuthTokenBridge,
useAppAuth,
} from '@/contexts/appAuthContext';
import { useAccessToken } from '@/contexts/authTokenContext';
+import { EditorContext } from '@/contexts/editorContext';
import ChatContextWrapper from '@/contexts/chatContext';
import {
OverallMode,
@@ -32,7 +32,8 @@ const POSTHOG_HOST = 'https://e.thoughtful-ai.com/';
const POSTHOG_ENABLED = true;
// Device-flow status surfaced during Better Auth sign-in. Shows the user code and a
-// user-clicked link that opens the approval page in a new tab (no focus-stealing).
+// button that opens the approval page in the system browser via the surface-specific
+// EditorAPI.openExternal (Word: openBrowserWindow; standalone/GDocs: new tab).
// Rendered inside the not-logged-in screen, NOT gated by the isLoading "Waiting" screen.
function DeviceAuthStatus({
authorization,
@@ -44,6 +45,8 @@ function DeviceAuthStatus({
error?: string;
};
}) {
+ const editorAPI = useContext(EditorContext);
+
if (!authorization) return null;
if (authorization.status === 'error') {
@@ -87,22 +90,15 @@ function DeviceAuthStatus({
{authorization.verificationUri ? (
- {
+ editorAPI.openExternal(authorization.verificationUri!);
}}
>
Open approval page →
-
+
) : null}
Open the approval page and enter the code above to continue.
@@ -232,11 +228,9 @@ function AppInner() {
- Available Auth Providers
+ Sign in with Google
-
-
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/frontend/src/popup.html b/frontend/src/popup.html
deleted file mode 100644
index 4d59c452..00000000
--- a/frontend/src/popup.html
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
Thoughtful
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/popup.tsx b/frontend/src/popup.tsx
deleted file mode 100644
index f2c7ae1c..00000000
--- a/frontend/src/popup.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-Office.onReady(() => {
- // From https://github.com/OfficeDev/Office-Add-in-Auth0/blob/master/Scripts/popup.js
- // and https://github.com/OfficeDev/Office-Add-in-Auth0/blob/master/Scripts/popupRedirect.js
- // (kca simplified this to just use a single page)
- const DEBUG = false;
- const searchParams = new URLSearchParams(window.location.search);
- const auth0Domain = process.env.AUTH0_DOMAIN;
- const redirect = searchParams.get('redirect');
- function constructValidatedURL(url: URL) {
- if (url.hostname !== auth0Domain) {
- return null;
- }
- const validatedURL = new URL(`https://${auth0Domain}`);
- if (url.pathname === '/authorize' || url.pathname === '/v2/logout') {
- validatedURL.pathname = url.pathname;
- validatedURL.search = url.search;
- }
- return validatedURL;
- }
- if (redirect) {
- const validatedURL = constructValidatedURL(new URL(redirect));
- if (!validatedURL) {
- document.body.innerText = `Invalid redirect URL: ${redirect}`;
- return;
- }
- if (DEBUG) {
- document.body.innerText = `Redirecting to ${validatedURL.href}`;
- }
- setTimeout(
- () => {
- window.location.href = validatedURL.href;
- },
- DEBUG ? 5000 : 0,
- );
- } else {
- // Note: this will also get called with `logout=true` in the logout flow, but
- // the only thing we need to do here is message the parent to get the dialog to close,
- // so it's fine to take the same action in both cases.
- const message = {
- status: 'success',
- urlWithAuthInfo: window.location.href,
- };
- if (DEBUG) {
- document.body.innerText = `Messaging parent with ${JSON.stringify(
- message,
- )}`;
- }
- setTimeout(
- () => {
- if (Office.context && Office.context.ui) {
- Office.context.ui.messageParent(JSON.stringify(message));
- } else {
-
- console.error(
- 'Could not message parent: Office.context.ui is undefined',
- );
- }
- },
- DEBUG ? 5000 : 0,
- );
- }
-});
diff --git a/frontend/src/static/privacypolicy.html b/frontend/src/static/privacypolicy.html
index eeff047f..ee4a0c46 100644
--- a/frontend/src/static/privacypolicy.html
+++ b/frontend/src/static/privacypolicy.html
@@ -200,7 +200,8 @@
Usage Data
- Basic authentication information provided through Auth0
+
+ Basic authentication information provided through our authentication provider
Email address (if you choose to provide it for support purposes)
@@ -253,7 +254,7 @@
Service Providers
Cloud computing infrastructure
AI processing capabilities
- Authentication (Auth0)
+ Authentication (Google sign-in)
Microsoft Office Add-in platform
All service providers are bound by strict confidentiality agreements
diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts
index d79a06ca..a8e600b2 100644
--- a/frontend/src/types.d.ts
+++ b/frontend/src/types.d.ts
@@ -23,8 +23,12 @@ interface SavedItem {
}
interface EditorAPI {
- doLogin(auth0Client: Auth0ContextInterface): Promise
;
- doLogout(auth0Client: Auth0ContextInterface): Promise;
+ /**
+ * Open a URL in the user's external browser. Surface-specific: Word uses
+ * Office.context.ui.openBrowserWindow; standalone/Google Docs use window.open.
+ * Used to open the Better Auth device-flow approval page.
+ */
+ openExternal(url: string): void;
getDocContext(this: void): Promise;
addSelectionChangeHandler: (handler: () => void) => void;
removeSelectionChangeHandler: (handler: () => void) => void;
diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js
index 2e1a9a62..fc2cb9ce 100644
--- a/frontend/webpack.config.js
+++ b/frontend/webpack.config.js
@@ -37,13 +37,6 @@ module.exports = async (env = {}, options = {}) => {
import: ['./src/logs/index.tsx', './src/logs/logs.html'],
dependOn: 'react'
},
- popup: {
- import: [
- './src/popup.tsx',
- './src/popup.html'
- ],
- dependOn: 'react'
- },
editor: {
import: ['./src/editor/index.tsx', './src/editor/editor.html'],
dependOn: 'react'
@@ -144,11 +137,6 @@ module.exports = async (env = {}, options = {}) => {
template: './src/logs/logs.html',
chunks: ['logs', 'react']
}),
- new HtmlWebpackPlugin({
- filename: 'popup.html',
- template: './src/popup.html',
- chunks: ['polyfill', 'popup', 'react']
- }),
new HtmlWebpackPlugin({
filename: 'commands.html',
template: './src/commands/commands.html',
@@ -158,8 +146,6 @@ module.exports = async (env = {}, options = {}) => {
Promise: ['es6-promise', 'Promise']
}),
new webpack.DefinePlugin({
- 'process.env.AUTH0_DOMAIN': JSON.stringify('dev-rbroo1fvav24wamu.us.auth0.com'),
- 'process.env.AUTH0_CLIENT_ID': JSON.stringify('YZhokQZRgE2YUqU5Is9LcaMiCzujoaVr'),
// Device-flow client ID; must match a value in the backend's
// BETTER_AUTH_DEVICE_CLIENT_IDS allowlist. Override via env for other envs.
'process.env.BETTER_AUTH_DEVICE_CLIENT_ID': JSON.stringify(
diff --git a/frontend/webpack.google-docs.config.js b/frontend/webpack.google-docs.config.js
index a33ab350..68c6ac08 100644
--- a/frontend/webpack.google-docs.config.js
+++ b/frontend/webpack.google-docs.config.js
@@ -53,8 +53,6 @@ module.exports = (_env = {}, options = {}) => {
filename: '[name].css'
}),
new webpack.DefinePlugin({
- 'process.env.AUTH0_DOMAIN': JSON.stringify('dev-rbroo1fvav24wamu.us.auth0.com'),
- 'process.env.AUTH0_CLIENT_ID': JSON.stringify('YZhokQZRgE2YUqU5Is9LcaMiCzujoaVr'),
'process.env.NODE_ENV': JSON.stringify(options.mode || 'development'),
// Backend origin for the Google Docs sidebar. Empty in dev (the sidebar
// reaches the backend through this dev server's /api proxy); in prod the
From 46c73e8e394e9967fe5d54935f907338ce8a7041 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 25 Jun 2026 17:01:48 +0000
Subject: [PATCH 02/15] 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&(SddeQI4@PUzb3)EiIoIk^R
zh|AknfPxkR2(8u=F2=UQfy3HOzGzT66nvxelj{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
+ )}
+
+
+
+ Your words — scratchpad
+
+
+ );
+}
diff --git a/frontend/src/pages/my-words/styles.module.css b/frontend/src/pages/my-words/styles.module.css
new file mode 100644
index 00000000..0e4b8d3f
--- /dev/null
+++ b/frontend/src/pages/my-words/styles.module.css
@@ -0,0 +1,132 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ gap: 0.5rem;
+ padding: 0.75rem;
+ box-sizing: border-box;
+}
+
+/* Ephemeral AI caption — closed-caption style, no scrollback. */
+.aiCaption {
+ flex: 0 0 auto;
+ min-height: 2.75rem;
+ display: flex;
+ align-items: center;
+ padding: 0.5rem 0.75rem;
+ border-radius: 8px;
+ background: #1f2937;
+ color: #f9fafb;
+ font-size: 0.9rem;
+ line-height: 1.35;
+}
+
+.thinking {
+ display: inline-flex;
+ gap: 4px;
+}
+.thinking span {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #9ca3af;
+ animation: blink 1.4s infinite both;
+}
+.thinking span:nth-child(2) {
+ animation-delay: 0.2s;
+}
+.thinking span:nth-child(3) {
+ animation-delay: 0.4s;
+}
+@keyframes blink {
+ 0%,
+ 80%,
+ 100% {
+ opacity: 0.2;
+ }
+ 40% {
+ opacity: 1;
+ }
+}
+
+.scratchLabel {
+ flex: 0 0 auto;
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #6b7280;
+}
+
+/* The corpus view takes most of the height. */
+.scratchpad {
+ flex: 1 1 auto;
+ resize: none;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ font-size: 0.95rem;
+ line-height: 1.5;
+ outline: none;
+}
+.scratchpad:focus {
+ border-color: #2563eb;
+}
+
+.saidList {
+ flex: 0 0 auto;
+ max-height: 6rem;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+.said {
+ align-self: flex-end;
+ max-width: 90%;
+ padding: 0.35rem 0.6rem;
+ border-radius: 10px;
+ background: #e0e7ff;
+ color: #1e293b;
+ font-size: 0.85rem;
+}
+
+.inputRow {
+ flex: 0 0 auto;
+ display: flex;
+ gap: 0.5rem;
+ align-items: flex-end;
+}
+.input {
+ flex: 1 1 auto;
+ resize: none;
+ max-height: 120px;
+ box-sizing: border-box;
+ padding: 0.55rem 0.7rem;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ outline: none;
+}
+.input:focus {
+ border-color: #2563eb;
+}
+.sendBtn {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ border: none;
+ border-radius: 8px;
+ background: #2563eb;
+ color: #fff;
+ cursor: pointer;
+}
+.sendBtn:disabled {
+ background: #9ca3af;
+ cursor: default;
+}
diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts
index d79a06ca..b3b633d2 100644
--- a/frontend/src/types.d.ts
+++ b/frontend/src/types.d.ts
@@ -22,6 +22,14 @@ interface SavedItem {
dateSaved: Date;
}
+/**
+ * A document edit the AI proposes through the "My Words" tools. The harness
+ * validates the inserted text against the writer's corpus before applying.
+ */
+type DocEdit =
+ | { type: 'str_replace'; oldStr: string; newStr: string }
+ | { type: 'insert'; after?: string; text: string };
+
interface EditorAPI {
doLogin(auth0Client: Auth0ContextInterface): Promise;
doLogout(auth0Client: Auth0ContextInterface): Promise;
@@ -29,6 +37,10 @@ interface EditorAPI {
addSelectionChangeHandler: (handler: () => void) => void;
removeSelectionChangeHandler: (handler: () => void) => void;
selectPhrase: (text: string) => Promise;
+ /** Full document text. Host-agnostic accessor for the corpus + `view` tool. */
+ getDocText(this: void): Promise;
+ /** Apply a validated edit to the document. */
+ applyEdit(this: void, edit: DocEdit): Promise;
}
interface ReflectionResponseItem {
From 1199711b630b5ea390f79bd638217d02d4dc04c0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 25 Jun 2026 18:03:26 +0000
Subject: [PATCH 03/15] Implement applyEdit for Word (My Words)
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
Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
---
frontend/src/api/wordEditorAPI.ts | 65 +++++++++++++++++++++++++++----
1 file changed, 58 insertions(+), 7 deletions(-)
diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts
index 3d9bdcc9..4b0c23c8 100644
--- a/frontend/src/api/wordEditorAPI.ts
+++ b/frontend/src/api/wordEditorAPI.ts
@@ -223,12 +223,63 @@ export const wordEditorAPI: EditorAPI = {
});
},
- // 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'),
- );
+ /**
+ * Apply a validated edit to the Word document. If the user has Track Changes
+ * on (Review ribbon → changeTrackingMode = TrackAll), these edits are
+ * recorded as revisions they can accept or reject — no extra work here.
+ *
+ * Note: Word's body.search() is limited to ~255 characters and does not match
+ * across paragraph breaks, so this supports sentence/phrase-level edits (how
+ * the AI already works), not multi-paragraph spans.
+ */
+ applyEdit(edit: DocEdit): Promise {
+ return Word.run(async (context: Word.RequestContext) => {
+ const body = context.document.body;
+ const searchOptions: Word.SearchOptions | object = {
+ matchCase: false,
+ matchWildcards: false,
+ ignorePunct: false,
+ ignoreSpace: false,
+ };
+
+ if (edit.type === 'str_replace') {
+ const results = body.search(edit.oldStr, searchOptions);
+ context.load(results, 'items');
+ await context.sync();
+ if (results.items.length === 0) {
+ throw new Error(
+ `Could not find the text to replace: "${edit.oldStr}"`,
+ );
+ }
+ results.items[0].insertText(
+ edit.newStr,
+ Word.InsertLocation.replace,
+ );
+ await context.sync();
+ return;
+ }
+
+ // insert
+ if (edit.after !== undefined && edit.after !== '') {
+ const results = body.search(edit.after, searchOptions);
+ context.load(results, 'items');
+ await context.sync();
+ if (results.items.length === 0) {
+ throw new Error(
+ `Could not find the anchor text: "${edit.after}"`,
+ );
+ }
+ results.items[0].insertText(
+ edit.text,
+ Word.InsertLocation.after,
+ );
+ } else {
+ // No anchor: insert at the current cursor / replace the selection.
+ context.document
+ .getSelection()
+ .insertText(edit.text, Word.InsertLocation.replace);
+ }
+ await context.sync();
+ });
},
};
From de5bdece12d5b960dc53e2999e1c971165e3b3ea Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 25 Jun 2026 18:10:15 +0000
Subject: [PATCH 04/15] Add paragraph-numbered view + paragraph-targeted insert
(My Words)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
---
frontend/src/api/googleDocsEditorAPI.ts | 7 ++++
frontend/src/api/wordEditorAPI.ts | 35 ++++++++++++++++-
frontend/src/contexts/editorContext.tsx | 1 +
frontend/src/editor/editor.tsx | 11 ++++++
frontend/src/editor/index.tsx | 16 ++++++++
frontend/src/pages/my-words/index.tsx | 50 +++++++++++++++++++------
frontend/src/types.d.ts | 19 +++++++++-
7 files changed, 126 insertions(+), 13 deletions(-)
diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts
index 774d1291..6d1d1629 100644
--- a/frontend/src/api/googleDocsEditorAPI.ts
+++ b/frontend/src/api/googleDocsEditorAPI.ts
@@ -238,6 +238,13 @@ export const googleDocsEditorAPI: EditorAPI = {
return `${ctx.beforeCursor || ''}${ctx.selectedText || ''}${ctx.afterCursor || ''}`;
},
+ /** Paragraphs in order — the coordinate system for `view` and inserts. */
+ async getParagraphs(): Promise {
+ const ctx = await window.GoogleAppsScript.getDocContext();
+ const text = `${ctx.beforeCursor || ''}${ctx.selectedText || ''}${ctx.afterCursor || ''}`;
+ 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.
diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts
index 4b0c23c8..db90d0a4 100644
--- a/frontend/src/api/wordEditorAPI.ts
+++ b/frontend/src/api/wordEditorAPI.ts
@@ -223,6 +223,16 @@ export const wordEditorAPI: EditorAPI = {
});
},
+ /** Paragraphs in order — the coordinate system for `view` and inserts. */
+ async getParagraphs(): Promise {
+ return Word.run(async (context: Word.RequestContext) => {
+ const paragraphs = context.document.body.paragraphs;
+ context.load(paragraphs, 'items/text');
+ await context.sync();
+ return paragraphs.items.map((p) => p.text.replace(/\r/g, '\n'));
+ });
+ },
+
/**
* Apply a validated edit to the Word document. If the user has Track Changes
* on (Review ribbon → changeTrackingMode = TrackAll), these edits are
@@ -259,7 +269,30 @@ export const wordEditorAPI: EditorAPI = {
return;
}
- // insert
+ // insert — by paragraph number (robust; avoids the search limit)
+ if (edit.paragraph !== undefined) {
+ const paragraphs = body.paragraphs;
+ context.load(paragraphs, 'items');
+ await context.sync();
+ if (
+ edit.paragraph < 1 ||
+ edit.paragraph > paragraphs.items.length
+ ) {
+ throw new Error(
+ `Paragraph ${edit.paragraph} is out of range (1–${paragraphs.items.length}).`,
+ );
+ }
+ paragraphs.items[edit.paragraph - 1].insertParagraph(
+ edit.text,
+ edit.position === 'before'
+ ? Word.InsertLocation.before
+ : Word.InsertLocation.after,
+ );
+ await context.sync();
+ return;
+ }
+
+ // insert — after an anchor string (within a paragraph)
if (edit.after !== undefined && edit.after !== '') {
const results = body.search(edit.after, searchOptions);
context.load(results, 'items');
diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx
index 34202b71..52e4d47d 100644
--- a/frontend/src/contexts/editorContext.tsx
+++ b/frontend/src/contexts/editorContext.tsx
@@ -19,6 +19,7 @@ export const EditorContext = createContext({
return new Promise((resolve) => resolve());
},
getDocText: () => Promise.resolve(''),
+ getParagraphs: () => Promise.resolve([]),
applyEdit: () => {
console.warn('applyEdit is not implemented yet');
return Promise.resolve();
diff --git a/frontend/src/editor/editor.tsx b/frontend/src/editor/editor.tsx
index 0e41f320..65bb6aa0 100644
--- a/frontend/src/editor/editor.tsx
+++ b/frontend/src/editor/editor.tsx
@@ -36,6 +36,8 @@ import classes from './editor.module.css';
*/
export interface EditorControls {
getText: () => string;
+ /** Top-level paragraphs in order — the coordinate system for `view`. */
+ getParagraphs: () => 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. */
@@ -62,6 +64,15 @@ function ControlsPlugin({
.getEditorState()
.read(() => $getRoot().getTextContent()),
+ getParagraphs: () =>
+ editor
+ .getEditorState()
+ .read(() =>
+ $getRoot()
+ .getChildren()
+ .map((node) => node.getTextContent()),
+ ),
+
setText: (text: string) => {
editor.update(() => {
const root = $getRoot();
diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx
index 72db2bad..bb5f07c8 100644
--- a/frontend/src/editor/index.tsx
+++ b/frontend/src/editor/index.tsx
@@ -93,6 +93,9 @@ export function EditorScreen({
getDocText: (): Promise => {
return Promise.resolve(controlsRef.current?.getText() ?? '');
},
+ getParagraphs: (): Promise => {
+ return Promise.resolve(controlsRef.current?.getParagraphs() ?? []);
+ },
applyEdit: (edit: DocEdit): Promise => {
const controls = controlsRef.current;
if (!controls) {
@@ -112,6 +115,19 @@ export function EditorScreen({
current.slice(0, idx) +
edit.newStr +
current.slice(idx + edit.oldStr.length);
+ } else if (edit.paragraph !== undefined) {
+ const paras = controls.getParagraphs();
+ if (edit.paragraph < 1 || edit.paragraph > paras.length) {
+ throw new Error(
+ `Paragraph ${edit.paragraph} is out of range (1–${paras.length}).`,
+ );
+ }
+ const spliceAt =
+ edit.position === 'before'
+ ? edit.paragraph - 1
+ : edit.paragraph;
+ paras.splice(spliceAt, 0, edit.text);
+ next = paras.join('\n');
} else if (edit.after !== undefined && edit.after !== '') {
const idx = current.indexOf(edit.after);
if (idx === -1) {
diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx
index c9b6c2fc..b56ec0ae 100644
--- a/frontend/src/pages/my-words/index.tsx
+++ b/frontend/src/pages/my-words/index.tsx
@@ -18,8 +18,9 @@ const SYSTEM_PROMPT = `You are a writing collaborator working under one strict r
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 \`view\` to read the current document before editing. It numbers each paragraph like [3].
- 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.
+- To add a new paragraph, prefer \`insert\` with a \`paragraph\` number (from \`view\`) and \`position\` — it is more reliable than anchoring on \`after\` text. Keep each edit to about a sentence.
- 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.
@@ -99,15 +100,20 @@ export default function MyWords() {
const tools = {
view: tool({
description:
- 'Read the current full text of the document being edited.',
+ 'Read the document. Each paragraph is prefixed with its 1-based number, e.g. [3], which you can target with the `insert` tool.',
inputSchema: jsonSchema>({
type: 'object',
properties: {},
additionalProperties: false,
}),
execute: async () => {
- const text = await editorAPI.getDocText();
- return text.trim().length > 0 ? text : '(the document is empty)';
+ const paragraphs = await editorAPI.getParagraphs();
+ if (!paragraphs.some((p) => p.trim().length > 0)) {
+ return '(the document is empty)';
+ }
+ return paragraphs
+ .map((p, i) => `[${i + 1}] ${p}`)
+ .join('\n');
},
}),
str_replace: tool({
@@ -148,31 +154,53 @@ export default function MyWords() {
}),
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 }>({
+ "Insert text, drawn from the writer's corpus (plus glue words/punctuation). To place a new paragraph reliably, pass `paragraph` (a number from `view`) and `position`. To add within an existing paragraph, pass `after` (existing text). With none of these, it inserts at the cursor.",
+ inputSchema: jsonSchema<{
+ text: string;
+ after?: string;
+ paragraph?: number;
+ position?: 'before' | 'after';
+ }>({
type: 'object',
properties: {
+ text: {
+ type: 'string',
+ description:
+ "Text to insert, drawn from the writer's words.",
+ },
after: {
type: 'string',
description:
- 'Existing text to insert after (optional).',
+ 'Existing text to insert right after (within a paragraph).',
},
- text: {
+ paragraph: {
+ type: 'number',
+ description:
+ '1-based paragraph number from `view` to place a new paragraph relative to.',
+ },
+ position: {
type: 'string',
+ enum: ['before', 'after'],
description:
- "Text to insert, drawn from the writer's words.",
+ "Where to place it relative to `paragraph`. Defaults to 'after'.",
},
},
required: ['text'],
additionalProperties: false,
}),
- execute: async ({ after, text }) => {
+ execute: async ({ text, after, paragraph, position }) => {
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 });
+ await editorAPI.applyEdit({
+ type: 'insert',
+ text,
+ after,
+ paragraph,
+ position,
+ });
return 'Applied.';
} catch (e) {
return `Could not apply: ${(e as Error).message}`;
diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts
index b3b633d2..c8c9a5f9 100644
--- a/frontend/src/types.d.ts
+++ b/frontend/src/types.d.ts
@@ -28,7 +28,19 @@ interface SavedItem {
*/
type DocEdit =
| { type: 'str_replace'; oldStr: string; newStr: string }
- | { type: 'insert'; after?: string; text: string };
+ | {
+ type: 'insert';
+ text: string;
+ /** Insert right after this existing text (within a paragraph). */
+ after?: string;
+ /**
+ * 1-based paragraph number (as shown by the `view` tool) to position a
+ * new paragraph relative to. More robust than `after` for placement.
+ */
+ paragraph?: number;
+ /** Where to insert relative to `paragraph`. Defaults to 'after'. */
+ position?: 'before' | 'after';
+ };
interface EditorAPI {
doLogin(auth0Client: Auth0ContextInterface): Promise;
@@ -39,6 +51,11 @@ interface EditorAPI {
selectPhrase: (text: string) => Promise;
/** Full document text. Host-agnostic accessor for the corpus + `view` tool. */
getDocText(this: void): Promise;
+ /**
+ * Document split into paragraphs, in order. This is the shared coordinate
+ * system the `view` tool numbers and paragraph-targeted inserts index into.
+ */
+ getParagraphs(this: void): Promise;
/** Apply a validated edit to the document. */
applyEdit(this: void, edit: DocEdit): Promise;
}
From b7517bedc8d17bb545a0607d4358dd80b13f6588 Mon Sep 17 00:00:00 2001
From: "Kenneth C. Arnold"
Date: Thu, 25 Jun 2026 14:15:51 -0400
Subject: [PATCH 05/15] bump model
---
frontend/src/api/openai.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/api/openai.ts b/frontend/src/api/openai.ts
index d5f236bd..353a1982 100644
--- a/frontend/src/api/openai.ts
+++ b/frontend/src/api/openai.ts
@@ -6,4 +6,4 @@ export const openai = createOpenAI({
apiKey: "unused",
});
-export const OPENAI_MODEL = "gpt-4o";
+export const OPENAI_MODEL = "gpt-5.5";
From 4d87dfbac324b46e1b07b8e258db282e041acffd Mon Sep 17 00:00:00 2001
From: "Kenneth C. Arnold"
Date: Thu, 25 Jun 2026 14:44:01 -0400
Subject: [PATCH 06/15] Add visibility for tool calling failures
---
frontend/src/pages/my-words/index.tsx | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx
index b56ec0ae..08c5d094 100644
--- a/frontend/src/pages/my-words/index.tsx
+++ b/frontend/src/pages/my-words/index.tsx
@@ -239,6 +239,25 @@ export default function MyWords() {
messages: modelMessagesRef.current,
tools,
stopWhen: stepCountIs(8),
+ // Visibility: log every tool call + its result as each step
+ // resolves. Tool failures (REJECTED / "Could not apply…" /
+ // search misses) are returned to the model as strings, so they
+ // never throw — without this they'd be invisible here.
+ onStepFinish: (step) => {
+ for (const call of step.toolCalls) {
+ const res = step.toolResults.find(
+ (r) => r.toolCallId === call.toolCallId,
+ );
+ const output = res?.output;
+ const failed =
+ typeof output === 'string' &&
+ /^(REJECTED|Could not)/.test(output);
+ console[failed ? 'warn' : 'debug'](
+ `[my-words] ${call.toolName}`,
+ { input: call.input, output },
+ );
+ }
+ },
});
modelMessagesRef.current = [
...modelMessagesRef.current,
@@ -246,6 +265,9 @@ export default function MyWords() {
];
setAiUtterance(result.text.trim() || 'Done — take a look.');
} catch (e) {
+ // Surface the full error for diagnosis; the caption only shows the
+ // message. Tool-argument/schema errors from the SDK land here too.
+ console.error('[my-words] turn failed', e);
setAiUtterance(`⚠️ ${(e as Error).message}`);
} finally {
setIsThinking(false);
From a5339ac473f5dd730d4a367dd9e01abf882b616a Mon Sep 17 00:00:00 2001
From: "Kenneth C. Arnold"
Date: Thu, 25 Jun 2026 14:58:28 -0400
Subject: [PATCH 07/15] update to ai-sdk v7
---
frontend/package-lock.json | 307 ++++++++++++++++++++++----
frontend/package.json | 6 +-
frontend/src/pages/my-words/index.tsx | 8 +-
frontend/src/pages/revise/index.tsx | 2 +-
4 files changed, 278 insertions(+), 45 deletions(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c5f49d75..5f584362 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,13 +9,15 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
- "@ai-sdk/openai": "^2.0.0",
+ "@ai-sdk/openai": "^2.0.109",
+ "@ai-sdk/otel": "^1.0.0",
+ "@ai-sdk/react": "^4.0.0",
"@auth0/auth0-react": "^2.2.4",
"@lexical/react": "^0.16.1",
"@posthog/react": "^1.9.0",
"@react-hook/window-size": "^3.1.1",
"@types/node": "^24.6.2",
- "ai": "^5.0.0",
+ "ai": "^7.0.0",
"jotai": "^2.12.5",
"lexical": "^0.16.1",
"posthog-js": "^1.388.1",
@@ -71,30 +73,107 @@
}
},
"node_modules/@ai-sdk/gateway": {
- "version": "2.0.98",
- "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.98.tgz",
- "integrity": "sha512-JNMc5Fbz8AwiLIR3Ar/lV2egbLFE+A5nfwbRKrdfgusoVN2VjgMX2U2KCLux5iWD/Q9+rg9+njHPZNw4HmzBJQ==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.0.tgz",
+ "integrity": "sha512-rcKukspbM4h511ot2E8TsPl7rXjRK1zHKrMCP7w4+XF55UKqQHaDzo2kKbGv5rp8Bjb1yQatIHJZE1E2yrOOMw==",
"license": "Apache-2.0",
"dependencies": {
- "@ai-sdk/provider": "2.0.3",
- "@ai-sdk/provider-utils": "3.0.25",
- "@vercel/oidc": "3.1.0"
+ "@ai-sdk/provider": "4.0.0",
+ "@ai-sdk/provider-utils": "5.0.0",
+ "@vercel/oidc": "3.2.0"
},
"engines": {
- "node": ">=18"
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz",
+ "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "json-schema": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider-utils": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.0.tgz",
+ "integrity": "sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "4.0.0",
+ "@standard-schema/spec": "^1.1.0",
+ "@workflow/serde": "4.1.0",
+ "eventsource-parser": "^3.0.8"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/mcp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-2.0.0.tgz",
+ "integrity": "sha512-+N6gJ1AbcDk3+6asoEsdIojVmgEReKNcvWIT716pDL3AepGI6j7RJVdaRyhQLltwzTj0arwpwU4BBzvhOiCd/g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "4.0.0",
+ "@ai-sdk/provider-utils": "5.0.0",
+ "pkce-challenge": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/mcp/node_modules/@ai-sdk/provider": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz",
+ "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "json-schema": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@ai-sdk/mcp/node_modules/@ai-sdk/provider-utils": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.0.tgz",
+ "integrity": "sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "4.0.0",
+ "@standard-schema/spec": "^1.1.0",
+ "@workflow/serde": "4.1.0",
+ "eventsource-parser": "^3.0.8"
+ },
+ "engines": {
+ "node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
- "version": "2.0.106",
- "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.106.tgz",
- "integrity": "sha512-EFC0rpo1wfe4HIz5KZCE72edP2J7fOeR7wPXzjCDljaTRB1wectKDIKRLowpU4F0mbcJ+XScAsoYNPK/Z20aVQ==",
+ "version": "2.0.109",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.109.tgz",
+ "integrity": "sha512-i2no65RS/08qjB+m3zmAej5AqO4JtTTxdfpWusgA9p73K6TYn7t15h76MZPQVT8tYv5hDR1nHRELuAWaXZyb9g==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.3",
- "@ai-sdk/provider-utils": "3.0.25"
+ "@ai-sdk/provider-utils": "3.0.27"
},
"engines": {
"node": ">=18"
@@ -103,6 +182,41 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
+ "node_modules/@ai-sdk/otel": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/otel/-/otel-1.0.0.tgz",
+ "integrity": "sha512-XRTAFrerQ042ITLc4Ew5dR+6fiaOntOVBKfIG+H9Vtwpf+HzEhkmpacBhUo/WuSYIxzJTMdlqveZC4gxH5Xocw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "4.0.0",
+ "@opentelemetry/api": "1.9.1",
+ "ai": "7.0.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@ai-sdk/otel/node_modules/@ai-sdk/provider": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz",
+ "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "json-schema": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@ai-sdk/otel/node_modules/@opentelemetry/api": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/@ai-sdk/provider": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz",
@@ -116,9 +230,9 @@
}
},
"node_modules/@ai-sdk/provider-utils": {
- "version": "3.0.25",
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.25.tgz",
- "integrity": "sha512-CvsRu+32Y8a167s+lrIBtsybvgTHp8j9y+6BeTvLeoW3Q+okw/b4CnNUFOLIXsRaKHQKAH+IHNJPYWywfpw0LA==",
+ "version": "3.0.27",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.27.tgz",
+ "integrity": "sha512-JFhJK5ynprll2FR3e+sHagJJIwvIagsNA0FLbLPq2Os4yLUK2/eiaCU0jXsADik73/hhvcPPLmD+Uo8eu5kFaQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.3",
@@ -132,6 +246,56 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
+ "node_modules/@ai-sdk/react": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-4.0.0.tgz",
+ "integrity": "sha512-B8QCCzSr/neXnJebBPQpS/rO+I++xhhs81hcaiKD1gU+C7GKg5gZSHLb5iP+AUqrx6VP8QWwgNpHmLlfrlV2rg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/mcp": "2.0.0",
+ "@ai-sdk/provider": "4.0.0",
+ "@ai-sdk/provider-utils": "5.0.0",
+ "ai": "7.0.0",
+ "swr": "^2.4.1",
+ "throttleit": "2.1.0"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
+ }
+ },
+ "node_modules/@ai-sdk/react/node_modules/@ai-sdk/provider": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz",
+ "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "json-schema": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@ai-sdk/react/node_modules/@ai-sdk/provider-utils": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.0.tgz",
+ "integrity": "sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "4.0.0",
+ "@standard-schema/spec": "^1.1.0",
+ "@workflow/serde": "4.1.0",
+ "eventsource-parser": "^3.0.8"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -5153,15 +5317,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@opentelemetry/api": {
- "version": "1.9.0",
- "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
- "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8.0.0"
- }
- },
"node_modules/@oxc-project/types": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
@@ -7173,9 +7328,9 @@
"license": "ISC"
},
"node_modules/@vercel/oidc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
- "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
+ "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
@@ -7305,6 +7460,12 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@workflow/serde": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz",
+ "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -7399,18 +7560,47 @@
}
},
"node_modules/ai": {
- "version": "5.0.197",
- "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.197.tgz",
- "integrity": "sha512-iUzFb2M3ZUL/Bbmfonh75DIZ354svWO5xh8VPC2wYNR6zzEMFghPOlJG5rtEpqRa037lHfdcjt0qmzg3em/WDw==",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.0.tgz",
+ "integrity": "sha512-hncs+jamJh8r36K6G8xky7oF4Ai/RLU5TF85FMzI2vElyMJGGnLoHihpdmuDiuY2BsktDWHevKaJM1l0VcRLGw==",
"license": "Apache-2.0",
"dependencies": {
- "@ai-sdk/gateway": "2.0.98",
- "@ai-sdk/provider": "2.0.3",
- "@ai-sdk/provider-utils": "3.0.25",
- "@opentelemetry/api": "1.9.0"
+ "@ai-sdk/gateway": "4.0.0",
+ "@ai-sdk/provider": "4.0.0",
+ "@ai-sdk/provider-utils": "5.0.0"
},
"engines": {
- "node": ">=18"
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/ai/node_modules/@ai-sdk/provider": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz",
+ "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "json-schema": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/ai/node_modules/@ai-sdk/provider-utils": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.0.tgz",
+ "integrity": "sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "4.0.0",
+ "@standard-schema/spec": "^1.1.0",
+ "@workflow/serde": "4.1.0",
+ "eventsource-parser": "^3.0.8"
+ },
+ "engines": {
+ "node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
@@ -9291,6 +9481,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -16626,9 +16825,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
- "dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.20.0"
}
@@ -19176,6 +19373,19 @@
"node": ">= 6"
}
},
+ "node_modules/swr": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz",
+ "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
@@ -19342,6 +19552,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/throttleit": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
+ "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -20017,6 +20239,15 @@
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
"dev": true
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ab69c382..6cd88842 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -46,13 +46,15 @@
"test:dev-server": "node test-dev-server.mjs"
},
"dependencies": {
- "@ai-sdk/openai": "^2.0.0",
+ "@ai-sdk/openai": "^2.0.109",
+ "@ai-sdk/otel": "^1.0.0",
+ "@ai-sdk/react": "^4.0.0",
"@auth0/auth0-react": "^2.2.4",
"@lexical/react": "^0.16.1",
"@posthog/react": "^1.9.0",
"@react-hook/window-size": "^3.1.1",
"@types/node": "^24.6.2",
- "ai": "^5.0.0",
+ "ai": "^7.0.0",
"jotai": "^2.12.5",
"lexical": "^0.16.1",
"posthog-js": "^1.388.1",
diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx
index 08c5d094..44604bcf 100644
--- a/frontend/src/pages/my-words/index.tsx
+++ b/frontend/src/pages/my-words/index.tsx
@@ -2,7 +2,7 @@ import {
generateText,
jsonSchema,
type ModelMessage,
- stepCountIs,
+ isStepCount,
tool,
} from 'ai';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
@@ -235,15 +235,15 @@ export default function MyWords() {
try {
const result = await generateText({
model: openai.chat(OPENAI_MODEL),
- system: SYSTEM_PROMPT,
+ instructions: SYSTEM_PROMPT,
messages: modelMessagesRef.current,
tools,
- stopWhen: stepCountIs(8),
+ stopWhen: isStepCount(8),
// Visibility: log every tool call + its result as each step
// resolves. Tool failures (REJECTED / "Could not apply…" /
// search misses) are returned to the model as strings, so they
// never throw — without this they'd be invisible here.
- onStepFinish: (step) => {
+ onStepEnd: (step) => {
for (const call of step.toolCalls) {
const res = step.toolResults.find(
(r) => r.toolCallId === call.toolCallId,
diff --git a/frontend/src/pages/revise/index.tsx b/frontend/src/pages/revise/index.tsx
index 43105e6c..1a74e636 100644
--- a/frontend/src/pages/revise/index.tsx
+++ b/frontend/src/pages/revise/index.tsx
@@ -293,7 +293,7 @@ ${request}
try {
const result = streamText({
model: openai.chat(OPENAI_MODEL),
- system: systemPrompt,
+ instructions: systemPrompt,
messages,
maxOutputTokens: 1024,
abortSignal: requestController.signal,
From 2b98149157466ce912c9db8d578d7b92fdefb314 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 25 Jun 2026 19:05:40 +0000
Subject: [PATCH 08/15] My Words: zod tool schemas, change-reporting results,
tutor framing
- 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
Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
---
frontend/package-lock.json | 516 +++++++++++++++++++++++++-
frontend/package.json | 3 +-
frontend/src/pages/my-words/index.tsx | 159 ++++----
3 files changed, 599 insertions(+), 79 deletions(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5f584362..3360380a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -25,7 +25,8 @@
"react-dom": "^18.2.0",
"react-icons": "^5.2.1",
"react-remark": "^2.1.0",
- "reshaped": "^3.5.3"
+ "reshaped": "^3.5.3",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
@@ -20568,6 +20569,474 @@
}
}
},
+ "node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
@@ -20605,6 +21074,50 @@
"node": ">=8"
}
},
+ "node_modules/vitest/node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
"node_modules/vitest/node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -21505,7 +22018,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/frontend/package.json b/frontend/package.json
index 6cd88842..2045c7f9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -62,7 +62,8 @@
"react-dom": "^18.2.0",
"react-icons": "^5.2.1",
"react-remark": "^2.1.0",
- "reshaped": "^3.5.3"
+ "reshaped": "^3.5.3",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx
index 44604bcf..7115fede 100644
--- a/frontend/src/pages/my-words/index.tsx
+++ b/frontend/src/pages/my-words/index.tsx
@@ -1,30 +1,34 @@
import {
generateText,
- jsonSchema,
type ModelMessage,
isStepCount,
tool,
} from 'ai';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { AiOutlineSend } from 'react-icons/ai';
+import { z } from 'zod';
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.
+const SYSTEM_PROMPT = `You are a writing tutor helping a writer develop their OWN writing. Two things define your role.
-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.
+1) You never contribute words. 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 edit whose text is not lifted from the corpus is REJECTED. Your edits only ever rearrange, tighten, or connect the writer's existing words; the ideas and the language stay theirs.
-How to work:
-- Use \`view\` to read the current document before editing. It numbers each paragraph like [3].
-- 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.
-- To add a new paragraph, prefer \`insert\` with a \`paragraph\` number (from \`view\`) and \`position\` — it is more reliable than anchoring on \`after\` text. Keep each edit to about a sentence.
-- 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.
+2) You are non-directive. Lead with curiosity, like a good tutor in a writing conference. Ask open questions about what the writer means, what they most want to say, how two ideas connect, what matters most here. Reflect their own words back to them. Draw out their thinking instead of prescribing a direction, and never impose your own thesis or opinion.
-Prefer asking the writer for their words over guessing. Never pad your replies.`;
+Hold these together: talk like a tutor (questions, reflection, encouragement) and edit like a careful hand arranging the writer's words. Favor one small, concrete move plus a question over a sweeping rewrite. When you are unsure what the writer wants, ask before editing.
+
+Working with the tools:
+- Use \`view\` to read the document; it numbers each paragraph like [3].
+- \`str_replace\` works on a SHORT span within a single paragraph — keep old_str to a phrase or sentence, and never let it cross a paragraph break. For a bigger change, make several small replacements.
+- To add or move a paragraph, use \`insert\` with a \`paragraph\` number (from \`view\`) and \`position\`. Paragraph numbers shift after an edit, so use the numbers in the tool result (or call \`view\` again) before your next placement.
+- Use \`highlight\` to point at a passage while you ask the writer about it.
+- When you need words you don't have, do NOT invent them — ask the writer for them.
+
+Take short turns. Your spoken replies are one or two sentences shown to the writer as fleeting captions, not a chat log. Never pad them.`;
/** A turn's worth of lightweight signals about what the writer just did. */
function buildActivityNote(opts: {
@@ -97,15 +101,35 @@ export default function MyWords() {
userMessages: messagesNow,
});
+ // Report what the document looks like right after an edit so the model
+ // can track paragraph-number shifts. Lightweight: total count + a 3-line
+ // window around the change, each line clipped.
+ const describeChange = async (probe: string): Promise => {
+ const paragraphs = await editorAPI.getParagraphs();
+ const total = paragraphs.length;
+ const fragment = probe.trim().slice(0, 40);
+ const k = fragment
+ ? paragraphs.findIndex((p) => p.includes(fragment))
+ : -1;
+ if (k === -1) {
+ return `Applied. The document now has ${total} paragraph(s).`;
+ }
+ const clip = (s: string) =>
+ s.length > 120 ? `${s.slice(0, 117)}…` : s;
+ const lo = Math.max(0, k - 1);
+ const hi = Math.min(total - 1, k + 1);
+ const window = paragraphs
+ .slice(lo, hi + 1)
+ .map((p, i) => `[${lo + i + 1}] ${clip(p)}`)
+ .join('\n');
+ return `Applied. The document now has ${total} paragraph(s); numbers may have shifted. Around your edit:\n${window}`;
+ };
+
const tools = {
view: tool({
description:
'Read the document. Each paragraph is prefixed with its 1-based number, e.g. [3], which you can target with the `insert` tool.',
- inputSchema: jsonSchema>({
- type: 'object',
- properties: {},
- additionalProperties: false,
- }),
+ inputSchema: z.object({}),
execute: async () => {
const paragraphs = await editorAPI.getParagraphs();
if (!paragraphs.some((p) => p.trim().length > 0)) {
@@ -118,22 +142,18 @@ export default function MyWords() {
}),
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,
+ "Replace the first occurrence of old_str with new_str. old_str must be a SHORT span within a single paragraph (a phrase or sentence) and must not cross a paragraph break. new_str must be lifted from the writer's corpus (plus glue words/punctuation).",
+ inputSchema: z.object({
+ old_str: z
+ .string()
+ .describe(
+ 'A short existing span to replace — a phrase or sentence within ONE paragraph. Must not span a paragraph break.',
+ ),
+ new_str: z
+ .string()
+ .describe(
+ "Replacement text, drawn only from the writer's words plus glue/punctuation.",
+ ),
}),
execute: async ({ old_str, new_str }) => {
const check = validateText(new_str, await makeCorpus());
@@ -146,47 +166,40 @@ export default function MyWords() {
oldStr: old_str,
newStr: new_str,
});
- return 'Applied.';
+ return await describeChange(new_str);
} catch (e) {
- return `Could not apply: ${(e as Error).message}`;
+ return `Could not apply: ${(e as Error).message} Keep old_str to a short span inside one paragraph (it cannot cross a paragraph break), or make the change as several smaller replacements.`;
}
},
}),
insert: tool({
description:
"Insert text, drawn from the writer's corpus (plus glue words/punctuation). To place a new paragraph reliably, pass `paragraph` (a number from `view`) and `position`. To add within an existing paragraph, pass `after` (existing text). With none of these, it inserts at the cursor.",
- inputSchema: jsonSchema<{
- text: string;
- after?: string;
- paragraph?: number;
- position?: 'before' | 'after';
- }>({
- type: 'object',
- properties: {
- text: {
- type: 'string',
- description:
- "Text to insert, drawn from the writer's words.",
- },
- after: {
- type: 'string',
- description:
- 'Existing text to insert right after (within a paragraph).',
- },
- paragraph: {
- type: 'number',
- description:
- '1-based paragraph number from `view` to place a new paragraph relative to.',
- },
- position: {
- type: 'string',
- enum: ['before', 'after'],
- description:
- "Where to place it relative to `paragraph`. Defaults to 'after'.",
- },
- },
- required: ['text'],
- additionalProperties: false,
+ inputSchema: z.object({
+ text: z
+ .string()
+ .describe(
+ "Text to insert, drawn only from the writer's words plus glue/punctuation.",
+ ),
+ after: z
+ .string()
+ .optional()
+ .describe(
+ 'Existing text to insert right after (within a paragraph).',
+ ),
+ paragraph: z
+ .number()
+ .int()
+ .optional()
+ .describe(
+ '1-based paragraph number from `view` to place a new paragraph relative to.',
+ ),
+ position: z
+ .enum(['before', 'after'])
+ .optional()
+ .describe(
+ "Where to place it relative to `paragraph`. Defaults to 'after'.",
+ ),
}),
execute: async ({ text, after, paragraph, position }) => {
const check = validateText(text, await makeCorpus());
@@ -201,7 +214,7 @@ export default function MyWords() {
paragraph,
position,
});
- return 'Applied.';
+ return await describeChange(text);
} catch (e) {
return `Could not apply: ${(e as Error).message}`;
}
@@ -210,16 +223,10 @@ export default function MyWords() {
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,
+ inputSchema: z.object({
+ phrase: z
+ .string()
+ .describe('Existing text to highlight.'),
}),
execute: async ({ phrase }) => {
try {
From bcf877642fec9125b94eefd9c68ed7211a70ee38 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 25 Jun 2026 19:16:40 +0000
Subject: [PATCH 09/15] My Words: make the corpus legible to the model
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
Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
---
frontend/src/pages/my-words/index.tsx | 31 +++++++++++++++++----------
1 file changed, 20 insertions(+), 11 deletions(-)
diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx
index 7115fede..d33161d7 100644
--- a/frontend/src/pages/my-words/index.tsx
+++ b/frontend/src/pages/my-words/index.tsx
@@ -15,7 +15,7 @@ import classes from './styles.module.css';
const SYSTEM_PROMPT = `You are a writing tutor helping a writer develop their OWN writing. Two things define your role.
-1) You never contribute words. 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 edit whose text is not lifted from the corpus is REJECTED. Your edits only ever rearrange, tighten, or connect the writer's existing words; the ideas and the language stay theirs.
+1) You never contribute words. 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, ...). Treat all three as a single word bank you may quote from freely: every line of the scratchpad, and everything the writer has typed or said to you in this conversation, is fair game to lift, exactly like the document text itself. (Their messages are a source of words, not just instructions.) The harness enforces this: any edit whose text is not lifted from the corpus is REJECTED. Your edits only ever rearrange, tighten, or connect the writer's existing words; the ideas and the language stay theirs.
2) You are non-directive. Lead with curiosity, like a good tutor in a writing conference. Ask open questions about what the writer means, what they most want to say, how two ideas connect, what matters most here. Reflect their own words back to them. Draw out their thinking instead of prescribing a direction, and never impose your own thesis or opinion.
@@ -38,7 +38,9 @@ function buildActivityNote(opts: {
}): 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"""`);
+ parts.push(
+ `Source words — the writer's scratchpad now reads (you may quote any of this):\n"""\n${opts.scratchpad}\n"""`,
+ );
}
if (opts.selectedText.trim().length > 0) {
parts.push(`The writer has selected this passage: "${opts.selectedText}"`);
@@ -128,16 +130,23 @@ export default function MyWords() {
const tools = {
view: tool({
description:
- 'Read the document. Each paragraph is prefixed with its 1-based number, e.g. [3], which you can target with the `insert` tool.',
+ "Read the document (paragraphs numbered like [3], which you can target with `insert`) together with the writer's scratchpad of source words. Paragraph numbers refer to the document only.",
inputSchema: z.object({}),
execute: async () => {
const paragraphs = await editorAPI.getParagraphs();
- if (!paragraphs.some((p) => p.trim().length > 0)) {
- return '(the document is empty)';
- }
- return paragraphs
- .map((p, i) => `[${i + 1}] ${p}`)
- .join('\n');
+ const docPart = paragraphs.some(
+ (p) => p.trim().length > 0,
+ )
+ ? paragraphs
+ .map((p, i) => `[${i + 1}] ${p}`)
+ .join('\n')
+ : '(the document is empty)';
+ const scratch = scratchpadNow.trim();
+ const scratchPart =
+ scratch.length > 0
+ ? `\n\n--- The writer's scratchpad (source words you may quote; not part of the document, so no paragraph numbers) ---\n${scratch}`
+ : '';
+ return `${docPart}${scratchPart}`;
},
}),
str_replace: tool({
@@ -158,7 +167,7 @@ export default function MyWords() {
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.`;
+ return `REJECTED: "${check.offending}" is not in the writer's words. You may lift from anywhere in their word bank — the document, the scratchpad, or anything they've typed to you — plus glue words/punctuation. If the words you need aren't there, ask the writer for them.`;
}
try {
await editorAPI.applyEdit({
@@ -204,7 +213,7 @@ export default function MyWords() {
execute: async ({ text, after, paragraph, position }) => {
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.`;
+ return `REJECTED: "${check.offending}" is not in the writer's words. You may lift from anywhere in their word bank — the document, the scratchpad, or anything they've typed to you — plus glue words/punctuation. If the words you need aren't there, ask the writer for them.`;
}
try {
await editorAPI.applyEdit({
From 1445b9b85faa15d7bb62cb68fe20b03090689961 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 25 Jun 2026 19:19:19 +0000
Subject: [PATCH 10/15] My Words: make `view` the single source for the
scratchpad
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
---
frontend/src/pages/my-words/index.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx
index d33161d7..ff63304f 100644
--- a/frontend/src/pages/my-words/index.tsx
+++ b/frontend/src/pages/my-words/index.tsx
@@ -22,7 +22,7 @@ const SYSTEM_PROMPT = `You are a writing tutor helping a writer develop their OW
Hold these together: talk like a tutor (questions, reflection, encouragement) and edit like a careful hand arranging the writer's words. Favor one small, concrete move plus a question over a sweeping rewrite. When you are unsure what the writer wants, ask before editing.
Working with the tools:
-- Use \`view\` to read the document; it numbers each paragraph like [3].
+- Use \`view\` to read the document (paragraphs numbered like [3]) and the writer's scratchpad of source words. Re-\`view\` whenever you're told the scratchpad changed.
- \`str_replace\` works on a SHORT span within a single paragraph — keep old_str to a phrase or sentence, and never let it cross a paragraph break. For a bigger change, make several small replacements.
- To add or move a paragraph, use \`insert\` with a \`paragraph\` number (from \`view\`) and \`position\`. Paragraph numbers shift after an edit, so use the numbers in the tool result (or call \`view\` again) before your next placement.
- Use \`highlight\` to point at a passage while you ask the writer about it.
@@ -38,8 +38,10 @@ function buildActivityNote(opts: {
}): string | null {
const parts: string[] = [];
if (opts.scratchpadChanged && opts.scratchpad.trim().length > 0) {
+ // Lightweight flag only — the current scratchpad is available via `view`,
+ // so we don't push its (potentially large, ever-accumulating) text here.
parts.push(
- `Source words — the writer's scratchpad now reads (you may quote any of this):\n"""\n${opts.scratchpad}\n"""`,
+ 'The writer has edited their scratchpad since you last looked — call `view` to see the current source words before quoting from it.',
);
}
if (opts.selectedText.trim().length > 0) {
From e43d1b39ba07c43cc00d6933999e84cc022549ed Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 25 Jun 2026 20:38:28 +0000
Subject: [PATCH 11/15] My Words: don't accumulate tool dumps in the context
window
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01XtzcT6Yo9dVoCHsWgNMQ6U
---
frontend/src/pages/my-words/index.tsx | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx
index ff63304f..27b158bb 100644
--- a/frontend/src/pages/my-words/index.tsx
+++ b/frontend/src/pages/my-words/index.tsx
@@ -70,7 +70,9 @@ export default function MyWords() {
const sentMessagesRef = useRef(sentMessages);
sentMessagesRef.current = sentMessages;
- // Running model transcript (assistant turns + tool calls/results).
+ // Durable conversation transcript: the writer's turns and the assistant's
+ // captions only. Intermediate tool steps (view dumps, edit confirmations)
+ // are deliberately NOT retained across turns — see runTurn.
const modelMessagesRef = useRef([]);
// What we last told the model, so signals stay lightweight (only deltas).
const lastSentScratchpadRef = useRef('');
@@ -277,11 +279,18 @@ export default function MyWords() {
}
},
});
+ // Persist only the conversation: the writer's turn is already in the
+ // transcript, so add the assistant's caption. We deliberately DROP the
+ // turn's tool calls/results — view dumps, paragraph-window confirmations,
+ // rejections — so stale full-document/scratchpad snapshots don't
+ // accumulate in the context window. The document is the source of truth;
+ // the model re-reads it with `view` when it needs current state.
+ const utterance = result.text.trim() || 'Done — take a look.';
modelMessagesRef.current = [
...modelMessagesRef.current,
- ...result.response.messages,
+ { role: 'assistant', content: utterance },
];
- setAiUtterance(result.text.trim() || 'Done — take a look.');
+ setAiUtterance(utterance);
} catch (e) {
// Surface the full error for diagnosis; the caption only shows the
// message. Tool-argument/schema errors from the SDK land here too.
From 2a605b57ac7143b99d67ffc9e3de334e6cef5dc4 Mon Sep 17 00:00:00 2001
From: "Kenneth C. Arnold"
Date: Thu, 25 Jun 2026 17:46:06 -0400
Subject: [PATCH 12/15] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
frontend/src/static/privacypolicy.html | 1 -
1 file changed, 1 deletion(-)
diff --git a/frontend/src/static/privacypolicy.html b/frontend/src/static/privacypolicy.html
index ee4a0c46..42e4bcd1 100644
--- a/frontend/src/static/privacypolicy.html
+++ b/frontend/src/static/privacypolicy.html
@@ -200,7 +200,6 @@ Usage Data
-
Basic authentication information provided through our authentication provider
Email address (if you choose to provide it for support purposes)
From 9a5468274c9080e2f224e9ec66a2585ecee32095 Mon Sep 17 00:00:00 2001
From: "Kenneth C. Arnold"
Date: Thu, 25 Jun 2026 17:53:48 -0400
Subject: [PATCH 13/15] Remove openExternal; an tag should work fine.
---
frontend/src/api/googleDocsEditorAPI.ts | 8 --------
frontend/src/api/wordEditorAPI.ts | 11 -----------
frontend/src/contexts/editorContext.tsx | 3 ---
frontend/src/editor/index.tsx | 3 ---
frontend/src/pages/app/index.tsx | 13 ++-----------
frontend/src/types.d.ts | 6 ------
6 files changed, 2 insertions(+), 42 deletions(-)
diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts
index 14d60a53..4e25ed2e 100644
--- a/frontend/src/api/googleDocsEditorAPI.ts
+++ b/frontend/src/api/googleDocsEditorAPI.ts
@@ -108,14 +108,6 @@ function stopPolling() {
* Google Docs implementation of the EditorAPI interface.
*/
export const googleDocsEditorAPI: EditorAPI = {
- /**
- * Open a URL in a new browser tab. Google Docs runs in demo mode, so this is only
- * used if the device-flow approval page is ever surfaced here.
- */
- openExternal(url: string): void {
- window.open(url, '_blank', 'noopener');
- },
-
/**
* Adds a handler for selection changes.
* Uses polling since Google Docs doesn't provide native selection events.
diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts
index 92833327..9e85d03b 100644
--- a/frontend/src/api/wordEditorAPI.ts
+++ b/frontend/src/api/wordEditorAPI.ts
@@ -1,15 +1,4 @@
export const wordEditorAPI: EditorAPI = {
- // Open the device-flow approval page in the system browser. Guarded so an Office
- // host that doesn't expose openBrowserWindow fails explainably rather than silently.
- openExternal(url: string): void {
- if (Office?.context?.ui?.openBrowserWindow) {
- Office.context.ui.openBrowserWindow(url);
- } else {
- throw new Error(
- 'External browser login is not supported in this Office host.',
- );
- }
- },
addSelectionChangeHandler: (handler: () => void) => {
Office.context.document.addHandlerAsync(
diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx
index 641f2880..4bf73f39 100644
--- a/frontend/src/contexts/editorContext.tsx
+++ b/frontend/src/contexts/editorContext.tsx
@@ -2,9 +2,6 @@ import { createContext } from 'react';
// Provides editor API functionality through context
export const EditorContext = createContext({
- openExternal: (url: string) => {
- window.open(url, '_blank', 'noopener');
- },
getDocContext: () =>
new Promise((resolve) =>
resolve({
diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx
index 0f5f65bd..1f5f53ba 100644
--- a/frontend/src/editor/index.tsx
+++ b/frontend/src/editor/index.tsx
@@ -43,9 +43,6 @@ export function EditorScreen({
};
const editorAPI: EditorAPI = useMemo(() => ({
- openExternal: (url: string) => {
- window.open(url, '_blank', 'noopener');
- },
getDocContext: async (): Promise => {
return Promise.resolve(docContextRef.current);
},
diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx
index 1dc13aa6..879bd72b 100644
--- a/frontend/src/pages/app/index.tsx
+++ b/frontend/src/pages/app/index.tsx
@@ -32,8 +32,7 @@ const POSTHOG_HOST = 'https://e.thoughtful-ai.com/';
const POSTHOG_ENABLED = true;
// Device-flow status surfaced during Better Auth sign-in. Shows the user code and a
-// button that opens the approval page in the system browser via the surface-specific
-// EditorAPI.openExternal (Word: openBrowserWindow; standalone/GDocs: new tab).
+// button that opens the approval page in a new window/tab.
// Rendered inside the not-logged-in screen, NOT gated by the isLoading "Waiting" screen.
function DeviceAuthStatus({
authorization,
@@ -90,15 +89,7 @@ function DeviceAuthStatus({
{authorization.verificationUri ? (
- {
- editorAPI.openExternal(authorization.verificationUri!);
- }}
- >
- Open approval page →
-
+ Open approval page
) : null}
Open the approval page and enter the code above to continue.
diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts
index a8e600b2..d93e9e0e 100644
--- a/frontend/src/types.d.ts
+++ b/frontend/src/types.d.ts
@@ -23,12 +23,6 @@ interface SavedItem {
}
interface EditorAPI {
- /**
- * Open a URL in the user's external browser. Surface-specific: Word uses
- * Office.context.ui.openBrowserWindow; standalone/Google Docs use window.open.
- * Used to open the Better Auth device-flow approval page.
- */
- openExternal(url: string): void;
getDocContext(this: void): Promise;
addSelectionChangeHandler: (handler: () => void) => void;
removeSelectionChangeHandler: (handler: () => void) => void;
From 05aa4de0c670dfdfdbb66dbea469962dd583a2d7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 25 Jun 2026 22:03:48 +0000
Subject: [PATCH 14/15] Remove dead editorAPI/useContext after openExternal
removal
Drop the now-unused EditorContext lookup in DeviceAuthStatus and add
rel="noopener" to the approval-page link.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01CHabwqx37ssPEFQwbU7Hk7
---
frontend/src/pages/app/index.tsx | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx
index 879bd72b..712047cf 100644
--- a/frontend/src/pages/app/index.tsx
+++ b/frontend/src/pages/app/index.tsx
@@ -1,7 +1,7 @@
import { PostHogProvider, PostHogErrorBoundary } from '@posthog/react';
import { useWindowSize } from '@react-hook/window-size/throttled';
import { useAtomValue } from 'jotai';
-import { useContext, useState } from 'react';
+import { useState } from 'react';
import { CgGoogle } from 'react-icons/cg';
import {
AppAuthProvider,
@@ -9,7 +9,6 @@ import {
useAppAuth,
} from '@/contexts/appAuthContext';
import { useAccessToken } from '@/contexts/authTokenContext';
-import { EditorContext } from '@/contexts/editorContext';
import ChatContextWrapper from '@/contexts/chatContext';
import {
OverallMode,
@@ -44,8 +43,6 @@ function DeviceAuthStatus({
error?: string;
};
}) {
- const editorAPI = useContext(EditorContext);
-
if (!authorization) return null;
if (authorization.status === 'error') {
@@ -89,7 +86,7 @@ function DeviceAuthStatus({
{authorization.verificationUri ? (
- Open approval page
+ Open approval page
) : null}
Open the approval page and enter the code above to continue.
From ce27e40da44429eb03ac4fd5a8ce1758ec5afccb Mon Sep 17 00:00:00 2001
From: "Kenneth C. Arnold"
Date: Thu, 25 Jun 2026 18:16:28 -0400
Subject: [PATCH 15/15] attempts to fix an intermittent empty-response problem
---
backend/src/app.ts | 85 +++++++++++++++++++++++++++++++++++------
frontend/vite.config.ts | 36 +++++++++++++----
2 files changed, 102 insertions(+), 19 deletions(-)
diff --git a/backend/src/app.ts b/backend/src/app.ts
index be7a4f96..97082fac 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -32,24 +32,85 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono {
});
// OpenAI-compatible passthrough. The frontend's ai-sdk client posts here; we
- // only inject the server-held API key and stream the upstream response back.
+ // only inject the server-held API key and relay the upstream response back.
+ // We log every request/response so empty or truncated replies are visible
+ // instead of silently surfacing as a 200 with no body.
app.post('/api/openai/chat/completions', async (c) => {
const body = await c.req.text();
- const upstream = await fetch(OPENAI_URL, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${openaiApiKey()}`,
- 'Content-Type': 'application/json',
+ // Non-streaming requests (e.g. the my-words generateText path) omit
+ // `stream:true`; those we buffer fully so a dropped upstream connection
+ // throws here rather than yielding an empty 200. Streaming requests
+ // (streamText pages) are relayed through with a byte counter.
+ const wantsStream = (() => {
+ try {
+ return Boolean((JSON.parse(body) as { stream?: unknown }).stream);
+ } catch {
+ return false;
+ }
+ })();
+
+ const started = Date.now();
+ let upstream: Response;
+ try {
+ upstream = await fetch(OPENAI_URL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${openaiApiKey()}`,
+ 'Content-Type': 'application/json',
+ },
+ body,
+ });
+ } catch (e) {
+ console.error('[openai-proxy] upstream fetch failed:', (e as Error).message);
+ throw e; // -> onError -> 500 JSON, instead of a silent empty response
+ }
+
+ const contentType =
+ upstream.headers.get('content-type') ??
+ (wantsStream ? 'text/event-stream' : 'application/json');
+
+ const log = (bytes: number) => {
+ const line = `[openai-proxy] ${upstream.status} ${
+ wantsStream ? 'stream' : 'json'
+ } ${contentType.split(';')[0]} ${bytes}B ${Date.now() - started}ms`;
+ if (!upstream.ok || bytes === 0) console.warn(`${line} ⚠️ EMPTY/ERROR`);
+ else console.log(line);
+ };
+
+ if (!wantsStream) {
+ let buf: ArrayBuffer;
+ try {
+ buf = await upstream.arrayBuffer();
+ } catch (e) {
+ console.error(
+ '[openai-proxy] upstream body read failed:',
+ (e as Error).message,
+ );
+ throw e;
+ }
+ log(buf.byteLength);
+ return new Response(buf, {
+ status: upstream.status,
+ headers: { 'Content-Type': contentType },
+ });
+ }
+
+ // Streaming: relay the body through a pass-through that tallies bytes so
+ // we can log the total (and flag an empty stream) once it completes.
+ let bytes = 0;
+ const counter = new TransformStream({
+ transform(chunk, ctrl) {
+ bytes += chunk.byteLength;
+ ctrl.enqueue(chunk);
+ },
+ flush() {
+ log(bytes);
},
- body,
});
- return new Response(upstream.body, {
+ return new Response(upstream.body?.pipeThrough(counter) ?? null, {
status: upstream.status,
- headers: {
- 'Content-Type':
- upstream.headers.get('content-type') ?? 'text/event-stream',
- },
+ headers: { 'Content-Type': contentType },
});
});
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index b57b6f2a..2d4672fb 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,12 +1,13 @@
-import { defineConfig, Plugin } from 'vite';
+import { defineConfig, Plugin, type ProxyOptions } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import fs from 'fs';
import { getHttpsServerOptions } from 'office-addin-dev-certs';
+import http from 'node:http';
const urlDev = 'https://localhost:3000';
const urlProd = 'https://app.thoughtful-ai.com';
-const backendDev = 'http://0.0.0.0:8000/';
+const backendDev = 'http://127.0.0.1:8000/';
const idProd = '46d2493d-60db-4522-b2aa-e6f2c08d2508';
const idDev = '46d2493d-60db-4522-b2aa-e6f2c08d2507';
@@ -50,6 +51,31 @@ export default defineConfig(async ({ mode }) => {
}
}
+ // Annotated so the `configure` callback params get contextual types
+ // (the inline `proxy: { ... }` record types them as `string | ProxyOptions`,
+ // which defeats inference and leaves the callback args as implicit `any`).
+ const apiProxy: ProxyOptions = {
+ target: backendDev,
+ changeOrigin: true,
+ secure: false,
+ // Don't reuse keep-alive sockets to the dev backend. Node's http server
+ // closes idle sockets after ~5s; if http-proxy reuses one mid-close we get
+ // a silent empty response (Content-Length: 0). A fresh socket per request
+ // avoids that race. Dev-only.
+ agent: new http.Agent({ keepAlive: false }),
+ configure: (proxy) => {
+ proxy.on('error', (err, _req, res) => {
+ console.error('[vite proxy] /api error:', err.message);
+ if ('writeHead' in res && !res.headersSent) {
+ res.writeHead(502, { 'Content-Type': 'application/json' });
+ res.end(
+ JSON.stringify({ detail: 'Proxy error', message: err.message }),
+ );
+ }
+ });
+ }
+ };
+
return {
// Multi-page app: don't fall back to index.html for unknown paths.
appType: 'mpa' as const,
@@ -98,11 +124,7 @@ export default defineConfig(async ({ mode }) => {
'Access-Control-Allow-Origin': '*'
},
proxy: {
- '/api': {
- target: backendDev,
- changeOrigin: true,
- secure: false
- }
+ '/api': apiProxy
}
},
optimizeDeps: {