@appsoftwareltd/etherpk-mcp 0.8.2 → 0.8.3

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.3",
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",
@@ -2511,6 +2511,119 @@ function snippetSegments(snippet) {
2511
2511
  }
2512
2512
  return segments.filter((s) => s.text !== "");
2513
2513
  }
2514
+ /** Letters, digits, `_`, `.` and `-`: a key a person can type, dot paths included. */
2515
+ var PROPERTY_KEY_PATTERN = /^[\p{L}\p{N}_][\p{L}\p{N}_.-]*$/u;
2516
+ /**
2517
+ * Whitespace-separated tokens. A quote opens a run that spaces do not end, whether it starts the
2518
+ * token (`"a phrase"`) or follows a key (`status:"in progress"`); an unclosed one runs to the end.
2519
+ */
2520
+ function searchTokens(input) {
2521
+ const out = [];
2522
+ let index = 0;
2523
+ while (index < input.length) {
2524
+ if (/\s/.test(input[index])) {
2525
+ index++;
2526
+ continue;
2527
+ }
2528
+ const from = index;
2529
+ while (index < input.length && !/\s/.test(input[index])) {
2530
+ if (input[index] === "\"") {
2531
+ const close = input.indexOf("\"", index + 1);
2532
+ index = close === -1 ? input.length : close + 1;
2533
+ continue;
2534
+ }
2535
+ index++;
2536
+ }
2537
+ out.push({
2538
+ text: input.slice(from, index),
2539
+ from,
2540
+ to: index
2541
+ });
2542
+ }
2543
+ return out;
2544
+ }
2545
+ /**
2546
+ * The filters in `input`, and the words left for name and text matching.
2547
+ *
2548
+ * A term is a filter only when its key is one at least one searchable document carries
2549
+ * (`knownKeys`, lower-cased): anything else shaped like `x:y` stays words, which keeps a URL, a
2550
+ * time or `note:` in prose as text. A fully quoted term is always words. A known key with no
2551
+ * value yet (`status:` mid-typing) is dropped from both, so the key is never searched as a word
2552
+ * a moment before the value arrives.
2553
+ */
2554
+ function parseSearchQuery(input, knownKeys) {
2555
+ const filters = [];
2556
+ const terms = [];
2557
+ const cut = [];
2558
+ if (knownKeys.size > 0) for (const token of searchTokens(input)) {
2559
+ if (token.text.startsWith("\"")) continue;
2560
+ const colon = token.text.indexOf(":");
2561
+ if (colon <= 0) continue;
2562
+ const negated = token.text.startsWith("-");
2563
+ const key = token.text.slice(negated ? 1 : 0, colon);
2564
+ if (!PROPERTY_KEY_PATTERN.test(key) || !knownKeys.has(key.toLowerCase())) continue;
2565
+ const filter = filterValue(key, token.text.slice(colon + 1), negated);
2566
+ cut.push({
2567
+ from: token.from,
2568
+ to: token.to
2569
+ });
2570
+ if (filter === null) continue;
2571
+ filters.push(filter);
2572
+ terms.push({
2573
+ filter,
2574
+ from: token.from,
2575
+ to: token.to
2576
+ });
2577
+ }
2578
+ return {
2579
+ words: withoutRanges(input, cut),
2580
+ filters,
2581
+ terms
2582
+ };
2583
+ }
2584
+ /** The filter for a value as typed, or null when there is no value yet. */
2585
+ function filterValue(key, raw, negated) {
2586
+ if (raw === "*") return {
2587
+ key,
2588
+ value: null,
2589
+ prefix: false,
2590
+ negated
2591
+ };
2592
+ if (raw.startsWith("\"")) {
2593
+ const close = raw.indexOf("\"", 1);
2594
+ const value = (close === -1 ? raw.slice(1) : raw.slice(1, close)).trim();
2595
+ return value === "" ? null : {
2596
+ key,
2597
+ value,
2598
+ prefix: false,
2599
+ negated
2600
+ };
2601
+ }
2602
+ if (raw.length > 1 && raw.endsWith("*")) return {
2603
+ key,
2604
+ value: raw.slice(0, -1),
2605
+ prefix: true,
2606
+ negated
2607
+ };
2608
+ return raw === "" ? null : {
2609
+ key,
2610
+ value: raw,
2611
+ prefix: false,
2612
+ negated
2613
+ };
2614
+ }
2615
+ /** `input` with the ranges removed and the gaps they leave closed to single spaces. */
2616
+ function withoutRanges(input, ranges) {
2617
+ if (ranges.length === 0) return input;
2618
+ const parts = [];
2619
+ let at = 0;
2620
+ for (const range of ranges) {
2621
+ parts.push(input.slice(at, range.from));
2622
+ at = range.to;
2623
+ }
2624
+ parts.push(input.slice(at));
2625
+ return parts.map((part) => part.trim()).filter((part) => part !== "").join(" ");
2626
+ }
2514
2627
  //#endregion
2515
2628
  //#region ../client/src/lib/document/index-db.ts
2516
2629
  /**
@@ -2572,6 +2685,15 @@ CREATE TABLE IF NOT EXISTS passages (
2572
2685
  page_id INTEGER NOT NULL, ord INTEGER NOT NULL, start_line INTEGER NOT NULL,
2573
2686
  end_line INTEGER NOT NULL, first_block_local_id INTEGER NOT NULL,
2574
2687
  content_hash TEXT NOT NULL, text TEXT NOT NULL);
2688
+ -- [[Property]] rows for [[Property Filter]]s (ADR 0107): one per value, keys as dot paths. key and
2689
+ -- value keep their spelling for display; key_lc and value_lc are lower-cased in JS, because
2690
+ -- SQLite's lower() folds ASCII only and a filter matches ignoring case in any script. value is
2691
+ -- NULL on a mapping's presence row. title and aliases rows come from the graph's names. A
2692
+ -- protected document has none.
2693
+ CREATE TABLE IF NOT EXISTS properties (
2694
+ page_id INTEGER NOT NULL, key TEXT NOT NULL, key_lc TEXT NOT NULL,
2695
+ value TEXT, value_lc TEXT);
2696
+ CREATE INDEX IF NOT EXISTS properties_key ON properties(key_lc, value_lc);
2575
2697
  CREATE INDEX IF NOT EXISTS passages_hash ON passages(content_hash);
2576
2698
  CREATE INDEX IF NOT EXISTS links_concept_key ON links(concept_key);
2577
2699
  CREATE INDEX IF NOT EXISTS blocks_page ON blocks(page_id);
@@ -2620,7 +2742,7 @@ function createSchema(db) {
2620
2742
  db.exec(SCHEMA$1);
2621
2743
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
2622
2744
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
2623
- db.exec(`PRAGMA user_version = 13`);
2745
+ db.exec(`PRAGMA user_version = 14`);
2624
2746
  }
2625
2747
  function activeIndexGeneration(db) {
2626
2748
  return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
@@ -2641,7 +2763,7 @@ function advanceIndexRevision(db) {
2641
2763
  */
2642
2764
  function isUsableIndex(db) {
2643
2765
  try {
2644
- return db.all("PRAGMA user_version")[0]?.user_version === 13;
2766
+ return db.all("PRAGMA user_version")[0]?.user_version === 14;
2645
2767
  } catch {
2646
2768
  return false;
2647
2769
  }
@@ -2661,8 +2783,11 @@ function documentHash(db, key) {
2661
2783
  */
2662
2784
  function indexDocHash(doc) {
2663
2785
  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")}`);
2786
+ const properties = doc.properties ?? [];
2787
+ if (includes.length === 0 && properties.length === 0) return hashText(doc.text);
2788
+ const includeText = includes.map((i) => `${i.publication}\u0001${i.slot}\u0001${i.concept}`).join("\0");
2789
+ const propertyText = properties.map((p) => `${p.key}\u0001${p.value ?? ""}`).join("\0");
2790
+ return hashText(`${doc.text}\u0000${includeText}\u0003${propertyText}`);
2666
2791
  }
2667
2792
  function hashText(text) {
2668
2793
  let h1 = 3735928559;
@@ -2703,6 +2828,7 @@ function insertDerived(db, pageId, doc) {
2703
2828
  conceptKey$1(alias),
2704
2829
  alias
2705
2830
  ]);
2831
+ insertProperties(db, pageId, doc);
2706
2832
  for (const include of doc.includes ?? []) db.run("INSERT INTO publication_includes (page_id, publication_id, slot, concept_key, concept) VALUES (?,?,?,?,?)", [
2707
2833
  pageId,
2708
2834
  include.publication,
@@ -2779,12 +2905,40 @@ function insertDerived(db, pageId, doc) {
2779
2905
  return searchable;
2780
2906
  }
2781
2907
  /**
2908
+ * Write one document's [[Property]] rows (ADR 0107): its name as `title`, each alias as
2909
+ * `aliases`, then what its Frontmatter carries. A [[Protected Document]] writes none. Its
2910
+ * Frontmatter is stored in plaintext, but no kind of matching in Search reaches protected
2911
+ * content, and a document with no rows cannot match a filter, not even a negated one, because
2912
+ * every filtered query also requires `protected = 0`.
2913
+ */
2914
+ function insertProperties(db, pageId, doc) {
2915
+ if (protectedFlag(doc) === 1) return;
2916
+ const rows = [
2917
+ {
2918
+ key: "title",
2919
+ value: doc.concept
2920
+ },
2921
+ ...doc.aliases.map((alias) => ({
2922
+ key: "aliases",
2923
+ value: alias
2924
+ })),
2925
+ ...doc.properties ?? []
2926
+ ];
2927
+ for (const row of rows) db.run("INSERT INTO properties (page_id, key, key_lc, value, value_lc) VALUES (?,?,?,?,?)", [
2928
+ pageId,
2929
+ row.key,
2930
+ row.key.toLowerCase(),
2931
+ row.value,
2932
+ row.value === null ? null : row.value.toLowerCase()
2933
+ ]);
2934
+ }
2935
+ /**
2782
2936
  * Write one document's [[Task Concept]] rows (ADR 0051).
2783
2937
  *
2784
2938
  * Derivation supplies the wikilinked half; the document's own concept is added HERE, as the
2785
2939
  * virtual root of every task's ancestry chain, because derivation reads text and never learns
2786
2940
  * 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.
2941
+ * the tasks written directly on it — the case the "Filter to <active tab>" shortcut exists for.
2788
2942
  *
2789
2943
  * Only the document's own concept goes in, never its aliases: the query resolves a filtered
2790
2944
  * name to its page and matches on every name that page answers to, so an alias row here would
@@ -2879,7 +3033,8 @@ function deleteGeneration(db, generation) {
2879
3033
  "links",
2880
3034
  "tasks",
2881
3035
  "task_concepts",
2882
- "passages"
3036
+ "passages",
3037
+ "properties"
2883
3038
  ]) db.run(`DELETE FROM ${table} WHERE page_id IN (SELECT id FROM pages WHERE generation = ?)`, [generation]);
2884
3039
  db.run("DELETE FROM pages WHERE generation = ?", [generation]);
2885
3040
  }
@@ -2954,6 +3109,7 @@ function ingestOne(db, doc) {
2954
3109
  db.run("DELETE FROM tasks WHERE page_id = ?", [pageId]);
2955
3110
  db.run("DELETE FROM task_concepts WHERE page_id = ?", [pageId]);
2956
3111
  db.run("DELETE FROM passages WHERE page_id = ?", [pageId]);
3112
+ db.run("DELETE FROM properties WHERE page_id = ?", [pageId]);
2957
3113
  db.run("UPDATE pages SET concept = ?, kind = ?, text_hash = ?, protected = ? WHERE id = ?", [
2958
3114
  doc.concept,
2959
3115
  doc.kind,
@@ -3366,25 +3522,27 @@ var SNIPPET_TOKENS = 24;
3366
3522
  * scores merely reintroduces the long-document bias bm25 exists to remove. Counting is cruder,
3367
3523
  * robust, and explainable in one sentence - and it orders by a number the row displays.
3368
3524
  */
3369
- function searchText$1(db, query, offset, limit) {
3525
+ function searchText$1(db, query, offset, limit, filters = []) {
3370
3526
  const match = buildFtsMatch(query);
3371
3527
  if (match === null) return {
3372
3528
  groups: [],
3373
3529
  hasMore: false
3374
3530
  };
3375
3531
  const generation = activeIndexGeneration(db);
3532
+ const narrow = propertyFilterSql(filters);
3376
3533
  const ranked = db.all(`WITH hits AS MATERIALIZED (
3377
3534
  SELECT rowid / ${BLOCK_FTS_STRIDE} AS page_id, bm25(block_fts) AS score
3378
3535
  FROM block_fts WHERE block_fts MATCH ?
3379
3536
  )
3380
3537
  SELECT h.page_id AS page_id, COUNT(*) AS matches, MIN(h.score) AS best
3381
3538
  FROM hits h
3382
- JOIN pages p ON p.id = h.page_id AND p.generation = ?
3539
+ JOIN pages p ON p.id = h.page_id AND p.generation = ?${narrow.sql}
3383
3540
  GROUP BY h.page_id
3384
3541
  ORDER BY matches DESC, best ASC, h.page_id ASC
3385
3542
  LIMIT ? OFFSET ?`, [
3386
3543
  match,
3387
3544
  generation,
3545
+ ...narrow.params,
3388
3546
  limit + 1,
3389
3547
  offset
3390
3548
  ]);
@@ -3462,21 +3620,23 @@ function searchBreadcrumb(blocks, localId) {
3462
3620
  * Capped rather than exact because a prefix query - which is every query, mid-typing - can
3463
3621
  * match most of the graph, and this runs beside the rows it must never delay.
3464
3622
  */
3465
- function searchTextCount(db, query) {
3623
+ function searchTextCount(db, query, filters = []) {
3466
3624
  const match = buildFtsMatch(query);
3467
3625
  if (match === null) return {
3468
3626
  total: 0,
3469
3627
  capped: false
3470
3628
  };
3629
+ const narrow = propertyFilterSql(filters);
3471
3630
  const n = db.all(`SELECT COUNT(*) AS n FROM (
3472
3631
  SELECT m.page_id FROM (
3473
3632
  SELECT DISTINCT rowid / 1048576 AS page_id FROM block_fts
3474
3633
  WHERE block_fts MATCH ?
3475
3634
  ) m
3476
- JOIN pages p ON p.id = m.page_id AND p.generation = ?
3635
+ JOIN pages p ON p.id = m.page_id AND p.generation = ?${narrow.sql}
3477
3636
  LIMIT ?)`, [
3478
3637
  match,
3479
3638
  activeIndexGeneration(db),
3639
+ ...narrow.params,
3480
3640
  1001
3481
3641
  ])[0]?.n ?? 0;
3482
3642
  return n > 1e3 ? {
@@ -3487,6 +3647,106 @@ function searchTextCount(db, query) {
3487
3647
  capped: false
3488
3648
  };
3489
3649
  }
3650
+ /** Documents a filtered name listing returns at most: well past any graph Search serves. */
3651
+ var PROPERTY_MATCH_CAP = 5e3;
3652
+ /**
3653
+ * The SQL that narrows a query over `pages AS p` to the documents passing every filter, as an
3654
+ * `AND …` suffix, with its parameters in order. Empty when there are no filters. A filtered
3655
+ * query never returns a [[Protected Document]], which has no property rows; the explicit
3656
+ * `protected = 0` is what keeps a NEGATED filter from matching one.
3657
+ */
3658
+ function propertyFilterSql(filters) {
3659
+ if (filters.length === 0) return {
3660
+ sql: "",
3661
+ params: []
3662
+ };
3663
+ const clauses = ["p.protected = 0"];
3664
+ const params = [];
3665
+ for (const filter of filters) {
3666
+ let test = "x.key_lc = ?";
3667
+ params.push(filter.key.toLowerCase());
3668
+ if (filter.value !== null) {
3669
+ const value = filter.value.toLowerCase();
3670
+ if (filter.prefix) {
3671
+ test += " AND substr(x.value_lc, 1, ?) = ?";
3672
+ params.push(Array.from(value).length, value);
3673
+ } else {
3674
+ test += " AND x.value_lc = ?";
3675
+ params.push(value);
3676
+ }
3677
+ }
3678
+ clauses.push(`${filter.negated ? "NOT " : ""}EXISTS (SELECT 1 FROM properties x WHERE x.page_id = p.id AND ${test})`);
3679
+ }
3680
+ return {
3681
+ sql: ` AND ${clauses.join(" AND ")}`,
3682
+ params
3683
+ };
3684
+ }
3685
+ /**
3686
+ * Every key a searchable document carries, once per spelling, most used first: what decides
3687
+ * whether a typed `x:y` is a filter, and what the key suggestions offer.
3688
+ */
3689
+ function propertyKeys(db) {
3690
+ return db.all(`SELECT pr.key AS key, COUNT(DISTINCT pr.page_id) AS documents
3691
+ FROM properties pr JOIN pages p ON p.id = pr.page_id
3692
+ WHERE p.generation = ?
3693
+ GROUP BY pr.key
3694
+ ORDER BY documents DESC, pr.key_lc, pr.key`, [activeIndexGeneration(db)]);
3695
+ }
3696
+ /** The values `key` has across the graph, grouped ignoring case, most used first. */
3697
+ function propertyValues(db, key, limit = 50) {
3698
+ return db.all(`SELECT MIN(pr.value) AS value, COUNT(DISTINCT pr.page_id) AS documents
3699
+ FROM properties pr JOIN pages p ON p.id = pr.page_id
3700
+ WHERE p.generation = ? AND pr.key_lc = ? AND pr.value IS NOT NULL
3701
+ GROUP BY pr.value_lc
3702
+ ORDER BY documents DESC, pr.value_lc
3703
+ LIMIT ?`, [
3704
+ activeIndexGeneration(db),
3705
+ key.toLowerCase(),
3706
+ limit
3707
+ ]);
3708
+ }
3709
+ /**
3710
+ * Every document that passes all `filters`, in name order, with the values of the keys the
3711
+ * positive filters name (not `title`, which the row already shows). Empty without a filter:
3712
+ * listing the whole graph is [[All Documents]]' job.
3713
+ */
3714
+ function documentsMatchingProperties(db, filters, limit = PROPERTY_MATCH_CAP) {
3715
+ if (filters.length === 0) return [];
3716
+ const narrow = propertyFilterSql(filters);
3717
+ const pages = db.all(`SELECT p.id AS id, p.concept AS concept, p.kind AS kind FROM pages p
3718
+ WHERE p.generation = ?${narrow.sql}
3719
+ ORDER BY p.concept_key
3720
+ LIMIT ?`, [
3721
+ activeIndexGeneration(db),
3722
+ ...narrow.params,
3723
+ limit
3724
+ ]);
3725
+ if (pages.length === 0) return [];
3726
+ const shown = [...new Set(filters.filter((f) => !f.negated).map((f) => f.key.toLowerCase()))].filter((key) => key !== "title");
3727
+ const values = /* @__PURE__ */ new Map();
3728
+ if (shown.length > 0) {
3729
+ const rows = db.all(`SELECT page_id, key, value FROM properties
3730
+ WHERE page_id IN (SELECT value FROM json_each(?))
3731
+ AND key_lc IN (SELECT value FROM json_each(?))
3732
+ AND value IS NOT NULL
3733
+ ORDER BY rowid`, [JSON.stringify(pages.map((page) => page.id)), JSON.stringify(shown)]);
3734
+ for (const row of rows) {
3735
+ const list = values.get(row.page_id);
3736
+ const entry = {
3737
+ key: row.key,
3738
+ value: row.value
3739
+ };
3740
+ if (list) list.push(entry);
3741
+ else values.set(row.page_id, [entry]);
3742
+ }
3743
+ }
3744
+ return pages.map((page) => ({
3745
+ concept: page.concept,
3746
+ kind: page.kind,
3747
+ properties: values.get(page.id) ?? []
3748
+ }));
3749
+ }
3490
3750
  /** Escape a literal for a LIKE pattern, so an asset name can never behave as a wildcard. */
3491
3751
  function likeLiteral(needle) {
3492
3752
  return needle.replace(/[\\%_]/g, (c) => `\\${c}`);
@@ -4010,7 +4270,7 @@ function cacheRoot(env) {
4010
4270
  * recovered from a joined path (see there).
4011
4271
  */
4012
4272
  var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
4013
- var INDEX_FILE_NAME = `index.v13.sqlite`;
4273
+ var INDEX_FILE_NAME = `index.v14.sqlite`;
4014
4274
  var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
4015
4275
  function cacheFile(dir) {
4016
4276
  return join(dir, CACHE_FILE_NAME);
@@ -6396,6 +6656,172 @@ function parseFrontmatter(text) {
6396
6656
  }
6397
6657
  }
6398
6658
  //#endregion
6659
+ //#region ../client/src/lib/document/frontmatter/frontmatter-yaml.ts
6660
+ /**
6661
+ * Reading, editing and writing a [[Frontmatter]] block's YAML in the one style EtherPK writes
6662
+ * (ADR 0108).
6663
+ *
6664
+ * Every writer goes through here: the identity write-back, publishing, the arbitrary-key patch,
6665
+ * the importers, Add frontmatter and the editor's tidy when an editing episode ends. Edits go
6666
+ * through the `yaml` library's document model rather than a plain object, so a comment, and any
6667
+ * key the edit does not touch, survives. The block is then written in the EtherPK style:
6668
+ *
6669
+ * - two-space indentation, a list under its key as ` - item`, one item per line;
6670
+ * - an inline list (`[a, b]`) written as a block list; an empty `[]` written bare on a key EtherPK
6671
+ * reads (`publications:`), where empty means not set, and kept on any other key;
6672
+ * - quotes only where a value needs them (`"true"` stays quoted, `"hello"` does not);
6673
+ * - no line wrapping, no blank lines, key order as found, comments kept;
6674
+ * - an empty value written bare (`slug:`), which reads as "fill this in".
6675
+ *
6676
+ * Restyling never changes what the block says: the value is compared before and after, and a
6677
+ * rewrite that would change it is dropped. A block that does not parse is never rewritten.
6678
+ *
6679
+ * Pure: no store, no editor. What counts as a block comes from `frontmatter-span.ts`.
6680
+ */
6681
+ /** The `yaml` serialiser options that make up the EtherPK style. */
6682
+ var STYLE = {
6683
+ indent: 2,
6684
+ indentSeq: true,
6685
+ lineWidth: 0,
6686
+ minContentWidth: 0,
6687
+ nullStr: ""
6688
+ };
6689
+ /**
6690
+ * The block's YAML parsed as a document, or null when it does not parse or is not a mapping.
6691
+ * An empty block (or one holding only comments) is an empty mapping.
6692
+ */
6693
+ function parseBlock(body) {
6694
+ const doc = parseDocument(body);
6695
+ if (doc.errors.length > 0) return null;
6696
+ if (doc.contents === null) {
6697
+ doc.contents = new YAMLMap();
6698
+ return doc;
6699
+ }
6700
+ return isMap(doc.contents) ? doc : null;
6701
+ }
6702
+ /** The block's YAML as a plain object, or null when it does not parse to a mapping. */
6703
+ function frontmatterData(body) {
6704
+ const doc = parseBlock(body);
6705
+ return doc === null ? null : doc.toJS();
6706
+ }
6707
+ /**
6708
+ * The keys EtherPK reads, on which an empty value means the key is not set (ADR 0108, point 5).
6709
+ * On these an empty `[]` and a bare key say the same thing, so the style writes it bare. Another
6710
+ * key's `[]` is kept: to a tool that reads it, an empty list and no value may differ.
6711
+ */
6712
+ var NOT_SET_WHEN_EMPTY = new Set([
6713
+ "title",
6714
+ "aliases",
6715
+ "public",
6716
+ "publications",
6717
+ "slug",
6718
+ "date",
6719
+ "publication"
6720
+ ]);
6721
+ /** Apply the EtherPK style to a document in place. */
6722
+ function applyStyle(doc) {
6723
+ visit(doc, {
6724
+ Pair(_, pair, path) {
6725
+ if (path.length !== 2) return;
6726
+ const key = isScalar(pair.key) ? pair.key.value : pair.key;
6727
+ if (typeof key === "string" && NOT_SET_WHEN_EMPTY.has(key) && isSeq(pair.value) && pair.value.items.length === 0) pair.value = new Scalar(null);
6728
+ },
6729
+ Scalar(_, node) {
6730
+ node.spaceBefore = false;
6731
+ if (typeof node.value === "string" && !node.value.includes("\n") && (node.type === "QUOTE_DOUBLE" || node.type === "QUOTE_SINGLE")) node.type = "PLAIN";
6732
+ },
6733
+ Map(_, node) {
6734
+ node.spaceBefore = false;
6735
+ if (node.items.length > 0) node.flow = false;
6736
+ },
6737
+ Seq(_, node) {
6738
+ node.spaceBefore = false;
6739
+ if (node.items.length > 0) node.flow = false;
6740
+ }
6741
+ });
6742
+ }
6743
+ /** The styled YAML of a document, ending in a newline, or '' when it has no keys and no comment. */
6744
+ function styledYaml(doc) {
6745
+ applyStyle(doc);
6746
+ const map = doc.contents;
6747
+ if (isMap(map) && map.items.length === 0) return doc.commentBefore ? `${doc.commentBefore.replace(/^/gm, "#")}\n` : "";
6748
+ return doc.toString(STYLE);
6749
+ }
6750
+ /** Deep equality over the plain values a block parses to. */
6751
+ function sameValue(a, b) {
6752
+ return JSON.stringify(a) === JSON.stringify(b);
6753
+ }
6754
+ /**
6755
+ * A new block holding `data`, in the EtherPK style: `---\n…---\n`, or '' when `data` has no keys.
6756
+ * For a document that has no block yet; to change a block that exists use {@link editFrontmatter},
6757
+ * which keeps what the block already says.
6758
+ */
6759
+ function renderFrontmatter(data) {
6760
+ if (Object.keys(data).length === 0) return "";
6761
+ return `---\n${styledYaml(new Document(data))}---\n`;
6762
+ }
6763
+ /** The line terminator a block uses, so a rewrite does not mix endings in a CRLF file. */
6764
+ function eolOf(text, end) {
6765
+ return text.slice(0, end).includes("\r\n") ? "\r\n" : "\n";
6766
+ }
6767
+ function withEol(yaml, eol) {
6768
+ return eol === "\n" ? yaml : yaml.replace(/\n/g, eol);
6769
+ }
6770
+ function editorFor(doc) {
6771
+ const map = doc.contents;
6772
+ const keyOf = (item) => String(isScalar(item.key) ? item.key.value : item.key);
6773
+ return {
6774
+ has: (key) => map.has(key),
6775
+ get: (key) => map.has(key) ? doc.toJS()[key] : void 0,
6776
+ set(key, value, where = "last") {
6777
+ if (map.has(key)) {
6778
+ const current = doc.toJS()[key];
6779
+ if (sameValue(current, value)) return;
6780
+ map.set(key, doc.createNode(value));
6781
+ return;
6782
+ }
6783
+ const pair = doc.createPair(key, value);
6784
+ if (where === "first") map.items.unshift(pair);
6785
+ else map.items.push(pair);
6786
+ },
6787
+ delete: (key) => {
6788
+ map.delete(key);
6789
+ },
6790
+ keys: () => map.items.map(keyOf)
6791
+ };
6792
+ }
6793
+ /**
6794
+ * The text with its block changed by `edit`, then written in the EtherPK style. The same string
6795
+ * comes back when the edit changes nothing the block says, so a writer that runs on every save
6796
+ * never restyles a block it agrees with. A block whose YAML does not parse is left alone: rewriting
6797
+ * it would destroy whatever the person was typing. A block the edit empties of every key is
6798
+ * removed.
6799
+ */
6800
+ function editFrontmatter(text, edit, options = {}) {
6801
+ const span = frontmatterSpan(text);
6802
+ if (!span && !options.addBlock) return text;
6803
+ const doc = span ? parseBlock(span.body) : parseBlock("");
6804
+ if (doc === null) return text;
6805
+ const before = doc.toJS();
6806
+ edit(editorFor(doc));
6807
+ if (sameValue(doc.toJS(), before)) return text;
6808
+ const body = span ? text.slice(span.end) : text;
6809
+ if (doc.contents.items.length === 0) return body;
6810
+ const eol = span ? eolOf(text, span.end) : "\n";
6811
+ return `---${eol}${withEol(styledYaml(doc), eol)}---${eol}${body}`;
6812
+ }
6813
+ /**
6814
+ * Whether a value counts as not set (ADR 0108): nothing, a blank string, an empty list, or a
6815
+ * mapping whose every value is itself empty. `false` and `0` are values.
6816
+ */
6817
+ function isEmptyValue(value) {
6818
+ if (value === null || value === void 0) return true;
6819
+ if (typeof value === "string") return value.trim() === "";
6820
+ if (Array.isArray(value)) return value.length === 0;
6821
+ if (typeof value === "object" && !(value instanceof Date)) return Object.values(value).every(isEmptyValue);
6822
+ return false;
6823
+ }
6824
+ //#endregion
6399
6825
  //#region ../client/src/lib/document/publish/publication.ts
6400
6826
  /** A publication id: kebab-case, what `publications:` entries and `theme:` graph ids look like. */
6401
6827
  var PUBLICATION_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -6411,13 +6837,19 @@ function readMembership(text, concept) {
6411
6837
  const { data } = parseFrontmatter(text);
6412
6838
  const issues = [];
6413
6839
  const isPublic = data.public === true;
6840
+ if (!isEmptyValue(data.public) && typeof data.public !== "boolean") issues.push({
6841
+ level: "warning",
6842
+ code: "public-not-a-boolean",
6843
+ 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.`,
6844
+ concept
6845
+ });
6414
6846
  const publications = [];
6415
- const raw = data.publications;
6416
- const entries = raw === void 0 ? [] : Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : null;
6847
+ const raw = isEmptyValue(data.publications) ? void 0 : data.publications;
6848
+ const entries = raw === void 0 ? [] : Array.isArray(raw) ? raw : null;
6417
6849
  if (entries === null) issues.push({
6418
6850
  level: "warning",
6419
6851
  code: "publications-not-a-list",
6420
- message: "`publications` must be a list of publication ids, like `publications: [docs, blog]`.",
6852
+ 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
6853
  concept
6422
6854
  });
6423
6855
  else for (const entry of entries) if (isPublicationId(entry)) {
@@ -6434,13 +6866,33 @@ function readMembership(text, concept) {
6434
6866
  issues
6435
6867
  };
6436
6868
  }
6869
+ var DATE = /^\d{4}-\d{2}-\d{2}$/;
6870
+ /**
6871
+ * The day a page is dated on a blog, from its `date:` key, and a warning when the key holds
6872
+ * something that is not a calendar day. A journal entry is dated by its name, never by this key.
6873
+ */
6874
+ function readPageDate(doc) {
6875
+ const date = parseFrontmatter(doc.text).data.date;
6876
+ if (isEmptyValue(date)) return { issues: [] };
6877
+ const text = date instanceof Date ? date.toISOString().slice(0, 10) : String(date);
6878
+ if (DATE.test(text)) return {
6879
+ date: text,
6880
+ issues: []
6881
+ };
6882
+ return { issues: [{
6883
+ level: "warning",
6884
+ code: "invalid-date",
6885
+ message: `"${doc.concept}" has \`date: ${String(date)}\`, which is not a calendar day (YYYY-MM-DD); the document is undated.`,
6886
+ concept: doc.concept
6887
+ }] };
6888
+ }
6437
6889
  var KINDS$1 = ["docs", "blog"];
6438
6890
  var SELECTIONS = ["named", "all-public"];
6439
6891
  /** The publication a page defines through its `publication:` mapping, validated. */
6440
6892
  function readPublicationDefinition(doc) {
6441
6893
  const { data, body } = parseFrontmatter(doc.text);
6442
6894
  const raw = data.publication;
6443
- if (raw === void 0) return {
6895
+ if (isEmptyValue(raw)) return {
6444
6896
  publication: null,
6445
6897
  issues: []
6446
6898
  };
@@ -6465,7 +6917,7 @@ function readPublicationDefinition(doc) {
6465
6917
  issues
6466
6918
  };
6467
6919
  }
6468
- const def = raw;
6920
+ const def = Object.fromEntries(Object.entries(raw).filter(([, value]) => !isEmptyValue(value)));
6469
6921
  const id = def.id;
6470
6922
  if (id === void 0 || id === null || id === "") issue("publication-missing-id", "`publication` needs an `id`: lower-case letters, digits and hyphens, like `docs`.");
6471
6923
  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 +6938,16 @@ function readPublicationDefinition(doc) {
6486
6938
  if (def.recent !== void 0) if (typeof def.recent === "number" && Number.isInteger(def.recent) && def.recent > 0) recent = def.recent;
6487
6939
  else issue("publication-invalid-recent", "`recent` must be a whole number above zero: how many posts the front page lists (10 unless set).");
6488
6940
  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
- });
6941
+ 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)) {
6942
+ if (isEmptyValue(value)) continue;
6943
+ if (typeof value === "string" && value.trim() !== "") includes[slot] = value.trim();
6944
+ else issues.push({
6945
+ level: "warning",
6946
+ code: "publication-invalid-include",
6947
+ message: `Include \`${slot}\` must name the page that fills it; it is ignored.`,
6948
+ concept: doc.concept
6949
+ });
6950
+ }
6496
6951
  else issue("publication-invalid-includes", "`includes` must be a mapping of include slot to the page that fills it.");
6497
6952
  if (issues.some((i) => i.level === "error")) return {
6498
6953
  publication: null,
@@ -8557,7 +9012,7 @@ function createIndexCore(host) {
8557
9012
  }];
8558
9013
  }
8559
9014
  case "search-text": {
8560
- const page = db ? searchText$1(db, request.query, request.offset, request.limit) : {
9015
+ const page = db ? searchText$1(db, request.query, request.offset, request.limit, request.filters ?? []) : {
8561
9016
  groups: [],
8562
9017
  hasMore: false
8563
9018
  };
@@ -8569,7 +9024,7 @@ function createIndexCore(host) {
8569
9024
  }];
8570
9025
  }
8571
9026
  case "search-count": {
8572
- const count = db ? searchTextCount(db, request.query) : {
9027
+ const count = db ? searchTextCount(db, request.query, request.filters ?? []) : {
8573
9028
  total: 0,
8574
9029
  capped: false
8575
9030
  };
@@ -8580,6 +9035,21 @@ function createIndexCore(host) {
8580
9035
  capped: count.capped
8581
9036
  }];
8582
9037
  }
9038
+ case "property-keys": return [{
9039
+ type: "property-keys",
9040
+ id: request.id,
9041
+ keys: db ? propertyKeys(db) : []
9042
+ }];
9043
+ case "property-values": return [{
9044
+ type: "property-values",
9045
+ id: request.id,
9046
+ values: db ? propertyValues(db, request.key) : []
9047
+ }];
9048
+ case "property-match": return [{
9049
+ type: "property-match",
9050
+ id: request.id,
9051
+ documents: db ? documentsMatchingProperties(db, request.filters) : []
9052
+ }];
8583
9053
  case "tasks": {
8584
9054
  const page = db ? tasksMatching(db, request.query, request.offset, request.limit) : {
8585
9055
  hits: [],
@@ -9297,30 +9767,52 @@ function createRemoteGraphIndex(source, transport, options) {
9297
9767
  needles: [...needles]
9298
9768
  }))).usage;
9299
9769
  },
9300
- async searchText(query, offset, limit) {
9770
+ async searchText(query, offset, limit, filters) {
9301
9771
  const response = await request((id) => ({
9302
9772
  type: "search-text",
9303
9773
  id,
9304
9774
  query,
9305
9775
  offset,
9306
- limit
9776
+ limit,
9777
+ ...filters?.length ? { filters: [...filters] } : {}
9307
9778
  }));
9308
9779
  return {
9309
9780
  groups: response.groups,
9310
9781
  hasMore: response.hasMore
9311
9782
  };
9312
9783
  },
9313
- async searchTextCount(query) {
9784
+ async searchTextCount(query, filters) {
9314
9785
  const response = await request((id) => ({
9315
9786
  type: "search-count",
9316
9787
  id,
9317
- query
9788
+ query,
9789
+ ...filters?.length ? { filters: [...filters] } : {}
9318
9790
  }));
9319
9791
  return {
9320
9792
  total: response.total,
9321
9793
  capped: response.capped
9322
9794
  };
9323
9795
  },
9796
+ async propertyKeys() {
9797
+ return (await request((id) => ({
9798
+ type: "property-keys",
9799
+ id
9800
+ }))).keys;
9801
+ },
9802
+ async propertyValues(key) {
9803
+ return (await request((id) => ({
9804
+ type: "property-values",
9805
+ id,
9806
+ key
9807
+ }))).values;
9808
+ },
9809
+ async propertyMatch(filters) {
9810
+ return (await request((id) => ({
9811
+ type: "property-match",
9812
+ id,
9813
+ filters: [...filters]
9814
+ }))).documents;
9815
+ },
9324
9816
  async tasks(query, offset, limit) {
9325
9817
  const response = await request((id) => ({
9326
9818
  type: "tasks",
@@ -10067,7 +10559,7 @@ async function deleteGraphTheme(adapter, id) {
10067
10559
  /** What the block claims. An unterminated block is not a block, as everywhere else. */
10068
10560
  function frontmatterIdentity(text) {
10069
10561
  const span = frontmatterSpan(text);
10070
- const data = span ? parseBlock$2(span.body) : {};
10562
+ const data = span ? frontmatterData(span.body) : {};
10071
10563
  if (data === null) return {
10072
10564
  title: null,
10073
10565
  aliases: [],
@@ -10119,37 +10611,19 @@ function sameAliases(a, b) {
10119
10611
  */
10120
10612
  function withFrontmatterIdentity(text, patch, options = {}) {
10121
10613
  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;
10614
+ if (span) {
10615
+ const data = frontmatterData(span.body);
10616
+ if (data === null) return text;
10617
+ const wantTitle = patch.title === void 0 ? titleOf(data) : patch.title;
10618
+ const wantAliases = patch.aliases === void 0 ? aliasesOf({ data }) : patch.aliases;
10619
+ if (wantTitle === titleOf(data) && sameAliases(wantAliases, aliasesOf({ data }))) return text;
10150
10620
  }
10151
- if (wantAliases.length > 0 && !("aliases" in data)) next.aliases = wantAliases;
10152
- return `---\n${serialise(next)}---\n${text.slice(span.end)}`;
10621
+ return editFrontmatter(text, (block) => {
10622
+ if (patch.title !== void 0) if (patch.title === null || patch.title.trim() === "") block.delete("title");
10623
+ else block.set("title", patch.title, "first");
10624
+ if (patch.aliases !== void 0) if (patch.aliases.length > 0) block.set("aliases", [...patch.aliases]);
10625
+ else block.delete("aliases");
10626
+ }, options);
10153
10627
  }
10154
10628
  /**
10155
10629
  * `after` - a writer's rewrite of `before` - with the document's aliases carried into the block
@@ -10164,19 +10638,83 @@ function withAliasesInAddedBlock(before, after, aliases) {
10164
10638
  if (aliases.length === 0 || frontmatterSpan(before) !== null || frontmatterSpan(after) === null) return after;
10165
10639
  return withFrontmatterIdentity(after, { aliases });
10166
10640
  }
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;
10641
+ //#endregion
10642
+ //#region ../client/src/lib/document/properties.ts
10643
+ /**
10644
+ * Keys a Property Filter answers from the graph's names rather than the block: the registry on
10645
+ * a Server Backend holds them, and a page created in the app there has no block to read.
10646
+ */
10647
+ var NAME_PROPERTY_KEYS = new Set(["title", "aliases"]);
10648
+ /** Rows per document at most. Frontmatter is metadata; a block past this is not a label set. */
10649
+ var MAX_ROWS = 256;
10650
+ /** Characters of one value kept. Longer text is prose, which Search's text group covers. */
10651
+ var MAX_VALUE_LENGTH = 1e3;
10652
+ /**
10653
+ * Whether a value counts as not set (ADR 0108): null, blank text, a list with no set item, or a
10654
+ * mapping with no set value. `false` and `0` are values.
10655
+ */
10656
+ function isEmptyPropertyValue(value) {
10657
+ if (value === null || value === void 0) return true;
10658
+ if (typeof value === "string") return value.trim() === "";
10659
+ if (Array.isArray(value)) return value.every(isEmptyPropertyValue);
10660
+ if (isMapping(value)) return Object.values(value).every(isEmptyPropertyValue);
10661
+ return false;
10662
+ }
10663
+ /**
10664
+ * The rows for one document's parsed Frontmatter, in the block's order. Empty values are left
10665
+ * out, and so are `title` and `aliases` in any spelling: those filters read the graph's names.
10666
+ */
10667
+ function propertiesOf(data) {
10668
+ const out = [];
10669
+ for (const [key, value] of Object.entries(data)) {
10670
+ if (NAME_PROPERTY_KEYS.has(key.toLowerCase())) continue;
10671
+ collect(key, value, out);
10672
+ if (out.length >= MAX_ROWS) break;
10673
+ }
10674
+ return out.slice(0, MAX_ROWS);
10675
+ }
10676
+ /** Append the rows for `value` under `path`; a mapping adds its presence row before its values. */
10677
+ function collect(path, value, out) {
10678
+ if (isEmptyPropertyValue(value)) return;
10679
+ if (Array.isArray(value)) {
10680
+ let presence = false;
10681
+ for (const item of value) if (isMapping(item)) {
10682
+ if (!presence && !isEmptyPropertyValue(item)) {
10683
+ out.push({
10684
+ key: path,
10685
+ value: null
10686
+ });
10687
+ presence = true;
10688
+ }
10689
+ for (const [key, child] of Object.entries(item)) collect(`${path}.${key}`, child, out);
10690
+ } else collect(path, item, out);
10691
+ return;
10692
+ }
10693
+ if (isMapping(value)) {
10694
+ out.push({
10695
+ key: path,
10696
+ value: null
10697
+ });
10698
+ for (const [key, child] of Object.entries(value)) collect(`${path}.${key}`, child, out);
10699
+ return;
10176
10700
  }
10701
+ out.push({
10702
+ key: path,
10703
+ value: textOf(value).slice(0, MAX_VALUE_LENGTH)
10704
+ });
10177
10705
  }
10178
- function serialise(data) {
10179
- return Object.keys(data).length === 0 ? "" : stringify(data);
10706
+ function isMapping(value) {
10707
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
10708
+ }
10709
+ /** A scalar as the text a person would type to find it. */
10710
+ function textOf(value) {
10711
+ if (value instanceof Date) {
10712
+ const iso = value.toISOString();
10713
+ return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
10714
+ }
10715
+ if (typeof value === "string") return value;
10716
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
10717
+ return JSON.stringify(value) ?? "";
10180
10718
  }
10181
10719
  //#endregion
10182
10720
  //#region ../client/src/lib/document/calendar/month-grid-core.ts
@@ -10757,23 +11295,6 @@ async function scanGraph(adapter, options = {}) {
10757
11295
  //#endregion
10758
11296
  //#region ../client/src/lib/storage/fs/filesystem-store.ts
10759
11297
  /**
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
11298
  * The snapshot for one document's full text: identity from the registry, aliases and include
10778
11299
  * facts from the frontmatter, the body for the index. Both backends build theirs this way, so
10779
11300
  * the index sees one shape whatever holds the document.
@@ -10786,12 +11307,14 @@ function indexSnapshotOf(identity, text) {
10786
11307
  text,
10787
11308
  aliases: []
10788
11309
  }) : [];
11310
+ const properties = propertiesOf(fm.data);
10789
11311
  return {
10790
11312
  concept: identity.concept,
10791
11313
  kind: identity.kind,
10792
11314
  aliases: aliasesOf(fm),
10793
11315
  text: fm.body,
10794
- ...includes.length > 0 ? { includes } : {}
11316
+ ...includes.length > 0 ? { includes } : {},
11317
+ ...properties.length > 0 ? { properties } : {}
10795
11318
  };
10796
11319
  }
10797
11320
  function applyTextChange(text, change) {
@@ -10964,7 +11487,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10964
11487
  const fm = parseFrontmatter(text);
10965
11488
  let aliases = aliasesOf(fm);
10966
11489
  let body = fm.body;
10967
- let data = { ...fm.data };
11490
+ let blockSource = text;
10968
11491
  if (strategy === "alias") {
10969
11492
  if (!aliases.some((a) => conceptKey(a) === conceptKey(step.from))) aliases = [...aliases, step.from];
10970
11493
  }
@@ -10983,15 +11506,17 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10983
11506
  });
10984
11507
  body = merged.body;
10985
11508
  aliases = merged.aliases;
10986
- data = { ...existingFm.data };
11509
+ blockSource = existing.text;
10987
11510
  }
10988
11511
  aliases = normaliseAliases(aliases, step.into);
10989
- data.title = step.into;
10990
- if (aliases.length > 0) data.aliases = aliases;
10991
- else delete data.aliases;
11512
+ const blockSpan = frontmatterSpan(blockSource);
11513
+ const content = withFrontmatterIdentity((blockSpan ? blockSource.slice(0, blockSpan.end) : "") + body, {
11514
+ title: step.into,
11515
+ aliases
11516
+ }, { addBlock: true });
10992
11517
  const subdir = survivor?.subdir ?? entry.subdir;
10993
11518
  const fileName = survivor?.fileName ?? await allocateFileName(subdir, step.into, entry);
10994
- const written = await adapter.write(subdir, fileName, `---\n${stringify(data)}---\n${body}`);
11519
+ const written = await adapter.write(subdir, fileName, content);
10995
11520
  if (!(subdir === entry.subdir && fileName === entry.fileName)) await adapter.remove(entry.subdir, entry.fileName);
10996
11521
  registry.delete(entry.key);
10997
11522
  if (survivor) registry.delete(survivor.key);
@@ -11354,7 +11879,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
11354
11879
  if (registry.get(key)) throw new Error(`A document for "${concept}" already exists.`);
11355
11880
  await adapter.ensureSkeleton();
11356
11881
  const fileName = await allocateFileName("pages", concept);
11357
- const content = `---\n${stringify({ title: concept })}---\n${body}`;
11882
+ const content = renderFrontmatter({ title: concept }) + body;
11358
11883
  await adapter.write("pages", fileName, content);
11359
11884
  await refreshRegistry();
11360
11885
  return concept;
@@ -11847,12 +12372,14 @@ function createServerDocumentStore(graph, options) {
11847
12372
  text,
11848
12373
  aliases: []
11849
12374
  }) : [];
12375
+ const properties = propertiesOf(parseFrontmatter(text).data);
11850
12376
  return {
11851
12377
  concept,
11852
12378
  kind: entry.kind,
11853
12379
  aliases: entry.aliases ?? [],
11854
12380
  text: bodyForIndex(text),
11855
- ...includes.length > 0 ? { includes } : {}
12381
+ ...includes.length > 0 ? { includes } : {},
12382
+ ...properties.length > 0 ? { properties } : {}
11856
12383
  };
11857
12384
  }
11858
12385
  /**
@@ -13005,8 +13532,9 @@ function assembleHeadlessGraph(parts) {
13005
13532
  }
13006
13533
  /** Open a synced graph, scan its registry and build the index; resolves once tools can answer. */
13007
13534
  async function openHeadlessGraph(deps) {
13008
- const cache = await openGraphCache(deps.graphId);
13009
- if (deps.persistDir) await loadLocalCache(deps.persistDir, deps.graphId);
13535
+ const cacheName = deps.localCacheName ?? deps.graphId;
13536
+ const cache = await openGraphCache(cacheName);
13537
+ if (deps.persistDir) await loadLocalCache(deps.persistDir, cacheName);
13010
13538
  const indexHost = deps.persistDir ? nodeIndexHost(deps.persistDir) : void 0;
13011
13539
  const sync = createGraphSync({
13012
13540
  graphId: deps.graphId,
@@ -13135,7 +13663,7 @@ async function openHeadlessGraph(deps) {
13135
13663
  embeddingModel: deps.embeddingModel,
13136
13664
  onSemanticProgress: deps.onSemanticProgress,
13137
13665
  onError: deps.onError,
13138
- persistBackend: deps.persistDir ? () => persistLocalCache(deps.persistDir, deps.graphId) : void 0,
13666
+ persistBackend: deps.persistDir ? () => persistLocalCache(deps.persistDir, cacheName) : void 0,
13139
13667
  onChange: (schedule) => sync.onDocUpdate(schedule),
13140
13668
  async settle(schedulePersist) {
13141
13669
  await sync.flushAll();
@@ -13989,134 +14517,43 @@ function bundledThemeList() {
13989
14517
  //#region ../client/src/lib/document/frontmatter/patch.ts
13990
14518
  /**
13991
14519
  * 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
14520
+ * rewrites identity: other keys keep their values, order and comments, the same string comes back
14521
+ * when nothing would change, and a block whose YAML does not parse is left alone rather than
13994
14522
  * destroyed. `null` removes a key. A block emptied of every key is removed with it. Identity keys
13995
14523
  * are not this function's business: pass them through `withFrontmatterIdentity` so the registry
13996
14524
  * rules (ADR 0061) apply.
13997
14525
  */
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
14526
  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)}`;
14527
+ return editFrontmatter(text, (block) => {
14528
+ for (const [key, value] of Object.entries(patch)) if (value === null || value === void 0) block.delete(key);
14529
+ else block.set(key, value);
14530
+ }, options);
14045
14531
  }
14046
14532
  //#endregion
14047
14533
  //#region ../client/src/lib/document/frontmatter/publishing.ts
14048
14534
  /**
14049
14535
  * 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
- }
14536
+ * switch, and `publications`, a list of publication ids, routes. A public document with no
14537
+ * publication keeps the key, written bare (`publications:`): public with nowhere to go is a state
14538
+ * worth a prompt, and the empty key is that prompt to whoever edits the file by hand. Bare rather
14539
+ * than `[]` because that is how the EtherPK style writes every empty value (ADR 0108), and because
14540
+ * Enter after a bare key opens the next line one level in, ready for the first `- id`. Not public, the empty key is
14541
+ * removed. Rewritten the way `withFrontmatterIdentity` rewrites identity: other keys keep their
14542
+ * values and order, the same string comes back when nothing would change, and a block whose YAML
14543
+ * does not parse is left alone rather than destroyed. Read through `readMembership` in
14544
+ * `publish/publication.ts`, which reads a bare key and `[]` alike; this file only writes.
14545
+ */
14070
14546
  /** The text with its publishing keys rewritten. `addBlock` adds a block to a document without one. */
14071
14547
  function withPublishing(text, patch, options = {}) {
14072
14548
  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)}`;
14549
+ return editFrontmatter(text, (block) => {
14550
+ if (patch.public === null) block.delete("public");
14551
+ else if (patch.public !== void 0) block.set("public", patch.public);
14552
+ if (publications === void 0) return;
14553
+ const keepEmpty = (patch.public === void 0 ? block.get("public") : patch.public) === true;
14554
+ if (publications === null || publications.length === 0 && !keepEmpty) block.delete("publications");
14555
+ else block.set("publications", publications.length === 0 ? null : publications);
14556
+ }, options);
14120
14557
  }
14121
14558
  //#endregion
14122
14559
  //#region ../client/src/lib/document/publish/seeded.ts
@@ -15211,7 +15648,7 @@ function allocate(documents) {
15211
15648
  const derived = [];
15212
15649
  for (const doc of documents) {
15213
15650
  const value = parseFrontmatter(doc.text).data.slug;
15214
- if (value === void 0) {
15651
+ if (isEmptyValue(value)) {
15215
15652
  derived.push(doc);
15216
15653
  continue;
15217
15654
  }
@@ -15298,7 +15735,6 @@ function createThemeRenderer(theme, includes) {
15298
15735
  * a pre-render pass for the asynchronous bits (diagrams, code); then rendering, includes, the
15299
15736
  * theme, the derived files. Everything derived is computed from the included documents alone.
15300
15737
  */
15301
- var DATE = /^\d{4}-\d{2}-\d{2}$/;
15302
15738
  function assetHrefOf(name) {
15303
15739
  return `assets/${encodeURIComponent(name)}`;
15304
15740
  }
@@ -15542,17 +15978,9 @@ async function publishPublication(source, publication, env, options = {}) {
15542
15978
  };
15543
15979
  if (doc.kind === "journal") page.date = doc.concept;
15544
15980
  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
- }
15981
+ const dated = readPageDate(doc);
15982
+ if (dated.date !== void 0) page.date = dated.date;
15983
+ take(dated.issues);
15556
15984
  }
15557
15985
  for (const link of linksOf(bodyOf(doc), resolver, doc)) report.missingLinks.push(link);
15558
15986
  pages.push(page);
@@ -16765,7 +17193,7 @@ function agentFrontmatter(rawText) {
16765
17193
  /**
16766
17194
  * The block after a patch. `public` and `publications` go through the publishing writer, so its
16767
17195
  * 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
17196
+ * document with an empty list keeps the key, written bare, as the prompt it is. Everything else is a
16769
17197
  * plain key. An invalid publication id is refused rather than dropped, which is what the
16770
17198
  * writer would do: an agent that misspells an id must hear about it.
16771
17199
  */
@@ -16948,7 +17376,24 @@ function round(similarity) {
16948
17376
  return Math.round(similarity * 1e3) / 1e3;
16949
17377
  }
16950
17378
  async function searchText(graph, query, offset, limit) {
16951
- const [result, count] = await Promise.all([graph.index.searchText(query, offset, limit), graph.index.searchTextCount(query)]);
17379
+ const keys = await graph.index.propertyKeys();
17380
+ const { words, filters } = parseSearchQuery(query, new Set(keys.map((info) => info.key.toLowerCase())));
17381
+ if (filters.length > 0 && words.trim() === "") {
17382
+ const documents = await graph.index.propertyMatch(filters);
17383
+ return {
17384
+ results: documents.slice(offset, offset + limit).map((doc) => ({
17385
+ concept: doc.concept,
17386
+ kind: doc.kind,
17387
+ matches: 0,
17388
+ hits: [],
17389
+ properties: doc.properties
17390
+ })),
17391
+ total: documents.length,
17392
+ totalCapped: false,
17393
+ hasMore: offset + limit < documents.length
17394
+ };
17395
+ }
17396
+ const [result, count] = await Promise.all([graph.index.searchText(words, offset, limit, filters), graph.index.searchTextCount(words, filters)]);
16952
17397
  return {
16953
17398
  results: result.groups.map((group) => ({
16954
17399
  concept: group.concept,
@@ -18518,7 +18963,7 @@ function createMcpServer(graph, info) {
18518
18963
  }, async (args) => run(() => readDocuments(graph, args)));
18519
18964
  server.registerTool("search", {
18520
18965
  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.`,
18966
+ 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 (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
18967
  inputSchema: {
18523
18968
  query: z.string().min(1),
18524
18969
  mode: z.enum([