neuro-vault-mcp 5.0.0 → 5.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
@@ -62,6 +62,18 @@ One question, one answer. Your assistant stops being a file browser and starts b
62
62
 
63
63
  ---
64
64
 
65
+ ### 🔍 Hybrid search: scope semantic with structural filters
66
+
67
+ `search_notes` accepts an optional `filter` to narrow the candidate set **before** semantic ranking — combining the precision of `query_notes` with the recall of vector search. Useful when domain-relevant notes are crowded out by larger narrative clusters.
68
+
69
+ ```json
70
+ { "query": "trading lessons", "filter": { "tags": ["trading"] } }
71
+ ```
72
+
73
+ `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.
74
+
75
+ ---
76
+
65
77
  ## 🏗 How it works
66
78
 
67
79
  ```mermaid
package/dist/cli.d.ts CHANGED
@@ -75,6 +75,13 @@ declare class WikilinkGraphIndex {
75
75
  private rebuild;
76
76
  }
77
77
 
78
+ interface NoteFilter {
79
+ path_prefix?: string;
80
+ tags?: string[];
81
+ frontmatter?: Record<string, unknown>;
82
+ }
83
+ type ListMatchingPaths = (filter: NoteFilter) => Promise<Set<string>>;
84
+
78
85
  interface BlockSearchResult {
79
86
  path: string;
80
87
  heading: string;
@@ -126,6 +133,7 @@ interface SemanticModuleDeps {
126
133
  vaultReaderFactory?: (opts: {
127
134
  vaultRoot: string;
128
135
  }) => VaultReader;
136
+ listMatchingPaths?: ListMatchingPaths;
129
137
  }
130
138
 
131
139
  type NoteIdentifier = {
package/dist/cli.js CHANGED
@@ -1022,6 +1022,21 @@ function isPlainObject(value) {
1022
1022
  return typeof value === "object" && value !== null && !Array.isArray(value);
1023
1023
  }
1024
1024
 
1025
+ // src/lib/obsidian/query/path-prefix.ts
1026
+ var WINDOWS_ABSOLUTE_PATH_RE2 = /^[A-Za-z]:[\\/]/;
1027
+ function normalizeVaultPathPrefix(raw, invalid) {
1028
+ const trimmed = raw.trim();
1029
+ if (trimmed === "" || trimmed === "." || trimmed === "./") return void 0;
1030
+ if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_PATH_RE2.test(trimmed)) {
1031
+ throw invalid("path_prefix must be vault-relative");
1032
+ }
1033
+ const slashed = trimmed.replace(/\\/g, "/").replace(/^\.\//, "");
1034
+ if (slashed.split("/").some((segment) => segment === "..")) {
1035
+ throw invalid("path_prefix must be vault-relative");
1036
+ }
1037
+ return slashed.replace(/\/+$/, "");
1038
+ }
1039
+
1025
1040
  // src/lib/obsidian/query/whitelist.ts
1026
1041
  var ALLOWED_OPERATORS = /* @__PURE__ */ new Set([
1027
1042
  "$eq",
@@ -1071,38 +1086,32 @@ function invalidFilter(message) {
1071
1086
  var sift = typeof siftModule === "function" ? siftModule : siftModule.default;
1072
1087
  var DEFAULT_LIMIT = 100;
1073
1088
  var HARD_LIMIT_CAP = 1e3;
1074
- var WINDOWS_ABSOLUTE_PATH_RE2 = /^[A-Za-z]:[\\/]/;
1075
1089
  var READ_BATCH_SIZE2 = 32;
1076
- async function runQueryNotes(input, reader, graph) {
1077
- const validated = validateInput(input);
1078
- validateFilter(validated.filter);
1079
- const effectiveFilter = applyDefaultRegexOptions(validated.filter);
1080
- if (graph) {
1081
- await graph.ensureFresh();
1082
- }
1090
+ async function collectMatchingPaths(input, deps) {
1091
+ const { filter, pathPrefix, includeContent, earlyExitAfter } = input;
1092
+ const { reader, graph } = deps;
1083
1093
  let paths;
1084
1094
  try {
1085
- paths = await reader.scan({ pathPrefix: validated.pathPrefix });
1095
+ paths = await reader.scan({ pathPrefix });
1086
1096
  } catch (err) {
1087
1097
  if (err instanceof ScanPathNotFoundError) {
1088
1098
  throw new ToolHandlerError("PATH_NOT_FOUND", err.message, {
1089
- details: { path_prefix: validated.pathPrefix }
1099
+ details: { path_prefix: pathPrefix }
1090
1100
  });
1091
1101
  }
1092
1102
  throw err;
1093
1103
  }
1094
1104
  if (paths.length === 0) {
1095
- return { results: [], count: 0, truncated: false };
1105
+ return [];
1096
1106
  }
1097
1107
  let matcher;
1098
1108
  try {
1099
- matcher = sift(effectiveFilter);
1109
+ matcher = sift(filter);
1100
1110
  } catch (err) {
1101
1111
  const message = err instanceof Error ? err.message : String(err);
1102
1112
  throw new ToolHandlerError("INVALID_FILTER", `filter rejected by sift: ${message}`);
1103
1113
  }
1104
- const fields = validated.includeContent ? ["frontmatter", "content"] : ["frontmatter"];
1105
- const earlyExitAllowed = !validated.sort || validated.sort.field === "path" && validated.sort.order === "asc";
1114
+ const fields = includeContent ? ["frontmatter", "content"] : ["frontmatter"];
1106
1115
  const matched = [];
1107
1116
  for (let i = 0; i < paths.length; i += READ_BATCH_SIZE2) {
1108
1117
  const slice = paths.slice(i, i + READ_BATCH_SIZE2);
@@ -1125,10 +1134,29 @@ async function runQueryNotes(input, reader, graph) {
1125
1134
  throw new ToolHandlerError("INVALID_FILTER", `filter execution failed: ${message}`);
1126
1135
  }
1127
1136
  if (!isMatch) continue;
1128
- matched.push(validated.includeContent ? { record: record2, content: item.content } : { record: record2 });
1137
+ matched.push(includeContent ? { record: record2, content: item.content } : { record: record2 });
1129
1138
  }
1130
- if (earlyExitAllowed && matched.length > validated.limit) break;
1139
+ if (earlyExitAfter !== void 0 && matched.length > earlyExitAfter) break;
1140
+ }
1141
+ return matched;
1142
+ }
1143
+ async function runQueryNotes(input, reader, graph) {
1144
+ const validated = validateInput(input);
1145
+ validateFilter(validated.filter);
1146
+ const effectiveFilter = applyDefaultRegexOptions(validated.filter);
1147
+ if (graph) {
1148
+ await graph.ensureFresh();
1131
1149
  }
1150
+ const earlyExitAllowed = !validated.sort || validated.sort.field === "path" && validated.sort.order === "asc";
1151
+ const matched = await collectMatchingPaths(
1152
+ {
1153
+ filter: effectiveFilter,
1154
+ pathPrefix: validated.pathPrefix,
1155
+ includeContent: validated.includeContent,
1156
+ earlyExitAfter: earlyExitAllowed ? validated.limit : void 0
1157
+ },
1158
+ { reader, graph }
1159
+ );
1132
1160
  if (validated.sort) {
1133
1161
  sortInPlace(matched, validated.sort);
1134
1162
  }
@@ -1192,16 +1220,7 @@ function validateInput(input) {
1192
1220
  };
1193
1221
  }
1194
1222
  function normalizeScanPrefixInput(raw) {
1195
- const trimmed = raw.trim();
1196
- if (trimmed === "" || trimmed === "." || trimmed === "./") return void 0;
1197
- if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_PATH_RE2.test(trimmed)) {
1198
- throw invalidParams("path_prefix must be vault-relative", "path_prefix");
1199
- }
1200
- const slashed = trimmed.replace(/\\/g, "/").replace(/^\.\//, "");
1201
- if (slashed.split("/").some((segment) => segment === "..")) {
1202
- throw invalidParams("path_prefix must be vault-relative", "path_prefix");
1203
- }
1204
- return slashed.replace(/\/+$/, "");
1223
+ return normalizeVaultPathPrefix(raw, (message) => invalidParams(message, "path_prefix"));
1205
1224
  }
1206
1225
  function validateSort(raw) {
1207
1226
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
@@ -1261,6 +1280,66 @@ function invalidParams(message, field) {
1261
1280
  return new ToolHandlerError("INVALID_PARAMS", message, { details: { field } });
1262
1281
  }
1263
1282
 
1283
+ // src/lib/obsidian/query/list-matching-paths.ts
1284
+ function createListMatchingPaths(deps) {
1285
+ return async (filter) => {
1286
+ const { path_prefix, tags, frontmatter } = filter;
1287
+ const hasPathPrefix = path_prefix !== void 0 && path_prefix !== "";
1288
+ const hasTags = Array.isArray(tags) && tags.length > 0;
1289
+ const hasFrontmatter = frontmatter !== void 0 && Object.keys(frontmatter).length > 0;
1290
+ if (!hasPathPrefix && !hasTags && !hasFrontmatter) {
1291
+ throw new ToolHandlerError(
1292
+ "INVALID_FILTER",
1293
+ "filter must specify at least one of: path_prefix, tags, frontmatter"
1294
+ );
1295
+ }
1296
+ const normalizedPrefix = hasPathPrefix ? normalizeVaultPathPrefix(
1297
+ path_prefix,
1298
+ (message) => new ToolHandlerError("INVALID_FILTER", message)
1299
+ ) : void 0;
1300
+ if (hasPathPrefix && !hasTags && !hasFrontmatter) {
1301
+ try {
1302
+ const paths = await deps.reader.scan({ pathPrefix: normalizedPrefix });
1303
+ return new Set(paths);
1304
+ } catch (err) {
1305
+ if (err instanceof ScanPathNotFoundError) {
1306
+ throw new ToolHandlerError("PATH_NOT_FOUND", err.message, {
1307
+ details: { path_prefix: normalizedPrefix }
1308
+ });
1309
+ }
1310
+ throw err;
1311
+ }
1312
+ }
1313
+ if (hasFrontmatter) {
1314
+ validateFilter(frontmatter);
1315
+ }
1316
+ const internalFilter = compileFilter({ tags, frontmatter });
1317
+ const effectiveFilter = applyDefaultRegexOptions(internalFilter);
1318
+ const rows = await collectMatchingPaths(
1319
+ {
1320
+ filter: effectiveFilter,
1321
+ pathPrefix: normalizedPrefix,
1322
+ includeContent: false
1323
+ },
1324
+ deps
1325
+ );
1326
+ return new Set(rows.map((row) => row.record.path));
1327
+ };
1328
+ }
1329
+ function compileFilter(parts) {
1330
+ const clauses = [];
1331
+ if (parts.tags && parts.tags.length > 0) {
1332
+ clauses.push({ tags: { $in: parts.tags } });
1333
+ }
1334
+ if (parts.frontmatter && Object.keys(parts.frontmatter).length > 0) {
1335
+ for (const [key, value] of Object.entries(parts.frontmatter)) {
1336
+ clauses.push({ [`frontmatter.${key}`]: value });
1337
+ }
1338
+ }
1339
+ if (clauses.length === 1) return clauses[0];
1340
+ return { $and: clauses };
1341
+ }
1342
+
1264
1343
  // node_modules/zod/v4/classic/external.js
1265
1344
  var external_exports = {};
1266
1345
  __export(external_exports, {
@@ -15752,13 +15831,29 @@ var SEARCH_NOTES_DESCRIPTION = [
15752
15831
  '- "where did I write about X?" \u2192 search_notes({query: "X"}) \u2014 quick.',
15753
15832
  '- "what do I know about Y?" \u2192 search_notes({query: "Y", mode: "deep"}).',
15754
15833
  '- multilingual pair: search_notes({query: ["embeddings", "\u0432\u0435\u043A\u0442\u043E\u0440\u043D\u0438\u0439 \u043F\u043E\u0448\u0443\u043A"]}) \u2014 returns one merged list; notes matched by both queries appear with both in matched_queries.',
15755
- '- multilingual deep: search_notes({query: ["optimization", "\u043E\u043F\u0442\u0438\u043C\u0456\u0437\u0430\u0446\u0456\u044F"], mode: "deep"}) \u2014 merged top-`limit` seeds, then expansion once on the merged set.'
15834
+ '- multilingual deep: search_notes({query: ["optimization", "\u043E\u043F\u0442\u0438\u043C\u0456\u0437\u0430\u0446\u0456\u044F"], mode: "deep"}) \u2014 merged top-`limit` seeds, then expansion once on the merged set.',
15835
+ "",
15836
+ "PRE-FILTER (filter parameter):",
15837
+ "- filter: optional structural narrowing applied BEFORE semantic ranking. Best when vault has many narrative notes that crowd top-K on a niche query.",
15838
+ " Shape: { path_prefix?, tags?, frontmatter? }. At least one field required.",
15839
+ ' - path_prefix: scope to a folder (e.g. "Resources/").',
15840
+ ' - tags: notes that have ANY of these tags (OR within the array; no leading "#").',
15841
+ ' - frontmatter: sift filter on frontmatter keys (e.g. { type: "reflection", status: "active" }). Same operator allow-list as query_notes.',
15842
+ " Composition: filter AND threshold AND semantic. Use this instead of querying twice and intersecting on the client.",
15843
+ '- scoped recall: search_notes({query: "trading lessons", filter: {tags: ["trading"]}}) \u2014 semantic only inside notes tagged trading.',
15844
+ '- 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"}).'
15756
15845
  ].join("\n");
15846
+ var filterSchema = external_exports.object({
15847
+ path_prefix: external_exports.string().optional(),
15848
+ tags: external_exports.array(external_exports.string()).optional(),
15849
+ frontmatter: external_exports.record(external_exports.string(), external_exports.unknown()).optional()
15850
+ });
15757
15851
  var inputSchema4 = external_exports.object({
15758
15852
  query: external_exports.union([external_exports.string(), external_exports.array(external_exports.string()).min(1).max(8)]),
15759
15853
  mode: external_exports.enum(["quick", "deep"]).optional(),
15760
15854
  limit: external_exports.number().int().positive().optional(),
15761
- threshold: external_exports.number().min(0).max(1).optional()
15855
+ threshold: external_exports.number().min(0).max(1).optional(),
15856
+ filter: filterSchema.optional()
15762
15857
  });
15763
15858
  async function buildExistingPathSet2(paths, pathExists) {
15764
15859
  const unique = new Set(paths);
@@ -15773,8 +15868,30 @@ function wrapDependencyError2(error48, message, details) {
15773
15868
  }
15774
15869
  return new ToolHandlerError("DEPENDENCY_ERROR", message, { details, cause: error48 });
15775
15870
  }
15871
+ function isFilterEmpty(filter) {
15872
+ const hasPath = filter.path_prefix !== void 0 && filter.path_prefix !== "";
15873
+ const hasTags = Array.isArray(filter.tags) && filter.tags.length > 0;
15874
+ const hasFm = filter.frontmatter !== void 0 && Object.keys(filter.frontmatter).length > 0;
15875
+ return !hasPath && !hasTags && !hasFm;
15876
+ }
15877
+ function narrowSources(sources, allowed) {
15878
+ const out = /* @__PURE__ */ new Map();
15879
+ for (const path7 of allowed) {
15880
+ const src = sources.get(path7);
15881
+ if (src) out.set(path7, src);
15882
+ }
15883
+ return out;
15884
+ }
15776
15885
  function buildSearchNotesTool(deps) {
15777
- const { sources, embeddingProvider, searchEngine, modelKey, pathExists, graph } = deps;
15886
+ const {
15887
+ sources,
15888
+ embeddingProvider,
15889
+ searchEngine,
15890
+ modelKey,
15891
+ pathExists,
15892
+ graph,
15893
+ listMatchingPaths
15894
+ } = deps;
15778
15895
  return {
15779
15896
  name: "search_notes",
15780
15897
  title: "Search Notes",
@@ -15784,6 +15901,39 @@ function buildSearchNotesTool(deps) {
15784
15901
  const mode = input.mode ?? "quick";
15785
15902
  const threshold = input.threshold !== void 0 ? readThreshold(input.threshold, input.threshold, "threshold") : void 0;
15786
15903
  const limit = input.limit !== void 0 ? readPositiveInteger(input.limit, input.limit, "limit") : void 0;
15904
+ let effectiveSources = sources;
15905
+ if (input.filter !== void 0) {
15906
+ if (isFilterEmpty(input.filter)) {
15907
+ throw new ToolHandlerError(
15908
+ "INVALID_ARGUMENT",
15909
+ "filter must specify at least one of: path_prefix, tags, frontmatter"
15910
+ );
15911
+ }
15912
+ let allowed;
15913
+ try {
15914
+ allowed = await listMatchingPaths(input.filter);
15915
+ } catch (error48) {
15916
+ if (error48 instanceof ToolHandlerError && error48.code === "INVALID_FILTER") {
15917
+ throw new ToolHandlerError("INVALID_ARGUMENT", error48.message, {
15918
+ details: error48.details
15919
+ });
15920
+ }
15921
+ throw wrapDependencyError2(error48, "Failed to compute filter set", {
15922
+ modelKey,
15923
+ operation: "search_notes"
15924
+ });
15925
+ }
15926
+ if (allowed.size === 0) {
15927
+ const isMulti = Array.isArray(input.query);
15928
+ const isDeep = mode === "deep";
15929
+ return {
15930
+ results: [],
15931
+ ...isDeep ? { blockResults: [] } : {},
15932
+ ...isMulti ? { truncated: false } : {}
15933
+ };
15934
+ }
15935
+ effectiveSources = narrowSources(sources, allowed);
15936
+ }
15787
15937
  if (Array.isArray(input.query)) {
15788
15938
  const queries = normalizeQueryArray(input.query);
15789
15939
  try {
@@ -15792,7 +15942,7 @@ function buildSearchNotesTool(deps) {
15792
15942
  mode,
15793
15943
  threshold,
15794
15944
  limit,
15795
- sources,
15945
+ sources: effectiveSources,
15796
15946
  embeddingProvider,
15797
15947
  searchEngine
15798
15948
  });
@@ -15823,7 +15973,7 @@ function buildSearchNotesTool(deps) {
15823
15973
  mode,
15824
15974
  limit,
15825
15975
  threshold,
15826
- sources,
15976
+ sources: effectiveSources,
15827
15977
  embeddingProvider,
15828
15978
  searchEngine
15829
15979
  });
@@ -15886,10 +16036,12 @@ async function createSemanticModule(config2, deps = {}) {
15886
16036
  const readNoteContent = deps.readNoteContent ?? createDefaultReadNoteContent(config2.vaultPath);
15887
16037
  const basenameIndex = buildBasenameIndex(corpus.sources.keys());
15888
16038
  let graph = deps.graph;
15889
- if (!graph) {
16039
+ let listMatchingPaths = deps.listMatchingPaths;
16040
+ if (!graph || !listMatchingPaths) {
15890
16041
  const readerFactory = deps.vaultReaderFactory ?? ((opts) => new FsVaultReader({ vaultRoot: opts.vaultRoot }));
15891
16042
  const reader = readerFactory({ vaultRoot: config2.vaultPath });
15892
- graph = new WikilinkGraphIndex({ reader });
16043
+ if (!graph) graph = new WikilinkGraphIndex({ reader });
16044
+ if (!listMatchingPaths) listMatchingPaths = createListMatchingPaths({ reader, graph });
15893
16045
  }
15894
16046
  const semanticDeps = {
15895
16047
  sources: corpus.sources,
@@ -15899,7 +16051,8 @@ async function createSemanticModule(config2, deps = {}) {
15899
16051
  pathExists,
15900
16052
  basenameIndex,
15901
16053
  readNoteContent,
15902
- graph
16054
+ graph,
16055
+ listMatchingPaths
15903
16056
  };
15904
16057
  return {
15905
16058
  tools: buildSemanticTools(semanticDeps),
@@ -16311,7 +16464,7 @@ async function resolveToPath(input, reader) {
16311
16464
  if (matches.length > 1) {
16312
16465
  throw new ToolHandlerError(
16313
16466
  "AMBIGUOUS_MATCH",
16314
- `Multiple notes match name '${name}'; pass an explicit path`,
16467
+ `Multiple notes match name '${name}': ${matches.join(", ")}; pass an explicit path`,
16315
16468
  { details: { name, matches } }
16316
16469
  );
16317
16470
  }
@@ -16595,7 +16748,7 @@ var FsVaultWriter = class {
16595
16748
  }
16596
16749
  throw new ToolHandlerError(
16597
16750
  "AMBIGUOUS_MATCH",
16598
- `Find text matched ${result.lines.length} times in ${input.path}; use a more specific anchor or omit the replace field to rewrite the whole body`,
16751
+ `Find text matched ${result.lines.length} times in ${input.path} at lines ${result.lines.join(", ")}; make 'replace' more specific (extend the anchor with surrounding text) or omit it to rewrite the whole body`,
16599
16752
  { details: { path: input.path, matches: result.lines } }
16600
16753
  );
16601
16754
  }
@@ -16703,6 +16856,10 @@ Use \`search_notes\` when the user is recalling a topic fuzzily, asking a concep
16703
16856
  3. The vault may contain notes written in several languages. If you have evidence of which languages are in use (from prior reads, file names, or earlier results), include translations of the key concepts into each of those languages in the same \`query\` array.
16704
16857
  4. If a search returns no results, lower the threshold to 0.3 before giving up.
16705
16858
 
16859
+ ### 1a. Narrow scope when domain is known
16860
+
16861
+ If the query is about a known folder, tag, or frontmatter dimension, pass \`filter\` to scope semantic search to that subset BEFORE ranking. This dramatically improves precision when the vault has many narrative notes that crowd top-K. Shape: \`{ path_prefix?, tags?, frontmatter? }\` (at least one). Tags match ANY-of (OR). \`frontmatter\` accepts the same sift operators as \`query_notes\`. Examples: \`filter: { tags: ['trading'] }\`, \`filter: { path_prefix: 'Resources/', frontmatter: { type: 'reflection' } }\`.
16862
+
16706
16863
  ### 2. Choose mode
16707
16864
  - **quick** (default) \u2014 returns up to 3 notes plus block-level matches scoped to those notes. Use for specific lookups.
16708
16865
  - **deep** \u2014 returns up to 8 notes plus block-level matches across the whole vault. After the merged top-\`limit\` seeds are selected, expansion runs once on those seeds to pull in related notes. Use for broad topics.
@@ -16740,6 +16897,10 @@ async function startNeuroVaultServer(config2, deps = {}) {
16740
16897
  const server = serverFactory();
16741
16898
  const sharedReader = new FsVaultReader({ vaultRoot: config2.vaultPath });
16742
16899
  const sharedGraph = new WikilinkGraphIndex({ reader: sharedReader });
16900
+ const sharedListMatchingPaths = createListMatchingPaths({
16901
+ reader: sharedReader,
16902
+ graph: sharedGraph
16903
+ });
16743
16904
  const registrations = [];
16744
16905
  let warmup = async () => {
16745
16906
  };
@@ -16751,7 +16912,11 @@ async function startNeuroVaultServer(config2, deps = {}) {
16751
16912
  modelKey: config2.semantic.modelKey,
16752
16913
  modelId: config2.semantic.modelId
16753
16914
  },
16754
- { graph: sharedGraph, ...deps.semantic }
16915
+ {
16916
+ graph: sharedGraph,
16917
+ listMatchingPaths: sharedListMatchingPaths,
16918
+ ...deps.semantic
16919
+ }
16755
16920
  );
16756
16921
  registrations.push(...semantic.tools);
16757
16922
  warmup = semantic.warmup;