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 CHANGED
@@ -10,13 +10,21 @@ import {
10
10
 
11
11
  // src/cli-args.ts
12
12
  function parseCliIntent(argv) {
13
- if (argv[0] === "init") return "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
- --vault <path> Vault to use (auto-detected from Obsidian if omitted; fallback: $SEEKSTONE_VAULT)
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, writeFile } from "fs/promises";
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 writeFile(absPath, newContent, "utf8");
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 { path: input.path, bytesWritten: Buffer.byteLength(newContent, "utf8") };
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, writeFile as writeFile2 } from "fs/promises";
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("Overwrite an existing note. Defaults to false \u2014 throws if the note already exists.")
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, input) {
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 writeFile2(absPath, raw, "utf8");
369
+ await atomicWrite(absPath, raw);
252
370
  upsertDoc(ctx2, buildDoc(input.path, raw));
253
- return { path: input.path, bytesWritten: Buffer.byteLength(raw, "utf8") };
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 deleteNote(ctx2, input) {
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
- await rm(absPath);
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
- return { path: input.path };
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 access2, mkdir as mkdir2, rename } from "fs/promises";
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, input) {
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 access2(absFrom);
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 access2(absTo);
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
- await mkdir2(dirname2(absTo), { recursive: true });
454
- await rename(absFrom, absTo);
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
- return { from: input.from, to: input.to };
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 readFile2 } from "fs/promises";
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 readFile2(absPath, "utf8");
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 readFile3, writeFile as writeFile3 } from "fs/promises";
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
- const original = await readFile3(absPath, "utf8");
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 writeFile3(absPath, newContent, "utf8");
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 { path: input.path, keysChanged, keysAdded, keysRemoved };
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 readFile4, rename as rename2, writeFile as writeFile4 } from "fs/promises";
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
- const raw = await readFile4(absPath, "utf8");
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 readFile4(absPath, "utf8");
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 access3, mkdir as mkdir3, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
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 readFile5(absPath, "utf8"));
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 access3(abs);
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 readFile5(templateAbs, "utf8");
1190
+ body = await readFile6(templateAbs, "utf8");
852
1191
  } catch {
853
1192
  }
854
1193
  }
855
1194
  }
856
- await mkdir3(dirname3(abs), { recursive: true });
857
- await writeFile5(abs, body, "utf8");
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 readFile5(abs, "utf8");
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 mkdir3(dirname3(abs), { recursive: true });
881
- await writeFile5(abs, input.content, "utf8");
1235
+ await mkdir4(dirname3(abs), { recursive: true });
1236
+ await atomicWrite(abs, input.content);
882
1237
  upsertDoc(ctx2, buildDoc(path2, input.content));
883
- return { path: path2, bytesWritten: Buffer.byteLength(input.content, "utf8") };
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 writeFile5(abs, newRaw, "utf8");
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 { path: path2, bytesWritten: Buffer.byteLength(newRaw, "utf8") };
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 readFile6 } from "fs/promises";
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 readFile6(absPath, "utf8");
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 { path: input.path, content: raw, bytesReturned: noteBytes, noteBytes };
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 readFile7, rename as rename3, writeFile as writeFile6 } from "fs/promises";
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 readFile7(absPath, "utf8");
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 atomicWrite2(absPath, newContent);
1282
- const written = await readFile7(absPath, "utf8");
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: [{ type: "text", text: `Created ${result.path} (${result.bytesWritten} bytes).` }]
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
- return { content: [{ type: "text", text: `Deleted ${input.path}.` }] };
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
- return { content: [{ type: "text", text: `Moved ${result.from} \u2192 ${result.to}.` }] };
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
- { type: "text", text: `Appended ${result.bytesWritten} bytes to ${result.path}.` }
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 readFile8 } from "fs/promises";
1467
- import { basename as basename2, extname as extname3 } from "path";
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 extname2, join as join13, relative, sep } from "path";
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 = extname2(relPath).toLowerCase();
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: extname2(relPath).toLowerCase(),
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 readFile8(entry.absPath, "utf8");
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 : basename2(entry.relPath, extname3(entry.relPath));
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 access4, copyFile, mkdir as mkdir4, readdir, readFile as readFile9, stat as stat2, writeFile as writeFile7 } from "fs/promises";
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 access4(join14(vaultPath, ".obsidian"));
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
- const rf = deps.readFile ?? readFile9;
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 mkdir4(dirname4(configPath), { recursive: true });
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 writeFile7(configPath, `${JSON.stringify(merged, null, 2)}
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 writeFile8(record) {
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
- writeFile8({ t: ts, level: lvl, msg, ...f });
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 readFile10 } from "fs/promises";
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 readFile10(join15(vaultRoot2, relPath), "utf8");
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.9.1" : "0.0.0-dev";
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: 17, transport: "stdio" });
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(