memory-braid 0.4.3 → 0.4.5
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/package.json +1 -1
- package/src/entities.ts +67 -5
- package/src/extract.ts +45 -1
- package/src/index.ts +142 -76
- package/src/mem0-client.ts +60 -7
- package/src/state.ts +2 -2
package/package.json
CHANGED
package/src/entities.ts
CHANGED
|
@@ -89,6 +89,20 @@ type NormalizedEntityToken = {
|
|
|
89
89
|
end?: number;
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
const ENTITY_CONNECTOR_WORDS = new Set([
|
|
93
|
+
"and",
|
|
94
|
+
"da",
|
|
95
|
+
"de",
|
|
96
|
+
"del",
|
|
97
|
+
"la",
|
|
98
|
+
"las",
|
|
99
|
+
"los",
|
|
100
|
+
"of",
|
|
101
|
+
"the",
|
|
102
|
+
"y",
|
|
103
|
+
]);
|
|
104
|
+
const ENTITY_MAX_MERGED_WORDS = 3;
|
|
105
|
+
|
|
92
106
|
function asFiniteNumber(value: unknown): number | undefined {
|
|
93
107
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
94
108
|
return undefined;
|
|
@@ -96,6 +110,21 @@ function asFiniteNumber(value: unknown): number | undefined {
|
|
|
96
110
|
return value;
|
|
97
111
|
}
|
|
98
112
|
|
|
113
|
+
function splitEntityWords(text: string): string[] {
|
|
114
|
+
return text.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isLikelyNoisyShortWord(word: string): boolean {
|
|
118
|
+
const normalized = word.toLowerCase();
|
|
119
|
+
if (normalized.length >= 3) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (ENTITY_CONNECTOR_WORDS.has(normalized)) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return !/^[A-Z]\.?$/.test(word);
|
|
126
|
+
}
|
|
127
|
+
|
|
99
128
|
function joinEntityText(left: NormalizedEntityToken, right: NormalizedEntityToken): string {
|
|
100
129
|
const leftEnd = left.end;
|
|
101
130
|
const rightStart = right.start;
|
|
@@ -108,11 +137,32 @@ function joinEntityText(left: NormalizedEntityToken, right: NormalizedEntityToke
|
|
|
108
137
|
return `${left.text} ${right.text}`;
|
|
109
138
|
}
|
|
110
139
|
|
|
111
|
-
function shouldMergeEntityTokens(
|
|
140
|
+
function shouldMergeEntityTokens(
|
|
141
|
+
left: NormalizedEntityToken,
|
|
142
|
+
right: NormalizedEntityToken,
|
|
143
|
+
sourceText?: string,
|
|
144
|
+
): boolean {
|
|
112
145
|
if (left.type !== right.type || !left.text || !right.text) {
|
|
113
146
|
return false;
|
|
114
147
|
}
|
|
115
148
|
|
|
149
|
+
const leftWords = splitEntityWords(left.text);
|
|
150
|
+
const rightWords = splitEntityWords(right.text);
|
|
151
|
+
if (leftWords.length === 0 || rightWords.length === 0) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (leftWords.length + rightWords.length > ENTITY_MAX_MERGED_WORDS) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const leftLastWord = leftWords[leftWords.length - 1];
|
|
158
|
+
const rightFirstWord = rightWords[0];
|
|
159
|
+
if (!leftLastWord || !rightFirstWord) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
if (isLikelyNoisyShortWord(leftLastWord) || isLikelyNoisyShortWord(rightFirstWord)) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
116
166
|
const leftEnd = left.end;
|
|
117
167
|
const rightStart = right.start;
|
|
118
168
|
if (typeof leftEnd === "number" && typeof rightStart === "number") {
|
|
@@ -120,7 +170,16 @@ function shouldMergeEntityTokens(left: NormalizedEntityToken, right: NormalizedE
|
|
|
120
170
|
if (gap < 0) {
|
|
121
171
|
return false;
|
|
122
172
|
}
|
|
123
|
-
|
|
173
|
+
if (gap > 1) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (sourceText && gap > 0) {
|
|
177
|
+
const between = sourceText.slice(leftEnd, rightStart);
|
|
178
|
+
if (between && /[^\s]/u.test(between)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
124
183
|
}
|
|
125
184
|
|
|
126
185
|
if (/[.,!?;:]$/.test(left.text) || /^[.,!?;:]/.test(right.text)) {
|
|
@@ -129,7 +188,10 @@ function shouldMergeEntityTokens(left: NormalizedEntityToken, right: NormalizedE
|
|
|
129
188
|
return true;
|
|
130
189
|
}
|
|
131
190
|
|
|
132
|
-
function collapseAdjacentEntityTokens(
|
|
191
|
+
function collapseAdjacentEntityTokens(
|
|
192
|
+
tokens: NormalizedEntityToken[],
|
|
193
|
+
sourceText?: string,
|
|
194
|
+
): NormalizedEntityToken[] {
|
|
133
195
|
if (tokens.length <= 1) {
|
|
134
196
|
return tokens;
|
|
135
197
|
}
|
|
@@ -137,7 +199,7 @@ function collapseAdjacentEntityTokens(tokens: NormalizedEntityToken[]): Normaliz
|
|
|
137
199
|
const collapsed: NormalizedEntityToken[] = [];
|
|
138
200
|
for (const token of tokens) {
|
|
139
201
|
const previous = collapsed[collapsed.length - 1];
|
|
140
|
-
if (!previous || !shouldMergeEntityTokens(previous, token)) {
|
|
202
|
+
if (!previous || !shouldMergeEntityTokens(previous, token, sourceText)) {
|
|
141
203
|
collapsed.push({ ...token });
|
|
142
204
|
continue;
|
|
143
205
|
}
|
|
@@ -416,7 +478,7 @@ export class EntityExtractionManager {
|
|
|
416
478
|
});
|
|
417
479
|
}
|
|
418
480
|
|
|
419
|
-
const collapsed = collapseAdjacentEntityTokens(normalized);
|
|
481
|
+
const collapsed = collapseAdjacentEntityTokens(normalized, params.text);
|
|
420
482
|
const deduped = new Map<string, ExtractedEntity>();
|
|
421
483
|
for (const token of collapsed) {
|
|
422
484
|
const canonicalUri = buildCanonicalEntityUri(token.type, token.text);
|
package/src/extract.ts
CHANGED
|
@@ -12,6 +12,34 @@ const HEURISTIC_PATTERNS = [
|
|
|
12
12
|
/my name is|i am|contact me at|email is|phone is/i,
|
|
13
13
|
/deadline|due date|todo|action item|follow up/i,
|
|
14
14
|
];
|
|
15
|
+
const HEURISTIC_LOOKBACK_MULTIPLIER = 4;
|
|
16
|
+
const HEURISTIC_MIN_LOOKBACK_MESSAGES = 12;
|
|
17
|
+
const FEED_TAG_PATTERN = /\[(?:n8n|rss|alert|news|cron|slack|discord|telegram|email|github|jira)[^[]*]/i;
|
|
18
|
+
const ROLE_LABEL_PATTERN = /\b(?:assistant|system|tool|developer)\s*:/gi;
|
|
19
|
+
|
|
20
|
+
function isLikelyFeedOrImportedText(text: string): boolean {
|
|
21
|
+
if (FEED_TAG_PATTERN.test(text)) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const roleLabels = text.match(ROLE_LABEL_PATTERN)?.length ?? 0;
|
|
26
|
+
if (roleLabels >= 2) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const lines = text
|
|
31
|
+
.split(/\r?\n+/)
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
if (lines.length === 0) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const rolePrefixedLines = lines.filter((line) =>
|
|
39
|
+
/^(assistant|system|tool|developer|user)\s*:/i.test(line),
|
|
40
|
+
).length;
|
|
41
|
+
return rolePrefixedLines >= 2;
|
|
42
|
+
}
|
|
15
43
|
|
|
16
44
|
function extractMessageText(content: unknown): string {
|
|
17
45
|
if (typeof content === "string") {
|
|
@@ -98,8 +126,10 @@ function pickHeuristicCandidates(
|
|
|
98
126
|
): ExtractedCandidate[] {
|
|
99
127
|
const out: ExtractedCandidate[] = [];
|
|
100
128
|
const seen = new Set<string>();
|
|
129
|
+
const lookback = Math.max(HEURISTIC_MIN_LOOKBACK_MESSAGES, maxItems * HEURISTIC_LOOKBACK_MULTIPLIER);
|
|
130
|
+
const startIndex = Math.max(0, messages.length - lookback);
|
|
101
131
|
|
|
102
|
-
for (let i = messages.length - 1; i >=
|
|
132
|
+
for (let i = messages.length - 1; i >= startIndex; i -= 1) {
|
|
103
133
|
const message = messages[i];
|
|
104
134
|
if (!message || (message.role !== "user" && message.role !== "assistant")) {
|
|
105
135
|
continue;
|
|
@@ -107,6 +137,9 @@ function pickHeuristicCandidates(
|
|
|
107
137
|
if (message.text.length < 20 || message.text.length > 3000) {
|
|
108
138
|
continue;
|
|
109
139
|
}
|
|
140
|
+
if (isLikelyFeedOrImportedText(message.text)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
110
143
|
|
|
111
144
|
const score = scoreHeuristic(message.text);
|
|
112
145
|
if (score < 0.2) {
|
|
@@ -435,6 +468,17 @@ export async function extractCandidates(params: {
|
|
|
435
468
|
|
|
436
469
|
try {
|
|
437
470
|
if (params.cfg.capture.mode === "hybrid") {
|
|
471
|
+
if (heuristic.length === 0) {
|
|
472
|
+
params.log.debug("memory_braid.capture.ml", {
|
|
473
|
+
runId: params.runId,
|
|
474
|
+
mode: params.cfg.capture.mode,
|
|
475
|
+
provider: params.cfg.capture.ml.provider,
|
|
476
|
+
model: params.cfg.capture.ml.model,
|
|
477
|
+
decision: "skip_ml_enrichment_no_heuristic_candidates",
|
|
478
|
+
});
|
|
479
|
+
return heuristic;
|
|
480
|
+
}
|
|
481
|
+
|
|
438
482
|
const ml = await callMlEnrichment({
|
|
439
483
|
provider: params.cfg.capture.ml.provider,
|
|
440
484
|
model: params.cfg.capture.ml.model,
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
writeStatsState,
|
|
25
25
|
} from "./state.js";
|
|
26
26
|
import type { LifecycleEntry, MemoryBraidResult, ScopeKey } from "./types.js";
|
|
27
|
-
import { normalizeForHash, sha256 } from "./chunking.js";
|
|
27
|
+
import { normalizeForHash, normalizeWhitespace, sha256 } from "./chunking.js";
|
|
28
28
|
|
|
29
29
|
function jsonToolResult(payload: unknown) {
|
|
30
30
|
return {
|
|
@@ -225,6 +225,17 @@ function isGenericUserSummary(text: string): boolean {
|
|
|
225
225
|
);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
function sanitizeRecallQuery(text: string): string {
|
|
229
|
+
if (!text) {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
const withoutInjectedMemories = text.replace(
|
|
233
|
+
/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi,
|
|
234
|
+
" ",
|
|
235
|
+
);
|
|
236
|
+
return normalizeWhitespace(withoutInjectedMemories);
|
|
237
|
+
}
|
|
238
|
+
|
|
228
239
|
function applyMem0QualityAdjustments(params: {
|
|
229
240
|
results: MemoryBraidResult[];
|
|
230
241
|
query: string;
|
|
@@ -1220,6 +1231,10 @@ const memoryBraidPlugin = {
|
|
|
1220
1231
|
|
|
1221
1232
|
api.on("before_agent_start", async (event, ctx) => {
|
|
1222
1233
|
const runId = log.newRunId();
|
|
1234
|
+
const recallQuery = sanitizeRecallQuery(event.prompt);
|
|
1235
|
+
if (!recallQuery) {
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1223
1238
|
const toolCtx: OpenClawPluginToolContext = {
|
|
1224
1239
|
config: api.config,
|
|
1225
1240
|
workspaceDir: ctx.workspaceDir,
|
|
@@ -1235,17 +1250,17 @@ const memoryBraidPlugin = {
|
|
|
1235
1250
|
log,
|
|
1236
1251
|
ctx: toolCtx,
|
|
1237
1252
|
statePaths: runtimeStatePaths,
|
|
1238
|
-
query:
|
|
1253
|
+
query: recallQuery,
|
|
1239
1254
|
args: {
|
|
1240
|
-
query:
|
|
1255
|
+
query: recallQuery,
|
|
1241
1256
|
maxResults: cfg.recall.maxResults,
|
|
1242
1257
|
},
|
|
1243
1258
|
runId,
|
|
1244
1259
|
});
|
|
1245
1260
|
|
|
1246
1261
|
const selected = selectMemoriesForInjection({
|
|
1247
|
-
query:
|
|
1248
|
-
results: recall.
|
|
1262
|
+
query: recallQuery,
|
|
1263
|
+
results: recall.mem0,
|
|
1249
1264
|
limit: cfg.recall.injectTopK,
|
|
1250
1265
|
});
|
|
1251
1266
|
if (selected.injected.length === 0) {
|
|
@@ -1256,6 +1271,7 @@ const memoryBraidPlugin = {
|
|
|
1256
1271
|
sessionKey: scope.sessionKey,
|
|
1257
1272
|
workspaceHash: scope.workspaceHash,
|
|
1258
1273
|
count: 0,
|
|
1274
|
+
source: "mem0",
|
|
1259
1275
|
queryTokens: selected.queryTokens,
|
|
1260
1276
|
filteredOut: selected.filteredOut,
|
|
1261
1277
|
genericRejected: selected.genericRejected,
|
|
@@ -1272,6 +1288,7 @@ const memoryBraidPlugin = {
|
|
|
1272
1288
|
sessionKey: scope.sessionKey,
|
|
1273
1289
|
workspaceHash: scope.workspaceHash,
|
|
1274
1290
|
count: selected.injected.length,
|
|
1291
|
+
source: "mem0",
|
|
1275
1292
|
queryTokens: selected.queryTokens,
|
|
1276
1293
|
filteredOut: selected.filteredOut,
|
|
1277
1294
|
genericRejected: selected.genericRejected,
|
|
@@ -1328,105 +1345,153 @@ const memoryBraidPlugin = {
|
|
|
1328
1345
|
return;
|
|
1329
1346
|
}
|
|
1330
1347
|
|
|
1331
|
-
|
|
1348
|
+
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
|
|
1349
|
+
const candidateEntries = candidates.map((candidate) => ({
|
|
1350
|
+
candidate,
|
|
1351
|
+
hash: sha256(normalizeForHash(candidate.text)),
|
|
1352
|
+
}));
|
|
1353
|
+
|
|
1354
|
+
const prepared = await withStateLock(runtimeStatePaths.stateLockFile, async () => {
|
|
1332
1355
|
const dedupe = await readCaptureDedupeState(runtimeStatePaths);
|
|
1333
|
-
const stats = await readStatsState(runtimeStatePaths);
|
|
1334
|
-
const lifecycle = cfg.lifecycle.enabled
|
|
1335
|
-
? await readLifecycleState(runtimeStatePaths)
|
|
1336
|
-
: null;
|
|
1337
1356
|
const now = Date.now();
|
|
1338
|
-
|
|
1357
|
+
|
|
1358
|
+
let pruned = 0;
|
|
1339
1359
|
for (const [key, ts] of Object.entries(dedupe.seen)) {
|
|
1340
1360
|
if (now - ts > thirtyDays) {
|
|
1341
1361
|
delete dedupe.seen[key];
|
|
1362
|
+
pruned += 1;
|
|
1342
1363
|
}
|
|
1343
1364
|
}
|
|
1344
1365
|
|
|
1345
|
-
let persisted = 0;
|
|
1346
1366
|
let dedupeSkipped = 0;
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
let mem0AddWithoutId = 0;
|
|
1352
|
-
for (const candidate of candidates) {
|
|
1353
|
-
const hash = sha256(normalizeForHash(candidate.text));
|
|
1354
|
-
if (dedupe.seen[hash]) {
|
|
1367
|
+
const pending: typeof candidateEntries = [];
|
|
1368
|
+
const seenInBatch = new Set<string>();
|
|
1369
|
+
for (const entry of candidateEntries) {
|
|
1370
|
+
if (dedupe.seen[entry.hash] || seenInBatch.has(entry.hash)) {
|
|
1355
1371
|
dedupeSkipped += 1;
|
|
1356
1372
|
continue;
|
|
1357
1373
|
}
|
|
1374
|
+
seenInBatch.add(entry.hash);
|
|
1375
|
+
pending.push(entry);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
if (pruned > 0) {
|
|
1379
|
+
await writeCaptureDedupeState(runtimeStatePaths, dedupe);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
return {
|
|
1383
|
+
dedupeSkipped,
|
|
1384
|
+
pending,
|
|
1385
|
+
};
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
let entityAnnotatedCandidates = 0;
|
|
1389
|
+
let totalEntitiesAttached = 0;
|
|
1390
|
+
let mem0AddAttempts = 0;
|
|
1391
|
+
let mem0AddWithId = 0;
|
|
1392
|
+
let mem0AddWithoutId = 0;
|
|
1393
|
+
const successfulAdds: Array<{
|
|
1394
|
+
memoryId: string;
|
|
1395
|
+
hash: string;
|
|
1396
|
+
category: (typeof candidates)[number]["category"];
|
|
1397
|
+
}> = [];
|
|
1398
|
+
|
|
1399
|
+
for (const entry of prepared.pending) {
|
|
1400
|
+
const { candidate, hash } = entry;
|
|
1401
|
+
const metadata: Record<string, unknown> = {
|
|
1402
|
+
sourceType: "capture",
|
|
1403
|
+
workspaceHash: scope.workspaceHash,
|
|
1404
|
+
agentId: scope.agentId,
|
|
1405
|
+
sessionKey: scope.sessionKey,
|
|
1406
|
+
category: candidate.category,
|
|
1407
|
+
captureScore: candidate.score,
|
|
1408
|
+
extractionSource: candidate.source,
|
|
1409
|
+
contentHash: hash,
|
|
1410
|
+
indexedAt: new Date().toISOString(),
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
if (cfg.entityExtraction.enabled) {
|
|
1414
|
+
const entities = await entityExtraction.extract({
|
|
1415
|
+
text: candidate.text,
|
|
1416
|
+
runId,
|
|
1417
|
+
});
|
|
1418
|
+
if (entities.length > 0) {
|
|
1419
|
+
entityAnnotatedCandidates += 1;
|
|
1420
|
+
totalEntitiesAttached += entities.length;
|
|
1421
|
+
metadata.entityUris = entities.map((entity) => entity.canonicalUri);
|
|
1422
|
+
metadata.entities = entities;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1358
1425
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1426
|
+
mem0AddAttempts += 1;
|
|
1427
|
+
const addResult = await mem0.addMemory({
|
|
1428
|
+
text: candidate.text,
|
|
1429
|
+
scope,
|
|
1430
|
+
metadata,
|
|
1431
|
+
runId,
|
|
1432
|
+
});
|
|
1433
|
+
if (addResult.id) {
|
|
1434
|
+
mem0AddWithId += 1;
|
|
1435
|
+
successfulAdds.push({
|
|
1436
|
+
memoryId: addResult.id,
|
|
1437
|
+
hash,
|
|
1438
|
+
category: candidate.category,
|
|
1439
|
+
});
|
|
1440
|
+
} else {
|
|
1441
|
+
mem0AddWithoutId += 1;
|
|
1442
|
+
log.warn("memory_braid.capture.persist", {
|
|
1443
|
+
runId,
|
|
1444
|
+
reason: "mem0_add_missing_id",
|
|
1361
1445
|
workspaceHash: scope.workspaceHash,
|
|
1362
1446
|
agentId: scope.agentId,
|
|
1363
1447
|
sessionKey: scope.sessionKey,
|
|
1448
|
+
contentHashPrefix: hash.slice(0, 12),
|
|
1364
1449
|
category: candidate.category,
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
indexedAt: new Date(now).toISOString(),
|
|
1369
|
-
};
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1370
1453
|
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1454
|
+
await withStateLock(runtimeStatePaths.stateLockFile, async () => {
|
|
1455
|
+
const dedupe = await readCaptureDedupeState(runtimeStatePaths);
|
|
1456
|
+
const stats = await readStatsState(runtimeStatePaths);
|
|
1457
|
+
const lifecycle = cfg.lifecycle.enabled
|
|
1458
|
+
? await readLifecycleState(runtimeStatePaths)
|
|
1459
|
+
: null;
|
|
1460
|
+
const now = Date.now();
|
|
1461
|
+
|
|
1462
|
+
for (const [key, ts] of Object.entries(dedupe.seen)) {
|
|
1463
|
+
if (now - ts > thirtyDays) {
|
|
1464
|
+
delete dedupe.seen[key];
|
|
1382
1465
|
}
|
|
1466
|
+
}
|
|
1383
1467
|
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
persisted += 1;
|
|
1395
|
-
if (lifecycle) {
|
|
1396
|
-
const memoryId = addResult.id;
|
|
1397
|
-
const existing = lifecycle.entries[memoryId];
|
|
1398
|
-
lifecycle.entries[memoryId] = {
|
|
1399
|
-
memoryId,
|
|
1400
|
-
contentHash: hash,
|
|
1401
|
-
workspaceHash: scope.workspaceHash,
|
|
1402
|
-
agentId: scope.agentId,
|
|
1403
|
-
sessionKey: scope.sessionKey,
|
|
1404
|
-
category: candidate.category,
|
|
1405
|
-
createdAt: existing?.createdAt ?? now,
|
|
1406
|
-
lastCapturedAt: now,
|
|
1407
|
-
lastRecalledAt: existing?.lastRecalledAt,
|
|
1408
|
-
recallCount: existing?.recallCount ?? 0,
|
|
1409
|
-
updatedAt: now,
|
|
1410
|
-
};
|
|
1411
|
-
}
|
|
1412
|
-
} else {
|
|
1413
|
-
mem0AddWithoutId += 1;
|
|
1414
|
-
log.warn("memory_braid.capture.persist", {
|
|
1415
|
-
runId,
|
|
1416
|
-
reason: "mem0_add_missing_id",
|
|
1468
|
+
let persisted = 0;
|
|
1469
|
+
for (const entry of successfulAdds) {
|
|
1470
|
+
dedupe.seen[entry.hash] = now;
|
|
1471
|
+
persisted += 1;
|
|
1472
|
+
|
|
1473
|
+
if (lifecycle) {
|
|
1474
|
+
const existing = lifecycle.entries[entry.memoryId];
|
|
1475
|
+
lifecycle.entries[entry.memoryId] = {
|
|
1476
|
+
memoryId: entry.memoryId,
|
|
1477
|
+
contentHash: entry.hash,
|
|
1417
1478
|
workspaceHash: scope.workspaceHash,
|
|
1418
1479
|
agentId: scope.agentId,
|
|
1419
1480
|
sessionKey: scope.sessionKey,
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1481
|
+
category: entry.category,
|
|
1482
|
+
createdAt: existing?.createdAt ?? now,
|
|
1483
|
+
lastCapturedAt: now,
|
|
1484
|
+
lastRecalledAt: existing?.lastRecalledAt,
|
|
1485
|
+
recallCount: existing?.recallCount ?? 0,
|
|
1486
|
+
updatedAt: now,
|
|
1487
|
+
};
|
|
1423
1488
|
}
|
|
1424
1489
|
}
|
|
1425
1490
|
|
|
1426
1491
|
stats.capture.runs += 1;
|
|
1427
1492
|
stats.capture.runsWithCandidates += 1;
|
|
1428
1493
|
stats.capture.candidates += candidates.length;
|
|
1429
|
-
stats.capture.dedupeSkipped += dedupeSkipped;
|
|
1494
|
+
stats.capture.dedupeSkipped += prepared.dedupeSkipped;
|
|
1430
1495
|
stats.capture.persisted += persisted;
|
|
1431
1496
|
stats.capture.mem0AddAttempts += mem0AddAttempts;
|
|
1432
1497
|
stats.capture.mem0AddWithId += mem0AddWithId;
|
|
@@ -1447,7 +1512,8 @@ const memoryBraidPlugin = {
|
|
|
1447
1512
|
agentId: scope.agentId,
|
|
1448
1513
|
sessionKey: scope.sessionKey,
|
|
1449
1514
|
candidates: candidates.length,
|
|
1450
|
-
|
|
1515
|
+
pending: prepared.pending.length,
|
|
1516
|
+
dedupeSkipped: prepared.dedupeSkipped,
|
|
1451
1517
|
persisted,
|
|
1452
1518
|
mem0AddAttempts,
|
|
1453
1519
|
mem0AddWithId,
|
package/src/mem0-client.ts
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { normalizeForHash } from "./chunking.js";
|
|
5
|
+
import { normalizeForHash, sha256 } from "./chunking.js";
|
|
6
6
|
import type { MemoryBraidConfig } from "./config.js";
|
|
7
7
|
import { MemoryBraidLogger } from "./logger.js";
|
|
8
8
|
import type { MemoryBraidResult, ScopeKey } from "./types.js";
|
|
@@ -403,6 +403,9 @@ type Mem0AdapterOptions = {
|
|
|
403
403
|
stateDir?: string;
|
|
404
404
|
};
|
|
405
405
|
|
|
406
|
+
const SEMANTIC_SEARCH_CACHE_TTL_MS = 30_000;
|
|
407
|
+
const SEMANTIC_SEARCH_CACHE_MAX_ENTRIES = 256;
|
|
408
|
+
|
|
406
409
|
export class Mem0Adapter {
|
|
407
410
|
private cloudClient: CloudClientLike | null = null;
|
|
408
411
|
private ossClient: OssClientLike | null = null;
|
|
@@ -410,6 +413,13 @@ export class Mem0Adapter {
|
|
|
410
413
|
private readonly log: MemoryBraidLogger;
|
|
411
414
|
private readonly pluginDir?: string;
|
|
412
415
|
private stateDir?: string;
|
|
416
|
+
private readonly semanticSearchCache = new Map<
|
|
417
|
+
string,
|
|
418
|
+
{
|
|
419
|
+
expiresAt: number;
|
|
420
|
+
results: MemoryBraidResult[];
|
|
421
|
+
}
|
|
422
|
+
>();
|
|
413
423
|
|
|
414
424
|
constructor(cfg: MemoryBraidConfig, log: MemoryBraidLogger, options?: Mem0AdapterOptions) {
|
|
415
425
|
this.cfg = cfg;
|
|
@@ -425,6 +435,7 @@ export class Mem0Adapter {
|
|
|
425
435
|
}
|
|
426
436
|
this.stateDir = next;
|
|
427
437
|
this.ossClient = null;
|
|
438
|
+
this.semanticSearchCache.clear();
|
|
428
439
|
}
|
|
429
440
|
|
|
430
441
|
private async ensureCloudClient(): Promise<CloudClientLike | null> {
|
|
@@ -844,12 +855,38 @@ export class Mem0Adapter {
|
|
|
844
855
|
runId?: string;
|
|
845
856
|
}): Promise<number | undefined> {
|
|
846
857
|
const rightHash = normalizeForHash(params.rightText);
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
858
|
+
if (!rightHash) {
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const leftHash = normalizeForHash(params.leftText);
|
|
863
|
+
if (!leftHash) {
|
|
864
|
+
return undefined;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const now = Date.now();
|
|
868
|
+
this.pruneSemanticSearchCache(now);
|
|
869
|
+
const scopeSession = params.scope.sessionKey ?? "";
|
|
870
|
+
const cacheKey = `${params.scope.workspaceHash}|${params.scope.agentId}|${scopeSession}|${sha256(leftHash)}`;
|
|
871
|
+
const cached = this.semanticSearchCache.get(cacheKey);
|
|
872
|
+
const results =
|
|
873
|
+
cached && cached.expiresAt > now
|
|
874
|
+
? cached.results
|
|
875
|
+
: await this.searchMemories({
|
|
876
|
+
query: params.leftText,
|
|
877
|
+
maxResults: 5,
|
|
878
|
+
scope: params.scope,
|
|
879
|
+
runId: params.runId,
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
if (!cached || cached.expiresAt <= now) {
|
|
883
|
+
this.semanticSearchCache.set(cacheKey, {
|
|
884
|
+
expiresAt: now + SEMANTIC_SEARCH_CACHE_TTL_MS,
|
|
885
|
+
results,
|
|
886
|
+
});
|
|
887
|
+
this.pruneSemanticSearchCache(now);
|
|
888
|
+
}
|
|
889
|
+
|
|
853
890
|
for (const result of results) {
|
|
854
891
|
if (normalizeForHash(result.snippet) === rightHash) {
|
|
855
892
|
return result.score;
|
|
@@ -857,4 +894,20 @@ export class Mem0Adapter {
|
|
|
857
894
|
}
|
|
858
895
|
return undefined;
|
|
859
896
|
}
|
|
897
|
+
|
|
898
|
+
private pruneSemanticSearchCache(now = Date.now()): void {
|
|
899
|
+
for (const [key, entry] of this.semanticSearchCache.entries()) {
|
|
900
|
+
if (entry.expiresAt <= now) {
|
|
901
|
+
this.semanticSearchCache.delete(key);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
while (this.semanticSearchCache.size > SEMANTIC_SEARCH_CACHE_MAX_ENTRIES) {
|
|
906
|
+
const oldest = this.semanticSearchCache.keys().next().value as string | undefined;
|
|
907
|
+
if (!oldest) {
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
this.semanticSearchCache.delete(oldest);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
860
913
|
}
|
package/src/state.ts
CHANGED
|
@@ -77,7 +77,7 @@ export async function readCaptureDedupeState(paths: StatePaths): Promise<Capture
|
|
|
77
77
|
const value = await readJsonFile(paths.captureDedupeFile, DEFAULT_CAPTURE_DEDUPE);
|
|
78
78
|
return {
|
|
79
79
|
version: 1,
|
|
80
|
-
seen: value.seen ?? {},
|
|
80
|
+
seen: { ...(value.seen ?? {}) },
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -92,7 +92,7 @@ export async function readLifecycleState(paths: StatePaths): Promise<LifecycleSt
|
|
|
92
92
|
const value = await readJsonFile(paths.lifecycleFile, DEFAULT_LIFECYCLE);
|
|
93
93
|
return {
|
|
94
94
|
version: 1,
|
|
95
|
-
entries: value.entries ?? {},
|
|
95
|
+
entries: { ...(value.entries ?? {}) },
|
|
96
96
|
lastCleanupAt: value.lastCleanupAt,
|
|
97
97
|
lastCleanupReason: value.lastCleanupReason,
|
|
98
98
|
lastCleanupScanned: value.lastCleanupScanned,
|