memory-braid 0.2.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 +300 -0
- package/openclaw.plugin.json +129 -0
- package/package.json +46 -0
- package/src/bootstrap.ts +82 -0
- package/src/chunking.ts +280 -0
- package/src/config.ts +271 -0
- package/src/dedupe.ts +96 -0
- package/src/extract.ts +351 -0
- package/src/index.ts +489 -0
- package/src/local-memory.ts +128 -0
- package/src/logger.ts +128 -0
- package/src/mem0-client.ts +605 -0
- package/src/merge.ts +56 -0
- package/src/reconcile.ts +278 -0
- package/src/state.ts +130 -0
- package/src/types.ts +96 -0
package/src/extract.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { normalizeForHash, normalizeWhitespace, sha256 } from "./chunking.js";
|
|
2
|
+
import type { MemoryBraidConfig } from "./config.js";
|
|
3
|
+
import { MemoryBraidLogger } from "./logger.js";
|
|
4
|
+
import type { ExtractedCandidate } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const HEURISTIC_PATTERNS = [
|
|
7
|
+
/remember|remember that|keep in mind|note that/i,
|
|
8
|
+
/i prefer|prefer to|don't like|do not like|hate|love/i,
|
|
9
|
+
/we decided|decision|let's use|we will use/i,
|
|
10
|
+
/my name is|i am|contact me at|email is|phone is/i,
|
|
11
|
+
/deadline|due date|todo|action item|follow up/i,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function extractMessageText(content: unknown): string {
|
|
15
|
+
if (typeof content === "string") {
|
|
16
|
+
return normalizeWhitespace(content);
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(content)) {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
const parts: string[] = [];
|
|
22
|
+
for (const block of content) {
|
|
23
|
+
if (!block || typeof block !== "object") {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const item = block as { type?: unknown; text?: unknown };
|
|
27
|
+
if (item.type === "text" && typeof item.text === "string") {
|
|
28
|
+
const normalized = normalizeWhitespace(item.text);
|
|
29
|
+
if (normalized) {
|
|
30
|
+
parts.push(normalized);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return parts.join(" ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeMessages(messages: unknown[]): Array<{ role: string; text: string }> {
|
|
38
|
+
const out: Array<{ role: string; text: string }> = [];
|
|
39
|
+
for (const entry of messages) {
|
|
40
|
+
if (!entry || typeof entry !== "object") {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const direct = entry as { role?: unknown; content?: unknown };
|
|
45
|
+
if (typeof direct.role === "string") {
|
|
46
|
+
const text = extractMessageText(direct.content);
|
|
47
|
+
if (text) {
|
|
48
|
+
out.push({ role: direct.role, text });
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const wrapped = entry as { message?: { role?: unknown; content?: unknown } };
|
|
54
|
+
if (wrapped.message && typeof wrapped.message.role === "string") {
|
|
55
|
+
const text = extractMessageText(wrapped.message.content);
|
|
56
|
+
if (text) {
|
|
57
|
+
out.push({ role: wrapped.message.role, text });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function scoreHeuristic(text: string): number {
|
|
65
|
+
let score = 0;
|
|
66
|
+
for (const pattern of HEURISTIC_PATTERNS) {
|
|
67
|
+
if (pattern.test(text)) {
|
|
68
|
+
score += 0.25;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (text.length > 40) {
|
|
72
|
+
score += 0.15;
|
|
73
|
+
}
|
|
74
|
+
return Math.min(1, score);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function classifyCategory(text: string): ExtractedCandidate["category"] {
|
|
78
|
+
if (/prefer|like|love|hate|don't like|do not like/i.test(text)) {
|
|
79
|
+
return "preference";
|
|
80
|
+
}
|
|
81
|
+
if (/we decided|decision|let's use|we will use/i.test(text)) {
|
|
82
|
+
return "decision";
|
|
83
|
+
}
|
|
84
|
+
if (/todo|action item|follow up|deadline|due date/i.test(text)) {
|
|
85
|
+
return "task";
|
|
86
|
+
}
|
|
87
|
+
if (/my name is|contact|email|phone|address|timezone/i.test(text)) {
|
|
88
|
+
return "fact";
|
|
89
|
+
}
|
|
90
|
+
return "other";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pickHeuristicCandidates(
|
|
94
|
+
messages: Array<{ role: string; text: string }>,
|
|
95
|
+
maxItems: number,
|
|
96
|
+
): ExtractedCandidate[] {
|
|
97
|
+
const out: ExtractedCandidate[] = [];
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
|
|
100
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
101
|
+
const message = messages[i];
|
|
102
|
+
if (!message || (message.role !== "user" && message.role !== "assistant")) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (message.text.length < 20 || message.text.length > 3000) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const score = scoreHeuristic(message.text);
|
|
110
|
+
if (score < 0.2) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const key = sha256(normalizeForHash(message.text));
|
|
115
|
+
if (seen.has(key)) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
seen.add(key);
|
|
119
|
+
out.push({
|
|
120
|
+
text: message.text,
|
|
121
|
+
category: classifyCategory(message.text),
|
|
122
|
+
score,
|
|
123
|
+
source: "heuristic",
|
|
124
|
+
});
|
|
125
|
+
if (out.length >= maxItems) {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseJsonObjectArray(raw: string): Array<Record<string, unknown>> {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
136
|
+
if (!Array.isArray(parsed)) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
return parsed.filter((entry) => entry && typeof entry === "object") as Array<
|
|
140
|
+
Record<string, unknown>
|
|
141
|
+
>;
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function callMlEnrichment(params: {
|
|
148
|
+
provider: "openai" | "anthropic" | "gemini";
|
|
149
|
+
model: string;
|
|
150
|
+
timeoutMs: number;
|
|
151
|
+
candidates: ExtractedCandidate[];
|
|
152
|
+
}): Promise<Array<Record<string, unknown>>> {
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
155
|
+
|
|
156
|
+
const prompt = [
|
|
157
|
+
"Classify the memory candidates.",
|
|
158
|
+
"Return ONLY JSON array.",
|
|
159
|
+
"Each item: {index:number, keep:boolean, category:string, score:number}.",
|
|
160
|
+
"Category one of: preference, decision, fact, task, other.",
|
|
161
|
+
JSON.stringify(params.candidates.map((candidate, index) => ({ index, text: candidate.text }))),
|
|
162
|
+
].join("\n");
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
if (params.provider === "openai") {
|
|
166
|
+
const key = process.env.OPENAI_API_KEY;
|
|
167
|
+
if (!key) {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: {
|
|
173
|
+
Authorization: `Bearer ${key}`,
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
model: params.model,
|
|
178
|
+
temperature: 0,
|
|
179
|
+
messages: [
|
|
180
|
+
{
|
|
181
|
+
role: "system",
|
|
182
|
+
content: "You return strict JSON only.",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
role: "user",
|
|
186
|
+
content: prompt,
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
}),
|
|
190
|
+
signal: controller.signal,
|
|
191
|
+
});
|
|
192
|
+
const data = (await response.json()) as {
|
|
193
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
194
|
+
};
|
|
195
|
+
const content = data.choices?.[0]?.message?.content ?? "";
|
|
196
|
+
return parseJsonObjectArray(content);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (params.provider === "anthropic") {
|
|
200
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
201
|
+
if (!key) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"x-api-key": key,
|
|
208
|
+
"anthropic-version": "2023-06-01",
|
|
209
|
+
"Content-Type": "application/json",
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
model: params.model,
|
|
213
|
+
max_tokens: 1000,
|
|
214
|
+
temperature: 0,
|
|
215
|
+
messages: [{ role: "user", content: prompt }],
|
|
216
|
+
}),
|
|
217
|
+
signal: controller.signal,
|
|
218
|
+
});
|
|
219
|
+
const data = (await response.json()) as {
|
|
220
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
221
|
+
};
|
|
222
|
+
const text = data.content?.find((item) => item.type === "text")?.text ?? "";
|
|
223
|
+
return parseJsonObjectArray(text);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const key = process.env.GEMINI_API_KEY;
|
|
227
|
+
if (!key) {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
const response = await fetch(
|
|
231
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(params.model)}:generateContent?key=${encodeURIComponent(key)}`,
|
|
232
|
+
{
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: {
|
|
235
|
+
"Content-Type": "application/json",
|
|
236
|
+
},
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
generationConfig: { temperature: 0 },
|
|
239
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
240
|
+
}),
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
const data = (await response.json()) as {
|
|
245
|
+
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
|
|
246
|
+
};
|
|
247
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
|
248
|
+
return parseJsonObjectArray(text);
|
|
249
|
+
} finally {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function applyMlResult(
|
|
255
|
+
candidates: ExtractedCandidate[],
|
|
256
|
+
result: Array<Record<string, unknown>>,
|
|
257
|
+
): ExtractedCandidate[] {
|
|
258
|
+
if (result.length === 0) {
|
|
259
|
+
return candidates;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const byIndex = new Map<number, Record<string, unknown>>();
|
|
263
|
+
for (const item of result) {
|
|
264
|
+
const index = typeof item.index === "number" ? item.index : -1;
|
|
265
|
+
if (index >= 0) {
|
|
266
|
+
byIndex.set(index, item);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const out: ExtractedCandidate[] = [];
|
|
271
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
272
|
+
const candidate = candidates[i];
|
|
273
|
+
if (!candidate) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const ml = byIndex.get(i);
|
|
277
|
+
if (!ml) {
|
|
278
|
+
out.push(candidate);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const keep = typeof ml.keep === "boolean" ? ml.keep : true;
|
|
282
|
+
if (!keep) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const category =
|
|
286
|
+
ml.category === "preference" ||
|
|
287
|
+
ml.category === "decision" ||
|
|
288
|
+
ml.category === "fact" ||
|
|
289
|
+
ml.category === "task" ||
|
|
290
|
+
ml.category === "other"
|
|
291
|
+
? (ml.category as ExtractedCandidate["category"])
|
|
292
|
+
: candidate.category;
|
|
293
|
+
const score = typeof ml.score === "number" ? Math.max(0, Math.min(1, ml.score)) : candidate.score;
|
|
294
|
+
out.push({
|
|
295
|
+
...candidate,
|
|
296
|
+
category,
|
|
297
|
+
score,
|
|
298
|
+
source: "ml",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function extractCandidates(params: {
|
|
305
|
+
messages: unknown[];
|
|
306
|
+
cfg: MemoryBraidConfig;
|
|
307
|
+
log: MemoryBraidLogger;
|
|
308
|
+
runId?: string;
|
|
309
|
+
}): Promise<ExtractedCandidate[]> {
|
|
310
|
+
const normalized = normalizeMessages(params.messages);
|
|
311
|
+
const heuristic = pickHeuristicCandidates(normalized, params.cfg.capture.ml.maxItemsPerRun);
|
|
312
|
+
|
|
313
|
+
params.log.debug("memory_braid.capture.extract", {
|
|
314
|
+
runId: params.runId,
|
|
315
|
+
totalMessages: normalized.length,
|
|
316
|
+
heuristicCandidates: heuristic.length,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (
|
|
320
|
+
params.cfg.capture.extraction.mode !== "heuristic_plus_ml" ||
|
|
321
|
+
!params.cfg.capture.ml.provider ||
|
|
322
|
+
!params.cfg.capture.ml.model
|
|
323
|
+
) {
|
|
324
|
+
return heuristic;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const ml = await callMlEnrichment({
|
|
329
|
+
provider: params.cfg.capture.ml.provider,
|
|
330
|
+
model: params.cfg.capture.ml.model,
|
|
331
|
+
timeoutMs: params.cfg.capture.ml.timeoutMs,
|
|
332
|
+
candidates: heuristic,
|
|
333
|
+
});
|
|
334
|
+
const enriched = applyMlResult(heuristic, ml);
|
|
335
|
+
params.log.debug("memory_braid.capture.ml", {
|
|
336
|
+
runId: params.runId,
|
|
337
|
+
provider: params.cfg.capture.ml.provider,
|
|
338
|
+
model: params.cfg.capture.ml.model,
|
|
339
|
+
requested: heuristic.length,
|
|
340
|
+
returned: ml.length,
|
|
341
|
+
enriched: enriched.length,
|
|
342
|
+
});
|
|
343
|
+
return enriched;
|
|
344
|
+
} catch (err) {
|
|
345
|
+
params.log.warn("memory_braid.capture.ml", {
|
|
346
|
+
runId: params.runId,
|
|
347
|
+
error: err instanceof Error ? err.message : String(err),
|
|
348
|
+
});
|
|
349
|
+
return heuristic;
|
|
350
|
+
}
|
|
351
|
+
}
|