seekstone 0.10.0 → 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
@@ -74,7 +74,7 @@ Docs: https://github.com/shaqmughal/seekstone`;
74
74
  }
75
75
 
76
76
  // src/tools/append_note.ts
77
- import { readFile, writeFile } from "fs/promises";
77
+ import { readFile } from "fs/promises";
78
78
  import { join } from "path";
79
79
 
80
80
  // ../core/src/frontmatter.ts
@@ -143,6 +143,34 @@ function parseFrontmatter(text) {
143
143
  // src/tools/append_note.ts
144
144
  import { z } from "zod";
145
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
+
146
174
  // src/policy.ts
147
175
  import picomatch from "picomatch";
148
176
  function parseWritePolicy(env) {
@@ -177,6 +205,9 @@ var AppendNoteInput = z.object({
177
205
  path: z.string().describe("Vault-relative path to the note."),
178
206
  content: z.string().min(1).describe(
179
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."
180
211
  )
181
212
  });
182
213
  async function appendNote(ctx2, input) {
@@ -186,6 +217,7 @@ async function appendNote(ctx2, input) {
186
217
  }
187
218
  assertWritable(ctx2.policy, input.path);
188
219
  const original = await readFile(absPath, "utf8");
220
+ if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
189
221
  const fm = parseFrontmatter(original);
190
222
  const body = fm.body.endsWith("\n") ? fm.body : `${fm.body}
191
223
  `;
@@ -193,17 +225,21 @@ async function appendNote(ctx2, input) {
193
225
  const newBody = `${body}${separator}${input.content}`;
194
226
  const header = original.slice(0, fm.bodyStart);
195
227
  const newContent = `${header}${newBody}`;
196
- await writeFile(absPath, newContent, "utf8");
228
+ await atomicWrite(absPath, newContent);
197
229
  const cached = ctx2.notes.get(input.path);
198
230
  if (cached) {
199
231
  cached.body = newBody;
200
232
  cached.raw = newContent;
201
233
  }
202
- 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
+ };
203
239
  }
204
240
 
205
241
  // src/tools/create_note.ts
206
- import { access, mkdir, writeFile as writeFile2 } from "fs/promises";
242
+ import { access, mkdir, readFile as readFile2 } from "fs/promises";
207
243
  import { dirname, join as join2 } from "path";
208
244
  import { stringify as stringifyYaml } from "yaml";
209
245
  import { z as z2 } from "zod";
@@ -282,9 +318,17 @@ var CreateNoteInput = z2.object({
282
318
  ),
283
319
  content: z2.string().default("").describe("Body content for the note."),
284
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."),
285
- 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)."
286
329
  });
287
- async function createNote(ctx2, input) {
330
+ async function createNote(ctx2, rawInput) {
331
+ const input = CreateNoteInput.parse(rawInput);
288
332
  const absPath = join2(ctx2.vaultRoot, input.path);
289
333
  if (!absPath.startsWith(ctx2.vaultRoot)) {
290
334
  throw new Error(`Path outside vault: ${input.path}`);
@@ -297,6 +341,22 @@ async function createNote(ctx2, input) {
297
341
  } catch (err) {
298
342
  if (err.code !== "ENOENT") throw err;
299
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);
300
360
  }
301
361
  let raw = "";
302
362
  if (input.frontmatter && Object.keys(input.frontmatter).length > 0) {
@@ -306,30 +366,112 @@ ${stringifyYaml(input.frontmatter)}---
306
366
  }
307
367
  raw += input.content ?? "";
308
368
  await mkdir(dirname(absPath), { recursive: true });
309
- await writeFile2(absPath, raw, "utf8");
369
+ await atomicWrite(absPath, raw);
310
370
  upsertDoc(ctx2, buildDoc(input.path, raw));
311
- 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
+ };
312
376
  }
313
377
 
314
378
  // src/tools/delete_note.ts
315
- import { rm } from "fs/promises";
316
- 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";
317
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
318
432
  var DeleteNoteInput = z3.object({
319
- 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
+ )
320
437
  });
321
- 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);
322
453
  const absPath = join3(ctx2.vaultRoot, input.path);
323
454
  if (!absPath.startsWith(ctx2.vaultRoot)) {
324
455
  throw new Error(`Path outside vault: ${input.path}`);
325
456
  }
326
457
  assertWritable(ctx2.policy, input.path);
327
- await rm(absPath);
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);
328
468
  if (ctx2.notes.has(input.path)) {
329
469
  ctx2.index.discard(input.path);
330
470
  ctx2.notes.delete(input.path);
331
471
  }
332
- return { path: input.path };
472
+ const result = { path: input.path, permanent: input.permanent };
473
+ if (trashedTo !== void 0) result.trashedTo = trashedTo;
474
+ return result;
333
475
  }
334
476
 
335
477
  // src/tools/get_backlinks.ts
@@ -372,24 +514,6 @@ function getBacklinks(ctx2, input) {
372
514
  // src/tools/get_links.ts
373
515
  import { join as join5 } from "path";
374
516
  import { z as z5 } from "zod";
375
-
376
- // src/index/resolve.ts
377
- function resolveLink(target, notes2) {
378
- if (notes2.has(target)) return target;
379
- const withMd = `${target}.md`;
380
- if (notes2.has(withMd)) return withMd;
381
- const targetLower = target.toLowerCase();
382
- const targetBase = (target.split("/").pop() ?? target).toLowerCase();
383
- for (const path2 of notes2.keys()) {
384
- const pathNoExt = path2.replace(/\.md$/i, "");
385
- if (pathNoExt.toLowerCase() === targetLower) return path2;
386
- const pathBase = (pathNoExt.split("/").pop() ?? pathNoExt).toLowerCase();
387
- if (pathBase === targetBase) return path2;
388
- }
389
- return void 0;
390
- }
391
-
392
- // src/tools/get_links.ts
393
517
  var GetLinksInput = z5.object({
394
518
  path: z5.string().describe("Vault-relative path to the note.")
395
519
  });
@@ -481,42 +605,10 @@ function listTags(ctx2, input) {
481
605
  }
482
606
 
483
607
  // src/tools/move_note.ts
484
- import { access as access2, mkdir as mkdir2, rename, writeFile as writeFile3 } from "fs/promises";
608
+ import { access as access3, mkdir as mkdir3, rename as rename3 } from "fs/promises";
485
609
  import { dirname as dirname2, join as join6 } from "path";
486
610
  import { z as z8 } from "zod";
487
611
 
488
- // src/index/backlinks.ts
489
- function removeNoteBacklinks(ctx2, relPath) {
490
- const oldDoc = ctx2.notes.get(relPath);
491
- if (oldDoc === void 0) return;
492
- for (const link of extractLinksWithLines(oldDoc.raw)) {
493
- const resolved = resolveLink(link.target, ctx2.notes);
494
- if (resolved === void 0) continue;
495
- const arr = ctx2.backlinks.get(resolved);
496
- if (arr === void 0) continue;
497
- const filtered = arr.filter((r) => r.path !== relPath);
498
- ctx2.backlinks.set(resolved, filtered);
499
- }
500
- }
501
- function addNoteBacklinks(ctx2, relPath, raw) {
502
- const seen = /* @__PURE__ */ new Set();
503
- for (const link of extractLinksWithLines(raw)) {
504
- const resolved = resolveLink(link.target, ctx2.notes);
505
- if (resolved === void 0) continue;
506
- const dedupeKey = `${relPath}\0${resolved}`;
507
- if (seen.has(dedupeKey)) continue;
508
- seen.add(dedupeKey);
509
- let arr = ctx2.backlinks.get(resolved);
510
- if (arr === void 0) {
511
- arr = [];
512
- ctx2.backlinks.set(resolved, arr);
513
- }
514
- const ref = { path: relPath, line: link.line, linkType: link.linkType };
515
- arr.push(ref);
516
- arr.sort((a, b) => a.path.localeCompare(b.path));
517
- }
518
- }
519
-
520
612
  // src/tools/rewrite_links.ts
521
613
  import { posix } from "path";
522
614
  var WIKI_RE = /(!?)\[\[([^\]|#\n]{1,512})(#[^\]|\n]{1,512})?(\|[^\]\n]{1,512})?\]\]/g;
@@ -625,13 +717,13 @@ async function moveNote(ctx2, rawInput) {
625
717
  assertWritable(ctx2.policy, input.from);
626
718
  assertWritable(ctx2.policy, input.to);
627
719
  try {
628
- await access2(absFrom);
720
+ await access3(absFrom);
629
721
  } catch {
630
722
  throw new Error(`Note not found: ${input.from}`);
631
723
  }
632
724
  if (!input.overwrite) {
633
725
  try {
634
- await access2(absTo);
726
+ await access3(absTo);
635
727
  throw new Error(
636
728
  `Destination already exists: ${input.to}. Pass overwrite: true to replace it.`
637
729
  );
@@ -649,8 +741,8 @@ async function moveNote(ctx2, rawInput) {
649
741
  }
650
742
  candidates.delete(input.from);
651
743
  }
652
- await mkdir2(dirname2(absTo), { recursive: true });
653
- await rename(absFrom, absTo);
744
+ await mkdir3(dirname2(absTo), { recursive: true });
745
+ await rename3(absFrom, absTo);
654
746
  const raw = ctx2.notes.get(input.from)?.raw ?? "";
655
747
  removeNoteBacklinks(ctx2, input.from);
656
748
  if (ctx2.notes.has(input.from)) {
@@ -679,7 +771,7 @@ async function moveNote(ctx2, rawInput) {
679
771
  );
680
772
  removeNoteBacklinks(ctx2, path2);
681
773
  if (count > 0) {
682
- await writeFile3(join6(ctx2.vaultRoot, path2), content, "utf8");
774
+ await atomicWrite(join6(ctx2.vaultRoot, path2), content);
683
775
  upsertDoc(ctx2, buildDoc(path2, content));
684
776
  notesRewritten++;
685
777
  linksRewritten += count;
@@ -695,7 +787,7 @@ async function moveNote(ctx2, rawInput) {
695
787
  }
696
788
 
697
789
  // src/tools/outline_note.ts
698
- import { readFile as readFile2 } from "fs/promises";
790
+ import { readFile as readFile3 } from "fs/promises";
699
791
  import { join as join7 } from "path";
700
792
 
701
793
  // ../core/src/outline.ts
@@ -757,7 +849,7 @@ async function outlineNote(ctx2, input) {
757
849
  if (!absPath.startsWith(ctx2.vaultRoot)) {
758
850
  throw new Error(`Path outside vault: ${input.path}`);
759
851
  }
760
- const raw = await readFile2(absPath, "utf8");
852
+ const raw = await readFile3(absPath, "utf8");
761
853
  return buildOutline(raw, {
762
854
  includeBlocks: input.includeBlocks,
763
855
  includeSizes: input.includeSizes
@@ -765,7 +857,7 @@ async function outlineNote(ctx2, input) {
765
857
  }
766
858
 
767
859
  // src/tools/patch_frontmatter.ts
768
- import { readFile as readFile3, writeFile as writeFile4 } from "fs/promises";
860
+ import { readFile as readFile4 } from "fs/promises";
769
861
  import { join as join8 } from "path";
770
862
  import { parseDocument } from "yaml";
771
863
  import { z as z10 } from "zod";
@@ -773,6 +865,9 @@ var PatchFrontmatterInput = z10.object({
773
865
  path: z10.string().describe("Vault-relative path to the note."),
774
866
  patch: z10.record(z10.string(), z10.unknown()).describe(
775
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."
776
871
  )
777
872
  });
778
873
  async function patchFrontmatter(ctx2, input) {
@@ -781,7 +876,8 @@ async function patchFrontmatter(ctx2, input) {
781
876
  throw new Error(`Path outside vault: ${input.path}`);
782
877
  }
783
878
  assertWritable(ctx2.policy, input.path);
784
- const original = await readFile3(absPath, "utf8");
879
+ const original = await readFile4(absPath, "utf8");
880
+ if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
785
881
  const fm = parseFrontmatter(original);
786
882
  const keysChanged = [];
787
883
  const keysAdded = [];
@@ -823,18 +919,24 @@ ${original}`;
823
919
  const tail = opensWithCRLF ? "---\r\n" : "---\n";
824
920
  newContent = `${head}${doc.toString()}${tail}${fm.body}`;
825
921
  }
826
- await writeFile4(absPath, newContent, "utf8");
922
+ await atomicWrite(absPath, newContent);
827
923
  const cached = ctx2.notes.get(input.path);
828
924
  if (cached) {
829
925
  const updated = parseFrontmatter(newContent);
830
926
  cached.raw = newContent;
831
927
  cached.fmKeys = updated.keys.join(" ");
832
928
  }
833
- return { path: input.path, keysChanged, keysAdded, keysRemoved };
929
+ return {
930
+ path: input.path,
931
+ keysChanged,
932
+ keysAdded,
933
+ keysRemoved,
934
+ contentHash: contentHash(newContent)
935
+ };
834
936
  }
835
937
 
836
938
  // src/tools/patch_note.ts
837
- import { readFile as readFile4, rename as rename2, writeFile as writeFile5 } from "fs/promises";
939
+ import { readFile as readFile5 } from "fs/promises";
838
940
  import { join as join9 } from "path";
839
941
  import { z as z11 } from "zod";
840
942
  var PatchNoteInput = z11.object({
@@ -849,6 +951,9 @@ var PatchNoteInput = z11.object({
849
951
  content: z11.string().min(1).describe("Content to insert or replace with."),
850
952
  createIfMissing: z11.boolean().default(false).describe(
851
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."
852
957
  )
853
958
  });
854
959
  function lineEnd(raw, offset) {
@@ -860,18 +965,14 @@ function lineStart(raw, offset) {
860
965
  const prev = raw.lastIndexOf("\n", offset - 1);
861
966
  return prev === -1 ? 0 : prev + 1;
862
967
  }
863
- async function atomicWrite(absPath, content) {
864
- const tmpPath = `${absPath}.seekstone-patch-tmp`;
865
- await writeFile5(tmpPath, content, "utf8");
866
- await rename2(tmpPath, absPath);
867
- }
868
968
  async function patchNote(ctx2, input) {
869
969
  const absPath = join9(ctx2.vaultRoot, input.path);
870
970
  if (!absPath.startsWith(ctx2.vaultRoot)) {
871
971
  throw new Error(`Path outside vault: ${input.path}`);
872
972
  }
873
973
  assertWritable(ctx2.policy, input.path);
874
- const raw = await readFile4(absPath, "utf8");
974
+ const raw = await readFile5(absPath, "utf8");
975
+ if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
875
976
  const fm = parseFrontmatter(raw);
876
977
  const originalFmRegion = raw.slice(0, fm.bodyStart);
877
978
  const outline = buildOutline(raw, { includeBlocks: true });
@@ -899,7 +1000,8 @@ ${input.content}${contentNl}`;
899
1000
  line: newContent2.slice(0, headingOffset).split("\n").length,
900
1001
  byteOffset: headingOffset
901
1002
  },
902
- frontmatterUnchanged: true
1003
+ frontmatterUnchanged: true,
1004
+ contentHash: contentHash(newContent2)
903
1005
  };
904
1006
  }
905
1007
  throw new Error(
@@ -964,11 +1066,12 @@ ${input.content}${contentNl}`;
964
1066
  path: input.path,
965
1067
  bytesWritten: Buffer.byteLength(newContent, "utf8"),
966
1068
  targetResolvedAt: resolvedAt,
967
- frontmatterUnchanged: true
1069
+ frontmatterUnchanged: true,
1070
+ contentHash: contentHash(newContent)
968
1071
  };
969
1072
  }
970
1073
  async function verifyFrontmatter(absPath, originalFmRegion) {
971
- const written = await readFile4(absPath, "utf8");
1074
+ const written = await readFile5(absPath, "utf8");
972
1075
  if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
973
1076
  throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
974
1077
  }
@@ -983,7 +1086,7 @@ function updateCache(ctx2, path2, newContent, bodyStart) {
983
1086
  }
984
1087
 
985
1088
  // src/tools/periodic_note.ts
986
- import { access as access3, mkdir as mkdir3, readFile as readFile5, writeFile as writeFile6 } from "fs/promises";
1089
+ import { access as access4, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
987
1090
  import { dirname as dirname3, join as join10 } from "path";
988
1091
  import { z as z12 } from "zod";
989
1092
  var PERIOD = z12.enum(["daily", "weekly", "monthly", "quarterly", "yearly"]);
@@ -996,7 +1099,7 @@ var DEFAULTS = {
996
1099
  };
997
1100
  async function readJson(absPath) {
998
1101
  try {
999
- return JSON.parse(await readFile5(absPath, "utf8"));
1102
+ return JSON.parse(await readFile6(absPath, "utf8"));
1000
1103
  } catch {
1001
1104
  return null;
1002
1105
  }
@@ -1069,7 +1172,7 @@ async function getPeriodicNote(ctx2, rawInput) {
1069
1172
  if (!abs.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${path2}`);
1070
1173
  let existed = false;
1071
1174
  try {
1072
- await access3(abs);
1175
+ await access4(abs);
1073
1176
  existed = true;
1074
1177
  } catch {
1075
1178
  }
@@ -1084,21 +1187,24 @@ async function getPeriodicNote(ctx2, rawInput) {
1084
1187
  );
1085
1188
  if (templateAbs.startsWith(ctx2.vaultRoot)) {
1086
1189
  try {
1087
- body = await readFile5(templateAbs, "utf8");
1190
+ body = await readFile6(templateAbs, "utf8");
1088
1191
  } catch {
1089
1192
  }
1090
1193
  }
1091
1194
  }
1092
- await mkdir3(dirname3(abs), { recursive: true });
1093
- await writeFile6(abs, body, "utf8");
1195
+ await mkdir4(dirname3(abs), { recursive: true });
1196
+ await atomicWrite(abs, body);
1094
1197
  upsertDoc(ctx2, buildDoc(path2, body));
1095
- return { path: path2, existed: false, created: true };
1198
+ return { path: path2, existed: false, created: true, contentHash: contentHash(body) };
1096
1199
  }
1097
1200
  var AppendPeriodicNoteInput = z12.object({
1098
1201
  period: PERIOD.default("daily").describe("Period type. Default: daily."),
1099
1202
  date: z12.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today."),
1100
1203
  content: z12.string().min(1).describe("Text to append to the note body. Separated from existing content by a blank line."),
1101
- 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
+ )
1102
1208
  });
1103
1209
  async function appendPeriodicNote(ctx2, rawInput) {
1104
1210
  const input = AppendPeriodicNoteInput.parse(rawInput);
@@ -1110,14 +1216,30 @@ async function appendPeriodicNote(ctx2, rawInput) {
1110
1216
  assertWritable(ctx2.policy, path2);
1111
1217
  let original = "";
1112
1218
  try {
1113
- original = await readFile5(abs, "utf8");
1219
+ original = await readFile6(abs, "utf8");
1220
+ if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, path2);
1114
1221
  } catch (err) {
1115
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
+ }
1116
1234
  if (!input.createIfMissing) throw new Error(`Periodic note not found: ${path2}`);
1117
- await mkdir3(dirname3(abs), { recursive: true });
1118
- await writeFile6(abs, input.content, "utf8");
1235
+ await mkdir4(dirname3(abs), { recursive: true });
1236
+ await atomicWrite(abs, input.content);
1119
1237
  upsertDoc(ctx2, buildDoc(path2, input.content));
1120
- 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
+ };
1121
1243
  }
1122
1244
  const fm = parseFrontmatter(original);
1123
1245
  const body = fm.body.endsWith("\n") ? fm.body : `${fm.body}
@@ -1126,13 +1248,17 @@ async function appendPeriodicNote(ctx2, rawInput) {
1126
1248
  const newBody = `${body}${separator}${input.content}`;
1127
1249
  const header = original.slice(0, fm.bodyStart);
1128
1250
  const newRaw = `${header}${newBody}`;
1129
- await writeFile6(abs, newRaw, "utf8");
1251
+ await atomicWrite(abs, newRaw);
1130
1252
  const cached = ctx2.notes.get(path2);
1131
1253
  if (cached) {
1132
1254
  cached.body = newBody;
1133
1255
  cached.raw = newRaw;
1134
1256
  }
1135
- return { path: path2, bytesWritten: Buffer.byteLength(newRaw, "utf8") };
1257
+ return {
1258
+ path: path2,
1259
+ bytesWritten: Buffer.byteLength(newRaw, "utf8"),
1260
+ contentHash: contentHash(newRaw)
1261
+ };
1136
1262
  }
1137
1263
 
1138
1264
  // src/tools/query_notes.ts
@@ -1341,7 +1467,7 @@ function queryNotes(ctx2, input) {
1341
1467
  }
1342
1468
 
1343
1469
  // src/tools/read_note.ts
1344
- import { readFile as readFile6 } from "fs/promises";
1470
+ import { readFile as readFile7 } from "fs/promises";
1345
1471
  import { join as join11 } from "path";
1346
1472
  import { z as z15 } from "zod";
1347
1473
  var ReadNoteInput = z15.object({
@@ -1363,10 +1489,17 @@ async function readNote(ctx2, input) {
1363
1489
  if (!absPath.startsWith(ctx2.vaultRoot)) {
1364
1490
  throw new Error(`Path outside vault: ${input.path}`);
1365
1491
  }
1366
- const raw = await readFile6(absPath, "utf8");
1492
+ const raw = await readFile7(absPath, "utf8");
1367
1493
  const noteBytes = Buffer.byteLength(raw, "utf8");
1494
+ const hash = contentHash(raw);
1368
1495
  if (input.section === void 0 && input.block === void 0 && input.lines === void 0) {
1369
- 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
+ };
1370
1503
  }
1371
1504
  const fm = parseFrontmatter(raw);
1372
1505
  const outline = buildOutline(raw, { includeBlocks: true });
@@ -1438,12 +1571,13 @@ async function readNote(ctx2, input) {
1438
1571
  content,
1439
1572
  bytesReturned: Buffer.byteLength(content, "utf8"),
1440
1573
  noteBytes,
1574
+ contentHash: hash,
1441
1575
  span: { charStart, charEnd }
1442
1576
  };
1443
1577
  }
1444
1578
 
1445
1579
  // src/tools/replace_in_note.ts
1446
- import { readFile as readFile7, rename as rename3, writeFile as writeFile7 } from "fs/promises";
1580
+ import { readFile as readFile8 } from "fs/promises";
1447
1581
  import { join as join12 } from "path";
1448
1582
  import { z as z16 } from "zod";
1449
1583
  var ReplaceInNoteInput = z16.object({
@@ -1454,7 +1588,10 @@ var ReplaceInNoteInput = z16.object({
1454
1588
  caseSensitive: z16.boolean().default(false).describe("Case-sensitive matching."),
1455
1589
  wholeWord: z16.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
1456
1590
  limit: z16.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
1457
- 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
+ )
1458
1595
  });
1459
1596
  function escapeRegex(s) {
1460
1597
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1465,11 +1602,6 @@ function buildRegex(find, opts) {
1465
1602
  const flags = `g${opts.caseSensitive ? "" : "i"}`;
1466
1603
  return new RegExp(pattern, flags);
1467
1604
  }
1468
- async function atomicWrite2(absPath, content) {
1469
- const tmpPath = `${absPath}.seekstone-replace-tmp`;
1470
- await writeFile7(tmpPath, content, "utf8");
1471
- await rename3(tmpPath, absPath);
1472
- }
1473
1605
  async function replaceInNote(ctx2, input) {
1474
1606
  const absPath = join12(ctx2.vaultRoot, input.path);
1475
1607
  if (!absPath.startsWith(ctx2.vaultRoot)) {
@@ -1486,7 +1618,8 @@ async function replaceInNote(ctx2, input) {
1486
1618
  } catch (err) {
1487
1619
  throw new Error(`Invalid regex: ${err instanceof Error ? err.message : String(err)}`);
1488
1620
  }
1489
- 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);
1490
1623
  const fm = parseFrontmatter(raw);
1491
1624
  const originalFmRegion = raw.slice(0, fm.bodyStart);
1492
1625
  const body = raw.slice(fm.bodyStart);
@@ -1516,8 +1649,8 @@ async function replaceInNote(ctx2, input) {
1516
1649
  }
1517
1650
  newBody += body.slice(lastIndex);
1518
1651
  const newContent = `${originalFmRegion}${newBody}`;
1519
- await atomicWrite2(absPath, newContent);
1520
- const written = await readFile7(absPath, "utf8");
1652
+ await atomicWrite(absPath, newContent);
1653
+ const written = await readFile8(absPath, "utf8");
1521
1654
  if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
1522
1655
  throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
1523
1656
  }
@@ -1532,7 +1665,8 @@ async function replaceInNote(ctx2, input) {
1532
1665
  replacements: toReplace.length,
1533
1666
  matches: matches2,
1534
1667
  bytesWritten: Buffer.byteLength(newContent, "utf8"),
1535
- frontmatterUnchanged: true
1668
+ frontmatterUnchanged: true,
1669
+ contentHash: contentHash(newContent)
1536
1670
  };
1537
1671
  }
1538
1672
 
@@ -1565,7 +1699,9 @@ var META_KEYS = [
1565
1699
  "minSizeBytes",
1566
1700
  "maxSizeBytes",
1567
1701
  "period",
1568
- "date"
1702
+ "date",
1703
+ "permanent",
1704
+ "prevHash"
1569
1705
  ];
1570
1706
  function safeMeta(args) {
1571
1707
  if (typeof args !== "object" || args === null) return {};
@@ -1653,13 +1789,19 @@ async function run(ctx2, name, args) {
1653
1789
  const input = CreateNoteInput.parse(args);
1654
1790
  const result = await createNote(ctx2, input);
1655
1791
  return {
1656
- 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
+ ]
1657
1798
  };
1658
1799
  }
1659
1800
  case "delete_note": {
1660
1801
  const input = DeleteNoteInput.parse(args);
1661
- await deleteNote(ctx2, input);
1662
- 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 }] };
1663
1805
  }
1664
1806
  case "move_note": {
1665
1807
  const input = MoveNoteInput.parse(args);
@@ -1675,7 +1817,10 @@ async function run(ctx2, name, args) {
1675
1817
  const result = await appendNote(ctx2, input);
1676
1818
  return {
1677
1819
  content: [
1678
- { 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
+ }
1679
1824
  ]
1680
1825
  };
1681
1826
  }
@@ -1721,7 +1866,7 @@ async function run(ctx2, name, args) {
1721
1866
  content: [
1722
1867
  {
1723
1868
  type: "text",
1724
- text: `Appended ${result.bytesWritten} bytes to ${result.path}.`
1869
+ text: `Appended ${result.bytesWritten} bytes to ${result.path}. contentHash: ${result.contentHash}`
1725
1870
  }
1726
1871
  ]
1727
1872
  };
@@ -1732,12 +1877,12 @@ async function run(ctx2, name, args) {
1732
1877
  }
1733
1878
 
1734
1879
  // src/index/build.ts
1735
- import { readFile as readFile8 } from "fs/promises";
1736
- 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";
1737
1882
 
1738
1883
  // ../core/src/walk.ts
1739
1884
  import { stat } from "fs/promises";
1740
- import { extname as extname2, join as join13, relative, sep } from "path";
1885
+ import { extname as extname3, join as join13, relative, sep } from "path";
1741
1886
  import fg from "fast-glob";
1742
1887
  var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]);
1743
1888
  var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
@@ -1752,7 +1897,7 @@ var IGNORE_GLOBS = [
1752
1897
  function classify(relPath) {
1753
1898
  if (relPath.endsWith(".excalidraw.md")) return "note";
1754
1899
  if (relPath.endsWith(".excalidraw")) return "excalidraw";
1755
- const ext = extname2(relPath).toLowerCase();
1900
+ const ext = extname3(relPath).toLowerCase();
1756
1901
  if (ext === ".md") return "note";
1757
1902
  if (ext === ".canvas") return "canvas";
1758
1903
  if (ext === ".pdf") return "pdf";
@@ -1784,7 +1929,7 @@ async function walkVault(vaultRoot2) {
1784
1929
  absPath,
1785
1930
  relPath,
1786
1931
  topDir,
1787
- ext: extname2(relPath).toLowerCase(),
1932
+ ext: extname3(relPath).toLowerCase(),
1788
1933
  kind: classify(relPath),
1789
1934
  sizeBytes: stats.size,
1790
1935
  mtimeMs: stats.mtimeMs
@@ -1814,12 +1959,12 @@ async function buildIndex(vaultRoot2) {
1814
1959
  await Promise.all(
1815
1960
  noteEntries.map(async (entry) => {
1816
1961
  try {
1817
- const raw = await readFile8(entry.absPath, "utf8");
1962
+ const raw = await readFile9(entry.absPath, "utf8");
1818
1963
  const fm = parseFrontmatter(raw);
1819
1964
  const inlineTags = extractInlineTags(fm.body);
1820
1965
  const fmTags = frontmatterTags(fm.data);
1821
1966
  const allTags = [.../* @__PURE__ */ new Set([...inlineTags, ...fmTags])];
1822
- 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));
1823
1968
  const doc = {
1824
1969
  id: entry.relPath,
1825
1970
  title,
@@ -1867,7 +2012,7 @@ function buildBacklinks(notes2) {
1867
2012
 
1868
2013
  // src/init.ts
1869
2014
  import { execFileSync } from "child_process";
1870
- import { access as access4, copyFile, mkdir as mkdir4, readdir, readFile as readFile9, stat as stat2, writeFile as writeFile8 } 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";
1871
2016
  import path, { dirname as dirname4, join as join14 } from "path";
1872
2017
  function parseInitArgs(argv) {
1873
2018
  const opts = { write: false, client: "desktop" };
@@ -1973,7 +2118,7 @@ async function validateVault(vaultPath) {
1973
2118
  return { ok: false, error: `Path does not exist: ${vaultPath}` };
1974
2119
  }
1975
2120
  try {
1976
- await access4(join14(vaultPath, ".obsidian"));
2121
+ await access5(join14(vaultPath, ".obsidian"));
1977
2122
  } catch {
1978
2123
  return {
1979
2124
  ok: false,
@@ -2011,7 +2156,7 @@ async function runInit(opts, deps) {
2011
2156
  ]
2012
2157
  };
2013
2158
  }
2014
- const rf = deps.readFile ?? readFile9;
2159
+ const rf = deps.readFile ?? readFile10;
2015
2160
  let vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
2016
2161
  if (!vaultPath) {
2017
2162
  const registryPath = obsidianRegistryPath(deps.platform, {
@@ -2142,12 +2287,12 @@ async function runInit(opts, deps) {
2142
2287
  }
2143
2288
  const merged = opts.client === "vscode" ? mergeSeekstoneConfigVsCode(existing, vaultPath) : mergeSeekstoneConfig(existing, vaultPath);
2144
2289
  let backupPath;
2145
- await mkdir4(dirname4(configPath), { recursive: true });
2290
+ await mkdir5(dirname4(configPath), { recursive: true });
2146
2291
  if (existingRaw !== null) {
2147
2292
  backupPath = `${configPath}.backup-${deps.timestamp}`;
2148
2293
  await copyFile(configPath, backupPath);
2149
2294
  }
2150
- await writeFile8(configPath, `${JSON.stringify(merged, null, 2)}
2295
+ await writeFile2(configPath, `${JSON.stringify(merged, null, 2)}
2151
2296
  `, "utf8");
2152
2297
  out.push(
2153
2298
  `\u2713 Patched ${configPath}`,
@@ -2218,7 +2363,7 @@ function createLogger(opts = {}) {
2218
2363
  );
2219
2364
  }
2220
2365
  }
2221
- function writeFile9(record) {
2366
+ function writeFile3(record) {
2222
2367
  if (!fileOk || filePath === void 0) return;
2223
2368
  const line = `${JSON.stringify(record)}
2224
2369
  `;
@@ -2240,7 +2385,7 @@ function createLogger(opts = {}) {
2240
2385
  const f = fields ?? {};
2241
2386
  stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
2242
2387
  `);
2243
- writeFile9({ t: ts, level: lvl, msg, ...f });
2388
+ writeFile3({ t: ts, level: lvl, msg, ...f });
2244
2389
  }
2245
2390
  return {
2246
2391
  level,
@@ -2346,7 +2491,7 @@ var ALL_TOOLS = [
2346
2491
  },
2347
2492
  {
2348
2493
  name: "read_note",
2349
- 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.",
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.",
2350
2495
  inputSchema: {
2351
2496
  type: "object",
2352
2497
  properties: {
@@ -2420,7 +2565,7 @@ var ALL_TOOLS = [
2420
2565
  },
2421
2566
  {
2422
2567
  name: "create_note",
2423
- 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.",
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).",
2424
2569
  inputSchema: {
2425
2570
  type: "object",
2426
2571
  properties: {
@@ -2437,6 +2582,10 @@ var ALL_TOOLS = [
2437
2582
  overwrite: {
2438
2583
  type: "boolean",
2439
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."
2440
2589
  }
2441
2590
  },
2442
2591
  required: ["path"]
@@ -2444,11 +2593,15 @@ var ALL_TOOLS = [
2444
2593
  },
2445
2594
  {
2446
2595
  name: "delete_note",
2447
- description: "Permanently delete a note from the vault. This cannot be undone.",
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.",
2448
2597
  inputSchema: {
2449
2598
  type: "object",
2450
2599
  properties: {
2451
- path: { type: "string", description: "Vault-relative path of the note to delete." }
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
+ }
2452
2605
  },
2453
2606
  required: ["path"]
2454
2607
  }
@@ -2480,7 +2633,11 @@ var ALL_TOOLS = [
2480
2633
  type: "object",
2481
2634
  properties: {
2482
2635
  path: { type: "string", description: "Vault-relative path to the note." },
2483
- content: { type: "string", description: "Text to append." }
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
+ }
2484
2641
  },
2485
2642
  required: ["path", "content"]
2486
2643
  }
@@ -2496,6 +2653,10 @@ var ALL_TOOLS = [
2496
2653
  type: "object",
2497
2654
  description: "Key-value pairs to set. Null value removes the key.",
2498
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."
2499
2660
  }
2500
2661
  },
2501
2662
  required: ["path", "patch"]
@@ -2540,6 +2701,10 @@ var ALL_TOOLS = [
2540
2701
  createIfMissing: {
2541
2702
  type: "boolean",
2542
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."
2543
2708
  }
2544
2709
  },
2545
2710
  required: ["path", "target", "operation", "content"]
@@ -2606,6 +2771,10 @@ var ALL_TOOLS = [
2606
2771
  dryRun: {
2607
2772
  type: "boolean",
2608
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."
2609
2778
  }
2610
2779
  },
2611
2780
  required: ["path", "find", "replace"]
@@ -2656,6 +2825,10 @@ var ALL_TOOLS = [
2656
2825
  createIfMissing: {
2657
2826
  type: "boolean",
2658
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."
2659
2832
  }
2660
2833
  },
2661
2834
  required: ["content"]
@@ -2669,7 +2842,7 @@ function visibleTools(policy2) {
2669
2842
 
2670
2843
  // src/watcher.ts
2671
2844
  import { realpathSync } from "fs";
2672
- import { readFile as readFile10 } from "fs/promises";
2845
+ import { readFile as readFile11 } from "fs/promises";
2673
2846
  import { join as join15, relative as relative2, sep as sep2 } from "path";
2674
2847
  import chokidar from "chokidar";
2675
2848
  function startWatcher(ctx2, log2, opts) {
@@ -2682,7 +2855,7 @@ function startWatcher(ctx2, log2, opts) {
2682
2855
  const toRel = (abs) => relative2(vaultRoot2, abs).split(sep2).join("/");
2683
2856
  async function upsert(relPath, op) {
2684
2857
  try {
2685
- const raw = await readFile10(join15(vaultRoot2, relPath), "utf8");
2858
+ const raw = await readFile11(join15(vaultRoot2, relPath), "utf8");
2686
2859
  removeNoteBacklinks(ctx2, relPath);
2687
2860
  upsertDoc(ctx2, buildDoc(relPath, raw));
2688
2861
  addNoteBacklinks(ctx2, relPath, raw);
@@ -2734,7 +2907,7 @@ function startWatcher(ctx2, log2, opts) {
2734
2907
  }
2735
2908
 
2736
2909
  // src/index.ts
2737
- var VERSION = true ? "0.10.0" : "0.0.0-dev";
2910
+ var VERSION = true ? "0.11.0" : "0.0.0-dev";
2738
2911
  var intent = parseCliIntent(process.argv.slice(2));
2739
2912
  if (intent === "version") {
2740
2913
  process.stdout.write(`${VERSION}