Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions SCRATCHPAD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
111 changes: 62 additions & 49 deletions apps/api/src/lib/ai/chat.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { getAiSdkTools } from "@eigenpal/docx-editor-agents/ai-sdk/server";
import { DocxReviewer } from "@eigenpal/docx-editor-agents/server";
import { fileCachedFetcher, lookupProvisions } from "@patrick/law";
import {
type ExchangeMetadata,
isEditableDoc,
modelsForProvider,
PATRICK_DOCS,
type PinnedSource,
Expand All @@ -31,9 +30,11 @@ import {
readDocumentMeta,
readExtractedText,
} from "../documents";
import { extractDocxText } from "../docx/redline";
import { readProfile } from "../profiles";
import { readTask } from "../tasks";
import { buildChartTools, chartManifest } from "./chart-tools";
import { buildDraftTools } from "./draft-tools";
import { createFindLaw } from "./find-law";
import { createModel, reasoningOptions, vendorOf } from "./model";
import { type AvailableDoc, buildSystemPrompt } from "./prompt";
Expand Down Expand Up @@ -66,18 +67,6 @@ async function frozenTemplate(
return existing?.systemTemplate ?? profile.prompts.agentpat;
}

// The drafting subset Patrick gets: locate + mutate, plus reads (the active
// draft isn't in the static prompt — the agent reads it live, always current).
const TOOL_ALLOW = new Set([
"read_document",
"read_selection",
"find_text",
"read_changes",
"read_comments",
"add_comment",
"suggest_change",
]);

type RequestBody = {
messages: UIMessage[];
profileId: string;
Expand All @@ -103,21 +92,32 @@ async function availableDocs(
folder: string,
pinned: PinnedSource[],
activeDraft: string | null,
): Promise<AvailableDoc[]> {
): Promise<{ available: AvailableDoc[]; otherDrafts: AvailableDoc[] }> {
const docs = await listDocuments(folder);
const pinnedNames = new Set(pinned.map((p) => p.filename));
return docs
.filter((d) => {
const editable =
d.filename.toLowerCase().endsWith(".docx") && !!d.createdInPatrick;
return (
!editable &&
!d.excluded &&
!pinnedNames.has(d.filename) &&
d.filename !== activeDraft
);
})
.map((d) => ({ filename: d.filename, label: d.label }));
const toEntry = (d: { filename: string; label?: string }) => ({
filename: d.filename,
label: d.label,
});
return {
available: docs
.filter(
(d) =>
!isEditableDoc(d) &&
!d.excluded &&
!pinnedNames.has(d.filename) &&
d.filename !== activeDraft,
)
.map(toEntry),
// Editable drafts other than the active one: the tools can't touch them,
// but the manifest must still name them or they vanish from Patrick's
// world (the attorney focuses a draft's tab to make it active).
otherDrafts: docs
.filter(
(d) => isEditableDoc(d) && !d.excluded && d.filename !== activeDraft,
)
.map(toEntry),
};
}

// HITL: no-execute tools. The model calls them to propose an action; the call
Expand Down Expand Up @@ -188,7 +188,7 @@ const createDraft = tool({

const requestUnlock = tool({
description:
"Propose making an editable copy of an original .docx document (the attorney's originals are read-only) so you can draft amendments in it. The attorney accepts; the copy opens as the active draft. Only works on .docx files — PDFs and other formats can't be edited, so don't propose it for them; instead explain that, or use createDraft to start a fresh document.",
"Propose unlocking an original .docx for editing — the attorney's originals are read-only until they unlock one. On accept, the document itself becomes the active draft you edit as tracked changes (a pristine backup is snapshotted first; rejecting your redlines in Word restores the text exactly). Only works on .docx files — PDFs and other formats can't be edited, so don't propose it for them; instead explain that, or use createDraft to start a fresh document.",
inputSchema: z.object({
filename: z
.string()
Expand Down Expand Up @@ -276,24 +276,31 @@ const epcLookup = tool({
}),
});

// Read-only docx → indexed plain text, headless from disk. The headless parse is
// the pricey bit and originals don't change, so memoise by path+mtime — a
// multi-turn chat then extracts each pinned docx once, not every turn.
// Read-only docx → plain text, headless from disk. Originals don't change, so
// memoise by path+mtime — a multi-turn chat then extracts each pinned docx
// once, not every turn.
//
// A pinned docx that was later UNLOCKED is a special case: the live file is now
// the mutable draft (Patrick's pending redlines would read as accepted text,
// and every edit would churn the cached prefix). The pinned source stays what
// the attorney actually pinned — the pristine snapshot under .patrick/backups.
const docxTextCache = new Map<string, { mtimeMs: number; text: string }>();

async function docxText(folder: string, filename: string): Promise<string> {
const path = join(folder, filename);
async function docxText(
folder: string,
filename: string,
pristine = false,
): Promise<string> {
const backup = join(folder, ".patrick", "backups", filename);
const path =
pristine && (await stat(backup).catch(() => null))
? backup
: join(folder, filename);
try {
const mtimeMs = (await stat(path)).mtimeMs;
const cached = docxTextCache.get(path);
if (cached && cached.mtimeMs === mtimeMs) return cached.text;
const buf = await readFile(path);
const bytes = buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength,
) as ArrayBuffer;
const reviewer = await DocxReviewer.fromBuffer(bytes, "Patrick");
const text = reviewer.getContentAsText().trim();
const text = await extractDocxText(new Uint8Array(await readFile(path)));
docxTextCache.set(path, { mtimeMs, text });
return text;
} catch {
Expand Down Expand Up @@ -391,7 +398,11 @@ export async function pinnedSourcesMessage(
return [];
}
}
const text = await docxText(folder, src.filename);
const text = await docxText(
folder,
src.filename,
!!meta[src.filename]?.unlocked,
);
console.log(
`[context] ${src.filename} → docx text, ${text.length} chars`,
);
Expand Down Expand Up @@ -440,12 +451,14 @@ export async function pinnedWithRequiredPrimary(
} as ModelMessage;
}

// The tools Patrick gets for a turn: editor tools (no execute — they run against
// the live editor client-side) + the law/search/HITL tools. Web search and the
// open-a-file proposal are conditional. Built in one place so the inspection
// panel can list the exact active tool names without drifting from what ships.
// The tools Patrick gets for a turn: the draft tools (server-executed against
// the active draft on disk, through the dance) + the law/search/HITL tools.
// Web search and the open-a-file proposal are conditional. Built in one place
// so the inspection panel can list the exact active tool names without
// drifting from what ships.
function buildChatTools(opts: {
folder: string;
activeDraft: string | null;
profile: Profile;
provider: Provider;
apiKey: string;
Expand All @@ -454,9 +467,7 @@ function buildChatTools(opts: {
hasDocs: boolean;
}) {
return {
...Object.fromEntries(
Object.entries(getAiSdkTools()).filter(([name]) => TOOL_ALLOW.has(name)),
),
...buildDraftTools(opts.folder, opts.activeDraft),
suggestLabel,
suggestBrief,
suggestPrompt,
Expand Down Expand Up @@ -498,7 +509,7 @@ export async function handleChat(c: Context) {

const pinnedSources = body.pinnedSources ?? [];
const activeDraft = body.activeDraft ?? null;
const available = await availableDocs(
const { available, otherDrafts } = await availableDocs(
task.folder,
pinnedSources,
activeDraft,
Expand All @@ -519,12 +530,14 @@ export async function handleChat(c: Context) {
available,
charts,
template,
otherDrafts,
);

// Adding/removing a capability? Do it in buildChatTools — and update
// PATRICK_CAPABILITIES in @patrick/shared so the prompt describes Patrick honestly.
const tools = buildChatTools({
folder: task.folder,
activeDraft,
profile,
provider,
apiKey,
Expand Down
143 changes: 143 additions & 0 deletions apps/api/src/lib/ai/draft-tools.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array | null> => {
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>;
}
Loading