@appsoftwareltd/etherpk-mcp 0.4.3 → 0.6.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 +133 -97
- package/dist/main.js +1940 -335
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { availableParallelism, homedir, hostname } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { chmod, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
8
|
import { x25519 } from "@noble/curves/ed25519.js";
|
|
8
9
|
import "@noble/hashes/argon2.js";
|
|
9
|
-
import { dirname, join } from "node:path";
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
|
-
import { createWriteStream } from "node:fs";
|
|
12
|
+
import { createWriteStream, statSync, watch } from "node:fs";
|
|
13
13
|
import { Readable } from "node:stream";
|
|
14
14
|
import { pipeline } from "node:stream/promises";
|
|
15
15
|
import { pathToFileURL } from "node:url";
|
|
16
16
|
import { Tokenizer } from "@huggingface/tokenizers";
|
|
17
17
|
import { deserialize, serialize } from "node:v8";
|
|
18
18
|
import { parser } from "@lezer/markdown";
|
|
19
|
-
import "fake-indexeddb/auto";
|
|
20
19
|
import * as Y from "yjs";
|
|
21
20
|
import { z } from "zod";
|
|
22
21
|
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness";
|
|
23
22
|
import * as encoding from "lib0/encoding";
|
|
24
23
|
import { parse, stringify } from "yaml";
|
|
24
|
+
import "fake-indexeddb/auto";
|
|
25
25
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
26
|
var package_default = {
|
|
27
27
|
name: "@appsoftwareltd/etherpk-mcp",
|
|
28
|
-
version: "0.
|
|
28
|
+
version: "0.6.0",
|
|
29
29
|
license: "Elastic-2.0",
|
|
30
30
|
description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
|
|
31
31
|
type: "module",
|
|
@@ -485,6 +485,70 @@ async function openVault(envelope, key) {
|
|
|
485
485
|
};
|
|
486
486
|
throw new EnvelopeError(`unexpected vault envelope kind ${envelope[1] ?? "none"}`);
|
|
487
487
|
}
|
|
488
|
+
var PAD_TO = 64;
|
|
489
|
+
var LENGTH_PREFIX = 2;
|
|
490
|
+
function graphNameAad(graphId) {
|
|
491
|
+
return contextAad("graph-name", `graph:${graphId}`);
|
|
492
|
+
}
|
|
493
|
+
/** Seal a name into an envelope under the keyring's current epoch. */
|
|
494
|
+
async function sealGraphName(keyring, graphId, name) {
|
|
495
|
+
const trimmed = name.trim();
|
|
496
|
+
if (trimmed === "") throw new Error("graph name is empty");
|
|
497
|
+
const utf8 = new TextEncoder().encode(trimmed);
|
|
498
|
+
if (utf8.byteLength > 512) throw new Error(`graph name is too long for the envelope (${utf8.byteLength} bytes)`);
|
|
499
|
+
const padded = new Uint8Array(Math.ceil((LENGTH_PREFIX + utf8.byteLength) / PAD_TO) * PAD_TO);
|
|
500
|
+
new DataView(padded.buffer).setUint16(0, utf8.byteLength, false);
|
|
501
|
+
padded.set(utf8, LENGTH_PREFIX);
|
|
502
|
+
const epoch = currentEpoch(keyring);
|
|
503
|
+
return sealSymmetric({
|
|
504
|
+
key: epoch.key,
|
|
505
|
+
epochId: epoch.epochId,
|
|
506
|
+
plaintext: padded,
|
|
507
|
+
aad: graphNameAad(graphId)
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Read the name from an envelope, or null when this keyring cannot: a missing epoch, another graph's
|
|
512
|
+
* envelope, tampered or malformed bytes. Callers treat null as "no label", never as an error,
|
|
513
|
+
* because the envelope is only ever a convenience.
|
|
514
|
+
*/
|
|
515
|
+
async function openGraphName(keyring, graphId, envelope) {
|
|
516
|
+
try {
|
|
517
|
+
const { plaintext } = await openSymmetric({
|
|
518
|
+
keyForEpoch: (epochId) => keyForEpoch(keyring, epochId),
|
|
519
|
+
envelope,
|
|
520
|
+
aad: graphNameAad(graphId)
|
|
521
|
+
});
|
|
522
|
+
if (plaintext.byteLength < LENGTH_PREFIX) return null;
|
|
523
|
+
const length = new DataView(plaintext.buffer, plaintext.byteOffset).getUint16(0, false);
|
|
524
|
+
if (length === 0 || LENGTH_PREFIX + length > plaintext.byteLength) return null;
|
|
525
|
+
return new TextDecoder().decode(plaintext.subarray(LENGTH_PREFIX, LENGTH_PREFIX + length));
|
|
526
|
+
} catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* The write side of the envelope, for `GraphSyncDeps.publishName`. Sends are chained so two
|
|
532
|
+
* renames in quick succession cannot land on the server out of order, and a failure (offline,
|
|
533
|
+
* an older server without the route) is reported and does not stop the next one.
|
|
534
|
+
*/
|
|
535
|
+
function createGraphNamePublisher(deps) {
|
|
536
|
+
const report = deps.onError ?? ((error) => console.warn("[sync] could not publish the graph name envelope", error));
|
|
537
|
+
let chain = Promise.resolve();
|
|
538
|
+
return {
|
|
539
|
+
publish(name) {
|
|
540
|
+
chain = chain.then(async () => {
|
|
541
|
+
try {
|
|
542
|
+
const envelope = await sealGraphName(deps.keyring, deps.graphId, name);
|
|
543
|
+
await deps.api.setGraphName(deps.graphId, toBase64Url(envelope));
|
|
544
|
+
} catch (err) {
|
|
545
|
+
report(err instanceof Error ? err : new Error(String(err)));
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
},
|
|
549
|
+
settled: () => chain
|
|
550
|
+
};
|
|
551
|
+
}
|
|
488
552
|
//#endregion
|
|
489
553
|
//#region ../client/src/lib/sync/sync-api.ts
|
|
490
554
|
var SyncApiError = class extends Error {
|
|
@@ -520,6 +584,12 @@ function createSyncApi(deps) {
|
|
|
520
584
|
listGraphs: () => call("/api/v1/sync/graphs").then((r) => r.graphs),
|
|
521
585
|
graphsOverview: () => call("/api/v1/sync/graphs"),
|
|
522
586
|
graphStorage: (graphId) => call(`/api/v1/sync/graphs/${graphId}/storage`),
|
|
587
|
+
setGraphName: async (graphId, envelope) => {
|
|
588
|
+
await call(`/api/v1/sync/graphs/${graphId}/name`, {
|
|
589
|
+
method: "PUT",
|
|
590
|
+
body: JSON.stringify({ envelope })
|
|
591
|
+
});
|
|
592
|
+
},
|
|
523
593
|
mintSyncToken: (graphId) => call("/api/v1/sync/token", {
|
|
524
594
|
method: "POST",
|
|
525
595
|
body: JSON.stringify({ graphId })
|
|
@@ -871,7 +941,7 @@ function normalise(vector) {
|
|
|
871
941
|
//#endregion
|
|
872
942
|
//#region ../client/src/lib/document/wikilink/model.ts
|
|
873
943
|
/** The concept for a matched `[[…]]` text: strip exactly the outer `[[` and `]]`. */
|
|
874
|
-
function conceptOf$
|
|
944
|
+
function conceptOf$2(text) {
|
|
875
945
|
return text.slice(2, -2);
|
|
876
946
|
}
|
|
877
947
|
//#endregion
|
|
@@ -902,7 +972,7 @@ function parseWikilinks(input) {
|
|
|
902
972
|
const text = input.slice(start, end + 1);
|
|
903
973
|
links.push({
|
|
904
974
|
text,
|
|
905
|
-
concept: conceptOf$
|
|
975
|
+
concept: conceptOf$2(text),
|
|
906
976
|
start,
|
|
907
977
|
end
|
|
908
978
|
});
|
|
@@ -916,6 +986,68 @@ function parseWikilinks(input) {
|
|
|
916
986
|
return links;
|
|
917
987
|
}
|
|
918
988
|
//#endregion
|
|
989
|
+
//#region ../client/src/lib/document/wikilink/derive.ts
|
|
990
|
+
/**
|
|
991
|
+
* The two derivations from a concept (the link's identity yields neither itself —
|
|
992
|
+
* see ADR 0011). `onDiskName` is a storage convenience; `publishSlug` is the URL
|
|
993
|
+
* name. Both are pure.
|
|
994
|
+
*/
|
|
995
|
+
/** Characters illegal in a file name on common filesystems (the AS Notes set). */
|
|
996
|
+
var INVALID_FILE_CHARS = /[/?<>\\:*|"]/g;
|
|
997
|
+
/**
|
|
998
|
+
* The on-disk file name for a concept (sans extension). Scoped concepts keep their
|
|
999
|
+
* inner `[ ]` brackets; only filesystem-illegal characters are replaced with `_`.
|
|
1000
|
+
* A derived convenience only — frontmatter is authoritative for identity (ADR 0007).
|
|
1001
|
+
*/
|
|
1002
|
+
function onDiskName(concept) {
|
|
1003
|
+
return concept.replace(INVALID_FILE_CHARS, "_");
|
|
1004
|
+
}
|
|
1005
|
+
/** Control characters: illegal or meaningless in a file name, and invisible if kept. */
|
|
1006
|
+
var CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
|
|
1007
|
+
/** Names Windows reserves for devices, with or without an extension (case-insensitive). */
|
|
1008
|
+
var RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
|
|
1009
|
+
/**
|
|
1010
|
+
* Longest stem written, in UTF-8 bytes. Common filesystems cap a name at 255 bytes, and the
|
|
1011
|
+
* mirror may add a ` (10).md` suffix to it, so this leaves room for both.
|
|
1012
|
+
*/
|
|
1013
|
+
var MAX_STEM_BYTES = 200;
|
|
1014
|
+
/** `value` cut to at most `maxBytes` of UTF-8, never splitting a code point. */
|
|
1015
|
+
function truncateToBytes(value, maxBytes) {
|
|
1016
|
+
if (value.length <= maxBytes / 4) return value;
|
|
1017
|
+
const encoder = new TextEncoder();
|
|
1018
|
+
if (encoder.encode(value).length <= maxBytes) return value;
|
|
1019
|
+
let out = "";
|
|
1020
|
+
let bytes = 0;
|
|
1021
|
+
for (const codePoint of value) {
|
|
1022
|
+
const size = encoder.encode(codePoint).length;
|
|
1023
|
+
if (bytes + size > maxBytes) break;
|
|
1024
|
+
out += codePoint;
|
|
1025
|
+
bytes += size;
|
|
1026
|
+
}
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* {@link onDiskName} hardened for a directory that may be carried between operating systems by
|
|
1031
|
+
* the user's own sync tool or version control - which is the [[Local Mirror]]'s whole purpose,
|
|
1032
|
+
* and true of an exported Filesystem Backend folder too.
|
|
1033
|
+
*
|
|
1034
|
+
* Beyond the illegal-character set: control characters go, Windows silently drops trailing dots
|
|
1035
|
+
* and spaces (so a name written here would not be the name read back), its reserved device names
|
|
1036
|
+
* cannot be files at all, and a very long concept would exceed the byte cap every common
|
|
1037
|
+
* filesystem enforces. A concept that empties out, or lands on another concept's stem (`etc` and
|
|
1038
|
+
* `etc.`, `A/B` and `A_B`, two titles that truncate alike), is not a loss: identity is the
|
|
1039
|
+
* frontmatter `title` (ADR 0007, ADR 0061), and the file name is disambiguated by the allocators -
|
|
1040
|
+
* `allocateFileName` in `storage/fs/filesystem-store.ts` for the Filesystem Backend and
|
|
1041
|
+
* `planMirrorNames` in `storage/server/mirror-names.ts` for the Local Mirror - which suffix it
|
|
1042
|
+
* ` (2)`, ` (3)`, ... by the rule in `storage/file-names.ts`. Never write a stem from here to
|
|
1043
|
+
* disk without one of them.
|
|
1044
|
+
*/
|
|
1045
|
+
function portableFileStem(concept) {
|
|
1046
|
+
const trimmed = truncateToBytes(onDiskName(concept).replace(CONTROL_CHARS, "_"), MAX_STEM_BYTES).replace(/[. ]+$/, "").replace(/^\./, "_");
|
|
1047
|
+
if (trimmed === "") return "_";
|
|
1048
|
+
return RESERVED_DEVICE_NAME.test(trimmed) ? `${trimmed}_` : trimmed;
|
|
1049
|
+
}
|
|
1050
|
+
//#endregion
|
|
919
1051
|
//#region ../client/src/lib/document/wikilink/code-ranges.ts
|
|
920
1052
|
/**
|
|
921
1053
|
* Code-region detection for wikilink suppression. Rather than hand-roll a fence
|
|
@@ -3309,6 +3441,21 @@ var CACHE_STORES = [
|
|
|
3309
3441
|
function graphCacheDir(env, serverBaseUrl, graphId) {
|
|
3310
3442
|
return join(env.ETHERPK_MCP_CACHE_DIR?.trim() || join(env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "etherpk", "mcp"), new URL(serverBaseUrl).host.replace(/[^A-Za-z0-9.-]/g, "_"), graphId);
|
|
3311
3443
|
}
|
|
3444
|
+
/**
|
|
3445
|
+
* A local folder's identity for its cache directory and its index: the folder's basename for a
|
|
3446
|
+
* person reading the cache root, plus a hash of the absolute path so two folders of the same
|
|
3447
|
+
* name stay apart. A folder carries no graph id of its own (only settings live in its
|
|
3448
|
+
* `etherpk/`), so the path is the identity, and moving the folder means a re-derive
|
|
3449
|
+
* ([[2026-09-18 Headless Client Serves A Local Folder]]).
|
|
3450
|
+
*/
|
|
3451
|
+
function folderKey(folderPath) {
|
|
3452
|
+
const absolute = resolve(folderPath);
|
|
3453
|
+
return `${basename(absolute).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 40) || "folder"}-${createHash("sha256").update(absolute).digest("hex").slice(0, 12)}`;
|
|
3454
|
+
}
|
|
3455
|
+
/** Where a local folder's index and embedding store live: `local/` is its "host" under the root. */
|
|
3456
|
+
function folderCacheDir(env, folderPath) {
|
|
3457
|
+
return join(cacheRoot(env), "local", folderKey(folderPath));
|
|
3458
|
+
}
|
|
3312
3459
|
/** The root every graph's cache dir sits under, for `logout` to remove. */
|
|
3313
3460
|
function cacheRoot(env) {
|
|
3314
3461
|
return env.ETHERPK_MCP_CACHE_DIR?.trim() || join(env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "etherpk", "mcp");
|
|
@@ -3938,6 +4085,92 @@ async function loadEmbeddingModel(env, options = {}) {
|
|
|
3938
4085
|
};
|
|
3939
4086
|
}
|
|
3940
4087
|
//#endregion
|
|
4088
|
+
//#region src/folder-watch.ts
|
|
4089
|
+
/**
|
|
4090
|
+
* The folder watcher for `serve --folder`: `fs.watch` over the whole graph directory, feeding
|
|
4091
|
+
* the folder backend's reconcile pass so the index follows an edit made in an editor or by the
|
|
4092
|
+
* agent writing markdown directly, without waiting for the next tool call. Best-effort by
|
|
4093
|
+
* design: a filesystem that cannot be watched (some network mounts) is reported once and the
|
|
4094
|
+
* per-call reconcile carries on alone, and the debounce and coalescing live in the backend, so
|
|
4095
|
+
* this is only the event source ([[2026-09-18 Headless Client Serves A Local Folder]]).
|
|
4096
|
+
*/
|
|
4097
|
+
/** A `HeadlessFolderDeps.watch` over `folder`; `onError` hears a watcher that could not start or died. */
|
|
4098
|
+
function watchFolder(folder, onError) {
|
|
4099
|
+
return (trigger) => {
|
|
4100
|
+
let watcher;
|
|
4101
|
+
try {
|
|
4102
|
+
if (!statSync(folder, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`${folder} is not a directory that can be watched`);
|
|
4103
|
+
watcher = watch(folder, { recursive: true }, () => trigger());
|
|
4104
|
+
watcher.on("error", (error) => {
|
|
4105
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
4106
|
+
watcher?.close();
|
|
4107
|
+
watcher = void 0;
|
|
4108
|
+
});
|
|
4109
|
+
} catch (error) {
|
|
4110
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
4111
|
+
return () => {};
|
|
4112
|
+
}
|
|
4113
|
+
return () => {
|
|
4114
|
+
watcher?.close();
|
|
4115
|
+
watcher = void 0;
|
|
4116
|
+
};
|
|
4117
|
+
};
|
|
4118
|
+
}
|
|
4119
|
+
//#endregion
|
|
4120
|
+
//#region src/graph-labels.ts
|
|
4121
|
+
/**
|
|
4122
|
+
* Labels for the `graphs` and `serve` commands, read from the Sync Server's name envelopes
|
|
4123
|
+
* (ADR 0031, amended 2026-09-17) with the keyrings the account vault holds. One list call
|
|
4124
|
+
* labels every graph with an envelope and nothing is opened. A graph with no envelope yet - not
|
|
4125
|
+
* opened by any member since envelopes existed - is read from its root document once through
|
|
4126
|
+
* the caller's `MetaNameReader` (graph-names.ts), which publishes what it finds, so the next
|
|
4127
|
+
* listing needs no connection for it either. "Unnamed" therefore means the root document has
|
|
4128
|
+
* no name at all, or the relay could not be reached to ask.
|
|
4129
|
+
*/
|
|
4130
|
+
var NO_KEY_LABEL = "(no key on this account yet - open it in EtherPK first)";
|
|
4131
|
+
var NO_NAME_LABEL = "(unnamed)";
|
|
4132
|
+
async function graphLabel(record, vault) {
|
|
4133
|
+
const keyring = vault.keyrings.find((entry) => entry.graphId === record.id);
|
|
4134
|
+
if (!keyring) return { kind: "no-key" };
|
|
4135
|
+
if (!record.nameEnvelope) return { kind: "unnamed" };
|
|
4136
|
+
const name = await openGraphName(keyring, record.id, fromBase64Url(record.nameEnvelope));
|
|
4137
|
+
return name ? {
|
|
4138
|
+
kind: "named",
|
|
4139
|
+
name
|
|
4140
|
+
} : { kind: "unnamed" };
|
|
4141
|
+
}
|
|
4142
|
+
/** The envelope first; for a graph without one, one read of the root document (which publishes it). */
|
|
4143
|
+
async function resolveGraphLabel(record, vault, readMeta) {
|
|
4144
|
+
const label = await graphLabel(record, vault);
|
|
4145
|
+
if (label.kind !== "unnamed") return label;
|
|
4146
|
+
const keyring = vault.keyrings.find((entry) => entry.graphId === record.id);
|
|
4147
|
+
if (!keyring) return { kind: "no-key" };
|
|
4148
|
+
const name = await readMeta(record, keyring);
|
|
4149
|
+
return name ? {
|
|
4150
|
+
kind: "named",
|
|
4151
|
+
name
|
|
4152
|
+
} : { kind: "unnamed" };
|
|
4153
|
+
}
|
|
4154
|
+
function describeGraphLabel(label) {
|
|
4155
|
+
switch (label.kind) {
|
|
4156
|
+
case "named": return label.name;
|
|
4157
|
+
case "unnamed": return NO_NAME_LABEL;
|
|
4158
|
+
case "no-key": return NO_KEY_LABEL;
|
|
4159
|
+
}
|
|
4160
|
+
}
|
|
4161
|
+
/** The graph named `wanted`, compared case-insensitively, envelope or root document; null when none. */
|
|
4162
|
+
async function findGraphByName(records, vault, wanted, readMeta) {
|
|
4163
|
+
const target = wanted.trim().toLowerCase();
|
|
4164
|
+
for (const record of records) {
|
|
4165
|
+
const label = await resolveGraphLabel(record, vault, readMeta);
|
|
4166
|
+
if (label.kind === "named" && label.name.toLowerCase() === target) return {
|
|
4167
|
+
record,
|
|
4168
|
+
name: label.name
|
|
4169
|
+
};
|
|
4170
|
+
}
|
|
4171
|
+
return null;
|
|
4172
|
+
}
|
|
4173
|
+
//#endregion
|
|
3941
4174
|
//#region ../client/src/lib/diagnostics/performance.ts
|
|
3942
4175
|
function assertSafeName(name) {
|
|
3943
4176
|
if (!/^[a-z0-9][a-z0-9._-]*$/u.test(name)) throw new Error("Performance metric names may contain only lower-case stable identifiers");
|
|
@@ -5333,6 +5566,34 @@ function createPresenceSession(identity, options = {}) {
|
|
|
5333
5566
|
};
|
|
5334
5567
|
}
|
|
5335
5568
|
//#endregion
|
|
5569
|
+
//#region ../client/src/lib/document/quick-notes.ts
|
|
5570
|
+
/** A hard ceiling, so a corrupt or hostile list cannot swamp the View or the root doc. */
|
|
5571
|
+
var MAX_QUICK_NOTES = 1e4;
|
|
5572
|
+
/**
|
|
5573
|
+
* Keep only well-formed notes, deduped by id (first seen wins) - tolerant of a hand-edited
|
|
5574
|
+
* `quick-notes.json`, an import from a newer client, or a peer's malformed element.
|
|
5575
|
+
*/
|
|
5576
|
+
function sanitizeQuickNotes(raw) {
|
|
5577
|
+
if (!Array.isArray(raw)) return [];
|
|
5578
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5579
|
+
const out = [];
|
|
5580
|
+
for (const entry of raw) {
|
|
5581
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
5582
|
+
const { id, text, createdAt } = entry;
|
|
5583
|
+
if (typeof id !== "string" || id.trim() === "" || seen.has(id)) continue;
|
|
5584
|
+
if (typeof text !== "string" || text.trim() === "") continue;
|
|
5585
|
+
if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) continue;
|
|
5586
|
+
seen.add(id);
|
|
5587
|
+
out.push({
|
|
5588
|
+
id,
|
|
5589
|
+
text: text.trim(),
|
|
5590
|
+
createdAt
|
|
5591
|
+
});
|
|
5592
|
+
if (out.length >= MAX_QUICK_NOTES) break;
|
|
5593
|
+
}
|
|
5594
|
+
return out;
|
|
5595
|
+
}
|
|
5596
|
+
//#endregion
|
|
5336
5597
|
//#region ../client/src/lib/sync/graph-sync.ts
|
|
5337
5598
|
/**
|
|
5338
5599
|
* Graph-level sync (plan Phase 3 Task 5): one WebSocket per open graph, multiplexing all
|
|
@@ -5580,6 +5841,26 @@ function createGraphSync(deps) {
|
|
|
5580
5841
|
const registryMap = root.doc.getMap("registry");
|
|
5581
5842
|
if (deps.onRegistryChange) registryMap.observe(() => deps.onRegistryChange?.());
|
|
5582
5843
|
const metaMap = root.doc.getMap("meta");
|
|
5844
|
+
const quickNotesArray = root.doc.getArray("quickNotes");
|
|
5845
|
+
let publishedName;
|
|
5846
|
+
let rootCaughtUpOnce = false;
|
|
5847
|
+
const publishName = (name) => {
|
|
5848
|
+
if (disposed || !deps.publishName) return;
|
|
5849
|
+
if (typeof name !== "string" || name === "" || name === publishedName) return;
|
|
5850
|
+
publishedName = name;
|
|
5851
|
+
deps.publishName(name);
|
|
5852
|
+
};
|
|
5853
|
+
const publishNameOnceCaughtUp = () => {
|
|
5854
|
+
if (rootCaughtUpOnce) publishName(metaMap.get("name"));
|
|
5855
|
+
};
|
|
5856
|
+
if (deps.publishName) {
|
|
5857
|
+
metaMap.observe(publishNameOnceCaughtUp);
|
|
5858
|
+
root.caughtUp().then(() => {
|
|
5859
|
+
if (disposed) return;
|
|
5860
|
+
rootCaughtUpOnce = true;
|
|
5861
|
+
publishNameOnceCaughtUp();
|
|
5862
|
+
}, () => {});
|
|
5863
|
+
}
|
|
5583
5864
|
function handleMessage(raw) {
|
|
5584
5865
|
const message = parseServerMessage(raw);
|
|
5585
5866
|
if (!message) return;
|
|
@@ -5743,6 +6024,7 @@ function createGraphSync(deps) {
|
|
|
5743
6024
|
},
|
|
5744
6025
|
setMetaName(name) {
|
|
5745
6026
|
metaMap.set("name", name);
|
|
6027
|
+
publishName(name);
|
|
5746
6028
|
},
|
|
5747
6029
|
setMetaSettings(settings) {
|
|
5748
6030
|
metaMap.set("settings", settings);
|
|
@@ -5751,6 +6033,31 @@ function createGraphSync(deps) {
|
|
|
5751
6033
|
metaMap.observe(listener);
|
|
5752
6034
|
return () => metaMap.unobserve(listener);
|
|
5753
6035
|
},
|
|
6036
|
+
quickNotes: () => ({
|
|
6037
|
+
list: () => sanitizeQuickNotes(quickNotesArray.toArray()),
|
|
6038
|
+
add(note) {
|
|
6039
|
+
quickNotesArray.push([{
|
|
6040
|
+
id: note.id,
|
|
6041
|
+
text: note.text,
|
|
6042
|
+
createdAt: note.createdAt
|
|
6043
|
+
}]);
|
|
6044
|
+
},
|
|
6045
|
+
remove(ids) {
|
|
6046
|
+
const gone = new Set(ids);
|
|
6047
|
+
root.doc.transact(() => {
|
|
6048
|
+
const items = quickNotesArray.toArray();
|
|
6049
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
6050
|
+
const item = items[i];
|
|
6051
|
+
const id = typeof item === "object" && item !== null ? item.id : void 0;
|
|
6052
|
+
if (typeof id === "string" && gone.has(id)) quickNotesArray.delete(i, 1);
|
|
6053
|
+
}
|
|
6054
|
+
});
|
|
6055
|
+
},
|
|
6056
|
+
observe(listener) {
|
|
6057
|
+
quickNotesArray.observe(listener);
|
|
6058
|
+
return () => quickNotesArray.unobserve(listener);
|
|
6059
|
+
}
|
|
6060
|
+
}),
|
|
5754
6061
|
connected: () => firstOpen,
|
|
5755
6062
|
rootCaughtUp: () => root.caughtUp(),
|
|
5756
6063
|
onDocUpdate(listener) {
|
|
@@ -6356,7 +6663,7 @@ function nodeTransport(url) {
|
|
|
6356
6663
|
}
|
|
6357
6664
|
//#endregion
|
|
6358
6665
|
//#region src/graph-names.ts
|
|
6359
|
-
/** The
|
|
6666
|
+
/** The name in the root document's meta map, or null when it has none or the relay did not answer in time. */
|
|
6360
6667
|
async function readGraphName(deps) {
|
|
6361
6668
|
const cache = await openGraphCache(`${deps.graphId}-name`);
|
|
6362
6669
|
const sync = createGraphSync({
|
|
@@ -6366,7 +6673,8 @@ async function readGraphName(deps) {
|
|
|
6366
6673
|
relayUrl: deps.relayUrl,
|
|
6367
6674
|
token: deps.token,
|
|
6368
6675
|
cache,
|
|
6369
|
-
connect: deps.connect ?? nodeTransport
|
|
6676
|
+
connect: deps.connect ?? nodeTransport,
|
|
6677
|
+
publishName: deps.publishName
|
|
6370
6678
|
});
|
|
6371
6679
|
try {
|
|
6372
6680
|
const timeout = new Promise((resolve) => setTimeout(() => resolve("timeout"), deps.timeoutMs ?? 1e4));
|
|
@@ -7581,129 +7889,6 @@ function inlineTransport(host = memoryDbHost()) {
|
|
|
7581
7889
|
}
|
|
7582
7890
|
};
|
|
7583
7891
|
}
|
|
7584
|
-
function createSemanticIndex(options) {
|
|
7585
|
-
const { index, model } = options;
|
|
7586
|
-
const floor = options.floor ?? .25;
|
|
7587
|
-
const settleMs = options.settleMs ?? 1500;
|
|
7588
|
-
const pauseMs = options.pauseMs ?? 100;
|
|
7589
|
-
let disposed = false;
|
|
7590
|
-
let running;
|
|
7591
|
-
let again = false;
|
|
7592
|
-
let timer;
|
|
7593
|
-
let unsubscribe;
|
|
7594
|
-
async function runBuild() {
|
|
7595
|
-
for (;;) {
|
|
7596
|
-
if (disposed) return;
|
|
7597
|
-
const pending = await index.semantic.pending(model.id, 128);
|
|
7598
|
-
if (pending.length === 0) break;
|
|
7599
|
-
const sorted = [...pending].sort((a, b) => a.text.length - b.text.length);
|
|
7600
|
-
const rows = [];
|
|
7601
|
-
for (let at = 0; at < sorted.length; at += 32) {
|
|
7602
|
-
const slice = sorted.slice(at, at + 32);
|
|
7603
|
-
const vectors = await model.embed(slice.map((passage) => passage.text));
|
|
7604
|
-
if (disposed) return;
|
|
7605
|
-
slice.forEach((passage, n) => rows.push({
|
|
7606
|
-
hash: passage.hash,
|
|
7607
|
-
...quantise(vectors[n])
|
|
7608
|
-
}));
|
|
7609
|
-
if (pauseMs > 0) await new Promise((resolve) => setTimeout(resolve, pauseMs));
|
|
7610
|
-
}
|
|
7611
|
-
await index.semantic.put(model.id, model.dims, rows);
|
|
7612
|
-
if (options.onProgress) options.onProgress(await index.semantic.status(model.id));
|
|
7613
|
-
}
|
|
7614
|
-
await index.semantic.sweep(model.id);
|
|
7615
|
-
}
|
|
7616
|
-
function build() {
|
|
7617
|
-
if (running) {
|
|
7618
|
-
again = true;
|
|
7619
|
-
return running;
|
|
7620
|
-
}
|
|
7621
|
-
running = runBuild().catch((error) => options.onError?.(error instanceof Error ? error : new Error(String(error)))).finally(() => {
|
|
7622
|
-
running = void 0;
|
|
7623
|
-
if (again && !disposed) {
|
|
7624
|
-
again = false;
|
|
7625
|
-
build();
|
|
7626
|
-
}
|
|
7627
|
-
});
|
|
7628
|
-
return running;
|
|
7629
|
-
}
|
|
7630
|
-
function scheduleBuild() {
|
|
7631
|
-
if (disposed) return;
|
|
7632
|
-
clearTimeout(timer);
|
|
7633
|
-
timer = setTimeout(() => void build(), settleMs);
|
|
7634
|
-
}
|
|
7635
|
-
return {
|
|
7636
|
-
model,
|
|
7637
|
-
status: () => index.semantic.status(model.id),
|
|
7638
|
-
async search(query, offset, limit) {
|
|
7639
|
-
const [vector] = await model.embed([query]);
|
|
7640
|
-
return index.semantic.search(model.id, vector, offset, limit, floor);
|
|
7641
|
-
},
|
|
7642
|
-
build,
|
|
7643
|
-
follow() {
|
|
7644
|
-
if (unsubscribe || disposed) return;
|
|
7645
|
-
unsubscribe = index.onUpdated(scheduleBuild);
|
|
7646
|
-
build();
|
|
7647
|
-
},
|
|
7648
|
-
dispose() {
|
|
7649
|
-
disposed = true;
|
|
7650
|
-
clearTimeout(timer);
|
|
7651
|
-
unsubscribe?.();
|
|
7652
|
-
unsubscribe = void 0;
|
|
7653
|
-
}
|
|
7654
|
-
};
|
|
7655
|
-
}
|
|
7656
|
-
//#endregion
|
|
7657
|
-
//#region ../client/src/lib/document/calendar/month-grid-core.ts
|
|
7658
|
-
/** `YYYY-MM-DD` for a local date — the journal file-name format (fs/identity.ts). */
|
|
7659
|
-
function formatISODate(date) {
|
|
7660
|
-
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
7661
|
-
}
|
|
7662
|
-
/** Today, as the ISO day the user is living in. The app's single definition of "today". */
|
|
7663
|
-
function todayISO(now = /* @__PURE__ */ new Date()) {
|
|
7664
|
-
return formatISODate(now);
|
|
7665
|
-
}
|
|
7666
|
-
var ISO_SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
7667
|
-
/**
|
|
7668
|
-
* The local `Date` a `YYYY-MM-DD` string names, or `null` when it names no day that exists.
|
|
7669
|
-
*
|
|
7670
|
-
* The shape test alone is not enough: `2026-02-30` and `2026-13-01` match it and are not days.
|
|
7671
|
-
* Round-tripping through `Date` catches both, because JS silently rolls an out-of-range field
|
|
7672
|
-
* forward (31 February becomes 3 March) and the re-format then disagrees with the input.
|
|
7673
|
-
*/
|
|
7674
|
-
function parseISODate(value) {
|
|
7675
|
-
const match = ISO_SHAPE.exec(value.trim());
|
|
7676
|
-
if (!match) return null;
|
|
7677
|
-
const [, y, m, d] = match;
|
|
7678
|
-
const date = new Date(Number(y), Number(m) - 1, Number(d));
|
|
7679
|
-
return formatISODate(date) === value.trim() ? date : null;
|
|
7680
|
-
}
|
|
7681
|
-
/** True when `value` names a calendar day that actually exists. */
|
|
7682
|
-
function isCalendarDay(value) {
|
|
7683
|
-
return parseISODate(value) !== null;
|
|
7684
|
-
}
|
|
7685
|
-
//#endregion
|
|
7686
|
-
//#region ../client/src/lib/document/journal-concept.ts
|
|
7687
|
-
/**
|
|
7688
|
-
* What makes a [[Concept]] a [[Journal Concept]] — the one predicate that decides where a
|
|
7689
|
-
* promoting [[Draft]] writes, and which concepts refuse to be renamed (ADR 0056).
|
|
7690
|
-
*
|
|
7691
|
-
* It is deliberately **not** the `\d{4}-\d{2}-\d{2}` shape it grew out of. While the shape only
|
|
7692
|
-
* guarded rename, letting `2026-13-45` through cost nothing; now that the same answer decides
|
|
7693
|
-
* whether a file lands in `journals/` or `pages/`, a loose test would mint a journal entry for a
|
|
7694
|
-
* day no calendar can display or reach again.
|
|
7695
|
-
*
|
|
7696
|
-
* Pure; no DOM, no I/O.
|
|
7697
|
-
*/
|
|
7698
|
-
/**
|
|
7699
|
-
* True when `concept` names a calendar day that exists — a [[Journal Entry]]'s identity.
|
|
7700
|
-
*
|
|
7701
|
-
* Journal identity is the day itself, so this is case- and alias-free: there is nothing to
|
|
7702
|
-
* normalise beyond surrounding whitespace.
|
|
7703
|
-
*/
|
|
7704
|
-
function isJournalConcept(concept) {
|
|
7705
|
-
return isCalendarDay(concept);
|
|
7706
|
-
}
|
|
7707
7892
|
//#endregion
|
|
7708
7893
|
//#region ../client/src/lib/storage/fs/frontmatter.ts
|
|
7709
7894
|
/**
|
|
@@ -7741,10 +7926,30 @@ function parseFrontmatter(text) {
|
|
|
7741
7926
|
}
|
|
7742
7927
|
//#endregion
|
|
7743
7928
|
//#region ../client/src/lib/storage/fs/identity.ts
|
|
7929
|
+
/** The kind a content subdir holds, or `null` for non-document subdirs. */
|
|
7930
|
+
function documentKindOf(subdir) {
|
|
7931
|
+
if (subdir === "journals") return "journal";
|
|
7932
|
+
if (subdir === "pages") return "page";
|
|
7933
|
+
return null;
|
|
7934
|
+
}
|
|
7935
|
+
/** Strip a single trailing `.md` extension. */
|
|
7936
|
+
function fileStem(fileName) {
|
|
7937
|
+
return fileName.replace(/\.md$/i, "");
|
|
7938
|
+
}
|
|
7744
7939
|
/** The case-insensitive identity key for a concept (display casing is preserved elsewhere). */
|
|
7745
7940
|
function conceptKey(concept) {
|
|
7746
7941
|
return concept.toLowerCase();
|
|
7747
7942
|
}
|
|
7943
|
+
/** The display concept name for a page: frontmatter `title` if a non-empty string, else the filename stem. */
|
|
7944
|
+
function conceptOf$1(fm, fileNameStem) {
|
|
7945
|
+
const title = fm.data.title;
|
|
7946
|
+
if (typeof title === "string" && title.trim() !== "") return title;
|
|
7947
|
+
return fileNameStem;
|
|
7948
|
+
}
|
|
7949
|
+
/** The concept (ISO date) of a journal entry, derived from its filename. */
|
|
7950
|
+
function journalConceptOf(fileName) {
|
|
7951
|
+
return fileStem(fileName);
|
|
7952
|
+
}
|
|
7748
7953
|
/** The aliases declared in frontmatter (`aliases:`), as a string array; `[]` when absent/invalid. */
|
|
7749
7954
|
function aliasesOf(fm) {
|
|
7750
7955
|
const aliases = fm.data.aliases;
|
|
@@ -7850,54 +8055,55 @@ function serialise(data) {
|
|
|
7850
8055
|
return Object.keys(data).length === 0 ? "" : stringify(data);
|
|
7851
8056
|
}
|
|
7852
8057
|
//#endregion
|
|
7853
|
-
//#region ../client/src/lib/document/
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
super(`Document "${target}" is sync-degraded: ${status}`);
|
|
7864
|
-
this.target = target;
|
|
7865
|
-
this.status = status;
|
|
7866
|
-
this.name = "DocumentSyncDegradedError";
|
|
7867
|
-
}
|
|
7868
|
-
};
|
|
7869
|
-
//#endregion
|
|
7870
|
-
//#region ../client/src/lib/activity/breathe.ts
|
|
8058
|
+
//#region ../client/src/lib/document/calendar/month-grid-core.ts
|
|
8059
|
+
/** `YYYY-MM-DD` for a local date — the journal file-name format (fs/identity.ts). */
|
|
8060
|
+
function formatISODate(date) {
|
|
8061
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
8062
|
+
}
|
|
8063
|
+
/** Today, as the ISO day the user is living in. The app's single definition of "today". */
|
|
8064
|
+
function todayISO(now = /* @__PURE__ */ new Date()) {
|
|
8065
|
+
return formatISODate(now);
|
|
8066
|
+
}
|
|
8067
|
+
var ISO_SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
7871
8068
|
/**
|
|
7872
|
-
* The
|
|
7873
|
-
*
|
|
7874
|
-
* A long synchronous loop starves the main thread: Svelte cannot repaint, so a status
|
|
7875
|
-
* line jumps from nothing straight to its final value, possibly via the browser's
|
|
7876
|
-
* "page unresponsive" prompt. `breathe` hands control back to the event loop.
|
|
7877
|
-
*
|
|
7878
|
-
* It yields on **elapsed time, not item count**. A graph of one-line journals and a graph
|
|
7879
|
-
* of 5000-line pages want wildly different batch sizes, and the thing we actually care
|
|
7880
|
-
* about is how long a frame has been blocked - so measure that directly.
|
|
8069
|
+
* The local `Date` a `YYYY-MM-DD` string names, or `null` when it names no day that exists.
|
|
7881
8070
|
*
|
|
7882
|
-
* The
|
|
7883
|
-
*
|
|
7884
|
-
|
|
7885
|
-
/** Longest a loop may hold the thread before yielding. ~3 frames: smooth enough to repaint, coarse enough not to dominate. */
|
|
7886
|
-
var SLICE_MS = 50;
|
|
7887
|
-
/**
|
|
7888
|
-
* Create a breather. Call `await breathe(signal)` inside a loop: it returns immediately
|
|
7889
|
-
* while the current slice has budget left, and yields when it does not. It throws the
|
|
7890
|
-
* signal's reason as soon as the signal aborts, whether or not it yields.
|
|
8071
|
+
* The shape test alone is not enough: `2026-02-30` and `2026-13-01` match it and are not days.
|
|
8072
|
+
* Round-tripping through `Date` catches both, because JS silently rolls an out-of-range field
|
|
8073
|
+
* forward (31 February becomes 3 March) and the re-format then disagrees with the input.
|
|
7891
8074
|
*/
|
|
7892
|
-
function
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
8075
|
+
function parseISODate(value) {
|
|
8076
|
+
const match = ISO_SHAPE.exec(value.trim());
|
|
8077
|
+
if (!match) return null;
|
|
8078
|
+
const [, y, m, d] = match;
|
|
8079
|
+
const date = new Date(Number(y), Number(m) - 1, Number(d));
|
|
8080
|
+
return formatISODate(date) === value.trim() ? date : null;
|
|
8081
|
+
}
|
|
8082
|
+
/** True when `value` names a calendar day that actually exists. */
|
|
8083
|
+
function isCalendarDay(value) {
|
|
8084
|
+
return parseISODate(value) !== null;
|
|
8085
|
+
}
|
|
8086
|
+
//#endregion
|
|
8087
|
+
//#region ../client/src/lib/document/journal-concept.ts
|
|
8088
|
+
/**
|
|
8089
|
+
* What makes a [[Concept]] a [[Journal Concept]] — the one predicate that decides where a
|
|
8090
|
+
* promoting [[Draft]] writes, and which concepts refuse to be renamed (ADR 0056).
|
|
8091
|
+
*
|
|
8092
|
+
* It is deliberately **not** the `\d{4}-\d{2}-\d{2}` shape it grew out of. While the shape only
|
|
8093
|
+
* guarded rename, letting `2026-13-45` through cost nothing; now that the same answer decides
|
|
8094
|
+
* whether a file lands in `journals/` or `pages/`, a loose test would mint a journal entry for a
|
|
8095
|
+
* day no calendar can display or reach again.
|
|
8096
|
+
*
|
|
8097
|
+
* Pure; no DOM, no I/O.
|
|
8098
|
+
*/
|
|
8099
|
+
/**
|
|
8100
|
+
* True when `concept` names a calendar day that exists — a [[Journal Entry]]'s identity.
|
|
8101
|
+
*
|
|
8102
|
+
* Journal identity is the day itself, so this is case- and alias-free: there is nothing to
|
|
8103
|
+
* normalise beyond surrounding whitespace.
|
|
8104
|
+
*/
|
|
8105
|
+
function isJournalConcept(concept) {
|
|
8106
|
+
return isCalendarDay(concept);
|
|
7901
8107
|
}
|
|
7902
8108
|
//#endregion
|
|
7903
8109
|
//#region ../client/src/lib/document/wikilink/rename.ts
|
|
@@ -8056,6 +8262,23 @@ function cascadeFor(concepts, from, to) {
|
|
|
8056
8262
|
return out;
|
|
8057
8263
|
}
|
|
8058
8264
|
//#endregion
|
|
8265
|
+
//#region ../client/src/lib/document/types.ts
|
|
8266
|
+
var DocumentNotFoundError = class extends Error {
|
|
8267
|
+
constructor(target) {
|
|
8268
|
+
super(`No document for "${target}"`);
|
|
8269
|
+
this.target = target;
|
|
8270
|
+
this.name = "DocumentNotFoundError";
|
|
8271
|
+
}
|
|
8272
|
+
};
|
|
8273
|
+
var DocumentSyncDegradedError = class extends Error {
|
|
8274
|
+
constructor(target, status) {
|
|
8275
|
+
super(`Document "${target}" is sync-degraded: ${status}`);
|
|
8276
|
+
this.target = target;
|
|
8277
|
+
this.status = status;
|
|
8278
|
+
this.name = "DocumentSyncDegradedError";
|
|
8279
|
+
}
|
|
8280
|
+
};
|
|
8281
|
+
//#endregion
|
|
8059
8282
|
//#region ../client/src/lib/document/protection/cipher-fence.ts
|
|
8060
8283
|
/**
|
|
8061
8284
|
* The `etherpk-cipher` fence as it appears in document *text* — finding one, reading what it holds
|
|
@@ -8138,6 +8361,32 @@ function lastNonBlank(lines) {
|
|
|
8138
8361
|
return -1;
|
|
8139
8362
|
}
|
|
8140
8363
|
//#endregion
|
|
8364
|
+
//#region ../client/src/lib/storage/file-names.ts
|
|
8365
|
+
/**
|
|
8366
|
+
* How a document is named on disk, shared by every backend that writes a folder: the
|
|
8367
|
+
* [[Filesystem Backend]] (`fs/filesystem-store.ts`, `allocateFileName`) and the [[Local Mirror]]
|
|
8368
|
+
* (`server/mirror-names.ts`, `planMirrorNames`). One rule, the one the Logseq and Obsidian
|
|
8369
|
+
* converters already follow: **suffix the file name, keep the title**.
|
|
8370
|
+
*
|
|
8371
|
+
* A document's file is `<portable stem of its concept>.md`. The stem is lossy - `etc` and `etc.`,
|
|
8372
|
+
* `A/B` and `A_B`, `CON` and `CON_` are distinct concepts on one stem - so a second document
|
|
8373
|
+
* wanting a name already taken gets ` (2)`, ` (3)` and so on. The title inside is never touched:
|
|
8374
|
+
* identity is the frontmatter `title` (ADR 0007, ADR 0061), so a suffixed file invents no concept,
|
|
8375
|
+
* which is what keeps this clear of ADR 0038's refusal of `(2)` suffixes for *concept* collisions.
|
|
8376
|
+
* A folder either backend wrote is read back the same way by the other, and by [[Import]].
|
|
8377
|
+
*
|
|
8378
|
+
* Pure: strings only.
|
|
8379
|
+
*/
|
|
8380
|
+
/** The unsuffixed file name for a concept: its portable stem plus the markdown extension. */
|
|
8381
|
+
function portableFileName(concept) {
|
|
8382
|
+
return `${portableFileStem(concept)}.md`;
|
|
8383
|
+
}
|
|
8384
|
+
/** `Foo.md` at index 1, `Foo (2).md` at 2, and so on. */
|
|
8385
|
+
function suffixedFileName(base, index) {
|
|
8386
|
+
if (index <= 1) return base;
|
|
8387
|
+
return `${fileStem(base)} (${index}).md`;
|
|
8388
|
+
}
|
|
8389
|
+
//#endregion
|
|
8141
8390
|
//#region ../client/src/lib/storage/merge.ts
|
|
8142
8391
|
/**
|
|
8143
8392
|
* [[Merge]] (CONTEXT.md): combining two [[Document]]s that have come to share one
|
|
@@ -8269,58 +8518,910 @@ function planRename(input) {
|
|
|
8269
8518
|
* used to hold only ciphertext. Refused, not worked around: there is no join that keeps both
|
|
8270
8519
|
* documents' guarantees.
|
|
8271
8520
|
*
|
|
8272
|
-
* Both stores call this after planning, with their own way of reading a concept's stored text.
|
|
8273
|
-
* Only the endpoints of merging steps are asked about, so a plan with no collision - the common
|
|
8274
|
-
* case, and the one previewed on every keystroke in the dialog - reads nothing.
|
|
8521
|
+
* Both stores call this after planning, with their own way of reading a concept's stored text.
|
|
8522
|
+
* Only the endpoints of merging steps are asked about, so a plan with no collision - the common
|
|
8523
|
+
* case, and the one previewed on every keystroke in the dialog - reads nothing.
|
|
8524
|
+
*/
|
|
8525
|
+
async function refuseProtectedMerges(plan, isProtected) {
|
|
8526
|
+
if (plan.refusal) return plan;
|
|
8527
|
+
for (const step of renameSteps(plan)) {
|
|
8528
|
+
if (!step.merges) continue;
|
|
8529
|
+
if (await isProtected(step.into)) return {
|
|
8530
|
+
...plan,
|
|
8531
|
+
refusal: `“${step.into}” is a protected document, so nothing can be merged into it. Choose a different name.`
|
|
8532
|
+
};
|
|
8533
|
+
if (await isProtected(step.from)) return {
|
|
8534
|
+
...plan,
|
|
8535
|
+
refusal: `“${step.from}” is a protected document, so it cannot be merged into “${step.to}”. Choose a name that is not already taken.`
|
|
8536
|
+
};
|
|
8537
|
+
}
|
|
8538
|
+
return plan;
|
|
8539
|
+
}
|
|
8540
|
+
/**
|
|
8541
|
+
* A step merges when a DIFFERENT document already answers to the target name - by title or by
|
|
8542
|
+
* alias - and the source has a document to join to it. Renaming onto yourself (a pure
|
|
8543
|
+
* re-casing, or onto one of your own aliases) is neither. A source with no document landing
|
|
8544
|
+
* on a taken name redirects: its links come to point at the page that already answers to it
|
|
8545
|
+
* (ADR 0064 §2). `into` is that page's own title.
|
|
8546
|
+
*/
|
|
8547
|
+
function stepFor(from, to, hasDocument, existing) {
|
|
8548
|
+
const holder = existing.get(conceptKey(to));
|
|
8549
|
+
const taken = holder !== void 0 && conceptKey(holder) !== conceptKey(from);
|
|
8550
|
+
return {
|
|
8551
|
+
from,
|
|
8552
|
+
to,
|
|
8553
|
+
hasDocument,
|
|
8554
|
+
merges: taken && hasDocument,
|
|
8555
|
+
redirects: taken && !hasDocument,
|
|
8556
|
+
into: taken ? holder : to
|
|
8557
|
+
};
|
|
8558
|
+
}
|
|
8559
|
+
/** Nesting depth of a concept - `[[[[A]] B]] C` is deeper than `[[A]] B`. */
|
|
8560
|
+
function depthOf(concept) {
|
|
8561
|
+
let depth = 0;
|
|
8562
|
+
let max = 0;
|
|
8563
|
+
for (let i = 0; i < concept.length - 1; i++) if (concept[i] === "[" && concept[i + 1] === "[") {
|
|
8564
|
+
depth += 1;
|
|
8565
|
+
max = Math.max(max, depth);
|
|
8566
|
+
i += 1;
|
|
8567
|
+
} else if (concept[i] === "]" && concept[i + 1] === "]") {
|
|
8568
|
+
depth -= 1;
|
|
8569
|
+
i += 1;
|
|
8570
|
+
}
|
|
8571
|
+
return max;
|
|
8572
|
+
}
|
|
8573
|
+
//#endregion
|
|
8574
|
+
//#region ../client/src/lib/storage/fs/debounce.ts
|
|
8575
|
+
function debounce(fn, ms) {
|
|
8576
|
+
let timer;
|
|
8577
|
+
let pending;
|
|
8578
|
+
function clear() {
|
|
8579
|
+
if (timer !== void 0) {
|
|
8580
|
+
clearTimeout(timer);
|
|
8581
|
+
timer = void 0;
|
|
8582
|
+
}
|
|
8583
|
+
pending = void 0;
|
|
8584
|
+
}
|
|
8585
|
+
return {
|
|
8586
|
+
call(...args) {
|
|
8587
|
+
pending = args;
|
|
8588
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
8589
|
+
timer = setTimeout(() => {
|
|
8590
|
+
const args = pending;
|
|
8591
|
+
clear();
|
|
8592
|
+
fn(...args);
|
|
8593
|
+
}, ms);
|
|
8594
|
+
},
|
|
8595
|
+
flush() {
|
|
8596
|
+
if (pending === void 0) return;
|
|
8597
|
+
const args = pending;
|
|
8598
|
+
clear();
|
|
8599
|
+
fn(...args);
|
|
8600
|
+
},
|
|
8601
|
+
cancel() {
|
|
8602
|
+
clear();
|
|
8603
|
+
}
|
|
8604
|
+
};
|
|
8605
|
+
}
|
|
8606
|
+
//#endregion
|
|
8607
|
+
//#region ../client/src/lib/storage/fs/reconcile.ts
|
|
8608
|
+
function reconcileDecision({ dirty, baseText, diskText }) {
|
|
8609
|
+
if (diskText === baseText) return "noop";
|
|
8610
|
+
if (!dirty) return "reload";
|
|
8611
|
+
return "conflict";
|
|
8612
|
+
}
|
|
8613
|
+
//#endregion
|
|
8614
|
+
//#region ../client/src/lib/storage/fs/scan.ts
|
|
8615
|
+
var SCANNED_SUBDIRS = ["journals", "pages"];
|
|
8616
|
+
function isMarkdown(name) {
|
|
8617
|
+
return /\.md$/i.test(name);
|
|
8618
|
+
}
|
|
8619
|
+
async function scanGraph(adapter) {
|
|
8620
|
+
const journals = [];
|
|
8621
|
+
const pages = [];
|
|
8622
|
+
for (const subdir of SCANNED_SUBDIRS) {
|
|
8623
|
+
const kind = documentKindOf(subdir);
|
|
8624
|
+
if (!kind) continue;
|
|
8625
|
+
for (const { name, lastModified, size } of await adapter.list(subdir)) {
|
|
8626
|
+
if (!isMarkdown(name)) continue;
|
|
8627
|
+
const { text } = await adapter.read(subdir, name);
|
|
8628
|
+
const fm = parseFrontmatter(text);
|
|
8629
|
+
const concept = kind === "journal" ? journalConceptOf(name) : conceptOf$1(fm, fileStem(name));
|
|
8630
|
+
const entry = {
|
|
8631
|
+
kind,
|
|
8632
|
+
concept,
|
|
8633
|
+
key: conceptKey(concept),
|
|
8634
|
+
subdir,
|
|
8635
|
+
fileName: name,
|
|
8636
|
+
aliases: aliasesOf(fm),
|
|
8637
|
+
lastModified,
|
|
8638
|
+
size
|
|
8639
|
+
};
|
|
8640
|
+
(kind === "journal" ? journals : pages).push(entry);
|
|
8641
|
+
}
|
|
8642
|
+
}
|
|
8643
|
+
journals.sort((a, b) => b.concept.localeCompare(a.concept));
|
|
8644
|
+
pages.sort((a, b) => a.key.localeCompare(b.key));
|
|
8645
|
+
return [...journals, ...pages];
|
|
8646
|
+
}
|
|
8647
|
+
//#endregion
|
|
8648
|
+
//#region ../client/src/lib/storage/fs/filesystem-store.ts
|
|
8649
|
+
/**
|
|
8650
|
+
* A {@link DocumentStore} over a real directory (via a {@link DirectoryAdapter}):
|
|
8651
|
+
* the Filesystem Backend. The files *are* the state — this holds only a live
|
|
8652
|
+
* buffer per open document plus a derived registry, and reconciles external
|
|
8653
|
+
* changes (git checkout, Syncthing) the way DESIGN.md → The git workflow requires.
|
|
8654
|
+
*
|
|
8655
|
+
* Assembled entirely from the pure pieces (scan, identity, reconcileDecision,
|
|
8656
|
+
* debounce) so it is exercised in Node over createMemoryDirectoryAdapter before
|
|
8657
|
+
* any browser code exists.
|
|
8658
|
+
*
|
|
8659
|
+
* Async-seam note (see docs/docs/technical/Document Editor.md): the seam's
|
|
8660
|
+
* `getText()` is synchronous but disk reads are async, so `open()` returns a
|
|
8661
|
+
* handle whose buffer is empty on first open and is hydrated by an internal
|
|
8662
|
+
* awaited read that then notifies subscribers the *external* way — which is safe
|
|
8663
|
+
* because DocumentView registers its `subscribe` listener in the same synchronous
|
|
8664
|
+
* onMount tick as its `getText()` seed, before the read resolves.
|
|
8665
|
+
*/
|
|
8666
|
+
function applyTextChange(text, change) {
|
|
8667
|
+
return text.slice(0, change.from) + change.insert + text.slice(change.to);
|
|
8668
|
+
}
|
|
8669
|
+
/** A signature of the registry's membership + (mtime, size) stamps, to fire change events only on real change. */
|
|
8670
|
+
function registrySignature(entries) {
|
|
8671
|
+
return entries.map((e) => `${e.key}@${e.lastModified}:${e.size}`).join("|");
|
|
8672
|
+
}
|
|
8673
|
+
function createFilesystemDocumentStore(adapter, options = {}) {
|
|
8674
|
+
const { autosaveMs = 400, onConflict } = options;
|
|
8675
|
+
const registry = /* @__PURE__ */ new Map();
|
|
8676
|
+
let registrySig = "";
|
|
8677
|
+
const open = /* @__PURE__ */ new Map();
|
|
8678
|
+
const documentsChanged = /* @__PURE__ */ new Set();
|
|
8679
|
+
const documentRemoved = /* @__PURE__ */ new Set();
|
|
8680
|
+
const documentRenamed = /* @__PURE__ */ new Set();
|
|
8681
|
+
const changed = /* @__PURE__ */ new Set();
|
|
8682
|
+
function snapshotEntries() {
|
|
8683
|
+
return [...registry.values()];
|
|
8684
|
+
}
|
|
8685
|
+
/** Named when this store knows which document moved; unnamed means re-verify everything. */
|
|
8686
|
+
function emitChange(change) {
|
|
8687
|
+
for (const listener of changed) listener(change);
|
|
8688
|
+
}
|
|
8689
|
+
/** The concept an open document answers to, as the registry spells it. */
|
|
8690
|
+
function conceptOf(doc) {
|
|
8691
|
+
return registry.get(doc.key)?.concept ?? doc.target;
|
|
8692
|
+
}
|
|
8693
|
+
function emitDocumentsChanged() {
|
|
8694
|
+
for (const listener of documentsChanged) listener();
|
|
8695
|
+
emitChange();
|
|
8696
|
+
}
|
|
8697
|
+
function emitDocumentRemoved(target) {
|
|
8698
|
+
for (const listener of documentRemoved) listener(target);
|
|
8699
|
+
}
|
|
8700
|
+
/**
|
|
8701
|
+
* The registry listing changed but no content did - aliases patched from a save. The
|
|
8702
|
+
* listing's consumers are told; the [[Derived Index]] is not asked to re-verify the graph,
|
|
8703
|
+
* because the named change the save emits already covers the one document that moved.
|
|
8704
|
+
*/
|
|
8705
|
+
function emitRegistryOnly() {
|
|
8706
|
+
for (const listener of documentsChanged) listener();
|
|
8707
|
+
}
|
|
8708
|
+
/**
|
|
8709
|
+
* The buffer as it should rest on disk (ADR 0061): the REGISTRY's title, whatever the buffer
|
|
8710
|
+
* says, and the buffer's everything else. A title typed into the block is a proposal until the
|
|
8711
|
+
* rename dialog confirms it, and a file that already said the new name would be re-keyed by
|
|
8712
|
+
* the next rescan underneath the open document - which then found no entry for its old key
|
|
8713
|
+
* and was declared removed. Aliases are the file's to say, so they go as typed. A block is
|
|
8714
|
+
* only put back when the file name alone would not name the document.
|
|
8715
|
+
*/
|
|
8716
|
+
function proposedToStored(doc, buffer) {
|
|
8717
|
+
const entry = registry.get(doc.key);
|
|
8718
|
+
if (!entry || entry.kind !== "page") return buffer;
|
|
8719
|
+
return withFrontmatterIdentity(buffer, { title: entry.concept }, { addBlock: fileStem(entry.fileName) !== entry.concept });
|
|
8720
|
+
}
|
|
8721
|
+
/** Aliases are the file's to say: keep the entry in step without waiting for the next scan. */
|
|
8722
|
+
function adoptAliases(entry, text) {
|
|
8723
|
+
const aliases = aliasesOf(parseFrontmatter(text));
|
|
8724
|
+
if (sameAliases(aliases, entry.aliases)) return;
|
|
8725
|
+
entry.aliases = aliases;
|
|
8726
|
+
emitRegistryOnly();
|
|
8727
|
+
}
|
|
8728
|
+
/**
|
|
8729
|
+
* A file whose `title` was edited outside the app is a rename that already happened
|
|
8730
|
+
* (ADR 0061). The rescan has re-keyed it; the open document is re-keyed to match and its
|
|
8731
|
+
* consumers told, so the tab follows the file - instead of the document being declared
|
|
8732
|
+
* removed, and then resurrected over the external edit by its next keystroke.
|
|
8733
|
+
*/
|
|
8734
|
+
function followFile(doc) {
|
|
8735
|
+
const entry = [...registry.values()].find((e) => e.subdir === doc.subdir && e.fileName === doc.fileName);
|
|
8736
|
+
if (!entry) return;
|
|
8737
|
+
const from = doc.target;
|
|
8738
|
+
open.delete(doc.key);
|
|
8739
|
+
doc.key = entry.key;
|
|
8740
|
+
doc.target = entry.concept;
|
|
8741
|
+
open.set(doc.key, doc);
|
|
8742
|
+
for (const listener of documentRenamed) listener(from, entry.concept);
|
|
8743
|
+
}
|
|
8744
|
+
/** Replace the registry from a fresh scan; fire onDocumentsChanged iff it changed. */
|
|
8745
|
+
async function refreshRegistry() {
|
|
8746
|
+
const entries = await scanGraph(adapter);
|
|
8747
|
+
registry.clear();
|
|
8748
|
+
for (const entry of entries) registry.set(entry.key, entry);
|
|
8749
|
+
const sig = registrySignature(entries);
|
|
8750
|
+
if (sig !== registrySig) {
|
|
8751
|
+
registrySig = sig;
|
|
8752
|
+
emitDocumentsChanged();
|
|
8753
|
+
}
|
|
8754
|
+
return entries;
|
|
8755
|
+
}
|
|
8756
|
+
function notify(doc, text) {
|
|
8757
|
+
for (const listener of doc.listeners) listener(text);
|
|
8758
|
+
}
|
|
8759
|
+
/** The frontmatter block of `text`, given its parsed body (`''` when there is none). */
|
|
8760
|
+
function headOf(text, body) {
|
|
8761
|
+
return body === text ? "" : text.slice(0, text.length - body.length);
|
|
8762
|
+
}
|
|
8763
|
+
/** Documents whose BODY references `concept` at the top level. */
|
|
8764
|
+
async function countReferencing(concept) {
|
|
8765
|
+
let total = 0;
|
|
8766
|
+
for (const entry of registry.values()) {
|
|
8767
|
+
const { text } = await adapter.read(entry.subdir, entry.fileName);
|
|
8768
|
+
if (countWikilinkTargets(parseFrontmatter(text).body, concept) > 0) total += 1;
|
|
8769
|
+
}
|
|
8770
|
+
return total;
|
|
8771
|
+
}
|
|
8772
|
+
/**
|
|
8773
|
+
* The file a document is written to under `concept` - this backend's half of the naming
|
|
8774
|
+
* rule in `storage/file-names.ts`. The portable stem is lossy (`etc` and `etc.`, `A/B` and
|
|
8775
|
+
* `A_B`, two titles that truncate alike), so the bare name may already be another
|
|
8776
|
+
* document's: the file took whichever was listed last and the other's bytes were gone. The
|
|
8777
|
+
* first free candidate of `Title.md`, `Title (2).md`, ... is taken instead, the title
|
|
8778
|
+
* untouched, so no concept is invented.
|
|
8779
|
+
*
|
|
8780
|
+
* Taken means held by another registry entry in the subdir, compared case-insensitively
|
|
8781
|
+
* because a Windows or macOS folder would, or present on disk without being scanned yet (a
|
|
8782
|
+
* file added behind the store's back). `own` is the document being renamed: its current file
|
|
8783
|
+
* is not in its own way, and a candidate that IS that file in another case keeps the file
|
|
8784
|
+
* as it is - on NTFS and APFS `foo.md` and `Foo.md` are one file, so writing the new casing
|
|
8785
|
+
* and then removing the old would delete the document. A single user's directory, so the
|
|
8786
|
+
* registry plus `exists` is enough; nothing coordinates with a concurrent creator.
|
|
8787
|
+
*/
|
|
8788
|
+
async function allocateFileName(subdir, concept, own) {
|
|
8789
|
+
const base = portableFileName(concept);
|
|
8790
|
+
const taken = /* @__PURE__ */ new Set();
|
|
8791
|
+
for (const entry of registry.values()) if (entry.subdir === subdir && entry.key !== own?.key) taken.add(entry.fileName.toLowerCase());
|
|
8792
|
+
for (let index = 1;; index++) {
|
|
8793
|
+
const candidate = suffixedFileName(base, index);
|
|
8794
|
+
const lower = candidate.toLowerCase();
|
|
8795
|
+
if (own && lower === own.fileName.toLowerCase()) return own.fileName;
|
|
8796
|
+
if (taken.has(lower)) continue;
|
|
8797
|
+
if (await adapter.exists(subdir, candidate)) continue;
|
|
8798
|
+
return candidate;
|
|
8799
|
+
}
|
|
8800
|
+
}
|
|
8801
|
+
/**
|
|
8802
|
+
* One step of a rename plan: retitle the document, and if the target name is already
|
|
8803
|
+
* taken, [[Merge]] into it instead.
|
|
8804
|
+
*
|
|
8805
|
+
* The document's own file is written before the old one is removed - a crash between the
|
|
8806
|
+
* two leaves a duplicate, which is recoverable, where the reverse order loses the
|
|
8807
|
+
* document.
|
|
8808
|
+
*/
|
|
8809
|
+
async function applyStep(step, strategy) {
|
|
8810
|
+
const entry = registry.get(conceptKey(step.from));
|
|
8811
|
+
if (!entry) return;
|
|
8812
|
+
if (conceptKey(step.from) === conceptKey(step.to) && step.from === step.to) return;
|
|
8813
|
+
const openDoc = open.get(entry.key);
|
|
8814
|
+
if (openDoc) {
|
|
8815
|
+
openDoc.save.cancel();
|
|
8816
|
+
await settled(openDoc);
|
|
8817
|
+
}
|
|
8818
|
+
const { text } = await adapter.read(entry.subdir, entry.fileName);
|
|
8819
|
+
const fm = parseFrontmatter(text);
|
|
8820
|
+
let aliases = aliasesOf(fm);
|
|
8821
|
+
let body = fm.body;
|
|
8822
|
+
let data = { ...fm.data };
|
|
8823
|
+
if (strategy === "alias") {
|
|
8824
|
+
if (!aliases.some((a) => conceptKey(a) === conceptKey(step.from))) aliases = [...aliases, step.from];
|
|
8825
|
+
}
|
|
8826
|
+
const targetEntry = step.merges ? registry.get(conceptKey(step.into)) : void 0;
|
|
8827
|
+
const survivor = targetEntry && targetEntry.key !== entry.key ? targetEntry : void 0;
|
|
8828
|
+
if (survivor) {
|
|
8829
|
+
const existingFm = parseFrontmatter((await adapter.read(survivor.subdir, survivor.fileName)).text);
|
|
8830
|
+
const merged = mergeDocuments({
|
|
8831
|
+
body: existingFm.body,
|
|
8832
|
+
aliases: aliasesOf(existingFm)
|
|
8833
|
+
}, {
|
|
8834
|
+
body,
|
|
8835
|
+
aliases
|
|
8836
|
+
});
|
|
8837
|
+
body = merged.body;
|
|
8838
|
+
aliases = merged.aliases;
|
|
8839
|
+
data = { ...existingFm.data };
|
|
8840
|
+
}
|
|
8841
|
+
aliases = normaliseAliases(aliases, step.into);
|
|
8842
|
+
data.title = step.into;
|
|
8843
|
+
if (aliases.length > 0) data.aliases = aliases;
|
|
8844
|
+
else delete data.aliases;
|
|
8845
|
+
const subdir = survivor?.subdir ?? entry.subdir;
|
|
8846
|
+
const fileName = survivor?.fileName ?? await allocateFileName(subdir, step.into, entry);
|
|
8847
|
+
const written = await adapter.write(subdir, fileName, `---\n${stringify(data)}---\n${body}`);
|
|
8848
|
+
if (!(subdir === entry.subdir && fileName === entry.fileName)) await adapter.remove(entry.subdir, entry.fileName);
|
|
8849
|
+
registry.delete(entry.key);
|
|
8850
|
+
if (survivor) registry.delete(survivor.key);
|
|
8851
|
+
registry.set(conceptKey(step.into), {
|
|
8852
|
+
kind: survivor?.kind ?? entry.kind,
|
|
8853
|
+
concept: step.into,
|
|
8854
|
+
key: conceptKey(step.into),
|
|
8855
|
+
subdir,
|
|
8856
|
+
fileName,
|
|
8857
|
+
aliases,
|
|
8858
|
+
lastModified: written.lastModified,
|
|
8859
|
+
size: written.size
|
|
8860
|
+
});
|
|
8861
|
+
if (openDoc) open.delete(entry.key);
|
|
8862
|
+
}
|
|
8863
|
+
/**
|
|
8864
|
+
* Start the document's save if none is in flight, else note that one is wanted. One writable
|
|
8865
|
+
* per file at a time: each `createWritable()` gets its own swap file, made visible at
|
|
8866
|
+
* `close()`, and nothing orders two of them - so of two overlapping writes the older text
|
|
8867
|
+
* could land last, and the first to settle nulled the pointer while the other was still
|
|
8868
|
+
* open, which let a reconcile pass read the app's own write as an external edit and raise a
|
|
8869
|
+
* conflict. A write wanted mid-flight runs when the current one settles, with the buffer as
|
|
8870
|
+
* it is then, so a burst of keystrokes costs two writes at most.
|
|
8871
|
+
*
|
|
8872
|
+
* The chain never rejects. An autosave has no caller to reject to, so a failed write (a full
|
|
8873
|
+
* disk, a lapsed folder permission, a file locked by another program) was an unhandled
|
|
8874
|
+
* rejection with `dirty` left true, the debounce spent and nothing to retry it. Now the
|
|
8875
|
+
* failure is recorded and reported (`onSaveError`), the buffer stays dirty - dirty work is
|
|
8876
|
+
* the source of truth - and `flushDocument`, the next keystroke's autosave and `dispose`
|
|
8877
|
+
* retry it. Never a timer: a full disk should not be hammered.
|
|
8878
|
+
*/
|
|
8879
|
+
function runSave(doc) {
|
|
8880
|
+
if (doc.conflict || doc.removed) {
|
|
8881
|
+
doc.queued = false;
|
|
8882
|
+
return;
|
|
8883
|
+
}
|
|
8884
|
+
if (!doc.loaded) {
|
|
8885
|
+
doc.queued = false;
|
|
8886
|
+
if (doc.loadError !== null) options.onSaveError?.(conceptOf(doc), doc.loadError);
|
|
8887
|
+
return;
|
|
8888
|
+
}
|
|
8889
|
+
if (doc.saving) {
|
|
8890
|
+
doc.queued = true;
|
|
8891
|
+
return;
|
|
8892
|
+
}
|
|
8893
|
+
doc.queued = false;
|
|
8894
|
+
const snapshot = doc.buffer;
|
|
8895
|
+
const written = proposedToStored(doc, snapshot);
|
|
8896
|
+
doc.saving = adapter.write(doc.subdir, doc.fileName, written).then((res) => {
|
|
8897
|
+
doc.saveError = null;
|
|
8898
|
+
doc.baseText = written;
|
|
8899
|
+
doc.lastModified = res.lastModified;
|
|
8900
|
+
doc.size = res.size;
|
|
8901
|
+
doc.dirty = doc.buffer !== snapshot;
|
|
8902
|
+
const entry = registry.get(doc.key);
|
|
8903
|
+
if (entry) {
|
|
8904
|
+
entry.lastModified = res.lastModified;
|
|
8905
|
+
entry.size = res.size;
|
|
8906
|
+
adoptAliases(entry, written);
|
|
8907
|
+
} else refreshRegistry();
|
|
8908
|
+
emitChange({ concept: conceptOf(doc) });
|
|
8909
|
+
}, (error) => {
|
|
8910
|
+
doc.saveError = error;
|
|
8911
|
+
options.onSaveError?.(conceptOf(doc), error);
|
|
8912
|
+
}).finally(() => {
|
|
8913
|
+
doc.saving = null;
|
|
8914
|
+
if (doc.queued && doc.dirty) runSave(doc);
|
|
8915
|
+
});
|
|
8916
|
+
}
|
|
8917
|
+
/** Resolves once no write is in flight for `doc`, including any that were queued behind it. */
|
|
8918
|
+
async function settled(doc) {
|
|
8919
|
+
while (doc.saving) await doc.saving;
|
|
8920
|
+
}
|
|
8921
|
+
/**
|
|
8922
|
+
* Write the buffer now if it is dirty, and wait for every write in flight. The pending
|
|
8923
|
+
* debounce is superseded rather than flushed: `dirty` is what says there is work, and it
|
|
8924
|
+
* outlives a spent debounce - which is what lets a failed autosave be retried here. A
|
|
8925
|
+
* document that could not be read at open is read again first: a retry is the user asking,
|
|
8926
|
+
* and reconcile either reloads the buffer or, if something was typed, raises a conflict.
|
|
8927
|
+
*/
|
|
8928
|
+
async function saveNow(doc) {
|
|
8929
|
+
doc.save.cancel();
|
|
8930
|
+
if (!doc.loaded && doc.loadError !== null) try {
|
|
8931
|
+
await reconcileDoc(doc);
|
|
8932
|
+
} catch (error) {
|
|
8933
|
+
doc.loadError = error;
|
|
8934
|
+
}
|
|
8935
|
+
if (doc.dirty) runSave(doc);
|
|
8936
|
+
await settled(doc);
|
|
8937
|
+
}
|
|
8938
|
+
function makeOpenDoc(target, entry) {
|
|
8939
|
+
const doc = {
|
|
8940
|
+
key: entry.key,
|
|
8941
|
+
target,
|
|
8942
|
+
subdir: entry.subdir,
|
|
8943
|
+
fileName: entry.fileName,
|
|
8944
|
+
buffer: "",
|
|
8945
|
+
baseText: "",
|
|
8946
|
+
lastModified: 0,
|
|
8947
|
+
size: 0,
|
|
8948
|
+
dirty: false,
|
|
8949
|
+
loaded: false,
|
|
8950
|
+
loadError: null,
|
|
8951
|
+
ready: Promise.resolve(),
|
|
8952
|
+
removed: false,
|
|
8953
|
+
conflict: null,
|
|
8954
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
8955
|
+
save: void 0,
|
|
8956
|
+
saving: null,
|
|
8957
|
+
queued: false,
|
|
8958
|
+
saveError: null,
|
|
8959
|
+
handle: void 0
|
|
8960
|
+
};
|
|
8961
|
+
doc.save = debounce(() => runSave(doc), autosaveMs);
|
|
8962
|
+
doc.handle = {
|
|
8963
|
+
get id() {
|
|
8964
|
+
return doc.target;
|
|
8965
|
+
},
|
|
8966
|
+
getText: () => doc.buffer,
|
|
8967
|
+
applyChange(change, origin = "editor") {
|
|
8968
|
+
doc.buffer = applyTextChange(doc.buffer, change);
|
|
8969
|
+
doc.dirty = true;
|
|
8970
|
+
if (doc.removed) {
|
|
8971
|
+
doc.removed = false;
|
|
8972
|
+
options.onResurrected?.(doc.target);
|
|
8973
|
+
}
|
|
8974
|
+
doc.save.call();
|
|
8975
|
+
if (origin === "external") notify(doc, doc.buffer);
|
|
8976
|
+
},
|
|
8977
|
+
subscribe(listener) {
|
|
8978
|
+
doc.listeners.add(listener);
|
|
8979
|
+
return () => doc.listeners.delete(listener);
|
|
8980
|
+
}
|
|
8981
|
+
};
|
|
8982
|
+
doc.ready = adapter.read(doc.subdir, doc.fileName).then((content) => {
|
|
8983
|
+
doc.buffer = content.text;
|
|
8984
|
+
doc.baseText = content.text;
|
|
8985
|
+
doc.lastModified = content.lastModified;
|
|
8986
|
+
doc.size = content.size;
|
|
8987
|
+
doc.loaded = true;
|
|
8988
|
+
notify(doc, content.text);
|
|
8989
|
+
}).catch(async (error) => {
|
|
8990
|
+
if (await adapter.exists(doc.subdir, doc.fileName).catch(() => true)) doc.loadError = error;
|
|
8991
|
+
else doc.loaded = true;
|
|
8992
|
+
});
|
|
8993
|
+
return doc;
|
|
8994
|
+
}
|
|
8995
|
+
async function reconcileDoc(doc) {
|
|
8996
|
+
if (doc.removed) return;
|
|
8997
|
+
await settled(doc);
|
|
8998
|
+
const entry = registry.get(doc.key);
|
|
8999
|
+
if (!entry) {
|
|
9000
|
+
doc.removed = true;
|
|
9001
|
+
doc.save.cancel();
|
|
9002
|
+
emitDocumentRemoved(doc.target);
|
|
9003
|
+
return;
|
|
9004
|
+
}
|
|
9005
|
+
if (doc.loaded && entry.lastModified === doc.lastModified && entry.size === doc.size) return;
|
|
9006
|
+
const { text: diskText, lastModified, size } = await adapter.read(doc.subdir, doc.fileName);
|
|
9007
|
+
const decision = reconcileDecision({
|
|
9008
|
+
dirty: doc.dirty,
|
|
9009
|
+
baseText: doc.baseText,
|
|
9010
|
+
diskText
|
|
9011
|
+
});
|
|
9012
|
+
if (decision === "noop") {
|
|
9013
|
+
doc.lastModified = lastModified;
|
|
9014
|
+
doc.size = size;
|
|
9015
|
+
doc.loaded = true;
|
|
9016
|
+
doc.loadError = null;
|
|
9017
|
+
return;
|
|
9018
|
+
}
|
|
9019
|
+
if (decision === "reload") {
|
|
9020
|
+
doc.buffer = diskText;
|
|
9021
|
+
doc.baseText = diskText;
|
|
9022
|
+
doc.lastModified = lastModified;
|
|
9023
|
+
doc.size = size;
|
|
9024
|
+
doc.loaded = true;
|
|
9025
|
+
doc.loadError = null;
|
|
9026
|
+
doc.dirty = false;
|
|
9027
|
+
notify(doc, diskText);
|
|
9028
|
+
emitChange({ concept: conceptOf(doc) });
|
|
9029
|
+
return;
|
|
9030
|
+
}
|
|
9031
|
+
doc.conflict = {
|
|
9032
|
+
target: doc.target,
|
|
9033
|
+
diskText,
|
|
9034
|
+
bufferText: doc.buffer
|
|
9035
|
+
};
|
|
9036
|
+
doc.save.cancel();
|
|
9037
|
+
onConflict?.(doc.conflict);
|
|
9038
|
+
}
|
|
9039
|
+
return {
|
|
9040
|
+
open(target) {
|
|
9041
|
+
const key = conceptKey(target);
|
|
9042
|
+
const existing = open.get(key);
|
|
9043
|
+
if (existing) return existing.handle;
|
|
9044
|
+
const entry = registry.get(key);
|
|
9045
|
+
if (!entry) throw new DocumentNotFoundError(target);
|
|
9046
|
+
const doc = makeOpenDoc(target, entry);
|
|
9047
|
+
open.set(key, doc);
|
|
9048
|
+
return doc.handle;
|
|
9049
|
+
},
|
|
9050
|
+
async whenReady(target) {
|
|
9051
|
+
await (open.get(conceptKey(target)) ?? (this.open(target), open.get(conceptKey(target))))?.ready;
|
|
9052
|
+
},
|
|
9053
|
+
async scan() {
|
|
9054
|
+
await adapter.ensureSkeleton();
|
|
9055
|
+
await refreshRegistry();
|
|
9056
|
+
},
|
|
9057
|
+
listDocuments() {
|
|
9058
|
+
return snapshotEntries();
|
|
9059
|
+
},
|
|
9060
|
+
onDocumentsChanged(listener) {
|
|
9061
|
+
documentsChanged.add(listener);
|
|
9062
|
+
return () => documentsChanged.delete(listener);
|
|
9063
|
+
},
|
|
9064
|
+
onDocumentRemoved(listener) {
|
|
9065
|
+
documentRemoved.add(listener);
|
|
9066
|
+
return () => documentRemoved.delete(listener);
|
|
9067
|
+
},
|
|
9068
|
+
onDocumentRenamed(listener) {
|
|
9069
|
+
documentRenamed.add(listener);
|
|
9070
|
+
return () => documentRenamed.delete(listener);
|
|
9071
|
+
},
|
|
9072
|
+
async setAliases(target, aliases) {
|
|
9073
|
+
const entry = registry.get(conceptKey(target));
|
|
9074
|
+
if (!entry) throw new DocumentNotFoundError(target);
|
|
9075
|
+
const next = normaliseAliases(aliases, entry.concept);
|
|
9076
|
+
const doc = open.get(entry.key);
|
|
9077
|
+
if (doc) {
|
|
9078
|
+
await doc.ready;
|
|
9079
|
+
const text = withFrontmatterIdentity(doc.buffer, { aliases: next }, { addBlock: next.length > 0 });
|
|
9080
|
+
if (text !== doc.buffer) doc.handle.applyChange({
|
|
9081
|
+
from: 0,
|
|
9082
|
+
to: doc.buffer.length,
|
|
9083
|
+
insert: text
|
|
9084
|
+
}, "external");
|
|
9085
|
+
} else {
|
|
9086
|
+
const { text } = await adapter.read(entry.subdir, entry.fileName);
|
|
9087
|
+
const rewritten = withFrontmatterIdentity(text, { aliases: next }, { addBlock: next.length > 0 });
|
|
9088
|
+
if (rewritten !== text) {
|
|
9089
|
+
const res = await adapter.write(entry.subdir, entry.fileName, rewritten);
|
|
9090
|
+
entry.lastModified = res.lastModified;
|
|
9091
|
+
entry.size = res.size;
|
|
9092
|
+
emitChange({ concept: entry.concept });
|
|
9093
|
+
}
|
|
9094
|
+
}
|
|
9095
|
+
if (!sameAliases(entry.aliases, next)) {
|
|
9096
|
+
entry.aliases = next;
|
|
9097
|
+
emitRegistryOnly();
|
|
9098
|
+
}
|
|
9099
|
+
},
|
|
9100
|
+
onChange(listener) {
|
|
9101
|
+
changed.add(listener);
|
|
9102
|
+
return () => changed.delete(listener);
|
|
9103
|
+
},
|
|
9104
|
+
async snapshotForIndex() {
|
|
9105
|
+
const out = [];
|
|
9106
|
+
const stream = await this.streamForIndex();
|
|
9107
|
+
for await (const batch of stream.batches) out.push(...batch);
|
|
9108
|
+
return out;
|
|
9109
|
+
},
|
|
9110
|
+
async streamForIndex() {
|
|
9111
|
+
const entries = [...registry.values()];
|
|
9112
|
+
return {
|
|
9113
|
+
total: entries.length,
|
|
9114
|
+
batches: (async function* () {
|
|
9115
|
+
const batch = [];
|
|
9116
|
+
for (const entry of entries) {
|
|
9117
|
+
const read = await adapter.read(entry.subdir, entry.fileName).catch(() => null);
|
|
9118
|
+
if (!read) continue;
|
|
9119
|
+
const fm = parseFrontmatter(read.text);
|
|
9120
|
+
batch.push({
|
|
9121
|
+
concept: entry.concept,
|
|
9122
|
+
kind: entry.kind,
|
|
9123
|
+
aliases: aliasesOf(fm),
|
|
9124
|
+
text: fm.body
|
|
9125
|
+
});
|
|
9126
|
+
if (batch.length === 100) yield batch.splice(0);
|
|
9127
|
+
}
|
|
9128
|
+
if (batch.length > 0) yield batch;
|
|
9129
|
+
})()
|
|
9130
|
+
};
|
|
9131
|
+
},
|
|
9132
|
+
async snapshotDocument(concept) {
|
|
9133
|
+
const entry = registry.get(conceptKey(concept));
|
|
9134
|
+
if (!entry) return null;
|
|
9135
|
+
const doc = open.get(entry.key);
|
|
9136
|
+
let text;
|
|
9137
|
+
if (doc) {
|
|
9138
|
+
await doc.ready;
|
|
9139
|
+
text = doc.buffer;
|
|
9140
|
+
} else {
|
|
9141
|
+
const read = await adapter.read(entry.subdir, entry.fileName).catch(() => null);
|
|
9142
|
+
if (!read) return null;
|
|
9143
|
+
text = read.text;
|
|
9144
|
+
}
|
|
9145
|
+
const fm = parseFrontmatter(text);
|
|
9146
|
+
return {
|
|
9147
|
+
concept: entry.concept,
|
|
9148
|
+
kind: entry.kind,
|
|
9149
|
+
aliases: aliasesOf(fm),
|
|
9150
|
+
text: fm.body
|
|
9151
|
+
};
|
|
9152
|
+
},
|
|
9153
|
+
async reconcile() {
|
|
9154
|
+
await refreshRegistry();
|
|
9155
|
+
for (const doc of [...open.values()]) {
|
|
9156
|
+
if (!registry.has(doc.key)) followFile(doc);
|
|
9157
|
+
await reconcileDoc(doc);
|
|
9158
|
+
}
|
|
9159
|
+
},
|
|
9160
|
+
async resolveConflict(target, choice) {
|
|
9161
|
+
const doc = open.get(conceptKey(target));
|
|
9162
|
+
if (!doc || !doc.conflict) return;
|
|
9163
|
+
doc.loaded = true;
|
|
9164
|
+
doc.loadError = null;
|
|
9165
|
+
if (choice === "take-disk") {
|
|
9166
|
+
const { text, lastModified, size } = await adapter.read(doc.subdir, doc.fileName);
|
|
9167
|
+
doc.buffer = text;
|
|
9168
|
+
doc.baseText = text;
|
|
9169
|
+
doc.lastModified = lastModified;
|
|
9170
|
+
doc.size = size;
|
|
9171
|
+
doc.dirty = false;
|
|
9172
|
+
doc.conflict = null;
|
|
9173
|
+
notify(doc, text);
|
|
9174
|
+
} else {
|
|
9175
|
+
const res = await adapter.write(doc.subdir, doc.fileName, doc.buffer);
|
|
9176
|
+
doc.baseText = doc.buffer;
|
|
9177
|
+
doc.lastModified = res.lastModified;
|
|
9178
|
+
doc.size = res.size;
|
|
9179
|
+
doc.dirty = false;
|
|
9180
|
+
doc.conflict = null;
|
|
9181
|
+
const entry = registry.get(doc.key);
|
|
9182
|
+
if (entry) {
|
|
9183
|
+
entry.lastModified = res.lastModified;
|
|
9184
|
+
entry.size = res.size;
|
|
9185
|
+
}
|
|
9186
|
+
}
|
|
9187
|
+
emitChange({ concept: conceptOf(doc) });
|
|
9188
|
+
},
|
|
9189
|
+
async createJournal(date, body = "") {
|
|
9190
|
+
const concept = date.trim();
|
|
9191
|
+
if (!isJournalConcept(concept)) throw new Error(`"${date}" is not a calendar day.`);
|
|
9192
|
+
if (registry.get(conceptKey(concept))) throw new Error(`A document for "${concept}" already exists.`);
|
|
9193
|
+
await adapter.ensureSkeleton();
|
|
9194
|
+
await adapter.write("journals", `${concept}.md`, body);
|
|
9195
|
+
await refreshRegistry();
|
|
9196
|
+
return concept;
|
|
9197
|
+
},
|
|
9198
|
+
async createPage(title, body = "") {
|
|
9199
|
+
const concept = title.trim();
|
|
9200
|
+
if (concept === "") throw new Error("A page needs a non-empty title.");
|
|
9201
|
+
const key = conceptKey(concept);
|
|
9202
|
+
if (registry.get(key)) throw new Error(`A document for "${concept}" already exists.`);
|
|
9203
|
+
await adapter.ensureSkeleton();
|
|
9204
|
+
const fileName = await allocateFileName("pages", concept);
|
|
9205
|
+
const content = `---\n${stringify({ title: concept })}---\n${body}`;
|
|
9206
|
+
await adapter.write("pages", fileName, content);
|
|
9207
|
+
await refreshRegistry();
|
|
9208
|
+
return concept;
|
|
9209
|
+
},
|
|
9210
|
+
async planRename(from, to, referencingDocuments) {
|
|
9211
|
+
return refuseProtectedMerges(planRename({
|
|
9212
|
+
from,
|
|
9213
|
+
to,
|
|
9214
|
+
kind: registry.get(conceptKey(from))?.kind ?? null,
|
|
9215
|
+
concepts: [...registry.values()].map((e) => e.concept),
|
|
9216
|
+
aliases: [...registry.values()].flatMap((e) => (e.aliases ?? []).map((name) => ({
|
|
9217
|
+
name,
|
|
9218
|
+
concept: e.concept
|
|
9219
|
+
}))),
|
|
9220
|
+
referencingDocuments: referencingDocuments ?? await countReferencing(from)
|
|
9221
|
+
}), async (concept) => {
|
|
9222
|
+
const other = registry.get(conceptKey(concept));
|
|
9223
|
+
if (!other) return false;
|
|
9224
|
+
return documentProtection(open.get(other.key)?.buffer ?? (await adapter.read(other.subdir, other.fileName)).text).kind === "document";
|
|
9225
|
+
});
|
|
9226
|
+
},
|
|
9227
|
+
async renamePage(from, to, options) {
|
|
9228
|
+
const plan = await this.planRename(from, to, 0);
|
|
9229
|
+
if (plan.refusal) throw new Error(plan.refusal);
|
|
9230
|
+
for (const step of renameSteps(plan)) await applyStep(step, options.strategy);
|
|
9231
|
+
let rewritten = 0;
|
|
9232
|
+
if (options.strategy === "rewrite") for (const other of [...registry.values()]) {
|
|
9233
|
+
const openDoc = open.get(other.key);
|
|
9234
|
+
if (openDoc) {
|
|
9235
|
+
await openDoc.ready;
|
|
9236
|
+
const prefix = frontmatterSpan(openDoc.buffer)?.end ?? 0;
|
|
9237
|
+
const splices = wikilinkScopeSplices(openDoc.buffer.slice(prefix), plan.direct.from, plan.direct.to);
|
|
9238
|
+
if (splices.length === 0) continue;
|
|
9239
|
+
for (let i = splices.length - 1; i >= 0; i--) {
|
|
9240
|
+
const splice = splices[i];
|
|
9241
|
+
openDoc.handle.applyChange({
|
|
9242
|
+
from: splice.from + prefix,
|
|
9243
|
+
to: splice.to + prefix,
|
|
9244
|
+
insert: splice.insert
|
|
9245
|
+
}, "external");
|
|
9246
|
+
}
|
|
9247
|
+
rewritten += 1;
|
|
9248
|
+
continue;
|
|
9249
|
+
}
|
|
9250
|
+
const doc = await adapter.read(other.subdir, other.fileName);
|
|
9251
|
+
const parsed = parseFrontmatter(doc.text);
|
|
9252
|
+
const result = rewriteWikilinkScope(parsed.body, plan.direct.from, plan.direct.to);
|
|
9253
|
+
if (result.count === 0) continue;
|
|
9254
|
+
await adapter.write(other.subdir, other.fileName, `${headOf(doc.text, parsed.body)}${result.text}`);
|
|
9255
|
+
rewritten += 1;
|
|
9256
|
+
}
|
|
9257
|
+
await refreshRegistry();
|
|
9258
|
+
return {
|
|
9259
|
+
concept: plan.direct.into,
|
|
9260
|
+
rewritten,
|
|
9261
|
+
cascaded: plan.cascade.length,
|
|
9262
|
+
merged: mergeCount(plan)
|
|
9263
|
+
};
|
|
9264
|
+
},
|
|
9265
|
+
async deleteDocument(concept) {
|
|
9266
|
+
const entry = registry.get(conceptKey(concept));
|
|
9267
|
+
if (!entry) return;
|
|
9268
|
+
const doc = open.get(entry.key);
|
|
9269
|
+
if (doc) {
|
|
9270
|
+
doc.save.cancel();
|
|
9271
|
+
doc.removed = true;
|
|
9272
|
+
await settled(doc);
|
|
9273
|
+
}
|
|
9274
|
+
await adapter.remove(entry.subdir, entry.fileName);
|
|
9275
|
+
await refreshRegistry();
|
|
9276
|
+
emitDocumentRemoved(entry.concept);
|
|
9277
|
+
},
|
|
9278
|
+
async flushDocument(target) {
|
|
9279
|
+
const doc = open.get(conceptKey(target));
|
|
9280
|
+
if (!doc) return;
|
|
9281
|
+
await saveNow(doc);
|
|
9282
|
+
},
|
|
9283
|
+
async dispose() {
|
|
9284
|
+
await Promise.all([...open.values()].map(saveNow));
|
|
9285
|
+
open.clear();
|
|
9286
|
+
documentsChanged.clear();
|
|
9287
|
+
documentRemoved.clear();
|
|
9288
|
+
changed.clear();
|
|
9289
|
+
}
|
|
9290
|
+
};
|
|
9291
|
+
}
|
|
9292
|
+
//#endregion
|
|
9293
|
+
//#region ../client/src/lib/storage/fs/save-error-copy.ts
|
|
9294
|
+
/**
|
|
9295
|
+
* Turn a failed write to the graph folder into a sentence the user can act on (AGENTS.md rule 6:
|
|
9296
|
+
* what happened, why, what next). The File System Access API reports faults as DOMExceptions
|
|
9297
|
+
* whose `name` is the only stable signal. The sync path's `describeSyncFailure` reads those as
|
|
9298
|
+
* IndexedDB or network faults and falls back to "the sync server could not be reached", which
|
|
9299
|
+
* is wrong for a folder on disk - so the [[Filesystem Backend]] has its own map.
|
|
9300
|
+
*
|
|
9301
|
+
* Every sentence ends by saying the edits are kept: the buffer stays dirty until a retry
|
|
9302
|
+
* succeeds (`FilesystemDocumentStoreOptions.onSaveError`), and the user should know that closing
|
|
9303
|
+
* the tab is what loses them.
|
|
9304
|
+
*
|
|
9305
|
+
* Pure, so the mapping is unit tested rather than inferred from a screenshot.
|
|
9306
|
+
*/
|
|
9307
|
+
var KEPT = "Your edits are kept in this tab until a save succeeds.";
|
|
9308
|
+
function describeFilesystemSaveFailure(error, concept) {
|
|
9309
|
+
const opening = `Could not save “${concept}”.`;
|
|
9310
|
+
switch (error instanceof Error ? error.name : "") {
|
|
9311
|
+
case "QuotaExceededError": return `${opening} The disk this folder is on is full. Free up some space, then retry. ${KEPT}`;
|
|
9312
|
+
case "NotAllowedError":
|
|
9313
|
+
case "SecurityError": return `${opening} Permission to write to the folder has lapsed. Grant access again when the browser asks, then retry. ${KEPT}`;
|
|
9314
|
+
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}`;
|
|
9315
|
+
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}`;
|
|
9316
|
+
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}`;
|
|
9317
|
+
default: return `${opening}${error instanceof Error && error.message ? ` The browser reported: ${error.message}.` : ""} Retry in a moment. ${KEPT}`;
|
|
9318
|
+
}
|
|
9319
|
+
}
|
|
9320
|
+
function createSemanticIndex(options) {
|
|
9321
|
+
const { index, model } = options;
|
|
9322
|
+
const floor = options.floor ?? .25;
|
|
9323
|
+
const settleMs = options.settleMs ?? 1500;
|
|
9324
|
+
const pauseMs = options.pauseMs ?? 100;
|
|
9325
|
+
let disposed = false;
|
|
9326
|
+
let running;
|
|
9327
|
+
let again = false;
|
|
9328
|
+
let timer;
|
|
9329
|
+
let unsubscribe;
|
|
9330
|
+
async function runBuild() {
|
|
9331
|
+
for (;;) {
|
|
9332
|
+
if (disposed) return;
|
|
9333
|
+
const pending = await index.semantic.pending(model.id, 128);
|
|
9334
|
+
if (pending.length === 0) break;
|
|
9335
|
+
const sorted = [...pending].sort((a, b) => a.text.length - b.text.length);
|
|
9336
|
+
const rows = [];
|
|
9337
|
+
for (let at = 0; at < sorted.length; at += 32) {
|
|
9338
|
+
const slice = sorted.slice(at, at + 32);
|
|
9339
|
+
const vectors = await model.embed(slice.map((passage) => passage.text));
|
|
9340
|
+
if (disposed) return;
|
|
9341
|
+
slice.forEach((passage, n) => rows.push({
|
|
9342
|
+
hash: passage.hash,
|
|
9343
|
+
...quantise(vectors[n])
|
|
9344
|
+
}));
|
|
9345
|
+
if (pauseMs > 0) await new Promise((resolve) => setTimeout(resolve, pauseMs));
|
|
9346
|
+
}
|
|
9347
|
+
await index.semantic.put(model.id, model.dims, rows);
|
|
9348
|
+
if (options.onProgress) options.onProgress(await index.semantic.status(model.id));
|
|
9349
|
+
}
|
|
9350
|
+
await index.semantic.sweep(model.id);
|
|
9351
|
+
}
|
|
9352
|
+
function build() {
|
|
9353
|
+
if (running) {
|
|
9354
|
+
again = true;
|
|
9355
|
+
return running;
|
|
9356
|
+
}
|
|
9357
|
+
running = runBuild().catch((error) => options.onError?.(error instanceof Error ? error : new Error(String(error)))).finally(() => {
|
|
9358
|
+
running = void 0;
|
|
9359
|
+
if (again && !disposed) {
|
|
9360
|
+
again = false;
|
|
9361
|
+
build();
|
|
9362
|
+
}
|
|
9363
|
+
});
|
|
9364
|
+
return running;
|
|
9365
|
+
}
|
|
9366
|
+
function scheduleBuild() {
|
|
9367
|
+
if (disposed) return;
|
|
9368
|
+
clearTimeout(timer);
|
|
9369
|
+
timer = setTimeout(() => void build(), settleMs);
|
|
9370
|
+
}
|
|
9371
|
+
return {
|
|
9372
|
+
model,
|
|
9373
|
+
status: () => index.semantic.status(model.id),
|
|
9374
|
+
async search(query, offset, limit) {
|
|
9375
|
+
const [vector] = await model.embed([query]);
|
|
9376
|
+
return index.semantic.search(model.id, vector, offset, limit, floor);
|
|
9377
|
+
},
|
|
9378
|
+
build,
|
|
9379
|
+
follow() {
|
|
9380
|
+
if (unsubscribe || disposed) return;
|
|
9381
|
+
unsubscribe = index.onUpdated(scheduleBuild);
|
|
9382
|
+
build();
|
|
9383
|
+
},
|
|
9384
|
+
dispose() {
|
|
9385
|
+
disposed = true;
|
|
9386
|
+
clearTimeout(timer);
|
|
9387
|
+
unsubscribe?.();
|
|
9388
|
+
unsubscribe = void 0;
|
|
9389
|
+
}
|
|
9390
|
+
};
|
|
9391
|
+
}
|
|
9392
|
+
//#endregion
|
|
9393
|
+
//#region ../client/src/lib/activity/breathe.ts
|
|
9394
|
+
/**
|
|
9395
|
+
* The yield-and-cancel checkpoint (plan: 2026-07-27 Import Progress And Activities).
|
|
9396
|
+
*
|
|
9397
|
+
* A long synchronous loop starves the main thread: Svelte cannot repaint, so a status
|
|
9398
|
+
* line jumps from nothing straight to its final value, possibly via the browser's
|
|
9399
|
+
* "page unresponsive" prompt. `breathe` hands control back to the event loop.
|
|
9400
|
+
*
|
|
9401
|
+
* It yields on **elapsed time, not item count**. A graph of one-line journals and a graph
|
|
9402
|
+
* of 5000-line pages want wildly different batch sizes, and the thing we actually care
|
|
9403
|
+
* about is how long a frame has been blocked - so measure that directly.
|
|
9404
|
+
*
|
|
9405
|
+
* The yield point is also the natural cancel checkpoint, so cancellation rides along at
|
|
9406
|
+
* no extra cost (ADR 0035 §3): callers thread one `AbortSignal` and get both.
|
|
8275
9407
|
*/
|
|
8276
|
-
|
|
8277
|
-
|
|
8278
|
-
for (const step of renameSteps(plan)) {
|
|
8279
|
-
if (!step.merges) continue;
|
|
8280
|
-
if (await isProtected(step.into)) return {
|
|
8281
|
-
...plan,
|
|
8282
|
-
refusal: `“${step.into}” is a protected document, so nothing can be merged into it. Choose a different name.`
|
|
8283
|
-
};
|
|
8284
|
-
if (await isProtected(step.from)) return {
|
|
8285
|
-
...plan,
|
|
8286
|
-
refusal: `“${step.from}” is a protected document, so it cannot be merged into “${step.to}”. Choose a name that is not already taken.`
|
|
8287
|
-
};
|
|
8288
|
-
}
|
|
8289
|
-
return plan;
|
|
8290
|
-
}
|
|
9408
|
+
/** Longest a loop may hold the thread before yielding. ~3 frames: smooth enough to repaint, coarse enough not to dominate. */
|
|
9409
|
+
var SLICE_MS = 50;
|
|
8291
9410
|
/**
|
|
8292
|
-
*
|
|
8293
|
-
*
|
|
8294
|
-
*
|
|
8295
|
-
* on a taken name redirects: its links come to point at the page that already answers to it
|
|
8296
|
-
* (ADR 0064 §2). `into` is that page's own title.
|
|
9411
|
+
* Create a breather. Call `await breathe(signal)` inside a loop: it returns immediately
|
|
9412
|
+
* while the current slice has budget left, and yields when it does not. It throws the
|
|
9413
|
+
* signal's reason as soon as the signal aborts, whether or not it yields.
|
|
8297
9414
|
*/
|
|
8298
|
-
function
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
redirects: taken && !hasDocument,
|
|
8307
|
-
into: taken ? holder : to
|
|
9415
|
+
function createBreather(now = () => performance.now()) {
|
|
9416
|
+
let lastYield = now();
|
|
9417
|
+
return async (signal) => {
|
|
9418
|
+
signal?.throwIfAborted();
|
|
9419
|
+
if (now() - lastYield < SLICE_MS) return;
|
|
9420
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
9421
|
+
lastYield = now();
|
|
9422
|
+
signal?.throwIfAborted();
|
|
8308
9423
|
};
|
|
8309
9424
|
}
|
|
8310
|
-
/** Nesting depth of a concept - `[[[[A]] B]] C` is deeper than `[[A]] B`. */
|
|
8311
|
-
function depthOf(concept) {
|
|
8312
|
-
let depth = 0;
|
|
8313
|
-
let max = 0;
|
|
8314
|
-
for (let i = 0; i < concept.length - 1; i++) if (concept[i] === "[" && concept[i + 1] === "[") {
|
|
8315
|
-
depth += 1;
|
|
8316
|
-
max = Math.max(max, depth);
|
|
8317
|
-
i += 1;
|
|
8318
|
-
} else if (concept[i] === "]" && concept[i + 1] === "]") {
|
|
8319
|
-
depth -= 1;
|
|
8320
|
-
i += 1;
|
|
8321
|
-
}
|
|
8322
|
-
return max;
|
|
8323
|
-
}
|
|
8324
9425
|
//#endregion
|
|
8325
9426
|
//#region ../client/src/lib/storage/server/server-document-store.ts
|
|
8326
9427
|
/**
|
|
@@ -9010,7 +10111,114 @@ function createServerDocumentStore(graph, options) {
|
|
|
9010
10111
|
* mid-way would lose every vector of the run. Throttled exports bound the loss to this window.
|
|
9011
10112
|
*/
|
|
9012
10113
|
var BUILD_PERSIST_EVERY_MS = 3e4;
|
|
9013
|
-
/**
|
|
10114
|
+
/** How long a read waits for the relay before answering with what it has. */
|
|
10115
|
+
var CATCH_UP_TIMEOUT_MS = 15e3;
|
|
10116
|
+
/** How long `refresh()` waits for the index to absorb a change before answering from what it has. */
|
|
10117
|
+
var INDEX_FOLLOW_MS = 5e3;
|
|
10118
|
+
/**
|
|
10119
|
+
* Whether the index has yet to absorb the source's latest change. A tool that searches right
|
|
10120
|
+
* after a write - its own, or one found on disk - would otherwise race the index's debounce and
|
|
10121
|
+
* answer from the moment before; `refresh()` on either backend waits here first. The wait is
|
|
10122
|
+
* bounded: an index that is rebuilding a large graph should not stall every tool behind it.
|
|
10123
|
+
*/
|
|
10124
|
+
function followIndex(index, source, followMs = INDEX_FOLLOW_MS) {
|
|
10125
|
+
let pending = false;
|
|
10126
|
+
let waiters = [];
|
|
10127
|
+
const stopSource = source.onChange(() => {
|
|
10128
|
+
pending = true;
|
|
10129
|
+
});
|
|
10130
|
+
const stopIndex = index.onUpdated(() => {
|
|
10131
|
+
pending = false;
|
|
10132
|
+
const resolved = waiters;
|
|
10133
|
+
waiters = [];
|
|
10134
|
+
for (const resolve of resolved) resolve();
|
|
10135
|
+
});
|
|
10136
|
+
return {
|
|
10137
|
+
settled() {
|
|
10138
|
+
if (!pending) return Promise.resolve();
|
|
10139
|
+
return new Promise((resolve) => {
|
|
10140
|
+
waiters.push(resolve);
|
|
10141
|
+
setTimeout(resolve, followMs).unref?.();
|
|
10142
|
+
});
|
|
10143
|
+
},
|
|
10144
|
+
dispose() {
|
|
10145
|
+
stopSource();
|
|
10146
|
+
stopIndex();
|
|
10147
|
+
}
|
|
10148
|
+
};
|
|
10149
|
+
}
|
|
10150
|
+
/**
|
|
10151
|
+
* The part of a headless graph that does not care where its documents come from: snapshots of
|
|
10152
|
+
* the index (and whatever the backend keeps beside it) off the tool's critical path, semantic
|
|
10153
|
+
* search opened on demand, and a dispose that takes a last snapshot.
|
|
10154
|
+
*/
|
|
10155
|
+
function assembleHeadlessGraph(parts) {
|
|
10156
|
+
const { index, indexHost } = parts;
|
|
10157
|
+
let persisting = Promise.resolve();
|
|
10158
|
+
let timer;
|
|
10159
|
+
const persist = () => {
|
|
10160
|
+
if (!parts.persistDir || !indexHost) return persisting;
|
|
10161
|
+
persisting = persisting.then(() => parts.persistBackend?.()).then(() => indexHost.export()).catch((error) => parts.onError?.(error instanceof Error ? error : new Error(String(error))));
|
|
10162
|
+
return persisting;
|
|
10163
|
+
};
|
|
10164
|
+
const schedulePersist = () => {
|
|
10165
|
+
if (!parts.persistDir) return;
|
|
10166
|
+
clearTimeout(timer);
|
|
10167
|
+
timer = setTimeout(() => void persist(), parts.persistDebounceMs ?? 5e3);
|
|
10168
|
+
};
|
|
10169
|
+
const unsubscribe = parts.onChange(schedulePersist);
|
|
10170
|
+
let lastBuildPersist = 0;
|
|
10171
|
+
const persistDuringBuild = () => {
|
|
10172
|
+
if (Date.now() - lastBuildPersist >= BUILD_PERSIST_EVERY_MS) {
|
|
10173
|
+
lastBuildPersist = Date.now();
|
|
10174
|
+
persist();
|
|
10175
|
+
} else schedulePersist();
|
|
10176
|
+
};
|
|
10177
|
+
let semanticOpening;
|
|
10178
|
+
let semanticModel;
|
|
10179
|
+
const semantic = () => {
|
|
10180
|
+
if (semanticOpening) return semanticOpening;
|
|
10181
|
+
semanticOpening = (async () => {
|
|
10182
|
+
if (!parts.embeddingModel) throw new Error("Semantic search is not available in this process: no embedding model was configured.");
|
|
10183
|
+
const model = await parts.embeddingModel();
|
|
10184
|
+
semanticModel = model;
|
|
10185
|
+
const created = createSemanticIndex({
|
|
10186
|
+
index,
|
|
10187
|
+
model,
|
|
10188
|
+
onError: parts.onError,
|
|
10189
|
+
onProgress: (status) => {
|
|
10190
|
+
persistDuringBuild();
|
|
10191
|
+
parts.onSemanticProgress?.(status);
|
|
10192
|
+
}
|
|
10193
|
+
});
|
|
10194
|
+
created.follow();
|
|
10195
|
+
return created;
|
|
10196
|
+
})();
|
|
10197
|
+
semanticOpening.catch(() => {
|
|
10198
|
+
semanticOpening = void 0;
|
|
10199
|
+
});
|
|
10200
|
+
return semanticOpening;
|
|
10201
|
+
};
|
|
10202
|
+
return {
|
|
10203
|
+
graphId: parts.graphId,
|
|
10204
|
+
name: parts.name,
|
|
10205
|
+
store: parts.store,
|
|
10206
|
+
index,
|
|
10207
|
+
settle: () => parts.settle(schedulePersist),
|
|
10208
|
+
persist,
|
|
10209
|
+
semantic,
|
|
10210
|
+
async dispose() {
|
|
10211
|
+
clearTimeout(timer);
|
|
10212
|
+
unsubscribe();
|
|
10213
|
+
if (semanticOpening) await semanticOpening.then((s) => s.dispose()).catch(() => {});
|
|
10214
|
+
await persist();
|
|
10215
|
+
index.dispose();
|
|
10216
|
+
await parts.disposeBackend();
|
|
10217
|
+
await semanticModel?.dispose?.();
|
|
10218
|
+
}
|
|
10219
|
+
};
|
|
10220
|
+
}
|
|
10221
|
+
/** Open a synced graph, scan its registry and build the index; resolves once tools can answer. */
|
|
9014
10222
|
async function openHeadlessGraph(deps) {
|
|
9015
10223
|
const cache = await openGraphCache(deps.graphId);
|
|
9016
10224
|
if (deps.persistDir) await loadLocalCache(deps.persistDir, deps.graphId);
|
|
@@ -9029,7 +10237,8 @@ async function openHeadlessGraph(deps) {
|
|
|
9029
10237
|
name: deps.presenceName,
|
|
9030
10238
|
...PRESENCE_PALETTE[0]
|
|
9031
10239
|
},
|
|
9032
|
-
onError: deps.onError
|
|
10240
|
+
onError: deps.onError,
|
|
10241
|
+
publishName: deps.publishName
|
|
9033
10242
|
});
|
|
9034
10243
|
const store = createServerDocumentStore(sync, { readyTimeoutMs: deps.readyTimeoutMs });
|
|
9035
10244
|
try {
|
|
@@ -9037,76 +10246,54 @@ async function openHeadlessGraph(deps) {
|
|
|
9037
10246
|
const index = createRemoteGraphIndex(store, inlineTransport(indexHost ?? memoryDbHost()), { graphId: deps.graphId });
|
|
9038
10247
|
await index.prepare();
|
|
9039
10248
|
await index.refresh();
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
const persist = () => {
|
|
9043
|
-
if (!deps.persistDir || !indexHost) return persisting;
|
|
9044
|
-
const dir = deps.persistDir;
|
|
9045
|
-
persisting = persisting.then(() => persistLocalCache(dir, deps.graphId)).then(() => indexHost.export()).catch((error) => deps.onError?.(error instanceof Error ? error : new Error(String(error))));
|
|
9046
|
-
return persisting;
|
|
9047
|
-
};
|
|
9048
|
-
const schedulePersist = () => {
|
|
9049
|
-
if (!deps.persistDir) return;
|
|
9050
|
-
clearTimeout(timer);
|
|
9051
|
-
timer = setTimeout(() => void persist(), deps.persistDebounceMs ?? 5e3);
|
|
9052
|
-
};
|
|
9053
|
-
const unsubscribe = sync.onDocUpdate(schedulePersist);
|
|
9054
|
-
let lastBuildPersist = 0;
|
|
9055
|
-
const persistDuringBuild = () => {
|
|
9056
|
-
if (Date.now() - lastBuildPersist >= BUILD_PERSIST_EVERY_MS) {
|
|
9057
|
-
lastBuildPersist = Date.now();
|
|
9058
|
-
persist();
|
|
9059
|
-
} else schedulePersist();
|
|
9060
|
-
};
|
|
9061
|
-
let semanticOpening;
|
|
9062
|
-
let semanticModel;
|
|
9063
|
-
const semantic = () => {
|
|
9064
|
-
if (semanticOpening) return semanticOpening;
|
|
9065
|
-
semanticOpening = (async () => {
|
|
9066
|
-
if (!deps.embeddingModel) throw new Error("Semantic search is not available in this process: no embedding model was configured.");
|
|
9067
|
-
const model = await deps.embeddingModel();
|
|
9068
|
-
semanticModel = model;
|
|
9069
|
-
const created = createSemanticIndex({
|
|
9070
|
-
index,
|
|
9071
|
-
model,
|
|
9072
|
-
onError: deps.onError,
|
|
9073
|
-
onProgress: (status) => {
|
|
9074
|
-
persistDuringBuild();
|
|
9075
|
-
deps.onSemanticProgress?.(status);
|
|
9076
|
-
}
|
|
9077
|
-
});
|
|
9078
|
-
created.follow();
|
|
9079
|
-
return created;
|
|
9080
|
-
})();
|
|
9081
|
-
semanticOpening.catch(() => {
|
|
9082
|
-
semanticOpening = void 0;
|
|
9083
|
-
});
|
|
9084
|
-
return semanticOpening;
|
|
9085
|
-
};
|
|
9086
|
-
return {
|
|
10249
|
+
const follower = followIndex(index, store);
|
|
10250
|
+
return assembleHeadlessGraph({
|
|
9087
10251
|
graphId: deps.graphId,
|
|
9088
|
-
sync,
|
|
9089
|
-
store
|
|
10252
|
+
name: sync.getMeta().name ?? deps.graphId,
|
|
10253
|
+
store: {
|
|
10254
|
+
refresh: () => follower.settled(),
|
|
10255
|
+
listDocuments: () => store.listDocuments(),
|
|
10256
|
+
async whenReady(concept) {
|
|
10257
|
+
await store.whenReady(concept);
|
|
10258
|
+
const wanted = conceptKey(concept);
|
|
10259
|
+
const identity = store.listIdentities().find((candidate) => conceptKey(candidate.concept) === wanted);
|
|
10260
|
+
if (!identity) return;
|
|
10261
|
+
const caughtUp = sync.caughtUpDoc(identity.docId).then(() => "ok");
|
|
10262
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve("timeout"), CATCH_UP_TIMEOUT_MS));
|
|
10263
|
+
await Promise.race([caughtUp, timeout]);
|
|
10264
|
+
},
|
|
10265
|
+
open: (concept) => store.open(concept),
|
|
10266
|
+
createJournal: (date, body) => store.createJournal(date, body),
|
|
10267
|
+
createPage: (title, body) => store.createPage(title, body),
|
|
10268
|
+
setAliases: (target, aliases) => store.setAliases(target, aliases),
|
|
10269
|
+
deleteDocument: (concept) => store.deleteDocument(concept)
|
|
10270
|
+
},
|
|
9090
10271
|
index,
|
|
9091
|
-
|
|
10272
|
+
indexHost,
|
|
10273
|
+
persistDir: deps.persistDir,
|
|
10274
|
+
persistDebounceMs: deps.persistDebounceMs,
|
|
10275
|
+
embeddingModel: deps.embeddingModel,
|
|
10276
|
+
onSemanticProgress: deps.onSemanticProgress,
|
|
10277
|
+
onError: deps.onError,
|
|
10278
|
+
persistBackend: deps.persistDir ? () => persistLocalCache(deps.persistDir, deps.graphId) : void 0,
|
|
10279
|
+
onChange: (schedule) => sync.onDocUpdate(schedule),
|
|
10280
|
+
async settle(schedulePersist) {
|
|
9092
10281
|
await sync.flushAll();
|
|
9093
10282
|
const result = await sync.awaitAcked({ stallMs: 1e4 });
|
|
9094
10283
|
schedulePersist();
|
|
9095
|
-
return
|
|
10284
|
+
if (result.settled) return { settled: true };
|
|
10285
|
+
return {
|
|
10286
|
+
settled: false,
|
|
10287
|
+
outstanding: result.outstanding,
|
|
10288
|
+
message: `The edit is saved locally but the Sync Server has not acknowledged it (${result.outstanding} outstanding). It will be delivered when the connection recovers.`
|
|
10289
|
+
};
|
|
9096
10290
|
},
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
async dispose() {
|
|
9100
|
-
clearTimeout(timer);
|
|
9101
|
-
unsubscribe();
|
|
9102
|
-
if (semanticOpening) await semanticOpening.then((s) => s.dispose()).catch(() => {});
|
|
9103
|
-
await persist();
|
|
9104
|
-
index.dispose();
|
|
9105
|
-
await semanticModel?.dispose?.();
|
|
10291
|
+
async disposeBackend() {
|
|
10292
|
+
follower.dispose();
|
|
9106
10293
|
await store.dispose();
|
|
9107
10294
|
cache.dispose();
|
|
9108
10295
|
}
|
|
9109
|
-
};
|
|
10296
|
+
});
|
|
9110
10297
|
} catch (error) {
|
|
9111
10298
|
await store.dispose().catch(() => {});
|
|
9112
10299
|
cache.dispose();
|
|
@@ -9114,6 +10301,211 @@ async function openHeadlessGraph(deps) {
|
|
|
9114
10301
|
}
|
|
9115
10302
|
}
|
|
9116
10303
|
//#endregion
|
|
10304
|
+
//#region src/headless-folder.ts
|
|
10305
|
+
/**
|
|
10306
|
+
* A local graph folder, open in a process with no editor: the client's filesystem store over a
|
|
10307
|
+
* directory adapter, with the same derived index, persistence and semantic search a synced
|
|
10308
|
+
* graph gets from `assembleHeadlessGraph` ([[2026-09-18 Headless Client Serves A Local Folder]]).
|
|
10309
|
+
*
|
|
10310
|
+
* A folder has no relay, so the two things the relay gave the synced backend for free are done
|
|
10311
|
+
* here instead:
|
|
10312
|
+
*
|
|
10313
|
+
* - **Edits made outside this process.** `refresh()` runs the store's `reconcile()` - re-list
|
|
10314
|
+
* the folder, re-read files whose stamp moved, follow renames - and then waits, bounded, for
|
|
10315
|
+
* the index to absorb what it found, so a search right after an external edit sees it. Every
|
|
10316
|
+
* tool calls it first; a watcher the caller supplies (`fs.watch` in the CLI) feeds the same
|
|
10317
|
+
* pass between calls so the index does not go stale while the agent thinks.
|
|
10318
|
+
* - **Knowing a write is done.** The store autosaves on a debounce and reports a failed write
|
|
10319
|
+
* through a callback rather than a rejection. `settle()` flushes every document a tool touched
|
|
10320
|
+
* and turns a failure into the message the agent gets; the buffer keeps the edit, the store
|
|
10321
|
+
* retries it on the next flush and at shutdown, and a read meanwhile shows the pending text.
|
|
10322
|
+
* If the file then changes on disk before the retry succeeds, the disk copy wins and the lost
|
|
10323
|
+
* edit is logged: it was already reported as not written.
|
|
10324
|
+
*
|
|
10325
|
+
* A local document's file opens with a frontmatter block that carries its identity (ADR 0061);
|
|
10326
|
+
* a synced document's text never does, because identity lives in the encrypted registry. The
|
|
10327
|
+
* tools get the same shape on both: `open()` here is a view of the body alone, with edit
|
|
10328
|
+
* offsets translated past the block, so a read shows the note and never its identity, an edit
|
|
10329
|
+
* cannot reach the `title`, and the line numbers the index reports (it strips the block too)
|
|
10330
|
+
* match the text the agent was given. The store rewrites the title on every save anyway.
|
|
10331
|
+
*
|
|
10332
|
+
* Nothing is written into the folder except the documents a tool writes. The index and the
|
|
10333
|
+
* embedding store live under `persistDir`, a directory the CLI keys by the folder's path.
|
|
10334
|
+
*/
|
|
10335
|
+
var WATCH_DEBOUNCE_MS = 250;
|
|
10336
|
+
/** The document's text after its frontmatter block, and where that block ends. */
|
|
10337
|
+
function body(text) {
|
|
10338
|
+
const head = frontmatterSpan(text)?.end ?? 0;
|
|
10339
|
+
return {
|
|
10340
|
+
head,
|
|
10341
|
+
body: text.slice(head)
|
|
10342
|
+
};
|
|
10343
|
+
}
|
|
10344
|
+
function bodyView(handle) {
|
|
10345
|
+
return {
|
|
10346
|
+
id: handle.id,
|
|
10347
|
+
getText: () => body(handle.getText()).body,
|
|
10348
|
+
applyChange(change, origin) {
|
|
10349
|
+
const { head } = body(handle.getText());
|
|
10350
|
+
handle.applyChange({
|
|
10351
|
+
from: change.from + head,
|
|
10352
|
+
to: change.to + head,
|
|
10353
|
+
insert: change.insert
|
|
10354
|
+
}, origin);
|
|
10355
|
+
},
|
|
10356
|
+
subscribe: (listener) => handle.subscribe((text) => listener(body(text).body))
|
|
10357
|
+
};
|
|
10358
|
+
}
|
|
10359
|
+
/** Open the folder, scan it and build the index; resolves once tools can answer. */
|
|
10360
|
+
async function openHeadlessFolder(deps) {
|
|
10361
|
+
const warn = deps.onWarning ?? ((line) => console.error(`etherpk-mcp: ${line}`));
|
|
10362
|
+
/** The last failed write per document, until a flush of it is attempted again. */
|
|
10363
|
+
const saveErrors = /* @__PURE__ */ new Map();
|
|
10364
|
+
/** Conflicts raised during the reconcile pass in flight, resolved as soon as it returns. */
|
|
10365
|
+
let conflicts = [];
|
|
10366
|
+
const store = createFilesystemDocumentStore(deps.adapter, {
|
|
10367
|
+
onConflict: (conflict) => conflicts.push(conflict),
|
|
10368
|
+
onSaveError: (concept, error) => saveErrors.set(conceptKey(concept), error)
|
|
10369
|
+
});
|
|
10370
|
+
const indexHost = deps.persistDir ? nodeIndexHost(deps.persistDir) : void 0;
|
|
10371
|
+
/**
|
|
10372
|
+
* The folder's listing as one string: every document file's name, mtime and size. The
|
|
10373
|
+
* store's own reconcile re-reads every file to learn titles and aliases, which is right
|
|
10374
|
+
* for the browser's poll but too much for a pass before every tool call on a large
|
|
10375
|
+
* graph, so a pass first lists the two document subdirectories - a stat per file, no
|
|
10376
|
+
* reads - and runs the full reconcile only when this differs from the last pass. An
|
|
10377
|
+
* edit that keeps both mtime and size (an mtime-preserving copy) is missed until
|
|
10378
|
+
* something else changes, the same blind spot the store's own fast path accepts.
|
|
10379
|
+
*/
|
|
10380
|
+
const listingSignature = async () => {
|
|
10381
|
+
const parts = [];
|
|
10382
|
+
for (const subdir of ["journals", "pages"]) for (const entry of await deps.adapter.list(subdir)) parts.push(`${subdir}/${entry.name}@${entry.lastModified}:${entry.size}`);
|
|
10383
|
+
return parts.sort().join("|");
|
|
10384
|
+
};
|
|
10385
|
+
let lastListing;
|
|
10386
|
+
let stopWatching;
|
|
10387
|
+
try {
|
|
10388
|
+
lastListing = await listingSignature();
|
|
10389
|
+
await store.scan();
|
|
10390
|
+
const index = createRemoteGraphIndex(store, inlineTransport(indexHost ?? memoryDbHost()), { graphId: deps.graphId });
|
|
10391
|
+
await index.prepare();
|
|
10392
|
+
await index.refresh();
|
|
10393
|
+
const follower = followIndex(index, store, deps.indexFollowMs);
|
|
10394
|
+
/**
|
|
10395
|
+
* One reconcile pass: the store's own, then the conflicts it raised. A conflict here can
|
|
10396
|
+
* only be a dirty buffer whose write already failed and was reported, so the disk copy
|
|
10397
|
+
* wins and the person is told what was dropped. Passes never overlap: a request during
|
|
10398
|
+
* one runs another after it.
|
|
10399
|
+
*/
|
|
10400
|
+
let reconciling;
|
|
10401
|
+
let rerun = false;
|
|
10402
|
+
const reconcileNow = () => {
|
|
10403
|
+
if (reconciling) {
|
|
10404
|
+
rerun = true;
|
|
10405
|
+
return reconciling;
|
|
10406
|
+
}
|
|
10407
|
+
reconciling = (async () => {
|
|
10408
|
+
const listing = await listingSignature();
|
|
10409
|
+
if (listing === lastListing) return;
|
|
10410
|
+
lastListing = listing;
|
|
10411
|
+
await store.reconcile();
|
|
10412
|
+
const raised = conflicts;
|
|
10413
|
+
conflicts = [];
|
|
10414
|
+
for (const conflict of raised) {
|
|
10415
|
+
await store.resolveConflict(conflict.target, "take-disk");
|
|
10416
|
+
saveErrors.delete(conceptKey(conflict.target));
|
|
10417
|
+
warn(`"${conflict.target}" changed on disk while an edit to it could not be written; took the file on disk and discarded the edit.`);
|
|
10418
|
+
}
|
|
10419
|
+
})();
|
|
10420
|
+
const pass = reconciling;
|
|
10421
|
+
pass.finally(() => {
|
|
10422
|
+
reconciling = void 0;
|
|
10423
|
+
if (rerun) {
|
|
10424
|
+
rerun = false;
|
|
10425
|
+
reconcileNow();
|
|
10426
|
+
}
|
|
10427
|
+
});
|
|
10428
|
+
return pass;
|
|
10429
|
+
};
|
|
10430
|
+
if (deps.watch) {
|
|
10431
|
+
let timer;
|
|
10432
|
+
stopWatching = deps.watch(() => {
|
|
10433
|
+
clearTimeout(timer);
|
|
10434
|
+
timer = setTimeout(() => void reconcileNow(), deps.watchDebounceMs ?? WATCH_DEBOUNCE_MS);
|
|
10435
|
+
});
|
|
10436
|
+
const stop = stopWatching;
|
|
10437
|
+
stopWatching = () => {
|
|
10438
|
+
clearTimeout(timer);
|
|
10439
|
+
stop();
|
|
10440
|
+
};
|
|
10441
|
+
}
|
|
10442
|
+
/** Documents a tool has opened since the last clean settle: the ones a flush must cover. */
|
|
10443
|
+
const touched = /* @__PURE__ */ new Map();
|
|
10444
|
+
return assembleHeadlessGraph({
|
|
10445
|
+
graphId: deps.graphId,
|
|
10446
|
+
name: deps.name,
|
|
10447
|
+
store: {
|
|
10448
|
+
async refresh() {
|
|
10449
|
+
await reconcileNow();
|
|
10450
|
+
await follower.settled();
|
|
10451
|
+
},
|
|
10452
|
+
listDocuments: () => store.listDocuments(),
|
|
10453
|
+
whenReady: (concept) => store.whenReady(concept),
|
|
10454
|
+
open(concept) {
|
|
10455
|
+
touched.set(conceptKey(concept), concept);
|
|
10456
|
+
return bodyView(store.open(concept));
|
|
10457
|
+
},
|
|
10458
|
+
createJournal: (date, body) => store.createJournal(date, body),
|
|
10459
|
+
createPage: (title, body) => store.createPage(title, body),
|
|
10460
|
+
async setAliases(target, aliases) {
|
|
10461
|
+
await store.setAliases(target, aliases);
|
|
10462
|
+
await store.flushDocument(target);
|
|
10463
|
+
},
|
|
10464
|
+
deleteDocument: (concept) => store.deleteDocument(concept)
|
|
10465
|
+
},
|
|
10466
|
+
index,
|
|
10467
|
+
indexHost,
|
|
10468
|
+
persistDir: deps.persistDir,
|
|
10469
|
+
persistDebounceMs: deps.persistDebounceMs,
|
|
10470
|
+
embeddingModel: deps.embeddingModel,
|
|
10471
|
+
onSemanticProgress: deps.onSemanticProgress,
|
|
10472
|
+
onError: deps.onError,
|
|
10473
|
+
onChange: (schedule) => store.onChange(() => schedule()),
|
|
10474
|
+
async settle(schedulePersist) {
|
|
10475
|
+
const failures = [];
|
|
10476
|
+
for (const [key, concept] of touched) {
|
|
10477
|
+
saveErrors.delete(key);
|
|
10478
|
+
await store.flushDocument(concept);
|
|
10479
|
+
const error = saveErrors.get(key);
|
|
10480
|
+
if (error === void 0) {
|
|
10481
|
+
touched.delete(key);
|
|
10482
|
+
continue;
|
|
10483
|
+
}
|
|
10484
|
+
failures.push(describeFilesystemSaveFailure(error, concept));
|
|
10485
|
+
}
|
|
10486
|
+
schedulePersist();
|
|
10487
|
+
if (failures.length === 0) return { settled: true };
|
|
10488
|
+
const message = `${failures.join(" ")} The edit is kept in memory, shows in a read of the document, and is retried on the next write and at shutdown.`;
|
|
10489
|
+
warn(message);
|
|
10490
|
+
return {
|
|
10491
|
+
settled: false,
|
|
10492
|
+
outstanding: failures.length,
|
|
10493
|
+
message
|
|
10494
|
+
};
|
|
10495
|
+
},
|
|
10496
|
+
async disposeBackend() {
|
|
10497
|
+
stopWatching?.();
|
|
10498
|
+
follower.dispose();
|
|
10499
|
+
await store.dispose();
|
|
10500
|
+
}
|
|
10501
|
+
});
|
|
10502
|
+
} catch (error) {
|
|
10503
|
+
stopWatching?.();
|
|
10504
|
+
await store.dispose().catch(() => {});
|
|
10505
|
+
throw error;
|
|
10506
|
+
}
|
|
10507
|
+
}
|
|
10508
|
+
//#endregion
|
|
9117
10509
|
//#region ../client/src/lib/sync/device-approval.ts
|
|
9118
10510
|
/**
|
|
9119
10511
|
* Device approval (ADR 0026 flows): unlock a NEW device from an already-unlocked one, so
|
|
@@ -9228,11 +10620,11 @@ var ToolError = class extends Error {
|
|
|
9228
10620
|
this.name = "ToolError";
|
|
9229
10621
|
}
|
|
9230
10622
|
};
|
|
9231
|
-
/** How `concept`, `today` and aliases resolve to one
|
|
10623
|
+
/** How `concept`, `today` and aliases resolve to one document, case-insensitively. */
|
|
9232
10624
|
function resolveIdentity(graph, target) {
|
|
9233
10625
|
const wanted = conceptKey(target === "today" ? todayISO() : target.trim());
|
|
9234
|
-
for (const identity of graph.store.
|
|
9235
|
-
if (
|
|
10626
|
+
for (const identity of graph.store.listDocuments()) {
|
|
10627
|
+
if (identity.key === wanted) return identity;
|
|
9236
10628
|
if (identity.aliases.some((alias) => conceptKey(alias) === wanted)) return identity;
|
|
9237
10629
|
}
|
|
9238
10630
|
return null;
|
|
@@ -9243,19 +10635,14 @@ function requireIdentity(graph, target) {
|
|
|
9243
10635
|
return identity;
|
|
9244
10636
|
}
|
|
9245
10637
|
/**
|
|
9246
|
-
* The document's current text from a live handle,
|
|
9247
|
-
*
|
|
9248
|
-
*
|
|
10638
|
+
* The document's current text from a live handle, once the backend says it can be trusted:
|
|
10639
|
+
* caught up with the relay, or read from disk (`HeadlessDocuments.whenReady`). A fresh process
|
|
10640
|
+
* seeds every document empty until then, and a tool must never believe that emptiness.
|
|
9249
10641
|
*/
|
|
9250
10642
|
async function liveText(graph, identity) {
|
|
9251
10643
|
await graph.store.whenReady(identity.concept);
|
|
9252
|
-
const caughtUp = graph.sync.caughtUpDoc(identity.docId).then(() => "ok");
|
|
9253
|
-
const timeout = new Promise((resolve) => setTimeout(() => resolve("timeout"), CATCH_UP_TIMEOUT_MS));
|
|
9254
|
-
await Promise.race([caughtUp, timeout]);
|
|
9255
10644
|
return graph.store.open(identity.concept).getText();
|
|
9256
10645
|
}
|
|
9257
|
-
/** How long a read waits for the relay before answering with what it has. */
|
|
9258
|
-
var CATCH_UP_TIMEOUT_MS = 15e3;
|
|
9259
10646
|
function refuseIfProtected(concept, text) {
|
|
9260
10647
|
if (documentProtection(text).kind === "document") throw new ToolError("protected_document", `"${concept}" is a protected document. Its content is encrypted under a key this client never holds, so it cannot be read or changed here; the user can unlock it in EtherPK.`);
|
|
9261
10648
|
}
|
|
@@ -9270,12 +10657,16 @@ function bounded(value, fallback, max) {
|
|
|
9270
10657
|
if (!Number.isInteger(value) || value < 0) throw new ToolError("invalid_argument", "offset and limit must be non-negative integers.");
|
|
9271
10658
|
return Math.min(value, max);
|
|
9272
10659
|
}
|
|
9273
|
-
/**
|
|
10660
|
+
/**
|
|
10661
|
+
* After a write: make it durable - acknowledged by the relay, or on disk - so the agent's next
|
|
10662
|
+
* read anywhere sees it, and refuse in the backend's own words when that did not happen.
|
|
10663
|
+
*/
|
|
9274
10664
|
async function settle(graph) {
|
|
9275
10665
|
const result = await graph.settle();
|
|
9276
|
-
if (!result.settled) throw new ToolError("not_settled",
|
|
10666
|
+
if (!result.settled) throw new ToolError("not_settled", result.message);
|
|
9277
10667
|
}
|
|
9278
|
-
function listDocuments(graph, args = {}) {
|
|
10668
|
+
async function listDocuments(graph, args = {}) {
|
|
10669
|
+
await graph.store.refresh();
|
|
9279
10670
|
const offset = bounded(args.offset, 0, Number.MAX_SAFE_INTEGER);
|
|
9280
10671
|
const limit = bounded(args.limit, 200, 200);
|
|
9281
10672
|
const protectedKeys = new Set(graph.index.allConcepts().filter((candidate) => candidate.protected && (candidate.kind === "page" || candidate.kind === "journal")).map((candidate) => candidate.key));
|
|
@@ -9293,6 +10684,7 @@ function listDocuments(graph, args = {}) {
|
|
|
9293
10684
|
};
|
|
9294
10685
|
}
|
|
9295
10686
|
async function readDocument(graph, concept) {
|
|
10687
|
+
await graph.store.refresh();
|
|
9296
10688
|
const identity = requireIdentity(graph, concept);
|
|
9297
10689
|
const text = await liveText(graph, identity);
|
|
9298
10690
|
refuseIfProtected(identity.concept, text);
|
|
@@ -9307,6 +10699,7 @@ async function readDocument(graph, concept) {
|
|
|
9307
10699
|
async function search(graph, args) {
|
|
9308
10700
|
const query = args.query.trim();
|
|
9309
10701
|
if (query === "") throw new ToolError("invalid_argument", "query must not be empty.");
|
|
10702
|
+
await graph.store.refresh();
|
|
9310
10703
|
const offset = bounded(args.offset, 0, Number.MAX_SAFE_INTEGER);
|
|
9311
10704
|
const limit = bounded(args.limit, 20, 50);
|
|
9312
10705
|
const mode = args.mode ?? "text";
|
|
@@ -9377,6 +10770,7 @@ async function searchText(graph, query, offset, limit) {
|
|
|
9377
10770
|
async function backlinks(graph, concept) {
|
|
9378
10771
|
const target = concept.trim() === "today" ? todayISO() : concept.trim();
|
|
9379
10772
|
if (target === "") throw new ToolError("invalid_argument", "concept must not be empty.");
|
|
10773
|
+
await graph.store.refresh();
|
|
9380
10774
|
return {
|
|
9381
10775
|
concept: target,
|
|
9382
10776
|
sources: (await graph.index.backlinks(target)).map((group) => ({
|
|
@@ -9396,6 +10790,7 @@ async function tasks(graph, args = {}) {
|
|
|
9396
10790
|
const statuses = args.statuses ?? OPEN_TASK_STATUSES;
|
|
9397
10791
|
for (const status of statuses) if (!TASK_STATUSES.includes(status)) throw new ToolError("invalid_argument", `Unknown task status "${status}".`);
|
|
9398
10792
|
const priorities = args.priorities ? args.priorities.map((priority) => priority === "none" ? null : priority) : [...TASK_PRIORITY_FILTERS];
|
|
10793
|
+
await graph.store.refresh();
|
|
9399
10794
|
const result = await graph.index.tasks({
|
|
9400
10795
|
concept: args.concept?.trim() ? args.concept.trim() === "today" ? todayISO() : args.concept.trim() : null,
|
|
9401
10796
|
statuses,
|
|
@@ -9426,6 +10821,7 @@ async function tasks(graph, args = {}) {
|
|
|
9426
10821
|
}
|
|
9427
10822
|
async function editDocument(graph, args) {
|
|
9428
10823
|
if (args.old === "") throw new ToolError("invalid_argument", "old must not be empty.");
|
|
10824
|
+
await graph.store.refresh();
|
|
9429
10825
|
const identity = requireIdentity(graph, args.concept);
|
|
9430
10826
|
const text = await liveText(graph, identity);
|
|
9431
10827
|
refuseIfProtected(identity.concept, text);
|
|
@@ -9449,6 +10845,7 @@ async function appendDocument(graph, args) {
|
|
|
9449
10845
|
const text = normaliseIndentUnit(args.text);
|
|
9450
10846
|
if (text.trim() === "") throw new ToolError("invalid_argument", "text must not be empty.");
|
|
9451
10847
|
const target = args.concept.trim() === "today" ? todayISO() : args.concept.trim();
|
|
10848
|
+
await graph.store.refresh();
|
|
9452
10849
|
const existing = resolveIdentity(graph, target);
|
|
9453
10850
|
if (!existing) {
|
|
9454
10851
|
if (!isJournalConcept(target)) throw new ToolError("not_found", `No document is named "${target}". Use create_page to start a new page.`);
|
|
@@ -9477,6 +10874,7 @@ async function createPage(graph, args) {
|
|
|
9477
10874
|
const title = args.title.trim();
|
|
9478
10875
|
if (title === "") throw new ToolError("invalid_argument", "title must not be empty.");
|
|
9479
10876
|
if (isJournalConcept(title)) throw new ToolError("invalid_argument", `"${title}" is a calendar day, so it names a journal entry; use append_document to write to it.`);
|
|
10877
|
+
await graph.store.refresh();
|
|
9480
10878
|
if (resolveIdentity(graph, title)) throw new ToolError("already_exists", `A document already answers to "${title}". Use edit_document or append_document instead.`);
|
|
9481
10879
|
await graph.store.createPage(title, normaliseIndentUnit(args.text ?? ""));
|
|
9482
10880
|
await settle(graph);
|
|
@@ -9598,7 +10996,7 @@ function createMcpServer(graph, info) {
|
|
|
9598
10996
|
}, async (args) => run(() => tasks(graph, args)));
|
|
9599
10997
|
server.registerTool("edit_document", {
|
|
9600
10998
|
title: "Edit a document",
|
|
9601
|
-
description: "Replace one exact occurrence of \"old\" with \"new\" in a document. \"old\" must appear exactly once (include surrounding lines to disambiguate); the change is applied as one edit so it merges with anyone typing elsewhere in the page. Refused on a protected document.",
|
|
10999
|
+
description: "Replace one exact occurrence of \"old\" with \"new\" in a document. \"old\" must appear exactly once (include surrounding lines to disambiguate); the change is applied as one edit, so on a synced graph it merges with anyone typing elsewhere in the page, and on a folder it is in the file when this returns. Refused on a protected document.",
|
|
9602
11000
|
inputSchema: {
|
|
9603
11001
|
concept,
|
|
9604
11002
|
old: z.string().min(1).describe("The exact text to replace, as read_document returned it."),
|
|
@@ -9624,6 +11022,147 @@ function createMcpServer(graph, info) {
|
|
|
9624
11022
|
return server;
|
|
9625
11023
|
}
|
|
9626
11024
|
//#endregion
|
|
11025
|
+
//#region ../client/src/lib/storage/fs/directory-adapter.ts
|
|
11026
|
+
/** All four, in skeleton-creation order. */
|
|
11027
|
+
var SUBDIRS = [
|
|
11028
|
+
"journals",
|
|
11029
|
+
"pages",
|
|
11030
|
+
"assets",
|
|
11031
|
+
"etherpk"
|
|
11032
|
+
];
|
|
11033
|
+
//#endregion
|
|
11034
|
+
//#region src/node-directory-adapter.ts
|
|
11035
|
+
/**
|
|
11036
|
+
* A {@link DirectoryAdapter} over a real directory through `node:fs`: the third adapter behind
|
|
11037
|
+
* the seam the filesystem store reads and writes through (the browser's `web-fs-adapter.ts`
|
|
11038
|
+
* and the tests' `memory-adapter.ts` are the other two), so `createFilesystemDocumentStore`
|
|
11039
|
+
* runs unchanged in the [[Headless Client]] over a local graph folder.
|
|
11040
|
+
*
|
|
11041
|
+
* The contract it keeps, because the store's external-change detection depends on it: a
|
|
11042
|
+
* listing reports each file's mtime in epoch milliseconds and its size in bytes, a write
|
|
11043
|
+
* answers with the same pair the next listing will show, listings hold files only, and a
|
|
11044
|
+
* root-file read answers `null` for "absent" and rejects for anything else, so a permission
|
|
11045
|
+
* problem is never mistaken for an empty slot and written over (see `directory-adapter.ts`).
|
|
11046
|
+
*/
|
|
11047
|
+
function isMissing(error) {
|
|
11048
|
+
return error?.code === "ENOENT";
|
|
11049
|
+
}
|
|
11050
|
+
/** `bytes` as an ArrayBuffer-backed view of exactly its own length, as the contract promises. */
|
|
11051
|
+
function standalone(bytes) {
|
|
11052
|
+
const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength));
|
|
11053
|
+
copy.set(bytes);
|
|
11054
|
+
return copy;
|
|
11055
|
+
}
|
|
11056
|
+
function createNodeDirectoryAdapter(root) {
|
|
11057
|
+
const dir = resolve(root);
|
|
11058
|
+
const path = (subdir, name) => join(dir, subdir, name);
|
|
11059
|
+
async function stamped(file) {
|
|
11060
|
+
const info = await stat(file);
|
|
11061
|
+
return {
|
|
11062
|
+
lastModified: info.mtimeMs,
|
|
11063
|
+
size: info.size
|
|
11064
|
+
};
|
|
11065
|
+
}
|
|
11066
|
+
return {
|
|
11067
|
+
async list(subdir) {
|
|
11068
|
+
const entries = await readdir(join(dir, subdir), { withFileTypes: true }).catch((error) => {
|
|
11069
|
+
if (isMissing(error)) return [];
|
|
11070
|
+
throw error;
|
|
11071
|
+
});
|
|
11072
|
+
const out = [];
|
|
11073
|
+
for (const entry of entries) {
|
|
11074
|
+
if (!entry.isFile()) continue;
|
|
11075
|
+
const { lastModified, size } = await stamped(path(subdir, entry.name));
|
|
11076
|
+
out.push({
|
|
11077
|
+
name: entry.name,
|
|
11078
|
+
lastModified,
|
|
11079
|
+
size
|
|
11080
|
+
});
|
|
11081
|
+
}
|
|
11082
|
+
return out;
|
|
11083
|
+
},
|
|
11084
|
+
async read(subdir, name) {
|
|
11085
|
+
const file = path(subdir, name);
|
|
11086
|
+
return {
|
|
11087
|
+
text: await readFile(file, "utf8"),
|
|
11088
|
+
...await stamped(file)
|
|
11089
|
+
};
|
|
11090
|
+
},
|
|
11091
|
+
async write(subdir, name, text) {
|
|
11092
|
+
const file = path(subdir, name);
|
|
11093
|
+
await mkdir(join(dir, subdir), { recursive: true });
|
|
11094
|
+
await writeFile(file, text, "utf8");
|
|
11095
|
+
return {
|
|
11096
|
+
text,
|
|
11097
|
+
...await stamped(file)
|
|
11098
|
+
};
|
|
11099
|
+
},
|
|
11100
|
+
async readBinary(subdir, name) {
|
|
11101
|
+
const file = path(subdir, name);
|
|
11102
|
+
return {
|
|
11103
|
+
bytes: standalone(await readFile(file)),
|
|
11104
|
+
lastModified: (await stamped(file)).lastModified
|
|
11105
|
+
};
|
|
11106
|
+
},
|
|
11107
|
+
async writeBinary(subdir, name, bytes) {
|
|
11108
|
+
const file = path(subdir, name);
|
|
11109
|
+
await mkdir(join(dir, subdir), { recursive: true });
|
|
11110
|
+
await writeFile(file, bytes);
|
|
11111
|
+
return {
|
|
11112
|
+
bytes,
|
|
11113
|
+
lastModified: (await stamped(file)).lastModified
|
|
11114
|
+
};
|
|
11115
|
+
},
|
|
11116
|
+
async exists(subdir, name) {
|
|
11117
|
+
try {
|
|
11118
|
+
await access(path(subdir, name));
|
|
11119
|
+
return true;
|
|
11120
|
+
} catch (error) {
|
|
11121
|
+
if (isMissing(error)) return false;
|
|
11122
|
+
throw error;
|
|
11123
|
+
}
|
|
11124
|
+
},
|
|
11125
|
+
async remove(subdir, name) {
|
|
11126
|
+
await rm(path(subdir, name), { force: true });
|
|
11127
|
+
},
|
|
11128
|
+
async ensureSkeleton() {
|
|
11129
|
+
for (const subdir of SUBDIRS) await mkdir(join(dir, subdir), { recursive: true });
|
|
11130
|
+
},
|
|
11131
|
+
async readRootFile(name) {
|
|
11132
|
+
const file = join(dir, name);
|
|
11133
|
+
let text;
|
|
11134
|
+
try {
|
|
11135
|
+
text = await readFile(file, "utf8");
|
|
11136
|
+
} catch (error) {
|
|
11137
|
+
if (isMissing(error)) return null;
|
|
11138
|
+
throw error;
|
|
11139
|
+
}
|
|
11140
|
+
return {
|
|
11141
|
+
text,
|
|
11142
|
+
...await stamped(file)
|
|
11143
|
+
};
|
|
11144
|
+
},
|
|
11145
|
+
async writeRootFile(name, text) {
|
|
11146
|
+
const file = join(dir, name);
|
|
11147
|
+
await writeFile(file, text, "utf8");
|
|
11148
|
+
return {
|
|
11149
|
+
text,
|
|
11150
|
+
...await stamped(file)
|
|
11151
|
+
};
|
|
11152
|
+
}
|
|
11153
|
+
};
|
|
11154
|
+
}
|
|
11155
|
+
/**
|
|
11156
|
+
* Whether `root` looks like a graph folder: a directory holding the `pages` and `journals`
|
|
11157
|
+
* subdirectories the skeleton creates. The Headless Client refuses anything else rather than
|
|
11158
|
+
* creating a skeleton in whatever directory was mistyped; opening the folder in EtherPK once is
|
|
11159
|
+
* what makes a graph.
|
|
11160
|
+
*/
|
|
11161
|
+
async function isGraphFolder(root) {
|
|
11162
|
+
for (const subdir of ["pages", "journals"]) if (!(await stat(join(root, subdir)).catch(() => null))?.isDirectory()) return false;
|
|
11163
|
+
return true;
|
|
11164
|
+
}
|
|
11165
|
+
//#endregion
|
|
9627
11166
|
//#region src/main.ts
|
|
9628
11167
|
/**
|
|
9629
11168
|
* `etherpk-mcp`: the [[Headless Client]]'s command line (ADR 0072).
|
|
@@ -9631,6 +11170,7 @@ function createMcpServer(graph, info) {
|
|
|
9631
11170
|
* etherpk-mcp login --sync-server <url> [--pat <token>] [--recovery-code]
|
|
9632
11171
|
* etherpk-mcp graphs [--sync-server <url>]
|
|
9633
11172
|
* etherpk-mcp serve --graph <id or name> [--sync-server <url>] [--no-semantic]
|
|
11173
|
+
* etherpk-mcp serve --folder <path> [--no-semantic]
|
|
9634
11174
|
* etherpk-mcp logout [--sync-server <url> | --all]
|
|
9635
11175
|
* etherpk-mcp semantic setup | status | remove
|
|
9636
11176
|
*
|
|
@@ -9660,11 +11200,17 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
|
|
|
9660
11200
|
${CMD} graphs [--sync-server <url>]
|
|
9661
11201
|
List the synced graphs each signed-in account can reach, by name and id.
|
|
9662
11202
|
${CMD} serve --graph <id or name> [--sync-server <url>] [--no-semantic]
|
|
9663
|
-
Serve one graph to an agent over stdio. For Claude Code:
|
|
11203
|
+
Serve one synced graph to an agent over stdio. For Claude Code:
|
|
9664
11204
|
claude mcp add etherpk -- npx @appsoftwareltd/etherpk-mcp serve --sync-server <url> --graph <id>
|
|
9665
11205
|
Once "semantic setup" has run on this computer, serve also keeps a search-by-meaning
|
|
9666
11206
|
store of the graph current (the agent's search tool gains mode: semantic); pass
|
|
9667
11207
|
--no-semantic to leave it off for this registration.
|
|
11208
|
+
${CMD} serve --folder <path> [--no-semantic]
|
|
11209
|
+
Serve a local graph folder the same way: no sign-in, no server. The agent gets search,
|
|
11210
|
+
backlinks, tasks and format-safe edits over the folder's markdown, alongside the files
|
|
11211
|
+
themselves. Edits made in an editor or by the agent directly are picked up as they land.
|
|
11212
|
+
The folder must already be a graph (open it in EtherPK once); its index is kept under
|
|
11213
|
+
the cache directory, never in the folder.
|
|
9668
11214
|
${CMD} logout [--sync-server <url> | --all]
|
|
9669
11215
|
Forget that server's token, keys and cached graphs on this machine.
|
|
9670
11216
|
${CMD} semantic setup
|
|
@@ -9826,6 +11372,32 @@ async function graphsCommand(args) {
|
|
|
9826
11372
|
if (logins.length === 0) fail(`Not logged in on this machine. Run: ${CMD} login --sync-server <url>`);
|
|
9827
11373
|
for (const login of logins) await listGraphs(login, logins.length > 1);
|
|
9828
11374
|
}
|
|
11375
|
+
/**
|
|
11376
|
+
* The fallback for a graph the server carries no name envelope for: read its root document once
|
|
11377
|
+
* and publish the name, so no later listing has to. The publish is awaited because these
|
|
11378
|
+
* commands exit as soon as they have printed, and a request still in flight would be lost.
|
|
11379
|
+
*/
|
|
11380
|
+
function metaNameReader(account) {
|
|
11381
|
+
return async (record, keyring) => {
|
|
11382
|
+
const publisher = createGraphNamePublisher({
|
|
11383
|
+
api: account.api,
|
|
11384
|
+
keyring,
|
|
11385
|
+
graphId: record.id
|
|
11386
|
+
});
|
|
11387
|
+
try {
|
|
11388
|
+
return await readGraphName({
|
|
11389
|
+
graphId: record.id,
|
|
11390
|
+
rootDocId: record.rootDocId,
|
|
11391
|
+
keyring,
|
|
11392
|
+
relayUrl: account.relayUrl,
|
|
11393
|
+
token: account.tokenFor(record.id),
|
|
11394
|
+
publishName: publisher.publish
|
|
11395
|
+
});
|
|
11396
|
+
} finally {
|
|
11397
|
+
await publisher.settled();
|
|
11398
|
+
}
|
|
11399
|
+
};
|
|
11400
|
+
}
|
|
9829
11401
|
async function listGraphs(login, several) {
|
|
9830
11402
|
if (!login.vaultKey) fail(`Keys are not unlocked on this machine for ${login.syncServer}. Run: ${CMD} login --sync-server ${login.syncServer}`);
|
|
9831
11403
|
const account = await connectAccount(login);
|
|
@@ -9837,18 +11409,8 @@ async function listGraphs(login, several) {
|
|
|
9837
11409
|
return;
|
|
9838
11410
|
}
|
|
9839
11411
|
console.log(several ? `Synced graphs on ${login.syncServer}:` : "Synced graphs:");
|
|
9840
|
-
|
|
9841
|
-
|
|
9842
|
-
const name = keyring ? await readGraphName({
|
|
9843
|
-
graphId: record.id,
|
|
9844
|
-
rootDocId: record.rootDocId,
|
|
9845
|
-
keyring,
|
|
9846
|
-
relayUrl: account.relayUrl,
|
|
9847
|
-
token: account.tokenFor(record.id)
|
|
9848
|
-
}) : null;
|
|
9849
|
-
const label = keyring ? name ?? "(unnamed)" : "(no key on this account yet - open it in EtherPK first)";
|
|
9850
|
-
console.log(` ${record.id} ${label} [${record.role}]`);
|
|
9851
|
-
}
|
|
11412
|
+
const readMeta = metaNameReader(account);
|
|
11413
|
+
for (const record of graphs) console.log(` ${record.id} ${describeGraphLabel(await resolveGraphLabel(record, vault, readMeta))} [${record.role}]`);
|
|
9852
11414
|
console.log("");
|
|
9853
11415
|
console.log(`Serve one to an agent with: ${CMD} serve${serverFlag} --graph <id>`);
|
|
9854
11416
|
console.log(`For Claude Code: claude mcp add etherpk -- ${CMD} serve${serverFlag} --graph <id>`);
|
|
@@ -9904,9 +11466,49 @@ function memoryNote() {
|
|
|
9904
11466
|
return ` [rss ${mb(m.rss)} MB, heap ${mb(m.heapUsed)} MB, external ${mb(m.external)} MB, arrayBuffers ${mb(m.arrayBuffers)} MB]`;
|
|
9905
11467
|
}
|
|
9906
11468
|
if (debugMemory) setInterval(() => console.error(`etherpk-mcp: memory${memoryNote()}`), 1e4).unref();
|
|
11469
|
+
/**
|
|
11470
|
+
* The embedding model for a serve, whichever backend: refused with the reason when the agent's
|
|
11471
|
+
* registration turned it off, else loaded on first semantic use. Always wired, so a semantic
|
|
11472
|
+
* search on a machine without setup is refused with the command to run - and once it has run,
|
|
11473
|
+
* the next search loads the model with no restart.
|
|
11474
|
+
*/
|
|
11475
|
+
function embeddingModelFor(args) {
|
|
11476
|
+
return args["no-semantic"] ? () => Promise.reject(new SemanticUnavailable("Semantic search is off for this agent: serve was started with --no-semantic.")) : () => loadEmbeddingModel(process.env);
|
|
11477
|
+
}
|
|
9907
11478
|
async function serve(args) {
|
|
11479
|
+
const folder = args.folder?.trim();
|
|
9908
11480
|
const wanted = args.graph?.trim();
|
|
9909
|
-
if (
|
|
11481
|
+
if (folder && (wanted || args["sync-server"])) fail("serve takes either --folder <path> or --graph <id or name> (with an optional --sync-server), not both.");
|
|
11482
|
+
if (!folder && !wanted) fail("serve needs --graph <id or name>, or --folder <path> for a local graph folder.");
|
|
11483
|
+
const { graph, graphName } = folder ? await openFolderForServe(folder, args) : await openSyncedForServe(wanted, args);
|
|
11484
|
+
await serveGraph(graph, graphName, args);
|
|
11485
|
+
}
|
|
11486
|
+
/**
|
|
11487
|
+
* A local graph folder: no sign-in and no server ([[2026-09-18 Headless Client Serves A Local
|
|
11488
|
+
* Folder]]). The folder is what it is; the CLI never creates a skeleton in whatever directory
|
|
11489
|
+
* was mistyped. The index lives under the cache root, keyed by the folder's path.
|
|
11490
|
+
*/
|
|
11491
|
+
async function openFolderForServe(folder, args) {
|
|
11492
|
+
const path = resolve(folder);
|
|
11493
|
+
if (!await isGraphFolder(path)) fail(`${path} is not an EtherPK graph folder: it has no pages/ and journals/ directories. Open the folder in EtherPK once to make it one, then serve it.`);
|
|
11494
|
+
const name = basename(path);
|
|
11495
|
+
console.error(`etherpk-mcp: opening folder ${path}…`);
|
|
11496
|
+
return {
|
|
11497
|
+
graph: await openHeadlessFolder({
|
|
11498
|
+
adapter: createNodeDirectoryAdapter(path),
|
|
11499
|
+
name,
|
|
11500
|
+
graphId: folderKey(path),
|
|
11501
|
+
persistDir: folderCacheDir(process.env, path),
|
|
11502
|
+
embeddingModel: embeddingModelFor(args),
|
|
11503
|
+
onSemanticProgress: reportSemanticProgress,
|
|
11504
|
+
onError: (error) => console.error(`etherpk-mcp: ${error.message}`),
|
|
11505
|
+
onWarning: (line) => console.error(`etherpk-mcp: ${line}`),
|
|
11506
|
+
watch: watchFolder(path, (error) => console.error(`etherpk-mcp: not watching the folder for changes (${error.message}); edits made outside are still picked up before each tool call.`))
|
|
11507
|
+
}),
|
|
11508
|
+
graphName: name
|
|
11509
|
+
};
|
|
11510
|
+
}
|
|
11511
|
+
async function openSyncedForServe(wanted, args) {
|
|
9910
11512
|
const login = requireServer(await loadConfig(defaultConfigPath()), args["sync-server"]);
|
|
9911
11513
|
if (!login.vaultKey) fail(`Keys are not unlocked on this machine for ${login.syncServer}. Run: ${CMD} login --sync-server ${login.syncServer}`);
|
|
9912
11514
|
const account = await connectAccount(login);
|
|
@@ -9914,26 +11516,15 @@ async function serve(args) {
|
|
|
9914
11516
|
const graphs = await account.api.listGraphs();
|
|
9915
11517
|
let graphId = graphs.find((graph) => graph.id === wanted)?.id;
|
|
9916
11518
|
let graphName = null;
|
|
9917
|
-
if (!graphId)
|
|
9918
|
-
const
|
|
9919
|
-
if (
|
|
9920
|
-
|
|
9921
|
-
|
|
9922
|
-
rootDocId: record.rootDocId,
|
|
9923
|
-
keyring,
|
|
9924
|
-
relayUrl: account.relayUrl,
|
|
9925
|
-
token: account.tokenFor(record.id)
|
|
9926
|
-
});
|
|
9927
|
-
if (name && name.toLowerCase() === wanted.toLowerCase()) {
|
|
9928
|
-
graphId = record.id;
|
|
9929
|
-
graphName = name;
|
|
9930
|
-
break;
|
|
11519
|
+
if (!graphId) {
|
|
11520
|
+
const match = await findGraphByName(graphs, vault, wanted, metaNameReader(account));
|
|
11521
|
+
if (match) {
|
|
11522
|
+
graphId = match.record.id;
|
|
11523
|
+
graphName = match.name;
|
|
9931
11524
|
}
|
|
9932
11525
|
}
|
|
9933
11526
|
if (!graphId) fail(`No synced graph on ${login.syncServer} is named or identified by "${wanted}". Run: ${CMD} graphs`);
|
|
9934
11527
|
const { record, keyring } = resolveGraphById(graphs, vault, graphId);
|
|
9935
|
-
const semanticReady = await semanticSetupStatus(process.env);
|
|
9936
|
-
const semanticOn = !args["no-semantic"] && semanticReady.runtime && semanticReady.model;
|
|
9937
11528
|
console.error(`etherpk-mcp: opening graph ${graphId} on ${account.serverBaseUrl}…`);
|
|
9938
11529
|
const graph = await openHeadlessGraph({
|
|
9939
11530
|
graphId,
|
|
@@ -9944,11 +11535,24 @@ async function serve(args) {
|
|
|
9944
11535
|
presenceName: `Agent on ${hostname()}`,
|
|
9945
11536
|
readyTimeoutMs: 2e4,
|
|
9946
11537
|
persistDir: graphCacheDir(process.env, account.serverBaseUrl, graphId),
|
|
9947
|
-
embeddingModel: args
|
|
11538
|
+
embeddingModel: embeddingModelFor(args),
|
|
9948
11539
|
onSemanticProgress: reportSemanticProgress,
|
|
9949
|
-
onError: (error) => console.error(`etherpk-mcp: ${error.message}`)
|
|
11540
|
+
onError: (error) => console.error(`etherpk-mcp: ${error.message}`),
|
|
11541
|
+
publishName: createGraphNamePublisher({
|
|
11542
|
+
api: account.api,
|
|
11543
|
+
keyring,
|
|
11544
|
+
graphId
|
|
11545
|
+
}).publish
|
|
9950
11546
|
});
|
|
9951
|
-
|
|
11547
|
+
return {
|
|
11548
|
+
graph,
|
|
11549
|
+
graphName: graphName ?? graph.name
|
|
11550
|
+
};
|
|
11551
|
+
}
|
|
11552
|
+
/** Speak MCP over stdio for an open graph until the transport closes or a signal arrives. */
|
|
11553
|
+
async function serveGraph(graph, graphName, args) {
|
|
11554
|
+
const semanticReady = await semanticSetupStatus(process.env);
|
|
11555
|
+
const semanticOn = !args["no-semantic"] && semanticReady.runtime && semanticReady.model;
|
|
9952
11556
|
const server = createMcpServer(graph, {
|
|
9953
11557
|
graphName,
|
|
9954
11558
|
version: VERSION
|
|
@@ -10008,6 +11612,7 @@ async function main() {
|
|
|
10008
11612
|
pat: { type: "string" },
|
|
10009
11613
|
"recovery-code": { type: "boolean" },
|
|
10010
11614
|
graph: { type: "string" },
|
|
11615
|
+
folder: { type: "string" },
|
|
10011
11616
|
"no-semantic": { type: "boolean" },
|
|
10012
11617
|
all: { type: "boolean" },
|
|
10013
11618
|
help: {
|