claude-mem-lite 3.48.0 → 3.50.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
@@ -15,7 +15,6 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
15
15
  import { searchResources } from './registry-retriever.mjs';
16
16
  import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
17
17
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
18
- import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
19
18
  import {
20
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
20
  recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
@@ -25,13 +24,16 @@ import {
25
24
  } from './lib/maintain-core.mjs';
26
25
  import { snapshotDb } from './lib/db-backup.mjs';
27
26
  import { deleteObservations } from './lib/delete-core.mjs';
27
+ import { OBS_TYPE_SET } from './lib/obs-types.mjs';
28
+ import { computeStatsFeed } from './lib/stats-core.mjs';
29
+ import { buildLessonNudge } from './lib/save-nudge.mjs';
28
30
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
29
31
  import { buildSessionContextLines } from './hook-context.mjs';
30
32
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
31
33
  import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
32
34
  import { auditMemdir, memdirPath } from './memdir.mjs';
33
35
  import { aggregateProjectCiteRecall } from './lib/citation-tracker.mjs';
34
- import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
36
+ import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
35
37
  import { join, sep, dirname } from 'path';
36
38
  import { readFileSync, existsSync, readdirSync } from 'fs';
37
39
 
@@ -42,7 +44,6 @@ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatP
42
44
  import { saveObservation } from './lib/save-observation.mjs';
43
45
  import { rebuildObservationDerived, normalizeScope } from './lib/observation-write.mjs';
44
46
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
45
- import { computeNoiseGauge } from './lib/stats-quality.mjs';
46
47
  import { recallByFile } from './lib/recall-core.mjs';
47
48
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
48
49
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
@@ -53,6 +54,8 @@ import { aggregateMetrics } from './lib/metrics.mjs';
53
54
  import {
54
55
  insertDeferred, listOpenWithOrdinal, dropDeferred,
55
56
  resolveDeferredIds, closeDeferredItems,
57
+ getDeferredByIds, formatDeferredDetail,
58
+ searchDeferredWork, formatDeferredSearchTrailer,
56
59
  } from './lib/deferred-work.mjs';
57
60
 
58
61
  // ─── Commands ────────────────────────────────────────────────────────────────
@@ -72,7 +75,7 @@ async function cmdSearch(db, args, { llm } = {}) {
72
75
 
73
76
  const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
74
77
  const type = flags.type || null;
75
- const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
78
+ const validObsTypes = OBS_TYPE_SET;
76
79
  if (type && !validObsTypes.has(type)) {
77
80
  fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
78
81
  return;
@@ -141,6 +144,20 @@ async function cmdSearch(db, args, { llm } = {}) {
141
144
  return;
142
145
  }
143
146
 
147
+ // P2: deferred trailer — open deferred items matching the query, appended
148
+ // after (and never counted in) the main results. Unfiltered first-page text
149
+ // searches only; --json keeps its documented shape (deliberate asymmetry,
150
+ // locked by tests). Defined before the sanitize-empty early-return so a pure
151
+ // "D#92" query (which sanitizes to no FTS terms) still reaches the item.
152
+ const wantDeferredTrailer = !jsonOutput && !source && !type && !branch && !tier && !minImportance && offset === 0;
153
+ const emitDeferredTrailer = () => {
154
+ if (!wantDeferredTrailer) return;
155
+ try {
156
+ const rows = searchDeferredWork(db, query, project || inferProject());
157
+ for (const line of formatDeferredSearchTrailer(rows, 'claude-mem-lite get D#<id>')) out(line);
158
+ } catch { /* trailer is best-effort; never break search */ }
159
+ };
160
+
144
161
  const ftsQuery = buildSearchFtsQuery(query, { or: useOr });
145
162
  // --deep proceeds even when the literal query sanitizes to nothing — its LLM
146
163
  // rewrite may still produce searchable variants (F3, parity with server.mjs).
@@ -152,6 +169,7 @@ async function cmdSearch(db, args, { llm } = {}) {
152
169
  if (jsonOutput) {
153
170
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: false, results: [] }));
154
171
  } else {
172
+ emitDeferredTrailer();
155
173
  fail(`[mem] No valid search terms in "${query}"`);
156
174
  }
157
175
  return;
@@ -245,6 +263,9 @@ async function cmdSearch(db, args, { llm } = {}) {
245
263
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: isDeep, variants: isDeep ? deepVariants : undefined, results: [] }));
246
264
  } else {
247
265
  out(`[mem] No results for "${query}"`);
266
+ // The zero-result path is where the trailer earns its keep — the D#92
267
+ // failure chain was exactly "searched, found nothing, item was deferred".
268
+ emitDeferredTrailer();
248
269
  }
249
270
  return;
250
271
  }
@@ -333,6 +354,7 @@ async function cmdSearch(db, args, { llm } = {}) {
333
354
  }
334
355
  }
335
356
  }
357
+ emitDeferredTrailer();
336
358
  }
337
359
 
338
360
  function cmdRecent(db, args) {
@@ -369,7 +391,7 @@ function cmdRecent(db, args) {
369
391
  // try this for "show recent bugfixes". Mirror cmdSearch's enum validation.
370
392
  const type = flags.type || null;
371
393
  if (type) {
372
- const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
394
+ const validObsTypes = OBS_TYPE_SET;
373
395
  if (!validObsTypes.has(type)) {
374
396
  fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
375
397
  return;
@@ -572,7 +594,7 @@ function cmdGet(db, args) {
572
594
  const idStr = positional.join(',');
573
595
  if (!idStr) {
574
596
  fail('[mem] Usage: claude-mem-lite get <id1,id2,...> [--source obs|session|prompt|event] [--fields f1,f2,...]\n' +
575
- ' IDs accept prefix from search output: #123 (obs), P#123 (prompt), S#123 (session), E#123 (event).');
597
+ ' IDs accept prefix from search output: #123 (obs), P#123 (prompt), S#123 (session), E#123 (event), D#123 (deferred item, full detail).');
576
598
  return;
577
599
  }
578
600
 
@@ -586,12 +608,16 @@ function cmdGet(db, args) {
586
608
  return;
587
609
  }
588
610
 
611
+ // D#N deferred tokens are peeled off BEFORE bucketing/source-forcing — they
612
+ // always read deferred_work (get-only surface; delete/timeline keep rejecting).
613
+ const { deferredIds, rest } = splitDeferredTokens(tokens);
614
+
589
615
  // Shared bucketing with MCP mem_get — single source of truth for P#/S#/E#/# routing (#8050).
590
- const { bySrc, invalid: unparseable } = bucketIdTokens(tokens, { explicit, defaultSource: 'obs' });
616
+ const { bySrc, invalid: unparseable } = bucketIdTokens(rest, { explicit, defaultSource: 'obs' });
591
617
  if (unparseable.length > 0) {
592
618
  process.stderr.write(`[mem] Ignoring unparseable ID token(s): ${unparseable.join(', ')}\n`);
593
619
  }
594
- if (bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length === 0) {
620
+ if (bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length + deferredIds.length === 0) {
595
621
  fail('[mem] No valid IDs provided');
596
622
  return;
597
623
  }
@@ -614,6 +640,21 @@ function cmdGet(db, args) {
614
640
 
615
641
  const sections = [];
616
642
  let totalFound = 0;
643
+ // Deferred sections render first: explicit D# requests are rare and the
644
+ // FULL detail (never truncated) is the whole point of this surface.
645
+ let deferredMissing = [];
646
+ if (deferredIds.length > 0) {
647
+ const dRows = getDeferredByIds(db, deferredIds);
648
+ const found = new Set(dRows.map(r => r.id));
649
+ deferredMissing = deferredIds.filter(id => !found.has(id));
650
+ if (dRows.length > 0) {
651
+ sections.push(dRows.map(formatDeferredDetail).join('\n\n'));
652
+ totalFound += dRows.length;
653
+ }
654
+ if (deferredMissing.length > 0) {
655
+ process.stderr.write(`[mem] Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}\n`);
656
+ }
657
+ }
617
658
  if (bySrc.obs.length > 0) {
618
659
  const s = renderObsRows(db, bySrc.obs, requestedFields);
619
660
  if (s) { sections.push(s.text); totalFound += s.count; }
@@ -632,6 +673,12 @@ function cmdGet(db, args) {
632
673
  }
633
674
 
634
675
  if (totalFound === 0) {
676
+ // Deferred-only request that found nothing — the source-probe below is
677
+ // about obs/session/prompt/event and would print an empty source list.
678
+ if (deferredMissing.length > 0 && bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length === 0) {
679
+ fail(`[mem] Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}. List open items: claude-mem-lite defer list`);
680
+ return;
681
+ }
635
682
  // Probe the OTHER sources so the caller can retry with the right prefix.
636
683
  const queried = new Set(Object.entries(bySrc).filter(([, v]) => v.length > 0).map(([k]) => k));
637
684
  const allIds = [...bySrc.obs, ...bySrc.session, ...bySrc.prompt, ...bySrc.event];
@@ -803,7 +850,7 @@ function cmdSave(db, args) {
803
850
  if (rejectBareStringFlags(flags, ['title', 'files', 'lesson', 'lesson-learned', 'project', 'type'])) return;
804
851
 
805
852
  const type = flags.type || 'discovery';
806
- const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
853
+ const validTypes = OBS_TYPE_SET;
807
854
  if (!validTypes.has(type)) {
808
855
  fail(`[mem] Invalid type "${type}". Valid: ${[...validTypes].join(', ')}`);
809
856
  return;
@@ -912,7 +959,7 @@ function cmdSave(db, args) {
912
959
  const supersededNote = result.supersededIds && result.supersededIds.length > 0
913
960
  ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
914
961
  : '';
915
- out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}`);
962
+ out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}${buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'cli' })}`);
916
963
  }
917
964
 
918
965
  // ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
@@ -993,6 +1040,9 @@ function cmdDeferList(db, args) {
993
1040
  const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
994
1041
  out(` ${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
995
1042
  }
1043
+ // Affordance for the detail field — list stays title-only by design (it is
1044
+ // mirrored into the SessionStart dashboard, where detail would be noise).
1045
+ out(` Full detail: claude-mem-lite get D#<id>`);
996
1046
  }
997
1047
 
998
1048
  function cmdDeferDrop(db, args) {
@@ -1112,98 +1162,12 @@ async function cmdStats(db, args) {
1112
1162
  return;
1113
1163
  }
1114
1164
 
1115
- const projectFilter = project ? 'AND project = ?' : '';
1116
- const baseParams = project ? [project] : [];
1117
-
1118
1165
  const now = Date.now();
1119
- const cutoff = now - days * 86400000;
1120
-
1121
- // Total counts (aligned with MCP mem_stats: use session_summaries, not sdk_sessions)
1122
- const obsTotal = db.prepare(
1123
- `SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`
1124
- ).get(...baseParams);
1125
- const sessTotal = db.prepare(
1126
- `SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`
1127
- ).get(...baseParams);
1128
- const promptTotal = project
1129
- ? db.prepare('SELECT COUNT(*) as c FROM user_prompts p JOIN sdk_sessions s ON p.content_session_id = s.content_session_id WHERE s.project = ?').get(project)
1130
- : db.prepare('SELECT COUNT(*) as c FROM user_prompts').get();
1131
-
1132
- // Recent counts
1133
- const obsRecent = db.prepare(
1134
- `SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`
1135
- ).get(cutoff, ...baseParams);
1136
- const sessRecent = db.prepare(
1137
- `SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`
1138
- ).get(cutoff, ...baseParams);
1139
-
1140
- // Type distribution (recent)
1141
- const types = db.prepare(`
1142
- SELECT type, COUNT(*) as c FROM observations
1143
- WHERE created_at_epoch >= ? ${projectFilter}
1144
- GROUP BY type ORDER BY c DESC
1145
- `).all(cutoff, ...baseParams);
1146
-
1147
- // Top projects (global view — skipped when filtering by single project; aligned with MCP)
1148
- const projects = project ? [] : db.prepare(`
1149
- SELECT project, COUNT(*) as c FROM observations
1150
- GROUP BY project ORDER BY c DESC LIMIT 20
1151
- `).all();
1152
-
1153
- // Daily activity (last 7 days; aligned with MCP mem_stats)
1154
- const daily = db.prepare(`
1155
- SELECT date(created_at) as day, COUNT(*) as c FROM observations
1156
- WHERE created_at_epoch >= ? ${projectFilter}
1157
- GROUP BY day ORDER BY day DESC LIMIT 7
1158
- `).all(now - 7 * 86400000, ...baseParams);
1159
-
1160
- // Data health (aligned with MCP mem_stats)
1161
- const tokenEst = db.prepare(`
1162
- SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
1163
- FROM observations WHERE 1=1 ${projectFilter}
1164
- `).get(...baseParams);
1165
- const avgImp = db.prepare(
1166
- `SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`
1167
- ).get(...baseParams);
1168
- const thirtyDaysAgo = now - 30 * 86400000;
1169
- // v3.23 noise-gauge de-blinding: the prior `importance = 1` predicate was
1170
- // structurally blind to imp=0 — decay's floor + the LLM low-signal filter push
1171
- // dormant rows to 0, which on a real store is ~half the live corpus. The gauge
1172
- // therefore reported "0.0% noise" while the store was dormant-heavy. `<= 1` makes
1173
- // imp=0 visible; injection_count=0 mirrors decay's NEVER-INJECTED guard so an
1174
- // injected-but-decayed row (pinned noise, tracked separately) is not miscounted
1175
- // as "never used".
1176
- const lowVal = db.prepare(`
1177
- SELECT COUNT(*) as c FROM observations
1178
- WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
1179
- AND COALESCE(injection_count,0) = 0
1180
- AND COALESCE(compressed_into, 0) = 0
1181
- AND created_at_epoch < ? ${projectFilter}
1182
- `).get(thirtyDaysAgo, ...baseParams);
1183
- // Low-signal-title population: template / tool-log titles (Modified, Worked on,
1184
- // Error while working, Error:, node/npm/npx …) that the retrieval layer already
1185
- // filters out by default. The imp=1 "Low-value" metric above structurally can't
1186
- // see these — they often carry inflated importance and recent access — so the
1187
- // health gauge under-reports real noise without this line. Same LOW_SIGNAL
1188
- // pattern source as the read-side filter (lib/low-signal-patterns.mjs).
1189
- const lowSignalTitle = db.prepare(`
1190
- SELECT COUNT(*) as c FROM observations
1191
- WHERE NOT ${buildNotLowSignalSql()}
1192
- AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
1193
- `).get(...baseParams);
1194
- // F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
1195
- // obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
1196
- // Shared with the MCP mem_stats gauge via computeNoiseGauge (lib/stats-quality.mjs).
1197
- const liveTotal = db.prepare(
1198
- `SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
1199
- ).get(...baseParams);
1200
- const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
1201
- const compressedCount = db.prepare(
1202
- `SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
1203
- ).get(...baseParams);
1204
- const supersededOnlyCount = db.prepare(
1205
- `SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
1206
- ).get(...baseParams);
1166
+ const {
1167
+ obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
1168
+ types, projects, daily, tokenEst, avgImp, lowVal, lowSignalTitle,
1169
+ noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount, tierMap,
1170
+ } = computeStatsFeed(db, { project, days, now });
1207
1171
 
1208
1172
  // Hook self-observation: count PreToolUse / Skill-bridge script failures
1209
1173
  // recorded in the last 24h. Surfaces silent breakage (DB corruption,
@@ -1211,17 +1175,6 @@ async function cmdStats(db, args) {
1211
1175
  // failure mode that left code-graph's matcher bug undetected for 10 sessions.
1212
1176
  const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
1213
1177
 
1214
- // Tier distribution (aligned with MCP mem_stats)
1215
- const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
1216
- const tdParams = tierSqlParams(tierCtx);
1217
- const tierDist = db.prepare(`
1218
- SELECT tier, COUNT(*) as c FROM (
1219
- SELECT ${TIER_CASE_SQL} as tier FROM observations
1220
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
1221
- ) GROUP BY tier ORDER BY tier
1222
- `).all(...tdParams, ...baseParams);
1223
- const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
1224
-
1225
1178
  if (jsonOutput) {
1226
1179
  out(JSON.stringify({
1227
1180
  project,
@@ -1570,7 +1523,7 @@ function cmdUpdate(db, args) {
1570
1523
  updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative));
1571
1524
  }
1572
1525
  if (flags.type) {
1573
- const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
1526
+ const validTypes = OBS_TYPE_SET;
1574
1527
  if (!validTypes.has(flags.type)) {
1575
1528
  fail(`[mem] Invalid type "${flags.type}". Valid: ${[...validTypes].join(', ')}`);
1576
1529
  return;
@@ -1654,7 +1607,7 @@ function cmdExport(db, args) {
1654
1607
  if (flags.type) {
1655
1608
  // Reject unknown types — silently returning [] for `--type bogus` looked like a
1656
1609
  // legitimate empty filter result, hiding the typo. Mirrors cmdSearch / cmdSave / cmdUpdate.
1657
- const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
1610
+ const validObsTypes = OBS_TYPE_SET;
1658
1611
  if (!validObsTypes.has(flags.type)) {
1659
1612
  fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...validObsTypes].join(', ')}`);
1660
1613
  return;
@@ -2646,9 +2599,11 @@ Commands:
2646
2599
  --json Output as JSON: {file,limit,include_noise,total,results:[…]}
2647
2600
 
2648
2601
  get <id1,id2,...> Get full details by ID
2649
- IDs accept search-output prefixes: #123 (obs), P#123 (prompt), S#123 (session).
2602
+ IDs accept search-output prefixes: #123 (obs), P#123 (prompt), S#123 (session),
2603
+ D#123 (deferred item — FULL detail; defer list is title-only).
2650
2604
  Bare N defaults to obs. Mixed prefixes in one call route each token correctly.
2651
- --source S Force record type (obs|session|prompt); overrides prefixes.
2605
+ --source S Force record type (obs|session|prompt); overrides prefixes
2606
+ (D# tokens exempt — they always read deferred_work).
2652
2607
  --fields f1,f2,... Select specific fields to return (observations only).
2653
2608
 
2654
2609
  timeline Show observations around an anchor (shows recent if no anchor)
@@ -2680,7 +2635,7 @@ Commands:
2680
2635
  --detail T Constraint + why deferred
2681
2636
  --files f1,f2 Comma-separated file paths
2682
2637
  --project P Project name
2683
- list List open deferred items
2638
+ list List open deferred items (title-only; full detail via get D#N)
2684
2639
  --limit N Max results (default 10)
2685
2640
  --project P Filter by project
2686
2641
  drop <D#N|ordinal>[,...] Drop one or more deferred items (no fix needed)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.48.0",
3
+ "version": "3.50.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",
@@ -92,6 +92,9 @@
92
92
  "lib/db-backup.mjs",
93
93
  "lib/delete-core.mjs",
94
94
  "lib/events-injection.mjs",
95
+ "lib/stats-core.mjs",
96
+ "lib/obs-types.mjs",
97
+ "lib/save-nudge.mjs",
95
98
  "lib/maintain-core.mjs",
96
99
  "lib/dedup-constants.mjs",
97
100
  "lib/deferred-work.mjs",
package/registry.mjs CHANGED
@@ -376,7 +376,7 @@ const UPSERT_SQL = `
376
376
  repo_url=CASE WHEN excluded.repo_url != '' THEN excluded.repo_url ELSE repo_url END,
377
377
  repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
378
378
  local_path=CASE WHEN excluded.local_path != '' THEN excluded.local_path ELSE local_path END,
379
- file_hash=excluded.file_hash,
379
+ file_hash=CASE WHEN excluded.file_hash IS NOT NULL AND excluded.file_hash != '' THEN excluded.file_hash ELSE file_hash END,
380
380
  invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
381
381
  -- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
382
382
  -- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
package/schema.mjs CHANGED
@@ -176,7 +176,7 @@ const CORE_SCHEMA = `
176
176
  memory_session_id TEXT NOT NULL,
177
177
  project TEXT NOT NULL,
178
178
  text TEXT,
179
- type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
179
+ type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')), -- keep in sync with lib/obs-types.mjs OBS_TYPES (locked by tests/obs-types-invariant.test.mjs)
180
180
  title TEXT,
181
181
  subtitle TEXT,
182
182
  facts TEXT,
@@ -218,3 +218,30 @@ export function extractFiles(text) {
218
218
  !/^\d+\.\d+$/.test(m) // Exclude pure version numbers like "3.14" (not paths like "1.0/config.json")
219
219
  );
220
220
  }
221
+
222
+ // ─── Deferred-work references (D#N) ──────────────────────────────────────────
223
+
224
+ // Cap injected deferred items per prompt — a batch approval ("D#1 D#2 D#3 全部
225
+ // 批准") gets the first three; more would blow the injection noise budget.
226
+ export const MAX_DEFERRED_REFS = 3;
227
+
228
+ /**
229
+ * Extract deferred_work ids the prompt explicitly references as D#N (case-
230
+ * insensitive). Requires the `#` — bare "D92" is prose (chip names, model
231
+ * numbers), not a token. Deduped, input order, capped at MAX_DEFERRED_REFS.
232
+ * @param {string} text
233
+ * @returns {number[]}
234
+ */
235
+ export function extractDeferredRefs(text) {
236
+ const out = [];
237
+ const re = /\bD#(\d+)\b/gi;
238
+ let m;
239
+ while ((m = re.exec(String(text || ''))) !== null) {
240
+ const id = parseInt(m[1], 10);
241
+ if (id > 0 && !out.includes(id)) {
242
+ out.push(id);
243
+ if (out.length >= MAX_DEFERRED_REFS) break;
244
+ }
245
+ }
246
+ return out;
247
+ }
@@ -11,7 +11,8 @@ import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
11
11
  import { join, sep } from 'path';
12
12
  import { pathToFileURL } from 'url';
13
13
  import Database from 'better-sqlite3';
14
- import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
14
+ import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, extractDeferredRefs, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
15
+ import { getDeferredByIds } from '../lib/deferred-work.mjs';
15
16
  import { recommendSkill } from '../registry-recommend.mjs';
16
17
 
17
18
  // ─── Constants ──────────────────────────────────────────────────────────────
@@ -580,15 +581,68 @@ async function main() {
580
581
  // into the FTS MATCH query terms. Mirrors hook.mjs handleUserPrompt.
581
582
  const promptText = stripPrivate(rawPrompt);
582
583
 
583
- // Skip short/confirmation/slash-command/simple-op prompts
584
- if (shouldSkip(promptText)) return;
585
-
586
584
  // P0: User-explicit "ignore memory" override (mirrors CC built-in
587
- // memoryTypes.ts:215). When the prompt directly tells Claude to skip
588
- // memory recall, we short-circuit before FTS no FTS budget burn,
589
- // no .claude-mem-injected-* state churn, no surface emission.
585
+ // memoryTypes.ts:215). Moved ABOVE the deterministic D# path so it
586
+ // short-circuits ALL injection surfaces (previously ran after shouldSkip —
587
+ // same outcome there, both return without output).
590
588
  if (detectMemOverride(promptText)) return;
591
589
 
590
+ // ─── Deterministic D#N deferred-detail injection (v3.50) ──────────────────
591
+ // A prompt naming D#N ("D#92 批准,进 writing-plans") is the highest-precision
592
+ // trigger this hook has: the user is resuming a deferred item whose FULL
593
+ // detail no list surface renders (defer list / dashboard are title-only —
594
+ // the 2026-07-18 D#92 post-/clear failure chain). Runs BEFORE shouldSkip /
595
+ // length gates: short approval prompts are the common case here, and the
596
+ // trigger is exact-reference, not relevance-scored.
597
+ let db = null;
598
+ try {
599
+ const deferredRefs = extractDeferredRefs(promptText);
600
+ if (deferredRefs.length > 0) {
601
+ db = ensureDb();
602
+ const project = inferProject();
603
+ const openRows = getDeferredByIds(db, deferredRefs)
604
+ .filter(r => r.status === 'open' && r.project === project);
605
+ // Namespace dedup ids as "D<id>" (parity with the "P<id>" prompt-corpus
606
+ // convention) so obs ids can't collide in the shared injected-ids file.
607
+ const dedupIds = openRows.map(r => `D${r.id}`);
608
+ if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, INJECTED_IDS_FILE)) {
609
+ const lines = ['[mem] Deferred work referenced in prompt (open items, full detail):'];
610
+ for (const r of openRows) {
611
+ const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
612
+ lines.push(`D#${r.id} ${pTag} [P${r.priority}] ${neutralizeContextDelimiters(r.title || '')}`);
613
+ if (r.detail) {
614
+ // Full detail, defanged, never truncated — the point of this surface.
615
+ for (const dl of neutralizeContextDelimiters(r.detail).split('\n')) lines.push(` ${dl}`);
616
+ }
617
+ }
618
+ process.stdout.write(lines.join('\n') + '\n');
619
+ // Merge into the dedup file so a re-referencing prompt within the stale
620
+ // window skips re-injection. A later FTS-path write replaces ids wholesale
621
+ // (accepted: worst case is one cheap re-injection after an obs-emitting
622
+ // prompt inside the same 5-min window).
623
+ try {
624
+ let prevIds = [];
625
+ let prevCount = 0;
626
+ try {
627
+ const prev = JSON.parse(readFileSync(INJECTED_IDS_FILE, 'utf8'));
628
+ if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS) {
629
+ prevIds = Array.isArray(prev.ids) ? prev.ids : [];
630
+ prevCount = prev.count || 0;
631
+ }
632
+ } catch {}
633
+ writeFileSync(INJECTED_IDS_FILE, JSON.stringify({
634
+ ids: [...new Set([...prevIds.map(String), ...dedupIds])],
635
+ ts: Date.now(),
636
+ count: prevCount + 1,
637
+ }));
638
+ } catch {}
639
+ }
640
+ }
641
+ } catch { /* deterministic path must never block the main flow */ }
642
+
643
+ // Skip short/confirmation/slash-command/simple-op prompts
644
+ if (shouldSkip(promptText)) { try { db?.close(); } catch {} return; }
645
+
592
646
  // T3 (v2.31): additional raw-length gate on top of shouldSkip's CJK-weighted
593
647
  // effective-length check. Suppresses medium-short Latin prompts ("run tests",
594
648
  // "fix bug now") that carry too few content tokens for a meaningful FTS lookup.
@@ -596,13 +650,15 @@ async function main() {
596
650
  // short continuations ("前面那个?", "does it work?") depend on prior context.
597
651
  const followUp = isFollowUpSession();
598
652
  const promptMinLen = followUp ? FOLLOWUP_PROMPT_MIN_LENGTH : PROMPT_MIN_LENGTH;
599
- if (computeEffectiveLen(promptText.trim()) < promptMinLen) return;
653
+ if (computeEffectiveLen(promptText.trim()) < promptMinLen) { try { db?.close(); } catch {} return; }
600
654
  const bm25Floor = followUp ? FOLLOWUP_BM25_MIN_SCORE : BM25_MIN_SCORE;
601
655
 
602
- let db;
603
- try {
604
- db = ensureDb();
605
- } catch { return; }
656
+ // db may already be open from the deterministic D# path above.
657
+ if (!db) {
658
+ try {
659
+ db = ensureDb();
660
+ } catch { return; }
661
+ }
606
662
 
607
663
  try {
608
664
  const project = inferProject();
@@ -787,8 +843,11 @@ async function main() {
787
843
  if (matched) {
788
844
  const cooldown = getSkillCooldown();
789
845
  if (!cooldown[matched]) {
846
+ // Registry skill names come from third-party repos (tools/adopt import) — an
847
+ // untrusted boundary like every other DB-derived string on this surface.
848
+ const safeName = neutralizeContextDelimiters(matched);
790
849
  process.stdout.write(
791
- `\n[mem] Skill "${matched}" may apply — invoke via SkillTool or run: claude-mem-lite registry show ${matched}\n`
850
+ `\n[mem] Skill "${safeName}" may apply — invoke via SkillTool or run: claude-mem-lite registry show ${safeName}\n`
792
851
  );
793
852
  setSkillCooldown(matched);
794
853
  }