claude-mem-lite 3.65.0 → 3.66.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.
@@ -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;
@@ -342,16 +344,55 @@ export function extractInjectedFromFyi(transcriptPath, opts = {}) {
342
344
  return ids;
343
345
  }
344
346
 
347
+ /**
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 enter this session's denominator. No
360
+ * coordinates → empty set, so transcript-only callers keep their old behaviour.
361
+ *
362
+ * @param {object} [ctx]
363
+ * @param {string} [ctx.runtimeDir]
364
+ * @param {string} [ctx.project]
365
+ * @param {string|null} [ctx.sessionId]
366
+ * @returns {Set<number>}
367
+ */
368
+ export function extractInjectedFromKeyContext({ runtimeDir, project, sessionId = null } = {}) {
369
+ const ids = new Set();
370
+ if (!runtimeDir || !project) return ids;
371
+ try {
372
+ const raw = readFileSync(join(runtimeDir, keyContextIdsFileName(project, sessionId)), 'utf8');
373
+ const parsed = JSON.parse(raw);
374
+ if (sessionId && parsed?.session && parsed.session !== sessionId) return ids;
375
+ for (const id of Array.isArray(parsed?.ids) ? parsed.ids : []) addObsId(ids, id);
376
+ } catch { /* no marker / torn write → nothing was rendered, fail open */ }
377
+ return ids;
378
+ }
379
+
345
380
  /**
346
381
  * Union of every injection surface's IDs for a transcript: pre-tool-recall +
347
382
  * UserPromptSubmit `<memory-context>` + PostToolUse error-recall + the
348
- * user-prompt-search FYI block. Single integration point the Stop handler calls.
383
+ * user-prompt-search FYI block + the SessionStart Key Context block. Single
384
+ * integration point the Stop handler calls.
349
385
  *
350
386
  * @param {string|null|undefined} transcriptPath
351
387
  * @param {object} [opts]
352
388
  * @param {boolean} [opts.mainOnly=false] Skip sidechain-injected IDs. The
353
389
  * citation-decay caller passes true so the injected denominator matches the
354
390
  * mainOnly cited numerator; the P4 access-bump caller omits it (broader).
391
+ * @param {string} [opts.runtimeDir] With `project`, enables the Key Context
392
+ * face. Omitted by callers that only have a transcript path (computeCiteRecall
393
+ * over an arbitrary file), which then see the four transcript-derived faces.
394
+ * @param {string} [opts.project]
395
+ * @param {string|null} [opts.sessionId]
355
396
  * @returns {Set<number>}
356
397
  */
357
398
  export function extractAllInjected(transcriptPath, opts = {}) {
@@ -360,6 +401,9 @@ export function extractAllInjected(transcriptPath, opts = {}) {
360
401
  ...extractInjectedFromUserPromptSubmit(transcriptPath, opts),
361
402
  ...extractInjectedFromErrorRecall(transcriptPath, opts),
362
403
  ...extractInjectedFromFyi(transcriptPath, opts),
404
+ // Marker-derived, not transcript-derived: no-op unless the caller passes
405
+ // runtimeDir + project (see extractInjectedFromKeyContext).
406
+ ...extractInjectedFromKeyContext(opts),
363
407
  ]);
364
408
  }
365
409
 
@@ -808,8 +852,8 @@ export function computeCitationFunnelTrend(db, { days = 7, limit = 10, project =
808
852
  if (!db) return empty;
809
853
  try {
810
854
  const now = Date.now();
811
- const windowStart = now - days * 86400000;
812
- const priorStart = now - 2 * days * 86400000;
855
+ const windowStart = now - days * DAY_MS;
856
+ const priorStart = now - 2 * days * DAY_MS;
813
857
  const projClause = project ? 'AND project = ?' : '';
814
858
 
815
859
  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,71 @@
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
+ let bumped = 0;
46
+ if (db && clean.length > 0) {
47
+ try {
48
+ const now = Date.now();
49
+ // Mirrors hook-memory.mjs's UPS bump verbatim so the two surfaces feed the
50
+ // same counter with the same semantics. Per-row try/catch for FTS trigger
51
+ // safety (project_non_obvious.md).
52
+ const stmt = db.prepare(
53
+ 'UPDATE observations SET injection_count = COALESCE(injection_count, 0) + 1, last_injected_at = ? WHERE id = ?'
54
+ );
55
+ for (const id of clean) {
56
+ try { stmt.run(now, id); bumped++; } catch { /* single-row failure must not drop the rest */ }
57
+ }
58
+ } catch (e) { debugCatch(e, 'keyctx-bump'); }
59
+ }
60
+
61
+ let written = false;
62
+ try {
63
+ writeFileSync(
64
+ join(runtimeDir, keyContextIdsFileName(project, sessionId)),
65
+ JSON.stringify({ ids: clean, ts: Date.now(), session: sessionId || null }),
66
+ );
67
+ written = true;
68
+ } catch (e) { debugCatch(e, 'keyctx-marker-write'); }
69
+
70
+ return { bumped, written };
71
+ }
@@ -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.0",
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.0",
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.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",
@@ -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 */ } }
package/scoring-sql.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
5
5
 
6
+ import { DAY_MS } from './lib/time-constants.mjs';
6
7
  // ─── Why these multipliers exist (read before "simplifying" them) ────────────
7
8
  //
8
9
  // The recency-decay, type-quality, project-boost, importance, cite, and noise
@@ -33,14 +34,14 @@ import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
33
34
 
34
35
  /** Recency half-life per observation type (in milliseconds) */
35
36
  export const DECAY_HALF_LIFE_BY_TYPE = {
36
- decision: 90 * 86400000, // 90 days — architectural decisions persist
37
- discovery: 60 * 86400000, // 60 days — learned patterns last
38
- feature: 30 * 86400000, // 30 days — feature work is mid-range
39
- bugfix: 14 * 86400000, // 14 days — bugs are usually one-off
40
- refactor: 14 * 86400000, // 14 days — code cleanup
41
- change: 7 * 86400000, // 7 days — routine changes decay fast
37
+ decision: 90 * DAY_MS, // 90 days — architectural decisions persist
38
+ discovery: 60 * DAY_MS, // 60 days — learned patterns last
39
+ feature: 30 * DAY_MS, // 30 days — feature work is mid-range
40
+ bugfix: 14 * DAY_MS, // 14 days — bugs are usually one-off
41
+ refactor: 14 * DAY_MS, // 14 days — code cleanup
42
+ change: 7 * DAY_MS, // 7 days — routine changes decay fast
42
43
  };
43
- export const DEFAULT_DECAY_HALF_LIFE_MS = 14 * 86400000;
44
+ export const DEFAULT_DECAY_HALF_LIFE_MS = 14 * DAY_MS;
44
45
 
45
46
  // ─── BM25 Weight Constants ──────────────────────────────────────────────────
46
47
  // Single source of truth for FTS5 BM25 weight expressions.
@@ -20,6 +20,7 @@ import { recordMetric } from '../lib/metrics.mjs';
20
20
  import { presentIdents } from '../lib/lesson-idents.mjs';
21
21
  import { neutralizeContextDelimiters } from '../format-utils.mjs';
22
22
 
23
+ import { DAY_MS } from '../lib/time-constants.mjs';
23
24
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
24
25
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
25
26
  // per-component overrides for tests that mix isolated + real paths.
@@ -372,7 +373,7 @@ try {
372
373
  const fileMatch = fileMatchClause('of2');
373
374
  const fileParams = fileMatchParams(filePath);
374
375
  // 60-day lookback to avoid surfacing ancient observations
375
- const cutoff = Date.now() - 60 * 86400000;
376
+ const cutoff = Date.now() - 60 * DAY_MS;
376
377
 
377
378
  // Surface actionable lessons first, then high-importance bugfix/decision observations.
378
379
  // Priority: 1) observations with lesson_learned (most actionable for preventing repeat bugs)
@@ -18,6 +18,7 @@ import { recommendSkill } from '../registry-recommend.mjs';
18
18
  import { recordHookError } from '../lib/hook-telemetry.mjs';
19
19
  import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
20
20
 
21
+ import { DAY_MS } from '../lib/time-constants.mjs';
21
22
  // ─── Constants ──────────────────────────────────────────────────────────────
22
23
 
23
24
  // Telemetry sink (lib/hook-telemetry.mjs contract): env override for tests, else
@@ -37,7 +38,7 @@ const injectedIdsFileFor = (sessionId) =>
37
38
  // gated by explicit "before/previously/记得" prompts where breadth is the
38
39
  // point). Env override for projects that want broader recall or to A/B.
39
40
  const MAX_RESULTS = Number(process.env.CLAUDE_MEM_UPS_MAX_RESULTS || 3);
40
- const LOOKBACK_MS = 60 * 86400000; // 60 days
41
+ const LOOKBACK_MS = 60 * DAY_MS; // 60 days
41
42
 
42
43
  // v2.56.x: Past-similar-questions fallback row cap. Cut from 3 → 1 after
43
44
  // 30d transcript scan (#8062 follow-up, 2026-05-09) showed UPS prompt-fallback
@@ -11,6 +11,7 @@ import { porterStem } from './tfidf.mjs';
11
11
  import { CLI_INVOKE } from './cli-path.mjs';
12
12
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
13
13
 
14
+ import { DAY_MS } from './lib/time-constants.mjs';
14
15
  // ─── MCP Server Instructions Builder ───────────────────────────────────────
15
16
  // Phase A (v2.31.3+): when quiet=true, drops WHEN-TO-USE proactive-trigger and
16
17
  // Decision-rules sections; keeps the irreducible CLI/MCP tool list. Intended
@@ -302,7 +303,7 @@ export function runIdleCleanup(db) {
302
303
 
303
304
  db.transaction(() => {
304
305
  for (const { types, days } of staleThresholds) {
305
- const cutoff = Date.now() - days * 86400000;
306
+ const cutoff = Date.now() - days * DAY_MS;
306
307
 
307
308
  const marked = db.prepare(`
308
309
  UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
package/server.mjs CHANGED
@@ -52,6 +52,7 @@ import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredToken
52
52
  import { saveObservation } from './lib/save-observation.mjs';
53
53
  import { applyObsUpdate } from './lib/observation-write.mjs';
54
54
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
55
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
55
56
  import { recallByFile } from './lib/recall-core.mjs';
56
57
  import { fetchRecent } from './lib/recent-core.mjs';
57
58
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
@@ -1032,7 +1033,7 @@ server.registerTool(
1032
1033
  if (args.project) args = { ...args, project: resolveProject(args.project) };
1033
1034
  const preview = args.preview !== false;
1034
1035
  const ageDays = args.age_days ?? 30;
1035
- const cutoff = Date.now() - ageDays * 86400000;
1036
+ const cutoff = Date.now() - ageDays * DAY_MS;
1036
1037
  const candidates = selectCompressionCandidates(db, { cutoff, project: args.project || null });
1037
1038
 
1038
1039
  if (candidates.length === 0) {
@@ -1181,7 +1182,7 @@ server.registerTool(
1181
1182
  // only rows a PRIOR run marked (backed up); rows decay marks below wait one cycle.
1182
1183
  if (ops.includes('purge_stale')) {
1183
1184
  const retainDays = args.retain_days ?? 30;
1184
- const retainCutoff = Date.now() - retainDays * 86400000;
1185
+ const retainCutoff = Date.now() - retainDays * DAY_MS;
1185
1186
  if (!purgeConfirmed) {
1186
1187
  // Dry-run preview (parity with CLI `maintain` without --confirm): the other
1187
1188
  // requested non-destructive ops still run below.
@@ -1687,8 +1688,12 @@ export async function handleExportForTest(db, args) {
1687
1688
  async function runExport(db, args) {
1688
1689
  const wheres = [];
1689
1690
  const params = [];
1690
- if (!args.include_compressed) wheres.push('COALESCE(compressed_into, 0) = 0');
1691
- wheres.push('superseded_at IS NULL');
1691
+ // Composed, not hand-written: the CLI twin (mem-cli.mjs cmdExport) already
1692
+ // routes through liveObsFilterSql, and a hand-rolled copy here is how the
1693
+ // live-row predicate drifted apart on other surfaces (P2-11/D#123). With
1694
+ // include_compressed the compressed half is dropped but retractions still
1695
+ // are not — tombstone export is opt-in, supersession is never exported.
1696
+ wheres.push(args.include_compressed ? 'superseded_at IS NULL' : liveObsFilterSql(''));
1692
1697
  if (args.project) { wheres.push('project = ?'); params.push(_resolveProjectShared(db, args.project)); }
1693
1698
  if (args.type) { wheres.push('type = ?'); params.push(args.type); }
1694
1699
  // T3-P1-A: surface invalid dates instead of silently dropping the filter — mirrors
@@ -1801,6 +1806,7 @@ server.registerTool(
1801
1806
  // Handler extracted to server/fts-check.mjs (v2.41 split).
1802
1807
  import { handleMemFtsCheck } from './server/fts-check.mjs';
1803
1808
 
1809
+ import { DAY_MS } from './lib/time-constants.mjs';
1804
1810
  server.registerTool(
1805
1811
  'mem_fts_check',
1806
1812
  {
package/source-files.mjs CHANGED
@@ -123,6 +123,12 @@ export const SOURCE_FILES = [
123
123
  // scripts/user-prompt-search.js + scripts/pre-tool-recall.js. Under lib/ for
124
124
  // the same scripts-dir-rename reason as mem-override.mjs above.
125
125
  'lib/injected-ids.mjs',
126
+ // P2-13 (narrowed): millisecond time units, single-sourced from the four
127
+ // modules that each declared their own DAY_MS. Leaf module, zero imports.
128
+ 'lib/time-constants.mjs',
129
+ // D#124: Key Context marker write + injection_count bump, shared by
130
+ // handleSessionStart and handlePreCompact. Statically imported by hook.mjs.
131
+ 'lib/keyctx-marker.mjs',
126
132
  // P2-11: injection-side shared SQL core (live-row filter / clamped decay /
127
133
  // injection relevance chain). Statically imported by hook.mjs, hook-memory.mjs,
128
134
  // search-engine.mjs AND the standalone hook scripts — missing it from the