@openparachute/vault 0.5.3-rc.3 → 0.6.0

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 (41) hide show
  1. package/.parachute/module.json +14 -3
  2. package/core/src/mcp.ts +20 -0
  3. package/core/src/schema.ts +45 -1
  4. package/core/src/store.ts +66 -19
  5. package/core/src/tag-expand-axis.test.ts +301 -0
  6. package/core/src/tag-hierarchy.ts +80 -0
  7. package/core/src/triggers-store.test.ts +100 -0
  8. package/core/src/triggers-store.ts +165 -0
  9. package/core/src/types.ts +27 -1
  10. package/package.json +1 -1
  11. package/src/admin-spa.test.ts +100 -10
  12. package/src/admin-spa.ts +48 -3
  13. package/src/auto-transcribe.test.ts +51 -0
  14. package/src/auto-transcribe.ts +24 -6
  15. package/src/cli.ts +45 -18
  16. package/src/config.test.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/live-match.test.ts +198 -0
  19. package/src/live-match.ts +310 -0
  20. package/src/routes.ts +192 -78
  21. package/src/routing.test.ts +64 -0
  22. package/src/routing.ts +48 -1
  23. package/src/server.ts +49 -3
  24. package/src/subscribe.test.ts +588 -0
  25. package/src/subscribe.ts +248 -0
  26. package/src/subscriptions.ts +295 -0
  27. package/src/tag-expand-routes.test.ts +45 -0
  28. package/src/triggers-api.test.ts +533 -0
  29. package/src/triggers-api.ts +295 -0
  30. package/src/triggers.ts +93 -7
  31. package/src/vault-create.test.ts +35 -1
  32. package/src/vault-name.test.ts +61 -3
  33. package/src/vault-name.ts +62 -14
  34. package/src/vault-remove.test.ts +187 -0
  35. package/src/vault-store.ts +10 -3
  36. package/src/vault.test.ts +194 -0
  37. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  38. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  41. package/web/ui/dist/assets/index-DJL6Az--.js +0 -60
@@ -0,0 +1,310 @@
1
+ /**
2
+ * In-process query matcher for live subscriptions (live-query SSE — design
3
+ * `design/2026-06-08-live-query-sse.md`).
4
+ *
5
+ * The snapshot path evaluates a `QueryOpts` against the DB via the SQL query
6
+ * engine (`core/src/notes.ts queryNotes`). The live path can't go back to the
7
+ * DB for every mutation — the changed note is already in hand from the
8
+ * post-commit hook — so this module re-implements the *supported subset* of
9
+ * the query predicate against a single in-memory `Note`.
10
+ *
11
+ * **Predicate parity is the contract.** For any supported query, the set of
12
+ * notes the snapshot SQL returns MUST equal the set this matcher accepts.
13
+ * `subscribe.test.ts` enforces that over a seeded corpus. Every clause below
14
+ * is written to mirror the exact SQL semantics in `queryNotes`:
15
+ *
16
+ * - `tags` ("all"/"any") with descendant expansion — mirrors the per-input
17
+ * `_tagsExpanded` JOINs (each input tag matches the input OR any declared
18
+ * descendant). Expansion is resolved ONCE at subscribe time (see
19
+ * `buildLiveMatcher`) and frozen into the matcher, exactly as the engine
20
+ * freezes it for the snapshot query.
21
+ * - `excludeTags` — raw exact-name match (engine does NOT expand excludes).
22
+ * - `path` — case-insensitive exact (engine: `n.path = ? COLLATE NOCASE`).
23
+ * - `pathPrefix` — prefix (engine: `n.path LIKE prefix || '%'`).
24
+ * - `extension` — lower-cased, default "md" (engine: `LOWER(n.extension)`),
25
+ * a note with no extension is treated as "md".
26
+ * - `metadata` operator objects (eq/ne/gt/gte/lt/lte/in/not_in/exists) +
27
+ * primitive exact-match — mirrors `buildOperatorClause` / the json_extract
28
+ * shorthand. NULL-aware exactly like the SQL (`ne`/`not_in` match the
29
+ * field-absent row; `eq null` matches absence).
30
+ *
31
+ * - `hasTags` (presence) — `note.tags?.length > 0`, trivial + parity-safe.
32
+ *
33
+ * **Unsupported (rejected upstream with 400, never reach the matcher):**
34
+ * `search` (FTS) and `near` (graph BFS) — not evaluable against a single note;
35
+ * `hasLinks` — needs the `links` table (not on the in-hand note); date filters
36
+ * (`dateFrom`/`dateTo`/`dateFilter`) — parity risk + some need indexed columns;
37
+ * `cursor` — paging is meaningless for a live set. The subscribe route returns
38
+ * 400 for these BEFORE a subscription is created (see
39
+ * `unsupportedSubscriptionReason` + the route's raw-param checks).
40
+ *
41
+ * **Ignored (irrelevant to set membership):** `orderBy`, `limit`, `offset`,
42
+ * `ids`. The route strips `limit`/`offset` from the snapshot query so the
43
+ * snapshot is the COMPLETE matching set (parity with the unbounded matcher).
44
+ */
45
+
46
+ import type { Note, QueryOpts, Store } from "../core/src/types.ts";
47
+ import { SUPPORTED_OPS } from "../core/src/query-operators.ts";
48
+
49
+ const OPS_SET: ReadonlySet<string> = new Set<string>(SUPPORTED_OPS);
50
+
51
+ /**
52
+ * Frozen, pre-resolved form of a `QueryOpts` for fast per-note matching.
53
+ * Tag descendant expansion (a DB read) is done once here, not per event.
54
+ */
55
+ export interface LiveMatcher {
56
+ /** The original supported opts (for diagnostics / parity tests). */
57
+ readonly opts: QueryOpts;
58
+ /**
59
+ * Per-input-tag expanded sets: `tagSets[i]` = `{tags[i]} ∪ descendants`.
60
+ * Mirrors `_tagsExpanded` in the engine. Empty when no `tags` filter.
61
+ * A note matches under "all" iff it carries ≥1 tag from EACH set; under
62
+ * "any" iff it carries ≥1 tag from the UNION.
63
+ */
64
+ readonly tagSets: string[][];
65
+ readonly tagMatch: "all" | "any";
66
+ readonly excludeTags: string[];
67
+ match(note: Note): boolean;
68
+ }
69
+
70
+ /**
71
+ * Query shapes a live subscription can't evaluate against a single note.
72
+ * The route layer rejects these with 400 before creating a subscription.
73
+ */
74
+ export function unsupportedSubscriptionReason(opts: QueryOpts): string | null {
75
+ // `search` / `near` are parsed/handled by the notes route separately; the
76
+ // subscribe route detects them from raw query params. These guards catch the
77
+ // remaining shapes that the matcher can't faithfully evaluate against a
78
+ // single in-hand note (so snapshot and live would disagree).
79
+ if (opts.cursor) {
80
+ return "cursor pagination is not supported for live subscriptions";
81
+ }
82
+ if (opts.hasLinks !== undefined) {
83
+ return "has_links is not supported for live subscriptions (requires the links table)";
84
+ }
85
+ if (opts.dateFilter || opts.dateFrom || opts.dateTo) {
86
+ return "date filters are not supported for live subscriptions";
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function metaValue(note: Note, key: string): unknown {
92
+ const m = note.metadata;
93
+ if (!m || typeof m !== "object") return undefined;
94
+ return (m as Record<string, unknown>)[key];
95
+ }
96
+
97
+ /**
98
+ * Compare two primitives the way SQLite's generated `meta_<field>` column
99
+ * would for ordering operators. Values stored via the indexed column are
100
+ * primitives; we compare numbers numerically and strings lexically, matching
101
+ * SQLite's type affinity for a column holding the JSON-extracted scalar.
102
+ */
103
+ function cmp(a: unknown, b: unknown): number | null {
104
+ if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0;
105
+ // Booleans compare as their numeric form (SQLite stores 1/0).
106
+ const an = typeof a === "boolean" ? (a ? 1 : 0) : a;
107
+ const bn = typeof b === "boolean" ? (b ? 1 : 0) : b;
108
+ if (typeof an === "number" && typeof bn === "number") return an < bn ? -1 : an > bn ? 1 : 0;
109
+ const as = String(an);
110
+ const bs = String(bn);
111
+ return as < bs ? -1 : as > bs ? 1 : 0;
112
+ }
113
+
114
+ /** Loose equality mirroring the json_extract shorthand + operator `eq`. */
115
+ function looseEq(actual: unknown, expected: unknown): boolean {
116
+ if (actual === expected) return true;
117
+ // Numbers vs numeric strings: the engine's operator path binds the raw
118
+ // value to an indexed numeric column, so `5` and `5` match; the shorthand
119
+ // path stringifies. Normalize via string compare as a backstop.
120
+ if (actual == null || expected == null) return actual === expected;
121
+ if (typeof actual === "boolean" || typeof expected === "boolean") {
122
+ return (actual ? 1 : 0) === (expected ? 1 : 0) || String(actual) === String(expected);
123
+ }
124
+ return String(actual) === String(expected);
125
+ }
126
+
127
+ function evalOperatorObject(actual: unknown, opObj: Record<string, unknown>): boolean {
128
+ for (const [op, expected] of Object.entries(opObj)) {
129
+ switch (op) {
130
+ case "eq":
131
+ if (expected === null) {
132
+ if (actual !== undefined && actual !== null) return false;
133
+ } else if (!looseEq(actual, expected)) {
134
+ return false;
135
+ }
136
+ break;
137
+ case "ne":
138
+ // SQL: (col IS NULL OR col <> ?) — absent field passes; equal fails.
139
+ if (expected === null) {
140
+ if (actual === undefined || actual === null) return false;
141
+ } else if (actual !== undefined && actual !== null && looseEq(actual, expected)) {
142
+ return false;
143
+ }
144
+ break;
145
+ case "gt":
146
+ case "gte":
147
+ case "lt":
148
+ case "lte": {
149
+ // SQL comparison operators yield NULL (→ excluded) when the column
150
+ // is NULL/absent.
151
+ if (actual === undefined || actual === null) return false;
152
+ const c = cmp(actual, expected);
153
+ if (c === null) return false;
154
+ if (op === "gt" && !(c > 0)) return false;
155
+ if (op === "gte" && !(c >= 0)) return false;
156
+ if (op === "lt" && !(c < 0)) return false;
157
+ if (op === "lte" && !(c <= 0)) return false;
158
+ break;
159
+ }
160
+ case "in": {
161
+ if (!Array.isArray(expected)) return false;
162
+ if (expected.length === 0) return false; // engine emits `0`
163
+ if (actual === undefined || actual === null) return false;
164
+ if (!expected.some((v) => looseEq(actual, v))) return false;
165
+ break;
166
+ }
167
+ case "not_in": {
168
+ if (!Array.isArray(expected)) return false;
169
+ if (expected.length === 0) break; // engine emits `1` (no-op pass)
170
+ // SQL: (col IS NULL OR col NOT IN (...)) — absent passes.
171
+ if (actual === undefined || actual === null) break;
172
+ if (expected.some((v) => looseEq(actual, v))) return false;
173
+ break;
174
+ }
175
+ case "exists": {
176
+ const present = actual !== undefined && actual !== null;
177
+ if (expected === true && !present) return false;
178
+ if (expected === false && present) return false;
179
+ break;
180
+ }
181
+ default:
182
+ // Unknown operator — the snapshot path would have thrown
183
+ // UNKNOWN_OPERATOR at query time, so this never matches.
184
+ return false;
185
+ }
186
+ }
187
+ return true;
188
+ }
189
+
190
+ /** True iff every key of `value` is a supported operator (operator-object). */
191
+ function isOperatorObject(value: unknown): value is Record<string, unknown> {
192
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
193
+ const keys = Object.keys(value as object);
194
+ if (keys.length === 0) return false;
195
+ return keys.every((k) => OPS_SET.has(k));
196
+ }
197
+
198
+ /**
199
+ * Resolve a `QueryOpts` into a frozen `LiveMatcher`, expanding tag descendants
200
+ * once via the store (the same hierarchy the snapshot query uses). Call at
201
+ * subscribe time; the returned matcher is pure + sync for per-event use.
202
+ */
203
+ export async function buildLiveMatcher(store: Store, opts: QueryOpts): Promise<LiveMatcher> {
204
+ const tags = opts.tags ?? [];
205
+ const tagMatch: "all" | "any" = opts.tagMatch ?? "all";
206
+ // Per-input expansion along the SAME axis the snapshot query uses
207
+ // (vault tag `expand` axis). `opts.expand` (default "subtypes") MUST be
208
+ // threaded through here so the live matcher and the snapshot query engine
209
+ // lower the IDENTICAL expansion — otherwise a subscription's snapshot and
210
+ // its live events would disagree on which notes match. `expandTags` already
211
+ // includes each input tag.
212
+ const tagSets: string[][] = [];
213
+ for (const t of tags) {
214
+ const set = await store.expandTags([t], opts.expand);
215
+ // Be defensive: ensure the root is present (expandTags always includes it
216
+ // for non-empty input, but the contract should not rely on it here).
217
+ set.add(t);
218
+ tagSets.push(Array.from(set));
219
+ }
220
+ const excludeTags = opts.excludeTags ?? [];
221
+
222
+ const matcher: LiveMatcher = {
223
+ opts,
224
+ tagSets,
225
+ tagMatch,
226
+ excludeTags,
227
+ match(note: Note): boolean {
228
+ return matchAgainst(note, opts, tagSets, tagMatch, excludeTags);
229
+ },
230
+ };
231
+ return matcher;
232
+ }
233
+
234
+ function matchAgainst(
235
+ note: Note,
236
+ opts: QueryOpts,
237
+ tagSets: string[][],
238
+ tagMatch: "all" | "any",
239
+ excludeTags: string[],
240
+ ): boolean {
241
+ const noteTags = note.tags ?? [];
242
+
243
+ // ---- tags ----
244
+ if (tagSets.length > 0) {
245
+ if (tagMatch === "any") {
246
+ const union = new Set(tagSets.flat());
247
+ if (!noteTags.some((t) => union.has(t))) return false;
248
+ } else {
249
+ // "all": for each input tag's expanded set, the note must carry ≥1.
250
+ for (const set of tagSets) {
251
+ if (set.length === 0) continue;
252
+ const s = new Set(set);
253
+ if (!noteTags.some((t) => s.has(t))) return false;
254
+ }
255
+ }
256
+ }
257
+
258
+ // ---- excludeTags (raw exact, no expansion — mirrors engine) ----
259
+ for (const ex of excludeTags) {
260
+ if (noteTags.includes(ex)) return false;
261
+ }
262
+
263
+ // ---- hasTags (presence) — ignored by the engine when a `tags` filter is
264
+ // also set (the tag filter already constrains to tagged notes), so mirror
265
+ // that: only apply when there's no `tags` filter. See queryNotes
266
+ // `filterByTags` short-circuit.
267
+ if (opts.hasTags !== undefined && tagSets.length === 0) {
268
+ const has = noteTags.length > 0;
269
+ if (opts.hasTags !== has) return false;
270
+ }
271
+
272
+ // ---- path (case-insensitive exact) ----
273
+ if (opts.path) {
274
+ if (!note.path || note.path.toLowerCase() !== opts.path.toLowerCase()) return false;
275
+ }
276
+
277
+ // ---- pathPrefix (case-insensitive — the engine uses `LIKE prefix || '%'`,
278
+ // and SQLite LIKE is ASCII-case-insensitive by default) ----
279
+ if (opts.pathPrefix) {
280
+ if (!note.path || !note.path.toLowerCase().startsWith(opts.pathPrefix.toLowerCase())) return false;
281
+ }
282
+
283
+ // ---- extension (lower-cased; default "md") ----
284
+ if (opts.extension !== undefined) {
285
+ const exts = Array.isArray(opts.extension) ? opts.extension : [opts.extension];
286
+ const cleaned = exts
287
+ .filter((e): e is string => typeof e === "string" && e.length > 0)
288
+ .map((e) => e.toLowerCase());
289
+ if (cleaned.length > 0) {
290
+ const noteExt = (note.extension ?? "md").toLowerCase();
291
+ if (!cleaned.includes(noteExt)) return false;
292
+ }
293
+ }
294
+
295
+ // ---- metadata ----
296
+ if (opts.metadata) {
297
+ for (const [key, value] of Object.entries(opts.metadata)) {
298
+ const actual = metaValue(note, key);
299
+ if (isOperatorObject(value)) {
300
+ if (!evalOperatorObject(actual, value)) return false;
301
+ } else {
302
+ // Primitive exact-match (json_extract shorthand). The engine compares
303
+ // the JSON-extracted scalar against the string/JSON form.
304
+ if (!looseEq(actual, value)) return false;
305
+ }
306
+ }
307
+ }
308
+
309
+ return true;
310
+ }
package/src/routes.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  * and the Request, and returns a Response.
12
12
  */
13
13
 
14
- import type { Store, Note } from "../core/src/types.ts";
14
+ import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
+ import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
15
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
17
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
17
18
  import { attachValidationStatus } from "../core/src/mcp.ts";
@@ -46,7 +47,7 @@ import {
46
47
  } from "../core/src/expand.ts";
47
48
  import { join, extname, normalize } from "path";
48
49
  import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
49
- import { assetsDir } from "./config.ts";
50
+ import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
50
51
  import { shouldAutoTranscribe } from "./auto-transcribe.ts";
51
52
  // usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
52
53
  // of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
@@ -435,6 +436,124 @@ function parseMetadataJsonAlias(url: URL): {
435
436
  return { metadata: parsed as Record<string, unknown> };
436
437
  }
437
438
 
439
+ /**
440
+ * Parse + validate the `?expand=` tag-expansion axis (vault tag `expand` axis).
441
+ * Shared by `parseNotesQueryOpts` (structured + subscribe) AND the full-text
442
+ * search branch of `handleNotes` (which bypasses `parseNotesQueryOpts`), so the
443
+ * enum lives in exactly one place and `GET /notes?search=...&expand=bogus` is
444
+ * validated identically to the structured path.
445
+ *
446
+ * Returns `{ expand }` (undefined when absent/empty → store defaults to
447
+ * "subtypes") or `{ error }` (a 400 Response) on an unknown value.
448
+ */
449
+ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Response } {
450
+ const expandParam = parseQuery(url, "expand");
451
+ if (expandParam === null || expandParam === "") return {};
452
+ if (!(TAG_EXPAND_MODES as readonly string[]).includes(expandParam)) {
453
+ return {
454
+ error: json(
455
+ {
456
+ error: `invalid \`expand\` value "${expandParam}" — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes": parent_names descendants).`,
457
+ code: "INVALID_QUERY",
458
+ },
459
+ 400,
460
+ ),
461
+ };
462
+ }
463
+ return { expand: expandParam as TagExpandMode };
464
+ }
465
+
466
+ /**
467
+ * Parse the shared notes-query parameters (tags / excludeTags / path /
468
+ * pathPrefix / extension / metadata filters / date filters / sort / paging /
469
+ * cursor) into a `QueryOpts`, plus flags for the query shapes that the live
470
+ * subscription endpoint must reject (`search`, `near`, `cursor`).
471
+ *
472
+ * Factored out of `handleNotesInner`'s structured-query branch so the
473
+ * `/subscribe` route evaluates the SAME predicate the snapshot query does —
474
+ * predicate parity by construction, not copy-paste. `handleNotesInner` keeps
475
+ * its own inline parsing for the single-note (`id`) and full-text (`search`)
476
+ * branches; this helper covers the structured-query shape both endpoints share.
477
+ *
478
+ * Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
479
+ * as the inline code did. `hasSearch` is surfaced from the raw `search` param
480
+ * (this helper does not itself build a search query — the caller routes that).
481
+ */
482
+ export function parseNotesQueryOpts(url: URL): {
483
+ queryOpts?: QueryOpts;
484
+ hasSearch: boolean;
485
+ hasNear: boolean;
486
+ hasCursor: boolean;
487
+ error?: Response;
488
+ } {
489
+ const hasSearch = parseQuery(url, "search") !== null && parseQuery(url, "search") !== "";
490
+ const hasNear = parseQuery(url, "near[note_id]") !== null;
491
+ const cursorParam = parseQuery(url, "cursor");
492
+ const hasCursor = cursorParam !== null && cursorParam !== "";
493
+
494
+ const tags = parseQueryList(url, "tag");
495
+ const bracket = parseMetaBrackets(url);
496
+ if (bracket.error) return { hasSearch, hasNear, hasCursor, error: bracket.error };
497
+ const metadataAlias = parseMetadataJsonAlias(url);
498
+ if (metadataAlias.error) return { hasSearch, hasNear, hasCursor, error: metadataAlias.error };
499
+ if (metadataAlias.metadata && bracket.metadata) {
500
+ return {
501
+ hasSearch,
502
+ hasNear,
503
+ hasCursor,
504
+ error: json(
505
+ {
506
+ error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
507
+ code: "INVALID_QUERY",
508
+ },
509
+ 400,
510
+ ),
511
+ };
512
+ }
513
+
514
+ // Tag-expansion axis (vault tag `expand` axis). Optional; absent →
515
+ // "subtypes" (resolved at the store, kept undefined here so it stays
516
+ // byte-identical to pre-axis behavior). Unknown value → 400 via the shared
517
+ // helper (same shape the search branch uses).
518
+ const expandParsed = parseExpandParam(url);
519
+ if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
520
+ const expand = expandParsed.expand;
521
+
522
+ const queryOpts: QueryOpts = {
523
+ tags,
524
+ tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
525
+ expand,
526
+ excludeTags: parseQueryList(url, "exclude_tag"),
527
+ hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
528
+ hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
529
+ path: parseQuery(url, "path") ?? undefined,
530
+ pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
531
+ extension: parseExtensionFilter(url),
532
+ metadata: bracket.metadata ?? metadataAlias.metadata,
533
+ ...(bracket.dateFilter
534
+ ? { dateFilter: bracket.dateFilter }
535
+ : parseQuery(url, "date_field")
536
+ ? {
537
+ dateFilter: {
538
+ field: parseQuery(url, "date_field")!,
539
+ from: parseQuery(url, "date_from") ?? undefined,
540
+ to: parseQuery(url, "date_to") ?? undefined,
541
+ },
542
+ }
543
+ : {
544
+ dateFrom: parseQuery(url, "date_from") ?? undefined,
545
+ dateTo: parseQuery(url, "date_to") ?? undefined,
546
+ }),
547
+ sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
548
+ orderBy: parseQuery(url, "order_by") ?? undefined,
549
+ limit: parseInt10(parseQuery(url, "limit")) ?? 50,
550
+ offset: parseInt10(parseQuery(url, "offset")),
551
+ cursor: cursorParam ?? undefined,
552
+ };
553
+
554
+ return { queryOpts, hasSearch, hasNear, hasCursor };
555
+ }
556
+
438
557
  /**
439
558
  * Parse include_metadata query param.
440
559
  * - absent/null → undefined (all metadata, default)
@@ -634,7 +753,17 @@ async function handleNotesInner(
634
753
  if (search) {
635
754
  const searchTags = parseQueryList(url, "tag");
636
755
  const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
637
- const rawResults = await store.searchNotes(search, { tags: searchTags, limit });
756
+ // Tag-expansion axis (vault tag `expand` axis). This branch bypasses
757
+ // `parseNotesQueryOpts`, so validate `?expand=` here too — otherwise
758
+ // `GET /notes?search=x&expand=bogus` would silently ignore the bad
759
+ // value. The validated mode is threaded into the search tag-narrowing.
760
+ const tagExpand = parseExpandParam(url);
761
+ if (tagExpand.error) return tagExpand.error;
762
+ const rawResults = await store.searchNotes(search, {
763
+ tags: searchTags,
764
+ limit,
765
+ expand: tagExpand.expand,
766
+ });
638
767
  // Tag-scope: drop any result the token isn't permitted to see. Filter
639
768
  // happens after the store query so an empty post-filter list still
640
769
  // returns 200 [] (consistent with "no matches"), not 403.
@@ -694,36 +823,16 @@ async function handleNotesInner(
694
823
  // Surface asymmetry: REST flattens to a query string; MCP takes a
695
824
  // nested `date_filter: { field, from, to }` object directly. Both
696
825
  // lower to the same store-level `dateFilter` shape.
697
- const tags = parseQueryList(url, "tag");
698
- const bracket = parseMetaBrackets(url);
699
- if (bracket.error) return bracket.error;
700
- // `?metadata=<json>` alias — the JSON-object form of the metadata
701
- // filter, symmetric with the nested object MCP forwards. Before this,
702
- // a `metadata=` param was silently dropped (the bracket grammar never
703
- // matched it), so the query returned ALL tag-matching notes.
704
- const metadataAlias = parseMetadataJsonAlias(url);
705
- if (metadataAlias.error) return metadataAlias.error;
706
- // Reject "both forms" loudly. If a caller passes BOTH the JSON
707
- // `metadata=` param AND any `meta[...]` bracket param, there's no
708
- // well-defined merge and silently picking a winner is exactly the
709
- // class of bug we're fixing. Symmetric with the mixed shorthand/
710
- // operator rejection inside parseMetaBrackets. Guard stays narrow —
711
- // only the named `metadata` param triggers it.
712
- if (metadataAlias.metadata && bracket.metadata) {
713
- return json(
714
- {
715
- error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
716
- code: "INVALID_QUERY",
717
- },
718
- 400,
719
- );
720
- }
721
- // Opaque cursor for "since last checked" agent loops (vault#313).
722
- // When present, switches the response shape to {notes, next_cursor}
723
- // and routes through queryNotesPaged for keyset pagination. Mutually
724
- // exclusive with the `near` graph-neighborhood scope (rebuilding the
725
- // neighborhood per page isn't stable) — rejected below.
826
+ // Structured-query parsing is shared with the live `/subscribe` route
827
+ // (see `parseNotesQueryOpts`) so both endpoints lower an identical query
828
+ // string to the same `QueryOpts` — predicate parity by construction.
829
+ const parsed = parseNotesQueryOpts(url);
830
+ if (parsed.error) return parsed.error;
831
+ const queryOpts = parsed.queryOpts!;
726
832
  const cursorParam = parseQuery(url, "cursor");
833
+ // Opaque cursor for "since last checked" agent loops (vault#313) is
834
+ // mutually exclusive with the `near` graph-neighborhood scope (rebuilding
835
+ // the neighborhood per page isn't stable).
727
836
  const nearNoteIdEarly = parseQuery(url, "near[note_id]");
728
837
  if (cursorParam && nearNoteIdEarly) {
729
838
  return json(
@@ -736,50 +845,6 @@ async function handleNotesInner(
736
845
  }
737
846
  let results: Note[];
738
847
  let nextCursor: string | null = null;
739
- const queryOpts = {
740
- tags,
741
- tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
742
- excludeTags: parseQueryList(url, "exclude_tag"),
743
- hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
744
- hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
745
- path: parseQuery(url, "path") ?? undefined,
746
- pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
747
- // Extension filter (vault#328). Accepts repeated `extension=`
748
- // params for the array form: `?extension=csv&extension=yaml`.
749
- // `parseQueryList` already returns undefined when no params
750
- // are present, so the filter is silently skipped on a plain
751
- // GET without the extension query.
752
- extension: parseExtensionFilter(url),
753
- // Bracket form and JSON-alias form are mutually exclusive (guarded
754
- // above), so at most one of these is set.
755
- metadata: bracket.metadata ?? metadataAlias.metadata,
756
- // Date-range precedence chain (highest to lowest):
757
- // 1. Bracket-style `meta[created_at][gte]=…` (canonical).
758
- // 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
759
- // 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
760
- // — filters on `n.created_at` by definition.
761
- // The engine rejects combinations of `dateFilter` with the legacy
762
- // `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
763
- ...(bracket.dateFilter
764
- ? { dateFilter: bracket.dateFilter }
765
- : parseQuery(url, "date_field")
766
- ? {
767
- dateFilter: {
768
- field: parseQuery(url, "date_field")!,
769
- from: parseQuery(url, "date_from") ?? undefined,
770
- to: parseQuery(url, "date_to") ?? undefined,
771
- },
772
- }
773
- : {
774
- dateFrom: parseQuery(url, "date_from") ?? undefined,
775
- dateTo: parseQuery(url, "date_to") ?? undefined,
776
- }),
777
- sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
778
- orderBy: parseQuery(url, "order_by") ?? undefined,
779
- limit: parseInt10(parseQuery(url, "limit")) ?? 50,
780
- offset: parseInt10(parseQuery(url, "offset")),
781
- cursor: cursorParam ?? undefined,
782
- };
783
848
  try {
784
849
  if (cursorParam) {
785
850
  const page = await store.queryNotesPaged(queryOpts);
@@ -1079,7 +1144,14 @@ async function handleNotesInner(
1079
1144
  // Explicit `transcribe: true` wins — if the caller asked, we honor that
1080
1145
  // regardless of the auto-transcribe toggle (back-compat).
1081
1146
  const explicitOptIn = body.transcribe === true;
1082
- const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType);
1147
+ // Per-vault auto-transcribe: read THIS vault's `auto_transcribe.enabled`
1148
+ // (vault.yaml) and pass it as the precedence-winning toggle. A vault that
1149
+ // set its own value uses it; one that left it unset falls through to the
1150
+ // global toggle inside `shouldAutoTranscribe` (per-vault → global → true).
1151
+ const perVaultEnabled = vault
1152
+ ? readVaultConfig(vault)?.auto_transcribe?.enabled
1153
+ : undefined;
1154
+ const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
1083
1155
  const attMeta = (explicitOptIn || autoOptIn)
1084
1156
  ? {
1085
1157
  transcribe_status: "pending" as const,
@@ -1895,16 +1967,36 @@ type VaultConfigLike = {
1895
1967
  name: string;
1896
1968
  description?: string;
1897
1969
  audio_retention?: "keep" | "until_transcribed" | "never";
1970
+ auto_transcribe?: { enabled?: boolean };
1898
1971
  };
1899
1972
 
1900
1973
  const VALID_AUDIO_RETENTION = ["keep", "until_transcribed", "never"] as const;
1901
1974
 
1975
+ /**
1976
+ * Resolve the effective auto-transcribe toggle for a vault's GET response, the
1977
+ * SAME resolution `shouldAutoTranscribe` uses at the decision point:
1978
+ * **per-vault → global → true**. A vault that set its own `auto_transcribe`
1979
+ * shows that; one that left it unset shows the server-wide default (itself
1980
+ * default-ON). This keeps the GET in lock-step with what the worker actually
1981
+ * does, with no field-name drift.
1982
+ */
1983
+ function resolveAutoTranscribeEnabled(vaultConfig: VaultConfigLike): boolean {
1984
+ return (
1985
+ vaultConfig.auto_transcribe?.enabled
1986
+ ?? readGlobalConfig().auto_transcribe?.enabled
1987
+ ?? true
1988
+ );
1989
+ }
1990
+
1902
1991
  function vaultResponse(vaultConfig: VaultConfigLike): Record<string, unknown> {
1903
1992
  return {
1904
1993
  name: vaultConfig.name,
1905
1994
  description: vaultConfig.description ?? null,
1906
1995
  config: {
1907
1996
  audio_retention: vaultConfig.audio_retention ?? "keep",
1997
+ auto_transcribe: {
1998
+ enabled: resolveAutoTranscribeEnabled(vaultConfig),
1999
+ },
1908
2000
  },
1909
2001
  };
1910
2002
  }
@@ -1928,7 +2020,7 @@ export async function handleVault(
1928
2020
  if (req.method === "PATCH") {
1929
2021
  const body = await req.json() as {
1930
2022
  description?: string;
1931
- config?: { audio_retention?: string };
2023
+ config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
1932
2024
  };
1933
2025
  let dirty = false;
1934
2026
 
@@ -1952,6 +2044,28 @@ export async function handleVault(
1952
2044
  dirty = true;
1953
2045
  }
1954
2046
 
2047
+ // auto_transcribe.enabled — PER-VAULT toggle persisted to THIS vault's
2048
+ // vault.yaml (via `persist`, the same writeVaultConfig path as
2049
+ // description/audio_retention). Flipping it for vault X affects only X;
2050
+ // scribe's "link to vault X" PATCHes this and never touches other vaults.
2051
+ // The worker reads the same per-vault field (per-vault → global → true).
2052
+ // Validate the shape: when `auto_transcribe` is present it must carry a
2053
+ // boolean `enabled`.
2054
+ if (body.config?.auto_transcribe !== undefined) {
2055
+ const enabled = body.config.auto_transcribe?.enabled;
2056
+ if (typeof enabled !== "boolean") {
2057
+ return json(
2058
+ {
2059
+ error: "invalid_auto_transcribe",
2060
+ message: "auto_transcribe.enabled must be a boolean",
2061
+ },
2062
+ 400,
2063
+ );
2064
+ }
2065
+ vaultConfig.auto_transcribe = { ...vaultConfig.auto_transcribe, enabled };
2066
+ dirty = true;
2067
+ }
2068
+
1955
2069
  if (dirty && persist) persist();
1956
2070
  return json(vaultResponse(vaultConfig));
1957
2071
  }