@kolisachint/hoocode-agent 0.4.90 → 0.4.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/core/tools/docedit.d.ts +2 -0
- package/dist/core/tools/docedit.d.ts.map +1 -1
- package/dist/core/tools/docedit.js +34 -7
- package/dist/core/tools/docedit.js.map +1 -1
- package/dist/core/tools/docread.d.ts +10 -0
- package/dist/core/tools/docread.d.ts.map +1 -1
- package/dist/core/tools/docread.js +18 -6
- package/dist/core/tools/docread.js.map +1 -1
- package/dist/core/tools/docwrite.d.ts.map +1 -1
- package/dist/core/tools/docwrite.js +17 -8
- package/dist/core/tools/docwrite.js.map +1 -1
- package/dist/core/tools/filetools-shared.d.ts +62 -3
- package/dist/core/tools/filetools-shared.d.ts.map +1 -1
- package/dist/core/tools/filetools-shared.js +121 -5
- package/dist/core/tools/filetools-shared.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +9 -0
- package/dist/main.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -19,6 +19,26 @@
|
|
|
19
19
|
import { type Static, Type } from "typebox";
|
|
20
20
|
/** Default timeout (seconds) for a single filetools invocation. */
|
|
21
21
|
export declare const FILETOOLS_DEFAULT_TIMEOUT_SECS = 30;
|
|
22
|
+
/**
|
|
23
|
+
* Soft token ceiling for a single DocRead render. The filetools binary has no
|
|
24
|
+
* pagination, so a dense file (e.g. a large spreadsheet) can project into a
|
|
25
|
+
* huge id-addressed dump that floods the model context and burns tokens. We
|
|
26
|
+
* cannot make the extract itself smaller without the binary's help, so DocRead
|
|
27
|
+
* truncates the rendered view to roughly this budget and tells the model how to
|
|
28
|
+
* narrow it (readonly projection, a smaller/targeted file, or direct edits).
|
|
29
|
+
*/
|
|
30
|
+
export declare const DOCREAD_MAX_RENDER_TOKENS = 10000;
|
|
31
|
+
/** Rough token estimate (chars/4), matching the agent's compaction heuristic. */
|
|
32
|
+
export declare function estimateTextTokens(text: string): number;
|
|
33
|
+
/**
|
|
34
|
+
* Truncate rendered envelope lines to roughly `maxTokens`, keeping whole lines.
|
|
35
|
+
* Returns the kept text plus how many lines were dropped (0 when nothing was
|
|
36
|
+
* truncated).
|
|
37
|
+
*/
|
|
38
|
+
export declare function truncateRenderToTokenBudget(lines: string[], maxTokens?: number): {
|
|
39
|
+
text: string;
|
|
40
|
+
droppedLines: number;
|
|
41
|
+
};
|
|
22
42
|
/** How faithfully a handler can reconstruct a file after edits. */
|
|
23
43
|
export type Fidelity = "lossless" | "in_place_text" | "read_only";
|
|
24
44
|
export interface DocSource {
|
|
@@ -110,6 +130,22 @@ export declare const patchOpsSchema: Type.TArray<Type.TUnion<[Type.TObject<{
|
|
|
110
130
|
export type PatchOpsInput = Static<typeof patchOpsSchema>;
|
|
111
131
|
/** Wrap the model-facing ops array into the binary's `{ patch: [...] }` envelope. */
|
|
112
132
|
export declare function toPatch(ops: PatchOpsInput): Patch;
|
|
133
|
+
/** Find a node by id anywhere in a (recursive) structure tree. */
|
|
134
|
+
export declare function findNodeById(nodes: DocNode[], id: string): DocNode | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* Extract the target node id from a patch op pointer. Returns undefined for ops
|
|
137
|
+
* that reference a node by anchor (`add`) rather than a `/structure/<id>/...`
|
|
138
|
+
* path. Pointer shapes: `/structure/<id>`, `/structure/<id>/text`,
|
|
139
|
+
* `/structure/<id>/attrs/<name>`.
|
|
140
|
+
*/
|
|
141
|
+
export declare function patchOpNodeId(op: PatchOp): string | undefined;
|
|
142
|
+
/**
|
|
143
|
+
* Validate that every node id referenced by `ops` still exists in `structure`.
|
|
144
|
+
* Returns the ids that are missing (empty array means the patch is applicable to
|
|
145
|
+
* this extract). Used to detect when a patch was authored against a stale
|
|
146
|
+
* extract — e.g. after an external tool rewrote the document.
|
|
147
|
+
*/
|
|
148
|
+
export declare function findMissingPatchIds(ops: PatchOp[], structure: DocNode[]): string[];
|
|
113
149
|
export interface ExtractRecord {
|
|
114
150
|
/** Absolute path of the source document. */
|
|
115
151
|
source: string;
|
|
@@ -136,9 +172,32 @@ export declare function getExtractRecord(absolutePath: string): ExtractRecord |
|
|
|
136
172
|
/** Drop any cached extraction for `absolutePath`. */
|
|
137
173
|
export declare function invalidateExtractRecord(absolutePath: string): void;
|
|
138
174
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
175
|
+
* Thrown when a patch references node ids that are absent from the current
|
|
176
|
+
* extract — typically because the document was rewritten out-of-band (e.g. by a
|
|
177
|
+
* script) after the ids were read, or the patch was authored against an older
|
|
178
|
+
* extract. Carries the freshly re-extracted envelope so the caller can surface
|
|
179
|
+
* current ids to the agent without forcing a separate DocRead.
|
|
180
|
+
*/
|
|
181
|
+
export declare class StalePatchError extends Error {
|
|
182
|
+
readonly envelope: Envelope;
|
|
183
|
+
readonly missingIds: string[];
|
|
184
|
+
constructor(envelope: Envelope, missingIds: string[]);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Return a valid cached extract for `absolutePath`, re-extracting automatically
|
|
188
|
+
* when the cache is missing or stale (e.g. the source changed on disk since the
|
|
189
|
+
* last extract). This keeps DocEdit/DocWrite usable after an out-of-band write
|
|
190
|
+
* without forcing the agent to call DocRead again.
|
|
191
|
+
*/
|
|
192
|
+
export declare function ensureExtractRecord(absolutePath: string, cwd: string, signal: AbortSignal | undefined, options?: {
|
|
193
|
+
timeoutSecs?: number;
|
|
194
|
+
}): Promise<ExtractRecord>;
|
|
195
|
+
/**
|
|
196
|
+
* Apply `patch` to a document, writing the reconstructed bytes to `outPath`.
|
|
197
|
+
* Auto-extracts when the cache is missing or stale (so an out-of-band rewrite no
|
|
198
|
+
* longer forces a manual DocRead), then validates that the patch's node ids
|
|
199
|
+
* still exist in the current extract. A mismatch throws {@link StalePatchError}
|
|
200
|
+
* carrying the fresh envelope so the caller can show current ids.
|
|
142
201
|
*/
|
|
143
202
|
export declare function reconstructDocument(absolutePath: string, patch: Patch, outPath: string, cwd: string, signal: AbortSignal | undefined, options?: {
|
|
144
203
|
timeoutSecs?: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"filetools-shared.d.ts","sourceRoot":"","sources":["../../../src/core/tools/filetools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `DocRead` / `DocEdit` / `DocWrite` tools.\n *\n * All three shell out to the `filetools` binary (extract / reconstruct\n * subcommands, resolved/downloaded via {@link ensureTool}) to losslessly\n * project structured/binary documents (XML, drawio, OOXML, PDF) into editable,\n * id-addressed JSON and reconstruct them after id-based patches.\n *\n * Unlike webtools, the filetools CLI is file-oriented, not stdout-oriented:\n * `extract` writes the envelope JSON to `--out` and the sidecar id-map next to\n * it, emitting only a human status line on stderr. This module therefore:\n * - owns a per-process working directory where envelopes + sidecars live,\n * - runs extract/reconstruct and reads the resulting files back,\n * - keeps a small cache mapping a source file to its extracted envelope +\n * sidecar, so a DocRead can be followed by a DocEdit/DocWrite (the stateful\n * extract -> patch -> reconstruct flow), and\n * - exposes the locked JSON wire types mirroring the Rust `model.rs`/`patch.rs`.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport { type Static, Type } from \"typebox\";\nimport { APP_NAME } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\n/** Default timeout (seconds) for a single filetools invocation. */\nexport const FILETOOLS_DEFAULT_TIMEOUT_SECS = 30;\n\n// ============================================================================\n// Wire types (locked against `filetools` model.rs / patch.rs)\n// ============================================================================\n\n/** How faithfully a handler can reconstruct a file after edits. */\nexport type Fidelity = \"lossless\" | \"in_place_text\" | \"read_only\";\n\nexport interface DocSource {\n\tpath: string;\n\t/** Logical format, e.g. \"xml\", \"drawio\". */\n\ttype: string;\n\t/** `sha256:<hex>` of the original bytes. */\n\thash: string;\n}\n\nexport interface DocAttr {\n\tname: string;\n\tvalue: string;\n}\n\nexport interface DocNode {\n\tid: string;\n\ttag: string;\n\tattrs?: DocAttr[];\n\ttext?: string;\n\tchildren?: DocNode[];\n}\n\n/** The extract output handed to the model. Mirrors the Rust `Envelope`. */\nexport interface Envelope {\n\tversion: string;\n\tsource: DocSource;\n\tfidelity: Fidelity;\n\twritable: boolean;\n\tidmap_ref?: string;\n\tstructure: DocNode[];\n}\n\n/** A new element for an `add` op (text-only content, v1). */\nexport interface NewElement {\n\ttag: string;\n\tattrs?: DocAttr[];\n\ttext?: string;\n}\n\n/**\n * One patch operation. RFC-6902 vocabulary, id-based pointers\n * (`/structure/<id>/text`, `/structure/<id>/attrs/<name>`), per the filetools\n * patch format.\n */\nexport type PatchOp =\n\t| { op: \"test\"; path: string; hash: string }\n\t| { op: \"replace\"; path: string; value: string }\n\t| { op: \"add\"; after?: string; before?: string; value: NewElement }\n\t| { op: \"remove\"; path: string };\n\nexport interface Patch {\n\tpatch: PatchOp[];\n}\n\n// ----------------------------------------------------------------------------\n// TypeBox schema for the model-facing patch input (shared by DocEdit/DocWrite)\n// ----------------------------------------------------------------------------\n\nconst attrSchema = Type.Object({\n\tname: Type.String(),\n\tvalue: Type.String(),\n});\n\nconst newElementSchema = Type.Object({\n\ttag: Type.String({ description: 'Element tag name, e.g. \"w:p\" or \"mxCell\".' }),\n\tattrs: Type.Optional(Type.Array(attrSchema, { description: \"Attributes in document order.\" })),\n\ttext: Type.Optional(Type.String({ description: \"Inline text content (text-only elements, v1).\" })),\n});\n\nconst patchOpSchema = Type.Union([\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"test\"),\n\t\t\tpath: Type.String({ description: \"Pointer `/structure/<id>` (or /text, /attrs/<name>) to guard.\" }),\n\t\t\thash: Type.String({ description: \"Expected content hash of the target node.\" }),\n\t\t},\n\t\t{ description: \"Optimistic guard: assert the target node's content hash before mutating.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"replace\"),\n\t\t\tpath: Type.String({\n\t\t\t\tdescription: \"`/structure/<id>/text` for element text, or `/structure/<id>/attrs/<name>` for an attribute.\",\n\t\t\t}),\n\t\t\tvalue: Type.String({ description: \"New text or attribute value.\" }),\n\t\t},\n\t\t{ description: \"Replace an element's text or an attribute value.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"add\"),\n\t\t\tafter: Type.Optional(Type.String({ description: \"Anchor node id to insert AFTER.\" })),\n\t\t\tbefore: Type.Optional(Type.String({ description: \"Anchor node id to insert BEFORE.\" })),\n\t\t\tvalue: newElementSchema,\n\t\t},\n\t\t{ description: \"Insert a new element next to an anchor. Provide exactly one of `after`/`before`.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"remove\"),\n\t\t\tpath: Type.String({ description: \"Pointer `/structure/<id>` of the element to delete.\" }),\n\t\t},\n\t\t{ description: \"Delete an element and all its bytes.\" },\n\t),\n]);\n\n/**\n * The model-facing patch parameter: an array of id-based RFC-6902 ops, matching\n * the filetools patch wire format. Shared by DocEdit and DocWrite.\n */\nexport const patchOpsSchema = Type.Array(patchOpSchema, {\n\tdescription:\n\t\t\"Ordered id-based patch ops (test/replace/add/remove) targeting node ids from a prior DocRead. Applied atomically.\",\n});\n\nexport type PatchOpsInput = Static<typeof patchOpsSchema>;\n\n/** Wrap the model-facing ops array into the binary's `{ patch: [...] }` envelope. */\nexport function toPatch(ops: PatchOpsInput): Patch {\n\treturn { patch: ops as PatchOp[] };\n}\n\n// ============================================================================\n// Binary runner + working directory\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"filetools binary unavailable and could not be downloaded — the document tools require the `filetools` CLI on PATH or a published release for this platform\";\n\n/** Lazily-created per-process working directory for envelopes + sidecars. */\nlet workDir: string | undefined;\nfunction getWorkDir(): string {\n\tif (workDir) return workDir;\n\tconst base = join(tmpdir(), `${APP_NAME}-filetools`);\n\tmkdirSync(base, { recursive: true });\n\tworkDir = mkdtempSync(join(base, \"doc-\"));\n\treturn workDir;\n}\n\n/** Short, filesystem-safe key for a source path (used to name its subdir). */\nfunction pathKey(absolutePath: string): string {\n\treturn createHash(\"sha256\").update(absolutePath).digest(\"hex\").slice(0, 16);\n}\n\nasync function resolveBinary(): Promise<string> {\n\tconst binaryPath = await ensureTool(\"filetools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\treturn binaryPath;\n}\n\nasync function runFiletools(\n\tbinaryPath: string,\n\tsubcommand: \"extract\" | \"reconstruct\",\n\targs: string[],\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\ttimeoutSecs: number,\n): Promise<string> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tconst spawnTimeoutMs = (timeoutSecs + 5) * 1000;\n\tconst result = await execCommand(binaryPath, [subcommand, ...args], cwd, { signal, timeout: spawnTimeoutMs });\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`filetools ${subcommand} timed out after ${timeoutSecs}s`);\n\tif (result.code !== 0) {\n\t\tconst stderr = result.stderr.trim();\n\t\tthrow new Error(stderr || `filetools ${subcommand} exited with code ${result.code}`);\n\t}\n\t// Status goes to stderr; callers read the produced files, not stdout.\n\treturn result.stderr.trim();\n}\n\n// ============================================================================\n// Extraction cache (source file -> extracted envelope + sidecar)\n// ============================================================================\n\nexport interface ExtractRecord {\n\t/** Absolute path of the source document. */\n\tsource: string;\n\t/** Path to the envelope JSON in the working directory. */\n\tenvelopePath: string;\n\t/** Parsed envelope (also returned to the model on DocRead). */\n\tenvelope: Envelope;\n\t/** The source's stat signature at extract time, to detect drift cheaply. */\n\tsignature: string;\n}\n\nconst records = new Map<string, ExtractRecord>();\n\nfunction statSignature(absolutePath: string): string {\n\ttry {\n\t\tconst st = statSync(absolutePath);\n\t\treturn `${st.mtimeMs}:${st.size}`;\n\t} catch {\n\t\treturn \"absent\";\n\t}\n}\n\n/**\n * Extract `absolutePath` to an envelope (+ sidecar) in the working directory,\n * cache the result keyed by the source path, and return the parsed envelope.\n *\n * `readonly` strips ids for a smaller, analysis-only projection that cannot be\n * reconstructed (DocRead's default-off mode).\n */\nexport async function extractDocument(\n\tabsolutePath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { readonly?: boolean; timeoutSecs?: number },\n): Promise<Envelope> {\n\tconst binaryPath = await resolveBinary();\n\tconst dir = join(getWorkDir(), pathKey(absolutePath));\n\tmkdirSync(dir, { recursive: true });\n\tconst envelopePath = join(dir, \"envelope.json\");\n\n\tconst args = [\"--input\", absolutePath, \"--out\", envelopePath];\n\tif (options?.readonly) args.push(\"--readonly\");\n\tawait runFiletools(binaryPath, \"extract\", args, cwd, signal, options?.timeoutSecs ?? FILETOOLS_DEFAULT_TIMEOUT_SECS);\n\n\tconst envelope = readEnvelope(envelopePath);\n\tif (!options?.readonly) {\n\t\trecords.set(absolutePath, {\n\t\t\tsource: absolutePath,\n\t\t\tenvelopePath,\n\t\t\tenvelope,\n\t\t\tsignature: statSignature(absolutePath),\n\t\t});\n\t}\n\treturn envelope;\n}\n\nfunction readEnvelope(envelopePath: string): Envelope {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(envelopePath, \"utf8\");\n\t} catch {\n\t\tthrow new Error(\"filetools extract produced no envelope\");\n\t}\n\ttry {\n\t\treturn JSON.parse(raw) as Envelope;\n\t} catch {\n\t\tthrow new Error(\"filetools extract produced a malformed envelope\");\n\t}\n}\n\n/** Look up a cached extraction for `absolutePath`, if one is still valid. */\nexport function getExtractRecord(absolutePath: string): ExtractRecord | undefined {\n\tconst record = records.get(absolutePath);\n\tif (!record) return undefined;\n\t// Drop a stale record if the source changed since extract; reconstruct would\n\t// fail the binary's hash-drift guard anyway, but a clearer error is better.\n\tif (record.signature !== statSignature(absolutePath)) {\n\t\trecords.delete(absolutePath);\n\t\treturn undefined;\n\t}\n\treturn record;\n}\n\n/** Drop any cached extraction for `absolutePath`. */\nexport function invalidateExtractRecord(absolutePath: string): void {\n\trecords.delete(absolutePath);\n}\n\n/**\n * Apply `patch` to a previously-extracted document, writing the reconstructed\n * bytes to `outPath`. Requires a prior {@link extractDocument} (the stateful\n * flow): the cached envelope + sidecar carry the id-map reconstruct needs.\n */\nexport async function reconstructDocument(\n\tabsolutePath: string,\n\tpatch: Patch,\n\toutPath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { timeoutSecs?: number },\n): Promise<void> {\n\tconst record = getExtractRecord(absolutePath);\n\tif (!record) {\n\t\tthrow new Error(\n\t\t\t`no extracted envelope for ${basename(absolutePath)} — run DocRead on it first, then DocEdit/DocWrite`,\n\t\t);\n\t}\n\tif (!record.envelope.writable) {\n\t\tthrow new Error(\n\t\t\t`${basename(absolutePath)} is read-only (fidelity ${record.envelope.fidelity}); it cannot be edited`,\n\t\t);\n\t}\n\n\tconst binaryPath = await resolveBinary();\n\tconst patchPath = join(getWorkDir(), pathKey(absolutePath), \"patch.json\");\n\twriteFileSync(patchPath, JSON.stringify(patch), \"utf8\");\n\ttry {\n\t\tawait runFiletools(\n\t\t\tbinaryPath,\n\t\t\t\"reconstruct\",\n\t\t\t[\"--envelope\", record.envelopePath, \"--patch\", patchPath, \"--out\", outPath, \"--original\", absolutePath],\n\t\t\tcwd,\n\t\t\tsignal,\n\t\t\toptions?.timeoutSecs ?? FILETOOLS_DEFAULT_TIMEOUT_SECS,\n\t\t);\n\t} finally {\n\t\trmSync(patchPath, { force: true });\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"filetools-shared.d.ts","sourceRoot":"","sources":["../../../src/core/tools/filetools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `DocRead` / `DocEdit` / `DocWrite` tools.\n *\n * All three shell out to the `filetools` binary (extract / reconstruct\n * subcommands, resolved/downloaded via {@link ensureTool}) to losslessly\n * project structured/binary documents (XML, drawio, OOXML, PDF) into editable,\n * id-addressed JSON and reconstruct them after id-based patches.\n *\n * Unlike webtools, the filetools CLI is file-oriented, not stdout-oriented:\n * `extract` writes the envelope JSON to `--out` and the sidecar id-map next to\n * it, emitting only a human status line on stderr. This module therefore:\n * - owns a per-process working directory where envelopes + sidecars live,\n * - runs extract/reconstruct and reads the resulting files back,\n * - keeps a small cache mapping a source file to its extracted envelope +\n * sidecar, so a DocRead can be followed by a DocEdit/DocWrite (the stateful\n * extract -> patch -> reconstruct flow), and\n * - exposes the locked JSON wire types mirroring the Rust `model.rs`/`patch.rs`.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport { type Static, Type } from \"typebox\";\nimport { APP_NAME } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\n/** Default timeout (seconds) for a single filetools invocation. */\nexport const FILETOOLS_DEFAULT_TIMEOUT_SECS = 30;\n\n/**\n * Soft token ceiling for a single DocRead render. The filetools binary has no\n * pagination, so a dense file (e.g. a large spreadsheet) can project into a\n * huge id-addressed dump that floods the model context and burns tokens. We\n * cannot make the extract itself smaller without the binary's help, so DocRead\n * truncates the rendered view to roughly this budget and tells the model how to\n * narrow it (readonly projection, a smaller/targeted file, or direct edits).\n */\nexport const DOCREAD_MAX_RENDER_TOKENS = 10000;\n\n/** Rough token estimate (chars/4), matching the agent's compaction heuristic. */\nexport function estimateTextTokens(text: string): number {\n\treturn Math.ceil(text.length / 4);\n}\n\n/**\n * Truncate rendered envelope lines to roughly `maxTokens`, keeping whole lines.\n * Returns the kept text plus how many lines were dropped (0 when nothing was\n * truncated).\n */\nexport function truncateRenderToTokenBudget(\n\tlines: string[],\n\tmaxTokens: number = DOCREAD_MAX_RENDER_TOKENS,\n): { text: string; droppedLines: number } {\n\tconst full = lines.join(\"\\n\");\n\tif (estimateTextTokens(full) <= maxTokens) {\n\t\treturn { text: full, droppedLines: 0 };\n\t}\n\tconst budgetChars = maxTokens * 4;\n\tconst kept: string[] = [];\n\tlet used = 0;\n\tfor (const line of lines) {\n\t\tconst next = used + line.length + 1; // + newline\n\t\tif (next > budgetChars && kept.length > 0) break;\n\t\tkept.push(line);\n\t\tused = next;\n\t}\n\treturn { text: kept.join(\"\\n\"), droppedLines: lines.length - kept.length };\n}\n\n// ============================================================================\n// Wire types (locked against `filetools` model.rs / patch.rs)\n// ============================================================================\n\n/** How faithfully a handler can reconstruct a file after edits. */\nexport type Fidelity = \"lossless\" | \"in_place_text\" | \"read_only\";\n\nexport interface DocSource {\n\tpath: string;\n\t/** Logical format, e.g. \"xml\", \"drawio\". */\n\ttype: string;\n\t/** `sha256:<hex>` of the original bytes. */\n\thash: string;\n}\n\nexport interface DocAttr {\n\tname: string;\n\tvalue: string;\n}\n\nexport interface DocNode {\n\tid: string;\n\ttag: string;\n\tattrs?: DocAttr[];\n\ttext?: string;\n\tchildren?: DocNode[];\n}\n\n/** The extract output handed to the model. Mirrors the Rust `Envelope`. */\nexport interface Envelope {\n\tversion: string;\n\tsource: DocSource;\n\tfidelity: Fidelity;\n\twritable: boolean;\n\tidmap_ref?: string;\n\tstructure: DocNode[];\n}\n\n/** A new element for an `add` op (text-only content, v1). */\nexport interface NewElement {\n\ttag: string;\n\tattrs?: DocAttr[];\n\ttext?: string;\n}\n\n/**\n * One patch operation. RFC-6902 vocabulary, id-based pointers\n * (`/structure/<id>/text`, `/structure/<id>/attrs/<name>`), per the filetools\n * patch format.\n */\nexport type PatchOp =\n\t| { op: \"test\"; path: string; hash: string }\n\t| { op: \"replace\"; path: string; value: string }\n\t| { op: \"add\"; after?: string; before?: string; value: NewElement }\n\t| { op: \"remove\"; path: string };\n\nexport interface Patch {\n\tpatch: PatchOp[];\n}\n\n// ----------------------------------------------------------------------------\n// TypeBox schema for the model-facing patch input (shared by DocEdit/DocWrite)\n// ----------------------------------------------------------------------------\n\nconst attrSchema = Type.Object({\n\tname: Type.String(),\n\tvalue: Type.String(),\n});\n\nconst newElementSchema = Type.Object({\n\ttag: Type.String({ description: 'Element tag name, e.g. \"w:p\" or \"mxCell\".' }),\n\tattrs: Type.Optional(Type.Array(attrSchema, { description: \"Attributes in document order.\" })),\n\ttext: Type.Optional(Type.String({ description: \"Inline text content (text-only elements, v1).\" })),\n});\n\nconst patchOpSchema = Type.Union([\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"test\"),\n\t\t\tpath: Type.String({ description: \"Pointer `/structure/<id>` (or /text, /attrs/<name>) to guard.\" }),\n\t\t\thash: Type.String({ description: \"Expected content hash of the target node.\" }),\n\t\t},\n\t\t{ description: \"Optimistic guard: assert the target node's content hash before mutating.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"replace\"),\n\t\t\tpath: Type.String({\n\t\t\t\tdescription: \"`/structure/<id>/text` for element text, or `/structure/<id>/attrs/<name>` for an attribute.\",\n\t\t\t}),\n\t\t\tvalue: Type.String({ description: \"New text or attribute value.\" }),\n\t\t},\n\t\t{ description: \"Replace an element's text or an attribute value.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"add\"),\n\t\t\tafter: Type.Optional(Type.String({ description: \"Anchor node id to insert AFTER.\" })),\n\t\t\tbefore: Type.Optional(Type.String({ description: \"Anchor node id to insert BEFORE.\" })),\n\t\t\tvalue: newElementSchema,\n\t\t},\n\t\t{ description: \"Insert a new element next to an anchor. Provide exactly one of `after`/`before`.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"remove\"),\n\t\t\tpath: Type.String({ description: \"Pointer `/structure/<id>` of the element to delete.\" }),\n\t\t},\n\t\t{ description: \"Delete an element and all its bytes.\" },\n\t),\n]);\n\n/**\n * The model-facing patch parameter: an array of id-based RFC-6902 ops, matching\n * the filetools patch wire format. Shared by DocEdit and DocWrite.\n */\nexport const patchOpsSchema = Type.Array(patchOpSchema, {\n\tdescription:\n\t\t\"Ordered id-based patch ops (test/replace/add/remove) targeting node ids from a prior DocRead. Applied atomically.\",\n});\n\nexport type PatchOpsInput = Static<typeof patchOpsSchema>;\n\n/** Wrap the model-facing ops array into the binary's `{ patch: [...] }` envelope. */\nexport function toPatch(ops: PatchOpsInput): Patch {\n\treturn { patch: ops as PatchOp[] };\n}\n\n/** Find a node by id anywhere in a (recursive) structure tree. */\nexport function findNodeById(nodes: DocNode[], id: string): DocNode | undefined {\n\tfor (const node of nodes) {\n\t\tif (node.id === id) return node;\n\t\tif (node.children) {\n\t\t\tconst hit = findNodeById(node.children, id);\n\t\t\tif (hit) return hit;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/**\n * Extract the target node id from a patch op pointer. Returns undefined for ops\n * that reference a node by anchor (`add`) rather than a `/structure/<id>/...`\n * path. Pointer shapes: `/structure/<id>`, `/structure/<id>/text`,\n * `/structure/<id>/attrs/<name>`.\n */\nexport function patchOpNodeId(op: PatchOp): string | undefined {\n\tif (op.op === \"add\") return op.after ?? op.before;\n\tconst parts = op.path.split(\"/\");\n\t// [\"\", \"structure\", \"<id>\", ...]\n\treturn parts[1] === \"structure\" ? parts[2] : undefined;\n}\n\n/**\n * Validate that every node id referenced by `ops` still exists in `structure`.\n * Returns the ids that are missing (empty array means the patch is applicable to\n * this extract). Used to detect when a patch was authored against a stale\n * extract — e.g. after an external tool rewrote the document.\n */\nexport function findMissingPatchIds(ops: PatchOp[], structure: DocNode[]): string[] {\n\tconst missing: string[] = [];\n\tfor (const op of ops) {\n\t\tconst id = patchOpNodeId(op);\n\t\tif (id && !findNodeById(structure, id)) missing.push(id);\n\t}\n\treturn missing;\n}\n\n// ============================================================================\n// Binary runner + working directory\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"filetools binary unavailable and could not be downloaded — the document tools require the `filetools` CLI on PATH or a published release for this platform\";\n\n/** Lazily-created per-process working directory for envelopes + sidecars. */\nlet workDir: string | undefined;\nfunction getWorkDir(): string {\n\tif (workDir) return workDir;\n\tconst base = join(tmpdir(), `${APP_NAME}-filetools`);\n\tmkdirSync(base, { recursive: true });\n\tworkDir = mkdtempSync(join(base, \"doc-\"));\n\treturn workDir;\n}\n\n/** Short, filesystem-safe key for a source path (used to name its subdir). */\nfunction pathKey(absolutePath: string): string {\n\treturn createHash(\"sha256\").update(absolutePath).digest(\"hex\").slice(0, 16);\n}\n\nasync function resolveBinary(): Promise<string> {\n\tconst binaryPath = await ensureTool(\"filetools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\treturn binaryPath;\n}\n\nasync function runFiletools(\n\tbinaryPath: string,\n\tsubcommand: \"extract\" | \"reconstruct\",\n\targs: string[],\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\ttimeoutSecs: number,\n): Promise<string> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tconst spawnTimeoutMs = (timeoutSecs + 5) * 1000;\n\tconst result = await execCommand(binaryPath, [subcommand, ...args], cwd, { signal, timeout: spawnTimeoutMs });\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`filetools ${subcommand} timed out after ${timeoutSecs}s`);\n\tif (result.code !== 0) {\n\t\tconst stderr = result.stderr.trim();\n\t\tthrow new Error(stderr || `filetools ${subcommand} exited with code ${result.code}`);\n\t}\n\t// Status goes to stderr; callers read the produced files, not stdout.\n\treturn result.stderr.trim();\n}\n\n// ============================================================================\n// Extraction cache (source file -> extracted envelope + sidecar)\n// ============================================================================\n\nexport interface ExtractRecord {\n\t/** Absolute path of the source document. */\n\tsource: string;\n\t/** Path to the envelope JSON in the working directory. */\n\tenvelopePath: string;\n\t/** Parsed envelope (also returned to the model on DocRead). */\n\tenvelope: Envelope;\n\t/** The source's stat signature at extract time, to detect drift cheaply. */\n\tsignature: string;\n}\n\nconst records = new Map<string, ExtractRecord>();\n\nfunction statSignature(absolutePath: string): string {\n\ttry {\n\t\tconst st = statSync(absolutePath);\n\t\treturn `${st.mtimeMs}:${st.size}`;\n\t} catch {\n\t\treturn \"absent\";\n\t}\n}\n\n/**\n * Extract `absolutePath` to an envelope (+ sidecar) in the working directory,\n * cache the result keyed by the source path, and return the parsed envelope.\n *\n * `readonly` strips ids for a smaller, analysis-only projection that cannot be\n * reconstructed (DocRead's default-off mode).\n */\nexport async function extractDocument(\n\tabsolutePath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { readonly?: boolean; timeoutSecs?: number },\n): Promise<Envelope> {\n\tconst binaryPath = await resolveBinary();\n\tconst dir = join(getWorkDir(), pathKey(absolutePath));\n\tmkdirSync(dir, { recursive: true });\n\tconst envelopePath = join(dir, \"envelope.json\");\n\n\tconst args = [\"--input\", absolutePath, \"--out\", envelopePath];\n\tif (options?.readonly) args.push(\"--readonly\");\n\tawait runFiletools(binaryPath, \"extract\", args, cwd, signal, options?.timeoutSecs ?? FILETOOLS_DEFAULT_TIMEOUT_SECS);\n\n\tconst envelope = readEnvelope(envelopePath);\n\tif (!options?.readonly) {\n\t\trecords.set(absolutePath, {\n\t\t\tsource: absolutePath,\n\t\t\tenvelopePath,\n\t\t\tenvelope,\n\t\t\tsignature: statSignature(absolutePath),\n\t\t});\n\t}\n\treturn envelope;\n}\n\nfunction readEnvelope(envelopePath: string): Envelope {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(envelopePath, \"utf8\");\n\t} catch {\n\t\tthrow new Error(\"filetools extract produced no envelope\");\n\t}\n\ttry {\n\t\treturn JSON.parse(raw) as Envelope;\n\t} catch {\n\t\tthrow new Error(\"filetools extract produced a malformed envelope\");\n\t}\n}\n\n/** Look up a cached extraction for `absolutePath`, if one is still valid. */\nexport function getExtractRecord(absolutePath: string): ExtractRecord | undefined {\n\tconst record = records.get(absolutePath);\n\tif (!record) return undefined;\n\t// Drop a stale record if the source changed since extract; reconstruct would\n\t// fail the binary's hash-drift guard anyway, but a clearer error is better.\n\tif (record.signature !== statSignature(absolutePath)) {\n\t\trecords.delete(absolutePath);\n\t\treturn undefined;\n\t}\n\treturn record;\n}\n\n/** Drop any cached extraction for `absolutePath`. */\nexport function invalidateExtractRecord(absolutePath: string): void {\n\trecords.delete(absolutePath);\n}\n\n/**\n * Thrown when a patch references node ids that are absent from the current\n * extract — typically because the document was rewritten out-of-band (e.g. by a\n * script) after the ids were read, or the patch was authored against an older\n * extract. Carries the freshly re-extracted envelope so the caller can surface\n * current ids to the agent without forcing a separate DocRead.\n */\nexport class StalePatchError extends Error {\n\treadonly envelope: Envelope;\n\treadonly missingIds: string[];\n\tconstructor(envelope: Envelope, missingIds: string[]) {\n\t\tsuper(\n\t\t\t`patch references ${missingIds.length} node id${missingIds.length === 1 ? \"\" : \"s\"} that no longer exist ` +\n\t\t\t\t`in ${basename(envelope.source.path)} (${missingIds.slice(0, 5).join(\", \")}` +\n\t\t\t\t`${missingIds.length > 5 ? \", …\" : \"\"}). The document was re-extracted; re-issue the patch against the ids below.`,\n\t\t);\n\t\tthis.name = \"StalePatchError\";\n\t\tthis.envelope = envelope;\n\t\tthis.missingIds = missingIds;\n\t}\n}\n\n/**\n * Return a valid cached extract for `absolutePath`, re-extracting automatically\n * when the cache is missing or stale (e.g. the source changed on disk since the\n * last extract). This keeps DocEdit/DocWrite usable after an out-of-band write\n * without forcing the agent to call DocRead again.\n */\nexport async function ensureExtractRecord(\n\tabsolutePath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { timeoutSecs?: number },\n): Promise<ExtractRecord> {\n\tconst existing = getExtractRecord(absolutePath);\n\tif (existing) return existing;\n\tinvalidateExtractRecord(absolutePath);\n\tawait extractDocument(absolutePath, cwd, signal, { timeoutSecs: options?.timeoutSecs });\n\tconst record = getExtractRecord(absolutePath);\n\tif (!record) {\n\t\tthrow new Error(`failed to extract ${basename(absolutePath)} — the document tools could not read it`);\n\t}\n\treturn record;\n}\n\n/**\n * Apply `patch` to a document, writing the reconstructed bytes to `outPath`.\n * Auto-extracts when the cache is missing or stale (so an out-of-band rewrite no\n * longer forces a manual DocRead), then validates that the patch's node ids\n * still exist in the current extract. A mismatch throws {@link StalePatchError}\n * carrying the fresh envelope so the caller can show current ids.\n */\nexport async function reconstructDocument(\n\tabsolutePath: string,\n\tpatch: Patch,\n\toutPath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { timeoutSecs?: number },\n): Promise<void> {\n\tconst record = await ensureExtractRecord(absolutePath, cwd, signal, options);\n\tif (!record.envelope.writable) {\n\t\tthrow new Error(\n\t\t\t`${basename(absolutePath)} is read-only (fidelity ${record.envelope.fidelity}); it cannot be edited`,\n\t\t);\n\t}\n\tconst missingIds = findMissingPatchIds(patch.patch, record.envelope.structure);\n\tif (missingIds.length > 0) {\n\t\tthrow new StalePatchError(record.envelope, missingIds);\n\t}\n\n\tconst binaryPath = await resolveBinary();\n\tconst patchPath = join(getWorkDir(), pathKey(absolutePath), \"patch.json\");\n\twriteFileSync(patchPath, JSON.stringify(patch), \"utf8\");\n\ttry {\n\t\tawait runFiletools(\n\t\t\tbinaryPath,\n\t\t\t\"reconstruct\",\n\t\t\t[\"--envelope\", record.envelopePath, \"--patch\", patchPath, \"--out\", outPath, \"--original\", absolutePath],\n\t\t\tcwd,\n\t\t\tsignal,\n\t\t\toptions?.timeoutSecs ?? FILETOOLS_DEFAULT_TIMEOUT_SECS,\n\t\t);\n\t} finally {\n\t\trmSync(patchPath, { force: true });\n\t}\n}\n"]}
|
|
@@ -26,6 +26,41 @@ import { ensureTool } from "../../utils/tools-manager.js";
|
|
|
26
26
|
import { execCommand } from "../exec.js";
|
|
27
27
|
/** Default timeout (seconds) for a single filetools invocation. */
|
|
28
28
|
export const FILETOOLS_DEFAULT_TIMEOUT_SECS = 30;
|
|
29
|
+
/**
|
|
30
|
+
* Soft token ceiling for a single DocRead render. The filetools binary has no
|
|
31
|
+
* pagination, so a dense file (e.g. a large spreadsheet) can project into a
|
|
32
|
+
* huge id-addressed dump that floods the model context and burns tokens. We
|
|
33
|
+
* cannot make the extract itself smaller without the binary's help, so DocRead
|
|
34
|
+
* truncates the rendered view to roughly this budget and tells the model how to
|
|
35
|
+
* narrow it (readonly projection, a smaller/targeted file, or direct edits).
|
|
36
|
+
*/
|
|
37
|
+
export const DOCREAD_MAX_RENDER_TOKENS = 10000;
|
|
38
|
+
/** Rough token estimate (chars/4), matching the agent's compaction heuristic. */
|
|
39
|
+
export function estimateTextTokens(text) {
|
|
40
|
+
return Math.ceil(text.length / 4);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Truncate rendered envelope lines to roughly `maxTokens`, keeping whole lines.
|
|
44
|
+
* Returns the kept text plus how many lines were dropped (0 when nothing was
|
|
45
|
+
* truncated).
|
|
46
|
+
*/
|
|
47
|
+
export function truncateRenderToTokenBudget(lines, maxTokens = DOCREAD_MAX_RENDER_TOKENS) {
|
|
48
|
+
const full = lines.join("\n");
|
|
49
|
+
if (estimateTextTokens(full) <= maxTokens) {
|
|
50
|
+
return { text: full, droppedLines: 0 };
|
|
51
|
+
}
|
|
52
|
+
const budgetChars = maxTokens * 4;
|
|
53
|
+
const kept = [];
|
|
54
|
+
let used = 0;
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
const next = used + line.length + 1; // + newline
|
|
57
|
+
if (next > budgetChars && kept.length > 0)
|
|
58
|
+
break;
|
|
59
|
+
kept.push(line);
|
|
60
|
+
used = next;
|
|
61
|
+
}
|
|
62
|
+
return { text: kept.join("\n"), droppedLines: lines.length - kept.length };
|
|
63
|
+
}
|
|
29
64
|
// ----------------------------------------------------------------------------
|
|
30
65
|
// TypeBox schema for the model-facing patch input (shared by DocEdit/DocWrite)
|
|
31
66
|
// ----------------------------------------------------------------------------
|
|
@@ -73,6 +108,47 @@ export const patchOpsSchema = Type.Array(patchOpSchema, {
|
|
|
73
108
|
export function toPatch(ops) {
|
|
74
109
|
return { patch: ops };
|
|
75
110
|
}
|
|
111
|
+
/** Find a node by id anywhere in a (recursive) structure tree. */
|
|
112
|
+
export function findNodeById(nodes, id) {
|
|
113
|
+
for (const node of nodes) {
|
|
114
|
+
if (node.id === id)
|
|
115
|
+
return node;
|
|
116
|
+
if (node.children) {
|
|
117
|
+
const hit = findNodeById(node.children, id);
|
|
118
|
+
if (hit)
|
|
119
|
+
return hit;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Extract the target node id from a patch op pointer. Returns undefined for ops
|
|
126
|
+
* that reference a node by anchor (`add`) rather than a `/structure/<id>/...`
|
|
127
|
+
* path. Pointer shapes: `/structure/<id>`, `/structure/<id>/text`,
|
|
128
|
+
* `/structure/<id>/attrs/<name>`.
|
|
129
|
+
*/
|
|
130
|
+
export function patchOpNodeId(op) {
|
|
131
|
+
if (op.op === "add")
|
|
132
|
+
return op.after ?? op.before;
|
|
133
|
+
const parts = op.path.split("/");
|
|
134
|
+
// ["", "structure", "<id>", ...]
|
|
135
|
+
return parts[1] === "structure" ? parts[2] : undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Validate that every node id referenced by `ops` still exists in `structure`.
|
|
139
|
+
* Returns the ids that are missing (empty array means the patch is applicable to
|
|
140
|
+
* this extract). Used to detect when a patch was authored against a stale
|
|
141
|
+
* extract — e.g. after an external tool rewrote the document.
|
|
142
|
+
*/
|
|
143
|
+
export function findMissingPatchIds(ops, structure) {
|
|
144
|
+
const missing = [];
|
|
145
|
+
for (const op of ops) {
|
|
146
|
+
const id = patchOpNodeId(op);
|
|
147
|
+
if (id && !findNodeById(structure, id))
|
|
148
|
+
missing.push(id);
|
|
149
|
+
}
|
|
150
|
+
return missing;
|
|
151
|
+
}
|
|
76
152
|
// ============================================================================
|
|
77
153
|
// Binary runner + working directory
|
|
78
154
|
// ============================================================================
|
|
@@ -183,18 +259,58 @@ export function invalidateExtractRecord(absolutePath) {
|
|
|
183
259
|
records.delete(absolutePath);
|
|
184
260
|
}
|
|
185
261
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
262
|
+
* Thrown when a patch references node ids that are absent from the current
|
|
263
|
+
* extract — typically because the document was rewritten out-of-band (e.g. by a
|
|
264
|
+
* script) after the ids were read, or the patch was authored against an older
|
|
265
|
+
* extract. Carries the freshly re-extracted envelope so the caller can surface
|
|
266
|
+
* current ids to the agent without forcing a separate DocRead.
|
|
189
267
|
*/
|
|
190
|
-
export
|
|
268
|
+
export class StalePatchError extends Error {
|
|
269
|
+
envelope;
|
|
270
|
+
missingIds;
|
|
271
|
+
constructor(envelope, missingIds) {
|
|
272
|
+
super(`patch references ${missingIds.length} node id${missingIds.length === 1 ? "" : "s"} that no longer exist ` +
|
|
273
|
+
`in ${basename(envelope.source.path)} (${missingIds.slice(0, 5).join(", ")}` +
|
|
274
|
+
`${missingIds.length > 5 ? ", …" : ""}). The document was re-extracted; re-issue the patch against the ids below.`);
|
|
275
|
+
this.name = "StalePatchError";
|
|
276
|
+
this.envelope = envelope;
|
|
277
|
+
this.missingIds = missingIds;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Return a valid cached extract for `absolutePath`, re-extracting automatically
|
|
282
|
+
* when the cache is missing or stale (e.g. the source changed on disk since the
|
|
283
|
+
* last extract). This keeps DocEdit/DocWrite usable after an out-of-band write
|
|
284
|
+
* without forcing the agent to call DocRead again.
|
|
285
|
+
*/
|
|
286
|
+
export async function ensureExtractRecord(absolutePath, cwd, signal, options) {
|
|
287
|
+
const existing = getExtractRecord(absolutePath);
|
|
288
|
+
if (existing)
|
|
289
|
+
return existing;
|
|
290
|
+
invalidateExtractRecord(absolutePath);
|
|
291
|
+
await extractDocument(absolutePath, cwd, signal, { timeoutSecs: options?.timeoutSecs });
|
|
191
292
|
const record = getExtractRecord(absolutePath);
|
|
192
293
|
if (!record) {
|
|
193
|
-
throw new Error(`
|
|
294
|
+
throw new Error(`failed to extract ${basename(absolutePath)} — the document tools could not read it`);
|
|
194
295
|
}
|
|
296
|
+
return record;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Apply `patch` to a document, writing the reconstructed bytes to `outPath`.
|
|
300
|
+
* Auto-extracts when the cache is missing or stale (so an out-of-band rewrite no
|
|
301
|
+
* longer forces a manual DocRead), then validates that the patch's node ids
|
|
302
|
+
* still exist in the current extract. A mismatch throws {@link StalePatchError}
|
|
303
|
+
* carrying the fresh envelope so the caller can show current ids.
|
|
304
|
+
*/
|
|
305
|
+
export async function reconstructDocument(absolutePath, patch, outPath, cwd, signal, options) {
|
|
306
|
+
const record = await ensureExtractRecord(absolutePath, cwd, signal, options);
|
|
195
307
|
if (!record.envelope.writable) {
|
|
196
308
|
throw new Error(`${basename(absolutePath)} is read-only (fidelity ${record.envelope.fidelity}); it cannot be edited`);
|
|
197
309
|
}
|
|
310
|
+
const missingIds = findMissingPatchIds(patch.patch, record.envelope.structure);
|
|
311
|
+
if (missingIds.length > 0) {
|
|
312
|
+
throw new StalePatchError(record.envelope, missingIds);
|
|
313
|
+
}
|
|
198
314
|
const binaryPath = await resolveBinary();
|
|
199
315
|
const patchPath = join(getWorkDir(), pathKey(absolutePath), "patch.json");
|
|
200
316
|
writeFileSync(patchPath, JSON.stringify(patch), "utf8");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"filetools-shared.js","sourceRoot":"","sources":["../../../src/core/tools/filetools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `DocRead` / `DocEdit` / `DocWrite` tools.\n *\n * All three shell out to the `filetools` binary (extract / reconstruct\n * subcommands, resolved/downloaded via {@link ensureTool}) to losslessly\n * project structured/binary documents (XML, drawio, OOXML, PDF) into editable,\n * id-addressed JSON and reconstruct them after id-based patches.\n *\n * Unlike webtools, the filetools CLI is file-oriented, not stdout-oriented:\n * `extract` writes the envelope JSON to `--out` and the sidecar id-map next to\n * it, emitting only a human status line on stderr. This module therefore:\n * - owns a per-process working directory where envelopes + sidecars live,\n * - runs extract/reconstruct and reads the resulting files back,\n * - keeps a small cache mapping a source file to its extracted envelope +\n * sidecar, so a DocRead can be followed by a DocEdit/DocWrite (the stateful\n * extract -> patch -> reconstruct flow), and\n * - exposes the locked JSON wire types mirroring the Rust `model.rs`/`patch.rs`.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport { type Static, Type } from \"typebox\";\nimport { APP_NAME } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\n/** Default timeout (seconds) for a single filetools invocation. */\nexport const FILETOOLS_DEFAULT_TIMEOUT_SECS = 30;\n\n// ============================================================================\n// Wire types (locked against `filetools` model.rs / patch.rs)\n// ============================================================================\n\n/** How faithfully a handler can reconstruct a file after edits. */\nexport type Fidelity = \"lossless\" | \"in_place_text\" | \"read_only\";\n\nexport interface DocSource {\n\tpath: string;\n\t/** Logical format, e.g. \"xml\", \"drawio\". */\n\ttype: string;\n\t/** `sha256:<hex>` of the original bytes. */\n\thash: string;\n}\n\nexport interface DocAttr {\n\tname: string;\n\tvalue: string;\n}\n\nexport interface DocNode {\n\tid: string;\n\ttag: string;\n\tattrs?: DocAttr[];\n\ttext?: string;\n\tchildren?: DocNode[];\n}\n\n/** The extract output handed to the model. Mirrors the Rust `Envelope`. */\nexport interface Envelope {\n\tversion: string;\n\tsource: DocSource;\n\tfidelity: Fidelity;\n\twritable: boolean;\n\tidmap_ref?: string;\n\tstructure: DocNode[];\n}\n\n/** A new element for an `add` op (text-only content, v1). */\nexport interface NewElement {\n\ttag: string;\n\tattrs?: DocAttr[];\n\ttext?: string;\n}\n\n/**\n * One patch operation. RFC-6902 vocabulary, id-based pointers\n * (`/structure/<id>/text`, `/structure/<id>/attrs/<name>`), per the filetools\n * patch format.\n */\nexport type PatchOp =\n\t| { op: \"test\"; path: string; hash: string }\n\t| { op: \"replace\"; path: string; value: string }\n\t| { op: \"add\"; after?: string; before?: string; value: NewElement }\n\t| { op: \"remove\"; path: string };\n\nexport interface Patch {\n\tpatch: PatchOp[];\n}\n\n// ----------------------------------------------------------------------------\n// TypeBox schema for the model-facing patch input (shared by DocEdit/DocWrite)\n// ----------------------------------------------------------------------------\n\nconst attrSchema = Type.Object({\n\tname: Type.String(),\n\tvalue: Type.String(),\n});\n\nconst newElementSchema = Type.Object({\n\ttag: Type.String({ description: 'Element tag name, e.g. \"w:p\" or \"mxCell\".' }),\n\tattrs: Type.Optional(Type.Array(attrSchema, { description: \"Attributes in document order.\" })),\n\ttext: Type.Optional(Type.String({ description: \"Inline text content (text-only elements, v1).\" })),\n});\n\nconst patchOpSchema = Type.Union([\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"test\"),\n\t\t\tpath: Type.String({ description: \"Pointer `/structure/<id>` (or /text, /attrs/<name>) to guard.\" }),\n\t\t\thash: Type.String({ description: \"Expected content hash of the target node.\" }),\n\t\t},\n\t\t{ description: \"Optimistic guard: assert the target node's content hash before mutating.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"replace\"),\n\t\t\tpath: Type.String({\n\t\t\t\tdescription: \"`/structure/<id>/text` for element text, or `/structure/<id>/attrs/<name>` for an attribute.\",\n\t\t\t}),\n\t\t\tvalue: Type.String({ description: \"New text or attribute value.\" }),\n\t\t},\n\t\t{ description: \"Replace an element's text or an attribute value.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"add\"),\n\t\t\tafter: Type.Optional(Type.String({ description: \"Anchor node id to insert AFTER.\" })),\n\t\t\tbefore: Type.Optional(Type.String({ description: \"Anchor node id to insert BEFORE.\" })),\n\t\t\tvalue: newElementSchema,\n\t\t},\n\t\t{ description: \"Insert a new element next to an anchor. Provide exactly one of `after`/`before`.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"remove\"),\n\t\t\tpath: Type.String({ description: \"Pointer `/structure/<id>` of the element to delete.\" }),\n\t\t},\n\t\t{ description: \"Delete an element and all its bytes.\" },\n\t),\n]);\n\n/**\n * The model-facing patch parameter: an array of id-based RFC-6902 ops, matching\n * the filetools patch wire format. Shared by DocEdit and DocWrite.\n */\nexport const patchOpsSchema = Type.Array(patchOpSchema, {\n\tdescription:\n\t\t\"Ordered id-based patch ops (test/replace/add/remove) targeting node ids from a prior DocRead. Applied atomically.\",\n});\n\nexport type PatchOpsInput = Static<typeof patchOpsSchema>;\n\n/** Wrap the model-facing ops array into the binary's `{ patch: [...] }` envelope. */\nexport function toPatch(ops: PatchOpsInput): Patch {\n\treturn { patch: ops as PatchOp[] };\n}\n\n// ============================================================================\n// Binary runner + working directory\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"filetools binary unavailable and could not be downloaded — the document tools require the `filetools` CLI on PATH or a published release for this platform\";\n\n/** Lazily-created per-process working directory for envelopes + sidecars. */\nlet workDir: string | undefined;\nfunction getWorkDir(): string {\n\tif (workDir) return workDir;\n\tconst base = join(tmpdir(), `${APP_NAME}-filetools`);\n\tmkdirSync(base, { recursive: true });\n\tworkDir = mkdtempSync(join(base, \"doc-\"));\n\treturn workDir;\n}\n\n/** Short, filesystem-safe key for a source path (used to name its subdir). */\nfunction pathKey(absolutePath: string): string {\n\treturn createHash(\"sha256\").update(absolutePath).digest(\"hex\").slice(0, 16);\n}\n\nasync function resolveBinary(): Promise<string> {\n\tconst binaryPath = await ensureTool(\"filetools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\treturn binaryPath;\n}\n\nasync function runFiletools(\n\tbinaryPath: string,\n\tsubcommand: \"extract\" | \"reconstruct\",\n\targs: string[],\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\ttimeoutSecs: number,\n): Promise<string> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tconst spawnTimeoutMs = (timeoutSecs + 5) * 1000;\n\tconst result = await execCommand(binaryPath, [subcommand, ...args], cwd, { signal, timeout: spawnTimeoutMs });\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`filetools ${subcommand} timed out after ${timeoutSecs}s`);\n\tif (result.code !== 0) {\n\t\tconst stderr = result.stderr.trim();\n\t\tthrow new Error(stderr || `filetools ${subcommand} exited with code ${result.code}`);\n\t}\n\t// Status goes to stderr; callers read the produced files, not stdout.\n\treturn result.stderr.trim();\n}\n\n// ============================================================================\n// Extraction cache (source file -> extracted envelope + sidecar)\n// ============================================================================\n\nexport interface ExtractRecord {\n\t/** Absolute path of the source document. */\n\tsource: string;\n\t/** Path to the envelope JSON in the working directory. */\n\tenvelopePath: string;\n\t/** Parsed envelope (also returned to the model on DocRead). */\n\tenvelope: Envelope;\n\t/** The source's stat signature at extract time, to detect drift cheaply. */\n\tsignature: string;\n}\n\nconst records = new Map<string, ExtractRecord>();\n\nfunction statSignature(absolutePath: string): string {\n\ttry {\n\t\tconst st = statSync(absolutePath);\n\t\treturn `${st.mtimeMs}:${st.size}`;\n\t} catch {\n\t\treturn \"absent\";\n\t}\n}\n\n/**\n * Extract `absolutePath` to an envelope (+ sidecar) in the working directory,\n * cache the result keyed by the source path, and return the parsed envelope.\n *\n * `readonly` strips ids for a smaller, analysis-only projection that cannot be\n * reconstructed (DocRead's default-off mode).\n */\nexport async function extractDocument(\n\tabsolutePath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { readonly?: boolean; timeoutSecs?: number },\n): Promise<Envelope> {\n\tconst binaryPath = await resolveBinary();\n\tconst dir = join(getWorkDir(), pathKey(absolutePath));\n\tmkdirSync(dir, { recursive: true });\n\tconst envelopePath = join(dir, \"envelope.json\");\n\n\tconst args = [\"--input\", absolutePath, \"--out\", envelopePath];\n\tif (options?.readonly) args.push(\"--readonly\");\n\tawait runFiletools(binaryPath, \"extract\", args, cwd, signal, options?.timeoutSecs ?? FILETOOLS_DEFAULT_TIMEOUT_SECS);\n\n\tconst envelope = readEnvelope(envelopePath);\n\tif (!options?.readonly) {\n\t\trecords.set(absolutePath, {\n\t\t\tsource: absolutePath,\n\t\t\tenvelopePath,\n\t\t\tenvelope,\n\t\t\tsignature: statSignature(absolutePath),\n\t\t});\n\t}\n\treturn envelope;\n}\n\nfunction readEnvelope(envelopePath: string): Envelope {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(envelopePath, \"utf8\");\n\t} catch {\n\t\tthrow new Error(\"filetools extract produced no envelope\");\n\t}\n\ttry {\n\t\treturn JSON.parse(raw) as Envelope;\n\t} catch {\n\t\tthrow new Error(\"filetools extract produced a malformed envelope\");\n\t}\n}\n\n/** Look up a cached extraction for `absolutePath`, if one is still valid. */\nexport function getExtractRecord(absolutePath: string): ExtractRecord | undefined {\n\tconst record = records.get(absolutePath);\n\tif (!record) return undefined;\n\t// Drop a stale record if the source changed since extract; reconstruct would\n\t// fail the binary's hash-drift guard anyway, but a clearer error is better.\n\tif (record.signature !== statSignature(absolutePath)) {\n\t\trecords.delete(absolutePath);\n\t\treturn undefined;\n\t}\n\treturn record;\n}\n\n/** Drop any cached extraction for `absolutePath`. */\nexport function invalidateExtractRecord(absolutePath: string): void {\n\trecords.delete(absolutePath);\n}\n\n/**\n * Apply `patch` to a previously-extracted document, writing the reconstructed\n * bytes to `outPath`. Requires a prior {@link extractDocument} (the stateful\n * flow): the cached envelope + sidecar carry the id-map reconstruct needs.\n */\nexport async function reconstructDocument(\n\tabsolutePath: string,\n\tpatch: Patch,\n\toutPath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { timeoutSecs?: number },\n): Promise<void> {\n\tconst record = getExtractRecord(absolutePath);\n\tif (!record) {\n\t\tthrow new Error(\n\t\t\t`no extracted envelope for ${basename(absolutePath)} — run DocRead on it first, then DocEdit/DocWrite`,\n\t\t);\n\t}\n\tif (!record.envelope.writable) {\n\t\tthrow new Error(\n\t\t\t`${basename(absolutePath)} is read-only (fidelity ${record.envelope.fidelity}); it cannot be edited`,\n\t\t);\n\t}\n\n\tconst binaryPath = await resolveBinary();\n\tconst patchPath = join(getWorkDir(), pathKey(absolutePath), \"patch.json\");\n\twriteFileSync(patchPath, JSON.stringify(patch), \"utf8\");\n\ttry {\n\t\tawait runFiletools(\n\t\t\tbinaryPath,\n\t\t\t\"reconstruct\",\n\t\t\t[\"--envelope\", record.envelopePath, \"--patch\", patchPath, \"--out\", outPath, \"--original\", absolutePath],\n\t\t\tcwd,\n\t\t\tsignal,\n\t\t\toptions?.timeoutSecs ?? FILETOOLS_DEFAULT_TIMEOUT_SECS,\n\t\t);\n\t} finally {\n\t\trmSync(patchPath, { force: true });\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"filetools-shared.js","sourceRoot":"","sources":["../../../src/core/tools/filetools-shared.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared plumbing for the `DocRead` / `DocEdit` / `DocWrite` tools.\n *\n * All three shell out to the `filetools` binary (extract / reconstruct\n * subcommands, resolved/downloaded via {@link ensureTool}) to losslessly\n * project structured/binary documents (XML, drawio, OOXML, PDF) into editable,\n * id-addressed JSON and reconstruct them after id-based patches.\n *\n * Unlike webtools, the filetools CLI is file-oriented, not stdout-oriented:\n * `extract` writes the envelope JSON to `--out` and the sidecar id-map next to\n * it, emitting only a human status line on stderr. This module therefore:\n * - owns a per-process working directory where envelopes + sidecars live,\n * - runs extract/reconstruct and reads the resulting files back,\n * - keeps a small cache mapping a source file to its extracted envelope +\n * sidecar, so a DocRead can be followed by a DocEdit/DocWrite (the stateful\n * extract -> patch -> reconstruct flow), and\n * - exposes the locked JSON wire types mirroring the Rust `model.rs`/`patch.rs`.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport { type Static, Type } from \"typebox\";\nimport { APP_NAME } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\n/** Default timeout (seconds) for a single filetools invocation. */\nexport const FILETOOLS_DEFAULT_TIMEOUT_SECS = 30;\n\n/**\n * Soft token ceiling for a single DocRead render. The filetools binary has no\n * pagination, so a dense file (e.g. a large spreadsheet) can project into a\n * huge id-addressed dump that floods the model context and burns tokens. We\n * cannot make the extract itself smaller without the binary's help, so DocRead\n * truncates the rendered view to roughly this budget and tells the model how to\n * narrow it (readonly projection, a smaller/targeted file, or direct edits).\n */\nexport const DOCREAD_MAX_RENDER_TOKENS = 10000;\n\n/** Rough token estimate (chars/4), matching the agent's compaction heuristic. */\nexport function estimateTextTokens(text: string): number {\n\treturn Math.ceil(text.length / 4);\n}\n\n/**\n * Truncate rendered envelope lines to roughly `maxTokens`, keeping whole lines.\n * Returns the kept text plus how many lines were dropped (0 when nothing was\n * truncated).\n */\nexport function truncateRenderToTokenBudget(\n\tlines: string[],\n\tmaxTokens: number = DOCREAD_MAX_RENDER_TOKENS,\n): { text: string; droppedLines: number } {\n\tconst full = lines.join(\"\\n\");\n\tif (estimateTextTokens(full) <= maxTokens) {\n\t\treturn { text: full, droppedLines: 0 };\n\t}\n\tconst budgetChars = maxTokens * 4;\n\tconst kept: string[] = [];\n\tlet used = 0;\n\tfor (const line of lines) {\n\t\tconst next = used + line.length + 1; // + newline\n\t\tif (next > budgetChars && kept.length > 0) break;\n\t\tkept.push(line);\n\t\tused = next;\n\t}\n\treturn { text: kept.join(\"\\n\"), droppedLines: lines.length - kept.length };\n}\n\n// ============================================================================\n// Wire types (locked against `filetools` model.rs / patch.rs)\n// ============================================================================\n\n/** How faithfully a handler can reconstruct a file after edits. */\nexport type Fidelity = \"lossless\" | \"in_place_text\" | \"read_only\";\n\nexport interface DocSource {\n\tpath: string;\n\t/** Logical format, e.g. \"xml\", \"drawio\". */\n\ttype: string;\n\t/** `sha256:<hex>` of the original bytes. */\n\thash: string;\n}\n\nexport interface DocAttr {\n\tname: string;\n\tvalue: string;\n}\n\nexport interface DocNode {\n\tid: string;\n\ttag: string;\n\tattrs?: DocAttr[];\n\ttext?: string;\n\tchildren?: DocNode[];\n}\n\n/** The extract output handed to the model. Mirrors the Rust `Envelope`. */\nexport interface Envelope {\n\tversion: string;\n\tsource: DocSource;\n\tfidelity: Fidelity;\n\twritable: boolean;\n\tidmap_ref?: string;\n\tstructure: DocNode[];\n}\n\n/** A new element for an `add` op (text-only content, v1). */\nexport interface NewElement {\n\ttag: string;\n\tattrs?: DocAttr[];\n\ttext?: string;\n}\n\n/**\n * One patch operation. RFC-6902 vocabulary, id-based pointers\n * (`/structure/<id>/text`, `/structure/<id>/attrs/<name>`), per the filetools\n * patch format.\n */\nexport type PatchOp =\n\t| { op: \"test\"; path: string; hash: string }\n\t| { op: \"replace\"; path: string; value: string }\n\t| { op: \"add\"; after?: string; before?: string; value: NewElement }\n\t| { op: \"remove\"; path: string };\n\nexport interface Patch {\n\tpatch: PatchOp[];\n}\n\n// ----------------------------------------------------------------------------\n// TypeBox schema for the model-facing patch input (shared by DocEdit/DocWrite)\n// ----------------------------------------------------------------------------\n\nconst attrSchema = Type.Object({\n\tname: Type.String(),\n\tvalue: Type.String(),\n});\n\nconst newElementSchema = Type.Object({\n\ttag: Type.String({ description: 'Element tag name, e.g. \"w:p\" or \"mxCell\".' }),\n\tattrs: Type.Optional(Type.Array(attrSchema, { description: \"Attributes in document order.\" })),\n\ttext: Type.Optional(Type.String({ description: \"Inline text content (text-only elements, v1).\" })),\n});\n\nconst patchOpSchema = Type.Union([\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"test\"),\n\t\t\tpath: Type.String({ description: \"Pointer `/structure/<id>` (or /text, /attrs/<name>) to guard.\" }),\n\t\t\thash: Type.String({ description: \"Expected content hash of the target node.\" }),\n\t\t},\n\t\t{ description: \"Optimistic guard: assert the target node's content hash before mutating.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"replace\"),\n\t\t\tpath: Type.String({\n\t\t\t\tdescription: \"`/structure/<id>/text` for element text, or `/structure/<id>/attrs/<name>` for an attribute.\",\n\t\t\t}),\n\t\t\tvalue: Type.String({ description: \"New text or attribute value.\" }),\n\t\t},\n\t\t{ description: \"Replace an element's text or an attribute value.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"add\"),\n\t\t\tafter: Type.Optional(Type.String({ description: \"Anchor node id to insert AFTER.\" })),\n\t\t\tbefore: Type.Optional(Type.String({ description: \"Anchor node id to insert BEFORE.\" })),\n\t\t\tvalue: newElementSchema,\n\t\t},\n\t\t{ description: \"Insert a new element next to an anchor. Provide exactly one of `after`/`before`.\" },\n\t),\n\tType.Object(\n\t\t{\n\t\t\top: Type.Literal(\"remove\"),\n\t\t\tpath: Type.String({ description: \"Pointer `/structure/<id>` of the element to delete.\" }),\n\t\t},\n\t\t{ description: \"Delete an element and all its bytes.\" },\n\t),\n]);\n\n/**\n * The model-facing patch parameter: an array of id-based RFC-6902 ops, matching\n * the filetools patch wire format. Shared by DocEdit and DocWrite.\n */\nexport const patchOpsSchema = Type.Array(patchOpSchema, {\n\tdescription:\n\t\t\"Ordered id-based patch ops (test/replace/add/remove) targeting node ids from a prior DocRead. Applied atomically.\",\n});\n\nexport type PatchOpsInput = Static<typeof patchOpsSchema>;\n\n/** Wrap the model-facing ops array into the binary's `{ patch: [...] }` envelope. */\nexport function toPatch(ops: PatchOpsInput): Patch {\n\treturn { patch: ops as PatchOp[] };\n}\n\n/** Find a node by id anywhere in a (recursive) structure tree. */\nexport function findNodeById(nodes: DocNode[], id: string): DocNode | undefined {\n\tfor (const node of nodes) {\n\t\tif (node.id === id) return node;\n\t\tif (node.children) {\n\t\t\tconst hit = findNodeById(node.children, id);\n\t\t\tif (hit) return hit;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/**\n * Extract the target node id from a patch op pointer. Returns undefined for ops\n * that reference a node by anchor (`add`) rather than a `/structure/<id>/...`\n * path. Pointer shapes: `/structure/<id>`, `/structure/<id>/text`,\n * `/structure/<id>/attrs/<name>`.\n */\nexport function patchOpNodeId(op: PatchOp): string | undefined {\n\tif (op.op === \"add\") return op.after ?? op.before;\n\tconst parts = op.path.split(\"/\");\n\t// [\"\", \"structure\", \"<id>\", ...]\n\treturn parts[1] === \"structure\" ? parts[2] : undefined;\n}\n\n/**\n * Validate that every node id referenced by `ops` still exists in `structure`.\n * Returns the ids that are missing (empty array means the patch is applicable to\n * this extract). Used to detect when a patch was authored against a stale\n * extract — e.g. after an external tool rewrote the document.\n */\nexport function findMissingPatchIds(ops: PatchOp[], structure: DocNode[]): string[] {\n\tconst missing: string[] = [];\n\tfor (const op of ops) {\n\t\tconst id = patchOpNodeId(op);\n\t\tif (id && !findNodeById(structure, id)) missing.push(id);\n\t}\n\treturn missing;\n}\n\n// ============================================================================\n// Binary runner + working directory\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"filetools binary unavailable and could not be downloaded — the document tools require the `filetools` CLI on PATH or a published release for this platform\";\n\n/** Lazily-created per-process working directory for envelopes + sidecars. */\nlet workDir: string | undefined;\nfunction getWorkDir(): string {\n\tif (workDir) return workDir;\n\tconst base = join(tmpdir(), `${APP_NAME}-filetools`);\n\tmkdirSync(base, { recursive: true });\n\tworkDir = mkdtempSync(join(base, \"doc-\"));\n\treturn workDir;\n}\n\n/** Short, filesystem-safe key for a source path (used to name its subdir). */\nfunction pathKey(absolutePath: string): string {\n\treturn createHash(\"sha256\").update(absolutePath).digest(\"hex\").slice(0, 16);\n}\n\nasync function resolveBinary(): Promise<string> {\n\tconst binaryPath = await ensureTool(\"filetools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\treturn binaryPath;\n}\n\nasync function runFiletools(\n\tbinaryPath: string,\n\tsubcommand: \"extract\" | \"reconstruct\",\n\targs: string[],\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\ttimeoutSecs: number,\n): Promise<string> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tconst spawnTimeoutMs = (timeoutSecs + 5) * 1000;\n\tconst result = await execCommand(binaryPath, [subcommand, ...args], cwd, { signal, timeout: spawnTimeoutMs });\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`filetools ${subcommand} timed out after ${timeoutSecs}s`);\n\tif (result.code !== 0) {\n\t\tconst stderr = result.stderr.trim();\n\t\tthrow new Error(stderr || `filetools ${subcommand} exited with code ${result.code}`);\n\t}\n\t// Status goes to stderr; callers read the produced files, not stdout.\n\treturn result.stderr.trim();\n}\n\n// ============================================================================\n// Extraction cache (source file -> extracted envelope + sidecar)\n// ============================================================================\n\nexport interface ExtractRecord {\n\t/** Absolute path of the source document. */\n\tsource: string;\n\t/** Path to the envelope JSON in the working directory. */\n\tenvelopePath: string;\n\t/** Parsed envelope (also returned to the model on DocRead). */\n\tenvelope: Envelope;\n\t/** The source's stat signature at extract time, to detect drift cheaply. */\n\tsignature: string;\n}\n\nconst records = new Map<string, ExtractRecord>();\n\nfunction statSignature(absolutePath: string): string {\n\ttry {\n\t\tconst st = statSync(absolutePath);\n\t\treturn `${st.mtimeMs}:${st.size}`;\n\t} catch {\n\t\treturn \"absent\";\n\t}\n}\n\n/**\n * Extract `absolutePath` to an envelope (+ sidecar) in the working directory,\n * cache the result keyed by the source path, and return the parsed envelope.\n *\n * `readonly` strips ids for a smaller, analysis-only projection that cannot be\n * reconstructed (DocRead's default-off mode).\n */\nexport async function extractDocument(\n\tabsolutePath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { readonly?: boolean; timeoutSecs?: number },\n): Promise<Envelope> {\n\tconst binaryPath = await resolveBinary();\n\tconst dir = join(getWorkDir(), pathKey(absolutePath));\n\tmkdirSync(dir, { recursive: true });\n\tconst envelopePath = join(dir, \"envelope.json\");\n\n\tconst args = [\"--input\", absolutePath, \"--out\", envelopePath];\n\tif (options?.readonly) args.push(\"--readonly\");\n\tawait runFiletools(binaryPath, \"extract\", args, cwd, signal, options?.timeoutSecs ?? FILETOOLS_DEFAULT_TIMEOUT_SECS);\n\n\tconst envelope = readEnvelope(envelopePath);\n\tif (!options?.readonly) {\n\t\trecords.set(absolutePath, {\n\t\t\tsource: absolutePath,\n\t\t\tenvelopePath,\n\t\t\tenvelope,\n\t\t\tsignature: statSignature(absolutePath),\n\t\t});\n\t}\n\treturn envelope;\n}\n\nfunction readEnvelope(envelopePath: string): Envelope {\n\tlet raw: string;\n\ttry {\n\t\traw = readFileSync(envelopePath, \"utf8\");\n\t} catch {\n\t\tthrow new Error(\"filetools extract produced no envelope\");\n\t}\n\ttry {\n\t\treturn JSON.parse(raw) as Envelope;\n\t} catch {\n\t\tthrow new Error(\"filetools extract produced a malformed envelope\");\n\t}\n}\n\n/** Look up a cached extraction for `absolutePath`, if one is still valid. */\nexport function getExtractRecord(absolutePath: string): ExtractRecord | undefined {\n\tconst record = records.get(absolutePath);\n\tif (!record) return undefined;\n\t// Drop a stale record if the source changed since extract; reconstruct would\n\t// fail the binary's hash-drift guard anyway, but a clearer error is better.\n\tif (record.signature !== statSignature(absolutePath)) {\n\t\trecords.delete(absolutePath);\n\t\treturn undefined;\n\t}\n\treturn record;\n}\n\n/** Drop any cached extraction for `absolutePath`. */\nexport function invalidateExtractRecord(absolutePath: string): void {\n\trecords.delete(absolutePath);\n}\n\n/**\n * Thrown when a patch references node ids that are absent from the current\n * extract — typically because the document was rewritten out-of-band (e.g. by a\n * script) after the ids were read, or the patch was authored against an older\n * extract. Carries the freshly re-extracted envelope so the caller can surface\n * current ids to the agent without forcing a separate DocRead.\n */\nexport class StalePatchError extends Error {\n\treadonly envelope: Envelope;\n\treadonly missingIds: string[];\n\tconstructor(envelope: Envelope, missingIds: string[]) {\n\t\tsuper(\n\t\t\t`patch references ${missingIds.length} node id${missingIds.length === 1 ? \"\" : \"s\"} that no longer exist ` +\n\t\t\t\t`in ${basename(envelope.source.path)} (${missingIds.slice(0, 5).join(\", \")}` +\n\t\t\t\t`${missingIds.length > 5 ? \", …\" : \"\"}). The document was re-extracted; re-issue the patch against the ids below.`,\n\t\t);\n\t\tthis.name = \"StalePatchError\";\n\t\tthis.envelope = envelope;\n\t\tthis.missingIds = missingIds;\n\t}\n}\n\n/**\n * Return a valid cached extract for `absolutePath`, re-extracting automatically\n * when the cache is missing or stale (e.g. the source changed on disk since the\n * last extract). This keeps DocEdit/DocWrite usable after an out-of-band write\n * without forcing the agent to call DocRead again.\n */\nexport async function ensureExtractRecord(\n\tabsolutePath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { timeoutSecs?: number },\n): Promise<ExtractRecord> {\n\tconst existing = getExtractRecord(absolutePath);\n\tif (existing) return existing;\n\tinvalidateExtractRecord(absolutePath);\n\tawait extractDocument(absolutePath, cwd, signal, { timeoutSecs: options?.timeoutSecs });\n\tconst record = getExtractRecord(absolutePath);\n\tif (!record) {\n\t\tthrow new Error(`failed to extract ${basename(absolutePath)} — the document tools could not read it`);\n\t}\n\treturn record;\n}\n\n/**\n * Apply `patch` to a document, writing the reconstructed bytes to `outPath`.\n * Auto-extracts when the cache is missing or stale (so an out-of-band rewrite no\n * longer forces a manual DocRead), then validates that the patch's node ids\n * still exist in the current extract. A mismatch throws {@link StalePatchError}\n * carrying the fresh envelope so the caller can show current ids.\n */\nexport async function reconstructDocument(\n\tabsolutePath: string,\n\tpatch: Patch,\n\toutPath: string,\n\tcwd: string,\n\tsignal: AbortSignal | undefined,\n\toptions?: { timeoutSecs?: number },\n): Promise<void> {\n\tconst record = await ensureExtractRecord(absolutePath, cwd, signal, options);\n\tif (!record.envelope.writable) {\n\t\tthrow new Error(\n\t\t\t`${basename(absolutePath)} is read-only (fidelity ${record.envelope.fidelity}); it cannot be edited`,\n\t\t);\n\t}\n\tconst missingIds = findMissingPatchIds(patch.patch, record.envelope.structure);\n\tif (missingIds.length > 0) {\n\t\tthrow new StalePatchError(record.envelope, missingIds);\n\t}\n\n\tconst binaryPath = await resolveBinary();\n\tconst patchPath = join(getWorkDir(), pathKey(absolutePath), \"patch.json\");\n\twriteFileSync(patchPath, JSON.stringify(patch), \"utf8\");\n\ttry {\n\t\tawait runFiletools(\n\t\t\tbinaryPath,\n\t\t\t\"reconstruct\",\n\t\t\t[\"--envelope\", record.envelopePath, \"--patch\", patchPath, \"--out\", outPath, \"--original\", absolutePath],\n\t\t\tcwd,\n\t\t\tsignal,\n\t\t\toptions?.timeoutSecs ?? FILETOOLS_DEFAULT_TIMEOUT_SECS,\n\t\t);\n\t} finally {\n\t\trmSync(patchPath, { force: true });\n\t}\n}\n"]}
|