seekstone 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +971 -530
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -10,13 +10,21 @@ import {
|
|
|
10
10
|
|
|
11
11
|
// src/cli-args.ts
|
|
12
12
|
function parseCliIntent(argv) {
|
|
13
|
-
if (argv[0] === "init")
|
|
13
|
+
if (argv[0] === "init") {
|
|
14
|
+
for (const arg of argv.slice(1)) {
|
|
15
|
+
if (arg === "--help" || arg === "-h") return "init-help";
|
|
16
|
+
}
|
|
17
|
+
return "init";
|
|
18
|
+
}
|
|
14
19
|
for (const arg of argv) {
|
|
15
20
|
if (arg === "--version" || arg === "-v") return "version";
|
|
16
21
|
if (arg === "--help" || arg === "-h") return "help";
|
|
17
22
|
}
|
|
18
23
|
return "run";
|
|
19
24
|
}
|
|
25
|
+
var initOptionLines = ` --vault <path> Vault to use (auto-detected from Obsidian if omitted; fallback: $SEEKSTONE_VAULT)
|
|
26
|
+
--client <name> desktop (default) | code | cursor | vscode
|
|
27
|
+
--write Patch the client config in place (backs it up first)`;
|
|
20
28
|
function helpText(version) {
|
|
21
29
|
return `seekstone ${version} \u2014 an Obsidian MCP server (filesystem-direct, low context-tax)
|
|
22
30
|
|
|
@@ -30,9 +38,7 @@ Usage:
|
|
|
30
38
|
seekstone --help Print this help and exit
|
|
31
39
|
|
|
32
40
|
"seekstone init" options:
|
|
33
|
-
|
|
34
|
-
--client <name> desktop (default) | code | cursor | vscode
|
|
35
|
-
--write Patch the client config in place (backs it up first)
|
|
41
|
+
${initOptionLines}
|
|
36
42
|
|
|
37
43
|
Required environment:
|
|
38
44
|
SEEKSTONE_VAULT Absolute path to your Obsidian vault
|
|
@@ -45,11 +51,30 @@ Optional environment:
|
|
|
45
51
|
Add to Claude Code:
|
|
46
52
|
claude mcp add seekstone --env SEEKSTONE_VAULT=/path/to/vault -- npx -y seekstone
|
|
47
53
|
|
|
54
|
+
Docs: https://github.com/shaqmughal/seekstone`;
|
|
55
|
+
}
|
|
56
|
+
function initHelpText(version) {
|
|
57
|
+
return `seekstone ${version} \u2014 seekstone init
|
|
58
|
+
|
|
59
|
+
Validate an Obsidian vault and print (or, with --write, patch) the MCP config
|
|
60
|
+
for the chosen client (Claude Desktop, Claude Code, Cursor, VS Code).
|
|
61
|
+
|
|
62
|
+
Usage:
|
|
63
|
+
seekstone init [options]
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
${initOptionLines}
|
|
67
|
+
--help, -h Print this help and exit
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
seekstone init
|
|
71
|
+
seekstone init --vault "/Users/you/Obsidian/My Vault" --client code --write
|
|
72
|
+
|
|
48
73
|
Docs: https://github.com/shaqmughal/seekstone`;
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
// src/tools/append_note.ts
|
|
52
|
-
import { readFile
|
|
77
|
+
import { readFile } from "fs/promises";
|
|
53
78
|
import { join } from "path";
|
|
54
79
|
|
|
55
80
|
// ../core/src/frontmatter.ts
|
|
@@ -117,10 +142,72 @@ function parseFrontmatter(text) {
|
|
|
117
142
|
|
|
118
143
|
// src/tools/append_note.ts
|
|
119
144
|
import { z } from "zod";
|
|
145
|
+
|
|
146
|
+
// src/atomic-write.ts
|
|
147
|
+
import { rename, writeFile } from "fs/promises";
|
|
148
|
+
async function atomicWrite(absPath, content) {
|
|
149
|
+
const tmpPath = `${absPath}.seekstone-tmp`;
|
|
150
|
+
await writeFile(tmpPath, content, "utf8");
|
|
151
|
+
await rename(tmpPath, absPath);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/content-hash.ts
|
|
155
|
+
import { createHash } from "crypto";
|
|
156
|
+
function contentHash(raw) {
|
|
157
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
158
|
+
}
|
|
159
|
+
function assertHashMatch(raw, prevHash, path2) {
|
|
160
|
+
const actual = contentHash(raw);
|
|
161
|
+
if (actual !== prevHash) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
error: "hash_conflict",
|
|
165
|
+
path: path2,
|
|
166
|
+
expected: prevHash,
|
|
167
|
+
actual,
|
|
168
|
+
hint: "Note changed on disk since it was read. Re-read it and retry with the new contentHash."
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/policy.ts
|
|
175
|
+
import picomatch from "picomatch";
|
|
176
|
+
function parseWritePolicy(env) {
|
|
177
|
+
const ro = (env.SEEKSTONE_READ_ONLY ?? "").toLowerCase();
|
|
178
|
+
const readOnly = ro === "1" || ro === "true";
|
|
179
|
+
const globs = (env.SEEKSTONE_WRITE_PATHS ?? "").split(",").map((g) => g.trim()).filter((g) => g.length > 0);
|
|
180
|
+
return globs.length > 0 ? { readOnly, writeGlobs: globs } : { readOnly };
|
|
181
|
+
}
|
|
182
|
+
function normalizeRel(relPath) {
|
|
183
|
+
let p = relPath.replaceAll("\\", "/");
|
|
184
|
+
while (p.startsWith("./")) p = p.slice(2);
|
|
185
|
+
return p;
|
|
186
|
+
}
|
|
187
|
+
function isWritable(policy2, relPath) {
|
|
188
|
+
if (policy2.readOnly) return false;
|
|
189
|
+
if (!policy2.writeGlobs) return true;
|
|
190
|
+
return picomatch(policy2.writeGlobs, { dot: true })(normalizeRel(relPath));
|
|
191
|
+
}
|
|
192
|
+
function assertWritable(policy2, relPath) {
|
|
193
|
+
if (policy2.readOnly) {
|
|
194
|
+
throw new Error("Server is read-only (SEEKSTONE_READ_ONLY=1); write tools are disabled.");
|
|
195
|
+
}
|
|
196
|
+
if (!isWritable(policy2, relPath)) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Write not permitted: "${relPath}" is outside SEEKSTONE_WRITE_PATHS (${policy2.writeGlobs?.join(", ")}).`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/tools/append_note.ts
|
|
120
204
|
var AppendNoteInput = z.object({
|
|
121
205
|
path: z.string().describe("Vault-relative path to the note."),
|
|
122
206
|
content: z.string().min(1).describe(
|
|
123
207
|
"Text to append to the note body. Will be separated from existing content by a blank line."
|
|
208
|
+
),
|
|
209
|
+
prevHash: z.string().optional().describe(
|
|
210
|
+
"Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
124
211
|
)
|
|
125
212
|
});
|
|
126
213
|
async function appendNote(ctx2, input) {
|
|
@@ -128,7 +215,9 @@ async function appendNote(ctx2, input) {
|
|
|
128
215
|
if (!absPath.startsWith(ctx2.vaultRoot)) {
|
|
129
216
|
throw new Error(`Path outside vault: ${input.path}`);
|
|
130
217
|
}
|
|
218
|
+
assertWritable(ctx2.policy, input.path);
|
|
131
219
|
const original = await readFile(absPath, "utf8");
|
|
220
|
+
if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
|
|
132
221
|
const fm = parseFrontmatter(original);
|
|
133
222
|
const body = fm.body.endsWith("\n") ? fm.body : `${fm.body}
|
|
134
223
|
`;
|
|
@@ -136,17 +225,21 @@ async function appendNote(ctx2, input) {
|
|
|
136
225
|
const newBody = `${body}${separator}${input.content}`;
|
|
137
226
|
const header = original.slice(0, fm.bodyStart);
|
|
138
227
|
const newContent = `${header}${newBody}`;
|
|
139
|
-
await
|
|
228
|
+
await atomicWrite(absPath, newContent);
|
|
140
229
|
const cached = ctx2.notes.get(input.path);
|
|
141
230
|
if (cached) {
|
|
142
231
|
cached.body = newBody;
|
|
143
232
|
cached.raw = newContent;
|
|
144
233
|
}
|
|
145
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
path: input.path,
|
|
236
|
+
bytesWritten: Buffer.byteLength(newContent, "utf8"),
|
|
237
|
+
contentHash: contentHash(newContent)
|
|
238
|
+
};
|
|
146
239
|
}
|
|
147
240
|
|
|
148
241
|
// src/tools/create_note.ts
|
|
149
|
-
import { access, mkdir,
|
|
242
|
+
import { access, mkdir, readFile as readFile2 } from "fs/promises";
|
|
150
243
|
import { dirname, join as join2 } from "path";
|
|
151
244
|
import { stringify as stringifyYaml } from "yaml";
|
|
152
245
|
import { z as z2 } from "zod";
|
|
@@ -225,13 +318,22 @@ var CreateNoteInput = z2.object({
|
|
|
225
318
|
),
|
|
226
319
|
content: z2.string().default("").describe("Body content for the note."),
|
|
227
320
|
frontmatter: z2.record(z2.string(), z2.unknown()).optional().describe("Frontmatter key-value pairs to write as a YAML block at the top of the note."),
|
|
228
|
-
overwrite: z2.boolean().default(false).describe(
|
|
321
|
+
overwrite: z2.boolean().default(false).describe(
|
|
322
|
+
"Overwrite an existing note. Defaults to false \u2014 throws if the note already exists."
|
|
323
|
+
),
|
|
324
|
+
prevHash: z2.string().optional().describe(
|
|
325
|
+
"Optional compare-and-swap guard for overwrite: true \u2014 the contentHash of the note being replaced. Fails with hash_conflict if it changed since it was read."
|
|
326
|
+
)
|
|
327
|
+
}).refine((v) => v.prevHash === void 0 || v.overwrite === true, {
|
|
328
|
+
message: "prevHash only makes sense with overwrite: true (a fresh create has no prior version)."
|
|
229
329
|
});
|
|
230
|
-
async function createNote(ctx2,
|
|
330
|
+
async function createNote(ctx2, rawInput) {
|
|
331
|
+
const input = CreateNoteInput.parse(rawInput);
|
|
231
332
|
const absPath = join2(ctx2.vaultRoot, input.path);
|
|
232
333
|
if (!absPath.startsWith(ctx2.vaultRoot)) {
|
|
233
334
|
throw new Error(`Path outside vault: ${input.path}`);
|
|
234
335
|
}
|
|
336
|
+
assertWritable(ctx2.policy, input.path);
|
|
235
337
|
if (!input.overwrite) {
|
|
236
338
|
try {
|
|
237
339
|
await access(absPath);
|
|
@@ -239,6 +341,22 @@ async function createNote(ctx2, input) {
|
|
|
239
341
|
} catch (err) {
|
|
240
342
|
if (err.code !== "ENOENT") throw err;
|
|
241
343
|
}
|
|
344
|
+
} else if (input.prevHash !== void 0) {
|
|
345
|
+
let existing;
|
|
346
|
+
try {
|
|
347
|
+
existing = await readFile2(absPath, "utf8");
|
|
348
|
+
} catch {
|
|
349
|
+
throw new Error(
|
|
350
|
+
JSON.stringify({
|
|
351
|
+
error: "hash_conflict",
|
|
352
|
+
path: input.path,
|
|
353
|
+
expected: input.prevHash,
|
|
354
|
+
actual: null,
|
|
355
|
+
hint: "The note no longer exists on disk. Re-check before overwriting."
|
|
356
|
+
})
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
assertHashMatch(existing, input.prevHash, input.path);
|
|
242
360
|
}
|
|
243
361
|
let raw = "";
|
|
244
362
|
if (input.frontmatter && Object.keys(input.frontmatter).length > 0) {
|
|
@@ -248,29 +366,112 @@ ${stringifyYaml(input.frontmatter)}---
|
|
|
248
366
|
}
|
|
249
367
|
raw += input.content ?? "";
|
|
250
368
|
await mkdir(dirname(absPath), { recursive: true });
|
|
251
|
-
await
|
|
369
|
+
await atomicWrite(absPath, raw);
|
|
252
370
|
upsertDoc(ctx2, buildDoc(input.path, raw));
|
|
253
|
-
return {
|
|
371
|
+
return {
|
|
372
|
+
path: input.path,
|
|
373
|
+
bytesWritten: Buffer.byteLength(raw, "utf8"),
|
|
374
|
+
contentHash: contentHash(raw)
|
|
375
|
+
};
|
|
254
376
|
}
|
|
255
377
|
|
|
256
378
|
// src/tools/delete_note.ts
|
|
257
|
-
import { rm } from "fs/promises";
|
|
258
|
-
import { join as join3 } from "path";
|
|
379
|
+
import { access as access2, mkdir as mkdir2, rename as rename2, rm } from "fs/promises";
|
|
380
|
+
import { basename as basename2, extname as extname2, join as join3 } from "path";
|
|
259
381
|
import { z as z3 } from "zod";
|
|
382
|
+
|
|
383
|
+
// src/index/resolve.ts
|
|
384
|
+
function resolveLink(target, notes2) {
|
|
385
|
+
if (notes2.has(target)) return target;
|
|
386
|
+
const withMd = `${target}.md`;
|
|
387
|
+
if (notes2.has(withMd)) return withMd;
|
|
388
|
+
const targetLower = target.toLowerCase();
|
|
389
|
+
const targetBase = (target.split("/").pop() ?? target).toLowerCase();
|
|
390
|
+
for (const path2 of notes2.keys()) {
|
|
391
|
+
const pathNoExt = path2.replace(/\.md$/i, "");
|
|
392
|
+
if (pathNoExt.toLowerCase() === targetLower) return path2;
|
|
393
|
+
const pathBase = (pathNoExt.split("/").pop() ?? pathNoExt).toLowerCase();
|
|
394
|
+
if (pathBase === targetBase) return path2;
|
|
395
|
+
}
|
|
396
|
+
return void 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/index/backlinks.ts
|
|
400
|
+
function removeNoteBacklinks(ctx2, relPath) {
|
|
401
|
+
const oldDoc = ctx2.notes.get(relPath);
|
|
402
|
+
if (oldDoc === void 0) return;
|
|
403
|
+
for (const link of extractLinksWithLines(oldDoc.raw)) {
|
|
404
|
+
const resolved = resolveLink(link.target, ctx2.notes);
|
|
405
|
+
if (resolved === void 0) continue;
|
|
406
|
+
const arr = ctx2.backlinks.get(resolved);
|
|
407
|
+
if (arr === void 0) continue;
|
|
408
|
+
const filtered = arr.filter((r) => r.path !== relPath);
|
|
409
|
+
ctx2.backlinks.set(resolved, filtered);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function addNoteBacklinks(ctx2, relPath, raw) {
|
|
413
|
+
const seen = /* @__PURE__ */ new Set();
|
|
414
|
+
for (const link of extractLinksWithLines(raw)) {
|
|
415
|
+
const resolved = resolveLink(link.target, ctx2.notes);
|
|
416
|
+
if (resolved === void 0) continue;
|
|
417
|
+
const dedupeKey = `${relPath}\0${resolved}`;
|
|
418
|
+
if (seen.has(dedupeKey)) continue;
|
|
419
|
+
seen.add(dedupeKey);
|
|
420
|
+
let arr = ctx2.backlinks.get(resolved);
|
|
421
|
+
if (arr === void 0) {
|
|
422
|
+
arr = [];
|
|
423
|
+
ctx2.backlinks.set(resolved, arr);
|
|
424
|
+
}
|
|
425
|
+
const ref = { path: relPath, line: link.line, linkType: link.linkType };
|
|
426
|
+
arr.push(ref);
|
|
427
|
+
arr.sort((a, b) => a.path.localeCompare(b.path));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/tools/delete_note.ts
|
|
260
432
|
var DeleteNoteInput = z3.object({
|
|
261
|
-
path: z3.string().describe("Vault-relative path of the note to delete.")
|
|
433
|
+
path: z3.string().describe("Vault-relative path of the note to delete."),
|
|
434
|
+
permanent: z3.boolean().default(false).describe(
|
|
435
|
+
"Permanently remove the note instead of moving it to the vault .trash/ folder. Defaults to false \u2014 deletes are recoverable."
|
|
436
|
+
)
|
|
262
437
|
});
|
|
263
|
-
async function
|
|
438
|
+
async function freeTrashPath(vaultRoot2, noteBase) {
|
|
439
|
+
const ext = extname2(noteBase);
|
|
440
|
+
const stem = noteBase.slice(0, noteBase.length - ext.length);
|
|
441
|
+
let candidate = `.trash/${noteBase}`;
|
|
442
|
+
for (; ; ) {
|
|
443
|
+
try {
|
|
444
|
+
await access2(join3(vaultRoot2, candidate));
|
|
445
|
+
} catch {
|
|
446
|
+
return candidate;
|
|
447
|
+
}
|
|
448
|
+
candidate = `.trash/${stem}.${Date.now()}${ext}`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async function deleteNote(ctx2, rawInput) {
|
|
452
|
+
const input = DeleteNoteInput.parse(rawInput);
|
|
264
453
|
const absPath = join3(ctx2.vaultRoot, input.path);
|
|
265
454
|
if (!absPath.startsWith(ctx2.vaultRoot)) {
|
|
266
455
|
throw new Error(`Path outside vault: ${input.path}`);
|
|
267
456
|
}
|
|
268
|
-
|
|
457
|
+
assertWritable(ctx2.policy, input.path);
|
|
458
|
+
let trashedTo;
|
|
459
|
+
if (input.permanent) {
|
|
460
|
+
await rm(absPath);
|
|
461
|
+
} else {
|
|
462
|
+
await access2(absPath);
|
|
463
|
+
await mkdir2(join3(ctx2.vaultRoot, ".trash"), { recursive: true });
|
|
464
|
+
trashedTo = await freeTrashPath(ctx2.vaultRoot, basename2(input.path));
|
|
465
|
+
await rename2(absPath, join3(ctx2.vaultRoot, trashedTo));
|
|
466
|
+
}
|
|
467
|
+
removeNoteBacklinks(ctx2, input.path);
|
|
269
468
|
if (ctx2.notes.has(input.path)) {
|
|
270
469
|
ctx2.index.discard(input.path);
|
|
271
470
|
ctx2.notes.delete(input.path);
|
|
272
471
|
}
|
|
273
|
-
|
|
472
|
+
const result = { path: input.path, permanent: input.permanent };
|
|
473
|
+
if (trashedTo !== void 0) result.trashedTo = trashedTo;
|
|
474
|
+
return result;
|
|
274
475
|
}
|
|
275
476
|
|
|
276
477
|
// src/tools/get_backlinks.ts
|
|
@@ -313,24 +514,6 @@ function getBacklinks(ctx2, input) {
|
|
|
313
514
|
// src/tools/get_links.ts
|
|
314
515
|
import { join as join5 } from "path";
|
|
315
516
|
import { z as z5 } from "zod";
|
|
316
|
-
|
|
317
|
-
// src/index/resolve.ts
|
|
318
|
-
function resolveLink(target, notes2) {
|
|
319
|
-
if (notes2.has(target)) return target;
|
|
320
|
-
const withMd = `${target}.md`;
|
|
321
|
-
if (notes2.has(withMd)) return withMd;
|
|
322
|
-
const targetLower = target.toLowerCase();
|
|
323
|
-
const targetBase = (target.split("/").pop() ?? target).toLowerCase();
|
|
324
|
-
for (const path2 of notes2.keys()) {
|
|
325
|
-
const pathNoExt = path2.replace(/\.md$/i, "");
|
|
326
|
-
if (pathNoExt.toLowerCase() === targetLower) return path2;
|
|
327
|
-
const pathBase = (pathNoExt.split("/").pop() ?? pathNoExt).toLowerCase();
|
|
328
|
-
if (pathBase === targetBase) return path2;
|
|
329
|
-
}
|
|
330
|
-
return void 0;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// src/tools/get_links.ts
|
|
334
517
|
var GetLinksInput = z5.object({
|
|
335
518
|
path: z5.string().describe("Vault-relative path to the note.")
|
|
336
519
|
});
|
|
@@ -422,27 +605,125 @@ function listTags(ctx2, input) {
|
|
|
422
605
|
}
|
|
423
606
|
|
|
424
607
|
// src/tools/move_note.ts
|
|
425
|
-
import { access as
|
|
608
|
+
import { access as access3, mkdir as mkdir3, rename as rename3 } from "fs/promises";
|
|
426
609
|
import { dirname as dirname2, join as join6 } from "path";
|
|
427
610
|
import { z as z8 } from "zod";
|
|
611
|
+
|
|
612
|
+
// src/tools/rewrite_links.ts
|
|
613
|
+
import { posix } from "path";
|
|
614
|
+
var WIKI_RE = /(!?)\[\[([^\]|#\n]{1,512})(#[^\]|\n]{1,512})?(\|[^\]\n]{1,512})?\]\]/g;
|
|
615
|
+
var MD_RE = /(!?)\[([^\]\n]*)\]\(([^)\n]+)\)/g;
|
|
616
|
+
var FENCE_RE = /^\s*(```|~~~)/;
|
|
617
|
+
function wikiTargetFor(newPath, postNotes) {
|
|
618
|
+
const noExt = newPath.replace(/\.md$/i, "");
|
|
619
|
+
const base = noExt.split("/").pop() ?? noExt;
|
|
620
|
+
const baseLower = base.toLowerCase();
|
|
621
|
+
let matches2 = 0;
|
|
622
|
+
for (const p of postNotes.keys()) {
|
|
623
|
+
const pBase = (p.replace(/\.md$/i, "").split("/").pop() ?? "").toLowerCase();
|
|
624
|
+
if (pBase === baseLower && ++matches2 > 1) return noExt;
|
|
625
|
+
}
|
|
626
|
+
return base;
|
|
627
|
+
}
|
|
628
|
+
function mdTargetToVaultPath(rawTarget, sourceDir) {
|
|
629
|
+
let t = rawTarget.trim();
|
|
630
|
+
if (t.startsWith("<") && t.endsWith(">")) t = t.slice(1, -1);
|
|
631
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(t)) return void 0;
|
|
632
|
+
if (t.startsWith("#") || t === "") return void 0;
|
|
633
|
+
const hashIdx = t.indexOf("#");
|
|
634
|
+
if (hashIdx !== -1) t = t.slice(0, hashIdx);
|
|
635
|
+
try {
|
|
636
|
+
t = decodeURIComponent(t);
|
|
637
|
+
} catch {
|
|
638
|
+
}
|
|
639
|
+
const joined = posix.normalize(posix.join(sourceDir, t));
|
|
640
|
+
if (joined.startsWith("..")) return void 0;
|
|
641
|
+
return joined;
|
|
642
|
+
}
|
|
643
|
+
function rewriteNoteLinks(raw, sourcePath, oldPath, newPath, preNotes, postNotes) {
|
|
644
|
+
const sourceDir = posix.dirname(sourcePath);
|
|
645
|
+
const oldNoExt = oldPath.replace(/\.md$/i, "");
|
|
646
|
+
let count = 0;
|
|
647
|
+
const rewriteWikiLine = (line) => line.replace(WIKI_RE, (full, bang, target, frag, alias) => {
|
|
648
|
+
const t = target.trim();
|
|
649
|
+
if (resolveLink(t, postNotes) === newPath) return full;
|
|
650
|
+
if (resolveLink(t, preNotes) !== oldPath) return full;
|
|
651
|
+
count++;
|
|
652
|
+
const next = wikiTargetFor(newPath, postNotes);
|
|
653
|
+
return `${bang}[[${next}${frag ?? ""}${alias ?? ""}]]`;
|
|
654
|
+
});
|
|
655
|
+
const rewriteMdLine = (line) => line.replace(MD_RE, (full, bang, text, rawTarget) => {
|
|
656
|
+
const asRelative = mdTargetToVaultPath(rawTarget, sourceDir);
|
|
657
|
+
const asAbsolute = mdTargetToVaultPath(rawTarget, ".");
|
|
658
|
+
let vaultAbsoluteStyle;
|
|
659
|
+
if (asRelative === oldPath || asRelative === oldNoExt) vaultAbsoluteStyle = false;
|
|
660
|
+
else if (asAbsolute === oldPath || asAbsolute === oldNoExt) vaultAbsoluteStyle = true;
|
|
661
|
+
else return full;
|
|
662
|
+
count++;
|
|
663
|
+
const trimmed = rawTarget.trim();
|
|
664
|
+
const wrapped = trimmed.startsWith("<") && trimmed.endsWith(">");
|
|
665
|
+
const inner = wrapped ? trimmed.slice(1, -1) : trimmed;
|
|
666
|
+
const hashIdx = inner.indexOf("#");
|
|
667
|
+
const fragment = hashIdx === -1 ? "" : inner.slice(hashIdx);
|
|
668
|
+
const hadExt = /\.md(#|$)/i.test(inner);
|
|
669
|
+
let next = vaultAbsoluteStyle ? newPath : posix.relative(sourceDir, newPath);
|
|
670
|
+
if (!hadExt) next = next.replace(/\.md$/i, "");
|
|
671
|
+
if (inner.includes("%20")) next = next.replaceAll(" ", "%20");
|
|
672
|
+
const needsWrap = wrapped || next.includes(" ");
|
|
673
|
+
const target = needsWrap ? `<${next}${fragment}>` : `${next}${fragment}`;
|
|
674
|
+
return `${bang}[${text}](${target})`;
|
|
675
|
+
});
|
|
676
|
+
let inFence = false;
|
|
677
|
+
const lines = raw.split("\n").map((line) => {
|
|
678
|
+
if (FENCE_RE.test(line)) {
|
|
679
|
+
inFence = !inFence;
|
|
680
|
+
return line;
|
|
681
|
+
}
|
|
682
|
+
if (inFence) return line;
|
|
683
|
+
return rewriteMdLine(rewriteWikiLine(line));
|
|
684
|
+
});
|
|
685
|
+
return { content: lines.join("\n"), count };
|
|
686
|
+
}
|
|
687
|
+
function hasMarkdownLinkTo(raw, sourcePath, targetPath) {
|
|
688
|
+
if (!raw.includes("](")) return false;
|
|
689
|
+
const sourceDir = posix.dirname(sourcePath);
|
|
690
|
+
const noExt = targetPath.replace(/\.md$/i, "");
|
|
691
|
+
for (const m of raw.matchAll(MD_RE)) {
|
|
692
|
+
const rawTarget = m[3] ?? "";
|
|
693
|
+
const asRelative = mdTargetToVaultPath(rawTarget, sourceDir);
|
|
694
|
+
const asAbsolute = mdTargetToVaultPath(rawTarget, ".");
|
|
695
|
+
if (asRelative === targetPath || asRelative === noExt || asAbsolute === targetPath || asAbsolute === noExt) {
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/tools/move_note.ts
|
|
428
703
|
var MoveNoteInput = z8.object({
|
|
429
704
|
from: z8.string().describe("Vault-relative path of the note to move."),
|
|
430
705
|
to: z8.string().describe("Vault-relative destination path."),
|
|
431
|
-
overwrite: z8.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false.")
|
|
706
|
+
overwrite: z8.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false."),
|
|
707
|
+
rewriteLinks: z8.boolean().default(true).describe(
|
|
708
|
+
"Rewrite wikilinks and markdown links in other notes that point at the moved note, so nothing breaks. Defaults to true; pass false to move the file only."
|
|
709
|
+
)
|
|
432
710
|
});
|
|
433
|
-
async function moveNote(ctx2,
|
|
711
|
+
async function moveNote(ctx2, rawInput) {
|
|
712
|
+
const input = MoveNoteInput.parse(rawInput);
|
|
434
713
|
const absFrom = join6(ctx2.vaultRoot, input.from);
|
|
435
714
|
const absTo = join6(ctx2.vaultRoot, input.to);
|
|
436
715
|
if (!absFrom.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${input.from}`);
|
|
437
716
|
if (!absTo.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${input.to}`);
|
|
717
|
+
assertWritable(ctx2.policy, input.from);
|
|
718
|
+
assertWritable(ctx2.policy, input.to);
|
|
438
719
|
try {
|
|
439
|
-
await
|
|
720
|
+
await access3(absFrom);
|
|
440
721
|
} catch {
|
|
441
722
|
throw new Error(`Note not found: ${input.from}`);
|
|
442
723
|
}
|
|
443
724
|
if (!input.overwrite) {
|
|
444
725
|
try {
|
|
445
|
-
await
|
|
726
|
+
await access3(absTo);
|
|
446
727
|
throw new Error(
|
|
447
728
|
`Destination already exists: ${input.to}. Pass overwrite: true to replace it.`
|
|
448
729
|
);
|
|
@@ -450,19 +731,63 @@ async function moveNote(ctx2, input) {
|
|
|
450
731
|
if (err.code !== "ENOENT") throw err;
|
|
451
732
|
}
|
|
452
733
|
}
|
|
453
|
-
|
|
454
|
-
|
|
734
|
+
const preNotes = new Map(ctx2.notes);
|
|
735
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
736
|
+
if (input.rewriteLinks) {
|
|
737
|
+
for (const ref of ctx2.backlinks.get(input.from) ?? []) candidates.add(ref.path);
|
|
738
|
+
for (const [path2, doc] of preNotes) {
|
|
739
|
+
if (path2 === input.from || candidates.has(path2)) continue;
|
|
740
|
+
if (hasMarkdownLinkTo(doc.raw, path2, input.from)) candidates.add(path2);
|
|
741
|
+
}
|
|
742
|
+
candidates.delete(input.from);
|
|
743
|
+
}
|
|
744
|
+
await mkdir3(dirname2(absTo), { recursive: true });
|
|
745
|
+
await rename3(absFrom, absTo);
|
|
455
746
|
const raw = ctx2.notes.get(input.from)?.raw ?? "";
|
|
747
|
+
removeNoteBacklinks(ctx2, input.from);
|
|
456
748
|
if (ctx2.notes.has(input.from)) {
|
|
457
749
|
ctx2.index.discard(input.from);
|
|
458
750
|
ctx2.notes.delete(input.from);
|
|
459
751
|
}
|
|
460
752
|
upsertDoc(ctx2, buildDoc(input.to, raw));
|
|
461
|
-
|
|
753
|
+
addNoteBacklinks(ctx2, input.to, raw);
|
|
754
|
+
let notesRewritten = 0;
|
|
755
|
+
let linksRewritten = 0;
|
|
756
|
+
const skipped = [];
|
|
757
|
+
for (const path2 of [...candidates].sort()) {
|
|
758
|
+
const doc = ctx2.notes.get(path2);
|
|
759
|
+
if (doc === void 0) continue;
|
|
760
|
+
if (!isWritable(ctx2.policy, path2)) {
|
|
761
|
+
skipped.push(path2);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const { content, count } = rewriteNoteLinks(
|
|
765
|
+
doc.raw,
|
|
766
|
+
path2,
|
|
767
|
+
input.from,
|
|
768
|
+
input.to,
|
|
769
|
+
preNotes,
|
|
770
|
+
ctx2.notes
|
|
771
|
+
);
|
|
772
|
+
removeNoteBacklinks(ctx2, path2);
|
|
773
|
+
if (count > 0) {
|
|
774
|
+
await atomicWrite(join6(ctx2.vaultRoot, path2), content);
|
|
775
|
+
upsertDoc(ctx2, buildDoc(path2, content));
|
|
776
|
+
notesRewritten++;
|
|
777
|
+
linksRewritten += count;
|
|
778
|
+
addNoteBacklinks(ctx2, path2, content);
|
|
779
|
+
} else {
|
|
780
|
+
addNoteBacklinks(ctx2, path2, doc.raw);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
ctx2.backlinks.delete(input.from);
|
|
784
|
+
const result = { from: input.from, to: input.to, notesRewritten, linksRewritten };
|
|
785
|
+
if (skipped.length > 0) result.skipped = skipped;
|
|
786
|
+
return result;
|
|
462
787
|
}
|
|
463
788
|
|
|
464
789
|
// src/tools/outline_note.ts
|
|
465
|
-
import { readFile as
|
|
790
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
466
791
|
import { join as join7 } from "path";
|
|
467
792
|
|
|
468
793
|
// ../core/src/outline.ts
|
|
@@ -524,7 +849,7 @@ async function outlineNote(ctx2, input) {
|
|
|
524
849
|
if (!absPath.startsWith(ctx2.vaultRoot)) {
|
|
525
850
|
throw new Error(`Path outside vault: ${input.path}`);
|
|
526
851
|
}
|
|
527
|
-
const raw = await
|
|
852
|
+
const raw = await readFile3(absPath, "utf8");
|
|
528
853
|
return buildOutline(raw, {
|
|
529
854
|
includeBlocks: input.includeBlocks,
|
|
530
855
|
includeSizes: input.includeSizes
|
|
@@ -532,7 +857,7 @@ async function outlineNote(ctx2, input) {
|
|
|
532
857
|
}
|
|
533
858
|
|
|
534
859
|
// src/tools/patch_frontmatter.ts
|
|
535
|
-
import { readFile as
|
|
860
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
536
861
|
import { join as join8 } from "path";
|
|
537
862
|
import { parseDocument } from "yaml";
|
|
538
863
|
import { z as z10 } from "zod";
|
|
@@ -540,6 +865,9 @@ var PatchFrontmatterInput = z10.object({
|
|
|
540
865
|
path: z10.string().describe("Vault-relative path to the note."),
|
|
541
866
|
patch: z10.record(z10.string(), z10.unknown()).describe(
|
|
542
867
|
"Key-value pairs to set in the frontmatter. Existing keys not in the patch are unchanged. Pass null as a value to remove a key."
|
|
868
|
+
),
|
|
869
|
+
prevHash: z10.string().optional().describe(
|
|
870
|
+
"Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
543
871
|
)
|
|
544
872
|
});
|
|
545
873
|
async function patchFrontmatter(ctx2, input) {
|
|
@@ -547,7 +875,9 @@ async function patchFrontmatter(ctx2, input) {
|
|
|
547
875
|
if (!absPath.startsWith(ctx2.vaultRoot)) {
|
|
548
876
|
throw new Error(`Path outside vault: ${input.path}`);
|
|
549
877
|
}
|
|
550
|
-
|
|
878
|
+
assertWritable(ctx2.policy, input.path);
|
|
879
|
+
const original = await readFile4(absPath, "utf8");
|
|
880
|
+
if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
|
|
551
881
|
const fm = parseFrontmatter(original);
|
|
552
882
|
const keysChanged = [];
|
|
553
883
|
const keysAdded = [];
|
|
@@ -589,18 +919,24 @@ ${original}`;
|
|
|
589
919
|
const tail = opensWithCRLF ? "---\r\n" : "---\n";
|
|
590
920
|
newContent = `${head}${doc.toString()}${tail}${fm.body}`;
|
|
591
921
|
}
|
|
592
|
-
await
|
|
922
|
+
await atomicWrite(absPath, newContent);
|
|
593
923
|
const cached = ctx2.notes.get(input.path);
|
|
594
924
|
if (cached) {
|
|
595
925
|
const updated = parseFrontmatter(newContent);
|
|
596
926
|
cached.raw = newContent;
|
|
597
927
|
cached.fmKeys = updated.keys.join(" ");
|
|
598
928
|
}
|
|
599
|
-
return {
|
|
929
|
+
return {
|
|
930
|
+
path: input.path,
|
|
931
|
+
keysChanged,
|
|
932
|
+
keysAdded,
|
|
933
|
+
keysRemoved,
|
|
934
|
+
contentHash: contentHash(newContent)
|
|
935
|
+
};
|
|
600
936
|
}
|
|
601
937
|
|
|
602
938
|
// src/tools/patch_note.ts
|
|
603
|
-
import { readFile as
|
|
939
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
604
940
|
import { join as join9 } from "path";
|
|
605
941
|
import { z as z11 } from "zod";
|
|
606
942
|
var PatchNoteInput = z11.object({
|
|
@@ -615,6 +951,9 @@ var PatchNoteInput = z11.object({
|
|
|
615
951
|
content: z11.string().min(1).describe("Content to insert or replace with."),
|
|
616
952
|
createIfMissing: z11.boolean().default(false).describe(
|
|
617
953
|
"If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets."
|
|
954
|
+
),
|
|
955
|
+
prevHash: z11.string().optional().describe(
|
|
956
|
+
"Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
618
957
|
)
|
|
619
958
|
});
|
|
620
959
|
function lineEnd(raw, offset) {
|
|
@@ -626,17 +965,14 @@ function lineStart(raw, offset) {
|
|
|
626
965
|
const prev = raw.lastIndexOf("\n", offset - 1);
|
|
627
966
|
return prev === -1 ? 0 : prev + 1;
|
|
628
967
|
}
|
|
629
|
-
async function atomicWrite(absPath, content) {
|
|
630
|
-
const tmpPath = `${absPath}.seekstone-patch-tmp`;
|
|
631
|
-
await writeFile4(tmpPath, content, "utf8");
|
|
632
|
-
await rename2(tmpPath, absPath);
|
|
633
|
-
}
|
|
634
968
|
async function patchNote(ctx2, input) {
|
|
635
969
|
const absPath = join9(ctx2.vaultRoot, input.path);
|
|
636
970
|
if (!absPath.startsWith(ctx2.vaultRoot)) {
|
|
637
971
|
throw new Error(`Path outside vault: ${input.path}`);
|
|
638
972
|
}
|
|
639
|
-
|
|
973
|
+
assertWritable(ctx2.policy, input.path);
|
|
974
|
+
const raw = await readFile5(absPath, "utf8");
|
|
975
|
+
if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
|
|
640
976
|
const fm = parseFrontmatter(raw);
|
|
641
977
|
const originalFmRegion = raw.slice(0, fm.bodyStart);
|
|
642
978
|
const outline = buildOutline(raw, { includeBlocks: true });
|
|
@@ -664,7 +1000,8 @@ ${input.content}${contentNl}`;
|
|
|
664
1000
|
line: newContent2.slice(0, headingOffset).split("\n").length,
|
|
665
1001
|
byteOffset: headingOffset
|
|
666
1002
|
},
|
|
667
|
-
frontmatterUnchanged: true
|
|
1003
|
+
frontmatterUnchanged: true,
|
|
1004
|
+
contentHash: contentHash(newContent2)
|
|
668
1005
|
};
|
|
669
1006
|
}
|
|
670
1007
|
throw new Error(
|
|
@@ -729,11 +1066,12 @@ ${input.content}${contentNl}`;
|
|
|
729
1066
|
path: input.path,
|
|
730
1067
|
bytesWritten: Buffer.byteLength(newContent, "utf8"),
|
|
731
1068
|
targetResolvedAt: resolvedAt,
|
|
732
|
-
frontmatterUnchanged: true
|
|
1069
|
+
frontmatterUnchanged: true,
|
|
1070
|
+
contentHash: contentHash(newContent)
|
|
733
1071
|
};
|
|
734
1072
|
}
|
|
735
1073
|
async function verifyFrontmatter(absPath, originalFmRegion) {
|
|
736
|
-
const written = await
|
|
1074
|
+
const written = await readFile5(absPath, "utf8");
|
|
737
1075
|
if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
|
|
738
1076
|
throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
|
|
739
1077
|
}
|
|
@@ -748,7 +1086,7 @@ function updateCache(ctx2, path2, newContent, bodyStart) {
|
|
|
748
1086
|
}
|
|
749
1087
|
|
|
750
1088
|
// src/tools/periodic_note.ts
|
|
751
|
-
import { access as
|
|
1089
|
+
import { access as access4, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
|
|
752
1090
|
import { dirname as dirname3, join as join10 } from "path";
|
|
753
1091
|
import { z as z12 } from "zod";
|
|
754
1092
|
var PERIOD = z12.enum(["daily", "weekly", "monthly", "quarterly", "yearly"]);
|
|
@@ -761,7 +1099,7 @@ var DEFAULTS = {
|
|
|
761
1099
|
};
|
|
762
1100
|
async function readJson(absPath) {
|
|
763
1101
|
try {
|
|
764
|
-
return JSON.parse(await
|
|
1102
|
+
return JSON.parse(await readFile6(absPath, "utf8"));
|
|
765
1103
|
} catch {
|
|
766
1104
|
return null;
|
|
767
1105
|
}
|
|
@@ -834,12 +1172,13 @@ async function getPeriodicNote(ctx2, rawInput) {
|
|
|
834
1172
|
if (!abs.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${path2}`);
|
|
835
1173
|
let existed = false;
|
|
836
1174
|
try {
|
|
837
|
-
await
|
|
1175
|
+
await access4(abs);
|
|
838
1176
|
existed = true;
|
|
839
1177
|
} catch {
|
|
840
1178
|
}
|
|
841
1179
|
if (existed) return { path: path2, existed: true, created: false };
|
|
842
1180
|
if (!input.createIfMissing) return { path: path2, existed: false, created: false };
|
|
1181
|
+
assertWritable(ctx2.policy, path2);
|
|
843
1182
|
let body = "";
|
|
844
1183
|
if (cfg.template) {
|
|
845
1184
|
const templateAbs = join10(
|
|
@@ -848,21 +1187,24 @@ async function getPeriodicNote(ctx2, rawInput) {
|
|
|
848
1187
|
);
|
|
849
1188
|
if (templateAbs.startsWith(ctx2.vaultRoot)) {
|
|
850
1189
|
try {
|
|
851
|
-
body = await
|
|
1190
|
+
body = await readFile6(templateAbs, "utf8");
|
|
852
1191
|
} catch {
|
|
853
1192
|
}
|
|
854
1193
|
}
|
|
855
1194
|
}
|
|
856
|
-
await
|
|
857
|
-
await
|
|
1195
|
+
await mkdir4(dirname3(abs), { recursive: true });
|
|
1196
|
+
await atomicWrite(abs, body);
|
|
858
1197
|
upsertDoc(ctx2, buildDoc(path2, body));
|
|
859
|
-
return { path: path2, existed: false, created: true };
|
|
1198
|
+
return { path: path2, existed: false, created: true, contentHash: contentHash(body) };
|
|
860
1199
|
}
|
|
861
1200
|
var AppendPeriodicNoteInput = z12.object({
|
|
862
1201
|
period: PERIOD.default("daily").describe("Period type. Default: daily."),
|
|
863
1202
|
date: z12.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today."),
|
|
864
1203
|
content: z12.string().min(1).describe("Text to append to the note body. Separated from existing content by a blank line."),
|
|
865
|
-
createIfMissing: z12.boolean().default(true).describe("Create the note if it does not exist before appending. Default true.")
|
|
1204
|
+
createIfMissing: z12.boolean().default(true).describe("Create the note if it does not exist before appending. Default true."),
|
|
1205
|
+
prevHash: z12.string().optional().describe(
|
|
1206
|
+
"Optional compare-and-swap guard: the contentHash from a prior read of the resolved periodic note. Fails with hash_conflict if it changed since."
|
|
1207
|
+
)
|
|
866
1208
|
});
|
|
867
1209
|
async function appendPeriodicNote(ctx2, rawInput) {
|
|
868
1210
|
const input = AppendPeriodicNoteInput.parse(rawInput);
|
|
@@ -871,16 +1213,33 @@ async function appendPeriodicNote(ctx2, rawInput) {
|
|
|
871
1213
|
const path2 = resolveNotePath(cfg, date);
|
|
872
1214
|
const abs = join10(ctx2.vaultRoot, path2);
|
|
873
1215
|
if (!abs.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${path2}`);
|
|
1216
|
+
assertWritable(ctx2.policy, path2);
|
|
874
1217
|
let original = "";
|
|
875
1218
|
try {
|
|
876
|
-
original = await
|
|
1219
|
+
original = await readFile6(abs, "utf8");
|
|
1220
|
+
if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, path2);
|
|
877
1221
|
} catch (err) {
|
|
878
1222
|
if (err.code !== "ENOENT") throw err;
|
|
1223
|
+
if (input.prevHash !== void 0) {
|
|
1224
|
+
throw new Error(
|
|
1225
|
+
JSON.stringify({
|
|
1226
|
+
error: "hash_conflict",
|
|
1227
|
+
path: path2,
|
|
1228
|
+
expected: input.prevHash,
|
|
1229
|
+
actual: null,
|
|
1230
|
+
hint: "The periodic note no longer exists on disk. Re-read before retrying."
|
|
1231
|
+
})
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
879
1234
|
if (!input.createIfMissing) throw new Error(`Periodic note not found: ${path2}`);
|
|
880
|
-
await
|
|
881
|
-
await
|
|
1235
|
+
await mkdir4(dirname3(abs), { recursive: true });
|
|
1236
|
+
await atomicWrite(abs, input.content);
|
|
882
1237
|
upsertDoc(ctx2, buildDoc(path2, input.content));
|
|
883
|
-
return {
|
|
1238
|
+
return {
|
|
1239
|
+
path: path2,
|
|
1240
|
+
bytesWritten: Buffer.byteLength(input.content, "utf8"),
|
|
1241
|
+
contentHash: contentHash(input.content)
|
|
1242
|
+
};
|
|
884
1243
|
}
|
|
885
1244
|
const fm = parseFrontmatter(original);
|
|
886
1245
|
const body = fm.body.endsWith("\n") ? fm.body : `${fm.body}
|
|
@@ -889,13 +1248,17 @@ async function appendPeriodicNote(ctx2, rawInput) {
|
|
|
889
1248
|
const newBody = `${body}${separator}${input.content}`;
|
|
890
1249
|
const header = original.slice(0, fm.bodyStart);
|
|
891
1250
|
const newRaw = `${header}${newBody}`;
|
|
892
|
-
await
|
|
1251
|
+
await atomicWrite(abs, newRaw);
|
|
893
1252
|
const cached = ctx2.notes.get(path2);
|
|
894
1253
|
if (cached) {
|
|
895
1254
|
cached.body = newBody;
|
|
896
1255
|
cached.raw = newRaw;
|
|
897
1256
|
}
|
|
898
|
-
return {
|
|
1257
|
+
return {
|
|
1258
|
+
path: path2,
|
|
1259
|
+
bytesWritten: Buffer.byteLength(newRaw, "utf8"),
|
|
1260
|
+
contentHash: contentHash(newRaw)
|
|
1261
|
+
};
|
|
899
1262
|
}
|
|
900
1263
|
|
|
901
1264
|
// src/tools/query_notes.ts
|
|
@@ -1104,7 +1467,7 @@ function queryNotes(ctx2, input) {
|
|
|
1104
1467
|
}
|
|
1105
1468
|
|
|
1106
1469
|
// src/tools/read_note.ts
|
|
1107
|
-
import { readFile as
|
|
1470
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
1108
1471
|
import { join as join11 } from "path";
|
|
1109
1472
|
import { z as z15 } from "zod";
|
|
1110
1473
|
var ReadNoteInput = z15.object({
|
|
@@ -1126,10 +1489,17 @@ async function readNote(ctx2, input) {
|
|
|
1126
1489
|
if (!absPath.startsWith(ctx2.vaultRoot)) {
|
|
1127
1490
|
throw new Error(`Path outside vault: ${input.path}`);
|
|
1128
1491
|
}
|
|
1129
|
-
const raw = await
|
|
1492
|
+
const raw = await readFile7(absPath, "utf8");
|
|
1130
1493
|
const noteBytes = Buffer.byteLength(raw, "utf8");
|
|
1494
|
+
const hash = contentHash(raw);
|
|
1131
1495
|
if (input.section === void 0 && input.block === void 0 && input.lines === void 0) {
|
|
1132
|
-
return {
|
|
1496
|
+
return {
|
|
1497
|
+
path: input.path,
|
|
1498
|
+
content: raw,
|
|
1499
|
+
bytesReturned: noteBytes,
|
|
1500
|
+
noteBytes,
|
|
1501
|
+
contentHash: hash
|
|
1502
|
+
};
|
|
1133
1503
|
}
|
|
1134
1504
|
const fm = parseFrontmatter(raw);
|
|
1135
1505
|
const outline = buildOutline(raw, { includeBlocks: true });
|
|
@@ -1201,12 +1571,13 @@ async function readNote(ctx2, input) {
|
|
|
1201
1571
|
content,
|
|
1202
1572
|
bytesReturned: Buffer.byteLength(content, "utf8"),
|
|
1203
1573
|
noteBytes,
|
|
1574
|
+
contentHash: hash,
|
|
1204
1575
|
span: { charStart, charEnd }
|
|
1205
1576
|
};
|
|
1206
1577
|
}
|
|
1207
1578
|
|
|
1208
1579
|
// src/tools/replace_in_note.ts
|
|
1209
|
-
import { readFile as
|
|
1580
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
1210
1581
|
import { join as join12 } from "path";
|
|
1211
1582
|
import { z as z16 } from "zod";
|
|
1212
1583
|
var ReplaceInNoteInput = z16.object({
|
|
@@ -1217,7 +1588,10 @@ var ReplaceInNoteInput = z16.object({
|
|
|
1217
1588
|
caseSensitive: z16.boolean().default(false).describe("Case-sensitive matching."),
|
|
1218
1589
|
wholeWord: z16.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
|
|
1219
1590
|
limit: z16.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
|
|
1220
|
-
dryRun: z16.boolean().default(false).describe("If true, report matches without writing anything.")
|
|
1591
|
+
dryRun: z16.boolean().default(false).describe("If true, report matches without writing anything."),
|
|
1592
|
+
prevHash: z16.string().optional().describe(
|
|
1593
|
+
"Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
1594
|
+
)
|
|
1221
1595
|
});
|
|
1222
1596
|
function escapeRegex(s) {
|
|
1223
1597
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -1228,16 +1602,12 @@ function buildRegex(find, opts) {
|
|
|
1228
1602
|
const flags = `g${opts.caseSensitive ? "" : "i"}`;
|
|
1229
1603
|
return new RegExp(pattern, flags);
|
|
1230
1604
|
}
|
|
1231
|
-
async function atomicWrite2(absPath, content) {
|
|
1232
|
-
const tmpPath = `${absPath}.seekstone-replace-tmp`;
|
|
1233
|
-
await writeFile6(tmpPath, content, "utf8");
|
|
1234
|
-
await rename3(tmpPath, absPath);
|
|
1235
|
-
}
|
|
1236
1605
|
async function replaceInNote(ctx2, input) {
|
|
1237
1606
|
const absPath = join12(ctx2.vaultRoot, input.path);
|
|
1238
1607
|
if (!absPath.startsWith(ctx2.vaultRoot)) {
|
|
1239
1608
|
throw new Error(`Path outside vault: ${input.path}`);
|
|
1240
1609
|
}
|
|
1610
|
+
assertWritable(ctx2.policy, input.path);
|
|
1241
1611
|
let re;
|
|
1242
1612
|
try {
|
|
1243
1613
|
re = buildRegex(input.find, {
|
|
@@ -1248,7 +1618,8 @@ async function replaceInNote(ctx2, input) {
|
|
|
1248
1618
|
} catch (err) {
|
|
1249
1619
|
throw new Error(`Invalid regex: ${err instanceof Error ? err.message : String(err)}`);
|
|
1250
1620
|
}
|
|
1251
|
-
const raw = await
|
|
1621
|
+
const raw = await readFile8(absPath, "utf8");
|
|
1622
|
+
if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
|
|
1252
1623
|
const fm = parseFrontmatter(raw);
|
|
1253
1624
|
const originalFmRegion = raw.slice(0, fm.bodyStart);
|
|
1254
1625
|
const body = raw.slice(fm.bodyStart);
|
|
@@ -1278,8 +1649,8 @@ async function replaceInNote(ctx2, input) {
|
|
|
1278
1649
|
}
|
|
1279
1650
|
newBody += body.slice(lastIndex);
|
|
1280
1651
|
const newContent = `${originalFmRegion}${newBody}`;
|
|
1281
|
-
await
|
|
1282
|
-
const written = await
|
|
1652
|
+
await atomicWrite(absPath, newContent);
|
|
1653
|
+
const written = await readFile8(absPath, "utf8");
|
|
1283
1654
|
if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
|
|
1284
1655
|
throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
|
|
1285
1656
|
}
|
|
@@ -1294,11 +1665,22 @@ async function replaceInNote(ctx2, input) {
|
|
|
1294
1665
|
replacements: toReplace.length,
|
|
1295
1666
|
matches: matches2,
|
|
1296
1667
|
bytesWritten: Buffer.byteLength(newContent, "utf8"),
|
|
1297
|
-
frontmatterUnchanged: true
|
|
1668
|
+
frontmatterUnchanged: true,
|
|
1669
|
+
contentHash: contentHash(newContent)
|
|
1298
1670
|
};
|
|
1299
1671
|
}
|
|
1300
1672
|
|
|
1301
1673
|
// src/dispatch.ts
|
|
1674
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
1675
|
+
"create_note",
|
|
1676
|
+
"delete_note",
|
|
1677
|
+
"move_note",
|
|
1678
|
+
"append_note",
|
|
1679
|
+
"patch_frontmatter",
|
|
1680
|
+
"patch_note",
|
|
1681
|
+
"replace_in_note",
|
|
1682
|
+
"append_periodic_note"
|
|
1683
|
+
]);
|
|
1302
1684
|
var META_KEYS = [
|
|
1303
1685
|
"path",
|
|
1304
1686
|
"from",
|
|
@@ -1317,7 +1699,9 @@ var META_KEYS = [
|
|
|
1317
1699
|
"minSizeBytes",
|
|
1318
1700
|
"maxSizeBytes",
|
|
1319
1701
|
"period",
|
|
1320
|
-
"date"
|
|
1702
|
+
"date",
|
|
1703
|
+
"permanent",
|
|
1704
|
+
"prevHash"
|
|
1321
1705
|
];
|
|
1322
1706
|
function safeMeta(args) {
|
|
1323
1707
|
if (typeof args !== "object" || args === null) return {};
|
|
@@ -1339,6 +1723,23 @@ async function dispatch(ctx2, name, args, log2) {
|
|
|
1339
1723
|
const start = performance.now();
|
|
1340
1724
|
const meta = safeMeta(args);
|
|
1341
1725
|
log2.debug("tool start", { tool: name, args });
|
|
1726
|
+
if (ctx2.policy.readOnly && WRITE_TOOLS.has(name)) {
|
|
1727
|
+
log2.info("tool rejected (read-only)", { tool: name, ...meta });
|
|
1728
|
+
return {
|
|
1729
|
+
content: [
|
|
1730
|
+
{
|
|
1731
|
+
type: "text",
|
|
1732
|
+
text: "Error: Server is read-only (SEEKSTONE_READ_ONLY=1); write tools are disabled."
|
|
1733
|
+
}
|
|
1734
|
+
],
|
|
1735
|
+
isError: true
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
if (ctx2.policy.readOnly && name === "get_periodic_note") {
|
|
1739
|
+
if (typeof args === "object" && args !== null) {
|
|
1740
|
+
args = { ...args, createIfMissing: false };
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1342
1743
|
try {
|
|
1343
1744
|
const result = await run(ctx2, name, args);
|
|
1344
1745
|
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
@@ -1388,25 +1789,38 @@ async function run(ctx2, name, args) {
|
|
|
1388
1789
|
const input = CreateNoteInput.parse(args);
|
|
1389
1790
|
const result = await createNote(ctx2, input);
|
|
1390
1791
|
return {
|
|
1391
|
-
content: [
|
|
1792
|
+
content: [
|
|
1793
|
+
{
|
|
1794
|
+
type: "text",
|
|
1795
|
+
text: `Created ${result.path} (${result.bytesWritten} bytes). contentHash: ${result.contentHash}`
|
|
1796
|
+
}
|
|
1797
|
+
]
|
|
1392
1798
|
};
|
|
1393
1799
|
}
|
|
1394
1800
|
case "delete_note": {
|
|
1395
1801
|
const input = DeleteNoteInput.parse(args);
|
|
1396
|
-
await deleteNote(ctx2, input);
|
|
1397
|
-
|
|
1802
|
+
const result = await deleteNote(ctx2, input);
|
|
1803
|
+
const text = result.trashedTo ? `Moved ${result.path} to ${result.trashedTo} (recoverable \u2014 restore by moving it back).` : `Permanently deleted ${result.path}.`;
|
|
1804
|
+
return { content: [{ type: "text", text }] };
|
|
1398
1805
|
}
|
|
1399
1806
|
case "move_note": {
|
|
1400
1807
|
const input = MoveNoteInput.parse(args);
|
|
1401
1808
|
const result = await moveNote(ctx2, input);
|
|
1402
|
-
|
|
1809
|
+
let text = `Moved ${result.from} \u2192 ${result.to}. Rewrote ${result.linksRewritten} link${result.linksRewritten === 1 ? "" : "s"} in ${result.notesRewritten} note${result.notesRewritten === 1 ? "" : "s"}.`;
|
|
1810
|
+
if (result.skipped?.length) {
|
|
1811
|
+
text += ` Skipped (outside SEEKSTONE_WRITE_PATHS): ${result.skipped.join(", ")}.`;
|
|
1812
|
+
}
|
|
1813
|
+
return { content: [{ type: "text", text }] };
|
|
1403
1814
|
}
|
|
1404
1815
|
case "append_note": {
|
|
1405
1816
|
const input = AppendNoteInput.parse(args);
|
|
1406
1817
|
const result = await appendNote(ctx2, input);
|
|
1407
1818
|
return {
|
|
1408
1819
|
content: [
|
|
1409
|
-
{
|
|
1820
|
+
{
|
|
1821
|
+
type: "text",
|
|
1822
|
+
text: `Appended ${result.bytesWritten} bytes to ${result.path}. contentHash: ${result.contentHash}`
|
|
1823
|
+
}
|
|
1410
1824
|
]
|
|
1411
1825
|
};
|
|
1412
1826
|
}
|
|
@@ -1452,7 +1866,7 @@ async function run(ctx2, name, args) {
|
|
|
1452
1866
|
content: [
|
|
1453
1867
|
{
|
|
1454
1868
|
type: "text",
|
|
1455
|
-
text: `Appended ${result.bytesWritten} bytes to ${result.path}
|
|
1869
|
+
text: `Appended ${result.bytesWritten} bytes to ${result.path}. contentHash: ${result.contentHash}`
|
|
1456
1870
|
}
|
|
1457
1871
|
]
|
|
1458
1872
|
};
|
|
@@ -1463,12 +1877,12 @@ async function run(ctx2, name, args) {
|
|
|
1463
1877
|
}
|
|
1464
1878
|
|
|
1465
1879
|
// src/index/build.ts
|
|
1466
|
-
import { readFile as
|
|
1467
|
-
import { basename as
|
|
1880
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
1881
|
+
import { basename as basename3, extname as extname4 } from "path";
|
|
1468
1882
|
|
|
1469
1883
|
// ../core/src/walk.ts
|
|
1470
1884
|
import { stat } from "fs/promises";
|
|
1471
|
-
import { extname as
|
|
1885
|
+
import { extname as extname3, join as join13, relative, sep } from "path";
|
|
1472
1886
|
import fg from "fast-glob";
|
|
1473
1887
|
var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]);
|
|
1474
1888
|
var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
|
|
@@ -1483,7 +1897,7 @@ var IGNORE_GLOBS = [
|
|
|
1483
1897
|
function classify(relPath) {
|
|
1484
1898
|
if (relPath.endsWith(".excalidraw.md")) return "note";
|
|
1485
1899
|
if (relPath.endsWith(".excalidraw")) return "excalidraw";
|
|
1486
|
-
const ext =
|
|
1900
|
+
const ext = extname3(relPath).toLowerCase();
|
|
1487
1901
|
if (ext === ".md") return "note";
|
|
1488
1902
|
if (ext === ".canvas") return "canvas";
|
|
1489
1903
|
if (ext === ".pdf") return "pdf";
|
|
@@ -1515,7 +1929,7 @@ async function walkVault(vaultRoot2) {
|
|
|
1515
1929
|
absPath,
|
|
1516
1930
|
relPath,
|
|
1517
1931
|
topDir,
|
|
1518
|
-
ext:
|
|
1932
|
+
ext: extname3(relPath).toLowerCase(),
|
|
1519
1933
|
kind: classify(relPath),
|
|
1520
1934
|
sizeBytes: stats.size,
|
|
1521
1935
|
mtimeMs: stats.mtimeMs
|
|
@@ -1545,12 +1959,12 @@ async function buildIndex(vaultRoot2) {
|
|
|
1545
1959
|
await Promise.all(
|
|
1546
1960
|
noteEntries.map(async (entry) => {
|
|
1547
1961
|
try {
|
|
1548
|
-
const raw = await
|
|
1962
|
+
const raw = await readFile9(entry.absPath, "utf8");
|
|
1549
1963
|
const fm = parseFrontmatter(raw);
|
|
1550
1964
|
const inlineTags = extractInlineTags(fm.body);
|
|
1551
1965
|
const fmTags = frontmatterTags(fm.data);
|
|
1552
1966
|
const allTags = [.../* @__PURE__ */ new Set([...inlineTags, ...fmTags])];
|
|
1553
|
-
const title = typeof fm.data?.title === "string" && fm.data.title ? fm.data.title :
|
|
1967
|
+
const title = typeof fm.data?.title === "string" && fm.data.title ? fm.data.title : basename3(entry.relPath, extname4(entry.relPath));
|
|
1554
1968
|
const doc = {
|
|
1555
1969
|
id: entry.relPath,
|
|
1556
1970
|
title,
|
|
@@ -1598,7 +2012,7 @@ function buildBacklinks(notes2) {
|
|
|
1598
2012
|
|
|
1599
2013
|
// src/init.ts
|
|
1600
2014
|
import { execFileSync } from "child_process";
|
|
1601
|
-
import { access as
|
|
2015
|
+
import { access as access5, copyFile, mkdir as mkdir5, readdir, readFile as readFile10, stat as stat2, writeFile as writeFile2 } from "fs/promises";
|
|
1602
2016
|
import path, { dirname as dirname4, join as join14 } from "path";
|
|
1603
2017
|
function parseInitArgs(argv) {
|
|
1604
2018
|
const opts = { write: false, client: "desktop" };
|
|
@@ -1609,6 +2023,7 @@ function parseInitArgs(argv) {
|
|
|
1609
2023
|
else if (a === "--client") {
|
|
1610
2024
|
const v = argv[++i];
|
|
1611
2025
|
if (v === "desktop" || v === "code" || v === "cursor" || v === "vscode") opts.client = v;
|
|
2026
|
+
else opts.invalidClient = v ?? "(missing)";
|
|
1612
2027
|
}
|
|
1613
2028
|
}
|
|
1614
2029
|
return opts;
|
|
@@ -1703,7 +2118,7 @@ async function validateVault(vaultPath) {
|
|
|
1703
2118
|
return { ok: false, error: `Path does not exist: ${vaultPath}` };
|
|
1704
2119
|
}
|
|
1705
2120
|
try {
|
|
1706
|
-
await
|
|
2121
|
+
await access5(join14(vaultPath, ".obsidian"));
|
|
1707
2122
|
} catch {
|
|
1708
2123
|
return {
|
|
1709
2124
|
ok: false,
|
|
@@ -1732,7 +2147,16 @@ async function countMarkdown(root) {
|
|
|
1732
2147
|
return count;
|
|
1733
2148
|
}
|
|
1734
2149
|
async function runInit(opts, deps) {
|
|
1735
|
-
|
|
2150
|
+
if (opts.invalidClient !== void 0) {
|
|
2151
|
+
return {
|
|
2152
|
+
ok: false,
|
|
2153
|
+
exitCode: 1,
|
|
2154
|
+
output: [
|
|
2155
|
+
`\u2717 Unknown --client: ${opts.invalidClient}. Known clients: desktop, code, cursor, vscode.`
|
|
2156
|
+
]
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
const rf = deps.readFile ?? readFile10;
|
|
1736
2160
|
let vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
|
|
1737
2161
|
if (!vaultPath) {
|
|
1738
2162
|
const registryPath = obsidianRegistryPath(deps.platform, {
|
|
@@ -1863,12 +2287,12 @@ async function runInit(opts, deps) {
|
|
|
1863
2287
|
}
|
|
1864
2288
|
const merged = opts.client === "vscode" ? mergeSeekstoneConfigVsCode(existing, vaultPath) : mergeSeekstoneConfig(existing, vaultPath);
|
|
1865
2289
|
let backupPath;
|
|
1866
|
-
await
|
|
2290
|
+
await mkdir5(dirname4(configPath), { recursive: true });
|
|
1867
2291
|
if (existingRaw !== null) {
|
|
1868
2292
|
backupPath = `${configPath}.backup-${deps.timestamp}`;
|
|
1869
2293
|
await copyFile(configPath, backupPath);
|
|
1870
2294
|
}
|
|
1871
|
-
await
|
|
2295
|
+
await writeFile2(configPath, `${JSON.stringify(merged, null, 2)}
|
|
1872
2296
|
`, "utf8");
|
|
1873
2297
|
out.push(
|
|
1874
2298
|
`\u2713 Patched ${configPath}`,
|
|
@@ -1939,7 +2363,7 @@ function createLogger(opts = {}) {
|
|
|
1939
2363
|
);
|
|
1940
2364
|
}
|
|
1941
2365
|
}
|
|
1942
|
-
function
|
|
2366
|
+
function writeFile3(record) {
|
|
1943
2367
|
if (!fileOk || filePath === void 0) return;
|
|
1944
2368
|
const line = `${JSON.stringify(record)}
|
|
1945
2369
|
`;
|
|
@@ -1961,7 +2385,7 @@ function createLogger(opts = {}) {
|
|
|
1961
2385
|
const f = fields ?? {};
|
|
1962
2386
|
stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
|
|
1963
2387
|
`);
|
|
1964
|
-
|
|
2388
|
+
writeFile3({ t: ts, level: lvl, msg, ...f });
|
|
1965
2389
|
}
|
|
1966
2390
|
return {
|
|
1967
2391
|
level,
|
|
@@ -1992,41 +2416,435 @@ function installProcessGuards(log2) {
|
|
|
1992
2416
|
};
|
|
1993
2417
|
}
|
|
1994
2418
|
|
|
2419
|
+
// src/tool-list.ts
|
|
2420
|
+
var ALL_TOOLS = [
|
|
2421
|
+
{
|
|
2422
|
+
name: "search",
|
|
2423
|
+
description: "Full-text search across the vault. Returns ranked excerpts (~200 chars) \u2014 not full notes \u2014 to minimise context usage. Supports fuzzy matching and prefix search.",
|
|
2424
|
+
inputSchema: {
|
|
2425
|
+
type: "object",
|
|
2426
|
+
properties: {
|
|
2427
|
+
query: { type: "string", description: "Search query." },
|
|
2428
|
+
limit: { type: "number", description: "Max results (1\u201350, default 10)." },
|
|
2429
|
+
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
2430
|
+
tag: { type: "string", description: "Restrict to notes with this tag." }
|
|
2431
|
+
},
|
|
2432
|
+
required: ["query"]
|
|
2433
|
+
}
|
|
2434
|
+
},
|
|
2435
|
+
{
|
|
2436
|
+
name: "query_notes",
|
|
2437
|
+
description: "Structured metadata query \u2014 filter notes by frontmatter key/value predicates, tag, folder, modified time, and size. Returns compact rows (path + title by default; opt into more via select), not note content. Use this instead of search when filtering by properties rather than text.",
|
|
2438
|
+
inputSchema: {
|
|
2439
|
+
type: "object",
|
|
2440
|
+
properties: {
|
|
2441
|
+
where: {
|
|
2442
|
+
type: "array",
|
|
2443
|
+
description: "Frontmatter predicates \u2014 all must match (AND).",
|
|
2444
|
+
items: {
|
|
2445
|
+
type: "object",
|
|
2446
|
+
properties: {
|
|
2447
|
+
key: { type: "string", description: "Frontmatter key to test." },
|
|
2448
|
+
op: {
|
|
2449
|
+
type: "string",
|
|
2450
|
+
enum: ["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"],
|
|
2451
|
+
description: "eq/ne compare scalars; contains matches array membership or substring; exists/missing test key presence; gt/gte/lt/lte compare numbers or strings (ISO dates sort correctly)."
|
|
2452
|
+
},
|
|
2453
|
+
value: {
|
|
2454
|
+
description: "Comparison value. Required for every op except exists/missing."
|
|
2455
|
+
}
|
|
2456
|
+
},
|
|
2457
|
+
required: ["key", "op"]
|
|
2458
|
+
}
|
|
2459
|
+
},
|
|
2460
|
+
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
2461
|
+
tag: { type: "string", description: "Restrict to notes with this tag (# optional)." },
|
|
2462
|
+
modifiedAfter: {
|
|
2463
|
+
type: "string",
|
|
2464
|
+
description: "Only notes modified at or after this ISO 8601 date/time."
|
|
2465
|
+
},
|
|
2466
|
+
modifiedBefore: {
|
|
2467
|
+
type: "string",
|
|
2468
|
+
description: "Only notes modified before this ISO 8601 date/time."
|
|
2469
|
+
},
|
|
2470
|
+
minSizeBytes: { type: "number", description: "Only notes at least this many bytes." },
|
|
2471
|
+
maxSizeBytes: { type: "number", description: "Only notes at most this many bytes." },
|
|
2472
|
+
select: {
|
|
2473
|
+
type: "array",
|
|
2474
|
+
items: { type: "string" },
|
|
2475
|
+
description: 'Extra fields per hit: frontmatter keys, or "mtime", "size", "tags". Default returns only path + title.'
|
|
2476
|
+
},
|
|
2477
|
+
sort: {
|
|
2478
|
+
type: "string",
|
|
2479
|
+
enum: ["path", "title", "mtime", "size"],
|
|
2480
|
+
description: "Sort field (default path)."
|
|
2481
|
+
},
|
|
2482
|
+
order: {
|
|
2483
|
+
type: "string",
|
|
2484
|
+
enum: ["asc", "desc"],
|
|
2485
|
+
description: "Sort order (default asc)."
|
|
2486
|
+
},
|
|
2487
|
+
limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
|
|
2488
|
+
},
|
|
2489
|
+
required: []
|
|
2490
|
+
}
|
|
2491
|
+
},
|
|
2492
|
+
{
|
|
2493
|
+
name: "read_note",
|
|
2494
|
+
description: "Read a note or a span of it \u2014 by heading section, block reference, or line range. Returns structured JSON with the content, bytes returned, total note size, and a contentHash to pass as prevHash to edit tools for compare-and-swap. Use search or outline_note first to find the right path and section names.",
|
|
2495
|
+
inputSchema: {
|
|
2496
|
+
type: "object",
|
|
2497
|
+
properties: {
|
|
2498
|
+
path: {
|
|
2499
|
+
type: "string",
|
|
2500
|
+
description: 'Vault-relative path, e.g. "Daily Notes/2026-05-29.md".'
|
|
2501
|
+
},
|
|
2502
|
+
section: {
|
|
2503
|
+
type: "string",
|
|
2504
|
+
description: "Return only this heading section (heading text, # prefix optional). First match wins."
|
|
2505
|
+
},
|
|
2506
|
+
block: {
|
|
2507
|
+
type: "string",
|
|
2508
|
+
description: "Return only the line anchored by this block id (^ prefix optional)."
|
|
2509
|
+
},
|
|
2510
|
+
lines: {
|
|
2511
|
+
type: "object",
|
|
2512
|
+
description: "Return only this line range (1-indexed, inclusive).",
|
|
2513
|
+
properties: {
|
|
2514
|
+
from: { type: "number", description: "First line (1-indexed)." },
|
|
2515
|
+
to: {
|
|
2516
|
+
type: "number",
|
|
2517
|
+
description: "Last line (1-indexed, inclusive). Defaults to EOF."
|
|
2518
|
+
}
|
|
2519
|
+
},
|
|
2520
|
+
required: ["from"]
|
|
2521
|
+
},
|
|
2522
|
+
includeFrontmatter: {
|
|
2523
|
+
type: "boolean",
|
|
2524
|
+
description: "Prepend frontmatter to section/block span results. Default false for spans."
|
|
2525
|
+
}
|
|
2526
|
+
},
|
|
2527
|
+
required: ["path"]
|
|
2528
|
+
}
|
|
2529
|
+
},
|
|
2530
|
+
{
|
|
2531
|
+
name: "list_notes",
|
|
2532
|
+
description: "List notes, optionally filtered by folder prefix or tag.",
|
|
2533
|
+
inputSchema: {
|
|
2534
|
+
type: "object",
|
|
2535
|
+
properties: {
|
|
2536
|
+
folder: { type: "string", description: "Vault-relative folder prefix." },
|
|
2537
|
+
tag: { type: "string", description: "Filter by tag (# prefix optional)." },
|
|
2538
|
+
limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
|
|
2539
|
+
},
|
|
2540
|
+
required: []
|
|
2541
|
+
}
|
|
2542
|
+
},
|
|
2543
|
+
{
|
|
2544
|
+
name: "list_tags",
|
|
2545
|
+
description: "List all tags in the vault with usage counts. Supports substring filtering, minimum count threshold, and sort order. Nested tags (e.g. area/work) include a parent field.",
|
|
2546
|
+
inputSchema: {
|
|
2547
|
+
type: "object",
|
|
2548
|
+
properties: {
|
|
2549
|
+
pattern: {
|
|
2550
|
+
type: "string",
|
|
2551
|
+
description: 'Substring filter on tag name. Example: "work" matches "work" and "area/work".'
|
|
2552
|
+
},
|
|
2553
|
+
minCount: {
|
|
2554
|
+
type: "number",
|
|
2555
|
+
description: "Only return tags used in at least this many notes."
|
|
2556
|
+
},
|
|
2557
|
+
sort: {
|
|
2558
|
+
type: "string",
|
|
2559
|
+
enum: ["count", "name"],
|
|
2560
|
+
description: "Sort by usage count descending (default) or alphabetically."
|
|
2561
|
+
}
|
|
2562
|
+
},
|
|
2563
|
+
required: []
|
|
2564
|
+
}
|
|
2565
|
+
},
|
|
2566
|
+
{
|
|
2567
|
+
name: "create_note",
|
|
2568
|
+
description: "Create a new note at a vault-relative path. Optionally sets frontmatter and body content. Parent directories are created automatically. Fails if the note already exists unless overwrite is true (prevHash may guard the overwrite).",
|
|
2569
|
+
inputSchema: {
|
|
2570
|
+
type: "object",
|
|
2571
|
+
properties: {
|
|
2572
|
+
path: {
|
|
2573
|
+
type: "string",
|
|
2574
|
+
description: 'Vault-relative path, e.g. "Daily Notes/2026-06-01.md".'
|
|
2575
|
+
},
|
|
2576
|
+
content: { type: "string", description: "Body content for the note." },
|
|
2577
|
+
frontmatter: {
|
|
2578
|
+
type: "object",
|
|
2579
|
+
description: "Frontmatter key-value pairs.",
|
|
2580
|
+
additionalProperties: true
|
|
2581
|
+
},
|
|
2582
|
+
overwrite: {
|
|
2583
|
+
type: "boolean",
|
|
2584
|
+
description: "Overwrite an existing note. Defaults to false."
|
|
2585
|
+
},
|
|
2586
|
+
prevHash: {
|
|
2587
|
+
type: "string",
|
|
2588
|
+
description: "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
2589
|
+
}
|
|
2590
|
+
},
|
|
2591
|
+
required: ["path"]
|
|
2592
|
+
}
|
|
2593
|
+
},
|
|
2594
|
+
{
|
|
2595
|
+
name: "delete_note",
|
|
2596
|
+
description: "Delete a note. By default it is moved to the vault .trash/ folder (recoverable by moving it back); pass permanent: true to remove it outright.",
|
|
2597
|
+
inputSchema: {
|
|
2598
|
+
type: "object",
|
|
2599
|
+
properties: {
|
|
2600
|
+
path: { type: "string", description: "Vault-relative path of the note to delete." },
|
|
2601
|
+
permanent: {
|
|
2602
|
+
type: "boolean",
|
|
2603
|
+
description: "Permanently remove instead of moving to .trash/. Defaults to false."
|
|
2604
|
+
}
|
|
2605
|
+
},
|
|
2606
|
+
required: ["path"]
|
|
2607
|
+
}
|
|
2608
|
+
},
|
|
2609
|
+
{
|
|
2610
|
+
name: "move_note",
|
|
2611
|
+
description: "Move or rename a note to a new vault-relative path, rewriting wikilinks and markdown links in other notes that point at it so nothing breaks (links inside fenced code blocks are left alone). Parent directories at the destination are created automatically. Fails if the destination already exists unless overwrite is true.",
|
|
2612
|
+
inputSchema: {
|
|
2613
|
+
type: "object",
|
|
2614
|
+
properties: {
|
|
2615
|
+
from: { type: "string", description: "Vault-relative source path." },
|
|
2616
|
+
to: { type: "string", description: "Vault-relative destination path." },
|
|
2617
|
+
overwrite: {
|
|
2618
|
+
type: "boolean",
|
|
2619
|
+
description: "Overwrite destination if it exists. Defaults to false."
|
|
2620
|
+
},
|
|
2621
|
+
rewriteLinks: {
|
|
2622
|
+
type: "boolean",
|
|
2623
|
+
description: "Rewrite references in other notes to follow the move. Defaults to true; pass false to move the file only."
|
|
2624
|
+
}
|
|
2625
|
+
},
|
|
2626
|
+
required: ["from", "to"]
|
|
2627
|
+
}
|
|
2628
|
+
},
|
|
2629
|
+
{
|
|
2630
|
+
name: "append_note",
|
|
2631
|
+
description: "Append text to a note body without touching the frontmatter. Safe for meeting notes, daily logs, and append-only workflows.",
|
|
2632
|
+
inputSchema: {
|
|
2633
|
+
type: "object",
|
|
2634
|
+
properties: {
|
|
2635
|
+
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2636
|
+
content: { type: "string", description: "Text to append." },
|
|
2637
|
+
prevHash: {
|
|
2638
|
+
type: "string",
|
|
2639
|
+
description: "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
2640
|
+
}
|
|
2641
|
+
},
|
|
2642
|
+
required: ["path", "content"]
|
|
2643
|
+
}
|
|
2644
|
+
},
|
|
2645
|
+
{
|
|
2646
|
+
name: "patch_frontmatter",
|
|
2647
|
+
description: "Set, update, or delete frontmatter keys without reordering existing keys or changing quote style. Pass null as a value to delete a key.",
|
|
2648
|
+
inputSchema: {
|
|
2649
|
+
type: "object",
|
|
2650
|
+
properties: {
|
|
2651
|
+
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2652
|
+
patch: {
|
|
2653
|
+
type: "object",
|
|
2654
|
+
description: "Key-value pairs to set. Null value removes the key.",
|
|
2655
|
+
additionalProperties: true
|
|
2656
|
+
},
|
|
2657
|
+
prevHash: {
|
|
2658
|
+
type: "string",
|
|
2659
|
+
description: "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
2660
|
+
}
|
|
2661
|
+
},
|
|
2662
|
+
required: ["path", "patch"]
|
|
2663
|
+
}
|
|
2664
|
+
},
|
|
2665
|
+
{
|
|
2666
|
+
name: "outline_note",
|
|
2667
|
+
description: "Return a note's structure \u2014 heading tree with offsets, block-reference anchors, and frontmatter key list \u2014 without returning any prose. Use this before section reads or patches to discover what sections exist at a fraction of the cost of reading the full note.",
|
|
2668
|
+
inputSchema: {
|
|
2669
|
+
type: "object",
|
|
2670
|
+
properties: {
|
|
2671
|
+
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2672
|
+
includeBlocks: {
|
|
2673
|
+
type: "boolean",
|
|
2674
|
+
description: "Include block-reference anchors (^id). Default true."
|
|
2675
|
+
},
|
|
2676
|
+
includeSizes: {
|
|
2677
|
+
type: "boolean",
|
|
2678
|
+
description: "Include per-section character length in each heading entry. Default false."
|
|
2679
|
+
}
|
|
2680
|
+
},
|
|
2681
|
+
required: ["path"]
|
|
2682
|
+
}
|
|
2683
|
+
},
|
|
2684
|
+
{
|
|
2685
|
+
name: "patch_note",
|
|
2686
|
+
description: "Surgically edit a section of a note \u2014 targeted by heading or block reference \u2014 without rewriting the whole file. Operations: append (add after section), prepend (add after heading line), replace (swap section content). Frontmatter is never touched.",
|
|
2687
|
+
inputSchema: {
|
|
2688
|
+
type: "object",
|
|
2689
|
+
properties: {
|
|
2690
|
+
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2691
|
+
target: {
|
|
2692
|
+
type: "object",
|
|
2693
|
+
description: 'Exactly one of: { heading: "Section Title" } or { block: "block-id" } (without the ^ prefix).'
|
|
2694
|
+
},
|
|
2695
|
+
operation: {
|
|
2696
|
+
type: "string",
|
|
2697
|
+
enum: ["append", "prepend", "replace"],
|
|
2698
|
+
description: "append: insert after section content. prepend: insert after heading line. replace: swap section content."
|
|
2699
|
+
},
|
|
2700
|
+
content: { type: "string", description: "Content to insert or replace with." },
|
|
2701
|
+
createIfMissing: {
|
|
2702
|
+
type: "boolean",
|
|
2703
|
+
description: "If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets. Default false."
|
|
2704
|
+
},
|
|
2705
|
+
prevHash: {
|
|
2706
|
+
type: "string",
|
|
2707
|
+
description: "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
2708
|
+
}
|
|
2709
|
+
},
|
|
2710
|
+
required: ["path", "target", "operation", "content"]
|
|
2711
|
+
}
|
|
2712
|
+
},
|
|
2713
|
+
{
|
|
2714
|
+
name: "get_backlinks",
|
|
2715
|
+
description: "Return every note that links to the target note, with the source line and an optional excerpt. Results come from the pre-built reverse-link index so this is a fast, pure index lookup. Sort order: source path ascending.",
|
|
2716
|
+
inputSchema: {
|
|
2717
|
+
type: "object",
|
|
2718
|
+
properties: {
|
|
2719
|
+
path: { type: "string", description: "Vault-relative path to the target note." },
|
|
2720
|
+
includeContext: {
|
|
2721
|
+
type: "boolean",
|
|
2722
|
+
description: "Include a short excerpt (~200 chars) from the linking line. Default true."
|
|
2723
|
+
},
|
|
2724
|
+
limit: {
|
|
2725
|
+
type: "number",
|
|
2726
|
+
description: "Max backlinks to return (1\u2013500). Default 50."
|
|
2727
|
+
}
|
|
2728
|
+
},
|
|
2729
|
+
required: ["path"]
|
|
2730
|
+
}
|
|
2731
|
+
},
|
|
2732
|
+
{
|
|
2733
|
+
name: "get_links",
|
|
2734
|
+
description: "Return all outgoing wikilinks and embeds from a note. Each link is marked resolved (with target path) or unresolved. Duplicate targets are de-duplicated; results sorted by line number.",
|
|
2735
|
+
inputSchema: {
|
|
2736
|
+
type: "object",
|
|
2737
|
+
properties: {
|
|
2738
|
+
path: { type: "string", description: "Vault-relative path to the note." }
|
|
2739
|
+
},
|
|
2740
|
+
required: ["path"]
|
|
2741
|
+
}
|
|
2742
|
+
},
|
|
2743
|
+
{
|
|
2744
|
+
name: "replace_in_note",
|
|
2745
|
+
description: "Find and replace text within a note body. Supports literal and regex search, case sensitivity, whole-word matching, and a replacement limit. Frontmatter is never touched. Use dryRun to preview matches before writing.",
|
|
2746
|
+
inputSchema: {
|
|
2747
|
+
type: "object",
|
|
2748
|
+
properties: {
|
|
2749
|
+
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2750
|
+
find: { type: "string", description: "Text or pattern to find." },
|
|
2751
|
+
replace: {
|
|
2752
|
+
type: "string",
|
|
2753
|
+
description: "Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."
|
|
2754
|
+
},
|
|
2755
|
+
regex: {
|
|
2756
|
+
type: "boolean",
|
|
2757
|
+
description: "Treat find as a regular expression. Default false."
|
|
2758
|
+
},
|
|
2759
|
+
caseSensitive: {
|
|
2760
|
+
type: "boolean",
|
|
2761
|
+
description: "Case-sensitive matching. Default false."
|
|
2762
|
+
},
|
|
2763
|
+
wholeWord: {
|
|
2764
|
+
type: "boolean",
|
|
2765
|
+
description: "Match whole words only (\\b boundary). Default false."
|
|
2766
|
+
},
|
|
2767
|
+
limit: {
|
|
2768
|
+
type: "number",
|
|
2769
|
+
description: "Maximum number of replacements. Omit to replace all."
|
|
2770
|
+
},
|
|
2771
|
+
dryRun: {
|
|
2772
|
+
type: "boolean",
|
|
2773
|
+
description: "If true, report matches without writing. Default false."
|
|
2774
|
+
},
|
|
2775
|
+
prevHash: {
|
|
2776
|
+
type: "string",
|
|
2777
|
+
description: "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
2778
|
+
}
|
|
2779
|
+
},
|
|
2780
|
+
required: ["path", "find", "replace"]
|
|
2781
|
+
}
|
|
2782
|
+
},
|
|
2783
|
+
{
|
|
2784
|
+
name: "get_periodic_note",
|
|
2785
|
+
description: "Get the path and existence status of a periodic note (daily, weekly, monthly, quarterly, or yearly) for a given date. Reads folder/format config from .obsidian/daily-notes.json (daily) or the periodic-notes plugin data.json. Optionally creates the note from the configured template if it is missing.",
|
|
2786
|
+
inputSchema: {
|
|
2787
|
+
type: "object",
|
|
2788
|
+
properties: {
|
|
2789
|
+
period: {
|
|
2790
|
+
type: "string",
|
|
2791
|
+
enum: ["daily", "weekly", "monthly", "quarterly", "yearly"],
|
|
2792
|
+
description: "Period type. Default: daily."
|
|
2793
|
+
},
|
|
2794
|
+
date: {
|
|
2795
|
+
type: "string",
|
|
2796
|
+
description: "ISO date string (YYYY-MM-DD). Defaults to today when omitted."
|
|
2797
|
+
},
|
|
2798
|
+
createIfMissing: {
|
|
2799
|
+
type: "boolean",
|
|
2800
|
+
description: "Create the note from the configured template if it does not exist. Default false."
|
|
2801
|
+
}
|
|
2802
|
+
},
|
|
2803
|
+
required: []
|
|
2804
|
+
}
|
|
2805
|
+
},
|
|
2806
|
+
{
|
|
2807
|
+
name: "append_periodic_note",
|
|
2808
|
+
description: "Append text to a periodic note (daily, weekly, monthly, quarterly, or yearly). Preserves existing frontmatter exactly. Creates the note first (from template if configured) when createIfMissing is true (default).",
|
|
2809
|
+
inputSchema: {
|
|
2810
|
+
type: "object",
|
|
2811
|
+
properties: {
|
|
2812
|
+
period: {
|
|
2813
|
+
type: "string",
|
|
2814
|
+
enum: ["daily", "weekly", "monthly", "quarterly", "yearly"],
|
|
2815
|
+
description: "Period type. Default: daily."
|
|
2816
|
+
},
|
|
2817
|
+
date: {
|
|
2818
|
+
type: "string",
|
|
2819
|
+
description: "ISO date string (YYYY-MM-DD). Defaults to today when omitted."
|
|
2820
|
+
},
|
|
2821
|
+
content: {
|
|
2822
|
+
type: "string",
|
|
2823
|
+
description: "Text to append to the note body."
|
|
2824
|
+
},
|
|
2825
|
+
createIfMissing: {
|
|
2826
|
+
type: "boolean",
|
|
2827
|
+
description: "Create the note if it does not exist before appending. Default true."
|
|
2828
|
+
},
|
|
2829
|
+
prevHash: {
|
|
2830
|
+
type: "string",
|
|
2831
|
+
description: "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
|
|
2832
|
+
}
|
|
2833
|
+
},
|
|
2834
|
+
required: ["content"]
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
];
|
|
2838
|
+
function visibleTools(policy2) {
|
|
2839
|
+
if (!policy2.readOnly) return ALL_TOOLS;
|
|
2840
|
+
return ALL_TOOLS.filter((t) => !WRITE_TOOLS.has(t.name));
|
|
2841
|
+
}
|
|
2842
|
+
|
|
1995
2843
|
// src/watcher.ts
|
|
1996
2844
|
import { realpathSync } from "fs";
|
|
1997
|
-
import { readFile as
|
|
2845
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
1998
2846
|
import { join as join15, relative as relative2, sep as sep2 } from "path";
|
|
1999
2847
|
import chokidar from "chokidar";
|
|
2000
|
-
function removeNoteBacklinks(ctx2, relPath) {
|
|
2001
|
-
const oldDoc = ctx2.notes.get(relPath);
|
|
2002
|
-
if (oldDoc === void 0) return;
|
|
2003
|
-
for (const link of extractLinksWithLines(oldDoc.raw)) {
|
|
2004
|
-
const resolved = resolveLink(link.target, ctx2.notes);
|
|
2005
|
-
if (resolved === void 0) continue;
|
|
2006
|
-
const arr = ctx2.backlinks.get(resolved);
|
|
2007
|
-
if (arr === void 0) continue;
|
|
2008
|
-
const filtered = arr.filter((r) => r.path !== relPath);
|
|
2009
|
-
ctx2.backlinks.set(resolved, filtered);
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
function addNoteBacklinks(ctx2, relPath, raw) {
|
|
2013
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2014
|
-
for (const link of extractLinksWithLines(raw)) {
|
|
2015
|
-
const resolved = resolveLink(link.target, ctx2.notes);
|
|
2016
|
-
if (resolved === void 0) continue;
|
|
2017
|
-
const dedupeKey = `${relPath}\0${resolved}`;
|
|
2018
|
-
if (seen.has(dedupeKey)) continue;
|
|
2019
|
-
seen.add(dedupeKey);
|
|
2020
|
-
let arr = ctx2.backlinks.get(resolved);
|
|
2021
|
-
if (arr === void 0) {
|
|
2022
|
-
arr = [];
|
|
2023
|
-
ctx2.backlinks.set(resolved, arr);
|
|
2024
|
-
}
|
|
2025
|
-
const ref = { path: relPath, line: link.line, linkType: link.linkType };
|
|
2026
|
-
arr.push(ref);
|
|
2027
|
-
arr.sort((a, b) => a.path.localeCompare(b.path));
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2030
2848
|
function startWatcher(ctx2, log2, opts) {
|
|
2031
2849
|
const usePolling = opts?.usePolling ?? process.env.SEEKSTONE_WATCH_POLL === "1";
|
|
2032
2850
|
let vaultRoot2 = ctx2.vaultRoot;
|
|
@@ -2037,7 +2855,7 @@ function startWatcher(ctx2, log2, opts) {
|
|
|
2037
2855
|
const toRel = (abs) => relative2(vaultRoot2, abs).split(sep2).join("/");
|
|
2038
2856
|
async function upsert(relPath, op) {
|
|
2039
2857
|
try {
|
|
2040
|
-
const raw = await
|
|
2858
|
+
const raw = await readFile11(join15(vaultRoot2, relPath), "utf8");
|
|
2041
2859
|
removeNoteBacklinks(ctx2, relPath);
|
|
2042
2860
|
upsertDoc(ctx2, buildDoc(relPath, raw));
|
|
2043
2861
|
addNoteBacklinks(ctx2, relPath, raw);
|
|
@@ -2089,7 +2907,7 @@ function startWatcher(ctx2, log2, opts) {
|
|
|
2089
2907
|
}
|
|
2090
2908
|
|
|
2091
2909
|
// src/index.ts
|
|
2092
|
-
var VERSION = true ? "0.
|
|
2910
|
+
var VERSION = true ? "0.11.0" : "0.0.0-dev";
|
|
2093
2911
|
var intent = parseCliIntent(process.argv.slice(2));
|
|
2094
2912
|
if (intent === "version") {
|
|
2095
2913
|
process.stdout.write(`${VERSION}
|
|
@@ -2101,6 +2919,11 @@ if (intent === "help") {
|
|
|
2101
2919
|
`);
|
|
2102
2920
|
process.exit(0);
|
|
2103
2921
|
}
|
|
2922
|
+
if (intent === "init-help") {
|
|
2923
|
+
process.stdout.write(`${initHelpText(VERSION)}
|
|
2924
|
+
`);
|
|
2925
|
+
process.exit(0);
|
|
2926
|
+
}
|
|
2104
2927
|
if (intent === "init") {
|
|
2105
2928
|
const result = await runInit(parseInitArgs(process.argv.slice(3)), {
|
|
2106
2929
|
env: process.env,
|
|
@@ -2119,400 +2942,18 @@ if (!vaultRoot) {
|
|
|
2119
2942
|
log.error("SEEKSTONE_VAULT env var is required");
|
|
2120
2943
|
process.exit(1);
|
|
2121
2944
|
}
|
|
2945
|
+
var policy = parseWritePolicy(process.env);
|
|
2946
|
+
if (policy.readOnly) log.info("read-only mode", { env: "SEEKSTONE_READ_ONLY" });
|
|
2947
|
+
if (policy.writeGlobs) log.info("write paths scoped", { globs: policy.writeGlobs });
|
|
2122
2948
|
log.info("building index", { vault: vaultRoot });
|
|
2123
2949
|
var { index, notes, backlinks, buildMs } = await buildIndex(vaultRoot);
|
|
2124
2950
|
log.info("index ready", { notes: notes.size, buildMs });
|
|
2125
|
-
var ctx = { vaultRoot, index, notes, backlinks };
|
|
2951
|
+
var ctx = { vaultRoot, index, notes, backlinks, policy };
|
|
2126
2952
|
var watcher = startWatcher(ctx, log);
|
|
2127
2953
|
process.on("exit", () => void watcher.stop());
|
|
2128
2954
|
var server = new Server({ name: "seekstone", version: VERSION }, { capabilities: { tools: {} } });
|
|
2129
2955
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
2130
|
-
tools:
|
|
2131
|
-
{
|
|
2132
|
-
name: "search",
|
|
2133
|
-
description: "Full-text search across the vault. Returns ranked excerpts (~200 chars) \u2014 not full notes \u2014 to minimise context usage. Supports fuzzy matching and prefix search.",
|
|
2134
|
-
inputSchema: {
|
|
2135
|
-
type: "object",
|
|
2136
|
-
properties: {
|
|
2137
|
-
query: { type: "string", description: "Search query." },
|
|
2138
|
-
limit: { type: "number", description: "Max results (1\u201350, default 10)." },
|
|
2139
|
-
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
2140
|
-
tag: { type: "string", description: "Restrict to notes with this tag." }
|
|
2141
|
-
},
|
|
2142
|
-
required: ["query"]
|
|
2143
|
-
}
|
|
2144
|
-
},
|
|
2145
|
-
{
|
|
2146
|
-
name: "query_notes",
|
|
2147
|
-
description: "Structured metadata query \u2014 filter notes by frontmatter key/value predicates, tag, folder, modified time, and size. Returns compact rows (path + title by default; opt into more via select), not note content. Use this instead of search when filtering by properties rather than text.",
|
|
2148
|
-
inputSchema: {
|
|
2149
|
-
type: "object",
|
|
2150
|
-
properties: {
|
|
2151
|
-
where: {
|
|
2152
|
-
type: "array",
|
|
2153
|
-
description: "Frontmatter predicates \u2014 all must match (AND).",
|
|
2154
|
-
items: {
|
|
2155
|
-
type: "object",
|
|
2156
|
-
properties: {
|
|
2157
|
-
key: { type: "string", description: "Frontmatter key to test." },
|
|
2158
|
-
op: {
|
|
2159
|
-
type: "string",
|
|
2160
|
-
enum: ["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"],
|
|
2161
|
-
description: "eq/ne compare scalars; contains matches array membership or substring; exists/missing test key presence; gt/gte/lt/lte compare numbers or strings (ISO dates sort correctly)."
|
|
2162
|
-
},
|
|
2163
|
-
value: {
|
|
2164
|
-
description: "Comparison value. Required for every op except exists/missing."
|
|
2165
|
-
}
|
|
2166
|
-
},
|
|
2167
|
-
required: ["key", "op"]
|
|
2168
|
-
}
|
|
2169
|
-
},
|
|
2170
|
-
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
2171
|
-
tag: { type: "string", description: "Restrict to notes with this tag (# optional)." },
|
|
2172
|
-
modifiedAfter: {
|
|
2173
|
-
type: "string",
|
|
2174
|
-
description: "Only notes modified at or after this ISO 8601 date/time."
|
|
2175
|
-
},
|
|
2176
|
-
modifiedBefore: {
|
|
2177
|
-
type: "string",
|
|
2178
|
-
description: "Only notes modified before this ISO 8601 date/time."
|
|
2179
|
-
},
|
|
2180
|
-
minSizeBytes: { type: "number", description: "Only notes at least this many bytes." },
|
|
2181
|
-
maxSizeBytes: { type: "number", description: "Only notes at most this many bytes." },
|
|
2182
|
-
select: {
|
|
2183
|
-
type: "array",
|
|
2184
|
-
items: { type: "string" },
|
|
2185
|
-
description: 'Extra fields per hit: frontmatter keys, or "mtime", "size", "tags". Default returns only path + title.'
|
|
2186
|
-
},
|
|
2187
|
-
sort: {
|
|
2188
|
-
type: "string",
|
|
2189
|
-
enum: ["path", "title", "mtime", "size"],
|
|
2190
|
-
description: "Sort field (default path)."
|
|
2191
|
-
},
|
|
2192
|
-
order: {
|
|
2193
|
-
type: "string",
|
|
2194
|
-
enum: ["asc", "desc"],
|
|
2195
|
-
description: "Sort order (default asc)."
|
|
2196
|
-
},
|
|
2197
|
-
limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
|
|
2198
|
-
},
|
|
2199
|
-
required: []
|
|
2200
|
-
}
|
|
2201
|
-
},
|
|
2202
|
-
{
|
|
2203
|
-
name: "read_note",
|
|
2204
|
-
description: "Read a note or a span of it \u2014 by heading section, block reference, or line range. Returns structured JSON with the content, bytes returned, and total note size so payload savings are measurable. Use search or outline_note first to find the right path and section names.",
|
|
2205
|
-
inputSchema: {
|
|
2206
|
-
type: "object",
|
|
2207
|
-
properties: {
|
|
2208
|
-
path: {
|
|
2209
|
-
type: "string",
|
|
2210
|
-
description: 'Vault-relative path, e.g. "Daily Notes/2026-05-29.md".'
|
|
2211
|
-
},
|
|
2212
|
-
section: {
|
|
2213
|
-
type: "string",
|
|
2214
|
-
description: "Return only this heading section (heading text, # prefix optional). First match wins."
|
|
2215
|
-
},
|
|
2216
|
-
block: {
|
|
2217
|
-
type: "string",
|
|
2218
|
-
description: "Return only the line anchored by this block id (^ prefix optional)."
|
|
2219
|
-
},
|
|
2220
|
-
lines: {
|
|
2221
|
-
type: "object",
|
|
2222
|
-
description: "Return only this line range (1-indexed, inclusive).",
|
|
2223
|
-
properties: {
|
|
2224
|
-
from: { type: "number", description: "First line (1-indexed)." },
|
|
2225
|
-
to: {
|
|
2226
|
-
type: "number",
|
|
2227
|
-
description: "Last line (1-indexed, inclusive). Defaults to EOF."
|
|
2228
|
-
}
|
|
2229
|
-
},
|
|
2230
|
-
required: ["from"]
|
|
2231
|
-
},
|
|
2232
|
-
includeFrontmatter: {
|
|
2233
|
-
type: "boolean",
|
|
2234
|
-
description: "Prepend frontmatter to section/block span results. Default false for spans."
|
|
2235
|
-
}
|
|
2236
|
-
},
|
|
2237
|
-
required: ["path"]
|
|
2238
|
-
}
|
|
2239
|
-
},
|
|
2240
|
-
{
|
|
2241
|
-
name: "list_notes",
|
|
2242
|
-
description: "List notes, optionally filtered by folder prefix or tag.",
|
|
2243
|
-
inputSchema: {
|
|
2244
|
-
type: "object",
|
|
2245
|
-
properties: {
|
|
2246
|
-
folder: { type: "string", description: "Vault-relative folder prefix." },
|
|
2247
|
-
tag: { type: "string", description: "Filter by tag (# prefix optional)." },
|
|
2248
|
-
limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
|
|
2249
|
-
},
|
|
2250
|
-
required: []
|
|
2251
|
-
}
|
|
2252
|
-
},
|
|
2253
|
-
{
|
|
2254
|
-
name: "list_tags",
|
|
2255
|
-
description: "List all tags in the vault with usage counts. Supports substring filtering, minimum count threshold, and sort order. Nested tags (e.g. area/work) include a parent field.",
|
|
2256
|
-
inputSchema: {
|
|
2257
|
-
type: "object",
|
|
2258
|
-
properties: {
|
|
2259
|
-
pattern: {
|
|
2260
|
-
type: "string",
|
|
2261
|
-
description: 'Substring filter on tag name. Example: "work" matches "work" and "area/work".'
|
|
2262
|
-
},
|
|
2263
|
-
minCount: {
|
|
2264
|
-
type: "number",
|
|
2265
|
-
description: "Only return tags used in at least this many notes."
|
|
2266
|
-
},
|
|
2267
|
-
sort: {
|
|
2268
|
-
type: "string",
|
|
2269
|
-
enum: ["count", "name"],
|
|
2270
|
-
description: "Sort by usage count descending (default) or alphabetically."
|
|
2271
|
-
}
|
|
2272
|
-
},
|
|
2273
|
-
required: []
|
|
2274
|
-
}
|
|
2275
|
-
},
|
|
2276
|
-
{
|
|
2277
|
-
name: "create_note",
|
|
2278
|
-
description: "Create a new note at a vault-relative path. Optionally sets frontmatter and body content. Parent directories are created automatically. Fails if the note already exists unless overwrite is true.",
|
|
2279
|
-
inputSchema: {
|
|
2280
|
-
type: "object",
|
|
2281
|
-
properties: {
|
|
2282
|
-
path: {
|
|
2283
|
-
type: "string",
|
|
2284
|
-
description: 'Vault-relative path, e.g. "Daily Notes/2026-06-01.md".'
|
|
2285
|
-
},
|
|
2286
|
-
content: { type: "string", description: "Body content for the note." },
|
|
2287
|
-
frontmatter: {
|
|
2288
|
-
type: "object",
|
|
2289
|
-
description: "Frontmatter key-value pairs.",
|
|
2290
|
-
additionalProperties: true
|
|
2291
|
-
},
|
|
2292
|
-
overwrite: {
|
|
2293
|
-
type: "boolean",
|
|
2294
|
-
description: "Overwrite an existing note. Defaults to false."
|
|
2295
|
-
}
|
|
2296
|
-
},
|
|
2297
|
-
required: ["path"]
|
|
2298
|
-
}
|
|
2299
|
-
},
|
|
2300
|
-
{
|
|
2301
|
-
name: "delete_note",
|
|
2302
|
-
description: "Permanently delete a note from the vault. This cannot be undone.",
|
|
2303
|
-
inputSchema: {
|
|
2304
|
-
type: "object",
|
|
2305
|
-
properties: {
|
|
2306
|
-
path: { type: "string", description: "Vault-relative path of the note to delete." }
|
|
2307
|
-
},
|
|
2308
|
-
required: ["path"]
|
|
2309
|
-
}
|
|
2310
|
-
},
|
|
2311
|
-
{
|
|
2312
|
-
name: "move_note",
|
|
2313
|
-
description: "Move or rename a note to a new vault-relative path. Parent directories at the destination are created automatically. Fails if the destination already exists unless overwrite is true.",
|
|
2314
|
-
inputSchema: {
|
|
2315
|
-
type: "object",
|
|
2316
|
-
properties: {
|
|
2317
|
-
from: { type: "string", description: "Vault-relative source path." },
|
|
2318
|
-
to: { type: "string", description: "Vault-relative destination path." },
|
|
2319
|
-
overwrite: {
|
|
2320
|
-
type: "boolean",
|
|
2321
|
-
description: "Overwrite destination if it exists. Defaults to false."
|
|
2322
|
-
}
|
|
2323
|
-
},
|
|
2324
|
-
required: ["from", "to"]
|
|
2325
|
-
}
|
|
2326
|
-
},
|
|
2327
|
-
{
|
|
2328
|
-
name: "append_note",
|
|
2329
|
-
description: "Append text to a note body without touching the frontmatter. Safe for meeting notes, daily logs, and append-only workflows.",
|
|
2330
|
-
inputSchema: {
|
|
2331
|
-
type: "object",
|
|
2332
|
-
properties: {
|
|
2333
|
-
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2334
|
-
content: { type: "string", description: "Text to append." }
|
|
2335
|
-
},
|
|
2336
|
-
required: ["path", "content"]
|
|
2337
|
-
}
|
|
2338
|
-
},
|
|
2339
|
-
{
|
|
2340
|
-
name: "patch_frontmatter",
|
|
2341
|
-
description: "Set, update, or delete frontmatter keys without reordering existing keys or changing quote style. Pass null as a value to delete a key.",
|
|
2342
|
-
inputSchema: {
|
|
2343
|
-
type: "object",
|
|
2344
|
-
properties: {
|
|
2345
|
-
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2346
|
-
patch: {
|
|
2347
|
-
type: "object",
|
|
2348
|
-
description: "Key-value pairs to set. Null value removes the key.",
|
|
2349
|
-
additionalProperties: true
|
|
2350
|
-
}
|
|
2351
|
-
},
|
|
2352
|
-
required: ["path", "patch"]
|
|
2353
|
-
}
|
|
2354
|
-
},
|
|
2355
|
-
{
|
|
2356
|
-
name: "outline_note",
|
|
2357
|
-
description: "Return a note's structure \u2014 heading tree with offsets, block-reference anchors, and frontmatter key list \u2014 without returning any prose. Use this before section reads or patches to discover what sections exist at a fraction of the cost of reading the full note.",
|
|
2358
|
-
inputSchema: {
|
|
2359
|
-
type: "object",
|
|
2360
|
-
properties: {
|
|
2361
|
-
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2362
|
-
includeBlocks: {
|
|
2363
|
-
type: "boolean",
|
|
2364
|
-
description: "Include block-reference anchors (^id). Default true."
|
|
2365
|
-
},
|
|
2366
|
-
includeSizes: {
|
|
2367
|
-
type: "boolean",
|
|
2368
|
-
description: "Include per-section character length in each heading entry. Default false."
|
|
2369
|
-
}
|
|
2370
|
-
},
|
|
2371
|
-
required: ["path"]
|
|
2372
|
-
}
|
|
2373
|
-
},
|
|
2374
|
-
{
|
|
2375
|
-
name: "patch_note",
|
|
2376
|
-
description: "Surgically edit a section of a note \u2014 targeted by heading or block reference \u2014 without rewriting the whole file. Operations: append (add after section), prepend (add after heading line), replace (swap section content). Frontmatter is never touched.",
|
|
2377
|
-
inputSchema: {
|
|
2378
|
-
type: "object",
|
|
2379
|
-
properties: {
|
|
2380
|
-
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2381
|
-
target: {
|
|
2382
|
-
type: "object",
|
|
2383
|
-
description: 'Exactly one of: { heading: "Section Title" } or { block: "block-id" } (without the ^ prefix).'
|
|
2384
|
-
},
|
|
2385
|
-
operation: {
|
|
2386
|
-
type: "string",
|
|
2387
|
-
enum: ["append", "prepend", "replace"],
|
|
2388
|
-
description: "append: insert after section content. prepend: insert after heading line. replace: swap section content."
|
|
2389
|
-
},
|
|
2390
|
-
content: { type: "string", description: "Content to insert or replace with." },
|
|
2391
|
-
createIfMissing: {
|
|
2392
|
-
type: "boolean",
|
|
2393
|
-
description: "If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets. Default false."
|
|
2394
|
-
}
|
|
2395
|
-
},
|
|
2396
|
-
required: ["path", "target", "operation", "content"]
|
|
2397
|
-
}
|
|
2398
|
-
},
|
|
2399
|
-
{
|
|
2400
|
-
name: "get_backlinks",
|
|
2401
|
-
description: "Return every note that links to the target note, with the source line and an optional excerpt. Results come from the pre-built reverse-link index so this is a fast, pure index lookup. Sort order: source path ascending.",
|
|
2402
|
-
inputSchema: {
|
|
2403
|
-
type: "object",
|
|
2404
|
-
properties: {
|
|
2405
|
-
path: { type: "string", description: "Vault-relative path to the target note." },
|
|
2406
|
-
includeContext: {
|
|
2407
|
-
type: "boolean",
|
|
2408
|
-
description: "Include a short excerpt (~200 chars) from the linking line. Default true."
|
|
2409
|
-
},
|
|
2410
|
-
limit: {
|
|
2411
|
-
type: "number",
|
|
2412
|
-
description: "Max backlinks to return (1\u2013500). Default 50."
|
|
2413
|
-
}
|
|
2414
|
-
},
|
|
2415
|
-
required: ["path"]
|
|
2416
|
-
}
|
|
2417
|
-
},
|
|
2418
|
-
{
|
|
2419
|
-
name: "get_links",
|
|
2420
|
-
description: "Return all outgoing wikilinks and embeds from a note. Each link is marked resolved (with target path) or unresolved. Duplicate targets are de-duplicated; results sorted by line number.",
|
|
2421
|
-
inputSchema: {
|
|
2422
|
-
type: "object",
|
|
2423
|
-
properties: {
|
|
2424
|
-
path: { type: "string", description: "Vault-relative path to the note." }
|
|
2425
|
-
},
|
|
2426
|
-
required: ["path"]
|
|
2427
|
-
}
|
|
2428
|
-
},
|
|
2429
|
-
{
|
|
2430
|
-
name: "replace_in_note",
|
|
2431
|
-
description: "Find and replace text within a note body. Supports literal and regex search, case sensitivity, whole-word matching, and a replacement limit. Frontmatter is never touched. Use dryRun to preview matches before writing.",
|
|
2432
|
-
inputSchema: {
|
|
2433
|
-
type: "object",
|
|
2434
|
-
properties: {
|
|
2435
|
-
path: { type: "string", description: "Vault-relative path to the note." },
|
|
2436
|
-
find: { type: "string", description: "Text or pattern to find." },
|
|
2437
|
-
replace: {
|
|
2438
|
-
type: "string",
|
|
2439
|
-
description: "Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."
|
|
2440
|
-
},
|
|
2441
|
-
regex: {
|
|
2442
|
-
type: "boolean",
|
|
2443
|
-
description: "Treat find as a regular expression. Default false."
|
|
2444
|
-
},
|
|
2445
|
-
caseSensitive: {
|
|
2446
|
-
type: "boolean",
|
|
2447
|
-
description: "Case-sensitive matching. Default false."
|
|
2448
|
-
},
|
|
2449
|
-
wholeWord: {
|
|
2450
|
-
type: "boolean",
|
|
2451
|
-
description: "Match whole words only (\\b boundary). Default false."
|
|
2452
|
-
},
|
|
2453
|
-
limit: {
|
|
2454
|
-
type: "number",
|
|
2455
|
-
description: "Maximum number of replacements. Omit to replace all."
|
|
2456
|
-
},
|
|
2457
|
-
dryRun: {
|
|
2458
|
-
type: "boolean",
|
|
2459
|
-
description: "If true, report matches without writing. Default false."
|
|
2460
|
-
}
|
|
2461
|
-
},
|
|
2462
|
-
required: ["path", "find", "replace"]
|
|
2463
|
-
}
|
|
2464
|
-
},
|
|
2465
|
-
{
|
|
2466
|
-
name: "get_periodic_note",
|
|
2467
|
-
description: "Get the path and existence status of a periodic note (daily, weekly, monthly, quarterly, or yearly) for a given date. Reads folder/format config from .obsidian/daily-notes.json (daily) or the periodic-notes plugin data.json. Optionally creates the note from the configured template if it is missing.",
|
|
2468
|
-
inputSchema: {
|
|
2469
|
-
type: "object",
|
|
2470
|
-
properties: {
|
|
2471
|
-
period: {
|
|
2472
|
-
type: "string",
|
|
2473
|
-
enum: ["daily", "weekly", "monthly", "quarterly", "yearly"],
|
|
2474
|
-
description: "Period type. Default: daily."
|
|
2475
|
-
},
|
|
2476
|
-
date: {
|
|
2477
|
-
type: "string",
|
|
2478
|
-
description: "ISO date string (YYYY-MM-DD). Defaults to today when omitted."
|
|
2479
|
-
},
|
|
2480
|
-
createIfMissing: {
|
|
2481
|
-
type: "boolean",
|
|
2482
|
-
description: "Create the note from the configured template if it does not exist. Default false."
|
|
2483
|
-
}
|
|
2484
|
-
},
|
|
2485
|
-
required: []
|
|
2486
|
-
}
|
|
2487
|
-
},
|
|
2488
|
-
{
|
|
2489
|
-
name: "append_periodic_note",
|
|
2490
|
-
description: "Append text to a periodic note (daily, weekly, monthly, quarterly, or yearly). Preserves existing frontmatter exactly. Creates the note first (from template if configured) when createIfMissing is true (default).",
|
|
2491
|
-
inputSchema: {
|
|
2492
|
-
type: "object",
|
|
2493
|
-
properties: {
|
|
2494
|
-
period: {
|
|
2495
|
-
type: "string",
|
|
2496
|
-
enum: ["daily", "weekly", "monthly", "quarterly", "yearly"],
|
|
2497
|
-
description: "Period type. Default: daily."
|
|
2498
|
-
},
|
|
2499
|
-
date: {
|
|
2500
|
-
type: "string",
|
|
2501
|
-
description: "ISO date string (YYYY-MM-DD). Defaults to today when omitted."
|
|
2502
|
-
},
|
|
2503
|
-
content: {
|
|
2504
|
-
type: "string",
|
|
2505
|
-
description: "Text to append to the note body."
|
|
2506
|
-
},
|
|
2507
|
-
createIfMissing: {
|
|
2508
|
-
type: "boolean",
|
|
2509
|
-
description: "Create the note if it does not exist before appending. Default true."
|
|
2510
|
-
}
|
|
2511
|
-
},
|
|
2512
|
-
required: ["content"]
|
|
2513
|
-
}
|
|
2514
|
-
}
|
|
2515
|
-
]
|
|
2956
|
+
tools: visibleTools(ctx.policy)
|
|
2516
2957
|
}));
|
|
2517
2958
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
2518
2959
|
const { name, arguments: args } = req.params;
|
|
@@ -2520,7 +2961,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
2520
2961
|
});
|
|
2521
2962
|
var transport = new StdioServerTransport();
|
|
2522
2963
|
await server.connect(transport);
|
|
2523
|
-
log.info("ready", { tools:
|
|
2964
|
+
log.info("ready", { tools: visibleTools(ctx.policy).length, transport: "stdio" });
|
|
2524
2965
|
process.stderr.write(
|
|
2525
2966
|
`seekstone: add to Claude Desktop:
|
|
2526
2967
|
${JSON.stringify(
|