dsh-tool-docx 0.5.0

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/lib/index.js ADDED
@@ -0,0 +1,1507 @@
1
+ import { i as mapFsError, n as requireWriteBytes, r as DocxError } from "./fs-binary-D0seEN6R.js";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { defineTool } from "@deepseek-ai/dsh-tools";
4
+ import { XMLParser } from "fast-xml-parser";
5
+ import { FsError } from "@deepseek-ai/dsh-fs";
6
+ import { Buffer } from "node:buffer";
7
+ import { fromBuffer } from "yauzl";
8
+ import { AlignmentType, Document, ExternalHyperlink, HeadingLevel, LevelFormat, Packer, Paragraph, Tab, Table, TableCell, TableRow, TextRun } from "docx";
9
+ import JSZip from "jszip";
10
+ import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from "@deepseek-ai/dsh-sandbox";
11
+ //#region lib/types/docx/zip.js
12
+ /**
13
+ * Minimal bounded ZIP reader over `yauzl`: extracts every entry of a docx
14
+ * package into a name в†’ bytes map. The uncompressed total is capped so a
15
+ * compressed bomb inside an already-bounded file cannot expand without limit.
16
+ * @module dsh-tool-docx/zip
17
+ */
18
+ /**
19
+ * Read every file entry of a ZIP buffer into memory.
20
+ * @param data - the whole archive bytes (already bounded by the caller's read cap).
21
+ * @param maxUncompressedBytes - inclusive cap on the total uncompressed content.
22
+ * @returns archive-name в†’ content, directory entries omitted.
23
+ */
24
+ function readZip(data, maxUncompressedBytes) {
25
+ return new Promise((resolve, reject) => {
26
+ fromBuffer(Buffer.from(data), {
27
+ lazyEntries: true,
28
+ decodeStrings: true
29
+ }, (error, zipfile) => {
30
+ if (error) {
31
+ reject(error);
32
+ return;
33
+ }
34
+ const entries = /* @__PURE__ */ new Map();
35
+ let total = 0;
36
+ let failed = false;
37
+ const fail = (cause) => {
38
+ if (failed) return;
39
+ failed = true;
40
+ reject(cause);
41
+ };
42
+ zipfile.on("error", fail);
43
+ zipfile.on("end", () => {
44
+ if (!failed) resolve(entries);
45
+ });
46
+ zipfile.readEntry();
47
+ zipfile.on("entry", (entry) => {
48
+ if (failed) return;
49
+ if (/\/$/.test(entry.fileName)) {
50
+ zipfile.readEntry();
51
+ return;
52
+ }
53
+ zipfile.openReadStream(entry, (streamError, stream) => {
54
+ if (streamError) {
55
+ fail(streamError);
56
+ return;
57
+ }
58
+ const chunks = [];
59
+ let size = 0;
60
+ stream.on("data", (chunk) => {
61
+ size += chunk.length;
62
+ if (size > maxUncompressedBytes) {
63
+ fail(/* @__PURE__ */ new Error(`readZip: uncompressed content exceeds the ${maxUncompressedBytes}-byte limit`));
64
+ return;
65
+ }
66
+ chunks.push(chunk);
67
+ });
68
+ stream.on("end", () => {
69
+ if (failed) return;
70
+ total += size;
71
+ if (total > maxUncompressedBytes) {
72
+ fail(/* @__PURE__ */ new Error(`readZip: uncompressed content exceeds the ${maxUncompressedBytes}-byte limit`));
73
+ return;
74
+ }
75
+ entries.set(entry.fileName, Buffer.concat(chunks, size));
76
+ zipfile.readEntry();
77
+ });
78
+ stream.on("error", fail);
79
+ });
80
+ });
81
+ });
82
+ });
83
+ }
84
+ //#endregion
85
+ //#region lib/types/docx/extract.js
86
+ /**
87
+ * Extract a `.docx` package into Markdown and structured blocks: walks
88
+ * `word/document.xml` (paragraphs, runs, lists, tables, hyperlinks, images),
89
+ * resolves list numbering through `word/numbering.xml`, and reads document
90
+ * properties from `docProps/core.xml`. Pure — no I/O; callers supply the
91
+ * bounded package bytes.
92
+ * @module dsh-tool-docx/docx/extract
93
+ */
94
+ const CONTENT_TYPES = "[Content_Types].xml";
95
+ const DOCUMENT_XML = "word/document.xml";
96
+ const NUMBERING_XML = "word/numbering.xml";
97
+ const CORE_PROPS_XML = "docProps/core.xml";
98
+ const DOCUMENT_RELS_XML = "word/_rels/document.xml.rels";
99
+ const XML_OPTIONS = {
100
+ ignoreAttributes: false,
101
+ attributeNamePrefix: "@_",
102
+ trimValues: false,
103
+ parseTagValue: false,
104
+ parseAttributeValue: false,
105
+ processEntities: true
106
+ };
107
+ function asArray(value) {
108
+ if (value === void 0 || value === null) return [];
109
+ return Array.isArray(value) ? value : [value];
110
+ }
111
+ function attr(node, name) {
112
+ if (typeof node !== "object" || node === null) return void 0;
113
+ const value = node[name];
114
+ return typeof value === "string" ? value : void 0;
115
+ }
116
+ function textOf(node) {
117
+ if (node === void 0 || node === null) return "";
118
+ if (typeof node === "string") return node;
119
+ if (typeof node === "number" || typeof node === "boolean") return String(node);
120
+ if (typeof node === "object") {
121
+ const text = node["#text"];
122
+ if (typeof text === "string") return text;
123
+ }
124
+ return "";
125
+ }
126
+ /** Escape characters that carry Markdown meaning in body text. */
127
+ function escapeMarkdown(text, inCell = false) {
128
+ const escaped = text.replace(/\\/g, "\\\\").replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/`/g, "\\`").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
129
+ return inCell ? escaped.replace(/\|/g, "\\|") : escaped;
130
+ }
131
+ function renderInline(run) {
132
+ const body = escapeMarkdown(run.text);
133
+ if (run.code) return `\`${body}\``;
134
+ const italic = run.italic ? `*${body}*` : body;
135
+ const bold = run.bold ? `**${italic}**` : italic;
136
+ return run.strike ? `~~${bold}~~` : bold;
137
+ }
138
+ /** Walk one run (`w:r`) and collect its text plus inline styling. */
139
+ function parseRun(run, images) {
140
+ const rPr = run["w:rPr"];
141
+ const bold = rPr !== void 0 && "w:b" in rPr;
142
+ const italic = rPr !== void 0 && "w:i" in rPr;
143
+ const strike = rPr !== void 0 && "w:strike" in rPr;
144
+ const rFonts = rPr?.["w:rFonts"];
145
+ const code = (attr(rFonts, "@_w:ascii") ?? attr(rFonts, "@_w:hAnsi")) === "Consolas";
146
+ const parts = [];
147
+ const add = (node) => {
148
+ if (typeof node === "string") parts.push(node);
149
+ else if (typeof node === "object" && node !== null) {
150
+ const record = node;
151
+ if ("#text" in record) parts.push(String(record["#text"]));
152
+ if ("w:tab" in record) parts.push(" ");
153
+ if ("w:drawing" in record || "w:pict" in record) images.count += 1;
154
+ }
155
+ };
156
+ if ("w:t" in run) {
157
+ const t = run["w:t"];
158
+ if (Array.isArray(t)) for (const piece of t) add(piece);
159
+ else add(t);
160
+ }
161
+ return {
162
+ text: parts.join(""),
163
+ bold,
164
+ italic,
165
+ strike,
166
+ code
167
+ };
168
+ }
169
+ function parseParagraph(p, images, hyperlinks) {
170
+ const pPr = p["w:pPr"];
171
+ const style = pPr !== void 0 ? attr(pPr["w:pStyle"], "@_w:val") : void 0;
172
+ const numPr = pPr?.["w:numPr"];
173
+ const numId = numPr !== void 0 ? attr(numPr["w:numId"], "@_w:val") : void 0;
174
+ const parsedIlvl = Number.parseInt(attr(numPr?.["w:ilvl"], "@_w:val") ?? "0", 10);
175
+ const pieces = [];
176
+ let pageBreaks = 0;
177
+ let footnoteRefs = 0;
178
+ let hyperlinkCount = 0;
179
+ const consumeRuns = (runs) => {
180
+ for (const runNode of asArray(runs)) {
181
+ const run = parseRun(runNode, images);
182
+ if (run.text.length > 0) pieces.push(renderInline(run));
183
+ if ("w:br" in runNode) {
184
+ for (const br of asArray(runNode["w:br"])) if (attr(br, "@_w:type") === "page") pageBreaks += 1;
185
+ }
186
+ if ("w:footnoteReference" in runNode || "w:endnoteReference" in runNode) footnoteRefs += 1;
187
+ }
188
+ };
189
+ consumeRuns(p["w:r"]);
190
+ for (const link of asArray(p["w:hyperlink"])) {
191
+ const linkId = attr(link, "@_r:id");
192
+ const target = linkId !== void 0 ? hyperlinks.get(linkId) : void 0;
193
+ if (target !== void 0) hyperlinkCount += 1;
194
+ const before = pieces.length;
195
+ consumeRuns(link["w:r"]);
196
+ if (target !== void 0 && pieces.length > before) {
197
+ const linkText = pieces.splice(before).join("");
198
+ pieces.push(`[${linkText}](${target.replaceAll(")", "%29")})`);
199
+ }
200
+ }
201
+ return {
202
+ style,
203
+ numId,
204
+ ilvl: Number.isNaN(parsedIlvl) ? 0 : parsedIlvl,
205
+ text: pieces.join(""),
206
+ pageBreaks,
207
+ footnoteRefs,
208
+ hyperlinkCount
209
+ };
210
+ }
211
+ /** Resolve `numId` в†’ ordered/unordered from `word/numbering.xml`. */
212
+ function buildNumberingMap(entries) {
213
+ const result = /* @__PURE__ */ new Map();
214
+ const raw = entries.get(NUMBERING_XML);
215
+ if (raw === void 0) return result;
216
+ let root;
217
+ try {
218
+ root = new XMLParser(XML_OPTIONS).parse(raw.toString("utf8"));
219
+ } catch {
220
+ return result;
221
+ }
222
+ const numbering = root["w:numbering"];
223
+ if (!numbering) return result;
224
+ const abstractFormats = /* @__PURE__ */ new Map();
225
+ for (const abstractNum of asArray(numbering["w:abstractNum"])) {
226
+ const id = attr(abstractNum, "@_w:abstractNumId");
227
+ if (id === void 0) continue;
228
+ let ordered = false;
229
+ for (const lvl of asArray(abstractNum["w:lvl"])) {
230
+ if (Number.parseInt(attr(lvl, "@_w:ilvl") ?? "0", 10) !== 0) continue;
231
+ ordered = (attr(lvl["w:numFmt"], "@_w:val") ?? "decimal") !== "bullet";
232
+ }
233
+ abstractFormats.set(id, ordered);
234
+ }
235
+ for (const num of asArray(numbering["w:num"])) {
236
+ const numId = attr(num, "@_w:numId");
237
+ const abstractId = attr(num["w:abstractNumId"], "@_w:val");
238
+ if (numId === void 0 || abstractId === void 0) continue;
239
+ const ordered = abstractFormats.get(abstractId);
240
+ if (ordered !== void 0) result.set(numId, { ordered });
241
+ }
242
+ return result;
243
+ }
244
+ /** `word/_rels/document.xml.rels` в†’ relationship id в†’ external target. */
245
+ function buildHyperlinkMap(entries) {
246
+ const result = /* @__PURE__ */ new Map();
247
+ const raw = entries.get(DOCUMENT_RELS_XML);
248
+ if (raw === void 0) return result;
249
+ let root;
250
+ try {
251
+ root = new XMLParser(XML_OPTIONS).parse(raw.toString("utf8"));
252
+ } catch {
253
+ return result;
254
+ }
255
+ const relationships = root["Relationships"];
256
+ if (!relationships) return result;
257
+ for (const relationship of asArray(relationships["Relationship"])) {
258
+ const id = attr(relationship, "@_Id");
259
+ const type = attr(relationship, "@_Type") ?? "";
260
+ if (id === void 0 || !type.endsWith("/hyperlink")) continue;
261
+ const target = attr(relationship, "@_Target");
262
+ if (target !== void 0) result.set(id, target);
263
+ }
264
+ return result;
265
+ }
266
+ /** Read document properties from `docProps/core.xml`. */
267
+ function parseCoreProps(entries) {
268
+ const props = {
269
+ title: null,
270
+ author: null,
271
+ created: null
272
+ };
273
+ const raw = entries.get(CORE_PROPS_XML);
274
+ if (raw === void 0) return props;
275
+ let root;
276
+ try {
277
+ root = new XMLParser(XML_OPTIONS).parse(raw.toString("utf8"));
278
+ } catch {
279
+ return props;
280
+ }
281
+ const core = root["cp:coreProperties"];
282
+ if (!core) return props;
283
+ const title = textOf(core["dc:title"]);
284
+ const author = textOf(core["dc:creator"]);
285
+ const created = textOf(core["dcterms:created"]);
286
+ if (title.length > 0) props.title = title;
287
+ if (author.length > 0) props.author = author;
288
+ if (created.length > 0) props.created = created;
289
+ return props;
290
+ }
291
+ /** Render one table (`w:tbl`) as a markdown pipe table; null when it has no rows. */
292
+ function renderTable(tbl, warnings) {
293
+ const rows = [];
294
+ let merged = false;
295
+ for (const tr of asArray(tbl["w:tr"])) {
296
+ const cells = [];
297
+ for (const tc of asArray(tr["w:tc"])) {
298
+ const tcPr = tc["w:tcPr"];
299
+ if (tcPr !== void 0 && ("w:gridSpan" in tcPr || "w:vMerge" in tcPr)) merged = true;
300
+ const lines = asArray(tc["w:p"]).map((paragraph) => parseParagraph(paragraph, { count: 0 }, /* @__PURE__ */ new Map()).text);
301
+ cells.push(lines.join("\n"));
302
+ }
303
+ rows.push(cells);
304
+ }
305
+ const [header, ...body] = rows;
306
+ if (header === void 0) return null;
307
+ if (merged) warnings.push("the table contains merged cells; the pipe-table rendering is approximate");
308
+ const cell = (value) => escapeMarkdown(value.replaceAll("\n", "<br>"), true);
309
+ const line = (cells) => `| ${cells.map(cell).join(" | ")} |`;
310
+ const separator = `| ${header.map(() => "---").join(" | ")} |`;
311
+ return {
312
+ header: header.slice(),
313
+ rows: body,
314
+ markdown: [
315
+ line(header),
316
+ separator,
317
+ ...body.map(line)
318
+ ].join("\n")
319
+ };
320
+ }
321
+ /**
322
+ * Extract one `.docx` package into markdown + structured blocks.
323
+ * @param data - the whole package bytes (already bounded by the caller).
324
+ * @param maxUncompressedBytes - cap for the ZIP expansion.
325
+ * @returns the extraction result.
326
+ * @throws {@link DocxError} with a stable code for invalid/encrypted packages.
327
+ */
328
+ async function extractDocx(data, maxUncompressedBytes) {
329
+ let entries;
330
+ try {
331
+ entries = await readZip(data, maxUncompressedBytes);
332
+ } catch (error) {
333
+ throw new DocxError(`failed to read the .docx archive: ${error instanceof Error ? error.message : String(error)}`, "DOCX_NOT_DOCX", { cause: error });
334
+ }
335
+ const warnings = [];
336
+ const contentTypes = entries.get(CONTENT_TYPES);
337
+ if (contentTypes === void 0) throw new DocxError("the file is not a .docx document (missing [Content_Types].xml)", "DOCX_NOT_DOCX");
338
+ if (contentTypes.toString("utf8").includes("EncryptionInfo")) throw new DocxError("the document is encrypted (password-protected); decryption is not supported", "DOCX_ENCRYPTED");
339
+ const documentXml = entries.get(DOCUMENT_XML);
340
+ if (documentXml === void 0) throw new DocxError("the file is not a .docx document (missing word/document.xml)", "DOCX_NOT_DOCX");
341
+ let root;
342
+ try {
343
+ root = new XMLParser(XML_OPTIONS).parse(documentXml.toString("utf8"));
344
+ } catch (error) {
345
+ throw new DocxError(`failed to parse document XML: ${error instanceof Error ? error.message : String(error)}`, "DOCX_PARSE_ERROR", { cause: error });
346
+ }
347
+ const body = root["w:document"]?.["w:body"];
348
+ if (!body) throw new DocxError("the document has no body (word/document.xml without w:body)", "DOCX_PARSE_ERROR");
349
+ const numbering = buildNumberingMap(entries);
350
+ const hyperlinks = buildHyperlinkMap(entries);
351
+ const props = parseCoreProps(entries);
352
+ const images = { count: 0 };
353
+ const blocks = [];
354
+ const lines = [];
355
+ let pageBreaks = 0;
356
+ let footnoteRefs = 0;
357
+ let hyperlinkCount = 0;
358
+ let currentList = null;
359
+ const flushList = () => {
360
+ const list = currentList;
361
+ if (list) {
362
+ blocks.push({
363
+ kind: "list",
364
+ ordered: list.ordered,
365
+ items: list.items
366
+ });
367
+ for (const item of list.items) lines.push(`${" ".repeat(item.level)}${list.ordered ? "1." : "-"} ${item.text}`);
368
+ currentList = null;
369
+ }
370
+ };
371
+ const bodyChildren = [];
372
+ for (const key of ["w:p", "w:tbl"]) for (const node of asArray(body[key])) bodyChildren.push([key, node]);
373
+ for (const [key, node] of bodyChildren) {
374
+ if (key === "w:tbl") {
375
+ flushList();
376
+ const table = renderTable(node, warnings);
377
+ if (table) {
378
+ blocks.push({
379
+ kind: "table",
380
+ header: table.header,
381
+ rows: table.rows
382
+ });
383
+ lines.push(table.markdown);
384
+ }
385
+ continue;
386
+ }
387
+ const p = parseParagraph(node, images, hyperlinks);
388
+ pageBreaks += p.pageBreaks;
389
+ footnoteRefs += p.footnoteRefs;
390
+ hyperlinkCount += p.hyperlinkCount;
391
+ if (p.numId !== void 0) {
392
+ const ordered = numbering.get(p.numId)?.ordered ?? true;
393
+ if (!currentList || currentList.ordered !== ordered) {
394
+ flushList();
395
+ currentList = {
396
+ ordered,
397
+ items: []
398
+ };
399
+ }
400
+ currentList.items.push({
401
+ level: p.ilvl,
402
+ text: p.text
403
+ });
404
+ continue;
405
+ }
406
+ flushList();
407
+ const heading = p.style !== void 0 ? /^Heading([1-6])$/.exec(p.style) : null;
408
+ if (heading) {
409
+ const levelText = heading[1];
410
+ if (levelText !== void 0) {
411
+ const level = Number.parseInt(levelText, 10);
412
+ blocks.push({
413
+ kind: "heading",
414
+ level,
415
+ text: p.text
416
+ });
417
+ lines.push(`${"#".repeat(level)} ${p.text}`);
418
+ }
419
+ continue;
420
+ }
421
+ if (p.style === "Title") {
422
+ blocks.push({
423
+ kind: "heading",
424
+ level: 1,
425
+ text: p.text
426
+ });
427
+ lines.push(`# ${p.text}`);
428
+ continue;
429
+ }
430
+ if (p.text.trim().length === 0) continue;
431
+ blocks.push({
432
+ kind: "paragraph",
433
+ text: p.text
434
+ });
435
+ lines.push(p.text);
436
+ }
437
+ flushList();
438
+ if (images.count > 0) warnings.push(`the document contains ${images.count} image(s); image bytes are not extracted, placeholders are emitted instead`);
439
+ if (pageBreaks > 0) warnings.push("page breaks are ignored during extraction");
440
+ if (footnoteRefs > 0) warnings.push("footnotes and endnotes are not extracted");
441
+ if (hyperlinkCount > 0) warnings.push("hyperlinks are reproduced as [text](url)");
442
+ return {
443
+ props,
444
+ markdown: lines.join("\n\n"),
445
+ blocks,
446
+ images: images.count,
447
+ warnings
448
+ };
449
+ }
450
+ //#endregion
451
+ //#region lib/types/tool-utils.js
452
+ /**
453
+ * Shared helpers for the docx tools: path/extension validation, session-cwd
454
+ * resolution, observed-state emission, and common argument validation.
455
+ * @module dsh-tool-docx/tool-utils
456
+ */
457
+ const PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
458
+ /**
459
+ * Reject `.doc` with the legacy hint; everything else is parsed by content.
460
+ * @param path - the file path to check.
461
+ */
462
+ function assertSupportedExtension(path) {
463
+ if (/\.doc$/i.test(path.trim())) throw new DocxError("legacy .doc format is not supported — convert the document to .docx first", "DOCX_LEGACY_DOC");
464
+ }
465
+ /**
466
+ * Validate a non-empty file path; whitespace-only paths are rejected like the fs tool suite.
467
+ * @param path - the raw tool argument.
468
+ * @returns the same path, confirmed non-blank.
469
+ */
470
+ function requirePath(path) {
471
+ if (path.trim().length === 0) throw new Error("file_path must be a non-empty string");
472
+ return path;
473
+ }
474
+ /**
475
+ * The calling agent's session cwd, or undefined for a non-agent caller.
476
+ * @param exec - the tool-execution context; only its optional `agent` is read.
477
+ * @returns the agent's session workspace cwd, or undefined.
478
+ */
479
+ function sessionCwd(exec) {
480
+ const cwd = exec.agent?.session.header.cwd;
481
+ if (cwd === void 0 || !PARENT_PATH_SEGMENT.test(cwd)) return cwd;
482
+ return cwd;
483
+ }
484
+ /**
485
+ * Resolution options for the current call: session cwd + cancellation.
486
+ * @param exec - the tool-execution context supplying session cwd and cancellation.
487
+ * @returns provider resolution options for the current tool call.
488
+ */
489
+ function resolveOptions(exec) {
490
+ const cwd = sessionCwd(exec);
491
+ return {
492
+ ...cwd !== void 0 ? { cwd } : {},
493
+ signal: exec.signal
494
+ };
495
+ }
496
+ /**
497
+ * Record an authoritative positive observation (no-op when no policy listens).
498
+ * @param ctx - the Cordis context the event is emitted on.
499
+ * @param target - the observed target.
500
+ * @param version - the observed file version.
501
+ * @param exec - the tool-execution context, carried as the event actor.
502
+ */
503
+ function emitObserved(ctx, target, version, exec) {
504
+ ctx.emit("fs/observed", target, {
505
+ kind: "present",
506
+ version
507
+ }, exec);
508
+ }
509
+ /**
510
+ * Record a confirmed-absent observation (no-op when no policy listens).
511
+ * @param ctx - the Cordis context the event is emitted on.
512
+ * @param target - the observed (absent) target.
513
+ * @param exec - the tool-execution context, carried as the event actor.
514
+ */
515
+ function emitAbsent(ctx, target, exec) {
516
+ ctx.emit("fs/observed", target, { kind: "absent" }, exec);
517
+ }
518
+ /**
519
+ * Validate a positive-integer cap from config.
520
+ * @param name - the config field name, for the error message.
521
+ * @param value - the configured value to validate.
522
+ */
523
+ function assertPositiveInteger(name, value) {
524
+ if (!Number.isInteger(value) || value < 1) throw new Error(`tool-docx: ${name} must be a positive integer`);
525
+ }
526
+ //#endregion
527
+ //#region lib/types/tools/read.js
528
+ /**
529
+ * Model-facing `docx_read`: extract a `.docx` file as Markdown or structured
530
+ * JSON blocks. Bounded by the configured byte cap (whole file), the ZIP
531
+ * expansion cap, and the returned-markdown character cap.
532
+ * @module dsh-tool-docx/tools/read
533
+ */
534
+ function parseReadArgs(args) {
535
+ const filePath = requirePath(args.file_path);
536
+ const format = args.format ?? "markdown";
537
+ if (args.max_chars !== void 0 && (!Number.isInteger(args.max_chars) || args.max_chars < 1)) throw new Error("max_chars must be a positive integer");
538
+ return {
539
+ filePath,
540
+ format,
541
+ maxChars: args.max_chars
542
+ };
543
+ }
544
+ /** Render the read value as model-facing text: markdown, or pretty JSON blocks. */
545
+ function renderReadValue(value, maxChars) {
546
+ const body = value.format === "json" ? JSON.stringify(value.blocks, null, 2) : value.markdown ?? "";
547
+ return body.length > maxChars ? `${body.slice(0, maxChars)}\n… (truncated)` : body;
548
+ }
549
+ /**
550
+ * Register the `docx_read` tool and its system-prompt guidance.
551
+ * @param ctx - the plugin context; execution uses its `fs` service (`readBytes`
552
+ * is part of the published filesystem contract since rc.7).
553
+ * @param caps - the deployment's resolved caps.
554
+ */
555
+ function applyReadTool(ctx, caps) {
556
+ ctx.systemPrompt.section({
557
+ name: "tool:docx-read",
558
+ order: 110,
559
+ text: "MS Word .docx files are binary (ZIP+XML) and the read tool cannot read them. Use docx_read to extract a document as Markdown (default) or structured JSON blocks, docx_create to generate a new .docx from Markdown, and docx_edit to replace a document's content from Markdown while preserving its title/author/created properties. Legacy .doc is not supported — convert it to .docx first."
560
+ });
561
+ ctx.tools.register(defineTool({
562
+ name: "docx_read",
563
+ description: "Read a Microsoft Word .docx file: extract its content as Markdown or structured JSON blocks, plus document properties.",
564
+ parameters: {
565
+ file_path: {
566
+ type: "string",
567
+ required: true,
568
+ description: "Path to the .docx file, resolved by the filesystem backend."
569
+ },
570
+ format: {
571
+ type: "string",
572
+ enum: ["markdown", "json"],
573
+ description: "Output shape: markdown (default) or structured JSON blocks."
574
+ },
575
+ max_chars: {
576
+ type: "number",
577
+ description: "Optional cap on the returned markdown/JSON length (defaults to the deployment cap)."
578
+ }
579
+ },
580
+ output: {
581
+ schema: {
582
+ type: "object",
583
+ additionalProperties: false,
584
+ properties: {
585
+ path: {
586
+ type: "string",
587
+ required: true
588
+ },
589
+ format: {
590
+ type: "string",
591
+ required: true,
592
+ enum: ["markdown", "json"]
593
+ },
594
+ docProps: {
595
+ required: true,
596
+ type: "object",
597
+ additionalProperties: false,
598
+ properties: {
599
+ title: {
600
+ required: true,
601
+ oneOf: [{ type: "string" }, { type: "null" }]
602
+ },
603
+ author: {
604
+ required: true,
605
+ oneOf: [{ type: "string" }, { type: "null" }]
606
+ },
607
+ created: {
608
+ required: true,
609
+ oneOf: [{ type: "string" }, { type: "null" }]
610
+ }
611
+ }
612
+ },
613
+ charCount: {
614
+ type: "number",
615
+ required: true
616
+ },
617
+ images: {
618
+ type: "number",
619
+ required: true
620
+ },
621
+ warnings: {
622
+ type: "array",
623
+ required: true,
624
+ items: { type: "string" }
625
+ },
626
+ markdown: {
627
+ required: true,
628
+ oneOf: [{ type: "string" }, { type: "null" }]
629
+ },
630
+ blocks: {
631
+ required: true,
632
+ oneOf: [{
633
+ type: "array",
634
+ items: {
635
+ type: "object",
636
+ additionalProperties: true
637
+ }
638
+ }, { type: "null" }]
639
+ }
640
+ }
641
+ },
642
+ render: (_args, value) => [{
643
+ type: "text",
644
+ text: renderReadValue(value, caps.maxReadChars)
645
+ }]
646
+ },
647
+ async execute(args, exec) {
648
+ const input = parseReadArgs(args);
649
+ assertSupportedExtension(input.filePath);
650
+ const fs = ctx.fs;
651
+ const target = await fs.resolve(input.filePath, resolveOptions(exec));
652
+ const info = await fs.stat(target, exec.signal);
653
+ if (!info) {
654
+ emitAbsent(ctx, target, exec);
655
+ throw new DocxError(`file not found: ${target.displayPath}`, "DOCX_NOT_FOUND");
656
+ }
657
+ if (info.type !== "file") throw new DocxError(`cannot read "${target.displayPath}": not a regular file`, "DOCX_NOT_REGULAR_FILE");
658
+ let data;
659
+ try {
660
+ data = await fs.readBytes(target, exec.signal, caps.maxDocxBytes);
661
+ } catch (error) {
662
+ throw mapFsError(error);
663
+ }
664
+ const extracted = await extractDocx(data, caps.maxDocxBytes);
665
+ const cap = input.maxChars ?? caps.maxReadChars;
666
+ let markdown = extracted.markdown;
667
+ let warnings = extracted.warnings;
668
+ if (input.format === "markdown" && markdown.length > cap) {
669
+ markdown = markdown.slice(0, cap);
670
+ warnings = [...warnings, `output truncated to ${cap} characters`];
671
+ }
672
+ emitObserved(ctx, target, info.version, exec);
673
+ return {
674
+ path: target.displayPath,
675
+ format: input.format,
676
+ docProps: extracted.props,
677
+ charCount: markdown.length,
678
+ images: extracted.images,
679
+ warnings,
680
+ markdown: input.format === "markdown" ? markdown : null,
681
+ blocks: input.format === "json" ? extracted.blocks : null
682
+ };
683
+ },
684
+ presentCall(args) {
685
+ return {
686
+ card: "generic",
687
+ title: `Read ${args.file_path}`,
688
+ kind: "read",
689
+ locations: [{ path: args.file_path }]
690
+ };
691
+ },
692
+ presentResult(args, result) {
693
+ if (result.isError) return void 0;
694
+ return {
695
+ card: "generic",
696
+ title: `Read ${args.file_path}`
697
+ };
698
+ }
699
+ }));
700
+ }
701
+ //#endregion
702
+ //#region lib/types/markdown.js
703
+ /**
704
+ * Markdown в†’ block parsing for the docx generator: headings, paragraphs,
705
+ * nested lists, pipe tables, and inline formatting (`**bold**`, `*italic*`,
706
+ * `` `code` ``, `~~strike~~`, `[text](url)`). The supported subset is
707
+ * deliberately small and matches what {@link extractDocx} emits, so a
708
+ * read в†’ edit в†’ write round trip is stable. Unsupported constructs degrade to
709
+ * paragraphs with a warning instead of failing.
710
+ * @module dsh-tool-docx/markdown
711
+ */
712
+ const INLINE_PATTERN = /(\*\*[^*\n]+\*\*|\*[^*\n]+\*|~~[^~\n]+~~|`[^`\n]+`|\[[^\]\n]+\]\([^)\n]+\))/g;
713
+ /** Unescape the markdown-significant escapes produced by extraction. */
714
+ function unescapeMarkdown(text) {
715
+ return text.replace(/\\([\\*_`[\]|])/g, "$1");
716
+ }
717
+ /**
718
+ * Split inline text into styled segments. Bare asterisks, unterminated
719
+ * markers, and stray brackets stay literal text.
720
+ * @param text - inline markdown text (escapes from extraction are unescaped).
721
+ * @returns ordered segments; adjacent plain text is not merged.
722
+ */
723
+ function parseInline(text) {
724
+ const segments = [];
725
+ let cursor = 0;
726
+ for (const match of text.matchAll(INLINE_PATTERN)) {
727
+ const index = match.index;
728
+ if (index > cursor) segments.push({ text: unescapeMarkdown(text.slice(cursor, index)) });
729
+ const token = match[0];
730
+ cursor = index + token.length;
731
+ if (token.startsWith("**") && token.endsWith("**") && token.length > 4) segments.push({
732
+ text: unescapeMarkdown(token.slice(2, -2)),
733
+ bold: true
734
+ });
735
+ else if (token.startsWith("*") && token.endsWith("*") && token.length > 2) segments.push({
736
+ text: unescapeMarkdown(token.slice(1, -1)),
737
+ italic: true
738
+ });
739
+ else if (token.startsWith("~~") && token.endsWith("~~") && token.length > 4) segments.push({
740
+ text: unescapeMarkdown(token.slice(2, -2)),
741
+ strike: true
742
+ });
743
+ else if (token.startsWith("`") && token.endsWith("`") && token.length > 2) segments.push({
744
+ text: token.slice(1, -1),
745
+ code: true
746
+ });
747
+ else {
748
+ const link = /^\[([^\]\n]+)\]\(([^)\n]+)\)$/.exec(token);
749
+ const text = link?.[1];
750
+ const url = link?.[2];
751
+ if (text !== void 0 && url !== void 0) segments.push({
752
+ text: unescapeMarkdown(text),
753
+ link: url
754
+ });
755
+ else segments.push({ text: unescapeMarkdown(token) });
756
+ }
757
+ }
758
+ if (cursor < text.length) segments.push({ text: unescapeMarkdown(text.slice(cursor)) });
759
+ return segments;
760
+ }
761
+ /** One parsed table row (raw cell strings, trimmed, unescaped). */
762
+ function splitTableRow(line) {
763
+ const trimmed = line.trim();
764
+ const body = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
765
+ return (body.endsWith("|") ? body.slice(0, -1) : body).split("|").map((cell) => unescapeMarkdown(cell.trim()));
766
+ }
767
+ const TABLE_SEPARATOR = /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
768
+ /**
769
+ * Parse a markdown document into structured blocks.
770
+ * @param markdown - the markdown source (must fit the caller's input cap).
771
+ * @param warnings - receives human-readable notes about unsupported constructs.
772
+ * @returns the blocks the generator renders; an empty document yields `[]`.
773
+ */
774
+ function parseMarkdown(markdown, warnings) {
775
+ const lines = markdown.replaceAll("\r\n", "\n").split("\n");
776
+ const blocks = [];
777
+ let codeWarning = false;
778
+ let imageWarning = false;
779
+ const flushParagraph = (buffer) => {
780
+ const text = buffer.join("\n").trimEnd();
781
+ if (text.length > 0) blocks.push({
782
+ kind: "paragraph",
783
+ text
784
+ });
785
+ };
786
+ let paragraph = [];
787
+ let list = null;
788
+ let inCodeFence = null;
789
+ let codeBuffer = [];
790
+ const flushList = () => {
791
+ if (list) {
792
+ blocks.push({
793
+ kind: "list",
794
+ ordered: list.ordered,
795
+ items: list.items
796
+ });
797
+ list = null;
798
+ }
799
+ };
800
+ const emitParagraph = () => {
801
+ flushList();
802
+ if (paragraph.length > 0) {
803
+ flushParagraph(paragraph);
804
+ paragraph = [];
805
+ }
806
+ };
807
+ let index = 0;
808
+ while (index < lines.length) {
809
+ const line = lines[index];
810
+ if (line === void 0) break;
811
+ if (inCodeFence !== null) {
812
+ if (line.trim().startsWith(inCodeFence)) {
813
+ inCodeFence = null;
814
+ if (codeBuffer.length > 0) {
815
+ emitParagraph();
816
+ blocks.push({
817
+ kind: "paragraph",
818
+ text: codeBuffer.join("\n")
819
+ });
820
+ codeBuffer = [];
821
+ }
822
+ index += 1;
823
+ continue;
824
+ }
825
+ codeBuffer.push(line);
826
+ index += 1;
827
+ continue;
828
+ }
829
+ const fence = /^```|^~~~/.exec(line.trim());
830
+ if (fence) {
831
+ emitParagraph();
832
+ if (!codeWarning) {
833
+ warnings.push("code blocks become paragraphs with code styling");
834
+ codeWarning = true;
835
+ }
836
+ inCodeFence = fence[0];
837
+ codeBuffer = [];
838
+ index += 1;
839
+ continue;
840
+ }
841
+ if (line.trim().length === 0) {
842
+ emitParagraph();
843
+ index += 1;
844
+ continue;
845
+ }
846
+ const next = lines[index + 1];
847
+ if (line.trim().startsWith("|") && next !== void 0 && TABLE_SEPARATOR.test(next.trim())) {
848
+ emitParagraph();
849
+ const header = splitTableRow(line);
850
+ index += 2;
851
+ const rows = [];
852
+ while (index < lines.length) {
853
+ const rowLine = lines[index];
854
+ if (rowLine === void 0 || !rowLine.trim().startsWith("|")) break;
855
+ rows.push(splitTableRow(rowLine));
856
+ index += 1;
857
+ }
858
+ blocks.push({
859
+ kind: "table",
860
+ header,
861
+ rows
862
+ });
863
+ continue;
864
+ }
865
+ const heading = /^(#{1,6})\s+(.+)$/.exec(line);
866
+ if (heading) {
867
+ emitParagraph();
868
+ const hashes = heading[1];
869
+ const text = heading[2];
870
+ if (hashes !== void 0 && text !== void 0) blocks.push({
871
+ kind: "heading",
872
+ level: hashes.length,
873
+ text: text.trim()
874
+ });
875
+ index += 1;
876
+ continue;
877
+ }
878
+ const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
879
+ if (item) {
880
+ const indent = item[1];
881
+ const marker = item[2];
882
+ const rest = item[3];
883
+ if (indent !== void 0 && marker !== void 0 && rest !== void 0 && !(marker === "-" && rest.trim().length === 0)) {
884
+ emitParagraph();
885
+ const level = Math.max(1, Math.floor(indent.length / 2));
886
+ const ordered = /^\d/.test(marker);
887
+ if (!list || list.ordered !== ordered) {
888
+ flushList();
889
+ list = {
890
+ ordered,
891
+ items: []
892
+ };
893
+ }
894
+ const image = /^!\[([^\]\n]*)\]\([^)\n]+\)$/.exec(rest.trim());
895
+ const alt = image?.[1];
896
+ if (image) {
897
+ if (!imageWarning) {
898
+ warnings.push("images are not supported when generating; only the alt text is kept");
899
+ imageWarning = true;
900
+ }
901
+ const altText = alt ?? "";
902
+ list.items.push({
903
+ level,
904
+ text: altText.length > 0 ? altText : "image"
905
+ });
906
+ } else list.items.push({
907
+ level,
908
+ text: rest.trim()
909
+ });
910
+ }
911
+ index += 1;
912
+ continue;
913
+ }
914
+ const image = /^!\[([^\]\n]*)\]\([^)\n]+\)$/.exec(line.trim());
915
+ if (image) {
916
+ emitParagraph();
917
+ if (!imageWarning) {
918
+ warnings.push("images are not supported when generating; only the alt text is kept");
919
+ imageWarning = true;
920
+ }
921
+ const alt = image[1] ?? "";
922
+ blocks.push({
923
+ kind: "paragraph",
924
+ text: alt.length > 0 ? alt : "image"
925
+ });
926
+ index += 1;
927
+ continue;
928
+ }
929
+ paragraph.push(line.replace(/^>\s?/, ""));
930
+ index += 1;
931
+ }
932
+ emitParagraph();
933
+ if (inCodeFence !== null && codeBuffer.length > 0) blocks.push({
934
+ kind: "paragraph",
935
+ text: codeBuffer.join("\n")
936
+ });
937
+ return blocks;
938
+ }
939
+ //#endregion
940
+ //#region lib/types/docx/generate.js
941
+ /**
942
+ * Generate a `.docx` package buffer from structured blocks using the `docx`
943
+ * library: headings, paragraphs with inline styling, nested bullet/numbered
944
+ * lists, pipe tables, and external hyperlinks. Document properties come from
945
+ * the caller (extracted from the previous version on an edit).
946
+ * @module dsh-tool-docx/docx/generate
947
+ */
948
+ const NUMBER_REFERENCE = "dsh-ordered";
949
+ const BULLET_REFERENCE = "dsh-bullet";
950
+ /** Indent (twips) per numbering level: 0.5" step, hanging first line. */
951
+ function levelStyle(level) {
952
+ return { paragraph: { indent: {
953
+ left: 720 + level * 720,
954
+ hanging: 360
955
+ } } };
956
+ }
957
+ function numberingLevels(format, text) {
958
+ return Array.from({ length: 9 }, (_, level) => ({
959
+ level,
960
+ format,
961
+ text: text(level),
962
+ alignment: AlignmentType.LEFT,
963
+ style: levelStyle(level)
964
+ }));
965
+ }
966
+ const NUMBERING = { config: [{
967
+ reference: NUMBER_REFERENCE,
968
+ levels: numberingLevels(LevelFormat.DECIMAL, (level) => `%${level + 1}.`)
969
+ }, {
970
+ reference: BULLET_REFERENCE,
971
+ levels: numberingLevels(LevelFormat.BULLET, () => "•")
972
+ }] };
973
+ const HEADING_LEVELS = {
974
+ 1: HeadingLevel.HEADING_1,
975
+ 2: HeadingLevel.HEADING_2,
976
+ 3: HeadingLevel.HEADING_3,
977
+ 4: HeadingLevel.HEADING_4,
978
+ 5: HeadingLevel.HEADING_5,
979
+ 6: HeadingLevel.HEADING_6
980
+ };
981
+ /** Convert inline segments into docx run elements (tabs become `Tab` elements). */
982
+ function inlineToRuns(segments) {
983
+ const runs = [];
984
+ for (const segment of segments) {
985
+ if (segment.text.length === 0) continue;
986
+ if (segment.link !== void 0) {
987
+ runs.push(new ExternalHyperlink({
988
+ children: [new TextRun({ text: segment.text })],
989
+ link: segment.link
990
+ }));
991
+ continue;
992
+ }
993
+ segment.text.split(" ").forEach((part, index) => {
994
+ if (index > 0) runs.push(new Tab());
995
+ if (part.length === 0) return;
996
+ runs.push(new TextRun({
997
+ text: part,
998
+ ...segment.bold ? { bold: true } : {},
999
+ ...segment.italic ? { italics: true } : {},
1000
+ ...segment.strike ? { strike: true } : {},
1001
+ ...segment.code ? {
1002
+ font: { name: "Consolas" },
1003
+ color: "1F3864"
1004
+ } : {}
1005
+ }));
1006
+ });
1007
+ }
1008
+ return runs;
1009
+ }
1010
+ /** One paragraph element from inline-markdown text. */
1011
+ function paragraphFromText(text) {
1012
+ return new Paragraph({ children: inlineToRuns(parseInline(text)) });
1013
+ }
1014
+ /** Render blocks into docx section children (paragraphs + tables). */
1015
+ function renderBlocks(blocks) {
1016
+ const children = [];
1017
+ for (const block of blocks) switch (block.kind) {
1018
+ case "heading": {
1019
+ const level = HEADING_LEVELS[block.level] ?? HeadingLevel.HEADING_1;
1020
+ children.push(new Paragraph({
1021
+ heading: level,
1022
+ children: inlineToRuns(parseInline(block.text))
1023
+ }));
1024
+ break;
1025
+ }
1026
+ case "paragraph":
1027
+ children.push(paragraphFromText(block.text));
1028
+ break;
1029
+ case "list":
1030
+ for (const item of block.items) {
1031
+ const level = Math.min(8, Math.max(0, item.level - 1));
1032
+ children.push(new Paragraph({
1033
+ numbering: {
1034
+ reference: block.ordered ? NUMBER_REFERENCE : BULLET_REFERENCE,
1035
+ level
1036
+ },
1037
+ children: inlineToRuns(parseInline(item.text))
1038
+ }));
1039
+ }
1040
+ break;
1041
+ case "table": {
1042
+ const row = (cells) => new TableRow({ children: cells.map((cell) => new TableCell({ children: cell.split("\n").map((line) => paragraphFromText(line)) })) });
1043
+ const rows = [];
1044
+ if (block.header) rows.push(row(block.header));
1045
+ for (const bodyRow of block.rows) rows.push(row(bodyRow));
1046
+ children.push(new Table({ rows }));
1047
+ break;
1048
+ }
1049
+ }
1050
+ return children;
1051
+ }
1052
+ /** Escape XML text content for core-properties elements. */
1053
+ function escapeXml(value) {
1054
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
1055
+ }
1056
+ /**
1057
+ * The `docx` library stamps default core properties (current time as
1058
+ * `dcterms:created`, `Un-named` as `dc:creator`); patch the packed package's
1059
+ * `docProps/core.xml` so an edit round trip keeps the document's exact
1060
+ * title/author/created — and removes the elements when the value is null.
1061
+ * @param packed - the freshly packed `.docx` bytes.
1062
+ * @param props - the document properties to stamp.
1063
+ * @returns the repacked bytes with the patched core-properties document.
1064
+ */
1065
+ async function patchCoreProps(packed, props) {
1066
+ const zip = await JSZip.loadAsync(packed);
1067
+ const corePath = "docProps/core.xml";
1068
+ const file = zip.file(corePath);
1069
+ if (file === null) return packed;
1070
+ let core = await file.async("string");
1071
+ const setOrRemove = (xml, tag, value) => {
1072
+ const match = new RegExp(`<${tag}[^>]*>.*?</${tag}>`).exec(xml);
1073
+ if (value === null) return match !== null ? xml.replace(match[0], "") : xml;
1074
+ const element = `<${tag}>${escapeXml(value)}</${tag}>`;
1075
+ return match !== null ? xml.replace(match[0], element) : xml;
1076
+ };
1077
+ core = setOrRemove(core, "dc:title", props.title);
1078
+ core = setOrRemove(core, "dc:creator", props.author);
1079
+ core = setOrRemove(core, "dcterms:created", props.created);
1080
+ zip.file(corePath, core);
1081
+ return await zip.generateAsync({ type: "nodebuffer" });
1082
+ }
1083
+ /**
1084
+ * Generate a `.docx` package buffer from blocks.
1085
+ * @param blocks - the structured content to render.
1086
+ * @param props - document properties to stamp (title/creator/created).
1087
+ * @returns the packed `.docx` bytes.
1088
+ */
1089
+ async function generateDocx(blocks, props) {
1090
+ const children = renderBlocks(blocks);
1091
+ const document = new Document({
1092
+ ...props.title !== null ? { title: props.title } : {},
1093
+ ...props.author !== null ? { creator: props.author } : {},
1094
+ numbering: NUMBERING,
1095
+ sections: [{
1096
+ properties: {},
1097
+ children
1098
+ }]
1099
+ });
1100
+ try {
1101
+ return await patchCoreProps(await Packer.toBuffer(document), props);
1102
+ } catch (error) {
1103
+ throw new DocxError(`failed to assemble the .docx document: ${error instanceof Error ? error.message : String(error)}`, "DOCX_WRITE_ERROR", { cause: error });
1104
+ }
1105
+ }
1106
+ //#endregion
1107
+ //#region lib/types/tools/create.js
1108
+ /**
1109
+ * Model-facing `docx_create`: generate a new `.docx` file from Markdown.
1110
+ * Guarded with `createIfAbsent` by default so an existing file is never
1111
+ * blindly overwritten (the observation-policy waterfall may supply its own
1112
+ * intent).
1113
+ * @module dsh-tool-docx/tools/create
1114
+ */
1115
+ function parseCreateArgs(args, maxMarkdownChars) {
1116
+ const filePath = requirePath(args.file_path);
1117
+ if (args.markdown.length > maxMarkdownChars) throw new DocxError(`markdown exceeds the ${maxMarkdownChars}-character limit`, "DOCX_INPUT_TOO_LARGE");
1118
+ return {
1119
+ filePath,
1120
+ markdown: args.markdown,
1121
+ title: args.title !== void 0 && args.title.trim().length > 0 ? args.title : void 0,
1122
+ author: args.author !== void 0 && args.author.trim().length > 0 ? args.author : void 0
1123
+ };
1124
+ }
1125
+ /**
1126
+ * Register the `docx_create` tool.
1127
+ * @param ctx - the plugin context; execution uses its `fs` service for
1128
+ * resolution/reads and the `fsBinary` binary writer for the mutation.
1129
+ * @param caps - the deployment's resolved caps.
1130
+ * @param sandbox - the shared sandbox-escalation API.
1131
+ */
1132
+ function applyCreateTool(ctx, caps, sandbox) {
1133
+ ctx.tools.register(defineTool({
1134
+ name: "docx_create",
1135
+ description: "Create a new Microsoft Word .docx file from Markdown. Refuses to overwrite an existing file (read it first, then use docx_edit).",
1136
+ parameters: {
1137
+ file_path: {
1138
+ type: "string",
1139
+ required: true,
1140
+ description: "Path of the new .docx file, resolved by the filesystem backend."
1141
+ },
1142
+ markdown: {
1143
+ type: "string",
1144
+ required: true,
1145
+ description: "Markdown content: headings, paragraphs, bold/italic/code, nested lists, and pipe tables."
1146
+ },
1147
+ title: {
1148
+ type: "string",
1149
+ description: "Optional document title property."
1150
+ },
1151
+ author: {
1152
+ type: "string",
1153
+ description: "Optional document author (creator) property."
1154
+ },
1155
+ ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}
1156
+ },
1157
+ output: {
1158
+ schema: {
1159
+ type: "object",
1160
+ additionalProperties: false,
1161
+ properties: {
1162
+ path: {
1163
+ type: "string",
1164
+ required: true
1165
+ },
1166
+ operation: {
1167
+ type: "string",
1168
+ required: true,
1169
+ enum: ["create", "update"]
1170
+ },
1171
+ bytes: {
1172
+ type: "number",
1173
+ required: true
1174
+ },
1175
+ warnings: {
1176
+ type: "array",
1177
+ required: true,
1178
+ items: { type: "string" }
1179
+ }
1180
+ }
1181
+ },
1182
+ render: (_args, value) => [{
1183
+ type: "text",
1184
+ text: `<path>${value.path}</path>\n<type>docx</type>\n<content>\nCreated ${value.bytes}-byte .docx document\n</content>`
1185
+ }]
1186
+ },
1187
+ async execute(args, exec) {
1188
+ const input = parseCreateArgs(args, caps.maxMarkdownChars);
1189
+ assertSupportedExtension(input.filePath);
1190
+ const fs = ctx.fs;
1191
+ const writeBytes = requireWriteBytes(ctx);
1192
+ const sandboxPolicy = await sandbox.resolvePolicy("docx_create", args, exec);
1193
+ const warnings = [];
1194
+ const buffer = await generateDocx(parseMarkdown(input.markdown, warnings), {
1195
+ title: input.title ?? null,
1196
+ author: input.author ?? null,
1197
+ created: null
1198
+ });
1199
+ const target = await fs.resolve(input.filePath, resolveOptions(exec));
1200
+ const intent = await ctx.waterfall("fs/write-intent", target, exec, () => ({ kind: "createIfAbsent" }));
1201
+ let outcome;
1202
+ try {
1203
+ outcome = await writeBytes(target, buffer, intent, exec.signal, sandboxPolicy);
1204
+ } catch (error) {
1205
+ throw mapFsError(sandbox.mapError(error, sandboxPolicy));
1206
+ }
1207
+ emitObserved(ctx, target, outcome.version, exec);
1208
+ return {
1209
+ path: target.displayPath,
1210
+ operation: outcome.operation,
1211
+ bytes: buffer.byteLength,
1212
+ warnings
1213
+ };
1214
+ },
1215
+ presentCall(args) {
1216
+ return {
1217
+ card: "generic",
1218
+ title: `Create ${args.file_path}`,
1219
+ kind: "edit",
1220
+ locations: [{ path: args.file_path }]
1221
+ };
1222
+ },
1223
+ presentResult(args, result) {
1224
+ if (result.isError) return void 0;
1225
+ return {
1226
+ card: "generic",
1227
+ title: `Create ${args.file_path}`
1228
+ };
1229
+ }
1230
+ }));
1231
+ }
1232
+ //#endregion
1233
+ //#region lib/types/tools/edit.js
1234
+ /**
1235
+ * Model-facing `docx_edit`: replace a `.docx` document's content from
1236
+ * Markdown, preserving its title/author/created properties. Reads the current
1237
+ * file (validating it is a docx), regenerates the body, and writes back with a
1238
+ * version guard so a concurrent change reports `DOCX_STALE`.
1239
+ * @module dsh-tool-docx/tools/edit
1240
+ */
1241
+ function parseEditArgs(args, maxMarkdownChars) {
1242
+ const filePath = requirePath(args.file_path);
1243
+ if (args.markdown.length > maxMarkdownChars) throw new DocxError(`markdown exceeds the ${maxMarkdownChars}-character limit`, "DOCX_INPUT_TOO_LARGE");
1244
+ return {
1245
+ filePath,
1246
+ markdown: args.markdown
1247
+ };
1248
+ }
1249
+ /**
1250
+ * Register the `docx_edit` tool.
1251
+ * @param ctx - the plugin context; execution uses its `fs` service for
1252
+ * resolution/reads and the `fsBinary` binary writer for the mutation.
1253
+ * @param caps - the deployment's resolved caps.
1254
+ * @param sandbox - the shared sandbox-escalation API.
1255
+ */
1256
+ function applyEditTool(ctx, caps, sandbox) {
1257
+ ctx.tools.register(defineTool({
1258
+ name: "docx_edit",
1259
+ description: "Edit a Microsoft Word .docx file: replace its content from Markdown while preserving title/author/created. Round-trip: read with docx_read, modify the Markdown, then call docx_edit with the full new Markdown.",
1260
+ parameters: {
1261
+ file_path: {
1262
+ type: "string",
1263
+ required: true,
1264
+ description: "Path of the .docx file to edit, resolved by the filesystem backend."
1265
+ },
1266
+ markdown: {
1267
+ type: "string",
1268
+ required: true,
1269
+ description: "The full new Markdown content for the document (headings, paragraphs, bold/italic/code, nested lists, pipe tables)."
1270
+ },
1271
+ ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}
1272
+ },
1273
+ output: {
1274
+ schema: {
1275
+ type: "object",
1276
+ additionalProperties: false,
1277
+ properties: {
1278
+ path: {
1279
+ type: "string",
1280
+ required: true
1281
+ },
1282
+ operation: {
1283
+ type: "string",
1284
+ required: true,
1285
+ enum: ["create", "update"]
1286
+ },
1287
+ bytes: {
1288
+ type: "number",
1289
+ required: true
1290
+ },
1291
+ warnings: {
1292
+ type: "array",
1293
+ required: true,
1294
+ items: { type: "string" }
1295
+ },
1296
+ docProps: {
1297
+ type: "object",
1298
+ additionalProperties: false,
1299
+ properties: {
1300
+ title: {
1301
+ required: true,
1302
+ oneOf: [{ type: "string" }, { type: "null" }]
1303
+ },
1304
+ author: {
1305
+ required: true,
1306
+ oneOf: [{ type: "string" }, { type: "null" }]
1307
+ },
1308
+ created: {
1309
+ required: true,
1310
+ oneOf: [{ type: "string" }, { type: "null" }]
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ },
1316
+ render: (_args, value) => [{
1317
+ type: "text",
1318
+ text: `<path>${value.path}</path>\n<type>docx</type>\n<content>\nUpdated ${value.bytes}-byte .docx document\n</content>`
1319
+ }]
1320
+ },
1321
+ async execute(args, exec) {
1322
+ const input = parseEditArgs(args, caps.maxMarkdownChars);
1323
+ assertSupportedExtension(input.filePath);
1324
+ const fs = ctx.fs;
1325
+ const writeBytes = requireWriteBytes(ctx);
1326
+ const sandboxPolicy = await sandbox.resolvePolicy("docx_edit", args, exec);
1327
+ const target = await fs.resolve(input.filePath, resolveOptions(exec));
1328
+ const info = await fs.stat(target, exec.signal);
1329
+ if (!info) {
1330
+ emitAbsent(ctx, target, exec);
1331
+ throw new DocxError(`file not found: ${target.displayPath}`, "DOCX_NOT_FOUND");
1332
+ }
1333
+ if (info.type !== "file") throw new DocxError(`cannot edit "${target.displayPath}": not a regular file`, "DOCX_NOT_REGULAR_FILE");
1334
+ let data;
1335
+ try {
1336
+ data = await fs.readBytes(target, exec.signal, caps.maxDocxBytes);
1337
+ } catch (error) {
1338
+ throw mapFsError(error);
1339
+ }
1340
+ const existing = await extractDocx(data, caps.maxDocxBytes);
1341
+ const warnings = [...existing.warnings];
1342
+ const buffer = await generateDocx(parseMarkdown(input.markdown, warnings), existing.props);
1343
+ const intent = await ctx.waterfall("fs/write-intent", target, exec, () => ({
1344
+ kind: "replaceIfVersion",
1345
+ version: info.version
1346
+ }));
1347
+ let outcome;
1348
+ try {
1349
+ outcome = await writeBytes(target, buffer, intent, exec.signal, sandboxPolicy);
1350
+ } catch (error) {
1351
+ throw mapFsError(sandbox.mapError(error, sandboxPolicy));
1352
+ }
1353
+ emitObserved(ctx, target, outcome.version, exec);
1354
+ return {
1355
+ path: target.displayPath,
1356
+ operation: outcome.operation,
1357
+ bytes: buffer.byteLength,
1358
+ warnings,
1359
+ docProps: existing.props
1360
+ };
1361
+ },
1362
+ presentCall(args) {
1363
+ return {
1364
+ card: "generic",
1365
+ title: `Edit ${args.file_path}`,
1366
+ kind: "edit",
1367
+ locations: [{ path: args.file_path }]
1368
+ };
1369
+ },
1370
+ presentResult(args, result) {
1371
+ if (result.isError) return void 0;
1372
+ return {
1373
+ card: "generic",
1374
+ title: `Edit ${args.file_path}`
1375
+ };
1376
+ }
1377
+ }));
1378
+ }
1379
+ //#endregion
1380
+ //#region lib/types/sandbox.js
1381
+ /**
1382
+ * The sandbox-escalation API for the mutating docx tools: per-call policy
1383
+ * resolution, advertised escalation fields, and denial-marker mapping — the
1384
+ * same pieces `dsh-tool-fs` uses, so docx mutations escalate identically to
1385
+ * bash and fs. Built ONCE per plugin from `ctx.fs.sandboxMode`.
1386
+ *
1387
+ * This mirrors `packages/fs/tool-fs/src/sandbox.ts`; extracting a shared
1388
+ * controller is deferred work (see the package README).
1389
+ *
1390
+ * @module dsh-tool-docx/sandbox
1391
+ */
1392
+ /** The docx escalation API: advertisement gating, policy resolution, and denial mapping. */
1393
+ var DocxSandboxController = class {
1394
+ ctx;
1395
+ /** Escalation targets this composition advertises (`[]` when no confining backend is mounted). */
1396
+ escalationModes;
1397
+ policy;
1398
+ constructor(ctx) {
1399
+ this.ctx = ctx;
1400
+ const defaultMode = ctx.fs.sandboxMode;
1401
+ this.escalationModes = defaultMode === void 0 ? [] : ESCALATION_TARGETS;
1402
+ this.policy = ctx.get("sandboxPolicy");
1403
+ if (defaultMode !== void 0 && this.policy === void 0) throw new Error("tool-docx: the mounted filesystem confines but ctx.sandboxPolicy is missing");
1404
+ }
1405
+ /**
1406
+ * The escalation schema fields for a mutating tool's `parameters` (confining backend only).
1407
+ * @returns the two escalation parameter specs.
1408
+ */
1409
+ schemaFields() {
1410
+ return {
1411
+ sandbox_permissions: {
1412
+ type: "string",
1413
+ enum: [...this.escalationModes],
1414
+ description: "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval."
1415
+ },
1416
+ justification: {
1417
+ type: "string",
1418
+ description: "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
1419
+ }
1420
+ };
1421
+ }
1422
+ /**
1423
+ * The policy to stamp onto this mutation: an approved escalation grant, else
1424
+ * the session's standing mode (with the session cwd as the workspace root).
1425
+ * @param toolName - the mutating tool's name, for the approval audit trail.
1426
+ * @param args - the call's escalation arguments.
1427
+ * @param exec - the tool-execution context.
1428
+ * @returns the policy for the mutation, or undefined for an unsandboxed backend.
1429
+ */
1430
+ async resolvePolicy(toolName, args, exec) {
1431
+ validateEscalationArgs(args.sandbox_permissions, args.justification);
1432
+ const standingPolicy = this.policy?.resolve({ ...exec.agent ? { session: exec.agent.session } : {} });
1433
+ if (args.sandbox_permissions === void 0 || args.justification === void 0) return standingPolicy;
1434
+ if (this.escalationModes.length === 0) throw new Error("sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)");
1435
+ const policy = standingPolicy;
1436
+ const approvedMode = await approveEscalation({
1437
+ requestedMode: args.sandbox_permissions,
1438
+ justification: args.justification,
1439
+ effectiveMode: policy.mode,
1440
+ subject: "operation"
1441
+ }, {
1442
+ approver: this.ctx.get("approval"),
1443
+ agent: exec.agent,
1444
+ callId: exec.callId,
1445
+ toolName,
1446
+ signal: exec.signal
1447
+ });
1448
+ return {
1449
+ ...policy,
1450
+ mode: approvedMode
1451
+ };
1452
+ }
1453
+ /**
1454
+ * Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes a
1455
+ * `DocxError` carrying the shared `[sandbox: …]` marker plus the same-turn
1456
+ * escalation hint (keeping the structured `DOCX_SANDBOX_DENIED` code).
1457
+ * @param error - the error thrown by the mutation.
1458
+ * @param policy - the policy stamped onto the call.
1459
+ * @returns the error to throw.
1460
+ */
1461
+ mapError(error, policy) {
1462
+ if (!(error instanceof FsError) || error.code !== "FS_SANDBOX_DENIED") return error;
1463
+ const mode = policy.mode;
1464
+ return new DocxError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker("operation")}`, "DOCX_SANDBOX_DENIED", { cause: error });
1465
+ }
1466
+ };
1467
+ //#endregion
1468
+ //#region lib/types/index.js
1469
+ /**
1470
+ * Model-facing Microsoft Word (.docx) tools: `docx_read` (docx → Markdown or
1471
+ * structured JSON blocks), `docx_create` (Markdown → new docx), and
1472
+ * `docx_edit` (round-trip Markdown replacement preserving document
1473
+ * properties). Reading uses the bounded `ctx.fs.readBytes` primitive (part of
1474
+ * the published filesystem contract since rc.7); creating and editing use a
1475
+ * binary writer resolved at call time — the plugin's `fsBinary` service
1476
+ * (`dsh-tool-docx/fs-binary-sandbox-plugin` / `fs-binary-local-plugin`) or a
1477
+ * host `ctx.fs` that natively provides `writeBytes` — so the sandbox fence and
1478
+ * observation policy apply to docx mutations exactly as they do to text
1479
+ * writes, without ever replacing the host's own `ctx.fs`.
1480
+ * @module dsh-tool-docx
1481
+ */
1482
+ /** Cordis plugin name used by loader diagnostics. */
1483
+ const name = "tool-docx";
1484
+ /** Services required by the docx tool suite. */
1485
+ const inject = [
1486
+ "tools",
1487
+ "fs",
1488
+ "systemPrompt"
1489
+ ];
1490
+ const Config = z.object({
1491
+ maxDocxBytes: z.number().default(67108864),
1492
+ maxMarkdownChars: z.number().default(1e6),
1493
+ maxReadChars: z.number().default(2e5)
1494
+ });
1495
+ /** Register the full `docx_read`/`docx_create`/`docx_edit` tool suite. */
1496
+ function apply(ctx, config) {
1497
+ const resolved = config;
1498
+ assertPositiveInteger("maxDocxBytes", resolved.maxDocxBytes);
1499
+ assertPositiveInteger("maxMarkdownChars", resolved.maxMarkdownChars);
1500
+ assertPositiveInteger("maxReadChars", resolved.maxReadChars);
1501
+ const sandbox = new DocxSandboxController(ctx);
1502
+ applyReadTool(ctx, resolved);
1503
+ applyCreateTool(ctx, resolved, sandbox);
1504
+ applyEditTool(ctx, resolved, sandbox);
1505
+ }
1506
+ //#endregion
1507
+ export { Config, apply, inject, name };