memory-braid 0.3.7 → 0.4.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/src/index.ts CHANGED
@@ -11,17 +11,20 @@ import { MemoryBraidLogger } from "./logger.js";
11
11
  import { resolveLocalTools, runLocalGet, runLocalSearch } from "./local-memory.js";
12
12
  import { Mem0Adapter } from "./mem0-client.js";
13
13
  import { mergeWithRrf } from "./merge.js";
14
- import { resolveTargets, runReconcileOnce } from "./reconcile.js";
15
14
  import {
16
15
  createStatePaths,
17
16
  ensureStateDir,
18
17
  readCaptureDedupeState,
18
+ readLifecycleState,
19
+ readStatsState,
19
20
  type StatePaths,
21
+ withStateLock,
20
22
  writeCaptureDedupeState,
23
+ writeLifecycleState,
24
+ writeStatsState,
21
25
  } from "./state.js";
22
- import type { MemoryBraidResult, ScopeKey, TargetWorkspace } from "./types.js";
26
+ import type { LifecycleEntry, MemoryBraidResult, ScopeKey } from "./types.js";
23
27
  import { normalizeForHash, sha256 } from "./chunking.js";
24
- import { runBootstrapIfNeeded } from "./bootstrap.js";
25
28
 
26
29
  function jsonToolResult(payload: unknown) {
27
30
  return {
@@ -95,12 +98,341 @@ function formatEntityExtractionStatus(params: {
95
98
  ].join("\n");
96
99
  }
97
100
 
101
+ function asRecord(value: unknown): Record<string, unknown> {
102
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
103
+ return {};
104
+ }
105
+ return value as Record<string, unknown>;
106
+ }
107
+
108
+ function resolveCoreTemporalDecay(params: {
109
+ config?: unknown;
110
+ agentId?: string;
111
+ }): { enabled: boolean; halfLifeDays: number } {
112
+ const root = asRecord(params.config);
113
+ const agents = asRecord(root.agents);
114
+ const defaults = asRecord(agents.defaults);
115
+ const defaultMemorySearch = asRecord(defaults.memorySearch);
116
+ const defaultTemporalDecay = asRecord(asRecord(asRecord(defaultMemorySearch.query).hybrid).temporalDecay);
117
+
118
+ const requestedAgent = (params.agentId ?? "").trim().toLowerCase();
119
+ let agentTemporalDecay: Record<string, unknown> = {};
120
+ if (requestedAgent) {
121
+ const agentList = Array.isArray(agents.list) ? agents.list : [];
122
+ for (const entry of agentList) {
123
+ const row = asRecord(entry);
124
+ const rowAgentId = typeof row.id === "string" ? row.id.trim().toLowerCase() : "";
125
+ if (!rowAgentId || rowAgentId !== requestedAgent) {
126
+ continue;
127
+ }
128
+ const memorySearch = asRecord(row.memorySearch);
129
+ agentTemporalDecay = asRecord(asRecord(asRecord(memorySearch.query).hybrid).temporalDecay);
130
+ break;
131
+ }
132
+ }
133
+
134
+ const enabledRaw =
135
+ typeof agentTemporalDecay.enabled === "boolean"
136
+ ? agentTemporalDecay.enabled
137
+ : typeof defaultTemporalDecay.enabled === "boolean"
138
+ ? defaultTemporalDecay.enabled
139
+ : false;
140
+ const halfLifeRaw =
141
+ typeof agentTemporalDecay.halfLifeDays === "number"
142
+ ? agentTemporalDecay.halfLifeDays
143
+ : typeof defaultTemporalDecay.halfLifeDays === "number"
144
+ ? defaultTemporalDecay.halfLifeDays
145
+ : 30;
146
+ const halfLifeDays = Math.max(1, Math.min(3650, Math.round(halfLifeRaw)));
147
+
148
+ return {
149
+ enabled: enabledRaw,
150
+ halfLifeDays,
151
+ };
152
+ }
153
+
154
+ function resolveDateFromPath(pathValue?: string): number | undefined {
155
+ if (!pathValue) {
156
+ return undefined;
157
+ }
158
+ const match = /(?:^|[/\\])memory[/\\](\d{4})-(\d{2})-(\d{2})\.md$/i.exec(pathValue);
159
+ if (!match) {
160
+ return undefined;
161
+ }
162
+ const [, yearRaw, monthRaw, dayRaw] = match;
163
+ const year = Number(yearRaw);
164
+ const month = Number(monthRaw);
165
+ const day = Number(dayRaw);
166
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
167
+ return undefined;
168
+ }
169
+ const parsed = new Date(year, month - 1, day).getTime();
170
+ return Number.isFinite(parsed) ? parsed : undefined;
171
+ }
172
+
173
+ function resolveTimestampMs(result: MemoryBraidResult): number | undefined {
174
+ const metadata = asRecord(result.metadata);
175
+ const fields = [
176
+ metadata.indexedAt,
177
+ metadata.updatedAt,
178
+ metadata.createdAt,
179
+ metadata.timestamp,
180
+ metadata.lastSeenAt,
181
+ ];
182
+ for (const value of fields) {
183
+ if (typeof value === "string") {
184
+ const parsed = Date.parse(value);
185
+ if (Number.isFinite(parsed)) {
186
+ return parsed;
187
+ }
188
+ }
189
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
190
+ return value > 1e12 ? value : value * 1000;
191
+ }
192
+ }
193
+ return resolveDateFromPath(result.path);
194
+ }
195
+
196
+ function applyTemporalDecayToMem0(params: {
197
+ results: MemoryBraidResult[];
198
+ halfLifeDays: number;
199
+ nowMs: number;
200
+ }): { results: MemoryBraidResult[]; decayed: number; missingTimestamp: number } {
201
+ if (params.results.length === 0) {
202
+ return {
203
+ results: params.results,
204
+ decayed: 0,
205
+ missingTimestamp: 0,
206
+ };
207
+ }
208
+
209
+ const lambda = Math.LN2 / Math.max(1, params.halfLifeDays);
210
+ let decayed = 0;
211
+ let missingTimestamp = 0;
212
+ const out = params.results.map((result, index) => {
213
+ const ts = resolveTimestampMs(result);
214
+ if (!ts) {
215
+ missingTimestamp += 1;
216
+ return { result, index };
217
+ }
218
+ const ageDays = Math.max(0, (params.nowMs - ts) / (24 * 60 * 60 * 1000));
219
+ const decay = Math.exp(-lambda * ageDays);
220
+ decayed += 1;
221
+ return {
222
+ index,
223
+ result: {
224
+ ...result,
225
+ score: result.score * decay,
226
+ },
227
+ };
228
+ });
229
+
230
+ out.sort((left, right) => {
231
+ const scoreDelta = right.result.score - left.result.score;
232
+ if (scoreDelta !== 0) {
233
+ return scoreDelta;
234
+ }
235
+ return left.index - right.index;
236
+ });
237
+
238
+ return {
239
+ results: out.map((entry) => entry.result),
240
+ decayed,
241
+ missingTimestamp,
242
+ };
243
+ }
244
+
245
+ function resolveLifecycleReferenceTs(entry: LifecycleEntry, reinforceOnRecall: boolean): number {
246
+ const capturedTs = Number.isFinite(entry.lastCapturedAt)
247
+ ? entry.lastCapturedAt
248
+ : Number.isFinite(entry.createdAt)
249
+ ? entry.createdAt
250
+ : 0;
251
+ if (!reinforceOnRecall) {
252
+ return capturedTs;
253
+ }
254
+ const recalledTs = Number.isFinite(entry.lastRecalledAt) ? entry.lastRecalledAt : 0;
255
+ return Math.max(capturedTs, recalledTs);
256
+ }
257
+
258
+ async function reinforceLifecycleEntries(params: {
259
+ cfg: ReturnType<typeof parseConfig>;
260
+ log: MemoryBraidLogger;
261
+ statePaths: StatePaths;
262
+ runId: string;
263
+ scope: ScopeKey;
264
+ results: MemoryBraidResult[];
265
+ }): Promise<void> {
266
+ if (!params.cfg.lifecycle.enabled || !params.cfg.lifecycle.reinforceOnRecall) {
267
+ return;
268
+ }
269
+
270
+ const memoryIds = Array.from(
271
+ new Set(
272
+ params.results
273
+ .filter((result) => result.source === "mem0" && typeof result.id === "string" && result.id)
274
+ .map((result) => result.id as string),
275
+ ),
276
+ );
277
+ if (memoryIds.length === 0) {
278
+ return;
279
+ }
280
+
281
+ const now = Date.now();
282
+ const updatedIds = await withStateLock(params.statePaths.stateLockFile, async () => {
283
+ const lifecycle = await readLifecycleState(params.statePaths);
284
+ const touched: string[] = [];
285
+
286
+ for (const memoryId of memoryIds) {
287
+ const entry = lifecycle.entries[memoryId];
288
+ if (!entry) {
289
+ continue;
290
+ }
291
+ lifecycle.entries[memoryId] = {
292
+ ...entry,
293
+ recallCount: Math.max(0, entry.recallCount ?? 0) + 1,
294
+ lastRecalledAt: now,
295
+ updatedAt: now,
296
+ };
297
+ touched.push(memoryId);
298
+ }
299
+
300
+ if (touched.length > 0) {
301
+ await writeLifecycleState(params.statePaths, lifecycle);
302
+ }
303
+
304
+ return touched;
305
+ });
306
+
307
+ if (updatedIds.length === 0) {
308
+ return;
309
+ }
310
+
311
+ params.log.debug("memory_braid.lifecycle.reinforce", {
312
+ runId: params.runId,
313
+ workspaceHash: params.scope.workspaceHash,
314
+ agentId: params.scope.agentId,
315
+ sessionKey: params.scope.sessionKey,
316
+ matchedResults: memoryIds.length,
317
+ reinforced: updatedIds.length,
318
+ });
319
+ }
320
+
321
+ async function runLifecycleCleanupOnce(params: {
322
+ cfg: ReturnType<typeof parseConfig>;
323
+ mem0: Mem0Adapter;
324
+ log: MemoryBraidLogger;
325
+ statePaths: StatePaths;
326
+ reason: "startup" | "interval" | "command";
327
+ runId?: string;
328
+ }): Promise<{ scanned: number; expired: number; deleted: number; failed: number }> {
329
+ if (!params.cfg.lifecycle.enabled) {
330
+ return {
331
+ scanned: 0,
332
+ expired: 0,
333
+ deleted: 0,
334
+ failed: 0,
335
+ };
336
+ }
337
+
338
+ const now = Date.now();
339
+ const ttlMs = params.cfg.lifecycle.captureTtlDays * 24 * 60 * 60 * 1000;
340
+ const expiredCandidates = await withStateLock(params.statePaths.stateLockFile, async () => {
341
+ const lifecycle = await readLifecycleState(params.statePaths);
342
+ const expired: Array<{ memoryId: string; scope: ScopeKey }> = [];
343
+ const malformedIds: string[] = [];
344
+
345
+ for (const [memoryId, entry] of Object.entries(lifecycle.entries)) {
346
+ if (!memoryId || !entry.workspaceHash || !entry.agentId) {
347
+ malformedIds.push(memoryId);
348
+ continue;
349
+ }
350
+ const referenceTs = resolveLifecycleReferenceTs(entry, params.cfg.lifecycle.reinforceOnRecall);
351
+ if (!Number.isFinite(referenceTs) || referenceTs <= 0) {
352
+ malformedIds.push(memoryId);
353
+ continue;
354
+ }
355
+ if (now - referenceTs < ttlMs) {
356
+ continue;
357
+ }
358
+ expired.push({
359
+ memoryId,
360
+ scope: {
361
+ workspaceHash: entry.workspaceHash,
362
+ agentId: entry.agentId,
363
+ sessionKey: entry.sessionKey,
364
+ },
365
+ });
366
+ }
367
+
368
+ for (const memoryId of malformedIds) {
369
+ delete lifecycle.entries[memoryId];
370
+ }
371
+ if (malformedIds.length > 0) {
372
+ await writeLifecycleState(params.statePaths, lifecycle);
373
+ }
374
+
375
+ return {
376
+ scanned: Object.keys(lifecycle.entries).length + malformedIds.length,
377
+ expired,
378
+ };
379
+ });
380
+
381
+ let deleted = 0;
382
+ let failed = 0;
383
+ const deletedIds = new Set<string>();
384
+ for (const candidate of expiredCandidates.expired) {
385
+ const ok = await params.mem0.deleteMemory({
386
+ memoryId: candidate.memoryId,
387
+ scope: candidate.scope,
388
+ runId: params.runId,
389
+ });
390
+ if (ok) {
391
+ deleted += 1;
392
+ deletedIds.add(candidate.memoryId);
393
+ } else {
394
+ failed += 1;
395
+ }
396
+ }
397
+
398
+ await withStateLock(params.statePaths.stateLockFile, async () => {
399
+ const lifecycle = await readLifecycleState(params.statePaths);
400
+ for (const memoryId of deletedIds) {
401
+ delete lifecycle.entries[memoryId];
402
+ }
403
+ lifecycle.lastCleanupAt = new Date(now).toISOString();
404
+ lifecycle.lastCleanupReason = params.reason;
405
+ lifecycle.lastCleanupScanned = expiredCandidates.scanned;
406
+ lifecycle.lastCleanupExpired = expiredCandidates.expired.length;
407
+ lifecycle.lastCleanupDeleted = deleted;
408
+ lifecycle.lastCleanupFailed = failed;
409
+ await writeLifecycleState(params.statePaths, lifecycle);
410
+ });
411
+
412
+ params.log.debug("memory_braid.lifecycle.cleanup", {
413
+ runId: params.runId,
414
+ reason: params.reason,
415
+ scanned: expiredCandidates.scanned,
416
+ expired: expiredCandidates.expired.length,
417
+ deleted,
418
+ failed,
419
+ });
420
+
421
+ return {
422
+ scanned: expiredCandidates.scanned,
423
+ expired: expiredCandidates.expired.length,
424
+ deleted,
425
+ failed,
426
+ };
427
+ }
428
+
98
429
  async function runHybridRecall(params: {
99
430
  api: OpenClawPluginApi;
100
431
  cfg: ReturnType<typeof parseConfig>;
101
432
  mem0: Mem0Adapter;
102
433
  log: MemoryBraidLogger;
103
434
  ctx: OpenClawPluginToolContext;
435
+ statePaths?: StatePaths | null;
104
436
  query: string;
105
437
  toolCallId?: string;
106
438
  args?: Record<string, unknown>;
@@ -151,24 +483,63 @@ async function runHybridRecall(params: {
151
483
 
152
484
  const scope = resolveScopeFromToolContext(params.ctx);
153
485
  const mem0Started = Date.now();
154
- const mem0Search = await params.mem0.searchMemories({
486
+ const mem0Raw = await params.mem0.searchMemories({
155
487
  query: params.query,
156
488
  maxResults,
157
489
  scope,
158
490
  runId: params.runId,
159
491
  });
492
+ const mem0Search = mem0Raw.filter((result) => {
493
+ const sourceType = asRecord(result.metadata).sourceType;
494
+ return sourceType !== "markdown" && sourceType !== "session";
495
+ });
496
+ let mem0ForMerge = mem0Search;
497
+ if (params.cfg.timeDecay.enabled) {
498
+ const coreDecay = resolveCoreTemporalDecay({
499
+ config: params.ctx.config,
500
+ agentId: params.ctx.agentId,
501
+ });
502
+ if (coreDecay.enabled) {
503
+ const decayed = applyTemporalDecayToMem0({
504
+ results: mem0Search,
505
+ halfLifeDays: coreDecay.halfLifeDays,
506
+ nowMs: Date.now(),
507
+ });
508
+ mem0ForMerge = decayed.results;
509
+ params.log.debug("memory_braid.search.mem0_decay", {
510
+ runId: params.runId,
511
+ agentId: scope.agentId,
512
+ sessionKey: scope.sessionKey,
513
+ workspaceHash: scope.workspaceHash,
514
+ enabled: true,
515
+ halfLifeDays: coreDecay.halfLifeDays,
516
+ inputCount: mem0Search.length,
517
+ decayed: decayed.decayed,
518
+ missingTimestamp: decayed.missingTimestamp,
519
+ });
520
+ } else {
521
+ params.log.debug("memory_braid.search.mem0_decay", {
522
+ runId: params.runId,
523
+ agentId: scope.agentId,
524
+ sessionKey: scope.sessionKey,
525
+ workspaceHash: scope.workspaceHash,
526
+ enabled: false,
527
+ reason: "memory_core_temporal_decay_disabled",
528
+ });
529
+ }
530
+ }
160
531
  params.log.debug("memory_braid.search.mem0", {
161
532
  runId: params.runId,
162
533
  agentId: scope.agentId,
163
534
  sessionKey: scope.sessionKey,
164
535
  workspaceHash: scope.workspaceHash,
165
- count: mem0Search.length,
536
+ count: mem0ForMerge.length,
166
537
  durMs: Date.now() - mem0Started,
167
538
  });
168
539
 
169
540
  const merged = mergeWithRrf({
170
541
  local: localSearch.results,
171
- mem0: mem0Search,
542
+ mem0: mem0ForMerge,
172
543
  options: {
173
544
  rrfK: params.cfg.recall.merge.rrfK,
174
545
  localWeight: params.cfg.recall.merge.localWeight,
@@ -193,22 +564,34 @@ async function runHybridRecall(params: {
193
564
  runId: params.runId,
194
565
  workspaceHash: scope.workspaceHash,
195
566
  localCount: localSearch.results.length,
196
- mem0Count: mem0Search.length,
567
+ mem0Count: mem0ForMerge.length,
197
568
  mergedCount: merged.length,
198
569
  dedupedCount: deduped.length,
199
570
  });
200
571
 
572
+ const topMerged = deduped.slice(0, maxResults);
573
+ if (params.statePaths) {
574
+ await reinforceLifecycleEntries({
575
+ cfg: params.cfg,
576
+ log: params.log,
577
+ statePaths: params.statePaths,
578
+ runId: params.runId,
579
+ scope,
580
+ results: topMerged,
581
+ });
582
+ }
583
+
201
584
  return {
202
585
  local: localSearch.results,
203
- mem0: mem0Search,
204
- merged: deduped.slice(0, maxResults),
586
+ mem0: mem0ForMerge,
587
+ merged: topMerged,
205
588
  };
206
589
  }
207
590
 
208
591
  const memoryBraidPlugin = {
209
592
  id: "memory-braid",
210
593
  name: "Memory Braid",
211
- description: "Hybrid memory plugin with local + Mem0 recall, capture, bootstrap import, and reconcile",
594
+ description: "Hybrid memory plugin with local + Mem0 recall and capture.",
212
595
  kind: "memory" as const,
213
596
  configSchema: pluginConfigSchema,
214
597
 
@@ -221,9 +604,29 @@ const memoryBraidPlugin = {
221
604
  stateDir: initialStateDir,
222
605
  });
223
606
 
224
- let serviceTimer: NodeJS.Timeout | null = null;
607
+ let lifecycleTimer: NodeJS.Timeout | null = null;
225
608
  let statePaths: StatePaths | null = null;
226
- let targets: TargetWorkspace[] = [];
609
+
610
+ async function ensureRuntimeStatePaths(): Promise<StatePaths | null> {
611
+ if (statePaths) {
612
+ return statePaths;
613
+ }
614
+ const resolvedStateDir = api.runtime.state.resolveStateDir();
615
+ if (!resolvedStateDir) {
616
+ return null;
617
+ }
618
+
619
+ const next = createStatePaths(resolvedStateDir);
620
+ try {
621
+ await ensureStateDir(next);
622
+ statePaths = next;
623
+ mem0.setStateDir(resolvedStateDir);
624
+ entityExtraction.setStateDir(resolvedStateDir);
625
+ return statePaths;
626
+ } catch {
627
+ return null;
628
+ }
629
+ }
227
630
 
228
631
  api.registerTool(
229
632
  (ctx) => {
@@ -259,12 +662,14 @@ const memoryBraidPlugin = {
259
662
  });
260
663
  }
261
664
 
665
+ const runtimeStatePaths = await ensureRuntimeStatePaths();
262
666
  const recall = await runHybridRecall({
263
667
  api,
264
668
  cfg,
265
669
  mem0,
266
670
  log,
267
671
  ctx,
672
+ statePaths: runtimeStatePaths,
268
673
  query,
269
674
  toolCallId,
270
675
  args,
@@ -320,7 +725,7 @@ const memoryBraidPlugin = {
320
725
 
321
726
  api.registerCommand({
322
727
  name: "memorybraid",
323
- description: "Memory Braid status and entity extraction warmup.",
728
+ description: "Memory Braid status, stats, lifecycle cleanup, and entity extraction warmup.",
324
729
  acceptsArgs: true,
325
730
  handler: async (ctx) => {
326
731
  const args = ctx.args?.trim() ?? "";
@@ -328,14 +733,124 @@ const memoryBraidPlugin = {
328
733
  const action = (tokens[0] ?? "status").toLowerCase();
329
734
 
330
735
  if (action === "status") {
736
+ const coreDecay = resolveCoreTemporalDecay({
737
+ config: ctx.config,
738
+ });
739
+ const paths = await ensureRuntimeStatePaths();
740
+ const lifecycle =
741
+ cfg.lifecycle.enabled && paths
742
+ ? await readLifecycleState(paths)
743
+ : { entries: {}, lastCleanupAt: undefined, lastCleanupReason: undefined };
331
744
  return {
332
745
  text: [
333
746
  `capture.mode: ${cfg.capture.mode}`,
747
+ `capture.includeAssistant: ${cfg.capture.includeAssistant}`,
748
+ `timeDecay.enabled: ${cfg.timeDecay.enabled}`,
749
+ `memoryCore.temporalDecay.enabled: ${coreDecay.enabled}`,
750
+ `memoryCore.temporalDecay.halfLifeDays: ${coreDecay.halfLifeDays}`,
751
+ `lifecycle.enabled: ${cfg.lifecycle.enabled}`,
752
+ `lifecycle.captureTtlDays: ${cfg.lifecycle.captureTtlDays}`,
753
+ `lifecycle.cleanupIntervalMinutes: ${cfg.lifecycle.cleanupIntervalMinutes}`,
754
+ `lifecycle.reinforceOnRecall: ${cfg.lifecycle.reinforceOnRecall}`,
755
+ `lifecycle.tracked: ${Object.keys(lifecycle.entries).length}`,
756
+ `lifecycle.lastCleanupAt: ${lifecycle.lastCleanupAt ?? "n/a"}`,
757
+ `lifecycle.lastCleanupReason: ${lifecycle.lastCleanupReason ?? "n/a"}`,
334
758
  formatEntityExtractionStatus(entityExtraction.getStatus()),
335
759
  ].join("\n\n"),
336
760
  };
337
761
  }
338
762
 
763
+ if (action === "stats") {
764
+ const paths = await ensureRuntimeStatePaths();
765
+ if (!paths) {
766
+ return {
767
+ text: "Stats unavailable: state directory is not ready.",
768
+ isError: true,
769
+ };
770
+ }
771
+
772
+ const stats = await readStatsState(paths);
773
+ const lifecycle = await readLifecycleState(paths);
774
+ const capture = stats.capture;
775
+ const mem0SuccessRate =
776
+ capture.mem0AddAttempts > 0
777
+ ? `${((capture.mem0AddWithId / capture.mem0AddAttempts) * 100).toFixed(1)}%`
778
+ : "n/a";
779
+ const mem0NoIdRate =
780
+ capture.mem0AddAttempts > 0
781
+ ? `${((capture.mem0AddWithoutId / capture.mem0AddAttempts) * 100).toFixed(1)}%`
782
+ : "n/a";
783
+ const dedupeSkipRate =
784
+ capture.candidates > 0
785
+ ? `${((capture.dedupeSkipped / capture.candidates) * 100).toFixed(1)}%`
786
+ : "n/a";
787
+
788
+ return {
789
+ text: [
790
+ "Memory Braid stats",
791
+ "",
792
+ "Capture:",
793
+ `- runs: ${capture.runs}`,
794
+ `- runsWithCandidates: ${capture.runsWithCandidates}`,
795
+ `- runsNoCandidates: ${capture.runsNoCandidates}`,
796
+ `- candidates: ${capture.candidates}`,
797
+ `- dedupeSkipped: ${capture.dedupeSkipped} (${dedupeSkipRate})`,
798
+ `- persisted: ${capture.persisted}`,
799
+ `- mem0AddAttempts: ${capture.mem0AddAttempts}`,
800
+ `- mem0AddWithId: ${capture.mem0AddWithId} (${mem0SuccessRate})`,
801
+ `- mem0AddWithoutId: ${capture.mem0AddWithoutId} (${mem0NoIdRate})`,
802
+ `- lastRunAt: ${capture.lastRunAt ?? "n/a"}`,
803
+ "",
804
+ "Lifecycle:",
805
+ `- enabled: ${cfg.lifecycle.enabled}`,
806
+ `- tracked: ${Object.keys(lifecycle.entries).length}`,
807
+ `- captureTtlDays: ${cfg.lifecycle.captureTtlDays}`,
808
+ `- cleanupIntervalMinutes: ${cfg.lifecycle.cleanupIntervalMinutes}`,
809
+ `- reinforceOnRecall: ${cfg.lifecycle.reinforceOnRecall}`,
810
+ `- lastCleanupAt: ${lifecycle.lastCleanupAt ?? "n/a"}`,
811
+ `- lastCleanupReason: ${lifecycle.lastCleanupReason ?? "n/a"}`,
812
+ `- lastCleanupScanned: ${lifecycle.lastCleanupScanned ?? "n/a"}`,
813
+ `- lastCleanupExpired: ${lifecycle.lastCleanupExpired ?? "n/a"}`,
814
+ `- lastCleanupDeleted: ${lifecycle.lastCleanupDeleted ?? "n/a"}`,
815
+ `- lastCleanupFailed: ${lifecycle.lastCleanupFailed ?? "n/a"}`,
816
+ ].join("\n"),
817
+ };
818
+ }
819
+
820
+ if (action === "cleanup") {
821
+ if (!cfg.lifecycle.enabled) {
822
+ return {
823
+ text: "Lifecycle cleanup skipped: lifecycle.enabled is false.",
824
+ isError: true,
825
+ };
826
+ }
827
+ const paths = await ensureRuntimeStatePaths();
828
+ if (!paths) {
829
+ return {
830
+ text: "Cleanup unavailable: state directory is not ready.",
831
+ isError: true,
832
+ };
833
+ }
834
+ const runId = log.newRunId();
835
+ const summary = await runLifecycleCleanupOnce({
836
+ cfg,
837
+ mem0,
838
+ log,
839
+ statePaths: paths,
840
+ reason: "command",
841
+ runId,
842
+ });
843
+ return {
844
+ text: [
845
+ "Lifecycle cleanup complete.",
846
+ `- scanned: ${summary.scanned}`,
847
+ `- expired: ${summary.expired}`,
848
+ `- deleted: ${summary.deleted}`,
849
+ `- failed: ${summary.failed}`,
850
+ ].join("\n"),
851
+ };
852
+ }
853
+
339
854
  if (action === "warmup") {
340
855
  const runId = log.newRunId();
341
856
  const forceReload = tokens.some((token) => token === "--force");
@@ -368,7 +883,7 @@ const memoryBraidPlugin = {
368
883
  }
369
884
 
370
885
  return {
371
- text: "Usage: /memorybraid [status|warmup [--force]]",
886
+ text: "Usage: /memorybraid [status|stats|cleanup|warmup [--force]]",
372
887
  };
373
888
  },
374
889
  });
@@ -381,6 +896,7 @@ const memoryBraidPlugin = {
381
896
  agentId: ctx.agentId,
382
897
  sessionKey: ctx.sessionKey,
383
898
  };
899
+ const runtimeStatePaths = await ensureRuntimeStatePaths();
384
900
 
385
901
  const recall = await runHybridRecall({
386
902
  api,
@@ -388,6 +904,7 @@ const memoryBraidPlugin = {
388
904
  mem0,
389
905
  log,
390
906
  ctx: toolCtx,
907
+ statePaths: runtimeStatePaths,
391
908
  query: event.prompt,
392
909
  args: {
393
910
  query: event.prompt,
@@ -427,8 +944,18 @@ const memoryBraidPlugin = {
427
944
  log,
428
945
  runId,
429
946
  });
947
+ const runtimeStatePaths = await ensureRuntimeStatePaths();
430
948
 
431
949
  if (candidates.length === 0) {
950
+ if (runtimeStatePaths) {
951
+ await withStateLock(runtimeStatePaths.stateLockFile, async () => {
952
+ const stats = await readStatsState(runtimeStatePaths);
953
+ stats.capture.runs += 1;
954
+ stats.capture.runsNoCandidates += 1;
955
+ stats.capture.lastRunAt = new Date().toISOString();
956
+ await writeStatsState(runtimeStatePaths, stats);
957
+ });
958
+ }
432
959
  log.debug("memory_braid.capture.skip", {
433
960
  runId,
434
961
  reason: "no_candidates",
@@ -439,35 +966,7 @@ const memoryBraidPlugin = {
439
966
  return;
440
967
  }
441
968
 
442
- if (!statePaths) {
443
- const resolvedStateDir = api.runtime.state.resolveStateDir();
444
- if (resolvedStateDir) {
445
- const lazyStatePaths = createStatePaths(resolvedStateDir);
446
- try {
447
- await ensureStateDir(lazyStatePaths);
448
- statePaths = lazyStatePaths;
449
- mem0.setStateDir(resolvedStateDir);
450
- entityExtraction.setStateDir(resolvedStateDir);
451
- log.info("memory_braid.state.ready", {
452
- runId,
453
- reason: "lazy_capture",
454
- stateDir: resolvedStateDir,
455
- });
456
- } catch (err) {
457
- log.warn("memory_braid.capture.skip", {
458
- runId,
459
- reason: "state_init_failed",
460
- workspaceHash: scope.workspaceHash,
461
- agentId: scope.agentId,
462
- sessionKey: scope.sessionKey,
463
- error: err instanceof Error ? err.message : String(err),
464
- });
465
- return;
466
- }
467
- }
468
- }
469
-
470
- if (!statePaths) {
969
+ if (!runtimeStatePaths) {
471
970
  log.warn("memory_braid.capture.skip", {
472
971
  runId,
473
972
  reason: "state_not_ready",
@@ -478,96 +977,135 @@ const memoryBraidPlugin = {
478
977
  return;
479
978
  }
480
979
 
481
- const dedupe = await readCaptureDedupeState(statePaths);
482
- const now = Date.now();
483
- const thirtyDays = 30 * 24 * 60 * 60 * 1000;
484
- for (const [key, ts] of Object.entries(dedupe.seen)) {
485
- if (now - ts > thirtyDays) {
486
- delete dedupe.seen[key];
487
- }
488
- }
489
-
490
- let persisted = 0;
491
- let dedupeSkipped = 0;
492
- let entityAnnotatedCandidates = 0;
493
- let totalEntitiesAttached = 0;
494
- let mem0AddAttempts = 0;
495
- let mem0AddWithId = 0;
496
- let mem0AddWithoutId = 0;
497
- for (const candidate of candidates) {
498
- const hash = sha256(normalizeForHash(candidate.text));
499
- if (dedupe.seen[hash]) {
500
- dedupeSkipped += 1;
501
- continue;
980
+ await withStateLock(runtimeStatePaths.stateLockFile, async () => {
981
+ const dedupe = await readCaptureDedupeState(runtimeStatePaths);
982
+ const stats = await readStatsState(runtimeStatePaths);
983
+ const lifecycle = cfg.lifecycle.enabled
984
+ ? await readLifecycleState(runtimeStatePaths)
985
+ : null;
986
+ const now = Date.now();
987
+ const thirtyDays = 30 * 24 * 60 * 60 * 1000;
988
+ for (const [key, ts] of Object.entries(dedupe.seen)) {
989
+ if (now - ts > thirtyDays) {
990
+ delete dedupe.seen[key];
991
+ }
502
992
  }
503
- dedupe.seen[hash] = now;
504
-
505
- const metadata: Record<string, unknown> = {
506
- sourceType: "capture",
507
- workspaceHash: scope.workspaceHash,
508
- agentId: scope.agentId,
509
- sessionKey: scope.sessionKey,
510
- category: candidate.category,
511
- captureScore: candidate.score,
512
- extractionSource: candidate.source,
513
- contentHash: hash,
514
- indexedAt: new Date().toISOString(),
515
- };
516
993
 
517
- if (cfg.entityExtraction.enabled) {
518
- const entities = await entityExtraction.extract({
519
- text: candidate.text,
520
- runId,
521
- });
522
- if (entities.length > 0) {
523
- entityAnnotatedCandidates += 1;
524
- totalEntitiesAttached += entities.length;
525
- metadata.entityUris = entities.map((entity) => entity.canonicalUri);
526
- metadata.entities = entities;
994
+ let persisted = 0;
995
+ let dedupeSkipped = 0;
996
+ let entityAnnotatedCandidates = 0;
997
+ let totalEntitiesAttached = 0;
998
+ let mem0AddAttempts = 0;
999
+ let mem0AddWithId = 0;
1000
+ let mem0AddWithoutId = 0;
1001
+ for (const candidate of candidates) {
1002
+ const hash = sha256(normalizeForHash(candidate.text));
1003
+ if (dedupe.seen[hash]) {
1004
+ dedupeSkipped += 1;
1005
+ continue;
527
1006
  }
528
- }
529
1007
 
530
- mem0AddAttempts += 1;
531
- const addResult = await mem0.addMemory({
532
- text: candidate.text,
533
- scope,
534
- metadata,
535
- runId,
536
- });
537
- if (addResult.id) {
538
- mem0AddWithId += 1;
539
- } else {
540
- mem0AddWithoutId += 1;
541
- log.warn("memory_braid.capture.persist", {
542
- runId,
543
- reason: "mem0_add_missing_id",
1008
+ const metadata: Record<string, unknown> = {
1009
+ sourceType: "capture",
544
1010
  workspaceHash: scope.workspaceHash,
545
1011
  agentId: scope.agentId,
546
1012
  sessionKey: scope.sessionKey,
547
- contentHashPrefix: hash.slice(0, 12),
548
1013
  category: candidate.category,
1014
+ captureScore: candidate.score,
1015
+ extractionSource: candidate.source,
1016
+ contentHash: hash,
1017
+ indexedAt: new Date(now).toISOString(),
1018
+ };
1019
+
1020
+ if (cfg.entityExtraction.enabled) {
1021
+ const entities = await entityExtraction.extract({
1022
+ text: candidate.text,
1023
+ runId,
1024
+ });
1025
+ if (entities.length > 0) {
1026
+ entityAnnotatedCandidates += 1;
1027
+ totalEntitiesAttached += entities.length;
1028
+ metadata.entityUris = entities.map((entity) => entity.canonicalUri);
1029
+ metadata.entities = entities;
1030
+ }
1031
+ }
1032
+
1033
+ mem0AddAttempts += 1;
1034
+ const addResult = await mem0.addMemory({
1035
+ text: candidate.text,
1036
+ scope,
1037
+ metadata,
1038
+ runId,
549
1039
  });
1040
+ if (addResult.id) {
1041
+ dedupe.seen[hash] = now;
1042
+ mem0AddWithId += 1;
1043
+ persisted += 1;
1044
+ if (lifecycle) {
1045
+ const memoryId = addResult.id;
1046
+ const existing = lifecycle.entries[memoryId];
1047
+ lifecycle.entries[memoryId] = {
1048
+ memoryId,
1049
+ contentHash: hash,
1050
+ workspaceHash: scope.workspaceHash,
1051
+ agentId: scope.agentId,
1052
+ sessionKey: scope.sessionKey,
1053
+ category: candidate.category,
1054
+ createdAt: existing?.createdAt ?? now,
1055
+ lastCapturedAt: now,
1056
+ lastRecalledAt: existing?.lastRecalledAt,
1057
+ recallCount: existing?.recallCount ?? 0,
1058
+ updatedAt: now,
1059
+ };
1060
+ }
1061
+ } else {
1062
+ mem0AddWithoutId += 1;
1063
+ log.warn("memory_braid.capture.persist", {
1064
+ runId,
1065
+ reason: "mem0_add_missing_id",
1066
+ workspaceHash: scope.workspaceHash,
1067
+ agentId: scope.agentId,
1068
+ sessionKey: scope.sessionKey,
1069
+ contentHashPrefix: hash.slice(0, 12),
1070
+ category: candidate.category,
1071
+ });
1072
+ }
550
1073
  }
551
- persisted += 1;
552
- }
553
1074
 
554
- await writeCaptureDedupeState(statePaths, dedupe);
555
- log.debug("memory_braid.capture.persist", {
556
- runId,
557
- mode: cfg.capture.mode,
558
- workspaceHash: scope.workspaceHash,
559
- agentId: scope.agentId,
560
- sessionKey: scope.sessionKey,
561
- candidates: candidates.length,
562
- dedupeSkipped,
563
- persisted,
564
- mem0AddAttempts,
565
- mem0AddWithId,
566
- mem0AddWithoutId,
567
- entityExtractionEnabled: cfg.entityExtraction.enabled,
568
- entityAnnotatedCandidates,
569
- totalEntitiesAttached,
570
- }, true);
1075
+ stats.capture.runs += 1;
1076
+ stats.capture.runsWithCandidates += 1;
1077
+ stats.capture.candidates += candidates.length;
1078
+ stats.capture.dedupeSkipped += dedupeSkipped;
1079
+ stats.capture.persisted += persisted;
1080
+ stats.capture.mem0AddAttempts += mem0AddAttempts;
1081
+ stats.capture.mem0AddWithId += mem0AddWithId;
1082
+ stats.capture.mem0AddWithoutId += mem0AddWithoutId;
1083
+ stats.capture.entityAnnotatedCandidates += entityAnnotatedCandidates;
1084
+ stats.capture.totalEntitiesAttached += totalEntitiesAttached;
1085
+ stats.capture.lastRunAt = new Date(now).toISOString();
1086
+
1087
+ await writeCaptureDedupeState(runtimeStatePaths, dedupe);
1088
+ if (lifecycle) {
1089
+ await writeLifecycleState(runtimeStatePaths, lifecycle);
1090
+ }
1091
+ await writeStatsState(runtimeStatePaths, stats);
1092
+ log.debug("memory_braid.capture.persist", {
1093
+ runId,
1094
+ mode: cfg.capture.mode,
1095
+ workspaceHash: scope.workspaceHash,
1096
+ agentId: scope.agentId,
1097
+ sessionKey: scope.sessionKey,
1098
+ candidates: candidates.length,
1099
+ dedupeSkipped,
1100
+ persisted,
1101
+ mem0AddAttempts,
1102
+ mem0AddWithId,
1103
+ mem0AddWithoutId,
1104
+ entityExtractionEnabled: cfg.entityExtraction.enabled,
1105
+ entityAnnotatedCandidates,
1106
+ totalEntitiesAttached,
1107
+ }, true);
1108
+ });
571
1109
  });
572
1110
 
573
1111
  api.registerService({
@@ -577,31 +1115,26 @@ const memoryBraidPlugin = {
577
1115
  entityExtraction.setStateDir(ctx.stateDir);
578
1116
  statePaths = createStatePaths(ctx.stateDir);
579
1117
  await ensureStateDir(statePaths);
580
- targets = await resolveTargets({
581
- config: api.config as unknown as {
582
- agents?: {
583
- defaults?: { workspace?: string };
584
- list?: Array<{ id?: string; workspace?: string; default?: boolean }>;
585
- };
586
- },
587
- stateDir: ctx.stateDir,
588
- fallbackWorkspaceDir: ctx.workspaceDir,
589
- });
590
1118
 
591
1119
  const runId = log.newRunId();
592
1120
  log.info("memory_braid.startup", {
593
1121
  runId,
594
1122
  stateDir: ctx.stateDir,
595
- targets: targets.length,
596
1123
  });
597
1124
  log.info("memory_braid.config", {
598
1125
  runId,
599
1126
  mem0Mode: cfg.mem0.mode,
600
1127
  captureEnabled: cfg.capture.enabled,
601
1128
  captureMode: cfg.capture.mode,
1129
+ captureIncludeAssistant: cfg.capture.includeAssistant,
602
1130
  captureMaxItemsPerRun: cfg.capture.maxItemsPerRun,
603
1131
  captureMlProvider: cfg.capture.ml.provider ?? "unset",
604
1132
  captureMlModel: cfg.capture.ml.model ?? "unset",
1133
+ timeDecayEnabled: cfg.timeDecay.enabled,
1134
+ lifecycleEnabled: cfg.lifecycle.enabled,
1135
+ lifecycleCaptureTtlDays: cfg.lifecycle.captureTtlDays,
1136
+ lifecycleCleanupIntervalMinutes: cfg.lifecycle.cleanupIntervalMinutes,
1137
+ lifecycleReinforceOnRecall: cfg.lifecycle.reinforceOnRecall,
605
1138
  entityExtractionEnabled: cfg.entityExtraction.enabled,
606
1139
  entityProvider: cfg.entityExtraction.provider,
607
1140
  entityModel: cfg.entityExtraction.model,
@@ -613,32 +1146,15 @@ const memoryBraidPlugin = {
613
1146
  debugSamplingRate: cfg.debug.logSamplingRate,
614
1147
  });
615
1148
 
616
- // Bootstrap is async by design so tool availability is not blocked.
617
- void runBootstrapIfNeeded({
1149
+ void runLifecycleCleanupOnce({
618
1150
  cfg,
619
1151
  mem0,
620
- statePaths,
621
1152
  log,
622
- targets,
623
- runId,
624
- }).catch((err) => {
625
- log.warn("memory_braid.bootstrap.error", {
626
- runId,
627
- error: err instanceof Error ? err.message : String(err),
628
- });
629
- });
630
-
631
- // One startup reconcile pass (non-blocking).
632
- void runReconcileOnce({
633
- cfg,
634
- mem0,
635
1153
  statePaths,
636
- log,
637
- targets,
638
1154
  reason: "startup",
639
1155
  runId,
640
1156
  }).catch((err) => {
641
- log.warn("memory_braid.reconcile.error", {
1157
+ log.warn("memory_braid.lifecycle.cleanup", {
642
1158
  runId,
643
1159
  reason: "startup",
644
1160
  error: err instanceof Error ? err.message : String(err),
@@ -660,18 +1176,18 @@ const memoryBraidPlugin = {
660
1176
  });
661
1177
  }
662
1178
 
663
- if (cfg.reconcile.enabled) {
664
- const intervalMs = cfg.reconcile.intervalMinutes * 60 * 1000;
665
- serviceTimer = setInterval(() => {
666
- void runReconcileOnce({
1179
+ if (cfg.lifecycle.enabled) {
1180
+ const intervalMs = cfg.lifecycle.cleanupIntervalMinutes * 60 * 1000;
1181
+ lifecycleTimer = setInterval(() => {
1182
+ void runLifecycleCleanupOnce({
667
1183
  cfg,
668
1184
  mem0,
669
- statePaths: statePaths!,
670
1185
  log,
671
- targets,
1186
+ statePaths: statePaths!,
672
1187
  reason: "interval",
673
1188
  }).catch((err) => {
674
- log.warn("memory_braid.reconcile.error", {
1189
+ log.warn("memory_braid.lifecycle.cleanup", {
1190
+ reason: "interval",
675
1191
  error: err instanceof Error ? err.message : String(err),
676
1192
  });
677
1193
  });
@@ -679,9 +1195,9 @@ const memoryBraidPlugin = {
679
1195
  }
680
1196
  },
681
1197
  stop: async () => {
682
- if (serviceTimer) {
683
- clearInterval(serviceTimer);
684
- serviceTimer = null;
1198
+ if (lifecycleTimer) {
1199
+ clearInterval(lifecycleTimer);
1200
+ lifecycleTimer = null;
685
1201
  }
686
1202
  },
687
1203
  });