claude-mem-lite 3.89.0 → 3.90.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/hook-memory.mjs +33 -7
- package/hook.mjs +64 -3
- package/lib/injected-ids.mjs +4 -1
- package/lib/patha-exclude-meter.mjs +274 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/source-files.mjs +1 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.90.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.90.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/hook-memory.mjs
CHANGED
|
@@ -247,7 +247,28 @@ function hasFilePaths(filesModified) {
|
|
|
247
247
|
* @param {number[]} excludeIds Observation IDs already in Key Context
|
|
248
248
|
* @returns {object[]} Top memories (max 3) with {id, type, title, lesson_learned}
|
|
249
249
|
*/
|
|
250
|
-
|
|
250
|
+
/**
|
|
251
|
+
* @param {object} [opts]
|
|
252
|
+
* @param {boolean} [opts.counterfactual] — this call is a MEASUREMENT, not a delivery.
|
|
253
|
+
* Nothing it returns is shown to the model, so it must leave no trace: no
|
|
254
|
+
* `injection_count` / `last_injected_at` bump, and no `inject` metric row.
|
|
255
|
+
*
|
|
256
|
+
* Added for `lib/patha-exclude-meter.mjs`'s arm B (D#214). The first version of that
|
|
257
|
+
* ruler handed this function the live writable handle, and the pre-tag review
|
|
258
|
+
* reproduced both halves of the damage: rows that were never shown to anyone reached
|
|
259
|
+
* `injection_count = 1` — which feeds `noisePenaltyClause`, `demotePinned`'s
|
|
260
|
+
* `injection_count >= N AND cited_count = 0` predicate, and the `injection_count = 0`
|
|
261
|
+
* GC-eligibility gate — and the `inject` meter counted two calls per prompt, on
|
|
262
|
+
* exactly the installs where the D#214 corpus is gathered. CLAUDE.md already carried
|
|
263
|
+
* this rule for `rerank-pool-replay` ("the handle must reject a write … a writable
|
|
264
|
+
* handle would move the very noise signal being measured"); the new ruler quoted it
|
|
265
|
+
* and then broke it.
|
|
266
|
+
*
|
|
267
|
+
* A read-only handle would also work; a flag is used instead because the caller needs
|
|
268
|
+
* BOTH arms to see one store state, which is achieved by ordering (arm B first, and
|
|
269
|
+
* it writes nothing) rather than by isolation.
|
|
270
|
+
*/
|
|
271
|
+
export function searchRelevantMemories(db, userPrompt, project, excludeIds = [], { counterfactual = false } = {}) {
|
|
251
272
|
// Min-length guard is English-centric: 5 chars ≈ one short English word. A CJK
|
|
252
273
|
// query is meaningful at 2 chars (状态/架构) and most real Chinese queries are
|
|
253
274
|
// 2-4 chars (状态管理, 召回率, 熔断降级) — the bare `.length < 5` silently
|
|
@@ -271,6 +292,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
271
292
|
const _t0 = Date.now();
|
|
272
293
|
let _candidates = 0, _aboveThreshold = 0, _returned = 0, _orFired = false;
|
|
273
294
|
const _emit = () => {
|
|
295
|
+
if (counterfactual) return;
|
|
274
296
|
try {
|
|
275
297
|
recordMetric(DB_DIR, {
|
|
276
298
|
event: 'inject',
|
|
@@ -472,12 +494,16 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
472
494
|
// denominator is citation_surface_log, not this column.
|
|
473
495
|
// Per-row try/catch for FTS trigger safety (project_non_obvious.md).
|
|
474
496
|
const result = coverageFiltered.slice(0, MAX_MEMORY_INJECTIONS);
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
497
|
+
// `counterfactual` skips the bump entirely rather than reverting it: these rows were
|
|
498
|
+
// never shown to anyone, and `injection_count` is read by three ranking/GC paths.
|
|
499
|
+
if (!counterfactual) {
|
|
500
|
+
const now = Date.now();
|
|
501
|
+
const bumpStmt = db.prepare(
|
|
502
|
+
'UPDATE observations SET injection_count = COALESCE(injection_count, 0) + 1, last_injected_at = ? WHERE id = ?'
|
|
503
|
+
);
|
|
504
|
+
for (const r of result) {
|
|
505
|
+
try { bumpStmt.run(now, r.id); } catch {}
|
|
506
|
+
}
|
|
481
507
|
}
|
|
482
508
|
|
|
483
509
|
_returned = result.length;
|
package/hook.mjs
CHANGED
|
@@ -84,6 +84,7 @@ import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs
|
|
|
84
84
|
import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
85
85
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
86
86
|
import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
|
|
87
|
+
import { pathAMeterEnabled, coerceMarkerIds, recordPathAExclude } from './lib/patha-exclude-meter.mjs';
|
|
87
88
|
import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
|
|
88
89
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
89
90
|
import { selectErrorRecall } from './lib/error-recall-core.mjs';
|
|
@@ -2214,9 +2215,12 @@ async function handleUserPrompt() {
|
|
|
2214
2215
|
// from the pool and sometimes just lost (`rerank-pool-replay`: 6587 of 11289
|
|
2215
2216
|
// prompts already inject nothing) and the `ups` cite-rate is 8.1%.
|
|
2216
2217
|
//
|
|
2217
|
-
// The ruler that
|
|
2218
|
-
// exclude
|
|
2219
|
-
//
|
|
2218
|
+
// The ruler that settles it is now BUILT and sits at the bottom of this same
|
|
2219
|
+
// function: `lib/patha-exclude-meter.mjs`, off unless CLAUDE_MEM_METRICS=1. It
|
|
2220
|
+
// does not persist the marker for an offline replay — reconstructing per-prompt
|
|
2221
|
+
// exclude sets that way needs a file that rotates after DEDUP_STALE_MS, and the
|
|
2222
|
+
// replay would then run against a drifted database. Both arms run at this read
|
|
2223
|
+
// instead. What is still missing is elapsed time, not a method. D#213.
|
|
2220
2224
|
// tests/pathA-exclude-inert.test.mjs pins this state so a silent flip goes red.
|
|
2221
2225
|
for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
|
|
2222
2226
|
}
|
|
@@ -2238,6 +2242,42 @@ async function handleUserPrompt() {
|
|
|
2238
2242
|
// until then this stays experimental and off.
|
|
2239
2243
|
const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
|
|
2240
2244
|
|| process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
|
|
2245
|
+
// ── D#214 arm B (counterfactual), computed BEFORE the delivered arm ─────────
|
|
2246
|
+
// Ordering is the whole correctness argument, so it is stated where the order is:
|
|
2247
|
+
// arm A's search legitimately bumps `injection_count` on every row it delivers,
|
|
2248
|
+
// and that column feeds `noisePenaltyClause`. Running the counterfactual AFTER it
|
|
2249
|
+
// — as the first version did — lets arm A push a row across the >=4 noise gate and
|
|
2250
|
+
// then attributes the resulting difference to the repair. The pre-tag review
|
|
2251
|
+
// reproduced that: a marker id for a row the query never matches, where the honest
|
|
2252
|
+
// answer is `suppressed 0 / refilled 0`, reported `refilled: 1, setChanged: true`.
|
|
2253
|
+
//
|
|
2254
|
+
// So arm B runs first, on the same handle, with `counterfactual: true` — it writes
|
|
2255
|
+
// nothing and emits no `inject` metric row, so arm A afterwards sees exactly the
|
|
2256
|
+
// state arm B saw. Both arms, one state, and neither one perturbs the other.
|
|
2257
|
+
//
|
|
2258
|
+
// Arm B also carries its OWN imperative pick. Reusing arm A's put a pick the
|
|
2259
|
+
// repaired system would not have made into arm B's exclude, so on any prompt where
|
|
2260
|
+
// the pick changed, the delta described a system that does not exist.
|
|
2261
|
+
const meterCoerced = (pathAMeterEnabled() && pathAInjectedIds.length > 0)
|
|
2262
|
+
? [...coerceMarkerIds(pathAInjectedIds)]
|
|
2263
|
+
: null;
|
|
2264
|
+
let meterArmB = null;
|
|
2265
|
+
if (meterCoerced) {
|
|
2266
|
+
try {
|
|
2267
|
+
const pickB = taskImperativeOn
|
|
2268
|
+
? selectImperativeLesson(db, promptText, project, [...pathAInjectedIds, ...meterCoerced])
|
|
2269
|
+
: null;
|
|
2270
|
+
const excludeB = pickB ? [...keyContextIds, pickB.id] : keyContextIds;
|
|
2271
|
+
meterArmB = {
|
|
2272
|
+
rows: searchRelevantMemories(db, promptText, project, [...excludeB, ...meterCoerced], { counterfactual: true }),
|
|
2273
|
+
pick: pickB ? pickB.id : null,
|
|
2274
|
+
};
|
|
2275
|
+
} catch (e) {
|
|
2276
|
+
debugCatch(e, 'patha-exclude-meter-armB');
|
|
2277
|
+
meterArmB = { error: String(e?.message || 'unknown') };
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2241
2281
|
// Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
|
|
2242
2282
|
// SessionStart Key Context set, which overlaps the high-value lesson pool and
|
|
2243
2283
|
// would suppress the pick. The chosen id is excluded from the <memory-context>
|
|
@@ -2282,6 +2322,27 @@ async function handleUserPrompt() {
|
|
|
2282
2322
|
const imperativeLine = formatTaskImperative(imperativePick.lesson_learned, imperativePick.id);
|
|
2283
2323
|
if (imperativeLine) process.stdout.write(imperativeLine + '\n');
|
|
2284
2324
|
}
|
|
2325
|
+
|
|
2326
|
+
// D#214's ruler, second half: arm B was computed above, before anything was
|
|
2327
|
+
// delivered; this only shapes the row and appends it. Kept after every
|
|
2328
|
+
// `process.stdout.write` so the metric append is never in front of the injection,
|
|
2329
|
+
// and so a throw here cannot corrupt what was already emitted.
|
|
2330
|
+
//
|
|
2331
|
+
// `meterCoerced` being non-null is the gate — it is null unless
|
|
2332
|
+
// CLAUDE_MEM_METRICS=1 AND the marker carried ids, which is what keeps both the
|
|
2333
|
+
// counterfactual search and the second lesson selection off a stock install.
|
|
2334
|
+
try {
|
|
2335
|
+
if (meterCoerced) {
|
|
2336
|
+
recordPathAExclude(join(RUNTIME_DIR, '..'), {
|
|
2337
|
+
markerIds: pathAInjectedIds,
|
|
2338
|
+
emitted: memories,
|
|
2339
|
+
after: meterArmB,
|
|
2340
|
+
imperativeArm: taskImperativeOn ? 'on' : 'off',
|
|
2341
|
+
imperativeBefore: imperativePick ? imperativePick.id : null,
|
|
2342
|
+
imperativeAfter: meterArmB ? (meterArmB.pick ?? null) : null,
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
} catch (e) { debugCatch(e, 'patha-exclude-meter'); }
|
|
2285
2346
|
} catch (e) { debugCatch(e, 'handleUserPrompt-memory'); }
|
|
2286
2347
|
} finally {
|
|
2287
2348
|
db.close();
|
package/lib/injected-ids.mjs
CHANGED
|
@@ -80,7 +80,10 @@ export function injectedIdsFileName(project, sessionId) {
|
|
|
80
80
|
* SQLite. Measured: excluding `1` returns nothing, excluding `'1'` returns the row.
|
|
81
81
|
* The pre-tag correctness review found this. What it exposes is a real and separate
|
|
82
82
|
* defect — that exclude list is inert for every id the marker holds as a string,
|
|
83
|
-
* observations included — which is D#193,
|
|
83
|
+
* observations included — which is D#213 (re-filed twice from D#193, because the first
|
|
84
|
+
* two versions measured the marker's WRITER instead of its reader and published 18.0%
|
|
85
|
+
* for the mirror population; the corrected upper bound is 9.0%), not this one. Its ruler
|
|
86
|
+
* is lib/patha-exclude-meter.mjs.
|
|
84
87
|
*
|
|
85
88
|
* Legacy in-flight files (bare ids that were a mix of both tables) keep their old
|
|
86
89
|
* meaning for at most DEDUP_STALE_MS and then rotate; there is deliberately no
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// lib/patha-exclude-meter.mjs — the ruler D#213 named as its only blocker.
|
|
2
|
+
//
|
|
3
|
+
// D#213 (which replaced D#212, which replaced D#193) is not open because the mechanism
|
|
4
|
+
// is unclear. The mechanism is settled by reading: `mergeCrossHookInjected` `.map(String)`s
|
|
5
|
+
// the whole marker union, `hook.mjs handleUserPrompt` pushes those ids into
|
|
6
|
+
// `pathAInjectedIds` as they arrive, and both consumers test `new Set(excludeIds).has(r.id)`
|
|
7
|
+
// against a NUMBER out of SQLite — so from the first PreToolUse emission in the window the
|
|
8
|
+
// exclude suppresses nothing. It is open because nobody can price the repair:
|
|
9
|
+
//
|
|
10
|
+
// "reconstructing per-prompt exclude sets needs the marker file, which rotates after
|
|
11
|
+
// DEDUP_STALE_MS and is never persisted"
|
|
12
|
+
//
|
|
13
|
+
// THIS MODULE DOES NOT SOLVE THAT BY PERSISTING THE MARKER. Recording `{project, session,
|
|
14
|
+
// ids, ts}` at write time — the route the ledger leaned toward — buys a corpus that still
|
|
15
|
+
// has to be replayed later, against a database that has drifted, by a harness that has to
|
|
16
|
+
// re-derive which rows the search would have returned. This project has a standing rule
|
|
17
|
+
// about exactly that shape ("never diff two runs taken at different times"), and three
|
|
18
|
+
// separate rulers here carry a warning earned by breaking it.
|
|
19
|
+
//
|
|
20
|
+
// So the measurement is taken WHERE AND WHEN THE READ HAPPENS, both arms in one process
|
|
21
|
+
// against one database state microseconds apart:
|
|
22
|
+
//
|
|
23
|
+
// arm A (shipped) — the exclude as it arrives: inert against every string id.
|
|
24
|
+
// arm B (repaired) — the same call with the ids coerced to numbers.
|
|
25
|
+
//
|
|
26
|
+
// **ORDER AND SIDE EFFECTS ARE PART OF THE CONTRACT, and the caller owns both.** Arm B
|
|
27
|
+
// must run FIRST and must run with `counterfactual: true`. `searchRelevantMemories` is
|
|
28
|
+
// not a read: it bumps `injection_count` on every row it returns, and that column feeds
|
|
29
|
+
// `noisePenaltyClause`, `demotePinned`'s `injection_count >= N AND cited_count = 0`
|
|
30
|
+
// predicate, and the `injection_count = 0` GC gate. The first version of this module took
|
|
31
|
+
// a `rerun` CALLBACK, which invited the caller to run arm B wherever was convenient — and
|
|
32
|
+
// the convenient place, after the delivery, is the one place it is wrong. The pre-tag
|
|
33
|
+
// review reproduced both halves: rows never shown to anyone reached `injection_count = 1`,
|
|
34
|
+
// and a prompt whose honest answer was `suppressed 0 / refilled 0` reported
|
|
35
|
+
// `refilled: 1, setChanged: true`, purely because arm A's own bump pushed a row across the
|
|
36
|
+
// >= 4 noise gate before arm B scored the corpus. CLAUDE.md already carried this rule for
|
|
37
|
+
// `rerank-pool-replay` — this module quoted it and then broke it. `after` is therefore an
|
|
38
|
+
// already-computed result, not a callback: the caller owns the ordering, this file owns
|
|
39
|
+
// only the arithmetic.
|
|
40
|
+
//
|
|
41
|
+
// The difference between the two delivered sets is the price of the repair, per prompt,
|
|
42
|
+
// with no reconstruction and no drift. `refilled` is what the pool puts back into the
|
|
43
|
+
// freed slots — the direction the ledger calls unknown, and the reason a suppression
|
|
44
|
+
// count alone would not have settled anything.
|
|
45
|
+
//
|
|
46
|
+
// `suppressed` is EXACT rather than estimated. A draft justified that with "arm A's
|
|
47
|
+
// exclude is inert, so arm A's result IS the unexcluded search", which is unsound: arm A's
|
|
48
|
+
// exclude is inert only for the ids that arrive as strings, and on a marker of plain
|
|
49
|
+
// numbers it works. The conclusion survives on a different argument, which is the one to
|
|
50
|
+
// keep: an id arm A already excluded cannot appear in `emitted`, so `numeric ∩ emittedIds`
|
|
51
|
+
// reports the INCREMENTAL drop, which is exactly the quantity wanted. The row carries
|
|
52
|
+
// `markerNumbers` beside `markerStrings` so a reader can see which regime each prompt was
|
|
53
|
+
// in rather than having to trust this paragraph.
|
|
54
|
+
//
|
|
55
|
+
// COST AND GATING. Arm B is a second search on the UserPromptSubmit path, so it runs only
|
|
56
|
+
// when CLAUDE_MEM_METRICS=1 AND the marker actually carried ids. With metrics off (the
|
|
57
|
+
// default) nothing here executes and nothing is imported at cost — the module is pure ESM
|
|
58
|
+
// with no dependency beyond lib/metrics.mjs.
|
|
59
|
+
//
|
|
60
|
+
// WHAT THIS RULER CANNOT SEE, stated because a face omitted silently is how the citation
|
|
61
|
+
// replay shipped a wrong denominator: `task_imperative` is behind
|
|
62
|
+
// CLAUDE_MEM_TASK_IMPERATIVE and default OFF, so on a stock install its arm is recorded as
|
|
63
|
+
// `'off'` rather than dropped from the row. And this measures the `ups` face's own
|
|
64
|
+
// delivery only — it says nothing about whether the reader then cited what it got.
|
|
65
|
+
|
|
66
|
+
import { recordMetric } from './metrics.mjs';
|
|
67
|
+
|
|
68
|
+
/** Metric `event` name. Readers filter on this. */
|
|
69
|
+
export const PATHA_EXCLUDE_EVENT = 'patha_exclude';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Mirrors lib/metrics.mjs's private `metricsEnabled`. Duplicated deliberately rather
|
|
73
|
+
* than exported from there: this module must decide whether to run a SECOND SEARCH
|
|
74
|
+
* before it calls recordMetric, and a sink that no-ops after the work is done would
|
|
75
|
+
* make the expensive half unconditional.
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function pathAMeterEnabled() {
|
|
79
|
+
return process.env.CLAUDE_MEM_METRICS === '1';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How the marker's ids arrive, by JS type. The whole defect is a type, so the type
|
|
84
|
+
* distribution is the first thing any reading of this metric needs.
|
|
85
|
+
* @param {Array<number|string>} ids
|
|
86
|
+
* @returns {{total:number, strings:number, numbers:number, other:number}}
|
|
87
|
+
*/
|
|
88
|
+
export function markerTypeSplit(ids) {
|
|
89
|
+
const out = { total: 0, strings: 0, numbers: 0, other: 0 };
|
|
90
|
+
for (const id of ids || []) {
|
|
91
|
+
out.total++;
|
|
92
|
+
if (typeof id === 'string') out.strings++;
|
|
93
|
+
else if (typeof id === 'number') out.numbers++;
|
|
94
|
+
else out.other++;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The coercion the repair would apply, and no more than that.
|
|
101
|
+
*
|
|
102
|
+
* Event ids are namespaced `E<id>` in the marker (D#188) and are NOT observation ids.
|
|
103
|
+
* `Number('E42')` is NaN, and a NaN in an exclude Set is not merely useless — it is a
|
|
104
|
+
* second silent no-op wearing the costume of a fix. The `Number.isInteger` gate below
|
|
105
|
+
* is what excludes them, and it is the ONLY thing that does: a first version of this
|
|
106
|
+
* function carried an explicit `/^E/` skip above it, and mutating that line away left
|
|
107
|
+
* all 17 cases green, because no input reaches it that the integer gate does not also
|
|
108
|
+
* reject. Deleted rather than kept as a guard nobody can see fire (the D#197 precedent).
|
|
109
|
+
* The behaviour is still pinned by a test — what is gone is the unreachable branch.
|
|
110
|
+
*
|
|
111
|
+
* Same reasoning for anything non-integral or non-positive: an exclude set is a set of
|
|
112
|
+
* primary keys, so a value that cannot be one does not belong in it. The gate is
|
|
113
|
+
* `Number.isInteger(n) && n > 0`, not truthiness — `Number('')` and `Number(null)` are
|
|
114
|
+
* both 0, which `if (n)` would reject by accident and for the wrong reason.
|
|
115
|
+
* @param {Array<number|string>} ids
|
|
116
|
+
* @returns {Set<number>}
|
|
117
|
+
*/
|
|
118
|
+
export function coerceMarkerIds(ids) {
|
|
119
|
+
const out = new Set();
|
|
120
|
+
for (const raw of ids || []) {
|
|
121
|
+
const n = Number(raw);
|
|
122
|
+
if (Number.isInteger(n) && n > 0) out.add(n);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Rows a numerically-comparing exclude would have removed from what arm A delivered.
|
|
129
|
+
* Exact rather than estimated — see the header.
|
|
130
|
+
* @param {Array<number|string>} markerIds
|
|
131
|
+
* @param {number[]} emittedIds
|
|
132
|
+
* @returns {number[]}
|
|
133
|
+
*/
|
|
134
|
+
export function suppressedByWorkingExclude(markerIds, emittedIds) {
|
|
135
|
+
const numeric = coerceMarkerIds(markerIds);
|
|
136
|
+
return (emittedIds || []).filter(id => numeric.has(id));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Ids that are BOTH coercible to an observation id AND arrived as a string — i.e. the
|
|
141
|
+
* ids the shipped comparison silently fails to match. This, not "any string is present",
|
|
142
|
+
* is what makes a prompt's exclude inert.
|
|
143
|
+
*
|
|
144
|
+
* The distinction is load-bearing and a first version got it wrong. That version defined
|
|
145
|
+
* inert as `markerTypeSplit().strings > 0`, on the belief — written into four files — that
|
|
146
|
+
* UPS writes plain numbers and "only a PreToolUse emission inside the same window turns
|
|
147
|
+
* the union into strings". That belief is FALSE, and the counterexamples are in UPS
|
|
148
|
+
* itself: `scripts/user-prompt-search.js` writes `P<id>` on its prompt-fallback leg and
|
|
149
|
+
* `D<id>` on its deferred leg, and the deferred leg merges `prevIds.map(String)`, which
|
|
150
|
+
* stringifies whatever the file already held with no tool call involved.
|
|
151
|
+
*
|
|
152
|
+
* The consequence of the wrong definition ran in the opposite direction to the one the
|
|
153
|
+
* design was defending against: a marker holding ONLY `P`/`D`/`E` ids was recorded
|
|
154
|
+
* `inert: true` with `markerCoercible: 0` — a prompt whose exclude had nothing it could
|
|
155
|
+
* ever have excluded, counted into the inert population. Found by the pre-tag claims
|
|
156
|
+
* review (B5).
|
|
157
|
+
* @param {Array<number|string>} ids
|
|
158
|
+
* @returns {number[]}
|
|
159
|
+
*/
|
|
160
|
+
export function inertMarkerIds(ids) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const raw of ids || []) {
|
|
163
|
+
if (typeof raw !== 'string') continue;
|
|
164
|
+
const n = Number(raw);
|
|
165
|
+
if (Number.isInteger(n) && n > 0) out.push(n);
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Run both arms and shape the metric row. Pure with respect to the store: every DB
|
|
172
|
+
* access is the caller's `rerun` callback, so this is testable with no database and the
|
|
173
|
+
* hot path keeps its own imports.
|
|
174
|
+
*
|
|
175
|
+
* `after` is arm B's ALREADY-COMPUTED outcome, not a callback. It was a callback in the
|
|
176
|
+
* first version, which invited the caller to run arm B wherever was convenient — and the
|
|
177
|
+
* convenient place (after the delivery) is the one place it is wrong, because arm A's
|
|
178
|
+
* `injection_count` bump changes the corpus arm B then scores. The caller now owns the
|
|
179
|
+
* ordering and this function owns only the arithmetic.
|
|
180
|
+
*
|
|
181
|
+
* `after` absent → armB: 'skipped'
|
|
182
|
+
* `{ rows: [...] }` → armB: 'ok'
|
|
183
|
+
* `{ error: '<message>' }`→ armB: 'error', with `net`/`setChanged` left UNDEFINED
|
|
184
|
+
*
|
|
185
|
+
* A failed arm must never read as a measured zero — "Δ all-zero because it did not fire"
|
|
186
|
+
* is a failure mode this repo has shipped before.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} o
|
|
189
|
+
* @param {Array<number|string>} o.markerIds ids as `pathAInjectedIds` holds them
|
|
190
|
+
* @param {Array<{id:number}>} o.emitted arm A's delivered rows
|
|
191
|
+
* @param {{rows?: Array<{id:number}>, error?: string}|null} [o.after]
|
|
192
|
+
* @param {string} [o.imperativeArm] 'off' | 'on'
|
|
193
|
+
* @param {number|null} [o.imperativeBefore] arm A's pick id, when the flag is on
|
|
194
|
+
* @param {number|null} [o.imperativeAfter] arm B's pick id, when the flag is on
|
|
195
|
+
* @returns {object} the metric payload (without `event`/`ts`)
|
|
196
|
+
*/
|
|
197
|
+
export function measurePathAExclude({
|
|
198
|
+
markerIds, emitted, after,
|
|
199
|
+
imperativeArm = 'off', imperativeBefore = null, imperativeAfter = null,
|
|
200
|
+
}) {
|
|
201
|
+
const split = markerTypeSplit(markerIds);
|
|
202
|
+
const numeric = coerceMarkerIds(markerIds);
|
|
203
|
+
const inertStrings = inertMarkerIds(markerIds);
|
|
204
|
+
const emittedIds = (emitted || []).map(r => r.id);
|
|
205
|
+
// Through the exported helper, not a second copy of `emittedIds.filter(id =>
|
|
206
|
+
// numeric.has(id))`. A first draft inlined it, which would have left the function the
|
|
207
|
+
// metric row is built from and the function the tests assert on as two implementations
|
|
208
|
+
// of one rule — the twin-drift class this repo pays for more often than any other.
|
|
209
|
+
const suppressed = suppressedByWorkingExclude(markerIds, emittedIds);
|
|
210
|
+
|
|
211
|
+
const row = {
|
|
212
|
+
markerTotal: split.total,
|
|
213
|
+
markerStrings: split.strings,
|
|
214
|
+
markerNumbers: split.numbers,
|
|
215
|
+
markerCoercible: numeric.size,
|
|
216
|
+
// Ids that are coercible AND arrived as strings: what the shipped comparison fails
|
|
217
|
+
// to match. `markerStrings` above counts every string INCLUDING `P`/`D`/`E`, which
|
|
218
|
+
// are other tables' namespaces and were never observation ids to begin with.
|
|
219
|
+
markerCoercibleStrings: inertStrings.length,
|
|
220
|
+
// The shipped exclude is inert for THIS prompt exactly when at least one id it
|
|
221
|
+
// COULD have matched arrives as a string — not merely when some string is present.
|
|
222
|
+
// See inertMarkerIds: defining it on `strings > 0` counts a `P`/`D`/`E`-only marker
|
|
223
|
+
// as inert although it had nothing excludable, inflating the very denominator this
|
|
224
|
+
// column exists to keep honest.
|
|
225
|
+
inert: inertStrings.length > 0,
|
|
226
|
+
emitted: emittedIds.length,
|
|
227
|
+
suppressed: suppressed.length,
|
|
228
|
+
suppressedIds: suppressed,
|
|
229
|
+
imperativeArm,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (imperativeArm === 'on') {
|
|
233
|
+
row.imperativeBefore = imperativeBefore;
|
|
234
|
+
row.imperativeAfter = imperativeAfter;
|
|
235
|
+
row.imperativeChanged = imperativeBefore !== imperativeAfter;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (after && Array.isArray(after.rows)) {
|
|
239
|
+
const afterIds = after.rows.map(r => r.id);
|
|
240
|
+
row.armB = 'ok';
|
|
241
|
+
row.delivered = afterIds.length;
|
|
242
|
+
// The number the ledger says is unknown: slots freed by the exclude that the
|
|
243
|
+
// pool refills with something else. `delivered - emitted` is the NET, and net
|
|
244
|
+
// zero does not mean nothing happened — a suppressed row replaced one-for-one
|
|
245
|
+
// reads as no change while the delivered SET is different.
|
|
246
|
+
const before = new Set(emittedIds);
|
|
247
|
+
row.refilledIds = afterIds.filter(id => !before.has(id));
|
|
248
|
+
row.refilled = row.refilledIds.length;
|
|
249
|
+
row.net = afterIds.length - emittedIds.length;
|
|
250
|
+
row.setChanged = row.suppressed > 0 || row.refilled > 0;
|
|
251
|
+
} else if (after && after.error) {
|
|
252
|
+
// Never a measured zero: `net` and `setChanged` stay undefined so a reader cannot
|
|
253
|
+
// mistake a failed arm for "the repair changed nothing".
|
|
254
|
+
row.armB = 'error';
|
|
255
|
+
row.armBError = String(after.error);
|
|
256
|
+
} else {
|
|
257
|
+
row.armB = 'skipped';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return row;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Gate + measure + append. Returns the recorded payload, or null when it did not run,
|
|
265
|
+
* so a caller (or a test) can tell "measured nothing" from "did not measure".
|
|
266
|
+
* @returns {object|null}
|
|
267
|
+
*/
|
|
268
|
+
export function recordPathAExclude(dbDir, opts) {
|
|
269
|
+
if (!pathAMeterEnabled()) return null;
|
|
270
|
+
if (!opts || !Array.isArray(opts.markerIds) || opts.markerIds.length === 0) return null;
|
|
271
|
+
const row = measurePathAExclude(opts);
|
|
272
|
+
recordMetric(dbDir, { event: PATHA_EXCLUDE_EVENT, ...row });
|
|
273
|
+
return row;
|
|
274
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.90.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.90.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.90.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",
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"lib/release-digest.mjs",
|
|
90
90
|
"lib/mem-override.mjs",
|
|
91
91
|
"lib/injected-ids.mjs",
|
|
92
|
+
"lib/patha-exclude-meter.mjs",
|
|
92
93
|
"lib/time-constants.mjs",
|
|
93
94
|
"lib/keyctx-marker.mjs",
|
|
94
95
|
"lib/inject-search-core.mjs",
|
package/source-files.mjs
CHANGED
|
@@ -141,6 +141,7 @@ export const SOURCE_FILES = [
|
|
|
141
141
|
// scripts/user-prompt-search.js + scripts/pre-tool-recall.js. Under lib/ for
|
|
142
142
|
// the same scripts-dir-rename reason as mem-override.mjs above.
|
|
143
143
|
'lib/injected-ids.mjs',
|
|
144
|
+
'lib/patha-exclude-meter.mjs',
|
|
144
145
|
// P2-13 (narrowed): millisecond time units, single-sourced from the four
|
|
145
146
|
// modules that each declared their own DAY_MS. Leaf module, zero imports.
|
|
146
147
|
'lib/time-constants.mjs',
|