@appsoftwareltd/etherpk-mcp 0.8.3 → 0.8.5

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
@@ -34,7 +34,7 @@ import { request } from "node:https";
34
34
  import { BlockList, isIP } from "node:net";
35
35
  var package_default = {
36
36
  name: "@appsoftwareltd/etherpk-mcp",
37
- version: "0.8.3",
37
+ version: "0.8.5",
38
38
  license: "Elastic-2.0",
39
39
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
40
40
  type: "module",
@@ -613,11 +613,12 @@ function createGraphNamePublisher(deps) {
613
613
  //#endregion
614
614
  //#region ../client/src/lib/sync/sync-api.ts
615
615
  var SyncApiError = class extends Error {
616
- constructor(message, status, code, retryable = false) {
616
+ constructor(message, status, code, retryable = false, connectionKind) {
617
617
  super(message);
618
618
  this.status = status;
619
619
  this.code = code;
620
620
  this.retryable = retryable;
621
+ this.connectionKind = connectionKind;
621
622
  }
622
623
  };
623
624
  function createSyncApi(deps) {
@@ -637,7 +638,7 @@ function createSyncApi(deps) {
637
638
  const body = await res.json().catch(() => null);
638
639
  const nested = typeof body?.error === "object" && body.error !== null ? body.error : null;
639
640
  const code = nested?.code ?? body?.code;
640
- throw new SyncApiError(nested?.message ?? body?.code ?? `HTTP ${res.status}`, res.status, code, body?.retryable === true);
641
+ throw new SyncApiError(nested?.message ?? body?.code ?? `HTTP ${res.status}`, res.status, code, body?.retryable === true, deps.kind);
641
642
  }
642
643
  return await res.json();
643
644
  }
@@ -720,7 +721,7 @@ function createSyncApi(deps) {
720
721
  };
721
722
  }
722
723
  //#endregion
723
- //#region ../client/src/lib/sync/sync-config.ts
724
+ //#region ../client/src/lib/sync/sync-connections.ts
724
725
  /** Derive the `ws(s)://…/sync` relay URL from the server base URL. */
725
726
  function relayUrlFrom(serverBaseUrl) {
726
727
  return serverBaseUrl.replace(/\/$/, "").replace(/^http/, "ws") + "/sync";
@@ -2148,19 +2149,31 @@ function flatten(roots) {
2148
2149
  roots.forEach((root, i) => walk(root, null, i));
2149
2150
  return rows;
2150
2151
  }
2151
- /** Derive the index rows for a single document's text. Pure. */
2152
- function deriveDoc(text) {
2152
+ /**
2153
+ * A document's blocks and links, each link tagged with the block holding it, and which block
2154
+ * each source line belongs to (the deepest whose own range covers it). The one reading of a
2155
+ * document's outline that the index rows and {@link blockConceptChain} share.
2156
+ */
2157
+ function blocksAndLinks(text) {
2153
2158
  const blocks = flatten(parseBlocks(text));
2154
2159
  const lineToBlock = /* @__PURE__ */ new Map();
2155
2160
  for (const b of blocks) for (let line = b.startLine; line <= b.endLine; line++) lineToBlock.set(line, b.localId);
2156
- const links = wikilinkOccurrencesInSource(text).map((occ) => ({
2157
- concept: occ.concept,
2158
- line: occ.line,
2159
- lineText: occ.lineText,
2160
- matchStart: occ.matchStart,
2161
- matchEnd: occ.matchEnd,
2162
- blockLocalId: lineToBlock.get(occ.line) ?? null
2163
- }));
2161
+ return {
2162
+ blocks,
2163
+ links: wikilinkOccurrencesInSource(text).map((occ) => ({
2164
+ concept: occ.concept,
2165
+ line: occ.line,
2166
+ lineText: occ.lineText,
2167
+ matchStart: occ.matchStart,
2168
+ matchEnd: occ.matchEnd,
2169
+ blockLocalId: lineToBlock.get(occ.line) ?? null
2170
+ })),
2171
+ lineToBlock
2172
+ };
2173
+ }
2174
+ /** Derive the index rows for a single document's text. Pure. */
2175
+ function deriveDoc(text) {
2176
+ const { blocks, links } = blocksAndLinks(text);
2164
2177
  const candidateTasks = blocks.filter((b) => b.kind === "task");
2165
2178
  const fenced = candidateTasks.length === 0 ? () => false : fencedLines(text);
2166
2179
  const taskBlocks = candidateTasks.filter((b) => !fenced(b.startLine));
@@ -2197,6 +2210,17 @@ function deriveDoc(text) {
2197
2210
  */
2198
2211
  function deriveTaskConcepts(blocks, links, taskBlocks) {
2199
2212
  if (taskBlocks.length === 0) return [];
2213
+ const byBlock = linksByBlock(links);
2214
+ if (byBlock.size === 0) return [];
2215
+ const rows = [];
2216
+ for (const task of taskBlocks) for (const { concept } of conceptChain(blocks, byBlock, task.localId)) rows.push({
2217
+ blockLocalId: task.localId,
2218
+ concept
2219
+ });
2220
+ return rows;
2221
+ }
2222
+ /** The concepts linked on each block, in the order they are written, by block. */
2223
+ function linksByBlock(links) {
2200
2224
  const byBlock = /* @__PURE__ */ new Map();
2201
2225
  for (const link of links) {
2202
2226
  if (link.blockLocalId === null) continue;
@@ -2204,21 +2228,26 @@ function deriveTaskConcepts(blocks, links, taskBlocks) {
2204
2228
  if (existing) existing.push(link.concept);
2205
2229
  else byBlock.set(link.blockLocalId, [link.concept]);
2206
2230
  }
2207
- if (byBlock.size === 0) return [];
2208
- const rows = [];
2209
- for (const task of taskBlocks) {
2210
- const seen = /* @__PURE__ */ new Set();
2211
- for (let id = task.localId; id !== null; id = blocks[id]?.parentId ?? null) for (const concept of byBlock.get(id) ?? []) {
2212
- const key = conceptKey$1(concept);
2213
- if (seen.has(key)) continue;
2214
- seen.add(key);
2215
- rows.push({
2216
- blockLocalId: task.localId,
2217
- concept
2218
- });
2219
- }
2231
+ return byBlock;
2232
+ }
2233
+ /**
2234
+ * The walk under a [[Task Concept]] (ADR 0051): the concepts linked on block `from`, then on each
2235
+ * ancestor up to the root, each named once where it is nearest, whatever its case.
2236
+ */
2237
+ function conceptChain(blocks, byBlock, from) {
2238
+ const out = [];
2239
+ const seen = /* @__PURE__ */ new Set();
2240
+ let depth = 0;
2241
+ for (let id = from; id !== null; id = blocks[id]?.parentId ?? null, depth++) for (const concept of byBlock.get(id) ?? []) {
2242
+ const key = conceptKey$1(concept);
2243
+ if (seen.has(key)) continue;
2244
+ seen.add(key);
2245
+ out.push({
2246
+ concept,
2247
+ depth
2248
+ });
2220
2249
  }
2221
- return rows;
2250
+ return out;
2222
2251
  }
2223
2252
  /**
2224
2253
  * The references a document makes BY ITS NAME: every wikilink nested in the concept string, at
@@ -2243,6 +2272,14 @@ function deriveTitleLinks(concept) {
2243
2272
  matchEnd: occ.matchEnd
2244
2273
  }));
2245
2274
  }
2275
+ /**
2276
+ * The [[Task Concept]]s every task in a document has, whatever its outline (ADR 0051): the
2277
+ * document's own concept, then the [[Scope]]s in its name, outermost first. The index writes these
2278
+ * for every task, and `/kanban` offers them after the block's own (ADR 0113).
2279
+ */
2280
+ function documentTaskConcepts(concept) {
2281
+ return [concept, ...deriveTitleLinks(concept).map((link) => link.concept)];
2282
+ }
2246
2283
  /** The ancestor labels of a block, root-first (the outline-chain breadcrumb). */
2247
2284
  function ancestorChain(blocks, localId) {
2248
2285
  const chain = [];
@@ -2742,7 +2779,7 @@ function createSchema(db) {
2742
2779
  db.exec(SCHEMA$1);
2743
2780
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
2744
2781
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
2745
- db.exec(`PRAGMA user_version = 14`);
2782
+ db.exec(`PRAGMA user_version = 15`);
2746
2783
  }
2747
2784
  function activeIndexGeneration(db) {
2748
2785
  return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
@@ -2763,7 +2800,7 @@ function advanceIndexRevision(db) {
2763
2800
  */
2764
2801
  function isUsableIndex(db) {
2765
2802
  try {
2766
- return db.all("PRAGMA user_version")[0]?.user_version === 14;
2803
+ return db.all("PRAGMA user_version")[0]?.user_version === 15;
2767
2804
  } catch {
2768
2805
  return false;
2769
2806
  }
@@ -2940,13 +2977,17 @@ function insertProperties(db, pageId, doc) {
2940
2977
  * the document's identity. Without that row, filtering to a project page returns nothing for
2941
2978
  * the tasks written directly on it — the case the "Filter to <active tab>" shortcut exists for.
2942
2979
  *
2943
- * Only the document's own concept goes in, never its aliases: the query resolves a filtered
2944
- * name to its page and matches on every name that page answers to, so an alias row here would
2945
- * be redundant now and stale after a rename.
2980
+ * The [[Scope]]s in the document's name sit on that root too (ADR 0051, amended 2026-09-29), so
2981
+ * the tasks on `[[Acme]] Website` answer to Acme. They come from the same scan that makes such a
2982
+ * page a Backlink of its scope (ADR 0083), so a page gives its tasks to exactly the concepts it
2983
+ * is a title reference of.
2984
+ *
2985
+ * Only the names the document is written with go in, never an alias of them: the query resolves
2986
+ * a filtered name to its page and matches on every name that page answers to, so an alias row
2987
+ * here would be redundant now and stale after a rename.
2946
2988
  */
2947
2989
  function insertTaskConcepts(db, pageId, doc, tasks, taskConcepts) {
2948
2990
  if (tasks.length === 0) return;
2949
- const ownKey = conceptKey$1(doc.concept);
2950
2991
  const written = /* @__PURE__ */ new Set();
2951
2992
  const write = (blockLocalId, key) => {
2952
2993
  const seen = `${blockLocalId} ${key}`;
@@ -2958,7 +2999,8 @@ function insertTaskConcepts(db, pageId, doc, tasks, taskConcepts) {
2958
2999
  key
2959
3000
  ]);
2960
3001
  };
2961
- for (const t of tasks) write(t.blockLocalId, ownKey);
3002
+ const rootKeys = documentTaskConcepts(doc.concept).map(conceptKey$1);
3003
+ for (const t of tasks) for (const key of rootKeys) write(t.blockLocalId, key);
2962
3004
  for (const tc of taskConcepts) write(tc.blockLocalId, conceptKey$1(tc.concept));
2963
3005
  }
2964
3006
  /**
@@ -3843,6 +3885,15 @@ var ORDER_SQL = {
3843
3885
  due: `(t.due IS NULL), t.due ASC, (t.priority IS NULL), t.priority ASC`
3844
3886
  };
3845
3887
  /**
3888
+ * What decides a tie the grouping leaves: the document's name, then the task's line, which a
3889
+ * person can see and which survive a rebuild. A folder graph re-derives the whole graph with
3890
+ * fresh page ids on any file change, so ordering by page id let equal tasks swap places in the
3891
+ * [[Tasks View]] and on a [[Kanban Board]] (ADR 0113) between one refresh and the next.
3892
+ * `page_id, block_local_id` come last only to keep the order total, so paging cannot repeat or
3893
+ * skip.
3894
+ */
3895
+ var TIE_BREAK_SQL = "p.concept_key ASC, t.line ASC, t.page_id ASC, t.block_local_id ASC";
3896
+ /**
3846
3897
  * The WHERE clause and parameters shared by {@link tasksMatching} and its count, so the two
3847
3898
  * can never drift into answering different questions about the same filter.
3848
3899
  *
@@ -3911,7 +3962,7 @@ function tasksMatching(db, query, offset, limit) {
3911
3962
  t.priority, t.waiting, t.doing, t.cancelled, t.due, t.completion, t.scheduled
3912
3963
  FROM tasks t JOIN pages p ON p.id = t.page_id
3913
3964
  WHERE ${predicate.where}
3914
- ORDER BY ${ORDER_SQL[query.groupBy]}, t.page_id ASC, t.block_local_id ASC
3965
+ ORDER BY ${ORDER_SQL[query.groupBy]}, ${TIE_BREAK_SQL}
3915
3966
  LIMIT ? OFFSET ?`, [
3916
3967
  ...predicate.params,
3917
3968
  limit + 1,
@@ -3923,30 +3974,35 @@ function tasksMatching(db, query, offset, limit) {
3923
3974
  hits: [],
3924
3975
  hasMore: false
3925
3976
  };
3926
- const breadcrumbs = breadcrumbsFor(db, page);
3977
+ const ancestry = ancestryFor(db, page);
3927
3978
  return {
3928
- hits: page.map((row) => ({
3929
- ...toHit(row),
3930
- breadcrumb: breadcrumbs.get(row.page_id)?.get(row.block_local_id) ?? []
3931
- })),
3979
+ hits: page.map((row) => {
3980
+ const found = ancestry.get(row.page_id)?.get(row.block_local_id);
3981
+ return {
3982
+ ...toHit(row),
3983
+ breadcrumb: found?.breadcrumb ?? [],
3984
+ parentTask: found?.parentTask ?? null
3985
+ };
3986
+ }),
3932
3987
  hasMore
3933
3988
  };
3934
3989
  }
3935
3990
  /**
3936
- * Ancestor labels for a whole page of tasks: one read for every document involved, then the
3937
- * chains walked in JS. The same shape searchText uses, and for the same reason - a recursive
3938
- * SQL walk per row would be one query per task.
3991
+ * Ancestor labels for a whole page of tasks, and each task's nearest task ancestor: one read for
3992
+ * every document involved, then the chains walked in JS. The same shape searchText uses, and for
3993
+ * the same reason - a recursive SQL walk per row would be one query per task.
3939
3994
  */
3940
- function breadcrumbsFor(db, rows) {
3995
+ function ancestryFor(db, rows) {
3941
3996
  const ids = [...new Set(rows.map((row) => row.page_id))];
3942
3997
  const placeholders = ids.map(() => "?").join(",");
3943
- const blocks = db.all(`SELECT page_id, local_id, parent_local_id, label FROM blocks WHERE page_id IN (${placeholders})`, ids);
3998
+ const blocks = db.all(`SELECT page_id, local_id, parent_local_id, kind, label FROM blocks WHERE page_id IN (${placeholders})`, ids);
3944
3999
  const byPage = /* @__PURE__ */ new Map();
3945
4000
  for (const block of blocks) {
3946
4001
  let page = byPage.get(block.page_id);
3947
4002
  if (!page) byPage.set(block.page_id, page = /* @__PURE__ */ new Map());
3948
4003
  page.set(block.local_id, {
3949
4004
  parent_local_id: block.parent_local_id,
4005
+ kind: block.kind,
3950
4006
  label: block.label
3951
4007
  });
3952
4008
  }
@@ -3956,10 +4012,24 @@ function breadcrumbsFor(db, rows) {
3956
4012
  if (!page) continue;
3957
4013
  let chains = out.get(row.page_id);
3958
4014
  if (!chains) out.set(row.page_id, chains = /* @__PURE__ */ new Map());
3959
- chains.set(row.block_local_id, searchBreadcrumb(page, row.block_local_id));
4015
+ chains.set(row.block_local_id, {
4016
+ breadcrumb: searchBreadcrumb(page, row.block_local_id),
4017
+ parentTask: nearestTask(page, row.block_local_id)
4018
+ });
3960
4019
  }
3961
4020
  return out;
3962
4021
  }
4022
+ /** The label of the nearest ancestor of `localId` that is a task, or null. */
4023
+ function nearestTask(blocks, localId) {
4024
+ let parent = blocks.get(localId)?.parent_local_id ?? null;
4025
+ for (let guard = 0; parent !== null && guard <= blocks.size; guard++) {
4026
+ const block = blocks.get(parent);
4027
+ if (!block) break;
4028
+ if (block.kind === "task") return block.label;
4029
+ parent = block.parent_local_id;
4030
+ }
4031
+ return null;
4032
+ }
3963
4033
  /** How many tasks match. Uncapped: this counts an indexed join, not a text index. */
3964
4034
  function tasksMatchingCount(db, query) {
3965
4035
  const predicate = taskPredicate(db, query);
@@ -4270,7 +4340,7 @@ function cacheRoot(env) {
4270
4340
  * recovered from a joined path (see there).
4271
4341
  */
4272
4342
  var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
4273
- var INDEX_FILE_NAME = `index.v14.sqlite`;
4343
+ var INDEX_FILE_NAME = `index.v15.sqlite`;
4274
4344
  var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
4275
4345
  function cacheFile(dir) {
4276
4346
  return join(dir, CACHE_FILE_NAME);
@@ -4299,7 +4369,7 @@ async function openCacheDb() {
4299
4369
  const db = await request$1(indexedDB.open(CACHE_DB, CACHE_DB_VERSION));
4300
4370
  for (const store of CACHE_STORES) if (!db.objectStoreNames.contains(store)) {
4301
4371
  db.close();
4302
- throw new Error(`Local Cache database has no "${store}" store; open the graph cache before restoring it.`);
4372
+ throw new Error(`Local Cache database has no "${store}" store - open the graph cache before restoring it.`);
4303
4373
  }
4304
4374
  return db;
4305
4375
  }
@@ -4587,7 +4657,7 @@ function describeGraphStore(store, now = Date.now()) {
4587
4657
  if (total === 0) return `empty - no passages indexed here yet (snapshot ${age})`;
4588
4658
  if (embedded >= total) return `up to date - ${count} embedded (snapshot ${age})`;
4589
4659
  const percent = Math.floor(embedded / total * 100);
4590
- return ageMs < 9e4 ? `building - ${count} (${percent}%) embedded, snapshot ${age}, refreshed every 30 s while it builds` : `paused - ${count} (${percent}%) embedded, last snapshot ${age}; nothing is building it now, it continues when serve next runs`;
4660
+ return ageMs < 9e4 ? `building - ${count} (${percent}%) embedded, snapshot ${age}, refreshed every 30 s while it builds` : `paused - ${count} (${percent}%) embedded, last snapshot ${age}. Nothing is building it now, it continues when serve next runs`;
4591
4661
  }
4592
4662
  /** Remove every persisted graph under the cache root (logout of the last server). */
4593
4663
  async function removeCacheRoot(env) {
@@ -4784,7 +4854,7 @@ async function setupSemantic(io) {
4784
4854
  const files = io.files ?? MODEL.files;
4785
4855
  const status = await semanticSetupStatus(io.env, files);
4786
4856
  if (!status.runtime) {
4787
- io.say(`Installing ${RUNTIME.name} ${RUNTIME.version} into ${status.runtimeDir} (about 300 MB; native, so it is not part of the etherpk-mcp package)…`);
4857
+ io.say(`Installing ${RUNTIME.name} ${RUNTIME.version} into ${status.runtimeDir} (about 300 MB - native, so it is not part of the etherpk-mcp package)…`);
4788
4858
  await mkdir(status.runtimeDir, {
4789
4859
  recursive: true,
4790
4860
  mode: 448
@@ -4857,7 +4927,7 @@ async function download(fetchFn, file, path) {
4857
4927
  const digest = hash.digest("hex");
4858
4928
  if (digest !== file.sha256) {
4859
4929
  await rm(temp, { force: true });
4860
- throw new Error(`${file.name} downloaded with sha256 ${digest}, expected ${file.sha256}; not kept.`);
4930
+ throw new Error(`${file.name} downloaded with sha256 ${digest}, expected ${file.sha256} - not kept.`);
4861
4931
  }
4862
4932
  await rename(temp, path);
4863
4933
  }
@@ -4879,7 +4949,7 @@ var SemanticUnavailable = class extends Error {
4879
4949
  this.name = "SemanticUnavailable";
4880
4950
  }
4881
4951
  };
4882
- var SETUP_HINT = "run `npx @appsoftwareltd/etherpk-mcp semantic setup` on this computer once (it installs a ~300 MB runtime and a 23 MB model into the cache directory); the next semantic search will use it, no restart needed.";
4952
+ var SETUP_HINT = "run `npx @appsoftwareltd/etherpk-mcp semantic setup` on this computer once (it installs a ~300 MB runtime and a 23 MB model into the cache directory) - the next semantic search will use it, no restart needed.";
4883
4953
  /** The thread count `serve` uses: the option, else the environment, else the conservative default. */
4884
4954
  function semanticThreads(env, requested) {
4885
4955
  if (requested !== void 0) return Math.max(1, Math.floor(requested));
@@ -4900,7 +4970,7 @@ async function loadEmbeddingModel(env, options = {}) {
4900
4970
  const loaded = await import(pathToFileURL(require.resolve(RUNTIME.name)).href);
4901
4971
  ort = loaded.default?.InferenceSession ? loaded.default : loaded;
4902
4972
  } catch (error) {
4903
- throw new SemanticUnavailable(`The embedding runtime in ${status.runtimeDir} failed to load (${error instanceof Error ? error.message : String(error)}); ${SETUP_HINT}`);
4973
+ throw new SemanticUnavailable(`The embedding runtime in ${status.runtimeDir} failed to load (${error instanceof Error ? error.message : String(error)}): ${SETUP_HINT}`);
4904
4974
  }
4905
4975
  const tokenizer = new Tokenizer(JSON.parse(await readFile(join(status.modelDir, "tokenizer.json"), "utf8")), JSON.parse(await readFile(join(status.modelDir, "tokenizer_config.json"), "utf8")));
4906
4976
  const threads = semanticThreads(env, options.threads);
@@ -6882,7 +6952,7 @@ function readPageDate(doc) {
6882
6952
  return { issues: [{
6883
6953
  level: "warning",
6884
6954
  code: "invalid-date",
6885
- message: `"${doc.concept}" has \`date: ${String(date)}\`, which is not a calendar day (YYYY-MM-DD); the document is undated.`,
6955
+ message: `"${doc.concept}" has \`date: ${String(date)}\`, which is not a calendar day (YYYY-MM-DD) - the document is undated.`,
6886
6956
  concept: doc.concept
6887
6957
  }] };
6888
6958
  }
@@ -6911,7 +6981,7 @@ function readPublicationDefinition(doc) {
6911
6981
  };
6912
6982
  }
6913
6983
  if (doc.kind === "journal") {
6914
- issue("publication-on-journal", "A journal entry cannot define a publication; use a page.");
6984
+ issue("publication-on-journal", "A journal entry cannot define a publication - use a page.");
6915
6985
  return {
6916
6986
  publication: null,
6917
6987
  issues
@@ -6944,7 +7014,7 @@ function readPublicationDefinition(doc) {
6944
7014
  else issues.push({
6945
7015
  level: "warning",
6946
7016
  code: "publication-invalid-include",
6947
- message: `Include \`${slot}\` must name the page that fills it; it is ignored.`,
7017
+ message: `Include \`${slot}\` must name the page that fills it - it is ignored.`,
6948
7018
  concept: doc.concept
6949
7019
  });
6950
7020
  }
@@ -7006,7 +7076,7 @@ function discoverPublications(documents) {
7006
7076
  for (const publication of list) issues.push({
7007
7077
  level: "error",
7008
7078
  code: "publication-duplicate-id",
7009
- message: `Two pages define the publication "${id}" (${list.map((p) => `"${p.concept}"`).join(" and ")}); neither is published until one is changed.`,
7079
+ message: `Two pages define the publication "${id}" (${list.map((p) => `"${p.concept}"`).join(" and ")}) - neither is published until one is changed.`,
7010
7080
  concept: publication.concept
7011
7081
  });
7012
7082
  }
@@ -7043,7 +7113,7 @@ function validateThemeManifest(raw) {
7043
7113
  const version = typeof data.version === "string" && data.version.trim() !== "" ? data.version.trim() : "0.0.0";
7044
7114
  const contract = typeof data.contract === "number" && Number.isInteger(data.contract) ? data.contract : null;
7045
7115
  if (contract === null) errors.push("theme.json needs an integer `contract`, the view version the theme was written for.");
7046
- else if (contract > 1) errors.push(`The theme was written for view contract ${contract}; this EtherPK writes contract 1. Update EtherPK, or use an older theme.`);
7116
+ else if (contract > 1) errors.push(`The theme was written for view contract ${contract} - this EtherPK writes contract 1. Update EtherPK, or use an older theme.`);
7047
7117
  const kinds = [];
7048
7118
  if (Array.isArray(data.kinds)) {
7049
7119
  for (const k of data.kinds) if (KINDS.has(k)) kinds.push(k);
@@ -10778,6 +10848,27 @@ function dayIsNotAPageName(day) {
10778
10848
  return `“${day.trim()}” is a date, and a date is the name of that day's journal entry, so a page cannot be called that. Choose a different name.`;
10779
10849
  }
10780
10850
  //#endregion
10851
+ //#region ../client/src/lib/document/line-endings.ts
10852
+ /** `text` with every `\r\n` and every lone `\r` written as `\n`; `text` itself when it holds no `\r`. */
10853
+ function lineFeedsOnly(text) {
10854
+ return text.includes("\r") ? text.replace(/\r\n?/g, "\n") : text;
10855
+ }
10856
+ /**
10857
+ * The changes that turn `text` into {@link lineFeedsOnly}`(text)`, in document order and each
10858
+ * against `text` as it is: one per `\r`, deleting it before a `\n` and replacing it with `\n`
10859
+ * anywhere else. For a writer that must change a shared text in place rather than replace it,
10860
+ * applying them from the last to the first.
10861
+ */
10862
+ function lineFeedChanges(text) {
10863
+ const changes = [];
10864
+ for (let at = text.indexOf("\r"); at !== -1; at = text.indexOf("\r", at + 1)) changes.push({
10865
+ from: at,
10866
+ to: at + 1,
10867
+ insert: text[at + 1] === "\n" ? "" : "\n"
10868
+ });
10869
+ return changes;
10870
+ }
10871
+ //#endregion
10781
10872
  //#region ../client/src/lib/document/wikilink/rename.ts
10782
10873
  /**
10783
10874
  * Rewriting [[Wikilink]] targets across a document's source, for the rewrite arm of a page
@@ -11045,7 +11136,7 @@ var RenameUnconfirmedError = class extends Error {
11045
11136
  name = "RenameUnconfirmedError";
11046
11137
  constructor(concepts) {
11047
11138
  const list = concepts.slice(0, 5).join(", ") + (concepts.length > 5 ? "…" : "");
11048
- super(`${concepts.length === 1 ? "A document" : `${concepts.length} documents`} the rename would change ${concepts.length === 1 ? "has" : "have"} not finished syncing to this device: ${list}. Nothing was renamed; try again once sync has caught up.`);
11139
+ super(`${concepts.length === 1 ? "A document" : `${concepts.length} documents`} the rename would change ${concepts.length === 1 ? "has" : "have"} not finished syncing to this device: ${list}. Nothing was renamed - try again once sync has caught up.`);
11049
11140
  this.concepts = concepts;
11050
11141
  }
11051
11142
  };
@@ -11420,8 +11511,9 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11420
11511
  }
11421
11512
  return entries;
11422
11513
  }
11423
- function notify(doc, text) {
11424
- for (const listener of doc.listeners) listener(text);
11514
+ /** Tell an open document's subscribers its text, all but `except` (the editor that made the edit). */
11515
+ function notify(doc, text, except) {
11516
+ for (const listener of doc.listeners) if (listener !== except) listener(text);
11425
11517
  }
11426
11518
  /** The frontmatter block of `text`, given its parsed body (`''` when there is none). */
11427
11519
  function headOf(text, body) {
@@ -11659,7 +11751,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11659
11751
  return doc.target;
11660
11752
  },
11661
11753
  getText: () => doc.buffer,
11662
- applyChange(change, origin = "editor") {
11754
+ applyChange(change, origin = "editor", editor) {
11663
11755
  doc.buffer = applyTextChange(doc.buffer, change);
11664
11756
  doc.dirty = true;
11665
11757
  if (doc.removed) {
@@ -11668,6 +11760,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11668
11760
  }
11669
11761
  doc.save.call();
11670
11762
  if (origin === "external") notify(doc, doc.buffer);
11763
+ else if (editor) notify(doc, doc.buffer, editor);
11671
11764
  },
11672
11765
  subscribe(listener) {
11673
11766
  doc.listeners.add(listener);
@@ -11675,12 +11768,12 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11675
11768
  }
11676
11769
  };
11677
11770
  doc.ready = adapter.read(doc.subdir, doc.fileName).then((content) => {
11678
- doc.buffer = content.text;
11771
+ doc.buffer = lineFeedsOnly(content.text);
11679
11772
  doc.baseText = content.text;
11680
11773
  doc.lastModified = content.lastModified;
11681
11774
  doc.size = content.size;
11682
11775
  doc.loaded = true;
11683
- notify(doc, content.text);
11776
+ notify(doc, doc.buffer);
11684
11777
  }).catch(async (error) => {
11685
11778
  if (await adapter.exists(doc.subdir, doc.fileName).catch(() => true)) doc.loadError = error;
11686
11779
  else doc.loaded = true;
@@ -11698,7 +11791,14 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11698
11791
  return;
11699
11792
  }
11700
11793
  if (doc.loaded && entry.lastModified === doc.lastModified && entry.size === doc.size) return;
11701
- const { text: diskText, lastModified, size } = await adapter.read(doc.subdir, doc.fileName);
11794
+ followDisk(doc, await adapter.read(doc.subdir, doc.fileName));
11795
+ }
11796
+ /**
11797
+ * Bring an open document in line with its file as just read (`reconcileDecision`): nothing
11798
+ * but the stamps when the file is what the buffer last synced with, a reload of a clean buffer,
11799
+ * a conflict over a dirty one.
11800
+ */
11801
+ function followDisk(doc, { text: diskText, lastModified, size }) {
11702
11802
  const decision = reconcileDecision({
11703
11803
  dirty: doc.dirty,
11704
11804
  baseText: doc.baseText,
@@ -11712,14 +11812,14 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11712
11812
  return;
11713
11813
  }
11714
11814
  if (decision === "reload") {
11715
- doc.buffer = diskText;
11815
+ doc.buffer = lineFeedsOnly(diskText);
11716
11816
  doc.baseText = diskText;
11717
11817
  doc.lastModified = lastModified;
11718
11818
  doc.size = size;
11719
11819
  doc.loaded = true;
11720
11820
  doc.loadError = null;
11721
11821
  doc.dirty = false;
11722
- notify(doc, diskText);
11822
+ notify(doc, doc.buffer);
11723
11823
  emitChange({ concept: conceptOf(doc) });
11724
11824
  return;
11725
11825
  }
@@ -11731,6 +11831,53 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11731
11831
  doc.save.cancel();
11732
11832
  onConflict?.(doc.conflict);
11733
11833
  }
11834
+ /** A file's content, or null when it does not exist; any other failure rejects. */
11835
+ async function readIfPresent(subdir, fileName) {
11836
+ try {
11837
+ return await adapter.read(subdir, fileName);
11838
+ } catch (error) {
11839
+ if (await adapter.exists(subdir, fileName).catch(() => true)) throw error;
11840
+ return null;
11841
+ }
11842
+ }
11843
+ /**
11844
+ * A Formatting Scan's fix to an open document (ADR 0109): through the buffer, so the editor
11845
+ * showing it is told, then saved now. The file is read first: an edit made outside the app
11846
+ * since the buffer last synced is followed, a reload or a conflict over unsaved typing, and
11847
+ * the fix is refused rather than saved over it.
11848
+ */
11849
+ async function spliceOpenDocument(doc, expected, splices) {
11850
+ await doc.ready;
11851
+ await settled(doc);
11852
+ if (doc.removed) return "gone";
11853
+ if (doc.conflict) return "changed";
11854
+ const disk = await readIfPresent(doc.subdir, doc.fileName);
11855
+ if (disk === null || doc.removed) return "gone";
11856
+ if (doc.saving || doc.conflict) return "changed";
11857
+ followDisk(doc, disk);
11858
+ if (doc.conflict || !doc.loaded) return "changed";
11859
+ if (containsCipherFence(doc.buffer)) return "protected";
11860
+ if (doc.buffer !== expected) return "changed";
11861
+ for (let i = splices.length - 1; i >= 0; i--) doc.handle.applyChange(splices[i], "external");
11862
+ await saveNow(doc);
11863
+ return "written";
11864
+ }
11865
+ /** A Formatting Scan's fix to a document nobody has open: read, compare, write (ADR 0109). */
11866
+ async function spliceClosedDocument(entry, expected, splices) {
11867
+ const read = await readIfPresent(entry.subdir, entry.fileName);
11868
+ if (read === null) return "gone";
11869
+ const opened = open.get(entry.key);
11870
+ if (opened) return spliceOpenDocument(opened, expected, splices);
11871
+ if (containsCipherFence(read.text)) return "protected";
11872
+ if (read.text !== expected) return "changed";
11873
+ let text = read.text;
11874
+ for (let i = splices.length - 1; i >= 0; i--) text = applyTextChange(text, splices[i]);
11875
+ const written = await adapter.write(entry.subdir, entry.fileName, text);
11876
+ entry.lastModified = written.lastModified;
11877
+ entry.size = written.size;
11878
+ emitChange({ concept: entry.concept });
11879
+ return "written";
11880
+ }
11734
11881
  return {
11735
11882
  open(target) {
11736
11883
  return openDocNamed(target).handle;
@@ -11785,6 +11932,22 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11785
11932
  emitRegistryOnly();
11786
11933
  }
11787
11934
  },
11935
+ async documentText(target) {
11936
+ const entry = registry.get(conceptKey(target));
11937
+ if (!entry) return null;
11938
+ const doc = open.get(entry.key);
11939
+ if (doc) {
11940
+ await doc.ready;
11941
+ if (doc.loaded) return doc.buffer;
11942
+ }
11943
+ return (await readIfPresent(entry.subdir, entry.fileName))?.text ?? null;
11944
+ },
11945
+ async spliceIfUnchanged(target, expected, splices) {
11946
+ const entry = registry.get(conceptKey(target));
11947
+ if (!entry) return "gone";
11948
+ const doc = open.get(entry.key);
11949
+ return doc ? spliceOpenDocument(doc, expected, splices) : spliceClosedDocument(entry, expected, splices);
11950
+ },
11788
11951
  onChange(listener) {
11789
11952
  changed.add(listener);
11790
11953
  return () => changed.delete(listener);
@@ -11840,13 +12003,13 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11840
12003
  doc.loadError = null;
11841
12004
  if (choice === "take-disk") {
11842
12005
  const { text, lastModified, size } = await adapter.read(doc.subdir, doc.fileName);
11843
- doc.buffer = text;
12006
+ doc.buffer = lineFeedsOnly(text);
11844
12007
  doc.baseText = text;
11845
12008
  doc.lastModified = lastModified;
11846
12009
  doc.size = size;
11847
12010
  doc.dirty = false;
11848
12011
  doc.conflict = null;
11849
- notify(doc, text);
12012
+ notify(doc, doc.buffer);
11850
12013
  } else {
11851
12014
  const res = await adapter.write(doc.subdir, doc.fileName, doc.buffer);
11852
12015
  doc.baseText = doc.buffer;
@@ -12478,7 +12641,17 @@ function createServerDocumentStore(graph, options) {
12478
12641
  },
12479
12642
  getYText(target) {
12480
12643
  const docId = docIdFor(target);
12481
- return docId ? graph.docSync(docId).doc.getText("content") : void 0;
12644
+ if (!docId) return void 0;
12645
+ const ytext = graph.docSync(docId).doc.getText("content");
12646
+ const changes = lineFeedChanges(ytext.toString());
12647
+ if (changes.length > 0) ytext.doc.transact(() => {
12648
+ for (let i = changes.length - 1; i >= 0; i--) {
12649
+ const change = changes[i];
12650
+ ytext.delete(change.from, change.to - change.from);
12651
+ if (change.insert) ytext.insert(change.from, change.insert);
12652
+ }
12653
+ }, STORE);
12654
+ return ytext;
12482
12655
  },
12483
12656
  async whenReady(target) {
12484
12657
  const docId = docIdFor(target);
@@ -12780,6 +12953,28 @@ function createServerDocumentStore(graph, options) {
12780
12953
  registry.set(docId, updated);
12781
12954
  writeBackIdentity(docId);
12782
12955
  },
12956
+ async spliceIfUnchanged(docId, expected, splices, spliceOptions) {
12957
+ if (!registry.has(docId)) return "gone";
12958
+ const materialised = await materialise([docId], spliceOptions?.timeoutMs ?? COLD_CONTENT_TIMEOUT_MS);
12959
+ try {
12960
+ if (!registry.has(docId)) return "gone";
12961
+ if (materialised.unconfirmed.length > 0) return "unconfirmed";
12962
+ const ytext = graph.docSync(docId).doc.getText("content");
12963
+ const text = ytext.toString();
12964
+ if (containsCipherFence(text)) return "protected";
12965
+ if (text !== expected) return "changed";
12966
+ ytext.doc.transact(() => {
12967
+ for (let i = splices.length - 1; i >= 0; i--) {
12968
+ const splice = splices[i];
12969
+ if (splice.to > splice.from) ytext.delete(splice.from, splice.to - splice.from);
12970
+ if (splice.insert) ytext.insert(splice.from, splice.insert);
12971
+ }
12972
+ }, STORE);
12973
+ return "written";
12974
+ } finally {
12975
+ materialised.release();
12976
+ }
12977
+ },
12783
12978
  async reconcile() {},
12784
12979
  async resolveConflict() {},
12785
12980
  async createJournal(date, body = "") {
@@ -13506,6 +13701,7 @@ function assembleHeadlessGraph(parts) {
13506
13701
  };
13507
13702
  return {
13508
13703
  graphId: parts.graphId,
13704
+ clientUrl: parts.clientUrl ?? null,
13509
13705
  name: parts.name,
13510
13706
  store: parts.store,
13511
13707
  index,
@@ -13633,6 +13829,7 @@ async function openHeadlessGraph(deps) {
13633
13829
  }
13634
13830
  return assembleHeadlessGraph({
13635
13831
  graphId: deps.graphId,
13832
+ clientUrl: deps.clientUrl ?? null,
13636
13833
  name: sync.getMeta().name ?? deps.graphId,
13637
13834
  accessLoss: () => sync.accessLoss(),
13638
13835
  store: documents,
@@ -13790,7 +13987,7 @@ async function openHeadlessFolder(deps) {
13790
13987
  for (const conflict of raised) {
13791
13988
  await store.resolveConflict(conflict.target, "take-disk");
13792
13989
  saveErrors.delete(conceptKey(conflict.target));
13793
- warn(`"${conflict.target}" changed on disk while an edit to it could not be written; took the file on disk and discarded the edit.`);
13990
+ warn(`"${conflict.target}" changed on disk while an edit to it could not be written - took the file on disk and discarded the edit.`);
13794
13991
  }
13795
13992
  })();
13796
13993
  const pass = reconciling;
@@ -13969,7 +14166,7 @@ async function openHeadlessFolder(deps) {
13969
14166
  /** The account has no vault yet, so there is nothing for a Recovery Code to open. */
13970
14167
  var NoVaultError = class extends Error {
13971
14168
  constructor() {
13972
- super("This account has no encryption keys yet; there is nothing for a Recovery Code to open.");
14169
+ super("This account has no encryption keys yet - there is nothing for a Recovery Code to open.");
13973
14170
  this.name = "NoVaultError";
13974
14171
  }
13975
14172
  };
@@ -13985,7 +14182,7 @@ async function openVaultWithRecoveryCode(api, code) {
13985
14182
  try {
13986
14183
  return (await openVault(fromBase64Url(stored.vault), wrapKey)).vaultKey;
13987
14184
  } catch (error) {
13988
- if (error instanceof EnvelopeError) throw new RecoveryCodeError("That Recovery Code does not open this account’s keys. Check it character by character; only the most recently issued code works.");
14185
+ if (error instanceof EnvelopeError) throw new RecoveryCodeError("That Recovery Code does not open this account’s keys. Check it character by character - only the most recently issued code works.");
13989
14186
  throw error;
13990
14187
  }
13991
14188
  }
@@ -14114,7 +14311,7 @@ async function unlockByDeviceApproval(api, io) {
14114
14311
  const where = io.clientUrl ? `open EtherPK at ${io.clientUrl}` : "open EtherPK in a browser";
14115
14312
  io.say("");
14116
14313
  io.say(`To approve this device, ${where} (any page - it need not be a note), connected to this account`);
14117
- io.say("with its keys unlocked. A prompt will show a code; confirm it matches this one:");
14314
+ io.say("with its keys unlocked. A prompt will show a code - confirm it matches this one:");
14118
14315
  io.say("");
14119
14316
  io.say(` ${request.sas}`);
14120
14317
  io.say("");
@@ -14337,7 +14534,7 @@ var files = /* @__PURE__ */ Object.assign({
14337
14534
  "../etherpk-al-folio/assets/theme.js": "/*\n * The light / dark toggle: a `.theme-toggle` button flips the page between the two and the\n * choice is remembered in this browser; with no choice made the page follows the system. The\n * stylesheet keys on `data-theme` on <html>, and shell-top applies the remembered choice before\n * the first paint so a dark reader never sees a light flash.\n */\n(function () {\n 'use strict';\n var KEY = 'etherpk-theme';\n var root = document.documentElement;\n function isDark() {\n var chosen = root.getAttribute('data-theme');\n if (chosen === 'dark' || chosen === 'light') return chosen === 'dark';\n return window.matchMedia('(prefers-color-scheme: dark)').matches;\n }\n function apply(theme) {\n if (theme === 'dark' || theme === 'light') root.setAttribute('data-theme', theme);\n else root.removeAttribute('data-theme');\n var buttons = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < buttons.length; i++) buttons[i].setAttribute('aria-pressed', isDark() ? 'true' : 'false');\n }\n try { apply(localStorage.getItem(KEY)); } catch (_e) { apply(null); }\n var toggles = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < toggles.length; i++) {\n toggles[i].addEventListener('click', function () {\n var next = isDark() ? 'light' : 'dark';\n apply(next);\n try { localStorage.setItem(KEY, next); } catch (_e) { /* private mode: the choice lasts the page */ }\n });\n }\n})();\n",
14338
14535
  "../etherpk-al-folio/layouts/404.html": "{{> shell-top}}\n <div class=\"post\">\n <header class=\"post-header\"><h1 class=\"post-title\">Page not found</h1></header>\n <div class=\"post-content\">\n <p>The page you followed a link to is not part of this site. <a href=\"index.html\">Back to {{site.title}}</a>.</p>\n </div>\n </div>\n{{> shell-bottom}}\n",
14339
14536
  "../etherpk-al-folio/layouts/archive.html": "{{> shell-top}}\n <div class=\"post\">\n <header class=\"post-header\"><h1 class=\"post-title\">{{#page.isPostsArchive}}posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}journal{{/page.isJournalArchive}}</h1></header>\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <h2 class=\"archive-year\" id=\"y{{year}}\">{{year}}</h2>\n <ul class=\"post-list archive-list\">\n {{#posts}}<li><time datetime=\"{{date}}\">{{date}}</time> <a href=\"{{url}}\">{{title}}</a></li>{{/posts}}\n </ul>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n <ul class=\"post-list\">\n {{#site.journals}}\n <li>\n <h3><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n <p class=\"post-meta\"><time datetime=\"{{date}}\">{{date}}</time></p>\n </li>\n {{/site.journals}}\n </ul>\n {{/page.isJournalArchive}}\n </div>\n{{> shell-bottom}}\n",
14340
- "../etherpk-al-folio/layouts/home.html": "{{> shell-top}}\n <article class=\"post about\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{^page.isGeneratedIndex}}{{{page.titleHtml}}}{{/page.isGeneratedIndex}}{{#page.isGeneratedIndex}}{{site.title}}{{/page.isGeneratedIndex}}</h1>\n </header>\n {{^page.isGeneratedIndex}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}{{^site.isBlog}}\n <div class=\"post-content page-contents\">\n{{{page.content}}}\n </div>\n {{/site.isBlog}}{{/page.isGeneratedIndex}}\n {{#site.hasJournals}}\n <section class=\"news\">\n <h2><a href=\"journal.html\">news</a></h2>\n <table class=\"news-table\">\n {{#site.journals}}\n <tr>\n <th scope=\"row\"><time datetime=\"{{date}}\">{{date}}</time></th>\n <td><a href=\"{{url}}\">{{title}}</a>{{#excerpt}} <span class=\"news-excerpt\">{{excerpt}}</span>{{/excerpt}}</td>\n </tr>\n {{/site.journals}}\n </table>\n </section>\n {{/site.hasJournals}}\n {{#site.isBlog}}\n <section class=\"posts\">\n <h2>posts</h2>\n {{#site.hasPosts}}\n <ul class=\"post-list\">\n {{#site.recentPosts}}\n <li>\n <h3><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n <p class=\"post-meta\"><time datetime=\"{{date}}\">{{date}}</time></p>\n </li>\n {{/site.recentPosts}}\n </ul>\n <p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">all {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}\n {{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}\n </section>\n {{/site.isBlog}}\n </article>\n{{> shell-bottom}}\n",
14537
+ "../etherpk-al-folio/layouts/home.html": "{{> shell-top}}\n <article class=\"post about\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{^page.isGeneratedIndex}}{{{page.titleHtml}}}{{/page.isGeneratedIndex}}{{#page.isGeneratedIndex}}{{site.title}}{{/page.isGeneratedIndex}}</h1>\n </header>\n {{^page.isGeneratedIndex}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}{{^site.isBlog}}\n <div class=\"post-content page-contents\">\n{{{page.content}}}\n </div>\n {{/site.isBlog}}{{/page.isGeneratedIndex}}\n {{#site.hasJournals}}\n <section class=\"news\">\n <h2><a href=\"journal.html\">news</a></h2>\n <table class=\"news-table\">\n {{#site.journals}}\n <tr>\n <th scope=\"row\"><time datetime=\"{{date}}\">{{date}}</time></th>\n <td><a href=\"{{url}}\">{{title}}</a>{{#excerpt}} <span class=\"news-excerpt\">{{excerpt}}</span>{{/excerpt}}</td>\n </tr>\n {{/site.journals}}\n </table>\n </section>\n {{/site.hasJournals}}\n {{#site.isBlog}}\n <section class=\"posts\">\n <h2>posts</h2>\n {{#site.hasPosts}}\n <ul class=\"post-list\">\n {{#site.recentPosts}}\n <li>\n <h3><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n <p class=\"post-meta\"><time datetime=\"{{date}}\">{{date}}</time></p>\n </li>\n {{/site.recentPosts}}\n </ul>\n <p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">all {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}\n {{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}\n </section>\n {{/site.isBlog}}\n </article>\n{{> shell-bottom}}\n",
14341
14538
  "../etherpk-al-folio/layouts/journal.html": "{{> shell-top}}\n <article class=\"post\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n <p class=\"post-meta\">{{#page.hasDate}}<time datetime=\"{{page.date}}\">{{page.date}}</time> · {{/page.hasDate}}{{page.readingMinutes}} min read</p>\n </header>\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <nav class=\"post-nav\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"older\" href=\"{{url}}\">← {{title}}</a>{{/page.older}}\n {{#page.newer}}<a class=\"newer\" href=\"{{url}}\">{{title}} →</a>{{/page.newer}}\n </nav>\n </article>\n{{> shell-bottom}}\n",
14342
14539
  "../etherpk-al-folio/layouts/page.html": "{{> shell-top}}\n <article class=\"post\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n <p class=\"post-meta\">{{#page.hasDate}}<time datetime=\"{{page.date}}\">{{page.date}}</time> · {{/page.hasDate}}{{page.readingMinutes}} min read</p>\n </header>\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <nav class=\"post-nav\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"older\" href=\"{{url}}\">← {{title}}</a>{{/page.older}}\n {{#page.newer}}<a class=\"newer\" href=\"{{url}}\">{{title}} →</a>{{/page.newer}}\n </nav>\n </article>\n{{> shell-bottom}}\n",
14343
14540
  "../etherpk-al-folio/partials/after-content.html": "",
@@ -14358,7 +14555,7 @@ var files = /* @__PURE__ */ Object.assign({
14358
14555
  "../etherpk-blog/assets/theme.css": "/* EtherPK blog theme. Tokens follow the app's own (agent blue, the gk surface scale); the font\n is the system stack so the site loads nothing from anywhere. Dark mode follows the reader's\n preference. Classes the publisher emits and this file styles: .wikilink, .wikilink-missing,\n .task, .task-tag, .toc, .backlinks, .site-nav-list, .diagram, .math, .tok-* (code). */\n\n:root {\n --font-sans: \"Inter\", system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n --agent-500: #007acc;\n --agent-600: #005f9e;\n --agent-100: #c2e3ff;\n --surface-0: #ffffff;\n --surface-1: #f9fafb;\n --surface-2: #e5e7eb;\n --text-strong: #030712;\n --text-default: #374151;\n --text-muted: #6b7280;\n --border-soft: rgba(3, 7, 18, 0.08);\n --border-strong: rgba(3, 7, 18, 0.16);\n --mark: #fff3a3;\n --missing: #b45309;\n --nav-width: 17rem;\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --agent-500: #5ab3ff;\n --agent-600: #94ccff;\n --agent-100: #002e4f;\n --surface-0: #18181b;\n --surface-1: #1c1c20;\n --surface-2: #2a2a30;\n --text-strong: #fafafa;\n --text-default: #d4d4d8;\n --text-muted: #a1a1aa;\n --border-soft: rgba(255, 255, 255, 0.08);\n --border-strong: rgba(255, 255, 255, 0.16);\n --mark: #6b5a00;\n --missing: #f59e0b;\n }\n}\n\n*, *::before, *::after { box-sizing: border-box; }\nhtml { font-family: var(--font-sans); -webkit-font-smoothing: antialiased; }\nbody {\n margin: 0;\n color: var(--text-default);\n background: var(--surface-0);\n line-height: 1.6;\n font-size: 16px;\n}\na { color: var(--agent-500); }\na:hover { color: var(--agent-600); }\n.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.skip-link { position: absolute; left: -999px; top: 0; background: var(--surface-1); padding: 0.5rem 1rem; z-index: 100; }\n.skip-link:focus { left: 1rem; top: 1rem; }\n\n/* -- Header ----------------------------------------------------------------------------- */\n.site-header {\n display: flex;\n align-items: center;\n gap: 1rem;\n padding: 0.75rem 1.5rem;\n border-bottom: 1px solid var(--border-soft);\n background: var(--surface-1);\n position: sticky;\n top: 0;\n z-index: 20;\n}\n.site-title { display: inline-flex; align-items: center; gap: 0.5rem; min-width: 0; font-weight: 600; color: var(--text-strong); text-decoration: none; font-size: 1.05rem; }\n.site-title:hover { color: var(--agent-500); }\n.site-logo { flex-shrink: 0; }\n.site-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.site-search { position: relative; margin-left: auto; flex: 0 1 22rem; }\n.site-search input {\n width: 100%;\n padding: 0.45rem 0.75rem;\n border: 1px solid var(--border-strong);\n border-radius: 0.5rem;\n background: var(--surface-0);\n color: var(--text-default);\n font: inherit;\n}\n.site-search input:focus { outline: 2px solid var(--agent-500); outline-offset: 1px; }\n.site-search-results {\n position: absolute;\n top: calc(100% + 0.35rem);\n left: 0;\n right: 0;\n max-height: 24rem;\n overflow: auto;\n background: var(--surface-0);\n border: 1px solid var(--border-strong);\n border-radius: 0.5rem;\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);\n z-index: 30;\n}\n.site-search-hit { display: block; padding: 0.55rem 0.75rem; text-decoration: none; color: var(--text-default); border-bottom: 1px solid var(--border-soft); }\n.site-search-hit:hover, .site-search-hit:focus { background: var(--surface-1); }\n.site-search-title { display: block; font-weight: 600; color: var(--text-strong); }\n.site-search-snippet { display: block; font-size: 0.9rem; color: var(--text-muted); }\n.site-search-empty { margin: 0; padding: 0.6rem 0.75rem; color: var(--text-muted); }\n\n/* -- Shell: top navigation, centred column --------------------------------------------- */\n.site-header { display: block; padding: 1rem 1.5rem 0; position: static; }\n.site-header-row { display: flex; align-items: center; gap: 1rem; max-width: 46rem; margin: 0 auto; }\n.site-title { font-size: 1.35rem; }\n.site-nav { max-width: 46rem; margin: 0.75rem auto 0; }\n.site-nav-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; }\n.site-nav-list .site-nav-list { display: block; padding-left: 0.75rem; }\n.site-nav a, .site-nav .nav-group-label { color: var(--text-default); text-decoration: none; font-size: 0.95rem; }\n.site-nav a:hover { color: var(--text-strong); }\n.site-nav .nav-current > a { color: var(--agent-500); font-weight: 600; }\n.site-nav .nav-group-label { font-weight: 600; color: var(--text-strong); }\n.site-nav a .wikilink { color: inherit; text-decoration: none; }\n.nav-toggle, .nav-backdrop { display: none; }\n\n.content { padding: 2rem 1.5rem 4rem; max-width: 46rem; margin: 0 auto; }\n.post-header h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 0.5rem; color: var(--text-strong); }\n.post-header h1 .wikilink { color: inherit; text-decoration: none; }\n.post-header h1 .wikilink:hover { text-decoration: underline; }\n.post-date { color: var(--text-muted); font-size: 0.95rem; }\n.post-body h2 { margin-top: 2rem; font-size: 1.4rem; color: var(--text-strong); }\n.post-body h3 { margin-top: 1.5rem; font-size: 1.15rem; color: var(--text-strong); }\n.post-body ul { padding-left: 1.4rem; }\n.post-body li { margin: 0.2rem 0; }\n.post-body img { max-width: 100%; height: auto; border-radius: 0.35rem; }\n.post-body table { border-collapse: collapse; margin: 1rem 0; width: 100%; }\n.post-body th, .post-body td { border: 1px solid var(--border-strong); padding: 0.4rem 0.6rem; text-align: left; }\n.post-body th { background: var(--surface-1); }\n.post-body blockquote { border-left: 3px solid var(--border-strong); margin: 1rem 0; padding: 0.25rem 1rem; color: var(--text-muted); }\n.post-body code { font-family: var(--font-mono); font-size: 0.9em; background: var(--surface-1); padding: 0.1em 0.35em; border-radius: 0.25rem; }\n.post-body pre { background: var(--surface-1); border: 1px solid var(--border-soft); border-radius: 0.5rem; padding: 0.9rem 1rem; overflow-x: auto; }\n.post-body pre code { background: none; padding: 0; font-size: 0.88rem; }\nmark { background: var(--mark); color: inherit; padding: 0 0.15em; border-radius: 0.2em; }\n\n/* Wikilinks, as the editor colours them. A missing target is visibly missing. */\na.wikilink { text-decoration: none; border-bottom: 1px solid var(--agent-100); }\na.wikilink:hover { border-bottom-color: var(--agent-500); }\na.wikilink-missing { color: var(--missing); text-decoration: none; border-bottom: 1px dashed var(--missing); }\n\n/* Tasks and their badges. */\nli.task { list-style: none; margin-left: -1.4rem; padding-left: 0; }\nli.task input[type=\"checkbox\"] { margin-right: 0.35rem; vertical-align: -0.1em; }\nli.task.done { color: var(--text-muted); }\n.task-tag { display: inline-block; font-size: 0.8rem; font-weight: 600; padding: 0.05em 0.5em; border-radius: 999px; background: var(--surface-2); color: var(--text-strong); margin-right: 0.25rem; vertical-align: 0.1em; }\n.task-tag.priority-1 { background: #fee2e2; color: #991b1b; }\n.task-tag.priority-2 { background: #ffedd5; color: #9a3412; }\n.task-tag.priority-3 { background: #fef9c3; color: #854d0e; }\n.task-tag.waiting, .task-tag.doing { background: var(--agent-100); color: var(--agent-600); }\n.task-tag.cancelled { text-decoration: line-through; }\n\n/* Table of contents, diagrams, maths. */\n.toc { margin: 0 0 1.5rem; font-size: 0.95rem; }\n.toc summary { cursor: pointer; font-weight: 600; color: var(--text-strong); }\n.toc ul { list-style: none; padding-left: 0; margin: 0.4rem 0 0; }\n.toc li.toc-level-3 { padding-left: 1rem; }\n.toc li.toc-level-4 { padding-left: 2rem; }\n.diagram { margin: 1rem 0; text-align: center; }\n.diagram svg { max-width: 100%; height: auto; }\n.math-block { overflow-x: auto; margin: 1rem 0; }\n\n/* Linked references, beneath the content. */\n.backlinks { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border-soft); }\n.backlinks h2 { font-size: 1.1rem; color: var(--text-strong); margin: 0 0 0.75rem; }\n.backlink-list { list-style: none; padding: 0; margin: 0; }\n.backlink-source { margin: 0 0 0.9rem; }\n.backlink-source > a { font-weight: 600; }\n.backlink-source ul { list-style: none; padding-left: 1rem; margin: 0.25rem 0 0; border-left: 2px solid var(--border-soft); }\n.backlink-context { color: var(--text-muted); font-size: 0.95rem; margin: 0.15rem 0; }\n\n/* Archive and index lists. */\n.journal-archive, .page-index-list { list-style: none; padding: 0; }\n.journal-archive li { margin: 0 0 1rem; }\n.journal-archive time { color: var(--text-muted); margin-right: 0.5rem; font-variant-numeric: tabular-nums; }\n.archive-excerpt { margin: 0.2rem 0 0; color: var(--text-muted); }\n.archive-year { margin: 1.5rem 0 0.5rem; font-size: 1.1rem; }\n.all-posts { margin: 1.5rem 0 0; }\n\n.site-footer { border-top: 1px solid var(--border-soft); padding: 1rem 1.5rem; color: var(--text-muted); font-size: 0.9rem; text-align: center; }\n\n/* Code tokens (lezer classHighlighter names). */\n.tok-keyword { color: #0a7; }\n.tok-string, .tok-string2 { color: #690; }\n.tok-comment, .tok-lineComment, .tok-blockComment { color: #999; font-style: italic; }\n.tok-number, .tok-bool, .tok-null, .tok-atom { color: #905; }\n.tok-function, .tok-definition.tok-variableName { color: #c60; }\n.tok-typeName, .tok-className, .tok-namespace { color: #b58900; }\n.tok-operator, .tok-punctuation { color: #555; }\n.tok-propertyName, .tok-attributeName { color: #268bd2; }\n.tok-tagName { color: #22863a; }\n.tok-invalid { color: #d00; }\n@media (prefers-color-scheme: dark) {\n .tok-keyword { color: #4ec9b0; }\n .tok-string, .tok-string2 { color: #b5cea8; }\n .tok-comment, .tok-lineComment, .tok-blockComment { color: #7f848e; }\n .tok-number, .tok-bool, .tok-null, .tok-atom { color: #d19a66; }\n .tok-function, .tok-definition.tok-variableName { color: #dcdcaa; }\n .tok-typeName, .tok-className, .tok-namespace { color: #e5c07b; }\n .tok-operator, .tok-punctuation { color: #abb2bf; }\n .tok-propertyName, .tok-attributeName { color: #61afef; }\n .tok-tagName { color: #98c379; }\n}\n\n/* -- Posts ------------------------------------------------------------------------------ */\n.post-summary { margin: 0 0 2rem; }\n.post-summary h2 { margin: 0 0 0.2rem; font-size: 1.35rem; }\n.post-summary h2 a { color: var(--text-strong); text-decoration: none; }\n.post-summary h2 a:hover { color: var(--agent-500); }\n.post-summary time { color: var(--text-muted); font-size: 0.9rem; font-variant-numeric: tabular-nums; }\n.post-summary p { margin: 0.4rem 0 0; color: var(--text-default); }\n.home-intro { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-soft); }\n.post-list-empty { color: var(--text-muted); }\n@media (max-width: 40rem) {\n .site-header-row { flex-wrap: wrap; }\n .site-search { flex-basis: 100%; margin-left: 0; }\n .content { padding: 1.25rem 1rem 3rem; }\n}\n",
14359
14556
  "../etherpk-blog/layouts/404.html": "{{> shell-top}}\n <article class=\"post not-published\">\n <header class=\"post-header\">\n <h1>Not published</h1>\n </header>\n <div class=\"post-body\">\n <p>The page you followed a link to is not part of this site.</p>\n <p><a href=\"index.html\">Back to {{site.title}}</a></p>\n </div>\n </article>\n{{> shell-bottom}}\n",
14360
14557
  "../etherpk-blog/layouts/archive.html": "{{> shell-top}}\n <article class=\"post archive\">\n <header class=\"post-header\">\n <h1>{{#page.isPostsArchive}}Posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}Journal{{/page.isJournalArchive}}</h1>\n </header>\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <h2 class=\"archive-year\" id=\"y{{year}}\">{{year}}</h2>\n <ul class=\"journal-archive\">\n {{#posts}}\n <li>\n <time datetime=\"{{date}}\">{{date}}</time>\n <a href=\"{{url}}\">{{title}}</a>\n </li>\n {{/posts}}\n </ul>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n <ul class=\"journal-archive\">\n {{#site.journals}}\n <li>\n <time datetime=\"{{date}}\">{{date}}</time>\n <a href=\"{{url}}\">{{title}}</a>\n {{#excerpt}}<p class=\"archive-excerpt\">{{excerpt}}</p>{{/excerpt}}\n </li>\n {{/site.journals}}\n </ul>\n {{/page.isJournalArchive}}\n </article>\n{{> shell-bottom}}\n",
14361
- "../etherpk-blog/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <section class=\"home-intro\">\n <div class=\"post-body\">\n{{{page.content}}}\n </div>\n </section>\n {{/page.isGeneratedIndex}}\n <section class=\"post-list\" aria-label=\"Posts\">\n {{#site.hasPosts}}\n {{#site.recentPosts}}\n <article class=\"post-summary\">\n <h2><a href=\"{{url}}\">{{title}}</a></h2>\n <time datetime=\"{{date}}\">{{date}}</time>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n </article>\n {{/site.recentPosts}}\n <p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}\n {{^site.hasPosts}}\n <p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>\n {{/site.hasPosts}}\n </section>\n{{> shell-bottom}}\n",
14558
+ "../etherpk-blog/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <section class=\"home-intro\">\n <div class=\"post-body\">\n{{{page.content}}}\n </div>\n </section>\n {{/page.isGeneratedIndex}}\n <section class=\"post-list\" aria-label=\"Posts\">\n {{#site.hasPosts}}\n {{#site.recentPosts}}\n <article class=\"post-summary\">\n <h2><a href=\"{{url}}\">{{title}}</a></h2>\n <time datetime=\"{{date}}\">{{date}}</time>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n </article>\n {{/site.recentPosts}}\n <p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}\n {{^site.hasPosts}}\n <p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>\n {{/site.hasPosts}}\n </section>\n{{> shell-bottom}}\n",
14362
14559
  "../etherpk-blog/layouts/journal.html": "{{> shell-top}}\n <article class=\"post\">\n <header class=\"post-header\">\n <h1>{{{page.titleHtml}}}</h1>\n {{#page.hasDate}}<time class=\"post-date\" datetime=\"{{page.date}}\">{{page.date}}</time>{{/page.hasDate}}\n </header>\n <div class=\"post-body\">\n{{{page.content}}}\n </div>\n </article>\n{{> shell-bottom}}\n",
14363
14560
  "../etherpk-blog/layouts/page.html": "{{> shell-top}}\n <article class=\"post\">\n <header class=\"post-header\">\n <h1>{{{page.titleHtml}}}</h1>\n {{#page.hasDate}}<time class=\"post-date\" datetime=\"{{page.date}}\">{{page.date}}</time>{{/page.hasDate}}\n </header>\n <div class=\"post-body\">\n{{{page.content}}}\n </div>\n </article>\n{{> shell-bottom}}\n",
14364
14561
  "../etherpk-blog/partials/after-content.html": "",
@@ -14386,7 +14583,7 @@ var files = /* @__PURE__ */ Object.assign({
14386
14583
  "../etherpk-docs/partials/before-content.html": "",
14387
14584
  "../etherpk-docs/partials/footer.html": " <footer class=\"site-footer\">\n <p>{{site.title}} · published with <a href=\"https://etherpk.com\" rel=\"noopener\">EtherPK</a></p>\n </footer>\n",
14388
14585
  "../etherpk-docs/partials/head.html": "",
14389
- "../etherpk-docs/partials/header.html": " <header class=\"site-header\">\n <a class=\"site-title\" href=\"index.html\">{{> logo}}<span class=\"site-title-text\">{{site.title}}</span></a>\n <form class=\"site-search\" role=\"search\" data-search-index=\"{{site.search.url}}\" data-search-script=\"{{site.search.scriptUrl}}\">\n <label class=\"visually-hidden\" for=\"site-search-input\">Search</label>\n <input id=\"site-search-input\" type=\"search\" name=\"q\" placeholder=\"Search\" autocomplete=\"off\" aria-describedby=\"site-search-hint\">\n <span id=\"site-search-hint\" class=\"visually-hidden\">Type to search the published pages; results appear below the box.</span>\n <div class=\"site-search-results\" role=\"listbox\" hidden></div>\n </form>\n </header>\n",
14586
+ "../etherpk-docs/partials/header.html": " <header class=\"site-header\">\n <a class=\"site-title\" href=\"index.html\">{{> logo}}<span class=\"site-title-text\">{{site.title}}</span></a>\n <form class=\"site-search\" role=\"search\" data-search-index=\"{{site.search.url}}\" data-search-script=\"{{site.search.scriptUrl}}\">\n <label class=\"visually-hidden\" for=\"site-search-input\">Search</label>\n <input id=\"site-search-input\" type=\"search\" name=\"q\" placeholder=\"Search\" autocomplete=\"off\" aria-describedby=\"site-search-hint\">\n <span id=\"site-search-hint\" class=\"visually-hidden\">Type to search the published pages - results appear below the box.</span>\n <div class=\"site-search-results\" role=\"listbox\" hidden></div>\n </form>\n </header>\n",
14390
14587
  "../etherpk-docs/partials/logo.html": "<!-- The EtherPK icon (resources/brand/etherpk-icon-*.svg), inline so the page fetches nothing and it takes the text colour; aria-hidden because the title beside it is the name. Fill the \"logo\" include, or edit this partial, for your own. -->\n<svg class=\"site-logo\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 191.82 166.13\" width=\"24\" height=\"21\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M67.09,132.92,47.93,166.13H9.59L0,149.52,86.33,0H105.5L19.17,149.52H38.34l19.17-33.2h76.8l9.59,16.6Zm48-83.12,9.65,16.71H143.9L115.08,16.6q-24,41.57-48,83.12H143.9q14.37,24.9,28.76,49.8h-96l-9.59,16.61H182.24l9.58-16.61-38.34-66.4H96C95.85,82.9,113.39,52.73,115.08,49.8Z\"/></svg>\n",
14391
14588
  "../etherpk-docs/partials/nav-item.html": " <li class=\"{{#current}}nav-current {{/current}}{{#open}}nav-open {{/open}}{{^href}}nav-group{{/href}}\">\n {{#href}}<a href=\"{{href}}\"{{#current}} aria-current=\"page\"{{/current}}{{#external}} rel=\"noopener\"{{/external}}>{{label}}</a>{{/href}}\n {{^href}}<span class=\"nav-group-label\">{{{labelHtml}}}</span>{{/href}}\n {{#hasChildren}}<ul class=\"site-nav-list\">{{#children}}{{> nav-item}}{{/children}}</ul>{{/hasChildren}}\n </li>\n",
14392
14589
  "../etherpk-docs/partials/nav.html": " <nav id=\"site-nav\" class=\"site-nav\" aria-label=\"Site\">\n <ul class=\"site-nav-list\">\n{{#nav}}{{> nav-item}}{{/nav}}\n </ul>\n </nav>\n",
@@ -14401,7 +14598,7 @@ var files = /* @__PURE__ */ Object.assign({
14401
14598
  "../etherpk-papermod/assets/theme.js": "/*\n * The light / dark toggle: a `.theme-toggle` button flips the page between the two and the\n * choice is remembered in this browser; with no choice made the page follows the system. The\n * stylesheet keys on `data-theme` on <html>, and shell-top applies the remembered choice before\n * the first paint so a dark reader never sees a light flash.\n */\n(function () {\n 'use strict';\n var KEY = 'etherpk-theme';\n var root = document.documentElement;\n function isDark() {\n var chosen = root.getAttribute('data-theme');\n if (chosen === 'dark' || chosen === 'light') return chosen === 'dark';\n return window.matchMedia('(prefers-color-scheme: dark)').matches;\n }\n function apply(theme) {\n if (theme === 'dark' || theme === 'light') root.setAttribute('data-theme', theme);\n else root.removeAttribute('data-theme');\n var buttons = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < buttons.length; i++) buttons[i].setAttribute('aria-pressed', isDark() ? 'true' : 'false');\n }\n try { apply(localStorage.getItem(KEY)); } catch (_e) { apply(null); }\n var toggles = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < toggles.length; i++) {\n toggles[i].addEventListener('click', function () {\n var next = isDark() ? 'light' : 'dark';\n apply(next);\n try { localStorage.setItem(KEY, next); } catch (_e) { /* private mode: the choice lasts the page */ }\n });\n }\n})();\n",
14402
14599
  "../etherpk-papermod/layouts/404.html": "{{> shell-top}}\n <div class=\"not-found\">\n <h1>404</h1>\n <p>This page is not published. <a href=\"index.html\">Back to {{site.title}}</a>.</p>\n </div>\n{{> shell-bottom}}\n",
14403
14600
  "../etherpk-papermod/layouts/archive.html": "{{> shell-top}}\n <header class=\"page-header\"><h1>{{#page.isPostsArchive}}Posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}Journal{{/page.isJournalArchive}}</h1></header>\n <div class=\"archive-posts\">\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <div class=\"archive-year\">\n <h2 class=\"archive-year-header\" id=\"y{{year}}\">{{year}}</h2>\n {{#posts}}\n <div class=\"archive-entry\">\n <h3 class=\"archive-entry-title\"><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<div class=\"archive-entry-content\">{{excerpt}}</div>{{/excerpt}}\n <footer class=\"entry-footer\"><time datetime=\"{{date}}\">{{date}}</time></footer>\n </div>\n {{/posts}}\n </div>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n {{#site.journals}}\n <div class=\"archive-entry\">\n <h3 class=\"archive-entry-title\"><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<div class=\"archive-entry-content\">{{excerpt}}</div>{{/excerpt}}\n <footer class=\"entry-footer\"><time datetime=\"{{date}}\">{{date}}</time></footer>\n </div>\n {{/site.journals}}\n {{/page.isJournalArchive}}\n </div>\n{{> shell-bottom}}\n",
14404
- "../etherpk-papermod/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"post-single home-info\">\n <header class=\"post-header\"><h1 class=\"post-title\">{{{page.titleHtml}}}</h1></header>\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}\n <header class=\"page-header\"><h1>{{site.title}}</h1></header>\n {{/page.isGeneratedIndex}}\n <section class=\"post-list\" aria-label=\"{{#site.isBlog}}Posts{{/site.isBlog}}{{^site.isBlog}}Pages{{/site.isBlog}}\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> post-entry}}{{/site.recentPosts}}{{#site.hasPosts}}<p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>{{/site.hasPosts}}{{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<div class=\"post-content page-contents\">{{{page.content}}}</div>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </section>\n{{> shell-bottom}}\n",
14601
+ "../etherpk-papermod/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"post-single home-info\">\n <header class=\"post-header\"><h1 class=\"post-title\">{{{page.titleHtml}}}</h1></header>\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}\n <header class=\"page-header\"><h1>{{site.title}}</h1></header>\n {{/page.isGeneratedIndex}}\n <section class=\"post-list\" aria-label=\"{{#site.isBlog}}Posts{{/site.isBlog}}{{^site.isBlog}}Pages{{/site.isBlog}}\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> post-entry}}{{/site.recentPosts}}{{#site.hasPosts}}<p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>{{/site.hasPosts}}{{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<div class=\"post-content page-contents\">{{{page.content}}}</div>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </section>\n{{> shell-bottom}}\n",
14405
14602
  "../etherpk-papermod/layouts/journal.html": "{{> shell-top}}\n <article class=\"post-single\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n{{> post-meta}}\n </header>\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <footer class=\"post-footer\">\n <nav class=\"paginav\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"prev\" href=\"{{url}}\"><span class=\"title\">« Prev</span><br><span>{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"next\" href=\"{{url}}\"><span class=\"title\">Next »</span><br><span>{{title}}</span></a>{{/page.newer}}\n </nav>\n </footer>\n </article>\n{{> shell-bottom}}\n",
14406
14603
  "../etherpk-papermod/layouts/page.html": "{{> shell-top}}\n <article class=\"post-single\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n{{> post-meta}}\n </header>\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <footer class=\"post-footer\">\n <nav class=\"paginav\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"prev\" href=\"{{url}}\"><span class=\"title\">« Prev</span><br><span>{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"next\" href=\"{{url}}\"><span class=\"title\">Next »</span><br><span>{{title}}</span></a>{{/page.newer}}\n </nav>\n </footer>\n </article>\n{{> shell-bottom}}\n",
14407
14604
  "../etherpk-papermod/partials/after-content.html": "",
@@ -14425,7 +14622,7 @@ var files = /* @__PURE__ */ Object.assign({
14425
14622
  "../etherpk-stack/assets/theme.js": "/*\n * The light / dark toggle: a `.theme-toggle` button flips the page between the two and the\n * choice is remembered in this browser; with no choice made the page follows the system. The\n * stylesheet keys on `data-theme` on <html>, and shell-top applies the remembered choice before\n * the first paint so a dark reader never sees a light flash.\n */\n(function () {\n 'use strict';\n var KEY = 'etherpk-theme';\n var root = document.documentElement;\n function isDark() {\n var chosen = root.getAttribute('data-theme');\n if (chosen === 'dark' || chosen === 'light') return chosen === 'dark';\n return window.matchMedia('(prefers-color-scheme: dark)').matches;\n }\n function apply(theme) {\n if (theme === 'dark' || theme === 'light') root.setAttribute('data-theme', theme);\n else root.removeAttribute('data-theme');\n var buttons = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < buttons.length; i++) buttons[i].setAttribute('aria-pressed', isDark() ? 'true' : 'false');\n }\n try { apply(localStorage.getItem(KEY)); } catch (_e) { apply(null); }\n var toggles = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < toggles.length; i++) {\n toggles[i].addEventListener('click', function () {\n var next = isDark() ? 'light' : 'dark';\n apply(next);\n try { localStorage.setItem(KEY, next); } catch (_e) { /* private mode: the choice lasts the page */ }\n });\n }\n})();\n",
14426
14623
  "../etherpk-stack/layouts/404.html": "{{> shell-top}}\n <article class=\"article-page not-found\">\n <header class=\"article-header\"><h1 class=\"article-title\">Not published</h1></header>\n <div class=\"article-content\">\n <p>The page you followed a link to is not part of this site.</p>\n <p><a href=\"index.html\">Back to {{site.title}}</a></p>\n </div>\n </article>\n{{> shell-bottom}}\n",
14427
14624
  "../etherpk-stack/layouts/archive.html": "{{> shell-top}}\n <header class=\"section-header\"><h1>{{#page.isPostsArchive}}Posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}Journal{{/page.isJournalArchive}}</h1></header>\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <section class=\"archive-year\" aria-label=\"{{year}}\">\n <h2 class=\"archive-year-title\" id=\"y{{year}}\">{{year}}</h2>\n <ul class=\"archive-list\">\n {{#posts}}<li><time datetime=\"{{date}}\">{{date}}</time> <a href=\"{{url}}\">{{title}}</a></li>{{/posts}}\n </ul>\n </section>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n <section class=\"article-list\" aria-label=\"Journal\">\n {{#site.journals}}{{> article-card}}{{/site.journals}}\n </section>\n {{/page.isJournalArchive}}\n{{> shell-bottom}}\n",
14428
- "../etherpk-stack/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"article-page home-intro\">\n <header class=\"article-header\"><h1 class=\"article-title\">{{{page.titleHtml}}}</h1></header>\n <div class=\"article-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n <section class=\"article-list\" aria-label=\"{{#site.isBlog}}Posts{{/site.isBlog}}{{^site.isBlog}}Pages{{/site.isBlog}}\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> article-card}}{{/site.recentPosts}}{{#site.hasPosts}}<p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>{{/site.hasPosts}}{{^site.hasPosts}}<p class=\"article-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<article class=\"article-page page-contents\"><header class=\"article-header\"><h1 class=\"article-title\">{{site.title}}</h1></header><div class=\"article-content\">{{{page.content}}}</div></article>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </section>\n{{> shell-bottom}}\n",
14625
+ "../etherpk-stack/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"article-page home-intro\">\n <header class=\"article-header\"><h1 class=\"article-title\">{{{page.titleHtml}}}</h1></header>\n <div class=\"article-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n <section class=\"article-list\" aria-label=\"{{#site.isBlog}}Posts{{/site.isBlog}}{{^site.isBlog}}Pages{{/site.isBlog}}\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> article-card}}{{/site.recentPosts}}{{#site.hasPosts}}<p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>{{/site.hasPosts}}{{^site.hasPosts}}<p class=\"article-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<article class=\"article-page page-contents\"><header class=\"article-header\"><h1 class=\"article-title\">{{site.title}}</h1></header><div class=\"article-content\">{{{page.content}}}</div></article>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </section>\n{{> shell-bottom}}\n",
14429
14626
  "../etherpk-stack/layouts/journal.html": "{{> shell-top}}\n <article class=\"article-page\">\n <header class=\"article-header\">\n <span class=\"article-category\">{{page.kind}}</span>\n <h1 class=\"article-title\">{{{page.titleHtml}}}</h1>\n <div class=\"article-details\">{{#page.hasDate}}<time datetime=\"{{page.date}}\">{{page.date}}</time> · {{/page.hasDate}}<span>{{page.readingMinutes}} minute read</span></div>\n </header>\n <div class=\"article-content\">\n{{{page.content}}}\n </div>\n <footer class=\"article-footer\">\n <nav class=\"article-pagination\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"article-card pagination-card\" href=\"{{url}}\"><span class=\"pagination-label\">Previous</span><span class=\"pagination-title\">{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"article-card pagination-card next\" href=\"{{url}}\"><span class=\"pagination-label\">Next</span><span class=\"pagination-title\">{{title}}</span></a>{{/page.newer}}\n </nav>\n </footer>\n </article>\n{{> shell-bottom}}\n",
14430
14627
  "../etherpk-stack/layouts/page.html": "{{> shell-top}}\n <article class=\"article-page\">\n <header class=\"article-header\">\n <span class=\"article-category\">{{page.kind}}</span>\n <h1 class=\"article-title\">{{{page.titleHtml}}}</h1>\n <div class=\"article-details\">{{#page.hasDate}}<time datetime=\"{{page.date}}\">{{page.date}}</time> · {{/page.hasDate}}<span>{{page.readingMinutes}} minute read</span></div>\n </header>\n <div class=\"article-content\">\n{{{page.content}}}\n </div>\n <footer class=\"article-footer\">\n <nav class=\"article-pagination\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"article-card pagination-card\" href=\"{{url}}\"><span class=\"pagination-label\">Previous</span><span class=\"pagination-title\">{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"article-card pagination-card next\" href=\"{{url}}\"><span class=\"pagination-label\">Next</span><span class=\"pagination-title\">{{title}}</span></a>{{/page.newer}}\n </nav>\n </footer>\n </article>\n{{> shell-bottom}}\n",
14431
14628
  "../etherpk-stack/partials/after-content.html": "",
@@ -14443,12 +14640,12 @@ var files = /* @__PURE__ */ Object.assign({
14443
14640
  "../etherpk-stack/partials/theme-scripts.html": " <script src=\"theme/search.js\" defer><\/script>\n <script src=\"theme/theme.js\" defer><\/script>\n",
14444
14641
  "../etherpk-stack/partials/toc.html": " <nav class=\"toc\" aria-label=\"Table of contents\">\n <ul>\n {{#page.toc}}<li class=\"toc-level-{{level}}\"><a href=\"#{{id}}\">{{text}}</a></li>{{/page.toc}}\n </ul>\n </nav>\n",
14445
14642
  "../etherpk-stack/partials/widgets.html": " <section class=\"widget search-widget\" aria-label=\"Search\">\n <form class=\"site-search\" role=\"search\" data-search-index=\"{{site.search.url}}\" data-search-script=\"{{site.search.scriptUrl}}\">\n <label class=\"visually-hidden\" for=\"site-search-input\">Search</label>\n <input id=\"site-search-input\" type=\"search\" name=\"q\" placeholder=\"Search\" autocomplete=\"off\">\n <div class=\"site-search-results\" role=\"listbox\" hidden></div>\n </form>\n </section>\n {{#page.hasToc}}\n <section class=\"widget toc-widget\">\n <h2 class=\"widget-title\">Table of contents</h2>\n{{> toc}}\n </section>\n {{/page.hasToc}}\n {{#site.hasPostsArchive}}\n <!-- Years with counts, as Stack's archives widget does: a list of every post would grow\n with the blog; the archive page carries them. -->\n <section class=\"widget archives\">\n <h2 class=\"widget-title\">Archives</h2>\n <ul class=\"widget-list\">\n {{#site.postsByYear}}<li><a href=\"{{site.postsUrl}}#y{{year}}\"><span>{{year}}</span><span class=\"widget-count\">{{count}}</span></a></li>{{/site.postsByYear}}\n </ul>\n <p class=\"widget-more\"><a href=\"{{site.postsUrl}}\">All posts</a></p>\n </section>\n {{/site.hasPostsArchive}}\n {{#site.hasJournals}}\n <section class=\"widget journal\">\n <h2 class=\"widget-title\">Journal</h2>\n <p class=\"widget-more\"><a href=\"journal.html\">Every entry, by day</a></p>\n </section>\n {{/site.hasJournals}}\n <!-- A blog's posts are in the archive above; only its other pages belong here. -->\n {{#site.isBlog}}{{#site.hasUndatedPages}}\n <section class=\"widget pages\">\n <h2 class=\"widget-title\">Pages</h2>\n <ul class=\"widget-list\">\n {{#site.undatedPages}}<li><a href=\"{{url}}\"><span>{{title}}</span></a></li>{{/site.undatedPages}}\n </ul>\n </section>\n {{/site.hasUndatedPages}}{{/site.isBlog}}\n {{^site.isBlog}}\n <section class=\"widget pages\">\n <h2 class=\"widget-title\">Pages</h2>\n <ul class=\"widget-list\">\n {{#site.pages}}<li><a href=\"{{url}}\"><span>{{title}}</span></a></li>{{/site.pages}}\n </ul>\n </section>\n {{/site.isBlog}}\n",
14446
- "../etherpk-stack/theme.json": "{\n \"name\": \"etherpk-stack\",\n \"title\": \"EtherPK Stack\",\n \"version\": \"1.0.0\",\n \"contract\": 1,\n \"description\": \"In the mould of Hugo's Stack: a card-based blog with a left sidebar of site identity and menu, article cards in the middle, and widgets (search, contents, archive, pages) on the right.\",\n \"kinds\": [\"blog\", \"docs\"],\n \"includes\": [\n { \"name\": \"head\", \"description\": \"Extra markup inside <head>: analytics, fonts, meta tags. Raw HTML in the page body.\" },\n { \"name\": \"logo\", \"description\": \"The avatar above the site title in the sidebar: an image or inline SVG. Replaces the EtherPK icon.\" },\n { \"name\": \"header\", \"description\": \"The left sidebar's identity block (avatar, title, description). Replaces the theme's; the menu stays.\" },\n { \"name\": \"before-content\", \"description\": \"Rendered above every page's content: a banner, a notice.\" },\n { \"name\": \"after-content\", \"description\": \"Rendered below every page's content, above the linked references: an author bio, a comments widget.\" },\n { \"name\": \"footer\", \"description\": \"The footer under the article column.\" },\n { \"name\": \"scripts\", \"description\": \"Scripts before </body>, added after the theme's own (search, the theme toggle): analytics, a widget. Raw HTML in the page body.\" },\n { \"name\": \"styles\", \"description\": \"CSS appended after the theme's stylesheet: the page's first css fence.\", \"kind\": \"css\" }\n ],\n \"files\": [\n \"layouts/page.html\", \"layouts/home.html\", \"layouts/journal.html\", \"layouts/archive.html\", \"layouts/404.html\",\n \"partials/shell-top.html\", \"partials/shell-bottom.html\", \"partials/head.html\", \"partials/logo.html\", \"partials/header.html\",\n \"partials/nav-item.html\", \"partials/toc.html\", \"partials/backlinks.html\", \"partials/article-card.html\", \"partials/widgets.html\",\n \"partials/before-content.html\", \"partials/after-content.html\", \"partials/footer.html\", \"partials/theme-scripts.html\", \"partials/scripts.html\",\n \"assets/theme.css\", \"assets/search.js\", \"assets/theme.js\"\n ]\n}\n",
14643
+ "../etherpk-stack/theme.json": "{\n \"name\": \"etherpk-stack\",\n \"title\": \"EtherPK Stack\",\n \"version\": \"1.0.0\",\n \"contract\": 1,\n \"description\": \"In the mould of Hugo's Stack: a card-based blog with a left sidebar of site identity and menu, article cards in the middle, and widgets (search, contents, archive, pages) on the right.\",\n \"kinds\": [\"blog\", \"docs\"],\n \"includes\": [\n { \"name\": \"head\", \"description\": \"Extra markup inside <head>: analytics, fonts, meta tags. Raw HTML in the page body.\" },\n { \"name\": \"logo\", \"description\": \"The avatar above the site title in the sidebar: an image or inline SVG. Replaces the EtherPK icon.\" },\n { \"name\": \"header\", \"description\": \"The left sidebar's identity block (avatar, title, description). Replaces the theme's - the menu stays.\" },\n { \"name\": \"before-content\", \"description\": \"Rendered above every page's content: a banner, a notice.\" },\n { \"name\": \"after-content\", \"description\": \"Rendered below every page's content, above the linked references: an author bio, a comments widget.\" },\n { \"name\": \"footer\", \"description\": \"The footer under the article column.\" },\n { \"name\": \"scripts\", \"description\": \"Scripts before </body>, added after the theme's own (search, the theme toggle): analytics, a widget. Raw HTML in the page body.\" },\n { \"name\": \"styles\", \"description\": \"CSS appended after the theme's stylesheet: the page's first css fence.\", \"kind\": \"css\" }\n ],\n \"files\": [\n \"layouts/page.html\", \"layouts/home.html\", \"layouts/journal.html\", \"layouts/archive.html\", \"layouts/404.html\",\n \"partials/shell-top.html\", \"partials/shell-bottom.html\", \"partials/head.html\", \"partials/logo.html\", \"partials/header.html\",\n \"partials/nav-item.html\", \"partials/toc.html\", \"partials/backlinks.html\", \"partials/article-card.html\", \"partials/widgets.html\",\n \"partials/before-content.html\", \"partials/after-content.html\", \"partials/footer.html\", \"partials/theme-scripts.html\", \"partials/scripts.html\",\n \"assets/theme.css\", \"assets/search.js\", \"assets/theme.js\"\n ]\n}\n",
14447
14644
  "../etherpk-terminal/assets/search.js": "/*\n * Search for a Published Site: reads search.json (built over the publication's own documents\n * and nothing else) and scores documents by title and text matches. No dependencies, no\n * network beyond the one index file, and nothing runs until the search box is used. The same\n * script runs the sidebar toggle on small screens, so a page carries one script.\n */\n(function () {\n 'use strict';\n\n // -- Navigation toggle (small screens) -------------------------------------------------\n var toggle = document.querySelector('.nav-toggle');\n var backdrop = document.querySelector('.nav-backdrop');\n var nav = document.getElementById('site-nav');\n function setNavOpen(open) {\n var was = document.body.classList.contains('nav-open');\n document.body.classList.toggle('nav-open', open);\n if (toggle) toggle.setAttribute('aria-expanded', open ? 'true' : 'false');\n if (backdrop) backdrop.hidden = !open;\n // Focus follows the drawer: into its first link on open, back to the button on close,\n // so a keyboard or screen-reader user is never left on something now hidden.\n if (open && !was) {\n var first = nav.querySelector('a');\n if (first) first.focus();\n } else if (!open && was && toggle) {\n toggle.focus();\n }\n }\n if (toggle && nav) {\n toggle.addEventListener('click', function () { setNavOpen(!document.body.classList.contains('nav-open')); });\n if (backdrop) backdrop.addEventListener('click', function () { setNavOpen(false); });\n document.addEventListener('keydown', function (e) {\n if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNavOpen(false);\n });\n }\n\n // -- Search --------------------------------------------------------------------------\n var form = document.querySelector('.site-search');\n if (!form) return;\n var input = form.querySelector('input[type=\"search\"]');\n var results = form.querySelector('.site-search-results');\n var indexUrl = form.getAttribute('data-search-index') || 'search.json';\n var scriptUrl = form.getAttribute('data-search-script') || 'search-index.js';\n var index = null;\n var loading = null;\n /* True once neither route produced the index: the results then say so rather than \"nothing matches\". */\n var unavailable = false;\n\n /* The index as a script assigning window.etherpkSearchIndex: what a site opened from disk can\n load, since file:// refuses fetch but allows <script src>. */\n function loadScript() {\n return new Promise(function (resolve, reject) {\n if (window.etherpkSearchIndex) { resolve(window.etherpkSearchIndex); return; }\n var s = document.createElement('script');\n s.src = scriptUrl;\n s.onload = function () {\n if (window.etherpkSearchIndex) resolve(window.etherpkSearchIndex);\n else reject(new Error('no index'));\n };\n s.onerror = function () { reject(new Error('no script')); };\n document.head.appendChild(s);\n });\n }\n\n function load() {\n if (index) return Promise.resolve(index);\n if (!loading) {\n loading = fetch(indexUrl).then(function (r) {\n if (!r.ok) throw new Error(String(r.status));\n return r.json();\n }).catch(loadScript).then(function (data) {\n index = (data && data.documents) || [];\n return index;\n }).catch(function () { unavailable = true; index = []; return index; });\n }\n return loading;\n }\n\n function tokens(q) {\n return q.toLowerCase().split(/\\s+/).filter(function (t) { return t.length > 0; });\n }\n\n function score(doc, terms) {\n var title = (doc.title || '').toLowerCase();\n var text = (doc.text || '').toLowerCase();\n var s = 0;\n for (var i = 0; i < terms.length; i++) {\n var t = terms[i];\n if (title === t) s += 20;\n else if (title.indexOf(t) === 0) s += 10;\n else if (title.indexOf(t) !== -1) s += 6;\n var at = text.indexOf(t);\n if (at !== -1) s += 2;\n else if (title.indexOf(t) === -1) return 0; // every term must match somewhere\n }\n return s;\n }\n\n function snippet(doc, terms) {\n var text = doc.text || '';\n var lower = text.toLowerCase();\n var at = -1;\n for (var i = 0; i < terms.length && at === -1; i++) at = lower.indexOf(terms[i]);\n if (at === -1) return doc.excerpt || '';\n var start = Math.max(0, at - 60);\n var end = Math.min(text.length, at + 100);\n return (start > 0 ? '…' : '') + text.slice(start, end).replace(/\\s+/g, ' ') + (end < text.length ? '…' : '');\n }\n\n function escapeHtml(s) {\n return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n }\n\n function render(list, q) {\n if (!q) { results.hidden = true; results.innerHTML = ''; return; }\n if (unavailable) {\n results.innerHTML = '<p class=\"site-search-empty\">The search index could not be loaded.</p>';\n results.hidden = false;\n return;\n }\n if (list.length === 0) {\n results.innerHTML = '<p class=\"site-search-empty\">Nothing matches \"' + escapeHtml(q) + '\".</p>';\n results.hidden = false;\n return;\n }\n results.innerHTML = list.map(function (hit) {\n return '<a class=\"site-search-hit\" role=\"option\" href=\"' + escapeHtml(hit.doc.url) + '\">' +\n '<span class=\"site-search-title\">' + escapeHtml(hit.doc.title) + '</span>' +\n '<span class=\"site-search-snippet\">' + escapeHtml(hit.snippet) + '</span></a>';\n }).join('');\n results.hidden = false;\n }\n\n var timer = null;\n function run() {\n var q = input.value.trim();\n var terms = tokens(q);\n if (terms.length === 0) { render([], ''); return; }\n load().then(function (docs) {\n var hits = [];\n for (var i = 0; i < docs.length; i++) {\n var s = score(docs[i], terms);\n if (s > 0) hits.push({ doc: docs[i], score: s, snippet: snippet(docs[i], terms) });\n }\n hits.sort(function (a, b) { return b.score - a.score || a.doc.title.localeCompare(b.doc.title); });\n render(hits.slice(0, 12), q);\n });\n }\n\n input.addEventListener('input', function () {\n clearTimeout(timer);\n timer = setTimeout(run, 120);\n });\n input.addEventListener('focus', function () { load(); });\n form.addEventListener('submit', function (e) {\n e.preventDefault();\n var first = results.querySelector('a');\n if (first) window.location.href = first.getAttribute('href');\n });\n document.addEventListener('keydown', function (e) {\n if (e.key === '/' && document.activeElement !== input && !/input|textarea/i.test(document.activeElement.tagName)) {\n e.preventDefault();\n input.focus();\n }\n if (e.key === 'Escape' && !results.hidden) { render([], ''); }\n });\n document.addEventListener('click', function (e) {\n if (!form.contains(e.target)) render([], '');\n });\n})();\n",
14448
14645
  "../etherpk-terminal/assets/theme.css": "/* EtherPK's Terminal: after Hugo's Terminal (panr, MIT), written afresh for EtherPK's view - one\n monospace face, a dark ground, no colour at all - the accent is a brighter shade of the text,\n so everything is drawn in tones of one ink - the block logo with its blinking cursor,\n underlined titles, framed boxes and bordered buttons. Uses Fira Code\n or the system monospace, so the site loads nothing from anywhere. Classes the publisher emits\n and this file styles: .wikilink, .wikilink-missing, .task, .task-tag, .toc, .backlinks,\n .site-nav-list, .diagram, .math, .tok-* (code). */\n\n:root {\n --accent: #f2f2f2;\n --background: #1d1e28;\n --color: #cfcfd6;\n --color-muted: #8f8f9a;\n --border-color: rgba(255, 255, 255, 0.1);\n --code-bg: rgba(255, 255, 255, 0.06);\n --font: \"Fira Code\", ui-monospace, Monaco, Consolas, \"Ubuntu Mono\", monospace;\n --container-width: 1080px;\n --mark: rgba(255, 255, 255, 0.22);\n --missing: var(--color-muted);\n}\n/* Terminal is dark by nature; a light system preference is respected, the ink inverted. */\n@media (prefers-color-scheme: light) {\n :root:not([data-theme=\"dark\"]) {\n --accent: #111111;\n --background: #f7f4ef;\n --color: #2b2b2b;\n --color-muted: #6a6a6a;\n --mark: rgba(0, 0, 0, 0.14);\n --border-color: rgba(0, 0, 0, 0.12);\n --code-bg: rgba(0, 0, 0, 0.05);\n }\n}\n\n*, *::before, *::after { box-sizing: border-box; }\nhtml { color-scheme: dark light; }\nbody {\n margin: 0;\n font-family: var(--font);\n font-size: 0.9375rem;\n line-height: 1.6;\n background: var(--background);\n color: var(--color);\n text-rendering: optimizeLegibility;\n -webkit-font-smoothing: antialiased;\n font-feature-settings: \"liga\", \"tnum\", \"zero\", \"ss01\", \"locl\";\n}\na { color: var(--accent); }\na:hover { text-decoration: none; }\nimg { max-width: 100%; height: auto; display: block; }\n.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.skip-link { position: absolute; left: -999px; top: 0; background: var(--accent); color: var(--background); padding: 8px 16px; z-index: 100; }\n.skip-link:focus { left: 16px; top: 16px; }\n\n.container { max-width: var(--container-width); margin: 0 auto; padding: 40px; }\n\n/* -- Header: the prompt-style logo ------------------------------------------------------ */\n.header { margin-bottom: 40px; }\n.header__inner { display: flex; align-items: center; justify-content: space-between; gap: 20px; }\n/* The block logo: the mark, the title and a blinking cursor on one line, the mark's box sized\n to the text's cap height so the two read as one glyph run. */\n.logo {\n display: inline-flex;\n align-items: center;\n gap: 12px;\n padding: 8px 16px;\n background: var(--accent);\n color: var(--background);\n font-size: 1.375rem;\n line-height: 1;\n font-weight: bold;\n text-decoration: none;\n}\n.logo:hover { text-decoration: none; }\n.logo__mark { display: inline-flex; align-items: center; line-height: 0; }\n.logo__mark .site-logo { display: block; width: 1.3em; height: 1.125em; }\n.logo__cursor {\n display: inline-block;\n width: 0.5em;\n height: 1em;\n background: var(--background);\n animation: cursor 1s infinite;\n}\n@keyframes cursor { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } }\n@media (prefers-reduced-motion: reduce) { .logo__cursor { animation: none; } }\n.menu { margin-top: 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 30px; }\n.menu__inner { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 30px; }\n.menu__inner li a, .menu__inner .nav-group-label { color: var(--color); text-decoration: none; }\n.menu__inner li a:hover { color: var(--accent); }\n.menu__inner .nav-current > a { color: var(--accent); }\n.menu__inner .nav-current > a::after { content: \" *\"; }\n.menu__inner .nav-group-label { color: var(--color-muted); }\n.menu__inner .nav-group-label::after { content: \":\"; }\n/* A nested outline level follows its parent on the same line, the way a path reads. */\n.menu__inner li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 12px; }\n.menu__inner .site-nav-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0 12px; }\n.menu__inner .site-nav-list li::before { content: \"/\"; color: var(--color-muted); margin-right: 12px; }\n.nav-toggle { display: none; }\n.nav-backdrop { display: none; }\n.site-search { position: relative; margin-left: auto; flex: 0 1 16rem; }\n.site-search input {\n width: 100%;\n padding: 5px 10px;\n border: 1px solid var(--accent);\n background: transparent;\n color: var(--color);\n font: inherit;\n}\n.site-search input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }\n.site-search input::placeholder { color: var(--color-muted); }\n.site-search-results {\n position: absolute;\n right: 0;\n top: calc(100% + 6px);\n width: min(26rem, 90vw);\n max-height: 24rem;\n overflow: auto;\n background: var(--background);\n border: 1px solid var(--accent);\n z-index: 30;\n}\n.site-search-hit { display: block; padding: 8px 12px; color: var(--color); text-decoration: none; border-bottom: 1px solid var(--border-color); }\n.site-search-hit:hover, .site-search-hit:focus { background: var(--code-bg); }\n.site-search-title { display: block; color: var(--accent); }\n.site-search-snippet { display: block; color: var(--color-muted); font-size: 0.9rem; }\n.site-search-empty { margin: 0; padding: 8px 12px; color: var(--color-muted); }\n\n/* -- Posts ------------------------------------------------------------------------------ */\n.posts { width: 100%; }\n.post { width: 100%; }\n.post.on-list { margin-bottom: 40px; padding-bottom: 40px; border-bottom: 1px dotted var(--accent); }\n.post.on-list:last-child { border-bottom: 0; }\n.post-title {\n position: relative;\n margin: 0 0 15px;\n font-size: 1.6rem;\n line-height: 1.3;\n color: var(--accent);\n text-decoration: underline;\n text-decoration-color: var(--accent);\n text-decoration-thickness: 2px;\n text-underline-offset: 6px;\n}\n.post-title a { color: inherit; text-decoration: none; }\n.post-title .wikilink { color: inherit; text-decoration: none; }\n.index-title { text-decoration: none; }\n.index-title::before { content: \"# \"; }\n.post-meta { margin: 0 0 20px; font-size: 0.9rem; color: var(--color-muted); }\n.post-content { margin-top: 20px; }\n.post-content h1, .post-content h2, .post-content h3, .post-content h4 { font-weight: bold; line-height: 1.3; margin: 40px 0 20px; }\n.post-content h2 { font-size: 1.3rem; }\n.post-content h3 { font-size: 1.2rem; }\n.post-content h2::before { content: \"## \"; color: var(--accent); }\n.post-content h3::before { content: \"### \"; color: var(--accent); }\n.post-content p { margin: 0 0 20px; }\n.post-content ul, .post-content ol { margin: 0 0 20px; padding-left: 24px; }\n.post-content li { margin: 4px 0; }\n.post-content ul li::marker { color: var(--accent); }\n.post-content img { margin: 20px 0; border: 1px solid var(--border-color); }\n.post-content .diagram { margin: 20px 0; text-align: center; }\n.post-content .diagram svg { max-width: 100%; height: auto; }\n.post-content blockquote {\n position: relative;\n margin: 40px 0;\n padding: 25px;\n border-top: 1px solid var(--accent);\n border-bottom: 1px solid var(--accent);\n font-style: italic;\n}\n.post-content blockquote::before { content: \"”\"; position: absolute; top: -40px; left: 10px; font-size: 3.875rem; font-style: normal; color: var(--accent); }\n.post-content blockquote p:last-child { margin-bottom: 0; }\n.post-content hr { border: 0; border-top: 1px dashed var(--accent); margin: 40px 0; }\n.post-content table { width: 100%; border-collapse: collapse; margin: 20px 0; }\n.post-content th, .post-content td { padding: 8px 12px; border: 1px dashed var(--accent); text-align: left; }\n.post-content th { color: var(--accent); }\n.post-content code { padding: 1px 6px; background: var(--code-bg); font-family: inherit; font-size: 0.95em; }\n.post-content pre {\n margin: 20px 0;\n padding: 20px;\n background: var(--code-bg);\n border-left: 3px solid var(--accent);\n overflow-x: auto;\n font-family: inherit;\n font-size: 0.95rem;\n line-height: 1.5;\n}\n.post-content pre code { padding: 0; background: none; font-size: inherit; }\n.math-block { overflow-x: auto; margin: 20px 0; }\nmark { background: var(--mark); color: inherit; padding: 0 0.15em; }\n\n/* Wikilinks and tasks. */\na.wikilink { color: var(--accent); }\na.wikilink-missing { color: var(--missing); text-decoration-style: dotted; }\nli.task { list-style: none; margin-left: -24px; }\nli.task input[type=\"checkbox\"] { accent-color: var(--accent); margin-right: 6px; vertical-align: -0.1em; }\nli.task.done { color: var(--color-muted); text-decoration: line-through; }\n.task-tag { display: inline-block; padding: 0 6px; margin-right: 4px; border: 1px solid var(--accent); color: var(--accent); font-size: 0.8rem; }\n.task-tag.cancelled { text-decoration: line-through; }\n\n/* Buttons, frames, the table of contents and pagination. */\n.button {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n padding: 8px 18px;\n border: 1px solid var(--accent);\n color: var(--accent);\n font: inherit;\n text-decoration: none;\n background: transparent;\n cursor: pointer;\n}\n.button:hover { background: var(--accent); color: var(--background); }\n.button.inline { display: inline-flex; }\n.read-more { margin-top: 10px; }\n.all-posts { margin: 30px 0 0; }\n/* The contents page of a docs site: the outline as a tree, then any page it leaves out. */\n.page-index-list { list-style: none; margin: 0; padding: 0; }\n.page-index-list .page-index-list { padding-left: 24px; }\n.page-index-list li { margin: 6px 0; }\n.page-index-list li::before { content: \"- \"; color: var(--accent); }\n.page-index-list a { color: var(--color); }\n.page-index-list a:hover { color: var(--accent); }\n.page-index-group-label { color: var(--color-muted); }\n.page-index-group-label::after { content: \":\"; }\n.page-index-other { margin: 30px 0 10px; font-size: 1.1rem; }\n.page-index-other::before { content: \"## \"; color: var(--accent); }\n/* The archives: a year as a prompt-style heading, then one line per post. */\n.archive-year { margin: 30px 0 10px; font-size: 1.1rem; }\n.archive-year::before { content: \"## \"; color: var(--accent); }\n.archive-list { list-style: none; margin: 0; padding: 0; }\n.archive-list li { margin: 6px 0; }\n.archive-list time { color: var(--color-muted); margin-right: 12px; font-variant-numeric: tabular-nums; }\n.archive-list a { color: var(--color); }\n.archive-list a:hover { color: var(--accent); }\n.archive-excerpt { color: var(--color-muted); }\n.framed { border: 1px solid var(--accent); padding: 20px; }\n.toc { margin: 20px 0 40px; }\n.toc__title { margin: 0 0 10px; color: var(--accent); }\n.toc ul { list-style: none; margin: 0; padding: 0; }\n.toc li { margin: 4px 0; }\n.toc li::before { content: \"- \"; color: var(--accent); }\n.toc li.toc-level-3 { padding-left: 1.5rem; }\n.toc li.toc-level-4 { padding-left: 3rem; }\n.toc a { color: var(--color); text-decoration: none; }\n.toc a:hover { color: var(--accent); }\n.pagination { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin-top: 50px; }\n.pagination__next { margin-left: auto; }\n.button__text { max-width: 20rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n\n/* Linked references, framed like everything else worth a box. */\n.backlinks { margin-top: 40px; border: 1px solid var(--accent); padding: 20px; }\n.backlinks h2 { margin: 0 0 12px; font-size: 1rem; color: var(--accent); }\n.backlinks h2::before { content: \":: \"; }\n.backlink-list { list-style: none; margin: 0; padding: 0; }\n.backlink-source { margin: 0 0 12px; }\n.backlink-source > a { color: var(--color); }\n.backlink-source ul { list-style: none; margin: 4px 0 0; padding: 0 0 0 16px; border-left: 1px dashed var(--accent); }\n.backlink-context { color: var(--color-muted); font-size: 0.9rem; margin: 2px 0; }\n\n/* -- Footer ----------------------------------------------------------------------------- */\n.footer { margin-top: 60px; padding-top: 20px; border-top: 1px dotted var(--accent); font-size: 0.9rem; color: var(--color-muted); }\n.footer__inner { display: flex; flex-wrap: wrap; gap: 10px; }\n.copyright { display: flex; flex-wrap: wrap; gap: 0 8px; }\n.footer__separator { color: var(--accent); }\n\n/* Code tokens (lezer classHighlighter names). */\n/* Code tokens (lezer classHighlighter names), in tones of the one ink: weight and italics carry\n the distinction the usual colours would. */\n.tok-keyword { color: var(--accent); font-weight: bold; }\n.tok-string, .tok-string2 { color: var(--color); font-style: italic; }\n.tok-comment, .tok-lineComment, .tok-blockComment { color: var(--color-muted); font-style: italic; }\n.tok-number, .tok-bool, .tok-null, .tok-atom { color: var(--accent); }\n.tok-function, .tok-definition.tok-variableName { color: var(--accent); }\n.tok-typeName, .tok-className, .tok-namespace { color: var(--accent); font-weight: bold; }\n.tok-operator, .tok-punctuation { color: var(--color-muted); }\n.tok-propertyName, .tok-attributeName { color: var(--color); }\n.tok-tagName { color: var(--accent); }\n.tok-invalid { color: var(--missing); text-decoration: underline wavy; }\n\n/* -- Small screens: the menu behind a Terminal-style button ----------------------------- */\n@media (max-width: 684px) {\n .container { padding: 20px; }\n .nav-toggle {\n display: inline-block;\n padding: 5px 10px;\n border: 1px solid var(--accent);\n background: transparent;\n color: var(--accent);\n font: inherit;\n cursor: pointer;\n }\n .nav-toggle::before { content: \"$ \"; }\n .menu { display: none; flex-direction: column; align-items: stretch; gap: 12px; margin-top: 20px; padding: 20px; border: 1px solid var(--accent); }\n body.nav-open .menu { display: flex; }\n .menu__inner { flex-direction: column; gap: 8px; }\n .menu__inner li { flex-direction: column; align-items: flex-start; }\n .menu__inner .site-nav-list { flex-direction: column; padding-left: 12px; }\n .menu__inner .site-nav-list li::before { margin-right: 6px; }\n .site-search { margin-left: 0; flex-basis: auto; }\n .post-title { font-size: 1.3rem; }\n .pagination__prev, .pagination__next { flex-basis: 100%; }\n .pagination__next { justify-content: flex-end; }\n}\n",
14449
14646
  "../etherpk-terminal/layouts/404.html": "{{> shell-top}}\n <article class=\"post\">\n <h1 class=\"post-title\">404 :: not published</h1>\n <div class=\"post-content\">\n <p>$ the page you followed a link to is not part of this site.</p>\n <p><a class=\"button inline\" href=\"index.html\">← {{site.title}}</a></p>\n </div>\n </article>\n{{> shell-bottom}}\n",
14450
14647
  "../etherpk-terminal/layouts/archive.html": "{{> shell-top}}\n <article class=\"post archive\">\n <h1 class=\"post-title index-title\">{{#page.isPostsArchive}}posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}journal{{/page.isJournalArchive}}</h1>\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <h2 class=\"archive-year\" id=\"y{{year}}\">{{year}}</h2>\n <ul class=\"archive-list\">\n {{#posts}}<li><time datetime=\"{{date}}\">{{date}}</time> <a href=\"{{url}}\">{{title}}</a></li>{{/posts}}\n </ul>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n <ul class=\"archive-list\">\n {{#site.journals}}<li><time datetime=\"{{date}}\">{{date}}</time> <a href=\"{{url}}\">{{title}}</a>{{#excerpt}} <span class=\"archive-excerpt\">{{excerpt}}</span>{{/excerpt}}</li>{{/site.journals}}\n </ul>\n {{/page.isJournalArchive}}\n </article>\n{{> shell-bottom}}\n",
14451
- "../etherpk-terminal/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"post home-info\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}\n <h1 class=\"post-title index-title\">{{site.title}}</h1>\n {{/page.isGeneratedIndex}}\n <div class=\"posts\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> post-entry}}{{/site.recentPosts}}{{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<div class=\"post-content\">{{{page.content}}}</div>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </div>\n {{#site.isBlog}}{{#site.hasPosts}}\n <p class=\"all-posts\"><a class=\"button inline\" href=\"{{site.postsUrl}}\">$ ls all {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}{{/site.isBlog}}\n{{> shell-bottom}}\n",
14648
+ "../etherpk-terminal/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"post home-info\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}\n <h1 class=\"post-title index-title\">{{site.title}}</h1>\n {{/page.isGeneratedIndex}}\n <div class=\"posts\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> post-entry}}{{/site.recentPosts}}{{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<div class=\"post-content\">{{{page.content}}}</div>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </div>\n {{#site.isBlog}}{{#site.hasPosts}}\n <p class=\"all-posts\"><a class=\"button inline\" href=\"{{site.postsUrl}}\">$ ls all {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}{{/site.isBlog}}\n{{> shell-bottom}}\n",
14452
14649
  "../etherpk-terminal/layouts/journal.html": "{{> shell-top}}\n <article class=\"post\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n{{> post-meta}}\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <nav class=\"pagination\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"button pagination__prev\" href=\"{{url}}\"><span class=\"button__icon\">←</span><span class=\"button__text\">{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"button pagination__next\" href=\"{{url}}\"><span class=\"button__text\">{{title}}</span><span class=\"button__icon\">→</span></a>{{/page.newer}}\n </nav>\n </article>\n{{> shell-bottom}}\n",
14453
14650
  "../etherpk-terminal/layouts/page.html": "{{> shell-top}}\n <article class=\"post\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n{{> post-meta}}\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <nav class=\"pagination\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"button pagination__prev\" href=\"{{url}}\"><span class=\"button__icon\">←</span><span class=\"button__text\">{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"button pagination__next\" href=\"{{url}}\"><span class=\"button__text\">{{title}}</span><span class=\"button__icon\">→</span></a>{{/page.newer}}\n </nav>\n </article>\n{{> shell-bottom}}\n",
14454
14651
  "../etherpk-terminal/partials/after-content.html": "",
@@ -14567,7 +14764,7 @@ function seededFiles(publication) {
14567
14764
  return new Map([
14568
14765
  [PAGES_WORKFLOW_PATH, `# Publishes this folder to GitHub Pages as it is: the site is already built by EtherPK,
14569
14766
  # so there is no build step. In the repository's Settings → Pages, set the source to
14570
- # "GitHub Actions" once; every push to main then deploys.
14767
+ # "GitHub Actions" once - every push to main then deploys.
14571
14768
  name: Deploy to GitHub Pages
14572
14769
  on:
14573
14770
  push:
@@ -14598,7 +14795,7 @@ jobs:
14598
14795
  [WRANGLER_PATH, `// Cloudflare Workers with static assets (Cloudflare's successor to Pages for new sites).
14599
14796
  // Deploy with \`npx wrangler deploy\`, or connect the repository in the dashboard under
14600
14797
  // Workers → Create → Import a repository and leave the build command empty. The Worker is
14601
- // named after the publication; Cloudflare warns when this name and the Worker's differ.
14798
+ // named after the publication - Cloudflare warns when this name and the Worker's differ.
14602
14799
  {
14603
14800
  "name": "${publication.id}",
14604
14801
  "compatibility_date": "2026-01-01",
@@ -14616,9 +14813,9 @@ This folder is a website published by [EtherPK](https://etherpk.com) from the pu
14616
14813
  publish, so edit the notes, the publication page, its include pages and its theme in EtherPK,
14617
14814
  not here.
14618
14815
 
14619
- - Preview: open \`index.html\` in a browser; search works from disk too.
14816
+ - Preview: open \`index.html\` in a browser - search works from disk too.
14620
14817
  - GitHub Pages: push this folder to a repository and set Settings → Pages → Source to
14621
- "GitHub Actions"; \`.github/workflows/pages.yaml\` deploys every push to \`main\`.
14818
+ "GitHub Actions" - \`.github/workflows/pages.yaml\` deploys every push to \`main\`.
14622
14819
  - Cloudflare: \`npx wrangler deploy\` uses \`wrangler.jsonc\`, or connect the repository in the
14623
14820
  Workers dashboard with no build command.
14624
14821
  - Netlify, GitLab Pages, Vercel, S3 or your own server: the folder is plain files, so any of
@@ -14626,7 +14823,7 @@ not here.
14626
14823
  `],
14627
14824
  [GITIGNORE_PATH, `.DS_Store\nThumbs.db\n`],
14628
14825
  [ASSETS_IGNORE_PATH, `# Files in this folder that are not part of the website, kept out of the Worker's assets.
14629
- # Written once by EtherPK; yours to edit.
14826
+ # Written once by EtherPK - yours to edit.
14630
14827
  .git
14631
14828
  .github
14632
14829
  .gitignore
@@ -14650,15 +14847,15 @@ This folder is a website generated by [EtherPK](https://etherpk.com) from the pu
14650
14847
  \`robots.txt\` and the text outside this section of \`AGENTS.md\` is rewritten on each publish
14651
14848
  and files that are no longer produced are deleted.
14652
14849
 
14653
- - Do not edit the HTML, the assets or \`theme/\` here; the change is lost on the next publish.
14850
+ - Do not edit the HTML, the assets or \`theme/\` here - the change is lost on the next publish.
14654
14851
  - Content changes are made to the documents in EtherPK. A document is published when its
14655
14852
  frontmatter says \`public: true\` and names this publication in \`publications: [${publication.id}]\`
14656
14853
  (unless the publication takes every public document).
14657
- - The navigation is the outline of the publication page in EtherPK; the theme and the include
14854
+ - The navigation is the outline of the publication page in EtherPK - the theme and the include
14658
14855
  slots (head, logo, header, footer, before-content, after-content, scripts, styles) are chosen
14659
14856
  and edited there too, in the Publish settings and the Theme editor.
14660
14857
  - \`index.html\` opens from disk for a preview. \`.github/workflows/pages.yaml\` deploys the folder
14661
- to GitHub Pages; \`wrangler.jsonc\` deploys it to Cloudflare Workers; any static host serves it
14858
+ to GitHub Pages, \`wrangler.jsonc\` deploys it to Cloudflare Workers, and any static host serves it
14662
14859
  as it is (https://docs.etherpk.com/hosting-a-published-site).
14663
14860
 
14664
14861
  This section is rewritten by EtherPK on every publish. Put your own notes above or below it.
@@ -15544,7 +15741,7 @@ function buildNav(outline, resolver) {
15544
15741
  issues.push({
15545
15742
  level: "warning",
15546
15743
  code: "nav-entry-not-published",
15547
- message: `The navigation names "${whole.concept}", which is not in this publication; the entry is left out.`
15744
+ message: `The navigation names "${whole.concept}", which is not in this publication - the entry is left out.`
15548
15745
  });
15549
15746
  continue;
15550
15747
  }
@@ -15565,7 +15762,7 @@ function buildNav(outline, resolver) {
15565
15762
  issues.push({
15566
15763
  level: "warning",
15567
15764
  code: "nav-link-unsafe",
15568
- message: `The navigation links "${label}" to a target a published page does not carry (only http, https, mailto and site paths); the entry is shown without its link.`
15765
+ message: `The navigation links "${label}" to a target a published page does not carry (only http, https, mailto and site paths) - the entry is shown without its link.`
15569
15766
  });
15570
15767
  out.push({
15571
15768
  label,
@@ -15802,7 +15999,7 @@ async function publishPublication(source, publication, env, options = {}) {
15802
15999
  if (homeKey !== null && !homeDoc) warnings.push({
15803
16000
  level: "warning",
15804
16001
  code: "home-not-published",
15805
- message: `The home page "${publication.home}" is not in this publication; the front page lists the published pages instead.`
16002
+ message: `The home page "${publication.home}" is not in this publication - the front page lists the published pages instead.`
15806
16003
  });
15807
16004
  const allocation = allocateSlugs.withReport(included.filter((d) => d !== homeDoc));
15808
16005
  const slugs = new Map(allocation.slugs);
@@ -15811,13 +16008,13 @@ async function publishPublication(source, publication, env, options = {}) {
15811
16008
  for (const c of allocation.collisions) if (c.explicit) warnings.push({
15812
16009
  level: "warning",
15813
16010
  code: "slug-taken",
15814
- message: `"${c.concept}" asks for the address ${c.wanted}.html in its frontmatter, but another document has it; it is at ${c.slug}.html.`,
16011
+ message: `"${c.concept}" asks for the address ${c.wanted}.html in its frontmatter, but another document has it - it is at ${c.slug}.html.`,
15815
16012
  concept: c.concept
15816
16013
  });
15817
16014
  else info.push({
15818
16015
  level: "info",
15819
16016
  code: "slug-collision",
15820
- message: `"${c.concept}" wanted the address ${c.wanted}.html, which another document has; it is at ${c.slug}.html.`,
16017
+ message: `"${c.concept}" wanted the address ${c.wanted}.html, which another document has - it is at ${c.slug}.html.`,
15821
16018
  concept: c.concept
15822
16019
  });
15823
16020
  for (const ignoredSlug of allocation.ignored) {
@@ -15825,7 +16022,7 @@ async function publishPublication(source, publication, env, options = {}) {
15825
16022
  warnings.push({
15826
16023
  level: "warning",
15827
16024
  code: "slug-ignored",
15828
- message: `"${ignoredSlug.concept}" has \`slug: ${ignoredSlug.value}\`, which ${why}; its address is derived from its name instead.`,
16025
+ message: `"${ignoredSlug.concept}" has \`slug: ${ignoredSlug.value}\`, which ${why} - its address is derived from its name instead.`,
15829
16026
  concept: ignoredSlug.concept
15830
16027
  });
15831
16028
  }
@@ -15875,12 +16072,12 @@ async function publishPublication(source, publication, env, options = {}) {
15875
16072
  if (theme.manifest.contract < 1) warnings.push({
15876
16073
  level: "warning",
15877
16074
  code: "theme-older-contract",
15878
- message: `The theme "${theme.manifest.name}" was written for view contract ${theme.manifest.contract}; this EtherPK writes contract 1. It is rendered as is; newer view fields are simply absent to it.`
16075
+ message: `The theme "${theme.manifest.name}" was written for view contract ${theme.manifest.contract} - this EtherPK writes contract 1. It is rendered as is - newer view fields are simply absent to it.`
15879
16076
  });
15880
16077
  if (!theme.manifest.kinds.includes(publication.kind)) warnings.push({
15881
16078
  level: "warning",
15882
16079
  code: "theme-kind-mismatch",
15883
- message: `The theme "${theme.manifest.name}" is made for ${theme.manifest.kinds.join(" and ")} publications; this one is ${publication.kind}.`
16080
+ message: `The theme "${theme.manifest.name}" is made for ${theme.manifest.kinds.join(" and ")} publications - this one is ${publication.kind}.`
15884
16081
  });
15885
16082
  const includeDocs = /* @__PURE__ */ new Map();
15886
16083
  for (const [slot, concept] of Object.entries(publication.includes)) {
@@ -15890,7 +16087,7 @@ async function publishPublication(source, publication, env, options = {}) {
15890
16087
  warnings.push({
15891
16088
  level: "warning",
15892
16089
  code: "include-page-missing",
15893
- message: `The include "${slot}" names "${concept}", which is not a document in this graph; the theme's own ${slot} is used.`
16090
+ message: `The include "${slot}" names "${concept}", which is not a document in this graph - the theme's own ${slot} is used.`
15894
16091
  });
15895
16092
  continue;
15896
16093
  }
@@ -15905,7 +16102,7 @@ async function publishPublication(source, publication, env, options = {}) {
15905
16102
  warnings.push({
15906
16103
  level: "warning",
15907
16104
  code: `include-page-${status}`,
15908
- message: `The include "${slot}" names "${concept}", which ${why}; the theme's own ${slot} is used.`
16105
+ message: `The include "${slot}" names "${concept}", which ${why} - the theme's own ${slot} is used.`
15909
16106
  });
15910
16107
  continue;
15911
16108
  }
@@ -16019,7 +16216,7 @@ async function publishPublication(source, publication, env, options = {}) {
16019
16216
  warnings.push({
16020
16217
  level: "warning",
16021
16218
  code: "include-unknown-slot",
16022
- message: `The theme "${theme.manifest.name}" has no include slot "${slot}"; the page "${doc.concept}" is not used.`
16219
+ message: `The theme "${theme.manifest.name}" has no include slot "${slot}" - the page "${doc.concept}" is not used.`
16023
16220
  });
16024
16221
  continue;
16025
16222
  }
@@ -16030,7 +16227,7 @@ async function publishPublication(source, publication, env, options = {}) {
16030
16227
  warnings.push({
16031
16228
  level: "warning",
16032
16229
  code: "include-no-css",
16033
- message: `The include "${slot}" names "${doc.concept}", which has no \`css\` fence; nothing is appended to the stylesheet.`
16230
+ message: `The include "${slot}" names "${doc.concept}", which has no \`css\` fence - nothing is appended to the stylesheet.`
16034
16231
  });
16035
16232
  continue;
16036
16233
  }
@@ -16206,7 +16403,7 @@ async function publishPublication(source, publication, env, options = {}) {
16206
16403
  warnings.push({
16207
16404
  level: "warning",
16208
16405
  code: "asset-missing",
16209
- message: `"${from}" references ${name}, which the graph does not hold; the link is left as it is.`,
16406
+ message: `"${from}" references ${name}, which the graph does not hold - the link is left as it is.`,
16210
16407
  concept: from
16211
16408
  });
16212
16409
  continue;
@@ -16222,7 +16419,7 @@ async function publishPublication(source, publication, env, options = {}) {
16222
16419
  else warnings.push({
16223
16420
  level: "warning",
16224
16421
  code: "katex-assets-unavailable",
16225
- message: "The publication has maths but this host has no KaTeX stylesheet to copy in; formulas will render unstyled."
16422
+ message: "The publication has maths but this host has no KaTeX stylesheet to copy in - formulas will render unstyled."
16226
16423
  });
16227
16424
  }
16228
16425
  const search = pages.map((p) => ({
@@ -16256,7 +16453,7 @@ async function publishPublication(source, publication, env, options = {}) {
16256
16453
  } else info.push({
16257
16454
  level: "info",
16258
16455
  code: "no-site-url",
16259
- message: "The publication has no `url`, so no sitemap or feed is written; add one to the publication page to get both."
16456
+ message: "The publication has no `url`, so no sitemap or feed is written - add one to the publication page to get both."
16260
16457
  });
16261
16458
  bundle.set(".nojekyll", "");
16262
16459
  const external = /* @__PURE__ */ new Set();
@@ -16267,7 +16464,7 @@ async function publishPublication(source, publication, env, options = {}) {
16267
16464
  if (pages.some((p) => p.rendered.mermaidFallback)) warnings.push({
16268
16465
  level: "warning",
16269
16466
  code: "mermaid-not-prerendered",
16270
- message: "A diagram was not pre-rendered on this host and is left as its source; publish from the browser, or run `etherpk-mcp publish setup`, to get inline SVG."
16467
+ message: "A diagram was not pre-rendered on this host and is left as its source - publish from the browser, or run `etherpk-mcp diagrams setup`, to get inline SVG."
16271
16468
  });
16272
16469
  progress("writing", pages.length + 3, pages.length + 3);
16273
16470
  report.ok = errors.length === 0;
@@ -16360,7 +16557,7 @@ async function fetchTheme(url, fetchText) {
16360
16557
  const { manifest, errors } = parseThemeManifest(json);
16361
16558
  if (!manifest) throw new Error(`The theme at ${url} cannot be used: ${errors.join(" ")}`);
16362
16559
  if (manifest.files.length === 0) throw new Error(`The theme at ${url} lists no files in its manifest, so nothing can be fetched.`);
16363
- if (manifest.files.length > MAX_URL_THEME_FILES) throw new Error(`The theme at ${url} lists ${manifest.files.length} files; a theme may list at most ${MAX_URL_THEME_FILES}.`);
16560
+ if (manifest.files.length > MAX_URL_THEME_FILES) throw new Error(`The theme at ${url} lists ${manifest.files.length} files - a theme may list at most ${MAX_URL_THEME_FILES}.`);
16364
16561
  const base = new URL(url);
16365
16562
  const files = /* @__PURE__ */ new Map();
16366
16563
  for (const path of manifest.files) {
@@ -16456,7 +16653,7 @@ function publicationMapping(input) {
16456
16653
  * page whose purpose was anyone's guess (2026-09-19).
16457
16654
  */
16458
16655
  function publicationPageIntro(name) {
16459
- return `This page defines the publication "${name}". Its settings are the frontmatter above; edit them in Settings → Publish rather than here. The outline below is the site's navigation: one bullet per menu entry, written as a wikilink to the page; nested bullets are sub-entries; a plain bullet with children is a group; a markdown link is an outside link. A page that is public and in this publication but not listed here is still on the site, reachable by search and links, just not in the menu.`;
16656
+ return `This page defines the publication "${name}". Its settings are the frontmatter above - edit them in Settings → Publish rather than here. The outline below is the site's navigation: one bullet per menu entry, written as a wikilink to the page. Nested bullets are sub-entries, a plain bullet with children is a group, and a markdown link is an outside link. A page that is public and in this publication but not listed here is still on the site, reachable by search and links, just not in the menu.`;
16460
16657
  }
16461
16658
  /**
16462
16659
  * Create a publication page: a page whose frontmatter defines the publication and whose body
@@ -16610,7 +16807,7 @@ async function setupDiagrams(env, say) {
16610
16807
  }
16611
16808
  const cli = require$1.resolve("playwright-core/cli.js");
16612
16809
  const dir = env.PLAYWRIGHT_BROWSERS_PATH?.trim() || browsersDir(env);
16613
- say(`Installing Chromium into ${dir} (about 170 MB, once per computer; a diagram is rendered by a browser, and this is the one etherpk-mcp drives)…`);
16810
+ say(`Installing Chromium into ${dir} (about 170 MB, once per computer - a diagram is rendered by a browser, and this is the one etherpk-mcp drives)…`);
16614
16811
  await new Promise((resolve, reject) => {
16615
16812
  const child = spawn(process.execPath, [
16616
16813
  cli,
@@ -16636,7 +16833,7 @@ async function setupDiagrams(env, say) {
16636
16833
  child.on("close", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`Playwright's installer exited with code ${code}.`)));
16637
16834
  });
16638
16835
  const after = await chromiumStatus(env);
16639
- if (!after.executable) throw new Error("The installer finished but no Chromium executable was found; set ETHERPK_CHROMIUM to a browser on this machine instead.");
16836
+ if (!after.executable) throw new Error("The installer finished but no Chromium executable was found - set ETHERPK_CHROMIUM to a browser on this machine instead.");
16640
16837
  say(`Chromium is set up: ${after.executable}`);
16641
16838
  return after;
16642
16839
  }
@@ -17166,6 +17363,243 @@ async function writeDownload(directory, name, bytes) {
17166
17363
  }
17167
17364
  throw new Error(`${directory} already holds a thousand files named like ${safe}.`);
17168
17365
  }
17366
+ //#endregion
17367
+ //#region ../client/src/lib/navigation/document-url.ts
17368
+ /** Encode a concept for the `[...concept]` rest param: per-segment, `/` kept. */
17369
+ function encodeConceptPath(concept) {
17370
+ return concept.split("/").map(encodeURIComponent).join("/");
17371
+ }
17372
+ /** The Document URL for a concept within a graph. */
17373
+ function documentUrl(graphId, concept) {
17374
+ return `${graphUrl(graphId)}/d/${encodeConceptPath(concept)}`;
17375
+ }
17376
+ /** The bare workspace URL for a graph. */
17377
+ function graphUrl(graphId) {
17378
+ return `/g/${encodeURIComponent(graphId)}`;
17379
+ }
17380
+ /**
17381
+ * The words that identify a task while its tags change: the text after the checkbox and the tag
17382
+ * run, whitespace collapsed and Unicode NFC applied. A task written as tags alone is its tags.
17383
+ */
17384
+ function taskWords(label) {
17385
+ return (parseTaskTags(label).text || label).normalize("NFC").replace(/\s+/g, " ").trim();
17386
+ }
17387
+ /** The fingerprint of a task's words, from its label (tag run included or not). */
17388
+ async function taskFingerprint(label) {
17389
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(taskWords(label)));
17390
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 12);
17391
+ }
17392
+ /** The reference's address: the Document URL with the task in its fragment, which a browser never sends. */
17393
+ function taskReferenceUrl(reference, origin = "") {
17394
+ return `${origin}${documentUrl(reference.graphId, reference.document)}#task=${reference.line}-${reference.fingerprint}`;
17395
+ }
17396
+ var REFERENCE = /\/g\/([^/?#\s]+)\/d\/([^?#\s]+?)(?:\?[^#\s]*)?#task=(\d+)-([0-9a-f]{12})(?![0-9a-f])/;
17397
+ /**
17398
+ * The reference in `input`: the two lines as copied, the address alone, with any origin or none,
17399
+ * or null when `input` holds none.
17400
+ */
17401
+ function parseTaskReference(input) {
17402
+ const match = REFERENCE.exec(input);
17403
+ if (!match) return null;
17404
+ const [, graph, path, line, fingerprint] = match;
17405
+ try {
17406
+ return {
17407
+ graphId: decodeURIComponent(graph),
17408
+ document: path.split("/").map(decodeURIComponent).join("/"),
17409
+ line: Number(line),
17410
+ fingerprint
17411
+ };
17412
+ } catch {
17413
+ return null;
17414
+ }
17415
+ }
17416
+ /**
17417
+ * Find the task a reference names. First the named document's line, when the words there match;
17418
+ * then the one task in that document with those words; then the one task in the graph. None is
17419
+ * `task_not_found`, more than one `task_ambiguous`, since acting on a guess could change the
17420
+ * wrong task. A document that no longer answers to its name goes straight to the graph.
17421
+ */
17422
+ async function resolveTaskReference(reference, sources) {
17423
+ if (sources.graphId !== null && sources.graphId !== reference.graphId) return {
17424
+ ok: false,
17425
+ code: "other_graph",
17426
+ servedGraphId: sources.graphId
17427
+ };
17428
+ const named = sources.resolveDocument(reference.document);
17429
+ if (named !== null) {
17430
+ const body = await sources.readBody(named);
17431
+ if (body !== null) {
17432
+ const matches = await taskLinesWith(body.split("\n"), reference.fingerprint);
17433
+ if (matches.includes(reference.line)) return {
17434
+ ok: true,
17435
+ document: named,
17436
+ line: reference.line,
17437
+ body,
17438
+ foundBy: "at_line"
17439
+ };
17440
+ if (matches.length === 1) return {
17441
+ ok: true,
17442
+ document: named,
17443
+ line: matches[0],
17444
+ body,
17445
+ foundBy: "moved"
17446
+ };
17447
+ if (matches.length > 1) return {
17448
+ ok: false,
17449
+ code: "task_ambiguous",
17450
+ candidates: matches.map((line) => ({
17451
+ document: named,
17452
+ line
17453
+ }))
17454
+ };
17455
+ }
17456
+ }
17457
+ const found = [];
17458
+ const looked = new Set(named === null ? [] : [named.toLowerCase()]);
17459
+ for (const task of await sources.allTasks()) {
17460
+ if (looked.has(task.document.toLowerCase())) continue;
17461
+ if (await taskFingerprint(task.label) !== reference.fingerprint) continue;
17462
+ looked.add(task.document.toLowerCase());
17463
+ const body = await sources.readBody(task.document);
17464
+ if (body === null) continue;
17465
+ for (const line of await taskLinesWith(body.split("\n"), reference.fingerprint)) found.push({
17466
+ document: task.document,
17467
+ line,
17468
+ body
17469
+ });
17470
+ }
17471
+ if (found.length === 1) return {
17472
+ ok: true,
17473
+ ...found[0],
17474
+ foundBy: "other_document"
17475
+ };
17476
+ if (found.length > 1) return {
17477
+ ok: false,
17478
+ code: "task_ambiguous",
17479
+ candidates: found.map(({ document, line }) => ({
17480
+ document,
17481
+ line
17482
+ }))
17483
+ };
17484
+ return {
17485
+ ok: false,
17486
+ code: "task_not_found"
17487
+ };
17488
+ }
17489
+ /** How many tasks `everyIndexedTask` asks the index for at a time. */
17490
+ var INDEX_PAGE = 500;
17491
+ /** Every task the index knows, in pages, as {@link TaskReferenceSources.allTasks} wants them. */
17492
+ async function everyIndexedTask(index, today) {
17493
+ const query = {
17494
+ concept: null,
17495
+ statuses: TASK_STATUSES,
17496
+ priorities: TASK_PRIORITY_FILTERS,
17497
+ due: "any",
17498
+ groupBy: "document",
17499
+ today
17500
+ };
17501
+ const out = [];
17502
+ for (let offset = 0;; offset += INDEX_PAGE) {
17503
+ const page = await index.tasks(query, offset, INDEX_PAGE);
17504
+ for (const hit of page.hits) out.push({
17505
+ document: hit.concept,
17506
+ line: hit.line,
17507
+ label: hit.text
17508
+ });
17509
+ if (!page.hasMore) return out;
17510
+ }
17511
+ }
17512
+ /** The lines of `lines` that hold a task with this fingerprint, never one inside a code fence. */
17513
+ async function taskLinesWith(lines, fingerprint) {
17514
+ const inFence = /* @__PURE__ */ new Set();
17515
+ for (const block of fencedBlocks(lines)) for (let i = block.start; i <= block.end; i++) inFence.add(i);
17516
+ const out = [];
17517
+ for (let i = 0; i < lines.length; i++) {
17518
+ if (inFence.has(i) || parseTaskLine(lines[i]) === null) continue;
17519
+ if (await taskFingerprint(bulletLabel(lines[i])) === fingerprint) out.push(i);
17520
+ }
17521
+ return out;
17522
+ }
17523
+ //#endregion
17524
+ //#region ../client/src/lib/document/view/minimal-replacement.ts
17525
+ /**
17526
+ * The smallest single replacement turning `text` into `next`: the shared prefix and suffix
17527
+ * trimmed away. Null when the two are equal.
17528
+ *
17529
+ * Used wherever a whole new text arrives for a document that is being edited - a heal, an
17530
+ * external write reaching an open editor - so that the change is the change and nothing more
17531
+ * (ADR 0066): on a synced graph a whole-document replace is one enormous CRDT operation that
17532
+ * clobbers everyone else's concurrent edits, and in any editor it throws the caret to the end.
17533
+ */
17534
+ function minimalReplacement(text, next) {
17535
+ if (next === text) return null;
17536
+ let prefix = 0;
17537
+ while (prefix < text.length && prefix < next.length && text[prefix] === next[prefix]) prefix++;
17538
+ let suffix = 0;
17539
+ while (suffix < text.length - prefix && suffix < next.length - prefix && text[text.length - 1 - suffix] === next[next.length - 1 - suffix]) suffix++;
17540
+ return {
17541
+ from: prefix,
17542
+ to: text.length - suffix,
17543
+ insert: next.slice(prefix, next.length - suffix)
17544
+ };
17545
+ }
17546
+ //#endregion
17547
+ //#region ../client/src/lib/document/task-write.ts
17548
+ var MARKER = /^(\s*-\s+\[)[ xX](\])\s?/;
17549
+ /**
17550
+ * `line` with `changes` made, or null when `line` is not a task.
17551
+ *
17552
+ * A changed tag run is written in the fixed order the `#` helper writes (`serialiseTags`), for
17553
+ * the reason `applyTaskTag` gives: the run is a set, and a predictable order is worth more than
17554
+ * one that carries no meaning. A task already as asked comes back untouched, so a move that
17555
+ * changes nothing reorders nothing either.
17556
+ */
17557
+ function taskLineWith(line, changes) {
17558
+ const parsed = parseTaskLine(line);
17559
+ const marker = MARKER.exec(line);
17560
+ if (!parsed || !marker) return null;
17561
+ const { done: wasDone, ...current } = parsed;
17562
+ const tags = { ...current };
17563
+ let done = wasDone;
17564
+ switch (changes.status) {
17565
+ case "open":
17566
+ done = false;
17567
+ tags.waiting = tags.doing = tags.cancelled = false;
17568
+ break;
17569
+ case "doing":
17570
+ done = false;
17571
+ tags.doing = true;
17572
+ tags.waiting = tags.cancelled = false;
17573
+ break;
17574
+ case "waiting":
17575
+ done = false;
17576
+ tags.waiting = true;
17577
+ tags.doing = tags.cancelled = false;
17578
+ break;
17579
+ case "done":
17580
+ done = true;
17581
+ tags.waiting = tags.doing = tags.cancelled = false;
17582
+ break;
17583
+ case "cancelled":
17584
+ done = true;
17585
+ tags.cancelled = true;
17586
+ tags.waiting = tags.doing = false;
17587
+ break;
17588
+ case void 0: break;
17589
+ }
17590
+ if (changes.priority !== void 0) tags.priority = changes.priority;
17591
+ if (changes.due !== void 0) tags.due = changes.due;
17592
+ if (changes.scheduled !== void 0) tags.scheduled = changes.scheduled;
17593
+ if (done === wasDone && sameTags(tags, current)) return line;
17594
+ return [
17595
+ `${marker[1]}${done ? "x" : " "}${marker[2]}`,
17596
+ ...serialiseTags(tags),
17597
+ tags.text
17598
+ ].filter((part) => part !== "").join(" ");
17599
+ }
17600
+ function sameTags(a, b) {
17601
+ return a.priority === b.priority && a.waiting === b.waiting && a.doing === b.doing && a.cancelled === b.cancelled && a.due === b.due && a.scheduled === b.scheduled && a.completion === b.completion;
17602
+ }
17169
17603
  /** UTF-16 units of document text returned before `truncated` is set. */
17170
17604
  var READ_TEXT_CAP = 2e5;
17171
17605
  /** A refusal the agent can act on: a stable code first, a sentence second. */
@@ -17267,7 +17701,7 @@ async function liveText(graph, identity) {
17267
17701
  return graph.store.open(identity.concept).getText();
17268
17702
  }
17269
17703
  function refuseIfProtected(concept, text) {
17270
- 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.`);
17704
+ 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.`);
17271
17705
  }
17272
17706
  function page$1(items, offset, limit) {
17273
17707
  return {
@@ -17444,7 +17878,7 @@ async function tasks(graph, args = {}) {
17444
17878
  today: todayISO()
17445
17879
  }, offset, limit);
17446
17880
  return {
17447
- tasks: result.hits.map((hit) => ({
17881
+ tasks: await Promise.all(result.hits.map(async (hit) => ({
17448
17882
  concept: hit.concept,
17449
17883
  kind: hit.kind,
17450
17884
  line: hit.line,
@@ -17457,12 +17891,20 @@ async function tasks(graph, args = {}) {
17457
17891
  due: hit.due,
17458
17892
  scheduled: hit.scheduled,
17459
17893
  completion: hit.completion,
17460
- breadcrumb: hit.breadcrumb
17461
- })),
17894
+ breadcrumb: hit.breadcrumb,
17895
+ reference: await referenceFor(graph, hit.concept, hit.line, hit.text)
17896
+ }))),
17462
17897
  total: result.total,
17463
17898
  hasMore: result.hasMore
17464
17899
  };
17465
17900
  }
17901
+ /**
17902
+ * Text an agent sent, in the form EtherPK writes: line feeds only (ADR 0112), then the
17903
+ * two-space Indent Unit (ADR 0067). Line endings go first so the normaliser reads whole lines.
17904
+ */
17905
+ function asWritten(text) {
17906
+ return normaliseIndentUnit(lineFeedsOnly(text));
17907
+ }
17466
17908
  async function editDocument(graph, args) {
17467
17909
  if (args.old === "") throw new ToolError("invalid_argument", "old must not be empty.");
17468
17910
  await graph.store.refresh();
@@ -17470,9 +17912,9 @@ async function editDocument(graph, args) {
17470
17912
  const text = await liveText(graph, identity);
17471
17913
  refuseIfProtected(identity.concept, text);
17472
17914
  const first = text.indexOf(args.old);
17473
- if (first === -1) throw new ToolError("no_match", `"${identity.concept}" does not contain the text to replace. Read the document again; it may have changed.`);
17915
+ if (first === -1) throw new ToolError("no_match", `"${identity.concept}" does not contain the text to replace. Read the document again - it may have changed.`);
17474
17916
  if (text.indexOf(args.old, first + 1) !== -1) throw new ToolError("ambiguous_match", `The text to replace occurs more than once in "${identity.concept}". Include more surrounding text so it matches exactly once.`);
17475
- const insert = normaliseIndentUnit(args.new);
17917
+ const insert = asWritten(args.new);
17476
17918
  graph.store.open(identity.concept).applyChange({
17477
17919
  from: first,
17478
17920
  to: first + args.old.length,
@@ -17486,7 +17928,7 @@ async function editDocument(graph, args) {
17486
17928
  };
17487
17929
  }
17488
17930
  async function appendDocument(graph, args) {
17489
- const text = normaliseIndentUnit(args.text);
17931
+ const text = asWritten(args.text);
17490
17932
  if (text.trim() === "") throw new ToolError("invalid_argument", "text must not be empty.");
17491
17933
  const target = args.concept.trim() === "today" ? todayISO() : args.concept.trim();
17492
17934
  await graph.store.refresh();
@@ -17517,10 +17959,10 @@ async function appendDocument(graph, args) {
17517
17959
  async function createPage(graph, args) {
17518
17960
  const title = args.title.trim();
17519
17961
  if (title === "") throw new ToolError("invalid_argument", "title must not be empty.");
17520
- 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.`);
17962
+ 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.`);
17521
17963
  await graph.store.refresh();
17522
17964
  if (resolveIdentity(graph, title)) throw new ToolError("already_exists", `A document already answers to "${title}". Use edit_document or append_document instead.`);
17523
- await graph.store.createPage(title, normaliseIndentUnit(args.text ?? ""));
17965
+ await graph.store.createPage(title, asWritten(args.text ?? ""));
17524
17966
  if (args.frontmatter && Object.keys(args.frontmatter).length > 0) {
17525
17967
  await graph.store.whenReady(title);
17526
17968
  const raw = graph.store.openRaw(title).getText();
@@ -17847,7 +18289,7 @@ async function listAssets(graph, args = {}) {
17847
18289
  */
17848
18290
  async function readDocuments(graph, args) {
17849
18291
  if (!Array.isArray(args.concepts) || args.concepts.length === 0) throw new ToolError("invalid_argument", "concepts must be a non-empty list of names.");
17850
- if (args.concepts.length > 20) throw new ToolError("too_many", `At most 20 documents per call; ask again for the rest.`);
18292
+ if (args.concepts.length > 20) throw new ToolError("too_many", `At most 20 documents per call - ask again for the rest.`);
17851
18293
  await graph.store.refresh();
17852
18294
  let budget = READ_MANY_TEXT_CAP;
17853
18295
  const documents = [];
@@ -17884,22 +18326,25 @@ async function readDocuments(graph, args) {
17884
18326
  }
17885
18327
  return { documents };
17886
18328
  }
17887
- /** The task line rebuilt with these tags and checkbox: indent and marker kept, the tag run in the fixed order. */
17888
- function taskLineWith(line, done, tags) {
17889
- const marker = /^(\s*-\s+\[)[ xX](\])\s?/.exec(line);
17890
- if (!marker) throw new Error("not a task line");
17891
- const run = serialiseTags(tags);
17892
- return `${marker[1]}${done ? "x" : " "}${marker[2]} ${[...run, tags.text].join(" ")}`.replace(/\s+$/, "");
17893
- }
17894
18329
  /**
17895
18330
  * Change one task's status, priority or dates, by document and line (ADR 0032's grammar).
17896
- * Status is exclusive and means what the index's own derivation means: `waiting` and `doing`
17897
- * are unchecked states, `done` and `cancelled` checked ones, `open` none of them. The Client's
17898
- * checkbox writes no completion date, so neither does this. `expect` is the stale-line guard:
17899
- * the same one the Tasks View applies, made explicit for a caller working from a list.
18331
+ * The line is rewritten by the writer a Kanban Board move uses (`task-write.ts`), so a status
18332
+ * here means exactly what a lane means: exclusive, `waiting` and `doing` unchecked states, `done`
18333
+ * and `cancelled` checked ones, `open` none of them, and no completion date written. `expect` is
18334
+ * the stale-line guard, made explicit for a caller working from a list.
17900
18335
  */
17901
18336
  async function setTask(graph, args) {
17902
- if (!Number.isInteger(args.line) || args.line < 0) throw new ToolError("invalid_argument", "line must be a non-negative integer.");
18337
+ if (args.reference !== void 0) {
18338
+ const found = await requireReferencedTask(graph, args.reference);
18339
+ return setTask(graph, {
18340
+ concept: found.document,
18341
+ line: found.line,
18342
+ changes: args.changes
18343
+ });
18344
+ }
18345
+ if (args.concept === void 0) throw new ToolError("invalid_argument", "Name the task by reference, or by concept and line.");
18346
+ if (args.line === void 0 || !Number.isInteger(args.line) || args.line < 0) throw new ToolError("invalid_argument", "line must be a non-negative integer.");
18347
+ const line = args.line;
17903
18348
  const changes = args.changes ?? {};
17904
18349
  if (changes.status !== void 0 && !TASK_STATUSES.includes(changes.status)) throw new ToolError("invalid_argument", `Unknown task status "${String(changes.status)}".`);
17905
18350
  if (changes.priority !== void 0 && changes.priority !== null && ![
@@ -17913,49 +18358,19 @@ async function setTask(graph, args) {
17913
18358
  const text = await liveText(graph, identity);
17914
18359
  refuseIfProtected(identity.concept, text);
17915
18360
  const lines = text.split("\n");
17916
- const current = lines[args.line];
18361
+ const current = lines[line];
17917
18362
  const parsed = current === void 0 ? null : parseTaskLine(current);
17918
- if (!parsed) throw new ToolError("task_moved", `Line ${args.line} of "${identity.concept}" is not a task now; read the document or list tasks again.`);
18363
+ if (!parsed) throw new ToolError("task_moved", `Line ${line} of "${identity.concept}" is not a task now - read the document or list tasks again.`);
17919
18364
  const afterCheckbox = current.replace(/^\s*-\s+\[[ xX]\]\s?/, "").trim();
17920
- if (args.expect !== void 0 && args.expect.trim() !== afterCheckbox && args.expect.trim() !== parsed.text.trim()) throw new ToolError("task_moved", `Line ${args.line} of "${identity.concept}" now reads "${parsed.text}", not "${args.expect}"; list tasks again before changing it.`);
17921
- const tags = { ...parsed };
17922
- let done = parsed.done;
17923
- switch (changes.status) {
17924
- case "open":
17925
- done = false;
17926
- tags.waiting = tags.doing = tags.cancelled = false;
17927
- break;
17928
- case "doing":
17929
- done = false;
17930
- tags.doing = true;
17931
- tags.waiting = tags.cancelled = false;
17932
- break;
17933
- case "waiting":
17934
- done = false;
17935
- tags.waiting = true;
17936
- tags.doing = tags.cancelled = false;
17937
- break;
17938
- case "done":
17939
- done = true;
17940
- tags.waiting = tags.doing = tags.cancelled = false;
17941
- break;
17942
- case "cancelled":
17943
- done = true;
17944
- tags.cancelled = true;
17945
- tags.waiting = tags.doing = false;
17946
- break;
17947
- case void 0: break;
17948
- }
17949
- if (changes.priority !== void 0) tags.priority = changes.priority;
17950
- if (changes.due !== void 0) tags.due = changes.due;
17951
- if (changes.scheduled !== void 0) tags.scheduled = changes.scheduled;
17952
- const next = taskLineWith(current, done, tags);
17953
- if (next !== current) {
17954
- const from = lines.slice(0, args.line).reduce((n, l) => n + l.length + 1, 0);
18365
+ if (args.expect !== void 0 && args.expect.trim() !== afterCheckbox && args.expect.trim() !== parsed.text.trim()) throw new ToolError("task_moved", `Line ${line} of "${identity.concept}" now reads "${parsed.text}", not "${args.expect}" - list tasks again before changing it.`);
18366
+ const next = taskLineWith(current, changes) ?? current;
18367
+ const edit = minimalReplacement(current, next);
18368
+ if (edit) {
18369
+ const from = lines.slice(0, line).reduce((n, l) => n + l.length + 1, 0);
17955
18370
  graph.store.open(identity.concept).applyChange({
17956
- from,
17957
- to: from + current.length,
17958
- insert: next
18371
+ from: from + edit.from,
18372
+ to: from + edit.to,
18373
+ insert: edit.insert
17959
18374
  }, "external");
17960
18375
  await settle$2(graph);
17961
18376
  }
@@ -17963,13 +18378,124 @@ async function setTask(graph, args) {
17963
18378
  const status = after.done ? after.cancelled ? "cancelled" : "done" : after.waiting ? "waiting" : after.doing ? "doing" : "open";
17964
18379
  return {
17965
18380
  concept: identity.concept,
17966
- line: args.line,
18381
+ line,
17967
18382
  text: after.text,
17968
18383
  status,
17969
18384
  priority: after.priority,
17970
18385
  due: after.due,
17971
18386
  scheduled: after.scheduled,
17972
- changed: next !== current
18387
+ changed: next !== current,
18388
+ reference: await referenceFor(graph, identity.concept, line, bulletLabel(next))
18389
+ };
18390
+ }
18391
+ /** A task's status from its parsed line, as the index derives it (`STATUS_SQL`). */
18392
+ function statusOf(task) {
18393
+ return task.done ? task.cancelled ? "cancelled" : "done" : task.waiting ? "waiting" : task.doing ? "doing" : "open";
18394
+ }
18395
+ /** Where a Task Reference is resolved in this graph: its documents, read live, and its index. */
18396
+ function referenceSources(graph) {
18397
+ return {
18398
+ graphId: graph.backend.kind === "synced" ? graph.graphId : null,
18399
+ resolveDocument: (name) => resolveIdentity(graph, name)?.concept ?? null,
18400
+ async readBody(document) {
18401
+ const identity = resolveIdentity(graph, document);
18402
+ if (!identity) return null;
18403
+ const text = await liveText(graph, identity);
18404
+ return documentProtection(text).kind === "document" ? null : text;
18405
+ },
18406
+ allTasks: () => everyIndexedTask(graph.index, todayISO())
18407
+ };
18408
+ }
18409
+ /** The Task Reference to the task at `line` of `concept`, whose label is `label`. */
18410
+ async function referenceFor(graph, concept, line, label) {
18411
+ const fingerprint = await taskFingerprint(label);
18412
+ return taskReferenceUrl({
18413
+ graphId: graph.graphId,
18414
+ document: concept,
18415
+ line,
18416
+ fingerprint
18417
+ }, graph.clientUrl ?? "");
18418
+ }
18419
+ /** The task a Task Reference names in this graph, or the error that says why there is none. */
18420
+ async function requireReferencedTask(graph, reference) {
18421
+ const parsed = parseTaskReference(reference);
18422
+ if (!parsed) throw new ToolError("invalid_argument", "That is not a task reference: one ends in #task=<line>-<fingerprint>, as Copy task reference in EtherPK writes it.");
18423
+ await graph.store.refresh();
18424
+ const found = await resolveTaskReference(parsed, referenceSources(graph));
18425
+ if (found.ok) return found;
18426
+ if (found.code === "other_graph") throw new ToolError("other_graph", `That task reference is for another graph. This server serves "${graph.name}" (${found.servedGraphId}); ask the user to connect the graph the reference is from.`);
18427
+ if (found.code === "task_ambiguous") throw new ToolError("task_ambiguous", `More than one task has those words (${found.candidates.map((candidate) => `"${candidate.document}" line ${candidate.line}`).join(", ")}), so none was chosen. Ask the user which one, or name it by concept and line.`);
18428
+ throw new ToolError("task_not_found", "No task in the graph has those words now: it was edited or deleted. Ask the user for a fresh reference.");
18429
+ }
18430
+ /**
18431
+ * The block's own lines and everything nested under it: in the index's rows, which run in
18432
+ * document order, the rows after it until one that is not its descendant.
18433
+ */
18434
+ function subtreeEnd(blocks, localId) {
18435
+ let end = blocks[localId].endLine;
18436
+ const inside = new Set([localId]);
18437
+ for (let i = localId + 1; i < blocks.length; i++) {
18438
+ const parent = blocks[i].parentId;
18439
+ if (parent === null || !inside.has(parent)) break;
18440
+ inside.add(i);
18441
+ end = Math.max(end, blocks[i].endLine);
18442
+ }
18443
+ return end;
18444
+ }
18445
+ /**
18446
+ * The task a Task Reference names: its words and tags, where it is now, what is above it (the
18447
+ * headings and bullets its breadcrumb names) and its detail (its continuation lines and
18448
+ * everything nested under it), and how it was found. For the text around it, read the document.
18449
+ */
18450
+ async function readTask(graph, args) {
18451
+ const found = await requireReferencedTask(graph, args.reference);
18452
+ const lines = found.body.split("\n");
18453
+ const line = lines[found.line];
18454
+ const task = parseTaskLine(line);
18455
+ const { blocks } = deriveDoc(found.body);
18456
+ const block = blocks.find((candidate) => candidate.startLine === found.line);
18457
+ const end = block ? subtreeEnd(blocks, block.localId) : found.line;
18458
+ return {
18459
+ concept: found.document,
18460
+ line: found.line,
18461
+ text: task.text,
18462
+ status: statusOf(task),
18463
+ priority: task.priority,
18464
+ due: task.due,
18465
+ scheduled: task.scheduled,
18466
+ breadcrumb: block ? ancestorChain(blocks, block.parentId) : [],
18467
+ detail: lines.slice(found.line + 1, end + 1).join("\n"),
18468
+ foundBy: found.foundBy,
18469
+ reference: await referenceFor(graph, found.document, found.line, bulletLabel(line))
18470
+ };
18471
+ }
18472
+ /**
18473
+ * Add a note under the task a Task Reference names: the text as a bullet one level under the
18474
+ * task, after its existing detail, so a Kanban Board's Task Detail shows it with the task. Text
18475
+ * that is already bullets is nested as it is.
18476
+ */
18477
+ async function addTaskNote(graph, args) {
18478
+ if (typeof args.text !== "string" || args.text.trim() === "") throw new ToolError("invalid_argument", "text must not be empty.");
18479
+ const found = await requireReferencedTask(graph, args.reference);
18480
+ const lines = found.body.split("\n");
18481
+ const { blocks } = deriveDoc(found.body);
18482
+ const block = blocks.find((candidate) => candidate.startLine === found.line);
18483
+ const end = block ? subtreeEnd(blocks, block.localId) : found.line;
18484
+ const childIndent = `${/^\s*/.exec(lines[found.line])[0]} `;
18485
+ const written = asWritten(args.text.trim());
18486
+ const insert = (written.startsWith("- ") ? written : `- ${written.replace(/\n/g, "\n ")}`).split("\n").map((noteLine) => noteLine === "" ? noteLine : childIndent + noteLine).join("\n");
18487
+ const at = lines.slice(0, end + 1).reduce((n, l) => n + l.length + 1, 0) - 1;
18488
+ graph.store.open(found.document).applyChange({
18489
+ from: at,
18490
+ to: at,
18491
+ insert: `\n${insert}`
18492
+ }, "external");
18493
+ await settle$2(graph);
18494
+ return {
18495
+ concept: found.document,
18496
+ line: found.line,
18497
+ noteLine: end + 1,
18498
+ reference: await referenceFor(graph, found.document, found.line, bulletLabel(lines[found.line]))
17973
18499
  };
17974
18500
  }
17975
18501
  /** What an agent should know before it starts: where it is, what is here, what is set up. */
@@ -17994,7 +18520,7 @@ async function graphInfo(graph) {
17994
18520
  complete: status.embedded >= status.total
17995
18521
  } : {
17996
18522
  state: "not-opened",
17997
- note: "The first search with mode \"semantic\" loads the model; if it is not set up on this computer, that search says so and names the command."
18523
+ note: "The first search with mode \"semantic\" loads the model - if it is not set up on this computer, that search says so and names the command."
17998
18524
  },
17999
18525
  assets: { available: graph.assets !== void 0 }
18000
18526
  };
@@ -18110,7 +18636,7 @@ async function createPublication(graph, args, host) {
18110
18636
  await graph.store.refresh();
18111
18637
  const before = summarisePublishing((await graph.publishing.readSource()).source);
18112
18638
  if (before.publications.some((p) => p.id === id)) throw new ToolError("already_exists", `A publication with the id "${id}" already exists (page "${before.publications.find((p) => p.id === id).concept}").`);
18113
- if (graph.store.listDocuments().some((d) => d.key === title.toLowerCase() || d.aliases.some((a) => a.toLowerCase() === title.toLowerCase()))) throw new ToolError("already_exists", `A document already answers to "${title}"; choose another title for the publication page.`);
18639
+ if (graph.store.listDocuments().some((d) => d.key === title.toLowerCase() || d.aliases.some((a) => a.toLowerCase() === title.toLowerCase()))) throw new ToolError("already_exists", `A document already answers to "${title}" - choose another title for the publication page.`);
18114
18640
  if (args.theme) await requireTheme(graph, args.theme);
18115
18641
  const store = frontmatterStore(graph);
18116
18642
  const concept = await createPublicationPage(store, {
@@ -18363,8 +18889,8 @@ var SAMPLE_SOURCE = {
18363
18889
  ""
18364
18890
  ].join("\n")),
18365
18891
  page("[[Getting Started]] Installing", "---\npublic: true\n---\n- A scoped concept: its heading links to its scope. Back to [[Getting Started]].\n"),
18366
- page("Wikilinks", "---\npublic: true\naliases: [Links]\n---\n- `[[Concept]]` links to a page; an alias like [[Links]] resolves to this one.\n", ["Links"]),
18367
- page("Private Notes", "- Not public, so never on the site; links to it are styled as missing.\n"),
18892
+ page("Wikilinks", "---\npublic: true\naliases: [Links]\n---\n- `[[Concept]]` links to a page - an alias like [[Links]] resolves to this one.\n", ["Links"]),
18893
+ page("Private Notes", "- Not public, so never on the site - links to it are styled as missing.\n"),
18368
18894
  {
18369
18895
  concept: "2026-06-02",
18370
18896
  kind: "journal",
@@ -18587,7 +19113,7 @@ async function customisePublicationTheme(graph, args) {
18587
19113
  await graph.store.refresh();
18588
19114
  const { source } = await graph.publishing.readSource();
18589
19115
  const publication = summarisePublishing(source).publications.find((p) => p.id === args.publication.trim());
18590
- if (!publication) throw new ToolError("publication_not_found", `No publication has the id "${args.publication}"; list_publications shows them.`);
19116
+ if (!publication) throw new ToolError("publication_not_found", `No publication has the id "${args.publication}" - list_publications shows them.`);
18591
19117
  const current = await graph.themes.get(publication.theme);
18592
19118
  if (current) return {
18593
19119
  created: false,
@@ -18623,7 +19149,7 @@ async function requireEditable(graph, id) {
18623
19149
  const own = await graph.themes.get(id);
18624
19150
  if (own) return own;
18625
19151
  if (bundledTheme(id)) throw new ToolError("theme_not_editable", `"${id}" is a bundled theme and cannot be edited in place. create_theme (or customise_publication_theme for the publication that uses it) makes a copy in the graph that can be.`);
18626
- throw new ToolError("theme_not_found", `No theme in this graph is called "${id}"; list_themes shows them.`);
19152
+ throw new ToolError("theme_not_found", `No theme in this graph is called "${id}" - list_themes shows them.`);
18627
19153
  }
18628
19154
  function requireThemePath(path) {
18629
19155
  const clean = path.trim().replace(/^\.?\//, "");
@@ -18723,7 +19249,7 @@ async function importThemeFolder(graph, args) {
18723
19249
  } catch (error) {
18724
19250
  throw new ToolError("invalid_argument", `Cannot read "${args.dir}": ${error instanceof Error ? error.message : String(error)}`);
18725
19251
  }
18726
- if (!files.has("theme.json")) throw new ToolError("invalid_argument", `"${args.dir}" has no theme.json; a theme folder has one at its top.`);
19252
+ if (!files.has("theme.json")) throw new ToolError("invalid_argument", `"${args.dir}" has no theme.json - a theme folder has one at its top.`);
18727
19253
  const next = {
18728
19254
  ...theme,
18729
19255
  files: Object.fromEntries(files),
@@ -18745,7 +19271,7 @@ async function deleteTheme(graph, args) {
18745
19271
  await graph.store.refresh();
18746
19272
  const theme = await requireEditable(graph, args.id.trim());
18747
19273
  const users = await publicationsUsing(graph, theme.id);
18748
- if (users.length > 0) throw new ToolError("theme_in_use", `"${theme.id}" is the theme of ${users.map((p) => `"${p.name}"`).join(", ")}; point ${users.length === 1 ? "that publication" : "those publications"} at another theme (update_publication) first.`);
19274
+ if (users.length > 0) throw new ToolError("theme_in_use", `"${theme.id}" is the theme of ${users.map((p) => `"${p.name}"`).join(", ")} - point ${users.length === 1 ? "that publication" : "those publications"} at another theme (update_publication) first.`);
18749
19275
  await graph.themes.remove(theme.id);
18750
19276
  await settle(graph);
18751
19277
  return {
@@ -18770,7 +19296,7 @@ async function previewTheme(graph, args, host) {
18770
19296
  if (args.publication?.trim()) {
18771
19297
  const read = await graph.publishing.readSource();
18772
19298
  const found = summarisePublishing(read.source).publications.find((p) => p.id === args.publication.trim());
18773
- if (!found) throw new ToolError("publication_not_found", `No publication has the id "${args.publication}"; list_publications shows them.`);
19299
+ if (!found) throw new ToolError("publication_not_found", `No publication has the id "${args.publication}" - list_publications shows them.`);
18774
19300
  publication = found;
18775
19301
  source = read.source;
18776
19302
  unsettled = read.unsettled;
@@ -18814,7 +19340,7 @@ async function previewTheme(graph, args, host) {
18814
19340
  ...screenshots ? { screenshots } : {},
18815
19341
  ...chromium ? {
18816
19342
  screenshots: null,
18817
- note: `No browser to photograph with; run ${chromium.setupCommand} or set ETHERPK_CHROMIUM.`
19343
+ note: `No browser to photograph with - run ${chromium.setupCommand} or set ETHERPK_CHROMIUM.`
18818
19344
  } : {}
18819
19345
  };
18820
19346
  } finally {
@@ -18928,25 +19454,26 @@ function createMcpServer(graph, info) {
18928
19454
  version: info.version
18929
19455
  }, { instructions: [
18930
19456
  `You are connected to the EtherPK knowledge graph "${info.graphName}": daily journal entries and titled pages in plain markdown, linked with [[wikilinks]].`,
18931
- "Concept names are case-insensitive. A protected document is listed with protected: true and cannot be read or written here; tell the user to unlock it in EtherPK if a task needs it.",
18932
- "Bullets are \"- \" with two-space nesting; tasks are \"- [ ]\" / \"- [x]\" with task tags (#P1 #P2 #P3, #W #D #C, #D-YYYY-MM-DD due, #S-YYYY-MM-DD scheduled) right after the checkbox.",
18933
- "read_document returns a document's body as text and its frontmatter block as data (the \"frontmatter\" object); the block is never in the text, so line numbers from search, backlinks and tasks match the text. Set frontmatter keys with set_frontmatter (public, publications, date, author, description and any other key); a document's title and aliases and a publication page's definition are not frontmatter here and have tools of their own.",
18934
- "Edit with edit_document (an exact, unique old→new replacement) or append_document; read first, then edit, and never paste a whole page back.",
18935
- "To rename a page or concept use plan_rename then rename, never a frontmatter edit: rename carries scoped concepts (\"[[Old]] Notes\") along and by default rewrites every [[Old]] link in the graph to the new name (strategy \"rewrite\"); strategy \"alias\" keeps the old name resolving instead. A rename onto a name that is taken merges two documents and needs confirm_merge: true. References inside protected documents cannot be seen or rewritten.",
18936
- "search has two kinds of matching: mode \"text\" (default) matches words and quoted phrases; mode \"semantic\" finds passages about what a question or description means even when no words match. Use semantic for questions and descriptions, text for names, identifiers and exact phrases; \"hybrid\" returns both groups. When you answer from a search result, cite the document and where in it (its breadcrumb or lines). Semantic mode needs a one-time \"semantic setup\" on this computer; if it is not set up, search says so and names the command.",
18937
- "Start with graph_info to see what you are connected to. read_documents reads several documents in one call; tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
18938
- "Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it; its outline is the site navigation) and the public documents none takes; a document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings; publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
18939
- "Themes: a publication's look is a theme - Mustache templates, a stylesheet, a script and a manifest. list_themes shows the bundled ones (read-only) and the graph's own; read_theme writes a theme's files to a folder on this machine to read and edit; customise_publication_theme copies a publication's bundled theme into the graph and points the publication at the copy (create_theme copies any theme); write_theme_file, delete_theme_file and import_theme_folder change a graph theme; preview_theme renders a theme to a folder (with screenshots when a browser is set up) to check before publish. For a snippet such as an analytics script, an include slot (update_publication includes, e.g. head) filled by a page may be lighter than a theme copy.",
18940
- "Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document; read_asset writes an asset to a local file you can open; list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session). read_asset, read_theme and preview_theme write under the graph's downloads directory and return the path; name a folder relative to it, never elsewhere.",
19457
+ "Concept names are case-insensitive. A protected document is listed with protected: true and cannot be read or written here - tell the user to unlock it in EtherPK if a task needs it.",
19458
+ "Bullets are \"- \" with two-space nesting. Tasks are \"- [ ]\" / \"- [x]\" with task tags (#P1 #P2 #P3, #W #D #C, #D-YYYY-MM-DD due, #S-YYYY-MM-DD scheduled) right after the checkbox.",
19459
+ "read_document returns a document's body as text and its frontmatter block as data (the \"frontmatter\" object) - the block is never in the text, so line numbers from search, backlinks and tasks match the text. Set frontmatter keys with set_frontmatter (public, publications, date, author, description and any other key) - a document's title and aliases and a publication page's definition are not frontmatter here and have tools of their own.",
19460
+ "Edit with edit_document (an exact, unique old→new replacement) or append_document - read first, then edit, and never paste a whole page back.",
19461
+ "To rename a page or concept use plan_rename then rename, never a frontmatter edit: rename carries scoped concepts (\"[[Old]] Notes\") along and by default rewrites every [[Old]] link in the graph to the new name (strategy \"rewrite\") - strategy \"alias\" keeps the old name resolving instead. A rename onto a name that is taken merges two documents and needs confirm_merge: true. References inside protected documents cannot be seen or rewritten.",
19462
+ "search has two kinds of matching: mode \"text\" (default) matches words and quoted phrases, and mode \"semantic\" finds passages about what a question or description means even when no words match. Use semantic for questions and descriptions, text for names, identifiers and exact phrases. \"hybrid\" returns both groups. When you answer from a search result, cite the document and where in it (its breadcrumb or lines). Semantic mode needs a one-time \"semantic setup\" on this computer - if it is not set up, search says so and names the command.",
19463
+ "Start with graph_info to see what you are connected to. read_documents reads several documents in one call. tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
19464
+ "A task reference is how a person hands you one task: its words, then an EtherPK address ending in #task=<line>-<fingerprint>. Pass the whole reference, or the address, to read_task for the task and its detail, to set_task (as reference) to change it, and to add_task_note to write what you did under it. It finds the task after lines are added above it or its tags change; if its words were edited it answers task_not_found, and you should ask the user for a fresh one.",
19465
+ "Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it - its outline is the site navigation) and the public documents none takes. A document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings, and publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
19466
+ "Themes: a publication's look is a theme - Mustache templates, a stylesheet, a script and a manifest. list_themes shows the bundled ones (read-only) and the graph's own. read_theme writes a theme's files to a folder on this machine to read and edit. customise_publication_theme copies a publication's bundled theme into the graph and points the publication at the copy (create_theme copies any theme). write_theme_file, delete_theme_file and import_theme_folder change a graph theme. preview_theme renders a theme to a folder (with screenshots when a browser is set up) to check before publish. For a snippet such as an analytics script, an include slot (update_publication includes, e.g. head) filled by a page may be lighter than a theme copy.",
19467
+ "Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document. read_asset writes an asset to a local file you can open, and list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session). read_asset, read_theme and preview_theme write under the graph's downloads directory and return the path - name a folder relative to it, never elsewhere.",
18941
19468
  "The user documentation is at https://docs.etherpk.com."
18942
19469
  ].join("\n") });
18943
19470
  server.registerTool("list_documents", {
18944
19471
  title: "List documents",
18945
- description: `Every page and journal entry in the graph with its aliases; protected documents carry protected: true and have no readable body. from / to narrow to the journal entries of a range of days. Paged, at most 200 per call.`,
19472
+ description: `Every page and journal entry in the graph with its aliases - protected documents carry protected: true and have no readable body. from / to narrow to the journal entries of a range of days. Paged, at most 200 per call.`,
18946
19473
  inputSchema: {
18947
19474
  kind: z.enum(["page", "journal"]).optional().describe("Only pages, or only journal entries."),
18948
- from: z.string().optional().describe("Journal entries on or after this day (YYYY-MM-DD); implies kind journal."),
18949
- to: z.string().optional().describe("Journal entries on or before this day (YYYY-MM-DD); implies kind journal."),
19475
+ from: z.string().optional().describe("Journal entries on or after this day (YYYY-MM-DD) - implies kind journal."),
19476
+ to: z.string().optional().describe("Journal entries on or before this day (YYYY-MM-DD) - implies kind journal."),
18950
19477
  offset,
18951
19478
  limit: z.number().int().positive().max(200).optional()
18952
19479
  }
@@ -18963,7 +19490,7 @@ function createMcpServer(graph, info) {
18963
19490
  }, async (args) => run(() => readDocuments(graph, args)));
18964
19491
  server.registerTool("search", {
18965
19492
  title: "Search",
18966
- description: `Search every unprotected document. mode "text" (default): words match by prefix, "quoted phrases" match exactly; documents come most-matches-first with the matching lines. In text mode a term key:value is a property filter on the documents' frontmatter when some document has that key (keys and values ignore case; key:"two words", -key:value excludes, key:* means the key is set, key:va* matches the start of a value, publication.id:docs reaches a nested key, title: and aliases: match the document's names); filters alone list every matching document with the values they matched and no hits. mode "semantic": the passages closest in MEANING to the query, best first, each with its text, a similarity 0..1 and where it is (concept, breadcrumb of headings and parent bullets, start and end line) - finds a note that says "self-assessment is due 31 January" for "tax deadline". Line numbers are 0-based in every mode. Passage text is the note's own words with bullet markers and [[ ]] stripped: quote and cite it, but anchor edit_document on text from read_document. Use semantic for questions and descriptions, text for names, identifiers and exact phrases; mode "hybrid" returns both as separate groups. Semantic results carry complete: false while this computer is still embedding the graph (rerun later for more). If semantic mode answers error semantic_unavailable, it is not set up on this computer: tell the user the command in the message. At most 50 documents per call.`,
19493
+ description: `Search every unprotected document. mode "text" (default): words match by prefix, "quoted phrases" match exactly. Documents come most-matches-first with the matching lines. In text mode a term key:value is a property filter on the documents' frontmatter when some document has that key, and keys and values ignore case (key:"two words", -key:value excludes, key:* means the key is set, key:va* matches the start of a value, publication.id:docs reaches a nested key, title: and aliases: match the document's names). Filters alone list every matching document with the values they matched and no hits. mode "semantic": the passages closest in MEANING to the query, best first, each with its text, a similarity 0..1 and where it is (concept, breadcrumb of headings and parent bullets, start and end line) - finds a note that says "self-assessment is due 31 January" for "tax deadline". Line numbers are 0-based in every mode. Passage text is the note's own words with bullet markers and [[ ]] stripped: quote and cite it, but anchor edit_document on text from read_document. Use semantic for questions and descriptions, text for names, identifiers and exact phrases. mode "hybrid" returns both as separate groups. Semantic results carry complete: false while this computer is still embedding the graph (rerun later for more). If semantic mode answers error semantic_unavailable, it is not set up on this computer: tell the user the command in the message. At most 50 documents per call.`,
18967
19494
  inputSchema: {
18968
19495
  query: z.string().min(1),
18969
19496
  mode: z.enum([
@@ -18982,7 +19509,7 @@ function createMcpServer(graph, info) {
18982
19509
  }, async (args) => run(() => backlinks(graph, args.concept)));
18983
19510
  server.registerTool("tasks", {
18984
19511
  title: "Tasks",
18985
- description: `Tasks ("- [ ]" bullets) across the graph with their tags. Defaults to every unfinished task. A concept filter matches tasks written on that page, linking it, or nested under a block or heading that links it. At most 200 per call.`,
19512
+ description: `Tasks ("- [ ]" bullets) across the graph with their tags. Defaults to every unfinished task. A concept filter matches tasks written on that page or on a page named for it ("[[Concept]] Notes"), linking it, or nested under a block or heading that links it. At most 200 per call.`,
18986
19513
  inputSchema: {
18987
19514
  concept: concept.optional(),
18988
19515
  statuses: z.array(z.enum([
@@ -19008,12 +19535,26 @@ function createMcpServer(graph, info) {
19008
19535
  limit: z.number().int().positive().max(200).optional()
19009
19536
  }
19010
19537
  }, async (args) => run(() => tasks(graph, args)));
19538
+ server.registerTool("read_task", {
19539
+ title: "Read a task",
19540
+ description: "The task a task reference names: its text, status, priority, due and scheduled days, its document and current 0-based line, its breadcrumb (the headings and bullets above it), its detail (its continuation lines and everything nested under it), foundBy (at_line, moved or other_document) and its current reference. Errors: task_not_found (its words were edited or it was deleted), task_ambiguous (two tasks have its words; the message names them), other_graph (the reference is from another graph).",
19541
+ inputSchema: { reference: z.string().min(1).describe("The task reference as the user gave it: the two lines, or the address alone.") }
19542
+ }, async (args) => run(() => readTask(graph, args)));
19543
+ server.registerTool("add_task_note", {
19544
+ title: "Add a note under a task",
19545
+ description: "Add text under the task a task reference names, one level in, after what is already nested there: a line of text becomes a bullet, and bullets are nested as written. Use it to record what you did about the task. Refuses as read_task does when the reference finds no single task.",
19546
+ inputSchema: {
19547
+ reference: z.string().min(1).describe("The task reference as the user gave it."),
19548
+ text: z.string().min(1).describe("Markdown: a line, or \"- \" bullets.")
19549
+ }
19550
+ }, async (args) => run(() => addTaskNote(graph, args)));
19011
19551
  server.registerTool("set_task", {
19012
19552
  title: "Set a task",
19013
- description: "Change one task by document and 0-based line (as tasks and read_document count lines): its status (open, doing, waiting, done, cancelled - exclusive), priority (1, 2, 3 or null), due and scheduled days (YYYY-MM-DD or null). Pass expect with the task's text from tasks; if the line no longer holds that task the change is refused with error \"task_moved\" and nothing is edited.",
19553
+ description: "Change one task, named by a task reference or by document and 0-based line (as tasks and read_document count lines): its status (open, doing, waiting, done, cancelled - exclusive), priority (1, 2, 3 or null), due and scheduled days (YYYY-MM-DD or null). By line, pass expect with the task's text from tasks - if the line no longer holds that task the change is refused with error \"task_moved\" and nothing is edited. A reference finds the task wherever it is now.",
19014
19554
  inputSchema: {
19015
- concept,
19016
- line: z.number().int().nonnegative(),
19555
+ reference: z.string().min(1).optional().describe("A task reference, in place of concept, line and expect."),
19556
+ concept: concept.optional(),
19557
+ line: z.number().int().nonnegative().optional(),
19017
19558
  changes: z.object({
19018
19559
  status: z.enum([
19019
19560
  "open",
@@ -19036,7 +19577,7 @@ function createMcpServer(graph, info) {
19036
19577
  }, async (args) => run(() => setTask(graph, args)));
19037
19578
  server.registerTool("edit_document", {
19038
19579
  title: "Edit a document",
19039
- 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.",
19580
+ 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.",
19040
19581
  inputSchema: {
19041
19582
  concept,
19042
19583
  old: z.string().min(1).describe("The exact text to replace, as read_document returned it."),
@@ -19070,7 +19611,7 @@ function createMcpServer(graph, info) {
19070
19611
  }, async (args) => run(() => planRename(graph, args)));
19071
19612
  server.registerTool("rename", {
19072
19613
  title: "Rename",
19073
- description: "Rename a page or concept. Its page (if any) and every scoped concept beneath it (\"[[Old]] Notes\" becomes \"[[New]] Notes\") move with it. strategy \"rewrite\" (default) rewrites every [[Old]] link in the graph to the new name; \"alias\" keeps the old name as an alias so existing links keep resolving. If the new name is already taken the two documents would merge, which is irreversible: refused with error \"merge_requires_confirmation\" unless confirm_merge is true. Refused with \"unconfirmed_documents\" if a document it must read has not finished syncing to this device (nothing changes; try again). Call plan_rename first to see what will happen.",
19614
+ description: "Rename a page or concept. Its page (if any) and every scoped concept beneath it (\"[[Old]] Notes\" becomes \"[[New]] Notes\") move with it. strategy \"rewrite\" (default) rewrites every [[Old]] link in the graph to the new name. \"alias\" keeps the old name as an alias so existing links keep resolving. If the new name is already taken the two documents would merge, which is irreversible: refused with error \"merge_requires_confirmation\" unless confirm_merge is true. Refused with \"unconfirmed_documents\" if a document it must read has not finished syncing to this device (nothing changes - try again). Call plan_rename first to see what will happen.",
19074
19615
  inputSchema: {
19075
19616
  from: z.string().min(1),
19076
19617
  to: z.string().min(1),
@@ -19083,7 +19624,7 @@ function createMcpServer(graph, info) {
19083
19624
  description: "Replace a document's aliases: other names it answers to in [[wikilinks]], Quick Find and on a published site. The list you pass is the complete list. A name that is already another document's title or alias is refused with error \"name_taken\".",
19084
19625
  inputSchema: {
19085
19626
  concept,
19086
- aliases: z.array(z.string()).describe("Every alias the document should have; an empty list removes them all.")
19627
+ aliases: z.array(z.string()).describe("Every alias the document should have - an empty list removes them all.")
19087
19628
  }
19088
19629
  }, async (args) => run(() => setAliases(graph, args)));
19089
19630
  server.registerTool("set_frontmatter", {
@@ -19091,23 +19632,23 @@ function createMcpServer(graph, info) {
19091
19632
  description: "Set keys of a document's frontmatter block, adding the block if there is none. A merge patch: the keys you name are set, null removes a key, every other key keeps its value. \"public\" (true/false) and \"publications\" (a list of publication ids: lower-case letters, digits and hyphens) decide what is published, and are validated. Refuses \"title\", \"aliases\" and \"publication\" with error \"identity_key\" (use rename, set_aliases and the publication tools), and a protected document. Returns the resulting frontmatter.",
19092
19633
  inputSchema: {
19093
19634
  concept,
19094
- patch: z.record(z.string(), frontmatterValue).describe("Keys to set; null removes a key. Example: {\"public\": true, \"publications\": [\"blog\"], \"date\": \"2026-01-31\", \"draft\": null}.")
19635
+ patch: z.record(z.string(), frontmatterValue).describe("Keys to set - null removes a key. Example: {\"public\": true, \"publications\": [\"blog\"], \"date\": \"2026-01-31\", \"draft\": null}.")
19095
19636
  }
19096
19637
  }, async (args) => run(() => setFrontmatter(graph, args)));
19097
19638
  server.registerTool("upload_asset", {
19098
19639
  title: "Upload an asset",
19099
- description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"invalid_argument\" for a folder, a file over 100 MiB, a hidden file or one in a hidden folder (.ssh, .env), and the Headless Client's own config and cache; with error \"asset_refused\" when the server declines it on a quota.",
19640
+ description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"invalid_argument\" for a folder, a file over 100 MiB, a hidden file or one in a hidden folder (.ssh, .env), and the Headless Client's own config and cache. Refused with error \"asset_refused\" when the server declines it on a quota.",
19100
19641
  inputSchema: {
19101
19642
  path: z.string().min(1).describe("Absolute path of an ordinary file on this machine."),
19102
- name: z.string().min(1).optional().describe("The name to store it under; the file's own name by default.")
19643
+ name: z.string().min(1).optional().describe("The name to store it under - the file's own name by default.")
19103
19644
  }
19104
19645
  }, async (args) => run(() => uploadAsset(graph, args)));
19105
19646
  server.registerTool("read_asset", {
19106
19647
  title: "Read an asset",
19107
- description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session); otherwise error \"asset_not_found\". The file is written under the graph's downloads directory, named after the asset, and never over an existing file.",
19648
+ description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session) - otherwise error \"asset_not_found\". The file is written under the graph's downloads directory, named after the asset, and never over an existing file.",
19108
19649
  inputSchema: {
19109
19650
  ref: z.string().min(1),
19110
- out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; the downloads directory itself by default. A folder outside it is refused.")
19651
+ out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it - the downloads directory itself by default. A folder outside it is refused.")
19111
19652
  }
19112
19653
  }, async (args) => run(() => readAsset(graph, args)));
19113
19654
  server.registerTool("list_assets", {
@@ -19121,25 +19662,25 @@ function createMcpServer(graph, info) {
19121
19662
  };
19122
19663
  server.registerTool("list_publications", {
19123
19664
  title: "List publications",
19124
- description: "The publications this graph defines - id, name, the page that defines each, kind (docs or blog), selection (named: documents that list it in publications; all-public: every public document), home, url, theme, include slots, and the publish folder set for it on this machine (null when none) - with any issues in their pages and the public documents no publication takes.",
19665
+ description: "The publications this graph defines - id, name, the page that defines each, kind (docs or blog), selection (named: documents that list it in publications, all-public: every public document), home, url, theme, include slots, and the publish folder set for it on this machine (null when none) - with any issues in their pages and the public documents no publication takes.",
19125
19666
  inputSchema: {}
19126
19667
  }, async () => run(() => listPublications(graph, host)));
19127
19668
  server.registerTool("create_publication", {
19128
19669
  title: "Create a publication",
19129
- description: "Create a publication: a page titled with its name whose frontmatter defines it and whose outline (bullets of [[wikilinks]]) is the site navigation. id defaults to a slug of the title; kind is docs (default) or blog; selection is named (default: a document joins by listing the id in its publications) or all-public. theme is a bundled theme name (etherpk-docs, etherpk-blog), a theme in the graph, or a url.",
19670
+ description: "Create a publication: a page titled with its name whose frontmatter defines it and whose outline (bullets of [[wikilinks]]) is the site navigation. id defaults to a slug of the title. kind is docs (default) or blog. selection is named (default: a document joins by listing the id in its publications) or all-public. theme is a bundled theme name (etherpk-docs, etherpk-blog), a theme in the graph, or a url.",
19130
19671
  inputSchema: {
19131
19672
  title: z.string().min(1),
19132
19673
  id: z.string().min(1).optional(),
19133
19674
  kind: z.enum(["docs", "blog"]).optional(),
19134
19675
  selection: z.enum(["named", "all-public"]).optional(),
19135
- home: z.string().min(1).optional().describe("The page whose content is the front page; absent, an index is generated."),
19676
+ home: z.string().min(1).optional().describe("The page whose content is the front page - absent, an index is generated."),
19136
19677
  url: z.string().min(1).optional().describe("Where the site will be deployed, for its feed and sitemap."),
19137
19678
  theme: z.string().min(1).optional()
19138
19679
  }
19139
19680
  }, async (args) => run(() => createPublication(graph, args, host)));
19140
19681
  server.registerTool("update_publication", {
19141
19682
  title: "Update a publication",
19142
- description: "Change a publication's settings on its page. Each change is optional; null removes a field (back to its default) or clears an include slot. includes maps a theme's slot name (head, logo, header, footer, before, after, scripts, styles, or one the theme adds) to the page that fills it; that page must be public and in the publication.",
19683
+ description: "Change a publication's settings on its page. Each change is optional - null removes a field (back to its default) or clears an include slot. includes maps a theme's slot name (head, logo, header, footer, before, after, scripts, styles, or one the theme adds) to the page that fills it - that page must be public and in the publication.",
19143
19684
  inputSchema: {
19144
19685
  id: z.string().min(1),
19145
19686
  changes: z.object({
@@ -19148,27 +19689,27 @@ function createMcpServer(graph, info) {
19148
19689
  home: z.string().nullable().optional(),
19149
19690
  url: z.string().nullable().optional(),
19150
19691
  theme: z.string().nullable().optional(),
19151
- recent: z.number().int().nonnegative().nullable().optional().describe("Posts on a blog's front page; null for the default (10)."),
19692
+ recent: z.number().int().nonnegative().nullable().optional().describe("Posts on a blog's front page - null for the default (10)."),
19152
19693
  includes: z.record(z.string(), z.string().nullable()).optional()
19153
19694
  })
19154
19695
  }
19155
19696
  }, async (args) => run(() => updatePublication(graph, args, host)));
19156
19697
  server.registerTool("publish", {
19157
19698
  title: "Publish",
19158
- description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then; the tool never chooses a folder). Pages with Mermaid diagrams need the browser from \"diagrams setup\" (error \"chromium_unavailable\"). The report is this result, trimmed to counts and first entries; nothing of it is written into the folder, so the site never names the documents it leaves out. Publishing writes files; it does not deploy them.",
19699
+ description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then - the tool never chooses a folder). Pages with Mermaid diagrams need the browser from \"diagrams setup\" (error \"chromium_unavailable\"). The report is this result, trimmed to counts and first entries - nothing of it is written into the folder, so the site never names the documents it leaves out. Publishing writes files - it does not deploy them.",
19159
19700
  inputSchema: { id: z.string().min(1) }
19160
19701
  }, async (args) => run(() => publish(graph, args, host)));
19161
19702
  server.registerTool("list_themes", {
19162
19703
  title: "List themes",
19163
- description: "Every theme a publication here can use: the bundled ones (etherpk-docs, etherpk-blog; read-only, with their files and manifest) and the graph's own (editable; id, name, origin, files, manifest, validation errors, and which publications use each).",
19704
+ description: "Every theme a publication here can use: the bundled ones (etherpk-docs and etherpk-blog, read-only, with their files and manifest) and the graph's own (editable, with id, name, origin, files, manifest, validation errors, and which publications use each).",
19164
19705
  inputSchema: {}
19165
19706
  }, async () => run(() => listThemes(graph)));
19166
19707
  server.registerTool("read_theme", {
19167
19708
  title: "Read a theme",
19168
- description: "Write a theme's files (theme.json, layouts/, partials/, assets/) to a folder on this machine and return the path and file list, so you can read and edit them with your own tools. ref is a graph theme id, a bundled theme name or a url. Editing the folder changes nothing until write_theme_file or import_theme_folder brings it back; a bundled or url theme cannot be edited in place at all (create_theme copies it).",
19709
+ description: "Write a theme's files (theme.json, layouts/, partials/, assets/) to a folder on this machine and return the path and file list, so you can read and edit them with your own tools. ref is a graph theme id, a bundled theme name or a url. Editing the folder changes nothing until write_theme_file or import_theme_folder brings it back. A bundled or url theme cannot be edited in place at all (create_theme copies it).",
19169
19710
  inputSchema: {
19170
19711
  ref: z.string().min(1),
19171
- out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; themes/<ref> there by default. A folder outside it is refused.")
19712
+ out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it - themes/<ref> there by default. A folder outside it is refused.")
19172
19713
  }
19173
19714
  }, async (args) => run(() => readTheme(graph, args)));
19174
19715
  server.registerTool("read_theme_file", {
@@ -19181,19 +19722,19 @@ function createMcpServer(graph, info) {
19181
19722
  }, async (args) => run(() => readThemeFile(graph, args)));
19182
19723
  server.registerTool("create_theme", {
19183
19724
  title: "Create a theme",
19184
- description: "Copy a theme into the graph as one of its own, ready to edit: from a bundled theme name, a url, or another theme in the graph. Returns the new theme; point a publication at it with update_publication (theme: <id>).",
19725
+ description: "Copy a theme into the graph as one of its own, ready to edit: from a bundled theme name, a url, or another theme in the graph. Returns the new theme - point a publication at it with update_publication (theme: <id>).",
19185
19726
  inputSchema: {
19186
19727
  from: z.string().min(1),
19187
- id: z.string().min(1).optional().describe("Lower-case letters, digits and hyphens; from the source by default."),
19728
+ id: z.string().min(1).optional().describe("Lower-case letters, digits and hyphens - from the source by default."),
19188
19729
  name: z.string().min(1).optional()
19189
19730
  }
19190
19731
  }, async (args) => run(() => createTheme(graph, args)));
19191
19732
  server.registerTool("customise_publication_theme", {
19192
19733
  title: "Customise a publication's theme",
19193
- description: "Make a publication's theme editable: if it uses a bundled or url theme, copy that theme into the graph and point the publication at the copy; if it already uses a graph theme, return it. The step before editing the look of a published site.",
19734
+ description: "Make a publication's theme editable: if it uses a bundled or url theme, copy that theme into the graph and point the publication at the copy. If it already uses a graph theme, return it. The step before editing the look of a published site.",
19194
19735
  inputSchema: {
19195
19736
  publication: z.string().min(1).describe("The publication id."),
19196
- id: z.string().min(1).optional().describe("The id for the copy; <publication>-theme by default."),
19737
+ id: z.string().min(1).optional().describe("The id for the copy - <publication>-theme by default."),
19197
19738
  name: z.string().min(1).optional()
19198
19739
  }
19199
19740
  }, async (args) => run(() => customisePublicationTheme(graph, args)));
@@ -19208,7 +19749,7 @@ function createMcpServer(graph, info) {
19208
19749
  }, async (args) => run(() => writeThemeFile(graph, args)));
19209
19750
  server.registerTool("delete_theme_file", {
19210
19751
  title: "Delete a theme file",
19211
- description: "Remove one file from a graph theme; the theme is validated afterwards and problems returned as errors.",
19752
+ description: "Remove one file from a graph theme - the theme is validated afterwards and problems returned as errors.",
19212
19753
  inputSchema: {
19213
19754
  id: z.string().min(1),
19214
19755
  path: z.string().min(1)
@@ -19216,7 +19757,7 @@ function createMcpServer(graph, info) {
19216
19757
  }, async (args) => run(() => deleteThemeFile(graph, args)));
19217
19758
  server.registerTool("import_theme_folder", {
19218
19759
  title: "Import a theme folder",
19219
- description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme; the folder needs a theme.json at its top. dir is the folder read_theme returned, or another under the graph's downloads directory; a folder outside it is refused, and symbolic links in it are skipped. Validated afterwards.",
19760
+ description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme - the folder needs a theme.json at its top. dir is the folder read_theme returned, or another under the graph's downloads directory - a folder outside it is refused, and symbolic links in it are skipped. Validated afterwards.",
19220
19761
  inputSchema: {
19221
19762
  id: z.string().min(1),
19222
19763
  dir: z.string().min(1)
@@ -19224,12 +19765,12 @@ function createMcpServer(graph, info) {
19224
19765
  }, async (args) => run(() => importThemeFolder(graph, args)));
19225
19766
  server.registerTool("delete_theme", {
19226
19767
  title: "Delete a theme",
19227
- description: "Remove a graph theme. Refused with error \"theme_in_use\" while a publication's saved settings name it; point the publication at another theme first.",
19768
+ description: "Remove a graph theme. Refused with error \"theme_in_use\" while a publication's saved settings name it - point the publication at another theme first.",
19228
19769
  inputSchema: { id: z.string().min(1) }
19229
19770
  }, async (args) => run(() => deleteTheme(graph, args)));
19230
19771
  server.registerTool("preview_theme", {
19231
19772
  title: "Preview a theme",
19232
- description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder. The page loads nothing from the network while it is photographed. out_dir is a folder under the graph's downloads directory (previews/<name> there by default); a folder outside it is refused.",
19773
+ description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder. The page loads nothing from the network while it is photographed. out_dir is a folder under the graph's downloads directory (previews/<name> there by default) - a folder outside it is refused.",
19233
19774
  inputSchema: {
19234
19775
  theme: z.string().min(1).optional(),
19235
19776
  publication: z.string().min(1).optional(),
@@ -19465,20 +20006,20 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
19465
20006
  Serve one synced graph to an agent over stdio. For Claude Code:
19466
20007
  claude mcp add etherpk -- npx @appsoftwareltd/etherpk-mcp serve --sync-server <url> --graph <id>
19467
20008
  Once "semantic setup" has run on this computer, serve also keeps a search-by-meaning
19468
- store of the graph current (the agent's search tool gains mode: semantic); pass
20009
+ store of the graph current (the agent's search tool gains mode: semantic) - pass
19469
20010
  --no-semantic to leave it off for this registration.
19470
20011
  ${CMD} serve --folder <path> [--no-semantic]
19471
20012
  Serve a local graph folder the same way: no sign-in, no server. The agent gets search,
19472
20013
  backlinks, tasks and format-safe edits over the folder's markdown, alongside the files
19473
20014
  themselves. Edits made in an editor or by the agent directly are picked up as they land.
19474
- The folder must already be a graph (open it in EtherPK once); its index is kept under
20015
+ The folder must already be a graph (open it in EtherPK once) - its index is kept under
19475
20016
  the cache directory, never in the folder.
19476
20017
  ${CMD} logout [--sync-server <url> | --all]
19477
20018
  Forget that server's token, keys and cached graphs on this machine.
19478
20019
  ${CMD} semantic setup
19479
20020
  Let the agent search by meaning. Installs a ~300 MB native runtime (onnxruntime-node,
19480
20021
  with your npm) and downloads a 23 MB embedding model into the cache directory, once
19481
- per computer. Nothing leaves this computer when the model runs; notes are never sent
20022
+ per computer. Nothing leaves this computer when the model runs - notes are never sent
19482
20023
  to a service to be embedded.
19483
20024
  ${CMD} semantic status | remove
19484
20025
  Whether it is set up here, or delete it (logout --all deletes it too).
@@ -19488,20 +20029,20 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
19488
20029
  then on this command without --out, and the agent's publish tool, write to the same
19489
20030
  folder (the agent cannot choose one). The folder's owned files (.html at the top,
19490
20031
  assets/, theme/, the search, feed and report files) are rewritten and their strays
19491
- removed; everything else in it is left alone. Publishing writes files; deploying them
20032
+ removed - everything else in it is left alone. Publishing writes files - deploying them
19492
20033
  (a git push, say) is yours. Run it from cron for a site that republishes itself.
19493
20034
  ${CMD} diagrams setup
19494
20035
  Let a publish draw Mermaid diagrams. Downloads a Chromium (about 170 MB) into the cache
19495
- directory with Playwright's installer, once per computer; a publish whose pages hold
20036
+ directory with Playwright's installer, once per computer - a publish whose pages hold
19496
20037
  diagrams refuses until this has run. Set ETHERPK_CHROMIUM=<path> to use a browser
19497
20038
  already on this machine instead (NixOS needs this).
19498
20039
  ${CMD} diagrams status
19499
20040
  Which browser a publish would use, if any.
19500
20041
 
19501
- This machine can hold logins for several Sync Servers at once; --sync-server says which one
19502
- a command means, and can be left out while there is only one. The config file is
20042
+ This machine can hold logins for several Sync Servers at once. The --sync-server flag says
20043
+ which one a command means, and can be left out while there is only one. The config file is
19503
20044
  ${defaultConfigPath()} (override with
19504
- ETHERPK_MCP_CONFIG); cached graphs live under ~/.cache/etherpk/mcp (override with
20045
+ ETHERPK_MCP_CONFIG) - cached graphs live under ~/.cache/etherpk/mcp (override with
19505
20046
  ETHERPK_MCP_CACHE_DIR).
19506
20047
  Docs: https://docs.etherpk.com/using-ai-agents-with-your-notes
19507
20048
  `;
@@ -19514,7 +20055,7 @@ function fail(message) {
19514
20055
  * an agent starting the process), since each question has its own option or variable.
19515
20056
  */
19516
20057
  async function ask(question, { secret = false, hint }) {
19517
- if (!process.stdin.isTTY) fail(`${question.replace(/:\s*$/, "")}: no terminal to ask on; ${hint}.`);
20058
+ if (!process.stdin.isTTY) fail(`${question.replace(/:\s*$/, "")}: no terminal to ask on - ${hint}.`);
19518
20059
  if (!secret) {
19519
20060
  const rl = createInterface({
19520
20061
  input: process.stdin,
@@ -19553,7 +20094,7 @@ async function ask(question, { secret = false, hint }) {
19553
20094
  }
19554
20095
  async function loadConfig(path) {
19555
20096
  const config = await readConfig(path);
19556
- if (!config) fail(`${path} is not a config file this version understands. Run: ${CMD} login --sync-server <url> (it will be rewritten; nothing else is affected).`);
20097
+ if (!config) fail(`${path} is not a config file this version understands. Run: ${CMD} login --sync-server <url> (it will be rewritten - nothing else is affected).`);
19557
20098
  return config;
19558
20099
  }
19559
20100
  /** The login a command means, or the reason there is none - in words the user can act on. */
@@ -19594,7 +20135,7 @@ async function login(args) {
19594
20135
  await writeConfig(path, config);
19595
20136
  console.log(`Keys unlocked and cached in ${path} (owner-only). Anyone who can read your files on this machine can read this account, as with a signed-in browser.`);
19596
20137
  const others = Object.keys(config.servers).filter((server) => server !== syncServer);
19597
- if (others.length > 0) console.log(`Also logged in to ${others.join(", ")}; commands now need --sync-server <url> to say which.`);
20138
+ if (others.length > 0) console.log(`Also logged in to ${others.join(", ")} - commands now need --sync-server <url> to say which.`);
19598
20139
  await listGraphs({
19599
20140
  syncServer,
19600
20141
  pat,
@@ -19650,7 +20191,7 @@ async function approveOrFallBack(account, byRecoveryCode) {
19650
20191
  process.exit(controls.exitCode);
19651
20192
  }
19652
20193
  if (!abandoned) throw new Error("unreachable");
19653
- console.log("Approval cancelled; unlocking with your Recovery Code instead.");
20194
+ console.log("Approval cancelled - unlocking with your Recovery Code instead.");
19654
20195
  return byRecoveryCode();
19655
20196
  }
19656
20197
  /**
@@ -19719,7 +20260,7 @@ async function semanticCommand(what) {
19719
20260
  const status = await semanticSetupStatus(process.env);
19720
20261
  console.log(`Runtime: ${status.runtime ? "installed" : "missing"} (${status.runtimeDir})`);
19721
20262
  console.log(`Model: ${status.model ? "present" : "missing"} (${status.modelDir})`);
19722
- console.log(status.runtime && status.model ? "Semantic search is set up; serve uses it unless started with --no-semantic." : `Not set up. Run: ${CMD} semantic setup`);
20263
+ console.log(status.runtime && status.model ? "Semantic search is set up - serve uses it unless started with --no-semantic." : `Not set up. Run: ${CMD} semantic setup`);
19723
20264
  const stores = await listGraphStores(process.env, MODEL.id);
19724
20265
  if (stores.length === 0) return;
19725
20266
  console.log("");
@@ -19778,7 +20319,7 @@ async function publishCommand(args) {
19778
20319
  const out = resolve(args.out.trim());
19779
20320
  await writePublishFolders(configPath, withPublishFolder(await readPublishFolders(configPath), key, publication, out));
19780
20321
  console.error(`etherpk-mcp: publish folder for "${publication}" of "${graphName}" set to ${out} (remembered in ${configPath}).`);
19781
- } else if (!publishFolderOf(await readPublishFolders(configPath), key, publication)) fail(`No publish folder is set for "${publication}" of "${graphName}" on this machine. Pass --out <dir> once; it is remembered.`);
20322
+ } else if (!publishFolderOf(await readPublishFolders(configPath), key, publication)) fail(`No publish folder is set for "${publication}" of "${graphName}" on this machine. Pass --out <dir> once - it is remembered.`);
19782
20323
  const result = await publish(graph, { id: publication }, host);
19783
20324
  console.log(JSON.stringify(cliPublishOutput(result), null, 2));
19784
20325
  if (!result.ok) process.exitCode = 1;
@@ -19851,7 +20392,7 @@ async function openFolderForServe(folder, args) {
19851
20392
  onSemanticProgress: reportSemanticProgress,
19852
20393
  onError: (error) => console.error(`etherpk-mcp: ${error.message}`),
19853
20394
  onWarning: (line) => console.error(`etherpk-mcp: ${line}`),
19854
- 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.`))
20395
+ 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.`))
19855
20396
  }),
19856
20397
  graphName: name
19857
20398
  };
@@ -19887,6 +20428,7 @@ async function openSyncedForServe(wanted, args) {
19887
20428
  keyring,
19888
20429
  relayUrl: account.relayUrl,
19889
20430
  token: account.tokenFor(graphId),
20431
+ clientUrl: account.clientUrl,
19890
20432
  presenceName: `Agent on ${hostname()}`,
19891
20433
  readyTimeoutMs: 2e4,
19892
20434
  persistDir,
@@ -19923,7 +20465,7 @@ async function serveGraph(graph, graphName, args) {
19923
20465
  transport.onclose = listener;
19924
20466
  },
19925
20467
  async shutdown(end) {
19926
- console.error(`etherpk-mcp: ${end}; flushing and exiting.`);
20468
+ console.error(`etherpk-mcp: ${end} - flushing and exiting.`);
19927
20469
  await graph.settle().catch(() => {});
19928
20470
  await graph.dispose().catch(() => {});
19929
20471
  process.exit(0);
@@ -19933,17 +20475,17 @@ async function serveGraph(graph, graphName, args) {
19933
20475
  console.error(`etherpk-mcp: serving "${graphName}" over stdio as "Agent on ${hostname()}".`);
19934
20476
  chromiumStatus(process.env, CMD).then((chromium) => {
19935
20477
  if (chromium.executable) return;
19936
- console.error(`etherpk-mcp: no browser for diagrams (run: ${chromium.setupCommand}, or set ETHERPK_CHROMIUM); a publish or theme preview with Mermaid diagrams refuses until then.`);
20478
+ console.error(`etherpk-mcp: no browser for diagrams (run: ${chromium.setupCommand}, or set ETHERPK_CHROMIUM) - a publish or theme preview with Mermaid diagrams refuses until then.`);
19937
20479
  });
19938
20480
  const startSemantic = () => graph.semantic().then(async (semantic) => {
19939
20481
  const status = await semantic.status();
19940
- console.error(`etherpk-mcp: semantic search on (${semantic.model.id}); ${status.embedded} of ${status.total} passages embedded, building the rest in the background.`);
20482
+ console.error(`etherpk-mcp: semantic search on (${semantic.model.id}) - ${status.embedded} of ${status.total} passages embedded, building the rest in the background.`);
19941
20483
  }).catch((error) => console.error(`etherpk-mcp: semantic search unavailable: ${error instanceof Error ? error.message : String(error)}`));
19942
20484
  if (semanticOn) startSemantic();
19943
20485
  else if (!args["no-semantic"]) {
19944
- console.error(`etherpk-mcp: semantic search is not set up on this computer (run: ${CMD} semantic setup; this process will notice within half a minute, no restart needed); search answers by text only until then.`);
20486
+ console.error(`etherpk-mcp: semantic search is not set up on this computer (run: ${CMD} semantic setup - this process will notice within half a minute, no restart needed). Search answers by text only until then.`);
19945
20487
  whenSemanticSetUp(process.env, () => {
19946
- console.error("etherpk-mcp: semantic setup found; loading the model and building the store.");
20488
+ console.error("etherpk-mcp: semantic setup found - loading the model and building the store.");
19947
20489
  startSemantic();
19948
20490
  });
19949
20491
  }