claude-mem-lite 3.49.0 → 3.51.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.49.0",
13
+ "version": "3.51.0",
14
14
  "source": "./",
15
15
  "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)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.49.0",
3
+ "version": "3.51.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
  "author": {
6
6
  "name": "sdsrss"
package/hook-optimize.mjs CHANGED
@@ -940,7 +940,11 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
940
940
  // via auto-maintain) passes 'wide' explicitly — so aliases never had a cadence and
941
941
  // live coverage crawled at ~15%. The split is ADAPTIVE: aliases takes at most half
942
942
  // the budget and only what its candidate pool actually holds, so a zero-candidate
943
- // aliases pass costs nothing and the main scope keeps its full budget.
943
+ // aliases pass costs nothing and the main scope keeps its full budget. Boundary:
944
+ // at budget.reenrich === 1, `half` floors to 1 (the whole budget), so with ≥1
945
+ // alias candidate the main scope gets 0 that cycle — pre-existing v3.43 semantics
946
+ // (reachable only via manual `optimize --max ≤4`; the daily path runs reenrich=6),
947
+ // and the starved scope self-corrects next cycle.
944
948
  // An explicit --scope aliases still runs exactly that one scope (below).
945
949
  const half = Math.max(1, Math.floor(budget.reenrich / 2));
946
950
  const aliasBudget = Math.min(half, findReenrichCandidates(db, half, { scope: 'aliases', project }).length);
package/hook.mjs CHANGED
@@ -344,8 +344,13 @@ async function handlePostToolUse() {
344
344
  let db = null;
345
345
  const getDb = () => { if (!db) db = openDb(); return db; };
346
346
 
347
- // Tier 2 G: Error-triggered recall
348
- if (bashSig?.isError) {
347
+ // Tier 2 G: Error-triggered recall. Gated on isHardError (genuine failure
348
+ // fingerprint), NOT isError — the loose gate fired "Related memories found for
349
+ // this error" on exit-0 commands whose output merely contained the word "error"
350
+ // (G8, roadmap 2026-07-18), and was self-recursive: the hint string itself
351
+ // contains 'error', so a later command echoing it re-triggered recall.
352
+ // entry.isError above keeps the loose semantics on purpose (episode narrative).
353
+ if (bashSig?.isHardError) {
349
354
  const d = getDb();
350
355
  if (d) triggerErrorRecall(d, toolInput, resp);
351
356
  }
@@ -58,6 +58,49 @@ export function listOpenWithOrdinal(db, project, limit = 10) {
58
58
  `).all(project, limit);
59
59
  }
60
60
 
61
+ // ─── G11: list age + stale refresh hint (roadmap 2026-07-18) ─────────────────
62
+
63
+ const DAY_MS = 86_400_000;
64
+ export const DEFER_STALE_DAYS = 30;
65
+
66
+ /**
67
+ * Render one `defer list` row (shared by CLI cmdDeferList and MCP
68
+ * mem_defer_list — no hand-synced twins). Age rides inside the id tag:
69
+ * `1. 🟡 [P2] title (D#5, 12d)`.
70
+ * @param {{ordinal, priority, title, id, created_at_epoch}} r
71
+ * @param {number} [now]
72
+ */
73
+ export function formatDeferListRow(r, now = Date.now()) {
74
+ const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
75
+ const days = Math.max(0, Math.floor((now - r.created_at_epoch) / DAY_MS));
76
+ return `${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id}, ${days}d)`;
77
+ }
78
+
79
+ /**
80
+ * Count ALL open rows older than DEFER_STALE_DAYS in the project — deliberately
81
+ * not derived from the displayed list: (priority DESC, created_at ASC) ordering
82
+ * sinks old P1 rows past the LIMIT, and those are exactly the ones the hint
83
+ * exists for.
84
+ */
85
+ export function countStaleOpen(db, project, now = Date.now()) {
86
+ return db.prepare(`
87
+ SELECT COUNT(*) AS n FROM deferred_work
88
+ WHERE project = ? AND status = 'open' AND created_at_epoch < ?
89
+ `).get(project, now - DEFER_STALE_DAYS * DAY_MS).n;
90
+ }
91
+
92
+ /**
93
+ * Tail hint for stale open items. Null when none — callers print nothing.
94
+ * Nudge-only by design: no auto-demote / auto-drop (defer is a commitment
95
+ * surface; silent cleanup would break it).
96
+ * @param {number} staleCount
97
+ * @returns {string|null}
98
+ */
99
+ export function formatDeferStaleHint(staleCount) {
100
+ if (!staleCount || staleCount <= 0) return null;
101
+ return `⚠ ${staleCount} item(s) open >${DEFER_STALE_DAYS} days — refresh (still relevant?) or drop with a reason.`;
102
+ }
103
+
61
104
  /**
62
105
  * Set status='dropped' with a non-empty reason. No-op when status is not 'open'.
63
106
  * @returns {{changed: number}} 1 if updated, 0 if not found or not open.
@@ -74,6 +117,143 @@ export function dropDeferred(db, id, reason) {
74
117
  return { changed: r.changes };
75
118
  }
76
119
 
120
+ /**
121
+ * Fetch full deferred_work rows by raw id — ANY status, input order preserved,
122
+ * missing ids omitted. This is the read half of the D# surface: `defer list`
123
+ * stays title-only by design (dashboard noise budget), so `get D#N` is where
124
+ * the detail field becomes readable at all.
125
+ * @param {Database} db
126
+ * @param {number[]} ids Raw deferred_work ids
127
+ * @returns {Array<object>} Full rows
128
+ */
129
+ export function getDeferredByIds(db, ids) {
130
+ if (!Array.isArray(ids) || ids.length === 0) return [];
131
+ const stmt = db.prepare(`SELECT * FROM deferred_work WHERE id = ?`);
132
+ const rows = [];
133
+ for (const id of ids) {
134
+ if (!Number.isInteger(id) || id <= 0) continue;
135
+ const r = stmt.get(id);
136
+ if (r) rows.push(r);
137
+ }
138
+ return rows;
139
+ }
140
+
141
+ /**
142
+ * Render one deferred_work row with FULL detail (never truncated) — shared by
143
+ * CLI cmdGet and MCP mem_get so the two surfaces cannot drift.
144
+ * @param {object} row Full deferred_work row (from getDeferredByIds)
145
+ * @returns {string}
146
+ */
147
+ export function formatDeferredDetail(row) {
148
+ const pTag = row.priority === 3 ? '🔴' : row.priority === 1 ? '⚪' : '🟡';
149
+ const lines = [`── D#${row.id} ── deferred (${row.status}) ${pTag} [P${row.priority}]`];
150
+ lines.push(`project: ${row.project}`);
151
+ lines.push(`title: ${row.title}`);
152
+ if (row.detail) lines.push(`detail: ${row.detail}`);
153
+ if (row.files) {
154
+ try {
155
+ const f = JSON.parse(row.files);
156
+ if (Array.isArray(f) && f.length > 0) lines.push(`files: ${f.join(', ')}`);
157
+ } catch { /* legacy non-JSON files value — skip rather than render garbage */ }
158
+ }
159
+ if (row.created_at_epoch) lines.push(`created: ${new Date(row.created_at_epoch).toISOString()}`);
160
+ if (row.status === 'dropped' && row.drop_reason) lines.push(`drop_reason: ${row.drop_reason}`);
161
+ if (row.status === 'done' && row.closed_by_obs_id) lines.push(`closed_by: #${row.closed_by_obs_id}`);
162
+ return lines.join('\n');
163
+ }
164
+
165
+ /**
166
+ * P2 search leg: make deferred items reachable from mem_search / CLI search.
167
+ *
168
+ * Two channels, merged direct-first and deduped:
169
+ * 1. Explicit "D#N" refs in the query → direct id lookup, ANY status
170
+ * (an explicit reference deserves the row even if closed), project-scoped.
171
+ * 2. Keyword match over OPEN items' title+detail — JS substring matching
172
+ * (never SQL LIKE, so %/_ are literals and wildcard injection is
173
+ * structurally impossible; corpus is ≤50 open rows per project).
174
+ * Multi-token queries need ceil(n/2) token hits so one generic token
175
+ * ("test", "lesson") can't drag the trailer into every search.
176
+ *
177
+ * Date bounds are deliberately NOT applied — open items are few, current by
178
+ * definition, and the trailer is labeled as deferred work, not search results.
179
+ *
180
+ * @param {Database} db
181
+ * @param {string} query Raw user query
182
+ * @param {string} project Project scope (required — trailer is project-local)
183
+ * @param {{limit?: number}} [opts]
184
+ * @returns {Array<object>} Full rows, direct refs first, capped at limit
185
+ */
186
+ export function searchDeferredWork(db, query, project, { limit = 3 } = {}) {
187
+ if (!query || typeof query !== 'string' || !project) return [];
188
+
189
+ const refIds = [];
190
+ const refRe = /\bD#(\d+)\b/gi;
191
+ let m;
192
+ while ((m = refRe.exec(query)) !== null) {
193
+ const id = parseInt(m[1], 10);
194
+ if (id > 0 && !refIds.includes(id)) refIds.push(id);
195
+ }
196
+ const direct = refIds.length > 0
197
+ ? getDeferredByIds(db, refIds).filter(r => r.project === project)
198
+ : [];
199
+
200
+ const tokens = query
201
+ .replace(/\bD#\d+\b/gi, ' ')
202
+ .toLowerCase()
203
+ .split(/\s+/)
204
+ .filter(t => t.length >= 2)
205
+ .slice(0, 8);
206
+
207
+ let keyword = [];
208
+ if (tokens.length > 0) {
209
+ const open = db.prepare(`
210
+ SELECT * FROM deferred_work
211
+ WHERE project = ? AND status = 'open'
212
+ ORDER BY priority DESC, created_at_epoch ASC
213
+ LIMIT 50
214
+ `).all(project);
215
+ const need = Math.max(1, Math.ceil(tokens.length / 2));
216
+ keyword = open
217
+ .map(r => {
218
+ const hay = `${r.title || ''} ${r.detail || ''}`.toLowerCase();
219
+ return { r, matched: tokens.filter(t => hay.includes(t)).length };
220
+ })
221
+ .filter(x => x.matched >= need)
222
+ .sort((a, b) => b.matched - a.matched)
223
+ .map(x => x.r);
224
+ }
225
+
226
+ const seen = new Set();
227
+ const out = [];
228
+ for (const r of [...direct, ...keyword]) {
229
+ if (seen.has(r.id)) continue;
230
+ seen.add(r.id);
231
+ out.push(r);
232
+ if (out.length >= limit) break;
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /**
238
+ * Render the deferred search trailer — appended AFTER (and never counted in)
239
+ * the main result set. Title-only lines; `invokeHint` names the surface's
240
+ * full-detail reader (CLI `get D#N` / MCP mem_get).
241
+ * @param {Array<object>} rows From searchDeferredWork
242
+ * @param {string} invokeHint e.g. 'claude-mem-lite get D#<id>'
243
+ * @returns {string[]} Lines (empty array when rows is empty)
244
+ */
245
+ export function formatDeferredSearchTrailer(rows, invokeHint) {
246
+ if (!Array.isArray(rows) || rows.length === 0) return [];
247
+ const lines = [`[mem] Deferred work matches — full detail: ${invokeHint}`];
248
+ for (const r of rows) {
249
+ const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
250
+ const statusTag = r.status === 'open' ? '' : ` [${r.status}]`;
251
+ const title = (r.title || '(untitled)').length > 80 ? `${r.title.slice(0, 80)}…` : (r.title || '(untitled)');
252
+ lines.push(` D#${r.id} ${pTag} [P${r.priority}]${statusTag} ${title}`);
253
+ }
254
+ return lines;
255
+ }
256
+
77
257
  /**
78
258
  * Resolve mixed ordinal (int) + raw-id ("D#<n>") tokens to real deferred_work
79
259
  * ids, validated against caller project + status='open'.
@@ -55,6 +55,32 @@ export function bucketIdTokens(tokens, { explicit = null, defaultSource = 'obs'
55
55
  return { bySrc, invalid };
56
56
  }
57
57
 
58
+ /**
59
+ * Peel D#N deferred-work tokens off a mixed get-token list BEFORE bucketing.
60
+ * D# is a get-only read surface: deferred rows live outside the observation
61
+ * timeline, so they never enter bucketIdTokens' obs/session/prompt/event
62
+ * buckets, are exempt from `source` forcing, and stay rejected by the
63
+ * delete/timeline schemas. Requires the `#` (bare "D92" is prose, not a token).
64
+ *
65
+ * @param {Array<string|number>} tokens Mixed input
66
+ * @returns {{deferredIds: number[], rest: Array<string|number>}} deferredIds
67
+ * deduped in input order; rest preserved for bucketIdTokens.
68
+ */
69
+ export function splitDeferredTokens(tokens) {
70
+ const deferredIds = [];
71
+ const rest = [];
72
+ for (const raw of tokens) {
73
+ const m = typeof raw === 'string' ? /^[Dd]#(\d+)$/.exec(raw.trim()) : null;
74
+ if (m) {
75
+ const id = parseInt(m[1], 10);
76
+ if (id > 0 && !deferredIds.includes(id)) deferredIds.push(id);
77
+ } else {
78
+ rest.push(raw);
79
+ }
80
+ }
81
+ return { deferredIds, rest };
82
+ }
83
+
58
84
  /**
59
85
  * Probe the observations / session_summaries / user_prompts tables for any
60
86
  * of the given numeric IDs, excluding the sources the caller already queried.
package/mem-cli.mjs CHANGED
@@ -33,7 +33,7 @@ import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
33
33
  import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
34
34
  import { auditMemdir, memdirPath } from './memdir.mjs';
35
35
  import { aggregateProjectCiteRecall } from './lib/citation-tracker.mjs';
36
- import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
36
+ import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
37
37
  import { join, sep, dirname } from 'path';
38
38
  import { readFileSync, existsSync, readdirSync } from 'fs';
39
39
 
@@ -54,6 +54,9 @@ import { aggregateMetrics } from './lib/metrics.mjs';
54
54
  import {
55
55
  insertDeferred, listOpenWithOrdinal, dropDeferred,
56
56
  resolveDeferredIds, closeDeferredItems,
57
+ getDeferredByIds, formatDeferredDetail,
58
+ searchDeferredWork, formatDeferredSearchTrailer,
59
+ formatDeferListRow, countStaleOpen, formatDeferStaleHint,
57
60
  } from './lib/deferred-work.mjs';
58
61
 
59
62
  // ─── Commands ────────────────────────────────────────────────────────────────
@@ -142,6 +145,20 @@ async function cmdSearch(db, args, { llm } = {}) {
142
145
  return;
143
146
  }
144
147
 
148
+ // P2: deferred trailer — open deferred items matching the query, appended
149
+ // after (and never counted in) the main results. Unfiltered first-page text
150
+ // searches only; --json keeps its documented shape (deliberate asymmetry,
151
+ // locked by tests). Defined before the sanitize-empty early-return so a pure
152
+ // "D#92" query (which sanitizes to no FTS terms) still reaches the item.
153
+ const wantDeferredTrailer = !jsonOutput && !source && !type && !branch && !tier && !minImportance && offset === 0;
154
+ const emitDeferredTrailer = () => {
155
+ if (!wantDeferredTrailer) return;
156
+ try {
157
+ const rows = searchDeferredWork(db, query, project || inferProject());
158
+ for (const line of formatDeferredSearchTrailer(rows, 'claude-mem-lite get D#<id>')) out(line);
159
+ } catch { /* trailer is best-effort; never break search */ }
160
+ };
161
+
145
162
  const ftsQuery = buildSearchFtsQuery(query, { or: useOr });
146
163
  // --deep proceeds even when the literal query sanitizes to nothing — its LLM
147
164
  // rewrite may still produce searchable variants (F3, parity with server.mjs).
@@ -153,6 +170,7 @@ async function cmdSearch(db, args, { llm } = {}) {
153
170
  if (jsonOutput) {
154
171
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: false, results: [] }));
155
172
  } else {
173
+ emitDeferredTrailer();
156
174
  fail(`[mem] No valid search terms in "${query}"`);
157
175
  }
158
176
  return;
@@ -246,6 +264,9 @@ async function cmdSearch(db, args, { llm } = {}) {
246
264
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: isDeep, variants: isDeep ? deepVariants : undefined, results: [] }));
247
265
  } else {
248
266
  out(`[mem] No results for "${query}"`);
267
+ // The zero-result path is where the trailer earns its keep — the D#92
268
+ // failure chain was exactly "searched, found nothing, item was deferred".
269
+ emitDeferredTrailer();
249
270
  }
250
271
  return;
251
272
  }
@@ -334,6 +355,7 @@ async function cmdSearch(db, args, { llm } = {}) {
334
355
  }
335
356
  }
336
357
  }
358
+ emitDeferredTrailer();
337
359
  }
338
360
 
339
361
  function cmdRecent(db, args) {
@@ -573,7 +595,7 @@ function cmdGet(db, args) {
573
595
  const idStr = positional.join(',');
574
596
  if (!idStr) {
575
597
  fail('[mem] Usage: claude-mem-lite get <id1,id2,...> [--source obs|session|prompt|event] [--fields f1,f2,...]\n' +
576
- ' IDs accept prefix from search output: #123 (obs), P#123 (prompt), S#123 (session), E#123 (event).');
598
+ ' IDs accept prefix from search output: #123 (obs), P#123 (prompt), S#123 (session), E#123 (event), D#123 (deferred item, full detail).');
577
599
  return;
578
600
  }
579
601
 
@@ -587,12 +609,16 @@ function cmdGet(db, args) {
587
609
  return;
588
610
  }
589
611
 
612
+ // D#N deferred tokens are peeled off BEFORE bucketing/source-forcing — they
613
+ // always read deferred_work (get-only surface; delete/timeline keep rejecting).
614
+ const { deferredIds, rest } = splitDeferredTokens(tokens);
615
+
590
616
  // Shared bucketing with MCP mem_get — single source of truth for P#/S#/E#/# routing (#8050).
591
- const { bySrc, invalid: unparseable } = bucketIdTokens(tokens, { explicit, defaultSource: 'obs' });
617
+ const { bySrc, invalid: unparseable } = bucketIdTokens(rest, { explicit, defaultSource: 'obs' });
592
618
  if (unparseable.length > 0) {
593
619
  process.stderr.write(`[mem] Ignoring unparseable ID token(s): ${unparseable.join(', ')}\n`);
594
620
  }
595
- if (bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length === 0) {
621
+ if (bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length + deferredIds.length === 0) {
596
622
  fail('[mem] No valid IDs provided');
597
623
  return;
598
624
  }
@@ -615,6 +641,21 @@ function cmdGet(db, args) {
615
641
 
616
642
  const sections = [];
617
643
  let totalFound = 0;
644
+ // Deferred sections render first: explicit D# requests are rare and the
645
+ // FULL detail (never truncated) is the whole point of this surface.
646
+ let deferredMissing = [];
647
+ if (deferredIds.length > 0) {
648
+ const dRows = getDeferredByIds(db, deferredIds);
649
+ const found = new Set(dRows.map(r => r.id));
650
+ deferredMissing = deferredIds.filter(id => !found.has(id));
651
+ if (dRows.length > 0) {
652
+ sections.push(dRows.map(formatDeferredDetail).join('\n\n'));
653
+ totalFound += dRows.length;
654
+ }
655
+ if (deferredMissing.length > 0) {
656
+ process.stderr.write(`[mem] Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}\n`);
657
+ }
658
+ }
618
659
  if (bySrc.obs.length > 0) {
619
660
  const s = renderObsRows(db, bySrc.obs, requestedFields);
620
661
  if (s) { sections.push(s.text); totalFound += s.count; }
@@ -633,6 +674,12 @@ function cmdGet(db, args) {
633
674
  }
634
675
 
635
676
  if (totalFound === 0) {
677
+ // Deferred-only request that found nothing — the source-probe below is
678
+ // about obs/session/prompt/event and would print an empty source list.
679
+ if (deferredMissing.length > 0 && bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length === 0) {
680
+ fail(`[mem] Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}. List open items: claude-mem-lite defer list`);
681
+ return;
682
+ }
636
683
  // Probe the OTHER sources so the caller can retry with the right prefix.
637
684
  const queried = new Set(Object.entries(bySrc).filter(([, v]) => v.length > 0).map(([k]) => k));
638
685
  const allIds = [...bySrc.obs, ...bySrc.session, ...bySrc.prompt, ...bySrc.event];
@@ -991,9 +1038,13 @@ function cmdDeferList(db, args) {
991
1038
  }
992
1039
  out(`[mem] Open deferred items (project "${project}"):`);
993
1040
  for (const r of list) {
994
- const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
995
- out(` ${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
1041
+ out(` ${formatDeferListRow(r)}`);
996
1042
  }
1043
+ const staleHint = formatDeferStaleHint(countStaleOpen(db, project));
1044
+ if (staleHint) out(` ${staleHint}`);
1045
+ // Affordance for the detail field — list stays title-only by design (it is
1046
+ // mirrored into the SessionStart dashboard, where detail would be noise).
1047
+ out(` Full detail: claude-mem-lite get D#<id>`);
997
1048
  }
998
1049
 
999
1050
  function cmdDeferDrop(db, args) {
@@ -1163,6 +1214,9 @@ async function cmdStats(db, args) {
1163
1214
  }
1164
1215
 
1165
1216
  out(`[mem] Stats${project ? ` (${project})` : ''}:`);
1217
+ // Env-aware data dir (CLAUDE_MEM_DIR || ~/.claude-mem-lite) — stated so any
1218
+ // raw-db fallback can't guess a co-located-with-the-CLI path (D#92 chain).
1219
+ out(`Data dir: ${DB_DIR}`);
1166
1220
  out(`Total: ${obsTotal.c.toLocaleString()} observations | ${sessTotal.c} sessions | ${promptTotal.c} prompts`);
1167
1221
  out(`Last ${days}d: ${obsRecent.c} observations | ${sessRecent.c} sessions`);
1168
1222
  out('');
@@ -2550,9 +2604,11 @@ Commands:
2550
2604
  --json Output as JSON: {file,limit,include_noise,total,results:[…]}
2551
2605
 
2552
2606
  get <id1,id2,...> Get full details by ID
2553
- IDs accept search-output prefixes: #123 (obs), P#123 (prompt), S#123 (session).
2607
+ IDs accept search-output prefixes: #123 (obs), P#123 (prompt), S#123 (session),
2608
+ D#123 (deferred item — FULL detail; defer list is title-only).
2554
2609
  Bare N defaults to obs. Mixed prefixes in one call route each token correctly.
2555
- --source S Force record type (obs|session|prompt); overrides prefixes.
2610
+ --source S Force record type (obs|session|prompt); overrides prefixes
2611
+ (D# tokens exempt — they always read deferred_work).
2556
2612
  --fields f1,f2,... Select specific fields to return (observations only).
2557
2613
 
2558
2614
  timeline Show observations around an anchor (shows recent if no anchor)
@@ -2584,7 +2640,7 @@ Commands:
2584
2640
  --detail T Constraint + why deferred
2585
2641
  --files f1,f2 Comma-separated file paths
2586
2642
  --project P Project name
2587
- list List open deferred items
2643
+ list List open deferred items (title-only; full detail via get D#N)
2588
2644
  --limit N Max results (default 10)
2589
2645
  --project P Filter by project
2590
2646
  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.49.0",
3
+ "version": "3.51.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",
@@ -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();
package/server.mjs CHANGED
@@ -43,7 +43,7 @@ import { join, sep } from 'path';
43
43
  import { homedir } from 'os';
44
44
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
45
45
  import { searchResources } from './registry-retriever.mjs';
46
- import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
46
+ import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
47
47
  import { saveObservation } from './lib/save-observation.mjs';
48
48
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
49
49
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
@@ -52,6 +52,9 @@ import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
52
52
  import {
53
53
  insertDeferred, listOpenWithOrdinal, dropDeferred,
54
54
  resolveDeferredIds, closeDeferredItems,
55
+ getDeferredByIds, formatDeferredDetail,
56
+ searchDeferredWork, formatDeferredSearchTrailer,
57
+ formatDeferListRow, countStaleOpen, formatDeferStaleHint,
55
58
  } from './lib/deferred-work.mjs';
56
59
  import { _resetVocabCache } from './tfidf.mjs';
57
60
  import { createRequire } from 'module';
@@ -283,11 +286,30 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
283
286
  const deepMode = resolveDeepMode(args.deep, { surface: 'mcp' });
284
287
  const rerank = args.rerank === true && deepMode === 'deep';
285
288
 
289
+ // P2: deferred trailer — open deferred items matching the query, appended to
290
+ // the text blob AFTER (and never counted in) the main results/total. Parity
291
+ // with CLI cmdSearch's emitDeferredTrailer; unfiltered first-page searches
292
+ // only. Structured fields (results/total) stay untouched — the trailer is a
293
+ // text affordance, not a result source.
294
+ const wantDeferredTrailer = !args.type && !args.obs_type && !args.branch && !args.tier && !args.importance && offset === 0;
295
+ const appendDeferredTrailer = (result) => {
296
+ if (!wantDeferredTrailer) return result;
297
+ try {
298
+ const rows = searchDeferredWork(db, args.query || '', args.project || currentProject);
299
+ const lines = formatDeferredSearchTrailer(rows, 'mem_get ids=["D#<id>"]');
300
+ if (lines.length > 0 && result.content?.[0]?.type === 'text') {
301
+ result.content[0].text += `\n\n${lines.join('\n')}`;
302
+ }
303
+ } catch { /* trailer is best-effort; never break search */ }
304
+ return result;
305
+ };
306
+
286
307
  // Early return when query was provided but sanitized to nothing (all FTS5
287
308
  // keywords/special chars). Skipped for deep/auto (the LLM rewrite may still
288
309
  // produce variants) and for filter-only listings (date/obs_type/importance).
310
+ // A pure "D#92" query lands here — the trailer still reaches the item.
289
311
  if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance && deepMode === 'normal') {
290
- return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
312
+ return { ...appendDeferredTrailer(formatSearchOutput([], args, ftsQuery, 0)), escalated: false, results: [], total: 0, variants: null };
291
313
  }
292
314
 
293
315
  // Source scoping. deep is observations-only (deepSearch fuses hybrid-obs lists). branch/tier are
@@ -349,6 +371,7 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
349
371
  if (r.reranked && output.content?.[0]?.type === 'text') {
350
372
  output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
351
373
  }
374
+ appendDeferredTrailer(output);
352
375
 
353
376
  // Expose structured fields for tests + the MCP content blob.
354
377
  return { ...output, results: r.page, total: r.total, escalated: r.escalated, variants: r.variants, reranked: r.reranked };
@@ -505,14 +528,18 @@ server.registerTool(
505
528
  inputSchema: memGetSchema,
506
529
  },
507
530
  safeHandler(async (args) => {
531
+ // D#N deferred tokens are peeled off BEFORE bucketing/source-forcing —
532
+ // get-only read surface into deferred_work (parity with CLI cmdGet; the
533
+ // fetch+render live in lib/deferred-work.mjs so the twins cannot drift).
534
+ const { deferredIds, rest } = splitDeferredTokens(args.ids);
508
535
  // Bucket by per-token prefix (or force all to `args.source` when explicit).
509
536
  // coerceMixedIdTokens has already stringified + regex-validated each token.
510
- const { bySrc, invalid } = bucketIdTokens(args.ids, { explicit: args.source || null, defaultSource: 'obs' });
537
+ const { bySrc, invalid } = bucketIdTokens(rest, { explicit: args.source || null, defaultSource: 'obs' });
511
538
  if (invalid.length > 0) {
512
539
  // Should not happen — schema regex already rejected bad tokens — but guard defensively.
513
- return { content: [{ type: 'text', text: `Invalid ID token(s): ${invalid.join(', ')}. Expected N, #N, P#N, S#N, or E#N.` }] };
540
+ return { content: [{ type: 'text', text: `Invalid ID token(s): ${invalid.join(', ')}. Expected N, #N, P#N, S#N, E#N, or D#N.` }] };
514
541
  }
515
- const totalRequested = bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length;
542
+ const totalRequested = bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length + deferredIds.length;
516
543
  if (totalRequested === 0) {
517
544
  return { content: [{ type: 'text', text: 'No valid IDs provided.' }] };
518
545
  }
@@ -615,7 +642,26 @@ server.registerTool(
615
642
  }
616
643
  }
617
644
 
618
- const totalFound = foundBySource.obs.size + foundBySource.session.size + foundBySource.prompt.size + foundBySource.event.size;
645
+ // Deferred sections prepended below (explicit D# requests are rare; the
646
+ // FULL untruncated detail is the point of this surface).
647
+ let deferredSections = [];
648
+ let deferredFound = 0;
649
+ let deferredMissing = [];
650
+ if (deferredIds.length > 0) {
651
+ const dRows = getDeferredByIds(db, deferredIds);
652
+ const found = new Set(dRows.map(r => r.id));
653
+ deferredMissing = deferredIds.filter(id => !found.has(id));
654
+ deferredSections = dRows.map(formatDeferredDetail);
655
+ deferredFound = dRows.length;
656
+ }
657
+
658
+ const totalFound = foundBySource.obs.size + foundBySource.session.size + foundBySource.prompt.size + foundBySource.event.size + deferredFound;
659
+
660
+ if (totalFound === 0 && deferredIds.length > 0 && bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length === 0) {
661
+ // Deferred-only request, nothing found — the source-probe below is about
662
+ // obs/session/prompt/event and would render an empty source list.
663
+ return { content: [{ type: 'text', text: `Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}. List open items: mem_defer_list.` }] };
664
+ }
619
665
 
620
666
  if (totalFound === 0) {
621
667
  // Probe other sources so callers can retry with the right prefix/source override.
@@ -629,7 +675,8 @@ server.registerTool(
629
675
  if (probe.event.length > 0) hints.push(`E#${probe.event.join(', E#')} (event — use source='event' or E#N)`);
630
676
  const hint = hints.length > 0 ? ` Try: ${hints.join('; ')}.` : '';
631
677
  const queriedList = [...queried].join(', ');
632
- const msg = `No records found in source(s) [${queriedList}] for the given ID(s).${hint}`;
678
+ const deferredNote = deferredMissing.length > 0 ? ` Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}.` : '';
679
+ const msg = `No records found in source(s) [${queriedList}] for the given ID(s).${deferredNote}${hint}`;
633
680
  return { content: [{ type: 'text', text: fieldsNote ? `${msg}\n\n${fieldsNote}` : msg }] };
634
681
  }
635
682
 
@@ -641,10 +688,11 @@ server.registerTool(
641
688
  missingHints.push(...miss(bySrc.session, foundBySource.session, 'S#'));
642
689
  missingHints.push(...miss(bySrc.prompt, foundBySource.prompt, 'P#'));
643
690
  missingHints.push(...miss(bySrc.event, foundBySource.event, 'E#'));
691
+ missingHints.push(...deferredMissing.map(id => `D#${id}`));
644
692
 
645
693
  const parts = [];
646
694
  if (fieldsNote) parts.push(fieldsNote);
647
- parts.push(...sections);
695
+ parts.push(...deferredSections, ...sections);
648
696
  if (missingHints.length > 0) {
649
697
  parts.push(`Note: ID(s) ${missingHints.join(', ')} not found.`);
650
698
  }
@@ -799,9 +847,12 @@ server.registerTool(
799
847
  }
800
848
  const lines = [`Open deferred items (project "${project}"):`];
801
849
  for (const r of list) {
802
- const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
803
- lines.push(`${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
850
+ lines.push(formatDeferListRow(r));
804
851
  }
852
+ const staleHint = formatDeferStaleHint(countStaleOpen(db, project));
853
+ if (staleHint) lines.push(staleHint);
854
+ // Affordance for the detail field — list stays title-only by design.
855
+ lines.push(`Full detail: mem_get ids=["D#<id>"]`);
805
856
  return { content: [{ type: 'text', text: lines.join('\n') }] };
806
857
  })
807
858
  );
@@ -858,6 +909,8 @@ server.registerTool(
858
909
  const lines = [
859
910
  `Memory Statistics${args.project ? ` (project: ${args.project})` : ''}:`,
860
911
  '',
912
+ // Env-aware data dir — parity with CLI cmdStats (D#92 wrong-path chain).
913
+ `Data dir: ${DB_DIR}`,
861
914
  `Total: ${obsTotal.c} observations | ${sessTotal.c} sessions | ${promptTotal.c} prompts`,
862
915
  `Last ${days}d: ${obsRecent.c} observations | ${sessRecent.c} sessions`,
863
916
  '',
package/tool-schemas.mjs CHANGED
@@ -52,7 +52,7 @@ const coerceStringArray = z.preprocess(
52
52
  z.array(z.string())
53
53
  );
54
54
 
55
- // Coerce mixed ID tokens (#N / P#N / S#N / bare N) for mem_get. Accepts:
55
+ // Coerce mixed ID tokens (#N / P#N / S#N / D#N / bare N) for mem_get. Accepts:
56
56
  // - native arrays: [1, "P#2", "#3"]
57
57
  // - single number: 1
58
58
  // - single/comma string: "1,P#2,S#3"
@@ -76,7 +76,10 @@ const coerceMixedIdTokens = z.preprocess(
76
76
  }
77
77
  return v;
78
78
  },
79
- z.array(z.string().regex(/^[EePpSs]?#?\d+$/, 'Expected N, #N, P#N, S#N, or E#N')).min(1).max(20)
79
+ // D#N (deferred_work) requires the `#` bare "D92" is prose, not a token.
80
+ // Round-trip rule: `defer list` renders "(D#92)", so mem_get must accept it
81
+ // back (tests/schema-roundtrip.test.mjs). Delete/timeline keep rejecting D#.
82
+ z.array(z.string().regex(/^(?:[Dd]#|[EePpSs]?#?)\d+$/, 'Expected N, #N, P#N, S#N, E#N, or D#N')).min(1).max(20)
80
83
  );
81
84
 
82
85
  export const memSearchSchema = {
@@ -137,8 +140,8 @@ export const memGetSchema = {
137
140
  // Accepts mixed tokens so pasted search results work verbatim: [1], [1, "P#2"], "1,P#2,S#3",
138
141
  // or the JSON-stringified form ["1","P#2"]. Each token's prefix routes to its source bucket
139
142
  // in server.mjs via lib/id-routing.bucketIdTokens. An explicit `source` override still wins.
140
- ids: coerceMixedIdTokens.describe('Mixed observation/prompt/session/event IDs — accepts N, #N, P#N, S#N, E#N; comma-strings and JSON arrays also coerced'),
141
- source: z.enum(['obs', 'session', 'prompt', 'event']).optional().describe('Force all IDs to this source (overrides per-token prefixes). Omit to let P#/S#/E#/# prefixes route individually.'),
143
+ ids: coerceMixedIdTokens.describe('Mixed observation/prompt/session/event/deferred IDs — accepts N, #N, P#N, S#N, E#N, D#N; comma-strings and JSON arrays also coerced. D#N reads a deferred_work item with FULL detail (defer list is title-only)'),
144
+ source: z.enum(['obs', 'session', 'prompt', 'event']).optional().describe('Force all IDs to this source (overrides per-token prefixes). Omit to let P#/S#/E#/# prefixes route individually. D#N tokens are exempt — they always read deferred_work.'),
142
145
  fields: coerceStringArray.optional().describe('Specific fields to return (default: all; validated against obs schema — session/prompt sources ignore this filter)'),
143
146
  };
144
147