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 +1 -1
- package/dist/cli.d.ts +62 -26
- package/dist/cli.js +213 -41
- 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) {
|
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
16151
|
-
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,
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
}
|