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.
- package/README.md +74 -0
- package/dist/commands/import.js +232 -0
- package/dist/commands/install.js +112 -0
- package/dist/commands/status.js +29 -0
- package/dist/commands/uninstall-legacy.js +159 -0
- package/dist/db.js +359 -0
- package/dist/index.js +58 -0
- package/dist/lib/compliance.js +169 -0
- package/dist/lib/compose.js +130 -0
- package/dist/lib/conventions.js +78 -0
- package/dist/lib/embed.js +47 -0
- package/dist/lib/extract.js +297 -0
- package/dist/lib/maintain.js +81 -0
- package/dist/lib/recall.js +152 -0
- package/dist/lib/redact.js +64 -0
- package/dist/lib/tiers.js +405 -0
- package/dist/mcp/server.js +238 -0
- package/package.json +42 -0
- package/templates/hooks/post-tool-use.mjs +29 -0
- package/templates/hooks/session-start.mjs +72 -0
- package/templates/hooks/stop.mjs +113 -0
- package/templates/hooks/user-prompt-submit.mjs +72 -0
|
@@ -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
|
+
}
|