memory-braid 0.4.4 → 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.4",
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/extract.ts CHANGED
@@ -468,6 +468,17 @@ export async function extractCandidates(params: {
468
468
 
469
469
  try {
470
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
+
471
482
  const ml = await callMlEnrichment({
472
483
  provider: params.cfg.capture.ml.provider,
473
484
  model: params.cfg.capture.ml.model,
package/src/index.ts CHANGED
@@ -1345,105 +1345,153 @@ const memoryBraidPlugin = {
1345
1345
  return;
1346
1346
  }
1347
1347
 
1348
- 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 () => {
1349
1355
  const dedupe = await readCaptureDedupeState(runtimeStatePaths);
1350
- const stats = await readStatsState(runtimeStatePaths);
1351
- const lifecycle = cfg.lifecycle.enabled
1352
- ? await readLifecycleState(runtimeStatePaths)
1353
- : null;
1354
1356
  const now = Date.now();
1355
- const thirtyDays = 30 * 24 * 60 * 60 * 1000;
1357
+
1358
+ let pruned = 0;
1356
1359
  for (const [key, ts] of Object.entries(dedupe.seen)) {
1357
1360
  if (now - ts > thirtyDays) {
1358
1361
  delete dedupe.seen[key];
1362
+ pruned += 1;
1359
1363
  }
1360
1364
  }
1361
1365
 
1362
- let persisted = 0;
1363
1366
  let dedupeSkipped = 0;
1364
- let entityAnnotatedCandidates = 0;
1365
- let totalEntitiesAttached = 0;
1366
- let mem0AddAttempts = 0;
1367
- let mem0AddWithId = 0;
1368
- let mem0AddWithoutId = 0;
1369
- for (const candidate of candidates) {
1370
- const hash = sha256(normalizeForHash(candidate.text));
1371
- 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)) {
1372
1371
  dedupeSkipped += 1;
1373
1372
  continue;
1374
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
+ }
1375
1425
 
1376
- const metadata: Record<string, unknown> = {
1377
- 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",
1378
1445
  workspaceHash: scope.workspaceHash,
1379
1446
  agentId: scope.agentId,
1380
1447
  sessionKey: scope.sessionKey,
1448
+ contentHashPrefix: hash.slice(0, 12),
1381
1449
  category: candidate.category,
1382
- captureScore: candidate.score,
1383
- extractionSource: candidate.source,
1384
- contentHash: hash,
1385
- indexedAt: new Date(now).toISOString(),
1386
- };
1450
+ });
1451
+ }
1452
+ }
1387
1453
 
1388
- if (cfg.entityExtraction.enabled) {
1389
- const entities = await entityExtraction.extract({
1390
- text: candidate.text,
1391
- runId,
1392
- });
1393
- if (entities.length > 0) {
1394
- entityAnnotatedCandidates += 1;
1395
- totalEntitiesAttached += entities.length;
1396
- metadata.entityUris = entities.map((entity) => entity.canonicalUri);
1397
- metadata.entities = entities;
1398
- }
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];
1399
1465
  }
1466
+ }
1400
1467
 
1401
- mem0AddAttempts += 1;
1402
- const addResult = await mem0.addMemory({
1403
- text: candidate.text,
1404
- scope,
1405
- metadata,
1406
- runId,
1407
- });
1408
- if (addResult.id) {
1409
- dedupe.seen[hash] = now;
1410
- mem0AddWithId += 1;
1411
- persisted += 1;
1412
- if (lifecycle) {
1413
- const memoryId = addResult.id;
1414
- const existing = lifecycle.entries[memoryId];
1415
- lifecycle.entries[memoryId] = {
1416
- memoryId,
1417
- contentHash: hash,
1418
- workspaceHash: scope.workspaceHash,
1419
- agentId: scope.agentId,
1420
- sessionKey: scope.sessionKey,
1421
- category: candidate.category,
1422
- createdAt: existing?.createdAt ?? now,
1423
- lastCapturedAt: now,
1424
- lastRecalledAt: existing?.lastRecalledAt,
1425
- recallCount: existing?.recallCount ?? 0,
1426
- updatedAt: now,
1427
- };
1428
- }
1429
- } else {
1430
- mem0AddWithoutId += 1;
1431
- log.warn("memory_braid.capture.persist", {
1432
- runId,
1433
- 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,
1434
1478
  workspaceHash: scope.workspaceHash,
1435
1479
  agentId: scope.agentId,
1436
1480
  sessionKey: scope.sessionKey,
1437
- contentHashPrefix: hash.slice(0, 12),
1438
- category: candidate.category,
1439
- });
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
+ };
1440
1488
  }
1441
1489
  }
1442
1490
 
1443
1491
  stats.capture.runs += 1;
1444
1492
  stats.capture.runsWithCandidates += 1;
1445
1493
  stats.capture.candidates += candidates.length;
1446
- stats.capture.dedupeSkipped += dedupeSkipped;
1494
+ stats.capture.dedupeSkipped += prepared.dedupeSkipped;
1447
1495
  stats.capture.persisted += persisted;
1448
1496
  stats.capture.mem0AddAttempts += mem0AddAttempts;
1449
1497
  stats.capture.mem0AddWithId += mem0AddWithId;
@@ -1464,7 +1512,8 @@ const memoryBraidPlugin = {
1464
1512
  agentId: scope.agentId,
1465
1513
  sessionKey: scope.sessionKey,
1466
1514
  candidates: candidates.length,
1467
- dedupeSkipped,
1515
+ pending: prepared.pending.length,
1516
+ dedupeSkipped: prepared.dedupeSkipped,
1468
1517
  persisted,
1469
1518
  mem0AddAttempts,
1470
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,