@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/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.4.3",
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$1(text) {
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$1(text),
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 graph's name, or null when it has none yet or the relay could not be reached in time. */
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/types.ts
7854
- var DocumentNotFoundError = class extends Error {
7855
- constructor(target) {
7856
- super(`No document for "${target}"`);
7857
- this.target = target;
7858
- this.name = "DocumentNotFoundError";
7859
- }
7860
- };
7861
- var DocumentSyncDegradedError = class extends Error {
7862
- constructor(target, status) {
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 yield-and-cancel checkpoint (plan: 2026-07-27 Import Progress And Activities).
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 yield point is also the natural cancel checkpoint, so cancellation rides along at
7883
- * no extra cost (ADR 0035 §3): callers thread one `AbortSignal` and get both.
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 createBreather(now = () => performance.now()) {
7893
- let lastYield = now();
7894
- return async (signal) => {
7895
- signal?.throwIfAborted();
7896
- if (now() - lastYield < SLICE_MS) return;
7897
- await new Promise((resolve) => setTimeout(resolve));
7898
- lastYield = now();
7899
- signal?.throwIfAborted();
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
- async function refuseProtectedMerges(plan, isProtected) {
8277
- if (plan.refusal) return plan;
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
- * A step merges when a DIFFERENT document already answers to the target name - by title or by
8293
- * alias - and the source has a document to join to it. Renaming onto yourself (a pure
8294
- * re-casing, or onto one of your own aliases) is neither. A source with no document landing
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 stepFor(from, to, hasDocument, existing) {
8299
- const holder = existing.get(conceptKey(to));
8300
- const taken = holder !== void 0 && conceptKey(holder) !== conceptKey(from);
8301
- return {
8302
- from,
8303
- to,
8304
- hasDocument,
8305
- merges: taken && hasDocument,
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
- /** Open the graph, scan its registry and build the index; resolves once tools can answer. */
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
- let persisting = Promise.resolve();
9041
- let timer;
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
- async settle() {
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 result;
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
- persist,
9098
- semantic,
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 registry entry, case-insensitively. */
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.listIdentities()) {
9235
- if (conceptKey(identity.concept) === wanted) return identity;
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, after its relay catch-up has reached its
9247
- * terminal page. A fresh process has an empty Local Cache, so a cache-seeded read answers ""
9248
- * for every document until the relay has spoken; a tool must never believe that emptiness.
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
- /** After a write: push it and wait for the relay, so the agent's next read anywhere sees it. */
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", `The edit is saved locally but the Sync Server has not acknowledged it (${result.outstanding} outstanding). It will be delivered when the connection recovers.`);
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
- for (const record of graphs) {
9841
- const keyring = vault.keyrings.find((entry) => entry.graphId === record.id);
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 (!wanted) fail("serve needs --graph <id or name>.");
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) for (const record of graphs) {
9918
- const keyring = vault.keyrings.find((entry) => entry.graphId === record.id);
9919
- if (!keyring) continue;
9920
- const name = await readGraphName({
9921
- graphId: record.id,
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["no-semantic"] ? () => Promise.reject(new SemanticUnavailable("Semantic search is off for this agent: serve was started with --no-semantic.")) : () => loadEmbeddingModel(process.env),
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
- graphName ??= graph.sync.getMeta().name ?? graphId;
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: {