neuro-vault-mcp 8.0.0 → 8.2.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
@@ -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
 
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
@@ -15202,6 +15202,56 @@ function normalizeVaultPathPrefix(raw, invalid) {
15202
15202
  return slashed.replace(/\/+$/, "");
15203
15203
  }
15204
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
+ function rethrowPathNotFoundWithIndex(err, prefix, index, total) {
15237
+ const isRaw = err instanceof ScanPathNotFoundError;
15238
+ const isWrapped = err instanceof ToolHandlerError && err.code === "PATH_NOT_FOUND";
15239
+ if (!isRaw && !isWrapped) {
15240
+ throw err;
15241
+ }
15242
+ if (total <= 1) {
15243
+ if (isRaw) {
15244
+ throw new ToolHandlerError("PATH_NOT_FOUND", err.message);
15245
+ }
15246
+ throw err;
15247
+ }
15248
+ throw new ToolHandlerError(
15249
+ "PATH_NOT_FOUND",
15250
+ `path_prefix[${index}] not found: ${JSON.stringify(prefix)}`,
15251
+ { details: { path_prefix: prefix, index } }
15252
+ );
15253
+ }
15254
+
15205
15255
  // src/lib/obsidian/query/whitelist.ts
15206
15256
  var ALLOWED_OPERATORS = /* @__PURE__ */ new Set([
15207
15257
  "$eq",
@@ -15289,6 +15339,9 @@ async function collectMatchingPaths(input, deps) {
15289
15339
  }
15290
15340
  continue;
15291
15341
  }
15342
+ if (input.excludePathPrefixes !== void 0 && matchesAnyPrefix(item.path, input.excludePathPrefixes)) {
15343
+ continue;
15344
+ }
15292
15345
  const backlinkCount = graph?.getBacklinkCount(item.path) ?? 0;
15293
15346
  const record2 = toNoteRecord(item, backlinkCount);
15294
15347
  let isMatch;
@@ -15312,16 +15365,37 @@ async function runQueryNotes(input, reader, graph) {
15312
15365
  if (graph) {
15313
15366
  await graph.ensureFresh();
15314
15367
  }
15315
- const earlyExitAllowed = !validated.sort || validated.sort.field === "path" && validated.sort.order === "asc";
15316
- const matched = await collectMatchingPaths(
15317
- {
15318
- filter: effectiveFilter,
15319
- pathPrefix: validated.pathPrefix,
15320
- includeContent: validated.includeContent,
15321
- earlyExitAfter: earlyExitAllowed ? validated.limit : void 0
15322
- },
15323
- { reader, graph }
15368
+ const sortOrderMatchesScan = !validated.sort || validated.sort.field === "path" && validated.sort.order === "asc";
15369
+ const includes = validated.includePrefixes ?? [void 0];
15370
+ const multiPrefix = includes.length > 1;
15371
+ const earlyExitAfter = sortOrderMatchesScan && !multiPrefix ? validated.limit : void 0;
15372
+ const totalIncludes = includes.length;
15373
+ const batches = await Promise.all(
15374
+ includes.map((prefix, i) => {
15375
+ const promise2 = collectMatchingPaths(
15376
+ {
15377
+ filter: effectiveFilter,
15378
+ pathPrefix: prefix,
15379
+ includeContent: validated.includeContent,
15380
+ earlyExitAfter,
15381
+ excludePathPrefixes: validated.excludePrefixes
15382
+ },
15383
+ { reader, graph }
15384
+ );
15385
+ return prefix === void 0 ? promise2 : promise2.catch(
15386
+ (err) => rethrowPathNotFoundWithIndex(err, prefix, i, totalIncludes)
15387
+ );
15388
+ })
15324
15389
  );
15390
+ const seen = /* @__PURE__ */ new Set();
15391
+ const matched = [];
15392
+ for (const rows of batches) {
15393
+ for (const row of rows) {
15394
+ if (seen.has(row.record.path)) continue;
15395
+ seen.add(row.record.path);
15396
+ matched.push(row);
15397
+ }
15398
+ }
15325
15399
  if (validated.sort) {
15326
15400
  sortInPlace(matched, validated.sort);
15327
15401
  }
@@ -15351,13 +15425,12 @@ function validateInput(input) {
15351
15425
  if (typeof input.filter !== "object" || Array.isArray(input.filter)) {
15352
15426
  throw invalidParams("filter must be a JSON object", "filter");
15353
15427
  }
15354
- let pathPrefix;
15355
- if (input.path_prefix !== void 0) {
15356
- if (typeof input.path_prefix !== "string") {
15357
- throw invalidParams("path_prefix must be a string", "path_prefix");
15358
- }
15359
- pathPrefix = normalizeScanPrefixInput(input.path_prefix);
15360
- }
15428
+ const includePrefixes = normalizePrefixList(input.path_prefix, "path_prefix", "INVALID_PARAMS");
15429
+ const excludePrefixes = normalizePrefixList(
15430
+ input.exclude_path_prefix,
15431
+ "exclude_path_prefix",
15432
+ "INVALID_PARAMS"
15433
+ );
15361
15434
  let sort;
15362
15435
  if (input.sort !== void 0) {
15363
15436
  sort = validateSort(input.sort);
@@ -15378,15 +15451,13 @@ function validateInput(input) {
15378
15451
  }
15379
15452
  return {
15380
15453
  filter: input.filter,
15381
- pathPrefix,
15454
+ includePrefixes,
15455
+ excludePrefixes,
15382
15456
  sort,
15383
15457
  limit,
15384
15458
  includeContent
15385
15459
  };
15386
15460
  }
15387
- function normalizeScanPrefixInput(raw) {
15388
- return normalizeVaultPathPrefix(raw, (message) => invalidParams(message, "path_prefix"));
15389
- }
15390
15461
  function validateSort(raw) {
15391
15462
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
15392
15463
  throw invalidParams("sort must be an object { field, order }", "sort");
@@ -15448,47 +15519,71 @@ function invalidParams(message, field) {
15448
15519
  // src/lib/obsidian/query/list-matching-paths.ts
15449
15520
  function createListMatchingPaths(deps) {
15450
15521
  return async (filter) => {
15451
- const { path_prefix, tags, frontmatter } = filter;
15452
- const hasPathPrefix = path_prefix !== void 0 && path_prefix !== "";
15522
+ const { path_prefix, exclude_path_prefix, tags, frontmatter } = filter;
15523
+ const includePrefixes = normalizePrefixList(path_prefix, "path_prefix", "INVALID_FILTER");
15524
+ const excludePrefixes = normalizePrefixList(
15525
+ exclude_path_prefix,
15526
+ "exclude_path_prefix",
15527
+ "INVALID_FILTER"
15528
+ );
15529
+ const hasInclude = includePrefixes !== void 0;
15530
+ const hasExclude = excludePrefixes !== void 0;
15453
15531
  const hasTags = Array.isArray(tags) && tags.length > 0;
15454
15532
  const hasFrontmatter = frontmatter !== void 0 && Object.keys(frontmatter).length > 0;
15455
- if (!hasPathPrefix && !hasTags && !hasFrontmatter) {
15533
+ if (!hasInclude && !hasExclude && !hasTags && !hasFrontmatter) {
15456
15534
  throw new ToolHandlerError(
15457
15535
  "INVALID_FILTER",
15458
- "filter must specify at least one of: path_prefix, tags, frontmatter"
15536
+ "filter must specify at least one of: path_prefix, exclude_path_prefix, tags, frontmatter"
15459
15537
  );
15460
15538
  }
15461
- const normalizedPrefix = hasPathPrefix ? normalizeVaultPathPrefix(
15462
- path_prefix,
15463
- (message) => new ToolHandlerError("INVALID_FILTER", message)
15464
- ) : void 0;
15465
- if (hasPathPrefix && !hasTags && !hasFrontmatter) {
15466
- try {
15467
- const paths = await deps.reader.scan({ pathPrefix: normalizedPrefix });
15468
- return new Set(paths);
15469
- } catch (err) {
15470
- if (err instanceof ScanPathNotFoundError) {
15471
- throw new ToolHandlerError("PATH_NOT_FOUND", err.message, {
15472
- details: { path_prefix: normalizedPrefix }
15473
- });
15539
+ if (!hasTags && !hasFrontmatter) {
15540
+ const collected = /* @__PURE__ */ new Set();
15541
+ if (hasInclude) {
15542
+ const total = includePrefixes.length;
15543
+ const scans = await Promise.all(
15544
+ includePrefixes.map(
15545
+ (p, i) => deps.reader.scan({ pathPrefix: p }).catch((err) => rethrowPathNotFoundWithIndex(err, p, i, total))
15546
+ )
15547
+ );
15548
+ for (const list of scans) for (const p of list) collected.add(p);
15549
+ } else {
15550
+ for (const p of await deps.reader.scan({ pathPrefix: void 0 })) collected.add(p);
15551
+ }
15552
+ if (hasExclude) {
15553
+ for (const p of [...collected]) {
15554
+ if (matchesAnyPrefix(p, excludePrefixes)) collected.delete(p);
15474
15555
  }
15475
- throw err;
15476
15556
  }
15557
+ return collected;
15477
15558
  }
15478
15559
  if (hasFrontmatter) {
15479
15560
  validateFilter(frontmatter);
15480
15561
  }
15481
15562
  const internalFilter = compileFilter({ tags, frontmatter });
15482
15563
  const effectiveFilter = applyDefaultRegexOptions(internalFilter);
15483
- const rows = await collectMatchingPaths(
15484
- {
15485
- filter: effectiveFilter,
15486
- pathPrefix: normalizedPrefix,
15487
- includeContent: false
15488
- },
15489
- deps
15564
+ const scanPrefixes = hasInclude ? [...includePrefixes] : [void 0];
15565
+ const totalIncludes = scanPrefixes.length;
15566
+ const batched = await Promise.all(
15567
+ scanPrefixes.map((prefix, i) => {
15568
+ const promise2 = collectMatchingPaths(
15569
+ {
15570
+ filter: effectiveFilter,
15571
+ pathPrefix: prefix,
15572
+ includeContent: false,
15573
+ excludePathPrefixes: hasExclude ? excludePrefixes : void 0
15574
+ },
15575
+ deps
15576
+ );
15577
+ return prefix === void 0 ? promise2 : promise2.catch(
15578
+ (err) => rethrowPathNotFoundWithIndex(err, prefix, i, totalIncludes)
15579
+ );
15580
+ })
15490
15581
  );
15491
- return new Set(rows.map((row) => row.record.path));
15582
+ const out = /* @__PURE__ */ new Set();
15583
+ for (const rows of batched) {
15584
+ for (const row of rows) out.add(row.record.path);
15585
+ }
15586
+ return out;
15492
15587
  };
15493
15588
  }
15494
15589
  function compileFilter(parts) {
@@ -16013,8 +16108,10 @@ async function executeMultiRetrieval(input) {
16013
16108
  }
16014
16109
 
16015
16110
  // src/modules/semantic/tools/search-notes.ts
16111
+ var prefixSchema = external_exports.union([external_exports.string(), external_exports.array(external_exports.string()).min(1)]);
16016
16112
  var filterSchema = external_exports.object({
16017
- path_prefix: external_exports.string().optional(),
16113
+ path_prefix: prefixSchema.optional(),
16114
+ exclude_path_prefix: prefixSchema.optional(),
16018
16115
  tags: external_exports.array(external_exports.string()).optional(),
16019
16116
  frontmatter: external_exports.record(external_exports.string(), external_exports.unknown()).optional()
16020
16117
  });
@@ -16034,10 +16131,11 @@ function wrapDependencyError2(error48, message, details) {
16034
16131
  return new ToolHandlerError("DEPENDENCY_ERROR", message, { details, cause: error48 });
16035
16132
  }
16036
16133
  function isFilterEmpty(filter) {
16037
- const hasPath = filter.path_prefix !== void 0 && filter.path_prefix !== "";
16134
+ const hasInclude = filter.path_prefix !== void 0 && (Array.isArray(filter.path_prefix) ? filter.path_prefix.length > 0 : filter.path_prefix !== "");
16135
+ const hasExclude = filter.exclude_path_prefix !== void 0 && (Array.isArray(filter.exclude_path_prefix) ? filter.exclude_path_prefix.length > 0 : filter.exclude_path_prefix !== "");
16038
16136
  const hasTags = Array.isArray(filter.tags) && filter.tags.length > 0;
16039
16137
  const hasFm = filter.frontmatter !== void 0 && Object.keys(filter.frontmatter).length > 0;
16040
- return !hasPath && !hasTags && !hasFm;
16138
+ return !hasInclude && !hasExclude && !hasTags && !hasFm;
16041
16139
  }
16042
16140
  function narrowSources(sources, allowed) {
16043
16141
  const out = /* @__PURE__ */ new Map();
@@ -16068,7 +16166,7 @@ async function runSearchForEntry(entry, input, deps) {
16068
16166
  if (isFilterEmpty(input.filter)) {
16069
16167
  throw new ToolHandlerError(
16070
16168
  "INVALID_ARGUMENT",
16071
- "filter must specify at least one of: path_prefix, tags, frontmatter"
16169
+ "filter must specify at least one of: path_prefix, exclude_path_prefix, tags, frontmatter"
16072
16170
  );
16073
16171
  }
16074
16172
  let allowed;
@@ -16208,13 +16306,15 @@ function buildSearchNotesTool(deps) {
16208
16306
  "",
16209
16307
  "PRE-FILTER (filter parameter):",
16210
16308
  "- filter: optional structural narrowing applied BEFORE semantic ranking. Best when vault has many narrative notes that crowd top-K on a niche query.",
16211
- " Shape: { path_prefix?, tags?, frontmatter? }. At least one field required.",
16212
- ' - path_prefix: scope to a folder (e.g. "Resources/").',
16309
+ " Shape: { path_prefix?, exclude_path_prefix?, tags?, frontmatter? }. At least one field required.",
16310
+ ' - path_prefix: scope to a folder, or array of folders for OR-semantics (e.g. ["Tasks/", "Reflections/"]).',
16311
+ ' - 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".',
16213
16312
  ' - tags: notes that have ANY of these tags (OR within the array; no leading "#").',
16214
16313
  ' - frontmatter: sift filter on frontmatter keys (e.g. { type: "reflection", status: "active" }). Same operator allow-list as query_notes.',
16215
- " Composition: filter AND threshold AND semantic. Use this instead of querying twice and intersecting on the client.",
16314
+ " Composition: include \u2192 exclude \u2192 tags \u2192 frontmatter \u2192 threshold \u2192 semantic. Use this instead of querying twice and intersecting on the client.",
16216
16315
  '- scoped recall: search_notes({query: "trading lessons", filter: {tags: ["trading"]}}) \u2014 semantic only inside notes tagged trading.',
16217
- '- 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"}).',
16316
+ '- carve out noise: search_notes({query: "active thinking", filter: {exclude_path_prefix: ["Resources/", "Archive/"]}, mode: "deep"}).',
16317
+ '- 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"}).',
16218
16318
  ...registry2.isMulti() ? [
16219
16319
  "",
16220
16320
  "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`).",
@@ -16708,12 +16808,14 @@ async function runQueryForEntry(entry, input) {
16708
16808
  }));
16709
16809
  return { results, count: raw.count, truncated: raw.truncated };
16710
16810
  }
16811
+ var queryNotesPrefixSchema = external_exports.union([external_exports.string(), external_exports.array(external_exports.string()).min(1)]);
16711
16812
  function buildQueryNotesTool(deps) {
16712
16813
  const { registry: registry2 } = deps;
16713
16814
  const inputSchema = external_exports.object({
16714
16815
  ...vaultParamShape(registry2),
16715
16816
  filter: external_exports.record(external_exports.string(), external_exports.unknown()),
16716
- path_prefix: external_exports.string().optional(),
16817
+ path_prefix: queryNotesPrefixSchema.optional(),
16818
+ exclude_path_prefix: queryNotesPrefixSchema.optional(),
16717
16819
  sort: queryNotesSortSchema.optional(),
16718
16820
  limit: external_exports.number().int().min(1).max(1e3).optional(),
16719
16821
  include_content: external_exports.boolean().optional()
@@ -16721,7 +16823,7 @@ function buildQueryNotesTool(deps) {
16721
16823
  return {
16722
16824
  name: "query_notes",
16723
16825
  title: "Query Notes",
16724
- 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(
16826
+ 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(
16725
16827
  registry2,
16726
16828
  '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.'
16727
16829
  ),