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,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Compose a memory body for an atom that has no `content` yet.
|
|
4
|
+
*
|
|
5
|
+
* Historical atoms predate the content field, and the server has no transcript
|
|
6
|
+
* for them — only the prompt, the tool calls, the files touched and whatever
|
|
7
|
+
* the old regex scraper put in `decisions`. This squeezes a usable memory out
|
|
8
|
+
* of that material, or reports that there isn't one.
|
|
9
|
+
*
|
|
10
|
+
* The important behaviour is the second case. The previous refresh pass kept
|
|
11
|
+
* every atom because its extractor unconditionally appended `task: <prompt>`
|
|
12
|
+
* for any prompt of 20 characters or more, so "decisions.length > 0" was
|
|
13
|
+
* always true and the archive branch was unreachable. Filler is filtered out
|
|
14
|
+
* here before the emptiness check, so archival can actually happen.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.meaningfulDecisions = meaningfulDecisions;
|
|
18
|
+
exports.isControlFlowPrompt = isControlFlowPrompt;
|
|
19
|
+
exports.indexText = indexText;
|
|
20
|
+
exports.composeAtomContent = composeAtomContent;
|
|
21
|
+
/** Decision prefixes that restate inputs rather than record an outcome. */
|
|
22
|
+
const FILLER = /^(task|at commit|areas|pattern|convention|summary):\s*/i;
|
|
23
|
+
/** Prompts that moved a session along without containing anything to remember. */
|
|
24
|
+
const CONTROL_FLOW = /^(ok|okay|yes|yep|y|k|go|next|continue|proceed|thanks?|ty|test again|try again|do it|lets? go|ok lets? go|yes lets? go|whats? next\s*\??|ok whats? next\s*\??|git add commit and push( everything)?\.?|push it|commit it|save|\/\w[\w-]*|<task-notification>|\[image #\d+\])\s*[.!?]*$/i;
|
|
25
|
+
function parseToolCalls(raw) {
|
|
26
|
+
const arr = typeof raw === "string" ? safeParse(raw) : raw;
|
|
27
|
+
if (!Array.isArray(arr))
|
|
28
|
+
return [];
|
|
29
|
+
return arr
|
|
30
|
+
.filter((t) => t && typeof t.tool === "string")
|
|
31
|
+
.map((t) => ({ tool: String(t.tool), target: String(t.target ?? "") }));
|
|
32
|
+
}
|
|
33
|
+
function safeParse(s) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(s);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Decisions that actually say something, in original order. */
|
|
42
|
+
function meaningfulDecisions(decisions) {
|
|
43
|
+
if (!Array.isArray(decisions))
|
|
44
|
+
return [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const d of decisions) {
|
|
48
|
+
const text = String(d || "").trim();
|
|
49
|
+
if (!text || FILLER.test(text))
|
|
50
|
+
continue;
|
|
51
|
+
if (text.length < 15)
|
|
52
|
+
continue;
|
|
53
|
+
const key = text.toLowerCase();
|
|
54
|
+
if (seen.has(key))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
out.push(text);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Strip harness scaffolding before judging a prompt.
|
|
63
|
+
*
|
|
64
|
+
* Claude Code injects structured payloads — task notifications, image
|
|
65
|
+
* references, system reminders — as the user turn. The tag alone was matched,
|
|
66
|
+
* but a real `<task-notification>` carries an id and a summary inside it, so
|
|
67
|
+
* the multi-line form slipped through and reached the index as a "memory".
|
|
68
|
+
*/
|
|
69
|
+
function stripHarnessNoise(text) {
|
|
70
|
+
return (text || "")
|
|
71
|
+
.replace(/<task-notification>[\s\S]*?<\/task-notification>/gi, " ")
|
|
72
|
+
.replace(/<\/?(?:task-notification|task-id|summary|system-reminder|command-name|local-command-stdout)[^>]*>/gi, " ")
|
|
73
|
+
.replace(/\[Image #\d+\]/gi, " ")
|
|
74
|
+
.replace(/\s+/g, " ")
|
|
75
|
+
.trim();
|
|
76
|
+
}
|
|
77
|
+
function isControlFlowPrompt(prompt) {
|
|
78
|
+
const p = stripHarnessNoise(prompt);
|
|
79
|
+
if (p.length < 12)
|
|
80
|
+
return true;
|
|
81
|
+
return CONTROL_FLOW.test(p);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The text that gets embedded.
|
|
85
|
+
*
|
|
86
|
+
* Both halves matter and for different reasons. The prompt carries how the
|
|
87
|
+
* user phrases things, and at recall time the query *is* a new user prompt —
|
|
88
|
+
* dropping it measurably hurt retrieval. The content carries what was
|
|
89
|
+
* actually learned, which is what makes the returned memory worth reading.
|
|
90
|
+
* Indexing only one of them loses either the match or the payload.
|
|
91
|
+
*
|
|
92
|
+
* This also brings the vector arm in line with the lexical arm, whose
|
|
93
|
+
* tsvector has always covered content, prompt, decisions and files together.
|
|
94
|
+
*/
|
|
95
|
+
const INDEX_TEXT_CAP = 1200;
|
|
96
|
+
function indexText(prompt, content) {
|
|
97
|
+
const p = stripHarnessNoise(prompt || "").slice(0, 400);
|
|
98
|
+
const c = (content || "").trim();
|
|
99
|
+
const joined = [c, p].filter(Boolean).join("\n");
|
|
100
|
+
return joined.slice(0, INDEX_TEXT_CAP);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Returns null when the atom holds nothing durable — the caller should archive
|
|
104
|
+
* it rather than keep it competing for space in recall results.
|
|
105
|
+
*/
|
|
106
|
+
function composeAtomContent(atom) {
|
|
107
|
+
const meaningful = meaningfulDecisions(atom.decisions);
|
|
108
|
+
const tools = parseToolCalls(atom.tool_calls);
|
|
109
|
+
const files = Array.isArray(atom.files_touched) ? atom.files_touched : [];
|
|
110
|
+
if (meaningful.length > 0) {
|
|
111
|
+
const content = meaningful.slice(0, 3).join(" · ").slice(0, 500);
|
|
112
|
+
return { content, decisions: meaningful.slice(0, 15) };
|
|
113
|
+
}
|
|
114
|
+
// No usable prose. Fall back to a factual description of the work, but only
|
|
115
|
+
// when there was real work — a control-flow prompt with no files changed is
|
|
116
|
+
// not a memory no matter how many tool calls it made.
|
|
117
|
+
const wrote = tools.filter((t) => ["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(t.tool));
|
|
118
|
+
if (files.length === 0 && wrote.length === 0)
|
|
119
|
+
return null;
|
|
120
|
+
if (isControlFlowPrompt(atom.prompt) && files.length === 0)
|
|
121
|
+
return null;
|
|
122
|
+
const changed = [...new Set(wrote.map((t) => t.target.split("/").pop()).filter(Boolean))];
|
|
123
|
+
const subject = changed.length ? changed.slice(0, 5).join(", ") : files.slice(0, 5).join(", ");
|
|
124
|
+
if (!subject)
|
|
125
|
+
return null;
|
|
126
|
+
const task = isControlFlowPrompt(atom.prompt) ? "" : `${atom.prompt.slice(0, 160).trim()} — `;
|
|
127
|
+
const commit = atom.git_commit ? ` (at ${atom.git_commit})` : "";
|
|
128
|
+
const content = `${task}changed ${subject}${commit}`.slice(0, 500);
|
|
129
|
+
return { content, decisions: meaningful };
|
|
130
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Convention derivation — pure logic, no database.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of DB imports so the tiering logic and the evaluation harness can
|
|
6
|
+
* use it without a connection. The persistence side lives in
|
|
7
|
+
* services/preferences.ts.
|
|
8
|
+
*
|
|
9
|
+
* Only records things that are checkable and that repeated. "Runs tests with
|
|
10
|
+
* vitest" comes from actually seeing `vitest run` N times; the store it
|
|
11
|
+
* replaced was filled by asking a model for "preferences observed" on every
|
|
12
|
+
* session, which always answered, and after five months held 101 rows saying
|
|
13
|
+
* things like "Clean and maintainable code structure".
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.norm = exports.CONFIRM_THRESHOLD = void 0;
|
|
17
|
+
exports.deriveObservations = deriveObservations;
|
|
18
|
+
/** A signal must be seen this many times before it is treated as a convention. */
|
|
19
|
+
exports.CONFIRM_THRESHOLD = 3;
|
|
20
|
+
const norm = (s) => (s || "").toLowerCase().replace(/[.!?,;:]+$/g, "").replace(/\s+/g, " ").trim();
|
|
21
|
+
exports.norm = norm;
|
|
22
|
+
const COMMAND_SIGNALS = [
|
|
23
|
+
{ match: /\bvitest\b/, category: "testing", preference: "Runs tests with vitest" },
|
|
24
|
+
{ match: /\bjest\b/, category: "testing", preference: "Runs tests with jest" },
|
|
25
|
+
{ match: /\bpytest\b/, category: "testing", preference: "Runs tests with pytest" },
|
|
26
|
+
{ match: /\bpnpm\s/, category: "tooling", preference: "Uses pnpm as the package manager" },
|
|
27
|
+
{ match: /\byarn\s/, category: "tooling", preference: "Uses yarn as the package manager" },
|
|
28
|
+
{ match: /\bbun\s/, category: "tooling", preference: "Uses bun as the runtime/package manager" },
|
|
29
|
+
{ match: /\bpm2\s/, category: "tooling", preference: "Deploys with pm2" },
|
|
30
|
+
{ match: /\bdocker(?:-compose)?\s/, category: "tooling", preference: "Uses Docker for local services" },
|
|
31
|
+
{ match: /\bturbo\s/, category: "architecture", preference: "Uses Turborepo to orchestrate workspace tasks" },
|
|
32
|
+
{ match: /\btsc\b|\btype-?check\b/, category: "tooling", preference: "Type-checks with tsc before shipping" },
|
|
33
|
+
];
|
|
34
|
+
const FILE_SIGNALS = [
|
|
35
|
+
{ match: /\/migrations?\/\d{3}_[\w-]+\.sql$/, category: "conventions", preference: "Numbered SQL migration files (NNN_name.sql)" },
|
|
36
|
+
{ match: /__tests__\//, category: "testing", preference: "Tests live in __tests__/ directories" },
|
|
37
|
+
{ match: /\.(test|spec)\.[tj]sx?$/, category: "testing", preference: "Tests colocated as *.test.ts alongside source" },
|
|
38
|
+
{ match: /\/components?\/[A-Z][A-Za-z0-9]*\.tsx$/, category: "conventions", preference: "PascalCase component filenames" },
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Derive candidate observations from one session's raw material. Returns one
|
|
42
|
+
* entry per distinct signal seen — the caller records them, and only signals
|
|
43
|
+
* that recur across sessions ever reach CONFIRM_THRESHOLD.
|
|
44
|
+
*/
|
|
45
|
+
function deriveObservations(input) {
|
|
46
|
+
const out = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const add = (o) => {
|
|
49
|
+
const k = `${o.category}::${(0, exports.norm)(o.preference)}`;
|
|
50
|
+
if (seen.has(k))
|
|
51
|
+
return;
|
|
52
|
+
seen.add(k);
|
|
53
|
+
out.push(o);
|
|
54
|
+
};
|
|
55
|
+
for (const cmd of input.commands || []) {
|
|
56
|
+
for (const sig of COMMAND_SIGNALS) {
|
|
57
|
+
if (sig.match.test(cmd)) {
|
|
58
|
+
add({ category: sig.category, preference: sig.preference, evidence: cmd.slice(0, 200) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const file of input.files || []) {
|
|
63
|
+
for (const sig of FILE_SIGNALS) {
|
|
64
|
+
if (sig.match.test(file)) {
|
|
65
|
+
add({ category: sig.category, preference: sig.preference, evidence: file });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const conventional = (input.commitMessages || []).filter((m) => /^(feat|fix|chore|refactor|docs|test|perf|style|build|ci)(\([^)]+\))?:/i.test(m));
|
|
70
|
+
if (conventional.length >= 2) {
|
|
71
|
+
add({
|
|
72
|
+
category: "conventions",
|
|
73
|
+
preference: "Conventional commit prefixes (feat/fix/chore)",
|
|
74
|
+
evidence: conventional.slice(0, 3).join(" | ").slice(0, 200),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.modelCacheDir = modelCacheDir;
|
|
4
|
+
exports.getEmbedder = getEmbedder;
|
|
5
|
+
exports.embed = embed;
|
|
6
|
+
exports.embedMany = embedMany;
|
|
7
|
+
/**
|
|
8
|
+
* Local embeddings. bge-small-en-v1.5 via fastembed, on CPU.
|
|
9
|
+
*
|
|
10
|
+
* ~190ms per call warm, 417ms to load the model the first time. No API key,
|
|
11
|
+
* no network, no per-call cost — the text being embedded is often the user's
|
|
12
|
+
* own source code, and it never leaves the machine.
|
|
13
|
+
*/
|
|
14
|
+
const fastembed_1 = require("fastembed");
|
|
15
|
+
const os_1 = require("os");
|
|
16
|
+
const path_1 = require("path");
|
|
17
|
+
let _embedder = null;
|
|
18
|
+
function modelCacheDir() {
|
|
19
|
+
return process.env.AGENTIC_MEMORY_MODELS || (0, path_1.join)((0, os_1.homedir)(), ".agentic-memory", "models");
|
|
20
|
+
}
|
|
21
|
+
function getEmbedder() {
|
|
22
|
+
if (!_embedder) {
|
|
23
|
+
_embedder = fastembed_1.FlagEmbedding.init({
|
|
24
|
+
model: fastembed_1.EmbeddingModel.BGESmallENV15,
|
|
25
|
+
cacheDir: modelCacheDir(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return _embedder;
|
|
29
|
+
}
|
|
30
|
+
async function embed(text) {
|
|
31
|
+
const e = await getEmbedder();
|
|
32
|
+
for await (const batch of e.embed([text], 1))
|
|
33
|
+
return Float32Array.from(batch[0]);
|
|
34
|
+
throw new Error("embedding produced no vector");
|
|
35
|
+
}
|
|
36
|
+
async function embedMany(texts, onProgress) {
|
|
37
|
+
if (texts.length === 0)
|
|
38
|
+
return [];
|
|
39
|
+
const e = await getEmbedder();
|
|
40
|
+
const out = [];
|
|
41
|
+
for await (const batch of e.embed(texts, 32)) {
|
|
42
|
+
for (const v of batch)
|
|
43
|
+
out.push(Float32Array.from(v));
|
|
44
|
+
onProgress?.(out.length);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Transcript → memory, with no LLM and no network call.
|
|
4
|
+
*
|
|
5
|
+
* This replaces summarizeTranscript(), which sent transcript.slice(-8000) to
|
|
6
|
+
* gpt-4o-mini. Two things were wrong with that beyond the vendor dependency:
|
|
7
|
+
* it read the *tail* of a session (the sign-off, not the decisions), and when
|
|
8
|
+
* it failed it fell back to storing transcript.slice(-500) verbatim — raw
|
|
9
|
+
* tool_result JSON, cut mid-word — which then got injected into every
|
|
10
|
+
* subsequent SessionStart as a "memory".
|
|
11
|
+
*
|
|
12
|
+
* The approach here is extractive rather than generative: Claude already
|
|
13
|
+
* states what it did and why, in prose, inside the transcript. Mining those
|
|
14
|
+
* statements and ranking them produces better memories than a small model
|
|
15
|
+
* paraphrasing the last 8KB, costs nothing, adds no latency, and keeps the
|
|
16
|
+
* user's code on their own infrastructure.
|
|
17
|
+
*
|
|
18
|
+
* The other half of the design is that this returns `null` when it finds
|
|
19
|
+
* nothing worth keeping. A missing memory is strictly better than a poisoned
|
|
20
|
+
* one; there is no fallback that stores raw text.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.extractMemory = extractMemory;
|
|
24
|
+
// ── statement classification ─────────────────────────────────────────────────
|
|
25
|
+
/** Claude narrating what it is about to do. Never a memory. */
|
|
26
|
+
const NARRATION = /^(let me\b|i'?ll\b|i will\b|i'?m going to\b|now (?:let|i)\b|first,? (?:let|i)\b|reading\b|checking\b|looking\b|running\b|searching\b|scanning\b|here'?s\b|here are\b|okay\b|sure\b|got it\b|perfect\b|great\b)/i;
|
|
27
|
+
/** Structural scaffolding from markdown output. */
|
|
28
|
+
const SCAFFOLD = /^(\||[-=*_]{3,}|#{1,6}\s|\d+\.\s*$|```)/;
|
|
29
|
+
/** Claude stating a completed outcome. */
|
|
30
|
+
const OUTCOME = /^(done|fixed|added|created|removed|updated|refactored|migrated|implemented|configured|wired|built|set up|pushed|published|committed|installed|deployed|disabled|enabled|renamed|dropped|moved|merged|extracted|archived|cleaned|deleted|replaced|switched|reverted|bumped|patched)\b/i;
|
|
31
|
+
/** Explanation of why something is the way it is. */
|
|
32
|
+
const CAUSAL = /\b(root cause|because|the reason|turns? out|which is why|caused by|due to|the bug was|was failing because|the problem was)\b/i;
|
|
33
|
+
/** A concrete before→after change. */
|
|
34
|
+
const CHANGE = /\b(switched from .+ to |replaced .+ with |changed .+ (?:from .+ )?to |moved .+ (?:from .+ )?to |now uses?\b|instead of\b|no longer\b|renamed .+ to )/i;
|
|
35
|
+
/** A durable rule that will still be true next session. */
|
|
36
|
+
const CONSTRAINT = /\b(must (?:be|not|always|never)|cannot be|can'?t be|should (?:always|never)|always (?:use|run|keep)|never (?:use|run|commit)|requires? |only works? (?:if|when)|is required)\b/i;
|
|
37
|
+
/** Looks like it names something real in the codebase. */
|
|
38
|
+
const CODEISH = /(`[^`]+`|\b[\w.-]+\/[\w./-]+\b|\b\w+\.(?:ts|tsx|js|jsx|mjs|sql|json|css|py|go|rb|sh|ya?ml|toml|md)\b|\b[a-z][a-zA-Z0-9]*[A-Z]\w*\b|\b[A-Z_]{3,}\b|\b\w+\(\)|\$\d|\b\d+(?:\.\d+)+\b)/;
|
|
39
|
+
/** Numbers, versions, measurements — specificity signal. */
|
|
40
|
+
const QUANTIFIED = /\b\d+(?:\.\d+)?\s*(?:ms|s|kb|mb|gb|%|x|rows?|files?|px|dims?|tokens?)\b|\bv?\d+\.\d+/i;
|
|
41
|
+
const MIN_LEN = 25;
|
|
42
|
+
const MAX_LEN = 240;
|
|
43
|
+
function scoreStatement(line) {
|
|
44
|
+
const s = line.trim();
|
|
45
|
+
if (s.length < MIN_LEN || s.length > MAX_LEN)
|
|
46
|
+
return -Infinity;
|
|
47
|
+
if (s.endsWith("?"))
|
|
48
|
+
return -Infinity;
|
|
49
|
+
if (SCAFFOLD.test(s))
|
|
50
|
+
return -Infinity;
|
|
51
|
+
if (NARRATION.test(s))
|
|
52
|
+
return -Infinity;
|
|
53
|
+
let score = 0;
|
|
54
|
+
if (OUTCOME.test(s))
|
|
55
|
+
score += 3;
|
|
56
|
+
if (CAUSAL.test(s))
|
|
57
|
+
score += 3;
|
|
58
|
+
if (CHANGE.test(s))
|
|
59
|
+
score += 2;
|
|
60
|
+
if (CONSTRAINT.test(s))
|
|
61
|
+
score += 2;
|
|
62
|
+
if (CODEISH.test(s))
|
|
63
|
+
score += 2;
|
|
64
|
+
if (QUANTIFIED.test(s))
|
|
65
|
+
score += 1;
|
|
66
|
+
// A sentence that matched nothing above is prose about nothing in particular.
|
|
67
|
+
if (score === 0)
|
|
68
|
+
return -Infinity;
|
|
69
|
+
// Prefer statements that are specific without being a wall of text.
|
|
70
|
+
if (s.length > 160)
|
|
71
|
+
score -= 1;
|
|
72
|
+
return score;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Handles both shapes we receive: Claude Code's JSONL transcript, and a plain
|
|
76
|
+
* concatenated string (what the older hooks posted). Anything unparseable is
|
|
77
|
+
* treated as prose rather than discarded.
|
|
78
|
+
*/
|
|
79
|
+
function parseTranscript(transcript) {
|
|
80
|
+
const assistantText = [];
|
|
81
|
+
const toolCalls = [];
|
|
82
|
+
let sawJson = false;
|
|
83
|
+
for (const line of transcript.split("\n")) {
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
if (!trimmed)
|
|
86
|
+
continue;
|
|
87
|
+
if (trimmed.startsWith("{")) {
|
|
88
|
+
let row;
|
|
89
|
+
try {
|
|
90
|
+
row = JSON.parse(trimmed);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
sawJson = true;
|
|
96
|
+
const blocks = row?.message?.content;
|
|
97
|
+
if (row?.type !== "assistant" || !Array.isArray(blocks))
|
|
98
|
+
continue;
|
|
99
|
+
for (const b of blocks) {
|
|
100
|
+
if (b?.type === "text" && typeof b.text === "string") {
|
|
101
|
+
assistantText.push(b.text);
|
|
102
|
+
}
|
|
103
|
+
else if (b?.type === "tool_use" && typeof b.name === "string") {
|
|
104
|
+
const input = b.input || {};
|
|
105
|
+
const target = input.file_path || input.path || input.command || input.pattern || "";
|
|
106
|
+
toolCalls.push({ tool: b.name, target: String(target).slice(0, 300) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Plain-text transcript: keep it whole and let the scorer sort it out.
|
|
112
|
+
if (!sawJson)
|
|
113
|
+
assistantText.push(transcript);
|
|
114
|
+
return { assistantText, toolCalls };
|
|
115
|
+
}
|
|
116
|
+
// ── mining ───────────────────────────────────────────────────────────────────
|
|
117
|
+
/**
|
|
118
|
+
* Claude's markdown output is line-oriented, but a plain-text transcript is
|
|
119
|
+
* not — a paragraph arrives as one long line that would blow past MAX_LEN and
|
|
120
|
+
* be discarded whole. Split on newlines first, then on sentence boundaries for
|
|
121
|
+
* anything still too long to score.
|
|
122
|
+
*/
|
|
123
|
+
function splitStatements(text) {
|
|
124
|
+
const lines = text
|
|
125
|
+
.split("\n")
|
|
126
|
+
.map((l) => l
|
|
127
|
+
.replace(/^[-•*]\s+/, "")
|
|
128
|
+
.replace(/^\d+\.\s+/, "")
|
|
129
|
+
.replace(/[*`#>]/g, "")
|
|
130
|
+
.trim())
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
if (line.length <= MAX_LEN) {
|
|
135
|
+
out.push(line);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
for (const sentence of line.split(/(?<=[.!?])\s+/)) {
|
|
139
|
+
const trimmed = sentence.trim();
|
|
140
|
+
if (trimmed)
|
|
141
|
+
out.push(trimmed);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function normalizeKey(s) {
|
|
147
|
+
return s.toLowerCase().replace(/[^a-z0-9 ]/g, "").replace(/\s+/g, " ").trim();
|
|
148
|
+
}
|
|
149
|
+
/** Dependencies, commits and config changes are facts, not prose — read them
|
|
150
|
+
* off the tool calls rather than hoping Claude mentioned them. */
|
|
151
|
+
function factsFromTools(toolCalls) {
|
|
152
|
+
const facts = [];
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
const push = (f) => {
|
|
155
|
+
const k = normalizeKey(f);
|
|
156
|
+
if (!seen.has(k)) {
|
|
157
|
+
seen.add(k);
|
|
158
|
+
facts.push(f);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
for (const tc of toolCalls) {
|
|
162
|
+
if (tc.tool !== "Bash")
|
|
163
|
+
continue;
|
|
164
|
+
const cmd = tc.target;
|
|
165
|
+
const dep = cmd.match(/\b(?:npm|pnpm|yarn|bun)\s+(?:install|add)\s+(-{1,2}\S+\s+)*([^\s&|;]+)/);
|
|
166
|
+
if (dep?.[2] && !dep[2].startsWith("-"))
|
|
167
|
+
push(`dependency: added ${dep[2]}`);
|
|
168
|
+
const commit = cmd.match(/git commit[^"']*["']([^"']{6,120})["']/);
|
|
169
|
+
if (commit?.[1])
|
|
170
|
+
push(`commit: ${commit[1]}`);
|
|
171
|
+
if (/\b(psql|migrate|migration)\b/.test(cmd)) {
|
|
172
|
+
const mig = cmd.match(/(\d{3}_[\w-]+\.sql)/);
|
|
173
|
+
if (mig?.[1])
|
|
174
|
+
push(`migration: applied ${mig[1]}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const tc of toolCalls) {
|
|
178
|
+
if ((tc.tool === "Write" || tc.tool === "Edit") && /\.env/.test(tc.target)) {
|
|
179
|
+
push(`config: modified ${tc.target.split("/").pop()}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return facts;
|
|
183
|
+
}
|
|
184
|
+
function filesFromTools(toolCalls) {
|
|
185
|
+
const files = new Set();
|
|
186
|
+
for (const tc of toolCalls) {
|
|
187
|
+
if (!["Write", "Edit", "MultiEdit", "NotebookEdit", "Read"].includes(tc.tool))
|
|
188
|
+
continue;
|
|
189
|
+
const t = tc.target;
|
|
190
|
+
if (t && /\.\w{1,5}$/.test(t) && !t.includes(" "))
|
|
191
|
+
files.add(t);
|
|
192
|
+
}
|
|
193
|
+
return [...files].slice(0, 40);
|
|
194
|
+
}
|
|
195
|
+
// ── composition ──────────────────────────────────────────────────────────────
|
|
196
|
+
/**
|
|
197
|
+
* The indexed memory body. Built from the highest-scoring statements rather
|
|
198
|
+
* than a positional slice, so it reflects what the session concluded instead
|
|
199
|
+
* of where it happened to stop.
|
|
200
|
+
*/
|
|
201
|
+
function composeContent(ranked, files) {
|
|
202
|
+
const parts = [];
|
|
203
|
+
let budget = 420;
|
|
204
|
+
for (const s of ranked) {
|
|
205
|
+
const clean = s.endsWith(".") ? s : `${s}.`;
|
|
206
|
+
if (clean.length > budget)
|
|
207
|
+
break;
|
|
208
|
+
parts.push(clean);
|
|
209
|
+
budget -= clean.length + 1;
|
|
210
|
+
if (parts.length >= 3)
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (parts.length === 0)
|
|
214
|
+
return "";
|
|
215
|
+
const areas = summariseAreas(files);
|
|
216
|
+
return areas ? `${parts.join(" ")} (${areas})` : parts.join(" ");
|
|
217
|
+
}
|
|
218
|
+
function summariseAreas(files) {
|
|
219
|
+
const areas = new Set();
|
|
220
|
+
for (const f of files) {
|
|
221
|
+
if (/\/components?\//.test(f))
|
|
222
|
+
areas.add("components");
|
|
223
|
+
if (/\/routes?\//.test(f))
|
|
224
|
+
areas.add("API routes");
|
|
225
|
+
if (/\/hooks?\//.test(f))
|
|
226
|
+
areas.add("hooks");
|
|
227
|
+
if (/\/migrations?\//.test(f))
|
|
228
|
+
areas.add("migrations");
|
|
229
|
+
if (/\.(test|spec)\.|__tests__/.test(f))
|
|
230
|
+
areas.add("tests");
|
|
231
|
+
if (/\/lib\//.test(f))
|
|
232
|
+
areas.add("lib");
|
|
233
|
+
}
|
|
234
|
+
return [...areas].slice(0, 3).join(", ");
|
|
235
|
+
}
|
|
236
|
+
// ── entry point ──────────────────────────────────────────────────────────────
|
|
237
|
+
/**
|
|
238
|
+
* Returns null when the transcript yields nothing durable. Callers must treat
|
|
239
|
+
* null as "do not save" — there is deliberately no raw-text fallback.
|
|
240
|
+
*/
|
|
241
|
+
function extractMemory(transcript) {
|
|
242
|
+
if (!transcript || transcript.trim().length < 100)
|
|
243
|
+
return null;
|
|
244
|
+
const { assistantText, toolCalls } = parseTranscript(transcript);
|
|
245
|
+
if (assistantText.length === 0 && toolCalls.length === 0)
|
|
246
|
+
return null;
|
|
247
|
+
// Rank every candidate statement across the whole transcript. Deliberately
|
|
248
|
+
// not restricted to the tail: the diagnosis and the tradeoff are usually
|
|
249
|
+
// made in the middle of a session, and the tail is the sign-off.
|
|
250
|
+
const scored = [];
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
for (const text of assistantText) {
|
|
253
|
+
for (const stmt of splitStatements(text)) {
|
|
254
|
+
const score = scoreStatement(stmt);
|
|
255
|
+
if (score === -Infinity)
|
|
256
|
+
continue;
|
|
257
|
+
const key = normalizeKey(stmt);
|
|
258
|
+
if (seen.has(key))
|
|
259
|
+
continue;
|
|
260
|
+
seen.add(key);
|
|
261
|
+
scored.push({ text: stmt, score });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
scored.sort((a, b) => b.score - a.score);
|
|
265
|
+
const facts = factsFromTools(toolCalls);
|
|
266
|
+
const files = filesFromTools(toolCalls);
|
|
267
|
+
const ranked = scored.map((s) => s.text);
|
|
268
|
+
const content = composeContent(ranked, files);
|
|
269
|
+
// The bar for saving anything at all. Any one of these is durable evidence
|
|
270
|
+
// that something happened worth remembering:
|
|
271
|
+
// - a statement that scored on substance
|
|
272
|
+
// - a hard fact read off the tool calls (a dependency, a commit)
|
|
273
|
+
// - files actually written or edited
|
|
274
|
+
// A turn with none of the three produced nothing to remember.
|
|
275
|
+
const strong = scored.filter((s) => s.score >= 3);
|
|
276
|
+
const wroteFiles = toolCalls.some((t) => ["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(t.tool));
|
|
277
|
+
if (strong.length === 0 && facts.length === 0 && !wroteFiles)
|
|
278
|
+
return null;
|
|
279
|
+
const decisions = [...facts, ...ranked.slice(0, 12)].slice(0, 15);
|
|
280
|
+
// Files changed but nobody said anything quotable — record that factually
|
|
281
|
+
// rather than dropping the turn.
|
|
282
|
+
const body = content ||
|
|
283
|
+
(facts.length ? facts.slice(0, 3).join(" · ") : "") ||
|
|
284
|
+
(files.length ? `changed ${[...new Set(files.map((f) => f.split("/").pop()))].slice(0, 5).join(", ")}` : "");
|
|
285
|
+
if (!body)
|
|
286
|
+
return null;
|
|
287
|
+
const problems = scored
|
|
288
|
+
.filter((s) => CAUSAL.test(s.text))
|
|
289
|
+
.slice(0, 5)
|
|
290
|
+
.map((s) => s.text);
|
|
291
|
+
return {
|
|
292
|
+
content: body,
|
|
293
|
+
decisions,
|
|
294
|
+
files_touched: files,
|
|
295
|
+
problems_solved: problems,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.upsertProject = void 0;
|
|
4
|
+
exports.maintain = maintain;
|
|
5
|
+
/**
|
|
6
|
+
* Maintenance: supersede, tier, derive facts.
|
|
7
|
+
*
|
|
8
|
+
* On the hosted version this was a nightly cron. Locally there is nothing to
|
|
9
|
+
* schedule against, so it runs opportunistically — cheap enough to do on
|
|
10
|
+
* SessionStart when it has not run recently.
|
|
11
|
+
*/
|
|
12
|
+
const db_1 = require("../db");
|
|
13
|
+
Object.defineProperty(exports, "upsertProject", { enumerable: true, get: function () { return db_1.upsertProject; } });
|
|
14
|
+
const tiers_1 = require("./tiers");
|
|
15
|
+
const MIN_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
16
|
+
function toTierAtom(r) {
|
|
17
|
+
const parse = (s) => {
|
|
18
|
+
try {
|
|
19
|
+
const v = JSON.parse(s);
|
|
20
|
+
return Array.isArray(v) ? v : [];
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
id: String(r.id),
|
|
28
|
+
project_id: String(r.project_id),
|
|
29
|
+
prompt: r.prompt,
|
|
30
|
+
content: r.content,
|
|
31
|
+
decisions: parse(r.decisions),
|
|
32
|
+
files_touched: parse(r.files),
|
|
33
|
+
tool_calls: parse(r.tool_calls),
|
|
34
|
+
git_commit: r.git_commit,
|
|
35
|
+
started_at: r.started_at,
|
|
36
|
+
confidence: r.confidence,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function maintain(force = false) {
|
|
40
|
+
const last = Number((0, db_1.getMeta)("last_maintain") ?? 0);
|
|
41
|
+
if (!force && Date.now() - last < MIN_INTERVAL_MS)
|
|
42
|
+
return null;
|
|
43
|
+
const conn = (0, db_1.db)();
|
|
44
|
+
const rows = (0, db_1.liveMemories)().map(toTierAtom);
|
|
45
|
+
// Supersede first, then tier what survives, then derive tier 1 from the
|
|
46
|
+
// live set. Order matters: a superseded memory must not shape the facts.
|
|
47
|
+
const sup = (0, tiers_1.findSupersessions)(rows);
|
|
48
|
+
const supStmt = conn.prepare("UPDATE memories SET superseded_at = datetime('now'), superseded_by = ? WHERE id = ?");
|
|
49
|
+
conn.transaction(() => {
|
|
50
|
+
for (const s of sup)
|
|
51
|
+
supStmt.run(Number(s.by), Number(s.superseded));
|
|
52
|
+
})();
|
|
53
|
+
const supersededIds = new Set(sup.map((s) => s.superseded));
|
|
54
|
+
const live = rows.filter((r) => !supersededIds.has(r.id));
|
|
55
|
+
const tierStmt = conn.prepare("UPDATE memories SET tier = ? WHERE id = ?");
|
|
56
|
+
let tier2 = 0;
|
|
57
|
+
conn.transaction(() => {
|
|
58
|
+
for (const r of live) {
|
|
59
|
+
const t = (0, tiers_1.assignTier)(r);
|
|
60
|
+
if (t === 2)
|
|
61
|
+
tier2++;
|
|
62
|
+
tierStmt.run(t, Number(r.id));
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
const byProject = new Map();
|
|
66
|
+
for (const r of live) {
|
|
67
|
+
const list = byProject.get(r.project_id);
|
|
68
|
+
if (list)
|
|
69
|
+
list.push(r);
|
|
70
|
+
else
|
|
71
|
+
byProject.set(r.project_id, [r]);
|
|
72
|
+
}
|
|
73
|
+
let facts = 0;
|
|
74
|
+
for (const [projectId, atoms] of byProject) {
|
|
75
|
+
const derived = (0, tiers_1.deriveProjectFacts)(atoms);
|
|
76
|
+
(0, db_1.replaceFacts)(Number(projectId), derived);
|
|
77
|
+
facts += derived.length;
|
|
78
|
+
}
|
|
79
|
+
(0, db_1.setMeta)("last_maintain", String(Date.now()));
|
|
80
|
+
return { superseded: sup.length, tier2, facts };
|
|
81
|
+
}
|