pi-mega-compact 0.4.28 → 0.5.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 +47 -2
- package/dist/extensions/dashboard-server.js +58 -2
- 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 +14 -0
- 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 +63 -2
- 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 +15 -0
- 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
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* driftDetection.ts — R4: cross-repo drift detection over the machine-wide
|
|
3
|
+
* repo_registry (index.sqlite). Reads the registry, classifies each repo
|
|
4
|
+
* against simple drift signals, and returns a structured report that the
|
|
5
|
+
* dashboard's Multi-repo tab and the /api/drift endpoint can render.
|
|
6
|
+
*
|
|
7
|
+
* Signals (all derived from repo_registry alone — no checkpoint scans):
|
|
8
|
+
* - stale: last_seen older than STALE_DAYS (default 30). Repo is up but
|
|
9
|
+
* hasn't touched the dashboard in a while — usually parked work.
|
|
10
|
+
* - compaction_lag: last_seen within ACTIVE_DAYS (default 7) but
|
|
11
|
+
* last_compacted_at is null or > 24h behind. The repo is actively running
|
|
12
|
+
* work but compaction isn't keeping pace — usually a config regression.
|
|
13
|
+
* - model_churn: model_captured_at within MODEL_CHURN_DAYS (default 7) —
|
|
14
|
+
* the active model changed recently. Could be a routine upgrade or a
|
|
15
|
+
* silent fallback; both worth flagging.
|
|
16
|
+
*
|
|
17
|
+
* Scope: read-only by design. No writes — drift reporting should never mutate
|
|
18
|
+
* the registry. Severity classification is conservative: warnings, not alarms.
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { DatabaseSync } from "node:sqlite";
|
|
24
|
+
import { getIndexDir } from "./store/sqlite.js";
|
|
25
|
+
const DAY_SEC = 86_400;
|
|
26
|
+
const STALE_DAYS = 30;
|
|
27
|
+
const ACTIVE_DAYS = 7;
|
|
28
|
+
const MODEL_CHURN_DAYS = 7;
|
|
29
|
+
/** Compaction lag threshold: last_seen newer than this AND last_compacted_at
|
|
30
|
+
* more than this far behind. 24h is generous — compactions usually fire in
|
|
31
|
+
* minutes; >24h usually means something is wedged. */
|
|
32
|
+
const COMPACTION_LAG_SEC = 24 * 3600;
|
|
33
|
+
/** Read all repos from the machine-wide registry, classify drift, return report. */
|
|
34
|
+
export function detectCrossRepoDrift(indexDir = getIndexDir()) {
|
|
35
|
+
const generatedAt = Math.floor(Date.now() / 1000);
|
|
36
|
+
const indexPath = join(indexDir, "index.sqlite");
|
|
37
|
+
const totals = { ok: 0, warn: 0, stale: 0, compactionLag: 0, modelChurn: 0 };
|
|
38
|
+
if (!existsSync(indexPath))
|
|
39
|
+
return { generatedAt, totals, repos: [] };
|
|
40
|
+
let db;
|
|
41
|
+
try {
|
|
42
|
+
db = new DatabaseSync(indexPath, { readOnly: true });
|
|
43
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
44
|
+
const rows = db
|
|
45
|
+
.prepare(`SELECT repo_root, display_name, last_seen, last_compacted_at,
|
|
46
|
+
model_name, provider, model_captured_at
|
|
47
|
+
FROM repo_registry`)
|
|
48
|
+
.all();
|
|
49
|
+
const repos = [];
|
|
50
|
+
for (const r of rows) {
|
|
51
|
+
const lastSeen = r.last_seen ?? 0;
|
|
52
|
+
const lastCompacted = r.last_compacted_at ?? null;
|
|
53
|
+
const modelCaptured = r.model_captured_at ?? null;
|
|
54
|
+
const signals = [];
|
|
55
|
+
if (lastSeen > 0 && generatedAt - lastSeen > STALE_DAYS * DAY_SEC) {
|
|
56
|
+
const daysAgo = Math.floor((generatedAt - lastSeen) / DAY_SEC);
|
|
57
|
+
signals.push({ kind: "stale", severity: "info", detail: `last activity ${daysAgo}d ago` });
|
|
58
|
+
totals.stale++;
|
|
59
|
+
}
|
|
60
|
+
if (lastSeen > 0 &&
|
|
61
|
+
generatedAt - lastSeen <= ACTIVE_DAYS * DAY_SEC &&
|
|
62
|
+
(lastCompacted === null || generatedAt - lastCompacted > COMPACTION_LAG_SEC)) {
|
|
63
|
+
const lagSec = lastCompacted ? generatedAt - lastCompacted : generatedAt - lastSeen;
|
|
64
|
+
const lagH = Math.floor(lagSec / 3600);
|
|
65
|
+
signals.push({
|
|
66
|
+
kind: "compaction_lag",
|
|
67
|
+
severity: "warn",
|
|
68
|
+
detail: lastCompacted ? `${lagH}h behind last activity` : "never compacted",
|
|
69
|
+
});
|
|
70
|
+
totals.compactionLag++;
|
|
71
|
+
}
|
|
72
|
+
if (modelCaptured && generatedAt - modelCaptured <= MODEL_CHURN_DAYS * DAY_SEC) {
|
|
73
|
+
const label = [r.provider, r.model_name].filter(Boolean).join("/") || "model";
|
|
74
|
+
signals.push({ kind: "model_churn", severity: "info", detail: `${label} captured recently` });
|
|
75
|
+
totals.modelChurn++;
|
|
76
|
+
}
|
|
77
|
+
const status = signals.some((s) => s.severity === "warn") ? "warn" : "ok";
|
|
78
|
+
if (status === "warn")
|
|
79
|
+
totals.warn++;
|
|
80
|
+
else
|
|
81
|
+
totals.ok++;
|
|
82
|
+
repos.push({
|
|
83
|
+
repoRoot: r.repo_root,
|
|
84
|
+
displayName: r.display_name ?? r.repo_root,
|
|
85
|
+
lastSeen,
|
|
86
|
+
lastCompactedAt: lastCompacted,
|
|
87
|
+
modelCapturedAt: modelCaptured,
|
|
88
|
+
signals,
|
|
89
|
+
status,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// Sort: warn first, then by lastSeen desc so the active ones are on top.
|
|
93
|
+
repos.sort((a, b) => {
|
|
94
|
+
if (a.status !== b.status)
|
|
95
|
+
return a.status === "warn" ? -1 : 1;
|
|
96
|
+
return b.lastSeen - a.lastSeen;
|
|
97
|
+
});
|
|
98
|
+
return { generatedAt, totals, repos };
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
db?.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { upsertRepoRegistry } from "./store/sqlite.js";
|
|
7
|
+
import { detectCrossRepoDrift } from "./driftDetection.js";
|
|
8
|
+
const NOW = Math.floor(Date.now() / 1000);
|
|
9
|
+
const D = 86_400;
|
|
10
|
+
test("driftDetection: empty registry returns ok report", () => {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-empty-"));
|
|
12
|
+
try {
|
|
13
|
+
const report = detectCrossRepoDrift(dir);
|
|
14
|
+
assert.equal(report.totals.ok, 0);
|
|
15
|
+
assert.equal(report.totals.warn, 0);
|
|
16
|
+
assert.equal(report.repos.length, 0);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
test("driftDetection: flags stale repos older than 30 days", () => {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-stale-"));
|
|
24
|
+
try {
|
|
25
|
+
upsertRepoRegistry({ repoRoot: "/r/old", displayName: "old", stateDir: "/r/old", checkpointCount: 1, tokensSaved: 0, compressedOriginalBytes: 0, lastSeen: NOW - 45 * D }, dir);
|
|
26
|
+
const report = detectCrossRepoDrift(dir);
|
|
27
|
+
assert.equal(report.repos.length, 1);
|
|
28
|
+
assert.ok(report.repos[0].signals.some((s) => s.kind === "stale"), "stale signal present");
|
|
29
|
+
assert.equal(report.repos[0].status, "ok", "stale alone is info, not warn");
|
|
30
|
+
assert.equal(report.totals.stale, 1);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
test("driftDetection: active repo with no compaction flagged as warn", () => {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-lag-"));
|
|
38
|
+
try {
|
|
39
|
+
upsertRepoRegistry({ repoRoot: "/r/active", displayName: "active", stateDir: "/r/active", checkpointCount: 1, tokensSaved: 0, compressedOriginalBytes: 0, lastSeen: NOW - 1 * D }, dir);
|
|
40
|
+
const report = detectCrossRepoDrift(dir);
|
|
41
|
+
const r = report.repos[0];
|
|
42
|
+
assert.ok(r.signals.some((s) => s.kind === "compaction_lag"), "lag signal present");
|
|
43
|
+
assert.equal(r.status, "warn", "compaction lag is warn-level");
|
|
44
|
+
assert.equal(report.totals.warn, 1);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
test("driftDetection: active repo with recent compaction is ok", () => {
|
|
51
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-ok-"));
|
|
52
|
+
try {
|
|
53
|
+
upsertRepoRegistry({ repoRoot: "/r/healthy", displayName: "healthy", stateDir: "/r/healthy", checkpointCount: 1, tokensSaved: 0, compressedOriginalBytes: 0, lastSeen: NOW, lastCompactedAt: NOW }, dir);
|
|
54
|
+
const report = detectCrossRepoDrift(dir);
|
|
55
|
+
const r = report.repos[0];
|
|
56
|
+
assert.equal(r.status, "ok");
|
|
57
|
+
assert.equal(r.signals.length, 0);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
test("driftDetection: recent model churn flagged as info", () => {
|
|
64
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-model-"));
|
|
65
|
+
try {
|
|
66
|
+
upsertRepoRegistry({
|
|
67
|
+
repoRoot: "/r/swap",
|
|
68
|
+
displayName: "swap",
|
|
69
|
+
stateDir: "/r/swap",
|
|
70
|
+
checkpointCount: 1,
|
|
71
|
+
tokensSaved: 0,
|
|
72
|
+
compressedOriginalBytes: 0,
|
|
73
|
+
lastSeen: NOW,
|
|
74
|
+
lastCompactedAt: NOW,
|
|
75
|
+
provider: "anthropic",
|
|
76
|
+
providerName: "Anthropic",
|
|
77
|
+
modelName: "sonnet-4.6",
|
|
78
|
+
modelCapturedAt: NOW - 1 * D,
|
|
79
|
+
}, dir);
|
|
80
|
+
const report = detectCrossRepoDrift(dir);
|
|
81
|
+
assert.ok(report.repos[0].signals.some((s) => s.kind === "model_churn"), "model churn detected");
|
|
82
|
+
assert.equal(report.totals.modelChurn, 1);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { collectRecentUserRequests } from "./compact.js";
|
|
2
|
+
import { cosineSimilarity, defaultEmbedder } from "./embedder.js";
|
|
3
|
+
import { DedupConfig } from "./config/dedup.js";
|
|
4
|
+
import { listMemories, removeMemory, replaceMemory } from "./store/sqlite.js";
|
|
5
|
+
import { getStateDir } from "./store.js";
|
|
6
|
+
const DECISION_PATTERNS = [
|
|
7
|
+
/\bwe (?:use|chose|decided|will use|standardized on|go with)\b/i,
|
|
8
|
+
/\b(?:the|our) (?:threshold|policy|rule|convention|default) is\b/i,
|
|
9
|
+
/\bactually\b/i, /\braise (?:the )?|lower (?:the )?|switch (?:to )?\b/i,
|
|
10
|
+
];
|
|
11
|
+
// Patterns that signal an explicit memory drop. Grounded only when the user
|
|
12
|
+
// references an existing memory's content (handled in reviewConversation).
|
|
13
|
+
const DROP_PATTERNS = [
|
|
14
|
+
/\b(?:stop using|don't use|dont use|drop(?:ping)?|forget|remove from memory|no longer)\b/i,
|
|
15
|
+
];
|
|
16
|
+
/** Heuristic, extractive review. No LLM. Downgrades un-grounded claims to none. */
|
|
17
|
+
export function reviewConversation(messages, existing = []) {
|
|
18
|
+
const ops = [];
|
|
19
|
+
const requests = collectRecentUserRequests(messages, 20);
|
|
20
|
+
for (let i = 0; i < requests.length; i++) {
|
|
21
|
+
const r = requests[i];
|
|
22
|
+
const isDecision = DECISION_PATTERNS.some((p) => p.test(r));
|
|
23
|
+
const isDrop = DROP_PATTERNS.some((p) => p.test(r));
|
|
24
|
+
// A "stop using/drop/forget" signal takes precedence over a decision — the
|
|
25
|
+
// user asking to forget a memory supersedes any "switch to" phrasing it
|
|
26
|
+
// happens to contain (which would otherwise route into REPLACE).
|
|
27
|
+
if (isDrop && existing.some((e) => sharesTopic(e.content, r))) {
|
|
28
|
+
const target = existing.find((e) => sharesTopic(e.content, r));
|
|
29
|
+
if (target)
|
|
30
|
+
ops.push({ op: "remove", content: target.content });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (isDrop) {
|
|
34
|
+
// Drop pattern matched but no existing topic-overlapping memory — nothing
|
|
35
|
+
// to remove. Don't fall through to the decision branch (the request might
|
|
36
|
+
// also contain 'switch to' phrasing).
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!isDecision)
|
|
40
|
+
continue;
|
|
41
|
+
// Treat earlier in-conversation decisions as "existing" too, so later messages
|
|
42
|
+
// that contradict them emit a REPLACE instead of an ADD.
|
|
43
|
+
const inConvoExisting = requests.slice(0, i).map((r) => ({ content: r }));
|
|
44
|
+
const contradicted = [...inConvoExisting, ...existing].find((e) => sharesTopic(e.content, r) && differs(e.content, r));
|
|
45
|
+
if (contradicted) {
|
|
46
|
+
ops.push({ op: "replace", targetContent: contradicted.content, memory: { content: r, category: "decision", sourceTurn: i } });
|
|
47
|
+
}
|
|
48
|
+
else if (!existing.some((e) => nearDup(e.content, r))) {
|
|
49
|
+
ops.push({ op: "add", memory: { content: r, category: "decision", sourceTurn: i } });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Guardrail: drop any add/replace op whose memory content isn't grounded in a
|
|
53
|
+
// real message (hallucination prevention). REMOVE ops are exempt — their
|
|
54
|
+
// `content` is an EXISTING memory (matched by topic overlap), so it predates
|
|
55
|
+
// the current conversation and won't appear verbatim in any message.
|
|
56
|
+
return ops.filter((o) => o.op === "remove"
|
|
57
|
+
? true
|
|
58
|
+
: messages.some((m) => String(m.text ?? "").includes(o.memory.content)));
|
|
59
|
+
}
|
|
60
|
+
function sharesTopic(a, b) {
|
|
61
|
+
const aw = new Set(a.toLowerCase().split(/\W+/).filter((w) => w.length > 3));
|
|
62
|
+
const bw = new Set(b.toLowerCase().split(/\W+/).filter((w) => w.length > 3));
|
|
63
|
+
let shared = 0;
|
|
64
|
+
for (const w of bw)
|
|
65
|
+
if (aw.has(w))
|
|
66
|
+
shared++;
|
|
67
|
+
return shared >= 1;
|
|
68
|
+
}
|
|
69
|
+
function differs(a, b) { return !nearDup(a, b); }
|
|
70
|
+
function nearDup(a, b) {
|
|
71
|
+
const aw = new Set(a.toLowerCase().split(/\W+/));
|
|
72
|
+
const bw = new Set(b.toLowerCase().split(/\W+/));
|
|
73
|
+
let shared = 0;
|
|
74
|
+
for (const w of bw)
|
|
75
|
+
if (aw.has(w))
|
|
76
|
+
shared++;
|
|
77
|
+
return shared / Math.max(1, bw.size) >= 0.8;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* mergePhrases — for two near-duplicate texts (cosine >= threshold), build a
|
|
81
|
+
* merged content string. If the loser's token set is mostly contained in the
|
|
82
|
+
* survivor's, the survivor already covers the meaning and we keep it as-is.
|
|
83
|
+
* Otherwise we append the loser's text as a new paragraph so no phrasing is
|
|
84
|
+
* lost.
|
|
85
|
+
*/
|
|
86
|
+
function mergePhrases(survivor, loser) {
|
|
87
|
+
if (nearDup(survivor, loser))
|
|
88
|
+
return survivor;
|
|
89
|
+
return `${survivor}\n\n${loser}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* consolidateMemories — merge near-duplicate rows in the `memories` table
|
|
93
|
+
* (Sprint 21, Task S21.2). Pure local cosine over `defaultEmbedder`
|
|
94
|
+
* embeddings (zero-net, deterministic, no LLM). Uses the consolidation
|
|
95
|
+
* threshold `DedupConfig.CONSOLIDATE_COSINE` (default 0.7) — lower than the
|
|
96
|
+
* off-line SemDeDup threshold (0.95) because drift between manually-typed
|
|
97
|
+
* memories about the same topic is expected, and the goal is to clean it up
|
|
98
|
+
* rather than be paranoid about over-merging. One row survives; redundant
|
|
99
|
+
* rows are removed. Returns the number of merges performed.
|
|
100
|
+
*
|
|
101
|
+
* Algorithm:
|
|
102
|
+
* 1. Load all memories for the repo (or all repos when repo is null).
|
|
103
|
+
* 2. Embed their `content` field.
|
|
104
|
+
* 3. For every pair, if cosine >= threshold AND same category → merge:
|
|
105
|
+
* survivor is the newest (largest id). Loser's content is appended as a
|
|
106
|
+
* paragraph to the survivor's content if it adds non-redundant phrasing,
|
|
107
|
+
* otherwise dropped. Loser's row is removed.
|
|
108
|
+
*
|
|
109
|
+
* PREVENT-PI-004: local-only embedding, no network.
|
|
110
|
+
*/
|
|
111
|
+
export async function consolidateMemories(stateDir = getStateDir(), repo = null, threshold = DedupConfig.CONSOLIDATE_COSINE) {
|
|
112
|
+
const rows = listMemories(repo, 1000, stateDir);
|
|
113
|
+
if (rows.length < 2)
|
|
114
|
+
return 0;
|
|
115
|
+
const emb = defaultEmbedder();
|
|
116
|
+
const vectors = rows.map((r) => emb.embed(r.content));
|
|
117
|
+
let merges = 0;
|
|
118
|
+
// Iterate once per row. Older rows are processed first; the merge keeps the
|
|
119
|
+
// newer (larger id), so we always merge away the older side.
|
|
120
|
+
for (let i = 0; i < rows.length; i++) {
|
|
121
|
+
if (rows[i].id == null)
|
|
122
|
+
continue; // safety: in case the row was removed mid-loop
|
|
123
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
124
|
+
if (rows[j].id == null)
|
|
125
|
+
continue;
|
|
126
|
+
if (rows[i].category !== rows[j].category)
|
|
127
|
+
continue; // different buckets: not a dup
|
|
128
|
+
const sim = cosineSimilarity(vectors[i], vectors[j]);
|
|
129
|
+
if (sim < threshold)
|
|
130
|
+
continue;
|
|
131
|
+
// Pick survivor: largest id (most recently inserted / referenced wins).
|
|
132
|
+
const survivorId = rows[i].id > rows[j].id ? rows[i].id : rows[j].id;
|
|
133
|
+
const loserId = survivorId === rows[i].id ? rows[j].id : rows[i].id;
|
|
134
|
+
const survivor = survivorId === rows[i].id ? rows[i] : rows[j];
|
|
135
|
+
const loser = survivorId === rows[i].id ? rows[j] : rows[i];
|
|
136
|
+
// Merge content: keep survivor's content; if loser's content adds a
|
|
137
|
+
// phrase (token overlap < 80% with survivor) append it as a paragraph.
|
|
138
|
+
const mergedContent = mergePhrases(survivor.content, loser.content);
|
|
139
|
+
replaceMemory(survivorId, { content: mergedContent }, stateDir);
|
|
140
|
+
removeMemory(loserId, stateDir);
|
|
141
|
+
// Mark the loser row in the surviving array so we skip it on later pairs.
|
|
142
|
+
rows[loserId === rows[i].id ? i : j] = { ...loser, id: undefined };
|
|
143
|
+
merges++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return merges;
|
|
147
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { reviewConversation } from "./memory.js";
|
|
4
|
+
test("reviewConversation: yields an ADD op for a stated decision", () => {
|
|
5
|
+
const msgs = [
|
|
6
|
+
{ role: "user", text: "we use node:sqlite as the store" },
|
|
7
|
+
{ role: "assistant", text: "got it, node:sqlite is the source of truth" },
|
|
8
|
+
];
|
|
9
|
+
const ops = reviewConversation(msgs);
|
|
10
|
+
assert.ok(ops.some((o) => o.op === "add" && /sqlite|store/i.test(o.memory.content)), "adds a decision memory");
|
|
11
|
+
});
|
|
12
|
+
test("reviewConversation: REPLACE when a later message contradicts an earlier one", () => {
|
|
13
|
+
const msgs = [
|
|
14
|
+
{ role: "user", text: "the threshold is 50k" },
|
|
15
|
+
{ role: "assistant", text: "ok 50k threshold" },
|
|
16
|
+
{ role: "user", text: "actually raise the threshold to 100k" },
|
|
17
|
+
];
|
|
18
|
+
const ops = reviewConversation(msgs);
|
|
19
|
+
assert.ok(ops.some((o) => o.op === "replace"), "replaces the superseded value");
|
|
20
|
+
});
|
|
21
|
+
test("reviewConversation: no ops on pure smalltalk (no durable fact)", () => {
|
|
22
|
+
const msgs = [{ role: "user", text: "hi" }, { role: "assistant", text: "hey" }];
|
|
23
|
+
assert.equal(reviewConversation(msgs).length, 0);
|
|
24
|
+
});
|
|
25
|
+
test("reviewConversation: emits REMOVE when a user asks to drop an existing memory", () => {
|
|
26
|
+
const existing = [{ content: "we use node:sqlite as the store" }];
|
|
27
|
+
// Plain drop statement — no "switch to …" phrasing so we don't accidentally
|
|
28
|
+
// match DECISION_PATTERNS and route into the replace branch instead.
|
|
29
|
+
const msgs = [
|
|
30
|
+
{ role: "user", text: "stop using node:sqlite for the store — drop it from memory" },
|
|
31
|
+
{ role: "assistant", text: "ok dropped" },
|
|
32
|
+
];
|
|
33
|
+
const ops = reviewConversation(msgs, existing);
|
|
34
|
+
assert.ok(ops.some((o) => o.op === "remove" && /sqlite/i.test(o.content)), "emits a remove op targeting the old memory");
|
|
35
|
+
});
|
|
36
|
+
test("reviewConversation: REMOVE requires topic overlap (no accidental drop)", () => {
|
|
37
|
+
const existing = [{ content: "the timezone is America/Los_Angeles" }];
|
|
38
|
+
const msgs = [{ role: "user", text: "drop it" }];
|
|
39
|
+
const ops = reviewConversation(msgs, existing);
|
|
40
|
+
assert.equal(ops.filter((o) => o.op === "remove").length, 0, "vague 'drop it' with no topic overlap does not remove anything");
|
|
41
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-consolidate-"));
|
|
9
|
+
test("consolidateMemories: merges near-duplicate memories (one kept, one removed)", async () => {
|
|
10
|
+
const dir = join(baseTmp, "merge");
|
|
11
|
+
// Two near-identical memories — same topic, minor wording drift.
|
|
12
|
+
addMemory({ content: "we use node:sqlite as the store", category: "decision" }, null, dir);
|
|
13
|
+
addMemory({ content: "we use node:sqlite for our store", category: "decision" }, null, dir);
|
|
14
|
+
const merged = await consolidateMemories(dir);
|
|
15
|
+
assert.equal(merged, 1, "one merge was performed");
|
|
16
|
+
const rows = listMemories(null, 50, dir);
|
|
17
|
+
// Exactly one of the two near-dupes survives.
|
|
18
|
+
assert.equal(rows.length, 1, "exactly one memory remains after merge");
|
|
19
|
+
assert.match(rows[0].content, /node:sqlite.*store/i, "survivor mentions node:sqlite + store");
|
|
20
|
+
});
|
|
21
|
+
test("consolidateMemories: leaves unrelated memories alone", async () => {
|
|
22
|
+
const dir = join(baseTmp, "noop");
|
|
23
|
+
addMemory({ content: "we use node:sqlite as the store", category: "decision" }, null, dir);
|
|
24
|
+
addMemory({ content: "the gpt-5 release is on hold pending benchmark results", category: "note" }, null, dir);
|
|
25
|
+
const merged = await consolidateMemories(dir);
|
|
26
|
+
assert.equal(merged, 0, "no merges on unrelated memories");
|
|
27
|
+
const rows = listMemories(null, 50, dir);
|
|
28
|
+
assert.equal(rows.length, 2, "both memories survive");
|
|
29
|
+
});
|
|
30
|
+
test("consolidateMemories: empty store returns 0 and changes nothing", async () => {
|
|
31
|
+
const dir = join(baseTmp, "empty");
|
|
32
|
+
const merged = await consolidateMemories(dir);
|
|
33
|
+
assert.equal(merged, 0, "no merges on empty store");
|
|
34
|
+
assert.equal(listMemories(null, 50, dir).length, 0, "store still empty");
|
|
35
|
+
});
|
|
36
|
+
test("cleanup memops", () => {
|
|
37
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
38
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { addMemory, listMemories, replaceMemory, removeMemory, } from "./store/sqlite.js";
|
|
2
|
+
/** Find a memory row whose content exactly matches (case-insensitive). */
|
|
3
|
+
function findByContent(memories, content) {
|
|
4
|
+
const norm = content.trim().toLowerCase();
|
|
5
|
+
return memories.find((m) => m.content.trim().toLowerCase() === norm);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Apply add/replace/remove ops to the memories table. Replaces are matched by
|
|
9
|
+
* existing content; removes by content. Idempotent: an add that already exists
|
|
10
|
+
* is a no-op, a replace of a missing memory degrades to an add.
|
|
11
|
+
*/
|
|
12
|
+
export async function applyMemoryOps(ops, stateDir) {
|
|
13
|
+
if (!ops.length)
|
|
14
|
+
return;
|
|
15
|
+
const repo = null; // memories are repo-scoped by stateDir, not by the repo arg here
|
|
16
|
+
const existing = listMemories(repo, 1000, stateDir);
|
|
17
|
+
for (const op of ops) {
|
|
18
|
+
if (op.op === "add") {
|
|
19
|
+
// Skip if an identical memory already exists.
|
|
20
|
+
if (findByContent(existing, op.memory.content))
|
|
21
|
+
continue;
|
|
22
|
+
addMemory({
|
|
23
|
+
kind: op.memory.category,
|
|
24
|
+
content: op.memory.content,
|
|
25
|
+
tags: [],
|
|
26
|
+
category: op.memory.category,
|
|
27
|
+
target: op.memory.target,
|
|
28
|
+
sourceTurn: op.memory.sourceTurn,
|
|
29
|
+
}, repo, stateDir);
|
|
30
|
+
}
|
|
31
|
+
else if (op.op === "replace") {
|
|
32
|
+
const match = findByContent(existing, op.targetContent);
|
|
33
|
+
if (match) {
|
|
34
|
+
replaceMemory(match.id, {
|
|
35
|
+
kind: op.memory.category,
|
|
36
|
+
content: op.memory.content,
|
|
37
|
+
category: op.memory.category,
|
|
38
|
+
sourceTurn: op.memory.sourceTurn,
|
|
39
|
+
}, stateDir);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// Target missing (e.g. earlier in-conversation contradiction) → add.
|
|
43
|
+
addMemory({
|
|
44
|
+
kind: op.memory.category,
|
|
45
|
+
content: op.memory.content,
|
|
46
|
+
tags: [],
|
|
47
|
+
category: op.memory.category,
|
|
48
|
+
sourceTurn: op.memory.sourceTurn,
|
|
49
|
+
}, repo, stateDir);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const match = findByContent(existing, op.content);
|
|
54
|
+
if (match)
|
|
55
|
+
removeMemory(match.id, stateDir);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
|
|
9
|
+
test("applyMemoryOps: ADD inserts a new memory", async () => {
|
|
10
|
+
const dir = join(baseTmp, "add");
|
|
11
|
+
await applyMemoryOps([{ op: "add", memory: { content: "we use node:sqlite as the store", category: "decision", sourceTurn: 0 } }], dir);
|
|
12
|
+
const rows = listMemories(null, 50, dir);
|
|
13
|
+
assert.ok(rows.some((m) => /node:sqlite/.test(m.content)), "added memory present");
|
|
14
|
+
assert.equal(rows[0].category, "decision", "category persisted");
|
|
15
|
+
});
|
|
16
|
+
test("applyMemoryOps: ADD is idempotent (no duplicate)", async () => {
|
|
17
|
+
const dir = join(baseTmp, "dup");
|
|
18
|
+
const op = { op: "add", memory: { content: "threshold is 50k", category: "decision", sourceTurn: 0 } };
|
|
19
|
+
await applyMemoryOps([op], dir);
|
|
20
|
+
await applyMemoryOps([op], dir);
|
|
21
|
+
const rows = listMemories(null, 50, dir);
|
|
22
|
+
assert.equal(rows.filter((m) => /threshold is 50k/.test(m.content)).length, 1, "no duplicate");
|
|
23
|
+
});
|
|
24
|
+
test("applyMemoryOps: REPLACE updates the matching memory", async () => {
|
|
25
|
+
const dir = join(baseTmp, "replace");
|
|
26
|
+
addMemory({ content: "the threshold is 50k", category: "decision" }, null, dir);
|
|
27
|
+
await applyMemoryOps([{ op: "replace", targetContent: "the threshold is 50k", memory: { content: "the threshold is 100k", category: "decision", sourceTurn: 2 } }], dir);
|
|
28
|
+
const rows = listMemories(null, 50, dir);
|
|
29
|
+
assert.ok(rows.some((m) => /100k/.test(m.content)), "replaced content present");
|
|
30
|
+
assert.ok(!rows.some((m) => /50k/.test(m.content)), "old content gone");
|
|
31
|
+
});
|
|
32
|
+
test("applyMemoryOps: REMOVE deletes the matching memory", async () => {
|
|
33
|
+
const dir = join(baseTmp, "remove");
|
|
34
|
+
addMemory({ content: "obsolete note", category: "note" }, null, dir);
|
|
35
|
+
await applyMemoryOps([{ op: "remove", content: "obsolete note" }], dir);
|
|
36
|
+
const rows = listMemories(null, 50, dir);
|
|
37
|
+
assert.ok(!rows.some((m) => /obsolete note/.test(m.content)), "removed");
|
|
38
|
+
});
|
|
39
|
+
test("cleanup memops", () => {
|
|
40
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
41
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
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 } from "./store/sqlite.js";
|
|
13
|
+
/** Default category weights — `decision` wins ties on tied cosine. */
|
|
14
|
+
export const DEFAULT_CATEGORY_WEIGHTS = {
|
|
15
|
+
decision: 1.10,
|
|
16
|
+
preference: 1.05,
|
|
17
|
+
fact: 1.0,
|
|
18
|
+
};
|
|
19
|
+
const DEFAULT_RECENCY_WEIGHT = 0.05;
|
|
20
|
+
/**
|
|
21
|
+
* Rank memories by a blended score of cosine similarity, category weight, and
|
|
22
|
+
* recency of last_referenced (with createdAt as a fallback). Returns the
|
|
23
|
+
* top-k above minSimilarity, sorted by blended score descending. Empty on
|
|
24
|
+
* no matches.
|
|
25
|
+
*/
|
|
26
|
+
export async function recallMemories(query, stateDir, opts = {}) {
|
|
27
|
+
const topK = opts.topK ?? 10;
|
|
28
|
+
const minSimilarity = opts.minSimilarity ?? 0.2;
|
|
29
|
+
const markReferenced = opts.markReferenced ?? true;
|
|
30
|
+
const embedder = opts.embedder ?? defaultEmbedder();
|
|
31
|
+
const repo = opts.repo === undefined ? null : opts.repo;
|
|
32
|
+
const categoryWeights = { ...DEFAULT_CATEGORY_WEIGHTS, ...(opts.categoryWeights ?? {}) };
|
|
33
|
+
const recencyWeight = opts.recencyWeight ?? DEFAULT_RECENCY_WEIGHT;
|
|
34
|
+
const queryVec = embedder.embed(query);
|
|
35
|
+
const memories = listMemories(repo, 1000, stateDir);
|
|
36
|
+
if (!memories.length || !query.trim())
|
|
37
|
+
return [];
|
|
38
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
39
|
+
const scored = [];
|
|
40
|
+
for (const mem of memories) {
|
|
41
|
+
const vec = embedder.embed(mem.content);
|
|
42
|
+
const sim = cosineSimilarity(queryVec, vec);
|
|
43
|
+
if (sim < minSimilarity)
|
|
44
|
+
continue;
|
|
45
|
+
const categoryW = categoryWeights[mem.category ?? "fact"] ?? 1.0;
|
|
46
|
+
const lastTouch = mem.lastReferenced ?? mem.createdAt ?? nowSec;
|
|
47
|
+
// log(1 + daysSince) grows slowly — half-life-style decay.
|
|
48
|
+
const daysSince = Math.max(0, (nowSec - lastTouch) / 86_400);
|
|
49
|
+
const recencyBoost = Math.log1p(daysSince) * recencyWeight;
|
|
50
|
+
const finalScore = sim * categoryW * (1 + recencyBoost);
|
|
51
|
+
scored.push({ memory: mem, score: finalScore });
|
|
52
|
+
}
|
|
53
|
+
scored.sort((a, b) => b.score - a.score);
|
|
54
|
+
const top = scored.slice(0, topK);
|
|
55
|
+
if (markReferenced) {
|
|
56
|
+
for (const hit of top)
|
|
57
|
+
referenceMemory(hit.memory.id, stateDir);
|
|
58
|
+
}
|
|
59
|
+
return top;
|
|
60
|
+
}
|