@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/README.md +14 -13
- package/dist/main.js +243 -169
- package/dist/main.js.map +1 -1
- package/package.json +5 -5
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
2682
|
+
/** Trade a one-time Agent Setup Code for an agent token named `name` ("Agent on <hostname>"). */
|
|
2670
2683
|
async function redeemSetupCode(call) {
|
|
2671
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
3021
|
-
*
|
|
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.
|
|
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$
|
|
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
|
-
|
|
16979
|
-
|
|
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 (
|
|
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:
|
|
17246
|
+
text: capped.text,
|
|
17131
17247
|
frontmatter: agentFrontmatter(graph.store.openRaw(identity.concept).getText()),
|
|
17132
|
-
truncated:
|
|
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
|
-
|
|
17189
|
-
|
|
17190
|
-
|
|
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:
|
|
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) ||
|
|
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 (
|
|
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
|
|
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$
|
|
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
|
|
25413
|
-
*
|
|
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
|
|
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
|
|
25444
|
-
* unlocked
|
|
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)
|
|
25513
|
-
io.say("
|
|
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 <
|
|
25768
|
-
Sign this
|
|
25769
|
-
(a synced graph's Settings > Agents, or <url>/account/tokens) and pass it with --code:
|
|
25770
|
-
|
|
25771
|
-
|
|
25772
|
-
|
|
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(
|
|
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 <
|
|
26053
|
+
hint: "pass --code <Agent Setup Code>"
|
|
25978
26054
|
})).trim();
|
|
25979
|
-
if (!given) fail(
|
|
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(
|
|
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
|
-
|
|
26087
|
+
token = await exchangeForAgentToken({
|
|
26012
26088
|
syncServer,
|
|
26013
26089
|
token,
|
|
26014
26090
|
name
|
|
26015
26091
|
}).catch(async (error) => {
|
|
26016
|
-
|
|
26017
|
-
|
|
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
|
-
|
|
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);
|