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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-braid",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "OpenClaw memory plugin that augments local memory with Mem0 capture and recall.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
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(left: NormalizedEntityToken, right: NormalizedEntityToken): boolean {
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
- return gap <= 1;
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(tokens: NormalizedEntityToken[]): NormalizedEntityToken[] {
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 >= 0; i -= 1) {
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: event.prompt,
1253
+ query: recallQuery,
1239
1254
  args: {
1240
- query: event.prompt,
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: event.prompt,
1248
- results: recall.merged,
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
- await withStateLock(runtimeStatePaths.stateLockFile, async () => {
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
- const thirtyDays = 30 * 24 * 60 * 60 * 1000;
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
- let entityAnnotatedCandidates = 0;
1348
- let totalEntitiesAttached = 0;
1349
- let mem0AddAttempts = 0;
1350
- let mem0AddWithId = 0;
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
- const metadata: Record<string, unknown> = {
1360
- sourceType: "capture",
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
- captureScore: candidate.score,
1366
- extractionSource: candidate.source,
1367
- contentHash: hash,
1368
- indexedAt: new Date(now).toISOString(),
1369
- };
1450
+ });
1451
+ }
1452
+ }
1370
1453
 
1371
- if (cfg.entityExtraction.enabled) {
1372
- const entities = await entityExtraction.extract({
1373
- text: candidate.text,
1374
- runId,
1375
- });
1376
- if (entities.length > 0) {
1377
- entityAnnotatedCandidates += 1;
1378
- totalEntitiesAttached += entities.length;
1379
- metadata.entityUris = entities.map((entity) => entity.canonicalUri);
1380
- metadata.entities = entities;
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
- mem0AddAttempts += 1;
1385
- const addResult = await mem0.addMemory({
1386
- text: candidate.text,
1387
- scope,
1388
- metadata,
1389
- runId,
1390
- });
1391
- if (addResult.id) {
1392
- dedupe.seen[hash] = now;
1393
- mem0AddWithId += 1;
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
- contentHashPrefix: hash.slice(0, 12),
1421
- category: candidate.category,
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
- dedupeSkipped,
1515
+ pending: prepared.pending.length,
1516
+ dedupeSkipped: prepared.dedupeSkipped,
1451
1517
  persisted,
1452
1518
  mem0AddAttempts,
1453
1519
  mem0AddWithId,
@@ -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
- const results = await this.searchMemories({
848
- query: params.leftText,
849
- maxResults: 5,
850
- scope: params.scope,
851
- runId: params.runId,
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,