Skip to content

Commit f9eef50

Browse files
committed
feat: handleMerge contextLabel + metadata caps + E2E test
- smart-extractor.ts: handleMerge now accepts contextLabel and updates support stats after successful merge (aligns with support/contextualize/ contradict handlers) - smart-metadata.ts: stringifySmartMetadata caps arrays to prevent JSON bloat (sources≤20, history≤50, relations≤16) - test/context-support-e2e.mjs: 3 E2E scenarios testing support, contextualize, and contradict decisions end-to-end
1 parent 18ed0b9 commit f9eef50

File tree

3 files changed

+277
-2
lines changed

3 files changed

+277
-2
lines changed

src/smart-extractor.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ export class SmartExtractor {
341341
dedupResult.matchId,
342342
scopeFilter,
343343
targetScope,
344+
dedupResult.contextLabel,
344345
);
345346
stats.merged++;
346347
} else {
@@ -540,6 +541,7 @@ export class SmartExtractor {
540541
matchId: string,
541542
scopeFilter: string[],
542543
targetScope: string,
544+
contextLabel?: string,
543545
): Promise<void> {
544546
let existingAbstract = "";
545547
let existingOverview = "";
@@ -619,8 +621,22 @@ export class SmartExtractor {
619621
scopeFilter,
620622
);
621623

624+
// Update support stats on the merged memory
625+
try {
626+
const updatedEntry = await this.store.getById(matchId, scopeFilter);
627+
if (updatedEntry) {
628+
const meta = parseSmartMetadata(updatedEntry.metadata, updatedEntry);
629+
const supportInfo = parseSupportInfo(meta.support_info);
630+
updateSupportStats(supportInfo, contextLabel, "support");
631+
const finalMetadata = stringifySmartMetadata({ ...meta, support_info: supportInfo });
632+
await this.store.update(matchId, { metadata: finalMetadata }, scopeFilter);
633+
}
634+
} catch {
635+
// Non-critical: merge succeeded, support stats update is best-effort
636+
}
637+
622638
this.log(
623-
`memory-pro: smart-extractor: merged [${candidate.category}] into ${matchId.slice(0, 8)}`,
639+
`memory-pro: smart-extractor: merged [${candidate.category}]${contextLabel ? ` [${contextLabel}]` : ""} into ${matchId.slice(0, 8)}`,
624640
);
625641
}
626642

src/smart-metadata.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,28 @@ export function buildSmartMetadata(
171171
};
172172
}
173173

174+
// Metadata array size caps — prevent unbounded JSON growth
175+
const MAX_SOURCES = 20;
176+
const MAX_HISTORY = 50;
177+
const MAX_RELATIONS = 16;
178+
174179
export function stringifySmartMetadata(
175180
metadata: SmartMemoryMetadata | Record<string, unknown>,
176181
): string {
177-
return JSON.stringify(metadata);
182+
const capped = { ...metadata } as Record<string, unknown>;
183+
184+
// Cap array fields to prevent metadata bloat
185+
if (Array.isArray(capped.sources) && capped.sources.length > MAX_SOURCES) {
186+
capped.sources = capped.sources.slice(-MAX_SOURCES); // keep most recent
187+
}
188+
if (Array.isArray(capped.history) && capped.history.length > MAX_HISTORY) {
189+
capped.history = capped.history.slice(-MAX_HISTORY);
190+
}
191+
if (Array.isArray(capped.relations) && capped.relations.length > MAX_RELATIONS) {
192+
capped.relations = capped.relations.slice(0, MAX_RELATIONS);
193+
}
194+
195+
return JSON.stringify(capped);
178196
}
179197

180198
export function toLifecycleMemory(

test/context-support-e2e.mjs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/**
2+
* Context-Aware Support E2E Test
3+
*
4+
* Tests the full pipeline for support/contextualize/contradict decisions
5+
* using mock LLM and embedding servers against a real LanceDB store.
6+
*/
7+
8+
import assert from "node:assert/strict";
9+
import http from "node:http";
10+
import { mkdtempSync, rmSync } from "node:fs";
11+
import Module from "node:module";
12+
import { tmpdir } from "node:os";
13+
import path from "node:path";
14+
15+
import jitiFactory from "jiti";
16+
17+
process.env.NODE_PATH = [
18+
process.env.NODE_PATH,
19+
"/opt/homebrew/lib/node_modules/openclaw/node_modules",
20+
"/opt/homebrew/lib/node_modules",
21+
].filter(Boolean).join(":");
22+
Module._initPaths();
23+
24+
const jiti = jitiFactory(import.meta.url, { interopDefault: true });
25+
const { MemoryStore } = jiti("../src/store.ts");
26+
const { createEmbedder } = jiti("../src/embedder.ts");
27+
const { SmartExtractor } = jiti("../src/smart-extractor.ts");
28+
const { createLlmClient } = jiti("../src/llm-client.ts");
29+
const { buildSmartMetadata, stringifySmartMetadata, parseSupportInfo } = jiti("../src/smart-metadata.ts");
30+
31+
const EMBEDDING_DIMENSIONS = 2560;
32+
33+
// ============================================================================
34+
// Mock Embedding Server (constant vectors — fine for unit-level E2E)
35+
// ============================================================================
36+
37+
function createEmbeddingServer() {
38+
return http.createServer(async (req, res) => {
39+
if (req.method !== "POST" || req.url !== "/v1/embeddings") {
40+
res.writeHead(404); res.end(); return;
41+
}
42+
const chunks = [];
43+
for await (const chunk of req) chunks.push(chunk);
44+
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
45+
const inputs = Array.isArray(payload.input) ? payload.input : [payload.input];
46+
const value = 1 / Math.sqrt(EMBEDDING_DIMENSIONS);
47+
res.writeHead(200, { "Content-Type": "application/json" });
48+
res.end(JSON.stringify({
49+
object: "list",
50+
data: inputs.map((_, index) => ({
51+
object: "embedding", index,
52+
embedding: new Array(EMBEDDING_DIMENSIONS).fill(value),
53+
})),
54+
model: "mock", usage: { prompt_tokens: 0, total_tokens: 0 },
55+
}));
56+
});
57+
}
58+
59+
// ============================================================================
60+
// Test Runner
61+
// ============================================================================
62+
63+
async function runTest() {
64+
const workDir = mkdtempSync(path.join(tmpdir(), "ctx-support-e2e-"));
65+
const dbPath = path.join(workDir, "db");
66+
const logs = [];
67+
let dedupDecision = "support"; // controlled per scenario
68+
let dedupContextLabel = "evening";
69+
70+
const embeddingServer = createEmbeddingServer();
71+
72+
// Mock LLM: extraction returns 1 memory, dedup returns controlled decision
73+
const llmServer = http.createServer(async (req, res) => {
74+
if (req.method !== "POST" || req.url !== "/chat/completions") {
75+
res.writeHead(404); res.end(); return;
76+
}
77+
const chunks = [];
78+
for await (const chunk of req) chunks.push(chunk);
79+
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
80+
const prompt = payload.messages?.[1]?.content || "";
81+
let content;
82+
83+
if (prompt.includes("Analyze the following session context")) {
84+
content = JSON.stringify({
85+
memories: [{
86+
category: "preferences",
87+
abstract: "饮品偏好:乌龙茶",
88+
overview: "## Preference\n- 喜欢乌龙茶",
89+
content: "用户喜欢乌龙茶。",
90+
}],
91+
});
92+
} else if (prompt.includes("Determine how to handle this candidate memory")) {
93+
content = JSON.stringify({
94+
decision: dedupDecision,
95+
match_index: 1,
96+
reason: `test ${dedupDecision}`,
97+
context_label: dedupContextLabel,
98+
});
99+
} else {
100+
content = JSON.stringify({ memories: [] });
101+
}
102+
103+
res.writeHead(200, { "Content-Type": "application/json" });
104+
res.end(JSON.stringify({
105+
id: "test", object: "chat.completion",
106+
created: Math.floor(Date.now() / 1000), model: "mock",
107+
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
108+
}));
109+
});
110+
111+
await new Promise(r => embeddingServer.listen(0, "127.0.0.1", r));
112+
await new Promise(r => llmServer.listen(0, "127.0.0.1", r));
113+
const embPort = embeddingServer.address().port;
114+
const llmPort = llmServer.address().port;
115+
process.env.TEST_EMBEDDING_BASE_URL = `http://127.0.0.1:${embPort}/v1`;
116+
117+
try {
118+
const store = new MemoryStore({ dbPath, vectorDim: EMBEDDING_DIMENSIONS });
119+
const embedder = createEmbedder({
120+
provider: "openai-compatible", apiKey: "dummy", model: "mock",
121+
baseURL: `http://127.0.0.1:${embPort}/v1`, dimensions: EMBEDDING_DIMENSIONS,
122+
});
123+
const llm = createLlmClient({
124+
apiKey: "dummy", model: "mock",
125+
baseURL: `http://127.0.0.1:${llmPort}`,
126+
timeoutMs: 10000,
127+
log: (msg) => logs.push(msg),
128+
});
129+
130+
// Seed a preference memory
131+
const seedText = "饮品偏好:乌龙茶";
132+
const seedVector = await embedder.embedPassage(seedText);
133+
await store.store({
134+
text: seedText, vector: seedVector, category: "preference",
135+
scope: "test", importance: 0.8,
136+
metadata: stringifySmartMetadata(
137+
buildSmartMetadata({ text: seedText, category: "preference", importance: 0.8 }, {
138+
l0_abstract: seedText,
139+
l1_overview: "## Preference\n- 喜欢乌龙茶",
140+
l2_content: "用户喜欢乌龙茶。",
141+
memory_category: "preferences", tier: "working", confidence: 0.8,
142+
}),
143+
),
144+
});
145+
146+
const extractor = new SmartExtractor(store, embedder, llm, {
147+
user: "User", extractMinMessages: 1, extractMaxChars: 8000,
148+
defaultScope: "test",
149+
log: (msg) => logs.push(msg),
150+
});
151+
152+
// ----------------------------------------------------------------
153+
// Scenario 1: support — should update support_info, no new entry
154+
// ----------------------------------------------------------------
155+
console.log("Test 1: support decision updates support_info...");
156+
dedupDecision = "support";
157+
dedupContextLabel = "evening";
158+
logs.length = 0;
159+
160+
const stats1 = await extractor.extractAndPersist(
161+
"用户再次确认喜欢乌龙茶,特别是晚上。",
162+
"test-session",
163+
{ scope: "test", scopeFilter: ["test"] },
164+
);
165+
166+
const entries1 = await store.list(["test"], undefined, 10, 0);
167+
assert.equal(entries1.length, 1, "support should NOT create new entry");
168+
assert.equal(stats1.supported, 1, "supported count should be 1");
169+
170+
// Check support_info was updated
171+
const meta1 = JSON.parse(entries1[0].metadata || "{}");
172+
const si1 = parseSupportInfo(meta1.support_info);
173+
assert.ok(si1.total_observations >= 1, "total_observations should increase");
174+
const eveningSlice = si1.slices.find(s => s.context === "evening");
175+
assert.ok(eveningSlice, "evening slice should exist");
176+
assert.equal(eveningSlice.confirmations, 1, "evening confirmations should be 1");
177+
console.log(" ✅ support decision works correctly");
178+
179+
// ----------------------------------------------------------------
180+
// Scenario 2: contextualize — should create linked entry
181+
// ----------------------------------------------------------------
182+
console.log("Test 2: contextualize decision creates linked entry...");
183+
dedupDecision = "contextualize";
184+
dedupContextLabel = "night";
185+
logs.length = 0;
186+
187+
const stats2 = await extractor.extractAndPersist(
188+
"用户说晚上改喝花茶。",
189+
"test-session",
190+
{ scope: "test", scopeFilter: ["test"] },
191+
);
192+
193+
const entries2 = await store.list(["test"], undefined, 10, 0);
194+
assert.equal(entries2.length, 2, "contextualize should create 1 new entry");
195+
assert.equal(stats2.created, 1, "created count should be 1");
196+
console.log(" ✅ contextualize decision works correctly");
197+
198+
// ----------------------------------------------------------------
199+
// Scenario 3: contradict — should record contradiction + new entry
200+
// ----------------------------------------------------------------
201+
console.log("Test 3: contradict decision records contradiction...");
202+
dedupDecision = "contradict";
203+
dedupContextLabel = "weekend";
204+
logs.length = 0;
205+
206+
const stats3 = await extractor.extractAndPersist(
207+
"用户说周末不喝茶了。",
208+
"test-session",
209+
{ scope: "test", scopeFilter: ["test"] },
210+
);
211+
212+
const entries3 = await store.list(["test"], undefined, 10, 0);
213+
assert.equal(entries3.length, 3, "contradict should create 1 new entry");
214+
assert.equal(stats3.created, 1, "created count should be 1");
215+
216+
// Check contradictions recorded on some existing entry
217+
// (with constant vectors, dedup may match any existing entry)
218+
let foundWeekend = false;
219+
for (const entry of entries3) {
220+
const meta = JSON.parse(entry.metadata || "{}");
221+
const si = parseSupportInfo(meta.support_info);
222+
const weekendSlice = si.slices.find(s => s.context === "weekend");
223+
if (weekendSlice && weekendSlice.contradictions >= 1) {
224+
foundWeekend = true;
225+
break;
226+
}
227+
}
228+
assert.ok(foundWeekend, "at least one entry should have weekend contradiction");
229+
console.log(" ✅ contradict decision works correctly");
230+
231+
console.log("\n=== All Context-Support E2E tests passed! ===");
232+
233+
} finally {
234+
delete process.env.TEST_EMBEDDING_BASE_URL;
235+
await new Promise(r => embeddingServer.close(r));
236+
await new Promise(r => llmServer.close(r));
237+
rmSync(workDir, { recursive: true, force: true });
238+
}
239+
}
240+
241+
await runTest();

0 commit comments

Comments
 (0)