From b3f850c5f1c9bbed0c956494fc2c7057ced054ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 14:17:04 +0000 Subject: [PATCH 1/6] docs: map arXiv 2609.01588 (proactive thought partners) onto this add-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before writing any code, work out what the paper's probe actually needs and which of it a task-pane add-in can supply. Records eight challenges up front, of which two are decisions rather than obstacles: Execute / "Help Me Write" is omitted because it contradicts the repository's stated covenant, and the paper's session goal is folded into the existing document brief. The paper's prompts are said to be in supplementary materials that the arXiv PDF does not contain, so every prompt in this reproduction is reconstructed from the prose in §4.2-§4.3. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LzPdByDv9KAZyJd6kmTYpR --- docs/proactive-partners-reproduction.md | 187 ++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/proactive-partners-reproduction.md diff --git a/docs/proactive-partners-reproduction.md b/docs/proactive-partners-reproduction.md new file mode 100644 index 00000000..5099c135 --- /dev/null +++ b/docs/proactive-partners-reproduction.md @@ -0,0 +1,187 @@ +# Reproducing "Designing Proactive Thought Partners for Writing" in Thoughtful + +Source: Zhang, Davis, Chen & Hsu, *Designing Proactive Thought Partners for +Writing*, arXiv:2609.01588v1 (Sept 2026). + +This document is two things: a mapping from the paper's probe onto this +add-in, and a **running log of the challenges** the reproduction hit. It is +written to be argued with — several entries are judgment calls that could +reasonably have gone the other way, and they are marked as such. + +--- + +## 1. What the paper actually built + +The probe (paper §4) is a Next.js app with a BlockNote markdown editor and a +right-hand suggestion panel. Its moving parts: + +| Part | Paper §4 | Detail | +|---|---|---| +| Session goal | §4 onboarding | User states writing goals for the session | +| Partner config | §4.1 | name + emoji, **role**, **event trigger(s)**, **contextual heuristic** | +| Event triggers | §4.2 | Long Pause (5s default), Sentence End (1s idle after `.!?`), Text Selection (5s idle) — rule-based, over **keystrokes** | +| Decision engine | §4.2 | On a trigger, an LLM gets goal + document + writing behaviours (cursor position, trigger, **15s keystroke log**) + enabled partners; picks **at most two** whose heuristics match | +| Suggestion | §4.3 | Activated partner produces **acknowledgement** (what the writer seems to be doing) + a **question-style suggestion** | +| Engagement | §4.4 | *Ignore* (tag fades after 15s), *Inspire* (click tag → card, optional follow-up chat), *Execute* ("Help Me Write" inserts/revises text, then accept/revert) | +| Models | §4.5 | `gemini-2.5-flash-lite` for the decision engine (<1s), `gemini-2.5-flash` for suggestions (~8s) | + +Note: the paper says its prompts are "provided in the supplementary +materials". **The arXiv PDF contains no such appendix** — pages 24–30 are +references only. So every prompt here is reconstructed from the prose +descriptions in §4.2–§4.3, not transcribed. Any behavioural difference from +the paper could be a prompt difference and we would not be able to tell. + +## 2. The reproduction target + +Thoughtful is not a web app that owns its own editor. It is a **task-pane +add-in** that lives beside Word, Google Docs, or a standalone Lexical editor, +reaching the document through the host-agnostic `EditorAPI` +(`frontend/src/types.d.ts`). That single fact causes most of the challenges +below. + +Implemented as a **lab page** (`frontend/src/pages/partners/`), reachable from +the Labs (···) menu. Lab tier, not core: the registry caps core tabs at three +and this is a probe, not a product. + +--- + +## 3. Challenge log + +### C1 — There are no keystrokes to log. *(blocking, worked around)* + +The paper's triggers are "rule-based and operate on user keystrokes monitored +in real time within the editor" (§4.2), and the decision engine is fed "the +keystroke logs from the 15 seconds before the trigger" (§4.2). + +In a task pane, the writer types into a *different application*. Office.js +exposes `DocumentSelectionChanged` and nothing finer; Google Docs exposes no +selection event at all (see the comment in `frontend/src/utilities/index.tsx` +— the Apps Script bridge has to re-fetch the whole document). There is no +character-level event stream on any host, and no timing information about +individual keys. + +**Workaround:** poll `EditorAPI.getDocContext()` on an interval and diff +consecutive snapshots into a coarse *activity trace* — text length delta, +whether the change was an insertion or a deletion, cursor movement, selection +changes — keeping a rolling 15-second window to stand in for the keystroke +log. See `frontend/src/pages/partners/signals.ts`. + +**What is lost:** intra-word pause structure, burst/pause rhythm, backspace +runs, and anything else the keystroke-analysis literature the paper cites +(Baaijen, Galbraith, Bixler & D'Mello) actually depends on. Our "writing +behaviour" is a much thinner signal than theirs. This is the single biggest +fidelity gap in the reproduction, and it is not closeable on Word or Google +Docs — it is a platform limit, not an implementation shortcut. + +**Not taken:** the standalone editor (`frontend/src/editor/`) is Lexical +running in *our own* page, so real keystrokes are available there. Building +the triggers against Lexical would reproduce the paper faithfully on exactly +one surface and not at all on the two that writers actually use. Targeting +the lowest common denominator was the call; it is arguable. + +### C2 — Polling has a per-host cost the paper never pays. + +The paper's triggers cost nothing: they are local event listeners. Ours cost a +host round-trip per tick. In Word that is a `Word.run` sync; in Google Docs it +is an Apps Script call that fetches the whole document. + +**Workaround:** one poll interval (`POLL_MS`), deliberately slack, and the +poll is suspended whenever the page is hidden or the partner list is empty. + +**What is lost:** trigger latency is now quantised to the poll interval, so +"5-second pause" means "5 to 5+POLL_MS seconds". The Sentence End trigger, +which the paper fires after a **1 second** idle, is the one that suffers: at a +1s poll the detection is barely finer than the thing being detected. Google +Docs will be worse still. + +### C3 — "Aligned with the user's current cursor position" is not available. + +The paper's floating tags appear in the right-side panel *vertically aligned +with the writer's cursor* (§4.2, Fig. 5). A task pane cannot know where the +cursor is on screen — it has no access to the host's rendering geometry, only +to a character offset. + +**Workaround:** tags appear at a fixed position in the panel. + +**What is lost:** the spatial coupling between the suggestion and the text it +is about. Since the paper's §6 findings specifically credit "lightweight +visual representations" for feeling non-intrusive, this is a fidelity gap that +touches one of the paper's actual conclusions, not just its plumbing. + +### C4 — "Execute" / "Help Me Write" conflicts with this project's covenant. *(deliberate omission)* + +The paper's deepest engagement form has the partner "insert or revise text +directly in the editor" (§4.4). This repository's stated design commitment is +the opposite: `docs/design/interface-concepts.md` opens its shared covenant +with "**The writer's sentences are the writer's.** The AI quotes, asks, +points, and arranges," and `frontend/CLAUDE.md` states as fact that "nothing +in the add-in rewrites the writer's prose." + +**Decision:** *Ignore* and *Inspire* are implemented; **Execute is not.** + +This is the one place where a faithful reproduction and the host project's +values genuinely diverge, so it is flagged rather than silently resolved. The +machinery to build it exists (`EditorAPI.applyEdit`), so this is a decision to +revisit, not a capability gap. Note that any study run on this build cannot +speak to the paper's findings about Executing. + +### C5 — Two models become one. + +The paper uses a cheap fast model for the decision engine and a stronger one +for suggestions, and reports the split matters: the tag appears in <1s while +the suggestion takes ~8s, which is what makes the perceived latency tolerable. + +Thoughtful proxies a single model (`OPENAI_MODEL` in +`frontend/src/api/openai.ts`); adding a second means a backend pricing-table +entry (`backend/src/pricing.ts`) or the usage summary reports `cost: null`. + +**Workaround:** both calls use the shared model, but the *interaction* shape +is preserved — the tag renders as soon as the decision returns, and the +suggestion is generated lazily, only when the writer clicks the tag. The +paper generates eagerly; generating on click means an unclicked suggestion +costs nothing, which matters more here because our triggers are noisier (C1). + +**What is lost:** the paper's sub-second tag latency. Ours is one full model +call. If tags feel sluggish, this is why. + +### C6 — Where do partners live? + +The paper's partners are per-user, reusable across sessions, configured +before a session starts. + +`EditorAPI` offers exactly one persistence primitive — `getDocumentSetting` / +`setDocumentSetting`, which is *document*-scoped and follows the file. There +is no user-scoped store that works on all three surfaces. + +**Decision:** partners are stored with the document, alongside the writer's +brief. Defensible (a partner like "Evidence Partner" is usually about *this* +piece) but it is not what the paper did, and it means a writer re-creates +their partners per document until a user-scoped store exists. + +### C7 — Session goal: reuse, not rebuild. + +The paper's onboarding panel collects the session's writing goals. Thoughtful +already has the **brief** (audience / purpose / constraints, +`contexts/docBriefContext.tsx`), which is the same information in a shape this +codebase has already argued about. + +**Decision:** reuse the brief rather than add a second goal field. This is +arguably *better* than the paper (the brief is structured), and it is a +deviation regardless. + +### C8 — Trigger noise, and a cooldown the paper does not have. + +With keystrokes, a "long pause" is one unambiguous event. With polling, every +tick where nothing changed looks like a pause, and a writer who stops to think +for two minutes generates one pause plus a lot of ambiguity about whether it +is still the same pause. + +**Workaround:** each trigger fires at most once per quiet period / per +selection, plus a global cooldown between activations. + +**What is lost:** the cooldown is a parameter the paper does not have, and it +directly shapes how proactive the system feels — which is the paper's whole +subject. Findings about intrusiveness on this build are partly findings about +this constant. + + From 1a5131cc3228ebfecf9224480e010251d531763f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 14:20:26 +0000 Subject: [PATCH 2/6] feat(partners): reconstruct the paper's event triggers from polled snapshots The paper's three triggers run on a live keystroke stream inside its own editor. A task pane has no keystrokes on any host, so this derives the same three from diffs of polled getDocContext() snapshots, and derives a coarse activity trace to stand in for the 15-second keystroke log the decision engine reads. The state machine is a pure fold over (state, snapshot, config), so the timing behaviour is tested by feeding it made-up timestamps rather than by running clocks. It carries one parameter the paper does not have: a cooldown between fired triggers, because polling makes triggers coincide in ways keystrokes do not, and because each fired trigger costs a model call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LzPdByDv9KAZyJd6kmTYpR --- .../pages/partners/__tests__/signals.test.ts | 245 +++++++++++++++ frontend/src/pages/partners/signals.ts | 278 ++++++++++++++++++ frontend/src/pages/partners/types.ts | 109 +++++++ 3 files changed, 632 insertions(+) create mode 100644 frontend/src/pages/partners/__tests__/signals.test.ts create mode 100644 frontend/src/pages/partners/signals.ts create mode 100644 frontend/src/pages/partners/types.ts diff --git a/frontend/src/pages/partners/__tests__/signals.test.ts b/frontend/src/pages/partners/__tests__/signals.test.ts new file mode 100644 index 00000000..2e85a224 --- /dev/null +++ b/frontend/src/pages/partners/__tests__/signals.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SIGNAL_CONFIG, + describeActivity, + endsSentence, + initialSignalState, + observe, + type SignalConfig, + type SignalState, +} from '../signals'; +import type { DocSnapshot, TriggerEvent } from '../types'; + +const CONFIG: SignalConfig = { ...DEFAULT_SIGNAL_CONFIG, cooldownMs: 0 }; + +function snap( + at: number, + beforeCursor: string, + selectedText = '', + afterCursor = '', +): DocSnapshot { + return { at, beforeCursor, selectedText, afterCursor }; +} + +/** Feed a series of snapshots through the machine, collecting what fired. */ +function run( + snapshots: DocSnapshot[], + config: SignalConfig = CONFIG, +): { state: SignalState; events: TriggerEvent[] } { + let state = initialSignalState(); + const events: TriggerEvent[] = []; + for (const snapshot of snapshots) { + const result = observe(state, snapshot, config); + state = result.state; + if (result.event) events.push(result.event); + } + return { state, events }; +} + +describe('endsSentence', () => { + it('accepts sentence-final punctuation, with closers and trailing space', () => { + expect(endsSentence('It rained.')).toBe(true); + expect(endsSentence('Did it? ')).toBe(true); + expect(endsSentence('"Stop!"')).toBe(true); + expect(endsSentence('(so it goes.)')).toBe(true); + }); + + it('rejects mid-sentence positions', () => { + expect(endsSentence('It rained')).toBe(false); + expect(endsSentence('It rained, and')).toBe(false); + expect(endsSentence('')).toBe(false); + }); + + it('has the abbreviation false positive documented in signals.ts', () => { + // Not a bug to fix silently: over-firing only means the decision engine + // gets asked, and it is free to decline. + expect(endsSentence('e.g.')).toBe(true); + }); +}); + +describe('observe', () => { + it('fires nothing on the first snapshot', () => { + const { events } = run([snap(0, 'hello')]); + expect(events).toEqual([]); + }); + + it('does not treat an untouched document as a pause', () => { + // The writer has not started; sitting still is not a pause. + const { events } = run([ + snap(0, 'existing text'), + snap(1_000, 'existing text'), + snap(20_000, 'existing text'), + ]); + expect(events).toEqual([]); + }); + + it('fires a long pause once the writer stops after typing', () => { + const { events } = run([ + snap(0, 'The '), + snap(1_000, 'The claim'), + snap(6_500, 'The claim'), + ]); + expect(events.map((e) => e.trigger)).toEqual(['long-pause']); + expect(events[0].at).toBe(6_500); + }); + + it('fires a long pause only once per quiet period', () => { + const { events } = run([ + snap(0, 'a'), + snap(1_000, 'ab'), + snap(7_000, 'ab'), + snap(8_000, 'ab'), + snap(30_000, 'ab'), + ]); + expect(events.map((e) => e.trigger)).toEqual(['long-pause']); + }); + + it('re-arms the pause after the writer resumes', () => { + const { events } = run([ + snap(0, 'a'), + snap(1_000, 'ab'), + snap(7_000, 'ab'), // pause 1 + snap(8_000, 'abc'), // resumed + snap(14_000, 'abc'), // pause 2 + ]); + expect(events.map((e) => e.trigger)).toEqual([ + 'long-pause', + 'long-pause', + ]); + }); + + it('fires a sentence end after the short idle, not immediately', () => { + const { events } = run([ + snap(0, 'It rained'), + snap(500, 'It rained.'), + // 500ms later: under the 1s idle, nothing yet. + snap(1_000, 'It rained.'), + snap(1_800, 'It rained.'), + ]); + expect(events.map((e) => e.trigger)).toEqual(['sentence-end']); + expect(events[0].at).toBe(1_800); + }); + + it('does not re-fire for the same finished sentence', () => { + const { events } = run([ + snap(0, 'It rained'), + snap(500, 'It rained.'), + snap(1_800, 'It rained.'), + snap(3_000, 'It rained.'), + ]); + // The pause would fire later; restrict to sentence ends. + expect(events.filter((e) => e.trigger === 'sentence-end')).toHaveLength( + 1, + ); + }); + + it('fires again for the next sentence', () => { + const { events } = run([ + snap(0, 'One'), + snap(500, 'One.'), + snap(1_800, 'One.'), + snap(2_500, 'One. Two.'), + snap(4_000, 'One. Two.'), + ]); + expect( + events.filter((e) => e.trigger === 'sentence-end'), + ).toHaveLength(2); + }); + + it('fires a text selection once the selection is held still', () => { + const { events } = run([ + snap(0, 'The claim is bold', '', ''), + snap(1_000, 'The ', 'claim', ' is bold'), + snap(3_000, 'The ', 'claim', ' is bold'), + snap(6_500, 'The ', 'claim', ' is bold'), + ]); + expect(events.map((e) => e.trigger)).toEqual(['text-selection']); + expect(events[0].at).toBe(6_500); + }); + + it('does not fire for a selection that keeps changing', () => { + const { events } = run([ + snap(0, 'The claim is bold'), + snap(1_000, 'The ', 'claim', ' is bold'), + snap(4_000, 'The ', 'claim is', ' bold'), + snap(7_000, 'The ', 'claim is b', 'old'), + ]); + expect(events).toEqual([]); + }); + + it('prefers selection over a pause when both are due', () => { + // Nothing has moved for 6s, and a selection has been held for 6s. + const { events } = run([ + snap(0, 'The claim'), + snap(500, 'The claims'), + snap(1_000, 'The ', 'claims', ''), + snap(8_000, 'The ', 'claims', ''), + ]); + expect(events.map((e) => e.trigger)).toEqual(['text-selection']); + }); + + it('suppresses a second trigger inside the cooldown', () => { + const withCooldown: SignalConfig = { ...CONFIG, cooldownMs: 30_000 }; + const { events } = run( + [ + snap(0, 'a'), + snap(500, 'a.'), + snap(2_000, 'a.'), // sentence end fires + snap(3_000, 'a. b'), + snap(4_000, 'a. b.'), + snap(6_000, 'a. b.'), // would fire, but inside cooldown + ], + withCooldown, + ); + expect(events.map((e) => e.trigger)).toEqual(['sentence-end']); + }); + + it('keeps only the activity inside the window', () => { + const { state } = run([ + snap(0, 'a'), + snap(1_000, 'ab'), + snap(2_000, 'abc'), + snap(19_000, 'abcd'), + ]); + // The 1s and 2s records are older than the 15s window at t=19s. + expect(state.activity.map((a) => a.at)).toEqual([19_000]); + }); + + it('classifies insertions, deletions and cursor movement', () => { + const { state } = run([ + snap(0, 'abc'), + snap(100, 'abcd'), + snap(200, 'abc'), + snap(300, 'ab', '', 'c'), + ]); + expect(state.activity.map((a) => a.kind)).toEqual([ + 'typed', + 'deleted', + 'moved', + ]); + }); + + it('records a same-length replacement as a revision', () => { + const { state } = run([snap(0, 'cat'), snap(100, 'dog')]); + expect(state.activity.map((a) => a.kind)).toEqual(['revised']); + }); +}); + +describe('describeActivity', () => { + it('says so plainly when nothing happened', () => { + expect(describeActivity([], 1_000)).toMatch(/No editing activity/); + }); + + it('summarises additions, deletions and recency', () => { + const text = describeActivity( + [ + { at: 0, kind: 'typed', charsDelta: 40, cursor: 40 }, + { at: 1_000, kind: 'deleted', charsDelta: -5, cursor: 35 }, + ], + 3_000, + ); + expect(text).toContain('40 characters added'); + expect(text).toContain('5 deleted'); + expect(text).toContain('2 seconds ago'); + }); +}); diff --git a/frontend/src/pages/partners/signals.ts b/frontend/src/pages/partners/signals.ts new file mode 100644 index 00000000..552225a6 --- /dev/null +++ b/frontend/src/pages/partners/signals.ts @@ -0,0 +1,278 @@ +/** + * Trigger detection for the proactive-partners probe. + * + * The paper's three event triggers (§4.2) are rule-based over a live keystroke + * stream. A task pane has no such stream — the writer types into Word or + * Google Docs, and the only channel back is `EditorAPI.getDocContext()`. So + * this module reconstructs the triggers by *diffing polled snapshots of the + * document*, and reconstructs the paper's 15-second keystroke log as a much + * coarser activity trace over the same diffs. See challenge C1 in + * `docs/proactive-partners-reproduction.md` for what that loses. + * + * Everything here is a pure function of (state, snapshot, config) so the whole + * state machine is testable by feeding it snapshots with made-up timestamps — + * no timers, no fake clocks, no host. + */ +import type { + ActivityRecord, + DocSnapshot, + EventTrigger, + TriggerEvent, +} from './types'; + +export interface SignalConfig { + /** Idle time before a pause counts as one. Paper's default: 5s. */ + pauseMs: number; + /** Idle time after a completed sentence. Paper's default: 1s. */ + sentenceIdleMs: number; + /** Idle time with a selection held. Paper's default: 5s. */ + selectionIdleMs: number; + /** + * Minimum gap between two fired triggers. **Not in the paper** — it exists + * because polling makes triggers noisier than keystrokes do (challenge C8), + * and because each fired trigger costs a model call. + */ + cooldownMs: number; + /** How much activity history to keep. Paper's window: 15s. */ + activityWindowMs: number; +} + +export const DEFAULT_SIGNAL_CONFIG: SignalConfig = { + pauseMs: 5_000, + sentenceIdleMs: 1_000, + selectionIdleMs: 5_000, + cooldownMs: 45_000, + activityWindowMs: 15_000, +}; + +export interface SignalState { + last: DocSnapshot | null; + /** Rolling activity window, oldest first. */ + activity: ActivityRecord[]; + /** When the document last changed in any way (text, cursor, or selection). */ + lastChangeAt: number; + /** When the document *text* last changed. 0 if it never has. */ + lastTextChangeAt: number; + /** True once a pause has fired for the current quiet period. */ + pauseFired: boolean; + /** Fingerprint of the sentence a sentence-end already fired for. */ + sentenceEndFiredFor: string | null; + /** The selected text a selection trigger already fired for. */ + selectionFiredFor: string | null; + /** When the current selection last changed. */ + selectionSince: number; + /** When a trigger last fired, for the cooldown. */ + lastEventAt: number; +} + +export function initialSignalState(): SignalState { + return { + last: null, + activity: [], + lastChangeAt: 0, + lastTextChangeAt: 0, + pauseFired: false, + sentenceEndFiredFor: null, + selectionFiredFor: null, + selectionSince: 0, + lastEventAt: 0, + }; +} + +export function snapshotText(snapshot: DocSnapshot): string { + return snapshot.beforeCursor + snapshot.selectedText + snapshot.afterCursor; +} + +/** Character offset of the cursor (or of the selection start). */ +export function cursorOffset(snapshot: DocSnapshot): number { + return snapshot.beforeCursor.length; +} + +/** + * Sentence-final punctuation, allowing trailing closing quotes/brackets and + * whitespace: `... end."` and `... end.)` both count. + * + * Known false positive: abbreviations ("e.g.", "Dr.") read as sentence ends. + * The paper does not say how it handled this, and over-firing is cheap here — + * a sentence-end trigger only makes the decision engine *consider* partners, + * which can and often does decline. + */ +const SENTENCE_END = /[.!?]["'”’)\]]*\s*$/; + +export function endsSentence(beforeCursor: string): boolean { + return SENTENCE_END.test(beforeCursor); +} + +/** Stable-enough identity for "the sentence the cursor just finished". */ +function sentenceFingerprint(beforeCursor: string): string { + return `${beforeCursor.length}:${beforeCursor.slice(-80)}`; +} + +function classify( + prev: DocSnapshot, + next: DocSnapshot, +): ActivityRecord['kind'] { + const prevText = snapshotText(prev); + const nextText = snapshotText(next); + if (prevText !== nextText) { + const delta = nextText.length - prevText.length; + if (delta > 0) return 'typed'; + if (delta < 0) return 'deleted'; + // Same length, different content: a replacement or an overtype. + return 'revised'; + } + if (prev.selectedText !== next.selectedText) return 'selected'; + if (cursorOffset(prev) !== cursorOffset(next)) return 'moved'; + return 'idle'; +} + +/** + * Fold one polled snapshot into the state, returning the new state and any + * trigger that fired. + * + * At most one trigger fires per observation. When several are due the most + * specific wins (selection, then sentence end, then pause) — the paper does + * not specify an order because with keystrokes its triggers rarely coincide; + * with polling they routinely do. + */ +export function observe( + state: SignalState, + snapshot: DocSnapshot, + config: SignalConfig = DEFAULT_SIGNAL_CONFIG, +): { state: SignalState; event: TriggerEvent | null } { + const now = snapshot.at; + + // First observation establishes the baseline; nothing to diff against. + if (state.last === null) { + return { + state: { + ...state, + last: snapshot, + lastChangeAt: now, + selectionSince: now, + }, + event: null, + }; + } + + const kind = classify(state.last, snapshot); + const textChanged = + kind === 'typed' || kind === 'deleted' || kind === 'revised'; + const selectionChanged = state.last.selectedText !== snapshot.selectedText; + + let next: SignalState = { ...state, last: snapshot }; + + if (kind !== 'idle') { + const record: ActivityRecord = { + at: now, + kind, + charsDelta: + snapshotText(snapshot).length - snapshotText(state.last).length, + cursor: cursorOffset(snapshot), + }; + next.activity = [...state.activity, record].filter( + (entry) => now - entry.at <= config.activityWindowMs, + ); + next.lastChangeAt = now; + // Any activity opens a new quiet period. + next.pauseFired = false; + } else { + next.activity = state.activity.filter( + (entry) => now - entry.at <= config.activityWindowMs, + ); + } + + if (textChanged) next.lastTextChangeAt = now; + if (selectionChanged) { + next.selectionSince = now; + next.selectionFiredFor = null; + } + + // A writer who has not typed anything yet is not pausing or finishing a + // sentence; they have not started. Selection is exempt — selecting text in + // an existing document is a real signal on its own. + const hasWritten = next.lastTextChangeAt > 0; + const idleFor = now - next.lastChangeAt; + const textIdleFor = now - next.lastTextChangeAt; + const inCooldown = + next.lastEventAt > 0 && now - next.lastEventAt < config.cooldownMs; + + const fire = (trigger: EventTrigger): { state: SignalState; event: TriggerEvent } => ({ + state: { ...next, lastEventAt: now }, + event: { trigger, at: now, snapshot, activity: next.activity }, + }); + + if (inCooldown) return { state: next, event: null }; + + // 1. Text selection: a selection held still long enough to mean something. + if ( + snapshot.selectedText.trim() !== '' && + now - next.selectionSince >= config.selectionIdleMs && + next.selectionFiredFor !== snapshot.selectedText + ) { + const fired = fire('text-selection'); + fired.state.selectionFiredFor = snapshot.selectedText; + return fired; + } + + // 2. Sentence end: the cursor sits just past sentence-final punctuation and + // the writer has stopped for the short idle the paper uses. + const fingerprint = sentenceFingerprint(snapshot.beforeCursor); + if ( + hasWritten && + snapshot.selectedText === '' && + endsSentence(snapshot.beforeCursor) && + textIdleFor >= config.sentenceIdleMs && + next.sentenceEndFiredFor !== fingerprint + ) { + const fired = fire('sentence-end'); + fired.state.sentenceEndFiredFor = fingerprint; + return fired; + } + + // 3. Long pause: nothing at all has happened for a while. + if (hasWritten && !next.pauseFired && idleFor >= config.pauseMs) { + const fired = fire('long-pause'); + fired.state.pauseFired = true; + return fired; + } + + return { state: next, event: null }; +} + +/** + * Render the activity window as the prose the decision engine reads. + * + * The paper hands its engine a keystroke log; this is the honest description + * of what we have instead. Written as a summary rather than a dump because the + * records are already lossy — pretending to per-key detail would invite the + * model to over-read them. + */ +export function describeActivity( + activity: ActivityRecord[], + now: number, + windowMs: number = DEFAULT_SIGNAL_CONFIG.activityWindowMs, +): string { + const seconds = Math.round(windowMs / 1000); + if (activity.length === 0) { + return `No editing activity in the last ${seconds} seconds.`; + } + const added = activity + .filter((entry) => entry.charsDelta > 0) + .reduce((sum, entry) => sum + entry.charsDelta, 0); + const removed = -activity + .filter((entry) => entry.charsDelta < 0) + .reduce((sum, entry) => sum + entry.charsDelta, 0); + const kinds = new Set(activity.map((entry) => entry.kind)); + const last = activity[activity.length - 1]; + const sinceLast = Math.round((now - last.at) / 1000); + + const parts = [ + `In the last ${seconds} seconds: about ${added} characters added` + + (removed > 0 ? `, ${removed} deleted` : '') + + '.', + `Observed activity: ${[...kinds].join(', ')}.`, + `Last observed change was about ${sinceLast} second${sinceLast === 1 ? '' : 's'} ago.`, + ]; + return parts.join(' '); +} diff --git a/frontend/src/pages/partners/types.ts b/frontend/src/pages/partners/types.ts new file mode 100644 index 00000000..6dc6f495 --- /dev/null +++ b/frontend/src/pages/partners/types.ts @@ -0,0 +1,109 @@ +/** + * Types for the proactive-thought-partners probe. + * + * Reproduction of Zhang et al., *Designing Proactive Thought Partners for + * Writing* (arXiv:2609.01588v1) §4. See + * `docs/proactive-partners-reproduction.md` for the mapping and for the + * challenge log — several names here are deliberately the paper's rather than + * this codebase's, so the two can be read side by side. + */ + +/** + * The observable moments a partner may be considered at. The paper's three + * (§4.2), kept in its order and with its default timings. + */ +export type EventTrigger = 'long-pause' | 'sentence-end' | 'text-selection'; + +export const EVENT_TRIGGERS: EventTrigger[] = [ + 'long-pause', + 'sentence-end', + 'text-selection', +]; + +export const TRIGGER_LABELS: Record = { + 'long-pause': 'Long pause', + 'sentence-end': 'Sentence end', + 'text-selection': 'Text selection', +}; + +/** The rationale each trigger came from, shown in the config panel. */ +export const TRIGGER_HINTS: Record = { + 'long-pause': 'After you stop for a while — a natural break.', + 'sentence-end': 'Just after you finish a sentence.', + 'text-selection': 'When you select text and sit with it.', +}; + +/** + * A writer-configured partner. The four fields are the paper's (§4.1): what + * kind of help, when it may consider helping, and under what conditions it + * should actually speak. + */ +export interface Partner { + id: string; + /** Shown on the floating tag beside the name. */ + emoji: string; + name: string; + /** What kind of support this partner provides. */ + role: string; + /** Broad candidate moments. Empty means the partner never activates. */ + triggers: EventTrigger[]; + /** + * The contextual criteria under which the partner should take the + * initiative once one of its triggers fires — "when a claim lacks + * evidence", "when the argument may need a counterpoint". + */ + heuristic: string; + enabled: boolean; +} + +/** + * One tick of coarse writing behaviour, standing in for the paper's keystroke + * log (challenge C1). Derived by diffing polled document snapshots, so a + * single record can cover several seconds of typing rather than one key. + */ +export interface ActivityRecord { + /** ms since epoch. */ + at: number; + kind: 'typed' | 'deleted' | 'revised' | 'moved' | 'selected' | 'idle'; + /** Net characters added (negative for deletions). */ + charsDelta: number; + /** Cursor offset from the start of the document, after the change. */ + cursor: number; +} + +/** A snapshot of the document as the polling loop sees it. */ +export interface DocSnapshot { + at: number; + beforeCursor: string; + selectedText: string; + afterCursor: string; +} + +/** A fired trigger, with the context the decision engine will need. */ +export interface TriggerEvent { + trigger: EventTrigger; + at: number; + snapshot: DocSnapshot; + /** The rolling activity window, oldest first. */ + activity: ActivityRecord[]; +} + +/** What the decision engine returns for one activated partner. */ +export interface Activation { + /** Unique per activation, so a re-activated partner gets a fresh card. */ + id: string; + partnerId: string; + trigger: EventTrigger; + at: number; + /** The engine's own one-line reason. Shown only in the log, not the UI. */ + why: string; + /** The document state that activated it, reused for suggestion generation. */ + snapshot: DocSnapshot; + activity: ActivityRecord[]; +} + +/** The two-part suggestion of §4.3. */ +export interface Suggestion { + acknowledgement: string; + question: string; +} From 5c20d1366980b78091b869d729028b6603be6fb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 14:22:52 +0000 Subject: [PATCH 3/6] feat(partners): decision engine, suggestion generation, and partner storage Reconstructs the paper's two LLM stages from its prose, since the prompts it points at are in supplementary materials the arXiv PDF does not contain: an engine that judges each partner's contextual heuristic against the moment and selects at most two (preferring silence), and a suggestion of the paper's two parts - an acknowledgement of what the writer is doing, then a question. Both parsers are deliberately tolerant. A model that answers in prose, fences its JSON, invents a partner id, or returns three partners should degrade to something the writer can use, not throw on the writing path. Partners persist as a document setting, which is the only store all three hosts share; the paper's are per-user. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LzPdByDv9KAZyJd6kmTYpR --- .../pages/partners/__tests__/engine.test.ts | 132 ++++++++ .../pages/partners/__tests__/storage.test.ts | 101 ++++++ frontend/src/pages/partners/engine.ts | 289 ++++++++++++++++++ frontend/src/pages/partners/storage.ts | 117 +++++++ 4 files changed, 639 insertions(+) create mode 100644 frontend/src/pages/partners/__tests__/engine.test.ts create mode 100644 frontend/src/pages/partners/__tests__/storage.test.ts create mode 100644 frontend/src/pages/partners/engine.ts create mode 100644 frontend/src/pages/partners/storage.ts diff --git a/frontend/src/pages/partners/__tests__/engine.test.ts b/frontend/src/pages/partners/__tests__/engine.test.ts new file mode 100644 index 00000000..fd6b520f --- /dev/null +++ b/frontend/src/pages/partners/__tests__/engine.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_ACTIVATIONS, + parseDecision, + parseSuggestion, + renderDocument, +} from '../engine'; + +const KNOWN = new Set(['a', 'b', 'c']); + +describe('parseDecision', () => { + it('reads a plain decision', () => { + expect( + parseDecision('{"activate":[{"id":"a","why":"no evidence yet"}]}', KNOWN), + ).toEqual([{ id: 'a', why: 'no evidence yet' }]); + }); + + it('reads silence', () => { + expect(parseDecision('{"activate":[]}', KNOWN)).toEqual([]); + }); + + it('unwraps a fenced code block', () => { + expect( + parseDecision('```json\n{"activate":[{"id":"b","why":"x"}]}\n```', KNOWN), + ).toEqual([{ id: 'b', why: 'x' }]); + }); + + it('tolerates a preamble around the object', () => { + expect( + parseDecision('Sure! {"activate":[{"id":"c","why":"y"}]} Hope that helps.', KNOWN), + ).toEqual([{ id: 'c', why: 'y' }]); + }); + + it('drops ids the writer never configured', () => { + expect( + parseDecision( + '{"activate":[{"id":"ghost","why":"x"},{"id":"a","why":"y"}]}', + KNOWN, + ), + ).toEqual([{ id: 'a', why: 'y' }]); + }); + + it('drops a repeated id', () => { + expect( + parseDecision( + '{"activate":[{"id":"a","why":"x"},{"id":"a","why":"y"}]}', + KNOWN, + ), + ).toEqual([{ id: 'a', why: 'x' }]); + }); + + it("enforces the paper's cap of two", () => { + const choices = parseDecision( + '{"activate":[{"id":"a"},{"id":"b"},{"id":"c"}]}', + KNOWN, + ); + expect(choices).toHaveLength(MAX_ACTIVATIONS); + expect(choices.map((c) => c.id)).toEqual(['a', 'b']); + }); + + it('survives a reply that is not JSON at all', () => { + expect(parseDecision('I think nobody should speak.', KNOWN)).toEqual([]); + expect(parseDecision('', KNOWN)).toEqual([]); + expect(parseDecision('{ not json }', KNOWN)).toEqual([]); + }); +}); + +describe('parseSuggestion', () => { + it('reads both parts', () => { + expect( + parseSuggestion( + '{"acknowledgement":"You just moved to examples.","question":"Which one?"}', + ), + ).toEqual({ + acknowledgement: 'You just moved to examples.', + question: 'Which one?', + }); + }); + + it('falls back to prose as the question', () => { + // A partner that said something beats an error card. + expect(parseSuggestion('What would change if you led with the claim?')).toEqual( + { + acknowledgement: '', + question: 'What would change if you led with the claim?', + }, + ); + }); + + it('keeps whichever half the model supplied', () => { + expect(parseSuggestion('{"question":"Why now?"}')).toEqual({ + acknowledgement: '', + question: 'Why now?', + }); + }); +}); + +describe('renderDocument', () => { + it('marks the cursor', () => { + expect( + renderDocument({ + at: 0, + beforeCursor: 'One. ', + selectedText: '', + afterCursor: 'Two.', + }), + ).toBe('One. <>Two.'); + }); + + it('marks a selection instead', () => { + expect( + renderDocument({ + at: 0, + beforeCursor: 'One ', + selectedText: 'big', + afterCursor: ' claim', + }), + ).toBe('One <>big<> claim'); + }); + + it('truncates a long document around the cursor', () => { + const rendered = renderDocument({ + at: 0, + beforeCursor: 'x'.repeat(5_000), + selectedText: '', + afterCursor: 'y'.repeat(5_000), + }); + expect(rendered.startsWith('…')).toBe(true); + expect(rendered.endsWith('…')).toBe(true); + expect(rendered.length).toBeLessThan(4_100); + }); +}); diff --git a/frontend/src/pages/partners/__tests__/storage.test.ts b/frontend/src/pages/partners/__tests__/storage.test.ts new file mode 100644 index 00000000..7c001949 --- /dev/null +++ b/frontend/src/pages/partners/__tests__/storage.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { + activeTriggers, + emptyPartner, + isPartnerActivatable, + parsePartners, + partnersForTrigger, + serializePartners, +} from '../storage'; +import type { Partner } from '../types'; + +function partner(overrides: Partial = {}): Partner { + return { + id: 'p1', + emoji: '🔎', + name: 'Evidence', + role: 'Help me strengthen claims with concrete support.', + triggers: ['long-pause'], + heuristic: 'When I make a claim without an example.', + enabled: true, + ...overrides, + }; +} + +describe('emptyPartner', () => { + it("selects no triggers, matching the paper's anti-anchoring default", () => { + expect(emptyPartner().triggers).toEqual([]); + }); +}); + +describe('parsePartners', () => { + it('round-trips', () => { + const partners = [partner(), partner({ id: 'p2', name: 'Skeptic' })]; + expect(parsePartners(serializePartners(partners))).toEqual(partners); + }); + + it('returns nothing for absent or unreadable values', () => { + expect(parsePartners(null)).toEqual([]); + expect(parsePartners('')).toEqual([]); + expect(parsePartners('not json')).toEqual([]); + expect(parsePartners('{"partners":[]}')).toEqual([]); + }); + + it('drops entries with no id rather than failing the whole list', () => { + const raw = JSON.stringify([{ name: 'nameless' }, partner()]); + expect(parsePartners(raw).map((p) => p.id)).toEqual(['p1']); + }); + + it('drops unknown trigger names', () => { + const raw = JSON.stringify([ + { ...partner(), triggers: ['long-pause', 'telepathy'] }, + ]); + expect(parsePartners(raw)[0].triggers).toEqual(['long-pause']); + }); + + it('defaults enabled to true and emoji to a placeholder', () => { + const raw = JSON.stringify([{ id: 'p9' }]); + expect(parsePartners(raw)[0]).toMatchObject({ + enabled: true, + emoji: '💭', + }); + }); +}); + +describe('isPartnerActivatable', () => { + it('requires a name, a role, a heuristic and a trigger', () => { + expect(isPartnerActivatable(partner())).toBe(true); + expect(isPartnerActivatable(partner({ name: ' ' }))).toBe(false); + expect(isPartnerActivatable(partner({ role: '' }))).toBe(false); + expect(isPartnerActivatable(partner({ heuristic: '' }))).toBe(false); + expect(isPartnerActivatable(partner({ triggers: [] }))).toBe(false); + expect(isPartnerActivatable(partner({ enabled: false }))).toBe(false); + }); +}); + +describe('partnersForTrigger / activeTriggers', () => { + const partners = [ + partner({ id: 'a', triggers: ['long-pause'] }), + partner({ id: 'b', triggers: ['text-selection', 'sentence-end'] }), + partner({ id: 'c', triggers: ['long-pause'], enabled: false }), + partner({ id: 'd', triggers: ['long-pause'], heuristic: '' }), + ]; + + it('matches only complete, enabled partners listening for the trigger', () => { + expect(partnersForTrigger(partners, 'long-pause').map((p) => p.id)).toEqual( + ['a'], + ); + expect( + partnersForTrigger(partners, 'sentence-end').map((p) => p.id), + ).toEqual(['b']); + }); + + it('reports which triggers are worth watching for at all', () => { + expect([...activeTriggers(partners)].sort()).toEqual([ + 'long-pause', + 'sentence-end', + 'text-selection', + ]); + expect(activeTriggers([partner({ triggers: [] })]).size).toBe(0); + }); +}); diff --git a/frontend/src/pages/partners/engine.ts b/frontend/src/pages/partners/engine.ts new file mode 100644 index 00000000..58041eff --- /dev/null +++ b/frontend/src/pages/partners/engine.ts @@ -0,0 +1,289 @@ +/** + * The decision engine (§4.2) and suggestion generation (§4.3). + * + * ## About these prompts + * + * The paper says its prompts are in supplementary materials. The arXiv PDF has + * no such appendix — pages 24-30 are references. Everything below is therefore + * **reconstructed from the prose**, not transcribed, and any behavioural + * difference from the paper could be a prompt difference we cannot detect. + * What the prose does pin down, and what is honoured here: + * + * - The engine receives the session goal, the current text, the recent writing + * behaviour, and the enabled partners; it "selects at most two enabled + * partners, if any" by "checking whether their user-defined contextual + * heuristics are satisfied" (§4.2). + * - A suggestion is two parts: an acknowledgement of what the writer has just + * done or may be trying to do next, then a *question* — question form + * because the paper cites evidence that it stimulates ideas while preserving + * the writer's ownership (§4.3). + * + * ## One model, not two + * + * The paper splits a fast model for the decision and a stronger one for the + * suggestion. We proxy a single model, so both calls use it, and the + * suggestion is generated lazily on click rather than eagerly on activation. + * See challenge C5 in `docs/proactive-partners-reproduction.md`. + */ +import { generateFullText } from '@/api/generate'; +import { languageModel, openaiProviderOptions } from '@/api/openai'; +import { describeActivity } from './signals'; +import type { + ActivityRecord, + DocSnapshot, + EventTrigger, + Partner, + Suggestion, + TriggerEvent, +} from './types'; +import { TRIGGER_LABELS } from './types'; + +/** The paper's cap: "at most two" partners per triggering event (§4.2). */ +export const MAX_ACTIVATIONS = 2; + +/** + * How much document to send. The paper sends "the current text in the editor"; + * we cap it because a task pane can be sitting beside a book chapter, and the + * useful context for a moment-to-moment judgement is local. + */ +const CONTEXT_CHARS_BEFORE = 3_000; +const CONTEXT_CHARS_AFTER = 1_000; + +function describeTrigger(trigger: EventTrigger): string { + switch (trigger) { + case 'long-pause': + return 'The writer stopped typing for several seconds.'; + case 'sentence-end': + return 'The writer just finished a sentence and paused briefly.'; + case 'text-selection': + return 'The writer selected a span of text and left it selected.'; + } +} + +/** + * The document as the model sees it: a window around the cursor, with the + * cursor or selection marked. Matches the convention the Chat page already + * uses, so a reader of the logs sees one document format across pages. + */ +export function renderDocument(snapshot: DocSnapshot): string { + const before = snapshot.beforeCursor.slice(-CONTEXT_CHARS_BEFORE); + const after = snapshot.afterCursor.slice(0, CONTEXT_CHARS_AFTER); + const truncatedBefore = + snapshot.beforeCursor.length > CONTEXT_CHARS_BEFORE ? '…' : ''; + const truncatedAfter = + snapshot.afterCursor.length > CONTEXT_CHARS_AFTER ? '…' : ''; + if (snapshot.selectedText === '') { + return `${truncatedBefore}${before}<>${after}${truncatedAfter}`; + } + return `${truncatedBefore}${before}<>${snapshot.selectedText}<>${after}${truncatedAfter}`; +} + +function renderSituation( + snapshot: DocSnapshot, + activity: ActivityRecord[], + trigger: EventTrigger, + brief: string | null, +): string { + return [ + brief, + `What just happened: ${describeTrigger(trigger)}`, + `Recent writing behaviour: ${describeActivity(activity, snapshot.at)}`, + `The document, with the writer's cursor or selection marked:\n\n${renderDocument(snapshot)}`, + ] + .filter(Boolean) + .join('\n\n'); +} + +export const DECISION_INSTRUCTIONS = `\ +You decide whether an AI writing partner should speak up right now, while someone is writing. + +You will be given the writer's brief, what they just did, a summary of their recent editing activity, their document with the cursor or selection marked, and a list of partners the writer configured. Each partner has a role (the kind of help it gives) and a contextual heuristic (the condition the writer said should be true before it interrupts). + +For each partner, judge only one thing: is that partner's heuristic actually satisfied by this moment in this document? Judge the heuristic as written. Do not activate a partner because its role seems generally useful. + +Select at most two partners, and prefer to select none. Interrupting a writer who does not need help is worse than staying quiet: silence is the correct answer most of the time. + +Reply with JSON and nothing else, in this shape: +{"activate": [{"id": "", "why": ""}]} +Use {"activate": []} to stay silent.`; + +function renderPartnerList(partners: Partner[]): string { + return partners + .map( + (partner) => + `- id: ${partner.id}\n name: ${partner.name}\n role: ${partner.role}\n speak up when: ${partner.heuristic}`, + ) + .join('\n'); +} + +export interface DecisionChoice { + id: string; + why: string; +} + +/** + * Parse the engine's reply. + * + * Tolerant on purpose: a fenced code block, a stray preamble, or a missing + * `why` should degrade to "no partners" or "this partner, no reason" rather + * than to an exception on the writing path. Unknown ids are dropped — the + * model occasionally invents one — and the result is capped at two. + */ +export function parseDecision( + raw: string, + knownIds: Set, +): DecisionChoice[] { + const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw); + const body = (fenced ? fenced[1] : raw).trim(); + const start = body.indexOf('{'); + const end = body.lastIndexOf('}'); + if (start === -1 || end <= start) return []; + let parsed: unknown; + try { + parsed = JSON.parse(body.slice(start, end + 1)); + } catch { + return []; + } + const activate = (parsed as { activate?: unknown })?.activate; + if (!Array.isArray(activate)) return []; + const seen = new Set(); + const choices: DecisionChoice[] = []; + for (const entry of activate) { + if (typeof entry !== 'object' || entry === null) continue; + const record = entry as Record; + const id = typeof record.id === 'string' ? record.id : ''; + if (!knownIds.has(id) || seen.has(id)) continue; + seen.add(id); + choices.push({ + id, + why: typeof record.why === 'string' ? record.why : '', + }); + if (choices.length === MAX_ACTIVATIONS) break; + } + return choices; +} + +/** Ask the engine which of `candidates`, if any, should speak up now. */ +export async function decideActivations( + event: TriggerEvent, + candidates: Partner[], + brief: string | null, + signal?: AbortSignal, +): Promise { + if (candidates.length === 0) return []; + const prompt = [ + renderSituation(event.snapshot, event.activity, event.trigger, brief), + `The writer's partners:\n${renderPartnerList(candidates)}`, + ].join('\n\n'); + + const raw = await generateFullText({ + model: languageModel, + instructions: DECISION_INSTRUCTIONS, + messages: [{ role: 'user', content: prompt }], + providerOptions: openaiProviderOptions, + abortSignal: signal, + }); + return parseDecision( + raw, + new Set(candidates.map((partner) => partner.id)), + ); +} + +export const SUGGESTION_INSTRUCTIONS = `\ +You are a thought partner for a writer, configured by them for one specific kind of help. You have just decided this is a good moment to speak up, unprompted, while they are mid-draft. + +Say two things, in this order. + +First, one sentence acknowledging what the writer appears to be doing right now — what they have just written, or what they seem to be reaching for next. Ground it in their actual text. This is how you show you have read them; it also lets them catch you if you have misread. + +Second, one question that opens up the thinking your role is meant to support. A question, not an instruction and not a rewrite: the writer's sentences are theirs, and your job is to make a line of thought available to them, not to supply it. It should be specific to this draft — a question that would make sense pasted under any document is not worth interrupting for. It may name a concrete possibility as part of the question, but it must still end as a question they could answer either way. + +Be brief. Two or three sentences total. Do not greet them, do not explain yourself, and do not praise the writing. + +Reply with JSON and nothing else: +{"acknowledgement": "", "question": ""}`; + +/** + * Parse a suggestion, falling back to treating the whole reply as the question + * when the model answers in prose. A partner that produced *something* is more + * useful to the writer than an error card, and an empty question is still + * caught by the caller. + */ +export function parseSuggestion(raw: string): Suggestion { + const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw); + const body = (fenced ? fenced[1] : raw).trim(); + const start = body.indexOf('{'); + const end = body.lastIndexOf('}'); + if (start !== -1 && end > start) { + try { + const parsed = JSON.parse(body.slice(start, end + 1)) as Record< + string, + unknown + >; + const acknowledgement = + typeof parsed.acknowledgement === 'string' + ? parsed.acknowledgement + : ''; + const question = + typeof parsed.question === 'string' ? parsed.question : ''; + if (acknowledgement || question) { + return { acknowledgement, question }; + } + } catch { + // Fall through to the prose reading below. + } + } + return { acknowledgement: '', question: body }; +} + +export async function generateSuggestion( + partner: Partner, + snapshot: DocSnapshot, + activity: ActivityRecord[], + trigger: EventTrigger, + brief: string | null, + signal?: AbortSignal, +): Promise { + const prompt = [ + `Your role: ${partner.role}`, + `The writer asked you to speak up when: ${partner.heuristic}`, + renderSituation(snapshot, activity, trigger, brief), + ].join('\n\n'); + + const raw = await generateFullText({ + model: languageModel, + instructions: SUGGESTION_INSTRUCTIONS, + messages: [{ role: 'user', content: prompt }], + providerOptions: openaiProviderOptions, + abortSignal: signal, + }); + return parseSuggestion(raw); +} + +/** + * The follow-up conversation of §4.4 ("Inspiring"): the writer can clarify, + * ask for alternatives, or challenge the partner's framing. Kept in the same + * register as the suggestion — this is still a partner, not a text generator. + */ +export const FOLLOW_UP_INSTRUCTIONS = `\ +You are the same thought partner, now in a short follow-up conversation the writer opened from your suggestion. They may want it clarified, want alternatives, or want to push back on your framing. + +Stay a thought partner. Answer plainly and briefly — two or three sentences. Do not draft prose for the document and do not rewrite their sentences; where they seem to want that, point at what the choice actually is and let them make it. It is fine to say your suggestion does not apply here.`; + +export function followUpContext( + partner: Partner, + snapshot: DocSnapshot, + trigger: EventTrigger, + suggestion: Suggestion, + brief: string | null, +): string { + return [ + `Your role: ${partner.role}`, + brief, + `You spoke up because: ${TRIGGER_LABELS[trigger]} — ${describeTrigger(trigger)}`, + `The document at that moment:\n\n${renderDocument(snapshot)}`, + `What you said:\n${suggestion.acknowledgement}\n${suggestion.question}`, + ] + .filter(Boolean) + .join('\n\n'); +} diff --git a/frontend/src/pages/partners/storage.ts b/frontend/src/pages/partners/storage.ts new file mode 100644 index 00000000..7f823cec --- /dev/null +++ b/frontend/src/pages/partners/storage.ts @@ -0,0 +1,117 @@ +/** + * Persistence for writer-configured partners. + * + * Partners live *with the document* (`EditorAPI.getDocumentSetting` / + * `setDocumentSetting`), alongside the writer's brief. The paper's partners + * are per-user and reused across sessions; document settings are the only + * store that works on all three of our surfaces, so this is a deviation — + * see challenge C6 in `docs/proactive-partners-reproduction.md`. + * + * Parsing is defensive rather than schema-validated: the value is JSON written + * by an older build of this same page, and a partner list that fails to load + * would silently disable the whole feature. Anything unreadable is dropped, + * anything readable is kept. + */ +import { EVENT_TRIGGERS, type EventTrigger, type Partner } from './types'; + +/** The document setting the partner list is serialized into, as JSON. */ +export const PARTNERS_SETTING_KEY = 'proactivePartners'; + +export function newPartnerId(): string { + return `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * A blank partner. Deliberately starts with **no triggers selected** — the + * paper does the same "to avoid anchoring effects" (§4.1, Fig. 3), and a + * partner with no trigger simply never activates, which is the honest default + * for something the writer has not finished configuring. + */ +export function emptyPartner(): Partner { + return { + id: newPartnerId(), + emoji: '💭', + name: '', + role: '', + triggers: [], + heuristic: '', + enabled: true, + }; +} + +function asString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +function asTriggers(value: unknown): EventTrigger[] { + if (!Array.isArray(value)) return []; + return EVENT_TRIGGERS.filter((trigger) => value.includes(trigger)); +} + +export function parsePartners(raw: string | null): Partner[] { + if (!raw) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.flatMap((entry): Partner[] => { + if (typeof entry !== 'object' || entry === null) return []; + const record = entry as Record; + const id = asString(record.id); + if (!id) return []; + return [ + { + id, + emoji: asString(record.emoji, '💭') || '💭', + name: asString(record.name), + role: asString(record.role), + triggers: asTriggers(record.triggers), + heuristic: asString(record.heuristic), + enabled: record.enabled !== false, + }, + ]; + }); +} + +export function serializePartners(partners: Partner[]): string { + return JSON.stringify(partners); +} + +/** + * Whether a partner is complete enough to activate. A partner with no role has + * nothing to say, one with no heuristic gives the decision engine no criterion + * to test, and one with no trigger has no moment to be considered at. + */ +export function isPartnerActivatable(partner: Partner): boolean { + return ( + partner.enabled && + partner.name.trim() !== '' && + partner.role.trim() !== '' && + partner.heuristic.trim() !== '' && + partner.triggers.length > 0 + ); +} + +/** The partners eligible to be considered when `trigger` fires. */ +export function partnersForTrigger( + partners: Partner[], + trigger: EventTrigger, +): Partner[] { + return partners.filter( + (partner) => + isPartnerActivatable(partner) && partner.triggers.includes(trigger), + ); +} + +/** The union of triggers any activatable partner listens for. */ +export function activeTriggers(partners: Partner[]): Set { + const active = new Set(); + for (const partner of partners) { + if (!isPartnerActivatable(partner)) continue; + for (const trigger of partner.triggers) active.add(trigger); + } + return active; +} From d5871318d8e2deeeb3f2a274ba3e8e2d2ee32689 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 14:28:35 +0000 Subject: [PATCH 4/6] feat(partners): the Partners lab page - watch loop, tags, and suggestion cards Wires the pieces into a page: configure partners, switch watching on, and tags appear when the decision engine finds a heuristic satisfied. Clicking a tag opens the suggestion and a short follow-up conversation; ignoring it lets the tag fade after fifteen seconds, as in the paper. Two of the paper's three engagement forms are here. The third, where the partner writes into the document, is not, and the page says so on its face. The suggestion is generated on click rather than on activation. The paper can afford eager generation because it runs a cheap model for the decision and a separate one for the suggestion; we have one model and noisier triggers, so an unopened tag should not cost a generation. Adds the page's log events at schema version 6. The interesting one is partners_activated with activated: 0 - the engine choosing not to interrupt, which is the outcome the paper reports as most common. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LzPdByDv9KAZyJd6kmTYpR --- .../src/api/__tests__/wordEditorAPI.test.ts | 9 +- frontend/src/api/handoff.ts | 7 +- frontend/src/api/logging.ts | 102 ++++- frontend/src/api/wordEditorAPI.ts | 4 +- frontend/src/contexts/pageContext.tsx | 1 + frontend/src/pages/__tests__/registry.test.ts | 6 +- frontend/src/pages/partners/PartnerEditor.tsx | 264 ++++++++++++ .../src/pages/partners/SuggestionCard.tsx | 223 +++++++++++ .../pages/partners/__tests__/engine.test.ts | 31 +- .../pages/partners/__tests__/signals.test.ts | 6 +- .../pages/partners/__tests__/storage.test.ts | 6 +- frontend/src/pages/partners/engine.ts | 5 +- frontend/src/pages/partners/index.tsx | 320 +++++++++++++++ frontend/src/pages/partners/signals.ts | 6 +- frontend/src/pages/partners/styles.module.css | 379 ++++++++++++++++++ .../src/pages/partners/usePartnerWatch.ts | 226 +++++++++++ frontend/src/pages/registry.tsx | 8 + frontend/src/pages/tools/index.tsx | 4 +- 18 files changed, 1573 insertions(+), 34 deletions(-) create mode 100644 frontend/src/pages/partners/PartnerEditor.tsx create mode 100644 frontend/src/pages/partners/SuggestionCard.tsx create mode 100644 frontend/src/pages/partners/index.tsx create mode 100644 frontend/src/pages/partners/styles.module.css create mode 100644 frontend/src/pages/partners/usePartnerWatch.ts diff --git a/frontend/src/api/__tests__/wordEditorAPI.test.ts b/frontend/src/api/__tests__/wordEditorAPI.test.ts index 8fdc6522..0d7229dd 100644 --- a/frontend/src/api/__tests__/wordEditorAPI.test.ts +++ b/frontend/src/api/__tests__/wordEditorAPI.test.ts @@ -4,7 +4,9 @@ import { wordDocumentLabel } from '../wordEditorAPI'; describe('wordDocumentLabel', () => { it('uses the decoded filename from the Office document URL', () => { expect( - wordDocumentLabel('https://example.sharepoint.com/docs/My%20Essay.docx'), + wordDocumentLabel( + 'https://example.sharepoint.com/docs/My%20Essay.docx', + ), ).toBe('My Essay.docx'); }); @@ -17,7 +19,10 @@ describe('wordDocumentLabel', () => { ['C:\\Users\\writer\\OneDrive\\Essay.docx', 'Essay.docx'], ['C:/Users/writer/OneDrive/Essay%20Draft.docx', 'Essay Draft.docx'], ['\\\\server\\private\\team\\Shared.docx', 'Shared.docx'], - ['https://example.test/private/folder/Final%20Draft.docx', 'Final Draft.docx'], + [ + 'https://example.test/private/folder/Final%20Draft.docx', + 'Final Draft.docx', + ], ['https://example.test/private/folder/Bad%ZZ.docx', 'Bad%ZZ.docx'], ])('never exposes directory components from %s', (input, expected) => { expect(wordDocumentLabel(input)).toBe(expected); diff --git a/frontend/src/api/handoff.ts b/frontend/src/api/handoff.ts index 55672113..d218dd94 100644 --- a/frontend/src/api/handoff.ts +++ b/frontend/src/api/handoff.ts @@ -86,8 +86,7 @@ export function openInBrowser(url: string): void { } export type BrowserLaunchReservation = - | { kind: 'office' } - | { kind: 'window'; popup: Window }; + { kind: 'office' } | { kind: 'window'; popup: Window }; function officeBrowserOpener(): ((url: string) => void) | undefined { const officeUi = ( @@ -151,7 +150,9 @@ export function completeBrowserLaunch( reservation.popup.location.replace(url); } -export function cancelBrowserLaunch(reservation: BrowserLaunchReservation): void { +export function cancelBrowserLaunch( + reservation: BrowserLaunchReservation, +): void { if (reservation.kind === 'window' && !reservation.popup.closed) { reservation.popup.close(); } diff --git a/frontend/src/api/logging.ts b/frontend/src/api/logging.ts index 0911d9a3..3f91ea63 100644 --- a/frontend/src/api/logging.ts +++ b/frontend/src/api/logging.ts @@ -48,11 +48,16 @@ import type { LogFn } from '@/hooks/useLog'; * Chat renders doctext citations too, so `page` is now what says where a * reference event came from. A reader that took those event names to mean * Revise has to read `page` instead. + * 6 — Added the Partners lab page (the proactive-thought-partners probe) and + * its events. Its events are the only ones the writer did not initiate, + * so a reader counting "requests" per session must exclude + * `trigger_fired` / `partners_activated`, which the system emits on its + * own while the writer is typing. */ -export const LOG_SCHEMA_VERSION = 5; +export const LOG_SCHEMA_VERSION = 6; /** Pages that emit events. Matches the user-facing tabs. */ -export type LogPage = 'draft' | 'revise' | 'chat' | 'tools'; +export type LogPage = 'draft' | 'revise' | 'chat' | 'tools' | 'partners'; /** * Emit one event through the page's {@link LogFn}, stamping the schema version, @@ -267,3 +272,96 @@ export const toolsLog = { return emit(log, 'tools', 'adhoc_opened', {}); }, }; + +/** + * Partners page: the proactive-thought-partners probe + * (`docs/proactive-partners-reproduction.md`). + * + * Two things make these events unlike every other page's. First, most of them + * are *system*-initiated — the writer did not ask for anything, so a reader + * measuring engagement has to compare what was offered against what was + * opened, not just count generations. Second, the interesting negative case is + * silence: `partners_activated` with `activated: 0` is the decision engine + * deciding not to interrupt, which is the outcome the paper reports as most + * common and is exactly what a reader needs to see. + */ +export const partnersLog = { + /** The writer added, edited, or removed a partner in the config panel. */ + partnerConfigured( + log: LogFn, + data: { + action: 'created' | 'updated' | 'deleted' | 'enabled' | 'disabled'; + triggers: string[]; + hasRole: boolean; + hasHeuristic: boolean; + }, + ) { + return emit(log, 'partners', 'partner_configured', data); + }, + /** The writer switched watching on or off. */ + watchToggled(log: LogFn, data: { watching: boolean; partners: number }) { + return emit(log, 'partners', 'watch_toggled', data); + }, + /** + * A trigger fired. `candidates` is how many partners listen for it — a + * trigger with none never reaches the decision engine. + */ + triggerFired( + log: LogFn, + data: { trigger: string; candidates: number; charsInWindow: number }, + ) { + return emit(log, 'partners', 'trigger_fired', data); + }, + /** The decision engine answered. `activated: 0` means it chose silence. */ + partnersActivated( + log: LogFn, + data: { + trigger: string; + candidates: number; + activated: number; + latencyMs: number; + }, + ) { + return emit(log, 'partners', 'partners_activated', data); + }, + /** A tag faded out without being opened — the paper's "Ignoring". */ + activationIgnored(log: LogFn, data: { trigger: string }) { + return emit(log, 'partners', 'activation_ignored', data); + }, + /** The writer clicked a tag, which is what asks for the suggestion. */ + activationOpened( + log: LogFn, + data: { trigger: string; ageMs: number; docContext: string }, + ) { + return emit(log, 'partners', 'activation_opened', data); + }, + /** A suggestion arrived (or failed). `response` is the partner's text. */ + suggestionGenerated( + log: LogFn, + data: { trigger: string; latencyMs: number; response: string }, + ) { + return emit(log, 'partners', 'suggestion_generated', data); + }, + /** The writer asked the partner a follow-up question. */ + followUpSent(log: LogFn, data: { message: string; turn: number }) { + return emit(log, 'partners', 'follow_up_sent', data); + }, + /** The writer dismissed an open card. */ + activationDismissed( + log: LogFn, + data: { trigger: string; opened: boolean }, + ) { + return emit(log, 'partners', 'activation_dismissed', data); + }, + /** A generation failed. `error` carries the provider text, not the UI sentence. */ + generationError( + log: LogFn, + data: { + stage: 'decision' | 'suggestion' | 'follow_up'; + error: string; + code?: string; + }, + ) { + return emit(log, 'partners', 'generation_error', data); + }, +}; diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index 240b91e7..22beae2f 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -53,7 +53,9 @@ export const wordEditorAPI: EditorAPI = { Word.run(async (context: Word.RequestContext) => { const body: Word.Body = context.document.body; const docContext: DocContext = { - documentLabel: wordDocumentLabel(Office.context.document.url), + documentLabel: wordDocumentLabel( + Office.context.document.url, + ), beforeCursor: '', selectedText: '', afterCursor: '', diff --git a/frontend/src/contexts/pageContext.tsx b/frontend/src/contexts/pageContext.tsx index 91afce0b..00030d21 100644 --- a/frontend/src/contexts/pageContext.tsx +++ b/frontend/src/contexts/pageContext.tsx @@ -7,6 +7,7 @@ export enum PageName { TagLinker = 'tag-linker', MyWords = 'my-words', Tools = 'tools', + Partners = 'partners', } export enum OverallMode { diff --git a/frontend/src/pages/__tests__/registry.test.ts b/frontend/src/pages/__tests__/registry.test.ts index 4212ba8f..6c63f5b3 100644 --- a/frontend/src/pages/__tests__/registry.test.ts +++ b/frontend/src/pages/__tests__/registry.test.ts @@ -70,9 +70,9 @@ describe('page registry', () => { expect(pagesByTier('lab').map((entry) => entry.name)).toContain( PageName.Tools, ); - expect(pagesByTier('core').map((entry) => entry.name)).not.toContain( - PageName.Tools, - ); + expect( + pagesByTier('core').map((entry) => entry.name), + ).not.toContain(PageName.Tools); }); }); diff --git a/frontend/src/pages/partners/PartnerEditor.tsx b/frontend/src/pages/partners/PartnerEditor.tsx new file mode 100644 index 00000000..d3ce6cfc --- /dev/null +++ b/frontend/src/pages/partners/PartnerEditor.tsx @@ -0,0 +1,264 @@ +/** + * The partner configuration panel (paper §4.1, Fig. 3): name + emoji, role, + * event triggers, contextual heuristic. + * + * Two details are deliberately the paper's rather than better UI: + * + * - **No trigger is selected by default.** The paper says so explicitly, "to + * avoid anchoring effects" — a pre-selected trigger would be telling the + * writer when they ought to want help. + * - **Role and heuristic are separate fields**, even though a writer could put + * both in one box. Keeping them apart is what makes the study legible: the + * role is what kind of help, the heuristic is the condition under which the + * help is worth an interruption, and the paper's findings turn on writers + * treating those as different questions. + */ +import { useState } from 'react'; +import { AiOutlineDelete, AiOutlinePlus } from 'react-icons/ai'; +import { + EVENT_TRIGGERS, + TRIGGER_HINTS, + TRIGGER_LABELS, + type EventTrigger, + type Partner, +} from './types'; +import { emptyPartner, isPartnerActivatable } from './storage'; +import classes from './styles.module.css'; + +export interface PartnerEditorProps { + partners: Partner[]; + onChange: (partners: Partner[]) => void; + onConfigured: ( + action: 'created' | 'updated' | 'deleted' | 'enabled' | 'disabled', + partner: Partner, + ) => void; +} + +export default function PartnerEditor({ + partners, + onChange, + onConfigured, +}: PartnerEditorProps): React.JSX.Element { + // Which partner is expanded for editing. A newly added one opens itself. + const [openId, setOpenId] = useState(null); + + function update(partner: Partner, patch: Partial): void { + const updated = { ...partner, ...patch }; + onChange(partners.map((p) => (p.id === partner.id ? updated : p))); + return; + } + + function add(): void { + const partner = emptyPartner(); + onChange([...partners, partner]); + setOpenId(partner.id); + onConfigured('created', partner); + } + + function remove(partner: Partner): void { + onChange(partners.filter((p) => p.id !== partner.id)); + onConfigured('deleted', partner); + } + + function toggleTrigger(partner: Partner, trigger: EventTrigger): void { + const triggers = partner.triggers.includes(trigger) + ? partner.triggers.filter((t) => t !== trigger) + : [...partner.triggers, trigger]; + update(partner, { triggers }); + } + + return ( +
+ {partners.length === 0 && ( +

+ No partners yet. A partner is a kind of help you want, plus + the moment you want it — nothing happens until you describe + both. +

+ )} + +
    + {partners.map((partner) => { + const isOpen = openId === partner.id; + const ready = isPartnerActivatable(partner); + return ( +
  • +
    + + update(partner, { + emoji: e.target.value || '💭', + }) + } + /> + + update(partner, { + name: e.target.value, + }) + } + onBlur={() => + onConfigured('updated', partner) + } + /> + + +
    + + + + {isOpen ? ( +
    +