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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/cli-path.mjs +5 -1
- package/hook-context.mjs +88 -24
- package/hook-llm.mjs +10 -2
- package/hook-memory.mjs +23 -13
- package/hook.mjs +45 -4
- package/lib/citation-tracker.mjs +176 -104
- package/lib/deferred-work.mjs +81 -8
- package/lib/events-injection.mjs +12 -1
- package/lib/injected-ids.mjs +17 -0
- package/lib/maintain-core.mjs +4 -2
- package/lib/native-binding-hint.mjs +5 -2
- package/lib/save-observation.mjs +231 -12
- package/mem-cli.mjs +53 -17
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scoring-sql.mjs +11 -1
- package/scripts/pre-tool-recall.js +32 -6
- package/server.mjs +19 -9
- package/tool-schemas.mjs +11 -4
package/lib/save-observation.mjs
CHANGED
|
@@ -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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
953
|
-
//
|
|
954
|
-
//
|
|
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
|
-
|
|
959
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 →
|
|
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
|
|
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
|
-
|
|
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);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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
|
|
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(`
|
|
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(`
|
|
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.
|
|
751
|
-
//
|
|
752
|
-
//
|
|
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
|