memory-braid 0.2.0 → 0.3.3

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/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  } from "openclaw/plugin-sdk";
6
6
  import { parseConfig, pluginConfigSchema } from "./config.js";
7
7
  import { stagedDedupe } from "./dedupe.js";
8
+ import { EntityExtractionManager } from "./entities.js";
8
9
  import { extractCandidates } from "./extract.js";
9
10
  import { MemoryBraidLogger } from "./logger.js";
10
11
  import { resolveLocalTools, runLocalGet, runLocalSearch } from "./local-memory.js";
@@ -75,6 +76,25 @@ function formatRelevantMemories(results: MemoryBraidResult[], maxChars = 600): s
75
76
  ].join("\n");
76
77
  }
77
78
 
79
+ function formatEntityExtractionStatus(params: {
80
+ enabled: boolean;
81
+ provider: string;
82
+ model: string;
83
+ minScore: number;
84
+ maxEntitiesPerMemory: number;
85
+ cacheDir: string;
86
+ }): string {
87
+ return [
88
+ "Memory Braid entity extraction:",
89
+ `- enabled: ${params.enabled}`,
90
+ `- provider: ${params.provider}`,
91
+ `- model: ${params.model}`,
92
+ `- minScore: ${params.minScore}`,
93
+ `- maxEntitiesPerMemory: ${params.maxEntitiesPerMemory}`,
94
+ `- cacheDir: ${params.cacheDir}`,
95
+ ].join("\n");
96
+ }
97
+
78
98
  async function runHybridRecall(params: {
79
99
  api: OpenClawPluginApi;
80
100
  cfg: ReturnType<typeof parseConfig>;
@@ -94,6 +114,13 @@ async function runHybridRecall(params: {
94
114
  }> {
95
115
  const local = resolveLocalTools(params.api, params.ctx);
96
116
  if (!local.searchTool) {
117
+ params.log.warn("memory_braid.search.skip", {
118
+ runId: params.runId,
119
+ reason: "local_search_tool_unavailable",
120
+ agentId: params.ctx.agentId,
121
+ sessionKey: params.ctx.sessionKey,
122
+ workspaceHash: workspaceHashFromDir(params.ctx.workspaceDir),
123
+ });
97
124
  return { local: [], mem0: [], merged: [] };
98
125
  }
99
126
 
@@ -190,6 +217,9 @@ const memoryBraidPlugin = {
190
217
  const log = new MemoryBraidLogger(api.logger, cfg.debug);
191
218
  const initialStateDir = api.runtime.state.resolveStateDir();
192
219
  const mem0 = new Mem0Adapter(cfg, log, { stateDir: initialStateDir });
220
+ const entityExtraction = new EntityExtractionManager(cfg.entityExtraction, log, {
221
+ stateDir: initialStateDir,
222
+ });
193
223
 
194
224
  let serviceTimer: NodeJS.Timeout | null = null;
195
225
  let statePaths: StatePaths | null = null;
@@ -288,6 +318,61 @@ const memoryBraidPlugin = {
288
318
  { names: ["memory_search", "memory_get"] },
289
319
  );
290
320
 
321
+ api.registerCommand({
322
+ name: "memorybraid",
323
+ description: "Memory Braid status and entity extraction warmup.",
324
+ acceptsArgs: true,
325
+ handler: async (ctx) => {
326
+ const args = ctx.args?.trim() ?? "";
327
+ const tokens = args.split(/\s+/).filter(Boolean);
328
+ const action = (tokens[0] ?? "status").toLowerCase();
329
+
330
+ if (action === "status") {
331
+ return {
332
+ text: [
333
+ `capture.mode: ${cfg.capture.mode}`,
334
+ formatEntityExtractionStatus(entityExtraction.getStatus()),
335
+ ].join("\n\n"),
336
+ };
337
+ }
338
+
339
+ if (action === "warmup") {
340
+ const runId = log.newRunId();
341
+ const forceReload = tokens.some((token) => token === "--force");
342
+ const result = await entityExtraction.warmup({
343
+ runId,
344
+ reason: "command",
345
+ forceReload,
346
+ });
347
+ if (!result.ok) {
348
+ return {
349
+ text: [
350
+ "Entity extraction warmup failed.",
351
+ `- model: ${result.model}`,
352
+ `- cacheDir: ${result.cacheDir}`,
353
+ `- durMs: ${result.durMs}`,
354
+ `- error: ${result.error ?? "unknown"}`,
355
+ ].join("\n"),
356
+ isError: true,
357
+ };
358
+ }
359
+ return {
360
+ text: [
361
+ "Entity extraction warmup complete.",
362
+ `- model: ${result.model}`,
363
+ `- cacheDir: ${result.cacheDir}`,
364
+ `- entities: ${result.entities}`,
365
+ `- durMs: ${result.durMs}`,
366
+ ].join("\n"),
367
+ };
368
+ }
369
+
370
+ return {
371
+ text: "Usage: /memorybraid [status|warmup [--force]]",
372
+ };
373
+ },
374
+ });
375
+
291
376
  api.on("before_agent_start", async (event, ctx) => {
292
377
  const runId = log.newRunId();
293
378
  const toolCtx: OpenClawPluginToolContext = {
@@ -375,14 +460,21 @@ const memoryBraidPlugin = {
375
460
  }
376
461
 
377
462
  let persisted = 0;
463
+ let dedupeSkipped = 0;
464
+ let entityAnnotatedCandidates = 0;
465
+ let totalEntitiesAttached = 0;
466
+ let mem0AddAttempts = 0;
467
+ let mem0AddWithId = 0;
468
+ let mem0AddWithoutId = 0;
378
469
  for (const candidate of candidates) {
379
470
  const hash = sha256(normalizeForHash(candidate.text));
380
471
  if (dedupe.seen[hash]) {
472
+ dedupeSkipped += 1;
381
473
  continue;
382
474
  }
383
475
  dedupe.seen[hash] = now;
384
476
 
385
- const metadata = {
477
+ const metadata: Record<string, unknown> = {
386
478
  sourceType: "capture",
387
479
  workspaceHash: scope.workspaceHash,
388
480
  agentId: scope.agentId,
@@ -394,23 +486,59 @@ const memoryBraidPlugin = {
394
486
  indexedAt: new Date().toISOString(),
395
487
  };
396
488
 
397
- await mem0.addMemory({
489
+ if (cfg.entityExtraction.enabled) {
490
+ const entities = await entityExtraction.extract({
491
+ text: candidate.text,
492
+ runId,
493
+ });
494
+ if (entities.length > 0) {
495
+ entityAnnotatedCandidates += 1;
496
+ totalEntitiesAttached += entities.length;
497
+ metadata.entityUris = entities.map((entity) => entity.canonicalUri);
498
+ metadata.entities = entities;
499
+ }
500
+ }
501
+
502
+ mem0AddAttempts += 1;
503
+ const addResult = await mem0.addMemory({
398
504
  text: candidate.text,
399
505
  scope,
400
506
  metadata,
401
507
  runId,
402
508
  });
509
+ if (addResult.id) {
510
+ mem0AddWithId += 1;
511
+ } else {
512
+ mem0AddWithoutId += 1;
513
+ log.warn("memory_braid.capture.persist", {
514
+ runId,
515
+ reason: "mem0_add_missing_id",
516
+ workspaceHash: scope.workspaceHash,
517
+ agentId: scope.agentId,
518
+ sessionKey: scope.sessionKey,
519
+ contentHashPrefix: hash.slice(0, 12),
520
+ category: candidate.category,
521
+ });
522
+ }
403
523
  persisted += 1;
404
524
  }
405
525
 
406
526
  await writeCaptureDedupeState(statePaths, dedupe);
407
527
  log.debug("memory_braid.capture.persist", {
408
528
  runId,
529
+ mode: cfg.capture.mode,
409
530
  workspaceHash: scope.workspaceHash,
410
531
  agentId: scope.agentId,
411
532
  sessionKey: scope.sessionKey,
412
533
  candidates: candidates.length,
534
+ dedupeSkipped,
413
535
  persisted,
536
+ mem0AddAttempts,
537
+ mem0AddWithId,
538
+ mem0AddWithoutId,
539
+ entityExtractionEnabled: cfg.entityExtraction.enabled,
540
+ entityAnnotatedCandidates,
541
+ totalEntitiesAttached,
414
542
  }, true);
415
543
  });
416
544
 
@@ -418,6 +546,7 @@ const memoryBraidPlugin = {
418
546
  id: "memory-braid-service",
419
547
  start: async (ctx) => {
420
548
  mem0.setStateDir(ctx.stateDir);
549
+ entityExtraction.setStateDir(ctx.stateDir);
421
550
  statePaths = createStatePaths(ctx.stateDir);
422
551
  await ensureStateDir(statePaths);
423
552
  targets = await resolveTargets({
@@ -437,6 +566,24 @@ const memoryBraidPlugin = {
437
566
  stateDir: ctx.stateDir,
438
567
  targets: targets.length,
439
568
  });
569
+ log.info("memory_braid.config", {
570
+ runId,
571
+ mem0Mode: cfg.mem0.mode,
572
+ captureEnabled: cfg.capture.enabled,
573
+ captureMode: cfg.capture.mode,
574
+ captureMaxItemsPerRun: cfg.capture.maxItemsPerRun,
575
+ captureMlProvider: cfg.capture.ml.provider ?? "unset",
576
+ captureMlModel: cfg.capture.ml.model ?? "unset",
577
+ entityExtractionEnabled: cfg.entityExtraction.enabled,
578
+ entityProvider: cfg.entityExtraction.provider,
579
+ entityModel: cfg.entityExtraction.model,
580
+ entityMinScore: cfg.entityExtraction.minScore,
581
+ entityMaxPerMemory: cfg.entityExtraction.maxEntitiesPerMemory,
582
+ entityWarmupOnStartup: cfg.entityExtraction.startup.downloadOnStartup,
583
+ debugEnabled: cfg.debug.enabled,
584
+ debugIncludePayloads: cfg.debug.includePayloads,
585
+ debugSamplingRate: cfg.debug.logSamplingRate,
586
+ });
440
587
 
441
588
  // Bootstrap is async by design so tool availability is not blocked.
442
589
  void runBootstrapIfNeeded({
@@ -446,6 +593,11 @@ const memoryBraidPlugin = {
446
593
  log,
447
594
  targets,
448
595
  runId,
596
+ }).catch((err) => {
597
+ log.warn("memory_braid.bootstrap.error", {
598
+ runId,
599
+ error: err instanceof Error ? err.message : String(err),
600
+ });
449
601
  });
450
602
 
451
603
  // One startup reconcile pass (non-blocking).
@@ -456,8 +608,30 @@ const memoryBraidPlugin = {
456
608
  log,
457
609
  targets,
458
610
  reason: "startup",
611
+ runId,
612
+ }).catch((err) => {
613
+ log.warn("memory_braid.reconcile.error", {
614
+ runId,
615
+ reason: "startup",
616
+ error: err instanceof Error ? err.message : String(err),
617
+ });
459
618
  });
460
619
 
620
+ if (cfg.entityExtraction.enabled && cfg.entityExtraction.startup.downloadOnStartup) {
621
+ void entityExtraction
622
+ .warmup({
623
+ runId,
624
+ reason: "startup",
625
+ })
626
+ .catch((err) => {
627
+ log.warn("memory_braid.entity.warmup", {
628
+ runId,
629
+ reason: "startup",
630
+ error: err instanceof Error ? err.message : String(err),
631
+ });
632
+ });
633
+ }
634
+
461
635
  if (cfg.reconcile.enabled) {
462
636
  const intervalMs = cfg.reconcile.intervalMinutes * 60 * 1000;
463
637
  serviceTimer = setInterval(() => {
@@ -44,6 +44,8 @@ type OssClientLike = {
44
44
  delete: (memoryId: string) => Promise<{ message: string }>;
45
45
  };
46
46
 
47
+ type OssMemoryCtor = new (config?: Record<string, unknown>) => OssClientLike;
48
+
47
49
  function extractCloudText(memory: CloudRecord): string {
48
50
  const byData = memory.data?.memory;
49
51
  if (typeof byData === "string" && byData.trim()) {
@@ -102,6 +104,30 @@ function asNonEmptyString(value: unknown): string | undefined {
102
104
  return trimmed ? trimmed : undefined;
103
105
  }
104
106
 
107
+ function asOssMemoryCtor(value: unknown): OssMemoryCtor | undefined {
108
+ if (typeof value !== "function") {
109
+ return undefined;
110
+ }
111
+ return value as OssMemoryCtor;
112
+ }
113
+
114
+ export function resolveOssMemoryCtor(moduleValue: unknown): OssMemoryCtor | undefined {
115
+ if (!moduleValue) {
116
+ return undefined;
117
+ }
118
+
119
+ const mod = asRecord(moduleValue);
120
+ const defaultMod = asRecord(mod.default);
121
+
122
+ return (
123
+ asOssMemoryCtor(mod.Memory) ??
124
+ asOssMemoryCtor(mod.MemoryClient) ??
125
+ asOssMemoryCtor(defaultMod.Memory) ??
126
+ asOssMemoryCtor(defaultMod.MemoryClient) ??
127
+ asOssMemoryCtor(mod.default)
128
+ );
129
+ }
130
+
105
131
  function resolveStateDir(explicitStateDir?: string): string {
106
132
  const resolved =
107
133
  explicitStateDir?.trim() ||
@@ -319,10 +345,13 @@ export class Mem0Adapter {
319
345
 
320
346
  try {
321
347
  const mod = await import("mem0ai/oss");
322
- const Memory = (mod as { Memory?: new (config?: Record<string, unknown>) => OssClientLike })
323
- .Memory;
348
+ const Memory = resolveOssMemoryCtor(mod);
324
349
  if (!Memory) {
325
- throw new Error("mem0ai/oss Memory export not found");
350
+ const exportKeys = Object.keys(asRecord(mod));
351
+ const defaultKeys = Object.keys(asRecord(asRecord(mod).default));
352
+ throw new Error(
353
+ `mem0ai/oss Memory export not found (exports=${exportKeys.join(",") || "none"}; default=${defaultKeys.join(",") || "none"})`,
354
+ );
326
355
  }
327
356
 
328
357
  const providedConfig = this.cfg.mem0.ossConfig;
package/src/state.ts CHANGED
@@ -97,22 +97,38 @@ export async function writeCaptureDedupeState(
97
97
  export async function withStateLock<T>(
98
98
  lockFilePath: string,
99
99
  fn: () => Promise<T>,
100
- options?: { retries?: number; retryDelayMs?: number },
100
+ options?: { retries?: number; retryDelayMs?: number; staleLockMs?: number },
101
101
  ): Promise<T> {
102
102
  const retries = options?.retries ?? 12;
103
103
  const retryDelayMs = options?.retryDelayMs ?? 150;
104
+ const staleLockMs = options?.staleLockMs ?? 30_000;
104
105
  await fs.mkdir(path.dirname(lockFilePath), { recursive: true });
105
106
 
106
107
  let handle: fs.FileHandle | null = null;
107
108
  for (let attempt = 0; attempt <= retries; attempt += 1) {
108
109
  try {
109
110
  handle = await fs.open(lockFilePath, "wx");
111
+ await handle.writeFile(
112
+ `${JSON.stringify({
113
+ pid: process.pid,
114
+ startedAt: new Date().toISOString(),
115
+ })}\n`,
116
+ "utf8",
117
+ );
110
118
  break;
111
119
  } catch (err) {
112
120
  const code = (err as { code?: string }).code;
113
- if (code !== "EEXIST" || attempt >= retries) {
121
+ if (code !== "EEXIST") {
114
122
  throw err;
115
123
  }
124
+ const recovered = await recoverStaleLock(lockFilePath, staleLockMs);
125
+ if (recovered) {
126
+ attempt -= 1;
127
+ continue;
128
+ }
129
+ if (attempt >= retries) {
130
+ throw new Error(`Failed to acquire lock for ${lockFilePath}: lock file already exists`);
131
+ }
116
132
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
117
133
  }
118
134
  }
@@ -128,3 +144,56 @@ export async function withStateLock<T>(
128
144
  await fs.unlink(lockFilePath).catch(() => undefined);
129
145
  }
130
146
  }
147
+
148
+ function isProcessAlive(pid: number): boolean {
149
+ try {
150
+ process.kill(pid, 0);
151
+ return true;
152
+ } catch (err) {
153
+ const code = (err as { code?: string }).code;
154
+ if (code === "ESRCH") {
155
+ return false;
156
+ }
157
+ return true;
158
+ }
159
+ }
160
+
161
+ async function recoverStaleLock(lockFilePath: string, staleLockMs: number): Promise<boolean> {
162
+ let stat: Awaited<ReturnType<typeof fs.stat>>;
163
+ try {
164
+ stat = await fs.stat(lockFilePath);
165
+ } catch {
166
+ return false;
167
+ }
168
+
169
+ const ageMs = Date.now() - stat.mtimeMs;
170
+
171
+ let raw: string | null = null;
172
+ try {
173
+ raw = await fs.readFile(lockFilePath, "utf8");
174
+ } catch {
175
+ raw = null;
176
+ }
177
+
178
+ if (raw) {
179
+ try {
180
+ const parsed = JSON.parse(raw) as { pid?: unknown };
181
+ if (typeof parsed.pid === "number" && Number.isFinite(parsed.pid)) {
182
+ if (isProcessAlive(parsed.pid)) {
183
+ return false;
184
+ }
185
+ await fs.unlink(lockFilePath).catch(() => undefined);
186
+ return true;
187
+ }
188
+ } catch {
189
+ // Legacy lock file format, handled by age-based fallback below.
190
+ }
191
+ }
192
+
193
+ if (ageMs < staleLockMs) {
194
+ return false;
195
+ }
196
+
197
+ await fs.unlink(lockFilePath).catch(() => undefined);
198
+ return true;
199
+ }