@openparachute/vault 0.7.0-rc.8 → 0.7.1

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.
Files changed (38) hide show
  1. package/core/src/aggregate.test.ts +260 -0
  2. package/core/src/contract-taxonomy.test.ts +26 -2
  3. package/core/src/contract-typed-index.test.ts +4 -2
  4. package/core/src/core.test.ts +1151 -0
  5. package/core/src/cursor.ts +1 -0
  6. package/core/src/doctor.ts +23 -1
  7. package/core/src/indexed-fields.test.ts +4 -1
  8. package/core/src/indexed-fields.ts +6 -1
  9. package/core/src/links.ts +22 -0
  10. package/core/src/mcp.ts +587 -79
  11. package/core/src/notes.ts +371 -18
  12. package/core/src/query-warnings.ts +60 -12
  13. package/core/src/schema-defaults.ts +7 -1
  14. package/core/src/seed-packs.test.ts +14 -0
  15. package/core/src/seed-packs.ts +42 -5
  16. package/core/src/store.ts +129 -5
  17. package/core/src/tag-schemas.ts +20 -7
  18. package/core/src/types.ts +98 -0
  19. package/core/src/ulid.test.ts +56 -0
  20. package/core/src/ulid.ts +116 -0
  21. package/core/src/vault-projection.ts +19 -1
  22. package/core/src/wikilinks.test.ts +205 -1
  23. package/core/src/wikilinks.ts +244 -35
  24. package/package.json +1 -1
  25. package/src/aggregate-routes.test.ts +230 -0
  26. package/src/contract-errors.test.ts +2 -1
  27. package/src/contract-search.test.ts +17 -0
  28. package/src/mcp-link-warnings-scope.test.ts +144 -0
  29. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  30. package/src/mcp-tools.ts +100 -19
  31. package/src/routes.ts +469 -39
  32. package/src/routing.test.ts +167 -0
  33. package/src/routing.ts +47 -11
  34. package/src/tag-integrity-mcp.test.ts +45 -6
  35. package/src/tag-scope.ts +10 -1
  36. package/src/vault.test.ts +923 -21
  37. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  38. package/web/ui/dist/index.html +1 -1
package/core/src/notes.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Database, type SQLQueryBindings } from "bun:sqlite";
2
- import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats } from "./types.js";
2
+ import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats, VaultMap, AggregateSpec, AggregateRow } from "./types.js";
3
3
  import { normalizePath } from "./paths.js";
4
4
  import { transaction } from "./txn.js";
5
5
  import {
@@ -28,8 +28,7 @@ import {
28
28
  SEARCH_WEIGHT_CONTENT,
29
29
  type SearchMode,
30
30
  } from "./search-query.js";
31
-
32
- let idCounter = 0;
31
+ import { generateUlid } from "./ulid.js";
33
32
 
34
33
  /**
35
34
  * Write-attribution context (vault#298) — the two axes of provenance threaded
@@ -61,20 +60,25 @@ function attrValue(v: string | null | undefined): string | null {
61
60
  return t.length === 0 ? null : t;
62
61
  }
63
62
 
64
- /** Generate a timestamp-based ID: YYYY-MM-DD-HH-MM-SS-ffffff */
63
+ /**
64
+ * Generate a new note/attachment ID.
65
+ *
66
+ * As of vault#ulid-ids this returns a ULID (see `ulid.ts`) — monotonic,
67
+ * lexicographically time-sortable, Crockford base32, opaque, and
68
+ * collision-resistant. Previously this returned a timestamp-format ID
69
+ * (`YYYY-MM-DD-HH-MM-SS-ffffff`).
70
+ *
71
+ * IMPORTANT: existing notes are NOT migrated. Old timestamp-format IDs
72
+ * stay exactly as they are — only newly-generated IDs use the ULID
73
+ * format, so a vault's `id` column is (and must remain) a mix of both
74
+ * shapes indefinitely. Nothing may assume a uniform id format, and
75
+ * nothing may parse a note's ID to recover its creation time — that's
76
+ * what the `created_at` column is for. The cursor-pagination tiebreaker
77
+ * (`cursor.ts`) treats `id` as an opaque, stable string for ordering
78
+ * ties only, which holds for any id format.
79
+ */
65
80
  export function generateId(): string {
66
- const now = new Date();
67
- const pad = (n: number, len = 2) => String(n).padStart(len, "0");
68
- const micro = now.getMilliseconds() * 1000 + (idCounter++ % 1000);
69
- return [
70
- now.getFullYear(),
71
- pad(now.getMonth() + 1),
72
- pad(now.getDate()),
73
- pad(now.getHours()),
74
- pad(now.getMinutes()),
75
- pad(now.getSeconds()),
76
- pad(micro, 6),
77
- ].join("-");
81
+ return generateUlid();
78
82
  }
79
83
 
80
84
  export function createNote(
@@ -183,6 +187,66 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
183
187
  );
184
188
  }
185
189
 
190
+ /**
191
+ * Extract a note's "H1 title" — the first line in `content` that starts
192
+ * with a literal `"# "` (a level-1 Markdown heading; `"## "`+ don't match
193
+ * because `^#[ \t]` fails at that line's start once a second `#` follows
194
+ * the first). Returns null when no such line exists, or the text after
195
+ * the marker is blank once trimmed.
196
+ */
197
+ export function extractH1Title(content: string): string | null {
198
+ const match = content.match(/^#[ \t]+(.+)$/m);
199
+ if (!match) return null;
200
+ const title = match[1]!.trim();
201
+ return title.length > 0 ? title : null;
202
+ }
203
+
204
+ /**
205
+ * Find every note whose H1 title (see {@link extractH1Title}) equals
206
+ * `title`, case-insensitively (matches the COLLATE NOCASE policy every
207
+ * other path/basename lookup in this file uses). Used as the title-fallback
208
+ * step for wikilink/id/path resolution — a target that misses on
209
+ * id/path/basename gets one more chance against notes whose displayed
210
+ * title differs from their path.
211
+ *
212
+ * This is a full-content scan: no index exists on "first heading line," so
213
+ * every note's content is read once and matched in JS. Acceptable because
214
+ * every call site reaches this ONLY after the cheap indexed lookups (id,
215
+ * path, basename) have already missed — a rare fallback path, not the hot
216
+ * resolution route.
217
+ */
218
+ export function findNotesByTitle(db: Database, title: string): { id: string; path: string | null }[] {
219
+ const needle = title.trim().toLowerCase();
220
+ if (!needle) return [];
221
+ const rows = db.prepare("SELECT id, path, content FROM notes").all() as {
222
+ id: string;
223
+ path: string | null;
224
+ content: string;
225
+ }[];
226
+ const matches: { id: string; path: string | null }[] = [];
227
+ for (const row of rows) {
228
+ const h1 = extractH1Title(row.content);
229
+ if (h1 && h1.toLowerCase() === needle) {
230
+ matches.push({ id: row.id, path: row.path });
231
+ }
232
+ }
233
+ return matches;
234
+ }
235
+
236
+ /**
237
+ * Title-fallback lookup: resolve `title` to a note ONLY when exactly one
238
+ * note in the vault carries that H1 title (see {@link findNotesByTitle}).
239
+ * Zero matches or 2+ matches (ambiguous) both return null — "don't guess"
240
+ * mirrors the existing basename-ambiguity policy (vault#328). Callers use
241
+ * this as the LAST resort after id and path/basename resolution have
242
+ * already missed; exact id/path matches always win first.
243
+ */
244
+ export function getNoteByTitle(db: Database, title: string): Note | null {
245
+ const matches = findNotesByTitle(db, title);
246
+ if (matches.length !== 1) return null;
247
+ return getNote(db, matches[0]!.id);
248
+ }
249
+
186
250
  export function getNotes(db: Database, ids: string[]): Note[] {
187
251
  if (ids.length === 0) return [];
188
252
  // Dedupe before chunking: a duplicate id straddling two chunk boundaries
@@ -769,8 +833,20 @@ function validateIsoDateValue(field: string, value: string): void {
769
833
  }
770
834
  }
771
835
 
772
- export function queryNotes(db: Database, opts: QueryOpts): Note[] {
773
- validateLimitOffset(opts);
836
+ /**
837
+ * Build the shared WHERE-clause conditions + bound params for the filter
838
+ * surface both `queryNotes` and `aggregateNotes` apply: tag membership
839
+ * (include/exclude/presence), link/broken-link presence, id-set scoping,
840
+ * exact path / path-prefix / extension, write-attribution, metadata
841
+ * (operator + plain-equality), and date range. Does NOT include the
842
+ * cursor-keyset predicate, ORDER BY, or LIMIT/OFFSET — those are
843
+ * query-shape concerns specific to `queryNotes`'s paginated-list contract
844
+ * and don't apply to an aggregate rollup (which spans every matching row,
845
+ * not a page of them). Extracted so aggregation reuses the EXACT filter
846
+ * semantics a normal query applies, rather than risking the two drifting
847
+ * apart.
848
+ */
849
+ function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: string[]; params: SQLQueryBindings[] } {
774
850
  const conditions: string[] = [];
775
851
  const params: SQLQueryBindings[] = [];
776
852
 
@@ -851,6 +927,32 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
851
927
  );
852
928
  }
853
929
 
930
+ // Presence: has_broken_links (vault#555) — a dangling outbound wikilink or
931
+ // structured `links` target that never resolved. The `unresolved_wikilinks`
932
+ // table is created lazily (see wikilinks.ts:ensureUnresolvedTable) only when
933
+ // a link actually goes unresolved — a vault where nothing ever has won't
934
+ // have the table at all. Check existence first rather than reference it
935
+ // unconditionally: a read-only query filter shouldn't have the side effect
936
+ // of creating a table, and a bare `EXISTS`/`NOT EXISTS` against a missing
937
+ // table would throw "no such table" instead of the correct empty answer.
938
+ if (opts.hasBrokenLinks !== undefined) {
939
+ const unresolvedTableExists = db.prepare(
940
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'unresolved_wikilinks'",
941
+ ).get() !== null;
942
+ if (!unresolvedTableExists) {
943
+ // No table yet means no note has ever had a broken link:
944
+ // hasBrokenLinks:true matches nothing; hasBrokenLinks:false is a
945
+ // no-op (every note already qualifies) — add no condition at all.
946
+ if (opts.hasBrokenLinks) conditions.push("0 = 1");
947
+ } else {
948
+ conditions.push(
949
+ opts.hasBrokenLinks
950
+ ? `EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`
951
+ : `NOT EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`,
952
+ );
953
+ }
954
+ }
955
+
854
956
  // ID set filter — used by `near` to push neighborhood scoping into SQL so
855
957
  // that LIMIT applies to the neighborhood, not the whole notes table.
856
958
  if (opts.ids !== undefined) {
@@ -1028,6 +1130,13 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1028
1130
  }
1029
1131
  }
1030
1132
 
1133
+ return { conditions, params };
1134
+ }
1135
+
1136
+ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1137
+ validateLimitOffset(opts);
1138
+ const { conditions, params } = buildFilterConditions(db, opts);
1139
+
1031
1140
  // ---- Cursor predicate (vault#313) ----
1032
1141
  //
1033
1142
  // Cursor mode is keyed on PRESENCE of `opts.cursor`, not truthiness
@@ -1176,6 +1285,133 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
1176
1285
  return fetchNotesByIdsOrdered(db, idRows.map((r) => r.id));
1177
1286
  }
1178
1287
 
1288
+ /**
1289
+ * Aggregation / rollup query (top new-feature ask from a UX round — see
1290
+ * `QueryOpts.aggregate` / `AggregateSpec` in types.ts). Applies the SAME
1291
+ * filter surface `queryNotes` does — via the shared `buildFilterConditions`:
1292
+ * tags, exclude-tags, presence filters, `ids`, path/extension, write-
1293
+ * attribution, metadata, date range — BEFORE grouping, so aggregating over a
1294
+ * filtered query rolls up exactly the notes that query would have listed.
1295
+ *
1296
+ * Tag-scope is NOT enforced here — core stays scope-unaware, same division
1297
+ * every other core query surface keeps. A tag-scoped caller narrows via
1298
+ * `opts.ids` to a pre-computed visible-note-id set (see `src/mcp-tools.ts`'s
1299
+ * `aggregateVisibility` wiring for MCP and `src/routes.ts`'s REST aggregate
1300
+ * branch — both filter to visible notes FIRST, then pass the resulting ids
1301
+ * in here, exactly like a normal query's `near` neighborhood scoping does).
1302
+ *
1303
+ * `group_by: "tag"` groups by tag MEMBERSHIP, not partition: implemented as
1304
+ * a JOIN against `note_tags`, so a note carrying N of the tags present in
1305
+ * the filtered result set contributes to N separate group rows (the
1306
+ * existing filter conditions, which reference `n.*`, compose unchanged
1307
+ * against the join). Any other `group_by` value must be a declared
1308
+ * `indexed: true` metadata field — reuses `requireIndexedField`, the same
1309
+ * FIELD_NOT_INDEXED contract `metadata` operator queries and `order_by`
1310
+ * use — and groups by its generated `meta_<field>` column.
1311
+ *
1312
+ * `op: "sum"` requires `field` — another indexed field whose declared
1313
+ * storage type is `INTEGER` (the only indexable numeric shape; a bare
1314
+ * `type: "number"` schema field is never indexed — see
1315
+ * `indexed-fields.ts`'s `TYPE_MAP` — and a `TEXT`-backed field can't be
1316
+ * summed). `op: "count"` ignores `field`.
1317
+ *
1318
+ * A note whose group_by value is absent/null collects into one
1319
+ * `{group: null, ...}` row — standard SQL `GROUP BY` behavior, not silently
1320
+ * dropped.
1321
+ */
1322
+ export function aggregateNotes(db: Database, opts: QueryOpts): AggregateRow[] {
1323
+ const spec = opts.aggregate;
1324
+ if (!spec) {
1325
+ throw new QueryError(
1326
+ `aggregateNotes requires opts.aggregate`,
1327
+ "INVALID_QUERY",
1328
+ {
1329
+ error_type: "invalid_query",
1330
+ field: "aggregate",
1331
+ hint: `pass { group_by, op } — group_by is an indexed metadata field or "tag"; op is "count" or "sum"`,
1332
+ },
1333
+ );
1334
+ }
1335
+ if (typeof spec.group_by !== "string" || spec.group_by.length === 0) {
1336
+ throw new QueryError(
1337
+ `aggregate.group_by is required — an indexed metadata field name, or "tag"`,
1338
+ "INVALID_QUERY",
1339
+ {
1340
+ error_type: "invalid_query",
1341
+ field: "aggregate.group_by",
1342
+ got: spec.group_by,
1343
+ hint: `pass an indexed metadata field name, or "tag"`,
1344
+ },
1345
+ );
1346
+ }
1347
+ if (spec.op !== "count" && spec.op !== "sum") {
1348
+ throw new QueryError(
1349
+ `invalid aggregate.op: ${JSON.stringify(spec.op)} — must be "count" or "sum"`,
1350
+ "INVALID_QUERY",
1351
+ { error_type: "invalid_query", field: "aggregate.op", got: spec.op, hint: `pass "count" or "sum"` },
1352
+ );
1353
+ }
1354
+ if (spec.op === "sum" && (typeof spec.field !== "string" || spec.field.length === 0)) {
1355
+ throw new QueryError(
1356
+ `aggregate.field is required when aggregate.op is "sum"`,
1357
+ "INVALID_QUERY",
1358
+ {
1359
+ error_type: "invalid_query",
1360
+ field: "aggregate.field",
1361
+ hint: "pass the indexed numeric metadata field to sum",
1362
+ },
1363
+ );
1364
+ }
1365
+
1366
+ const { conditions, params } = buildFilterConditions(db, opts);
1367
+
1368
+ const groupByTag = spec.group_by === "tag";
1369
+ let groupExpr: string;
1370
+ let fromClause: string;
1371
+ if (groupByTag) {
1372
+ groupExpr = "nt.tag_name";
1373
+ fromClause = "FROM notes n JOIN note_tags nt ON nt.note_id = n.id";
1374
+ } else {
1375
+ // `group_by` came from indexed_fields (validated via FIELD_NAME_RE at
1376
+ // declaration time), so interpolating the column name is safe — same
1377
+ // justification `orderBy`/`buildOperatorClause` use.
1378
+ requireIndexedField(db, spec.group_by);
1379
+ groupExpr = `"meta_${spec.group_by}"`;
1380
+ fromClause = "FROM notes n";
1381
+ }
1382
+
1383
+ let valueExpr: string;
1384
+ if (spec.op === "count") {
1385
+ valueExpr = "COUNT(*)";
1386
+ } else {
1387
+ const fieldInfo = requireIndexedField(db, spec.field!);
1388
+ if (fieldInfo.sqliteType !== "INTEGER") {
1389
+ throw new QueryError(
1390
+ `aggregate.field "${spec.field}" is not numeric (declared sqlite type ${fieldInfo.sqliteType}) — "sum" requires a field declared type: "integer" or "boolean" in its tag schema`,
1391
+ "INVALID_QUERY",
1392
+ {
1393
+ error_type: "invalid_query",
1394
+ field: "aggregate.field",
1395
+ got: spec.field,
1396
+ hint: `sum requires a numeric indexed field (type: "integer"/"boolean"); "${spec.field}" is declared a non-numeric type`,
1397
+ },
1398
+ );
1399
+ }
1400
+ valueExpr = `SUM("meta_${spec.field}")`;
1401
+ }
1402
+
1403
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1404
+ const sql = `
1405
+ SELECT ${groupExpr} AS group_key, ${valueExpr} AS value
1406
+ ${fromClause}
1407
+ ${whereClause}
1408
+ GROUP BY ${groupExpr}
1409
+ ORDER BY ${groupExpr}
1410
+ `;
1411
+ const rows = db.prepare(sql).all(...params) as { group_key: string | number | null; value: number | null }[];
1412
+ return rows.map((r) => ({ group: r.group_key, value: r.value ?? 0 }));
1413
+ }
1414
+
1179
1415
  /**
1180
1416
  * Fetch full note rows for `ids`, preserving the input order, with tags
1181
1417
  * hydrated via ONE batched query per chunk (not one per note). Ids not
@@ -1238,6 +1474,7 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
1238
1474
  excludeTags: opts.excludeTags,
1239
1475
  hasTags: opts.hasTags,
1240
1476
  hasLinks: opts.hasLinks,
1477
+ hasBrokenLinks: opts.hasBrokenLinks,
1241
1478
  path: opts.path,
1242
1479
  pathPrefix: opts.pathPrefix,
1243
1480
  extension: opts.extension,
@@ -2332,6 +2569,122 @@ export function getVaultStats(
2332
2569
  };
2333
2570
  }
2334
2571
 
2572
+ // ---- Vault map (front-door structural orientation) ----
2573
+
2574
+ /** Shared bucket-expression for the top-level path segment: the text before
2575
+ * the first `/`, or the whole path when it has none. Applied to whichever
2576
+ * `path` column reference the caller substitutes in (`path` or `n.path`). */
2577
+ function pathBucketExpr(pathCol: string): string {
2578
+ return `CASE WHEN instr(${pathCol}, '/') > 0 THEN substr(${pathCol}, 1, instr(${pathCol}, '/') - 1) ELSE ${pathCol} END`;
2579
+ }
2580
+
2581
+ /**
2582
+ * Compute the compact structural map `vault-info` surfaces for one-call
2583
+ * orientation: total note count, every tag currently carried by at least one
2584
+ * note with its membership count, and every top-level path "bucket" (the
2585
+ * first `/`-delimited segment of `path`) with its note count — plus a count
2586
+ * of notes with no path at all (excluded from `path_buckets`, nothing to
2587
+ * bucket). Counts only, no content; three grouped-COUNT queries, safe on
2588
+ * large vaults.
2589
+ *
2590
+ * `opts.tagFilter`, when the KEY IS PRESENT, restricts every count to notes
2591
+ * reachable through that exact tag-name set (an `IN`-clause join through
2592
+ * `note_tags`) — the scope-aware path a tag-scoped caller's already-expanded
2593
+ * allowlist takes (server layer; core stays scope-unaware, it just filters
2594
+ * by the plain tag-name list it's given). An empty array is a valid,
2595
+ * meaningful filter — "nothing is in scope" — and short-circuits to an
2596
+ * all-zero map WITHOUT falling through to the unfiltered/full-vault query;
2597
+ * only OMITTING `tagFilter` entirely computes the vault-wide map.
2598
+ */
2599
+ export function getVaultMap(
2600
+ db: Database,
2601
+ opts?: { tagFilter?: string[] },
2602
+ ): VaultMap {
2603
+ const hasFilter = opts !== undefined && opts.tagFilter !== undefined;
2604
+ const tagFilter = opts?.tagFilter ?? [];
2605
+
2606
+ if (hasFilter && tagFilter.length === 0) {
2607
+ return { total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 };
2608
+ }
2609
+
2610
+ if (hasFilter) {
2611
+ const placeholders = tagFilter.map(() => "?").join(",");
2612
+
2613
+ const totalRow = db
2614
+ .prepare(`SELECT COUNT(DISTINCT note_id) as c FROM note_tags WHERE tag_name IN (${placeholders})`)
2615
+ .get(...tagFilter) as { c: number };
2616
+
2617
+ const tagRows = db
2618
+ .prepare(
2619
+ `SELECT tag_name AS name, COUNT(*) AS count
2620
+ FROM note_tags
2621
+ WHERE tag_name IN (${placeholders})
2622
+ GROUP BY tag_name
2623
+ ORDER BY count DESC, name ASC`,
2624
+ )
2625
+ .all(...tagFilter) as { name: string; count: number }[];
2626
+
2627
+ const bucketRows = db
2628
+ .prepare(
2629
+ `SELECT ${pathBucketExpr("n.path")} AS name, COUNT(DISTINCT n.id) AS count
2630
+ FROM notes n
2631
+ JOIN note_tags nt ON nt.note_id = n.id
2632
+ WHERE nt.tag_name IN (${placeholders}) AND n.path IS NOT NULL
2633
+ GROUP BY name
2634
+ ORDER BY count DESC, name ASC`,
2635
+ )
2636
+ .all(...tagFilter) as { name: string; count: number }[];
2637
+
2638
+ const unfiledRow = db
2639
+ .prepare(
2640
+ `SELECT COUNT(DISTINCT n.id) as c
2641
+ FROM notes n
2642
+ JOIN note_tags nt ON nt.note_id = n.id
2643
+ WHERE nt.tag_name IN (${placeholders}) AND n.path IS NULL`,
2644
+ )
2645
+ .get(...tagFilter) as { c: number };
2646
+
2647
+ return {
2648
+ total_notes: totalRow.c,
2649
+ tags: tagRows,
2650
+ path_buckets: bucketRows,
2651
+ unfiled_notes: unfiledRow.c,
2652
+ };
2653
+ }
2654
+
2655
+ const totalRow = db.prepare("SELECT COUNT(*) as c FROM notes").get() as { c: number };
2656
+
2657
+ const tagRows = db
2658
+ .prepare(
2659
+ `SELECT tag_name AS name, COUNT(*) AS count
2660
+ FROM note_tags
2661
+ GROUP BY tag_name
2662
+ ORDER BY count DESC, name ASC`,
2663
+ )
2664
+ .all() as { name: string; count: number }[];
2665
+
2666
+ const bucketRows = db
2667
+ .prepare(
2668
+ `SELECT ${pathBucketExpr("path")} AS name, COUNT(*) AS count
2669
+ FROM notes
2670
+ WHERE path IS NOT NULL
2671
+ GROUP BY name
2672
+ ORDER BY count DESC, name ASC`,
2673
+ )
2674
+ .all() as { name: string; count: number }[];
2675
+
2676
+ const unfiledRow = db
2677
+ .prepare("SELECT COUNT(*) as c FROM notes WHERE path IS NULL")
2678
+ .get() as { c: number };
2679
+
2680
+ return {
2681
+ total_notes: totalRow.c,
2682
+ tags: tagRows,
2683
+ path_buckets: bucketRows,
2684
+ unfiled_notes: unfiledRow.c,
2685
+ };
2686
+ }
2687
+
2335
2688
  // ---- Bulk Operations ----
2336
2689
 
2337
2690
  export interface BulkNoteInput {
@@ -34,6 +34,7 @@ import {
34
34
  type TagHierarchy,
35
35
  } from "./tag-hierarchy.js";
36
36
  import { chunkForInClause } from "./sql-in.js";
37
+ import { escapeFtsToken } from "./search-query.js";
37
38
 
38
39
  export interface QueryWarning {
39
40
  code: string;
@@ -231,13 +232,15 @@ export function ignoredParamWarning(param: string, reason: string): QueryWarning
231
232
  * - the FTS5 vocabulary — a `notes_fts_vocab` `fts5vocab('row')` table
232
233
  * created here LAZILY and BEST-EFFORT (not part of the schema/
233
234
  * migration — see the try/catch below) — the terms actually indexed,
234
- * POST-tokenization/stemming (porter). A suggestion therefore
235
- * sometimes reads as a stemmed form ("propoli" rather than "propolis")
236
- * rather than the original dictionary word an accepted tradeoff
237
- * (documented in docs/HTTP_API.md) rather than maintaining a second,
238
- * unstemmed index just for spelling suggestions.
235
+ * POST-tokenization/stemming (porter). The closest CANDIDATE is found
236
+ * against this stemmed vocabulary (small, deduped, cheap to
237
+ * range-scan), but a stemmed candidate is never returned verbatim
238
+ * see `resolveSurfaceForm` below, which maps it back to a real word
239
+ * before it's handed to the caller (vault#570 — a raw stem like
240
+ * "propoli" or "cactu" isn't a word anyone would type).
239
241
  * - tag names, when the caller passes `tagNames` (already cached by the
240
- * caller's tag hierarchy — free to include).
242
+ * caller's tag hierarchy — free to include, and already real words —
243
+ * no de-stemming needed).
241
244
  *
242
245
  * `fts5vocab` is registered by the same FTS5 extension init as `notes_fts`
243
246
  * itself (not a separately-enabled module) so it's expected to be
@@ -263,6 +266,8 @@ export function computeSearchDidYouMean(
263
266
  const tokens = rawQuery.trim().split(/\s+/).filter(Boolean);
264
267
  if (tokens.length === 0) return undefined;
265
268
 
269
+ const tagNameSet = tagNames ? new Set(tagNames) : undefined;
270
+
266
271
  db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts_vocab USING fts5vocab(notes_fts, 'row')");
267
272
  const exactStmt = db.prepare("SELECT 1 FROM notes_fts_vocab WHERE term = ? LIMIT 1");
268
273
  const rangeStmt = db.prepare("SELECT term FROM notes_fts_vocab WHERE length(term) BETWEEN ? AND ?");
@@ -284,14 +289,19 @@ export function computeSearchDidYouMean(
284
289
  const hi = clean.length + 2;
285
290
  const rows = rangeStmt.all(lo, hi) as { term: string }[];
286
291
  const candidates = new Set<string>(rows.map((r) => r.term));
287
- if (tagNames) for (const t of tagNames) candidates.add(t);
292
+ if (tagNameSet) for (const t of tagNameSet) candidates.add(t);
288
293
 
289
294
  const suggestion = suggestSimilarTag(candidates, lower);
290
- if (suggestion && suggestion.toLowerCase() !== lower) {
291
- changed = true;
292
- return suggestion;
293
- }
294
- return tok;
295
+ if (!suggestion || suggestion.toLowerCase() === lower) return tok;
296
+
297
+ changed = true;
298
+ // A literal tag name is already a real word — return it verbatim. A
299
+ // vocabulary hit, though, came out of the PORTER-STEMMED index, so
300
+ // it's usually a truncated stem ("cactu" for "cactus") rather than a
301
+ // word a user would ever type (vault#570) — recover the real surface
302
+ // form before handing it back.
303
+ if (tagNameSet?.has(suggestion)) return suggestion;
304
+ return resolveSurfaceForm(db, suggestion) ?? suggestion;
295
305
  });
296
306
 
297
307
  if (!changed) return undefined;
@@ -301,6 +311,44 @@ export function computeSearchDidYouMean(
301
311
  }
302
312
  }
303
313
 
314
+ /**
315
+ * Map a porter-stemmed vocabulary term ("cactu") back to the real
316
+ * dictionary word a note actually contains ("cactus") — vault#570 (issue
317
+ * #570's search polish item). `notes_fts` is porter-tokenized, so MATCHing
318
+ * the bare stem finds any note carrying a word that stems to it (the stem
319
+ * came FROM `notes_fts_vocab` in the first place, so at least one match is
320
+ * guaranteed). From there, tokenize that note's own path/content text —
321
+ * unstemmed, done here in JS with the same punctuation-stripping rule used
322
+ * above — and return the first token that literally STARTS WITH the stem.
323
+ * This works because Porter's rules are almost entirely suffix-stripping:
324
+ * the stem is near-always a prefix of the original word. Irregular cases
325
+ * (a stem that isn't a literal prefix) are a known, accepted limitation —
326
+ * same class as the porter tokenizer's irregular-plural gap documented in
327
+ * docs/HTTP_API.md — and simply fall back to the raw stem via the caller's
328
+ * `?? suggestion`, never worse than the pre-fix behavior.
329
+ *
330
+ * Bounded to a handful of rows (`LIMIT 5`) and wrapped in try/catch same as
331
+ * the caller — a nicety, never worth risking the search response itself.
332
+ */
333
+ function resolveSurfaceForm(db: Database, stem: string): string | undefined {
334
+ try {
335
+ const rows = db
336
+ .prepare("SELECT path, content FROM notes_fts WHERE notes_fts MATCH ? LIMIT 5")
337
+ .all(escapeFtsToken(stem)) as { path: string | null; content: string | null }[];
338
+ for (const row of rows) {
339
+ const text = `${row.path ?? ""} ${row.content ?? ""}`;
340
+ for (const raw of text.split(/[^\p{L}\p{N}]+/gu)) {
341
+ if (!raw) continue;
342
+ const lower = raw.toLowerCase();
343
+ if (lower.length > stem.length && lower.startsWith(stem)) return lower;
344
+ }
345
+ }
346
+ return undefined;
347
+ } catch {
348
+ return undefined;
349
+ }
350
+ }
351
+
304
352
  /**
305
353
  * `search_did_you_mean` warning (vault#551 WS2B) — wraps
306
354
  * {@link computeSearchDidYouMean}'s suggestion in the standard warning
@@ -57,7 +57,7 @@ export interface SchemaField {
57
57
  * `type_mismatch` warning that previously fired on every integer-shaped
58
58
  * field because the validator had no `"integer"` case. See vault#310.
59
59
  */
60
- type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
60
+ type?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "reference";
61
61
  enum?: string[];
62
62
  description?: string;
63
63
  /**
@@ -369,6 +369,12 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
369
369
  return Array.isArray(value);
370
370
  case "object":
371
371
  return !!value && typeof value === "object" && !Array.isArray(value);
372
+ // `reference` (vault#typed-reference-field) validates like `string` —
373
+ // the write path (core/src/store.ts) separately resolves this value to
374
+ // a note and maintains a graph link; see tag-schemas.ts's
375
+ // `VALID_FIELD_TYPES` doc comment for the full contract.
376
+ case "reference":
377
+ return typeof value === "string";
372
378
  }
373
379
  }
374
380
 
@@ -291,6 +291,20 @@ describe("getting-started pack", () => {
291
291
  expect(GETTING_STARTED_CONTENT).toContain("over MCP");
292
292
  });
293
293
 
294
+ test("Getting Started carries the journaling / agent-memory / search conventions (vault#555)", () => {
295
+ expect(GETTING_STARTED_CONTENT).toContain("## A few shapes worth reusing");
296
+ // Journaling: indexed entry_date + mood enum.
297
+ expect(GETTING_STARTED_CONTENT).toContain("entry_date");
298
+ expect(GETTING_STARTED_CONTENT).toContain("mood");
299
+ // Agent-memory pattern: thread + messages + the if_exists:"ignore" crash-replay recipe.
300
+ expect(GETTING_STARTED_CONTENT).toContain("#thread");
301
+ expect(GETTING_STARTED_CONTENT).toContain("#message");
302
+ expect(GETTING_STARTED_CONTENT).toContain('if_exists: "ignore"');
303
+ // Search one-liner: literal by default, search_mode:"advanced" for FTS syntax.
304
+ expect(GETTING_STARTED_CONTENT).toContain("literal by default");
305
+ expect(GETTING_STARTED_CONTENT).toContain('search_mode: "advanced"');
306
+ });
307
+
294
308
  test("default vault-description constants orient + tell the AI to self-replace them", () => {
295
309
  for (const d of [DEFAULT_VAULT_DESCRIPTION, IMPORTED_VAULT_DESCRIPTION]) {
296
310
  expect(d.length).toBeGreaterThan(100);
@@ -392,10 +392,14 @@ description) so the next one picks up where you left off.
392
392
  ### If this vault already has content
393
393
 
394
394
  Some vaults arrive full — an import, a restore, months of use. Then your first
395
- job is to **learn it, not seed it**: \`vault-info { include_stats: true }\`,
396
- \`list-tags\`, read a sample (\`query-notes { search: "..." }\`), then reflect the
397
- shape back to the person and ask what's missing or wrong. Propose structure
398
- only once you can describe what's already there.
395
+ job is to **learn it, not seed it**: \`vault-info\` a single call now returns
396
+ a compact \`map\` (every tag's note count, every top-level path's note count,
397
+ total notes) alongside the schema catalog, so you get the shape of a strange
398
+ vault in one round trip; add \`include_stats: true\` only if you also want the
399
+ deeper monthly distribution. Then \`list-tags\` and read a sample
400
+ (\`query-notes { search: "..." }\`), and reflect the shape back to the person
401
+ and ask what's missing or wrong. Propose structure only once you can describe
402
+ what's already there.
399
403
 
400
404
  ### Close the loop: describe the vault
401
405
 
@@ -424,7 +428,9 @@ Core moves you already have as MCP tools:
424
428
  - \`query-notes\` — by id/path, by tag, full-text \`search\`, or graph \`near\` a note.
425
429
  - \`list-tags\` / \`update-tag\` / \`delete-tag\` — manage the tag vocabulary + schemas.
426
430
  - \`find-path\` — shortest link path between two notes.
427
- - \`vault-info\` — refresh the live schema/stats projection any time.
431
+ - \`vault-info\` — refresh the live schema projection any time; the base call
432
+ includes a compact \`map\` (tag counts, top-level path-bucket counts, total
433
+ notes) so you can orient in this one call — no flag needed.
428
434
 
429
435
  \`[[wikilinks]]\` in note content auto-link to the note at that path — use them
430
436
  freely; they resolve even if the target is created later.
@@ -481,6 +487,37 @@ support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
481
487
  \`order_by\`; all tags declaring the same field must agree on its \`type\` and
482
488
  \`indexed\` flag.
483
489
 
490
+ ## A few shapes worth reusing
491
+
492
+ Testers of this vault independently reinvented these — start from them instead:
493
+
494
+ - **Journaling.** One tag (\`#journal\`) with an indexed \`entry_date\`
495
+ (\`{ type: "string", indexed: true }\`, ISO date) and a \`mood\` enum
496
+ (\`{ type: "string", enum: [...] }\`). Indexing \`entry_date\` (see "Only
497
+ \`indexed: true\` fields support operator queries" above) is what makes
498
+ date-range queries (\`metadata: { entry_date: { gte: "2026-01-01" } }\`) and
499
+ \`order_by: "entry_date"\` work.
500
+ - **Agent memory (thread + messages, crash-replay-safe).** Model a
501
+ conversation as one \`#thread\` note (metadata: \`status\`, \`cursor\`) plus many
502
+ \`#message\` notes linked to it (\`relationship: "in-thread"\`), each carrying
503
+ its own \`status\`. If your write loop can crash and replay the same message,
504
+ give it a stable \`path\` (e.g. \`Threads/<thread-id>/msg-<n>\`) and create it
505
+ with \`if_exists: "ignore"\` — a retry after a crash safely returns the
506
+ already-written message instead of creating a duplicate, no
507
+ query-before-write round trip needed.
508
+ - **Search.** \`query-notes { search: "..." }\` is literal by default — your
509
+ text is escaped, not parsed as FTS5 syntax, so punctuation like "didn't" or
510
+ "18.6" just works. Pass \`search_mode: "advanced"\` only when you actually
511
+ want FTS5 boolean/phrase/prefix syntax.
512
+ - **A front-door "Map" note.** Once a vault grows past a few dozen notes,
513
+ keep one note (path \`Map\`, or whatever fits) as a human-legible index —
514
+ the tags and top-level paths that matter and what each means, with
515
+ wikilinks into anchor notes. It's the "why" companion to \`vault-info\`'s
516
+ auto-computed \`map\` (tag/path-bucket counts, the "what's there"): the
517
+ counts tell a fresh AI the shape of the vault in one call, this note tells
518
+ it what that shape MEANS. Update it when the vault's structure shifts;
519
+ link to it from the vault description so it's easy to find.
520
+
484
521
  ## Write gotchas
485
522
 
486
523
  A few behaviors worth knowing before you write at scale: