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