From d9af5b5668ad7a65768b988848857344012af488 Mon Sep 17 00:00:00 2001 From: Michael Hurhangee Date: Sat, 4 Jul 2026 11:27:33 +0100 Subject: [PATCH 01/10] spike: prove headless docx redlining with docx-redline-js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evaluates @ansonlai/docx-redline-js (MIT) as the tracked-changes engine for the post-editor direction: edit .docx on disk, review in Word, no in-app editor. spike.ts exercises redline + comment + accept/reject round-trips on a minimal generated docx; spike2.ts does the same on a real USPTO office action (bring your own .docx, gitignored) and measures collateral damage. Findings: runs under Bun with @xmldom/xmldom injected; edits confine to the target paragraphs; accept/reject round-trips are exact. One engine bug found: reconstruction mode stamps spurious w:rPrChange (ghost 'Format' revisions) on unchanged runs — strippable in a post-process, properly fixable upstream. --- spikes/docx-redline/.gitignore | 2 + spikes/docx-redline/bun.lock | 47 ++++++++++++ spikes/docx-redline/package.json | 10 +++ spikes/docx-redline/spike.ts | 118 +++++++++++++++++++++++++++++++ spikes/docx-redline/spike2.ts | 84 ++++++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 spikes/docx-redline/.gitignore create mode 100644 spikes/docx-redline/bun.lock create mode 100644 spikes/docx-redline/package.json create mode 100644 spikes/docx-redline/spike.ts create mode 100644 spikes/docx-redline/spike2.ts diff --git a/spikes/docx-redline/.gitignore b/spikes/docx-redline/.gitignore new file mode 100644 index 0000000..4f2c13a --- /dev/null +++ b/spikes/docx-redline/.gitignore @@ -0,0 +1,2 @@ +node_modules +*.docx diff --git a/spikes/docx-redline/bun.lock b/spikes/docx-redline/bun.lock new file mode 100644 index 0000000..1b43ebb --- /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/package.json b/spikes/docx-redline/package.json new file mode 100644 index 0000000..fc81839 --- /dev/null +++ b/spikes/docx-redline/package.json @@ -0,0 +1,10 @@ +{ + "name": "docx-redline-spike", + "private": true, + "type": "module", + "dependencies": { + "@ansonlai/docx-redline-js": "github:AnsonLai/docx-redline-js", + "@xmldom/xmldom": "^0.9.10", + "jszip": "^3.10.1" + } +} diff --git a/spikes/docx-redline/spike.ts b/spikes/docx-redline/spike.ts new file mode 100644 index 0000000..dd17178 --- /dev/null +++ b/spikes/docx-redline/spike.ts @@ -0,0 +1,118 @@ +// Spike: can docx-redline-js produce native Word tracked changes headlessly under Bun? +// Flow: build a minimal "createdInPatrick"-style docx → apply a redline edit → +// inject a comment → validate → verify accept/reject round-trips → write output files. +import JSZip from "jszip"; +import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; +import { + acceptTrackedChangesInOoxml, + configureXmlProvider, + ensureCommentsArtifactsInZip, + injectCommentsIntoOoxml, + rejectTrackedChangesInOoxml, + setDefaultAuthor, + validateDocxPackage, +} from "@ansonlai/docx-redline-js"; +import { applyOperationToDocumentXml } from "@ansonlai/docx-redline-js/services/standalone-operation-runner.js"; + +configureXmlProvider({ DOMParser, XMLSerializer }); +setDefaultAuthor("Patrick"); + +// --- 1. Build a minimal docx (stands in for a Patrick-generated draft) --- +const paragraphs = [ + "Amended Claims — Response to Communication under Article 94(3) EPC", + "Claim 1. A widget comprising a housing, a sensor disposed within the housing, and a controller configured to receive signals from the sensor.", + "Claim 2. The widget of claim 1, wherein the sensor is a temperature sensor.", + "The applicant respectfully submits that the cited document D1 does not disclose a controller configured to receive signals from the sensor.", +]; + +const p = (t: string) => `${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)); From 63547a09f9649a3c023cf1a6063406f589ad9c90 Mon Sep 17 00:00:00 2001 From: Michael Hurhangee Date: Sat, 4 Jul 2026 11:40:02 +0100 Subject: [PATCH 02/10] =?UTF-8?q?spike:=20the=20DANCE=20=E2=80=94=20lock-a?= =?UTF-8?q?ware=20parked=20writes=20+=20save-triggered=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive mini-Patrick for the no-editor protocol: watches the canonical draft, detects Word (~$) / LibreOffice (.~lock) markers, applies redlines immediately when unlocked, parks them while open and applies on close, and scans each save for new @Patrick comments as an in-document command channel. --sim mode fakes the lock marker and verifies the park/apply machine. --- spikes/docx-redline/dance.ts | 178 +++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 spikes/docx-redline/dance.ts diff --git a/spikes/docx-redline/dance.ts b/spikes/docx-redline/dance.ts new file mode 100644 index 0000000..64bf2e6 --- /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(" Date: Sat, 4 Jul 2026 11:56:40 +0100 Subject: [PATCH 03/10] docs: plan the editor teardown + headless-redline integration Adds the living-doc plan (measured integration surface, target architecture, two-PR slicing, risks) and a public USPTO office action as the real-world docx fixture for the new engine's tests. --- e2e/fixtures/uspto-office-action.docx | Bin 0 -> 19170 bytes living-docs/editor-teardown.md | 66 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 e2e/fixtures/uspto-office-action.docx create mode 100644 living-docs/editor-teardown.md diff --git a/e2e/fixtures/uspto-office-action.docx b/e2e/fixtures/uspto-office-action.docx new file mode 100644 index 0000000000000000000000000000000000000000..4ef9a8eac4cfd74a3562edea35a4ff48951f3a23 GIT binary patch literal 19170 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 CPdHcr literal 0 HcmV?d00001 diff --git a/living-docs/editor-teardown.md b/living-docs/editor-teardown.md new file mode 100644 index 0000000..7036849 --- /dev/null +++ b/living-docs/editor-teardown.md @@ -0,0 +1,66 @@ +# Editor teardown & headless-redline integration + +**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? From 358af8755915fa15614d961b8f21acf7c53a0718 Mon Sep 17 00:00:00 2001 From: Michael Hurhangee Date: Sat, 4 Jul 2026 12:08:14 +0100 Subject: [PATCH 04/10] feat(api): headless docx redline engine + the dance service The adapter (lib/docx/redline.ts) is the only import point for @ansonlai/docx-redline-js and files off its measured sharp edges: it resolves the target paragraph itself (exact + unambiguous, so the engine's fuzzy matcher can never redline the wrong paragraph), supersedes Patrick's own pending revisions before a re-edit (redlines are original -> latest, never stacked), verifies the landed text before returning, and strips the engine's ghost format revisions. The dance (lib/docx/dance.ts) shares the draft with Word/LibreOffice by protocol: reads never block, writes apply immediately when the draft is closed and park while a lock marker is present, draining on a 1s tick (comments before redlines so anchors resolve). Status exposes lock state, parked count, @Patrick comment mentions, and failed parked edits. --- apps/api/package.json | 3 +- apps/api/src/lib/docx/dance.test.ts | 155 +++++++++++ apps/api/src/lib/docx/dance.ts | 176 +++++++++++++ apps/api/src/lib/docx/redline-js.d.ts | 19 ++ apps/api/src/lib/docx/redline.test.ts | 170 ++++++++++++ apps/api/src/lib/docx/redline.ts | 363 ++++++++++++++++++++++++++ packages/shared/src/draft.ts | 48 ++++ packages/shared/src/index.ts | 1 + pnpm-lock.yaml | 30 ++- 9 files changed, 961 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/lib/docx/dance.test.ts create mode 100644 apps/api/src/lib/docx/dance.ts create mode 100644 apps/api/src/lib/docx/redline-js.d.ts create mode 100644 apps/api/src/lib/docx/redline.test.ts create mode 100644 apps/api/src/lib/docx/redline.ts create mode 100644 packages/shared/src/draft.ts diff --git a/apps/api/package.json b/apps/api/package.json index 191c95e..821dfe2 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/docx/dance.test.ts b/apps/api/src/lib/docx/dance.test.ts new file mode 100644 index 0000000..648f460 --- /dev/null +++ b/apps/api/src/lib/docx/dance.test.ts @@ -0,0 +1,155 @@ +import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +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("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 0000000..3019102 --- /dev/null +++ b/apps/api/src/lib/docx/dance.ts @@ -0,0 +1,176 @@ +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { 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). +// - 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[] = []; + + constructor( + readonly folder: string, + readonly filename: string, + private author = REDLINE_AUTHOR, + ) {} + + private get path(): string { + return join(this.folder, this.filename); + } + + /** A Word (`~$…`) or LibreOffice (`.~lock.…#`) lock marker is present. */ + async isLocked(): Promise { + // Word's owner file replaces the filename's first two characters with ~$ + // (short names keep them), LibreOffice wraps the full name. + const markers = new Set([ + `.~lock.${this.filename}#`, + `~$${this.filename}`, + `~$${this.filename.slice(2)}`, + ]); + try { + return (await readdir(this.folder)).some((f) => markers.has(f)); + } catch { + return false; + } + } + + async status(): Promise { + let lastSavedMs: number | null = null; + let exists = true; + let mentions: DraftComment[] = []; + try { + lastSavedMs = (await stat(this.path)).mtimeMs; + } catch { + exists = false; + } + if (exists) { + try { + const comments = await listComments( + new Uint8Array(await readFile(this.path)), + ); + mentions = comments.filter( + (c) => c.author !== this.author && /@patrick/i.test(c.text), + ); + } 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], + }; + } + + /** Apply the op now if the draft is closed; park it for the tick if not. */ + async applyOrPark(op: DraftOp): Promise { + if (await this.isLocked()) { + this.parked.push(op); + return { status: "parked", parkedEdits: this.parked.length }; + } + 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}` }; + } + const result = + op.kind === "redline" + ? await applyRedline(bytes, op.edit, this.author) + : await addComment(bytes, op.request, this.author); + 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()) { + this.parked.push(op); + return { status: "parked", parkedEdits: this.parked.length }; + } + await writeFile(this.path, result.bytes); + 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 is recorded and surfaced via status; a re-lock + * mid-drain re-parks the remainder. + */ + async tick(): 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 `op`; keep the rest queued behind it, in order. + this.parked.push(...ops); + return; + } + } + } + + /** 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 0000000..e2d1372 --- /dev/null +++ b/apps/api/src/lib/docx/redline-js.d.ts @@ -0,0 +1,19 @@ +// The document-safe runner is a deep import the package doesn't ship types for. +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 0000000..15da23a --- /dev/null +++ b/apps/api/src/lib/docx/redline.test.ts @@ -0,0 +1,170 @@ +import { join } from "node:path"; +import { + acceptTrackedChangesInOoxml, + rejectTrackedChangesInOoxml, +} from "@ansonlai/docx-redline-js"; +import { describe, expect, test } from "bun:test"; +import JSZip from "jszip"; +import { + addComment, + applyRedline, + extractDocxText, + listComments, + readDraftParagraphs, +} 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); + return zip.file("word/document.xml")!.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"); + }); +}); + +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 0000000..62b4207 --- /dev/null +++ b/apps/api/src/lib/docx/redline.ts @@ -0,0 +1,363 @@ +import { + configureXmlProvider, + ensureCommentsArtifactsInZip, + injectCommentsIntoOoxml, + setDefaultAuthor, +} from "@ansonlai/docx-redline-js"; +import { applyOperationToDocumentXml } from "@ansonlai/docx-redline-js/services/standalone-operation-runner.js"; +import type { DraftComment } from "@patrick/shared"; +import { DOMParser, XMLSerializer } 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) => + new DOMParser().parseFromString(xml, "text/xml"); +const serializeXml = (node: Node) => new XMLSerializer().serializeToString(node); + +type XmlElement = ReturnType["documentElement"] & object; + +function paragraphsOf(doc: ReturnType): XmlElement[] { + const list = doc.getElementsByTagNameNS(W_NS, "p"); + const out: XmlElement[] = []; + for (let i = 0; i < list.length; i++) { + const p = list[i]; + if (p) out.push(p as XmlElement); + } + return out; +} + +function hasAncestor(node: Node, localName: string, until: Node): boolean { + for (let n = node.parentNode; n && n !== until; n = n.parentNode) { + if ( + n.nodeType === 1 && + (n as XmlElement).localName === localName && + n.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 walk = (node: Node) => { + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.nodeType !== 1) continue; + const el = c as XmlElement; + 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; + } + 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)); + return paragraphsOf(doc).map((p, i) => ({ + index: i + 1, + text: paragraphText(p, "accepted"), + hasRevisions: paragraphHasRevisions(p), + })); +} + +/** 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((p, i) => ({ p, i })) + .filter(({ p }) => norm(paragraphText(p, "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 { p, i } = matches[0] as { p: XmlElement; i: number }; + + // 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: the paragraph's as-if-accepted text must now be + // the requested text (same index, or anywhere if the edit split paragraphs). + const outXml = stripGhostFormatRevisions(result.documentXml, author); + const outDoc = parseXml(outXml); + const outParagraphs = paragraphsOf(outDoc); + const wanted = norm(edit.newText); + const atIndex = outParagraphs[i] + ? norm(paragraphText(outParagraphs[i] as XmlElement, "accepted")) + : ""; + const landed = + atIndex === wanted || + norm( + outParagraphs.map((op) => paragraphText(op, "accepted")).join(" "), + ).includes(wanted); + if (!landed) + return { + applied: false, + reason: + "the edit did not land as requested (engine mismatch) — nothing was changed", + }; + + return { + applied: true, + bytes: await writeDocumentXml(zip, outXml), + paragraphIndex: i + 1, + }; +} + +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 result = (await injectCommentsIntoOoxml( + await documentXmlOf(zip), + [ + { + 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/packages/shared/src/draft.ts b/packages/shared/src/draft.ts new file mode 100644 index 0000000..848a59e --- /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 bc7985a..f8b7876 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/pnpm-lock.yaml b/pnpm-lock.yaml index f90efda..abb0cab 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) @@ -571,6 +574,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==} @@ -3499,6 +3512,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 +5090,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 @@ -7755,6 +7777,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff-match-patch@1.0.5: {} + diff@8.0.4: {} docxtemplater@3.69.0: From 9c3d7fc436b54b221762f8722cc29fd36dbaf2e8 Mon Sep 17 00:00:00 2001 From: Michael Hurhangee Date: Sat, 4 Jul 2026 12:19:07 +0100 Subject: [PATCH 05/10] feat(api): draft tools replace the editor tool round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_draft / edit_paragraph / add_draft_comment / read_draft_comments — server-executed against the active draft on disk through the dance (names single-sourced in @patrick/shared). The seven no-execute editor tools and their client round-trip are gone; pinned-docx context extraction now uses the adapter's own text walk instead of DocxReviewer. New routes: draft-status (+ clear-failures), docx-text (the in-app preview), and open (OS opener for Word). Capabilities prose teaches the dance: save = talk to Patrick, close = let Patrick write. --- apps/api/src/lib/ai/chat.ts | 45 +++----- apps/api/src/lib/ai/draft-tools.ts | 143 ++++++++++++++++++++++++++ apps/api/src/lib/ai/prompt.test.ts | 2 +- apps/api/src/lib/ai/prompt.ts | 8 +- apps/api/src/lib/docx/dance.test.ts | 9 +- apps/api/src/lib/docx/dance.ts | 4 +- apps/api/src/lib/docx/redline.test.ts | 15 ++- apps/api/src/lib/docx/redline.ts | 32 ++++-- apps/api/src/routes/tasks.ts | 73 ++++++++++++- packages/shared/src/prompt.ts | 2 +- pnpm-lock.yaml | 16 --- 11 files changed, 274 insertions(+), 75 deletions(-) create mode 100644 apps/api/src/lib/ai/draft-tools.ts diff --git a/apps/api/src/lib/ai/chat.ts b/apps/api/src/lib/ai/chat.ts index 39cf3a6..13f86c6 100644 --- a/apps/api/src/lib/ai/chat.ts +++ b/apps/api/src/lib/ai/chat.ts @@ -1,7 +1,5 @@ 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, @@ -31,9 +29,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 +66,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; @@ -276,9 +264,9 @@ 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. const docxTextCache = new Map(); async function docxText(folder: string, filename: string): Promise { @@ -287,13 +275,7 @@ async function docxText(folder: string, filename: string): Promise { 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 { @@ -440,12 +422,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 +438,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, @@ -525,6 +507,7 @@ export async function handleChat(c: Context) { // 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 0000000..5e8f9d2 --- /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 5673ba2..732e2af 100644 --- a/apps/api/src/lib/ai/prompt.test.ts +++ b/apps/api/src/lib/ai/prompt.test.ts @@ -84,7 +84,7 @@ 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."); }); diff --git a/apps/api/src/lib/ai/prompt.ts b/apps/api/src/lib/ai/prompt.ts index f7db84d..3ea444b 100644 --- a/apps/api/src/lib/ai/prompt.ts +++ b/apps/api/src/lib/ai/prompt.ts @@ -21,7 +21,7 @@ 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( @@ -42,9 +42,9 @@ 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."); diff --git a/apps/api/src/lib/docx/dance.test.ts b/apps/api/src/lib/docx/dance.test.ts index 648f460..c98ba85 100644 --- a/apps/api/src/lib/docx/dance.test.ts +++ b/apps/api/src/lib/docx/dance.test.ts @@ -1,11 +1,16 @@ +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 { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { DraftDance } from "./dance"; import { extractDocxText, listComments } from "./redline"; -const FIXTURE = join(process.cwd(), "e2e", "fixtures", "uspto-office-action.docx"); +const FIXTURE = join( + process.cwd(), + "e2e", + "fixtures", + "uspto-office-action.docx", +); const DRAFT = "Response (Patrick).docx"; const TARGET = diff --git a/apps/api/src/lib/docx/dance.ts b/apps/api/src/lib/docx/dance.ts index 3019102..bbdf626 100644 --- a/apps/api/src/lib/docx/dance.ts +++ b/apps/api/src/lib/docx/dance.ts @@ -135,9 +135,7 @@ export class DraftDance { async tick(): 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, - ); + 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); diff --git a/apps/api/src/lib/docx/redline.test.ts b/apps/api/src/lib/docx/redline.test.ts index 15da23a..97055f3 100644 --- a/apps/api/src/lib/docx/redline.test.ts +++ b/apps/api/src/lib/docx/redline.test.ts @@ -1,9 +1,9 @@ +import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, } from "@ansonlai/docx-redline-js"; -import { describe, expect, test } from "bun:test"; import JSZip from "jszip"; import { addComment, @@ -14,14 +14,21 @@ import { } from "./redline"; // A real USPTO office action — styles, numbering, header, examiner comments. -const FIXTURE = join(process.cwd(), "e2e", "fixtures", "uspto-office-action.docx"); +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); - return zip.file("word/document.xml")!.async("string"); + const entry = zip.file("word/document.xml"); + if (!entry) throw new Error("no document.xml"); + return entry.async("string"); }; const textOf = (xml: string) => @@ -57,7 +64,7 @@ describe("applyRedline", () => { if (!result.applied) return; const xml = await documentXmlOf(result.bytes); - expect(xml).toContain(' { return entry.async("string"); } -const parseXml = (xml: string) => +const parseXml = (xml: string): XmlDocument => new DOMParser().parseFromString(xml, "text/xml"); -const serializeXml = (node: Node) => new XMLSerializer().serializeToString(node); - -type XmlElement = ReturnType["documentElement"] & object; +const serializeXml = (node: XmlNode) => + new XMLSerializer().serializeToString(node); -function paragraphsOf(doc: ReturnType): XmlElement[] { +function paragraphsOf(doc: XmlDocument): XmlElement[] { const list = doc.getElementsByTagNameNS(W_NS, "p"); const out: XmlElement[] = []; for (let i = 0; i < list.length; i++) { const p = list[i]; - if (p) out.push(p as XmlElement); + if (p) out.push(p); } return out; } -function hasAncestor(node: Node, localName: string, until: Node): boolean { +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.namespaceURI === W_NS + (n as XmlElement).namespaceURI === W_NS ) return true; } @@ -86,7 +95,7 @@ function hasAncestor(node: Node, localName: string, until: Node): boolean { /** 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 walk = (node: Node) => { + const walk = (node: XmlNode) => { for (let c = node.firstChild; c; c = c.nextSibling) { if (c.nodeType !== 1) continue; const el = c as XmlElement; @@ -147,7 +156,8 @@ const norm = (s: string) => s.replace(/\s+/g, " ").trim(); 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 byAuthor = (el: XmlElement) => + el.getAttributeNS(W_NS, "author") === author; const collect = (localName: string): XmlElement[] => { const list = p.getElementsByTagNameNS(W_NS, localName); diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index 36fcb44..a339df0 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -49,14 +49,16 @@ import { unlockDocumentCopy, writeDocumentMeta, } from "../lib/documents"; +import { danceFor } from "../lib/docx/dance"; +import { readDraftParagraphs } 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. @@ -467,6 +469,73 @@ 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 rendered as plain text (tracked changes as-if-accepted) — the +// in-app preview; the real document opens 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 readDraftParagraphs( + new Uint8Array(await file.arrayBuffer()), + ); + return c.json({ + paragraphs: paragraphs.map((p) => ({ + index: p.index, + text: p.text, + hasRevisions: p.hasRevisions, + })), + }); + } 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/packages/shared/src/prompt.ts b/packages/shared/src/prompt.ts index a5ab398..7c19d0f 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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abb0cab..50bb05c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,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 @@ -117,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 @@ -2753,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==} @@ -6974,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': From 8c514b24f4758c8c873847a1bd891d8c1aa9070f Mon Sep 17 00:00:00 2001 From: Michael Hurhangee Date: Sat, 4 Jul 2026 12:19:07 +0100 Subject: [PATCH 06/10] feat(frontend): DraftPanel replaces the in-app docx editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .docx tab is now a live text preview + the dance status bar (lock state, parked-edit count, @Patrick comment mentions, failed parked edits, Open in Word) — deliberately not a Word imitation; review happens in Word's own review pane. Deletes the editor mount (docx-viewer), the active-editor ref registry, and the zoom pill; drops the @eigenpal deps, the editor CSS @source, and the editor branch of onToolCall (draft tools resolve server-side). Spikes are lint/knip-exempt like other non-app code. --- apps/frontend/package.json | 3 - apps/frontend/src/api/tasks.ts | 31 ++- .../src/components/workspace/agent-chat.tsx | 52 ++--- .../workspace/chat-message-parts.tsx | 43 ++-- .../components/workspace/document-viewer.tsx | 19 +- .../src/components/workspace/docx-viewer.tsx | 167 --------------- .../src/components/workspace/draft-panel.tsx | 191 ++++++++++++++++++ .../src/components/workspace/zoom-pill.tsx | 85 -------- apps/frontend/src/index.css | 9 - apps/frontend/src/lib/active-editor.tsx | 113 ----------- apps/frontend/src/lib/desktop.ts | 17 -- apps/frontend/src/routes/_app.tsx | 5 +- biome.json | 1 + knip.config.ts | 2 + spikes/docx-redline/.gitignore | 1 + 15 files changed, 257 insertions(+), 482 deletions(-) delete mode 100644 apps/frontend/src/components/workspace/docx-viewer.tsx create mode 100644 apps/frontend/src/components/workspace/draft-panel.tsx delete mode 100644 apps/frontend/src/components/workspace/zoom-pill.tsx delete mode 100644 apps/frontend/src/lib/active-editor.tsx diff --git a/apps/frontend/package.json b/apps/frontend/package.json index e74cfc9..9fb4332 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 ce4fa78..b4bde6e 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, @@ -158,12 +159,26 @@ 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 plain paragraphs (tracked changes as-if-accepted) — the preview. */ + docxText: (id: string, filename: string) => + api.get<{ + paragraphs: { index: number; text: string; 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/workspace/agent-chat.tsx b/apps/frontend/src/components/workspace/agent-chat.tsx index 50b9bfa..c8f111d 100644 --- a/apps/frontend/src/components/workspace/agent-chat.tsx +++ b/apps/frontend/src/components/workspace/agent-chat.tsx @@ -1,9 +1,8 @@ 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, @@ -26,7 +25,6 @@ import { } from "ai"; import { ArrowUp, ChevronDown, Globe, Square } from "lucide-react"; import { - type RefObject, useCallback, useEffect, useLayoutEffect, @@ -52,7 +50,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,9 +70,8 @@ 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. @@ -89,6 +85,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 +248,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 @@ -270,18 +269,10 @@ function ChatSession({ return openEditableIds[0] ?? null; }); }, [focusedDoc, openEditableIds]); - const editorRef = useEditorRefFor(activeDraft); - const waitForEditor = useEditorReadiness(); - // 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,22 +386,12 @@ 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}` }, }); }, }); @@ -573,13 +554,10 @@ 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. createDraft: async (name) => { try { const res = await createDoc.mutateAsync(name); open(res.filename); - await waitForEditor(res.filename); return res.filename; } catch { return null; @@ -589,7 +567,6 @@ function ChatSession({ try { const res = await unlockDoc.mutateAsync(filename); open(res.filename); - await waitForEditor(res.filename); return res.filename; } catch { return null; @@ -651,7 +628,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 19286c4..49aae2a 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", diff --git a/apps/frontend/src/components/workspace/document-viewer.tsx b/apps/frontend/src/components/workspace/document-viewer.tsx index 79e72d2..bc2f889 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 67a3d51..0000000 --- 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 0000000..0999cc1 --- /dev/null +++ b/apps/frontend/src/components/workspace/draft-panel.tsx @@ -0,0 +1,191 @@ +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. + const chars = doc?.paragraphs.reduce((n, p) => n + p.text.length, 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.filter((p) => p.text.trim()).length === 0 ? ( +

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

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

+ {p.text} +

+ ) : null, + ) + )} +
+
+
+ ); +} + +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 1084223..0000000 --- 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 2461f06..af2b5a6 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 38ce633..0000000 --- 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 3199c8a..0237524 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/routes/_app.tsx b/apps/frontend/src/routes/_app.tsx index 12745f7..07d8211 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 8e56359..a41d68f 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/knip.config.ts b/knip.config.ts index c877c27..b8a10e7 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/spikes/docx-redline/.gitignore b/spikes/docx-redline/.gitignore index 4f2c13a..22b3f4e 100644 --- a/spikes/docx-redline/.gitignore +++ b/spikes/docx-redline/.gitignore @@ -1,2 +1,3 @@ node_modules *.docx +.~lock.* From af7afee18a469a2d8cf688425d1e292321537663 Mon Sep 17 00:00:00 2001 From: Michael Hurhangee Date: Sat, 4 Jul 2026 12:20:27 +0100 Subject: [PATCH 07/10] docs: mark PR 1 code-complete in the teardown plan --- living-docs/editor-teardown.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/living-docs/editor-teardown.md b/living-docs/editor-teardown.md index 7036849..9e84ba5 100644 --- a/living-docs/editor-teardown.md +++ b/living-docs/editor-teardown.md @@ -1,5 +1,10 @@ # 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) From 2121f5b95b800ba5029bc4b563a5961c0bb5868b Mon Sep 17 00:00:00 2001 From: Michael Hurhangee Date: Sat, 4 Jul 2026 12:49:21 +0100 Subject: [PATCH 08/10] fix(docx): serialize draft mutations; render redlines in the preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field testing found parallel tool calls losing writes: every mutation is read-file -> transform -> write-file, and the model fires calls concurrently, so 12 parallel comments collapsed to 3 (each read the same base bytes; last write won). All mutations now serialize through a per-draft promise chain — same discipline as mutateChart — with a 12-parallel-comments regression test. The preview now shows pending redlines as real ins/del marks (run-level extraction in the adapter, rendered as /), and the panel refreshes the moment a mutating draft tool result streams back instead of waiting for the status poll. --- apps/api/src/lib/docx/dance.test.ts | 24 +++++++++ apps/api/src/lib/docx/dance.ts | 34 ++++++++++--- apps/api/src/lib/docx/redline.test.ts | 22 ++++++++ apps/api/src/lib/docx/redline.ts | 48 ++++++++++++++++++ apps/api/src/routes/tasks.ts | 16 ++---- apps/frontend/src/api/tasks.ts | 8 ++- .../src/components/workspace/agent-chat.tsx | 38 ++++++++++---- .../src/components/workspace/draft-panel.tsx | 50 ++++++++++++++++--- 8 files changed, 203 insertions(+), 37 deletions(-) diff --git a/apps/api/src/lib/docx/dance.test.ts b/apps/api/src/lib/docx/dance.test.ts index c98ba85..28125cd 100644 --- a/apps/api/src/lib/docx/dance.test.ts +++ b/apps/api/src/lib/docx/dance.test.ts @@ -104,6 +104,30 @@ describe("applyOrPark", () => { 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("records a failure when a parked edit no longer applies", async () => { await writeFile(lockPath(), "lo"); await dance.applyOrPark({ diff --git a/apps/api/src/lib/docx/dance.ts b/apps/api/src/lib/docx/dance.ts index bbdf626..99e7868 100644 --- a/apps/api/src/lib/docx/dance.ts +++ b/apps/api/src/lib/docx/dance.ts @@ -95,13 +95,28 @@ export class DraftDance { }; } + // 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) 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. */ - async applyOrPark(op: DraftOp): Promise { - if (await this.isLocked()) { - this.parked.push(op); - return { status: "parked", parkedEdits: this.parked.length }; - } - return this.applyNow(op); + applyOrPark(op: DraftOp): Promise { + return this.enqueue(async () => { + if (await this.isLocked()) { + this.parked.push(op); + return { status: "parked", parkedEdits: this.parked.length }; + } + return this.applyNow(op); + }); } private async applyNow(op: DraftOp): Promise { @@ -132,7 +147,12 @@ export class DraftDance { * arrival order. A failure is recorded and surfaced via status; a re-lock * mid-drain re-parks the remainder. */ - async tick(): Promise { + tick(): Promise { + if (this.parked.length === 0) return Promise.resolve(); + return this.enqueue(() => 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)); diff --git a/apps/api/src/lib/docx/redline.test.ts b/apps/api/src/lib/docx/redline.test.ts index 97055f3..b8edf41 100644 --- a/apps/api/src/lib/docx/redline.test.ts +++ b/apps/api/src/lib/docx/redline.test.ts @@ -11,6 +11,7 @@ import { extractDocxText, listComments, readDraftParagraphs, + readDraftRuns, } from "./redline"; // A real USPTO office action — styles, numbering, header, examiner comments. @@ -143,6 +144,27 @@ describe("applyRedline", () => { }); }); +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(); diff --git a/apps/api/src/lib/docx/redline.ts b/apps/api/src/lib/docx/redline.ts index 636a960..e9344fc 100644 --- a/apps/api/src/lib/docx/redline.ts +++ b/apps/api/src/lib/docx/redline.ts @@ -134,6 +134,54 @@ export async function readDraftParagraphs( })); } +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 walk = (node: XmlNode) => { + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.nodeType !== 1) continue; + const el = c as XmlElement; + 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; + } + 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) => ({ + index: i + 1, + runs: paragraphRuns(p), + hasRevisions: paragraphHasRevisions(p), + })); +} + /** Plain text of a .docx (paragraphs joined by newlines) — context extraction. */ export async function extractDocxText(bytes: Uint8Array): Promise { const paragraphs = await readDraftParagraphs(bytes); diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index a339df0..22f42f4 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -50,7 +50,7 @@ import { writeDocumentMeta, } from "../lib/documents"; import { danceFor } from "../lib/docx/dance"; -import { readDraftParagraphs } from "../lib/docx/redline"; +import { readDraftRuns } from "../lib/docx/redline"; import { fetchPublication } from "../lib/patents"; import { readProfile } from "../lib/profiles"; import { deleteTask, listTasks, readTask, writeTask } from "../lib/tasks"; @@ -489,8 +489,8 @@ tasks.post( }, ); -// A .docx rendered as plain text (tracked changes as-if-accepted) — the -// in-app preview; the real document opens in Word. +// 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); @@ -498,16 +498,10 @@ tasks.get("/:id/documents/:filename/docx-text", async (c) => { const file = Bun.file(join(task.folder, name)); if (!(await file.exists())) return c.json({ error: "file not found" }, 404); try { - const paragraphs = await readDraftParagraphs( + const paragraphs = await readDraftRuns( new Uint8Array(await file.arrayBuffer()), ); - return c.json({ - paragraphs: paragraphs.map((p) => ({ - index: p.index, - text: p.text, - hasRevisions: p.hasRevisions, - })), - }); + return c.json({ paragraphs }); } catch { return c.json({ error: "could not read this .docx" }, 422); } diff --git a/apps/frontend/src/api/tasks.ts b/apps/frontend/src/api/tasks.ts index b4bde6e..ede611a 100644 --- a/apps/frontend/src/api/tasks.ts +++ b/apps/frontend/src/api/tasks.ts @@ -170,10 +170,14 @@ export const tasksApi = { `/tasks/${id}/documents/${encodeURIComponent(filename)}/draft-status/clear-failures`, {}, ), - /** A .docx as plain paragraphs (tracked changes as-if-accepted) — the preview. */ + /** A .docx as paragraphs-of-runs, pending redlines marked ins/del — the preview. */ docxText: (id: string, filename: string) => api.get<{ - paragraphs: { index: number; text: string; hasRevisions: boolean }[]; + 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) => diff --git a/apps/frontend/src/components/workspace/agent-chat.tsx b/apps/frontend/src/components/workspace/agent-chat.tsx index c8f111d..3435dff 100644 --- a/apps/frontend/src/components/workspace/agent-chat.tsx +++ b/apps/frontend/src/components/workspace/agent-chat.tsx @@ -8,6 +8,7 @@ import { type ExchangeMetadata, MODELS_BY_ID, MUTATING_CHART_TOOLS, + MUTATING_DRAFT_TOOLS, type PinnedSource, READ_CHART_TOOL, toStoredMessage, @@ -76,6 +77,7 @@ import { SystemCard } from "./system-card"; // 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", @@ -396,28 +398,44 @@ function ChatSession({ }, }); - // 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); diff --git a/apps/frontend/src/components/workspace/draft-panel.tsx b/apps/frontend/src/components/workspace/draft-panel.tsx index 0999cc1..cea862a 100644 --- a/apps/frontend/src/components/workspace/draft-panel.tsx +++ b/apps/frontend/src/components/workspace/draft-panel.tsx @@ -56,8 +56,15 @@ export function DraftPanel({ }); }, [lastSavedMs, activeTaskId, filename, queryClient]); - // Feed the context control's token estimate for read-only docx sources. - const chars = doc?.paragraphs.reduce((n, p) => n + p.text.length, 0) ?? 0; + // 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 }); @@ -81,25 +88,25 @@ export function DraftPanel({ {editable && }
- {doc.paragraphs.filter((p) => p.text.trim()).length === 0 ? ( + {doc.paragraphs.every((p) => !paragraphHasText(p)) ? (

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

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

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

) : null, ) @@ -110,6 +117,35 @@ export function DraftPanel({ ); } +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, From 1d6e349b76483349ffea7dda75c7340dbfb68a21 Mon Sep 17 00:00:00 2001 From: Michael Hurhangee Date: Sat, 4 Jul 2026 13:06:01 +0100 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20unlock=20originals=20in=20place?= =?UTF-8?q?=20=E2=80=94=20no=20more=20'(Patrick)=20copy'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Patrick edit is now a surgical tracked change (reject-all restores the text exactly, byte-identical elsewhere), so the copy-on-unlock indirection that protected originals from the old editor's whole-file round-trip is no longer earning its clunkiness. requestUnlock now flips the original .docx itself to editable — one explicit consent per document — after snapshotting its pristine bytes to .patrick/backups (never overwritten by a re-unlock). editable ≡ .docx && (createdInPatrick || unlocked), single-sourced in @patrick/shared isEditableDoc. Originals still can't be renamed or deleted, and paraId backfill goes with the old anchor-by-id tools. --- apps/api/src/lib/ai/chat.ts | 7 +-- apps/api/src/lib/documents.test.ts | 51 +++++++++++++++ apps/api/src/lib/documents.ts | 63 ++++++++----------- apps/api/src/lib/docx.ts | 50 --------------- apps/api/src/routes/tasks.ts | 9 +-- apps/frontend/src/api/tasks.ts | 4 +- .../shell/sidebar/documents-nav.tsx | 33 +++++----- .../workspace/chat-message-parts.tsx | 8 +-- apps/frontend/src/lib/workspace.tsx | 4 +- packages/shared/src/prompt.ts | 2 +- packages/shared/src/task.ts | 18 ++++++ 11 files changed, 129 insertions(+), 120 deletions(-) create mode 100644 apps/api/src/lib/documents.test.ts delete mode 100644 apps/api/src/lib/docx.ts diff --git a/apps/api/src/lib/ai/chat.ts b/apps/api/src/lib/ai/chat.ts index 13f86c6..2b2a9d4 100644 --- a/apps/api/src/lib/ai/chat.ts +++ b/apps/api/src/lib/ai/chat.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { fileCachedFetcher, lookupProvisions } from "@patrick/law"; import { type ExchangeMetadata, + isEditableDoc, modelsForProvider, PATRICK_DOCS, type PinnedSource, @@ -96,10 +97,8 @@ async function availableDocs( const pinnedNames = new Set(pinned.map((p) => p.filename)); return docs .filter((d) => { - const editable = - d.filename.toLowerCase().endsWith(".docx") && !!d.createdInPatrick; return ( - !editable && + !isEditableDoc(d) && !d.excluded && !pinnedNames.has(d.filename) && d.filename !== activeDraft @@ -176,7 +175,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() diff --git a/apps/api/src/lib/documents.test.ts b/apps/api/src/lib/documents.test.ts new file mode 100644 index 0000000..fe23cdb --- /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 59acb33..fbd9a3a 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,38 +140,36 @@ 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. + * Overwrite a document's bytes. Guards the attorney's originals: only editable + * files (Patrick-created, or unlocked in place) may be written. */ export async function saveDocumentBytes( folder: string, @@ -191,7 +178,8 @@ export async function saveDocumentBytes( ): Promise { if (!(await fileExists(folder, filename))) return "not-found"; const meta = await readDocumentMeta(folder); - if (!meta[filename]?.createdInPatrick) return "forbidden"; + if (!meta[filename]?.createdInPatrick && !meta[filename]?.unlocked) + return "forbidden"; await writeFile(join(folder, filename), Buffer.from(bytes)); return "ok"; } @@ -252,6 +240,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 a58c8e4..0000000 --- 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/routes/tasks.ts b/apps/api/src/routes/tasks.ts index 22f42f4..d129587 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -46,7 +46,7 @@ import { saveExtractedText, saveRetrievedDocument, saveSearchIndex, - unlockDocumentCopy, + unlockDocumentInPlace, writeDocumentMeta, } from "../lib/documents"; import { danceFor } from "../lib/docx/dance"; @@ -364,11 +364,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")), ); diff --git a/apps/frontend/src/api/tasks.ts b/apps/frontend/src/api/tasks.ts index ede611a..91e789b 100644 --- a/apps/frontend/src/api/tasks.ts +++ b/apps/frontend/src/api/tasks.ts @@ -142,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. */ diff --git a/apps/frontend/src/components/shell/sidebar/documents-nav.tsx b/apps/frontend/src/components/shell/sidebar/documents-nav.tsx index 35f9f24..87f1c25 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/chat-message-parts.tsx b/apps/frontend/src/components/workspace/chat-message-parts.tsx index 49aae2a..109b475 100644 --- a/apps/frontend/src/components/workspace/chat-message-parts.tsx +++ b/apps/frontend/src/components/workspace/chat-message-parts.tsx @@ -483,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; @@ -604,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; @@ -614,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/lib/workspace.tsx b/apps/frontend/src/lib/workspace.tsx index 3b99ce2..828730a 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/packages/shared/src/prompt.ts b/packages/shared/src/prompt.ts index 7c19d0f..a14c3ff 100644 --- a/packages/shared/src/prompt.ts +++ b/packages/shared/src/prompt.ts @@ -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 012688d..8193cb3 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; From e43893500afc6102b8eeefac62f9f437ece7b23e Mon Sep 17 00:00:00 2001 From: Michael Hurhangee Date: Sat, 4 Jul 2026 15:40:25 +0100 Subject: [PATCH 10/10] fix: action the PR #169 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter (redline.ts): - strict post-apply verification at the edited position only (block i..i+delta when the engine splits paragraphs) — newText appearing elsewhere in the document can no longer vouch for a mangled apply - refuse paragraphs carrying the ATTORNEY's pending tracked changes (editing through them risks absorbing their authorship) - merge result.numberingXml so list-shaped rewrites can't reference numbering ids that don't exist (Word repair prompt) - text boxes: skip w:txbxContent + mc:Fallback in walks and hide nested w:p from reads/edits (indices keep their engine-aligned slots) — box text was extracted twice and permanently unambiguous to edit - tabs/breaks extract as separators; comment ids seed from the document so a restart can't mint a duplicate id Dance: - new ops drain older parked ops first (a stale parked edit could supersede a newer edit to the same paragraph) - parked ops persist to .patrick/parked/ and survive an app restart - engine throws inside a drain are recorded as failures instead of silently discarding the rest of the queue; draft writes are atomic (tmp + rename); mentions memoised by mtime; 7-char Word lock tier Context/product: - a pinned docx that gets unlocked keeps serving the PRISTINE backup as the pinned source (immutable, cacheable) instead of the live draft - the manifest names other editable drafts so a second unlocked doc doesn't vanish from Patrick's world - createDraft/unlock HITL handlers set the active draft synchronously (state + transport ref) so the auto-continued turn can't ship activeDraft: null; the sticky-draft effect keeps unknown-yet drafts - the dead PUT document-bytes route is gone (it bypassed the dance) --- SCRATCHPAD.md | 22 +++ apps/api/src/lib/ai/chat.ts | 61 +++++-- apps/api/src/lib/ai/prompt.test.ts | 12 ++ apps/api/src/lib/ai/prompt.ts | 14 +- apps/api/src/lib/documents.ts | 17 -- apps/api/src/lib/docx/dance.test.ts | 39 +++++ apps/api/src/lib/docx/dance.ts | 143 ++++++++++++---- apps/api/src/lib/docx/redline-js.d.ts | 8 +- apps/api/src/lib/docx/redline.test.ts | 120 +++++++++++++ apps/api/src/lib/docx/redline.ts | 159 +++++++++++++++--- apps/api/src/routes/tasks.ts | 17 -- .../src/components/workspace/agent-chat.tsx | 14 +- 12 files changed, 512 insertions(+), 114 deletions(-) diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index fa233c0..730b62a 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/src/lib/ai/chat.ts b/apps/api/src/lib/ai/chat.ts index 2b2a9d4..9ed2345 100644 --- a/apps/api/src/lib/ai/chat.ts +++ b/apps/api/src/lib/ai/chat.ts @@ -92,19 +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) => { - return ( - !isEditableDoc(d) && - !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 @@ -266,10 +279,23 @@ const epcLookup = tool({ // 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); @@ -372,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`, ); @@ -479,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, @@ -500,6 +530,7 @@ export async function handleChat(c: Context) { available, charts, template, + otherDrafts, ); // Adding/removing a capability? Do it in buildChatTools — and update diff --git a/apps/api/src/lib/ai/prompt.test.ts b/apps/api/src/lib/ai/prompt.test.ts index 732e2af..75b5830 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 ?? [], ); } @@ -88,6 +90,16 @@ describe("buildSystemPrompt — manifest (lists, never content)", () => { 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 3ea444b..9a02fe2 100644 --- a/apps/api/src/lib/ai/prompt.ts +++ b/apps/api/src/lib/ai/prompt.ts @@ -28,6 +28,7 @@ function manifest( pinned: PinnedSource[], activeDraft: string | null, available: AvailableDoc[], + otherDrafts: AvailableDoc[], charts: string, ): string { const lines: string[] = []; @@ -49,6 +50,16 @@ function manifest( } 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.ts b/apps/api/src/lib/documents.ts index fbd9a3a..f64fa86 100644 --- a/apps/api/src/lib/documents.ts +++ b/apps/api/src/lib/documents.ts @@ -167,23 +167,6 @@ export async function unlockDocumentInPlace( export type SaveResult = "ok" | "not-found" | "forbidden"; -/** - * Overwrite a document's bytes. Guards the attorney's originals: only editable - * files (Patrick-created, or unlocked in place) may be written. - */ -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 && !meta[filename]?.unlocked) - 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, diff --git a/apps/api/src/lib/docx/dance.test.ts b/apps/api/src/lib/docx/dance.test.ts index 28125cd..c5d979f 100644 --- a/apps/api/src/lib/docx/dance.test.ts +++ b/apps/api/src/lib/docx/dance.test.ts @@ -128,6 +128,45 @@ describe("applyOrPark", () => { 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({ diff --git a/apps/api/src/lib/docx/dance.ts b/apps/api/src/lib/docx/dance.ts index 99e7868..8a744e5 100644 --- a/apps/api/src/lib/docx/dance.ts +++ b/apps/api/src/lib/docx/dance.ts @@ -1,5 +1,13 @@ -import { readdir, readFile, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +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, @@ -15,6 +23,8 @@ import { // - 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." @@ -37,6 +47,11 @@ function describeOp(op: DraftOp): string { 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, @@ -48,13 +63,52 @@ export class DraftDance { 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 { - // Word's owner file replaces the filename's first two characters with ~$ - // (short names keep them), LibreOffice wraps the full name. + // 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 { @@ -65,6 +119,7 @@ export class DraftDance { } async status(): Promise { + await this.enqueue(() => this.ensureLoaded()); let lastSavedMs: number | null = null; let exists = true; let mentions: DraftComment[] = []; @@ -73,16 +128,21 @@ export class DraftDance { } catch { exists = false; } - if (exists) { - try { - const comments = await listComments( - new Uint8Array(await readFile(this.path)), - ); - mentions = comments.filter( - (c) => c.author !== this.author && /@patrick/i.test(c.text), - ); - } catch { - // unreadable mid-save — mentions just come back empty this tick + 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 { @@ -99,7 +159,7 @@ export class DraftDance { // (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) stay lock-free. + // per-draft chain; reads (status content) stay lock-free. private chain: Promise = Promise.resolve(); private enqueue(fn: () => Promise): Promise { @@ -111,10 +171,12 @@ export class DraftDance { /** Apply the op now if the draft is closed; park it for the tick if not. */ applyOrPark(op: DraftOp): Promise { return this.enqueue(async () => { - if (await this.isLocked()) { - this.parked.push(op); - return { status: "parked", parkedEdits: this.parked.length }; - } + 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); }); } @@ -126,30 +188,42 @@ export class DraftDance { } catch { return { status: "failed", reason: `cannot read ${this.filename}` }; } - const result = - op.kind === "redline" - ? await applyRedline(bytes, op.edit, this.author) - : await addComment(bytes, op.request, this.author); + 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()) { - this.parked.push(op); - return { status: "parked", parkedEdits: this.parked.length }; - } - await writeFile(this.path, result.bytes); + 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 is recorded and surfaced via status; a re-lock - * mid-drain re-parks the remainder. + * arrival order. A failure (returned OR thrown) is recorded and surfaced via + * status; a re-lock mid-drain re-parks the remainder. */ tick(): Promise { - if (this.parked.length === 0) return Promise.resolve(); - return this.enqueue(() => this.drain()); + // 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 { @@ -162,11 +236,12 @@ export class DraftDance { if (outcome.status === "failed") this.failures.push(`${describeOp(op)} — ${outcome.reason}`); if (outcome.status === "parked") { - // applyNow re-parked `op`; keep the rest queued behind it, in order. + // applyNow re-parked (and persisted) `op`; keep the rest behind it. this.parked.push(...ops); - return; + break; } } + await this.persistParked(); } /** Clear surfaced failures once the UI has shown them. */ diff --git a/apps/api/src/lib/docx/redline-js.d.ts b/apps/api/src/lib/docx/redline-js.d.ts index e2d1372..b2ed2d9 100644 --- a/apps/api/src/lib/docx/redline-js.d.ts +++ b/apps/api/src/lib/docx/redline-js.d.ts @@ -1,4 +1,10 @@ -// The document-safe runner is a deep import the package doesn't ship types for. +// 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"; diff --git a/apps/api/src/lib/docx/redline.test.ts b/apps/api/src/lib/docx/redline.test.ts index b8edf41..c1cac5e 100644 --- a/apps/api/src/lib/docx/redline.test.ts +++ b/apps/api/src/lib/docx/redline.test.ts @@ -144,6 +144,126 @@ describe("applyRedline", () => { }); }); +// 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(), { diff --git a/apps/api/src/lib/docx/redline.ts b/apps/api/src/lib/docx/redline.ts index e9344fc..b335cea 100644 --- a/apps/api/src/lib/docx/redline.ts +++ b/apps/api/src/lib/docx/redline.ts @@ -1,9 +1,11 @@ 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 { @@ -66,12 +68,34 @@ const parseXml = (xml: string): XmlDocument => const serializeXml = (node: XmlNode) => new XMLSerializer().serializeToString(node); -function paragraphsOf(doc: XmlDocument): XmlElement[] { +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: XmlElement[] = []; + const out: ParagraphEntry[] = []; for (let i = 0; i < list.length; i++) { const p = list[i]; - if (p) out.push(p); + if (p) out.push({ el: p, nested: hasAncestor(p, "p", doc) }); } return out; } @@ -95,10 +119,15 @@ function hasAncestor( /** 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 ?? ""; @@ -108,6 +137,17 @@ function paragraphText(p: XmlElement, view: "accepted" | "base"): string { 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); } }; @@ -127,11 +167,16 @@ export async function readDraftParagraphs( ): Promise { const zip = await JSZip.loadAsync(bytes); const doc = parseXml(await documentXmlOf(zip)); - return paragraphsOf(doc).map((p, i) => ({ - index: i + 1, - text: paragraphText(p, "accepted"), - hasRevisions: paragraphHasRevisions(p), - })); + // 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" }; @@ -151,10 +196,17 @@ function paragraphRuns(p: XmlElement): DraftRun[] { 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; @@ -163,6 +215,15 @@ function paragraphRuns(p: XmlElement): DraftRun[] { 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); } }; @@ -175,11 +236,14 @@ export async function readDraftRuns( ): Promise { const zip = await JSZip.loadAsync(bytes); const doc = parseXml(await documentXmlOf(zip)); - return paragraphsOf(doc).map((p, i) => ({ - index: i + 1, - runs: paragraphRuns(p), - hasRevisions: paragraphHasRevisions(p), - })); + 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. */ @@ -290,8 +354,12 @@ export async function applyRedline( const paragraphs = paragraphsOf(doc); const matches = paragraphs - .map((p, i) => ({ p, i })) - .filter(({ p }) => norm(paragraphText(p, "accepted")).includes(target)); + .map((entry, i) => ({ entry, i })) + .filter( + ({ entry }) => + !entry.nested && + norm(paragraphText(entry.el, "accepted")).includes(target), + ); if (matches.length === 0) return { applied: false, @@ -304,7 +372,18 @@ export async function applyRedline( reason: `target text matches ${matches.length} paragraphs — quote more of the paragraph so the target is unique`, }; - const { p, i } = matches[0] as { p: XmlElement; i: number }; + 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. @@ -324,27 +403,34 @@ export async function applyRedline( reason: `the redline engine could not apply the edit (status: ${result.status})`, }; - // Verify before returning: the paragraph's as-if-accepted text must now be - // the requested text (same index, or anywhere if the edit split paragraphs). + // 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 atIndex = outParagraphs[i] - ? norm(paragraphText(outParagraphs[i] as XmlElement, "accepted")) - : ""; - const landed = - atIndex === wanted || - norm( - outParagraphs.map((op) => paragraphText(op, "accepted")).join(" "), - ).includes(wanted); - if (!landed) + 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), @@ -352,6 +438,18 @@ export async function applyRedline( }; } +/** 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; @@ -370,8 +468,13 @@ export async function addComment( 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( - await documentXmlOf(zip), + documentXml, [ { paragraphIndex: request.paragraphIndex, diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index d129587..4448737 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -42,7 +42,6 @@ import { readExtractedText, readSearchIndex, renameDocument, - saveDocumentBytes, saveExtractedText, saveRetrievedDocument, saveSearchIndex, @@ -378,22 +377,6 @@ tasks.post("/:id/documents/:filename/unlock", 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")); diff --git a/apps/frontend/src/components/workspace/agent-chat.tsx b/apps/frontend/src/components/workspace/agent-chat.tsx index 3435dff..852d912 100644 --- a/apps/frontend/src/components/workspace/agent-chat.tsx +++ b/apps/frontend/src/components/workspace/agent-chat.tsx @@ -268,9 +268,13 @@ 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]); + }, [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; @@ -572,9 +576,15 @@ 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. + // 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); return res.filename; } catch { @@ -584,6 +594,8 @@ function ChatSession({ unlockSource: async (filename) => { try { const res = await unlockDoc.mutateAsync(filename); + setActiveDraft(res.filename); + activeDraftRef.current = res.filename; open(res.filename); return res.filename; } catch {