@appsoftwareltd/etherpk-mcp 0.9.6 → 0.10.1

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/dist/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { availableParallelism, homedir, hostname, tmpdir } from "node:os";
4
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import nodePath, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { parseArgs } from "node:util";
6
6
  import { access, chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, unlink, utimes, writeFile } from "node:fs/promises";
7
7
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
@@ -717,7 +717,7 @@ function looksLikeAgentSetupCode(value) {
717
717
  }
718
718
  var package_default = {
719
719
  name: "@appsoftwareltd/etherpk-mcp",
720
- version: "0.9.6",
720
+ version: "0.10.1",
721
721
  license: "Elastic-2.0",
722
722
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
723
723
  type: "module",
@@ -4162,9 +4162,49 @@ function parseBlocks(markdown) {
4162
4162
  return roots;
4163
4163
  }
4164
4164
  //#endregion
4165
+ //#region ../client/src/lib/document/extension-fence.ts
4166
+ /**
4167
+ * [[Extension Fence]]s (ADR 0138): a fence whose info word is `<extension id>.<name>` holds an
4168
+ * extension's data, such as a drawing's elements. Every reader of a document decides from the info
4169
+ * word alone, with no extension running, so the editor, the index, Semantic Search, the publisher
4170
+ * and the Headless Client always agree: the body is never read as writing, never searched, and never
4171
+ * published as code.
4172
+ *
4173
+ * Framework-free, so the index worker, the publisher and the Headless Client read it as the editor
4174
+ * does.
4175
+ */
4176
+ /** An extension's id or a name under it: lowercase letters, digits and hyphens, from a letter. */
4177
+ var NAME = "[a-z][a-z0-9-]*";
4178
+ var EXTENSION_FENCE_INFO = new RegExp(`^${NAME}\\.${NAME}$`);
4179
+ /** Whether an info word names an Extension Fence: an extension's id, a dot and a name. */
4180
+ function isExtensionFenceInfo(info) {
4181
+ return EXTENSION_FENCE_INFO.test(info);
4182
+ }
4183
+ /** The extension an Extension Fence's info word names: the part before the dot. */
4184
+ function extensionOfFence(info) {
4185
+ return info.slice(0, info.indexOf("."));
4186
+ }
4187
+ /** Each complete, outermost Extension Fence in `lines`, by its opener's and closer's 0-based lines. */
4188
+ function extensionFences(lines) {
4189
+ const out = [];
4190
+ const { sorted, parent } = fencedBlockTree(fencedBlocks(lines));
4191
+ sorted.forEach((block, i) => {
4192
+ if (parent[i] !== -1) return;
4193
+ const info = fenceLineInfo(lines[block.start])?.info ?? "";
4194
+ if (isExtensionFenceInfo(info)) out.push({
4195
+ start: block.start,
4196
+ end: block.end,
4197
+ info
4198
+ });
4199
+ });
4200
+ return out;
4201
+ }
4202
+ //#endregion
4165
4203
  //#region ../client/src/lib/document/map-text.ts
4166
4204
  var NUMBER = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)`;
4167
4205
  var POINT = new RegExp(String.raw`^\s*(${NUMBER})\s*,\s*(${NUMBER})\s*$`);
4206
+ /** How many of the last `@`s on a line may end its name (`readMapLine`). */
4207
+ var MAX_NAME_ENDS = 16;
4168
4208
  function readPoint(text) {
4169
4209
  const match = POINT.exec(text);
4170
4210
  if (!match) return null;
@@ -4214,7 +4254,8 @@ function readLocation(text, name) {
4214
4254
  function readMapLine(text) {
4215
4255
  const trimmed = text.trim();
4216
4256
  if (trimmed === "") return null;
4217
- for (let at = trimmed.lastIndexOf("@"); at >= 0; at = at === 0 ? -1 : trimmed.lastIndexOf("@", at - 1)) {
4257
+ let tries = 0;
4258
+ for (let at = trimmed.lastIndexOf("@"); at >= 0 && tries < MAX_NAME_ENDS; at = at === 0 ? -1 : trimmed.lastIndexOf("@", at - 1), tries++) {
4218
4259
  const line = readLocation(trimmed.slice(at + 1), trimmed.slice(0, at).trim());
4219
4260
  if (line) return line;
4220
4261
  }
@@ -4528,30 +4569,57 @@ function deriveDoc(text) {
4528
4569
  const candidateTasks = blocks.filter((b) => b.kind === "task");
4529
4570
  const fenced = candidateTasks.length === 0 ? () => false : fencedLines(text);
4530
4571
  const taskBlocks = candidateTasks.filter((b) => !fenced(b.startLine));
4572
+ const tasks = taskBlocks.map((b) => {
4573
+ const tags = parseTaskTags(b.label);
4574
+ return {
4575
+ blockLocalId: b.localId,
4576
+ done: b.done ?? false,
4577
+ text: b.label,
4578
+ line: b.startLine,
4579
+ priority: tags.priority,
4580
+ waiting: tags.waiting,
4581
+ doing: tags.doing,
4582
+ cancelled: tags.cancelled,
4583
+ due: tags.due,
4584
+ completion: tags.completion,
4585
+ scheduled: tags.scheduled
4586
+ };
4587
+ });
4588
+ const maps = deriveMaps(text, blocks, links, lineToBlock);
4531
4589
  return {
4532
4590
  blocks,
4533
4591
  links,
4534
- tasks: taskBlocks.map((b) => {
4535
- const tags = parseTaskTags(b.label);
4536
- return {
4537
- blockLocalId: b.localId,
4538
- done: b.done ?? false,
4539
- text: b.label,
4540
- line: b.startLine,
4541
- priority: tags.priority,
4542
- waiting: tags.waiting,
4543
- doing: tags.doing,
4544
- cancelled: tags.cancelled,
4545
- due: tags.due,
4546
- completion: tags.completion,
4547
- scheduled: tags.scheduled
4548
- };
4549
- }),
4592
+ tasks,
4550
4593
  taskConcepts: deriveTaskConcepts(blocks, links, taskBlocks),
4551
- ...deriveMaps(text, blocks, links, lineToBlock)
4594
+ ...maps,
4595
+ searchText: withoutExtensionFences(text, blocks, lineToBlock, maps.searchText)
4552
4596
  };
4553
4597
  }
4554
4598
  /**
4599
+ * What search and Semantic Search read for each block holding an [[Extension Fence]] (ADR 0138):
4600
+ * the block's text with the fence's lines left out, so an extension's data (a drawing's elements)
4601
+ * never fills either index. Added to what `deriveMaps` gave, block by block.
4602
+ */
4603
+ function withoutExtensionFences(text, blocks, lineToBlock, searchText) {
4604
+ if (!/```[a-z][a-z0-9-]*\.[a-z]/.test(text)) return searchText;
4605
+ const fences = extensionFences(text.split("\n"));
4606
+ if (fences.length === 0) return searchText;
4607
+ const hidden = /* @__PURE__ */ new Map();
4608
+ for (const fence of fences) {
4609
+ const id = lineToBlock.get(fence.start);
4610
+ if (id === void 0) continue;
4611
+ const lines = hidden.get(id) ?? /* @__PURE__ */ new Set();
4612
+ for (let line = fence.start; line <= fence.end; line++) lines.add(line);
4613
+ hidden.set(id, lines);
4614
+ }
4615
+ for (const [id, lines] of hidden) {
4616
+ const block = blocks[id];
4617
+ const read = (searchText.get(id) ?? block.text).split("\n").map((line, n) => lines.has(block.startLine + n) ? "" : line);
4618
+ searchText.set(id, read.join("\n"));
4619
+ }
4620
+ return searchText;
4621
+ }
4622
+ /**
4555
4623
  * The [[Place]]s and [[Route]]s a document's [[Map Block]]s hold (ADR 0118), the [[Block
4556
4624
  * Concept]]s of the blocks they sit in, and what text search reads for those blocks instead of
4557
4625
  * their coordinates.
@@ -12651,6 +12719,13 @@ function wikilinkRule(md, resolve) {
12651
12719
  * grammars for code), so they are pre-rendered by the orchestrator and handed in as lookups;
12652
12720
  * the renderer itself is synchronous and pure over its inputs.
12653
12721
  */
12722
+ /** A fence an extension published, as HTML the publisher writes: the extension never writes markup into a site. */
12723
+ function fenceOutputHtml(output) {
12724
+ if (output.kind === "picture") return `<figure class="diagram diagram-extension"><img src="${escapeHtml$3(output.src)}" alt="${escapeHtml$3(output.alt)}" width="${output.width}" height="${output.height}" loading="lazy"></figure>\n`;
12725
+ const size = (image) => image.width && image.height ? ` width="${Math.round(image.width)}" height="${Math.round(image.height)}"` : "";
12726
+ const image = output.image ? `<img src="${escapeHtml$3(output.image.src)}" alt="${escapeHtml$3(output.image.alt)}"${size(output.image)} loading="lazy">` : "";
12727
+ return `<figure class="diagram diagram-link"><a href="${escapeHtml$3(output.href)}">${image}</a><figcaption><a href="${escapeHtml$3(output.href)}">${escapeHtml$3(output.text)}</a></figcaption></figure>\n`;
12728
+ }
12654
12729
  /** The names on a Map Block, one line for the search index and the excerpt; never its coordinates. */
12655
12730
  function mapNames(source) {
12656
12731
  return readMapBody(source.split("\n")).items.map((item) => item.name).filter((name) => name !== "").join(", ");
@@ -12698,7 +12773,7 @@ function plainText(tokens) {
12698
12773
  if (info === "map") {
12699
12774
  const names = mapNames(token.content);
12700
12775
  if (names !== "") lines.push(names);
12701
- } else if (info !== "mermaid" && info !== "math") lines.push(token.content.trim());
12776
+ } else if (info !== "mermaid" && info !== "math" && !isExtensionFenceInfo(info)) lines.push(token.content.trim());
12702
12777
  }
12703
12778
  return lines.join("\n");
12704
12779
  }
@@ -12777,6 +12852,9 @@ function createDocumentRenderer(options) {
12777
12852
  if (!picture) return "";
12778
12853
  return `<figure class="map"><img src="${escapeHtml$3(picture.src)}" alt="${escapeHtml$3(picture.alt)}" width="${picture.width}" height="${picture.height}" loading="lazy"></figure>\n`;
12779
12854
  }
12855
+ const output = options.fenceOutput?.(lang, code);
12856
+ if (output) return fenceOutputHtml(output);
12857
+ if (output === null || isExtensionFenceInfo(lang)) return "";
12780
12858
  const highlighted = options.highlighted?.(lang, code);
12781
12859
  return `<pre><code${lang === "" ? "" : ` class="language-${escapeHtml$3(lang)}"`}>${highlighted ?? escapeHtml$3(code)}</code></pre>\n`;
12782
12860
  };
@@ -13229,6 +13307,80 @@ async function mapPictureName(source) {
13229
13307
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${MAP_PICTURE_VERSION}\n${source}`));
13230
13308
  return [...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join("");
13231
13309
  }
13310
+ /** A fence's body as lines, as an extension's widget reads it: markdown-it's content ends with a line break. */
13311
+ function bodyLines(code) {
13312
+ const lines = code.split("\n");
13313
+ if (lines[lines.length - 1] === "") lines.pop();
13314
+ return lines;
13315
+ }
13316
+ var PICTURE_EXTENSIONS = {
13317
+ "image/svg+xml": "svg",
13318
+ "image/png": "png",
13319
+ "image/webp": "webp"
13320
+ };
13321
+ /** An `https` address as written, or null for any other. */
13322
+ function httpsAddress(value) {
13323
+ if (typeof value !== "string") return null;
13324
+ try {
13325
+ return new URL(value).protocol === "https:" ? value : null;
13326
+ } catch {
13327
+ return null;
13328
+ }
13329
+ }
13330
+ /**
13331
+ * What an extension's `publish` answered for a fence, checked, its picture kept for the site under
13332
+ * a name taken from its bytes: a picture, a link (`https` only, as is any picture in it), null when
13333
+ * it is left out, or undefined when it could not be made, after which an ordinary fence is published
13334
+ * as code and an Extension Fence left out, with a warning either way.
13335
+ */
13336
+ async function publishedFence(asked, info, concept, pictures, warnings) {
13337
+ try {
13338
+ const out = await asked;
13339
+ if (out === null) return null;
13340
+ if (out.kind === "picture") {
13341
+ const extension = PICTURE_EXTENSIONS[out.type];
13342
+ if (!(out.bytes instanceof Uint8Array) || !extension) throw new Error("its picture is not an SVG, PNG or WebP file");
13343
+ const digest = await crypto.subtle.digest("SHA-256", out.bytes);
13344
+ const path = `pictures/${[...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join("")}.${extension}`;
13345
+ pictures.set(path, out.bytes);
13346
+ return {
13347
+ kind: "picture",
13348
+ src: path,
13349
+ alt: String(out.alt ?? ""),
13350
+ width: Math.round(out.width),
13351
+ height: Math.round(out.height)
13352
+ };
13353
+ }
13354
+ if (out.kind === "link") {
13355
+ const href = httpsAddress(out.href);
13356
+ if (!href) throw new Error("its link is not an https address");
13357
+ const src = httpsAddress(out.image?.src);
13358
+ const image = src && out.image ? {
13359
+ src,
13360
+ alt: String(out.image.alt ?? ""),
13361
+ width: out.image.width,
13362
+ height: out.image.height
13363
+ } : void 0;
13364
+ return {
13365
+ kind: "link",
13366
+ href,
13367
+ text: String(out.text ?? href),
13368
+ ...image ? { image } : {}
13369
+ };
13370
+ }
13371
+ throw new Error("it answered with something that is neither a picture nor a link");
13372
+ } catch (error) {
13373
+ const why = error instanceof Error ? error.message : String(error);
13374
+ const extensionFence = isExtensionFenceInfo(info);
13375
+ warnings.push({
13376
+ level: "warning",
13377
+ code: "extension-fence-failed",
13378
+ message: `A \`${info}\` block in "${concept}" could not be published: ${why}. ${extensionFence ? "It is left out." : "It is published as its text."}`,
13379
+ concept
13380
+ });
13381
+ return extensionFence ? null : void 0;
13382
+ }
13383
+ }
13232
13384
  function assetHrefOf(name) {
13233
13385
  return `assets/${encodeURIComponent(name)}`;
13234
13386
  }
@@ -13407,6 +13559,10 @@ async function publishPublication(source, publication, env, options = {}) {
13407
13559
  }
13408
13560
  const mermaidSvg = /* @__PURE__ */ new Map();
13409
13561
  const mapPictures = /* @__PURE__ */ new Map();
13562
+ /** What each fence an extension publishes becomes, by its info word and body; null when it is left out. */
13563
+ const fenceOutputs = /* @__PURE__ */ new Map();
13564
+ /** The pictures extensions made of fences, by their path in the site. */
13565
+ const fencePictures = /* @__PURE__ */ new Map();
13410
13566
  const highlighted = /* @__PURE__ */ new Map();
13411
13567
  const highlight = env.highlightCode ?? highlightCode;
13412
13568
  const probe = createDocumentRenderer({
@@ -13417,51 +13573,77 @@ async function publishPublication(source, publication, env, options = {}) {
13417
13573
  doc: d,
13418
13574
  body: bodyOf$1(d)
13419
13575
  }));
13420
- for (const { doc, body } of bodies) for (const fence of probe.fences(body)) if (fence.lang === "mermaid") {
13421
- if (!env.renderMermaid || mermaidSvg.has(fence.code)) continue;
13422
- try {
13423
- mermaidSvg.set(fence.code, await env.renderMermaid(fence.code));
13424
- } catch (error) {
13576
+ for (const { doc, body } of bodies) for (const fence of probe.fences(body)) {
13577
+ const fenceKey = `${fence.lang}\n${fence.code}`;
13578
+ const extensionFence = isExtensionFenceInfo(fence.lang);
13579
+ const asked = fence.lang === "" || fenceOutputs.has(fenceKey) ? void 0 : env.publishFence?.({
13580
+ info: fence.lang,
13581
+ body: bodyLines(fence.code),
13582
+ document: doc.concept
13583
+ });
13584
+ if (asked) {
13585
+ const output = await publishedFence(asked, fence.lang, doc.concept, fencePictures, warnings);
13586
+ if (output !== void 0) {
13587
+ fenceOutputs.set(fenceKey, output);
13588
+ continue;
13589
+ }
13590
+ } else if (fenceOutputs.has(fenceKey)) continue;
13591
+ if (extensionFence) {
13592
+ fenceOutputs.set(fenceKey, null);
13425
13593
  warnings.push({
13426
13594
  level: "warning",
13427
- code: "mermaid-render-failed",
13428
- message: `A diagram in "${doc.concept}" could not be rendered: ${error instanceof Error ? error.message : String(error)}. It is left as its source for a script to draw.`,
13429
- concept: doc.concept
13430
- });
13431
- }
13432
- } else if (fence.lang === "map") {
13433
- if (mapPictures.has(fence.code) || errors.some((e) => e.code === "map-not-drawn" && e.concept === doc.concept)) continue;
13434
- if (!env.renderMap) {
13435
- errors.push({
13436
- level: "error",
13437
- code: "map-not-drawn",
13438
- message: `"${doc.concept}" holds a map, which this publisher can't draw. Publish the site from EtherPK in a browser, where maps are drawn.`,
13595
+ code: "extension-fence-left-out",
13596
+ message: `"${doc.concept}" holds content from the ${extensionOfFence(fence.lang)} extension, which this publisher can't draw, so it is left out. Publish from EtherPK in a browser, with that extension on, to include it.`,
13439
13597
  concept: doc.concept
13440
13598
  });
13441
13599
  continue;
13442
13600
  }
13443
- try {
13444
- const picture = await env.renderMap(fence.code);
13445
- const path = `maps/${await mapPictureName(fence.code)}.${picture.type === "image/webp" ? "webp" : "png"}`;
13446
- mapPictures.set(fence.code, {
13447
- ...picture,
13448
- path,
13449
- alt: mapAltText(fence.code.split("\n"))
13450
- });
13451
- } catch (error) {
13452
- const why = error instanceof Error ? error.message : String(error);
13453
- errors.push({
13454
- level: "error",
13455
- code: "map-not-drawn",
13456
- message: `The map in "${doc.concept}" could not be drawn: ${why}. Nothing was published, so try again once it can be.`,
13457
- concept: doc.concept
13458
- });
13601
+ if (fence.lang === "mermaid") {
13602
+ if (!env.renderMermaid || mermaidSvg.has(fence.code)) continue;
13603
+ try {
13604
+ mermaidSvg.set(fence.code, await env.renderMermaid(fence.code));
13605
+ } catch (error) {
13606
+ warnings.push({
13607
+ level: "warning",
13608
+ code: "mermaid-render-failed",
13609
+ message: `A diagram in "${doc.concept}" could not be rendered: ${error instanceof Error ? error.message : String(error)}. It is left as its source for a script to draw.`,
13610
+ concept: doc.concept
13611
+ });
13612
+ }
13613
+ } else if (fence.lang === "map") {
13614
+ if (mapPictures.has(fence.code) || errors.some((e) => e.code === "map-not-drawn" && e.concept === doc.concept)) continue;
13615
+ if (!env.renderMap) {
13616
+ errors.push({
13617
+ level: "error",
13618
+ code: "map-not-drawn",
13619
+ message: `"${doc.concept}" holds a map, which this publisher can't draw. Publish the site from EtherPK in a browser, where maps are drawn.`,
13620
+ concept: doc.concept
13621
+ });
13622
+ continue;
13623
+ }
13624
+ try {
13625
+ const picture = await env.renderMap(fence.code);
13626
+ const path = `maps/${await mapPictureName(fence.code)}.${picture.type === "image/webp" ? "webp" : "png"}`;
13627
+ mapPictures.set(fence.code, {
13628
+ ...picture,
13629
+ path,
13630
+ alt: mapAltText(fence.code.split("\n"))
13631
+ });
13632
+ } catch (error) {
13633
+ const why = error instanceof Error ? error.message : String(error);
13634
+ errors.push({
13635
+ level: "error",
13636
+ code: "map-not-drawn",
13637
+ message: `The map in "${doc.concept}" could not be drawn: ${why}. Nothing was published, so try again once it can be.`,
13638
+ concept: doc.concept
13639
+ });
13640
+ }
13641
+ } else if (fence.lang !== "" && fence.lang !== "math" && fence.lang !== "etherpk-cipher") {
13642
+ const key = `${fence.lang}\n${fence.code}`;
13643
+ if (highlighted.has(key)) continue;
13644
+ const html = await highlight(fence.lang, fence.code);
13645
+ if (html !== null) highlighted.set(key, html);
13459
13646
  }
13460
- } else if (fence.lang !== "" && fence.lang !== "math" && fence.lang !== "etherpk-cipher") {
13461
- const key = `${fence.lang}\n${fence.code}`;
13462
- if (highlighted.has(key)) continue;
13463
- const html = await highlight(fence.lang, fence.code);
13464
- if (html !== null) highlighted.set(key, html);
13465
13647
  }
13466
13648
  if (errors.length > 0) return {
13467
13649
  bundle,
@@ -13493,7 +13675,8 @@ async function publishPublication(source, publication, env, options = {}) {
13493
13675
  width: picture.width,
13494
13676
  height: picture.height
13495
13677
  };
13496
- }
13678
+ },
13679
+ fenceOutput: (lang, code) => fenceOutputs.get(`${lang}\n${code}`)
13497
13680
  });
13498
13681
  const pages = [];
13499
13682
  let done = 0;
@@ -13752,6 +13935,7 @@ async function publishPublication(source, publication, env, options = {}) {
13752
13935
  report.assets.copied.push(name);
13753
13936
  }
13754
13937
  for (const picture of mapPictures.values()) bundle.set(picture.path, picture.bytes);
13938
+ for (const [path, bytes] of fencePictures) bundle.set(path, bytes);
13755
13939
  for (const [path, text] of theme.files) if (path.startsWith("assets/")) bundle.set(`theme/${path.slice(7)}`, text);
13756
13940
  if (customCss !== "") bundle.set("theme/custom.css", customCss.endsWith("\n") ? customCss : `${customCss}\n`);
13757
13941
  if (hasMath) {
@@ -14663,6 +14847,45 @@ async function writePublishFolders(path, folders) {
14663
14847
  function publishFolderOf(folders, graphKey, publicationId) {
14664
14848
  return folders.folders[graphKey]?.[publicationId] ?? null;
14665
14849
  }
14850
+ /**
14851
+ * How two absolute folder paths relate, by the platform's own `relative` rather than by splitting
14852
+ * on `/` (Windows paths use `\`, and a shared prefix such as `notes` and `notes-site` is not a
14853
+ * parent). Spelling only: {@link publishFolderOverlap} resolves symbolic links first.
14854
+ */
14855
+ function relateFolders(graphFolder, publishFolder, options = {}) {
14856
+ const path = options.path ?? nodePath;
14857
+ const caseInsensitive = options.caseInsensitive ?? (process.platform === "darwin" || process.platform === "win32");
14858
+ const fold = (p) => caseInsensitive ? path.resolve(p).toLowerCase() : path.resolve(p);
14859
+ const graph = fold(graphFolder);
14860
+ const site = fold(publishFolder);
14861
+ const below = (from, to) => {
14862
+ const rel = path.relative(from, to);
14863
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
14864
+ };
14865
+ if (path.relative(graph, site) === "") return "same";
14866
+ if (below(graph, site)) return "inside";
14867
+ if (below(site, graph)) return "contains";
14868
+ return null;
14869
+ }
14870
+ /**
14871
+ * A path with its symbolic links resolved, for a folder that may not exist yet: the nearest
14872
+ * existing ancestor is resolved and the rest appended, so `--out ./site` under a linked parent is
14873
+ * compared where it will really be written.
14874
+ */
14875
+ async function canonicalFolder(folder) {
14876
+ const absolute = resolve(folder);
14877
+ try {
14878
+ return await realpath(absolute);
14879
+ } catch {
14880
+ const parent = dirname(absolute);
14881
+ if (parent === absolute) return absolute;
14882
+ return join(await canonicalFolder(parent), basename(absolute));
14883
+ }
14884
+ }
14885
+ /** {@link relateFolders} over the folders as they are on disk, symbolic links followed. */
14886
+ async function publishFolderOverlap(graphFolder, publishFolder) {
14887
+ return relateFolders(await canonicalFolder(graphFolder), await canonicalFolder(publishFolder));
14888
+ }
14666
14889
  /** The same file with one publication's folder set (made absolute) - the caller writes it. */
14667
14890
  function withPublishFolder(folders, graphKey, publicationId, folder) {
14668
14891
  return { folders: {
@@ -17395,6 +17618,26 @@ async function findPublication(graph, id, host) {
17395
17618
  if (!found) throw publicationNotFound(id, publications, via);
17396
17619
  return found;
17397
17620
  }
17621
+ var OVERLAP_WORDS = {
17622
+ same: "is the folder of",
17623
+ inside: "is inside the folder of",
17624
+ contains: "holds the folder of"
17625
+ };
17626
+ /**
17627
+ * Refuse a Publish Folder that overlaps a served folder: the graph's own folder, one inside it, or
17628
+ * one holding it. A publish deletes the files it did not write from the folder it writes into
17629
+ * (`isOwnedPath`), which would take the graph's attachments, or mix the site's files into the
17630
+ * graph. Checked where a folder is set (`--out`) and again before every publish, since the
17631
+ * folder may have been set before this check existed. A synced graph has no folder here.
17632
+ */
17633
+ async function checkPublishFolder(graph, publicationId, folder, host) {
17634
+ if (graph.backend.kind !== "folder") return;
17635
+ const overlap = await publishFolderOverlap(graph.backend.path, folder);
17636
+ if (!overlap) return;
17637
+ const { cmd, via } = hostOf$1(host);
17638
+ const command = `${cmd} publish --folder "${graph.backend.path}" --publication ${publicationId} --out <a folder outside the graph>`;
17639
+ throw new ToolError("publish_folder_overlaps_graph", `${folder} ${OVERLAP_WORDS[overlap]} the graph "${graph.name}" (${graph.backend.path}). A publish deletes the files it did not write from the folder it publishes into, so the site needs a folder of its own outside the graph. Nothing was published. ` + (via === "cli" ? `Run: ${command}` : `Ask the user to run: ${command}`));
17640
+ }
17398
17641
  /** The publication page's mapping and body, through the raw handles: what `publish-service` writes to. */
17399
17642
  function frontmatterStore(graph) {
17400
17643
  return {
@@ -17526,7 +17769,7 @@ function head(items, n = 20) {
17526
17769
  /**
17527
17770
  * Publish one publication into its Publish Folder and report. The folder is the one a person
17528
17771
  * set for this graph and publication on this machine (ADR 0086); a publish with Mermaid needs
17529
- * the browser (ADR 0084).
17772
+ * the browser (ADR 0084). A dry run does everything but write, so it needs no folder.
17530
17773
  *
17531
17774
  * The result IS the report, trimmed to counts and first entries: nothing of it is written into
17532
17775
  * the folder, because a static host serves whatever the folder holds and the report names every
@@ -17543,10 +17786,12 @@ async function publish(graph, args, host) {
17543
17786
  const publication = summary.publications.find((p) => p.id === args.id.trim());
17544
17787
  if (!publication) throw publicationNotFound(args.id, summary.publications, via);
17545
17788
  const folder = (await foldersFor(graph, env))(publication.id);
17546
- if (!folder) {
17789
+ const dryRun = args.dryRun === true;
17790
+ if (!folder && !dryRun) {
17547
17791
  const where = graph.backend.kind === "folder" ? `--folder "${graph.backend.path}"` : `--graph "${graph.name}"`;
17548
17792
  throw new ToolError("no_publish_folder", `No publish folder is set for "${publication.name}" on this machine. Ask the user to run: ${cmd} publish ${where} --publication ${publication.id} --out <dir> - that publishes once and remembers the folder for this tool.`);
17549
17793
  }
17794
+ if (folder && !dryRun) await checkPublishFolder(graph, publication.id, folder, host);
17550
17795
  const selected = selectDocuments(source.documents, publication, summary.publications);
17551
17796
  const includeTexts = Object.values(publication.includes).map((concept) => source.documents.find((d) => d.concept.toLowerCase() === concept.toLowerCase())?.text ?? "");
17552
17797
  const bodies = [...selected.included.map((d) => d.text), ...includeTexts];
@@ -17577,7 +17822,7 @@ async function publish(graph, args, host) {
17577
17822
  }),
17578
17823
  unsettled
17579
17824
  });
17580
- const written = run.report.ok ? await writeSite(nodeSiteFolder(folder), run.bundle, {
17825
+ const written = run.report.ok && folder && !dryRun ? await writeSite(nodeSiteFolder(folder), run.bundle, {
17581
17826
  seeded: run.seeded,
17582
17827
  agentsMd: run.agentsMd
17583
17828
  }) : null;
@@ -17585,6 +17830,7 @@ async function publish(graph, args, host) {
17585
17830
  for (const e of run.report.excluded) excludedByReason[e.reason] = (excludedByReason[e.reason] ?? 0) + 1;
17586
17831
  return {
17587
17832
  ok: run.report.ok,
17833
+ ...dryRun ? { dryRun: true } : {},
17588
17834
  publication: run.report.publication,
17589
17835
  folder,
17590
17836
  written: written ? {
@@ -18316,7 +18562,7 @@ function createMcpServer(source, info) {
18316
18562
  "graph_insights says what the links show across the whole graph (the most-linked concepts, pages many documents ask for that do not exist yet, pages linked to nothing, groups of related concepts and the concepts bridging them), and graph_path finds how two concepts are connected. Use them to answer questions about the shape of the graph, or to suggest pages to write or link.",
18317
18563
  "Start with graph_info to see what you are connected to. read_documents reads several documents in one call. tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
18318
18564
  "A task reference is how a person hands you one task: its words, then an EtherPK address ending in #task=<line>-<fingerprint>. Pass the whole reference, or the address, to read_task for the task and its detail, to set_task (as reference) to change it, and to add_task_note to write what you did under it. It finds the task after lines are added above it or its tags change; if its words were edited it answers task_not_found, and you should ask the user for a fresh one.",
18319
- "Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it - its outline is the site navigation) and the public documents none takes. A document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings, and publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
18565
+ "Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it - its outline is the site navigation) and the public documents none takes. A document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings, and publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. publish with dryRun: true renders and reports without writing and needs no folder: run it after editing a publication's pages and fix what it names. Diagrams need a browser the user installs once with \"diagrams setup\".",
18320
18566
  "Themes: a publication's look is a theme - Mustache templates, a stylesheet, a script and a manifest. list_themes shows the bundled ones (read-only) and the graph's own. read_theme writes a theme's files to a folder on this machine to read and edit. customise_publication_theme copies a publication's bundled theme into the graph and points the publication at the copy (create_theme copies any theme). write_theme_file, delete_theme_file and import_theme_folder change a graph theme. preview_theme renders a theme to a folder (with screenshots when a browser is set up) to check before publish. For a snippet such as an analytics script, an include slot (update_publication includes, e.g. head) filled by a page may be lighter than a theme copy.",
18321
18567
  "Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document. read_asset writes an asset to a local file you can open, and list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session). read_asset, read_theme and preview_theme write under the graph's downloads directory and return the path - name a folder relative to it, never elsewhere.",
18322
18568
  "The user documentation is at https://docs.etherpk.com."
@@ -18498,7 +18744,7 @@ function createMcpServer(source, info) {
18498
18744
  }, async (args) => run((graph) => rename$1(graph, args)));
18499
18745
  server.registerTool("set_aliases", {
18500
18746
  title: "Set aliases",
18501
- description: "Replace a document's aliases: other names it answers to in [[wikilinks]], Quick Find and on a published site. The list you pass is the complete list, and it is shown in the document's frontmatter, in a block added if there is none. A name that is already another document's title or alias is refused with error \"name_taken\".",
18747
+ description: "Replace a document's aliases: other names it answers to in [[wikilinks]], Search and on a published site. The list you pass is the complete list, and it is shown in the document's frontmatter, in a block added if there is none. A name that is already another document's title or alias is refused with error \"name_taken\".",
18502
18748
  inputSchema: {
18503
18749
  concept,
18504
18750
  aliases: z.array(z.string()).describe("Every alias the document should have - an empty list removes them all.")
@@ -18573,8 +18819,11 @@ function createMcpServer(source, info) {
18573
18819
  }, async (args) => run((graph) => updatePublication(graph, args, host)));
18574
18820
  server.registerTool("publish", {
18575
18821
  title: "Publish",
18576
- description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then - the tool never chooses a folder). Pages with Mermaid diagrams or maps need the browser from \"diagrams setup\" (error \"chromium_unavailable\"). The report is this result, trimmed to counts and first entries - nothing of it is written into the folder, so the site never names the documents it leaves out. Publishing writes files - it does not deploy them.",
18577
- inputSchema: { id: z.string().min(1) }
18822
+ description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then - the tool never chooses a folder), and a folder that is the graph's own folder, inside it or holding it is refused (error \"publish_folder_overlaps_graph\"). With dryRun: true it renders and reports but writes nothing and needs no folder: use it to check a publication after editing its pages, and fix what the report names. Pages with Mermaid diagrams or maps need the browser from \"diagrams setup\" (error \"chromium_unavailable\"), for a dry run too. The report is this result, trimmed to counts and first entries - nothing of it is written into the folder, so the site never names the documents it leaves out. Publishing writes files - it does not deploy them.",
18823
+ inputSchema: {
18824
+ id: z.string().min(1),
18825
+ dryRun: z.boolean().optional().describe("Render and report without writing anything. No publish folder needed.")
18826
+ }
18578
18827
  }, async (args) => run((graph) => publish(graph, args, host)));
18579
18828
  server.registerTool("list_themes", {
18580
18829
  title: "List themes",
@@ -19010,6 +19259,7 @@ async function runGraphHost(deps) {
19010
19259
  if (stopping) return answer({ etherpk: "stopping" });
19011
19260
  return startSession(socket, request, reply);
19012
19261
  case "publish":
19262
+ case "publish-check":
19013
19263
  if (stopping) return answer({ etherpk: "stopping" });
19014
19264
  return runPublish(request, answer);
19015
19265
  default: return answer({
@@ -25024,7 +25274,7 @@ function bindServeLifetime(deps) {
25024
25274
  * etherpk-mcp stop [--graph <id or name> [--sync-server <url>] | --folder <path> | --all]
25025
25275
  * etherpk-mcp logout [--sync-server <url> | --all]
25026
25276
  * etherpk-mcp semantic setup | status | remove
25027
- * etherpk-mcp publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir>]
25277
+ * etherpk-mcp publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --dry-run]
25028
25278
  * etherpk-mcp diagrams setup | status
25029
25279
  *
25030
25280
  * One config file holds a login per Sync Server (ADR 0075). `--sync-server` names the one a
@@ -25075,8 +25325,9 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
25075
25325
  Serve a local graph folder the same way: no sign-in, no server. The agent gets search,
25076
25326
  backlinks, tasks and format-safe edits over the folder's markdown, alongside the files
25077
25327
  themselves. Edits made in an editor or by the agent directly are picked up as they land.
25078
- The folder must already be a graph (open it in EtherPK once) - its index is kept under
25079
- the cache directory, never in the folder.
25328
+ The folder must already be a graph, with pages/ and journals/ folders in it (EtherPK
25329
+ makes them when it opens a folder) - its index is kept under the cache directory, never
25330
+ in the folder.
25080
25331
  ${CMD} running
25081
25332
  List the graphs served on this computer: each one's background process, its agents and
25082
25333
  its log. A background process stops on its own once no agent has used it for five
@@ -25095,14 +25346,18 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
25095
25346
  to a service to be embedded.
25096
25347
  ${CMD} semantic status | remove
25097
25348
  Whether it is set up here, or delete it (logout --all deletes it too).
25098
- ${CMD} publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir>]
25349
+ ${CMD} publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --dry-run]
25099
25350
  Publish one publication of a graph to a folder on this machine and print the report.
25100
25351
  --out sets the publish folder for that graph and publication and is remembered: from
25101
25352
  then on this command without --out, and the agent's publish tool, write to the same
25102
- folder (the agent cannot choose one). The folder's owned files (.html at the top,
25103
- assets/, theme/, the search, feed and report files) are rewritten and their strays
25104
- removed - everything else in it is left alone. Publishing writes files - deploying them
25105
- (a git push, say) is yours. Run it from cron for a site that republishes itself.
25353
+ folder (the agent cannot choose one). It must be a folder of its own, outside the
25354
+ graph's folder: the folder's owned files (.html at the top, assets/, theme/, the search
25355
+ and feed files) are rewritten and their strays removed - everything else in it is left
25356
+ alone. Publishing writes files - deploying them (a git push, say) is yours. Run it from
25357
+ cron for a site that republishes itself.
25358
+ --dry-run renders and checks the publication and prints the whole report, with the
25359
+ names of the pages it mentions, but writes nothing and needs no folder. Keep its output
25360
+ out of public logs.
25106
25361
  ${CMD} diagrams setup
25107
25362
  Let a publish draw Mermaid diagrams and maps. Downloads a Chromium (about 170 MB) into the
25108
25363
  cache directory with Playwright's installer, once per computer - a publish whose pages hold
@@ -25510,14 +25765,22 @@ async function publishCommand(args) {
25510
25765
  const request = graphRequest("publish", args);
25511
25766
  const publication = args.publication?.trim();
25512
25767
  if (!publication) fail("publish needs --publication <id>. The agent's list_publications tool, or Settings → Publish in EtherPK, shows the ids.");
25513
- const found = await findGraph(request).catch((error) => fail(`etherpk-mcp: ${describeError(error)}`));
25768
+ const dryRun = args["dry-run"] === true;
25514
25769
  const out = args.out?.trim();
25515
- const { reply, socket } = await requestHost(launcherFor(found), {
25770
+ if (dryRun && out) fail("publish --dry-run writes nothing, so it takes no --out. Leave --out off, or drop --dry-run to publish.");
25771
+ const found = await findGraph(request).catch((error) => fail(`etherpk-mcp: ${describeError(error)}`));
25772
+ const env = publishSettings(process.env);
25773
+ const { reply, socket } = await requestHost(launcherFor(found), dryRun ? {
25774
+ etherpk: "publish-check",
25775
+ protocol: 1,
25776
+ publication,
25777
+ env
25778
+ } : {
25516
25779
  etherpk: "publish",
25517
25780
  protocol: 1,
25518
25781
  publication,
25519
25782
  ...out ? { out: resolve(out) } : {},
25520
- env: publishSettings(process.env)
25783
+ env
25521
25784
  }).catch((error) => fail(`etherpk-mcp: ${describeError(error)}`));
25522
25785
  socket.destroy();
25523
25786
  switch (reply.etherpk) {
@@ -25527,15 +25790,18 @@ async function publishCommand(args) {
25527
25790
  process.exitCode = reply.exitCode;
25528
25791
  return;
25529
25792
  case "refused": return fail(reply.message);
25530
- case "failed": return fail(`etherpk-mcp: ${reply.message}`);
25793
+ case "failed":
25794
+ if (dryRun && reply.message === "The request was not understood.") return fail(`etherpk-mcp: this graph is served on this computer by an older etherpk-mcp, which cannot run a dry run. Run: ${CMD} stop, then try again.`);
25795
+ return fail(`etherpk-mcp: ${reply.message}`);
25531
25796
  case "unsupported": return fail(`etherpk-mcp: this graph is served on this computer by etherpk-mcp ${reply.version}, which cannot run a publish for this version (${VERSION}). Run: ${CMD} stop, then publish again.`);
25532
25797
  default: return fail(`etherpk-mcp: the graph's background process gave an answer this version does not know ("${reply.etherpk}").`);
25533
25798
  }
25534
25799
  }
25535
25800
  /**
25536
25801
  * A command-line publish, as the graph's host runs it: the publication first, so a mistyped id is
25537
- * never remembered as a publish folder, then `--out` remembered, then the publish the agent's
25538
- * tool runs. The answer is what the command prints, in its own words.
25802
+ * never remembered as a publish folder, then `--out` checked against the graph's folder and
25803
+ * remembered, then the publish the agent's tool runs. The answer is what the command prints, in
25804
+ * its own words.
25539
25805
  */
25540
25806
  async function publishInHost(graph, request) {
25541
25807
  const env = withPublishSettings(process.env, request.env ?? {});
@@ -25546,10 +25812,23 @@ async function publishInHost(graph, request) {
25546
25812
  };
25547
25813
  const notes = [];
25548
25814
  try {
25815
+ if (request.etherpk === "publish-check") {
25816
+ const result = await publish(graph, {
25817
+ id: request.publication,
25818
+ dryRun: true
25819
+ }, host);
25820
+ return {
25821
+ etherpk: "published",
25822
+ output: result,
25823
+ notes: [],
25824
+ exitCode: result.ok ? 0 : 1
25825
+ };
25826
+ }
25549
25827
  await findPublication(graph, request.publication, host);
25550
25828
  const configPath = defaultPublishFoldersPath(env);
25551
25829
  const key = publishGraphKey(graph.backend, graph.graphId);
25552
25830
  if (request.out) {
25831
+ await checkPublishFolder(graph, request.publication, request.out, host);
25553
25832
  await writePublishFolders(configPath, withPublishFolder(await readPublishFolders(configPath), key, request.publication, request.out));
25554
25833
  notes.push(`etherpk-mcp: publish folder for "${request.publication}" of "${graph.name}" set to ${request.out} (remembered in ${configPath}).`);
25555
25834
  } else if (!publishFolderOf(await readPublishFolders(configPath), key, request.publication)) return {
@@ -26072,6 +26351,7 @@ async function main() {
26072
26351
  "no-semantic": { type: "boolean" },
26073
26352
  publication: { type: "string" },
26074
26353
  out: { type: "string" },
26354
+ "dry-run": { type: "boolean" },
26075
26355
  all: { type: "boolean" },
26076
26356
  name: { type: "string" },
26077
26357
  help: {