@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.
- package/README.md +35 -0
- package/dist/{chunk-6H2TI5IW.js → chunk-BDR4JVMF.js} +1 -1
- package/dist/{chunk-G42RV536.js → chunk-MTPXE734.js} +793 -68
- package/dist/{cli-77JECNWA.js → cli-OXKBAUBE.js} +81 -6
- package/dist/cli.js +2 -2
- package/dist/llmnesia.mcpb +0 -0
- package/dist/mcp.js +95 -4
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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-
|
|
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 =
|
|
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 =
|
|
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.
|
|
279
|
-
const
|
|
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 =
|
|
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
|
|
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
|
|
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 (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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({
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
565
|
-
const dest = join4(corpus.paths.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1780
|
+
import { dirname as dirname3, join as join6 } from "path";
|
|
1061
1781
|
import { fileURLToPath } from "url";
|
|
1062
1782
|
function defaultSourceDir() {
|
|
1063
|
-
return
|
|
1783
|
+
return dirname3(fileURLToPath(import.meta.url));
|
|
1064
1784
|
}
|
|
1065
1785
|
function runtimeBinDir(home = homedir2()) {
|
|
1066
|
-
return
|
|
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 =
|
|
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 =
|
|
1799
|
+
const parentDir = dirname3(binDir);
|
|
1080
1800
|
const nonce = `${process.pid}-${Date.now()}`;
|
|
1081
|
-
const stagingDir =
|
|
1082
|
-
const backupDir =
|
|
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(
|
|
1809
|
+
await cp(join6(sourceDir, name), join6(stagingDir, name), { recursive: true });
|
|
1090
1810
|
}
|
|
1091
1811
|
await writeFile(
|
|
1092
|
-
|
|
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 =
|
|
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
|
|
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
|
|
1877
|
+
return join7(home, ".llmnesia", "update-state.json");
|
|
1158
1878
|
}
|
|
1159
|
-
async function
|
|
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
|
|
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 =
|
|
1220
|
-
return process.platform === "win32" ?
|
|
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(
|
|
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
|
|
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 ??
|
|
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
|
|
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
|
|
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
|
|
2008
|
+
await writeState2(home, checked);
|
|
1289
2009
|
return { action: "up-to-date", latest };
|
|
1290
2010
|
}
|
|
1291
2011
|
if (state.lastFailedVersion === latest) {
|
|
1292
|
-
await
|
|
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
|
|
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
|
|
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(
|
|
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,
|