claude-mem-lite 3.47.0 → 3.49.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-context.mjs +20 -1
- package/hook-llm.mjs +2 -1
- package/hook-optimize.mjs +23 -13
- package/hook.mjs +54 -3
- package/lib/activity.mjs +2 -1
- package/lib/citation-tracker.mjs +7 -1
- package/lib/delete-core.mjs +72 -0
- package/lib/events-injection.mjs +100 -0
- package/lib/export-columns.mjs +4 -1
- package/lib/obs-types.mjs +12 -0
- package/lib/save-nudge.mjs +22 -0
- package/lib/search-core.mjs +47 -5
- package/lib/stats-core.mjs +113 -0
- package/mem-cli.mjs +22 -147
- package/package.json +6 -1
- package/registry.mjs +1 -1
- package/schema.mjs +1 -1
- package/scripts/pre-tool-recall.js +12 -4
- package/scripts/user-prompt-search.js +4 -1
- package/server.mjs +17 -136
- package/source-files.mjs +23 -0
- package/tool-schemas.mjs +2 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// lib/stats-core.mjs — shared primary stats feed for CLI `stats` and MCP `mem_stats`.
|
|
2
|
+
//
|
|
3
|
+
// Audit 2026-07-17 MED-4: these ~15 COUNT/GROUP-BY queries were hand-copied
|
|
4
|
+
// byte-equivalent between server.mjs (mem_stats) and mem-cli.mjs (cmdStats) — the
|
|
5
|
+
// same twin-drift class the `--quality` sub-report already closed via
|
|
6
|
+
// lib/stats-quality.mjs, and delete orchestration via lib/delete-core.mjs. One
|
|
7
|
+
// computation, two renderers: callers keep their own formatting (MCP text block /
|
|
8
|
+
// CLI console+JSON) plus any surface-only extras (CLI hookErrors24h).
|
|
9
|
+
import { inferProject } from '../utils.mjs';
|
|
10
|
+
import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
|
|
11
|
+
import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
|
|
12
|
+
import { computeNoiseGauge } from './stats-quality.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compute the primary stats feed. Row shapes are returned exactly as the twin
|
|
16
|
+
* blocks produced them ({c}/{t}/{v} single-row objects, arrays for distributions)
|
|
17
|
+
* so both call sites render with minimal diff.
|
|
18
|
+
*/
|
|
19
|
+
export function computeStatsFeed(db, { project = null, days = 30, now = Date.now() } = {}) {
|
|
20
|
+
const cutoff = now - days * 86400000;
|
|
21
|
+
const projectFilter = project ? 'AND project = ?' : '';
|
|
22
|
+
const baseParams = project ? [project] : [];
|
|
23
|
+
|
|
24
|
+
// Total counts (session_summaries, not sdk_sessions — CLI↔MCP aligned)
|
|
25
|
+
const obsTotal = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
|
|
26
|
+
const sessTotal = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`).get(...baseParams);
|
|
27
|
+
const promptTotal = project
|
|
28
|
+
? 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)
|
|
29
|
+
: db.prepare('SELECT COUNT(*) as c FROM user_prompts').get();
|
|
30
|
+
|
|
31
|
+
// Recent counts
|
|
32
|
+
const obsRecent = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
|
|
33
|
+
const sessRecent = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
|
|
34
|
+
|
|
35
|
+
// Type distribution (recent)
|
|
36
|
+
const types = db.prepare(`
|
|
37
|
+
SELECT type, COUNT(*) as c FROM observations
|
|
38
|
+
WHERE created_at_epoch >= ? ${projectFilter}
|
|
39
|
+
GROUP BY type ORDER BY c DESC
|
|
40
|
+
`).all(cutoff, ...baseParams);
|
|
41
|
+
|
|
42
|
+
// Top projects (global view — skipped when filtering by single project)
|
|
43
|
+
const projects = project ? [] : db.prepare(`
|
|
44
|
+
SELECT project, COUNT(*) as c FROM observations
|
|
45
|
+
GROUP BY project ORDER BY c DESC LIMIT 20
|
|
46
|
+
`).all();
|
|
47
|
+
|
|
48
|
+
// Daily activity (last 7 days)
|
|
49
|
+
const daily = db.prepare(`
|
|
50
|
+
SELECT date(created_at) as day, COUNT(*) as c FROM observations
|
|
51
|
+
WHERE created_at_epoch >= ? ${projectFilter}
|
|
52
|
+
GROUP BY day ORDER BY day DESC LIMIT 7
|
|
53
|
+
`).all(now - 7 * 86400000, ...baseParams);
|
|
54
|
+
|
|
55
|
+
// Data health
|
|
56
|
+
const tokenEst = db.prepare(`
|
|
57
|
+
SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
|
|
58
|
+
FROM observations WHERE 1=1 ${projectFilter}
|
|
59
|
+
`).get(...baseParams);
|
|
60
|
+
const avgImp = db.prepare(`SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
|
|
61
|
+
|
|
62
|
+
const thirtyDaysAgo = now - 30 * 86400000;
|
|
63
|
+
// v3.23 noise-gauge de-blinding: `<= 1` makes the imp=0 dormant population visible
|
|
64
|
+
// (decay floor + LLM low-signal filter push ~half the live corpus to 0);
|
|
65
|
+
// injection_count=0 mirrors decay's NEVER-INJECTED guard so injected-but-decayed
|
|
66
|
+
// pinned noise isn't miscounted as "never used".
|
|
67
|
+
const lowVal = db.prepare(`
|
|
68
|
+
SELECT COUNT(*) as c FROM observations
|
|
69
|
+
WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
|
|
70
|
+
AND COALESCE(injection_count,0) = 0
|
|
71
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
72
|
+
AND created_at_epoch < ? ${projectFilter}
|
|
73
|
+
`).get(thirtyDaysAgo, ...baseParams);
|
|
74
|
+
// Low-signal-title population (template / tool-log titles the read-side filter
|
|
75
|
+
// already excludes). The imp≤1 "Low-value" metric can't see these, so the gauge
|
|
76
|
+
// under-reports real noise without it. Same source as lib/low-signal-patterns.mjs.
|
|
77
|
+
const lowSignalTitle = db.prepare(`
|
|
78
|
+
SELECT COUNT(*) as c FROM observations
|
|
79
|
+
WHERE NOT ${buildNotLowSignalSql()}
|
|
80
|
+
AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
81
|
+
`).get(...baseParams);
|
|
82
|
+
// F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
|
|
83
|
+
// obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
|
|
84
|
+
const liveTotal = db.prepare(
|
|
85
|
+
`SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
|
|
86
|
+
).get(...baseParams);
|
|
87
|
+
const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
|
|
88
|
+
const compressedCount = db.prepare(
|
|
89
|
+
`SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
|
|
90
|
+
).get(...baseParams);
|
|
91
|
+
const supersededOnlyCount = db.prepare(
|
|
92
|
+
`SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
|
|
93
|
+
).get(...baseParams);
|
|
94
|
+
|
|
95
|
+
// Tier distribution
|
|
96
|
+
const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
|
|
97
|
+
const tdParams = tierSqlParams(tierCtx);
|
|
98
|
+
const tierDist = db.prepare(`
|
|
99
|
+
SELECT tier, COUNT(*) as c FROM (
|
|
100
|
+
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
101
|
+
WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
102
|
+
) GROUP BY tier ORDER BY tier
|
|
103
|
+
`).all(...tdParams, ...baseParams);
|
|
104
|
+
const tierMap = Object.fromEntries(tierDist.map((r) => [r.tier, r.c]));
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
|
|
108
|
+
types, projects, daily,
|
|
109
|
+
tokenEst, avgImp, lowVal, lowSignalTitle, liveTotal,
|
|
110
|
+
noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount,
|
|
111
|
+
tierMap,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -15,15 +15,18 @@ 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,
|
|
22
21
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
23
|
-
|
|
22
|
+
hardDeleteCandidateCount,
|
|
24
23
|
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
25
24
|
} from './lib/maintain-core.mjs';
|
|
26
25
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
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';
|
|
27
30
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
28
31
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
29
32
|
import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
|
|
@@ -41,7 +44,6 @@ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatP
|
|
|
41
44
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
42
45
|
import { rebuildObservationDerived, normalizeScope } from './lib/observation-write.mjs';
|
|
43
46
|
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
44
|
-
import { computeNoiseGauge } from './lib/stats-quality.mjs';
|
|
45
47
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
46
48
|
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
|
|
47
49
|
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
@@ -71,7 +73,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
71
73
|
|
|
72
74
|
const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
|
|
73
75
|
const type = flags.type || null;
|
|
74
|
-
const validObsTypes =
|
|
76
|
+
const validObsTypes = OBS_TYPE_SET;
|
|
75
77
|
if (type && !validObsTypes.has(type)) {
|
|
76
78
|
fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
|
|
77
79
|
return;
|
|
@@ -368,7 +370,7 @@ function cmdRecent(db, args) {
|
|
|
368
370
|
// try this for "show recent bugfixes". Mirror cmdSearch's enum validation.
|
|
369
371
|
const type = flags.type || null;
|
|
370
372
|
if (type) {
|
|
371
|
-
const validObsTypes =
|
|
373
|
+
const validObsTypes = OBS_TYPE_SET;
|
|
372
374
|
if (!validObsTypes.has(type)) {
|
|
373
375
|
fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
|
|
374
376
|
return;
|
|
@@ -802,7 +804,7 @@ function cmdSave(db, args) {
|
|
|
802
804
|
if (rejectBareStringFlags(flags, ['title', 'files', 'lesson', 'lesson-learned', 'project', 'type'])) return;
|
|
803
805
|
|
|
804
806
|
const type = flags.type || 'discovery';
|
|
805
|
-
const validTypes =
|
|
807
|
+
const validTypes = OBS_TYPE_SET;
|
|
806
808
|
if (!validTypes.has(type)) {
|
|
807
809
|
fail(`[mem] Invalid type "${type}". Valid: ${[...validTypes].join(', ')}`);
|
|
808
810
|
return;
|
|
@@ -911,7 +913,7 @@ function cmdSave(db, args) {
|
|
|
911
913
|
const supersededNote = result.supersededIds && result.supersededIds.length > 0
|
|
912
914
|
? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
|
|
913
915
|
: '';
|
|
914
|
-
out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}`);
|
|
916
|
+
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' })}`);
|
|
915
917
|
}
|
|
916
918
|
|
|
917
919
|
// ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
|
|
@@ -1111,98 +1113,12 @@ async function cmdStats(db, args) {
|
|
|
1111
1113
|
return;
|
|
1112
1114
|
}
|
|
1113
1115
|
|
|
1114
|
-
const projectFilter = project ? 'AND project = ?' : '';
|
|
1115
|
-
const baseParams = project ? [project] : [];
|
|
1116
|
-
|
|
1117
1116
|
const now = Date.now();
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
).get(...baseParams);
|
|
1124
|
-
const sessTotal = db.prepare(
|
|
1125
|
-
`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`
|
|
1126
|
-
).get(...baseParams);
|
|
1127
|
-
const promptTotal = project
|
|
1128
|
-
? 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)
|
|
1129
|
-
: db.prepare('SELECT COUNT(*) as c FROM user_prompts').get();
|
|
1130
|
-
|
|
1131
|
-
// Recent counts
|
|
1132
|
-
const obsRecent = db.prepare(
|
|
1133
|
-
`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`
|
|
1134
|
-
).get(cutoff, ...baseParams);
|
|
1135
|
-
const sessRecent = db.prepare(
|
|
1136
|
-
`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`
|
|
1137
|
-
).get(cutoff, ...baseParams);
|
|
1138
|
-
|
|
1139
|
-
// Type distribution (recent)
|
|
1140
|
-
const types = db.prepare(`
|
|
1141
|
-
SELECT type, COUNT(*) as c FROM observations
|
|
1142
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
1143
|
-
GROUP BY type ORDER BY c DESC
|
|
1144
|
-
`).all(cutoff, ...baseParams);
|
|
1145
|
-
|
|
1146
|
-
// Top projects (global view — skipped when filtering by single project; aligned with MCP)
|
|
1147
|
-
const projects = project ? [] : db.prepare(`
|
|
1148
|
-
SELECT project, COUNT(*) as c FROM observations
|
|
1149
|
-
GROUP BY project ORDER BY c DESC LIMIT 20
|
|
1150
|
-
`).all();
|
|
1151
|
-
|
|
1152
|
-
// Daily activity (last 7 days; aligned with MCP mem_stats)
|
|
1153
|
-
const daily = db.prepare(`
|
|
1154
|
-
SELECT date(created_at) as day, COUNT(*) as c FROM observations
|
|
1155
|
-
WHERE created_at_epoch >= ? ${projectFilter}
|
|
1156
|
-
GROUP BY day ORDER BY day DESC LIMIT 7
|
|
1157
|
-
`).all(now - 7 * 86400000, ...baseParams);
|
|
1158
|
-
|
|
1159
|
-
// Data health (aligned with MCP mem_stats)
|
|
1160
|
-
const tokenEst = db.prepare(`
|
|
1161
|
-
SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
|
|
1162
|
-
FROM observations WHERE 1=1 ${projectFilter}
|
|
1163
|
-
`).get(...baseParams);
|
|
1164
|
-
const avgImp = db.prepare(
|
|
1165
|
-
`SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`
|
|
1166
|
-
).get(...baseParams);
|
|
1167
|
-
const thirtyDaysAgo = now - 30 * 86400000;
|
|
1168
|
-
// v3.23 noise-gauge de-blinding: the prior `importance = 1` predicate was
|
|
1169
|
-
// structurally blind to imp=0 — decay's floor + the LLM low-signal filter push
|
|
1170
|
-
// dormant rows to 0, which on a real store is ~half the live corpus. The gauge
|
|
1171
|
-
// therefore reported "0.0% noise" while the store was dormant-heavy. `<= 1` makes
|
|
1172
|
-
// imp=0 visible; injection_count=0 mirrors decay's NEVER-INJECTED guard so an
|
|
1173
|
-
// injected-but-decayed row (pinned noise, tracked separately) is not miscounted
|
|
1174
|
-
// as "never used".
|
|
1175
|
-
const lowVal = db.prepare(`
|
|
1176
|
-
SELECT COUNT(*) as c FROM observations
|
|
1177
|
-
WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
|
|
1178
|
-
AND COALESCE(injection_count,0) = 0
|
|
1179
|
-
AND COALESCE(compressed_into, 0) = 0
|
|
1180
|
-
AND created_at_epoch < ? ${projectFilter}
|
|
1181
|
-
`).get(thirtyDaysAgo, ...baseParams);
|
|
1182
|
-
// Low-signal-title population: template / tool-log titles (Modified, Worked on,
|
|
1183
|
-
// Error while working, Error:, node/npm/npx …) that the retrieval layer already
|
|
1184
|
-
// filters out by default. The imp=1 "Low-value" metric above structurally can't
|
|
1185
|
-
// see these — they often carry inflated importance and recent access — so the
|
|
1186
|
-
// health gauge under-reports real noise without this line. Same LOW_SIGNAL
|
|
1187
|
-
// pattern source as the read-side filter (lib/low-signal-patterns.mjs).
|
|
1188
|
-
const lowSignalTitle = db.prepare(`
|
|
1189
|
-
SELECT COUNT(*) as c FROM observations
|
|
1190
|
-
WHERE NOT ${buildNotLowSignalSql()}
|
|
1191
|
-
AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
1192
|
-
`).get(...baseParams);
|
|
1193
|
-
// F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
|
|
1194
|
-
// obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
|
|
1195
|
-
// Shared with the MCP mem_stats gauge via computeNoiseGauge (lib/stats-quality.mjs).
|
|
1196
|
-
const liveTotal = db.prepare(
|
|
1197
|
-
`SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
|
|
1198
|
-
).get(...baseParams);
|
|
1199
|
-
const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
|
|
1200
|
-
const compressedCount = db.prepare(
|
|
1201
|
-
`SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
|
|
1202
|
-
).get(...baseParams);
|
|
1203
|
-
const supersededOnlyCount = db.prepare(
|
|
1204
|
-
`SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
|
|
1205
|
-
).get(...baseParams);
|
|
1117
|
+
const {
|
|
1118
|
+
obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
|
|
1119
|
+
types, projects, daily, tokenEst, avgImp, lowVal, lowSignalTitle,
|
|
1120
|
+
noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount, tierMap,
|
|
1121
|
+
} = computeStatsFeed(db, { project, days, now });
|
|
1206
1122
|
|
|
1207
1123
|
// Hook self-observation: count PreToolUse / Skill-bridge script failures
|
|
1208
1124
|
// recorded in the last 24h. Surfaces silent breakage (DB corruption,
|
|
@@ -1210,17 +1126,6 @@ async function cmdStats(db, args) {
|
|
|
1210
1126
|
// failure mode that left code-graph's matcher bug undetected for 10 sessions.
|
|
1211
1127
|
const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
|
|
1212
1128
|
|
|
1213
|
-
// Tier distribution (aligned with MCP mem_stats)
|
|
1214
|
-
const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
|
|
1215
|
-
const tdParams = tierSqlParams(tierCtx);
|
|
1216
|
-
const tierDist = db.prepare(`
|
|
1217
|
-
SELECT tier, COUNT(*) as c FROM (
|
|
1218
|
-
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
1219
|
-
WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
1220
|
-
) GROUP BY tier ORDER BY tier
|
|
1221
|
-
`).all(...tdParams, ...baseParams);
|
|
1222
|
-
const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
|
|
1223
|
-
|
|
1224
1129
|
if (jsonOutput) {
|
|
1225
1130
|
out(JSON.stringify({
|
|
1226
1131
|
project,
|
|
@@ -1508,43 +1413,13 @@ function cmdDelete(db, args) {
|
|
|
1508
1413
|
return;
|
|
1509
1414
|
}
|
|
1510
1415
|
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
// (
|
|
1514
|
-
|
|
1515
|
-
snapshotDb(db, { tag: 'pre-delete' });
|
|
1516
|
-
|
|
1517
|
-
// Transaction: clean up related_ids references + delete (aligned with MCP mem_delete)
|
|
1518
|
-
const deletedIds = new Set(ids);
|
|
1519
|
-
const deleteTx = db.transaction(() => {
|
|
1520
|
-
const likeConditions = ids.map(() => `related_ids LIKE ?`).join(' OR ');
|
|
1521
|
-
const likeParams = ids.map(id => `%${id}%`);
|
|
1522
|
-
const referencing = db.prepare(`
|
|
1523
|
-
SELECT id, related_ids FROM observations
|
|
1524
|
-
WHERE related_ids IS NOT NULL AND related_ids != '[]' AND (${likeConditions})
|
|
1525
|
-
`).all(...likeParams);
|
|
1526
|
-
for (const r of referencing) {
|
|
1527
|
-
let refIds;
|
|
1528
|
-
try { refIds = JSON.parse(r.related_ids); } catch { continue; }
|
|
1529
|
-
if (!Array.isArray(refIds)) continue;
|
|
1530
|
-
const filtered = refIds.filter(id => !deletedIds.has(id));
|
|
1531
|
-
if (filtered.length !== refIds.length) {
|
|
1532
|
-
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
// Resurface any rows merged/compressed INTO the doomed keepers before deleting,
|
|
1536
|
-
// else they dangle behind a missing parent (compressed_into has no FK) — invisible
|
|
1537
|
-
// to every COALESCE(compressed_into,0)=0 view and unrecoverable. Same guard the
|
|
1538
|
-
// maintain hard-delete paths use (recoverChildrenOf); the interactive delete path
|
|
1539
|
-
// was missing it. Returned in the result so the user sees the recovery count.
|
|
1540
|
-
const recovered = recoverChildrenOf(db, ids);
|
|
1541
|
-
const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
|
|
1542
|
-
return { changes: deleted.changes, recovered };
|
|
1543
|
-
});
|
|
1544
|
-
const result = deleteTx();
|
|
1416
|
+
// Full delete orchestration (snapshot + related_ids cleanup + child recovery + delete
|
|
1417
|
+
// transaction) lives in lib/delete-core.mjs — single source of truth shared with the MCP
|
|
1418
|
+
// mem_delete path (was inlined here + kept in sync by parity comments, the #1 drift risk).
|
|
1419
|
+
const result = deleteObservations(db, ids);
|
|
1545
1420
|
const missing = ids.filter(id => !rows.some(r => r.id === id));
|
|
1546
|
-
const recoveredNote = result.
|
|
1547
|
-
out(`[mem] Deleted ${result.
|
|
1421
|
+
const recoveredNote = result.recoveredChildren > 0 ? ` Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.` : '';
|
|
1422
|
+
out(`[mem] Deleted ${result.deleted} observation(s).${recoveredNote}${missing.length > 0 ? ` Note: ID(s) ${missing.join(', ')} not found.` : ''}`);
|
|
1548
1423
|
}
|
|
1549
1424
|
|
|
1550
1425
|
// ─── Update ──────────────────────────────────────────────────────────────────
|
|
@@ -1599,7 +1474,7 @@ function cmdUpdate(db, args) {
|
|
|
1599
1474
|
updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative));
|
|
1600
1475
|
}
|
|
1601
1476
|
if (flags.type) {
|
|
1602
|
-
const validTypes =
|
|
1477
|
+
const validTypes = OBS_TYPE_SET;
|
|
1603
1478
|
if (!validTypes.has(flags.type)) {
|
|
1604
1479
|
fail(`[mem] Invalid type "${flags.type}". Valid: ${[...validTypes].join(', ')}`);
|
|
1605
1480
|
return;
|
|
@@ -1683,7 +1558,7 @@ function cmdExport(db, args) {
|
|
|
1683
1558
|
if (flags.type) {
|
|
1684
1559
|
// Reject unknown types — silently returning [] for `--type bogus` looked like a
|
|
1685
1560
|
// legitimate empty filter result, hiding the typo. Mirrors cmdSearch / cmdSave / cmdUpdate.
|
|
1686
|
-
const validObsTypes =
|
|
1561
|
+
const validObsTypes = OBS_TYPE_SET;
|
|
1687
1562
|
if (!validObsTypes.has(flags.type)) {
|
|
1688
1563
|
fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...validObsTypes].join(', ')}`);
|
|
1689
1564
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.49.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",
|
|
@@ -90,6 +90,11 @@
|
|
|
90
90
|
"lib/rrf.mjs",
|
|
91
91
|
"lib/compress-core.mjs",
|
|
92
92
|
"lib/db-backup.mjs",
|
|
93
|
+
"lib/delete-core.mjs",
|
|
94
|
+
"lib/events-injection.mjs",
|
|
95
|
+
"lib/stats-core.mjs",
|
|
96
|
+
"lib/obs-types.mjs",
|
|
97
|
+
"lib/save-nudge.mjs",
|
|
93
98
|
"lib/maintain-core.mjs",
|
|
94
99
|
"lib/dedup-constants.mjs",
|
|
95
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,
|
|
@@ -15,6 +15,7 @@ import { fileIntelFor } from '../lib/file-intel.mjs';
|
|
|
15
15
|
import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
|
|
16
16
|
import { recordMetric } from '../lib/metrics.mjs';
|
|
17
17
|
import { presentIdents } from '../lib/lesson-idents.mjs';
|
|
18
|
+
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
18
19
|
|
|
19
20
|
// CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
|
|
20
21
|
// whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
|
|
@@ -519,7 +520,14 @@ try {
|
|
|
519
520
|
// when the model misreads passive lesson context as a closing note.
|
|
520
521
|
lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
|
|
521
522
|
}
|
|
522
|
-
|
|
523
|
+
// MED-1 (full audit 2026-07-16): defang the injection-block delimiters in
|
|
524
|
+
// all DB/file-derived text before it enters additionalContext (which CC wraps
|
|
525
|
+
// in <system-reminder>). Stored text is raw — a lesson/title/summary carrying
|
|
526
|
+
// a literal </system-reminder> or forged <invoke ...> would break the wrapper
|
|
527
|
+
// and inject a privileged instruction. Mirrors formatErrorRecallHints, which
|
|
528
|
+
// already applies the same guard on the parallel error-recall surface. The
|
|
529
|
+
// `#id [type]` prefix is agent-generated (numeric id + type enum) — no defang.
|
|
530
|
+
if (fileIntelLine) lines.push(neutralizeContextDelimiters(fileIntelLine));
|
|
523
531
|
if (hasLessons) {
|
|
524
532
|
lines.push(`[mem] Lessons for ${fname}:`);
|
|
525
533
|
for (const r of allRows) {
|
|
@@ -527,12 +535,12 @@ try {
|
|
|
527
535
|
const lesson = r.lesson_learned.length > LESSON_MAX
|
|
528
536
|
? r.lesson_learned.slice(0, LESSON_MAX - 3) + '...'
|
|
529
537
|
: r.lesson_learned;
|
|
530
|
-
lines.push(` #${r.id} [${r.type}] ${lesson}`);
|
|
538
|
+
lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(lesson)}`);
|
|
531
539
|
} else {
|
|
532
540
|
const title = (r.title || '').length > LESSON_MAX
|
|
533
541
|
? r.title.slice(0, LESSON_MAX - 3) + '...'
|
|
534
542
|
: (r.title || '');
|
|
535
|
-
lines.push(` #${r.id} [${r.type}] ${title}`);
|
|
543
|
+
lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(title)}`);
|
|
536
544
|
}
|
|
537
545
|
}
|
|
538
546
|
// v2.98 salience: Edit/Write is the action point — close the block with an
|
|
@@ -544,7 +552,7 @@ try {
|
|
|
544
552
|
const changeText = [toolInput?.old_string, toolInput?.new_string, toolInput?.content]
|
|
545
553
|
.filter(Boolean).join('\n');
|
|
546
554
|
const bridged = await bridgeTopLesson(allRows, changeText);
|
|
547
|
-
if (bridged) lines.push(`[mem] ⚠ #${bridged.id} → this edit must: ${bridged.check}. Confirm your new code satisfies it.`);
|
|
555
|
+
if (bridged) lines.push(`[mem] ⚠ #${bridged.id} → this edit must: ${neutralizeContextDelimiters(bridged.check)}. Confirm your new code satisfies it.`);
|
|
548
556
|
else lines.push(`[mem] ⚠ Before this edit: ${ACTIVE_DIRECTIVE}`);
|
|
549
557
|
}
|
|
550
558
|
} else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
|
|
@@ -787,8 +787,11 @@ async function main() {
|
|
|
787
787
|
if (matched) {
|
|
788
788
|
const cooldown = getSkillCooldown();
|
|
789
789
|
if (!cooldown[matched]) {
|
|
790
|
+
// Registry skill names come from third-party repos (tools/adopt import) — an
|
|
791
|
+
// untrusted boundary like every other DB-derived string on this surface.
|
|
792
|
+
const safeName = neutralizeContextDelimiters(matched);
|
|
790
793
|
process.stdout.write(
|
|
791
|
-
`\n[mem] Skill "${
|
|
794
|
+
`\n[mem] Skill "${safeName}" may apply — invoke via SkillTool or run: claude-mem-lite registry show ${safeName}\n`
|
|
792
795
|
);
|
|
793
796
|
setSkillCooldown(matched);
|
|
794
797
|
}
|