@appsoftwareltd/etherpk-mcp 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -717,7 +717,7 @@ function looksLikeAgentSetupCode(value) {
717
717
  }
718
718
  var package_default = {
719
719
  name: "@appsoftwareltd/etherpk-mcp",
720
- version: "0.11.1",
720
+ version: "0.13.0",
721
721
  license: "Elastic-2.0",
722
722
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
723
723
  type: "module",
@@ -1541,6 +1541,7 @@ async function openVault(envelope, key) {
1541
1541
  };
1542
1542
  throw new EnvelopeError(`unexpected vault envelope kind ${envelope[1] ?? "none"}`);
1543
1543
  }
1544
+ contextAad("settings-vault", "v1");
1544
1545
  var PAD_TO = 64;
1545
1546
  var LENGTH_PREFIX = 2;
1546
1547
  function graphNameAad(graphId) {
@@ -1743,6 +1744,19 @@ function createSyncApi(deps) {
1743
1744
  method: "PUT",
1744
1745
  body: JSON.stringify(write)
1745
1746
  }).then((r) => r.version),
1747
+ /**
1748
+ * The person's Settings Vault (ADR 0148), or null while the account has none. An agent
1749
+ * token is refused it: only the account's own devices read it.
1750
+ */
1751
+ getSettingsVault: () => call("/api/v1/sync/settings-vault").catch((e) => {
1752
+ if (e instanceof SyncApiError && e.status === 404) return null;
1753
+ throw e;
1754
+ }),
1755
+ /** Write the Settings Vault, signed. Resolves to the new version. A stale `expectedVersion` rejects with status 409. */
1756
+ putSettingsVault: (write) => call("/api/v1/sync/settings-vault", {
1757
+ method: "PUT",
1758
+ body: JSON.stringify(write)
1759
+ }).then((r) => r.version),
1746
1760
  /** Key Replacement (ADR 0128): new keys, and everything an old copy of them reached cut off. */
1747
1761
  replaceKeys: (replacement) => call("/api/v1/sync/keys/replace", {
1748
1762
  method: "POST",
@@ -2560,7 +2574,7 @@ var TLS_CODES = /* @__PURE__ */ new Set([
2560
2574
  function describeConnectionFailure(error, syncServer) {
2561
2575
  if (error instanceof SyncApiError) {
2562
2576
  if (error.status === 404 && error.message === "HTTP 404") return `${syncServer} is not a Sync Server (it may be the EtherPK app). Use the Sync Server address shown in EtherPK under a synced graph's Settings > Agents.`;
2563
- if (error.status === 401) return `${syncServer} did not accept the token: it may have been revoked, mistyped, or left unused for 30 days. Make a setup code in EtherPK (a synced graph's Settings > Agents) and run login again.`;
2577
+ if (error.status === 401) return `${syncServer} did not accept the token: it may have been revoked, mistyped, or left unused for 30 days. Create an Agent Setup Code in EtherPK (the Graphs page's Agents tab, or a synced graph's Settings > Agents) and run login again.`;
2564
2578
  return null;
2565
2579
  }
2566
2580
  if (error instanceof TypeError && error.message === "fetch failed") {
@@ -2638,9 +2652,9 @@ function resolveGraphById(graphs, vault, graphId) {
2638
2652
  /**
2639
2653
  * How the Headless Client gets and gives up its agent token (ADR 0132), against the Sync Server's
2640
2654
  * own routes. Separate from the shared REST bridge (`sync-api.ts`), which always sends a token:
2641
- * redeeming a setup code is the one call made before this computer holds any.
2655
+ * redeeming an Agent Setup Code is the one call made before this computer holds any.
2642
2656
  *
2643
- * - `redeemSetupCode`: `login --code` trades the one-time code for the agent token.
2657
+ * - `redeemSetupCode`: `login --code` trades the one-time Agent Setup Code for the agent token.
2644
2658
  * - `exchangeForAgentToken`: a standard token, pasted into `login` or left in the login file by
2645
2659
  * an older version, is traded for an agent token; the Sync Server revokes the standard one.
2646
2660
  * - `revokeHeldToken`: `logout` revokes the token it is about to forget, so no token is left on
@@ -2656,7 +2670,6 @@ var AgentTokenError = class extends Error {
2656
2670
  this.name = "AgentTokenError";
2657
2671
  }
2658
2672
  };
2659
- var OLDER_SERVER = "This Sync Server runs an older version of EtherPK that cannot give agent tokens. Ask whoever runs it to update it, or log in with an access token from its Access tokens page.";
2660
2673
  function base(syncServer) {
2661
2674
  return syncServer.replace(/\/+$/, "");
2662
2675
  }
@@ -2666,38 +2679,31 @@ async function tokenFrom(response) {
2666
2679
  if (response.ok && typeof body?.token === "string") return body.token;
2667
2680
  throw new AgentTokenError(body?.error?.message ?? `HTTP ${response.status}`, response.status, body?.error?.code);
2668
2681
  }
2669
- /** Trade a one-time setup code for an agent token named `name` ("Agent on <hostname>"). */
2682
+ /** Trade a one-time Agent Setup Code for an agent token named `name` ("Agent on <hostname>"). */
2670
2683
  async function redeemSetupCode(call) {
2671
- const response = await (call.fetch ?? fetch)(`${base(call.syncServer)}/api/v1/sync/agent-tokens`, {
2684
+ return tokenFrom(await (call.fetch ?? fetch)(`${base(call.syncServer)}/api/v1/sync/agent-tokens`, {
2672
2685
  method: "POST",
2673
2686
  headers: { "content-type": "application/json" },
2674
2687
  body: JSON.stringify({
2675
2688
  setupCode: call.code,
2676
2689
  name: call.name
2677
2690
  })
2678
- });
2679
- if (response.status === 404 && !response.headers.get("content-type")?.includes("json")) throw new AgentTokenError(OLDER_SERVER, 404);
2680
- return tokenFrom(response);
2691
+ }));
2681
2692
  }
2682
- /**
2683
- * Trade the standard token `token` for an agent token named `name`; the server revokes `token`.
2684
- * Null on a server from before agent tokens, where the standard token simply goes on working.
2685
- */
2693
+ /** Trade the standard token `token` for an agent token named `name`; the server revokes `token`. */
2686
2694
  async function exchangeForAgentToken(call) {
2687
- const response = await (call.fetch ?? fetch)(`${base(call.syncServer)}/api/v1/sync/agent-tokens`, {
2695
+ return tokenFrom(await (call.fetch ?? fetch)(`${base(call.syncServer)}/api/v1/sync/agent-tokens`, {
2688
2696
  method: "POST",
2689
2697
  headers: {
2690
2698
  "content-type": "application/json",
2691
2699
  authorization: `Bearer ${call.token}`
2692
2700
  },
2693
2701
  body: JSON.stringify({ name: call.name })
2694
- });
2695
- if (response.status === 404 && !response.headers.get("content-type")?.includes("json")) return null;
2696
- return tokenFrom(response);
2702
+ }));
2697
2703
  }
2698
2704
  /**
2699
2705
  * Revoke the token this computer holds. True once the server has revoked it; false when it could
2700
- * not be reached or refused (already revoked, expired, or a server from before this route), which
2706
+ * not be reached or refused (already revoked or expired), which
2701
2707
  * a logout reports but does not stop for: the local copy is forgotten either way.
2702
2708
  */
2703
2709
  async function revokeHeldToken(call) {
@@ -3017,8 +3023,8 @@ async function forgetLoginSecrets(syncServer, stored, keychain) {
3017
3023
  /**
3018
3024
  * A login an older Headless Client made holds a standard token, which reaches everything a
3019
3025
  * signed-in session does. Trade it for an agent token, which the Sync Server revokes the standard
3020
- * one for, and save that in its place. Anything that stops the trade (a server from before agent
3021
- * tokens, no network) leaves the login as it was, to be tried again on the next run.
3026
+ * one for, and save that in its place. A trade that fails (no network, a server not answering)
3027
+ * leaves the login as it was, to be tried again on the next run.
3022
3028
  */
3023
3029
  async function upgradeToAgentToken(opts) {
3024
3030
  const { path, credentials, keychain, name, say } = opts;
@@ -3036,7 +3042,6 @@ async function upgradeToAgentToken(opts) {
3036
3042
  token: current.pat,
3037
3043
  name
3038
3044
  });
3039
- if (!agentToken) return current;
3040
3045
  const where = await saveLogin({
3041
3046
  config,
3042
3047
  path,
@@ -3055,7 +3060,7 @@ async function upgradeToAgentToken(opts) {
3055
3060
  });
3056
3061
  } catch (error) {
3057
3062
  const reason = error instanceof Error ? error.message : String(error);
3058
- if (opts.stillWorks && !await opts.stillWorks(credentials.pat).catch(() => true)) say(`etherpk-mcp: could not replace the access token for ${credentials.syncServer} with an agent token (${reason}), and the old token no longer works. Make a setup code in EtherPK (a synced graph's Settings > Agents) and run login again.`);
3063
+ if (opts.stillWorks && !await opts.stillWorks(credentials.pat).catch(() => true)) say(`etherpk-mcp: could not replace the access token for ${credentials.syncServer} with an agent token (${reason}), and the old token no longer works. Create an Agent Setup Code in EtherPK (the Graphs page's Agents tab, or a synced graph's Settings > Agents) and run login again.`);
3059
3064
  else say(`etherpk-mcp: could not replace the access token for ${credentials.syncServer} with an agent token this time (${reason}). It goes on working, and the next run tries again.`);
3060
3065
  return credentials;
3061
3066
  }
@@ -3616,6 +3621,28 @@ function compareGroups$1(a, b) {
3616
3621
  return conceptKey$1(a.sourceConcept).localeCompare(conceptKey$1(b.sourceConcept));
3617
3622
  }
3618
3623
  //#endregion
3624
+ //#region ../client/src/lib/document/backlinks/shown-backlinks.ts
3625
+ /**
3626
+ * The groups with a Template's body links left out and its title kept. A Template whose only
3627
+ * links are in its body drops out whole.
3628
+ */
3629
+ function withoutTemplateContent(groups) {
3630
+ const kept = [];
3631
+ for (const group of groups) {
3632
+ if (!group.sourceIsTemplate) {
3633
+ kept.push(group);
3634
+ continue;
3635
+ }
3636
+ const refs = group.refs.filter((ref) => ref.kind === "title");
3637
+ if (refs.length > 0) kept.push({
3638
+ ...group,
3639
+ refs,
3640
+ bodyNames: []
3641
+ });
3642
+ }
3643
+ return kept;
3644
+ }
3645
+ //#endregion
3619
3646
  //#region ../client/src/lib/document/index-db-sqlite.ts
3620
3647
  /**
3621
3648
  * Adapter from the `@sqlite.org/sqlite-wasm` oo1 DB to the engine-agnostic
@@ -4564,7 +4591,8 @@ function blocksAndLinks(text) {
4564
4591
  };
4565
4592
  }
4566
4593
  /** Derive the index rows for a single document's text. Pure. */
4567
- function deriveDoc(text) {
4594
+ function deriveDoc(text, options = {}) {
4595
+ if (options.template) return deriveTemplate(text);
4568
4596
  const { blocks, links, lineToBlock } = blocksAndLinks(text);
4569
4597
  const candidateTasks = blocks.filter((b) => b.kind === "task");
4570
4598
  const fenced = candidateTasks.length === 0 ? () => false : fencedLines(text);
@@ -4595,6 +4623,21 @@ function deriveDoc(text) {
4595
4623
  searchText: withoutExtensionFences(text, blocks, lineToBlock, maps.searchText)
4596
4624
  };
4597
4625
  }
4626
+ /** A variable's opening braces in a link target: `[[{{date}}]]` names no concept until copied. */
4627
+ var VARIABLE_IN_TARGET = /\{\{/;
4628
+ function deriveTemplate(text) {
4629
+ const { blocks, links, lineToBlock } = blocksAndLinks(text);
4630
+ const maps = deriveMaps(text, blocks, links, lineToBlock);
4631
+ return {
4632
+ blocks,
4633
+ links: links.filter((link) => !VARIABLE_IN_TARGET.test(link.concept)),
4634
+ tasks: [],
4635
+ taskConcepts: [],
4636
+ mapItems: [],
4637
+ mapConcepts: [],
4638
+ searchText: withoutExtensionFences(text, blocks, lineToBlock, maps.searchText)
4639
+ };
4640
+ }
4598
4641
  /**
4599
4642
  * What search and Semantic Search read for each block holding an [[Extension Fence]] (ADR 0138):
4600
4643
  * the block's text with the fence's lines left out, so an extension's data (a drawing's elements)
@@ -5162,10 +5205,12 @@ CREATE TABLE IF NOT EXISTS index_metadata (
5162
5205
  key TEXT PRIMARY KEY, value INTEGER NOT NULL);
5163
5206
  -- protected: the stored text holds a cipher fence (fence-info.ts). Kept on the page row so a
5164
5207
  -- listing can wear a padlock without opening the document - the one cheap answer the tab has.
5208
+ -- template: the page is a [[Template]] (ADR 0144). Its links are kept for a rename and marked
5209
+ -- by this, so the views that show relations can leave them out. Never set on a protected page.
5165
5210
  CREATE TABLE IF NOT EXISTS pages (
5166
5211
  id INTEGER PRIMARY KEY, concept TEXT NOT NULL, concept_key TEXT NOT NULL, kind TEXT NOT NULL,
5167
5212
  text_hash TEXT NOT NULL DEFAULT '', generation INTEGER NOT NULL DEFAULT 1,
5168
- protected INTEGER NOT NULL DEFAULT 0);
5213
+ protected INTEGER NOT NULL DEFAULT 0, template INTEGER NOT NULL DEFAULT 0);
5169
5214
  CREATE TABLE IF NOT EXISTS aliases (
5170
5215
  page_id INTEGER NOT NULL, alias_key TEXT NOT NULL, display TEXT NOT NULL);
5171
5216
  -- A publication page's includes (ADR 0082), one row per slot: page_id is the PUBLICATION
@@ -5273,7 +5318,7 @@ function createSchema(db) {
5273
5318
  db.exec(SCHEMA$1);
5274
5319
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
5275
5320
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
5276
- db.exec(`PRAGMA user_version = 16`);
5321
+ db.exec(`PRAGMA user_version = 17`);
5277
5322
  }
5278
5323
  function activeIndexGeneration(db) {
5279
5324
  return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
@@ -5294,7 +5339,7 @@ function advanceIndexRevision(db) {
5294
5339
  */
5295
5340
  function isUsableIndex(db) {
5296
5341
  try {
5297
- return db.all("PRAGMA user_version")[0]?.user_version === 16;
5342
+ return db.all("PRAGMA user_version")[0]?.user_version === 17;
5298
5343
  } catch {
5299
5344
  return false;
5300
5345
  }
@@ -5347,10 +5392,11 @@ function indexDocHash(doc) {
5347
5392
  const includes = doc.includes ?? [];
5348
5393
  const properties = doc.properties ?? [];
5349
5394
  const aliases = doc.aliases ?? [];
5350
- if (includes.length === 0 && properties.length === 0 && aliases.length === 0) return hashText(doc.text);
5395
+ if (includes.length === 0 && properties.length === 0 && aliases.length === 0 && !doc.template) return hashText(doc.text);
5351
5396
  const includeText = includes.map((i) => `${i.publication}\u0001${i.slot}\u0001${i.concept}`).join("\0");
5352
5397
  const propertyText = properties.map((p) => `${p.key}\u0001${p.value ?? ""}`).join("\0");
5353
- return hashText(`${doc.text}\u0000${includeText}\u0003${propertyText}\u0004${aliases.join("\0")}`);
5398
+ const templateMark = doc.template ? "template" : "";
5399
+ return hashText(`${doc.text}\u0000${includeText}\u0003${propertyText}\u0004${aliases.join("\0")}${templateMark}`);
5354
5400
  }
5355
5401
  function hashText(text) {
5356
5402
  let h1 = 3735928559;
@@ -5409,7 +5455,7 @@ function insertDerived(db, pageId, doc) {
5409
5455
  t.matchEnd,
5410
5456
  null
5411
5457
  ]);
5412
- const { blocks, links, tasks, taskConcepts, mapItems, mapConcepts, searchText } = deriveDoc(doc.text);
5458
+ const { blocks, links, tasks, taskConcepts, mapItems, mapConcepts, searchText } = deriveDoc(doc.text, { template: templateFlag(doc) === 1 });
5413
5459
  for (const b of blocks) {
5414
5460
  db.run("INSERT INTO blocks (page_id, local_id, parent_local_id, ord, kind, depth, done, start_line, end_line, label, text) VALUES (?,?,?,?,?,?,?,?,?,?,?)", [
5415
5461
  pageId,
@@ -5615,15 +5661,23 @@ function purgeIfProtected(db, generation) {
5615
5661
  function protectedFlag(doc) {
5616
5662
  return containsCipherFence(doc.text) ? 1 : 0;
5617
5663
  }
5664
+ /**
5665
+ * The `template` column as stored. A [[Protected Document]] is never a [[Template]] (ADR 0144):
5666
+ * its text cannot be read for a copy, and the index holds none of its frontmatter's meaning.
5667
+ */
5668
+ function templateFlag(doc) {
5669
+ return doc.template === true && doc.kind === "page" && protectedFlag(doc) === 0 ? 1 : 0;
5670
+ }
5618
5671
  function insertPage(db, pageId, generation, doc) {
5619
- db.run("INSERT INTO pages (id, concept, concept_key, kind, text_hash, generation, protected) VALUES (?,?,?,?,?,?,?)", [
5672
+ db.run("INSERT INTO pages (id, concept, concept_key, kind, text_hash, generation, protected, template) VALUES (?,?,?,?,?,?,?,?)", [
5620
5673
  pageId,
5621
5674
  doc.concept,
5622
5675
  conceptKey$1(doc.concept),
5623
5676
  doc.kind,
5624
5677
  indexDocHash(doc),
5625
5678
  generation,
5626
- protectedFlag(doc)
5679
+ protectedFlag(doc),
5680
+ templateFlag(doc)
5627
5681
  ]);
5628
5682
  insertDerived(db, pageId, doc);
5629
5683
  }
@@ -5708,23 +5762,25 @@ function ingestOne(db, doc) {
5708
5762
  if (found.length > 0) {
5709
5763
  pageId = found[0].id;
5710
5764
  deleteDerivedRows(db, pageId);
5711
- db.run("UPDATE pages SET concept = ?, kind = ?, text_hash = ?, protected = ? WHERE id = ?", [
5765
+ db.run("UPDATE pages SET concept = ?, kind = ?, text_hash = ?, protected = ?, template = ? WHERE id = ?", [
5712
5766
  doc.concept,
5713
5767
  doc.kind,
5714
5768
  indexDocHash(doc),
5715
5769
  protectedFlag(doc),
5770
+ templateFlag(doc),
5716
5771
  pageId
5717
5772
  ]);
5718
5773
  } else {
5719
5774
  pageId = (db.all("SELECT COALESCE(MAX(id), 0) AS n FROM pages")[0]?.n ?? 0) + 1;
5720
- db.run("INSERT INTO pages (id, concept, concept_key, kind, text_hash, generation, protected) VALUES (?,?,?,?,?,?,?)", [
5775
+ db.run("INSERT INTO pages (id, concept, concept_key, kind, text_hash, generation, protected, template) VALUES (?,?,?,?,?,?,?,?)", [
5721
5776
  pageId,
5722
5777
  doc.concept,
5723
5778
  key,
5724
5779
  doc.kind,
5725
5780
  indexDocHash(doc),
5726
5781
  generation,
5727
- protectedFlag(doc)
5782
+ protectedFlag(doc),
5783
+ templateFlag(doc)
5728
5784
  ]);
5729
5785
  }
5730
5786
  const written = new Set(insertDerived(db, pageId, doc));
@@ -5939,10 +5995,25 @@ function conceptFamilyKeys(db, key) {
5939
5995
  function indexDocumentFactKeys(doc) {
5940
5996
  return {
5941
5997
  aliases: doc.aliases.map(conceptKey$1),
5942
- linkTargets: [.../* @__PURE__ */ new Set([...deriveTitleLinks(doc.concept).map((link) => conceptKey$1(link.concept)), ...deriveDoc(doc.text).links.map((link) => conceptKey$1(link.concept))])],
5998
+ linkTargets: [.../* @__PURE__ */ new Set([...deriveTitleLinks(doc.concept).map((link) => conceptKey$1(link.concept)), ...deriveDoc(doc.text, { template: templateFlag(doc) === 1 }).links.map((link) => conceptKey$1(link.concept))])],
5943
5999
  includeTargets: [...new Set((doc.includes ?? []).map((include) => conceptKey$1(include.concept)))].sort()
5944
6000
  };
5945
6001
  }
6002
+ /**
6003
+ * The graph's [[Template]]s (ADR 0144), ordered by name as it reads with the brackets of a
6004
+ * [[Scope]] left out, so `[[Templates]] Daily` sorts among the Ts. One query over rows the index
6005
+ * already holds: no document is opened to list them.
6006
+ */
6007
+ function templatePages(db) {
6008
+ const generation = activeIndexGeneration(db);
6009
+ const rows = db.all(`SELECT p.concept, (SELECT b.label FROM blocks b WHERE b.page_id = p.id ORDER BY b.local_id LIMIT 1) AS firstLine
6010
+ FROM pages p WHERE p.generation = ? AND p.template = 1`, [generation]);
6011
+ const sortKey = (concept) => concept.replace(/\[\[|\]\]/g, "").toLowerCase();
6012
+ return rows.map((row) => ({
6013
+ concept: row.concept,
6014
+ firstLine: row.firstLine ?? ""
6015
+ })).sort((a, b) => sortKey(a.concept).localeCompare(sortKey(b.concept)));
6016
+ }
5946
6017
  /** The canonical page id a concept resolves to (by its own name or an alias), or null. */
5947
6018
  function canonicalPageId(db, key) {
5948
6019
  const generation = activeIndexGeneration(db);
@@ -6093,7 +6164,7 @@ function backlinksFor(db, concept) {
6093
6164
  const placeholders = names.map(() => "?").join(",");
6094
6165
  const generation = activeIndexGeneration(db);
6095
6166
  const hits = db.all(`SELECT l.page_id, l.concept_key, p.concept AS sourceConcept, p.kind AS sourceKind, l.line, l.line_text,
6096
- l.match_start, l.match_end, l.block_local_id, l.in_title
6167
+ l.match_start, l.match_end, l.block_local_id, l.in_title, p.template
6097
6168
  FROM links l JOIN pages p ON p.id = l.page_id
6098
6169
  WHERE p.generation=? AND l.concept_key IN (${placeholders})
6099
6170
  ORDER BY l.page_id, l.line, l.match_start`, [generation, ...names]);
@@ -6125,7 +6196,8 @@ function backlinksFor(db, concept) {
6125
6196
  sourceKind: h.sourceKind,
6126
6197
  refs: [],
6127
6198
  bodyNames: [],
6128
- sourceIsTarget: h.page_id === pageId
6199
+ sourceIsTarget: h.page_id === pageId,
6200
+ sourceIsTemplate: h.template === 1
6129
6201
  };
6130
6202
  groups.set(h.sourceConcept, group);
6131
6203
  }
@@ -6159,7 +6231,7 @@ var SNIPPET_TOKENS = 24;
6159
6231
  * scores merely reintroduces the long-document bias bm25 exists to remove. Counting is cruder,
6160
6232
  * robust, and explainable in one sentence - and it orders by a number the row displays.
6161
6233
  */
6162
- function searchText$1(db, query, offset, limit, filters = []) {
6234
+ function searchText$2(db, query, offset, limit, filters = []) {
6163
6235
  const match = buildFtsMatch(query);
6164
6236
  if (match === null) return {
6165
6237
  groups: [],
@@ -6937,7 +7009,7 @@ function cacheRoot(env) {
6937
7009
  * recovered from a joined path (see there).
6938
7010
  */
6939
7011
  var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
6940
- var INDEX_FILE_NAME = `index.v16.sqlite`;
7012
+ var INDEX_FILE_NAME = `index.v17.sqlite`;
6941
7013
  var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
6942
7014
  function cacheFile(dir) {
6943
7015
  return join(dir, CACHE_FILE_NAME);
@@ -10124,10 +10196,13 @@ function createGraphSync(deps) {
10124
10196
  let forceToken = false;
10125
10197
  let credentialRefusals = 0;
10126
10198
  let reconnectTimer;
10199
+ /** True while a retry waits on `reconnectTimer`: no socket is open or being made. */
10200
+ let retryWaiting = false;
10127
10201
  const retryDelay = deps.retryDelayMs ?? ((attempt) => reconnectDelayMs(attempt));
10128
10202
  function scheduleConnect(delayMs) {
10129
10203
  if (disposed || lost || protocolMismatch) return;
10130
10204
  clearTimeout(reconnectTimer);
10205
+ retryWaiting = true;
10131
10206
  reconnectTimer = setTimeout(connect, delayMs);
10132
10207
  }
10133
10208
  function backOff() {
@@ -10159,6 +10234,7 @@ function createGraphSync(deps) {
10159
10234
  * on open. Only rebuildable snapshot uploads use a volatile queue.
10160
10235
  */
10161
10236
  function connect() {
10237
+ retryWaiting = false;
10162
10238
  if (disposed || lost || protocolMismatch) return;
10163
10239
  const force = forceToken;
10164
10240
  forceToken = false;
@@ -10458,6 +10534,11 @@ function createGraphSync(deps) {
10458
10534
  refusalTimer = void 0;
10459
10535
  resendRefused();
10460
10536
  },
10537
+ reconnectNow() {
10538
+ if (!retryWaiting || disposed || lost || protocolMismatch) return;
10539
+ attempts = 0;
10540
+ scheduleConnect(0);
10541
+ },
10461
10542
  async flushAll({ onProgress } = {}) {
10462
10543
  const all = [...engines.values()];
10463
10544
  let flushed = 0;
@@ -11517,19 +11598,25 @@ function publicDocumentsInNoPublication(documents, publications) {
11517
11598
  //#endregion
11518
11599
  //#region ../client/src/lib/document/publish/host/site-writer.ts
11519
11600
  /**
11520
- * Stale files are removed only from the places the publisher owns. `etherpk-publish.json` is not
11521
- * produced but stays owned, so the first publish after the upgrade deletes the copy an older
11522
- * build left in the folder.
11601
+ * The folders the publisher writes into, whole: the graph's assets, the theme, and the pictures
11602
+ * it draws for maps (`maps/`) and for extension blocks (`pictures/`). Those pictures are named by
11603
+ * their content, so a changed map or an unpublished page leaves the old one behind as a stray.
11523
11604
  */
11605
+ var OWNED_FOLDERS = [
11606
+ "assets/",
11607
+ "theme/",
11608
+ "maps/",
11609
+ "pictures/"
11610
+ ];
11611
+ /** Stale files are removed only from the places the publisher owns. */
11524
11612
  function isOwnedPath(path) {
11525
- if (path.startsWith("assets/") || path.startsWith("theme/")) return true;
11613
+ if (OWNED_FOLDERS.some((folder) => path.startsWith(folder))) return true;
11526
11614
  if (path.includes("/")) return false;
11527
11615
  return /\.html$/.test(path) || [
11528
11616
  "search.json",
11529
11617
  "search-index.js",
11530
11618
  "sitemap.xml",
11531
11619
  "feed.xml",
11532
- "etherpk-publish.json",
11533
11620
  ".nojekyll"
11534
11621
  ].includes(path);
11535
11622
  }
@@ -12038,7 +12125,6 @@ not here.
12038
12125
  wrangler.jsonc
12039
12126
  AGENTS.md
12040
12127
  README.md
12041
- etherpk-publish.json
12042
12128
  `]
12043
12129
  ]);
12044
12130
  }
@@ -12058,7 +12144,7 @@ and files that are no longer produced are deleted.
12058
12144
  frontmatter says \`public: true\` and names this publication in \`publications: [${publication.id}]\`
12059
12145
  (unless the publication takes every public document).
12060
12146
  - The navigation is the outline of the publication page in EtherPK - the theme and the include
12061
- slots (head, logo, header, footer, before-content, after-content, scripts, styles) are chosen
12147
+ slots (head, favicon, logo, header, footer, before-content, after-content, scripts, styles) are chosen
12062
12148
  and edited there too, in the Publish settings and the Theme editor.
12063
12149
  - \`index.html\` opens from disk for a preview. \`.github/workflows/pages.yaml\` deploys the folder
12064
12150
  to GitHub Pages, \`wrangler.jsonc\` deploys it to Cloudflare Workers, and any static host serves it
@@ -12078,6 +12164,80 @@ function agentsMdWithManagedSection(existing, publication) {
12078
12164
  return `${existing.trimEnd()}\n\n${section}`;
12079
12165
  }
12080
12166
  //#endregion
12167
+ //#region ../client/src/lib/document/fence-answer.ts
12168
+ var PICTURE_TYPES = /* @__PURE__ */ new Set([
12169
+ "image/svg+xml",
12170
+ "image/png",
12171
+ "image/webp"
12172
+ ]);
12173
+ /** An `https` address as written, or null for any other. */
12174
+ function httpsAddress(value) {
12175
+ if (typeof value !== "string") return null;
12176
+ try {
12177
+ return new URL(value).protocol === "https:" ? value : null;
12178
+ } catch {
12179
+ return null;
12180
+ }
12181
+ }
12182
+ /** A positive, finite size, or undefined. */
12183
+ function size(value) {
12184
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
12185
+ }
12186
+ /**
12187
+ * The answer as it may be used, or null where the extension said there is nothing to show. Throws,
12188
+ * saying why, for anything else: an answer missing altogether among them, which is a mistake rather
12189
+ * than a choice.
12190
+ */
12191
+ function checkedFenceAnswer(out) {
12192
+ if (out === null) return null;
12193
+ if (typeof out !== "object") throw new Error("it answered with something that is neither a picture nor a link");
12194
+ const answer = out;
12195
+ const kind = answer.kind;
12196
+ if (kind === "picture") {
12197
+ const { type, bytes, alt } = answer;
12198
+ const width = size(answer.width);
12199
+ const height = size(answer.height);
12200
+ if (typeof type !== "string" || !PICTURE_TYPES.has(type) || !(bytes instanceof Uint8Array)) throw new Error("its picture is not an SVG, PNG or WebP file");
12201
+ if (width === void 0 || height === void 0) throw new Error("its picture has no size");
12202
+ return {
12203
+ kind: "picture",
12204
+ type,
12205
+ bytes: new Uint8Array(bytes),
12206
+ width,
12207
+ height,
12208
+ alt: String(alt ?? "")
12209
+ };
12210
+ }
12211
+ if (kind === "link") {
12212
+ const href = httpsAddress(answer.href);
12213
+ if (!href) throw new Error("its link is not an https address");
12214
+ const text = answer.text;
12215
+ const image = answer.image;
12216
+ let picture;
12217
+ if (image !== null && typeof image === "object") {
12218
+ const { src: rawSrc, alt, width: rawWidth, height: rawHeight } = image;
12219
+ const src = httpsAddress(rawSrc);
12220
+ const width = size(rawWidth);
12221
+ const height = size(rawHeight);
12222
+ if (src) picture = {
12223
+ src,
12224
+ alt: String(alt ?? ""),
12225
+ ...width !== void 0 && height !== void 0 ? {
12226
+ width,
12227
+ height
12228
+ } : {}
12229
+ };
12230
+ }
12231
+ return {
12232
+ kind: "link",
12233
+ href,
12234
+ text: String(text ?? href),
12235
+ ...picture ? { image: picture } : {}
12236
+ };
12237
+ }
12238
+ throw new Error("it answered with something that is neither a picture nor a link");
12239
+ }
12240
+ //#endregion
12081
12241
  //#region ../client/src/lib/document/publish/derived.ts
12082
12242
  function searchIndexJson(entries) {
12083
12243
  return `${JSON.stringify({
@@ -12974,6 +13134,59 @@ function linkGroups(match) {
12974
13134
  return match.groups;
12975
13135
  }
12976
13136
  //#endregion
13137
+ //#region ../client/src/lib/document/line-endings.ts
13138
+ /** `text` with every `\r\n` and every lone `\r` written as `\n`; `text` itself when it holds no `\r`. */
13139
+ function lineFeedsOnly(text) {
13140
+ return text.includes("\r") ? text.replace(/\r\n?/g, "\n") : text;
13141
+ }
13142
+ /**
13143
+ * The changes that turn `text` into {@link lineFeedsOnly}`(text)`, in document order and each
13144
+ * against `text` as it is: one per `\r`, deleting it before a `\n` and replacing it with `\n`
13145
+ * anywhere else. For a writer that must change a shared text in place rather than replace it,
13146
+ * applying them from the last to the first.
13147
+ */
13148
+ function lineFeedChanges(text) {
13149
+ const changes = [];
13150
+ for (let at = text.indexOf("\r"); at !== -1; at = text.indexOf("\r", at + 1)) changes.push({
13151
+ from: at,
13152
+ to: at + 1,
13153
+ insert: text[at + 1] === "\n" ? "" : "\n"
13154
+ });
13155
+ return changes;
13156
+ }
13157
+ //#endregion
13158
+ //#region ../client/src/lib/document/templates/template-page.ts
13159
+ /**
13160
+ * What makes a document a [[Template]], and the part of it a copy takes (ADR 0144).
13161
+ *
13162
+ * One predicate for every reader, so the index, the `/Template` picker, the Journal Template's
13163
+ * setting and the Headless Client cannot disagree about which pages are Templates.
13164
+ */
13165
+ /**
13166
+ * True for a [[Page]] whose frontmatter says `template: true`. The value must be the boolean, as
13167
+ * `public` must be (publication.ts), so a quoted `"true"` or a name is not a Template. A
13168
+ * [[Journal Entry]] is never one: a day is not a pattern.
13169
+ */
13170
+ function isTemplate(frontmatter, kind) {
13171
+ return kind === "page" && frontmatter.template === true;
13172
+ }
13173
+ /**
13174
+ * The text a copy of the Template takes: everything below its frontmatter, without the blank
13175
+ * lines before and after it. The frontmatter itself is never copied, so a Template that says
13176
+ * `public: true` cannot make a copy public.
13177
+ */
13178
+ function templateBody(text) {
13179
+ return withoutBlankEnds(parseFrontmatter(text).body);
13180
+ }
13181
+ /**
13182
+ * The body in the form the editor holds text: line feeds only (ADR 0112), since a file another app
13183
+ * wrote may end its lines otherwise and a copy is offset by the editor's own lines. Then without
13184
+ * the blank lines before and after it.
13185
+ */
13186
+ function withoutBlankEnds(body) {
13187
+ return lineFeedsOnly(body).replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
13188
+ }
13189
+ //#endregion
12977
13190
  //#region ../client/src/lib/document/publish/resolve.ts
12978
13191
  /**
12979
13192
  * The publication's resolver: concept → destination inside one [[Published Site]]. Alias folding
@@ -12995,12 +13208,15 @@ function titleText(concept) {
12995
13208
  return concept.replace(/\[\[|\]\]/g, "");
12996
13209
  }
12997
13210
  function createPublicationResolver(input) {
12998
- const index = buildBacklinkIndex(input.included.map((doc) => ({
12999
- concept: doc.concept,
13000
- kind: doc.kind,
13001
- aliases: [...doc.aliases],
13002
- text: parseFrontmatter(doc.text).body
13003
- })));
13211
+ const index = buildBacklinkIndex(input.included.map((doc) => {
13212
+ const { data, body } = parseFrontmatter(doc.text);
13213
+ return {
13214
+ concept: doc.concept,
13215
+ kind: doc.kind,
13216
+ aliases: [...doc.aliases],
13217
+ text: isTemplate(data, doc.kind) ? "" : body
13218
+ };
13219
+ }));
13004
13220
  const graphIndex = buildBacklinkIndex(input.allDocuments.map((doc) => ({
13005
13221
  concept: doc.concept,
13006
13222
  kind: doc.kind,
@@ -13170,12 +13386,15 @@ function buildNav(outline, resolver) {
13170
13386
  * an address keeps it whatever order the source listed it in. The generated files' names are
13171
13387
  * reserved.
13172
13388
  */
13173
- /** Names the publisher writes itself; a document wanting one is suffixed instead. */
13389
+ /**
13390
+ * The pages the publisher writes itself (the home page, the 404 page, and the journal and posts
13391
+ * archives); a document wanting one of their names is suffixed instead.
13392
+ */
13174
13393
  var RESERVED_SLUGS = [
13175
13394
  "index",
13176
13395
  "404",
13177
13396
  "journal",
13178
- "search"
13397
+ "posts"
13179
13398
  ];
13180
13399
  /** The document's `slug:` key normalised like any slug, or null when it has none. */
13181
13400
  function explicitSlugOf(text) {
@@ -13318,57 +13537,34 @@ var PICTURE_EXTENSIONS = {
13318
13537
  "image/png": "png",
13319
13538
  "image/webp": "webp"
13320
13539
  };
13321
- /** An `https` address as written, or null for any other. */
13322
- function httpsAddress(value) {
13323
- if (typeof value !== "string") return null;
13324
- try {
13325
- return new URL(value).protocol === "https:" ? value : null;
13326
- } catch {
13327
- return null;
13328
- }
13329
- }
13330
13540
  /**
13331
- * What an extension's `publish` answered for a fence, checked, its picture kept for the site under
13332
- * a name taken from its bytes: a picture, a link (`https` only, as is any picture in it), null when
13333
- * it is left out, or undefined when it could not be made, after which an ordinary fence is published
13334
- * as code and an Extension Fence left out, with a warning either way.
13541
+ * What an extension's `publish` answered for a fence, checked (`fence-answer.ts`), its picture kept
13542
+ * for the site under a name taken from its bytes: a picture, a link (`https` only, as is any picture
13543
+ * in it), null when it is left out, or undefined when it could not be made, after which an ordinary
13544
+ * fence is published as code and an Extension Fence left out, with a warning either way.
13335
13545
  */
13336
13546
  async function publishedFence(asked, info, concept, pictures, warnings) {
13337
13547
  try {
13338
- const out = await asked;
13548
+ const out = checkedFenceAnswer(await asked);
13339
13549
  if (out === null) return null;
13340
13550
  if (out.kind === "picture") {
13341
- const extension = PICTURE_EXTENSIONS[out.type];
13342
- if (!(out.bytes instanceof Uint8Array) || !extension) throw new Error("its picture is not an SVG, PNG or WebP file");
13343
13551
  const digest = await crypto.subtle.digest("SHA-256", out.bytes);
13344
- const path = `pictures/${[...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join("")}.${extension}`;
13552
+ const path = `pictures/${[...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join("")}.${PICTURE_EXTENSIONS[out.type]}`;
13345
13553
  pictures.set(path, out.bytes);
13346
13554
  return {
13347
13555
  kind: "picture",
13348
13556
  src: path,
13349
- alt: String(out.alt ?? ""),
13557
+ alt: out.alt,
13350
13558
  width: Math.round(out.width),
13351
13559
  height: Math.round(out.height)
13352
13560
  };
13353
13561
  }
13354
- if (out.kind === "link") {
13355
- const href = httpsAddress(out.href);
13356
- if (!href) throw new Error("its link is not an https address");
13357
- const src = httpsAddress(out.image?.src);
13358
- const image = src && out.image ? {
13359
- src,
13360
- alt: String(out.image.alt ?? ""),
13361
- width: out.image.width,
13362
- height: out.image.height
13363
- } : void 0;
13364
- return {
13365
- kind: "link",
13366
- href,
13367
- text: String(out.text ?? href),
13368
- ...image ? { image } : {}
13369
- };
13370
- }
13371
- throw new Error("it answered with something that is neither a picture nor a link");
13562
+ return {
13563
+ kind: "link",
13564
+ href: out.href,
13565
+ text: out.text,
13566
+ ...out.image ? { image: out.image } : {}
13567
+ };
13372
13568
  } catch (error) {
13373
13569
  const why = error instanceof Error ? error.message : String(error);
13374
13570
  const extensionFence = isExtensionFenceInfo(info);
@@ -14897,88 +15093,6 @@ function withPublishFolder(folders, graphKey, publicationId, folder) {
14897
15093
  } };
14898
15094
  }
14899
15095
  //#endregion
14900
- //#region ../client/src/lib/document/calendar/month-grid-core.ts
14901
- /** `YYYY-MM-DD` for a local date — the journal file-name format (fs/identity.ts). */
14902
- function formatISODate(date) {
14903
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
14904
- }
14905
- /** Today, as the ISO day the user is living in. The app's single definition of "today". */
14906
- function todayISO(now = /* @__PURE__ */ new Date()) {
14907
- return formatISODate(now);
14908
- }
14909
- var ISO_SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
14910
- /**
14911
- * The local `Date` a `YYYY-MM-DD` string names, or `null` when it names no day that exists.
14912
- *
14913
- * The shape test alone is not enough: `2026-02-30` and `2026-13-01` match it and are not days.
14914
- * Round-tripping through `Date` catches both, because JS silently rolls an out-of-range field
14915
- * forward (31 February becomes 3 March) and the re-format then disagrees with the input.
14916
- */
14917
- function parseISODate(value) {
14918
- const match = ISO_SHAPE.exec(value.trim());
14919
- if (!match) return null;
14920
- const [, y, m, d] = match;
14921
- const date = new Date(Number(y), Number(m) - 1, Number(d));
14922
- return formatISODate(date) === value.trim() ? date : null;
14923
- }
14924
- /** True when `value` names a calendar day that actually exists. */
14925
- function isCalendarDay(value) {
14926
- return parseISODate(value) !== null;
14927
- }
14928
- //#endregion
14929
- //#region ../client/src/lib/document/line-endings.ts
14930
- /** `text` with every `\r\n` and every lone `\r` written as `\n`; `text` itself when it holds no `\r`. */
14931
- function lineFeedsOnly(text) {
14932
- return text.includes("\r") ? text.replace(/\r\n?/g, "\n") : text;
14933
- }
14934
- /**
14935
- * The changes that turn `text` into {@link lineFeedsOnly}`(text)`, in document order and each
14936
- * against `text` as it is: one per `\r`, deleting it before a `\n` and replacing it with `\n`
14937
- * anywhere else. For a writer that must change a shared text in place rather than replace it,
14938
- * applying them from the last to the first.
14939
- */
14940
- function lineFeedChanges(text) {
14941
- const changes = [];
14942
- for (let at = text.indexOf("\r"); at !== -1; at = text.indexOf("\r", at + 1)) changes.push({
14943
- from: at,
14944
- to: at + 1,
14945
- insert: text[at + 1] === "\n" ? "" : "\n"
14946
- });
14947
- return changes;
14948
- }
14949
- //#endregion
14950
- //#region ../client/src/lib/document/journal-concept.ts
14951
- /**
14952
- * What makes a [[Concept]] a [[Journal Concept]] — the one predicate that decides where a
14953
- * promoting [[Draft]] writes, and which concepts refuse to be renamed (ADR 0056).
14954
- *
14955
- * It is deliberately **not** the `\d{4}-\d{2}-\d{2}` shape it grew out of. While the shape only
14956
- * guarded rename, letting `2026-13-45` through cost nothing; now that the same answer decides
14957
- * whether a file lands in `journals/` or `pages/`, a loose test would mint a journal entry for a
14958
- * day no calendar can display or reach again.
14959
- *
14960
- * Pure; no DOM, no I/O.
14961
- */
14962
- /**
14963
- * True when `concept` names a calendar day that exists — a [[Journal Entry]]'s identity.
14964
- *
14965
- * Journal identity is the day itself, so this is case- and alias-free: there is nothing to
14966
- * normalise beyond surrounding whitespace.
14967
- */
14968
- function isJournalConcept(concept) {
14969
- return isCalendarDay(concept);
14970
- }
14971
- /**
14972
- * Why a [[Page]] cannot be called `day`: a day is the name of that day's [[Journal Entry]].
14973
- *
14974
- * A page given one sits in `pages/` answering to the day, which is what an older version left
14975
- * behind when a Draft for a date promoted to a page. Creating a page and renaming one both
14976
- * refuse with this, on both backends, so the copy is the same wherever the user meets it.
14977
- */
14978
- function dayIsNotAPageName(day) {
14979
- 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.`;
14980
- }
14981
- //#endregion
14982
15096
  //#region ../client/src/lib/document/protection/cipher-fence.ts
14983
15097
  /**
14984
15098
  * The `etherpk-cipher` fence as it appears in document *text* — finding one, reading what it holds
@@ -15061,6 +15175,157 @@ function lastNonBlank(lines) {
15061
15175
  return -1;
15062
15176
  }
15063
15177
  //#endregion
15178
+ //#region ../client/src/lib/agent-read/read-rules.ts
15179
+ /**
15180
+ * What an agent may read of a graph, and how much, for both readers that give a language model the
15181
+ * graph's text: the [[Headless Client]]'s MCP tools (`apps/mcp/src/tools.ts`) and an [[Agent Chat]]
15182
+ * in the browser (`documents-reader.ts`, ADR 0147). Kept in one place so the size caps, the test for
15183
+ * protected text and the shape of a search or backlinks answer cannot drift between the two.
15184
+ *
15185
+ * The two differ in one rule, which each reader states for itself: the Headless Client lists a
15186
+ * [[Protected Document]] by name, while an Agent Chat leaves it out entirely, because what it reads
15187
+ * may be sent to a model provider.
15188
+ */
15189
+ /** UTF-16 units of one document's text given before the rest is cut and the answer says so. */
15190
+ var READ_TEXT_CAP = 2e5;
15191
+ /** UTF-16 units across one bulk read before the rest is cut. */
15192
+ var READ_MANY_TEXT_CAP = 4e5;
15193
+ /** `text` cut at `cap`, and whether it was. */
15194
+ function capText(text, cap = READ_TEXT_CAP) {
15195
+ return text.length > cap ? {
15196
+ text: text.slice(0, cap),
15197
+ truncated: true
15198
+ } : {
15199
+ text,
15200
+ truncated: false
15201
+ };
15202
+ }
15203
+ /** Whether `text` is a Protected Document's: its whole body one encrypted fence. */
15204
+ function isProtectedDocumentText(text) {
15205
+ return documentProtection(text).kind === "document";
15206
+ }
15207
+ /**
15208
+ * A page of the documents whose text matches `query`, as [[Search]] reads it: a `key:value` term
15209
+ * whose key some unprotected document carries is a [[Property Filter]] (ADR 0107), and anything
15210
+ * else is words. The index holds no protected text, so no answer here can quote one.
15211
+ */
15212
+ async function searchText$1(index, query, offset, limit) {
15213
+ const keys = await index.propertyKeys();
15214
+ const { words, filters } = parseSearchQuery(query, new Set(keys.map((info) => info.key.toLowerCase())));
15215
+ if (filters.length > 0 && words.trim() === "") {
15216
+ const documents = await index.propertyMatch(filters);
15217
+ return {
15218
+ results: documents.slice(offset, offset + limit).map((doc) => ({
15219
+ concept: doc.concept,
15220
+ kind: doc.kind,
15221
+ matches: 0,
15222
+ hits: [],
15223
+ properties: doc.properties
15224
+ })),
15225
+ total: documents.length,
15226
+ totalCapped: false,
15227
+ hasMore: offset + limit < documents.length
15228
+ };
15229
+ }
15230
+ const [result, count] = await Promise.all([index.searchText(words, offset, limit, filters), index.searchTextCount(words, filters)]);
15231
+ return {
15232
+ results: result.groups.map((group) => ({
15233
+ concept: group.concept,
15234
+ kind: group.kind,
15235
+ matches: group.matches,
15236
+ hits: group.hits.map((hit) => ({
15237
+ line: hit.line,
15238
+ breadcrumb: hit.breadcrumb,
15239
+ snippet: hit.snippet.map((segment) => segment.text).join("")
15240
+ }))
15241
+ })),
15242
+ total: count.total,
15243
+ totalCapped: count.capped,
15244
+ hasMore: result.hasMore
15245
+ };
15246
+ }
15247
+ /**
15248
+ * The documents that link to a concept, each with the places it does. A [[Template]]'s body is a
15249
+ * pattern rather than a reference (ADR 0144), so only its name is reported.
15250
+ */
15251
+ function backlinkSources(groups) {
15252
+ return withoutTemplateContent(groups).map((group) => ({
15253
+ concept: group.sourceConcept,
15254
+ kind: group.sourceKind,
15255
+ references: group.refs.map((ref) => ({
15256
+ kind: ref.kind,
15257
+ line: ref.line,
15258
+ breadcrumb: ref.breadcrumb,
15259
+ text: ref.kind === "block" ? ref.subtree.map((node) => node.text).join("\n") : ref.context?.text ?? ""
15260
+ }))
15261
+ }));
15262
+ }
15263
+ //#endregion
15264
+ //#region ../client/src/lib/document/calendar/month-grid-core.ts
15265
+ /** `YYYY-MM-DD` for a local date — the journal file-name format (fs/identity.ts). */
15266
+ function formatISODate(date) {
15267
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
15268
+ }
15269
+ /** Today, as the ISO day the user is living in. The app's single definition of "today". */
15270
+ function todayISO(now = /* @__PURE__ */ new Date()) {
15271
+ return formatISODate(now);
15272
+ }
15273
+ var ISO_SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
15274
+ /**
15275
+ * The local `Date` a `YYYY-MM-DD` string names, or `null` when it names no day that exists.
15276
+ *
15277
+ * The shape test alone is not enough: `2026-02-30` and `2026-13-01` match it and are not days.
15278
+ * Round-tripping through `Date` catches both, because JS silently rolls an out-of-range field
15279
+ * forward (31 February becomes 3 March) and the re-format then disagrees with the input.
15280
+ */
15281
+ function parseISODate(value) {
15282
+ const match = ISO_SHAPE.exec(value.trim());
15283
+ if (!match) return null;
15284
+ const [, y, m, d] = match;
15285
+ const date = new Date(Number(y), Number(m) - 1, Number(d));
15286
+ return formatISODate(date) === value.trim() ? date : null;
15287
+ }
15288
+ /** True when `value` names a calendar day that actually exists. */
15289
+ function isCalendarDay(value) {
15290
+ return parseISODate(value) !== null;
15291
+ }
15292
+ /** A new Date `days` after `date` (normalises across month/year boundaries). */
15293
+ function shiftDate(date, days) {
15294
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
15295
+ }
15296
+ //#endregion
15297
+ //#region ../client/src/lib/document/journal-concept.ts
15298
+ /**
15299
+ * What makes a [[Concept]] a [[Journal Concept]] — the one predicate that decides where a
15300
+ * promoting [[Draft]] writes, and which concepts refuse to be renamed (ADR 0056).
15301
+ *
15302
+ * It is deliberately **not** the `\d{4}-\d{2}-\d{2}` shape it grew out of. While the shape only
15303
+ * guarded rename, letting `2026-13-45` through cost nothing; now that the same answer decides
15304
+ * whether a file lands in `journals/` or `pages/`, a loose test would mint a journal entry for a
15305
+ * day no calendar can display or reach again.
15306
+ *
15307
+ * Pure; no DOM, no I/O.
15308
+ */
15309
+ /**
15310
+ * True when `concept` names a calendar day that exists — a [[Journal Entry]]'s identity.
15311
+ *
15312
+ * Journal identity is the day itself, so this is case- and alias-free: there is nothing to
15313
+ * normalise beyond surrounding whitespace.
15314
+ */
15315
+ function isJournalConcept(concept) {
15316
+ return isCalendarDay(concept);
15317
+ }
15318
+ /**
15319
+ * Why a [[Page]] cannot be called `day`: a day is the name of that day's [[Journal Entry]].
15320
+ *
15321
+ * A page given one sits in `pages/` answering to the day, which is what an older version left
15322
+ * behind when a Draft for a date promoted to a page. Creating a page and renaming one both
15323
+ * refuse with this, on both backends, so the copy is the same wherever the user meets it.
15324
+ */
15325
+ function dayIsNotAPageName(day) {
15326
+ 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.`;
15327
+ }
15328
+ //#endregion
15064
15329
  //#region ../client/src/lib/document/types.ts
15065
15330
  var DocumentNotFoundError = class extends Error {
15066
15331
  target;
@@ -15566,6 +15831,17 @@ function isTransient(error) {
15566
15831
  function isTransientStatus(status) {
15567
15832
  return status === 401 || status === 408 || status === 429 || status >= 500;
15568
15833
  }
15834
+ /**
15835
+ * The Sync Server gave no answer about an upload, once its retries were spent: the connection
15836
+ * failed, or it answered with a status that means "try again later". A refusal (the plan, the
15837
+ * account, the graph) and a cancellation are answers, and a file is never queued for them.
15838
+ */
15839
+ function cannotReach(error) {
15840
+ if (error instanceof AssetRefusedError) return false;
15841
+ if (error instanceof DOMException && error.name === "AbortError") return false;
15842
+ if (error instanceof HttpFailure) return error.status === 408 || error.status === 429 || error.status >= 500;
15843
+ return true;
15844
+ }
15569
15845
  function kebabStem(name) {
15570
15846
  return name.replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "asset";
15571
15847
  }
@@ -15602,6 +15878,7 @@ function assetIdFromRef(ref) {
15602
15878
  }
15603
15879
  function createServerAssetStore(deps) {
15604
15880
  const f = deps.fetch ?? fetch;
15881
+ const online = deps.online ?? (() => typeof navigator === "undefined" || navigator.onLine !== false);
15605
15882
  const base = deps.baseUrl.replace(/\/$/, "");
15606
15883
  const newAssetId = deps.newAssetId ?? (() => crypto.randomUUID());
15607
15884
  const objectUrls = [];
@@ -15714,24 +15991,20 @@ function createServerAssetStore(deps) {
15714
15991
  return fetchAssetBytesOnce(ref);
15715
15992
  }
15716
15993
  }
15717
- async function fetchAssetBytesOnce(ref) {
15718
- const assetId = assetIdFromRef(ref);
15719
- if (!assetId) return null;
15720
- const body = await requestAsset(assetId);
15721
- if (!body) return null;
15722
- const metadata = await openMetadata(assetId, body.encryptedMetadata);
15723
- if (typeof metadata.chunkCount === "number" && body.downloadUrls.length !== metadata.chunkCount) throw new AssetIntegrityError();
15994
+ /**
15995
+ * Decrypt an asset's chunks under the per-asset key its opened metadata carries, and join them.
15996
+ * The metadata is the uploader's own record of what was stored, so the chunk count, the size
15997
+ * and the hash are checked against it, and a mismatch is an `AssetIntegrityError`: the bytes
15998
+ * are never handed over in part (ADR 0027, amended 2026-10-05).
15999
+ */
16000
+ async function openChunks(ref, assetId, metadata, encrypted) {
16001
+ if (typeof metadata.chunkCount === "number" && encrypted.length !== metadata.chunkCount) throw new AssetIntegrityError();
15724
16002
  const perAssetKey = fromBase64Url(metadata.perAssetKey);
15725
16003
  const parts = [];
15726
- for (let n = 0; n < body.downloadUrls.length; n++) {
15727
- const chunkRes = await readRequest(body.downloadUrls[n]);
15728
- if (!chunkRes.ok) {
15729
- if (chunkRes.status !== 401 && isTransientStatus(chunkRes.status)) throw new AssetUnavailableError(`Storage could not hand over the file just now (HTTP ${chunkRes.status}).`, { status: chunkRes.status });
15730
- return null;
15731
- }
16004
+ for (let n = 0; n < encrypted.length; n++) {
15732
16005
  const { plaintext } = await openSymmetric({
15733
16006
  keyForEpoch: () => perAssetKey,
15734
- envelope: new Uint8Array(await chunkRes.arrayBuffer()),
16007
+ envelope: encrypted[n],
15735
16008
  aad: contextAad("asset", `id:${assetId}`, `chunk:${n}`)
15736
16009
  });
15737
16010
  parts.push(plaintext);
@@ -15751,6 +16024,166 @@ function createServerAssetStore(deps) {
15751
16024
  type: assetTypeFor(ref)
15752
16025
  };
15753
16026
  }
16027
+ /**
16028
+ * The asset from this device's copy (ADR 0143), or null when there is none. A copy that will
16029
+ * not open is passed over, and the network is asked instead: a good download replaces it.
16030
+ */
16031
+ async function fromCopy(ref, assetId) {
16032
+ if (!deps.copies) return null;
16033
+ try {
16034
+ const copy = await deps.copies.read(deps.graphId, assetId);
16035
+ if (!copy) return null;
16036
+ return await openChunks(ref, assetId, await openMetadata(assetId, copy.encryptedMetadata), copy.chunks);
16037
+ } catch (error) {
16038
+ console.warn(`[assets] the copy of ${assetId} on this device would not open; fetching it again`, error);
16039
+ return null;
16040
+ }
16041
+ }
16042
+ /** Keep `asset` on this device. A failure costs only a later download, so it is logged and passed over. */
16043
+ async function keepCopy(assetId, asset) {
16044
+ await deps.copies?.keep(deps.graphId, assetId, asset).catch((error) => {
16045
+ console.warn(`[assets] could not keep a copy of ${assetId} on this device`, error);
16046
+ });
16047
+ }
16048
+ async function fetchAssetBytesOnce(ref) {
16049
+ const assetId = assetIdFromRef(ref);
16050
+ if (!assetId) return null;
16051
+ const copied = await fromCopy(ref, assetId);
16052
+ if (copied) return copied;
16053
+ const body = await requestAsset(assetId);
16054
+ if (!body) return null;
16055
+ const metadata = await openMetadata(assetId, body.encryptedMetadata);
16056
+ if (typeof metadata.chunkCount === "number" && body.downloadUrls.length !== metadata.chunkCount) throw new AssetIntegrityError();
16057
+ const encrypted = [];
16058
+ for (let n = 0; n < body.downloadUrls.length; n++) {
16059
+ const chunkRes = await readRequest(body.downloadUrls[n]);
16060
+ if (!chunkRes.ok) {
16061
+ if (chunkRes.status !== 401 && isTransientStatus(chunkRes.status)) throw new AssetUnavailableError(`Storage could not hand over the file just now (HTTP ${chunkRes.status}).`, { status: chunkRes.status });
16062
+ return null;
16063
+ }
16064
+ encrypted.push(new Uint8Array(await chunkRes.arrayBuffer()));
16065
+ }
16066
+ const asset = await openChunks(ref, assetId, metadata, encrypted);
16067
+ await keepCopy(assetId, {
16068
+ encryptedMetadata: body.encryptedMetadata,
16069
+ chunks: encrypted
16070
+ });
16071
+ return asset;
16072
+ }
16073
+ /**
16074
+ * Begin an upload: record the metadata and get one presigned PUT URL per chunk, OR learn that a
16075
+ * complete asset in this graph already carries the dedup token. Safe to retry - the server
16076
+ * records (graph, asset) ON CONFLICT DO NOTHING, so a second attempt stores nothing new,
16077
+ * double-counts no storage, and simply re-presigns.
16078
+ */
16079
+ function begin(upload, dedupToken) {
16080
+ return step(async (fresh) => {
16081
+ const res = await f(`${base}/api/v1/sync/assets`, {
16082
+ method: "POST",
16083
+ headers: await authHeaders(fresh),
16084
+ body: JSON.stringify({
16085
+ graphId: deps.graphId,
16086
+ assetId: upload.assetId,
16087
+ size: upload.size,
16088
+ chunkCount: upload.chunkCount,
16089
+ encryptedMetadata: upload.encryptedMetadata,
16090
+ ...dedupToken ? { dedupToken } : {}
16091
+ })
16092
+ });
16093
+ if (!res.ok) throw await beginFailure(res);
16094
+ return await res.json();
16095
+ });
16096
+ }
16097
+ /** Chunk the bytes and encrypt each chunk under the per-asset key (AAD binds asset id + index). */
16098
+ async function sealChunks(bytes, assetId, perAssetKey) {
16099
+ const chunkCount = assetChunkCount(bytes.length);
16100
+ const chunks = [];
16101
+ for (let n = 0; n < chunkCount; n++) chunks.push(await sealSymmetric({
16102
+ key: perAssetKey,
16103
+ epochId: 0,
16104
+ plaintext: bytes.subarray(n * CHUNK_SIZE, (n + 1) * CHUNK_SIZE),
16105
+ aad: contextAad("asset", `id:${assetId}`, `chunk:${n}`)
16106
+ }));
16107
+ return chunks;
16108
+ }
16109
+ /**
16110
+ * Upload each encrypted chunk straight to S3 via its presigned URL. Already parallel: a single
16111
+ * large asset is not the serial part - the outer asset-to-asset loop is. Each chunk retries on
16112
+ * its own: one blip must not cost the whole asset, let alone (via the import's unwind) the whole
16113
+ * graph. `report` hears the SOURCE bytes of each chunk as it lands.
16114
+ */
16115
+ async function putChunks(uploadUrls, chunks, size, report) {
16116
+ await Promise.all(uploadUrls.map((u, n) => step(async () => {
16117
+ let r;
16118
+ try {
16119
+ r = await f(u, {
16120
+ method: "PUT",
16121
+ body: chunks[n]
16122
+ });
16123
+ } catch {
16124
+ throw new Error(!online() ? "The connection dropped while uploading to the storage bucket." : "Uploading to the storage bucket was blocked. The bucket's CORS policy must allow this app's address, which whoever runs the Sync Server sets.");
16125
+ }
16126
+ if (!r.ok) throw new HttpFailure(r.status, `chunk ${n} upload failed: ${r.status}`);
16127
+ report?.(Math.min(CHUNK_SIZE, size - n * CHUNK_SIZE));
16128
+ })));
16129
+ }
16130
+ /**
16131
+ * Complete: flips the row to 'complete', which is INFORMATIONAL - nothing reads it to serve an
16132
+ * asset. Retried, but a persistent failure is swallowed: the chunks and metadata are already
16133
+ * durable, and forfeiting a twenty-minute import over a cosmetic flag is precisely the trade
16134
+ * this retry exists to prevent. A cancellation still propagates.
16135
+ */
16136
+ async function complete(assetId) {
16137
+ await step(async (fresh) => {
16138
+ const res = await f(`${base}/api/v1/sync/assets/${deps.graphId}/${assetId}`, {
16139
+ method: "POST",
16140
+ headers: await authHeaders(fresh)
16141
+ });
16142
+ if (!res.ok) throw new HttpFailure(res.status, `asset complete failed: ${res.status}`);
16143
+ }).catch((err) => {
16144
+ if (deps.signal?.aborted) throw err;
16145
+ });
16146
+ }
16147
+ /**
16148
+ * Send the uploads queued on this device for this graph, oldest first (ADR 0143). Each goes up
16149
+ * under the id its reference already names, with no dedup token, so the server cannot answer
16150
+ * with another file. Stops at the first that fails: one the server cannot be reached for waits
16151
+ * for the next try, and one it refuses waits too, with the refusal handed back to be said.
16152
+ */
16153
+ async function sendQueued() {
16154
+ if (!deps.copies) return {
16155
+ sent: 0,
16156
+ waiting: 0
16157
+ };
16158
+ const waiting = await deps.copies.queued(deps.graphId);
16159
+ let sent = 0;
16160
+ for (const asset of waiting) {
16161
+ let beginning = true;
16162
+ try {
16163
+ const begun = await begin(asset, null);
16164
+ if ("reuse" in begun) throw new Error("The sync server answered an upload with a file it was not asked for.");
16165
+ beginning = false;
16166
+ await putChunks(begun.uploadUrls, asset.chunks, asset.size);
16167
+ await complete(asset.assetId);
16168
+ } catch (error) {
16169
+ if (!(error instanceof HttpFailure && error.status === 409)) {
16170
+ const away = !online() || beginning && cannotReach(error);
16171
+ return {
16172
+ sent,
16173
+ waiting: waiting.length - sent,
16174
+ ...away ? {} : { refusal: error }
16175
+ };
16176
+ }
16177
+ }
16178
+ await deps.copies.sent(deps.graphId, asset.assetId);
16179
+ sent += 1;
16180
+ }
16181
+ return {
16182
+ sent,
16183
+ waiting: 0
16184
+ };
16185
+ }
16186
+ let sending = null;
15754
16187
  return {
15755
16188
  async save({ name, bytes, type }, onBytes) {
15756
16189
  const ring = keyring();
@@ -15768,7 +16201,11 @@ function createServerAssetStore(deps) {
15768
16201
  isImage,
15769
16202
  ...reused ? { reused } : {}
15770
16203
  });
15771
- const dedupToken = await assetDedupToken(await dedupSecret(ring), hash);
16204
+ let reported = 0;
16205
+ const report = (n) => {
16206
+ reported += n;
16207
+ onBytes?.(n);
16208
+ };
15772
16209
  const metadata = {
15773
16210
  name,
15774
16211
  type,
@@ -15778,73 +16215,66 @@ function createServerAssetStore(deps) {
15778
16215
  size: bytes.length,
15779
16216
  chunkCount
15780
16217
  };
15781
- const encryptedMetadata = await sealSymmetric({
15782
- key: currentEpoch(ring).key,
15783
- epochId: currentEpoch(ring).epochId,
15784
- plaintext: utf8(JSON.stringify(metadata)),
15785
- aad: contextAad("asset-meta", `graph:${deps.graphId}`, `id:${assetId}`)
15786
- });
15787
- const begin = (withToken) => step(async (fresh) => {
15788
- const res = await f(`${base}/api/v1/sync/assets`, {
15789
- method: "POST",
15790
- headers: await authHeaders(fresh),
15791
- body: JSON.stringify({
15792
- graphId: deps.graphId,
15793
- assetId,
15794
- size: bytes.length,
15795
- chunkCount,
15796
- encryptedMetadata: toBase64Url(encryptedMetadata),
15797
- ...withToken ? { dedupToken } : {}
15798
- })
16218
+ const upload = {
16219
+ assetId,
16220
+ size: bytes.length,
16221
+ chunkCount,
16222
+ encryptedMetadata: toBase64Url(await sealSymmetric({
16223
+ key: currentEpoch(ring).key,
16224
+ epochId: currentEpoch(ring).epochId,
16225
+ plaintext: utf8(JSON.stringify(metadata)),
16226
+ aad: contextAad("asset-meta", `graph:${deps.graphId}`, `id:${assetId}`)
16227
+ }))
16228
+ };
16229
+ const queue = async (chunks) => {
16230
+ await deps.copies.queue(deps.graphId, {
16231
+ ...upload,
16232
+ chunks: chunks ?? await sealChunks(bytes, assetId, perAssetKey)
15799
16233
  });
15800
- if (!res.ok) throw await beginFailure(res);
15801
- return await res.json();
15802
- });
15803
- let begun = await begin(true);
16234
+ report(bytes.length - reported);
16235
+ return {
16236
+ ...saved(assetId, false),
16237
+ queued: true
16238
+ };
16239
+ };
16240
+ if (deps.copies && !online()) return queue();
16241
+ const dedupToken = await assetDedupToken(await dedupSecret(ring), hash);
16242
+ let begun;
16243
+ try {
16244
+ begun = await begin(upload, dedupToken);
16245
+ } catch (error) {
16246
+ if (deps.copies && !deps.signal?.aborted && cannotReach(error)) return queue();
16247
+ throw error;
16248
+ }
15804
16249
  if ("reuse" in begun) {
15805
16250
  if (await holdsSameBytes(begun.reuse.assetId, hash)) {
15806
- onBytes?.(bytes.length);
16251
+ report(bytes.length);
15807
16252
  return saved(begun.reuse.assetId, true);
15808
16253
  }
15809
16254
  console.warn(`[sync] the server offered asset ${begun.reuse.assetId} for reuse, but its hash differs; uploading a fresh copy`);
15810
- begun = await begin(false);
16255
+ begun = await begin(upload, null);
15811
16256
  if ("reuse" in begun) throw new Error("The sync server answered an upload with a file it was not asked for.");
15812
16257
  }
15813
- const { uploadUrls } = begun;
15814
- const chunks = [];
15815
- for (let n = 0; n < chunkCount; n++) {
15816
- const slice = bytes.subarray(n * CHUNK_SIZE, (n + 1) * CHUNK_SIZE);
15817
- chunks.push(await sealSymmetric({
15818
- key: perAssetKey,
15819
- epochId: 0,
15820
- plaintext: slice,
15821
- aad: contextAad("asset", `id:${assetId}`, `chunk:${n}`)
15822
- }));
16258
+ const chunks = await sealChunks(bytes, assetId, perAssetKey);
16259
+ try {
16260
+ await putChunks(begun.uploadUrls, chunks, bytes.length, report);
16261
+ } catch (error) {
16262
+ if (deps.copies && !deps.signal?.aborted && !online()) return queue(chunks);
16263
+ throw error;
15823
16264
  }
15824
- await Promise.all(uploadUrls.map((u, n) => step(async () => {
15825
- let r;
15826
- try {
15827
- r = await f(u, {
15828
- method: "PUT",
15829
- body: chunks[n]
15830
- });
15831
- } catch {
15832
- throw new Error(typeof navigator !== "undefined" && navigator.onLine === false ? "The connection dropped while uploading to the storage bucket." : "Uploading to the storage bucket was blocked. The bucket's CORS policy must allow this app's origin - see \"Asset storage\" in the Synced Graphs doc.");
15833
- }
15834
- if (!r.ok) throw new HttpFailure(r.status, `chunk ${n} upload failed: ${r.status}`);
15835
- onBytes?.(Math.min(CHUNK_SIZE, bytes.length - n * CHUNK_SIZE));
15836
- })));
15837
- await step(async (fresh) => {
15838
- const res = await f(`${base}/api/v1/sync/assets/${deps.graphId}/${assetId}`, {
15839
- method: "POST",
15840
- headers: await authHeaders(fresh)
15841
- });
15842
- if (!res.ok) throw new HttpFailure(res.status, `asset complete failed: ${res.status}`);
15843
- }).catch((err) => {
15844
- if (deps.signal?.aborted) throw err;
16265
+ await complete(assetId);
16266
+ await keepCopy(assetId, {
16267
+ encryptedMetadata: upload.encryptedMetadata,
16268
+ chunks
15845
16269
  });
15846
16270
  return saved(assetId, false);
15847
16271
  },
16272
+ uploadQueued() {
16273
+ sending ??= sendQueued().finally(() => {
16274
+ sending = null;
16275
+ });
16276
+ return sending;
16277
+ },
15848
16278
  readBytes: (ref) => fetchAssetBytes(ref).catch((error) => error instanceof AssetUnavailableError && error.status !== void 0 || error instanceof AssetIntegrityError ? null : Promise.reject(error)),
15849
16279
  async resolve(ref) {
15850
16280
  const asset = await fetchAssetBytes(ref);
@@ -16185,6 +16615,89 @@ async function taskLinesWith(lines, fingerprint) {
16185
16615
  return out;
16186
16616
  }
16187
16617
  //#endregion
16618
+ //#region ../client/src/lib/document/templates/template-variables.ts
16619
+ /**
16620
+ * [[Template Variable]]s: the `{{name}}` text in a [[Template]] that is filled in when the
16621
+ * Template is copied (ADR 0144).
16622
+ *
16623
+ * Every variable gives plain text, so a link is written around one (`[[{{date}}]]`). The date
16624
+ * variables read the context's `day`, which is the receiving [[Journal Entry]]'s own day or
16625
+ * today anywhere else; the time variables read the clock. A name this module does not know is
16626
+ * copied as written, so `{{name}}` in a Handlebars sample survives a copy, and a backslash before
16627
+ * the braces (`\{{date}}`) writes the braces literally.
16628
+ *
16629
+ * Pure and framework-free: the Client's insert command, the Journal Template's [[Draft]] and the
16630
+ * Headless Client all render through `renderTemplate`.
16631
+ */
16632
+ /**
16633
+ * Double braces around anything without a brace, with an optional backslash before them. One
16634
+ * pass with this pattern is the whole renderer, so a value it writes is never read again.
16635
+ */
16636
+ var VARIABLE = /(\\?)\{\{([^{}]+)\}\}/g;
16637
+ /** The format tokens, longest first so `YYYY` is never read as two `YY`s. Case-sensitive. */
16638
+ var FORMAT_TOKEN = /YYYY|YY|MM|DD|HH|mm|ss/g;
16639
+ var FORMAT_PREFIX = /^(date|time):(.+)$/i;
16640
+ /** The context for copying a Template into the document named `concept`, at `now`. */
16641
+ function templateContextFor(concept, now = /* @__PURE__ */ new Date()) {
16642
+ return {
16643
+ day: isJournalConcept(concept) ? concept.trim() : todayISO(now),
16644
+ now,
16645
+ title: concept
16646
+ };
16647
+ }
16648
+ /** Fill in `body`'s variables for `context`. */
16649
+ function renderTemplate(body, context) {
16650
+ const day = parseISODate(context.day) ?? context.now;
16651
+ let cursor = null;
16652
+ let text = "";
16653
+ let last = 0;
16654
+ for (const match of body.matchAll(VARIABLE)) {
16655
+ const [whole, backslash, inner] = match;
16656
+ text += body.slice(last, match.index);
16657
+ last = match.index + whole.length;
16658
+ if (backslash) {
16659
+ text += whole.slice(1);
16660
+ continue;
16661
+ }
16662
+ if (inner.trim().toLowerCase() === "cursor") {
16663
+ cursor ??= text.length;
16664
+ continue;
16665
+ }
16666
+ text += valueOf(inner.trim(), day, context) ?? whole;
16667
+ }
16668
+ return {
16669
+ text: text + body.slice(last),
16670
+ cursor
16671
+ };
16672
+ }
16673
+ /** The text a variable stands for, or null for a name this module does not know. */
16674
+ function valueOf(inner, day, context) {
16675
+ switch (inner.toLowerCase()) {
16676
+ case "date": return formatISODate(day);
16677
+ case "yesterday": return formatISODate(shiftDate(day, -1));
16678
+ case "tomorrow": return formatISODate(shiftDate(day, 1));
16679
+ case "time": return format("HH:mm", day, context.now);
16680
+ case "datetime": return `${formatISODate(day)} ${format("HH:mm", day, context.now)}`;
16681
+ case "title": return context.title;
16682
+ }
16683
+ const custom = FORMAT_PREFIX.exec(inner);
16684
+ return custom ? format(custom[2], day, context.now) : null;
16685
+ }
16686
+ function format(pattern, day, now) {
16687
+ const pad = (n) => String(n).padStart(2, "0");
16688
+ return pattern.replace(FORMAT_TOKEN, (token) => {
16689
+ switch (token) {
16690
+ case "YYYY": return String(day.getFullYear());
16691
+ case "YY": return pad(day.getFullYear() % 100);
16692
+ case "MM": return pad(day.getMonth() + 1);
16693
+ case "DD": return pad(day.getDate());
16694
+ case "HH": return pad(now.getHours());
16695
+ case "mm": return pad(now.getMinutes());
16696
+ default: return pad(now.getSeconds());
16697
+ }
16698
+ });
16699
+ }
16700
+ //#endregion
16188
16701
  //#region ../../extensions/graph-view/src/model/graph-model.ts
16189
16702
  /**
16190
16703
  * The [[Graph View]]'s model: the index's {@link LinkGraph} turned into a graphology graph the
@@ -16553,8 +17066,34 @@ function taskLineWith(line, changes) {
16553
17066
  function sameTags(a, b) {
16554
17067
  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;
16555
17068
  }
16556
- /** UTF-16 units of document text returned before `truncated` is set. */
16557
- var READ_TEXT_CAP = 2e5;
17069
+ //#endregion
17070
+ //#region src/tools.ts
17071
+ /**
17072
+ * The eight tools an agent gets, as plain functions over an open {@link HeadlessGraph}. The MCP
17073
+ * layer (`mcp-server.ts`) only serialises these; everything an agent can and cannot do is
17074
+ * decided here, and tested here over both backends - the loopback relay standing in for the
17075
+ * Sync Server, and an in-memory folder - without a server or a transport. The tools see only
17076
+ * `HeadlessDocuments`: every one starts with `refresh()` so a folder is re-read before it is
17077
+ * answered from, and a write ends with `settle()` so success means durable.
17078
+ *
17079
+ * The rules (ADR 0072, [[2026-09-15 Headless Client And MCP]]):
17080
+ *
17081
+ * - A [[Protected Document]] is listed by name and nothing else. Reading refuses; every write
17082
+ * refuses, frontmatter included. The Headless Client never holds a Protection Key, so this is
17083
+ * a courtesy to the agent rather than the guarantee - the guarantee is that there is nothing
17084
+ * here to serve.
17085
+ * - Edits are anchored: `edit_document` replaces one exact, unique occurrence and lands as one
17086
+ * `applyChange` range, which the sync engine carries as one `Y.Text` change that merges with
17087
+ * whoever is typing elsewhere in the page. There is no whole-page replace.
17088
+ * - Every write passes through the paste / import indent normaliser, so an agent that writes
17089
+ * four-space bullets lands on the grid like everyone else.
17090
+ * - Every result is bounded: pages are paged, a document read is capped and says so.
17091
+ * - `search` has a `mode`: `text` (the index's FTS, the default), `semantic` (nearest
17092
+ * [[Passage]]s by [[Embedding]], ADR 0076) and `hybrid` (both, as two labelled groups rather
17093
+ * than one fused ranking - the same choice the Search modal makes, so the agent can see WHY
17094
+ * something matched). Semantic mode needs the model set up on this machine; without it the
17095
+ * refusal says what to run.
17096
+ */
16558
17097
  /** A refusal the agent can act on: a stable code first, a sentence second. */
16559
17098
  var ToolError = class extends Error {
16560
17099
  code;
@@ -16564,8 +17103,6 @@ var ToolError = class extends Error {
16564
17103
  this.name = "ToolError";
16565
17104
  }
16566
17105
  };
16567
- /** UTF-16 units across one `read_documents` call before the rest is cut. */
16568
- var READ_MANY_TEXT_CAP = 4e5;
16569
17106
  /** Keys the frontmatter tools never read or write; each has a tool of its own. */
16570
17107
  var OWNED_KEYS = /* @__PURE__ */ new Set([
16571
17108
  "title",
@@ -16655,7 +17192,7 @@ async function liveText(graph, identity) {
16655
17192
  return graph.store.open(identity.concept).getText();
16656
17193
  }
16657
17194
  function refuseIfProtected(concept, text) {
16658
- 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.`);
17195
+ if (isProtectedDocumentText(text)) 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.`);
16659
17196
  }
16660
17197
  function page$1(items, offset, limit) {
16661
17198
  return {
@@ -16701,13 +17238,14 @@ async function readDocument(graph, concept) {
16701
17238
  const identity = requireIdentity(graph, concept);
16702
17239
  const text = await liveText(graph, identity);
16703
17240
  refuseIfProtected(identity.concept, text);
17241
+ const capped = capText(text, READ_TEXT_CAP);
16704
17242
  return {
16705
17243
  concept: identity.concept,
16706
17244
  kind: identity.kind,
16707
17245
  aliases: identity.aliases,
16708
- text: text.length > 2e5 ? text.slice(0, READ_TEXT_CAP) : text,
17246
+ text: capped.text,
16709
17247
  frontmatter: agentFrontmatter(graph.store.openRaw(identity.concept).getText()),
16710
- truncated: text.length > READ_TEXT_CAP
17248
+ truncated: capped.truncated
16711
17249
  };
16712
17250
  }
16713
17251
  async function search(graph, args) {
@@ -16763,40 +17301,9 @@ async function searchSemantic(graph, query, offset, limit) {
16763
17301
  function round(similarity) {
16764
17302
  return Math.round(similarity * 1e3) / 1e3;
16765
17303
  }
16766
- async function searchText(graph, query, offset, limit) {
16767
- const keys = await graph.index.propertyKeys();
16768
- const { words, filters } = parseSearchQuery(query, new Set(keys.map((info) => info.key.toLowerCase())));
16769
- if (filters.length > 0 && words.trim() === "") {
16770
- const documents = await graph.index.propertyMatch(filters);
16771
- return {
16772
- results: documents.slice(offset, offset + limit).map((doc) => ({
16773
- concept: doc.concept,
16774
- kind: doc.kind,
16775
- matches: 0,
16776
- hits: [],
16777
- properties: doc.properties
16778
- })),
16779
- total: documents.length,
16780
- totalCapped: false,
16781
- hasMore: offset + limit < documents.length
16782
- };
16783
- }
16784
- const [result, count] = await Promise.all([graph.index.searchText(words, offset, limit, filters), graph.index.searchTextCount(words, filters)]);
16785
- return {
16786
- results: result.groups.map((group) => ({
16787
- concept: group.concept,
16788
- kind: group.kind,
16789
- matches: group.matches,
16790
- hits: group.hits.map((hit) => ({
16791
- line: hit.line,
16792
- breadcrumb: hit.breadcrumb,
16793
- snippet: hit.snippet.map((segment) => segment.text).join("")
16794
- }))
16795
- })),
16796
- total: count.total,
16797
- totalCapped: count.capped,
16798
- hasMore: result.hasMore
16799
- };
17304
+ /** The index's text group, read as Search reads it (shared with the browser's Agent Chat). */
17305
+ function searchText(graph, query, offset, limit) {
17306
+ return searchText$1(graph.index, query, offset, limit);
16800
17307
  }
16801
17308
  async function backlinks(graph, concept) {
16802
17309
  const target = concept.trim() === "today" ? todayISO() : concept.trim();
@@ -16804,16 +17311,7 @@ async function backlinks(graph, concept) {
16804
17311
  await graph.store.refresh();
16805
17312
  return {
16806
17313
  concept: target,
16807
- sources: (await graph.index.backlinks(target)).map((group) => ({
16808
- concept: group.sourceConcept,
16809
- kind: group.sourceKind,
16810
- references: group.refs.map((ref) => ({
16811
- kind: ref.kind,
16812
- line: ref.line,
16813
- breadcrumb: ref.breadcrumb,
16814
- text: ref.kind === "block" ? ref.subtree.map((node) => node.text).join("\n") : ref.context?.text ?? ""
16815
- }))
16816
- }))
17314
+ sources: backlinkSources(await graph.index.backlinks(target))
16817
17315
  };
16818
17316
  }
16819
17317
  /**
@@ -16933,6 +17431,30 @@ async function tasks(graph, args = {}) {
16933
17431
  };
16934
17432
  }
16935
17433
  /**
17434
+ * What a tool writes from `template` and `text`: the named [[Template]]'s body with its variables
17435
+ * filled in for the document called `into`, then the text, each in the form EtherPK writes
17436
+ * (ADR 0144). `{{cursor}}` marks where a person's caret would go, so here it is only removed.
17437
+ * The Journal Template is never applied on its own: an agent's write holds what it asked for.
17438
+ */
17439
+ async function withTemplate(graph, template, into, text) {
17440
+ const parts = [];
17441
+ if (template !== void 0) parts.push(asWritten(renderTemplate(await templateBodyFor(graph, template), templateContextFor(into)).text));
17442
+ if (text !== void 0 && text !== "") parts.push(asWritten(text));
17443
+ return parts.join("\n");
17444
+ }
17445
+ /** The body a copy of the Template named `name` takes, or a refusal that says how to find one. */
17446
+ async function templateBodyFor(graph, name) {
17447
+ const identity = resolveIdentity(graph, name.trim());
17448
+ let raw = "";
17449
+ if (identity) {
17450
+ await graph.store.whenReady(identity.concept);
17451
+ raw = graph.store.openRaw(identity.concept).getText();
17452
+ }
17453
+ const { data, body } = parseFrontmatter(raw);
17454
+ if (!identity || !isTemplate(data, identity.kind) || isProtectedDocumentText(body)) throw new ToolError("invalid_argument", `"${name}" is not a Template. A page is a Template when its frontmatter says template: true; search for template:true to list them.`);
17455
+ return templateBody(raw);
17456
+ }
17457
+ /**
16936
17458
  * Text an agent sent, in the form EtherPK writes: line feeds only (ADR 0112), then the
16937
17459
  * two-space Indent Unit (ADR 0067). Line endings go first so the normaliser reads whole lines.
16938
17460
  */
@@ -16962,10 +17484,10 @@ async function editDocument(graph, args) {
16962
17484
  };
16963
17485
  }
16964
17486
  async function appendDocument(graph, args) {
16965
- const text = asWritten(args.text);
16966
- if (text.trim() === "") throw new ToolError("invalid_argument", "text must not be empty.");
16967
17487
  const target = args.concept.trim() === "today" ? todayISO() : args.concept.trim();
16968
17488
  await graph.store.refresh();
17489
+ const text = await withTemplate(graph, args.template, target, args.text);
17490
+ if (text.trim() === "") throw new ToolError("invalid_argument", "Give text, a template, or both.");
16969
17491
  const existing = resolveIdentity(graph, target);
16970
17492
  if (!existing) {
16971
17493
  if (!isJournalConcept(target)) throw new ToolError("not_found", `No document is named "${target}". Use create_page to start a new page.`);
@@ -16996,7 +17518,7 @@ async function createPage(graph, args) {
16996
17518
  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.`);
16997
17519
  await graph.store.refresh();
16998
17520
  if (resolveIdentity(graph, title)) throw new ToolError("already_exists", `A document already answers to "${title}". Use edit_document or append_document instead.`);
16999
- await graph.store.createPage(title, asWritten(args.text ?? ""));
17521
+ await graph.store.createPage(title, await withTemplate(graph, args.template, title, args.text));
17000
17522
  if (args.frontmatter && Object.keys(args.frontmatter).length > 0) {
17001
17523
  await graph.store.whenReady(title);
17002
17524
  const raw = graph.store.openRaw(title).getText();
@@ -17355,7 +17877,7 @@ async function readDocuments(graph, args) {
17355
17877
  continue;
17356
17878
  }
17357
17879
  const text = await liveText(graph, identity);
17358
- if (documentProtection(text).kind === "document") {
17880
+ if (isProtectedDocumentText(text)) {
17359
17881
  documents.push({
17360
17882
  concept: identity.concept,
17361
17883
  error: "protected_document",
@@ -17452,7 +17974,7 @@ function referenceSources(graph) {
17452
17974
  const identity = resolveIdentity(graph, document);
17453
17975
  if (!identity) return null;
17454
17976
  const text = await liveText(graph, identity);
17455
- return documentProtection(text).kind === "document" ? null : text;
17977
+ return isProtectedDocumentText(text) ? null : text;
17456
17978
  },
17457
17979
  allTasks: () => everyIndexedTask(graph.index, todayISO())
17458
17980
  };
@@ -18709,18 +19231,20 @@ function createMcpServer(source, info) {
18709
19231
  }, async (args) => run((graph) => editDocument(graph, args)));
18710
19232
  server.registerTool("append_document", {
18711
19233
  title: "Append to a document",
18712
- description: "Add text at the end of a page or a journal entry, on a new line. A journal day that has no entry yet is created (\"today\" is accepted). A page that does not exist is not created: use create_page.",
19234
+ description: "Add text at the end of a page or a journal entry, on a new line. A journal day that has no entry yet is created (\"today\" is accepted). A page that does not exist is not created: use create_page. With template, a Template's body is added first, its variables ({{date}}, {{title}}, ...) filled in for this document; a journal day takes its own date. The graph's Journal Template is never added on its own.",
18713
19235
  inputSchema: {
18714
19236
  concept,
18715
- text: z.string().min(1)
19237
+ text: z.string().min(1).optional(),
19238
+ template: z.string().min(1).optional().describe("The name of a Template: a page whose frontmatter says template: true. Search for template:true to list them.")
18716
19239
  }
18717
19240
  }, async (args) => run((graph) => appendDocument(graph, args)));
18718
19241
  server.registerTool("create_page", {
18719
19242
  title: "Create a page",
18720
- description: "Create a new page with the given title, optional markdown body and optional frontmatter (the same keys and rules as set_frontmatter). Refuses a title that already names a page or an alias, or that is a calendar day (use append_document for journal entries).",
19243
+ description: "Create a new page with the given title, optional markdown body and optional frontmatter (the same keys and rules as set_frontmatter). With template, the page starts with that Template's body, its variables ({{date}}, {{title}}, ...) filled in for the new page, then the text. Refuses a title that already names a page or an alias, or that is a calendar day (use append_document for journal entries).",
18721
19244
  inputSchema: {
18722
19245
  title: z.string().min(1),
18723
19246
  text: z.string().optional(),
19247
+ template: z.string().min(1).optional().describe("The name of a Template: a page whose frontmatter says template: true. Search for template:true to list them."),
18724
19248
  frontmatter: z.record(z.string(), frontmatterValue).optional().describe("Frontmatter keys for the new page, e.g. {\"public\": true, \"publications\": [\"blog\"], \"date\": \"2026-01-31\"}.")
18725
19249
  }
18726
19250
  }, async (args) => run((graph) => createPage(graph, args)));
@@ -19421,14 +19945,19 @@ function sameNames(a, b) {
19421
19945
  //#region ../client/src/lib/document/index-link-graph.ts
19422
19946
  function linkGraph(db) {
19423
19947
  const generation = activeIndexGeneration(db);
19424
- const pages = db.all("SELECT concept, concept_key AS key, kind, protected FROM pages WHERE generation = ? ORDER BY id", [generation]);
19948
+ const pages = db.all("SELECT concept, concept_key AS key, kind, protected, template FROM pages WHERE generation = ? ORDER BY id", [generation]);
19425
19949
  const aliases = db.all("SELECT a.alias_key AS key, p.concept_key AS page FROM aliases a JOIN pages p ON p.id = a.page_id WHERE p.generation = ? ORDER BY a.page_id", [generation]);
19426
19950
  const rows = db.all(`SELECT p.concept_key AS source, json_group_array(json_array(l.concept_key, l.concept, l.in_title)) AS links
19427
19951
  FROM links l JOIN pages p ON p.id = l.page_id
19428
19952
  WHERE p.generation = ?
19429
19953
  GROUP BY p.id`, [generation]);
19430
19954
  const concepts = /* @__PURE__ */ new Map();
19955
+ const templates = /* @__PURE__ */ new Set();
19431
19956
  for (const page of pages) {
19957
+ if (page.template) {
19958
+ templates.add(page.key);
19959
+ continue;
19960
+ }
19432
19961
  if (concepts.has(page.key)) continue;
19433
19962
  const concept = {
19434
19963
  key: page.key,
@@ -19442,15 +19971,15 @@ function linkGraph(db) {
19442
19971
  } : concept);
19443
19972
  }
19444
19973
  const pageOfAlias = /* @__PURE__ */ new Map();
19445
- for (const alias of aliases) if (!concepts.has(alias.key) && !pageOfAlias.has(alias.key)) pageOfAlias.set(alias.key, alias.page);
19974
+ for (const alias of aliases) if (!concepts.has(alias.key) && !templates.has(alias.key) && !pageOfAlias.has(alias.key)) pageOfAlias.set(alias.key, alias.page);
19446
19975
  const resolve = (key) => concepts.has(key) ? key : pageOfAlias.get(key) ?? key;
19447
- const parsed = rows.map((row) => ({
19976
+ const parsed = rows.filter((row) => !templates.has(row.source)).map((row) => ({
19448
19977
  source: row.source,
19449
19978
  links: JSON.parse(row.links)
19450
19979
  }));
19451
19980
  const spellings = /* @__PURE__ */ new Map();
19452
19981
  for (const row of parsed) for (const [key, written] of row.links) {
19453
- if (concepts.has(resolve(key))) continue;
19982
+ if (concepts.has(resolve(key)) || templates.has(resolve(key))) continue;
19454
19983
  const counts = spellings.get(key) ?? /* @__PURE__ */ new Map();
19455
19984
  counts.set(written, (counts.get(written) ?? 0) + 1);
19456
19985
  spellings.set(key, counts);
@@ -19825,7 +20354,7 @@ function createIndexCore(host) {
19825
20354
  }];
19826
20355
  }
19827
20356
  case "search-text": {
19828
- const page = db ? searchText$1(db, request.query, request.offset, request.limit, request.filters ?? []) : {
20357
+ const page = db ? searchText$2(db, request.query, request.offset, request.limit, request.filters ?? []) : {
19829
20358
  groups: [],
19830
20359
  hasMore: false
19831
20360
  };
@@ -19853,6 +20382,11 @@ function createIndexCore(host) {
19853
20382
  id: request.id,
19854
20383
  keys: db ? propertyKeys(db) : []
19855
20384
  }];
20385
+ case "templates": return [{
20386
+ type: "templates",
20387
+ id: request.id,
20388
+ templates: db ? templatePages(db) : []
20389
+ }];
19856
20390
  case "property-values": return [{
19857
20391
  type: "property-values",
19858
20392
  id: request.id,
@@ -20754,6 +21288,12 @@ function createRemoteGraphIndex(source, transport, options) {
20754
21288
  id
20755
21289
  }))).keys;
20756
21290
  },
21291
+ async templates() {
21292
+ return (await request((id) => ({
21293
+ type: "templates",
21294
+ id
21295
+ }))).templates;
21296
+ },
20757
21297
  async propertyValues(key) {
20758
21298
  return (await request((id) => ({
20759
21299
  type: "property-values",
@@ -21866,7 +22406,8 @@ function indexSnapshotOf(identity, text) {
21866
22406
  aliases: aliasesOf(fm),
21867
22407
  text: fm.body,
21868
22408
  ...includes.length > 0 ? { includes } : {},
21869
- ...properties.length > 0 ? { properties } : {}
22409
+ ...properties.length > 0 ? { properties } : {},
22410
+ ...isTemplate(fm.data, identity.kind) ? { template: true } : {}
21870
22411
  };
21871
22412
  }
21872
22413
  function applyTextChange(text, change) {
@@ -23122,14 +23663,16 @@ function createServerDocumentStore(graph, options) {
23122
23663
  text,
23123
23664
  aliases: []
23124
23665
  }) : [];
23125
- const properties = propertiesOf(parseFrontmatter(text).data);
23666
+ const frontmatter = parseFrontmatter(text).data;
23667
+ const properties = propertiesOf(frontmatter);
23126
23668
  return {
23127
23669
  concept,
23128
23670
  kind: entry.kind,
23129
23671
  aliases: entry.aliases ?? [],
23130
23672
  text: bodyForIndex(text),
23131
23673
  ...includes.length > 0 ? { includes } : {},
23132
- ...properties.length > 0 ? { properties } : {}
23674
+ ...properties.length > 0 ? { properties } : {},
23675
+ ...isTemplate(frontmatter, entry.kind) ? { template: true } : {}
23133
23676
  };
23134
23677
  }
23135
23678
  /**
@@ -24942,11 +25485,12 @@ async function pollDeviceApproval(api, request) {
24942
25485
  * (ADR 0072). Two routes, both already served by the Sync Server for every new device:
24943
25486
  *
24944
25487
  * - **Device Approval** (ADR 0125), the default: this process posts a commitment to a one-time
24945
- * key; once an unlocked EtherPK tab answers, it prints the code both screens show. The user
24946
- * approves in the tab AND presses y here: a server can play the approving device itself, and
25488
+ * key; once EtherPK answers on a device where the account's Encryption Keys are unlocked, it
25489
+ * prints the code both screens show. The user approves on that device AND presses y here: a
25490
+ * server can play the approving device itself, and
24947
25491
  * only the user, comparing the two screens, can tell. The key that arrives is checked against
24948
25492
  * the vault before it is used. No secret passes through the terminal.
24949
- * - **Recovery Code**, behind a flag, for a box with no tab to hand: the code derives the wrap
25493
+ * - **Recovery Code**, behind a flag, for a box with no unlocked device to hand: the code derives the wrap
24950
25494
  * key, which opens the vault; what is cached is the vault key, never the code.
24951
25495
  *
24952
25496
  * Pure over an injected clock and output so the flow is testable without a terminal.
@@ -24973,8 +25517,8 @@ var SIGNAL_EXIT_CODES = {
24973
25517
  * The keys the approval wait answers to. `y` confirms the codes match; `n` says they do not, which
24974
25518
  * cancels the request; `r` switches to the Recovery Code; Ctrl-C (a key while the terminal is in
24975
25519
  * raw mode) and SIGINT, SIGTERM or SIGHUP quit. Every way out aborts the wait, so it cancels its
24976
- * approval server-side before anything exits: a login left pending showed its stale code in every
24977
- * unlocked tab for ten minutes. `exitCode` is set once the user has quit.
25520
+ * approval server-side before anything exits: a login left pending showed its stale code on every
25521
+ * unlocked device for ten minutes. `exitCode` is set once the user has quit.
24978
25522
  */
24979
25523
  function approvalWaitControls() {
24980
25524
  const abort = new AbortController();
@@ -25042,8 +25586,8 @@ async function unlockByDeviceApproval(api, io) {
25042
25586
  const request = await beginDeviceApproval(api);
25043
25587
  const where = io.clientUrl ? `open EtherPK at ${io.clientUrl}` : "open EtherPK in a browser";
25044
25588
  io.say("");
25045
- io.say(`To approve this device, ${where} (any page - it need not be a note), connected to this account`);
25046
- io.say("with its Encryption Keys unlocked. A prompt will appear there, and this terminal then shows a code to compare.");
25589
+ io.say(`To approve this device, ${where} (any page - it need not be a note) on any device where this account's`);
25590
+ io.say("Encryption Keys are unlocked. A prompt will appear there, and this terminal then shows a code to compare.");
25047
25591
  io.say("");
25048
25592
  io.say("Waiting (up to ten minutes)…");
25049
25593
  const abandon = async () => {
@@ -25297,13 +25841,12 @@ var VERSION = package_default.version;
25297
25841
  var CMD = /[\\/]_npx[\\/]/.test(process.argv[1] ?? "") ? "npx @appsoftwareltd/etherpk-mcp" : "etherpk-mcp";
25298
25842
  var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synced graph)
25299
25843
 
25300
- ${CMD} login --sync-server <url> [--code <setup code>] [--recovery-code]
25301
- Sign this machine in as a device of your account. Make a setup code in EtherPK
25302
- (a synced graph's Settings > Agents, or <url>/account/tokens) and pass it with --code:
25303
- it works once, within 10 minutes, and gets this machine an agent token of its own,
25304
- which can do only what an agent needs. Then unlock your Encryption Keys by Device
25305
- Approval: open EtherPK in a browser connected to the account with its Encryption Keys
25306
- unlocked, approve there if it shows the same code, then press y. Press r while waiting,
25844
+ ${CMD} login --sync-server <url> [--code <Agent Setup Code>] [--recovery-code]
25845
+ Sign in this computer as a device of your account. Create an Agent Setup Code in EtherPK
25846
+ (the Graphs page's Agents tab, or a synced graph's Settings > Agents, or <url>/account/tokens) and pass it with --code: it works once, within 10
25847
+ minutes, and gets this computer an Agent Token limited to the routes an agent needs. Then
25848
+ unlock your Encryption Keys by Device Approval: confirm the matching code in EtherPK on
25849
+ any device where your Encryption Keys are unlocked, then press y. Press r while waiting,
25307
25850
  or pass --recovery-code, to type your Recovery Code instead (or ETHERPK_RECOVERY_CODE,
25308
25851
  for a scripted setup).
25309
25852
  For a script, --pat <token> or ETHERPK_PAT takes an account-wide access token instead,
@@ -25505,11 +26048,11 @@ async function login(args) {
25505
26048
  const syncServer = normaliseSyncServer(args["sync-server"] ?? (known.length === 1 ? known[0] : await ask("Sync Server URL: ", { hint: "pass --sync-server <url>" })));
25506
26049
  if (!/^https?:\/\//.test(syncServer)) fail("The Sync Server must be an http(s) URL.");
25507
26050
  const previous = config.servers[syncServer];
25508
- const given = (args.code ?? args.pat ?? process.env.ETHERPK_PAT ?? await ask("Setup code (make one in EtherPK: a synced graph's Settings > Agents): ", {
26051
+ const given = (args.code ?? args.pat ?? process.env.ETHERPK_PAT ?? await ask(`Agent Setup Code (create one in EtherPK: the Graphs page's Agents tab, or a synced graph's Settings > Agents): `, {
25509
26052
  secret: true,
25510
- hint: "pass --code <setup code>"
26053
+ hint: "pass --code <Agent Setup Code>"
25511
26054
  })).trim();
25512
- if (!given) fail("A setup code is required. Make one in EtherPK: a synced graph's Settings > Agents.");
26055
+ if (!given) fail(`An Agent Setup Code is required. Create one in EtherPK: the Graphs page's Agents tab, or a synced graph's Settings > Agents.`);
25513
26056
  const name = agentTokenName();
25514
26057
  let token;
25515
26058
  let redeemed = false;
@@ -25521,7 +26064,7 @@ async function login(args) {
25521
26064
  }).catch((error) => fail(describeLoginFailure(error, syncServer)));
25522
26065
  redeemed = true;
25523
26066
  } else if (accessTokenKind(given) !== null) token = given;
25524
- else fail("That is neither a setup code (epk_setup_...) nor an access token (epk_pat_...). Make a setup code in EtherPK: a synced graph's Settings > Agents.");
26067
+ else fail(`That is neither an Agent Setup Code (epk_setup_...) nor an access token (epk_pat_...). Create an Agent Setup Code in EtherPK: the Graphs page's Agents tab, or a synced graph's Settings > Agents.`);
25525
26068
  const abandon = async (error) => {
25526
26069
  if (redeemed) await revokeHeldToken({
25527
26070
  syncServer,
@@ -25541,18 +26084,16 @@ async function login(args) {
25541
26084
  const vaultKey = await (args["recovery-code"] ? byRecoveryCode() : approveOrFallBack(account, byRecoveryCode)).catch(abandon);
25542
26085
  if (accessTokenKind(token) === "standard") {
25543
26086
  const given = token;
25544
- const agentToken = await exchangeForAgentToken({
26087
+ token = await exchangeForAgentToken({
25545
26088
  syncServer,
25546
26089
  token,
25547
26090
  name
25548
26091
  }).catch(async (error) => {
25549
- if (!await tokenStillWorks(syncServer, given)) fail(`Could not swap the access token for an agent token (${describeLoginFailure(error, syncServer)}), and the access token you gave no longer works. Run login again with a setup code.`);
25550
- console.log(`Could not swap the access token for an agent token (${describeLoginFailure(error, syncServer)}), so this computer keeps the access token you gave. Run login again to try again.`);
26092
+ const reason = describeLoginFailure(error, syncServer);
26093
+ if (!await tokenStillWorks(syncServer, given)) return fail(`Could not swap the access token for an Agent Token (${reason}), and the access token you gave no longer works. Run login again with an Agent Setup Code.`);
26094
+ return fail(`Could not swap the access token for an Agent Token (${reason}). Nothing was kept on this computer: run login again.`);
25551
26095
  });
25552
- if (agentToken) {
25553
- token = agentToken;
25554
- console.log(`The access token you gave has been revoked. This computer now holds an agent token of its own, which can do only what an agent needs. If you used that access token anywhere else, make a new one at ${syncServer}/account/tokens.`);
25555
- } else if (agentToken === null) console.log("This Sync Server runs an older version of EtherPK that cannot give agent tokens, so this computer keeps the access token you gave.");
26096
+ console.log(`The access token you gave has been revoked. This computer now holds an Agent Token, limited to the routes an agent needs. If you used that access token anywhere else, create a new one at ${syncServer}/account/tokens.`);
25556
26097
  }
25557
26098
  if (previous) {
25558
26099
  const before = await loginCredentials(syncServer, previous, keychain()).catch(() => null);