dsh-figmark 0.0.0-stage → 1.0.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,970 @@
1
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ //#region src/host/guidance.ts
5
+ /**
6
+ * The model-facing guidance this plugin contributes while it is mounted.
7
+ *
8
+ * The annotator delivers work to the agent as an ordinary user message, so the
9
+ * agent needs to know what that message means and what a complete response to it
10
+ * looks like. The text is a static prompt section: it names no plugin internals,
11
+ * only the files the message points at.
12
+ * @module dsh-figmark/host/guidance
13
+ */
14
+ /** Section order used for the annotation guidance (just before deliverable references). */
15
+ const GUIDANCE_ORDER = 8990;
16
+ /** Stable section name, unique in the assembled prompt. */
17
+ const GUIDANCE_NAME = "dsh-figmark:annotations";
18
+ /** The guidance text appended to the system prompt. */
19
+ const GUIDANCE = [
20
+ "## 附图标注(dsh-figmark)",
21
+ "",
22
+ "用户可能在文档预览里直接标注(箭头/框选/椭圆/手绘/单点),发送给你的消息以【附图标注】开头,正文就是**逐条标注清单**,不需要再去读任何附加文件(标注文件是投递用的临时件,发出即删)。收到时按下列纪律处理:",
23
+ "",
24
+ "1. 逐条读消息正文的清单:每条形如 `① 箭头 (10,10) → (40,30),要改:标号指错了`,括号里是图面像素坐标(原点在左上角),`要改:` 之后是用户写的**详细说明**——那是这条标注唯一的内容,按它判断意图。有的条目还带 `锚定:` 后缀,说明该处落在一个具体元素上(SVG 给 `title`/`id`/元素文字,HTML 给 `selector`/元素文字),定位时优先用它。",
25
+ "2. **PDF 是整份文档一份标注**:每条标注都带 `第 N 页`,坐标是那一页自己的(原点在该页左上角)。看每条标注前先确认它的页码。",
26
+ "3. 有形状的标注(箭头/框选/椭圆/手绘)会附一张标注图(`<图名>.annotated.png`,PDF 则是一份**带批注的 PDF**,其批注是原生 PDF 注释对象:`/Ink` 手绘、`/Square` 框选、`/Circle` 圈选、`/Line` 箭头),可直接打开核对图面位置。**若消息说「本条标注只有位置点,没有标注图」,那就是全部内容:只有坐标加一句说明,直接按原图定位处理,不要去找不存在的附件。**",
27
+ "4. 逐条处理标注:每条标注都要落到具体的图面改动,并在答复里逐条回应(编号与标注一致)。无法实现的标注要说明原因,不要静默跳过。",
28
+ "5. 修改附图以**标注所指的生成源**为准(绘制脚本、SVG 源或 `generate_patent_figure` 的入参),不要直接涂改导出的位图;PDF 同理,改的是生成该 PDF 的源头,不是那份带批注的副本。未标注的部分保持不动。",
29
+ "6. 改完图后重新渲染并核对:图面尺寸/坐标若变化,旧标注的坐标会失准,此时以 `anchor` 与标注文字为准复核一遍,再回报结果。",
30
+ "7. 若消息以【HTML 标注】开头,那是**渲染出来的 HTML 文档**:标注画在固定 1024px 宽的渲染面上,`points` 是那个宽度下的渲染像素。这类标注**没有标注图**(浏览器无法把渲染好的文档栅格化成图片),所以定位只能靠 `marks[].anchor.selector`——那是一条**可直接粘贴使用**的精确路径:每段是 `tag:nth-of-type(n)`,遇到 `#id` 就在那里截断,因此它要么以 `body > …` 开头,要么直接是 `#id > …`;`anchor.text` 是该元素的文字,`points` 只作辅助(渲染宽度一变坐标就会漂)。",
31
+ "8. 处理 HTML 标注时,改的是**生成这份 HTML 的源文件**(模板、生成脚本或 HTML 本体),不是预览里的渲染结果;渲染面按本插件内联的样式与脚本策略绘制,脚本不会执行、外部资源不会加载,所以别把\"预览里看起来不对\"当成文件本身的问题。"
32
+ ].join("\n");
33
+ /** Section order used for the Markdown edit-proposal guidance (after the annotation one). */
34
+ const EDIT_GUIDANCE_ORDER = 8991;
35
+ /** Stable section name, unique in the assembled prompt. */
36
+ const EDIT_GUIDANCE_NAME = "dsh-figmark:markdown-edits";
37
+ /** The guidance text appended for messages that carry a Markdown difference. */
38
+ const EDIT_GUIDANCE = [
39
+ "## Markdown 修改建议(dsh-figmark)",
40
+ "",
41
+ "用户可能在文档预览里直接编辑 Markdown,保存后你会收到一条【文档修改建议】开头、正文是一段 unified diff 的用户消息(`--- a/<文件名>`、`+++ b/<文件名>`,`@@` 头给出两侧的起始行与行数)。收到时按下列纪律处理:",
42
+ "",
43
+ "1. 先读文件(消息里给出路径)。**用户没有把改动写进磁盘**:磁盘上的文件仍是旧版,`a` 侧是用户打开时的那一版,`b` 侧是用户改完的结果。",
44
+ "2. 把 diff 应用到源文件:`-` 行删除、`+` 行写入、空格开头的是上下文(保持原样),逐处落地;不要重写整个文件,diff 之外的部分保持不动。",
45
+ "3. 消息里若说「另有 N 处改动未在本消息里展开」,完整结果就是随消息发送的 `<文件名>.edited.md` 附件;以它为准。",
46
+ "4. 消息里若带「我的说明:」清单,那是用户对某一行/某一处改动写的理由(形如 `1. 第 6 行(@@ -3,7 +3,7 @@):…`);落地时按它判断意图,说明里提到的行号是**基线(a 侧)**的行号。",
47
+ "5. 消息里若说文件在编辑期间被改动过、或 diff 以整块替换呈现,先核对再落地;无法安全应用时说明原因,不要猜着改。",
48
+ "6. 落地后逐处回应(顺序与 diff 一致),并说明哪些改动你没有采纳以及为什么。"
49
+ ].join("\n");
50
+ //#endregion
51
+ //#region src/shared/address.ts
52
+ /** Documented prefix of every file address. */
53
+ const PREFIX = "dsh-resource://file/";
54
+ /**
55
+ * Decode one address segment, leaving malformed escapes as their literal text.
56
+ * @param segment - raw percent-encoded segment.
57
+ * @returns the decoded segment.
58
+ */
59
+ function decodeSegment(segment) {
60
+ try {
61
+ return decodeURIComponent(segment);
62
+ } catch {
63
+ return segment;
64
+ }
65
+ }
66
+ /**
67
+ * Parse a `dsh-resource://file/…` address.
68
+ * @param address - the address string taken from a preview tab.
69
+ * @returns the parsed address, or undefined when the string is not a file address.
70
+ */
71
+ function parseFileAddress(address) {
72
+ if (!address.startsWith(PREFIX)) return void 0;
73
+ const rest = address.slice(20);
74
+ if (rest.startsWith("session/")) {
75
+ const afterScope = rest.slice(8);
76
+ const slash = afterScope.indexOf("/");
77
+ if (slash <= 0) return void 0;
78
+ return {
79
+ sessionId: decodeSegment(afterScope.slice(0, slash)),
80
+ path: decodeSegment(afterScope.slice(slash + 1))
81
+ };
82
+ }
83
+ if (rest.startsWith("absolute/")) return { path: decodeSegment(rest.slice(9)) };
84
+ }
85
+ /** Route prefix owned by this plugin. */
86
+ const ROUTE_PREFIX = "/dsh-figmark";
87
+ //#endregion
88
+ //#region src/shared/figure-kind.ts
89
+ /**
90
+ * Document suffixes, shared by the browser and Node halves.
91
+ * @module dsh-figmark/shared/figure-kind
92
+ */
93
+ /**
94
+ * File suffixes this plugin treats as annotatable documents.
95
+ *
96
+ * A PDF is one document however many pages it has: the browser half renders one
97
+ * page at a time, each mark records the page it was drawn on, and saving writes
98
+ * the marks back into a copy of the document as native annotations.
99
+ *
100
+ * An HTML document is one surface: the browser half renders it at a fixed width
101
+ * and records marks in those layout pixels, and its marks file is the only
102
+ * artifact a save writes — a rendered document cannot be flattened into a
103
+ * picture the way a figure can.
104
+ */
105
+ const FIGURE_EXTENSIONS = [
106
+ "svg",
107
+ "png",
108
+ "jpg",
109
+ "jpeg",
110
+ "webp",
111
+ "bmp",
112
+ "gif",
113
+ "ico",
114
+ "pdf",
115
+ "html",
116
+ "htm"
117
+ ];
118
+ /**
119
+ * Read a path's lower-case suffix without the dot.
120
+ * @param path - file path or name.
121
+ * @returns the suffix, or an empty string when there is none.
122
+ */
123
+ function suffixOf(path) {
124
+ const name = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
125
+ const dot = name.lastIndexOf(".");
126
+ return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
127
+ }
128
+ /**
129
+ * Whether a path names a document this plugin renders.
130
+ * @param path - file path.
131
+ * @returns true when the suffix is one this plugin annotates.
132
+ */
133
+ function isFigurePath(path) {
134
+ return FIGURE_EXTENSIONS.includes(suffixOf(path));
135
+ }
136
+ /**
137
+ * Media type of one document path.
138
+ * @param path - file path.
139
+ * @returns the media type, or undefined for an unsupported suffix.
140
+ */
141
+ function figureMediaType(path) {
142
+ switch (suffixOf(path)) {
143
+ case "pdf": return "application/pdf";
144
+ case "svg": return "image/svg+xml";
145
+ case "png": return "image/png";
146
+ case "jpg":
147
+ case "jpeg": return "image/jpeg";
148
+ case "webp": return "image/webp";
149
+ case "bmp": return "image/bmp";
150
+ case "gif": return "image/gif";
151
+ case "ico": return "image/x-icon";
152
+ case "html":
153
+ case "htm": return "text/html";
154
+ default: return;
155
+ }
156
+ }
157
+ //#endregion
158
+ //#region src/host/sidecar.ts
159
+ /**
160
+ * Annotation files beside one figure.
161
+ *
162
+ * An annotation never modifies the figure: it is written to sibling files
163
+ * (`<name>.annot.json` for the marks and `<name>.annotated.png` — or
164
+ * `<name>.annotated.pdf` for a document that has pages — for the flattened
165
+ * review artifact). One figure is one document, however many pages it has, so a
166
+ * paged document's marks share a single sidecar and name their page inside it.
167
+ *
168
+ * The name keeps the figure's own extension (`fig1.svg` → `fig1.svg.annot.json`):
169
+ * `fig1.svg` and `fig1.png` in one directory are two different documents, and marks
170
+ * drawn on one of them mean nothing on the other, so they must never share a
171
+ * sidecar. Every name is derived from the figure path, so the plugin can never be
172
+ * asked to write an arbitrary location.
173
+ * @module dsh-figmark/host/sidecar
174
+ */
175
+ /** Suffix appended to a figure's name for the marks file. */
176
+ const ANNOTATION_SUFFIX = ".annot.json";
177
+ /** Suffix appended to a figure's name for the flattened review artifact. */
178
+ const ANNOTATED_SUFFIX = ".annotated";
179
+ /**
180
+ * Resolve one figure's sidecar paths.
181
+ * @param figurePath - absolute path of the annotated figure.
182
+ * @returns the sidecar paths; both live in the figure's own directory.
183
+ */
184
+ function sidecarPaths(figurePath) {
185
+ const name = basename(figurePath);
186
+ const directory = dirname(figurePath);
187
+ const reviewExtension = reviewFileExtension(figurePath);
188
+ return {
189
+ annotation: join(directory, `${name}${ANNOTATION_SUFFIX}`),
190
+ review: join(directory, `${name}${ANNOTATED_SUFFIX}${reviewExtension}`)
191
+ };
192
+ }
193
+ /**
194
+ * Suffix of the flattened review artifact for one figure.
195
+ *
196
+ * A paged document is reviewed as a document — the page marks travel as native
197
+ * PDF annotations in a copy of the original — while a single-surface figure is
198
+ * reviewed as the picture it is.
199
+ * @param figurePath - absolute path of the annotated figure.
200
+ * @returns the file extension, dot included.
201
+ */
202
+ function reviewFileExtension(figurePath) {
203
+ return extname(figurePath).toLowerCase() === ".pdf" ? ".pdf" : ".png";
204
+ }
205
+ /**
206
+ * The marks file this plugin derived before the figure's extension became part of
207
+ * the name (`fig1.svg` → `fig1.annot.json`). Read-only: a file an earlier version
208
+ * wrote stays readable, so an existing annotation never disappears on upgrade.
209
+ * @param figurePath - absolute path of the annotated figure.
210
+ * @returns the legacy marks file path beside the figure.
211
+ */
212
+ function legacyAnnotationPath(figurePath) {
213
+ return join(dirname(figurePath), `${basename(figurePath, extname(figurePath))}${ANNOTATION_SUFFIX}`);
214
+ }
215
+ /**
216
+ * Marks files to read for one figure, most authoritative first: the current name,
217
+ * then the legacy one. A figure whose name carries no extension derives both from
218
+ * the same name, so the list is deduplicated.
219
+ * @param figurePath - absolute path of the annotated figure.
220
+ * @returns the candidate marks file paths.
221
+ */
222
+ function annotationCandidates(figurePath) {
223
+ const current = sidecarPaths(figurePath).annotation;
224
+ const legacy = legacyAnnotationPath(figurePath);
225
+ return current === legacy ? [current] : [current, legacy];
226
+ }
227
+ /**
228
+ * Name pattern of the one-page-per-file sidecars this plugin wrote before a paged
229
+ * document became a single annotation.
230
+ *
231
+ * Both spellings are matched — the extension-bearing name
232
+ * (`fig1.pdf.p2.annot.json`) and the base-name one an even earlier version wrote
233
+ * (`fig1.p2.annot.json`) — and the page number is the pattern's first group.
234
+ *
235
+ * @param figurePath - absolute path of the annotated figure.
236
+ * @returns an anchored, case-insensitive pattern over one directory entry's name.
237
+ */
238
+ function legacyPagePattern(figurePath) {
239
+ const extension = extname(figurePath);
240
+ const alternatives = [.../* @__PURE__ */ new Set([basename(figurePath), basename(figurePath, extension)])].map(escapeForRegExp).join("|");
241
+ return new RegExp(`^(?:${alternatives})\\.p(\\d+)${escapeForRegExp(ANNOTATION_SUFFIX)}$`, "iu");
242
+ }
243
+ /** Escape one literal so it matches itself inside a regular expression. */
244
+ function escapeForRegExp(value) {
245
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
246
+ }
247
+ /**
248
+ * Whether a stored document is the annotation of this very figure.
249
+ *
250
+ * A sidecar sits beside its figure and both names derive from the figure path, so
251
+ * the file name — extension included, and case-insensitive because one file may be
252
+ * spelled `FIG1.SVG` — is the whole identity. The check matters most for the legacy
253
+ * name, which two figures sharing a base name also share.
254
+ *
255
+ * @param document - stored document to test.
256
+ * @param figurePath - absolute path of the figure being opened.
257
+ * @returns true when the document names this figure.
258
+ */
259
+ function annotationTargetsFigure(document, figurePath) {
260
+ return basename(document.figure.path).toLowerCase() === basename(figurePath).toLowerCase();
261
+ }
262
+ //#endregion
263
+ //#region src/host/store.ts
264
+ /**
265
+ * Reading and writing one figure's annotation sidecar.
266
+ *
267
+ * The marks file is JSON owned by this plugin; the review artifact is the same
268
+ * marks applied to the figure (a PNG for a single-surface figure, a copy of the
269
+ * document carrying native PDF annotations for one that has pages). Both are
270
+ * written through a temporary file and a rename, so a reader never observes a
271
+ * half-written document.
272
+ * @module dsh-figmark/host/store
273
+ */
274
+ /** Maximum accepted sidecar size; a document larger than this is not one we wrote. */
275
+ const MAX_ANNOTATION_BYTES = 4 * 1024 * 1024;
276
+ /** Maximum accepted flattened review artifact. */
277
+ const MAX_REVIEW_BYTES = 24 * 1024 * 1024;
278
+ /** Longest element selector one anchor may carry. */
279
+ const MAX_SELECTOR_CHARS = 400;
280
+ /** Kinds a mark may declare. */
281
+ const MARK_KINDS = [
282
+ "arrow",
283
+ "rect",
284
+ "ellipse",
285
+ "pen",
286
+ "point"
287
+ ];
288
+ /** Content hash of one file. */
289
+ async function fileDigest(path) {
290
+ const bytes = await readFile(path);
291
+ return createHash("sha256").update(bytes).digest("hex");
292
+ }
293
+ /**
294
+ * Whether a value is a finite figure coordinate pair.
295
+ * @param value - candidate value from the wire.
296
+ * @returns true when the value is a two-number tuple.
297
+ */
298
+ function isPoint(value) {
299
+ return Array.isArray(value) && value.length === 2 && value.every((entry) => typeof entry === "number" && Number.isFinite(entry));
300
+ }
301
+ /**
302
+ * Whether a value is a finite figure rectangle.
303
+ * @param value - candidate value from the wire.
304
+ * @returns true when the value is a four-number tuple.
305
+ */
306
+ function isBox(value) {
307
+ return Array.isArray(value) && value.length === 4 && value.every((entry) => typeof entry === "number" && Number.isFinite(entry));
308
+ }
309
+ /**
310
+ * Validate one anchor from the wire.
311
+ * @param value - candidate anchor.
312
+ * @returns the anchor, or undefined when absent; throws when malformed.
313
+ */
314
+ function readAnchor(value) {
315
+ if (value === void 0 || value === null) return void 0;
316
+ if (typeof value !== "object") throw new Error("annotation mark anchor must be an object");
317
+ const raw = value;
318
+ if (typeof raw["tag"] !== "string") throw new Error("annotation mark anchor needs a tag");
319
+ if (!isBox(raw["bbox"])) throw new Error("annotation mark anchor needs a four-number bbox");
320
+ return {
321
+ tag: raw["tag"],
322
+ bbox: raw["bbox"],
323
+ ...typeof raw["id"] === "string" ? { id: raw["id"] } : {},
324
+ ...typeof raw["title"] === "string" ? { title: raw["title"] } : {},
325
+ ...typeof raw["text"] === "string" ? { text: raw["text"] } : {},
326
+ ...typeof raw["selector"] === "string" ? { selector: raw["selector"].slice(0, MAX_SELECTOR_CHARS) } : {}
327
+ };
328
+ }
329
+ /**
330
+ * Read one optional rotation from the wire.
331
+ * @param value - the field as it arrived.
332
+ * @returns the angle in degrees, or undefined when absent.
333
+ * @throws {Error} when the field is present but is not a usable angle.
334
+ */
335
+ function readRotation(value) {
336
+ if (value === void 0) return void 0;
337
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("annotation mark rotation must be a finite number of degrees");
338
+ return value;
339
+ }
340
+ /**
341
+ * Read one optional positive integer field from the wire.
342
+ * @param raw - the object as it arrived.
343
+ * @param field - field name, used in the failure message.
344
+ * @returns the number, or undefined when the field is absent.
345
+ * @throws {Error} when the field is present but is not a positive integer.
346
+ */
347
+ function readPositiveInteger(raw, field) {
348
+ const value = raw[field];
349
+ if (value === void 0) return void 0;
350
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error(`annotation ${field} must be a positive integer`);
351
+ return value;
352
+ }
353
+ /**
354
+ * Validate one mark from the wire.
355
+ *
356
+ * A mark names the page it was drawn on exactly when its figure is a paged
357
+ * document: on a single-surface figure a page number would be a coordinate space
358
+ * that does not exist, so it is refused rather than ignored.
359
+ *
360
+ * @param value - candidate mark.
361
+ * @param index - position used in the failure message.
362
+ * @param figure - the validated figure the mark belongs to.
363
+ * @returns the validated mark.
364
+ * @throws {Error} when a required field is missing or mistyped.
365
+ */
366
+ function readMark(value, index, figure) {
367
+ if (typeof value !== "object" || value === null) throw new Error(`annotation mark ${index} must be an object`);
368
+ const raw = value;
369
+ const kind = raw["kind"];
370
+ if (typeof kind !== "string" || !MARK_KINDS.includes(kind)) throw new Error(`annotation mark ${index} has unknown kind ${JSON.stringify(kind)}`);
371
+ if (typeof raw["id"] !== "string" || raw["id"] === "") throw new Error(`annotation mark ${index} needs an id`);
372
+ if (typeof raw["color"] !== "string") throw new Error(`annotation mark ${index} needs a color`);
373
+ const points = raw["points"];
374
+ if (!Array.isArray(points) || points.length === 0 || !points.every(isPoint)) throw new Error(`annotation mark ${index} needs at least one coordinate pair`);
375
+ const page = readPositiveInteger(raw, "page");
376
+ if (page !== void 0 && figure.width !== void 0) throw new Error(`annotation mark ${index} names a page, but its figure is one surface`);
377
+ if (page !== void 0 && figure.pageCount !== void 0 && page > figure.pageCount) throw new Error(`annotation mark ${index} is on page ${page} of a ${figure.pageCount}-page document`);
378
+ const anchor = readAnchor(raw["anchor"]);
379
+ const rotation = readRotation(raw["rotation"]);
380
+ return {
381
+ id: raw["id"],
382
+ kind,
383
+ color: raw["color"],
384
+ points,
385
+ ...typeof raw["detail"] === "string" ? { detail: raw["detail"] } : {},
386
+ ...page === void 0 ? {} : { page },
387
+ ...rotation === void 0 ? {} : { rotation },
388
+ ...anchor === void 0 ? {} : { anchor }
389
+ };
390
+ }
391
+ /**
392
+ * Validate a whole annotation document from the wire.
393
+ * @param value - parsed request body.
394
+ * @returns the validated document.
395
+ * @throws {Error} when the body is not a document this plugin wrote.
396
+ */
397
+ function readAnnotationDocument(value) {
398
+ if (typeof value !== "object" || value === null) throw new Error("annotation document must be an object");
399
+ const raw = value;
400
+ if (raw["version"] !== 1) throw new Error(`annotation document version ${JSON.stringify(raw["version"])} is not 1`);
401
+ const figure = raw["figure"];
402
+ if (typeof figure !== "object" || figure === null) throw new Error("annotation document needs a figure");
403
+ const figureRaw = figure;
404
+ for (const field of [
405
+ "address",
406
+ "path",
407
+ "mediaType",
408
+ "sha256"
409
+ ]) if (typeof figureRaw[field] !== "string" || figureRaw[field] === "") throw new Error(`annotation figure needs ${field}`);
410
+ const width = readSize(figureRaw, "width");
411
+ const height = readSize(figureRaw, "height");
412
+ if (width === void 0 !== (height === void 0)) throw new Error("annotation figure needs both its width and its height, or neither");
413
+ const pageCount = readPositiveInteger(figureRaw, "pageCount");
414
+ if (pageCount !== void 0 && width !== void 0) throw new Error("annotation figure is a paged document, so it cannot carry width and height");
415
+ const marks = raw["marks"];
416
+ if (!Array.isArray(marks)) throw new Error("annotation document needs a marks array");
417
+ const updatedAt = typeof raw["updatedAt"] === "string" ? raw["updatedAt"] : (/* @__PURE__ */ new Date()).toISOString();
418
+ return {
419
+ version: 1,
420
+ figure: {
421
+ address: figureRaw["address"],
422
+ path: figureRaw["path"],
423
+ mediaType: figureRaw["mediaType"],
424
+ sha256: figureRaw["sha256"],
425
+ ...width !== void 0 && height !== void 0 ? {
426
+ width,
427
+ height
428
+ } : {},
429
+ ...pageCount === void 0 ? {} : { pageCount }
430
+ },
431
+ createdAt: typeof raw["createdAt"] === "string" ? raw["createdAt"] : updatedAt,
432
+ updatedAt,
433
+ marks: marks.map((mark, index) => readMark(mark, index, {
434
+ ...width === void 0 ? {} : { width },
435
+ ...pageCount === void 0 ? {} : { pageCount }
436
+ })),
437
+ ...typeof raw["summary"] === "string" && raw["summary"] !== "" ? { summary: raw["summary"] } : {}
438
+ };
439
+ }
440
+ /**
441
+ * Read one optional positive size from the wire.
442
+ * @param raw - the figure object as it arrived.
443
+ * @param field - field name, used in the failure message.
444
+ * @returns the size, or undefined when the field is absent.
445
+ * @throws {Error} when the field is present but is not a positive number.
446
+ */
447
+ function readSize(raw, field) {
448
+ const value = raw[field];
449
+ if (value === void 0) return void 0;
450
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`annotation figure ${field} must be a positive number`);
451
+ return value;
452
+ }
453
+ /**
454
+ * Read one figure's saved annotation.
455
+ *
456
+ * The current sidecar name is tried first, then the legacy base-name one, and a
457
+ * document that names a different figure is skipped — two figures in one directory
458
+ * that share a base name would otherwise read each other's marks.
459
+ *
460
+ * @param figurePath - absolute path of the figure.
461
+ * @returns the document, or null when none was stored or it is unreadable.
462
+ */
463
+ async function readAnnotation(figurePath) {
464
+ for (const candidate of annotationCandidates(figurePath)) {
465
+ const document = await readSidecar(candidate);
466
+ if (document !== null && annotationTargetsFigure(document, figurePath)) return document;
467
+ }
468
+ if (figureMediaType(figurePath) !== "application/pdf") return null;
469
+ return await readLegacyPages(figurePath);
470
+ }
471
+ /**
472
+ * Restate the per-page sidecars an earlier version wrote as one document.
473
+ *
474
+ * That version annotated one page at a time and stored each page as its own file,
475
+ * so a figure annotated then has no whole-figure sidecar. Reading them merged
476
+ * keeps those marks visible; the files are left untouched, and the next save
477
+ * writes the unified name.
478
+ *
479
+ * The page count is the highest page found, since the per-page files do not
480
+ * record it — the browser half replaces it with the document's real count the next
481
+ * time it saves.
482
+ *
483
+ * @param figurePath - absolute path of the figure.
484
+ * @returns the merged document, or null when no readable page file exists.
485
+ */
486
+ async function readLegacyPages(figurePath) {
487
+ const directory = dirname(figurePath);
488
+ const pattern = legacyPagePattern(figurePath);
489
+ let entries;
490
+ try {
491
+ entries = await readdir(directory);
492
+ } catch {
493
+ return null;
494
+ }
495
+ const pages = entries.map((name) => ({
496
+ name,
497
+ page: Number(pattern.exec(name)?.[1])
498
+ })).filter((entry) => Number.isInteger(entry.page) && entry.page > 0).sort((left, right) => left.page - right.page);
499
+ const read = [];
500
+ for (const entry of pages) {
501
+ const document = await readSidecar(join(directory, entry.name));
502
+ if (document !== null && annotationTargetsFigure(document, figurePath)) read.push({
503
+ page: entry.page,
504
+ document
505
+ });
506
+ }
507
+ const first = read[0];
508
+ if (first === void 0) return null;
509
+ let summary;
510
+ for (const entry of read) if (entry.document.summary !== void 0) summary = entry.document.summary;
511
+ return {
512
+ version: 1,
513
+ figure: {
514
+ address: first.document.figure.address,
515
+ path: first.document.figure.path,
516
+ mediaType: first.document.figure.mediaType,
517
+ sha256: first.document.figure.sha256,
518
+ pageCount: read.reduce((highest, entry) => Math.max(highest, entry.page), first.page)
519
+ },
520
+ createdAt: read.reduce((earliest, entry) => entry.document.createdAt < earliest ? entry.document.createdAt : earliest, first.document.createdAt),
521
+ updatedAt: read.reduce((latest, entry) => entry.document.updatedAt > latest ? entry.document.updatedAt : latest, first.document.updatedAt),
522
+ marks: read.flatMap((entry) => entry.document.marks.map((mark) => ({
523
+ ...mark,
524
+ page: entry.page
525
+ }))),
526
+ ...summary === void 0 ? {} : { summary }
527
+ };
528
+ }
529
+ /**
530
+ * Parse one marks file into this plugin's document shape.
531
+ * @param sidecarPath - absolute path of the marks file.
532
+ * @returns the document, or null when the file is missing, oversized, or unreadable.
533
+ */
534
+ async function readSidecar(sidecarPath) {
535
+ let text;
536
+ try {
537
+ if ((await stat(sidecarPath)).size > MAX_ANNOTATION_BYTES) return null;
538
+ text = await readFile(sidecarPath, "utf8");
539
+ } catch {
540
+ return null;
541
+ }
542
+ try {
543
+ return readStoredDocument(JSON.parse(text));
544
+ } catch {
545
+ return null;
546
+ }
547
+ }
548
+ /**
549
+ * Read a stored sidecar document in either schema version this workbench sees.
550
+ * @param value - parsed marks file content.
551
+ * @returns the document in this plugin's shape, or null when it is neither version.
552
+ */
553
+ function readStoredDocument(value) {
554
+ if (typeof value !== "object" || value === null) return null;
555
+ const raw = value;
556
+ if (raw["version"] === 1) return readAnnotationDocument(raw);
557
+ if (raw["version"] === 2) return readAnnotationDocument(bridgeV2Document(raw));
558
+ return null;
559
+ }
560
+ /**
561
+ * Restate a v2 document as the v1 shape this plugin validates and writes.
562
+ *
563
+ * Both versions carry the same marks and the same figure facts. v2 names the figure
564
+ * `target`, adds fields this plugin does not model (`kind` on the target, per-mark
565
+ * `targetFingerprint`, `nodeId`/`ref` on an anchor — the v1 reader then drops them),
566
+ * and omits `address`, the one v1 field it has no counterpart for: the figure's own
567
+ * path stands in, and the browser half replaces it with the live preview address the
568
+ * next time it saves.
569
+ *
570
+ * @param raw - parsed v2 document.
571
+ * @returns the same document as a v1 shape.
572
+ * @throws {Error} when the document carries no target object.
573
+ */
574
+ function bridgeV2Document(raw) {
575
+ const target = raw["target"];
576
+ if (typeof target !== "object" || target === null) throw new Error("annotation document needs a target");
577
+ const figure = target;
578
+ return {
579
+ version: 1,
580
+ figure: {
581
+ address: figure["path"],
582
+ path: figure["path"],
583
+ mediaType: figure["mediaType"],
584
+ width: figure["width"],
585
+ height: figure["height"],
586
+ sha256: figure["sha256"]
587
+ },
588
+ createdAt: raw["createdAt"],
589
+ updatedAt: raw["updatedAt"],
590
+ marks: raw["marks"],
591
+ summary: raw["summary"]
592
+ };
593
+ }
594
+ /**
595
+ * Write one figure's annotation, and optionally its flattened review artifact.
596
+ * @param figurePath - absolute path of the annotated figure.
597
+ * @param document - validated document to persist.
598
+ * @param review - the flattened artifact, when the browser exported one.
599
+ * @returns the written paths.
600
+ * @throws {Error} when the artifact's kind contradicts the figure's own kind.
601
+ */
602
+ async function writeAnnotation(figurePath, document, review) {
603
+ const paths = sidecarPaths(figurePath);
604
+ if (review !== void 0 && review.kind !== (reviewFileExtension(figurePath) === ".pdf" ? "pdf" : "image")) throw new Error(`a ${reviewFileExtension(figurePath)} review artifact cannot be written from ${review.kind} bytes`);
605
+ await mkdir(dirname(paths.annotation), { recursive: true });
606
+ const temporary = `${paths.annotation}.tmp-${process.pid}-${Date.now()}`;
607
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, "utf8");
608
+ await rename(temporary, paths.annotation);
609
+ if (review === void 0) return {
610
+ paths,
611
+ wroteReview: false
612
+ };
613
+ const reviewTemporary = `${paths.review}.tmp-${process.pid}-${Date.now()}`;
614
+ await writeFile(reviewTemporary, review.bytes);
615
+ await rename(reviewTemporary, paths.review);
616
+ return {
617
+ paths,
618
+ wroteReview: true
619
+ };
620
+ }
621
+ /**
622
+ * Delete one figure's annotation sidecars, leaving the figure itself alone.
623
+ *
624
+ * A sidecar has no meaning once its marks are gone: the marks file would be read
625
+ * back as an empty annotation on the next open, and the review artifact is a
626
+ * picture of marks that no longer exist. Removing both is what makes a deleted or
627
+ * delivered annotation stay gone. A missing file is not an error — the caller's
628
+ * intent is "there are no sidecars here", and that is true either way.
629
+ *
630
+ * @param figurePath - absolute path of the figure whose sidecars are removed.
631
+ * @returns whether anything was removed.
632
+ * @throws {Error} when a sidecar exists but cannot be unlinked.
633
+ */
634
+ async function removeAnnotation(figurePath) {
635
+ const paths = sidecarPaths(figurePath);
636
+ let removed = false;
637
+ for (const path of [paths.annotation, paths.review]) try {
638
+ await rm(path, { force: false });
639
+ removed = true;
640
+ } catch (error) {
641
+ if (error.code !== "ENOENT") throw error;
642
+ }
643
+ return {
644
+ paths,
645
+ removed
646
+ };
647
+ }
648
+ //#endregion
649
+ //#region src/host/routes.ts
650
+ /**
651
+ * HTTP routes of the annotator.
652
+ *
653
+ * The routes live outside the `/api` channel, so they carry their own guard: a
654
+ * required request header (which a cross-origin form or image request cannot
655
+ * set without a preflight this plugin never answers) plus an origin check on
656
+ * browsers that send `Sec-Fetch-Site`. Writes are limited to the two sidecar
657
+ * files derived from a figure that already exists.
658
+ * @module dsh-figmark/host/routes
659
+ */
660
+ /** Largest accepted request body. */
661
+ const MAX_BODY_BYTES = MAX_REVIEW_BYTES * 2;
662
+ /** First bytes of every PDF file, used to tell one payload from another. */
663
+ const PDF_HEADER = "%PDF-";
664
+ /**
665
+ * Resolve one request address to an existing figure file.
666
+ * @param address - `dsh-resource://file/…` address from the preview tab.
667
+ * @param deps - Session lookup used for a relative path.
668
+ * @returns the target, or an error message describing why it cannot be annotated.
669
+ */
670
+ async function resolveTarget(address, deps) {
671
+ const parsed = parseFileAddress(address);
672
+ if (parsed === void 0) return {
673
+ ok: false,
674
+ message: `not a file address: ${address}`
675
+ };
676
+ let absolute;
677
+ if (isAbsolute(parsed.path)) absolute = parsed.path;
678
+ else {
679
+ const sessionId = parsed.sessionId;
680
+ const cwd = sessionId === void 0 ? void 0 : await deps.sessionCwd(sessionId);
681
+ if (cwd === void 0) return {
682
+ ok: false,
683
+ message: `cannot resolve this Session's directory for a relative path: ${address}`
684
+ };
685
+ absolute = resolve(cwd, parsed.path);
686
+ }
687
+ if (!isFigurePath(absolute)) return {
688
+ ok: false,
689
+ message: `not an annotatable figure: ${absolute}`
690
+ };
691
+ const mediaType = figureMediaType(absolute);
692
+ /* v8 ignore next -- isFigurePath already restricted the suffixes figureMediaType covers. */
693
+ if (mediaType === void 0) return {
694
+ ok: false,
695
+ message: `no media type for ${absolute}`
696
+ };
697
+ try {
698
+ if (!(await stat(absolute)).isFile()) return {
699
+ ok: false,
700
+ message: `not a regular file: ${absolute}`
701
+ };
702
+ } catch {
703
+ return {
704
+ ok: false,
705
+ message: `no such figure: ${absolute}`
706
+ };
707
+ }
708
+ return {
709
+ ok: true,
710
+ target: {
711
+ address,
712
+ path: absolute,
713
+ mediaType
714
+ }
715
+ };
716
+ }
717
+ /**
718
+ * Read the one flattened review artifact a save may carry.
719
+ *
720
+ * The kind is taken from the payload, and the store refuses a pair that
721
+ * contradicts the figure's own suffix — so a caller cannot name PNG bytes as the
722
+ * review of a PDF.
723
+ *
724
+ * @param body - parsed request body.
725
+ * @returns the artifact, or undefined when the save carried none.
726
+ * @throws {Error} when both payloads are present, or the PDF bytes are not a PDF.
727
+ */
728
+ function readReview(body) {
729
+ const image = typeof body["reviewImage"] === "string" ? body["reviewImage"] : "";
730
+ const pdf = typeof body["annotatedPdf"] === "string" ? body["annotatedPdf"] : "";
731
+ if (image !== "" && pdf !== "") throw new Error("a save carries one review artifact, not both");
732
+ if (pdf !== "") {
733
+ const bytes = new Uint8Array(Buffer.from(pdf, "base64"));
734
+ if (!Buffer.from(bytes.subarray(0, 5)).toString("latin1").startsWith(PDF_HEADER)) throw new Error("the annotated document is not a PDF");
735
+ return {
736
+ kind: "pdf",
737
+ bytes
738
+ };
739
+ }
740
+ if (image !== "") return {
741
+ kind: "image",
742
+ bytes: new Uint8Array(Buffer.from(image, "base64"))
743
+ };
744
+ }
745
+ /**
746
+ * Whether a request carries the plugin guard and a same-origin posture.
747
+ * @param req - incoming request.
748
+ * @returns true when the request may proceed.
749
+ */
750
+ function guarded(req) {
751
+ if (req.headers["x-dsh-figmark"] === void 0) return false;
752
+ const site = req.headers["sec-fetch-site"];
753
+ if (typeof site !== "string") return true;
754
+ return site === "same-origin" || site === "none";
755
+ }
756
+ /**
757
+ * Write one JSON response.
758
+ * @param res - response to own.
759
+ * @param status - HTTP status code.
760
+ * @param body - JSON-serializable payload.
761
+ */
762
+ function sendJson(res, status, body) {
763
+ const text = JSON.stringify(body);
764
+ res.writeHead(status, {
765
+ "content-type": "application/json; charset=utf-8",
766
+ "cache-control": "no-store"
767
+ });
768
+ res.end(text);
769
+ }
770
+ /**
771
+ * Read a request body with a byte cap.
772
+ * @param req - incoming request.
773
+ * @returns the body text.
774
+ * @throws {Error} when the body exceeds the cap.
775
+ */
776
+ async function readBody(req) {
777
+ const chunks = [];
778
+ let total = 0;
779
+ for await (const chunk of req) {
780
+ const buffer = chunk;
781
+ total += buffer.length;
782
+ if (total > MAX_BODY_BYTES) throw new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`);
783
+ chunks.push(buffer);
784
+ }
785
+ return Buffer.concat(chunks).toString("utf8");
786
+ }
787
+ /**
788
+ * Build the route handlers this plugin registers.
789
+ * @param deps - Session lookup.
790
+ * @returns one handler per route path.
791
+ */
792
+ function createHandlers(deps) {
793
+ const annotation = async (req, res) => {
794
+ if (!guarded(req)) {
795
+ sendJson(res, 403, {
796
+ ok: false,
797
+ error: "missing plugin guard header"
798
+ });
799
+ return;
800
+ }
801
+ const url = new URL(req.url ?? "/", "http://localhost");
802
+ try {
803
+ if (req.method === "GET") {
804
+ const resolved = await resolveTarget(url.searchParams.get("address") ?? "", deps);
805
+ if (!resolved.ok) {
806
+ sendJson(res, 404, {
807
+ ok: false,
808
+ error: resolved.message
809
+ });
810
+ return;
811
+ }
812
+ const [sha256, saved] = await Promise.all([fileDigest(resolved.target.path), readAnnotation(resolved.target.path)]);
813
+ sendJson(res, 200, {
814
+ ok: true,
815
+ figure: {
816
+ ...resolved.target,
817
+ sha256
818
+ },
819
+ annotation: saved
820
+ });
821
+ return;
822
+ }
823
+ if (req.method === "POST") {
824
+ const body = JSON.parse(await readBody(req));
825
+ const resolved = await resolveTarget(typeof body["address"] === "string" ? body["address"] : "", deps);
826
+ if (!resolved.ok) {
827
+ sendJson(res, 404, {
828
+ ok: false,
829
+ error: resolved.message
830
+ });
831
+ return;
832
+ }
833
+ const document = readAnnotationDocument(body["annotation"]);
834
+ const review = readReview(body);
835
+ if (review !== void 0 && review.bytes.byteLength > 25165824) {
836
+ sendJson(res, 413, {
837
+ ok: false,
838
+ error: "the review artifact is too large"
839
+ });
840
+ return;
841
+ }
842
+ const saved = await writeAnnotation(resolved.target.path, document, review);
843
+ sendJson(res, 200, {
844
+ ok: true,
845
+ annotationPath: saved.paths.annotation,
846
+ reviewPath: saved.wroteReview ? saved.paths.review : null
847
+ });
848
+ return;
849
+ }
850
+ if (req.method === "DELETE") {
851
+ const resolved = await resolveTarget(url.searchParams.get("address") ?? "", deps);
852
+ if (!resolved.ok) {
853
+ sendJson(res, 404, {
854
+ ok: false,
855
+ error: resolved.message
856
+ });
857
+ return;
858
+ }
859
+ const removed = await removeAnnotation(resolved.target.path);
860
+ sendJson(res, 200, {
861
+ ok: true,
862
+ annotationPath: removed.paths.annotation,
863
+ reviewPath: removed.paths.review,
864
+ removed: removed.removed
865
+ });
866
+ return;
867
+ }
868
+ sendJson(res, 405, {
869
+ ok: false,
870
+ error: `method ${req.method ?? ""} is not allowed`
871
+ });
872
+ } catch (error) {
873
+ sendJson(res, 400, {
874
+ ok: false,
875
+ error: error instanceof Error ? error.message : String(error)
876
+ });
877
+ }
878
+ };
879
+ return { annotation };
880
+ }
881
+ //#endregion
882
+ //#region src/host/session-cwd.ts
883
+ /**
884
+ * Resolve one Session's workspace directory.
885
+ * @param sessionId - the Session named by a preview address.
886
+ * @param sources - the Host sources available in this composition.
887
+ * @returns the absolute directory, or undefined when no source knows it.
888
+ */
889
+ async function resolveSessionCwd(sessionId, sources) {
890
+ const live = sources.agents?.get(sessionId)?.session;
891
+ const liveCwd = live?.header?.cwd ?? live?.cwd;
892
+ if (liveCwd !== void 0 && liveCwd !== "") return liveCwd;
893
+ try {
894
+ const cwd = (await sources.sessionPersistence?.stat(sessionId))?.header?.cwd;
895
+ if (cwd !== void 0 && cwd !== "") return cwd;
896
+ } catch {}
897
+ for (const workspace of sources.workspaceRegistry?.list() ?? []) if (workspace.sessionIds?.includes(sessionId) === true && workspace.path !== void 0) return workspace.path;
898
+ }
899
+ //#endregion
900
+ //#region src/missing-service.ts
901
+ /**
902
+ * The one message a half-mounted plugin fails with.
903
+ *
904
+ * Both halves treat their collaborators as hard requirements, so the wording
905
+ * lives here rather than in each entry, where the two copies would drift.
906
+ * @module dsh-figmark/missing-service
907
+ */
908
+ /**
909
+ * Compose the message a missing service fails with.
910
+ * @param service - the service name that was absent.
911
+ * @returns the failure message.
912
+ */
913
+ function missingService(service) {
914
+ return `dsh-figmark: the composition provides no "${service}"; this plugin cannot mount without it`;
915
+ }
916
+ //#endregion
917
+ //#region src/index.ts
918
+ /** Plugin name, as the Loader entry and diagnostics spell it. */
919
+ const name = "dsh-figmark";
920
+ /**
921
+ * Services the host half cannot work without: the routes go on the Web server and
922
+ * the guidance goes into the prompt registry. Declaring them here also makes the
923
+ * Loader order this plugin after both.
924
+ */
925
+ const inject = ["webServer", "systemPrompt"];
926
+ /**
927
+ * Mount the host half.
928
+ * @param ctx - the plugin's Cordis context.
929
+ * @throws {Error} when a required service is missing from the composition.
930
+ */
931
+ function apply(ctx) {
932
+ const webServer = ctx.get("webServer");
933
+ const systemPrompt = ctx.get("systemPrompt");
934
+ if (webServer === void 0) throw new Error(missingService("webServer"));
935
+ if (systemPrompt === void 0) throw new Error(missingService("systemPrompt"));
936
+ /**
937
+ * Read the Host sources a Session's directory may be resolved from.
938
+ *
939
+ * Read per request, never captured when this row mounts: the plugin's own
940
+ * `inject` set is satisfied early (the web server and the prompt registry),
941
+ * while the session store and the workspace registry mount later in the same
942
+ * composition. A capture at mount time would freeze them as absent and take
943
+ * every session-relative preview address down with it.
944
+ */
945
+ const sources = () => ({
946
+ agents: ctx.get("agents"),
947
+ sessionPersistence: ctx.get("sessionPersistence"),
948
+ workspaceRegistry: ctx.get("workspaceRegistry")
949
+ });
950
+ const handlers = createHandlers({ sessionCwd: (sessionId) => resolveSessionCwd(sessionId, sources()) });
951
+ ctx.effect(() => webServer.register({
952
+ kind: "prefix",
953
+ path: ROUTE_PREFIX,
954
+ handler: handlers.annotation
955
+ }), "dsh-figmark: annotation route");
956
+ ctx.effect(() => systemPrompt.section({
957
+ name: GUIDANCE_NAME,
958
+ order: GUIDANCE_ORDER,
959
+ text: GUIDANCE,
960
+ stable: true
961
+ }), "dsh-figmark: guidance");
962
+ ctx.effect(() => systemPrompt.section({
963
+ name: EDIT_GUIDANCE_NAME,
964
+ order: EDIT_GUIDANCE_ORDER,
965
+ text: EDIT_GUIDANCE,
966
+ stable: true
967
+ }), "dsh-figmark: edit guidance");
968
+ }
969
+ //#endregion
970
+ export { apply, inject, name };