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 +1 -1
- package/CHANGELOG.md +7 -0
- package/client/capture.js +39 -24
- package/client/memory-match.js +47 -8
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
-
//
|
|
1739
|
-
//
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
//
|
|
1745
|
-
//
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1752
|
-
|
|
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 4–23 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
|
|
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
|
|
1895
|
-
// were not already shown this session.
|
|
1896
|
-
// low-signal prompt ("давай делай",
|
|
1897
|
-
//
|
|
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.
|
|
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
|
-
? {
|
|
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
|
-
|
|
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:
|
|
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,
|
package/client/memory-match.js
CHANGED
|
@@ -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 (
|
|
15
|
-
*
|
|
16
|
-
* would otherwise get idf ≤ 0 and
|
|
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
|
|
108
|
-
* Stable tie-break by id so ordering
|
|
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)
|
|
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));
|