claude-mem-lite 3.91.0 → 3.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mem-cli.mjs CHANGED
@@ -21,18 +21,17 @@ import { fetchObsDetail, fetchPromptDetail, fetchEventDetail, OBS_FIELDS, SESSIO
21
21
  import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
22
22
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
23
23
  import { ensureRegistryDb, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
24
- import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources } from './lib/registry-core.mjs';
24
+ import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources, enrichResourceRow, enrichImportedResources, enrichNamedResource, REGISTRY_CONFINE_ENV } from './lib/registry-core.mjs';
25
25
  import { searchResources } from './registry-retriever.mjs';
26
26
  import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
27
27
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
28
28
  import {
29
- cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
30
- recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
31
- purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
32
- hardDeleteCandidateCount,
29
+ runMaintainOps, findDuplicates, maintenanceStats,
33
30
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD, resolveDefaultMaintainOps,
34
31
  } from './lib/maintain-core.mjs';
35
- import { snapshotDb, listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
32
+ // snapshotDb left with maintain-core: the pre-maintain snapshot is part of the op ORDER
33
+ // (it must see the pre-existing pending rows), so it moved into runMaintainOps (P1-5).
34
+ import { listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
36
35
  import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
37
36
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
38
37
  import { computeStatsFeed } from './lib/stats-core.mjs';
@@ -55,7 +54,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
55
54
  import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
56
55
  import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
57
56
  import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
58
- import { saveObservation, formatSupersedeSkipped, formatSupersededNote } from './lib/save-observation.mjs';
57
+ import { saveObservation, saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from './lib/save-observation.mjs';
59
58
  import { normalizeScope, insertObservationVector, applyObsUpdate } from './lib/observation-write.mjs';
60
59
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
61
60
  import { recallByFile } from './lib/recall-core.mjs';
@@ -81,7 +80,7 @@ const SURFACE_LABELS = {
81
80
  import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
82
81
  import {
83
82
  insertDeferred, listOpenWithOrdinal, dropDeferred, formatDropReasonHint,
84
- resolveDeferredIds, closeDeferredItems,
83
+ resolveDeferredIds,
85
84
  getDeferredByIds, formatDeferredDetail,
86
85
  searchDeferredWork, formatDeferredSearchTrailer,
87
86
  formatDeferListRow, countStaleOpen, formatDeferStaleHint,
@@ -970,36 +969,21 @@ function cmdSave(db, args) {
970
969
  }
971
970
  }
972
971
 
973
- let result;
974
- let closesIds = null;
972
+ let result, closesIds;
975
973
  try {
976
- result = db.transaction(() => {
977
- const r = saveObservation(db, {
978
- content: text,
979
- title: flags.title,
980
- type,
981
- importance: rawImp,
982
- project,
983
- files: saveFiles,
984
- lesson_learned: rawLesson,
985
- supersedes: supersedesIds || undefined,
986
- });
987
- // Skip closure on dedup short-circuit — the obs row already exists, so
988
- // the deferred item should NOT be re-closed by a duplicate save call.
989
- // Resolving deferred ids only on the non-duplicate path keeps repeated
990
- // save commands (with the same --closes-deferred) idempotent even after
991
- // the deferred row has transitioned out of 'open'.
992
- if (r.kind === 'duplicate') return r;
993
- if (closesTokens) {
994
- // D#195: the close verb accepts a 'dropped' row and converts it to
995
- // 'done'. `defer drop` used on an item that was actually fixed was
996
- // otherwise a one-way gate, permanently losing the obs link. Kept in
997
- // sync with the same policy in server.mjs mem_save.
998
- closesIds = resolveDeferredIds(db, project, closesTokens, { allowStatuses: ['open', 'dropped'] });
999
- closeDeferredItems(db, closesIds, r.id);
1000
- }
1001
- return r;
1002
- })();
974
+ // The transaction body — dedup short-circuit BEFORE the resolver, and D#195's
975
+ // allowStatuses — is lib/save-observation.mjs's (audit 2026-09-02 P1-6). Both faces
976
+ // used to write it out and each carried a "kept in sync with the other" comment.
977
+ ({ result, closesIds } = saveWithClosures(db, {
978
+ content: text,
979
+ title: flags.title,
980
+ type,
981
+ importance: rawImp,
982
+ project,
983
+ files: saveFiles,
984
+ lesson_learned: rawLesson,
985
+ supersedes: supersedesIds || undefined,
986
+ }, { closesTokens, project }));
1003
987
  } catch (e) {
1004
988
  if (closesTokens) {
1005
989
  fail(`[mem] save with --closes-deferred failed: ${e.message}`);
@@ -2084,10 +2068,6 @@ function cmdMaintain(db, args) {
2084
2068
  }
2085
2069
  const staleAge = Date.now() - STALE_AGE_MS;
2086
2070
  const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };
2087
- const results = [];
2088
-
2089
- // T2-P1-B: surface the OP_CAP hit so users know to re-run, matching MCP mem_maintain.
2090
- const capHint = (changes) => (changes >= OP_CAP ? ' (cap reached, re-run for more)' : '');
2091
2071
 
2092
2072
  // Parse + validate --retain-days BEFORE the transaction so an invalid value rejects the
2093
2073
  // whole command atomically. The old code validated inside db.transaction() with a bare
@@ -2108,134 +2088,40 @@ function cmdMaintain(db, args) {
2108
2088
  // purge_stale is the only DELETE here — require --confirm so a mis-typed run can't wipe rows.
2109
2089
  const confirmed = flags.confirm === true || flags.confirm === 'true';
2110
2090
 
2111
- // Snapshot the DB before the irreversible cleanup/purge hard-deletes — only when rows will
2112
- // actually be removed, and OUTSIDE the transaction below (VACUUM cannot run inside one).
2113
- // Best-effort; snapshotDb never throws.
2114
- const willPurge = ops.includes('purge_stale') && confirmed;
2115
- if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: willPurge }) > 0) {
2116
- snapshotDb(db, { tag: 'pre-maintain' });
2117
- }
2118
-
2119
- db.transaction(() => {
2120
- // PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766). Running decay BEFORE
2121
- // purge in one transaction marked a stale row pending-purge AND deleted it in the SAME call
2122
- // (zero grace), and the pre-txn snapshot guard counts only PRE-EXISTING pending rows so it
2123
- // skipped the backup → permanent, unrecoverable loss of notable imp-2/3 memories (audit
2124
- // HIGH-1). Purging first deletes only rows a PRIOR run marked (which the guard saw + backed
2125
- // up); rows decay marks below wait for the next maintain run, regaining the grace cycle.
2126
- if (ops.includes('purge_stale')) {
2127
- if (!confirmed) {
2128
- const previewRow = purgeStalePreview(db, mctx, retainCutoff);
2129
- const pushLines = [`purge_stale preview (no --confirm):`,
2130
- ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
2131
- if (previewRow.candidates > 0) {
2132
- pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
2133
- pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
2134
- }
2135
- pushLines.push(` To delete, re-run with --confirm.`);
2136
- results.push(pushLines.join('\n'));
2137
- } else {
2138
- const purged = purgeStale(db, mctx, retainCutoff);
2139
- results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
2140
- }
2141
- }
2142
-
2143
- if (ops.includes('cleanup')) {
2144
- const deleted = cleanupBroken(db, mctx);
2145
- results.push(`Cleaned up ${deleted} broken observations${capHint(deleted)}`);
2146
- // Self-heal legacy orphans (keeper hard-deleted pre-recoverChildrenOf): resurface
2147
- // unreachable children. Non-destructive — un-hide only, no delete.
2148
- const orphans = recoverOrphanedChildren(db, mctx);
2149
- if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
2150
- // Heal lesson rows citation-decay buried at importance 0 (pre floor=1). 0→1 on
2151
- // lesson-bearing rows only; idempotent no-op once none remain.
2152
- const lessonsHealed = recoverBuriedLessons(db, mctx);
2153
- if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
2154
- // Heal deferred_work rows whose closing obs / source prompt was hard-deleted while FK was
2155
- // OFF (dangling ref foreign_key_check flags). Applies the ON DELETE SET NULL the FK would.
2156
- const deferredHealed = sweepDeferredWorkOrphans(db, mctx);
2157
- if (deferredHealed > 0) results.push(`Healed ${deferredHealed} deferred-work rows with dangling references`);
2158
- }
2159
-
2160
- if (ops.includes('decay')) {
2161
- // injection_count>0 protected (maintain-core; shared with MCP + hook auto-maintain).
2162
- const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
2163
- const decayCap = (decayed >= OP_CAP || idleMarked >= OP_CAP) ? ' (cap reached, re-run for more)' : '';
2164
- results.push(`Decayed ${decayed} stale observations, marked ${idleMarked} idle as pending-purge${decayCap}`);
2165
- }
2166
-
2167
- if (ops.includes('boost')) {
2168
- const boosted = boostAccessed(db, mctx);
2169
- results.push(`Boosted ${boosted} frequently-accessed observations${capHint(boosted)}`);
2170
- }
2171
-
2172
- // AFTER boost, matching server.mjs and hook.mjs. This block used to sit BEFORE
2173
- // it, and the order was load-bearing in the wrong direction: boostAccessed lifts
2174
- // any access_count>3 row with importance<3, so demoting a pinned row to 1 and
2175
- // then boosting handed it straight back at 2 — the demotion silently undone
2176
- // inside a single maintain run. DEFAULT_MAINTAIN_OPS pins the order; this block
2177
- // has to physically follow the boost block for that order to be real.
2178
- if (ops.includes('demote_pinned')) {
2179
- // Repair the citation-decay blind spot: decay protects injection_count>0, so a
2180
- // heavily-injected-but-uncited memory stays pinned at max importance forever.
2181
- // demotePinned (maintain-core) floors it in one pass: no lesson_learned -> 1,
2182
- // lesson-bearing -> 2 (v3.76.1 dual floor). Floor, not purge.
2183
- const demoted = demotePinned(db, mctx);
2184
- results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=${PINNED_INJ_THRESHOLD}, cited=0; no lesson → importance 1, lesson → 2)${capHint(demoted)}`);
2185
- }
2186
-
2187
- if (ops.includes('dedup') && flags['merge-ids']) {
2188
- // Parse "keepId:removeId1:removeId2,keepId2:removeId3"; surface malformed segments
2189
- // (non-numeric / single-element) instead of silently dropping them. The merge SQL
2190
- // itself lives in maintain-core mergeDuplicates (shared with MCP).
2191
- const invalidSegments = [];
2192
- const groups = [];
2193
- for (const seg of flags['merge-ids'].split(',').map(s => s.trim()).filter(Boolean)) {
2194
- const parts = seg.split(':').map(s => s.trim());
2195
- const nums = parts.map(p => Number(p));
2196
- if (parts.length < 2 || nums.some(n => !Number.isFinite(n) || n <= 0)) { invalidSegments.push(seg); continue; }
2197
- groups.push(nums);
2091
+ // Parse "keepId:removeId1:removeId2,keepId2:removeId3"; surface malformed segments
2092
+ // (non-numeric / single-element) instead of silently dropping them. The merge SQL and the
2093
+ // op ORDER both live in maintain-core (shared with MCP, audit 2026-09-02 P1-5) — this is
2094
+ // the one genuinely CLI-shaped part, because the MCP tool receives groups already typed.
2095
+ const invalidMergeSegments = [];
2096
+ const mergeGroups = [];
2097
+ if (flags['merge-ids']) {
2098
+ for (const seg of String(flags['merge-ids']).split(',').map(x => x.trim()).filter(Boolean)) {
2099
+ const parts = seg.split(':').map(x => x.trim());
2100
+ const nums = parts.map(x => Number(x));
2101
+ if (parts.length < 2 || nums.some(n => !Number.isFinite(n) || n <= 0)) { invalidMergeSegments.push(seg); continue; }
2102
+ mergeGroups.push(nums);
2103
+ }
2104
+ }
2105
+
2106
+ const results = runMaintainOps(db, mctx, ops, {
2107
+ retainDays,
2108
+ retainCutoff,
2109
+ confirmed,
2110
+ mergeGroups,
2111
+ mergeIdsProvided: Boolean(flags['merge-ids']),
2112
+ invalidMergeSegments,
2113
+ mergeIdsFlagName: '--merge-ids',
2114
+ renderPurgePreview: (previewRow) => {
2115
+ const lines = ['purge_stale preview (no --confirm):',
2116
+ ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
2117
+ if (previewRow.candidates > 0) {
2118
+ lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
2119
+ lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
2198
2120
  }
2199
- const totalMerged = mergeDuplicates(db, groups);
2200
- if (invalidSegments.length) {
2201
- results.push(`Warning: ignored ${invalidSegments.length} malformed --merge-ids segment(s): ${invalidSegments.join(', ')} (expected keepId:removeId[:removeId...] with positive integers)`);
2202
- }
2203
- results.push(`Merged ${totalMerged} duplicate observations`);
2204
- }
2205
-
2206
- // T2-P1-B parity with MCP: warn when merge-ids is provided but dedup wasn't requested.
2207
- if (!ops.includes('dedup') && flags['merge-ids']) {
2208
- results.push('Warning: --merge-ids provided but "dedup" not in operations — merge-ids ignored');
2209
- }
2210
-
2211
- })();
2212
-
2213
- // FTS optimize
2214
- db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
2215
- results.push('FTS5 index optimized');
2216
-
2217
- // rebuild_vectors: outside main transaction (maintain-core, shared with MCP).
2218
- if (ops.includes('rebuild_vectors')) {
2219
- try {
2220
- const r = rebuildVectors(db);
2221
- results.push(r.ok
2222
- ? `Vectors: rebuilt vocabulary (${r.terms} terms), updated ${r.updated}/${r.total} vectors`
2223
- : `Vectors: ${r.reason}`);
2224
- } catch (e) {
2225
- results.push(`Vectors: rebuild failed — ${e.message}`);
2226
- }
2227
- }
2228
-
2229
- // vacuum: reclaim freelist pages left by DELETEs. Whole-DB, outside any transaction.
2230
- // maintain-core, shared with MCP. Reports freelist before/after as the §7 reclaim metric.
2231
- if (ops.includes('vacuum')) {
2232
- try {
2233
- const v = vacuum(db);
2234
- results.push(`VACUUM: reclaimed ~${v.reclaimedMB}MB (freelist ${v.freeBefore} → ${v.freeAfter} pages)`);
2235
- } catch (e) {
2236
- results.push(`VACUUM failed — ${e.message}`);
2237
- }
2238
- }
2121
+ lines.push(' To delete, re-run with --confirm.');
2122
+ return lines.join('\n');
2123
+ },
2124
+ });
2239
2125
 
2240
2126
  out(`[mem] ${results.join('\n[mem] ')}`);
2241
2127
  }
@@ -2844,7 +2730,7 @@ Commands:
2844
2730
  --merge-ids K:R,... For dedup: keepId:removeId pairs (e.g. 10:11,20:21:22)
2845
2731
  --project P Filter by project
2846
2732
  --retain-days N For purge_stale: keep last N days (default 30)
2847
- demote_pinned: floors importance for inj>=8 & cited=0 — to 1 with no
2733
+ demote_pinned: floors importance for inj>=${PINNED_INJ_THRESHOLD} & cited=0 — to 1 with no
2848
2734
  lesson_learned, to 2 with one (clears pinned noise; a lesson-bearing
2849
2735
  row keeps eligibility on every importance>=2 injection face).
2850
2736
  In the default set since v3.76.0; runs AFTER boost, which would
@@ -2993,17 +2879,9 @@ async function cmdImport(argv) {
2993
2879
  if (flags.enrich) {
2994
2880
  out('[mem] Running LLM enrichment...');
2995
2881
  const { enrichResource } = await import('./registry-enricher.mjs');
2996
- let enriched = 0;
2997
- for (const r of results) {
2998
- const row = rdb.prepare('SELECT local_path FROM resources WHERE id = ?').get(r.id);
2999
- if (!row?.local_path) continue;
3000
- try {
3001
- const content = readFileSync(row.local_path, 'utf8');
3002
- const ok = await enrichResource(rdb, r.name, r.type, content);
3003
- if (ok) enriched++;
3004
- } catch {}
3005
- }
2882
+ const { ok: enriched, denied } = await enrichImportedResources(rdb, results, { confineTo: DB_DIR, enrichResource });
3006
2883
  out(`[mem] Enriched ${enriched}/${results.length} resources.`);
2884
+ if (denied) out(`[mem] Refused ${denied}: local_path outside the managed directory (${REGISTRY_CONFINE_ENV}=off to override).`);
3007
2885
  }
3008
2886
  } catch (e) {
3009
2887
  fail(`[mem] Import failed: ${e.message}`);
@@ -3121,24 +2999,28 @@ async function cmdEnrich(argv) {
3121
2999
  const rows = rdb.prepare("SELECT name, type, local_path FROM resources WHERE status = 'active' AND (enrichment_status IS NULL OR enrichment_status = 'failed')").all();
3122
3000
  if (rows.length === 0) { out('[mem] All resources already enriched.'); return; }
3123
3001
  out(`[mem] Enriching ${rows.length} resources...`);
3124
- let ok = 0, failCount = 0;
3002
+ let ok = 0, failCount = 0, denied = 0;
3125
3003
  for (const r of rows) {
3126
- if (!r.local_path) { failCount++; continue; }
3127
- try {
3128
- const content = readFileSync(r.local_path, 'utf8');
3129
- const success = await enrichResource(rdb, r.name, r.type, content);
3130
- if (success) ok++; else failCount++;
3131
- if (!flags.batch) await new Promise(resolve => setTimeout(resolve, 500));
3132
- } catch { failCount++; }
3004
+ const { status, error } = await enrichResourceRow(rdb, r, { confineTo: DB_DIR, enrichResource });
3005
+ if (status === 'enriched') ok++;
3006
+ else if (status === 'denied') denied++;
3007
+ else failCount++;
3008
+ // Pace exactly where the pre-refactor loop did: after an enrichResource call that
3009
+ // RETURNED. A missing path, an unreadable file, a gate refusal and a throw out of
3010
+ // enrichResource all skipped the sleep before, and still do — `error` present on a
3011
+ // 'failed' row is what distinguishes "returned false" from "threw".
3012
+ const apiReturned = status === 'enriched' || (status === 'failed' && !error);
3013
+ if (apiReturned && !flags.batch) await new Promise(resolve => setTimeout(resolve, 500));
3133
3014
  }
3134
3015
  out(`[mem] Done: ${ok} enriched, ${failCount} failed.`);
3016
+ if (denied) out(`[mem] Refused ${denied}: local_path outside the managed directory (${REGISTRY_CONFINE_ENV}=off to override).`);
3135
3017
  } else if (name) {
3136
- const row = rdb.prepare("SELECT name, type, local_path FROM resources WHERE name = ? AND status = 'active'").get(name);
3137
- if (!row) { fail(`[mem] Resource not found: ${name}`); return; }
3138
- if (!row.local_path) { fail(`[mem] No local_path for ${name}`); return; }
3139
- const content = readFileSync(row.local_path, 'utf8');
3140
- const success = await enrichResource(rdb, row.name, row.type, content);
3141
- out(success ? `[mem] Enriched: ${name}` : `[mem] Enrichment failed for ${name}`);
3018
+ const { status, error } = await enrichNamedResource(rdb, name, { confineTo: DB_DIR, enrichResource });
3019
+ if (status === 'not-found') { fail(`[mem] Resource not found: ${name}`); return; }
3020
+ if (status === 'no-path') { fail(`[mem] No local_path for ${name}`); return; }
3021
+ if (status === 'denied') { fail(`[mem] Access denied: local_path outside the managed directory (${REGISTRY_CONFINE_ENV}=off to override).`); return; }
3022
+ if (status === 'unreadable') { fail(`[mem] Enrich error: ${error?.message || 'cannot read local_path'}`); return; }
3023
+ out(status === 'enriched' ? `[mem] Enriched: ${name}` : `[mem] Enrichment failed for ${name}`);
3142
3024
  } else {
3143
3025
  fail('[mem] Usage: claude-mem-lite enrich <name> OR claude-mem-lite enrich --all [--batch]');
3144
3026
  }
package/memdir.mjs CHANGED
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // See docs/plans/2026-04-16-invited-memory-pattern.md for rationale.
11
11
 
12
- import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, mkdirSync, readdirSync } from 'fs';
12
+ import { readFileSync, existsSync, unlinkSync, mkdirSync, readdirSync } from 'fs';
13
+ import { atomicWriteFileSync as atomicWrite } from './lib/atomic-write.mjs';
13
14
  import { join } from 'path';
14
15
  import { homedir } from 'os';
15
16
  import { createHash } from 'crypto';
@@ -100,11 +101,10 @@ function canonicalBody(contentLine) {
100
101
 
101
102
  function sha256(s) { return createHash('sha256').update(s).digest('hex'); }
102
103
 
103
- function atomicWrite(path, content) {
104
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
105
- writeFileSync(tmp, content);
106
- renameSync(tmp, path);
107
- }
104
+ // Third byte-identical copy of the same temp+rename, all three missing the same lstat
105
+ // (audit 2026-09-02 P0-5). MEMORY.md under a memory dir is a prime symlink candidate —
106
+ // users point it at a dotfiles repo so the same index follows them between machines —
107
+ // and renaming onto the link NAME severs it silently. See lib/atomic-write.mjs.
108
108
 
109
109
  function readState(memdir, slug) {
110
110
  const p = stateFile(memdir, slug);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.91.0",
3
+ "version": "3.92.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.91.0",
9
+ "version": "3.92.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
@@ -18,10 +18,12 @@
18
18
  "devDependencies": {
19
19
  "@eslint/js": "^10.0.1",
20
20
  "@vitest/coverage-v8": "^4.0.18",
21
+ "acorn": "8.16.0",
21
22
  "eslint": "^10.0.0",
22
23
  "fast-check": "^4.5.3",
23
24
  "knip": "^6.12.1",
24
25
  "picomatch": "^4.0.4",
26
+ "prettier": "3.6.2",
25
27
  "vitest": "^4.0.18"
26
28
  },
27
29
  "engines": {
@@ -3796,6 +3798,22 @@
3796
3798
  "node": ">= 0.8.0"
3797
3799
  }
3798
3800
  },
3801
+ "node_modules/prettier": {
3802
+ "version": "3.6.2",
3803
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
3804
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
3805
+ "dev": true,
3806
+ "license": "MIT",
3807
+ "bin": {
3808
+ "prettier": "bin/prettier.cjs"
3809
+ },
3810
+ "engines": {
3811
+ "node": ">=14"
3812
+ },
3813
+ "funding": {
3814
+ "url": "https://github.com/prettier/prettier?sponsor=1"
3815
+ }
3816
+ },
3799
3817
  "node_modules/proxy-addr": {
3800
3818
  "version": "2.0.7",
3801
3819
  "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.91.0",
3
+ "version": "3.92.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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",
@@ -16,7 +16,11 @@
16
16
  },
17
17
  "scripts": {
18
18
  "lint": "eslint .",
19
+ "format": "prettier --write \"**/*.{mjs,js}\"",
20
+ "format:check": "prettier --check \"**/*.{mjs,js}\"",
19
21
  "dead-code": "knip",
22
+ "audit:metrics": "node scripts/audit-metrics.mjs --md",
23
+ "audit:baseline": "node scripts/audit-metrics.mjs --run-tests --md",
20
24
  "test": "vitest run",
21
25
  "test:smoke": "vitest run tests/smoke.test.mjs",
22
26
  "test:coverage": "vitest run --coverage",
@@ -65,6 +69,8 @@
65
69
  "lib/edge-attribution.mjs",
66
70
  "lib/file-edge-match.mjs",
67
71
  "lib/cite-back-hint.mjs",
72
+ "lib/cite-recall-path.mjs",
73
+ "lib/frontmatter.mjs",
68
74
  "lib/cooldown-path.mjs",
69
75
  "lib/tmp-fixture-sweep.mjs",
70
76
  "lib/summary-extractor.mjs",
@@ -201,10 +207,12 @@
201
207
  "devDependencies": {
202
208
  "@eslint/js": "^10.0.1",
203
209
  "@vitest/coverage-v8": "^4.0.18",
210
+ "acorn": "8.16.0",
204
211
  "eslint": "^10.0.0",
205
212
  "fast-check": "^4.5.3",
206
213
  "knip": "^6.12.1",
207
214
  "picomatch": "^4.0.4",
215
+ "prettier": "3.6.2",
208
216
  "vitest": "^4.0.18"
209
217
  }
210
218
  }
@@ -5,6 +5,7 @@
5
5
  import { parseGitHubUrl, buildTreeUrl, buildContentUrl, buildRepoUrl, buildHeaders } from './registry-github.mjs';
6
6
  import { upsertResource } from './registry.mjs';
7
7
  import { debugLog, isPathConfined } from './utils.mjs';
8
+ import { parseFrontmatter } from './lib/frontmatter.mjs';
8
9
  import { createHash } from 'crypto';
9
10
  import { mkdirSync, writeFileSync } from 'fs';
10
11
  import { join } from 'path';
@@ -98,40 +99,9 @@ export function discoverFromTree(treeData, pathFilter) {
98
99
  * @param {string} content Full file content
99
100
  * @returns {{ frontmatter: Record<string, any>, body: string }}
100
101
  */
101
- export function parseFrontmatter(content) {
102
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
103
- if (!match) return { frontmatter: {}, body: content };
104
-
105
- const raw = match[1];
106
- const body = content.slice(match[0].length).trim();
107
- const fm = {};
108
- let currentKey = null, currentValue = '', inMultiline = false;
109
-
110
- for (const line of raw.split('\n')) {
111
- if (inMultiline && (line.startsWith(' ') || line.startsWith('\t') || line.trim() === '')) {
112
- currentValue += ' ' + line.trim();
113
- continue;
114
- }
115
- if (inMultiline && currentKey) { fm[currentKey] = currentValue.trim(); inMultiline = false; }
116
-
117
- const kv = line.match(/^(\w[\w-]*)\s*:\s*(.*)/);
118
- if (kv) {
119
- currentKey = kv[1];
120
- let val = kv[2].trim();
121
- if (val === '|' || val === '>') { inMultiline = true; currentValue = ''; continue; }
122
- if (val.startsWith('[') && val.endsWith(']')) {
123
- try { fm[currentKey] = JSON.parse(val); } catch { fm[currentKey] = val; }
124
- continue;
125
- }
126
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))
127
- val = val.slice(1, -1);
128
- if (currentKey === 'description' && val) { inMultiline = true; currentValue = val; continue; }
129
- fm[currentKey] = val;
130
- }
131
- }
132
- if (inMultiline && currentKey) fm[currentKey] = currentValue.trim();
133
- return { frontmatter: fm, body };
134
- }
102
+ // The parser itself is lib/frontmatter.mjs's (audit 2026-09-02 P1-16); re-exported here
103
+ // because callers and tests import it from this module.
104
+ export { parseFrontmatter };
135
105
 
136
106
  // ─── Keyword Extraction ─────────────────────────────────────────────────────
137
107
 
package/registry.mjs CHANGED
@@ -443,7 +443,8 @@ export function ensureRegistryDb(dbPath) {
443
443
  return db;
444
444
  }
445
445
 
446
- // ─── Exported Schema (for test-helpers.mjs) ─────────────────────────────────
446
+ // ─── Exported Schema (test-helpers.mjs, and scripts/index-managed.mjs since P1-16, which
447
+ // rebuilds resources_fts from scratch and used to carry its own copy of both blocks) ──
447
448
 
448
449
  export { RESOURCES_SCHEMA, FTS5_SCHEMA, TRIGGERS_SCHEMA, INVOCATIONS_SCHEMA, PREINSTALLED_SCHEMA };
449
450
 
@@ -10,8 +10,13 @@
10
10
  // presentIdents excluded it); that class is carried by the pre-edit
11
11
  // BIND_DIRECTIVE, not here. See the spec's component-2 limits.
12
12
  //
13
- // Safety: readonly, no DB, exit 0 always. cooldownPathFor mirrors
14
- // pre-tool-recall.js (inlined per the #8447 fast-path convention).
13
+ // Safety: readonly, no DB, exit 0 always. The cooldown path rule comes from
14
+ // lib/cooldown-path.mjs — it used to be inlined here "per the #8447 fast-path
15
+ // convention", but that exemption stopped applying to THIS script the moment it grew
16
+ // the four lib imports below (audit 2026-09-02 P1-1: the fourth copy; the previous
17
+ // round collapsed only three of four). A writer/reader disagreement on this name does
18
+ // not error — it reads a file nobody wrote, silently zeroing the bind-mode
19
+ // dropped-identifier check.
15
20
 
16
21
  import { existsSync, readFileSync } from 'fs';
17
22
  import { basename, join } from 'path';
@@ -22,6 +27,7 @@ import { recordHookError } from '../lib/hook-telemetry.mjs';
22
27
  // emit added later merges instead of producing two JSON documents, which the host
23
28
  // parses as neither (lib/hook-stdout.mjs). Import-free module over no runtime deps.
24
29
  import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
30
+ import { cooldownPathFor as sharedCooldownPathFor } from '../lib/cooldown-path.mjs';
25
31
 
26
32
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
27
33
 
@@ -29,10 +35,12 @@ const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
29
35
  const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
30
36
  const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
31
37
 
38
+ // The no-session legacy fallback stays local: it is this script's own back-compat with
39
+ // pre-session-id cooldown files, not part of the shared naming rule. Same split as
40
+ // scripts/pre-tool-recall.js, which is the writer.
32
41
  function cooldownPathFor(sessionId) {
33
42
  if (!sessionId) return LEGACY_COOLDOWN_PATH;
34
- const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
35
- return join(RUNTIME_DIR, `pre-recall-cooldown-${safe}.json`);
43
+ return sharedCooldownPathFor(RUNTIME_DIR, sessionId);
36
44
  }
37
45
 
38
46
  async function main() {
@@ -8,7 +8,7 @@ import { existsSync, readFileSync, mkdirSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
9
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
10
10
  import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
11
- import { injectedIdsFileName, injectedIdKey, EVENT_ID_PREFIX } from '../lib/injected-ids.mjs';
11
+ import { injectedIdsFileName, injectedIdKey, EVENT_ID_PREFIX, readInjectedMarker, mergeInjectedMarker } from '../lib/injected-ids.mjs';
12
12
  import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
13
13
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
14
14
  import { recordHookError } from '../lib/hook-telemetry.mjs';
@@ -253,45 +253,21 @@ function crossHookInjectedFile(project, sessionId) {
253
253
  // read and merge now carry the CC session id: a payload written by a DIFFERENT
254
254
  // session is ignored (read) / replaced (merge), mirroring the v3.35.2 episode
255
255
  // session-key fix. Legacy payloads without `session` keep the old behavior.
256
+ // Predicate + payload shape live in lib/injected-ids.mjs (audit 2026-09-02 P1-2) — this
257
+ // script held two of the five hand-typed copies. The window stays this script's own
258
+ // constant; only the gate and the write are shared.
256
259
  function readCrossHookInjected(project, sessionId) {
257
- try {
258
- const raw = readFileSync(crossHookInjectedFile(project, sessionId), 'utf8');
259
- const { ids, ts, session } = JSON.parse(raw);
260
- if (session && sessionId && session !== sessionId) return new Set();
261
- if (!ts || Date.now() - ts > CROSS_HOOK_DEDUP_MS) return new Set();
262
- if (!Array.isArray(ids)) return new Set();
263
- return new Set(ids.map(String));
264
- } catch { return new Set(); }
260
+ const { ids } = readInjectedMarker(crossHookInjectedFile(project, sessionId),
261
+ { sessionId, maxAgeMs: CROSS_HOOK_DEDUP_MS });
262
+ return new Set(ids.map(String));
265
263
  }
266
264
 
267
265
  function mergeCrossHookInjected(project, newIds, sessionId) {
268
266
  if (!newIds || newIds.length === 0) return;
269
267
  try {
270
268
  mkdirSync(RUNTIME_DIR, { recursive: true });
271
- const file = crossHookInjectedFile(project, sessionId);
272
- let prev = { ids: [], ts: 0, count: 0 };
273
- try {
274
- const raw = readFileSync(file, 'utf8');
275
- const parsed = JSON.parse(raw);
276
- // Within the staleness window AND same session (or legacy): union.
277
- // Outside / other session: replace.
278
- if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS
279
- && !(parsed.session && sessionId && parsed.session !== sessionId)) {
280
- prev = parsed;
281
- }
282
- } catch { /* fresh file */ }
283
- const ids = [...new Set([
284
- ...(Array.isArray(prev.ids) ? prev.ids.map(String) : []),
285
- ...newIds.map(String),
286
- ])];
287
- // Atomic (tmp+rename, M-6): a plain write torn by a concurrent hook left the
288
- // shared marker as invalid JSON, silently disabling cross-hook dedup.
289
- atomicWriteFileSync(file, JSON.stringify({
290
- ids,
291
- ts: Date.now(),
292
- count: (prev.count || 0) + 1,
293
- ...(sessionId ? { session: sessionId } : {}),
294
- }));
269
+ mergeInjectedMarker(crossHookInjectedFile(project, sessionId), newIds,
270
+ { sessionId, maxAgeMs: CROSS_HOOK_DEDUP_MS, mode: 'union' });
295
271
  } catch { /* silent — dedup is best-effort */ }
296
272
  }
297
273