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,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
|
});
|
package/src/recall.ts
CHANGED
|
@@ -38,6 +38,10 @@ export interface RecallInjectOptions {
|
|
|
38
38
|
liveWindow?: string[];
|
|
39
39
|
/** Similarity threshold for inline dedupe (defaults to 0.9). */
|
|
40
40
|
dedupSim?: number;
|
|
41
|
+
/** S18: index dir of the machine-wide injected-set. When set on a cross-repo
|
|
42
|
+
* recall, a foreign checkpoint already injected (in any session) is skipped
|
|
43
|
+
* and a fresh injection is recorded globally. */
|
|
44
|
+
globalIndexDir?: string;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
export interface RecallInjectResult {
|
|
@@ -56,8 +60,12 @@ export function formatRecallBlock(hits: SearchHit[]): string {
|
|
|
56
60
|
if (hits.length === 0) return "";
|
|
57
61
|
const parts = hits.map((h, i) => {
|
|
58
62
|
const score = (h.score * 100).toFixed(0);
|
|
63
|
+
// S17: label a cross-repo hit with its source repo (the repoId doubles as
|
|
64
|
+
// that repo's stateDir, so the last path segment is the repo's display
|
|
65
|
+
// name). Same-repo hits (no repoId) stay unlabeled.
|
|
66
|
+
const repoName = h.repoId ? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})` : "";
|
|
59
67
|
return (
|
|
60
|
-
`### Recalled context [${i + 1}] (relevance ${score}%)\n` +
|
|
68
|
+
`### Recalled context [${i + 1}] (relevance ${score}%)${repoName}\n` +
|
|
61
69
|
`${h.checkpoint.summary.trim()}\n` +
|
|
62
70
|
(h.checkpoint.filesModified.length
|
|
63
71
|
? `Key files: ${h.checkpoint.filesModified.join(", ")}.\n`
|
|
@@ -138,6 +146,70 @@ export function recallAndInline(
|
|
|
138
146
|
};
|
|
139
147
|
}
|
|
140
148
|
|
|
149
|
+
// --- S21: memory recall ----------------------------------------------------
|
|
150
|
+
// Durables (decisions, rules, user-saved facts) live in the `memories` table.
|
|
151
|
+
// We mirror the checkpoint recall path: rank by cosine, format a block, respect
|
|
152
|
+
// a token cap so it can never net-inflate the system prompt.
|
|
153
|
+
|
|
154
|
+
export interface MemoryRecallInjectOptions {
|
|
155
|
+
query: string;
|
|
156
|
+
stateDir: string;
|
|
157
|
+
limit?: number;
|
|
158
|
+
/** Token ceiling; defaults to the same `recallMaxTokens` used for checkpoints. */
|
|
159
|
+
recallMaxTokens?: number;
|
|
160
|
+
/** Cosine threshold; default 0.2. */
|
|
161
|
+
minSimilarity?: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Format one memory hit for the recall block. Category + score for traceability. */
|
|
165
|
+
export function formatMemoryRecallBlock(
|
|
166
|
+
hits: Array<{ content: string; category: string | null; score: number }>,
|
|
167
|
+
): string {
|
|
168
|
+
if (hits.length === 0) return "";
|
|
169
|
+
const parts = hits.map((h, i) => {
|
|
170
|
+
const pct = (h.score * 100).toFixed(0);
|
|
171
|
+
const cat = h.category ? `[${h.category}] ` : "";
|
|
172
|
+
return `### Recalled memory [${i + 1}] (relevance ${pct}%)\n${cat}${h.content.trim()}`;
|
|
173
|
+
});
|
|
174
|
+
return (
|
|
175
|
+
"The following facts about this project were saved from earlier turns " +
|
|
176
|
+
"and are relevant to the current request. Treat them as established:\n\n" +
|
|
177
|
+
parts.join("\n")
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Recall top-k durable memories, format into a token-capped block. */
|
|
182
|
+
export async function recallMemoriesAndInline(
|
|
183
|
+
opts: MemoryRecallInjectOptions,
|
|
184
|
+
): Promise<{ empty: boolean; block: string; report: string[] }> {
|
|
185
|
+
const limit = opts.limit ?? 5;
|
|
186
|
+
const maxTokens = opts.recallMaxTokens ?? 0;
|
|
187
|
+
const { recallMemories } = await import("./memoryRecall.js");
|
|
188
|
+
const hits = await recallMemories(opts.query, opts.stateDir, {
|
|
189
|
+
topK: limit,
|
|
190
|
+
minSimilarity: opts.minSimilarity ?? 0.2,
|
|
191
|
+
});
|
|
192
|
+
if (hits.length === 0) return { empty: true, block: "", report: [] };
|
|
193
|
+
|
|
194
|
+
// Same incremental token cap pattern as checkpoint recall.
|
|
195
|
+
const parts: string[] = [];
|
|
196
|
+
const report: string[] = [];
|
|
197
|
+
let blockTokens = 0;
|
|
198
|
+
for (const h of hits) {
|
|
199
|
+
const part = formatMemoryRecallBlock([
|
|
200
|
+
{ content: h.memory.content, category: h.memory.category, score: h.score },
|
|
201
|
+
]);
|
|
202
|
+
const partTokens = estimateBlockTokens(part);
|
|
203
|
+
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
|
|
204
|
+
parts.push(part);
|
|
205
|
+
report.push(
|
|
206
|
+
` • memory#${h.memory.id} (${(h.score * 100).toFixed(0)}%): ${h.memory.content.slice(0, 60).replace(/\n/g, " ")}…`,
|
|
207
|
+
);
|
|
208
|
+
blockTokens += partTokens;
|
|
209
|
+
}
|
|
210
|
+
return { empty: parts.length === 0, block: parts.join("\n"), report };
|
|
211
|
+
}
|
|
212
|
+
|
|
141
213
|
/**
|
|
142
214
|
* Slice 2 async cross-repo recall. Same dedup/bound/inline contract as
|
|
143
215
|
* `recallAndInline`, but backed by `VectorStore.searchAsync` so it can recall
|
|
@@ -180,6 +252,17 @@ export async function recallAndInlineAsync(
|
|
|
180
252
|
|
|
181
253
|
for (const h of hits) {
|
|
182
254
|
if (skip && store.wasInjected(opts.sessionId, h.checkpoint.checkpointId)) continue;
|
|
255
|
+
// S18: machine-wide injected-set — a foreign checkpoint already injected
|
|
256
|
+
// (in any session) is never re-injected. Only applies to cross-repo hits
|
|
257
|
+
// (same-repo hits have no repoId and are handled by the per-session set).
|
|
258
|
+
if (opts.globalIndexDir && h.repoId) {
|
|
259
|
+
try {
|
|
260
|
+
const { wasInjectedGlobal } = await import("./store/sqlite.js");
|
|
261
|
+
if (wasInjectedGlobal(h.checkpoint.checkpointId, opts.sessionId, opts.globalIndexDir)) continue;
|
|
262
|
+
} catch {
|
|
263
|
+
/* non-fatal: degrade to per-session injected-set only */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
183
266
|
if (doWindowDedupe && liveEmbeddings.length > 0) {
|
|
184
267
|
const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
|
|
185
268
|
if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
|
|
@@ -191,6 +274,16 @@ export async function recallAndInlineAsync(
|
|
|
191
274
|
toInject.push(h);
|
|
192
275
|
blockTokens += partTokens;
|
|
193
276
|
store.markInjected(opts.sessionId, h.checkpoint.checkpointId);
|
|
277
|
+
// S18: record the cross-repo injection machine-wide so it's not re-injected
|
|
278
|
+
// by a later recall (same or different session).
|
|
279
|
+
if (opts.globalIndexDir && h.repoId) {
|
|
280
|
+
try {
|
|
281
|
+
const { markInjectedGlobal } = await import("./store/sqlite.js");
|
|
282
|
+
markInjectedGlobal(h.checkpoint.checkpointId, h.repoId, opts.sessionId, opts.globalIndexDir);
|
|
283
|
+
} catch {
|
|
284
|
+
/* non-fatal */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
194
287
|
}
|
|
195
288
|
|
|
196
289
|
const block = parts.join("\n");
|