claude-mem-lite 3.87.0 → 3.89.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.
@@ -18,6 +18,105 @@ 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
+ 'no-such-event': 'no event with that id',
26
+ 'other-project': 'belongs to a different project',
27
+ 'already-superseded': 'already superseded (no-op)',
28
+ 'duplicate-save': 'the save deduped, so nothing was superseded',
29
+ };
30
+
31
+ /**
32
+ * Split `--supersedes` tokens into the two tables they can name (D#205).
33
+ *
34
+ * `E#<n>` addresses the `events` table, matching the prefix those rows are
35
+ * RENDERED with in the injected lessons block since D#202 — so a reader who sees
36
+ * `E#10524` can retire it by typing back exactly what they read. A bare number
37
+ * stays an observation id: observations are the incumbent namespace, and the same
38
+ * asymmetry is already how `lib/injected-ids.mjs` writes the shared marker file.
39
+ *
40
+ * Anything else is malformed and is REPORTED rather than dropped — D#201's whole
41
+ * point, and the reason this returns the caller's original token for those.
42
+ *
43
+ * @param {Array<any>} raw
44
+ * @returns {{obs: number[], events: number[], malformed: Array<{id: any, reason: string}>}}
45
+ */
46
+ export function splitSupersedeTokens(raw) {
47
+ const obs = new Set();
48
+ const events = new Set();
49
+ const malformed = [];
50
+ for (const t of Array.isArray(raw) ? raw : []) {
51
+ const s = typeof t === 'string' ? t.trim() : t;
52
+ const m = typeof s === 'string' ? /^[Ee]#?(\d+)$/.exec(s) : null;
53
+ if (m) {
54
+ const n = Number(m[1]);
55
+ if (Number.isInteger(n) && n > 0) { events.add(n); continue; }
56
+ // `kind` survives even on the malformed branch, so `E#0` reports as `E#0` and not
57
+ // `#E#0` — the formatter prefixes by kind, and a doubled prefix on the one line
58
+ // whose job is to echo what the caller typed reads as a second defect.
59
+ malformed.push({ id: t, reason: 'malformed-id', kind: 'event' });
60
+ continue;
61
+ }
62
+ const n = Number(s);
63
+ if (Number.isInteger(n) && n > 0) obs.add(n);
64
+ else malformed.push({ id: t, reason: 'malformed-id', kind: 'obs' });
65
+ }
66
+ return { obs: [...obs], events: [...events], malformed };
67
+ }
68
+
69
+ /**
70
+ * Render the D#201 warning for requested-but-not-superseded ids. Lives here
71
+ * rather than in either face so the CLI and the MCP tool cannot word it
72
+ * differently or, more to the point, so one of them cannot quietly stop
73
+ * rendering it.
74
+ *
75
+ * D#205: an entry carrying `kind: 'event'` is rendered `E#<id>`, the same prefix the
76
+ * lessons block shows it under, so the id echoed back is the id the caller typed. The
77
+ * prefix is added HERE rather than baked into `id` because callers compare `id` against
78
+ * real row ids; a pre-prefixed string would silently break that.
79
+ *
80
+ * @param {Array<{id: any, reason: string, kind?: 'obs'|'event'}>} [skipped]
81
+ * @returns {string|null} null when nothing was skipped
82
+ */
83
+ /**
84
+ * Render the ` Superseded: …` note for a successful save (D#205).
85
+ *
86
+ * Lives here for the same reason `formatSupersedeSkipped` does: the CLI and the MCP tool
87
+ * each hand-built this string, and when events became supersedable one of the two would
88
+ * have kept printing observations only. This round fixed three separate instances of
89
+ * "the copy I fixed was not the only copy"; a shared renderer is the form that stops the
90
+ * fourth. `tests/save-observation-supersedes.test.mjs` sweeps both faces for the call.
91
+ *
92
+ * Events render with the `E#` prefix and are listed AFTER observations rather than merged,
93
+ * because the two tables share an id space and a flat list of bare `#N` would not say
94
+ * which row was retired.
95
+ *
96
+ * @param {{supersededIds?: number[], supersededEventIds?: number[]}} [result]
97
+ * @returns {string} '' when nothing was superseded (safe to concatenate)
98
+ */
99
+ export function formatSupersededNote(result) {
100
+ const obs = result?.supersededIds ?? [];
101
+ const events = result?.supersededEventIds ?? [];
102
+ if (obs.length === 0 && events.length === 0) return '';
103
+ const parts = [...obs.map((i) => `#${i}`), ...events.map((i) => `E#${i}`)];
104
+ return ` Superseded: ${parts.join(', ')}.`;
105
+ }
106
+
107
+ export function formatSupersedeSkipped(skipped) {
108
+ if (!Array.isArray(skipped) || skipped.length === 0) return null;
109
+ const parts = skipped.map(({ id, reason, kind }) => {
110
+ // Only a resolved NUMERIC id gets a prefix. A malformed entry carries the caller's
111
+ // ORIGINAL token, which may already contain its own `#` or `E#` — prefixing that
112
+ // produced `#E#0` (and, after a first attempt at fixing it, `E#E#0`). Echoing an
113
+ // unparseable token exactly as typed is also the more useful thing to print.
114
+ const label = typeof id === 'number' ? `${kind === 'event' ? 'E#' : '#'}${id}` : String(id);
115
+ return `${label} (${SUPERSEDE_SKIP_CAUSE[reason] || reason})`;
116
+ });
117
+ return `⚠ --supersedes: ${parts.length} id(s) NOT superseded — ${parts.join(', ')}.`;
118
+ }
119
+
21
120
  /**
22
121
  * Save a new observation if it isn't a near-duplicate of one saved within the
23
122
  * last 5 minutes (Jaccard similarity > 0.7 on title or content).
@@ -32,8 +131,24 @@ const DEDUP_RECENT_LIMIT = 50;
32
131
  * @param {string[]} [params.files=[]] File paths to attach (junction table).
33
132
  * @param {string|null} [params.lesson_learned] Caller validates ≤500 chars.
34
133
  * @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 }}
134
+ * Both result shapes carry `supersededIds` (observations actually tombstoned) and
135
+ * `supersedeSkipped` (requested but NOT tombstoned, each with a `reason`:
136
+ * `malformed-id` | `no-such-observation` | `no-such-event` | `other-project` |
137
+ * `already-superseded` | `duplicate-save`, plus `kind: 'obs'|'event'`). Callers MUST
138
+ * surface a non-empty `supersedeSkipped` — that is the whole point of D#201; dropping it
139
+ * puts the silent failure back.
140
+ *
141
+ * The `saved` shape also carries `supersededEventIds` (D#205), kept separate rather than
142
+ * merged: `events` and `observations` share an id space, so one flat list of bare `#N`
143
+ * could not say which table a retired row came from. Use `formatSupersededNote` to render
144
+ * both rather than reassembling the string per face.
145
+ *
146
+ * @returns {{ kind: 'duplicate', existingId: number, project: string, type: string,
147
+ * supersededIds: number[],
148
+ * supersedeSkipped: Array<{id: any, reason: string, kind?: string}> }
149
+ * | { kind: 'saved', id: number, type: string, project: string, title: string,
150
+ * lessonCaptured: boolean, supersededIds: number[], supersededEventIds: number[],
151
+ * supersedeSkipped: Array<{id: any, reason: string, kind?: string}> }}
37
152
  */
38
153
  export function saveObservation(db, params) {
39
154
  const now = params.now instanceof Date ? params.now : new Date();
@@ -83,12 +198,45 @@ export function saveObservation(db, params) {
83
198
  ORDER BY created_at_epoch DESC LIMIT ?
84
199
  `).all(project, dedupCutoff, DEDUP_RECENT_LIMIT);
85
200
 
201
+ // Requested supersession targets, normalized. Declared BEFORE the dedup
202
+ // short-circuit because that path reports on them too.
203
+ // Self-reference is filtered inside the transaction, once the new id exists.
204
+ //
205
+ // D#201: the tokens that DON'T survive normalization are kept, not dropped.
206
+ // A caller who names an id and gets no supersession has to be told which id
207
+ // and why — the previous shape reported "requested 4, superseded 0" and
208
+ // "requested nothing" identically, so a mistyped or wrong-table id read as a
209
+ // clean success. `malformed-id` is the pre-query class; the DB-level classes
210
+ // are decided inside the transaction.
211
+ //
212
+ // D#205: `E#<n>` addresses the events table. Until this release `--supersedes` could
213
+ // only retire an observation, so a conclusion carried by an EVENT row had no retirement
214
+ // path at all and kept injecting from two faces after later measurement overturned it
215
+ // (the founding case: event #10524's "2.1-3.8x per call", retracted in prose while the
216
+ // row stayed live). D#201 made that failure loud; this makes it fixable.
217
+ const rawSupersedes = Array.isArray(params.supersedes) ? params.supersedes : [];
218
+ const { obs: requestedSupersedes, events: requestedSupersedeEvents, malformed: malformedSupersedes } =
219
+ splitSupersedeTokens(rawSupersedes);
220
+
86
221
  const dupMatch = recent.find((r) =>
87
222
  jaccardSimilarity(r.title, safeTitle) > DEDUP_JACCARD_THRESHOLD ||
88
223
  jaccardSimilarity(r.text || '', safeContent) > DEDUP_JACCARD_THRESHOLD
89
224
  );
90
225
  if (dupMatch) {
91
- return { kind: 'duplicate', existingId: dupMatch.id, project, type };
226
+ // D#201: a dedup short-circuit swallows the requested supersession too — you
227
+ // write a correction, it reads as a near-duplicate of something saved in the
228
+ // last 5 minutes, and the rows you meant to retire stay live. Same sentence
229
+ // as the ineligible-id case ("requested, did not happen, no trace"), so it
230
+ // reports through the same channel rather than staying quiet.
231
+ return {
232
+ kind: 'duplicate', existingId: dupMatch.id, project, type,
233
+ supersededIds: [],
234
+ supersedeSkipped: [
235
+ ...malformedSupersedes,
236
+ ...requestedSupersedes.map((id) => ({ id, reason: 'duplicate-save', kind: 'obs' })),
237
+ ...requestedSupersedeEvents.map((id) => ({ id, reason: 'duplicate-save', kind: 'event' })),
238
+ ],
239
+ };
92
240
  }
93
241
 
94
242
  // FTS-indexed text field includes title + content + lesson + CJK bigrams,
@@ -99,13 +247,6 @@ export function saveObservation(db, params) {
99
247
  const bigramText = cjkBigrams(indexText);
100
248
  const textField = bigramText ? safeContent + ' ' + bigramText : safeContent;
101
249
 
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
250
  // Atomic: observation row + observation_files junction + observation_vectors
110
251
  // (TF-IDF) + supersession tombstones. Vector write is best-effort — vocab may be
111
252
  // uninitialized on a fresh DB; failure must not roll back the observation.
@@ -139,6 +280,7 @@ export function saveObservation(db, params) {
139
280
  // Write-the-correction and retire-its-predecessors is one unit or neither.
140
281
  const ids = requestedSupersedes.filter((n) => n !== savedId);
141
282
  let supersededIds = [];
283
+ const skipped = [];
142
284
  if (ids.length > 0) {
143
285
  const ph = ids.map(() => '?').join(',');
144
286
  const eligible = db.prepare(
@@ -150,11 +292,80 @@ export function saveObservation(db, params) {
150
292
  .run(now.getTime(), savedId, ...eligible);
151
293
  supersededIds = eligible;
152
294
  }
295
+ // D#201: classify the difference instead of discarding it. One extra
296
+ // SELECT, and only when something actually failed to land — the happy
297
+ // path (every id eligible) skips it entirely.
298
+ const landed = new Set(eligible);
299
+ const missed = ids.filter((n) => !landed.has(n));
300
+ if (missed.length > 0) {
301
+ const ph3 = missed.map(() => '?').join(',');
302
+ const rows = new Map(db.prepare(
303
+ `SELECT id, project, superseded_at FROM observations WHERE id IN (${ph3})`
304
+ ).all(...missed).map((r) => [r.id, r]));
305
+ for (const n of missed) {
306
+ const row = rows.get(n);
307
+ // Order matters: a row can be BOTH foreign-project and already
308
+ // superseded, and "it isn't yours" is the more actionable of the two.
309
+ if (!row) skipped.push({ id: n, reason: 'no-such-observation', kind: 'obs' });
310
+ else if (row.project !== project) skipped.push({ id: n, reason: 'other-project', kind: 'obs' });
311
+ else skipped.push({ id: n, reason: 'already-superseded', kind: 'obs' });
312
+ }
313
+ }
314
+ }
315
+
316
+ // D#205, the events half. Same three DB-level classes, same one-extra-SELECT-only-on-
317
+ // failure shape, and inside the SAME transaction as the observation write for the same
318
+ // reason: a correction that lands while the row it overturns stays live is the state
319
+ // supersession exists to prevent.
320
+ //
321
+ // `superseded_at_epoch` now carries TWO meanings on this table, and nothing
322
+ // distinguishes them: `lib/activity.mjs promoteInsightEvents` already stamps it to mark
323
+ // an event PROMOTED into an observation (its idempotency gate selects on
324
+ // `superseded_at_epoch IS NULL`), and this writes it to mean RETIRED BY A CORRECTION.
325
+ // Both consequences are benign today — a retired event correctly stops being a
326
+ // promotion candidate, and a promoted one correctly reports `already-superseded` as a
327
+ // no-op — but the skip reason reads slightly wrong for a promoted row, and any future
328
+ // code wanting to tell the two apart will need a second column. Flagged by the v3.89.0
329
+ // pre-tag review; not split here because inventing a column to record a distinction
330
+ // nothing currently reads is the kind of speculative schema change this repo avoids.
331
+ //
332
+ // `superseded_by_id` is deliberately left NULL. That column is
333
+ // `INTEGER REFERENCES events(id)`, so it cannot hold the id of the OBSERVATION doing
334
+ // the retiring — writing `savedId` there would point at whatever event happens to
335
+ // share the number, which is exactly the cross-table id collision D#202 just closed
336
+ // (25.7% of injectable events share an id with a live observation). A missing link is
337
+ // recoverable; a wrong one is not.
338
+ let supersededEventIds = [];
339
+ if (requestedSupersedeEvents.length > 0) {
340
+ const ph = requestedSupersedeEvents.map(() => '?').join(',');
341
+ const eligible = db.prepare(
342
+ `SELECT id FROM events WHERE id IN (${ph}) AND project = ? AND superseded_at_epoch IS NULL`
343
+ ).all(...requestedSupersedeEvents, project).map((r) => r.id);
344
+ if (eligible.length > 0) {
345
+ const ph2 = eligible.map(() => '?').join(',');
346
+ db.prepare(`UPDATE events SET superseded_at_epoch = ? WHERE id IN (${ph2})`)
347
+ .run(now.getTime(), ...eligible);
348
+ supersededEventIds = eligible;
349
+ }
350
+ const landed = new Set(eligible);
351
+ const missed = requestedSupersedeEvents.filter((n) => !landed.has(n));
352
+ if (missed.length > 0) {
353
+ const ph3 = missed.map(() => '?').join(',');
354
+ const rows = new Map(db.prepare(
355
+ `SELECT id, project, superseded_at_epoch FROM events WHERE id IN (${ph3})`
356
+ ).all(...missed).map((r) => [r.id, r]));
357
+ for (const n of missed) {
358
+ const row = rows.get(n);
359
+ if (!row) skipped.push({ id: n, reason: 'no-such-event', kind: 'event' });
360
+ else if (row.project !== project) skipped.push({ id: n, reason: 'other-project', kind: 'event' });
361
+ else skipped.push({ id: n, reason: 'already-superseded', kind: 'event' });
362
+ }
363
+ }
153
364
  }
154
365
 
155
- return { savedId, supersededIds };
366
+ return { savedId, supersededIds, supersededEventIds, skipped };
156
367
  });
157
- const { savedId, supersededIds } = saveTx();
368
+ const { savedId, supersededIds, supersededEventIds, skipped } = saveTx();
158
369
 
159
370
  return {
160
371
  kind: 'saved',
@@ -164,5 +375,13 @@ export function saveObservation(db, params) {
164
375
  title: safeTitle,
165
376
  lessonCaptured: Boolean(safeLesson),
166
377
  supersededIds,
378
+ // D#205: kept in its OWN array rather than merged into supersededIds. The two are
379
+ // different tables that share an id space, so a merged list would be ambiguous at
380
+ // exactly the point a reader needs to know which row was retired.
381
+ supersededEventIds,
382
+ // D#201: requested-but-not-superseded, with a reason each. Malformed tokens
383
+ // are prepended because they were rejected before the query and so carry the
384
+ // caller's ORIGINAL token (which may not even be a number) rather than an id.
385
+ supersedeSkipped: [...malformedSupersedes, ...skipped],
167
386
  };
168
387
  }
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, formatSupersededNote } 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,
@@ -896,7 +896,7 @@ function cmdSave(db, args) {
896
896
  const text = resolvePositionalAlias(positional.join(' '), flags, ['text', 'content']);
897
897
  if (text === null) return;
898
898
  if (!text.trim()) {
899
- fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T] [--closes-deferred 1,D#42] [--supersedes 8754,8771] — content may also be passed via --text/--content "<text>"');
899
+ fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T] [--closes-deferred 1,D#42] [--supersedes 8754,E#10524] — content may also be passed via --text/--content "<text>"');
900
900
  return;
901
901
  }
902
902
 
@@ -949,16 +949,23 @@ function cmdSave(db, args) {
949
949
  }
950
950
  }
951
951
 
952
- // --supersedes: comma-separated observation ids this save overturns. On save they
953
- // are tombstoned (drop out of live search) + linked (superseded_by = the new id).
954
- // Only same-project live rows are affected (enforced in saveObservation).
952
+ // --supersedes: comma-separated ids this save overturns — a bare number for an
953
+ // observation, `E#<n>` for an events row (D#205). Both are tombstoned (dropped from
954
+ // live search); only the observation half is LINKED (`superseded_by` = the new id),
955
+ // because `events.superseded_by_id` references events and cannot hold an observation
956
+ // id. Only same-project live rows are affected (enforced in saveObservation).
955
957
  let supersedesIds = null;
956
958
  if (flags.supersedes !== undefined && flags.supersedes !== false) {
957
959
  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);
960
+ // Tokens go through UNPARSED so saveObservation's classifier — not parseInt — decides
961
+ // what is malformed. parseInt is lenient in the one direction that costs data: it read
962
+ // `1abc` as 1, so a typo (`875x` for `8754`) tombstoned an unrelated observation and
963
+ // printed a clean `Superseded: #1.` Worse, the token vanished before saveObservation
964
+ // saw it, which made the `malformed-id` class D#201 added unreachable from the CLI —
965
+ // the exact face whose silence motivated D#201. Number() rejects `1abc` as NaN.
966
+ supersedesIds = raw.split(',').map((t) => t.trim()).filter(Boolean);
960
967
  if (supersedesIds.length === 0) {
961
- fail('[mem] --supersedes requires at least one positive observation id (e.g. --supersedes 8754,8771)');
968
+ fail('[mem] --supersedes requires at least one id: a number for an observation, or E#<n> for an event (e.g. --supersedes 8754,E#10524)');
962
969
  return;
963
970
  }
964
971
  }
@@ -984,7 +991,11 @@ function cmdSave(db, args) {
984
991
  // the deferred row has transitioned out of 'open'.
985
992
  if (r.kind === 'duplicate') return r;
986
993
  if (closesTokens) {
987
- closesIds = resolveDeferredIds(db, project, closesTokens);
994
+ // D#195: the close verb accepts a 'dropped' row and converts it to
995
+ // 'done'. `defer drop` used on an item that was actually fixed was
996
+ // otherwise a one-way gate, permanently losing the obs link. Kept in
997
+ // sync with the same policy in server.mjs mem_save.
998
+ closesIds = resolveDeferredIds(db, project, closesTokens, { allowStatuses: ['open', 'dropped'] });
988
999
  closeDeferredItems(db, closesIds, r.id);
989
1000
  }
990
1001
  return r;
@@ -1000,6 +1011,10 @@ function cmdSave(db, args) {
1000
1011
 
1001
1012
  if (result.kind === 'duplicate') {
1002
1013
  out(`[mem] Skipped: similar to existing #${result.existingId}. Use "claude-mem-lite get ${result.existingId}" to review.`);
1014
+ // D#201: the dedup swallowed the requested supersession too — say so here,
1015
+ // because this branch returns before the note below is ever reached.
1016
+ const dupSkip = formatSupersedeSkipped(result.supersedeSkipped);
1017
+ if (dupSkip) out(`[mem] ${dupSkip}`);
1003
1018
  return;
1004
1019
  }
1005
1020
 
@@ -1007,14 +1022,16 @@ function cmdSave(db, args) {
1007
1022
  const closedNote = closesIds && closesIds.length > 0
1008
1023
  ? ` Closed: ${closesIds.map(i => `D#${i}`).join(', ')}.`
1009
1024
  : '';
1010
- const supersededNote = result.supersededIds && result.supersededIds.length > 0
1011
- ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
1012
- : '';
1025
+ const supersededNote = formatSupersededNote(result);
1013
1026
  // G1+G2: detached backfill worker (lesson for obligated types + aliases for
1014
1027
  // every save) — fill-only-empty, so an agent acting on the nudge still wins.
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.87.0",
3
+ "version": "3.89.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.87.0",
9
+ "version": "3.89.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.87.0",
3
+ "version": "3.89.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
@@ -747,9 +766,16 @@ try {
747
766
  };
748
767
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
749
768
  // A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
750
- // file so the next UPS prompt skips them too. Always write, even on
751
- // empty allRows, so the file's ts stays fresh for the no-op case where
752
- // we'd otherwise drift outside the dedup window.
769
+ // file so the next UPS prompt skips them too.
770
+ //
771
+ // This comment used to say "Always write, even on empty allRows, so the file's ts
772
+ // stays fresh". It does not: `mergeCrossHookInjected` returns on line 268 when
773
+ // `newIds` is empty, so a firing that emits nothing leaves the timestamp where it
774
+ // was and the marker can age out of the dedup window. Corrected rather than
775
+ // implemented — refreshing `ts` on an empty firing would EXTEND suppression on the
776
+ // strength of an injection that did not happen, which is the opposite of what the
777
+ // window is for. The practical consequence is only that the trigger condition for
778
+ // D#193 is "PreToolUse emitted at least one row in the window", not "always".
753
779
  // D#188: namespaced on write too — otherwise a bare event id here would keep
754
780
  // blocking the same-numbered observation on the next UPS prompt. (An earlier
755
781
  // version of this comment also claimed it leaked into hook.mjs's