@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/README.md +14 -13
- package/dist/main.js +876 -335
- 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
|
}
|
|
@@ -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 =
|
|
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 ===
|
|
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
|
-
|
|
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$
|
|
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.
|
|
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
|
-
*
|
|
11521
|
-
*
|
|
11522
|
-
*
|
|
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 (
|
|
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
|
-
|
|
13000
|
-
|
|
13001
|
-
|
|
13002
|
-
|
|
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
|
-
/**
|
|
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
|
-
"
|
|
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
|
|
13332
|
-
* a name taken from its bytes: a picture, a link (`https` only, as is any picture
|
|
13333
|
-
* it is left out, or undefined when it could not be made, after which an ordinary
|
|
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("")}.${
|
|
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:
|
|
13557
|
+
alt: out.alt,
|
|
13350
13558
|
width: Math.round(out.width),
|
|
13351
13559
|
height: Math.round(out.height)
|
|
13352
13560
|
};
|
|
13353
13561
|
}
|
|
13354
|
-
|
|
13355
|
-
|
|
13356
|
-
|
|
13357
|
-
|
|
13358
|
-
|
|
13359
|
-
|
|
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
|
-
|
|
15718
|
-
|
|
15719
|
-
|
|
15720
|
-
|
|
15721
|
-
|
|
15722
|
-
|
|
15723
|
-
|
|
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 <
|
|
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:
|
|
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
|
-
|
|
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
|
|
15782
|
-
|
|
15783
|
-
|
|
15784
|
-
|
|
15785
|
-
|
|
15786
|
-
|
|
15787
|
-
|
|
15788
|
-
|
|
15789
|
-
|
|
15790
|
-
|
|
15791
|
-
|
|
15792
|
-
|
|
15793
|
-
|
|
15794
|
-
|
|
15795
|
-
|
|
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
|
-
|
|
15801
|
-
return
|
|
15802
|
-
|
|
15803
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
15814
|
-
|
|
15815
|
-
|
|
15816
|
-
|
|
15817
|
-
|
|
15818
|
-
|
|
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
|
|
15825
|
-
|
|
15826
|
-
|
|
15827
|
-
|
|
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
|
-
|
|
16557
|
-
|
|
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 (
|
|
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:
|
|
17246
|
+
text: capped.text,
|
|
16709
17247
|
frontmatter: agentFrontmatter(graph.store.openRaw(identity.concept).getText()),
|
|
16710
|
-
truncated:
|
|
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
|
-
|
|
16767
|
-
|
|
16768
|
-
|
|
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))
|
|
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,
|
|
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 (
|
|
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
|
|
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$
|
|
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
|
|
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
|
|
24946
|
-
*
|
|
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
|
|
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
|
|
24977
|
-
* 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.
|
|
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)
|
|
25046
|
-
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.");
|
|
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 <
|
|
25301
|
-
Sign this
|
|
25302
|
-
(a synced graph's Settings > Agents, or <url>/account/tokens) and pass it with --code:
|
|
25303
|
-
|
|
25304
|
-
|
|
25305
|
-
|
|
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(
|
|
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 <
|
|
26053
|
+
hint: "pass --code <Agent Setup Code>"
|
|
25511
26054
|
})).trim();
|
|
25512
|
-
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.`);
|
|
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(
|
|
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
|
-
|
|
26087
|
+
token = await exchangeForAgentToken({
|
|
25545
26088
|
syncServer,
|
|
25546
26089
|
token,
|
|
25547
26090
|
name
|
|
25548
26091
|
}).catch(async (error) => {
|
|
25549
|
-
|
|
25550
|
-
|
|
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
|
-
|
|
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);
|