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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-optimize.mjs +5 -1
- package/hook.mjs +7 -2
- package/lib/deferred-work.mjs +180 -0
- package/lib/id-routing.mjs +26 -0
- package/mem-cli.mjs +65 -9
- package/package.json +1 -1
- package/scripts/prompt-search-utils.mjs +27 -0
- package/scripts/user-prompt-search.js +68 -12
- package/server.mjs +63 -10
- package/tool-schemas.mjs +7 -4
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/lib/deferred-work.mjs
CHANGED
|
@@ -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'.
|
package/lib/id-routing.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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.
|
|
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).
|
|
588
|
-
//
|
|
589
|
-
//
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|