claude-mem-lite 3.85.1 → 3.87.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.85.1",
13
+ "version": "3.87.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.85.1",
3
+ "version": "3.87.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-context.mjs CHANGED
@@ -61,6 +61,65 @@ export function computeAdaptiveWindows(db, project) {
61
61
  }
62
62
  }
63
63
 
64
+ // D#192 (filed as D#189, re-scoped after measurement). These two are REACHABILITY bounds, not ranking bounds — the D#172 shape, and
65
+ // the fifth surface it has been found on. Both SELECTs order by pure `created_at_epoch
66
+ // DESC`; the JS below then re-sorts every candidate by `valueDensity`, a composite of
67
+ // typeQuality x impBoost(1.0/1.5/2.0) x lessonBoost(1.0/1.3) / sqrt(cost) whose dynamic
68
+ // range is far wider than the (1,2] that recency contributes. So the key the SQL sorts
69
+ // on barely participates in the final order, and anything past the LIMIT is unreachable
70
+ // however well it scores.
71
+ //
72
+ // Named rather than inline so benchmark/keyctx-pool-replay.mjs can patch a twin and
73
+ // price a change to them. Keep the `export const NAME = <int>;` shape — that ruler
74
+ // patches the DECLARATION by regex and throws when the anchor moves.
75
+ //
76
+ // OBS was 50 through v3.86.0 and is now an OOM backstop, not a relevance gate. What the
77
+ // ruler measured before the change (2026-09-02T06:11Z, 11 projects with >=20 live rows,
78
+ // budget 2000, AGAINST THE 50/10 TREE — reproduce with `--population --ref-obs 50
79
+ // --ref-sess 10` and with `--wide-obs 50 --wide-sess 10`, which runs the comparison
80
+ // backwards; the bare command now reports 0/11 because shipped is the wider bound):
81
+ // the 50-row bound truncated the pool in 3 of 11 projects, and lifting it
82
+ // alone moved the injected block in 2 of 11 — 8 rows newly reachable against 2 displaced,
83
+ // for +81 and +16 tokens. Selection here is NOT monotone, so a displaced row is a real
84
+ // cost and the ruler prints it as a first-class number (`--why-displaced` names the rows
85
+ // and the gate that dropped each).
86
+ //
87
+ // Both displaced rows lost their slot to the 3-per-type diversity cap. That is not a
88
+ // three-way discrimination: the token budget does not bind on this corpus (651 of 2000 in
89
+ // the widest arm's largest project) and the file-overlap `continue` below is UNREACHABLE
90
+ // (D#197) — so the cap is currently the only gate that can fire.
91
+ //
92
+ // 200 is ~2x the largest pool observed (107). The ruler CANNOT distinguish 200 from 500
93
+ // on this corpus — every bound >= the largest pool is one arm, identical in both
94
+ // selection and cost — so this value is a headroom choice, not a measured optimum.
95
+ // computeAdaptiveWindows NARROWS the windows as velocity rises (tier3 60d -> 30d -> 14d),
96
+ // so activity counteracts pool growth instead of driving it: the largest pool here is the
97
+ // lowest-velocity high-volume project (1.14 obs/day, 107 rows) while the only project a
98
+ // band up has 2.9x the velocity and 29% of the pool. A draft of this comment had that
99
+ // backwards.
100
+ //
101
+ // Cost is PER PROJECT: ~2.7x where the pool is 107, ~2.0x at 59, ~1.5x at 62, and
102
+ // unchanged (inferred, not measured) for the eight projects whose pool never reached 50.
103
+ // Once per SessionStart, both arms in the low single-digit milliseconds. It is NOT a clean
104
+ // function of pool growth — the 59-row project grows less than the 62-row one and costs
105
+ // more, on two independent harnesses, because fixed per-call work (SQL fetch,
106
+ // estimateTokens, JSON.parse, the unchanged summary half) sets the denominator. Three
107
+ // drafts were wrong here in three ways: a point estimate (2.65x), a global range
108
+ // (2.1x-3.8x) no measurement produced, and a causal claim n=3 refutes. Absolute
109
+ // milliseconds are not quotable. Re-derive with `--cost --wide-obs 50 --wide-sess 10
110
+ // --project <p>`, which prices the widening in the correct direction.
111
+ //
112
+ // SESS stays 10 DELIBERATELY, and the discriminator is `sessDisplaced = 0` in 11 of 11
113
+ // projects, not the observation column. Widening it to 40 displaces no summary at all —
114
+ // it is PURELY ADDITIVE, so that LIMIT is a volume cap and not the D#172 shape, which is
115
+ // the actual reason it does not need lifting. (It also changed zero observations, but
116
+ // that only says the obs side is unaffected.) What widening does buy is 130 newly injected
117
+ // summaries, roughly tripling the emitted block on the largest projects. It truncates MORE
118
+ // projects than the obs bound, 5 of 11 against 3 of 11, which is what made the original
119
+ // review propose it first; truncation count is not harm.
120
+ export const KEYCTX_POOL_OBS = 200;
121
+ export const KEYCTX_POOL_SESS = 10;
122
+
64
123
  /**
65
124
  * Select observations and sessions within a token budget using greedy knapsack.
66
125
  * Scores candidates by recency * importance, picks highest value-density first.
@@ -91,7 +150,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
91
150
  OR (created_at_epoch > ? AND importance >= 3)
92
151
  )
93
152
  ORDER BY created_at_epoch DESC
94
- LIMIT 50
153
+ LIMIT ${KEYCTX_POOL_OBS}
95
154
  `).all(project, tier1Ago, tier2Ago, tier3Ago);
96
155
 
97
156
  const sessPool = db.prepare(`
@@ -99,7 +158,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
99
158
  FROM session_summaries
100
159
  WHERE project = ? AND created_at_epoch > ?
101
160
  ORDER BY created_at_epoch DESC
102
- LIMIT 10
161
+ LIMIT ${KEYCTX_POOL_SESS}
103
162
  `).all(project, now_ms - windows.sessWindow);
104
163
 
105
164
  const selectedObs = [];
package/hook-update.mjs CHANGED
@@ -21,6 +21,7 @@ import { httpConnectProxyFor, getViaConnectProxy } from './lib/proxy-fetch.mjs';
21
21
  import { acquireLock } from './lib/proc-lock.mjs';
22
22
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
23
23
  import { verifyReleaseFiles, verifyManifestSignature } from './lib/release-digest.mjs';
24
+ import { detectInstallShape } from './lib/install-shape.mjs';
24
25
 
25
26
  // ── Configuration ──────────────────────────────────────────
26
27
  const GITHUB_REPO = 'sdsrss/claude-mem-lite';
@@ -156,8 +157,47 @@ export function isUpdateCheckDue() {
156
157
  } catch { return false; }
157
158
  }
158
159
 
160
+ // D#187. `CLAUDE_PLUGIN_ROOT` is set in every hook and MCP process Claude Code
161
+ // spawns, so inside those the env var answers "am I a plugin install?" perfectly —
162
+ // which is why v3.84.1's fix, which reads it, was enough THERE. It is not set in a
163
+ // plain terminal, and a plugin-only user typing `claude-mem-lite update` therefore
164
+ // fell off both plugin paths at once: getCurrentVersion() returned '0.0.0' (so every
165
+ // release compares as newer, forever), and isPluginMode() was false, so allowInstall
166
+ // defaulted to true and downloadAndInstall laid a full managed tree into
167
+ // ~/.claude-mem-lite — silently converting a plugin-only install into the hybrid
168
+ // whose two trees D#184 documents drifting apart.
169
+ //
170
+ // Same root cause as PR #17: process ENVIRONMENT was the only install-shape
171
+ // evidence consulted. detectInstallShape() reads the FILESYSTEM instead, so it
172
+ // answers the same in a hook, in a terminal, and in a subprocess.
173
+ //
174
+ // Memoised per process. The reason first written here was wrong and the pre-tag review
175
+ // traced it out: it claimed the memo keeps installExtractedRelease()'s post-install
176
+ // isPluginMode() call stable across the managed-tree write. That call cannot depend on
177
+ // this fallback at all — every path reaching installExtractedRelease is a plugin process
178
+ // (checkForUpdate cannot reach downloadAndInstall while pluginMode is true, and
179
+ // syncDataDirFromCache is only called from scripts/launch.mjs and
180
+ // scripts/hook-launcher.mjs), so CLAUDE_PLUGIN_ROOT is set and isPluginMode()
181
+ // short-circuits before pluginOnlyInstall() is consulted, before AND after the write.
182
+ // What the memo actually buys: one readdirSync per process instead of one per call, and
183
+ // a stable answer inside a long-lived MCP server whose plugin cache may be pruned or
184
+ // re-populated underneath it while it runs.
185
+ let shapeMemo;
186
+ function installShape() {
187
+ if (shapeMemo === undefined) {
188
+ try { shapeMemo = detectInstallShape({ installDir: INSTALL_DIR }); } catch { shapeMemo = null; }
189
+ }
190
+ return shapeMemo;
191
+ }
192
+
193
+ /** True when this machine runs the plugin and has NO managed code install to update. */
194
+ function pluginOnlyInstall() {
195
+ const shape = installShape();
196
+ return Boolean(shape && !shape.managed && shape.activePluginVersion);
197
+ }
198
+
159
199
  function isPluginMode() {
160
- return Boolean(process.env.CLAUDE_PLUGIN_ROOT);
200
+ return Boolean(process.env.CLAUDE_PLUGIN_ROOT) || pluginOnlyInstall();
161
201
  }
162
202
 
163
203
  // ── Dev Mode Detection ─────────────────────────────────────
@@ -313,6 +353,17 @@ export function getCurrentVersion() {
313
353
  if (pkg.version) return pkg.version;
314
354
  } catch { /* fall through to the last resort */ }
315
355
  }
356
+ // D#187: no env var in a plain terminal, so ask the filesystem which plugin
357
+ // version this machine actually runs. Without this a plugin-only user's
358
+ // `claude-mem-lite update` reads 0.0.0 and every release compares as newer.
359
+ const active = installShape()?.activePluginVersion;
360
+ if (active) {
361
+ try {
362
+ const pkg = JSON.parse(readFileSync(join(active.root, 'package.json'), 'utf8'));
363
+ if (pkg.version) return pkg.version;
364
+ } catch { /* the cache dir name IS the version — use it rather than 0.0.0 */ }
365
+ if (active.version) return active.version;
366
+ }
316
367
  return '0.0.0';
317
368
  }
318
369
 
@@ -115,6 +115,85 @@ export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
115
115
  return ids;
116
116
  }
117
117
 
118
+ /**
119
+ * D#179 prerequisite measurement: split each cited id into the responses that
120
+ * ACTED while naming it and the responses that only TALKED about it.
121
+ *
122
+ * `applyCitationDecay` promotes on any `#NN` in assistant text, so writing a release
123
+ * note, an audit, or a review that discusses a memory promotes that memory — including,
124
+ * self-referentially, a note observing that the memory is about to be evicted. Nothing
125
+ * downstream distinguishes "changed the code this lesson describes" from "mentioned the
126
+ * lesson's number". This function supplies the discriminator the deferred item asks for
127
+ * so the size of the contamination can be counted before anything is redesigned.
128
+ *
129
+ * The unit is a RESPONSE, keyed by `requestId` — one model turn, whose entries carry
130
+ * thinking / text / tool_use blocks under a shared id. That granularity is the point:
131
+ * a whole user-to-user exchange almost always contains some tool call, so bounding on
132
+ * user messages would classify nearly everything as "applied" and measure nothing.
133
+ *
134
+ * The key falls back `requestId || message.id || uuid`. In practice `message.id` carries
135
+ * every real Claude Code assistant entry that lacks a requestId, and it is still a
136
+ * per-response key, so the uuid arm is close to unreachable in production and exists only
137
+ * so a keyless entry is never merged into a shared bucket. What must NOT happen is any
138
+ * grouping coarser than one response: one tool call anywhere in a coarser bucket would
139
+ * mark every id in it as acted on, and the measurement would report no contamination on
140
+ * any coding session. (The pre-tag review pointed out that the earlier version of this
141
+ * sentence named only the uuid arm, and that the one test for it constructs a shape
142
+ * production never emits.)
143
+ *
144
+ * Returns a Map keyed by id: `{ withTool, textOnly }` response counts. An id is a pure
145
+ * MENTION when `withTool === 0`.
146
+ *
147
+ * NEITHER SIDE IS A BOUND, and an earlier draft of this comment claimed one. The proxy
148
+ * errs in both directions: naming an id in a response that also calls a tool is only
149
+ * co-occurrence, not evidence the lesson was followed (over-counts `withTool`); and an
150
+ * agent that acts in one response and cites the lesson in a later summary response gets
151
+ * classified pure-mention despite having applied it (over-counts `textOnly`). Use the
152
+ * split to size the question, not to settle it.
153
+ *
154
+ * @param {string} transcriptPath
155
+ * @param {object} [opts]
156
+ * @param {boolean} [opts.mainOnly=true] skip sidechain (subagent) records
157
+ * @returns {Map<number, {withTool: number, textOnly: number}>}
158
+ */
159
+ export function classifyCitationContext(transcriptPath, opts = {}) {
160
+ const { mainOnly = true } = opts;
161
+ // requestId -> { ids:Set<number>, tool:boolean }
162
+ const responses = new Map();
163
+ for (const entry of readTranscriptEntries(transcriptPath)) {
164
+ if (entry.type !== 'assistant' || !entry.message) continue;
165
+ if (mainOnly && entry.isSidechain === true) continue;
166
+ const content = entry.message.content;
167
+ if (!Array.isArray(content)) continue;
168
+ const key = entry.requestId || entry.message.id || entry.uuid;
169
+ if (!key) continue;
170
+ let r = responses.get(key);
171
+ if (!r) { r = { ids: new Set(), tool: false }; responses.set(key, r); }
172
+ for (const block of content) {
173
+ if (block.type === 'tool_use') { r.tool = true; continue; }
174
+ // Only `text` blocks count as citing. `thinking` is deliberately excluded:
175
+ // extractCitationsFromTranscript scores text only, and the whole point is to
176
+ // classify the same numerator the decay loop acts on, not a wider one.
177
+ if (block.type !== 'text' || typeof block.text !== 'string') continue;
178
+ CITATION_RE.lastIndex = 0;
179
+ let m;
180
+ while ((m = CITATION_RE.exec(block.text))) {
181
+ const id = Number(m[1]);
182
+ if (Number.isInteger(id) && id > 0 && id < 1e7) r.ids.add(id);
183
+ }
184
+ }
185
+ }
186
+ const out = new Map();
187
+ for (const { ids, tool } of responses.values()) {
188
+ for (const id of ids) {
189
+ let e = out.get(id);
190
+ if (!e) { e = { withTool: 0, textOnly: 0 }; out.set(id, e); }
191
+ if (tool) e.withTool++; else e.textOnly++;
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+
118
197
  /**
119
198
  * Compute cite-recall stats for one transcript: how many of the `#NN`
120
199
  * references that surfaced in non-assistant content (hook injections, system
@@ -28,6 +28,73 @@ export function injectedIdsFileName(project, sessionId) {
28
28
  return `${base}-${safe}`;
29
29
  }
30
30
 
31
+ /**
32
+ * Namespace prefix for an id written into the shared injected-ids marker.
33
+ *
34
+ * D#188. The marker file is a UNION across hooks and across tables, and the
35
+ * convention for keeping those tables apart already existed — user-prompt-search.js
36
+ * writes `P<id>` for user_prompts rows and `D<id>` for deferred rows, with the
37
+ * comment "so obs ids can't collide in the shared injected-ids file". Observations
38
+ * are the incumbent namespace and stay bare. EVENTS were the one table that never
39
+ * got a prefix, even though the line three above pre-tool-recall.js's dedup filter
40
+ * says in as many words that "events share the numeric id space with observations"
41
+ * — the `src` tag added to carry exactly that distinction was not consulted by the
42
+ * dedup predicate sitting next to it.
43
+ *
44
+ * The consequence, measured on the live store (3747 observations, 91.6% of observation
45
+ * ids also existing as an event id, 2026-09-01T19:56Z): a UPS-injected observation #42
46
+ * made event #42 unreachable to the PreToolUse face for the 5-minute window, and vice
47
+ * versa. Replaying every real session's UPS-injected id set against the injectable
48
+ * events of the project the SESSION ran in: **14 collisions across 11 of 60 sessions
49
+ * (18.3%)**, 2026-09-01T20:17Z.
50
+ *
51
+ * WHICH PROJECT, precisely, because the draft got this wrong while arguing about
52
+ * populations. `scripts/pre-tool-recall.js` does `const project = inferProject()` once
53
+ * and feeds that single value to BOTH `crossHookInjectedFile(project, sessionId)` and
54
+ * the events `SELECT ... WHERE project = ?`. So the scoping that matters is the
55
+ * SESSION's project. The draft instead scoped by the injected observation's own
56
+ * `project` column and reported 9 in 9 (15.5%), understating it. The pre-tag claims
57
+ * review caught the population, having reconstructed it independently at 12 in 10.
58
+ *
59
+ * That is not a rounding difference, and the number that proves it is worth carrying:
60
+ * **134 of 216 injected `ups` ids (62.0%) belong to a project OTHER than the session
61
+ * they were injected into** (2026-09-01T20:36Z; the review measured 128/210 = 61% an
62
+ * hour earlier). The `ups` face has a cross-project leg and it dominates, so "the
63
+ * observation's project" and "the session's project" select genuinely different
64
+ * populations — and only the latter is one the dedup mechanism ever asks about.
65
+ *
66
+ * The predicate, stated here because no harness is committed for it: seen-set per
67
+ * session = the shipped `extractInjectedBySurface(path).ups`; injectable events =
68
+ * `importance >= 2 AND superseded_at_epoch IS NULL AND file_paths NOT IN (NULL,'[]')`;
69
+ * session project recovered by matching each transcript directory against the DB's own
70
+ * project list (forward map, since flattening `/` and `_` to `-` is not invertible).
71
+ * Dropping the project condition entirely reports 72 in 41 — a population the
72
+ * project-filtered query can never reach.
73
+ *
74
+ * A SECOND consequence was claimed here before v3.86.0 was tagged and is FALSE, so it
75
+ * is recorded rather than deleted: bare event ids do flow into hook.mjs's
76
+ * `pathAInjectedIds`, which is handed to searchRelevantMemories and
77
+ * rankImperativeCandidates as an OBSERVATION exclude list — but they suppress
78
+ * NOTHING there, because `mergeCrossHookInjected` writes every id as a STRING and
79
+ * both consumers test `new Set(excludeIds).has(r.id)` against a NUMBER out of
80
+ * SQLite. Measured: excluding `1` returns nothing, excluding `'1'` returns the row.
81
+ * The pre-tag correctness review found this. What it exposes is a real and separate
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.
84
+ *
85
+ * Legacy in-flight files (bare ids that were a mix of both tables) keep their old
86
+ * meaning for at most DEDUP_STALE_MS and then rotate; there is deliberately no
87
+ * format version, because a 5-minute window of the PRE-EXISTING behaviour is a
88
+ * smaller cost than a schema every reader has to branch on.
89
+ *
90
+ * @param {number|string} id
91
+ * @param {'obs'|'evt'} [src]
92
+ * @returns {string}
93
+ */
94
+ export function injectedIdKey(id, src = 'obs') {
95
+ return src === 'evt' ? `E${id}` : String(id);
96
+ }
97
+
31
98
  /**
32
99
  * Runtime-dir FILE NAME for the SessionStart Key Context marker: the obs ids
33
100
  * ACTUALLY rendered into the <claude-mem-context> File Lessons / Key Context
@@ -4,6 +4,7 @@
4
4
  // collide with CLI's `out()` stdout-write pattern.
5
5
 
6
6
  import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
7
+ import { liveObsFilterSql } from './inject-search-core.mjs';
7
8
  import { truncate } from '../format-utils.mjs';
8
9
  import { COMPRESSED_PENDING_PURGE } from '../utils.mjs';
9
10
 
@@ -23,6 +24,26 @@ export function computeNoiseGauge({ liveTotal, lowValCount, lowSignalCount }) {
23
24
  };
24
25
  }
25
26
 
27
+ // D#191: the rule above is a rule about POPULATIONS, and until v3.86.0 it had only
28
+ // been applied to computeNoiseGauge — a pure function that takes `liveTotal` from its
29
+ // caller. Every query below ran on a bare `FROM observations`, so `stats --quality`
30
+ // rendered ratios over all rows (compressed + superseded included) under labels that
31
+ // name no population. Numerator and denominator were paired, so each ratio was
32
+ // internally consistent; what was wrong was the population it described. Measured on
33
+ // the live store 2026-09-01 (3742 rows total, 2284 live): all-time Lesson rate read
34
+ // 59.6% where the live store is 92.9%, and all-time LOW_SIGNAL read 22.9% where the
35
+ // live store is 1.1% — compression retires exactly the low-signal, lesson-less rows,
36
+ // so an all-rows denominator reports a store roughly three decades of quality worse
37
+ // than the one retrieval actually searches.
38
+ //
39
+ // `days` is user-settable (`stats --quality --days N`), so this is NOT confined to the
40
+ // all-time bracket: the wider the window, the more compressed rows it sweeps in. The
41
+ // filter is therefore applied to the window and per-type queries too, not only to the
42
+ // two the review named, and all three now describe one population — the live corpus.
43
+ //
44
+ // Deliberately NOT filtered: `purgeRow`, whose entire subject is compressed rows.
45
+ const LIVE = liveObsFilterSql('');
46
+
26
47
  export function computeQualityStats(db, { project, days }) {
27
48
  const projectFilter = project ? 'AND project = ?' : '';
28
49
  const baseParams = project ? [project] : [];
@@ -53,7 +74,7 @@ export function computeQualityStats(db, { project, days }) {
53
74
  COALESCE(SUM(CASE WHEN type = 'bugfix' THEN 1 ELSE 0 END), 0) as bugfix_total,
54
75
  COALESCE(SUM(CASE WHEN type = 'bugfix' AND ${unresolvedNarrativeExpr} THEN 1 ELSE 0 END), 0) as bugfix_unresolved
55
76
  FROM observations
56
- WHERE created_at_epoch >= ? ${projectFilter}
77
+ WHERE created_at_epoch >= ? AND ${LIVE} ${projectFilter}
57
78
  `).get(cutoff, ...baseParams);
58
79
 
59
80
  const allTimeRow = db.prepare(`
@@ -62,7 +83,7 @@ export function computeQualityStats(db, { project, days }) {
62
83
  COALESCE(SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END), 0) as with_lesson,
63
84
  COALESCE(SUM(CASE WHEN ${lowSignalIsMatchExpr} THEN 1 ELSE 0 END), 0) as low_signal
64
85
  FROM observations
65
- WHERE 1=1 ${projectFilter}
86
+ WHERE ${LIVE} ${projectFilter}
66
87
  `).get(...baseParams);
67
88
 
68
89
  const typeRows = db.prepare(`
@@ -72,7 +93,7 @@ export function computeQualityStats(db, { project, days }) {
72
93
  COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 0 THEN 1 ELSE 0 END), 0) as accessed,
73
94
  COALESCE(SUM(CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 1 ELSE 0 END), 0) as with_lesson
74
95
  FROM observations
75
- WHERE created_at_epoch >= ? ${projectFilter}
96
+ WHERE created_at_epoch >= ? AND ${LIVE} ${projectFilter}
76
97
  GROUP BY type
77
98
  ORDER BY total DESC
78
99
  `).all(cutoff, ...baseParams);
@@ -82,7 +103,7 @@ export function computeQualityStats(db, { project, days }) {
82
103
  FROM observations
83
104
  WHERE lesson_learned IS NOT NULL AND lesson_learned != ''
84
105
  AND COALESCE(access_count, 0) > 0
85
- AND COALESCE(compressed_into, 0) = 0
106
+ AND ${LIVE}
86
107
  ${projectFilter}
87
108
  ORDER BY ac DESC
88
109
  LIMIT 5
@@ -109,7 +130,9 @@ export function formatQualityReport(data) {
109
130
  const lines = [];
110
131
  lines.push(`[mem] Quality snapshot${scope} — window: ${days}d`);
111
132
  lines.push('────────────────────────────────────────────────────');
112
- lines.push(` Writes (${days}d): ${windowRow.total} observations`);
133
+ // Every ratio below divides live rows by live rows (D#191) — say so once, here,
134
+ // rather than qualifying each label and still leaving the brackets ambiguous.
135
+ lines.push(` Writes (${days}d): ${windowRow.total} live observations (compressed/superseded excluded throughout)`);
113
136
 
114
137
  const lessonPct = pct(windowRow.with_lesson, windowRow.total);
115
138
  const allLessonPct = pct(allTimeRow.with_lesson, allTimeRow.total);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.85.1",
3
+ "version": "3.87.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.85.1",
9
+ "version": "3.87.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.85.1",
3
+ "version": "3.87.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",
@@ -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 } from '../lib/injected-ids.mjs';
11
+ import { injectedIdsFileName, injectedIdKey } 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';
@@ -592,8 +592,12 @@ try {
592
592
  ...rows.map(r => ({ ...r, src: 'obs' })),
593
593
  ...eventRows.map(r => ({ ...r, src: 'evt' })),
594
594
  ];
595
+ // D#188: compare on the NAMESPACED key, not the bare number. The `src` tag two
596
+ // comments up exists precisely because the two tables share an id space; the
597
+ // predicate that consumed it did not use it, so an observation injected by UPS
598
+ // silently blocked the same-numbered event and vice versa.
595
599
  const dedupedRows = crossHookSeen.size > 0
596
- ? sourcedRows.filter(r => !crossHookSeen.has(String(r.id)))
600
+ ? sourcedRows.filter(r => !crossHookSeen.has(injectedIdKey(r.id, r.src)))
597
601
  : sourcedRows;
598
602
 
599
603
  // Merge: observations first (they carry richer lesson_learned), then events.
@@ -746,7 +750,13 @@ try {
746
750
  // file so the next UPS prompt skips them too. Always write, even on
747
751
  // empty allRows, so the file's ts stays fresh for the no-op case where
748
752
  // we'd otherwise drift outside the dedup window.
749
- mergeCrossHookInjected(project, allRows.map(r => r.id), sessionId);
753
+ // D#188: namespaced on write too — otherwise a bare event id here would keep
754
+ // blocking the same-numbered observation on the next UPS prompt. (An earlier
755
+ // version of this comment also claimed it leaked into hook.mjs's
756
+ // pathAInjectedIds; it reaches there, but suppresses nothing, because this
757
+ // function stringifies every id and that consumer compares against a numeric
758
+ // row id. That inertness is a separate live defect — D#193.)
759
+ mergeCrossHookInjected(project, allRows.map(r => injectedIdKey(r.id, r.src)), sessionId);
750
760
  } catch (e) {
751
761
  // Silent failure — never block editing, but record for self-observation.
752
762
  recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });