diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index fa233c05..730b62ab 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -6,6 +6,28 @@ Tags: `[high|med|low]` rough priority · *italic trigger* = when it becomes wort --- +# Headless docx redlining (post-editor) + +Deferred from the PR #169 code-review triage (the confirmed bugs were fixed on the branch): +- `ENGINE FIRST-MATCH TARGETING` **[med]**: `applyOperationToDocumentXml` resolves the target + paragraph by FIRST base-text match — when two paragraphs share base text, an edit meant for the + second is attempted on the first. The strict post-verify refuses it (error, never corruption), but + the right fix is applying the redline to the paragraph fragment ourselves (`applyRedlineToOxml` + + splice) so the adapter's own resolution is authoritative. *Trigger: a real draft hits the + "did not land (engine mismatch)" error.* +- `TEXT-BOX CONTENT INVISIBLE` **[low]**: `w:txbxContent` (and `mc:Fallback`) are skipped in all + text walks so box text can't duplicate/corrupt — but Patrick can't read or edit box text at all + (parity with the old editor's render-only headers). *Trigger: a user needs box text edited.* +- `DANCE REGISTRY EVICTION` **[low]**: `danceFor` keeps one DraftDance + 1s unref'd timer per draft + path forever (bounded by drafts touched per session; local app). *Trigger: long-running cloud + variant.* +- `ATTORNEY-REVISION PARAGRAPHS` **[med]**: Patrick refuses to edit a paragraph carrying the + attorney's own pending tracked changes (safe, honest error). Proper multi-author support = + supersede only Patrick's runs and diff against the attorney-accepted view. *Trigger: the refusal + proves annoying in real drafting sessions.* + +--- + # DOCX editor ## Removed in refactor slice 0 — re-add as real features when wanted diff --git a/apps/api/package.json b/apps/api/package.json index 191c95ea..821dfe2f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,8 @@ "@ai-sdk/anthropic": "^4.0.0", "@ai-sdk/google": "^4.0.0", "@ai-sdk/openai": "^4.0.0", - "@eigenpal/docx-editor-agents": "workspace:*", + "@ansonlai/docx-redline-js": "github:AnsonLai/docx-redline-js#541a6cf", + "@xmldom/xmldom": "^0.9.10", "@patrick/law": "workspace:*", "@patrick/shared": "workspace:*", "ai": "^7.0.2", diff --git a/apps/api/src/lib/ai/chat.ts b/apps/api/src/lib/ai/chat.ts index 39cf3a6b..9ed23450 100644 --- a/apps/api/src/lib/ai/chat.ts +++ b/apps/api/src/lib/ai/chat.ts @@ -1,10 +1,9 @@ import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { getAiSdkTools } from "@eigenpal/docx-editor-agents/ai-sdk/server"; -import { DocxReviewer } from "@eigenpal/docx-editor-agents/server"; import { fileCachedFetcher, lookupProvisions } from "@patrick/law"; import { type ExchangeMetadata, + isEditableDoc, modelsForProvider, PATRICK_DOCS, type PinnedSource, @@ -31,9 +30,11 @@ import { readDocumentMeta, readExtractedText, } from "../documents"; +import { extractDocxText } from "../docx/redline"; import { readProfile } from "../profiles"; import { readTask } from "../tasks"; import { buildChartTools, chartManifest } from "./chart-tools"; +import { buildDraftTools } from "./draft-tools"; import { createFindLaw } from "./find-law"; import { createModel, reasoningOptions, vendorOf } from "./model"; import { type AvailableDoc, buildSystemPrompt } from "./prompt"; @@ -66,18 +67,6 @@ async function frozenTemplate( return existing?.systemTemplate ?? profile.prompts.agentpat; } -// The drafting subset Patrick gets: locate + mutate, plus reads (the active -// draft isn't in the static prompt — the agent reads it live, always current). -const TOOL_ALLOW = new Set([ - "read_document", - "read_selection", - "find_text", - "read_changes", - "read_comments", - "add_comment", - "suggest_change", -]); - type RequestBody = { messages: UIMessage[]; profileId: string; @@ -103,21 +92,32 @@ async function availableDocs( folder: string, pinned: PinnedSource[], activeDraft: string | null, -): Promise { +): Promise<{ available: AvailableDoc[]; otherDrafts: AvailableDoc[] }> { const docs = await listDocuments(folder); const pinnedNames = new Set(pinned.map((p) => p.filename)); - return docs - .filter((d) => { - const editable = - d.filename.toLowerCase().endsWith(".docx") && !!d.createdInPatrick; - return ( - !editable && - !d.excluded && - !pinnedNames.has(d.filename) && - d.filename !== activeDraft - ); - }) - .map((d) => ({ filename: d.filename, label: d.label })); + const toEntry = (d: { filename: string; label?: string }) => ({ + filename: d.filename, + label: d.label, + }); + return { + available: docs + .filter( + (d) => + !isEditableDoc(d) && + !d.excluded && + !pinnedNames.has(d.filename) && + d.filename !== activeDraft, + ) + .map(toEntry), + // Editable drafts other than the active one: the tools can't touch them, + // but the manifest must still name them or they vanish from Patrick's + // world (the attorney focuses a draft's tab to make it active). + otherDrafts: docs + .filter( + (d) => isEditableDoc(d) && !d.excluded && d.filename !== activeDraft, + ) + .map(toEntry), + }; } // HITL: no-execute tools. The model calls them to propose an action; the call @@ -188,7 +188,7 @@ const createDraft = tool({ const requestUnlock = tool({ description: - "Propose making an editable copy of an original .docx document (the attorney's originals are read-only) so you can draft amendments in it. The attorney accepts; the copy opens as the active draft. Only works on .docx files — PDFs and other formats can't be edited, so don't propose it for them; instead explain that, or use createDraft to start a fresh document.", + "Propose unlocking an original .docx for editing — the attorney's originals are read-only until they unlock one. On accept, the document itself becomes the active draft you edit as tracked changes (a pristine backup is snapshotted first; rejecting your redlines in Word restores the text exactly). Only works on .docx files — PDFs and other formats can't be edited, so don't propose it for them; instead explain that, or use createDraft to start a fresh document.", inputSchema: z.object({ filename: z .string() @@ -276,24 +276,31 @@ const epcLookup = tool({ }), }); -// Read-only docx → indexed plain text, headless from disk. The headless parse is -// the pricey bit and originals don't change, so memoise by path+mtime — a -// multi-turn chat then extracts each pinned docx once, not every turn. +// Read-only docx → plain text, headless from disk. Originals don't change, so +// memoise by path+mtime — a multi-turn chat then extracts each pinned docx +// once, not every turn. +// +// A pinned docx that was later UNLOCKED is a special case: the live file is now +// the mutable draft (Patrick's pending redlines would read as accepted text, +// and every edit would churn the cached prefix). The pinned source stays what +// the attorney actually pinned — the pristine snapshot under .patrick/backups. const docxTextCache = new Map(); -async function docxText(folder: string, filename: string): Promise { - const path = join(folder, filename); +async function docxText( + folder: string, + filename: string, + pristine = false, +): Promise { + const backup = join(folder, ".patrick", "backups", filename); + const path = + pristine && (await stat(backup).catch(() => null)) + ? backup + : join(folder, filename); try { const mtimeMs = (await stat(path)).mtimeMs; const cached = docxTextCache.get(path); if (cached && cached.mtimeMs === mtimeMs) return cached.text; - const buf = await readFile(path); - const bytes = buf.buffer.slice( - buf.byteOffset, - buf.byteOffset + buf.byteLength, - ) as ArrayBuffer; - const reviewer = await DocxReviewer.fromBuffer(bytes, "Patrick"); - const text = reviewer.getContentAsText().trim(); + const text = await extractDocxText(new Uint8Array(await readFile(path))); docxTextCache.set(path, { mtimeMs, text }); return text; } catch { @@ -391,7 +398,11 @@ export async function pinnedSourcesMessage( return []; } } - const text = await docxText(folder, src.filename); + const text = await docxText( + folder, + src.filename, + !!meta[src.filename]?.unlocked, + ); console.log( `[context] ${src.filename} → docx text, ${text.length} chars`, ); @@ -440,12 +451,14 @@ export async function pinnedWithRequiredPrimary( } as ModelMessage; } -// The tools Patrick gets for a turn: editor tools (no execute — they run against -// the live editor client-side) + the law/search/HITL tools. Web search and the -// open-a-file proposal are conditional. Built in one place so the inspection -// panel can list the exact active tool names without drifting from what ships. +// The tools Patrick gets for a turn: the draft tools (server-executed against +// the active draft on disk, through the dance) + the law/search/HITL tools. +// Web search and the open-a-file proposal are conditional. Built in one place +// so the inspection panel can list the exact active tool names without +// drifting from what ships. function buildChatTools(opts: { folder: string; + activeDraft: string | null; profile: Profile; provider: Provider; apiKey: string; @@ -454,9 +467,7 @@ function buildChatTools(opts: { hasDocs: boolean; }) { return { - ...Object.fromEntries( - Object.entries(getAiSdkTools()).filter(([name]) => TOOL_ALLOW.has(name)), - ), + ...buildDraftTools(opts.folder, opts.activeDraft), suggestLabel, suggestBrief, suggestPrompt, @@ -498,7 +509,7 @@ export async function handleChat(c: Context) { const pinnedSources = body.pinnedSources ?? []; const activeDraft = body.activeDraft ?? null; - const available = await availableDocs( + const { available, otherDrafts } = await availableDocs( task.folder, pinnedSources, activeDraft, @@ -519,12 +530,14 @@ export async function handleChat(c: Context) { available, charts, template, + otherDrafts, ); // Adding/removing a capability? Do it in buildChatTools — and update // PATRICK_CAPABILITIES in @patrick/shared so the prompt describes Patrick honestly. const tools = buildChatTools({ folder: task.folder, + activeDraft, profile, provider, apiKey, diff --git a/apps/api/src/lib/ai/draft-tools.ts b/apps/api/src/lib/ai/draft-tools.ts new file mode 100644 index 00000000..5e8f9d2c --- /dev/null +++ b/apps/api/src/lib/ai/draft-tools.ts @@ -0,0 +1,143 @@ +import { readFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { + ADD_DRAFT_COMMENT_TOOL, + type DRAFT_TOOL_NAMES, + EDIT_PARAGRAPH_TOOL, + READ_DRAFT_COMMENTS_TOOL, + READ_DRAFT_TOOL, +} from "@patrick/shared"; +import { tool } from "ai"; +import { z } from "zod"; +import { danceFor } from "../docx/dance"; +import { listComments, readDraftParagraphs } from "../docx/redline"; + +// The draft-editing tools: server-executed against the active draft on disk +// (headless tracked changes — the attorney reviews them in Word). Writes go +// through the dance: applied immediately when the draft is closed, parked and +// applied on close when it's open in Word/LibreOffice. One paragraph per edit. + +const noDraft = { + error: + "No active draft. Propose one first: createDraft (a fresh document) or requestUnlock (an editable copy of an original).", +}; + +/** Bind the draft tools to the task folder + this turn's active draft. */ +export function buildDraftTools(folder: string, activeDraft: string | null) { + // The agent only ever sees basenames; never let a path reach outside the folder. + const draft = activeDraft ? basename(activeDraft) : null; + + const draftBytes = async (): Promise => { + if (!draft) return null; + try { + return new Uint8Array(await readFile(join(folder, draft))); + } catch { + return null; + } + }; + + const readDraft = tool({ + description: + "Read the active draft's current text, paragraph by paragraph — [n] is the paragraph number used by add_draft_comment, and the text shown is what you quote as edit_paragraph's target_text. Pending tracked changes read as if accepted, and (r) marks a paragraph that has pending revisions. The draft lives on disk and the attorney edits it in Word too, so always read fresh before editing rather than relying on earlier reads.", + inputSchema: z.object({}), + execute: async () => { + const bytes = await draftBytes(); + if (!draft || !bytes) return noDraft; + const paragraphs = await readDraftParagraphs(bytes); + const status = await danceFor(folder, draft).status(); + const body = paragraphs + .filter((p) => p.text.trim()) + .map((p) => `[${p.index}]${p.hasRevisions ? " (r)" : ""} ${p.text}`) + .join("\n"); + const note = status.openInEditor + ? "\n\n(The attorney has the draft open in Word right now — your edits will be parked and applied when they close it. Unsaved changes of theirs aren't visible to you yet.)" + : ""; + return `# ${draft}\n${body || "(the draft is empty)"}${note}`; + }, + }); + + const editParagraph = tool({ + description: + "Rewrite ONE paragraph of the active draft as a native tracked change (redline) the attorney reviews in Word. Quote the paragraph's current text from read_draft as target_text (enough of it to be unique — the full paragraph is safest) and give the complete revised paragraph as new_text. One paragraph per call — for a multi-paragraph revision, make one call per paragraph. Re-editing a paragraph that already has your pending redline replaces that redline (the attorney always reviews original → your latest). If the draft is open in Word the edit parks and lands when the attorney closes it.", + inputSchema: z.object({ + target_text: z + .string() + .min(1) + .describe( + "The paragraph's CURRENT text as shown by read_draft — verbatim, and unique to that paragraph", + ), + new_text: z + .string() + .describe("The complete revised text of the paragraph"), + }), + execute: async ({ target_text, new_text }) => { + if (!draft) return noDraft; + const outcome = await danceFor(folder, draft).applyOrPark({ + kind: "redline", + edit: { targetText: target_text, newText: new_text }, + }); + if (outcome.status === "failed") return { error: outcome.reason }; + if (outcome.status === "parked") + return { + parked: true, + note: `The draft is open in Word — this redline (and ${outcome.parkedEdits - 1} other parked edit(s)) will be applied when the attorney closes it. Don't retry.`, + }; + return { applied: true }; + }, + }); + + const addDraftComment = tool({ + description: + "Add a Word comment to the active draft, anchored to text in one paragraph — for margin notes the attorney reads in Word: flagging a risk, explaining an edit, or replying to their @Patrick comment. Not for edits (use edit_paragraph). Anchor to text you are NOT about to redline (a redline fragments the anchor); when commenting and editing the same paragraph, add the comment first.", + inputSchema: z.object({ + paragraph: z + .number() + .int() + .min(1) + .describe("The paragraph number [n] from read_draft"), + anchor_text: z + .string() + .min(1) + .describe( + "Verbatim text within that paragraph to attach the comment to", + ), + text: z.string().min(1).describe("The comment"), + }), + execute: async ({ paragraph, anchor_text, text }) => { + if (!draft) return noDraft; + const outcome = await danceFor(folder, draft).applyOrPark({ + kind: "comment", + request: { paragraphIndex: paragraph, textToFind: anchor_text, text }, + }); + if (outcome.status === "failed") return { error: outcome.reason }; + if (outcome.status === "parked") + return { + parked: true, + note: "The draft is open in Word — the comment will be added when the attorney closes it. Don't retry.", + }; + return { applied: true }; + }, + }); + + const readDraftComments = tool({ + description: + "Read all comments in the active draft (author + text) — including the attorney's @Patrick comments, which are instructions addressed to you.", + inputSchema: z.object({}), + execute: async () => { + const bytes = await draftBytes(); + if (!draft || !bytes) return noDraft; + const comments = await listComments(bytes); + if (comments.length === 0) return { comments: [] }; + return { + comments: comments.map((c) => ({ author: c.author, text: c.text })), + }; + }, + }); + + return { + [READ_DRAFT_TOOL]: readDraft, + [EDIT_PARAGRAPH_TOOL]: editParagraph, + [ADD_DRAFT_COMMENT_TOOL]: addDraftComment, + [READ_DRAFT_COMMENTS_TOOL]: readDraftComments, + } satisfies Record<(typeof DRAFT_TOOL_NAMES)[number], unknown>; +} diff --git a/apps/api/src/lib/ai/prompt.test.ts b/apps/api/src/lib/ai/prompt.test.ts index 5673ba2e..75b58308 100644 --- a/apps/api/src/lib/ai/prompt.test.ts +++ b/apps/api/src/lib/ai/prompt.test.ts @@ -24,6 +24,7 @@ function build( pinned?: PinnedSource[]; draft?: string | null; available?: AvailableDoc[]; + otherDrafts?: AvailableDoc[]; charts?: string; middle?: string; } = {}, @@ -35,6 +36,7 @@ function build( opts.available ?? [], opts.charts ?? "", opts.middle ?? "", + opts.otherDrafts ?? [], ); } @@ -84,10 +86,20 @@ describe("buildSystemPrompt — manifest (lists, never content)", () => { test("names the active draft and routes edits through the tools; else says none", () => { const withDraft = build({ draft: "Response (Patrick).docx" }); expect(withDraft).toContain("Active draft: Response (Patrick).docx"); - expect(withDraft).toContain("suggest_change"); + expect(withDraft).toContain("edit_paragraph"); expect(build({ draft: null })).toContain("No editable draft is open."); }); + test("names other editable drafts (they must not vanish from Patrick's world)", () => { + const out = build({ + draft: "Response (Patrick).docx", + otherDrafts: [{ filename: "claims.docx", label: "amended claims" }], + }); + expect(out).toContain("Other editable drafts"); + expect(out).toContain("claims.docx — amended claims"); + expect(build({ draft: "x.docx" })).not.toContain("Other editable drafts"); + }); + test("folder-awareness lists available docs with labels + the requestOpenFile gate", () => { const out = build({ available: [ diff --git a/apps/api/src/lib/ai/prompt.ts b/apps/api/src/lib/ai/prompt.ts index f7db84d5..9a02fe28 100644 --- a/apps/api/src/lib/ai/prompt.ts +++ b/apps/api/src/lib/ai/prompt.ts @@ -21,13 +21,14 @@ function taskBlock(task: Task): string { // The system prompt holds INSTRUCTIONS + a MANIFEST only — never document // content. Read-only sources ride as cached messages (see chat.ts); the editable -// draft is read live through the editor tools. This keeps the system prefix +// draft is read live through the draft tools. This keeps the system prefix // stable and cacheable for the whole chat. function manifest( pinned: PinnedSource[], activeDraft: string | null, available: AvailableDoc[], + otherDrafts: AvailableDoc[], charts: string, ): string { const lines: string[] = []; @@ -42,13 +43,23 @@ function manifest( lines.push(""); if (activeDraft) { lines.push( - `Active draft: ${activeDraft} — the document you edit. Use the document tools ` + - "(read_document / find_text / suggest_change) to read its current state and make " + - "tracked changes. Don't reproduce it in chat; work on it through the tools.", + `Active draft: ${activeDraft} — the document you edit. Use the draft tools ` + + "(read_draft / edit_paragraph / add_draft_comment) to read its current state and make " + + "tracked changes the attorney reviews in Word. Don't reproduce it in chat; work on it through the tools.", ); } else { lines.push("No editable draft is open."); } + // Other editable drafts exist but the tools bind to ONE active draft — name + // them (never content) so they don't vanish from Patrick's world entirely. + if (otherDrafts.length > 0) { + lines.push(""); + lines.push( + "Other editable drafts in this matter (you can only read/edit the active draft — to work on one of these, ask the attorney to focus its tab, which makes it the active draft):", + ); + for (const d of otherDrafts) + lines.push(`- ${d.filename}${d.label ? ` — ${d.label}` : ""}`); + } // Folder awareness: other documents the attorney has, by filename + their // label — never content. Patrick can propose pulling one in via requestOpenFile // (the attorney accepts); it can't read them until then. @@ -83,10 +94,11 @@ export function buildSystemPrompt( available: AvailableDoc[], charts: string, middle: string, + otherDrafts: AvailableDoc[] = [], ): string { return assembleSystemPrompt( middle.trim(), taskBlock(task), - manifest(pinned, activeDraft, available, charts), + manifest(pinned, activeDraft, available, otherDrafts, charts), ); } diff --git a/apps/api/src/lib/documents.test.ts b/apps/api/src/lib/documents.test.ts new file mode 100644 index 00000000..fe23cdb7 --- /dev/null +++ b/apps/api/src/lib/documents.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readDocumentMeta, unlockDocumentInPlace } from "./documents"; + +let folder: string; + +beforeEach(async () => { + folder = await mkdtemp(join(tmpdir(), "docs-")); + await writeFile(join(folder, "filing.docx"), "original bytes"); + await writeFile(join(folder, "art.pdf"), "%PDF"); +}); + +afterEach(async () => { + await rm(folder, { recursive: true, force: true }); +}); + +describe("unlockDocumentInPlace", () => { + test("snapshots the pristine bytes and marks the doc unlocked", async () => { + const name = await unlockDocumentInPlace(folder, "filing.docx"); + expect(name).toBe("filing.docx"); + expect( + await readFile( + join(folder, ".patrick", "backups", "filing.docx"), + "utf8", + ), + ).toBe("original bytes"); + expect((await readDocumentMeta(folder))["filing.docx"]?.unlocked).toBe( + true, + ); + }); + + test("a second unlock never overwrites the pristine backup", async () => { + await unlockDocumentInPlace(folder, "filing.docx"); + // Patrick edits the file, then it gets unlocked again (e.g. after a meta reset). + await writeFile(join(folder, "filing.docx"), "edited bytes"); + await unlockDocumentInPlace(folder, "filing.docx"); + expect( + await readFile( + join(folder, ".patrick", "backups", "filing.docx"), + "utf8", + ), + ).toBe("original bytes"); + }); + + test("refuses non-docx and missing files", async () => { + expect(await unlockDocumentInPlace(folder, "art.pdf")).toBeNull(); + expect(await unlockDocumentInPlace(folder, "ghost.docx")).toBeNull(); + }); +}); diff --git a/apps/api/src/lib/documents.ts b/apps/api/src/lib/documents.ts index 59acb33d..f64fa862 100644 --- a/apps/api/src/lib/documents.ts +++ b/apps/api/src/lib/documents.ts @@ -15,7 +15,6 @@ import type { SearchIndex, } from "@patrick/shared"; import { parse, stringify } from "yaml"; -import { ensureParaIds } from "./docx"; // What counts as a "document" in the attorney's folder. Plain-text (.md/.txt) // covers Patrick-retrieved references like prior art fetched from EPO OPS. @@ -76,12 +75,6 @@ export async function mergeDocumentMeta( await writeDocumentMeta(folder, meta); } -/** "report.docx" → "report (Patrick).docx". */ -function patrickCopyName(original: string): string { - const ext = extname(original); - return `${original.slice(0, original.length - ext.length)} (Patrick)${ext}`; -} - /** Pick a sibling name that doesn't collide: "x.docx" → "x 2.docx" → … */ async function uniqueName(folder: string, candidate: string): Promise { const ext = extname(candidate); @@ -108,11 +101,7 @@ export async function createBlankDocument( candidate?: string, ): Promise { const name = await uniqueName(folder, asDocxName(candidate)); - // Backfill paragraph ids so the agent's anchor-by-id tools work from the start. - await writeFile( - join(folder, name), - await ensureParaIds(await readFile(BLANK_DOCX)), - ); + await writeFile(join(folder, name), await readFile(BLANK_DOCX)); await mergeDocumentMeta(folder, name, { createdInPatrick: true }); return name; } @@ -151,51 +140,33 @@ export async function saveRetrievedDocument( } /** - * Unlock an original for editing: copy its bytes to a visible "(Patrick)" sibling - * in the same folder, carrying its label. The original is never touched. - * Returns the new filename, or null if the source is missing or not a .docx — - * only Word documents are editable (editable ≡ createdInPatrick && .docx), so an - * "editable copy" of a PDF would be an owned-but-uneditable dead end. + * Unlock an original .docx for IN-PLACE tracked-changes editing. The file itself + * becomes the draft — no "(Patrick) copy" — because every Patrick edit is a + * surgical tracked change (reject-all restores the text exactly). Safety net: + * the pristine bytes are snapshotted once to .patrick/backups/ before + * Patrick can ever write. Returns the filename, or null if the file is missing + * or not a .docx (only Word documents are editable). */ -export async function unlockDocumentCopy( +export async function unlockDocumentInPlace( folder: string, - source: string, + filename: string, ): Promise { - if (!source.toLowerCase().endsWith(".docx")) return null; - if (!(await fileExists(folder, source))) return null; - const dest = await uniqueName(folder, patrickCopyName(source)); - // Backfill paragraph ids: real filings often have none, which breaks the - // agent's anchor-by-id comment/suggest tools on the editable copy. - await writeFile( - join(folder, dest), - await ensureParaIds(await readFile(join(folder, source))), - ); - const meta = await readDocumentMeta(folder); - await mergeDocumentMeta(folder, dest, { - createdInPatrick: true, - label: meta[source]?.label, - }); - return dest; + if (!filename.toLowerCase().endsWith(".docx")) return null; + if (!(await fileExists(folder, filename))) return null; + const backupDir = join(folder, ".patrick", "backups"); + const backup = join(backupDir, filename); + try { + await stat(backup); // an earlier unlock already snapshotted the original + } catch { + await mkdir(backupDir, { recursive: true }); + await writeFile(backup, await readFile(join(folder, filename))); + } + await mergeDocumentMeta(folder, filename, { unlocked: true }); + return filename; } export type SaveResult = "ok" | "not-found" | "forbidden"; -/** - * Overwrite a document's bytes. Guards the attorney's originals: only files - * Patrick created (createdInPatrick) may be written — never an original. - */ -export async function saveDocumentBytes( - folder: string, - filename: string, - bytes: ArrayBuffer, -): Promise { - if (!(await fileExists(folder, filename))) return "not-found"; - const meta = await readDocumentMeta(folder); - if (!meta[filename]?.createdInPatrick) return "forbidden"; - await writeFile(join(folder, filename), Buffer.from(bytes)); - return "ok"; -} - /** Delete a Patrick-owned document (file + its meta entry). Originals refused. */ export async function deleteDocument( folder: string, @@ -252,6 +223,7 @@ export async function listDocuments(folder: string): Promise { excluded: m.excluded, starred: m.starred, createdInPatrick: m.createdInPatrick, + unlocked: m.unlocked, retrieved: m.retrieved, source: m.source, // Derived from the sidecar's existence, not stored in the writable meta. diff --git a/apps/api/src/lib/docx.ts b/apps/api/src/lib/docx.ts deleted file mode 100644 index a58c8e4f..00000000 --- a/apps/api/src/lib/docx.ts +++ /dev/null @@ -1,50 +0,0 @@ -import JSZip from "jszip"; - -const W14_NS = "http://schemas.microsoft.com/office/word/2010/wordml"; - -// 8 hex digits, uppercase — the shape Word uses for w14:paraId / w14:textId. -function newParaId(): string { - return Math.floor(Math.random() * 0xffffffff) - .toString(16) - .toUpperCase() - .padStart(8, "0"); -} - -/** - * Give every paragraph in a .docx a stable Word paragraph id (`w14:paraId`). - * - * The editor only generates ids for paragraphs you *create* while editing — it - * never backfills paragraphs loaded from a .docx that already lacks them. The - * agent's anchor-by-id tools (`add_comment` / `suggest_change`) then have no - * stable handle: `read_document` falls back to ordinal `[0],[1]…` indices and - * the edits fail. Many real-world filings have no `w14:paraId`, so we backfill - * when minting an editable copy. Idempotent — existing ids are left untouched. - */ -export async function ensureParaIds(bytes: Uint8Array): Promise { - const zip = await JSZip.loadAsync(bytes); - const entry = zip.file("word/document.xml"); - if (!entry) return bytes; // not a shape we understand — leave it be - let xml = await entry.async("string"); - - if (!/xmlns:w14=/.test(xml)) { - xml = xml.replace(/` or - // `/` — the lookahead keeps us off ``, ``, etc. - let added = 0; - xml = xml.replace( - /])([^>]*?)(\/?)>/g, - (full, attrs: string, selfClose: string) => { - if (/\bw14:paraId=/.test(attrs)) return full; - added++; - const id = newParaId(); - return ``; - }, - ); - if (added === 0) return bytes; // already fully id'd - - zip.file("word/document.xml", xml); - // DEFLATE to match how Word writes .docx — STORE would bloat the file ~5x. - return zip.generateAsync({ type: "uint8array", compression: "DEFLATE" }); -} diff --git a/apps/api/src/lib/docx/dance.test.ts b/apps/api/src/lib/docx/dance.test.ts new file mode 100644 index 00000000..c5d979f4 --- /dev/null +++ b/apps/api/src/lib/docx/dance.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DraftDance } from "./dance"; +import { extractDocxText, listComments } from "./redline"; + +const FIXTURE = join( + process.cwd(), + "e2e", + "fixtures", + "uspto-office-action.docx", +); +const DRAFT = "Response (Patrick).docx"; + +const TARGET = + "This communication is a First Office Action Non-Final Rejection on the merits."; +const MODIFIED = `${TARGET.slice(0, -1)}, responsive to the amendment.`; + +let folder: string; +let dance: DraftDance; + +const draftBytes = async () => + new Uint8Array(await Bun.file(join(folder, DRAFT)).arrayBuffer()); +const lockPath = () => join(folder, `.~lock.${DRAFT}#`); + +beforeEach(async () => { + folder = await mkdtemp(join(tmpdir(), "dance-")); + await writeFile(join(folder, DRAFT), await Bun.file(FIXTURE).bytes()); + dance = new DraftDance(folder, DRAFT); +}); + +afterEach(async () => { + await rm(folder, { recursive: true, force: true }); +}); + +describe("lock detection", () => { + test("sees LibreOffice and Word markers, and their absence", async () => { + expect(await dance.isLocked()).toBe(false); + await writeFile(lockPath(), "lo"); + expect(await dance.isLocked()).toBe(true); + await unlink(lockPath()); + await writeFile(join(folder, `~$${DRAFT.slice(2)}`), "word"); + expect(await dance.isLocked()).toBe(true); + }); +}); + +describe("applyOrPark", () => { + test("applies immediately when the draft is closed", async () => { + const outcome = await dance.applyOrPark({ + kind: "redline", + edit: { targetText: TARGET, newText: MODIFIED }, + }); + expect(outcome.status).toBe("applied"); + expect(await extractDocxText(await draftBytes())).toContain( + "responsive to the amendment", + ); + }); + + test("parks while locked, applies on tick after unlock", async () => { + await writeFile(lockPath(), "lo"); + const outcome = await dance.applyOrPark({ + kind: "redline", + edit: { targetText: TARGET, newText: MODIFIED }, + }); + expect(outcome.status).toBe("parked"); + expect((await dance.status()).parkedEdits).toBe(1); + + await dance.tick(); // still locked — nothing happens + expect((await dance.status()).parkedEdits).toBe(1); + + await unlink(lockPath()); + await dance.tick(); + const status = await dance.status(); + expect(status.parkedEdits).toBe(0); + expect(status.failures).toEqual([]); + expect(await extractDocxText(await draftBytes())).toContain( + "responsive to the amendment", + ); + }); + + test("drains comments before redlines so anchors still resolve", async () => { + await writeFile(lockPath(), "lo"); + // Parked in the WRONG order: the redline would fragment the anchor text. + await dance.applyOrPark({ + kind: "redline", + edit: { targetText: TARGET, newText: MODIFIED }, + }); + await dance.applyOrPark({ + kind: "comment", + request: { + paragraphIndex: 9, + textToFind: "Non-Final Rejection on the merits", + text: "@Michael: deadline check.", + }, + }); + await unlink(lockPath()); + await dance.tick(); + + const status = await dance.status(); + expect(status.parkedEdits).toBe(0); + expect(status.failures).toEqual([]); + const comments = await listComments(await draftBytes()); + expect(comments.some((c) => c.text.includes("deadline check"))).toBe(true); + }); + + test("parallel ops all land (the model fires tool calls concurrently)", async () => { + // Regression: 12 parallel comments once collapsed to 3 — each op read the + // same base bytes and the last write won. The per-draft queue serializes. + const before = (await import("./redline")).listComments; + const baseline = (await before(await draftBytes())).length; + const outcomes = await Promise.all( + Array.from({ length: 12 }, (_, i) => + dance.applyOrPark({ + kind: "comment", + request: { + paragraphIndex: 9, + textToFind: "Non-Final Rejection", + text: `@Michael: parallel comment ${i + 1}.`, + }, + }), + ), + ); + expect(outcomes.every((o) => o.status === "applied")).toBe(true); + const comments = await (await import("./redline")).listComments( + await draftBytes(), + ); + expect(comments.length).toBe(baseline + 12); + }); + + test("a new op never jumps ahead of older parked ops (stale-supersede)", async () => { + // Edit A parks while locked. After unlock, edit B (which depends on A's + // text) arrives BEFORE any tick — the queue must drain A first, or A would + // later supersede B. + await writeFile(lockPath(), "lo"); + await dance.applyOrPark({ + kind: "redline", + edit: { targetText: TARGET, newText: MODIFIED }, + }); + await unlink(lockPath()); + const outcome = await dance.applyOrPark({ + kind: "redline", + edit: { + targetText: MODIFIED, + newText: `${MODIFIED} And a follow-up sentence.`, + }, + }); + expect(outcome.status).toBe("applied"); + const text = await extractDocxText(await draftBytes()); + expect(text).toContain("responsive to the amendment. And a follow-up"); + }); + + test("parked ops survive a restart (persisted under .patrick/parked)", async () => { + await writeFile(lockPath(), "lo"); + await dance.applyOrPark({ + kind: "redline", + edit: { targetText: TARGET, newText: MODIFIED }, + }); + // A fresh instance (new process) must see and apply the parked edit. + const revived = new DraftDance(folder, DRAFT); + expect((await revived.status()).parkedEdits).toBe(1); + await unlink(lockPath()); + await revived.tick(); + expect((await revived.status()).parkedEdits).toBe(0); + expect(await extractDocxText(await draftBytes())).toContain( + "responsive to the amendment", + ); + }); + + test("records a failure when a parked edit no longer applies", async () => { + await writeFile(lockPath(), "lo"); + await dance.applyOrPark({ + kind: "redline", + edit: { targetText: "text nowhere in the draft", newText: "x" }, + }); + await unlink(lockPath()); + await dance.tick(); + const status = await dance.status(); + expect(status.parkedEdits).toBe(0); + expect(status.failures.length).toBe(1); + dance.clearFailures(); + expect((await dance.status()).failures).toEqual([]); + }); +}); + +describe("status", () => { + test("reports existence, mtime, and @Patrick mentions", async () => { + const status = await dance.status(); + expect(status.exists).toBe(true); + expect(status.openInEditor).toBe(false); + expect(status.lastSavedMs).toBeGreaterThan(0); + // The fixture ships with real @Patrick comments (added in Word) — they + // must all surface, and a newly added one must join them. + const baseline = status.mentions.length; + expect(baseline).toBeGreaterThan(0); + + const { addComment } = await import("./redline"); + const withMention = await addComment( + await draftBytes(), + { + paragraphIndex: 9, + textToFind: "Non-Final Rejection", + text: "@Patrick shorten this paragraph.", + }, + "Michael", + ); + expect(withMention.applied).toBe(true); + if (!withMention.applied) return; + await writeFile(join(folder, DRAFT), withMention.bytes); + const after = await dance.status(); + expect(after.mentions.length).toBe(baseline + 1); + expect( + after.mentions.some((m) => m.text.includes("shorten this paragraph")), + ).toBe(true); + }); + + test("reports a missing draft", async () => { + const missing = new DraftDance(folder, "nope.docx"); + const status = await missing.status(); + expect(status.exists).toBe(false); + expect(status.lastSavedMs).toBeNull(); + }); +}); diff --git a/apps/api/src/lib/docx/dance.ts b/apps/api/src/lib/docx/dance.ts new file mode 100644 index 00000000..8a744e5a --- /dev/null +++ b/apps/api/src/lib/docx/dance.ts @@ -0,0 +1,269 @@ +import { + mkdir, + readdir, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { DraftComment, DraftStatus } from "@patrick/shared"; +import { + addComment, + applyRedline, + type CommentRequest, + listComments, + REDLINE_AUTHOR, + type RedlineEdit, +} from "./redline"; + +// THE DANCE: Word/LibreOffice holds a lock on an open draft, so Patrick and the +// attorney share it by protocol, not by force: +// - Reads never block — the last-saved bytes are always readable. +// - Writes happen only in the write window (no lock marker). While the draft is +// open, finished edits PARK and land the moment it's closed (the tick). +// Parked ops are persisted under .patrick/parked/ — the tools already told +// the attorney the edit will land, so an app restart must not lose it. +// - Never write in place while open: Word won't reload an externally-changed +// file, and its next save would clobber the write. +// "Save = talk to Patrick. Close = let Patrick write." + +export type DraftOp = + | { kind: "redline"; edit: RedlineEdit } + | { kind: "comment"; request: CommentRequest }; + +export type OpOutcome = + | { status: "applied" } + | { status: "parked"; parkedEdits: number } + | { status: "failed"; reason: string }; + +function describeOp(op: DraftOp): string { + return op.kind === "redline" + ? `edit "${op.edit.targetText.slice(0, 60)}…"` + : `comment "${op.request.text.slice(0, 60)}…"`; +} + +export class DraftDance { + private parked: DraftOp[] = []; + private failures: string[] = []; + private loaded = false; + // Mentions are re-derived only when the file changes — status() is polled + // every 2s per open tab and a full unzip+parse per poll is waste. + private mentionsCache: { mtimeMs: number; mentions: DraftComment[] } | null = + null; + + constructor( + readonly folder: string, + readonly filename: string, + private author = REDLINE_AUTHOR, + ) {} + + private get path(): string { + return join(this.folder, this.filename); + } + + private get parkedPath(): string { + return join(this.folder, ".patrick", "parked", `${this.filename}.json`); + } + + // Parked ops survive an app/api restart: the queue is mirrored to disk on + // every change and lazily loaded before the first operation that needs it. + private async ensureLoaded(): Promise { + if (this.loaded) return; + this.loaded = true; + try { + const raw = await readFile(this.parkedPath, "utf8"); + const ops = JSON.parse(raw) as DraftOp[]; + if (Array.isArray(ops)) this.parked.push(...ops); + } catch { + // no sidecar — nothing parked from a previous session + } + } + + private async persistParked(): Promise { + try { + if (this.parked.length === 0) { + await rm(this.parkedPath, { force: true }); + return; + } + await mkdir(dirname(this.parkedPath), { recursive: true }); + await writeFile(this.parkedPath, JSON.stringify(this.parked), "utf8"); + } catch (err) { + console.error("[dance] failed to persist parked ops", err); + } + } + + private async park(op: DraftOp): Promise { + this.parked.push(op); + await this.persistParked(); + return { status: "parked", parkedEdits: this.parked.length }; + } + + /** A Word (`~$…`) or LibreOffice (`.~lock.…#`) lock marker is present. */ + async isLocked(): Promise { + // LibreOffice wraps the full name. Word's owner file replaces the leading + // characters with ~$: two for names ≥8 chars before the extension, one for + // 7-char names, none for shorter ones — probe all three tiers. + const markers = new Set([ + `.~lock.${this.filename}#`, + `~$${this.filename}`, + `~$${this.filename.slice(1)}`, + `~$${this.filename.slice(2)}`, + ]); + try { + return (await readdir(this.folder)).some((f) => markers.has(f)); + } catch { + return false; + } + } + + async status(): Promise { + await this.enqueue(() => this.ensureLoaded()); + let lastSavedMs: number | null = null; + let exists = true; + let mentions: DraftComment[] = []; + try { + lastSavedMs = (await stat(this.path)).mtimeMs; + } catch { + exists = false; + } + if (exists && lastSavedMs != null) { + if (this.mentionsCache?.mtimeMs === lastSavedMs) { + mentions = this.mentionsCache.mentions; + } else { + try { + const comments = await listComments( + new Uint8Array(await readFile(this.path)), + ); + mentions = comments.filter( + (c) => c.author !== this.author && /@patrick/i.test(c.text), + ); + this.mentionsCache = { mtimeMs: lastSavedMs, mentions }; + } catch { + // unreadable mid-save — mentions just come back empty this tick + } + } + } + return { + exists, + openInEditor: await this.isLocked(), + parkedEdits: this.parked.length, + lastSavedMs, + mentions, + failures: [...this.failures], + }; + } + + // Every mutation is read-file → transform → write-file, so concurrent ops + // (the model fires tool calls in parallel) would read the same base bytes and + // the last write would silently swallow the others — measured in the wild: + // 3 of 12 parallel comments survived. ALL mutations serialize through this + // per-draft chain; reads (status content) stay lock-free. + private chain: Promise = Promise.resolve(); + + private enqueue(fn: () => Promise): Promise { + const next = this.chain.then(fn, fn); + this.chain = next.catch(() => {}); + return next; + } + + /** Apply the op now if the draft is closed; park it for the tick if not. */ + applyOrPark(op: DraftOp): Promise { + return this.enqueue(async () => { + await this.ensureLoaded(); + if (await this.isLocked()) return this.park(op); + // Older parked ops go first — applying the new op ahead of them would + // let a stale parked edit supersede it when the tick drains. + await this.drain(); + if (await this.isLocked()) return this.park(op); + return this.applyNow(op); + }); + } + + private async applyNow(op: DraftOp): Promise { + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await readFile(this.path)); + } catch { + return { status: "failed", reason: `cannot read ${this.filename}` }; + } + let result: Awaited>; + try { + result = + op.kind === "redline" + ? await applyRedline(bytes, op.edit, this.author) + : await addComment(bytes, op.request, this.author); + } catch (err) { + return { + status: "failed", + reason: err instanceof Error ? err.message : "the edit engine failed", + }; + } + if (!result.applied) return { status: "failed", reason: result.reason }; + // The attorney may have opened the draft while we computed — park rather + // than write under a fresh lock (the tick recomputes against their saves). + if (await this.isLocked()) return this.park(op); + // Write atomically: a crash mid-write must never leave a truncated .docx. + const tmp = `${this.path}.patrick-tmp`; + await writeFile(tmp, result.bytes); + await rename(tmp, this.path); + return { status: "applied" }; + } + + /** + * Advance the machine: when the draft is closed, drain parked ops — comments + * first (they anchor to visible text a redline would fragment), otherwise in + * arrival order. A failure (returned OR thrown) is recorded and surfaced via + * status; a re-lock mid-drain re-parks the remainder. + */ + tick(): Promise { + // Fast path: once loaded, an empty queue needs no chain slot every second. + if (this.loaded && this.parked.length === 0) return Promise.resolve(); + return this.enqueue(async () => { + await this.ensureLoaded(); + await this.drain(); + }); + } + + private async drain(): Promise { + if (this.parked.length === 0 || (await this.isLocked())) return; + const ops = this.parked.splice(0); + ops.sort((a, b) => (a.kind === b.kind ? 0 : a.kind === "comment" ? -1 : 1)); + while (ops.length > 0) { + const op = ops.shift() as DraftOp; + const outcome = await this.applyNow(op); + if (outcome.status === "failed") + this.failures.push(`${describeOp(op)} — ${outcome.reason}`); + if (outcome.status === "parked") { + // applyNow re-parked (and persisted) `op`; keep the rest behind it. + this.parked.push(...ops); + break; + } + } + await this.persistParked(); + } + + /** Clear surfaced failures once the UI has shown them. */ + clearFailures(): void { + this.failures = []; + } +} + +// One dance per draft file, created on first touch. Each runs a 1s tick so +// parked edits land the moment the attorney closes the draft — unref'd, so an +// idle process (or a test run) can still exit. +const dances = new Map(); + +export function danceFor(folder: string, filename: string): DraftDance { + const key = join(folder, filename); + const existing = dances.get(key); + if (existing) return existing; + const dance = new DraftDance(folder, filename); + dances.set(key, dance); + const timer = setInterval(() => { + dance.tick().catch((err) => console.error("[dance]", key, err)); + }, 1000); + timer.unref?.(); + return dance; +} diff --git a/apps/api/src/lib/docx/redline-js.d.ts b/apps/api/src/lib/docx/redline-js.d.ts new file mode 100644 index 00000000..b2ed2d93 --- /dev/null +++ b/apps/api/src/lib/docx/redline-js.d.ts @@ -0,0 +1,25 @@ +// Deep imports the package doesn't ship types for. +declare module "@ansonlai/docx-redline-js/core/types.js" { + /** Bumps the module-global revision-id counter past every id already in the + * document — comments would otherwise collide after a process restart. */ + export function seedRevisionIdsFromDocument(xmlDoc: unknown): number; +} + +declare module "@ansonlai/docx-redline-js/services/standalone-operation-runner.js" { + export type RedlineOperation = { + type: "redline"; + target: string; + modified: string; + }; + export type OperationResult = { + documentXml: string; + hasChanges: boolean; + status: string; + numberingXml?: string | null; + }; + export function applyOperationToDocumentXml( + documentXml: string, + operation: RedlineOperation, + author?: string, + ): Promise; +} diff --git a/apps/api/src/lib/docx/redline.test.ts b/apps/api/src/lib/docx/redline.test.ts new file mode 100644 index 00000000..c1cac5e3 --- /dev/null +++ b/apps/api/src/lib/docx/redline.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + acceptTrackedChangesInOoxml, + rejectTrackedChangesInOoxml, +} from "@ansonlai/docx-redline-js"; +import JSZip from "jszip"; +import { + addComment, + applyRedline, + extractDocxText, + listComments, + readDraftParagraphs, + readDraftRuns, +} from "./redline"; + +// A real USPTO office action — styles, numbering, header, examiner comments. +const FIXTURE = join( + process.cwd(), + "e2e", + "fixtures", + "uspto-office-action.docx", +); + +const fixtureBytes = async () => + new Uint8Array(await Bun.file(FIXTURE).arrayBuffer()); + +const documentXmlOf = async (bytes: Uint8Array) => { + const zip = await JSZip.loadAsync(bytes); + const entry = zip.file("word/document.xml"); + if (!entry) throw new Error("no document.xml"); + return entry.async("string"); +}; + +const textOf = (xml: string) => + [...xml.matchAll(/]*>([^<]*)<\/w:t>/g)].map((m) => m[1]).join(""); + +const TARGET = + "This communication is a First Office Action Non-Final Rejection on the merits."; +const MODIFIED = + "This communication is a First Office Action Non-Final Rejection on the merits, responsive to the amendment filed 12 May 2026."; + +describe("readDraftParagraphs / extractDocxText", () => { + test("reads paragraphs 1-based in document order", async () => { + const paragraphs = await readDraftParagraphs(await fixtureBytes()); + expect(paragraphs.length).toBeGreaterThan(50); + expect(paragraphs[0]?.index).toBe(1); + expect(paragraphs.some((p) => p.text === TARGET)).toBe(true); + }); + + test("extracts plain text with empty paragraphs dropped", async () => { + const text = await extractDocxText(await fixtureBytes()); + expect(text).toContain(TARGET); + expect(text).not.toMatch(/\n\s*\n\s*\n/); + }); +}); + +describe("applyRedline", () => { + test("produces tracked changes; accept yields new text, reject restores original", async () => { + const result = await applyRedline(await fixtureBytes(), { + targetText: TARGET, + newText: MODIFIED, + }); + expect(result.applied).toBe(true); + if (!result.applied) return; + + const xml = await documentXmlOf(result.bytes); + expect(xml).toContain(" { + const result = await applyRedline(await fixtureBytes(), { + targetText: TARGET, + newText: MODIFIED, + }); + expect(result.applied).toBe(true); + if (!result.applied) return; + const xml = await documentXmlOf(result.bytes); + expect(xml).not.toMatch(/]*w:author="Patrick"/); + }); + + test("reports applied:false when the target text locates nowhere", async () => { + const result = await applyRedline(await fixtureBytes(), { + targetText: "text that does not exist anywhere in this document", + newText: "irrelevant", + }); + expect(result.applied).toBe(false); + }); + + test("re-editing a redlined paragraph supersedes (no stacked duplication)", async () => { + const first = await applyRedline(await fixtureBytes(), { + targetText: TARGET, + newText: MODIFIED, + }); + expect(first.applied).toBe(true); + if (!first.applied) return; + const paragraphs = await readDraftParagraphs(first.bytes); + // The agent reads the as-if-accepted view and targets THAT text. + expect(paragraphs.some((p) => p.text.includes("12 May 2026"))).toBe(true); + + const FINAL = `${MODIFIED} All claims stand rejected.`; + const second = await applyRedline(first.bytes, { + targetText: MODIFIED, + newText: FINAL, + }); + expect(second.applied).toBe(true); + if (!second.applied) return; + + // Accept-all must yield FINAL exactly once — the first redline was + // superseded, not stacked under the second. + const accepted = acceptTrackedChangesInOoxml( + await documentXmlOf(second.bytes), + { allAuthors: true }, + ); + const text = textOf(accepted.oxml); + expect(text).toContain(FINAL); + expect(text.match(/12 May 2026/g)?.length).toBe(1); + // Reject-all still restores the untouched original. + const rejected = rejectTrackedChangesInOoxml( + await documentXmlOf(second.bytes), + { allAuthors: true }, + ); + expect(textOf(rejected.oxml)).toContain(TARGET); + }); + + test("rejects an ambiguous target (text appearing in two paragraphs)", async () => { + // The fixture repeats this examiner boilerplate in two claim-2 paragraphs. + const result = await applyRedline(await fixtureBytes(), { + targetText: + "As per claim 2, the combination of Heinla, Iagnemma and Chen teaches element of:", + newText: "never applied", + }); + expect(result.applied).toBe(false); + if (result.applied) return; + expect(result.reason).toContain("matches 2 paragraphs"); + }); +}); + +// Minimal hand-built docx: real enough for the engine, small enough to shape +// exactly the structures a test needs (text boxes, tabs, shared base text). +async function buildDocx(bodyXml: string): Promise { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + ` + + + + +`, + ); + zip.file( + "_rels/.rels", + ` + + +`, + ); + zip.file( + "word/document.xml", + ` + +${bodyXml}`, + ); + return zip.generateAsync({ type: "uint8array" }); +} + +const para = (t: string) => + `${t}`; + +describe("guards", () => { + test("refuses to edit a paragraph carrying the attorney's tracked changes", async () => { + // The attorney's own pending revision (author ≠ Patrick) in the paragraph. + const withTheirs = await applyRedline( + await fixtureBytes(), + { targetText: TARGET, newText: `${TARGET} Their own addition.` }, + "Michael Hurhangee", + ); + expect(withTheirs.applied).toBe(true); + if (!withTheirs.applied) return; + + const result = await applyRedline(withTheirs.bytes, { + targetText: "Their own addition", + newText: "Patrick tramples the attorney's revision.", + }); + expect(result.applied).toBe(false); + if (result.applied) return; + expect(result.reason).toContain("attorney's own pending tracked changes"); + }); + + test("wrong-paragraph engine match is refused, not silently accepted", async () => { + // Two paragraphs share BASE text; a Patrick redline makes the second one's + // accepted view unique. The engine matches by FIRST base occurrence (the + // wrong paragraph) — the strict at-index verification must refuse rather + // than let newText-elsewhere vouch for it. + const SHARED = "The same boilerplate sentence appears twice in this draft."; + const bytes = await buildDocx( + [para(SHARED), para("A middle paragraph."), para(SHARED)].join(""), + ); + const first = await applyRedline(bytes, { + targetText: SHARED, + newText: "irrelevant", + }); + // Ambiguous by accepted view — refused outright. + expect(first.applied).toBe(false); + + const unique = await buildDocx( + [para(SHARED), para("A middle paragraph."), para(`${SHARED} Tail.`)].join( + "", + ), + ); + const edit = await applyRedline(unique, { + targetText: `${SHARED} Tail.`, + newText: "Completely new third paragraph.", + }); + // Either the engine hits the right paragraph (applied, verified at index 3) + // or it base-matched paragraph 1 and verification refused — never a silent + // wrong-paragraph write. + if (edit.applied) { + const paragraphs = await readDraftParagraphs(edit.bytes); + expect(paragraphs[2]?.text.replace(/\s+/g, " ").trim()).toBe( + "Completely new third paragraph.", + ); + expect(paragraphs[0]?.text).toBe(SHARED); + } else { + expect(edit.reason).toContain("did not land"); + } + }); +}); + +describe("structure handling", () => { + test("tabs and breaks extract as separators, not fused text", async () => { + const bytes = await buildDocx( + `Applicant:Acme Corp`, + ); + const paragraphs = await readDraftParagraphs(bytes); + expect(paragraphs[0]?.text).toBe("Applicant:\tAcme Corp"); + }); + + test("text-box content is not duplicated and stays unambiguous", async () => { + const box = + `Host paragraph.` + + `Box text here` + + `Box text here` + + `` + + para("A normal second paragraph."); + const bytes = await buildDocx(box); + const paragraphs = await readDraftParagraphs(bytes); + // Box paragraphs are hidden; host paragraph doesn't absorb their text. + const texts = paragraphs.map((p) => p.text); + expect(texts.join(" ")).not.toContain("Box text here"); + expect(texts).toContain("Host paragraph."); + expect(texts).toContain("A normal second paragraph."); + // Indices keep the engine-aligned slots (nested w:p still counted). + expect(paragraphs.at(-1)?.index).toBeGreaterThan(paragraphs.length); + }); +}); + +describe("readDraftRuns", () => { + test("pending redlines surface as ins/del runs for the preview", async () => { + const result = await applyRedline(await fixtureBytes(), { + targetText: TARGET, + newText: MODIFIED, + }); + expect(result.applied).toBe(true); + if (!result.applied) return; + const paragraphs = await readDraftRuns(result.bytes); + const edited = paragraphs.find((p) => p.hasRevisions); + expect(edited).toBeDefined(); + expect(edited?.runs.some((r) => r.kind === "ins")).toBe(true); + // Joined ins+text runs equal the accepted view. + const accepted = edited?.runs + .filter((r) => r.kind !== "del") + .map((r) => r.text) + .join(""); + expect(accepted?.replace(/\s+/g, " ")).toContain("12 May 2026"); + }); +}); + +describe("addComment / listComments", () => { + test("anchors a comment and lists it back", async () => { + const bytes = await fixtureBytes(); + const paragraphs = await readDraftParagraphs(bytes); + const anchor = paragraphs.find((p) => p.text === TARGET); + expect(anchor).toBeDefined(); + if (!anchor) return; + + const before = await listComments(bytes); + const result = await addComment(bytes, { + paragraphIndex: anchor.index, + textToFind: "Non-Final Rejection", + text: "@Michael: check the response deadline.", + }); + expect(result.applied).toBe(true); + if (!result.applied) return; + + const after = await listComments(result.bytes); + expect(after.length).toBe(before.length + 1); + const mine = after.find((c) => c.text.includes("response deadline")); + expect(mine?.author).toBe("Patrick"); + }); + + test("reports applied:false for an anchor that isn't in the paragraph", async () => { + const result = await addComment(await fixtureBytes(), { + paragraphIndex: 1, + textToFind: "text not present in paragraph one", + text: "never lands", + }); + expect(result.applied).toBe(false); + }); +}); diff --git a/apps/api/src/lib/docx/redline.ts b/apps/api/src/lib/docx/redline.ts new file mode 100644 index 00000000..b335cea5 --- /dev/null +++ b/apps/api/src/lib/docx/redline.ts @@ -0,0 +1,524 @@ +import { + configureXmlProvider, + ensureCommentsArtifactsInZip, + ensureNumberingArtifactsInZip, + injectCommentsIntoOoxml, + setDefaultAuthor, +} from "@ansonlai/docx-redline-js"; +import { seedRevisionIdsFromDocument } from "@ansonlai/docx-redline-js/core/types.js"; +import { applyOperationToDocumentXml } from "@ansonlai/docx-redline-js/services/standalone-operation-runner.js"; +import type { DraftComment } from "@patrick/shared"; +import { + DOMParser, + XMLSerializer, + type Document as XmlDocument, + type Element as XmlElement, + type Node as XmlNode, +} from "@xmldom/xmldom"; +import JSZip from "jszip"; + +// The ONLY import point for @ansonlai/docx-redline-js. Patrick edits .docx on +// disk as native tracked changes (w:ins/w:del) via paragraph-scoped +// reconciliation; the attorney reviews in Word. +// +// The engine is powerful but has sharp edges (measured, not assumed), and this +// adapter's job is to file them off: +// - Its paragraph matcher FUZZY-falls-back: a target that exists nowhere can +// still redline some other paragraph. So we resolve the paragraph ourselves +// (exact, unambiguous) and hand the engine the full paragraph text — its +// fuzzy path never triggers. +// - It matches against the paragraph's BASE text (as if revisions were +// rejected), and re-redlining an already-redlined paragraph double-applies. +// So before re-editing we reject OUR OWN pending revisions in that one +// paragraph (safe: Patrick only ever emits plain w:ins/w:del wrappers) — a +// paragraph's redline is always original → latest, never stacked. +// - It stamps spurious formatting revisions (w:rPrChange + explicit off-flags) +// on rebuilt runs. Patrick makes text edits only, so Patrick-authored +// rPrChange is always noise and is stripped from every write. +// - Comment anchoring searches the VISIBLE text, which a redline fragments — +// callers should comment first or anchor to untouched text. + +configureXmlProvider({ + DOMParser, + XMLSerializer, +} as unknown as Parameters[0]); +setDefaultAuthor("Patrick"); + +export const REDLINE_AUTHOR = "Patrick"; + +const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +export type DraftParagraph = { + /** 1-based position among ALL paragraphs (tables included), document order. */ + index: number; + /** The paragraph's text as if all tracked changes were accepted. */ + text: string; + /** The paragraph carries pending tracked changes (any author). */ + hasRevisions: boolean; +}; + +async function documentXmlOf(zip: JSZip): Promise { + const entry = zip.file("word/document.xml"); + if (!entry) throw new Error("not a .docx (word/document.xml missing)"); + return entry.async("string"); +} + +const parseXml = (xml: string): XmlDocument => + new DOMParser().parseFromString(xml, "text/xml"); +const serializeXml = (node: XmlNode) => + new XMLSerializer().serializeToString(node); + +const MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + +// Elements a text walk must NOT descend into: text-box bodies (their inner w:p +// would otherwise be read twice — inside the host paragraph AND as their own +// paragraph) and mc:Fallback (a duplicate of the mc:Choice next to it). Box +// text is therefore invisible to Patrick — a documented limitation, like the +// old editor's render-only headers — rather than duplicated and corrupting. +function isOpaque(el: XmlElement): boolean { + return ( + (el.namespaceURI === W_NS && el.localName === "txbxContent") || + (el.namespaceURI === MC_NS && el.localName === "Fallback") + ); +} + +type ParagraphEntry = { + el: XmlElement; + /** Inside another paragraph (a text box) — hidden from reads and edits, but + * it keeps its slot so indices stay aligned with the comment engine's + * all-`w:p` numbering. */ + nested: boolean; +}; + +function paragraphsOf(doc: XmlDocument): ParagraphEntry[] { + const list = doc.getElementsByTagNameNS(W_NS, "p"); + const out: ParagraphEntry[] = []; + for (let i = 0; i < list.length; i++) { + const p = list[i]; + if (p) out.push({ el: p, nested: hasAncestor(p, "p", doc) }); + } + return out; +} + +function hasAncestor( + node: XmlNode, + localName: string, + until: XmlNode, +): boolean { + for (let n = node.parentNode; n && n !== until; n = n.parentNode) { + if ( + n.nodeType === 1 && + (n as XmlElement).localName === localName && + (n as XmlElement).namespaceURI === W_NS + ) + return true; + } + return false; +} + +/** Paragraph text in a given view: accepted (ins in, del out) or base (the reverse). */ +function paragraphText(p: XmlElement, view: "accepted" | "base"): string { + let text = ""; + const include = (el: XmlElement) => + view === "accepted" + ? !hasAncestor(el, "del", p) + : !hasAncestor(el, "ins", p); + const walk = (node: XmlNode) => { + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.nodeType !== 1) continue; + const el = c as XmlElement; + if (isOpaque(el)) continue; + if (el.namespaceURI === W_NS && el.localName === "t") { + if (view === "accepted" || !hasAncestor(el, "ins", p)) + text += el.textContent ?? ""; + continue; + } + if (el.namespaceURI === W_NS && el.localName === "delText") { + if (view === "base") text += el.textContent ?? ""; + continue; + } + // Tabs and breaks are visible separators — losing them would fuse + // "Applicant:Acme" into "Applicant:Acme" and break matching. + if ( + el.namespaceURI === W_NS && + (el.localName === "tab" || + el.localName === "br" || + el.localName === "cr") + ) { + if (include(el)) text += el.localName === "tab" ? "\t" : "\n"; + continue; + } + walk(el); + } + }; + walk(p); + return text; +} + +function paragraphHasRevisions(p: XmlElement): boolean { + return ( + p.getElementsByTagNameNS(W_NS, "ins").length > 0 || + p.getElementsByTagNameNS(W_NS, "del").length > 0 + ); +} + +export async function readDraftParagraphs( + bytes: Uint8Array, +): Promise { + const zip = await JSZip.loadAsync(bytes); + const doc = parseXml(await documentXmlOf(zip)); + // Nested (text-box) paragraphs are dropped but keep their index slot, so + // [n] numbering stays aligned with the comment engine's all-w:p count. + return paragraphsOf(doc) + .map((p, i) => ({ entry: p, index: i + 1 })) + .filter(({ entry }) => !entry.nested) + .map(({ entry, index }) => ({ + index, + text: paragraphText(entry.el, "accepted"), + hasRevisions: paragraphHasRevisions(entry.el), + })); +} + +export type DraftRun = { text: string; kind: "text" | "ins" | "del" }; +export type DraftParagraphRuns = { + index: number; + runs: DraftRun[]; + hasRevisions: boolean; +}; + +/** Paragraph content split into runs with revision kind — the preview's shape, + * so pending redlines render as real ins/del marks, not invisible text. */ +function paragraphRuns(p: XmlElement): DraftRun[] { + const runs: DraftRun[] = []; + const push = (text: string, kind: DraftRun["kind"]) => { + if (!text) return; + const last = runs[runs.length - 1]; + if (last && last.kind === kind) last.text += text; + else runs.push({ text, kind }); + }; + const kindOf = (el: XmlElement): DraftRun["kind"] => + hasAncestor(el, "del", p) + ? "del" + : hasAncestor(el, "ins", p) + ? "ins" + : "text"; + const walk = (node: XmlNode) => { + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.nodeType !== 1) continue; + const el = c as XmlElement; + if (isOpaque(el)) continue; + if (el.namespaceURI === W_NS && el.localName === "t") { + push(el.textContent ?? "", hasAncestor(el, "ins", p) ? "ins" : "text"); + continue; + } + if (el.namespaceURI === W_NS && el.localName === "delText") { + push(el.textContent ?? "", "del"); + continue; + } + if ( + el.namespaceURI === W_NS && + (el.localName === "tab" || + el.localName === "br" || + el.localName === "cr") + ) { + push(el.localName === "tab" ? "\t" : "\n", kindOf(el)); + continue; + } + walk(el); + } + }; + walk(p); + return runs; +} + +export async function readDraftRuns( + bytes: Uint8Array, +): Promise { + const zip = await JSZip.loadAsync(bytes); + const doc = parseXml(await documentXmlOf(zip)); + return paragraphsOf(doc) + .map((p, i) => ({ entry: p, index: i + 1 })) + .filter(({ entry }) => !entry.nested) + .map(({ entry, index }) => ({ + index, + runs: paragraphRuns(entry.el), + hasRevisions: paragraphHasRevisions(entry.el), + })); +} + +/** Plain text of a .docx (paragraphs joined by newlines) — context extraction. */ +export async function extractDocxText(bytes: Uint8Array): Promise { + const paragraphs = await readDraftParagraphs(bytes); + return paragraphs + .map((p) => p.text) + .filter((t) => t.trim()) + .join("\n") + .trim(); +} + +// Whitespace-insensitive comparison: the engine normalises tabs/breaks and run +// boundaries, so byte-equality is the wrong test for "is this the same text". +const norm = (s: string) => s.replace(/\s+/g, " ").trim(); + +/** + * Reject one author's pending revisions inside ONE paragraph — DOM surgery, + * not the engine: Patrick only ever emits plain `w:ins` (drop whole) and + * `w:del` (unwrap, delText → t) wrappers, so this is exact for our own edits. + */ +function rejectAuthorRevisionsIn(p: XmlElement, author: string): void { + const doc = p.ownerDocument; + if (!doc) return; + const byAuthor = (el: XmlElement) => + el.getAttributeNS(W_NS, "author") === author; + + const collect = (localName: string): XmlElement[] => { + const list = p.getElementsByTagNameNS(W_NS, localName); + const out: XmlElement[] = []; + for (let i = 0; i < list.length; i++) { + const el = list[i] as XmlElement; + if (el && byAuthor(el)) out.push(el); + } + return out; + }; + + for (const ins of collect("ins")) ins.parentNode?.removeChild(ins); + for (const del of collect("del")) { + const parent = del.parentNode; + if (!parent) continue; + // delText → t inside the wrapper, then lift its children out. + const delTexts = del.getElementsByTagNameNS(W_NS, "delText"); + for (let i = delTexts.length - 1; i >= 0; i--) { + const dt = delTexts[i] as XmlElement; + if (!dt) continue; + const t = doc.createElementNS(W_NS, "w:t"); + t.setAttribute("xml:space", "preserve"); + t.textContent = dt.textContent ?? ""; + dt.parentNode?.replaceChild(t, dt); + } + while (del.firstChild) parent.insertBefore(del.firstChild, del); + parent.removeChild(del); + } +} + +// Patrick never makes formatting-only edits, so any Patrick-authored +// w:rPrChange is engine noise (ghost "Format" revisions in review panes). +function stripGhostFormatRevisions(xml: string, author: string): string { + const a = author.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return xml + .replace( + new RegExp( + `]*>[\\s\\S]*?`, + "g", + ), + "", + ) + .replace( + new RegExp(`]*/>`, "g"), + "", + ); +} + +async function writeDocumentXml(zip: JSZip, xml: string): Promise { + zip.file("word/document.xml", xml); + // DEFLATE to match how Word writes .docx — STORE would bloat the file ~5x. + return zip.generateAsync({ type: "uint8array", compression: "DEFLATE" }); +} + +export type RedlineEdit = { + /** The target paragraph's current text (as read) — or a unique portion of it. */ + targetText: string; + /** The paragraph's full revised text. */ + newText: string; +}; + +export type RedlineResult = + | { applied: true; bytes: Uint8Array; paragraphIndex: number } + | { applied: false; reason: string }; + +/** + * Apply one paragraph-scoped reconciliation redline. The paragraph is resolved + * here (exact match on the as-read text, must be unambiguous), any pending + * Patrick revisions in it are superseded (redline stays original → latest), + * and the result is verified before it's returned — a failure never mutates. + */ +export async function applyRedline( + bytes: Uint8Array, + edit: RedlineEdit, + author = REDLINE_AUTHOR, +): Promise { + const target = norm(edit.targetText); + if (!target) return { applied: false, reason: "empty target text" }; + + const zip = await JSZip.loadAsync(bytes); + const doc = parseXml(await documentXmlOf(zip)); + const paragraphs = paragraphsOf(doc); + + const matches = paragraphs + .map((entry, i) => ({ entry, i })) + .filter( + ({ entry }) => + !entry.nested && + norm(paragraphText(entry.el, "accepted")).includes(target), + ); + if (matches.length === 0) + return { + applied: false, + reason: + "target text not found in the draft — read the draft and quote the paragraph's current text exactly", + }; + if (matches.length > 1) + return { + applied: false, + reason: `target text matches ${matches.length} paragraphs — quote more of the paragraph so the target is unique`, + }; + + const { entry, i } = matches[0] as { entry: ParagraphEntry; i: number }; + const p = entry.el; + + // The ATTORNEY's own pending tracked changes are theirs to resolve — editing + // through them risks absorbing their revisions into a Patrick-authored + // change (destroying authorship and what reject-all restores). Refuse. + if (hasOtherAuthorsRevisions(p, author)) + return { + applied: false, + reason: + "this paragraph has the attorney's own pending tracked changes — ask them to accept/reject those in Word first, or add a comment instead of editing", + }; + + // Supersede: strip our own pending revisions from this one paragraph so the + // engine rebuilds original → newText instead of stacking redlines. + rejectAuthorRevisionsIn(p, author); + const baseTarget = paragraphText(p, "base"); + if (!norm(baseTarget)) + return { applied: false, reason: "target paragraph is empty" }; + + const result = await applyOperationToDocumentXml( + serializeXml(doc), + { type: "redline", target: baseTarget, modified: edit.newText }, + author, + ); + if (!result.hasChanges) + return { + applied: false, + reason: `the redline engine could not apply the edit (status: ${result.status})`, + }; + + // Verify before returning — STRICTLY at the edited position: the accepted + // text of the paragraph at index i (or, when the engine split the edit into + // several paragraphs, of the contiguous block i..i+delta) must equal the + // requested text. No document-wide fallback: newText legitimately appearing + // elsewhere (claim boilerplate) must never vouch for a mangled application. + const outXml = stripGhostFormatRevisions(result.documentXml, author); + const outDoc = parseXml(outXml); + const outParagraphs = paragraphsOf(outDoc); + const wanted = norm(edit.newText); + const delta = Math.max(0, outParagraphs.length - paragraphs.length); + const blockText = norm( + outParagraphs + .slice(i, i + delta + 1) + .map((op) => paragraphText(op.el, "accepted")) + .join(" "), + ); + if (blockText !== wanted) + return { + applied: false, + reason: + "the edit did not land as requested (engine mismatch) — nothing was changed", + }; + + // List-shaped rewrites can mint numbering definitions — merge them or the + // document references w:numId entries that don't exist (Word repair prompt). + if (result.numberingXml) + await ensureNumberingArtifactsInZip(zip, result.numberingXml); + + return { + applied: true, + bytes: await writeDocumentXml(zip, outXml), + paragraphIndex: i + 1, + }; +} + +/** Any pending w:ins/w:del in the paragraph NOT authored by `author`. */ +function hasOtherAuthorsRevisions(p: XmlElement, author: string): boolean { + for (const localName of ["ins", "del"]) { + const list = p.getElementsByTagNameNS(W_NS, localName); + for (let i = 0; i < list.length; i++) { + const el = list[i] as XmlElement; + if (el && el.getAttributeNS(W_NS, "author") !== author) return true; + } + } + return false; +} + +export type CommentRequest = { + /** 1-based paragraph index (from readDraftParagraphs). */ + paragraphIndex: number; + /** Visible text within that paragraph to anchor the comment to. */ + textToFind: string; + text: string; +}; + +export type CommentResult = + | { applied: true; bytes: Uint8Array } + | { applied: false; reason: string }; + +export async function addComment( + bytes: Uint8Array, + request: CommentRequest, + author = REDLINE_AUTHOR, +): Promise { + const zip = await JSZip.loadAsync(bytes); + const documentXml = await documentXmlOf(zip); + // The engine's revision-id counter is process-global and resets on restart; + // seed it past every id already in the document or a comment added to a + // draft commented in an earlier session throws "Duplicate comment id". + seedRevisionIdsFromDocument(parseXml(documentXml)); + const result = (await injectCommentsIntoOoxml( + documentXml, + [ + { + paragraphIndex: request.paragraphIndex, + textToFind: request.textToFind, + commentContent: request.text, + }, + ], + { author }, + )) as { + oxml: string; + commentsXml?: string; + commentsApplied: number; + warnings: string[]; + }; + if (result.commentsApplied < 1) + return { + applied: false, + reason: + result.warnings.join("; ") || "anchor text not found in that paragraph", + }; + if (result.commentsXml) + await ensureCommentsArtifactsInZip(zip, result.commentsXml); + return { applied: true, bytes: await writeDocumentXml(zip, result.oxml) }; +} + +/** All comments in the draft (word/comments.xml), in document order. */ +export async function listComments(bytes: Uint8Array): Promise { + const zip = await JSZip.loadAsync(bytes); + const entry = zip.file("word/comments.xml"); + if (!entry) return []; + const doc = parseXml(await entry.async("string")); + const comments = doc.getElementsByTagNameNS(W_NS, "comment"); + const out: DraftComment[] = []; + for (let i = 0; i < comments.length; i++) { + const el = comments[i] as XmlElement; + if (!el) continue; + const texts = el.getElementsByTagNameNS(W_NS, "t"); + let text = ""; + for (let j = 0; j < texts.length; j++) text += texts[j]?.textContent ?? ""; + out.push({ + id: el.getAttributeNS(W_NS, "id") ?? String(i), + author: el.getAttributeNS(W_NS, "author") ?? "", + text, + }); + } + return out; +} diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index 36fcb444..4448737d 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -42,21 +42,22 @@ import { readExtractedText, readSearchIndex, renameDocument, - saveDocumentBytes, saveExtractedText, saveRetrievedDocument, saveSearchIndex, - unlockDocumentCopy, + unlockDocumentInPlace, writeDocumentMeta, } from "../lib/documents"; +import { danceFor } from "../lib/docx/dance"; +import { readDraftRuns } from "../lib/docx/redline"; import { fetchPublication } from "../lib/patents"; import { readProfile } from "../lib/profiles"; import { deleteTask, listTasks, readTask, writeTask } from "../lib/tasks"; export const tasks = new Hono(); -// Patrick chat — streams a UI message response; docx tool calls round-trip to -// the client to run against the live editor. See lib/ai/chat.ts. +// Patrick chat — streams a UI message response; draft edits execute server-side +// against the .docx on disk (tracked changes via the dance). See lib/ai/chat.ts. tasks.post("/:id/chat", handleChat); // Persisted chats (under /.patrick/chats). Saved on each turn's finish. @@ -362,11 +363,12 @@ tasks.post("/:id/publication", async (c) => { return c.json({ filename, summary: result.summary }, 201); }); -// Unlock an original for editing → visible "(Patrick)" working copy in the folder. -tasks.post("/:id/documents/:filename/copy", async (c) => { +// Unlock an original .docx for in-place tracked-changes editing (the pristine +// bytes are snapshotted to .patrick/backups first). +tasks.post("/:id/documents/:filename/unlock", async (c) => { const task = await readTask(c.req.param("id")); if (!task) return c.json({ error: "not found" }, 404); - const name = await unlockDocumentCopy( + const name = await unlockDocumentInPlace( task.folder, basename(c.req.param("filename")), ); @@ -375,22 +377,6 @@ tasks.post("/:id/documents/:filename/copy", async (c) => { : c.json({ error: "not found" }, 404); }); -// Save edited bytes back to a Patrick-owned doc. Originals are refused. -tasks.put("/:id/documents/:filename", async (c) => { - const task = await readTask(c.req.param("id")); - if (!task) return c.json({ error: "not found" }, 404); - const bytes = await c.req.arrayBuffer(); - const result = await saveDocumentBytes( - task.folder, - basename(c.req.param("filename")), - bytes, - ); - if (result === "not-found") return c.json({ error: "file not found" }, 404); - if (result === "forbidden") - return c.json({ error: "original is read-only" }, 403); - return c.json({ ok: true }); -}); - // Rename a Patrick-owned doc. Originals are refused. tasks.post("/:id/documents/:filename/rename", async (c) => { const task = await readTask(c.req.param("id")); @@ -467,6 +453,67 @@ tasks.get("/:id/documents/:filename/text", async (c) => { return doc ? c.json(doc) : c.json({ error: "not found" }, 404); }); +// The dance, observable: lock state, parked edits, last save, @Patrick comment +// mentions — the draft panel polls this while a docx tab is open. +tasks.get("/:id/documents/:filename/draft-status", async (c) => { + const task = await readTask(c.req.param("id")); + if (!task) return c.json({ error: "not found" }, 404); + const dance = danceFor(task.folder, basename(c.req.param("filename"))); + return c.json(await dance.status()); +}); + +// Acknowledge surfaced parked-edit failures (the panel showed them). +tasks.post( + "/:id/documents/:filename/draft-status/clear-failures", + async (c) => { + const task = await readTask(c.req.param("id")); + if (!task) return c.json({ error: "not found" }, 404); + danceFor(task.folder, basename(c.req.param("filename"))).clearFailures(); + return c.json({ ok: true }); + }, +); + +// A .docx as paragraphs-of-runs, pending redlines marked ins/del — the in-app +// preview; the real review (accept/reject) happens in Word. +tasks.get("/:id/documents/:filename/docx-text", async (c) => { + const task = await readTask(c.req.param("id")); + if (!task) return c.json({ error: "not found" }, 404); + const name = basename(c.req.param("filename")); + const file = Bun.file(join(task.folder, name)); + if (!(await file.exists())) return c.json({ error: "file not found" }, 404); + try { + const paragraphs = await readDraftRuns( + new Uint8Array(await file.arrayBuffer()), + ); + return c.json({ paragraphs }); + } catch { + return c.json({ error: "could not read this .docx" }, 422); + } +}); + +// Open a document in its native app (Word/LibreOffice) — the api runs on the +// attorney's machine (Tauri sidecar / local dev), so a plain OS opener works. +tasks.post("/:id/documents/:filename/open", async (c) => { + const task = await readTask(c.req.param("id")); + if (!task) return c.json({ error: "not found" }, 404); + const name = basename(c.req.param("filename")); + const path = join(task.folder, name); + if (!(await Bun.file(path).exists())) + return c.json({ error: "file not found" }, 404); + const cmd = + process.platform === "darwin" + ? ["open", path] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", path] + : ["xdg-open", path]; + try { + Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }); + return c.json({ ok: true }); + } catch { + return c.json({ error: "could not open the document" }, 500); + } +}); + // Store / load the search-index sidecar for a document — the frontend builds it // (chunk + embed in the webview), the server just persists it under .patrick/index. tasks.put("/:id/documents/:filename/index", async (c) => { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index e74cfc9f..9fb43323 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -17,8 +17,6 @@ "@dnd-kit/dom": "^0.5.0", "@dnd-kit/helpers": "^0.5.0", "@dnd-kit/react": "^0.5.0", - "@eigenpal/docx-editor-agents": "workspace:*", - "@eigenpal/docx-editor-react": "workspace:*", "@fontsource-variable/hanken-grotesk": "^5.2.8", "@fontsource-variable/lora": "^5.2.8", "@huggingface/transformers": "^4.2.0", @@ -28,7 +26,6 @@ "@tanstack/react-query": "^5.101.1", "@tanstack/react-router": "^1.170.16", "@tauri-apps/plugin-dialog": "^2.7.1", - "@tauri-apps/plugin-shell": "^2.3.5", "@tesseract.js-data/eng": "^1.0.0", "@tiptap/extension-mention": "^3.27.1", "@tiptap/extensions": "^3.27.1", diff --git a/apps/frontend/src/api/tasks.ts b/apps/frontend/src/api/tasks.ts index ce4fa78b..91e789b3 100644 --- a/apps/frontend/src/api/tasks.ts +++ b/apps/frontend/src/api/tasks.ts @@ -6,6 +6,7 @@ import type { ClaimLimitation, Document, DocumentMeta, + DraftStatus, ExtractedDoc, LimitationRead, SearchIndex, @@ -141,10 +142,10 @@ export const tasksApi = { throw new Error(data.error ?? `publication fetch failed: ${res.status}`); return data as { filename: string; summary: string }; }, - /** Unlock an original → working copy; returns the copy's filename. */ + /** Unlock an original .docx for in-place editing; returns its filename. */ unlockDocument: (id: string, filename: string) => api.post<{ filename: string }>( - `/tasks/${id}/documents/${encodeURIComponent(filename)}/copy`, + `/tasks/${id}/documents/${encodeURIComponent(filename)}/unlock`, {}, ), /** Rename a Patrick-owned doc; returns the (possibly deduped) new filename. */ @@ -158,12 +159,30 @@ export const tasksApi = { api.del<{ ok: boolean }>( `/tasks/${id}/documents/${encodeURIComponent(filename)}`, ), - /** Overwrite a Patrick-owned doc's bytes (editor autosave). */ - saveFile: async (id: string, filename: string, bytes: ArrayBuffer) => { - const res = await fetch( - `${BASE_URL}/tasks/${id}/documents/${encodeURIComponent(filename)}`, - { method: "PUT", body: bytes }, - ); - if (!res.ok) throw new Error(`save ${filename} failed: ${res.status}`); - }, + /** The dance status for a docx draft: lock, parked edits, @Patrick mentions. */ + draftStatus: (id: string, filename: string) => + api.get( + `/tasks/${id}/documents/${encodeURIComponent(filename)}/draft-status`, + ), + /** Acknowledge surfaced parked-edit failures. */ + clearDraftFailures: (id: string, filename: string) => + api.post<{ ok: boolean }>( + `/tasks/${id}/documents/${encodeURIComponent(filename)}/draft-status/clear-failures`, + {}, + ), + /** A .docx as paragraphs-of-runs, pending redlines marked ins/del — the preview. */ + docxText: (id: string, filename: string) => + api.get<{ + paragraphs: { + index: number; + runs: { text: string; kind: "text" | "ins" | "del" }[]; + hasRevisions: boolean; + }[]; + }>(`/tasks/${id}/documents/${encodeURIComponent(filename)}/docx-text`), + /** Open a document in its native app (Word/LibreOffice) on the attorney's machine. */ + openDocument: (id: string, filename: string) => + api.post<{ ok: boolean }>( + `/tasks/${id}/documents/${encodeURIComponent(filename)}/open`, + {}, + ), }; diff --git a/apps/frontend/src/components/shell/sidebar/documents-nav.tsx b/apps/frontend/src/components/shell/sidebar/documents-nav.tsx index 35f9f24a..87f1c256 100644 --- a/apps/frontend/src/components/shell/sidebar/documents-nav.tsx +++ b/apps/frontend/src/components/shell/sidebar/documents-nav.tsx @@ -14,12 +14,12 @@ import { Skeleton } from "@patrick/ui/components/skeleton"; import { InfoTooltip } from "@patrick/ui/components/tooltip"; import { useNavigate } from "@tanstack/react-router"; import { - Copy, Eye, EyeOff, FileImage, FilePlus2, FileText, + LockOpen, Pencil, Plus, RefreshCw, @@ -134,9 +134,9 @@ export function DocumentsNav() { onSuccess: ({ filename }) => openDoc(filename), }); - const editCopy = (filename: string) => + const unlockDoc = (filename: string) => unlock.mutate(filename, { - onSuccess: ({ filename: copy }) => openDoc(copy), + onSuccess: ({ filename: name }) => openDoc(name), }); const renameDoc = (from: string, to: string) => @@ -178,7 +178,7 @@ export function DocumentsNav() { } onOpen={() => openDoc(doc.filename)} onUpdate={(patch) => update(doc.filename, patch)} - onEditCopy={() => editCopy(doc.filename)} + onUnlock={() => unlockDoc(doc.filename)} onRename={(to) => renameDoc(doc.filename, to)} onDelete={() => deleteDoc(doc.filename)} onExtract={() => extractText(doc.filename)} @@ -265,7 +265,7 @@ function DocumentRow({ state, onOpen, onUpdate, - onEditCopy, + onUnlock, onRename, onDelete, onExtract, @@ -278,7 +278,7 @@ function DocumentRow({ state: RowState; onOpen: () => void; onUpdate: (patch: Partial) => void; - onEditCopy: () => void; + onUnlock: () => void; onRename: (to: string) => void; onDelete: () => void; onExtract: () => void; @@ -356,7 +356,7 @@ function DocumentRow({ doc={doc} kind={kind} onUpdate={onUpdate} - onEditCopy={onEditCopy} + onUnlock={onUnlock} onStartRename={() => setRenaming(true)} onAskDelete={() => setConfirmDelete(true)} onExtract={onExtract} @@ -407,7 +407,7 @@ function DocumentMenu({ doc, kind, onUpdate, - onEditCopy, + onUnlock, onStartRename, onAskDelete, onExtract, @@ -419,7 +419,7 @@ function DocumentMenu({ doc: Document; kind: DocKind; onUpdate: (patch: Partial) => void; - onEditCopy: () => void; + onUnlock: () => void; onStartRename: () => void; onAskDelete: () => void; onExtract: () => void; @@ -428,10 +428,11 @@ function DocumentMenu({ labelling: boolean; labelError: string | null; }) { - // Originals are the attorney's files — Patrick never renames/deletes them; - // instead it offers an editable working copy. + // Originals are the attorney's files — Patrick never renames/deletes them. + // A .docx original can be UNLOCKED for in-place tracked-changes editing (a + // pristine backup is snapshotted under .patrick/backups first). const isPatrick = !!doc.createdInPatrick; - const canEditCopy = kind === "docx" && !isPatrick; + const canUnlock = kind === "docx" && !isPatrick && !doc.unlocked; const mode = doc.contextMode ?? "image"; return ( @@ -525,12 +526,12 @@ function DocumentMenu({ {/* File */} - {canEditCopy && ( + {canUnlock && ( <> - - - Edit a copy + + + Unlock for editing )} diff --git a/apps/frontend/src/components/workspace/agent-chat.tsx b/apps/frontend/src/components/workspace/agent-chat.tsx index 50b9bfa4..852d9126 100644 --- a/apps/frontend/src/components/workspace/agent-chat.tsx +++ b/apps/frontend/src/components/workspace/agent-chat.tsx @@ -1,14 +1,14 @@ import { useChat } from "@ai-sdk/react"; -import { useDocxAgentTools } from "@eigenpal/docx-editor-agents/react"; -import type { DocxEditorRef } from "@eigenpal/docx-editor-react"; import { type Chat, contextWindowFor, + DRAFT_TOOL_NAMES, docKind, type ExchangeContext, type ExchangeMetadata, MODELS_BY_ID, MUTATING_CHART_TOOLS, + MUTATING_DRAFT_TOOLS, type PinnedSource, READ_CHART_TOOL, toStoredMessage, @@ -26,7 +26,6 @@ import { } from "ai"; import { ArrowUp, ChevronDown, Globe, Square } from "lucide-react"; import { - type RefObject, useCallback, useEffect, useLayoutEffect, @@ -52,7 +51,6 @@ import { useUpdateTask, } from "@/hooks/use-tasks"; import { useActiveChat } from "@/lib/active-chat"; -import { useEditorReadiness, useEditorRefFor } from "@/lib/active-editor"; import { useActiveProfile } from "@/lib/active-profile"; import { useActiveTask } from "@/lib/active-task"; import { estimateDocTokens, useDocSize } from "@/lib/doc-size"; @@ -73,13 +71,13 @@ import { ContextRing, type ContextSource } from "./context-ring"; import { ExchangePanel, type ExchangePanelData } from "./exchange-panel"; import { SystemCard } from "./system-card"; -// Tools that execute on the server (their results stream back) — the client must -// not route them to the docx editor, which would error "Editor not ready". Like -// HITL tools, they're skipped in onToolCall. +// Tools that execute on the server (their results stream back) — the client +// skips them in onToolCall, like HITL tools. // Chart-driving tools run on the server (they read/write the Chart JSON). The mutating ones // refresh the open chart viewer via query invalidation; read_chart is read-only. Names come // from @patrick/shared so they can't drift from what buildChartTools actually ships. const CHART_TOOLS = new Set(MUTATING_CHART_TOOLS); +const DRAFT_MUTATORS = new Set(MUTATING_DRAFT_TOOLS); const SERVER_TOOLS = new Set([ "patrick_help", @@ -89,6 +87,9 @@ const SERVER_TOOLS = new Set([ "google_search", READ_CHART_TOOL, ...CHART_TOOLS, + // Draft tools run server-side too — headless tracked changes on the .docx + // on disk (through the dance); nothing executes in the webview. + ...DRAFT_TOOL_NAMES, ]); // One user message + every assistant message that follows it (the loop produces @@ -249,11 +250,11 @@ function ChatSession({ initial?.pinnedSources ?? [], ); - // Patrick edits ONE live draft at a time (the editor tools bind to a single - // editor). The active draft is sticky: the focused editable doc, else the one - // you were last editing, else any open editable doc — so it doesn't vanish - // when you focus a source to read it. It's not in the static context; the - // agent reads it live via the editor tools. + // Patrick edits ONE draft at a time (the draft tools bind to a single file). + // The active draft is sticky: the focused editable doc, else the one you were + // last editing, else any open editable doc — so it doesn't vanish when you + // focus a source to read it. It's not in the static context; the agent reads + // the file live via read_draft. const focusedDoc = focused ? getDoc(focused) : undefined; const openEditableIds = useMemo(() => { return columnList @@ -267,21 +268,17 @@ function ChatSession({ setActiveDraft((prev) => { if (focusedDoc?.editable) return focusedDoc.id; if (prev && openEditableIds.includes(prev)) return prev; + // A draft the workspace doesn't know yet (just created/unlocked, the + // documents refetch still in flight) stays active — clobbering it here + // would race the agent loop's auto-continue with activeDraft: null. + if (prev && !getDoc(prev)) return prev; return openEditableIds[0] ?? null; }); - }, [focusedDoc, openEditableIds]); - const editorRef = useEditorRefFor(activeDraft); - const waitForEditor = useEditorReadiness(); - + }, [focusedDoc, openEditableIds, getDoc]); // The chart tab in focus, so "this chart" resolves server-side. Unlike the draft it isn't // sticky — Patrick reads any chart by id via read_chart; this only disambiguates deixis. const openChart = focused && getChart(focused) ? focused : null; - const { executeToolCall } = useDocxAgentTools({ - editorRef: editorRef as RefObject, - author: profile?.identity.author?.trim() || "Patrick", - }); - // Web search: a per-chat toolbar toggle (default on). Patrick can search the // web; off removes the tool for the turn (also the escape hatch if a model // doesn't support it). Sent with each request. @@ -395,48 +392,54 @@ function ChatSession({ }); return; } - let output: unknown; - try { - output = executeToolCall( - toolCall.toolName, - toolCall.input as Record, - ); - } catch (err) { - output = { - success: false, - error: err instanceof Error ? err.message : "tool execution failed", - }; - } + // Anything else would be a tool nobody executes — resolve it rather than + // hang the agent loop forever. addToolResult({ tool: toolCall.toolName, toolCallId: toolCall.toolCallId, - output, + output: { error: `unknown client tool: ${toolCall.toolName}` }, }); }, }); - // Chart tools mutate the Chart JSON server-side; refresh the charts list and any open - // viewer when one completes. Dedupe by toolCallId so each result invalidates once. + // Chart and draft tools mutate server-side state; refresh their viewers the + // moment a mutating result streams back. Dedupe by toolCallId so each result + // invalidates once. const queryClient = useQueryClient(); - const seenChartTools = useRef(new Set()); + const seenMutatingTools = useRef(new Set()); useEffect(() => { const taskId = activeTaskIdRef.current; if (!taskId) return; - // Only the last assistant message can carry a newly-arrived chart result (earlier ones + // Only the last assistant message can carry a newly-arrived result (earlier ones // were handled while they were last, deduped by toolCallId) — so don't re-walk the whole // transcript on every streaming tick. const last = messages[messages.length - 1]; if (last?.role !== "assistant") return; - let hit = false; + let chartHit = false; + let draftHit = false; for (const part of last.parts) { if (!isToolUIPart(part) || part.state !== "output-available") continue; - if (!CHART_TOOLS.has(getToolName(part))) continue; - if (seenChartTools.current.has(part.toolCallId)) continue; - seenChartTools.current.add(part.toolCallId); - hit = true; + const name = getToolName(part); + const isChart = CHART_TOOLS.has(name); + const isDraft = DRAFT_MUTATORS.has(name); + if (!isChart && !isDraft) continue; + if (seenMutatingTools.current.has(part.toolCallId)) continue; + seenMutatingTools.current.add(part.toolCallId); + if (isChart) chartHit = true; + if (isDraft) draftHit = true; } - if (hit) + if (chartHit) queryClient.invalidateQueries({ queryKey: ["tasks", taskId, "charts"] }); + if (draftHit && activeDraftRef.current) { + // The draft file changed on disk (or an edit parked) — refresh the + // preview and the dance status without waiting for the poll. + queryClient.invalidateQueries({ + queryKey: ["tasks", taskId, "docx-text", activeDraftRef.current], + }); + queryClient.invalidateQueries({ + queryKey: ["tasks", taskId, "draft-status", activeDraftRef.current], + }); + } }, [messages, queryClient]); const [composerEmpty, setComposerEmpty] = useState(true); @@ -573,13 +576,16 @@ function ChatSession({ }, // Return null on failure rather than throwing — the card awaits this and // must always resolve the tool call, or the agent loop hangs forever. - // Wait for the new editor to mount + its agent to be ready before - // resolving, so the agent's first edit doesn't race an unbound editor. + // Set the active draft SYNCHRONOUSLY (state + the ref the transport + // reads): the agent loop auto-continues the moment the tool resolves, + // racing the documents refetch — waiting on the workspace effect would + // ship activeDraft: null and the agent's first read_draft would fail. createDraft: async (name) => { try { const res = await createDoc.mutateAsync(name); + setActiveDraft(res.filename); + activeDraftRef.current = res.filename; open(res.filename); - await waitForEditor(res.filename); return res.filename; } catch { return null; @@ -588,8 +594,9 @@ function ChatSession({ unlockSource: async (filename) => { try { const res = await unlockDoc.mutateAsync(filename); + setActiveDraft(res.filename); + activeDraftRef.current = res.filename; open(res.filename); - await waitForEditor(res.filename); return res.filename; } catch { return null; @@ -651,7 +658,6 @@ function ChatSession({ fetchPub.mutateAsync, updateTask.mutate, updateProfile.mutate, - waitForEditor, ], ); diff --git a/apps/frontend/src/components/workspace/chat-message-parts.tsx b/apps/frontend/src/components/workspace/chat-message-parts.tsx index 19286c40..109b475c 100644 --- a/apps/frontend/src/components/workspace/chat-message-parts.tsx +++ b/apps/frontend/src/components/workspace/chat-message-parts.tsx @@ -37,36 +37,27 @@ type Presenter = { }; const PRESENTERS: Record = { - read_document: { - label: "Read the document", - runningLabel: "Reading the document…", + read_draft: { + label: "Read the draft", + runningLabel: "Reading the draft…", }, - read_selection: { - label: "Read the selection", - runningLabel: "Reading the selection…", - }, - read_page: { label: "Read a page", runningLabel: "Reading a page…" }, - read_pages: { label: "Read pages", runningLabel: "Reading pages…" }, - find_text: { - label: "Find text", - runningLabel: "Searching the document…", - summary: (input) => input?.query ?? input?.text ?? null, - }, - read_changes: { - label: "Review tracked changes", - runningLabel: "Reviewing changes…", + edit_paragraph: { + label: "Redline a paragraph", + runningLabel: "Drafting a redline…", + summary: (input, output) => + output?.error ?? + (output?.parked + ? "parked — lands when the draft is closed" + : (input?.new_text ?? null)), }, - read_comments: { label: "Read comments", runningLabel: "Reading comments…" }, - add_comment: { - label: "Add a comment", + add_draft_comment: { + label: "Add a comment to the draft", runningLabel: "Adding a comment…", - summary: (input) => input?.text ?? null, + summary: (input, output) => output?.error ?? input?.text ?? null, }, - suggest_change: { - label: "Suggest a change", - runningLabel: "Proposing a tracked change…", - summary: (input) => - input?.replaceWith ?? input?.replacement ?? input?.search ?? null, + read_draft_comments: { + label: "Read the draft's comments", + runningLabel: "Reading comments…", }, ep_law_lookup: { label: "Recall EPC law", @@ -492,7 +483,7 @@ export type ToolUiHandlers = { setLabel: (filename: string, label: string, suggestions?: string[]) => void; /** Create a blank draft + open it; resolves to its filename (createDraft). */ createDraft: (name: string) => Promise; - /** Make an editable copy of an original + open it (requestUnlock). */ + /** Unlock an original for in-place editing + open it (requestUnlock). */ unlockSource: (filename: string) => Promise; /** Apply a proposed task brief — replace it, or append a note (suggestBrief acceptance). */ suggestBrief: (brief: string, append?: boolean) => void; @@ -613,8 +604,8 @@ const HITL_SPECS: Record = { }, requestUnlock: { icon: , - title: (i) => <>Make an editable copy of {bold(i.filename)} to draft in?, - acceptLabel: "Create copy", + title: (i) => <>Unlock {bold(i.filename)} for tracked-changes editing?, + acceptLabel: "Unlock", rejectLabel: "No", accept: async (i, h) => { const filename = i.filename ? await h.unlockSource(i.filename) : null; @@ -623,7 +614,7 @@ const HITL_SPECS: Record = { reject: () => ({ unlocked: false }), resolved: (o) => o.unlocked ? ( - <>Created editable copy {bold(o.filename)}. + <>Unlocked {bold(o.filename)} for editing. ) : ( <>Left it as-is. ), diff --git a/apps/frontend/src/components/workspace/document-viewer.tsx b/apps/frontend/src/components/workspace/document-viewer.tsx index 79e72d26..bc2f889b 100644 --- a/apps/frontend/src/components/workspace/document-viewer.tsx +++ b/apps/frontend/src/components/workspace/document-viewer.tsx @@ -13,7 +13,7 @@ import { Fragment } from "react"; import { DocIcon } from "@/components/doc-icon"; import { PanelToggleButton } from "@/components/shell/panel-toggle-button"; import { ClaimChartViewer } from "@/components/workspace/claim-chart-viewer"; -import { DocxViewer } from "@/components/workspace/docx-viewer"; +import { DraftPanel } from "@/components/workspace/draft-panel"; import { PdfViewer } from "@/components/workspace/pdf-viewer"; import { TextViewer } from "@/components/workspace/text-viewer"; import { cn } from "@/lib/utils"; @@ -67,7 +67,7 @@ function Column({ isFirst: boolean; isLast: boolean; }) { - const { focused, focus, close, splitRight, getDoc } = useWorkspace(); + const { focused, focus, close, splitRight } = useWorkspace(); const active = focused && column.tabs.includes(focused) ? focused : column.tabs[0]; @@ -107,15 +107,11 @@ function Column({
{column.tabs.map((id) => { - const isActive = id === active; - // The active tab renders. Editable drafts also stay mounted (hidden) - // when inactive so the agent's editor stays registered while you read - // a source on top — otherwise its tool calls hit an unmounted editor. - // Read-only tabs unmount when inactive (cheap to re-render; PDFs are - // heavy to keep alive). - if (!isActive && !getDoc(id)?.editable) return null; + // Only the active tab renders — the draft lives on disk and Patrick + // edits it server-side, so nothing needs to stay mounted behind it. + if (id !== active) return null; return ( -
+
); @@ -205,6 +201,5 @@ function DocContent({ id }: { id: string }) { } if (doc.kind === "pdf") return ; if (doc.kind === "text") return ; - // key per file so autosave + buffer state never leak across tab switches. - return ; + return ; } diff --git a/apps/frontend/src/components/workspace/docx-viewer.tsx b/apps/frontend/src/components/workspace/docx-viewer.tsx deleted file mode 100644 index 67a3d511..00000000 --- a/apps/frontend/src/components/workspace/docx-viewer.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { DocxEditor, type DocxEditorRef } from "@eigenpal/docx-editor-react"; -import "@eigenpal/docx-editor-react/styles/editor.css"; -import { estimateTextTokens } from "@patrick/shared"; -import { InfoTooltip } from "@patrick/ui/components/tooltip"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { tasksApi } from "@/api/tasks"; -import { Patrick } from "@/components/patrick"; -import { SaveStatus } from "@/components/save-status"; -import { useTheme } from "@/components/theme-provider"; -import { useAutosave } from "@/hooks/use-autosave"; -import { useRegisterEditor } from "@/lib/active-editor"; -import { useActiveTask } from "@/lib/active-task"; -import { openExternal } from "@/lib/desktop"; -import { recordDocSize } from "@/lib/doc-size"; -import { formatTokens } from "@/lib/format"; -import { ZoomPill } from "./zoom-pill"; - -// Centered drawing mark — the package's loadingIndicator slot renders its child -// raw (top-left), so it must center itself; also used for our pre-fetch state. -function DocxLoading() { - return ( -
- -
- ); -} - -/** - * Renders a .docx via @eigenpal/docx-editor (ProseMirror). Originals open - * read-only; Patrick-owned docs are editable and autosave their bytes back to - * the folder. Patrick-driven tracked changes (the -agents package) come later. - */ -export function DocxViewer({ - filename, - editable, -}: { - filename: string; - editable: boolean; -}) { - const { activeTaskId } = useActiveTask(); - const { resolvedTheme } = useTheme(); - const editorRef = useRef(null); - const [buffer, setBuffer] = useState(null); - const [error, setError] = useState(false); - - // Bumped on every editor mutation; drives the debounced autosave below. - const [rev, setRev] = useState(0); - - const save = useCallback(async () => { - const buf = await editorRef.current?.save(); - if (buf) await tasksApi.saveFile(activeTaskId ?? "", filename, buf); - }, [activeTaskId, filename]); - - const { status } = useAutosave(rev, save, 1000); - - // Editable docs register so Patrick (in the chat panel) can drive tool calls - // against this live editor; the registry resolves the focused one. - useRegisterEditor(filename, editorRef, editable); - - useEffect(() => { - let cancelled = false; - setBuffer(null); - setError(false); - fetch(tasksApi.fileUrl(activeTaskId ?? "", filename)) - .then((r) => r.arrayBuffer()) - .then((buf) => { - if (!cancelled) setBuffer(buf); - }) - .catch(() => { - if (!cancelled) setError(true); - }); - return () => { - cancelled = true; - }; - }, [activeTaskId, filename]); - - if (error) { - return ( -
- Couldn't load this document. -
- ); - } - if (!buffer) { - return ; - } - - // readOnly strips the editor chrome (incl. its zoom widget), so the viewer - // gets its own floating zoom pill — mirroring the PDF viewer. - if (!editable) return ; - - return ( -
-
- setRev((r) => r + 1)} - onOpenLink={openExternal} - renderTitleBarRight={() => } - loadingIndicator={} - /> -
- -
- ); -} - -function ReadOnlyDocx({ - buffer, - filename, -}: { - buffer: ArrayBuffer; - filename: string; -}) { - const { activeTaskId } = useActiveTask(); - const { resolvedTheme } = useTheme(); - const ref = useRef(null); - const [tokens, setTokens] = useState(null); - - // One-shot: poll until the document agent is available, then record the - // character count (drives the token estimate so the context control can cost - // this source) and stop the timer. - useEffect(() => { - const id = setInterval(() => { - const ed = ref.current; - if (!ed) return; - const chars = ed.getAgent()?.getCharacterCount(true); - if (chars && chars > 0) { - setTokens(estimateTextTokens(chars)); - recordDocSize(activeTaskId ?? "", filename, { chars }); - clearInterval(id); - } - }, 400); - return () => clearInterval(id); - }, [activeTaskId, filename]); - - return ( -
-
- } - /> -
- - - {tokens != null && ( - <> - - - - ~{formatTokens(tokens)} tokens - - - - )} - -
- ); -} diff --git a/apps/frontend/src/components/workspace/draft-panel.tsx b/apps/frontend/src/components/workspace/draft-panel.tsx new file mode 100644 index 00000000..cea862a2 --- /dev/null +++ b/apps/frontend/src/components/workspace/draft-panel.tsx @@ -0,0 +1,227 @@ +import type { DraftStatus } from "@patrick/shared"; +import { Button } from "@patrick/ui/components/button"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ExternalLink, MessageSquareText, X } from "lucide-react"; +import { useEffect } from "react"; +import { tasksApi } from "@/api/tasks"; +import { Patrick } from "@/components/patrick"; +import { useActiveTask } from "@/lib/active-task"; +import { recordDocSize } from "@/lib/doc-size"; +import { cn } from "@/lib/utils"; + +// The .docx surface after the editor teardown: Patrick edits the file on disk +// (headless tracked changes); Word is where the attorney reads, writes, and +// accepts/rejects. In-app, a draft is a live text preview + the dance status — +// deliberately not a Word imitation. "Save = talk to Patrick. Close = let +// Patrick write." + +function useDraftStatus(filename: string, enabled: boolean) { + const { activeTaskId } = useActiveTask(); + return useQuery({ + queryKey: ["tasks", activeTaskId, "draft-status", filename], + queryFn: () => tasksApi.draftStatus(activeTaskId ?? "", filename), + enabled: enabled && !!activeTaskId, + refetchInterval: 2000, + }); +} + +export function DraftPanel({ + filename, + editable, +}: { + filename: string; + editable: boolean; +}) { + const { activeTaskId } = useActiveTask(); + const queryClient = useQueryClient(); + const { data: status } = useDraftStatus(filename, editable); + + const { + data: doc, + isError, + isPending, + } = useQuery({ + queryKey: ["tasks", activeTaskId, "docx-text", filename], + queryFn: () => tasksApi.docxText(activeTaskId ?? "", filename), + enabled: !!activeTaskId, + }); + + // A Word-side save (or a landed parked edit) bumps lastSavedMs — refresh the + // preview so what's on screen tracks what's on disk. + const lastSavedMs = status?.lastSavedMs; + useEffect(() => { + if (lastSavedMs == null) return; + queryClient.invalidateQueries({ + queryKey: ["tasks", activeTaskId, "docx-text", filename], + }); + }, [lastSavedMs, activeTaskId, filename, queryClient]); + + // Feed the context control's token estimate for read-only docx sources + // (deleted-run text doesn't count — it's gone once accepted). + const chars = + doc?.paragraphs.reduce( + (n, p) => + n + + p.runs.reduce((m, r) => (r.kind === "del" ? m : m + r.text.length), 0), + 0, + ) ?? 0; + useEffect(() => { + if (doc && !editable) + recordDocSize(activeTaskId ?? "", filename, { chars }); + }, [doc, editable, activeTaskId, filename, chars]); + + if (isError) + return ( +
+ Couldn't read this document. +
+ ); + if (isPending || !doc) + return ( +
+ +
+ ); + + return ( +
+ {editable && } +
+
+ {doc.paragraphs.every((p) => !paragraphHasText(p)) ? ( +

+ This document is empty — ask Patrick to start drafting, or open it + in Word. +

+ ) : ( + doc.paragraphs.map((p) => + paragraphHasText(p) ? ( +

+ {/* Pending redlines render as real marks; accept/reject + still happens in Word's review pane. */} + {renderRuns(p.runs)} +

+ ) : null, + ) + )} +
+
+
+ ); +} + +function paragraphHasText(p: { + runs: { text: string; kind: string }[]; +}): boolean { + return p.runs.some((r) => r.text.trim()); +} + +// Keys derive from each run's kind + character offset — stable for a given +// fetched paragraph, no array-index keys. +function renderRuns(runs: { text: string; kind: "text" | "ins" | "del" }[]) { + let offset = 0; + return runs.map((r) => { + const key = `${r.kind}@${offset}`; + offset += r.text.length; + if (r.kind === "ins") + return ( + + {r.text} + + ); + if (r.kind === "del") + return ( + + {r.text} + + ); + return {r.text}; + }); +} + +function DraftStatusBar({ + filename, + status, +}: { + filename: string; + status: DraftStatus | undefined; +}) { + const { activeTaskId } = useActiveTask(); + const queryClient = useQueryClient(); + const open = status?.openInEditor ?? false; + const parked = status?.parkedEdits ?? 0; + + return ( +
+
+ + + {open + ? parked > 0 + ? `Open in Word — ${parked} edit${parked === 1 ? "" : "s"} waiting (close it and they'll appear)` + : "Open in Word — Patrick will apply edits when you close it" + : "Closed — Patrick can edit right now"} + + + +
+ {status?.mentions.map((m) => ( +
+ + + {m.author}{" "} + commented: {m.text} — mention it to Patrick in chat and it'll + respond. + +
+ ))} + {(status?.failures.length ?? 0) > 0 && ( +
+ + {status?.failures.map((f) => ( + + A parked edit couldn't be applied: {f} + + ))} + + +
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/workspace/zoom-pill.tsx b/apps/frontend/src/components/workspace/zoom-pill.tsx deleted file mode 100644 index 1084223b..00000000 --- a/apps/frontend/src/components/workspace/zoom-pill.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { DocxEditorRef } from "@eigenpal/docx-editor-react"; -import { Button } from "@patrick/ui/components/button"; -import { Minus, Plus, Search } from "lucide-react"; -import { type ReactNode, type RefObject, useEffect, useState } from "react"; - -const MIN_ZOOM = 0.5; -const MAX_ZOOM = 2.5; -const ZOOM_STEP = 0.1; - -/** - * Floating zoom + page-count pill, bottom-center. The editor exposes no - * page-change event, so we poll current/total page; zoom drives the editor via - * its imperative `setZoom`. Shared by the editable and read-only viewers (the - * toolbar carries no zoom widget). `children` appends extra readouts (the - * read-only viewer's token estimate). - */ -export function ZoomPill({ - editorRef, - children, -}: { - editorRef: RefObject; - children?: ReactNode; -}) { - const [zoom, setZoom] = useState(1); - const [page, setPage] = useState(1); - const [pages, setPages] = useState(0); - - useEffect(() => { - const id = setInterval(() => { - const ed = editorRef.current; - if (!ed) return; - setPage(ed.getCurrentPage() || 1); - setPages(ed.getTotalPages() || 0); - }, 400); - return () => clearInterval(id); - }, [editorRef]); - - const apply = (next: number) => { - const z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, +next.toFixed(2))); - editorRef.current?.setZoom(z); - setZoom(z); - }; - - return ( -
- - - {pages > 0 && ( - <> - - {page} / {pages} - - - - )} - - - {Math.round(zoom * 100)}% - - - {children} -
- ); -} diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 2461f062..af2b5a6e 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -10,12 +10,6 @@ scans the app's own tree by default). */ @source "../../../packages/ui/src"; -/* The vendored docx editor is consumed from source; this app's Tailwind - generates its utility classes, which live in the React adapter's components. - The editor's chrome stylesheet + `--docx-*` tokens come from - docx-editor-react/src/styles/editor.css, imported where the editor mounts. */ -@source "../../../packages/docx-editor-react/src"; - /* radix data-state variants, no-scrollbar utility, accordion keyframes */ @theme inline { @keyframes accordion-down { @@ -202,9 +196,6 @@ .tiptap-prose { @apply outline-none; } -/* docx-editor's CSS ships a global `[contenteditable=true]{caret-color:#000}` - (same specificity as a class, wins by load order). Out-specify it so our - caret follows the theme and stays visible in dark mode. */ .tiptap-prose[contenteditable="true"] { caret-color: var(--foreground); } diff --git a/apps/frontend/src/lib/active-editor.tsx b/apps/frontend/src/lib/active-editor.tsx deleted file mode 100644 index 38ce6334..00000000 --- a/apps/frontend/src/lib/active-editor.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { DocxEditorRef } from "@eigenpal/docx-editor-react"; -import { - createContext, - type ReactNode, - type RefObject, - useCallback, - useContext, - useEffect, - useMemo, - useRef, -} from "react"; - -// The editable .docx editors live in the viewer panel; Patrick lives in the -// chat panel. This registry bridges them: each editable DocxViewer registers its -// ref by document id, and the chat resolves the focused editable editor to drive -// tool calls against. Keyed by filename (a workspace doc id). -type Registry = { - register: (id: string, ref: RefObject) => void; - unregister: (id: string) => void; - getEditor: (id: string | null | undefined) => DocxEditorRef | null; -}; - -const Context = createContext(null); - -export function ActiveEditorProvider({ children }: { children: ReactNode }) { - const refs = useRef(new Map>()); - - const register = useCallback( - (id: string, ref: RefObject) => { - refs.current.set(id, ref); - }, - [], - ); - const unregister = useCallback((id: string) => { - refs.current.delete(id); - }, []); - const getEditor = useCallback((id: string | null | undefined) => { - return id ? (refs.current.get(id)?.current ?? null) : null; - }, []); - - const value = useMemo( - () => ({ register, unregister, getEditor }), - [register, unregister, getEditor], - ); - - return {children}; -} - -function useEditorRegistry(): Registry { - const ctx = useContext(Context); - if (!ctx) - throw new Error( - "useEditorRegistry must be used within ActiveEditorProvider", - ); - return ctx; -} - -/** Register an editable editor's ref while it's mounted (no-op when disabled). */ -export function useRegisterEditor( - id: string, - ref: RefObject, - enabled: boolean, -): void { - const registry = useEditorRegistry(); - useEffect(() => { - if (!enabled) return; - registry.register(id, ref); - return () => registry.unregister(id); - }, [id, ref, enabled, registry]); -} - -/** Resolve once the editor for `id` is mounted AND its document agent is ready - * to take tool calls. Used to gate a freshly-created/unlocked draft: opening it - * mounts the editor and loads its bytes asynchronously, so the agent's first - * edit would otherwise race the editor and silently fail. Resolves `false` on - * timeout (the caller proceeds anyway — worst case a retry). */ -export function useEditorReadiness(): ( - id: string, - timeoutMs?: number, -) => Promise { - const registry = useEditorRegistry(); - return useCallback( - (id: string, timeoutMs = 5000) => - new Promise((resolve) => { - const start = Date.now(); - const tick = () => { - if (registry.getEditor(id)?.getAgent()) return resolve(true); - if (Date.now() - start > timeoutMs) return resolve(false); - setTimeout(tick, 80); - }; - tick(); - }), - [registry], - ); -} - -/** A stable ref whose `.current` always resolves to the editor for `id` right - * now — survives editors mounting/unmounting as tabs change. */ -export function useEditorRefFor( - id: string | null | undefined, -): RefObject { - const registry = useEditorRegistry(); - const idRef = useRef(id); - idRef.current = id; - return useMemo( - () => ({ - get current() { - return registry.getEditor(idRef.current); - }, - }), - [registry], - ); -} diff --git a/apps/frontend/src/lib/desktop.ts b/apps/frontend/src/lib/desktop.ts index 3199c8af..02375241 100644 --- a/apps/frontend/src/lib/desktop.ts +++ b/apps/frontend/src/lib/desktop.ts @@ -1,5 +1,4 @@ import { open } from "@tauri-apps/plugin-dialog"; -import { open as shellOpen } from "@tauri-apps/plugin-shell"; /** * Desktop (Tauri) helpers. The frontend is shared with the browser (web/cloud @@ -23,19 +22,3 @@ export async function pickFolder(): Promise { }); return typeof selected === "string" ? selected : null; } - -/** - * Open an external URL in the system browser on desktop (Tauri can't follow - * `window.open`/`target=_blank` out of the webview), or a new tab on web. - */ -export function openExternal(url: string): void { - if (isTauri()) { - shellOpen(url).catch((err) => { - // A rejected open (scheme outside shell:allow-open, malformed href) would - // otherwise be a silent unhandled rejection. - console.error("Failed to open external URL:", url, err); - }); - } else { - window.open(url, "_blank", "noopener,noreferrer"); - } -} diff --git a/apps/frontend/src/lib/workspace.tsx b/apps/frontend/src/lib/workspace.tsx index 3b99ce20..828730ad 100644 --- a/apps/frontend/src/lib/workspace.tsx +++ b/apps/frontend/src/lib/workspace.tsx @@ -1,4 +1,4 @@ -import { type ChartType, docKind } from "@patrick/shared"; +import { type ChartType, docKind, isEditableDoc } from "@patrick/shared"; import { createContext, type Dispatch, @@ -84,7 +84,7 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { id: d.filename, label: d.filename, kind, - editable: kind === "docx" && !!d.createdInPatrick, + editable: isEditableDoc(d), suggestions: d.suggestions, }); } diff --git a/apps/frontend/src/routes/_app.tsx b/apps/frontend/src/routes/_app.tsx index 12745f77..07d82113 100644 --- a/apps/frontend/src/routes/_app.tsx +++ b/apps/frontend/src/routes/_app.tsx @@ -9,7 +9,6 @@ import { AppSidebar } from "@/components/shell/app-sidebar"; import { useTheme } from "@/components/theme-provider"; import { AgentChat } from "@/components/workspace/agent-chat"; import { useProfile } from "@/hooks/use-profiles"; -import { ActiveEditorProvider } from "@/lib/active-editor"; import { useActiveProfile } from "@/lib/active-profile"; import { applyAppearance } from "@/lib/appearance"; import { LayoutProvider, useLayout } from "@/lib/layout"; @@ -30,9 +29,7 @@ function AppLayout() { - - - + diff --git a/biome.json b/biome.json index 8e563595..a41d68f7 100644 --- a/biome.json +++ b/biome.json @@ -14,6 +14,7 @@ "!packages/law/data", "!packages/benchmarking/data", "!e2e/fixtures", + "!spikes", "!packages/docx-editor-core", "!packages/docx-editor-agents", "!packages/docx-editor-react" diff --git a/e2e/fixtures/uspto-office-action.docx b/e2e/fixtures/uspto-office-action.docx new file mode 100644 index 00000000..4ef9a8ea Binary files /dev/null and b/e2e/fixtures/uspto-office-action.docx differ diff --git a/knip.config.ts b/knip.config.ts index c877c278..b8a10e7c 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -14,6 +14,8 @@ const config: KnipConfig = { "packages/docx-editor-agents", "packages/docx-editor-react", ], + // Throwaway proof-of-concept scripts (run by hand with bun), not app code. + ignore: ["spikes/**"], workspaces: { "apps/frontend": { entry: [ diff --git a/living-docs/editor-teardown.md b/living-docs/editor-teardown.md new file mode 100644 index 00000000..9e84ba54 --- /dev/null +++ b/living-docs/editor-teardown.md @@ -0,0 +1,71 @@ +# Editor teardown & headless-redline integration + +> **Status:** PR 1 (engine + dance + tool swap + DraftPanel) is CODE-COMPLETE on +> `spike/docx-redline-js` — check green, 1,758 tests pass, new routes +> smoke-tested live. Remaining before PR 2 (package deletion): in-app +> end-to-end run with a real chat, and the Windows Word checkpoint. + +**Decision (final):** the vendored docx editor goes wholesale — all three `packages/docx-editor-*` packages. Patrick edits `.docx` on disk via headless reconciliation redlines (`@ansonlai/docx-redline-js`, MIT); Word/LibreOffice is the viewing + review surface. Spikes proving both halves live in `spikes/docx-redline/` (branch `spike/docx-redline-js`). + +## Current integration surface (measured) + +The editor's reach outside its own packages is 5 files + config: + +| Site | Uses | Fate | +|---|---|---| +| `apps/api/src/lib/ai/chat.ts` | `getAiSdkTools()` (7 no-execute tool schemas), `DocxReviewer` (docx→text for pinned context) | replace with server-executed draft tools + own text extractor | +| `apps/frontend/.../docx-viewer.tsx` (167) | `DocxEditor` mount + `editor.css` | replace with DraftPanel (status + text preview) | +| `apps/frontend/.../agent-chat.tsx` (uses ~40 of 1178 lines) | `useDocxAgentTools` executor, `DocxEditorRef` | delete the docx branch of `onToolCall`; keep HITL handling | +| `apps/frontend/src/lib/active-editor.tsx` (113) | editor-ref registry | delete; `activeDraft` (a filename) survives as-is | +| `apps/frontend/.../zoom-pill.tsx` (85) | `DocxEditorRef` zoom | docx portion deleted (PDF zoom stays) | + +Helpful facts: `createBlankDocument` copies `assets/blank.docx` (no editor dep); the api already has JSZip + `lib/docx.ts` (paraId backfill); docx drafts are **not** searchable sources today (`doc-index.ts` returns null) — nothing to unwind in search; citation-nav for docx was never built. + +## Target architecture + +- **Engine adapter** — `apps/api/src/lib/docx/redline.ts`, the ONLY import point for `@ansonlai/docx-redline-js` (pinned; later a patched fork): + - `readDraft(bytes)` → paragraphs `[{ index, text }]` (own JSZip walk; also replaces `DocxReviewer.getContentAsText`) + - `applyRedline(bytes, { target, modified }, author)` → new bytes (+ ghost-`rPrChange` strip) + - `addComment(bytes, { paragraphIndex, textToFind, text })` → new bytes + - engine learnings encoded here: comments before redlines / anchor to untouched text; one paragraph per operation. +- **The dance** — `apps/api/src/lib/docx/dance.ts`, per-draft state machine (proven in `dance.ts --sim`): + - lock detection: Word `~$*.docx` + LO `.~lock.*#`, 1s poll of the task folder + - writes apply immediately when unlocked; **park** while locked; auto-apply on lock release + - save detection (mtime) → rescan comments; new `@Patrick` comments surface to the chat layer + - status endpoint `GET /tasks/:id/drafts/:file/status` → `{ open, parkedCount, lastSaved }` (TanStack Query polling; no new transport) +- **Agent tools** (server-executed — the no-execute client round-trip for docx dies entirely): + - `read_draft`, `edit_paragraph` (target text + new text → redline, parks if locked), `add_draft_comment`, `read_draft_comments` + - names + schemas single-sourced in `@patrick/shared` (`satisfies`-checked) like the chart tools; write-through re-read-before-apply like `mutateChart` + - `PATRICK_CAPABILITIES` rewritten: one paragraph per `edit_paragraph` call; the dance mental model ("Save = talk to Patrick, Close = let Patrick write") +- **Frontend** — `DocxViewer` → **DraftPanel**: draft status (open-in-editor / N parked redlines / last save), **[Open in Word]** (Tauri shell-open; download in browser mode), read-only extracted-text preview (text-viewer pattern). Chat renders `edit_paragraph` as a before/after card. HITL `createDraft` unchanged. + +## Slices (2 PRs) + +**PR 1 — swap the engine (app fully on the new path; editor packages present but unused):** +1. Engine adapter + tests (`bun:test`: generated minimal docx + real-docx fixture; accept→modified / reject→original round-trip assertions, ghost-strip, comment anchoring). +2. Dance service + tests (fake lock markers, parked-queue drain, mtime save detection). +3. chat.ts: drop `getAiSdkTools`/`DocxReviewer`, add the four server tools + allowlist, new capabilities prose. +4. Frontend: DraftPanel replaces DocxViewer; delete active-editor.tsx + docx executor branch + zoom docx bits + `editor.css` import; tool-call rendering. +5. Draft status polling + parked-edit surfacing; @Patrick comment events → chat notification. + +**PR 2 — the teardown (mechanical):** +1. Delete `packages/docx-editor-{core,agents,react}`, editor fixtures in `e2e/fixtures/`, editor entries in knip/tsconfig/bunfig configs, workspace deps. +2. CLAUDE.md: rewrite "The docx editor (vendored)" + context-model/testing sections; SCRATCHPAD.md: drop editor-only deferrals (H/F tracked changes etc.). +3. `pnpm check` + full `bun test` green (test count drops from ~1,756 to Patrick's own + new engine/dance suites). + +**Parallel (any time):** fork `AnsonLai/docx-redline-js` → patch the RECONSTRUCTION-mode `rPrChange` guard (port surgical mode's `changesNeeded` check) → pin Patrick to the fork commit → PR upstream. Adapter's ghost-strip covers the interim. + +**Checkpoint before PR 2 merges:** Michael opens real outputs in Windows Word (not just LO/GDocs) — redlines accept/reject, comments render, no repair dialog. + +## What we lose (accepted) / risks + +- In-app redline view + accept/reject → Word's review pane (better for attorneys). Preview shows latest *plain* text; rendering ins/del markers in the preview is a cheap later add. +- Multi-paragraph rewrites = N tool calls (already the practiced pattern). +- Tables/lists: engine claims support — treat as untested until exercised; headers/footers: comments/edits out of scope (parity with old editor's render-only). +- Same-paragraph collision (attorney edited the paragraph Patrick is rewriting): re-read-before-apply detects it; surface as a chat card, never clobber. +- Word-lock semantics validated on LO only so far — the Windows Word checkpoint above gates the final teardown. + +## Open questions + +1. Commit one public USPTO office action as a test fixture? (public record, ideal real-world corpus) +2. DraftPanel v1: plain text preview only, or also a "recent redlines" list derived from chat history? diff --git a/packages/shared/src/draft.ts b/packages/shared/src/draft.ts new file mode 100644 index 00000000..848a59e6 --- /dev/null +++ b/packages/shared/src/draft.ts @@ -0,0 +1,48 @@ +// The draft-editing contract: Patrick edits the active .docx on disk as native +// tracked changes (headless — no in-app editor); the attorney reviews and +// accepts/rejects them in Word. Tool names live here so the server tools, the +// chat UI's tool routing, and the prompt can't drift apart. + +export const READ_DRAFT_TOOL = "read_draft"; +export const EDIT_PARAGRAPH_TOOL = "edit_paragraph"; +export const ADD_DRAFT_COMMENT_TOOL = "add_draft_comment"; +export const READ_DRAFT_COMMENTS_TOOL = "read_draft_comments"; + +/** Tools that write to the draft file (the UI refreshes the draft panel on these). */ +export const MUTATING_DRAFT_TOOLS = [ + EDIT_PARAGRAPH_TOOL, + ADD_DRAFT_COMMENT_TOOL, +] as const; + +export const DRAFT_TOOL_NAMES = [ + READ_DRAFT_TOOL, + READ_DRAFT_COMMENTS_TOOL, + ...MUTATING_DRAFT_TOOLS, +] as const; + +/** A comment living in the draft (word/comments.xml). */ +export type DraftComment = { + id: string; + author: string; + text: string; +}; + +/** + * The dance, observable: whether a Word/LibreOffice lock is held on the draft, + * how many of Patrick's edits are parked waiting for the write window, when the + * file last changed on disk, and any attorney comments addressed to Patrick + * (`@Patrick …`) currently in the document. + */ +export type DraftStatus = { + exists: boolean; + /** A Word (`~$…`) or LibreOffice (`.~lock.…#`) lock marker is present. */ + openInEditor: boolean; + /** Redlines/comments computed but waiting for the draft to be closed. */ + parkedEdits: number; + /** mtime of the draft file (ms), null when it doesn't exist. */ + lastSavedMs: number | null; + /** Non-Patrick comments in the draft that mention @Patrick. */ + mentions: DraftComment[]; + /** Parked edits that later failed to apply (e.g. the paragraph changed). */ + failures: string[]; +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bc7985a9..f8b7876a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,7 @@ export * from "./ai-models"; export * from "./chart"; export * from "./chat"; export * from "./claim-prompts"; +export * from "./draft"; export * from "./law"; export * from "./match"; export * from "./patrick-docs.generated"; diff --git a/packages/shared/src/prompt.ts b/packages/shared/src/prompt.ts index a5ab3983..a14c3ffa 100644 --- a/packages/shared/src/prompt.ts +++ b/packages/shared/src/prompt.ts @@ -13,7 +13,7 @@ Your own abilities, so you can answer questions about yourself accurately: What you can do now: - Read the matter's pinned source documents (PDFs and Word files) that are in context. -- Draft and amend Word documents as native tracked changes the attorney accepts or rejects — always through the document tools, never by reproducing a document in chat. +- Draft and amend Word documents as native tracked changes the attorney reviews (accepts/rejects) in Microsoft Word — always through the draft tools (read_draft / edit_paragraph / add_draft_comment), one paragraph per edit, never by reproducing a document in chat. The draft is a file the attorney also opens in Word: your edits apply immediately while it's closed, and PARK to land the moment they close it — tell them that plainly when a tool reports parked ("close the draft and my redlines will appear"). Always read_draft fresh before editing; their saves change it. They can also write you comments in Word — a comment mentioning @Patrick is an instruction to you (read_draft_comments; reply with add_draft_comment or make the edit). - Fetch the full text (claims + description) of a published patent by number — EP, WO, US, and other English-language offices (GB, AU, CA…) — a cited reference, or a clean text version of a publication the attorney is prosecuting. EP/WO come from EPO Open Patent Services when a key is set, otherwise (and for everything else) from Google Patents; non-English offices (JP, KR, CN…) aren't supported yet. The attorney confirms; it's saved into the matter folder and pinned into context. - Look up European patent law VERBATIM via the ep_law_lookup tool: EPC Articles, Rules, and Rules relating to Fees; the EPO Guidelines for Examination (EPC and PCT-EPO); and the Case Law of the Boards of Appeal (the "white book" sections). It recalls the current text with the in-force date (and, for EPC provisions, footnotes including Enlarged Board decision pointers). Always look something up when it's cited or you're about to rely on it, and quote only from the tool's output — never recite from memory. When the attorney tags a citation (it arrives bracketed — [A54], [Guidelines G-VII 5.3], [CLBA II.E.1.3.1]), you MUST retrieve it with ep_law_lookup before answering. - Find the relevant sections of the EPO Guidelines, PCT-EPO Guidelines, or the Case Law of the Boards of Appeal for a topic or question via the find_law tool, when you don't already have the exact citation. It returns section citations from that body's contents; you MUST then retrieve their verbatim text with ep_law_lookup and ground your answer on that. Prefer find_law over the web for what's covered by these bodies — it searches the official text directly. @@ -26,7 +26,7 @@ Citation convention (always): when you point to a position in a PDF document, ci What you can't do yet — say so plainly if asked, and that it's planned: - Search the web or external databases for prior art — you can fetch a known EP/WO/US publication by number, but there's no keyword prior-art search yet. - Recall the full text of an individual Board of Appeal decision (only the Case Law book's summaries of them are available), or US/other-office examination guidance like the MPEP — search the web for those. -- Edit anything other than the Word draft in focus, or change the attorney's originals (they're read-only — offer an editable copy instead).`; +- Edit anything other than the Word draft in focus. The attorney's original documents are read-only until they unlock one for tracked-changes editing — propose that with requestUnlock (a pristine backup is kept, and every edit is a rejectable redline).`; export type PromptBlock = { label: string; content: string }; diff --git a/packages/shared/src/task.ts b/packages/shared/src/task.ts index 012688db..8193cb30 100644 --- a/packages/shared/src/task.ts +++ b/packages/shared/src/task.ts @@ -39,6 +39,9 @@ export type Document = { starred?: boolean; /** True if Patrick created this file (vs an original the attorney added). */ createdInPatrick?: boolean; + /** True if the attorney unlocked this original for in-place tracked-changes + * editing (a backup snapshot is kept under .patrick/backups). */ + unlocked?: boolean; /** True if Patrick fetched this from a data service (a retrieved publication). */ retrieved?: boolean; /** Where a retrieved publication came from (e.g. "EPO OPS", "Google Patents"). */ @@ -62,6 +65,7 @@ export type DocumentMeta = Record< excluded?: boolean; starred?: boolean; createdInPatrick?: boolean; + unlocked?: boolean; retrieved?: boolean; source?: string; contextMode?: "image" | "text"; @@ -69,6 +73,19 @@ export type DocumentMeta = Record< } >; +/** Editable ≡ a .docx Patrick owns (created) or the attorney unlocked in place. + * The single predicate for "Patrick may write tracked changes to this file". */ +export function isEditableDoc(d: { + filename: string; + createdInPatrick?: boolean; + unlocked?: boolean; +}): boolean { + return ( + d.filename.toLowerCase().endsWith(".docx") && + (!!d.createdInPatrick || !!d.unlocked) + ); +} + /** A word with its box, normalized to page fraction (0–1) so it scales to any * zoom; from OCR (positions the selectable overlay over a scan). */ export type ExtractedWord = { @@ -115,6 +132,7 @@ export function toDocumentMeta(documents: Document[]): DocumentMeta { if (d.excluded) entry.excluded = true; if (d.starred) entry.starred = true; if (d.createdInPatrick) entry.createdInPatrick = true; + if (d.unlocked) entry.unlocked = true; if (d.retrieved) entry.retrieved = true; if (d.source) entry.source = d.source; if (d.contextMode) entry.contextMode = d.contextMode; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f90efdab..50bb05c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,15 +29,18 @@ importers: '@ai-sdk/openai': specifier: ^4.0.0 version: 4.0.0(zod@4.4.3) - '@eigenpal/docx-editor-agents': - specifier: workspace:* - version: link:../../packages/docx-editor-agents + '@ansonlai/docx-redline-js': + specifier: github:AnsonLai/docx-redline-js#541a6cf + version: https://codeload.github.com/AnsonLai/docx-redline-js/tar.gz/541a6cfb97cc816cf1aecce506ac87765fde9056(@xmldom/xmldom@0.9.10) '@patrick/law': specifier: workspace:* version: link:../../packages/law '@patrick/shared': specifier: workspace:* version: link:../../packages/shared + '@xmldom/xmldom': + specifier: ^0.9.10 + version: 0.9.10 ai: specifier: ^7.0.2 version: 7.0.2(zod@4.4.3) @@ -81,12 +84,6 @@ importers: '@dnd-kit/react': specifier: ^0.5.0 version: 0.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@eigenpal/docx-editor-agents': - specifier: workspace:* - version: link:../../packages/docx-editor-agents - '@eigenpal/docx-editor-react': - specifier: workspace:* - version: link:../../packages/docx-editor-react '@fontsource-variable/hanken-grotesk': specifier: ^5.2.8 version: 5.2.8 @@ -114,9 +111,6 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.7.1 version: 2.7.1 - '@tauri-apps/plugin-shell': - specifier: ^2.3.5 - version: 2.3.5 '@tesseract.js-data/eng': specifier: ^1.0.0 version: 1.0.0 @@ -571,6 +565,16 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ansonlai/docx-redline-js@https://codeload.github.com/AnsonLai/docx-redline-js/tar.gz/541a6cfb97cc816cf1aecce506ac87765fde9056': + resolution: {gitHosted: true, integrity: sha512-I8xOULUGQzT2bAxYoenngeWOrvS+qU/3vf2G+Vqfffu1gC5v+JfSgIaGamfXXe9l6uQyKSIqYHxq2vkQI9WTeg==, tarball: https://codeload.github.com/AnsonLai/docx-redline-js/tar.gz/541a6cfb97cc816cf1aecce506ac87765fde9056} + version: 0.1.4 + engines: {node: '>=18.0.0'} + peerDependencies: + '@xmldom/xmldom': '>=0.8.0' + peerDependenciesMeta: + '@xmldom/xmldom': + optional: true + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -2740,9 +2744,6 @@ packages: '@tauri-apps/plugin-dialog@2.7.1': resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} - '@tauri-apps/plugin-shell@2.3.5': - resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} - '@tesseract.js-data/eng@1.0.0': resolution: {integrity: sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==} @@ -3499,6 +3500,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -5074,6 +5078,12 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ansonlai/docx-redline-js@https://codeload.github.com/AnsonLai/docx-redline-js/tar.gz/541a6cfb97cc816cf1aecce506ac87765fde9056(@xmldom/xmldom@0.9.10)': + dependencies: + diff-match-patch: 1.0.5 + optionalDependencies: + '@xmldom/xmldom': 0.9.10 + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -6952,10 +6962,6 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 - '@tauri-apps/plugin-shell@2.3.5': - dependencies: - '@tauri-apps/api': 2.11.1 - '@tesseract.js-data/eng@1.0.0': {} '@testing-library/dom@10.4.1': @@ -7755,6 +7761,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff-match-patch@1.0.5: {} + diff@8.0.4: {} docxtemplater@3.69.0: diff --git a/spikes/docx-redline/.gitignore b/spikes/docx-redline/.gitignore new file mode 100644 index 00000000..22b3f4eb --- /dev/null +++ b/spikes/docx-redline/.gitignore @@ -0,0 +1,3 @@ +node_modules +*.docx +.~lock.* diff --git a/spikes/docx-redline/bun.lock b/spikes/docx-redline/bun.lock new file mode 100644 index 00000000..1b43ebbb --- /dev/null +++ b/spikes/docx-redline/bun.lock @@ -0,0 +1,47 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "docx-redline-spike", + "dependencies": { + "@ansonlai/docx-redline-js": "github:AnsonLai/docx-redline-js", + "@xmldom/xmldom": "^0.9.10", + "jszip": "^3.10.1", + }, + }, + }, + "packages": { + "@ansonlai/docx-redline-js": ["@ansonlai/docx-redline-js@github:AnsonLai/docx-redline-js#541a6cf", { "dependencies": { "diff-match-patch": "^1.0.5" }, "peerDependencies": { "@xmldom/xmldom": ">=0.8.0" }, "optionalPeers": ["@xmldom/xmldom"] }, "AnsonLai-docx-redline-js-541a6cf", "sha512-u3GR5rry3Lr7iU6lrZ3qAwCXVoWdSj0RptZm3Kshjov+Gdez6GQap7IHn6/cjdDAoXipgIgErZbkvvQzDLSe1Q=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + } +} diff --git a/spikes/docx-redline/dance.ts b/spikes/docx-redline/dance.ts new file mode 100644 index 00000000..64bf2e6b --- /dev/null +++ b/spikes/docx-redline/dance.ts @@ -0,0 +1,178 @@ +// Spike 3: THE DANCE — canonical file + lock detection + parked writes + save-triggered comments. +// +// bun dance.ts interactive: open dance-draft.docx in LibreOffice/Word and play. +// stdin commands: edit | status | quit +// bun dance.ts --sim automated: fakes a LibreOffice lock marker to verify park/apply. +// +// Protocol under test: reads never block; writes only when unlocked (else park, apply on +// lock release); every save is scanned for new @Patrick comments, which queue a response. +import JSZip from "jszip"; +import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; +import { configureXmlProvider, setDefaultAuthor } from "@ansonlai/docx-redline-js"; +import { applyOperationToDocumentXml } from "@ansonlai/docx-redline-js/services/standalone-operation-runner.js"; +import { copyFileSync, existsSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; + +configureXmlProvider({ DOMParser, XMLSerializer }); +setDefaultAuthor("Patrick"); + +const SOURCE = "Non-Final Rejection(1).docx"; +const DRAFT = "dance-draft.docx"; +const LO_LOCK = `.~lock.${DRAFT}#`; +const sim = process.argv.includes("--sim"); + +const log = (msg: string) => console.log(`[${new Date().toLocaleTimeString()}] ${msg}`); + +// Word owner files start with ~$; LibreOffice uses .~lock.# +const isLocked = () => + readdirSync(".").some((f) => f === LO_LOCK || (f.startsWith("~$") && f.endsWith(".docx"))); + +const EDITS = [ + { + label: "amend the 'on the merits' sentence", + target: "This communication is a First Office Action Non-Final Rejection on the merits.", + modified: + "This communication is a First Office Action Non-Final Rejection on the merits, responsive to the amendment filed 12 May 2026.", + }, + { + label: "contest the Heinla/Iagnemma combination", + target: + "Heinla and Iagnemma both teach analogous arts of autonomous vehicle control using LIDAR and camera sensor and processing units.", + modified: + "Heinla and Iagnemma are directed to unrelated fields and do not teach autonomous vehicle control combining LIDAR and camera sensing.", + }, +]; + +type Edit = (typeof EDITS)[number]; +const parked: Edit[] = []; +const seenComments = new Set(); +let editCursor = 0; +let lastMtime = 0; +let prevLocked = false; + +// Patrick makes text edits only, so Patrick-authored rPrChange is always engine noise. +const stripGhosts = (xml: string) => + xml + .replace(/]*>[\s\S]*?<\/w:rPrChange>/g, "") + .replace(/]*\/>/g, ""); + +async function applyEdits(edits: Edit[]) { + const zip = await JSZip.loadAsync(await Bun.file(DRAFT).arrayBuffer()); + let xml = await zip.file("word/document.xml")!.async("string"); + for (const e of edits) { + const r = await applyOperationToDocumentXml( + xml, + { type: "redline", target: e.target, modified: e.modified }, + "Patrick", + ); + if (r.hasChanges) { + xml = r.documentXml; + log(`✅ redline applied: ${e.label}`); + } else { + log(`⚠️ target text not found (paragraph changed since queued?): ${e.label}`); + } + } + zip.file("word/document.xml", stripGhosts(xml)); + await Bun.write(DRAFT, await zip.generateAsync({ type: "uint8array" })); + lastMtime = statSync(DRAFT).mtimeMs; // our own write is not a user save +} + +async function queueNextEdit(why: string) { + const edit = EDITS[editCursor++]; + if (!edit) { + log("no scripted edits left"); + return; + } + if (isLocked()) { + parked.push(edit); + log(`🔒 draft is open — parked "${edit.label}" (${why}). Close the doc and I'll write.`); + } else { + log(`🔓 write window open — applying "${edit.label}" (${why})`); + await applyEdits([edit]); + log(" reopen the draft to review the redline"); + } +} + +async function scanComments(seedOnly = false) { + const zip = await JSZip.loadAsync(await Bun.file(DRAFT).arrayBuffer()); + const file = zip.file("word/comments.xml"); + if (!file) return; + const xml = await file.async("string"); + for (const m of xml.matchAll(/]*?w:author="([^"]*)"[^>]*?>([\s\S]*?)<\/w:comment>/g)) { + const [whole, author, body] = m; + const id = /w:id="(\d+)"/.exec(whole)?.[1] ?? whole.slice(0, 60); + if (seenComments.has(id) || author === "Patrick") continue; + seenComments.add(id); + if (seedOnly) continue; + const text = [...body.matchAll(/]*>([^<]*)<\/w:t>/g)].map((x) => x[1]).join(""); + if (/@patrick/i.test(text)) { + log(`💬 @Patrick comment from ${author}: "${text}"`); + await queueNextEdit("responding to your comment"); + } + } +} + +async function tick() { + const locked = isLocked(); + if (locked !== prevLocked) { + prevLocked = locked; + if (locked) { + log("🔒 draft opened in an editor — writes will park until you close it"); + } else { + log("🔓 draft closed — write window open"); + if (parked.length) await applyEdits(parked.splice(0)); + } + } + const mtime = statSync(DRAFT).mtimeMs; + if (mtime !== lastMtime) { + lastMtime = mtime; + log("💾 save detected — re-reading latest state"); + await scanComments(); + } +} + +// --- setup --- +if (sim && existsSync(DRAFT)) rmSync(DRAFT); +if (!existsSync(DRAFT)) { + copyFileSync(SOURCE, DRAFT); + log(`created ${DRAFT} from ${SOURCE}`); +} +lastMtime = statSync(DRAFT).mtimeMs; +prevLocked = isLocked(); +await scanComments(true); // seed: don't react to comments that pre-exist (examiner's own) + +if (sim) { + log("--- SIM: edit while unlocked → applies immediately ---"); + await queueNextEdit("sim"); + log("--- SIM: fake LibreOffice lock, edit while locked → parks ---"); + writeFileSync(LO_LOCK, "sim"); + await tick(); + await queueNextEdit("sim"); + if (parked.length !== 1) throw new Error("expected 1 parked edit"); + log("--- SIM: release lock → parked edit applies ---"); + rmSync(LO_LOCK); + await tick(); + if (parked.length !== 0) throw new Error("expected parked queue drained"); + const zip = await JSZip.loadAsync(await Bun.file(DRAFT).arrayBuffer()); + const xml = await zip.file("word/document.xml")!.async("string"); + const text = [...xml.matchAll(/]*>([^<]*)<\/w:t>/g)].map((x) => x[1]).join(""); + const ok = + text.includes("responsive to the amendment filed 12 May 2026") && + text.includes("unrelated fields") && + xml.includes(" `${t}`; +const documentXml = ` + +${paragraphs.map(p).join("")}`; + +const zip = new JSZip(); +zip.file( + "[Content_Types].xml", + ` + + + + +`, +); +zip.file( + "_rels/.rels", + ` + + +`, +); +zip.file( + "word/_rels/document.xml.rels", + ` +`, +); +zip.file("word/document.xml", documentXml); +await Bun.write("base.docx", await zip.generateAsync({ type: "uint8array" })); +console.log("1. base.docx written"); + +// --- 2. Redline edit: amend claim 1 (the paragraph-scoped reconciliation model) --- +const original = + "Claim 1. A widget comprising a housing, a sensor disposed within the housing, and a controller configured to receive signals from the sensor."; +const modified = + "Claim 1. A widget comprising a housing, a temperature sensor disposed within the housing, and a controller configured to receive and process signals from the temperature sensor."; + +const redlined = await applyOperationToDocumentXml( + await zip.file("word/document.xml")!.async("string"), + { type: "redline", target: original, modified }, + "Patrick", +); +console.log("2. redline applied:", redlined.status, "| hasChanges:", redlined.hasChanges); + +// Second edit with real deletions: rewrite the argument sentence +const arg = paragraphs[3]; +const argModified = + "The applicant submits that D1 fails to disclose a controller configured to receive and process signals from a temperature sensor."; +const redlined2 = await applyOperationToDocumentXml( + redlined.documentXml, + { type: "redline", target: arg, modified: argModified }, + "Patrick", +); +console.log("2b. second redline:", redlined2.status, "| hasChanges:", redlined2.hasChanges); + +// --- 3. Inject a comment anchored to a text range --- +const commented = await injectCommentsIntoOoxml(redlined2.documentXml, [ + { + paragraphIndex: 3, + textToFind: "temperature sensor", + commentContent: + "@Michael: D1 ¶[0032] arguably discloses a signal-receiving controller — consider narrowing to 'process' language here too.", + }, +]); +console.log("3. comment injected:", commented.commentsApplied, "applied | warnings:", JSON.stringify(commented.warnings)); + +let finalDocXml: string = commented.oxml ?? commented.documentXml ?? commented; +zip.file("word/document.xml", finalDocXml); +if (commented.commentsXml) { + await ensureCommentsArtifactsInZip(zip, commented.commentsXml); + console.log(" comments.xml merged into package"); +} + +// --- 4. Validate + write output --- +const validation = await validateDocxPackage(zip); +console.log("4. validateDocxPackage:", JSON.stringify(validation)); +await Bun.write("redlined.docx", await zip.generateAsync({ type: "uint8array" })); +console.log(" redlined.docx written"); + +// --- 5. Programmatic verification: accept → modified text; reject → original text --- +const textOf = (xml: string) => + [...xml.matchAll(/]*>([^<]*)<\/w:t>/g)].map((m) => m[1]).join(""); + +const accepted = await acceptTrackedChangesInOoxml(finalDocXml, { allAuthors: true }); +const acceptedXml = accepted.oxml ?? accepted.documentXml ?? accepted; +const rejected = await rejectTrackedChangesInOoxml(finalDocXml, { allAuthors: true }); +const rejectedXml = rejected.oxml ?? rejected.documentXml ?? rejected; + +console.log("5. accept contains modified text:", textOf(acceptedXml).includes("receive and process signals from the temperature sensor")); +console.log(" reject restores original claim:", textOf(rejectedXml).includes(original.slice(9)), "| original argument:", textOf(rejectedXml).includes("respectfully submits that the cited document D1")); +console.log(" redlined has w:ins:", finalDocXml.includes(" 0 ? commented.oxml : r2.documentXml; +zip.file("word/document.xml", finalXml); +if (commented.commentsXml) await ensureCommentsArtifactsInZip(zip, commented.commentsXml); +await Bun.write("uspto-redlined.docx", await zip.generateAsync({ type: "uint8array" })); + +// --- Collateral-damage metrics --- +const count = (s: string, re: RegExp) => (s.match(re) ?? []).length; +console.log("\n-- collateral metrics (before -> after) --"); +console.log("w:rPrChange:", count(before, /", count(finalXml, /", count(finalXml, /", count(finalXml, /", count(finalXml, / s.match(/][\s\S]*?<\/w:p>/g) ?? []; +const pb = parasOf(before); +const pa = parasOf(finalXml); +console.log("paragraph count:", pb.length, "->", pa.length); +let touched = 0; +for (let i = 0; i < Math.min(pb.length, pa.length); i++) if (pb[i] !== pa[i]) touched++; +console.log("paragraphs byte-modified:", touched, "(expect ~3: two edits + one comment anchor)"); + +// Accept/reject round-trip on the real doc +const textOf = (xml: string) => + [...xml.matchAll(/]*>([^<]*)<\/w:t>/g)].map((m) => m[1]).join(""); +const acc = acceptTrackedChangesInOoxml(finalXml, { allAuthors: true }); +const rej = rejectTrackedChangesInOoxml(finalXml, { allAuthors: true }); +const accXml = (acc as any).oxml ?? (acc as any).documentXml ?? acc; +const rejXml = (rej as any).oxml ?? (rej as any).documentXml ?? rej; +console.log("\naccept -> modified text present:", textOf(accXml).includes("responsive to the amendment filed 12 May 2026")); +console.log("reject -> original text restored:", textOf(rejXml).includes(target) && textOf(rejXml).includes(target2));