claude-mem-lite 3.75.1 → 3.76.1
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/adopt-content.mjs +1 -1
- package/hook.mjs +31 -1
- package/lib/maintain-core.mjs +94 -10
- package/mem-cli.mjs +25 -12
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/launch.mjs +8 -1
- package/server.mjs +4 -4
- package/tool-schemas.mjs +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.76.1",
|
|
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.76.1",
|
|
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/adopt-content.mjs
CHANGED
|
@@ -159,7 +159,7 @@ PreToolUse hook 在你 Read / Edit / Write 文件前已自动 \`mem_recall\` 该
|
|
|
159
159
|
| 改某条 | \`${CLI_INVOKE} update <id> [--lesson "<≤500>"] [--title T] [--type T] [--importance 1-3] [--narrative T] [--concepts "a b c"]\` |
|
|
160
160
|
| 事件日志 | \`${CLI_INVOKE} activity save --type <bugfix\\|lesson\\|bug\\|discovery\\|refactor\\|feature\\|observation\\|decision> "<title>" [--body T] [--files f1,f2]\` |
|
|
161
161
|
|
|
162
|
-
\`maintain\` / \`optimize\` / \`compress\` 见上方「维护 / 管理类工具」;\`maintain --ops\` 取值 \`cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum
|
|
162
|
+
\`maintain\` / \`optimize\` / \`compress\` 见上方「维护 / 管理类工具」;\`maintain --ops\` 取值 \`cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum\`,省略时默认 \`cleanup,decay,boost,demote_pinned\`(顺序有意义:demote_pinned 必须在 boost 之后);\`--retain-days\` ∈ [7,365]。
|
|
163
163
|
|
|
164
164
|
## 卸载 / 关闭
|
|
165
165
|
|
package/hook.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { formatHookError } from './lib/native-binding-hint.mjs';
|
|
|
50
50
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
51
51
|
import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
|
|
52
52
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
53
|
-
import { cleanupBroken, decayAndMarkIdle, boostAccessed, markAutoCompressible, selectFuzzyDedupeIds, stampDedupSuperseded, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
|
|
53
|
+
import { cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, resolveDefaultMaintainOps, markAutoCompressible, selectFuzzyDedupeIds, stampDedupSuperseded, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
|
|
54
54
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
55
55
|
import {
|
|
56
56
|
extractCitationsFromTranscript,
|
|
@@ -1122,6 +1122,36 @@ function runSessionStartAutoMaintain(db, project) {
|
|
|
1122
1122
|
}
|
|
1123
1123
|
}
|
|
1124
1124
|
|
|
1125
|
+
// v3.76.0: the automatic path used to promote and never demote. boostAccessed ran
|
|
1126
|
+
// above; demotePinned was not even imported here, and it sat outside the default op
|
|
1127
|
+
// set of the CLI and MCP faces too — so the only op that can reach a
|
|
1128
|
+
// heavily-injected-but-uncited row (regular decay deliberately protects
|
|
1129
|
+
// injection_count>0) ran solely when a human typed `--ops demote_pinned`. Measured
|
|
1130
|
+
// on the maintainer's live DB before the fix: 148 rows demoted by citation decay,
|
|
1131
|
+
// never cited, and back at importance>=3, 148/148 of them boost-eligible.
|
|
1132
|
+
//
|
|
1133
|
+
// Placed AFTER boost, for the obvious reason: boostAccessed lifts any
|
|
1134
|
+
// access_count>3 row with importance<3, so demoting first hands the row straight
|
|
1135
|
+
// back at 2 (mem-cli had exactly that order and silently undid its own demotion
|
|
1136
|
+
// inside a single run).
|
|
1137
|
+
//
|
|
1138
|
+
// Placed AFTER fuzzy dedup, for a less obvious one, found by pre-tag review: the
|
|
1139
|
+
// dedup block above re-SELECTs `importance` and selectFuzzyDedupeIds keeps the
|
|
1140
|
+
// higher-importance member of a near-duplicate pair. Demoting first inverted that
|
|
1141
|
+
// rule using a value rewritten 40 lines earlier in the same pass — the pinned row
|
|
1142
|
+
// lost and was tombstoned, keeping the copy WITHOUT the injection history. Dedup
|
|
1143
|
+
// now decides on pre-demotion importance. Nothing between boost and here reads
|
|
1144
|
+
// importance, so the move is free.
|
|
1145
|
+
//
|
|
1146
|
+
// Whole-DB mctx (projectFilter ''), so unlike markAutoCompressibleIfDue this needs
|
|
1147
|
+
// NO per-project gate: one run under the global 24h gate covers every project at
|
|
1148
|
+
// once. Do not "fix" it into a per-project gate — that is the v3.75.0 regression in
|
|
1149
|
+
// reverse.
|
|
1150
|
+
const demotedPinned = resolveDefaultMaintainOps().includes('demote_pinned')
|
|
1151
|
+
? demotePinned(db, mctx)
|
|
1152
|
+
: 0;
|
|
1153
|
+
if (demotedPinned > 0) debugLog('DEBUG', 'auto-maintain', `demoted ${demotedPinned} pinned-but-uncited observations (no lesson → 1, lesson → 2)`);
|
|
1154
|
+
|
|
1125
1155
|
// Orphan sweep: remove `ep-flush-*` / `pending-*` runtime files older
|
|
1126
1156
|
// than 1h. handleLLMEpisode normally unlinks its own tmpFile on every
|
|
1127
1157
|
// exit path, but a crashed worker (OOM, host reboot, kill -9) leaves
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -30,6 +30,45 @@ export const MINHASH_PRE_THRESHOLD = MINHASH_PRE_THRESHOLD_SRC;
|
|
|
30
30
|
// the regular decay op can't touch (decay protects injection_count>0).
|
|
31
31
|
export const PINNED_INJ_THRESHOLD = 8;
|
|
32
32
|
|
|
33
|
+
// Single home for the default maintenance op set AND its order.
|
|
34
|
+
//
|
|
35
|
+
// Three faces run maintenance — hook.mjs auto-maintain, CLI `maintain execute`,
|
|
36
|
+
// MCP `mem_maintain` — and each used to hand-list its own default set. They had
|
|
37
|
+
// drifted in both ways a hand-copied list can:
|
|
38
|
+
// - `demote_pinned` was in NOBODY's default set, and hook.mjs did not even
|
|
39
|
+
// import demotePinned. Its opponent `boostAccessed` was in all three. So the
|
|
40
|
+
// automatic path promoted and never demoted: measured on the maintainer's live
|
|
41
|
+
// DB, 148 rows sat demoted-by-citation-decay, never cited, and back at
|
|
42
|
+
// importance>=3 — 148/148 of them boostAccessed-eligible (access_count>3).
|
|
43
|
+
// - the two faces that DID wire the op ran it in opposite orders: mem-cli did
|
|
44
|
+
// demote-then-boost, which hands the row straight back (importance 1 → 2);
|
|
45
|
+
// server.mjs did boost-then-demote, which lands it at 1.
|
|
46
|
+
// Hence: order matters, and `demote_pinned` MUST come after `boost`.
|
|
47
|
+
export const DEFAULT_MAINTAIN_OPS = Object.freeze(['cleanup', 'decay', 'boost', 'demote_pinned']);
|
|
48
|
+
|
|
49
|
+
// Opt-out for the v3.76.0 default change. Scoped to the DEFAULT set ONLY — an
|
|
50
|
+
// explicit `--ops demote_pinned` / `operations:["demote_pinned"]` still runs. An
|
|
51
|
+
// accepted value that silently means something else is worse than an unsupported
|
|
52
|
+
// one (cf. CLAUDE_MEM_RECOMMEND_MODE=live, which parses and then does not do what
|
|
53
|
+
// it says).
|
|
54
|
+
// The first cut of this compared `=== '1'`, which silently ignored `=true` / `=yes` /
|
|
55
|
+
// `= 1` — precisely the failure mode the comment above warns about, committed three
|
|
56
|
+
// lines under it. Sibling skip-flags in this repo are bare truthiness checks
|
|
57
|
+
// (`if (!process.env.CLAUDE_MEM_SKIP_COMPRESS)`), so any non-empty value opts out;
|
|
58
|
+
// the falsey WORDS are honoured too, because `=0` or `=false` reading as "skip" is the
|
|
59
|
+
// same class of silent surprise in the other direction.
|
|
60
|
+
function envFlagEnabled(raw) {
|
|
61
|
+
if (raw === undefined || raw === null) return false;
|
|
62
|
+
const v = String(raw).trim().toLowerCase();
|
|
63
|
+
return v !== '' && v !== '0' && v !== 'false' && v !== 'no' && v !== 'off';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveDefaultMaintainOps(env = process.env) {
|
|
67
|
+
return envFlagEnabled(env?.CLAUDE_MEM_SKIP_DEMOTE_PINNED)
|
|
68
|
+
? DEFAULT_MAINTAIN_OPS.filter((op) => op !== 'demote_pinned')
|
|
69
|
+
: [...DEFAULT_MAINTAIN_OPS];
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
// Two trimmed bodies count as "the same body" when both are empty (a genuine
|
|
34
73
|
// no-body re-save) or their word-set Jaccard clears the floor. One-empty-one-not
|
|
35
74
|
// is treated as DISTINCT so a body-bearing observation is never hidden by a
|
|
@@ -158,10 +197,23 @@ export function markAutoCompressible(db, project, {
|
|
|
158
197
|
// The write-side capNoiseImportance already forces imp=1 on these; this only shrinks GC
|
|
159
198
|
// latency so the corpus reduction materializes within a week instead of bleeding into
|
|
160
199
|
// the 30-day tier.
|
|
200
|
+
//
|
|
201
|
+
// v3.76.0: `injection_count = 0` added here to match the aged pass above, which has
|
|
202
|
+
// carried it since v2.56.0. Until this release nothing could reach BOTH `importance<=1`
|
|
203
|
+
// and `injection_count>=1`, so the omission was unobservable; `demote_pinned` joining
|
|
204
|
+
// the default op set creates exactly that population. Without this clause a demoted row
|
|
205
|
+
// could be marked COMPRESSED_AUTO on the NEXT maintain run — hidden from every
|
|
206
|
+
// `COALESCE(compressed_into,0)=0` read path, therefore never injected, therefore never
|
|
207
|
+
// cited, therefore with no path back. Pre-tag review reproduced that chain end to end.
|
|
208
|
+
// Today the two passes are also protected by an interlock — the title patterns below
|
|
209
|
+
// are the same set `notLowSignalTitleClause` (lib/low-signal-patterns.mjs) keeps off the
|
|
210
|
+
// only surfaces that bump `injection_count` — but that is two hand-listed sets in two
|
|
211
|
+
// files agreeing by maintenance, which is not a guarantee. This clause is.
|
|
161
212
|
const noise = db.prepare(`
|
|
162
213
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
163
214
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
164
215
|
AND COALESCE(importance, 1) <= 1
|
|
216
|
+
AND COALESCE(injection_count, 0) = 0
|
|
165
217
|
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
166
218
|
AND (facts IS NULL OR facts = '' OR facts = '[]')
|
|
167
219
|
AND (
|
|
@@ -340,21 +392,55 @@ export function boostAccessed(db, { projectFilter, baseParams, opCap = OP_CAP })
|
|
|
340
392
|
`).run(...baseParams).changes;
|
|
341
393
|
}
|
|
342
394
|
|
|
395
|
+
// Every other automatic pass in this file carries this clause verbatim (lines 179, 192,
|
|
396
|
+
// 299, 334) — a lesson is the distilled value a lessons store exists to hold, and
|
|
397
|
+
// background machinery must not quietly dispose of one.
|
|
398
|
+
const NO_LESSON_SQL = "(lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')";
|
|
399
|
+
|
|
400
|
+
// The floor demotePinned writes, and therefore also the bound that decides whether a row
|
|
401
|
+
// still HAS anything to demote. One home: v3.76.0 shipped with the floor here and the
|
|
402
|
+
// forecast in maintenanceStats still hard-coded `importance > 1`, so a lesson row already
|
|
403
|
+
// at 2 was counted as pinned forever while every execute reported "Demoted 0" — the exact
|
|
404
|
+
// scan-forecast-vs-execute drift the decayAndMarkIdle comment below exists to prevent.
|
|
405
|
+
const PINNED_FLOOR_SQL = `(CASE WHEN ${NO_LESSON_SQL} THEN 1 ELSE 2 END)`;
|
|
406
|
+
// Shared by the op and its forecast. Callers add their own compressed_into/project filter.
|
|
407
|
+
// Module-private on purpose: nothing outside this file consumes it, and exporting by habit
|
|
408
|
+
// is how the knip baseline drifts (cf. the v3.70.0 DEFAULT_MARKETPLACE trio).
|
|
409
|
+
const PINNED_PREDICATE_SQL = `
|
|
410
|
+
COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
|
|
411
|
+
AND COALESCE(cited_count, 0) = 0
|
|
412
|
+
AND COALESCE(importance, 1) > ${PINNED_FLOOR_SQL}`;
|
|
413
|
+
|
|
343
414
|
/**
|
|
344
415
|
* Repair the citation-decay blind spot: heavy-injection + zero-citation rows that
|
|
345
|
-
* decay protects (injection_count>0) stay pinned at max importance forever.
|
|
346
|
-
* them
|
|
347
|
-
*
|
|
416
|
+
* decay protects (injection_count>0) stay pinned at max importance forever. Floor
|
|
417
|
+
* them; never purge.
|
|
418
|
+
*
|
|
419
|
+
* TWO floors, and the asymmetry is the point. `importance >= 2` is a hard WHERE on the
|
|
420
|
+
* injection faces that actually earn citations — pre-tool-recall
|
|
421
|
+
* (scripts/pre-tool-recall.js:428,470), SessionStart Key Context (hook-context.mjs:95,315)
|
|
422
|
+
* and cross-project (hook-memory.mjs:280) — whereas `injection_count`, the signal that
|
|
423
|
+
* triggers this op at all, is incremented ONLY on the two UserPromptSubmit faces
|
|
424
|
+
* (hook-memory.mjs:367, scripts/user-prompt-search.js:979). Those are the weakest faces
|
|
425
|
+
* by measured cite-rate. Dropping straight to 1 therefore convicts a row on its weakest
|
|
426
|
+
* surface and evicts it from its strongest — which pre-tag review caught: on the
|
|
427
|
+
* maintainer's live DB, 16 of the 17 rows this op would have moved were lesson-bearing.
|
|
428
|
+
*
|
|
429
|
+
* no lesson -> 1 (fully de-ranked; the original behaviour, unchanged)
|
|
430
|
+
* lesson -> 2 (loses the top tier and its ranking weight, keeps eligibility on
|
|
431
|
+
* every importance>=2 face)
|
|
432
|
+
*
|
|
433
|
+
* The floor doubles as the WHERE bound so a row already sitting at its floor is not
|
|
434
|
+
* re-touched: SQLite counts a same-value UPDATE in `changes`, which would otherwise
|
|
435
|
+
* report phantom demotions on every run forever.
|
|
348
436
|
*/
|
|
349
437
|
export function demotePinned(db, { projectFilter, baseParams, opCap = OP_CAP }) {
|
|
350
438
|
return db.prepare(`
|
|
351
|
-
UPDATE observations SET importance =
|
|
439
|
+
UPDATE observations SET importance = ${PINNED_FLOOR_SQL}
|
|
352
440
|
WHERE id IN (
|
|
353
441
|
SELECT id FROM observations
|
|
354
442
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
355
|
-
AND
|
|
356
|
-
AND COALESCE(cited_count, 0) = 0
|
|
357
|
-
AND COALESCE(importance, 1) > 1
|
|
443
|
+
AND ${PINNED_PREDICATE_SQL}
|
|
358
444
|
${projectFilter} LIMIT ${opCap}
|
|
359
445
|
)
|
|
360
446
|
`).run(...baseParams).changes;
|
|
@@ -528,9 +614,7 @@ export function maintenanceStats(db, { projectFilter, baseParams, staleAge }) {
|
|
|
528
614
|
THEN 1 ELSE 0 END), 0) as broken,
|
|
529
615
|
COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 3 AND COALESCE(importance, 1) < 3
|
|
530
616
|
THEN 1 ELSE 0 END), 0) as boostable,
|
|
531
|
-
COALESCE(SUM(CASE WHEN
|
|
532
|
-
AND COALESCE(cited_count, 0) = 0 AND COALESCE(importance, 1) > 1
|
|
533
|
-
THEN 1 ELSE 0 END), 0) as pinned
|
|
617
|
+
COALESCE(SUM(CASE WHEN ${PINNED_PREDICATE_SQL} THEN 1 ELSE 0 END), 0) as pinned
|
|
534
618
|
FROM observations
|
|
535
619
|
WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}
|
|
536
620
|
`).get(staleAge, ...baseParams);
|
package/mem-cli.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
|
|
31
31
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
32
32
|
hardDeleteCandidateCount,
|
|
33
|
-
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
33
|
+
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD, resolveDefaultMaintainOps,
|
|
34
34
|
} from './lib/maintain-core.mjs';
|
|
35
35
|
import { snapshotDb, listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
|
|
36
36
|
import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
|
|
@@ -2012,7 +2012,7 @@ function cmdMaintain(db, args) {
|
|
|
2012
2012
|
out(` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`);
|
|
2013
2013
|
out(` Broken (no title/narrative): ${stats.broken}`);
|
|
2014
2014
|
out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
|
|
2015
|
-
out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0,
|
|
2015
|
+
out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, above floor): ${stats.pinned} — floored by the default maintain set since v3.76.0, no lesson → 1, lesson → 2 (opt out: CLAUDE_MEM_SKIP_DEMOTE_PINNED=1)`);
|
|
2016
2016
|
out(formatPendingPurgeLine(stats.pendingPurge));
|
|
2017
2017
|
if (duplicates.length > 0) {
|
|
2018
2018
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
@@ -2047,9 +2047,11 @@ function cmdMaintain(db, args) {
|
|
|
2047
2047
|
const VALID_OPS = ['cleanup', 'decay', 'boost', 'demote_pinned', 'dedup', 'purge_stale', 'rebuild_vectors', 'vacuum'];
|
|
2048
2048
|
// Distinguish flag-absent (use default op set) from flag-present-but-empty
|
|
2049
2049
|
// (`--ops ""`, e.g. an unset shell var). The latter previously coerced via `||`
|
|
2050
|
-
// to the destructive default
|
|
2051
|
-
//
|
|
2052
|
-
|
|
2050
|
+
// to the destructive default set and EXECUTED it; route it to the VALID_OPS check
|
|
2051
|
+
// below instead so it's rejected like `--ops " "` / `--ops "decay,"`. (That default
|
|
2052
|
+
// was the literal `cleanup,decay,boost` when this was written; it now comes from
|
|
2053
|
+
// DEFAULT_MAINTAIN_OPS, which is why the list is no longer spelled out here.)
|
|
2054
|
+
const opsStr = flags.ops === undefined ? resolveDefaultMaintainOps().join(',') : String(flags.ops);
|
|
2053
2055
|
const ops = opsStr.split(',').map(s => s.trim());
|
|
2054
2056
|
const invalidOps = ops.filter(op => !VALID_OPS.includes(op));
|
|
2055
2057
|
if (invalidOps.length > 0) {
|
|
@@ -2138,17 +2140,23 @@ function cmdMaintain(db, args) {
|
|
|
2138
2140
|
results.push(`Decayed ${decayed} stale observations, marked ${idleMarked} idle as pending-purge${decayCap}`);
|
|
2139
2141
|
}
|
|
2140
2142
|
|
|
2143
|
+
if (ops.includes('boost')) {
|
|
2144
|
+
const boosted = boostAccessed(db, mctx);
|
|
2145
|
+
results.push(`Boosted ${boosted} frequently-accessed observations${capHint(boosted)}`);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// AFTER boost, matching server.mjs and hook.mjs. This block used to sit BEFORE
|
|
2149
|
+
// it, and the order was load-bearing in the wrong direction: boostAccessed lifts
|
|
2150
|
+
// any access_count>3 row with importance<3, so demoting a pinned row to 1 and
|
|
2151
|
+
// then boosting handed it straight back at 2 — the demotion silently undone
|
|
2152
|
+
// inside a single maintain run. DEFAULT_MAINTAIN_OPS pins the order; this block
|
|
2153
|
+
// has to physically follow the boost block for that order to be real.
|
|
2141
2154
|
if (ops.includes('demote_pinned')) {
|
|
2142
2155
|
// Repair the citation-decay blind spot: decay protects injection_count>0, so a
|
|
2143
2156
|
// heavily-injected-but-uncited memory stays pinned at max importance forever.
|
|
2144
2157
|
// demotePinned (maintain-core) drops it to 1 in one pass. Floor 1, not purge.
|
|
2145
2158
|
const demoted = demotePinned(db, mctx);
|
|
2146
|
-
results.push(`Demoted ${demoted} pinned-but-uncited observations
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
if (ops.includes('boost')) {
|
|
2150
|
-
const boosted = boostAccessed(db, mctx);
|
|
2151
|
-
results.push(`Boosted ${boosted} frequently-accessed observations${capHint(boosted)}`);
|
|
2159
|
+
results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=${PINNED_INJ_THRESHOLD}, cited=0; no lesson → importance 1, lesson → 2)${capHint(demoted)}`);
|
|
2152
2160
|
}
|
|
2153
2161
|
|
|
2154
2162
|
if (ops.includes('dedup') && flags['merge-ids']) {
|
|
@@ -2784,10 +2792,15 @@ Commands:
|
|
|
2784
2792
|
|
|
2785
2793
|
maintain <scan|execute> Memory maintenance
|
|
2786
2794
|
--ops O Comma-separated: cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum
|
|
2795
|
+
Default when omitted: cleanup,decay,boost,demote_pinned (in that order)
|
|
2787
2796
|
--merge-ids K:R,... For dedup: keepId:removeId pairs (e.g. 10:11,20:21:22)
|
|
2788
2797
|
--project P Filter by project
|
|
2789
2798
|
--retain-days N For purge_stale: keep last N days (default 30)
|
|
2790
|
-
demote_pinned: importance→1 for inj>=8 & cited=0 (clears pinned noise)
|
|
2799
|
+
demote_pinned: importance→1 for inj>=8 & cited=0 (clears pinned noise).
|
|
2800
|
+
In the default set since v3.76.0; runs AFTER boost, which would
|
|
2801
|
+
otherwise hand the row straight back. Opt out of the DEFAULT with
|
|
2802
|
+
CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 — an explicit --ops demote_pinned
|
|
2803
|
+
still runs.
|
|
2791
2804
|
vacuum: reclaim freelist dead space (whole-DB, ignores --project)
|
|
2792
2805
|
|
|
2793
2806
|
optimize LLM-powered memory optimization (preview by default)
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.76.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.76.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.76.1",
|
|
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/scripts/launch.mjs
CHANGED
|
@@ -42,8 +42,15 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
|
|
|
42
42
|
//
|
|
43
43
|
// `e.status` not `e.code`: execSync failures carry the exit status on `status`,
|
|
44
44
|
// so the old `|| e.code` rung was dead.
|
|
45
|
+
// `?? null` not `!= null`: the loose form is the idiom, but this file is
|
|
46
|
+
// linted under `eqeqeq: always`, and rewriting it as `!== undefined` would
|
|
47
|
+
// be a BEHAVIOUR change — execSync reports a signal kill with `status: null`,
|
|
48
|
+
// which `!== undefined` accepts and would render as "npm exited null".
|
|
49
|
+
// Coalescing first keeps the original both-nullish semantics exactly, `0`
|
|
50
|
+
// included.
|
|
51
|
+
const status = e?.status ?? null;
|
|
45
52
|
const detail = e?.message?.split('\n')[0]
|
|
46
|
-
|| (
|
|
53
|
+
|| (status !== null ? `npm exited ${status}` : '')
|
|
47
54
|
|| (e?.signal ? `npm killed by ${e.signal}` : '')
|
|
48
55
|
|| 'unknown error';
|
|
49
56
|
process.stderr.write(`[claude-mem-lite] npm install failed in ${ROOT} — ${detail}\n`);
|
package/server.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
|
|
20
20
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
21
21
|
hardDeleteCandidateCount,
|
|
22
|
-
OP_CAP, STALE_AGE_MS,
|
|
22
|
+
OP_CAP, STALE_AGE_MS, resolveDefaultMaintainOps, DEFAULT_MAINTAIN_OPS,
|
|
23
23
|
} from './lib/maintain-core.mjs';
|
|
24
24
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
25
25
|
import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
|
|
@@ -1148,11 +1148,11 @@ server.registerTool(
|
|
|
1148
1148
|
if (action === 'execute') {
|
|
1149
1149
|
const ops = args.operations && args.operations.length > 0
|
|
1150
1150
|
? args.operations
|
|
1151
|
-
:
|
|
1151
|
+
: resolveDefaultMaintainOps();
|
|
1152
1152
|
// T2-P1-A: reject explicit empty array (vs. omitted → defaults above). Empty-array
|
|
1153
1153
|
// callers are almost always mistakes; silently running only FTS5 optimize hides the error.
|
|
1154
1154
|
if (args.operations && args.operations.length === 0) {
|
|
1155
|
-
return { content: [{ type: 'text', text:
|
|
1155
|
+
return { content: [{ type: 'text', text: `operations array is empty. Pass a non-empty list (e.g. ${JSON.stringify(DEFAULT_MAINTAIN_OPS)}) or omit operations to use the default set.` }], isError: true };
|
|
1156
1156
|
}
|
|
1157
1157
|
const results = [];
|
|
1158
1158
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
@@ -1239,7 +1239,7 @@ server.registerTool(
|
|
|
1239
1239
|
|
|
1240
1240
|
if (ops.includes('demote_pinned')) {
|
|
1241
1241
|
const demoted = demotePinned(db, mctx);
|
|
1242
|
-
results.push(`Demoted ${demoted} pinned-but-uncited observations
|
|
1242
|
+
results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=8, cited=0; no lesson → importance 1, lesson → 2)` + (demoted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1243
1243
|
}
|
|
1244
1244
|
|
|
1245
1245
|
if (ops.includes('dedup') && args.merge_ids) {
|
package/tool-schemas.mjs
CHANGED
|
@@ -247,7 +247,7 @@ export const memOptimizeSchema = {
|
|
|
247
247
|
export const memMaintainSchema = {
|
|
248
248
|
action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
|
|
249
249
|
operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'demote_pinned', 'purge_stale', 'rebuild_vectors', 'vacuum'])).optional()
|
|
250
|
-
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance→1 for obs injected>=8 times but never cited (clears pinned noise the decay op cannot reach), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
|
|
250
|
+
.describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance→1 for obs injected>=8 times but never cited (clears pinned noise the decay op cannot reach; in the default set since v3.76.0 and ordered after boost, since boost would otherwise raise the row straight back — set CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 to drop it from the DEFAULT set only), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
|
|
251
251
|
merge_ids: z.preprocess(
|
|
252
252
|
(v) => Array.isArray(v) ? v.map(g => Array.isArray(g) ? g.map(x => typeof x === 'string' ? parseInt(x, 10) : x) : g) : v,
|
|
253
253
|
z.array(z.array(z.number().int()).min(2))
|