claude-mem-lite 3.86.0 → 3.88.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.
@@ -119,6 +119,55 @@ export function dropDeferred(db, id, reason) {
119
119
  return { changed: r.changes };
120
120
  }
121
121
 
122
+ /**
123
+ * Reasons that mean "this item was FIXED", for which `defer drop` is the wrong
124
+ * verb — dropping loses the closed_by_obs_id link that `save --closes-deferred`
125
+ * would have written, and leaves the row indistinguishable from a genuinely
126
+ * rejected one (D#195; v3.86.0 dropped six fixed items this way).
127
+ *
128
+ * Deliberately a POSITIVE pattern for the fixed-shape, not a stop-list of
129
+ * rejection wordings: the set of ways to say "no longer relevant" is open-ended,
130
+ * the set of ways to say "done" is small. Advisory only — the drop still
131
+ * succeeds, so a false positive costs one line of output.
132
+ */
133
+ // `closed` and `landed` are here because of the six real v3.86.0 mis-drops this
134
+ // hint exists to prevent: their reason was "closed this round; fix + mutation-
135
+ // verified binding test landed", which an earlier draft anchored on `fixed` and
136
+ // `closed by` and therefore MISSED — the motivating case fell through its own
137
+ // predicate. Bare `fix` is deliberately NOT here: "waiting for an upstream fix"
138
+ // is a legitimate rejection and carries no veto word in English.
139
+ const DROP_REASON_FIXED_RE = /\b(fixed|implemented|shipped|resolved|done|closed|landed)\b|修复|已实现|已完成|完成了|已发布|已解决/i;
140
+
141
+ /**
142
+ * Negative-sense veto, applied to the WHOLE reason before the positive pattern.
143
+ *
144
+ * A lookbehind cannot do this job: the sense-carrying word is not adjacent to the
145
+ * keyword. "等待上游修复" (waiting for an upstream fix) puts 游 immediately before
146
+ * 修复, so `(?<![待未需])修复` still fires on it — measured, which is why the
147
+ * predicate is two-stage rather than one clever pattern. Suppression is the safe
148
+ * direction here: a missed hint costs nothing, a wrong hint trains the reader to
149
+ * ignore the line.
150
+ */
151
+ // `wontfix` is spelled with no boundary after `wont`, so a plain `won'?t\b` misses the
152
+ // single most common English way of writing this rejection and the hint then fires on
153
+ // "resolved as wontfix" — the positive arm's `resolved` winning over a veto that never
154
+ // ran. Matched explicitly rather than by loosening the boundary, which would also admit
155
+ // `wonton`. (pre-tag review v3.88.0, correctness N4)
156
+ const DROP_REASON_NOT_YET_RE = /\bwon'?tfix\b|\b(not|won'?t|cannot|can'?t|unable|pending|todo|blocked|waiting|obsolete|superseded|duplicate|irrelevant|refuted)\b|待|未|尚|需|无法|暂不|过时|重复|取代/i;
157
+
158
+ /**
159
+ * @param {string} reason The drop reason about to be recorded.
160
+ * @returns {string|null} Advisory hint, or null when the reason does not look
161
+ * like a completion.
162
+ */
163
+ export function formatDropReasonHint(reason) {
164
+ if (typeof reason !== 'string') return null;
165
+ if (DROP_REASON_NOT_YET_RE.test(reason)) return null;
166
+ if (!DROP_REASON_FIXED_RE.test(reason)) return null;
167
+ return '⚠ that reason reads like the item was FIXED — prefer `save --closes-deferred D#<id>`, '
168
+ + 'which records status=done plus the closing observation id. `drop` records a rejection.';
169
+ }
170
+
122
171
  /**
123
172
  * Fetch full deferred_work rows by raw id — ANY status, input order preserved,
124
173
  * missing ids omitted. This is the read half of the D# surface: `defer list`
@@ -161,6 +210,11 @@ export function formatDeferredDetail(row) {
161
210
  if (row.created_at_epoch) lines.push(`created: ${new Date(row.created_at_epoch).toISOString()}`);
162
211
  if (row.status === 'dropped' && row.drop_reason) lines.push(`drop_reason: ${row.drop_reason}`);
163
212
  if (row.status === 'done' && row.closed_by_obs_id) lines.push(`closed_by: #${row.closed_by_obs_id}`);
213
+ // D#195: a row re-closed out of 'dropped' keeps its drop_reason. Render it under
214
+ // a distinct label rather than dropping it from the view — the mis-drop is the
215
+ // part a later reader needs, and a `drop_reason:` line on a done row would read
216
+ // as a contradiction.
217
+ if (row.status === 'done' && row.drop_reason) lines.push(`previously_dropped: ${row.drop_reason}`);
164
218
  return lines.join('\n');
165
219
  }
166
220
 
@@ -258,18 +312,31 @@ export function formatDeferredSearchTrailer(rows, invokeHint) {
258
312
 
259
313
  /**
260
314
  * Resolve mixed ordinal (int) + raw-id ("D#<n>") tokens to real deferred_work
261
- * ids, validated against caller project + status='open'.
315
+ * ids, validated against caller project + an allowed status set.
262
316
  *
263
317
  * - bare integer N → ordinal-within-project (uses same ROW_NUMBER as listOpenWithOrdinal)
264
- * - "D#<n>" string → raw deferred_work.id; must belong to caller project AND be open
318
+ * - "D#<n>" string → raw deferred_work.id; must belong to caller project AND
319
+ * carry an allowed status
320
+ *
321
+ * `allowStatuses` defaults to open-only, which is the DROP verb's policy and the
322
+ * historical behaviour of every caller. The CLOSE verb (`save --closes-deferred`)
323
+ * passes `['open', 'dropped']` per D#195: dropping an item that was actually
324
+ * fixed used to be a one-way gate, permanently losing the closed_by_obs_id link.
325
+ * 'done' is never allowed under either policy — re-closing would overwrite an
326
+ * existing obs link with a different one.
327
+ *
328
+ * Ordinals stay open-only under every policy: the ROW_NUMBER that defines them
329
+ * is computed over open rows, so a dropped row simply has no ordinal to name.
330
+ * Reaching one requires the explicit `D#<n>` form.
265
331
  *
266
332
  * @param {Database} db
267
333
  * @param {string} project Caller project (FK guard)
268
334
  * @param {Array<number|string>} tokens Mixed input
335
+ * @param {{allowStatuses?: string[]}} [opts]
269
336
  * @returns {number[]} Real deferred_work ids in input order
270
337
  * @throws {Error} On unresolvable input — error message names the offending token
271
338
  */
272
- export function resolveDeferredIds(db, project, tokens) {
339
+ export function resolveDeferredIds(db, project, tokens, { allowStatuses = ['open'] } = {}) {
273
340
  if (!Array.isArray(tokens)) throw new Error('tokens must be an array');
274
341
  // Pre-load open list once for ordinal resolution (ROW_NUMBER snapshot stable
275
342
  // within this call so [1, 2] resolves consistently).
@@ -300,10 +367,11 @@ export function resolveDeferredIds(db, project, tokens) {
300
367
  if (row.project !== project) {
301
368
  throw new Error(`D#${id} belongs to project "${row.project}", not "${project}"`);
302
369
  }
303
- if (row.status !== 'open') {
370
+ if (!allowStatuses.includes(row.status)) {
304
371
  // Verb-neutral: resolveDeferredIds is shared by close (save --closes-deferred)
305
372
  // AND drop (mem_defer_drop), so "cannot close" mis-described the drop path.
306
- throw new Error(`D#${id} status is "${row.status}" — only 'open' items can be closed or dropped`);
373
+ const allowed = allowStatuses.map(s => `'${s}'`).join(' or ');
374
+ throw new Error(`D#${id} status is "${row.status}" — only ${allowed} items are accepted here`);
307
375
  }
308
376
  } else {
309
377
  throw new Error(`invalid token type ${typeof t} — expected D#N or integer ordinal`);
@@ -327,7 +395,7 @@ export function resolveDeferredIds(db, project, tokens) {
327
395
  * @param {Database} db
328
396
  * @param {number[]} ids Already-resolved real ids (use resolveDeferredIds first)
329
397
  * @param {number} closingObsId observations.id that proves closure
330
- * @throws {Error} If any id is not currently open (lookup-based safety net)
398
+ * @throws {Error} If any id is neither 'open' nor 'dropped' (lookup-based safety net)
331
399
  */
332
400
  export function closeDeferredItems(db, ids, closingObsId) {
333
401
  if (!Array.isArray(ids) || ids.length === 0) return;
@@ -337,17 +405,22 @@ export function closeDeferredItems(db, ids, closingObsId) {
337
405
  // Defense-in-depth: even if caller already validated via resolveDeferredIds,
338
406
  // re-check status here (caller may have done resolution earlier in the same
339
407
  // transaction without holding a lock).
408
+ //
409
+ // D#195: 'dropped' is closable. drop_reason is intentionally NOT cleared — the
410
+ // row's history is what makes a mis-drop auditable, and formatDeferredDetail
411
+ // renders it as `previously_dropped:` once the status is 'done'. 'done' stays
412
+ // excluded so a second close cannot overwrite an existing closed_by_obs_id.
340
413
  const stmt = db.prepare(`
341
414
  UPDATE deferred_work
342
415
  SET status='done', closed_at_epoch=?, closed_by_obs_id=?
343
- WHERE id=? AND status='open'
416
+ WHERE id=? AND status IN ('open', 'dropped')
344
417
  `);
345
418
  const now = Date.now();
346
419
  const tx = db.transaction((idList) => {
347
420
  for (const id of idList) {
348
421
  const r = stmt.run(now, closingObsId, id);
349
422
  if (r.changes !== 1) {
350
- throw new Error(`closeDeferredItems: id ${id} was not in 'open' status (changes=${r.changes})`);
423
+ throw new Error(`closeDeferredItems: id ${id} was not in a closable status ('open' or 'dropped') (changes=${r.changes})`);
351
424
  }
352
425
  }
353
426
  });
@@ -20,9 +20,20 @@
20
20
  // row is never mis-attributed to an observation id (which would let citation decay
21
21
  // mutate an unrelated observation sharing that id). Events carry no citation columns,
22
22
  // so they inject as reference-only — reachability, not decay bookkeeping.
23
+ //
24
+ // D#202: that paragraph was true of the faces it names and FALSE of the one it does
25
+ // not. `scripts/pre-tool-recall.js` renders its own merged obs+event rows and used a
26
+ // bare `#` for both, so nearly half of that channel's injected rows (44.9%, measured
27
+ // over 4227 firings) were event ids entering the observation decay denominator —
28
+ // exactly the mis-attribution this prefix exists to prevent. The invariant was stated
29
+ // here while the face that broke it lived elsewhere and was not enumerated. The prefix
30
+ // is now a shared constant (lib/injected-ids.mjs, a leaf so the hot PreToolUse path
31
+ // need not pull this file's search-core chain) and
32
+ // tests/pretool-event-id-namespace.test.mjs sweeps both renderers.
23
33
 
24
34
  import { searchEventsFts } from './search-core.mjs';
25
35
  import { neutralizeContextDelimiters } from '../format-utils.mjs';
36
+ import { EVENT_ID_PREFIX } from './injected-ids.mjs';
26
37
 
27
38
  const DEFAULT_LIMIT = 3;
28
39
  const DEFAULT_MIN_IMPORTANCE = 2;
@@ -98,7 +109,7 @@ export function renderInjectableEvent(row) {
98
109
  // hook-llm.mjs behind `if (EVENT_TYPE_SET.has(summary.type))`), so it carries no
99
110
  // injection markers and is intentionally NOT defanged — mirrors the un-defanged
100
111
  // `[type]` for observations. If an unvalidated event writer is ever added, defang it.
101
- const head = `E#${row.id} [${row.type}] ${title}`;
112
+ const head = `${EVENT_ID_PREFIX}${row.id} [${row.type}] ${title}`;
102
113
  if (row.lesson_learned) {
103
114
  const lesson = neutralizeContextDelimiters(row.lesson_learned.trim().slice(0, LESSON_MAX));
104
115
  if (lesson) return `${head} — ${lesson}`;
@@ -53,10 +53,16 @@ export function injectedIdsFileName(project, sessionId) {
53
53
  * and feeds that single value to BOTH `crossHookInjectedFile(project, sessionId)` and
54
54
  * the events `SELECT ... WHERE project = ?`. So the scoping that matters is the
55
55
  * SESSION's project. The draft instead scoped by the injected observation's own
56
- * `project` column — a different question, since the `ups`/`fyi` faces do inject
57
- * cross-project rows — and reported 9 in 9 (15.5%), understating it. The pre-tag claims
56
+ * `project` column and reported 9 in 9 (15.5%), understating it. The pre-tag claims
58
57
  * review caught the population, having reconstructed it independently at 12 in 10.
59
58
  *
59
+ * That is not a rounding difference, and the number that proves it is worth carrying:
60
+ * **134 of 216 injected `ups` ids (62.0%) belong to a project OTHER than the session
61
+ * they were injected into** (2026-09-01T20:36Z; the review measured 128/210 = 61% an
62
+ * hour earlier). The `ups` face has a cross-project leg and it dominates, so "the
63
+ * observation's project" and "the session's project" select genuinely different
64
+ * populations — and only the latter is one the dedup mechanism ever asks about.
65
+ *
60
66
  * The predicate, stated here because no harness is committed for it: seen-set per
61
67
  * session = the shipped `extractInjectedBySurface(path).ups`; injectable events =
62
68
  * `importance >= 2 AND superseded_at_epoch IS NULL AND file_paths NOT IN (NULL,'[]')`;
@@ -89,6 +95,23 @@ export function injectedIdKey(id, src = 'obs') {
89
95
  return src === 'evt' ? `E${id}` : String(id);
90
96
  }
91
97
 
98
+ /**
99
+ * DISPLAY prefix for an event id rendered into an injected line, as opposed to
100
+ * `injectedIdKey` above, which namespaces the same id inside the marker FILE.
101
+ * Two forms of one convention: the marker key is `E<id>` (no `#`, it is not
102
+ * citable text), the rendered token is `E#<id>` (the `#` is what makes an id
103
+ * look like an id to a reader).
104
+ *
105
+ * It lives in this leaf module — rather than beside either renderer — because
106
+ * D#202 was exactly the two renderers not agreeing. lib/events-injection.mjs
107
+ * had the `E#` convention and a header explaining it; scripts/pre-tool-recall.js
108
+ * rendered its own merged obs+event rows with a bare `#`, putting 44.9% of that
109
+ * channel's ids into the observation citation-decay denominator. Importing from
110
+ * here also keeps the hot PreToolUse path off events-injection.mjs's
111
+ * search-core.mjs dependency chain.
112
+ */
113
+ export const EVENT_ID_PREFIX = 'E#';
114
+
92
115
  /**
93
116
  * Runtime-dir FILE NAME for the SessionStart Key Context marker: the obs ids
94
117
  * ACTUALLY rendered into the <claude-mem-context> File Lessons / Key Context
@@ -265,8 +265,10 @@ export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [
265
265
  `).run(...baseParams).changes;
266
266
  }
267
267
 
268
- // Heal lesson-bearing rows that citation-decay buried at importance 0 under the old
269
- // IMPORTANCE_FLOOR=0 (fixed in citation-tracker.mjs → floor 1). All passive injection
268
+ // Heal lesson-bearing rows that citation-decay buried at importance 0 back when its floor
269
+ // was 0. That loop was later given a floor of 1, and as of D#179/D#198 it does not write
270
+ // `importance` on any branch at all — so this op no longer has an active producer and only
271
+ // drains the historical backlog. Keep it: nothing else lifts a stranded 0. All passive injection
270
272
  // surfaces exclude importance 0 (pre-tool-recall >=2, user-prompt-search >=1, memory-context
271
273
  // >=1), so a lesson demoted there is invisible AND — being injection_count>0 by construction
272
274
  // — sits in no GC queue either (decayAndMarkIdle only marks injection_count=0 rows): stranded
@@ -18,6 +18,31 @@ import { insertObservationRow, insertObservationFiles, insertObservationVector }
18
18
  const DEDUP_WINDOW_MS = 5 * 60 * 1000;
19
19
  const DEDUP_RECENT_LIMIT = 50;
20
20
 
21
+ /** Human-readable cause per `supersedeSkipped` reason (D#201). */
22
+ const SUPERSEDE_SKIP_CAUSE = {
23
+ 'malformed-id': 'not a positive integer id',
24
+ 'no-such-observation': 'no observation with that id',
25
+ 'other-project': 'belongs to a different project',
26
+ 'already-superseded': 'already superseded (no-op)',
27
+ 'duplicate-save': 'the save deduped, so nothing was superseded',
28
+ };
29
+
30
+ /**
31
+ * Render the D#201 warning for requested-but-not-superseded ids. Lives here
32
+ * rather than in either face so the CLI and the MCP tool cannot word it
33
+ * differently or, more to the point, so one of them cannot quietly stop
34
+ * rendering it.
35
+ *
36
+ * @param {Array<{id: any, reason: string}>} [skipped]
37
+ * @returns {string|null} null when nothing was skipped
38
+ */
39
+ export function formatSupersedeSkipped(skipped) {
40
+ if (!Array.isArray(skipped) || skipped.length === 0) return null;
41
+ const parts = skipped.map(({ id, reason }) =>
42
+ `#${id} (${SUPERSEDE_SKIP_CAUSE[reason] || reason})`);
43
+ return `⚠ --supersedes: ${parts.length} id(s) NOT superseded — ${parts.join(', ')}.`;
44
+ }
45
+
21
46
  /**
22
47
  * Save a new observation if it isn't a near-duplicate of one saved within the
23
48
  * last 5 minutes (Jaccard similarity > 0.7 on title or content).
@@ -32,8 +57,18 @@ const DEDUP_RECENT_LIMIT = 50;
32
57
  * @param {string[]} [params.files=[]] File paths to attach (junction table).
33
58
  * @param {string|null} [params.lesson_learned] Caller validates ≤500 chars.
34
59
  * @param {Date} [params.now] Override for tests.
35
- * @returns {{ kind: 'duplicate', existingId: number, project: string, type: string }
36
- * | { kind: 'saved', id: number, type: string, project: string, title: string, lessonCaptured: boolean }}
60
+ * Both result shapes carry `supersededIds` (what was actually tombstoned) and
61
+ * `supersedeSkipped` (requested but NOT tombstoned, each with a `reason`:
62
+ * `malformed-id` | `no-such-observation` | `other-project` |
63
+ * `already-superseded` | `duplicate-save`). Callers MUST surface a non-empty
64
+ * `supersedeSkipped` — that is the whole point of D#201; dropping it puts the
65
+ * silent failure back.
66
+ *
67
+ * @returns {{ kind: 'duplicate', existingId: number, project: string, type: string,
68
+ * supersededIds: number[], supersedeSkipped: Array<{id: any, reason: string}> }
69
+ * | { kind: 'saved', id: number, type: string, project: string, title: string,
70
+ * lessonCaptured: boolean, supersededIds: number[],
71
+ * supersedeSkipped: Array<{id: any, reason: string}> }}
37
72
  */
38
73
  export function saveObservation(db, params) {
39
74
  const now = params.now instanceof Date ? params.now : new Date();
@@ -83,12 +118,42 @@ export function saveObservation(db, params) {
83
118
  ORDER BY created_at_epoch DESC LIMIT ?
84
119
  `).all(project, dedupCutoff, DEDUP_RECENT_LIMIT);
85
120
 
121
+ // Requested supersession targets, normalized. Declared BEFORE the dedup
122
+ // short-circuit because that path reports on them too.
123
+ // Self-reference is filtered inside the transaction, once the new id exists.
124
+ //
125
+ // D#201: the tokens that DON'T survive normalization are kept, not dropped.
126
+ // A caller who names an id and gets no supersession has to be told which id
127
+ // and why — the previous shape reported "requested 4, superseded 0" and
128
+ // "requested nothing" identically, so a mistyped or wrong-table id read as a
129
+ // clean success. `malformed-id` is the pre-query class; the DB-level classes
130
+ // are decided inside the transaction.
131
+ const rawSupersedes = Array.isArray(params.supersedes) ? params.supersedes : [];
132
+ const requestedSupersedes = [...new Set(
133
+ rawSupersedes.map(Number).filter((n) => Number.isInteger(n) && n > 0)
134
+ )];
135
+ const malformedSupersedes = rawSupersedes
136
+ .filter((t) => { const n = Number(t); return !(Number.isInteger(n) && n > 0); })
137
+ .map((t) => ({ id: t, reason: 'malformed-id' }));
138
+
86
139
  const dupMatch = recent.find((r) =>
87
140
  jaccardSimilarity(r.title, safeTitle) > DEDUP_JACCARD_THRESHOLD ||
88
141
  jaccardSimilarity(r.text || '', safeContent) > DEDUP_JACCARD_THRESHOLD
89
142
  );
90
143
  if (dupMatch) {
91
- return { kind: 'duplicate', existingId: dupMatch.id, project, type };
144
+ // D#201: a dedup short-circuit swallows the requested supersession too — you
145
+ // write a correction, it reads as a near-duplicate of something saved in the
146
+ // last 5 minutes, and the rows you meant to retire stay live. Same sentence
147
+ // as the ineligible-id case ("requested, did not happen, no trace"), so it
148
+ // reports through the same channel rather than staying quiet.
149
+ return {
150
+ kind: 'duplicate', existingId: dupMatch.id, project, type,
151
+ supersededIds: [],
152
+ supersedeSkipped: [
153
+ ...malformedSupersedes,
154
+ ...requestedSupersedes.map((id) => ({ id, reason: 'duplicate-save' })),
155
+ ],
156
+ };
92
157
  }
93
158
 
94
159
  // FTS-indexed text field includes title + content + lesson + CJK bigrams,
@@ -99,13 +164,6 @@ export function saveObservation(db, params) {
99
164
  const bigramText = cjkBigrams(indexText);
100
165
  const textField = bigramText ? safeContent + ' ' + bigramText : safeContent;
101
166
 
102
- // Requested supersession targets, normalized before the transaction opens.
103
- // Self-reference is filtered inside, once the new id exists.
104
- const requestedSupersedes = [...new Set(
105
- (Array.isArray(params.supersedes) ? params.supersedes : [])
106
- .map(Number).filter((n) => Number.isInteger(n) && n > 0)
107
- )];
108
-
109
167
  // Atomic: observation row + observation_files junction + observation_vectors
110
168
  // (TF-IDF) + supersession tombstones. Vector write is best-effort — vocab may be
111
169
  // uninitialized on a fresh DB; failure must not roll back the observation.
@@ -139,6 +197,7 @@ export function saveObservation(db, params) {
139
197
  // Write-the-correction and retire-its-predecessors is one unit or neither.
140
198
  const ids = requestedSupersedes.filter((n) => n !== savedId);
141
199
  let supersededIds = [];
200
+ const skipped = [];
142
201
  if (ids.length > 0) {
143
202
  const ph = ids.map(() => '?').join(',');
144
203
  const eligible = db.prepare(
@@ -150,11 +209,30 @@ export function saveObservation(db, params) {
150
209
  .run(now.getTime(), savedId, ...eligible);
151
210
  supersededIds = eligible;
152
211
  }
212
+ // D#201: classify the difference instead of discarding it. One extra
213
+ // SELECT, and only when something actually failed to land — the happy
214
+ // path (every id eligible) skips it entirely.
215
+ const landed = new Set(eligible);
216
+ const missed = ids.filter((n) => !landed.has(n));
217
+ if (missed.length > 0) {
218
+ const ph3 = missed.map(() => '?').join(',');
219
+ const rows = new Map(db.prepare(
220
+ `SELECT id, project, superseded_at FROM observations WHERE id IN (${ph3})`
221
+ ).all(...missed).map((r) => [r.id, r]));
222
+ for (const n of missed) {
223
+ const row = rows.get(n);
224
+ // Order matters: a row can be BOTH foreign-project and already
225
+ // superseded, and "it isn't yours" is the more actionable of the two.
226
+ if (!row) skipped.push({ id: n, reason: 'no-such-observation' });
227
+ else if (row.project !== project) skipped.push({ id: n, reason: 'other-project' });
228
+ else skipped.push({ id: n, reason: 'already-superseded' });
229
+ }
230
+ }
153
231
  }
154
232
 
155
- return { savedId, supersededIds };
233
+ return { savedId, supersededIds, skipped };
156
234
  });
157
- const { savedId, supersededIds } = saveTx();
235
+ const { savedId, supersededIds, skipped } = saveTx();
158
236
 
159
237
  return {
160
238
  kind: 'saved',
@@ -164,5 +242,9 @@ export function saveObservation(db, params) {
164
242
  title: safeTitle,
165
243
  lessonCaptured: Boolean(safeLesson),
166
244
  supersededIds,
245
+ // D#201: requested-but-not-superseded, with a reason each. Malformed tokens
246
+ // are prepended because they were rejected before the query and so carry the
247
+ // caller's ORIGINAL token (which may not even be a number) rather than an id.
248
+ supersedeSkipped: [...malformedSupersedes, ...skipped],
167
249
  };
168
250
  }
package/mem-cli.mjs CHANGED
@@ -55,7 +55,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
55
55
  import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
56
56
  import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
57
57
  import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
58
- import { saveObservation } from './lib/save-observation.mjs';
58
+ import { saveObservation, formatSupersedeSkipped } from './lib/save-observation.mjs';
59
59
  import { normalizeScope, insertObservationVector, applyObsUpdate } from './lib/observation-write.mjs';
60
60
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
61
61
  import { recallByFile } from './lib/recall-core.mjs';
@@ -80,7 +80,7 @@ const SURFACE_LABELS = {
80
80
  };
81
81
  import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
82
82
  import {
83
- insertDeferred, listOpenWithOrdinal, dropDeferred,
83
+ insertDeferred, listOpenWithOrdinal, dropDeferred, formatDropReasonHint,
84
84
  resolveDeferredIds, closeDeferredItems,
85
85
  getDeferredByIds, formatDeferredDetail,
86
86
  searchDeferredWork, formatDeferredSearchTrailer,
@@ -955,8 +955,13 @@ function cmdSave(db, args) {
955
955
  let supersedesIds = null;
956
956
  if (flags.supersedes !== undefined && flags.supersedes !== false) {
957
957
  const raw = String(flags.supersedes);
958
- supersedesIds = raw.split(',').map(t => t.trim()).filter(Boolean)
959
- .map(t => parseInt(t, 10)).filter(n => Number.isInteger(n) && n > 0);
958
+ // Tokens go through UNPARSED so saveObservation's classifier — not parseInt — decides
959
+ // what is malformed. parseInt is lenient in the one direction that costs data: it read
960
+ // `1abc` as 1, so a typo (`875x` for `8754`) tombstoned an unrelated observation and
961
+ // printed a clean `Superseded: #1.` Worse, the token vanished before saveObservation
962
+ // saw it, which made the `malformed-id` class D#201 added unreachable from the CLI —
963
+ // the exact face whose silence motivated D#201. Number() rejects `1abc` as NaN.
964
+ supersedesIds = raw.split(',').map((t) => t.trim()).filter(Boolean);
960
965
  if (supersedesIds.length === 0) {
961
966
  fail('[mem] --supersedes requires at least one positive observation id (e.g. --supersedes 8754,8771)');
962
967
  return;
@@ -984,7 +989,11 @@ function cmdSave(db, args) {
984
989
  // the deferred row has transitioned out of 'open'.
985
990
  if (r.kind === 'duplicate') return r;
986
991
  if (closesTokens) {
987
- closesIds = resolveDeferredIds(db, project, closesTokens);
992
+ // D#195: the close verb accepts a 'dropped' row and converts it to
993
+ // 'done'. `defer drop` used on an item that was actually fixed was
994
+ // otherwise a one-way gate, permanently losing the obs link. Kept in
995
+ // sync with the same policy in server.mjs mem_save.
996
+ closesIds = resolveDeferredIds(db, project, closesTokens, { allowStatuses: ['open', 'dropped'] });
988
997
  closeDeferredItems(db, closesIds, r.id);
989
998
  }
990
999
  return r;
@@ -1000,6 +1009,10 @@ function cmdSave(db, args) {
1000
1009
 
1001
1010
  if (result.kind === 'duplicate') {
1002
1011
  out(`[mem] Skipped: similar to existing #${result.existingId}. Use "claude-mem-lite get ${result.existingId}" to review.`);
1012
+ // D#201: the dedup swallowed the requested supersession too — say so here,
1013
+ // because this branch returns before the note below is ever reached.
1014
+ const dupSkip = formatSupersedeSkipped(result.supersedeSkipped);
1015
+ if (dupSkip) out(`[mem] ${dupSkip}`);
1003
1016
  return;
1004
1017
  }
1005
1018
 
@@ -1015,6 +1028,10 @@ function cmdSave(db, args) {
1015
1028
  const enrichNote = shouldQueueSaveEnrich(result) && queueSaveEnrich(result.id)
1016
1029
  ? ' (background enrichment queued)' : '';
1017
1030
  out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}${enrichNote}${buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'cli' })}`);
1031
+ // D#201: on its OWN line, after the success line. Appending it to the success
1032
+ // string would put a warning inside a sentence that reads as "done".
1033
+ const skipNote = formatSupersedeSkipped(result.supersedeSkipped);
1034
+ if (skipNote) out(`[mem] ${skipNote}`);
1018
1035
  }
1019
1036
 
1020
1037
  // ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
@@ -1149,6 +1166,12 @@ function cmdDeferDrop(db, args) {
1149
1166
  if (noop.length > 0) {
1150
1167
  out(`[mem] No-op (not in 'open' status): ${noop.map(id => `D#${id}`).join(', ')}`);
1151
1168
  }
1169
+ // D#195 (c): catch the mis-drop at the moment it happens, not months later
1170
+ // when the ledger can no longer tell a fixed item from a rejected one.
1171
+ if (dropped.length > 0) {
1172
+ const hint = formatDropReasonHint(reason);
1173
+ if (hint) out(`[mem] ${hint}`);
1174
+ }
1152
1175
  }
1153
1176
 
1154
1177
  // N-1: Quality-focused stats for R-2 A/B baseline.
@@ -2540,10 +2563,19 @@ function cmdCitationStats(db, args) {
2540
2563
  LIMIT 20
2541
2564
  `).all();
2542
2565
 
2566
+ // D#179/D#198, second half: the sibling `demoted` caption below was re-worded when the
2567
+ // decay loop stopped writing `importance`, and this one was not — the same "the copy I
2568
+ // fixed was not the only copy" shape the batch's own sweeps exist to prevent. Gating on
2569
+ // `importance >= 3` made the section structurally empty for anything the loop produces:
2570
+ // a row cited in ten sessions now has cited_count = 10 and whatever importance it was
2571
+ // saved with, so the section degenerated into "rows that were already at 3" under a
2572
+ // caption reading "promoted". The discriminator is now the pair the promote branch
2573
+ // actually writes (`cited_count + 1`, `uncited_streak = 0`); `importance` is still
2574
+ // SELECTed and printed, so a reader can see it is unrelated.
2543
2575
  const promoted = db.prepare(`
2544
2576
  SELECT id, project, type, title, importance, cited_count
2545
2577
  FROM observations
2546
- WHERE importance >= 3 AND cited_count >= 1
2578
+ WHERE cited_count >= 1 AND COALESCE(uncited_streak, 0) = 0
2547
2579
  AND ${liveObsFilterSql('')}
2548
2580
  ORDER BY cited_count DESC
2549
2581
  LIMIT 10
@@ -2665,19 +2697,23 @@ function cmdCitationStats(db, args) {
2665
2697
  }
2666
2698
  }
2667
2699
  out('');
2668
- out('Active decay queue (uncited_streak >= 2, next miss → demote):');
2700
+ out('Active decay queue (uncited_streak >= 2, next miss → rollover):');
2669
2701
  if (decayQueue.length === 0) out(' (none)');
2670
2702
  for (const r of decayQueue) {
2671
2703
  out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} imp=${r.importance} streak=${r.uncited_streak}`);
2672
2704
  }
2673
2705
  out('');
2674
- out('Recently promoted (importance=3, cited_count >= 1):');
2706
+ out('Recently cited (cited_count >= 1, streak reset; importance unaffected):');
2675
2707
  if (promoted.length === 0) out(' (none)');
2676
2708
  for (const r of promoted) {
2677
2709
  out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} cited ${r.cited_count}x`);
2678
2710
  }
2679
2711
  out('');
2680
- out(`Recently demoted (last ${days}d, importance ↓):`);
2712
+ // D#179/D#198: demoted_at now stamps the UNCITED-STREAK ROLLOVER, which no
2713
+ // longer lowers importance. The old label ("importance ↓") would describe a
2714
+ // write that stopped happening while the column beside it kept printing the
2715
+ // row's unchanged value — a caption contradicting its own table.
2716
+ out(`Recently rolled over (last ${days}d, uncited streak reset; importance unaffected):`);
2681
2717
  if (demoted.length === 0) out(' (none)');
2682
2718
  for (const r of demoted) {
2683
2719
  const ago = Math.round((Date.now() - r.demoted_at) / DAY_MS);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.86.0",
3
+ "version": "3.88.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.86.0",
9
+ "version": "3.88.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.86.0",
3
+ "version": "3.88.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/scoring-sql.mjs CHANGED
@@ -185,11 +185,21 @@ export function notLowSignalTitleClause(alias = 'o') {
185
185
  //
186
186
  // Closes the citation-decay → ranking loop. The Stop hook citation-decay
187
187
  // already maintains cited_count (Promote on cite) and uncited_streak (bump on
188
- // uncited; reset on cite or demote-at-3). Before A1, both columns affected
188
+ // uncited; reset on cite or rollover-at-3). Before A1, both columns affected
189
189
  // only the importance ±1 dial — through `(0.5 + 0.5·importance)` that's a
190
190
  // ≤2× swing and saturates fast. This factor lets ranking respond directly to
191
191
  // observed agent behavior on the obs itself.
192
192
  //
193
+ // D#179/D#198 made this factor the ONLY thing citation-decay feeds: that loop
194
+ // no longer writes `importance` at all. The reason is that importance is not a
195
+ // ranking dial — every injection surface gates candidacy on it, so moving it
196
+ // changed WHO IS IN the pool rather than where they ranked. This clause is the
197
+ // right home for the signal precisely because it is bounded and pure-ranking:
198
+ // a mis-read citation costs at most a 3.0× / 0.4× rank shift and can never
199
+ // evict a row. (A second, independent citation → importance path still exists
200
+ // via bumpCitationAccess → access_count → the `boost` maintain op; see obs
201
+ // #10911. It is out of this clause's scope and is NOT closed.)
202
+ //
193
203
  // Formula: clamp(0.4, 3.0, 1 + 0.2·cited_count − 0.25·uncited_streak)
194
204
  // Distribution:
195
205
  // cited=0, streak=0 → 1.0 (fresh obs, neutral)
@@ -8,7 +8,7 @@ import { existsSync, readFileSync, mkdirSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
9
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
10
10
  import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
11
- import { injectedIdsFileName, injectedIdKey } from '../lib/injected-ids.mjs';
11
+ import { injectedIdsFileName, injectedIdKey, EVENT_ID_PREFIX } from '../lib/injected-ids.mjs';
12
12
  import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
13
13
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
14
14
  import { recordHookError } from '../lib/hook-telemetry.mjs';
@@ -660,17 +660,36 @@ try {
660
660
  if (fileIntelLine) lines.push(neutralizeContextDelimiters(fileIntelLine));
661
661
  if (hasLessons) {
662
662
  lines.push(`[mem] Lessons for ${fname}:`);
663
+ // D#202: this block merges TWO TABLES and rendered both with a bare `#NN`.
664
+ // lib/events-injection.mjs already established the `E#` prefix for exactly
665
+ // this reason, and its header even enumerates the extractors the prefix
666
+ // protects — FYI, memory-context, error-recall. It does not name THIS face,
667
+ // which is the one that was breaking the invariant.
668
+ //
669
+ // Measured on the live metrics log (4227 `pretool_recall` firings,
670
+ // 2026-07-18 -> 2026-09-02): 44.9% of the rows injected here are
671
+ // event-sourced and 40.2% of firings inject events only. Two costs:
672
+ // * a reader cannot tell which table to follow an id into — a
673
+ // `--supersedes` or `mem_get` on one fails for no visible reason;
674
+ // * load-bearing: extractInjectedFromPreToolUse reads these ids into the
675
+ // citation-decay DENOMINATOR, and applyCitationDecay resolves them
676
+ // against `observations` alone, so an event id colliding with a live
677
+ // SAME-PROJECT observation streaked or promoted an unrelated memory.
678
+ // 198 of 5476 injectable events (3.6%) sit in that position.
679
+ // The `E#` prefix closes the second by construction: INJECTED_ROW_RE
680
+ // anchors `#` after at most six spaces, so `E#` cannot match it.
663
681
  for (const r of allRows) {
682
+ const idTag = `${r.src === 'evt' ? EVENT_ID_PREFIX : '#'}${r.id}`;
664
683
  if (r.lesson_learned) {
665
684
  const lesson = r.lesson_learned.length > LESSON_MAX
666
685
  ? r.lesson_learned.slice(0, LESSON_MAX - 3) + '...'
667
686
  : r.lesson_learned;
668
- lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(lesson)}`);
687
+ lines.push(` ${idTag} [${r.type}] ${neutralizeContextDelimiters(lesson)}`);
669
688
  } else {
670
689
  const title = (r.title || '').length > LESSON_MAX
671
690
  ? r.title.slice(0, LESSON_MAX - 3) + '...'
672
691
  : (r.title || '');
673
- lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(title)}`);
692
+ lines.push(` ${idTag} [${r.type}] ${neutralizeContextDelimiters(title)}`);
674
693
  }
675
694
  }
676
695
  // v2.98 salience: Edit/Write is the action point — close the block with an