@openparachute/vault 0.7.0-rc.1 → 0.7.0-rc.2

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,184 @@
1
+ /**
2
+ * The `warnings: []` channel (vault#550 — Reliability & Usability Program
3
+ * WS1, "honest queries"). Ratified principle: if the vault can still answer
4
+ * the question asked, answer it and attach a warning; if it would answer a
5
+ * DIFFERENT question, return a structured named error. Silence is never the
6
+ * third option.
7
+ *
8
+ * This module owns the ONE validated choke point for `unknown_tag`
9
+ * warnings — both the REST structured-query path (`src/routes.ts`) and the
10
+ * MCP `query-notes` tool call into `collectUnknownTagWarnings` so the two
11
+ * surfaces can't drift. REST-only warnings (`removed_param` — a REST query
12
+ * string concept with no MCP analog) stay in `src/routes.ts`.
13
+ *
14
+ * Tag-scope note (security review): this module is deliberately
15
+ * SCOPE-UNAWARE — `collectUnknownTagWarnings` always resolves against the
16
+ * full vault-wide tag catalog, exactly like `core/src/notes.ts:queryNotes`
17
+ * itself. Callers on a tag-scoped session MUST NOT surface these warnings
18
+ * (or must first narrow the candidate pool) — `did_you_mean` naming an
19
+ * out-of-scope tag would leak its existence across the scope boundary,
20
+ * which this codebase treats as a hard "no leak" invariant elsewhere (see
21
+ * `docs/contracts/tag-scoped-tokens.md`). `src/routes.ts` skips this call
22
+ * entirely for scoped sessions; `src/mcp-tools.ts`'s `query-notes` wrapper
23
+ * strips any `warnings` core computed before returning to a scoped caller.
24
+ */
25
+
26
+ import { Database } from "bun:sqlite";
27
+ import {
28
+ DEFAULT_TAG_EXPAND_MODE,
29
+ getTagExpansion,
30
+ loadTagHierarchy,
31
+ stripTagHash,
32
+ suggestSimilarTag,
33
+ type TagExpandMode,
34
+ type TagHierarchy,
35
+ } from "./tag-hierarchy.js";
36
+ import { chunkForInClause } from "./sql-in.js";
37
+
38
+ export interface QueryWarning {
39
+ code: string;
40
+ message: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ /**
45
+ * Cap on `unknown_tag` warnings per query (vault#550 fold). A caller
46
+ * passing a garbage `tags` array (hundreds of junk names) would otherwise
47
+ * inflate the response — and the REST `X-Parachute-Warnings` header —
48
+ * unboundedly. Past the cap, a single `warnings_truncated` entry reports
49
+ * how many were suppressed.
50
+ */
51
+ export const MAX_UNKNOWN_TAG_WARNINGS = 8;
52
+
53
+ /**
54
+ * Membership counts for a specific set of candidate tag names — NOT a
55
+ * full `listTags()` vault-wide scan. `listTags` additionally computes
56
+ * `expanded_count` (a full `note_tags` pass over EVERY tag, vault#550),
57
+ * which this function deliberately avoids: `collectUnknownTagWarnings` runs
58
+ * on every tag-filtered structured query (a hot path), so paying for a
59
+ * vault-wide rollup just to answer "does tag X have ANY notes" would be a
60
+ * real perf regression. This scopes the `note_tags` scan to only the
61
+ * (typically small) set of names actually reachable from the query's own
62
+ * tag filter and its expansion.
63
+ */
64
+ function countMembership(db: Database, names: ReadonlySet<string>): Map<string, number> {
65
+ const counts = new Map<string, number>();
66
+ if (names.size === 0) return counts;
67
+ for (const chunk of chunkForInClause([...names])) {
68
+ const placeholders = chunk.map(() => "?").join(", ");
69
+ const rows = db.prepare(
70
+ `SELECT tag_name, COUNT(*) as c FROM note_tags WHERE tag_name IN (${placeholders}) GROUP BY tag_name`,
71
+ ).all(...chunk) as { tag_name: string; c: number }[];
72
+ for (const row of rows) counts.set(row.tag_name, row.c);
73
+ }
74
+ return counts;
75
+ }
76
+
77
+ /**
78
+ * Warn on each literal tag name in a `tags` filter that can contribute
79
+ * NOTHING to the result set no matter what — the caller almost certainly
80
+ * mistyped it, or is thinking of a different vault. A tag survives (no
81
+ * warning) if ANY of the following holds:
82
+ *
83
+ * - it has its own identity row (a `tags` table entry — created via
84
+ * `update-tag` or implicitly by tagging a note), OR
85
+ * - at least one note literally carries that tag, OR
86
+ * - its mode-aware expansion (subtypes/namespace/both/exact — whatever
87
+ * the query's `expand` axis resolves to) contains at least one tag
88
+ * name that itself has notes.
89
+ *
90
+ * `did_you_mean` names the closest existing tag (case variant, prefix
91
+ * relationship, or small edit distance) when one exists — see
92
+ * `suggestSimilarTag`.
93
+ *
94
+ * Scoped to the POSITIVE `tags` filter only — `excludeTags` is not
95
+ * checked (excluding a nonexistent tag is a harmless no-op, not a sign of
96
+ * a mistaken query) and this does not run under `search=` (out of scope
97
+ * for this wave — see #551).
98
+ *
99
+ * Perf shape (vault#550 fold): this runs on EVERY tag-filtered structured
100
+ * query, so the common all-tags-known case must cost ~nothing. Pass the
101
+ * store's cached `hierarchy` (`Store.getTagHierarchy()` — invalidated on
102
+ * tag writes) so no fresh `tags`-table scan happens per request; the
103
+ * `note_tags` membership query below only runs for input tags MISSING
104
+ * from the identity set (`h.allTags`), which for a well-formed query is
105
+ * none — a tag with an identity row can never warn (`hasIdentity`
106
+ * short-circuits), so there's nothing to look up. The `hierarchy` param
107
+ * stays optional for direct-core callers/tests (falls back to a fresh
108
+ * load).
109
+ */
110
+ export function collectUnknownTagWarnings(
111
+ db: Database,
112
+ tags: string[] | undefined,
113
+ expandMode: TagExpandMode | undefined,
114
+ hierarchy?: TagHierarchy,
115
+ ): QueryWarning[] {
116
+ if (!tags || tags.length === 0) return [];
117
+
118
+ const h = hierarchy ?? loadTagHierarchy(db);
119
+ const mode = expandMode ?? DEFAULT_TAG_EXPAND_MODE;
120
+
121
+ // Fast path: dedupe inputs and drop every tag with an identity row —
122
+ // those can never warn. For a well-formed query this empties the list
123
+ // and we return without touching the DB at all.
124
+ const suspects: string[] = [];
125
+ const seen = new Set<string>();
126
+ for (const raw of tags) {
127
+ const tag = stripTagHash(raw);
128
+ if (tag === "" || seen.has(tag)) continue;
129
+ seen.add(tag);
130
+ if (h.allTags.has(tag)) continue; // identity row → never unknown
131
+ suspects.push(tag);
132
+ }
133
+ if (suspects.length === 0) return [];
134
+
135
+ // Slow path (only for identity-less names): pre-compute each suspect's
136
+ // expansion set (memoized on `h.descendantsCache` — safe to share with
137
+ // the store's cache, entries are pure derived state) so the membership
138
+ // check is ONE batched IN-list query over the union, not one per tag.
139
+ // An identity-less tag can still be "known" two ways: a `note_tags` row
140
+ // exists without a `tags` row (not produced by current writers, but
141
+ // contract-tolerated), or children declared it a parent (`childrenOf`
142
+ // edges exist for undeclared parents) and a descendant has notes.
143
+ const expansions = new Map<string, Set<string>>();
144
+ for (const tag of suspects) expansions.set(tag, getTagExpansion(h, tag, mode));
145
+ const candidateNames = new Set<string>();
146
+ for (const set of expansions.values()) for (const name of set) candidateNames.add(name);
147
+ const counts = countMembership(db, candidateNames);
148
+
149
+ const warnings: QueryWarning[] = [];
150
+ let suppressed = 0;
151
+ for (const tag of suspects) {
152
+ const hasOwnMembership = (counts.get(tag) ?? 0) > 0;
153
+ let hasExpansionMembers = false;
154
+ for (const t of expansions.get(tag)!) {
155
+ if ((counts.get(t) ?? 0) > 0) {
156
+ hasExpansionMembers = true;
157
+ break;
158
+ }
159
+ }
160
+
161
+ if (!hasOwnMembership && !hasExpansionMembers) {
162
+ if (warnings.length >= MAX_UNKNOWN_TAG_WARNINGS) {
163
+ suppressed++;
164
+ continue;
165
+ }
166
+ const suggestion = suggestSimilarTag(h.allTags, tag);
167
+ warnings.push({
168
+ code: "unknown_tag",
169
+ message: `tag "${tag}" has no identity row and no notes match it (directly or via expansion) — check spelling, or create it first with update-tag`,
170
+ tag,
171
+ ...(suggestion ? { did_you_mean: suggestion } : {}),
172
+ });
173
+ }
174
+ }
175
+ if (suppressed > 0) {
176
+ warnings.push({
177
+ code: "warnings_truncated",
178
+ message: `${suppressed} additional unknown_tag warning(s) suppressed — at most ${MAX_UNKNOWN_TAG_WARNINGS} are reported per query.`,
179
+ suppressed,
180
+ limit: MAX_UNKNOWN_TAG_WARNINGS,
181
+ });
182
+ }
183
+ return warnings;
184
+ }
package/core/src/store.ts CHANGED
@@ -70,8 +70,15 @@ export class BunSqliteStore implements Store {
70
70
  * boot or after an invalidation does the scan; subsequent calls hit the
71
71
  * cache. Returns the same object until invalidated, so callers can rely
72
72
  * on identity for memoizing per-tag descendant sets.
73
+ *
74
+ * Public (vault#550 fold): the query-warnings collector
75
+ * (`core/src/query-warnings.ts:collectUnknownTagWarnings`) runs on every
76
+ * tag-filtered structured query — threading this cached hierarchy in
77
+ * (instead of a fresh `loadTagHierarchy` per request) keeps the
78
+ * common all-tags-known case at ~zero extra cost. Treat the returned
79
+ * object as READ-ONLY — it's the shared cache, invalidated by writers.
73
80
  */
74
- private getTagHierarchy(): TagHierarchy {
81
+ getTagHierarchy(): TagHierarchy {
75
82
  if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
76
83
  return this._tagHierarchy;
77
84
  }
@@ -445,7 +452,7 @@ export class BunSqliteStore implements Store {
445
452
  return expanded;
446
453
  }
447
454
 
448
- async listTags(): Promise<{ name: string; count: number }[]> {
455
+ async listTags(): Promise<{ name: string; count: number; expanded_count: number }[]> {
449
456
  return noteOps.listTags(this.db);
450
457
  }
451
458
 
@@ -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,18 @@
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
5
  import type { ValidationStatus } from "./schema-defaults.js";
6
6
  import type { ConformanceReport } from "./conformance.js";
7
+ import type { FindPathResult } from "./links.js";
7
8
 
8
9
  // ---- Re-exports ----
9
10
 
10
11
  export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
11
12
  export type { PrunedField } from "./indexed-fields.js";
12
- export type { TagExpandMode } from "./tag-hierarchy.js";
13
+ export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
13
14
  export type { ConformanceReport } from "./conformance.js";
15
+ export type { FindPathResult } from "./links.js";
14
16
 
15
17
  // ---- Note ----
16
18
 
@@ -336,7 +338,20 @@ export interface Store {
336
338
  * IDENTICAL expansion the snapshot query engine uses for the same `expand`.
337
339
  */
338
340
  expandTags(tags: string[], mode?: TagExpandMode): Promise<Set<string>>;
339
- listTags(): Promise<{ name: string; count: number }[]>;
341
+ /**
342
+ * The store's cached tag hierarchy (invalidated on tag/parent_names
343
+ * writes). Sync, like `db` and `transaction`. Exposed (vault#550 fold)
344
+ * so per-query consumers — the `unknown_tag` warning collector — reuse
345
+ * the cache instead of re-scanning the `tags` table per request. Treat
346
+ * the returned object as READ-ONLY shared state.
347
+ */
348
+ getTagHierarchy(): TagHierarchy;
349
+ /**
350
+ * `expanded_count` (vault#550): distinct notes matching the tag OR any
351
+ * transitive descendant under the DEFAULT (subtypes) expansion axis,
352
+ * alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
353
+ */
354
+ listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
340
355
  deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
341
356
  renameTag(
342
357
  oldName: string,
@@ -375,7 +390,7 @@ export interface Store {
375
390
 
376
391
  // Deeper link queries
377
392
  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>;
393
+ findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<FindPathResult | null>;
379
394
 
380
395
  // Tag schemas — schema-only facade (description + fields). Back-compat
381
396
  // 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.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",