@pentatonic-ai/ai-agent-sdk 0.4.8 → 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 +59 -0
- package/bin/cli.js +70 -9
- package/dist/index.cjs +25 -3
- package/dist/index.js +25 -3
- package/package.json +4 -2
- package/packages/doctor/README.md +106 -0
- package/packages/doctor/__tests__/checks.test.js +187 -0
- package/packages/doctor/__tests__/detect.test.js +101 -0
- package/packages/doctor/__tests__/output.test.js +92 -0
- package/packages/doctor/__tests__/plugins.test.js +111 -0
- package/packages/doctor/__tests__/runner.test.js +131 -0
- package/packages/doctor/package.json +6 -0
- package/packages/doctor/src/checks/hosted-tes.js +109 -0
- package/packages/doctor/src/checks/local-memory.js +290 -0
- package/packages/doctor/src/checks/platform.js +170 -0
- package/packages/doctor/src/checks/universal.js +121 -0
- package/packages/doctor/src/detect.js +102 -0
- package/packages/doctor/src/index.js +33 -0
- package/packages/doctor/src/output.js +55 -0
- package/packages/doctor/src/plugins.js +81 -0
- package/packages/doctor/src/runner.js +136 -0
- package/packages/memory/migrations/005-atomic-memories.sql +16 -0
- package/packages/memory/migrations/006-fix-vector-dim.sql +97 -0
- package/packages/memory/openclaw-plugin/__tests__/chat-turn.test.js +208 -0
- package/packages/memory/openclaw-plugin/__tests__/indicator.test.js +142 -0
- package/packages/memory/openclaw-plugin/__tests__/version-check.test.js +136 -0
- package/packages/memory/openclaw-plugin/index.js +369 -58
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +11 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/distill.test.js +175 -0
- package/packages/memory/src/__tests__/openclaw-chat-turn.test.js +289 -0
- package/packages/memory/src/distill.js +162 -0
- package/packages/memory/src/index.js +1 -0
- package/packages/memory/src/ingest.js +10 -0
- package/packages/memory/src/openclaw/index.js +280 -23
- package/packages/memory/src/openclaw/package.json +1 -1
- package/packages/memory/src/server.js +59 -5
- package/src/normalizer.js +16 -0
- package/src/session.js +21 -2
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distillation tests — unit tests for extractAtomicFacts and distill.
|
|
3
|
+
*
|
|
4
|
+
* Uses mock LLM/embedding clients and an in-memory db fake.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { extractAtomicFacts, distill } from "../distill.js";
|
|
8
|
+
|
|
9
|
+
// --- Mock helpers ---
|
|
10
|
+
|
|
11
|
+
function mockLlm(responseText) {
|
|
12
|
+
return {
|
|
13
|
+
chat: async () => responseText,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mockAi() {
|
|
18
|
+
return {
|
|
19
|
+
embed: async (text) => ({
|
|
20
|
+
embedding: new Array(768).fill(0).map((_, i) => i / 768),
|
|
21
|
+
dimensions: 768,
|
|
22
|
+
model: "mock",
|
|
23
|
+
}),
|
|
24
|
+
chat: async () => "",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function mockDb(overrides = {}) {
|
|
29
|
+
const calls = [];
|
|
30
|
+
const db = async (sql, params) => {
|
|
31
|
+
calls.push({ sql, params });
|
|
32
|
+
// Layer lookup
|
|
33
|
+
if (sql.includes("FROM memory_layers")) {
|
|
34
|
+
return { rows: [{ id: "layer_semantic_id" }] };
|
|
35
|
+
}
|
|
36
|
+
// Handle INSERTs / UPDATEs silently
|
|
37
|
+
return { rows: [] };
|
|
38
|
+
};
|
|
39
|
+
db.calls = calls;
|
|
40
|
+
return db;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- extractAtomicFacts ---
|
|
44
|
+
|
|
45
|
+
describe("extractAtomicFacts", () => {
|
|
46
|
+
it("parses a JSON array response", async () => {
|
|
47
|
+
const llm = mockLlm('["Phil loves steak", "Phil lives in Nantwich"]');
|
|
48
|
+
const facts = await extractAtomicFacts(llm, "I love steak and I live in Nantwich");
|
|
49
|
+
expect(facts).toEqual(["Phil loves steak", "Phil lives in Nantwich"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("strips markdown code fences", async () => {
|
|
53
|
+
const llm = mockLlm('```json\n["Phil likes coffee"]\n```');
|
|
54
|
+
const facts = await extractAtomicFacts(llm, "I like coffee");
|
|
55
|
+
expect(facts).toEqual(["Phil likes coffee"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns empty array when LLM returns empty", async () => {
|
|
59
|
+
const llm = mockLlm("");
|
|
60
|
+
const facts = await extractAtomicFacts(llm, "hi");
|
|
61
|
+
expect(facts).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns empty array when JSON is malformed", async () => {
|
|
65
|
+
const llm = mockLlm("not json at all");
|
|
66
|
+
const facts = await extractAtomicFacts(llm, "some content");
|
|
67
|
+
expect(facts).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns empty array when response is not an array", async () => {
|
|
71
|
+
const llm = mockLlm('{"fact": "Phil likes coffee"}');
|
|
72
|
+
const facts = await extractAtomicFacts(llm, "some content");
|
|
73
|
+
expect(facts).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("filters out non-string entries", async () => {
|
|
77
|
+
const llm = mockLlm('["valid fact", 123, null, "", " ", "another"]');
|
|
78
|
+
const facts = await extractAtomicFacts(llm, "...");
|
|
79
|
+
expect(facts).toEqual(["valid fact", "another"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("trims whitespace", async () => {
|
|
83
|
+
const llm = mockLlm('[" Phil likes tea "]');
|
|
84
|
+
const facts = await extractAtomicFacts(llm, "...");
|
|
85
|
+
expect(facts).toEqual(["Phil likes tea"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("includes userName hint in system prompt when provided", async () => {
|
|
89
|
+
let capturedMessages;
|
|
90
|
+
const llm = {
|
|
91
|
+
chat: async (messages) => {
|
|
92
|
+
capturedMessages = messages;
|
|
93
|
+
return "[]";
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
await extractAtomicFacts(llm, "I like tea", { userName: "Phil" });
|
|
97
|
+
expect(capturedMessages[0].content).toContain("Phil");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// --- distill ---
|
|
102
|
+
|
|
103
|
+
describe("distill", () => {
|
|
104
|
+
it("returns empty array when no facts extracted", async () => {
|
|
105
|
+
const db = mockDb();
|
|
106
|
+
const llm = mockLlm("[]");
|
|
107
|
+
const ai = mockAi();
|
|
108
|
+
const result = await distill(db, ai, llm, "mem_src", "hello", {
|
|
109
|
+
clientId: "test",
|
|
110
|
+
});
|
|
111
|
+
expect(result).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("stores each fact with source_id pointing back to the raw memory", async () => {
|
|
115
|
+
const db = mockDb();
|
|
116
|
+
const llm = mockLlm('["Phil likes coffee", "Phil lives in London"]');
|
|
117
|
+
const ai = mockAi();
|
|
118
|
+
|
|
119
|
+
const result = await distill(db, ai, llm, "mem_raw_123", "...", {
|
|
120
|
+
clientId: "test",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.length).toBe(2);
|
|
124
|
+
|
|
125
|
+
// Find the INSERTs
|
|
126
|
+
const inserts = db.calls.filter((c) =>
|
|
127
|
+
c.sql.includes("INSERT INTO memory_nodes")
|
|
128
|
+
);
|
|
129
|
+
expect(inserts.length).toBe(2);
|
|
130
|
+
|
|
131
|
+
// Each INSERT should include source_id = 'mem_raw_123'
|
|
132
|
+
inserts.forEach((call) => {
|
|
133
|
+
expect(call.params).toContain("mem_raw_123");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("skips when no semantic layer exists for client", async () => {
|
|
138
|
+
const db = async (sql) => {
|
|
139
|
+
if (sql.includes("FROM memory_layers")) return { rows: [] };
|
|
140
|
+
return { rows: [] };
|
|
141
|
+
};
|
|
142
|
+
db.calls = [];
|
|
143
|
+
const llm = mockLlm('["Phil likes tea"]');
|
|
144
|
+
const ai = mockAi();
|
|
145
|
+
|
|
146
|
+
const result = await distill(db, ai, llm, "mem_src", "content", {
|
|
147
|
+
clientId: "test",
|
|
148
|
+
});
|
|
149
|
+
expect(result).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("continues storing remaining facts if one fails", async () => {
|
|
153
|
+
let insertCount = 0;
|
|
154
|
+
const db = async (sql, params) => {
|
|
155
|
+
if (sql.includes("FROM memory_layers")) {
|
|
156
|
+
return { rows: [{ id: "layer_sem" }] };
|
|
157
|
+
}
|
|
158
|
+
if (sql.includes("INSERT INTO memory_nodes")) {
|
|
159
|
+
insertCount++;
|
|
160
|
+
if (insertCount === 1) throw new Error("simulated db failure");
|
|
161
|
+
}
|
|
162
|
+
return { rows: [] };
|
|
163
|
+
};
|
|
164
|
+
const llm = mockLlm('["fact one", "fact two"]');
|
|
165
|
+
const ai = mockAi();
|
|
166
|
+
|
|
167
|
+
const result = await distill(db, ai, llm, "mem_src", "...", {
|
|
168
|
+
clientId: "test",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// First INSERT fails, second succeeds
|
|
172
|
+
expect(result.length).toBe(1);
|
|
173
|
+
expect(result[0].content).toBe("fact two");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
_resetTurnBuffersForTest,
|
|
3
|
+
} from "../openclaw/index.js";
|
|
4
|
+
import plugin from "../openclaw/index.js";
|
|
5
|
+
|
|
6
|
+
const realFetch = globalThis.fetch;
|
|
7
|
+
|
|
8
|
+
function captureFetch() {
|
|
9
|
+
const calls = [];
|
|
10
|
+
globalThis.fetch = async (url, init) => {
|
|
11
|
+
const body = init?.body ? JSON.parse(init.body) : null;
|
|
12
|
+
calls.push({ url, body });
|
|
13
|
+
return {
|
|
14
|
+
ok: true,
|
|
15
|
+
status: 200,
|
|
16
|
+
json: async () => ({ data: { emitEvent: { eventId: "e", success: true } } }),
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
return calls;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
globalThis.fetch = realFetch;
|
|
24
|
+
_resetTurnBuffersForTest();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Build a working hosted engine via the plugin's register(api) hook.
|
|
28
|
+
// The plugin uses api.registerContextEngine(name, factory) — the factory
|
|
29
|
+
// is called once with no args and returns the engine object.
|
|
30
|
+
function makeEngine() {
|
|
31
|
+
let factory;
|
|
32
|
+
plugin.register({
|
|
33
|
+
config: {
|
|
34
|
+
tes_endpoint: "https://x.test",
|
|
35
|
+
tes_client_id: "c",
|
|
36
|
+
tes_api_key: "tes_c_xyz",
|
|
37
|
+
},
|
|
38
|
+
registerTool: () => {},
|
|
39
|
+
registerContextEngine: (_name, fn) => {
|
|
40
|
+
factory = fn;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
if (!factory) {
|
|
44
|
+
throw new Error("plugin did not register a context engine");
|
|
45
|
+
}
|
|
46
|
+
return factory();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("openclaw plugin — hosted CHAT_TURN emission", () => {
|
|
50
|
+
it("emits a CHAT_TURN when an assistant message follows a user message", async () => {
|
|
51
|
+
const calls = captureFetch();
|
|
52
|
+
const engine = makeEngine();
|
|
53
|
+
|
|
54
|
+
await engine.ingest({
|
|
55
|
+
sessionId: "sess-1",
|
|
56
|
+
message: { role: "user", content: "hi" },
|
|
57
|
+
});
|
|
58
|
+
await engine.ingest({
|
|
59
|
+
sessionId: "sess-1",
|
|
60
|
+
message: {
|
|
61
|
+
role: "assistant",
|
|
62
|
+
content: "hello",
|
|
63
|
+
usage: { input_tokens: 12, output_tokens: 8 },
|
|
64
|
+
model: "claude-3-5",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const chatTurnCall = calls.find(
|
|
69
|
+
(c) =>
|
|
70
|
+
c.body?.variables?.moduleId === "conversation-analytics" &&
|
|
71
|
+
c.body?.variables?.input?.eventType === "CHAT_TURN"
|
|
72
|
+
);
|
|
73
|
+
expect(chatTurnCall).toBeDefined();
|
|
74
|
+
const attrs = chatTurnCall.body.variables.input.data.attributes;
|
|
75
|
+
expect(attrs.user_message).toBe("hi");
|
|
76
|
+
expect(attrs.assistant_response).toBe("hello");
|
|
77
|
+
expect(attrs.model).toBe("claude-3-5");
|
|
78
|
+
expect(attrs.usage).toEqual({ input_tokens: 12, output_tokens: 8 });
|
|
79
|
+
expect(attrs.turn_number).toBe(1);
|
|
80
|
+
expect(attrs.source).toBe("openclaw-plugin");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("still emits STORE_MEMORY events alongside CHAT_TURN (existing behaviour preserved)", async () => {
|
|
84
|
+
const calls = captureFetch();
|
|
85
|
+
const engine = makeEngine();
|
|
86
|
+
|
|
87
|
+
await engine.ingest({
|
|
88
|
+
sessionId: "sess-2",
|
|
89
|
+
message: { role: "user", content: "a question" },
|
|
90
|
+
});
|
|
91
|
+
await engine.ingest({
|
|
92
|
+
sessionId: "sess-2",
|
|
93
|
+
message: { role: "assistant", content: "an answer" },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const storeMemoryCalls = calls.filter((c) => {
|
|
97
|
+
const m = c.body?.query;
|
|
98
|
+
return typeof m === "string" && m.includes("createModuleEvent");
|
|
99
|
+
});
|
|
100
|
+
expect(storeMemoryCalls.length).toBeGreaterThan(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("omits usage and tool_calls when the message has no metadata", async () => {
|
|
104
|
+
const calls = captureFetch();
|
|
105
|
+
const engine = makeEngine();
|
|
106
|
+
await engine.ingest({
|
|
107
|
+
sessionId: "sess-3",
|
|
108
|
+
message: { role: "user", content: "hi" },
|
|
109
|
+
});
|
|
110
|
+
await engine.ingest({
|
|
111
|
+
sessionId: "sess-3",
|
|
112
|
+
message: { role: "assistant", content: "ok" },
|
|
113
|
+
});
|
|
114
|
+
const turnCall = calls.find(
|
|
115
|
+
(c) =>
|
|
116
|
+
c.body?.variables?.moduleId === "conversation-analytics" &&
|
|
117
|
+
c.body?.variables?.input?.eventType === "CHAT_TURN"
|
|
118
|
+
);
|
|
119
|
+
expect("usage" in turnCall.body.variables.input.data.attributes).toBe(false);
|
|
120
|
+
expect("tool_calls" in turnCall.body.variables.input.data.attributes).toBe(
|
|
121
|
+
false
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("extracts tool_calls from a wrapped Anthropic raw response", async () => {
|
|
126
|
+
const calls = captureFetch();
|
|
127
|
+
const engine = makeEngine();
|
|
128
|
+
await engine.ingest({
|
|
129
|
+
sessionId: "sess-4",
|
|
130
|
+
message: { role: "user", content: "search" },
|
|
131
|
+
});
|
|
132
|
+
await engine.ingest({
|
|
133
|
+
sessionId: "sess-4",
|
|
134
|
+
message: {
|
|
135
|
+
role: "assistant",
|
|
136
|
+
content: "looking",
|
|
137
|
+
raw: {
|
|
138
|
+
content: [
|
|
139
|
+
{ type: "text", text: "looking" },
|
|
140
|
+
{ type: "tool_use", name: "search", input: { q: "shoes" } },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const attrs = calls.find(
|
|
146
|
+
(c) =>
|
|
147
|
+
c.body?.variables?.moduleId === "conversation-analytics" &&
|
|
148
|
+
c.body?.variables?.input?.eventType === "CHAT_TURN"
|
|
149
|
+
).body.variables.input.data.attributes;
|
|
150
|
+
expect(attrs.tool_calls).toEqual([
|
|
151
|
+
{ tool: "search", args: { q: "shoes" } },
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("increments turn_number per buffered user message in the same session", async () => {
|
|
156
|
+
const calls = captureFetch();
|
|
157
|
+
const engine = makeEngine();
|
|
158
|
+
for (let i = 0; i < 3; i++) {
|
|
159
|
+
await engine.ingest({
|
|
160
|
+
sessionId: "sess-5",
|
|
161
|
+
message: { role: "user", content: `q${i}` },
|
|
162
|
+
});
|
|
163
|
+
await engine.ingest({
|
|
164
|
+
sessionId: "sess-5",
|
|
165
|
+
message: { role: "assistant", content: `a${i}` },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const turns = calls.filter(
|
|
169
|
+
(c) =>
|
|
170
|
+
c.body?.variables?.moduleId === "conversation-analytics" &&
|
|
171
|
+
c.body?.variables?.input?.eventType === "CHAT_TURN"
|
|
172
|
+
);
|
|
173
|
+
expect(turns.map((t) => t.body.variables.input.data.attributes.turn_number)).toEqual(
|
|
174
|
+
[1, 2, 3]
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("emits even when assistant arrives with no buffered user message", async () => {
|
|
179
|
+
const calls = captureFetch();
|
|
180
|
+
const engine = makeEngine();
|
|
181
|
+
await engine.ingest({
|
|
182
|
+
sessionId: "sess-6",
|
|
183
|
+
message: { role: "assistant", content: "hi without prompt" },
|
|
184
|
+
});
|
|
185
|
+
const turn = calls.find(
|
|
186
|
+
(c) =>
|
|
187
|
+
c.body?.variables?.moduleId === "conversation-analytics" &&
|
|
188
|
+
c.body?.variables?.input?.eventType === "CHAT_TURN"
|
|
189
|
+
);
|
|
190
|
+
expect(turn).toBeDefined();
|
|
191
|
+
expect(turn.body.variables.input.data.attributes.user_message).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Regression: OpenClaw passes content as an array of content blocks
|
|
195
|
+
// (e.g. [{type: "text", text: "..."}]) rather than a plain string.
|
|
196
|
+
// The earlier String(message.content) approach produced "[object Object]"
|
|
197
|
+
// for every Telegram-sourced turn.
|
|
198
|
+
it("handles content-block arrays from the OpenClaw runtime", async () => {
|
|
199
|
+
const calls = captureFetch();
|
|
200
|
+
const engine = makeEngine();
|
|
201
|
+
|
|
202
|
+
await engine.ingest({
|
|
203
|
+
sessionId: "sess-blocks",
|
|
204
|
+
message: {
|
|
205
|
+
role: "user",
|
|
206
|
+
content: [{ type: "text", text: "what food do i like?" }],
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
await engine.ingest({
|
|
210
|
+
sessionId: "sess-blocks",
|
|
211
|
+
message: {
|
|
212
|
+
role: "assistant",
|
|
213
|
+
content: [{ type: "text", text: "you like steak" }],
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const attrs = calls.find(
|
|
218
|
+
(c) =>
|
|
219
|
+
c.body?.variables?.moduleId === "conversation-analytics" &&
|
|
220
|
+
c.body?.variables?.input?.eventType === "CHAT_TURN"
|
|
221
|
+
).body.variables.input.data.attributes;
|
|
222
|
+
expect(attrs.user_message).toBe("what food do i like?");
|
|
223
|
+
expect(attrs.assistant_response).toBe("you like steak");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Regression: OpenClaw wraps real user messages from external channels
|
|
227
|
+
// (Telegram, etc.) in "Conversation info (untrusted metadata)" envelopes
|
|
228
|
+
// with JSON blocks + the actual user text appended. The emit should
|
|
229
|
+
// contain just the user's real words, not the JSON wrapper.
|
|
230
|
+
it("strips OpenClaw metadata envelopes from user messages", async () => {
|
|
231
|
+
const calls = captureFetch();
|
|
232
|
+
const engine = makeEngine();
|
|
233
|
+
|
|
234
|
+
const envelope = [
|
|
235
|
+
"Conversation info (untrusted metadata):",
|
|
236
|
+
"```json",
|
|
237
|
+
JSON.stringify({ message_id: "42", sender: "Phil" }, null, 2),
|
|
238
|
+
"```",
|
|
239
|
+
"",
|
|
240
|
+
"remember that i love cheese",
|
|
241
|
+
].join("\n");
|
|
242
|
+
|
|
243
|
+
await engine.ingest({
|
|
244
|
+
sessionId: "sess-envelope",
|
|
245
|
+
message: { role: "user", content: envelope },
|
|
246
|
+
});
|
|
247
|
+
await engine.ingest({
|
|
248
|
+
sessionId: "sess-envelope",
|
|
249
|
+
message: { role: "assistant", content: "noted" },
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const attrs = calls.find(
|
|
253
|
+
(c) =>
|
|
254
|
+
c.body?.variables?.moduleId === "conversation-analytics" &&
|
|
255
|
+
c.body?.variables?.input?.eventType === "CHAT_TURN"
|
|
256
|
+
).body.variables.input.data.attributes;
|
|
257
|
+
expect(attrs.user_message).toBe("remember that i love cheese");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Newer OpenClaw runtimes call afterTurn instead of ingest. The plugin
|
|
261
|
+
// should behave identically via both paths.
|
|
262
|
+
it("emits CHAT_TURN via afterTurn when the runtime uses that hook", async () => {
|
|
263
|
+
const calls = captureFetch();
|
|
264
|
+
const engine = makeEngine();
|
|
265
|
+
|
|
266
|
+
// Simulate afterTurn: messages is the full conversation, prePromptMessageCount
|
|
267
|
+
// is where the pre-turn history ended.
|
|
268
|
+
await engine.afterTurn({
|
|
269
|
+
sessionId: "sess-afterTurn",
|
|
270
|
+
messages: [
|
|
271
|
+
{ role: "user", content: "hello" },
|
|
272
|
+
{ role: "assistant", content: "hi there", model: "claude" },
|
|
273
|
+
],
|
|
274
|
+
prePromptMessageCount: 0,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const turn = calls.find(
|
|
278
|
+
(c) =>
|
|
279
|
+
c.body?.variables?.moduleId === "conversation-analytics" &&
|
|
280
|
+
c.body?.variables?.input?.eventType === "CHAT_TURN"
|
|
281
|
+
);
|
|
282
|
+
expect(turn).toBeDefined();
|
|
283
|
+
const attrs = turn.body.variables.input.data.attributes;
|
|
284
|
+
expect(attrs.user_message).toBe("hello");
|
|
285
|
+
expect(attrs.assistant_response).toBe("hi there");
|
|
286
|
+
expect(attrs.model).toBe("claude");
|
|
287
|
+
expect(attrs.turn_number).toBe(1);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distilled memory — extract atomic facts from raw content.
|
|
3
|
+
*
|
|
4
|
+
* Each turn can contain multiple distinct facts. Distilling them into
|
|
5
|
+
* standalone atoms makes semantic retrieval more precise: searching for
|
|
6
|
+
* "what does Phil drink?" matches "Phil drinks cortado" better than a
|
|
7
|
+
* mixed paragraph covering food, drinks, and hobbies.
|
|
8
|
+
*
|
|
9
|
+
* Atoms are stored in the semantic layer with source_id pointing back
|
|
10
|
+
* to the raw memory in episodic.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { generateHypotheticalQueries } from "./ingest.js";
|
|
14
|
+
|
|
15
|
+
const EXTRACTION_PROMPT = `You extract atomic facts from conversations.
|
|
16
|
+
|
|
17
|
+
Rules:
|
|
18
|
+
- Only extract facts the user has explicitly stated about themselves, their preferences, decisions, relationships, or world.
|
|
19
|
+
- Each fact must be a single standalone statement (no "and", "or", no lists).
|
|
20
|
+
- Decontextualize: replace "I" / "my" with the user's name or role if known, otherwise use "the user".
|
|
21
|
+
- Reject questions, jokes, small talk, meta-discussion, and speculation.
|
|
22
|
+
- Reject facts about the AI or the current task.
|
|
23
|
+
- If nothing qualifies, return an empty array.
|
|
24
|
+
|
|
25
|
+
Output a JSON array of strings. No explanation, no markdown fences. Just the JSON array.`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract atomic facts from content using the LLM.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} llm - LLM client with chat() method
|
|
31
|
+
* @param {string} content - Raw content to distil
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {string} [opts.userName] - User's name for decontextualization
|
|
34
|
+
* @returns {Promise<string[]>} Array of atomic fact strings
|
|
35
|
+
*/
|
|
36
|
+
export async function extractAtomicFacts(llm, content, opts = {}) {
|
|
37
|
+
const userHint = opts.userName
|
|
38
|
+
? `\nThe user's name is ${opts.userName}.`
|
|
39
|
+
: "";
|
|
40
|
+
const text = await llm.chat(
|
|
41
|
+
[
|
|
42
|
+
{ role: "system", content: EXTRACTION_PROMPT + userHint },
|
|
43
|
+
{ role: "user", content: content.substring(0, 4000) },
|
|
44
|
+
],
|
|
45
|
+
{ maxTokens: 500, temperature: 0 }
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (!text) return [];
|
|
49
|
+
|
|
50
|
+
// Try to parse as JSON array. Be lenient about markdown fences.
|
|
51
|
+
let jsonText = text.trim();
|
|
52
|
+
const fenceMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
53
|
+
if (fenceMatch) jsonText = fenceMatch[1].trim();
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(jsonText);
|
|
57
|
+
if (!Array.isArray(parsed)) return [];
|
|
58
|
+
return parsed
|
|
59
|
+
.filter((f) => typeof f === "string" && f.trim().length > 0)
|
|
60
|
+
.map((f) => f.trim());
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Distill a raw memory into atomic facts and store each as a separate
|
|
68
|
+
* memory node in the semantic layer, linked via source_id.
|
|
69
|
+
*
|
|
70
|
+
* Fire-and-forget: call this without awaiting to avoid blocking ingest.
|
|
71
|
+
*
|
|
72
|
+
* @param {Function} db - Database query function
|
|
73
|
+
* @param {object} ai - Embedding client
|
|
74
|
+
* @param {object} llm - Chat client for extraction + HyDE
|
|
75
|
+
* @param {string} sourceId - The raw memory ID this distillation derives from
|
|
76
|
+
* @param {string} content - The raw content
|
|
77
|
+
* @param {object} opts
|
|
78
|
+
* @param {string} opts.clientId
|
|
79
|
+
* @param {string} [opts.userId]
|
|
80
|
+
* @param {string} [opts.userName]
|
|
81
|
+
* @param {Function} [opts.logger]
|
|
82
|
+
* @returns {Promise<Array<{id: string, content: string}>>}
|
|
83
|
+
*/
|
|
84
|
+
export async function distill(db, ai, llm, sourceId, content, opts = {}) {
|
|
85
|
+
const clientId = opts.clientId;
|
|
86
|
+
const log = opts.logger || (() => {});
|
|
87
|
+
|
|
88
|
+
const facts = await extractAtomicFacts(llm, content, opts);
|
|
89
|
+
if (!facts.length) {
|
|
90
|
+
log(`distill: no facts extracted from ${sourceId}`);
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolve semantic layer ID (create the atoms there, not in episodic)
|
|
95
|
+
const layerResult = await db(
|
|
96
|
+
`SELECT id FROM memory_layers
|
|
97
|
+
WHERE client_id = $1 AND name = 'semantic' AND is_active = TRUE
|
|
98
|
+
LIMIT 1`,
|
|
99
|
+
[clientId]
|
|
100
|
+
);
|
|
101
|
+
if (!layerResult.rows?.length) {
|
|
102
|
+
log(`distill: no semantic layer for client ${clientId}`);
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const layerId = layerResult.rows[0].id;
|
|
106
|
+
|
|
107
|
+
const stored = [];
|
|
108
|
+
for (const fact of facts) {
|
|
109
|
+
try {
|
|
110
|
+
const atomId = `mem_${crypto.randomUUID()}`;
|
|
111
|
+
|
|
112
|
+
// Insert the atom linked to its source
|
|
113
|
+
await db(
|
|
114
|
+
`INSERT INTO memory_nodes (id, client_id, layer_id, source_id, content, metadata, user_id, confidence, decay_rate, access_count)
|
|
115
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, 1.0, 0.05, 0)`,
|
|
116
|
+
[
|
|
117
|
+
atomId,
|
|
118
|
+
clientId,
|
|
119
|
+
layerId,
|
|
120
|
+
sourceId,
|
|
121
|
+
fact,
|
|
122
|
+
JSON.stringify({ distilled_from: sourceId }),
|
|
123
|
+
opts.userId || null,
|
|
124
|
+
]
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Embed the atom (non-fatal)
|
|
128
|
+
try {
|
|
129
|
+
const embResult = await ai.embed(fact, "passage");
|
|
130
|
+
if (embResult?.embedding) {
|
|
131
|
+
await db(
|
|
132
|
+
`UPDATE memory_nodes SET embedding = $1, updated_at = NOW() WHERE id = $2`,
|
|
133
|
+
[JSON.stringify(embResult.embedding), atomId]
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
log(`distill: embedding failed for ${atomId}: ${err.message}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// HyDE (2 queries for atoms — they're already focused)
|
|
141
|
+
try {
|
|
142
|
+
const queries = await generateHypotheticalQueries(llm, fact);
|
|
143
|
+
const trimmed = queries.slice(0, 2);
|
|
144
|
+
if (trimmed.length) {
|
|
145
|
+
await db(
|
|
146
|
+
`UPDATE memory_nodes SET metadata = jsonb_set(COALESCE(metadata, '{}')::jsonb, '{hypothetical_queries}', $1::jsonb), updated_at = NOW() WHERE id = $2`,
|
|
147
|
+
[JSON.stringify(trimmed), atomId]
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
log(`distill: HyDE failed for ${atomId}: ${err.message}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
stored.push({ id: atomId, content: fact });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
log(`distill: failed to store fact "${fact.substring(0, 40)}": ${err.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
log(`distill: ${stored.length}/${facts.length} atoms stored from ${sourceId}`);
|
|
161
|
+
return stored;
|
|
162
|
+
}
|
|
@@ -128,6 +128,7 @@ function normalizeDb(db, schema) {
|
|
|
128
128
|
export { createAIClient } from "./ai.js";
|
|
129
129
|
export { search, textSearch } from "./search.js";
|
|
130
130
|
export { ingest, generateHypotheticalQueries } from "./ingest.js";
|
|
131
|
+
export { distill, extractAtomicFacts } from "./distill.js";
|
|
131
132
|
export { decay } from "./decay.js";
|
|
132
133
|
export { consolidate } from "./consolidate.js";
|
|
133
134
|
export { ensureLayers, getLayers } from "./layers.js";
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Memory ingestion — store content, generate embedding, generate HyDE queries.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { distill } from "./distill.js";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Ingest content as a new memory node.
|
|
7
9
|
*
|
|
@@ -81,6 +83,14 @@ export async function ingest(db, ai, llm, content, opts = {}) {
|
|
|
81
83
|
log(`HyDE failed for ${memoryId}: ${err.message}`);
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// Distill atomic facts in the background — only for raw ingestions
|
|
87
|
+
// (skip if this call is already storing a distilled atom or user opted out).
|
|
88
|
+
if (opts.distill !== false && !opts.sourceId) {
|
|
89
|
+
distill(db, ai, llm, memoryId, content, { ...opts, logger: log }).catch(
|
|
90
|
+
(err) => log(`distill failed for ${memoryId}: ${err.message}`)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
return { id: memoryId, content, layerId };
|
|
85
95
|
}
|
|
86
96
|
|