flywheel-mcp 1.23.1
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/LICENSE +201 -0
- package/README.md +223 -0
- package/dist/CLAUDE.md +11 -0
- package/dist/index.js +4893 -0
- package/package.json +66 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4893 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/core/vault.ts
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
|
|
11
|
+
".obsidian",
|
|
12
|
+
".trash",
|
|
13
|
+
".git",
|
|
14
|
+
"node_modules"
|
|
15
|
+
]);
|
|
16
|
+
var WINDOWS_MAX_PATH = 260;
|
|
17
|
+
var EMOJI_REGEX = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F1E0}-\u{1F1FF}]/u;
|
|
18
|
+
function isValidPath(fullPath, fileName) {
|
|
19
|
+
if (EMOJI_REGEX.test(fileName)) {
|
|
20
|
+
return { valid: false, reason: "contains emoji characters" };
|
|
21
|
+
}
|
|
22
|
+
if (fullPath.length >= WINDOWS_MAX_PATH) {
|
|
23
|
+
return { valid: false, reason: `path length ${fullPath.length} exceeds Windows limit of ${WINDOWS_MAX_PATH}` };
|
|
24
|
+
}
|
|
25
|
+
return { valid: true };
|
|
26
|
+
}
|
|
27
|
+
async function scanVault(vaultPath2) {
|
|
28
|
+
const files = [];
|
|
29
|
+
async function scan(dir, relativePath = "") {
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(`Warning: Could not read directory ${dir}:`, err);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const fullPath = path.join(dir, entry.name);
|
|
39
|
+
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
40
|
+
if (entry.isDirectory()) {
|
|
41
|
+
if (EXCLUDED_DIRS.has(entry.name)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
await scan(fullPath, relPath);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error(`Warning: Could not scan directory ${fullPath}:`, err);
|
|
48
|
+
}
|
|
49
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
50
|
+
const validation = isValidPath(fullPath, entry.name);
|
|
51
|
+
if (!validation.valid) {
|
|
52
|
+
console.warn(`Skipping ${relPath}: ${validation.reason}`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const stats = await fs.promises.stat(fullPath);
|
|
57
|
+
files.push({
|
|
58
|
+
path: relPath.replace(/\\/g, "/"),
|
|
59
|
+
// Normalize to forward slashes
|
|
60
|
+
absolutePath: fullPath,
|
|
61
|
+
modified: stats.mtime,
|
|
62
|
+
created: stats.birthtime
|
|
63
|
+
});
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(`Warning: Could not stat ${fullPath}:`, err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await scan(vaultPath2);
|
|
71
|
+
return files;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/core/parser.ts
|
|
75
|
+
import * as fs2 from "fs";
|
|
76
|
+
import matter from "gray-matter";
|
|
77
|
+
var MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
78
|
+
function isBinaryContent(content) {
|
|
79
|
+
const nullBytes = (content.match(/\x00/g) || []).length;
|
|
80
|
+
if (nullBytes > 0) return true;
|
|
81
|
+
const sample = content.slice(0, 1e3);
|
|
82
|
+
const nonPrintable = sample.replace(/[\x20-\x7E\t\n\r]/g, "").length;
|
|
83
|
+
return nonPrintable / sample.length > 0.1;
|
|
84
|
+
}
|
|
85
|
+
var WIKILINK_REGEX = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g;
|
|
86
|
+
var TAG_REGEX = /(?:^|\s)#([a-zA-Z][a-zA-Z0-9_/-]*)/g;
|
|
87
|
+
var CODE_BLOCK_REGEX = /```[\s\S]*?```|`[^`\n]+`/g;
|
|
88
|
+
function extractWikilinks(content) {
|
|
89
|
+
const links = [];
|
|
90
|
+
const contentWithoutCode = content.replace(CODE_BLOCK_REGEX, (match) => " ".repeat(match.length));
|
|
91
|
+
const lines = contentWithoutCode.split("\n");
|
|
92
|
+
let charIndex = 0;
|
|
93
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
94
|
+
const line = lines[lineNum];
|
|
95
|
+
let match;
|
|
96
|
+
WIKILINK_REGEX.lastIndex = 0;
|
|
97
|
+
while ((match = WIKILINK_REGEX.exec(line)) !== null) {
|
|
98
|
+
const target = match[1].trim();
|
|
99
|
+
const alias = match[2]?.trim();
|
|
100
|
+
if (target) {
|
|
101
|
+
links.push({
|
|
102
|
+
target,
|
|
103
|
+
alias,
|
|
104
|
+
line: lineNum + 1
|
|
105
|
+
// 1-indexed
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
charIndex += line.length + 1;
|
|
110
|
+
}
|
|
111
|
+
return links;
|
|
112
|
+
}
|
|
113
|
+
function extractTags(content, frontmatter) {
|
|
114
|
+
const tags = /* @__PURE__ */ new Set();
|
|
115
|
+
const fmTags = frontmatter.tags;
|
|
116
|
+
if (Array.isArray(fmTags)) {
|
|
117
|
+
for (const tag of fmTags) {
|
|
118
|
+
if (typeof tag === "string") {
|
|
119
|
+
tags.add(tag.replace(/^#/, ""));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} else if (typeof fmTags === "string") {
|
|
123
|
+
tags.add(fmTags.replace(/^#/, ""));
|
|
124
|
+
}
|
|
125
|
+
const contentWithoutCode = content.replace(CODE_BLOCK_REGEX, "");
|
|
126
|
+
let match;
|
|
127
|
+
TAG_REGEX.lastIndex = 0;
|
|
128
|
+
while ((match = TAG_REGEX.exec(contentWithoutCode)) !== null) {
|
|
129
|
+
tags.add(match[1]);
|
|
130
|
+
}
|
|
131
|
+
return Array.from(tags);
|
|
132
|
+
}
|
|
133
|
+
function extractAliases(frontmatter) {
|
|
134
|
+
const aliases = frontmatter.aliases;
|
|
135
|
+
if (Array.isArray(aliases)) {
|
|
136
|
+
return aliases.filter((a) => typeof a === "string");
|
|
137
|
+
}
|
|
138
|
+
if (typeof aliases === "string") {
|
|
139
|
+
return [aliases];
|
|
140
|
+
}
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
async function parseNote(file) {
|
|
144
|
+
const result = await parseNoteWithWarnings(file);
|
|
145
|
+
if (result.skipped) {
|
|
146
|
+
throw new Error(result.skipReason || "File skipped");
|
|
147
|
+
}
|
|
148
|
+
if (result.warnings.length > 0) {
|
|
149
|
+
for (const warning of result.warnings) {
|
|
150
|
+
console.error(`Warning [${file.path}]: ${warning}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return result.note;
|
|
154
|
+
}
|
|
155
|
+
async function parseNoteWithWarnings(file) {
|
|
156
|
+
const warnings = [];
|
|
157
|
+
try {
|
|
158
|
+
const stats = await fs2.promises.stat(file.absolutePath);
|
|
159
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
160
|
+
return {
|
|
161
|
+
note: createEmptyNote(file),
|
|
162
|
+
warnings: [],
|
|
163
|
+
skipped: true,
|
|
164
|
+
skipReason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB limit)`
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
}
|
|
169
|
+
let content;
|
|
170
|
+
try {
|
|
171
|
+
content = await fs2.promises.readFile(file.absolutePath, "utf-8");
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return {
|
|
174
|
+
note: createEmptyNote(file),
|
|
175
|
+
warnings: [],
|
|
176
|
+
skipped: true,
|
|
177
|
+
skipReason: `Could not read file: ${err instanceof Error ? err.message : String(err)}`
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (content.trim().length === 0) {
|
|
181
|
+
return {
|
|
182
|
+
note: createEmptyNote(file),
|
|
183
|
+
warnings: ["Empty file"],
|
|
184
|
+
skipped: false
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (isBinaryContent(content)) {
|
|
188
|
+
return {
|
|
189
|
+
note: createEmptyNote(file),
|
|
190
|
+
warnings: [],
|
|
191
|
+
skipped: true,
|
|
192
|
+
skipReason: "Binary content detected"
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
let frontmatter = {};
|
|
196
|
+
let markdown = content;
|
|
197
|
+
try {
|
|
198
|
+
const parsed = matter(content);
|
|
199
|
+
frontmatter = parsed.data;
|
|
200
|
+
markdown = parsed.content;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
warnings.push(`Malformed frontmatter: ${err instanceof Error ? err.message : String(err)}`);
|
|
203
|
+
}
|
|
204
|
+
const title = file.path.replace(/\.md$/, "").split("/").pop() || file.path;
|
|
205
|
+
return {
|
|
206
|
+
note: {
|
|
207
|
+
path: file.path,
|
|
208
|
+
title,
|
|
209
|
+
aliases: extractAliases(frontmatter),
|
|
210
|
+
frontmatter,
|
|
211
|
+
outlinks: extractWikilinks(markdown),
|
|
212
|
+
tags: extractTags(markdown, frontmatter),
|
|
213
|
+
modified: file.modified,
|
|
214
|
+
created: file.created
|
|
215
|
+
},
|
|
216
|
+
warnings,
|
|
217
|
+
skipped: false
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function createEmptyNote(file) {
|
|
221
|
+
const title = file.path.replace(/\.md$/, "").split("/").pop() || file.path;
|
|
222
|
+
return {
|
|
223
|
+
path: file.path,
|
|
224
|
+
title,
|
|
225
|
+
aliases: [],
|
|
226
|
+
frontmatter: {},
|
|
227
|
+
outlinks: [],
|
|
228
|
+
tags: [],
|
|
229
|
+
modified: file.modified,
|
|
230
|
+
created: file.created
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/core/graph.ts
|
|
235
|
+
var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
236
|
+
var PARSE_CONCURRENCY = 50;
|
|
237
|
+
var PROGRESS_INTERVAL = 100;
|
|
238
|
+
var indexState = "building";
|
|
239
|
+
var indexProgress = { parsed: 0, total: 0 };
|
|
240
|
+
var indexError = null;
|
|
241
|
+
function getIndexState() {
|
|
242
|
+
return indexState;
|
|
243
|
+
}
|
|
244
|
+
function getIndexProgress() {
|
|
245
|
+
return { ...indexProgress };
|
|
246
|
+
}
|
|
247
|
+
function getIndexError() {
|
|
248
|
+
return indexError;
|
|
249
|
+
}
|
|
250
|
+
function setIndexState(state) {
|
|
251
|
+
indexState = state;
|
|
252
|
+
}
|
|
253
|
+
function setIndexError(error) {
|
|
254
|
+
indexError = error;
|
|
255
|
+
}
|
|
256
|
+
function updateIndexProgress(parsed, total) {
|
|
257
|
+
indexProgress = { parsed, total };
|
|
258
|
+
}
|
|
259
|
+
function normalizeTarget(target) {
|
|
260
|
+
return target.toLowerCase().replace(/\.md$/, "");
|
|
261
|
+
}
|
|
262
|
+
function normalizeNotePath(path11) {
|
|
263
|
+
return path11.toLowerCase().replace(/\.md$/, "");
|
|
264
|
+
}
|
|
265
|
+
async function buildVaultIndex(vaultPath2, options = {}) {
|
|
266
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, onProgress } = options;
|
|
267
|
+
console.error(`Scanning vault: ${vaultPath2}`);
|
|
268
|
+
const startTime = Date.now();
|
|
269
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
270
|
+
setTimeout(() => {
|
|
271
|
+
reject(new Error(`Vault indexing timed out after ${timeoutMs / 1e3}s`));
|
|
272
|
+
}, timeoutMs);
|
|
273
|
+
});
|
|
274
|
+
return Promise.race([
|
|
275
|
+
buildVaultIndexInternal(vaultPath2, startTime, onProgress),
|
|
276
|
+
timeoutPromise
|
|
277
|
+
]);
|
|
278
|
+
}
|
|
279
|
+
async function buildVaultIndexInternal(vaultPath2, startTime, onProgress) {
|
|
280
|
+
const files = await scanVault(vaultPath2);
|
|
281
|
+
console.error(`Found ${files.length} markdown files`);
|
|
282
|
+
updateIndexProgress(0, files.length);
|
|
283
|
+
const notes = /* @__PURE__ */ new Map();
|
|
284
|
+
const parseErrors = [];
|
|
285
|
+
let parsedCount = 0;
|
|
286
|
+
for (let i = 0; i < files.length; i += PARSE_CONCURRENCY) {
|
|
287
|
+
const batch = files.slice(i, i + PARSE_CONCURRENCY);
|
|
288
|
+
const results = await Promise.allSettled(
|
|
289
|
+
batch.map(async (file) => {
|
|
290
|
+
const note = await parseNote(file);
|
|
291
|
+
return { file, note };
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
for (const result of results) {
|
|
295
|
+
if (result.status === "fulfilled") {
|
|
296
|
+
notes.set(result.value.note.path, result.value.note);
|
|
297
|
+
} else {
|
|
298
|
+
const batchIndex = results.indexOf(result);
|
|
299
|
+
if (batchIndex >= 0 && batch[batchIndex]) {
|
|
300
|
+
parseErrors.push(batch[batchIndex].path);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
parsedCount++;
|
|
304
|
+
}
|
|
305
|
+
updateIndexProgress(parsedCount, files.length);
|
|
306
|
+
if (parsedCount % PROGRESS_INTERVAL === 0 || parsedCount === files.length) {
|
|
307
|
+
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
308
|
+
console.error(`Parsed ${parsedCount}/${files.length} files (${elapsed}s)`);
|
|
309
|
+
onProgress?.(parsedCount, files.length);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (parseErrors.length > 0) {
|
|
313
|
+
console.error(`Failed to parse ${parseErrors.length} files`);
|
|
314
|
+
}
|
|
315
|
+
const entities = /* @__PURE__ */ new Map();
|
|
316
|
+
for (const note of notes.values()) {
|
|
317
|
+
const normalizedTitle = normalizeTarget(note.title);
|
|
318
|
+
if (!entities.has(normalizedTitle)) {
|
|
319
|
+
entities.set(normalizedTitle, note.path);
|
|
320
|
+
}
|
|
321
|
+
const normalizedPath = normalizeNotePath(note.path);
|
|
322
|
+
entities.set(normalizedPath, note.path);
|
|
323
|
+
for (const alias of note.aliases) {
|
|
324
|
+
const normalizedAlias = normalizeTarget(alias);
|
|
325
|
+
if (!entities.has(normalizedAlias)) {
|
|
326
|
+
entities.set(normalizedAlias, note.path);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const backlinks = /* @__PURE__ */ new Map();
|
|
331
|
+
for (const note of notes.values()) {
|
|
332
|
+
for (const link of note.outlinks) {
|
|
333
|
+
const normalizedTarget = normalizeTarget(link.target);
|
|
334
|
+
const targetPath = entities.get(normalizedTarget);
|
|
335
|
+
const key = targetPath ? normalizeNotePath(targetPath) : normalizedTarget;
|
|
336
|
+
if (!backlinks.has(key)) {
|
|
337
|
+
backlinks.set(key, []);
|
|
338
|
+
}
|
|
339
|
+
backlinks.get(key).push({
|
|
340
|
+
source: note.path,
|
|
341
|
+
line: link.line
|
|
342
|
+
// Context will be loaded on-demand to save memory
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const tags = /* @__PURE__ */ new Map();
|
|
347
|
+
for (const note of notes.values()) {
|
|
348
|
+
for (const tag of note.tags) {
|
|
349
|
+
if (!tags.has(tag)) {
|
|
350
|
+
tags.set(tag, /* @__PURE__ */ new Set());
|
|
351
|
+
}
|
|
352
|
+
tags.get(tag).add(note.path);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
console.error(`Index built: ${notes.size} notes, ${entities.size} entities, ${backlinks.size} link targets, ${tags.size} tags`);
|
|
356
|
+
return {
|
|
357
|
+
notes,
|
|
358
|
+
backlinks,
|
|
359
|
+
entities,
|
|
360
|
+
tags,
|
|
361
|
+
builtAt: /* @__PURE__ */ new Date()
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function resolveTarget(index, target) {
|
|
365
|
+
const normalized = normalizeTarget(target);
|
|
366
|
+
return index.entities.get(normalized);
|
|
367
|
+
}
|
|
368
|
+
function getBacklinksForNote(index, notePath) {
|
|
369
|
+
const normalized = normalizeNotePath(notePath);
|
|
370
|
+
return index.backlinks.get(normalized) || [];
|
|
371
|
+
}
|
|
372
|
+
function getForwardLinksForNote(index, notePath) {
|
|
373
|
+
const note = index.notes.get(notePath);
|
|
374
|
+
if (!note) return [];
|
|
375
|
+
return note.outlinks.map((link) => {
|
|
376
|
+
const resolvedPath = resolveTarget(index, link.target);
|
|
377
|
+
return {
|
|
378
|
+
target: link.target,
|
|
379
|
+
alias: link.alias,
|
|
380
|
+
line: link.line,
|
|
381
|
+
resolvedPath,
|
|
382
|
+
exists: resolvedPath !== void 0
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
function findOrphanNotes(index, folder) {
|
|
387
|
+
const orphans = [];
|
|
388
|
+
for (const note of index.notes.values()) {
|
|
389
|
+
if (folder && !note.path.startsWith(folder)) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const backlinks = getBacklinksForNote(index, note.path);
|
|
393
|
+
if (backlinks.length === 0) {
|
|
394
|
+
orphans.push({
|
|
395
|
+
path: note.path,
|
|
396
|
+
title: note.title,
|
|
397
|
+
modified: note.modified
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return orphans.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
402
|
+
}
|
|
403
|
+
function findHubNotes(index, minLinks = 5) {
|
|
404
|
+
const hubs = [];
|
|
405
|
+
for (const note of index.notes.values()) {
|
|
406
|
+
const backlinkCount = getBacklinksForNote(index, note.path).length;
|
|
407
|
+
const forwardLinkCount = note.outlinks.length;
|
|
408
|
+
const totalConnections = backlinkCount + forwardLinkCount;
|
|
409
|
+
if (totalConnections >= minLinks) {
|
|
410
|
+
hubs.push({
|
|
411
|
+
path: note.path,
|
|
412
|
+
title: note.title,
|
|
413
|
+
backlink_count: backlinkCount,
|
|
414
|
+
forward_link_count: forwardLinkCount,
|
|
415
|
+
total_connections: totalConnections
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return hubs.sort((a, b) => b.total_connections - a.total_connections);
|
|
420
|
+
}
|
|
421
|
+
function levenshteinDistance(a, b) {
|
|
422
|
+
if (a.length === 0) return b.length;
|
|
423
|
+
if (b.length === 0) return a.length;
|
|
424
|
+
const matrix = [];
|
|
425
|
+
for (let i = 0; i <= b.length; i++) {
|
|
426
|
+
matrix[i] = [i];
|
|
427
|
+
}
|
|
428
|
+
for (let j = 0; j <= a.length; j++) {
|
|
429
|
+
matrix[0][j] = j;
|
|
430
|
+
}
|
|
431
|
+
for (let i = 1; i <= b.length; i++) {
|
|
432
|
+
for (let j = 1; j <= a.length; j++) {
|
|
433
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
434
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
435
|
+
} else {
|
|
436
|
+
matrix[i][j] = Math.min(
|
|
437
|
+
matrix[i - 1][j - 1] + 1,
|
|
438
|
+
// substitution
|
|
439
|
+
matrix[i][j - 1] + 1,
|
|
440
|
+
// insertion
|
|
441
|
+
matrix[i - 1][j] + 1
|
|
442
|
+
// deletion
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return matrix[b.length][a.length];
|
|
448
|
+
}
|
|
449
|
+
function findSimilarEntity(index, target) {
|
|
450
|
+
const normalized = normalizeTarget(target);
|
|
451
|
+
const normalizedLen = normalized.length;
|
|
452
|
+
if (normalizedLen <= 3) {
|
|
453
|
+
return void 0;
|
|
454
|
+
}
|
|
455
|
+
const maxDist = normalizedLen <= 10 ? 1 : 2;
|
|
456
|
+
let bestMatch;
|
|
457
|
+
for (const [entity, path11] of index.entities) {
|
|
458
|
+
const lenDiff = Math.abs(entity.length - normalizedLen);
|
|
459
|
+
if (lenDiff > maxDist) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const dist = levenshteinDistance(normalized, entity);
|
|
463
|
+
if (dist > 0 && dist <= maxDist) {
|
|
464
|
+
if (!bestMatch || dist < bestMatch.distance) {
|
|
465
|
+
bestMatch = { path: path11, entity, distance: dist };
|
|
466
|
+
if (dist === 1) {
|
|
467
|
+
return bestMatch;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return bestMatch;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/tools/graph.ts
|
|
476
|
+
import * as fs3 from "fs";
|
|
477
|
+
import * as path2 from "path";
|
|
478
|
+
import { z } from "zod";
|
|
479
|
+
|
|
480
|
+
// src/core/constants.ts
|
|
481
|
+
var MAX_LIMIT = 200;
|
|
482
|
+
|
|
483
|
+
// src/core/indexGuard.ts
|
|
484
|
+
function requireIndex() {
|
|
485
|
+
const state = getIndexState();
|
|
486
|
+
if (state === "building") {
|
|
487
|
+
const { parsed, total } = getIndexProgress();
|
|
488
|
+
const progress = total > 0 ? ` (${parsed}/${total} files)` : "";
|
|
489
|
+
throw new Error(`Index building${progress}... try again shortly`);
|
|
490
|
+
}
|
|
491
|
+
if (state === "error") {
|
|
492
|
+
const error = getIndexError();
|
|
493
|
+
throw new Error(`Index failed to build: ${error?.message || "unknown error"}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// src/tools/graph.ts
|
|
498
|
+
async function getContext(vaultPath2, sourcePath, line, contextLines = 1) {
|
|
499
|
+
try {
|
|
500
|
+
const fullPath = path2.join(vaultPath2, sourcePath);
|
|
501
|
+
const content = await fs3.promises.readFile(fullPath, "utf-8");
|
|
502
|
+
const lines = content.split("\n");
|
|
503
|
+
const startLine = Math.max(0, line - 1 - contextLines);
|
|
504
|
+
const endLine = Math.min(lines.length, line + contextLines);
|
|
505
|
+
return lines.slice(startLine, endLine).join("\n").trim();
|
|
506
|
+
} catch {
|
|
507
|
+
return "";
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
var BacklinkItemSchema = z.object({
|
|
511
|
+
source: z.string().describe("Path of the note containing the link"),
|
|
512
|
+
line: z.number().describe("Line number where the link appears"),
|
|
513
|
+
context: z.string().optional().describe("Surrounding text for context")
|
|
514
|
+
});
|
|
515
|
+
var GetBacklinksOutputSchema = {
|
|
516
|
+
note: z.string().describe("The resolved note path"),
|
|
517
|
+
backlink_count: z.number().describe("Total number of backlinks found"),
|
|
518
|
+
returned_count: z.number().describe("Number of backlinks returned (may be limited)"),
|
|
519
|
+
backlinks: z.array(BacklinkItemSchema).describe("List of backlinks")
|
|
520
|
+
};
|
|
521
|
+
function registerGraphTools(server2, getIndex, getVaultPath) {
|
|
522
|
+
server2.registerTool(
|
|
523
|
+
"get_backlinks",
|
|
524
|
+
{
|
|
525
|
+
title: "Get Backlinks",
|
|
526
|
+
description: "Get all notes that link TO the specified note. Returns the source file paths and line numbers where links appear.",
|
|
527
|
+
inputSchema: {
|
|
528
|
+
path: z.string().describe('Path to the note (e.g., "daily/2024-01-15.md" or just "My Note")'),
|
|
529
|
+
include_context: z.boolean().default(true).describe("Include surrounding text for context"),
|
|
530
|
+
limit: z.number().default(50).describe("Maximum number of results to return"),
|
|
531
|
+
offset: z.number().default(0).describe("Number of results to skip (for pagination)")
|
|
532
|
+
},
|
|
533
|
+
outputSchema: GetBacklinksOutputSchema
|
|
534
|
+
},
|
|
535
|
+
async ({ path: notePath, include_context, limit: requestedLimit, offset }) => {
|
|
536
|
+
requireIndex();
|
|
537
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
538
|
+
const index = getIndex();
|
|
539
|
+
const vaultPath2 = getVaultPath();
|
|
540
|
+
let resolvedPath = notePath;
|
|
541
|
+
if (!notePath.endsWith(".md")) {
|
|
542
|
+
const resolved = resolveTarget(index, notePath);
|
|
543
|
+
if (resolved) {
|
|
544
|
+
resolvedPath = resolved;
|
|
545
|
+
} else {
|
|
546
|
+
resolvedPath = notePath + ".md";
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const allBacklinks = getBacklinksForNote(index, resolvedPath);
|
|
550
|
+
const backlinks = allBacklinks.slice(offset, offset + limit);
|
|
551
|
+
const results = await Promise.all(
|
|
552
|
+
backlinks.map(async (bl) => {
|
|
553
|
+
const result = {
|
|
554
|
+
source: bl.source,
|
|
555
|
+
line: bl.line
|
|
556
|
+
};
|
|
557
|
+
if (include_context) {
|
|
558
|
+
result.context = await getContext(vaultPath2, bl.source, bl.line);
|
|
559
|
+
}
|
|
560
|
+
return result;
|
|
561
|
+
})
|
|
562
|
+
);
|
|
563
|
+
const output = {
|
|
564
|
+
note: resolvedPath,
|
|
565
|
+
backlink_count: allBacklinks.length,
|
|
566
|
+
returned_count: results.length,
|
|
567
|
+
backlinks: results
|
|
568
|
+
};
|
|
569
|
+
return {
|
|
570
|
+
content: [
|
|
571
|
+
{
|
|
572
|
+
type: "text",
|
|
573
|
+
text: JSON.stringify(output, null, 2)
|
|
574
|
+
}
|
|
575
|
+
],
|
|
576
|
+
structuredContent: output
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
);
|
|
580
|
+
const ForwardLinkItemSchema = z.object({
|
|
581
|
+
target: z.string().describe("The link target as written"),
|
|
582
|
+
alias: z.string().optional().describe("Display text if using [[target|alias]] syntax"),
|
|
583
|
+
line: z.number().describe("Line number where the link appears"),
|
|
584
|
+
resolved_path: z.string().optional().describe("Resolved path if target exists"),
|
|
585
|
+
exists: z.boolean().describe("Whether the target note exists")
|
|
586
|
+
});
|
|
587
|
+
const GetForwardLinksOutputSchema = {
|
|
588
|
+
note: z.string().describe("The source note path"),
|
|
589
|
+
forward_link_count: z.number().describe("Total number of forward links"),
|
|
590
|
+
forward_links: z.array(ForwardLinkItemSchema).describe("List of forward links")
|
|
591
|
+
};
|
|
592
|
+
server2.registerTool(
|
|
593
|
+
"get_forward_links",
|
|
594
|
+
{
|
|
595
|
+
title: "Get Forward Links",
|
|
596
|
+
description: "Get all notes that this note links TO. Returns the target paths and whether they exist.",
|
|
597
|
+
inputSchema: {
|
|
598
|
+
path: z.string().describe('Path to the note (e.g., "daily/2024-01-15.md" or just "My Note")')
|
|
599
|
+
},
|
|
600
|
+
outputSchema: GetForwardLinksOutputSchema
|
|
601
|
+
},
|
|
602
|
+
async ({ path: notePath }) => {
|
|
603
|
+
requireIndex();
|
|
604
|
+
const index = getIndex();
|
|
605
|
+
let resolvedPath = notePath;
|
|
606
|
+
if (!notePath.endsWith(".md")) {
|
|
607
|
+
const resolved = resolveTarget(index, notePath);
|
|
608
|
+
if (resolved) {
|
|
609
|
+
resolvedPath = resolved;
|
|
610
|
+
} else {
|
|
611
|
+
resolvedPath = notePath + ".md";
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const forwardLinks = getForwardLinksForNote(index, resolvedPath);
|
|
615
|
+
const output = {
|
|
616
|
+
note: resolvedPath,
|
|
617
|
+
forward_link_count: forwardLinks.length,
|
|
618
|
+
forward_links: forwardLinks.map((link) => ({
|
|
619
|
+
target: link.target,
|
|
620
|
+
alias: link.alias,
|
|
621
|
+
line: link.line,
|
|
622
|
+
resolved_path: link.resolvedPath,
|
|
623
|
+
exists: link.exists
|
|
624
|
+
}))
|
|
625
|
+
};
|
|
626
|
+
return {
|
|
627
|
+
content: [
|
|
628
|
+
{
|
|
629
|
+
type: "text",
|
|
630
|
+
text: JSON.stringify(output, null, 2)
|
|
631
|
+
}
|
|
632
|
+
],
|
|
633
|
+
structuredContent: output
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
);
|
|
637
|
+
const OrphanNoteSchema = z.object({
|
|
638
|
+
path: z.string().describe("Path to the orphan note"),
|
|
639
|
+
title: z.string().describe("Title of the note"),
|
|
640
|
+
modified: z.string().describe("Last modified date (ISO format)")
|
|
641
|
+
});
|
|
642
|
+
const FindOrphansOutputSchema = {
|
|
643
|
+
orphan_count: z.number().describe("Total number of orphan notes found"),
|
|
644
|
+
returned_count: z.number().describe("Number of orphans returned (may be limited)"),
|
|
645
|
+
folder: z.string().optional().describe("Folder filter if specified"),
|
|
646
|
+
orphans: z.array(OrphanNoteSchema).describe("List of orphan notes")
|
|
647
|
+
};
|
|
648
|
+
server2.registerTool(
|
|
649
|
+
"find_orphan_notes",
|
|
650
|
+
{
|
|
651
|
+
title: "Find Orphan Notes",
|
|
652
|
+
description: "Find notes that have no backlinks (no other notes link to them). Useful for finding disconnected content.",
|
|
653
|
+
inputSchema: {
|
|
654
|
+
folder: z.string().optional().describe('Limit search to a specific folder (e.g., "daily-notes/")'),
|
|
655
|
+
limit: z.number().default(50).describe("Maximum number of results to return"),
|
|
656
|
+
offset: z.number().default(0).describe("Number of results to skip (for pagination)")
|
|
657
|
+
},
|
|
658
|
+
outputSchema: FindOrphansOutputSchema
|
|
659
|
+
},
|
|
660
|
+
async ({ folder, limit: requestedLimit, offset }) => {
|
|
661
|
+
requireIndex();
|
|
662
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
663
|
+
const index = getIndex();
|
|
664
|
+
const allOrphans = findOrphanNotes(index, folder);
|
|
665
|
+
const orphans = allOrphans.slice(offset, offset + limit);
|
|
666
|
+
const output = {
|
|
667
|
+
orphan_count: allOrphans.length,
|
|
668
|
+
returned_count: orphans.length,
|
|
669
|
+
folder,
|
|
670
|
+
orphans: orphans.map((o) => ({
|
|
671
|
+
path: o.path,
|
|
672
|
+
title: o.title,
|
|
673
|
+
modified: o.modified.toISOString()
|
|
674
|
+
}))
|
|
675
|
+
};
|
|
676
|
+
return {
|
|
677
|
+
content: [
|
|
678
|
+
{
|
|
679
|
+
type: "text",
|
|
680
|
+
text: JSON.stringify(output, null, 2)
|
|
681
|
+
}
|
|
682
|
+
],
|
|
683
|
+
structuredContent: output
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
);
|
|
687
|
+
const HubNoteSchema = z.object({
|
|
688
|
+
path: z.string().describe("Path to the hub note"),
|
|
689
|
+
title: z.string().describe("Title of the note"),
|
|
690
|
+
backlink_count: z.number().describe("Number of notes linking TO this note"),
|
|
691
|
+
forward_link_count: z.number().describe("Number of notes this note links TO"),
|
|
692
|
+
total_connections: z.number().describe("Total connections (backlinks + forward links)")
|
|
693
|
+
});
|
|
694
|
+
const FindHubsOutputSchema = {
|
|
695
|
+
hub_count: z.number().describe("Total number of hub notes found"),
|
|
696
|
+
returned_count: z.number().describe("Number of hubs returned (may be limited)"),
|
|
697
|
+
min_links: z.number().describe("Minimum connection threshold used"),
|
|
698
|
+
hubs: z.array(HubNoteSchema).describe("List of hub notes, sorted by total connections")
|
|
699
|
+
};
|
|
700
|
+
server2.registerTool(
|
|
701
|
+
"find_hub_notes",
|
|
702
|
+
{
|
|
703
|
+
title: "Find Hub Notes",
|
|
704
|
+
description: "Find highly connected notes (hubs) that have many links to/from other notes. Useful for identifying key concepts.",
|
|
705
|
+
inputSchema: {
|
|
706
|
+
min_links: z.number().default(5).describe("Minimum total connections (backlinks + forward links) to qualify as a hub"),
|
|
707
|
+
limit: z.number().default(50).describe("Maximum number of results to return"),
|
|
708
|
+
offset: z.number().default(0).describe("Number of results to skip (for pagination)")
|
|
709
|
+
},
|
|
710
|
+
outputSchema: FindHubsOutputSchema
|
|
711
|
+
},
|
|
712
|
+
async ({ min_links, limit: requestedLimit, offset }) => {
|
|
713
|
+
requireIndex();
|
|
714
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
715
|
+
const index = getIndex();
|
|
716
|
+
const allHubs = findHubNotes(index, min_links);
|
|
717
|
+
const hubs = allHubs.slice(offset, offset + limit);
|
|
718
|
+
const output = {
|
|
719
|
+
hub_count: allHubs.length,
|
|
720
|
+
returned_count: hubs.length,
|
|
721
|
+
min_links,
|
|
722
|
+
hubs: hubs.map((h) => ({
|
|
723
|
+
path: h.path,
|
|
724
|
+
title: h.title,
|
|
725
|
+
backlink_count: h.backlink_count,
|
|
726
|
+
forward_link_count: h.forward_link_count,
|
|
727
|
+
total_connections: h.total_connections
|
|
728
|
+
}))
|
|
729
|
+
};
|
|
730
|
+
return {
|
|
731
|
+
content: [
|
|
732
|
+
{
|
|
733
|
+
type: "text",
|
|
734
|
+
text: JSON.stringify(output, null, 2)
|
|
735
|
+
}
|
|
736
|
+
],
|
|
737
|
+
structuredContent: output
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/tools/wikilinks.ts
|
|
744
|
+
import { z as z2 } from "zod";
|
|
745
|
+
function findEntityMatches(text, entities) {
|
|
746
|
+
const matches = [];
|
|
747
|
+
const sortedEntities = Array.from(entities.entries()).filter(([name]) => name.length >= 2).sort((a, b) => b[0].length - a[0].length);
|
|
748
|
+
const skipRegions = [];
|
|
749
|
+
const wikilinkRegex = /\[\[[^\]]+\]\]/g;
|
|
750
|
+
let match;
|
|
751
|
+
while ((match = wikilinkRegex.exec(text)) !== null) {
|
|
752
|
+
skipRegions.push({ start: match.index, end: match.index + match[0].length });
|
|
753
|
+
}
|
|
754
|
+
const codeBlockRegex = /```[\s\S]*?```|`[^`\n]+`/g;
|
|
755
|
+
while ((match = codeBlockRegex.exec(text)) !== null) {
|
|
756
|
+
skipRegions.push({ start: match.index, end: match.index + match[0].length });
|
|
757
|
+
}
|
|
758
|
+
const urlRegex = /https?:\/\/[^\s)>\]]+/g;
|
|
759
|
+
while ((match = urlRegex.exec(text)) !== null) {
|
|
760
|
+
skipRegions.push({ start: match.index, end: match.index + match[0].length });
|
|
761
|
+
}
|
|
762
|
+
const matchedPositions = /* @__PURE__ */ new Set();
|
|
763
|
+
function shouldSkip(start, end) {
|
|
764
|
+
for (const region of skipRegions) {
|
|
765
|
+
if (start < region.end && end > region.start) {
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
for (let i = start; i < end; i++) {
|
|
770
|
+
if (matchedPositions.has(i)) {
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
function markMatched(start, end) {
|
|
777
|
+
for (let i = start; i < end; i++) {
|
|
778
|
+
matchedPositions.add(i);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const textLower = text.toLowerCase();
|
|
782
|
+
for (const [entityName, targetPath] of sortedEntities) {
|
|
783
|
+
const entityLower = entityName.toLowerCase();
|
|
784
|
+
let searchStart = 0;
|
|
785
|
+
while (searchStart < textLower.length) {
|
|
786
|
+
const pos = textLower.indexOf(entityLower, searchStart);
|
|
787
|
+
if (pos === -1) break;
|
|
788
|
+
const end = pos + entityName.length;
|
|
789
|
+
const charBefore = pos > 0 ? text[pos - 1] : " ";
|
|
790
|
+
const charAfter = end < text.length ? text[end] : " ";
|
|
791
|
+
const isWordBoundaryBefore = /[\s\n\r.,;:!?()[\]{}'"<>-]/.test(charBefore);
|
|
792
|
+
const isWordBoundaryAfter = /[\s\n\r.,;:!?()[\]{}'"<>-]/.test(charAfter);
|
|
793
|
+
if (isWordBoundaryBefore && isWordBoundaryAfter && !shouldSkip(pos, end)) {
|
|
794
|
+
const originalText = text.substring(pos, end);
|
|
795
|
+
matches.push({
|
|
796
|
+
entity: originalText,
|
|
797
|
+
start: pos,
|
|
798
|
+
end,
|
|
799
|
+
target: targetPath
|
|
800
|
+
});
|
|
801
|
+
markMatched(pos, end);
|
|
802
|
+
}
|
|
803
|
+
searchStart = pos + 1;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return matches.sort((a, b) => a.start - b.start);
|
|
807
|
+
}
|
|
808
|
+
function registerWikilinkTools(server2, getIndex, getVaultPath) {
|
|
809
|
+
const SuggestionSchema = z2.object({
|
|
810
|
+
entity: z2.string().describe("The matched text in the input"),
|
|
811
|
+
start: z2.number().describe("Start position in text (0-indexed)"),
|
|
812
|
+
end: z2.number().describe("End position in text (0-indexed)"),
|
|
813
|
+
target: z2.string().describe("Path to the target note")
|
|
814
|
+
});
|
|
815
|
+
const SuggestWikilinksOutputSchema = {
|
|
816
|
+
input_length: z2.number().describe("Length of the input text"),
|
|
817
|
+
suggestion_count: z2.number().describe("Total number of suggestions found"),
|
|
818
|
+
returned_count: z2.number().describe("Number of suggestions returned (may be limited)"),
|
|
819
|
+
suggestions: z2.array(SuggestionSchema).describe("List of wikilink suggestions")
|
|
820
|
+
};
|
|
821
|
+
server2.registerTool(
|
|
822
|
+
"suggest_wikilinks",
|
|
823
|
+
{
|
|
824
|
+
title: "Suggest Wikilinks",
|
|
825
|
+
description: "Analyze text and suggest where wikilinks could be added. Finds mentions of existing note titles and aliases.",
|
|
826
|
+
inputSchema: {
|
|
827
|
+
text: z2.string().describe("The text to analyze for potential wikilinks"),
|
|
828
|
+
limit: z2.number().default(50).describe("Maximum number of suggestions to return"),
|
|
829
|
+
offset: z2.number().default(0).describe("Number of suggestions to skip (for pagination)")
|
|
830
|
+
},
|
|
831
|
+
outputSchema: SuggestWikilinksOutputSchema
|
|
832
|
+
},
|
|
833
|
+
async ({ text, limit: requestedLimit, offset }) => {
|
|
834
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
835
|
+
const index = getIndex();
|
|
836
|
+
const allMatches = findEntityMatches(text, index.entities);
|
|
837
|
+
const matches = allMatches.slice(offset, offset + limit);
|
|
838
|
+
const output = {
|
|
839
|
+
input_length: text.length,
|
|
840
|
+
suggestion_count: allMatches.length,
|
|
841
|
+
returned_count: matches.length,
|
|
842
|
+
suggestions: matches
|
|
843
|
+
};
|
|
844
|
+
return {
|
|
845
|
+
content: [
|
|
846
|
+
{
|
|
847
|
+
type: "text",
|
|
848
|
+
text: JSON.stringify(output, null, 2)
|
|
849
|
+
}
|
|
850
|
+
],
|
|
851
|
+
structuredContent: output
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
const BrokenLinkSchema = z2.object({
|
|
856
|
+
source: z2.string().describe("Path to the note containing the broken link"),
|
|
857
|
+
target: z2.string().describe("The broken link target"),
|
|
858
|
+
line: z2.number().describe("Line number where the link appears"),
|
|
859
|
+
suggestion: z2.string().optional().describe("Suggested fix if a similar note exists")
|
|
860
|
+
});
|
|
861
|
+
const ValidateLinksOutputSchema = {
|
|
862
|
+
scope: z2.string().describe('What was validated (note path or "all")'),
|
|
863
|
+
total_links: z2.number().describe("Total number of links checked"),
|
|
864
|
+
valid_links: z2.number().describe("Number of valid links"),
|
|
865
|
+
broken_links: z2.number().describe("Total number of broken links"),
|
|
866
|
+
returned_count: z2.number().describe("Number of broken links returned (may be limited)"),
|
|
867
|
+
broken: z2.array(BrokenLinkSchema).describe("List of broken links")
|
|
868
|
+
};
|
|
869
|
+
function findSimilarEntity2(target, entities) {
|
|
870
|
+
const targetLower = target.toLowerCase();
|
|
871
|
+
for (const [name, path11] of entities) {
|
|
872
|
+
if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
|
|
873
|
+
return path11;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
for (const [name, path11] of entities) {
|
|
877
|
+
if (name.includes(targetLower) || targetLower.includes(name)) {
|
|
878
|
+
return path11;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return void 0;
|
|
882
|
+
}
|
|
883
|
+
server2.registerTool(
|
|
884
|
+
"validate_links",
|
|
885
|
+
{
|
|
886
|
+
title: "Validate Links",
|
|
887
|
+
description: "Check wikilinks in a note (or all notes) and report broken links. Optionally suggests fixes.",
|
|
888
|
+
inputSchema: {
|
|
889
|
+
path: z2.string().optional().describe("Path to a specific note to validate. If omitted, validates all notes."),
|
|
890
|
+
limit: z2.number().default(50).describe("Maximum number of broken links to return"),
|
|
891
|
+
offset: z2.number().default(0).describe("Number of broken links to skip (for pagination)")
|
|
892
|
+
},
|
|
893
|
+
outputSchema: ValidateLinksOutputSchema
|
|
894
|
+
},
|
|
895
|
+
async ({ path: notePath, limit: requestedLimit, offset }) => {
|
|
896
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
897
|
+
const index = getIndex();
|
|
898
|
+
const allBroken = [];
|
|
899
|
+
let totalLinks = 0;
|
|
900
|
+
let validLinks = 0;
|
|
901
|
+
let notesToCheck;
|
|
902
|
+
if (notePath) {
|
|
903
|
+
let resolvedPath = notePath;
|
|
904
|
+
if (!notePath.endsWith(".md")) {
|
|
905
|
+
const resolved = resolveTarget(index, notePath);
|
|
906
|
+
if (resolved) {
|
|
907
|
+
resolvedPath = resolved;
|
|
908
|
+
} else {
|
|
909
|
+
resolvedPath = notePath + ".md";
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
notesToCheck = [resolvedPath];
|
|
913
|
+
} else {
|
|
914
|
+
notesToCheck = Array.from(index.notes.keys());
|
|
915
|
+
}
|
|
916
|
+
for (const sourcePath of notesToCheck) {
|
|
917
|
+
const note = index.notes.get(sourcePath);
|
|
918
|
+
if (!note) continue;
|
|
919
|
+
for (const link of note.outlinks) {
|
|
920
|
+
totalLinks++;
|
|
921
|
+
const resolved = resolveTarget(index, link.target);
|
|
922
|
+
if (resolved) {
|
|
923
|
+
validLinks++;
|
|
924
|
+
} else {
|
|
925
|
+
const suggestion = findSimilarEntity2(link.target, index.entities);
|
|
926
|
+
allBroken.push({
|
|
927
|
+
source: sourcePath,
|
|
928
|
+
target: link.target,
|
|
929
|
+
line: link.line,
|
|
930
|
+
suggestion
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
const broken = allBroken.slice(offset, offset + limit);
|
|
936
|
+
const output = {
|
|
937
|
+
scope: notePath || "all",
|
|
938
|
+
total_links: totalLinks,
|
|
939
|
+
valid_links: validLinks,
|
|
940
|
+
broken_links: allBroken.length,
|
|
941
|
+
returned_count: broken.length,
|
|
942
|
+
broken
|
|
943
|
+
};
|
|
944
|
+
return {
|
|
945
|
+
content: [
|
|
946
|
+
{
|
|
947
|
+
type: "text",
|
|
948
|
+
text: JSON.stringify(output, null, 2)
|
|
949
|
+
}
|
|
950
|
+
],
|
|
951
|
+
structuredContent: output
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/tools/health.ts
|
|
958
|
+
import * as fs4 from "fs";
|
|
959
|
+
import { z as z3 } from "zod";
|
|
960
|
+
var STALE_THRESHOLD_SECONDS = 300;
|
|
961
|
+
function registerHealthTools(server2, getIndex, getVaultPath) {
|
|
962
|
+
const IndexProgressSchema = z3.object({
|
|
963
|
+
parsed: z3.number().describe("Number of files parsed so far"),
|
|
964
|
+
total: z3.number().describe("Total number of files to parse")
|
|
965
|
+
}).optional();
|
|
966
|
+
const HealthCheckOutputSchema = {
|
|
967
|
+
status: z3.enum(["healthy", "degraded", "unhealthy"]).describe("Overall health status"),
|
|
968
|
+
vault_accessible: z3.boolean().describe("Whether the vault path is accessible"),
|
|
969
|
+
vault_path: z3.string().describe("The vault path being used"),
|
|
970
|
+
index_state: z3.enum(["building", "ready", "error"]).describe("Current state of the vault index"),
|
|
971
|
+
index_progress: IndexProgressSchema.describe("Progress of index build (when building)"),
|
|
972
|
+
index_error: z3.string().optional().describe("Error message if index failed to build"),
|
|
973
|
+
index_built: z3.boolean().describe("Whether the index has been built"),
|
|
974
|
+
index_age_seconds: z3.number().describe("Seconds since the index was built"),
|
|
975
|
+
index_stale: z3.boolean().describe("Whether the index is stale (>5 minutes old)"),
|
|
976
|
+
note_count: z3.number().describe("Number of notes in the index"),
|
|
977
|
+
entity_count: z3.number().describe("Number of linkable entities (titles + aliases)"),
|
|
978
|
+
tag_count: z3.number().describe("Number of unique tags"),
|
|
979
|
+
recommendations: z3.array(z3.string()).describe("Suggested actions if any issues detected")
|
|
980
|
+
};
|
|
981
|
+
server2.registerTool(
|
|
982
|
+
"health_check",
|
|
983
|
+
{
|
|
984
|
+
title: "Health Check",
|
|
985
|
+
description: "Check MCP server health status. Returns vault accessibility, index freshness, and recommendations. Use at session start to verify MCP is working correctly.",
|
|
986
|
+
inputSchema: {},
|
|
987
|
+
outputSchema: HealthCheckOutputSchema
|
|
988
|
+
},
|
|
989
|
+
async () => {
|
|
990
|
+
const index = getIndex();
|
|
991
|
+
const vaultPath2 = getVaultPath();
|
|
992
|
+
const recommendations = [];
|
|
993
|
+
const indexState2 = getIndexState();
|
|
994
|
+
const indexProgress2 = getIndexProgress();
|
|
995
|
+
const indexErrorObj = getIndexError();
|
|
996
|
+
let vaultAccessible = false;
|
|
997
|
+
try {
|
|
998
|
+
fs4.accessSync(vaultPath2, fs4.constants.R_OK);
|
|
999
|
+
vaultAccessible = true;
|
|
1000
|
+
} catch {
|
|
1001
|
+
vaultAccessible = false;
|
|
1002
|
+
recommendations.push("Vault path is not accessible. Check PROJECT_PATH environment variable.");
|
|
1003
|
+
}
|
|
1004
|
+
const indexBuilt = indexState2 === "ready" && index !== void 0 && index.notes !== void 0;
|
|
1005
|
+
const indexAge = indexBuilt && index.builtAt ? Math.floor((Date.now() - index.builtAt.getTime()) / 1e3) : -1;
|
|
1006
|
+
const indexStale = indexBuilt && indexAge > STALE_THRESHOLD_SECONDS;
|
|
1007
|
+
if (indexState2 === "building") {
|
|
1008
|
+
const { parsed, total } = indexProgress2;
|
|
1009
|
+
const progress = total > 0 ? ` (${parsed}/${total} files)` : "";
|
|
1010
|
+
recommendations.push(`Index is building${progress}. Some tools may not be available yet.`);
|
|
1011
|
+
} else if (indexState2 === "error") {
|
|
1012
|
+
recommendations.push(`Index failed to build: ${indexErrorObj?.message || "unknown error"}`);
|
|
1013
|
+
} else if (indexStale) {
|
|
1014
|
+
recommendations.push(`Index is ${Math.floor(indexAge / 60)} minutes old. Consider running refresh_index.`);
|
|
1015
|
+
}
|
|
1016
|
+
const noteCount = indexBuilt ? index.notes.size : 0;
|
|
1017
|
+
const entityCount = indexBuilt ? index.entities.size : 0;
|
|
1018
|
+
const tagCount = indexBuilt ? index.tags.size : 0;
|
|
1019
|
+
if (indexBuilt && noteCount === 0 && vaultAccessible) {
|
|
1020
|
+
recommendations.push("No notes found in vault. Is PROJECT_PATH pointing to a markdown vault?");
|
|
1021
|
+
}
|
|
1022
|
+
let status;
|
|
1023
|
+
if (!vaultAccessible || indexState2 === "error") {
|
|
1024
|
+
status = "unhealthy";
|
|
1025
|
+
} else if (indexState2 === "building" || indexStale || recommendations.length > 0) {
|
|
1026
|
+
status = "degraded";
|
|
1027
|
+
} else {
|
|
1028
|
+
status = "healthy";
|
|
1029
|
+
}
|
|
1030
|
+
const output = {
|
|
1031
|
+
status,
|
|
1032
|
+
vault_accessible: vaultAccessible,
|
|
1033
|
+
vault_path: vaultPath2,
|
|
1034
|
+
index_state: indexState2,
|
|
1035
|
+
index_progress: indexState2 === "building" ? indexProgress2 : void 0,
|
|
1036
|
+
index_error: indexState2 === "error" && indexErrorObj ? indexErrorObj.message : void 0,
|
|
1037
|
+
index_built: indexBuilt,
|
|
1038
|
+
index_age_seconds: indexAge,
|
|
1039
|
+
index_stale: indexStale,
|
|
1040
|
+
note_count: noteCount,
|
|
1041
|
+
entity_count: entityCount,
|
|
1042
|
+
tag_count: tagCount,
|
|
1043
|
+
recommendations
|
|
1044
|
+
};
|
|
1045
|
+
return {
|
|
1046
|
+
content: [
|
|
1047
|
+
{
|
|
1048
|
+
type: "text",
|
|
1049
|
+
text: JSON.stringify(output, null, 2)
|
|
1050
|
+
}
|
|
1051
|
+
],
|
|
1052
|
+
structuredContent: output
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
);
|
|
1056
|
+
const BrokenLinkSchema = z3.object({
|
|
1057
|
+
source: z3.string().describe("Path to the note containing the broken link"),
|
|
1058
|
+
target: z3.string().describe("The broken link target"),
|
|
1059
|
+
line: z3.number().describe("Line number where the link appears"),
|
|
1060
|
+
suggestion: z3.string().describe("Suggested correct target (similar entity found)")
|
|
1061
|
+
});
|
|
1062
|
+
const FindBrokenLinksOutputSchema = {
|
|
1063
|
+
scope: z3.string().describe('Folder searched, or "all" for entire vault'),
|
|
1064
|
+
broken_count: z3.number().describe("Total number of broken links found"),
|
|
1065
|
+
returned_count: z3.number().describe("Number of broken links returned (may be limited)"),
|
|
1066
|
+
affected_notes: z3.number().describe("Number of notes with broken links"),
|
|
1067
|
+
broken_links: z3.array(BrokenLinkSchema).describe("List of broken links with suggestions")
|
|
1068
|
+
};
|
|
1069
|
+
server2.registerTool(
|
|
1070
|
+
"find_broken_links",
|
|
1071
|
+
{
|
|
1072
|
+
title: "Find Broken Links",
|
|
1073
|
+
description: "Find wikilinks that appear to be typos or mistakes (links to non-existent notes that have a similar existing note). Links to notes that simply do not exist yet are not considered broken - only links where a similar note exists (suggesting a typo).",
|
|
1074
|
+
inputSchema: {
|
|
1075
|
+
folder: z3.string().optional().describe('Limit search to a specific folder (e.g., "daily-notes/")'),
|
|
1076
|
+
limit: z3.number().default(50).describe("Maximum number of results to return (capped at 500)"),
|
|
1077
|
+
offset: z3.number().default(0).describe("Number of results to skip (for pagination)")
|
|
1078
|
+
},
|
|
1079
|
+
outputSchema: FindBrokenLinksOutputSchema
|
|
1080
|
+
},
|
|
1081
|
+
async ({ folder, limit: requestedLimit, offset }) => {
|
|
1082
|
+
const index = getIndex();
|
|
1083
|
+
const affectedNotes = /* @__PURE__ */ new Set();
|
|
1084
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
1085
|
+
const similarityCache = /* @__PURE__ */ new Map();
|
|
1086
|
+
const unresolvedLinks = [];
|
|
1087
|
+
for (const note of index.notes.values()) {
|
|
1088
|
+
if (folder && !note.path.startsWith(folder)) {
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
for (const link of note.outlinks) {
|
|
1092
|
+
const resolved = resolveTarget(index, link.target);
|
|
1093
|
+
if (!resolved) {
|
|
1094
|
+
unresolvedLinks.push({
|
|
1095
|
+
source: note.path,
|
|
1096
|
+
target: link.target,
|
|
1097
|
+
line: link.line
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
unresolvedLinks.sort((a, b) => {
|
|
1103
|
+
const pathCompare = a.source.localeCompare(b.source);
|
|
1104
|
+
if (pathCompare !== 0) return pathCompare;
|
|
1105
|
+
return a.line - b.line;
|
|
1106
|
+
});
|
|
1107
|
+
const allBrokenLinks = [];
|
|
1108
|
+
for (const link of unresolvedLinks) {
|
|
1109
|
+
let similar;
|
|
1110
|
+
if (similarityCache.has(link.target)) {
|
|
1111
|
+
similar = similarityCache.get(link.target);
|
|
1112
|
+
} else {
|
|
1113
|
+
const result = findSimilarEntity(index, link.target);
|
|
1114
|
+
similar = result ?? null;
|
|
1115
|
+
similarityCache.set(link.target, similar);
|
|
1116
|
+
}
|
|
1117
|
+
if (similar) {
|
|
1118
|
+
allBrokenLinks.push({
|
|
1119
|
+
source: link.source,
|
|
1120
|
+
target: link.target,
|
|
1121
|
+
line: link.line,
|
|
1122
|
+
suggestion: similar.path
|
|
1123
|
+
});
|
|
1124
|
+
affectedNotes.add(link.source);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const brokenLinks = allBrokenLinks.slice(offset, offset + limit);
|
|
1128
|
+
const output = {
|
|
1129
|
+
scope: folder || "all",
|
|
1130
|
+
broken_count: allBrokenLinks.length,
|
|
1131
|
+
returned_count: brokenLinks.length,
|
|
1132
|
+
affected_notes: affectedNotes.size,
|
|
1133
|
+
broken_links: brokenLinks
|
|
1134
|
+
};
|
|
1135
|
+
return {
|
|
1136
|
+
content: [
|
|
1137
|
+
{
|
|
1138
|
+
type: "text",
|
|
1139
|
+
text: JSON.stringify(output, null, 2)
|
|
1140
|
+
}
|
|
1141
|
+
],
|
|
1142
|
+
structuredContent: output
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
);
|
|
1146
|
+
const TagStatSchema = z3.object({
|
|
1147
|
+
tag: z3.string().describe("The tag name"),
|
|
1148
|
+
count: z3.number().describe("Number of notes with this tag")
|
|
1149
|
+
});
|
|
1150
|
+
const FolderStatSchema = z3.object({
|
|
1151
|
+
folder: z3.string().describe("Folder path"),
|
|
1152
|
+
note_count: z3.number().describe("Number of notes in this folder")
|
|
1153
|
+
});
|
|
1154
|
+
const OrphanStatsSchema = z3.object({
|
|
1155
|
+
total: z3.number().describe("Total orphan notes (no backlinks)"),
|
|
1156
|
+
periodic: z3.number().describe("Orphan periodic notes (daily/weekly/monthly - expected)"),
|
|
1157
|
+
content: z3.number().describe("Orphan content notes (non-periodic - may need linking)")
|
|
1158
|
+
});
|
|
1159
|
+
const GetVaultStatsOutputSchema = {
|
|
1160
|
+
total_notes: z3.number().describe("Total number of notes in the vault"),
|
|
1161
|
+
total_links: z3.number().describe("Total number of wikilinks"),
|
|
1162
|
+
total_tags: z3.number().describe("Total number of unique tags"),
|
|
1163
|
+
orphan_notes: OrphanStatsSchema.describe("Orphan notes breakdown"),
|
|
1164
|
+
broken_links: z3.number().describe("Links pointing to non-existent notes"),
|
|
1165
|
+
average_links_per_note: z3.number().describe("Average outgoing links per note"),
|
|
1166
|
+
most_linked_notes: z3.array(
|
|
1167
|
+
z3.object({
|
|
1168
|
+
path: z3.string(),
|
|
1169
|
+
backlinks: z3.number()
|
|
1170
|
+
})
|
|
1171
|
+
).describe("Top 10 most linked-to notes"),
|
|
1172
|
+
top_tags: z3.array(TagStatSchema).describe("Top 20 most used tags"),
|
|
1173
|
+
folders: z3.array(FolderStatSchema).describe("Note counts by top-level folder")
|
|
1174
|
+
};
|
|
1175
|
+
function isPeriodicNote(path11) {
|
|
1176
|
+
const filename = path11.split("/").pop() || "";
|
|
1177
|
+
const nameWithoutExt = filename.replace(/\.md$/, "");
|
|
1178
|
+
const patterns = [
|
|
1179
|
+
/^\d{4}-\d{2}-\d{2}$/,
|
|
1180
|
+
// YYYY-MM-DD (daily)
|
|
1181
|
+
/^\d{4}-W\d{2}$/,
|
|
1182
|
+
// YYYY-Wnn (weekly)
|
|
1183
|
+
/^\d{4}-\d{2}$/,
|
|
1184
|
+
// YYYY-MM (monthly)
|
|
1185
|
+
/^\d{4}-Q[1-4]$/,
|
|
1186
|
+
// YYYY-Qn (quarterly)
|
|
1187
|
+
/^\d{4}$/
|
|
1188
|
+
// YYYY (yearly)
|
|
1189
|
+
];
|
|
1190
|
+
const periodicFolders = ["daily", "weekly", "monthly", "quarterly", "yearly", "journal", "journals"];
|
|
1191
|
+
const folder = path11.split("/")[0]?.toLowerCase() || "";
|
|
1192
|
+
return patterns.some((p) => p.test(nameWithoutExt)) || periodicFolders.includes(folder);
|
|
1193
|
+
}
|
|
1194
|
+
server2.registerTool(
|
|
1195
|
+
"get_vault_stats",
|
|
1196
|
+
{
|
|
1197
|
+
title: "Get Vault Statistics",
|
|
1198
|
+
description: "Get comprehensive statistics about the vault: note counts, link metrics, tag usage, and folder distribution.",
|
|
1199
|
+
inputSchema: {},
|
|
1200
|
+
outputSchema: GetVaultStatsOutputSchema
|
|
1201
|
+
},
|
|
1202
|
+
async () => {
|
|
1203
|
+
const index = getIndex();
|
|
1204
|
+
const totalNotes = index.notes.size;
|
|
1205
|
+
let totalLinks = 0;
|
|
1206
|
+
let brokenLinks = 0;
|
|
1207
|
+
let orphanTotal = 0;
|
|
1208
|
+
let orphanPeriodic = 0;
|
|
1209
|
+
let orphanContent = 0;
|
|
1210
|
+
for (const note of index.notes.values()) {
|
|
1211
|
+
totalLinks += note.outlinks.length;
|
|
1212
|
+
for (const link of note.outlinks) {
|
|
1213
|
+
if (!resolveTarget(index, link.target)) {
|
|
1214
|
+
const similar = findSimilarEntity(index, link.target);
|
|
1215
|
+
if (similar) {
|
|
1216
|
+
brokenLinks++;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
for (const note of index.notes.values()) {
|
|
1222
|
+
const backlinks = getBacklinksForNote(index, note.path);
|
|
1223
|
+
if (backlinks.length === 0) {
|
|
1224
|
+
orphanTotal++;
|
|
1225
|
+
if (isPeriodicNote(note.path)) {
|
|
1226
|
+
orphanPeriodic++;
|
|
1227
|
+
} else {
|
|
1228
|
+
orphanContent++;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const linkCounts = [];
|
|
1233
|
+
for (const note of index.notes.values()) {
|
|
1234
|
+
const backlinks = getBacklinksForNote(index, note.path);
|
|
1235
|
+
if (backlinks.length > 0) {
|
|
1236
|
+
linkCounts.push({ path: note.path, backlinks: backlinks.length });
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
linkCounts.sort((a, b) => b.backlinks - a.backlinks);
|
|
1240
|
+
const mostLinkedNotes = linkCounts.slice(0, 10);
|
|
1241
|
+
const tagStats = [];
|
|
1242
|
+
for (const [tag, notes] of index.tags) {
|
|
1243
|
+
tagStats.push({ tag, count: notes.size });
|
|
1244
|
+
}
|
|
1245
|
+
tagStats.sort((a, b) => b.count - a.count);
|
|
1246
|
+
const topTags = tagStats.slice(0, 20);
|
|
1247
|
+
const folderCounts = /* @__PURE__ */ new Map();
|
|
1248
|
+
for (const note of index.notes.values()) {
|
|
1249
|
+
const parts = note.path.split("/");
|
|
1250
|
+
const folder = parts.length > 1 ? parts[0] : "(root)";
|
|
1251
|
+
folderCounts.set(folder, (folderCounts.get(folder) || 0) + 1);
|
|
1252
|
+
}
|
|
1253
|
+
const folders = Array.from(folderCounts.entries()).map(([folder, count]) => ({ folder, note_count: count })).sort((a, b) => b.note_count - a.note_count);
|
|
1254
|
+
const output = {
|
|
1255
|
+
total_notes: totalNotes,
|
|
1256
|
+
total_links: totalLinks,
|
|
1257
|
+
total_tags: index.tags.size,
|
|
1258
|
+
orphan_notes: {
|
|
1259
|
+
total: orphanTotal,
|
|
1260
|
+
periodic: orphanPeriodic,
|
|
1261
|
+
content: orphanContent
|
|
1262
|
+
},
|
|
1263
|
+
broken_links: brokenLinks,
|
|
1264
|
+
average_links_per_note: totalNotes > 0 ? Math.round(totalLinks / totalNotes * 100) / 100 : 0,
|
|
1265
|
+
most_linked_notes: mostLinkedNotes,
|
|
1266
|
+
top_tags: topTags,
|
|
1267
|
+
folders
|
|
1268
|
+
};
|
|
1269
|
+
return {
|
|
1270
|
+
content: [
|
|
1271
|
+
{
|
|
1272
|
+
type: "text",
|
|
1273
|
+
text: JSON.stringify(output, null, 2)
|
|
1274
|
+
}
|
|
1275
|
+
],
|
|
1276
|
+
structuredContent: output
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// src/tools/query.ts
|
|
1283
|
+
import { z as z4 } from "zod";
|
|
1284
|
+
function matchesFrontmatter(note, where) {
|
|
1285
|
+
for (const [key, value] of Object.entries(where)) {
|
|
1286
|
+
const noteValue = note.frontmatter[key];
|
|
1287
|
+
if (value === null || value === void 0) {
|
|
1288
|
+
if (noteValue !== null && noteValue !== void 0) {
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
if (Array.isArray(noteValue)) {
|
|
1294
|
+
if (!noteValue.some((v) => String(v).toLowerCase() === String(value).toLowerCase())) {
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
if (typeof value === "string" && typeof noteValue === "string") {
|
|
1300
|
+
if (noteValue.toLowerCase() !== value.toLowerCase()) {
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (noteValue !== value) {
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return true;
|
|
1310
|
+
}
|
|
1311
|
+
function hasTag(note, tag) {
|
|
1312
|
+
const normalizedTag = tag.replace(/^#/, "").toLowerCase();
|
|
1313
|
+
return note.tags.some((t) => t.toLowerCase() === normalizedTag);
|
|
1314
|
+
}
|
|
1315
|
+
function hasAnyTag(note, tags) {
|
|
1316
|
+
return tags.some((tag) => hasTag(note, tag));
|
|
1317
|
+
}
|
|
1318
|
+
function hasAllTags(note, tags) {
|
|
1319
|
+
return tags.every((tag) => hasTag(note, tag));
|
|
1320
|
+
}
|
|
1321
|
+
function inFolder(note, folder) {
|
|
1322
|
+
const normalizedFolder = folder.endsWith("/") ? folder : folder + "/";
|
|
1323
|
+
return note.path.startsWith(normalizedFolder) || note.path.split("/")[0] === folder.replace("/", "");
|
|
1324
|
+
}
|
|
1325
|
+
function sortNotes(notes, sortBy, order) {
|
|
1326
|
+
const sorted = [...notes];
|
|
1327
|
+
sorted.sort((a, b) => {
|
|
1328
|
+
let comparison = 0;
|
|
1329
|
+
switch (sortBy) {
|
|
1330
|
+
case "modified":
|
|
1331
|
+
comparison = a.modified.getTime() - b.modified.getTime();
|
|
1332
|
+
break;
|
|
1333
|
+
case "created":
|
|
1334
|
+
const aCreated = a.created || a.modified;
|
|
1335
|
+
const bCreated = b.created || b.modified;
|
|
1336
|
+
comparison = aCreated.getTime() - bCreated.getTime();
|
|
1337
|
+
break;
|
|
1338
|
+
case "title":
|
|
1339
|
+
comparison = a.title.localeCompare(b.title);
|
|
1340
|
+
break;
|
|
1341
|
+
}
|
|
1342
|
+
return order === "desc" ? -comparison : comparison;
|
|
1343
|
+
});
|
|
1344
|
+
return sorted;
|
|
1345
|
+
}
|
|
1346
|
+
function registerQueryTools(server2, getIndex, getVaultPath) {
|
|
1347
|
+
const NoteResultSchema = z4.object({
|
|
1348
|
+
path: z4.string().describe("Path to the note"),
|
|
1349
|
+
title: z4.string().describe("Note title"),
|
|
1350
|
+
modified: z4.string().describe("Last modified date (ISO format)"),
|
|
1351
|
+
created: z4.string().optional().describe("Creation date if available (ISO format)"),
|
|
1352
|
+
tags: z4.array(z4.string()).describe("Tags on this note"),
|
|
1353
|
+
frontmatter: z4.record(z4.unknown()).describe("Frontmatter fields")
|
|
1354
|
+
});
|
|
1355
|
+
const SearchNotesOutputSchema = {
|
|
1356
|
+
query: z4.object({
|
|
1357
|
+
where: z4.record(z4.unknown()).optional(),
|
|
1358
|
+
has_tag: z4.string().optional(),
|
|
1359
|
+
has_any_tag: z4.array(z4.string()).optional(),
|
|
1360
|
+
has_all_tags: z4.array(z4.string()).optional(),
|
|
1361
|
+
folder: z4.string().optional(),
|
|
1362
|
+
title_contains: z4.string().optional(),
|
|
1363
|
+
sort_by: z4.string().optional(),
|
|
1364
|
+
order: z4.string().optional(),
|
|
1365
|
+
limit: z4.number().optional()
|
|
1366
|
+
}).describe("The search query that was executed"),
|
|
1367
|
+
total_matches: z4.number().describe("Total number of matching notes"),
|
|
1368
|
+
returned: z4.number().describe("Number of notes returned (may be limited)"),
|
|
1369
|
+
notes: z4.array(NoteResultSchema).describe("Matching notes")
|
|
1370
|
+
};
|
|
1371
|
+
server2.registerTool(
|
|
1372
|
+
"search_notes",
|
|
1373
|
+
{
|
|
1374
|
+
title: "Search Notes",
|
|
1375
|
+
description: "Search notes by frontmatter fields, tags, folders, or title. Covers ~80% of Dataview use cases.",
|
|
1376
|
+
inputSchema: {
|
|
1377
|
+
where: z4.record(z4.unknown()).optional().describe('Frontmatter filters as key-value pairs. Example: { "type": "project", "status": "active" }'),
|
|
1378
|
+
has_tag: z4.string().optional().describe('Filter to notes with this tag. Example: "work"'),
|
|
1379
|
+
has_any_tag: z4.array(z4.string()).optional().describe('Filter to notes with any of these tags. Example: ["work", "personal"]'),
|
|
1380
|
+
has_all_tags: z4.array(z4.string()).optional().describe('Filter to notes with all of these tags. Example: ["project", "active"]'),
|
|
1381
|
+
folder: z4.string().optional().describe('Limit to notes in this folder. Example: "daily-notes"'),
|
|
1382
|
+
title_contains: z4.string().optional().describe("Filter to notes whose title contains this text (case-insensitive)"),
|
|
1383
|
+
sort_by: z4.enum(["modified", "created", "title"]).default("modified").describe("Field to sort by"),
|
|
1384
|
+
order: z4.enum(["asc", "desc"]).default("desc").describe("Sort order"),
|
|
1385
|
+
limit: z4.number().default(50).describe("Maximum number of results to return")
|
|
1386
|
+
},
|
|
1387
|
+
outputSchema: SearchNotesOutputSchema
|
|
1388
|
+
},
|
|
1389
|
+
async ({
|
|
1390
|
+
where,
|
|
1391
|
+
has_tag,
|
|
1392
|
+
has_any_tag,
|
|
1393
|
+
has_all_tags,
|
|
1394
|
+
folder,
|
|
1395
|
+
title_contains,
|
|
1396
|
+
sort_by = "modified",
|
|
1397
|
+
order = "desc",
|
|
1398
|
+
limit: requestedLimit = 50
|
|
1399
|
+
}) => {
|
|
1400
|
+
const index = getIndex();
|
|
1401
|
+
const limit = Math.min(requestedLimit, MAX_LIMIT);
|
|
1402
|
+
let matchingNotes = Array.from(index.notes.values());
|
|
1403
|
+
if (where && Object.keys(where).length > 0) {
|
|
1404
|
+
matchingNotes = matchingNotes.filter((note) => matchesFrontmatter(note, where));
|
|
1405
|
+
}
|
|
1406
|
+
if (has_tag) {
|
|
1407
|
+
matchingNotes = matchingNotes.filter((note) => hasTag(note, has_tag));
|
|
1408
|
+
}
|
|
1409
|
+
if (has_any_tag && has_any_tag.length > 0) {
|
|
1410
|
+
matchingNotes = matchingNotes.filter((note) => hasAnyTag(note, has_any_tag));
|
|
1411
|
+
}
|
|
1412
|
+
if (has_all_tags && has_all_tags.length > 0) {
|
|
1413
|
+
matchingNotes = matchingNotes.filter((note) => hasAllTags(note, has_all_tags));
|
|
1414
|
+
}
|
|
1415
|
+
if (folder) {
|
|
1416
|
+
matchingNotes = matchingNotes.filter((note) => inFolder(note, folder));
|
|
1417
|
+
}
|
|
1418
|
+
if (title_contains) {
|
|
1419
|
+
const searchTerm = title_contains.toLowerCase();
|
|
1420
|
+
matchingNotes = matchingNotes.filter(
|
|
1421
|
+
(note) => note.title.toLowerCase().includes(searchTerm)
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
matchingNotes = sortNotes(matchingNotes, sort_by, order);
|
|
1425
|
+
const totalMatches = matchingNotes.length;
|
|
1426
|
+
const limitedNotes = matchingNotes.slice(0, limit);
|
|
1427
|
+
const notes = limitedNotes.map((note) => ({
|
|
1428
|
+
path: note.path,
|
|
1429
|
+
title: note.title,
|
|
1430
|
+
modified: note.modified.toISOString(),
|
|
1431
|
+
created: note.created?.toISOString(),
|
|
1432
|
+
tags: note.tags,
|
|
1433
|
+
frontmatter: note.frontmatter
|
|
1434
|
+
}));
|
|
1435
|
+
const output = {
|
|
1436
|
+
query: {
|
|
1437
|
+
where,
|
|
1438
|
+
has_tag,
|
|
1439
|
+
has_any_tag,
|
|
1440
|
+
has_all_tags,
|
|
1441
|
+
folder,
|
|
1442
|
+
title_contains,
|
|
1443
|
+
sort_by,
|
|
1444
|
+
order,
|
|
1445
|
+
limit
|
|
1446
|
+
},
|
|
1447
|
+
total_matches: totalMatches,
|
|
1448
|
+
returned: notes.length,
|
|
1449
|
+
notes
|
|
1450
|
+
};
|
|
1451
|
+
return {
|
|
1452
|
+
content: [
|
|
1453
|
+
{
|
|
1454
|
+
type: "text",
|
|
1455
|
+
text: JSON.stringify(output, null, 2)
|
|
1456
|
+
}
|
|
1457
|
+
],
|
|
1458
|
+
structuredContent: output
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// src/tools/system.ts
|
|
1465
|
+
import * as fs6 from "fs";
|
|
1466
|
+
import * as path4 from "path";
|
|
1467
|
+
import { z as z5 } from "zod";
|
|
1468
|
+
|
|
1469
|
+
// src/core/config.ts
|
|
1470
|
+
import * as fs5 from "fs";
|
|
1471
|
+
import * as path3 from "path";
|
|
1472
|
+
var DEFAULT_CONFIG = {
|
|
1473
|
+
exclude_task_tags: []
|
|
1474
|
+
};
|
|
1475
|
+
function loadConfig(vaultPath2) {
|
|
1476
|
+
const claudeDir = path3.join(vaultPath2, ".claude");
|
|
1477
|
+
const configPath = path3.join(claudeDir, ".flywheel.json");
|
|
1478
|
+
try {
|
|
1479
|
+
if (fs5.existsSync(configPath)) {
|
|
1480
|
+
const content = fs5.readFileSync(configPath, "utf-8");
|
|
1481
|
+
const config = JSON.parse(content);
|
|
1482
|
+
return { ...DEFAULT_CONFIG, ...config };
|
|
1483
|
+
}
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
console.error("[Flywheel] Failed to load .flywheel.json:", err);
|
|
1486
|
+
}
|
|
1487
|
+
return DEFAULT_CONFIG;
|
|
1488
|
+
}
|
|
1489
|
+
var RECURRING_TAG_PATTERNS = [
|
|
1490
|
+
"habit",
|
|
1491
|
+
"habits",
|
|
1492
|
+
"daily",
|
|
1493
|
+
"weekly",
|
|
1494
|
+
"monthly",
|
|
1495
|
+
"recurring",
|
|
1496
|
+
"routine",
|
|
1497
|
+
"template"
|
|
1498
|
+
];
|
|
1499
|
+
var FOLDER_PATTERNS = {
|
|
1500
|
+
daily_notes: ["daily", "dailies", "journal", "journals", "daily-notes", "daily_notes"],
|
|
1501
|
+
weekly_notes: ["weekly", "weeklies", "weekly-notes", "weekly_notes"],
|
|
1502
|
+
monthly_notes: ["monthly", "monthlies", "monthly-notes", "monthly_notes"],
|
|
1503
|
+
quarterly_notes: ["quarterly", "quarterlies", "quarterly-notes", "quarterly_notes"],
|
|
1504
|
+
yearly_notes: ["yearly", "yearlies", "annual", "yearly-notes", "yearly_notes"],
|
|
1505
|
+
templates: ["template", "templates"]
|
|
1506
|
+
};
|
|
1507
|
+
function extractFolders(index) {
|
|
1508
|
+
const folders = /* @__PURE__ */ new Set();
|
|
1509
|
+
for (const notePath of index.notes.keys()) {
|
|
1510
|
+
const dir = path3.dirname(notePath);
|
|
1511
|
+
if (dir && dir !== ".") {
|
|
1512
|
+
const parts = dir.split(/[/\\]/);
|
|
1513
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
1514
|
+
folders.add(parts.slice(0, i).join("/"));
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return Array.from(folders).sort((a, b) => {
|
|
1519
|
+
const depthA = (a.match(/\//g) || []).length;
|
|
1520
|
+
const depthB = (b.match(/\//g) || []).length;
|
|
1521
|
+
return depthA - depthB;
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
function findMatchingFolder(folders, patterns) {
|
|
1525
|
+
const lowerPatterns = patterns.map((p) => p.toLowerCase());
|
|
1526
|
+
for (const folder of folders) {
|
|
1527
|
+
const folderName = path3.basename(folder).toLowerCase();
|
|
1528
|
+
if (lowerPatterns.includes(folderName)) {
|
|
1529
|
+
return folder;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
return void 0;
|
|
1533
|
+
}
|
|
1534
|
+
function inferConfig(index, vaultPath2) {
|
|
1535
|
+
const inferred = {
|
|
1536
|
+
exclude_task_tags: [],
|
|
1537
|
+
paths: {}
|
|
1538
|
+
};
|
|
1539
|
+
if (vaultPath2) {
|
|
1540
|
+
inferred.vault_name = path3.basename(vaultPath2);
|
|
1541
|
+
}
|
|
1542
|
+
const folders = extractFolders(index);
|
|
1543
|
+
const detectedPath = findMatchingFolder(folders, FOLDER_PATTERNS.daily_notes);
|
|
1544
|
+
if (detectedPath) inferred.paths.daily_notes = detectedPath;
|
|
1545
|
+
const weeklyPath = findMatchingFolder(folders, FOLDER_PATTERNS.weekly_notes);
|
|
1546
|
+
if (weeklyPath) inferred.paths.weekly_notes = weeklyPath;
|
|
1547
|
+
const monthlyPath = findMatchingFolder(folders, FOLDER_PATTERNS.monthly_notes);
|
|
1548
|
+
if (monthlyPath) inferred.paths.monthly_notes = monthlyPath;
|
|
1549
|
+
const quarterlyPath = findMatchingFolder(folders, FOLDER_PATTERNS.quarterly_notes);
|
|
1550
|
+
if (quarterlyPath) inferred.paths.quarterly_notes = quarterlyPath;
|
|
1551
|
+
const yearlyPath = findMatchingFolder(folders, FOLDER_PATTERNS.yearly_notes);
|
|
1552
|
+
if (yearlyPath) inferred.paths.yearly_notes = yearlyPath;
|
|
1553
|
+
const templatesPath = findMatchingFolder(folders, FOLDER_PATTERNS.templates);
|
|
1554
|
+
if (templatesPath) inferred.paths.templates = templatesPath;
|
|
1555
|
+
for (const tag of index.tags.keys()) {
|
|
1556
|
+
const lowerTag = tag.toLowerCase();
|
|
1557
|
+
if (RECURRING_TAG_PATTERNS.some((pattern) => lowerTag.includes(pattern))) {
|
|
1558
|
+
inferred.exclude_task_tags.push(tag);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return inferred;
|
|
1562
|
+
}
|
|
1563
|
+
function saveConfig(vaultPath2, inferred, existing) {
|
|
1564
|
+
const claudeDir = path3.join(vaultPath2, ".claude");
|
|
1565
|
+
const configPath = path3.join(claudeDir, ".flywheel.json");
|
|
1566
|
+
try {
|
|
1567
|
+
if (!fs5.existsSync(claudeDir)) {
|
|
1568
|
+
fs5.mkdirSync(claudeDir, { recursive: true });
|
|
1569
|
+
}
|
|
1570
|
+
const mergedPaths = {
|
|
1571
|
+
...inferred.paths,
|
|
1572
|
+
...existing?.paths
|
|
1573
|
+
};
|
|
1574
|
+
const merged = {
|
|
1575
|
+
...DEFAULT_CONFIG,
|
|
1576
|
+
...inferred,
|
|
1577
|
+
...existing,
|
|
1578
|
+
// Only include paths if there are any detected values
|
|
1579
|
+
...Object.keys(mergedPaths).length > 0 ? { paths: mergedPaths } : {}
|
|
1580
|
+
};
|
|
1581
|
+
const content = JSON.stringify(merged, null, 2);
|
|
1582
|
+
fs5.writeFileSync(configPath, content, "utf-8");
|
|
1583
|
+
console.error(`[Flywheel] Saved .claude/.flywheel.json`);
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
console.error("[Flywheel] Failed to save .claude/.flywheel.json:", err);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// src/tools/system.ts
|
|
1590
|
+
function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfig) {
|
|
1591
|
+
const RefreshIndexOutputSchema = {
|
|
1592
|
+
success: z5.boolean().describe("Whether the refresh succeeded"),
|
|
1593
|
+
notes_count: z5.number().describe("Number of notes indexed"),
|
|
1594
|
+
entities_count: z5.number().describe("Number of entities (titles + aliases)"),
|
|
1595
|
+
duration_ms: z5.number().describe("Time taken to rebuild index")
|
|
1596
|
+
};
|
|
1597
|
+
server2.registerTool(
|
|
1598
|
+
"refresh_index",
|
|
1599
|
+
{
|
|
1600
|
+
title: "Refresh Index",
|
|
1601
|
+
description: "Rebuild the vault index without restarting the server. Use after making changes to notes in Obsidian.",
|
|
1602
|
+
inputSchema: {},
|
|
1603
|
+
outputSchema: RefreshIndexOutputSchema
|
|
1604
|
+
},
|
|
1605
|
+
async () => {
|
|
1606
|
+
const vaultPath2 = getVaultPath();
|
|
1607
|
+
const startTime = Date.now();
|
|
1608
|
+
setIndexState("building");
|
|
1609
|
+
setIndexError(null);
|
|
1610
|
+
try {
|
|
1611
|
+
const newIndex = await buildVaultIndex(vaultPath2);
|
|
1612
|
+
setIndex(newIndex);
|
|
1613
|
+
setIndexState("ready");
|
|
1614
|
+
if (setConfig) {
|
|
1615
|
+
const existing = loadConfig(vaultPath2);
|
|
1616
|
+
const inferred = inferConfig(newIndex, vaultPath2);
|
|
1617
|
+
saveConfig(vaultPath2, inferred, existing);
|
|
1618
|
+
setConfig(loadConfig(vaultPath2));
|
|
1619
|
+
}
|
|
1620
|
+
const output = {
|
|
1621
|
+
success: true,
|
|
1622
|
+
notes_count: newIndex.notes.size,
|
|
1623
|
+
entities_count: newIndex.entities.size,
|
|
1624
|
+
duration_ms: Date.now() - startTime
|
|
1625
|
+
};
|
|
1626
|
+
return {
|
|
1627
|
+
content: [
|
|
1628
|
+
{
|
|
1629
|
+
type: "text",
|
|
1630
|
+
text: JSON.stringify(output, null, 2)
|
|
1631
|
+
}
|
|
1632
|
+
],
|
|
1633
|
+
structuredContent: output
|
|
1634
|
+
};
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
setIndexState("error");
|
|
1637
|
+
setIndexError(err instanceof Error ? err : new Error(String(err)));
|
|
1638
|
+
const output = {
|
|
1639
|
+
success: false,
|
|
1640
|
+
notes_count: 0,
|
|
1641
|
+
entities_count: 0,
|
|
1642
|
+
duration_ms: Date.now() - startTime
|
|
1643
|
+
};
|
|
1644
|
+
return {
|
|
1645
|
+
content: [
|
|
1646
|
+
{
|
|
1647
|
+
type: "text",
|
|
1648
|
+
text: `Error refreshing index: ${err instanceof Error ? err.message : String(err)}`
|
|
1649
|
+
}
|
|
1650
|
+
],
|
|
1651
|
+
structuredContent: output
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
);
|
|
1656
|
+
const GetAllEntitiesOutputSchema = {
|
|
1657
|
+
entity_count: z5.number().describe("Total number of entities"),
|
|
1658
|
+
entities: z5.array(
|
|
1659
|
+
z5.object({
|
|
1660
|
+
name: z5.string().describe("Entity name (title or alias)"),
|
|
1661
|
+
path: z5.string().describe("Path to the note"),
|
|
1662
|
+
is_alias: z5.boolean().describe("Whether this is an alias vs title")
|
|
1663
|
+
})
|
|
1664
|
+
).describe("List of all entities")
|
|
1665
|
+
};
|
|
1666
|
+
server2.registerTool(
|
|
1667
|
+
"get_all_entities",
|
|
1668
|
+
{
|
|
1669
|
+
title: "Get All Entities",
|
|
1670
|
+
description: "Get all linkable entities in the vault (note titles and aliases). Useful for understanding what can be linked to.",
|
|
1671
|
+
inputSchema: {
|
|
1672
|
+
include_aliases: z5.boolean().default(true).describe("Include aliases in addition to titles"),
|
|
1673
|
+
limit: z5.number().optional().describe("Maximum number of entities to return")
|
|
1674
|
+
},
|
|
1675
|
+
outputSchema: GetAllEntitiesOutputSchema
|
|
1676
|
+
},
|
|
1677
|
+
async ({
|
|
1678
|
+
include_aliases,
|
|
1679
|
+
limit: requestedLimit
|
|
1680
|
+
}) => {
|
|
1681
|
+
requireIndex();
|
|
1682
|
+
const index = getIndex();
|
|
1683
|
+
const limit = requestedLimit ? Math.min(requestedLimit, MAX_LIMIT) : MAX_LIMIT;
|
|
1684
|
+
const entities = [];
|
|
1685
|
+
for (const note of index.notes.values()) {
|
|
1686
|
+
entities.push({
|
|
1687
|
+
name: note.title,
|
|
1688
|
+
path: note.path,
|
|
1689
|
+
is_alias: false
|
|
1690
|
+
});
|
|
1691
|
+
if (include_aliases) {
|
|
1692
|
+
for (const alias of note.aliases) {
|
|
1693
|
+
entities.push({
|
|
1694
|
+
name: alias,
|
|
1695
|
+
path: note.path,
|
|
1696
|
+
is_alias: true
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
entities.sort((a, b) => a.name.localeCompare(b.name));
|
|
1702
|
+
const limitedEntities = entities.slice(0, limit);
|
|
1703
|
+
const output = {
|
|
1704
|
+
entity_count: limitedEntities.length,
|
|
1705
|
+
entities: limitedEntities
|
|
1706
|
+
};
|
|
1707
|
+
return {
|
|
1708
|
+
content: [
|
|
1709
|
+
{
|
|
1710
|
+
type: "text",
|
|
1711
|
+
text: JSON.stringify(output, null, 2)
|
|
1712
|
+
}
|
|
1713
|
+
],
|
|
1714
|
+
structuredContent: output
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
);
|
|
1718
|
+
const GetRecentNotesOutputSchema = {
|
|
1719
|
+
count: z5.number().describe("Number of notes returned"),
|
|
1720
|
+
days: z5.number().describe("Number of days looked back"),
|
|
1721
|
+
notes: z5.array(
|
|
1722
|
+
z5.object({
|
|
1723
|
+
path: z5.string().describe("Path to the note"),
|
|
1724
|
+
title: z5.string().describe("Note title"),
|
|
1725
|
+
modified: z5.string().describe("Last modified date (ISO format)"),
|
|
1726
|
+
tags: z5.array(z5.string()).describe("Tags on this note")
|
|
1727
|
+
})
|
|
1728
|
+
).describe("List of recently modified notes")
|
|
1729
|
+
};
|
|
1730
|
+
server2.registerTool(
|
|
1731
|
+
"get_recent_notes",
|
|
1732
|
+
{
|
|
1733
|
+
title: "Get Recent Notes",
|
|
1734
|
+
description: "Get notes modified within the last N days. Useful for generating reviews and understanding recent activity.",
|
|
1735
|
+
inputSchema: {
|
|
1736
|
+
days: z5.number().default(7).describe("Number of days to look back"),
|
|
1737
|
+
limit: z5.number().default(50).describe("Maximum number of notes to return"),
|
|
1738
|
+
folder: z5.string().optional().describe("Limit to notes in this folder")
|
|
1739
|
+
},
|
|
1740
|
+
outputSchema: GetRecentNotesOutputSchema
|
|
1741
|
+
},
|
|
1742
|
+
async ({
|
|
1743
|
+
days,
|
|
1744
|
+
limit: requestedLimit,
|
|
1745
|
+
folder
|
|
1746
|
+
}) => {
|
|
1747
|
+
requireIndex();
|
|
1748
|
+
const index = getIndex();
|
|
1749
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
1750
|
+
const cutoffDate = /* @__PURE__ */ new Date();
|
|
1751
|
+
cutoffDate.setDate(cutoffDate.getDate() - days);
|
|
1752
|
+
const recentNotes = [];
|
|
1753
|
+
for (const note of index.notes.values()) {
|
|
1754
|
+
if (folder && !note.path.startsWith(folder)) {
|
|
1755
|
+
continue;
|
|
1756
|
+
}
|
|
1757
|
+
if (note.modified >= cutoffDate) {
|
|
1758
|
+
recentNotes.push({
|
|
1759
|
+
path: note.path,
|
|
1760
|
+
title: note.title,
|
|
1761
|
+
modified: note.modified.toISOString(),
|
|
1762
|
+
tags: note.tags,
|
|
1763
|
+
modifiedDate: note.modified
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
recentNotes.sort(
|
|
1768
|
+
(a, b) => b.modifiedDate.getTime() - a.modifiedDate.getTime()
|
|
1769
|
+
);
|
|
1770
|
+
const limitedNotes = recentNotes.slice(0, limit);
|
|
1771
|
+
const output = {
|
|
1772
|
+
count: limitedNotes.length,
|
|
1773
|
+
days,
|
|
1774
|
+
notes: limitedNotes.map((n) => ({
|
|
1775
|
+
path: n.path,
|
|
1776
|
+
title: n.title,
|
|
1777
|
+
modified: n.modified,
|
|
1778
|
+
tags: n.tags
|
|
1779
|
+
}))
|
|
1780
|
+
};
|
|
1781
|
+
return {
|
|
1782
|
+
content: [
|
|
1783
|
+
{
|
|
1784
|
+
type: "text",
|
|
1785
|
+
text: JSON.stringify(output, null, 2)
|
|
1786
|
+
}
|
|
1787
|
+
],
|
|
1788
|
+
structuredContent: output
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
);
|
|
1792
|
+
const GetUnlinkedMentionsOutputSchema = {
|
|
1793
|
+
entity: z5.string().describe("The entity searched for"),
|
|
1794
|
+
resolved_path: z5.string().optional().describe("Path of the note this entity refers to"),
|
|
1795
|
+
mention_count: z5.number().describe("Total unlinked mentions found"),
|
|
1796
|
+
mentions: z5.array(
|
|
1797
|
+
z5.object({
|
|
1798
|
+
path: z5.string().describe("Path of note with unlinked mention"),
|
|
1799
|
+
line: z5.number().describe("Line number of mention"),
|
|
1800
|
+
context: z5.string().describe("Surrounding text")
|
|
1801
|
+
})
|
|
1802
|
+
).describe("List of unlinked mentions")
|
|
1803
|
+
};
|
|
1804
|
+
server2.registerTool(
|
|
1805
|
+
"get_unlinked_mentions",
|
|
1806
|
+
{
|
|
1807
|
+
title: "Get Unlinked Mentions",
|
|
1808
|
+
description: "Find places where an entity (note title or alias) is mentioned in text but not linked. Useful for finding linking opportunities.",
|
|
1809
|
+
inputSchema: {
|
|
1810
|
+
entity: z5.string().describe('Entity to search for (e.g., "John Smith")'),
|
|
1811
|
+
limit: z5.number().default(50).describe("Maximum number of mentions to return")
|
|
1812
|
+
},
|
|
1813
|
+
outputSchema: GetUnlinkedMentionsOutputSchema
|
|
1814
|
+
},
|
|
1815
|
+
async ({
|
|
1816
|
+
entity,
|
|
1817
|
+
limit: requestedLimit
|
|
1818
|
+
}) => {
|
|
1819
|
+
requireIndex();
|
|
1820
|
+
const index = getIndex();
|
|
1821
|
+
const vaultPath2 = getVaultPath();
|
|
1822
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
1823
|
+
const normalizedEntity = entity.toLowerCase();
|
|
1824
|
+
const resolvedPath = index.entities.get(normalizedEntity);
|
|
1825
|
+
const mentions = [];
|
|
1826
|
+
for (const note of index.notes.values()) {
|
|
1827
|
+
if (resolvedPath && note.path === resolvedPath) {
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
try {
|
|
1831
|
+
const fullPath = path4.join(vaultPath2, note.path);
|
|
1832
|
+
const content = await fs6.promises.readFile(fullPath, "utf-8");
|
|
1833
|
+
const lines = content.split("\n");
|
|
1834
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1835
|
+
const line = lines[i];
|
|
1836
|
+
const lowerLine = line.toLowerCase();
|
|
1837
|
+
if (!lowerLine.includes(normalizedEntity)) {
|
|
1838
|
+
continue;
|
|
1839
|
+
}
|
|
1840
|
+
const linkPattern = new RegExp(
|
|
1841
|
+
`\\[\\[[^\\]]*${entity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\]]*\\]\\]`,
|
|
1842
|
+
"i"
|
|
1843
|
+
);
|
|
1844
|
+
if (linkPattern.test(line)) {
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
mentions.push({
|
|
1848
|
+
path: note.path,
|
|
1849
|
+
line: i + 1,
|
|
1850
|
+
context: line.trim().slice(0, 200)
|
|
1851
|
+
});
|
|
1852
|
+
if (mentions.length >= limit) {
|
|
1853
|
+
break;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
} catch {
|
|
1857
|
+
}
|
|
1858
|
+
if (mentions.length >= limit) {
|
|
1859
|
+
break;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
const output = {
|
|
1863
|
+
entity,
|
|
1864
|
+
resolved_path: resolvedPath,
|
|
1865
|
+
mention_count: mentions.length,
|
|
1866
|
+
mentions
|
|
1867
|
+
};
|
|
1868
|
+
return {
|
|
1869
|
+
content: [
|
|
1870
|
+
{
|
|
1871
|
+
type: "text",
|
|
1872
|
+
text: JSON.stringify(output, null, 2)
|
|
1873
|
+
}
|
|
1874
|
+
],
|
|
1875
|
+
structuredContent: output
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
);
|
|
1879
|
+
const GetNoteMetadataOutputSchema = {
|
|
1880
|
+
path: z5.string().describe("Path to the note"),
|
|
1881
|
+
title: z5.string().describe("Note title"),
|
|
1882
|
+
exists: z5.boolean().describe("Whether the note exists"),
|
|
1883
|
+
frontmatter: z5.record(z5.unknown()).describe("Frontmatter properties"),
|
|
1884
|
+
tags: z5.array(z5.string()).describe("Tags on this note"),
|
|
1885
|
+
aliases: z5.array(z5.string()).describe("Aliases for this note"),
|
|
1886
|
+
outlink_count: z5.number().describe("Number of outgoing links"),
|
|
1887
|
+
backlink_count: z5.number().describe("Number of incoming links"),
|
|
1888
|
+
word_count: z5.number().optional().describe("Approximate word count"),
|
|
1889
|
+
created: z5.string().optional().describe("Created date (ISO format)"),
|
|
1890
|
+
modified: z5.string().describe("Last modified date (ISO format)")
|
|
1891
|
+
};
|
|
1892
|
+
server2.registerTool(
|
|
1893
|
+
"get_note_metadata",
|
|
1894
|
+
{
|
|
1895
|
+
title: "Get Note Metadata",
|
|
1896
|
+
description: "Get metadata about a note (frontmatter, tags, link counts) without reading full content. Useful for quick analysis.",
|
|
1897
|
+
inputSchema: {
|
|
1898
|
+
path: z5.string().describe("Path to the note"),
|
|
1899
|
+
include_word_count: z5.boolean().default(false).describe("Count words (requires reading file)")
|
|
1900
|
+
},
|
|
1901
|
+
outputSchema: GetNoteMetadataOutputSchema
|
|
1902
|
+
},
|
|
1903
|
+
async ({
|
|
1904
|
+
path: notePath,
|
|
1905
|
+
include_word_count
|
|
1906
|
+
}) => {
|
|
1907
|
+
requireIndex();
|
|
1908
|
+
const index = getIndex();
|
|
1909
|
+
const vaultPath2 = getVaultPath();
|
|
1910
|
+
let resolvedPath = notePath;
|
|
1911
|
+
if (!notePath.endsWith(".md")) {
|
|
1912
|
+
const resolved = index.entities.get(notePath.toLowerCase());
|
|
1913
|
+
if (resolved) {
|
|
1914
|
+
resolvedPath = resolved;
|
|
1915
|
+
} else {
|
|
1916
|
+
resolvedPath = notePath + ".md";
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
const note = index.notes.get(resolvedPath);
|
|
1920
|
+
if (!note) {
|
|
1921
|
+
const output2 = {
|
|
1922
|
+
path: resolvedPath,
|
|
1923
|
+
title: resolvedPath.replace(/\.md$/, "").split("/").pop() || "",
|
|
1924
|
+
exists: false,
|
|
1925
|
+
frontmatter: {},
|
|
1926
|
+
tags: [],
|
|
1927
|
+
aliases: [],
|
|
1928
|
+
outlink_count: 0,
|
|
1929
|
+
backlink_count: 0,
|
|
1930
|
+
modified: (/* @__PURE__ */ new Date()).toISOString()
|
|
1931
|
+
};
|
|
1932
|
+
return {
|
|
1933
|
+
content: [
|
|
1934
|
+
{
|
|
1935
|
+
type: "text",
|
|
1936
|
+
text: JSON.stringify(output2, null, 2)
|
|
1937
|
+
}
|
|
1938
|
+
],
|
|
1939
|
+
structuredContent: output2
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
const normalizedPath = resolvedPath.toLowerCase().replace(/\.md$/, "");
|
|
1943
|
+
const backlinks = index.backlinks.get(normalizedPath) || [];
|
|
1944
|
+
let wordCount;
|
|
1945
|
+
if (include_word_count) {
|
|
1946
|
+
try {
|
|
1947
|
+
const fullPath = path4.join(vaultPath2, resolvedPath);
|
|
1948
|
+
const content = await fs6.promises.readFile(fullPath, "utf-8");
|
|
1949
|
+
wordCount = content.split(/\s+/).filter((w) => w.length > 0).length;
|
|
1950
|
+
} catch {
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
const output = {
|
|
1954
|
+
path: note.path,
|
|
1955
|
+
title: note.title,
|
|
1956
|
+
exists: true,
|
|
1957
|
+
frontmatter: note.frontmatter,
|
|
1958
|
+
tags: note.tags,
|
|
1959
|
+
aliases: note.aliases,
|
|
1960
|
+
outlink_count: note.outlinks.length,
|
|
1961
|
+
backlink_count: backlinks.length,
|
|
1962
|
+
word_count: wordCount,
|
|
1963
|
+
created: note.created?.toISOString(),
|
|
1964
|
+
modified: note.modified.toISOString()
|
|
1965
|
+
};
|
|
1966
|
+
return {
|
|
1967
|
+
content: [
|
|
1968
|
+
{
|
|
1969
|
+
type: "text",
|
|
1970
|
+
text: JSON.stringify(output, null, 2)
|
|
1971
|
+
}
|
|
1972
|
+
],
|
|
1973
|
+
structuredContent: output
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
);
|
|
1977
|
+
const GetFolderStructureOutputSchema = {
|
|
1978
|
+
folder_count: z5.number().describe("Total number of folders"),
|
|
1979
|
+
folders: z5.array(
|
|
1980
|
+
z5.object({
|
|
1981
|
+
path: z5.string().describe("Folder path"),
|
|
1982
|
+
note_count: z5.number().describe("Number of notes in this folder"),
|
|
1983
|
+
subfolder_count: z5.number().describe("Number of direct subfolders")
|
|
1984
|
+
})
|
|
1985
|
+
).describe("List of folders with note counts")
|
|
1986
|
+
};
|
|
1987
|
+
server2.registerTool(
|
|
1988
|
+
"get_folder_structure",
|
|
1989
|
+
{
|
|
1990
|
+
title: "Get Folder Structure",
|
|
1991
|
+
description: "Get the folder structure of the vault with note counts. Useful for understanding vault organization.",
|
|
1992
|
+
inputSchema: {},
|
|
1993
|
+
outputSchema: GetFolderStructureOutputSchema
|
|
1994
|
+
},
|
|
1995
|
+
async () => {
|
|
1996
|
+
requireIndex();
|
|
1997
|
+
const index = getIndex();
|
|
1998
|
+
const folderCounts = /* @__PURE__ */ new Map();
|
|
1999
|
+
const subfolders = /* @__PURE__ */ new Map();
|
|
2000
|
+
for (const note of index.notes.values()) {
|
|
2001
|
+
const parts = note.path.split("/");
|
|
2002
|
+
if (parts.length === 1) {
|
|
2003
|
+
folderCounts.set("/", (folderCounts.get("/") || 0) + 1);
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
const folderPath = parts.slice(0, -1).join("/");
|
|
2007
|
+
folderCounts.set(folderPath, (folderCounts.get(folderPath) || 0) + 1);
|
|
2008
|
+
for (let i = 1; i < parts.length - 1; i++) {
|
|
2009
|
+
const parent = parts.slice(0, i).join("/") || "/";
|
|
2010
|
+
const child = parts.slice(0, i + 1).join("/");
|
|
2011
|
+
if (!subfolders.has(parent)) {
|
|
2012
|
+
subfolders.set(parent, /* @__PURE__ */ new Set());
|
|
2013
|
+
}
|
|
2014
|
+
subfolders.get(parent).add(child);
|
|
2015
|
+
if (!folderCounts.has(parent)) {
|
|
2016
|
+
folderCounts.set(parent, 0);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
const folders = [];
|
|
2021
|
+
for (const [folderPath, noteCount] of folderCounts) {
|
|
2022
|
+
folders.push({
|
|
2023
|
+
path: folderPath,
|
|
2024
|
+
note_count: noteCount,
|
|
2025
|
+
subfolder_count: subfolders.get(folderPath)?.size || 0
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
folders.sort((a, b) => a.path.localeCompare(b.path));
|
|
2029
|
+
const output = {
|
|
2030
|
+
folder_count: folders.length,
|
|
2031
|
+
folders
|
|
2032
|
+
};
|
|
2033
|
+
return {
|
|
2034
|
+
content: [
|
|
2035
|
+
{
|
|
2036
|
+
type: "text",
|
|
2037
|
+
text: JSON.stringify(output, null, 2)
|
|
2038
|
+
}
|
|
2039
|
+
],
|
|
2040
|
+
structuredContent: output
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// src/tools/primitives.ts
|
|
2047
|
+
import { z as z6 } from "zod";
|
|
2048
|
+
|
|
2049
|
+
// src/tools/temporal.ts
|
|
2050
|
+
function getNotesModifiedOn(index, date) {
|
|
2051
|
+
const targetDate = new Date(date);
|
|
2052
|
+
const targetDay = targetDate.toISOString().split("T")[0];
|
|
2053
|
+
const results = [];
|
|
2054
|
+
for (const note of index.notes.values()) {
|
|
2055
|
+
const noteDay = note.modified.toISOString().split("T")[0];
|
|
2056
|
+
if (noteDay === targetDay) {
|
|
2057
|
+
results.push({
|
|
2058
|
+
path: note.path,
|
|
2059
|
+
title: note.title,
|
|
2060
|
+
created: note.created,
|
|
2061
|
+
modified: note.modified
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
return results.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
2066
|
+
}
|
|
2067
|
+
function getNotesInRange(index, startDate, endDate) {
|
|
2068
|
+
const start = new Date(startDate);
|
|
2069
|
+
start.setHours(0, 0, 0, 0);
|
|
2070
|
+
const end = new Date(endDate);
|
|
2071
|
+
end.setHours(23, 59, 59, 999);
|
|
2072
|
+
const results = [];
|
|
2073
|
+
for (const note of index.notes.values()) {
|
|
2074
|
+
if (note.modified >= start && note.modified <= end) {
|
|
2075
|
+
results.push({
|
|
2076
|
+
path: note.path,
|
|
2077
|
+
title: note.title,
|
|
2078
|
+
created: note.created,
|
|
2079
|
+
modified: note.modified
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
return results.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
2084
|
+
}
|
|
2085
|
+
function getStaleNotes(index, days, minBacklinks = 0) {
|
|
2086
|
+
const cutoff = /* @__PURE__ */ new Date();
|
|
2087
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
2088
|
+
const results = [];
|
|
2089
|
+
for (const note of index.notes.values()) {
|
|
2090
|
+
if (note.modified < cutoff) {
|
|
2091
|
+
const backlinkCount = getBacklinksForNote(index, note.path).length;
|
|
2092
|
+
if (backlinkCount >= minBacklinks) {
|
|
2093
|
+
const daysSince = Math.floor(
|
|
2094
|
+
(Date.now() - note.modified.getTime()) / (1e3 * 60 * 60 * 24)
|
|
2095
|
+
);
|
|
2096
|
+
results.push({
|
|
2097
|
+
path: note.path,
|
|
2098
|
+
title: note.title,
|
|
2099
|
+
backlink_count: backlinkCount,
|
|
2100
|
+
days_since_modified: daysSince,
|
|
2101
|
+
modified: note.modified
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
return results.sort((a, b) => {
|
|
2107
|
+
if (b.backlink_count !== a.backlink_count) {
|
|
2108
|
+
return b.backlink_count - a.backlink_count;
|
|
2109
|
+
}
|
|
2110
|
+
return b.days_since_modified - a.days_since_modified;
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
function getContemporaneousNotes(index, path11, hours = 24) {
|
|
2114
|
+
const targetNote = index.notes.get(path11);
|
|
2115
|
+
if (!targetNote) {
|
|
2116
|
+
return [];
|
|
2117
|
+
}
|
|
2118
|
+
const targetTime = targetNote.modified.getTime();
|
|
2119
|
+
const windowMs = hours * 60 * 60 * 1e3;
|
|
2120
|
+
const results = [];
|
|
2121
|
+
for (const note of index.notes.values()) {
|
|
2122
|
+
if (note.path === path11) continue;
|
|
2123
|
+
const timeDiff = Math.abs(note.modified.getTime() - targetTime);
|
|
2124
|
+
if (timeDiff <= windowMs) {
|
|
2125
|
+
results.push({
|
|
2126
|
+
path: note.path,
|
|
2127
|
+
title: note.title,
|
|
2128
|
+
modified: note.modified,
|
|
2129
|
+
time_diff_hours: Math.round(timeDiff / (1e3 * 60 * 60) * 10) / 10
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return results.sort((a, b) => a.time_diff_hours - b.time_diff_hours);
|
|
2134
|
+
}
|
|
2135
|
+
function getActivitySummary(index, days) {
|
|
2136
|
+
const cutoff = /* @__PURE__ */ new Date();
|
|
2137
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
2138
|
+
cutoff.setHours(0, 0, 0, 0);
|
|
2139
|
+
const dailyCounts = {};
|
|
2140
|
+
let notesModified = 0;
|
|
2141
|
+
let notesCreated = 0;
|
|
2142
|
+
for (const note of index.notes.values()) {
|
|
2143
|
+
if (note.modified >= cutoff) {
|
|
2144
|
+
notesModified++;
|
|
2145
|
+
const day = note.modified.toISOString().split("T")[0];
|
|
2146
|
+
dailyCounts[day] = (dailyCounts[day] || 0) + 1;
|
|
2147
|
+
}
|
|
2148
|
+
if (note.created && note.created >= cutoff) {
|
|
2149
|
+
notesCreated++;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
let mostActiveDay = null;
|
|
2153
|
+
let maxCount = 0;
|
|
2154
|
+
for (const [day, count] of Object.entries(dailyCounts)) {
|
|
2155
|
+
if (count > maxCount) {
|
|
2156
|
+
maxCount = count;
|
|
2157
|
+
mostActiveDay = day;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
return {
|
|
2161
|
+
period_days: days,
|
|
2162
|
+
notes_modified: notesModified,
|
|
2163
|
+
notes_created: notesCreated,
|
|
2164
|
+
most_active_day: mostActiveDay,
|
|
2165
|
+
daily_counts: dailyCounts
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// src/tools/structure.ts
|
|
2170
|
+
import * as fs7 from "fs";
|
|
2171
|
+
import * as path5 from "path";
|
|
2172
|
+
var HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
|
|
2173
|
+
function extractHeadings(content) {
|
|
2174
|
+
const lines = content.split("\n");
|
|
2175
|
+
const headings = [];
|
|
2176
|
+
let inCodeBlock = false;
|
|
2177
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2178
|
+
const line = lines[i];
|
|
2179
|
+
if (line.startsWith("```")) {
|
|
2180
|
+
inCodeBlock = !inCodeBlock;
|
|
2181
|
+
continue;
|
|
2182
|
+
}
|
|
2183
|
+
if (inCodeBlock) continue;
|
|
2184
|
+
const match = line.match(HEADING_REGEX);
|
|
2185
|
+
if (match) {
|
|
2186
|
+
headings.push({
|
|
2187
|
+
level: match[1].length,
|
|
2188
|
+
text: match[2].trim(),
|
|
2189
|
+
line: i + 1
|
|
2190
|
+
// 1-indexed
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
return headings;
|
|
2195
|
+
}
|
|
2196
|
+
function buildSections(headings, totalLines) {
|
|
2197
|
+
if (headings.length === 0) return [];
|
|
2198
|
+
const sections = [];
|
|
2199
|
+
const stack = [];
|
|
2200
|
+
for (let i = 0; i < headings.length; i++) {
|
|
2201
|
+
const heading = headings[i];
|
|
2202
|
+
const nextHeading = headings[i + 1];
|
|
2203
|
+
const lineEnd = nextHeading ? nextHeading.line - 1 : totalLines;
|
|
2204
|
+
const section = {
|
|
2205
|
+
heading,
|
|
2206
|
+
line_start: heading.line,
|
|
2207
|
+
line_end: lineEnd,
|
|
2208
|
+
subsections: []
|
|
2209
|
+
};
|
|
2210
|
+
while (stack.length > 0 && stack[stack.length - 1].heading.level >= heading.level) {
|
|
2211
|
+
stack.pop();
|
|
2212
|
+
}
|
|
2213
|
+
if (stack.length === 0) {
|
|
2214
|
+
sections.push(section);
|
|
2215
|
+
} else {
|
|
2216
|
+
stack[stack.length - 1].subsections.push(section);
|
|
2217
|
+
}
|
|
2218
|
+
stack.push(section);
|
|
2219
|
+
}
|
|
2220
|
+
return sections;
|
|
2221
|
+
}
|
|
2222
|
+
async function getNoteStructure(index, notePath, vaultPath2) {
|
|
2223
|
+
const note = index.notes.get(notePath);
|
|
2224
|
+
if (!note) return null;
|
|
2225
|
+
const absolutePath = path5.join(vaultPath2, notePath);
|
|
2226
|
+
let content;
|
|
2227
|
+
try {
|
|
2228
|
+
content = await fs7.promises.readFile(absolutePath, "utf-8");
|
|
2229
|
+
} catch {
|
|
2230
|
+
return null;
|
|
2231
|
+
}
|
|
2232
|
+
const lines = content.split("\n");
|
|
2233
|
+
const headings = extractHeadings(content);
|
|
2234
|
+
const sections = buildSections(headings, lines.length);
|
|
2235
|
+
const contentWithoutCode = content.replace(/```[\s\S]*?```/g, "");
|
|
2236
|
+
const words = contentWithoutCode.split(/\s+/).filter((w) => w.length > 0);
|
|
2237
|
+
return {
|
|
2238
|
+
path: notePath,
|
|
2239
|
+
headings,
|
|
2240
|
+
sections,
|
|
2241
|
+
word_count: words.length,
|
|
2242
|
+
line_count: lines.length
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
async function getHeadings(index, notePath, vaultPath2) {
|
|
2246
|
+
const note = index.notes.get(notePath);
|
|
2247
|
+
if (!note) return null;
|
|
2248
|
+
const absolutePath = path5.join(vaultPath2, notePath);
|
|
2249
|
+
let content;
|
|
2250
|
+
try {
|
|
2251
|
+
content = await fs7.promises.readFile(absolutePath, "utf-8");
|
|
2252
|
+
} catch {
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
return extractHeadings(content);
|
|
2256
|
+
}
|
|
2257
|
+
async function getSectionContent(index, notePath, headingText, vaultPath2, includeSubheadings = true) {
|
|
2258
|
+
const note = index.notes.get(notePath);
|
|
2259
|
+
if (!note) return null;
|
|
2260
|
+
const absolutePath = path5.join(vaultPath2, notePath);
|
|
2261
|
+
let content;
|
|
2262
|
+
try {
|
|
2263
|
+
content = await fs7.promises.readFile(absolutePath, "utf-8");
|
|
2264
|
+
} catch {
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2267
|
+
const lines = content.split("\n");
|
|
2268
|
+
const headings = extractHeadings(content);
|
|
2269
|
+
const targetHeading = headings.find(
|
|
2270
|
+
(h) => h.text.toLowerCase() === headingText.toLowerCase()
|
|
2271
|
+
);
|
|
2272
|
+
if (!targetHeading) return null;
|
|
2273
|
+
let lineEnd = lines.length;
|
|
2274
|
+
for (const h of headings) {
|
|
2275
|
+
if (h.line > targetHeading.line) {
|
|
2276
|
+
if (includeSubheadings) {
|
|
2277
|
+
if (h.level <= targetHeading.level) {
|
|
2278
|
+
lineEnd = h.line - 1;
|
|
2279
|
+
break;
|
|
2280
|
+
}
|
|
2281
|
+
} else {
|
|
2282
|
+
lineEnd = h.line - 1;
|
|
2283
|
+
break;
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
const sectionLines = lines.slice(targetHeading.line, lineEnd);
|
|
2288
|
+
const sectionContent = sectionLines.join("\n").trim();
|
|
2289
|
+
return {
|
|
2290
|
+
heading: targetHeading.text,
|
|
2291
|
+
level: targetHeading.level,
|
|
2292
|
+
content: sectionContent,
|
|
2293
|
+
line_start: targetHeading.line,
|
|
2294
|
+
line_end: lineEnd
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
async function findSections(index, headingPattern, vaultPath2, folder) {
|
|
2298
|
+
const regex = new RegExp(headingPattern, "i");
|
|
2299
|
+
const results = [];
|
|
2300
|
+
for (const note of index.notes.values()) {
|
|
2301
|
+
if (folder && !note.path.startsWith(folder)) continue;
|
|
2302
|
+
const absolutePath = path5.join(vaultPath2, note.path);
|
|
2303
|
+
let content;
|
|
2304
|
+
try {
|
|
2305
|
+
content = await fs7.promises.readFile(absolutePath, "utf-8");
|
|
2306
|
+
} catch {
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
const headings = extractHeadings(content);
|
|
2310
|
+
for (const heading of headings) {
|
|
2311
|
+
if (regex.test(heading.text)) {
|
|
2312
|
+
results.push({
|
|
2313
|
+
path: note.path,
|
|
2314
|
+
heading: heading.text,
|
|
2315
|
+
level: heading.level,
|
|
2316
|
+
line: heading.line
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
return results;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// src/tools/tasks.ts
|
|
2325
|
+
import * as fs8 from "fs";
|
|
2326
|
+
import * as path6 from "path";
|
|
2327
|
+
var TASK_REGEX = /^(\s*)- \[([ xX\-])\]\s+(.+)$/;
|
|
2328
|
+
var TAG_REGEX2 = /#([a-zA-Z][a-zA-Z0-9_/-]*)/g;
|
|
2329
|
+
var DATE_REGEX = /\b(\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{2,4})\b/;
|
|
2330
|
+
var HEADING_REGEX2 = /^(#{1,6})\s+(.+)$/;
|
|
2331
|
+
function parseStatus(char) {
|
|
2332
|
+
if (char === " ") return "open";
|
|
2333
|
+
if (char === "-") return "cancelled";
|
|
2334
|
+
return "completed";
|
|
2335
|
+
}
|
|
2336
|
+
function extractTags2(text) {
|
|
2337
|
+
const tags = [];
|
|
2338
|
+
let match;
|
|
2339
|
+
TAG_REGEX2.lastIndex = 0;
|
|
2340
|
+
while ((match = TAG_REGEX2.exec(text)) !== null) {
|
|
2341
|
+
tags.push(match[1]);
|
|
2342
|
+
}
|
|
2343
|
+
return tags;
|
|
2344
|
+
}
|
|
2345
|
+
function extractDueDate(text) {
|
|
2346
|
+
const match = text.match(DATE_REGEX);
|
|
2347
|
+
return match ? match[1] : void 0;
|
|
2348
|
+
}
|
|
2349
|
+
async function extractTasksFromNote(notePath, absolutePath) {
|
|
2350
|
+
let content;
|
|
2351
|
+
try {
|
|
2352
|
+
content = await fs8.promises.readFile(absolutePath, "utf-8");
|
|
2353
|
+
} catch {
|
|
2354
|
+
return [];
|
|
2355
|
+
}
|
|
2356
|
+
const lines = content.split("\n");
|
|
2357
|
+
const tasks = [];
|
|
2358
|
+
let currentHeading;
|
|
2359
|
+
let inCodeBlock = false;
|
|
2360
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2361
|
+
const line = lines[i];
|
|
2362
|
+
if (line.startsWith("```")) {
|
|
2363
|
+
inCodeBlock = !inCodeBlock;
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
if (inCodeBlock) continue;
|
|
2367
|
+
const headingMatch = line.match(HEADING_REGEX2);
|
|
2368
|
+
if (headingMatch) {
|
|
2369
|
+
currentHeading = headingMatch[2].trim();
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
const taskMatch = line.match(TASK_REGEX);
|
|
2373
|
+
if (taskMatch) {
|
|
2374
|
+
const statusChar = taskMatch[2];
|
|
2375
|
+
const text = taskMatch[3].trim();
|
|
2376
|
+
tasks.push({
|
|
2377
|
+
path: notePath,
|
|
2378
|
+
line: i + 1,
|
|
2379
|
+
text,
|
|
2380
|
+
status: parseStatus(statusChar),
|
|
2381
|
+
raw: line,
|
|
2382
|
+
context: currentHeading,
|
|
2383
|
+
tags: extractTags2(text),
|
|
2384
|
+
due_date: extractDueDate(text)
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
return tasks;
|
|
2389
|
+
}
|
|
2390
|
+
async function getAllTasks(index, vaultPath2, options = {}) {
|
|
2391
|
+
const { status = "all", folder, tag, excludeTags = [], limit } = options;
|
|
2392
|
+
const allTasks = [];
|
|
2393
|
+
for (const note of index.notes.values()) {
|
|
2394
|
+
if (folder && !note.path.startsWith(folder)) continue;
|
|
2395
|
+
const absolutePath = path6.join(vaultPath2, note.path);
|
|
2396
|
+
const tasks = await extractTasksFromNote(note.path, absolutePath);
|
|
2397
|
+
allTasks.push(...tasks);
|
|
2398
|
+
}
|
|
2399
|
+
let filteredTasks = allTasks;
|
|
2400
|
+
if (status !== "all") {
|
|
2401
|
+
filteredTasks = allTasks.filter((t) => t.status === status);
|
|
2402
|
+
}
|
|
2403
|
+
if (tag) {
|
|
2404
|
+
filteredTasks = filteredTasks.filter((t) => t.tags.includes(tag));
|
|
2405
|
+
}
|
|
2406
|
+
if (excludeTags.length > 0) {
|
|
2407
|
+
filteredTasks = filteredTasks.filter(
|
|
2408
|
+
(t) => !excludeTags.some((excludeTag) => t.tags.includes(excludeTag))
|
|
2409
|
+
);
|
|
2410
|
+
}
|
|
2411
|
+
const openCount = allTasks.filter((t) => t.status === "open").length;
|
|
2412
|
+
const completedCount = allTasks.filter((t) => t.status === "completed").length;
|
|
2413
|
+
const cancelledCount = allTasks.filter((t) => t.status === "cancelled").length;
|
|
2414
|
+
const returnTasks = limit ? filteredTasks.slice(0, limit) : filteredTasks;
|
|
2415
|
+
return {
|
|
2416
|
+
total: allTasks.length,
|
|
2417
|
+
open_count: openCount,
|
|
2418
|
+
completed_count: completedCount,
|
|
2419
|
+
cancelled_count: cancelledCount,
|
|
2420
|
+
tasks: returnTasks
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
async function getTasksFromNote(index, notePath, vaultPath2, excludeTags = []) {
|
|
2424
|
+
const note = index.notes.get(notePath);
|
|
2425
|
+
if (!note) return null;
|
|
2426
|
+
const absolutePath = path6.join(vaultPath2, notePath);
|
|
2427
|
+
let tasks = await extractTasksFromNote(notePath, absolutePath);
|
|
2428
|
+
if (excludeTags.length > 0) {
|
|
2429
|
+
tasks = tasks.filter(
|
|
2430
|
+
(t) => !excludeTags.some((excludeTag) => t.tags.includes(excludeTag))
|
|
2431
|
+
);
|
|
2432
|
+
}
|
|
2433
|
+
return tasks;
|
|
2434
|
+
}
|
|
2435
|
+
async function getTasksWithDueDates(index, vaultPath2, options = {}) {
|
|
2436
|
+
const { status = "open", folder, excludeTags } = options;
|
|
2437
|
+
const result = await getAllTasks(index, vaultPath2, { status, folder, excludeTags });
|
|
2438
|
+
return result.tasks.filter((t) => t.due_date).sort((a, b) => {
|
|
2439
|
+
const dateA = a.due_date || "";
|
|
2440
|
+
const dateB = b.due_date || "";
|
|
2441
|
+
return dateA.localeCompare(dateB);
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// src/tools/graphAdvanced.ts
|
|
2446
|
+
function getLinkPath(index, fromPath, toPath, maxDepth = 10) {
|
|
2447
|
+
const from = index.notes.has(fromPath) ? fromPath : resolveTarget(index, fromPath);
|
|
2448
|
+
const to = index.notes.has(toPath) ? toPath : resolveTarget(index, toPath);
|
|
2449
|
+
if (!from || !to) {
|
|
2450
|
+
return { exists: false, path: [], length: -1 };
|
|
2451
|
+
}
|
|
2452
|
+
if (from === to) {
|
|
2453
|
+
return { exists: true, path: [from], length: 0 };
|
|
2454
|
+
}
|
|
2455
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2456
|
+
const queue = [{ path: [from], current: from }];
|
|
2457
|
+
while (queue.length > 0) {
|
|
2458
|
+
const { path: currentPath, current } = queue.shift();
|
|
2459
|
+
if (currentPath.length > maxDepth) {
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
const note = index.notes.get(current);
|
|
2463
|
+
if (!note) continue;
|
|
2464
|
+
for (const link of note.outlinks) {
|
|
2465
|
+
const targetPath = resolveTarget(index, link.target);
|
|
2466
|
+
if (!targetPath) continue;
|
|
2467
|
+
if (targetPath === to) {
|
|
2468
|
+
const fullPath = [...currentPath, targetPath];
|
|
2469
|
+
return {
|
|
2470
|
+
exists: true,
|
|
2471
|
+
path: fullPath,
|
|
2472
|
+
length: fullPath.length - 1
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
if (!visited.has(targetPath)) {
|
|
2476
|
+
visited.add(targetPath);
|
|
2477
|
+
queue.push({
|
|
2478
|
+
path: [...currentPath, targetPath],
|
|
2479
|
+
current: targetPath
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
return { exists: false, path: [], length: -1 };
|
|
2485
|
+
}
|
|
2486
|
+
function getCommonNeighbors(index, noteAPath, noteBPath) {
|
|
2487
|
+
const noteA = index.notes.get(noteAPath);
|
|
2488
|
+
const noteB = index.notes.get(noteBPath);
|
|
2489
|
+
if (!noteA || !noteB) return [];
|
|
2490
|
+
const aTargets = /* @__PURE__ */ new Map();
|
|
2491
|
+
for (const link of noteA.outlinks) {
|
|
2492
|
+
const resolved = resolveTarget(index, link.target);
|
|
2493
|
+
if (resolved) {
|
|
2494
|
+
aTargets.set(resolved, link.line);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
const common = [];
|
|
2498
|
+
for (const link of noteB.outlinks) {
|
|
2499
|
+
const resolved = resolveTarget(index, link.target);
|
|
2500
|
+
if (resolved && aTargets.has(resolved)) {
|
|
2501
|
+
const targetNote = index.notes.get(resolved);
|
|
2502
|
+
if (targetNote) {
|
|
2503
|
+
common.push({
|
|
2504
|
+
path: resolved,
|
|
2505
|
+
title: targetNote.title,
|
|
2506
|
+
linked_from_a_line: aTargets.get(resolved),
|
|
2507
|
+
linked_from_b_line: link.line
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
return common;
|
|
2513
|
+
}
|
|
2514
|
+
function findBidirectionalLinks(index, notePath) {
|
|
2515
|
+
const results = [];
|
|
2516
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2517
|
+
const notesToCheck = notePath ? [index.notes.get(notePath)].filter(Boolean) : Array.from(index.notes.values());
|
|
2518
|
+
for (const noteA of notesToCheck) {
|
|
2519
|
+
if (!noteA) continue;
|
|
2520
|
+
for (const linkFromA of noteA.outlinks) {
|
|
2521
|
+
const targetPath = resolveTarget(index, linkFromA.target);
|
|
2522
|
+
if (!targetPath) continue;
|
|
2523
|
+
const noteB = index.notes.get(targetPath);
|
|
2524
|
+
if (!noteB) continue;
|
|
2525
|
+
for (const linkFromB of noteB.outlinks) {
|
|
2526
|
+
const backTarget = resolveTarget(index, linkFromB.target);
|
|
2527
|
+
if (backTarget === noteA.path) {
|
|
2528
|
+
const pairKey = [noteA.path, noteB.path].sort().join("|");
|
|
2529
|
+
if (!seen.has(pairKey)) {
|
|
2530
|
+
seen.add(pairKey);
|
|
2531
|
+
results.push({
|
|
2532
|
+
noteA: noteA.path,
|
|
2533
|
+
noteB: noteB.path,
|
|
2534
|
+
a_to_b_line: linkFromA.line,
|
|
2535
|
+
b_to_a_line: linkFromB.line
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
return results;
|
|
2543
|
+
}
|
|
2544
|
+
function findDeadEnds(index, folder, minBacklinks = 1) {
|
|
2545
|
+
const results = [];
|
|
2546
|
+
for (const note of index.notes.values()) {
|
|
2547
|
+
if (folder && !note.path.startsWith(folder)) continue;
|
|
2548
|
+
if (note.outlinks.length === 0) {
|
|
2549
|
+
const backlinkCount = getBacklinksForNote(index, note.path).length;
|
|
2550
|
+
if (backlinkCount >= minBacklinks) {
|
|
2551
|
+
results.push({
|
|
2552
|
+
path: note.path,
|
|
2553
|
+
title: note.title,
|
|
2554
|
+
backlink_count: backlinkCount
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
return results.sort((a, b) => b.backlink_count - a.backlink_count);
|
|
2560
|
+
}
|
|
2561
|
+
function findSources(index, folder, minOutlinks = 1) {
|
|
2562
|
+
const results = [];
|
|
2563
|
+
for (const note of index.notes.values()) {
|
|
2564
|
+
if (folder && !note.path.startsWith(folder)) continue;
|
|
2565
|
+
const backlinkCount = getBacklinksForNote(index, note.path).length;
|
|
2566
|
+
if (note.outlinks.length >= minOutlinks && backlinkCount === 0) {
|
|
2567
|
+
results.push({
|
|
2568
|
+
path: note.path,
|
|
2569
|
+
title: note.title,
|
|
2570
|
+
outlink_count: note.outlinks.length
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
return results.sort((a, b) => b.outlink_count - a.outlink_count);
|
|
2575
|
+
}
|
|
2576
|
+
function getConnectionStrength(index, noteAPath, noteBPath) {
|
|
2577
|
+
const noteA = index.notes.get(noteAPath);
|
|
2578
|
+
const noteB = index.notes.get(noteBPath);
|
|
2579
|
+
if (!noteA || !noteB) {
|
|
2580
|
+
return {
|
|
2581
|
+
score: 0,
|
|
2582
|
+
factors: {
|
|
2583
|
+
mutual_link: false,
|
|
2584
|
+
shared_tags: [],
|
|
2585
|
+
shared_outlinks: 0,
|
|
2586
|
+
same_folder: false
|
|
2587
|
+
}
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
let score = 0;
|
|
2591
|
+
const factors = {
|
|
2592
|
+
mutual_link: false,
|
|
2593
|
+
shared_tags: [],
|
|
2594
|
+
shared_outlinks: 0,
|
|
2595
|
+
same_folder: false
|
|
2596
|
+
};
|
|
2597
|
+
const aLinksToB = noteA.outlinks.some((l) => {
|
|
2598
|
+
const resolved = resolveTarget(index, l.target);
|
|
2599
|
+
return resolved === noteBPath;
|
|
2600
|
+
});
|
|
2601
|
+
const bLinksToA = noteB.outlinks.some((l) => {
|
|
2602
|
+
const resolved = resolveTarget(index, l.target);
|
|
2603
|
+
return resolved === noteAPath;
|
|
2604
|
+
});
|
|
2605
|
+
if (aLinksToB && bLinksToA) {
|
|
2606
|
+
factors.mutual_link = true;
|
|
2607
|
+
score += 3;
|
|
2608
|
+
} else if (aLinksToB || bLinksToA) {
|
|
2609
|
+
score += 1;
|
|
2610
|
+
}
|
|
2611
|
+
const tagsA = new Set(noteA.tags);
|
|
2612
|
+
for (const tag of noteB.tags) {
|
|
2613
|
+
if (tagsA.has(tag)) {
|
|
2614
|
+
factors.shared_tags.push(tag);
|
|
2615
|
+
score += 1;
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
const common = getCommonNeighbors(index, noteAPath, noteBPath);
|
|
2619
|
+
factors.shared_outlinks = common.length;
|
|
2620
|
+
score += common.length * 0.5;
|
|
2621
|
+
const folderA = noteAPath.split("/").slice(0, -1).join("/");
|
|
2622
|
+
const folderB = noteBPath.split("/").slice(0, -1).join("/");
|
|
2623
|
+
if (folderA === folderB && folderA !== "") {
|
|
2624
|
+
factors.same_folder = true;
|
|
2625
|
+
score += 1;
|
|
2626
|
+
}
|
|
2627
|
+
return { score, factors };
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// src/tools/frontmatter.ts
|
|
2631
|
+
function getValueType(value) {
|
|
2632
|
+
if (value === null) return "null";
|
|
2633
|
+
if (value === void 0) return "undefined";
|
|
2634
|
+
if (Array.isArray(value)) return "array";
|
|
2635
|
+
if (value instanceof Date) return "date";
|
|
2636
|
+
return typeof value;
|
|
2637
|
+
}
|
|
2638
|
+
function getFrontmatterSchema(index) {
|
|
2639
|
+
const fieldMap = /* @__PURE__ */ new Map();
|
|
2640
|
+
let notesWithFrontmatter = 0;
|
|
2641
|
+
for (const note of index.notes.values()) {
|
|
2642
|
+
const fm = note.frontmatter;
|
|
2643
|
+
if (!fm || Object.keys(fm).length === 0) continue;
|
|
2644
|
+
notesWithFrontmatter++;
|
|
2645
|
+
for (const [key, value] of Object.entries(fm)) {
|
|
2646
|
+
if (!fieldMap.has(key)) {
|
|
2647
|
+
fieldMap.set(key, {
|
|
2648
|
+
types: /* @__PURE__ */ new Set(),
|
|
2649
|
+
count: 0,
|
|
2650
|
+
examples: [],
|
|
2651
|
+
notes: []
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
const info = fieldMap.get(key);
|
|
2655
|
+
info.count++;
|
|
2656
|
+
info.types.add(getValueType(value));
|
|
2657
|
+
if (info.examples.length < 5) {
|
|
2658
|
+
const valueStr = JSON.stringify(value);
|
|
2659
|
+
const existingStrs = info.examples.map((e) => JSON.stringify(e));
|
|
2660
|
+
if (!existingStrs.includes(valueStr)) {
|
|
2661
|
+
info.examples.push(value);
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
if (info.notes.length < 5) {
|
|
2665
|
+
info.notes.push(note.path);
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
const fields = Array.from(fieldMap.entries()).map(([name, info]) => ({
|
|
2670
|
+
name,
|
|
2671
|
+
types: Array.from(info.types),
|
|
2672
|
+
count: info.count,
|
|
2673
|
+
examples: info.examples,
|
|
2674
|
+
notes_sample: info.notes
|
|
2675
|
+
})).sort((a, b) => b.count - a.count);
|
|
2676
|
+
return {
|
|
2677
|
+
total_notes: index.notes.size,
|
|
2678
|
+
notes_with_frontmatter: notesWithFrontmatter,
|
|
2679
|
+
field_count: fields.length,
|
|
2680
|
+
fields
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
function getFieldValues(index, fieldName) {
|
|
2684
|
+
const valueMap = /* @__PURE__ */ new Map();
|
|
2685
|
+
let totalWithField = 0;
|
|
2686
|
+
for (const note of index.notes.values()) {
|
|
2687
|
+
const value = note.frontmatter[fieldName];
|
|
2688
|
+
if (value === void 0) continue;
|
|
2689
|
+
totalWithField++;
|
|
2690
|
+
const values = Array.isArray(value) ? value : [value];
|
|
2691
|
+
for (const v of values) {
|
|
2692
|
+
const key = JSON.stringify(v);
|
|
2693
|
+
if (!valueMap.has(key)) {
|
|
2694
|
+
valueMap.set(key, {
|
|
2695
|
+
value: v,
|
|
2696
|
+
count: 0,
|
|
2697
|
+
notes: []
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
const info = valueMap.get(key);
|
|
2701
|
+
info.count++;
|
|
2702
|
+
info.notes.push(note.path);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
const valuesList = Array.from(valueMap.values()).sort((a, b) => b.count - a.count);
|
|
2706
|
+
return {
|
|
2707
|
+
field: fieldName,
|
|
2708
|
+
total_notes_with_field: totalWithField,
|
|
2709
|
+
unique_values: valuesList.length,
|
|
2710
|
+
values: valuesList
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
function findFrontmatterInconsistencies(index) {
|
|
2714
|
+
const schema = getFrontmatterSchema(index);
|
|
2715
|
+
const inconsistencies = [];
|
|
2716
|
+
for (const field of schema.fields) {
|
|
2717
|
+
if (field.types.length > 1) {
|
|
2718
|
+
const examples = [];
|
|
2719
|
+
for (const note of index.notes.values()) {
|
|
2720
|
+
const value = note.frontmatter[field.name];
|
|
2721
|
+
if (value === void 0) continue;
|
|
2722
|
+
const type = getValueType(value);
|
|
2723
|
+
if (!examples.some((e) => e.type === type)) {
|
|
2724
|
+
examples.push({
|
|
2725
|
+
type,
|
|
2726
|
+
value,
|
|
2727
|
+
note: note.path
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
if (examples.length >= field.types.length) break;
|
|
2731
|
+
}
|
|
2732
|
+
inconsistencies.push({
|
|
2733
|
+
field: field.name,
|
|
2734
|
+
types_found: field.types,
|
|
2735
|
+
examples
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
return inconsistencies;
|
|
2740
|
+
}
|
|
2741
|
+
function validateFrontmatter(index, schema, folder) {
|
|
2742
|
+
const results = [];
|
|
2743
|
+
for (const note of index.notes.values()) {
|
|
2744
|
+
if (folder && !note.path.startsWith(folder)) continue;
|
|
2745
|
+
const issues = [];
|
|
2746
|
+
for (const [fieldName, fieldSchema] of Object.entries(schema)) {
|
|
2747
|
+
const value = note.frontmatter[fieldName];
|
|
2748
|
+
if (fieldSchema.required && value === void 0) {
|
|
2749
|
+
issues.push({
|
|
2750
|
+
field: fieldName,
|
|
2751
|
+
issue: "missing",
|
|
2752
|
+
expected: "value required"
|
|
2753
|
+
});
|
|
2754
|
+
continue;
|
|
2755
|
+
}
|
|
2756
|
+
if (value === void 0) continue;
|
|
2757
|
+
if (fieldSchema.type) {
|
|
2758
|
+
const actualType = getValueType(value);
|
|
2759
|
+
const allowedTypes = Array.isArray(fieldSchema.type) ? fieldSchema.type : [fieldSchema.type];
|
|
2760
|
+
if (!allowedTypes.includes(actualType)) {
|
|
2761
|
+
issues.push({
|
|
2762
|
+
field: fieldName,
|
|
2763
|
+
issue: "wrong_type",
|
|
2764
|
+
expected: allowedTypes.join(" | "),
|
|
2765
|
+
actual: actualType
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
if (fieldSchema.values) {
|
|
2770
|
+
const valueStr = JSON.stringify(value);
|
|
2771
|
+
const allowedStrs = fieldSchema.values.map((v) => JSON.stringify(v));
|
|
2772
|
+
if (!allowedStrs.includes(valueStr)) {
|
|
2773
|
+
issues.push({
|
|
2774
|
+
field: fieldName,
|
|
2775
|
+
issue: "invalid_value",
|
|
2776
|
+
expected: fieldSchema.values.map((v) => String(v)).join(" | "),
|
|
2777
|
+
actual: String(value)
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
if (issues.length > 0) {
|
|
2783
|
+
results.push({
|
|
2784
|
+
path: note.path,
|
|
2785
|
+
issues
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
return results;
|
|
2790
|
+
}
|
|
2791
|
+
function findMissingFrontmatter(index, folderSchemas) {
|
|
2792
|
+
const results = [];
|
|
2793
|
+
for (const note of index.notes.values()) {
|
|
2794
|
+
for (const [folder, requiredFields] of Object.entries(folderSchemas)) {
|
|
2795
|
+
if (!note.path.startsWith(folder)) continue;
|
|
2796
|
+
const missing = requiredFields.filter(
|
|
2797
|
+
(field) => note.frontmatter[field] === void 0
|
|
2798
|
+
);
|
|
2799
|
+
if (missing.length > 0) {
|
|
2800
|
+
results.push({
|
|
2801
|
+
path: note.path,
|
|
2802
|
+
folder,
|
|
2803
|
+
missing_fields: missing
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
return results;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// src/tools/primitives.ts
|
|
2812
|
+
function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig = () => ({})) {
|
|
2813
|
+
server2.registerTool(
|
|
2814
|
+
"get_notes_modified_on",
|
|
2815
|
+
{
|
|
2816
|
+
title: "Get Notes Modified On Date",
|
|
2817
|
+
description: "Get all notes that were modified on a specific date.",
|
|
2818
|
+
inputSchema: {
|
|
2819
|
+
date: z6.string().describe("Date in YYYY-MM-DD format"),
|
|
2820
|
+
limit: z6.number().default(50).describe("Maximum number of results to return"),
|
|
2821
|
+
offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
|
|
2822
|
+
}
|
|
2823
|
+
},
|
|
2824
|
+
async ({ date, limit: requestedLimit, offset }) => {
|
|
2825
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
2826
|
+
const index = getIndex();
|
|
2827
|
+
const allResults = getNotesModifiedOn(index, date);
|
|
2828
|
+
const result = allResults.slice(offset, offset + limit);
|
|
2829
|
+
return {
|
|
2830
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
2831
|
+
date,
|
|
2832
|
+
total_count: allResults.length,
|
|
2833
|
+
returned_count: result.length,
|
|
2834
|
+
notes: result.map((n) => ({
|
|
2835
|
+
...n,
|
|
2836
|
+
created: n.created?.toISOString(),
|
|
2837
|
+
modified: n.modified.toISOString()
|
|
2838
|
+
}))
|
|
2839
|
+
}, null, 2) }]
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
);
|
|
2843
|
+
server2.registerTool(
|
|
2844
|
+
"get_notes_in_range",
|
|
2845
|
+
{
|
|
2846
|
+
title: "Get Notes In Date Range",
|
|
2847
|
+
description: "Get all notes modified within a date range.",
|
|
2848
|
+
inputSchema: {
|
|
2849
|
+
start_date: z6.string().describe("Start date in YYYY-MM-DD format"),
|
|
2850
|
+
end_date: z6.string().describe("End date in YYYY-MM-DD format"),
|
|
2851
|
+
limit: z6.number().default(50).describe("Maximum number of results to return"),
|
|
2852
|
+
offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
|
|
2853
|
+
}
|
|
2854
|
+
},
|
|
2855
|
+
async ({ start_date, end_date, limit: requestedLimit, offset }) => {
|
|
2856
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
2857
|
+
const index = getIndex();
|
|
2858
|
+
const allResults = getNotesInRange(index, start_date, end_date);
|
|
2859
|
+
const result = allResults.slice(offset, offset + limit);
|
|
2860
|
+
return {
|
|
2861
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
2862
|
+
start_date,
|
|
2863
|
+
end_date,
|
|
2864
|
+
total_count: allResults.length,
|
|
2865
|
+
returned_count: result.length,
|
|
2866
|
+
notes: result.map((n) => ({
|
|
2867
|
+
...n,
|
|
2868
|
+
created: n.created?.toISOString(),
|
|
2869
|
+
modified: n.modified.toISOString()
|
|
2870
|
+
}))
|
|
2871
|
+
}, null, 2) }]
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
);
|
|
2875
|
+
server2.registerTool(
|
|
2876
|
+
"get_stale_notes",
|
|
2877
|
+
{
|
|
2878
|
+
title: "Get Stale Notes",
|
|
2879
|
+
description: "Find important notes (by backlink count) that have not been modified recently.",
|
|
2880
|
+
inputSchema: {
|
|
2881
|
+
days: z6.number().describe("Notes not modified in this many days"),
|
|
2882
|
+
min_backlinks: z6.number().default(1).describe("Minimum backlinks to be considered important"),
|
|
2883
|
+
limit: z6.number().default(50).describe("Maximum results to return")
|
|
2884
|
+
}
|
|
2885
|
+
},
|
|
2886
|
+
async ({ days, min_backlinks, limit: requestedLimit }) => {
|
|
2887
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
2888
|
+
const index = getIndex();
|
|
2889
|
+
const result = getStaleNotes(index, days, min_backlinks).slice(0, limit);
|
|
2890
|
+
return {
|
|
2891
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
2892
|
+
criteria: { days, min_backlinks },
|
|
2893
|
+
count: result.length,
|
|
2894
|
+
notes: result.map((n) => ({
|
|
2895
|
+
...n,
|
|
2896
|
+
modified: n.modified.toISOString()
|
|
2897
|
+
}))
|
|
2898
|
+
}, null, 2) }]
|
|
2899
|
+
};
|
|
2900
|
+
}
|
|
2901
|
+
);
|
|
2902
|
+
server2.registerTool(
|
|
2903
|
+
"get_contemporaneous_notes",
|
|
2904
|
+
{
|
|
2905
|
+
title: "Get Contemporaneous Notes",
|
|
2906
|
+
description: "Find notes that were edited around the same time as a given note.",
|
|
2907
|
+
inputSchema: {
|
|
2908
|
+
path: z6.string().describe("Path to the reference note"),
|
|
2909
|
+
hours: z6.number().default(24).describe("Time window in hours"),
|
|
2910
|
+
limit: z6.number().default(50).describe("Maximum number of results to return"),
|
|
2911
|
+
offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
|
|
2912
|
+
}
|
|
2913
|
+
},
|
|
2914
|
+
async ({ path: path11, hours, limit: requestedLimit, offset }) => {
|
|
2915
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
2916
|
+
const index = getIndex();
|
|
2917
|
+
const allResults = getContemporaneousNotes(index, path11, hours);
|
|
2918
|
+
const result = allResults.slice(offset, offset + limit);
|
|
2919
|
+
return {
|
|
2920
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
2921
|
+
reference_note: path11,
|
|
2922
|
+
window_hours: hours,
|
|
2923
|
+
total_count: allResults.length,
|
|
2924
|
+
returned_count: result.length,
|
|
2925
|
+
notes: result.map((n) => ({
|
|
2926
|
+
...n,
|
|
2927
|
+
modified: n.modified.toISOString()
|
|
2928
|
+
}))
|
|
2929
|
+
}, null, 2) }]
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
);
|
|
2933
|
+
server2.registerTool(
|
|
2934
|
+
"get_activity_summary",
|
|
2935
|
+
{
|
|
2936
|
+
title: "Get Activity Summary",
|
|
2937
|
+
description: "Get a summary of vault activity over a period.",
|
|
2938
|
+
inputSchema: {
|
|
2939
|
+
days: z6.number().default(7).describe("Number of days to analyze")
|
|
2940
|
+
}
|
|
2941
|
+
},
|
|
2942
|
+
async ({ days }) => {
|
|
2943
|
+
const index = getIndex();
|
|
2944
|
+
const result = getActivitySummary(index, days);
|
|
2945
|
+
return {
|
|
2946
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
);
|
|
2950
|
+
server2.registerTool(
|
|
2951
|
+
"get_note_structure",
|
|
2952
|
+
{
|
|
2953
|
+
title: "Get Note Structure",
|
|
2954
|
+
description: "Get the heading structure and sections of a note.",
|
|
2955
|
+
inputSchema: {
|
|
2956
|
+
path: z6.string().describe("Path to the note")
|
|
2957
|
+
}
|
|
2958
|
+
},
|
|
2959
|
+
async ({ path: path11 }) => {
|
|
2960
|
+
const index = getIndex();
|
|
2961
|
+
const vaultPath2 = getVaultPath();
|
|
2962
|
+
const result = await getNoteStructure(index, path11, vaultPath2);
|
|
2963
|
+
if (!result) {
|
|
2964
|
+
return {
|
|
2965
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path11 }, null, 2) }]
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
return {
|
|
2969
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
2972
|
+
);
|
|
2973
|
+
server2.registerTool(
|
|
2974
|
+
"get_headings",
|
|
2975
|
+
{
|
|
2976
|
+
title: "Get Headings",
|
|
2977
|
+
description: "Get all headings from a note (lightweight).",
|
|
2978
|
+
inputSchema: {
|
|
2979
|
+
path: z6.string().describe("Path to the note")
|
|
2980
|
+
}
|
|
2981
|
+
},
|
|
2982
|
+
async ({ path: path11 }) => {
|
|
2983
|
+
const index = getIndex();
|
|
2984
|
+
const vaultPath2 = getVaultPath();
|
|
2985
|
+
const result = await getHeadings(index, path11, vaultPath2);
|
|
2986
|
+
if (!result) {
|
|
2987
|
+
return {
|
|
2988
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path11 }, null, 2) }]
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
return {
|
|
2992
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
2993
|
+
path: path11,
|
|
2994
|
+
heading_count: result.length,
|
|
2995
|
+
headings: result
|
|
2996
|
+
}, null, 2) }]
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
);
|
|
3000
|
+
server2.registerTool(
|
|
3001
|
+
"get_section_content",
|
|
3002
|
+
{
|
|
3003
|
+
title: "Get Section Content",
|
|
3004
|
+
description: "Get the content under a specific heading in a note.",
|
|
3005
|
+
inputSchema: {
|
|
3006
|
+
path: z6.string().describe("Path to the note"),
|
|
3007
|
+
heading: z6.string().describe("Heading text to find"),
|
|
3008
|
+
include_subheadings: z6.boolean().default(true).describe("Include content under subheadings")
|
|
3009
|
+
}
|
|
3010
|
+
},
|
|
3011
|
+
async ({ path: path11, heading, include_subheadings }) => {
|
|
3012
|
+
const index = getIndex();
|
|
3013
|
+
const vaultPath2 = getVaultPath();
|
|
3014
|
+
const result = await getSectionContent(index, path11, heading, vaultPath2, include_subheadings);
|
|
3015
|
+
if (!result) {
|
|
3016
|
+
return {
|
|
3017
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3018
|
+
error: "Section not found",
|
|
3019
|
+
path: path11,
|
|
3020
|
+
heading
|
|
3021
|
+
}, null, 2) }]
|
|
3022
|
+
};
|
|
3023
|
+
}
|
|
3024
|
+
return {
|
|
3025
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
);
|
|
3029
|
+
server2.registerTool(
|
|
3030
|
+
"find_sections",
|
|
3031
|
+
{
|
|
3032
|
+
title: "Find Sections",
|
|
3033
|
+
description: "Find all sections across vault matching a heading pattern.",
|
|
3034
|
+
inputSchema: {
|
|
3035
|
+
pattern: z6.string().describe("Regex pattern to match heading text"),
|
|
3036
|
+
folder: z6.string().optional().describe("Limit to notes in this folder"),
|
|
3037
|
+
limit: z6.number().default(50).describe("Maximum number of results to return"),
|
|
3038
|
+
offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
|
|
3039
|
+
}
|
|
3040
|
+
},
|
|
3041
|
+
async ({ pattern, folder, limit: requestedLimit, offset }) => {
|
|
3042
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
3043
|
+
const index = getIndex();
|
|
3044
|
+
const vaultPath2 = getVaultPath();
|
|
3045
|
+
const allResults = await findSections(index, pattern, vaultPath2, folder);
|
|
3046
|
+
const result = allResults.slice(offset, offset + limit);
|
|
3047
|
+
return {
|
|
3048
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3049
|
+
pattern,
|
|
3050
|
+
folder,
|
|
3051
|
+
total_count: allResults.length,
|
|
3052
|
+
returned_count: result.length,
|
|
3053
|
+
sections: result
|
|
3054
|
+
}, null, 2) }]
|
|
3055
|
+
};
|
|
3056
|
+
}
|
|
3057
|
+
);
|
|
3058
|
+
server2.registerTool(
|
|
3059
|
+
"get_all_tasks",
|
|
3060
|
+
{
|
|
3061
|
+
title: "Get All Tasks",
|
|
3062
|
+
description: "Get all tasks from the vault with filtering options.",
|
|
3063
|
+
inputSchema: {
|
|
3064
|
+
status: z6.enum(["open", "completed", "cancelled", "all"]).default("all").describe("Filter by task status"),
|
|
3065
|
+
folder: z6.string().optional().describe("Limit to notes in this folder"),
|
|
3066
|
+
tag: z6.string().optional().describe("Filter to tasks with this tag"),
|
|
3067
|
+
limit: z6.number().default(25).describe("Maximum tasks to return")
|
|
3068
|
+
}
|
|
3069
|
+
},
|
|
3070
|
+
async ({ status, folder, tag, limit: requestedLimit }) => {
|
|
3071
|
+
const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
|
|
3072
|
+
const index = getIndex();
|
|
3073
|
+
const vaultPath2 = getVaultPath();
|
|
3074
|
+
const config = getConfig();
|
|
3075
|
+
const result = await getAllTasks(index, vaultPath2, {
|
|
3076
|
+
status,
|
|
3077
|
+
folder,
|
|
3078
|
+
tag,
|
|
3079
|
+
limit,
|
|
3080
|
+
excludeTags: config.exclude_task_tags
|
|
3081
|
+
});
|
|
3082
|
+
return {
|
|
3083
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
);
|
|
3087
|
+
server2.registerTool(
|
|
3088
|
+
"get_tasks_from_note",
|
|
3089
|
+
{
|
|
3090
|
+
title: "Get Tasks From Note",
|
|
3091
|
+
description: "Get all tasks from a specific note.",
|
|
3092
|
+
inputSchema: {
|
|
3093
|
+
path: z6.string().describe("Path to the note")
|
|
3094
|
+
}
|
|
3095
|
+
},
|
|
3096
|
+
async ({ path: path11 }) => {
|
|
3097
|
+
const index = getIndex();
|
|
3098
|
+
const vaultPath2 = getVaultPath();
|
|
3099
|
+
const config = getConfig();
|
|
3100
|
+
const result = await getTasksFromNote(index, path11, vaultPath2, config.exclude_task_tags || []);
|
|
3101
|
+
if (!result) {
|
|
3102
|
+
return {
|
|
3103
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path11 }, null, 2) }]
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
return {
|
|
3107
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3108
|
+
path: path11,
|
|
3109
|
+
task_count: result.length,
|
|
3110
|
+
open: result.filter((t) => t.status === "open").length,
|
|
3111
|
+
completed: result.filter((t) => t.status === "completed").length,
|
|
3112
|
+
tasks: result
|
|
3113
|
+
}, null, 2) }]
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
);
|
|
3117
|
+
server2.registerTool(
|
|
3118
|
+
"get_tasks_with_due_dates",
|
|
3119
|
+
{
|
|
3120
|
+
title: "Get Tasks With Due Dates",
|
|
3121
|
+
description: "Get tasks that have due dates, sorted by date.",
|
|
3122
|
+
inputSchema: {
|
|
3123
|
+
status: z6.enum(["open", "completed", "cancelled", "all"]).default("open").describe("Filter by status"),
|
|
3124
|
+
folder: z6.string().optional().describe("Limit to notes in this folder"),
|
|
3125
|
+
limit: z6.number().default(25).describe("Maximum number of results to return"),
|
|
3126
|
+
offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
|
|
3127
|
+
}
|
|
3128
|
+
},
|
|
3129
|
+
async ({ status, folder, limit: requestedLimit, offset }) => {
|
|
3130
|
+
const limit = Math.min(requestedLimit ?? 25, MAX_LIMIT);
|
|
3131
|
+
const index = getIndex();
|
|
3132
|
+
const vaultPath2 = getVaultPath();
|
|
3133
|
+
const config = getConfig();
|
|
3134
|
+
const allResults = await getTasksWithDueDates(index, vaultPath2, {
|
|
3135
|
+
status,
|
|
3136
|
+
folder,
|
|
3137
|
+
excludeTags: config.exclude_task_tags
|
|
3138
|
+
});
|
|
3139
|
+
const result = allResults.slice(offset, offset + limit);
|
|
3140
|
+
return {
|
|
3141
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3142
|
+
total_count: allResults.length,
|
|
3143
|
+
returned_count: result.length,
|
|
3144
|
+
tasks: result
|
|
3145
|
+
}, null, 2) }]
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
);
|
|
3149
|
+
server2.registerTool(
|
|
3150
|
+
"get_incomplete_tasks",
|
|
3151
|
+
{
|
|
3152
|
+
title: "Get Incomplete Tasks",
|
|
3153
|
+
description: "Get all incomplete (open) tasks from the vault. Simpler interface that defaults to open tasks only.",
|
|
3154
|
+
inputSchema: {
|
|
3155
|
+
folder: z6.string().optional().describe("Limit to notes in this folder"),
|
|
3156
|
+
tag: z6.string().optional().describe("Filter to tasks with this tag"),
|
|
3157
|
+
limit: z6.number().default(50).describe("Maximum tasks to return"),
|
|
3158
|
+
offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
|
|
3159
|
+
}
|
|
3160
|
+
},
|
|
3161
|
+
async ({ folder, tag, limit: requestedLimit, offset }) => {
|
|
3162
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
3163
|
+
const index = getIndex();
|
|
3164
|
+
const vaultPath2 = getVaultPath();
|
|
3165
|
+
const config = getConfig();
|
|
3166
|
+
const result = await getAllTasks(index, vaultPath2, {
|
|
3167
|
+
status: "open",
|
|
3168
|
+
folder,
|
|
3169
|
+
tag,
|
|
3170
|
+
limit: limit + offset,
|
|
3171
|
+
excludeTags: config.exclude_task_tags
|
|
3172
|
+
});
|
|
3173
|
+
const paginatedTasks = result.tasks.slice(offset, offset + limit);
|
|
3174
|
+
return {
|
|
3175
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3176
|
+
total_incomplete: result.open_count,
|
|
3177
|
+
returned_count: paginatedTasks.length,
|
|
3178
|
+
tasks: paginatedTasks
|
|
3179
|
+
}, null, 2) }]
|
|
3180
|
+
};
|
|
3181
|
+
}
|
|
3182
|
+
);
|
|
3183
|
+
server2.registerTool(
|
|
3184
|
+
"get_link_path",
|
|
3185
|
+
{
|
|
3186
|
+
title: "Get Link Path",
|
|
3187
|
+
description: "Find the shortest path of links between two notes.",
|
|
3188
|
+
inputSchema: {
|
|
3189
|
+
from: z6.string().describe("Starting note path"),
|
|
3190
|
+
to: z6.string().describe("Target note path"),
|
|
3191
|
+
max_depth: z6.number().default(10).describe("Maximum path length to search")
|
|
3192
|
+
}
|
|
3193
|
+
},
|
|
3194
|
+
async ({ from, to, max_depth }) => {
|
|
3195
|
+
const index = getIndex();
|
|
3196
|
+
const result = getLinkPath(index, from, to, max_depth);
|
|
3197
|
+
return {
|
|
3198
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3199
|
+
from,
|
|
3200
|
+
to,
|
|
3201
|
+
...result
|
|
3202
|
+
}, null, 2) }]
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
);
|
|
3206
|
+
server2.registerTool(
|
|
3207
|
+
"get_common_neighbors",
|
|
3208
|
+
{
|
|
3209
|
+
title: "Get Common Neighbors",
|
|
3210
|
+
description: "Find notes that both specified notes link to.",
|
|
3211
|
+
inputSchema: {
|
|
3212
|
+
note_a: z6.string().describe("First note path"),
|
|
3213
|
+
note_b: z6.string().describe("Second note path")
|
|
3214
|
+
}
|
|
3215
|
+
},
|
|
3216
|
+
async ({ note_a, note_b }) => {
|
|
3217
|
+
const index = getIndex();
|
|
3218
|
+
const result = getCommonNeighbors(index, note_a, note_b);
|
|
3219
|
+
return {
|
|
3220
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3221
|
+
note_a,
|
|
3222
|
+
note_b,
|
|
3223
|
+
common_count: result.length,
|
|
3224
|
+
common_neighbors: result
|
|
3225
|
+
}, null, 2) }]
|
|
3226
|
+
};
|
|
3227
|
+
}
|
|
3228
|
+
);
|
|
3229
|
+
server2.registerTool(
|
|
3230
|
+
"find_bidirectional_links",
|
|
3231
|
+
{
|
|
3232
|
+
title: "Find Bidirectional Links",
|
|
3233
|
+
description: "Find pairs of notes that link to each other (mutual links).",
|
|
3234
|
+
inputSchema: {
|
|
3235
|
+
path: z6.string().optional().describe("Limit to links involving this note"),
|
|
3236
|
+
limit: z6.number().default(50).describe("Maximum number of results to return"),
|
|
3237
|
+
offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
|
|
3238
|
+
}
|
|
3239
|
+
},
|
|
3240
|
+
async ({ path: path11, limit: requestedLimit, offset }) => {
|
|
3241
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
3242
|
+
const index = getIndex();
|
|
3243
|
+
const allResults = findBidirectionalLinks(index, path11);
|
|
3244
|
+
const result = allResults.slice(offset, offset + limit);
|
|
3245
|
+
return {
|
|
3246
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3247
|
+
scope: path11 || "all",
|
|
3248
|
+
total_count: allResults.length,
|
|
3249
|
+
returned_count: result.length,
|
|
3250
|
+
pairs: result
|
|
3251
|
+
}, null, 2) }]
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
);
|
|
3255
|
+
server2.registerTool(
|
|
3256
|
+
"find_dead_ends",
|
|
3257
|
+
{
|
|
3258
|
+
title: "Find Dead Ends",
|
|
3259
|
+
description: "Find notes with backlinks but no outgoing links (consume but do not contribute).",
|
|
3260
|
+
inputSchema: {
|
|
3261
|
+
folder: z6.string().optional().describe("Limit to notes in this folder"),
|
|
3262
|
+
min_backlinks: z6.number().default(1).describe("Minimum backlinks required"),
|
|
3263
|
+
limit: z6.number().default(50).describe("Maximum number of results to return"),
|
|
3264
|
+
offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
|
|
3265
|
+
}
|
|
3266
|
+
},
|
|
3267
|
+
async ({ folder, min_backlinks, limit: requestedLimit, offset }) => {
|
|
3268
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
3269
|
+
const index = getIndex();
|
|
3270
|
+
const allResults = findDeadEnds(index, folder, min_backlinks);
|
|
3271
|
+
const result = allResults.slice(offset, offset + limit);
|
|
3272
|
+
return {
|
|
3273
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3274
|
+
criteria: { folder, min_backlinks },
|
|
3275
|
+
total_count: allResults.length,
|
|
3276
|
+
returned_count: result.length,
|
|
3277
|
+
dead_ends: result
|
|
3278
|
+
}, null, 2) }]
|
|
3279
|
+
};
|
|
3280
|
+
}
|
|
3281
|
+
);
|
|
3282
|
+
server2.registerTool(
|
|
3283
|
+
"find_sources",
|
|
3284
|
+
{
|
|
3285
|
+
title: "Find Sources",
|
|
3286
|
+
description: "Find notes with outgoing links but no backlinks (contribute but not referenced).",
|
|
3287
|
+
inputSchema: {
|
|
3288
|
+
folder: z6.string().optional().describe("Limit to notes in this folder"),
|
|
3289
|
+
min_outlinks: z6.number().default(1).describe("Minimum outlinks required"),
|
|
3290
|
+
limit: z6.number().default(50).describe("Maximum number of results to return"),
|
|
3291
|
+
offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
|
|
3292
|
+
}
|
|
3293
|
+
},
|
|
3294
|
+
async ({ folder, min_outlinks, limit: requestedLimit, offset }) => {
|
|
3295
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
3296
|
+
const index = getIndex();
|
|
3297
|
+
const allResults = findSources(index, folder, min_outlinks);
|
|
3298
|
+
const result = allResults.slice(offset, offset + limit);
|
|
3299
|
+
return {
|
|
3300
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3301
|
+
criteria: { folder, min_outlinks },
|
|
3302
|
+
total_count: allResults.length,
|
|
3303
|
+
returned_count: result.length,
|
|
3304
|
+
sources: result
|
|
3305
|
+
}, null, 2) }]
|
|
3306
|
+
};
|
|
3307
|
+
}
|
|
3308
|
+
);
|
|
3309
|
+
server2.registerTool(
|
|
3310
|
+
"get_connection_strength",
|
|
3311
|
+
{
|
|
3312
|
+
title: "Get Connection Strength",
|
|
3313
|
+
description: "Calculate the connection strength between two notes based on various factors.",
|
|
3314
|
+
inputSchema: {
|
|
3315
|
+
note_a: z6.string().describe("First note path"),
|
|
3316
|
+
note_b: z6.string().describe("Second note path")
|
|
3317
|
+
}
|
|
3318
|
+
},
|
|
3319
|
+
async ({ note_a, note_b }) => {
|
|
3320
|
+
const index = getIndex();
|
|
3321
|
+
const result = getConnectionStrength(index, note_a, note_b);
|
|
3322
|
+
return {
|
|
3323
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3324
|
+
note_a,
|
|
3325
|
+
note_b,
|
|
3326
|
+
...result
|
|
3327
|
+
}, null, 2) }]
|
|
3328
|
+
};
|
|
3329
|
+
}
|
|
3330
|
+
);
|
|
3331
|
+
server2.registerTool(
|
|
3332
|
+
"get_frontmatter_schema",
|
|
3333
|
+
{
|
|
3334
|
+
title: "Get Frontmatter Schema",
|
|
3335
|
+
description: "Analyze all frontmatter fields used across the vault.",
|
|
3336
|
+
inputSchema: {}
|
|
3337
|
+
},
|
|
3338
|
+
async () => {
|
|
3339
|
+
const index = getIndex();
|
|
3340
|
+
const result = getFrontmatterSchema(index);
|
|
3341
|
+
return {
|
|
3342
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
3343
|
+
};
|
|
3344
|
+
}
|
|
3345
|
+
);
|
|
3346
|
+
server2.registerTool(
|
|
3347
|
+
"get_field_values",
|
|
3348
|
+
{
|
|
3349
|
+
title: "Get Field Values",
|
|
3350
|
+
description: "Get all unique values for a specific frontmatter field.",
|
|
3351
|
+
inputSchema: {
|
|
3352
|
+
field: z6.string().describe("Frontmatter field name")
|
|
3353
|
+
}
|
|
3354
|
+
},
|
|
3355
|
+
async ({ field }) => {
|
|
3356
|
+
const index = getIndex();
|
|
3357
|
+
const result = getFieldValues(index, field);
|
|
3358
|
+
return {
|
|
3359
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
);
|
|
3363
|
+
server2.registerTool(
|
|
3364
|
+
"find_frontmatter_inconsistencies",
|
|
3365
|
+
{
|
|
3366
|
+
title: "Find Frontmatter Inconsistencies",
|
|
3367
|
+
description: "Find fields that have multiple different types across notes.",
|
|
3368
|
+
inputSchema: {}
|
|
3369
|
+
},
|
|
3370
|
+
async () => {
|
|
3371
|
+
const index = getIndex();
|
|
3372
|
+
const result = findFrontmatterInconsistencies(index);
|
|
3373
|
+
return {
|
|
3374
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3375
|
+
inconsistency_count: result.length,
|
|
3376
|
+
inconsistencies: result
|
|
3377
|
+
}, null, 2) }]
|
|
3378
|
+
};
|
|
3379
|
+
}
|
|
3380
|
+
);
|
|
3381
|
+
server2.registerTool(
|
|
3382
|
+
"validate_frontmatter",
|
|
3383
|
+
{
|
|
3384
|
+
title: "Validate Frontmatter",
|
|
3385
|
+
description: "Validate notes against a schema. Returns notes with issues (missing fields, wrong types, invalid values).",
|
|
3386
|
+
inputSchema: {
|
|
3387
|
+
schema: z6.record(z6.object({
|
|
3388
|
+
required: z6.boolean().optional().describe("Whether field is required"),
|
|
3389
|
+
type: z6.union([z6.string(), z6.array(z6.string())]).optional().describe("Expected type(s)"),
|
|
3390
|
+
values: z6.array(z6.unknown()).optional().describe("Allowed values")
|
|
3391
|
+
})).describe("Schema defining expected frontmatter fields"),
|
|
3392
|
+
folder: z6.string().optional().describe("Limit to notes in this folder")
|
|
3393
|
+
}
|
|
3394
|
+
},
|
|
3395
|
+
async (params) => {
|
|
3396
|
+
const index = getIndex();
|
|
3397
|
+
const result = validateFrontmatter(
|
|
3398
|
+
index,
|
|
3399
|
+
params.schema,
|
|
3400
|
+
params.folder
|
|
3401
|
+
);
|
|
3402
|
+
return {
|
|
3403
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3404
|
+
notes_with_issues: result.length,
|
|
3405
|
+
results: result
|
|
3406
|
+
}, null, 2) }]
|
|
3407
|
+
};
|
|
3408
|
+
}
|
|
3409
|
+
);
|
|
3410
|
+
server2.registerTool(
|
|
3411
|
+
"find_missing_frontmatter",
|
|
3412
|
+
{
|
|
3413
|
+
title: "Find Missing Frontmatter",
|
|
3414
|
+
description: "Find notes missing expected frontmatter fields based on their folder.",
|
|
3415
|
+
inputSchema: {
|
|
3416
|
+
folder_schemas: z6.record(z6.array(z6.string())).describe("Map of folder paths to required field names")
|
|
3417
|
+
}
|
|
3418
|
+
},
|
|
3419
|
+
async (params) => {
|
|
3420
|
+
const index = getIndex();
|
|
3421
|
+
const result = findMissingFrontmatter(
|
|
3422
|
+
index,
|
|
3423
|
+
params.folder_schemas
|
|
3424
|
+
);
|
|
3425
|
+
return {
|
|
3426
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
3427
|
+
notes_with_missing_fields: result.length,
|
|
3428
|
+
results: result
|
|
3429
|
+
}, null, 2) }]
|
|
3430
|
+
};
|
|
3431
|
+
}
|
|
3432
|
+
);
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
// src/tools/periodic.ts
|
|
3436
|
+
import { z as z7 } from "zod";
|
|
3437
|
+
var DATE_PATTERNS = {
|
|
3438
|
+
daily: [
|
|
3439
|
+
{ name: "YYYY-MM-DD", regex: /^\d{4}-\d{2}-\d{2}\.md$/, format: "YYYY-MM-DD" },
|
|
3440
|
+
{ name: "YYYY-MM-DD-*", regex: /^\d{4}-\d{2}-\d{2}-.+\.md$/, format: "YYYY-MM-DD-*" },
|
|
3441
|
+
{ name: "DD-MM-YYYY", regex: /^\d{2}-\d{2}-\d{4}\.md$/, format: "DD-MM-YYYY" }
|
|
3442
|
+
],
|
|
3443
|
+
weekly: [
|
|
3444
|
+
{ name: "YYYY-WXX", regex: /^\d{4}-W\d{2}\.md$/, format: "YYYY-WXX" },
|
|
3445
|
+
{ name: "YYYY-[W]XX", regex: /^\d{4}-\[W\]\d{2}\.md$/, format: "YYYY-[W]XX" }
|
|
3446
|
+
],
|
|
3447
|
+
monthly: [
|
|
3448
|
+
{ name: "YYYY-MM", regex: /^\d{4}-\d{2}\.md$/, format: "YYYY-MM" }
|
|
3449
|
+
],
|
|
3450
|
+
quarterly: [
|
|
3451
|
+
{ name: "YYYY-QX", regex: /^\d{4}-Q[1-4]\.md$/, format: "YYYY-QX" }
|
|
3452
|
+
],
|
|
3453
|
+
yearly: [
|
|
3454
|
+
{ name: "YYYY", regex: /^\d{4}\.md$/, format: "YYYY" }
|
|
3455
|
+
]
|
|
3456
|
+
};
|
|
3457
|
+
var COMMON_FOLDERS = {
|
|
3458
|
+
daily: ["daily-notes", "Daily", "journal", "Journal", "dailies"],
|
|
3459
|
+
weekly: ["weekly-notes", "Weekly", "weeklies"],
|
|
3460
|
+
monthly: ["monthly-notes", "Monthly", "monthlies"],
|
|
3461
|
+
quarterly: ["quarterly-notes", "Quarterly", "quarterlies"],
|
|
3462
|
+
yearly: ["yearly-notes", "Yearly", "yearlies"]
|
|
3463
|
+
};
|
|
3464
|
+
function getCurrentPeriodPath(type, folder, pattern) {
|
|
3465
|
+
const now = /* @__PURE__ */ new Date();
|
|
3466
|
+
let filename = "";
|
|
3467
|
+
switch (pattern) {
|
|
3468
|
+
case "YYYY-MM-DD":
|
|
3469
|
+
filename = now.toISOString().split("T")[0];
|
|
3470
|
+
break;
|
|
3471
|
+
case "YYYY-WXX": {
|
|
3472
|
+
const week = getISOWeek(now);
|
|
3473
|
+
filename = `${now.getFullYear()}-W${week.toString().padStart(2, "0")}`;
|
|
3474
|
+
break;
|
|
3475
|
+
}
|
|
3476
|
+
case "YYYY-[W]XX": {
|
|
3477
|
+
const week = getISOWeek(now);
|
|
3478
|
+
filename = `${now.getFullYear()}-[W]${week.toString().padStart(2, "0")}`;
|
|
3479
|
+
break;
|
|
3480
|
+
}
|
|
3481
|
+
case "YYYY-MM":
|
|
3482
|
+
filename = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, "0")}`;
|
|
3483
|
+
break;
|
|
3484
|
+
case "YYYY-QX": {
|
|
3485
|
+
const quarter = Math.floor(now.getMonth() / 3) + 1;
|
|
3486
|
+
filename = `${now.getFullYear()}-Q${quarter}`;
|
|
3487
|
+
break;
|
|
3488
|
+
}
|
|
3489
|
+
case "YYYY":
|
|
3490
|
+
filename = now.getFullYear().toString();
|
|
3491
|
+
break;
|
|
3492
|
+
default:
|
|
3493
|
+
filename = now.toISOString().split("T")[0];
|
|
3494
|
+
}
|
|
3495
|
+
return `${folder}/${filename}.md`;
|
|
3496
|
+
}
|
|
3497
|
+
function getISOWeek(date) {
|
|
3498
|
+
const target = new Date(date.valueOf());
|
|
3499
|
+
const dayNr = (date.getDay() + 6) % 7;
|
|
3500
|
+
target.setDate(target.getDate() - dayNr + 3);
|
|
3501
|
+
const firstThursday = target.valueOf();
|
|
3502
|
+
target.setMonth(0, 1);
|
|
3503
|
+
if (target.getDay() !== 4) {
|
|
3504
|
+
target.setMonth(0, 1 + (4 - target.getDay() + 7) % 7);
|
|
3505
|
+
}
|
|
3506
|
+
return 1 + Math.ceil((firstThursday - target.valueOf()) / 6048e5);
|
|
3507
|
+
}
|
|
3508
|
+
function detectPeriodicNotes(index, type) {
|
|
3509
|
+
const patterns = DATE_PATTERNS[type];
|
|
3510
|
+
const commonFolders = COMMON_FOLDERS[type];
|
|
3511
|
+
const candidates = [];
|
|
3512
|
+
const folderPatternCounts = /* @__PURE__ */ new Map();
|
|
3513
|
+
const thirtyDaysAgo = /* @__PURE__ */ new Date();
|
|
3514
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
3515
|
+
for (const note of index.notes.values()) {
|
|
3516
|
+
const parts = note.path.split("/");
|
|
3517
|
+
const filename = parts[parts.length - 1];
|
|
3518
|
+
const folder = parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
3519
|
+
for (const patternDef of patterns) {
|
|
3520
|
+
if (patternDef.regex.test(filename)) {
|
|
3521
|
+
const key = `${folder}::${patternDef.format}`;
|
|
3522
|
+
const existing = folderPatternCounts.get(key);
|
|
3523
|
+
if (existing) {
|
|
3524
|
+
existing.count++;
|
|
3525
|
+
if (note.modified >= thirtyDaysAgo) {
|
|
3526
|
+
existing.recentCount++;
|
|
3527
|
+
}
|
|
3528
|
+
existing.notes.push(note.path);
|
|
3529
|
+
} else {
|
|
3530
|
+
folderPatternCounts.set(key, {
|
|
3531
|
+
pattern: patternDef.format,
|
|
3532
|
+
count: 1,
|
|
3533
|
+
recentCount: note.modified >= thirtyDaysAgo ? 1 : 0,
|
|
3534
|
+
notes: [note.path]
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
for (const [key, data] of folderPatternCounts.entries()) {
|
|
3541
|
+
const [folder, pattern] = key.split("::");
|
|
3542
|
+
let score = Math.min(data.count / 30, 1) * 0.4;
|
|
3543
|
+
score += Math.min(data.recentCount / 7, 1) * 0.3;
|
|
3544
|
+
const folderName = folder.split("/").pop() || "";
|
|
3545
|
+
if (commonFolders.some((cf) => folderName.toLowerCase() === cf.toLowerCase())) {
|
|
3546
|
+
score += 0.2;
|
|
3547
|
+
}
|
|
3548
|
+
const folderNotes2 = Array.from(index.notes.values()).filter(
|
|
3549
|
+
(n) => n.path.startsWith(folder + "/")
|
|
3550
|
+
);
|
|
3551
|
+
const consistency = folderNotes2.length > 0 ? data.count / folderNotes2.length : 0;
|
|
3552
|
+
score += consistency * 0.1;
|
|
3553
|
+
candidates.push({
|
|
3554
|
+
folder: folder || ".",
|
|
3555
|
+
pattern,
|
|
3556
|
+
score
|
|
3557
|
+
});
|
|
3558
|
+
}
|
|
3559
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
3560
|
+
const best = candidates[0];
|
|
3561
|
+
if (!best) {
|
|
3562
|
+
return {
|
|
3563
|
+
type,
|
|
3564
|
+
detected: false,
|
|
3565
|
+
folder: null,
|
|
3566
|
+
pattern: null,
|
|
3567
|
+
confidence: 0,
|
|
3568
|
+
evidence: {
|
|
3569
|
+
note_count: 0,
|
|
3570
|
+
recent_notes: 0,
|
|
3571
|
+
pattern_consistency: 0
|
|
3572
|
+
},
|
|
3573
|
+
today_path: null,
|
|
3574
|
+
today_exists: false,
|
|
3575
|
+
candidates: []
|
|
3576
|
+
};
|
|
3577
|
+
}
|
|
3578
|
+
const bestData = folderPatternCounts.get(`${best.folder}::${best.pattern}`);
|
|
3579
|
+
const folderNotes = Array.from(index.notes.values()).filter(
|
|
3580
|
+
(n) => n.path.startsWith(best.folder === "." ? "" : best.folder + "/")
|
|
3581
|
+
);
|
|
3582
|
+
const patternConsistency = folderNotes.length > 0 ? bestData.count / folderNotes.length : 0;
|
|
3583
|
+
const todayPath = getCurrentPeriodPath(type, best.folder, best.pattern);
|
|
3584
|
+
const todayExists = index.notes.has(todayPath);
|
|
3585
|
+
return {
|
|
3586
|
+
type,
|
|
3587
|
+
detected: true,
|
|
3588
|
+
folder: best.folder,
|
|
3589
|
+
pattern: best.pattern,
|
|
3590
|
+
confidence: best.score,
|
|
3591
|
+
evidence: {
|
|
3592
|
+
note_count: bestData.count,
|
|
3593
|
+
recent_notes: bestData.recentCount,
|
|
3594
|
+
pattern_consistency: patternConsistency
|
|
3595
|
+
},
|
|
3596
|
+
today_path: todayPath,
|
|
3597
|
+
today_exists: todayExists,
|
|
3598
|
+
candidates: candidates.slice(0, 3)
|
|
3599
|
+
// top 3
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
function registerPeriodicTools(server2, getIndex) {
|
|
3603
|
+
const DetectPeriodicNotesOutputSchema = {
|
|
3604
|
+
type: z7.string().describe("The type of periodic note (daily, weekly, etc.)"),
|
|
3605
|
+
detected: z7.boolean().describe("Whether a pattern was detected"),
|
|
3606
|
+
folder: z7.string().nullable().describe('Best-guess folder path (e.g., "daily-notes")'),
|
|
3607
|
+
pattern: z7.string().nullable().describe('Best-guess filename pattern (e.g., "YYYY-MM-DD")'),
|
|
3608
|
+
confidence: z7.number().describe("Confidence score 0-1"),
|
|
3609
|
+
evidence: z7.object({
|
|
3610
|
+
note_count: z7.number().describe("Number of notes matching pattern"),
|
|
3611
|
+
recent_notes: z7.number().describe("Notes modified in last 30 days"),
|
|
3612
|
+
pattern_consistency: z7.number().describe("Ratio of matching notes in folder")
|
|
3613
|
+
}),
|
|
3614
|
+
today_path: z7.string().nullable().describe("Path to today/current period note"),
|
|
3615
|
+
today_exists: z7.boolean().describe("Whether today/current note exists"),
|
|
3616
|
+
candidates: z7.array(
|
|
3617
|
+
z7.object({
|
|
3618
|
+
folder: z7.string(),
|
|
3619
|
+
pattern: z7.string(),
|
|
3620
|
+
score: z7.number()
|
|
3621
|
+
})
|
|
3622
|
+
).describe("Top 3 candidate folder/pattern combinations")
|
|
3623
|
+
};
|
|
3624
|
+
server2.registerTool(
|
|
3625
|
+
"detect_periodic_notes",
|
|
3626
|
+
{
|
|
3627
|
+
title: "Detect Periodic Note Conventions",
|
|
3628
|
+
description: "Detect where the vault keeps periodic notes (daily, weekly, monthly, etc.) without user configuration. Returns best-guess folder, filename pattern, and path to today/current period note.",
|
|
3629
|
+
inputSchema: {
|
|
3630
|
+
type: z7.enum(["daily", "weekly", "monthly", "quarterly", "yearly"]).describe("Type of periodic note to detect")
|
|
3631
|
+
},
|
|
3632
|
+
outputSchema: DetectPeriodicNotesOutputSchema
|
|
3633
|
+
},
|
|
3634
|
+
async ({
|
|
3635
|
+
type
|
|
3636
|
+
}) => {
|
|
3637
|
+
const index = getIndex();
|
|
3638
|
+
const result = detectPeriodicNotes(index, type);
|
|
3639
|
+
return {
|
|
3640
|
+
content: [
|
|
3641
|
+
{
|
|
3642
|
+
type: "text",
|
|
3643
|
+
text: JSON.stringify(result, null, 2)
|
|
3644
|
+
}
|
|
3645
|
+
],
|
|
3646
|
+
structuredContent: result
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3649
|
+
);
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
// src/tools/bidirectional.ts
|
|
3653
|
+
import { z as z8 } from "zod";
|
|
3654
|
+
import * as fs9 from "fs/promises";
|
|
3655
|
+
import * as path7 from "path";
|
|
3656
|
+
import matter2 from "gray-matter";
|
|
3657
|
+
var PROSE_PATTERN_REGEX = /^([A-Za-z][A-Za-z0-9 _-]*):\s*(?:\[\[([^\]]+)\]\]|"([^"]+)"|([^\n]+?))\s*$/gm;
|
|
3658
|
+
var CODE_BLOCK_REGEX2 = /```[\s\S]*?```|`[^`\n]+`/g;
|
|
3659
|
+
var WIKILINK_REGEX2 = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]+)?\]\]/g;
|
|
3660
|
+
async function readFileContent(notePath, vaultPath2) {
|
|
3661
|
+
const fullPath = path7.join(vaultPath2, notePath);
|
|
3662
|
+
try {
|
|
3663
|
+
return await fs9.readFile(fullPath, "utf-8");
|
|
3664
|
+
} catch {
|
|
3665
|
+
return null;
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
function getBodyContent(content) {
|
|
3669
|
+
try {
|
|
3670
|
+
const parsed = matter2(content);
|
|
3671
|
+
const frontmatterMatch = content.match(/^---\n[\s\S]*?\n---\n?/);
|
|
3672
|
+
const bodyStartLine = frontmatterMatch ? frontmatterMatch[0].split("\n").length : 1;
|
|
3673
|
+
return { body: parsed.content, bodyStartLine };
|
|
3674
|
+
} catch {
|
|
3675
|
+
return { body: content, bodyStartLine: 1 };
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
function removeCodeBlocks(content) {
|
|
3679
|
+
return content.replace(CODE_BLOCK_REGEX2, (match) => {
|
|
3680
|
+
const newlines = (match.match(/\n/g) || []).length;
|
|
3681
|
+
return "\n".repeat(newlines);
|
|
3682
|
+
});
|
|
3683
|
+
}
|
|
3684
|
+
function extractWikilinksFromValue(value) {
|
|
3685
|
+
if (typeof value === "string") {
|
|
3686
|
+
const matches = [];
|
|
3687
|
+
let match;
|
|
3688
|
+
WIKILINK_REGEX2.lastIndex = 0;
|
|
3689
|
+
while ((match = WIKILINK_REGEX2.exec(value)) !== null) {
|
|
3690
|
+
matches.push(match[1].trim());
|
|
3691
|
+
}
|
|
3692
|
+
return matches;
|
|
3693
|
+
}
|
|
3694
|
+
if (Array.isArray(value)) {
|
|
3695
|
+
return value.flatMap((v) => extractWikilinksFromValue(v));
|
|
3696
|
+
}
|
|
3697
|
+
return [];
|
|
3698
|
+
}
|
|
3699
|
+
function isWikilinkValue(value) {
|
|
3700
|
+
return /^\[\[.+\]\]$/.test(value.trim());
|
|
3701
|
+
}
|
|
3702
|
+
function normalizeRef(ref) {
|
|
3703
|
+
return ref.toLowerCase().replace(/\.md$/, "").trim();
|
|
3704
|
+
}
|
|
3705
|
+
async function detectProsePatterns(index, notePath, vaultPath2) {
|
|
3706
|
+
const content = await readFileContent(notePath, vaultPath2);
|
|
3707
|
+
if (content === null) {
|
|
3708
|
+
return {
|
|
3709
|
+
path: notePath,
|
|
3710
|
+
patterns: [],
|
|
3711
|
+
error: "File not found"
|
|
3712
|
+
};
|
|
3713
|
+
}
|
|
3714
|
+
const { body, bodyStartLine } = getBodyContent(content);
|
|
3715
|
+
const cleanBody = removeCodeBlocks(body);
|
|
3716
|
+
const patterns = [];
|
|
3717
|
+
const lines = cleanBody.split("\n");
|
|
3718
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3719
|
+
const line = lines[i];
|
|
3720
|
+
const lineNumber = bodyStartLine + i;
|
|
3721
|
+
if (!line.trim()) continue;
|
|
3722
|
+
PROSE_PATTERN_REGEX.lastIndex = 0;
|
|
3723
|
+
const match = PROSE_PATTERN_REGEX.exec(line);
|
|
3724
|
+
if (match) {
|
|
3725
|
+
const key = match[1].trim();
|
|
3726
|
+
const wikilinkTarget = match[2]?.trim();
|
|
3727
|
+
const quotedValue = match[3]?.trim();
|
|
3728
|
+
const plainValue = match[4]?.trim();
|
|
3729
|
+
const value = wikilinkTarget || quotedValue || plainValue;
|
|
3730
|
+
if (value) {
|
|
3731
|
+
patterns.push({
|
|
3732
|
+
key,
|
|
3733
|
+
value,
|
|
3734
|
+
line: lineNumber,
|
|
3735
|
+
raw: line.trim(),
|
|
3736
|
+
isWikilink: !!wikilinkTarget
|
|
3737
|
+
});
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
return {
|
|
3742
|
+
path: notePath,
|
|
3743
|
+
patterns
|
|
3744
|
+
};
|
|
3745
|
+
}
|
|
3746
|
+
async function suggestFrontmatterFromProse(index, notePath, vaultPath2) {
|
|
3747
|
+
const content = await readFileContent(notePath, vaultPath2);
|
|
3748
|
+
if (content === null) {
|
|
3749
|
+
return {
|
|
3750
|
+
path: notePath,
|
|
3751
|
+
suggestions: [],
|
|
3752
|
+
error: "File not found"
|
|
3753
|
+
};
|
|
3754
|
+
}
|
|
3755
|
+
let existingFrontmatter = {};
|
|
3756
|
+
try {
|
|
3757
|
+
existingFrontmatter = matter2(content).data;
|
|
3758
|
+
} catch {
|
|
3759
|
+
}
|
|
3760
|
+
const { patterns } = await detectProsePatterns(index, notePath, vaultPath2);
|
|
3761
|
+
const patternsByKey = /* @__PURE__ */ new Map();
|
|
3762
|
+
for (const pattern of patterns) {
|
|
3763
|
+
const keyLower = pattern.key.toLowerCase();
|
|
3764
|
+
const existing = patternsByKey.get(keyLower);
|
|
3765
|
+
if (existing) {
|
|
3766
|
+
existing.patterns.push(pattern);
|
|
3767
|
+
} else {
|
|
3768
|
+
patternsByKey.set(keyLower, {
|
|
3769
|
+
patterns: [pattern],
|
|
3770
|
+
originalKey: pattern.key
|
|
3771
|
+
});
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
const suggestions = [];
|
|
3775
|
+
for (const [keyLower, { patterns: keyPatterns, originalKey }] of patternsByKey) {
|
|
3776
|
+
const existingKey = Object.keys(existingFrontmatter).find(
|
|
3777
|
+
(k) => k.toLowerCase() === keyLower
|
|
3778
|
+
);
|
|
3779
|
+
if (existingKey) continue;
|
|
3780
|
+
const fieldName = originalKey.toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_");
|
|
3781
|
+
const values = keyPatterns.map((p) => {
|
|
3782
|
+
if (p.isWikilink) {
|
|
3783
|
+
return `[[${p.value}]]`;
|
|
3784
|
+
}
|
|
3785
|
+
return p.value;
|
|
3786
|
+
});
|
|
3787
|
+
const uniqueValues = [...new Set(values)];
|
|
3788
|
+
const hasWikilink = keyPatterns.some((p) => p.isWikilink);
|
|
3789
|
+
suggestions.push({
|
|
3790
|
+
field: fieldName,
|
|
3791
|
+
value: uniqueValues.length === 1 ? uniqueValues[0] : uniqueValues,
|
|
3792
|
+
source_lines: keyPatterns.map((p) => p.line),
|
|
3793
|
+
confidence: hasWikilink ? 0.9 : 0.7,
|
|
3794
|
+
// Higher confidence for wikilink patterns
|
|
3795
|
+
preserveWikilink: hasWikilink
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
return {
|
|
3799
|
+
path: notePath,
|
|
3800
|
+
suggestions
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
async function suggestWikilinksInFrontmatter(index, notePath, vaultPath2) {
|
|
3804
|
+
const content = await readFileContent(notePath, vaultPath2);
|
|
3805
|
+
if (content === null) {
|
|
3806
|
+
return {
|
|
3807
|
+
path: notePath,
|
|
3808
|
+
suggestions: [],
|
|
3809
|
+
error: "File not found"
|
|
3810
|
+
};
|
|
3811
|
+
}
|
|
3812
|
+
let frontmatter = {};
|
|
3813
|
+
try {
|
|
3814
|
+
frontmatter = matter2(content).data;
|
|
3815
|
+
} catch {
|
|
3816
|
+
return {
|
|
3817
|
+
path: notePath,
|
|
3818
|
+
suggestions: [],
|
|
3819
|
+
error: "Invalid frontmatter"
|
|
3820
|
+
};
|
|
3821
|
+
}
|
|
3822
|
+
const suggestions = [];
|
|
3823
|
+
function checkValue(field, value, arrayIndex) {
|
|
3824
|
+
if (typeof value !== "string") return;
|
|
3825
|
+
if (isWikilinkValue(value)) return;
|
|
3826
|
+
if (!value.trim()) return;
|
|
3827
|
+
const normalizedValue = normalizeRef(value);
|
|
3828
|
+
const matchedPath = index.entities.get(normalizedValue);
|
|
3829
|
+
if (matchedPath) {
|
|
3830
|
+
const targetNote = index.notes.get(matchedPath);
|
|
3831
|
+
suggestions.push({
|
|
3832
|
+
field,
|
|
3833
|
+
current_value: value,
|
|
3834
|
+
suggested_link: `[[${targetNote?.title || value}]]`,
|
|
3835
|
+
target_note: matchedPath,
|
|
3836
|
+
array_index: arrayIndex
|
|
3837
|
+
});
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
for (const [field, value] of Object.entries(frontmatter)) {
|
|
3841
|
+
if (Array.isArray(value)) {
|
|
3842
|
+
value.forEach((v, i) => checkValue(field, v, i));
|
|
3843
|
+
} else {
|
|
3844
|
+
checkValue(field, value);
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
return {
|
|
3848
|
+
path: notePath,
|
|
3849
|
+
suggestions
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
async function validateCrossLayer(index, notePath, vaultPath2) {
|
|
3853
|
+
const content = await readFileContent(notePath, vaultPath2);
|
|
3854
|
+
if (content === null) {
|
|
3855
|
+
return {
|
|
3856
|
+
path: notePath,
|
|
3857
|
+
frontmatter_only: [],
|
|
3858
|
+
prose_only: [],
|
|
3859
|
+
consistent: [],
|
|
3860
|
+
error: "File not found"
|
|
3861
|
+
};
|
|
3862
|
+
}
|
|
3863
|
+
let frontmatter = {};
|
|
3864
|
+
let body = content;
|
|
3865
|
+
try {
|
|
3866
|
+
const parsed = matter2(content);
|
|
3867
|
+
frontmatter = parsed.data;
|
|
3868
|
+
body = parsed.content;
|
|
3869
|
+
} catch {
|
|
3870
|
+
}
|
|
3871
|
+
const frontmatterRefs = /* @__PURE__ */ new Map();
|
|
3872
|
+
for (const [field, value] of Object.entries(frontmatter)) {
|
|
3873
|
+
const wikilinks = extractWikilinksFromValue(value);
|
|
3874
|
+
for (const target of wikilinks) {
|
|
3875
|
+
frontmatterRefs.set(normalizeRef(target), { field, target });
|
|
3876
|
+
}
|
|
3877
|
+
if (typeof value === "string" && !isWikilinkValue(value)) {
|
|
3878
|
+
const normalized = normalizeRef(value);
|
|
3879
|
+
if (index.entities.has(normalized)) {
|
|
3880
|
+
frontmatterRefs.set(normalized, { field, target: value });
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
if (Array.isArray(value)) {
|
|
3884
|
+
for (const v of value) {
|
|
3885
|
+
if (typeof v === "string" && !isWikilinkValue(v)) {
|
|
3886
|
+
const normalized = normalizeRef(v);
|
|
3887
|
+
if (index.entities.has(normalized)) {
|
|
3888
|
+
frontmatterRefs.set(normalized, { field, target: v });
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
const proseRefs = /* @__PURE__ */ new Map();
|
|
3895
|
+
const cleanBody = removeCodeBlocks(body);
|
|
3896
|
+
const lines = cleanBody.split("\n");
|
|
3897
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3898
|
+
const line = lines[i];
|
|
3899
|
+
WIKILINK_REGEX2.lastIndex = 0;
|
|
3900
|
+
let match;
|
|
3901
|
+
while ((match = WIKILINK_REGEX2.exec(line)) !== null) {
|
|
3902
|
+
const target = match[1].trim();
|
|
3903
|
+
proseRefs.set(normalizeRef(target), { line: i + 1, target });
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
const frontmatter_only = [];
|
|
3907
|
+
const prose_only = [];
|
|
3908
|
+
const consistent = [];
|
|
3909
|
+
for (const [normalized, { field, target }] of frontmatterRefs) {
|
|
3910
|
+
if (proseRefs.has(normalized)) {
|
|
3911
|
+
consistent.push({ field, target });
|
|
3912
|
+
} else {
|
|
3913
|
+
frontmatter_only.push({ field, target });
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
for (const [normalized, { line, target }] of proseRefs) {
|
|
3917
|
+
if (!frontmatterRefs.has(normalized)) {
|
|
3918
|
+
prose_only.push({ pattern: `[[${target}]]`, target, line });
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
return {
|
|
3922
|
+
path: notePath,
|
|
3923
|
+
frontmatter_only,
|
|
3924
|
+
prose_only,
|
|
3925
|
+
consistent
|
|
3926
|
+
};
|
|
3927
|
+
}
|
|
3928
|
+
function registerBidirectionalTools(server2, getIndex, getVaultPath) {
|
|
3929
|
+
server2.registerTool(
|
|
3930
|
+
"detect_prose_patterns",
|
|
3931
|
+
{
|
|
3932
|
+
title: "Detect Prose Patterns",
|
|
3933
|
+
description: 'Find "Key: [[Value]]" or "Key: Value" patterns in note prose (not frontmatter). Useful for identifying implicit metadata.',
|
|
3934
|
+
inputSchema: {
|
|
3935
|
+
path: z8.string().describe('Path to the note (e.g., "daily/2024-01-15.md")')
|
|
3936
|
+
}
|
|
3937
|
+
},
|
|
3938
|
+
async ({ path: notePath }) => {
|
|
3939
|
+
const index = getIndex();
|
|
3940
|
+
const vaultPath2 = getVaultPath();
|
|
3941
|
+
const result = await detectProsePatterns(index, notePath, vaultPath2);
|
|
3942
|
+
return {
|
|
3943
|
+
content: [
|
|
3944
|
+
{
|
|
3945
|
+
type: "text",
|
|
3946
|
+
text: JSON.stringify(result, null, 2)
|
|
3947
|
+
}
|
|
3948
|
+
]
|
|
3949
|
+
};
|
|
3950
|
+
}
|
|
3951
|
+
);
|
|
3952
|
+
server2.registerTool(
|
|
3953
|
+
"suggest_frontmatter_from_prose",
|
|
3954
|
+
{
|
|
3955
|
+
title: "Suggest Frontmatter From Prose",
|
|
3956
|
+
description: "Analyze prose patterns and suggest YAML frontmatter additions. Groups repeated patterns and preserves wikilinks.",
|
|
3957
|
+
inputSchema: {
|
|
3958
|
+
path: z8.string().describe("Path to the note")
|
|
3959
|
+
}
|
|
3960
|
+
},
|
|
3961
|
+
async ({ path: notePath }) => {
|
|
3962
|
+
const index = getIndex();
|
|
3963
|
+
const vaultPath2 = getVaultPath();
|
|
3964
|
+
const result = await suggestFrontmatterFromProse(index, notePath, vaultPath2);
|
|
3965
|
+
return {
|
|
3966
|
+
content: [
|
|
3967
|
+
{
|
|
3968
|
+
type: "text",
|
|
3969
|
+
text: JSON.stringify(result, null, 2)
|
|
3970
|
+
}
|
|
3971
|
+
]
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3974
|
+
);
|
|
3975
|
+
server2.registerTool(
|
|
3976
|
+
"suggest_wikilinks_in_frontmatter",
|
|
3977
|
+
{
|
|
3978
|
+
title: "Suggest Wikilinks in Frontmatter",
|
|
3979
|
+
description: "Find frontmatter string values that match existing note titles or aliases, and suggest converting them to wikilinks.",
|
|
3980
|
+
inputSchema: {
|
|
3981
|
+
path: z8.string().describe("Path to the note")
|
|
3982
|
+
}
|
|
3983
|
+
},
|
|
3984
|
+
async ({ path: notePath }) => {
|
|
3985
|
+
const index = getIndex();
|
|
3986
|
+
const vaultPath2 = getVaultPath();
|
|
3987
|
+
const result = await suggestWikilinksInFrontmatter(
|
|
3988
|
+
index,
|
|
3989
|
+
notePath,
|
|
3990
|
+
vaultPath2
|
|
3991
|
+
);
|
|
3992
|
+
return {
|
|
3993
|
+
content: [
|
|
3994
|
+
{
|
|
3995
|
+
type: "text",
|
|
3996
|
+
text: JSON.stringify(result, null, 2)
|
|
3997
|
+
}
|
|
3998
|
+
]
|
|
3999
|
+
};
|
|
4000
|
+
}
|
|
4001
|
+
);
|
|
4002
|
+
server2.registerTool(
|
|
4003
|
+
"validate_cross_layer",
|
|
4004
|
+
{
|
|
4005
|
+
title: "Validate Cross Layer",
|
|
4006
|
+
description: "Check consistency between frontmatter references and prose wikilinks. Identifies references that appear in only one layer.",
|
|
4007
|
+
inputSchema: {
|
|
4008
|
+
path: z8.string().describe("Path to the note")
|
|
4009
|
+
}
|
|
4010
|
+
},
|
|
4011
|
+
async ({ path: notePath }) => {
|
|
4012
|
+
const index = getIndex();
|
|
4013
|
+
const vaultPath2 = getVaultPath();
|
|
4014
|
+
const result = await validateCrossLayer(index, notePath, vaultPath2);
|
|
4015
|
+
return {
|
|
4016
|
+
content: [
|
|
4017
|
+
{
|
|
4018
|
+
type: "text",
|
|
4019
|
+
text: JSON.stringify(result, null, 2)
|
|
4020
|
+
}
|
|
4021
|
+
]
|
|
4022
|
+
};
|
|
4023
|
+
}
|
|
4024
|
+
);
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
// src/tools/schema.ts
|
|
4028
|
+
import { z as z9 } from "zod";
|
|
4029
|
+
function getValueType2(value) {
|
|
4030
|
+
if (value === null) return "null";
|
|
4031
|
+
if (value === void 0) return "undefined";
|
|
4032
|
+
if (Array.isArray(value)) {
|
|
4033
|
+
if (value.some((v) => typeof v === "string" && /^\[\[.+\]\]$/.test(v))) {
|
|
4034
|
+
return "wikilink[]";
|
|
4035
|
+
}
|
|
4036
|
+
return "array";
|
|
4037
|
+
}
|
|
4038
|
+
if (typeof value === "string") {
|
|
4039
|
+
if (/^\[\[.+\]\]$/.test(value)) return "wikilink";
|
|
4040
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return "date";
|
|
4041
|
+
}
|
|
4042
|
+
if (value instanceof Date) return "date";
|
|
4043
|
+
return typeof value;
|
|
4044
|
+
}
|
|
4045
|
+
function getFolder(notePath) {
|
|
4046
|
+
const lastSlash = notePath.lastIndexOf("/");
|
|
4047
|
+
return lastSlash === -1 ? "" : notePath.substring(0, lastSlash);
|
|
4048
|
+
}
|
|
4049
|
+
function getNotesInFolder(index, folder) {
|
|
4050
|
+
const notes = [];
|
|
4051
|
+
for (const note of index.notes.values()) {
|
|
4052
|
+
if (!folder || note.path.startsWith(folder + "/") || getFolder(note.path) === folder) {
|
|
4053
|
+
notes.push(note);
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
return notes;
|
|
4057
|
+
}
|
|
4058
|
+
function detectNamingPattern(notes) {
|
|
4059
|
+
if (notes.length < 3) return null;
|
|
4060
|
+
const filenames = notes.map((n) => {
|
|
4061
|
+
const lastSlash = n.path.lastIndexOf("/");
|
|
4062
|
+
return lastSlash === -1 ? n.path : n.path.substring(lastSlash + 1);
|
|
4063
|
+
});
|
|
4064
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}/;
|
|
4065
|
+
const dateMatches = filenames.filter((f) => datePattern.test(f));
|
|
4066
|
+
if (dateMatches.length / filenames.length > 0.8) {
|
|
4067
|
+
const suffixes = dateMatches.map((f) => f.replace(/^\d{4}-\d{2}-\d{2}/, ""));
|
|
4068
|
+
const uniqueSuffixes = new Set(suffixes);
|
|
4069
|
+
if (uniqueSuffixes.size === 1) {
|
|
4070
|
+
return `YYYY-MM-DD${Array.from(uniqueSuffixes)[0]}`;
|
|
4071
|
+
}
|
|
4072
|
+
return "YYYY-MM-DD *.md";
|
|
4073
|
+
}
|
|
4074
|
+
const prefixPattern = /^([A-Z]+-)\d+/;
|
|
4075
|
+
const prefixMatches = filenames.filter((f) => prefixPattern.test(f));
|
|
4076
|
+
if (prefixMatches.length / filenames.length > 0.8) {
|
|
4077
|
+
const match = filenames[0].match(prefixPattern);
|
|
4078
|
+
if (match) {
|
|
4079
|
+
return `${match[1]}### *.md`;
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
return null;
|
|
4083
|
+
}
|
|
4084
|
+
function inferFolderConventions(index, folder, minConfidence = 0.5) {
|
|
4085
|
+
const notes = getNotesInFolder(index, folder);
|
|
4086
|
+
const totalNotes = notes.length;
|
|
4087
|
+
if (totalNotes === 0) {
|
|
4088
|
+
return {
|
|
4089
|
+
folder: folder || "(vault root)",
|
|
4090
|
+
note_count: 0,
|
|
4091
|
+
coverage: 0,
|
|
4092
|
+
inferred_fields: [],
|
|
4093
|
+
computed_field_suggestions: [],
|
|
4094
|
+
naming_pattern: null
|
|
4095
|
+
};
|
|
4096
|
+
}
|
|
4097
|
+
const notesWithFrontmatter = notes.filter(
|
|
4098
|
+
(n) => n.frontmatter && Object.keys(n.frontmatter).length > 0
|
|
4099
|
+
);
|
|
4100
|
+
const coverage = notesWithFrontmatter.length / totalNotes;
|
|
4101
|
+
const fieldStats = /* @__PURE__ */ new Map();
|
|
4102
|
+
for (const note of notes) {
|
|
4103
|
+
for (const [key, value] of Object.entries(note.frontmatter)) {
|
|
4104
|
+
if (!fieldStats.has(key)) {
|
|
4105
|
+
fieldStats.set(key, {
|
|
4106
|
+
count: 0,
|
|
4107
|
+
types: /* @__PURE__ */ new Map(),
|
|
4108
|
+
values: /* @__PURE__ */ new Map(),
|
|
4109
|
+
examples: []
|
|
4110
|
+
});
|
|
4111
|
+
}
|
|
4112
|
+
const stats = fieldStats.get(key);
|
|
4113
|
+
stats.count++;
|
|
4114
|
+
const type = getValueType2(value);
|
|
4115
|
+
stats.types.set(type, (stats.types.get(type) || 0) + 1);
|
|
4116
|
+
const valueStr = JSON.stringify(value);
|
|
4117
|
+
stats.values.set(valueStr, (stats.values.get(valueStr) || 0) + 1);
|
|
4118
|
+
if (stats.examples.length < 3) {
|
|
4119
|
+
stats.examples.push(note.path);
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
const inferredFields = [];
|
|
4124
|
+
for (const [name, stats] of fieldStats) {
|
|
4125
|
+
const frequency = stats.count / totalNotes;
|
|
4126
|
+
if (frequency < minConfidence) continue;
|
|
4127
|
+
let primaryType = "string";
|
|
4128
|
+
let maxTypeCount = 0;
|
|
4129
|
+
for (const [type, count] of stats.types) {
|
|
4130
|
+
if (count > maxTypeCount) {
|
|
4131
|
+
maxTypeCount = count;
|
|
4132
|
+
primaryType = type;
|
|
4133
|
+
}
|
|
4134
|
+
}
|
|
4135
|
+
const uniqueValues = stats.values.size;
|
|
4136
|
+
const isEnumerable = uniqueValues <= 20 && uniqueValues / stats.count < 0.5;
|
|
4137
|
+
let commonValues = null;
|
|
4138
|
+
if (isEnumerable) {
|
|
4139
|
+
const sortedValues = Array.from(stats.values.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
4140
|
+
commonValues = sortedValues.map(([v]) => JSON.parse(v));
|
|
4141
|
+
}
|
|
4142
|
+
const typeConsistency = maxTypeCount / stats.count;
|
|
4143
|
+
const confidence = Math.min(1, frequency * typeConsistency);
|
|
4144
|
+
inferredFields.push({
|
|
4145
|
+
name,
|
|
4146
|
+
frequency,
|
|
4147
|
+
inferred_type: primaryType,
|
|
4148
|
+
is_required: frequency >= 0.9,
|
|
4149
|
+
common_values: commonValues,
|
|
4150
|
+
example_notes: stats.examples,
|
|
4151
|
+
confidence
|
|
4152
|
+
});
|
|
4153
|
+
}
|
|
4154
|
+
inferredFields.sort((a, b) => b.frequency - a.frequency);
|
|
4155
|
+
const computedSuggestions = [];
|
|
4156
|
+
const hasWordCount = fieldStats.has("word_count");
|
|
4157
|
+
if (!hasWordCount && notes.length > 5) {
|
|
4158
|
+
computedSuggestions.push({
|
|
4159
|
+
name: "word_count",
|
|
4160
|
+
description: "Number of words in note body",
|
|
4161
|
+
sample_value: 500
|
|
4162
|
+
});
|
|
4163
|
+
}
|
|
4164
|
+
const hasLinkCount = fieldStats.has("link_count");
|
|
4165
|
+
if (!hasLinkCount && notes.some((n) => n.outlinks.length > 0)) {
|
|
4166
|
+
computedSuggestions.push({
|
|
4167
|
+
name: "link_count",
|
|
4168
|
+
description: "Number of outgoing wikilinks",
|
|
4169
|
+
sample_value: notes[0]?.outlinks.length || 0
|
|
4170
|
+
});
|
|
4171
|
+
}
|
|
4172
|
+
const namingPattern = detectNamingPattern(notes);
|
|
4173
|
+
return {
|
|
4174
|
+
folder: folder || "(vault root)",
|
|
4175
|
+
note_count: totalNotes,
|
|
4176
|
+
coverage,
|
|
4177
|
+
inferred_fields: inferredFields,
|
|
4178
|
+
computed_field_suggestions: computedSuggestions,
|
|
4179
|
+
naming_pattern: namingPattern
|
|
4180
|
+
};
|
|
4181
|
+
}
|
|
4182
|
+
function findIncompleteNotes(index, folder, minFrequency = 0.7, limit = 50, offset = 0) {
|
|
4183
|
+
const conventions = inferFolderConventions(index, folder, minFrequency);
|
|
4184
|
+
const notes = getNotesInFolder(index, folder);
|
|
4185
|
+
const expectedFields = conventions.inferred_fields.filter(
|
|
4186
|
+
(f) => f.frequency >= minFrequency
|
|
4187
|
+
);
|
|
4188
|
+
if (expectedFields.length === 0) {
|
|
4189
|
+
return {
|
|
4190
|
+
folder: folder || null,
|
|
4191
|
+
total_notes: notes.length,
|
|
4192
|
+
incomplete_count: 0,
|
|
4193
|
+
notes: []
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
const incompleteNotes = [];
|
|
4197
|
+
for (const note of notes) {
|
|
4198
|
+
const existingFields = Object.keys(note.frontmatter);
|
|
4199
|
+
const missingFields = [];
|
|
4200
|
+
for (const expected of expectedFields) {
|
|
4201
|
+
if (!existingFields.includes(expected.name)) {
|
|
4202
|
+
let suggestedValue = null;
|
|
4203
|
+
let suggestionSource = null;
|
|
4204
|
+
if (expected.common_values && expected.common_values.length > 0) {
|
|
4205
|
+
suggestedValue = expected.common_values[0];
|
|
4206
|
+
suggestionSource = "similar_notes";
|
|
4207
|
+
}
|
|
4208
|
+
missingFields.push({
|
|
4209
|
+
name: expected.name,
|
|
4210
|
+
expected_type: expected.inferred_type,
|
|
4211
|
+
frequency_in_folder: expected.frequency,
|
|
4212
|
+
suggested_value: suggestedValue,
|
|
4213
|
+
suggestion_source: suggestionSource
|
|
4214
|
+
});
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
4217
|
+
if (missingFields.length > 0) {
|
|
4218
|
+
const completeness = 1 - missingFields.length / expectedFields.length;
|
|
4219
|
+
incompleteNotes.push({
|
|
4220
|
+
path: note.path,
|
|
4221
|
+
existing_fields: existingFields,
|
|
4222
|
+
missing_fields: missingFields,
|
|
4223
|
+
completeness_score: Math.round(completeness * 100) / 100
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
incompleteNotes.sort((a, b) => a.completeness_score - b.completeness_score);
|
|
4228
|
+
const paginatedNotes = incompleteNotes.slice(offset, offset + limit);
|
|
4229
|
+
return {
|
|
4230
|
+
folder: folder || null,
|
|
4231
|
+
total_notes: notes.length,
|
|
4232
|
+
incomplete_count: incompleteNotes.length,
|
|
4233
|
+
notes: paginatedNotes
|
|
4234
|
+
};
|
|
4235
|
+
}
|
|
4236
|
+
function suggestFieldValues(index, field, context) {
|
|
4237
|
+
const notes = context?.folder ? getNotesInFolder(index, context.folder) : Array.from(index.notes.values());
|
|
4238
|
+
const valueStats = /* @__PURE__ */ new Map();
|
|
4239
|
+
let totalWithField = 0;
|
|
4240
|
+
let primaryType = "string";
|
|
4241
|
+
const typeCounts = /* @__PURE__ */ new Map();
|
|
4242
|
+
for (const note of notes) {
|
|
4243
|
+
const value = note.frontmatter[field];
|
|
4244
|
+
if (value === void 0) continue;
|
|
4245
|
+
totalWithField++;
|
|
4246
|
+
const type = getValueType2(value);
|
|
4247
|
+
typeCounts.set(type, (typeCounts.get(type) || 0) + 1);
|
|
4248
|
+
const values = Array.isArray(value) ? value : [value];
|
|
4249
|
+
for (const v of values) {
|
|
4250
|
+
const key = JSON.stringify(v);
|
|
4251
|
+
if (!valueStats.has(key)) {
|
|
4252
|
+
valueStats.set(key, {
|
|
4253
|
+
value: v,
|
|
4254
|
+
count: 0,
|
|
4255
|
+
notes: []
|
|
4256
|
+
});
|
|
4257
|
+
}
|
|
4258
|
+
const stats = valueStats.get(key);
|
|
4259
|
+
stats.count++;
|
|
4260
|
+
if (stats.notes.length < 3) {
|
|
4261
|
+
stats.notes.push(note.path);
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
let maxTypeCount = 0;
|
|
4266
|
+
for (const [type, count] of typeCounts) {
|
|
4267
|
+
if (count > maxTypeCount) {
|
|
4268
|
+
maxTypeCount = count;
|
|
4269
|
+
primaryType = type;
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
const suggestions = Array.from(valueStats.values()).map((stats) => {
|
|
4273
|
+
const frequency = stats.count / totalWithField;
|
|
4274
|
+
let confidence = frequency;
|
|
4275
|
+
let reason = `Used ${stats.count} times (${Math.round(frequency * 100)}%)`;
|
|
4276
|
+
if (context?.existing_frontmatter) {
|
|
4277
|
+
for (const notePath of stats.notes) {
|
|
4278
|
+
const note = index.notes.get(notePath);
|
|
4279
|
+
if (!note) continue;
|
|
4280
|
+
for (const [key, value] of Object.entries(context.existing_frontmatter)) {
|
|
4281
|
+
if (key !== field && JSON.stringify(note.frontmatter[key]) === JSON.stringify(value)) {
|
|
4282
|
+
confidence = Math.min(1, confidence + 0.1);
|
|
4283
|
+
reason = `Common when ${key}=${JSON.stringify(value)}`;
|
|
4284
|
+
break;
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
return {
|
|
4290
|
+
value: stats.value,
|
|
4291
|
+
frequency,
|
|
4292
|
+
confidence,
|
|
4293
|
+
reason,
|
|
4294
|
+
example_notes: stats.notes
|
|
4295
|
+
};
|
|
4296
|
+
}).sort((a, b) => b.confidence - a.confidence).slice(0, 10);
|
|
4297
|
+
const isEnumerable = valueStats.size <= 20;
|
|
4298
|
+
return {
|
|
4299
|
+
field,
|
|
4300
|
+
suggestions,
|
|
4301
|
+
value_type: primaryType,
|
|
4302
|
+
is_enumerable: isEnumerable
|
|
4303
|
+
};
|
|
4304
|
+
}
|
|
4305
|
+
function registerSchemaTools(server2, getIndex, getVaultPath) {
|
|
4306
|
+
server2.registerTool(
|
|
4307
|
+
"infer_folder_conventions",
|
|
4308
|
+
{
|
|
4309
|
+
title: "Infer Folder Conventions",
|
|
4310
|
+
description: "Auto-detect metadata conventions for a folder. Analyzes field frequency, types, common values, and naming patterns. Returns inferred schema with confidence scores.",
|
|
4311
|
+
inputSchema: {
|
|
4312
|
+
folder: z9.string().optional().describe('Folder to analyze (e.g., "meetings/"). If omitted, analyzes entire vault.'),
|
|
4313
|
+
min_confidence: z9.number().min(0).max(1).optional().describe("Minimum confidence threshold (0.0-1.0). Default 0.5.")
|
|
4314
|
+
}
|
|
4315
|
+
},
|
|
4316
|
+
async ({ folder, min_confidence }) => {
|
|
4317
|
+
const index = getIndex();
|
|
4318
|
+
const result = inferFolderConventions(
|
|
4319
|
+
index,
|
|
4320
|
+
folder,
|
|
4321
|
+
min_confidence ?? 0.5
|
|
4322
|
+
);
|
|
4323
|
+
return {
|
|
4324
|
+
content: [
|
|
4325
|
+
{
|
|
4326
|
+
type: "text",
|
|
4327
|
+
text: JSON.stringify(result, null, 2)
|
|
4328
|
+
}
|
|
4329
|
+
]
|
|
4330
|
+
};
|
|
4331
|
+
}
|
|
4332
|
+
);
|
|
4333
|
+
server2.registerTool(
|
|
4334
|
+
"find_incomplete_notes",
|
|
4335
|
+
{
|
|
4336
|
+
title: "Find Incomplete Notes",
|
|
4337
|
+
description: "Find notes missing expected fields based on inferred folder conventions. Returns completeness scores and suggested values.",
|
|
4338
|
+
inputSchema: {
|
|
4339
|
+
folder: z9.string().optional().describe("Folder to analyze. If omitted, analyzes entire vault."),
|
|
4340
|
+
min_frequency: z9.number().min(0).max(1).optional().describe('Minimum field frequency to consider "expected" (default 0.7).'),
|
|
4341
|
+
limit: z9.number().optional().describe("Maximum results to return (default 50)."),
|
|
4342
|
+
offset: z9.number().optional().describe("Number of results to skip for pagination (default 0).")
|
|
4343
|
+
}
|
|
4344
|
+
},
|
|
4345
|
+
async ({ folder, min_frequency, limit: requestedLimit, offset }) => {
|
|
4346
|
+
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
4347
|
+
const index = getIndex();
|
|
4348
|
+
const result = findIncompleteNotes(
|
|
4349
|
+
index,
|
|
4350
|
+
folder,
|
|
4351
|
+
min_frequency ?? 0.7,
|
|
4352
|
+
limit,
|
|
4353
|
+
offset ?? 0
|
|
4354
|
+
);
|
|
4355
|
+
return {
|
|
4356
|
+
content: [
|
|
4357
|
+
{
|
|
4358
|
+
type: "text",
|
|
4359
|
+
text: JSON.stringify(result, null, 2)
|
|
4360
|
+
}
|
|
4361
|
+
]
|
|
4362
|
+
};
|
|
4363
|
+
}
|
|
4364
|
+
);
|
|
4365
|
+
server2.registerTool(
|
|
4366
|
+
"suggest_field_values",
|
|
4367
|
+
{
|
|
4368
|
+
title: "Suggest Field Values",
|
|
4369
|
+
description: "Suggest appropriate values for a frontmatter field based on vault usage. Context-aware suggestions based on folder and existing fields.",
|
|
4370
|
+
inputSchema: {
|
|
4371
|
+
field: z9.string().describe("Field name to get suggestions for"),
|
|
4372
|
+
folder: z9.string().optional().describe("Limit suggestions to this folder"),
|
|
4373
|
+
existing_frontmatter: z9.record(z9.unknown()).optional().describe("Other frontmatter fields in the note for context")
|
|
4374
|
+
}
|
|
4375
|
+
},
|
|
4376
|
+
async ({ field, folder, existing_frontmatter }) => {
|
|
4377
|
+
const index = getIndex();
|
|
4378
|
+
const result = suggestFieldValues(index, field, {
|
|
4379
|
+
folder,
|
|
4380
|
+
existing_frontmatter
|
|
4381
|
+
});
|
|
4382
|
+
return {
|
|
4383
|
+
content: [
|
|
4384
|
+
{
|
|
4385
|
+
type: "text",
|
|
4386
|
+
text: JSON.stringify(result, null, 2)
|
|
4387
|
+
}
|
|
4388
|
+
]
|
|
4389
|
+
};
|
|
4390
|
+
}
|
|
4391
|
+
);
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
// src/tools/computed.ts
|
|
4395
|
+
import { z as z10 } from "zod";
|
|
4396
|
+
import * as fs10 from "fs/promises";
|
|
4397
|
+
import * as path8 from "path";
|
|
4398
|
+
import matter3 from "gray-matter";
|
|
4399
|
+
async function readFileContent2(notePath, vaultPath2) {
|
|
4400
|
+
const fullPath = path8.join(vaultPath2, notePath);
|
|
4401
|
+
try {
|
|
4402
|
+
return await fs10.readFile(fullPath, "utf-8");
|
|
4403
|
+
} catch {
|
|
4404
|
+
return null;
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
async function getFileStats(notePath, vaultPath2) {
|
|
4408
|
+
const fullPath = path8.join(vaultPath2, notePath);
|
|
4409
|
+
try {
|
|
4410
|
+
const stats = await fs10.stat(fullPath);
|
|
4411
|
+
return {
|
|
4412
|
+
modified: stats.mtime,
|
|
4413
|
+
created: stats.birthtime
|
|
4414
|
+
};
|
|
4415
|
+
} catch {
|
|
4416
|
+
return null;
|
|
4417
|
+
}
|
|
4418
|
+
}
|
|
4419
|
+
function countWords(text) {
|
|
4420
|
+
let clean = text.replace(/```[\s\S]*?```/g, "");
|
|
4421
|
+
clean = clean.replace(/`[^`\n]+`/g, "");
|
|
4422
|
+
clean = clean.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, target, alias) => alias || target);
|
|
4423
|
+
clean = clean.replace(/[#*_~`]/g, "");
|
|
4424
|
+
const words = clean.split(/\s+/).filter((w) => w.length > 0);
|
|
4425
|
+
return words.length;
|
|
4426
|
+
}
|
|
4427
|
+
function formatDate(date) {
|
|
4428
|
+
return date.toISOString().split("T")[0];
|
|
4429
|
+
}
|
|
4430
|
+
function calculateReadingTime(wordCount) {
|
|
4431
|
+
const minutes = Math.ceil(wordCount / 200);
|
|
4432
|
+
return `${minutes} min`;
|
|
4433
|
+
}
|
|
4434
|
+
var COMPUTABLE_FIELDS = [
|
|
4435
|
+
"word_count",
|
|
4436
|
+
"link_count",
|
|
4437
|
+
"backlink_count",
|
|
4438
|
+
"tag_count",
|
|
4439
|
+
"reading_time",
|
|
4440
|
+
"created",
|
|
4441
|
+
"last_updated"
|
|
4442
|
+
];
|
|
4443
|
+
async function computeFrontmatter(index, notePath, vaultPath2, fields) {
|
|
4444
|
+
const content = await readFileContent2(notePath, vaultPath2);
|
|
4445
|
+
if (content === null) {
|
|
4446
|
+
return {
|
|
4447
|
+
path: notePath,
|
|
4448
|
+
computed: [],
|
|
4449
|
+
suggested_additions: {},
|
|
4450
|
+
error: "File not found"
|
|
4451
|
+
};
|
|
4452
|
+
}
|
|
4453
|
+
let existingFrontmatter = {};
|
|
4454
|
+
let body = content;
|
|
4455
|
+
try {
|
|
4456
|
+
const parsed = matter3(content);
|
|
4457
|
+
existingFrontmatter = parsed.data;
|
|
4458
|
+
body = parsed.content;
|
|
4459
|
+
} catch {
|
|
4460
|
+
}
|
|
4461
|
+
const note = index.notes.get(notePath);
|
|
4462
|
+
const stats = await getFileStats(notePath, vaultPath2);
|
|
4463
|
+
const fieldsToCompute = fields ? fields.filter((f) => COMPUTABLE_FIELDS.includes(f)) : [...COMPUTABLE_FIELDS];
|
|
4464
|
+
const computed = [];
|
|
4465
|
+
const suggestedAdditions = {};
|
|
4466
|
+
for (const fieldName of fieldsToCompute) {
|
|
4467
|
+
let value = null;
|
|
4468
|
+
let method = "";
|
|
4469
|
+
switch (fieldName) {
|
|
4470
|
+
case "word_count": {
|
|
4471
|
+
value = countWords(body);
|
|
4472
|
+
method = "prose_word_count";
|
|
4473
|
+
break;
|
|
4474
|
+
}
|
|
4475
|
+
case "link_count": {
|
|
4476
|
+
value = note?.outlinks.length ?? 0;
|
|
4477
|
+
method = "outlink_count";
|
|
4478
|
+
break;
|
|
4479
|
+
}
|
|
4480
|
+
case "backlink_count": {
|
|
4481
|
+
const backlinks = index.backlinks.get(note?.title.toLowerCase() ?? "");
|
|
4482
|
+
value = backlinks?.length ?? 0;
|
|
4483
|
+
method = "backlink_index";
|
|
4484
|
+
break;
|
|
4485
|
+
}
|
|
4486
|
+
case "tag_count": {
|
|
4487
|
+
value = note?.tags.length ?? 0;
|
|
4488
|
+
method = "tag_count";
|
|
4489
|
+
break;
|
|
4490
|
+
}
|
|
4491
|
+
case "reading_time": {
|
|
4492
|
+
const words = countWords(body);
|
|
4493
|
+
value = calculateReadingTime(words);
|
|
4494
|
+
method = "word_count / 200";
|
|
4495
|
+
break;
|
|
4496
|
+
}
|
|
4497
|
+
case "created": {
|
|
4498
|
+
if (stats?.created) {
|
|
4499
|
+
value = formatDate(stats.created);
|
|
4500
|
+
method = "file_birthtime";
|
|
4501
|
+
}
|
|
4502
|
+
break;
|
|
4503
|
+
}
|
|
4504
|
+
case "last_updated": {
|
|
4505
|
+
if (stats?.modified) {
|
|
4506
|
+
value = formatDate(stats.modified);
|
|
4507
|
+
method = "file_mtime";
|
|
4508
|
+
}
|
|
4509
|
+
break;
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
if (value !== null) {
|
|
4513
|
+
const existingValue = existingFrontmatter[fieldName];
|
|
4514
|
+
const alreadyExists = existingValue !== void 0;
|
|
4515
|
+
let differs = null;
|
|
4516
|
+
if (alreadyExists) {
|
|
4517
|
+
differs = JSON.stringify(existingValue) !== JSON.stringify(value);
|
|
4518
|
+
}
|
|
4519
|
+
computed.push({
|
|
4520
|
+
name: fieldName,
|
|
4521
|
+
value,
|
|
4522
|
+
method,
|
|
4523
|
+
already_exists: alreadyExists,
|
|
4524
|
+
differs
|
|
4525
|
+
});
|
|
4526
|
+
if (!alreadyExists) {
|
|
4527
|
+
suggestedAdditions[fieldName] = value;
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
return {
|
|
4532
|
+
path: notePath,
|
|
4533
|
+
computed,
|
|
4534
|
+
suggested_additions: suggestedAdditions
|
|
4535
|
+
};
|
|
4536
|
+
}
|
|
4537
|
+
function registerComputedTools(server2, getIndex, getVaultPath) {
|
|
4538
|
+
server2.registerTool(
|
|
4539
|
+
"compute_frontmatter",
|
|
4540
|
+
{
|
|
4541
|
+
title: "Compute Frontmatter",
|
|
4542
|
+
description: "Auto-compute derived fields from note content. Computes: word_count, link_count, backlink_count, tag_count, reading_time, created, last_updated.",
|
|
4543
|
+
inputSchema: {
|
|
4544
|
+
path: z10.string().describe("Path to the note"),
|
|
4545
|
+
fields: z10.array(z10.string()).optional().describe("Specific fields to compute. If omitted, computes all available fields.")
|
|
4546
|
+
}
|
|
4547
|
+
},
|
|
4548
|
+
async ({ path: notePath, fields }) => {
|
|
4549
|
+
const index = getIndex();
|
|
4550
|
+
const vaultPath2 = getVaultPath();
|
|
4551
|
+
const result = await computeFrontmatter(index, notePath, vaultPath2, fields);
|
|
4552
|
+
return {
|
|
4553
|
+
content: [
|
|
4554
|
+
{
|
|
4555
|
+
type: "text",
|
|
4556
|
+
text: JSON.stringify(result, null, 2)
|
|
4557
|
+
}
|
|
4558
|
+
]
|
|
4559
|
+
};
|
|
4560
|
+
}
|
|
4561
|
+
);
|
|
4562
|
+
}
|
|
4563
|
+
|
|
4564
|
+
// src/tools/migrations.ts
|
|
4565
|
+
import { z as z11 } from "zod";
|
|
4566
|
+
import * as fs11 from "fs/promises";
|
|
4567
|
+
import * as path9 from "path";
|
|
4568
|
+
import matter4 from "gray-matter";
|
|
4569
|
+
function getNotesInFolder2(index, folder) {
|
|
4570
|
+
const notes = [];
|
|
4571
|
+
for (const note of index.notes.values()) {
|
|
4572
|
+
const noteFolder = note.path.includes("/") ? note.path.substring(0, note.path.lastIndexOf("/")) : "";
|
|
4573
|
+
if (!folder || note.path.startsWith(folder + "/") || noteFolder === folder) {
|
|
4574
|
+
notes.push(note);
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
4577
|
+
return notes;
|
|
4578
|
+
}
|
|
4579
|
+
async function readFileContent3(notePath, vaultPath2) {
|
|
4580
|
+
const fullPath = path9.join(vaultPath2, notePath);
|
|
4581
|
+
try {
|
|
4582
|
+
return await fs11.readFile(fullPath, "utf-8");
|
|
4583
|
+
} catch {
|
|
4584
|
+
return null;
|
|
4585
|
+
}
|
|
4586
|
+
}
|
|
4587
|
+
async function writeFileContent(notePath, vaultPath2, content) {
|
|
4588
|
+
const fullPath = path9.join(vaultPath2, notePath);
|
|
4589
|
+
try {
|
|
4590
|
+
await fs11.writeFile(fullPath, content, "utf-8");
|
|
4591
|
+
return true;
|
|
4592
|
+
} catch {
|
|
4593
|
+
return false;
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
async function updateFrontmatter(notePath, vaultPath2, updateFn) {
|
|
4597
|
+
const content = await readFileContent3(notePath, vaultPath2);
|
|
4598
|
+
if (content === null) return false;
|
|
4599
|
+
try {
|
|
4600
|
+
const parsed = matter4(content);
|
|
4601
|
+
const newFrontmatter = updateFn(parsed.data);
|
|
4602
|
+
const newContent = matter4.stringify(parsed.content, newFrontmatter);
|
|
4603
|
+
return await writeFileContent(notePath, vaultPath2, newContent);
|
|
4604
|
+
} catch {
|
|
4605
|
+
return false;
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
4608
|
+
async function renameField(index, vaultPath2, oldName, newName, options) {
|
|
4609
|
+
const dryRun = options?.dry_run ?? true;
|
|
4610
|
+
const notes = getNotesInFolder2(index, options?.folder);
|
|
4611
|
+
const previews = [];
|
|
4612
|
+
let conflicts = 0;
|
|
4613
|
+
for (const note of notes) {
|
|
4614
|
+
const oldValue = note.frontmatter[oldName];
|
|
4615
|
+
if (oldValue === void 0) continue;
|
|
4616
|
+
const hasNewField = note.frontmatter[newName] !== void 0;
|
|
4617
|
+
if (hasNewField) {
|
|
4618
|
+
previews.push({
|
|
4619
|
+
path: note.path,
|
|
4620
|
+
old_value: oldValue,
|
|
4621
|
+
status: "conflict"
|
|
4622
|
+
});
|
|
4623
|
+
conflicts++;
|
|
4624
|
+
continue;
|
|
4625
|
+
}
|
|
4626
|
+
if (dryRun) {
|
|
4627
|
+
previews.push({
|
|
4628
|
+
path: note.path,
|
|
4629
|
+
old_value: oldValue,
|
|
4630
|
+
status: "pending"
|
|
4631
|
+
});
|
|
4632
|
+
} else {
|
|
4633
|
+
const success = await updateFrontmatter(note.path, vaultPath2, (fm) => {
|
|
4634
|
+
const newFm = { ...fm };
|
|
4635
|
+
delete newFm[oldName];
|
|
4636
|
+
newFm[newName] = oldValue;
|
|
4637
|
+
return newFm;
|
|
4638
|
+
});
|
|
4639
|
+
previews.push({
|
|
4640
|
+
path: note.path,
|
|
4641
|
+
old_value: oldValue,
|
|
4642
|
+
status: success ? "applied" : "pending"
|
|
4643
|
+
});
|
|
4644
|
+
}
|
|
4645
|
+
}
|
|
4646
|
+
return {
|
|
4647
|
+
old_name: oldName,
|
|
4648
|
+
new_name: newName,
|
|
4649
|
+
dry_run: dryRun,
|
|
4650
|
+
affected_notes: previews.length,
|
|
4651
|
+
notes: previews,
|
|
4652
|
+
conflicts
|
|
4653
|
+
};
|
|
4654
|
+
}
|
|
4655
|
+
async function migrateFieldValues(index, vaultPath2, field, mapping, options) {
|
|
4656
|
+
const dryRun = options?.dry_run ?? true;
|
|
4657
|
+
const notes = getNotesInFolder2(index, options?.folder);
|
|
4658
|
+
const previews = [];
|
|
4659
|
+
let migrated = 0;
|
|
4660
|
+
let unchanged = 0;
|
|
4661
|
+
for (const note of notes) {
|
|
4662
|
+
const currentValue = note.frontmatter[field];
|
|
4663
|
+
if (currentValue === void 0) continue;
|
|
4664
|
+
const currentKey = String(currentValue);
|
|
4665
|
+
const newValue = mapping[currentKey];
|
|
4666
|
+
if (newValue === void 0) {
|
|
4667
|
+
previews.push({
|
|
4668
|
+
path: note.path,
|
|
4669
|
+
old_value: currentValue,
|
|
4670
|
+
new_value: currentValue,
|
|
4671
|
+
status: "no_match"
|
|
4672
|
+
});
|
|
4673
|
+
unchanged++;
|
|
4674
|
+
continue;
|
|
4675
|
+
}
|
|
4676
|
+
if (dryRun) {
|
|
4677
|
+
previews.push({
|
|
4678
|
+
path: note.path,
|
|
4679
|
+
old_value: currentValue,
|
|
4680
|
+
new_value: newValue,
|
|
4681
|
+
status: "pending"
|
|
4682
|
+
});
|
|
4683
|
+
migrated++;
|
|
4684
|
+
} else {
|
|
4685
|
+
const success = await updateFrontmatter(note.path, vaultPath2, (fm) => {
|
|
4686
|
+
return { ...fm, [field]: newValue };
|
|
4687
|
+
});
|
|
4688
|
+
previews.push({
|
|
4689
|
+
path: note.path,
|
|
4690
|
+
old_value: currentValue,
|
|
4691
|
+
new_value: newValue,
|
|
4692
|
+
status: success ? "applied" : "pending"
|
|
4693
|
+
});
|
|
4694
|
+
if (success) migrated++;
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4697
|
+
return {
|
|
4698
|
+
field,
|
|
4699
|
+
dry_run: dryRun,
|
|
4700
|
+
total_notes: notes.filter((n) => n.frontmatter[field] !== void 0).length,
|
|
4701
|
+
migrated,
|
|
4702
|
+
unchanged,
|
|
4703
|
+
previews
|
|
4704
|
+
};
|
|
4705
|
+
}
|
|
4706
|
+
function registerMigrationTools(server2, getIndex, getVaultPath) {
|
|
4707
|
+
server2.registerTool(
|
|
4708
|
+
"rename_field",
|
|
4709
|
+
{
|
|
4710
|
+
title: "Rename Field",
|
|
4711
|
+
description: "Bulk rename a frontmatter field across notes. Dry-run by default (preview only). Detects conflicts where new field name already exists.",
|
|
4712
|
+
inputSchema: {
|
|
4713
|
+
old_name: z11.string().describe("Current field name to rename"),
|
|
4714
|
+
new_name: z11.string().describe("New field name"),
|
|
4715
|
+
folder: z11.string().optional().describe("Limit to notes in this folder"),
|
|
4716
|
+
dry_run: z11.boolean().optional().describe("Preview only, no changes (default: true)")
|
|
4717
|
+
}
|
|
4718
|
+
},
|
|
4719
|
+
async ({ old_name, new_name, folder, dry_run }) => {
|
|
4720
|
+
const index = getIndex();
|
|
4721
|
+
const vaultPath2 = getVaultPath();
|
|
4722
|
+
const result = await renameField(index, vaultPath2, old_name, new_name, {
|
|
4723
|
+
folder,
|
|
4724
|
+
dry_run: dry_run ?? true
|
|
4725
|
+
});
|
|
4726
|
+
return {
|
|
4727
|
+
content: [
|
|
4728
|
+
{
|
|
4729
|
+
type: "text",
|
|
4730
|
+
text: JSON.stringify(result, null, 2)
|
|
4731
|
+
}
|
|
4732
|
+
]
|
|
4733
|
+
};
|
|
4734
|
+
}
|
|
4735
|
+
);
|
|
4736
|
+
server2.registerTool(
|
|
4737
|
+
"migrate_field_values",
|
|
4738
|
+
{
|
|
4739
|
+
title: "Migrate Field Values",
|
|
4740
|
+
description: 'Transform field values in bulk using a mapping (e.g., "high" -> 1). Dry-run by default.',
|
|
4741
|
+
inputSchema: {
|
|
4742
|
+
field: z11.string().describe("Field to migrate values for"),
|
|
4743
|
+
mapping: z11.record(z11.unknown()).describe('Mapping of old values to new values (e.g., {"high": 1, "medium": 2, "low": 3})'),
|
|
4744
|
+
folder: z11.string().optional().describe("Limit to notes in this folder"),
|
|
4745
|
+
dry_run: z11.boolean().optional().describe("Preview only, no changes (default: true)")
|
|
4746
|
+
}
|
|
4747
|
+
},
|
|
4748
|
+
async ({ field, mapping, folder, dry_run }) => {
|
|
4749
|
+
const index = getIndex();
|
|
4750
|
+
const vaultPath2 = getVaultPath();
|
|
4751
|
+
const result = await migrateFieldValues(index, vaultPath2, field, mapping, {
|
|
4752
|
+
folder,
|
|
4753
|
+
dry_run: dry_run ?? true
|
|
4754
|
+
});
|
|
4755
|
+
return {
|
|
4756
|
+
content: [
|
|
4757
|
+
{
|
|
4758
|
+
type: "text",
|
|
4759
|
+
text: JSON.stringify(result, null, 2)
|
|
4760
|
+
}
|
|
4761
|
+
]
|
|
4762
|
+
};
|
|
4763
|
+
}
|
|
4764
|
+
);
|
|
4765
|
+
}
|
|
4766
|
+
|
|
4767
|
+
// src/core/vaultRoot.ts
|
|
4768
|
+
import * as fs12 from "fs";
|
|
4769
|
+
import * as path10 from "path";
|
|
4770
|
+
var VAULT_MARKERS = [".obsidian", ".claude"];
|
|
4771
|
+
function findVaultRoot(startPath) {
|
|
4772
|
+
let current = path10.resolve(startPath || process.cwd());
|
|
4773
|
+
while (true) {
|
|
4774
|
+
for (const marker of VAULT_MARKERS) {
|
|
4775
|
+
const markerPath = path10.join(current, marker);
|
|
4776
|
+
if (fs12.existsSync(markerPath) && fs12.statSync(markerPath).isDirectory()) {
|
|
4777
|
+
return current;
|
|
4778
|
+
}
|
|
4779
|
+
}
|
|
4780
|
+
const parent = path10.dirname(current);
|
|
4781
|
+
if (parent === current) {
|
|
4782
|
+
return startPath || process.cwd();
|
|
4783
|
+
}
|
|
4784
|
+
current = parent;
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
// src/index.ts
|
|
4789
|
+
var vaultPath = process.env.PROJECT_PATH || findVaultRoot();
|
|
4790
|
+
var flywheelConfig = {};
|
|
4791
|
+
var vaultIndex;
|
|
4792
|
+
var server = new McpServer({
|
|
4793
|
+
name: "flywheel",
|
|
4794
|
+
version: "1.7.0"
|
|
4795
|
+
});
|
|
4796
|
+
registerGraphTools(
|
|
4797
|
+
server,
|
|
4798
|
+
() => vaultIndex,
|
|
4799
|
+
() => vaultPath
|
|
4800
|
+
);
|
|
4801
|
+
registerWikilinkTools(
|
|
4802
|
+
server,
|
|
4803
|
+
() => vaultIndex,
|
|
4804
|
+
() => vaultPath
|
|
4805
|
+
);
|
|
4806
|
+
registerHealthTools(
|
|
4807
|
+
server,
|
|
4808
|
+
() => vaultIndex,
|
|
4809
|
+
() => vaultPath
|
|
4810
|
+
);
|
|
4811
|
+
registerQueryTools(
|
|
4812
|
+
server,
|
|
4813
|
+
() => vaultIndex,
|
|
4814
|
+
() => vaultPath
|
|
4815
|
+
);
|
|
4816
|
+
registerSystemTools(
|
|
4817
|
+
server,
|
|
4818
|
+
() => vaultIndex,
|
|
4819
|
+
(newIndex) => {
|
|
4820
|
+
vaultIndex = newIndex;
|
|
4821
|
+
},
|
|
4822
|
+
() => vaultPath,
|
|
4823
|
+
(newConfig) => {
|
|
4824
|
+
flywheelConfig = newConfig;
|
|
4825
|
+
}
|
|
4826
|
+
);
|
|
4827
|
+
registerPrimitiveTools(
|
|
4828
|
+
server,
|
|
4829
|
+
() => vaultIndex,
|
|
4830
|
+
() => vaultPath,
|
|
4831
|
+
() => flywheelConfig
|
|
4832
|
+
);
|
|
4833
|
+
registerPeriodicTools(
|
|
4834
|
+
server,
|
|
4835
|
+
() => vaultIndex
|
|
4836
|
+
);
|
|
4837
|
+
registerBidirectionalTools(
|
|
4838
|
+
server,
|
|
4839
|
+
() => vaultIndex,
|
|
4840
|
+
() => vaultPath
|
|
4841
|
+
);
|
|
4842
|
+
registerSchemaTools(
|
|
4843
|
+
server,
|
|
4844
|
+
() => vaultIndex,
|
|
4845
|
+
() => vaultPath
|
|
4846
|
+
);
|
|
4847
|
+
registerComputedTools(
|
|
4848
|
+
server,
|
|
4849
|
+
() => vaultIndex,
|
|
4850
|
+
() => vaultPath
|
|
4851
|
+
);
|
|
4852
|
+
registerMigrationTools(
|
|
4853
|
+
server,
|
|
4854
|
+
() => vaultIndex,
|
|
4855
|
+
() => vaultPath
|
|
4856
|
+
);
|
|
4857
|
+
async function main() {
|
|
4858
|
+
const transport = new StdioServerTransport();
|
|
4859
|
+
await server.connect(transport);
|
|
4860
|
+
console.error("Flywheel MCP server running on stdio");
|
|
4861
|
+
console.error("Building vault index in background...");
|
|
4862
|
+
const startTime = Date.now();
|
|
4863
|
+
buildVaultIndex(vaultPath).then((index) => {
|
|
4864
|
+
vaultIndex = index;
|
|
4865
|
+
setIndexState("ready");
|
|
4866
|
+
const duration = Date.now() - startTime;
|
|
4867
|
+
console.error(`Vault index ready in ${duration}ms`);
|
|
4868
|
+
const existing = loadConfig(vaultPath);
|
|
4869
|
+
const inferred = inferConfig(vaultIndex, vaultPath);
|
|
4870
|
+
saveConfig(vaultPath, inferred, existing);
|
|
4871
|
+
flywheelConfig = loadConfig(vaultPath);
|
|
4872
|
+
if (flywheelConfig.vault_name) {
|
|
4873
|
+
console.error(`[Flywheel] Vault: ${flywheelConfig.vault_name}`);
|
|
4874
|
+
}
|
|
4875
|
+
if (flywheelConfig.paths) {
|
|
4876
|
+
const detectedPaths = Object.entries(flywheelConfig.paths).filter(([, v]) => v).map(([k, v]) => `${k}: ${v}`);
|
|
4877
|
+
if (detectedPaths.length) {
|
|
4878
|
+
console.error(`[Flywheel] Detected paths: ${detectedPaths.join(", ")}`);
|
|
4879
|
+
}
|
|
4880
|
+
}
|
|
4881
|
+
if (flywheelConfig.exclude_task_tags?.length) {
|
|
4882
|
+
console.error(`[Flywheel] Excluding task tags: ${flywheelConfig.exclude_task_tags.join(", ")}`);
|
|
4883
|
+
}
|
|
4884
|
+
}).catch((err) => {
|
|
4885
|
+
setIndexState("error");
|
|
4886
|
+
setIndexError(err instanceof Error ? err : new Error(String(err)));
|
|
4887
|
+
console.error("Failed to build vault index:", err);
|
|
4888
|
+
});
|
|
4889
|
+
}
|
|
4890
|
+
main().catch((error) => {
|
|
4891
|
+
console.error("Fatal error:", error);
|
|
4892
|
+
process.exit(1);
|
|
4893
|
+
});
|