local-agentic-ai-mem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.recall = recall;
4
+ /**
5
+ * Hybrid recall over the local store.
6
+ *
7
+ * Same shape as the hosted version — several arms fused with reciprocal rank
8
+ * fusion, then ranked and deduplicated — but the vector arms are a plain
9
+ * scan rather than an HNSW index. Measured: 1.7ms over 2,000 vectors. An
10
+ * index would be machinery in service of nothing at this scale.
11
+ *
12
+ * Arms, and why each exists:
13
+ * content vector what was learned
14
+ * prompt vector how it was asked — at recall time the query is itself a
15
+ * user prompt, and dropping this arm cost 2.7 points of
16
+ * precision@5 when it was measured
17
+ * lexical (FTS5) exact identifiers, SHAs, error strings, where embeddings
18
+ * are weakest
19
+ */
20
+ const db_1 = require("../db");
21
+ const RRF_K = 60;
22
+ const ARM_DEPTH = 60;
23
+ const RECENCY_HALF_LIFE_DAYS = 90;
24
+ const RECENCY_FLOOR = 0.45;
25
+ const SAME_PROJECT_BOOST = 1.3;
26
+ const PROVEN_WEIGHT = 0.12;
27
+ const PROVEN_CAP = 1.5;
28
+ const MMR_DUP_THRESHOLD = 0.6;
29
+ /** Work that reached a remote passed whatever gates exist. Grades, never gates. */
30
+ const CONFIDENCE_WEIGHT = { pushed: 1.25, committed: 1.1, stated: 1.0 };
31
+ function tokenSet(s) {
32
+ return new Set((s || "").toLowerCase().replace(/[^a-z0-9/._-]+/g, " ").split(/\s+/).filter((t) => t.length > 2));
33
+ }
34
+ function jaccard(a, b) {
35
+ if (!a.size || !b.size)
36
+ return 0;
37
+ let inter = 0;
38
+ for (const t of a)
39
+ if (b.has(t))
40
+ inter++;
41
+ return inter / (a.size + b.size - inter);
42
+ }
43
+ /**
44
+ * Staleness is measured from when a memory was last confirmed useful, not when
45
+ * it was written. A note from five months ago that was pulled up yesterday is
46
+ * not stale.
47
+ */
48
+ function recencyFactor(row) {
49
+ const created = row.started_at;
50
+ const recalled = row.last_recalled;
51
+ const iso = recalled && new Date(recalled) > new Date(created) ? recalled : created;
52
+ const ageDays = (Date.now() - new Date(iso).getTime()) / 86_400_000;
53
+ if (!Number.isFinite(ageDays) || ageDays < 0)
54
+ return 1;
55
+ return RECENCY_FLOOR + (1 - RECENCY_FLOOR) * Math.pow(0.5, ageDays / RECENCY_HALF_LIFE_DAYS);
56
+ }
57
+ function bodyOf(row) {
58
+ if (row.content && row.content.trim())
59
+ return row.content.trim();
60
+ const decisions = safeArray(row.decisions);
61
+ const meaningful = decisions.filter((d) => !/^(task|at commit|areas):/i.test(d));
62
+ if (meaningful.length)
63
+ return meaningful.slice(0, 4).join(" · ");
64
+ return (row.prompt || "").trim();
65
+ }
66
+ function safeArray(json) {
67
+ try {
68
+ const v = JSON.parse(json);
69
+ return Array.isArray(v) ? v.map(String) : [];
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ }
75
+ function rankArm(rows, key, q) {
76
+ const scored = [];
77
+ for (const r of rows) {
78
+ const v = (0, db_1.decodeVec)(r[key] ?? null);
79
+ if (!v)
80
+ continue;
81
+ scored.push({ id: r.id, sim: (0, db_1.cosine)(q, v) });
82
+ }
83
+ scored.sort((a, b) => b.sim - a.sim);
84
+ return scored.slice(0, ARM_DEPTH).map((s) => s.id);
85
+ }
86
+ function recall(opts) {
87
+ const limit = opts.limit ?? 5;
88
+ const q = opts.queryVec instanceof Float32Array ? opts.queryVec : Float32Array.from(opts.queryVec);
89
+ // One scan serves every arm — the whole live set for this user is small.
90
+ const rows = (0, db_1.liveMemories)({ tier: opts.tier ?? 2 });
91
+ if (rows.length === 0)
92
+ return [];
93
+ const byId = new Map(rows.map((r) => [r.id, r]));
94
+ const arms = [
95
+ rankArm(rows, "vec", q),
96
+ rankArm(rows, "prompt_vec", q),
97
+ (0, db_1.lexicalSearch)(opts.queryText, ARM_DEPTH).filter((id) => byId.has(id)),
98
+ ];
99
+ if (opts.projectId !== undefined) {
100
+ const scoped = rows.filter((r) => r.project_id === opts.projectId);
101
+ if (scoped.length)
102
+ arms.push(rankArm(scoped, "vec", q));
103
+ }
104
+ const fused = new Map();
105
+ for (const arm of arms) {
106
+ arm.forEach((id, rank) => {
107
+ fused.set(id, (fused.get(id) ?? 0) + 1 / (RRF_K + rank + 1));
108
+ });
109
+ }
110
+ const scored = [];
111
+ for (const [id, rrf] of fused) {
112
+ const row = byId.get(id);
113
+ const text = bodyOf(row);
114
+ if (!text)
115
+ continue;
116
+ const proven = Math.min(PROVEN_CAP, 1 + PROVEN_WEIGHT * Math.log1p(row.recall_count || 0));
117
+ const sameProject = opts.projectId !== undefined && row.project_id === opts.projectId;
118
+ scored.push({
119
+ id,
120
+ text,
121
+ decisions: safeArray(row.decisions).filter((d) => !/^(task|at commit|areas):/i.test(d)),
122
+ files: safeArray(row.files),
123
+ project: row.project_slug ?? "",
124
+ projectId: row.project_id,
125
+ date: row.started_at,
126
+ gitCommit: row.git_commit,
127
+ anchorSha: row.anchor_sha,
128
+ confidence: row.confidence,
129
+ score: rrf *
130
+ recencyFactor(row) *
131
+ (sameProject ? SAME_PROJECT_BOOST : 1) *
132
+ proven *
133
+ (CONFIDENCE_WEIGHT[row.confidence] ?? 1),
134
+ });
135
+ }
136
+ scored.sort((a, b) => b.score - a.score);
137
+ // Greedy MMR. Without it a query near a recurring phrase returns five copies
138
+ // of the same non-memory.
139
+ const picked = [];
140
+ const seen = [];
141
+ for (const cand of scored) {
142
+ if (picked.length >= limit)
143
+ break;
144
+ const t = tokenSet(cand.text);
145
+ if (seen.some((s) => jaccard(t, s) > MMR_DUP_THRESHOLD))
146
+ continue;
147
+ picked.push(cand);
148
+ seen.push(t);
149
+ }
150
+ (0, db_1.markRecalled)(picked.map((p) => p.id));
151
+ return picked;
152
+ }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * Server-side secret redaction.
4
+ *
5
+ * The CLI has an equivalent in templates/lib/pii.mjs, but it was only applied
6
+ * to the text being embedded — the raw prompt was posted and stored verbatim.
7
+ * That is backwards: secrets were scrubbed from the 384 floats nobody can read
8
+ * and preserved in the column anybody with database access can. Auditing the
9
+ * live corpus turned up internal hostnames, absolute server paths and shell
10
+ * lines like `user@host:~$ sudo /opt/platform/repo/tools/ops/refresh.sh`.
11
+ *
12
+ * The client now redacts before sending; this runs again on the server so an
13
+ * old or modified client cannot write an unredacted secret into the database.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.redact = redact;
17
+ exports.redactAll = redactAll;
18
+ const PATTERNS = [
19
+ // Prefixed opaque tokens (sk-, ghp_, xoxb-, api_key-...)
20
+ [/\b(sk|pk|rk|api|token|key|bearer|ghp|gho|ghu|ghs|ghr|xox[abprs])[-_][A-Za-z0-9_-]{16,}\b/gi, "[REDACTED_TOKEN]"],
21
+ [/\bnpm_[A-Za-z0-9]{20,}\b/g, "[REDACTED_NPM_TOKEN]"],
22
+ [/\bmemos_[a-f0-9-]{20,}\b/gi, "[REDACTED_MEMOS_KEY]"],
23
+ [/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED_AWS_KEY]"],
24
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[REDACTED_JWT]"],
25
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]"],
26
+ // Connection strings — the class of secret this codebase actually leaked.
27
+ [/\b([a-z][a-z0-9+.-]*):\/\/([^\s:/@]+):([^\s@]+)@/gi, "$1://$2:[REDACTED_PASSWORD]@"],
28
+ // Assignment shapes: DB_PASSWORD=hunter2, "secret": "abc123", api_key: xyz.
29
+ // Unambiguous because of the separator, so the value is taken as-is. The
30
+ // leading \w* matters: \b does not match between "DB_" and "PASSWORD",
31
+ // which is the most common env-var shape there is.
32
+ [
33
+ /\b(\w*(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret))(\s*["']?\s*[:=]\s*["']?)([^\s"',;]{6,})/gi,
34
+ (_m, k, sep) => `${k}${sep}[REDACTED_SECRET]`,
35
+ ],
36
+ // Prose shape: "password s3cretLeak". Riskier, so the value must look like a
37
+ // secret rather than an English word — it needs both a digit and a letter,
38
+ // which "password validation logic" does not have.
39
+ [
40
+ /\b(password|passphrase|secret|token)\s+(?=\S*[0-9])(?=\S*[A-Za-z])([^\s"',;]{8,})/gi,
41
+ (_m, k) => `${k} [REDACTED_SECRET]`,
42
+ ],
43
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[REDACTED_EMAIL]"],
44
+ // Public IPv4 only — private ranges are dev infra and useful context.
45
+ [/\b(?!10\.|192\.168\.|127\.|0\.|172\.(?:1[6-9]|2\d|3[01])\.)(?:\d{1,3}\.){3}\d{1,3}\b/g, "[REDACTED_IP]"],
46
+ [/\b(?:\d[ -]?){13,19}\b/g, (m) => (/\d{13,19}/.test(m.replace(/[ -]/g, "")) ? "[REDACTED_CC]" : m)],
47
+ [/\b[a-f0-9]{32,}\b/gi, "[REDACTED_HEX]"],
48
+ ];
49
+ function redact(text) {
50
+ if (!text || typeof text !== "string")
51
+ return text;
52
+ let out = text;
53
+ for (const [re, sub] of PATTERNS) {
54
+ out = typeof sub === "function"
55
+ ? out.replace(re, sub)
56
+ : out.replace(re, sub);
57
+ }
58
+ return out;
59
+ }
60
+ function redactAll(values) {
61
+ if (!Array.isArray(values))
62
+ return [];
63
+ return values.map((v) => redact(String(v)));
64
+ }
@@ -0,0 +1,405 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_FACTS_PER_PROJECT = void 0;
4
+ exports.assignTier = assignTier;
5
+ exports.gradeConfidence = gradeConfidence;
6
+ exports.confidenceFactor = confidenceFactor;
7
+ exports.deriveProjectFacts = deriveProjectFacts;
8
+ exports.findSupersessions = findSupersessions;
9
+ /**
10
+ * Memory tiering — what gets promoted, what expires, what is always present.
11
+ *
12
+ * The corpus problem was never ranking. It was that everything got in and
13
+ * nothing ever left, so 11,345 equals competed for five slots. These three
14
+ * tiers separate them by how they should be used:
15
+ *
16
+ * 1 repo knowledge — few, injected whole, never searched
17
+ * 2 decisions — retrieved, anchored, supersedable
18
+ * 3 episodes — raw turns, decay and archive
19
+ *
20
+ * This module is deliberately free of database imports: it is pure logic over
21
+ * atoms, so the evaluation harness can measure exactly what the server runs
22
+ * without needing a connection. The DB wrappers live in services/tiering.ts.
23
+ */
24
+ const compose_1 = require("./compose");
25
+ // ── tunables ─────────────────────────────────────────────────────────────────
26
+ /** Tier 1 is injected whole on every session, so it has to stay small. */
27
+ exports.MAX_FACTS_PER_PROJECT = 40;
28
+ const MAX_HOTSPOTS = 14;
29
+ const HOTSPOT_MIN_TOUCHES = 3;
30
+ const CONSTRAINT_MIN_OBSERVATIONS = 2;
31
+ /** Restatement threshold for supersession. High on purpose — see below. */
32
+ const RESTATEMENT_THRESHOLD = 0.7;
33
+ // ── tier assignment ──────────────────────────────────────────────────────────
34
+ /**
35
+ * Tier 2 is a decision you could act on: it says something, and it is anchored
36
+ * to code. Everything else is an episode. The anchor matters as much as the
37
+ * text — an unanchored claim cannot be checked for staleness later.
38
+ */
39
+ function assignTier(atom) {
40
+ const decisions = (0, compose_1.meaningfulDecisions)(atom.decisions);
41
+ const files = atom.files_touched ?? [];
42
+ if (decisions.length === 0)
43
+ return 3;
44
+ if (files.length === 0 && !atom.git_commit)
45
+ return 3;
46
+ return 2;
47
+ }
48
+ /**
49
+ * Grade, do not gate.
50
+ *
51
+ * Reaching a remote means the work passed whatever gates exist. But a great
52
+ * deal of valuable knowledge never touches a commit — ops facts, client
53
+ * preferences, "this is their bug not ours" — so this ranks memories rather
54
+ * than admitting them.
55
+ */
56
+ function gradeConfidence(atom) {
57
+ if (atom.git_commit && atom.git_pushed)
58
+ return "pushed";
59
+ if (atom.git_commit)
60
+ return "committed";
61
+ return "stated";
62
+ }
63
+ const CONFIDENCE_WEIGHT = {
64
+ pushed: 1.25,
65
+ committed: 1.1,
66
+ stated: 1.0,
67
+ };
68
+ function confidenceFactor(c) {
69
+ return CONFIDENCE_WEIGHT[c ?? "stated"];
70
+ }
71
+ // ── text helpers ─────────────────────────────────────────────────────────────
72
+ function tokens(s) {
73
+ return new Set((s || "").toLowerCase().replace(/[^a-z0-9/._-]+/g, " ").split(/\s+/).filter((t) => t.length > 2));
74
+ }
75
+ function jaccard(a, b) {
76
+ if (!a.size || !b.size)
77
+ return 0;
78
+ let inter = 0;
79
+ for (const t of a)
80
+ if (b.has(t))
81
+ inter++;
82
+ return inter / (a.size + b.size - inter);
83
+ }
84
+ const norm = (s) => s.toLowerCase().replace(/[.!?,;:]+$/g, "").replace(/\s+/g, " ").trim();
85
+ /** Claude stating a completed outcome. */
86
+ const OUTCOME = /^(done|fixed|added|created|removed|updated|refactored|migrated|implemented|configured|wired|built|set up|renamed|dropped|moved|merged|replaced|switched|reverted)\b/i;
87
+ /** An explanation of why something is the way it is. */
88
+ const CAUSAL = /\b(root cause|because|the reason|turns? out|which is why|caused by|due to|the bug was|the problem was)\b/i;
89
+ /** A statement that will still be true next month. */
90
+ const CONSTRAINT = /\b(must (?:be|not|always|never)|cannot be|can'?t be|should (?:always|never)|always (?:use|run|keep)|never (?:use|run|commit)|only works? (?:if|when)|is required|requires? )/i;
91
+ /**
92
+ * An approach that was tried and abandoned.
93
+ *
94
+ * This is the single most valuable thing a memory system holds, because it is
95
+ * the one thing that leaves no trace in the codebase. The code shows what was
96
+ * kept; nothing shows what was discarded and why. 28% of this corpus mentions
97
+ * a failed or abandoned path.
98
+ */
99
+ const ABANDONED = /\b(did ?n'?t work|does ?n'?t work|do ?n'?t work|didn't help|failed because|turned out|reverted|rolled back|had to (?:revert|undo|drop)|abandoned|gave up on|no longer works?|broke|regressed|caused the|was the culprit|is not the fix|won'?t work|can'?t (?:use|do) .+ because)\b/i;
100
+ /** A reason, which is what makes an abandoned path worth keeping. */
101
+ const REASONED = /\b(because|since|due to|root cause|the reason|turns? out|which is why|so that|otherwise)\b/i;
102
+ /**
103
+ * Something a human or an external system imposed. Also not in the code:
104
+ * client preferences, ops facts, third-party behaviour, "this is their bug".
105
+ * 28% of this corpus mentions one.
106
+ */
107
+ const EXTERNAL = /\b(client|customer|the user wants|they want|they asked|ops|sysadmin|staging|production server|credential|password rotat|third[- ]party|vendor|upstream|support (?:said|told)|their (?:bug|issue|side|infra|api|server)|rejected|approved|requires? (?:a|an|the) (?:account|licen[cs]e|key))\b/i;
108
+ /**
109
+ * Shapes that look like knowledge but are not.
110
+ *
111
+ * The source material is prose scraped out of a transcript, so it carries
112
+ * three kinds of impostor: thinking-aloud mid-debug ("there must be another
113
+ * code path"), UI strings the session happened to be editing ("always use
114
+ * data"), and changelog lines ("Pushed 58f7b73"). All three match the same
115
+ * surface patterns as real knowledge. A fact injected into every session
116
+ * forever has to clear a much higher bar than a search result.
117
+ */
118
+ const SPECULATION = /\b(there must be|must be another|maybe|perhaps|i think|i wonder|not sure|let me|let's check|hmm|wait[,.]|actually[,.]|probably|might be|could be|looks like|seems to)\b/i;
119
+ /** UI copy, option lists and rendered strings, not statements about the work. */
120
+ const UI_COPY = /(["'][^"']{3,}["']\s*(?:\/|→|->|,)\s*["']|^[-•*]\s|→|✓|✗|\bpanel\b|\bplaceholder\b|\blabel\b|\btooltip\b|\bbutton text\b)/i;
121
+ /** Commit/deploy narration — true, dated, and worthless next month. */
122
+ const CHANGELOG = /^(pushed|committed|deployed|merged|bumped|released)\b|\b[0-9a-f]{7,40}\b.*\b(will run|deploy)/i;
123
+ function isImpostor(d) {
124
+ return SPECULATION.test(d) || UI_COPY.test(d) || CHANGELOG.test(d);
125
+ }
126
+ /** An explicit replacement — the strongest supersession signal available. */
127
+ const REPLACEMENT = /\b(replaced .+ with|switched from .+ to|no longer|reverted|removed .+ in favou?r of|migrated from .+ to|renamed .+ to|deprecated)\b/i;
128
+ // ── Tier 1 derivation ────────────────────────────────────────────────────────
129
+ /**
130
+ * Build the always-injected set for one project.
131
+ *
132
+ * Nothing here is generated: every fact is either a file the project actually
133
+ * revolves around, a behaviour observed enough times to be a convention, or a
134
+ * constraint stated more than once. A fact that cannot point at its evidence
135
+ * does not belong in a set that is injected unconditionally.
136
+ */
137
+ function deriveProjectFacts(atoms) {
138
+ const facts = [];
139
+ const seen = new Set();
140
+ const push = (f) => {
141
+ const key = `${f.kind}::${norm(f.fact)}`;
142
+ if (seen.has(key))
143
+ return;
144
+ seen.add(key);
145
+ facts.push(f);
146
+ };
147
+ // ── hotspots: the files this project revolves around, and what is known
148
+ // about them. Frequency is the signal — a file touched twenty times is
149
+ // where the work happens.
150
+ const touches = new Map();
151
+ for (const a of atoms) {
152
+ for (const f of a.files_touched ?? []) {
153
+ if (!f)
154
+ continue;
155
+ const list = touches.get(f);
156
+ if (list)
157
+ list.push(a);
158
+ else
159
+ touches.set(f, [a]);
160
+ }
161
+ }
162
+ const ranked = [...touches.entries()]
163
+ .filter(([, as]) => as.length >= HOTSPOT_MIN_TOUCHES)
164
+ .sort((a, b) => b[1].length - a[1].length)
165
+ .slice(0, MAX_HOTSPOTS);
166
+ const described = [];
167
+ const bare = [];
168
+ for (const [file, fileAtoms] of ranked) {
169
+ const base = file.split("/").pop() ?? file;
170
+ const short = file.split("/").slice(-3).join("/");
171
+ // A hotspot fact is injected into every session, so a wrong one is
172
+ // expensive. Only attach a description when the statement actually names
173
+ // the file — scoring on prose signals alone attached a migration's root
174
+ // cause to a CI workflow just because one session touched both.
175
+ let best = null;
176
+ for (const a of fileAtoms) {
177
+ for (const raw of (0, compose_1.meaningfulDecisions)(a.decisions)) {
178
+ const d = raw.replace(/^[-•·*]\s+/, "").trim();
179
+ if (d.length < 30 || d.length > 180)
180
+ continue;
181
+ if (!d.includes(base))
182
+ continue;
183
+ // A "description" that is mostly the path again says nothing.
184
+ if (d.replace(file, "").replace(base, "").trim().length < 20)
185
+ continue;
186
+ // Prefer a statement that explains or decides over one that lists.
187
+ const better = OUTCOME.test(d) || CAUSAL.test(d) || CONSTRAINT.test(d);
188
+ if (!best || (better && !(OUTCOME.test(best.text) || CAUSAL.test(best.text)))) {
189
+ best = { text: d, conf: a.confidence ?? "stated" };
190
+ }
191
+ }
192
+ }
193
+ if (best) {
194
+ described.push(file);
195
+ push({
196
+ kind: "hotspot",
197
+ fact: `${short} — ${best.text}`,
198
+ evidence: `touched in ${fileAtoms.length} sessions`,
199
+ files: [file],
200
+ observations: fileAtoms.length,
201
+ confidence: best.conf,
202
+ });
203
+ }
204
+ else {
205
+ bare.push(file);
206
+ }
207
+ }
208
+ // Files we know are central but have nothing to say about. One line beats
209
+ // fourteen copies of "X is central to this project".
210
+ if (bare.length) {
211
+ push({
212
+ kind: "hotspot",
213
+ fact: `Most-touched files: ${bare.map((f) => f.split("/").slice(-2).join("/")).slice(0, 10).join(", ")}`,
214
+ evidence: `${bare.length} files by edit frequency`,
215
+ files: bare,
216
+ observations: bare.reduce((s2, f) => s2 + (touches.get(f)?.length ?? 0), 0),
217
+ confidence: "stated",
218
+ });
219
+ }
220
+ // ── pitfalls: what was tried and abandoned, with the reason
221
+ //
222
+ // Held to a high bar because these are injected into every session: a
223
+ // statement must both report an abandonment AND give a reason. "That broke"
224
+ // is not worth a permanent slot; "that broke because the hook runs in a
225
+ // fresh process each time" is.
226
+ const pitfalls = new Map();
227
+ for (const a of atoms) {
228
+ for (const raw of (0, compose_1.meaningfulDecisions)(a.decisions)) {
229
+ const d = raw.replace(/^[-•·*]\s+/, "").trim();
230
+ if (d.length < 30 || d.length > 220)
231
+ continue;
232
+ if (d.endsWith("?"))
233
+ continue;
234
+ if (!ABANDONED.test(d) || !REASONED.test(d))
235
+ continue;
236
+ if (isImpostor(d))
237
+ continue;
238
+ const key = norm(d);
239
+ const prev = pitfalls.get(key);
240
+ if (prev)
241
+ prev.count++;
242
+ else
243
+ pitfalls.set(key, { count: 1, files: a.files_touched ?? [], conf: a.confidence ?? "stated", at: a.started_at });
244
+ }
245
+ }
246
+ for (const [key, v] of [...pitfalls.entries()].sort((x, y) => y[1].count - x[1].count).slice(0, 10)) {
247
+ push({
248
+ kind: "pitfall",
249
+ fact: key.charAt(0).toUpperCase() + key.slice(1),
250
+ evidence: v.count > 1 ? `hit ${v.count} times` : "learned once",
251
+ files: v.files.slice(0, 3),
252
+ observations: v.count,
253
+ confidence: v.conf,
254
+ });
255
+ }
256
+ // ── context: what a human or an external system imposed
257
+ const contexts = new Map();
258
+ for (const a of atoms) {
259
+ for (const raw of (0, compose_1.meaningfulDecisions)(a.decisions)) {
260
+ const d = raw.replace(/^[-•·*]\s+/, "").trim();
261
+ if (d.length < 30 || d.length > 220)
262
+ continue;
263
+ if (d.endsWith("?"))
264
+ continue;
265
+ if (!EXTERNAL.test(d))
266
+ continue;
267
+ if (isImpostor(d))
268
+ continue;
269
+ // The external actor must be the subject of a durable claim, not a word
270
+ // that happens to appear. "the client wants X" qualifies; "update status
271
+ // modal (pending -> approved)" does not.
272
+ if (!/\b(client|customer|ops|vendor|upstream|they|their|support|staging|production)\b[^.]{0,40}\b(is|are|was|were|must|should|cannot|can'?t|wants?|needs?|requires?|rotat|rejected|approved|said|told|asked|returns?|fails?|crashes?|breaks?|blocks?|holds?|throws?|expires?|times out|changed)\b/i.test(d))
273
+ continue;
274
+ const key = norm(d);
275
+ const prev = contexts.get(key);
276
+ if (prev)
277
+ prev.count++;
278
+ else
279
+ contexts.set(key, { count: 1, files: a.files_touched ?? [], conf: a.confidence ?? "stated" });
280
+ }
281
+ }
282
+ for (const [key, v] of [...contexts.entries()].sort((x, y) => y[1].count - x[1].count).slice(0, 10)) {
283
+ push({
284
+ kind: "context",
285
+ fact: key.charAt(0).toUpperCase() + key.slice(1),
286
+ evidence: v.count > 1 ? `stated ${v.count} times` : "stated once",
287
+ files: v.files.slice(0, 3),
288
+ observations: v.count,
289
+ confidence: v.conf,
290
+ });
291
+ }
292
+ // ── constraints: rules stated more than once
293
+ const constraintCounts = new Map();
294
+ for (const a of atoms) {
295
+ for (const d of (0, compose_1.meaningfulDecisions)(a.decisions)) {
296
+ if (!CONSTRAINT.test(d) || d.length > 200)
297
+ continue;
298
+ if (isImpostor(d))
299
+ continue;
300
+ // "Done. now uses X" reports a change; it is not a standing rule.
301
+ // Outcome narration belongs in tier 2, where it can be retrieved when
302
+ // relevant, rather than asserted on every session forever.
303
+ if (OUTCOME.test(d))
304
+ continue;
305
+ const key = norm(d);
306
+ const prev = constraintCounts.get(key);
307
+ if (prev) {
308
+ prev.count++;
309
+ }
310
+ else {
311
+ constraintCounts.set(key, { count: 1, files: a.files_touched ?? [], conf: a.confidence ?? "stated" });
312
+ }
313
+ }
314
+ }
315
+ for (const [key, v] of [...constraintCounts.entries()].sort((a, b) => b[1].count - a[1].count)) {
316
+ if (v.count < CONSTRAINT_MIN_OBSERVATIONS)
317
+ continue;
318
+ push({
319
+ kind: "constraint",
320
+ fact: key.charAt(0).toUpperCase() + key.slice(1),
321
+ evidence: `stated ${v.count} times`,
322
+ files: v.files.slice(0, 3),
323
+ observations: v.count,
324
+ confidence: v.conf,
325
+ });
326
+ }
327
+ // Rank by what only memory can know, then by weight of evidence. Sorting on
328
+ // raw counts let hotspots — cheap, and re-derivable with one `git log` —
329
+ // crowd out the pitfalls that are the whole point of keeping a history.
330
+ const KIND_RANK = {
331
+ pitfall: 0, context: 1, constraint: 2, note: 3, hotspot: 4,
332
+ };
333
+ return facts
334
+ .sort((a, b) => (KIND_RANK[a.kind] - KIND_RANK[b.kind]) || (b.observations - a.observations))
335
+ .slice(0, exports.MAX_FACTS_PER_PROJECT);
336
+ }
337
+ function safeParse(s) {
338
+ try {
339
+ return JSON.parse(s);
340
+ }
341
+ catch {
342
+ return null;
343
+ }
344
+ }
345
+ /**
346
+ * Find memories a later one has made obsolete.
347
+ *
348
+ * Deliberately conservative, and nothing here deletes. Two rules only:
349
+ *
350
+ * restated the same statement, made again later about the same file. The
351
+ * older copy adds nothing.
352
+ * replaced a later memory explicitly says something was swapped out, and
353
+ * names a thing the older one was about.
354
+ *
355
+ * What this does NOT do is treat "the file changed" as "the decision is
356
+ * wrong". Files change constantly without invalidating why they are the way
357
+ * they are, and some decisions outlive their code entirely — "we left Stripe
358
+ * because of PH support" stays true long after the Stripe code is gone.
359
+ */
360
+ function findSupersessions(atoms) {
361
+ const byProject = new Map();
362
+ for (const a of atoms) {
363
+ const list = byProject.get(a.project_id);
364
+ if (list)
365
+ list.push(a);
366
+ else
367
+ byProject.set(a.project_id, [a]);
368
+ }
369
+ const out = [];
370
+ for (const group of byProject.values()) {
371
+ const sorted = [...group].sort((a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime());
372
+ for (let i = 0; i < sorted.length; i++) {
373
+ const older = sorted[i];
374
+ const olderFiles = new Set(older.files_touched ?? []);
375
+ if (olderFiles.size === 0)
376
+ continue;
377
+ const olderText = tokens(older.content ?? "");
378
+ if (olderText.size === 0)
379
+ continue;
380
+ for (let j = i + 1; j < sorted.length; j++) {
381
+ const newer = sorted[j];
382
+ const sharesFile = (newer.files_touched ?? []).some((f) => olderFiles.has(f));
383
+ if (!sharesFile)
384
+ continue;
385
+ const newerText = tokens(newer.content ?? "");
386
+ if (newerText.size === 0)
387
+ continue;
388
+ if (jaccard(olderText, newerText) >= RESTATEMENT_THRESHOLD) {
389
+ out.push({ superseded: older.id, by: newer.id, reason: "restated" });
390
+ break;
391
+ }
392
+ if (REPLACEMENT.test(newer.content ?? "")) {
393
+ // Only when the replacement actually talks about the same subject,
394
+ // not merely the same file.
395
+ const overlap = jaccard(olderText, newerText);
396
+ if (overlap >= 0.35) {
397
+ out.push({ superseded: older.id, by: newer.id, reason: "replaced" });
398
+ break;
399
+ }
400
+ }
401
+ }
402
+ }
403
+ }
404
+ return out;
405
+ }