neuro-vault-mcp 4.0.0 → 4.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
@@ -18,7 +18,7 @@ Your second brain stops being a folder you open between contexts and becomes a f
18
18
 
19
19
  - 🧠 **Semantic search that already knows your vault** — reuses [Smart Connections](https://github.com/brianpetro/obsidian-smart-connections) embeddings. No re-indexing, no API keys, no extra infrastructure.
20
20
  - 🎯 **Quick or deep, your call** — fast direct lookups for "find that note", or exploratory mode with related-note expansion when the question is fuzzy.
21
- - 🧭 **A real navigation toolkit for your agent** — instead of grepping files and opening notes one by one, your assistant walks the vault like a database: filter by tags and properties, batch-read metadata, discover the structure, jump to semantic neighbours.
21
+ - 🧭 **A real navigation toolkit for your agent** — instead of grepping files and opening notes one by one, your assistant walks the vault like a database: filter by tags and properties, batch-read metadata, traverse the wikilink graph, discover the structure, jump to semantic neighbours.
22
22
  - 🔎 **Ask structured questions in plain language** — _"active projects tagged #ai"_, _"todo tasks with a deadline this week"_, _"meeting notes from `Work/` newest first"_ — one call, ranked answer, no chains of reads.
23
23
  - ✍️ **Read and write through Obsidian itself** — create, append, edit notes, manage frontmatter and daily notes via the [Obsidian CLI](https://github.com/AlexMost/obsidian-cli), so Smart Connections, sync, and other plugins stay in the loop.
24
24
  - ⚡ **Zero infrastructure** — local stdio MCP server, in-memory index, no database, no background processes, no watchers.
package/dist/cli.d.ts CHANGED
@@ -18,6 +18,63 @@ interface SmartConnectionsCorpus {
18
18
  sources: Map<string, SmartSource>;
19
19
  }
20
20
 
21
+ type ReadNotesField = 'frontmatter' | 'content';
22
+ interface ReadNotesItemSuccess {
23
+ path: string;
24
+ frontmatter: Record<string, unknown> | null;
25
+ content: string;
26
+ }
27
+ interface ReadNotesItemError {
28
+ path: string;
29
+ error: {
30
+ code: 'NOT_FOUND' | 'INVALID_ARGUMENT' | 'READ_FAILED';
31
+ message: string;
32
+ };
33
+ }
34
+ type ReadNotesItem = ReadNotesItemSuccess | ReadNotesItemError;
35
+ interface ReadNotesInput {
36
+ paths: string[];
37
+ fields: ReadNotesField[];
38
+ }
39
+ interface ScanOptions {
40
+ pathPrefix?: string;
41
+ }
42
+ interface VaultReader {
43
+ readNotes(input: ReadNotesInput): Promise<ReadNotesItem[]>;
44
+ scan(opts?: ScanOptions): Promise<string[]>;
45
+ }
46
+
47
+ interface OutgoingLink {
48
+ target: string;
49
+ resolved: boolean;
50
+ path?: string;
51
+ }
52
+ interface IncomingLink {
53
+ source: string;
54
+ }
55
+ interface NoteLinks {
56
+ incoming: IncomingLink[];
57
+ outgoing: OutgoingLink[];
58
+ }
59
+ interface WikilinkGraphIndexOptions {
60
+ reader: VaultReader;
61
+ ttlMs?: number;
62
+ now?: () => number;
63
+ }
64
+ declare class WikilinkGraphIndex {
65
+ private readonly reader;
66
+ private readonly ttlMs;
67
+ private readonly now;
68
+ private byPath;
69
+ private lastBuildAt;
70
+ private buildPromise;
71
+ constructor(opts: WikilinkGraphIndexOptions);
72
+ ensureFresh(): Promise<void>;
73
+ getNoteLinks(notePath: string): NoteLinks;
74
+ getBacklinkCount(notePath: string): number;
75
+ private rebuild;
76
+ }
77
+
21
78
  interface BlockSearchResult {
22
79
  path: string;
23
80
  heading: string;
@@ -65,6 +122,10 @@ interface SemanticModuleDeps {
65
122
  searchEngine?: SearchEngine;
66
123
  pathExists?: PathExistsCheck;
67
124
  readNoteContent?: (vaultRelativePath: string) => Promise<string>;
125
+ graph?: WikilinkGraphIndex;
126
+ vaultReaderFactory?: (opts: {
127
+ vaultRoot: string;
128
+ }) => VaultReader;
68
129
  }
69
130
 
70
131
  type NoteIdentifier = {
@@ -150,37 +211,12 @@ interface ObsidianCLIProviderOptions {
150
211
  exec?: ExecFn;
151
212
  }
152
213
 
153
- type ReadNotesField = 'frontmatter' | 'content';
154
- interface ReadNotesItemSuccess {
155
- path: string;
156
- frontmatter: Record<string, unknown> | null;
157
- content: string;
158
- }
159
- interface ReadNotesItemError {
160
- path: string;
161
- error: {
162
- code: 'NOT_FOUND' | 'INVALID_ARGUMENT' | 'READ_FAILED';
163
- message: string;
164
- };
165
- }
166
- type ReadNotesItem = ReadNotesItemSuccess | ReadNotesItemError;
167
- interface ReadNotesInput {
168
- paths: string[];
169
- fields: ReadNotesField[];
170
- }
171
- interface ScanOptions {
172
- pathPrefix?: string;
173
- }
174
- interface VaultReader {
175
- readNotes(input: ReadNotesInput): Promise<ReadNotesItem[]>;
176
- scan(opts?: ScanOptions): Promise<string[]>;
177
- }
178
-
179
214
  interface OperationsModuleDeps {
180
215
  vaultProviderFactory?: (opts: ObsidianCLIProviderOptions) => VaultProvider;
181
216
  vaultReaderFactory?: (opts: {
182
217
  vaultRoot: string;
183
218
  }) => VaultReader;
219
+ graph?: WikilinkGraphIndex;
184
220
  }
185
221
 
186
222
  type ToolServer = Pick<McpServer, 'registerTool' | 'connect'>;
package/dist/cli.js CHANGED
@@ -753,6 +753,115 @@ async function getNoteLinks(opts) {
753
753
  return resolved;
754
754
  }
755
755
 
756
+ // src/lib/obsidian/wikilink-graph.ts
757
+ var DEFAULT_TTL_MS = 3 * 60 * 1e3;
758
+ var READ_BATCH_SIZE = 32;
759
+ var WikilinkGraphIndex = class {
760
+ reader;
761
+ ttlMs;
762
+ now;
763
+ byPath = /* @__PURE__ */ new Map();
764
+ lastBuildAt = 0;
765
+ buildPromise = null;
766
+ constructor(opts) {
767
+ this.reader = opts.reader;
768
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
769
+ this.now = opts.now ?? Date.now;
770
+ }
771
+ async ensureFresh() {
772
+ if (this.buildPromise) {
773
+ await this.buildPromise;
774
+ return;
775
+ }
776
+ if (this.lastBuildAt > 0 && this.now() - this.lastBuildAt < this.ttlMs) {
777
+ return;
778
+ }
779
+ this.buildPromise = this.rebuild().then(() => {
780
+ this.lastBuildAt = this.now();
781
+ }).finally(() => {
782
+ this.buildPromise = null;
783
+ });
784
+ await this.buildPromise;
785
+ }
786
+ getNoteLinks(notePath) {
787
+ const entry = this.byPath.get(notePath);
788
+ if (!entry) return { incoming: [], outgoing: [] };
789
+ return {
790
+ incoming: entry.incoming.map((e) => ({ ...e })),
791
+ outgoing: entry.outgoing.map((e) => ({ ...e }))
792
+ };
793
+ }
794
+ getBacklinkCount(notePath) {
795
+ return this.byPath.get(notePath)?.incoming.length ?? 0;
796
+ }
797
+ async rebuild() {
798
+ const paths = await this.reader.scan();
799
+ const basenameIndex = buildBasenameIndex(paths);
800
+ const next = /* @__PURE__ */ new Map();
801
+ for (const p of paths) {
802
+ next.set(p, { outgoing: [], incoming: [] });
803
+ }
804
+ for (let i = 0; i < paths.length; i += READ_BATCH_SIZE) {
805
+ const slice = paths.slice(i, i + READ_BATCH_SIZE);
806
+ const items = await this.reader.readNotes({
807
+ paths: slice,
808
+ fields: ["frontmatter", "content"]
809
+ });
810
+ for (const item of items) {
811
+ if ("error" in item) {
812
+ if (item.error.code === "READ_FAILED") {
813
+ process.stderr.write(`[neuro-vault] wikilink-graph: ${item.error.message}
814
+ `);
815
+ }
816
+ continue;
817
+ }
818
+ const adjacency = next.get(item.path);
819
+ if (!adjacency) continue;
820
+ const outgoing = collectOutgoing({
821
+ notePath: item.path,
822
+ body: item.content,
823
+ frontmatter: item.frontmatter,
824
+ basenameIndex
825
+ });
826
+ adjacency.outgoing = outgoing;
827
+ for (const edge of outgoing) {
828
+ if (!edge.resolved || !edge.path) continue;
829
+ const target = next.get(edge.path);
830
+ if (!target) continue;
831
+ target.incoming.push({ source: item.path });
832
+ }
833
+ }
834
+ }
835
+ this.byPath = next;
836
+ }
837
+ };
838
+ function collectOutgoing(args) {
839
+ const { notePath, body, frontmatter, basenameIndex } = args;
840
+ const rawTargets = [
841
+ ...parseWikilinks(body),
842
+ ...frontmatter ? extractWikilinksFromFrontmatter(frontmatter) : []
843
+ ];
844
+ const seenResolved = /* @__PURE__ */ new Set();
845
+ const seenUnresolved = /* @__PURE__ */ new Set();
846
+ const out = [];
847
+ for (const raw of rawTargets) {
848
+ const target = normalizeWikilinkTarget(raw);
849
+ if (!target) continue;
850
+ const resolved = basenameIndex.resolve(target);
851
+ if (resolved) {
852
+ if (resolved === notePath) continue;
853
+ if (seenResolved.has(resolved)) continue;
854
+ seenResolved.add(resolved);
855
+ out.push({ target, resolved: true, path: resolved });
856
+ } else {
857
+ if (seenUnresolved.has(target)) continue;
858
+ seenUnresolved.add(target);
859
+ out.push({ target, resolved: false });
860
+ }
861
+ }
862
+ return out;
863
+ }
864
+
756
865
  // src/lib/obsidian/query/query-notes.ts
757
866
  import siftModule from "sift";
758
867
 
@@ -812,12 +921,13 @@ async function invokeTool(handler) {
812
921
  }
813
922
 
814
923
  // src/lib/obsidian/query/note-record.ts
815
- function toNoteRecord(item) {
924
+ function toNoteRecord(item, backlinkCount = 0) {
816
925
  const frontmatter = item.frontmatter && typeof item.frontmatter === "object" ? item.frontmatter : {};
817
926
  return {
818
927
  path: item.path,
819
928
  frontmatter,
820
- tags: extractTags(frontmatter)
929
+ tags: extractTags(frontmatter),
930
+ backlink_count: backlinkCount
821
931
  };
822
932
  }
823
933
  function extractTags(frontmatter) {
@@ -883,10 +993,13 @@ var sift = typeof siftModule === "function" ? siftModule : siftModule.default;
883
993
  var DEFAULT_LIMIT = 100;
884
994
  var HARD_LIMIT_CAP = 1e3;
885
995
  var WINDOWS_ABSOLUTE_PATH_RE2 = /^[A-Za-z]:[\\/]/;
886
- var READ_BATCH_SIZE = 32;
887
- async function runQueryNotes(input, reader) {
996
+ var READ_BATCH_SIZE2 = 32;
997
+ async function runQueryNotes(input, reader, graph) {
888
998
  const validated = validateInput(input);
889
999
  validateFilter(validated.filter);
1000
+ if (graph) {
1001
+ await graph.ensureFresh();
1002
+ }
890
1003
  let paths;
891
1004
  try {
892
1005
  paths = await reader.scan({ pathPrefix: validated.pathPrefix });
@@ -911,8 +1024,8 @@ async function runQueryNotes(input, reader) {
911
1024
  const fields = validated.includeContent ? ["frontmatter", "content"] : ["frontmatter"];
912
1025
  const earlyExitAllowed = !validated.sort || validated.sort.field === "path" && validated.sort.order === "asc";
913
1026
  const matched = [];
914
- for (let i = 0; i < paths.length; i += READ_BATCH_SIZE) {
915
- const slice = paths.slice(i, i + READ_BATCH_SIZE);
1027
+ for (let i = 0; i < paths.length; i += READ_BATCH_SIZE2) {
1028
+ const slice = paths.slice(i, i + READ_BATCH_SIZE2);
916
1029
  const items = await reader.readNotes({ paths: slice, fields: [...fields] });
917
1030
  for (const item of items) {
918
1031
  if ("error" in item) {
@@ -922,7 +1035,8 @@ async function runQueryNotes(input, reader) {
922
1035
  }
923
1036
  continue;
924
1037
  }
925
- const record2 = toNoteRecord(item);
1038
+ const backlinkCount = graph?.getBacklinkCount(item.path) ?? 0;
1039
+ const record2 = toNoteRecord(item, backlinkCount);
926
1040
  let isMatch;
927
1041
  try {
928
1042
  isMatch = matcher(record2);
@@ -944,8 +1058,13 @@ async function runQueryNotes(input, reader) {
944
1058
  (row) => validated.includeContent ? {
945
1059
  path: row.record.path,
946
1060
  frontmatter: row.record.frontmatter,
1061
+ backlink_count: row.record.backlink_count,
947
1062
  content: row.content
948
- } : { path: row.record.path, frontmatter: row.record.frontmatter }
1063
+ } : {
1064
+ path: row.record.path,
1065
+ frontmatter: row.record.frontmatter,
1066
+ backlink_count: row.record.backlink_count
1067
+ }
949
1068
  );
950
1069
  return { results, count: results.length, truncated };
951
1070
  }
@@ -1012,8 +1131,11 @@ function validateSort(raw) {
1012
1131
  if (typeof obj.field !== "string" || obj.field.trim() === "") {
1013
1132
  throw invalidParams("sort.field must be a non-empty string", "sort.field");
1014
1133
  }
1015
- if (obj.field !== "path" && !obj.field.startsWith("frontmatter.")) {
1016
- throw invalidParams('sort.field must be "path" or start with "frontmatter."', "sort.field");
1134
+ if (obj.field !== "path" && obj.field !== "backlink_count" && !obj.field.startsWith("frontmatter.")) {
1135
+ throw invalidParams(
1136
+ 'sort.field must be "path", "backlink_count", or start with "frontmatter."',
1137
+ "sort.field"
1138
+ );
1017
1139
  }
1018
1140
  if (obj.order !== "asc" && obj.order !== "desc") {
1019
1141
  throw invalidParams('sort.order must be "asc" or "desc"', "sort.order");
@@ -1030,6 +1152,7 @@ function sortInPlace(rows, sort) {
1030
1152
  }
1031
1153
  function readField(record2, field) {
1032
1154
  if (field === "path") return record2.path;
1155
+ if (field === "backlink_count") return record2.backlink_count;
1033
1156
  const segments = field.split(".").slice(1);
1034
1157
  let cursor = record2.frontmatter;
1035
1158
  for (const seg of segments) {
@@ -15122,7 +15245,6 @@ function buildFindDuplicatesTool(deps) {
15122
15245
  // src/modules/semantic/tools/get-similar-notes.ts
15123
15246
  var DEFAULT_LIMIT2 = 10;
15124
15247
  var DEFAULT_THRESHOLD = 0.5;
15125
- var DEFAULT_EXCLUDE_FOLDERS = ["Templates", "System", "Daily", "Archive"];
15126
15248
  var inputSchema2 = external_exports.object({
15127
15249
  path: external_exports.string(),
15128
15250
  limit: external_exports.number().int().positive().optional(),
@@ -15229,9 +15351,7 @@ function buildGetSimilarNotesTool(deps) {
15229
15351
  }
15230
15352
  const limit = readPositiveInteger(input.limit, DEFAULT_LIMIT2, "limit");
15231
15353
  const threshold = readThreshold(input.threshold, DEFAULT_THRESHOLD, "threshold");
15232
- const excludePrefixes = (input.exclude_folders ?? DEFAULT_EXCLUDE_FOLDERS).map(
15233
- normalizeExcludeEntry
15234
- );
15354
+ const excludePrefixes = (input.exclude_folders ?? []).map(normalizeExcludeEntry);
15235
15355
  try {
15236
15356
  const candidates = collectSemanticCandidates({
15237
15357
  searchEngine,
@@ -15574,7 +15694,7 @@ function wrapDependencyError2(error48, message, details) {
15574
15694
  return new ToolHandlerError("DEPENDENCY_ERROR", message, { details, cause: error48 });
15575
15695
  }
15576
15696
  function buildSearchNotesTool(deps) {
15577
- const { sources, embeddingProvider, searchEngine, modelKey, pathExists } = deps;
15697
+ const { sources, embeddingProvider, searchEngine, modelKey, pathExists, graph } = deps;
15578
15698
  return {
15579
15699
  name: "search_notes",
15580
15700
  title: "Search Notes",
@@ -15600,9 +15720,12 @@ function buildSearchNotesTool(deps) {
15600
15720
  ...output.results.map((r) => r.path),
15601
15721
  ...output.blockResults?.map((b) => b.path) ?? []
15602
15722
  ];
15603
- const existing = await buildExistingPathSet2(candidatePaths, pathExists);
15723
+ const [existing] = await Promise.all([
15724
+ buildExistingPathSet2(candidatePaths, pathExists),
15725
+ graph.ensureFresh()
15726
+ ]);
15604
15727
  return {
15605
- results: output.results.filter((r) => existing.has(r.path)),
15728
+ results: output.results.filter((r) => existing.has(r.path)).map((r) => ({ ...r, backlink_count: graph.getBacklinkCount(r.path) })),
15606
15729
  ...output.blockResults !== void 0 ? { blockResults: output.blockResults.filter((b) => existing.has(b.path)) } : {},
15607
15730
  truncated: output.truncated
15608
15731
  };
@@ -15628,9 +15751,12 @@ function buildSearchNotesTool(deps) {
15628
15751
  ...output.results.map((r) => r.path),
15629
15752
  ...output.blockResults?.map((b) => b.path) ?? []
15630
15753
  ];
15631
- const existing = await buildExistingPathSet2(candidatePaths, pathExists);
15754
+ const [existing] = await Promise.all([
15755
+ buildExistingPathSet2(candidatePaths, pathExists),
15756
+ graph.ensureFresh()
15757
+ ]);
15632
15758
  return {
15633
- results: output.results.filter((r) => existing.has(r.path)),
15759
+ results: output.results.filter((r) => existing.has(r.path)).map((r) => ({ ...r, backlink_count: graph.getBacklinkCount(r.path) })),
15634
15760
  ...output.blockResults !== void 0 ? { blockResults: output.blockResults.filter((b) => existing.has(b.path)) } : {}
15635
15761
  };
15636
15762
  } catch (error48) {
@@ -15679,6 +15805,12 @@ async function createSemanticModule(config2, deps = {}) {
15679
15805
  const pathExists = deps.pathExists ?? createDefaultPathExists(config2.vaultPath);
15680
15806
  const readNoteContent = deps.readNoteContent ?? createDefaultReadNoteContent(config2.vaultPath);
15681
15807
  const basenameIndex = buildBasenameIndex(corpus.sources.keys());
15808
+ let graph = deps.graph;
15809
+ if (!graph) {
15810
+ const readerFactory = deps.vaultReaderFactory ?? ((opts) => new FsVaultReader({ vaultRoot: opts.vaultRoot }));
15811
+ const reader = readerFactory({ vaultRoot: config2.vaultPath });
15812
+ graph = new WikilinkGraphIndex({ reader });
15813
+ }
15682
15814
  const semanticDeps = {
15683
15815
  sources: corpus.sources,
15684
15816
  embeddingProvider: embeddingService,
@@ -15686,7 +15818,8 @@ async function createSemanticModule(config2, deps = {}) {
15686
15818
  modelKey: config2.modelKey,
15687
15819
  pathExists,
15688
15820
  basenameIndex,
15689
- readNoteContent
15821
+ readNoteContent,
15822
+ graph
15690
15823
  };
15691
15824
  return {
15692
15825
  tools: buildSemanticTools(semanticDeps),
@@ -16103,15 +16236,43 @@ function buildEditNoteTool(deps) {
16103
16236
  };
16104
16237
  }
16105
16238
 
16239
+ // src/modules/operations/tools/get-note-links.ts
16240
+ var inputSchema8 = external_exports.object({
16241
+ path: external_exports.string().min(1)
16242
+ });
16243
+ var DESCRIPTION = [
16244
+ "Return the wikilink adjacency for a single note: full incoming and outgoing edge lists derived from the vault-wide wikilink graph.",
16245
+ "",
16246
+ "`incoming` is the resolved list of source notes whose body or frontmatter wikilinks (or `![[embeds]]`) point at the requested path. `outgoing` lists the wikilink targets in the requested note; each carries `resolved: bool`. Resolved entries also carry the resolved vault path; unresolved entries (concepts the user has anchored but not yet written) are kept verbatim and surfaced for traversal use cases.",
16247
+ "",
16248
+ "Backed by an in-memory index that rebuilds lazily on query when older than 3 minutes \u2014 no background timers, no watchers. The first call after a stale window pays the rebuild cost.",
16249
+ "",
16250
+ "Use `search_notes` / `query_notes` to find a starting note, then call `get_note_links` to traverse the graph around it."
16251
+ ].join("\n");
16252
+ function buildGetNoteLinksTool(deps) {
16253
+ const { graph } = deps;
16254
+ return {
16255
+ name: "get_note_links",
16256
+ title: "Get Note Links",
16257
+ description: DESCRIPTION,
16258
+ inputSchema: inputSchema8,
16259
+ handler: async (input) => {
16260
+ const path6 = normalizePath(input.path);
16261
+ await graph.ensureFresh();
16262
+ return graph.getNoteLinks(path6);
16263
+ }
16264
+ };
16265
+ }
16266
+
16106
16267
  // src/modules/operations/tools/list-properties.ts
16107
- var inputSchema8 = external_exports.object({});
16268
+ var inputSchema9 = external_exports.object({});
16108
16269
  function buildListPropertiesTool(deps) {
16109
16270
  const { provider } = deps;
16110
16271
  return {
16111
16272
  name: "list_properties",
16112
16273
  title: "List Properties",
16113
16274
  description: "List all frontmatter properties used across the vault, sorted by occurrence count desc. Returns `[{name, count}]`. Useful for understanding the vault's metadata ontology.",
16114
- inputSchema: inputSchema8,
16275
+ inputSchema: inputSchema9,
16115
16276
  handler: async (_input) => {
16116
16277
  return provider.listProperties();
16117
16278
  }
@@ -16119,14 +16280,14 @@ function buildListPropertiesTool(deps) {
16119
16280
  }
16120
16281
 
16121
16282
  // src/modules/operations/tools/list-tags.ts
16122
- var inputSchema9 = external_exports.object({});
16283
+ var inputSchema10 = external_exports.object({});
16123
16284
  function buildListTagsTool(deps) {
16124
16285
  const { provider } = deps;
16125
16286
  return {
16126
16287
  name: "list_tags",
16127
16288
  title: "List Tags",
16128
16289
  description: "List all tags used across the vault, sorted by occurrence count desc. Returns `[{name, count}]`.",
16129
- inputSchema: inputSchema9,
16290
+ inputSchema: inputSchema10,
16130
16291
  handler: async (_input) => {
16131
16292
  return provider.listTags();
16132
16293
  }
@@ -16138,7 +16299,7 @@ var queryNotesSortSchema = external_exports.object({
16138
16299
  field: external_exports.string().min(1),
16139
16300
  order: external_exports.enum(["asc", "desc"])
16140
16301
  });
16141
- var inputSchema10 = external_exports.object({
16302
+ var inputSchema11 = external_exports.object({
16142
16303
  filter: external_exports.record(external_exports.string(), external_exports.unknown()),
16143
16304
  path_prefix: external_exports.string().optional(),
16144
16305
  sort: queryNotesSortSchema.optional(),
@@ -16146,27 +16307,27 @@ var inputSchema10 = external_exports.object({
16146
16307
  include_content: external_exports.boolean().optional()
16147
16308
  });
16148
16309
  function buildQueryNotesTool(deps) {
16149
- const { reader } = deps;
16310
+ const { reader, graph } = deps;
16150
16311
  return {
16151
16312
  name: "query_notes",
16152
16313
  title: "Query Notes",
16153
- description: 'Run a structured MongoDB-style query against the vault\'s frontmatter and tags. `filter` is a sift/MongoDB filter object evaluated against `NoteRecord` shape `{ path, frontmatter, tags }` \u2014 `tags` is an array of strings (no leading `#`) extracted from the `tags:` frontmatter field. Reference frontmatter keys with the dotted prefix `frontmatter.<key>`. Supported operators: `$eq`, `$ne`, `$in`, `$nin`, `$gt`, `$gte`, `$lt`, `$lte`, `$exists`, `$regex`, `$and`, `$or`, `$nor`, `$not`. 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"` 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 }`; `truncated` is true when more notes matched than `limit` allowed. Reads directly from disk and does not require Obsidian to be running.',
16154
- inputSchema: inputSchema10,
16314
+ 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`, `$and`, `$or`, `$nor`, `$not`. 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 `backlink_count`. `truncated` is true when more notes matched than `limit` allowed. Reads directly from disk and does not require Obsidian to be running.',
16315
+ inputSchema: inputSchema11,
16155
16316
  handler: async (input) => {
16156
- return runQueryNotes(input, reader);
16317
+ return runQueryNotes(input, reader, graph);
16157
16318
  }
16158
16319
  };
16159
16320
  }
16160
16321
 
16161
16322
  // src/modules/operations/tools/read-daily.ts
16162
- var inputSchema11 = external_exports.object({});
16323
+ var inputSchema12 = external_exports.object({});
16163
16324
  function buildReadDailyTool(deps) {
16164
16325
  const { provider } = deps;
16165
16326
  return {
16166
16327
  name: "read_daily",
16167
16328
  title: "Read Daily",
16168
16329
  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.",
16169
- inputSchema: inputSchema11,
16330
+ inputSchema: inputSchema12,
16170
16331
  handler: async (_input) => {
16171
16332
  return provider.readDaily();
16172
16333
  }
@@ -16175,7 +16336,7 @@ function buildReadDailyTool(deps) {
16175
16336
 
16176
16337
  // src/modules/operations/tools/read-notes.ts
16177
16338
  var readNotesFieldSchema = external_exports.enum(["frontmatter", "content"]);
16178
- var inputSchema12 = external_exports.object({
16339
+ var inputSchema13 = external_exports.object({
16179
16340
  paths: external_exports.union([external_exports.string().min(1), external_exports.array(external_exports.string()).min(1).max(50)]),
16180
16341
  fields: external_exports.array(readNotesFieldSchema).min(1).optional()
16181
16342
  });
@@ -16185,7 +16346,7 @@ function buildReadNotesTool(deps) {
16185
16346
  name: "read_notes",
16186
16347
  title: "Read Notes",
16187
16348
  description: "Read one or more notes in one call. `paths` is a vault-relative POSIX path string or an array of 1\u201350 such paths; duplicates are de-duplicated and results returned in input order. `fields` projects which parts of each note to return \u2014 choose from `frontmatter` and `content`; default `['frontmatter','content']`. One missing or unreadable path does not fail the others \u2014 per-item errors come back inline. A single MCP roundtrip with parallel disk reads. Reads are direct from disk and do not require Obsidian to be running.",
16188
- inputSchema: inputSchema12,
16349
+ inputSchema: inputSchema13,
16189
16350
  handler: async (input) => {
16190
16351
  const { paths, fields } = validateReadNotesInput(input);
16191
16352
  const seen = /* @__PURE__ */ new Set();
@@ -16237,7 +16398,7 @@ function buildReadNotesTool(deps) {
16237
16398
  }
16238
16399
 
16239
16400
  // src/modules/operations/tools/read-property.ts
16240
- var inputSchema13 = external_exports.object({
16401
+ var inputSchema14 = external_exports.object({
16241
16402
  name: external_exports.string().optional(),
16242
16403
  path: external_exports.string().optional(),
16243
16404
  key: external_exports.string()
@@ -16248,7 +16409,7 @@ function buildReadPropertyTool(deps) {
16248
16409
  name: "read_property",
16249
16410
  title: "Read Property",
16250
16411
  description: "Read a frontmatter property value from a note. Provide `name` or `path`, plus `key`. Returns `{ value }`. Use `read_notes` if you need the full frontmatter or accurate type information.",
16251
- inputSchema: inputSchema13,
16412
+ inputSchema: inputSchema14,
16252
16413
  handler: async (input) => {
16253
16414
  const identifier = resolveIdentifier(input.name, input.path);
16254
16415
  if (!input.key || input.key.trim() === "") {
@@ -16260,7 +16421,7 @@ function buildReadPropertyTool(deps) {
16260
16421
  }
16261
16422
 
16262
16423
  // src/modules/operations/tools/remove-property.ts
16263
- var inputSchema14 = external_exports.object({
16424
+ var inputSchema15 = external_exports.object({
16264
16425
  name: external_exports.string().optional(),
16265
16426
  path: external_exports.string().optional(),
16266
16427
  key: external_exports.string()
@@ -16271,7 +16432,7 @@ function buildRemovePropertyTool(deps) {
16271
16432
  name: "remove_property",
16272
16433
  title: "Remove Property",
16273
16434
  description: "Remove a frontmatter property from a note. Provide `name` or `path`, plus `key`. Idempotent \u2014 succeeds whether or not the property existed.",
16274
- inputSchema: inputSchema14,
16435
+ inputSchema: inputSchema15,
16275
16436
  handler: async (input) => {
16276
16437
  const identifier = resolveIdentifier(input.name, input.path);
16277
16438
  if (!input.key || input.key.trim() === "") {
@@ -16284,7 +16445,7 @@ function buildRemovePropertyTool(deps) {
16284
16445
  }
16285
16446
 
16286
16447
  // src/modules/operations/tools/set-property.ts
16287
- var inputSchema15 = external_exports.object({
16448
+ var inputSchema16 = external_exports.object({
16288
16449
  name: external_exports.string().optional(),
16289
16450
  path: external_exports.string().optional(),
16290
16451
  key: external_exports.string(),
@@ -16297,7 +16458,7 @@ function buildSetPropertyTool(deps) {
16297
16458
  name: "set_property",
16298
16459
  title: "Set Property",
16299
16460
  description: "Set a frontmatter property on a note. Provide either `name` (wikilink-style) or `path` (vault-relative). `key` is the frontmatter property name (e.g. `status`, `due`). `value` may be string/number/boolean/array \u2014 `type` is inferred from the JS type unless given. For `date`/`datetime` you MUST pass `type` explicitly AND use ISO format (`YYYY-MM-DD` for date, `YYYY-MM-DDTHH:mm:ss[.sss][Z|\xB1HH:mm]` for datetime) \u2014 non-ISO values are silently dropped by obsidian-cli, so this tool rejects them up front. List items must not contain commas (obsidian-cli limitation). Existing properties are overwritten.",
16300
- inputSchema: inputSchema15,
16461
+ inputSchema: inputSchema16,
16301
16462
  handler: async (input) => {
16302
16463
  const identifier = resolveIdentifier(input.name, input.path);
16303
16464
  if (!input.key || input.key.trim() === "") {
@@ -16323,7 +16484,8 @@ function buildOperationsTools(deps) {
16323
16484
  registerTool(buildReadPropertyTool(deps)),
16324
16485
  registerTool(buildRemovePropertyTool(deps)),
16325
16486
  registerTool(buildListPropertiesTool(deps)),
16326
- registerTool(buildListTagsTool(deps))
16487
+ registerTool(buildListTagsTool(deps)),
16488
+ registerTool(buildGetNoteLinksTool(deps))
16327
16489
  ];
16328
16490
  }
16329
16491
 
@@ -16333,7 +16495,8 @@ function createOperationsModule(config2, deps = {}) {
16333
16495
  const readerFactory = deps.vaultReaderFactory ?? ((opts) => new FsVaultReader({ vaultRoot: opts.vaultRoot }));
16334
16496
  const provider = providerFactory({ binaryPath: config2.binaryPath });
16335
16497
  const reader = readerFactory({ vaultRoot: config2.vaultPath });
16336
- const toolDeps = { provider, reader };
16498
+ const graph = deps.graph ?? new WikilinkGraphIndex({ reader });
16499
+ const toolDeps = { provider, reader, graph };
16337
16500
  return { tools: buildOperationsTools(toolDeps) };
16338
16501
  }
16339
16502
 
@@ -16368,7 +16531,7 @@ Use \`read_notes\`, \`create_note\`, \`edit_note\`, \`read_daily\`, \`append_dai
16368
16531
 
16369
16532
  ### Structured queries
16370
16533
 
16371
- Use \`query_notes\` for multi-criteria questions that combine tags, frontmatter properties, and ranges \u2014 for example "active projects with #ai", "todo tasks created this week", "notes with deadline set", "all notes tagged X". The \`filter\` is a MongoDB-style object evaluated against \`{ path, frontmatter, tags }\` \u2014 reference frontmatter keys as \`frontmatter.<key>\` and tags via the top-level \`tags\` field (no leading \`#\`). Supported operators: \`$eq\`, \`$ne\`, \`$in\`, \`$nin\`, \`$gt\`, \`$gte\`, \`$lt\`, \`$lte\`, \`$exists\`, \`$regex\`, \`$and\`, \`$or\`, \`$nor\`, \`$not\`. To list notes by a single tag use \`{ filter: { tags: 'X' } }\`. The result \`{ results, count, truncated }\` includes \`frontmatter\` always; pass \`include_content: true\` only when bodies are needed up-front (it can grow the response a lot). Reads directly from disk; does not need Obsidian running. \`limit\` defaults to 100 and is capped at 1000.
16534
+ Use \`query_notes\` for multi-criteria questions that combine tags, frontmatter properties, and ranges \u2014 for example "active projects with #ai", "todo tasks created this week", "notes with deadline set", "all notes tagged X". The \`filter\` is a MongoDB-style object evaluated against \`{ path, frontmatter, tags, backlink_count }\` \u2014 reference frontmatter keys as \`frontmatter.<key>\`, tags via the top-level \`tags\` field (no leading \`#\`), and \`backlink_count\` as a top-level scalar. Supported operators: \`$eq\`, \`$ne\`, \`$in\`, \`$nin\`, \`$gt\`, \`$gte\`, \`$lt\`, \`$lte\`, \`$exists\`, \`$regex\`, \`$and\`, \`$or\`, \`$nor\`, \`$not\`. To list notes by a single tag use \`{ filter: { tags: 'X' } }\`. \`backlink_count\` is filterable (\`{ backlink_count: { $gte: 5 } }\`) and sortable (\`sort: { field: 'backlink_count', order: 'desc' }\`). The result \`{ results, count, truncated }\` includes \`frontmatter\` and \`backlink_count\` always; pass \`include_content: true\` only when bodies are needed up-front (it can grow the response a lot). Reads directly from disk; does not need Obsidian running. \`limit\` defaults to 100 and is capped at 1000.
16372
16535
 
16373
16536
  ### Frontmatter properties
16374
16537
 
@@ -16382,6 +16545,10 @@ If you need frontmatter for one or more notes, call \`read_notes\` with \`fields
16382
16545
 
16383
16546
  Use \`list_tags\` to see all tags ranked by frequency. To list the notes that carry a specific tag, call \`query_notes\` with \`{ filter: { tags: '<name>' } }\` (no leading \`#\`).
16384
16547
 
16548
+ ### Wikilink graph
16549
+
16550
+ Use \`get_note_links\` for "what links here / where does this point" questions about a single note. It returns the full \`{ incoming, outgoing }\` adjacency from the vault-wide wikilink graph (covers both \`[[X]]\` and \`![[X]]\` embeds, in body and frontmatter). \`outgoing\` entries carry \`resolved: bool\`; unresolved targets are kept verbatim \u2014 useful for surfacing concepts the user has anchored but not yet written. The same graph powers the \`backlink_count\` field on \`search_notes\` and \`query_notes\` results, which is the right signal when you only need ranking by inbound popularity rather than a full edge list.
16551
+
16385
16552
  ### CLI availability
16386
16553
 
16387
16554
  The vault-operations tools (other than \`read_notes\` and \`query_notes\`) route through the Obsidian CLI and require Obsidian to be running. If a call fails with \`CLI_NOT_FOUND\` or \`CLI_UNAVAILABLE\`, tell the user and stop \u2014 do not retry. \`read_notes\` and \`query_notes\` read directly from disk and do not need Obsidian to be running.
@@ -16402,7 +16569,7 @@ Use \`search_notes\` when the user is recalling a topic fuzzily, asking a concep
16402
16569
  - Use \`limit\` to override the default note count in either mode. Widening \`limit\` widens recall.
16403
16570
 
16404
16571
  ### 3. Use the results
16405
- - \`results\` \u2014 notes ranked by similarity; read the file by path
16572
+ - \`results\` \u2014 notes ranked by similarity; read the file by path. Each result also carries \`backlink_count\` (vault-wide inbound wikilinks + embeds) \u2014 useful as a tiebreaker when several results share a similar similarity score.
16406
16573
  - \`matched_queries\` (only when \`query\` is an array) \u2014 which of your queries hit this note; lets you spot which synonym was load-bearing
16407
16574
  - \`truncated\` (only when \`query\` is an array) \u2014 true when unique merged candidates exceeded \`limit\`; widen \`limit\` to see more
16408
16575
  - \`via_expansion: true\` (deep mode only) \u2014 marks results pulled in by post-merge expansion; these have no \`matched_queries\`
@@ -16431,6 +16598,8 @@ async function startNeuroVaultServer(config2, deps = {}) {
16431
16598
  const serverFactory = deps.serverFactory ?? defaultServerFactory;
16432
16599
  const transportFactory = deps.transportFactory ?? defaultTransportFactory;
16433
16600
  const server = serverFactory();
16601
+ const sharedReader = new FsVaultReader({ vaultRoot: config2.vaultPath });
16602
+ const sharedGraph = new WikilinkGraphIndex({ reader: sharedReader });
16434
16603
  const registrations = [];
16435
16604
  let warmup = async () => {
16436
16605
  };
@@ -16442,7 +16611,7 @@ async function startNeuroVaultServer(config2, deps = {}) {
16442
16611
  modelKey: config2.semantic.modelKey,
16443
16612
  modelId: config2.semantic.modelId
16444
16613
  },
16445
- deps.semantic
16614
+ { graph: sharedGraph, ...deps.semantic }
16446
16615
  );
16447
16616
  registrations.push(...semantic.tools);
16448
16617
  warmup = semantic.warmup;
@@ -16453,7 +16622,7 @@ async function startNeuroVaultServer(config2, deps = {}) {
16453
16622
  vaultPath: config2.vaultPath,
16454
16623
  binaryPath: config2.operations.binaryPath
16455
16624
  },
16456
- deps.operations
16625
+ { graph: sharedGraph, ...deps.operations }
16457
16626
  );
16458
16627
  registrations.push(...operations.tools);
16459
16628
  }