neuro-vault-mcp 7.0.0 → 8.1.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 🧠💾 **Make your personal vault usable by agents.** Low-token retrieval, explicit provenance, and safe writes for your Obsidian notes — in Claude Code, Cursor, Windsurf, and any MCP client.
4
4
 
5
- https://github.com/user-attachments/assets/25c1bafb-7b90-43ac-aa50-50e85705fb5b
5
+ [https://github.com/user-attachments/assets/25c1bafb-7b90-43ac-aa50-50e85705fb5b](https://github.com/user-attachments/assets/5ac86cd7-2d67-420d-b940-dba4ae1a2ccb)
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/neuro-vault-mcp)](https://www.npmjs.com/package/neuro-vault-mcp)
8
8
  [![Node.js](https://img.shields.io/node/v/neuro-vault-mcp)](https://nodejs.org)
@@ -72,7 +72,7 @@ One question, one answer. Your assistant stops being a file browser and starts b
72
72
  { "query": "trading lessons", "filter": { "tags": ["trading"] } }
73
73
  ```
74
74
 
75
- `filter` accepts `path_prefix`, `tags` (ANY-of), and a `frontmatter` sift filter. Composition is `filter` AND `threshold` AND semantic. See the [Semantic Search guide](./docs/guide/semantic-search.md#pre-filter-filter-parameter) for full details.
75
+ `filter` accepts `path_prefix` (string or array), `exclude_path_prefix` (string or array — drops matched subtrees), `tags` (ANY-of), and a `frontmatter` sift filter. Composition is include → exclude → tags → frontmatter → threshold → semantic. See the [Semantic Search guide](./docs/guide/semantic-search.md#pre-filter-filter-parameter) for full details.
76
76
 
77
77
  ---
78
78
 
@@ -140,7 +140,7 @@ Two vaults cannot share the same directory basename — the basename doubles as
140
140
  With multiple vaults registered:
141
141
 
142
142
  - **Every tool** accepts an optional `vault: "<name>"` parameter to target a specific vault.
143
- - **`search_notes`, `query_notes`, and `get_vault_overview`** fan out across all registered vaults when `vault` is omitted. The response shape switches to `results_by_vault: [...]` (one entry per vault) plus `skipped_vaults: [...]` for any vault the tool could not reach.
143
+ - **`search_notes`, `query_notes`, `get_vault_overview`, `list_tags`, and `list_properties`** fan out across all registered vaults when `vault` is omitted. The response shape switches to `results_by_vault: [...]` (one entry per vault) plus `skipped_vaults: [...]` for any vault the tool could not reach and `failed_vaults: [...]` for per-vault runtime errors (`{ vault, error: { code, message, details? } }`). A single failed vault does not abort the whole call.
144
144
  - **All other tools** (writes, reads of specific paths, single-vault diagnostics) require an explicit `vault` in multi-vault mode. Omitting it returns `VAULT_REQUIRED`.
145
145
  - **Semantic fan-out** silently skips vaults whose Smart Connections `.smart-env/multi/` index is unavailable. Targeting such a vault explicitly with `vault: "<name>"` returns `SEMANTIC_INDEX_NOT_FOUND`.
146
146
 
package/dist/cli.d.ts CHANGED
@@ -72,7 +72,8 @@ declare class WikilinkGraphIndex {
72
72
  }
73
73
 
74
74
  interface NoteFilter {
75
- path_prefix?: string;
75
+ path_prefix?: string | string[];
76
+ exclude_path_prefix?: string | string[];
76
77
  tags?: string[];
77
78
  frontmatter?: Record<string, unknown>;
78
79
  }
package/dist/cli.js CHANGED
@@ -14228,6 +14228,16 @@ var ToolHandlerError = class extends Error {
14228
14228
  }
14229
14229
  }
14230
14230
  };
14231
+ var FATAL_TOOL_ERROR_CODES = /* @__PURE__ */ new Set([
14232
+ "INVALID_ARGUMENT",
14233
+ "INVALID_PARAMS",
14234
+ "INVALID_FILTER",
14235
+ "VAULT_REQUIRED",
14236
+ "VAULT_NOT_FOUND"
14237
+ ]);
14238
+ function isFatalToolError(error48) {
14239
+ return error48 instanceof ToolHandlerError && FATAL_TOOL_ERROR_CODES.has(error48.code);
14240
+ }
14231
14241
  function isPlainRecord(value) {
14232
14242
  return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
14233
14243
  }
@@ -15192,6 +15202,38 @@ function normalizeVaultPathPrefix(raw, invalid) {
15192
15202
  return slashed.replace(/\/+$/, "");
15193
15203
  }
15194
15204
 
15205
+ // src/lib/obsidian/query/path-prefix-set.ts
15206
+ function normalizePrefixList(raw, field, errorCode) {
15207
+ if (raw === void 0) return void 0;
15208
+ const invalid = (message) => new ToolHandlerError(errorCode, message, { details: { field } });
15209
+ if (Array.isArray(raw)) {
15210
+ if (raw.length === 0) {
15211
+ throw invalid(`${field} must contain at least one prefix`);
15212
+ }
15213
+ const out = /* @__PURE__ */ new Set();
15214
+ for (const entry of raw) {
15215
+ if (typeof entry !== "string") {
15216
+ throw invalid(`${field} elements must be strings`);
15217
+ }
15218
+ const normalized2 = normalizeVaultPathPrefix(entry, invalid);
15219
+ if (normalized2 !== void 0) out.add(normalized2);
15220
+ }
15221
+ return out.size === 0 ? void 0 : [...out];
15222
+ }
15223
+ if (typeof raw !== "string") {
15224
+ throw invalid(`${field} must be a string or array of strings`);
15225
+ }
15226
+ const normalized = normalizeVaultPathPrefix(raw, invalid);
15227
+ return normalized === void 0 ? void 0 : [normalized];
15228
+ }
15229
+ function matchesAnyPrefix(p, prefixes) {
15230
+ for (const q of prefixes) {
15231
+ if (p === q) return true;
15232
+ if (p.startsWith(`${q}/`)) return true;
15233
+ }
15234
+ return false;
15235
+ }
15236
+
15195
15237
  // src/lib/obsidian/query/whitelist.ts
15196
15238
  var ALLOWED_OPERATORS = /* @__PURE__ */ new Set([
15197
15239
  "$eq",
@@ -15279,6 +15321,9 @@ async function collectMatchingPaths(input, deps) {
15279
15321
  }
15280
15322
  continue;
15281
15323
  }
15324
+ if (input.excludePathPrefixes !== void 0 && matchesAnyPrefix(item.path, input.excludePathPrefixes)) {
15325
+ continue;
15326
+ }
15282
15327
  const backlinkCount = graph?.getBacklinkCount(item.path) ?? 0;
15283
15328
  const record2 = toNoteRecord(item, backlinkCount);
15284
15329
  let isMatch;
@@ -15302,16 +15347,33 @@ async function runQueryNotes(input, reader, graph) {
15302
15347
  if (graph) {
15303
15348
  await graph.ensureFresh();
15304
15349
  }
15305
- const earlyExitAllowed = !validated.sort || validated.sort.field === "path" && validated.sort.order === "asc";
15306
- const matched = await collectMatchingPaths(
15307
- {
15308
- filter: effectiveFilter,
15309
- pathPrefix: validated.pathPrefix,
15310
- includeContent: validated.includeContent,
15311
- earlyExitAfter: earlyExitAllowed ? validated.limit : void 0
15312
- },
15313
- { reader, graph }
15350
+ const sortOrderMatchesScan = !validated.sort || validated.sort.field === "path" && validated.sort.order === "asc";
15351
+ const includes = validated.includePrefixes ?? [void 0];
15352
+ const multiPrefix = includes.length > 1;
15353
+ const earlyExitAfter = sortOrderMatchesScan && !multiPrefix ? validated.limit : void 0;
15354
+ const batches = await Promise.all(
15355
+ includes.map(
15356
+ (prefix) => collectMatchingPaths(
15357
+ {
15358
+ filter: effectiveFilter,
15359
+ pathPrefix: prefix,
15360
+ includeContent: validated.includeContent,
15361
+ earlyExitAfter,
15362
+ excludePathPrefixes: validated.excludePrefixes
15363
+ },
15364
+ { reader, graph }
15365
+ )
15366
+ )
15314
15367
  );
15368
+ const seen = /* @__PURE__ */ new Set();
15369
+ const matched = [];
15370
+ for (const rows of batches) {
15371
+ for (const row of rows) {
15372
+ if (seen.has(row.record.path)) continue;
15373
+ seen.add(row.record.path);
15374
+ matched.push(row);
15375
+ }
15376
+ }
15315
15377
  if (validated.sort) {
15316
15378
  sortInPlace(matched, validated.sort);
15317
15379
  }
@@ -15341,13 +15403,12 @@ function validateInput(input) {
15341
15403
  if (typeof input.filter !== "object" || Array.isArray(input.filter)) {
15342
15404
  throw invalidParams("filter must be a JSON object", "filter");
15343
15405
  }
15344
- let pathPrefix;
15345
- if (input.path_prefix !== void 0) {
15346
- if (typeof input.path_prefix !== "string") {
15347
- throw invalidParams("path_prefix must be a string", "path_prefix");
15348
- }
15349
- pathPrefix = normalizeScanPrefixInput(input.path_prefix);
15350
- }
15406
+ const includePrefixes = normalizePrefixList(input.path_prefix, "path_prefix", "INVALID_PARAMS");
15407
+ const excludePrefixes = normalizePrefixList(
15408
+ input.exclude_path_prefix,
15409
+ "exclude_path_prefix",
15410
+ "INVALID_PARAMS"
15411
+ );
15351
15412
  let sort;
15352
15413
  if (input.sort !== void 0) {
15353
15414
  sort = validateSort(input.sort);
@@ -15368,15 +15429,13 @@ function validateInput(input) {
15368
15429
  }
15369
15430
  return {
15370
15431
  filter: input.filter,
15371
- pathPrefix,
15432
+ includePrefixes,
15433
+ excludePrefixes,
15372
15434
  sort,
15373
15435
  limit,
15374
15436
  includeContent
15375
15437
  };
15376
15438
  }
15377
- function normalizeScanPrefixInput(raw) {
15378
- return normalizeVaultPathPrefix(raw, (message) => invalidParams(message, "path_prefix"));
15379
- }
15380
15439
  function validateSort(raw) {
15381
15440
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
15382
15441
  throw invalidParams("sort must be an object { field, order }", "sort");
@@ -15438,47 +15497,71 @@ function invalidParams(message, field) {
15438
15497
  // src/lib/obsidian/query/list-matching-paths.ts
15439
15498
  function createListMatchingPaths(deps) {
15440
15499
  return async (filter) => {
15441
- const { path_prefix, tags, frontmatter } = filter;
15442
- const hasPathPrefix = path_prefix !== void 0 && path_prefix !== "";
15500
+ const { path_prefix, exclude_path_prefix, tags, frontmatter } = filter;
15501
+ const includePrefixes = normalizePrefixList(path_prefix, "path_prefix", "INVALID_FILTER");
15502
+ const excludePrefixes = normalizePrefixList(
15503
+ exclude_path_prefix,
15504
+ "exclude_path_prefix",
15505
+ "INVALID_FILTER"
15506
+ );
15507
+ const hasInclude = includePrefixes !== void 0;
15508
+ const hasExclude = excludePrefixes !== void 0;
15443
15509
  const hasTags = Array.isArray(tags) && tags.length > 0;
15444
15510
  const hasFrontmatter = frontmatter !== void 0 && Object.keys(frontmatter).length > 0;
15445
- if (!hasPathPrefix && !hasTags && !hasFrontmatter) {
15511
+ if (!hasInclude && !hasExclude && !hasTags && !hasFrontmatter) {
15446
15512
  throw new ToolHandlerError(
15447
15513
  "INVALID_FILTER",
15448
- "filter must specify at least one of: path_prefix, tags, frontmatter"
15514
+ "filter must specify at least one of: path_prefix, exclude_path_prefix, tags, frontmatter"
15449
15515
  );
15450
15516
  }
15451
- const normalizedPrefix = hasPathPrefix ? normalizeVaultPathPrefix(
15452
- path_prefix,
15453
- (message) => new ToolHandlerError("INVALID_FILTER", message)
15454
- ) : void 0;
15455
- if (hasPathPrefix && !hasTags && !hasFrontmatter) {
15517
+ if (!hasTags && !hasFrontmatter) {
15518
+ const collected = /* @__PURE__ */ new Set();
15456
15519
  try {
15457
- const paths = await deps.reader.scan({ pathPrefix: normalizedPrefix });
15458
- return new Set(paths);
15520
+ if (hasInclude) {
15521
+ const scans = await Promise.all(
15522
+ includePrefixes.map((p) => deps.reader.scan({ pathPrefix: p }))
15523
+ );
15524
+ for (const list of scans) for (const p of list) collected.add(p);
15525
+ } else {
15526
+ for (const p of await deps.reader.scan({ pathPrefix: void 0 })) collected.add(p);
15527
+ }
15459
15528
  } catch (err) {
15460
15529
  if (err instanceof ScanPathNotFoundError) {
15461
- throw new ToolHandlerError("PATH_NOT_FOUND", err.message, {
15462
- details: { path_prefix: normalizedPrefix }
15463
- });
15530
+ throw new ToolHandlerError("PATH_NOT_FOUND", err.message);
15464
15531
  }
15465
15532
  throw err;
15466
15533
  }
15534
+ if (hasExclude) {
15535
+ for (const p of [...collected]) {
15536
+ if (matchesAnyPrefix(p, excludePrefixes)) collected.delete(p);
15537
+ }
15538
+ }
15539
+ return collected;
15467
15540
  }
15468
15541
  if (hasFrontmatter) {
15469
15542
  validateFilter(frontmatter);
15470
15543
  }
15471
15544
  const internalFilter = compileFilter({ tags, frontmatter });
15472
15545
  const effectiveFilter = applyDefaultRegexOptions(internalFilter);
15473
- const rows = await collectMatchingPaths(
15474
- {
15475
- filter: effectiveFilter,
15476
- pathPrefix: normalizedPrefix,
15477
- includeContent: false
15478
- },
15479
- deps
15546
+ const scanPrefixes = hasInclude ? [...includePrefixes] : [void 0];
15547
+ const batched = await Promise.all(
15548
+ scanPrefixes.map(
15549
+ (prefix) => collectMatchingPaths(
15550
+ {
15551
+ filter: effectiveFilter,
15552
+ pathPrefix: prefix,
15553
+ includeContent: false,
15554
+ excludePathPrefixes: hasExclude ? excludePrefixes : void 0
15555
+ },
15556
+ deps
15557
+ )
15558
+ )
15480
15559
  );
15481
- return new Set(rows.map((row) => row.record.path));
15560
+ const out = /* @__PURE__ */ new Set();
15561
+ for (const rows of batched) {
15562
+ for (const row of rows) out.add(row.record.path);
15563
+ }
15564
+ return out;
15482
15565
  };
15483
15566
  }
15484
15567
  function compileFilter(parts) {
@@ -15715,20 +15798,73 @@ function buildGetStatsTool(deps) {
15715
15798
  }
15716
15799
 
15717
15800
  // src/lib/fan-out.ts
15801
+ function findFatalRejection(settled) {
15802
+ for (const outcome of settled) {
15803
+ if (outcome.status === "rejected" && isFatalToolError(outcome.reason)) {
15804
+ return outcome.reason;
15805
+ }
15806
+ }
15807
+ return void 0;
15808
+ }
15809
+ function mapRejectionToFailedVault(vault, reason) {
15810
+ if (reason instanceof ToolHandlerError) {
15811
+ return {
15812
+ vault,
15813
+ error: {
15814
+ code: reason.code,
15815
+ message: reason.message,
15816
+ ...reason.details ? { details: reason.details } : {}
15817
+ }
15818
+ };
15819
+ }
15820
+ return {
15821
+ vault,
15822
+ error: {
15823
+ code: "INTERNAL_ERROR",
15824
+ message: reason instanceof Error ? reason.message : String(reason)
15825
+ }
15826
+ };
15827
+ }
15718
15828
  async function runFanOut(registry2, fn) {
15719
15829
  const entries = registry2.list();
15720
- const results = await Promise.all(
15721
- entries.map(async (entry) => ({ vault: entry.name, ...await fn(entry) }))
15722
- );
15723
- return { results_by_vault: results, skipped_vaults: [] };
15830
+ const settled = await Promise.allSettled(entries.map((entry) => fn(entry)));
15831
+ const fatalError = findFatalRejection(settled);
15832
+ if (fatalError) {
15833
+ throw fatalError;
15834
+ }
15835
+ const results = [];
15836
+ const failed = [];
15837
+ for (let i = 0; i < settled.length; i++) {
15838
+ const outcome = settled[i];
15839
+ const vault = entries[i].name;
15840
+ if (outcome.status === "fulfilled") {
15841
+ results.push({ vault, ...outcome.value });
15842
+ } else {
15843
+ failed.push(mapRejectionToFailedVault(vault, outcome.reason));
15844
+ }
15845
+ }
15846
+ return { results_by_vault: results, skipped_vaults: [], failed_vaults: failed };
15724
15847
  }
15725
15848
  async function runSemanticFanOut(registry2, fn) {
15726
15849
  const eligible = registry2.semanticAvailableEntries();
15727
15850
  const skipped = registry2.list().filter((e) => !e.semanticAvailable).map((e) => ({ vault: e.name, reason: "SEMANTIC_INDEX_NOT_FOUND" }));
15728
- const results = await Promise.all(
15729
- eligible.map(async (entry) => ({ vault: entry.name, ...await fn(entry) }))
15730
- );
15731
- return { results_by_vault: results, skipped_vaults: skipped };
15851
+ const settled = await Promise.allSettled(eligible.map((entry) => fn(entry)));
15852
+ const fatalError = findFatalRejection(settled);
15853
+ if (fatalError) {
15854
+ throw fatalError;
15855
+ }
15856
+ const results = [];
15857
+ const failed = [];
15858
+ for (let i = 0; i < settled.length; i++) {
15859
+ const outcome = settled[i];
15860
+ const vault = eligible[i].name;
15861
+ if (outcome.status === "fulfilled") {
15862
+ results.push({ vault, ...outcome.value });
15863
+ } else {
15864
+ failed.push(mapRejectionToFailedVault(vault, outcome.reason));
15865
+ }
15866
+ }
15867
+ return { results_by_vault: results, skipped_vaults: skipped, failed_vaults: failed };
15732
15868
  }
15733
15869
 
15734
15870
  // src/modules/semantic/retrieval-policy.ts
@@ -15950,8 +16086,10 @@ async function executeMultiRetrieval(input) {
15950
16086
  }
15951
16087
 
15952
16088
  // src/modules/semantic/tools/search-notes.ts
16089
+ var prefixSchema = external_exports.union([external_exports.string(), external_exports.array(external_exports.string()).min(1)]);
15953
16090
  var filterSchema = external_exports.object({
15954
- path_prefix: external_exports.string().optional(),
16091
+ path_prefix: prefixSchema.optional(),
16092
+ exclude_path_prefix: prefixSchema.optional(),
15955
16093
  tags: external_exports.array(external_exports.string()).optional(),
15956
16094
  frontmatter: external_exports.record(external_exports.string(), external_exports.unknown()).optional()
15957
16095
  });
@@ -15971,10 +16109,11 @@ function wrapDependencyError2(error48, message, details) {
15971
16109
  return new ToolHandlerError("DEPENDENCY_ERROR", message, { details, cause: error48 });
15972
16110
  }
15973
16111
  function isFilterEmpty(filter) {
15974
- const hasPath = filter.path_prefix !== void 0 && filter.path_prefix !== "";
16112
+ const hasInclude = filter.path_prefix !== void 0 && (Array.isArray(filter.path_prefix) ? filter.path_prefix.length > 0 : filter.path_prefix !== "");
16113
+ const hasExclude = filter.exclude_path_prefix !== void 0 && (Array.isArray(filter.exclude_path_prefix) ? filter.exclude_path_prefix.length > 0 : filter.exclude_path_prefix !== "");
15975
16114
  const hasTags = Array.isArray(filter.tags) && filter.tags.length > 0;
15976
16115
  const hasFm = filter.frontmatter !== void 0 && Object.keys(filter.frontmatter).length > 0;
15977
- return !hasPath && !hasTags && !hasFm;
16116
+ return !hasInclude && !hasExclude && !hasTags && !hasFm;
15978
16117
  }
15979
16118
  function narrowSources(sources, allowed) {
15980
16119
  const out = /* @__PURE__ */ new Map();
@@ -16005,7 +16144,7 @@ async function runSearchForEntry(entry, input, deps) {
16005
16144
  if (isFilterEmpty(input.filter)) {
16006
16145
  throw new ToolHandlerError(
16007
16146
  "INVALID_ARGUMENT",
16008
- "filter must specify at least one of: path_prefix, tags, frontmatter"
16147
+ "filter must specify at least one of: path_prefix, exclude_path_prefix, tags, frontmatter"
16009
16148
  );
16010
16149
  }
16011
16150
  let allowed;
@@ -16145,13 +16284,15 @@ function buildSearchNotesTool(deps) {
16145
16284
  "",
16146
16285
  "PRE-FILTER (filter parameter):",
16147
16286
  "- filter: optional structural narrowing applied BEFORE semantic ranking. Best when vault has many narrative notes that crowd top-K on a niche query.",
16148
- " Shape: { path_prefix?, tags?, frontmatter? }. At least one field required.",
16149
- ' - path_prefix: scope to a folder (e.g. "Resources/").',
16287
+ " Shape: { path_prefix?, exclude_path_prefix?, tags?, frontmatter? }. At least one field required.",
16288
+ ' - path_prefix: scope to a folder, or array of folders for OR-semantics (e.g. ["Tasks/", "Reflections/"]).',
16289
+ ' - exclude_path_prefix: drop notes whose path starts with any of the listed prefixes (e.g. ["Resources/", "Archive/"]). Valid as the sole filter field \u2014 "search the whole vault except these subtrees".',
16150
16290
  ' - tags: notes that have ANY of these tags (OR within the array; no leading "#").',
16151
16291
  ' - frontmatter: sift filter on frontmatter keys (e.g. { type: "reflection", status: "active" }). Same operator allow-list as query_notes.',
16152
- " Composition: filter AND threshold AND semantic. Use this instead of querying twice and intersecting on the client.",
16292
+ " Composition: include \u2192 exclude \u2192 tags \u2192 frontmatter \u2192 threshold \u2192 semantic. Use this instead of querying twice and intersecting on the client.",
16153
16293
  '- scoped recall: search_notes({query: "trading lessons", filter: {tags: ["trading"]}}) \u2014 semantic only inside notes tagged trading.',
16154
- '- scoped multi-query: search_notes({query: ["embeddings","\u0432\u0435\u043A\u0442\u043E\u0440\u043D\u0438\u0439 \u043F\u043E\u0448\u0443\u043A"], filter: {path_prefix: "Resources/"}, mode: "deep"}).',
16294
+ '- carve out noise: search_notes({query: "active thinking", filter: {exclude_path_prefix: ["Resources/", "Archive/"]}, mode: "deep"}).',
16295
+ '- scoped multi-query: search_notes({query: ["embeddings","\u0432\u0435\u043A\u0442\u043E\u0440\u043D\u0438\u0439 \u043F\u043E\u0448\u0443\u043A"], filter: {path_prefix: ["Resources/", "Inbox/"]}, mode: "deep"}).',
16155
16296
  ...registry2.isMulti() ? [
16156
16297
  "",
16157
16298
  "In multi-vault mode, omit `vault:` to fan out across all registered vaults \u2014 the response shape switches to `results_by_vault: [...]` with `skipped_vaults: [...]` (vaults without a semantic index are listed in `skipped_vaults`).",
@@ -16645,12 +16786,14 @@ async function runQueryForEntry(entry, input) {
16645
16786
  }));
16646
16787
  return { results, count: raw.count, truncated: raw.truncated };
16647
16788
  }
16789
+ var queryNotesPrefixSchema = external_exports.union([external_exports.string(), external_exports.array(external_exports.string()).min(1)]);
16648
16790
  function buildQueryNotesTool(deps) {
16649
16791
  const { registry: registry2 } = deps;
16650
16792
  const inputSchema = external_exports.object({
16651
16793
  ...vaultParamShape(registry2),
16652
16794
  filter: external_exports.record(external_exports.string(), external_exports.unknown()),
16653
- path_prefix: external_exports.string().optional(),
16795
+ path_prefix: queryNotesPrefixSchema.optional(),
16796
+ exclude_path_prefix: queryNotesPrefixSchema.optional(),
16654
16797
  sort: queryNotesSortSchema.optional(),
16655
16798
  limit: external_exports.number().int().min(1).max(1e3).optional(),
16656
16799
  include_content: external_exports.boolean().optional()
@@ -16658,7 +16801,7 @@ function buildQueryNotesTool(deps) {
16658
16801
  return {
16659
16802
  name: "query_notes",
16660
16803
  title: "Query Notes",
16661
- description: 'Run a structured MongoDB-style query against the vault\'s frontmatter, tags, and wikilink graph. `filter` is a sift/MongoDB filter object evaluated against `NoteRecord` shape `{ path, frontmatter, tags, backlink_count }` \u2014 `tags` is an array of strings (no leading `#`) extracted from the `tags:` frontmatter field; `backlink_count` is the number of vault-wide wikilinks (and `![[embeds]]`) that point at the note. Reference frontmatter keys with the dotted prefix `frontmatter.<key>`. Supported operators: `$eq`, `$ne`, `$in`, `$nin`, `$gt`, `$gte`, `$lt`, `$lte`, `$exists`, `$regex`, `$options`, `$and`, `$or`, `$nor`, `$not`. `$regex` is case-insensitive by default; pass `$options` (e.g. `\'\'` for case-sensitive, `\'m\'` for multiline-only) to override. Optional `path_prefix` restricts the scan to a vault subtree (vault-relative POSIX, no leading slash). Optional `sort` is `{ field, order }` \u2014 `field` must be `"path"`, `"backlink_count"`, or start with `"frontmatter."`. Optional `limit` defaults to 100, max 1000. Optional `include_content` (default false) \u2014 when true, each result also carries `content` (note body). Returns `{ results, count, truncated }`; each result item carries `vault` and `backlink_count`. `truncated` is true when more notes matched than `limit` allowed. Reads directly from disk and does not require Obsidian to be running.' + describeMultiVault(
16804
+ description: 'Run a structured MongoDB-style query against the vault\'s frontmatter, tags, and wikilink graph. `filter` is a sift/MongoDB filter object evaluated against `NoteRecord` shape `{ path, frontmatter, tags, backlink_count }` \u2014 `tags` is an array of strings (no leading `#`) extracted from the `tags:` frontmatter field; `backlink_count` is the number of vault-wide wikilinks (and `![[embeds]]`) that point at the note. Reference frontmatter keys with the dotted prefix `frontmatter.<key>`. Supported operators: `$eq`, `$ne`, `$in`, `$nin`, `$gt`, `$gte`, `$lt`, `$lte`, `$exists`, `$regex`, `$options`, `$and`, `$or`, `$nor`, `$not`. `$regex` is case-insensitive by default; pass `$options` (e.g. `\'\'` for case-sensitive, `\'m\'` for multiline-only) to override. Optional `path_prefix` restricts the scan to a vault subtree (vault-relative POSIX, no leading slash); pass an array for OR-semantics across multiple subtrees (e.g. `["Tasks/", "Reflections/"]`). Optional `exclude_path_prefix` (string or array) drops notes whose path starts with any listed prefix \u2014 valid as the sole path filter, e.g. `["Resources/", "Archive/"]` to search the whole vault except those subtrees. Optional `sort` is `{ field, order }` \u2014 `field` must be `"path"`, `"backlink_count"`, or start with `"frontmatter."`. Optional `limit` defaults to 100, max 1000. Optional `include_content` (default false) \u2014 when true, each result also carries `content` (note body). Returns `{ results, count, truncated }`; each result item carries `vault` and `backlink_count`. `truncated` is true when more notes matched than `limit` allowed. Reads directly from disk and does not require Obsidian to be running.' + describeMultiVault(
16662
16805
  registry2,
16663
16806
  'In multi-vault mode, omit `vault:` to fan out across all registered vaults \u2014 the response shape switches to `results_by_vault: [...]` with `skipped_vaults: [...]`. Pass `vault: "<name>"` to target a specific vault when multiple are registered.'
16664
16807
  ),
@@ -17473,7 +17616,7 @@ async function buildServerInstructions(registry2) {
17473
17616
 
17474
17617
  ## Multi-vault mode
17475
17618
 
17476
- This server is registered with multiple vaults: ${names}. Every tool accepts an optional \`vault\` parameter. For broad recall, \`search_notes\`, \`query_notes\`, and \`get_vault_overview\` fan out across all vaults when \`vault\` is omitted; other tools (reads of specific paths, writes, single-vault diagnostics) require an explicit \`vault\` \u2014 omitting it returns \`VAULT_REQUIRED\`. Path-shaped parameters (\`path\`, \`paths\`, \`path_prefix\`) remain vault-relative; vault identity is always carried by \`vault\`.`;
17619
+ This server is registered with multiple vaults: ${names}. Every tool accepts an optional \`vault\` parameter. For broad recall, \`search_notes\`, \`query_notes\`, \`get_vault_overview\`, \`list_tags\`, and \`list_properties\` fan out across all vaults when \`vault\` is omitted; other tools (reads of specific paths, writes, single-vault diagnostics) require an explicit \`vault\` \u2014 omitting it returns \`VAULT_REQUIRED\`. Fan-out responses include \`results_by_vault\`, \`skipped_vaults\` (vaults pre-filtered out, e.g. missing semantic index), and \`failed_vaults\` (per-vault runtime errors with \`{ code, message, details? }\`) \u2014 a single failed vault does not abort the whole call. Path-shaped parameters (\`path\`, \`paths\`, \`path_prefix\`) remain vault-relative; vault identity is always carried by \`vault\`.`;
17477
17620
  }
17478
17621
  for (const entry of registry2.list()) {
17479
17622
  const extra = await readExternalAgentInstructions(entry.path);