pi-mega-compact 0.4.28 → 0.5.1
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 +47 -2
- package/dist/extensions/dashboard-server.js +66 -3
- package/dist/extensions/dashboard-server.test.js +95 -3
- package/dist/extensions/mega-commands.js +25 -9
- package/dist/extensions/mega-compact.test.js +133 -31
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-conflict-cmds.js +79 -0
- package/dist/extensions/mega-dashboard-cmds.js +6 -4
- package/dist/extensions/mega-events.js +144 -27
- package/dist/extensions/mega-pipeline.js +84 -1
- package/dist/extensions/mega-runtime.js +35 -2
- package/dist/extensions/mega-trim.js +48 -0
- package/dist/extensions/mega-trim.test.js +58 -0
- package/dist/src/config/dedup.js +1 -0
- package/dist/src/driftDetection.js +103 -0
- package/dist/src/driftDetection.test.js +87 -0
- package/dist/src/memory.js +147 -0
- package/dist/src/memory.test.js +41 -0
- package/dist/src/memoryConsolidate.test.js +38 -0
- package/dist/src/memoryOps.js +58 -0
- package/dist/src/memoryOps.test.js +41 -0
- package/dist/src/memoryRecall.js +60 -0
- package/dist/src/memoryRecall.test.js +92 -0
- package/dist/src/recall.js +70 -1
- package/dist/src/recall.test.js +69 -1
- package/dist/src/store/sqlite.js +127 -11
- package/dist/src/vectorStore.js +6 -1
- package/extensions/dashboard-server.test.ts +115 -3
- package/extensions/dashboard-server.ts +69 -4
- package/extensions/mega-commands.ts +24 -9
- package/extensions/mega-compact.test.ts +134 -31
- package/extensions/mega-config.ts +22 -0
- package/extensions/mega-conflict-cmds.ts +81 -0
- package/extensions/mega-dashboard-cmds.ts +6 -4
- package/extensions/mega-events.ts +139 -28
- package/extensions/mega-pipeline.ts +94 -1
- package/extensions/mega-runtime.ts +35 -2
- package/extensions/mega-trim.test.ts +64 -0
- package/extensions/mega-trim.ts +75 -0
- package/extensions/openclaw-mega-compact.ts +24 -9
- package/package.json +2 -2
- package/src/config/dedup.ts +2 -0
- package/src/driftDetection.test.ts +100 -0
- package/src/driftDetection.ts +136 -0
- package/src/memory.test.ts +46 -0
- package/src/memory.ts +164 -0
- package/src/memoryConsolidate.test.ts +47 -0
- package/src/memoryOps.test.ts +53 -0
- package/src/memoryOps.ts +75 -0
- package/src/memoryRecall.test.ts +100 -0
- package/src/memoryRecall.ts +83 -0
- package/src/recall.test.ts +77 -1
- package/src/recall.ts +94 -1
- package/src/store/sqlite.ts +188 -11
- package/src/store.ts +3 -0
- package/src/vectorStore.ts +10 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { reviewConversation } from "./memory.js";
|
|
4
|
+
|
|
5
|
+
test("reviewConversation: yields an ADD op for a stated decision", () => {
|
|
6
|
+
const msgs = [
|
|
7
|
+
{ role: "user", text: "we use node:sqlite as the store" },
|
|
8
|
+
{ role: "assistant", text: "got it, node:sqlite is the source of truth" },
|
|
9
|
+
] as any;
|
|
10
|
+
const ops = reviewConversation(msgs);
|
|
11
|
+
assert.ok(ops.some((o) => o.op === "add" && /sqlite|store/i.test(o.memory.content)), "adds a decision memory");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("reviewConversation: REPLACE when a later message contradicts an earlier one", () => {
|
|
15
|
+
const msgs = [
|
|
16
|
+
{ role: "user", text: "the threshold is 50k" },
|
|
17
|
+
{ role: "assistant", text: "ok 50k threshold" },
|
|
18
|
+
{ role: "user", text: "actually raise the threshold to 100k" },
|
|
19
|
+
] as any;
|
|
20
|
+
const ops = reviewConversation(msgs);
|
|
21
|
+
assert.ok(ops.some((o) => o.op === "replace"), "replaces the superseded value");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("reviewConversation: no ops on pure smalltalk (no durable fact)", () => {
|
|
25
|
+
const msgs = [{ role: "user", text: "hi" }, { role: "assistant", text: "hey" }] as any;
|
|
26
|
+
assert.equal(reviewConversation(msgs).length, 0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("reviewConversation: emits REMOVE when a user asks to drop an existing memory", () => {
|
|
30
|
+
const existing = [{ content: "we use node:sqlite as the store" }];
|
|
31
|
+
// Plain drop statement — no "switch to …" phrasing so we don't accidentally
|
|
32
|
+
// match DECISION_PATTERNS and route into the replace branch instead.
|
|
33
|
+
const msgs = [
|
|
34
|
+
{ role: "user", text: "stop using node:sqlite for the store — drop it from memory" },
|
|
35
|
+
{ role: "assistant", text: "ok dropped" },
|
|
36
|
+
] as any;
|
|
37
|
+
const ops = reviewConversation(msgs, existing);
|
|
38
|
+
assert.ok(ops.some((o) => o.op === "remove" && /sqlite/i.test(o.content)), "emits a remove op targeting the old memory");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("reviewConversation: REMOVE requires topic overlap (no accidental drop)", () => {
|
|
42
|
+
const existing = [{ content: "the timezone is America/Los_Angeles" }];
|
|
43
|
+
const msgs = [{ role: "user", text: "drop it" }] as any;
|
|
44
|
+
const ops = reviewConversation(msgs, existing);
|
|
45
|
+
assert.equal(ops.filter((o) => o.op === "remove").length, 0, "vague 'drop it' with no topic overlap does not remove anything");
|
|
46
|
+
});
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory.ts — auto-review + consolidation + recall-merge for the memories
|
|
3
|
+
* table (S20/S21). Local, hallucination-guarded. No LLM by default (extractive
|
|
4
|
+
* from the conversation); optional localhost Ollama mirroring RAPTOR. The review
|
|
5
|
+
* runs every N turns and emits add/replace/remove ops.
|
|
6
|
+
* PREVENT-PI-004: local only.
|
|
7
|
+
*/
|
|
8
|
+
import type { EngineMessage } from "./types.js";
|
|
9
|
+
import { collectRecentUserRequests } from "./compact.js";
|
|
10
|
+
import { cosineSimilarity, defaultEmbedder } from "./embedder.js";
|
|
11
|
+
import { DedupConfig } from "./config/dedup.js";
|
|
12
|
+
import { listMemories, removeMemory, replaceMemory } from "./store/sqlite.js";
|
|
13
|
+
import { getStateDir } from "./store.js";
|
|
14
|
+
export type MemoryOp =
|
|
15
|
+
| { op: "add"; memory: { content: string; category: string; target?: string; sourceTurn: number } }
|
|
16
|
+
| { op: "replace"; targetContent: string; memory: { content: string; category: string; sourceTurn: number } }
|
|
17
|
+
| { op: "remove"; content: string };
|
|
18
|
+
|
|
19
|
+
const DECISION_PATTERNS = [
|
|
20
|
+
/\bwe (?:use|chose|decided|will use|standardized on|go with)\b/i,
|
|
21
|
+
/\b(?:the|our) (?:threshold|policy|rule|convention|default) is\b/i,
|
|
22
|
+
/\bactually\b/i, /\braise (?:the )?|lower (?:the )?|switch (?:to )?\b/i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// Patterns that signal an explicit memory drop. Grounded only when the user
|
|
26
|
+
// references an existing memory's content (handled in reviewConversation).
|
|
27
|
+
const DROP_PATTERNS = [
|
|
28
|
+
/\b(?:stop using|don't use|dont use|drop(?:ping)?|forget|remove from memory|no longer)\b/i,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** Heuristic, extractive review. No LLM. Downgrades un-grounded claims to none. */
|
|
32
|
+
export function reviewConversation(messages: EngineMessage[], existing: { content: string }[] = []): MemoryOp[] {
|
|
33
|
+
const ops: MemoryOp[] = [];
|
|
34
|
+
const requests = collectRecentUserRequests(messages, 20);
|
|
35
|
+
for (let i = 0; i < requests.length; i++) {
|
|
36
|
+
const r = requests[i];
|
|
37
|
+
const isDecision = DECISION_PATTERNS.some((p) => p.test(r));
|
|
38
|
+
const isDrop = DROP_PATTERNS.some((p) => p.test(r));
|
|
39
|
+
// A "stop using/drop/forget" signal takes precedence over a decision — the
|
|
40
|
+
// user asking to forget a memory supersedes any "switch to" phrasing it
|
|
41
|
+
// happens to contain (which would otherwise route into REPLACE).
|
|
42
|
+
if (isDrop && existing.some((e) => sharesTopic(e.content, r))) {
|
|
43
|
+
const target = existing.find((e) => sharesTopic(e.content, r));
|
|
44
|
+
if (target) ops.push({ op: "remove", content: target.content });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (isDrop) {
|
|
48
|
+
// Drop pattern matched but no existing topic-overlapping memory — nothing
|
|
49
|
+
// to remove. Don't fall through to the decision branch (the request might
|
|
50
|
+
// also contain 'switch to' phrasing).
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!isDecision) continue;
|
|
54
|
+
// Treat earlier in-conversation decisions as "existing" too, so later messages
|
|
55
|
+
// that contradict them emit a REPLACE instead of an ADD.
|
|
56
|
+
const inConvoExisting = requests.slice(0, i).map((r) => ({ content: r }));
|
|
57
|
+
const contradicted = [...inConvoExisting, ...existing].find(
|
|
58
|
+
(e) => sharesTopic(e.content, r) && differs(e.content, r),
|
|
59
|
+
);
|
|
60
|
+
if (contradicted) {
|
|
61
|
+
ops.push({ op: "replace", targetContent: contradicted.content, memory: { content: r, category: "decision", sourceTurn: i } });
|
|
62
|
+
} else if (!existing.some((e) => nearDup(e.content, r))) {
|
|
63
|
+
ops.push({ op: "add", memory: { content: r, category: "decision", sourceTurn: i } });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Guardrail: drop any add/replace op whose memory content isn't grounded in a
|
|
67
|
+
// real message (hallucination prevention). REMOVE ops are exempt — their
|
|
68
|
+
// `content` is an EXISTING memory (matched by topic overlap), so it predates
|
|
69
|
+
// the current conversation and won't appear verbatim in any message.
|
|
70
|
+
return ops.filter((o) =>
|
|
71
|
+
o.op === "remove"
|
|
72
|
+
? true
|
|
73
|
+
: messages.some((m) => String(m.text ?? "").includes(o.memory.content)),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sharesTopic(a: string, b: string): boolean {
|
|
78
|
+
const aw = new Set(a.toLowerCase().split(/\W+/).filter((w) => w.length > 3));
|
|
79
|
+
const bw = new Set(b.toLowerCase().split(/\W+/).filter((w) => w.length > 3));
|
|
80
|
+
let shared = 0; for (const w of bw) if (aw.has(w)) shared++;
|
|
81
|
+
return shared >= 1;
|
|
82
|
+
}
|
|
83
|
+
function differs(a: string, b: string): boolean { return !nearDup(a, b); }
|
|
84
|
+
function nearDup(a: string, b: string): boolean {
|
|
85
|
+
const aw = new Set(a.toLowerCase().split(/\W+/));
|
|
86
|
+
const bw = new Set(b.toLowerCase().split(/\W+/));
|
|
87
|
+
let shared = 0; for (const w of bw) if (aw.has(w)) shared++;
|
|
88
|
+
return shared / Math.max(1, bw.size) >= 0.8;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* mergePhrases — for two near-duplicate texts (cosine >= threshold), build a
|
|
93
|
+
* merged content string. If the loser's token set is mostly contained in the
|
|
94
|
+
* survivor's, the survivor already covers the meaning and we keep it as-is.
|
|
95
|
+
* Otherwise we append the loser's text as a new paragraph so no phrasing is
|
|
96
|
+
* lost.
|
|
97
|
+
*/
|
|
98
|
+
function mergePhrases(survivor: string, loser: string): string {
|
|
99
|
+
if (nearDup(survivor, loser)) return survivor;
|
|
100
|
+
return `${survivor}\n\n${loser}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* consolidateMemories — merge near-duplicate rows in the `memories` table
|
|
105
|
+
* (Sprint 21, Task S21.2). Pure local cosine over `defaultEmbedder`
|
|
106
|
+
* embeddings (zero-net, deterministic, no LLM). Uses the consolidation
|
|
107
|
+
* threshold `DedupConfig.CONSOLIDATE_COSINE` (default 0.7) — lower than the
|
|
108
|
+
* off-line SemDeDup threshold (0.95) because drift between manually-typed
|
|
109
|
+
* memories about the same topic is expected, and the goal is to clean it up
|
|
110
|
+
* rather than be paranoid about over-merging. One row survives; redundant
|
|
111
|
+
* rows are removed. Returns the number of merges performed.
|
|
112
|
+
*
|
|
113
|
+
* Algorithm:
|
|
114
|
+
* 1. Load all memories for the repo (or all repos when repo is null).
|
|
115
|
+
* 2. Embed their `content` field.
|
|
116
|
+
* 3. For every pair, if cosine >= threshold AND same category → merge:
|
|
117
|
+
* survivor is the newest (largest id). Loser's content is appended as a
|
|
118
|
+
* paragraph to the survivor's content if it adds non-redundant phrasing,
|
|
119
|
+
* otherwise dropped. Loser's row is removed.
|
|
120
|
+
*
|
|
121
|
+
* PREVENT-PI-004: local-only embedding, no network.
|
|
122
|
+
*/
|
|
123
|
+
export async function consolidateMemories(
|
|
124
|
+
stateDir: string = getStateDir(),
|
|
125
|
+
repo: string | null = null,
|
|
126
|
+
threshold: number = DedupConfig.CONSOLIDATE_COSINE,
|
|
127
|
+
): Promise<number> {
|
|
128
|
+
const rows = listMemories(repo, 1000, stateDir);
|
|
129
|
+
if (rows.length < 2) return 0;
|
|
130
|
+
|
|
131
|
+
const emb = defaultEmbedder();
|
|
132
|
+
const vectors = rows.map((r) => emb.embed(r.content));
|
|
133
|
+
|
|
134
|
+
let merges = 0;
|
|
135
|
+
// Iterate once per row. Older rows are processed first; the merge keeps the
|
|
136
|
+
// newer (larger id), so we always merge away the older side.
|
|
137
|
+
for (let i = 0; i < rows.length; i++) {
|
|
138
|
+
if (rows[i].id == null) continue; // safety: in case the row was removed mid-loop
|
|
139
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
140
|
+
if (rows[j].id == null) continue;
|
|
141
|
+
if (rows[i].category !== rows[j].category) continue; // different buckets: not a dup
|
|
142
|
+
const sim = cosineSimilarity(vectors[i], vectors[j]);
|
|
143
|
+
if (sim < threshold) continue;
|
|
144
|
+
|
|
145
|
+
// Pick survivor: largest id (most recently inserted / referenced wins).
|
|
146
|
+
const survivorId = rows[i].id! > rows[j].id! ? rows[i].id! : rows[j].id!;
|
|
147
|
+
const loserId = survivorId === rows[i].id! ? rows[j].id! : rows[i].id!;
|
|
148
|
+
|
|
149
|
+
const survivor = survivorId === rows[i].id! ? rows[i] : rows[j];
|
|
150
|
+
const loser = survivorId === rows[i].id! ? rows[j] : rows[i];
|
|
151
|
+
|
|
152
|
+
// Merge content: keep survivor's content; if loser's content adds a
|
|
153
|
+
// phrase (token overlap < 80% with survivor) append it as a paragraph.
|
|
154
|
+
const mergedContent = mergePhrases(survivor.content, loser.content);
|
|
155
|
+
replaceMemory(survivorId, { content: mergedContent }, stateDir);
|
|
156
|
+
removeMemory(loserId, stateDir);
|
|
157
|
+
|
|
158
|
+
// Mark the loser row in the surviving array so we skip it on later pairs.
|
|
159
|
+
rows[loserId === rows[i].id! ? i : j] = { ...loser, id: undefined } as any;
|
|
160
|
+
merges++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return merges;
|
|
164
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { consolidateMemories } from "./memory.js";
|
|
7
|
+
import { addMemory, listMemories } from "./store/sqlite.js";
|
|
8
|
+
|
|
9
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-consolidate-"));
|
|
10
|
+
|
|
11
|
+
test("consolidateMemories: merges near-duplicate memories (one kept, one removed)", async () => {
|
|
12
|
+
const dir = join(baseTmp, "merge");
|
|
13
|
+
// Two near-identical memories — same topic, minor wording drift.
|
|
14
|
+
addMemory({ content: "we use node:sqlite as the store", category: "decision" }, null, dir);
|
|
15
|
+
addMemory({ content: "we use node:sqlite for our store", category: "decision" }, null, dir);
|
|
16
|
+
|
|
17
|
+
const merged = await consolidateMemories(dir);
|
|
18
|
+
assert.equal(merged, 1, "one merge was performed");
|
|
19
|
+
|
|
20
|
+
const rows = listMemories(null, 50, dir);
|
|
21
|
+
// Exactly one of the two near-dupes survives.
|
|
22
|
+
assert.equal(rows.length, 1, "exactly one memory remains after merge");
|
|
23
|
+
assert.match(rows[0].content, /node:sqlite.*store/i, "survivor mentions node:sqlite + store");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("consolidateMemories: leaves unrelated memories alone", async () => {
|
|
27
|
+
const dir = join(baseTmp, "noop");
|
|
28
|
+
addMemory({ content: "we use node:sqlite as the store", category: "decision" }, null, dir);
|
|
29
|
+
addMemory({ content: "the gpt-5 release is on hold pending benchmark results", category: "note" }, null, dir);
|
|
30
|
+
|
|
31
|
+
const merged = await consolidateMemories(dir);
|
|
32
|
+
assert.equal(merged, 0, "no merges on unrelated memories");
|
|
33
|
+
|
|
34
|
+
const rows = listMemories(null, 50, dir);
|
|
35
|
+
assert.equal(rows.length, 2, "both memories survive");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("consolidateMemories: empty store returns 0 and changes nothing", async () => {
|
|
39
|
+
const dir = join(baseTmp, "empty");
|
|
40
|
+
const merged = await consolidateMemories(dir);
|
|
41
|
+
assert.equal(merged, 0, "no merges on empty store");
|
|
42
|
+
assert.equal(listMemories(null, 50, dir).length, 0, "store still empty");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("cleanup memops", () => {
|
|
46
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
47
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { applyMemoryOps } from "./memoryOps.js";
|
|
7
|
+
import { addMemory, listMemories } from "./store/sqlite.js";
|
|
8
|
+
|
|
9
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
|
|
10
|
+
|
|
11
|
+
test("applyMemoryOps: ADD inserts a new memory", async () => {
|
|
12
|
+
const dir = join(baseTmp, "add");
|
|
13
|
+
await applyMemoryOps(
|
|
14
|
+
[{ op: "add", memory: { content: "we use node:sqlite as the store", category: "decision", sourceTurn: 0 } }],
|
|
15
|
+
dir,
|
|
16
|
+
);
|
|
17
|
+
const rows = listMemories(null, 50, dir);
|
|
18
|
+
assert.ok(rows.some((m) => /node:sqlite/.test(m.content)), "added memory present");
|
|
19
|
+
assert.equal(rows[0].category, "decision", "category persisted");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("applyMemoryOps: ADD is idempotent (no duplicate)", async () => {
|
|
23
|
+
const dir = join(baseTmp, "dup");
|
|
24
|
+
const op = { op: "add" as const, memory: { content: "threshold is 50k", category: "decision", sourceTurn: 0 } };
|
|
25
|
+
await applyMemoryOps([op], dir);
|
|
26
|
+
await applyMemoryOps([op], dir);
|
|
27
|
+
const rows = listMemories(null, 50, dir);
|
|
28
|
+
assert.equal(rows.filter((m) => /threshold is 50k/.test(m.content)).length, 1, "no duplicate");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("applyMemoryOps: REPLACE updates the matching memory", async () => {
|
|
32
|
+
const dir = join(baseTmp, "replace");
|
|
33
|
+
addMemory({ content: "the threshold is 50k", category: "decision" }, null, dir);
|
|
34
|
+
await applyMemoryOps(
|
|
35
|
+
[{ op: "replace", targetContent: "the threshold is 50k", memory: { content: "the threshold is 100k", category: "decision", sourceTurn: 2 } }],
|
|
36
|
+
dir,
|
|
37
|
+
);
|
|
38
|
+
const rows = listMemories(null, 50, dir);
|
|
39
|
+
assert.ok(rows.some((m) => /100k/.test(m.content)), "replaced content present");
|
|
40
|
+
assert.ok(!rows.some((m) => /50k/.test(m.content)), "old content gone");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("applyMemoryOps: REMOVE deletes the matching memory", async () => {
|
|
44
|
+
const dir = join(baseTmp, "remove");
|
|
45
|
+
addMemory({ content: "obsolete note", category: "note" }, null, dir);
|
|
46
|
+
await applyMemoryOps([{ op: "remove", content: "obsolete note" }], dir);
|
|
47
|
+
const rows = listMemories(null, 50, dir);
|
|
48
|
+
assert.ok(!rows.some((m) => /obsolete note/.test(m.content)), "removed");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("cleanup memops", () => {
|
|
52
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
53
|
+
});
|
package/src/memoryOps.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memoryOps.ts — apply reviewConversation() MemoryOp results to the durable
|
|
3
|
+
* memories store (S20.3). Thin layer over the SQLite memories helpers in
|
|
4
|
+
* src/store/sqlite.ts; no raw SQL here. Local only (PREVENT-PI-004).
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import type { MemoryOp } from "./memory.js";
|
|
8
|
+
import {
|
|
9
|
+
addMemory,
|
|
10
|
+
listMemories,
|
|
11
|
+
replaceMemory,
|
|
12
|
+
removeMemory,
|
|
13
|
+
type MemoryRecord,
|
|
14
|
+
} from "./store/sqlite.js";
|
|
15
|
+
|
|
16
|
+
/** Find a memory row whose content exactly matches (case-insensitive). */
|
|
17
|
+
function findByContent(memories: MemoryRecord[], content: string): MemoryRecord | undefined {
|
|
18
|
+
const norm = content.trim().toLowerCase();
|
|
19
|
+
return memories.find((m) => m.content.trim().toLowerCase() === norm);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Apply add/replace/remove ops to the memories table. Replaces are matched by
|
|
24
|
+
* existing content; removes by content. Idempotent: an add that already exists
|
|
25
|
+
* is a no-op, a replace of a missing memory degrades to an add.
|
|
26
|
+
*/
|
|
27
|
+
export async function applyMemoryOps(ops: MemoryOp[], stateDir: string): Promise<void> {
|
|
28
|
+
if (!ops.length) return;
|
|
29
|
+
const repo = null; // memories are repo-scoped by stateDir, not by the repo arg here
|
|
30
|
+
const existing = listMemories(repo, 1000, stateDir);
|
|
31
|
+
for (const op of ops) {
|
|
32
|
+
if (op.op === "add") {
|
|
33
|
+
// Skip if an identical memory already exists.
|
|
34
|
+
if (findByContent(existing, op.memory.content)) continue;
|
|
35
|
+
addMemory(
|
|
36
|
+
{
|
|
37
|
+
kind: op.memory.category,
|
|
38
|
+
content: op.memory.content,
|
|
39
|
+
tags: [],
|
|
40
|
+
category: op.memory.category,
|
|
41
|
+
target: op.memory.target,
|
|
42
|
+
sourceTurn: op.memory.sourceTurn,
|
|
43
|
+
},
|
|
44
|
+
repo,
|
|
45
|
+
stateDir,
|
|
46
|
+
);
|
|
47
|
+
} else if (op.op === "replace") {
|
|
48
|
+
const match = findByContent(existing, op.targetContent);
|
|
49
|
+
if (match) {
|
|
50
|
+
replaceMemory(match.id, {
|
|
51
|
+
kind: op.memory.category,
|
|
52
|
+
content: op.memory.content,
|
|
53
|
+
category: op.memory.category,
|
|
54
|
+
sourceTurn: op.memory.sourceTurn,
|
|
55
|
+
}, stateDir);
|
|
56
|
+
} else {
|
|
57
|
+
// Target missing (e.g. earlier in-conversation contradiction) → add.
|
|
58
|
+
addMemory(
|
|
59
|
+
{
|
|
60
|
+
kind: op.memory.category,
|
|
61
|
+
content: op.memory.content,
|
|
62
|
+
tags: [],
|
|
63
|
+
category: op.memory.category,
|
|
64
|
+
sourceTurn: op.memory.sourceTurn,
|
|
65
|
+
},
|
|
66
|
+
repo,
|
|
67
|
+
stateDir,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
const match = findByContent(existing, op.content);
|
|
72
|
+
if (match) removeMemory(match.id, stateDir);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { recallMemories } from "./memoryRecall.js";
|
|
7
|
+
import { addMemory, getMemory } from "./store/sqlite.js";
|
|
8
|
+
|
|
9
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memrec-"));
|
|
10
|
+
|
|
11
|
+
function biGramEmbedder() {
|
|
12
|
+
// Deterministic test embedder — bigger dim + binary encoding so two semantically
|
|
13
|
+
// related strings have higher cosine than unrelated ones.
|
|
14
|
+
const dim = 64;
|
|
15
|
+
return {
|
|
16
|
+
dim,
|
|
17
|
+
embed(text: string): number[] {
|
|
18
|
+
const v = new Array(dim).fill(0);
|
|
19
|
+
const norm = text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
|
|
20
|
+
for (let i = 0; i < norm.length - 1; i++) {
|
|
21
|
+
const idx = ((norm.charCodeAt(i) * 31 + norm.charCodeAt(i + 1)) >>> 0) % dim;
|
|
22
|
+
v[idx] = 1;
|
|
23
|
+
}
|
|
24
|
+
return v;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("recallMemories: ranks relevant memory above unrelated", async () => {
|
|
30
|
+
const dir = join(baseTmp, "rank");
|
|
31
|
+
addMemory({ content: "we use sqlite for the durable store", category: "decision" }, null, dir);
|
|
32
|
+
addMemory({ content: "the threshold is 100 thousand tokens", category: "decision" }, null, dir);
|
|
33
|
+
addMemory({ content: "katz says hi", category: "note" }, null, dir);
|
|
34
|
+
const hits = await recallMemories("what store do we use?", dir, {
|
|
35
|
+
embedder: biGramEmbedder(),
|
|
36
|
+
topK: 3,
|
|
37
|
+
minSimilarity: 0.0,
|
|
38
|
+
});
|
|
39
|
+
assert.ok(hits.length >= 2, "finds relevant");
|
|
40
|
+
assert.ok(/sqlite/.test(hits[0].memory.content), "top hit is sqlite one");
|
|
41
|
+
assert.ok(!/katz/.test(hits[0].memory.content), "unrelated not on top");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("recallMemories: marks referenced hits (last_referenced updated)", async () => {
|
|
45
|
+
const dir = join(baseTmp, "ref");
|
|
46
|
+
addMemory({ content: "policy is local-only", category: "rule" }, null, dir);
|
|
47
|
+
const hits = await recallMemories("local-only policy", dir, { embedder: biGramEmbedder() });
|
|
48
|
+
assert.ok(hits.length >= 1);
|
|
49
|
+
const fresh = getMemory(hits[0].memory.id, dir);
|
|
50
|
+
assert.ok(fresh && fresh.lastReferenced && fresh.lastReferenced > 0, "lastReferenced set");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("recallMemories: empty store returns []", async () => {
|
|
54
|
+
const dir = join(baseTmp, "empty");
|
|
55
|
+
const hits = await recallMemories("anything", dir, { embedder: biGramEmbedder() });
|
|
56
|
+
assert.deepEqual(hits, []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("recallMemories: decision category beats fact category at equal similarity", async () => {
|
|
60
|
+
const dir = join(baseTmp, "category");
|
|
61
|
+
// Two memories that share many bigrams with the query — both will have very
|
|
62
|
+
// similar cosine. The decision category should win because of categoryWeight.
|
|
63
|
+
addMemory({ content: "we use redis for cache" }, null, dir);
|
|
64
|
+
const aId = addMemory({ content: "we use redis as primary cache key", category: "fact" }, null, dir);
|
|
65
|
+
const dId = addMemory({ content: "we use redis as primary cache layer", category: "decision" }, null, dir);
|
|
66
|
+
const hits = await recallMemories("redis cache layer", dir, {
|
|
67
|
+
embedder: biGramEmbedder(),
|
|
68
|
+
topK: 3,
|
|
69
|
+
minSimilarity: 0.0,
|
|
70
|
+
});
|
|
71
|
+
assert.ok(hits.length >= 2);
|
|
72
|
+
assert.equal(hits[0].memory.id, dId, "decision-tagged memory outranks fact-tagged");
|
|
73
|
+
assert.notEqual(hits[0].memory.id, aId, "top is the decision row, not the unknown-category row");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("recallMemories: fresher reference beats older at equal similarity", async () => {
|
|
77
|
+
const dir = join(baseTmp, "recency");
|
|
78
|
+
const aId = addMemory({ content: "we use redis for cache", category: "fact" }, null, dir);
|
|
79
|
+
const bId = addMemory({ content: "we use redis for cache layer", category: "fact" }, null, dir);
|
|
80
|
+
// Backdate aId so its last_referenced is older; bId stays fresh.
|
|
81
|
+
const { openStore, closeStore } = await import("./store/sqlite.js");
|
|
82
|
+
const db = openStore(dir);
|
|
83
|
+
const longAgo = Math.floor(Date.now() / 1000) - 30 * 86_400; // 30d
|
|
84
|
+
db.prepare("UPDATE memories SET last_referenced = ? WHERE id = ?").run(longAgo, aId);
|
|
85
|
+
// closeStore evicts the cached handle — calling db.close() directly would
|
|
86
|
+
// leave a stale closed DB in the openStore cache and break subsequent
|
|
87
|
+
// callers (the "database is not open" failure under parallel runs).
|
|
88
|
+
closeStore(dir);
|
|
89
|
+
const hits = await recallMemories("redis cache layer", dir, {
|
|
90
|
+
embedder: biGramEmbedder(),
|
|
91
|
+
topK: 3,
|
|
92
|
+
minSimilarity: 0.0,
|
|
93
|
+
});
|
|
94
|
+
assert.ok(hits.length >= 2);
|
|
95
|
+
assert.equal(hits[0].memory.id, bId, "freshly-referenced memory outranks 30-day-old one");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("cleanup memrec", () => {
|
|
99
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
100
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memoryRecall.ts — semantic recall over the durable memories table (S21).
|
|
3
|
+
* Embeds a query with the same local embedder used by RAPTOR, ranks every
|
|
4
|
+
* memory in the current repo's SQLite by cosine similarity combined with a
|
|
5
|
+
* category-weighted + recency-boosted score, and returns the top-k. Side
|
|
6
|
+
* effect: marks returned memories as referenced (last_referenced) so drift
|
|
7
|
+
* can be measured. PREVENT-PI-004 — embedder is the same local one used
|
|
8
|
+
* everywhere else; no remote calls are introduced here.
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
|
|
12
|
+
import { listMemories, referenceMemory, type MemoryRecord } from "./store/sqlite.js";
|
|
13
|
+
|
|
14
|
+
export interface RecallMemoriesOptions {
|
|
15
|
+
/** Max memories to return. Default 10. */
|
|
16
|
+
topK?: number;
|
|
17
|
+
/** Min cosine similarity (0..1) to include. Default 0.2 — filters unrelated. */
|
|
18
|
+
minSimilarity?: number;
|
|
19
|
+
/** If true, mark returned memories as referenced. Default true. */
|
|
20
|
+
markReferenced?: boolean;
|
|
21
|
+
/** Override the embedder (test-only seam). */
|
|
22
|
+
embedder?: { embed(text: string): number[] };
|
|
23
|
+
/** Repo filter; null = current repo's memories. */
|
|
24
|
+
repo?: string | null;
|
|
25
|
+
/** Per-category ranking weights. Categories not listed default to 1.0. */
|
|
26
|
+
categoryWeights?: Record<string, number>;
|
|
27
|
+
/** Recency boost strength. Default 0.05 (5% bonus per log-day since reference). */
|
|
28
|
+
recencyWeight?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Default category weights — `decision` wins ties on tied cosine. */
|
|
32
|
+
export const DEFAULT_CATEGORY_WEIGHTS: Record<string, number> = {
|
|
33
|
+
decision: 1.10,
|
|
34
|
+
preference: 1.05,
|
|
35
|
+
fact: 1.0,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const DEFAULT_RECENCY_WEIGHT = 0.05;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rank memories by a blended score of cosine similarity, category weight, and
|
|
42
|
+
* recency of last_referenced (with createdAt as a fallback). Returns the
|
|
43
|
+
* top-k above minSimilarity, sorted by blended score descending. Empty on
|
|
44
|
+
* no matches.
|
|
45
|
+
*/
|
|
46
|
+
export async function recallMemories(
|
|
47
|
+
query: string,
|
|
48
|
+
stateDir: string,
|
|
49
|
+
opts: RecallMemoriesOptions = {},
|
|
50
|
+
): Promise<Array<{ memory: MemoryRecord; score: number }>> {
|
|
51
|
+
const topK = opts.topK ?? 10;
|
|
52
|
+
const minSimilarity = opts.minSimilarity ?? 0.2;
|
|
53
|
+
const markReferenced = opts.markReferenced ?? true;
|
|
54
|
+
const embedder = opts.embedder ?? defaultEmbedder();
|
|
55
|
+
const repo = opts.repo === undefined ? null : opts.repo;
|
|
56
|
+
const categoryWeights = { ...DEFAULT_CATEGORY_WEIGHTS, ...(opts.categoryWeights ?? {}) };
|
|
57
|
+
const recencyWeight = opts.recencyWeight ?? DEFAULT_RECENCY_WEIGHT;
|
|
58
|
+
|
|
59
|
+
const queryVec = embedder.embed(query);
|
|
60
|
+
const memories = listMemories(repo, 1000, stateDir);
|
|
61
|
+
if (!memories.length || !query.trim()) return [];
|
|
62
|
+
|
|
63
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
64
|
+
const scored: Array<{ memory: MemoryRecord; score: number }> = [];
|
|
65
|
+
for (const mem of memories) {
|
|
66
|
+
const vec = embedder.embed(mem.content);
|
|
67
|
+
const sim = cosineSimilarity(queryVec, vec);
|
|
68
|
+
if (sim < minSimilarity) continue;
|
|
69
|
+
const categoryW = categoryWeights[mem.category ?? "fact"] ?? 1.0;
|
|
70
|
+
const lastTouch = mem.lastReferenced ?? mem.createdAt ?? nowSec;
|
|
71
|
+
// log(1 + daysSince) grows slowly — half-life-style decay.
|
|
72
|
+
const daysSince = Math.max(0, (nowSec - lastTouch) / 86_400);
|
|
73
|
+
const recencyBoost = Math.log1p(daysSince) * recencyWeight;
|
|
74
|
+
const finalScore = sim * categoryW * (1 + recencyBoost);
|
|
75
|
+
scored.push({ memory: mem, score: finalScore });
|
|
76
|
+
}
|
|
77
|
+
scored.sort((a, b) => b.score - a.score);
|
|
78
|
+
const top = scored.slice(0, topK);
|
|
79
|
+
if (markReferenced) {
|
|
80
|
+
for (const hit of top) referenceMemory(hit.memory.id, stateDir);
|
|
81
|
+
}
|
|
82
|
+
return top;
|
|
83
|
+
}
|
package/src/recall.test.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { VectorStore } from "./vectorStore.js";
|
|
7
7
|
import { compactSession } from "./engine.js";
|
|
8
|
-
import { recallAndInline, formatRecallBlock } from "./recall.js";
|
|
8
|
+
import { recallAndInline, recallAndInlineAsync, formatRecallBlock } from "./recall.js";
|
|
9
|
+
import { markInjectedGlobal, wasInjectedGlobal, closeIndexStore } from "./store/sqlite.js";
|
|
9
10
|
import type { EngineMessage } from "./types.js";
|
|
10
11
|
|
|
11
12
|
const baseTmp = mkdtempSync(join(tmpdir(), "mc-recall-"));
|
|
@@ -47,6 +48,26 @@ test("formatRecallBlock is empty for no hits", () => {
|
|
|
47
48
|
assert.equal(formatRecallBlock([]), "");
|
|
48
49
|
});
|
|
49
50
|
|
|
51
|
+
test("formatRecallBlock (S17): labels a cross-repo hit with its source repo", () => {
|
|
52
|
+
const hit = {
|
|
53
|
+
checkpoint: { checkpointId: "chkpt_x", summary: "did thing Y", filesModified: ["a.ts"] },
|
|
54
|
+
score: 0.91,
|
|
55
|
+
repoId: "/home/u/rad-gateway",
|
|
56
|
+
} as any;
|
|
57
|
+
const block = formatRecallBlock([hit]);
|
|
58
|
+
assert.ok(block.includes("from repo"), "labels cross-repo source");
|
|
59
|
+
assert.ok(block.includes("rad-gateway"), "includes the repo display name");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("formatRecallBlock (S17): omits the label for same-repo hits (no repoId)", () => {
|
|
63
|
+
const hit = {
|
|
64
|
+
checkpoint: { checkpointId: "c1", summary: "s", filesModified: [] },
|
|
65
|
+
score: 0.9,
|
|
66
|
+
} as any;
|
|
67
|
+
const block = formatRecallBlock([hit]);
|
|
68
|
+
assert.ok(!block.includes("from repo"), "no source label for same-repo hits");
|
|
69
|
+
});
|
|
70
|
+
|
|
50
71
|
test("recallAndInline empty when store has nothing for query", () => {
|
|
51
72
|
const s = store();
|
|
52
73
|
const r = recallAndInline({ sessionId: SESS, query: "no such topic exists here", limit: 5, source: "command" }, s as any);
|
|
@@ -98,6 +119,61 @@ test("Fix C: inline dedupe drops a hit already resident in the live window", ()
|
|
|
98
119
|
);
|
|
99
120
|
});
|
|
100
121
|
|
|
122
|
+
test("S18: global injected-set skips a foreign checkpoint already injected machine-wide", async () => {
|
|
123
|
+
const indexDir = mkdtempSync(join(tmpdir(), "mc-gi-"));
|
|
124
|
+
try {
|
|
125
|
+
const sess = "sess_cross";
|
|
126
|
+
// A foreign checkpoint already marked injected globally (in this session).
|
|
127
|
+
markInjectedGlobal("chkpt_foreign", "/repo/other", sess, indexDir);
|
|
128
|
+
assert.equal(wasInjectedGlobal("chkpt_foreign", sess, indexDir), true);
|
|
129
|
+
// searchAsync returns the foreign hit; recallAndInlineAsync must skip it
|
|
130
|
+
// (globally injected) → toInject is empty.
|
|
131
|
+
const mockStore = {
|
|
132
|
+
searchAsync: async () => [{
|
|
133
|
+
checkpoint: { checkpointId: "chkpt_foreign", summary: "foreign work", filesModified: [], dedupStatus: "active" },
|
|
134
|
+
score: 0.92,
|
|
135
|
+
repoId: "/repo/other",
|
|
136
|
+
}],
|
|
137
|
+
wasInjected: () => false,
|
|
138
|
+
markInjected: () => {},
|
|
139
|
+
} as any;
|
|
140
|
+
const r = await recallAndInlineAsync(
|
|
141
|
+
{ sessionId: sess, query: "foreign", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir },
|
|
142
|
+
mockStore,
|
|
143
|
+
);
|
|
144
|
+
assert.equal(r.toInject.length, 0, "globally-injected foreign checkpoint skipped");
|
|
145
|
+
} finally {
|
|
146
|
+
closeIndexStore();
|
|
147
|
+
rmSync(indexDir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("S18: a fresh foreign checkpoint is injected AND recorded globally", async () => {
|
|
152
|
+
const indexDir = mkdtempSync(join(tmpdir(), "mc-gi2-"));
|
|
153
|
+
try {
|
|
154
|
+
const sess = "sess_fresh";
|
|
155
|
+
assert.equal(wasInjectedGlobal("chkpt_new", sess, indexDir), false);
|
|
156
|
+
const mockStore = {
|
|
157
|
+
searchAsync: async () => [{
|
|
158
|
+
checkpoint: { checkpointId: "chkpt_new", summary: "brand new foreign work", filesModified: [], dedupStatus: "active" },
|
|
159
|
+
score: 0.93,
|
|
160
|
+
repoId: "/repo/alpha",
|
|
161
|
+
}],
|
|
162
|
+
wasInjected: () => false,
|
|
163
|
+
markInjected: () => {},
|
|
164
|
+
} as any;
|
|
165
|
+
const r = await recallAndInlineAsync(
|
|
166
|
+
{ sessionId: sess, query: "foreign", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir },
|
|
167
|
+
mockStore,
|
|
168
|
+
);
|
|
169
|
+
assert.equal(r.toInject.length, 1, "fresh foreign checkpoint injected");
|
|
170
|
+
assert.equal(wasInjectedGlobal("chkpt_new", sess, indexDir), true, "recorded machine-wide");
|
|
171
|
+
} finally {
|
|
172
|
+
closeIndexStore();
|
|
173
|
+
rmSync(indexDir, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
101
177
|
test("cleanup", () => {
|
|
102
178
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
103
179
|
});
|