claude-mem-lite 3.65.0 → 3.66.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/hook.mjs CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
43
43
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
44
44
  sessionFile, getSessionId, createSessionId, openDb,
45
- spawnBackground, sweepOrphanEpisodeFiles,
45
+ spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
46
46
  } from './hook-shared.mjs';
47
47
  import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation, saveEpisodeImmediate } from './hook-llm.mjs';
48
48
  import { scrubRecord } from './lib/scrub-record.mjs';
@@ -54,6 +54,7 @@ import { snapshotDb } from './lib/db-backup.mjs';
54
54
  import {
55
55
  extractCitationsFromTranscript,
56
56
  extractAllInjected,
57
+ extractInjectedFromKeyContext,
57
58
  bumpCitationAccess,
58
59
  computeCiteRecall,
59
60
  applyCitationDecay,
@@ -69,6 +70,7 @@ import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs
69
70
  import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
70
71
  import { detectMemOverride } from './lib/mem-override.mjs';
71
72
  import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
73
+ import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
72
74
  import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
73
75
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
74
76
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
@@ -90,6 +92,7 @@ async function loadCacheGuard() {
90
92
  import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
91
93
  import { getVocabulary } from './tfidf.mjs';
92
94
 
95
+ import { DAY_MS } from './lib/time-constants.mjs';
93
96
  // Prevent recursive hooks from background claude -p calls
94
97
  // Background workers (llm-episode, llm-summary) are exempt — they're ours
95
98
  const event = process.argv[2];
@@ -745,7 +748,19 @@ async function handleStop() {
745
748
  // edit promotes them even without a literal #NN in text.
746
749
  const citeBackIds = extractCiteBackSignals(transcriptPath);
747
750
  for (const id of citeBackIds) injected.add(id);
748
- if (injected.size > 0) {
751
+ // D#124, promotion-only (v3.66.1): the SessionStart Key Context block
752
+ // leaves no hook attachment, so its ids come from the per-session
753
+ // marker. They are added to the decay set ONLY where they were
754
+ // actually cited (below), never as bare denominator: the block
755
+ // re-renders the same fixed top-10 unconditionally, so an uncited
756
+ // render says nothing about relevance — and since keyObs gates on
757
+ // `importance >= 2`, one demotion evicts the common importance-2 row
758
+ // from Key Context for good. v3.66.0 fed them in as denominator and
759
+ // that made the block eat its own contents.
760
+ const keyCtxIds = extractInjectedFromKeyContext({
761
+ runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
762
+ });
763
+ if (injected.size > 0 || keyCtxIds.size > 0) {
749
764
  // Text-floor gate: skip decay on tool-only Stops. Without this,
750
765
  // a turn that ends on tool_use locks every injected obs as
751
766
  // uncited (last_decided_session_id set), so a later turn that
@@ -758,6 +773,10 @@ async function handleStop() {
758
773
  } else {
759
774
  const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
760
775
  for (const id of citeBackIds) citedMain.add(id);
776
+ // The promotion-only half: a Key Context row the agent actually
777
+ // cited joins the decay set (and takes the promote branch); one
778
+ // it ignored is never entered, so it cannot streak or demote.
779
+ for (const id of keyCtxIds) if (citedMain.has(id)) injected.add(id);
761
780
  // D#60: the idempotency key must be the CC session UUID, NOT the
762
781
  // project-scoped memory sessionId — concurrent same-project CC
763
782
  // sessions share the latter, so the second session's decay pass
@@ -890,7 +909,7 @@ function gcStalePreRecallCooldowns() {
890
909
  function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now }) {
891
910
  // ── DB mutations in a transaction (crash-safe consistency) ──
892
911
  const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
893
- const autoCompressAge = Date.now() - 30 * 86400000; // 30 days (accelerated from 90)
912
+ const autoCompressAge = Date.now() - 30 * DAY_MS; // 30 days (accelerated from 90)
894
913
 
895
914
  db.transaction(() => {
896
915
  // Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
@@ -945,7 +964,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
945
964
  // imp=1 on these already; this just shrinks the GC latency so the
946
965
  // projected 32.5% corpus reduction materializes within a week on live
947
966
  // DBs instead of bleeding into the 30-day tier.
948
- const noiseCompressAge = Date.now() - 7 * 86400000;
967
+ const noiseCompressAge = Date.now() - 7 * DAY_MS;
949
968
  const noiseCompressed = db.prepare(`
950
969
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
951
970
  WHERE COALESCE(compressed_into, 0) = 0
@@ -975,7 +994,7 @@ function runSessionStartAutoMaintain(db) {
975
994
  } catch {}
976
995
  if (shouldMaintain) {
977
996
  try {
978
- const STALE_AGE = Date.now() - 30 * 86400000;
997
+ const STALE_AGE = Date.now() - 30 * DAY_MS;
979
998
  const OP_CAP = 500;
980
999
  // Shared maintenance context (whole-DB, cap 500) — used by every maintain-core
981
1000
  // op below AND the MED-2 snapshot guard. injection_count>0 protection lives in
@@ -995,7 +1014,7 @@ function runSessionStartAutoMaintain(db) {
995
1014
  // children (compressed_into dangling at a deleted id). purgeStale recovers them
996
1015
  // first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
997
1016
  // created_at_epoch: 30d marking gate + 7d grace = 37d.
998
- const purged = purgeStale(db, mctx, Date.now() - 37 * 86400000);
1017
+ const purged = purgeStale(db, mctx, Date.now() - 37 * DAY_MS);
999
1018
  if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
1000
1019
 
1001
1020
  const cleaned = cleanupBroken(db, mctx);
@@ -1109,7 +1128,7 @@ function runSessionStartAutoMaintain(db) {
1109
1128
  DELETE FROM session_handoffs
1110
1129
  WHERE (type = 'clear' AND created_at_epoch < ?)
1111
1130
  OR (type != 'clear' AND created_at_epoch < ?)
1112
- `).run(Date.now() - HANDOFF_EXPIRY_CLEAR - 86400000, Date.now() - HANDOFF_EXPIRY_EXIT - 86400000);
1131
+ `).run(Date.now() - HANDOFF_EXPIRY_CLEAR - DAY_MS, Date.now() - HANDOFF_EXPIRY_EXIT - DAY_MS);
1113
1132
  if (gc.changes > 0) debugLog('DEBUG', 'auto-maintain', `gc'd ${gc.changes} expired session_handoffs`);
1114
1133
  } catch (e) { debugCatch(e, 'auto-maintain-handoff-gc'); }
1115
1134
 
@@ -1349,6 +1368,10 @@ async function handleSessionStart() {
1349
1368
  // GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
1350
1369
  // moved here from pre-tool-recall.js's hot path.
1351
1370
  gcStalePreRecallCooldowns();
1371
+ // P2-15: the per-PROJECT half of the same problem — markers written once per
1372
+ // project and never revisited (session-/cite-recall-/skill cooldowns). Same
1373
+ // SessionStart cadence, 30d gate, named family list (hook-shared.mjs).
1374
+ try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
1352
1375
  // Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
1353
1376
  try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
1354
1377
  // Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
@@ -1487,12 +1510,15 @@ async function handleSessionStart() {
1487
1510
  // exclude-set blanked the same-project leg on adopted projects). Written
1488
1511
  // unconditionally (even when empty) so a resumed session can't act on a
1489
1512
  // previous session's stale marker semantics; 24h GC below.
1490
- try {
1491
- writeFileSync(
1492
- join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)),
1493
- JSON.stringify({ ids: contextCollector.keyContextIds || [], ts: Date.now(), session: ccSessionId || null }),
1494
- );
1495
- } catch (e) { debugCatch(e, 'session-start-keyctx-marker'); }
1513
+ // D#124: the same call also bumps injection_count on the rendered rows —
1514
+ // Key Context was a shown-but-uncounted surface, so its rows could never
1515
+ // reach applyCitationDecay's denominator. One recorder, both writers.
1516
+ recordKeyContextInjection(db, {
1517
+ runtimeDir: RUNTIME_DIR,
1518
+ project,
1519
+ sessionId: ccSessionId,
1520
+ ids: contextCollector.keyContextIds || [],
1521
+ });
1496
1522
 
1497
1523
  // One-time migration: remove any stale <claude-mem-context> block left in
1498
1524
  // CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
@@ -1797,7 +1823,7 @@ function handleAutoCompress() {
1797
1823
  if (!db) return;
1798
1824
 
1799
1825
  try {
1800
- const compressCutoff = Date.now() - 60 * 86400000; // 60 days
1826
+ const compressCutoff = Date.now() - 60 * DAY_MS; // 60 days
1801
1827
  const compressCandidates = selectCompressionCandidates(db, { cutoff: compressCutoff, includeAutoMarked: true });
1802
1828
  if (compressCandidates.length < 3) return;
1803
1829
 
@@ -13,7 +13,9 @@
13
13
  import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
14
14
  import { join } from 'path';
15
15
  import { debugCatch } from '../utils.mjs';
16
+ import { keyContextIdsFileName } from './injected-ids.mjs';
16
17
 
18
+ import { DAY_MS } from './time-constants.mjs';
17
19
  // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
18
20
  // Bounded to 1-7 digits to skip URL fragments, markdown anchors, etc.
19
21
  const CITATION_RE = /#(\d{1,7})\b/g;
@@ -343,9 +345,61 @@ export function extractInjectedFromFyi(transcriptPath, opts = {}) {
343
345
  }
344
346
 
345
347
  /**
346
- * Union of every injection surface's IDs for a transcript: pre-tool-recall +
347
- * UserPromptSubmit `<memory-context>` + PostToolUse error-recall + the
348
- * user-prompt-search FYI block. Single integration point the Stop handler calls.
348
+ * Extract observation IDs rendered into the SessionStart / PreCompact
349
+ * `<claude-mem-context>` File Lessons + Key Context sections (D#124).
350
+ *
351
+ * The odd one out among the extractors: this surface leaves no hook attachment
352
+ * in the transcript — the block is written straight to stdout at SessionStart —
353
+ * so there is nothing to parse. It reads the per-session marker instead, the
354
+ * same file handleUserPrompt uses as its exclude-set, which by construction
355
+ * lists what was ACTUALLY rendered (empty on quiet/adopted projects where the
356
+ * sections never appear).
357
+ *
358
+ * Session-gated: a marker whose recorded session differs from the caller's is
359
+ * another window's render and must not be attributed to this session.
360
+ *
361
+ * PROMOTION-ONLY (v3.66.1). Deliberately NOT part of extractAllInjected: the
362
+ * other four faces are query-conditioned — a row appears there because it
363
+ * MATCHED something, so its absence from the cited set is evidence it was not
364
+ * useful. A Key Context render is unconditional and re-renders the same fixed
365
+ * top-10 every session, so an uncited render is evidence of nothing but elapsed
366
+ * time. Feeding these ids into the decay DENOMINATOR made the block consume
367
+ * itself: keyObs gates on `importance >= 2` (hook-context.mjs), and a demotion
368
+ * takes the common importance-2 row to 1, dropping it out of Key Context
369
+ * permanently after 3 uncited sessions — each departure promoting the next row
370
+ * into the same grinder. Callers must therefore intersect with the cited set
371
+ * (see handleStop) so a CITED Key Context row is credited while an uncited one
372
+ * is left alone.
373
+ *
374
+ * @param {object} [ctx]
375
+ * @param {string} [ctx.runtimeDir]
376
+ * @param {string} [ctx.project]
377
+ * @param {string|null} [ctx.sessionId]
378
+ * @returns {Set<number>}
379
+ */
380
+ export function extractInjectedFromKeyContext({ runtimeDir, project, sessionId = null } = {}) {
381
+ const ids = new Set();
382
+ if (!runtimeDir || !project) return ids;
383
+ try {
384
+ const raw = readFileSync(join(runtimeDir, keyContextIdsFileName(project, sessionId)), 'utf8');
385
+ const parsed = JSON.parse(raw);
386
+ if (sessionId && parsed?.session && parsed.session !== sessionId) return ids;
387
+ for (const id of Array.isArray(parsed?.ids) ? parsed.ids : []) addObsId(ids, id);
388
+ } catch { /* no marker / torn write → nothing was rendered, fail open */ }
389
+ return ids;
390
+ }
391
+
392
+ /**
393
+ * Union of the QUERY-CONDITIONED injection surfaces for a transcript:
394
+ * pre-tool-recall + UserPromptSubmit `<memory-context>` + PostToolUse
395
+ * error-recall + the user-prompt-search FYI block. Single integration point the
396
+ * Stop handler calls for the decay DENOMINATOR.
397
+ *
398
+ * Key Context is intentionally absent (v3.66.1 — it was unioned here for one
399
+ * release): every face above appears because a row matched something, so an
400
+ * uncited appearance carries relevance information. An unconditional
401
+ * SessionStart render does not. Callers wanting the Key Context ids ask
402
+ * `extractInjectedFromKeyContext` directly and use them promotion-only.
349
403
  *
350
404
  * @param {string|null|undefined} transcriptPath
351
405
  * @param {object} [opts]
@@ -808,8 +862,8 @@ export function computeCitationFunnelTrend(db, { days = 7, limit = 10, project =
808
862
  if (!db) return empty;
809
863
  try {
810
864
  const now = Date.now();
811
- const windowStart = now - days * 86400000;
812
- const priorStart = now - 2 * days * 86400000;
865
+ const windowStart = now - days * DAY_MS;
866
+ const priorStart = now - 2 * days * DAY_MS;
813
867
  const projClause = project ? 'AND project = ?' : '';
814
868
 
815
869
  const sessions = db.prepare(`
package/lib/db-backup.mjs CHANGED
@@ -11,6 +11,7 @@ import { readdirSync, unlinkSync, statSync } from 'fs';
11
11
  import { dirname, basename, join } from 'path';
12
12
  import { debugLog } from '../utils.mjs';
13
13
 
14
+ import { DAY_MS } from './time-constants.mjs';
14
15
  // M-9 (audit 2026-08-14): per-tag retention alone let one-shot tags live forever —
15
16
  // each tag kept its newest 3, but a tag written once (pre-backfill-v3390 and four
16
17
  // siblings) never got a second snapshot to age it out, so 9 orphaned .bak files held
@@ -35,7 +36,7 @@ export function backupBudgetBytes() {
35
36
  // a per-tag exemption would make every one-shot tag's only snapshot immortal —
36
37
  // exactly the 360MB orphan shape M-9 exists to evict. Mirrors the purge grace
37
38
  // convention (7d) used by stale-observation retention.
38
- export const BACKUP_EVICTION_GRACE_MS = 7 * 86400000;
39
+ export const BACKUP_EVICTION_GRACE_MS = 7 * DAY_MS;
39
40
 
40
41
  // Canonical snapshot shape `<base>.<tag>-<ISO stamp>-<pid>-<seq>.bak` (what
41
42
  // snapshotDb writes). Budget eviction deletes ONLY names matching this — a
@@ -1,3 +1,4 @@
1
+ import { DAY_MS } from './time-constants.mjs';
1
2
  // claude-mem-lite — deferred_work data layer
2
3
  // Pure-data CRUD + ordinal resolver + transactional closure helper.
3
4
  // Decoupled from observations table: different lifecycle, different scoring.
@@ -60,7 +61,6 @@ export function listOpenWithOrdinal(db, project, limit = 10) {
60
61
 
61
62
  // ─── G11: list age + stale refresh hint (roadmap 2026-07-18) ─────────────────
62
63
 
63
- const DAY_MS = 86_400_000;
64
64
  // Internal const (was exported at v3.51.0 birth with zero external importers —
65
65
  // knip-baseline discipline: un-export rather than grow the unused-exports list).
66
66
  const DEFER_STALE_DAYS = 30;
@@ -19,8 +19,8 @@ import { appendFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSyn
19
19
  import { join } from 'path';
20
20
  import { scrubSecrets } from '../secret-scrub.mjs';
21
21
 
22
- const DAY_MS = 86400000;
23
22
 
23
+ import { DAY_MS } from './time-constants.mjs';
24
24
  function today() {
25
25
  // UTC date string; sharding daily keeps files bounded even at high sample rates.
26
26
  return new Date(Date.now()).toISOString().slice(0, 10);
@@ -26,7 +26,7 @@ import { join } from 'path';
26
26
  import { isNativeBindingError } from './binding-probe.mjs';
27
27
  import { recordNativeBindingBreakage } from './native-binding-hint.mjs';
28
28
 
29
- const DAY_MS = 86400000;
29
+ import { DAY_MS } from './time-constants.mjs';
30
30
  const RETENTION_MS = 14 * DAY_MS;
31
31
  const HOOK_ERRORS_SUBDIR = 'hook-errors';
32
32
 
@@ -0,0 +1,74 @@
1
+ // lib/keyctx-marker.mjs — the one place the SessionStart Key Context render is
2
+ // recorded. Two things happen together because they must describe the SAME set:
3
+ //
4
+ // ① the per-session marker file (D#123 review C-1): the obs ids ACTUALLY
5
+ // rendered into <claude-mem-context>, which handleUserPrompt reads as its
6
+ // exclude-set. Written even when empty, so a resumed session can never act
7
+ // on a previous session's stale semantics.
8
+ // ② injection_count / last_injected_at on those same rows (D#124): before
9
+ // this, Key Context was a shown-but-uncounted surface — up to 10 rows per
10
+ // session that could accrue no denominator and therefore never promote or
11
+ // demote through applyCitationDecay.
12
+ //
13
+ // handleSessionStart (hook.mjs) and handlePreCompact (hook-precompact.mjs) both
14
+ // render the block, so both call this. Keeping the pair in one function is the
15
+ // point: the CLI/MCP and SessionStart/PreCompact twin pairs in this repo have
16
+ // drifted often enough that "two callers, one body" is the standing rule.
17
+ //
18
+ // Never throws: a marker-write failure must not break context delivery, and it
19
+ // must not cost the metering either — the bump runs first.
20
+
21
+ import { writeFileSync } from 'fs';
22
+ import { join } from 'path';
23
+ import { debugCatch } from '../utils.mjs';
24
+ import { keyContextIdsFileName } from './injected-ids.mjs';
25
+
26
+ /**
27
+ * Record one Key Context render: bump the rendered rows, then persist the id
28
+ * list for the prompt-time exclude-set and the citation extractor.
29
+ *
30
+ * @param {import('better-sqlite3').Database} db
31
+ * @param {object} ctx
32
+ * @param {string} ctx.runtimeDir RUNTIME_DIR of the caller
33
+ * @param {string} ctx.project inferProject() value (filename-safe)
34
+ * @param {string|null} [ctx.sessionId] CC session id
35
+ * @param {number[]} [ctx.ids] obs ids ACTUALLY rendered (empty on quiet projects)
36
+ * @returns {{bumped: number, written: boolean}}
37
+ */
38
+ export function recordKeyContextInjection(db, { runtimeDir, project, sessionId = null, ids = [] } = {}) {
39
+ const clean = [];
40
+ for (const raw of Array.isArray(ids) ? ids : []) {
41
+ const id = Number(raw);
42
+ if (Number.isInteger(id) && id > 0 && id < 1e7) clean.push(id);
43
+ }
44
+
45
+ // NO injection_count bump. v3.66.0 added one here and it had to be reverted in
46
+ // v3.66.1: injection_count is not a neutral counter. scoring-sql.mjs states the
47
+ // invariant — "bumped ONLY on UserPromptSubmit / hook-memory auto-inject" —
48
+ // because noisePenaltyClause reads it as a NOISE signal: a row scores x0.5 once
49
+ // injection_count >= 4 (and > access_count * 3), x0.2 at >= 8. Nothing bumps
50
+ // access_count for a rendered row (bumpCitationAccess fires on CITED ids only),
51
+ // so the counter crosses those thresholds purely as a function of elapsed
52
+ // sessions, deprioritising the highest-importance rows — the exact rows Key
53
+ // Context renders — in mem_search, UPS ranking and injectionRelevanceSql.
54
+ //
55
+ // The UPS bump the reverted code claimed to "mirror verbatim" is
56
+ // query-conditioned: a row is counted only when it MATCHED a query, so the
57
+ // counter means "auto-injected and never useful". A Key Context render is
58
+ // unconditional and hits the same fixed row set every session, so it would have
59
+ // measured nothing but time. D#124's requirement is decay reachability, which
60
+ // the extractor face delivers on its own — decay reads decay_seen_count /
61
+ // uncited_streak / cited_count, never injection_count.
62
+ const bumped = 0;
63
+
64
+ let written = false;
65
+ try {
66
+ writeFileSync(
67
+ join(runtimeDir, keyContextIdsFileName(project, sessionId)),
68
+ JSON.stringify({ ids: clean, ts: Date.now(), session: sessionId || null }),
69
+ );
70
+ written = true;
71
+ } catch (e) { debugCatch(e, 'keyctx-marker-write'); }
72
+
73
+ return { bumped, written };
74
+ }
@@ -17,7 +17,8 @@ import { rebuildVocabulary, computeVector, _resetVocabCache, vectorsEnabled, vec
17
17
  import { DEDUP_JACCARD_THRESHOLD, MINHASH_PRE_THRESHOLD as MINHASH_PRE_THRESHOLD_SRC, FUZZY_DEDUP_THRESHOLD, FUZZY_BODY_THRESHOLD, MINHASH_PREFILTER } from './dedup-constants.mjs';
18
18
  import { liveObsFilterSql } from './inject-search-core.mjs';
19
19
 
20
- export const STALE_AGE_MS = 30 * 86400000;
20
+ import { DAY_MS } from './time-constants.mjs';
21
+ export const STALE_AGE_MS = 30 * DAY_MS;
21
22
  export const OP_CAP = 1000;
22
23
  export const SCAN_LIMIT = 500;
23
24
  export const DUPLICATE_LIMIT = 50;
package/lib/metrics.mjs CHANGED
@@ -19,8 +19,8 @@
19
19
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'fs';
20
20
  import { join } from 'path';
21
21
 
22
- const DAY_MS = 86400000;
23
22
 
23
+ import { DAY_MS } from './time-constants.mjs';
24
24
  function today() { return new Date(Date.now()).toISOString().slice(0, 10); }
25
25
 
26
26
  function metricsEnabled() {
@@ -84,7 +84,8 @@ export async function executeSaveEnrich(db, id, { callJson } = {}) {
84
84
  const wantAliases = !(row.search_aliases && row.search_aliases.trim());
85
85
  if (!wantLesson && !wantAliases) return { enriched: false, reason: 'complete' };
86
86
 
87
- const call = callJson || (await import('../haiku-client.mjs')).callModelJSON;
87
+ const { callModelJSON, BG_LLM_TIMEOUT_MS } = await import('../haiku-client.mjs');
88
+ const call = callJson || callModelJSON;
88
89
  const prompt = `A coding memory was just saved. Enrich its retrievability. Return ONLY valid JSON, no markdown fences.
89
90
 
90
91
  Title: ${truncate(row.title || '(untitled)', 200)}
@@ -96,7 +97,7 @@ search_aliases: 3-6 terms a user might search for the SAME memory that are NOT a
96
97
 
97
98
  let parsed = null;
98
99
  try {
99
- parsed = await call(prompt, 'haiku', { timeout: 15000, maxTokens: 400 });
100
+ parsed = await call(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 400 });
100
101
  } catch (e) { debugCatch(e, 'save-enrich-llm'); }
101
102
  if (!parsed || typeof parsed !== 'object') return { enriched: false, reason: 'llm-null' };
102
103
 
@@ -31,6 +31,7 @@ import { countSearchTotal, attachBodyTokens } from '../search-engine.mjs';
31
31
  import { notLowSignalTitleClause } from '../scoring-sql.mjs';
32
32
  import { liveObsFilterSql, recencyDecaySql } from './inject-search-core.mjs';
33
33
 
34
+ import { DAY_MS } from './time-constants.mjs';
34
35
  /** Sanitize a user query to FTS5 syntax; optionally force OR semantics. */
35
36
  export function buildSearchFtsQuery(query, { or = false } = {}) {
36
37
  let ftsQuery = sanitizeFtsQuery(query);
@@ -40,7 +41,7 @@ export function buildSearchFtsQuery(query, { or = false } = {}) {
40
41
 
41
42
  // Relative-duration units for `--since`. Months/years intentionally omitted —
42
43
  // they're calendar-ambiguous; absolute --from/--to cover longer windows.
43
- const DURATION_UNIT_MS = { s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 };
44
+ const DURATION_UNIT_MS = { s: 1000, m: 60000, h: 3600000, d: DAY_MS, w: 604800000 };
44
45
 
45
46
  /**
46
47
  * Parse a relative duration like "7d", "24h", "90m", "2w", "30s" into ms.
@@ -12,13 +12,14 @@ import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
12
12
  import { computeNoiseGauge } from './stats-quality.mjs';
13
13
  import { liveObsFilterSql } from './inject-search-core.mjs';
14
14
 
15
+ import { DAY_MS } from './time-constants.mjs';
15
16
  /**
16
17
  * Compute the primary stats feed. Row shapes are returned exactly as the twin
17
18
  * blocks produced them ({c}/{t}/{v} single-row objects, arrays for distributions)
18
19
  * so both call sites render with minimal diff.
19
20
  */
20
21
  export function computeStatsFeed(db, { project = null, days = 30, now = Date.now() } = {}) {
21
- const cutoff = now - days * 86400000;
22
+ const cutoff = now - days * DAY_MS;
22
23
  const projectFilter = project ? 'AND project = ?' : '';
23
24
  const baseParams = project ? [project] : [];
24
25
 
@@ -51,7 +52,7 @@ export function computeStatsFeed(db, { project = null, days = 30, now = Date.now
51
52
  SELECT date(created_at) as day, COUNT(*) as c FROM observations
52
53
  WHERE created_at_epoch >= ? ${projectFilter}
53
54
  GROUP BY day ORDER BY day DESC LIMIT 7
54
- `).all(now - 7 * 86400000, ...baseParams);
55
+ `).all(now - 7 * DAY_MS, ...baseParams);
55
56
 
56
57
  // Data health
57
58
  const tokenEst = db.prepare(`
@@ -60,7 +61,7 @@ export function computeStatsFeed(db, { project = null, days = 30, now = Date.now
60
61
  `).get(...baseParams);
61
62
  const avgImp = db.prepare(`SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
62
63
 
63
- const thirtyDaysAgo = now - 30 * 86400000;
64
+ const thirtyDaysAgo = now - 30 * DAY_MS;
64
65
  // v3.23 noise-gauge de-blinding: `<= 1` makes the imp=0 dormant population visible
65
66
  // (decay floor + LLM low-signal filter push ~half the live corpus to 0);
66
67
  // injection_count=0 mirrors decay's NEVER-INJECTED guard so injected-but-decayed
@@ -7,6 +7,7 @@ import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
7
7
  import { truncate } from '../format-utils.mjs';
8
8
  import { COMPRESSED_PENDING_PURGE } from '../utils.mjs';
9
9
 
10
+ import { DAY_MS } from './time-constants.mjs';
10
11
  // v3.42 F7: noise/low-signal ratios must divide by the LIVE (non-compressed) observation
11
12
  // count, NOT the all-rows total. Both numerators (lowVal, lowSignalTitle) already exclude
12
13
  // compressed rows (`compressed_into = 0`); dividing by an all-inclusive denominator
@@ -25,7 +26,7 @@ export function computeNoiseGauge({ liveTotal, lowValCount, lowSignalCount }) {
25
26
  export function computeQualityStats(db, { project, days }) {
26
27
  const projectFilter = project ? 'AND project = ?' : '';
27
28
  const baseParams = project ? [project] : [];
28
- const cutoff = Date.now() - days * 86400000;
29
+ const cutoff = Date.now() - days * DAY_MS;
29
30
 
30
31
  // LOW_SIGNAL match = NOT notLowSignal. Pure title-only builder: this is a
31
32
  // METRIC counting pattern-titled rows ("Low-signal titles" in stats output),
@@ -0,0 +1,21 @@
1
+ // lib/time-constants.mjs — millisecond time units, defined once.
2
+ //
3
+ // Four modules each carried their own `const DAY_MS` (deferred-work, metrics,
4
+ // err-sampler, hook-telemetry) with ~60 more bare 86400000 literals scattered
5
+ // across the runtime. The value cannot drift — that is not the problem. The
6
+ // problem is that a reader auditing a window ("is this 30 days or 30 hours?")
7
+ // had to re-derive the magic number at every site, and a grep for the policy
8
+ // returned a number rather than a name.
9
+ //
10
+ // Zero imports on purpose: lib/metrics.mjs and lib/err-sampler.mjs are sinks
11
+ // that must never crash or slow their callers, so anything they import has to
12
+ // stay a leaf.
13
+
14
+ // Kept internal rather than exported: only DAY_MS has runtime consumers today,
15
+ // and an export born with zero importers is how the knip baseline drifted
16
+ // 46→47 during v3.50–3.53 (DEFER_STALE_DAYS). Promote one when a caller needs it.
17
+ const SECOND_MS = 1000;
18
+ const MINUTE_MS = 60 * SECOND_MS;
19
+ const HOUR_MS = 60 * MINUTE_MS;
20
+
21
+ export const DAY_MS = 24 * HOUR_MS;
package/mem-cli.mjs CHANGED
@@ -1197,7 +1197,7 @@ async function cmdStats(db, args) {
1197
1197
  // recorded in the last 24h. Surfaces silent breakage (DB corruption,
1198
1198
  // CC upstream field rename) that would otherwise stay invisible — the
1199
1199
  // failure mode that left code-graph's matcher bug undetected for 10 sessions.
1200
- const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
1200
+ const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - DAY_MS);
1201
1201
 
1202
1202
  // M-9 (audit 2026-08-14): disk footprint — a "lite" store had accumulated 360MB of
1203
1203
  // pre-maintain snapshots against a 59MB DB with nothing reporting it. Cheap probes
@@ -1636,7 +1636,7 @@ function cmdExport(db, args) {
1636
1636
  if (flags.to) {
1637
1637
  exportToEpoch = new Date(flags.to).getTime();
1638
1638
  if (isNaN(exportToEpoch)) { fail(`[mem] Invalid --to date: "${flags.to}". Use YYYY-MM-DD or ISO 8601.`); return; }
1639
- if (/^\d{4}-\d{2}-\d{2}$/.test(flags.to)) exportToEpoch += 86400000 - 1;
1639
+ if (/^\d{4}-\d{2}-\d{2}$/.test(flags.to)) exportToEpoch += DAY_MS - 1;
1640
1640
  wheres.push('created_at_epoch <= ?'); params.push(exportToEpoch);
1641
1641
  }
1642
1642
  if (exportFromEpoch !== null && exportToEpoch !== null && exportFromEpoch > exportToEpoch) {
@@ -1883,7 +1883,7 @@ function cmdCompress(db, args) {
1883
1883
  }
1884
1884
  ageDays = parsed;
1885
1885
  }
1886
- const cutoff = Date.now() - ageDays * 86400000;
1886
+ const cutoff = Date.now() - ageDays * DAY_MS;
1887
1887
  const project = flags.project ? resolveProject(db, flags.project) : null;
1888
1888
 
1889
1889
  const candidates = selectCompressionCandidates(db, { cutoff, project });
@@ -2021,7 +2021,7 @@ function cmdMaintain(db, args) {
2021
2021
  }
2022
2022
  retainDays = parsed;
2023
2023
  }
2024
- const retainCutoff = Date.now() - retainDays * 86400000;
2024
+ const retainCutoff = Date.now() - retainDays * DAY_MS;
2025
2025
  // purge_stale is the only DELETE here — require --confirm so a mis-typed run can't wipe rows.
2026
2026
  const confirmed = flags.confirm === true || flags.confirm === 'true';
2027
2027
 
@@ -2592,7 +2592,7 @@ function cmdCitationStats(db, args) {
2592
2592
  out(`Recently demoted (last ${days}d, importance ↓):`);
2593
2593
  if (demoted.length === 0) out(' (none)');
2594
2594
  for (const r of demoted) {
2595
- const ago = Math.round((Date.now() - r.demoted_at) / 86400000);
2595
+ const ago = Math.round((Date.now() - r.demoted_at) / DAY_MS);
2596
2596
  out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} imp=${r.importance} ${ago}d ago`);
2597
2597
  }
2598
2598
  }
@@ -3140,6 +3140,7 @@ import { cmdDoctor } from './cli/doctor.mjs';
3140
3140
  // cmdActivity (T7 v2.31) extracted to cli/activity.mjs (v2.41 split).
3141
3141
  import { cmdActivity } from './cli/activity.mjs';
3142
3142
 
3143
+ import { DAY_MS } from './lib/time-constants.mjs';
3143
3144
  // ─── Main Entry Point ────────────────────────────────────────────────────────
3144
3145
 
3145
3146
  export async function run(argv) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.65.0",
3
+ "version": "3.66.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.65.0",
9
+ "version": "3.66.1",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.65.0",
3
+ "version": "3.66.1",
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",
@@ -83,6 +83,8 @@
83
83
  "lib/release-digest.mjs",
84
84
  "lib/mem-override.mjs",
85
85
  "lib/injected-ids.mjs",
86
+ "lib/time-constants.mjs",
87
+ "lib/keyctx-marker.mjs",
86
88
  "lib/inject-search-core.mjs",
87
89
  "lib/get-core.mjs",
88
90
  "lib/browse-core.mjs",
@@ -2,7 +2,7 @@
2
2
  // Sends resource content to Haiku for semantic metadata generation
3
3
  // Graceful degradation: failure preserves existing data
4
4
 
5
- import { callHaikuJSON } from './haiku-client.mjs';
5
+ import { callHaikuJSON, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
6
6
  import { truncate, debugCatch } from './utils.mjs';
7
7
 
8
8
  /**
@@ -84,7 +84,7 @@ export async function enrichResource(db, name, type, content) {
84
84
 
85
85
  try {
86
86
  const prompt = buildEnrichPrompt(name, content, existing);
87
- const result = await callHaikuJSON(prompt, { timeout: 15000, maxTokens: 500 });
87
+ const result = await callHaikuJSON(prompt, { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
88
88
 
89
89
  if (!result || !result.capability_summary) {
90
90
  db.prepare("UPDATE resources SET enrichment_status = 'failed' WHERE name = ? AND type = ?").run(name, type);
@@ -8,6 +8,7 @@ import { join } from 'path';
8
8
  import { resolveDataDir } from './lib/resolve-data-dir.mjs';
9
9
  import { searchResources, cjkIntentTokens } from './registry-retriever.mjs';
10
10
 
11
+ import { DAY_MS } from './lib/time-constants.mjs';
11
12
  const VALID_MODES = new Set(['shadow', 'live', 'off']);
12
13
 
13
14
  /** Recommendation mode from env; default 'shadow', unknown → 'shadow'. */
@@ -137,7 +138,7 @@ export function gcOldShadowShards(retainDays = 90) {
137
138
  try {
138
139
  const dir = shadowDir();
139
140
  if (!existsSync(dir)) return 0;
140
- const cutoff = new Date(Date.now() - retainDays * 86_400_000).toISOString().slice(0, 10);
141
+ const cutoff = new Date(Date.now() - retainDays * DAY_MS).toISOString().slice(0, 10);
141
142
  let removed = 0;
142
143
  for (const name of readdirSync(dir)) {
143
144
  const m = /^(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
@@ -155,7 +156,7 @@ export function logShadowAdoption(project, rec) { appendShadow({ ts: new Date().
155
156
  /** Yield parsed shadow rows from the last `days` daily shards. */
156
157
  export function* readShadowLog(days = 7) {
157
158
  for (let i = 0; i < days; i++) {
158
- const d = new Date(Date.now() - i * 86_400_000).toISOString().slice(0, 10);
159
+ const d = new Date(Date.now() - i * DAY_MS).toISOString().slice(0, 10);
159
160
  let raw;
160
161
  try { raw = readFileSync(join(shadowDir(), `${d}.jsonl`), 'utf8'); } catch { continue; }
161
162
  for (const line of raw.split('\n')) { if (!line) continue; try { yield JSON.parse(line); } catch { /* skip malformed */ } }