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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-optimize.mjs +5 -1
- package/lib/deferred-work.mjs +137 -0
- package/lib/id-routing.mjs +26 -0
- package/mem-cli.mjs +58 -7
- package/package.json +1 -1
- package/scripts/prompt-search-utils.mjs +27 -0
- package/scripts/user-prompt-search.js +68 -12
- package/server.mjs +57 -8
- 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.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.
|
|
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);
|
package/lib/deferred-work.mjs
CHANGED
|
@@ -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'.
|
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,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(
|
|
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.
|
|
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).
|
|
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,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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|