seekstone 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -175,6 +436,7 @@ Optional environment:
175
436
  SEEKSTONE_WATCH_POLL Set to 1 to poll for changes (network drives, WSL)
176
437
  SEEKSTONE_WATCH_POLL_INTERVAL Poll interval in ms (default 10000)
177
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)
178
440
  SEEKSTONE_MODEL_PATH Override the embedding-model directory
179
441
  SEEKSTONE_CACHE_DIR Override the cache root (default ~/.cache/seekstone)
180
442
 
@@ -204,7 +466,7 @@ Docs: https://github.com/shaqmughal/seekstone`;
204
466
  }
205
467
 
206
468
  // src/tools/append_note.ts
207
- import { readFile } from "fs/promises";
469
+ import { readFile as readFile2 } from "fs/promises";
208
470
 
209
471
  // ../core/src/frontmatter.ts
210
472
  import { parse as parseYaml } from "yaml";
@@ -300,6 +562,271 @@ function assertHashMatch(raw, prevHash, path2) {
300
562
  }
301
563
  }
302
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
+
303
830
  // src/policy.ts
304
831
  import picomatch from "picomatch";
305
832
  function parseWritePolicy(env) {
@@ -352,7 +879,7 @@ var AppendNoteInput = z.object({
352
879
  async function appendNote(ctx2, input) {
353
880
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
354
881
  assertWritable(ctx2.policy, input.path);
355
- const original = await readFile(absPath, "utf8");
882
+ const original = await readFile2(absPath, "utf8");
356
883
  if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
357
884
  const fm = parseFrontmatter(original);
358
885
  const body = fm.body.endsWith("\n") ? fm.body : `${fm.body}
@@ -361,6 +888,7 @@ async function appendNote(ctx2, input) {
361
888
  const newBody = `${body}${separator}${input.content}`;
362
889
  const header = original.slice(0, fm.bodyStart);
363
890
  const newContent = `${header}${newBody}`;
891
+ await journalWrite(ctx2, "append_note", input.path, original, newContent);
364
892
  await atomicWrite(absPath, newContent);
365
893
  const cached = ctx2.notes.get(input.path);
366
894
  if (cached) {
@@ -611,7 +1139,10 @@ function lexicalSearch(ctx2, input) {
611
1139
  }
612
1140
  function semanticSearch(ctx2, semantic, input) {
613
1141
  const queryVec = semantic.embedQuery(input.query);
614
- const candidates = semantic.store.topNotes(queryVec, SEMANTIC_CANDIDATES);
1142
+ const candidates = semantic.rerank(
1143
+ input.query,
1144
+ semantic.store.topNotes(queryVec, SEMANTIC_CANDIDATES)
1145
+ );
615
1146
  const terms = searchTerms(input.query);
616
1147
  const hits = [];
617
1148
  for (const c of candidates) {
@@ -839,7 +1370,7 @@ function contextPack(ctx2, input) {
839
1370
  }
840
1371
 
841
1372
  // src/tools/create_note.ts
842
- import { access, mkdir, readFile as readFile2 } from "fs/promises";
1373
+ import { access, mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
843
1374
  import { dirname } from "path";
844
1375
  import { stringify as stringifyYaml } from "yaml";
845
1376
  import { z as z4 } from "zod";
@@ -888,6 +1419,7 @@ async function createNote(ctx2, rawInput) {
888
1419
  const input = CreateNoteInput.parse(rawInput);
889
1420
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
890
1421
  assertWritable(ctx2.policy, input.path);
1422
+ let existing = null;
891
1423
  if (!input.overwrite) {
892
1424
  try {
893
1425
  await access(absPath);
@@ -895,22 +1427,26 @@ async function createNote(ctx2, rawInput) {
895
1427
  } catch (err) {
896
1428
  if (err.code !== "ENOENT") throw err;
897
1429
  }
898
- } else if (input.prevHash !== void 0) {
899
- let existing;
1430
+ } else {
900
1431
  try {
901
- existing = await readFile2(absPath, "utf8");
902
- } catch {
903
- throw new Error(
904
- JSON.stringify({
905
- error: "hash_conflict",
906
- path: input.path,
907
- expected: input.prevHash,
908
- actual: null,
909
- hint: "The note no longer exists on disk. Re-check before overwriting."
910
- })
911
- );
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);
912
1449
  }
913
- assertHashMatch(existing, input.prevHash, input.path);
914
1450
  }
915
1451
  let raw = "";
916
1452
  if (input.frontmatter && Object.keys(input.frontmatter).length > 0) {
@@ -919,7 +1455,8 @@ ${stringifyYaml(input.frontmatter, { lineWidth: 0 })}---
919
1455
  `;
920
1456
  }
921
1457
  raw += input.content ?? "";
922
- await mkdir(dirname(absPath), { recursive: true });
1458
+ await mkdir2(dirname(absPath), { recursive: true });
1459
+ await journalWrite(ctx2, "create_note", input.path, existing, raw);
923
1460
  await atomicWrite(absPath, raw);
924
1461
  upsertDoc(ctx2, buildDoc(input.path, raw));
925
1462
  return {
@@ -930,8 +1467,8 @@ ${stringifyYaml(input.frontmatter, { lineWidth: 0 })}---
930
1467
  }
931
1468
 
932
1469
  // src/tools/delete_note.ts
933
- import { access as access2, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm } from "fs/promises";
934
- 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";
935
1472
  import { z as z5 } from "zod";
936
1473
 
937
1474
  // src/index/backlinks.ts
@@ -982,7 +1519,7 @@ async function freeTrashPath(vaultRoot2, noteBase) {
982
1519
  let candidate = `.trash/${noteBase}`;
983
1520
  for (; ; ) {
984
1521
  try {
985
- await access2(join(vaultRoot2, candidate));
1522
+ await access2(join2(vaultRoot2, candidate));
986
1523
  } catch {
987
1524
  return candidate;
988
1525
  }
@@ -993,15 +1530,16 @@ async function deleteNote(ctx2, rawInput) {
993
1530
  const input = DeleteNoteInput.parse(rawInput);
994
1531
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
995
1532
  assertWritable(ctx2.policy, input.path);
996
- const raw = await readFile3(absPath, "utf8");
1533
+ const raw = await readFile4(absPath, "utf8");
997
1534
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
1535
+ await journalWrite(ctx2, "delete_note", input.path, raw, null);
998
1536
  let trashedTo;
999
1537
  if (input.permanent) {
1000
- await rm(absPath);
1538
+ await rm2(absPath);
1001
1539
  } else {
1002
- await mkdir2(join(ctx2.vaultRoot, ".trash"), { recursive: true });
1540
+ await mkdir3(join2(ctx2.vaultRoot, ".trash"), { recursive: true });
1003
1541
  trashedTo = await freeTrashPath(ctx2.vaultRoot, basename2(input.path));
1004
- await rename2(absPath, join(ctx2.vaultRoot, trashedTo));
1542
+ await rename3(absPath, join2(ctx2.vaultRoot, trashedTo));
1005
1543
  }
1006
1544
  removeNoteBacklinks(ctx2, input.path);
1007
1545
  if (ctx2.notes.has(input.path)) {
@@ -1139,10 +1677,24 @@ function listTags(ctx2, input) {
1139
1677
  return { tags, total: tags.length };
1140
1678
  }
1141
1679
 
1142
- // src/tools/move_note.ts
1143
- import { access as access3, mkdir as mkdir3, readFile as readFile4, rename as rename3 } from "fs/promises";
1144
- import { dirname as dirname2, join as join2 } from "path";
1680
+ // src/tools/list_writes.ts
1145
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";
1146
1698
 
1147
1699
  // src/tools/rewrite_links.ts
1148
1700
  import { posix } from "path";
@@ -1237,14 +1789,14 @@ function hasMarkdownLinkTo(raw, sourcePath, targetPath) {
1237
1789
  }
1238
1790
 
1239
1791
  // src/tools/move_note.ts
1240
- var MoveNoteInput = z10.object({
1241
- from: z10.string().describe("Vault-relative path of the note to move."),
1242
- to: z10.string().describe("Vault-relative destination path."),
1243
- overwrite: z10.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false."),
1244
- 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(
1245
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."
1246
1798
  ),
1247
- prevHash: z10.string().optional().describe(
1799
+ prevHash: z11.string().optional().describe(
1248
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."
1249
1801
  )
1250
1802
  });
@@ -1256,20 +1808,19 @@ async function moveNote(ctx2, rawInput) {
1256
1808
  assertWritable(ctx2.policy, input.to);
1257
1809
  let raw;
1258
1810
  try {
1259
- raw = await readFile4(absFrom, "utf8");
1811
+ raw = await readFile5(absFrom, "utf8");
1260
1812
  } catch {
1261
1813
  throw new Error(`Note not found: ${input.from}`);
1262
1814
  }
1263
1815
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.from);
1264
- if (!input.overwrite) {
1265
- try {
1266
- await access3(absTo);
1267
- throw new Error(
1268
- `Destination already exists: ${input.to}. Pass overwrite: true to replace it.`
1269
- );
1270
- } catch (err) {
1271
- if (err.code !== "ENOENT") throw err;
1272
- }
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.`);
1273
1824
  }
1274
1825
  const preNotes = new Map(ctx2.notes);
1275
1826
  const candidates = /* @__PURE__ */ new Set();
@@ -1281,20 +1832,13 @@ async function moveNote(ctx2, rawInput) {
1281
1832
  }
1282
1833
  candidates.delete(input.from);
1283
1834
  }
1284
- await mkdir3(dirname2(absTo), { recursive: true });
1285
- await rename3(absFrom, absTo);
1286
- removeNoteBacklinks(ctx2, input.from);
1287
- if (ctx2.notes.has(input.from)) {
1288
- ctx2.index.discard(input.from);
1289
- ctx2.notes.delete(input.from);
1290
- }
1291
- upsertDoc(ctx2, buildDoc(input.to, raw));
1292
- addNoteBacklinks(ctx2, input.to, raw);
1293
- let notesRewritten = 0;
1294
- 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 = [];
1295
1839
  const skipped = [];
1296
1840
  for (const path2 of [...candidates].sort()) {
1297
- const doc = ctx2.notes.get(path2);
1841
+ const doc = preNotes.get(path2);
1298
1842
  if (doc === void 0) continue;
1299
1843
  if (!isWritable(ctx2.policy, path2)) {
1300
1844
  skipped.push(path2);
@@ -1306,11 +1850,34 @@ async function moveNote(ctx2, rawInput) {
1306
1850
  input.from,
1307
1851
  input.to,
1308
1852
  preNotes,
1309
- ctx2.notes
1853
+ postNotes
1310
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;
1311
1878
  removeNoteBacklinks(ctx2, path2);
1312
1879
  if (count > 0) {
1313
- await atomicWrite(join2(ctx2.vaultRoot, path2), content);
1880
+ await atomicWrite(join3(ctx2.vaultRoot, path2), content);
1314
1881
  upsertDoc(ctx2, buildDoc(path2, content));
1315
1882
  notesRewritten++;
1316
1883
  linksRewritten += count;
@@ -1332,7 +1899,7 @@ async function moveNote(ctx2, rawInput) {
1332
1899
  }
1333
1900
 
1334
1901
  // src/tools/outline_note.ts
1335
- import { readFile as readFile5 } from "fs/promises";
1902
+ import { readFile as readFile6 } from "fs/promises";
1336
1903
 
1337
1904
  // ../core/src/outline.ts
1338
1905
  var HEADING_RE = /^(#{1,6})\s+(.+)/;
@@ -1382,15 +1949,15 @@ function buildOutline(raw, options = {}) {
1382
1949
  }
1383
1950
 
1384
1951
  // src/tools/outline_note.ts
1385
- import { z as z11 } from "zod";
1386
- var OutlineNoteInput = z11.object({
1387
- path: z11.string().describe("Vault-relative path to the note."),
1388
- includeBlocks: z11.boolean().default(true).describe("Include block-reference anchors (^id). Default true."),
1389
- 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.")
1390
1957
  });
1391
1958
  async function outlineNote(ctx2, input) {
1392
1959
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1393
- const raw = await readFile5(absPath, "utf8");
1960
+ const raw = await readFile6(absPath, "utf8");
1394
1961
  return buildOutline(raw, {
1395
1962
  includeBlocks: input.includeBlocks,
1396
1963
  includeSizes: input.includeSizes
@@ -1398,22 +1965,22 @@ async function outlineNote(ctx2, input) {
1398
1965
  }
1399
1966
 
1400
1967
  // src/tools/patch_frontmatter.ts
1401
- import { readFile as readFile6 } from "fs/promises";
1968
+ import { readFile as readFile7 } from "fs/promises";
1402
1969
  import { parseDocument as parseDocument2 } from "yaml";
1403
- import { z as z12 } from "zod";
1404
- var PatchFrontmatterInput = z12.object({
1405
- path: z12.string().describe("Vault-relative path to the note."),
1406
- 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(
1407
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."
1408
1975
  ),
1409
- prevHash: z12.string().optional().describe(
1976
+ prevHash: z13.string().optional().describe(
1410
1977
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
1411
1978
  )
1412
1979
  });
1413
1980
  async function patchFrontmatter(ctx2, input) {
1414
1981
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1415
1982
  assertWritable(ctx2.policy, input.path);
1416
- const original = await readFile6(absPath, "utf8");
1983
+ const original = await readFile7(absPath, "utf8");
1417
1984
  if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, input.path);
1418
1985
  const fm = parseFrontmatter(original);
1419
1986
  const keysChanged = [];
@@ -1456,6 +2023,7 @@ ${original}`;
1456
2023
  const tail = opensWithCRLF ? "---\r\n" : "---\n";
1457
2024
  newContent = `${head}${doc.toString({ lineWidth: 0 })}${tail}${fm.body}`;
1458
2025
  }
2026
+ await journalWrite(ctx2, "patch_frontmatter", input.path, original, newContent);
1459
2027
  await atomicWrite(absPath, newContent);
1460
2028
  const cached = ctx2.notes.get(input.path);
1461
2029
  if (cached) {
@@ -1473,22 +2041,22 @@ ${original}`;
1473
2041
  }
1474
2042
 
1475
2043
  // src/tools/patch_note.ts
1476
- import { readFile as readFile7 } from "fs/promises";
1477
- import { z as z13 } from "zod";
1478
- var PatchNoteInput = z13.object({
1479
- path: z13.string().describe("Vault-relative path to the note."),
1480
- target: z13.union([
1481
- z13.object({ heading: z13.string().describe("Heading text (without # markers).") }),
1482
- 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).") })
1483
2051
  ]).describe("Target section. Provide exactly one of heading or block."),
1484
- operation: z13.enum(["append", "prepend", "replace"]).describe(
2052
+ operation: z14.enum(["append", "prepend", "replace"]).describe(
1485
2053
  "append: insert after section content. prepend: insert after heading line. replace: swap section content."
1486
2054
  ),
1487
- content: z13.string().min(1).describe("Content to insert or replace with."),
1488
- 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(
1489
2057
  "If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets."
1490
2058
  ),
1491
- prevHash: z13.string().optional().describe(
2059
+ prevHash: z14.string().optional().describe(
1492
2060
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
1493
2061
  )
1494
2062
  });
@@ -1504,7 +2072,7 @@ function lineStart(raw, offset) {
1504
2072
  async function patchNote(ctx2, input) {
1505
2073
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1506
2074
  assertWritable(ctx2.policy, input.path);
1507
- const raw = await readFile7(absPath, "utf8");
2075
+ const raw = await readFile8(absPath, "utf8");
1508
2076
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
1509
2077
  const fm = parseFrontmatter(raw);
1510
2078
  const originalFmRegion = raw.slice(0, fm.bodyStart);
@@ -1522,6 +2090,7 @@ async function patchNote(ctx2, input) {
1522
2090
  const newContent2 = `${raw}${sep4}## ${targetText}
1523
2091
 
1524
2092
  ${input.content}${contentNl}`;
2093
+ await journalWrite(ctx2, "patch_note", input.path, raw, newContent2);
1525
2094
  await atomicWrite(absPath, newContent2);
1526
2095
  await verifyFrontmatter(absPath, originalFmRegion);
1527
2096
  updateCache(ctx2, input.path, newContent2, fm.bodyStart);
@@ -1592,6 +2161,7 @@ ${input.content}${contentNl}`;
1592
2161
  default:
1593
2162
  throw new Error(`Unknown operation: ${String(input.operation)}`);
1594
2163
  }
2164
+ await journalWrite(ctx2, "patch_note", input.path, raw, newContent);
1595
2165
  await atomicWrite(absPath, newContent);
1596
2166
  await verifyFrontmatter(absPath, originalFmRegion);
1597
2167
  updateCache(ctx2, input.path, newContent, fm.bodyStart);
@@ -1604,7 +2174,7 @@ ${input.content}${contentNl}`;
1604
2174
  };
1605
2175
  }
1606
2176
  async function verifyFrontmatter(absPath, originalFmRegion) {
1607
- const written = await readFile7(absPath, "utf8");
2177
+ const written = await readFile8(absPath, "utf8");
1608
2178
  if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
1609
2179
  throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
1610
2180
  }
@@ -1619,10 +2189,10 @@ function updateCache(ctx2, path2, newContent, bodyStart) {
1619
2189
  }
1620
2190
 
1621
2191
  // src/tools/periodic_note.ts
1622
- import { access as access4, mkdir as mkdir4, readFile as readFile8 } from "fs/promises";
1623
- import { dirname as dirname3, join as join3 } from "path";
1624
- import { z as z14 } from "zod";
1625
- 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"]);
1626
2196
  var DEFAULTS = {
1627
2197
  daily: { folder: "", format: "YYYY-MM-DD", template: "" },
1628
2198
  weekly: { folder: "", format: "gggg-[W]ww", template: "" },
@@ -1632,7 +2202,7 @@ var DEFAULTS = {
1632
2202
  };
1633
2203
  async function readJson(absPath) {
1634
2204
  try {
1635
- return JSON.parse(await readFile8(absPath, "utf8"));
2205
+ return JSON.parse(await readFile9(absPath, "utf8"));
1636
2206
  } catch {
1637
2207
  return null;
1638
2208
  }
@@ -1640,7 +2210,7 @@ async function readJson(absPath) {
1640
2210
  async function resolveConfig(vaultRoot2, period) {
1641
2211
  const defaults = DEFAULTS[period];
1642
2212
  if (period === "daily") {
1643
- const cfg2 = await readJson(join3(vaultRoot2, ".obsidian", "daily-notes.json"));
2213
+ const cfg2 = await readJson(join4(vaultRoot2, ".obsidian", "daily-notes.json"));
1644
2214
  return {
1645
2215
  folder: cfg2?.folder ?? defaults.folder,
1646
2216
  format: cfg2?.format ?? defaults.format,
@@ -1648,7 +2218,7 @@ async function resolveConfig(vaultRoot2, period) {
1648
2218
  };
1649
2219
  }
1650
2220
  const cfg = await readJson(
1651
- join3(vaultRoot2, ".obsidian", "plugins", "periodic-notes", "data.json")
2221
+ join4(vaultRoot2, ".obsidian", "plugins", "periodic-notes", "data.json")
1652
2222
  );
1653
2223
  const entry = cfg?.[period];
1654
2224
  return {
@@ -1687,12 +2257,12 @@ function resolveNotePath(cfg, date) {
1687
2257
  const filename = `${formatMomentDate(date, cfg.format)}.md`;
1688
2258
  return cfg.folder ? `${cfg.folder}/${filename}` : filename;
1689
2259
  }
1690
- var GetPeriodicNoteInput = z14.object({
2260
+ var GetPeriodicNoteInput = z15.object({
1691
2261
  period: PERIOD.default("daily").describe(
1692
2262
  "Period type: daily (default), weekly, monthly, quarterly, or yearly."
1693
2263
  ),
1694
- date: z14.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today when omitted."),
1695
- 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(
1696
2266
  "Create the note from the configured template if it does not exist. Default false \u2014 reports missing without creating."
1697
2267
  )
1698
2268
  });
@@ -1704,7 +2274,7 @@ async function getPeriodicNote(ctx2, rawInput) {
1704
2274
  const abs = resolveVaultPath(ctx2.vaultRoot, path2);
1705
2275
  let existed = false;
1706
2276
  try {
1707
- await access4(abs);
2277
+ await access3(abs);
1708
2278
  existed = true;
1709
2279
  } catch {
1710
2280
  }
@@ -1718,21 +2288,22 @@ async function getPeriodicNote(ctx2, rawInput) {
1718
2288
  ctx2.vaultRoot,
1719
2289
  cfg.template.endsWith(".md") ? cfg.template : `${cfg.template}.md`
1720
2290
  );
1721
- body = await readFile8(templateAbs, "utf8");
2291
+ body = await readFile9(templateAbs, "utf8");
1722
2292
  } catch {
1723
2293
  }
1724
2294
  }
1725
- await mkdir4(dirname3(abs), { recursive: true });
2295
+ await mkdir5(dirname3(abs), { recursive: true });
2296
+ await journalWrite(ctx2, "get_periodic_note", path2, null, body);
1726
2297
  await atomicWrite(abs, body);
1727
2298
  upsertDoc(ctx2, buildDoc(path2, body));
1728
2299
  return { path: path2, existed: false, created: true, contentHash: contentHash(body) };
1729
2300
  }
1730
- var AppendPeriodicNoteInput = z14.object({
2301
+ var AppendPeriodicNoteInput = z15.object({
1731
2302
  period: PERIOD.default("daily").describe("Period type. Default: daily."),
1732
- date: z14.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today."),
1733
- content: z14.string().min(1).describe("Text to append to the note body. Separated from existing content by a blank line."),
1734
- createIfMissing: z14.boolean().default(true).describe("Create the note if it does not exist before appending. Default true."),
1735
- 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(
1736
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."
1737
2308
  )
1738
2309
  });
@@ -1745,7 +2316,7 @@ async function appendPeriodicNote(ctx2, rawInput) {
1745
2316
  assertWritable(ctx2.policy, path2);
1746
2317
  let original = "";
1747
2318
  try {
1748
- original = await readFile8(abs, "utf8");
2319
+ original = await readFile9(abs, "utf8");
1749
2320
  if (input.prevHash !== void 0) assertHashMatch(original, input.prevHash, path2);
1750
2321
  } catch (err) {
1751
2322
  if (err.code !== "ENOENT") throw err;
@@ -1761,7 +2332,8 @@ async function appendPeriodicNote(ctx2, rawInput) {
1761
2332
  );
1762
2333
  }
1763
2334
  if (!input.createIfMissing) throw new Error(`Periodic note not found: ${path2}`);
1764
- await mkdir4(dirname3(abs), { recursive: true });
2335
+ await mkdir5(dirname3(abs), { recursive: true });
2336
+ await journalWrite(ctx2, "append_periodic_note", path2, null, input.content);
1765
2337
  await atomicWrite(abs, input.content);
1766
2338
  upsertDoc(ctx2, buildDoc(path2, input.content));
1767
2339
  return {
@@ -1777,6 +2349,7 @@ async function appendPeriodicNote(ctx2, rawInput) {
1777
2349
  const newBody = `${body}${separator}${input.content}`;
1778
2350
  const header = original.slice(0, fm.bodyStart);
1779
2351
  const newRaw = `${header}${newBody}`;
2352
+ await journalWrite(ctx2, "append_periodic_note", path2, original, newRaw);
1780
2353
  await atomicWrite(abs, newRaw);
1781
2354
  const cached = ctx2.notes.get(path2);
1782
2355
  if (cached) {
@@ -1791,36 +2364,36 @@ async function appendPeriodicNote(ctx2, rawInput) {
1791
2364
  }
1792
2365
 
1793
2366
  // src/tools/query_notes.ts
1794
- import { z as z15 } from "zod";
2367
+ import { z as z16 } from "zod";
1795
2368
  var OPS_WITHOUT_VALUE = /* @__PURE__ */ new Set(["exists", "missing"]);
1796
- var Predicate = z15.object({
1797
- key: z15.string().min(1).describe("Frontmatter key to test (top-level)."),
1798
- 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(
1799
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)."
1800
2373
  ),
1801
- 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.")
1802
2375
  }).refine((p) => OPS_WITHOUT_VALUE.has(p.op) || p.value !== void 0, {
1803
2376
  message: "value is required for this op"
1804
2377
  });
1805
- var ISO_DATE = z15.string().refine((s) => !Number.isNaN(Date.parse(s)), { message: "not a parseable date" });
1806
- var QueryNotesInput = z15.object({
1807
- where: z15.array(Predicate).max(20).default([]).describe("Frontmatter predicates. All must match (AND)."),
1808
- folder: z15.string().optional().describe("Restrict to notes under this vault-relative folder prefix."),
1809
- 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)."),
1810
2383
  modifiedAfter: ISO_DATE.optional().describe(
1811
2384
  "Only notes modified at or after this ISO 8601 date/time."
1812
2385
  ),
1813
2386
  modifiedBefore: ISO_DATE.optional().describe(
1814
2387
  "Only notes modified before this ISO 8601 date/time."
1815
2388
  ),
1816
- minSizeBytes: z15.number().int().min(0).optional().describe("Only notes at least this many bytes."),
1817
- maxSizeBytes: z15.number().int().min(0).optional().describe("Only notes at most this many bytes."),
1818
- 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(
1819
2392
  'Extra fields to return per hit: frontmatter keys, or the specials "mtime", "size", "tags". Default returns only path + title, keeping the payload minimal.'
1820
2393
  ),
1821
- sort: z15.enum(["path", "title", "mtime", "size"]).default("path").describe("Sort field."),
1822
- order: z15.enum(["asc", "desc"]).default("asc").describe("Sort order."),
1823
- 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.")
1824
2397
  });
1825
2398
  function scalarEq(a, b) {
1826
2399
  if (a === b) return true;
@@ -1918,17 +2491,17 @@ function queryNotes(ctx2, input) {
1918
2491
  }
1919
2492
 
1920
2493
  // src/tools/read_note.ts
1921
- import { readFile as readFile9 } from "fs/promises";
1922
- import { z as z16 } from "zod";
1923
- var ReadNoteInput = z16.object({
1924
- path: z16.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".'),
1925
- section: z16.string().optional().describe("Return only this heading section (heading text, # prefix optional)."),
1926
- block: z16.string().optional().describe("Return only the block anchored by this id (^ prefix optional)."),
1927
- lines: z16.object({
1928
- from: z16.number().int().min(1).describe("First line (1-indexed, inclusive)."),
1929
- 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.")
1930
2503
  }).optional().describe("Return only this line range (1-indexed, inclusive)."),
1931
- includeFrontmatter: z16.boolean().optional().describe(
2504
+ includeFrontmatter: z17.boolean().optional().describe(
1932
2505
  "Include frontmatter in span results. Default false for section/block spans. No effect on lines or whole-note reads."
1933
2506
  )
1934
2507
  }).refine((v) => [v.section, v.block, v.lines].filter((x) => x !== void 0).length <= 1, {
@@ -1936,7 +2509,7 @@ var ReadNoteInput = z16.object({
1936
2509
  });
1937
2510
  async function readNote(ctx2, input) {
1938
2511
  const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1939
- const raw = await readFile9(absPath, "utf8");
2512
+ const raw = await readFile10(absPath, "utf8");
1940
2513
  const noteBytes = Buffer.byteLength(raw, "utf8");
1941
2514
  const hash = contentHash(raw);
1942
2515
  if (input.section === void 0 && input.block === void 0 && input.lines === void 0) {
@@ -2024,18 +2597,18 @@ async function readNote(ctx2, input) {
2024
2597
  }
2025
2598
 
2026
2599
  // src/tools/rename_heading.ts
2027
- import { readFile as readFile10 } from "fs/promises";
2028
- import { join as join4 } from "path";
2029
- import { z as z17 } from "zod";
2030
- var RenameHeadingInput = z17.object({
2031
- path: z17.string().describe("Vault-relative path of the note containing the heading."),
2032
- 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(
2033
2606
  "Current heading text, without # markers. Matched case-insensitively; if the note has duplicate headings the first one wins, mirroring Obsidian link resolution."
2034
2607
  ),
2035
- newHeading: z17.string().min(1).refine((s) => !/[\n[\]|#]/.test(s), {
2608
+ newHeading: z18.string().min(1).refine((s) => !/[\n[\]|#]/.test(s), {
2036
2609
  message: 'newHeading must not contain newlines, "[", "]", "|", or "#" \u2014 those break [[note#heading]] link syntax.'
2037
2610
  }).describe("New heading text, without # markers."),
2038
- prevHash: z17.string().optional().describe(
2611
+ prevHash: z18.string().optional().describe(
2039
2612
  "Optional compare-and-swap guard: the contentHash from a prior read of this note. Fails with hash_conflict if the note changed since."
2040
2613
  )
2041
2614
  });
@@ -2065,7 +2638,7 @@ async function renameHeading(ctx2, rawInput) {
2065
2638
  assertWritable(ctx2.policy, input.path);
2066
2639
  let raw;
2067
2640
  try {
2068
- raw = await readFile10(absPath, "utf8");
2641
+ raw = await readFile11(absPath, "utf8");
2069
2642
  } catch {
2070
2643
  throw new Error(`Note not found: ${input.path}`);
2071
2644
  }
@@ -2099,21 +2672,11 @@ async function renameHeading(ctx2, rawInput) {
2099
2672
  (target) => target === "" || resolveLink(target, ctx2.notes) === input.path
2100
2673
  );
2101
2674
  newRaw = newRaw.slice(0, fm.bodyStart) + selfRewrite.content;
2102
- const originalFmRegion = raw.slice(0, fm.bodyStart);
2103
- await atomicWrite(absPath, newRaw);
2104
- const written = await readFile10(absPath, "utf8");
2105
- if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
2106
- throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
2107
- }
2108
- removeNoteBacklinks(ctx2, input.path);
2109
- upsertDoc(ctx2, buildDoc(input.path, newRaw));
2110
- addNoteBacklinks(ctx2, input.path, newRaw);
2111
- let notesRewritten = selfRewrite.count > 0 ? 1 : 0;
2112
- let linksRewritten = selfRewrite.count;
2113
- const skipped = [];
2114
2675
  const candidates = /* @__PURE__ */ new Set();
2115
2676
  for (const ref of ctx2.backlinks.get(input.path) ?? []) candidates.add(ref.path);
2116
2677
  candidates.delete(input.path);
2678
+ const rewrites = [];
2679
+ const skipped = [];
2117
2680
  for (const path2 of [...candidates].sort()) {
2118
2681
  const doc = ctx2.notes.get(path2);
2119
2682
  if (doc === void 0) continue;
@@ -2128,7 +2691,27 @@ async function renameHeading(ctx2, rawInput) {
2128
2691
  skipped.push(path2);
2129
2692
  continue;
2130
2693
  }
2131
- 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);
2132
2715
  upsertDoc(ctx2, buildDoc(path2, content));
2133
2716
  notesRewritten++;
2134
2717
  linksRewritten += count;
@@ -2147,18 +2730,18 @@ async function renameHeading(ctx2, rawInput) {
2147
2730
  }
2148
2731
 
2149
2732
  // src/tools/replace_in_note.ts
2150
- import { readFile as readFile11 } from "fs/promises";
2151
- import { z as z18 } from "zod";
2152
- var ReplaceInNoteInput = z18.object({
2153
- path: z18.string().describe("Vault-relative path to the note."),
2154
- find: z18.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
2155
- replace: z18.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
2156
- regex: z18.boolean().default(false).describe("Treat find as a regular expression."),
2157
- caseSensitive: z18.boolean().default(false).describe("Case-sensitive matching."),
2158
- wholeWord: z18.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
2159
- limit: z18.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
2160
- dryRun: z18.boolean().default(false).describe("If true, report matches without writing anything."),
2161
- 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(
2162
2745
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
2163
2746
  )
2164
2747
  });
@@ -2184,7 +2767,7 @@ async function replaceInNote(ctx2, input) {
2184
2767
  } catch (err) {
2185
2768
  throw new Error(`Invalid regex: ${err instanceof Error ? err.message : String(err)}`);
2186
2769
  }
2187
- const raw = await readFile11(absPath, "utf8");
2770
+ const raw = await readFile12(absPath, "utf8");
2188
2771
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
2189
2772
  const fm = parseFrontmatter(raw);
2190
2773
  const originalFmRegion = raw.slice(0, fm.bodyStart);
@@ -2218,8 +2801,9 @@ async function replaceInNote(ctx2, input) {
2218
2801
  }
2219
2802
  newBody += body.slice(lastIndex);
2220
2803
  const newContent = `${originalFmRegion}${newBody}`;
2804
+ await journalWrite(ctx2, "replace_in_note", input.path, raw, newContent);
2221
2805
  await atomicWrite(absPath, newContent);
2222
- const written = await readFile11(absPath, "utf8");
2806
+ const written = await readFile12(absPath, "utf8");
2223
2807
  if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
2224
2808
  throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
2225
2809
  }
@@ -2239,6 +2823,123 @@ async function replaceInNote(ctx2, input) {
2239
2823
  };
2240
2824
  }
2241
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
+
2242
2943
  // src/dispatch.ts
2243
2944
  var WRITE_TOOLS = /* @__PURE__ */ new Set([
2244
2945
  "create_note",
@@ -2249,7 +2950,8 @@ var WRITE_TOOLS = /* @__PURE__ */ new Set([
2249
2950
  "patch_frontmatter",
2250
2951
  "patch_note",
2251
2952
  "replace_in_note",
2252
- "append_periodic_note"
2953
+ "append_periodic_note",
2954
+ "undo_write"
2253
2955
  ]);
2254
2956
  var META_KEYS = [
2255
2957
  "path",
@@ -2273,7 +2975,9 @@ var META_KEYS = [
2273
2975
  "date",
2274
2976
  "permanent",
2275
2977
  "prevHash",
2276
- "budgetBytes"
2978
+ "budgetBytes",
2979
+ "seq",
2980
+ "force"
2277
2981
  ];
2278
2982
  function safeMeta(args) {
2279
2983
  if (typeof args !== "object" || args === null) return {};
@@ -2297,7 +3001,7 @@ async function dispatch(ctx2, name, args, log2) {
2297
3001
  log2.debug("tool start", { tool: name, args });
2298
3002
  if (ctx2.policy.readOnly && WRITE_TOOLS.has(name)) {
2299
3003
  log2.info("tool rejected (read-only)", { tool: name, ...meta });
2300
- return {
3004
+ const rejected = {
2301
3005
  content: [
2302
3006
  {
2303
3007
  type: "text",
@@ -2306,17 +3010,39 @@ async function dispatch(ctx2, name, args, log2) {
2306
3010
  ],
2307
3011
  isError: true
2308
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
+ });
2309
3023
  }
2310
3024
  if (ctx2.policy.readOnly && name === "get_periodic_note") {
2311
3025
  if (typeof args === "object" && args !== null) {
2312
3026
  args = { ...args, createIfMissing: false };
2313
3027
  }
2314
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();
2315
3032
  try {
2316
- const result = await run(ctx2, name, args);
3033
+ const { audit: detail, ...result } = await run(ctx2, name, args);
2317
3034
  const durationMs = Math.round((performance.now() - start) * 100) / 100;
2318
3035
  log2.info("tool ok", { tool: name, durationMs, resultBytes: resultBytes(result), ...meta });
2319
- 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
+ });
2320
3046
  } catch (err) {
2321
3047
  const durationMs = Math.round((performance.now() - start) * 100) / 100;
2322
3048
  const message = err instanceof Error ? err.message : String(err);
@@ -2327,7 +3053,66 @@ async function dispatch(ctx2, name, args, log2) {
2327
3053
  stack: err instanceof Error ? err.stack : void 0,
2328
3054
  ...meta
2329
3055
  });
2330
- 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
+ };
2331
3116
  }
2332
3117
  }
2333
3118
  async function run(ctx2, name, args) {
@@ -2371,14 +3156,26 @@ async function run(ctx2, name, args) {
2371
3156
  type: "text",
2372
3157
  text: `Created ${result.path} (${result.bytesWritten} bytes). contentHash: ${result.contentHash}`
2373
3158
  }
2374
- ]
3159
+ ],
3160
+ audit: {
3161
+ path: result.path,
3162
+ contentHash: result.contentHash,
3163
+ bytesWritten: result.bytesWritten,
3164
+ overwrite: input.overwrite
3165
+ }
2375
3166
  };
2376
3167
  }
2377
3168
  case "delete_note": {
2378
3169
  const input = DeleteNoteInput.parse(args);
2379
3170
  const result = await deleteNote(ctx2, input);
2380
3171
  const text = result.trashedTo ? `Moved ${result.path} to ${result.trashedTo} (recoverable \u2014 restore by moving it back).` : `Permanently deleted ${result.path}.`;
2381
- 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 };
2382
3179
  }
2383
3180
  case "move_note": {
2384
3181
  const input = MoveNoteInput.parse(args);
@@ -2387,7 +3184,17 @@ async function run(ctx2, name, args) {
2387
3184
  if (result.skipped?.length) {
2388
3185
  text += ` Skipped (outside SEEKSTONE_WRITE_PATHS): ${result.skipped.join(", ")}.`;
2389
3186
  }
2390
- 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
+ };
2391
3198
  }
2392
3199
  case "rename_heading": {
2393
3200
  const input = RenameHeadingInput.parse(args);
@@ -2396,7 +3203,17 @@ async function run(ctx2, name, args) {
2396
3203
  if (result.skipped?.length) {
2397
3204
  text += ` Skipped (outside SEEKSTONE_WRITE_PATHS): ${result.skipped.join(", ")}.`;
2398
3205
  }
2399
- 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
+ };
2400
3217
  }
2401
3218
  case "append_note": {
2402
3219
  const input = AppendNoteInput.parse(args);
@@ -2407,13 +3224,28 @@ async function run(ctx2, name, args) {
2407
3224
  type: "text",
2408
3225
  text: `Appended ${result.bytesWritten} bytes to ${result.path}. contentHash: ${result.contentHash}`
2409
3226
  }
2410
- ]
3227
+ ],
3228
+ audit: {
3229
+ path: result.path,
3230
+ contentHash: result.contentHash,
3231
+ bytesWritten: result.bytesWritten
3232
+ }
2411
3233
  };
2412
3234
  }
2413
3235
  case "patch_frontmatter": {
2414
3236
  const input = PatchFrontmatterInput.parse(args);
2415
3237
  const result = await patchFrontmatter(ctx2, input);
2416
- 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
+ };
2417
3249
  }
2418
3250
  case "outline_note": {
2419
3251
  const input = OutlineNoteInput.parse(args);
@@ -2423,7 +3255,16 @@ async function run(ctx2, name, args) {
2423
3255
  case "patch_note": {
2424
3256
  const input = PatchNoteInput.parse(args);
2425
3257
  const result = await patchNote(ctx2, input);
2426
- 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
+ };
2427
3268
  }
2428
3269
  case "get_backlinks": {
2429
3270
  const input = GetBacklinksInput.parse(args);
@@ -2438,12 +3279,26 @@ async function run(ctx2, name, args) {
2438
3279
  case "replace_in_note": {
2439
3280
  const input = ReplaceInNoteInput.parse(args);
2440
3281
  const result = await replaceInNote(ctx2, input);
2441
- 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
+ };
2442
3291
  }
2443
3292
  case "get_periodic_note": {
2444
3293
  const input = GetPeriodicNoteInput.parse(args);
2445
3294
  const result = await getPeriodicNote(ctx2, input);
2446
- 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;
2447
3302
  }
2448
3303
  case "append_periodic_note": {
2449
3304
  const input = AppendPeriodicNoteInput.parse(args);
@@ -2454,7 +3309,29 @@ async function run(ctx2, name, args) {
2454
3309
  type: "text",
2455
3310
  text: `Appended ${result.bytesWritten} bytes to ${result.path}. contentHash: ${result.contentHash}`
2456
3311
  }
2457
- ]
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
+ }
2458
3335
  };
2459
3336
  }
2460
3337
  default:
@@ -2463,12 +3340,12 @@ async function run(ctx2, name, args) {
2463
3340
  }
2464
3341
 
2465
3342
  // src/index/build.ts
2466
- import { readFile as readFile12 } from "fs/promises";
3343
+ import { readFile as readFile14 } from "fs/promises";
2467
3344
  import { basename as basename3, extname as extname4 } from "path";
2468
3345
 
2469
3346
  // ../core/src/walk.ts
2470
- import { stat } from "fs/promises";
2471
- 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";
2472
3349
  import fg from "fast-glob";
2473
3350
  var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]);
2474
3351
  var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
@@ -2476,6 +3353,7 @@ var AUDIO_EXTS = /* @__PURE__ */ new Set([".mp3", ".m4a", ".wav", ".flac", ".ogg
2476
3353
  var IGNORE_GLOBS = [
2477
3354
  "**/.obsidian/**",
2478
3355
  "**/.trash/**",
3356
+ "**/.seekstone/**",
2479
3357
  "**/.git/**",
2480
3358
  "**/.DS_Store",
2481
3359
  "**/node_modules/**"
@@ -2507,7 +3385,7 @@ async function walkVault(vaultRoot2) {
2507
3385
  const entries = [];
2508
3386
  for (const m of matches2) {
2509
3387
  const absPath = typeof m === "string" ? m : m.path;
2510
- const stats = typeof m === "string" ? await stat(absPath) : m.stats;
3388
+ const stats = typeof m === "string" ? await stat2(absPath) : m.stats;
2511
3389
  if (!stats) continue;
2512
3390
  const relPath = relative(vaultRoot2, absPath).split(sep2).join("/");
2513
3391
  const topDir = relPath.split("/")[0] ?? "";
@@ -2545,7 +3423,7 @@ async function buildIndex(vaultRoot2) {
2545
3423
  await Promise.all(
2546
3424
  noteEntries.map(async (entry) => {
2547
3425
  try {
2548
- const raw = await readFile12(entry.absPath, "utf8");
3426
+ const raw = await readFile14(entry.absPath, "utf8");
2549
3427
  const fm = parseFrontmatter(raw);
2550
3428
  const inlineTags = extractInlineTags(fm.body);
2551
3429
  const fmTags = frontmatterTags(fm.data);
@@ -2598,8 +3476,8 @@ function buildBacklinks(notes2) {
2598
3476
 
2599
3477
  // src/init.ts
2600
3478
  import { execFileSync } from "child_process";
2601
- import { access as access5, copyFile, mkdir as mkdir5, readdir, readFile as readFile13, stat as stat2, writeFile as writeFile2 } from "fs/promises";
2602
- 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";
2603
3481
  function parseInitArgs(argv) {
2604
3482
  const opts = { write: false, client: "desktop" };
2605
3483
  for (let i = 0; i < argv.length; i++) {
@@ -2642,7 +3520,7 @@ function cursorConfigPath(platform, env) {
2642
3520
  return j(env.home ?? "", ".cursor", "mcp.json");
2643
3521
  }
2644
3522
  function vscodeMcpConfigPath(cwd) {
2645
- return join6(cwd, ".vscode", "mcp.json");
3523
+ return join7(cwd, ".vscode", "mcp.json");
2646
3524
  }
2647
3525
  function obsidianRegistryPath(platform, env) {
2648
3526
  if (platform === "win32") {
@@ -2698,13 +3576,13 @@ function mergeSeekstoneConfigVsCode(existing, vaultPath) {
2698
3576
  }
2699
3577
  async function validateVault(vaultPath) {
2700
3578
  try {
2701
- const st = await stat2(vaultPath);
3579
+ const st = await stat3(vaultPath);
2702
3580
  if (!st.isDirectory()) return { ok: false, error: `Not a directory: ${vaultPath}` };
2703
3581
  } catch {
2704
3582
  return { ok: false, error: `Path does not exist: ${vaultPath}` };
2705
3583
  }
2706
3584
  try {
2707
- await access5(join6(vaultPath, ".obsidian"));
3585
+ await access4(join7(vaultPath, ".obsidian"));
2708
3586
  } catch {
2709
3587
  return {
2710
3588
  ok: false,
@@ -2718,13 +3596,13 @@ async function countMarkdown(root) {
2718
3596
  async function walk(dir) {
2719
3597
  let entries;
2720
3598
  try {
2721
- entries = await readdir(dir, { withFileTypes: true });
3599
+ entries = await readdir2(dir, { withFileTypes: true });
2722
3600
  } catch {
2723
3601
  return;
2724
3602
  }
2725
3603
  for (const e of entries) {
2726
3604
  if (e.name.startsWith(".")) continue;
2727
- const full = join6(dir, e.name);
3605
+ const full = join7(dir, e.name);
2728
3606
  if (e.isDirectory()) await walk(full);
2729
3607
  else if (e.name.endsWith(".md")) count++;
2730
3608
  }
@@ -2742,7 +3620,7 @@ async function runInit(opts, deps) {
2742
3620
  ]
2743
3621
  };
2744
3622
  }
2745
- const rf = deps.readFile ?? readFile13;
3623
+ const rf = deps.readFile ?? readFile15;
2746
3624
  let vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
2747
3625
  if (!vaultPath) {
2748
3626
  const registryPath = obsidianRegistryPath(deps.platform, {
@@ -2873,7 +3751,7 @@ async function runInit(opts, deps) {
2873
3751
  }
2874
3752
  const merged = opts.client === "vscode" ? mergeSeekstoneConfigVsCode(existing, vaultPath) : mergeSeekstoneConfig(existing, vaultPath);
2875
3753
  let backupPath;
2876
- await mkdir5(dirname4(configPath), { recursive: true });
3754
+ await mkdir7(dirname5(configPath), { recursive: true });
2877
3755
  if (existingRaw !== null) {
2878
3756
  backupPath = `${configPath}.backup-${deps.timestamp}`;
2879
3757
  await copyFile(configPath, backupPath);
@@ -2889,107 +3767,6 @@ async function runInit(opts, deps) {
2889
3767
  return { ok: true, exitCode: 0, output: out, wrotePath: configPath, backupPath };
2890
3768
  }
2891
3769
 
2892
- // src/log.ts
2893
- import { appendFileSync, renameSync, statSync } from "fs";
2894
- var ORDER = ["error", "warn", "info", "debug"];
2895
- var DEFAULT_MAX_SIZE = 5 * 1024 * 1024;
2896
- function parseLevel(raw, onBad) {
2897
- if (raw === void 0 || raw === "") return "info";
2898
- const v = raw.trim().toLowerCase();
2899
- for (const lvl of ORDER) {
2900
- if (lvl === v) return lvl;
2901
- }
2902
- onBad(`invalid SEEKSTONE_LOG_LEVEL=${JSON.stringify(raw)}; using "info"`);
2903
- return "info";
2904
- }
2905
- function parseSize(raw) {
2906
- if (raw === void 0) return DEFAULT_MAX_SIZE;
2907
- const m = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(raw.trim());
2908
- if (m === null) return DEFAULT_MAX_SIZE;
2909
- const num = Number(m[1]);
2910
- const unit = (m[2] ?? "b").toLowerCase();
2911
- const mult = unit === "gb" ? 1e9 : unit === "mb" ? 1e6 : unit === "kb" ? 1e3 : 1;
2912
- return Math.max(1, Math.floor(num * mult));
2913
- }
2914
- function fmtFields(fields) {
2915
- const parts = [];
2916
- for (const [k, v] of Object.entries(fields)) {
2917
- if (v === void 0) continue;
2918
- parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
2919
- }
2920
- return parts.length > 0 ? ` ${parts.join(" ")}` : "";
2921
- }
2922
- function createLogger(opts = {}) {
2923
- const env = opts.env ?? process.env;
2924
- const stderr = opts.stderr ?? ((line) => void process.stderr.write(line));
2925
- const now = opts.now ?? (() => /* @__PURE__ */ new Date());
2926
- const level = parseLevel(env.SEEKSTONE_LOG_LEVEL, (m) => {
2927
- stderr(`WARN ${now().toISOString()} ${m}
2928
- `);
2929
- });
2930
- const threshold = ORDER.indexOf(level);
2931
- const filePath = env.SEEKSTONE_LOG_FILE;
2932
- const maxSize = parseSize(env.SEEKSTONE_LOG_MAX_SIZE);
2933
- let fileOk = false;
2934
- let bytes = 0;
2935
- if (filePath !== void 0 && filePath !== "") {
2936
- try {
2937
- try {
2938
- bytes = statSync(filePath).size;
2939
- } catch {
2940
- bytes = 0;
2941
- }
2942
- appendFileSync(filePath, "");
2943
- fileOk = true;
2944
- } catch (err) {
2945
- const m = err instanceof Error ? err.message : String(err);
2946
- stderr(
2947
- `WARN ${now().toISOString()} cannot write log file ${filePath}: ${m}; file logging disabled
2948
- `
2949
- );
2950
- }
2951
- }
2952
- function writeFile5(record) {
2953
- if (!fileOk || filePath === void 0) return;
2954
- const line = `${JSON.stringify(record)}
2955
- `;
2956
- const len = Buffer.byteLength(line);
2957
- try {
2958
- if (bytes > 0 && bytes + len > maxSize) {
2959
- renameSync(filePath, `${filePath}.1`);
2960
- bytes = 0;
2961
- }
2962
- appendFileSync(filePath, line);
2963
- bytes += len;
2964
- } catch {
2965
- fileOk = false;
2966
- }
2967
- }
2968
- function emit(lvl, msg, fields) {
2969
- if (ORDER.indexOf(lvl) > threshold) return;
2970
- const ts = now().toISOString();
2971
- const f = fields ?? {};
2972
- stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
2973
- `);
2974
- writeFile5({ t: ts, level: lvl, msg, ...f });
2975
- }
2976
- return {
2977
- level,
2978
- error: (m, f) => {
2979
- emit("error", m, f);
2980
- },
2981
- warn: (m, f) => {
2982
- emit("warn", m, f);
2983
- },
2984
- info: (m, f) => {
2985
- emit("info", m, f);
2986
- },
2987
- debug: (m, f) => {
2988
- emit("debug", m, f);
2989
- }
2990
- };
2991
- }
2992
-
2993
3770
  // src/process-guards.ts
2994
3771
  function installProcessGuards(log2) {
2995
3772
  const onUnhandledRejection = (reason) => {
@@ -3078,9 +3855,83 @@ function countWords(text) {
3078
3855
  return m ? m.length : 0;
3079
3856
  }
3080
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
+
3081
3932
  // ../core/src/embed/model2vec.ts
3082
- import { readFile as readFile14 } from "fs/promises";
3083
- 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";
3084
3935
 
3085
3936
  // ../core/src/embed/safetensors.ts
3086
3937
  function readSafetensors(buf) {
@@ -3157,7 +4008,7 @@ function loadWordPieceTokenizer(tokenizerJson) {
3157
4008
  const specialIds = new Set(
3158
4009
  (tj.added_tokens ?? []).filter((t) => t.special).map((t) => t.id)
3159
4010
  );
3160
- function normalize(text) {
4011
+ function normalize2(text) {
3161
4012
  let out = cleanText ? doCleanText(text) : text;
3162
4013
  if (handleCjk) out = padCjkChars(out);
3163
4014
  if (stripAccents) out = out.normalize("NFD").replace(new RegExp("\\p{Mn}", "gu"), "");
@@ -3193,7 +4044,7 @@ function loadWordPieceTokenizer(tokenizerJson) {
3193
4044
  specialIds,
3194
4045
  encode(text) {
3195
4046
  const ids = [];
3196
- for (const word of preTokenize(normalize(text))) {
4047
+ for (const word of preTokenize(normalize2(text))) {
3197
4048
  for (const id of wordPiece(word)) ids.push(id);
3198
4049
  }
3199
4050
  return ids;
@@ -3261,9 +4112,9 @@ function isCjk(cp) {
3261
4112
  var TENSOR_NAME = "embeddings";
3262
4113
  async function loadModel2Vec(modelDir) {
3263
4114
  const [configRaw, tokenizerRaw, weights] = await Promise.all([
3264
- readFile14(join8(modelDir, "config.json"), "utf8"),
3265
- readFile14(join8(modelDir, "tokenizer.json"), "utf8"),
3266
- 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"))
3267
4118
  ]);
3268
4119
  const config = JSON.parse(configRaw);
3269
4120
  const tokenizer = loadWordPieceTokenizer(JSON.parse(tokenizerRaw));
@@ -3289,20 +4140,27 @@ async function loadModel2Vec(modelDir) {
3289
4140
  );
3290
4141
  }
3291
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
+ }
3292
4151
  return {
3293
4152
  id: basename4(modelDir),
3294
4153
  dim,
3295
4154
  embed(text) {
3296
4155
  const out = new Float32Array(dim);
3297
- let pooled = 0;
3298
- for (const id of tokenizer.encode(text)) {
3299
- if (tokenizer.specialIds.has(id) && id !== tokenizer.unkId) continue;
4156
+ const ids = pooledIds(text);
4157
+ for (const id of ids) {
3300
4158
  const base = id * dim;
3301
4159
  for (let j = 0; j < dim; j++) {
3302
4160
  out[j] = out[j] + table[base + j];
3303
4161
  }
3304
- pooled++;
3305
4162
  }
4163
+ const pooled = ids.length;
3306
4164
  if (pooled === 0) return out;
3307
4165
  let sumSquares = 0;
3308
4166
  for (let j = 0; j < dim; j++) {
@@ -3315,27 +4173,121 @@ async function loadModel2Vec(modelDir) {
3315
4173
  for (let j = 0; j < dim; j++) out[j] = out[j] / norm;
3316
4174
  }
3317
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;
3318
4193
  }
3319
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";
3320
4272
  }
3321
4273
 
3322
4274
  // src/semantic/cache.ts
3323
4275
  import { createHash as createHash2 } from "crypto";
3324
- import { mkdir as mkdir6, readFile as readFile15, rename as rename4, writeFile as writeFile3 } from "fs/promises";
3325
- 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";
3326
4278
  var CACHE_VERSION = 2;
3327
4279
  function cachePathsFor(cacheDir, vaultRoot2, modelId) {
3328
4280
  const vaultKey = createHash2("sha256").update(vaultRoot2).digest("hex").slice(0, 16);
3329
- const dir = join9(cacheDir, "embeddings", vaultKey);
3330
- 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`) };
3331
4283
  }
3332
4284
  async function loadCache(paths, modelId, dim) {
3333
4285
  try {
3334
- const manifest = JSON.parse(await readFile15(paths.manifest, "utf8"));
4286
+ const manifest = JSON.parse(await readFile17(paths.manifest, "utf8"));
3335
4287
  if (manifest.version !== CACHE_VERSION || manifest.modelId !== modelId || manifest.dim !== dim) {
3336
4288
  return void 0;
3337
4289
  }
3338
- const buf = await readFile15(paths.bin);
4290
+ const buf = await readFile17(paths.bin);
3339
4291
  const totalChunks = manifest.entries.reduce((a, e) => a + e.chunks, 0);
3340
4292
  const vectorBytes = totalChunks * dim * 4;
3341
4293
  const spanBytes = totalChunks * 2 * 4;
@@ -3385,26 +4337,64 @@ async function saveCache(paths, modelId, dim, notes2, hashes) {
3385
4337
  spanOffset += note.spans.length;
3386
4338
  }
3387
4339
  const manifest = { version: CACHE_VERSION, modelId, dim, entries };
3388
- await mkdir6(paths.dir, { recursive: true });
4340
+ await mkdir8(paths.dir, { recursive: true });
3389
4341
  await atomicWriteBytes(paths.bin, bin);
3390
4342
  await atomicWriteBytes(paths.manifest, new TextEncoder().encode(JSON.stringify(manifest)));
3391
4343
  }
3392
4344
  async function atomicWriteBytes(absPath, bytes) {
3393
4345
  const tmpPath = `${absPath}.seekstone-cache-tmp`;
3394
4346
  await writeFile3(tmpPath, bytes);
3395
- 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);
3396
4382
  }
3397
4383
 
3398
4384
  // src/semantic/store.ts
4385
+ var DEFAULT_POOLING = "max";
3399
4386
  var SemanticStore = class {
3400
4387
  dim;
4388
+ pooling;
3401
4389
  notes = /* @__PURE__ */ new Map();
3402
4390
  chunks = 0;
3403
- constructor(dim) {
4391
+ constructor(dim, pooling = DEFAULT_POOLING) {
3404
4392
  if (!Number.isInteger(dim) || dim <= 0) {
3405
4393
  throw new Error(`semantic store: invalid dim ${dim}`);
3406
4394
  }
4395
+ assertValidPooling(pooling);
3407
4396
  this.dim = dim;
4397
+ this.pooling = pooling;
3408
4398
  }
3409
4399
  /** Total chunk vectors across all notes. */
3410
4400
  get chunkCount() {
@@ -3444,38 +4434,55 @@ var SemanticStore = class {
3444
4434
  entries() {
3445
4435
  return this.notes.entries();
3446
4436
  }
3447
- /** 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
+ */
3448
4442
  topNotes(query, k) {
3449
4443
  if (query.length !== this.dim) {
3450
4444
  throw new Error(`semantic store: query dim ${query.length} does not match ${this.dim}`);
3451
4445
  }
3452
- const dim = this.dim;
3453
4446
  const hits = [];
3454
- for (const [path2, { packed, spans }] of this.notes) {
3455
- let best = Number.NEGATIVE_INFINITY;
3456
- let bestChunk = 0;
3457
- const n = packed.length / dim;
3458
- for (let c = 0; c < n; c++) {
3459
- const base = c * dim;
3460
- let score = 0;
3461
- for (let j = 0; j < dim; j++) {
3462
- score += packed[base + j] * query[j];
3463
- }
3464
- if (score > best) {
3465
- best = score;
3466
- bestChunk = c;
3467
- }
3468
- }
3469
- hits.push({
3470
- path: path2,
3471
- score: best,
3472
- chunkIndex: bestChunk,
3473
- start: spans[bestChunk * 2],
3474
- end: spans[bestChunk * 2 + 1]
3475
- });
4447
+ for (const [path2, vectors] of this.notes) {
4448
+ hits.push(this.scoreVectors(path2, vectors, query));
3476
4449
  }
3477
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));
3478
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
+ }
3479
4486
  };
3480
4487
 
3481
4488
  // src/semantic/state.ts
@@ -3490,11 +4497,21 @@ var Semantic = class _Semantic {
3490
4497
  saveDebounceMs;
3491
4498
  yieldEvery;
3492
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;
3493
4509
  pending = /* @__PURE__ */ new Map();
3494
4510
  saveTimer;
3495
4511
  stopped = false;
3496
4512
  constructor(embedder, ctx2, cfg, deps) {
3497
4513
  this.embedder = embedder;
4514
+ this.tokenEmbedder = isTokenEmbedder(embedder) ? embedder : void 0;
3498
4515
  this.store = new SemanticStore(embedder.dim);
3499
4516
  this.ctx = ctx2;
3500
4517
  this.paths = cachePathsFor(cfg.cacheDir, ctx2.vaultRoot, embedder.id);
@@ -3516,7 +4533,7 @@ var Semantic = class _Semantic {
3516
4533
  } catch (err) {
3517
4534
  const reason = err instanceof Error ? err.message : String(err);
3518
4535
  throw new Error(
3519
- `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})`
3520
4537
  );
3521
4538
  }
3522
4539
  const semantic = new _Semantic(embedder, ctx2, cfg, deps);
@@ -3591,9 +4608,35 @@ var Semantic = class _Semantic {
3591
4608
  embedQuery(query) {
3592
4609
  return this.embedder.embed(query);
3593
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
+ }
3594
4636
  /** Watcher hook: a note was added or changed (ctx.notes is already fresh). */
3595
4637
  noteChanged(path2) {
3596
4638
  if (this.stopped) return;
4639
+ this.tokenIdCache.delete(path2);
3597
4640
  const existing = this.pending.get(path2);
3598
4641
  if (existing) clearTimeout(existing);
3599
4642
  const timer = setTimeout(() => {
@@ -3610,6 +4653,7 @@ var Semantic = class _Semantic {
3610
4653
  clearTimeout(existing);
3611
4654
  this.pending.delete(path2);
3612
4655
  }
4656
+ this.tokenIdCache.delete(path2);
3613
4657
  if (this.hashes.has(path2) || this.store.getNote(path2)) {
3614
4658
  this.store.removeNote(path2);
3615
4659
  this.hashes.delete(path2);
@@ -4179,6 +5223,42 @@ var ALL_TOOLS = [
4179
5223
  },
4180
5224
  required: ["content"]
4181
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
+ }
4182
5262
  }
4183
5263
  ];
4184
5264
  function visibleTools(policy2) {
@@ -4188,8 +5268,8 @@ function visibleTools(policy2) {
4188
5268
 
4189
5269
  // src/watcher.ts
4190
5270
  import { realpathSync } from "fs";
4191
- import { readFile as readFile16 } from "fs/promises";
4192
- 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";
4193
5273
  import chokidar from "chokidar";
4194
5274
  var DEFAULT_POLL_INTERVAL_MS = 1e4;
4195
5275
  function resolvePollInterval(optValue, envValue, log2) {
@@ -4220,7 +5300,7 @@ function startWatcher(ctx2, log2, opts) {
4220
5300
  const toRel = (abs) => relative2(vaultRoot2, abs).split(sep3).join("/");
4221
5301
  async function upsert(relPath, op) {
4222
5302
  try {
4223
- const raw = await readFile16(join10(vaultRoot2, relPath), "utf8");
5303
+ const raw = await readFile18(join11(vaultRoot2, relPath), "utf8");
4224
5304
  removeNoteBacklinks(ctx2, relPath);
4225
5305
  upsertDoc(ctx2, buildDoc(relPath, raw));
4226
5306
  addNoteBacklinks(ctx2, relPath, raw);
@@ -4274,7 +5354,7 @@ function startWatcher(ctx2, log2, opts) {
4274
5354
  }
4275
5355
 
4276
5356
  // src/index.ts
4277
- var VERSION = true ? "0.16.0" : "0.0.0-dev";
5357
+ var VERSION = true ? "0.17.1" : "0.0.0-dev";
4278
5358
  var intent = parseCliIntent(process.argv.slice(2));
4279
5359
  if (intent === "version") {
4280
5360
  process.stdout.write(`${VERSION}
@@ -4305,7 +5385,11 @@ if (intent === "init") {
4305
5385
  if (intent === "fetch-model") {
4306
5386
  const { runFetchModel: runFetchModel2 } = await Promise.resolve().then(() => (init_fetch_model(), fetch_model_exports));
4307
5387
  const { homedir: homedir2 } = await import("os");
4308
- 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
+ });
4309
5393
  process.stdout.write(`${result.output.join("\n")}
4310
5394
  `);
4311
5395
  process.exit(result.exitCode);
@@ -4325,6 +5409,33 @@ log.info("building index", { vault: vaultRoot });
4325
5409
  var { index, notes, backlinks, buildMs } = await buildIndex(vaultRoot);
4326
5410
  log.info("index ready", { notes: notes.size, buildMs });
4327
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
+ }
4328
5439
  var semanticCfg = resolveSemanticConfig(process.env, homedir());
4329
5440
  if (semanticCfg) {
4330
5441
  try {