claude-mem-lite 3.49.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.49.0",
13
+ "version": "3.50.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.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
  "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);
@@ -74,6 +74,143 @@ export function dropDeferred(db, id, reason) {
74
74
  return { changed: r.changes };
75
75
  }
76
76
 
77
+ /**
78
+ * Fetch full deferred_work rows by raw id — ANY status, input order preserved,
79
+ * missing ids omitted. This is the read half of the D# surface: `defer list`
80
+ * stays title-only by design (dashboard noise budget), so `get D#N` is where
81
+ * the detail field becomes readable at all.
82
+ * @param {Database} db
83
+ * @param {number[]} ids Raw deferred_work ids
84
+ * @returns {Array<object>} Full rows
85
+ */
86
+ export function getDeferredByIds(db, ids) {
87
+ if (!Array.isArray(ids) || ids.length === 0) return [];
88
+ const stmt = db.prepare(`SELECT * FROM deferred_work WHERE id = ?`);
89
+ const rows = [];
90
+ for (const id of ids) {
91
+ if (!Number.isInteger(id) || id <= 0) continue;
92
+ const r = stmt.get(id);
93
+ if (r) rows.push(r);
94
+ }
95
+ return rows;
96
+ }
97
+
98
+ /**
99
+ * Render one deferred_work row with FULL detail (never truncated) — shared by
100
+ * CLI cmdGet and MCP mem_get so the two surfaces cannot drift.
101
+ * @param {object} row Full deferred_work row (from getDeferredByIds)
102
+ * @returns {string}
103
+ */
104
+ export function formatDeferredDetail(row) {
105
+ const pTag = row.priority === 3 ? '🔴' : row.priority === 1 ? '⚪' : '🟡';
106
+ const lines = [`── D#${row.id} ── deferred (${row.status}) ${pTag} [P${row.priority}]`];
107
+ lines.push(`project: ${row.project}`);
108
+ lines.push(`title: ${row.title}`);
109
+ if (row.detail) lines.push(`detail: ${row.detail}`);
110
+ if (row.files) {
111
+ try {
112
+ const f = JSON.parse(row.files);
113
+ if (Array.isArray(f) && f.length > 0) lines.push(`files: ${f.join(', ')}`);
114
+ } catch { /* legacy non-JSON files value — skip rather than render garbage */ }
115
+ }
116
+ if (row.created_at_epoch) lines.push(`created: ${new Date(row.created_at_epoch).toISOString()}`);
117
+ if (row.status === 'dropped' && row.drop_reason) lines.push(`drop_reason: ${row.drop_reason}`);
118
+ if (row.status === 'done' && row.closed_by_obs_id) lines.push(`closed_by: #${row.closed_by_obs_id}`);
119
+ return lines.join('\n');
120
+ }
121
+
122
+ /**
123
+ * P2 search leg: make deferred items reachable from mem_search / CLI search.
124
+ *
125
+ * Two channels, merged direct-first and deduped:
126
+ * 1. Explicit "D#N" refs in the query → direct id lookup, ANY status
127
+ * (an explicit reference deserves the row even if closed), project-scoped.
128
+ * 2. Keyword match over OPEN items' title+detail — JS substring matching
129
+ * (never SQL LIKE, so %/_ are literals and wildcard injection is
130
+ * structurally impossible; corpus is ≤50 open rows per project).
131
+ * Multi-token queries need ceil(n/2) token hits so one generic token
132
+ * ("test", "lesson") can't drag the trailer into every search.
133
+ *
134
+ * Date bounds are deliberately NOT applied — open items are few, current by
135
+ * definition, and the trailer is labeled as deferred work, not search results.
136
+ *
137
+ * @param {Database} db
138
+ * @param {string} query Raw user query
139
+ * @param {string} project Project scope (required — trailer is project-local)
140
+ * @param {{limit?: number}} [opts]
141
+ * @returns {Array<object>} Full rows, direct refs first, capped at limit
142
+ */
143
+ export function searchDeferredWork(db, query, project, { limit = 3 } = {}) {
144
+ if (!query || typeof query !== 'string' || !project) return [];
145
+
146
+ const refIds = [];
147
+ const refRe = /\bD#(\d+)\b/gi;
148
+ let m;
149
+ while ((m = refRe.exec(query)) !== null) {
150
+ const id = parseInt(m[1], 10);
151
+ if (id > 0 && !refIds.includes(id)) refIds.push(id);
152
+ }
153
+ const direct = refIds.length > 0
154
+ ? getDeferredByIds(db, refIds).filter(r => r.project === project)
155
+ : [];
156
+
157
+ const tokens = query
158
+ .replace(/\bD#\d+\b/gi, ' ')
159
+ .toLowerCase()
160
+ .split(/\s+/)
161
+ .filter(t => t.length >= 2)
162
+ .slice(0, 8);
163
+
164
+ let keyword = [];
165
+ if (tokens.length > 0) {
166
+ const open = db.prepare(`
167
+ SELECT * FROM deferred_work
168
+ WHERE project = ? AND status = 'open'
169
+ ORDER BY priority DESC, created_at_epoch ASC
170
+ LIMIT 50
171
+ `).all(project);
172
+ const need = Math.max(1, Math.ceil(tokens.length / 2));
173
+ keyword = open
174
+ .map(r => {
175
+ const hay = `${r.title || ''} ${r.detail || ''}`.toLowerCase();
176
+ return { r, matched: tokens.filter(t => hay.includes(t)).length };
177
+ })
178
+ .filter(x => x.matched >= need)
179
+ .sort((a, b) => b.matched - a.matched)
180
+ .map(x => x.r);
181
+ }
182
+
183
+ const seen = new Set();
184
+ const out = [];
185
+ for (const r of [...direct, ...keyword]) {
186
+ if (seen.has(r.id)) continue;
187
+ seen.add(r.id);
188
+ out.push(r);
189
+ if (out.length >= limit) break;
190
+ }
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * Render the deferred search trailer — appended AFTER (and never counted in)
196
+ * the main result set. Title-only lines; `invokeHint` names the surface's
197
+ * full-detail reader (CLI `get D#N` / MCP mem_get).
198
+ * @param {Array<object>} rows From searchDeferredWork
199
+ * @param {string} invokeHint e.g. 'claude-mem-lite get D#<id>'
200
+ * @returns {string[]} Lines (empty array when rows is empty)
201
+ */
202
+ export function formatDeferredSearchTrailer(rows, invokeHint) {
203
+ if (!Array.isArray(rows) || rows.length === 0) return [];
204
+ const lines = [`[mem] Deferred work matches — full detail: ${invokeHint}`];
205
+ for (const r of rows) {
206
+ const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
207
+ const statusTag = r.status === 'open' ? '' : ` [${r.status}]`;
208
+ const title = (r.title || '(untitled)').length > 80 ? `${r.title.slice(0, 80)}…` : (r.title || '(untitled)');
209
+ lines.push(` D#${r.id} ${pTag} [P${r.priority}]${statusTag} ${title}`);
210
+ }
211
+ return lines;
212
+ }
213
+
77
214
  /**
78
215
  * Resolve mixed ordinal (int) + raw-id ("D#<n>") tokens to real deferred_work
79
216
  * 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,8 @@ import { aggregateMetrics } from './lib/metrics.mjs';
54
54
  import {
55
55
  insertDeferred, listOpenWithOrdinal, dropDeferred,
56
56
  resolveDeferredIds, closeDeferredItems,
57
+ getDeferredByIds, formatDeferredDetail,
58
+ searchDeferredWork, formatDeferredSearchTrailer,
57
59
  } from './lib/deferred-work.mjs';
58
60
 
59
61
  // ─── Commands ────────────────────────────────────────────────────────────────
@@ -142,6 +144,20 @@ async function cmdSearch(db, args, { llm } = {}) {
142
144
  return;
143
145
  }
144
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
+
145
161
  const ftsQuery = buildSearchFtsQuery(query, { or: useOr });
146
162
  // --deep proceeds even when the literal query sanitizes to nothing — its LLM
147
163
  // rewrite may still produce searchable variants (F3, parity with server.mjs).
@@ -153,6 +169,7 @@ async function cmdSearch(db, args, { llm } = {}) {
153
169
  if (jsonOutput) {
154
170
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: false, results: [] }));
155
171
  } else {
172
+ emitDeferredTrailer();
156
173
  fail(`[mem] No valid search terms in "${query}"`);
157
174
  }
158
175
  return;
@@ -246,6 +263,9 @@ async function cmdSearch(db, args, { llm } = {}) {
246
263
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: isDeep, variants: isDeep ? deepVariants : undefined, results: [] }));
247
264
  } else {
248
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();
249
269
  }
250
270
  return;
251
271
  }
@@ -334,6 +354,7 @@ async function cmdSearch(db, args, { llm } = {}) {
334
354
  }
335
355
  }
336
356
  }
357
+ emitDeferredTrailer();
337
358
  }
338
359
 
339
360
  function cmdRecent(db, args) {
@@ -573,7 +594,7 @@ function cmdGet(db, args) {
573
594
  const idStr = positional.join(',');
574
595
  if (!idStr) {
575
596
  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).');
597
+ ' IDs accept prefix from search output: #123 (obs), P#123 (prompt), S#123 (session), E#123 (event), D#123 (deferred item, full detail).');
577
598
  return;
578
599
  }
579
600
 
@@ -587,12 +608,16 @@ function cmdGet(db, args) {
587
608
  return;
588
609
  }
589
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
+
590
615
  // 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' });
616
+ const { bySrc, invalid: unparseable } = bucketIdTokens(rest, { explicit, defaultSource: 'obs' });
592
617
  if (unparseable.length > 0) {
593
618
  process.stderr.write(`[mem] Ignoring unparseable ID token(s): ${unparseable.join(', ')}\n`);
594
619
  }
595
- 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) {
596
621
  fail('[mem] No valid IDs provided');
597
622
  return;
598
623
  }
@@ -615,6 +640,21 @@ function cmdGet(db, args) {
615
640
 
616
641
  const sections = [];
617
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
+ }
618
658
  if (bySrc.obs.length > 0) {
619
659
  const s = renderObsRows(db, bySrc.obs, requestedFields);
620
660
  if (s) { sections.push(s.text); totalFound += s.count; }
@@ -633,6 +673,12 @@ function cmdGet(db, args) {
633
673
  }
634
674
 
635
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
+ }
636
682
  // Probe the OTHER sources so the caller can retry with the right prefix.
637
683
  const queried = new Set(Object.entries(bySrc).filter(([, v]) => v.length > 0).map(([k]) => k));
638
684
  const allIds = [...bySrc.obs, ...bySrc.session, ...bySrc.prompt, ...bySrc.event];
@@ -994,6 +1040,9 @@ function cmdDeferList(db, args) {
994
1040
  const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
995
1041
  out(` ${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
996
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>`);
997
1046
  }
998
1047
 
999
1048
  function cmdDeferDrop(db, args) {
@@ -2550,9 +2599,11 @@ Commands:
2550
2599
  --json Output as JSON: {file,limit,include_noise,total,results:[…]}
2551
2600
 
2552
2601
  get <id1,id2,...> Get full details by ID
2553
- 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).
2554
2604
  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.
2605
+ --source S Force record type (obs|session|prompt); overrides prefixes
2606
+ (D# tokens exempt — they always read deferred_work).
2556
2607
  --fields f1,f2,... Select specific fields to return (observations only).
2557
2608
 
2558
2609
  timeline Show observations around an anchor (shows recent if no anchor)
@@ -2584,7 +2635,7 @@ Commands:
2584
2635
  --detail T Constraint + why deferred
2585
2636
  --files f1,f2 Comma-separated file paths
2586
2637
  --project P Project name
2587
- list List open deferred items
2638
+ list List open deferred items (title-only; full detail via get D#N)
2588
2639
  --limit N Max results (default 10)
2589
2640
  --project P Filter by project
2590
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.49.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",
@@ -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,8 @@ 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,
55
57
  } from './lib/deferred-work.mjs';
56
58
  import { _resetVocabCache } from './tfidf.mjs';
57
59
  import { createRequire } from 'module';
@@ -283,11 +285,30 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
283
285
  const deepMode = resolveDeepMode(args.deep, { surface: 'mcp' });
284
286
  const rerank = args.rerank === true && deepMode === 'deep';
285
287
 
288
+ // P2: deferred trailer — open deferred items matching the query, appended to
289
+ // the text blob AFTER (and never counted in) the main results/total. Parity
290
+ // with CLI cmdSearch's emitDeferredTrailer; unfiltered first-page searches
291
+ // only. Structured fields (results/total) stay untouched — the trailer is a
292
+ // text affordance, not a result source.
293
+ const wantDeferredTrailer = !args.type && !args.obs_type && !args.branch && !args.tier && !args.importance && offset === 0;
294
+ const appendDeferredTrailer = (result) => {
295
+ if (!wantDeferredTrailer) return result;
296
+ try {
297
+ const rows = searchDeferredWork(db, args.query || '', args.project || currentProject);
298
+ const lines = formatDeferredSearchTrailer(rows, 'mem_get ids=["D#<id>"]');
299
+ if (lines.length > 0 && result.content?.[0]?.type === 'text') {
300
+ result.content[0].text += `\n\n${lines.join('\n')}`;
301
+ }
302
+ } catch { /* trailer is best-effort; never break search */ }
303
+ return result;
304
+ };
305
+
286
306
  // Early return when query was provided but sanitized to nothing (all FTS5
287
307
  // keywords/special chars). Skipped for deep/auto (the LLM rewrite may still
288
308
  // produce variants) and for filter-only listings (date/obs_type/importance).
309
+ // A pure "D#92" query lands here — the trailer still reaches the item.
289
310
  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 };
311
+ return { ...appendDeferredTrailer(formatSearchOutput([], args, ftsQuery, 0)), escalated: false, results: [], total: 0, variants: null };
291
312
  }
292
313
 
293
314
  // Source scoping. deep is observations-only (deepSearch fuses hybrid-obs lists). branch/tier are
@@ -349,6 +370,7 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
349
370
  if (r.reranked && output.content?.[0]?.type === 'text') {
350
371
  output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
351
372
  }
373
+ appendDeferredTrailer(output);
352
374
 
353
375
  // Expose structured fields for tests + the MCP content blob.
354
376
  return { ...output, results: r.page, total: r.total, escalated: r.escalated, variants: r.variants, reranked: r.reranked };
@@ -505,14 +527,18 @@ server.registerTool(
505
527
  inputSchema: memGetSchema,
506
528
  },
507
529
  safeHandler(async (args) => {
530
+ // D#N deferred tokens are peeled off BEFORE bucketing/source-forcing —
531
+ // get-only read surface into deferred_work (parity with CLI cmdGet; the
532
+ // fetch+render live in lib/deferred-work.mjs so the twins cannot drift).
533
+ const { deferredIds, rest } = splitDeferredTokens(args.ids);
508
534
  // Bucket by per-token prefix (or force all to `args.source` when explicit).
509
535
  // coerceMixedIdTokens has already stringified + regex-validated each token.
510
- const { bySrc, invalid } = bucketIdTokens(args.ids, { explicit: args.source || null, defaultSource: 'obs' });
536
+ const { bySrc, invalid } = bucketIdTokens(rest, { explicit: args.source || null, defaultSource: 'obs' });
511
537
  if (invalid.length > 0) {
512
538
  // 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.` }] };
539
+ return { content: [{ type: 'text', text: `Invalid ID token(s): ${invalid.join(', ')}. Expected N, #N, P#N, S#N, E#N, or D#N.` }] };
514
540
  }
515
- const totalRequested = bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length;
541
+ const totalRequested = bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length + deferredIds.length;
516
542
  if (totalRequested === 0) {
517
543
  return { content: [{ type: 'text', text: 'No valid IDs provided.' }] };
518
544
  }
@@ -615,7 +641,26 @@ server.registerTool(
615
641
  }
616
642
  }
617
643
 
618
- const totalFound = foundBySource.obs.size + foundBySource.session.size + foundBySource.prompt.size + foundBySource.event.size;
644
+ // Deferred sections prepended below (explicit D# requests are rare; the
645
+ // FULL untruncated detail is the point of this surface).
646
+ let deferredSections = [];
647
+ let deferredFound = 0;
648
+ let deferredMissing = [];
649
+ if (deferredIds.length > 0) {
650
+ const dRows = getDeferredByIds(db, deferredIds);
651
+ const found = new Set(dRows.map(r => r.id));
652
+ deferredMissing = deferredIds.filter(id => !found.has(id));
653
+ deferredSections = dRows.map(formatDeferredDetail);
654
+ deferredFound = dRows.length;
655
+ }
656
+
657
+ const totalFound = foundBySource.obs.size + foundBySource.session.size + foundBySource.prompt.size + foundBySource.event.size + deferredFound;
658
+
659
+ if (totalFound === 0 && deferredIds.length > 0 && bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length === 0) {
660
+ // Deferred-only request, nothing found — the source-probe below is about
661
+ // obs/session/prompt/event and would render an empty source list.
662
+ return { content: [{ type: 'text', text: `Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}. List open items: mem_defer_list.` }] };
663
+ }
619
664
 
620
665
  if (totalFound === 0) {
621
666
  // Probe other sources so callers can retry with the right prefix/source override.
@@ -629,7 +674,8 @@ server.registerTool(
629
674
  if (probe.event.length > 0) hints.push(`E#${probe.event.join(', E#')} (event — use source='event' or E#N)`);
630
675
  const hint = hints.length > 0 ? ` Try: ${hints.join('; ')}.` : '';
631
676
  const queriedList = [...queried].join(', ');
632
- const msg = `No records found in source(s) [${queriedList}] for the given ID(s).${hint}`;
677
+ const deferredNote = deferredMissing.length > 0 ? ` Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}.` : '';
678
+ const msg = `No records found in source(s) [${queriedList}] for the given ID(s).${deferredNote}${hint}`;
633
679
  return { content: [{ type: 'text', text: fieldsNote ? `${msg}\n\n${fieldsNote}` : msg }] };
634
680
  }
635
681
 
@@ -641,10 +687,11 @@ server.registerTool(
641
687
  missingHints.push(...miss(bySrc.session, foundBySource.session, 'S#'));
642
688
  missingHints.push(...miss(bySrc.prompt, foundBySource.prompt, 'P#'));
643
689
  missingHints.push(...miss(bySrc.event, foundBySource.event, 'E#'));
690
+ missingHints.push(...deferredMissing.map(id => `D#${id}`));
644
691
 
645
692
  const parts = [];
646
693
  if (fieldsNote) parts.push(fieldsNote);
647
- parts.push(...sections);
694
+ parts.push(...deferredSections, ...sections);
648
695
  if (missingHints.length > 0) {
649
696
  parts.push(`Note: ID(s) ${missingHints.join(', ')} not found.`);
650
697
  }
@@ -802,6 +849,8 @@ server.registerTool(
802
849
  const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
803
850
  lines.push(`${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
804
851
  }
852
+ // Affordance for the detail field — list stays title-only by design.
853
+ lines.push(`Full detail: mem_get ids=["D#<id>"]`);
805
854
  return { content: [{ type: 'text', text: lines.join('\n') }] };
806
855
  })
807
856
  );
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