seekstone 0.15.2 → 0.17.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
@@ -14,79 +14,147 @@ var __export = (target, all) => {
14
14
  __defProp(target, name, { get: all[name], enumerable: true });
15
15
  };
16
16
 
17
+ // src/semantic/model-manifest.ts
18
+ function hfFiles(repo, hashes) {
19
+ const base = `https://huggingface.co/minishlab/${repo}/resolve/main`;
20
+ return [
21
+ {
22
+ name: "model.safetensors",
23
+ url: `${base}/model.safetensors`,
24
+ sha256: hashes.safetensors[0],
25
+ bytes: hashes.safetensors[1]
26
+ },
27
+ {
28
+ name: "tokenizer.json",
29
+ url: `${base}/tokenizer.json`,
30
+ sha256: hashes.tokenizer[0],
31
+ bytes: hashes.tokenizer[1]
32
+ },
33
+ {
34
+ name: "config.json",
35
+ url: `${base}/config.json`,
36
+ sha256: hashes.config[0],
37
+ bytes: hashes.config[1]
38
+ }
39
+ ];
40
+ }
41
+ function findModel(id) {
42
+ return MODELS.find((m) => m.id === id);
43
+ }
44
+ var DEFAULT_MODEL, RETRIEVAL_32M_MODEL, MODELS, MODEL_IDS;
45
+ var init_model_manifest = __esm({
46
+ "src/semantic/model-manifest.ts"() {
47
+ "use strict";
48
+ DEFAULT_MODEL = {
49
+ id: "potion-base-8M",
50
+ license: "MIT (https://huggingface.co/minishlab/potion-base-8M)",
51
+ dim: 256,
52
+ files: hfFiles("potion-base-8M", {
53
+ safetensors: ["f65d0f325faadc1e121c319e2faa41170d3fa07d8c89abd48ca5358d9a223de2", 30236760],
54
+ tokenizer: ["e67e803f624fb4d67dea1c730d06e1067e1b14d830e2c2202569e3ef0f70bb50", 683666],
55
+ config: ["2a6ac0e9aaa356a68a5688070db78fc3a464fefe85d2f06a1905ce3718687553", 202]
56
+ })
57
+ };
58
+ RETRIEVAL_32M_MODEL = {
59
+ id: "potion-retrieval-32M",
60
+ license: "MIT (https://huggingface.co/minishlab/potion-retrieval-32M)",
61
+ dim: 512,
62
+ files: hfFiles("potion-retrieval-32M", {
63
+ safetensors: ["07609e5bd33aad37900b3fd62f4ec96f6daec88ca4d46b9d8b928bfababf6ea0", 129210456],
64
+ tokenizer: ["7d75cbc54318138807c401b0f0c9721117c628b39de8e8e0edb6cb17e0ee7d18", 1493150],
65
+ config: ["63c00d90824c832c04ec1d02b6a983fb90489bf049f29fbff15ba481b8a432ee", 202]
66
+ })
67
+ };
68
+ MODELS = [DEFAULT_MODEL, RETRIEVAL_32M_MODEL];
69
+ MODEL_IDS = MODELS.map((m) => m.id);
70
+ }
71
+ });
72
+
17
73
  // src/semantic/config.ts
18
- import { join as join7 } from "path";
74
+ import { join as join8 } from "path";
19
75
  function defaultCacheDir(env, homedir2) {
20
- return env.SEEKSTONE_CACHE_DIR || join7(homedir2, ".cache", "seekstone");
76
+ return env.SEEKSTONE_CACHE_DIR || join8(homedir2, ".cache", "seekstone");
77
+ }
78
+ function resolveModel(raw) {
79
+ const id = raw?.trim();
80
+ if (!id) return DEFAULT_MODEL;
81
+ const manifest = findModel(id);
82
+ if (!manifest) {
83
+ throw new Error(
84
+ `SEEKSTONE_SEMANTIC_MODEL: unknown model "${id}" \u2014 expected one of ${MODEL_IDS.join(", ")}`
85
+ );
86
+ }
87
+ return manifest;
88
+ }
89
+ function resolveModelId(raw) {
90
+ return resolveModel(raw).id;
91
+ }
92
+ function fetchCommandFor(modelId) {
93
+ return modelId === DEFAULT_MODEL_ID ? "npx -y seekstone fetch-model" : `npx -y seekstone fetch-model --model ${modelId}`;
21
94
  }
22
95
  function resolveSemanticConfig(env, homedir2) {
23
96
  if (env.SEEKSTONE_SEMANTIC !== "1") return void 0;
97
+ const modelId = resolveModelId(env.SEEKSTONE_SEMANTIC_MODEL);
24
98
  const cacheDir = defaultCacheDir(env, homedir2);
25
- const modelDir = env.SEEKSTONE_MODEL_PATH || join7(cacheDir, "models", DEFAULT_MODEL_ID);
26
- return { modelDir, cacheDir };
99
+ const modelDir = env.SEEKSTONE_MODEL_PATH || join8(cacheDir, "models", modelId);
100
+ return { modelId, modelDir, cacheDir };
27
101
  }
28
102
  var DEFAULT_MODEL_ID;
29
103
  var init_config = __esm({
30
104
  "src/semantic/config.ts"() {
31
105
  "use strict";
32
- DEFAULT_MODEL_ID = "potion-base-8M";
33
- }
34
- });
35
-
36
- // src/semantic/model-manifest.ts
37
- var DEFAULT_MODEL;
38
- var init_model_manifest = __esm({
39
- "src/semantic/model-manifest.ts"() {
40
- "use strict";
41
- DEFAULT_MODEL = {
42
- id: "potion-base-8M",
43
- license: "MIT (https://huggingface.co/minishlab/potion-base-8M)",
44
- files: [
45
- {
46
- name: "model.safetensors",
47
- url: "https://huggingface.co/minishlab/potion-base-8M/resolve/main/model.safetensors",
48
- sha256: "f65d0f325faadc1e121c319e2faa41170d3fa07d8c89abd48ca5358d9a223de2",
49
- bytes: 30236760
50
- },
51
- {
52
- name: "tokenizer.json",
53
- url: "https://huggingface.co/minishlab/potion-base-8M/resolve/main/tokenizer.json",
54
- sha256: "e67e803f624fb4d67dea1c730d06e1067e1b14d830e2c2202569e3ef0f70bb50",
55
- bytes: 683666
56
- },
57
- {
58
- name: "config.json",
59
- url: "https://huggingface.co/minishlab/potion-base-8M/resolve/main/config.json",
60
- sha256: "2a6ac0e9aaa356a68a5688070db78fc3a464fefe85d2f06a1905ce3718687553",
61
- bytes: 202
62
- }
63
- ]
64
- };
106
+ init_model_manifest();
107
+ DEFAULT_MODEL_ID = DEFAULT_MODEL.id;
65
108
  }
66
109
  });
67
110
 
68
111
  // src/semantic/fetch-model.ts
69
112
  var fetch_model_exports = {};
70
113
  __export(fetch_model_exports, {
71
- runFetchModel: () => runFetchModel
114
+ runFetchModel: () => runFetchModel,
115
+ selectManifest: () => selectManifest
72
116
  });
73
117
  import { createHash as createHash3 } from "crypto";
74
118
  import { existsSync } from "fs";
75
- import { mkdir as mkdir7, readFile as readFile17, writeFile as writeFile4 } from "fs/promises";
76
- import { join as join11 } from "path";
119
+ import { mkdir as mkdir9, readFile as readFile19, writeFile as writeFile4 } from "fs/promises";
120
+ import { join as join12 } from "path";
121
+ function selectManifest(argv, env) {
122
+ let requested;
123
+ for (let i = 0; i < argv.length; i++) {
124
+ const arg = argv[i];
125
+ if (arg === "--model") {
126
+ requested = argv[i + 1];
127
+ if (!requested || requested.startsWith("--")) {
128
+ throw new Error(`--model needs a value: one of ${MODEL_IDS.join(", ")}`);
129
+ }
130
+ i++;
131
+ } else if (arg?.startsWith("--model=")) {
132
+ requested = arg.slice("--model=".length);
133
+ } else {
134
+ throw new Error(`fetch-model: unknown argument "${arg}" (usage: fetch-model [--model <id>])`);
135
+ }
136
+ }
137
+ return resolveModel(requested ?? env.SEEKSTONE_SEMANTIC_MODEL);
138
+ }
77
139
  async function runFetchModel(deps) {
78
- const manifest = deps.manifest ?? DEFAULT_MODEL;
79
- const fetchFn = deps.fetchFn ?? fetch;
80
- const destDir = deps.env.SEEKSTONE_MODEL_PATH || join11(defaultCacheDir(deps.env, deps.homedir), "models", manifest.id);
81
140
  const output = [];
141
+ let manifest;
142
+ try {
143
+ manifest = deps.manifest ?? selectManifest(deps.argv ?? [], deps.env);
144
+ } catch (err) {
145
+ output.push(`\u2717 ${err instanceof Error ? err.message : String(err)}`);
146
+ return { output, exitCode: 1 };
147
+ }
148
+ const fetchFn = deps.fetchFn ?? fetch;
149
+ const destDir = deps.env.SEEKSTONE_MODEL_PATH || join12(defaultCacheDir(deps.env, deps.homedir), "models", manifest.id);
82
150
  output.push(`fetch-model: ${manifest.id} \u2192 ${destDir}`);
83
151
  try {
84
- await mkdir7(destDir, { recursive: true });
152
+ await mkdir9(destDir, { recursive: true });
85
153
  let fetched = 0;
86
154
  let skipped = 0;
87
155
  for (const file of manifest.files) {
88
- const dest = join11(destDir, file.name);
89
- if (existsSync(dest) && sha256(await readFile17(dest)) === file.sha256) {
156
+ const dest = join12(destDir, file.name);
157
+ if (existsSync(dest) && sha256(await readFile19(dest)) === file.sha256) {
90
158
  skipped++;
91
159
  continue;
92
160
  }
@@ -103,7 +171,7 @@ async function runFetchModel(deps) {
103
171
  }
104
172
  output.push(`\u2713 ${manifest.id}: ${fetched} fetched, ${skipped} already present (verified).`);
105
173
  output.push(
106
- "Enable semantic search with SEEKSTONE_SEMANTIC=1 in your MCP server config, then restart the session."
174
+ manifest.id === DEFAULT_MODEL_ID ? "Enable semantic search with SEEKSTONE_SEMANTIC=1 in your MCP server config, then restart the session." : `Enable it with SEEKSTONE_SEMANTIC=1 and SEEKSTONE_SEMANTIC_MODEL=${manifest.id} in your MCP server config, then restart the session.`
107
175
  );
108
176
  return { output, exitCode: 0 };
109
177
  } catch (err) {
@@ -132,6 +200,198 @@ import {
132
200
  ListToolsRequestSchema
133
201
  } from "@modelcontextprotocol/sdk/types.js";
134
202
 
203
+ // src/audit.ts
204
+ import {
205
+ appendFileSync as appendFileSync2,
206
+ closeSync,
207
+ fsyncSync,
208
+ openSync,
209
+ renameSync as renameSync2,
210
+ statSync as statSync2,
211
+ writeSync
212
+ } from "fs";
213
+
214
+ // src/log.ts
215
+ import { appendFileSync, renameSync, statSync } from "fs";
216
+ var ORDER = ["error", "warn", "info", "debug"];
217
+ var DEFAULT_MAX_SIZE = 5 * 1024 * 1024;
218
+ function parseLevel(raw, onBad) {
219
+ if (raw === void 0 || raw === "") return "info";
220
+ const v = raw.trim().toLowerCase();
221
+ for (const lvl of ORDER) {
222
+ if (lvl === v) return lvl;
223
+ }
224
+ onBad(`invalid SEEKSTONE_LOG_LEVEL=${JSON.stringify(raw)}; using "info"`);
225
+ return "info";
226
+ }
227
+ function parseSize(raw, fallback = DEFAULT_MAX_SIZE) {
228
+ if (raw === void 0) return fallback;
229
+ const m = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(raw.trim());
230
+ if (m === null) return fallback;
231
+ const num = Number(m[1]);
232
+ const unit = (m[2] ?? "b").toLowerCase();
233
+ const mult = unit === "gb" ? 1e9 : unit === "mb" ? 1e6 : unit === "kb" ? 1e3 : 1;
234
+ return Math.max(1, Math.floor(num * mult));
235
+ }
236
+ function fmtFields(fields) {
237
+ const parts = [];
238
+ for (const [k, v] of Object.entries(fields)) {
239
+ if (v === void 0) continue;
240
+ parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
241
+ }
242
+ return parts.length > 0 ? ` ${parts.join(" ")}` : "";
243
+ }
244
+ function createLogger(opts = {}) {
245
+ const env = opts.env ?? process.env;
246
+ const stderr = opts.stderr ?? ((line) => void process.stderr.write(line));
247
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
248
+ const level = parseLevel(env.SEEKSTONE_LOG_LEVEL, (m) => {
249
+ stderr(`WARN ${now().toISOString()} ${m}
250
+ `);
251
+ });
252
+ const threshold = ORDER.indexOf(level);
253
+ const filePath = env.SEEKSTONE_LOG_FILE;
254
+ const maxSize = parseSize(env.SEEKSTONE_LOG_MAX_SIZE);
255
+ let fileOk = false;
256
+ let bytes = 0;
257
+ if (filePath !== void 0 && filePath !== "") {
258
+ try {
259
+ try {
260
+ bytes = statSync(filePath).size;
261
+ } catch {
262
+ bytes = 0;
263
+ }
264
+ appendFileSync(filePath, "");
265
+ fileOk = true;
266
+ } catch (err) {
267
+ const m = err instanceof Error ? err.message : String(err);
268
+ stderr(
269
+ `WARN ${now().toISOString()} cannot write log file ${filePath}: ${m}; file logging disabled
270
+ `
271
+ );
272
+ }
273
+ }
274
+ function writeFile5(record) {
275
+ if (!fileOk || filePath === void 0) return;
276
+ const line = `${JSON.stringify(record)}
277
+ `;
278
+ const len = Buffer.byteLength(line);
279
+ try {
280
+ if (bytes > 0 && bytes + len > maxSize) {
281
+ renameSync(filePath, `${filePath}.1`);
282
+ bytes = 0;
283
+ }
284
+ appendFileSync(filePath, line);
285
+ bytes += len;
286
+ } catch {
287
+ fileOk = false;
288
+ }
289
+ }
290
+ function emit(lvl, msg, fields) {
291
+ if (ORDER.indexOf(lvl) > threshold) return;
292
+ const ts = now().toISOString();
293
+ const f = fields ?? {};
294
+ stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
295
+ `);
296
+ writeFile5({ t: ts, level: lvl, msg, ...f });
297
+ }
298
+ return {
299
+ level,
300
+ error: (m, f) => {
301
+ emit("error", m, f);
302
+ },
303
+ warn: (m, f) => {
304
+ emit("warn", m, f);
305
+ },
306
+ info: (m, f) => {
307
+ emit("info", m, f);
308
+ },
309
+ debug: (m, f) => {
310
+ emit("debug", m, f);
311
+ }
312
+ };
313
+ }
314
+
315
+ // src/audit.ts
316
+ var DEFAULT_MAX_SIZE2 = 10 * 1e6;
317
+ function resolveAuditConfig(env) {
318
+ const path2 = (env.SEEKSTONE_AUDIT_FILE ?? "").trim();
319
+ if (path2 === "") return void 0;
320
+ return { path: path2, maxBytes: parseSize(env.SEEKSTONE_AUDIT_MAX_SIZE, DEFAULT_MAX_SIZE2) };
321
+ }
322
+ var AuditLog = class _AuditLog {
323
+ path;
324
+ maxBytes;
325
+ bytes;
326
+ constructor(path2, maxBytes, bytes) {
327
+ this.path = path2;
328
+ this.maxBytes = maxBytes;
329
+ this.bytes = bytes;
330
+ }
331
+ /**
332
+ * Open (create if missing) the audit file, verifying it is writable now so
333
+ * an unwritable path fails at boot rather than on the first write.
334
+ */
335
+ static open(config) {
336
+ let bytes = 0;
337
+ try {
338
+ bytes = statSync2(config.path).size;
339
+ } catch {
340
+ bytes = 0;
341
+ }
342
+ appendFileSync2(config.path, "");
343
+ return new _AuditLog(config.path, config.maxBytes, bytes);
344
+ }
345
+ /** Append one record durably. Throws if the append or fsync fails. */
346
+ record(rec) {
347
+ const line = `${JSON.stringify(rec)}
348
+ `;
349
+ const len = Buffer.byteLength(line);
350
+ if (this.bytes > 0 && this.bytes + len > this.maxBytes) {
351
+ renameSync2(this.path, `${this.path}.1`);
352
+ this.bytes = 0;
353
+ }
354
+ const fd = openSync(this.path, "a");
355
+ try {
356
+ writeSync(fd, line);
357
+ fsyncSync(fd);
358
+ } finally {
359
+ closeSync(fd);
360
+ }
361
+ this.bytes += len;
362
+ }
363
+ };
364
+ function classifyError(message) {
365
+ if (message.startsWith("{")) {
366
+ try {
367
+ const parsed = JSON.parse(message);
368
+ if (typeof parsed.error === "string") {
369
+ if (parsed.error === "hash_conflict")
370
+ return { outcome: "hash_conflict", error: parsed.error };
371
+ if (parsed.error === "undo_conflict")
372
+ return { outcome: "undo_conflict", error: parsed.error };
373
+ return { outcome: "error", error: parsed.error };
374
+ }
375
+ } catch {
376
+ }
377
+ }
378
+ if (message.startsWith("Server is read-only")) {
379
+ return { outcome: "policy_denied", policy: "read_only", error: "read_only" };
380
+ }
381
+ if (message.startsWith("Write not permitted")) {
382
+ return { outcome: "policy_denied", policy: "write_paths", error: "write_paths" };
383
+ }
384
+ if (/ENOENT|not found/i.test(message)) return { outcome: "error", error: "not_found" };
385
+ if (/already exists/i.test(message)) return { outcome: "error", error: "exists" };
386
+ if (/^Invalid regex/.test(message)) return { outcome: "error", error: "invalid_regex" };
387
+ if (/^Write-safety violation/.test(message))
388
+ return { outcome: "error", error: "write_safety_violation" };
389
+ if (/^\[\s*\{/.test(message) || /invalid_type|Required|Expected|expected/.test(message)) {
390
+ return { outcome: "error", error: "invalid_input" };
391
+ }
392
+ return { outcome: "error", error: "error" };
393
+ }
394
+
135
395
  // src/cli-args.ts
136
396
  function parseCliIntent(argv) {
137
397
  if (argv[0] === "init") {
@@ -160,6 +420,7 @@ Usage:
160
420
  seekstone Start the MCP server (reads from stdin/stdout)
161
421
  seekstone init Validate a vault and print/patch the client MCP config
162
422
  seekstone fetch-model Download the local embedding model for semantic search
423
+ (--model potion-base-8M | potion-retrieval-32M)
163
424
  seekstone --version Print the version and exit
164
425
  seekstone --help Print this help and exit
165
426
 
@@ -173,7 +434,9 @@ Optional environment:
173
434
  SEEKSTONE_LOG_LEVEL error | warn | info (default) | debug
174
435
  SEEKSTONE_LOG_FILE Absolute path; append JSON-line logs here
175
436
  SEEKSTONE_WATCH_POLL Set to 1 to poll for changes (network drives, WSL)
437
+ SEEKSTONE_WATCH_POLL_INTERVAL Poll interval in ms (default 10000)
176
438
  SEEKSTONE_SEMANTIC Set to 1 to enable semantic search (needs fetch-model first)
439
+ SEEKSTONE_SEMANTIC_MODEL potion-base-8M (default) | potion-retrieval-32M (bigger, more accurate)
177
440
  SEEKSTONE_MODEL_PATH Override the embedding-model directory
178
441
  SEEKSTONE_CACHE_DIR Override the cache root (default ~/.cache/seekstone)
179
442
 
@@ -203,7 +466,7 @@ Docs: https://github.com/shaqmughal/seekstone`;
203
466
  }
204
467
 
205
468
  // src/tools/append_note.ts
206
- import { readFile } from "fs/promises";
469
+ import { readFile as readFile2 } from "fs/promises";
207
470
 
208
471
  // ../core/src/frontmatter.ts
209
472
  import { parse as parseYaml } from "yaml";
@@ -299,6 +562,271 @@ function assertHashMatch(raw, prevHash, path2) {
299
562
  }
300
563
  }
301
564
 
565
+ // src/journal.ts
566
+ import { timingSafeEqual } from "crypto";
567
+ import { mkdir, open, readdir, readFile, rename as rename2, rm, stat } from "fs/promises";
568
+ import { join } from "path";
569
+ var DEFAULT_MAX_BYTES = 50 * 1e6;
570
+ var DEFAULT_MAX_ENTRIES = 1e3;
571
+ var HISTORY_DIR = ".seekstone/history";
572
+ function resolveJournalConfig(env) {
573
+ const flag = (env.SEEKSTONE_HISTORY ?? "").trim().toLowerCase();
574
+ if (flag === "0" || flag === "false" || flag === "off") return void 0;
575
+ const maxEntriesRaw = Number.parseInt(env.SEEKSTONE_HISTORY_MAX_ENTRIES ?? "", 10);
576
+ return {
577
+ maxBytes: parseSize(env.SEEKSTONE_HISTORY_MAX_SIZE, DEFAULT_MAX_BYTES),
578
+ maxEntries: Number.isFinite(maxEntriesRaw) && maxEntriesRaw > 0 ? maxEntriesRaw : DEFAULT_MAX_ENTRIES
579
+ };
580
+ }
581
+ async function writeDurable(dest, content) {
582
+ const tmp = `${dest}.seekstone-tmp`;
583
+ const fh = await open(tmp, "w");
584
+ try {
585
+ await fh.writeFile(content, "utf8");
586
+ await fh.sync();
587
+ } finally {
588
+ await fh.close();
589
+ }
590
+ await rename2(tmp, dest);
591
+ }
592
+ async function appendDurable(dest, line) {
593
+ const fh = await open(dest, "a");
594
+ try {
595
+ await fh.writeFile(line, "utf8");
596
+ await fh.sync();
597
+ } finally {
598
+ await fh.close();
599
+ }
600
+ }
601
+ var Journal = class _Journal {
602
+ config;
603
+ dir;
604
+ blobDir;
605
+ manifestPath;
606
+ log;
607
+ entries;
608
+ nextSeq;
609
+ /** Blob hash → byte size, for every blob currently on disk. */
610
+ blobSizes;
611
+ totalBytes;
612
+ /** Serialises commits so seq assignment and eviction never interleave. */
613
+ chain = Promise.resolve();
614
+ constructor(vaultRoot2, config, entries, blobSizes, log2) {
615
+ this.config = config;
616
+ this.dir = join(vaultRoot2, HISTORY_DIR);
617
+ this.blobDir = join(this.dir, "blobs");
618
+ this.manifestPath = join(this.dir, "manifest.jsonl");
619
+ this.entries = entries;
620
+ this.nextSeq = (entries.at(-1)?.seq ?? 0) + 1;
621
+ this.blobSizes = blobSizes;
622
+ this.totalBytes = 0;
623
+ for (const n of blobSizes.values()) this.totalBytes += n;
624
+ this.log = log2;
625
+ }
626
+ /** Open (or create) the journal under `vaultRoot`, loading the manifest into memory. */
627
+ static async open(vaultRoot2, config, opts = {}) {
628
+ const dir = join(vaultRoot2, HISTORY_DIR);
629
+ const blobDir = join(dir, "blobs");
630
+ await mkdir(blobDir, { recursive: true });
631
+ const entries = [];
632
+ let manifest = "";
633
+ try {
634
+ manifest = await readFile(join(dir, "manifest.jsonl"), "utf8");
635
+ } catch (err) {
636
+ if (err.code !== "ENOENT") throw err;
637
+ }
638
+ let lastSeq = 0;
639
+ for (const line of manifest.split("\n")) {
640
+ if (line.trim() === "") continue;
641
+ let parsed;
642
+ try {
643
+ parsed = JSON.parse(line);
644
+ } catch {
645
+ opts.log?.warn("journal: skipping malformed manifest line");
646
+ continue;
647
+ }
648
+ if (!isEntry(parsed) || parsed.seq <= lastSeq) {
649
+ opts.log?.warn("journal: skipping invalid manifest entry");
650
+ continue;
651
+ }
652
+ entries.push(parsed);
653
+ lastSeq = parsed.seq;
654
+ }
655
+ const blobSizes = /* @__PURE__ */ new Map();
656
+ for (const name of await readdir(blobDir)) {
657
+ if (!/^[0-9a-f]{64}$/.test(name)) continue;
658
+ try {
659
+ blobSizes.set(name, (await stat(join(blobDir, name))).size);
660
+ } catch {
661
+ }
662
+ }
663
+ return new _Journal(vaultRoot2, config, entries, blobSizes, opts.log);
664
+ }
665
+ begin(tool, undoOf) {
666
+ const files = [];
667
+ let committed = false;
668
+ return {
669
+ add: async (path2, pre, post) => {
670
+ if (committed) throw new Error("journal: add after commit");
671
+ const preHash = pre === null ? null : await this.storeBlob(pre);
672
+ files.push({ path: path2, preHash, postHash: post === null ? null : contentHash(post) });
673
+ },
674
+ commit: async () => {
675
+ if (committed) throw new Error("journal: double commit");
676
+ committed = true;
677
+ return this.enqueue(() => this.commit(tool, files, undoOf));
678
+ }
679
+ };
680
+ }
681
+ /** Newest-first rows for `list_writes`. */
682
+ list(opts = {}) {
683
+ const limit = opts.limit ?? 20;
684
+ const filtered = opts.path === void 0 ? this.entries : this.entries.filter((e) => e.files.some((f) => f.path === opts.path));
685
+ const writes = [];
686
+ for (let i = filtered.length - 1; i >= 0 && writes.length < limit; i--) {
687
+ const e = filtered[i];
688
+ const row = {
689
+ seq: e.seq,
690
+ ts: e.ts,
691
+ tool: e.tool,
692
+ paths: e.files.map((f) => f.path),
693
+ undoable: this.isUndoable(e)
694
+ };
695
+ if (e.undoOf !== void 0) row.undoOf = e.undoOf;
696
+ writes.push(row);
697
+ }
698
+ return { writes, total: filtered.length };
699
+ }
700
+ /** Seq of the newest entry (0 when empty). */
701
+ get lastSeq() {
702
+ return this.nextSeq - 1;
703
+ }
704
+ /** Entries committed after `seq`, oldest first. */
705
+ since(seq) {
706
+ return this.entries.filter((e) => e.seq > seq);
707
+ }
708
+ get(seq) {
709
+ return this.entries.find((e) => e.seq === seq);
710
+ }
711
+ /**
712
+ * The most recent write that is still in effect and restorable — the
713
+ * default target of `undo_write`. Undo entries and writes they reverted are
714
+ * skipped (so repeated default undos walk backwards instead of ping-ponging
715
+ * undo/redo); an undo that was itself undone re-exposes its target. Redo is
716
+ * always explicit: `undo_write({ seq: <the undo's seq> })`.
717
+ */
718
+ latestUndoable() {
719
+ const cancelled = /* @__PURE__ */ new Set();
720
+ for (let i = this.entries.length - 1; i >= 0; i--) {
721
+ const e = this.entries[i];
722
+ if (cancelled.has(e.seq)) continue;
723
+ if (e.undoOf !== void 0) {
724
+ cancelled.add(e.undoOf);
725
+ continue;
726
+ }
727
+ if (this.isUndoable(e)) return e;
728
+ }
729
+ return void 0;
730
+ }
731
+ isUndoable(entry) {
732
+ return entry.files.every((f) => f.preHash === null || this.blobSizes.has(f.preHash));
733
+ }
734
+ /** Read a stored pre-image by hash. Throws if evicted. */
735
+ async preimage(hash) {
736
+ const raw = await readFile(join(this.blobDir, hash), "utf8");
737
+ const actual = Buffer.from(contentHash(raw), "hex");
738
+ const expected = Buffer.from(hash, "hex");
739
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
740
+ throw new Error(`journal: blob ${hash} is corrupt (hash mismatch)`);
741
+ }
742
+ return raw;
743
+ }
744
+ /** Bytes of pre-images currently stored (for diagnostics/tests). */
745
+ get storedBytes() {
746
+ return this.totalBytes;
747
+ }
748
+ enqueue(fn) {
749
+ const next = this.chain.then(fn, fn);
750
+ this.chain = next.catch(() => void 0);
751
+ return next;
752
+ }
753
+ async storeBlob(content) {
754
+ const hash = contentHash(content);
755
+ if (this.blobSizes.has(hash)) return hash;
756
+ await writeDurable(join(this.blobDir, hash), content);
757
+ const size = Buffer.byteLength(content, "utf8");
758
+ this.blobSizes.set(hash, size);
759
+ this.totalBytes += size;
760
+ return hash;
761
+ }
762
+ async commit(tool, files, undoOf) {
763
+ const entry = { seq: this.nextSeq, ts: (/* @__PURE__ */ new Date()).toISOString(), tool, files };
764
+ if (undoOf !== void 0) entry.undoOf = undoOf;
765
+ await appendDurable(this.manifestPath, `${JSON.stringify(entry)}
766
+ `);
767
+ this.nextSeq++;
768
+ this.entries.push(entry);
769
+ await this.evict();
770
+ return entry;
771
+ }
772
+ /**
773
+ * Enforce the caps. Blobs are reference-counted across entries, so a
774
+ * pre-image shared by an old and a new entry survives the old one's
775
+ * eviction. Over the entry cap, the oldest rows are dropped and the
776
+ * manifest rewritten atomically; over the byte cap, the oldest entries'
777
+ * blobs are removed but the rows stay (listed as undoable: false).
778
+ */
779
+ async evict() {
780
+ const refs = /* @__PURE__ */ new Map();
781
+ for (const e of this.entries) {
782
+ for (const f of e.files) {
783
+ if (f.preHash !== null) refs.set(f.preHash, (refs.get(f.preHash) ?? 0) + 1);
784
+ }
785
+ }
786
+ const dropBlobs = async (e) => {
787
+ for (const f of e.files) {
788
+ if (f.preHash === null) continue;
789
+ const n = (refs.get(f.preHash) ?? 1) - 1;
790
+ refs.set(f.preHash, n);
791
+ if (n > 0 || !this.blobSizes.has(f.preHash)) continue;
792
+ await rm(join(this.blobDir, f.preHash), { force: true });
793
+ this.totalBytes -= this.blobSizes.get(f.preHash) ?? 0;
794
+ this.blobSizes.delete(f.preHash);
795
+ }
796
+ };
797
+ let rewrite = false;
798
+ while (this.entries.length > this.config.maxEntries) {
799
+ const oldest = this.entries.shift();
800
+ await dropBlobs(oldest);
801
+ rewrite = true;
802
+ }
803
+ for (let i = 0; this.totalBytes > this.config.maxBytes && i < this.entries.length - 1; i++) {
804
+ await dropBlobs(this.entries[i]);
805
+ }
806
+ if (rewrite) {
807
+ await writeDurable(
808
+ this.manifestPath,
809
+ this.entries.map((e) => `${JSON.stringify(e)}
810
+ `).join("")
811
+ );
812
+ this.log?.debug("journal: manifest compacted", { entries: this.entries.length });
813
+ }
814
+ }
815
+ };
816
+ function isEntry(v) {
817
+ if (typeof v !== "object" || v === null) return false;
818
+ const e = v;
819
+ return typeof e.seq === "number" && typeof e.ts === "string" && typeof e.tool === "string" && Array.isArray(e.files) && e.files.every(
820
+ (f) => typeof f === "object" && f !== null && typeof f.path === "string" && (f.preHash === null || typeof f.preHash === "string") && (f.postHash === null || typeof f.postHash === "string")
821
+ );
822
+ }
823
+ async function journalWrite(ctx2, tool, path2, pre, post) {
824
+ if (!ctx2.journal) return;
825
+ const txn = ctx2.journal.begin(tool);
826
+ await txn.add(path2, pre, post);
827
+ await txn.commit();
828
+ }
829
+
302
830
  // src/policy.ts
303
831
  import picomatch from "picomatch";
304
832
  function parseWritePolicy(env) {
@@ -351,7 +879,7 @@ var AppendNoteInput = z.object({
351
879
  async function appendNote(ctx2, input) {
352
880
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
353
881
  assertWritable(ctx2.policy, input.path);
354
- const original = await readFile(absPath, "utf8");
882
+ const original = await readFile2(absPath, "utf8");
355
883
  if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
356
884
  const fm = parseFrontmatter(original);
357
885
  const body = fm.body.endsWith("\n") ? fm.body : `${fm.body}
@@ -360,6 +888,7 @@ async function appendNote(ctx2, input) {
360
888
  const newBody = `${body}${separator}${input.content}`;
361
889
  const header = original.slice(0, fm.bodyStart);
362
890
  const newContent = `${header}${newBody}`;
891
+ await journalWrite(ctx2, "append_note", input.path, original, newContent);
363
892
  await atomicWrite(absPath, newContent);
364
893
  const cached = ctx2.notes.get(input.path);
365
894
  if (cached) {
@@ -610,7 +1139,10 @@ function lexicalSearch(ctx2, input) {
610
1139
  }
611
1140
  function semanticSearch(ctx2, semantic, input) {
612
1141
  const queryVec = semantic.embedQuery(input.query);
613
- const candidates = semantic.store.topNotes(queryVec, SEMANTIC_CANDIDATES);
1142
+ const candidates = semantic.rerank(
1143
+ input.query,
1144
+ semantic.store.topNotes(queryVec, SEMANTIC_CANDIDATES)
1145
+ );
614
1146
  const terms = searchTerms(input.query);
615
1147
  const hits = [];
616
1148
  for (const c of candidates) {
@@ -838,7 +1370,7 @@ function contextPack(ctx2, input) {
838
1370
  }
839
1371
 
840
1372
  // src/tools/create_note.ts
841
- import { access, mkdir, readFile as readFile2 } from "fs/promises";
1373
+ import { access, mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
842
1374
  import { dirname } from "path";
843
1375
  import { stringify as stringifyYaml } from "yaml";
844
1376
  import { z as z4 } from "zod";
@@ -887,6 +1419,7 @@ async function createNote(ctx2, rawInput) {
887
1419
  const input = CreateNoteInput.parse(rawInput);
888
1420
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
889
1421
  assertWritable(ctx2.policy, input.path);
1422
+ let existing = null;
890
1423
  if (!input.overwrite) {
891
1424
  try {
892
1425
  await access(absPath);
@@ -894,22 +1427,26 @@ async function createNote(ctx2, rawInput) {
894
1427
  } catch (err) {
895
1428
  if (err.code !== "ENOENT") throw err;
896
1429
  }
897
- } else if (input.prevHash !== void 0) {
898
- let existing;
1430
+ } else {
899
1431
  try {
900
- existing = await readFile2(absPath, "utf8");
901
- } catch {
902
- throw new Error(
903
- JSON.stringify({
904
- error: "hash_conflict",
905
- path: input.path,
906
- expected: input.prevHash,
907
- actual: null,
908
- hint: "The note no longer exists on disk. Re-check before overwriting."
909
- })
910
- );
1432
+ existing = await readFile3(absPath, "utf8");
1433
+ } catch (err) {
1434
+ if (err.code !== "ENOENT") throw err;
1435
+ if (input.prevHash !== void 0) {
1436
+ throw new Error(
1437
+ JSON.stringify({
1438
+ error: "hash_conflict",
1439
+ path: input.path,
1440
+ expected: input.prevHash,
1441
+ actual: null,
1442
+ hint: "The note no longer exists on disk. Re-check before overwriting."
1443
+ })
1444
+ );
1445
+ }
1446
+ }
1447
+ if (existing !== null && input.prevHash !== void 0) {
1448
+ assertHashMatch(existing, input.prevHash, input.path);
911
1449
  }
912
- assertHashMatch(existing, input.prevHash, input.path);
913
1450
  }
914
1451
  let raw = "";
915
1452
  if (input.frontmatter && Object.keys(input.frontmatter).length > 0) {
@@ -918,7 +1455,8 @@ ${stringifyYaml(input.frontmatter, { lineWidth: 0 })}---
918
1455
  `;
919
1456
  }
920
1457
  raw += input.content ?? "";
921
- await mkdir(dirname(absPath), { recursive: true });
1458
+ await mkdir2(dirname(absPath), { recursive: true });
1459
+ await journalWrite(ctx2, "create_note", input.path, existing, raw);
922
1460
  await atomicWrite(absPath, raw);
923
1461
  upsertDoc(ctx2, buildDoc(input.path, raw));
924
1462
  return {
@@ -929,8 +1467,8 @@ ${stringifyYaml(input.frontmatter, { lineWidth: 0 })}---
929
1467
  }
930
1468
 
931
1469
  // src/tools/delete_note.ts
932
- import { access as access2, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm } from "fs/promises";
933
- import { basename as basename2, extname as extname2, join } from "path";
1470
+ import { access as access2, mkdir as mkdir3, readFile as readFile4, rename as rename3, rm as rm2 } from "fs/promises";
1471
+ import { basename as basename2, extname as extname2, join as join2 } from "path";
934
1472
  import { z as z5 } from "zod";
935
1473
 
936
1474
  // src/index/backlinks.ts
@@ -981,7 +1519,7 @@ async function freeTrashPath(vaultRoot2, noteBase) {
981
1519
  let candidate = `.trash/${noteBase}`;
982
1520
  for (; ; ) {
983
1521
  try {
984
- await access2(join(vaultRoot2, candidate));
1522
+ await access2(join2(vaultRoot2, candidate));
985
1523
  } catch {
986
1524
  return candidate;
987
1525
  }
@@ -992,15 +1530,16 @@ async function deleteNote(ctx2, rawInput) {
992
1530
  const input = DeleteNoteInput.parse(rawInput);
993
1531
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
994
1532
  assertWritable(ctx2.policy, input.path);
995
- const raw = await readFile3(absPath, "utf8");
1533
+ const raw = await readFile4(absPath, "utf8");
996
1534
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
1535
+ await journalWrite(ctx2, "delete_note", input.path, raw, null);
997
1536
  let trashedTo;
998
1537
  if (input.permanent) {
999
- await rm(absPath);
1538
+ await rm2(absPath);
1000
1539
  } else {
1001
- await mkdir2(join(ctx2.vaultRoot, ".trash"), { recursive: true });
1540
+ await mkdir3(join2(ctx2.vaultRoot, ".trash"), { recursive: true });
1002
1541
  trashedTo = await freeTrashPath(ctx2.vaultRoot, basename2(input.path));
1003
- await rename2(absPath, join(ctx2.vaultRoot, trashedTo));
1542
+ await rename3(absPath, join2(ctx2.vaultRoot, trashedTo));
1004
1543
  }
1005
1544
  removeNoteBacklinks(ctx2, input.path);
1006
1545
  if (ctx2.notes.has(input.path)) {
@@ -1138,10 +1677,24 @@ function listTags(ctx2, input) {
1138
1677
  return { tags, total: tags.length };
1139
1678
  }
1140
1679
 
1141
- // src/tools/move_note.ts
1142
- import { access as access3, mkdir as mkdir3, readFile as readFile4, rename as rename3 } from "fs/promises";
1143
- import { dirname as dirname2, join as join2 } from "path";
1680
+ // src/tools/list_writes.ts
1144
1681
  import { z as z10 } from "zod";
1682
+ var ListWritesInput = z10.object({
1683
+ limit: z10.number().int().min(1).max(200).default(20).describe("Max entries to return, newest first (1\u2013200, default 20)."),
1684
+ path: z10.string().optional().describe("Only entries that touched this vault-relative path.")
1685
+ });
1686
+ function listWrites(ctx2, rawInput) {
1687
+ const input = ListWritesInput.parse(rawInput);
1688
+ if (!ctx2.journal) return { writes: [], total: 0, disabled: true };
1689
+ const opts = { limit: input.limit };
1690
+ if (input.path !== void 0) opts.path = input.path;
1691
+ return ctx2.journal.list(opts);
1692
+ }
1693
+
1694
+ // src/tools/move_note.ts
1695
+ import { mkdir as mkdir4, readFile as readFile5, rename as rename4 } from "fs/promises";
1696
+ import { dirname as dirname2, join as join3 } from "path";
1697
+ import { z as z11 } from "zod";
1145
1698
 
1146
1699
  // src/tools/rewrite_links.ts
1147
1700
  import { posix } from "path";
@@ -1236,14 +1789,14 @@ function hasMarkdownLinkTo(raw, sourcePath, targetPath) {
1236
1789
  }
1237
1790
 
1238
1791
  // src/tools/move_note.ts
1239
- var MoveNoteInput = z10.object({
1240
- from: z10.string().describe("Vault-relative path of the note to move."),
1241
- to: z10.string().describe("Vault-relative destination path."),
1242
- overwrite: z10.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false."),
1243
- rewriteLinks: z10.boolean().default(true).describe(
1792
+ var MoveNoteInput = z11.object({
1793
+ from: z11.string().describe("Vault-relative path of the note to move."),
1794
+ to: z11.string().describe("Vault-relative destination path."),
1795
+ overwrite: z11.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false."),
1796
+ rewriteLinks: z11.boolean().default(true).describe(
1244
1797
  "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."
1245
1798
  ),
1246
- prevHash: z10.string().optional().describe(
1799
+ prevHash: z11.string().optional().describe(
1247
1800
  "Optional compare-and-swap guard on the source note: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
1248
1801
  )
1249
1802
  });
@@ -1255,20 +1808,19 @@ async function moveNote(ctx2, rawInput) {
1255
1808
  assertWritable(ctx2.policy, input.to);
1256
1809
  let raw;
1257
1810
  try {
1258
- raw = await readFile4(absFrom, "utf8");
1811
+ raw = await readFile5(absFrom, "utf8");
1259
1812
  } catch {
1260
1813
  throw new Error(`Note not found: ${input.from}`);
1261
1814
  }
1262
1815
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.from);
1263
- if (!input.overwrite) {
1264
- try {
1265
- await access3(absTo);
1266
- throw new Error(
1267
- `Destination already exists: ${input.to}. Pass overwrite: true to replace it.`
1268
- );
1269
- } catch (err) {
1270
- if (err.code !== "ENOENT") throw err;
1271
- }
1816
+ let existingAtTo = null;
1817
+ try {
1818
+ existingAtTo = await readFile5(absTo, "utf8");
1819
+ } catch (err) {
1820
+ if (err.code !== "ENOENT") throw err;
1821
+ }
1822
+ if (existingAtTo !== null && !input.overwrite) {
1823
+ throw new Error(`Destination already exists: ${input.to}. Pass overwrite: true to replace it.`);
1272
1824
  }
1273
1825
  const preNotes = new Map(ctx2.notes);
1274
1826
  const candidates = /* @__PURE__ */ new Set();
@@ -1280,20 +1832,13 @@ async function moveNote(ctx2, rawInput) {
1280
1832
  }
1281
1833
  candidates.delete(input.from);
1282
1834
  }
1283
- await mkdir3(dirname2(absTo), { recursive: true });
1284
- await rename3(absFrom, absTo);
1285
- removeNoteBacklinks(ctx2, input.from);
1286
- if (ctx2.notes.has(input.from)) {
1287
- ctx2.index.discard(input.from);
1288
- ctx2.notes.delete(input.from);
1289
- }
1290
- upsertDoc(ctx2, buildDoc(input.to, raw));
1291
- addNoteBacklinks(ctx2, input.to, raw);
1292
- let notesRewritten = 0;
1293
- let linksRewritten = 0;
1835
+ const postNotes = new Map(preNotes);
1836
+ postNotes.delete(input.from);
1837
+ postNotes.set(input.to, buildDoc(input.to, raw));
1838
+ const rewrites = [];
1294
1839
  const skipped = [];
1295
1840
  for (const path2 of [...candidates].sort()) {
1296
- const doc = ctx2.notes.get(path2);
1841
+ const doc = preNotes.get(path2);
1297
1842
  if (doc === void 0) continue;
1298
1843
  if (!isWritable(ctx2.policy, path2)) {
1299
1844
  skipped.push(path2);
@@ -1305,11 +1850,34 @@ async function moveNote(ctx2, rawInput) {
1305
1850
  input.from,
1306
1851
  input.to,
1307
1852
  preNotes,
1308
- ctx2.notes
1853
+ postNotes
1309
1854
  );
1855
+ rewrites.push({ path: path2, pre: doc.raw, content, count });
1856
+ }
1857
+ if (ctx2.journal) {
1858
+ const txn = ctx2.journal.begin("move_note");
1859
+ await txn.add(input.from, raw, null);
1860
+ await txn.add(input.to, existingAtTo, raw);
1861
+ for (const r of rewrites) if (r.count > 0) await txn.add(r.path, r.pre, r.content);
1862
+ await txn.commit();
1863
+ }
1864
+ await mkdir4(dirname2(absTo), { recursive: true });
1865
+ await rename4(absFrom, absTo);
1866
+ removeNoteBacklinks(ctx2, input.from);
1867
+ if (ctx2.notes.has(input.from)) {
1868
+ ctx2.index.discard(input.from);
1869
+ ctx2.notes.delete(input.from);
1870
+ }
1871
+ upsertDoc(ctx2, buildDoc(input.to, raw));
1872
+ addNoteBacklinks(ctx2, input.to, raw);
1873
+ let notesRewritten = 0;
1874
+ let linksRewritten = 0;
1875
+ for (const { path: path2, content, count } of rewrites) {
1876
+ const doc = ctx2.notes.get(path2);
1877
+ if (doc === void 0) continue;
1310
1878
  removeNoteBacklinks(ctx2, path2);
1311
1879
  if (count > 0) {
1312
- await atomicWrite(join2(ctx2.vaultRoot, path2), content);
1880
+ await atomicWrite(join3(ctx2.vaultRoot, path2), content);
1313
1881
  upsertDoc(ctx2, buildDoc(path2, content));
1314
1882
  notesRewritten++;
1315
1883
  linksRewritten += count;
@@ -1331,7 +1899,7 @@ async function moveNote(ctx2, rawInput) {
1331
1899
  }
1332
1900
 
1333
1901
  // src/tools/outline_note.ts
1334
- import { readFile as readFile5 } from "fs/promises";
1902
+ import { readFile as readFile6 } from "fs/promises";
1335
1903
 
1336
1904
  // ../core/src/outline.ts
1337
1905
  var HEADING_RE = /^(#{1,6})\s+(.+)/;
@@ -1381,15 +1949,15 @@ function buildOutline(raw, options = {}) {
1381
1949
  }
1382
1950
 
1383
1951
  // src/tools/outline_note.ts
1384
- import { z as z11 } from "zod";
1385
- var OutlineNoteInput = z11.object({
1386
- path: z11.string().describe("Vault-relative path to the note."),
1387
- includeBlocks: z11.boolean().default(true).describe("Include block-reference anchors (^id). Default true."),
1388
- includeSizes: z11.boolean().default(false).describe("Include per-section character length in each heading entry. Default false.")
1952
+ import { z as z12 } from "zod";
1953
+ var OutlineNoteInput = z12.object({
1954
+ path: z12.string().describe("Vault-relative path to the note."),
1955
+ includeBlocks: z12.boolean().default(true).describe("Include block-reference anchors (^id). Default true."),
1956
+ includeSizes: z12.boolean().default(false).describe("Include per-section character length in each heading entry. Default false.")
1389
1957
  });
1390
1958
  async function outlineNote(ctx2, input) {
1391
1959
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1392
- const raw = await readFile5(absPath, "utf8");
1960
+ const raw = await readFile6(absPath, "utf8");
1393
1961
  return buildOutline(raw, {
1394
1962
  includeBlocks: input.includeBlocks,
1395
1963
  includeSizes: input.includeSizes
@@ -1397,22 +1965,22 @@ async function outlineNote(ctx2, input) {
1397
1965
  }
1398
1966
 
1399
1967
  // src/tools/patch_frontmatter.ts
1400
- import { readFile as readFile6 } from "fs/promises";
1968
+ import { readFile as readFile7 } from "fs/promises";
1401
1969
  import { parseDocument as parseDocument2 } from "yaml";
1402
- import { z as z12 } from "zod";
1403
- var PatchFrontmatterInput = z12.object({
1404
- path: z12.string().describe("Vault-relative path to the note."),
1405
- patch: z12.record(z12.string(), z12.unknown()).describe(
1970
+ import { z as z13 } from "zod";
1971
+ var PatchFrontmatterInput = z13.object({
1972
+ path: z13.string().describe("Vault-relative path to the note."),
1973
+ patch: z13.record(z13.string(), z13.unknown()).describe(
1406
1974
  "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."
1407
1975
  ),
1408
- prevHash: z12.string().optional().describe(
1976
+ prevHash: z13.string().optional().describe(
1409
1977
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
1410
1978
  )
1411
1979
  });
1412
1980
  async function patchFrontmatter(ctx2, input) {
1413
1981
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1414
1982
  assertWritable(ctx2.policy, input.path);
1415
- const original = await readFile6(absPath, "utf8");
1983
+ const original = await readFile7(absPath, "utf8");
1416
1984
  if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
1417
1985
  const fm = parseFrontmatter(original);
1418
1986
  const keysChanged = [];
@@ -1455,6 +2023,7 @@ ${original}`;
1455
2023
  const tail = opensWithCRLF ? "---\r\n" : "---\n";
1456
2024
  newContent = `${head}${doc.toString({ lineWidth: 0 })}${tail}${fm.body}`;
1457
2025
  }
2026
+ await journalWrite(ctx2, "patch_frontmatter", input.path, original, newContent);
1458
2027
  await atomicWrite(absPath, newContent);
1459
2028
  const cached = ctx2.notes.get(input.path);
1460
2029
  if (cached) {
@@ -1472,22 +2041,22 @@ ${original}`;
1472
2041
  }
1473
2042
 
1474
2043
  // src/tools/patch_note.ts
1475
- import { readFile as readFile7 } from "fs/promises";
1476
- import { z as z13 } from "zod";
1477
- var PatchNoteInput = z13.object({
1478
- path: z13.string().describe("Vault-relative path to the note."),
1479
- target: z13.union([
1480
- z13.object({ heading: z13.string().describe("Heading text (without # markers).") }),
1481
- z13.object({ block: z13.string().describe("Block reference ID (without the ^ prefix).") })
2044
+ import { readFile as readFile8 } from "fs/promises";
2045
+ import { z as z14 } from "zod";
2046
+ var PatchNoteInput = z14.object({
2047
+ path: z14.string().describe("Vault-relative path to the note."),
2048
+ target: z14.union([
2049
+ z14.object({ heading: z14.string().describe("Heading text (without # markers).") }),
2050
+ z14.object({ block: z14.string().describe("Block reference ID (without the ^ prefix).") })
1482
2051
  ]).describe("Target section. Provide exactly one of heading or block."),
1483
- operation: z13.enum(["append", "prepend", "replace"]).describe(
2052
+ operation: z14.enum(["append", "prepend", "replace"]).describe(
1484
2053
  "append: insert after section content. prepend: insert after heading line. replace: swap section content."
1485
2054
  ),
1486
- content: z13.string().min(1).describe("Content to insert or replace with."),
1487
- createIfMissing: z13.boolean().default(false).describe(
2055
+ content: z14.string().min(1).describe("Content to insert or replace with."),
2056
+ createIfMissing: z14.boolean().default(false).describe(
1488
2057
  "If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets."
1489
2058
  ),
1490
- prevHash: z13.string().optional().describe(
2059
+ prevHash: z14.string().optional().describe(
1491
2060
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
1492
2061
  )
1493
2062
  });
@@ -1503,7 +2072,7 @@ function lineStart(raw, offset) {
1503
2072
  async function patchNote(ctx2, input) {
1504
2073
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1505
2074
  assertWritable(ctx2.policy, input.path);
1506
- const raw = await readFile7(absPath, "utf8");
2075
+ const raw = await readFile8(absPath, "utf8");
1507
2076
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
1508
2077
  const fm = parseFrontmatter(raw);
1509
2078
  const originalFmRegion = raw.slice(0, fm.bodyStart);
@@ -1521,6 +2090,7 @@ async function patchNote(ctx2, input) {
1521
2090
  const newContent2 = `${raw}${sep4}## ${targetText}
1522
2091
 
1523
2092
  ${input.content}${contentNl}`;
2093
+ await journalWrite(ctx2, "patch_note", input.path, raw, newContent2);
1524
2094
  await atomicWrite(absPath, newContent2);
1525
2095
  await verifyFrontmatter(absPath, originalFmRegion);
1526
2096
  updateCache(ctx2, input.path, newContent2, fm.bodyStart);
@@ -1591,6 +2161,7 @@ ${input.content}${contentNl}`;
1591
2161
  default:
1592
2162
  throw new Error(`Unknown operation: ${String(input.operation)}`);
1593
2163
  }
2164
+ await journalWrite(ctx2, "patch_note", input.path, raw, newContent);
1594
2165
  await atomicWrite(absPath, newContent);
1595
2166
  await verifyFrontmatter(absPath, originalFmRegion);
1596
2167
  updateCache(ctx2, input.path, newContent, fm.bodyStart);
@@ -1603,7 +2174,7 @@ ${input.content}${contentNl}`;
1603
2174
  };
1604
2175
  }
1605
2176
  async function verifyFrontmatter(absPath, originalFmRegion) {
1606
- const written = await readFile7(absPath, "utf8");
2177
+ const written = await readFile8(absPath, "utf8");
1607
2178
  if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
1608
2179
  throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
1609
2180
  }
@@ -1618,10 +2189,10 @@ function updateCache(ctx2, path2, newContent, bodyStart) {
1618
2189
  }
1619
2190
 
1620
2191
  // src/tools/periodic_note.ts
1621
- import { access as access4, mkdir as mkdir4, readFile as readFile8 } from "fs/promises";
1622
- import { dirname as dirname3, join as join3 } from "path";
1623
- import { z as z14 } from "zod";
1624
- var PERIOD = z14.enum(["daily", "weekly", "monthly", "quarterly", "yearly"]);
2192
+ import { access as access3, mkdir as mkdir5, readFile as readFile9 } from "fs/promises";
2193
+ import { dirname as dirname3, join as join4 } from "path";
2194
+ import { z as z15 } from "zod";
2195
+ var PERIOD = z15.enum(["daily", "weekly", "monthly", "quarterly", "yearly"]);
1625
2196
  var DEFAULTS = {
1626
2197
  daily: { folder: "", format: "YYYY-MM-DD", template: "" },
1627
2198
  weekly: { folder: "", format: "gggg-[W]ww", template: "" },
@@ -1631,7 +2202,7 @@ var DEFAULTS = {
1631
2202
  };
1632
2203
  async function readJson(absPath) {
1633
2204
  try {
1634
- return JSON.parse(await readFile8(absPath, "utf8"));
2205
+ return JSON.parse(await readFile9(absPath, "utf8"));
1635
2206
  } catch {
1636
2207
  return null;
1637
2208
  }
@@ -1639,7 +2210,7 @@ async function readJson(absPath) {
1639
2210
  async function resolveConfig(vaultRoot2, period) {
1640
2211
  const defaults = DEFAULTS[period];
1641
2212
  if (period === "daily") {
1642
- const cfg2 = await readJson(join3(vaultRoot2, ".obsidian", "daily-notes.json"));
2213
+ const cfg2 = await readJson(join4(vaultRoot2, ".obsidian", "daily-notes.json"));
1643
2214
  return {
1644
2215
  folder: cfg2?.folder ?? defaults.folder,
1645
2216
  format: cfg2?.format ?? defaults.format,
@@ -1647,7 +2218,7 @@ async function resolveConfig(vaultRoot2, period) {
1647
2218
  };
1648
2219
  }
1649
2220
  const cfg = await readJson(
1650
- join3(vaultRoot2, ".obsidian", "plugins", "periodic-notes", "data.json")
2221
+ join4(vaultRoot2, ".obsidian", "plugins", "periodic-notes", "data.json")
1651
2222
  );
1652
2223
  const entry = cfg?.[period];
1653
2224
  return {
@@ -1686,12 +2257,12 @@ function resolveNotePath(cfg, date) {
1686
2257
  const filename = `${formatMomentDate(date, cfg.format)}.md`;
1687
2258
  return cfg.folder ? `${cfg.folder}/${filename}` : filename;
1688
2259
  }
1689
- var GetPeriodicNoteInput = z14.object({
2260
+ var GetPeriodicNoteInput = z15.object({
1690
2261
  period: PERIOD.default("daily").describe(
1691
2262
  "Period type: daily (default), weekly, monthly, quarterly, or yearly."
1692
2263
  ),
1693
- date: z14.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today when omitted."),
1694
- createIfMissing: z14.boolean().default(false).describe(
2264
+ date: z15.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today when omitted."),
2265
+ createIfMissing: z15.boolean().default(false).describe(
1695
2266
  "Create the note from the configured template if it does not exist. Default false \u2014 reports missing without creating."
1696
2267
  )
1697
2268
  });
@@ -1703,7 +2274,7 @@ async function getPeriodicNote(ctx2, rawInput) {
1703
2274
  const abs = resolveVaultPath(ctx2.vaultRoot, path2);
1704
2275
  let existed = false;
1705
2276
  try {
1706
- await access4(abs);
2277
+ await access3(abs);
1707
2278
  existed = true;
1708
2279
  } catch {
1709
2280
  }
@@ -1717,21 +2288,22 @@ async function getPeriodicNote(ctx2, rawInput) {
1717
2288
  ctx2.vaultRoot,
1718
2289
  cfg.template.endsWith(".md") ? cfg.template : `${cfg.template}.md`
1719
2290
  );
1720
- body = await readFile8(templateAbs, "utf8");
2291
+ body = await readFile9(templateAbs, "utf8");
1721
2292
  } catch {
1722
2293
  }
1723
2294
  }
1724
- await mkdir4(dirname3(abs), { recursive: true });
2295
+ await mkdir5(dirname3(abs), { recursive: true });
2296
+ await journalWrite(ctx2, "get_periodic_note", path2, null, body);
1725
2297
  await atomicWrite(abs, body);
1726
2298
  upsertDoc(ctx2, buildDoc(path2, body));
1727
2299
  return { path: path2, existed: false, created: true, contentHash: contentHash(body) };
1728
2300
  }
1729
- var AppendPeriodicNoteInput = z14.object({
2301
+ var AppendPeriodicNoteInput = z15.object({
1730
2302
  period: PERIOD.default("daily").describe("Period type. Default: daily."),
1731
- date: z14.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today."),
1732
- content: z14.string().min(1).describe("Text to append to the note body. Separated from existing content by a blank line."),
1733
- createIfMissing: z14.boolean().default(true).describe("Create the note if it does not exist before appending. Default true."),
1734
- prevHash: z14.string().optional().describe(
2303
+ date: z15.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today."),
2304
+ content: z15.string().min(1).describe("Text to append to the note body. Separated from existing content by a blank line."),
2305
+ createIfMissing: z15.boolean().default(true).describe("Create the note if it does not exist before appending. Default true."),
2306
+ prevHash: z15.string().optional().describe(
1735
2307
  "Optional compare-and-swap guard: the contentHash from a prior read of the resolved periodic note. Fails with hash_conflict if it changed since."
1736
2308
  )
1737
2309
  });
@@ -1744,7 +2316,7 @@ async function appendPeriodicNote(ctx2, rawInput) {
1744
2316
  assertWritable(ctx2.policy, path2);
1745
2317
  let original = "";
1746
2318
  try {
1747
- original = await readFile8(abs, "utf8");
2319
+ original = await readFile9(abs, "utf8");
1748
2320
  if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, path2);
1749
2321
  } catch (err) {
1750
2322
  if (err.code !== "ENOENT") throw err;
@@ -1760,7 +2332,8 @@ async function appendPeriodicNote(ctx2, rawInput) {
1760
2332
  );
1761
2333
  }
1762
2334
  if (!input.createIfMissing) throw new Error(`Periodic note not found: ${path2}`);
1763
- await mkdir4(dirname3(abs), { recursive: true });
2335
+ await mkdir5(dirname3(abs), { recursive: true });
2336
+ await journalWrite(ctx2, "append_periodic_note", path2, null, input.content);
1764
2337
  await atomicWrite(abs, input.content);
1765
2338
  upsertDoc(ctx2, buildDoc(path2, input.content));
1766
2339
  return {
@@ -1776,6 +2349,7 @@ async function appendPeriodicNote(ctx2, rawInput) {
1776
2349
  const newBody = `${body}${separator}${input.content}`;
1777
2350
  const header = original.slice(0, fm.bodyStart);
1778
2351
  const newRaw = `${header}${newBody}`;
2352
+ await journalWrite(ctx2, "append_periodic_note", path2, original, newRaw);
1779
2353
  await atomicWrite(abs, newRaw);
1780
2354
  const cached = ctx2.notes.get(path2);
1781
2355
  if (cached) {
@@ -1790,36 +2364,36 @@ async function appendPeriodicNote(ctx2, rawInput) {
1790
2364
  }
1791
2365
 
1792
2366
  // src/tools/query_notes.ts
1793
- import { z as z15 } from "zod";
2367
+ import { z as z16 } from "zod";
1794
2368
  var OPS_WITHOUT_VALUE = /* @__PURE__ */ new Set(["exists", "missing"]);
1795
- var Predicate = z15.object({
1796
- key: z15.string().min(1).describe("Frontmatter key to test (top-level)."),
1797
- op: z15.enum(["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"]).describe(
2369
+ var Predicate = z16.object({
2370
+ key: z16.string().min(1).describe("Frontmatter key to test (top-level)."),
2371
+ op: z16.enum(["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"]).describe(
1798
2372
  "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)."
1799
2373
  ),
1800
- value: z15.union([z15.string(), z15.number(), z15.boolean()]).optional().describe("Comparison value. Required for every op except exists/missing.")
2374
+ value: z16.union([z16.string(), z16.number(), z16.boolean()]).optional().describe("Comparison value. Required for every op except exists/missing.")
1801
2375
  }).refine((p) => OPS_WITHOUT_VALUE.has(p.op) || p.value !== void 0, {
1802
2376
  message: "value is required for this op"
1803
2377
  });
1804
- var ISO_DATE = z15.string().refine((s) => !Number.isNaN(Date.parse(s)), { message: "not a parseable date" });
1805
- var QueryNotesInput = z15.object({
1806
- where: z15.array(Predicate).max(20).default([]).describe("Frontmatter predicates. All must match (AND)."),
1807
- folder: z15.string().optional().describe("Restrict to notes under this vault-relative folder prefix."),
1808
- tag: z15.string().optional().describe("Restrict to notes with this tag (# prefix optional)."),
2378
+ var ISO_DATE = z16.string().refine((s) => !Number.isNaN(Date.parse(s)), { message: "not a parseable date" });
2379
+ var QueryNotesInput = z16.object({
2380
+ where: z16.array(Predicate).max(20).default([]).describe("Frontmatter predicates. All must match (AND)."),
2381
+ folder: z16.string().optional().describe("Restrict to notes under this vault-relative folder prefix."),
2382
+ tag: z16.string().optional().describe("Restrict to notes with this tag (# prefix optional)."),
1809
2383
  modifiedAfter: ISO_DATE.optional().describe(
1810
2384
  "Only notes modified at or after this ISO 8601 date/time."
1811
2385
  ),
1812
2386
  modifiedBefore: ISO_DATE.optional().describe(
1813
2387
  "Only notes modified before this ISO 8601 date/time."
1814
2388
  ),
1815
- minSizeBytes: z15.number().int().min(0).optional().describe("Only notes at least this many bytes."),
1816
- maxSizeBytes: z15.number().int().min(0).optional().describe("Only notes at most this many bytes."),
1817
- select: z15.array(z15.string()).max(20).default([]).describe(
2389
+ minSizeBytes: z16.number().int().min(0).optional().describe("Only notes at least this many bytes."),
2390
+ maxSizeBytes: z16.number().int().min(0).optional().describe("Only notes at most this many bytes."),
2391
+ select: z16.array(z16.string()).max(20).default([]).describe(
1818
2392
  'Extra fields to return per hit: frontmatter keys, or the specials "mtime", "size", "tags". Default returns only path + title, keeping the payload minimal.'
1819
2393
  ),
1820
- sort: z15.enum(["path", "title", "mtime", "size"]).default("path").describe("Sort field."),
1821
- order: z15.enum(["asc", "desc"]).default("asc").describe("Sort order."),
1822
- limit: z15.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
2394
+ sort: z16.enum(["path", "title", "mtime", "size"]).default("path").describe("Sort field."),
2395
+ order: z16.enum(["asc", "desc"]).default("asc").describe("Sort order."),
2396
+ limit: z16.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
1823
2397
  });
1824
2398
  function scalarEq(a, b) {
1825
2399
  if (a === b) return true;
@@ -1917,17 +2491,17 @@ function queryNotes(ctx2, input) {
1917
2491
  }
1918
2492
 
1919
2493
  // src/tools/read_note.ts
1920
- import { readFile as readFile9 } from "fs/promises";
1921
- import { z as z16 } from "zod";
1922
- var ReadNoteInput = z16.object({
1923
- path: z16.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".'),
1924
- section: z16.string().optional().describe("Return only this heading section (heading text, # prefix optional)."),
1925
- block: z16.string().optional().describe("Return only the block anchored by this id (^ prefix optional)."),
1926
- lines: z16.object({
1927
- from: z16.number().int().min(1).describe("First line (1-indexed, inclusive)."),
1928
- to: z16.number().int().min(1).optional().describe("Last line (1-indexed, inclusive). Defaults to end of file.")
2494
+ import { readFile as readFile10 } from "fs/promises";
2495
+ import { z as z17 } from "zod";
2496
+ var ReadNoteInput = z17.object({
2497
+ path: z17.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".'),
2498
+ section: z17.string().optional().describe("Return only this heading section (heading text, # prefix optional)."),
2499
+ block: z17.string().optional().describe("Return only the block anchored by this id (^ prefix optional)."),
2500
+ lines: z17.object({
2501
+ from: z17.number().int().min(1).describe("First line (1-indexed, inclusive)."),
2502
+ to: z17.number().int().min(1).optional().describe("Last line (1-indexed, inclusive). Defaults to end of file.")
1929
2503
  }).optional().describe("Return only this line range (1-indexed, inclusive)."),
1930
- includeFrontmatter: z16.boolean().optional().describe(
2504
+ includeFrontmatter: z17.boolean().optional().describe(
1931
2505
  "Include frontmatter in span results. Default false for section/block spans. No effect on lines or whole-note reads."
1932
2506
  )
1933
2507
  }).refine((v) => [v.section, v.block, v.lines].filter((x) => x !== void 0).length <= 1, {
@@ -1935,7 +2509,7 @@ var ReadNoteInput = z16.object({
1935
2509
  });
1936
2510
  async function readNote(ctx2, input) {
1937
2511
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1938
- const raw = await readFile9(absPath, "utf8");
2512
+ const raw = await readFile10(absPath, "utf8");
1939
2513
  const noteBytes = Buffer.byteLength(raw, "utf8");
1940
2514
  const hash = contentHash(raw);
1941
2515
  if (input.section === void 0 && input.block === void 0 && input.lines === void 0) {
@@ -2023,18 +2597,18 @@ async function readNote(ctx2, input) {
2023
2597
  }
2024
2598
 
2025
2599
  // src/tools/rename_heading.ts
2026
- import { readFile as readFile10 } from "fs/promises";
2027
- import { join as join4 } from "path";
2028
- import { z as z17 } from "zod";
2029
- var RenameHeadingInput = z17.object({
2030
- path: z17.string().describe("Vault-relative path of the note containing the heading."),
2031
- oldHeading: z17.string().min(1).describe(
2600
+ import { readFile as readFile11 } from "fs/promises";
2601
+ import { join as join5 } from "path";
2602
+ import { z as z18 } from "zod";
2603
+ var RenameHeadingInput = z18.object({
2604
+ path: z18.string().describe("Vault-relative path of the note containing the heading."),
2605
+ oldHeading: z18.string().min(1).describe(
2032
2606
  "Current heading text, without # markers. Matched case-insensitively; if the note has duplicate headings the first one wins, mirroring Obsidian link resolution."
2033
2607
  ),
2034
- newHeading: z17.string().min(1).refine((s) => !/[\n[\]|#]/.test(s), {
2608
+ newHeading: z18.string().min(1).refine((s) => !/[\n[\]|#]/.test(s), {
2035
2609
  message: 'newHeading must not contain newlines, "[", "]", "|", or "#" \u2014 those break [[note#heading]] link syntax.'
2036
2610
  }).describe("New heading text, without # markers."),
2037
- prevHash: z17.string().optional().describe(
2611
+ prevHash: z18.string().optional().describe(
2038
2612
  "Optional compare-and-swap guard: the contentHash from a prior read of this note. Fails with hash_conflict if the note changed since."
2039
2613
  )
2040
2614
  });
@@ -2064,7 +2638,7 @@ async function renameHeading(ctx2, rawInput) {
2064
2638
  assertWritable(ctx2.policy, input.path);
2065
2639
  let raw;
2066
2640
  try {
2067
- raw = await readFile10(absPath, "utf8");
2641
+ raw = await readFile11(absPath, "utf8");
2068
2642
  } catch {
2069
2643
  throw new Error(`Note not found: ${input.path}`);
2070
2644
  }
@@ -2098,21 +2672,11 @@ async function renameHeading(ctx2, rawInput) {
2098
2672
  (target) => target === "" || resolveLink(target, ctx2.notes) === input.path
2099
2673
  );
2100
2674
  newRaw = newRaw.slice(0, fm.bodyStart) + selfRewrite.content;
2101
- const originalFmRegion = raw.slice(0, fm.bodyStart);
2102
- await atomicWrite(absPath, newRaw);
2103
- const written = await readFile10(absPath, "utf8");
2104
- if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
2105
- throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
2106
- }
2107
- removeNoteBacklinks(ctx2, input.path);
2108
- upsertDoc(ctx2, buildDoc(input.path, newRaw));
2109
- addNoteBacklinks(ctx2, input.path, newRaw);
2110
- let notesRewritten = selfRewrite.count > 0 ? 1 : 0;
2111
- let linksRewritten = selfRewrite.count;
2112
- const skipped = [];
2113
2675
  const candidates = /* @__PURE__ */ new Set();
2114
2676
  for (const ref of ctx2.backlinks.get(input.path) ?? []) candidates.add(ref.path);
2115
2677
  candidates.delete(input.path);
2678
+ const rewrites = [];
2679
+ const skipped = [];
2116
2680
  for (const path2 of [...candidates].sort()) {
2117
2681
  const doc = ctx2.notes.get(path2);
2118
2682
  if (doc === void 0) continue;
@@ -2127,7 +2691,27 @@ async function renameHeading(ctx2, rawInput) {
2127
2691
  skipped.push(path2);
2128
2692
  continue;
2129
2693
  }
2130
- await atomicWrite(join4(ctx2.vaultRoot, path2), content);
2694
+ rewrites.push({ path: path2, pre: doc.raw, content, count });
2695
+ }
2696
+ if (ctx2.journal) {
2697
+ const txn = ctx2.journal.begin("rename_heading");
2698
+ await txn.add(input.path, raw, newRaw);
2699
+ for (const r of rewrites) await txn.add(r.path, r.pre, r.content);
2700
+ await txn.commit();
2701
+ }
2702
+ const originalFmRegion = raw.slice(0, fm.bodyStart);
2703
+ await atomicWrite(absPath, newRaw);
2704
+ const written = await readFile11(absPath, "utf8");
2705
+ if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
2706
+ throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
2707
+ }
2708
+ removeNoteBacklinks(ctx2, input.path);
2709
+ upsertDoc(ctx2, buildDoc(input.path, newRaw));
2710
+ addNoteBacklinks(ctx2, input.path, newRaw);
2711
+ let notesRewritten = selfRewrite.count > 0 ? 1 : 0;
2712
+ let linksRewritten = selfRewrite.count;
2713
+ for (const { path: path2, content, count } of rewrites) {
2714
+ await atomicWrite(join5(ctx2.vaultRoot, path2), content);
2131
2715
  upsertDoc(ctx2, buildDoc(path2, content));
2132
2716
  notesRewritten++;
2133
2717
  linksRewritten += count;
@@ -2146,18 +2730,18 @@ async function renameHeading(ctx2, rawInput) {
2146
2730
  }
2147
2731
 
2148
2732
  // src/tools/replace_in_note.ts
2149
- import { readFile as readFile11 } from "fs/promises";
2150
- import { z as z18 } from "zod";
2151
- var ReplaceInNoteInput = z18.object({
2152
- path: z18.string().describe("Vault-relative path to the note."),
2153
- find: z18.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
2154
- replace: z18.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
2155
- regex: z18.boolean().default(false).describe("Treat find as a regular expression."),
2156
- caseSensitive: z18.boolean().default(false).describe("Case-sensitive matching."),
2157
- wholeWord: z18.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
2158
- limit: z18.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
2159
- dryRun: z18.boolean().default(false).describe("If true, report matches without writing anything."),
2160
- prevHash: z18.string().optional().describe(
2733
+ import { readFile as readFile12 } from "fs/promises";
2734
+ import { z as z19 } from "zod";
2735
+ var ReplaceInNoteInput = z19.object({
2736
+ path: z19.string().describe("Vault-relative path to the note."),
2737
+ find: z19.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
2738
+ replace: z19.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
2739
+ regex: z19.boolean().default(false).describe("Treat find as a regular expression."),
2740
+ caseSensitive: z19.boolean().default(false).describe("Case-sensitive matching."),
2741
+ wholeWord: z19.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
2742
+ limit: z19.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
2743
+ dryRun: z19.boolean().default(false).describe("If true, report matches without writing anything."),
2744
+ prevHash: z19.string().optional().describe(
2161
2745
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
2162
2746
  )
2163
2747
  });
@@ -2183,7 +2767,7 @@ async function replaceInNote(ctx2, input) {
2183
2767
  } catch (err) {
2184
2768
  throw new Error(`Invalid regex: ${err instanceof Error ? err.message : String(err)}`);
2185
2769
  }
2186
- const raw = await readFile11(absPath, "utf8");
2770
+ const raw = await readFile12(absPath, "utf8");
2187
2771
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
2188
2772
  const fm = parseFrontmatter(raw);
2189
2773
  const originalFmRegion = raw.slice(0, fm.bodyStart);
@@ -2217,8 +2801,9 @@ async function replaceInNote(ctx2, input) {
2217
2801
  }
2218
2802
  newBody += body.slice(lastIndex);
2219
2803
  const newContent = `${originalFmRegion}${newBody}`;
2804
+ await journalWrite(ctx2, "replace_in_note", input.path, raw, newContent);
2220
2805
  await atomicWrite(absPath, newContent);
2221
- const written = await readFile11(absPath, "utf8");
2806
+ const written = await readFile12(absPath, "utf8");
2222
2807
  if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
2223
2808
  throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
2224
2809
  }
@@ -2238,6 +2823,123 @@ async function replaceInNote(ctx2, input) {
2238
2823
  };
2239
2824
  }
2240
2825
 
2826
+ // src/tools/undo_write.ts
2827
+ import { mkdir as mkdir6, readFile as readFile13, rm as rm3 } from "fs/promises";
2828
+ import { dirname as dirname4 } from "path";
2829
+ import { z as z20 } from "zod";
2830
+ var UndoWriteInput = z20.object({
2831
+ seq: z20.number().int().min(1).optional().describe(
2832
+ "Journal entry to revert (from list_writes). Defaults to the most recent undoable write."
2833
+ ),
2834
+ force: z20.boolean().default(false).describe(
2835
+ "Restore even if a file changed after the journaled write. The clobbered state is journaled first, so nothing is lost. Defaults to false \u2014 conflicts are refused."
2836
+ )
2837
+ });
2838
+ async function undoWrite(ctx2, rawInput) {
2839
+ const input = UndoWriteInput.parse(rawInput);
2840
+ const journal = ctx2.journal;
2841
+ if (!journal) {
2842
+ throw new Error("Write journal is disabled (SEEKSTONE_HISTORY=0); nothing to undo.");
2843
+ }
2844
+ let entry;
2845
+ if (input.seq === void 0) {
2846
+ entry = journal.latestUndoable();
2847
+ if (!entry) throw new Error("No undoable writes in the journal.");
2848
+ } else {
2849
+ entry = journal.get(input.seq);
2850
+ if (!entry) throw new Error(`Journal entry ${input.seq} not found.`);
2851
+ if (!journal.isUndoable(entry)) {
2852
+ throw new Error(
2853
+ JSON.stringify({
2854
+ error: "entry_evicted",
2855
+ seq: entry.seq,
2856
+ hint: "Its pre-image was evicted by the retention cap (SEEKSTONE_HISTORY_MAX_SIZE); it can no longer be undone."
2857
+ })
2858
+ );
2859
+ }
2860
+ }
2861
+ for (const f of entry.files) assertWritable(ctx2.policy, f.path);
2862
+ const current = /* @__PURE__ */ new Map();
2863
+ const conflicts = [];
2864
+ for (const f of entry.files) {
2865
+ const abs = resolveVaultPath(ctx2.vaultRoot, f.path);
2866
+ let cur = null;
2867
+ try {
2868
+ cur = await readFile13(abs, "utf8");
2869
+ } catch (err) {
2870
+ if (err.code !== "ENOENT") throw err;
2871
+ }
2872
+ current.set(f.path, cur);
2873
+ const actualHash = cur === null ? null : contentHash(cur);
2874
+ if (actualHash !== f.postHash) {
2875
+ conflicts.push({
2876
+ path: f.path,
2877
+ expectedHash: f.postHash,
2878
+ actualHash,
2879
+ expectedBytes: f.postHash === null ? null : await postBytes(journal, f.postHash),
2880
+ actualBytes: cur === null ? null : Buffer.byteLength(cur, "utf8")
2881
+ });
2882
+ }
2883
+ }
2884
+ if (conflicts.length > 0 && !input.force) {
2885
+ throw new Error(
2886
+ JSON.stringify({
2887
+ error: "undo_conflict",
2888
+ seq: entry.seq,
2889
+ conflicts,
2890
+ hint: "A file changed after this write (by you or another tool). Re-check it, then pass force: true to restore anyway \u2014 the current state is journaled first."
2891
+ })
2892
+ );
2893
+ }
2894
+ const preimages = /* @__PURE__ */ new Map();
2895
+ for (const f of entry.files) {
2896
+ preimages.set(f.path, f.preHash === null ? null : await journal.preimage(f.preHash));
2897
+ }
2898
+ const txn = journal.begin("undo_write", entry.seq);
2899
+ for (const f of entry.files) {
2900
+ await txn.add(f.path, current.get(f.path) ?? null, preimages.get(f.path) ?? null);
2901
+ }
2902
+ const undoEntry = await txn.commit();
2903
+ const restored = [];
2904
+ for (const f of entry.files) {
2905
+ const abs = resolveVaultPath(ctx2.vaultRoot, f.path);
2906
+ const pre = preimages.get(f.path) ?? null;
2907
+ if (pre === null) {
2908
+ await rm3(abs, { force: true });
2909
+ removeNoteBacklinks(ctx2, f.path);
2910
+ if (ctx2.notes.has(f.path)) {
2911
+ ctx2.index.discard(f.path);
2912
+ ctx2.notes.delete(f.path);
2913
+ }
2914
+ restored.push({ path: f.path, restoredHash: null });
2915
+ } else {
2916
+ await mkdir6(dirname4(abs), { recursive: true });
2917
+ await atomicWrite(abs, pre);
2918
+ if (f.path.endsWith(".md")) {
2919
+ removeNoteBacklinks(ctx2, f.path);
2920
+ upsertDoc(ctx2, buildDoc(f.path, pre));
2921
+ addNoteBacklinks(ctx2, f.path, pre);
2922
+ }
2923
+ restored.push({ path: f.path, restoredHash: contentHash(pre) });
2924
+ }
2925
+ }
2926
+ const result = {
2927
+ seq: entry.seq,
2928
+ undoSeq: undoEntry.seq,
2929
+ tool: entry.tool,
2930
+ restored
2931
+ };
2932
+ if (conflicts.length > 0) result.overrode = conflicts;
2933
+ return result;
2934
+ }
2935
+ async function postBytes(journal, hash) {
2936
+ try {
2937
+ return Buffer.byteLength(await journal.preimage(hash), "utf8");
2938
+ } catch {
2939
+ return null;
2940
+ }
2941
+ }
2942
+
2241
2943
  // src/dispatch.ts
2242
2944
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
2243
2945
  "create_note",
@@ -2248,7 +2950,8 @@ var WRITE_TOOLS = /* @__PURE__ */ new Set([
2248
2950
  "patch_frontmatter",
2249
2951
  "patch_note",
2250
2952
  "replace_in_note",
2251
- "append_periodic_note"
2953
+ "append_periodic_note",
2954
+ "undo_write"
2252
2955
  ]);
2253
2956
  var META_KEYS = [
2254
2957
  "path",
@@ -2272,7 +2975,9 @@ var META_KEYS = [
2272
2975
  "date",
2273
2976
  "permanent",
2274
2977
  "prevHash",
2275
- "budgetBytes"
2978
+ "budgetBytes",
2979
+ "seq",
2980
+ "force"
2276
2981
  ];
2277
2982
  function safeMeta(args) {
2278
2983
  if (typeof args !== "object" || args === null) return {};
@@ -2296,7 +3001,7 @@ async function dispatch(ctx2, name, args, log2) {
2296
3001
  log2.debug("tool start", { tool: name, args });
2297
3002
  if (ctx2.policy.readOnly && WRITE_TOOLS.has(name)) {
2298
3003
  log2.info("tool rejected (read-only)", { tool: name, ...meta });
2299
- return {
3004
+ const rejected = {
2300
3005
  content: [
2301
3006
  {
2302
3007
  type: "text",
@@ -2305,17 +3010,39 @@ async function dispatch(ctx2, name, args, log2) {
2305
3010
  ],
2306
3011
  isError: true
2307
3012
  };
3013
+ return audit(ctx2, log2, rejected, {
3014
+ v: 1,
3015
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3016
+ tool: name,
3017
+ outcome: "policy_denied",
3018
+ policy: "read_only",
3019
+ error: "read_only",
3020
+ durationMs: 0,
3021
+ ...pathMeta(meta)
3022
+ });
2308
3023
  }
2309
3024
  if (ctx2.policy.readOnly && name === "get_periodic_note") {
2310
3025
  if (typeof args === "object" && args !== null) {
2311
3026
  args = { ...args, createIfMissing: false };
2312
3027
  }
2313
3028
  }
3029
+ const isWrite = WRITE_TOOLS.has(name) || name === "get_periodic_note";
3030
+ const seqBefore = ctx2.journal?.lastSeq ?? 0;
3031
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
2314
3032
  try {
2315
- const result = await run(ctx2, name, args);
3033
+ const { audit: detail, ...result } = await run(ctx2, name, args);
2316
3034
  const durationMs = Math.round((performance.now() - start) * 100) / 100;
2317
3035
  log2.info("tool ok", { tool: name, durationMs, resultBytes: resultBytes(result), ...meta });
2318
- return result;
3036
+ if (!isWrite || detail === void 0) return result;
3037
+ return audit(ctx2, log2, result, {
3038
+ v: 1,
3039
+ ts,
3040
+ tool: name,
3041
+ outcome: "ok",
3042
+ durationMs,
3043
+ ...journalFiles(ctx2, seqBefore, detail),
3044
+ ...detail
3045
+ });
2319
3046
  } catch (err) {
2320
3047
  const durationMs = Math.round((performance.now() - start) * 100) / 100;
2321
3048
  const message = err instanceof Error ? err.message : String(err);
@@ -2326,7 +3053,66 @@ async function dispatch(ctx2, name, args, log2) {
2326
3053
  stack: err instanceof Error ? err.stack : void 0,
2327
3054
  ...meta
2328
3055
  });
2329
- return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
3056
+ const failed = {
3057
+ content: [{ type: "text", text: `Error: ${message}` }],
3058
+ isError: true
3059
+ };
3060
+ if (!isWrite) return failed;
3061
+ return audit(ctx2, log2, failed, {
3062
+ v: 1,
3063
+ ts,
3064
+ tool: name,
3065
+ ...classifyError(message),
3066
+ durationMs,
3067
+ ...pathMeta(meta)
3068
+ });
3069
+ }
3070
+ }
3071
+ function pathMeta(meta) {
3072
+ const out = {};
3073
+ for (const k of ["path", "from", "to", "seq"])
3074
+ if (meta[k] !== void 0) out[k] = meta[k];
3075
+ return out;
3076
+ }
3077
+ function journalFiles(ctx2, seqBefore, detail) {
3078
+ const entry = ctx2.journal?.since(seqBefore).at(-1);
3079
+ if (entry !== void 0) {
3080
+ return {
3081
+ seq: entry.seq,
3082
+ files: entry.files.map((f) => ({
3083
+ path: f.path,
3084
+ hashBefore: f.preHash,
3085
+ hashAfter: f.postHash
3086
+ }))
3087
+ };
3088
+ }
3089
+ if (typeof detail.path === "string" && typeof detail.contentHash === "string") {
3090
+ return { files: [{ path: detail.path, hashAfter: detail.contentHash }] };
3091
+ }
3092
+ return {};
3093
+ }
3094
+ function audit(ctx2, log2, result, rec) {
3095
+ if (!ctx2.audit) return result;
3096
+ try {
3097
+ ctx2.audit.record(rec);
3098
+ return result;
3099
+ } catch (err) {
3100
+ const message = err instanceof Error ? err.message : String(err);
3101
+ log2.error("audit write failed", { tool: rec.tool, error: message });
3102
+ return {
3103
+ content: [
3104
+ {
3105
+ type: "text",
3106
+ text: `Error: ${JSON.stringify({
3107
+ error: "audit_failed",
3108
+ tool: rec.tool,
3109
+ outcome: rec.outcome,
3110
+ hint: `The audit record could not be written (${message}). The vault operation itself ${rec.outcome === "ok" ? "DID complete" : "did not run"}; fix SEEKSTONE_AUDIT_FILE before continuing.`
3111
+ })}`
3112
+ }
3113
+ ],
3114
+ isError: true
3115
+ };
2330
3116
  }
2331
3117
  }
2332
3118
  async function run(ctx2, name, args) {
@@ -2370,14 +3156,26 @@ async function run(ctx2, name, args) {
2370
3156
  type: "text",
2371
3157
  text: `Created ${result.path} (${result.bytesWritten} bytes). contentHash: ${result.contentHash}`
2372
3158
  }
2373
- ]
3159
+ ],
3160
+ audit: {
3161
+ path: result.path,
3162
+ contentHash: result.contentHash,
3163
+ bytesWritten: result.bytesWritten,
3164
+ overwrite: input.overwrite
3165
+ }
2374
3166
  };
2375
3167
  }
2376
3168
  case "delete_note": {
2377
3169
  const input = DeleteNoteInput.parse(args);
2378
3170
  const result = await deleteNote(ctx2, input);
2379
3171
  const text = result.trashedTo ? `Moved ${result.path} to ${result.trashedTo} (recoverable \u2014 restore by moving it back).` : `Permanently deleted ${result.path}.`;
2380
- return { content: [{ type: "text", text }] };
3172
+ const audit2 = {
3173
+ path: result.path,
3174
+ contentHash: result.contentHash,
3175
+ permanent: result.permanent
3176
+ };
3177
+ if (result.trashedTo !== void 0) audit2.trashedTo = result.trashedTo;
3178
+ return { content: [{ type: "text", text }], audit: audit2 };
2381
3179
  }
2382
3180
  case "move_note": {
2383
3181
  const input = MoveNoteInput.parse(args);
@@ -2386,7 +3184,17 @@ async function run(ctx2, name, args) {
2386
3184
  if (result.skipped?.length) {
2387
3185
  text += ` Skipped (outside SEEKSTONE_WRITE_PATHS): ${result.skipped.join(", ")}.`;
2388
3186
  }
2389
- return { content: [{ type: "text", text }] };
3187
+ return {
3188
+ content: [{ type: "text", text }],
3189
+ audit: {
3190
+ from: result.from,
3191
+ to: result.to,
3192
+ contentHash: result.contentHash,
3193
+ notesRewritten: result.notesRewritten,
3194
+ linksRewritten: result.linksRewritten,
3195
+ ...result.skipped?.length ? { skipped: result.skipped } : {}
3196
+ }
3197
+ };
2390
3198
  }
2391
3199
  case "rename_heading": {
2392
3200
  const input = RenameHeadingInput.parse(args);
@@ -2395,7 +3203,17 @@ async function run(ctx2, name, args) {
2395
3203
  if (result.skipped?.length) {
2396
3204
  text += ` Skipped (outside SEEKSTONE_WRITE_PATHS): ${result.skipped.join(", ")}.`;
2397
3205
  }
2398
- return { content: [{ type: "text", text }] };
3206
+ return {
3207
+ content: [{ type: "text", text }],
3208
+ audit: {
3209
+ path: result.path,
3210
+ contentHash: result.contentHash,
3211
+ line: result.line,
3212
+ notesRewritten: result.notesRewritten,
3213
+ linksRewritten: result.linksRewritten,
3214
+ ...result.skipped?.length ? { skipped: result.skipped } : {}
3215
+ }
3216
+ };
2399
3217
  }
2400
3218
  case "append_note": {
2401
3219
  const input = AppendNoteInput.parse(args);
@@ -2406,13 +3224,28 @@ async function run(ctx2, name, args) {
2406
3224
  type: "text",
2407
3225
  text: `Appended ${result.bytesWritten} bytes to ${result.path}. contentHash: ${result.contentHash}`
2408
3226
  }
2409
- ]
3227
+ ],
3228
+ audit: {
3229
+ path: result.path,
3230
+ contentHash: result.contentHash,
3231
+ bytesWritten: result.bytesWritten
3232
+ }
2410
3233
  };
2411
3234
  }
2412
3235
  case "patch_frontmatter": {
2413
3236
  const input = PatchFrontmatterInput.parse(args);
2414
3237
  const result = await patchFrontmatter(ctx2, input);
2415
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3238
+ return {
3239
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3240
+ // Key NAMES only — values are note content and never leave the tool.
3241
+ audit: {
3242
+ path: result.path,
3243
+ contentHash: result.contentHash,
3244
+ keysChanged: result.keysChanged,
3245
+ keysAdded: result.keysAdded,
3246
+ keysRemoved: result.keysRemoved
3247
+ }
3248
+ };
2416
3249
  }
2417
3250
  case "outline_note": {
2418
3251
  const input = OutlineNoteInput.parse(args);
@@ -2422,7 +3255,16 @@ async function run(ctx2, name, args) {
2422
3255
  case "patch_note": {
2423
3256
  const input = PatchNoteInput.parse(args);
2424
3257
  const result = await patchNote(ctx2, input);
2425
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3258
+ return {
3259
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3260
+ audit: {
3261
+ path: result.path,
3262
+ contentHash: result.contentHash,
3263
+ bytesWritten: result.bytesWritten,
3264
+ operation: input.operation,
3265
+ targetLine: result.targetResolvedAt.line
3266
+ }
3267
+ };
2426
3268
  }
2427
3269
  case "get_backlinks": {
2428
3270
  const input = GetBacklinksInput.parse(args);
@@ -2437,12 +3279,26 @@ async function run(ctx2, name, args) {
2437
3279
  case "replace_in_note": {
2438
3280
  const input = ReplaceInNoteInput.parse(args);
2439
3281
  const result = await replaceInNote(ctx2, input);
2440
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3282
+ return {
3283
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3284
+ audit: {
3285
+ path: result.path,
3286
+ contentHash: result.contentHash,
3287
+ replacements: result.replacements,
3288
+ dryRun: input.dryRun
3289
+ }
3290
+ };
2441
3291
  }
2442
3292
  case "get_periodic_note": {
2443
3293
  const input = GetPeriodicNoteInput.parse(args);
2444
3294
  const result = await getPeriodicNote(ctx2, input);
2445
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3295
+ const out = {
3296
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
3297
+ };
3298
+ if (result.created) {
3299
+ out.audit = { path: result.path, contentHash: result.contentHash, created: true };
3300
+ }
3301
+ return out;
2446
3302
  }
2447
3303
  case "append_periodic_note": {
2448
3304
  const input = AppendPeriodicNoteInput.parse(args);
@@ -2453,7 +3309,29 @@ async function run(ctx2, name, args) {
2453
3309
  type: "text",
2454
3310
  text: `Appended ${result.bytesWritten} bytes to ${result.path}. contentHash: ${result.contentHash}`
2455
3311
  }
2456
- ]
3312
+ ],
3313
+ audit: {
3314
+ path: result.path,
3315
+ contentHash: result.contentHash,
3316
+ bytesWritten: result.bytesWritten
3317
+ }
3318
+ };
3319
+ }
3320
+ case "list_writes": {
3321
+ const input = ListWritesInput.parse(args);
3322
+ const result = listWrites(ctx2, input);
3323
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3324
+ }
3325
+ case "undo_write": {
3326
+ const input = UndoWriteInput.parse(args);
3327
+ const result = await undoWrite(ctx2, input);
3328
+ return {
3329
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3330
+ audit: {
3331
+ undoOf: result.seq,
3332
+ restored: result.restored.length,
3333
+ ...result.overrode ? { forced: true, overrode: result.overrode.length } : {}
3334
+ }
2457
3335
  };
2458
3336
  }
2459
3337
  default:
@@ -2462,12 +3340,12 @@ async function run(ctx2, name, args) {
2462
3340
  }
2463
3341
 
2464
3342
  // src/index/build.ts
2465
- import { readFile as readFile12 } from "fs/promises";
3343
+ import { readFile as readFile14 } from "fs/promises";
2466
3344
  import { basename as basename3, extname as extname4 } from "path";
2467
3345
 
2468
3346
  // ../core/src/walk.ts
2469
- import { stat } from "fs/promises";
2470
- import { extname as extname3, join as join5, relative, sep as sep2 } from "path";
3347
+ import { stat as stat2 } from "fs/promises";
3348
+ import { extname as extname3, join as join6, relative, sep as sep2 } from "path";
2471
3349
  import fg from "fast-glob";
2472
3350
  var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]);
2473
3351
  var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
@@ -2475,6 +3353,7 @@ var AUDIO_EXTS = /* @__PURE__ */ new Set([".mp3", ".m4a", ".wav", ".flac", ".ogg
2475
3353
  var IGNORE_GLOBS = [
2476
3354
  "**/.obsidian/**",
2477
3355
  "**/.trash/**",
3356
+ "**/.seekstone/**",
2478
3357
  "**/.git/**",
2479
3358
  "**/.DS_Store",
2480
3359
  "**/node_modules/**"
@@ -2506,7 +3385,7 @@ async function walkVault(vaultRoot2) {
2506
3385
  const entries = [];
2507
3386
  for (const m of matches2) {
2508
3387
  const absPath = typeof m === "string" ? m : m.path;
2509
- const stats = typeof m === "string" ? await stat(absPath) : m.stats;
3388
+ const stats = typeof m === "string" ? await stat2(absPath) : m.stats;
2510
3389
  if (!stats) continue;
2511
3390
  const relPath = relative(vaultRoot2, absPath).split(sep2).join("/");
2512
3391
  const topDir = relPath.split("/")[0] ?? "";
@@ -2544,7 +3423,7 @@ async function buildIndex(vaultRoot2) {
2544
3423
  await Promise.all(
2545
3424
  noteEntries.map(async (entry) => {
2546
3425
  try {
2547
- const raw = await readFile12(entry.absPath, "utf8");
3426
+ const raw = await readFile14(entry.absPath, "utf8");
2548
3427
  const fm = parseFrontmatter(raw);
2549
3428
  const inlineTags = extractInlineTags(fm.body);
2550
3429
  const fmTags = frontmatterTags(fm.data);
@@ -2597,8 +3476,8 @@ function buildBacklinks(notes2) {
2597
3476
 
2598
3477
  // src/init.ts
2599
3478
  import { execFileSync } from "child_process";
2600
- import { access as access5, copyFile, mkdir as mkdir5, readdir, readFile as readFile13, stat as stat2, writeFile as writeFile2 } from "fs/promises";
2601
- import path, { dirname as dirname4, join as join6 } from "path";
3479
+ import { access as access4, copyFile, mkdir as mkdir7, readdir as readdir2, readFile as readFile15, stat as stat3, writeFile as writeFile2 } from "fs/promises";
3480
+ import path, { dirname as dirname5, join as join7 } from "path";
2602
3481
  function parseInitArgs(argv) {
2603
3482
  const opts = { write: false, client: "desktop" };
2604
3483
  for (let i = 0; i < argv.length; i++) {
@@ -2641,7 +3520,7 @@ function cursorConfigPath(platform, env) {
2641
3520
  return j(env.home ?? "", ".cursor", "mcp.json");
2642
3521
  }
2643
3522
  function vscodeMcpConfigPath(cwd) {
2644
- return join6(cwd, ".vscode", "mcp.json");
3523
+ return join7(cwd, ".vscode", "mcp.json");
2645
3524
  }
2646
3525
  function obsidianRegistryPath(platform, env) {
2647
3526
  if (platform === "win32") {
@@ -2697,13 +3576,13 @@ function mergeSeekstoneConfigVsCode(existing, vaultPath) {
2697
3576
  }
2698
3577
  async function validateVault(vaultPath) {
2699
3578
  try {
2700
- const st = await stat2(vaultPath);
3579
+ const st = await stat3(vaultPath);
2701
3580
  if (!st.isDirectory()) return { ok: false, error: `Not a directory: ${vaultPath}` };
2702
3581
  } catch {
2703
3582
  return { ok: false, error: `Path does not exist: ${vaultPath}` };
2704
3583
  }
2705
3584
  try {
2706
- await access5(join6(vaultPath, ".obsidian"));
3585
+ await access4(join7(vaultPath, ".obsidian"));
2707
3586
  } catch {
2708
3587
  return {
2709
3588
  ok: false,
@@ -2717,13 +3596,13 @@ async function countMarkdown(root) {
2717
3596
  async function walk(dir) {
2718
3597
  let entries;
2719
3598
  try {
2720
- entries = await readdir(dir, { withFileTypes: true });
3599
+ entries = await readdir2(dir, { withFileTypes: true });
2721
3600
  } catch {
2722
3601
  return;
2723
3602
  }
2724
3603
  for (const e of entries) {
2725
3604
  if (e.name.startsWith(".")) continue;
2726
- const full = join6(dir, e.name);
3605
+ const full = join7(dir, e.name);
2727
3606
  if (e.isDirectory()) await walk(full);
2728
3607
  else if (e.name.endsWith(".md")) count++;
2729
3608
  }
@@ -2741,7 +3620,7 @@ async function runInit(opts, deps) {
2741
3620
  ]
2742
3621
  };
2743
3622
  }
2744
- const rf = deps.readFile ?? readFile13;
3623
+ const rf = deps.readFile ?? readFile15;
2745
3624
  let vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
2746
3625
  if (!vaultPath) {
2747
3626
  const registryPath = obsidianRegistryPath(deps.platform, {
@@ -2872,7 +3751,7 @@ async function runInit(opts, deps) {
2872
3751
  }
2873
3752
  const merged = opts.client === "vscode" ? mergeSeekstoneConfigVsCode(existing, vaultPath) : mergeSeekstoneConfig(existing, vaultPath);
2874
3753
  let backupPath;
2875
- await mkdir5(dirname4(configPath), { recursive: true });
3754
+ await mkdir7(dirname5(configPath), { recursive: true });
2876
3755
  if (existingRaw !== null) {
2877
3756
  backupPath = `${configPath}.backup-${deps.timestamp}`;
2878
3757
  await copyFile(configPath, backupPath);
@@ -2888,107 +3767,6 @@ async function runInit(opts, deps) {
2888
3767
  return { ok: true, exitCode: 0, output: out, wrotePath: configPath, backupPath };
2889
3768
  }
2890
3769
 
2891
- // src/log.ts
2892
- import { appendFileSync, renameSync, statSync } from "fs";
2893
- var ORDER = ["error", "warn", "info", "debug"];
2894
- var DEFAULT_MAX_SIZE = 5 * 1024 * 1024;
2895
- function parseLevel(raw, onBad) {
2896
- if (raw === void 0 || raw === "") return "info";
2897
- const v = raw.trim().toLowerCase();
2898
- for (const lvl of ORDER) {
2899
- if (lvl === v) return lvl;
2900
- }
2901
- onBad(`invalid SEEKSTONE_LOG_LEVEL=${JSON.stringify(raw)}; using "info"`);
2902
- return "info";
2903
- }
2904
- function parseSize(raw) {
2905
- if (raw === void 0) return DEFAULT_MAX_SIZE;
2906
- const m = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(raw.trim());
2907
- if (m === null) return DEFAULT_MAX_SIZE;
2908
- const num = Number(m[1]);
2909
- const unit = (m[2] ?? "b").toLowerCase();
2910
- const mult = unit === "gb" ? 1e9 : unit === "mb" ? 1e6 : unit === "kb" ? 1e3 : 1;
2911
- return Math.max(1, Math.floor(num * mult));
2912
- }
2913
- function fmtFields(fields) {
2914
- const parts = [];
2915
- for (const [k, v] of Object.entries(fields)) {
2916
- if (v === void 0) continue;
2917
- parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
2918
- }
2919
- return parts.length > 0 ? ` ${parts.join(" ")}` : "";
2920
- }
2921
- function createLogger(opts = {}) {
2922
- const env = opts.env ?? process.env;
2923
- const stderr = opts.stderr ?? ((line) => void process.stderr.write(line));
2924
- const now = opts.now ?? (() => /* @__PURE__ */ new Date());
2925
- const level = parseLevel(env.SEEKSTONE_LOG_LEVEL, (m) => {
2926
- stderr(`WARN ${now().toISOString()} ${m}
2927
- `);
2928
- });
2929
- const threshold = ORDER.indexOf(level);
2930
- const filePath = env.SEEKSTONE_LOG_FILE;
2931
- const maxSize = parseSize(env.SEEKSTONE_LOG_MAX_SIZE);
2932
- let fileOk = false;
2933
- let bytes = 0;
2934
- if (filePath !== void 0 && filePath !== "") {
2935
- try {
2936
- try {
2937
- bytes = statSync(filePath).size;
2938
- } catch {
2939
- bytes = 0;
2940
- }
2941
- appendFileSync(filePath, "");
2942
- fileOk = true;
2943
- } catch (err) {
2944
- const m = err instanceof Error ? err.message : String(err);
2945
- stderr(
2946
- `WARN ${now().toISOString()} cannot write log file ${filePath}: ${m}; file logging disabled
2947
- `
2948
- );
2949
- }
2950
- }
2951
- function writeFile5(record) {
2952
- if (!fileOk || filePath === void 0) return;
2953
- const line = `${JSON.stringify(record)}
2954
- `;
2955
- const len = Buffer.byteLength(line);
2956
- try {
2957
- if (bytes > 0 && bytes + len > maxSize) {
2958
- renameSync(filePath, `${filePath}.1`);
2959
- bytes = 0;
2960
- }
2961
- appendFileSync(filePath, line);
2962
- bytes += len;
2963
- } catch {
2964
- fileOk = false;
2965
- }
2966
- }
2967
- function emit(lvl, msg, fields) {
2968
- if (ORDER.indexOf(lvl) > threshold) return;
2969
- const ts = now().toISOString();
2970
- const f = fields ?? {};
2971
- stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
2972
- `);
2973
- writeFile5({ t: ts, level: lvl, msg, ...f });
2974
- }
2975
- return {
2976
- level,
2977
- error: (m, f) => {
2978
- emit("error", m, f);
2979
- },
2980
- warn: (m, f) => {
2981
- emit("warn", m, f);
2982
- },
2983
- info: (m, f) => {
2984
- emit("info", m, f);
2985
- },
2986
- debug: (m, f) => {
2987
- emit("debug", m, f);
2988
- }
2989
- };
2990
- }
2991
-
2992
3770
  // src/process-guards.ts
2993
3771
  function installProcessGuards(log2) {
2994
3772
  const onUnhandledRejection = (reason) => {
@@ -3077,9 +3855,83 @@ function countWords(text) {
3077
3855
  return m ? m.length : 0;
3078
3856
  }
3079
3857
 
3858
+ // ../core/src/embed/maxsim.ts
3859
+ function maxsimScoreTokens(query, docs, tokenVector, opts = {}) {
3860
+ const n = query.ids.length;
3861
+ const weights = opts.weights;
3862
+ if (weights !== void 0 && weights.length !== n) {
3863
+ throw new Error(`maxsim: ${weights.length} weights for ${n} query tokens`);
3864
+ }
3865
+ const mean = (opts.aggregate ?? "sum") === "mean";
3866
+ const out = new Array(docs.length).fill(0);
3867
+ if (n === 0) return out;
3868
+ const { dim } = query;
3869
+ const q = query.vectors;
3870
+ const simCache = /* @__PURE__ */ new Map();
3871
+ const best = new Float64Array(n);
3872
+ for (let d = 0; d < docs.length; d++) {
3873
+ const ids = docs[d];
3874
+ const m = ids.length;
3875
+ if (m === 0) continue;
3876
+ best.fill(Number.NEGATIVE_INFINITY);
3877
+ for (let t = 0; t < m; t++) {
3878
+ const id = ids[t];
3879
+ let sims = simCache.get(id);
3880
+ if (sims === void 0) {
3881
+ sims = new Float64Array(n);
3882
+ const vec = tokenVector(id);
3883
+ if (vec.length !== dim) {
3884
+ throw new Error(`maxsim: token vector dim ${vec.length} does not match query dim ${dim}`);
3885
+ }
3886
+ for (let i = 0; i < n; i++) {
3887
+ const qBase = i * dim;
3888
+ let dot = 0;
3889
+ for (let j = 0; j < dim; j++) {
3890
+ dot += q[qBase + j] * vec[j];
3891
+ }
3892
+ sims[i] = dot;
3893
+ }
3894
+ simCache.set(id, sims);
3895
+ }
3896
+ for (let i = 0; i < n; i++) {
3897
+ const s = sims[i];
3898
+ if (s > best[i]) best[i] = s;
3899
+ }
3900
+ }
3901
+ let sum = 0;
3902
+ let weightSum = 0;
3903
+ for (let i = 0; i < n; i++) {
3904
+ const w = weights === void 0 ? 1 : weights[i];
3905
+ sum += w * best[i];
3906
+ weightSum += w;
3907
+ }
3908
+ out[d] = mean ? weightSum > 0 ? sum / weightSum : 0 : sum;
3909
+ }
3910
+ return out;
3911
+ }
3912
+ function candidateSetIdf(queryIds, docs) {
3913
+ const n = docs.length;
3914
+ const docSets = [];
3915
+ for (const doc of docs) {
3916
+ const set = /* @__PURE__ */ new Set();
3917
+ for (let t = 0; t < doc.length; t++) set.add(doc[t]);
3918
+ docSets.push(set);
3919
+ }
3920
+ const out = new Array(queryIds.length);
3921
+ for (let i = 0; i < queryIds.length; i++) {
3922
+ const id = queryIds[i];
3923
+ let df = 0;
3924
+ for (const set of docSets) {
3925
+ if (set.has(id)) df++;
3926
+ }
3927
+ out[i] = Math.log(1 + (n - df + 0.5) / (df + 0.5));
3928
+ }
3929
+ return out;
3930
+ }
3931
+
3080
3932
  // ../core/src/embed/model2vec.ts
3081
- import { readFile as readFile14 } from "fs/promises";
3082
- import { basename as basename4, join as join8 } from "path";
3933
+ import { readFile as readFile16 } from "fs/promises";
3934
+ import { basename as basename4, join as join9 } from "path";
3083
3935
 
3084
3936
  // ../core/src/embed/safetensors.ts
3085
3937
  function readSafetensors(buf) {
@@ -3156,7 +4008,7 @@ function loadWordPieceTokenizer(tokenizerJson) {
3156
4008
  const specialIds = new Set(
3157
4009
  (tj.added_tokens ?? []).filter((t) => t.special).map((t) => t.id)
3158
4010
  );
3159
- function normalize(text) {
4011
+ function normalize2(text) {
3160
4012
  let out = cleanText ? doCleanText(text) : text;
3161
4013
  if (handleCjk) out = padCjkChars(out);
3162
4014
  if (stripAccents) out = out.normalize("NFD").replace(new RegExp("\\p{Mn}", "gu"), "");
@@ -3192,7 +4044,7 @@ function loadWordPieceTokenizer(tokenizerJson) {
3192
4044
  specialIds,
3193
4045
  encode(text) {
3194
4046
  const ids = [];
3195
- for (const word of preTokenize(normalize(text))) {
4047
+ for (const word of preTokenize(normalize2(text))) {
3196
4048
  for (const id of wordPiece(word)) ids.push(id);
3197
4049
  }
3198
4050
  return ids;
@@ -3260,9 +4112,9 @@ function isCjk(cp) {
3260
4112
  var TENSOR_NAME = "embeddings";
3261
4113
  async function loadModel2Vec(modelDir) {
3262
4114
  const [configRaw, tokenizerRaw, weights] = await Promise.all([
3263
- readFile14(join8(modelDir, "config.json"), "utf8"),
3264
- readFile14(join8(modelDir, "tokenizer.json"), "utf8"),
3265
- readFile14(join8(modelDir, "model.safetensors"))
4115
+ readFile16(join9(modelDir, "config.json"), "utf8"),
4116
+ readFile16(join9(modelDir, "tokenizer.json"), "utf8"),
4117
+ readFile16(join9(modelDir, "model.safetensors"))
3266
4118
  ]);
3267
4119
  const config = JSON.parse(configRaw);
3268
4120
  const tokenizer = loadWordPieceTokenizer(JSON.parse(tokenizerRaw));
@@ -3288,20 +4140,27 @@ async function loadModel2Vec(modelDir) {
3288
4140
  );
3289
4141
  }
3290
4142
  const table = tensor.data;
4143
+ function pooledIds(text) {
4144
+ const ids = [];
4145
+ for (const id of tokenizer.encode(text)) {
4146
+ if (tokenizer.specialIds.has(id) && id !== tokenizer.unkId) continue;
4147
+ ids.push(id);
4148
+ }
4149
+ return ids;
4150
+ }
3291
4151
  return {
3292
4152
  id: basename4(modelDir),
3293
4153
  dim,
3294
4154
  embed(text) {
3295
4155
  const out = new Float32Array(dim);
3296
- let pooled = 0;
3297
- for (const id of tokenizer.encode(text)) {
3298
- if (tokenizer.specialIds.has(id) && id !== tokenizer.unkId) continue;
4156
+ const ids = pooledIds(text);
4157
+ for (const id of ids) {
3299
4158
  const base = id * dim;
3300
4159
  for (let j = 0; j < dim; j++) {
3301
4160
  out[j] = out[j] + table[base + j];
3302
4161
  }
3303
- pooled++;
3304
4162
  }
4163
+ const pooled = ids.length;
3305
4164
  if (pooled === 0) return out;
3306
4165
  let sumSquares = 0;
3307
4166
  for (let j = 0; j < dim; j++) {
@@ -3314,27 +4173,121 @@ async function loadModel2Vec(modelDir) {
3314
4173
  for (let j = 0; j < dim; j++) out[j] = out[j] / norm;
3315
4174
  }
3316
4175
  return out;
4176
+ },
4177
+ tokenEmbed(text) {
4178
+ const ids = pooledIds(text);
4179
+ const vectors = new Float32Array(ids.length * dim);
4180
+ for (let t = 0; t < ids.length; t++) {
4181
+ writeNormalizedRow(ids[t], vectors, t * dim);
4182
+ }
4183
+ return { ids, dim, vectors };
4184
+ },
4185
+ tokenIds: pooledIds,
4186
+ tokenVector(id) {
4187
+ if (!Number.isInteger(id) || id < 0 || id >= tokenizer.vocabSize) {
4188
+ throw new Error(`model2vec: token id ${id} outside vocab [0, ${tokenizer.vocabSize})`);
4189
+ }
4190
+ const out = new Float32Array(dim);
4191
+ writeNormalizedRow(id, out, 0);
4192
+ return out;
3317
4193
  }
3318
4194
  };
4195
+ function writeNormalizedRow(id, out, offset) {
4196
+ const base = id * dim;
4197
+ let sumSquares = 0;
4198
+ for (let j = 0; j < dim; j++) {
4199
+ const x = table[base + j];
4200
+ out[offset + j] = x;
4201
+ sumSquares += x * x;
4202
+ }
4203
+ const norm = Math.sqrt(sumSquares);
4204
+ if (norm > 0) {
4205
+ for (let j = 0; j < dim; j++) {
4206
+ out[offset + j] = out[offset + j] / norm;
4207
+ }
4208
+ }
4209
+ }
4210
+ }
4211
+
4212
+ // ../core/src/embed/pooling.ts
4213
+ var PoolAccumulator = class {
4214
+ top = Number.NEGATIVE_INFINITY;
4215
+ second = Number.NEGATIVE_INFINITY;
4216
+ bestChunk = 0;
4217
+ count = 0;
4218
+ /** Σ w_i and Σ s_i·w_i with w_i = exp((s_i − 1) / τ); shift keeps exp() ≤ 1. */
4219
+ sumW = 0;
4220
+ sumSW = 0;
4221
+ invTemp;
4222
+ constructor(pooling) {
4223
+ this.invTemp = typeof pooling === "object" && pooling.kind === "softmax" ? 1 / pooling.temperature : 0;
4224
+ }
4225
+ add(score, chunkIndex) {
4226
+ this.count++;
4227
+ if (score > this.top) {
4228
+ this.second = this.top;
4229
+ this.top = score;
4230
+ this.bestChunk = chunkIndex;
4231
+ } else if (score > this.second) {
4232
+ this.second = score;
4233
+ }
4234
+ if (this.invTemp > 0) {
4235
+ const w = Math.exp((score - 1) * this.invTemp);
4236
+ this.sumW += w;
4237
+ this.sumSW += score * w;
4238
+ }
4239
+ }
4240
+ pool(pooling) {
4241
+ if (this.count === 0) return Number.NEGATIVE_INFINITY;
4242
+ if (pooling === "max") return this.top;
4243
+ if (pooling === "top2mean") {
4244
+ return this.second === Number.NEGATIVE_INFINITY ? this.top : (this.top + this.second) / 2;
4245
+ }
4246
+ if (pooling.kind === "logdiscount") return this.top - pooling.lambda * Math.log(this.count);
4247
+ return this.sumW > 0 ? this.sumSW / this.sumW : this.top;
4248
+ }
4249
+ };
4250
+ function assertValidPooling(p) {
4251
+ const raw = p;
4252
+ if (typeof p === "string") {
4253
+ if (p !== "max" && p !== "top2mean") throw new Error(`pooling: unknown kind "${String(raw)}"`);
4254
+ return;
4255
+ }
4256
+ if (p.kind === "logdiscount") {
4257
+ if (!(p.lambda >= 0))
4258
+ throw new Error(`pooling: logdiscount lambda must be \u2265 0 (got ${p.lambda})`);
4259
+ } else if (p.kind === "softmax") {
4260
+ if (!(p.temperature > 0)) {
4261
+ throw new Error(`pooling: softmax temperature must be > 0 (got ${p.temperature})`);
4262
+ }
4263
+ } else {
4264
+ const kind = typeof raw === "object" && raw !== null ? raw.kind : raw;
4265
+ throw new Error(`pooling: unknown kind "${String(kind)}"`);
4266
+ }
4267
+ }
4268
+
4269
+ // ../core/src/embed/types.ts
4270
+ function isTokenEmbedder(e) {
4271
+ return typeof e.tokenEmbed === "function";
3319
4272
  }
3320
4273
 
3321
4274
  // src/semantic/cache.ts
3322
4275
  import { createHash as createHash2 } from "crypto";
3323
- import { mkdir as mkdir6, readFile as readFile15, rename as rename4, writeFile as writeFile3 } from "fs/promises";
3324
- import { join as join9 } from "path";
4276
+ import { mkdir as mkdir8, readFile as readFile17, rename as rename5, writeFile as writeFile3 } from "fs/promises";
4277
+ import { join as join10 } from "path";
3325
4278
  var CACHE_VERSION = 2;
3326
4279
  function cachePathsFor(cacheDir, vaultRoot2, modelId) {
3327
4280
  const vaultKey = createHash2("sha256").update(vaultRoot2).digest("hex").slice(0, 16);
3328
- const dir = join9(cacheDir, "embeddings", vaultKey);
3329
- return { dir, bin: join9(dir, `${modelId}.bin`), manifest: join9(dir, `${modelId}.json`) };
4281
+ const dir = join10(cacheDir, "embeddings", vaultKey);
4282
+ return { dir, bin: join10(dir, `${modelId}.bin`), manifest: join10(dir, `${modelId}.json`) };
3330
4283
  }
3331
4284
  async function loadCache(paths, modelId, dim) {
3332
4285
  try {
3333
- const manifest = JSON.parse(await readFile15(paths.manifest, "utf8"));
4286
+ const manifest = JSON.parse(await readFile17(paths.manifest, "utf8"));
3334
4287
  if (manifest.version !== CACHE_VERSION || manifest.modelId !== modelId || manifest.dim !== dim) {
3335
4288
  return void 0;
3336
4289
  }
3337
- const buf = await readFile15(paths.bin);
4290
+ const buf = await readFile17(paths.bin);
3338
4291
  const totalChunks = manifest.entries.reduce((a, e) => a + e.chunks, 0);
3339
4292
  const vectorBytes = totalChunks * dim * 4;
3340
4293
  const spanBytes = totalChunks * 2 * 4;
@@ -3384,26 +4337,64 @@ async function saveCache(paths, modelId, dim, notes2, hashes) {
3384
4337
  spanOffset += note.spans.length;
3385
4338
  }
3386
4339
  const manifest = { version: CACHE_VERSION, modelId, dim, entries };
3387
- await mkdir6(paths.dir, { recursive: true });
4340
+ await mkdir8(paths.dir, { recursive: true });
3388
4341
  await atomicWriteBytes(paths.bin, bin);
3389
4342
  await atomicWriteBytes(paths.manifest, new TextEncoder().encode(JSON.stringify(manifest)));
3390
4343
  }
3391
4344
  async function atomicWriteBytes(absPath, bytes) {
3392
4345
  const tmpPath = `${absPath}.seekstone-cache-tmp`;
3393
4346
  await writeFile3(tmpPath, bytes);
3394
- await rename4(tmpPath, absPath);
4347
+ await rename5(tmpPath, absPath);
4348
+ }
4349
+
4350
+ // src/semantic/state.ts
4351
+ init_config();
4352
+
4353
+ // src/semantic/rerank.ts
4354
+ var MAXSIM_BETA = 0.5;
4355
+ var EMPTY = [];
4356
+ function maxsimRerankHits(embedder, query, hits, tokenIdsFor, beta = MAXSIM_BETA) {
4357
+ if (hits.length < 2 || beta <= 0) return [...hits];
4358
+ const qTok = embedder.tokenEmbed(query);
4359
+ if (qTok.ids.length === 0) return [...hits];
4360
+ const docs = hits.map((h) => tokenIdsFor(h) ?? EMPTY);
4361
+ const weights = candidateSetIdf(qTok.ids, docs);
4362
+ const maxsim = maxsimScoreTokens(qTok, docs, (id) => embedder.tokenVector(id), {
4363
+ aggregate: "mean",
4364
+ weights
4365
+ });
4366
+ const stage1 = normalize(hits.map((h) => h.score));
4367
+ const rerank = normalize(maxsim);
4368
+ return hits.map((h, i) => ({
4369
+ ...h,
4370
+ score: (1 - beta) * stage1[i] + beta * rerank[i]
4371
+ })).sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
4372
+ }
4373
+ function normalize(scores) {
4374
+ let min = Number.POSITIVE_INFINITY;
4375
+ let max = Number.NEGATIVE_INFINITY;
4376
+ for (const s of scores) {
4377
+ if (s < min) min = s;
4378
+ if (s > max) max = s;
4379
+ }
4380
+ const span = max - min;
4381
+ return scores.map((s) => span > 0 ? (s - min) / span : 1);
3395
4382
  }
3396
4383
 
3397
4384
  // src/semantic/store.ts
4385
+ var DEFAULT_POOLING = "max";
3398
4386
  var SemanticStore = class {
3399
4387
  dim;
4388
+ pooling;
3400
4389
  notes = /* @__PURE__ */ new Map();
3401
4390
  chunks = 0;
3402
- constructor(dim) {
4391
+ constructor(dim, pooling = DEFAULT_POOLING) {
3403
4392
  if (!Number.isInteger(dim) || dim <= 0) {
3404
4393
  throw new Error(`semantic store: invalid dim ${dim}`);
3405
4394
  }
4395
+ assertValidPooling(pooling);
3406
4396
  this.dim = dim;
4397
+ this.pooling = pooling;
3407
4398
  }
3408
4399
  /** Total chunk vectors across all notes. */
3409
4400
  get chunkCount() {
@@ -3443,38 +4434,55 @@ var SemanticStore = class {
3443
4434
  entries() {
3444
4435
  return this.notes.entries();
3445
4436
  }
3446
- /** Top `k` notes by max-pooled chunk cosine, ties broken path-ascending. */
4437
+ /**
4438
+ * Top `k` notes by pooled chunk cosine, ties broken path-ascending. The
4439
+ * returned chunk/span is always the note's single best chunk, whatever
4440
+ * pooling ranked the note.
4441
+ */
3447
4442
  topNotes(query, k) {
3448
4443
  if (query.length !== this.dim) {
3449
4444
  throw new Error(`semantic store: query dim ${query.length} does not match ${this.dim}`);
3450
4445
  }
3451
- const dim = this.dim;
3452
4446
  const hits = [];
3453
- for (const [path2, { packed, spans }] of this.notes) {
3454
- let best = Number.NEGATIVE_INFINITY;
3455
- let bestChunk = 0;
3456
- const n = packed.length / dim;
3457
- for (let c = 0; c < n; c++) {
3458
- const base = c * dim;
3459
- let score = 0;
3460
- for (let j = 0; j < dim; j++) {
3461
- score += packed[base + j] * query[j];
3462
- }
3463
- if (score > best) {
3464
- best = score;
3465
- bestChunk = c;
3466
- }
3467
- }
3468
- hits.push({
3469
- path: path2,
3470
- score: best,
3471
- chunkIndex: bestChunk,
3472
- start: spans[bestChunk * 2],
3473
- end: spans[bestChunk * 2 + 1]
3474
- });
4447
+ for (const [path2, vectors] of this.notes) {
4448
+ hits.push(this.scoreVectors(path2, vectors, query));
3475
4449
  }
3476
4450
  return hits.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)).slice(0, Math.max(0, k));
3477
4451
  }
4452
+ /**
4453
+ * Score a single note's cached chunk vectors against the query (SHA-315
4454
+ * graph expansion gates candidates on this). Same pooling and best-chunk
4455
+ * span as topNotes; undefined when the note has no vectors.
4456
+ */
4457
+ scoreNote(path2, query) {
4458
+ if (query.length !== this.dim) {
4459
+ throw new Error(`semantic store: query dim ${query.length} does not match ${this.dim}`);
4460
+ }
4461
+ const vectors = this.notes.get(path2);
4462
+ return vectors === void 0 ? void 0 : this.scoreVectors(path2, vectors, query);
4463
+ }
4464
+ scoreVectors(path2, { packed, spans }, query) {
4465
+ const dim = this.dim;
4466
+ const pooling = this.pooling;
4467
+ const acc = new PoolAccumulator(pooling);
4468
+ const n = packed.length / dim;
4469
+ for (let c = 0; c < n; c++) {
4470
+ const base = c * dim;
4471
+ let score = 0;
4472
+ for (let j = 0; j < dim; j++) {
4473
+ score += packed[base + j] * query[j];
4474
+ }
4475
+ acc.add(score, c);
4476
+ }
4477
+ const bestChunk = acc.bestChunk;
4478
+ return {
4479
+ path: path2,
4480
+ score: acc.pool(pooling),
4481
+ chunkIndex: bestChunk,
4482
+ start: spans[bestChunk * 2],
4483
+ end: spans[bestChunk * 2 + 1]
4484
+ };
4485
+ }
3478
4486
  };
3479
4487
 
3480
4488
  // src/semantic/state.ts
@@ -3489,11 +4497,21 @@ var Semantic = class _Semantic {
3489
4497
  saveDebounceMs;
3490
4498
  yieldEvery;
3491
4499
  hashes = /* @__PURE__ */ new Map();
4500
+ /**
4501
+ * Lazy per-note chunk token ids for the MaxSim rerank (SHA-314): filled on
4502
+ * first rerank touch, dropped whenever the note changes or is removed.
4503
+ * Only queried candidates are ever tokenized, so the memo stays a small
4504
+ * fraction of the vault; it lives in memory only — the cache file format
4505
+ * is untouched.
4506
+ */
4507
+ tokenIdCache = /* @__PURE__ */ new Map();
4508
+ tokenEmbedder;
3492
4509
  pending = /* @__PURE__ */ new Map();
3493
4510
  saveTimer;
3494
4511
  stopped = false;
3495
4512
  constructor(embedder, ctx2, cfg, deps) {
3496
4513
  this.embedder = embedder;
4514
+ this.tokenEmbedder = isTokenEmbedder(embedder) ? embedder : void 0;
3497
4515
  this.store = new SemanticStore(embedder.dim);
3498
4516
  this.ctx = ctx2;
3499
4517
  this.paths = cachePathsFor(cfg.cacheDir, ctx2.vaultRoot, embedder.id);
@@ -3515,7 +4533,7 @@ var Semantic = class _Semantic {
3515
4533
  } catch (err) {
3516
4534
  const reason = err instanceof Error ? err.message : String(err);
3517
4535
  throw new Error(
3518
- `semantic search: could not load the embedding model from ${cfg.modelDir} \u2014 run \`npx -y seekstone fetch-model\` to download it, or point SEEKSTONE_MODEL_PATH at a Model2Vec model directory (${reason})`
4536
+ `semantic search: could not load the embedding model ${cfg.modelId} from ${cfg.modelDir} \u2014 run \`${fetchCommandFor(cfg.modelId)}\` to download it, or point SEEKSTONE_MODEL_PATH at a Model2Vec model directory (${reason})`
3519
4537
  );
3520
4538
  }
3521
4539
  const semantic = new _Semantic(embedder, ctx2, cfg, deps);
@@ -3590,9 +4608,35 @@ var Semantic = class _Semantic {
3590
4608
  embedQuery(query) {
3591
4609
  return this.embedder.embed(query);
3592
4610
  }
4611
+ /**
4612
+ * MaxSim-rerank stage-1 candidates (SHA-314). Pass-through when the
4613
+ * embedder exposes no token vectors (alternative runtimes) — semantic
4614
+ * search then ships stage-1 order unchanged.
4615
+ */
4616
+ rerank(query, hits) {
4617
+ const embedder = this.tokenEmbedder;
4618
+ if (!embedder) return [...hits];
4619
+ return maxsimRerankHits(embedder, query, hits, (hit) => {
4620
+ const ids = this.chunkTokenIds(hit.path);
4621
+ return ids?.[hit.chunkIndex];
4622
+ });
4623
+ }
4624
+ chunkTokenIds(path2) {
4625
+ const cached = this.tokenIdCache.get(path2);
4626
+ if (cached) return cached;
4627
+ const embedder = this.tokenEmbedder;
4628
+ const note = this.ctx.notes.get(path2);
4629
+ if (!embedder || !note) return void 0;
4630
+ const ids = chunkNote(note.title, note.body).map(
4631
+ (chunk) => Uint32Array.from(embedder.tokenIds(chunk.text))
4632
+ );
4633
+ this.tokenIdCache.set(path2, ids);
4634
+ return ids;
4635
+ }
3593
4636
  /** Watcher hook: a note was added or changed (ctx.notes is already fresh). */
3594
4637
  noteChanged(path2) {
3595
4638
  if (this.stopped) return;
4639
+ this.tokenIdCache.delete(path2);
3596
4640
  const existing = this.pending.get(path2);
3597
4641
  if (existing) clearTimeout(existing);
3598
4642
  const timer = setTimeout(() => {
@@ -3609,6 +4653,7 @@ var Semantic = class _Semantic {
3609
4653
  clearTimeout(existing);
3610
4654
  this.pending.delete(path2);
3611
4655
  }
4656
+ this.tokenIdCache.delete(path2);
3612
4657
  if (this.hashes.has(path2) || this.store.getNote(path2)) {
3613
4658
  this.store.removeNote(path2);
3614
4659
  this.hashes.delete(path2);
@@ -4178,6 +5223,42 @@ var ALL_TOOLS = [
4178
5223
  },
4179
5224
  required: ["content"]
4180
5225
  }
5226
+ },
5227
+ {
5228
+ name: "list_writes",
5229
+ annotations: READ_ONLY_ANNOTATIONS,
5230
+ description: "List recent writes from the journal \u2014 every write tool records the pre-image of each file it touches, so any of them can be reverted with undo_write. Returns compact metadata rows (seq, timestamp, tool, paths, undoable), never note content.",
5231
+ inputSchema: {
5232
+ type: "object",
5233
+ properties: {
5234
+ limit: {
5235
+ type: "number",
5236
+ description: "Max entries to return, newest first (1\u2013200, default 20)."
5237
+ },
5238
+ path: {
5239
+ type: "string",
5240
+ description: "Only entries that touched this vault-relative path."
5241
+ }
5242
+ }
5243
+ }
5244
+ },
5245
+ {
5246
+ name: "undo_write",
5247
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
5248
+ description: "Revert a journaled write: restores every file it touched to its byte-identical pre-write state (a multi-file move or heading rename is restored whole; a delete is restored even if it was permanent). Defaults to the most recent undoable write. Refuses with undo_conflict if a file changed since that write, unless force: true. The undo is itself journaled \u2014 undo it to redo.",
5249
+ inputSchema: {
5250
+ type: "object",
5251
+ properties: {
5252
+ seq: {
5253
+ type: "number",
5254
+ description: "Journal entry to revert (from list_writes). Defaults to the most recent undoable write."
5255
+ },
5256
+ force: {
5257
+ type: "boolean",
5258
+ description: "Restore even if a file changed after the journaled write; the clobbered state is journaled first so nothing is lost. Defaults to false."
5259
+ }
5260
+ }
5261
+ }
4181
5262
  }
4182
5263
  ];
4183
5264
  function visibleTools(policy2) {
@@ -4187,11 +5268,30 @@ function visibleTools(policy2) {
4187
5268
 
4188
5269
  // src/watcher.ts
4189
5270
  import { realpathSync } from "fs";
4190
- import { readFile as readFile16 } from "fs/promises";
4191
- import { join as join10, relative as relative2, sep as sep3 } from "path";
5271
+ import { readFile as readFile18 } from "fs/promises";
5272
+ import { join as join11, relative as relative2, sep as sep3 } from "path";
4192
5273
  import chokidar from "chokidar";
5274
+ var DEFAULT_POLL_INTERVAL_MS = 1e4;
5275
+ function resolvePollInterval(optValue, envValue, log2) {
5276
+ if (optValue !== void 0) return optValue;
5277
+ if (envValue === void 0 || envValue === "") return DEFAULT_POLL_INTERVAL_MS;
5278
+ const parsed = Number(envValue);
5279
+ if (!Number.isInteger(parsed) || parsed <= 0) {
5280
+ log2?.warn("invalid SEEKSTONE_WATCH_POLL_INTERVAL, using default", {
5281
+ value: envValue,
5282
+ defaultMs: DEFAULT_POLL_INTERVAL_MS
5283
+ });
5284
+ return DEFAULT_POLL_INTERVAL_MS;
5285
+ }
5286
+ return parsed;
5287
+ }
4193
5288
  function startWatcher(ctx2, log2, opts) {
4194
5289
  const usePolling = opts?.usePolling ?? process.env.SEEKSTONE_WATCH_POLL === "1";
5290
+ const pollInterval = resolvePollInterval(
5291
+ opts?.pollInterval,
5292
+ process.env.SEEKSTONE_WATCH_POLL_INTERVAL,
5293
+ log2
5294
+ );
4195
5295
  let vaultRoot2 = ctx2.vaultRoot;
4196
5296
  try {
4197
5297
  vaultRoot2 = realpathSync(ctx2.vaultRoot);
@@ -4200,7 +5300,7 @@ function startWatcher(ctx2, log2, opts) {
4200
5300
  const toRel = (abs) => relative2(vaultRoot2, abs).split(sep3).join("/");
4201
5301
  async function upsert(relPath, op) {
4202
5302
  try {
4203
- const raw = await readFile16(join10(vaultRoot2, relPath), "utf8");
5303
+ const raw = await readFile18(join11(vaultRoot2, relPath), "utf8");
4204
5304
  removeNoteBacklinks(ctx2, relPath);
4205
5305
  upsertDoc(ctx2, buildDoc(relPath, raw));
4206
5306
  addNoteBacklinks(ctx2, relPath, raw);
@@ -4227,8 +5327,8 @@ function startWatcher(ctx2, log2, opts) {
4227
5327
  // The startup index build already covers existing files.
4228
5328
  ignoreInitial: true,
4229
5329
  usePolling,
4230
- interval: 50,
4231
- binaryInterval: 100,
5330
+ interval: pollInterval,
5331
+ binaryInterval: pollInterval * 2,
4232
5332
  // Skip dot-dirs (.obsidian, .git, .trash, …) regardless of where the vault
4233
5333
  // lives — matched on the path relative to the vault root.
4234
5334
  ignored: (p) => {
@@ -4254,7 +5354,7 @@ function startWatcher(ctx2, log2, opts) {
4254
5354
  }
4255
5355
 
4256
5356
  // src/index.ts
4257
- var VERSION = true ? "0.15.2" : "0.0.0-dev";
5357
+ var VERSION = true ? "0.17.0" : "0.0.0-dev";
4258
5358
  var intent = parseCliIntent(process.argv.slice(2));
4259
5359
  if (intent === "version") {
4260
5360
  process.stdout.write(`${VERSION}
@@ -4285,7 +5385,11 @@ if (intent === "init") {
4285
5385
  if (intent === "fetch-model") {
4286
5386
  const { runFetchModel: runFetchModel2 } = await Promise.resolve().then(() => (init_fetch_model(), fetch_model_exports));
4287
5387
  const { homedir: homedir2 } = await import("os");
4288
- const result = await runFetchModel2({ env: process.env, homedir: homedir2() });
5388
+ const result = await runFetchModel2({
5389
+ env: process.env,
5390
+ homedir: homedir2(),
5391
+ argv: process.argv.slice(3)
5392
+ });
4289
5393
  process.stdout.write(`${result.output.join("\n")}
4290
5394
  `);
4291
5395
  process.exit(result.exitCode);
@@ -4305,6 +5409,33 @@ log.info("building index", { vault: vaultRoot });
4305
5409
  var { index, notes, backlinks, buildMs } = await buildIndex(vaultRoot);
4306
5410
  log.info("index ready", { notes: notes.size, buildMs });
4307
5411
  var ctx = { vaultRoot, index, notes, backlinks, policy };
5412
+ var journalCfg = resolveJournalConfig(process.env);
5413
+ if (journalCfg && !policy.readOnly) {
5414
+ try {
5415
+ ctx.journal = await Journal.open(vaultRoot, journalCfg, { log });
5416
+ log.info("write journal ready", {
5417
+ entries: ctx.journal.list({ limit: 1 }).total,
5418
+ maxBytes: journalCfg.maxBytes
5419
+ });
5420
+ } catch (err) {
5421
+ log.error(`write journal failed to open: ${err instanceof Error ? err.message : String(err)}`);
5422
+ process.exit(1);
5423
+ }
5424
+ } else if (!journalCfg) {
5425
+ log.info("write journal disabled", { env: "SEEKSTONE_HISTORY" });
5426
+ }
5427
+ var auditCfg = resolveAuditConfig(process.env);
5428
+ if (auditCfg) {
5429
+ try {
5430
+ ctx.audit = AuditLog.open(auditCfg);
5431
+ log.info("audit log ready", { file: auditCfg.path, maxBytes: auditCfg.maxBytes });
5432
+ } catch (err) {
5433
+ log.error(
5434
+ `audit log ${auditCfg.path} is not writable: ${err instanceof Error ? err.message : String(err)}`
5435
+ );
5436
+ process.exit(1);
5437
+ }
5438
+ }
4308
5439
  var semanticCfg = resolveSemanticConfig(process.env, homedir());
4309
5440
  if (semanticCfg) {
4310
5441
  try {