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 +1 -1
- package/dist/cli.d.ts +62 -26
- package/dist/cli.js +214 -45
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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
|
|
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 +=
|
|
915
|
-
const slice = paths.slice(i, i +
|
|
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
|
|
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
|
-
} : {
|
|
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(
|
|
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 ??
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
16154
|
-
inputSchema:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
}
|