neuro-vault-mcp 4.0.1 → 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) {
@@ -15571,7 +15694,7 @@ function wrapDependencyError2(error48, message, details) {
15571
15694
  return new ToolHandlerError("DEPENDENCY_ERROR", message, { details, cause: error48 });
15572
15695
  }
15573
15696
  function buildSearchNotesTool(deps) {
15574
- const { sources, embeddingProvider, searchEngine, modelKey, pathExists } = deps;
15697
+ const { sources, embeddingProvider, searchEngine, modelKey, pathExists, graph } = deps;
15575
15698
  return {
15576
15699
  name: "search_notes",
15577
15700
  title: "Search Notes",
@@ -15597,9 +15720,12 @@ function buildSearchNotesTool(deps) {
15597
15720
  ...output.results.map((r) => r.path),
15598
15721
  ...output.blockResults?.map((b) => b.path) ?? []
15599
15722
  ];
15600
- const existing = await buildExistingPathSet2(candidatePaths, pathExists);
15723
+ const [existing] = await Promise.all([
15724
+ buildExistingPathSet2(candidatePaths, pathExists),
15725
+ graph.ensureFresh()
15726
+ ]);
15601
15727
  return {
15602
- 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) })),
15603
15729
  ...output.blockResults !== void 0 ? { blockResults: output.blockResults.filter((b) => existing.has(b.path)) } : {},
15604
15730
  truncated: output.truncated
15605
15731
  };
@@ -15625,9 +15751,12 @@ function buildSearchNotesTool(deps) {
15625
15751
  ...output.results.map((r) => r.path),
15626
15752
  ...output.blockResults?.map((b) => b.path) ?? []
15627
15753
  ];
15628
- const existing = await buildExistingPathSet2(candidatePaths, pathExists);
15754
+ const [existing] = await Promise.all([
15755
+ buildExistingPathSet2(candidatePaths, pathExists),
15756
+ graph.ensureFresh()
15757
+ ]);
15629
15758
  return {
15630
- 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) })),
15631
15760
  ...output.blockResults !== void 0 ? { blockResults: output.blockResults.filter((b) => existing.has(b.path)) } : {}
15632
15761
  };
15633
15762
  } catch (error48) {
@@ -15676,6 +15805,12 @@ async function createSemanticModule(config2, deps = {}) {
15676
15805
  const pathExists = deps.pathExists ?? createDefaultPathExists(config2.vaultPath);
15677
15806
  const readNoteContent = deps.readNoteContent ?? createDefaultReadNoteContent(config2.vaultPath);
15678
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
+ }
15679
15814
  const semanticDeps = {
15680
15815
  sources: corpus.sources,
15681
15816
  embeddingProvider: embeddingService,
@@ -15683,7 +15818,8 @@ async function createSemanticModule(config2, deps = {}) {
15683
15818
  modelKey: config2.modelKey,
15684
15819
  pathExists,
15685
15820
  basenameIndex,
15686
- readNoteContent
15821
+ readNoteContent,
15822
+ graph
15687
15823
  };
15688
15824
  return {
15689
15825
  tools: buildSemanticTools(semanticDeps),
@@ -16100,15 +16236,43 @@ function buildEditNoteTool(deps) {
16100
16236
  };
16101
16237
  }
16102
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
+
16103
16267
  // src/modules/operations/tools/list-properties.ts
16104
- var inputSchema8 = external_exports.object({});
16268
+ var inputSchema9 = external_exports.object({});
16105
16269
  function buildListPropertiesTool(deps) {
16106
16270
  const { provider } = deps;
16107
16271
  return {
16108
16272
  name: "list_properties",
16109
16273
  title: "List Properties",
16110
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.",
16111
- inputSchema: inputSchema8,
16275
+ inputSchema: inputSchema9,
16112
16276
  handler: async (_input) => {
16113
16277
  return provider.listProperties();
16114
16278
  }
@@ -16116,14 +16280,14 @@ function buildListPropertiesTool(deps) {
16116
16280
  }
16117
16281
 
16118
16282
  // src/modules/operations/tools/list-tags.ts
16119
- var inputSchema9 = external_exports.object({});
16283
+ var inputSchema10 = external_exports.object({});
16120
16284
  function buildListTagsTool(deps) {
16121
16285
  const { provider } = deps;
16122
16286
  return {
16123
16287
  name: "list_tags",
16124
16288
  title: "List Tags",
16125
16289
  description: "List all tags used across the vault, sorted by occurrence count desc. Returns `[{name, count}]`.",
16126
- inputSchema: inputSchema9,
16290
+ inputSchema: inputSchema10,
16127
16291
  handler: async (_input) => {
16128
16292
  return provider.listTags();
16129
16293
  }
@@ -16135,7 +16299,7 @@ var queryNotesSortSchema = external_exports.object({
16135
16299
  field: external_exports.string().min(1),
16136
16300
  order: external_exports.enum(["asc", "desc"])
16137
16301
  });
16138
- var inputSchema10 = external_exports.object({
16302
+ var inputSchema11 = external_exports.object({
16139
16303
  filter: external_exports.record(external_exports.string(), external_exports.unknown()),
16140
16304
  path_prefix: external_exports.string().optional(),
16141
16305
  sort: queryNotesSortSchema.optional(),
@@ -16143,27 +16307,27 @@ var inputSchema10 = external_exports.object({
16143
16307
  include_content: external_exports.boolean().optional()
16144
16308
  });
16145
16309
  function buildQueryNotesTool(deps) {
16146
- const { reader } = deps;
16310
+ const { reader, graph } = deps;
16147
16311
  return {
16148
16312
  name: "query_notes",
16149
16313
  title: "Query Notes",
16150
- 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.',
16151
- 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,
16152
16316
  handler: async (input) => {
16153
- return runQueryNotes(input, reader);
16317
+ return runQueryNotes(input, reader, graph);
16154
16318
  }
16155
16319
  };
16156
16320
  }
16157
16321
 
16158
16322
  // src/modules/operations/tools/read-daily.ts
16159
- var inputSchema11 = external_exports.object({});
16323
+ var inputSchema12 = external_exports.object({});
16160
16324
  function buildReadDailyTool(deps) {
16161
16325
  const { provider } = deps;
16162
16326
  return {
16163
16327
  name: "read_daily",
16164
16328
  title: "Read Daily",
16165
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.",
16166
- inputSchema: inputSchema11,
16330
+ inputSchema: inputSchema12,
16167
16331
  handler: async (_input) => {
16168
16332
  return provider.readDaily();
16169
16333
  }
@@ -16172,7 +16336,7 @@ function buildReadDailyTool(deps) {
16172
16336
 
16173
16337
  // src/modules/operations/tools/read-notes.ts
16174
16338
  var readNotesFieldSchema = external_exports.enum(["frontmatter", "content"]);
16175
- var inputSchema12 = external_exports.object({
16339
+ var inputSchema13 = external_exports.object({
16176
16340
  paths: external_exports.union([external_exports.string().min(1), external_exports.array(external_exports.string()).min(1).max(50)]),
16177
16341
  fields: external_exports.array(readNotesFieldSchema).min(1).optional()
16178
16342
  });
@@ -16182,7 +16346,7 @@ function buildReadNotesTool(deps) {
16182
16346
  name: "read_notes",
16183
16347
  title: "Read Notes",
16184
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.",
16185
- inputSchema: inputSchema12,
16349
+ inputSchema: inputSchema13,
16186
16350
  handler: async (input) => {
16187
16351
  const { paths, fields } = validateReadNotesInput(input);
16188
16352
  const seen = /* @__PURE__ */ new Set();
@@ -16234,7 +16398,7 @@ function buildReadNotesTool(deps) {
16234
16398
  }
16235
16399
 
16236
16400
  // src/modules/operations/tools/read-property.ts
16237
- var inputSchema13 = external_exports.object({
16401
+ var inputSchema14 = external_exports.object({
16238
16402
  name: external_exports.string().optional(),
16239
16403
  path: external_exports.string().optional(),
16240
16404
  key: external_exports.string()
@@ -16245,7 +16409,7 @@ function buildReadPropertyTool(deps) {
16245
16409
  name: "read_property",
16246
16410
  title: "Read Property",
16247
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.",
16248
- inputSchema: inputSchema13,
16412
+ inputSchema: inputSchema14,
16249
16413
  handler: async (input) => {
16250
16414
  const identifier = resolveIdentifier(input.name, input.path);
16251
16415
  if (!input.key || input.key.trim() === "") {
@@ -16257,7 +16421,7 @@ function buildReadPropertyTool(deps) {
16257
16421
  }
16258
16422
 
16259
16423
  // src/modules/operations/tools/remove-property.ts
16260
- var inputSchema14 = external_exports.object({
16424
+ var inputSchema15 = external_exports.object({
16261
16425
  name: external_exports.string().optional(),
16262
16426
  path: external_exports.string().optional(),
16263
16427
  key: external_exports.string()
@@ -16268,7 +16432,7 @@ function buildRemovePropertyTool(deps) {
16268
16432
  name: "remove_property",
16269
16433
  title: "Remove Property",
16270
16434
  description: "Remove a frontmatter property from a note. Provide `name` or `path`, plus `key`. Idempotent \u2014 succeeds whether or not the property existed.",
16271
- inputSchema: inputSchema14,
16435
+ inputSchema: inputSchema15,
16272
16436
  handler: async (input) => {
16273
16437
  const identifier = resolveIdentifier(input.name, input.path);
16274
16438
  if (!input.key || input.key.trim() === "") {
@@ -16281,7 +16445,7 @@ function buildRemovePropertyTool(deps) {
16281
16445
  }
16282
16446
 
16283
16447
  // src/modules/operations/tools/set-property.ts
16284
- var inputSchema15 = external_exports.object({
16448
+ var inputSchema16 = external_exports.object({
16285
16449
  name: external_exports.string().optional(),
16286
16450
  path: external_exports.string().optional(),
16287
16451
  key: external_exports.string(),
@@ -16294,7 +16458,7 @@ function buildSetPropertyTool(deps) {
16294
16458
  name: "set_property",
16295
16459
  title: "Set Property",
16296
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.",
16297
- inputSchema: inputSchema15,
16461
+ inputSchema: inputSchema16,
16298
16462
  handler: async (input) => {
16299
16463
  const identifier = resolveIdentifier(input.name, input.path);
16300
16464
  if (!input.key || input.key.trim() === "") {
@@ -16320,7 +16484,8 @@ function buildOperationsTools(deps) {
16320
16484
  registerTool(buildReadPropertyTool(deps)),
16321
16485
  registerTool(buildRemovePropertyTool(deps)),
16322
16486
  registerTool(buildListPropertiesTool(deps)),
16323
- registerTool(buildListTagsTool(deps))
16487
+ registerTool(buildListTagsTool(deps)),
16488
+ registerTool(buildGetNoteLinksTool(deps))
16324
16489
  ];
16325
16490
  }
16326
16491
 
@@ -16330,7 +16495,8 @@ function createOperationsModule(config2, deps = {}) {
16330
16495
  const readerFactory = deps.vaultReaderFactory ?? ((opts) => new FsVaultReader({ vaultRoot: opts.vaultRoot }));
16331
16496
  const provider = providerFactory({ binaryPath: config2.binaryPath });
16332
16497
  const reader = readerFactory({ vaultRoot: config2.vaultPath });
16333
- const toolDeps = { provider, reader };
16498
+ const graph = deps.graph ?? new WikilinkGraphIndex({ reader });
16499
+ const toolDeps = { provider, reader, graph };
16334
16500
  return { tools: buildOperationsTools(toolDeps) };
16335
16501
  }
16336
16502
 
@@ -16365,7 +16531,7 @@ Use \`read_notes\`, \`create_note\`, \`edit_note\`, \`read_daily\`, \`append_dai
16365
16531
 
16366
16532
  ### Structured queries
16367
16533
 
16368
- 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.
16369
16535
 
16370
16536
  ### Frontmatter properties
16371
16537
 
@@ -16379,6 +16545,10 @@ If you need frontmatter for one or more notes, call \`read_notes\` with \`fields
16379
16545
 
16380
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 \`#\`).
16381
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
+
16382
16552
  ### CLI availability
16383
16553
 
16384
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.
@@ -16399,7 +16569,7 @@ Use \`search_notes\` when the user is recalling a topic fuzzily, asking a concep
16399
16569
  - Use \`limit\` to override the default note count in either mode. Widening \`limit\` widens recall.
16400
16570
 
16401
16571
  ### 3. Use the results
16402
- - \`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.
16403
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
16404
16574
  - \`truncated\` (only when \`query\` is an array) \u2014 true when unique merged candidates exceeded \`limit\`; widen \`limit\` to see more
16405
16575
  - \`via_expansion: true\` (deep mode only) \u2014 marks results pulled in by post-merge expansion; these have no \`matched_queries\`
@@ -16428,6 +16598,8 @@ async function startNeuroVaultServer(config2, deps = {}) {
16428
16598
  const serverFactory = deps.serverFactory ?? defaultServerFactory;
16429
16599
  const transportFactory = deps.transportFactory ?? defaultTransportFactory;
16430
16600
  const server = serverFactory();
16601
+ const sharedReader = new FsVaultReader({ vaultRoot: config2.vaultPath });
16602
+ const sharedGraph = new WikilinkGraphIndex({ reader: sharedReader });
16431
16603
  const registrations = [];
16432
16604
  let warmup = async () => {
16433
16605
  };
@@ -16439,7 +16611,7 @@ async function startNeuroVaultServer(config2, deps = {}) {
16439
16611
  modelKey: config2.semantic.modelKey,
16440
16612
  modelId: config2.semantic.modelId
16441
16613
  },
16442
- deps.semantic
16614
+ { graph: sharedGraph, ...deps.semantic }
16443
16615
  );
16444
16616
  registrations.push(...semantic.tools);
16445
16617
  warmup = semantic.warmup;
@@ -16450,7 +16622,7 @@ async function startNeuroVaultServer(config2, deps = {}) {
16450
16622
  vaultPath: config2.vaultPath,
16451
16623
  binaryPath: config2.operations.binaryPath
16452
16624
  },
16453
- deps.operations
16625
+ { graph: sharedGraph, ...deps.operations }
16454
16626
  );
16455
16627
  registrations.push(...operations.tools);
16456
16628
  }