claude-mem-lite 3.33.1 → 3.35.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/cli/common.mjs +66 -0
- package/format-utils.mjs +8 -4
- package/hook-handoff.mjs +6 -3
- package/hook-llm.mjs +16 -0
- package/hook.mjs +10 -1
- package/lib/citation-tracker.mjs +10 -1
- package/lib/maintain-core.mjs +23 -0
- package/lib/metrics.mjs +28 -1
- package/lib/startup-dashboard.mjs +18 -7
- package/mem-cli.mjs +15 -2
- package/package.json +1 -1
- package/project-utils.mjs +11 -0
- package/scripts/post-tool-recall.js +10 -1
- package/scripts/user-prompt-search.js +13 -5
- package/server.mjs +5 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.35.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.35.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/cli/common.mjs
CHANGED
|
@@ -89,6 +89,72 @@ export function rejectBareStringFlags(flags, keys) {
|
|
|
89
89
|
return false;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// ─── Unknown-flag typo guard ─────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Union of every flag name any CLI command reads (parseArgs silently drops the rest).
|
|
96
|
+
* Over-inclusive BY DESIGN: a flag listed here that a given command ignores just means
|
|
97
|
+
* "no typo warning for it" — harmless. The only real risk is OMITTING a valid flag, and
|
|
98
|
+
* the edit-distance gate in suggestUnknownFlags() makes even that non-fatal (a distinct
|
|
99
|
+
* real flag rarely lands within distance 2 of another). Add new flags here when adding
|
|
100
|
+
* them to a command — same maintenance contract as JSON_SUPPORTED_CMDS in mem-cli.
|
|
101
|
+
*/
|
|
102
|
+
export const KNOWN_CLI_FLAGS = new Set([
|
|
103
|
+
'after', 'age-days', 'all', 'anchor', 'batch', 'before', 'benchmark', 'body', 'branch',
|
|
104
|
+
'capability-summary', 'category', 'closes-deferred', 'concepts', 'confirm', 'days', 'deep',
|
|
105
|
+
'detail', 'domain-tags', 'dry-run', 'enrich', 'execute', 'fields', 'file', 'files', 'floors',
|
|
106
|
+
'force', 'format', 'from', 'has', 'help', 'importance', 'include-compressed', 'include-noise',
|
|
107
|
+
'intent-tags', 'invocation-name', 'json', 'key', 'keywords', 'lesson', 'lesson-learned', 'limit',
|
|
108
|
+
'local-path', 'margins', 'max', 'memdir', 'merge-ids', 'metrics', 'name', 'narrative', 'no-deep',
|
|
109
|
+
'offset', 'ops', 'or', 'out', 'priority', 'project', 'quality', 'query', 'reason', 'repo-url',
|
|
110
|
+
'rerank', 'resource-type', 'retain-days', 'retry', 'run', 'run-all', 'scope', 'session-audit',
|
|
111
|
+
'sidechain', 'since', 'sort', 'source', 'status', 'sweep', 'task', 'tech-stack', 'tier', 'title',
|
|
112
|
+
'to', 'trigger-patterns', 'type', 'use-cases', 'verbose',
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
/** Levenshtein distance, early-exit past `max` (cheap enough for a handful of flags). */
|
|
116
|
+
function editDistance(a, b, max = 2) {
|
|
117
|
+
const m = a.length, n = b.length;
|
|
118
|
+
if (Math.abs(m - n) > max) return max + 1;
|
|
119
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
120
|
+
for (let i = 1; i <= m; i++) {
|
|
121
|
+
const cur = [i];
|
|
122
|
+
let rowMin = i;
|
|
123
|
+
for (let j = 1; j <= n; j++) {
|
|
124
|
+
const d = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0));
|
|
125
|
+
cur[j] = d;
|
|
126
|
+
if (d < rowMin) rowMin = d;
|
|
127
|
+
}
|
|
128
|
+
if (rowMin > max) return max + 1; // whole row already past budget → give up
|
|
129
|
+
prev = cur;
|
|
130
|
+
}
|
|
131
|
+
return prev[n];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Detect likely-typo flags: names NOT in KNOWN_CLI_FLAGS but within edit distance 2 of
|
|
136
|
+
* a known flag. parseArgs silently drops unknown flags, so `save --improtance 3` used to
|
|
137
|
+
* persist the DEFAULT importance and `recent --projcte X` silently queried the inferred
|
|
138
|
+
* project — a typo produced a wrong result with zero signal. Returns [{flag, suggestion}].
|
|
139
|
+
* Unknown flags with NO close match are omitted: they may be a valid flag we didn't
|
|
140
|
+
* catalog, so silence beats a false alarm. Warning-only by contract — never fails.
|
|
141
|
+
* @param {object} flags Parsed flags from parseArgs.
|
|
142
|
+
* @returns {Array<{flag: string, suggestion: string}>}
|
|
143
|
+
*/
|
|
144
|
+
export function suggestUnknownFlags(flags) {
|
|
145
|
+
const result = [];
|
|
146
|
+
for (const key of Object.keys(flags)) {
|
|
147
|
+
if (!key || KNOWN_CLI_FLAGS.has(key)) continue;
|
|
148
|
+
let best = null, bestDist = 3;
|
|
149
|
+
for (const known of KNOWN_CLI_FLAGS) {
|
|
150
|
+
const d = editDistance(key, known);
|
|
151
|
+
if (d < bestDist) { bestDist = d; best = known; }
|
|
152
|
+
}
|
|
153
|
+
if (best && bestDist <= 2) result.push({ flag: key, suggestion: best });
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
92
158
|
// ─── Time Formatting ─────────────────────────────────────────────────────────
|
|
93
159
|
|
|
94
160
|
/** "just now" / "5m ago" / "3h ago" / "2d ago" relative to now. */
|
package/format-utils.mjs
CHANGED
|
@@ -37,12 +37,16 @@ export function truncate(str, max = 80) {
|
|
|
37
37
|
// block would smuggle a privileged-channel instruction / fake tool-call narrative
|
|
38
38
|
// into the model's context. It can't escape its wrapper (class 1 closers are
|
|
39
39
|
// defanged), but a nested forged authority/tool tag is still an indirect-prompt-
|
|
40
|
-
// injection vector; strip the brackets so it reads as inert text.
|
|
41
|
-
// (<
|
|
42
|
-
//
|
|
40
|
+
// injection vector; strip the brackets so it reads as inert text. Tool-call tags
|
|
41
|
+
// (<invoke \u2026>/<parameter \u2026>, bare + antml:-namespaced) are included: a prior turn's
|
|
42
|
+
// malformed tool-XML replayed through a handoff corrupted the continuation surface
|
|
43
|
+
// (mid-token truncation + model confusion), so replayed tool-XML must defang too.
|
|
44
|
+
// These carry attributes, so the match allows an optional attribute tail before the
|
|
45
|
+
// closing `>` \u2014 which also catches an attribute-bearing forgery of an authority tag
|
|
46
|
+
// (<system-reminder foo="\u2026">). Unrelated tags (<other-tag>) are left intact.
|
|
43
47
|
// Reachable by editing files that contain these tokens \u2014 e.g. developing claude-mem-lite
|
|
44
48
|
// itself, where source/observations carry the delimiter names.
|
|
45
|
-
const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results)
|
|
49
|
+
const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results|(?:antml:)?invoke|(?:antml:)?parameter)(?:\s[^>]*)?>/gi;
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
52
|
* Defang the literal context-block delimiter tags in user-derived text. Strips just the
|
package/hook-handoff.mjs
CHANGED
|
@@ -478,9 +478,12 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
478
478
|
if (summary && (summary.completed || summary.next_steps || summary.remaining_items)) {
|
|
479
479
|
lines.push('');
|
|
480
480
|
lines.push('<session-summary source="haiku">');
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
481
|
+
// Defang: these come from session_summaries, populated by Haiku OR by
|
|
482
|
+
// extractStructuredSummary over the assistant transcript tail — replayed text that can
|
|
483
|
+
// carry tool-XML / forged authority tags, same class as working_on above (audit MED-4).
|
|
484
|
+
if (summary.completed) lines.push(neutralizeContextDelimiters(summary.completed));
|
|
485
|
+
if (summary.remaining_items) lines.push(`Remaining: ${neutralizeContextDelimiters(summary.remaining_items)}`);
|
|
486
|
+
if (summary.next_steps) lines.push(`Next steps: ${neutralizeContextDelimiters(summary.next_steps)}`);
|
|
484
487
|
lines.push('</session-summary>');
|
|
485
488
|
}
|
|
486
489
|
} catch {}
|
package/hook-llm.mjs
CHANGED
|
@@ -146,6 +146,22 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
146
146
|
return null;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Paired-gate DROP at the auto-capture choke-point (v3.35). isLowYieldChangeObs
|
|
150
|
+
// (type=change + imp<2 + no real lesson — the substantive-title band isNoiseObservation
|
|
151
|
+
// misses) previously ran ONLY on the LLM-success path (handleLLMEpisode). The pre-save
|
|
152
|
+
// write here and the LLM-failure fallback (which keeps the pre-saved row) both bypassed
|
|
153
|
+
// it, so template change-rows survived on LLM failure. Gate covers all three write paths.
|
|
154
|
+
// Runs BEFORE capNoiseImportance: the pre-assigned importance IS the rule signal for a
|
|
155
|
+
// provisional pre-save — an imp>=2 rule-triggered episode (config/error change) must land
|
|
156
|
+
// so it stays visible immediately, survives a total LLM failure (the keep-pre-saved
|
|
157
|
+
// branch), and keeps its original created_at for the later in-place upgrade. (A dropped
|
|
158
|
+
// pre-save is not lost on LLM success — that path clean-inserts a fresh row — but it loses
|
|
159
|
+
// those three.) capNoiseImportance then caps any title-noise survivor to imp=1 as before.
|
|
160
|
+
if (isLowYieldChangeObs(obs)) {
|
|
161
|
+
debugLog('saveObservation', `dropped low-yield change: ${truncate(obs.title || '', 60)}`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
149
165
|
// v2.47 P0-3: importance cap for LOW_SIGNAL titles that kept the drop gate
|
|
150
166
|
// open via importance>=2 but carry no lesson/facts signal. 341 rows in live
|
|
151
167
|
// DB had imp=3 under these conditions (99.4% noise). Cap to 1 so they
|
package/hook.mjs
CHANGED
|
@@ -47,7 +47,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
|
|
|
47
47
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
48
48
|
import { formatHookError } from './lib/native-binding-hint.mjs';
|
|
49
49
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
50
|
-
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren } from './lib/maintain-core.mjs';
|
|
50
|
+
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons } from './lib/maintain-core.mjs';
|
|
51
51
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
52
52
|
import {
|
|
53
53
|
extractCitationsFromTranscript,
|
|
@@ -62,6 +62,7 @@ import { extractTailAssistantText, extractStructuredSummary } from './lib/summar
|
|
|
62
62
|
import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
|
|
63
63
|
import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
64
64
|
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
65
|
+
import { gcOldMetricShards } from './lib/metrics.mjs';
|
|
65
66
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
66
67
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
67
68
|
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
@@ -795,6 +796,11 @@ function runSessionStartAutoMaintain(db) {
|
|
|
795
796
|
const orphansRecovered = recoverOrphanedChildren(db, mctx);
|
|
796
797
|
if (orphansRecovered > 0) debugLog('DEBUG', 'auto-maintain', `recovered ${orphansRecovered} orphaned compression children`);
|
|
797
798
|
|
|
799
|
+
// Heal lesson rows citation-decay buried at importance 0 under the old floor=0.
|
|
800
|
+
// Non-destructive (0→1 on lesson-bearing rows only); idempotent no-op once none remain.
|
|
801
|
+
const lessonsHealed = recoverBuriedLessons(db, mctx);
|
|
802
|
+
if (lessonsHealed > 0) debugLog('DEBUG', 'auto-maintain', `healed ${lessonsHealed} lesson rows buried at importance 0`);
|
|
803
|
+
|
|
798
804
|
const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
|
|
799
805
|
if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
|
|
800
806
|
if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
|
|
@@ -1098,6 +1104,9 @@ async function handleSessionStart() {
|
|
|
1098
1104
|
gcStalePreRecallCooldowns();
|
|
1099
1105
|
// Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
|
|
1100
1106
|
try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1107
|
+
// Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
|
|
1108
|
+
// metrics are disabled, so shards left by a since-toggled-off run still get pruned.
|
|
1109
|
+
try { gcOldMetricShards(join(RUNTIME_DIR, '..')); } catch { /* best-effort */ }
|
|
1101
1110
|
|
|
1102
1111
|
// Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
|
|
1103
1112
|
// re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -472,7 +472,16 @@ export function hasMainThreadAssistantText(transcriptPath) {
|
|
|
472
472
|
}
|
|
473
473
|
|
|
474
474
|
const IMPORTANCE_CAP = 3;
|
|
475
|
-
|
|
475
|
+
// Demote floor = 1, NOT 0. Both passive injection surfaces exclude importance 0
|
|
476
|
+
// (pre-tool-recall.js requires >=2, user-prompt-search.js requires >=1), so a row
|
|
477
|
+
// demoted to 0 can never be re-injected → never re-cited → never recovers: a one-way
|
|
478
|
+
// burial that silently hides lesson-bearing rows the "lessons never auto-GC" guards
|
|
479
|
+
// (maintain decayAndMarkIdle + compress-core) protect on every other path. Floor 1
|
|
480
|
+
// keeps a decayed row on the >=1 surface with a citation-recovery path. Genuine noise
|
|
481
|
+
// still sinks via maintain's PENDING_PURGE pipeline, which keys on compressed_into
|
|
482
|
+
// (not importance) over injection_count=0 rows — a disjoint population from these
|
|
483
|
+
// injected-but-uncited rows — so noise GC is unaffected.
|
|
484
|
+
const IMPORTANCE_FLOOR = 1;
|
|
476
485
|
const UNCITED_STREAK_THRESHOLD = 3;
|
|
477
486
|
|
|
478
487
|
// Adoption-rate gate (P5 ②). A project's cite-rate is SUM(cited_count) /
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -117,6 +117,29 @@ export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [
|
|
|
117
117
|
`).run(...baseParams).changes;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// Heal lesson-bearing rows that citation-decay buried at importance 0 under the old
|
|
121
|
+
// IMPORTANCE_FLOOR=0 (fixed in citation-tracker.mjs → floor 1). All passive injection
|
|
122
|
+
// surfaces exclude importance 0 (pre-tool-recall >=2, user-prompt-search >=1, memory-context
|
|
123
|
+
// >=1), so a lesson demoted there is invisible AND — being injection_count>0 by construction
|
|
124
|
+
// — sits in no GC queue either (decayAndMarkIdle only marks injection_count=0 rows): stranded
|
|
125
|
+
// out of reach with its distilled lesson. Lifting to 1 restores >=1-surface visibility + a
|
|
126
|
+
// citation-recovery path. NON-DESTRUCTIVE (only 0→1 on lesson-bearing rows, never
|
|
127
|
+
// deletes/hides), idempotent (a no-op once no imp-0 lesson rows remain), so safe to run
|
|
128
|
+
// unconditionally alongside recoverOrphanedChildren. `superseded_at IS NULL` mirrors the
|
|
129
|
+
// injection surfaces' own filter (pre-tool-recall:368, memory-context:217) — a de-dup loser
|
|
130
|
+
// (auto-dedup sets superseded_at but leaves compressed_into=0) must NOT be lifted back into
|
|
131
|
+
// injectability. Non-lesson imp-0 rows are left buried (low-value, not worth resurfacing).
|
|
132
|
+
export function recoverBuriedLessons(db, { projectFilter = '', baseParams = [] } = {}) {
|
|
133
|
+
return db.prepare(`
|
|
134
|
+
UPDATE observations SET importance = 1
|
|
135
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
136
|
+
AND COALESCE(importance, 1) = 0
|
|
137
|
+
AND superseded_at IS NULL
|
|
138
|
+
AND lesson_learned IS NOT NULL AND lesson_learned <> '' AND lower(lesson_learned) <> 'none'
|
|
139
|
+
${projectFilter}
|
|
140
|
+
`).run(...baseParams).changes;
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
|
|
121
144
|
const doomed = db.prepare(`
|
|
122
145
|
SELECT id FROM observations
|
package/lib/metrics.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// skip malformed lines silently. The module imports nothing heavy so hook
|
|
17
17
|
// cold-start pays near-zero when metrics are disabled.
|
|
18
18
|
|
|
19
|
-
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs';
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'fs';
|
|
20
20
|
import { join } from 'path';
|
|
21
21
|
|
|
22
22
|
const DAY_MS = 86400000;
|
|
@@ -68,6 +68,33 @@ export function timed(dbDir, event, fn, extra = {}) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Prune metric daily shards older than `retainDays`. recordMetric writes one
|
|
73
|
+
* YYYY-MM-DD.jsonl per day with no GC, so a long-lived CLAUDE_MEM_METRICS=1 install
|
|
74
|
+
* grows the dir unbounded. 90d keeps a full quarter for aggregate windows while
|
|
75
|
+
* bounding the dir. Runs regardless of the enable flag so a user who toggles metrics
|
|
76
|
+
* OFF still gets old shards cleaned. Shard date read from the filename (ISO dates sort
|
|
77
|
+
* lexicographically = chronologically). Best-effort, never throws — called from the
|
|
78
|
+
* SessionStart GC sweep (mirrors registry-recommend.gcOldShadowShards).
|
|
79
|
+
* @returns {number} shards removed
|
|
80
|
+
*/
|
|
81
|
+
export function gcOldMetricShards(dbDir, retainDays = 90) {
|
|
82
|
+
try {
|
|
83
|
+
if (!dbDir) return 0;
|
|
84
|
+
const dir = join(dbDir, 'metrics');
|
|
85
|
+
if (!existsSync(dir)) return 0;
|
|
86
|
+
const cutoff = new Date(Date.now() - retainDays * DAY_MS).toISOString().slice(0, 10);
|
|
87
|
+
let removed = 0;
|
|
88
|
+
for (const name of readdirSync(dir)) {
|
|
89
|
+
const m = /^(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
|
|
90
|
+
if (m && m[1] < cutoff) {
|
|
91
|
+
try { unlinkSync(join(dir, name)); removed++; } catch { /* per-entry, silent */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return removed;
|
|
95
|
+
} catch { return 0; }
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
/** Count of days to aggregate by default in readMetrics / aggregate. */
|
|
72
99
|
export const DEFAULT_WINDOW_DAYS = 7;
|
|
73
100
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { readGitState } from './git-state.mjs';
|
|
9
9
|
import { readProjectTasks } from './task-reader.mjs';
|
|
10
10
|
import { recentPlans } from './plan-reader.mjs';
|
|
11
|
-
import { isAdoptedHere } from '../hook-shared.mjs';
|
|
11
|
+
import { isAdoptedHere, HANDOFF_EXPIRY_EXIT } from '../hook-shared.mjs';
|
|
12
12
|
|
|
13
13
|
function ageStr(ms) {
|
|
14
14
|
const diff = Date.now() - ms;
|
|
@@ -22,11 +22,16 @@ function ageStr(ms) {
|
|
|
22
22
|
|
|
23
23
|
function readRecentHandoff(db, project) {
|
|
24
24
|
try {
|
|
25
|
+
// Only surface a handoff the UserPromptSubmit injection could actually deliver: apply
|
|
26
|
+
// the SAME expiry the injection's pickHandoffToInject enforces (HANDOFF_EXPIRY_EXIT).
|
|
27
|
+
// Without this the dashboard promised "context injects on your next message" for an
|
|
28
|
+
// exit handoff >7d old that pickHandoffToInject filters as expired → the promise could
|
|
29
|
+
// never be kept (batch2-reviewer Finding 1).
|
|
25
30
|
return db.prepare(`
|
|
26
31
|
SELECT created_at_epoch, working_on FROM session_handoffs
|
|
27
|
-
WHERE project = ? AND type = 'exit'
|
|
32
|
+
WHERE project = ? AND type = 'exit' AND created_at_epoch > ?
|
|
28
33
|
ORDER BY created_at_epoch DESC LIMIT 1
|
|
29
|
-
`).get(project);
|
|
34
|
+
`).get(project, Date.now() - HANDOFF_EXPIRY_EXIT);
|
|
30
35
|
} catch { return null; }
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -82,10 +87,16 @@ export function buildDashboard({ db, project, projectPath = process.cwd(), stubs
|
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
if (handoff) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
// Continuation POINTER only. The working_on CONTENT is delivered once — by the
|
|
91
|
+
// UserPromptSubmit <session-handoff> block on a continuation-intent prompt
|
|
92
|
+
// (renderHandoffInjection). Emitting the teaser here too double-injected working_on
|
|
93
|
+
// into the model's context on every resume (post-defang: redundant, not unsafe).
|
|
94
|
+
// Wording is CONDITIONAL ("resume it to restore"), not a promise of automatic
|
|
95
|
+
// injection: the injection is gated on continuation-intent, so on a genuinely new
|
|
96
|
+
// first task the gate stays silent (stale working_on correctly not injected) and the
|
|
97
|
+
// pointer must not claim otherwise. readRecentHandoff already drops handoffs older than
|
|
98
|
+
// the injection's own expiry, so a shown pointer is always deliverable on resume.
|
|
99
|
+
parts.push(`🔄 Continuation available (/exit ${ageStr(handoff.created_at_epoch)}) — resume it to restore the full context.`);
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
if (eventCount > 0) {
|
package/mem-cli.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
|
|
|
18
18
|
import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
|
|
19
19
|
import {
|
|
20
20
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
21
|
-
recoverOrphanedChildren,
|
|
21
|
+
recoverOrphanedChildren, recoverBuriedLessons,
|
|
22
22
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
23
23
|
recoverChildrenOf, hardDeleteCandidateCount,
|
|
24
24
|
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
@@ -37,7 +37,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
|
37
37
|
// v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
|
|
38
38
|
// router + remaining-command bodies during the incremental split. Future work:
|
|
39
39
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
40
|
-
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
|
|
40
|
+
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
|
|
41
41
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
42
42
|
import { rebuildObservationDerived } from './lib/observation-write.mjs';
|
|
43
43
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
@@ -1972,6 +1972,10 @@ function cmdMaintain(db, args) {
|
|
|
1972
1972
|
// unreachable children. Non-destructive — un-hide only, no delete.
|
|
1973
1973
|
const orphans = recoverOrphanedChildren(db, mctx);
|
|
1974
1974
|
if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
|
|
1975
|
+
// Heal lesson rows citation-decay buried at importance 0 (pre floor=1). 0→1 on
|
|
1976
|
+
// lesson-bearing rows only; idempotent no-op once none remain.
|
|
1977
|
+
const lessonsHealed = recoverBuriedLessons(db, mctx);
|
|
1978
|
+
if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
|
|
1975
1979
|
}
|
|
1976
1980
|
|
|
1977
1981
|
if (ops.includes('decay')) {
|
|
@@ -3007,6 +3011,15 @@ export async function run(argv) {
|
|
|
3007
3011
|
return;
|
|
3008
3012
|
}
|
|
3009
3013
|
|
|
3014
|
+
// Typo guard: parseArgs silently DROPS unknown flags, so `save --improtance 3` used to
|
|
3015
|
+
// persist the DEFAULT importance and `recent --projcte X` silently queried the inferred
|
|
3016
|
+
// project — a misspelled flag changed results with zero signal. Warn (stderr, non-fatal)
|
|
3017
|
+
// when a flag looks like a misspelling of a real one; stdout + exit code stay untouched,
|
|
3018
|
+
// so JSON/text consumers are unaffected. Mirrors the unknown-COMMAND suggester in cli.mjs.
|
|
3019
|
+
for (const { flag, suggestion } of suggestUnknownFlags(parseArgs(cmdArgs).flags)) {
|
|
3020
|
+
process.stderr.write(`[mem] Unknown flag --${flag}; did you mean --${suggestion}?\n`);
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3010
3023
|
// adopt / unadopt do pure filesystem work on ~/.claude/projects/<encoded>/memory/ —
|
|
3011
3024
|
// no DB needed. Route them before ensureDb() so an unbootable DB doesn't block.
|
|
3012
3025
|
if (cmd === 'adopt') { cmdAdopt(cmdArgs); return; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.35.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",
|
package/project-utils.mjs
CHANGED
|
@@ -32,6 +32,17 @@ export function resolveProject(db, name) {
|
|
|
32
32
|
).get(`%--${name}`);
|
|
33
33
|
if (suffixed) { _cache.set(name, suffixed.project); return suffixed.project; }
|
|
34
34
|
|
|
35
|
+
// 1.5) Exact-name match: a project literally named "p" (e.g. inferProject() at a
|
|
36
|
+
// filesystem-root cwd yields no "--", or a manually-saved bare name). MUST beat the
|
|
37
|
+
// fuzzy prefix/substring fallbacks below — otherwise `%p%` matches every "projects--*"
|
|
38
|
+
// row and ORDER BY COUNT(*) returns the biggest UNRELATED project, making the exact
|
|
39
|
+
// project permanently unreachable via --project. Ranks below step 1 only, preserving
|
|
40
|
+
// the documented "prefer canonical parent--name over a stray short name" intent.
|
|
41
|
+
const exact = db.prepare(
|
|
42
|
+
'SELECT project FROM observations WHERE project = ? LIMIT 1'
|
|
43
|
+
).get(name);
|
|
44
|
+
if (exact) { _cache.set(name, exact.project); return exact.project; }
|
|
45
|
+
|
|
35
46
|
// 2) Prefix-in-suffix match: "code-graph" → "projects--code-graph-mcp"
|
|
36
47
|
const prefixed = db.prepare(
|
|
37
48
|
'SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1'
|
|
@@ -68,4 +68,13 @@ async function main() {
|
|
|
68
68
|
}));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
// No forced process.exit(0): main() consumes stdin to EOF (or early-returns without
|
|
72
|
+
// touching it) and holds no open handles (no DB, no timers), so the event loop drains
|
|
73
|
+
// and the process exits 0 on its own — which FLUSHES the stdout nudge above. A forced
|
|
74
|
+
// exit could drop that pending async write on a piped stdout (the v3.33.1 gotcha the
|
|
75
|
+
// payload-bearing sibling hooks avoid). catch() keeps the exit code 0.
|
|
76
|
+
// Swallow EPIPE: if Claude Code closes the read end before the async write drains, the
|
|
77
|
+
// stream emits 'error' — without the (now-removed) forced exit, an unhandled one would
|
|
78
|
+
// surface as a non-zero exit + stack. A hook must never fail loud on a dropped pipe.
|
|
79
|
+
process.stdout.on('error', () => {});
|
|
80
|
+
main().catch(() => {});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
|
|
5
5
|
|
|
6
6
|
import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
|
|
7
|
-
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate } from '../utils.mjs';
|
|
7
|
+
import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate, neutralizeContextDelimiters } from '../utils.mjs';
|
|
8
8
|
import { citeFactorClause } from '../scoring-sql.mjs';
|
|
9
9
|
import { cjkPrecisionOk } from '../nlp.mjs';
|
|
10
10
|
import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
|
|
@@ -265,7 +265,7 @@ export function rowMatchesIdentifier(row, idsLower) {
|
|
|
265
265
|
// Each row includes `bm25_raw` (pre-multiplier bm25 magnitude) alongside the
|
|
266
266
|
// composite `relevance`, so callers can distinguish raw-match strength from
|
|
267
267
|
// importance/type/decay inflation.
|
|
268
|
-
function searchByFts(db, queryText, project, limit, typeFilter) {
|
|
268
|
+
export function searchByFts(db, queryText, project, limit, typeFilter) {
|
|
269
269
|
const ftsQuery = sanitizeFtsQuery(queryText);
|
|
270
270
|
if (!ftsQuery) return { rows: [], mode: null };
|
|
271
271
|
|
|
@@ -299,6 +299,7 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
|
|
|
299
299
|
AND o.importance >= 1
|
|
300
300
|
AND o.created_at_epoch > ?
|
|
301
301
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
302
|
+
AND o.superseded_at IS NULL
|
|
302
303
|
AND ${notLowSignalTitleClause('o')}
|
|
303
304
|
${typeClause}
|
|
304
305
|
ORDER BY relevance
|
|
@@ -345,6 +346,7 @@ function searchByFile(db, files, project, limit) {
|
|
|
345
346
|
WHERE o.project = ?
|
|
346
347
|
AND o.importance >= 1
|
|
347
348
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
349
|
+
AND o.superseded_at IS NULL
|
|
348
350
|
AND o.created_at_epoch > ?
|
|
349
351
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
350
352
|
AND ${notLowSignalTitleClause('o')}
|
|
@@ -420,6 +422,7 @@ function searchRecent(db, project, limit) {
|
|
|
420
422
|
WHERE project = ?
|
|
421
423
|
AND importance >= 1
|
|
422
424
|
AND COALESCE(compressed_into, 0) = 0
|
|
425
|
+
AND superseded_at IS NULL
|
|
423
426
|
AND created_at_epoch > ?
|
|
424
427
|
AND ${notLowSignalTitleClause('')}
|
|
425
428
|
ORDER BY created_at_epoch DESC
|
|
@@ -464,8 +467,10 @@ function formatResults(rows) {
|
|
|
464
467
|
const lines = ['[mem] FYI — Related memories (continue your task):'];
|
|
465
468
|
for (const r of rows) {
|
|
466
469
|
const icon = typeIcon(r.type);
|
|
467
|
-
|
|
468
|
-
|
|
470
|
+
// Defang replayed obs text before truncation: a poisoned title/lesson carrying tool-XML
|
|
471
|
+
// or a forged authority tag must not render as a live delimiter in this injected block.
|
|
472
|
+
const title = truncate(neutralizeContextDelimiters(r.title || ''), 70);
|
|
473
|
+
const lesson = !QUIET_HOOKS && r.lesson_learned ? ` — ${truncate(neutralizeContextDelimiters(r.lesson_learned), 50)}` : '';
|
|
469
474
|
lines.push(`#${r.id} ${icon} ${title}${lesson}`);
|
|
470
475
|
}
|
|
471
476
|
return lines.join('\n');
|
|
@@ -479,7 +484,10 @@ function formatPromptResults(rows) {
|
|
|
479
484
|
if (!rows || rows.length === 0) return null;
|
|
480
485
|
const lines = ['[mem] FYI — Past similar questions (continue your task):'];
|
|
481
486
|
for (const r of rows) {
|
|
482
|
-
|
|
487
|
+
// prompt_text is a raw prior USER prompt — the highest-risk replayed class (this is
|
|
488
|
+
// exactly the column that carried malformed tool-XML into the handoff bug). Defang the
|
|
489
|
+
// delimiters, then collapse whitespace + truncate.
|
|
490
|
+
const text = truncate(neutralizeContextDelimiters(r.prompt_text || '').replace(/\s+/g, ' '), 80);
|
|
483
491
|
lines.push(`P#${r.id} 💬 ${text}`);
|
|
484
492
|
}
|
|
485
493
|
return lines.join('\n');
|
package/server.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
|
|
|
17
17
|
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
18
18
|
import {
|
|
19
19
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
20
|
-
recoverOrphanedChildren,
|
|
20
|
+
recoverOrphanedChildren, recoverBuriedLessons,
|
|
21
21
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
22
22
|
recoverChildrenOf, hardDeleteCandidateCount,
|
|
23
23
|
OP_CAP, STALE_AGE_MS,
|
|
@@ -1139,6 +1139,10 @@ server.registerTool(
|
|
|
1139
1139
|
// resurface unreachable children. Non-destructive — un-hide only, no delete.
|
|
1140
1140
|
const orphans = recoverOrphanedChildren(db, mctx);
|
|
1141
1141
|
if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
|
|
1142
|
+
// Heal lesson rows citation-decay buried at importance 0 (pre floor=1). 0→1 on
|
|
1143
|
+
// lesson-bearing rows only; idempotent no-op once none remain.
|
|
1144
|
+
const lessonsHealed = recoverBuriedLessons(db, mctx);
|
|
1145
|
+
if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
|
|
1142
1146
|
}
|
|
1143
1147
|
|
|
1144
1148
|
if (ops.includes('decay')) {
|