ai-lens 0.8.114 → 0.8.115
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 +5 -0
- package/bin/ai-lens.js +6 -0
- package/cli/memory.js +47 -0
- package/client/capture.js +284 -11
- package/client/config.js +12 -0
- package/client/memory-match.js +162 -0
- package/client/memory-messages.js +44 -0
- package/client/sender.js +5 -0
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
3067749
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
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.115 — 2026-07-01
|
|
6
|
+
- feat(team-memory): inject relevant team memory per prompt (matched to what you're asking) instead of a blind dump at session start
|
|
7
|
+
- feat(team-memory): injected memory carries a hint to report it as wrong/outdated via the `ai_lens_memory_report_wrong` MCP tool
|
|
8
|
+
- feat(cli): `ai-lens memory on|off|status` — control team-memory injection on your machine
|
|
9
|
+
|
|
5
10
|
## 0.8.114 — 2026-07-01
|
|
6
11
|
- fix(client): normalize Windows /c:/ drive paths so Cursor git metadata is captured
|
|
7
12
|
- 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,226 @@ 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
|
+
const MEMORY_MATCH_MIN_SCORE = 1.5;
|
|
1749
|
+
// Budget: never inject more than this many REPO matches per prompt (keeps a prompt
|
|
1750
|
+
// from being flooded; team-general in phase-1 is a small separate allowance).
|
|
1751
|
+
const MEMORY_INJECT_BUDGET = 2;
|
|
1752
|
+
// Phase-1 team-general allowance (cross-repo conventions) — kept small so the very
|
|
1753
|
+
// first prompt stays light (total phase-1 items ≈ budget + this ≤ ~3–4).
|
|
1754
|
+
const MEMORY_PHASE1_TEAM_GENERAL = 2;
|
|
1755
|
+
const MEMORY_INJECT_MAX_LEN = 1500;
|
|
1756
|
+
const MEMORY_INJECT_HEADER = 'AI Lens team memory — relevant to this prompt (full text via ai_lens_memory_read):';
|
|
1757
|
+
// Bound the per-session shown-set (safety valve; sessions are naturally bounded).
|
|
1758
|
+
const MEMORY_SHOWN_SET_MAX = 200;
|
|
1759
|
+
|
|
1760
|
+
function sessionMemoryShownPath(sessionId) {
|
|
1761
|
+
return join(SESSION_MEMORY_SHOWN_DIR, encodeURIComponent(sessionId || 'unknown'));
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/** Read the per-session shown-set (`{ <mem_id>: iso, __count: n }`). Never throws. */
|
|
1765
|
+
function readSessionShown(sessionId) {
|
|
1766
|
+
const map = readJsonFileSafe(sessionMemoryShownPath(sessionId));
|
|
1767
|
+
return map && typeof map === 'object' ? map : {};
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Atomically persist the per-session shown-set (tmp+rename). Best-effort.
|
|
1772
|
+
*
|
|
1773
|
+
* CONCURRENCY: this is a read-modify-write with last-writer-wins on the whole map
|
|
1774
|
+
* (the tmp+rename is atomic per WRITE — no torn file — but two overlapping capture
|
|
1775
|
+
* processes for the SAME session_id can lose an update: both read __count=0, both
|
|
1776
|
+
* fire phase-1, both write __count=1). Accepted, NOT locked: a UserPromptSubmit is a
|
|
1777
|
+
* single human submitting a single prompt in a single conversation, and session_id
|
|
1778
|
+
* is per-conversation — so concurrent same-session UserPrompts are architecturally
|
|
1779
|
+
* implausible (subagents don't emit UserPromptSubmit; two IDE windows = two
|
|
1780
|
+
* session_ids). Worst case in the implausible burst = a team-general memory shown
|
|
1781
|
+
* twice or one memory re-injected once — bounded by the budget, non-corrupting. A
|
|
1782
|
+
* lockfile would add a new failure mode to a best-effort cosmetic path for a race
|
|
1783
|
+
* that can't occur in real single-user use.
|
|
1784
|
+
*/
|
|
1785
|
+
function writeSessionShown(sessionId, map) {
|
|
1786
|
+
try {
|
|
1787
|
+
// Ensure the shown-set dir exists (self-heal if it was never mkdir'd — e.g. an
|
|
1788
|
+
// old data dir predating T6d). ensureDataDir mkdirs SESSION_MEMORY_SHOWN_DIR.
|
|
1789
|
+
ensureDataDir();
|
|
1790
|
+
// Bound growth: if too many mem ids accumulate, drop the oldest by iso.
|
|
1791
|
+
const ids = Object.keys(map).filter(k => !k.startsWith('__'));
|
|
1792
|
+
if (ids.length > MEMORY_SHOWN_SET_MAX) {
|
|
1793
|
+
ids.sort((a, b) => (Date.parse(map[a]) || 0) - (Date.parse(map[b]) || 0))
|
|
1794
|
+
.slice(0, ids.length - MEMORY_SHOWN_SET_MAX)
|
|
1795
|
+
.forEach(k => delete map[k]);
|
|
1796
|
+
}
|
|
1797
|
+
const p = sessionMemoryShownPath(sessionId);
|
|
1798
|
+
const tmp = p + '.tmp.' + process.pid;
|
|
1799
|
+
writeFileSync(tmp, JSON.stringify(map));
|
|
1800
|
+
renameSync(tmp, p);
|
|
1801
|
+
} catch { /* best-effort */ }
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Render selected candidates to a compact block: title + preview + the pull pointer.
|
|
1806
|
+
* Length-bounded + control-char-sanitized (defense-in-depth; preview is server text).
|
|
1807
|
+
* Pure. Returns '' when nothing renderable.
|
|
1808
|
+
*/
|
|
1809
|
+
function renderPromptInject(entries) {
|
|
1810
|
+
if (!Array.isArray(entries) || entries.length === 0) return '';
|
|
1811
|
+
const lines = [MEMORY_INJECT_HEADER];
|
|
1812
|
+
for (const e of entries) {
|
|
1813
|
+
if (!e || !e.id) continue;
|
|
1814
|
+
const title = typeof e.title === 'string' ? e.title.replace(/\s+/g, ' ').trim() : '';
|
|
1815
|
+
lines.push(`- ${title || e.id}`);
|
|
1816
|
+
const preview = typeof e.preview === 'string' ? e.preview.replace(/\s+/g, ' ').trim() : '';
|
|
1817
|
+
if (preview) lines.push(` ${preview}`);
|
|
1818
|
+
lines.push(` full text: ai_lens_memory_read('${e.id}')`);
|
|
1819
|
+
// T8 [0110] report-wrong: point the agent at the report tool with this entry's
|
|
1820
|
+
// id (i18n; RU default). Only on the per-prompt inject (NOT the SessionStart
|
|
1821
|
+
// index, which stays id+title-only). Length is bounded by MEMORY_INJECT_MAX_LEN.
|
|
1822
|
+
// OMIT the line entirely when the message is empty (soft-import miss / unknown
|
|
1823
|
+
// key → memoryMsg returns '') — never push a blank half-rendered line.
|
|
1824
|
+
const hint = memoryMsg('inject_report_hint', { id: e.id });
|
|
1825
|
+
if (hint) lines.push(` ${hint}`);
|
|
1826
|
+
}
|
|
1827
|
+
if (lines.length === 1) return '';
|
|
1828
|
+
return sanitizeMessageText(lines.join('\n').slice(0, MEMORY_INJECT_MAX_LEN));
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* On a UserPromptSubmit, locally score the prompt against the primed candidate pool
|
|
1833
|
+
* and inject the relevant memories via capture's one stdout write. Claude →
|
|
1834
|
+
* `systemMessage`; Cursor → `additional_context` (+ relay suffix). Codex/unknown →
|
|
1835
|
+
* no-op (parity with the SessionStart inject). Reads only local caches → identity-
|
|
1836
|
+
* independent; never throws → can't flip capture's exit code.
|
|
1837
|
+
*
|
|
1838
|
+
* Returns `{ wrote, match }` where `match` (or null) is the metadata the caller
|
|
1839
|
+
* stamps onto the queued event as `data.memory_match` so the SERVER can emit
|
|
1840
|
+
* `memory_matched`. `match` carries IDS + SCALARS only — never the prompt text.
|
|
1841
|
+
*
|
|
1842
|
+
* @param {object} primary the normalized UserPromptSubmit event
|
|
1843
|
+
* @returns {{wrote:boolean, match:null|{matched_ids:string[],top_score:number,prompt_index:number,n_matched:number,had_team_general:boolean}}}
|
|
1844
|
+
*/
|
|
1845
|
+
export function maybeEmitUserPromptMemoryInject(primary, { now = Date.now() } = {}) {
|
|
1846
|
+
const NONE = { wrote: false, match: null };
|
|
1847
|
+
try {
|
|
1848
|
+
if (!primary || primary.type !== 'UserPromptSubmit') return NONE;
|
|
1849
|
+
const source = primary.source;
|
|
1850
|
+
if (source !== 'claude_code' && source !== 'cursor') return NONE; // codex/unknown deferred
|
|
1851
|
+
|
|
1852
|
+
// Per-person opt-out (client-side, privacy-first): no render, no annotation.
|
|
1853
|
+
if (existsSync(MEMORY_OPT_OUT_PATH)) return NONE;
|
|
1854
|
+
|
|
1855
|
+
// Load the primed pool. Empty/absent cache = not eligible (server only primes
|
|
1856
|
+
// for treatment ∧ not opt-out) ⇒ this is a clean no-op. Also enforce the same
|
|
1857
|
+
// freshness TTL the SessionStart render used (an offline client must not match
|
|
1858
|
+
// against a week-old pool forever).
|
|
1859
|
+
const cache = readJsonFileSafe(MEMORY_INDEX_PATH);
|
|
1860
|
+
const entries = cache && Array.isArray(cache.entries) ? cache.entries : null;
|
|
1861
|
+
if (!entries || entries.length === 0) return NONE;
|
|
1862
|
+
const updatedAtMs = cache.updatedAt ? Date.parse(cache.updatedAt) : NaN;
|
|
1863
|
+
if (!Number.isFinite(updatedAtMs) || now - updatedAtMs > MEMORY_INDEX_MAX_AGE_MS) return NONE;
|
|
1864
|
+
|
|
1865
|
+
const promptText = primary.data && typeof primary.data.prompt === 'string' ? primary.data.prompt : '';
|
|
1866
|
+
|
|
1867
|
+
// Phase index: __count advances on EVERY UserPrompt (even a silent no-inject),
|
|
1868
|
+
// so phase-1 = truly the FIRST prompt of the session, not the first inject.
|
|
1869
|
+
const shown = readSessionShown(primary.session_id);
|
|
1870
|
+
const promptIndex = Number.isFinite(shown.__count) ? shown.__count : 0;
|
|
1871
|
+
|
|
1872
|
+
// Always advance the phase counter for this prompt before any early return below,
|
|
1873
|
+
// and persist it — so a silent prompt still moves phase-1 → phase-N.
|
|
1874
|
+
const nowIso = new Date(now).toISOString();
|
|
1875
|
+
const commitCount = () => { shown.__count = promptIndex + 1; writeSessionShown(primary.session_id, shown); };
|
|
1876
|
+
|
|
1877
|
+
if (!promptText) { commitCount(); return NONE; }
|
|
1878
|
+
|
|
1879
|
+
const repo = entries.filter(e => e && e.delivery !== 'team');
|
|
1880
|
+
const teamGeneral = entries.filter(e => e && e.delivery === 'team');
|
|
1881
|
+
|
|
1882
|
+
// Score with the local BM25-lite scorer over the whole pool.
|
|
1883
|
+
const scored = scoreCandidates(promptText, entries);
|
|
1884
|
+
const byId = new Map(entries.map(e => [e.id, e]));
|
|
1885
|
+
const scoreOf = new Map(scored.map(s => [s.id, s.score]));
|
|
1886
|
+
|
|
1887
|
+
let picked = []; // entries to inject
|
|
1888
|
+
let hadTeamGeneral = false;
|
|
1889
|
+
|
|
1890
|
+
if (promptIndex === 0) {
|
|
1891
|
+
// Phase-1: strongest intent signal — top repo matches by score (≥ threshold)
|
|
1892
|
+
// PLUS team-general (cross-repo conventions are always apt on the first prompt,
|
|
1893
|
+
// regardless of BM25). Keep total light.
|
|
1894
|
+
const topRepo = scored
|
|
1895
|
+
.filter(s => s.score >= MEMORY_MATCH_MIN_SCORE)
|
|
1896
|
+
.map(s => byId.get(s.id))
|
|
1897
|
+
.filter(e => e && e.delivery !== 'team')
|
|
1898
|
+
.slice(0, MEMORY_INJECT_BUDGET);
|
|
1899
|
+
const tg = teamGeneral.slice(0, MEMORY_PHASE1_TEAM_GENERAL);
|
|
1900
|
+
hadTeamGeneral = tg.length > 0;
|
|
1901
|
+
// De-dupe (a team-general could also appear in topRepo if mis-tagged).
|
|
1902
|
+
const seen = new Set();
|
|
1903
|
+
picked = [...topRepo, ...tg].filter(e => e && !seen.has(e.id) && seen.add(e.id));
|
|
1904
|
+
} else {
|
|
1905
|
+
// Phase-N: surgical — only NEW (unshown) candidates crossing the threshold to
|
|
1906
|
+
// THIS prompt. No blanket team-general re-inject (already shown in phase-1).
|
|
1907
|
+
picked = scored
|
|
1908
|
+
.filter(s => s.score >= MEMORY_MATCH_MIN_SCORE && !shown[s.id])
|
|
1909
|
+
.map(s => byId.get(s.id))
|
|
1910
|
+
.filter(Boolean)
|
|
1911
|
+
.slice(0, MEMORY_INJECT_BUDGET);
|
|
1912
|
+
hadTeamGeneral = picked.some(e => e.delivery === 'team');
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
if (picked.length === 0) { commitCount(); return NONE; }
|
|
1916
|
+
|
|
1917
|
+
const text = renderPromptInject(picked);
|
|
1918
|
+
if (!text) { commitCount(); return NONE; }
|
|
1919
|
+
|
|
1920
|
+
const out = source === 'claude_code'
|
|
1921
|
+
? { systemMessage: text }
|
|
1922
|
+
: { additional_context: text + CURSOR_RELAY_SUFFIX };
|
|
1923
|
+
process.stdout.write(JSON.stringify(out));
|
|
1924
|
+
|
|
1925
|
+
// Record the injected ids in the shown-set AND advance the phase counter (once).
|
|
1926
|
+
for (const e of picked) shown[e.id] = nowIso;
|
|
1927
|
+
commitCount();
|
|
1928
|
+
|
|
1929
|
+
const matchedIds = picked.map(e => e.id);
|
|
1930
|
+
const topScore = matchedIds.reduce((m, id) => Math.max(m, scoreOf.get(id) || 0), 0);
|
|
1931
|
+
return {
|
|
1932
|
+
wrote: true,
|
|
1933
|
+
match: {
|
|
1934
|
+
matched_ids: matchedIds,
|
|
1935
|
+
top_score: topScore,
|
|
1936
|
+
prompt_index: promptIndex,
|
|
1937
|
+
n_matched: matchedIds.length,
|
|
1938
|
+
had_team_general: hadTeamGeneral,
|
|
1939
|
+
},
|
|
1940
|
+
};
|
|
1941
|
+
} catch { return NONE; } // never break capture
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1695
1944
|
// Weekly throttle for the Node-upgrade nudge below (its own stamp file).
|
|
1696
1945
|
const NODE_NUDGE_THROTTLE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1697
1946
|
|
|
@@ -1797,17 +2046,31 @@ async function main() {
|
|
|
1797
2046
|
// hook invocation on the same machine.
|
|
1798
2047
|
const primary = events[0];
|
|
1799
2048
|
|
|
1800
|
-
//
|
|
1801
|
-
//
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1807
|
-
//
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2049
|
+
// Single-stdout slot — BEFORE the project_filter / no_email / dedup gates below
|
|
2050
|
+
// (those drop exactly the stale/unknown devs we want to reach). Identity-
|
|
2051
|
+
// independent, reads only local caches, never throws → can't flip this process to
|
|
2052
|
+
// a non-zero exit. capture.js makes exactly ONE stdout write per invocation.
|
|
2053
|
+
//
|
|
2054
|
+
// The two branches are keyed on the primary event TYPE and are mutually exclusive
|
|
2055
|
+
// (a hook invocation is EITHER a SessionStart OR a UserPromptSubmit, never both),
|
|
2056
|
+
// so they never contend for the one write:
|
|
2057
|
+
// - SessionStart → the [0093] server nudge (wins), else the local Node-upgrade
|
|
2058
|
+
// nudge. NOTE (T6d [0118]): SessionStart is now PRIME-ONLY for team memory —
|
|
2059
|
+
// the T6 blind-index render (maybeEmitSessionStartMemoryIndex) is intentionally
|
|
2060
|
+
// no longer called from here; memory injection moved to the UserPrompt branch.
|
|
2061
|
+
// (The function + its tests are kept, revivable; its git_remote-null throttle
|
|
2062
|
+
// quirk is moot now that it isn't wired in.)
|
|
2063
|
+
// - UserPromptSubmit → the query-aware per-prompt team-memory inject (T6d),
|
|
2064
|
+
// which also returns match metadata we stamp onto the queued event below so
|
|
2065
|
+
// the server can emit `memory_matched`.
|
|
2066
|
+
let userPromptMemoryMatch = null;
|
|
2067
|
+
if (primary.type === 'SessionStart') {
|
|
2068
|
+
if (!maybeEmitSessionStartMessage(primary)) {
|
|
2069
|
+
maybeEmitNodeUpgradeNudge(primary);
|
|
2070
|
+
}
|
|
2071
|
+
} else if (primary.type === 'UserPromptSubmit') {
|
|
2072
|
+
const r = maybeEmitUserPromptMemoryInject(primary);
|
|
2073
|
+
userPromptMemoryMatch = r.match;
|
|
1811
2074
|
}
|
|
1812
2075
|
|
|
1813
2076
|
// Filter by monitored projects (if configured) — based on the primary event.
|
|
@@ -1846,6 +2109,16 @@ async function main() {
|
|
|
1846
2109
|
// Attach git metadata once — every event in the batch shares it.
|
|
1847
2110
|
const gitMeta = getGitMetadata(primary.project_path);
|
|
1848
2111
|
|
|
2112
|
+
// T6d [0118]: stamp the local per-prompt match onto the primary event's data so
|
|
2113
|
+
// the SERVER emits `memory_matched` on ingest. Placed BEFORE writeToSpool (below)
|
|
2114
|
+
// so it's persisted, and AFTER event_id-independent so it never perturbs the
|
|
2115
|
+
// deterministic event_id (that's hashed from raw stdin, not from data). METADATA
|
|
2116
|
+
// ONLY — ids + scalars, never the prompt text (bright line).
|
|
2117
|
+
if (userPromptMemoryMatch && primary.type === 'UserPromptSubmit') {
|
|
2118
|
+
primary.data = primary.data || {};
|
|
2119
|
+
primary.data.memory_match = userPromptMemoryMatch;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
1849
2122
|
// Assign event_ids:
|
|
1850
2123
|
// - Primary: deterministic from stdin hash (so Cursor + Claude Code firing
|
|
1851
2124
|
// 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
|
|