@llmnesia/mcp 0.2.3 → 0.2.5

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.
@@ -3,13 +3,13 @@ import { createRequire as __llmnesiaCreateRequire } from "node:module";
3
3
  const require = __llmnesiaCreateRequire(import.meta.url);
4
4
  import {
5
5
  VERSION
6
- } from "./chunk-6H2TI5IW.js";
6
+ } from "./chunk-BDR4JVMF.js";
7
7
 
8
8
  // src/config.ts
9
9
  import { readFileSync } from "fs";
10
10
  import { homedir } from "os";
11
11
  import { join, resolve } from "path";
12
- var CORPUS_FORMAT_VERSION = 1;
12
+ var CORPUS_FORMAT_VERSION = 2;
13
13
  function nativeHostConfigPath() {
14
14
  return join(homedir(), ".llmnesia", "config.json");
15
15
  }
@@ -39,7 +39,7 @@ function expandHome(p) {
39
39
  import { join as join2 } from "path";
40
40
  import { readdir } from "fs/promises";
41
41
  import { createHash } from "crypto";
42
- var INDEX_FORMAT_VERSION = 2;
42
+ var INDEX_FORMAT_VERSION = 3;
43
43
  function indexDbFilename(version) {
44
44
  return version === 1 ? "llmnesia.db" : `llmnesia-v${version}.db`;
45
45
  }
@@ -63,12 +63,24 @@ var CorpusPaths = class {
63
63
  get metaFile() {
64
64
  return join2(this.root, "meta.json");
65
65
  }
66
+ /** Content-free state for automatic storage maintenance. */
67
+ get maintenanceStateFile() {
68
+ return join2(this.root, "maintenance-v1.json");
69
+ }
70
+ /** Cross-process lease preventing several MCP clients compacting together. */
71
+ get maintenanceLockFile() {
72
+ return join2(this.root, ".maintenance-v1.lock");
73
+ }
66
74
  get inboxDir() {
67
75
  return join2(this.root, "inbox");
68
76
  }
69
77
  get processedDir() {
70
78
  return join2(this.inboxDir, "processed");
71
79
  }
80
+ /** Input that was readable but not clean enough to discard automatically. */
81
+ get quarantineDir() {
82
+ return join2(this.inboxDir, "quarantine");
83
+ }
72
84
  get conversationsDir() {
73
85
  return join2(this.root, "conversations");
74
86
  }
@@ -87,6 +99,14 @@ var CorpusPaths = class {
87
99
  }
88
100
  /** Absolute path to the JSON file backing a given docId. */
89
101
  conversationFile(platform, docId) {
102
+ return this.compressedConversationFile(platform, docId);
103
+ }
104
+ /** Canonical v2 path. Compression is lossless and independently readable. */
105
+ compressedConversationFile(platform, docId) {
106
+ return join2(this.platformDir(platform), `${slugifyDocId(docId)}.json.gz`);
107
+ }
108
+ /** v1 path accepted until confirmed/resumable compaction migrates it. */
109
+ legacyConversationFile(platform, docId) {
90
110
  return join2(this.platformDir(platform), `${slugifyDocId(docId)}.json`);
91
111
  }
92
112
  /**
@@ -114,8 +134,10 @@ function safeSegment(value) {
114
134
  }
115
135
 
116
136
  // src/corpus.ts
117
- import { promises as fs } from "fs";
137
+ import { promises as fs, readFileSync as readFileSync2 } from "fs";
118
138
  import { dirname, join as join3 } from "path";
139
+ import { promisify } from "util";
140
+ import { gzip as gzipCallback, gunzip as gunzipCallback, gunzipSync } from "zlib";
119
141
 
120
142
  // src/serialize.ts
121
143
  function canonicalizeConversation(record) {
@@ -212,6 +234,10 @@ function assignIfDefined(target, key, value) {
212
234
  // src/corpus.ts
213
235
  var DIR_MODE = 448;
214
236
  var FILE_MODE = 384;
237
+ var gzip = promisify(gzipCallback);
238
+ var gunzip = promisify(gunzipCallback);
239
+ var MAINTENANCE_WRITE_WAIT_MS = 2e3;
240
+ var MAINTENANCE_WRITE_POLL_MS = 50;
215
241
  var Corpus = class {
216
242
  paths;
217
243
  // Serialize writes within this process so concurrent upserts (inbox drain +
@@ -226,12 +252,14 @@ var Corpus = class {
226
252
  await fs.mkdir(this.paths.conversationsDir, { recursive: true, mode: DIR_MODE });
227
253
  await fs.mkdir(this.paths.memoriesDir, { recursive: true, mode: DIR_MODE });
228
254
  await fs.mkdir(this.paths.processedDir, { recursive: true, mode: DIR_MODE });
255
+ await fs.mkdir(this.paths.quarantineDir, { recursive: true, mode: DIR_MODE });
229
256
  await fs.mkdir(this.paths.indexDir, { recursive: true, mode: DIR_MODE });
230
257
  await hardenDir(this.paths.root);
231
258
  await hardenDir(this.paths.conversationsDir);
232
259
  await hardenDir(this.paths.memoriesDir);
233
260
  await hardenDir(this.paths.inboxDir);
234
261
  await hardenDir(this.paths.processedDir);
262
+ await hardenDir(this.paths.quarantineDir);
235
263
  await hardenDir(this.paths.indexDir);
236
264
  try {
237
265
  await fs.access(this.paths.metaFile);
@@ -273,10 +301,12 @@ var Corpus = class {
273
301
  */
274
302
  async upsert(record) {
275
303
  return this.enqueueWrite(async () => {
304
+ await waitForMaintenanceLease(this.paths.maintenanceLockFile);
276
305
  const canonical = canonicalizeConversation(record);
277
306
  const { conversation } = canonical;
278
- const file = this.paths.conversationFile(conversation.platform, conversation.docId);
279
- const existing = await readJsonIfExists(file);
307
+ const file = this.paths.compressedConversationFile(conversation.platform, conversation.docId);
308
+ const legacyFile = this.paths.legacyConversationFile(conversation.platform, conversation.docId);
309
+ const existing = await this.readBestConversation(conversation.platform, conversation.docId);
280
310
  if (existing) {
281
311
  const existingChangedAt = existing.conversation.corpusChangedAt ?? existing.conversation.updatedAt ?? 0;
282
312
  const incomingChangedAt = conversation.corpusChangedAt ?? conversation.updatedAt ?? 0;
@@ -286,8 +316,7 @@ var Corpus = class {
286
316
  return { docId: conversation.docId, outcome: "skipped" };
287
317
  }
288
318
  }
289
- const serialized = `${JSON.stringify(canonical, null, 2)}
290
- `;
319
+ const serialized = serializeConversation(canonical);
291
320
  if (existing) {
292
321
  const existingSerialized = `${JSON.stringify(canonicalizeConversation(existing), null, 2)}
293
322
  `;
@@ -296,22 +325,39 @@ var Corpus = class {
296
325
  }
297
326
  }
298
327
  await fs.mkdir(dirname(file), { recursive: true, mode: DIR_MODE });
299
- await writeFileAtomic(file, serialized);
328
+ await writeCompressedConversationAtomic(file, serialized);
329
+ await fs.rm(legacyFile, { force: true });
300
330
  return { docId: conversation.docId, outcome: existing ? "updated" : "created" };
301
331
  });
302
332
  }
303
333
  /** Read a conversation by docId, scanning platform dirs for the slug file. */
304
334
  async get(docId) {
305
- const filename = `${slugifyDocId(docId)}.json`;
306
335
  for (const platform of await this.listPlatforms()) {
307
- const candidate = join3(this.paths.platformDir(platform), filename);
308
- const record = await readJsonIfExists(candidate);
336
+ const record = await this.readBestConversation(platform, docId);
309
337
  if (record && record.conversation.docId === docId) {
310
338
  return record;
311
339
  }
312
340
  }
313
341
  return null;
314
342
  }
343
+ /** Direct form used by search snippet hydration, avoiding a platform scan. */
344
+ async getFromPlatform(platform, docId) {
345
+ const record = await this.readBestConversation(platform, docId);
346
+ return record?.conversation.docId === docId ? record : null;
347
+ }
348
+ /**
349
+ * Synchronous twin used after SQLite has ranked a small result set. The
350
+ * search connection is synchronous already (`node:sqlite` DatabaseSync), and
351
+ * reading at most `limit` local gzip files keeps the public retriever API
352
+ * stable while avoiding a second raw-text store inside FTS5.
353
+ */
354
+ getFromPlatformSync(platform, docId) {
355
+ const record = readBestConversationFilesSync(
356
+ this.paths.compressedConversationFile(platform, docId),
357
+ this.paths.legacyConversationFile(platform, docId)
358
+ );
359
+ return record?.conversation.docId === docId ? record : null;
360
+ }
315
361
  /**
316
362
  * Write an agent-authored memory, keyed on memoryId.
317
363
  *
@@ -385,17 +431,116 @@ var Corpus = class {
385
431
  } catch {
386
432
  continue;
387
433
  }
434
+ const stems = /* @__PURE__ */ new Set();
388
435
  for (const name of files) {
389
- if (!name.endsWith(".json")) {
390
- continue;
391
- }
392
- const record = await readJsonIfExists(join3(dir, name));
436
+ if (name.endsWith(".json.gz")) stems.add(name.slice(0, -".json.gz".length));
437
+ else if (name.endsWith(".json")) stems.add(name.slice(0, -".json".length));
438
+ }
439
+ for (const stem of [...stems].sort()) {
440
+ const record = await readBestConversationFiles(
441
+ join3(dir, `${stem}.json.gz`),
442
+ join3(dir, `${stem}.json`)
443
+ );
393
444
  if (record) {
394
445
  yield record;
395
446
  }
396
447
  }
397
448
  }
398
449
  }
450
+ /** Byte/count breakdown used by `stats` and `compact --dry-run`. */
451
+ async conversationStorageStats() {
452
+ const stats = {
453
+ legacyFiles: 0,
454
+ legacyBytes: 0,
455
+ compressedFiles: 0,
456
+ compressedBytes: 0
457
+ };
458
+ for (const platform of await this.listPlatforms()) {
459
+ const dir = this.paths.platformDir(platform);
460
+ let entries;
461
+ try {
462
+ entries = await fs.readdir(dir, { withFileTypes: true });
463
+ } catch {
464
+ continue;
465
+ }
466
+ for (const entry of entries) {
467
+ if (!entry.isFile()) continue;
468
+ const file = join3(dir, entry.name);
469
+ if (entry.name.endsWith(".json.gz")) {
470
+ stats.compressedFiles += 1;
471
+ stats.compressedBytes += (await fs.stat(file)).size;
472
+ } else if (entry.name.endsWith(".json")) {
473
+ stats.legacyFiles += 1;
474
+ stats.legacyBytes += (await fs.stat(file)).size;
475
+ }
476
+ }
477
+ }
478
+ return stats;
479
+ }
480
+ /**
481
+ * Convert every legacy conversation independently. Each candidate is written,
482
+ * read back, and byte-compared in canonical JSON form before its plaintext
483
+ * source is removed, making the operation resumable after any interruption.
484
+ */
485
+ async migrateLegacyConversations(options = {}) {
486
+ const dryRun = options.dryRun === true;
487
+ const yieldEvery = Math.max(0, Math.floor(options.yieldEvery ?? 0));
488
+ const before = await this.conversationStorageStats();
489
+ const report = {
490
+ ...before,
491
+ dryRun,
492
+ candidates: before.legacyFiles,
493
+ migrated: 0,
494
+ legacyRemoved: 0,
495
+ projectedCompressedBytes: before.compressedBytes,
496
+ failures: []
497
+ };
498
+ for (const platform of await this.listPlatforms()) {
499
+ const dir = this.paths.platformDir(platform);
500
+ let names;
501
+ try {
502
+ names = (await fs.readdir(dir)).filter((name) => name.endsWith(".json")).sort();
503
+ } catch {
504
+ continue;
505
+ }
506
+ for (const name of names) {
507
+ const legacyFile = join3(dir, name);
508
+ const compressedFile = join3(dir, `${name}.gz`);
509
+ try {
510
+ const migrateOne = async () => {
511
+ const legacy = await readPlainConversationIfExists(legacyFile);
512
+ if (!legacy) throw new Error("legacy JSON could not be parsed");
513
+ const compressed = await readCompressedConversationIfExists(compressedFile);
514
+ const winner = pickNewerConversation(compressed, legacy) ?? legacy;
515
+ const serialized = serializeConversation(canonicalizeConversation(winner));
516
+ const compressedBytes = await gzip(Buffer.from(serialized, "utf8"), { level: 9 });
517
+ report.projectedCompressedBytes -= await fileSizeIfExists(compressedFile);
518
+ report.projectedCompressedBytes += compressedBytes.byteLength;
519
+ if (!dryRun) {
520
+ await writeVerifiedCompressedConversationAtomic(compressedFile, compressedBytes, serialized);
521
+ await fs.rm(legacyFile);
522
+ }
523
+ };
524
+ if (dryRun) await migrateOne();
525
+ else await this.enqueueWrite(migrateOne);
526
+ report.migrated += 1;
527
+ report.legacyRemoved += 1;
528
+ } catch (error) {
529
+ report.failures.push({
530
+ file: legacyFile,
531
+ error: error instanceof Error ? error.message : String(error)
532
+ });
533
+ }
534
+ if (yieldEvery > 0 && (report.migrated + report.failures.length) % yieldEvery === 0) {
535
+ await yieldToEventLoop();
536
+ }
537
+ }
538
+ }
539
+ if (!dryRun && report.failures.length === 0) {
540
+ await this.writeMeta({ formatVersion: CORPUS_FORMAT_VERSION });
541
+ }
542
+ return report;
543
+ }
399
544
  async stats() {
400
545
  const byPlatform = {};
401
546
  let conversationCount = 0;
@@ -409,9 +554,13 @@ var Corpus = class {
409
554
  return { conversationCount, messageCount, byPlatform };
410
555
  }
411
556
  /** Refresh meta.json's conversationCount / lastIngestAt after an ingest run. */
412
- async touchIngestMeta() {
557
+ async touchIngestMeta(receipt) {
413
558
  const { conversationCount } = await this.stats();
414
- await this.writeMeta({ lastIngestAt: Date.now(), conversationCount });
559
+ await this.writeMeta({
560
+ lastIngestAt: Date.now(),
561
+ conversationCount,
562
+ ...receipt ? { lastIngestReceipt: receipt } : {}
563
+ });
415
564
  }
416
565
  enqueueWrite(fn) {
417
566
  const run = this.writeChain.then(fn, fn);
@@ -421,7 +570,30 @@ var Corpus = class {
421
570
  );
422
571
  return run;
423
572
  }
573
+ readBestConversation(platform, docId) {
574
+ return readBestConversationFiles(
575
+ this.paths.compressedConversationFile(platform, docId),
576
+ this.paths.legacyConversationFile(platform, docId)
577
+ );
578
+ }
424
579
  };
580
+ function yieldToEventLoop() {
581
+ return new Promise((resolve2) => setImmediate(resolve2));
582
+ }
583
+ async function waitForMaintenanceLease(file) {
584
+ const deadline = Date.now() + MAINTENANCE_WRITE_WAIT_MS;
585
+ while (true) {
586
+ try {
587
+ await fs.access(file);
588
+ } catch {
589
+ return;
590
+ }
591
+ if (Date.now() >= deadline) {
592
+ throw new Error("automatic corpus maintenance is in progress; retry this write shortly");
593
+ }
594
+ await new Promise((resolve2) => setTimeout(resolve2, MAINTENANCE_WRITE_POLL_MS));
595
+ }
596
+ }
425
597
  async function readJsonIfExists(file) {
426
598
  try {
427
599
  const raw = await fs.readFile(file, "utf8");
@@ -430,9 +602,83 @@ async function readJsonIfExists(file) {
430
602
  return null;
431
603
  }
432
604
  }
605
+ function serializeConversation(record) {
606
+ return `${JSON.stringify(canonicalizeConversation(record), null, 2)}
607
+ `;
608
+ }
609
+ async function readPlainConversationIfExists(file) {
610
+ return readJsonIfExists(file);
611
+ }
612
+ async function readCompressedConversationIfExists(file) {
613
+ try {
614
+ const raw = await fs.readFile(file);
615
+ return JSON.parse((await gunzip(raw)).toString("utf8"));
616
+ } catch {
617
+ return null;
618
+ }
619
+ }
620
+ async function readBestConversationFiles(compressedFile, legacyFile) {
621
+ const [compressed, legacy] = await Promise.all([
622
+ readCompressedConversationIfExists(compressedFile),
623
+ readPlainConversationIfExists(legacyFile)
624
+ ]);
625
+ return pickNewerConversation(compressed, legacy);
626
+ }
627
+ function readBestConversationFilesSync(compressedFile, legacyFile) {
628
+ let compressed = null;
629
+ let legacy = null;
630
+ try {
631
+ compressed = JSON.parse(gunzipSync(readFileSync2(compressedFile)).toString("utf8"));
632
+ } catch {
633
+ }
634
+ try {
635
+ legacy = JSON.parse(readFileSync2(legacyFile, "utf8"));
636
+ } catch {
637
+ }
638
+ return pickNewerConversation(compressed, legacy);
639
+ }
640
+ function pickNewerConversation(a, b) {
641
+ if (!a) return b;
642
+ if (!b) return a;
643
+ const aChanged = a.conversation.corpusChangedAt ?? a.conversation.updatedAt ?? 0;
644
+ const bChanged = b.conversation.corpusChangedAt ?? b.conversation.updatedAt ?? 0;
645
+ if (aChanged !== bChanged) return aChanged > bChanged ? a : b;
646
+ const aUpdated = a.conversation.updatedAt ?? 0;
647
+ const bUpdated = b.conversation.updatedAt ?? 0;
648
+ return aUpdated >= bUpdated ? a : b;
649
+ }
650
+ async function writeCompressedConversationAtomic(file, serialized) {
651
+ const compressed = await gzip(Buffer.from(serialized, "utf8"), { level: 9 });
652
+ await writeFileAtomic(file, compressed);
653
+ }
654
+ async function writeVerifiedCompressedConversationAtomic(file, compressed, serialized) {
655
+ const tmp = `${file}.migration-${process.pid}-${Date.now()}`;
656
+ try {
657
+ await fs.writeFile(tmp, compressed, { mode: FILE_MODE });
658
+ const verified = await readCompressedConversationIfExists(tmp);
659
+ if (!verified || serializeConversation(canonicalizeConversation(verified)) !== serialized) {
660
+ throw new Error("compressed read-back verification failed");
661
+ }
662
+ await fs.rename(tmp, file);
663
+ } catch (error) {
664
+ await fs.rm(tmp, { force: true });
665
+ throw error;
666
+ }
667
+ }
668
+ async function fileSizeIfExists(file) {
669
+ try {
670
+ return (await fs.stat(file)).size;
671
+ } catch {
672
+ return 0;
673
+ }
674
+ }
433
675
  async function writeFileAtomic(file, contents) {
434
676
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
435
- await fs.writeFile(tmp, contents, { encoding: "utf8", mode: FILE_MODE });
677
+ if (typeof contents === "string") {
678
+ await fs.writeFile(tmp, contents, { encoding: "utf8", mode: FILE_MODE });
679
+ } else {
680
+ await fs.writeFile(tmp, contents, { mode: FILE_MODE });
681
+ }
436
682
  await fs.rename(tmp, file);
437
683
  }
438
684
  async function hardenDir(dir) {
@@ -533,23 +779,46 @@ async function drainInbox(corpus, index, options = {}) {
533
779
  await corpus.init();
534
780
  const inbox = corpus.paths.inboxDir;
535
781
  const entries = await pendingInboxFiles(corpus);
536
- const total = { ...EMPTY_REPORT, files: 0, failed: 0 };
782
+ const total = {
783
+ ...EMPTY_REPORT,
784
+ files: 0,
785
+ failed: 0,
786
+ discarded: 0,
787
+ quarantined: 0
788
+ };
537
789
  for (const name of entries) {
538
790
  const src = join4(inbox, name);
539
791
  try {
540
792
  const report = await ingestFile(corpus, src, index);
541
793
  accumulate(total, report);
542
794
  total.files += 1;
543
- await moveToProcessed(corpus, src, name);
795
+ if (report.malformedLines > 0) {
796
+ await moveToQuarantine(corpus, src, name);
797
+ total.quarantined += 1;
798
+ } else {
799
+ await fs2.unlink(src);
800
+ total.discarded += 1;
801
+ }
544
802
  } catch (error) {
545
803
  if (await stillPending(src)) {
546
804
  total.failed += 1;
805
+ if (isCorruptDeltaError(error)) {
806
+ await moveToQuarantine(corpus, src, name);
807
+ total.quarantined += 1;
808
+ }
547
809
  options.onFileError?.(name, error);
548
810
  }
549
811
  }
550
812
  }
551
813
  if (total.files > 0) {
552
- await corpus.touchIngestMeta();
814
+ await corpus.touchIngestMeta({
815
+ files: total.files,
816
+ conversations: total.conversations,
817
+ messages: total.messages,
818
+ malformedLines: total.malformedLines,
819
+ discarded: total.discarded,
820
+ quarantined: total.quarantined
821
+ });
553
822
  }
554
823
  return total;
555
824
  }
@@ -561,16 +830,23 @@ async function stillPending(src) {
561
830
  return false;
562
831
  }
563
832
  }
564
- async function moveToProcessed(corpus, src, name) {
565
- const dest = join4(corpus.paths.processedDir, name);
833
+ async function moveToQuarantine(corpus, src, name) {
834
+ const dest = join4(corpus.paths.quarantineDir, name);
566
835
  try {
567
836
  await fs2.rename(src, dest);
568
837
  } catch {
569
- const alt = join4(corpus.paths.processedDir, `${Date.now()}-${name}`);
838
+ const alt = join4(corpus.paths.quarantineDir, `${Date.now()}-${name}`);
570
839
  await fs2.copyFile(src, alt);
571
840
  await fs2.unlink(src);
572
841
  }
573
842
  }
843
+ function isCorruptDeltaError(error) {
844
+ if (!error || typeof error !== "object" || !("code" in error)) {
845
+ return false;
846
+ }
847
+ const code = String(error.code ?? "");
848
+ return code === "Z_DATA_ERROR" || code === "Z_BUF_ERROR" || code.startsWith("ERR_ZLIB_");
849
+ }
574
850
  function isNdjson(name) {
575
851
  const lower = name.toLowerCase();
576
852
  return lower.endsWith(".jsonl") || lower.endsWith(".ndjson") || lower.endsWith(".jsonl.gz") || lower.endsWith(".ndjson.gz");
@@ -703,6 +979,7 @@ CREATE TABLE IF NOT EXISTS index_meta (
703
979
  var DROP_DERIVED_SQL = `
704
980
  DROP TABLE IF EXISTS conversations;
705
981
  DROP TABLE IF EXISTS messages_fts;
982
+ DROP TABLE IF EXISTS message_rows;
706
983
  DROP TABLE IF EXISTS memories;
707
984
  DROP TABLE IF EXISTS memories_fts;
708
985
  `;
@@ -728,11 +1005,18 @@ CREATE TABLE IF NOT EXISTS conversations (
728
1005
  CREATE INDEX IF NOT EXISTS idx_conversations_platform ON conversations(platform);
729
1006
  CREATE INDEX IF NOT EXISTS idx_conversations_createdAt ON conversations(createdAt);
730
1007
 
1008
+ CREATE TABLE IF NOT EXISTS message_rows (
1009
+ rowid INTEGER PRIMARY KEY,
1010
+ docId TEXT NOT NULL,
1011
+ msgIndex INTEGER NOT NULL,
1012
+ role TEXT NOT NULL
1013
+ );
1014
+ CREATE INDEX IF NOT EXISTS idx_message_rows_docId ON message_rows(docId);
1015
+
731
1016
  CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
732
- docId UNINDEXED,
733
- msgIndex UNINDEXED,
734
- role UNINDEXED,
735
1017
  text,
1018
+ content = '',
1019
+ contentless_delete = 1,
736
1020
  tokenize = 'porter unicode61'
737
1021
  );
738
1022
 
@@ -760,7 +1044,10 @@ var SearchIndex = class {
760
1044
  stmts;
761
1045
  /** Whether the on-disk index was a stale format version when it was opened. */
762
1046
  staleOnOpen;
763
- constructor(dbPath) {
1047
+ /** Canonical source used only to hydrate the few snippets returned by rank. */
1048
+ corpus;
1049
+ constructor(dbPath, corpus) {
1050
+ this.corpus = corpus;
764
1051
  hardenDbFile(dbPath);
765
1052
  this.db = new (loadSqlite()).DatabaseSync(dbPath);
766
1053
  this.db.exec("PRAGMA journal_mode = WAL");
@@ -771,12 +1058,19 @@ var SearchIndex = class {
771
1058
  this.db.exec(DROP_DERIVED_SQL);
772
1059
  }
773
1060
  this.db.exec(SCHEMA_SQL);
1061
+ this.db.exec("PRAGMA temp_store = MEMORY");
1062
+ this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS temp.snippet_fts USING fts5(
1063
+ text, tokenize = 'porter unicode61'
1064
+ )`);
774
1065
  this.setMeta("format_version", String(INDEX_FORMAT_VERSION));
775
1066
  this.stmts = {
776
- deleteFts: this.db.prepare("DELETE FROM messages_fts WHERE docId = ?"),
777
- insertFts: this.db.prepare(
778
- "INSERT INTO messages_fts (docId, msgIndex, role, text) VALUES (?, ?, ?, ?)"
1067
+ selectFtsRows: this.db.prepare("SELECT rowid FROM message_rows WHERE docId = ?"),
1068
+ deleteFts: this.db.prepare("DELETE FROM messages_fts WHERE rowid = ?"),
1069
+ deleteMessageRows: this.db.prepare("DELETE FROM message_rows WHERE docId = ?"),
1070
+ insertMessageRow: this.db.prepare(
1071
+ "INSERT INTO message_rows (docId, msgIndex, role) VALUES (?, ?, ?)"
779
1072
  ),
1073
+ insertFts: this.db.prepare("INSERT INTO messages_fts (rowid, text) VALUES (?, ?)"),
780
1074
  deleteConversation: this.db.prepare("DELETE FROM conversations WHERE docId = ?"),
781
1075
  insertConversation: this.db.prepare(
782
1076
  `INSERT INTO conversations
@@ -792,6 +1086,12 @@ var SearchIndex = class {
792
1086
  `INSERT INTO memories
793
1087
  (memoryId, kind, label, text, source, pinned, enabled, createdAt, updatedAt)
794
1088
  VALUES (@memoryId, @kind, @label, @text, @source, @pinned, @enabled, @createdAt, @updatedAt)`
1089
+ ),
1090
+ clearSnippetFts: this.db.prepare("DELETE FROM temp.snippet_fts"),
1091
+ insertSnippetFts: this.db.prepare("INSERT INTO temp.snippet_fts (text) VALUES (?)"),
1092
+ renderSnippet: this.db.prepare(
1093
+ `SELECT snippet(snippet_fts, 0, '[', ']', '\u2026', ${SNIPPET_TOKENS}) AS snippet
1094
+ FROM temp.snippet_fts WHERE snippet_fts MATCH ?`
795
1095
  )
796
1096
  };
797
1097
  }
@@ -855,7 +1155,7 @@ var SearchIndex = class {
855
1155
  }
856
1156
  removeConversation(docId) {
857
1157
  this.transaction(() => {
858
- this.stmts.deleteFts.run(docId);
1158
+ this.deleteFtsRows(docId);
859
1159
  this.stmts.deleteConversation.run(docId);
860
1160
  });
861
1161
  }
@@ -865,7 +1165,8 @@ var SearchIndex = class {
865
1165
  * reads, which the sync {@link transaction} helper cannot hold.
866
1166
  */
867
1167
  async rebuildFrom(corpus) {
868
- this.db.exec("DELETE FROM messages_fts; DELETE FROM conversations;");
1168
+ this.corpus = corpus;
1169
+ this.db.exec("DELETE FROM messages_fts; DELETE FROM message_rows; DELETE FROM conversations;");
869
1170
  this.db.exec("DELETE FROM memories_fts; DELETE FROM memories;");
870
1171
  let conversations = 0;
871
1172
  let memories = 0;
@@ -890,15 +1191,15 @@ var SearchIndex = class {
890
1191
  // transaction() helper for single upserts, or rebuildFrom's manual one).
891
1192
  writeRecord(rec) {
892
1193
  const { conversation, messages } = rec;
893
- this.stmts.deleteFts.run(conversation.docId);
1194
+ this.deleteFtsRows(conversation.docId);
894
1195
  this.stmts.deleteConversation.run(conversation.docId);
895
1196
  const titleText = [conversation.title, conversation.preview].filter(Boolean).join("\n");
896
1197
  if (titleText.trim()) {
897
- this.stmts.insertFts.run(conversation.docId, TITLE_ROW_INDEX, TITLE_ROLE, titleText);
1198
+ this.insertFtsRow(conversation.docId, TITLE_ROW_INDEX, TITLE_ROLE, titleText);
898
1199
  }
899
1200
  for (const message of messages) {
900
1201
  if (message.text.trim()) {
901
- this.stmts.insertFts.run(conversation.docId, message.msgIndex, message.role, message.text);
1202
+ this.insertFtsRow(conversation.docId, message.msgIndex, message.role, message.text);
902
1203
  }
903
1204
  }
904
1205
  this.stmts.insertConversation.run({
@@ -915,6 +1216,17 @@ var SearchIndex = class {
915
1216
  accountLabel: conversation.accountLabel ?? null
916
1217
  });
917
1218
  }
1219
+ insertFtsRow(docId, msgIndex, role, text) {
1220
+ const inserted = this.stmts.insertMessageRow.run(docId, msgIndex, role);
1221
+ this.stmts.insertFts.run(inserted.lastInsertRowid, text);
1222
+ }
1223
+ deleteFtsRows(docId) {
1224
+ const rows = this.stmts.selectFtsRows.all(docId);
1225
+ for (const row of rows) {
1226
+ this.stmts.deleteFts.run(row.rowid);
1227
+ }
1228
+ this.stmts.deleteMessageRows.run(docId);
1229
+ }
918
1230
  // Replace a memory's index rows. Caller supplies the transaction, as with
919
1231
  // writeRecord. Memories live in their own tables rather than in messages_fts:
920
1232
  // a memory is not a turn in a conversation, and mixing them would let a
@@ -968,9 +1280,9 @@ var SearchIndex = class {
968
1280
  c.docId, c.platform, c.title, c.url, c.createdAt, c.updatedAt,
969
1281
  c.messageCount, c.preview, c.pinned, c.accountId, c.accountLabel,
970
1282
  m.msgIndex AS msgIndex, m.role AS role,
971
- snippet(messages_fts, 3, '[', ']', '\u2026', ${SNIPPET_TOKENS}) AS snippet,
972
1283
  bm25(messages_fts) AS bm25
973
- FROM messages_fts m
1284
+ FROM messages_fts
1285
+ JOIN message_rows m ON m.rowid = messages_fts.rowid
974
1286
  JOIN conversations c ON c.docId = m.docId
975
1287
  WHERE ${clauses.join(" AND ")}
976
1288
  ORDER BY bm25
@@ -996,7 +1308,7 @@ var SearchIndex = class {
996
1308
  pinned: row.pinned === 1,
997
1309
  // BM25 is negative (more negative = better); expose a positive relevance.
998
1310
  score: -row.bm25,
999
- snippet: row.snippet,
1311
+ snippet: this.hydrateSnippet(row, match),
1000
1312
  matchRole: normalizeMatchRole(row.role),
1001
1313
  matchMsgIndex: row.msgIndex
1002
1314
  };
@@ -1009,6 +1321,25 @@ var SearchIndex = class {
1009
1321
  }
1010
1322
  return hits;
1011
1323
  }
1324
+ /** Reclaim free pages and merge FTS segments after a confirmed compaction. */
1325
+ compact() {
1326
+ this.db.exec("INSERT INTO messages_fts(messages_fts) VALUES('optimize')");
1327
+ this.db.exec("INSERT INTO memories_fts(memories_fts) VALUES('optimize')");
1328
+ this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1329
+ this.db.exec("VACUUM");
1330
+ }
1331
+ hydrateSnippet(row, match) {
1332
+ const record = this.corpus?.getFromPlatformSync(row.platform, row.docId);
1333
+ const source = row.msgIndex === TITLE_ROW_INDEX ? [record?.conversation.title ?? row.title, record?.conversation.preview ?? row.preview].filter(Boolean).join("\n") : record?.messages.find((message) => message.msgIndex === row.msgIndex)?.text ?? row.preview;
1334
+ this.stmts.clearSnippetFts.run();
1335
+ try {
1336
+ this.stmts.insertSnippetFts.run(source);
1337
+ const rendered = this.stmts.renderSnippet.get(match);
1338
+ return rendered?.snippet ?? fallbackSnippet(source, SNIPPET_TOKENS);
1339
+ } finally {
1340
+ this.stmts.clearSnippetFts.run();
1341
+ }
1342
+ }
1012
1343
  close() {
1013
1344
  this.db.close();
1014
1345
  }
@@ -1022,7 +1353,7 @@ var SearchIndex = class {
1022
1353
  };
1023
1354
  async function openSearchIndex(corpus, options = {}) {
1024
1355
  await corpus.init();
1025
- const index = new SearchIndex(corpus.paths.indexDbFile);
1356
+ const index = new SearchIndex(corpus.paths.indexDbFile, corpus);
1026
1357
  if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0 && index.countMemories() === 0)) {
1027
1358
  await index.rebuildFrom(corpus);
1028
1359
  }
@@ -1034,7 +1365,7 @@ async function deleteIndexDb(dbFile) {
1034
1365
  }
1035
1366
  }
1036
1367
  function toMatchQuery(query, mode = "any") {
1037
- const terms = query.split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 0);
1368
+ const terms = queryTerms(query);
1038
1369
  if (terms.length === 0) {
1039
1370
  return null;
1040
1371
  }
@@ -1043,6 +1374,17 @@ function toMatchQuery(query, mode = "any") {
1043
1374
  }
1044
1375
  return terms.map((t) => `"${t}"`).join(mode === "all" ? " AND " : " OR ");
1045
1376
  }
1377
+ function queryTerms(query) {
1378
+ return query.split(/[^\p{L}\p{N}]+/u).filter((term) => term.length > 0);
1379
+ }
1380
+ function fallbackSnippet(source, maxTokens) {
1381
+ const collapsed = source.replace(/\s+/g, " ").trim();
1382
+ if (!collapsed) return "";
1383
+ const words = collapsed.split(" ");
1384
+ const width = Math.max(1, maxTokens);
1385
+ const end = Math.min(words.length, width);
1386
+ return `${words.slice(0, end).join(" ")}${end < words.length ? "\u2026" : ""}`;
1387
+ }
1046
1388
  function escapeLike(value) {
1047
1389
  return value.replace(/[\\%_]/g, "\\$&");
1048
1390
  }
@@ -1053,43 +1395,421 @@ function normalizeMatchRole(role) {
1053
1395
  return role === "assistant" || role === "user" || role === "system" ? role : "unknown";
1054
1396
  }
1055
1397
 
1398
+ // src/storageMaintenance.ts
1399
+ import { promises as fs4 } from "fs";
1400
+ import { join as join5 } from "path";
1401
+ async function inspectCorpusStorage(corpus) {
1402
+ const conversations = await corpus.conversationStorageStats();
1403
+ const processed = await directoryFiles(corpus.paths.processedDir);
1404
+ const quarantine = await directoryFiles(corpus.paths.quarantineDir);
1405
+ const activeNames = [
1406
+ corpus.paths.indexDbFile,
1407
+ `${corpus.paths.indexDbFile}-wal`,
1408
+ `${corpus.paths.indexDbFile}-shm`
1409
+ ];
1410
+ const activeIndex = await explicitFiles(activeNames);
1411
+ const staleNames = await staleIndexFiles(corpus.paths.indexDir);
1412
+ const stalePaths = staleNames.flatMap((name) => {
1413
+ const base = join5(corpus.paths.indexDir, name);
1414
+ return [base, `${base}-wal`, `${base}-shm`];
1415
+ });
1416
+ const staleIndexes = await explicitFiles(stalePaths);
1417
+ const root = await directoryFiles(corpus.paths.root);
1418
+ const classifiedBytes = conversations.legacyBytes + conversations.compressedBytes + processed.bytes + quarantine.bytes + activeIndex.bytes + staleIndexes.bytes;
1419
+ const classifiedFiles = conversations.legacyFiles + conversations.compressedFiles + processed.files + quarantine.files + activeIndex.files + staleIndexes.files;
1420
+ const other = {
1421
+ files: Math.max(0, root.files - classifiedFiles),
1422
+ bytes: Math.max(0, root.bytes - classifiedBytes)
1423
+ };
1424
+ return {
1425
+ root: corpus.paths.root,
1426
+ conversations,
1427
+ processed,
1428
+ quarantine,
1429
+ activeIndex,
1430
+ staleIndexes,
1431
+ other,
1432
+ totalBytes: root.bytes,
1433
+ immediatelyReclaimableBytes: processed.bytes + staleIndexes.bytes
1434
+ };
1435
+ }
1436
+ async function compactCorpus(corpus, options = {}) {
1437
+ const dryRun = options.dryRun === true;
1438
+ const before = await inspectCorpusStorage(corpus);
1439
+ if (dryRun) {
1440
+ const compression = await corpus.migrateLegacyConversations({ dryRun: true });
1441
+ const compressionSavings = Math.max(
1442
+ 0,
1443
+ compression.legacyBytes + compression.compressedBytes - compression.projectedCompressedBytes
1444
+ );
1445
+ return {
1446
+ dryRun: true,
1447
+ before,
1448
+ compression,
1449
+ processedRemoved: before.processed,
1450
+ staleIndexesRemoved: before.staleIndexes,
1451
+ after: null,
1452
+ reclaimedBytes: before.immediatelyReclaimableBytes + compressionSavings
1453
+ };
1454
+ }
1455
+ await corpus.init();
1456
+ const processedRemoved = await removeDirectoryFiles(corpus.paths.processedDir);
1457
+ const index = options.index ?? await openSearchIndex(corpus);
1458
+ try {
1459
+ index.compact();
1460
+ } finally {
1461
+ if (!options.index) index.close();
1462
+ }
1463
+ const appliedCompression = await corpus.migrateLegacyConversations({
1464
+ yieldEvery: options.yieldEvery
1465
+ });
1466
+ if (appliedCompression.failures.length > 0) {
1467
+ throw new Error(
1468
+ `Corpus compression left ${appliedCompression.failures.length} legacy file(s) untouched; stale indexes were preserved. Run compact again after fixing the reported files.`
1469
+ );
1470
+ }
1471
+ const staleIndexesRemoved = await removeStaleIndexes(corpus);
1472
+ const after = await inspectCorpusStorage(corpus);
1473
+ return {
1474
+ dryRun: false,
1475
+ before,
1476
+ compression: appliedCompression,
1477
+ processedRemoved,
1478
+ staleIndexesRemoved,
1479
+ after,
1480
+ reclaimedBytes: Math.max(0, before.totalBytes - after.totalBytes)
1481
+ };
1482
+ }
1483
+ async function removeStaleIndexes(corpus) {
1484
+ const names = await staleIndexFiles(corpus.paths.indexDir);
1485
+ const paths = names.flatMap((name) => {
1486
+ const base = join5(corpus.paths.indexDir, name);
1487
+ return [base, `${base}-wal`, `${base}-shm`];
1488
+ });
1489
+ const before = await explicitFiles(paths);
1490
+ for (const name of names) {
1491
+ await deleteIndexDb(join5(corpus.paths.indexDir, name));
1492
+ }
1493
+ return before;
1494
+ }
1495
+ async function removeDirectoryFiles(dir) {
1496
+ const before = await directoryFiles(dir);
1497
+ await removeFilesRecursively(dir);
1498
+ return before;
1499
+ }
1500
+ async function removeFilesRecursively(dir) {
1501
+ let entries;
1502
+ try {
1503
+ entries = await fs4.readdir(dir, { withFileTypes: true });
1504
+ } catch {
1505
+ return;
1506
+ }
1507
+ for (const entry of entries) {
1508
+ const path = join5(dir, entry.name);
1509
+ if (entry.isDirectory()) await removeFilesRecursively(path);
1510
+ else if (entry.isFile()) await fs4.rm(path, { force: true });
1511
+ }
1512
+ }
1513
+ async function explicitFiles(files) {
1514
+ let count = 0;
1515
+ let bytes = 0;
1516
+ for (const file of files) {
1517
+ try {
1518
+ const stat = await fs4.stat(file);
1519
+ if (stat.isFile()) {
1520
+ count += 1;
1521
+ bytes += stat.size;
1522
+ }
1523
+ } catch {
1524
+ }
1525
+ }
1526
+ return { files: count, bytes };
1527
+ }
1528
+ async function directoryFiles(dir) {
1529
+ let entries;
1530
+ try {
1531
+ entries = await fs4.readdir(dir, { withFileTypes: true });
1532
+ } catch {
1533
+ return { files: 0, bytes: 0 };
1534
+ }
1535
+ let files = 0;
1536
+ let bytes = 0;
1537
+ for (const entry of entries) {
1538
+ const path = join5(dir, entry.name);
1539
+ if (entry.isDirectory()) {
1540
+ const nested = await directoryFiles(path);
1541
+ files += nested.files;
1542
+ bytes += nested.bytes;
1543
+ } else if (entry.isFile()) {
1544
+ files += 1;
1545
+ bytes += (await fs4.stat(path)).size;
1546
+ }
1547
+ }
1548
+ return { files, bytes };
1549
+ }
1550
+
1551
+ // src/autoMaintenance.ts
1552
+ import { constants as fsConstants, promises as fs5 } from "fs";
1553
+ import { dirname as dirname2 } from "path";
1554
+ var FILE_MODE2 = 384;
1555
+ var STATE_SCHEMA_VERSION = 1;
1556
+ var FORMAT_KEY = `corpus-${CORPUS_FORMAT_VERSION}-index-${INDEX_FORMAT_VERSION}`;
1557
+ var AUTO_MAINTENANCE_GRACE_MS = 3 * 24 * 60 * 60 * 1e3;
1558
+ var AUTO_MAINTENANCE_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1559
+ var AUTO_MAINTENANCE_RETRY_MS = 24 * 60 * 60 * 1e3;
1560
+ var AUTO_MAINTENANCE_LOCK_STALE_MS = 6 * 60 * 60 * 1e3;
1561
+ var AUTO_MAINTENANCE_YIELD_EVERY = 25;
1562
+ var AUTO_MAINTENANCE_SETTLE_MS = 1e3;
1563
+ function isAutomaticMaintenanceDisabled(env = process.env) {
1564
+ const raw = env.LLMNESIA_NO_AUTO_MAINTENANCE;
1565
+ return raw === "1" || raw === "true";
1566
+ }
1567
+ async function runAutomaticMaintenance(corpus, index, options = {}) {
1568
+ const env = options.env ?? process.env;
1569
+ if (isAutomaticMaintenanceDisabled(env)) return { action: "disabled" };
1570
+ await corpus.init();
1571
+ const now = options.now ?? Date.now;
1572
+ const at = now();
1573
+ const graceMs = options.graceMs ?? AUTO_MAINTENANCE_GRACE_MS;
1574
+ const recheckMs = options.recheckMs ?? AUTO_MAINTENANCE_RECHECK_MS;
1575
+ const retryMs = options.retryMs ?? AUTO_MAINTENANCE_RETRY_MS;
1576
+ const state = await readState(corpus);
1577
+ if (state?.formatKey === FORMAT_KEY && state.status === "complete" && state.completedAt !== void 0 && at - state.completedAt < recheckMs) {
1578
+ return { action: "recently-checked", completedAt: state.completedAt };
1579
+ }
1580
+ if (state?.formatKey === FORMAT_KEY && state.nextEligibleAt !== void 0 && at < state.nextEligibleAt) {
1581
+ return { action: "deferred", until: state.nextEligibleAt };
1582
+ }
1583
+ const before = await inspectCorpusStorage(corpus);
1584
+ if (!hasWork(before)) {
1585
+ await writeState(corpus, {
1586
+ schemaVersion: STATE_SCHEMA_VERSION,
1587
+ formatKey: FORMAT_KEY,
1588
+ status: "complete",
1589
+ firstSeenAt: state?.formatKey === FORMAT_KEY ? state.firstSeenAt : at,
1590
+ completedAt: at,
1591
+ beforeBytes: before.totalBytes,
1592
+ afterBytes: before.totalBytes,
1593
+ reclaimedBytes: 0
1594
+ });
1595
+ return { action: "no-work" };
1596
+ }
1597
+ const firstSeenAt = state?.formatKey === FORMAT_KEY ? state.firstSeenAt : at;
1598
+ const eligibleAt = firstSeenAt + Math.max(0, graceMs);
1599
+ if (at < eligibleAt) {
1600
+ await writeState(corpus, {
1601
+ schemaVersion: STATE_SCHEMA_VERSION,
1602
+ formatKey: FORMAT_KEY,
1603
+ status: "waiting",
1604
+ firstSeenAt,
1605
+ nextEligibleAt: eligibleAt,
1606
+ beforeBytes: before.totalBytes
1607
+ });
1608
+ return { action: "deferred", until: eligibleAt };
1609
+ }
1610
+ const lease = await acquireLease(
1611
+ corpus,
1612
+ at,
1613
+ options.lockStaleMs ?? AUTO_MAINTENANCE_LOCK_STALE_MS
1614
+ );
1615
+ if (!lease) return { action: "locked" };
1616
+ try {
1617
+ await delay(options.settleMs ?? AUTO_MAINTENANCE_SETTLE_MS);
1618
+ const lockedBefore = await inspectCorpusStorage(corpus);
1619
+ if (!hasWork(lockedBefore)) {
1620
+ await writeState(corpus, {
1621
+ schemaVersion: STATE_SCHEMA_VERSION,
1622
+ formatKey: FORMAT_KEY,
1623
+ status: "complete",
1624
+ firstSeenAt,
1625
+ completedAt: now(),
1626
+ beforeBytes: lockedBefore.totalBytes,
1627
+ afterBytes: lockedBefore.totalBytes,
1628
+ reclaimedBytes: 0
1629
+ });
1630
+ return { action: "no-work" };
1631
+ }
1632
+ await writeState(corpus, {
1633
+ schemaVersion: STATE_SCHEMA_VERSION,
1634
+ formatKey: FORMAT_KEY,
1635
+ status: "running",
1636
+ firstSeenAt,
1637
+ lastAttemptAt: at,
1638
+ beforeBytes: lockedBefore.totalBytes
1639
+ });
1640
+ try {
1641
+ const report = await compactCorpus(corpus, {
1642
+ index,
1643
+ yieldEvery: options.yieldEvery ?? AUTO_MAINTENANCE_YIELD_EVERY
1644
+ });
1645
+ const completedAt = now();
1646
+ await writeState(corpus, {
1647
+ schemaVersion: STATE_SCHEMA_VERSION,
1648
+ formatKey: FORMAT_KEY,
1649
+ status: "complete",
1650
+ firstSeenAt,
1651
+ lastAttemptAt: at,
1652
+ completedAt,
1653
+ beforeBytes: report.before.totalBytes,
1654
+ afterBytes: report.after?.totalBytes ?? report.before.totalBytes,
1655
+ reclaimedBytes: report.reclaimedBytes
1656
+ });
1657
+ return { action: "compacted", report };
1658
+ } catch (error) {
1659
+ const message = error instanceof Error ? error.message : String(error);
1660
+ const retryAt = now() + Math.max(0, retryMs);
1661
+ await writeState(corpus, {
1662
+ schemaVersion: STATE_SCHEMA_VERSION,
1663
+ formatKey: FORMAT_KEY,
1664
+ status: "failed",
1665
+ firstSeenAt,
1666
+ lastAttemptAt: at,
1667
+ nextEligibleAt: retryAt,
1668
+ beforeBytes: lockedBefore.totalBytes,
1669
+ lastError: message
1670
+ });
1671
+ return { action: "failed", error: message, retryAt };
1672
+ }
1673
+ } finally {
1674
+ await lease.release();
1675
+ }
1676
+ }
1677
+ async function readAutomaticMaintenanceState(corpus) {
1678
+ return readState(corpus);
1679
+ }
1680
+ function hasWork(report) {
1681
+ return report.conversations.legacyFiles > 0 || report.processed.files > 0 || report.staleIndexes.files > 0;
1682
+ }
1683
+ async function readState(corpus) {
1684
+ try {
1685
+ const parsed = JSON.parse(
1686
+ await fs5.readFile(corpus.paths.maintenanceStateFile, "utf8")
1687
+ );
1688
+ return parsed?.schemaVersion === STATE_SCHEMA_VERSION ? parsed : null;
1689
+ } catch {
1690
+ return null;
1691
+ }
1692
+ }
1693
+ async function writeState(corpus, state) {
1694
+ const file = corpus.paths.maintenanceStateFile;
1695
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
1696
+ await fs5.mkdir(dirname2(file), { recursive: true });
1697
+ try {
1698
+ const handle = await fs5.open(
1699
+ temp,
1700
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
1701
+ FILE_MODE2
1702
+ );
1703
+ try {
1704
+ await handle.writeFile(`${JSON.stringify(state, null, 2)}
1705
+ `, "utf8");
1706
+ } finally {
1707
+ await handle.close();
1708
+ }
1709
+ await fs5.rename(temp, file);
1710
+ } finally {
1711
+ await fs5.rm(temp, { force: true }).catch(() => {
1712
+ });
1713
+ }
1714
+ }
1715
+ async function acquireLease(corpus, now, staleMs) {
1716
+ const file = corpus.paths.maintenanceLockFile;
1717
+ const token = `${process.pid}-${now}-${Math.random().toString(16).slice(2)}`;
1718
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1719
+ try {
1720
+ const handle = await fs5.open(file, "wx", FILE_MODE2);
1721
+ try {
1722
+ await handle.writeFile(`${JSON.stringify({ token, pid: process.pid, startedAt: now })}
1723
+ `, "utf8");
1724
+ } finally {
1725
+ await handle.close();
1726
+ }
1727
+ return {
1728
+ token,
1729
+ release: async () => {
1730
+ try {
1731
+ const current = JSON.parse(await fs5.readFile(file, "utf8"));
1732
+ if (current.token === token) await fs5.rm(file, { force: true });
1733
+ } catch {
1734
+ }
1735
+ }
1736
+ };
1737
+ } catch (error) {
1738
+ if (!isAlreadyExists(error)) throw error;
1739
+ const existing = await readLease(file);
1740
+ const alive = existing?.pid ? isProcessAlive(existing.pid) : false;
1741
+ const age = existing?.startedAt ? now - existing.startedAt : Number.POSITIVE_INFINITY;
1742
+ if (alive && age < staleMs) return null;
1743
+ const stale = `${file}.stale-${token}`;
1744
+ try {
1745
+ await fs5.rename(file, stale);
1746
+ await fs5.rm(stale, { force: true });
1747
+ } catch {
1748
+ return null;
1749
+ }
1750
+ }
1751
+ }
1752
+ return null;
1753
+ }
1754
+ async function readLease(file) {
1755
+ try {
1756
+ return JSON.parse(await fs5.readFile(file, "utf8"));
1757
+ } catch {
1758
+ return null;
1759
+ }
1760
+ }
1761
+ function isProcessAlive(pid) {
1762
+ try {
1763
+ process.kill(pid, 0);
1764
+ return true;
1765
+ } catch (error) {
1766
+ return error.code === "EPERM";
1767
+ }
1768
+ }
1769
+ function isAlreadyExists(error) {
1770
+ return error.code === "EEXIST";
1771
+ }
1772
+ function delay(ms) {
1773
+ return ms > 0 ? new Promise((resolve2) => setTimeout(resolve2, ms)) : Promise.resolve();
1774
+ }
1775
+
1056
1776
  // src/runtimeInstall.ts
1057
1777
  import { cp, mkdir, readdir as readdir2, rename, rm, writeFile } from "fs/promises";
1058
1778
  import { existsSync } from "fs";
1059
1779
  import { homedir as homedir2 } from "os";
1060
- import { dirname as dirname2, join as join5 } from "path";
1780
+ import { dirname as dirname3, join as join6 } from "path";
1061
1781
  import { fileURLToPath } from "url";
1062
1782
  function defaultSourceDir() {
1063
- return dirname2(fileURLToPath(import.meta.url));
1783
+ return dirname3(fileURLToPath(import.meta.url));
1064
1784
  }
1065
1785
  function runtimeBinDir(home = homedir2()) {
1066
- return join5(home, ".llmnesia", "bin");
1786
+ return join6(home, ".llmnesia", "bin");
1067
1787
  }
1068
1788
  async function installRuntime(options = {}) {
1069
1789
  const home = options.home ?? homedir2();
1070
1790
  const sourceDir = options.sourceDir ?? defaultSourceDir();
1071
1791
  const nodePath = options.nodePath ?? process.execPath;
1072
1792
  const binDir = runtimeBinDir(home);
1073
- const sourceEntry = join5(sourceDir, "cli.js");
1793
+ const sourceEntry = join6(sourceDir, "cli.js");
1074
1794
  if (!existsSync(sourceEntry)) {
1075
1795
  throw new Error(
1076
1796
  `Cannot locate the built runtime: no cli.js in ${sourceDir}. Run this from an installed package (npx -y @llmnesia/mcp@latest install) or build first (npm run build).`
1077
1797
  );
1078
1798
  }
1079
- const parentDir = dirname2(binDir);
1799
+ const parentDir = dirname3(binDir);
1080
1800
  const nonce = `${process.pid}-${Date.now()}`;
1081
- const stagingDir = join5(parentDir, `.bin-install-${nonce}`);
1082
- const backupDir = join5(parentDir, `.bin-backup-${nonce}`);
1801
+ const stagingDir = join6(parentDir, `.bin-install-${nonce}`);
1802
+ const backupDir = join6(parentDir, `.bin-backup-${nonce}`);
1083
1803
  await mkdir(parentDir, { recursive: true });
1084
1804
  await rm(stagingDir, { recursive: true, force: true });
1085
1805
  await rm(backupDir, { recursive: true, force: true });
1086
1806
  await mkdir(stagingDir, { recursive: true });
1087
1807
  try {
1088
1808
  for (const name of await readdir2(sourceDir)) {
1089
- await cp(join5(sourceDir, name), join5(stagingDir, name), { recursive: true });
1809
+ await cp(join6(sourceDir, name), join6(stagingDir, name), { recursive: true });
1090
1810
  }
1091
1811
  await writeFile(
1092
- join5(stagingDir, "package.json"),
1812
+ join6(stagingDir, "package.json"),
1093
1813
  `${JSON.stringify(
1094
1814
  {
1095
1815
  name: "llmnesia-mcp-runtime",
@@ -1123,7 +1843,7 @@ async function installRuntime(options = {}) {
1123
1843
  } finally {
1124
1844
  await rm(stagingDir, { recursive: true, force: true });
1125
1845
  }
1126
- const entry = join5(binDir, "cli.js");
1846
+ const entry = join6(binDir, "cli.js");
1127
1847
  return {
1128
1848
  binDir,
1129
1849
  entry,
@@ -1144,7 +1864,7 @@ async function uninstallRuntime(home = homedir2()) {
1144
1864
  // src/selfUpdate.ts
1145
1865
  import { spawn } from "child_process";
1146
1866
  import { mkdtemp, readFile, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1147
- import { dirname as dirname3, join as join6 } from "path";
1867
+ import { dirname as dirname4, join as join7 } from "path";
1148
1868
  import { homedir as homedir3, tmpdir } from "os";
1149
1869
  import { fileURLToPath as fileURLToPath2 } from "url";
1150
1870
  var PACKAGE_NAME = "@llmnesia/mcp";
@@ -1154,9 +1874,9 @@ var REGISTRY_TIMEOUT_MS = 5e3;
1154
1874
  var INSTALL_TIMEOUT_MS = 12e4;
1155
1875
  var UPDATE_CHECK_DELAY_MS = 3e4;
1156
1876
  function stateFile(home) {
1157
- return join6(home, ".llmnesia", "update-state.json");
1877
+ return join7(home, ".llmnesia", "update-state.json");
1158
1878
  }
1159
- async function readState(home) {
1879
+ async function readState2(home) {
1160
1880
  try {
1161
1881
  const parsed = JSON.parse(await readFile(stateFile(home), "utf8"));
1162
1882
  return typeof parsed === "object" && parsed !== null ? parsed : {};
@@ -1164,7 +1884,7 @@ async function readState(home) {
1164
1884
  return {};
1165
1885
  }
1166
1886
  }
1167
- async function writeState(home, state) {
1887
+ async function writeState2(home, state) {
1168
1888
  try {
1169
1889
  await writeFile2(stateFile(home), `${JSON.stringify(state, null, 2)}
1170
1890
  `, "utf8");
@@ -1216,11 +1936,11 @@ async function fetchLatestFromRegistry() {
1216
1936
  }
1217
1937
  }
1218
1938
  function npmCommand() {
1219
- const binDir = dirname3(process.execPath);
1220
- return process.platform === "win32" ? join6(binDir, "npm.cmd") : join6(binDir, "npm");
1939
+ const binDir = dirname4(process.execPath);
1940
+ return process.platform === "win32" ? join7(binDir, "npm.cmd") : join7(binDir, "npm");
1221
1941
  }
1222
1942
  async function npmDownload(version) {
1223
- const prefix = await mkdtemp(join6(tmpdir(), "llmnesia-update-"));
1943
+ const prefix = await mkdtemp(join7(tmpdir(), "llmnesia-update-"));
1224
1944
  await new Promise((resolve2, reject) => {
1225
1945
  const child = spawn(
1226
1946
  npmCommand(),
@@ -1255,7 +1975,7 @@ async function npmDownload(version) {
1255
1975
  }
1256
1976
  });
1257
1977
  });
1258
- return join6(prefix, "node_modules", PACKAGE_NAME, "dist");
1978
+ return join7(prefix, "node_modules", PACKAGE_NAME, "dist");
1259
1979
  }
1260
1980
  async function maybeSelfUpdate(options = {}) {
1261
1981
  const env = options.env ?? process.env;
@@ -1264,7 +1984,7 @@ async function maybeSelfUpdate(options = {}) {
1264
1984
  }
1265
1985
  const home = options.home ?? homedir3();
1266
1986
  const currentVersion = options.currentVersion ?? VERSION;
1267
- const runningDir = options.runningDir ?? dirname3(fileURLToPath2(import.meta.url));
1987
+ const runningDir = options.runningDir ?? dirname4(fileURLToPath2(import.meta.url));
1268
1988
  if (runningDir !== runtimeBinDir(home)) {
1269
1989
  return { action: "not-installed-runtime" };
1270
1990
  }
@@ -1272,7 +1992,7 @@ async function maybeSelfUpdate(options = {}) {
1272
1992
  return { action: "not-a-release-build", currentVersion };
1273
1993
  }
1274
1994
  const now = options.now ?? Date.now;
1275
- const state = await readState(home);
1995
+ const state = await readState2(home);
1276
1996
  if (state.lastCheckedAt !== void 0 && now() - state.lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) {
1277
1997
  return { action: "throttled" };
1278
1998
  }
@@ -1280,30 +2000,30 @@ async function maybeSelfUpdate(options = {}) {
1280
2000
  try {
1281
2001
  latest = await (options.fetchLatestVersion ?? fetchLatestFromRegistry)();
1282
2002
  } catch (err) {
1283
- await writeState(home, { ...state, lastCheckedAt: now() });
2003
+ await writeState2(home, { ...state, lastCheckedAt: now() });
1284
2004
  return { action: "check-failed", error: err instanceof Error ? err.message : String(err) };
1285
2005
  }
1286
2006
  const checked = { ...state, lastCheckedAt: now(), lastSeenVersion: latest };
1287
2007
  if (!isStableRelease(latest) || compareVersions(latest, currentVersion) <= 0) {
1288
- await writeState(home, checked);
2008
+ await writeState2(home, checked);
1289
2009
  return { action: "up-to-date", latest };
1290
2010
  }
1291
2011
  if (state.lastFailedVersion === latest) {
1292
- await writeState(home, checked);
2012
+ await writeState2(home, checked);
1293
2013
  return { action: "skipped-failed-before", latest };
1294
2014
  }
1295
2015
  let sourceDir;
1296
2016
  try {
1297
2017
  sourceDir = await (options.downloadPackage ?? npmDownload)(latest);
1298
2018
  await installRuntime({ home, sourceDir, version: latest });
1299
- await writeState(home, { ...checked, lastUpdatedTo: latest, lastFailedVersion: void 0 });
2019
+ await writeState2(home, { ...checked, lastUpdatedTo: latest, lastFailedVersion: void 0 });
1300
2020
  return { action: "updated", from: currentVersion, to: latest };
1301
2021
  } catch (err) {
1302
- await writeState(home, { ...checked, lastFailedVersion: latest });
2022
+ await writeState2(home, { ...checked, lastFailedVersion: latest });
1303
2023
  return { action: "update-failed", latest, error: err instanceof Error ? err.message : String(err) };
1304
2024
  } finally {
1305
2025
  if (sourceDir) {
1306
- await rm2(join6(sourceDir, "..", "..", ".."), { recursive: true, force: true }).catch(() => {
2026
+ await rm2(join7(sourceDir, "..", "..", ".."), { recursive: true, force: true }).catch(() => {
1307
2027
  });
1308
2028
  }
1309
2029
  }
@@ -1352,6 +2072,11 @@ export {
1352
2072
  SearchIndex,
1353
2073
  openSearchIndex,
1354
2074
  deleteIndexDb,
2075
+ inspectCorpusStorage,
2076
+ compactCorpus,
2077
+ AUTO_MAINTENANCE_RECHECK_MS,
2078
+ runAutomaticMaintenance,
2079
+ readAutomaticMaintenanceState,
1355
2080
  runtimeBinDir,
1356
2081
  installRuntime,
1357
2082
  uninstallRuntime,