@openparachute/vault 0.7.0-rc.1 → 0.7.0-rc.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.
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ SEARCH_MODES,
4
+ escapeFtsToken,
5
+ buildLiteralSearchQuery,
6
+ isValidSearchMode,
7
+ } from "./search-query.js";
8
+
9
+ describe("escapeFtsToken", () => {
10
+ it("wraps a plain token in quotes", () => {
11
+ expect(escapeFtsToken("widgets")).toBe(`"widgets"`);
12
+ });
13
+
14
+ it("doubles an embedded quote", () => {
15
+ expect(escapeFtsToken(`say"hi`)).toBe(`"say""hi"`);
16
+ });
17
+
18
+ it("doubles multiple embedded quotes", () => {
19
+ expect(escapeFtsToken(`"eleven-day`)).toBe(`"""eleven-day"`);
20
+ expect(escapeFtsToken(`delay"`)).toBe(`"delay"""`);
21
+ });
22
+
23
+ it("preserves punctuation that isn't a quote (hyphen, period, apostrophe)", () => {
24
+ expect(escapeFtsToken("eleven-day")).toBe(`"eleven-day"`);
25
+ expect(escapeFtsToken("18.6")).toBe(`"18.6"`);
26
+ expect(escapeFtsToken("didn't")).toBe(`"didn't"`);
27
+ });
28
+ });
29
+
30
+ describe("buildLiteralSearchQuery", () => {
31
+ it("wraps a single token", () => {
32
+ const r = buildLiteralSearchQuery("widgets");
33
+ expect(r.isEmpty).toBe(false);
34
+ expect(r.query).toBe(`"widgets"`);
35
+ });
36
+
37
+ it("phrase-quotes each whitespace-delimited token and ANDs them (vault#551 root fix)", () => {
38
+ const r = buildLiteralSearchQuery("eleven-day capping delay");
39
+ expect(r.isEmpty).toBe(false);
40
+ expect(r.query).toBe(`"eleven-day" "capping" "delay"`);
41
+ });
42
+
43
+ it("escapes an apostrophe as literal content, not syntax", () => {
44
+ const r = buildLiteralSearchQuery("didn't");
45
+ expect(r.isEmpty).toBe(false);
46
+ expect(r.query).toBe(`"didn't"`);
47
+ });
48
+
49
+ it("escapes a decimal point as literal content, not syntax", () => {
50
+ const r = buildLiteralSearchQuery("18.6");
51
+ expect(r.isEmpty).toBe(false);
52
+ expect(r.query).toBe(`"18.6"`);
53
+ });
54
+
55
+ it("collapses runs of whitespace between tokens", () => {
56
+ const r = buildLiteralSearchQuery("widgets gadgets");
57
+ expect(r.query).toBe(`"widgets" "gadgets"`);
58
+ });
59
+
60
+ it("trims leading/trailing whitespace", () => {
61
+ const r = buildLiteralSearchQuery(" widgets ");
62
+ expect(r.query).toBe(`"widgets"`);
63
+ });
64
+
65
+ it("escapes a manually-quoted phrase's embedded quote characters as content", () => {
66
+ // The user typed literal quote marks expecting phrase syntax; in
67
+ // literal mode those quote characters are just more content that gets
68
+ // escaped like anything else (vault#551 — this is CORRECT, not a bug:
69
+ // raw phrase/boolean/prefix syntax now requires search_mode:"advanced").
70
+ const r = buildLiteralSearchQuery(`"eleven-day capping delay"`);
71
+ expect(r.isEmpty).toBe(false);
72
+ expect(r.query).toBe(`"""eleven-day" "capping" "delay"""`);
73
+ });
74
+
75
+ it("treats a blank string as empty", () => {
76
+ expect(buildLiteralSearchQuery("").isEmpty).toBe(true);
77
+ });
78
+
79
+ it("treats a whitespace-only string as empty", () => {
80
+ expect(buildLiteralSearchQuery(" ").isEmpty).toBe(true);
81
+ });
82
+
83
+ it("treats a lone quote character as empty", () => {
84
+ expect(buildLiteralSearchQuery(`"`).isEmpty).toBe(true);
85
+ });
86
+
87
+ it("treats a quotes-and-whitespace-only string as empty", () => {
88
+ expect(buildLiteralSearchQuery(`" " "`).isEmpty).toBe(true);
89
+ });
90
+
91
+ it("an empty-flagged result never carries a query string callers should use", () => {
92
+ const r = buildLiteralSearchQuery(" ");
93
+ expect(r.isEmpty).toBe(true);
94
+ expect(r.query).toBe("");
95
+ });
96
+
97
+ // Control-character sanitization (vault#551 review fold) — a NUL byte
98
+ // (or any C0/DEL control) inside a token would otherwise crash FTS5's
99
+ // C-string parser with a raw `unterminated string` error, breaking the
100
+ // "literal mode cannot throw" guarantee.
101
+ it("splits a token on an embedded NUL byte — both halves stay searchable", () => {
102
+ const r = buildLiteralSearchQuery("hello\x00world");
103
+ expect(r.isEmpty).toBe(false);
104
+ expect(r.query).toBe(`"hello" "world"`);
105
+ });
106
+
107
+ it("treats a NUL-only string as empty (short-circuits, never reaches FTS5)", () => {
108
+ const r = buildLiteralSearchQuery("\x00");
109
+ expect(r.isEmpty).toBe(true);
110
+ expect(r.query).toBe("");
111
+ });
112
+
113
+ it("collapses a run of NUL/control bytes between tokens like whitespace", () => {
114
+ const r = buildLiteralSearchQuery("hello\x00\x00world");
115
+ expect(r.query).toBe(`"hello" "world"`);
116
+ });
117
+
118
+ it("sanitizes the other C0 controls and DEL (0x01-0x1F, 0x7F) as separators too", () => {
119
+ expect(buildLiteralSearchQuery("a\x01\x02b").query).toBe(`"a" "b"`);
120
+ expect(buildLiteralSearchQuery("a\x1fb").query).toBe(`"a" "b"`);
121
+ expect(buildLiteralSearchQuery("a\x7fb").query).toBe(`"a" "b"`);
122
+ });
123
+
124
+ it("a control byte adjacent to a real token still yields the clean token", () => {
125
+ expect(buildLiteralSearchQuery("\x00hello\x00").query).toBe(`"hello"`);
126
+ });
127
+ });
128
+
129
+ describe("isValidSearchMode / SEARCH_MODES", () => {
130
+ it("SEARCH_MODES is exactly literal + advanced", () => {
131
+ expect(SEARCH_MODES).toEqual(["literal", "advanced"]);
132
+ });
133
+
134
+ it("accepts literal and advanced", () => {
135
+ expect(isValidSearchMode("literal")).toBe(true);
136
+ expect(isValidSearchMode("advanced")).toBe(true);
137
+ });
138
+
139
+ it("rejects an unknown string", () => {
140
+ expect(isValidSearchMode("bogus")).toBe(false);
141
+ });
142
+
143
+ it("rejects non-string values", () => {
144
+ expect(isValidSearchMode(123)).toBe(false);
145
+ expect(isValidSearchMode(null)).toBe(false);
146
+ expect(isValidSearchMode(undefined)).toBe(false);
147
+ expect(isValidSearchMode(["literal"])).toBe(false);
148
+ });
149
+ });
Binary file
package/core/src/store.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  countConformanceViolations,
35
35
  type ConformanceReport,
36
36
  } from "./conformance.js";
37
+ import type { SearchMode } from "./search-query.js";
37
38
 
38
39
  /**
39
40
  * bun:sqlite-backed Store implementation. Internally everything is
@@ -70,8 +71,15 @@ export class BunSqliteStore implements Store {
70
71
  * boot or after an invalidation does the scan; subsequent calls hit the
71
72
  * cache. Returns the same object until invalidated, so callers can rely
72
73
  * on identity for memoizing per-tag descendant sets.
74
+ *
75
+ * Public (vault#550 fold): the query-warnings collector
76
+ * (`core/src/query-warnings.ts:collectUnknownTagWarnings`) runs on every
77
+ * tag-filtered structured query — threading this cached hierarchy in
78
+ * (instead of a fresh `loadTagHierarchy` per request) keeps the
79
+ * common all-tags-known case at ~zero extra cost. Treat the returned
80
+ * object as READ-ONLY — it's the shared cache, invalidated by writers.
73
81
  */
74
- private getTagHierarchy(): TagHierarchy {
82
+ getTagHierarchy(): TagHierarchy {
75
83
  if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
76
84
  return this._tagHierarchy;
77
85
  }
@@ -368,7 +376,7 @@ export class BunSqliteStore implements Store {
368
376
  return { ...opts, _tagsExpanded: expanded } as QueryOpts;
369
377
  }
370
378
 
371
- async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
379
+ async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]> {
372
380
  // Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
373
381
  // filters before expansion, so `#manual` and `manual` resolve identically.
374
382
  if (opts?.tags && opts.tags.length > 0) {
@@ -445,7 +453,7 @@ export class BunSqliteStore implements Store {
445
453
  return expanded;
446
454
  }
447
455
 
448
- async listTags(): Promise<{ name: string; count: number }[]> {
456
+ async listTags(): Promise<{ name: string; count: number; expanded_count: number }[]> {
449
457
  return noteOps.listTags(this.db);
450
458
  }
451
459
 
@@ -249,6 +249,119 @@ export function getTagExpansion(
249
249
  }
250
250
  }
251
251
 
252
+ /**
253
+ * Suggest the closest EXISTING tag name to an unmatched input — the
254
+ * `did_you_mean` hint on `unknown_tag` warnings (vault#550) and
255
+ * `tag_not_found` errors. Candidates are scored, lower is better:
256
+ *
257
+ * 0. case-only difference (`Voice` vs `voice`)
258
+ * 1. a prefix relationship either direction (`voice` / `voice-memo` —
259
+ * catches plural/singular drift and namespace-child typos)
260
+ * 2+dist. Levenshtein edit distance, when within a length-scaled budget
261
+ *
262
+ * Returns the single best match, or `undefined` when nothing is close
263
+ * enough to be worth suggesting — a genuinely novel tag name shouldn't get
264
+ * a noisy "did you mean" pointing at an unrelated tag.
265
+ */
266
+ export function suggestSimilarTag(
267
+ candidates: Iterable<string>,
268
+ input: string,
269
+ ): string | undefined {
270
+ const lower = input.toLowerCase();
271
+ let best: string | undefined;
272
+ let bestScore = Infinity;
273
+ for (const candidate of candidates) {
274
+ if (candidate === input) continue;
275
+ const candLower = candidate.toLowerCase();
276
+ let score: number | null = null;
277
+ if (candLower === lower) {
278
+ score = 0;
279
+ } else if (lower.length >= 2 && candLower.length >= 2 && (candLower.startsWith(lower) || lower.startsWith(candLower))) {
280
+ score = 1;
281
+ } else {
282
+ const dist = levenshtein(lower, candLower);
283
+ const budget = Math.max(2, Math.ceil(Math.min(lower.length, candLower.length) * 0.34));
284
+ if (dist <= budget) score = 2 + dist;
285
+ }
286
+ if (score !== null && score < bestScore) {
287
+ bestScore = score;
288
+ best = candidate;
289
+ }
290
+ }
291
+ return best;
292
+ }
293
+
294
+ /** Classic Levenshtein edit distance, O(m·n) time / O(n) space. */
295
+ function levenshtein(a: string, b: string): number {
296
+ const m = a.length;
297
+ const n = b.length;
298
+ if (m === 0) return n;
299
+ if (n === 0) return m;
300
+ let prev = new Array<number>(n + 1);
301
+ let curr = new Array<number>(n + 1);
302
+ for (let j = 0; j <= n; j++) prev[j] = j;
303
+ for (let i = 1; i <= m; i++) {
304
+ curr[0] = i;
305
+ for (let j = 1; j <= n; j++) {
306
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
307
+ curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
308
+ }
309
+ [prev, curr] = [curr, prev];
310
+ }
311
+ return prev[n]!;
312
+ }
313
+
314
+ /**
315
+ * Compute, for every declared tag, the number of DISTINCT notes matching
316
+ * that tag OR any transitive descendant under the subtypes axis (vault#550
317
+ * `expanded_count` — the rollup a parent tag whose notes are all tagged
318
+ * with a CHILD tag needs to report a non-zero count).
319
+ *
320
+ * One query fetches every `(tag_name, note_id)` pairing from `note_tags`;
321
+ * the fan-out to ancestors is pure in-memory set work over the (memoized)
322
+ * hierarchy — not one query per tag, so this stays cheap regardless of how
323
+ * many tags the vault declares. `getTagDescendants` is memoized per tag on
324
+ * `h`, so the ancestor-closure build below reuses that cache instead of
325
+ * re-walking the graph per candidate.
326
+ */
327
+ export function computeExpandedTagCounts(
328
+ db: Database,
329
+ h: TagHierarchy,
330
+ ): Map<string, number> {
331
+ const rows = db.prepare(`SELECT tag_name, note_id FROM note_tags`).all() as
332
+ { tag_name: string; note_id: string }[];
333
+
334
+ const expandedNotes = new Map<string, Set<string>>();
335
+ const ancestorsCache = new Map<string, string[]>();
336
+
337
+ function ancestorsOrSelf(x: string): string[] {
338
+ const cached = ancestorsCache.get(x);
339
+ if (cached) return cached;
340
+ const result: string[] = [];
341
+ for (const t of h.allTags) {
342
+ if (getTagDescendants(h, t).has(x)) result.push(t);
343
+ }
344
+ if (!result.includes(x)) result.push(x);
345
+ ancestorsCache.set(x, result);
346
+ return result;
347
+ }
348
+
349
+ for (const row of rows) {
350
+ for (const ancestor of ancestorsOrSelf(row.tag_name)) {
351
+ let set = expandedNotes.get(ancestor);
352
+ if (!set) {
353
+ set = new Set();
354
+ expandedNotes.set(ancestor, set);
355
+ }
356
+ set.add(row.note_id);
357
+ }
358
+ }
359
+
360
+ const counts = new Map<string, number>();
361
+ for (const [tag, set] of expandedNotes) counts.set(tag, set.size);
362
+ return counts;
363
+ }
364
+
252
365
  /**
253
366
  * Detect cycles in the declared hierarchy. Returns the list of tags
254
367
  * reachable from themselves via parent declarations. Used by
package/core/src/types.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
3
3
  import type { PrunedField } from "./indexed-fields.js";
4
- import type { TagExpandMode } from "./tag-hierarchy.js";
4
+ import type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
5
+ import type { SearchMode } from "./search-query.js";
5
6
  import type { ValidationStatus } from "./schema-defaults.js";
6
7
  import type { ConformanceReport } from "./conformance.js";
8
+ import type { FindPathResult } from "./links.js";
7
9
 
8
10
  // ---- Re-exports ----
9
11
 
10
12
  export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
11
13
  export type { PrunedField } from "./indexed-fields.js";
12
- export type { TagExpandMode } from "./tag-hierarchy.js";
14
+ export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
13
15
  export type { ConformanceReport } from "./conformance.js";
16
+ export type { FindPathResult } from "./links.js";
14
17
 
15
18
  // ---- Note ----
16
19
 
@@ -312,7 +315,18 @@ export interface Store {
312
315
  * agent loop can persist a single watermark and keep polling.
313
316
  */
314
317
  queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage>;
315
- searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]>;
318
+ /**
319
+ * `mode` (vault#551 — literal-by-default): "literal" (default) escapes +
320
+ * phrase-quotes the query so FTS5 punctuation syntax (hyphen = NOT, an
321
+ * apostrophe/period breaking the parse, ...) is treated as ordinary
322
+ * content. "advanced" passes `query` through to FTS5 raw (pre-#551
323
+ * behavior) for callers who want boolean/phrase/prefix operators — a
324
+ * syntax error in that mode throws (`error_type: "invalid_search_syntax"`)
325
+ * rather than silently returning `[]`. `sort` (vault#551): omitted stays
326
+ * FTS5 relevance ranking (default); an explicit "asc"/"desc" switches to
327
+ * `created_at` ordering. See `core/src/search-query.ts`.
328
+ */
329
+ searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]>;
316
330
 
317
331
  // Tags
318
332
  tagNote(noteId: string, tags: string[]): Promise<void>;
@@ -336,7 +350,20 @@ export interface Store {
336
350
  * IDENTICAL expansion the snapshot query engine uses for the same `expand`.
337
351
  */
338
352
  expandTags(tags: string[], mode?: TagExpandMode): Promise<Set<string>>;
339
- listTags(): Promise<{ name: string; count: number }[]>;
353
+ /**
354
+ * The store's cached tag hierarchy (invalidated on tag/parent_names
355
+ * writes). Sync, like `db` and `transaction`. Exposed (vault#550 fold)
356
+ * so per-query consumers — the `unknown_tag` warning collector — reuse
357
+ * the cache instead of re-scanning the `tags` table per request. Treat
358
+ * the returned object as READ-ONLY shared state.
359
+ */
360
+ getTagHierarchy(): TagHierarchy;
361
+ /**
362
+ * `expanded_count` (vault#550): distinct notes matching the tag OR any
363
+ * transitive descendant under the DEFAULT (subtypes) expansion axis,
364
+ * alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
365
+ */
366
+ listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
340
367
  deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
341
368
  renameTag(
342
369
  oldName: string,
@@ -375,7 +402,7 @@ export interface Store {
375
402
 
376
403
  // Deeper link queries
377
404
  traverseLinks(noteId: string, opts?: { max_depth?: number; relationship?: string }): Promise<{ noteId: string; depth: number; relationship: string; direction: "outbound" | "inbound" }[]>;
378
- findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<{ path: string[]; relationships: string[] } | null>;
405
+ findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<FindPathResult | null>;
379
406
 
380
407
  // Tag schemas — schema-only facade (description + fields). Back-compat
381
408
  // surface for v13-and-earlier callers; reads/writes route through the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.1",
3
+ "version": "0.7.0-rc.3",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",