ai-lens 0.8.117 → 0.8.119

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/.commithash CHANGED
@@ -1 +1 @@
1
- 28976f3
1
+ 8674f71
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
4
4
 
5
+ ## 0.8.119 — 2026-07-02
6
+ - feat: team-memory injection now fires only on genuinely relevant prompts — the trigger matches distinctive terms of the memory title instead of an absolute score, cutting noise ~20x while keeping real matches
7
+ - feat: the full team memory pool is delivered to the session cache (previously most team-tier memories could never surface), server-gated to clients on this version and newer
8
+
9
+ ## 0.8.118 — 2026-07-01
10
+ - fix(team-memory): injected memory now actually reaches the model on Claude Code (via `additionalContext`, not a user-only `systemMessage` banner) — before, relevant memory showed on screen but the model never received it. Silent by design; no screen clutter
11
+
5
12
  ## 0.8.117 — 2026-07-01
6
13
  - fix(team-memory): never inject team memory in projects outside your `projects` filter — an unrelated repo now gets nothing, even if it's on your machine
7
14
 
package/client/capture.js CHANGED
@@ -1735,21 +1735,23 @@ export function maybeEmitSessionStartMemoryIndex(primary, { now = Date.now() } =
1735
1735
  // empty cache ⇒ this is a no-op) and relevance (local BM25 threshold).
1736
1736
  // =============================================================================
1737
1737
 
1738
- // Relevance floor for the local BM25 scorer. Below this a candidate never surfaces.
1739
- // PROVISIONAL, UNCALIBRATED a starting guess to be tuned live on analytics ([0118]).
1740
- // Measured band on realistic pairs (see test/client/memory-match.test.js): a single
1741
- // on-topic term ("iceberg") ~2.2; a 2-word on-topic prompt 34; a generic dev prompt
1742
- // sharing ONE weak word with a memory preview lands ~1.2–1.4 (just under) — so 1.5
1743
- // currently suppresses single-generic-word overlaps while passing 2+ on-topic-word
1744
- // matches. That margin is THIN (a 0.1–0.2 shift flips borderline prompts), which is
1745
- // exactly why the number is deferred to live tuning, not treated as final. Bounded
1746
- // blast radius (budget below + phase-N unshown-gate) keeps a mis-tuned floor from
1747
- // flooding worst case is 1–2 extra short memories, never a dump.
1748
- // Calibrated on live prod pairs (analytics pilot, 2026-07): genuine matches score
1749
- // ≫3 (specific-term prompts hit 3–13), noise clusters at 1.5–2.0. 3.0 cleanly
1750
- // separates them precision over recall BY DESIGN: silence beats a context-rotting
1751
- // near-miss. Tunable down with PostHog match data if it proves too quiet.
1752
- const MEMORY_MATCH_MIN_SCORE = 3.0;
1738
+ // Inject decision rule — REPLACED the absolute raw-BM25 floor (was 1.5, then 3.0).
1739
+ // A 20k-prompt historical replay (analytics, 90d, board [0102]) showed the raw sum
1740
+ // cannot be thresholded: it grows with prompt length, so long unrelated prompts
1741
+ // (pasted logs, slash-command expansions) hit 423 while true matches are defined
1742
+ // by WHICH terms hit. The shipped rule uses the two per-candidate signals from
1743
+ // scoreCandidates (client/memory-match.js):
1744
+ // dth — distinctive title hits (unique query terms landing in the TITLE
1745
+ // that are rare in the pool, df max(1, ⌊0.3·N⌋));
1746
+ // coverage raw score / the query's saturation ceiling (length-normalized).
1747
+ // `dth >= 2 && coverage >= 0.12` measured on the replay golden set
1748
+ // (test/client/memory-match-golden.test.js): recall 65% on verified positive
1749
+ // pairs, 4.5% fire-rate on the 20k noise set (~7 injects/day team-wide) — vs 100%
1750
+ // noise fire-rate for the old raw>=3 on a full pool. Precision over recall BY
1751
+ // DESIGN: silence beats a context-rotting near-miss. Tune ONLY against the golden
1752
+ // harness + PostHog memory_matched (top_dth/top_coverage) data.
1753
+ const MEMORY_MATCH_MIN_DTH = 2;
1754
+ const MEMORY_MATCH_MIN_COVERAGE = 0.12;
1753
1755
  // Budget: never inject more than this many memories per prompt (repo OR team-general
1754
1756
  // alike) — keeps a prompt light even on a broad match.
1755
1757
  const MEMORY_INJECT_BUDGET = 2;
@@ -1888,16 +1890,17 @@ export function maybeEmitUserPromptMemoryInject(primary, { now = Date.now() } =
1888
1890
  // Score with the local BM25-lite scorer over the whole pool.
1889
1891
  const scored = scoreCandidates(promptText, entries);
1890
1892
  const byId = new Map(entries.map(e => [e.id, e]));
1891
- const scoreOf = new Map(scored.map(s => [s.id, s.score]));
1893
+ const signalOf = new Map(scored.map(s => [s.id, s]));
1892
1894
 
1893
1895
  // Relevance-gated selection (UNIFORM across phases): inject ONLY candidates —
1894
- // repo OR team-general — that clear MEMORY_MATCH_MIN_SCORE for THIS prompt and
1895
- // were not already shown this session. NO unconditional team-general dump: a
1896
- // low-signal prompt ("давай делай", "continue") scores ~0 → injects NOTHING.
1897
- // Precision over recall BY DESIGN silence beats context-rot. `promptIndex`
1896
+ // repo OR team-general — that clear the dth+coverage decision rule (see the
1897
+ // constants block) for THIS prompt and were not already shown this session.
1898
+ // NO unconditional team-general dump: a low-signal prompt ("давай делай",
1899
+ // "continue") has no distinctive title hits injects NOTHING. Ranking among
1900
+ // qualifiers stays by raw score (scoreCandidates sort order). `promptIndex`
1898
1901
  // still advances via commitCount() for telemetry, but no longer gates selection.
1899
1902
  const picked = scored
1900
- .filter(s => s.score >= MEMORY_MATCH_MIN_SCORE && !shown[s.id])
1903
+ .filter(s => s.dth >= MEMORY_MATCH_MIN_DTH && s.coverage >= MEMORY_MATCH_MIN_COVERAGE && !shown[s.id])
1901
1904
  .map(s => byId.get(s.id))
1902
1905
  .filter(Boolean)
1903
1906
  .slice(0, MEMORY_INJECT_BUDGET);
@@ -1908,8 +1911,15 @@ export function maybeEmitUserPromptMemoryInject(primary, { now = Date.now() } =
1908
1911
  const text = renderPromptInject(picked);
1909
1912
  if (!text) { commitCount(); return NONE; }
1910
1913
 
1914
+ // Claude → hookSpecificOutput.additionalContext: this is the field the MODEL
1915
+ // actually receives (injected into the context window + persisted to the
1916
+ // transcript, survives --resume). `systemMessage` is a USER-only banner the
1917
+ // model NEVER sees — using it meant the memory showed on screen but the model
1918
+ // couldn't use it. Silent by design (no banner): the model gets it, the user's
1919
+ // screen stays clean. Cursor's `additional_context` is already the model-context
1920
+ // field. (See Claude Code hooks: UserPromptSubmit additionalContext vs systemMessage.)
1911
1921
  const out = source === 'claude_code'
1912
- ? { systemMessage: text }
1922
+ ? { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } }
1913
1923
  : { additional_context: text + CURSOR_RELAY_SUFFIX };
1914
1924
  process.stdout.write(JSON.stringify(out));
1915
1925
 
@@ -1918,12 +1928,17 @@ export function maybeEmitUserPromptMemoryInject(primary, { now = Date.now() } =
1918
1928
  commitCount();
1919
1929
 
1920
1930
  const matchedIds = picked.map(e => e.id);
1921
- const topScore = matchedIds.reduce((m, id) => Math.max(m, scoreOf.get(id) || 0), 0);
1931
+ // top_* = max over the picked set. top_score keeps its raw-BM25 semantics
1932
+ // (telemetry continuity); top_dth/top_coverage carry the decision signals so
1933
+ // the live PostHog stream can validate/re-tune the rule against real traffic.
1934
+ const agg = (key) => matchedIds.reduce((m, id) => Math.max(m, signalOf.get(id)?.[key] || 0), 0);
1922
1935
  return {
1923
1936
  wrote: true,
1924
1937
  match: {
1925
1938
  matched_ids: matchedIds,
1926
- top_score: topScore,
1939
+ top_score: agg('score'),
1940
+ top_dth: agg('dth'),
1941
+ top_coverage: agg('coverage'),
1927
1942
  prompt_index: promptIndex,
1928
1943
  n_matched: matchedIds.length,
1929
1944
  had_team_general: hadTeamGeneral,
@@ -11,10 +11,26 @@
11
11
  * this must have ZERO runtime deps (no `natural`, no tokenizer package). ~1 file,
12
12
  * pure functions, deterministic — fully unit-testable without I/O.
13
13
  *
14
- * The corpus is the SMALL primed pool (cap ~30 candidates), so this is BM25 with a
15
- * tiny-corpus caveat baked in: idf is FLOORED (a term present in every candidate
16
- * would otherwise get idf ≤ 0 and vanish, even when it's the whole point of the
17
- * prompt). See IDF_FLOOR.
14
+ * The corpus is the SMALL primed pool (up to ~100 candidates for 0.8.119+ clients,
15
+ * legacy cap ~30), so this is BM25 with a tiny-corpus caveat baked in: idf is
16
+ * FLOORED (a term present in every candidate would otherwise get idf ≤ 0 and
17
+ * vanish, even when it's the whole point of the prompt). See IDF_FLOOR.
18
+ *
19
+ * DECISION SIGNALS (post-replay redesign, board [0102]): a 20k-prompt historical
20
+ * replay showed the RAW BM25 sum cannot be thresholded — it grows with prompt
21
+ * length (long pasted logs hit 4–23 on unrelated memories), while true matches
22
+ * are distinguished by WHICH terms hit, not how many points accumulate. So each
23
+ * scored candidate also carries:
24
+ * - `dth` (distinctive title hits) — how many unique query terms land in the
25
+ * candidate's TITLE while being RARE in the pool (df ≤ max(1, ⌊0.3·N⌋)).
26
+ * df-based on purpose: an absolute idf cutoff is unreachable for pools of
27
+ * N ≤ 3 (a title term always has df ≥ 1 → idf ≤ 1.204), silently disabling
28
+ * injection for small teams.
29
+ * - `coverage` — rawScore / the query's saturation ceiling (every unique term
30
+ * matched at tf→∞ on an average-length doc). Normalizes away prompt length.
31
+ * The inject decision in capture.js is `dth >= 2 && coverage >= 0.12`, measured
32
+ * on the replay golden set (test/client/memory-match-golden.test.js): recall 65%
33
+ * on verified positives, 4.5% fire-rate on the noise set (vs 100% for raw>=3).
18
34
  *
19
35
  * Bilingual by design: memory titles + our MEMORY.md entries are Russian; prompts
20
36
  * are mixed RU/EN. Tokenization is Unicode-letter/number aware so Cyrillic and
@@ -36,6 +52,12 @@ const WEIGHT_TAGS = 2;
36
52
  const WEIGHT_PATHS = 2;
37
53
  const WEIGHT_PREVIEW = 1;
38
54
 
55
+ // A query term is "distinctive" for dth-counting when its pool document frequency
56
+ // is at most this fraction of the pool (floored at 1 so N=3 pools still work).
57
+ // 0.3 reproduces the replay-measured cutoff (df ≤ 20 at N = 67) and stays
58
+ // reachable at any pool size — see the header note on why NOT an idf cutoff.
59
+ const DISTINCTIVE_DF_RATIO = 0.3;
60
+
39
61
  // Bilingual stopwords (EN + RU) — high-frequency function words that carry no
40
62
  // topic signal. Kept deliberately small; over-stopping hurts recall more than a
41
63
  // few function words hurt precision on this tiny corpus.
@@ -104,8 +126,11 @@ export function candidateBag(cand) {
104
126
  *
105
127
  * @param {string} promptText the user's current prompt (already truncated upstream)
106
128
  * @param {Array<object>} candidates primed light-index entries ({id,title,tags,repo_rel_paths,preview,…})
107
- * @returns {Array<{id:string,score:number}>} candidates with score > 0, sorted desc by score.
108
- * Stable tie-break by id so ordering is deterministic across engines.
129
+ * @returns {Array<{id:string,score:number,dth:number,coverage:number}>} candidates
130
+ * with score > 0, sorted desc by score. Stable tie-break by id so ordering
131
+ * is deterministic across engines. `dth`/`coverage` are the inject-decision
132
+ * signals (see header); `score` stays the raw BM25 sum used for RANKING
133
+ * and telemetry (`top_score` semantics unchanged).
109
134
  */
110
135
  export function scoreCandidates(promptText, candidates) {
111
136
  const queryTerms = tokenize(promptText);
@@ -114,18 +139,21 @@ export function scoreCandidates(promptText, candidates) {
114
139
  }
115
140
 
116
141
  // Build per-candidate term-frequency maps + doc lengths from the weighted bags.
142
+ // titleTerms is kept separately for dth: a hit only counts as "title" when the
143
+ // term appears in the title field itself, not merely anywhere in the bag.
117
144
  const docs = [];
118
145
  for (const cand of candidates) {
119
146
  if (!cand || cand.id == null) continue;
120
147
  const bag = candidateBag(cand);
121
148
  const tf = new Map();
122
149
  for (const t of bag) tf.set(t, (tf.get(t) || 0) + 1);
123
- docs.push({ id: cand.id, tf, length: bag.length });
150
+ docs.push({ id: cand.id, tf, length: bag.length, titleTerms: new Set(tokenize(cand.title)) });
124
151
  }
125
152
  if (docs.length === 0) return [];
126
153
 
127
154
  const N = docs.length;
128
155
  const avgdl = docs.reduce((s, d) => s + d.length, 0) / N || 1;
156
+ const distinctiveDfCap = Math.max(1, Math.floor(DISTINCTIVE_DF_RATIO * N));
129
157
 
130
158
  // Document frequency per UNIQUE query term (over the pool = the corpus).
131
159
  const uniqueQueryTerms = [...new Set(queryTerms)];
@@ -144,17 +172,28 @@ export function scoreCandidates(promptText, candidates) {
144
172
  idf.set(term, Math.max(raw, IDF_FLOOR));
145
173
  }
146
174
 
175
+ // Query saturation ceiling for coverage: what the score would be if EVERY unique
176
+ // query term matched at tf→∞ on an average-length doc (den → f + K1 ⇒ factor
177
+ // (K1+1)/(1+K1)·f/(f+…) → (K1+1)/(1+K1) at f=1 normalization). Constant per
178
+ // query, so coverage is comparable across prompts of any length.
179
+ let sumIdfSat = 0;
180
+ for (const term of uniqueQueryTerms) sumIdfSat += idf.get(term) * (K1 + 1) / (1 + K1);
181
+
147
182
  const results = [];
148
183
  for (const d of docs) {
149
184
  let score = 0;
185
+ let dth = 0;
150
186
  for (const term of uniqueQueryTerms) {
151
187
  const f = d.tf.get(term);
152
188
  if (!f) continue;
189
+ if (d.titleTerms.has(term) && df.get(term) <= distinctiveDfCap) dth++;
153
190
  const num = f * (K1 + 1);
154
191
  const den = f + K1 * (1 - B + B * (d.length / avgdl));
155
192
  score += idf.get(term) * (num / den);
156
193
  }
157
- if (score > 0) results.push({ id: d.id, score });
194
+ if (score > 0) {
195
+ results.push({ id: d.id, score, dth, coverage: sumIdfSat > 0 ? score / sumIdfSat : 0 });
196
+ }
158
197
  }
159
198
 
160
199
  results.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.117",
3
+ "version": "0.8.119",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {