claude-mem-lite 3.89.0 → 3.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.89.0",
13
+ "version": "3.91.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.89.0",
3
+ "version": "3.91.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
- export function searchRelevantMemories(db, userPrompt, project, excludeIds = []) {
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
- const now = Date.now();
476
- const bumpStmt = db.prepare(
477
- 'UPDATE observations SET injection_count = COALESCE(injection_count, 0) + 1, last_injected_at = ? WHERE id = ?'
478
- );
479
- for (const r of result) {
480
- try { bumpStmt.run(now, r.id); } catch {}
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 would settle it does not exist yet: reconstructing per-prompt
2218
- // exclude sets needs the marker file, which rotates after DEDUP_STALE_MS and is
2219
- // never persisted. Tracked as a re-filed D#193 naming that as the prerequisite.
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();
@@ -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, not this one.
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
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.89.0",
3
+ "version": "3.91.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.89.0",
9
+ "version": "3.91.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
@@ -2007,9 +2007,9 @@
2007
2007
  "license": "MIT"
2008
2008
  },
2009
2009
  "node_modules/es-object-atoms": {
2010
- "version": "1.1.1",
2011
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
2012
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
2010
+ "version": "1.1.2",
2011
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
2012
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
2013
2013
  "license": "MIT",
2014
2014
  "dependencies": {
2015
2015
  "es-errors": "^1.3.0"
@@ -2387,9 +2387,9 @@
2387
2387
  "license": "MIT"
2388
2388
  },
2389
2389
  "node_modules/fast-uri": {
2390
- "version": "3.1.5",
2391
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
2392
- "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
2390
+ "version": "3.1.7",
2391
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
2392
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
2393
2393
  "funding": [
2394
2394
  {
2395
2395
  "type": "github",
@@ -2676,9 +2676,9 @@
2676
2676
  }
2677
2677
  },
2678
2678
  "node_modules/hasown": {
2679
- "version": "2.0.3",
2680
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
2681
- "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
2679
+ "version": "2.0.4",
2680
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
2681
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
2682
2682
  "license": "MIT",
2683
2683
  "dependencies": {
2684
2684
  "function-bind": "^1.1.2"
@@ -3847,12 +3847,13 @@
3847
3847
  "license": "MIT"
3848
3848
  },
3849
3849
  "node_modules/qs": {
3850
- "version": "6.15.2",
3851
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
3852
- "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
3850
+ "version": "6.16.0",
3851
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
3852
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
3853
3853
  "license": "BSD-3-Clause",
3854
3854
  "dependencies": {
3855
- "side-channel": "^1.1.0"
3855
+ "es-define-property": "^1.0.1",
3856
+ "side-channel": "^1.1.1"
3856
3857
  },
3857
3858
  "engines": {
3858
3859
  "node": ">=0.6"
@@ -4089,14 +4090,14 @@
4089
4090
  }
4090
4091
  },
4091
4092
  "node_modules/side-channel": {
4092
- "version": "1.1.0",
4093
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
4094
- "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
4093
+ "version": "1.1.1",
4094
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
4095
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
4095
4096
  "license": "MIT",
4096
4097
  "dependencies": {
4097
4098
  "es-errors": "^1.3.0",
4098
- "object-inspect": "^1.13.3",
4099
- "side-channel-list": "^1.0.0",
4099
+ "object-inspect": "^1.13.4",
4100
+ "side-channel-list": "^1.0.1",
4100
4101
  "side-channel-map": "^1.0.1",
4101
4102
  "side-channel-weakmap": "^1.0.2"
4102
4103
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.89.0",
3
+ "version": "3.91.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',