@appsoftwareltd/etherpk-mcp 0.7.0 → 0.8.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/dist/main.js CHANGED
@@ -23,15 +23,15 @@ import * as encoding from "lib0/encoding";
23
23
  import { parse, stringify } from "yaml";
24
24
  import "fake-indexeddb/auto";
25
25
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
- import "@codemirror/language";
27
- import { languages } from "@codemirror/language-data";
28
26
  import { classHighlighter, highlightTree } from "@lezer/highlight";
27
+ import { LanguageDescription } from "@codemirror/language";
28
+ import { languages } from "@codemirror/language-data";
29
29
  import MarkdownIt from "markdown-it";
30
30
  import katex from "katex";
31
31
  import Mustache from "mustache";
32
32
  var package_default = {
33
33
  name: "@appsoftwareltd/etherpk-mcp",
34
- version: "0.7.0",
34
+ version: "0.8.0",
35
35
  license: "Elastic-2.0",
36
36
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
37
37
  type: "module",
@@ -39,10 +39,11 @@ var package_default = {
39
39
  bin: { "etherpk-mcp": "./bin/etherpk-mcp.js" },
40
40
  files: ["bin", "dist"],
41
41
  scripts: {
42
- "build": "vite build",
42
+ "client-tsconfig": "pnpm --dir ../client exec svelte-kit sync",
43
+ "build": "pnpm client-tsconfig && vite build",
43
44
  "check": "tsc -p tsconfig.json --noEmit",
44
- "test": "vitest run",
45
- "test:watch": "vitest",
45
+ "test": "pnpm client-tsconfig && vitest run",
46
+ "test:watch": "pnpm client-tsconfig && vitest",
46
47
  "prepack": "pnpm build"
47
48
  },
48
49
  dependencies: {
@@ -80,7 +81,7 @@ var package_default = {
80
81
  homepage: "https://docs.etherpk.com/using-ai-agents-with-your-notes",
81
82
  repository: {
82
83
  "type": "git",
83
- "url": "https://github.com/appsoftwareltd/etherpk",
84
+ "url": "https://github.com/appsoftwareltd/etherpk-client",
84
85
  "directory": "apps/mcp"
85
86
  },
86
87
  keywords: [
@@ -1393,50 +1394,6 @@ function wrapOo1Db(db) {
1393
1394
  };
1394
1395
  }
1395
1396
  //#endregion
1396
- //#region ../client/src/lib/storage/fs/frontmatter-span.ts
1397
- /** A line that is exactly a `---` delimiter (trailing spaces and tabs allowed). */
1398
- var DELIMITER = /^---[ \t]*\r?$/;
1399
- /** The Frontmatter of `text`, or `null` when it has none — including an unterminated opener. */
1400
- function frontmatterSpan(text) {
1401
- const firstBreak = text.indexOf("\n");
1402
- if (firstBreak === -1 || !DELIMITER.test(text.slice(0, firstBreak))) return null;
1403
- const bodyFrom = firstBreak + 1;
1404
- let lineStart = bodyFrom;
1405
- let lines = 1;
1406
- while (lineStart <= text.length) {
1407
- const nextBreak = text.indexOf("\n", lineStart);
1408
- const lineEnd = nextBreak === -1 ? text.length : nextBreak;
1409
- lines += 1;
1410
- if (DELIMITER.test(text.slice(lineStart, lineEnd))) {
1411
- let bodyTo = Math.max(bodyFrom, lineStart - 1);
1412
- if (bodyTo > bodyFrom && text[bodyTo - 1] === "\r") bodyTo -= 1;
1413
- return {
1414
- end: nextBreak === -1 ? text.length : nextBreak + 1,
1415
- lines,
1416
- body: text.slice(bodyFrom, bodyTo),
1417
- bodyFrom,
1418
- bodyTo
1419
- };
1420
- }
1421
- if (nextBreak === -1) break;
1422
- lineStart = nextBreak + 1;
1423
- }
1424
- return null;
1425
- }
1426
- /**
1427
- * How many lines a document's [[Frontmatter]] occupies - opener and closer included - given the
1428
- * document already split into lines, or 0 when it has none. The same rule as {@link frontmatterSpan}
1429
- * (a lone `---` is not a block; an unterminated one is not a block), for the line-shaped consumers
1430
- * in the editor that treat the block as opaque: the outliner's scans, the bullet dots, the guides,
1431
- * the clamp. Those already hold `lines`, and re-joining them per keystroke to ask the text form
1432
- * would be the only O(n) step in an otherwise line-local pass.
1433
- */
1434
- function frontmatterLines(lines) {
1435
- if (lines.length < 2 || !DELIMITER.test(lines[0])) return 0;
1436
- for (let i = 1; i < lines.length; i++) if (DELIMITER.test(lines[i])) return i + 1;
1437
- return 0;
1438
- }
1439
- //#endregion
1440
1397
  //#region ../client/src/lib/document/fenced-code.ts
1441
1398
  /** A backtick fence line, tolerant of a leading plain-bullet marker (`- `) so `- ``` ` is a fence.
1442
1399
  * Backticks only (this slice) — tildes are left untouched, so they are not auto-completed. */
@@ -1518,6 +1475,57 @@ function fencedBlocks(lines, closerTolerance = 0) {
1518
1475
  }
1519
1476
  return blocks;
1520
1477
  }
1478
+ /**
1479
+ * A line of a fenced block as code: the indentation up to the fence's column is structure (the
1480
+ * block's place in the outline) and goes; whatever lies past it is the code's own and stays.
1481
+ */
1482
+ function codeLineText(line, fenceColumn) {
1483
+ return line.length - line.trimStart().length >= fenceColumn ? line.slice(fenceColumn) : line.trimStart();
1484
+ }
1485
+ //#endregion
1486
+ //#region ../client/src/lib/storage/fs/frontmatter-span.ts
1487
+ /** A line that is exactly a `---` delimiter (trailing spaces and tabs allowed). */
1488
+ var DELIMITER = /^---[ \t]*\r?$/;
1489
+ /** The Frontmatter of `text`, or `null` when it has none — including an unterminated opener. */
1490
+ function frontmatterSpan(text) {
1491
+ const firstBreak = text.indexOf("\n");
1492
+ if (firstBreak === -1 || !DELIMITER.test(text.slice(0, firstBreak))) return null;
1493
+ const bodyFrom = firstBreak + 1;
1494
+ let lineStart = bodyFrom;
1495
+ let lines = 1;
1496
+ while (lineStart <= text.length) {
1497
+ const nextBreak = text.indexOf("\n", lineStart);
1498
+ const lineEnd = nextBreak === -1 ? text.length : nextBreak;
1499
+ lines += 1;
1500
+ if (DELIMITER.test(text.slice(lineStart, lineEnd))) {
1501
+ let bodyTo = Math.max(bodyFrom, lineStart - 1);
1502
+ if (bodyTo > bodyFrom && text[bodyTo - 1] === "\r") bodyTo -= 1;
1503
+ return {
1504
+ end: nextBreak === -1 ? text.length : nextBreak + 1,
1505
+ lines,
1506
+ body: text.slice(bodyFrom, bodyTo),
1507
+ bodyFrom,
1508
+ bodyTo
1509
+ };
1510
+ }
1511
+ if (nextBreak === -1) break;
1512
+ lineStart = nextBreak + 1;
1513
+ }
1514
+ return null;
1515
+ }
1516
+ /**
1517
+ * How many lines a document's [[Frontmatter]] occupies - opener and closer included - given the
1518
+ * document already split into lines, or 0 when it has none. The same rule as {@link frontmatterSpan}
1519
+ * (a lone `---` is not a block; an unterminated one is not a block), for the line-shaped consumers
1520
+ * in the editor that treat the block as opaque: the outliner's scans, the bullet dots, the guides,
1521
+ * the clamp. Those already hold `lines`, and re-joining them per keystroke to ask the text form
1522
+ * would be the only O(n) step in an otherwise line-local pass.
1523
+ */
1524
+ function frontmatterLines(lines) {
1525
+ if (lines.length < 2 || !DELIMITER.test(lines[0])) return 0;
1526
+ for (let i = 1; i < lines.length; i++) if (DELIMITER.test(lines[i])) return i + 1;
1527
+ return 0;
1528
+ }
1521
1529
  " ".repeat(2);
1522
1530
  /** Width of the `- ` marker, which sets a bullet's content column (`indent + MARKER_WIDTH`). */
1523
1531
  var MARKER_WIDTH = 2;
@@ -1729,11 +1737,17 @@ function normaliseIndentUnit(text) {
1729
1737
  * 3. **Continuation lines** — non-bullet lines indented to a bullet's content
1730
1738
  * column belong to the *same* block (the soft-newline-within-a-block).
1731
1739
  *
1740
+ * A complete [[Fenced Code Block]] is opaque to all three: it belongs whole to the block its
1741
+ * opener sits in, so a blank line, a `# comment` or a `- item` inside it is code, never a block
1742
+ * boundary, a heading or a bullet. "Complete" is the editor's pairing (`fencedBlocks`), the same
1743
+ * one the outline walk (`indent-unit.ts`) takes fences whole by.
1744
+ *
1732
1745
  * A block has a kind (heading / paragraph / bullet / task). Blocks have no
1733
1746
  * persistent identity: a block is its source range in the current parse.
1734
1747
  *
1735
- * Pure and DOM-free; the editor (keyboard ops, fold) and the derived index consume
1736
- * it. See Dual Mode Editor.md.
1748
+ * Pure and DOM-free; the derived index (`index-derive.ts`) and the publisher's navigation
1749
+ * (`publish/nav.ts`) consume it. The editor reads the same outline through `indent-unit.ts`.
1750
+ * See Dual Mode Editor.md.
1737
1751
  */
1738
1752
  function indentOf(line) {
1739
1753
  return line.length - line.trimStart().length;
@@ -1762,7 +1776,21 @@ function taskDone(line) {
1762
1776
  */
1763
1777
  function parseBlocks(markdown) {
1764
1778
  const lines = markdown.split("\n");
1765
- const outline = outlineLines(lines);
1779
+ const fences = fencedBlocks(lines);
1780
+ const outline = outlineLines(lines, fences);
1781
+ const fenceAt = new Map(fences.map((f) => [f.start, f]));
1782
+ /**
1783
+ * Add line `k` to a block's `buf` and return the line after it. A line that opens a complete
1784
+ * fenced block brings the whole block with it, so nothing inside the fence is read as structure.
1785
+ * A fence nested inside it starts within the range taken, so only outermost fences reach here.
1786
+ */
1787
+ const take = (k, buf) => {
1788
+ buf.push(lines[k].trimStart());
1789
+ const fence = fenceAt.get(k);
1790
+ if (!fence) return k + 1;
1791
+ for (let n = k + 1; n <= fence.end; n++) buf.push(codeLineText(lines[n], fence.fenceColumn));
1792
+ return fence.end + 1;
1793
+ };
1766
1794
  const flat = [];
1767
1795
  let i = 0;
1768
1796
  while (i < lines.length) {
@@ -1788,12 +1816,9 @@ function parseBlocks(markdown) {
1788
1816
  const contentCol = indentOf(lines[i]) + 2;
1789
1817
  const done = taskDone(lines[i]);
1790
1818
  const start = i;
1791
- const buf = [lines[i].trimStart()];
1792
- i++;
1793
- while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) >= contentCol) {
1794
- buf.push(lines[i].trimStart());
1795
- i++;
1796
- }
1819
+ const buf = [];
1820
+ i = take(i, buf);
1821
+ while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) >= contentCol) i = take(i, buf);
1797
1822
  flat.push({
1798
1823
  type: done === void 0 ? "bullet" : "task",
1799
1824
  depth,
@@ -1805,12 +1830,9 @@ function parseBlocks(markdown) {
1805
1830
  continue;
1806
1831
  }
1807
1832
  const start = i;
1808
- const buf = [lines[i].trimStart()];
1809
- i++;
1810
- while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) === indentOf(lines[start])) {
1811
- buf.push(lines[i].trimStart());
1812
- i++;
1813
- }
1833
+ const buf = [];
1834
+ i = take(i, buf);
1835
+ while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) === indentOf(lines[start])) i = take(i, buf);
1814
1836
  flat.push({
1815
1837
  type: "paragraph",
1816
1838
  depth,
@@ -1997,14 +2019,12 @@ function serialiseTags(tags) {
1997
2019
  /**
1998
2020
  * A predicate over source line numbers: is this line inside a [[Fenced Code Block]]?
1999
2021
  *
2000
- * The [[Block]] model does not treat fences as opaque, so `- [ ] x` written inside a fence
2001
- * parses as a [[Task]] like any other bullet. That was invisible while nothing listed tasks;
2002
- * the [[Tasks View]] makes it visible, as phantom tasks lifted out of code examples. Two
2003
- * things follow from excluding them here rather than in the block model: the editor's outliner
2004
- * behaviour is untouched (a fence's lines still belong to their block, which is what the
2005
- * keyboard layer expects), and a [[Protected Document]] - an `etherpk-cipher` fence - can never
2006
- * contribute a task, whatever it holds. That last one is a rule, not an optimisation: the
2007
- * [[Derived Index]] is plaintext at rest and outlives the session.
2022
+ * A `- [ ] x` written inside a fence must not become a [[Task]]: the [[Tasks View]] would list
2023
+ * phantom tasks lifted out of code examples. The [[Block]] model now takes a complete fence
2024
+ * whole (block-model.ts), so no task starts inside one; this scan stays for what the block model
2025
+ * cannot know, that a [[Protected Document]] - an `etherpk-cipher` fence - never contributes a
2026
+ * task, whatever it holds. That is a rule, not an optimisation: the [[Derived Index]] is
2027
+ * plaintext at rest and outlives the session.
2008
2028
  *
2009
2029
  * "Inside a fence" is the EDITOR's answer, not a second one: the column-scoped pairing of
2010
2030
  * `fencedBlocks` (Editor Content Rules → "Inside a block"), so a task is indexed exactly when
@@ -2040,6 +2060,15 @@ function blockLabel(block) {
2040
2060
  return bulletLabel(first);
2041
2061
  }
2042
2062
  /**
2063
+ * A block as a reader sees it: the marker stripped as in its {@link blockLabel}, and every line
2064
+ * after the first kept - continuation lines and fenced code, which a label drops. What a
2065
+ * references [[View]] quotes and a semantic passage embeds.
2066
+ */
2067
+ function blockContent(block) {
2068
+ const newline = block.text.indexOf("\n");
2069
+ return newline === -1 ? block.label : block.label + block.text.slice(newline);
2070
+ }
2071
+ /**
2043
2072
  * The label of a bullet or [[Task]] line — its marker and checkbox stripped, indentation
2044
2073
  * ignored. Exported because the [[Tasks View]]'s write-back guard has to ask "is the line in
2045
2074
  * the document still the task the index recorded?", and the only honest way to answer is with
@@ -2225,9 +2254,7 @@ var OVERLAP_MAX_CHARS = Math.floor(PASSAGE_BUDGET_CHARS / 4);
2225
2254
  var CRUMB = " > ";
2226
2255
  /** A block's text with its bullet / task / heading marker gone, continuation lines kept. */
2227
2256
  function blockBody(block) {
2228
- const newline = block.text.indexOf("\n");
2229
- const rest = newline === -1 ? "" : block.text.slice(newline);
2230
- return stripWikilinkBrackets(block.label + rest);
2257
+ return stripWikilinkBrackets(blockContent(block));
2231
2258
  }
2232
2259
  /** `[[Physics]]` embeds as `Physics`: the brackets are syntax, not meaning. */
2233
2260
  function stripWikilinkBrackets(text) {
@@ -2545,7 +2572,7 @@ function createSchema(db) {
2545
2572
  db.exec(SCHEMA$1);
2546
2573
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
2547
2574
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
2548
- db.exec(`PRAGMA user_version = 11`);
2575
+ db.exec(`PRAGMA user_version = 12`);
2549
2576
  }
2550
2577
  function activeIndexGeneration(db) {
2551
2578
  return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
@@ -2566,7 +2593,7 @@ function advanceIndexRevision(db) {
2566
2593
  */
2567
2594
  function isUsableIndex(db) {
2568
2595
  try {
2569
- return db.all("PRAGMA user_version")[0]?.user_version === 11;
2596
+ return db.all("PRAGMA user_version")[0]?.user_version === 12;
2570
2597
  } catch {
2571
2598
  return false;
2572
2599
  }
@@ -3077,16 +3104,40 @@ function collectSubtree(blocks, rootId) {
3077
3104
  const b = blocks[id];
3078
3105
  if (!b) return;
3079
3106
  out.push({
3080
- label: b.label,
3107
+ text: blockContent(b),
3081
3108
  depth,
3082
- isMatch: id === rootId
3109
+ isMatch: id === rootId,
3110
+ ...b.kind === "task" ? { done: b.done === true } : {}
3083
3111
  });
3084
3112
  for (const child of childrenByParent.get(id) ?? []) walk(child.localId, depth + 1);
3085
3113
  };
3086
3114
  walk(rootId, 0);
3087
3115
  return out;
3088
3116
  }
3089
- /** Truncate `text` to MAX_CONTEXT chars centred on the match, with ellipses. */
3117
+ /**
3118
+ * Widen `[start, end)` so that neither end falls inside a complete fenced code block. Half a code
3119
+ * block is no excerpt at all, and an opener cut from its closer would render as raw backticks.
3120
+ */
3121
+ function widenToFences(text, start, end) {
3122
+ const lines = text.split("\n");
3123
+ const lineStarts = [];
3124
+ let offset = 0;
3125
+ for (const line of lines) {
3126
+ lineStarts.push(offset);
3127
+ offset += line.length + 1;
3128
+ }
3129
+ for (const fence of fencedBlocks(lines)) {
3130
+ const from = lineStarts[fence.start];
3131
+ const to = lineStarts[fence.end] + lines[fence.end].length;
3132
+ if (start > from && start < to) start = from;
3133
+ if (end > from && end < to) end = to;
3134
+ }
3135
+ return [start, end];
3136
+ }
3137
+ /**
3138
+ * Truncate `text` to MAX_CONTEXT chars centred on the match, with ellipses. The window grows past
3139
+ * MAX_CONTEXT rather than cut a fenced code block in two ({@link widenToFences}).
3140
+ */
3090
3141
  function truncateContext(text, matchStart, matchEnd) {
3091
3142
  if (text.length <= MAX_CONTEXT) return {
3092
3143
  text,
@@ -3095,17 +3146,17 @@ function truncateContext(text, matchStart, matchEnd) {
3095
3146
  truncated: false
3096
3147
  };
3097
3148
  const mid = Math.floor((matchStart + matchEnd) / 2);
3098
- let start = Math.max(0, mid - Math.floor(MAX_CONTEXT / 2));
3099
- const end = Math.min(text.length, start + MAX_CONTEXT);
3100
- start = Math.max(0, end - MAX_CONTEXT);
3101
- const prefix = start > 0 ? "…" : "";
3102
- const suffix = end < text.length ? "…" : "";
3149
+ const windowStart = Math.max(0, mid - Math.floor(MAX_CONTEXT / 2));
3150
+ const windowEnd = Math.min(text.length, windowStart + MAX_CONTEXT);
3151
+ const [start, end] = widenToFences(text, Math.max(0, windowEnd - MAX_CONTEXT), windowEnd);
3152
+ const prefix = start === 0 ? "" : text[start - 1] === "\n" ? "…\n" : "…";
3153
+ const suffix = end === text.length ? "" : text[end] === "\n" ? "\n…" : "…";
3103
3154
  const shift = prefix.length - start;
3104
3155
  return {
3105
3156
  text: prefix + text.slice(start, end) + suffix,
3106
3157
  matchStart: Math.max(0, matchStart + shift),
3107
3158
  matchEnd: Math.max(0, matchEnd + shift),
3108
- truncated: true
3159
+ truncated: prefix !== "" || suffix !== ""
3109
3160
  };
3110
3161
  }
3111
3162
  /** Build a reference: a block subtree for bullets/tasks, prose context otherwise. */
@@ -3167,7 +3218,7 @@ function backlinksFor(db, concept) {
3167
3218
  WHERE p.generation=? AND l.concept_key IN (${placeholders})`, [generation, ...names]);
3168
3219
  const blocksByPage = /* @__PURE__ */ new Map();
3169
3220
  for (const pid of new Set(hits.map((h) => h.page_id))) {
3170
- const rows = db.all("SELECT local_id, parent_local_id, ord, kind, depth, label, text FROM blocks WHERE page_id=? ORDER BY local_id", [pid]);
3221
+ const rows = db.all("SELECT local_id, parent_local_id, ord, kind, depth, done, label, text FROM blocks WHERE page_id=? ORDER BY local_id", [pid]);
3171
3222
  const blocks = [];
3172
3223
  for (const r of rows) blocks[r.local_id] = {
3173
3224
  localId: r.local_id,
@@ -3175,6 +3226,7 @@ function backlinksFor(db, concept) {
3175
3226
  ord: r.ord,
3176
3227
  kind: r.kind,
3177
3228
  depth: r.depth,
3229
+ ...r.done === null ? {} : { done: r.done === 1 },
3178
3230
  startLine: 0,
3179
3231
  endLine: 0,
3180
3232
  text: r.text,
@@ -3854,19 +3906,25 @@ function folderCacheDir(env, folderPath) {
3854
3906
  function cacheRoot(env) {
3855
3907
  return env.ETHERPK_MCP_CACHE_DIR?.trim() || join(env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "etherpk", "mcp");
3856
3908
  }
3909
+ /**
3910
+ * The files this build reads and writes in a graph's directory, by name: the cache, the index,
3911
+ * and the [[Embedding]] store beside the index with a stamp of its own (ADR 0076) - an index
3912
+ * schema bump discards `index.v<N>` and leaves the vectors alone, because their rows cost
3913
+ * minutes to re-derive where the index's cost a second. `removeStaleFiles` keeps exactly these
3914
+ * names, so they are declared once here and joined to a directory below; a name is never
3915
+ * recovered from a joined path (see there).
3916
+ */
3917
+ var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
3918
+ var INDEX_FILE_NAME = `index.v12.sqlite`;
3919
+ var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
3857
3920
  function cacheFile(dir) {
3858
- return join(dir, `local-cache.v${CACHE_DB_VERSION}.bin`);
3921
+ return join(dir, CACHE_FILE_NAME);
3859
3922
  }
3860
3923
  function indexFile(dir) {
3861
- return join(dir, `index.v11.sqlite`);
3924
+ return join(dir, INDEX_FILE_NAME);
3862
3925
  }
3863
- /**
3864
- * The [[Embedding]] store, beside the index but with its own stamp (ADR 0076): an index
3865
- * schema bump discards `index.v<N>` and leaves this file alone, because its rows cost minutes
3866
- * to re-derive where the index's cost a second.
3867
- */
3868
3926
  function vectorsFile(dir) {
3869
- return join(dir, `vectors.v1.sqlite`);
3927
+ return join(dir, VECTORS_FILE_NAME);
3870
3928
  }
3871
3929
  function request(req) {
3872
3930
  return new Promise((resolve, reject) => {
@@ -3997,10 +4055,10 @@ var ABANDONED_TMP_AFTER_MS = 10 * 6e4;
3997
4055
  */
3998
4056
  async function removeStaleFiles(dir, now = Date.now()) {
3999
4057
  const keep = new Set([
4000
- cacheFile(dir),
4001
- indexFile(dir),
4002
- vectorsFile(dir)
4003
- ].map((f) => f.split("/").pop()));
4058
+ CACHE_FILE_NAME,
4059
+ INDEX_FILE_NAME,
4060
+ VECTORS_FILE_NAME
4061
+ ]);
4004
4062
  const removed = [];
4005
4063
  for (const name of await readdir(dir).catch(() => [])) {
4006
4064
  const stale = /^(index|vectors)\.v\d+\.sqlite$/.test(name) && !keep.has(name);
@@ -4643,13 +4701,8 @@ var performanceRecorder = createPerformanceRecorder({ enabled: typeof window !==
4643
4701
  var ENTITLEMENT_AUDIENCE = "urn:etherpk:sync-entitlements";
4644
4702
  var ENTITLEMENT_SERVICE = "managed-sync";
4645
4703
  /**
4646
- * Version-agnostic, like `isUuid` on the Server. The subject was matched by four different
4647
- * hand-rolled patterns that happened to agree only because billing account ids are v4 today:
4648
- * one accepted v1 to v5, another v1 to v8, another any 36 characters of hex and dashes
4649
- * (quality audit B4). If an id ever became a uuidv7 - which is what better-auth already mints
4650
- * - the strictest of them would have silently stopped matching, and the Stripe webhook's
4651
- * metadata fallback would have answered 409 "Unknown Stripe customer" for a real subscription.
4652
- * One pattern, here, next to the claim it belongs to.
4704
+ * Version-agnostic, like `isUuid` on the Server: any UUID version matches, so an id minted as a
4705
+ * uuidv7 is recognised as readily as a v4. One pattern, here, next to the claim it belongs to.
4653
4706
  */
4654
4707
  var ENTITLEMENT_SUBJECT_PATTERN = /^billing-account:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4655
4708
  var httpsUrl = z.url().refine((value) => {
@@ -5087,7 +5140,7 @@ function createSyncProtocol(overrides = {}) {
5087
5140
  }
5088
5141
  var defaultProtocol = createSyncProtocol();
5089
5142
  defaultProtocol.parseClientMessage;
5090
- var parseServerMessage$1 = defaultProtocol.parseServerMessage;
5143
+ var parseServerMessage = defaultProtocol.parseServerMessage;
5091
5144
  var serializeClientMessage$1 = defaultProtocol.serializeClientMessage;
5092
5145
  defaultProtocol.serializeServerMessage;
5093
5146
  //#endregion
@@ -5096,13 +5149,10 @@ defaultProtocol.serializeServerMessage;
5096
5149
  * The asset chunking contract, shared by the Client that uploads and the Sync Server that
5097
5150
  * signs the upload URLs.
5098
5151
  *
5099
- * These constants used to live only in the Client, which made the declared `size` on
5100
- * `POST /api/v1/sync/assets` a number the server had to take on trust: it counted the
5101
- * declared figure against the storage entitlement and then handed back presigned PUT URLs
5102
- * that bound nothing, so `size: 1024` bought unlimited bytes in the bucket (quality audit
5103
- * S2). With the contract stated here the server can derive exactly how many chunks an asset
5104
- * of a given size has, and exactly how long each chunk's ciphertext must be, and sign that
5105
- * length into the URL.
5152
+ * Shared so that the declared `size` on `POST /api/v1/sync/assets` is not taken on trust: from
5153
+ * the contract stated here the server derives exactly how many chunks an asset of a given size
5154
+ * has, and exactly how long each chunk's ciphertext must be, and signs that length into each
5155
+ * presigned PUT URL.
5106
5156
  *
5107
5157
  * Changing either constant is a protocol change: the Client's chunking and the Server's
5108
5158
  * signed lengths must move together, or every upload fails with an opaque 403.
@@ -5846,13 +5896,41 @@ function createDocSync(deps) {
5846
5896
  * added and checked only at the serialisation boundary so the document engine does not repeat
5847
5897
  * a transport constant on every operation.
5848
5898
  */
5849
- /** Returns null for anything malformed, oversized or from an unsupported protocol version. */
5850
- function parseServerMessage(raw) {
5851
- const result = parseServerMessage$1(raw);
5852
- if (!result.ok) return null;
5853
- const { v: _version, ...message } = result.value;
5854
- return message;
5899
+ function readServerMessage(raw) {
5900
+ const result = parseServerMessage(raw);
5901
+ if (result.ok) {
5902
+ const { v: _version, ...message } = result.value;
5903
+ return {
5904
+ kind: "message",
5905
+ message
5906
+ };
5907
+ }
5908
+ if (result.code === "unsupported_version") {
5909
+ const { v } = JSON.parse(raw);
5910
+ if (typeof v === "number" && Number.isInteger(v)) return {
5911
+ kind: "protocol_mismatch",
5912
+ serverVersion: v
5913
+ };
5914
+ }
5915
+ return { kind: "malformed" };
5855
5916
  }
5917
+ /**
5918
+ * The Sync Server speaks another sync protocol version, so nothing it sends can be read and
5919
+ * nothing this Client sends will be accepted. Retrying cannot fix it; one side has to be
5920
+ * upgraded. The message is for logs and the Headless Client; the browser maps the error to
5921
+ * its own copy in `sync-error-copy.ts`.
5922
+ */
5923
+ var SyncProtocolMismatchError = class extends Error {
5924
+ name = "SyncProtocolMismatchError";
5925
+ constructor(serverVersion, clientVersion = 2) {
5926
+ super(serverVersion < clientVersion ? `The Sync Server speaks sync protocol ${serverVersion} and this Client speaks ${clientVersion}: the server is older, and its operator needs to upgrade it.` : `The Sync Server speaks sync protocol ${serverVersion} and this Client speaks ${clientVersion}: this Client is older and needs upgrading.`);
5927
+ this.serverVersion = serverVersion;
5928
+ this.clientVersion = clientVersion;
5929
+ }
5930
+ get serverIsOlder() {
5931
+ return this.serverVersion < this.clientVersion;
5932
+ }
5933
+ };
5856
5934
  function serializeClientMessage(message) {
5857
5935
  return serializeClientMessage$1({
5858
5936
  v: 2,
@@ -6013,6 +6091,39 @@ function sanitizeQuickNotes(raw) {
6013
6091
  }
6014
6092
  return out;
6015
6093
  }
6094
+ new Intl.Segmenter(void 0, { granularity: "word" });
6095
+ /**
6096
+ * The spelling a dictionary is asked about: a curly apostrophe (`’`), which keyboards and
6097
+ * pastes produce, straightened, since Hunspell dictionaries spell contractions with `'`.
6098
+ */
6099
+ function spellingForm(word) {
6100
+ return word.replace(/’/g, "'");
6101
+ }
6102
+ /** Longer than any real word; a line this long is not one. */
6103
+ var MAX_WORD_LENGTH = 100;
6104
+ /**
6105
+ * The word as the dictionary keeps it, or null when it cannot be one entry. A curly apostrophe is
6106
+ * straightened, the form the checker compares (`spelling/words.ts` → `spellingForm`), so a word
6107
+ * added from `don’t` also accepts `don't`.
6108
+ */
6109
+ function normaliseDictionaryWord(raw) {
6110
+ const word = spellingForm(raw.trim().normalize("NFC"));
6111
+ if (word === "" || /\s/u.test(word) || word.length > MAX_WORD_LENGTH) return null;
6112
+ return word;
6113
+ }
6114
+ /** Well-formed words, deduped (first seen wins), capped: tolerant of a peer's or a newer client's list. */
6115
+ function sanitizeDictionaryWords(raw) {
6116
+ if (!Array.isArray(raw)) return [];
6117
+ const seen = /* @__PURE__ */ new Set();
6118
+ for (const entry of raw) {
6119
+ if (typeof entry !== "string") continue;
6120
+ const word = normaliseDictionaryWord(entry);
6121
+ if (word === null || seen.has(word)) continue;
6122
+ seen.add(word);
6123
+ if (seen.size >= 5e4) break;
6124
+ }
6125
+ return [...seen];
6126
+ }
6016
6127
  //#endregion
6017
6128
  //#region ../client/src/lib/storage/fs/frontmatter.ts
6018
6129
  /**
@@ -6385,6 +6496,12 @@ function createGraphSync(deps) {
6385
6496
  let socket;
6386
6497
  let open = false;
6387
6498
  let disposed = false;
6499
+ /**
6500
+ * Set when the Sync Server turned out to speak another protocol version. The session then
6501
+ * stops for good: every reconnect would meet the same server, and nothing either side
6502
+ * sends can be read by the other. Pending appends stay in the cache for a later session.
6503
+ */
6504
+ let protocolMismatch;
6388
6505
  const reportError = (error) => {
6389
6506
  if (!disposed) deps.onError?.(error instanceof Error ? error : new Error(String(error)));
6390
6507
  };
@@ -6406,6 +6523,7 @@ function createGraphSync(deps) {
6406
6523
  function waitForCurrentConnection() {
6407
6524
  if (open && socket) return Promise.resolve();
6408
6525
  if (disposed) return Promise.reject(/* @__PURE__ */ new Error("the graph sync session was disposed"));
6526
+ if (protocolMismatch) return Promise.reject(protocolMismatch);
6409
6527
  return new Promise((resolve, reject) => {
6410
6528
  connectionWaiters.add({
6411
6529
  resolve,
@@ -6445,7 +6563,7 @@ function createGraphSync(deps) {
6445
6563
  }
6446
6564
  /**
6447
6565
  * Documents the CURRENT socket has subscribed to. The relay keeps and forwards presence
6448
- * only for a subscribed document (ASTRA F11), and this mirrors that rule at the source:
6566
+ * only for a subscribed document, and this mirrors that rule at the source:
6449
6567
  * y-protocols renews every engine's awareness state every fifteen seconds, and an engine
6450
6568
  * created for a background walk starts with an empty `{}` state, so without the gate every
6451
6569
  * unretained engine encrypted and sent presence the relay would only drop. Cleared with
@@ -6463,9 +6581,9 @@ function createGraphSync(deps) {
6463
6581
  }
6464
6582
  /**
6465
6583
  * Subscribe to every retained document on a freshly opened socket, in batches the relay
6466
- * accepts. One message used to carry the whole set, and above the protocol's per-message
6467
- * limit the relay rejects it as invalid, which left a tab with many open views silently
6468
- * without live updates for anything after a reconnect (ASTRA F11).
6584
+ * accepts. Above the protocol's per-message limit the relay rejects a subscribe as invalid,
6585
+ * which would leave a tab with many open views silently without live updates after a
6586
+ * reconnect.
6469
6587
  */
6470
6588
  function subscribeRetained() {
6471
6589
  const docIds = [...retained.keys()];
@@ -6603,6 +6721,7 @@ function createGraphSync(deps) {
6603
6721
  if (deps.onRegistryChange) registryMap.observe(() => deps.onRegistryChange?.());
6604
6722
  const metaMap = root.doc.getMap("meta");
6605
6723
  const quickNotesArray = root.doc.getArray("quickNotes");
6724
+ const dictionaryMap = root.doc.getMap("spellingDictionary");
6606
6725
  const themesMap = root.doc.getMap("themes");
6607
6726
  /** A stored theme as a plain object, or null when the entry is not one. */
6608
6727
  function readTheme(id) {
@@ -6656,9 +6775,23 @@ function createGraphSync(deps) {
6656
6775
  publishNameOnceCaughtUp();
6657
6776
  }, () => {});
6658
6777
  }
6778
+ /** Report once, fail everything waiting for a connection, and close without reconnecting. */
6779
+ function stopForProtocolMismatch(serverVersion) {
6780
+ if (protocolMismatch) return;
6781
+ protocolMismatch = new SyncProtocolMismatchError(serverVersion);
6782
+ reportError(protocolMismatch);
6783
+ for (const waiter of connectionWaiters) waiter.reject(protocolMismatch);
6784
+ connectionWaiters.clear();
6785
+ socket?.close();
6786
+ }
6659
6787
  function handleMessage(raw) {
6660
- const message = parseServerMessage(raw);
6661
- if (!message) return;
6788
+ const read = readServerMessage(raw);
6789
+ if (read.kind === "protocol_mismatch") {
6790
+ stopForProtocolMismatch(read.serverVersion);
6791
+ return;
6792
+ }
6793
+ if (read.kind === "malformed") return;
6794
+ const message = read.message;
6662
6795
  if (message.type === "catchup_batch") performanceRecorder.mark("sync.catchup.batch", {
6663
6796
  rows: message.updates.length,
6664
6797
  bytes: new TextEncoder().encode(raw).byteLength,
@@ -6704,7 +6837,7 @@ function createGraphSync(deps) {
6704
6837
  * on open. Only rebuildable snapshot uploads use a volatile queue.
6705
6838
  */
6706
6839
  function connect() {
6707
- if (disposed) return;
6840
+ if (disposed || protocolMismatch) return;
6708
6841
  deps.token().then((token) => {
6709
6842
  if (disposed) return;
6710
6843
  const s = deps.connect(`${deps.relayUrl}?token=${encodeURIComponent(token)}`);
@@ -6746,7 +6879,7 @@ function createGraphSync(deps) {
6746
6879
  pending.reject(new WatermarkConnectionInterruptedError());
6747
6880
  }
6748
6881
  watermarkRequests.clear();
6749
- if (!disposed) setTimeout(connect, RECONNECT_MS);
6882
+ if (!disposed && !protocolMismatch) setTimeout(connect, RECONNECT_MS);
6750
6883
  });
6751
6884
  }, () => {
6752
6885
  if (!disposed) setTimeout(connect, TOKEN_RETRY_MS);
@@ -6853,6 +6986,21 @@ function createGraphSync(deps) {
6853
6986
  return () => quickNotesArray.unobserve(listener);
6854
6987
  }
6855
6988
  }),
6989
+ spellingDictionary: () => ({
6990
+ list: () => sanitizeDictionaryWords([...dictionaryMap.keys()]),
6991
+ add(word) {
6992
+ dictionaryMap.set(word, true);
6993
+ },
6994
+ remove(words) {
6995
+ root.doc.transact(() => {
6996
+ for (const word of words) dictionaryMap.delete(word);
6997
+ });
6998
+ },
6999
+ observe(listener) {
7000
+ dictionaryMap.observe(listener);
7001
+ return () => dictionaryMap.unobserve(listener);
7002
+ }
7003
+ }),
6856
7004
  themes: () => ({
6857
7005
  list() {
6858
7006
  const out = [];
@@ -9449,6 +9597,16 @@ function isCalendarDay(value) {
9449
9597
  function isJournalConcept(concept) {
9450
9598
  return isCalendarDay(concept);
9451
9599
  }
9600
+ /**
9601
+ * Why a [[Page]] cannot be called `day`: a day is the name of that day's [[Journal Entry]].
9602
+ *
9603
+ * A page given one sits in `pages/` answering to the day, which is what an older version left
9604
+ * behind when a Draft for a date promoted to a page. Creating a page and renaming one both
9605
+ * refuse with this, on both backends, so the copy is the same wherever the user meets it.
9606
+ */
9607
+ function dayIsNotAPageName(day) {
9608
+ return `“${day.trim()}” is a date, and a date is the name of that day's journal entry, so a page cannot be called that. Choose a different name.`;
9609
+ }
9452
9610
  //#endregion
9453
9611
  //#region ../client/src/lib/document/wikilink/rename.ts
9454
9612
  /**
@@ -9730,11 +9888,17 @@ var RenameUnconfirmedError = class extends Error {
9730
9888
  * journal-shaped [[Pageless Concept]] (a day nobody has written yet) is refused for the same
9731
9889
  * reason: its name is its day whether or not the entry exists.
9732
9890
  *
9891
+ * The same holds in the other direction: a page renamed TO a day is refused, rather than left
9892
+ * in `pages/` answering to the day or merged into its journal entry with a `title` block the
9893
+ * entry never carries (ADR 0056).
9894
+ *
9733
9895
  * A collision is NOT a refusal (ADR 0038 §4): it is a [[Merge]], confirmed in the dialog.
9734
9896
  */
9735
9897
  function renameRefusal(options) {
9736
- if (options.to.trim() === "") return "A page needs a non-empty name.";
9898
+ const to = options.to.trim();
9899
+ if (to === "") return "A page needs a non-empty name.";
9737
9900
  if (options.kind === "journal" || options.kind === null && isJournalConcept(options.from)) return "A journal entry cannot be renamed - its name is its date, and there is exactly one per day.";
9901
+ if (options.kind === "page" && isJournalConcept(to)) return dayIsNotAPageName(to);
9738
9902
  return null;
9739
9903
  }
9740
9904
  /** Every step, direct first - what an applier iterates. */
@@ -9890,17 +10054,29 @@ function reconcileDecision({ dirty, baseText, diskText }) {
9890
10054
  //#endregion
9891
10055
  //#region ../client/src/lib/storage/fs/scan.ts
9892
10056
  var SCANNED_SUBDIRS = ["journals", "pages"];
9893
- function isMarkdown(name) {
10057
+ /** Whether a listed name is a document file: `.md`, in any case. A `.crswap` swap file is not. */
10058
+ function isDocumentFile(name) {
9894
10059
  return /\.md$/i.test(name);
9895
10060
  }
9896
- async function scanGraph(adapter) {
10061
+ function fileKey(subdir, fileName) {
10062
+ return `${subdir}/${fileName}`;
10063
+ }
10064
+ async function scanGraph(adapter, options = {}) {
10065
+ const previous = /* @__PURE__ */ new Map();
10066
+ for (const entry of options.previous ?? []) previous.set(fileKey(entry.subdir, entry.fileName), entry);
10067
+ const writeInFlight = options.writeInFlight ?? (() => false);
9897
10068
  const journals = [];
9898
10069
  const pages = [];
9899
10070
  for (const subdir of SCANNED_SUBDIRS) {
9900
10071
  const kind = documentKindOf(subdir);
9901
10072
  if (!kind) continue;
9902
10073
  for (const { name, lastModified, size } of await adapter.list(subdir)) {
9903
- if (!isMarkdown(name)) continue;
10074
+ if (!isDocumentFile(name)) continue;
10075
+ const known = previous.get(fileKey(subdir, name));
10076
+ if (known !== void 0 && (known.lastModified === lastModified && known.size === size || writeInFlight(subdir, name))) {
10077
+ (kind === "journal" ? journals : pages).push(known);
10078
+ continue;
10079
+ }
9904
10080
  const { text } = await adapter.read(subdir, name);
9905
10081
  const fm = parseFrontmatter(text);
9906
10082
  const concept = kind === "journal" ? journalConceptOf(name) : conceptOf$1(fm, fileStem(name));
@@ -9933,7 +10109,7 @@ async function scanGraph(adapter) {
9933
10109
  * debounce) so it is exercised in Node over createMemoryDirectoryAdapter before
9934
10110
  * any browser code exists.
9935
10111
  *
9936
- * Async-seam note (see docs/docs/technical/Document Editor.md): the seam's
10112
+ * Async-seam note: the seam's
9937
10113
  * `getText()` is synchronous but disk reads are async, so `open()` returns a
9938
10114
  * handle whose buffer is empty on first open and is hydrated by an internal
9939
10115
  * awaited read that then notifies subscribers the *external* way — which is safe
@@ -10039,9 +10215,22 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10039
10215
  open.set(doc.key, doc);
10040
10216
  for (const listener of documentRenamed) listener(from, entry.concept);
10041
10217
  }
10042
- /** Replace the registry from a fresh scan; fire onDocumentsChanged iff it changed. */
10218
+ /**
10219
+ * Replace the registry from a fresh scan; fire onDocumentsChanged iff it changed. The scan
10220
+ * is given the entries it has and reuses each whose file has not moved, so a pass over an
10221
+ * unchanged graph reads nothing, and it is told which files have a write in flight so it
10222
+ * never opens one the store is saving - on Windows that read handle would make the
10223
+ * browser's rename of its swap file over the target fail. A fresh store has no entries, so
10224
+ * graph open reads every file and learns every identity.
10225
+ */
10043
10226
  async function refreshRegistry() {
10044
- const entries = await scanGraph(adapter);
10227
+ const entries = await scanGraph(adapter, {
10228
+ previous: registry.values(),
10229
+ writeInFlight: (subdir, fileName) => {
10230
+ for (const doc of open.values()) if (doc.saving && doc.subdir === subdir && doc.fileName === fileName) return true;
10231
+ return false;
10232
+ }
10233
+ });
10045
10234
  registry.clear();
10046
10235
  for (const entry of entries) registry.set(entry.key, entry);
10047
10236
  const sig = registrySignature(entries);
@@ -10484,6 +10673,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10484
10673
  async createPage(title, body = "") {
10485
10674
  const concept = title.trim();
10486
10675
  if (concept === "") throw new Error("A page needs a non-empty title.");
10676
+ if (isJournalConcept(concept)) throw new Error(dayIsNotAPageName(concept));
10487
10677
  const key = conceptKey(concept);
10488
10678
  if (registry.get(key)) throw new Error(`A document for "${concept}" already exists.`);
10489
10679
  await adapter.ensureSkeleton();
@@ -10604,6 +10794,7 @@ function describeFilesystemSaveFailure(error, concept) {
10604
10794
  case "NoModificationAllowedError": return `${opening} The file is locked by another program - a sync tool, or an editor holding it open. Close that, then retry. ${KEPT}`;
10605
10795
  case "NotFoundError": return `${opening} The folder is no longer where it was - a drive unplugged, or the folder moved. Make it available again, then retry. ${KEPT}`;
10606
10796
  case "NotReadableError": return `${opening} The file could not be read when it was opened, so nothing is written over it. Once it can be read the app reloads it, or asks you to choose if you have typed since. ${KEPT}`;
10797
+ case "InvalidStateError": return `${opening} Another program was using the file at the same time. Retry in a moment. If it keeps happening, check what else is using this folder. ${KEPT}`;
10607
10798
  default: return `${opening}${error instanceof Error && error.message ? ` The browser reported: ${error.message}.` : ""} Retry in a moment. ${KEPT}`;
10608
10799
  }
10609
10800
  }
@@ -11340,6 +11531,7 @@ function createServerDocumentStore(graph, options) {
11340
11531
  return day;
11341
11532
  },
11342
11533
  async createPage(title, body = "") {
11534
+ if (isJournalConcept(title)) throw new Error(dayIsNotAPageName(title));
11343
11535
  if (docIdFor(title)) throw new Error(`A page for "${title}" already exists`);
11344
11536
  const docId = crypto.randomUUID();
11345
11537
  registry.set(docId, {
@@ -12221,17 +12413,22 @@ async function openHeadlessFolder(deps) {
12221
12413
  });
12222
12414
  const indexHost = deps.persistDir ? nodeIndexHost(deps.persistDir) : void 0;
12223
12415
  /**
12224
- * The folder's listing as one string: every document file's name, mtime and size. The
12225
- * store's own reconcile re-reads every file to learn titles and aliases, which is right
12226
- * for the browser's poll but too much for a pass before every tool call on a large
12227
- * graph, so a pass first lists the two document subdirectories - a stat per file, no
12228
- * reads - and runs the full reconcile only when this differs from the last pass. An
12229
- * edit that keeps both mtime and size (an mtime-preserving copy) is missed until
12416
+ * The folder's listing as one string: every document file's name, mtime and size. A pass
12417
+ * first lists the two document subdirectories - a stat per file, no reads - and runs the
12418
+ * store's reconcile only when this differs from the last pass, so a pass before every
12419
+ * tool call on a large graph costs nothing when nothing moved. Only document files count:
12420
+ * a browser saving into the same folder writes a `.crswap` swap file first and renames it
12421
+ * over the document when the save lands, and a pass started by the swap file would read
12422
+ * the documents while that rename is due, which on Windows makes the browser's save fail.
12423
+ * An edit that keeps both mtime and size (an mtime-preserving copy) is missed until
12230
12424
  * something else changes, the same blind spot the store's own fast path accepts.
12231
12425
  */
12232
12426
  const listingSignature = async () => {
12233
12427
  const parts = [];
12234
- for (const subdir of ["journals", "pages"]) for (const entry of await deps.adapter.list(subdir)) parts.push(`${subdir}/${entry.name}@${entry.lastModified}:${entry.size}`);
12428
+ for (const subdir of ["journals", "pages"]) for (const entry of await deps.adapter.list(subdir)) {
12429
+ if (!isDocumentFile(entry.name)) continue;
12430
+ parts.push(`${subdir}/${entry.name}@${entry.lastModified}:${entry.size}`);
12431
+ }
12235
12432
  return parts.sort().join("|");
12236
12433
  };
12237
12434
  let lastListing;
@@ -13312,31 +13509,48 @@ function archiveHtml(journals) {
13312
13509
  return `<ul class="journal-archive">${journals.map((j) => `<li><time datetime="${escapeHtml$6(j.date ?? "")}">${escapeHtml$6(j.date ?? "")}</time> <a href="${escapeHtml$6(j.url)}">${escapeHtml$6(j.title)}</a>${j.excerpt ? `<p>${escapeHtml$6(j.excerpt)}</p>` : ""}</li>`).join("")}</ul>`;
13313
13510
  }
13314
13511
  //#endregion
13315
- //#region ../client/src/lib/document/publish/highlight.ts
13316
- function escapeHtml$5(text) {
13317
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
13318
- }
13512
+ //#region ../client/src/lib/document/code-languages.ts
13513
+ /**
13514
+ * The grammar for a [[Fenced Code Block]]'s info-string, from the registry the editor nests inside
13515
+ * fences (`@codemirror/language-data`, see `view/augmentations/code-highlight.ts`). Every surface
13516
+ * that highlights code outside an editor - the publisher (`publish/highlight.ts`) and the
13517
+ * read-only quotes (`code-tokens.ts`) - resolves a language here, so each knows the languages
13518
+ * the editor knows, by the same names and aliases.
13519
+ *
13520
+ * The match is exact: a language's name or one of its aliases, in any case. The editor's own
13521
+ * lookup (`@codemirror/lang-markdown`) also matches an alias found inside the info-string, which
13522
+ * reads `text`, `plaintext` and `context` as LaTeX (alias `tex`), so a plain-text block turned
13523
+ * into a LaTeX one, `%` starting a comment. That fuzzy step is not repeated here.
13524
+ */
13319
13525
  var loaded = /* @__PURE__ */ new Map();
13320
- function describe(lang) {
13321
- const byName = languages.find((d) => d.name.toLowerCase() === lang.toLowerCase());
13322
- if (byName) return byName;
13323
- return languages.find((d) => d.alias.some((a) => a.toLowerCase() === lang.toLowerCase())) ?? null;
13324
- }
13325
13526
  /** The grammar for an info-string, loaded once; null for a language the editor does not know either. */
13326
- async function loadLanguage(lang) {
13527
+ async function loadCodeLanguage(lang) {
13327
13528
  const key = lang.toLowerCase();
13328
13529
  let pending = loaded.get(key);
13329
13530
  if (!pending) {
13330
- const description = describe(key);
13531
+ const description = LanguageDescription.matchLanguageName(languages, key, false);
13331
13532
  pending = description ? description.load().catch(() => null) : Promise.resolve(null);
13332
13533
  loaded.set(key, pending);
13333
13534
  }
13334
13535
  return pending;
13335
13536
  }
13537
+ //#endregion
13538
+ //#region ../client/src/lib/document/publish/highlight.ts
13539
+ /**
13540
+ * Code highlighting for the site with the grammars the editor already ships: the same
13541
+ * `@codemirror/language-data` registry `code-highlight.ts` nests inside fences, resolved by
13542
+ * `code-languages.ts`, run headless over the fence's text and emitted as `<span class="tok-…">`
13543
+ * (the `classHighlighter` names), so a site knows exactly the languages the editor knows and needs
13544
+ * no script for it. A theme colours the `tok-*` classes. Unknown languages come back as null and
13545
+ * render escaped.
13546
+ */
13547
+ function escapeHtml$5(text) {
13548
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
13549
+ }
13336
13550
  /** The fence's code as highlighted HTML (the `<code>` element's inner HTML), or null when the language is unknown. */
13337
13551
  async function highlightCode(lang, code) {
13338
13552
  if (lang === "") return null;
13339
- const support = await loadLanguage(lang);
13553
+ const support = await loadCodeLanguage(lang);
13340
13554
  if (!support) return null;
13341
13555
  const tree = support.language.parser.parse(code);
13342
13556
  let out = "";
@@ -15510,7 +15724,7 @@ async function backlinks(graph, concept) {
15510
15724
  kind: ref.kind,
15511
15725
  line: ref.line,
15512
15726
  breadcrumb: ref.breadcrumb,
15513
- text: ref.kind === "block" ? ref.subtree.map((node) => node.label).join("\n") : ref.context?.text ?? ""
15727
+ text: ref.kind === "block" ? ref.subtree.map((node) => node.text).join("\n") : ref.context?.text ?? ""
15514
15728
  }))
15515
15729
  }))
15516
15730
  };
@@ -17363,6 +17577,24 @@ async function isGraphFolder(root) {
17363
17577
  return true;
17364
17578
  }
17365
17579
  //#endregion
17580
+ //#region src/serve-lifetime.ts
17581
+ /** Bind every end to one single-flight shutdown; returns it, for an end the caller finds itself. */
17582
+ function bindServeLifetime(deps) {
17583
+ let ending;
17584
+ const shutdown = (end) => ending ??= deps.shutdown(end);
17585
+ let spoken = false;
17586
+ deps.stdin.once("data", () => {
17587
+ spoken = true;
17588
+ });
17589
+ deps.stdin.once("end", () => {
17590
+ if (spoken) shutdown("the client closed stdin");
17591
+ });
17592
+ deps.signals.on("SIGINT", () => void shutdown("SIGINT"));
17593
+ deps.signals.on("SIGTERM", () => void shutdown("SIGTERM"));
17594
+ deps.transportClosed(() => void shutdown("the transport closed"));
17595
+ return shutdown;
17596
+ }
17597
+ //#endregion
17366
17598
  //#region src/main.ts
17367
17599
  /**
17368
17600
  * `etherpk-mcp`: the [[Headless Client]]'s command line (ADR 0072).
@@ -17830,14 +18062,19 @@ async function serveGraph(graph, graphName, args) {
17830
18062
  cmd: CMD
17831
18063
  });
17832
18064
  const transport = new StdioServerTransport();
17833
- const shutdown = async () => {
17834
- await graph.settle().catch(() => {});
17835
- await graph.dispose().catch(() => {});
17836
- process.exit(0);
17837
- };
17838
- process.on("SIGINT", () => void shutdown());
17839
- process.on("SIGTERM", () => void shutdown());
17840
- transport.onclose = () => void shutdown();
18065
+ bindServeLifetime({
18066
+ signals: process,
18067
+ stdin: process.stdin,
18068
+ transportClosed: (listener) => {
18069
+ transport.onclose = listener;
18070
+ },
18071
+ async shutdown(end) {
18072
+ console.error(`etherpk-mcp: ${end}; flushing and exiting.`);
18073
+ await graph.settle().catch(() => {});
18074
+ await graph.dispose().catch(() => {});
18075
+ process.exit(0);
18076
+ }
18077
+ });
17841
18078
  await server.connect(transport);
17842
18079
  console.error(`etherpk-mcp: serving "${graphName}" over stdio as "Agent on ${hostname()}".`);
17843
18080
  chromiumStatus(process.env, CMD).then((chromium) => {