@appsoftwareltd/etherpk-mcp 0.12.0 → 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.12.0",
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
  }
@@ -6226,7 +6231,7 @@ var SNIPPET_TOKENS = 24;
6226
6231
  * scores merely reintroduces the long-document bias bm25 exists to remove. Counting is cruder,
6227
6232
  * robust, and explainable in one sentence - and it orders by a number the row displays.
6228
6233
  */
6229
- function searchText$1(db, query, offset, limit, filters = []) {
6234
+ function searchText$2(db, query, offset, limit, filters = []) {
6230
6235
  const match = buildFtsMatch(query);
6231
6236
  if (match === null) return {
6232
6237
  groups: [],
@@ -15088,71 +15093,6 @@ function withPublishFolder(folders, graphKey, publicationId, folder) {
15088
15093
  } };
15089
15094
  }
15090
15095
  //#endregion
15091
- //#region ../client/src/lib/document/calendar/month-grid-core.ts
15092
- /** `YYYY-MM-DD` for a local date — the journal file-name format (fs/identity.ts). */
15093
- function formatISODate(date) {
15094
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
15095
- }
15096
- /** Today, as the ISO day the user is living in. The app's single definition of "today". */
15097
- function todayISO(now = /* @__PURE__ */ new Date()) {
15098
- return formatISODate(now);
15099
- }
15100
- var ISO_SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
15101
- /**
15102
- * The local `Date` a `YYYY-MM-DD` string names, or `null` when it names no day that exists.
15103
- *
15104
- * The shape test alone is not enough: `2026-02-30` and `2026-13-01` match it and are not days.
15105
- * Round-tripping through `Date` catches both, because JS silently rolls an out-of-range field
15106
- * forward (31 February becomes 3 March) and the re-format then disagrees with the input.
15107
- */
15108
- function parseISODate(value) {
15109
- const match = ISO_SHAPE.exec(value.trim());
15110
- if (!match) return null;
15111
- const [, y, m, d] = match;
15112
- const date = new Date(Number(y), Number(m) - 1, Number(d));
15113
- return formatISODate(date) === value.trim() ? date : null;
15114
- }
15115
- /** True when `value` names a calendar day that actually exists. */
15116
- function isCalendarDay(value) {
15117
- return parseISODate(value) !== null;
15118
- }
15119
- /** A new Date `days` after `date` (normalises across month/year boundaries). */
15120
- function shiftDate(date, days) {
15121
- return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
15122
- }
15123
- //#endregion
15124
- //#region ../client/src/lib/document/journal-concept.ts
15125
- /**
15126
- * What makes a [[Concept]] a [[Journal Concept]] — the one predicate that decides where a
15127
- * promoting [[Draft]] writes, and which concepts refuse to be renamed (ADR 0056).
15128
- *
15129
- * It is deliberately **not** the `\d{4}-\d{2}-\d{2}` shape it grew out of. While the shape only
15130
- * guarded rename, letting `2026-13-45` through cost nothing; now that the same answer decides
15131
- * whether a file lands in `journals/` or `pages/`, a loose test would mint a journal entry for a
15132
- * day no calendar can display or reach again.
15133
- *
15134
- * Pure; no DOM, no I/O.
15135
- */
15136
- /**
15137
- * True when `concept` names a calendar day that exists — a [[Journal Entry]]'s identity.
15138
- *
15139
- * Journal identity is the day itself, so this is case- and alias-free: there is nothing to
15140
- * normalise beyond surrounding whitespace.
15141
- */
15142
- function isJournalConcept(concept) {
15143
- return isCalendarDay(concept);
15144
- }
15145
- /**
15146
- * Why a [[Page]] cannot be called `day`: a day is the name of that day's [[Journal Entry]].
15147
- *
15148
- * A page given one sits in `pages/` answering to the day, which is what an older version left
15149
- * behind when a Draft for a date promoted to a page. Creating a page and renaming one both
15150
- * refuse with this, on both backends, so the copy is the same wherever the user meets it.
15151
- */
15152
- function dayIsNotAPageName(day) {
15153
- 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.`;
15154
- }
15155
- //#endregion
15156
15096
  //#region ../client/src/lib/document/protection/cipher-fence.ts
15157
15097
  /**
15158
15098
  * The `etherpk-cipher` fence as it appears in document *text* — finding one, reading what it holds
@@ -15235,6 +15175,157 @@ function lastNonBlank(lines) {
15235
15175
  return -1;
15236
15176
  }
15237
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
15238
15329
  //#region ../client/src/lib/document/types.ts
15239
15330
  var DocumentNotFoundError = class extends Error {
15240
15331
  target;
@@ -16975,8 +17066,34 @@ function taskLineWith(line, changes) {
16975
17066
  function sameTags(a, b) {
16976
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;
16977
17068
  }
16978
- /** UTF-16 units of document text returned before `truncated` is set. */
16979
- 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
+ */
16980
17097
  /** A refusal the agent can act on: a stable code first, a sentence second. */
16981
17098
  var ToolError = class extends Error {
16982
17099
  code;
@@ -16986,8 +17103,6 @@ var ToolError = class extends Error {
16986
17103
  this.name = "ToolError";
16987
17104
  }
16988
17105
  };
16989
- /** UTF-16 units across one `read_documents` call before the rest is cut. */
16990
- var READ_MANY_TEXT_CAP = 4e5;
16991
17106
  /** Keys the frontmatter tools never read or write; each has a tool of its own. */
16992
17107
  var OWNED_KEYS = /* @__PURE__ */ new Set([
16993
17108
  "title",
@@ -17077,7 +17192,7 @@ async function liveText(graph, identity) {
17077
17192
  return graph.store.open(identity.concept).getText();
17078
17193
  }
17079
17194
  function refuseIfProtected(concept, text) {
17080
- 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.`);
17081
17196
  }
17082
17197
  function page$1(items, offset, limit) {
17083
17198
  return {
@@ -17123,13 +17238,14 @@ async function readDocument(graph, concept) {
17123
17238
  const identity = requireIdentity(graph, concept);
17124
17239
  const text = await liveText(graph, identity);
17125
17240
  refuseIfProtected(identity.concept, text);
17241
+ const capped = capText(text, READ_TEXT_CAP);
17126
17242
  return {
17127
17243
  concept: identity.concept,
17128
17244
  kind: identity.kind,
17129
17245
  aliases: identity.aliases,
17130
- text: text.length > 2e5 ? text.slice(0, READ_TEXT_CAP) : text,
17246
+ text: capped.text,
17131
17247
  frontmatter: agentFrontmatter(graph.store.openRaw(identity.concept).getText()),
17132
- truncated: text.length > READ_TEXT_CAP
17248
+ truncated: capped.truncated
17133
17249
  };
17134
17250
  }
17135
17251
  async function search(graph, args) {
@@ -17185,40 +17301,9 @@ async function searchSemantic(graph, query, offset, limit) {
17185
17301
  function round(similarity) {
17186
17302
  return Math.round(similarity * 1e3) / 1e3;
17187
17303
  }
17188
- async function searchText(graph, query, offset, limit) {
17189
- const keys = await graph.index.propertyKeys();
17190
- const { words, filters } = parseSearchQuery(query, new Set(keys.map((info) => info.key.toLowerCase())));
17191
- if (filters.length > 0 && words.trim() === "") {
17192
- const documents = await graph.index.propertyMatch(filters);
17193
- return {
17194
- results: documents.slice(offset, offset + limit).map((doc) => ({
17195
- concept: doc.concept,
17196
- kind: doc.kind,
17197
- matches: 0,
17198
- hits: [],
17199
- properties: doc.properties
17200
- })),
17201
- total: documents.length,
17202
- totalCapped: false,
17203
- hasMore: offset + limit < documents.length
17204
- };
17205
- }
17206
- const [result, count] = await Promise.all([graph.index.searchText(words, offset, limit, filters), graph.index.searchTextCount(words, filters)]);
17207
- return {
17208
- results: result.groups.map((group) => ({
17209
- concept: group.concept,
17210
- kind: group.kind,
17211
- matches: group.matches,
17212
- hits: group.hits.map((hit) => ({
17213
- line: hit.line,
17214
- breadcrumb: hit.breadcrumb,
17215
- snippet: hit.snippet.map((segment) => segment.text).join("")
17216
- }))
17217
- })),
17218
- total: count.total,
17219
- totalCapped: count.capped,
17220
- hasMore: result.hasMore
17221
- };
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);
17222
17307
  }
17223
17308
  async function backlinks(graph, concept) {
17224
17309
  const target = concept.trim() === "today" ? todayISO() : concept.trim();
@@ -17226,16 +17311,7 @@ async function backlinks(graph, concept) {
17226
17311
  await graph.store.refresh();
17227
17312
  return {
17228
17313
  concept: target,
17229
- sources: withoutTemplateContent(await graph.index.backlinks(target)).map((group) => ({
17230
- concept: group.sourceConcept,
17231
- kind: group.sourceKind,
17232
- references: group.refs.map((ref) => ({
17233
- kind: ref.kind,
17234
- line: ref.line,
17235
- breadcrumb: ref.breadcrumb,
17236
- text: ref.kind === "block" ? ref.subtree.map((node) => node.text).join("\n") : ref.context?.text ?? ""
17237
- }))
17238
- }))
17314
+ sources: backlinkSources(await graph.index.backlinks(target))
17239
17315
  };
17240
17316
  }
17241
17317
  /**
@@ -17375,7 +17451,7 @@ async function templateBodyFor(graph, name) {
17375
17451
  raw = graph.store.openRaw(identity.concept).getText();
17376
17452
  }
17377
17453
  const { data, body } = parseFrontmatter(raw);
17378
- if (!identity || !isTemplate(data, identity.kind) || documentProtection(body).kind === "document") 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.`);
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.`);
17379
17455
  return templateBody(raw);
17380
17456
  }
17381
17457
  /**
@@ -17801,7 +17877,7 @@ async function readDocuments(graph, args) {
17801
17877
  continue;
17802
17878
  }
17803
17879
  const text = await liveText(graph, identity);
17804
- if (documentProtection(text).kind === "document") {
17880
+ if (isProtectedDocumentText(text)) {
17805
17881
  documents.push({
17806
17882
  concept: identity.concept,
17807
17883
  error: "protected_document",
@@ -17898,7 +17974,7 @@ function referenceSources(graph) {
17898
17974
  const identity = resolveIdentity(graph, document);
17899
17975
  if (!identity) return null;
17900
17976
  const text = await liveText(graph, identity);
17901
- return documentProtection(text).kind === "document" ? null : text;
17977
+ return isProtectedDocumentText(text) ? null : text;
17902
17978
  },
17903
17979
  allTasks: () => everyIndexedTask(graph.index, todayISO())
17904
17980
  };
@@ -20278,7 +20354,7 @@ function createIndexCore(host) {
20278
20354
  }];
20279
20355
  }
20280
20356
  case "search-text": {
20281
- 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 ?? []) : {
20282
20358
  groups: [],
20283
20359
  hasMore: false
20284
20360
  };
@@ -25409,11 +25485,12 @@ async function pollDeviceApproval(api, request) {
25409
25485
  * (ADR 0072). Two routes, both already served by the Sync Server for every new device:
25410
25486
  *
25411
25487
  * - **Device Approval** (ADR 0125), the default: this process posts a commitment to a one-time
25412
- * key; once an unlocked EtherPK tab answers, it prints the code both screens show. The user
25413
- * 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
25414
25491
  * only the user, comparing the two screens, can tell. The key that arrives is checked against
25415
25492
  * the vault before it is used. No secret passes through the terminal.
25416
- * - **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
25417
25494
  * key, which opens the vault; what is cached is the vault key, never the code.
25418
25495
  *
25419
25496
  * Pure over an injected clock and output so the flow is testable without a terminal.
@@ -25440,8 +25517,8 @@ var SIGNAL_EXIT_CODES = {
25440
25517
  * The keys the approval wait answers to. `y` confirms the codes match; `n` says they do not, which
25441
25518
  * cancels the request; `r` switches to the Recovery Code; Ctrl-C (a key while the terminal is in
25442
25519
  * raw mode) and SIGINT, SIGTERM or SIGHUP quit. Every way out aborts the wait, so it cancels its
25443
- * approval server-side before anything exits: a login left pending showed its stale code in every
25444
- * 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.
25445
25522
  */
25446
25523
  function approvalWaitControls() {
25447
25524
  const abort = new AbortController();
@@ -25509,8 +25586,8 @@ async function unlockByDeviceApproval(api, io) {
25509
25586
  const request = await beginDeviceApproval(api);
25510
25587
  const where = io.clientUrl ? `open EtherPK at ${io.clientUrl}` : "open EtherPK in a browser";
25511
25588
  io.say("");
25512
- io.say(`To approve this device, ${where} (any page - it need not be a note), connected to this account`);
25513
- 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.");
25514
25591
  io.say("");
25515
25592
  io.say("Waiting (up to ten minutes)…");
25516
25593
  const abandon = async () => {
@@ -25764,13 +25841,12 @@ var VERSION = package_default.version;
25764
25841
  var CMD = /[\\/]_npx[\\/]/.test(process.argv[1] ?? "") ? "npx @appsoftwareltd/etherpk-mcp" : "etherpk-mcp";
25765
25842
  var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synced graph)
25766
25843
 
25767
- ${CMD} login --sync-server <url> [--code <setup code>] [--recovery-code]
25768
- Sign this machine in as a device of your account. Make a setup code in EtherPK
25769
- (a synced graph's Settings > Agents, or <url>/account/tokens) and pass it with --code:
25770
- it works once, within 10 minutes, and gets this machine an agent token of its own,
25771
- which can do only what an agent needs. Then unlock your Encryption Keys by Device
25772
- Approval: open EtherPK in a browser connected to the account with its Encryption Keys
25773
- 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,
25774
25850
  or pass --recovery-code, to type your Recovery Code instead (or ETHERPK_RECOVERY_CODE,
25775
25851
  for a scripted setup).
25776
25852
  For a script, --pat <token> or ETHERPK_PAT takes an account-wide access token instead,
@@ -25972,11 +26048,11 @@ async function login(args) {
25972
26048
  const syncServer = normaliseSyncServer(args["sync-server"] ?? (known.length === 1 ? known[0] : await ask("Sync Server URL: ", { hint: "pass --sync-server <url>" })));
25973
26049
  if (!/^https?:\/\//.test(syncServer)) fail("The Sync Server must be an http(s) URL.");
25974
26050
  const previous = config.servers[syncServer];
25975
- 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): `, {
25976
26052
  secret: true,
25977
- hint: "pass --code <setup code>"
26053
+ hint: "pass --code <Agent Setup Code>"
25978
26054
  })).trim();
25979
- 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.`);
25980
26056
  const name = agentTokenName();
25981
26057
  let token;
25982
26058
  let redeemed = false;
@@ -25988,7 +26064,7 @@ async function login(args) {
25988
26064
  }).catch((error) => fail(describeLoginFailure(error, syncServer)));
25989
26065
  redeemed = true;
25990
26066
  } else if (accessTokenKind(given) !== null) token = given;
25991
- 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.`);
25992
26068
  const abandon = async (error) => {
25993
26069
  if (redeemed) await revokeHeldToken({
25994
26070
  syncServer,
@@ -26008,18 +26084,16 @@ async function login(args) {
26008
26084
  const vaultKey = await (args["recovery-code"] ? byRecoveryCode() : approveOrFallBack(account, byRecoveryCode)).catch(abandon);
26009
26085
  if (accessTokenKind(token) === "standard") {
26010
26086
  const given = token;
26011
- const agentToken = await exchangeForAgentToken({
26087
+ token = await exchangeForAgentToken({
26012
26088
  syncServer,
26013
26089
  token,
26014
26090
  name
26015
26091
  }).catch(async (error) => {
26016
- 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.`);
26017
- 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.`);
26018
26095
  });
26019
- if (agentToken) {
26020
- token = agentToken;
26021
- 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.`);
26022
- } 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.`);
26023
26097
  }
26024
26098
  if (previous) {
26025
26099
  const before = await loginCredentials(syncServer, previous, keychain()).catch(() => null);