@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/README.md +188 -138
- package/dist/main.js +402 -165
- package/dist/main.js.map +1 -1
- package/package.json +6 -5
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.
|
|
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
|
-
"
|
|
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
|
|
1736
|
-
* it.
|
|
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
|
|
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 = [
|
|
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 = [
|
|
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
|
-
*
|
|
2001
|
-
*
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2005
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
3099
|
-
const
|
|
3100
|
-
start = Math.max(0,
|
|
3101
|
-
const prefix = start
|
|
3102
|
-
const suffix = end
|
|
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:
|
|
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,
|
|
3921
|
+
return join(dir, CACHE_FILE_NAME);
|
|
3859
3922
|
}
|
|
3860
3923
|
function indexFile(dir) {
|
|
3861
|
-
return join(dir,
|
|
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,
|
|
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
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
]
|
|
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
|
|
4647
|
-
*
|
|
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
|
|
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
|
-
*
|
|
5100
|
-
*
|
|
5101
|
-
*
|
|
5102
|
-
*
|
|
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
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
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
|
|
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.
|
|
6467
|
-
*
|
|
6468
|
-
*
|
|
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
|
|
6661
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
12225
|
-
*
|
|
12226
|
-
*
|
|
12227
|
-
*
|
|
12228
|
-
*
|
|
12229
|
-
*
|
|
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))
|
|
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/
|
|
13316
|
-
|
|
13317
|
-
|
|
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
|
|
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 =
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
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.
|
|
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
|
-
|
|
17834
|
-
|
|
17835
|
-
|
|
17836
|
-
|
|
17837
|
-
|
|
17838
|
-
|
|
17839
|
-
|
|
17840
|
-
|
|
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) => {
|