neuro-vault-mcp 1.6.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts CHANGED
@@ -6,6 +6,11 @@ interface RetrievalOutput {
6
6
  results: SearchResult[];
7
7
  blockResults?: BlockSearchResult[];
8
8
  }
9
+ interface MultiRetrievalOutput {
10
+ results: MultiSearchResult[];
11
+ blockResults?: MultiBlockSearchResult[];
12
+ truncated: boolean;
13
+ }
9
14
 
10
15
  interface SmartBlock {
11
16
  key: string;
@@ -58,7 +63,7 @@ interface SearchEngine {
58
63
  }
59
64
  type SearchMode = 'quick' | 'deep';
60
65
  interface SearchNotesInput {
61
- query: string;
66
+ query: string | string[];
62
67
  mode?: SearchMode;
63
68
  limit?: number;
64
69
  threshold?: number;
@@ -79,6 +84,7 @@ interface ToolStats {
79
84
  embeddingDimension: number;
80
85
  modelKey: string;
81
86
  }
87
+ type PathExistsCheck = (vaultRelativePath: string) => Promise<boolean>;
82
88
  interface ToolHandlerDependencies {
83
89
  loader: {
84
90
  sources: Map<string, SmartSource>;
@@ -86,13 +92,20 @@ interface ToolHandlerDependencies {
86
92
  embeddingProvider: EmbeddingProvider;
87
93
  searchEngine: SearchEngine;
88
94
  modelKey: string;
95
+ pathExists?: PathExistsCheck;
89
96
  }
90
97
  interface ToolHandlers {
91
- searchNotes(input: SearchNotesInput): Promise<RetrievalOutput>;
98
+ searchNotes(input: SearchNotesInput): Promise<RetrievalOutput | MultiRetrievalOutput>;
92
99
  getSimilarNotes(input: GetSimilarNotesInput): Promise<SearchResult[]>;
93
100
  findDuplicates(input?: FindDuplicatesInput): Promise<DuplicatePair[]>;
94
101
  getStats(): Promise<ToolStats>;
95
102
  }
103
+ interface MultiSearchResult extends SearchResult {
104
+ matched_queries: string[];
105
+ }
106
+ interface MultiBlockSearchResult extends BlockSearchResult {
107
+ matched_queries: string[];
108
+ }
96
109
 
97
110
  interface SmartConnectionsCorpus {
98
111
  sources: Map<string, SmartSource>;
@@ -103,6 +116,7 @@ interface SemanticModuleDeps {
103
116
  embeddingServiceFactory?: (modelId: string) => EmbeddingProvider;
104
117
  searchEngine?: SearchEngine;
105
118
  toolHandlersFactory?: (deps: ToolHandlerDependencies) => ToolHandlers;
119
+ pathExists?: PathExistsCheck;
106
120
  }
107
121
 
108
122
  type NoteIdentifier = {
@@ -117,6 +131,7 @@ interface ReadNoteInput {
117
131
  }
118
132
  interface ReadNoteResult {
119
133
  path: string;
134
+ frontmatter: Record<string, unknown> | null;
120
135
  content: string;
121
136
  }
122
137
  interface CreateNoteInput {
@@ -137,6 +152,7 @@ interface EditNoteInput {
137
152
  }
138
153
  interface DailyNoteResult {
139
154
  path: string;
155
+ frontmatter: Record<string, unknown> | null;
140
156
  content: string;
141
157
  }
142
158
  interface AppendDailyInput {
package/dist/cli.js CHANGED
@@ -61,6 +61,10 @@ import { createRequire } from "module";
61
61
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
62
62
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
63
63
 
64
+ // src/modules/semantic/index.ts
65
+ import fs2 from "fs/promises";
66
+ import path4 from "path";
67
+
64
68
  // src/modules/semantic/embedding-service.ts
65
69
  import { pipeline } from "@xenova/transformers";
66
70
  var DEFAULT_MODEL_KEY2 = "bge-micro-v2";
@@ -496,6 +500,7 @@ import path3 from "path";
496
500
  // src/modules/semantic/retrieval-policy.ts
497
501
  var FALLBACK_THRESHOLD = 0.3;
498
502
  var QUICK_BLOCK_LIMIT = 5;
503
+ var HARD_MERGE_CAP = 50;
499
504
  var MODE_DEFAULTS = {
500
505
  quick: { limit: 3, threshold: 0.5, expansion: false, expansionLimit: 0 },
501
506
  deep: { limit: 8, threshold: 0.35, expansion: true, expansionLimit: 3 }
@@ -570,6 +575,97 @@ async function executeRetrieval(input) {
570
575
  ...blockResults !== void 0 ? { blockResults } : {}
571
576
  };
572
577
  }
578
+ function mergeNoteResults(perQuery) {
579
+ const byPath = /* @__PURE__ */ new Map();
580
+ for (const { query, results } of perQuery) {
581
+ for (const result of results) {
582
+ const existing = byPath.get(result.path);
583
+ if (!existing) {
584
+ byPath.set(result.path, {
585
+ path: result.path,
586
+ similarity: result.similarity,
587
+ matched_queries: [query]
588
+ });
589
+ continue;
590
+ }
591
+ if (result.similarity > existing.similarity) {
592
+ existing.similarity = result.similarity;
593
+ }
594
+ if (!existing.matched_queries.includes(query)) {
595
+ existing.matched_queries.push(query);
596
+ }
597
+ }
598
+ }
599
+ return [...byPath.values()].sort(
600
+ (a, b) => b.similarity - a.similarity || a.path.localeCompare(b.path)
601
+ );
602
+ }
603
+ function mergeBlockResults(perQuery) {
604
+ const byKey = /* @__PURE__ */ new Map();
605
+ for (const { query, blocks } of perQuery) {
606
+ for (const block of blocks) {
607
+ const key = `${block.path}\0${block.heading}\0${block.lines[0]}-${block.lines[1]}`;
608
+ const existing = byKey.get(key);
609
+ if (!existing) {
610
+ byKey.set(key, {
611
+ path: block.path,
612
+ heading: block.heading,
613
+ lines: block.lines,
614
+ similarity: block.similarity,
615
+ matched_queries: [query]
616
+ });
617
+ continue;
618
+ }
619
+ if (block.similarity > existing.similarity) {
620
+ existing.similarity = block.similarity;
621
+ }
622
+ if (!existing.matched_queries.includes(query)) {
623
+ existing.matched_queries.push(query);
624
+ }
625
+ }
626
+ }
627
+ return [...byKey.values()].sort(
628
+ (a, b) => b.similarity - a.similarity || a.path.localeCompare(b.path)
629
+ );
630
+ }
631
+ async function executeMultiRetrieval(input) {
632
+ const { queries, mode, sources, embeddingProvider, searchEngine } = input;
633
+ const perQueryOutputs = await Promise.all(
634
+ queries.map(async (query) => {
635
+ const output = await executeRetrieval({
636
+ query,
637
+ mode,
638
+ threshold: input.threshold,
639
+ expansion: input.expansion,
640
+ expansionLimit: input.expansionLimit,
641
+ sources,
642
+ embeddingProvider,
643
+ searchEngine
644
+ });
645
+ return { query, output };
646
+ })
647
+ );
648
+ const mergedNotes = mergeNoteResults(
649
+ perQueryOutputs.map(({ query, output }) => ({ query, results: output.results }))
650
+ );
651
+ const anyBlocks = perQueryOutputs.some(({ output }) => output.blockResults !== void 0);
652
+ const mergedBlocks = anyBlocks ? mergeBlockResults(
653
+ perQueryOutputs.map(({ query, output }) => ({
654
+ query,
655
+ blocks: output.blockResults ?? []
656
+ }))
657
+ ) : void 0;
658
+ const perQueryLimit = input.limit ?? MODE_DEFAULTS[mode].limit;
659
+ const cap = Math.min(perQueryLimit * queries.length, HARD_MERGE_CAP);
660
+ const truncated = mergedNotes.length > cap || (mergedBlocks?.length ?? 0) > cap;
661
+ const cappedNotes = mergedNotes.slice(0, cap);
662
+ const cappedBlocks = mergedBlocks ? mergedBlocks.slice(0, cap) : void 0;
663
+ return {
664
+ results: cappedNotes,
665
+ ...cappedBlocks !== void 0 ? { blockResults: cappedBlocks } : {},
666
+ truncated
667
+ };
668
+ }
573
669
 
574
670
  // src/lib/tool-response.ts
575
671
  var ToolHandlerError = class extends Error {
@@ -624,6 +720,7 @@ var DEFAULT_SEARCH_LIMIT = 10;
624
720
  var DEFAULT_SEARCH_THRESHOLD = 0.5;
625
721
  var DEFAULT_DUPLICATE_THRESHOLD = 0.9;
626
722
  var WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
723
+ var MAX_MULTI_QUERIES = 8;
627
724
  function normalizeNotePath(notePath) {
628
725
  const trimmed = notePath.trim();
629
726
  if (!trimmed) {
@@ -664,6 +761,40 @@ function normalizeQuery(query) {
664
761
  }
665
762
  return normalized;
666
763
  }
764
+ function normalizeQueryArray(queries) {
765
+ if (queries.length === 0) {
766
+ throw new ToolHandlerError("INVALID_ARGUMENT", "query array must not be empty", {
767
+ details: { field: "query" }
768
+ });
769
+ }
770
+ if (queries.length > MAX_MULTI_QUERIES) {
771
+ throw new ToolHandlerError(
772
+ "INVALID_ARGUMENT",
773
+ `query array must contain at most ${MAX_MULTI_QUERIES} entries`,
774
+ { details: { field: "query", max: MAX_MULTI_QUERIES, received: queries.length } }
775
+ );
776
+ }
777
+ const seen = /* @__PURE__ */ new Set();
778
+ const out = [];
779
+ for (const raw of queries) {
780
+ if (typeof raw !== "string") {
781
+ throw new ToolHandlerError("INVALID_ARGUMENT", "query array must contain strings", {
782
+ details: { field: "query" }
783
+ });
784
+ }
785
+ const trimmed = raw.trim();
786
+ if (trimmed.length === 0) {
787
+ throw new ToolHandlerError("INVALID_ARGUMENT", "query entries must not be empty", {
788
+ details: { field: "query" }
789
+ });
790
+ }
791
+ if (!seen.has(trimmed)) {
792
+ seen.add(trimmed);
793
+ out.push(trimmed);
794
+ }
795
+ }
796
+ return out;
797
+ }
667
798
  function readPositiveInteger(value, defaultValue, field) {
668
799
  if (value === void 0) {
669
800
  return defaultValue;
@@ -732,20 +863,61 @@ function wrapDependencyError(error48, message, details) {
732
863
  cause: error48
733
864
  });
734
865
  }
866
+ async function buildExistingPathSet(paths, pathExists) {
867
+ const unique = new Set(paths);
868
+ const checks = await Promise.all(
869
+ [...unique].map(async (notePath) => [notePath, await pathExists(notePath)])
870
+ );
871
+ return new Set(checks.filter(([, exists]) => exists).map(([notePath]) => notePath));
872
+ }
735
873
  function createToolHandlers({
736
874
  loader,
737
875
  embeddingProvider,
738
876
  searchEngine,
739
- modelKey
877
+ modelKey,
878
+ pathExists
740
879
  }) {
880
+ const existsCheck = pathExists ?? (async () => true);
741
881
  return {
742
882
  async searchNotes(input) {
743
- const query = normalizeQuery(input.query);
744
883
  const mode = input.mode ?? "quick";
745
884
  const threshold = input.threshold !== void 0 ? readThreshold(input.threshold, input.threshold, "threshold") : void 0;
746
885
  const expansionLimit = input.expansion_limit !== void 0 ? readPositiveInteger(input.expansion_limit, 3, "expansion_limit") : void 0;
886
+ if (Array.isArray(input.query)) {
887
+ const queries = normalizeQueryArray(input.query);
888
+ const limit = input.limit !== void 0 ? readPositiveInteger(input.limit, input.limit, "limit") : void 0;
889
+ try {
890
+ const output = await executeMultiRetrieval({
891
+ queries,
892
+ mode,
893
+ threshold,
894
+ expansion: input.expansion,
895
+ expansionLimit,
896
+ limit,
897
+ sources: loader.sources,
898
+ embeddingProvider,
899
+ searchEngine
900
+ });
901
+ const candidatePaths = [
902
+ ...output.results.map((r) => r.path),
903
+ ...output.blockResults?.map((b) => b.path) ?? []
904
+ ];
905
+ const existing = await buildExistingPathSet(candidatePaths, existsCheck);
906
+ return {
907
+ results: output.results.filter((r) => existing.has(r.path)),
908
+ ...output.blockResults !== void 0 ? { blockResults: output.blockResults.filter((b) => existing.has(b.path)) } : {},
909
+ truncated: output.truncated
910
+ };
911
+ } catch (error48) {
912
+ throw wrapDependencyError(error48, "Failed to search notes", {
913
+ modelKey,
914
+ operation: "search_notes"
915
+ });
916
+ }
917
+ }
918
+ const query = normalizeQuery(input.query);
747
919
  try {
748
- return await executeRetrieval({
920
+ const output = await executeRetrieval({
749
921
  query,
750
922
  mode,
751
923
  threshold,
@@ -755,6 +927,15 @@ function createToolHandlers({
755
927
  embeddingProvider,
756
928
  searchEngine
757
929
  });
930
+ const candidatePaths = [
931
+ ...output.results.map((r) => r.path),
932
+ ...output.blockResults?.map((b) => b.path) ?? []
933
+ ];
934
+ const existing = await buildExistingPathSet(candidatePaths, existsCheck);
935
+ return {
936
+ results: output.results.filter((r) => existing.has(r.path)),
937
+ ...output.blockResults !== void 0 ? { blockResults: output.blockResults.filter((b) => existing.has(b.path)) } : {}
938
+ };
758
939
  } catch (error48) {
759
940
  throw wrapDependencyError(error48, "Failed to search notes", {
760
941
  modelKey,
@@ -773,7 +954,7 @@ function createToolHandlers({
773
954
  const limit = readPositiveInteger(input.limit, DEFAULT_SEARCH_LIMIT, "limit");
774
955
  const threshold = readThreshold(input.threshold, DEFAULT_SEARCH_THRESHOLD, "threshold");
775
956
  try {
776
- return toSearchResults2(
957
+ const results = toSearchResults2(
777
958
  searchEngine,
778
959
  source.embedding,
779
960
  loader.sources.values(),
@@ -781,6 +962,11 @@ function createToolHandlers({
781
962
  limit,
782
963
  notePath
783
964
  );
965
+ const existing = await buildExistingPathSet(
966
+ results.map((r) => r.path),
967
+ existsCheck
968
+ );
969
+ return results.filter((r) => existing.has(r.path));
784
970
  } catch (error48) {
785
971
  throw wrapDependencyError(error48, "Failed to find similar notes", {
786
972
  modelKey,
@@ -792,10 +978,15 @@ function createToolHandlers({
792
978
  async findDuplicates(input = {}) {
793
979
  const threshold = readThreshold(input.threshold, DEFAULT_DUPLICATE_THRESHOLD, "threshold");
794
980
  try {
795
- return searchEngine.findDuplicates({
981
+ const pairs = searchEngine.findDuplicates({
796
982
  sources: loader.sources.values(),
797
983
  threshold
798
984
  });
985
+ const existing = await buildExistingPathSet(
986
+ pairs.flatMap((p) => [p.note_a, p.note_b]),
987
+ existsCheck
988
+ );
989
+ return pairs.filter((p) => existing.has(p.note_a) && existing.has(p.note_b));
799
990
  } catch (error48) {
800
991
  throw wrapDependencyError(error48, "Failed to find duplicate notes", {
801
992
  modelKey,
@@ -1585,10 +1776,10 @@ function mergeDefs(...defs) {
1585
1776
  function cloneDef(schema) {
1586
1777
  return mergeDefs(schema._zod.def);
1587
1778
  }
1588
- function getElementAtPath(obj, path4) {
1589
- if (!path4)
1779
+ function getElementAtPath(obj, path5) {
1780
+ if (!path5)
1590
1781
  return obj;
1591
- return path4.reduce((acc, key) => acc?.[key], obj);
1782
+ return path5.reduce((acc, key) => acc?.[key], obj);
1592
1783
  }
1593
1784
  function promiseAllObject(promisesObj) {
1594
1785
  const keys = Object.keys(promisesObj);
@@ -1971,11 +2162,11 @@ function aborted(x, startIndex = 0) {
1971
2162
  }
1972
2163
  return false;
1973
2164
  }
1974
- function prefixIssues(path4, issues) {
2165
+ function prefixIssues(path5, issues) {
1975
2166
  return issues.map((iss) => {
1976
2167
  var _a2;
1977
2168
  (_a2 = iss).path ?? (_a2.path = []);
1978
- iss.path.unshift(path4);
2169
+ iss.path.unshift(path5);
1979
2170
  return iss;
1980
2171
  });
1981
2172
  }
@@ -2158,7 +2349,7 @@ function formatError(error48, mapper = (issue2) => issue2.message) {
2158
2349
  }
2159
2350
  function treeifyError(error48, mapper = (issue2) => issue2.message) {
2160
2351
  const result = { errors: [] };
2161
- const processError = (error49, path4 = []) => {
2352
+ const processError = (error49, path5 = []) => {
2162
2353
  var _a2, _b;
2163
2354
  for (const issue2 of error49.issues) {
2164
2355
  if (issue2.code === "invalid_union" && issue2.errors.length) {
@@ -2168,7 +2359,7 @@ function treeifyError(error48, mapper = (issue2) => issue2.message) {
2168
2359
  } else if (issue2.code === "invalid_element") {
2169
2360
  processError({ issues: issue2.issues }, issue2.path);
2170
2361
  } else {
2171
- const fullpath = [...path4, ...issue2.path];
2362
+ const fullpath = [...path5, ...issue2.path];
2172
2363
  if (fullpath.length === 0) {
2173
2364
  result.errors.push(mapper(issue2));
2174
2365
  continue;
@@ -2200,8 +2391,8 @@ function treeifyError(error48, mapper = (issue2) => issue2.message) {
2200
2391
  }
2201
2392
  function toDotPath(_path) {
2202
2393
  const segs = [];
2203
- const path4 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
2204
- for (const seg of path4) {
2394
+ const path5 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
2395
+ for (const seg of path5) {
2205
2396
  if (typeof seg === "number")
2206
2397
  segs.push(`[${seg}]`);
2207
2398
  else if (typeof seg === "symbol")
@@ -14178,13 +14369,13 @@ function resolveRef(ref, ctx) {
14178
14369
  if (!ref.startsWith("#")) {
14179
14370
  throw new Error("External $ref is not supported, only local refs (#/...) are allowed");
14180
14371
  }
14181
- const path4 = ref.slice(1).split("/").filter(Boolean);
14182
- if (path4.length === 0) {
14372
+ const path5 = ref.slice(1).split("/").filter(Boolean);
14373
+ if (path5.length === 0) {
14183
14374
  return ctx.rootSchema;
14184
14375
  }
14185
14376
  const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";
14186
- if (path4[0] === defsKey) {
14187
- const key = path4[1];
14377
+ if (path5[0] === defsKey) {
14378
+ const key = path5[1];
14188
14379
  if (!key || !ctx.defs[key]) {
14189
14380
  throw new Error(`Reference not found: ${ref}`);
14190
14381
  }
@@ -14588,7 +14779,7 @@ config(en_default());
14588
14779
 
14589
14780
  // src/modules/semantic/tools.ts
14590
14781
  var searchNotesSchema = external_exports.object({
14591
- query: external_exports.string(),
14782
+ query: external_exports.union([external_exports.string(), external_exports.array(external_exports.string()).min(1).max(8)]),
14592
14783
  mode: external_exports.enum(["quick", "deep"]).optional(),
14593
14784
  limit: external_exports.number().int().positive().optional(),
14594
14785
  threshold: external_exports.number().min(0).max(1).optional(),
@@ -14609,7 +14800,7 @@ function buildSemanticTools(handlers) {
14609
14800
  name: "search_notes",
14610
14801
  spec: {
14611
14802
  title: "Search Notes",
14612
- description: 'Search notes by semantic similarity for fuzzy recall, topic lookup, or cross-language matching. Pass a short keyword query (1-4 words). Choose mode: "quick" for specific lookups (up to 3 notes), "deep" for broad topic overview with block-level search and expansion. Call multiple times with different queries for synonyms or multi-language searches.',
14803
+ description: 'Search notes by semantic similarity for fuzzy recall, topic lookup, or cross-language matching. Pass a short keyword query (1-4 words). For synonyms, reformulations, or translations into the languages used in the vault, pass an array of 1-8 queries \u2014 they are batch-embedded server-side and returned as one merged ranked list with matched_queries on each result. Choose mode: "quick" for specific lookups (up to 3 notes), "deep" for broad topic overview with block-level search and expansion.',
14613
14804
  inputSchema: searchNotesSchema
14614
14805
  },
14615
14806
  handler: async (args) => invokeTool(() => handlers.searchNotes(searchNotesSchema.parse(args)))
@@ -14644,6 +14835,16 @@ function buildSemanticTools(handlers) {
14644
14835
  }
14645
14836
 
14646
14837
  // src/modules/semantic/index.ts
14838
+ function createDefaultPathExists(vaultPath) {
14839
+ return async (vaultRelativePath) => {
14840
+ try {
14841
+ await fs2.access(path4.join(vaultPath, vaultRelativePath));
14842
+ return true;
14843
+ } catch {
14844
+ return false;
14845
+ }
14846
+ };
14847
+ }
14647
14848
  async function createSemanticModule(config2, deps = {}) {
14648
14849
  const loadCorpus = deps.loadCorpus ?? loadSmartConnectionsCorpus;
14649
14850
  const embeddingServiceFactory = deps.embeddingServiceFactory ?? ((modelId) => new EmbeddingService({ modelKey: modelId }));
@@ -14654,11 +14855,13 @@ async function createSemanticModule(config2, deps = {}) {
14654
14855
  throw new Error("Loaded Smart Connections corpus is empty");
14655
14856
  }
14656
14857
  const embeddingService = embeddingServiceFactory(config2.modelId);
14858
+ const pathExists = deps.pathExists ?? createDefaultPathExists(config2.vaultPath);
14657
14859
  const handlers = toolHandlersFactory({
14658
14860
  loader: corpus,
14659
14861
  embeddingProvider: embeddingService,
14660
14862
  searchEngine,
14661
- modelKey: config2.modelKey
14863
+ modelKey: config2.modelKey,
14864
+ pathExists
14662
14865
  });
14663
14866
  return {
14664
14867
  tools: buildSemanticTools(handlers),
@@ -14671,9 +14874,47 @@ async function createSemanticModule(config2, deps = {}) {
14671
14874
  // src/modules/operations/obsidian-cli-provider.ts
14672
14875
  import { execFile } from "child_process";
14673
14876
  import { promisify } from "util";
14877
+
14878
+ // src/modules/operations/frontmatter.ts
14879
+ import { parse as parseYaml } from "yaml";
14880
+ var FRONTMATTER_OPEN = /^---[ \t]*\r?\n/;
14881
+ var FRONTMATTER_CLOSE = /(^|\r?\n)---[ \t]*(\r?\n|$)/;
14882
+ function splitFrontmatter(raw) {
14883
+ const openMatch = raw.match(FRONTMATTER_OPEN);
14884
+ if (!openMatch) {
14885
+ return { frontmatter: null, content: raw };
14886
+ }
14887
+ const afterOpen = raw.slice(openMatch[0].length);
14888
+ const closeMatch = afterOpen.match(FRONTMATTER_CLOSE);
14889
+ if (!closeMatch || closeMatch.index === void 0) {
14890
+ return { frontmatter: null, content: raw };
14891
+ }
14892
+ const yamlBody = afterOpen.slice(0, closeMatch.index);
14893
+ const content = afterOpen.slice(closeMatch.index + closeMatch[0].length);
14894
+ let parsed;
14895
+ try {
14896
+ parsed = parseYaml(yamlBody);
14897
+ } catch (err) {
14898
+ console.warn(
14899
+ `[neuro-vault] frontmatter YAML parse failed: ${err.message}; returning raw content`
14900
+ );
14901
+ return { frontmatter: null, content: raw };
14902
+ }
14903
+ if (parsed === null || parsed === void 0) {
14904
+ return { frontmatter: {}, content };
14905
+ }
14906
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
14907
+ console.warn(
14908
+ `[neuro-vault] frontmatter parsed to non-object (${typeof parsed}); returning raw content`
14909
+ );
14910
+ return { frontmatter: null, content: raw };
14911
+ }
14912
+ return { frontmatter: parsed, content };
14913
+ }
14914
+
14915
+ // src/modules/operations/obsidian-cli-provider.ts
14674
14916
  var DEFAULT_BINARY = "obsidian";
14675
14917
  var DEFAULT_TIMEOUT_MS = 1e4;
14676
- var SEPARATOR = "\n---\n";
14677
14918
  var execFileAsync = promisify(execFile);
14678
14919
  var defaultExec = async (binary, args, options) => {
14679
14920
  const result = await execFileAsync(binary, args, {
@@ -14700,8 +14941,10 @@ var ObsidianCLIProvider = class {
14700
14941
  this.exec = opts.exec ?? defaultExec;
14701
14942
  }
14702
14943
  async readNote(input) {
14944
+ const path5 = await this.resolvePath(input.identifier);
14703
14945
  const { stdout } = await this.runCommand("read", [identifierToArg(input.identifier)]);
14704
- return this.parseReadOutput(stdout);
14946
+ const { frontmatter, content } = splitFrontmatter(stdout);
14947
+ return { path: path5, frontmatter, content };
14705
14948
  }
14706
14949
  async createNote(input) {
14707
14950
  if (input.name === void 0 && input.path === void 0) {
@@ -14723,8 +14966,11 @@ var ObsidianCLIProvider = class {
14723
14966
  ]);
14724
14967
  }
14725
14968
  async readDaily() {
14969
+ const { stdout: pathStdout } = await this.runCommand("daily:path", []);
14970
+ const path5 = pathStdout.trim();
14726
14971
  const { stdout } = await this.runCommand("daily:read", []);
14727
- return this.parseReadOutput(stdout);
14972
+ const { frontmatter, content } = splitFrontmatter(stdout);
14973
+ return { path: path5, frontmatter, content };
14728
14974
  }
14729
14975
  async appendDaily(input) {
14730
14976
  await this.runCommand("daily:append", [`content=${input.content}`]);
@@ -14905,15 +15151,21 @@ var ObsidianCLIProvider = class {
14905
15151
  if (this.vaultName) args.push(`vault=${this.vaultName}`);
14906
15152
  return args;
14907
15153
  }
14908
- parseReadOutput(stdout) {
14909
- const sepIndex = stdout.indexOf(SEPARATOR);
14910
- if (sepIndex === -1) {
14911
- return { path: "", content: stdout };
15154
+ async resolvePath(identifier) {
15155
+ if (identifier.kind === "path") return identifier.value;
15156
+ const { stdout } = await this.runCommand("file", [identifierToArg(identifier)]);
15157
+ for (const line of stdout.split(/\r?\n/)) {
15158
+ const idx = line.indexOf(" ");
15159
+ if (idx === -1) continue;
15160
+ if (line.slice(0, idx).trim() === "path") {
15161
+ return line.slice(idx + 1).trim();
15162
+ }
14912
15163
  }
14913
- return {
14914
- path: stdout.slice(0, sepIndex),
14915
- content: stdout.slice(sepIndex + SEPARATOR.length)
14916
- };
15164
+ throw new ToolHandlerError(
15165
+ "CLI_ERROR",
15166
+ `Could not resolve path for name '${identifier.value}': 'path' not found in obsidian file output`,
15167
+ { details: { name: identifier.value, stdout: stdout.slice(0, 500) } }
15168
+ );
14917
15169
  }
14918
15170
  };
14919
15171
 
@@ -15168,7 +15420,7 @@ function buildOperationsTools(handlers) {
15168
15420
  name: "read_note",
15169
15421
  spec: {
15170
15422
  title: "Read Note",
15171
- description: "Read a note's contents. Provide either `name` (wikilink-style, resolves like Obsidian) or `path` (vault-relative, exact). Returns `{ path, content }`.",
15423
+ description: "Read a note's contents. Provide either `name` (wikilink-style, resolves like Obsidian) or `path` (vault-relative, exact). Returns `{ path, frontmatter, content }` where `frontmatter` is the parsed YAML object (or `null` if absent/malformed) and `content` is the body without the YAML block.",
15172
15424
  inputSchema: readNoteSchema
15173
15425
  },
15174
15426
  handler: async (args) => invokeTool(() => handlers.readNote(readNoteSchema.parse(args)))
@@ -15195,7 +15447,7 @@ function buildOperationsTools(handlers) {
15195
15447
  name: "read_daily",
15196
15448
  spec: {
15197
15449
  title: "Read Daily",
15198
- description: "Read today's daily note. Returns `{ path, content }`. Useful for 'what's on my agenda?' questions.",
15450
+ description: "Read today's daily note. Returns `{ path, frontmatter, content }` where `frontmatter` is the parsed YAML object (or `null` if absent/malformed) and `content` is the body without the YAML block. Useful for 'what's on my agenda?' questions.",
15199
15451
  inputSchema: readDailySchema
15200
15452
  },
15201
15453
  handler: async () => invokeTool(() => handlers.readDaily({}))
@@ -15313,10 +15565,9 @@ Use \`search_notes\` when the user is recalling a topic fuzzily, asking a concep
15313
15565
 
15314
15566
  ### 1. Write the query
15315
15567
  1. Extract the core nouns and concepts from the user's message \u2014 strip filler words and verbs. From "remind me what I wanted to build with LLM agents" the key concepts are "LLM", "agents", "build".
15316
- 2. Search each concept separately and in small combinations: "LLM", "agents", "LLM agents", "AI projects".
15317
- 3. Try synonyms and reformulations \u2014 the note may use different wording than the query.
15318
- 4. The vault may contain notes in multiple languages. Search in the language of the user's message + English.
15319
- 5. If a search returns no results, lower the threshold to 0.3 before giving up.
15568
+ 2. For synonyms, reformulations, or translations, pass \`query\` as an array of 1-8 strings in a SINGLE call \u2014 the server batch-embeds them and returns one merged ranked list with \`matched_queries\` showing which query hit which note. Do not make multiple sequential calls for synonyms.
15569
+ 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.
15570
+ 4. If a search returns no results, lower the threshold to 0.3 before giving up.
15320
15571
 
15321
15572
  ### 2. Choose mode
15322
15573
  - **quick** (default) \u2014 returns up to 3 notes, no expansion. Use for specific lookups.
@@ -15325,6 +15576,8 @@ Use \`search_notes\` when the user is recalling a topic fuzzily, asking a concep
15325
15576
 
15326
15577
  ### 3. Use the results
15327
15578
  - \`results\` \u2014 notes ranked by similarity; read the file by path
15579
+ - \`matched_queries\` (only when \`query\` is an array) \u2014 which of your queries hit this note; lets you spot which synonym was load-bearing
15580
+ - \`truncated\` (only when \`query\` is an array) \u2014 true when more candidates were merged than fit in the cap
15328
15581
  - \`blockResults\` \u2014 sections ranked by relevance; use heading + line range to jump to the relevant part
15329
15582
  - After finding a relevant note, call get_similar_notes to discover related content
15330
15583
 
@@ -15356,6 +15609,7 @@ async function startNeuroVaultServer(config2, deps = {}) {
15356
15609
  if (config2.semantic.enabled) {
15357
15610
  const semantic = await createSemanticModule(
15358
15611
  {
15612
+ vaultPath: config2.vaultPath,
15359
15613
  smartEnvPath: config2.semantic.smartEnvPath,
15360
15614
  modelKey: config2.semantic.modelKey,
15361
15615
  modelId: config2.semantic.modelId