claude-mem-lite 6.25.0 → 6.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-update.mjs +180 -33
- package/install.mjs +325 -74
- package/lib/doctor-stale-temp.mjs +117 -14
- package/lib/schema-skew.mjs +5 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.25.
|
|
12
|
+
"version": "6.25.1",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.25.
|
|
3
|
+
"version": "6.25.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/hook-update.mjs
CHANGED
|
@@ -828,11 +828,15 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
|
|
|
828
828
|
// updater that was killed mid-swap (it applies the same staleness bound).
|
|
829
829
|
const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress'); // runtime-dir:stays-put — installation identity
|
|
830
830
|
// Intent journal, written INSIDE the backup dir before each rename. On a hard kill
|
|
831
|
-
// the backup dir survives (
|
|
831
|
+
// the backup dir survives (a normal exit deletes it, unless its journal cannot be
|
|
832
|
+
// removed, D#283) and this file says exactly
|
|
832
833
|
// which paths were in flight, so the next entry can finish the rollback at the right
|
|
833
834
|
// granularity — a bare directory walk cannot tell a nested relPath from a directory
|
|
834
835
|
// relPath like `node_modules`.
|
|
835
|
-
const SWAP_JOURNAL = '.swap-journal.json';
|
|
836
|
+
const SWAP_JOURNAL = '.swap-journal.json'; // doctor and cleanup spell it SWAP_JOURNAL_NAME (lib/doctor-stale-temp.mjs)
|
|
837
|
+
// Written into the backup dir when its swap is over (committed, or rolled back): never replay it
|
|
838
|
+
// (D#292 C). Doctor and cleanup spell it SWAP_RESOLVED_NAME.
|
|
839
|
+
const SWAP_RESOLVED = '.swap-resolved';
|
|
836
840
|
|
|
837
841
|
function markSwapStart() {
|
|
838
842
|
try {
|
|
@@ -856,19 +860,131 @@ function clearSwapMarker() {
|
|
|
856
860
|
// backup dir but nothing records it — recovery would then delete the backup dir with
|
|
857
861
|
// the only copy of that file inside it. rollbackInstall guards every entry with
|
|
858
862
|
// existsSync/force, so an intent that never happened is a harmless no-op.
|
|
863
|
+
//
|
|
864
|
+
// By temp file and rename, and it throws (D#289). writeFileSync truncates the journal before it
|
|
865
|
+
// writes it, so a kill or ENOSPC in between left an empty journal, which recovery read as "nothing
|
|
866
|
+
// moved" and then deleted the backups. A write error was swallowed, so the rename it was meant to
|
|
867
|
+
// record went ahead unrecorded. Now the previous journal stays whole until the new one replaces
|
|
868
|
+
// it, and a swap that cannot journal stops before the rename, through the rollback in the caller.
|
|
869
|
+
//
|
|
870
|
+
// Retried for a moment on EPERM/EBUSY/EACCES: the rename replaces a file just written, about twice
|
|
871
|
+
// per switched path, and on Windows a scanner holding that file makes a rename over it fail
|
|
872
|
+
// briefly. Each failure stopped the update and rolled it back, and a rollback is where a restore
|
|
873
|
+
// can fail (D#292 A). Bounded at about 0.7 s, then it throws as before.
|
|
874
|
+
const JOURNAL_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
859
875
|
function journalSwap(backupDir, backedUp, installed) {
|
|
876
|
+
const data = JSON.stringify({ backedUp, installed });
|
|
877
|
+
for (let attempt = 1; ; attempt++) {
|
|
878
|
+
try {
|
|
879
|
+
atomicWriteFileSync(join(backupDir, SWAP_JOURNAL), data);
|
|
880
|
+
return;
|
|
881
|
+
} catch (e) {
|
|
882
|
+
if (attempt >= 8 || !JOURNAL_RETRY_CODES.has(e.code)) throw e;
|
|
883
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Clean up after a swap that is over: committed, or rolled back. Never throws, because a cleanup
|
|
889
|
+
// that throws reaches the caller's catch, which rolls the swap back again (D#283; EBUSY is
|
|
890
|
+
// ordinary on Windows and `force` does not suppress it).
|
|
891
|
+
//
|
|
892
|
+
// The dir is marked resolved first (D#292 C). A journal left behind (it could not be removed, or a
|
|
893
|
+
// kill came first) was replayed by the next recovery: over a committed swap that put the previous
|
|
894
|
+
// release back, and over a rolled-back one whose journal outlived a later swap it deleted a path
|
|
895
|
+
// that swap had installed. Recovery, install, cleanup and doctor leave a resolved dir's journal
|
|
896
|
+
// alone. The marker is a new empty file, so it needs no access to the journal a failed removal
|
|
897
|
+
// could not get. With the marker written, a journal that stays is inert and the backups go anyway,
|
|
898
|
+
// child by child around the journal and the marker: a recursive removal of the dir takes children
|
|
899
|
+
// in directory order and stops at the first failure, so it could take the marker and then stop at
|
|
900
|
+
// the journal (v6.25.1 delta review). If the marker cannot be written either, the journal still
|
|
901
|
+
// goes before the backups, and if it stays, so do the backups, complete, so that a replay restores
|
|
902
|
+
// one whole release.
|
|
903
|
+
function discardBackupDir(backupDir) {
|
|
904
|
+
let resolved = false;
|
|
905
|
+
try {
|
|
906
|
+
writeFileSync(join(backupDir, SWAP_RESOLVED), '');
|
|
907
|
+
resolved = true;
|
|
908
|
+
} catch (e) {
|
|
909
|
+
debugCatch(e, 'mark-swap-resolved');
|
|
910
|
+
}
|
|
911
|
+
try {
|
|
912
|
+
rmSync(join(backupDir, SWAP_JOURNAL), { force: true });
|
|
913
|
+
} catch (e) {
|
|
914
|
+
debugCatch(e, 'discard-swap-journal');
|
|
915
|
+
if (resolved) removeBackupsBeside(backupDir);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
920
|
+
} catch (e) {
|
|
921
|
+
debugCatch(e, 'discard-backup-dir');
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function removeBackupsBeside(backupDir) {
|
|
926
|
+
let names = [];
|
|
927
|
+
try {
|
|
928
|
+
names = readdirSync(backupDir);
|
|
929
|
+
} catch (e) {
|
|
930
|
+
debugCatch(e, 'discard-backups-list');
|
|
931
|
+
}
|
|
932
|
+
for (const name of names) {
|
|
933
|
+
if (name === SWAP_JOURNAL || name === SWAP_RESOLVED) continue;
|
|
934
|
+
try {
|
|
935
|
+
rmSync(join(backupDir, name), { recursive: true, force: true });
|
|
936
|
+
} catch (e) {
|
|
937
|
+
debugCatch(e, 'discard-backup');
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Mark resolved the backup dirs a swap found left over and unfinished (recovery leaves one whose
|
|
943
|
+
// journal it cannot read right now). Called once that swap has committed: it replaced every path
|
|
944
|
+
// it switches, so a later replay of an older journal, once it can be read, would only mix the
|
|
945
|
+
// releases back (v6.25.1 delta review). The same retirement install does after writing the tree.
|
|
946
|
+
function retireUnresolvedBackups(targetDir, names) {
|
|
947
|
+
for (const name of names) {
|
|
948
|
+
try {
|
|
949
|
+
writeFileSync(join(targetDir, name, SWAP_RESOLVED), '');
|
|
950
|
+
debugLog('WARN', 'hook-update', `Retired ${name}: the committed swap replaced what its journal names`);
|
|
951
|
+
} catch (e) {
|
|
952
|
+
debugCatch(e, 'retire-unresolved-backup');
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// The `.update-backup-*` dirs in targetDir that carry no resolved marker.
|
|
958
|
+
function unresolvedBackups(targetDir) {
|
|
959
|
+
try {
|
|
960
|
+
return readdirSync(targetDir, { withFileTypes: true })
|
|
961
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('.update-backup-'))
|
|
962
|
+
.map((e) => e.name)
|
|
963
|
+
.filter((name) => !existsSync(join(targetDir, name, SWAP_RESOLVED)));
|
|
964
|
+
} catch (e) {
|
|
965
|
+
debugCatch(e, 'list-unresolved-backups');
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// The backup dir is resolved before staging goes: removing a whole staging tree takes long enough
|
|
971
|
+
// for a kill to land in, and a kill there left a committed swap's journal to be replayed.
|
|
972
|
+
function discardSwapDirs(stagingDir, backupDir) {
|
|
973
|
+
discardBackupDir(backupDir);
|
|
860
974
|
try {
|
|
861
|
-
|
|
975
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
862
976
|
} catch (e) {
|
|
863
|
-
debugCatch(e, '
|
|
977
|
+
debugCatch(e, 'discard-staging-dir');
|
|
864
978
|
}
|
|
865
979
|
}
|
|
866
980
|
|
|
867
981
|
/**
|
|
868
982
|
* Finish any swap a previous process was killed in the middle of, then clear its
|
|
869
983
|
* residue. Called on every install entry, under the install lock, BEFORE a new
|
|
870
|
-
* staging/backup pair is created.
|
|
871
|
-
*
|
|
984
|
+
* staging/backup pair is created; and by install.mjs, under the same lock, before
|
|
985
|
+
* `install` (and so `repair`) writes the tree and from `cleanup` (D#289).
|
|
986
|
+
* @returns {number} number of backup dirs processed, with a journal or without; one left in place
|
|
987
|
+
* because its journal cannot be read is not counted
|
|
872
988
|
*/
|
|
873
989
|
export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
874
990
|
let entries;
|
|
@@ -895,21 +1011,43 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
895
1011
|
}
|
|
896
1012
|
if (!entry.name.startsWith('.update-backup-')) continue;
|
|
897
1013
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1014
|
+
// Its swap is over and only its cleanup failed: remove what is left, never replay it (D#292 C).
|
|
1015
|
+
if (existsSync(join(dir, SWAP_RESOLVED))) {
|
|
1016
|
+
discardBackupDir(dir);
|
|
1017
|
+
recovered++;
|
|
1018
|
+
continue;
|
|
903
1019
|
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
//
|
|
907
|
-
|
|
1020
|
+
|
|
1021
|
+
// No journal: the swap is over and its journal is gone (or it was killed before its first
|
|
1022
|
+
// rename), so there is nothing to replay and the dir is residue. A journal that is there but
|
|
1023
|
+
// cannot be read, or not as one (an in-place write of 6.25.0 or older torn by a kill), says
|
|
1024
|
+
// nothing about what moved, and the dir may hold the only copy of it: leave it. It used to be
|
|
1025
|
+
// read as "nothing moved" and deleted (v6.25.1 review). doctor and cleanup name it; the next
|
|
1026
|
+
// swap that commits, or an install, retires it.
|
|
1027
|
+
let journal = { backedUp: [], installed: [] };
|
|
1028
|
+
let raw = null;
|
|
908
1029
|
try {
|
|
909
|
-
|
|
1030
|
+
raw = readFileSync(join(dir, SWAP_JOURNAL), 'utf8');
|
|
910
1031
|
} catch (e) {
|
|
911
|
-
|
|
1032
|
+
if (e.code !== 'ENOENT') {
|
|
1033
|
+
debugCatch(e, 'recover-read-journal');
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (raw !== null) {
|
|
1038
|
+
try {
|
|
1039
|
+
journal = JSON.parse(raw);
|
|
1040
|
+
} catch {
|
|
1041
|
+
journal = null;
|
|
1042
|
+
}
|
|
1043
|
+
if (!Array.isArray(journal?.backedUp) || !Array.isArray(journal?.installed)) {
|
|
1044
|
+
debugLog('WARN', 'hook-update', `Left ${entry.name} in place: its swap journal cannot be read`);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
912
1047
|
}
|
|
1048
|
+
const { backedUp, installed } = journal;
|
|
1049
|
+
rollbackInstall(installed, backedUp, dir, targetDir);
|
|
1050
|
+
discardBackupDir(dir);
|
|
913
1051
|
recovered++;
|
|
914
1052
|
debugLog(
|
|
915
1053
|
'WARN',
|
|
@@ -923,17 +1061,27 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
923
1061
|
// A rollback swaps a file SET back just as the install swapped it in, so it holds the same
|
|
924
1062
|
// marker for the same reason (D#239 g). After the MED-5 smoke gate the loop has already cleared
|
|
925
1063
|
// it; after a throw inside the loop it is still set, and this call takes it over.
|
|
1064
|
+
//
|
|
1065
|
+
// Safe to run twice over one journal (D#283). A backed-up path whose backup is gone holds the OLD
|
|
1066
|
+
// file already, restored by an earlier rollback or never moved out, so it is not deleted: a
|
|
1067
|
+
// second rollback used to delete every installed path, by then the only copy of the old install.
|
|
1068
|
+
// That holds for a journal this code wrote: its cleanup removes the journal before any backup, and
|
|
1069
|
+
// when the journal cannot be removed, the resolved marker keeps it from being replayed. A backup
|
|
1070
|
+
// dir 6.25.0 or older left half deleted beside its journal can still replay to a mix.
|
|
1071
|
+
// The arrays are copied, not reversed in place, so a caller's journal is left as it was.
|
|
926
1072
|
function rollbackInstall(installed, backedUp, backupDir, targetDir) {
|
|
927
1073
|
markSwapStart();
|
|
928
1074
|
try {
|
|
929
|
-
|
|
1075
|
+
const restored = new Set(backedUp.filter((relPath) => !existsSync(join(backupDir, relPath))));
|
|
1076
|
+
for (const relPath of [...installed].reverse()) {
|
|
1077
|
+
if (restored.has(relPath)) continue;
|
|
930
1078
|
try {
|
|
931
1079
|
rmSync(join(targetDir, relPath), { recursive: true, force: true });
|
|
932
1080
|
} catch {
|
|
933
1081
|
/* best-effort */
|
|
934
1082
|
}
|
|
935
1083
|
}
|
|
936
|
-
for (const relPath of backedUp.reverse()) {
|
|
1084
|
+
for (const relPath of [...backedUp].reverse()) {
|
|
937
1085
|
const backupPath = join(backupDir, relPath);
|
|
938
1086
|
const targetPath = join(targetDir, relPath);
|
|
939
1087
|
try {
|
|
@@ -1049,6 +1197,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1049
1197
|
const backupDir = join(targetDir, `.update-backup-${ts}`);
|
|
1050
1198
|
const backedUp = [];
|
|
1051
1199
|
const installed = [];
|
|
1200
|
+
let committed = false;
|
|
1052
1201
|
|
|
1053
1202
|
const manifest = await loadReleaseManifest(sourceDir);
|
|
1054
1203
|
const switchablePaths = buildSwitchablePaths(manifest.SOURCE_FILES);
|
|
@@ -1058,6 +1207,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1058
1207
|
// one — otherwise this install stacks on top of a mixed-version tree and its
|
|
1059
1208
|
// own backup can no longer restore a coherent state.
|
|
1060
1209
|
recoverInterruptedSwaps(targetDir);
|
|
1210
|
+
// What recovery left unfinished (a journal it cannot read right now) is retired if this swap
|
|
1211
|
+
// commits, and left for the next entry if it rolls back.
|
|
1212
|
+
const leftUnresolved = unresolvedBackups(targetDir);
|
|
1061
1213
|
|
|
1062
1214
|
mkdirSync(stagingDir, { recursive: true });
|
|
1063
1215
|
mkdirSync(backupDir, { recursive: true });
|
|
@@ -1106,13 +1258,14 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1106
1258
|
// keeps working rather than leaving a broken install with no way back.
|
|
1107
1259
|
if (!opts.skipSmoke && !smokeInstalledRelease(targetDir)) {
|
|
1108
1260
|
rollbackInstall(installed, backedUp, backupDir, targetDir);
|
|
1109
|
-
|
|
1110
|
-
rmSync(backupDir, { recursive: true, force: true });
|
|
1261
|
+
discardSwapDirs(stagingDir, backupDir);
|
|
1111
1262
|
return false;
|
|
1112
1263
|
}
|
|
1113
1264
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1265
|
+
// Committed: from here on the catch below must not roll back (D#283).
|
|
1266
|
+
committed = true;
|
|
1267
|
+
retireUnresolvedBackups(targetDir, leftUnresolved);
|
|
1268
|
+
discardSwapDirs(stagingDir, backupDir);
|
|
1116
1269
|
|
|
1117
1270
|
// Post-update migration: reconcile settings.json against the release we just
|
|
1118
1271
|
// swapped in. `configureHooks()` already strips stale mem entries — but its only
|
|
@@ -1198,17 +1351,11 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1198
1351
|
return true;
|
|
1199
1352
|
} catch (err) {
|
|
1200
1353
|
debugCatch(err, 'installExtractedRelease');
|
|
1354
|
+
// Past the commit point the backups are gone and the new release IS the install: a rollback
|
|
1355
|
+
// would delete the paths it added and restore nothing (D#283).
|
|
1356
|
+
if (committed) return true;
|
|
1201
1357
|
rollbackInstall(installed, backedUp, backupDir, targetDir);
|
|
1202
|
-
|
|
1203
|
-
rmSync(stagingDir, { recursive: true, force: true });
|
|
1204
|
-
} catch {
|
|
1205
|
-
/* best-effort */
|
|
1206
|
-
}
|
|
1207
|
-
try {
|
|
1208
|
-
rmSync(backupDir, { recursive: true, force: true });
|
|
1209
|
-
} catch {
|
|
1210
|
-
/* best-effort */
|
|
1211
|
-
}
|
|
1358
|
+
discardSwapDirs(stagingDir, backupDir);
|
|
1212
1359
|
return false;
|
|
1213
1360
|
} finally {
|
|
1214
1361
|
release();
|
package/install.mjs
CHANGED
|
@@ -73,10 +73,12 @@ import { doctorDbModeHint } from './lib/doctor-modes.mjs';
|
|
|
73
73
|
import { checkHookInterpreter } from './lib/doctor-hook-interpreter.mjs';
|
|
74
74
|
import {
|
|
75
75
|
classifyEpisodeFile,
|
|
76
|
+
classifyUpdateResidue,
|
|
76
77
|
EPISODE_AGE_LABEL,
|
|
77
78
|
isEpisodeResidue,
|
|
78
79
|
isUpdateResidue,
|
|
79
80
|
scanStaleTempFiles,
|
|
81
|
+
SWAP_RESOLVED_NAME,
|
|
80
82
|
} from './lib/doctor-stale-temp.mjs';
|
|
81
83
|
const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
|
|
82
84
|
|
|
@@ -1298,6 +1300,115 @@ function offerCleanOldVectorDb() {
|
|
|
1298
1300
|
}
|
|
1299
1301
|
}
|
|
1300
1302
|
|
|
1303
|
+
// A path, a dangling symlink included (existsSync follows the link).
|
|
1304
|
+
function pathPresent(p) {
|
|
1305
|
+
try {
|
|
1306
|
+
lstatSync(p);
|
|
1307
|
+
return true;
|
|
1308
|
+
} catch {
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Update residue in the code dir, each entry with what it is (lib/doctor-stale-temp.mjs).
|
|
1314
|
+
function listUpdateResidue() {
|
|
1315
|
+
return readdirSync(INSTALL_DIR)
|
|
1316
|
+
.map((f) => ({ f, kind: classifyUpdateResidue(INSTALL_DIR, f) }))
|
|
1317
|
+
.filter((r) => r.kind);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Finish what interrupted updates left in the code dir, the way the next update entry would:
|
|
1322
|
+
* hook-update's recoverInterruptedSwaps replays each journal, then removes the residue. Reports
|
|
1323
|
+
* every entry by what became of it. The caller holds install.lock.
|
|
1324
|
+
*
|
|
1325
|
+
* Never throws: install calls it before writing the tree, and a recovery that threw would stop
|
|
1326
|
+
* the install it exists to protect. If hook-update cannot load, the residue of finished swaps is
|
|
1327
|
+
* still removed and an unfinished swap is left whole (for install, which retires it once the
|
|
1328
|
+
* tree is written, or for a later entry that can replay it).
|
|
1329
|
+
* @param {{f: string, kind: string}[]} found entries from listUpdateResidue
|
|
1330
|
+
* @returns {Promise<{finished: string[], removed: string[], kept: string[], deferred: string[],
|
|
1331
|
+
* left: string[]}>} kept: left on purpose because its journal cannot be read; deferred: an
|
|
1332
|
+
* unfinished swap left because hook-update could not load; left: anything else still there
|
|
1333
|
+
*/
|
|
1334
|
+
async function recoverUpdateResidue(found) {
|
|
1335
|
+
let replayed = true;
|
|
1336
|
+
try {
|
|
1337
|
+
const { recoverInterruptedSwaps } = await import('./hook-update.mjs');
|
|
1338
|
+
recoverInterruptedSwaps(INSTALL_DIR);
|
|
1339
|
+
} catch (e) {
|
|
1340
|
+
replayed = false;
|
|
1341
|
+
warn(`Could not finish an interrupted update here (${e.message}) — it is left in place`);
|
|
1342
|
+
}
|
|
1343
|
+
// Leftovers recovery did not take: a file or a symlink with the name (it replays only real
|
|
1344
|
+
// directories), or everything when hook-update could not load. Only 'stale' ones go.
|
|
1345
|
+
for (const { f, kind } of found) {
|
|
1346
|
+
if (kind !== 'stale' || !pathPresent(join(INSTALL_DIR, f))) continue;
|
|
1347
|
+
try {
|
|
1348
|
+
rmSync(join(INSTALL_DIR, f), { recursive: true, force: true });
|
|
1349
|
+
} catch {
|
|
1350
|
+
/* reported below as left */
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
const out = { finished: [], removed: [], kept: [], deferred: [], left: [] };
|
|
1354
|
+
for (const { f, kind } of found) {
|
|
1355
|
+
if (!pathPresent(join(INSTALL_DIR, f))) (kind === 'unfinished-swap' ? out.finished : out.removed).push(f);
|
|
1356
|
+
else if (kind === 'unreadable-journal') out.kept.push(f);
|
|
1357
|
+
else if (kind === 'unfinished-swap' && !replayed) out.deferred.push(f);
|
|
1358
|
+
else out.left.push(f);
|
|
1359
|
+
}
|
|
1360
|
+
return out;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// A swap an updater was killed in the middle of is finished BEFORE this install writes the tree
|
|
1364
|
+
// (D#289). Its journal names an older release's paths; left for the next update entry, it was
|
|
1365
|
+
// replayed over the tree written here: it deleted the files the killed swap had added and put the
|
|
1366
|
+
// older ones back. Runs before the swap barrier, because the recovery arms and clears that marker.
|
|
1367
|
+
async function finishInterruptedSwaps() {
|
|
1368
|
+
let found;
|
|
1369
|
+
try {
|
|
1370
|
+
found = listUpdateResidue();
|
|
1371
|
+
} catch {
|
|
1372
|
+
return; // no code dir yet, so nothing to finish
|
|
1373
|
+
}
|
|
1374
|
+
if (!found.some((r) => r.kind === 'unfinished-swap')) return;
|
|
1375
|
+
const r = await recoverUpdateResidue(found);
|
|
1376
|
+
if (r.finished.length > 0)
|
|
1377
|
+
ok(`Finished ${r.finished.length} interrupted update(s) before installing: ${r.finished.join(', ')}`);
|
|
1378
|
+
// A swap left unfinished here (deferred, kept) is retired once the tree is written.
|
|
1379
|
+
for (const f of r.left) warn(`Could not remove ${f} from ${INSTALL_DIR}`);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const isUnresolvedSwap = (f) => {
|
|
1383
|
+
const kind = classifyUpdateResidue(INSTALL_DIR, f);
|
|
1384
|
+
return kind === 'unfinished-swap' || kind === 'unreadable-journal';
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// A journal still unresolved once deployCodeTree has written the code tree names paths of a
|
|
1388
|
+
// release this install has just replaced, and a later update entry would replay it over this tree:
|
|
1389
|
+
// delete the files the install wrote and put that release's back (v6.25.1 review; reached when
|
|
1390
|
+
// hook-update cannot load, or a journal cannot be read or removed). Marked resolved, it is never
|
|
1391
|
+
// replayed, and cleanup removes it as leftover. node_modules is written after this, by
|
|
1392
|
+
// installDependencies: if that fails, a node_modules the retired dir held is not put back (D#293).
|
|
1393
|
+
function retireLeftoverJournals() {
|
|
1394
|
+
let names;
|
|
1395
|
+
try {
|
|
1396
|
+
names = readdirSync(INSTALL_DIR).filter(isUnresolvedSwap);
|
|
1397
|
+
} catch {
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
for (const f of names) {
|
|
1401
|
+
try {
|
|
1402
|
+
writeFileSync(join(INSTALL_DIR, f, SWAP_RESOLVED_NAME), '');
|
|
1403
|
+
ok(`Retired ${f}: this install replaced the release it belongs to (cleanup removes it)`);
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
warn(
|
|
1406
|
+
`Could not retire ${f} (${e.code || e.message}): delete it, or the next update puts back the files it holds`,
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1301
1412
|
async function install() {
|
|
1302
1413
|
console.log('\nclaude-mem-lite installer\n');
|
|
1303
1414
|
// Refuse an unparseable settings.json BEFORE the first side effect (throws
|
|
@@ -1310,12 +1421,14 @@ async function install() {
|
|
|
1310
1421
|
const IS_DEV = flags.has('--dev');
|
|
1311
1422
|
|
|
1312
1423
|
prepareInstallDirs();
|
|
1424
|
+
await finishInterruptedSwaps();
|
|
1313
1425
|
// Both steps rewrite the tree hooks import — the code, then node_modules — so one swap
|
|
1314
1426
|
// barrier spans them (D#223), for its first two minutes: the launcher ignores an older marker,
|
|
1315
1427
|
// and npm install has no timeout. installDependencies may process.exit(1): the marker it leaves
|
|
1316
1428
|
// names a dead pid, which the launcher ignores.
|
|
1317
1429
|
await withSwapBarrier(async () => {
|
|
1318
1430
|
deployCodeTree(IS_DEV);
|
|
1431
|
+
retireLeftoverJournals();
|
|
1319
1432
|
await installDependencies(IS_DEV);
|
|
1320
1433
|
});
|
|
1321
1434
|
createCliSymlink();
|
|
@@ -1742,14 +1855,22 @@ async function status() {
|
|
|
1742
1855
|
}
|
|
1743
1856
|
|
|
1744
1857
|
// Database. A data dir this process cannot enter reads as an empty one (D#199), and
|
|
1745
|
-
// `exists: false` would be a guess — null says nobody could look.
|
|
1858
|
+
// `exists: false` would be a guess — null says nobody could look. One it can read but not write
|
|
1859
|
+
// (the 755 a `sudo` run leaves owned by root) is read where SQLite can open the store there: its
|
|
1860
|
+
// counts are real, under a ✗ (D#284). A closed WAL store there cannot be opened; see the catch.
|
|
1746
1861
|
const dataDirDenied = dataDirAccessError();
|
|
1747
|
-
|
|
1862
|
+
const dataDirUnreadable = dataDirDenied
|
|
1863
|
+
? dataDirAccessError(MEM_DATA_DIR, fsConstants.R_OK | fsConstants.X_OK)
|
|
1864
|
+
: null;
|
|
1865
|
+
const unwritable = dataDirDenied
|
|
1866
|
+
? ` — ${MEM_DATA_DIR} can be read but not written (${dataDirDenied}). Fix: ${dataDirAccessRemedy(dataDirDenied)}`
|
|
1867
|
+
: '';
|
|
1868
|
+
if (dataDirUnreadable) {
|
|
1748
1869
|
push(
|
|
1749
1870
|
'fail',
|
|
1750
1871
|
'database',
|
|
1751
|
-
`Database: ${MEM_DATA_DIR} is not accessible (${
|
|
1752
|
-
{ exists: null, error:
|
|
1872
|
+
`Database: ${MEM_DATA_DIR} is not accessible (${dataDirUnreadable}) — Fix: ${dataDirAccessRemedy(dataDirUnreadable)}`,
|
|
1873
|
+
{ exists: null, error: dataDirUnreadable },
|
|
1753
1874
|
);
|
|
1754
1875
|
} else if (existsSync(DB_PATH)) {
|
|
1755
1876
|
try {
|
|
@@ -1759,19 +1880,36 @@ async function status() {
|
|
|
1759
1880
|
// DISTINCT, like stats: a session can own several summary rows (legacy duplicates).
|
|
1760
1881
|
const sess = db.prepare('SELECT COUNT(DISTINCT memory_session_id) as c FROM session_summaries').get();
|
|
1761
1882
|
db.close();
|
|
1762
|
-
push(
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1883
|
+
push(
|
|
1884
|
+
dataDirDenied ? 'fail' : 'ok',
|
|
1885
|
+
'database',
|
|
1886
|
+
`Database: ${obs.c} observations, ${sess.c} sessions${unwritable}`,
|
|
1887
|
+
{
|
|
1888
|
+
exists: true,
|
|
1889
|
+
observations: obs.c,
|
|
1890
|
+
sessions: sess.c,
|
|
1891
|
+
...(dataDirDenied && { error: dataDirDenied }),
|
|
1892
|
+
},
|
|
1893
|
+
);
|
|
1767
1894
|
} catch (e) {
|
|
1768
|
-
|
|
1895
|
+
// The store is WAL (schema.mjs), and even a read-only open needs its -wal and -shm files, which
|
|
1896
|
+
// SQLite creates beside the DB: in a directory it cannot write, a closed store cannot be opened
|
|
1897
|
+
// in place (a copy elsewhere could be read) — 6.24.0 could not open it either. Say so rather than relay "attempt to write a readonly database".
|
|
1898
|
+
// The errno stands in for SQLite's message only where the directory is the cause.
|
|
1899
|
+
const walBlocked = dataDirDenied && e.code === 'SQLITE_READONLY_DIRECTORY';
|
|
1900
|
+
const why = walBlocked
|
|
1901
|
+
? 'exists, but SQLite cannot open a WAL database without creating its -wal/-shm files beside it'
|
|
1902
|
+
: 'exists but check failed — ' + e.message;
|
|
1903
|
+
push(dataDirDenied ? 'fail' : 'warn', 'database', `Database: ${why}${unwritable}`, {
|
|
1769
1904
|
exists: true,
|
|
1770
|
-
error: e.message,
|
|
1905
|
+
error: walBlocked ? dataDirDenied : e.message,
|
|
1771
1906
|
});
|
|
1772
1907
|
}
|
|
1773
1908
|
} else {
|
|
1774
|
-
push('warn', 'database', 'Database: not found', {
|
|
1909
|
+
push(dataDirDenied ? 'fail' : 'warn', 'database', 'Database: not found' + unwritable, {
|
|
1910
|
+
exists: false,
|
|
1911
|
+
...(dataDirDenied && { error: dataDirDenied }),
|
|
1912
|
+
});
|
|
1775
1913
|
}
|
|
1776
1914
|
|
|
1777
1915
|
// CLI.
|
|
@@ -1990,12 +2128,21 @@ async function doctor() {
|
|
|
1990
2128
|
fail(
|
|
1991
2129
|
`Data directory: ${MEM_DATA_DIR} is not accessible (${dataDirDenied}) — ` +
|
|
1992
2130
|
(dataDirUnreadable ? 'checks that read it say "not checked"' : 'it can be read but not written') +
|
|
1993
|
-
`. Fix: ${dataDirAccessRemedy()}`,
|
|
2131
|
+
`. Fix: ${dataDirAccessRemedy(dataDirDenied)}`,
|
|
1994
2132
|
);
|
|
1995
2133
|
issues++;
|
|
1996
2134
|
}
|
|
1997
2135
|
const notCheckedDenied = (what, dir = MEM_DATA_DIR) =>
|
|
1998
2136
|
dwarn(`${what}: not checked — ${dir} is not accessible`);
|
|
2137
|
+
// A closed WAL store in a data dir that can be read but not written: even a read-only open has to
|
|
2138
|
+
// create the -wal/-shm files beside it, so every check that opens the DB fails, for the fault the
|
|
2139
|
+
// Data directory line names and counts. Those checks say they did not look, in the words status
|
|
2140
|
+
// uses (D#287, the D#199 shape). Keyed on SQLite's code: its message is shared with other faults.
|
|
2141
|
+
const walBlocked = (code) => Boolean(dataDirDenied) && code === 'SQLITE_READONLY_DIRECTORY';
|
|
2142
|
+
const notCheckedWal = (what, scope = '') =>
|
|
2143
|
+
dwarn(
|
|
2144
|
+
`${what}: not checked${scope} — SQLite cannot open a WAL database without creating its -wal/-shm files beside it, and ${MEM_DATA_DIR} cannot be written (see Data directory above)`,
|
|
2145
|
+
);
|
|
1999
2146
|
// The code dir is the data dir in the default shape; under CLAUDE_MEM_DIR it is checked apart.
|
|
2000
2147
|
const codeIsDataDir = sameDir(INSTALL_DIR, MEM_DATA_DIR);
|
|
2001
2148
|
const codeDirDenied = codeIsDataDir ? dataDirUnreadable : dataDirAccessError(INSTALL_DIR, readMode);
|
|
@@ -2016,7 +2163,10 @@ async function doctor() {
|
|
|
2016
2163
|
// process.
|
|
2017
2164
|
const rootProbes = probeRuntimeRoots(shape.runtimeRoots);
|
|
2018
2165
|
const brokenRoots = rootProbes.filter((r) => !r.ok);
|
|
2019
|
-
if (rootProbes.length === 0) {
|
|
2166
|
+
if (rootProbes.length === 0 && codeDirDenied) {
|
|
2167
|
+
// The code home is locked, so it may own a binding nobody could look at (D#284 review P3-4).
|
|
2168
|
+
notCheckedDenied('better-sqlite3', INSTALL_DIR);
|
|
2169
|
+
} else if (rootProbes.length === 0) {
|
|
2020
2170
|
fail('better-sqlite3: no install on this machine owns a native binding — nothing here can open the DB');
|
|
2021
2171
|
issues++;
|
|
2022
2172
|
} else if (brokenRoots.length === 0) {
|
|
@@ -2064,6 +2214,8 @@ async function doctor() {
|
|
|
2064
2214
|
notCheckedDenied('DB schema');
|
|
2065
2215
|
} else if (!existsSync(DB_PATH)) {
|
|
2066
2216
|
ok('DB schema: no database yet — nothing to compare');
|
|
2217
|
+
} else if (rootProbes.length === 0 && codeDirDenied) {
|
|
2218
|
+
notCheckedDenied('DB schema', INSTALL_DIR); // the locked code home may own one (D#284 review)
|
|
2067
2219
|
} else if (rootProbes.length === 0) {
|
|
2068
2220
|
// The fourth outcome the first cut had and did not print. The `fail` above already tells
|
|
2069
2221
|
// the reader no install owns a binding, but a block whose stated design point is "three
|
|
@@ -2135,7 +2287,8 @@ async function doctor() {
|
|
|
2135
2287
|
// printed as a green line is the defect the v6.2.0 round wrote and its pre-ship review
|
|
2136
2288
|
// caught before the tag — a check that says "nothing to check" and "I could not look"
|
|
2137
2289
|
// in the same voice ends the reader's search instead of directing it.
|
|
2138
|
-
|
|
2290
|
+
if (walBlocked(u.errorCode)) notCheckedWal('DB schema', ` for ${u.label}`);
|
|
2291
|
+
else dwarn(`DB schema: could not determine compatibility for ${u.label} (${u.error})`);
|
|
2139
2292
|
}
|
|
2140
2293
|
}
|
|
2141
2294
|
|
|
@@ -2220,7 +2373,11 @@ async function doctor() {
|
|
|
2220
2373
|
// is 6h-rate-limited stderr nobody reads), the live probe says "is it broken
|
|
2221
2374
|
// right now". A Node upgrade breaks every DB-touching path at once, so this is
|
|
2222
2375
|
// the single highest-value line in doctor when it fires.
|
|
2223
|
-
|
|
2376
|
+
// The marker sits in the runtime dir: in a locked data dir it reads as absent (D#284).
|
|
2377
|
+
const breakageUnread = dataDirUnreadable && runtimeInDataDir;
|
|
2378
|
+
const breakage = breakageUnread ? null : readNativeBindingBreakage(MEM_RUNTIME_DIR);
|
|
2379
|
+
const breakageSeen = (b) =>
|
|
2380
|
+
`~${Math.round((Date.now() - (b.ts || 0)) / 3600000)}h ago (${b.reason || 'unknown'})`;
|
|
2224
2381
|
// Reuses the per-root probes above — same trees, same question, and doctor
|
|
2225
2382
|
// should not pay for another round of child spawns to ask it twice.
|
|
2226
2383
|
if (brokenRoots.length > 0) {
|
|
@@ -2228,10 +2385,20 @@ async function doctor() {
|
|
|
2228
2385
|
`Native DB binding: unusable in ${brokenRoots.map((b) => b.label).join(', ')} — run \`node ${shellWord(join(PROJECT_DIR, 'cli.mjs'))} rebuild-binding\` (repairs every broken install, not just this one)`,
|
|
2229
2386
|
);
|
|
2230
2387
|
issues++;
|
|
2388
|
+
} else if (rootProbes.length === 0) {
|
|
2389
|
+
// Nothing was probed: a ✓ here sat under the better-sqlite3 ✗ for the same machine. Said, not
|
|
2390
|
+
// left silent, like DB schema's fourth outcome above; a marker it could read still shows.
|
|
2391
|
+
const why = codeDirDenied
|
|
2392
|
+
? `${INSTALL_DIR} is not accessible`
|
|
2393
|
+
: 'no install on this machine owns a native binding to probe';
|
|
2394
|
+
dwarn(
|
|
2395
|
+
`Native DB binding: not checked — ${why}${breakage ? `; a fire failed ${breakageSeen(breakage)}` : ''}`,
|
|
2396
|
+
);
|
|
2397
|
+
} else if (breakageUnread) {
|
|
2398
|
+
notCheckedDenied(`Native DB binding: loadable on Node ${process.version}, past failures`);
|
|
2231
2399
|
} else if (breakage) {
|
|
2232
|
-
const ageH = Math.round((Date.now() - (breakage.ts || 0)) / 3600000);
|
|
2233
2400
|
dwarn(
|
|
2234
|
-
`Native DB binding: healthy now, but a fire failed
|
|
2401
|
+
`Native DB binding: healthy now, but a fire failed ${breakageSeen(breakage)} — stale marker clears on the next successful rebuild-binding`,
|
|
2235
2402
|
);
|
|
2236
2403
|
} else {
|
|
2237
2404
|
ok(`Native DB binding: loadable on Node ${process.version}`);
|
|
@@ -2474,24 +2641,37 @@ async function doctor() {
|
|
|
2474
2641
|
dwarn('FTS5 index: missing (will be created on server start)');
|
|
2475
2642
|
}
|
|
2476
2643
|
} catch (e) {
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2644
|
+
if (walBlocked(e.code)) {
|
|
2645
|
+
notCheckedWal('Database');
|
|
2646
|
+
} else {
|
|
2647
|
+
fail('Database: ' + e.message);
|
|
2648
|
+
// Every other ✗ on this screen carries a remedy; this one used to be the exception,
|
|
2649
|
+
// and a corrupt store is the failure a user is least able to diagnose unaided.
|
|
2650
|
+
// dbCheckRemedy returns null rather than invent one for an error it cannot classify.
|
|
2651
|
+
// Pre-ship review 2026-09-09: dbCheckRemedy became async and lazy (P1-1), which moved
|
|
2652
|
+
// its failure mode from load time into THIS catch — and an unhandled rejection here
|
|
2653
|
+
// aborts doctor exactly as the static import did, so `--json` still emitted zero bytes
|
|
2654
|
+
// on the compound shape "a file is missing AND the database will not open". A remedy
|
|
2655
|
+
// is an extra sentence on a check that has already failed; never let it take the run.
|
|
2656
|
+
let remedy = null;
|
|
2657
|
+
try {
|
|
2658
|
+
remedy = await dbCheckRemedy(DB_PATH, e);
|
|
2659
|
+
} catch (remedyErr) {
|
|
2660
|
+
log(` (could not build a repair hint: ${remedyErr.message})`);
|
|
2661
|
+
}
|
|
2662
|
+
if (remedy) log(` ${remedy}`);
|
|
2663
|
+
issues++;
|
|
2491
2664
|
}
|
|
2492
|
-
if (remedy) log(` ${remedy}`);
|
|
2493
|
-
issues++;
|
|
2494
2665
|
}
|
|
2666
|
+
} else if (rootProbes.length === 0) {
|
|
2667
|
+
// "will be created" beside the better-sqlite3 line's "nothing here can open the DB" was a
|
|
2668
|
+
// promise this screen had just contradicted (D#287). With the code home locked the binding
|
|
2669
|
+
// was not looked for, so neither half is claimed.
|
|
2670
|
+
dwarn(
|
|
2671
|
+
codeDirDenied
|
|
2672
|
+
? 'Database: not found'
|
|
2673
|
+
: 'Database: not found — and nothing here can create it until an install has a native binding (see better-sqlite3 above)',
|
|
2674
|
+
);
|
|
2495
2675
|
} else {
|
|
2496
2676
|
dwarn('Database: not found (will be created)');
|
|
2497
2677
|
}
|
|
@@ -2822,18 +3002,38 @@ async function doctor() {
|
|
|
2822
3002
|
// Stale temp files. The rules live in lib/doctor-stale-temp.mjs because this scanner and
|
|
2823
3003
|
// cleanup's deleter are the same question asked twice and had drifted twice — see that
|
|
2824
3004
|
// file. Counting is all that differs here; the classification is shared, so "what doctor
|
|
2825
|
-
// calls stale" and "what cleanup removes" agree on the age gate
|
|
2826
|
-
//
|
|
2827
|
-
//
|
|
2828
|
-
//
|
|
2829
|
-
//
|
|
3005
|
+
// calls stale" and "what cleanup removes" agree on the age gate and on the directory, the
|
|
3006
|
+
// axes they diverged on (the third time, D#289: both looked for update residue in the data
|
|
3007
|
+
// dir, which CLAUDE_MEM_DIR moves away from the code dir it lives in). Not on every axis:
|
|
3008
|
+
// cleanup skips update residue entirely while install.lock is held and the scanner has no
|
|
3009
|
+
// such gate, so mid-self-update doctor still counts what cleanup will decline. That one is
|
|
3010
|
+
// milder than D#53 — cleanup SAYS it is skipping — and the unfinished-update line says a
|
|
3011
|
+
// running update looks the same.
|
|
2830
3012
|
try {
|
|
2831
|
-
const { stale, inFlight } = scanStaleTempFiles({
|
|
2832
|
-
|
|
3013
|
+
const { stale, inFlight, unfinishedSwaps, unreadableJournals, notChecked } = scanStaleTempFiles({
|
|
3014
|
+
codeDir: INSTALL_DIR,
|
|
2833
3015
|
runtimeDir: MEM_RUNTIME_DIR,
|
|
3016
|
+
legacyDir: codeIsDataDir ? undefined : MEM_DATA_DIR,
|
|
2834
3017
|
});
|
|
2835
|
-
|
|
2836
|
-
|
|
3018
|
+
const installer = `node ${shellWord(join(PROJECT_DIR, 'install.mjs'))}`;
|
|
3019
|
+
if (unfinishedSwaps > 0) {
|
|
3020
|
+
dwarn(
|
|
3021
|
+
`Unfinished update: ${unfinishedSwaps} backup dir(s) in ${INSTALL_DIR} still hold a swap journal — an update was interrupted, or is running right now (run: ${installer} cleanup — it finishes an interrupted one, and skips while an update holds install.lock)`,
|
|
3022
|
+
);
|
|
3023
|
+
}
|
|
3024
|
+
if (unreadableJournals > 0) {
|
|
3025
|
+
dwarn(
|
|
3026
|
+
`Unfinished update: ${unreadableJournals} backup dir(s) in ${INSTALL_DIR} whose journal cannot be read, so nothing can finish that update; they hold the files it moved aside (run: ${installer} repair — it reinstalls over them; cleanup then removes them)`,
|
|
3027
|
+
);
|
|
3028
|
+
}
|
|
3029
|
+
if (notChecked.length > 0) {
|
|
3030
|
+
const where = notChecked.map((n) => `${n.dir} is not accessible (${n.code})`).join('; ');
|
|
3031
|
+
dwarn(
|
|
3032
|
+
`Stale temp files: not checked — ${where}` +
|
|
3033
|
+
(stale > 0 ? `; ${stale} found elsewhere (run: ${installer} cleanup)` : ''),
|
|
3034
|
+
);
|
|
3035
|
+
} else if (stale > 0) {
|
|
3036
|
+
dwarn(`Stale temp files: ${stale} found (run: ${installer} cleanup)`);
|
|
2837
3037
|
} else {
|
|
2838
3038
|
ok('Stale temp files: none');
|
|
2839
3039
|
}
|
|
@@ -2875,7 +3075,8 @@ async function doctor() {
|
|
|
2875
3075
|
dwarn(`${stats} — but the install you are running cannot use this database (see DB schema above)`);
|
|
2876
3076
|
else ok(stats);
|
|
2877
3077
|
} catch (e) {
|
|
2878
|
-
|
|
3078
|
+
if (walBlocked(e.code)) notCheckedWal('DB stats');
|
|
3079
|
+
else dwarn('DB stats: ' + e.message);
|
|
2879
3080
|
}
|
|
2880
3081
|
}
|
|
2881
3082
|
|
|
@@ -3229,7 +3430,7 @@ function writeSettings(settings) {
|
|
|
3229
3430
|
|
|
3230
3431
|
// ─── Cleanup Stale Files ─────────────────────────────────────────────────────
|
|
3231
3432
|
|
|
3232
|
-
function cleanup() {
|
|
3433
|
+
async function cleanup() {
|
|
3233
3434
|
// Dogfood-7 addition: --dry-run lists which files would be removed without
|
|
3234
3435
|
// touching disk. Useful before running cleanup on a remote/CI machine where
|
|
3235
3436
|
// accidentally pruning the wrong file would be costly. Doctor reports stale
|
|
@@ -3238,39 +3439,80 @@ function cleanup() {
|
|
|
3238
3439
|
console.log(`\nclaude-mem-lite cleanup${dryRun ? ' (--dry-run)' : ''}\n`);
|
|
3239
3440
|
let removed = 0;
|
|
3240
3441
|
|
|
3241
|
-
//
|
|
3242
|
-
//
|
|
3442
|
+
// Update residue: `.update-staging-*` / `.update-backup-*` in the CODE dir, where hook-update's
|
|
3443
|
+
// swap writes them. Under CLAUDE_MEM_DIR this scanned only the data dir, where nothing has written
|
|
3444
|
+
// them since v2.90.0; residue an older updater left there is still removed, below. A backup dir
|
|
3445
|
+
// that holds its journal and no resolved marker is a swap whose updater was killed, holding the
|
|
3446
|
+
// only copy of every file the swap had moved out: cleanup finishes it, by replaying the journal
|
|
3447
|
+
// the way the next update entry would, instead of deleting it (D#289).
|
|
3243
3448
|
//
|
|
3244
|
-
// R10 P2-10: take install.lock first.
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3247
|
-
// and the
|
|
3248
|
-
//
|
|
3249
|
-
|
|
3449
|
+
// R10 P2-10: take install.lock first. A backup dir of a swap running now holds a journal too,
|
|
3450
|
+
// and the staging dir is the tree being swapped in; touching either mid-update leaves the install
|
|
3451
|
+
// unrecoverable. The window is long (it spans the source-compile fallback, up to five minutes),
|
|
3452
|
+
// and doctor tells the user to run cleanup. Non-blocking: if an installer holds the lock we skip
|
|
3453
|
+
// only the update residue, not the rest of cleanup.
|
|
3454
|
+
let finished = 0;
|
|
3250
3455
|
const updateLock = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
|
|
3251
3456
|
if (!updateLock) {
|
|
3252
3457
|
warn('Update residue skipped: install in progress (install.lock held)');
|
|
3253
|
-
} else
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3458
|
+
} else {
|
|
3459
|
+
try {
|
|
3460
|
+
let found = [];
|
|
3461
|
+
try {
|
|
3462
|
+
found = existsSync(INSTALL_DIR) ? listUpdateResidue() : [];
|
|
3463
|
+
} catch (e) {
|
|
3464
|
+
warn(`Update residue: could not list ${INSTALL_DIR} (${e.code || e.message})`);
|
|
3465
|
+
}
|
|
3466
|
+
if (dryRun) {
|
|
3467
|
+
for (const { f, kind } of found) {
|
|
3468
|
+
if (kind === 'unfinished-swap') {
|
|
3469
|
+
ok(`Would finish an interrupted update: ${f} (puts back the files its journal lists)`);
|
|
3470
|
+
finished++;
|
|
3471
|
+
} else if (kind === 'unreadable-journal') {
|
|
3472
|
+
warn(`Would leave in place: ${f} (its journal cannot be read)`);
|
|
3473
|
+
} else {
|
|
3474
|
+
ok(`Would remove: ${f}`);
|
|
3475
|
+
removed++;
|
|
3476
|
+
}
|
|
3260
3477
|
}
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3478
|
+
} else if (found.length > 0) {
|
|
3479
|
+
const r = await recoverUpdateResidue(found);
|
|
3480
|
+
for (const f of r.finished)
|
|
3481
|
+
ok(`Finished an interrupted update: ${f} (put back the files it had moved out)`);
|
|
3482
|
+
for (const f of r.removed) ok(`Removed: ${f}`);
|
|
3483
|
+
for (const f of r.kept)
|
|
3484
|
+
warn(
|
|
3485
|
+
`Left in place: ${f} — its journal cannot be read, so nothing can finish that update, and it holds the files the update moved aside. repair reinstalls over it; cleanup removes it after that.`,
|
|
3486
|
+
);
|
|
3487
|
+
for (const f of r.left) warn(`Failed to remove ${f}`);
|
|
3488
|
+
finished += r.finished.length;
|
|
3489
|
+
removed += r.removed.length;
|
|
3490
|
+
}
|
|
3491
|
+
// A relocated data dir that an updater older than v2.90.0 swapped into: its residue is stale,
|
|
3492
|
+
// whatever it holds, and is never replayed (lib/doctor-stale-temp.mjs, legacyDir).
|
|
3493
|
+
if (!sameDir(INSTALL_DIR, MEM_DATA_DIR) && existsSync(MEM_DATA_DIR)) {
|
|
3494
|
+
for (const f of readdirSync(MEM_DATA_DIR)) {
|
|
3495
|
+
if (!isUpdateResidue(f)) continue;
|
|
3496
|
+
if (dryRun) {
|
|
3497
|
+
ok(`Would remove: ${join(MEM_DATA_DIR, f)}`);
|
|
3498
|
+
removed++;
|
|
3499
|
+
continue;
|
|
3500
|
+
}
|
|
3501
|
+
try {
|
|
3502
|
+
rmSync(join(MEM_DATA_DIR, f), { recursive: true, force: true });
|
|
3503
|
+
ok(`Removed: ${join(MEM_DATA_DIR, f)}`);
|
|
3504
|
+
removed++;
|
|
3505
|
+
} catch (e) {
|
|
3506
|
+
warn(`Failed to remove ${join(MEM_DATA_DIR, f)}: ${e.message}`);
|
|
3507
|
+
}
|
|
3267
3508
|
}
|
|
3268
3509
|
}
|
|
3510
|
+
} finally {
|
|
3511
|
+
// Released here: the rest of cleanup touches runtime scratch that no installer owns, and
|
|
3512
|
+
// holding install.lock across it would block a self-heal for no reason.
|
|
3513
|
+
updateLock();
|
|
3269
3514
|
}
|
|
3270
3515
|
}
|
|
3271
|
-
// Release immediately: the rest of cleanup touches runtime scratch that no installer owns,
|
|
3272
|
-
// and holding install.lock across it would block a self-heal for no reason.
|
|
3273
|
-
if (updateLock) updateLock();
|
|
3274
3516
|
|
|
3275
3517
|
// Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override).
|
|
3276
3518
|
//
|
|
@@ -3322,7 +3564,11 @@ function cleanup() {
|
|
|
3322
3564
|
// start of every run (tests/global-setup.mjs → lib/tmp-fixture-sweep.mjs).
|
|
3323
3565
|
|
|
3324
3566
|
const verb = dryRun ? 'would be removed' : 'removed';
|
|
3325
|
-
|
|
3567
|
+
const done = [];
|
|
3568
|
+
if (removed > 0) done.push(`${removed} stale file(s) ${verb}`);
|
|
3569
|
+
if (finished > 0)
|
|
3570
|
+
done.push(`${finished} interrupted update(s) ${dryRun ? 'would be finished' : 'finished'}`);
|
|
3571
|
+
console.log(`\n ${done.length === 0 ? 'No stale files found.' : `${done.join('; ')}.`}\n`);
|
|
3326
3572
|
}
|
|
3327
3573
|
|
|
3328
3574
|
// ─── Manual Update ───────────────────────────────────────────────────────────
|
|
@@ -3710,14 +3956,19 @@ function sameDir(a, b) {
|
|
|
3710
3956
|
}
|
|
3711
3957
|
}
|
|
3712
3958
|
|
|
3713
|
-
|
|
3714
|
-
|
|
3959
|
+
// The fix for a data dir this user cannot use, chosen by the error code. EROFS is the file system,
|
|
3960
|
+
// not the mode bits: accessSync(W_OK) on a read-only mount fails with it, and chmod cannot help
|
|
3961
|
+
// there (D#287).
|
|
3962
|
+
export const dataDirAccessRemedy = (code) =>
|
|
3963
|
+
code === 'EROFS'
|
|
3964
|
+
? `the file system holding ${shellWord(MEM_DATA_DIR)} is mounted read-only — remount it read-write`
|
|
3965
|
+
: `chmod u+rwx ${shellWord(MEM_DATA_DIR)} (or chown it back to your user if a sudo run created it)`;
|
|
3715
3966
|
|
|
3716
3967
|
async function runLockedInstall() {
|
|
3717
3968
|
const denied = dataDirAccessError();
|
|
3718
3969
|
if (denied) {
|
|
3719
3970
|
console.error(
|
|
3720
|
-
`[install] ${MEM_DATA_DIR} is not accessible (${denied}) — nothing was done. Fix: ${dataDirAccessRemedy()}`,
|
|
3971
|
+
`[install] ${MEM_DATA_DIR} is not accessible (${denied}) — nothing was done. Fix: ${dataDirAccessRemedy(denied)}`,
|
|
3721
3972
|
);
|
|
3722
3973
|
process.exitCode = 1;
|
|
3723
3974
|
return;
|
|
@@ -3824,7 +4075,7 @@ async function dispatch(cmd) {
|
|
|
3824
4075
|
await cleanupHooks();
|
|
3825
4076
|
break;
|
|
3826
4077
|
case 'cleanup':
|
|
3827
|
-
cleanup();
|
|
4078
|
+
await cleanup();
|
|
3828
4079
|
break;
|
|
3829
4080
|
case 'self-update':
|
|
3830
4081
|
case 'update':
|
|
@@ -21,13 +21,31 @@
|
|
|
21
21
|
* A leaf: node:fs, node:path, and one constants module that imports nothing.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
24
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { ORPHAN_EPISODE_AGE_MS } from './time-constants.mjs';
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Residue of a self-update. hook-update's installExtractedRelease stages and backs up beside the
|
|
30
|
+
* tree it swaps: the CODE dir (~/.claude-mem-lite), which CLAUDE_MEM_DIR does not move (D#289).
|
|
31
|
+
* Before v2.90.0 the code lived in the data dir, so an updater then swapped into a relocated data
|
|
32
|
+
* dir; residue it left there is stale whatever it holds (scanStaleTempFiles' legacyDir).
|
|
33
|
+
*/
|
|
29
34
|
const UPDATE_RESIDUE_PREFIXES = ['.update-staging-', '.update-backup-'];
|
|
30
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The journal a swap keeps inside its backup dir. A literal, not an import: hook-update.mjs is on
|
|
38
|
+
* the repair path and stays off this leaf's load graph; tests/update-residue-faces.test.mjs holds
|
|
39
|
+
* the two spellings equal.
|
|
40
|
+
*/
|
|
41
|
+
export const SWAP_JOURNAL_NAME = '.swap-journal.json';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Written into a backup dir when its swap is over (committed, or rolled back), before the journal
|
|
45
|
+
* is removed: a journal left beside it is never replayed (D#292 C). Same arrangement as above.
|
|
46
|
+
*/
|
|
47
|
+
export const SWAP_RESOLVED_NAME = '.swap-resolved';
|
|
48
|
+
|
|
31
49
|
/** Episode hand-off files under the runtime dir. Age-gated: see classifyEpisodeFile. */
|
|
32
50
|
const EPISODE_RESIDUE_PREFIXES = ['pending-', 'ep-flush-'];
|
|
33
51
|
|
|
@@ -35,6 +53,67 @@ export const isUpdateResidue = (name) => UPDATE_RESIDUE_PREFIXES.some((p) => nam
|
|
|
35
53
|
|
|
36
54
|
export const isEpisodeResidue = (name) => EPISODE_RESIDUE_PREFIXES.some((p) => name.startsWith(p));
|
|
37
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Is this update residue the leftover of a swap that is over, or a swap that never finished?
|
|
58
|
+
*
|
|
59
|
+
* A backup dir that holds its journal and no resolved marker is a swap whose updater was killed:
|
|
60
|
+
* it holds the only copy of every file the swap had moved out, and the next update entry replays
|
|
61
|
+
* the journal to put them back. Deleting it as stale temp kept the half-swapped tree for good
|
|
62
|
+
* (D#289). A swap that is over (committed, or rolled back) writes the resolved marker and then
|
|
63
|
+
* removes its journal, so a backup dir with the marker or without a journal, and any staging dir,
|
|
64
|
+
* is residue. A dir from 6.25.0 or older carries no marker; one whose journal survived a finished
|
|
65
|
+
* swap there still reads as unfinished.
|
|
66
|
+
*
|
|
67
|
+
* A swap running right now looks the same as one that was killed; cleanup tells them apart by
|
|
68
|
+
* install.lock, doctor cannot, and says so.
|
|
69
|
+
*
|
|
70
|
+
* 'unreadable-journal' is an unresolved backup dir whose journal is there but cannot be read now,
|
|
71
|
+
* or not as one (a write of 6.25.0 or older torn by a kill). Recovery cannot replay it and it may
|
|
72
|
+
* hold the only copy of what moved, so recovery leaves it (the same rule as hook-update's
|
|
73
|
+
* recoverInterruptedSwaps); the next swap that commits, or an install, retires it. One whose read
|
|
74
|
+
* error clears before that would be replayed like any unfinished swap.
|
|
75
|
+
*
|
|
76
|
+
* Only a real directory is a swap: recovery skips a file or a symlink with the name (its Dirent is
|
|
77
|
+
* not a directory), so the classifier calls those leftover too, judged by lstat as recovery does.
|
|
78
|
+
*
|
|
79
|
+
* @returns {'unfinished-swap'|'unreadable-journal'|'stale'|null} null when not update residue
|
|
80
|
+
*/
|
|
81
|
+
export function classifyUpdateResidue(codeDir, name) {
|
|
82
|
+
if (!isUpdateResidue(name)) return null;
|
|
83
|
+
const dir = join(codeDir, name);
|
|
84
|
+
let st;
|
|
85
|
+
try {
|
|
86
|
+
st = lstatSync(dir);
|
|
87
|
+
} catch {
|
|
88
|
+
return 'stale';
|
|
89
|
+
}
|
|
90
|
+
if (!st.isDirectory() || !name.startsWith('.update-backup-')) return 'stale';
|
|
91
|
+
if (existsSync(join(dir, SWAP_RESOLVED_NAME))) return 'stale';
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = readFileSync(join(dir, SWAP_JOURNAL_NAME), 'utf8');
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return e.code === 'ENOENT' ? 'stale' : 'unreadable-journal';
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const j = JSON.parse(raw);
|
|
100
|
+
if (Array.isArray(j?.backedUp) && Array.isArray(j?.installed)) return 'unfinished-swap';
|
|
101
|
+
} catch {
|
|
102
|
+
/* not a journal */
|
|
103
|
+
}
|
|
104
|
+
return 'unreadable-journal';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A directory's names; [] when it does not exist. Any other error throws: absent is not unreadable. */
|
|
108
|
+
function listDir(dir) {
|
|
109
|
+
try {
|
|
110
|
+
return readdirSync(dir);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (e.code === 'ENOENT') return [];
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
38
117
|
/**
|
|
39
118
|
* How the age gate is SPOKEN, derived from the gate itself rather than hand-written.
|
|
40
119
|
*
|
|
@@ -87,25 +166,49 @@ export function classifyEpisodeFile(
|
|
|
87
166
|
* detail rather than a warning, because a file that is supposed to exist right now is not
|
|
88
167
|
* a fault. Note the asymmetry, which is deliberate and is NOT the age gate being applied
|
|
89
168
|
* inconsistently: update residue is guarded by install.lock in cleanup, not by age, so
|
|
90
|
-
* there is no age gate here for it to mirror.
|
|
169
|
+
* there is no age gate here for it to mirror. `unfinishedSwaps` are backup dirs with a readable
|
|
170
|
+
* journal and no resolved marker: cleanup finishes those swaps rather than removing them, and
|
|
171
|
+
* `unreadableJournals` it leaves (classifyUpdateResidue).
|
|
91
172
|
*
|
|
92
|
-
* @
|
|
173
|
+
* @param {{codeDir: string, runtimeDir: string, legacyDir?: string, now?: number, episodeAgeMs?: number}} args
|
|
174
|
+
* codeDir is where update residue lives; runtimeDir is where episode files live. legacyDir is a
|
|
175
|
+
* data dir CLAUDE_MEM_DIR keeps apart from the code dir: update residue there was left by an
|
|
176
|
+
* updater older than v2.90.0, which swapped into it, so all of it is stale and none of it is
|
|
177
|
+
* replayed. (It holds no journal either: journals came in v3.57.0.)
|
|
178
|
+
* @returns {{stale: number, inFlight: number, unfinishedSwaps: number, unreadableJournals: number,
|
|
179
|
+
* notChecked: Array<{dir: string, code: string}>}} A directory that exists but cannot be listed
|
|
180
|
+
* goes in notChecked, never counted as empty, and the others are still counted.
|
|
93
181
|
*/
|
|
94
|
-
export function scanStaleTempFiles({
|
|
182
|
+
export function scanStaleTempFiles({ codeDir, runtimeDir, legacyDir, now = Date.now(), episodeAgeMs }) {
|
|
95
183
|
let stale = 0;
|
|
96
184
|
let inFlight = 0;
|
|
185
|
+
let unfinishedSwaps = 0;
|
|
186
|
+
let unreadableJournals = 0;
|
|
187
|
+
const notChecked = [];
|
|
188
|
+
const list = (dir) => {
|
|
189
|
+
try {
|
|
190
|
+
return listDir(dir);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
notChecked.push({ dir, code: e.code || 'error' });
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
};
|
|
97
196
|
|
|
98
|
-
|
|
99
|
-
|
|
197
|
+
for (const f of list(codeDir)) {
|
|
198
|
+
const kind = classifyUpdateResidue(codeDir, f);
|
|
199
|
+
if (kind === 'unfinished-swap') unfinishedSwaps++;
|
|
200
|
+
else if (kind === 'unreadable-journal') unreadableJournals++;
|
|
201
|
+
else if (kind === 'stale') stale++;
|
|
202
|
+
}
|
|
203
|
+
if (legacyDir) {
|
|
204
|
+
for (const f of list(legacyDir)) if (isUpdateResidue(f)) stale++;
|
|
100
205
|
}
|
|
101
206
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
else stale++;
|
|
107
|
-
}
|
|
207
|
+
for (const f of list(runtimeDir)) {
|
|
208
|
+
if (!isEpisodeResidue(f)) continue;
|
|
209
|
+
if (classifyEpisodeFile(runtimeDir, f, { now, episodeAgeMs }) === 'in-flight') inFlight++;
|
|
210
|
+
else stale++;
|
|
108
211
|
}
|
|
109
212
|
|
|
110
|
-
return { stale, inFlight };
|
|
213
|
+
return { stale, inFlight, unfinishedSwaps, unreadableJournals, notChecked };
|
|
111
214
|
}
|
package/lib/schema-skew.mjs
CHANGED
|
@@ -273,7 +273,7 @@ export function schemaCompatProbeSource(root, dbPath) {
|
|
|
273
273
|
' const r = d.prepare("SELECT version FROM schema_version LIMIT 1").get();' +
|
|
274
274
|
' d.close();' +
|
|
275
275
|
' out.dbVersion = r && typeof r.version === "number" ? r.version : null; }' +
|
|
276
|
-
' catch (e) { out.dbError = String((e && e.message) || e); }' +
|
|
276
|
+
' catch (e) { out.dbError = String((e && e.message) || e); out.dbErrorCode = (e && e.code) || null; }' +
|
|
277
277
|
`process.stdout.write(${open} + JSON.stringify(out) + ${close}); })()`
|
|
278
278
|
);
|
|
279
279
|
}
|
|
@@ -318,7 +318,7 @@ function extractProbePayload(stdout) {
|
|
|
318
318
|
* (a native crash leaving both streams empty, a spawn that never starts).
|
|
319
319
|
*
|
|
320
320
|
* @param {{timeoutMs?: number, spawn?: (cmd: string, args: string[], opts: object) => object}} [opts]
|
|
321
|
-
* @returns {{status: 'ok'|'skew'|'unknown', supported?: number|null, dbVersion?: number|null, error?: string}}
|
|
321
|
+
* @returns {{status: 'ok'|'skew'|'unknown', supported?: number|null, dbVersion?: number|null, error?: string, errorCode?: string}}
|
|
322
322
|
*/
|
|
323
323
|
export function probeSchemaCompatInFreshProcess(
|
|
324
324
|
root,
|
|
@@ -350,6 +350,9 @@ export function probeSchemaCompatInFreshProcess(
|
|
|
350
350
|
supported: supported ?? null,
|
|
351
351
|
dbVersion: dbVersion ?? null,
|
|
352
352
|
error: out.supportedError || out.dbError || 'probe returned no version',
|
|
353
|
+
// The DB open's own code, only when that is the error reported: a caller classifies on the
|
|
354
|
+
// code, never on SQLite's message, which several codes share (D#287).
|
|
355
|
+
...(!out.supportedError && out.dbErrorCode && { errorCode: out.dbErrorCode }),
|
|
353
356
|
};
|
|
354
357
|
}
|
|
355
358
|
return { status: supported < dbVersion ? 'skew' : 'ok', supported, dbVersion };
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.25.
|
|
3
|
+
"version": "6.25.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.25.
|
|
9
|
+
"version": "6.25.1",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.25.
|
|
3
|
+
"version": "6.25.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|