claude-mem-lite 3.75.1 → 3.76.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.75.1",
13
+ "version": "3.76.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.75.1",
3
+ "version": "3.76.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/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\`,\`--retain-days\` ∈ [7,365]。
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
@@ -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,44 @@ 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
+
343
400
  /**
344
401
  * Repair the citation-decay blind spot: heavy-injection + zero-citation rows that
345
- * decay protects (injection_count>0) stay pinned at max importance forever. Drop
346
- * them to importance 1 in one pass (injection priority is binary at >=2, so a
347
- * single step would not de-rank). Floor 1, not purge.
402
+ * decay protects (injection_count>0) stay pinned at max importance forever. Floor
403
+ * them; never purge.
404
+ *
405
+ * TWO floors, and the asymmetry is the point. `importance >= 2` is a hard WHERE on the
406
+ * injection faces that actually earn citations — pre-tool-recall
407
+ * (scripts/pre-tool-recall.js:428,470), SessionStart Key Context (hook-context.mjs:95,315)
408
+ * and cross-project (hook-memory.mjs:280) — whereas `injection_count`, the signal that
409
+ * triggers this op at all, is incremented ONLY on the two UserPromptSubmit faces
410
+ * (hook-memory.mjs:367, scripts/user-prompt-search.js:979). Those are the weakest faces
411
+ * by measured cite-rate. Dropping straight to 1 therefore convicts a row on its weakest
412
+ * surface and evicts it from its strongest — which pre-tag review caught: on the
413
+ * maintainer's live DB, 16 of the 17 rows this op would have moved were lesson-bearing.
414
+ *
415
+ * no lesson -> 1 (fully de-ranked; the original behaviour, unchanged)
416
+ * lesson -> 2 (loses the top tier and its ranking weight, keeps eligibility on
417
+ * every importance>=2 face)
418
+ *
419
+ * The floor doubles as the WHERE bound so a row already sitting at its floor is not
420
+ * re-touched: SQLite counts a same-value UPDATE in `changes`, which would otherwise
421
+ * report phantom demotions on every run forever.
348
422
  */
349
423
  export function demotePinned(db, { projectFilter, baseParams, opCap = OP_CAP }) {
424
+ const floor = `(CASE WHEN ${NO_LESSON_SQL} THEN 1 ELSE 2 END)`;
350
425
  return db.prepare(`
351
- UPDATE observations SET importance = 1
426
+ UPDATE observations SET importance = ${floor}
352
427
  WHERE id IN (
353
428
  SELECT id FROM observations
354
429
  WHERE COALESCE(compressed_into, 0) = 0
355
430
  AND COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
356
431
  AND COALESCE(cited_count, 0) = 0
357
- AND COALESCE(importance, 1) > 1
432
+ AND COALESCE(importance, 1) > ${floor}
358
433
  ${projectFilter} LIMIT ${opCap}
359
434
  )
360
435
  `).run(...baseParams).changes;
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, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
2015
+ out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — cleared by the default maintain set since v3.76.0 (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 cleanup,decay,boost and EXECUTED it; route it to the
2051
- // VALID_OPS check below instead so it's rejected like `--ops " "` / `--ops "decay,"`.
2052
- const opsStr = flags.ops === undefined ? 'cleanup,decay,boost' : String(flags.ops);
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 to importance 1 (inj>=${PINNED_INJ_THRESHOLD}, cited=0)${capHint(demoted)}`);
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)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.75.1",
3
+ "version": "3.76.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.75.1",
9
+ "version": "3.76.0",
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.75.1",
3
+ "version": "3.76.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",
@@ -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
- || (e?.status != null ? `npm exited ${e.status}` : '')
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,
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,7 +1148,7 @@ server.registerTool(
1148
1148
  if (action === 'execute') {
1149
1149
  const ops = args.operations && args.operations.length > 0
1150
1150
  ? args.operations
1151
- : ['cleanup', 'decay', 'boost'];
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) {
@@ -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 to importance 1 (inj>=8, cited=0)` + (demoted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
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))