ai-lens 0.8.114 → 0.8.117

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
- 507ff10
1
+ 28976f3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
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.117 — 2026-07-01
6
+ - 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
+
8
+ ## 0.8.116 — 2026-07-01
9
+ - fix(team-memory): only inject memory that is genuinely relevant to your prompt — a vague or off-topic prompt now injects nothing, instead of dumping arbitrary team conventions
10
+
11
+ ## 0.8.115 — 2026-07-01
12
+ - feat(team-memory): inject relevant team memory per prompt (matched to what you're asking) instead of a blind dump at session start
13
+ - feat(team-memory): injected memory carries a hint to report it as wrong/outdated via the `ai_lens_memory_report_wrong` MCP tool
14
+ - feat(cli): `ai-lens memory on|off|status` — control team-memory injection on your machine
15
+
5
16
  ## 0.8.114 — 2026-07-01
6
17
  - fix(client): normalize Windows /c:/ drive paths so Cursor git metadata is captured
7
18
  - feat(team-memory): SessionStart recall + injection (T6)
package/bin/ai-lens.js CHANGED
@@ -38,6 +38,11 @@ switch (command) {
38
38
  await deleteSessions();
39
39
  break;
40
40
  }
41
+ case 'memory': {
42
+ const { default: memory } = await import('../cli/memory.js');
43
+ await memory();
44
+ break;
45
+ }
41
46
  case 'local-server':
42
47
  case 'install-local-server': {
43
48
  // `install-local-server` is an alias that always means `local-server up`.
@@ -89,6 +94,7 @@ switch (command) {
89
94
  console.log(' --days N --source S --limit N --json');
90
95
  console.log(' delete-sessions Delete your own sessions (dry-run unless --yes)');
91
96
  console.log(' <id…> | --from D --to D [--source S] [--days N] [--yes] [--json]');
97
+ console.log(' memory <on|off|status> Toggle per-prompt team-memory injection on your machine');
92
98
  console.log(' local-server <up|down|status> Run a private local server in Docker (your data stays local)');
93
99
  console.log(' up [--port N] [--yes] [--recreate] Bring it up, then offer to point the client at it');
94
100
  console.log(' down [--purge] Stop it (--purge also deletes the data volume)');
package/cli/memory.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `ai-lens memory <on|off|status>` — per-person team-memory opt-out (T6d [0118]).
3
+ *
4
+ * Privacy-first + client-side: opting out is a local flag file (MEMORY_OPT_OUT_PATH)
5
+ * — the dev decides on their own machine. When set:
6
+ * - capture.js renders NO per-prompt memory inject and writes NO match annotation;
7
+ * - sender.js sends `X-Memory-Opt-Out: 1` so the server stops PRIMING the cache.
8
+ * `on` = memory injection ON (removes the flag). `off` = opt out (writes the flag).
9
+ * No server round-trip, no auth needed.
10
+ */
11
+
12
+ import { existsSync, writeFileSync, unlinkSync } from 'node:fs';
13
+ import { ensureDataDir, MEMORY_OPT_OUT_PATH, MEMORY_INDEX_PATH } from '../client/config.js';
14
+
15
+ function isOptedOut() {
16
+ try { return existsSync(MEMORY_OPT_OUT_PATH); } catch { return false; }
17
+ }
18
+
19
+ export default async function memory() {
20
+ const sub = (process.argv[3] || 'status').toLowerCase();
21
+
22
+ if (sub === 'on' || sub === 'enable') {
23
+ // Opt back IN: remove the flag file. (The server resumes priming on the next POST.)
24
+ try { unlinkSync(MEMORY_OPT_OUT_PATH); } catch { /* already absent */ }
25
+ console.log('AI Lens team memory: ON — relevant team memories will be surfaced on your prompts.');
26
+ return;
27
+ }
28
+
29
+ if (sub === 'off' || sub === 'disable') {
30
+ ensureDataDir();
31
+ try {
32
+ writeFileSync(MEMORY_OPT_OUT_PATH, new Date().toISOString() + '\n');
33
+ } catch (err) {
34
+ console.error(`Could not write opt-out flag: ${err.message}`);
35
+ process.exit(1);
36
+ }
37
+ // Drop the primed cache immediately so no stale inject can fire before TTL.
38
+ try { unlinkSync(MEMORY_INDEX_PATH); } catch { /* already absent */ }
39
+ console.log('AI Lens team memory: OFF — no memories will be injected into your prompts.');
40
+ console.log('Re-enable any time with `ai-lens memory on`.');
41
+ return;
42
+ }
43
+
44
+ // status (default)
45
+ console.log(`AI Lens team memory: ${isOptedOut() ? 'OFF (opted out)' : 'ON'}`);
46
+ console.log('Usage: ai-lens memory <on|off|status>');
47
+ }
package/client/capture.js CHANGED
@@ -35,9 +35,38 @@ import {
35
35
  NODE_NUDGE_SHOWN_PATH,
36
36
  MEMORY_INDEX_PATH,
37
37
  MEMORY_INDEX_SHOWN_PATH,
38
+ SESSION_MEMORY_SHOWN_DIR,
39
+ MEMORY_OPT_OUT_PATH,
38
40
  } from './config.js';
39
41
  import { isLockStale, isSenderBackoffActive } from './sender.js';
40
42
  import { toNumberOrNull, buildTokenUsageRaw } from './token-usage.js';
43
+ // Soft import — memory-messages.js (i18n seam for the injected report-wrong hint,
44
+ // RU default) ships alongside this file by installClientFiles, but a partial/
45
+ // corrupted copy or version skew must NOT take down capture with ERR_MODULE_NOT_FOUND
46
+ // (mirrors memory-match/redact/mojibake-fix below). On a miss, memoryMsg → '' and the
47
+ // hint LINE is omitted (renderPromptInject guards on falsy) — hooks survive, report
48
+ // hint goes dark. Standalone client copy — the hook can't import server code; keep RU
49
+ // copy in sync with server/lib/memory-messages.js.
50
+ let memoryMsg = () => '';
51
+ try {
52
+ const mod = await import('./memory-messages.js');
53
+ memoryMsg = mod.t ?? (() => '');
54
+ } catch { /* partial install — report hint disabled, hooks survive */ }
55
+ // Soft import — memory-match.js is shipped alongside this file by installClientFiles
56
+ // (dynamic client/*.js discovery COPIES it atomically with capture.js — verified), so
57
+ // on a normal install this branch always resolves; the try/catch is belt-not-trousers
58
+ // for a corrupted/partial install or version skew, never the prod path. A missing/
59
+ // broken module → no-op scorer (never ERR_MODULE_NOT_FOUND, never a half-broken
60
+ // scorer: a missing export falls back to () => [], a call-time throw is caught inside
61
+ // maybeEmitUserPromptMemoryInject). DEGRADED SYMPTOM (silent, by design — a per-hook
62
+ // log would spam every prompt): repo-specific BM25 matches go dark, but team-general
63
+ // (phase-1) still injects since it does NOT depend on the scorer. If per-prompt repo
64
+ // matches ever vanish org-wide, suspect a partial client install here first.
65
+ let scoreCandidates = () => [];
66
+ try {
67
+ const mod = await import('./memory-match.js');
68
+ scoreCandidates = mod.scoreCandidates ?? (() => []);
69
+ } catch { /* corrupted/partial install — repo matches disabled, team-general survives */ }
41
70
  // Soft import — redact.js may not exist on older client installs
42
71
  let redactObject = (o) => o;
43
72
  try {
@@ -1692,6 +1721,217 @@ export function maybeEmitSessionStartMemoryIndex(primary, { now = Date.now() } =
1692
1721
  } catch { return false; } // never break capture
1693
1722
  }
1694
1723
 
1724
+ // =============================================================================
1725
+ // Team-memory PER-PROMPT inject (T6d [0118]) — query-aware, LOCAL BM25 match.
1726
+ //
1727
+ // SessionStart is now a PRIME ONLY: the server ships a candidate POOL (both tiers,
1728
+ // with preview + repo_rel_paths) which sender.js caches to MEMORY_INDEX_PATH. This
1729
+ // helper — on each UserPromptSubmit — scores the CURRENT prompt against that pool
1730
+ // locally (the server's recall can't see this prompt in time; its response is a
1731
+ // prompt behind) and injects only the memories that cross the relevance threshold.
1732
+ // phase-1 (first prompt): top repo matches + team-general (always apt);
1733
+ // phase-N: only NEW/unshown crossings (surgical re-inject).
1734
+ // Two gating axes: eligibility (server only primes for treatment ∧ not opt-out →
1735
+ // empty cache ⇒ this is a no-op) and relevance (local BM25 threshold).
1736
+ // =============================================================================
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 3–4; 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;
1753
+ // Budget: never inject more than this many memories per prompt (repo OR team-general
1754
+ // alike) — keeps a prompt light even on a broad match.
1755
+ const MEMORY_INJECT_BUDGET = 2;
1756
+ const MEMORY_INJECT_MAX_LEN = 1500;
1757
+ const MEMORY_INJECT_HEADER = 'AI Lens team memory — relevant to this prompt (full text via ai_lens_memory_read):';
1758
+ // Bound the per-session shown-set (safety valve; sessions are naturally bounded).
1759
+ const MEMORY_SHOWN_SET_MAX = 200;
1760
+
1761
+ function sessionMemoryShownPath(sessionId) {
1762
+ return join(SESSION_MEMORY_SHOWN_DIR, encodeURIComponent(sessionId || 'unknown'));
1763
+ }
1764
+
1765
+ /** Read the per-session shown-set (`{ <mem_id>: iso, __count: n }`). Never throws. */
1766
+ function readSessionShown(sessionId) {
1767
+ const map = readJsonFileSafe(sessionMemoryShownPath(sessionId));
1768
+ return map && typeof map === 'object' ? map : {};
1769
+ }
1770
+
1771
+ /**
1772
+ * Atomically persist the per-session shown-set (tmp+rename). Best-effort.
1773
+ *
1774
+ * CONCURRENCY: this is a read-modify-write with last-writer-wins on the whole map
1775
+ * (the tmp+rename is atomic per WRITE — no torn file — but two overlapping capture
1776
+ * processes for the SAME session_id can lose an update: both read __count=0, both
1777
+ * fire phase-1, both write __count=1). Accepted, NOT locked: a UserPromptSubmit is a
1778
+ * single human submitting a single prompt in a single conversation, and session_id
1779
+ * is per-conversation — so concurrent same-session UserPrompts are architecturally
1780
+ * implausible (subagents don't emit UserPromptSubmit; two IDE windows = two
1781
+ * session_ids). Worst case in the implausible burst = a team-general memory shown
1782
+ * twice or one memory re-injected once — bounded by the budget, non-corrupting. A
1783
+ * lockfile would add a new failure mode to a best-effort cosmetic path for a race
1784
+ * that can't occur in real single-user use.
1785
+ */
1786
+ function writeSessionShown(sessionId, map) {
1787
+ try {
1788
+ // Ensure the shown-set dir exists (self-heal if it was never mkdir'd — e.g. an
1789
+ // old data dir predating T6d). ensureDataDir mkdirs SESSION_MEMORY_SHOWN_DIR.
1790
+ ensureDataDir();
1791
+ // Bound growth: if too many mem ids accumulate, drop the oldest by iso.
1792
+ const ids = Object.keys(map).filter(k => !k.startsWith('__'));
1793
+ if (ids.length > MEMORY_SHOWN_SET_MAX) {
1794
+ ids.sort((a, b) => (Date.parse(map[a]) || 0) - (Date.parse(map[b]) || 0))
1795
+ .slice(0, ids.length - MEMORY_SHOWN_SET_MAX)
1796
+ .forEach(k => delete map[k]);
1797
+ }
1798
+ const p = sessionMemoryShownPath(sessionId);
1799
+ const tmp = p + '.tmp.' + process.pid;
1800
+ writeFileSync(tmp, JSON.stringify(map));
1801
+ renameSync(tmp, p);
1802
+ } catch { /* best-effort */ }
1803
+ }
1804
+
1805
+ /**
1806
+ * Render selected candidates to a compact block: title + preview + the pull pointer.
1807
+ * Length-bounded + control-char-sanitized (defense-in-depth; preview is server text).
1808
+ * Pure. Returns '' when nothing renderable.
1809
+ */
1810
+ function renderPromptInject(entries) {
1811
+ if (!Array.isArray(entries) || entries.length === 0) return '';
1812
+ const lines = [MEMORY_INJECT_HEADER];
1813
+ for (const e of entries) {
1814
+ if (!e || !e.id) continue;
1815
+ const title = typeof e.title === 'string' ? e.title.replace(/\s+/g, ' ').trim() : '';
1816
+ lines.push(`- ${title || e.id}`);
1817
+ const preview = typeof e.preview === 'string' ? e.preview.replace(/\s+/g, ' ').trim() : '';
1818
+ if (preview) lines.push(` ${preview}`);
1819
+ lines.push(` full text: ai_lens_memory_read('${e.id}')`);
1820
+ // T8 [0110] report-wrong: point the agent at the report tool with this entry's
1821
+ // id (i18n; RU default). Only on the per-prompt inject (NOT the SessionStart
1822
+ // index, which stays id+title-only). Length is bounded by MEMORY_INJECT_MAX_LEN.
1823
+ // OMIT the line entirely when the message is empty (soft-import miss / unknown
1824
+ // key → memoryMsg returns '') — never push a blank half-rendered line.
1825
+ const hint = memoryMsg('inject_report_hint', { id: e.id });
1826
+ if (hint) lines.push(` ${hint}`);
1827
+ }
1828
+ if (lines.length === 1) return '';
1829
+ return sanitizeMessageText(lines.join('\n').slice(0, MEMORY_INJECT_MAX_LEN));
1830
+ }
1831
+
1832
+ /**
1833
+ * On a UserPromptSubmit, locally score the prompt against the primed candidate pool
1834
+ * and inject the relevant memories via capture's one stdout write. Claude →
1835
+ * `systemMessage`; Cursor → `additional_context` (+ relay suffix). Codex/unknown →
1836
+ * no-op (parity with the SessionStart inject). Reads only local caches → identity-
1837
+ * independent; never throws → can't flip capture's exit code.
1838
+ *
1839
+ * Returns `{ wrote, match }` where `match` (or null) is the metadata the caller
1840
+ * stamps onto the queued event as `data.memory_match` so the SERVER can emit
1841
+ * `memory_matched`. `match` carries IDS + SCALARS only — never the prompt text.
1842
+ *
1843
+ * @param {object} primary the normalized UserPromptSubmit event
1844
+ * @returns {{wrote:boolean, match:null|{matched_ids:string[],top_score:number,prompt_index:number,n_matched:number,had_team_general:boolean}}}
1845
+ */
1846
+ export function maybeEmitUserPromptMemoryInject(primary, { now = Date.now() } = {}) {
1847
+ const NONE = { wrote: false, match: null };
1848
+ try {
1849
+ if (!primary || primary.type !== 'UserPromptSubmit') return NONE;
1850
+ const source = primary.source;
1851
+ if (source !== 'claude_code' && source !== 'cursor') return NONE; // codex/unknown deferred
1852
+
1853
+ // Per-person opt-out (client-side, privacy-first): no render, no annotation.
1854
+ if (existsSync(MEMORY_OPT_OUT_PATH)) return NONE;
1855
+
1856
+ // Respect the projects filter. The primed pool is machine-GLOBAL, but a match
1857
+ // must NOT surface in an UNTRACKED project — an unrelated repo the user doesn't
1858
+ // monitor for AI Lens (e.g. a GPU/ASR build that has nothing to do with the team).
1859
+ // This mirrors the project_filter drop the caller applies to event shipping; it
1860
+ // just runs earlier here because the inject is emitted BEFORE that drop. No filter
1861
+ // / no project_path → monitored (inject allowed — unchanged for single-project users).
1862
+ if (!isProjectMonitored(primary.project_path, primary.workspace_roots, getMonitoredProjects())) return NONE;
1863
+
1864
+ // Load the primed pool. Empty/absent cache = not eligible (server only primes
1865
+ // for treatment ∧ not opt-out) ⇒ this is a clean no-op. Also enforce the same
1866
+ // freshness TTL the SessionStart render used (an offline client must not match
1867
+ // against a week-old pool forever).
1868
+ const cache = readJsonFileSafe(MEMORY_INDEX_PATH);
1869
+ const entries = cache && Array.isArray(cache.entries) ? cache.entries : null;
1870
+ if (!entries || entries.length === 0) return NONE;
1871
+ const updatedAtMs = cache.updatedAt ? Date.parse(cache.updatedAt) : NaN;
1872
+ if (!Number.isFinite(updatedAtMs) || now - updatedAtMs > MEMORY_INDEX_MAX_AGE_MS) return NONE;
1873
+
1874
+ const promptText = primary.data && typeof primary.data.prompt === 'string' ? primary.data.prompt : '';
1875
+
1876
+ // Phase index: __count advances on EVERY UserPrompt (even a silent no-inject),
1877
+ // so phase-1 = truly the FIRST prompt of the session, not the first inject.
1878
+ const shown = readSessionShown(primary.session_id);
1879
+ const promptIndex = Number.isFinite(shown.__count) ? shown.__count : 0;
1880
+
1881
+ // Always advance the phase counter for this prompt before any early return below,
1882
+ // and persist it — so a silent prompt still moves phase-1 → phase-N.
1883
+ const nowIso = new Date(now).toISOString();
1884
+ const commitCount = () => { shown.__count = promptIndex + 1; writeSessionShown(primary.session_id, shown); };
1885
+
1886
+ if (!promptText) { commitCount(); return NONE; }
1887
+
1888
+ // Score with the local BM25-lite scorer over the whole pool.
1889
+ const scored = scoreCandidates(promptText, entries);
1890
+ const byId = new Map(entries.map(e => [e.id, e]));
1891
+ const scoreOf = new Map(scored.map(s => [s.id, s.score]));
1892
+
1893
+ // 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`
1898
+ // still advances via commitCount() for telemetry, but no longer gates selection.
1899
+ const picked = scored
1900
+ .filter(s => s.score >= MEMORY_MATCH_MIN_SCORE && !shown[s.id])
1901
+ .map(s => byId.get(s.id))
1902
+ .filter(Boolean)
1903
+ .slice(0, MEMORY_INJECT_BUDGET);
1904
+ const hadTeamGeneral = picked.some(e => e && e.delivery === 'team');
1905
+
1906
+ if (picked.length === 0) { commitCount(); return NONE; }
1907
+
1908
+ const text = renderPromptInject(picked);
1909
+ if (!text) { commitCount(); return NONE; }
1910
+
1911
+ const out = source === 'claude_code'
1912
+ ? { systemMessage: text }
1913
+ : { additional_context: text + CURSOR_RELAY_SUFFIX };
1914
+ process.stdout.write(JSON.stringify(out));
1915
+
1916
+ // Record the injected ids in the shown-set AND advance the phase counter (once).
1917
+ for (const e of picked) shown[e.id] = nowIso;
1918
+ commitCount();
1919
+
1920
+ const matchedIds = picked.map(e => e.id);
1921
+ const topScore = matchedIds.reduce((m, id) => Math.max(m, scoreOf.get(id) || 0), 0);
1922
+ return {
1923
+ wrote: true,
1924
+ match: {
1925
+ matched_ids: matchedIds,
1926
+ top_score: topScore,
1927
+ prompt_index: promptIndex,
1928
+ n_matched: matchedIds.length,
1929
+ had_team_general: hadTeamGeneral,
1930
+ },
1931
+ };
1932
+ } catch { return NONE; } // never break capture
1933
+ }
1934
+
1695
1935
  // Weekly throttle for the Node-upgrade nudge below (its own stamp file).
1696
1936
  const NODE_NUDGE_THROTTLE_MS = 7 * 24 * 60 * 60 * 1000;
1697
1937
 
@@ -1797,17 +2037,31 @@ async function main() {
1797
2037
  // hook invocation on the same machine.
1798
2038
  const primary = events[0];
1799
2039
 
1800
- // Render a server-driven update nudge ([0093]) on SessionStart BEFORE the
1801
- // project_filter / no_email / dedup gates below (those drop exactly the
1802
- // stale/unknown devs we want to reach). Identity-independent, reads only the
1803
- // local cache, never throws can't flip this process to a non-zero exit.
1804
- // capture.js makes exactly ONE stdout write per invocation, so these share the
1805
- // slot via a priority chain (each helper writes at most once): the server-driven
1806
- // [0093] message wins, else the team-memory index (T6 [0108], treatment cohort),
1807
- // else the local Node-upgrade nudge (Cursor sessions on Node < 22.5).
1808
- if (!maybeEmitSessionStartMessage(primary)
1809
- && !maybeEmitSessionStartMemoryIndex(primary)) {
1810
- maybeEmitNodeUpgradeNudge(primary);
2040
+ // Single-stdout slot BEFORE the project_filter / no_email / dedup gates below
2041
+ // (those drop exactly the stale/unknown devs we want to reach). Identity-
2042
+ // independent, reads only local caches, never throws can't flip this process to
2043
+ // a non-zero exit. capture.js makes exactly ONE stdout write per invocation.
2044
+ //
2045
+ // The two branches are keyed on the primary event TYPE and are mutually exclusive
2046
+ // (a hook invocation is EITHER a SessionStart OR a UserPromptSubmit, never both),
2047
+ // so they never contend for the one write:
2048
+ // - SessionStart → the [0093] server nudge (wins), else the local Node-upgrade
2049
+ // nudge. NOTE (T6d [0118]): SessionStart is now PRIME-ONLY for team memory —
2050
+ // the T6 blind-index render (maybeEmitSessionStartMemoryIndex) is intentionally
2051
+ // no longer called from here; memory injection moved to the UserPrompt branch.
2052
+ // (The function + its tests are kept, revivable; its git_remote-null throttle
2053
+ // quirk is moot now that it isn't wired in.)
2054
+ // - UserPromptSubmit → the query-aware per-prompt team-memory inject (T6d),
2055
+ // which also returns match metadata we stamp onto the queued event below so
2056
+ // the server can emit `memory_matched`.
2057
+ let userPromptMemoryMatch = null;
2058
+ if (primary.type === 'SessionStart') {
2059
+ if (!maybeEmitSessionStartMessage(primary)) {
2060
+ maybeEmitNodeUpgradeNudge(primary);
2061
+ }
2062
+ } else if (primary.type === 'UserPromptSubmit') {
2063
+ const r = maybeEmitUserPromptMemoryInject(primary);
2064
+ userPromptMemoryMatch = r.match;
1811
2065
  }
1812
2066
 
1813
2067
  // Filter by monitored projects (if configured) — based on the primary event.
@@ -1846,6 +2100,16 @@ async function main() {
1846
2100
  // Attach git metadata once — every event in the batch shares it.
1847
2101
  const gitMeta = getGitMetadata(primary.project_path);
1848
2102
 
2103
+ // T6d [0118]: stamp the local per-prompt match onto the primary event's data so
2104
+ // the SERVER emits `memory_matched` on ingest. Placed BEFORE writeToSpool (below)
2105
+ // so it's persisted, and AFTER event_id-independent so it never perturbs the
2106
+ // deterministic event_id (that's hashed from raw stdin, not from data). METADATA
2107
+ // ONLY — ids + scalars, never the prompt text (bright line).
2108
+ if (userPromptMemoryMatch && primary.type === 'UserPromptSubmit') {
2109
+ primary.data = primary.data || {};
2110
+ primary.data.memory_match = userPromptMemoryMatch;
2111
+ }
2112
+
1849
2113
  // Assign event_ids:
1850
2114
  // - Primary: deterministic from stdin hash (so Cursor + Claude Code firing
1851
2115
  // the same hook compute the same id and dedup at ON CONFLICT).
package/client/config.js CHANGED
@@ -38,6 +38,17 @@ export const NODE_NUDGE_SHOWN_PATH = join(DATA_DIR, 'node-nudge-shown');
38
38
  // throttle, so a dev re-opening sessions doesn't suppress the adoption funnel.
39
39
  export const MEMORY_INDEX_PATH = join(DATA_DIR, 'memory-index.json');
40
40
  export const MEMORY_INDEX_SHOWN_PATH = join(DATA_DIR, 'memory-index-shown.json');
41
+ // Query-aware per-prompt memory injection (T6d [0118]): the per-session shown-set
42
+ // (one file per session, `{ <mem_id>: iso, __count: n }`) records which memories
43
+ // have been injected in a session so phase-N re-injects only NEW/unshown crossings
44
+ // (pattern of SESSION_PATHS_DIR — per-session file, no shared-state race), and
45
+ // `__count` advances on EVERY UserPrompt so phase-1 = truly the first prompt.
46
+ export const SESSION_MEMORY_SHOWN_DIR = join(DATA_DIR, 'session-memory-shown');
47
+ // Per-person opt-out (privacy-first, CLIENT-side): presence of this file = the dev
48
+ // opted out of memory injection. Read by capture.js (no render/annotation) AND by
49
+ // sender.js (sends `X-Memory-Opt-Out: 1` so the server stops PRIMING the cache).
50
+ // Toggled by `ai-lens memory on|off`. Absence = opted in (the treatment default).
51
+ export const MEMORY_OPT_OUT_PATH = join(DATA_DIR, 'memory-opt-out');
41
52
  export const LOG_MAX_AGE_DAYS = 30;
42
53
  const GIT_ROOT_CACHE = new Map();
43
54
  // Pipe stderr (instead of inheriting it) so that "fatal: not a git repository"
@@ -104,6 +115,7 @@ export function ensureDataDir() {
104
115
  mkdirSync(SESSION_PATHS_DIR, { recursive: true });
105
116
  mkdirSync(TRANSCRIPT_OFFSETS_DIR, { recursive: true });
106
117
  mkdirSync(GIT_REMOTES_DIR, { recursive: true });
118
+ mkdirSync(SESSION_MEMORY_SHOWN_DIR, { recursive: true });
107
119
  }
108
120
 
109
121
  export function getServerUrl() {
@@ -0,0 +1,162 @@
1
+ /**
2
+ * BM25-lite local scorer — query-aware per-prompt memory matching (T6d [0118]).
3
+ *
4
+ * WHY LOCAL (not a server call): the UserPromptSubmit hook must emit its inject
5
+ * into the CURRENT prompt synchronously (stdout is read into THIS turn), but the
6
+ * server's piggyback recall response arrives a prompt LATER. So the client can't
7
+ * ask the server "what matches THIS prompt" in time — it must score the current
8
+ * prompt against the already-primed candidate pool locally, here.
9
+ *
10
+ * Self-contained by necessity: the client ships via npm to developer machines, so
11
+ * this must have ZERO runtime deps (no `natural`, no tokenizer package). ~1 file,
12
+ * pure functions, deterministic — fully unit-testable without I/O.
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.
18
+ *
19
+ * Bilingual by design: memory titles + our MEMORY.md entries are Russian; prompts
20
+ * are mixed RU/EN. Tokenization is Unicode-letter/number aware so Cyrillic and
21
+ * Latin both tokenize, and the stopword set covers both languages.
22
+ */
23
+
24
+ // BM25 knobs — standard defaults. Tunable; these get tuned live on analytics.
25
+ const K1 = 1.2;
26
+ const B = 0.75;
27
+ // Tiny-corpus idf floor: with ≤30 docs, a term in most/all docs yields idf ≤ 0
28
+ // under the classic `ln((N - df + 0.5)/(df + 0.5))`. Floor it so a ubiquitous but
29
+ // on-topic term still contributes a little instead of scoring negative/zero.
30
+ const IDF_FLOOR = 0.01;
31
+
32
+ // Field weights: repeat a field's tokens N times in the candidate's bag so BM25's
33
+ // term-frequency naturally up-weights title/tags/paths over the softer preview.
34
+ const WEIGHT_TITLE = 3;
35
+ const WEIGHT_TAGS = 2;
36
+ const WEIGHT_PATHS = 2;
37
+ const WEIGHT_PREVIEW = 1;
38
+
39
+ // Bilingual stopwords (EN + RU) — high-frequency function words that carry no
40
+ // topic signal. Kept deliberately small; over-stopping hurts recall more than a
41
+ // few function words hurt precision on this tiny corpus.
42
+ export const STOPWORDS = new Set([
43
+ // English
44
+ 'the', 'a', 'an', 'and', 'or', 'but', 'if', 'is', 'are', 'was', 'were', 'be',
45
+ 'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'as', 'it', 'this', 'that',
46
+ 'these', 'those', 'i', 'you', 'we', 'they', 'he', 'she', 'my', 'our', 'your',
47
+ 'do', 'does', 'did', 'not', 'no', 'so', 'up', 'out', 'can', 'will', 'would',
48
+ 'how', 'what', 'when', 'where', 'why', 'which', 'who',
49
+ // Russian
50
+ 'и', 'в', 'во', 'не', 'на', 'с', 'со', 'что', 'как', 'а', 'то', 'по', 'но',
51
+ 'за', 'из', 'к', 'у', 'же', 'от', 'о', 'об', 'для', 'до', 'это', 'этот', 'эта',
52
+ 'эти', 'мне', 'мы', 'вы', 'я', 'ты', 'он', 'она', 'они', 'бы', 'ли', 'да',
53
+ 'или', 'если', 'так', 'при', 'над', 'под', 'без', 'есть',
54
+ ]);
55
+
56
+ /**
57
+ * Tokenize a string into lowercased, Unicode-aware terms.
58
+ * - split on any run of non-letter/non-number/non-underscore (so `foo/bar-baz`,
59
+ * `модель.таблица` split into their segments — high-signal path/identifier bits
60
+ * become their own terms);
61
+ * - drop tokens shorter than 2 chars (noise);
62
+ * - drop stopwords.
63
+ * Pure. Returns [] for non-strings.
64
+ * @param {string} s
65
+ * @returns {string[]}
66
+ */
67
+ export function tokenize(s) {
68
+ if (typeof s !== 'string' || !s) return [];
69
+ const out = [];
70
+ // \p{L} letters (incl. Cyrillic), \p{N} numbers, plus `_`. Everything else splits.
71
+ for (const raw of s.toLowerCase().split(/[^\p{L}\p{N}_]+/u)) {
72
+ if (raw.length < 2) continue;
73
+ if (STOPWORDS.has(raw)) continue;
74
+ out.push(raw);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * Build a candidate's weighted token bag from its matchable metadata fields
81
+ * (title ×3, tags ×2, repo_rel_paths ×2, preview ×1). NEVER touches `body` (the
82
+ * server already projected it to `preview`). Path segments are additionally split
83
+ * on `/._-` by the tokenizer so `models/staging/foo.sql` contributes `models`,
84
+ * `staging`, `foo`, `sql`.
85
+ * @param {object} cand a primed light-index entry
86
+ * @returns {string[]} the weighted bag (with repetition)
87
+ */
88
+ export function candidateBag(cand) {
89
+ if (!cand || typeof cand !== 'object') return [];
90
+ const bag = [];
91
+ const push = (text, weight) => {
92
+ const toks = tokenize(text);
93
+ for (let w = 0; w < weight; w++) bag.push(...toks);
94
+ };
95
+ push(cand.title, WEIGHT_TITLE);
96
+ if (Array.isArray(cand.tags)) push(cand.tags.join(' '), WEIGHT_TAGS);
97
+ if (Array.isArray(cand.repo_rel_paths)) push(cand.repo_rel_paths.join(' '), WEIGHT_PATHS);
98
+ push(cand.preview, WEIGHT_PREVIEW);
99
+ return bag;
100
+ }
101
+
102
+ /**
103
+ * Score a prompt against a pool of candidates with BM25-lite.
104
+ *
105
+ * @param {string} promptText the user's current prompt (already truncated upstream)
106
+ * @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.
109
+ */
110
+ export function scoreCandidates(promptText, candidates) {
111
+ const queryTerms = tokenize(promptText);
112
+ if (!Array.isArray(candidates) || candidates.length === 0 || queryTerms.length === 0) {
113
+ return [];
114
+ }
115
+
116
+ // Build per-candidate term-frequency maps + doc lengths from the weighted bags.
117
+ const docs = [];
118
+ for (const cand of candidates) {
119
+ if (!cand || cand.id == null) continue;
120
+ const bag = candidateBag(cand);
121
+ const tf = new Map();
122
+ for (const t of bag) tf.set(t, (tf.get(t) || 0) + 1);
123
+ docs.push({ id: cand.id, tf, length: bag.length });
124
+ }
125
+ if (docs.length === 0) return [];
126
+
127
+ const N = docs.length;
128
+ const avgdl = docs.reduce((s, d) => s + d.length, 0) / N || 1;
129
+
130
+ // Document frequency per UNIQUE query term (over the pool = the corpus).
131
+ const uniqueQueryTerms = [...new Set(queryTerms)];
132
+ const df = new Map();
133
+ for (const term of uniqueQueryTerms) {
134
+ let count = 0;
135
+ for (const d of docs) if (d.tf.has(term)) count++;
136
+ df.set(term, count);
137
+ }
138
+
139
+ // Robertson–Spärck-Jones idf with a tiny-corpus floor.
140
+ const idf = new Map();
141
+ for (const term of uniqueQueryTerms) {
142
+ const n = df.get(term) || 0;
143
+ const raw = Math.log((N - n + 0.5) / (n + 0.5) + 1); // +1 keeps it > 0 for df<N
144
+ idf.set(term, Math.max(raw, IDF_FLOOR));
145
+ }
146
+
147
+ const results = [];
148
+ for (const d of docs) {
149
+ let score = 0;
150
+ for (const term of uniqueQueryTerms) {
151
+ const f = d.tf.get(term);
152
+ if (!f) continue;
153
+ const num = f * (K1 + 1);
154
+ const den = f + K1 * (1 - B + B * (d.length / avgdl));
155
+ score += idf.get(term) * (num / den);
156
+ }
157
+ if (score > 0) results.push({ id: d.id, score });
158
+ }
159
+
160
+ results.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
161
+ return results;
162
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Team-memory user-facing strings — MINIMAL i18n seam for the CLIENT HOOK runtime
3
+ * (client/capture.js inject render), epic [0102] / T8 [0110] "report-wrong".
4
+ *
5
+ * WHY A SEPARATE COPY FROM server/lib/memory-messages.js:
6
+ * The client hook is a STANDALONE process installed to ~/.ai-lens/client/ (copied by
7
+ * cli/hooks.js installClientFiles, which ships every client/*.js). It cannot import
8
+ * server code — so it carries its own mirror of the ONE string it renders (the inject
9
+ * hint). Same keyed/locale shape as the server module so both translate identically.
10
+ * Keep the RU copy in sync with server/lib/memory-messages.js.
11
+ *
12
+ * SHAPE: `MESSAGES[locale][key]` — string or `(vars) => string`. `t(key, vars)`
13
+ * resolves the DEFAULT locale (RU) for now; `resolveLocale()` is the seam a future
14
+ * per-user locale plugs into (single default today, marked TODO).
15
+ */
16
+
17
+ const DEFAULT_LOCALE = 'ru';
18
+
19
+ const MESSAGES = {
20
+ ru: {
21
+ // The per-prompt inject hint appended after the memory_read pointer. `id` = the
22
+ // memory's id, so the agent can call the report tool with it verbatim.
23
+ inject_report_hint: ({ id }) =>
24
+ `неверно/устарело? → ai_lens_memory_report_wrong(id='${id}', comment='…')`,
25
+ },
26
+ };
27
+
28
+ /**
29
+ * Resolve the active locale. TODO(i18n): thread a per-user locale here later.
30
+ * Single default (RU) for now — mirrors server/lib/memory-messages.js.
31
+ */
32
+ export function resolveLocale() {
33
+ return DEFAULT_LOCALE;
34
+ }
35
+
36
+ /** Keyed lookup for the active locale (falls back to default then the key). */
37
+ export function t(key, vars = {}) {
38
+ const locale = resolveLocale();
39
+ const table = MESSAGES[locale] || MESSAGES[DEFAULT_LOCALE] || {};
40
+ const fallback = MESSAGES[DEFAULT_LOCALE] || {};
41
+ const val = table[key] ?? fallback[key];
42
+ if (val == null) return key;
43
+ return typeof val === 'function' ? val(vars) : val;
44
+ }
package/client/sender.js CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  getClientMode,
45
45
  MESSAGES_PATH,
46
46
  MEMORY_INDEX_PATH,
47
+ MEMORY_OPT_OUT_PATH,
47
48
  DEFAULT_SERVER_URL,
48
49
  log,
49
50
  } from './config.js';
@@ -691,6 +692,10 @@ function buildHeaders(identity, authToken) {
691
692
  if (identity.email) headers['X-Developer-Git-Email'] = identity.email;
692
693
  if (identity.name) headers['X-Developer-Name'] = encodeURIComponent(identity.name);
693
694
  if (authToken) headers['X-Auth-Token'] = authToken;
695
+ // Per-person memory opt-out (T6d [0118]): presence of the flag file (toggled by
696
+ // `ai-lens memory off`) tells the server to stop PRIMING the team-memory cache.
697
+ // Best-effort: a stat error just omits the header (server keeps priming).
698
+ try { if (existsSync(MEMORY_OPT_OUT_PATH)) headers['X-Memory-Opt-Out'] = '1'; } catch { /* ignore */ }
694
699
  return headers;
695
700
  }
696
701
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.114",
3
+ "version": "0.8.117",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {