@appsoftwareltd/etherpk-mcp 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -20,7 +20,7 @@ import * as Y from "yjs";
20
20
  import { z } from "zod";
21
21
  import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness";
22
22
  import * as encoding from "lib0/encoding";
23
- import { parse, stringify } from "yaml";
23
+ import { Document, Scalar, YAMLMap, isMap, isScalar, isSeq, parse, parseDocument, visit } from "yaml";
24
24
  import "fake-indexeddb/auto";
25
25
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
26
  import { classHighlighter, highlightTree } from "@lezer/highlight";
@@ -34,7 +34,7 @@ import { request } from "node:https";
34
34
  import { BlockList, isIP } from "node:net";
35
35
  var package_default = {
36
36
  name: "@appsoftwareltd/etherpk-mcp",
37
- version: "0.8.2",
37
+ version: "0.8.4",
38
38
  license: "Elastic-2.0",
39
39
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
40
40
  type: "module",
@@ -613,11 +613,12 @@ function createGraphNamePublisher(deps) {
613
613
  //#endregion
614
614
  //#region ../client/src/lib/sync/sync-api.ts
615
615
  var SyncApiError = class extends Error {
616
- constructor(message, status, code, retryable = false) {
616
+ constructor(message, status, code, retryable = false, connectionKind) {
617
617
  super(message);
618
618
  this.status = status;
619
619
  this.code = code;
620
620
  this.retryable = retryable;
621
+ this.connectionKind = connectionKind;
621
622
  }
622
623
  };
623
624
  function createSyncApi(deps) {
@@ -637,7 +638,7 @@ function createSyncApi(deps) {
637
638
  const body = await res.json().catch(() => null);
638
639
  const nested = typeof body?.error === "object" && body.error !== null ? body.error : null;
639
640
  const code = nested?.code ?? body?.code;
640
- throw new SyncApiError(nested?.message ?? body?.code ?? `HTTP ${res.status}`, res.status, code, body?.retryable === true);
641
+ throw new SyncApiError(nested?.message ?? body?.code ?? `HTTP ${res.status}`, res.status, code, body?.retryable === true, deps.kind);
641
642
  }
642
643
  return await res.json();
643
644
  }
@@ -720,7 +721,7 @@ function createSyncApi(deps) {
720
721
  };
721
722
  }
722
723
  //#endregion
723
- //#region ../client/src/lib/sync/sync-config.ts
724
+ //#region ../client/src/lib/sync/sync-connections.ts
724
725
  /** Derive the `ws(s)://…/sync` relay URL from the server base URL. */
725
726
  function relayUrlFrom(serverBaseUrl) {
726
727
  return serverBaseUrl.replace(/\/$/, "").replace(/^http/, "ws") + "/sync";
@@ -2511,6 +2512,119 @@ function snippetSegments(snippet) {
2511
2512
  }
2512
2513
  return segments.filter((s) => s.text !== "");
2513
2514
  }
2515
+ /** Letters, digits, `_`, `.` and `-`: a key a person can type, dot paths included. */
2516
+ var PROPERTY_KEY_PATTERN = /^[\p{L}\p{N}_][\p{L}\p{N}_.-]*$/u;
2517
+ /**
2518
+ * Whitespace-separated tokens. A quote opens a run that spaces do not end, whether it starts the
2519
+ * token (`"a phrase"`) or follows a key (`status:"in progress"`); an unclosed one runs to the end.
2520
+ */
2521
+ function searchTokens(input) {
2522
+ const out = [];
2523
+ let index = 0;
2524
+ while (index < input.length) {
2525
+ if (/\s/.test(input[index])) {
2526
+ index++;
2527
+ continue;
2528
+ }
2529
+ const from = index;
2530
+ while (index < input.length && !/\s/.test(input[index])) {
2531
+ if (input[index] === "\"") {
2532
+ const close = input.indexOf("\"", index + 1);
2533
+ index = close === -1 ? input.length : close + 1;
2534
+ continue;
2535
+ }
2536
+ index++;
2537
+ }
2538
+ out.push({
2539
+ text: input.slice(from, index),
2540
+ from,
2541
+ to: index
2542
+ });
2543
+ }
2544
+ return out;
2545
+ }
2546
+ /**
2547
+ * The filters in `input`, and the words left for name and text matching.
2548
+ *
2549
+ * A term is a filter only when its key is one at least one searchable document carries
2550
+ * (`knownKeys`, lower-cased): anything else shaped like `x:y` stays words, which keeps a URL, a
2551
+ * time or `note:` in prose as text. A fully quoted term is always words. A known key with no
2552
+ * value yet (`status:` mid-typing) is dropped from both, so the key is never searched as a word
2553
+ * a moment before the value arrives.
2554
+ */
2555
+ function parseSearchQuery(input, knownKeys) {
2556
+ const filters = [];
2557
+ const terms = [];
2558
+ const cut = [];
2559
+ if (knownKeys.size > 0) for (const token of searchTokens(input)) {
2560
+ if (token.text.startsWith("\"")) continue;
2561
+ const colon = token.text.indexOf(":");
2562
+ if (colon <= 0) continue;
2563
+ const negated = token.text.startsWith("-");
2564
+ const key = token.text.slice(negated ? 1 : 0, colon);
2565
+ if (!PROPERTY_KEY_PATTERN.test(key) || !knownKeys.has(key.toLowerCase())) continue;
2566
+ const filter = filterValue(key, token.text.slice(colon + 1), negated);
2567
+ cut.push({
2568
+ from: token.from,
2569
+ to: token.to
2570
+ });
2571
+ if (filter === null) continue;
2572
+ filters.push(filter);
2573
+ terms.push({
2574
+ filter,
2575
+ from: token.from,
2576
+ to: token.to
2577
+ });
2578
+ }
2579
+ return {
2580
+ words: withoutRanges(input, cut),
2581
+ filters,
2582
+ terms
2583
+ };
2584
+ }
2585
+ /** The filter for a value as typed, or null when there is no value yet. */
2586
+ function filterValue(key, raw, negated) {
2587
+ if (raw === "*") return {
2588
+ key,
2589
+ value: null,
2590
+ prefix: false,
2591
+ negated
2592
+ };
2593
+ if (raw.startsWith("\"")) {
2594
+ const close = raw.indexOf("\"", 1);
2595
+ const value = (close === -1 ? raw.slice(1) : raw.slice(1, close)).trim();
2596
+ return value === "" ? null : {
2597
+ key,
2598
+ value,
2599
+ prefix: false,
2600
+ negated
2601
+ };
2602
+ }
2603
+ if (raw.length > 1 && raw.endsWith("*")) return {
2604
+ key,
2605
+ value: raw.slice(0, -1),
2606
+ prefix: true,
2607
+ negated
2608
+ };
2609
+ return raw === "" ? null : {
2610
+ key,
2611
+ value: raw,
2612
+ prefix: false,
2613
+ negated
2614
+ };
2615
+ }
2616
+ /** `input` with the ranges removed and the gaps they leave closed to single spaces. */
2617
+ function withoutRanges(input, ranges) {
2618
+ if (ranges.length === 0) return input;
2619
+ const parts = [];
2620
+ let at = 0;
2621
+ for (const range of ranges) {
2622
+ parts.push(input.slice(at, range.from));
2623
+ at = range.to;
2624
+ }
2625
+ parts.push(input.slice(at));
2626
+ return parts.map((part) => part.trim()).filter((part) => part !== "").join(" ");
2627
+ }
2514
2628
  //#endregion
2515
2629
  //#region ../client/src/lib/document/index-db.ts
2516
2630
  /**
@@ -2572,6 +2686,15 @@ CREATE TABLE IF NOT EXISTS passages (
2572
2686
  page_id INTEGER NOT NULL, ord INTEGER NOT NULL, start_line INTEGER NOT NULL,
2573
2687
  end_line INTEGER NOT NULL, first_block_local_id INTEGER NOT NULL,
2574
2688
  content_hash TEXT NOT NULL, text TEXT NOT NULL);
2689
+ -- [[Property]] rows for [[Property Filter]]s (ADR 0107): one per value, keys as dot paths. key and
2690
+ -- value keep their spelling for display; key_lc and value_lc are lower-cased in JS, because
2691
+ -- SQLite's lower() folds ASCII only and a filter matches ignoring case in any script. value is
2692
+ -- NULL on a mapping's presence row. title and aliases rows come from the graph's names. A
2693
+ -- protected document has none.
2694
+ CREATE TABLE IF NOT EXISTS properties (
2695
+ page_id INTEGER NOT NULL, key TEXT NOT NULL, key_lc TEXT NOT NULL,
2696
+ value TEXT, value_lc TEXT);
2697
+ CREATE INDEX IF NOT EXISTS properties_key ON properties(key_lc, value_lc);
2575
2698
  CREATE INDEX IF NOT EXISTS passages_hash ON passages(content_hash);
2576
2699
  CREATE INDEX IF NOT EXISTS links_concept_key ON links(concept_key);
2577
2700
  CREATE INDEX IF NOT EXISTS blocks_page ON blocks(page_id);
@@ -2620,7 +2743,7 @@ function createSchema(db) {
2620
2743
  db.exec(SCHEMA$1);
2621
2744
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
2622
2745
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
2623
- db.exec(`PRAGMA user_version = 13`);
2746
+ db.exec(`PRAGMA user_version = 14`);
2624
2747
  }
2625
2748
  function activeIndexGeneration(db) {
2626
2749
  return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
@@ -2641,7 +2764,7 @@ function advanceIndexRevision(db) {
2641
2764
  */
2642
2765
  function isUsableIndex(db) {
2643
2766
  try {
2644
- return db.all("PRAGMA user_version")[0]?.user_version === 13;
2767
+ return db.all("PRAGMA user_version")[0]?.user_version === 14;
2645
2768
  } catch {
2646
2769
  return false;
2647
2770
  }
@@ -2661,8 +2784,11 @@ function documentHash(db, key) {
2661
2784
  */
2662
2785
  function indexDocHash(doc) {
2663
2786
  const includes = doc.includes ?? [];
2664
- if (includes.length === 0) return hashText(doc.text);
2665
- return hashText(`${doc.text}\u0000${includes.map((i) => `${i.publication}\u0001${i.slot}\u0001${i.concept}`).join("\0")}`);
2787
+ const properties = doc.properties ?? [];
2788
+ if (includes.length === 0 && properties.length === 0) return hashText(doc.text);
2789
+ const includeText = includes.map((i) => `${i.publication}\u0001${i.slot}\u0001${i.concept}`).join("\0");
2790
+ const propertyText = properties.map((p) => `${p.key}\u0001${p.value ?? ""}`).join("\0");
2791
+ return hashText(`${doc.text}\u0000${includeText}\u0003${propertyText}`);
2666
2792
  }
2667
2793
  function hashText(text) {
2668
2794
  let h1 = 3735928559;
@@ -2703,6 +2829,7 @@ function insertDerived(db, pageId, doc) {
2703
2829
  conceptKey$1(alias),
2704
2830
  alias
2705
2831
  ]);
2832
+ insertProperties(db, pageId, doc);
2706
2833
  for (const include of doc.includes ?? []) db.run("INSERT INTO publication_includes (page_id, publication_id, slot, concept_key, concept) VALUES (?,?,?,?,?)", [
2707
2834
  pageId,
2708
2835
  include.publication,
@@ -2779,12 +2906,40 @@ function insertDerived(db, pageId, doc) {
2779
2906
  return searchable;
2780
2907
  }
2781
2908
  /**
2909
+ * Write one document's [[Property]] rows (ADR 0107): its name as `title`, each alias as
2910
+ * `aliases`, then what its Frontmatter carries. A [[Protected Document]] writes none. Its
2911
+ * Frontmatter is stored in plaintext, but no kind of matching in Search reaches protected
2912
+ * content, and a document with no rows cannot match a filter, not even a negated one, because
2913
+ * every filtered query also requires `protected = 0`.
2914
+ */
2915
+ function insertProperties(db, pageId, doc) {
2916
+ if (protectedFlag(doc) === 1) return;
2917
+ const rows = [
2918
+ {
2919
+ key: "title",
2920
+ value: doc.concept
2921
+ },
2922
+ ...doc.aliases.map((alias) => ({
2923
+ key: "aliases",
2924
+ value: alias
2925
+ })),
2926
+ ...doc.properties ?? []
2927
+ ];
2928
+ for (const row of rows) db.run("INSERT INTO properties (page_id, key, key_lc, value, value_lc) VALUES (?,?,?,?,?)", [
2929
+ pageId,
2930
+ row.key,
2931
+ row.key.toLowerCase(),
2932
+ row.value,
2933
+ row.value === null ? null : row.value.toLowerCase()
2934
+ ]);
2935
+ }
2936
+ /**
2782
2937
  * Write one document's [[Task Concept]] rows (ADR 0051).
2783
2938
  *
2784
2939
  * Derivation supplies the wikilinked half; the document's own concept is added HERE, as the
2785
2940
  * virtual root of every task's ancestry chain, because derivation reads text and never learns
2786
2941
  * the document's identity. Without that row, filtering to a project page returns nothing for
2787
- * the tasks written directly on it — the case the "Use <active tab>" shortcut exists for.
2942
+ * the tasks written directly on it — the case the "Filter to <active tab>" shortcut exists for.
2788
2943
  *
2789
2944
  * Only the document's own concept goes in, never its aliases: the query resolves a filtered
2790
2945
  * name to its page and matches on every name that page answers to, so an alias row here would
@@ -2879,7 +3034,8 @@ function deleteGeneration(db, generation) {
2879
3034
  "links",
2880
3035
  "tasks",
2881
3036
  "task_concepts",
2882
- "passages"
3037
+ "passages",
3038
+ "properties"
2883
3039
  ]) db.run(`DELETE FROM ${table} WHERE page_id IN (SELECT id FROM pages WHERE generation = ?)`, [generation]);
2884
3040
  db.run("DELETE FROM pages WHERE generation = ?", [generation]);
2885
3041
  }
@@ -2954,6 +3110,7 @@ function ingestOne(db, doc) {
2954
3110
  db.run("DELETE FROM tasks WHERE page_id = ?", [pageId]);
2955
3111
  db.run("DELETE FROM task_concepts WHERE page_id = ?", [pageId]);
2956
3112
  db.run("DELETE FROM passages WHERE page_id = ?", [pageId]);
3113
+ db.run("DELETE FROM properties WHERE page_id = ?", [pageId]);
2957
3114
  db.run("UPDATE pages SET concept = ?, kind = ?, text_hash = ?, protected = ? WHERE id = ?", [
2958
3115
  doc.concept,
2959
3116
  doc.kind,
@@ -3366,25 +3523,27 @@ var SNIPPET_TOKENS = 24;
3366
3523
  * scores merely reintroduces the long-document bias bm25 exists to remove. Counting is cruder,
3367
3524
  * robust, and explainable in one sentence - and it orders by a number the row displays.
3368
3525
  */
3369
- function searchText$1(db, query, offset, limit) {
3526
+ function searchText$1(db, query, offset, limit, filters = []) {
3370
3527
  const match = buildFtsMatch(query);
3371
3528
  if (match === null) return {
3372
3529
  groups: [],
3373
3530
  hasMore: false
3374
3531
  };
3375
3532
  const generation = activeIndexGeneration(db);
3533
+ const narrow = propertyFilterSql(filters);
3376
3534
  const ranked = db.all(`WITH hits AS MATERIALIZED (
3377
3535
  SELECT rowid / ${BLOCK_FTS_STRIDE} AS page_id, bm25(block_fts) AS score
3378
3536
  FROM block_fts WHERE block_fts MATCH ?
3379
3537
  )
3380
3538
  SELECT h.page_id AS page_id, COUNT(*) AS matches, MIN(h.score) AS best
3381
3539
  FROM hits h
3382
- JOIN pages p ON p.id = h.page_id AND p.generation = ?
3540
+ JOIN pages p ON p.id = h.page_id AND p.generation = ?${narrow.sql}
3383
3541
  GROUP BY h.page_id
3384
3542
  ORDER BY matches DESC, best ASC, h.page_id ASC
3385
3543
  LIMIT ? OFFSET ?`, [
3386
3544
  match,
3387
3545
  generation,
3546
+ ...narrow.params,
3388
3547
  limit + 1,
3389
3548
  offset
3390
3549
  ]);
@@ -3462,21 +3621,23 @@ function searchBreadcrumb(blocks, localId) {
3462
3621
  * Capped rather than exact because a prefix query - which is every query, mid-typing - can
3463
3622
  * match most of the graph, and this runs beside the rows it must never delay.
3464
3623
  */
3465
- function searchTextCount(db, query) {
3624
+ function searchTextCount(db, query, filters = []) {
3466
3625
  const match = buildFtsMatch(query);
3467
3626
  if (match === null) return {
3468
3627
  total: 0,
3469
3628
  capped: false
3470
3629
  };
3630
+ const narrow = propertyFilterSql(filters);
3471
3631
  const n = db.all(`SELECT COUNT(*) AS n FROM (
3472
3632
  SELECT m.page_id FROM (
3473
3633
  SELECT DISTINCT rowid / 1048576 AS page_id FROM block_fts
3474
3634
  WHERE block_fts MATCH ?
3475
3635
  ) m
3476
- JOIN pages p ON p.id = m.page_id AND p.generation = ?
3636
+ JOIN pages p ON p.id = m.page_id AND p.generation = ?${narrow.sql}
3477
3637
  LIMIT ?)`, [
3478
3638
  match,
3479
3639
  activeIndexGeneration(db),
3640
+ ...narrow.params,
3480
3641
  1001
3481
3642
  ])[0]?.n ?? 0;
3482
3643
  return n > 1e3 ? {
@@ -3487,6 +3648,106 @@ function searchTextCount(db, query) {
3487
3648
  capped: false
3488
3649
  };
3489
3650
  }
3651
+ /** Documents a filtered name listing returns at most: well past any graph Search serves. */
3652
+ var PROPERTY_MATCH_CAP = 5e3;
3653
+ /**
3654
+ * The SQL that narrows a query over `pages AS p` to the documents passing every filter, as an
3655
+ * `AND …` suffix, with its parameters in order. Empty when there are no filters. A filtered
3656
+ * query never returns a [[Protected Document]], which has no property rows; the explicit
3657
+ * `protected = 0` is what keeps a NEGATED filter from matching one.
3658
+ */
3659
+ function propertyFilterSql(filters) {
3660
+ if (filters.length === 0) return {
3661
+ sql: "",
3662
+ params: []
3663
+ };
3664
+ const clauses = ["p.protected = 0"];
3665
+ const params = [];
3666
+ for (const filter of filters) {
3667
+ let test = "x.key_lc = ?";
3668
+ params.push(filter.key.toLowerCase());
3669
+ if (filter.value !== null) {
3670
+ const value = filter.value.toLowerCase();
3671
+ if (filter.prefix) {
3672
+ test += " AND substr(x.value_lc, 1, ?) = ?";
3673
+ params.push(Array.from(value).length, value);
3674
+ } else {
3675
+ test += " AND x.value_lc = ?";
3676
+ params.push(value);
3677
+ }
3678
+ }
3679
+ clauses.push(`${filter.negated ? "NOT " : ""}EXISTS (SELECT 1 FROM properties x WHERE x.page_id = p.id AND ${test})`);
3680
+ }
3681
+ return {
3682
+ sql: ` AND ${clauses.join(" AND ")}`,
3683
+ params
3684
+ };
3685
+ }
3686
+ /**
3687
+ * Every key a searchable document carries, once per spelling, most used first: what decides
3688
+ * whether a typed `x:y` is a filter, and what the key suggestions offer.
3689
+ */
3690
+ function propertyKeys(db) {
3691
+ return db.all(`SELECT pr.key AS key, COUNT(DISTINCT pr.page_id) AS documents
3692
+ FROM properties pr JOIN pages p ON p.id = pr.page_id
3693
+ WHERE p.generation = ?
3694
+ GROUP BY pr.key
3695
+ ORDER BY documents DESC, pr.key_lc, pr.key`, [activeIndexGeneration(db)]);
3696
+ }
3697
+ /** The values `key` has across the graph, grouped ignoring case, most used first. */
3698
+ function propertyValues(db, key, limit = 50) {
3699
+ return db.all(`SELECT MIN(pr.value) AS value, COUNT(DISTINCT pr.page_id) AS documents
3700
+ FROM properties pr JOIN pages p ON p.id = pr.page_id
3701
+ WHERE p.generation = ? AND pr.key_lc = ? AND pr.value IS NOT NULL
3702
+ GROUP BY pr.value_lc
3703
+ ORDER BY documents DESC, pr.value_lc
3704
+ LIMIT ?`, [
3705
+ activeIndexGeneration(db),
3706
+ key.toLowerCase(),
3707
+ limit
3708
+ ]);
3709
+ }
3710
+ /**
3711
+ * Every document that passes all `filters`, in name order, with the values of the keys the
3712
+ * positive filters name (not `title`, which the row already shows). Empty without a filter:
3713
+ * listing the whole graph is [[All Documents]]' job.
3714
+ */
3715
+ function documentsMatchingProperties(db, filters, limit = PROPERTY_MATCH_CAP) {
3716
+ if (filters.length === 0) return [];
3717
+ const narrow = propertyFilterSql(filters);
3718
+ const pages = db.all(`SELECT p.id AS id, p.concept AS concept, p.kind AS kind FROM pages p
3719
+ WHERE p.generation = ?${narrow.sql}
3720
+ ORDER BY p.concept_key
3721
+ LIMIT ?`, [
3722
+ activeIndexGeneration(db),
3723
+ ...narrow.params,
3724
+ limit
3725
+ ]);
3726
+ if (pages.length === 0) return [];
3727
+ const shown = [...new Set(filters.filter((f) => !f.negated).map((f) => f.key.toLowerCase()))].filter((key) => key !== "title");
3728
+ const values = /* @__PURE__ */ new Map();
3729
+ if (shown.length > 0) {
3730
+ const rows = db.all(`SELECT page_id, key, value FROM properties
3731
+ WHERE page_id IN (SELECT value FROM json_each(?))
3732
+ AND key_lc IN (SELECT value FROM json_each(?))
3733
+ AND value IS NOT NULL
3734
+ ORDER BY rowid`, [JSON.stringify(pages.map((page) => page.id)), JSON.stringify(shown)]);
3735
+ for (const row of rows) {
3736
+ const list = values.get(row.page_id);
3737
+ const entry = {
3738
+ key: row.key,
3739
+ value: row.value
3740
+ };
3741
+ if (list) list.push(entry);
3742
+ else values.set(row.page_id, [entry]);
3743
+ }
3744
+ }
3745
+ return pages.map((page) => ({
3746
+ concept: page.concept,
3747
+ kind: page.kind,
3748
+ properties: values.get(page.id) ?? []
3749
+ }));
3750
+ }
3490
3751
  /** Escape a literal for a LIKE pattern, so an asset name can never behave as a wildcard. */
3491
3752
  function likeLiteral(needle) {
3492
3753
  return needle.replace(/[\\%_]/g, (c) => `\\${c}`);
@@ -4010,7 +4271,7 @@ function cacheRoot(env) {
4010
4271
  * recovered from a joined path (see there).
4011
4272
  */
4012
4273
  var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
4013
- var INDEX_FILE_NAME = `index.v13.sqlite`;
4274
+ var INDEX_FILE_NAME = `index.v14.sqlite`;
4014
4275
  var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
4015
4276
  function cacheFile(dir) {
4016
4277
  return join(dir, CACHE_FILE_NAME);
@@ -4039,7 +4300,7 @@ async function openCacheDb() {
4039
4300
  const db = await request$1(indexedDB.open(CACHE_DB, CACHE_DB_VERSION));
4040
4301
  for (const store of CACHE_STORES) if (!db.objectStoreNames.contains(store)) {
4041
4302
  db.close();
4042
- throw new Error(`Local Cache database has no "${store}" store; open the graph cache before restoring it.`);
4303
+ throw new Error(`Local Cache database has no "${store}" store - open the graph cache before restoring it.`);
4043
4304
  }
4044
4305
  return db;
4045
4306
  }
@@ -4327,7 +4588,7 @@ function describeGraphStore(store, now = Date.now()) {
4327
4588
  if (total === 0) return `empty - no passages indexed here yet (snapshot ${age})`;
4328
4589
  if (embedded >= total) return `up to date - ${count} embedded (snapshot ${age})`;
4329
4590
  const percent = Math.floor(embedded / total * 100);
4330
- return ageMs < 9e4 ? `building - ${count} (${percent}%) embedded, snapshot ${age}, refreshed every 30 s while it builds` : `paused - ${count} (${percent}%) embedded, last snapshot ${age}; nothing is building it now, it continues when serve next runs`;
4591
+ return ageMs < 9e4 ? `building - ${count} (${percent}%) embedded, snapshot ${age}, refreshed every 30 s while it builds` : `paused - ${count} (${percent}%) embedded, last snapshot ${age}. Nothing is building it now, it continues when serve next runs`;
4331
4592
  }
4332
4593
  /** Remove every persisted graph under the cache root (logout of the last server). */
4333
4594
  async function removeCacheRoot(env) {
@@ -4524,7 +4785,7 @@ async function setupSemantic(io) {
4524
4785
  const files = io.files ?? MODEL.files;
4525
4786
  const status = await semanticSetupStatus(io.env, files);
4526
4787
  if (!status.runtime) {
4527
- io.say(`Installing ${RUNTIME.name} ${RUNTIME.version} into ${status.runtimeDir} (about 300 MB; native, so it is not part of the etherpk-mcp package)…`);
4788
+ io.say(`Installing ${RUNTIME.name} ${RUNTIME.version} into ${status.runtimeDir} (about 300 MB - native, so it is not part of the etherpk-mcp package)…`);
4528
4789
  await mkdir(status.runtimeDir, {
4529
4790
  recursive: true,
4530
4791
  mode: 448
@@ -4597,7 +4858,7 @@ async function download(fetchFn, file, path) {
4597
4858
  const digest = hash.digest("hex");
4598
4859
  if (digest !== file.sha256) {
4599
4860
  await rm(temp, { force: true });
4600
- throw new Error(`${file.name} downloaded with sha256 ${digest}, expected ${file.sha256}; not kept.`);
4861
+ throw new Error(`${file.name} downloaded with sha256 ${digest}, expected ${file.sha256} - not kept.`);
4601
4862
  }
4602
4863
  await rename(temp, path);
4603
4864
  }
@@ -4619,7 +4880,7 @@ var SemanticUnavailable = class extends Error {
4619
4880
  this.name = "SemanticUnavailable";
4620
4881
  }
4621
4882
  };
4622
- var SETUP_HINT = "run `npx @appsoftwareltd/etherpk-mcp semantic setup` on this computer once (it installs a ~300 MB runtime and a 23 MB model into the cache directory); the next semantic search will use it, no restart needed.";
4883
+ var SETUP_HINT = "run `npx @appsoftwareltd/etherpk-mcp semantic setup` on this computer once (it installs a ~300 MB runtime and a 23 MB model into the cache directory) - the next semantic search will use it, no restart needed.";
4623
4884
  /** The thread count `serve` uses: the option, else the environment, else the conservative default. */
4624
4885
  function semanticThreads(env, requested) {
4625
4886
  if (requested !== void 0) return Math.max(1, Math.floor(requested));
@@ -4640,7 +4901,7 @@ async function loadEmbeddingModel(env, options = {}) {
4640
4901
  const loaded = await import(pathToFileURL(require.resolve(RUNTIME.name)).href);
4641
4902
  ort = loaded.default?.InferenceSession ? loaded.default : loaded;
4642
4903
  } catch (error) {
4643
- throw new SemanticUnavailable(`The embedding runtime in ${status.runtimeDir} failed to load (${error instanceof Error ? error.message : String(error)}); ${SETUP_HINT}`);
4904
+ throw new SemanticUnavailable(`The embedding runtime in ${status.runtimeDir} failed to load (${error instanceof Error ? error.message : String(error)}): ${SETUP_HINT}`);
4644
4905
  }
4645
4906
  const tokenizer = new Tokenizer(JSON.parse(await readFile(join(status.modelDir, "tokenizer.json"), "utf8")), JSON.parse(await readFile(join(status.modelDir, "tokenizer_config.json"), "utf8")));
4646
4907
  const threads = semanticThreads(env, options.threads);
@@ -6396,6 +6657,172 @@ function parseFrontmatter(text) {
6396
6657
  }
6397
6658
  }
6398
6659
  //#endregion
6660
+ //#region ../client/src/lib/document/frontmatter/frontmatter-yaml.ts
6661
+ /**
6662
+ * Reading, editing and writing a [[Frontmatter]] block's YAML in the one style EtherPK writes
6663
+ * (ADR 0108).
6664
+ *
6665
+ * Every writer goes through here: the identity write-back, publishing, the arbitrary-key patch,
6666
+ * the importers, Add frontmatter and the editor's tidy when an editing episode ends. Edits go
6667
+ * through the `yaml` library's document model rather than a plain object, so a comment, and any
6668
+ * key the edit does not touch, survives. The block is then written in the EtherPK style:
6669
+ *
6670
+ * - two-space indentation, a list under its key as ` - item`, one item per line;
6671
+ * - an inline list (`[a, b]`) written as a block list; an empty `[]` written bare on a key EtherPK
6672
+ * reads (`publications:`), where empty means not set, and kept on any other key;
6673
+ * - quotes only where a value needs them (`"true"` stays quoted, `"hello"` does not);
6674
+ * - no line wrapping, no blank lines, key order as found, comments kept;
6675
+ * - an empty value written bare (`slug:`), which reads as "fill this in".
6676
+ *
6677
+ * Restyling never changes what the block says: the value is compared before and after, and a
6678
+ * rewrite that would change it is dropped. A block that does not parse is never rewritten.
6679
+ *
6680
+ * Pure: no store, no editor. What counts as a block comes from `frontmatter-span.ts`.
6681
+ */
6682
+ /** The `yaml` serialiser options that make up the EtherPK style. */
6683
+ var STYLE = {
6684
+ indent: 2,
6685
+ indentSeq: true,
6686
+ lineWidth: 0,
6687
+ minContentWidth: 0,
6688
+ nullStr: ""
6689
+ };
6690
+ /**
6691
+ * The block's YAML parsed as a document, or null when it does not parse or is not a mapping.
6692
+ * An empty block (or one holding only comments) is an empty mapping.
6693
+ */
6694
+ function parseBlock(body) {
6695
+ const doc = parseDocument(body);
6696
+ if (doc.errors.length > 0) return null;
6697
+ if (doc.contents === null) {
6698
+ doc.contents = new YAMLMap();
6699
+ return doc;
6700
+ }
6701
+ return isMap(doc.contents) ? doc : null;
6702
+ }
6703
+ /** The block's YAML as a plain object, or null when it does not parse to a mapping. */
6704
+ function frontmatterData(body) {
6705
+ const doc = parseBlock(body);
6706
+ return doc === null ? null : doc.toJS();
6707
+ }
6708
+ /**
6709
+ * The keys EtherPK reads, on which an empty value means the key is not set (ADR 0108, point 5).
6710
+ * On these an empty `[]` and a bare key say the same thing, so the style writes it bare. Another
6711
+ * key's `[]` is kept: to a tool that reads it, an empty list and no value may differ.
6712
+ */
6713
+ var NOT_SET_WHEN_EMPTY = new Set([
6714
+ "title",
6715
+ "aliases",
6716
+ "public",
6717
+ "publications",
6718
+ "slug",
6719
+ "date",
6720
+ "publication"
6721
+ ]);
6722
+ /** Apply the EtherPK style to a document in place. */
6723
+ function applyStyle(doc) {
6724
+ visit(doc, {
6725
+ Pair(_, pair, path) {
6726
+ if (path.length !== 2) return;
6727
+ const key = isScalar(pair.key) ? pair.key.value : pair.key;
6728
+ if (typeof key === "string" && NOT_SET_WHEN_EMPTY.has(key) && isSeq(pair.value) && pair.value.items.length === 0) pair.value = new Scalar(null);
6729
+ },
6730
+ Scalar(_, node) {
6731
+ node.spaceBefore = false;
6732
+ if (typeof node.value === "string" && !node.value.includes("\n") && (node.type === "QUOTE_DOUBLE" || node.type === "QUOTE_SINGLE")) node.type = "PLAIN";
6733
+ },
6734
+ Map(_, node) {
6735
+ node.spaceBefore = false;
6736
+ if (node.items.length > 0) node.flow = false;
6737
+ },
6738
+ Seq(_, node) {
6739
+ node.spaceBefore = false;
6740
+ if (node.items.length > 0) node.flow = false;
6741
+ }
6742
+ });
6743
+ }
6744
+ /** The styled YAML of a document, ending in a newline, or '' when it has no keys and no comment. */
6745
+ function styledYaml(doc) {
6746
+ applyStyle(doc);
6747
+ const map = doc.contents;
6748
+ if (isMap(map) && map.items.length === 0) return doc.commentBefore ? `${doc.commentBefore.replace(/^/gm, "#")}\n` : "";
6749
+ return doc.toString(STYLE);
6750
+ }
6751
+ /** Deep equality over the plain values a block parses to. */
6752
+ function sameValue(a, b) {
6753
+ return JSON.stringify(a) === JSON.stringify(b);
6754
+ }
6755
+ /**
6756
+ * A new block holding `data`, in the EtherPK style: `---\n…---\n`, or '' when `data` has no keys.
6757
+ * For a document that has no block yet; to change a block that exists use {@link editFrontmatter},
6758
+ * which keeps what the block already says.
6759
+ */
6760
+ function renderFrontmatter(data) {
6761
+ if (Object.keys(data).length === 0) return "";
6762
+ return `---\n${styledYaml(new Document(data))}---\n`;
6763
+ }
6764
+ /** The line terminator a block uses, so a rewrite does not mix endings in a CRLF file. */
6765
+ function eolOf(text, end) {
6766
+ return text.slice(0, end).includes("\r\n") ? "\r\n" : "\n";
6767
+ }
6768
+ function withEol(yaml, eol) {
6769
+ return eol === "\n" ? yaml : yaml.replace(/\n/g, eol);
6770
+ }
6771
+ function editorFor(doc) {
6772
+ const map = doc.contents;
6773
+ const keyOf = (item) => String(isScalar(item.key) ? item.key.value : item.key);
6774
+ return {
6775
+ has: (key) => map.has(key),
6776
+ get: (key) => map.has(key) ? doc.toJS()[key] : void 0,
6777
+ set(key, value, where = "last") {
6778
+ if (map.has(key)) {
6779
+ const current = doc.toJS()[key];
6780
+ if (sameValue(current, value)) return;
6781
+ map.set(key, doc.createNode(value));
6782
+ return;
6783
+ }
6784
+ const pair = doc.createPair(key, value);
6785
+ if (where === "first") map.items.unshift(pair);
6786
+ else map.items.push(pair);
6787
+ },
6788
+ delete: (key) => {
6789
+ map.delete(key);
6790
+ },
6791
+ keys: () => map.items.map(keyOf)
6792
+ };
6793
+ }
6794
+ /**
6795
+ * The text with its block changed by `edit`, then written in the EtherPK style. The same string
6796
+ * comes back when the edit changes nothing the block says, so a writer that runs on every save
6797
+ * never restyles a block it agrees with. A block whose YAML does not parse is left alone: rewriting
6798
+ * it would destroy whatever the person was typing. A block the edit empties of every key is
6799
+ * removed.
6800
+ */
6801
+ function editFrontmatter(text, edit, options = {}) {
6802
+ const span = frontmatterSpan(text);
6803
+ if (!span && !options.addBlock) return text;
6804
+ const doc = span ? parseBlock(span.body) : parseBlock("");
6805
+ if (doc === null) return text;
6806
+ const before = doc.toJS();
6807
+ edit(editorFor(doc));
6808
+ if (sameValue(doc.toJS(), before)) return text;
6809
+ const body = span ? text.slice(span.end) : text;
6810
+ if (doc.contents.items.length === 0) return body;
6811
+ const eol = span ? eolOf(text, span.end) : "\n";
6812
+ return `---${eol}${withEol(styledYaml(doc), eol)}---${eol}${body}`;
6813
+ }
6814
+ /**
6815
+ * Whether a value counts as not set (ADR 0108): nothing, a blank string, an empty list, or a
6816
+ * mapping whose every value is itself empty. `false` and `0` are values.
6817
+ */
6818
+ function isEmptyValue(value) {
6819
+ if (value === null || value === void 0) return true;
6820
+ if (typeof value === "string") return value.trim() === "";
6821
+ if (Array.isArray(value)) return value.length === 0;
6822
+ if (typeof value === "object" && !(value instanceof Date)) return Object.values(value).every(isEmptyValue);
6823
+ return false;
6824
+ }
6825
+ //#endregion
6399
6826
  //#region ../client/src/lib/document/publish/publication.ts
6400
6827
  /** A publication id: kebab-case, what `publications:` entries and `theme:` graph ids look like. */
6401
6828
  var PUBLICATION_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -6411,13 +6838,19 @@ function readMembership(text, concept) {
6411
6838
  const { data } = parseFrontmatter(text);
6412
6839
  const issues = [];
6413
6840
  const isPublic = data.public === true;
6841
+ if (!isEmptyValue(data.public) && typeof data.public !== "boolean") issues.push({
6842
+ level: "warning",
6843
+ code: "public-not-a-boolean",
6844
+ message: `\`public\` is the text "${String(data.public)}", not \`true\` or \`false\`, so the document is not public. Write \`public: true\` without quotes to publish it.`,
6845
+ concept
6846
+ });
6414
6847
  const publications = [];
6415
- const raw = data.publications;
6416
- const entries = raw === void 0 ? [] : Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : null;
6848
+ const raw = isEmptyValue(data.publications) ? void 0 : data.publications;
6849
+ const entries = raw === void 0 ? [] : Array.isArray(raw) ? raw : null;
6417
6850
  if (entries === null) issues.push({
6418
6851
  level: "warning",
6419
6852
  code: "publications-not-a-list",
6420
- message: "`publications` must be a list of publication ids, like `publications: [docs, blog]`.",
6853
+ message: "`publications` must be a list of publication ids, each on its own line under the key as `- docs`, even when there is one.",
6421
6854
  concept
6422
6855
  });
6423
6856
  else for (const entry of entries) if (isPublicationId(entry)) {
@@ -6434,13 +6867,33 @@ function readMembership(text, concept) {
6434
6867
  issues
6435
6868
  };
6436
6869
  }
6870
+ var DATE = /^\d{4}-\d{2}-\d{2}$/;
6871
+ /**
6872
+ * The day a page is dated on a blog, from its `date:` key, and a warning when the key holds
6873
+ * something that is not a calendar day. A journal entry is dated by its name, never by this key.
6874
+ */
6875
+ function readPageDate(doc) {
6876
+ const date = parseFrontmatter(doc.text).data.date;
6877
+ if (isEmptyValue(date)) return { issues: [] };
6878
+ const text = date instanceof Date ? date.toISOString().slice(0, 10) : String(date);
6879
+ if (DATE.test(text)) return {
6880
+ date: text,
6881
+ issues: []
6882
+ };
6883
+ return { issues: [{
6884
+ level: "warning",
6885
+ code: "invalid-date",
6886
+ message: `"${doc.concept}" has \`date: ${String(date)}\`, which is not a calendar day (YYYY-MM-DD) - the document is undated.`,
6887
+ concept: doc.concept
6888
+ }] };
6889
+ }
6437
6890
  var KINDS$1 = ["docs", "blog"];
6438
6891
  var SELECTIONS = ["named", "all-public"];
6439
6892
  /** The publication a page defines through its `publication:` mapping, validated. */
6440
6893
  function readPublicationDefinition(doc) {
6441
6894
  const { data, body } = parseFrontmatter(doc.text);
6442
6895
  const raw = data.publication;
6443
- if (raw === void 0) return {
6896
+ if (isEmptyValue(raw)) return {
6444
6897
  publication: null,
6445
6898
  issues: []
6446
6899
  };
@@ -6459,13 +6912,13 @@ function readPublicationDefinition(doc) {
6459
6912
  };
6460
6913
  }
6461
6914
  if (doc.kind === "journal") {
6462
- issue("publication-on-journal", "A journal entry cannot define a publication; use a page.");
6915
+ issue("publication-on-journal", "A journal entry cannot define a publication - use a page.");
6463
6916
  return {
6464
6917
  publication: null,
6465
6918
  issues
6466
6919
  };
6467
6920
  }
6468
- const def = raw;
6921
+ const def = Object.fromEntries(Object.entries(raw).filter(([, value]) => !isEmptyValue(value)));
6469
6922
  const id = def.id;
6470
6923
  if (id === void 0 || id === null || id === "") issue("publication-missing-id", "`publication` needs an `id`: lower-case letters, digits and hyphens, like `docs`.");
6471
6924
  else if (!isPublicationId(id)) issue("publication-invalid-id", `"${String(id)}" is not a publication id: use lower-case letters, digits and hyphens, like \`docs\`.`);
@@ -6486,13 +6939,16 @@ function readPublicationDefinition(doc) {
6486
6939
  if (def.recent !== void 0) if (typeof def.recent === "number" && Number.isInteger(def.recent) && def.recent > 0) recent = def.recent;
6487
6940
  else issue("publication-invalid-recent", "`recent` must be a whole number above zero: how many posts the front page lists (10 unless set).");
6488
6941
  const includes = {};
6489
- if (def.includes !== void 0) if (typeof def.includes === "object" && def.includes !== null && !Array.isArray(def.includes)) for (const [slot, value] of Object.entries(def.includes)) if (typeof value === "string" && value.trim() !== "") includes[slot] = value.trim();
6490
- else issues.push({
6491
- level: "warning",
6492
- code: "publication-invalid-include",
6493
- message: `Include \`${slot}\` must name the page that fills it; it is ignored.`,
6494
- concept: doc.concept
6495
- });
6942
+ if (def.includes !== void 0) if (typeof def.includes === "object" && def.includes !== null && !Array.isArray(def.includes)) for (const [slot, value] of Object.entries(def.includes)) {
6943
+ if (isEmptyValue(value)) continue;
6944
+ if (typeof value === "string" && value.trim() !== "") includes[slot] = value.trim();
6945
+ else issues.push({
6946
+ level: "warning",
6947
+ code: "publication-invalid-include",
6948
+ message: `Include \`${slot}\` must name the page that fills it - it is ignored.`,
6949
+ concept: doc.concept
6950
+ });
6951
+ }
6496
6952
  else issue("publication-invalid-includes", "`includes` must be a mapping of include slot to the page that fills it.");
6497
6953
  if (issues.some((i) => i.level === "error")) return {
6498
6954
  publication: null,
@@ -6551,7 +7007,7 @@ function discoverPublications(documents) {
6551
7007
  for (const publication of list) issues.push({
6552
7008
  level: "error",
6553
7009
  code: "publication-duplicate-id",
6554
- message: `Two pages define the publication "${id}" (${list.map((p) => `"${p.concept}"`).join(" and ")}); neither is published until one is changed.`,
7010
+ message: `Two pages define the publication "${id}" (${list.map((p) => `"${p.concept}"`).join(" and ")}) - neither is published until one is changed.`,
6555
7011
  concept: publication.concept
6556
7012
  });
6557
7013
  }
@@ -6588,7 +7044,7 @@ function validateThemeManifest(raw) {
6588
7044
  const version = typeof data.version === "string" && data.version.trim() !== "" ? data.version.trim() : "0.0.0";
6589
7045
  const contract = typeof data.contract === "number" && Number.isInteger(data.contract) ? data.contract : null;
6590
7046
  if (contract === null) errors.push("theme.json needs an integer `contract`, the view version the theme was written for.");
6591
- else if (contract > 1) errors.push(`The theme was written for view contract ${contract}; this EtherPK writes contract 1. Update EtherPK, or use an older theme.`);
7047
+ else if (contract > 1) errors.push(`The theme was written for view contract ${contract} - this EtherPK writes contract 1. Update EtherPK, or use an older theme.`);
6592
7048
  const kinds = [];
6593
7049
  if (Array.isArray(data.kinds)) {
6594
7050
  for (const k of data.kinds) if (KINDS.has(k)) kinds.push(k);
@@ -8557,7 +9013,7 @@ function createIndexCore(host) {
8557
9013
  }];
8558
9014
  }
8559
9015
  case "search-text": {
8560
- const page = db ? searchText$1(db, request.query, request.offset, request.limit) : {
9016
+ const page = db ? searchText$1(db, request.query, request.offset, request.limit, request.filters ?? []) : {
8561
9017
  groups: [],
8562
9018
  hasMore: false
8563
9019
  };
@@ -8569,7 +9025,7 @@ function createIndexCore(host) {
8569
9025
  }];
8570
9026
  }
8571
9027
  case "search-count": {
8572
- const count = db ? searchTextCount(db, request.query) : {
9028
+ const count = db ? searchTextCount(db, request.query, request.filters ?? []) : {
8573
9029
  total: 0,
8574
9030
  capped: false
8575
9031
  };
@@ -8580,6 +9036,21 @@ function createIndexCore(host) {
8580
9036
  capped: count.capped
8581
9037
  }];
8582
9038
  }
9039
+ case "property-keys": return [{
9040
+ type: "property-keys",
9041
+ id: request.id,
9042
+ keys: db ? propertyKeys(db) : []
9043
+ }];
9044
+ case "property-values": return [{
9045
+ type: "property-values",
9046
+ id: request.id,
9047
+ values: db ? propertyValues(db, request.key) : []
9048
+ }];
9049
+ case "property-match": return [{
9050
+ type: "property-match",
9051
+ id: request.id,
9052
+ documents: db ? documentsMatchingProperties(db, request.filters) : []
9053
+ }];
8583
9054
  case "tasks": {
8584
9055
  const page = db ? tasksMatching(db, request.query, request.offset, request.limit) : {
8585
9056
  hits: [],
@@ -9297,30 +9768,52 @@ function createRemoteGraphIndex(source, transport, options) {
9297
9768
  needles: [...needles]
9298
9769
  }))).usage;
9299
9770
  },
9300
- async searchText(query, offset, limit) {
9771
+ async searchText(query, offset, limit, filters) {
9301
9772
  const response = await request((id) => ({
9302
9773
  type: "search-text",
9303
9774
  id,
9304
9775
  query,
9305
9776
  offset,
9306
- limit
9777
+ limit,
9778
+ ...filters?.length ? { filters: [...filters] } : {}
9307
9779
  }));
9308
9780
  return {
9309
9781
  groups: response.groups,
9310
9782
  hasMore: response.hasMore
9311
9783
  };
9312
9784
  },
9313
- async searchTextCount(query) {
9785
+ async searchTextCount(query, filters) {
9314
9786
  const response = await request((id) => ({
9315
9787
  type: "search-count",
9316
9788
  id,
9317
- query
9789
+ query,
9790
+ ...filters?.length ? { filters: [...filters] } : {}
9318
9791
  }));
9319
9792
  return {
9320
9793
  total: response.total,
9321
9794
  capped: response.capped
9322
9795
  };
9323
9796
  },
9797
+ async propertyKeys() {
9798
+ return (await request((id) => ({
9799
+ type: "property-keys",
9800
+ id
9801
+ }))).keys;
9802
+ },
9803
+ async propertyValues(key) {
9804
+ return (await request((id) => ({
9805
+ type: "property-values",
9806
+ id,
9807
+ key
9808
+ }))).values;
9809
+ },
9810
+ async propertyMatch(filters) {
9811
+ return (await request((id) => ({
9812
+ type: "property-match",
9813
+ id,
9814
+ filters: [...filters]
9815
+ }))).documents;
9816
+ },
9324
9817
  async tasks(query, offset, limit) {
9325
9818
  const response = await request((id) => ({
9326
9819
  type: "tasks",
@@ -10067,7 +10560,7 @@ async function deleteGraphTheme(adapter, id) {
10067
10560
  /** What the block claims. An unterminated block is not a block, as everywhere else. */
10068
10561
  function frontmatterIdentity(text) {
10069
10562
  const span = frontmatterSpan(text);
10070
- const data = span ? parseBlock$2(span.body) : {};
10563
+ const data = span ? frontmatterData(span.body) : {};
10071
10564
  if (data === null) return {
10072
10565
  title: null,
10073
10566
  aliases: [],
@@ -10119,37 +10612,19 @@ function sameAliases(a, b) {
10119
10612
  */
10120
10613
  function withFrontmatterIdentity(text, patch, options = {}) {
10121
10614
  const span = frontmatterSpan(text);
10122
- if (!span) {
10123
- if (!options.addBlock) return text;
10124
- const data = {};
10125
- if (typeof patch.title === "string" && patch.title.trim() !== "") data.title = patch.title;
10126
- const aliases = patch.aliases ? [...patch.aliases] : [];
10127
- if (aliases.length > 0) data.aliases = aliases;
10128
- if (Object.keys(data).length === 0) return text;
10129
- return `---\n${stringify(data)}---\n${text}`;
10130
- }
10131
- const data = parseBlock$2(span.body);
10132
- if (data === null) return text;
10133
- const currentTitle = titleOf(data);
10134
- const currentAliases = aliasesOf({ data });
10135
- const wantTitle = patch.title === void 0 ? currentTitle : patch.title;
10136
- const wantAliases = patch.aliases === void 0 ? currentAliases : [...patch.aliases];
10137
- if (wantTitle === currentTitle && sameAliases(wantAliases, currentAliases)) return text;
10138
- const next = {};
10139
- if (wantTitle !== null && !("title" in data)) next.title = wantTitle;
10140
- for (const [key, value] of Object.entries(data)) {
10141
- if (key === "title") {
10142
- if (wantTitle !== null) next.title = wantTitle;
10143
- continue;
10144
- }
10145
- if (key === "aliases") {
10146
- if (wantAliases.length > 0) next.aliases = wantAliases;
10147
- continue;
10148
- }
10149
- next[key] = value;
10615
+ if (span) {
10616
+ const data = frontmatterData(span.body);
10617
+ if (data === null) return text;
10618
+ const wantTitle = patch.title === void 0 ? titleOf(data) : patch.title;
10619
+ const wantAliases = patch.aliases === void 0 ? aliasesOf({ data }) : patch.aliases;
10620
+ if (wantTitle === titleOf(data) && sameAliases(wantAliases, aliasesOf({ data }))) return text;
10150
10621
  }
10151
- if (wantAliases.length > 0 && !("aliases" in data)) next.aliases = wantAliases;
10152
- return `---\n${serialise(next)}---\n${text.slice(span.end)}`;
10622
+ return editFrontmatter(text, (block) => {
10623
+ if (patch.title !== void 0) if (patch.title === null || patch.title.trim() === "") block.delete("title");
10624
+ else block.set("title", patch.title, "first");
10625
+ if (patch.aliases !== void 0) if (patch.aliases.length > 0) block.set("aliases", [...patch.aliases]);
10626
+ else block.delete("aliases");
10627
+ }, options);
10153
10628
  }
10154
10629
  /**
10155
10630
  * `after` - a writer's rewrite of `before` - with the document's aliases carried into the block
@@ -10164,19 +10639,83 @@ function withAliasesInAddedBlock(before, after, aliases) {
10164
10639
  if (aliases.length === 0 || frontmatterSpan(before) !== null || frontmatterSpan(after) === null) return after;
10165
10640
  return withFrontmatterIdentity(after, { aliases });
10166
10641
  }
10167
- /** The block's YAML as a plain object, or null when it is malformed or not an object. */
10168
- function parseBlock$2(yaml) {
10169
- try {
10170
- const parsed = parse(yaml);
10171
- if (parsed === null || parsed === void 0) return {};
10172
- if (typeof parsed !== "object" || Array.isArray(parsed)) return null;
10173
- return parsed;
10174
- } catch {
10175
- return null;
10642
+ //#endregion
10643
+ //#region ../client/src/lib/document/properties.ts
10644
+ /**
10645
+ * Keys a Property Filter answers from the graph's names rather than the block: the registry on
10646
+ * a Server Backend holds them, and a page created in the app there has no block to read.
10647
+ */
10648
+ var NAME_PROPERTY_KEYS = new Set(["title", "aliases"]);
10649
+ /** Rows per document at most. Frontmatter is metadata; a block past this is not a label set. */
10650
+ var MAX_ROWS = 256;
10651
+ /** Characters of one value kept. Longer text is prose, which Search's text group covers. */
10652
+ var MAX_VALUE_LENGTH = 1e3;
10653
+ /**
10654
+ * Whether a value counts as not set (ADR 0108): null, blank text, a list with no set item, or a
10655
+ * mapping with no set value. `false` and `0` are values.
10656
+ */
10657
+ function isEmptyPropertyValue(value) {
10658
+ if (value === null || value === void 0) return true;
10659
+ if (typeof value === "string") return value.trim() === "";
10660
+ if (Array.isArray(value)) return value.every(isEmptyPropertyValue);
10661
+ if (isMapping(value)) return Object.values(value).every(isEmptyPropertyValue);
10662
+ return false;
10663
+ }
10664
+ /**
10665
+ * The rows for one document's parsed Frontmatter, in the block's order. Empty values are left
10666
+ * out, and so are `title` and `aliases` in any spelling: those filters read the graph's names.
10667
+ */
10668
+ function propertiesOf(data) {
10669
+ const out = [];
10670
+ for (const [key, value] of Object.entries(data)) {
10671
+ if (NAME_PROPERTY_KEYS.has(key.toLowerCase())) continue;
10672
+ collect(key, value, out);
10673
+ if (out.length >= MAX_ROWS) break;
10674
+ }
10675
+ return out.slice(0, MAX_ROWS);
10676
+ }
10677
+ /** Append the rows for `value` under `path`; a mapping adds its presence row before its values. */
10678
+ function collect(path, value, out) {
10679
+ if (isEmptyPropertyValue(value)) return;
10680
+ if (Array.isArray(value)) {
10681
+ let presence = false;
10682
+ for (const item of value) if (isMapping(item)) {
10683
+ if (!presence && !isEmptyPropertyValue(item)) {
10684
+ out.push({
10685
+ key: path,
10686
+ value: null
10687
+ });
10688
+ presence = true;
10689
+ }
10690
+ for (const [key, child] of Object.entries(item)) collect(`${path}.${key}`, child, out);
10691
+ } else collect(path, item, out);
10692
+ return;
10693
+ }
10694
+ if (isMapping(value)) {
10695
+ out.push({
10696
+ key: path,
10697
+ value: null
10698
+ });
10699
+ for (const [key, child] of Object.entries(value)) collect(`${path}.${key}`, child, out);
10700
+ return;
10176
10701
  }
10702
+ out.push({
10703
+ key: path,
10704
+ value: textOf(value).slice(0, MAX_VALUE_LENGTH)
10705
+ });
10177
10706
  }
10178
- function serialise(data) {
10179
- return Object.keys(data).length === 0 ? "" : stringify(data);
10707
+ function isMapping(value) {
10708
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
10709
+ }
10710
+ /** A scalar as the text a person would type to find it. */
10711
+ function textOf(value) {
10712
+ if (value instanceof Date) {
10713
+ const iso = value.toISOString();
10714
+ return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
10715
+ }
10716
+ if (typeof value === "string") return value;
10717
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
10718
+ return JSON.stringify(value) ?? "";
10180
10719
  }
10181
10720
  //#endregion
10182
10721
  //#region ../client/src/lib/document/calendar/month-grid-core.ts
@@ -10507,7 +11046,7 @@ var RenameUnconfirmedError = class extends Error {
10507
11046
  name = "RenameUnconfirmedError";
10508
11047
  constructor(concepts) {
10509
11048
  const list = concepts.slice(0, 5).join(", ") + (concepts.length > 5 ? "…" : "");
10510
- super(`${concepts.length === 1 ? "A document" : `${concepts.length} documents`} the rename would change ${concepts.length === 1 ? "has" : "have"} not finished syncing to this device: ${list}. Nothing was renamed; try again once sync has caught up.`);
11049
+ super(`${concepts.length === 1 ? "A document" : `${concepts.length} documents`} the rename would change ${concepts.length === 1 ? "has" : "have"} not finished syncing to this device: ${list}. Nothing was renamed - try again once sync has caught up.`);
10511
11050
  this.concepts = concepts;
10512
11051
  }
10513
11052
  };
@@ -10757,23 +11296,6 @@ async function scanGraph(adapter, options = {}) {
10757
11296
  //#endregion
10758
11297
  //#region ../client/src/lib/storage/fs/filesystem-store.ts
10759
11298
  /**
10760
- * A {@link DocumentStore} over a real directory (via a {@link DirectoryAdapter}):
10761
- * the Filesystem Backend. The files *are* the state — this holds only a live
10762
- * buffer per open document plus a derived registry, and reconciles external
10763
- * changes (git checkout, Syncthing) the way DESIGN.md → The git workflow requires.
10764
- *
10765
- * Assembled entirely from the pure pieces (scan, identity, reconcileDecision,
10766
- * debounce) so it is exercised in Node over createMemoryDirectoryAdapter before
10767
- * any browser code exists.
10768
- *
10769
- * Async-seam note: the seam's
10770
- * `getText()` is synchronous but disk reads are async, so `open()` returns a
10771
- * handle whose buffer is empty on first open and is hydrated by an internal
10772
- * awaited read that then notifies subscribers the *external* way — which is safe
10773
- * because DocumentView registers its `subscribe` listener in the same synchronous
10774
- * onMount tick as its `getText()` seed, before the read resolves.
10775
- */
10776
- /**
10777
11299
  * The snapshot for one document's full text: identity from the registry, aliases and include
10778
11300
  * facts from the frontmatter, the body for the index. Both backends build theirs this way, so
10779
11301
  * the index sees one shape whatever holds the document.
@@ -10786,12 +11308,14 @@ function indexSnapshotOf(identity, text) {
10786
11308
  text,
10787
11309
  aliases: []
10788
11310
  }) : [];
11311
+ const properties = propertiesOf(fm.data);
10789
11312
  return {
10790
11313
  concept: identity.concept,
10791
11314
  kind: identity.kind,
10792
11315
  aliases: aliasesOf(fm),
10793
11316
  text: fm.body,
10794
- ...includes.length > 0 ? { includes } : {}
11317
+ ...includes.length > 0 ? { includes } : {},
11318
+ ...properties.length > 0 ? { properties } : {}
10795
11319
  };
10796
11320
  }
10797
11321
  function applyTextChange(text, change) {
@@ -10964,7 +11488,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10964
11488
  const fm = parseFrontmatter(text);
10965
11489
  let aliases = aliasesOf(fm);
10966
11490
  let body = fm.body;
10967
- let data = { ...fm.data };
11491
+ let blockSource = text;
10968
11492
  if (strategy === "alias") {
10969
11493
  if (!aliases.some((a) => conceptKey(a) === conceptKey(step.from))) aliases = [...aliases, step.from];
10970
11494
  }
@@ -10983,15 +11507,17 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10983
11507
  });
10984
11508
  body = merged.body;
10985
11509
  aliases = merged.aliases;
10986
- data = { ...existingFm.data };
11510
+ blockSource = existing.text;
10987
11511
  }
10988
11512
  aliases = normaliseAliases(aliases, step.into);
10989
- data.title = step.into;
10990
- if (aliases.length > 0) data.aliases = aliases;
10991
- else delete data.aliases;
11513
+ const blockSpan = frontmatterSpan(blockSource);
11514
+ const content = withFrontmatterIdentity((blockSpan ? blockSource.slice(0, blockSpan.end) : "") + body, {
11515
+ title: step.into,
11516
+ aliases
11517
+ }, { addBlock: true });
10992
11518
  const subdir = survivor?.subdir ?? entry.subdir;
10993
11519
  const fileName = survivor?.fileName ?? await allocateFileName(subdir, step.into, entry);
10994
- const written = await adapter.write(subdir, fileName, `---\n${stringify(data)}---\n${body}`);
11520
+ const written = await adapter.write(subdir, fileName, content);
10995
11521
  if (!(subdir === entry.subdir && fileName === entry.fileName)) await adapter.remove(entry.subdir, entry.fileName);
10996
11522
  registry.delete(entry.key);
10997
11523
  if (survivor) registry.delete(survivor.key);
@@ -11354,7 +11880,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11354
11880
  if (registry.get(key)) throw new Error(`A document for "${concept}" already exists.`);
11355
11881
  await adapter.ensureSkeleton();
11356
11882
  const fileName = await allocateFileName("pages", concept);
11357
- const content = `---\n${stringify({ title: concept })}---\n${body}`;
11883
+ const content = renderFrontmatter({ title: concept }) + body;
11358
11884
  await adapter.write("pages", fileName, content);
11359
11885
  await refreshRegistry();
11360
11886
  return concept;
@@ -11847,12 +12373,14 @@ function createServerDocumentStore(graph, options) {
11847
12373
  text,
11848
12374
  aliases: []
11849
12375
  }) : [];
12376
+ const properties = propertiesOf(parseFrontmatter(text).data);
11850
12377
  return {
11851
12378
  concept,
11852
12379
  kind: entry.kind,
11853
12380
  aliases: entry.aliases ?? [],
11854
12381
  text: bodyForIndex(text),
11855
- ...includes.length > 0 ? { includes } : {}
12382
+ ...includes.length > 0 ? { includes } : {},
12383
+ ...properties.length > 0 ? { properties } : {}
11856
12384
  };
11857
12385
  }
11858
12386
  /**
@@ -13005,8 +13533,9 @@ function assembleHeadlessGraph(parts) {
13005
13533
  }
13006
13534
  /** Open a synced graph, scan its registry and build the index; resolves once tools can answer. */
13007
13535
  async function openHeadlessGraph(deps) {
13008
- const cache = await openGraphCache(deps.graphId);
13009
- if (deps.persistDir) await loadLocalCache(deps.persistDir, deps.graphId);
13536
+ const cacheName = deps.localCacheName ?? deps.graphId;
13537
+ const cache = await openGraphCache(cacheName);
13538
+ if (deps.persistDir) await loadLocalCache(deps.persistDir, cacheName);
13010
13539
  const indexHost = deps.persistDir ? nodeIndexHost(deps.persistDir) : void 0;
13011
13540
  const sync = createGraphSync({
13012
13541
  graphId: deps.graphId,
@@ -13135,7 +13664,7 @@ async function openHeadlessGraph(deps) {
13135
13664
  embeddingModel: deps.embeddingModel,
13136
13665
  onSemanticProgress: deps.onSemanticProgress,
13137
13666
  onError: deps.onError,
13138
- persistBackend: deps.persistDir ? () => persistLocalCache(deps.persistDir, deps.graphId) : void 0,
13667
+ persistBackend: deps.persistDir ? () => persistLocalCache(deps.persistDir, cacheName) : void 0,
13139
13668
  onChange: (schedule) => sync.onDocUpdate(schedule),
13140
13669
  async settle(schedulePersist) {
13141
13670
  await sync.flushAll();
@@ -13262,7 +13791,7 @@ async function openHeadlessFolder(deps) {
13262
13791
  for (const conflict of raised) {
13263
13792
  await store.resolveConflict(conflict.target, "take-disk");
13264
13793
  saveErrors.delete(conceptKey(conflict.target));
13265
- warn(`"${conflict.target}" changed on disk while an edit to it could not be written; took the file on disk and discarded the edit.`);
13794
+ warn(`"${conflict.target}" changed on disk while an edit to it could not be written - took the file on disk and discarded the edit.`);
13266
13795
  }
13267
13796
  })();
13268
13797
  const pass = reconciling;
@@ -13441,7 +13970,7 @@ async function openHeadlessFolder(deps) {
13441
13970
  /** The account has no vault yet, so there is nothing for a Recovery Code to open. */
13442
13971
  var NoVaultError = class extends Error {
13443
13972
  constructor() {
13444
- super("This account has no encryption keys yet; there is nothing for a Recovery Code to open.");
13973
+ super("This account has no encryption keys yet - there is nothing for a Recovery Code to open.");
13445
13974
  this.name = "NoVaultError";
13446
13975
  }
13447
13976
  };
@@ -13457,7 +13986,7 @@ async function openVaultWithRecoveryCode(api, code) {
13457
13986
  try {
13458
13987
  return (await openVault(fromBase64Url(stored.vault), wrapKey)).vaultKey;
13459
13988
  } catch (error) {
13460
- if (error instanceof EnvelopeError) throw new RecoveryCodeError("That Recovery Code does not open this account’s keys. Check it character by character; only the most recently issued code works.");
13989
+ if (error instanceof EnvelopeError) throw new RecoveryCodeError("That Recovery Code does not open this account’s keys. Check it character by character - only the most recently issued code works.");
13461
13990
  throw error;
13462
13991
  }
13463
13992
  }
@@ -13586,7 +14115,7 @@ async function unlockByDeviceApproval(api, io) {
13586
14115
  const where = io.clientUrl ? `open EtherPK at ${io.clientUrl}` : "open EtherPK in a browser";
13587
14116
  io.say("");
13588
14117
  io.say(`To approve this device, ${where} (any page - it need not be a note), connected to this account`);
13589
- io.say("with its keys unlocked. A prompt will show a code; confirm it matches this one:");
14118
+ io.say("with its keys unlocked. A prompt will show a code - confirm it matches this one:");
13590
14119
  io.say("");
13591
14120
  io.say(` ${request.sas}`);
13592
14121
  io.say("");
@@ -13809,7 +14338,7 @@ var files = /* @__PURE__ */ Object.assign({
13809
14338
  "../etherpk-al-folio/assets/theme.js": "/*\n * The light / dark toggle: a `.theme-toggle` button flips the page between the two and the\n * choice is remembered in this browser; with no choice made the page follows the system. The\n * stylesheet keys on `data-theme` on <html>, and shell-top applies the remembered choice before\n * the first paint so a dark reader never sees a light flash.\n */\n(function () {\n 'use strict';\n var KEY = 'etherpk-theme';\n var root = document.documentElement;\n function isDark() {\n var chosen = root.getAttribute('data-theme');\n if (chosen === 'dark' || chosen === 'light') return chosen === 'dark';\n return window.matchMedia('(prefers-color-scheme: dark)').matches;\n }\n function apply(theme) {\n if (theme === 'dark' || theme === 'light') root.setAttribute('data-theme', theme);\n else root.removeAttribute('data-theme');\n var buttons = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < buttons.length; i++) buttons[i].setAttribute('aria-pressed', isDark() ? 'true' : 'false');\n }\n try { apply(localStorage.getItem(KEY)); } catch (_e) { apply(null); }\n var toggles = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < toggles.length; i++) {\n toggles[i].addEventListener('click', function () {\n var next = isDark() ? 'light' : 'dark';\n apply(next);\n try { localStorage.setItem(KEY, next); } catch (_e) { /* private mode: the choice lasts the page */ }\n });\n }\n})();\n",
13810
14339
  "../etherpk-al-folio/layouts/404.html": "{{> shell-top}}\n <div class=\"post\">\n <header class=\"post-header\"><h1 class=\"post-title\">Page not found</h1></header>\n <div class=\"post-content\">\n <p>The page you followed a link to is not part of this site. <a href=\"index.html\">Back to {{site.title}}</a>.</p>\n </div>\n </div>\n{{> shell-bottom}}\n",
13811
14340
  "../etherpk-al-folio/layouts/archive.html": "{{> shell-top}}\n <div class=\"post\">\n <header class=\"post-header\"><h1 class=\"post-title\">{{#page.isPostsArchive}}posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}journal{{/page.isJournalArchive}}</h1></header>\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <h2 class=\"archive-year\" id=\"y{{year}}\">{{year}}</h2>\n <ul class=\"post-list archive-list\">\n {{#posts}}<li><time datetime=\"{{date}}\">{{date}}</time> <a href=\"{{url}}\">{{title}}</a></li>{{/posts}}\n </ul>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n <ul class=\"post-list\">\n {{#site.journals}}\n <li>\n <h3><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n <p class=\"post-meta\"><time datetime=\"{{date}}\">{{date}}</time></p>\n </li>\n {{/site.journals}}\n </ul>\n {{/page.isJournalArchive}}\n </div>\n{{> shell-bottom}}\n",
13812
- "../etherpk-al-folio/layouts/home.html": "{{> shell-top}}\n <article class=\"post about\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{^page.isGeneratedIndex}}{{{page.titleHtml}}}{{/page.isGeneratedIndex}}{{#page.isGeneratedIndex}}{{site.title}}{{/page.isGeneratedIndex}}</h1>\n </header>\n {{^page.isGeneratedIndex}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}{{^site.isBlog}}\n <div class=\"post-content page-contents\">\n{{{page.content}}}\n </div>\n {{/site.isBlog}}{{/page.isGeneratedIndex}}\n {{#site.hasJournals}}\n <section class=\"news\">\n <h2><a href=\"journal.html\">news</a></h2>\n <table class=\"news-table\">\n {{#site.journals}}\n <tr>\n <th scope=\"row\"><time datetime=\"{{date}}\">{{date}}</time></th>\n <td><a href=\"{{url}}\">{{title}}</a>{{#excerpt}} <span class=\"news-excerpt\">{{excerpt}}</span>{{/excerpt}}</td>\n </tr>\n {{/site.journals}}\n </table>\n </section>\n {{/site.hasJournals}}\n {{#site.isBlog}}\n <section class=\"posts\">\n <h2>posts</h2>\n {{#site.hasPosts}}\n <ul class=\"post-list\">\n {{#site.recentPosts}}\n <li>\n <h3><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n <p class=\"post-meta\"><time datetime=\"{{date}}\">{{date}}</time></p>\n </li>\n {{/site.recentPosts}}\n </ul>\n <p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">all {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}\n {{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}\n </section>\n {{/site.isBlog}}\n </article>\n{{> shell-bottom}}\n",
14341
+ "../etherpk-al-folio/layouts/home.html": "{{> shell-top}}\n <article class=\"post about\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{^page.isGeneratedIndex}}{{{page.titleHtml}}}{{/page.isGeneratedIndex}}{{#page.isGeneratedIndex}}{{site.title}}{{/page.isGeneratedIndex}}</h1>\n </header>\n {{^page.isGeneratedIndex}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}{{^site.isBlog}}\n <div class=\"post-content page-contents\">\n{{{page.content}}}\n </div>\n {{/site.isBlog}}{{/page.isGeneratedIndex}}\n {{#site.hasJournals}}\n <section class=\"news\">\n <h2><a href=\"journal.html\">news</a></h2>\n <table class=\"news-table\">\n {{#site.journals}}\n <tr>\n <th scope=\"row\"><time datetime=\"{{date}}\">{{date}}</time></th>\n <td><a href=\"{{url}}\">{{title}}</a>{{#excerpt}} <span class=\"news-excerpt\">{{excerpt}}</span>{{/excerpt}}</td>\n </tr>\n {{/site.journals}}\n </table>\n </section>\n {{/site.hasJournals}}\n {{#site.isBlog}}\n <section class=\"posts\">\n <h2>posts</h2>\n {{#site.hasPosts}}\n <ul class=\"post-list\">\n {{#site.recentPosts}}\n <li>\n <h3><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n <p class=\"post-meta\"><time datetime=\"{{date}}\">{{date}}</time></p>\n </li>\n {{/site.recentPosts}}\n </ul>\n <p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">all {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}\n {{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}\n </section>\n {{/site.isBlog}}\n </article>\n{{> shell-bottom}}\n",
13813
14342
  "../etherpk-al-folio/layouts/journal.html": "{{> shell-top}}\n <article class=\"post\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n <p class=\"post-meta\">{{#page.hasDate}}<time datetime=\"{{page.date}}\">{{page.date}}</time> · {{/page.hasDate}}{{page.readingMinutes}} min read</p>\n </header>\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <nav class=\"post-nav\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"older\" href=\"{{url}}\">← {{title}}</a>{{/page.older}}\n {{#page.newer}}<a class=\"newer\" href=\"{{url}}\">{{title}} →</a>{{/page.newer}}\n </nav>\n </article>\n{{> shell-bottom}}\n",
13814
14343
  "../etherpk-al-folio/layouts/page.html": "{{> shell-top}}\n <article class=\"post\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n <p class=\"post-meta\">{{#page.hasDate}}<time datetime=\"{{page.date}}\">{{page.date}}</time> · {{/page.hasDate}}{{page.readingMinutes}} min read</p>\n </header>\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <nav class=\"post-nav\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"older\" href=\"{{url}}\">← {{title}}</a>{{/page.older}}\n {{#page.newer}}<a class=\"newer\" href=\"{{url}}\">{{title}} →</a>{{/page.newer}}\n </nav>\n </article>\n{{> shell-bottom}}\n",
13815
14344
  "../etherpk-al-folio/partials/after-content.html": "",
@@ -13830,7 +14359,7 @@ var files = /* @__PURE__ */ Object.assign({
13830
14359
  "../etherpk-blog/assets/theme.css": "/* EtherPK blog theme. Tokens follow the app's own (agent blue, the gk surface scale); the font\n is the system stack so the site loads nothing from anywhere. Dark mode follows the reader's\n preference. Classes the publisher emits and this file styles: .wikilink, .wikilink-missing,\n .task, .task-tag, .toc, .backlinks, .site-nav-list, .diagram, .math, .tok-* (code). */\n\n:root {\n --font-sans: \"Inter\", system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n --agent-500: #007acc;\n --agent-600: #005f9e;\n --agent-100: #c2e3ff;\n --surface-0: #ffffff;\n --surface-1: #f9fafb;\n --surface-2: #e5e7eb;\n --text-strong: #030712;\n --text-default: #374151;\n --text-muted: #6b7280;\n --border-soft: rgba(3, 7, 18, 0.08);\n --border-strong: rgba(3, 7, 18, 0.16);\n --mark: #fff3a3;\n --missing: #b45309;\n --nav-width: 17rem;\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --agent-500: #5ab3ff;\n --agent-600: #94ccff;\n --agent-100: #002e4f;\n --surface-0: #18181b;\n --surface-1: #1c1c20;\n --surface-2: #2a2a30;\n --text-strong: #fafafa;\n --text-default: #d4d4d8;\n --text-muted: #a1a1aa;\n --border-soft: rgba(255, 255, 255, 0.08);\n --border-strong: rgba(255, 255, 255, 0.16);\n --mark: #6b5a00;\n --missing: #f59e0b;\n }\n}\n\n*, *::before, *::after { box-sizing: border-box; }\nhtml { font-family: var(--font-sans); -webkit-font-smoothing: antialiased; }\nbody {\n margin: 0;\n color: var(--text-default);\n background: var(--surface-0);\n line-height: 1.6;\n font-size: 16px;\n}\na { color: var(--agent-500); }\na:hover { color: var(--agent-600); }\n.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.skip-link { position: absolute; left: -999px; top: 0; background: var(--surface-1); padding: 0.5rem 1rem; z-index: 100; }\n.skip-link:focus { left: 1rem; top: 1rem; }\n\n/* -- Header ----------------------------------------------------------------------------- */\n.site-header {\n display: flex;\n align-items: center;\n gap: 1rem;\n padding: 0.75rem 1.5rem;\n border-bottom: 1px solid var(--border-soft);\n background: var(--surface-1);\n position: sticky;\n top: 0;\n z-index: 20;\n}\n.site-title { display: inline-flex; align-items: center; gap: 0.5rem; min-width: 0; font-weight: 600; color: var(--text-strong); text-decoration: none; font-size: 1.05rem; }\n.site-title:hover { color: var(--agent-500); }\n.site-logo { flex-shrink: 0; }\n.site-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.site-search { position: relative; margin-left: auto; flex: 0 1 22rem; }\n.site-search input {\n width: 100%;\n padding: 0.45rem 0.75rem;\n border: 1px solid var(--border-strong);\n border-radius: 0.5rem;\n background: var(--surface-0);\n color: var(--text-default);\n font: inherit;\n}\n.site-search input:focus { outline: 2px solid var(--agent-500); outline-offset: 1px; }\n.site-search-results {\n position: absolute;\n top: calc(100% + 0.35rem);\n left: 0;\n right: 0;\n max-height: 24rem;\n overflow: auto;\n background: var(--surface-0);\n border: 1px solid var(--border-strong);\n border-radius: 0.5rem;\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);\n z-index: 30;\n}\n.site-search-hit { display: block; padding: 0.55rem 0.75rem; text-decoration: none; color: var(--text-default); border-bottom: 1px solid var(--border-soft); }\n.site-search-hit:hover, .site-search-hit:focus { background: var(--surface-1); }\n.site-search-title { display: block; font-weight: 600; color: var(--text-strong); }\n.site-search-snippet { display: block; font-size: 0.9rem; color: var(--text-muted); }\n.site-search-empty { margin: 0; padding: 0.6rem 0.75rem; color: var(--text-muted); }\n\n/* -- Shell: top navigation, centred column --------------------------------------------- */\n.site-header { display: block; padding: 1rem 1.5rem 0; position: static; }\n.site-header-row { display: flex; align-items: center; gap: 1rem; max-width: 46rem; margin: 0 auto; }\n.site-title { font-size: 1.35rem; }\n.site-nav { max-width: 46rem; margin: 0.75rem auto 0; }\n.site-nav-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; }\n.site-nav-list .site-nav-list { display: block; padding-left: 0.75rem; }\n.site-nav a, .site-nav .nav-group-label { color: var(--text-default); text-decoration: none; font-size: 0.95rem; }\n.site-nav a:hover { color: var(--text-strong); }\n.site-nav .nav-current > a { color: var(--agent-500); font-weight: 600; }\n.site-nav .nav-group-label { font-weight: 600; color: var(--text-strong); }\n.site-nav a .wikilink { color: inherit; text-decoration: none; }\n.nav-toggle, .nav-backdrop { display: none; }\n\n.content { padding: 2rem 1.5rem 4rem; max-width: 46rem; margin: 0 auto; }\n.post-header h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 0.5rem; color: var(--text-strong); }\n.post-header h1 .wikilink { color: inherit; text-decoration: none; }\n.post-header h1 .wikilink:hover { text-decoration: underline; }\n.post-date { color: var(--text-muted); font-size: 0.95rem; }\n.post-body h2 { margin-top: 2rem; font-size: 1.4rem; color: var(--text-strong); }\n.post-body h3 { margin-top: 1.5rem; font-size: 1.15rem; color: var(--text-strong); }\n.post-body ul { padding-left: 1.4rem; }\n.post-body li { margin: 0.2rem 0; }\n.post-body img { max-width: 100%; height: auto; border-radius: 0.35rem; }\n.post-body table { border-collapse: collapse; margin: 1rem 0; width: 100%; }\n.post-body th, .post-body td { border: 1px solid var(--border-strong); padding: 0.4rem 0.6rem; text-align: left; }\n.post-body th { background: var(--surface-1); }\n.post-body blockquote { border-left: 3px solid var(--border-strong); margin: 1rem 0; padding: 0.25rem 1rem; color: var(--text-muted); }\n.post-body code { font-family: var(--font-mono); font-size: 0.9em; background: var(--surface-1); padding: 0.1em 0.35em; border-radius: 0.25rem; }\n.post-body pre { background: var(--surface-1); border: 1px solid var(--border-soft); border-radius: 0.5rem; padding: 0.9rem 1rem; overflow-x: auto; }\n.post-body pre code { background: none; padding: 0; font-size: 0.88rem; }\nmark { background: var(--mark); color: inherit; padding: 0 0.15em; border-radius: 0.2em; }\n\n/* Wikilinks, as the editor colours them. A missing target is visibly missing. */\na.wikilink { text-decoration: none; border-bottom: 1px solid var(--agent-100); }\na.wikilink:hover { border-bottom-color: var(--agent-500); }\na.wikilink-missing { color: var(--missing); text-decoration: none; border-bottom: 1px dashed var(--missing); }\n\n/* Tasks and their badges. */\nli.task { list-style: none; margin-left: -1.4rem; padding-left: 0; }\nli.task input[type=\"checkbox\"] { margin-right: 0.35rem; vertical-align: -0.1em; }\nli.task.done { color: var(--text-muted); }\n.task-tag { display: inline-block; font-size: 0.8rem; font-weight: 600; padding: 0.05em 0.5em; border-radius: 999px; background: var(--surface-2); color: var(--text-strong); margin-right: 0.25rem; vertical-align: 0.1em; }\n.task-tag.priority-1 { background: #fee2e2; color: #991b1b; }\n.task-tag.priority-2 { background: #ffedd5; color: #9a3412; }\n.task-tag.priority-3 { background: #fef9c3; color: #854d0e; }\n.task-tag.waiting, .task-tag.doing { background: var(--agent-100); color: var(--agent-600); }\n.task-tag.cancelled { text-decoration: line-through; }\n\n/* Table of contents, diagrams, maths. */\n.toc { margin: 0 0 1.5rem; font-size: 0.95rem; }\n.toc summary { cursor: pointer; font-weight: 600; color: var(--text-strong); }\n.toc ul { list-style: none; padding-left: 0; margin: 0.4rem 0 0; }\n.toc li.toc-level-3 { padding-left: 1rem; }\n.toc li.toc-level-4 { padding-left: 2rem; }\n.diagram { margin: 1rem 0; text-align: center; }\n.diagram svg { max-width: 100%; height: auto; }\n.math-block { overflow-x: auto; margin: 1rem 0; }\n\n/* Linked references, beneath the content. */\n.backlinks { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border-soft); }\n.backlinks h2 { font-size: 1.1rem; color: var(--text-strong); margin: 0 0 0.75rem; }\n.backlink-list { list-style: none; padding: 0; margin: 0; }\n.backlink-source { margin: 0 0 0.9rem; }\n.backlink-source > a { font-weight: 600; }\n.backlink-source ul { list-style: none; padding-left: 1rem; margin: 0.25rem 0 0; border-left: 2px solid var(--border-soft); }\n.backlink-context { color: var(--text-muted); font-size: 0.95rem; margin: 0.15rem 0; }\n\n/* Archive and index lists. */\n.journal-archive, .page-index-list { list-style: none; padding: 0; }\n.journal-archive li { margin: 0 0 1rem; }\n.journal-archive time { color: var(--text-muted); margin-right: 0.5rem; font-variant-numeric: tabular-nums; }\n.archive-excerpt { margin: 0.2rem 0 0; color: var(--text-muted); }\n.archive-year { margin: 1.5rem 0 0.5rem; font-size: 1.1rem; }\n.all-posts { margin: 1.5rem 0 0; }\n\n.site-footer { border-top: 1px solid var(--border-soft); padding: 1rem 1.5rem; color: var(--text-muted); font-size: 0.9rem; text-align: center; }\n\n/* Code tokens (lezer classHighlighter names). */\n.tok-keyword { color: #0a7; }\n.tok-string, .tok-string2 { color: #690; }\n.tok-comment, .tok-lineComment, .tok-blockComment { color: #999; font-style: italic; }\n.tok-number, .tok-bool, .tok-null, .tok-atom { color: #905; }\n.tok-function, .tok-definition.tok-variableName { color: #c60; }\n.tok-typeName, .tok-className, .tok-namespace { color: #b58900; }\n.tok-operator, .tok-punctuation { color: #555; }\n.tok-propertyName, .tok-attributeName { color: #268bd2; }\n.tok-tagName { color: #22863a; }\n.tok-invalid { color: #d00; }\n@media (prefers-color-scheme: dark) {\n .tok-keyword { color: #4ec9b0; }\n .tok-string, .tok-string2 { color: #b5cea8; }\n .tok-comment, .tok-lineComment, .tok-blockComment { color: #7f848e; }\n .tok-number, .tok-bool, .tok-null, .tok-atom { color: #d19a66; }\n .tok-function, .tok-definition.tok-variableName { color: #dcdcaa; }\n .tok-typeName, .tok-className, .tok-namespace { color: #e5c07b; }\n .tok-operator, .tok-punctuation { color: #abb2bf; }\n .tok-propertyName, .tok-attributeName { color: #61afef; }\n .tok-tagName { color: #98c379; }\n}\n\n/* -- Posts ------------------------------------------------------------------------------ */\n.post-summary { margin: 0 0 2rem; }\n.post-summary h2 { margin: 0 0 0.2rem; font-size: 1.35rem; }\n.post-summary h2 a { color: var(--text-strong); text-decoration: none; }\n.post-summary h2 a:hover { color: var(--agent-500); }\n.post-summary time { color: var(--text-muted); font-size: 0.9rem; font-variant-numeric: tabular-nums; }\n.post-summary p { margin: 0.4rem 0 0; color: var(--text-default); }\n.home-intro { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-soft); }\n.post-list-empty { color: var(--text-muted); }\n@media (max-width: 40rem) {\n .site-header-row { flex-wrap: wrap; }\n .site-search { flex-basis: 100%; margin-left: 0; }\n .content { padding: 1.25rem 1rem 3rem; }\n}\n",
13831
14360
  "../etherpk-blog/layouts/404.html": "{{> shell-top}}\n <article class=\"post not-published\">\n <header class=\"post-header\">\n <h1>Not published</h1>\n </header>\n <div class=\"post-body\">\n <p>The page you followed a link to is not part of this site.</p>\n <p><a href=\"index.html\">Back to {{site.title}}</a></p>\n </div>\n </article>\n{{> shell-bottom}}\n",
13832
14361
  "../etherpk-blog/layouts/archive.html": "{{> shell-top}}\n <article class=\"post archive\">\n <header class=\"post-header\">\n <h1>{{#page.isPostsArchive}}Posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}Journal{{/page.isJournalArchive}}</h1>\n </header>\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <h2 class=\"archive-year\" id=\"y{{year}}\">{{year}}</h2>\n <ul class=\"journal-archive\">\n {{#posts}}\n <li>\n <time datetime=\"{{date}}\">{{date}}</time>\n <a href=\"{{url}}\">{{title}}</a>\n </li>\n {{/posts}}\n </ul>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n <ul class=\"journal-archive\">\n {{#site.journals}}\n <li>\n <time datetime=\"{{date}}\">{{date}}</time>\n <a href=\"{{url}}\">{{title}}</a>\n {{#excerpt}}<p class=\"archive-excerpt\">{{excerpt}}</p>{{/excerpt}}\n </li>\n {{/site.journals}}\n </ul>\n {{/page.isJournalArchive}}\n </article>\n{{> shell-bottom}}\n",
13833
- "../etherpk-blog/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <section class=\"home-intro\">\n <div class=\"post-body\">\n{{{page.content}}}\n </div>\n </section>\n {{/page.isGeneratedIndex}}\n <section class=\"post-list\" aria-label=\"Posts\">\n {{#site.hasPosts}}\n {{#site.recentPosts}}\n <article class=\"post-summary\">\n <h2><a href=\"{{url}}\">{{title}}</a></h2>\n <time datetime=\"{{date}}\">{{date}}</time>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n </article>\n {{/site.recentPosts}}\n <p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}\n {{^site.hasPosts}}\n <p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>\n {{/site.hasPosts}}\n </section>\n{{> shell-bottom}}\n",
14362
+ "../etherpk-blog/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <section class=\"home-intro\">\n <div class=\"post-body\">\n{{{page.content}}}\n </div>\n </section>\n {{/page.isGeneratedIndex}}\n <section class=\"post-list\" aria-label=\"Posts\">\n {{#site.hasPosts}}\n {{#site.recentPosts}}\n <article class=\"post-summary\">\n <h2><a href=\"{{url}}\">{{title}}</a></h2>\n <time datetime=\"{{date}}\">{{date}}</time>\n {{#excerpt}}<p>{{excerpt}}</p>{{/excerpt}}\n </article>\n {{/site.recentPosts}}\n <p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}\n {{^site.hasPosts}}\n <p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>\n {{/site.hasPosts}}\n </section>\n{{> shell-bottom}}\n",
13834
14363
  "../etherpk-blog/layouts/journal.html": "{{> shell-top}}\n <article class=\"post\">\n <header class=\"post-header\">\n <h1>{{{page.titleHtml}}}</h1>\n {{#page.hasDate}}<time class=\"post-date\" datetime=\"{{page.date}}\">{{page.date}}</time>{{/page.hasDate}}\n </header>\n <div class=\"post-body\">\n{{{page.content}}}\n </div>\n </article>\n{{> shell-bottom}}\n",
13835
14364
  "../etherpk-blog/layouts/page.html": "{{> shell-top}}\n <article class=\"post\">\n <header class=\"post-header\">\n <h1>{{{page.titleHtml}}}</h1>\n {{#page.hasDate}}<time class=\"post-date\" datetime=\"{{page.date}}\">{{page.date}}</time>{{/page.hasDate}}\n </header>\n <div class=\"post-body\">\n{{{page.content}}}\n </div>\n </article>\n{{> shell-bottom}}\n",
13836
14365
  "../etherpk-blog/partials/after-content.html": "",
@@ -13858,7 +14387,7 @@ var files = /* @__PURE__ */ Object.assign({
13858
14387
  "../etherpk-docs/partials/before-content.html": "",
13859
14388
  "../etherpk-docs/partials/footer.html": " <footer class=\"site-footer\">\n <p>{{site.title}} · published with <a href=\"https://etherpk.com\" rel=\"noopener\">EtherPK</a></p>\n </footer>\n",
13860
14389
  "../etherpk-docs/partials/head.html": "",
13861
- "../etherpk-docs/partials/header.html": " <header class=\"site-header\">\n <a class=\"site-title\" href=\"index.html\">{{> logo}}<span class=\"site-title-text\">{{site.title}}</span></a>\n <form class=\"site-search\" role=\"search\" data-search-index=\"{{site.search.url}}\" data-search-script=\"{{site.search.scriptUrl}}\">\n <label class=\"visually-hidden\" for=\"site-search-input\">Search</label>\n <input id=\"site-search-input\" type=\"search\" name=\"q\" placeholder=\"Search\" autocomplete=\"off\" aria-describedby=\"site-search-hint\">\n <span id=\"site-search-hint\" class=\"visually-hidden\">Type to search the published pages; results appear below the box.</span>\n <div class=\"site-search-results\" role=\"listbox\" hidden></div>\n </form>\n </header>\n",
14390
+ "../etherpk-docs/partials/header.html": " <header class=\"site-header\">\n <a class=\"site-title\" href=\"index.html\">{{> logo}}<span class=\"site-title-text\">{{site.title}}</span></a>\n <form class=\"site-search\" role=\"search\" data-search-index=\"{{site.search.url}}\" data-search-script=\"{{site.search.scriptUrl}}\">\n <label class=\"visually-hidden\" for=\"site-search-input\">Search</label>\n <input id=\"site-search-input\" type=\"search\" name=\"q\" placeholder=\"Search\" autocomplete=\"off\" aria-describedby=\"site-search-hint\">\n <span id=\"site-search-hint\" class=\"visually-hidden\">Type to search the published pages - results appear below the box.</span>\n <div class=\"site-search-results\" role=\"listbox\" hidden></div>\n </form>\n </header>\n",
13862
14391
  "../etherpk-docs/partials/logo.html": "<!-- The EtherPK icon (resources/brand/etherpk-icon-*.svg), inline so the page fetches nothing and it takes the text colour; aria-hidden because the title beside it is the name. Fill the \"logo\" include, or edit this partial, for your own. -->\n<svg class=\"site-logo\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 191.82 166.13\" width=\"24\" height=\"21\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M67.09,132.92,47.93,166.13H9.59L0,149.52,86.33,0H105.5L19.17,149.52H38.34l19.17-33.2h76.8l9.59,16.6Zm48-83.12,9.65,16.71H143.9L115.08,16.6q-24,41.57-48,83.12H143.9q14.37,24.9,28.76,49.8h-96l-9.59,16.61H182.24l9.58-16.61-38.34-66.4H96C95.85,82.9,113.39,52.73,115.08,49.8Z\"/></svg>\n",
13863
14392
  "../etherpk-docs/partials/nav-item.html": " <li class=\"{{#current}}nav-current {{/current}}{{#open}}nav-open {{/open}}{{^href}}nav-group{{/href}}\">\n {{#href}}<a href=\"{{href}}\"{{#current}} aria-current=\"page\"{{/current}}{{#external}} rel=\"noopener\"{{/external}}>{{label}}</a>{{/href}}\n {{^href}}<span class=\"nav-group-label\">{{{labelHtml}}}</span>{{/href}}\n {{#hasChildren}}<ul class=\"site-nav-list\">{{#children}}{{> nav-item}}{{/children}}</ul>{{/hasChildren}}\n </li>\n",
13864
14393
  "../etherpk-docs/partials/nav.html": " <nav id=\"site-nav\" class=\"site-nav\" aria-label=\"Site\">\n <ul class=\"site-nav-list\">\n{{#nav}}{{> nav-item}}{{/nav}}\n </ul>\n </nav>\n",
@@ -13873,7 +14402,7 @@ var files = /* @__PURE__ */ Object.assign({
13873
14402
  "../etherpk-papermod/assets/theme.js": "/*\n * The light / dark toggle: a `.theme-toggle` button flips the page between the two and the\n * choice is remembered in this browser; with no choice made the page follows the system. The\n * stylesheet keys on `data-theme` on <html>, and shell-top applies the remembered choice before\n * the first paint so a dark reader never sees a light flash.\n */\n(function () {\n 'use strict';\n var KEY = 'etherpk-theme';\n var root = document.documentElement;\n function isDark() {\n var chosen = root.getAttribute('data-theme');\n if (chosen === 'dark' || chosen === 'light') return chosen === 'dark';\n return window.matchMedia('(prefers-color-scheme: dark)').matches;\n }\n function apply(theme) {\n if (theme === 'dark' || theme === 'light') root.setAttribute('data-theme', theme);\n else root.removeAttribute('data-theme');\n var buttons = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < buttons.length; i++) buttons[i].setAttribute('aria-pressed', isDark() ? 'true' : 'false');\n }\n try { apply(localStorage.getItem(KEY)); } catch (_e) { apply(null); }\n var toggles = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < toggles.length; i++) {\n toggles[i].addEventListener('click', function () {\n var next = isDark() ? 'light' : 'dark';\n apply(next);\n try { localStorage.setItem(KEY, next); } catch (_e) { /* private mode: the choice lasts the page */ }\n });\n }\n})();\n",
13874
14403
  "../etherpk-papermod/layouts/404.html": "{{> shell-top}}\n <div class=\"not-found\">\n <h1>404</h1>\n <p>This page is not published. <a href=\"index.html\">Back to {{site.title}}</a>.</p>\n </div>\n{{> shell-bottom}}\n",
13875
14404
  "../etherpk-papermod/layouts/archive.html": "{{> shell-top}}\n <header class=\"page-header\"><h1>{{#page.isPostsArchive}}Posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}Journal{{/page.isJournalArchive}}</h1></header>\n <div class=\"archive-posts\">\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <div class=\"archive-year\">\n <h2 class=\"archive-year-header\" id=\"y{{year}}\">{{year}}</h2>\n {{#posts}}\n <div class=\"archive-entry\">\n <h3 class=\"archive-entry-title\"><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<div class=\"archive-entry-content\">{{excerpt}}</div>{{/excerpt}}\n <footer class=\"entry-footer\"><time datetime=\"{{date}}\">{{date}}</time></footer>\n </div>\n {{/posts}}\n </div>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n {{#site.journals}}\n <div class=\"archive-entry\">\n <h3 class=\"archive-entry-title\"><a href=\"{{url}}\">{{title}}</a></h3>\n {{#excerpt}}<div class=\"archive-entry-content\">{{excerpt}}</div>{{/excerpt}}\n <footer class=\"entry-footer\"><time datetime=\"{{date}}\">{{date}}</time></footer>\n </div>\n {{/site.journals}}\n {{/page.isJournalArchive}}\n </div>\n{{> shell-bottom}}\n",
13876
- "../etherpk-papermod/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"post-single home-info\">\n <header class=\"post-header\"><h1 class=\"post-title\">{{{page.titleHtml}}}</h1></header>\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}\n <header class=\"page-header\"><h1>{{site.title}}</h1></header>\n {{/page.isGeneratedIndex}}\n <section class=\"post-list\" aria-label=\"{{#site.isBlog}}Posts{{/site.isBlog}}{{^site.isBlog}}Pages{{/site.isBlog}}\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> post-entry}}{{/site.recentPosts}}{{#site.hasPosts}}<p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>{{/site.hasPosts}}{{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<div class=\"post-content page-contents\">{{{page.content}}}</div>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </section>\n{{> shell-bottom}}\n",
14405
+ "../etherpk-papermod/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"post-single home-info\">\n <header class=\"post-header\"><h1 class=\"post-title\">{{{page.titleHtml}}}</h1></header>\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}\n <header class=\"page-header\"><h1>{{site.title}}</h1></header>\n {{/page.isGeneratedIndex}}\n <section class=\"post-list\" aria-label=\"{{#site.isBlog}}Posts{{/site.isBlog}}{{^site.isBlog}}Pages{{/site.isBlog}}\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> post-entry}}{{/site.recentPosts}}{{#site.hasPosts}}<p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>{{/site.hasPosts}}{{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<div class=\"post-content page-contents\">{{{page.content}}}</div>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </section>\n{{> shell-bottom}}\n",
13877
14406
  "../etherpk-papermod/layouts/journal.html": "{{> shell-top}}\n <article class=\"post-single\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n{{> post-meta}}\n </header>\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <footer class=\"post-footer\">\n <nav class=\"paginav\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"prev\" href=\"{{url}}\"><span class=\"title\">« Prev</span><br><span>{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"next\" href=\"{{url}}\"><span class=\"title\">Next »</span><br><span>{{title}}</span></a>{{/page.newer}}\n </nav>\n </footer>\n </article>\n{{> shell-bottom}}\n",
13878
14407
  "../etherpk-papermod/layouts/page.html": "{{> shell-top}}\n <article class=\"post-single\">\n <header class=\"post-header\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n{{> post-meta}}\n </header>\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <footer class=\"post-footer\">\n <nav class=\"paginav\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"prev\" href=\"{{url}}\"><span class=\"title\">« Prev</span><br><span>{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"next\" href=\"{{url}}\"><span class=\"title\">Next »</span><br><span>{{title}}</span></a>{{/page.newer}}\n </nav>\n </footer>\n </article>\n{{> shell-bottom}}\n",
13879
14408
  "../etherpk-papermod/partials/after-content.html": "",
@@ -13897,7 +14426,7 @@ var files = /* @__PURE__ */ Object.assign({
13897
14426
  "../etherpk-stack/assets/theme.js": "/*\n * The light / dark toggle: a `.theme-toggle` button flips the page between the two and the\n * choice is remembered in this browser; with no choice made the page follows the system. The\n * stylesheet keys on `data-theme` on <html>, and shell-top applies the remembered choice before\n * the first paint so a dark reader never sees a light flash.\n */\n(function () {\n 'use strict';\n var KEY = 'etherpk-theme';\n var root = document.documentElement;\n function isDark() {\n var chosen = root.getAttribute('data-theme');\n if (chosen === 'dark' || chosen === 'light') return chosen === 'dark';\n return window.matchMedia('(prefers-color-scheme: dark)').matches;\n }\n function apply(theme) {\n if (theme === 'dark' || theme === 'light') root.setAttribute('data-theme', theme);\n else root.removeAttribute('data-theme');\n var buttons = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < buttons.length; i++) buttons[i].setAttribute('aria-pressed', isDark() ? 'true' : 'false');\n }\n try { apply(localStorage.getItem(KEY)); } catch (_e) { apply(null); }\n var toggles = document.querySelectorAll('.theme-toggle');\n for (var i = 0; i < toggles.length; i++) {\n toggles[i].addEventListener('click', function () {\n var next = isDark() ? 'light' : 'dark';\n apply(next);\n try { localStorage.setItem(KEY, next); } catch (_e) { /* private mode: the choice lasts the page */ }\n });\n }\n})();\n",
13898
14427
  "../etherpk-stack/layouts/404.html": "{{> shell-top}}\n <article class=\"article-page not-found\">\n <header class=\"article-header\"><h1 class=\"article-title\">Not published</h1></header>\n <div class=\"article-content\">\n <p>The page you followed a link to is not part of this site.</p>\n <p><a href=\"index.html\">Back to {{site.title}}</a></p>\n </div>\n </article>\n{{> shell-bottom}}\n",
13899
14428
  "../etherpk-stack/layouts/archive.html": "{{> shell-top}}\n <header class=\"section-header\"><h1>{{#page.isPostsArchive}}Posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}Journal{{/page.isJournalArchive}}</h1></header>\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <section class=\"archive-year\" aria-label=\"{{year}}\">\n <h2 class=\"archive-year-title\" id=\"y{{year}}\">{{year}}</h2>\n <ul class=\"archive-list\">\n {{#posts}}<li><time datetime=\"{{date}}\">{{date}}</time> <a href=\"{{url}}\">{{title}}</a></li>{{/posts}}\n </ul>\n </section>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n <section class=\"article-list\" aria-label=\"Journal\">\n {{#site.journals}}{{> article-card}}{{/site.journals}}\n </section>\n {{/page.isJournalArchive}}\n{{> shell-bottom}}\n",
13900
- "../etherpk-stack/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"article-page home-intro\">\n <header class=\"article-header\"><h1 class=\"article-title\">{{{page.titleHtml}}}</h1></header>\n <div class=\"article-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n <section class=\"article-list\" aria-label=\"{{#site.isBlog}}Posts{{/site.isBlog}}{{^site.isBlog}}Pages{{/site.isBlog}}\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> article-card}}{{/site.recentPosts}}{{#site.hasPosts}}<p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>{{/site.hasPosts}}{{^site.hasPosts}}<p class=\"article-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<article class=\"article-page page-contents\"><header class=\"article-header\"><h1 class=\"article-title\">{{site.title}}</h1></header><div class=\"article-content\">{{{page.content}}}</div></article>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </section>\n{{> shell-bottom}}\n",
14429
+ "../etherpk-stack/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"article-page home-intro\">\n <header class=\"article-header\"><h1 class=\"article-title\">{{{page.titleHtml}}}</h1></header>\n <div class=\"article-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n <section class=\"article-list\" aria-label=\"{{#site.isBlog}}Posts{{/site.isBlog}}{{^site.isBlog}}Pages{{/site.isBlog}}\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> article-card}}{{/site.recentPosts}}{{#site.hasPosts}}<p class=\"all-posts\"><a href=\"{{site.postsUrl}}\">All {{site.postCount}} posts →</a></p>{{/site.hasPosts}}{{^site.hasPosts}}<p class=\"article-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<article class=\"article-page page-contents\"><header class=\"article-header\"><h1 class=\"article-title\">{{site.title}}</h1></header><div class=\"article-content\">{{{page.content}}}</div></article>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </section>\n{{> shell-bottom}}\n",
13901
14430
  "../etherpk-stack/layouts/journal.html": "{{> shell-top}}\n <article class=\"article-page\">\n <header class=\"article-header\">\n <span class=\"article-category\">{{page.kind}}</span>\n <h1 class=\"article-title\">{{{page.titleHtml}}}</h1>\n <div class=\"article-details\">{{#page.hasDate}}<time datetime=\"{{page.date}}\">{{page.date}}</time> · {{/page.hasDate}}<span>{{page.readingMinutes}} minute read</span></div>\n </header>\n <div class=\"article-content\">\n{{{page.content}}}\n </div>\n <footer class=\"article-footer\">\n <nav class=\"article-pagination\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"article-card pagination-card\" href=\"{{url}}\"><span class=\"pagination-label\">Previous</span><span class=\"pagination-title\">{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"article-card pagination-card next\" href=\"{{url}}\"><span class=\"pagination-label\">Next</span><span class=\"pagination-title\">{{title}}</span></a>{{/page.newer}}\n </nav>\n </footer>\n </article>\n{{> shell-bottom}}\n",
13902
14431
  "../etherpk-stack/layouts/page.html": "{{> shell-top}}\n <article class=\"article-page\">\n <header class=\"article-header\">\n <span class=\"article-category\">{{page.kind}}</span>\n <h1 class=\"article-title\">{{{page.titleHtml}}}</h1>\n <div class=\"article-details\">{{#page.hasDate}}<time datetime=\"{{page.date}}\">{{page.date}}</time> · {{/page.hasDate}}<span>{{page.readingMinutes}} minute read</span></div>\n </header>\n <div class=\"article-content\">\n{{{page.content}}}\n </div>\n <footer class=\"article-footer\">\n <nav class=\"article-pagination\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"article-card pagination-card\" href=\"{{url}}\"><span class=\"pagination-label\">Previous</span><span class=\"pagination-title\">{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"article-card pagination-card next\" href=\"{{url}}\"><span class=\"pagination-label\">Next</span><span class=\"pagination-title\">{{title}}</span></a>{{/page.newer}}\n </nav>\n </footer>\n </article>\n{{> shell-bottom}}\n",
13903
14432
  "../etherpk-stack/partials/after-content.html": "",
@@ -13915,12 +14444,12 @@ var files = /* @__PURE__ */ Object.assign({
13915
14444
  "../etherpk-stack/partials/theme-scripts.html": " <script src=\"theme/search.js\" defer><\/script>\n <script src=\"theme/theme.js\" defer><\/script>\n",
13916
14445
  "../etherpk-stack/partials/toc.html": " <nav class=\"toc\" aria-label=\"Table of contents\">\n <ul>\n {{#page.toc}}<li class=\"toc-level-{{level}}\"><a href=\"#{{id}}\">{{text}}</a></li>{{/page.toc}}\n </ul>\n </nav>\n",
13917
14446
  "../etherpk-stack/partials/widgets.html": " <section class=\"widget search-widget\" aria-label=\"Search\">\n <form class=\"site-search\" role=\"search\" data-search-index=\"{{site.search.url}}\" data-search-script=\"{{site.search.scriptUrl}}\">\n <label class=\"visually-hidden\" for=\"site-search-input\">Search</label>\n <input id=\"site-search-input\" type=\"search\" name=\"q\" placeholder=\"Search\" autocomplete=\"off\">\n <div class=\"site-search-results\" role=\"listbox\" hidden></div>\n </form>\n </section>\n {{#page.hasToc}}\n <section class=\"widget toc-widget\">\n <h2 class=\"widget-title\">Table of contents</h2>\n{{> toc}}\n </section>\n {{/page.hasToc}}\n {{#site.hasPostsArchive}}\n <!-- Years with counts, as Stack's archives widget does: a list of every post would grow\n with the blog; the archive page carries them. -->\n <section class=\"widget archives\">\n <h2 class=\"widget-title\">Archives</h2>\n <ul class=\"widget-list\">\n {{#site.postsByYear}}<li><a href=\"{{site.postsUrl}}#y{{year}}\"><span>{{year}}</span><span class=\"widget-count\">{{count}}</span></a></li>{{/site.postsByYear}}\n </ul>\n <p class=\"widget-more\"><a href=\"{{site.postsUrl}}\">All posts</a></p>\n </section>\n {{/site.hasPostsArchive}}\n {{#site.hasJournals}}\n <section class=\"widget journal\">\n <h2 class=\"widget-title\">Journal</h2>\n <p class=\"widget-more\"><a href=\"journal.html\">Every entry, by day</a></p>\n </section>\n {{/site.hasJournals}}\n <!-- A blog's posts are in the archive above; only its other pages belong here. -->\n {{#site.isBlog}}{{#site.hasUndatedPages}}\n <section class=\"widget pages\">\n <h2 class=\"widget-title\">Pages</h2>\n <ul class=\"widget-list\">\n {{#site.undatedPages}}<li><a href=\"{{url}}\"><span>{{title}}</span></a></li>{{/site.undatedPages}}\n </ul>\n </section>\n {{/site.hasUndatedPages}}{{/site.isBlog}}\n {{^site.isBlog}}\n <section class=\"widget pages\">\n <h2 class=\"widget-title\">Pages</h2>\n <ul class=\"widget-list\">\n {{#site.pages}}<li><a href=\"{{url}}\"><span>{{title}}</span></a></li>{{/site.pages}}\n </ul>\n </section>\n {{/site.isBlog}}\n",
13918
- "../etherpk-stack/theme.json": "{\n \"name\": \"etherpk-stack\",\n \"title\": \"EtherPK Stack\",\n \"version\": \"1.0.0\",\n \"contract\": 1,\n \"description\": \"In the mould of Hugo's Stack: a card-based blog with a left sidebar of site identity and menu, article cards in the middle, and widgets (search, contents, archive, pages) on the right.\",\n \"kinds\": [\"blog\", \"docs\"],\n \"includes\": [\n { \"name\": \"head\", \"description\": \"Extra markup inside <head>: analytics, fonts, meta tags. Raw HTML in the page body.\" },\n { \"name\": \"logo\", \"description\": \"The avatar above the site title in the sidebar: an image or inline SVG. Replaces the EtherPK icon.\" },\n { \"name\": \"header\", \"description\": \"The left sidebar's identity block (avatar, title, description). Replaces the theme's; the menu stays.\" },\n { \"name\": \"before-content\", \"description\": \"Rendered above every page's content: a banner, a notice.\" },\n { \"name\": \"after-content\", \"description\": \"Rendered below every page's content, above the linked references: an author bio, a comments widget.\" },\n { \"name\": \"footer\", \"description\": \"The footer under the article column.\" },\n { \"name\": \"scripts\", \"description\": \"Scripts before </body>, added after the theme's own (search, the theme toggle): analytics, a widget. Raw HTML in the page body.\" },\n { \"name\": \"styles\", \"description\": \"CSS appended after the theme's stylesheet: the page's first css fence.\", \"kind\": \"css\" }\n ],\n \"files\": [\n \"layouts/page.html\", \"layouts/home.html\", \"layouts/journal.html\", \"layouts/archive.html\", \"layouts/404.html\",\n \"partials/shell-top.html\", \"partials/shell-bottom.html\", \"partials/head.html\", \"partials/logo.html\", \"partials/header.html\",\n \"partials/nav-item.html\", \"partials/toc.html\", \"partials/backlinks.html\", \"partials/article-card.html\", \"partials/widgets.html\",\n \"partials/before-content.html\", \"partials/after-content.html\", \"partials/footer.html\", \"partials/theme-scripts.html\", \"partials/scripts.html\",\n \"assets/theme.css\", \"assets/search.js\", \"assets/theme.js\"\n ]\n}\n",
14447
+ "../etherpk-stack/theme.json": "{\n \"name\": \"etherpk-stack\",\n \"title\": \"EtherPK Stack\",\n \"version\": \"1.0.0\",\n \"contract\": 1,\n \"description\": \"In the mould of Hugo's Stack: a card-based blog with a left sidebar of site identity and menu, article cards in the middle, and widgets (search, contents, archive, pages) on the right.\",\n \"kinds\": [\"blog\", \"docs\"],\n \"includes\": [\n { \"name\": \"head\", \"description\": \"Extra markup inside <head>: analytics, fonts, meta tags. Raw HTML in the page body.\" },\n { \"name\": \"logo\", \"description\": \"The avatar above the site title in the sidebar: an image or inline SVG. Replaces the EtherPK icon.\" },\n { \"name\": \"header\", \"description\": \"The left sidebar's identity block (avatar, title, description). Replaces the theme's - the menu stays.\" },\n { \"name\": \"before-content\", \"description\": \"Rendered above every page's content: a banner, a notice.\" },\n { \"name\": \"after-content\", \"description\": \"Rendered below every page's content, above the linked references: an author bio, a comments widget.\" },\n { \"name\": \"footer\", \"description\": \"The footer under the article column.\" },\n { \"name\": \"scripts\", \"description\": \"Scripts before </body>, added after the theme's own (search, the theme toggle): analytics, a widget. Raw HTML in the page body.\" },\n { \"name\": \"styles\", \"description\": \"CSS appended after the theme's stylesheet: the page's first css fence.\", \"kind\": \"css\" }\n ],\n \"files\": [\n \"layouts/page.html\", \"layouts/home.html\", \"layouts/journal.html\", \"layouts/archive.html\", \"layouts/404.html\",\n \"partials/shell-top.html\", \"partials/shell-bottom.html\", \"partials/head.html\", \"partials/logo.html\", \"partials/header.html\",\n \"partials/nav-item.html\", \"partials/toc.html\", \"partials/backlinks.html\", \"partials/article-card.html\", \"partials/widgets.html\",\n \"partials/before-content.html\", \"partials/after-content.html\", \"partials/footer.html\", \"partials/theme-scripts.html\", \"partials/scripts.html\",\n \"assets/theme.css\", \"assets/search.js\", \"assets/theme.js\"\n ]\n}\n",
13919
14448
  "../etherpk-terminal/assets/search.js": "/*\n * Search for a Published Site: reads search.json (built over the publication's own documents\n * and nothing else) and scores documents by title and text matches. No dependencies, no\n * network beyond the one index file, and nothing runs until the search box is used. The same\n * script runs the sidebar toggle on small screens, so a page carries one script.\n */\n(function () {\n 'use strict';\n\n // -- Navigation toggle (small screens) -------------------------------------------------\n var toggle = document.querySelector('.nav-toggle');\n var backdrop = document.querySelector('.nav-backdrop');\n var nav = document.getElementById('site-nav');\n function setNavOpen(open) {\n var was = document.body.classList.contains('nav-open');\n document.body.classList.toggle('nav-open', open);\n if (toggle) toggle.setAttribute('aria-expanded', open ? 'true' : 'false');\n if (backdrop) backdrop.hidden = !open;\n // Focus follows the drawer: into its first link on open, back to the button on close,\n // so a keyboard or screen-reader user is never left on something now hidden.\n if (open && !was) {\n var first = nav.querySelector('a');\n if (first) first.focus();\n } else if (!open && was && toggle) {\n toggle.focus();\n }\n }\n if (toggle && nav) {\n toggle.addEventListener('click', function () { setNavOpen(!document.body.classList.contains('nav-open')); });\n if (backdrop) backdrop.addEventListener('click', function () { setNavOpen(false); });\n document.addEventListener('keydown', function (e) {\n if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNavOpen(false);\n });\n }\n\n // -- Search --------------------------------------------------------------------------\n var form = document.querySelector('.site-search');\n if (!form) return;\n var input = form.querySelector('input[type=\"search\"]');\n var results = form.querySelector('.site-search-results');\n var indexUrl = form.getAttribute('data-search-index') || 'search.json';\n var scriptUrl = form.getAttribute('data-search-script') || 'search-index.js';\n var index = null;\n var loading = null;\n /* True once neither route produced the index: the results then say so rather than \"nothing matches\". */\n var unavailable = false;\n\n /* The index as a script assigning window.etherpkSearchIndex: what a site opened from disk can\n load, since file:// refuses fetch but allows <script src>. */\n function loadScript() {\n return new Promise(function (resolve, reject) {\n if (window.etherpkSearchIndex) { resolve(window.etherpkSearchIndex); return; }\n var s = document.createElement('script');\n s.src = scriptUrl;\n s.onload = function () {\n if (window.etherpkSearchIndex) resolve(window.etherpkSearchIndex);\n else reject(new Error('no index'));\n };\n s.onerror = function () { reject(new Error('no script')); };\n document.head.appendChild(s);\n });\n }\n\n function load() {\n if (index) return Promise.resolve(index);\n if (!loading) {\n loading = fetch(indexUrl).then(function (r) {\n if (!r.ok) throw new Error(String(r.status));\n return r.json();\n }).catch(loadScript).then(function (data) {\n index = (data && data.documents) || [];\n return index;\n }).catch(function () { unavailable = true; index = []; return index; });\n }\n return loading;\n }\n\n function tokens(q) {\n return q.toLowerCase().split(/\\s+/).filter(function (t) { return t.length > 0; });\n }\n\n function score(doc, terms) {\n var title = (doc.title || '').toLowerCase();\n var text = (doc.text || '').toLowerCase();\n var s = 0;\n for (var i = 0; i < terms.length; i++) {\n var t = terms[i];\n if (title === t) s += 20;\n else if (title.indexOf(t) === 0) s += 10;\n else if (title.indexOf(t) !== -1) s += 6;\n var at = text.indexOf(t);\n if (at !== -1) s += 2;\n else if (title.indexOf(t) === -1) return 0; // every term must match somewhere\n }\n return s;\n }\n\n function snippet(doc, terms) {\n var text = doc.text || '';\n var lower = text.toLowerCase();\n var at = -1;\n for (var i = 0; i < terms.length && at === -1; i++) at = lower.indexOf(terms[i]);\n if (at === -1) return doc.excerpt || '';\n var start = Math.max(0, at - 60);\n var end = Math.min(text.length, at + 100);\n return (start > 0 ? '…' : '') + text.slice(start, end).replace(/\\s+/g, ' ') + (end < text.length ? '…' : '');\n }\n\n function escapeHtml(s) {\n return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n }\n\n function render(list, q) {\n if (!q) { results.hidden = true; results.innerHTML = ''; return; }\n if (unavailable) {\n results.innerHTML = '<p class=\"site-search-empty\">The search index could not be loaded.</p>';\n results.hidden = false;\n return;\n }\n if (list.length === 0) {\n results.innerHTML = '<p class=\"site-search-empty\">Nothing matches \"' + escapeHtml(q) + '\".</p>';\n results.hidden = false;\n return;\n }\n results.innerHTML = list.map(function (hit) {\n return '<a class=\"site-search-hit\" role=\"option\" href=\"' + escapeHtml(hit.doc.url) + '\">' +\n '<span class=\"site-search-title\">' + escapeHtml(hit.doc.title) + '</span>' +\n '<span class=\"site-search-snippet\">' + escapeHtml(hit.snippet) + '</span></a>';\n }).join('');\n results.hidden = false;\n }\n\n var timer = null;\n function run() {\n var q = input.value.trim();\n var terms = tokens(q);\n if (terms.length === 0) { render([], ''); return; }\n load().then(function (docs) {\n var hits = [];\n for (var i = 0; i < docs.length; i++) {\n var s = score(docs[i], terms);\n if (s > 0) hits.push({ doc: docs[i], score: s, snippet: snippet(docs[i], terms) });\n }\n hits.sort(function (a, b) { return b.score - a.score || a.doc.title.localeCompare(b.doc.title); });\n render(hits.slice(0, 12), q);\n });\n }\n\n input.addEventListener('input', function () {\n clearTimeout(timer);\n timer = setTimeout(run, 120);\n });\n input.addEventListener('focus', function () { load(); });\n form.addEventListener('submit', function (e) {\n e.preventDefault();\n var first = results.querySelector('a');\n if (first) window.location.href = first.getAttribute('href');\n });\n document.addEventListener('keydown', function (e) {\n if (e.key === '/' && document.activeElement !== input && !/input|textarea/i.test(document.activeElement.tagName)) {\n e.preventDefault();\n input.focus();\n }\n if (e.key === 'Escape' && !results.hidden) { render([], ''); }\n });\n document.addEventListener('click', function (e) {\n if (!form.contains(e.target)) render([], '');\n });\n})();\n",
13920
14449
  "../etherpk-terminal/assets/theme.css": "/* EtherPK's Terminal: after Hugo's Terminal (panr, MIT), written afresh for EtherPK's view - one\n monospace face, a dark ground, no colour at all - the accent is a brighter shade of the text,\n so everything is drawn in tones of one ink - the block logo with its blinking cursor,\n underlined titles, framed boxes and bordered buttons. Uses Fira Code\n or the system monospace, so the site loads nothing from anywhere. Classes the publisher emits\n and this file styles: .wikilink, .wikilink-missing, .task, .task-tag, .toc, .backlinks,\n .site-nav-list, .diagram, .math, .tok-* (code). */\n\n:root {\n --accent: #f2f2f2;\n --background: #1d1e28;\n --color: #cfcfd6;\n --color-muted: #8f8f9a;\n --border-color: rgba(255, 255, 255, 0.1);\n --code-bg: rgba(255, 255, 255, 0.06);\n --font: \"Fira Code\", ui-monospace, Monaco, Consolas, \"Ubuntu Mono\", monospace;\n --container-width: 1080px;\n --mark: rgba(255, 255, 255, 0.22);\n --missing: var(--color-muted);\n}\n/* Terminal is dark by nature; a light system preference is respected, the ink inverted. */\n@media (prefers-color-scheme: light) {\n :root:not([data-theme=\"dark\"]) {\n --accent: #111111;\n --background: #f7f4ef;\n --color: #2b2b2b;\n --color-muted: #6a6a6a;\n --mark: rgba(0, 0, 0, 0.14);\n --border-color: rgba(0, 0, 0, 0.12);\n --code-bg: rgba(0, 0, 0, 0.05);\n }\n}\n\n*, *::before, *::after { box-sizing: border-box; }\nhtml { color-scheme: dark light; }\nbody {\n margin: 0;\n font-family: var(--font);\n font-size: 0.9375rem;\n line-height: 1.6;\n background: var(--background);\n color: var(--color);\n text-rendering: optimizeLegibility;\n -webkit-font-smoothing: antialiased;\n font-feature-settings: \"liga\", \"tnum\", \"zero\", \"ss01\", \"locl\";\n}\na { color: var(--accent); }\na:hover { text-decoration: none; }\nimg { max-width: 100%; height: auto; display: block; }\n.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.skip-link { position: absolute; left: -999px; top: 0; background: var(--accent); color: var(--background); padding: 8px 16px; z-index: 100; }\n.skip-link:focus { left: 16px; top: 16px; }\n\n.container { max-width: var(--container-width); margin: 0 auto; padding: 40px; }\n\n/* -- Header: the prompt-style logo ------------------------------------------------------ */\n.header { margin-bottom: 40px; }\n.header__inner { display: flex; align-items: center; justify-content: space-between; gap: 20px; }\n/* The block logo: the mark, the title and a blinking cursor on one line, the mark's box sized\n to the text's cap height so the two read as one glyph run. */\n.logo {\n display: inline-flex;\n align-items: center;\n gap: 12px;\n padding: 8px 16px;\n background: var(--accent);\n color: var(--background);\n font-size: 1.375rem;\n line-height: 1;\n font-weight: bold;\n text-decoration: none;\n}\n.logo:hover { text-decoration: none; }\n.logo__mark { display: inline-flex; align-items: center; line-height: 0; }\n.logo__mark .site-logo { display: block; width: 1.3em; height: 1.125em; }\n.logo__cursor {\n display: inline-block;\n width: 0.5em;\n height: 1em;\n background: var(--background);\n animation: cursor 1s infinite;\n}\n@keyframes cursor { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } }\n@media (prefers-reduced-motion: reduce) { .logo__cursor { animation: none; } }\n.menu { margin-top: 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 30px; }\n.menu__inner { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 30px; }\n.menu__inner li a, .menu__inner .nav-group-label { color: var(--color); text-decoration: none; }\n.menu__inner li a:hover { color: var(--accent); }\n.menu__inner .nav-current > a { color: var(--accent); }\n.menu__inner .nav-current > a::after { content: \" *\"; }\n.menu__inner .nav-group-label { color: var(--color-muted); }\n.menu__inner .nav-group-label::after { content: \":\"; }\n/* A nested outline level follows its parent on the same line, the way a path reads. */\n.menu__inner li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 12px; }\n.menu__inner .site-nav-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0 12px; }\n.menu__inner .site-nav-list li::before { content: \"/\"; color: var(--color-muted); margin-right: 12px; }\n.nav-toggle { display: none; }\n.nav-backdrop { display: none; }\n.site-search { position: relative; margin-left: auto; flex: 0 1 16rem; }\n.site-search input {\n width: 100%;\n padding: 5px 10px;\n border: 1px solid var(--accent);\n background: transparent;\n color: var(--color);\n font: inherit;\n}\n.site-search input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }\n.site-search input::placeholder { color: var(--color-muted); }\n.site-search-results {\n position: absolute;\n right: 0;\n top: calc(100% + 6px);\n width: min(26rem, 90vw);\n max-height: 24rem;\n overflow: auto;\n background: var(--background);\n border: 1px solid var(--accent);\n z-index: 30;\n}\n.site-search-hit { display: block; padding: 8px 12px; color: var(--color); text-decoration: none; border-bottom: 1px solid var(--border-color); }\n.site-search-hit:hover, .site-search-hit:focus { background: var(--code-bg); }\n.site-search-title { display: block; color: var(--accent); }\n.site-search-snippet { display: block; color: var(--color-muted); font-size: 0.9rem; }\n.site-search-empty { margin: 0; padding: 8px 12px; color: var(--color-muted); }\n\n/* -- Posts ------------------------------------------------------------------------------ */\n.posts { width: 100%; }\n.post { width: 100%; }\n.post.on-list { margin-bottom: 40px; padding-bottom: 40px; border-bottom: 1px dotted var(--accent); }\n.post.on-list:last-child { border-bottom: 0; }\n.post-title {\n position: relative;\n margin: 0 0 15px;\n font-size: 1.6rem;\n line-height: 1.3;\n color: var(--accent);\n text-decoration: underline;\n text-decoration-color: var(--accent);\n text-decoration-thickness: 2px;\n text-underline-offset: 6px;\n}\n.post-title a { color: inherit; text-decoration: none; }\n.post-title .wikilink { color: inherit; text-decoration: none; }\n.index-title { text-decoration: none; }\n.index-title::before { content: \"# \"; }\n.post-meta { margin: 0 0 20px; font-size: 0.9rem; color: var(--color-muted); }\n.post-content { margin-top: 20px; }\n.post-content h1, .post-content h2, .post-content h3, .post-content h4 { font-weight: bold; line-height: 1.3; margin: 40px 0 20px; }\n.post-content h2 { font-size: 1.3rem; }\n.post-content h3 { font-size: 1.2rem; }\n.post-content h2::before { content: \"## \"; color: var(--accent); }\n.post-content h3::before { content: \"### \"; color: var(--accent); }\n.post-content p { margin: 0 0 20px; }\n.post-content ul, .post-content ol { margin: 0 0 20px; padding-left: 24px; }\n.post-content li { margin: 4px 0; }\n.post-content ul li::marker { color: var(--accent); }\n.post-content img { margin: 20px 0; border: 1px solid var(--border-color); }\n.post-content .diagram { margin: 20px 0; text-align: center; }\n.post-content .diagram svg { max-width: 100%; height: auto; }\n.post-content blockquote {\n position: relative;\n margin: 40px 0;\n padding: 25px;\n border-top: 1px solid var(--accent);\n border-bottom: 1px solid var(--accent);\n font-style: italic;\n}\n.post-content blockquote::before { content: \"”\"; position: absolute; top: -40px; left: 10px; font-size: 3.875rem; font-style: normal; color: var(--accent); }\n.post-content blockquote p:last-child { margin-bottom: 0; }\n.post-content hr { border: 0; border-top: 1px dashed var(--accent); margin: 40px 0; }\n.post-content table { width: 100%; border-collapse: collapse; margin: 20px 0; }\n.post-content th, .post-content td { padding: 8px 12px; border: 1px dashed var(--accent); text-align: left; }\n.post-content th { color: var(--accent); }\n.post-content code { padding: 1px 6px; background: var(--code-bg); font-family: inherit; font-size: 0.95em; }\n.post-content pre {\n margin: 20px 0;\n padding: 20px;\n background: var(--code-bg);\n border-left: 3px solid var(--accent);\n overflow-x: auto;\n font-family: inherit;\n font-size: 0.95rem;\n line-height: 1.5;\n}\n.post-content pre code { padding: 0; background: none; font-size: inherit; }\n.math-block { overflow-x: auto; margin: 20px 0; }\nmark { background: var(--mark); color: inherit; padding: 0 0.15em; }\n\n/* Wikilinks and tasks. */\na.wikilink { color: var(--accent); }\na.wikilink-missing { color: var(--missing); text-decoration-style: dotted; }\nli.task { list-style: none; margin-left: -24px; }\nli.task input[type=\"checkbox\"] { accent-color: var(--accent); margin-right: 6px; vertical-align: -0.1em; }\nli.task.done { color: var(--color-muted); text-decoration: line-through; }\n.task-tag { display: inline-block; padding: 0 6px; margin-right: 4px; border: 1px solid var(--accent); color: var(--accent); font-size: 0.8rem; }\n.task-tag.cancelled { text-decoration: line-through; }\n\n/* Buttons, frames, the table of contents and pagination. */\n.button {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n padding: 8px 18px;\n border: 1px solid var(--accent);\n color: var(--accent);\n font: inherit;\n text-decoration: none;\n background: transparent;\n cursor: pointer;\n}\n.button:hover { background: var(--accent); color: var(--background); }\n.button.inline { display: inline-flex; }\n.read-more { margin-top: 10px; }\n.all-posts { margin: 30px 0 0; }\n/* The contents page of a docs site: the outline as a tree, then any page it leaves out. */\n.page-index-list { list-style: none; margin: 0; padding: 0; }\n.page-index-list .page-index-list { padding-left: 24px; }\n.page-index-list li { margin: 6px 0; }\n.page-index-list li::before { content: \"- \"; color: var(--accent); }\n.page-index-list a { color: var(--color); }\n.page-index-list a:hover { color: var(--accent); }\n.page-index-group-label { color: var(--color-muted); }\n.page-index-group-label::after { content: \":\"; }\n.page-index-other { margin: 30px 0 10px; font-size: 1.1rem; }\n.page-index-other::before { content: \"## \"; color: var(--accent); }\n/* The archives: a year as a prompt-style heading, then one line per post. */\n.archive-year { margin: 30px 0 10px; font-size: 1.1rem; }\n.archive-year::before { content: \"## \"; color: var(--accent); }\n.archive-list { list-style: none; margin: 0; padding: 0; }\n.archive-list li { margin: 6px 0; }\n.archive-list time { color: var(--color-muted); margin-right: 12px; font-variant-numeric: tabular-nums; }\n.archive-list a { color: var(--color); }\n.archive-list a:hover { color: var(--accent); }\n.archive-excerpt { color: var(--color-muted); }\n.framed { border: 1px solid var(--accent); padding: 20px; }\n.toc { margin: 20px 0 40px; }\n.toc__title { margin: 0 0 10px; color: var(--accent); }\n.toc ul { list-style: none; margin: 0; padding: 0; }\n.toc li { margin: 4px 0; }\n.toc li::before { content: \"- \"; color: var(--accent); }\n.toc li.toc-level-3 { padding-left: 1.5rem; }\n.toc li.toc-level-4 { padding-left: 3rem; }\n.toc a { color: var(--color); text-decoration: none; }\n.toc a:hover { color: var(--accent); }\n.pagination { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin-top: 50px; }\n.pagination__next { margin-left: auto; }\n.button__text { max-width: 20rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n\n/* Linked references, framed like everything else worth a box. */\n.backlinks { margin-top: 40px; border: 1px solid var(--accent); padding: 20px; }\n.backlinks h2 { margin: 0 0 12px; font-size: 1rem; color: var(--accent); }\n.backlinks h2::before { content: \":: \"; }\n.backlink-list { list-style: none; margin: 0; padding: 0; }\n.backlink-source { margin: 0 0 12px; }\n.backlink-source > a { color: var(--color); }\n.backlink-source ul { list-style: none; margin: 4px 0 0; padding: 0 0 0 16px; border-left: 1px dashed var(--accent); }\n.backlink-context { color: var(--color-muted); font-size: 0.9rem; margin: 2px 0; }\n\n/* -- Footer ----------------------------------------------------------------------------- */\n.footer { margin-top: 60px; padding-top: 20px; border-top: 1px dotted var(--accent); font-size: 0.9rem; color: var(--color-muted); }\n.footer__inner { display: flex; flex-wrap: wrap; gap: 10px; }\n.copyright { display: flex; flex-wrap: wrap; gap: 0 8px; }\n.footer__separator { color: var(--accent); }\n\n/* Code tokens (lezer classHighlighter names). */\n/* Code tokens (lezer classHighlighter names), in tones of the one ink: weight and italics carry\n the distinction the usual colours would. */\n.tok-keyword { color: var(--accent); font-weight: bold; }\n.tok-string, .tok-string2 { color: var(--color); font-style: italic; }\n.tok-comment, .tok-lineComment, .tok-blockComment { color: var(--color-muted); font-style: italic; }\n.tok-number, .tok-bool, .tok-null, .tok-atom { color: var(--accent); }\n.tok-function, .tok-definition.tok-variableName { color: var(--accent); }\n.tok-typeName, .tok-className, .tok-namespace { color: var(--accent); font-weight: bold; }\n.tok-operator, .tok-punctuation { color: var(--color-muted); }\n.tok-propertyName, .tok-attributeName { color: var(--color); }\n.tok-tagName { color: var(--accent); }\n.tok-invalid { color: var(--missing); text-decoration: underline wavy; }\n\n/* -- Small screens: the menu behind a Terminal-style button ----------------------------- */\n@media (max-width: 684px) {\n .container { padding: 20px; }\n .nav-toggle {\n display: inline-block;\n padding: 5px 10px;\n border: 1px solid var(--accent);\n background: transparent;\n color: var(--accent);\n font: inherit;\n cursor: pointer;\n }\n .nav-toggle::before { content: \"$ \"; }\n .menu { display: none; flex-direction: column; align-items: stretch; gap: 12px; margin-top: 20px; padding: 20px; border: 1px solid var(--accent); }\n body.nav-open .menu { display: flex; }\n .menu__inner { flex-direction: column; gap: 8px; }\n .menu__inner li { flex-direction: column; align-items: flex-start; }\n .menu__inner .site-nav-list { flex-direction: column; padding-left: 12px; }\n .menu__inner .site-nav-list li::before { margin-right: 6px; }\n .site-search { margin-left: 0; flex-basis: auto; }\n .post-title { font-size: 1.3rem; }\n .pagination__prev, .pagination__next { flex-basis: 100%; }\n .pagination__next { justify-content: flex-end; }\n}\n",
13921
14450
  "../etherpk-terminal/layouts/404.html": "{{> shell-top}}\n <article class=\"post\">\n <h1 class=\"post-title\">404 :: not published</h1>\n <div class=\"post-content\">\n <p>$ the page you followed a link to is not part of this site.</p>\n <p><a class=\"button inline\" href=\"index.html\">← {{site.title}}</a></p>\n </div>\n </article>\n{{> shell-bottom}}\n",
13922
14451
  "../etherpk-terminal/layouts/archive.html": "{{> shell-top}}\n <article class=\"post archive\">\n <h1 class=\"post-title index-title\">{{#page.isPostsArchive}}posts{{/page.isPostsArchive}}{{#page.isJournalArchive}}journal{{/page.isJournalArchive}}</h1>\n {{#page.isPostsArchive}}\n {{#site.postsByYear}}\n <h2 class=\"archive-year\" id=\"y{{year}}\">{{year}}</h2>\n <ul class=\"archive-list\">\n {{#posts}}<li><time datetime=\"{{date}}\">{{date}}</time> <a href=\"{{url}}\">{{title}}</a></li>{{/posts}}\n </ul>\n {{/site.postsByYear}}\n {{/page.isPostsArchive}}\n {{#page.isJournalArchive}}\n <ul class=\"archive-list\">\n {{#site.journals}}<li><time datetime=\"{{date}}\">{{date}}</time> <a href=\"{{url}}\">{{title}}</a>{{#excerpt}} <span class=\"archive-excerpt\">{{excerpt}}</span>{{/excerpt}}</li>{{/site.journals}}\n </ul>\n {{/page.isJournalArchive}}\n </article>\n{{> shell-bottom}}\n",
13923
- "../etherpk-terminal/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"post home-info\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}\n <h1 class=\"post-title index-title\">{{site.title}}</h1>\n {{/page.isGeneratedIndex}}\n <div class=\"posts\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> post-entry}}{{/site.recentPosts}}{{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day; a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<div class=\"post-content\">{{{page.content}}}</div>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </div>\n {{#site.isBlog}}{{#site.hasPosts}}\n <p class=\"all-posts\"><a class=\"button inline\" href=\"{{site.postsUrl}}\">$ ls all {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}{{/site.isBlog}}\n{{> shell-bottom}}\n",
14452
+ "../etherpk-terminal/layouts/home.html": "{{> shell-top}}\n {{^page.isGeneratedIndex}}\n <article class=\"post home-info\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n </article>\n {{/page.isGeneratedIndex}}\n {{#page.isGeneratedIndex}}\n <h1 class=\"post-title index-title\">{{site.title}}</h1>\n {{/page.isGeneratedIndex}}\n <div class=\"posts\">\n {{#site.isBlog}}{{#site.recentPosts}}{{> post-entry}}{{/site.recentPosts}}{{^site.hasPosts}}<p class=\"post-list-empty\">No dated documents are published yet. A journal entry is dated by its day, a page by a <code>date:</code> line in its frontmatter.</p>{{/site.hasPosts}}{{/site.isBlog}}\n {{^site.isBlog}}{{#page.isGeneratedIndex}}<div class=\"post-content\">{{{page.content}}}</div>{{/page.isGeneratedIndex}}{{/site.isBlog}}\n </div>\n {{#site.isBlog}}{{#site.hasPosts}}\n <p class=\"all-posts\"><a class=\"button inline\" href=\"{{site.postsUrl}}\">$ ls all {{site.postCount}} posts →</a></p>\n {{/site.hasPosts}}{{/site.isBlog}}\n{{> shell-bottom}}\n",
13924
14453
  "../etherpk-terminal/layouts/journal.html": "{{> shell-top}}\n <article class=\"post\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n{{> post-meta}}\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <nav class=\"pagination\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"button pagination__prev\" href=\"{{url}}\"><span class=\"button__icon\">←</span><span class=\"button__text\">{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"button pagination__next\" href=\"{{url}}\"><span class=\"button__text\">{{title}}</span><span class=\"button__icon\">→</span></a>{{/page.newer}}\n </nav>\n </article>\n{{> shell-bottom}}\n",
13925
14454
  "../etherpk-terminal/layouts/page.html": "{{> shell-top}}\n <article class=\"post\">\n <h1 class=\"post-title\">{{{page.titleHtml}}}</h1>\n{{> post-meta}}\n{{> toc}}\n <div class=\"post-content\">\n{{{page.content}}}\n </div>\n <nav class=\"pagination\" aria-label=\"Neighbouring posts\">\n {{#page.older}}<a class=\"button pagination__prev\" href=\"{{url}}\"><span class=\"button__icon\">←</span><span class=\"button__text\">{{title}}</span></a>{{/page.older}}\n {{#page.newer}}<a class=\"button pagination__next\" href=\"{{url}}\"><span class=\"button__text\">{{title}}</span><span class=\"button__icon\">→</span></a>{{/page.newer}}\n </nav>\n </article>\n{{> shell-bottom}}\n",
13926
14455
  "../etherpk-terminal/partials/after-content.html": "",
@@ -13989,134 +14518,43 @@ function bundledThemeList() {
13989
14518
  //#region ../client/src/lib/document/frontmatter/patch.ts
13990
14519
  /**
13991
14520
  * Rewriting arbitrary keys of a document's [[Frontmatter]] block, the way `withFrontmatterIdentity`
13992
- * rewrites identity: other keys keep their values and order, the same string comes back when
13993
- * nothing would change, and a block whose YAML does not parse is left alone rather than
14521
+ * rewrites identity: other keys keep their values, order and comments, the same string comes back
14522
+ * when nothing would change, and a block whose YAML does not parse is left alone rather than
13994
14523
  * destroyed. `null` removes a key. A block emptied of every key is removed with it. Identity keys
13995
14524
  * are not this function's business: pass them through `withFrontmatterIdentity` so the registry
13996
14525
  * rules (ADR 0061) apply.
13997
14526
  */
13998
- function parseBlock$1(yaml) {
13999
- try {
14000
- const parsed = parse(yaml);
14001
- if (parsed === null || parsed === void 0) return {};
14002
- if (typeof parsed !== "object" || Array.isArray(parsed)) return null;
14003
- return parsed;
14004
- } catch {
14005
- return null;
14006
- }
14007
- }
14008
- function same(a, b) {
14009
- return JSON.stringify(a) === JSON.stringify(b);
14010
- }
14011
14527
  function withFrontmatterPatch(text, patch, options = {}) {
14012
- const span = frontmatterSpan(text);
14013
- if (!span) {
14014
- if (!options.addBlock) return text;
14015
- const data = {};
14016
- for (const [key, value] of Object.entries(patch)) if (value !== null && value !== void 0) data[key] = value;
14017
- if (Object.keys(data).length === 0) return text;
14018
- return `---\n${stringify(data)}---\n${text}`;
14019
- }
14020
- const data = parseBlock$1(span.body);
14021
- if (data === null) return text;
14022
- const next = {};
14023
- let changed = false;
14024
- for (const [key, value] of Object.entries(data)) {
14025
- if (key in patch) {
14026
- const wanted = patch[key];
14027
- if (wanted === null || wanted === void 0) {
14028
- changed = true;
14029
- continue;
14030
- }
14031
- next[key] = wanted;
14032
- if (!same(wanted, value)) changed = true;
14033
- continue;
14034
- }
14035
- next[key] = value;
14036
- }
14037
- for (const [key, value] of Object.entries(patch)) {
14038
- if (key in data || value === null || value === void 0) continue;
14039
- next[key] = value;
14040
- changed = true;
14041
- }
14042
- if (!changed) return text;
14043
- if (Object.keys(next).length === 0) return text.slice(span.end);
14044
- return `---\n${stringify(next)}---\n${text.slice(span.end)}`;
14528
+ return editFrontmatter(text, (block) => {
14529
+ for (const [key, value] of Object.entries(patch)) if (value === null || value === void 0) block.delete(key);
14530
+ else block.set(key, value);
14531
+ }, options);
14045
14532
  }
14046
14533
  //#endregion
14047
14534
  //#region ../client/src/lib/document/frontmatter/publishing.ts
14048
14535
  /**
14049
14536
  * The publishing keys of a document's [[Frontmatter]] (ADR 0082): `public: true` is the consent
14050
- * switch, `publications: [docs, blog]` routes. A public document with an empty list keeps the
14051
- * key as `publications: []`: public with nowhere to go is a state worth a prompt, and the empty
14052
- * key is that prompt to whoever edits the file by hand. Not public, the empty key is removed. Rewritten the way `withFrontmatterIdentity`
14053
- * rewrites identity: other keys keep their values and order, the same string comes back when
14054
- * nothing would change, and a block whose YAML does not parse is left alone rather than
14055
- * destroyed. Read through `readMembership` in `publish/publication.ts`; this file only writes.
14056
- */
14057
- function parseBlock(yaml) {
14058
- try {
14059
- const parsed = parse(yaml);
14060
- if (parsed === null || parsed === void 0) return {};
14061
- if (typeof parsed !== "object" || Array.isArray(parsed)) return null;
14062
- return parsed;
14063
- } catch {
14064
- return null;
14065
- }
14066
- }
14067
- function sameList(a, b) {
14068
- return Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
14069
- }
14537
+ * switch, and `publications`, a list of publication ids, routes. A public document with no
14538
+ * publication keeps the key, written bare (`publications:`): public with nowhere to go is a state
14539
+ * worth a prompt, and the empty key is that prompt to whoever edits the file by hand. Bare rather
14540
+ * than `[]` because that is how the EtherPK style writes every empty value (ADR 0108), and because
14541
+ * Enter after a bare key opens the next line one level in, ready for the first `- id`. Not public, the empty key is
14542
+ * removed. Rewritten the way `withFrontmatterIdentity` rewrites identity: other keys keep their
14543
+ * values and order, the same string comes back when nothing would change, and a block whose YAML
14544
+ * does not parse is left alone rather than destroyed. Read through `readMembership` in
14545
+ * `publish/publication.ts`, which reads a bare key and `[]` alike; this file only writes.
14546
+ */
14070
14547
  /** The text with its publishing keys rewritten. `addBlock` adds a block to a document without one. */
14071
14548
  function withPublishing(text, patch, options = {}) {
14072
14549
  const publications = patch.publications === void 0 || patch.publications === null ? patch.publications : [...new Set(patch.publications.filter(isPublicationId))];
14073
- const span = frontmatterSpan(text);
14074
- if (!span) {
14075
- if (!options.addBlock) return text;
14076
- const data = {};
14077
- if (patch.public === true || patch.public === false) data.public = patch.public;
14078
- if (publications && (publications.length > 0 || patch.public === true)) data.publications = publications;
14079
- if (Object.keys(data).length === 0) return text;
14080
- return `---\n${stringify(data)}---\n${text}`;
14081
- }
14082
- const data = parseBlock(span.body);
14083
- if (data === null) return text;
14084
- const next = {};
14085
- let changed = false;
14086
- const wantPublic = patch.public === void 0 ? data.public : patch.public;
14087
- const keepEmpty = wantPublic === true;
14088
- const wantPublications = publications === void 0 ? data.publications : publications && (publications.length > 0 || keepEmpty) ? publications : null;
14089
- for (const [key, value] of Object.entries(data)) {
14090
- if (key === "public") {
14091
- if (wantPublic === null || wantPublic === void 0) changed = true;
14092
- else {
14093
- next.public = wantPublic;
14094
- if (wantPublic !== value) changed = true;
14095
- }
14096
- continue;
14097
- }
14098
- if (key === "publications") {
14099
- if (wantPublications === null || wantPublications === void 0) changed = true;
14100
- else {
14101
- next.publications = wantPublications;
14102
- if (!sameList(value, wantPublications)) changed = true;
14103
- }
14104
- continue;
14105
- }
14106
- next[key] = value;
14107
- }
14108
- if (!("public" in data) && (wantPublic === true || wantPublic === false)) {
14109
- next.public = wantPublic;
14110
- changed = true;
14111
- }
14112
- if (!("publications" in data) && Array.isArray(wantPublications) && (wantPublications.length > 0 || keepEmpty)) {
14113
- next.publications = wantPublications;
14114
- changed = true;
14115
- }
14116
- if (!changed) return text;
14117
- const yaml = Object.keys(next).length === 0 ? "" : stringify(next);
14118
- if (yaml === "") return text.slice(span.end);
14119
- return `---\n${yaml}---\n${text.slice(span.end)}`;
14550
+ return editFrontmatter(text, (block) => {
14551
+ if (patch.public === null) block.delete("public");
14552
+ else if (patch.public !== void 0) block.set("public", patch.public);
14553
+ if (publications === void 0) return;
14554
+ const keepEmpty = (patch.public === void 0 ? block.get("public") : patch.public) === true;
14555
+ if (publications === null || publications.length === 0 && !keepEmpty) block.delete("publications");
14556
+ else block.set("publications", publications.length === 0 ? null : publications);
14557
+ }, options);
14120
14558
  }
14121
14559
  //#endregion
14122
14560
  //#region ../client/src/lib/document/publish/seeded.ts
@@ -14130,7 +14568,7 @@ function seededFiles(publication) {
14130
14568
  return new Map([
14131
14569
  [PAGES_WORKFLOW_PATH, `# Publishes this folder to GitHub Pages as it is: the site is already built by EtherPK,
14132
14570
  # so there is no build step. In the repository's Settings → Pages, set the source to
14133
- # "GitHub Actions" once; every push to main then deploys.
14571
+ # "GitHub Actions" once - every push to main then deploys.
14134
14572
  name: Deploy to GitHub Pages
14135
14573
  on:
14136
14574
  push:
@@ -14161,7 +14599,7 @@ jobs:
14161
14599
  [WRANGLER_PATH, `// Cloudflare Workers with static assets (Cloudflare's successor to Pages for new sites).
14162
14600
  // Deploy with \`npx wrangler deploy\`, or connect the repository in the dashboard under
14163
14601
  // Workers → Create → Import a repository and leave the build command empty. The Worker is
14164
- // named after the publication; Cloudflare warns when this name and the Worker's differ.
14602
+ // named after the publication - Cloudflare warns when this name and the Worker's differ.
14165
14603
  {
14166
14604
  "name": "${publication.id}",
14167
14605
  "compatibility_date": "2026-01-01",
@@ -14179,9 +14617,9 @@ This folder is a website published by [EtherPK](https://etherpk.com) from the pu
14179
14617
  publish, so edit the notes, the publication page, its include pages and its theme in EtherPK,
14180
14618
  not here.
14181
14619
 
14182
- - Preview: open \`index.html\` in a browser; search works from disk too.
14620
+ - Preview: open \`index.html\` in a browser - search works from disk too.
14183
14621
  - GitHub Pages: push this folder to a repository and set Settings → Pages → Source to
14184
- "GitHub Actions"; \`.github/workflows/pages.yaml\` deploys every push to \`main\`.
14622
+ "GitHub Actions" - \`.github/workflows/pages.yaml\` deploys every push to \`main\`.
14185
14623
  - Cloudflare: \`npx wrangler deploy\` uses \`wrangler.jsonc\`, or connect the repository in the
14186
14624
  Workers dashboard with no build command.
14187
14625
  - Netlify, GitLab Pages, Vercel, S3 or your own server: the folder is plain files, so any of
@@ -14189,7 +14627,7 @@ not here.
14189
14627
  `],
14190
14628
  [GITIGNORE_PATH, `.DS_Store\nThumbs.db\n`],
14191
14629
  [ASSETS_IGNORE_PATH, `# Files in this folder that are not part of the website, kept out of the Worker's assets.
14192
- # Written once by EtherPK; yours to edit.
14630
+ # Written once by EtherPK - yours to edit.
14193
14631
  .git
14194
14632
  .github
14195
14633
  .gitignore
@@ -14213,15 +14651,15 @@ This folder is a website generated by [EtherPK](https://etherpk.com) from the pu
14213
14651
  \`robots.txt\` and the text outside this section of \`AGENTS.md\` is rewritten on each publish
14214
14652
  and files that are no longer produced are deleted.
14215
14653
 
14216
- - Do not edit the HTML, the assets or \`theme/\` here; the change is lost on the next publish.
14654
+ - Do not edit the HTML, the assets or \`theme/\` here - the change is lost on the next publish.
14217
14655
  - Content changes are made to the documents in EtherPK. A document is published when its
14218
14656
  frontmatter says \`public: true\` and names this publication in \`publications: [${publication.id}]\`
14219
14657
  (unless the publication takes every public document).
14220
- - The navigation is the outline of the publication page in EtherPK; the theme and the include
14658
+ - The navigation is the outline of the publication page in EtherPK - the theme and the include
14221
14659
  slots (head, logo, header, footer, before-content, after-content, scripts, styles) are chosen
14222
14660
  and edited there too, in the Publish settings and the Theme editor.
14223
14661
  - \`index.html\` opens from disk for a preview. \`.github/workflows/pages.yaml\` deploys the folder
14224
- to GitHub Pages; \`wrangler.jsonc\` deploys it to Cloudflare Workers; any static host serves it
14662
+ to GitHub Pages, \`wrangler.jsonc\` deploys it to Cloudflare Workers, and any static host serves it
14225
14663
  as it is (https://docs.etherpk.com/hosting-a-published-site).
14226
14664
 
14227
14665
  This section is rewritten by EtherPK on every publish. Put your own notes above or below it.
@@ -15107,7 +15545,7 @@ function buildNav(outline, resolver) {
15107
15545
  issues.push({
15108
15546
  level: "warning",
15109
15547
  code: "nav-entry-not-published",
15110
- message: `The navigation names "${whole.concept}", which is not in this publication; the entry is left out.`
15548
+ message: `The navigation names "${whole.concept}", which is not in this publication - the entry is left out.`
15111
15549
  });
15112
15550
  continue;
15113
15551
  }
@@ -15128,7 +15566,7 @@ function buildNav(outline, resolver) {
15128
15566
  issues.push({
15129
15567
  level: "warning",
15130
15568
  code: "nav-link-unsafe",
15131
- message: `The navigation links "${label}" to a target a published page does not carry (only http, https, mailto and site paths); the entry is shown without its link.`
15569
+ message: `The navigation links "${label}" to a target a published page does not carry (only http, https, mailto and site paths) - the entry is shown without its link.`
15132
15570
  });
15133
15571
  out.push({
15134
15572
  label,
@@ -15211,7 +15649,7 @@ function allocate(documents) {
15211
15649
  const derived = [];
15212
15650
  for (const doc of documents) {
15213
15651
  const value = parseFrontmatter(doc.text).data.slug;
15214
- if (value === void 0) {
15652
+ if (isEmptyValue(value)) {
15215
15653
  derived.push(doc);
15216
15654
  continue;
15217
15655
  }
@@ -15298,7 +15736,6 @@ function createThemeRenderer(theme, includes) {
15298
15736
  * a pre-render pass for the asynchronous bits (diagrams, code); then rendering, includes, the
15299
15737
  * theme, the derived files. Everything derived is computed from the included documents alone.
15300
15738
  */
15301
- var DATE = /^\d{4}-\d{2}-\d{2}$/;
15302
15739
  function assetHrefOf(name) {
15303
15740
  return `assets/${encodeURIComponent(name)}`;
15304
15741
  }
@@ -15366,7 +15803,7 @@ async function publishPublication(source, publication, env, options = {}) {
15366
15803
  if (homeKey !== null && !homeDoc) warnings.push({
15367
15804
  level: "warning",
15368
15805
  code: "home-not-published",
15369
- message: `The home page "${publication.home}" is not in this publication; the front page lists the published pages instead.`
15806
+ message: `The home page "${publication.home}" is not in this publication - the front page lists the published pages instead.`
15370
15807
  });
15371
15808
  const allocation = allocateSlugs.withReport(included.filter((d) => d !== homeDoc));
15372
15809
  const slugs = new Map(allocation.slugs);
@@ -15375,13 +15812,13 @@ async function publishPublication(source, publication, env, options = {}) {
15375
15812
  for (const c of allocation.collisions) if (c.explicit) warnings.push({
15376
15813
  level: "warning",
15377
15814
  code: "slug-taken",
15378
- message: `"${c.concept}" asks for the address ${c.wanted}.html in its frontmatter, but another document has it; it is at ${c.slug}.html.`,
15815
+ message: `"${c.concept}" asks for the address ${c.wanted}.html in its frontmatter, but another document has it - it is at ${c.slug}.html.`,
15379
15816
  concept: c.concept
15380
15817
  });
15381
15818
  else info.push({
15382
15819
  level: "info",
15383
15820
  code: "slug-collision",
15384
- message: `"${c.concept}" wanted the address ${c.wanted}.html, which another document has; it is at ${c.slug}.html.`,
15821
+ message: `"${c.concept}" wanted the address ${c.wanted}.html, which another document has - it is at ${c.slug}.html.`,
15385
15822
  concept: c.concept
15386
15823
  });
15387
15824
  for (const ignoredSlug of allocation.ignored) {
@@ -15389,7 +15826,7 @@ async function publishPublication(source, publication, env, options = {}) {
15389
15826
  warnings.push({
15390
15827
  level: "warning",
15391
15828
  code: "slug-ignored",
15392
- message: `"${ignoredSlug.concept}" has \`slug: ${ignoredSlug.value}\`, which ${why}; its address is derived from its name instead.`,
15829
+ message: `"${ignoredSlug.concept}" has \`slug: ${ignoredSlug.value}\`, which ${why} - its address is derived from its name instead.`,
15393
15830
  concept: ignoredSlug.concept
15394
15831
  });
15395
15832
  }
@@ -15439,12 +15876,12 @@ async function publishPublication(source, publication, env, options = {}) {
15439
15876
  if (theme.manifest.contract < 1) warnings.push({
15440
15877
  level: "warning",
15441
15878
  code: "theme-older-contract",
15442
- message: `The theme "${theme.manifest.name}" was written for view contract ${theme.manifest.contract}; this EtherPK writes contract 1. It is rendered as is; newer view fields are simply absent to it.`
15879
+ message: `The theme "${theme.manifest.name}" was written for view contract ${theme.manifest.contract} - this EtherPK writes contract 1. It is rendered as is - newer view fields are simply absent to it.`
15443
15880
  });
15444
15881
  if (!theme.manifest.kinds.includes(publication.kind)) warnings.push({
15445
15882
  level: "warning",
15446
15883
  code: "theme-kind-mismatch",
15447
- message: `The theme "${theme.manifest.name}" is made for ${theme.manifest.kinds.join(" and ")} publications; this one is ${publication.kind}.`
15884
+ message: `The theme "${theme.manifest.name}" is made for ${theme.manifest.kinds.join(" and ")} publications - this one is ${publication.kind}.`
15448
15885
  });
15449
15886
  const includeDocs = /* @__PURE__ */ new Map();
15450
15887
  for (const [slot, concept] of Object.entries(publication.includes)) {
@@ -15454,7 +15891,7 @@ async function publishPublication(source, publication, env, options = {}) {
15454
15891
  warnings.push({
15455
15892
  level: "warning",
15456
15893
  code: "include-page-missing",
15457
- message: `The include "${slot}" names "${concept}", which is not a document in this graph; the theme's own ${slot} is used.`
15894
+ message: `The include "${slot}" names "${concept}", which is not a document in this graph - the theme's own ${slot} is used.`
15458
15895
  });
15459
15896
  continue;
15460
15897
  }
@@ -15469,7 +15906,7 @@ async function publishPublication(source, publication, env, options = {}) {
15469
15906
  warnings.push({
15470
15907
  level: "warning",
15471
15908
  code: `include-page-${status}`,
15472
- message: `The include "${slot}" names "${concept}", which ${why}; the theme's own ${slot} is used.`
15909
+ message: `The include "${slot}" names "${concept}", which ${why} - the theme's own ${slot} is used.`
15473
15910
  });
15474
15911
  continue;
15475
15912
  }
@@ -15542,17 +15979,9 @@ async function publishPublication(source, publication, env, options = {}) {
15542
15979
  };
15543
15980
  if (doc.kind === "journal") page.date = doc.concept;
15544
15981
  else {
15545
- const date = parseFrontmatter(doc.text).data.date;
15546
- if (date !== void 0) {
15547
- const text = date instanceof Date ? date.toISOString().slice(0, 10) : String(date);
15548
- if (DATE.test(text)) page.date = text;
15549
- else warnings.push({
15550
- level: "warning",
15551
- code: "invalid-date",
15552
- message: `"${doc.concept}" has \`date: ${String(date)}\`, which is not a calendar day (YYYY-MM-DD); the document is undated.`,
15553
- concept: doc.concept
15554
- });
15555
- }
15982
+ const dated = readPageDate(doc);
15983
+ if (dated.date !== void 0) page.date = dated.date;
15984
+ take(dated.issues);
15556
15985
  }
15557
15986
  for (const link of linksOf(bodyOf(doc), resolver, doc)) report.missingLinks.push(link);
15558
15987
  pages.push(page);
@@ -15591,7 +16020,7 @@ async function publishPublication(source, publication, env, options = {}) {
15591
16020
  warnings.push({
15592
16021
  level: "warning",
15593
16022
  code: "include-unknown-slot",
15594
- message: `The theme "${theme.manifest.name}" has no include slot "${slot}"; the page "${doc.concept}" is not used.`
16023
+ message: `The theme "${theme.manifest.name}" has no include slot "${slot}" - the page "${doc.concept}" is not used.`
15595
16024
  });
15596
16025
  continue;
15597
16026
  }
@@ -15602,7 +16031,7 @@ async function publishPublication(source, publication, env, options = {}) {
15602
16031
  warnings.push({
15603
16032
  level: "warning",
15604
16033
  code: "include-no-css",
15605
- message: `The include "${slot}" names "${doc.concept}", which has no \`css\` fence; nothing is appended to the stylesheet.`
16034
+ message: `The include "${slot}" names "${doc.concept}", which has no \`css\` fence - nothing is appended to the stylesheet.`
15606
16035
  });
15607
16036
  continue;
15608
16037
  }
@@ -15778,7 +16207,7 @@ async function publishPublication(source, publication, env, options = {}) {
15778
16207
  warnings.push({
15779
16208
  level: "warning",
15780
16209
  code: "asset-missing",
15781
- message: `"${from}" references ${name}, which the graph does not hold; the link is left as it is.`,
16210
+ message: `"${from}" references ${name}, which the graph does not hold - the link is left as it is.`,
15782
16211
  concept: from
15783
16212
  });
15784
16213
  continue;
@@ -15794,7 +16223,7 @@ async function publishPublication(source, publication, env, options = {}) {
15794
16223
  else warnings.push({
15795
16224
  level: "warning",
15796
16225
  code: "katex-assets-unavailable",
15797
- message: "The publication has maths but this host has no KaTeX stylesheet to copy in; formulas will render unstyled."
16226
+ message: "The publication has maths but this host has no KaTeX stylesheet to copy in - formulas will render unstyled."
15798
16227
  });
15799
16228
  }
15800
16229
  const search = pages.map((p) => ({
@@ -15828,7 +16257,7 @@ async function publishPublication(source, publication, env, options = {}) {
15828
16257
  } else info.push({
15829
16258
  level: "info",
15830
16259
  code: "no-site-url",
15831
- message: "The publication has no `url`, so no sitemap or feed is written; add one to the publication page to get both."
16260
+ message: "The publication has no `url`, so no sitemap or feed is written - add one to the publication page to get both."
15832
16261
  });
15833
16262
  bundle.set(".nojekyll", "");
15834
16263
  const external = /* @__PURE__ */ new Set();
@@ -15839,7 +16268,7 @@ async function publishPublication(source, publication, env, options = {}) {
15839
16268
  if (pages.some((p) => p.rendered.mermaidFallback)) warnings.push({
15840
16269
  level: "warning",
15841
16270
  code: "mermaid-not-prerendered",
15842
- message: "A diagram was not pre-rendered on this host and is left as its source; publish from the browser, or run `etherpk-mcp publish setup`, to get inline SVG."
16271
+ message: "A diagram was not pre-rendered on this host and is left as its source - publish from the browser, or run `etherpk-mcp diagrams setup`, to get inline SVG."
15843
16272
  });
15844
16273
  progress("writing", pages.length + 3, pages.length + 3);
15845
16274
  report.ok = errors.length === 0;
@@ -15932,7 +16361,7 @@ async function fetchTheme(url, fetchText) {
15932
16361
  const { manifest, errors } = parseThemeManifest(json);
15933
16362
  if (!manifest) throw new Error(`The theme at ${url} cannot be used: ${errors.join(" ")}`);
15934
16363
  if (manifest.files.length === 0) throw new Error(`The theme at ${url} lists no files in its manifest, so nothing can be fetched.`);
15935
- if (manifest.files.length > MAX_URL_THEME_FILES) throw new Error(`The theme at ${url} lists ${manifest.files.length} files; a theme may list at most ${MAX_URL_THEME_FILES}.`);
16364
+ if (manifest.files.length > MAX_URL_THEME_FILES) throw new Error(`The theme at ${url} lists ${manifest.files.length} files - a theme may list at most ${MAX_URL_THEME_FILES}.`);
15936
16365
  const base = new URL(url);
15937
16366
  const files = /* @__PURE__ */ new Map();
15938
16367
  for (const path of manifest.files) {
@@ -16028,7 +16457,7 @@ function publicationMapping(input) {
16028
16457
  * page whose purpose was anyone's guess (2026-09-19).
16029
16458
  */
16030
16459
  function publicationPageIntro(name) {
16031
- return `This page defines the publication "${name}". Its settings are the frontmatter above; edit them in Settings → Publish rather than here. The outline below is the site's navigation: one bullet per menu entry, written as a wikilink to the page; nested bullets are sub-entries; a plain bullet with children is a group; a markdown link is an outside link. A page that is public and in this publication but not listed here is still on the site, reachable by search and links, just not in the menu.`;
16460
+ return `This page defines the publication "${name}". Its settings are the frontmatter above - edit them in Settings → Publish rather than here. The outline below is the site's navigation: one bullet per menu entry, written as a wikilink to the page. Nested bullets are sub-entries, a plain bullet with children is a group, and a markdown link is an outside link. A page that is public and in this publication but not listed here is still on the site, reachable by search and links, just not in the menu.`;
16032
16461
  }
16033
16462
  /**
16034
16463
  * Create a publication page: a page whose frontmatter defines the publication and whose body
@@ -16182,7 +16611,7 @@ async function setupDiagrams(env, say) {
16182
16611
  }
16183
16612
  const cli = require$1.resolve("playwright-core/cli.js");
16184
16613
  const dir = env.PLAYWRIGHT_BROWSERS_PATH?.trim() || browsersDir(env);
16185
- say(`Installing Chromium into ${dir} (about 170 MB, once per computer; a diagram is rendered by a browser, and this is the one etherpk-mcp drives)…`);
16614
+ say(`Installing Chromium into ${dir} (about 170 MB, once per computer - a diagram is rendered by a browser, and this is the one etherpk-mcp drives)…`);
16186
16615
  await new Promise((resolve, reject) => {
16187
16616
  const child = spawn(process.execPath, [
16188
16617
  cli,
@@ -16208,7 +16637,7 @@ async function setupDiagrams(env, say) {
16208
16637
  child.on("close", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`Playwright's installer exited with code ${code}.`)));
16209
16638
  });
16210
16639
  const after = await chromiumStatus(env);
16211
- if (!after.executable) throw new Error("The installer finished but no Chromium executable was found; set ETHERPK_CHROMIUM to a browser on this machine instead.");
16640
+ if (!after.executable) throw new Error("The installer finished but no Chromium executable was found - set ETHERPK_CHROMIUM to a browser on this machine instead.");
16212
16641
  say(`Chromium is set up: ${after.executable}`);
16213
16642
  return after;
16214
16643
  }
@@ -16765,7 +17194,7 @@ function agentFrontmatter(rawText) {
16765
17194
  /**
16766
17195
  * The block after a patch. `public` and `publications` go through the publishing writer, so its
16767
17196
  * rules hold whoever writes them: an id is lower-case letters, digits and hyphens, and a public
16768
- * document with an empty list keeps `publications: []` as the prompt it is. Everything else is a
17197
+ * document with an empty list keeps the key, written bare, as the prompt it is. Everything else is a
16769
17198
  * plain key. An invalid publication id is refused rather than dropped, which is what the
16770
17199
  * writer would do: an agent that misspells an id must hear about it.
16771
17200
  */
@@ -16839,7 +17268,7 @@ async function liveText(graph, identity) {
16839
17268
  return graph.store.open(identity.concept).getText();
16840
17269
  }
16841
17270
  function refuseIfProtected(concept, text) {
16842
- if (documentProtection(text).kind === "document") throw new ToolError("protected_document", `"${concept}" is a protected document. Its content is encrypted under a key this client never holds, so it cannot be read or changed here; the user can unlock it in EtherPK.`);
17271
+ if (documentProtection(text).kind === "document") throw new ToolError("protected_document", `"${concept}" is a protected document. Its content is encrypted under a key this client never holds, so it cannot be read or changed here - the user can unlock it in EtherPK.`);
16843
17272
  }
16844
17273
  function page$1(items, offset, limit) {
16845
17274
  return {
@@ -16948,7 +17377,24 @@ function round(similarity) {
16948
17377
  return Math.round(similarity * 1e3) / 1e3;
16949
17378
  }
16950
17379
  async function searchText(graph, query, offset, limit) {
16951
- const [result, count] = await Promise.all([graph.index.searchText(query, offset, limit), graph.index.searchTextCount(query)]);
17380
+ const keys = await graph.index.propertyKeys();
17381
+ const { words, filters } = parseSearchQuery(query, new Set(keys.map((info) => info.key.toLowerCase())));
17382
+ if (filters.length > 0 && words.trim() === "") {
17383
+ const documents = await graph.index.propertyMatch(filters);
17384
+ return {
17385
+ results: documents.slice(offset, offset + limit).map((doc) => ({
17386
+ concept: doc.concept,
17387
+ kind: doc.kind,
17388
+ matches: 0,
17389
+ hits: [],
17390
+ properties: doc.properties
17391
+ })),
17392
+ total: documents.length,
17393
+ totalCapped: false,
17394
+ hasMore: offset + limit < documents.length
17395
+ };
17396
+ }
17397
+ const [result, count] = await Promise.all([graph.index.searchText(words, offset, limit, filters), graph.index.searchTextCount(words, filters)]);
16952
17398
  return {
16953
17399
  results: result.groups.map((group) => ({
16954
17400
  concept: group.concept,
@@ -17025,7 +17471,7 @@ async function editDocument(graph, args) {
17025
17471
  const text = await liveText(graph, identity);
17026
17472
  refuseIfProtected(identity.concept, text);
17027
17473
  const first = text.indexOf(args.old);
17028
- if (first === -1) throw new ToolError("no_match", `"${identity.concept}" does not contain the text to replace. Read the document again; it may have changed.`);
17474
+ if (first === -1) throw new ToolError("no_match", `"${identity.concept}" does not contain the text to replace. Read the document again - it may have changed.`);
17029
17475
  if (text.indexOf(args.old, first + 1) !== -1) throw new ToolError("ambiguous_match", `The text to replace occurs more than once in "${identity.concept}". Include more surrounding text so it matches exactly once.`);
17030
17476
  const insert = normaliseIndentUnit(args.new);
17031
17477
  graph.store.open(identity.concept).applyChange({
@@ -17072,7 +17518,7 @@ async function appendDocument(graph, args) {
17072
17518
  async function createPage(graph, args) {
17073
17519
  const title = args.title.trim();
17074
17520
  if (title === "") throw new ToolError("invalid_argument", "title must not be empty.");
17075
- if (isJournalConcept(title)) throw new ToolError("invalid_argument", `"${title}" is a calendar day, so it names a journal entry; use append_document to write to it.`);
17521
+ 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.`);
17076
17522
  await graph.store.refresh();
17077
17523
  if (resolveIdentity(graph, title)) throw new ToolError("already_exists", `A document already answers to "${title}". Use edit_document or append_document instead.`);
17078
17524
  await graph.store.createPage(title, normaliseIndentUnit(args.text ?? ""));
@@ -17402,7 +17848,7 @@ async function listAssets(graph, args = {}) {
17402
17848
  */
17403
17849
  async function readDocuments(graph, args) {
17404
17850
  if (!Array.isArray(args.concepts) || args.concepts.length === 0) throw new ToolError("invalid_argument", "concepts must be a non-empty list of names.");
17405
- if (args.concepts.length > 20) throw new ToolError("too_many", `At most 20 documents per call; ask again for the rest.`);
17851
+ if (args.concepts.length > 20) throw new ToolError("too_many", `At most 20 documents per call - ask again for the rest.`);
17406
17852
  await graph.store.refresh();
17407
17853
  let budget = READ_MANY_TEXT_CAP;
17408
17854
  const documents = [];
@@ -17470,9 +17916,9 @@ async function setTask(graph, args) {
17470
17916
  const lines = text.split("\n");
17471
17917
  const current = lines[args.line];
17472
17918
  const parsed = current === void 0 ? null : parseTaskLine(current);
17473
- if (!parsed) throw new ToolError("task_moved", `Line ${args.line} of "${identity.concept}" is not a task now; read the document or list tasks again.`);
17919
+ if (!parsed) throw new ToolError("task_moved", `Line ${args.line} of "${identity.concept}" is not a task now - read the document or list tasks again.`);
17474
17920
  const afterCheckbox = current.replace(/^\s*-\s+\[[ xX]\]\s?/, "").trim();
17475
- if (args.expect !== void 0 && args.expect.trim() !== afterCheckbox && args.expect.trim() !== parsed.text.trim()) throw new ToolError("task_moved", `Line ${args.line} of "${identity.concept}" now reads "${parsed.text}", not "${args.expect}"; list tasks again before changing it.`);
17921
+ if (args.expect !== void 0 && args.expect.trim() !== afterCheckbox && args.expect.trim() !== parsed.text.trim()) throw new ToolError("task_moved", `Line ${args.line} of "${identity.concept}" now reads "${parsed.text}", not "${args.expect}" - list tasks again before changing it.`);
17476
17922
  const tags = { ...parsed };
17477
17923
  let done = parsed.done;
17478
17924
  switch (changes.status) {
@@ -17549,7 +17995,7 @@ async function graphInfo(graph) {
17549
17995
  complete: status.embedded >= status.total
17550
17996
  } : {
17551
17997
  state: "not-opened",
17552
- note: "The first search with mode \"semantic\" loads the model; if it is not set up on this computer, that search says so and names the command."
17998
+ note: "The first search with mode \"semantic\" loads the model - if it is not set up on this computer, that search says so and names the command."
17553
17999
  },
17554
18000
  assets: { available: graph.assets !== void 0 }
17555
18001
  };
@@ -17665,7 +18111,7 @@ async function createPublication(graph, args, host) {
17665
18111
  await graph.store.refresh();
17666
18112
  const before = summarisePublishing((await graph.publishing.readSource()).source);
17667
18113
  if (before.publications.some((p) => p.id === id)) throw new ToolError("already_exists", `A publication with the id "${id}" already exists (page "${before.publications.find((p) => p.id === id).concept}").`);
17668
- if (graph.store.listDocuments().some((d) => d.key === title.toLowerCase() || d.aliases.some((a) => a.toLowerCase() === title.toLowerCase()))) throw new ToolError("already_exists", `A document already answers to "${title}"; choose another title for the publication page.`);
18114
+ if (graph.store.listDocuments().some((d) => d.key === title.toLowerCase() || d.aliases.some((a) => a.toLowerCase() === title.toLowerCase()))) throw new ToolError("already_exists", `A document already answers to "${title}" - choose another title for the publication page.`);
17669
18115
  if (args.theme) await requireTheme(graph, args.theme);
17670
18116
  const store = frontmatterStore(graph);
17671
18117
  const concept = await createPublicationPage(store, {
@@ -17918,8 +18364,8 @@ var SAMPLE_SOURCE = {
17918
18364
  ""
17919
18365
  ].join("\n")),
17920
18366
  page("[[Getting Started]] Installing", "---\npublic: true\n---\n- A scoped concept: its heading links to its scope. Back to [[Getting Started]].\n"),
17921
- page("Wikilinks", "---\npublic: true\naliases: [Links]\n---\n- `[[Concept]]` links to a page; an alias like [[Links]] resolves to this one.\n", ["Links"]),
17922
- page("Private Notes", "- Not public, so never on the site; links to it are styled as missing.\n"),
18367
+ page("Wikilinks", "---\npublic: true\naliases: [Links]\n---\n- `[[Concept]]` links to a page - an alias like [[Links]] resolves to this one.\n", ["Links"]),
18368
+ page("Private Notes", "- Not public, so never on the site - links to it are styled as missing.\n"),
17923
18369
  {
17924
18370
  concept: "2026-06-02",
17925
18371
  kind: "journal",
@@ -18142,7 +18588,7 @@ async function customisePublicationTheme(graph, args) {
18142
18588
  await graph.store.refresh();
18143
18589
  const { source } = await graph.publishing.readSource();
18144
18590
  const publication = summarisePublishing(source).publications.find((p) => p.id === args.publication.trim());
18145
- if (!publication) throw new ToolError("publication_not_found", `No publication has the id "${args.publication}"; list_publications shows them.`);
18591
+ if (!publication) throw new ToolError("publication_not_found", `No publication has the id "${args.publication}" - list_publications shows them.`);
18146
18592
  const current = await graph.themes.get(publication.theme);
18147
18593
  if (current) return {
18148
18594
  created: false,
@@ -18178,7 +18624,7 @@ async function requireEditable(graph, id) {
18178
18624
  const own = await graph.themes.get(id);
18179
18625
  if (own) return own;
18180
18626
  if (bundledTheme(id)) throw new ToolError("theme_not_editable", `"${id}" is a bundled theme and cannot be edited in place. create_theme (or customise_publication_theme for the publication that uses it) makes a copy in the graph that can be.`);
18181
- throw new ToolError("theme_not_found", `No theme in this graph is called "${id}"; list_themes shows them.`);
18627
+ throw new ToolError("theme_not_found", `No theme in this graph is called "${id}" - list_themes shows them.`);
18182
18628
  }
18183
18629
  function requireThemePath(path) {
18184
18630
  const clean = path.trim().replace(/^\.?\//, "");
@@ -18278,7 +18724,7 @@ async function importThemeFolder(graph, args) {
18278
18724
  } catch (error) {
18279
18725
  throw new ToolError("invalid_argument", `Cannot read "${args.dir}": ${error instanceof Error ? error.message : String(error)}`);
18280
18726
  }
18281
- if (!files.has("theme.json")) throw new ToolError("invalid_argument", `"${args.dir}" has no theme.json; a theme folder has one at its top.`);
18727
+ if (!files.has("theme.json")) throw new ToolError("invalid_argument", `"${args.dir}" has no theme.json - a theme folder has one at its top.`);
18282
18728
  const next = {
18283
18729
  ...theme,
18284
18730
  files: Object.fromEntries(files),
@@ -18300,7 +18746,7 @@ async function deleteTheme(graph, args) {
18300
18746
  await graph.store.refresh();
18301
18747
  const theme = await requireEditable(graph, args.id.trim());
18302
18748
  const users = await publicationsUsing(graph, theme.id);
18303
- if (users.length > 0) throw new ToolError("theme_in_use", `"${theme.id}" is the theme of ${users.map((p) => `"${p.name}"`).join(", ")}; point ${users.length === 1 ? "that publication" : "those publications"} at another theme (update_publication) first.`);
18749
+ if (users.length > 0) throw new ToolError("theme_in_use", `"${theme.id}" is the theme of ${users.map((p) => `"${p.name}"`).join(", ")} - point ${users.length === 1 ? "that publication" : "those publications"} at another theme (update_publication) first.`);
18304
18750
  await graph.themes.remove(theme.id);
18305
18751
  await settle(graph);
18306
18752
  return {
@@ -18325,7 +18771,7 @@ async function previewTheme(graph, args, host) {
18325
18771
  if (args.publication?.trim()) {
18326
18772
  const read = await graph.publishing.readSource();
18327
18773
  const found = summarisePublishing(read.source).publications.find((p) => p.id === args.publication.trim());
18328
- if (!found) throw new ToolError("publication_not_found", `No publication has the id "${args.publication}"; list_publications shows them.`);
18774
+ if (!found) throw new ToolError("publication_not_found", `No publication has the id "${args.publication}" - list_publications shows them.`);
18329
18775
  publication = found;
18330
18776
  source = read.source;
18331
18777
  unsettled = read.unsettled;
@@ -18369,7 +18815,7 @@ async function previewTheme(graph, args, host) {
18369
18815
  ...screenshots ? { screenshots } : {},
18370
18816
  ...chromium ? {
18371
18817
  screenshots: null,
18372
- note: `No browser to photograph with; run ${chromium.setupCommand} or set ETHERPK_CHROMIUM.`
18818
+ note: `No browser to photograph with - run ${chromium.setupCommand} or set ETHERPK_CHROMIUM.`
18373
18819
  } : {}
18374
18820
  };
18375
18821
  } finally {
@@ -18483,25 +18929,25 @@ function createMcpServer(graph, info) {
18483
18929
  version: info.version
18484
18930
  }, { instructions: [
18485
18931
  `You are connected to the EtherPK knowledge graph "${info.graphName}": daily journal entries and titled pages in plain markdown, linked with [[wikilinks]].`,
18486
- "Concept names are case-insensitive. A protected document is listed with protected: true and cannot be read or written here; tell the user to unlock it in EtherPK if a task needs it.",
18487
- "Bullets are \"- \" with two-space nesting; tasks are \"- [ ]\" / \"- [x]\" with task tags (#P1 #P2 #P3, #W #D #C, #D-YYYY-MM-DD due, #S-YYYY-MM-DD scheduled) right after the checkbox.",
18488
- "read_document returns a document's body as text and its frontmatter block as data (the \"frontmatter\" object); the block is never in the text, so line numbers from search, backlinks and tasks match the text. Set frontmatter keys with set_frontmatter (public, publications, date, author, description and any other key); a document's title and aliases and a publication page's definition are not frontmatter here and have tools of their own.",
18489
- "Edit with edit_document (an exact, unique old→new replacement) or append_document; read first, then edit, and never paste a whole page back.",
18490
- "To rename a page or concept use plan_rename then rename, never a frontmatter edit: rename carries scoped concepts (\"[[Old]] Notes\") along and by default rewrites every [[Old]] link in the graph to the new name (strategy \"rewrite\"); strategy \"alias\" keeps the old name resolving instead. A rename onto a name that is taken merges two documents and needs confirm_merge: true. References inside protected documents cannot be seen or rewritten.",
18491
- "search has two kinds of matching: mode \"text\" (default) matches words and quoted phrases; mode \"semantic\" finds passages about what a question or description means even when no words match. Use semantic for questions and descriptions, text for names, identifiers and exact phrases; \"hybrid\" returns both groups. When you answer from a search result, cite the document and where in it (its breadcrumb or lines). Semantic mode needs a one-time \"semantic setup\" on this computer; if it is not set up, search says so and names the command.",
18492
- "Start with graph_info to see what you are connected to. read_documents reads several documents in one call; tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
18493
- "Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it; its outline is the site navigation) and the public documents none takes; a document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings; publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
18494
- "Themes: a publication's look is a theme - Mustache templates, a stylesheet, a script and a manifest. list_themes shows the bundled ones (read-only) and the graph's own; read_theme writes a theme's files to a folder on this machine to read and edit; customise_publication_theme copies a publication's bundled theme into the graph and points the publication at the copy (create_theme copies any theme); write_theme_file, delete_theme_file and import_theme_folder change a graph theme; preview_theme renders a theme to a folder (with screenshots when a browser is set up) to check before publish. For a snippet such as an analytics script, an include slot (update_publication includes, e.g. head) filled by a page may be lighter than a theme copy.",
18495
- "Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document; read_asset writes an asset to a local file you can open; list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session). read_asset, read_theme and preview_theme write under the graph's downloads directory and return the path; name a folder relative to it, never elsewhere.",
18932
+ "Concept names are case-insensitive. A protected document is listed with protected: true and cannot be read or written here - tell the user to unlock it in EtherPK if a task needs it.",
18933
+ "Bullets are \"- \" with two-space nesting. Tasks are \"- [ ]\" / \"- [x]\" with task tags (#P1 #P2 #P3, #W #D #C, #D-YYYY-MM-DD due, #S-YYYY-MM-DD scheduled) right after the checkbox.",
18934
+ "read_document returns a document's body as text and its frontmatter block as data (the \"frontmatter\" object) - the block is never in the text, so line numbers from search, backlinks and tasks match the text. Set frontmatter keys with set_frontmatter (public, publications, date, author, description and any other key) - a document's title and aliases and a publication page's definition are not frontmatter here and have tools of their own.",
18935
+ "Edit with edit_document (an exact, unique old→new replacement) or append_document - read first, then edit, and never paste a whole page back.",
18936
+ "To rename a page or concept use plan_rename then rename, never a frontmatter edit: rename carries scoped concepts (\"[[Old]] Notes\") along and by default rewrites every [[Old]] link in the graph to the new name (strategy \"rewrite\") - strategy \"alias\" keeps the old name resolving instead. A rename onto a name that is taken merges two documents and needs confirm_merge: true. References inside protected documents cannot be seen or rewritten.",
18937
+ "search has two kinds of matching: mode \"text\" (default) matches words and quoted phrases, and mode \"semantic\" finds passages about what a question or description means even when no words match. Use semantic for questions and descriptions, text for names, identifiers and exact phrases. \"hybrid\" returns both groups. When you answer from a search result, cite the document and where in it (its breadcrumb or lines). Semantic mode needs a one-time \"semantic setup\" on this computer - if it is not set up, search says so and names the command.",
18938
+ "Start with graph_info to see what you are connected to. read_documents reads several documents in one call. tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
18939
+ "Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it - its outline is the site navigation) and the public documents none takes. A document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings, and publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
18940
+ "Themes: a publication's look is a theme - Mustache templates, a stylesheet, a script and a manifest. list_themes shows the bundled ones (read-only) and the graph's own. read_theme writes a theme's files to a folder on this machine to read and edit. customise_publication_theme copies a publication's bundled theme into the graph and points the publication at the copy (create_theme copies any theme). write_theme_file, delete_theme_file and import_theme_folder change a graph theme. preview_theme renders a theme to a folder (with screenshots when a browser is set up) to check before publish. For a snippet such as an analytics script, an include slot (update_publication includes, e.g. head) filled by a page may be lighter than a theme copy.",
18941
+ "Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document. read_asset writes an asset to a local file you can open, and list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session). read_asset, read_theme and preview_theme write under the graph's downloads directory and return the path - name a folder relative to it, never elsewhere.",
18496
18942
  "The user documentation is at https://docs.etherpk.com."
18497
18943
  ].join("\n") });
18498
18944
  server.registerTool("list_documents", {
18499
18945
  title: "List documents",
18500
- description: `Every page and journal entry in the graph with its aliases; protected documents carry protected: true and have no readable body. from / to narrow to the journal entries of a range of days. Paged, at most 200 per call.`,
18946
+ description: `Every page and journal entry in the graph with its aliases - protected documents carry protected: true and have no readable body. from / to narrow to the journal entries of a range of days. Paged, at most 200 per call.`,
18501
18947
  inputSchema: {
18502
18948
  kind: z.enum(["page", "journal"]).optional().describe("Only pages, or only journal entries."),
18503
- from: z.string().optional().describe("Journal entries on or after this day (YYYY-MM-DD); implies kind journal."),
18504
- to: z.string().optional().describe("Journal entries on or before this day (YYYY-MM-DD); implies kind journal."),
18949
+ from: z.string().optional().describe("Journal entries on or after this day (YYYY-MM-DD) - implies kind journal."),
18950
+ to: z.string().optional().describe("Journal entries on or before this day (YYYY-MM-DD) - implies kind journal."),
18505
18951
  offset,
18506
18952
  limit: z.number().int().positive().max(200).optional()
18507
18953
  }
@@ -18518,7 +18964,7 @@ function createMcpServer(graph, info) {
18518
18964
  }, async (args) => run(() => readDocuments(graph, args)));
18519
18965
  server.registerTool("search", {
18520
18966
  title: "Search",
18521
- description: `Search every unprotected document. mode "text" (default): words match by prefix, "quoted phrases" match exactly; documents come most-matches-first with the matching lines. mode "semantic": the passages closest in MEANING to the query, best first, each with its text, a similarity 0..1 and where it is (concept, breadcrumb of headings and parent bullets, start and end line) - finds a note that says "self-assessment is due 31 January" for "tax deadline". Line numbers are 0-based in every mode. Passage text is the note's own words with bullet markers and [[ ]] stripped: quote and cite it, but anchor edit_document on text from read_document. Use semantic for questions and descriptions, text for names, identifiers and exact phrases; mode "hybrid" returns both as separate groups. Semantic results carry complete: false while this computer is still embedding the graph (rerun later for more). If semantic mode answers error semantic_unavailable, it is not set up on this computer: tell the user the command in the message. At most 50 documents per call.`,
18967
+ description: `Search every unprotected document. mode "text" (default): words match by prefix, "quoted phrases" match exactly. Documents come most-matches-first with the matching lines. In text mode a term key:value is a property filter on the documents' frontmatter when some document has that key, and keys and values ignore case (key:"two words", -key:value excludes, key:* means the key is set, key:va* matches the start of a value, publication.id:docs reaches a nested key, title: and aliases: match the document's names). Filters alone list every matching document with the values they matched and no hits. mode "semantic": the passages closest in MEANING to the query, best first, each with its text, a similarity 0..1 and where it is (concept, breadcrumb of headings and parent bullets, start and end line) - finds a note that says "self-assessment is due 31 January" for "tax deadline". Line numbers are 0-based in every mode. Passage text is the note's own words with bullet markers and [[ ]] stripped: quote and cite it, but anchor edit_document on text from read_document. Use semantic for questions and descriptions, text for names, identifiers and exact phrases. mode "hybrid" returns both as separate groups. Semantic results carry complete: false while this computer is still embedding the graph (rerun later for more). If semantic mode answers error semantic_unavailable, it is not set up on this computer: tell the user the command in the message. At most 50 documents per call.`,
18522
18968
  inputSchema: {
18523
18969
  query: z.string().min(1),
18524
18970
  mode: z.enum([
@@ -18565,7 +19011,7 @@ function createMcpServer(graph, info) {
18565
19011
  }, async (args) => run(() => tasks(graph, args)));
18566
19012
  server.registerTool("set_task", {
18567
19013
  title: "Set a task",
18568
- description: "Change one task by document and 0-based line (as tasks and read_document count lines): its status (open, doing, waiting, done, cancelled - exclusive), priority (1, 2, 3 or null), due and scheduled days (YYYY-MM-DD or null). Pass expect with the task's text from tasks; if the line no longer holds that task the change is refused with error \"task_moved\" and nothing is edited.",
19014
+ description: "Change one task by document and 0-based line (as tasks and read_document count lines): its status (open, doing, waiting, done, cancelled - exclusive), priority (1, 2, 3 or null), due and scheduled days (YYYY-MM-DD or null). Pass expect with the task's text from tasks - if the line no longer holds that task the change is refused with error \"task_moved\" and nothing is edited.",
18569
19015
  inputSchema: {
18570
19016
  concept,
18571
19017
  line: z.number().int().nonnegative(),
@@ -18591,7 +19037,7 @@ function createMcpServer(graph, info) {
18591
19037
  }, async (args) => run(() => setTask(graph, args)));
18592
19038
  server.registerTool("edit_document", {
18593
19039
  title: "Edit a document",
18594
- description: "Replace one exact occurrence of \"old\" with \"new\" in a document. \"old\" must appear exactly once (include surrounding lines to disambiguate); the change is applied as one edit, so on a synced graph it merges with anyone typing elsewhere in the page, and on a folder it is in the file when this returns. Refused on a protected document.",
19040
+ description: "Replace one exact occurrence of \"old\" with \"new\" in a document. \"old\" must appear exactly once (include surrounding lines to disambiguate). The change is applied as one edit, so on a synced graph it merges with anyone typing elsewhere in the page, and on a folder it is in the file when this returns. Refused on a protected document.",
18595
19041
  inputSchema: {
18596
19042
  concept,
18597
19043
  old: z.string().min(1).describe("The exact text to replace, as read_document returned it."),
@@ -18625,7 +19071,7 @@ function createMcpServer(graph, info) {
18625
19071
  }, async (args) => run(() => planRename(graph, args)));
18626
19072
  server.registerTool("rename", {
18627
19073
  title: "Rename",
18628
- description: "Rename a page or concept. Its page (if any) and every scoped concept beneath it (\"[[Old]] Notes\" becomes \"[[New]] Notes\") move with it. strategy \"rewrite\" (default) rewrites every [[Old]] link in the graph to the new name; \"alias\" keeps the old name as an alias so existing links keep resolving. If the new name is already taken the two documents would merge, which is irreversible: refused with error \"merge_requires_confirmation\" unless confirm_merge is true. Refused with \"unconfirmed_documents\" if a document it must read has not finished syncing to this device (nothing changes; try again). Call plan_rename first to see what will happen.",
19074
+ description: "Rename a page or concept. Its page (if any) and every scoped concept beneath it (\"[[Old]] Notes\" becomes \"[[New]] Notes\") move with it. strategy \"rewrite\" (default) rewrites every [[Old]] link in the graph to the new name. \"alias\" keeps the old name as an alias so existing links keep resolving. If the new name is already taken the two documents would merge, which is irreversible: refused with error \"merge_requires_confirmation\" unless confirm_merge is true. Refused with \"unconfirmed_documents\" if a document it must read has not finished syncing to this device (nothing changes - try again). Call plan_rename first to see what will happen.",
18629
19075
  inputSchema: {
18630
19076
  from: z.string().min(1),
18631
19077
  to: z.string().min(1),
@@ -18638,7 +19084,7 @@ function createMcpServer(graph, info) {
18638
19084
  description: "Replace a document's aliases: other names it answers to in [[wikilinks]], Quick Find and on a published site. The list you pass is the complete list. A name that is already another document's title or alias is refused with error \"name_taken\".",
18639
19085
  inputSchema: {
18640
19086
  concept,
18641
- aliases: z.array(z.string()).describe("Every alias the document should have; an empty list removes them all.")
19087
+ aliases: z.array(z.string()).describe("Every alias the document should have - an empty list removes them all.")
18642
19088
  }
18643
19089
  }, async (args) => run(() => setAliases(graph, args)));
18644
19090
  server.registerTool("set_frontmatter", {
@@ -18646,23 +19092,23 @@ function createMcpServer(graph, info) {
18646
19092
  description: "Set keys of a document's frontmatter block, adding the block if there is none. A merge patch: the keys you name are set, null removes a key, every other key keeps its value. \"public\" (true/false) and \"publications\" (a list of publication ids: lower-case letters, digits and hyphens) decide what is published, and are validated. Refuses \"title\", \"aliases\" and \"publication\" with error \"identity_key\" (use rename, set_aliases and the publication tools), and a protected document. Returns the resulting frontmatter.",
18647
19093
  inputSchema: {
18648
19094
  concept,
18649
- patch: z.record(z.string(), frontmatterValue).describe("Keys to set; null removes a key. Example: {\"public\": true, \"publications\": [\"blog\"], \"date\": \"2026-01-31\", \"draft\": null}.")
19095
+ patch: z.record(z.string(), frontmatterValue).describe("Keys to set - null removes a key. Example: {\"public\": true, \"publications\": [\"blog\"], \"date\": \"2026-01-31\", \"draft\": null}.")
18650
19096
  }
18651
19097
  }, async (args) => run(() => setFrontmatter(graph, args)));
18652
19098
  server.registerTool("upload_asset", {
18653
19099
  title: "Upload an asset",
18654
- description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"invalid_argument\" for a folder, a file over 100 MiB, a hidden file or one in a hidden folder (.ssh, .env), and the Headless Client's own config and cache; with error \"asset_refused\" when the server declines it on a quota.",
19100
+ description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"invalid_argument\" for a folder, a file over 100 MiB, a hidden file or one in a hidden folder (.ssh, .env), and the Headless Client's own config and cache. Refused with error \"asset_refused\" when the server declines it on a quota.",
18655
19101
  inputSchema: {
18656
19102
  path: z.string().min(1).describe("Absolute path of an ordinary file on this machine."),
18657
- name: z.string().min(1).optional().describe("The name to store it under; the file's own name by default.")
19103
+ name: z.string().min(1).optional().describe("The name to store it under - the file's own name by default.")
18658
19104
  }
18659
19105
  }, async (args) => run(() => uploadAsset(graph, args)));
18660
19106
  server.registerTool("read_asset", {
18661
19107
  title: "Read an asset",
18662
- description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session); otherwise error \"asset_not_found\". The file is written under the graph's downloads directory, named after the asset, and never over an existing file.",
19108
+ description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session) - otherwise error \"asset_not_found\". The file is written under the graph's downloads directory, named after the asset, and never over an existing file.",
18663
19109
  inputSchema: {
18664
19110
  ref: z.string().min(1),
18665
- out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; the downloads directory itself by default. A folder outside it is refused.")
19111
+ out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it - the downloads directory itself by default. A folder outside it is refused.")
18666
19112
  }
18667
19113
  }, async (args) => run(() => readAsset(graph, args)));
18668
19114
  server.registerTool("list_assets", {
@@ -18676,25 +19122,25 @@ function createMcpServer(graph, info) {
18676
19122
  };
18677
19123
  server.registerTool("list_publications", {
18678
19124
  title: "List publications",
18679
- description: "The publications this graph defines - id, name, the page that defines each, kind (docs or blog), selection (named: documents that list it in publications; all-public: every public document), home, url, theme, include slots, and the publish folder set for it on this machine (null when none) - with any issues in their pages and the public documents no publication takes.",
19125
+ description: "The publications this graph defines - id, name, the page that defines each, kind (docs or blog), selection (named: documents that list it in publications, all-public: every public document), home, url, theme, include slots, and the publish folder set for it on this machine (null when none) - with any issues in their pages and the public documents no publication takes.",
18680
19126
  inputSchema: {}
18681
19127
  }, async () => run(() => listPublications(graph, host)));
18682
19128
  server.registerTool("create_publication", {
18683
19129
  title: "Create a publication",
18684
- description: "Create a publication: a page titled with its name whose frontmatter defines it and whose outline (bullets of [[wikilinks]]) is the site navigation. id defaults to a slug of the title; kind is docs (default) or blog; selection is named (default: a document joins by listing the id in its publications) or all-public. theme is a bundled theme name (etherpk-docs, etherpk-blog), a theme in the graph, or a url.",
19130
+ description: "Create a publication: a page titled with its name whose frontmatter defines it and whose outline (bullets of [[wikilinks]]) is the site navigation. id defaults to a slug of the title. kind is docs (default) or blog. selection is named (default: a document joins by listing the id in its publications) or all-public. theme is a bundled theme name (etherpk-docs, etherpk-blog), a theme in the graph, or a url.",
18685
19131
  inputSchema: {
18686
19132
  title: z.string().min(1),
18687
19133
  id: z.string().min(1).optional(),
18688
19134
  kind: z.enum(["docs", "blog"]).optional(),
18689
19135
  selection: z.enum(["named", "all-public"]).optional(),
18690
- home: z.string().min(1).optional().describe("The page whose content is the front page; absent, an index is generated."),
19136
+ home: z.string().min(1).optional().describe("The page whose content is the front page - absent, an index is generated."),
18691
19137
  url: z.string().min(1).optional().describe("Where the site will be deployed, for its feed and sitemap."),
18692
19138
  theme: z.string().min(1).optional()
18693
19139
  }
18694
19140
  }, async (args) => run(() => createPublication(graph, args, host)));
18695
19141
  server.registerTool("update_publication", {
18696
19142
  title: "Update a publication",
18697
- description: "Change a publication's settings on its page. Each change is optional; null removes a field (back to its default) or clears an include slot. includes maps a theme's slot name (head, logo, header, footer, before, after, scripts, styles, or one the theme adds) to the page that fills it; that page must be public and in the publication.",
19143
+ description: "Change a publication's settings on its page. Each change is optional - null removes a field (back to its default) or clears an include slot. includes maps a theme's slot name (head, logo, header, footer, before, after, scripts, styles, or one the theme adds) to the page that fills it - that page must be public and in the publication.",
18698
19144
  inputSchema: {
18699
19145
  id: z.string().min(1),
18700
19146
  changes: z.object({
@@ -18703,27 +19149,27 @@ function createMcpServer(graph, info) {
18703
19149
  home: z.string().nullable().optional(),
18704
19150
  url: z.string().nullable().optional(),
18705
19151
  theme: z.string().nullable().optional(),
18706
- recent: z.number().int().nonnegative().nullable().optional().describe("Posts on a blog's front page; null for the default (10)."),
19152
+ recent: z.number().int().nonnegative().nullable().optional().describe("Posts on a blog's front page - null for the default (10)."),
18707
19153
  includes: z.record(z.string(), z.string().nullable()).optional()
18708
19154
  })
18709
19155
  }
18710
19156
  }, async (args) => run(() => updatePublication(graph, args, host)));
18711
19157
  server.registerTool("publish", {
18712
19158
  title: "Publish",
18713
- description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then; the tool never chooses a folder). Pages with Mermaid diagrams need the browser from \"diagrams setup\" (error \"chromium_unavailable\"). The report is this result, trimmed to counts and first entries; nothing of it is written into the folder, so the site never names the documents it leaves out. Publishing writes files; it does not deploy them.",
19159
+ description: "Render a publication to its publish folder on this machine and return the report: what was included and why documents were left out, missing links, assets, warnings. The folder is the one the user set with \"etherpk-mcp publish --publication <id> --out <dir>\" (error \"no_publish_folder\" until then - the tool never chooses a folder). Pages with Mermaid diagrams need the browser from \"diagrams setup\" (error \"chromium_unavailable\"). The report is this result, trimmed to counts and first entries - nothing of it is written into the folder, so the site never names the documents it leaves out. Publishing writes files - it does not deploy them.",
18714
19160
  inputSchema: { id: z.string().min(1) }
18715
19161
  }, async (args) => run(() => publish(graph, args, host)));
18716
19162
  server.registerTool("list_themes", {
18717
19163
  title: "List themes",
18718
- description: "Every theme a publication here can use: the bundled ones (etherpk-docs, etherpk-blog; read-only, with their files and manifest) and the graph's own (editable; id, name, origin, files, manifest, validation errors, and which publications use each).",
19164
+ description: "Every theme a publication here can use: the bundled ones (etherpk-docs and etherpk-blog, read-only, with their files and manifest) and the graph's own (editable, with id, name, origin, files, manifest, validation errors, and which publications use each).",
18719
19165
  inputSchema: {}
18720
19166
  }, async () => run(() => listThemes(graph)));
18721
19167
  server.registerTool("read_theme", {
18722
19168
  title: "Read a theme",
18723
- description: "Write a theme's files (theme.json, layouts/, partials/, assets/) to a folder on this machine and return the path and file list, so you can read and edit them with your own tools. ref is a graph theme id, a bundled theme name or a url. Editing the folder changes nothing until write_theme_file or import_theme_folder brings it back; a bundled or url theme cannot be edited in place at all (create_theme copies it).",
19169
+ description: "Write a theme's files (theme.json, layouts/, partials/, assets/) to a folder on this machine and return the path and file list, so you can read and edit them with your own tools. ref is a graph theme id, a bundled theme name or a url. Editing the folder changes nothing until write_theme_file or import_theme_folder brings it back. A bundled or url theme cannot be edited in place at all (create_theme copies it).",
18724
19170
  inputSchema: {
18725
19171
  ref: z.string().min(1),
18726
- out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; themes/<ref> there by default. A folder outside it is refused.")
19172
+ out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it - themes/<ref> there by default. A folder outside it is refused.")
18727
19173
  }
18728
19174
  }, async (args) => run(() => readTheme(graph, args)));
18729
19175
  server.registerTool("read_theme_file", {
@@ -18736,19 +19182,19 @@ function createMcpServer(graph, info) {
18736
19182
  }, async (args) => run(() => readThemeFile(graph, args)));
18737
19183
  server.registerTool("create_theme", {
18738
19184
  title: "Create a theme",
18739
- description: "Copy a theme into the graph as one of its own, ready to edit: from a bundled theme name, a url, or another theme in the graph. Returns the new theme; point a publication at it with update_publication (theme: <id>).",
19185
+ description: "Copy a theme into the graph as one of its own, ready to edit: from a bundled theme name, a url, or another theme in the graph. Returns the new theme - point a publication at it with update_publication (theme: <id>).",
18740
19186
  inputSchema: {
18741
19187
  from: z.string().min(1),
18742
- id: z.string().min(1).optional().describe("Lower-case letters, digits and hyphens; from the source by default."),
19188
+ id: z.string().min(1).optional().describe("Lower-case letters, digits and hyphens - from the source by default."),
18743
19189
  name: z.string().min(1).optional()
18744
19190
  }
18745
19191
  }, async (args) => run(() => createTheme(graph, args)));
18746
19192
  server.registerTool("customise_publication_theme", {
18747
19193
  title: "Customise a publication's theme",
18748
- description: "Make a publication's theme editable: if it uses a bundled or url theme, copy that theme into the graph and point the publication at the copy; if it already uses a graph theme, return it. The step before editing the look of a published site.",
19194
+ description: "Make a publication's theme editable: if it uses a bundled or url theme, copy that theme into the graph and point the publication at the copy. If it already uses a graph theme, return it. The step before editing the look of a published site.",
18749
19195
  inputSchema: {
18750
19196
  publication: z.string().min(1).describe("The publication id."),
18751
- id: z.string().min(1).optional().describe("The id for the copy; <publication>-theme by default."),
19197
+ id: z.string().min(1).optional().describe("The id for the copy - <publication>-theme by default."),
18752
19198
  name: z.string().min(1).optional()
18753
19199
  }
18754
19200
  }, async (args) => run(() => customisePublicationTheme(graph, args)));
@@ -18763,7 +19209,7 @@ function createMcpServer(graph, info) {
18763
19209
  }, async (args) => run(() => writeThemeFile(graph, args)));
18764
19210
  server.registerTool("delete_theme_file", {
18765
19211
  title: "Delete a theme file",
18766
- description: "Remove one file from a graph theme; the theme is validated afterwards and problems returned as errors.",
19212
+ description: "Remove one file from a graph theme - the theme is validated afterwards and problems returned as errors.",
18767
19213
  inputSchema: {
18768
19214
  id: z.string().min(1),
18769
19215
  path: z.string().min(1)
@@ -18771,7 +19217,7 @@ function createMcpServer(graph, info) {
18771
19217
  }, async (args) => run(() => deleteThemeFile(graph, args)));
18772
19218
  server.registerTool("import_theme_folder", {
18773
19219
  title: "Import a theme folder",
18774
- description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme; the folder needs a theme.json at its top. dir is the folder read_theme returned, or another under the graph's downloads directory; a folder outside it is refused, and symbolic links in it are skipped. Validated afterwards.",
19220
+ description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme - the folder needs a theme.json at its top. dir is the folder read_theme returned, or another under the graph's downloads directory - a folder outside it is refused, and symbolic links in it are skipped. Validated afterwards.",
18775
19221
  inputSchema: {
18776
19222
  id: z.string().min(1),
18777
19223
  dir: z.string().min(1)
@@ -18779,12 +19225,12 @@ function createMcpServer(graph, info) {
18779
19225
  }, async (args) => run(() => importThemeFolder(graph, args)));
18780
19226
  server.registerTool("delete_theme", {
18781
19227
  title: "Delete a theme",
18782
- description: "Remove a graph theme. Refused with error \"theme_in_use\" while a publication's saved settings name it; point the publication at another theme first.",
19228
+ description: "Remove a graph theme. Refused with error \"theme_in_use\" while a publication's saved settings name it - point the publication at another theme first.",
18783
19229
  inputSchema: { id: z.string().min(1) }
18784
19230
  }, async (args) => run(() => deleteTheme(graph, args)));
18785
19231
  server.registerTool("preview_theme", {
18786
19232
  title: "Preview a theme",
18787
- description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder. The page loads nothing from the network while it is photographed. out_dir is a folder under the graph's downloads directory (previews/<name> there by default); a folder outside it is refused.",
19233
+ description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder. The page loads nothing from the network while it is photographed. out_dir is a folder under the graph's downloads directory (previews/<name> there by default) - a folder outside it is refused.",
18788
19234
  inputSchema: {
18789
19235
  theme: z.string().min(1).optional(),
18790
19236
  publication: z.string().min(1).optional(),
@@ -19020,20 +19466,20 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
19020
19466
  Serve one synced graph to an agent over stdio. For Claude Code:
19021
19467
  claude mcp add etherpk -- npx @appsoftwareltd/etherpk-mcp serve --sync-server <url> --graph <id>
19022
19468
  Once "semantic setup" has run on this computer, serve also keeps a search-by-meaning
19023
- store of the graph current (the agent's search tool gains mode: semantic); pass
19469
+ store of the graph current (the agent's search tool gains mode: semantic) - pass
19024
19470
  --no-semantic to leave it off for this registration.
19025
19471
  ${CMD} serve --folder <path> [--no-semantic]
19026
19472
  Serve a local graph folder the same way: no sign-in, no server. The agent gets search,
19027
19473
  backlinks, tasks and format-safe edits over the folder's markdown, alongside the files
19028
19474
  themselves. Edits made in an editor or by the agent directly are picked up as they land.
19029
- The folder must already be a graph (open it in EtherPK once); its index is kept under
19475
+ The folder must already be a graph (open it in EtherPK once) - its index is kept under
19030
19476
  the cache directory, never in the folder.
19031
19477
  ${CMD} logout [--sync-server <url> | --all]
19032
19478
  Forget that server's token, keys and cached graphs on this machine.
19033
19479
  ${CMD} semantic setup
19034
19480
  Let the agent search by meaning. Installs a ~300 MB native runtime (onnxruntime-node,
19035
19481
  with your npm) and downloads a 23 MB embedding model into the cache directory, once
19036
- per computer. Nothing leaves this computer when the model runs; notes are never sent
19482
+ per computer. Nothing leaves this computer when the model runs - notes are never sent
19037
19483
  to a service to be embedded.
19038
19484
  ${CMD} semantic status | remove
19039
19485
  Whether it is set up here, or delete it (logout --all deletes it too).
@@ -19043,20 +19489,20 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
19043
19489
  then on this command without --out, and the agent's publish tool, write to the same
19044
19490
  folder (the agent cannot choose one). The folder's owned files (.html at the top,
19045
19491
  assets/, theme/, the search, feed and report files) are rewritten and their strays
19046
- removed; everything else in it is left alone. Publishing writes files; deploying them
19492
+ removed - everything else in it is left alone. Publishing writes files - deploying them
19047
19493
  (a git push, say) is yours. Run it from cron for a site that republishes itself.
19048
19494
  ${CMD} diagrams setup
19049
19495
  Let a publish draw Mermaid diagrams. Downloads a Chromium (about 170 MB) into the cache
19050
- directory with Playwright's installer, once per computer; a publish whose pages hold
19496
+ directory with Playwright's installer, once per computer - a publish whose pages hold
19051
19497
  diagrams refuses until this has run. Set ETHERPK_CHROMIUM=<path> to use a browser
19052
19498
  already on this machine instead (NixOS needs this).
19053
19499
  ${CMD} diagrams status
19054
19500
  Which browser a publish would use, if any.
19055
19501
 
19056
- This machine can hold logins for several Sync Servers at once; --sync-server says which one
19057
- a command means, and can be left out while there is only one. The config file is
19502
+ This machine can hold logins for several Sync Servers at once. The --sync-server flag says
19503
+ which one a command means, and can be left out while there is only one. The config file is
19058
19504
  ${defaultConfigPath()} (override with
19059
- ETHERPK_MCP_CONFIG); cached graphs live under ~/.cache/etherpk/mcp (override with
19505
+ ETHERPK_MCP_CONFIG) - cached graphs live under ~/.cache/etherpk/mcp (override with
19060
19506
  ETHERPK_MCP_CACHE_DIR).
19061
19507
  Docs: https://docs.etherpk.com/using-ai-agents-with-your-notes
19062
19508
  `;
@@ -19069,7 +19515,7 @@ function fail(message) {
19069
19515
  * an agent starting the process), since each question has its own option or variable.
19070
19516
  */
19071
19517
  async function ask(question, { secret = false, hint }) {
19072
- if (!process.stdin.isTTY) fail(`${question.replace(/:\s*$/, "")}: no terminal to ask on; ${hint}.`);
19518
+ if (!process.stdin.isTTY) fail(`${question.replace(/:\s*$/, "")}: no terminal to ask on - ${hint}.`);
19073
19519
  if (!secret) {
19074
19520
  const rl = createInterface({
19075
19521
  input: process.stdin,
@@ -19108,7 +19554,7 @@ async function ask(question, { secret = false, hint }) {
19108
19554
  }
19109
19555
  async function loadConfig(path) {
19110
19556
  const config = await readConfig(path);
19111
- if (!config) fail(`${path} is not a config file this version understands. Run: ${CMD} login --sync-server <url> (it will be rewritten; nothing else is affected).`);
19557
+ if (!config) fail(`${path} is not a config file this version understands. Run: ${CMD} login --sync-server <url> (it will be rewritten - nothing else is affected).`);
19112
19558
  return config;
19113
19559
  }
19114
19560
  /** The login a command means, or the reason there is none - in words the user can act on. */
@@ -19149,7 +19595,7 @@ async function login(args) {
19149
19595
  await writeConfig(path, config);
19150
19596
  console.log(`Keys unlocked and cached in ${path} (owner-only). Anyone who can read your files on this machine can read this account, as with a signed-in browser.`);
19151
19597
  const others = Object.keys(config.servers).filter((server) => server !== syncServer);
19152
- if (others.length > 0) console.log(`Also logged in to ${others.join(", ")}; commands now need --sync-server <url> to say which.`);
19598
+ if (others.length > 0) console.log(`Also logged in to ${others.join(", ")} - commands now need --sync-server <url> to say which.`);
19153
19599
  await listGraphs({
19154
19600
  syncServer,
19155
19601
  pat,
@@ -19205,7 +19651,7 @@ async function approveOrFallBack(account, byRecoveryCode) {
19205
19651
  process.exit(controls.exitCode);
19206
19652
  }
19207
19653
  if (!abandoned) throw new Error("unreachable");
19208
- console.log("Approval cancelled; unlocking with your Recovery Code instead.");
19654
+ console.log("Approval cancelled - unlocking with your Recovery Code instead.");
19209
19655
  return byRecoveryCode();
19210
19656
  }
19211
19657
  /**
@@ -19274,7 +19720,7 @@ async function semanticCommand(what) {
19274
19720
  const status = await semanticSetupStatus(process.env);
19275
19721
  console.log(`Runtime: ${status.runtime ? "installed" : "missing"} (${status.runtimeDir})`);
19276
19722
  console.log(`Model: ${status.model ? "present" : "missing"} (${status.modelDir})`);
19277
- console.log(status.runtime && status.model ? "Semantic search is set up; serve uses it unless started with --no-semantic." : `Not set up. Run: ${CMD} semantic setup`);
19723
+ console.log(status.runtime && status.model ? "Semantic search is set up - serve uses it unless started with --no-semantic." : `Not set up. Run: ${CMD} semantic setup`);
19278
19724
  const stores = await listGraphStores(process.env, MODEL.id);
19279
19725
  if (stores.length === 0) return;
19280
19726
  console.log("");
@@ -19333,7 +19779,7 @@ async function publishCommand(args) {
19333
19779
  const out = resolve(args.out.trim());
19334
19780
  await writePublishFolders(configPath, withPublishFolder(await readPublishFolders(configPath), key, publication, out));
19335
19781
  console.error(`etherpk-mcp: publish folder for "${publication}" of "${graphName}" set to ${out} (remembered in ${configPath}).`);
19336
- } else if (!publishFolderOf(await readPublishFolders(configPath), key, publication)) fail(`No publish folder is set for "${publication}" of "${graphName}" on this machine. Pass --out <dir> once; it is remembered.`);
19782
+ } else if (!publishFolderOf(await readPublishFolders(configPath), key, publication)) fail(`No publish folder is set for "${publication}" of "${graphName}" on this machine. Pass --out <dir> once - it is remembered.`);
19337
19783
  const result = await publish(graph, { id: publication }, host);
19338
19784
  console.log(JSON.stringify(cliPublishOutput(result), null, 2));
19339
19785
  if (!result.ok) process.exitCode = 1;
@@ -19406,7 +19852,7 @@ async function openFolderForServe(folder, args) {
19406
19852
  onSemanticProgress: reportSemanticProgress,
19407
19853
  onError: (error) => console.error(`etherpk-mcp: ${error.message}`),
19408
19854
  onWarning: (line) => console.error(`etherpk-mcp: ${line}`),
19409
- watch: watchFolder(path, (error) => console.error(`etherpk-mcp: not watching the folder for changes (${error.message}); edits made outside are still picked up before each tool call.`))
19855
+ watch: watchFolder(path, (error) => console.error(`etherpk-mcp: not watching the folder for changes (${error.message}) - edits made outside are still picked up before each tool call.`))
19410
19856
  }),
19411
19857
  graphName: name
19412
19858
  };
@@ -19478,7 +19924,7 @@ async function serveGraph(graph, graphName, args) {
19478
19924
  transport.onclose = listener;
19479
19925
  },
19480
19926
  async shutdown(end) {
19481
- console.error(`etherpk-mcp: ${end}; flushing and exiting.`);
19927
+ console.error(`etherpk-mcp: ${end} - flushing and exiting.`);
19482
19928
  await graph.settle().catch(() => {});
19483
19929
  await graph.dispose().catch(() => {});
19484
19930
  process.exit(0);
@@ -19488,17 +19934,17 @@ async function serveGraph(graph, graphName, args) {
19488
19934
  console.error(`etherpk-mcp: serving "${graphName}" over stdio as "Agent on ${hostname()}".`);
19489
19935
  chromiumStatus(process.env, CMD).then((chromium) => {
19490
19936
  if (chromium.executable) return;
19491
- console.error(`etherpk-mcp: no browser for diagrams (run: ${chromium.setupCommand}, or set ETHERPK_CHROMIUM); a publish or theme preview with Mermaid diagrams refuses until then.`);
19937
+ console.error(`etherpk-mcp: no browser for diagrams (run: ${chromium.setupCommand}, or set ETHERPK_CHROMIUM) - a publish or theme preview with Mermaid diagrams refuses until then.`);
19492
19938
  });
19493
19939
  const startSemantic = () => graph.semantic().then(async (semantic) => {
19494
19940
  const status = await semantic.status();
19495
- console.error(`etherpk-mcp: semantic search on (${semantic.model.id}); ${status.embedded} of ${status.total} passages embedded, building the rest in the background.`);
19941
+ console.error(`etherpk-mcp: semantic search on (${semantic.model.id}) - ${status.embedded} of ${status.total} passages embedded, building the rest in the background.`);
19496
19942
  }).catch((error) => console.error(`etherpk-mcp: semantic search unavailable: ${error instanceof Error ? error.message : String(error)}`));
19497
19943
  if (semanticOn) startSemantic();
19498
19944
  else if (!args["no-semantic"]) {
19499
- console.error(`etherpk-mcp: semantic search is not set up on this computer (run: ${CMD} semantic setup; this process will notice within half a minute, no restart needed); search answers by text only until then.`);
19945
+ console.error(`etherpk-mcp: semantic search is not set up on this computer (run: ${CMD} semantic setup - this process will notice within half a minute, no restart needed). Search answers by text only until then.`);
19500
19946
  whenSemanticSetUp(process.env, () => {
19501
- console.error("etherpk-mcp: semantic setup found; loading the model and building the store.");
19947
+ console.error("etherpk-mcp: semantic setup found - loading the model and building the store.");
19502
19948
  startSemantic();
19503
19949
  });
19504
19950
  }