memory-braid 0.4.7 → 0.5.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
@@ -1,8 +1,13 @@
1
1
  import path from "node:path";
2
- import type {
3
- OpenClawPluginApi,
4
- OpenClawPluginToolContext,
5
- } from "openclaw/plugin-sdk";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
+ import {
4
+ assembleCaptureInput,
5
+ getPendingInboundTurn,
6
+ isLikelyTranscriptLikeText,
7
+ isOversizedAtomicMemory,
8
+ matchCandidateToCaptureInput,
9
+ normalizeHookMessages,
10
+ } from "./capture.js";
6
11
  import { parseConfig, pluginConfigSchema } from "./config.js";
7
12
  import { stagedDedupe } from "./dedupe.js";
8
13
  import { EntityExtractionManager } from "./entities.js";
@@ -11,21 +16,50 @@ import { MemoryBraidLogger } from "./logger.js";
11
16
  import { resolveLocalTools, runLocalGet, runLocalSearch } from "./local-memory.js";
12
17
  import { Mem0Adapter } from "./mem0-client.js";
13
18
  import { mergeWithRrf } from "./merge.js";
19
+ import {
20
+ appendUsageWindow,
21
+ createUsageSnapshot,
22
+ summarizeUsageWindow,
23
+ type UsageWindowEntry,
24
+ } from "./observability.js";
25
+ import {
26
+ buildAuditSummary,
27
+ buildQuarantineMetadata,
28
+ formatAuditSummary,
29
+ isQuarantinedMemory,
30
+ selectRemediationTargets,
31
+ type RemediationAction,
32
+ } from "./remediation.js";
14
33
  import {
15
34
  createStatePaths,
16
35
  ensureStateDir,
17
36
  readCaptureDedupeState,
18
37
  readLifecycleState,
38
+ readRemediationState,
19
39
  readStatsState,
20
40
  type StatePaths,
21
41
  withStateLock,
22
42
  writeCaptureDedupeState,
23
43
  writeLifecycleState,
44
+ writeRemediationState,
24
45
  writeStatsState,
25
46
  } from "./state.js";
26
- import type { LifecycleEntry, MemoryBraidResult, ScopeKey } from "./types.js";
47
+ import type {
48
+ LifecycleEntry,
49
+ MemoryBraidResult,
50
+ PendingInboundTurn,
51
+ ScopeKey,
52
+ } from "./types.js";
53
+ import { PLUGIN_CAPTURE_VERSION } from "./types.js";
27
54
  import { normalizeForHash, normalizeWhitespace, sha256 } from "./chunking.js";
28
55
 
56
+ type ToolContext = {
57
+ config?: unknown;
58
+ workspaceDir?: string;
59
+ agentId?: string;
60
+ sessionKey?: string;
61
+ };
62
+
29
63
  function jsonToolResult(payload: unknown) {
30
64
  return {
31
65
  content: [
@@ -43,7 +77,7 @@ function workspaceHashFromDir(workspaceDir?: string): string {
43
77
  return sha256(base.toLowerCase());
44
78
  }
45
79
 
46
- function resolveScopeFromToolContext(ctx: OpenClawPluginToolContext): ScopeKey {
80
+ function resolveScopeFromToolContext(ctx: ToolContext): ScopeKey {
47
81
  return {
48
82
  workspaceHash: workspaceHashFromDir(ctx.workspaceDir),
49
83
  agentId: (ctx.agentId ?? "main").trim() || "main",
@@ -63,55 +97,23 @@ function resolveScopeFromHookContext(ctx: {
63
97
  };
64
98
  }
65
99
 
66
- function extractHookMessageText(content: unknown): string {
67
- if (typeof content === "string") {
68
- return normalizeWhitespace(content);
69
- }
70
- if (!Array.isArray(content)) {
71
- return "";
72
- }
73
-
74
- const parts: string[] = [];
75
- for (const block of content) {
76
- if (!block || typeof block !== "object") {
77
- continue;
78
- }
79
- const item = block as { type?: unknown; text?: unknown };
80
- if (item.type === "text" && typeof item.text === "string") {
81
- const normalized = normalizeWhitespace(item.text);
82
- if (normalized) {
83
- parts.push(normalized);
84
- }
85
- }
86
- }
87
- return parts.join(" ");
100
+ function resolveWorkspaceDirFromConfig(config?: unknown): string | undefined {
101
+ const root = asRecord(config);
102
+ const agents = asRecord(root.agents);
103
+ const defaults = asRecord(agents.defaults);
104
+ const workspace =
105
+ typeof defaults.workspace === "string" ? defaults.workspace.trim() : "";
106
+ return workspace || undefined;
88
107
  }
89
108
 
90
- function normalizeHookMessages(messages: unknown[]): Array<{ role: string; text: string }> {
91
- const out: Array<{ role: string; text: string }> = [];
92
- for (const entry of messages) {
93
- if (!entry || typeof entry !== "object") {
94
- continue;
95
- }
96
-
97
- const direct = entry as { role?: unknown; content?: unknown };
98
- if (typeof direct.role === "string") {
99
- const text = extractHookMessageText(direct.content);
100
- if (text) {
101
- out.push({ role: direct.role, text });
102
- }
103
- continue;
104
- }
105
-
106
- const wrapped = entry as { message?: { role?: unknown; content?: unknown } };
107
- if (wrapped.message && typeof wrapped.message.role === "string") {
108
- const text = extractHookMessageText(wrapped.message.content);
109
- if (text) {
110
- out.push({ role: wrapped.message.role, text });
111
- }
112
- }
113
- }
114
- return out;
109
+ function resolveCommandScope(config?: unknown): {
110
+ workspaceHash: string;
111
+ agentId?: string;
112
+ sessionKey?: string;
113
+ } {
114
+ return {
115
+ workspaceHash: workspaceHashFromDir(resolveWorkspaceDirFromConfig(config)),
116
+ };
115
117
  }
116
118
 
117
119
  function resolveLatestUserTurnSignature(messages?: unknown[]): string | undefined {
@@ -664,15 +666,19 @@ function applyTemporalDecayToMem0(params: {
664
666
  }
665
667
 
666
668
  function resolveLifecycleReferenceTs(entry: LifecycleEntry, reinforceOnRecall: boolean): number {
667
- const capturedTs = Number.isFinite(entry.lastCapturedAt)
668
- ? entry.lastCapturedAt
669
- : Number.isFinite(entry.createdAt)
670
- ? entry.createdAt
671
- : 0;
669
+ const capturedTs =
670
+ typeof entry.lastCapturedAt === "number" && Number.isFinite(entry.lastCapturedAt)
671
+ ? entry.lastCapturedAt
672
+ : typeof entry.createdAt === "number" && Number.isFinite(entry.createdAt)
673
+ ? entry.createdAt
674
+ : 0;
672
675
  if (!reinforceOnRecall) {
673
676
  return capturedTs;
674
677
  }
675
- const recalledTs = Number.isFinite(entry.lastRecalledAt) ? entry.lastRecalledAt : 0;
678
+ const recalledTs =
679
+ typeof entry.lastRecalledAt === "number" && Number.isFinite(entry.lastRecalledAt)
680
+ ? entry.lastRecalledAt
681
+ : 0;
676
682
  return Math.max(capturedTs, recalledTs);
677
683
  }
678
684
 
@@ -852,7 +858,7 @@ async function runHybridRecall(params: {
852
858
  cfg: ReturnType<typeof parseConfig>;
853
859
  mem0: Mem0Adapter;
854
860
  log: MemoryBraidLogger;
855
- ctx: OpenClawPluginToolContext;
861
+ ctx: ToolContext;
856
862
  statePaths?: StatePaths | null;
857
863
  query: string;
858
864
  toolCallId?: string;
@@ -910,9 +916,21 @@ async function runHybridRecall(params: {
910
916
  scope,
911
917
  runId: params.runId,
912
918
  });
919
+ const remediationState = params.statePaths
920
+ ? await readRemediationState(params.statePaths)
921
+ : undefined;
922
+ let quarantinedFiltered = 0;
913
923
  const mem0Search = mem0Raw.filter((result) => {
914
924
  const sourceType = asRecord(result.metadata).sourceType;
915
- return sourceType !== "markdown" && sourceType !== "session";
925
+ if (sourceType === "markdown" || sourceType === "session") {
926
+ return false;
927
+ }
928
+ const quarantine = isQuarantinedMemory(result, remediationState);
929
+ if (quarantine.quarantined) {
930
+ quarantinedFiltered += 1;
931
+ return false;
932
+ }
933
+ return true;
916
934
  });
917
935
  let mem0ForMerge = mem0Search;
918
936
  if (params.cfg.timeDecay.enabled) {
@@ -962,6 +980,7 @@ async function runHybridRecall(params: {
962
980
  sessionKey: scope.sessionKey,
963
981
  workspaceHash: scope.workspaceHash,
964
982
  inputCount: mem0Search.length,
983
+ quarantinedFiltered,
965
984
  adjusted: qualityAdjusted.adjusted,
966
985
  overlapBoosted: qualityAdjusted.overlapBoosted,
967
986
  overlapPenalized: qualityAdjusted.overlapPenalized,
@@ -1030,6 +1049,188 @@ async function runHybridRecall(params: {
1030
1049
  };
1031
1050
  }
1032
1051
 
1052
+ function parseIntegerFlag(tokens: string[], flag: string, fallback: number): number {
1053
+ const index = tokens.findIndex((token) => token === flag);
1054
+ if (index < 0 || index === tokens.length - 1) {
1055
+ return fallback;
1056
+ }
1057
+ const raw = Number(tokens[index + 1]);
1058
+ if (!Number.isFinite(raw)) {
1059
+ return fallback;
1060
+ }
1061
+ return Math.max(1, Math.round(raw));
1062
+ }
1063
+
1064
+ function resolveRecordScope(
1065
+ memory: MemoryBraidResult,
1066
+ fallbackScope: { workspaceHash: string; agentId?: string; sessionKey?: string },
1067
+ ): ScopeKey {
1068
+ const metadata = asRecord(memory.metadata);
1069
+ const workspaceHash =
1070
+ typeof metadata.workspaceHash === "string" && metadata.workspaceHash.trim()
1071
+ ? metadata.workspaceHash
1072
+ : fallbackScope.workspaceHash;
1073
+ const agentId =
1074
+ typeof metadata.agentId === "string" && metadata.agentId.trim()
1075
+ ? metadata.agentId
1076
+ : fallbackScope.agentId ?? "main";
1077
+ const sessionKey =
1078
+ typeof metadata.sessionKey === "string" && metadata.sessionKey.trim()
1079
+ ? metadata.sessionKey
1080
+ : fallbackScope.sessionKey;
1081
+ return {
1082
+ workspaceHash,
1083
+ agentId,
1084
+ sessionKey,
1085
+ };
1086
+ }
1087
+
1088
+ async function runRemediationAction(params: {
1089
+ action: RemediationAction;
1090
+ apply: boolean;
1091
+ mem0: Mem0Adapter;
1092
+ statePaths: StatePaths;
1093
+ scope: { workspaceHash: string; agentId?: string; sessionKey?: string };
1094
+ log: MemoryBraidLogger;
1095
+ runId: string;
1096
+ fetchLimit: number;
1097
+ sampleLimit: number;
1098
+ }): Promise<string> {
1099
+ const memories = await params.mem0.getAllMemories({
1100
+ scope: params.scope,
1101
+ limit: params.fetchLimit,
1102
+ runId: params.runId,
1103
+ });
1104
+ const remediationState = await readRemediationState(params.statePaths);
1105
+ const summary = buildAuditSummary({
1106
+ records: memories,
1107
+ remediationState,
1108
+ sampleLimit: params.sampleLimit,
1109
+ });
1110
+ if (params.action === "audit") {
1111
+ return formatAuditSummary(summary);
1112
+ }
1113
+
1114
+ const targets = selectRemediationTargets(summary, params.action);
1115
+ if (!params.apply) {
1116
+ return [
1117
+ formatAuditSummary(summary),
1118
+ "",
1119
+ `Dry run: ${params.action}`,
1120
+ `- targets: ${targets.length}`,
1121
+ "Add --apply to mutate Mem0 state.",
1122
+ ].join("\n");
1123
+ }
1124
+
1125
+ const nowIso = new Date().toISOString();
1126
+ let updated = 0;
1127
+ let remoteTagged = 0;
1128
+ let deleted = 0;
1129
+ const quarantinedUpdates: Array<{
1130
+ id: string;
1131
+ reason: string;
1132
+ updatedRemotely: boolean;
1133
+ }> = [];
1134
+ const deletedIds = new Set<string>();
1135
+
1136
+ if (params.action === "quarantine") {
1137
+ for (const target of targets) {
1138
+ const memoryId = target.memory.id;
1139
+ if (!memoryId) {
1140
+ continue;
1141
+ }
1142
+ const reason = target.suspiciousReasons.join(",");
1143
+ const updatedRemotely = await params.mem0.updateMemoryMetadata({
1144
+ memoryId,
1145
+ scope: resolveRecordScope(target.memory, params.scope),
1146
+ text: target.memory.snippet,
1147
+ metadata: buildQuarantineMetadata(asRecord(target.memory.metadata), reason, nowIso),
1148
+ runId: params.runId,
1149
+ });
1150
+ quarantinedUpdates.push({
1151
+ id: memoryId,
1152
+ reason,
1153
+ updatedRemotely,
1154
+ });
1155
+ updated += 1;
1156
+ if (updatedRemotely) {
1157
+ remoteTagged += 1;
1158
+ }
1159
+ }
1160
+
1161
+ await withStateLock(params.statePaths.stateLockFile, async () => {
1162
+ const nextRemediation = await readRemediationState(params.statePaths);
1163
+ const stats = await readStatsState(params.statePaths);
1164
+ for (const update of quarantinedUpdates) {
1165
+ nextRemediation.quarantined[update.id] = {
1166
+ memoryId: update.id,
1167
+ reason: update.reason,
1168
+ quarantinedAt: nowIso,
1169
+ updatedRemotely: update.updatedRemotely,
1170
+ };
1171
+ }
1172
+ stats.capture.remediationQuarantined += quarantinedUpdates.length;
1173
+ stats.capture.lastRemediationAt = nowIso;
1174
+ await writeRemediationState(params.statePaths, nextRemediation);
1175
+ await writeStatsState(params.statePaths, stats);
1176
+ });
1177
+
1178
+ return [
1179
+ formatAuditSummary(summary),
1180
+ "",
1181
+ "Remediation applied.",
1182
+ `- action: quarantine`,
1183
+ `- targets: ${targets.length}`,
1184
+ `- quarantined: ${updated}`,
1185
+ `- remoteMetadataUpdated: ${remoteTagged}`,
1186
+ `- localQuarantineState: ${quarantinedUpdates.length}`,
1187
+ ].join("\n");
1188
+ }
1189
+
1190
+ for (const target of targets) {
1191
+ const memoryId = target.memory.id;
1192
+ if (!memoryId) {
1193
+ continue;
1194
+ }
1195
+ const ok = await params.mem0.deleteMemory({
1196
+ memoryId,
1197
+ scope: resolveRecordScope(target.memory, params.scope),
1198
+ runId: params.runId,
1199
+ });
1200
+ if (!ok) {
1201
+ continue;
1202
+ }
1203
+ deleted += 1;
1204
+ deletedIds.add(memoryId);
1205
+ }
1206
+
1207
+ await withStateLock(params.statePaths.stateLockFile, async () => {
1208
+ const nextRemediation = await readRemediationState(params.statePaths);
1209
+ const lifecycle = await readLifecycleState(params.statePaths);
1210
+ const stats = await readStatsState(params.statePaths);
1211
+
1212
+ for (const memoryId of deletedIds) {
1213
+ delete nextRemediation.quarantined[memoryId];
1214
+ delete lifecycle.entries[memoryId];
1215
+ }
1216
+
1217
+ stats.capture.remediationDeleted += deletedIds.size;
1218
+ stats.capture.lastRemediationAt = nowIso;
1219
+ await writeRemediationState(params.statePaths, nextRemediation);
1220
+ await writeLifecycleState(params.statePaths, lifecycle);
1221
+ await writeStatsState(params.statePaths, stats);
1222
+ });
1223
+
1224
+ return [
1225
+ formatAuditSummary(summary),
1226
+ "",
1227
+ "Remediation applied.",
1228
+ `- action: ${params.action}`,
1229
+ `- targets: ${targets.length}`,
1230
+ `- deleted: ${deleted}`,
1231
+ ].join("\n");
1232
+ }
1233
+
1033
1234
  const memoryBraidPlugin = {
1034
1235
  id: "memory-braid",
1035
1236
  name: "Memory Braid",
@@ -1047,6 +1248,8 @@ const memoryBraidPlugin = {
1047
1248
  });
1048
1249
  const recallSeenByScope = new Map<string, string>();
1049
1250
  const captureSeenByScope = new Map<string, string>();
1251
+ const pendingInboundTurns = new Map<string, PendingInboundTurn>();
1252
+ const usageByRunScope = new Map<string, UsageWindowEntry[]>();
1050
1253
 
1051
1254
  let lifecycleTimer: NodeJS.Timeout | null = null;
1052
1255
  let statePaths: StatePaths | null = null;
@@ -1135,8 +1338,10 @@ const memoryBraidPlugin = {
1135
1338
  };
1136
1339
 
1137
1340
  const getTool = {
1138
- ...local.getTool,
1139
1341
  name: "memory_get",
1342
+ label: local.getTool.label ?? "Memory Get",
1343
+ description: local.getTool.description ?? "Read a specific local memory entry.",
1344
+ parameters: local.getTool.parameters,
1140
1345
  execute: async (
1141
1346
  toolCallId: string,
1142
1347
  args: Record<string, unknown>,
@@ -1162,14 +1367,14 @@ const memoryBraidPlugin = {
1162
1367
  },
1163
1368
  };
1164
1369
 
1165
- return [searchTool, getTool];
1370
+ return [searchTool, getTool] as never;
1166
1371
  },
1167
1372
  { names: ["memory_search", "memory_get"] },
1168
1373
  );
1169
1374
 
1170
1375
  api.registerCommand({
1171
1376
  name: "memorybraid",
1172
- description: "Memory Braid status, stats, lifecycle cleanup, and entity extraction warmup.",
1377
+ description: "Memory Braid status, stats, remediation, lifecycle cleanup, and entity extraction warmup.",
1173
1378
  acceptsArgs: true,
1174
1379
  handler: async (ctx) => {
1175
1380
  const args = ctx.args?.trim() ?? "";
@@ -1243,7 +1448,15 @@ const memoryBraidPlugin = {
1243
1448
  `- mem0AddAttempts: ${capture.mem0AddAttempts}`,
1244
1449
  `- mem0AddWithId: ${capture.mem0AddWithId} (${mem0SuccessRate})`,
1245
1450
  `- mem0AddWithoutId: ${capture.mem0AddWithoutId} (${mem0NoIdRate})`,
1451
+ `- trustedTurns: ${capture.trustedTurns}`,
1452
+ `- fallbackTurnSlices: ${capture.fallbackTurnSlices}`,
1453
+ `- provenanceSkipped: ${capture.provenanceSkipped}`,
1454
+ `- transcriptShapeSkipped: ${capture.transcriptShapeSkipped}`,
1455
+ `- quarantinedFiltered: ${capture.quarantinedFiltered}`,
1456
+ `- remediationQuarantined: ${capture.remediationQuarantined}`,
1457
+ `- remediationDeleted: ${capture.remediationDeleted}`,
1246
1458
  `- lastRunAt: ${capture.lastRunAt ?? "n/a"}`,
1459
+ `- lastRemediationAt: ${capture.lastRemediationAt ?? "n/a"}`,
1247
1460
  "",
1248
1461
  "Lifecycle:",
1249
1462
  `- enabled: ${cfg.lifecycle.enabled}`,
@@ -1261,6 +1474,44 @@ const memoryBraidPlugin = {
1261
1474
  };
1262
1475
  }
1263
1476
 
1477
+ if (action === "audit" || action === "remediate") {
1478
+ const subAction = action === "audit" ? "audit" : (tokens[1] ?? "audit").toLowerCase();
1479
+ if (
1480
+ subAction !== "audit" &&
1481
+ subAction !== "quarantine" &&
1482
+ subAction !== "delete" &&
1483
+ subAction !== "purge-all-captured"
1484
+ ) {
1485
+ return {
1486
+ text:
1487
+ "Usage: /memorybraid remediate [audit|quarantine|delete|purge-all-captured] [--apply] [--limit N] [--sample N]",
1488
+ isError: true,
1489
+ };
1490
+ }
1491
+
1492
+ const paths = await ensureRuntimeStatePaths();
1493
+ if (!paths) {
1494
+ return {
1495
+ text: "Remediation unavailable: state directory is not ready.",
1496
+ isError: true,
1497
+ };
1498
+ }
1499
+
1500
+ return {
1501
+ text: await runRemediationAction({
1502
+ action: subAction as RemediationAction,
1503
+ apply: tokens.includes("--apply"),
1504
+ mem0,
1505
+ statePaths: paths,
1506
+ scope: resolveCommandScope(ctx.config),
1507
+ log,
1508
+ runId: log.newRunId(),
1509
+ fetchLimit: parseIntegerFlag(tokens, "--limit", 500),
1510
+ sampleLimit: parseIntegerFlag(tokens, "--sample", 5),
1511
+ }),
1512
+ };
1513
+ }
1514
+
1264
1515
  if (action === "cleanup") {
1265
1516
  if (!cfg.lifecycle.enabled) {
1266
1517
  return {
@@ -1327,11 +1578,122 @@ const memoryBraidPlugin = {
1327
1578
  }
1328
1579
 
1329
1580
  return {
1330
- text: "Usage: /memorybraid [status|stats|cleanup|warmup [--force]]",
1581
+ text:
1582
+ "Usage: /memorybraid [status|stats|audit|remediate <audit|quarantine|delete|purge-all-captured> [--apply] [--limit N] [--sample N]|cleanup|warmup [--force]]",
1331
1583
  };
1332
1584
  },
1333
1585
  });
1334
1586
 
1587
+ api.on("before_message_write", (event) => {
1588
+ const pending = getPendingInboundTurn(event.message);
1589
+ if (!pending) {
1590
+ return;
1591
+ }
1592
+ const scopeKey = resolveRunScopeKey({
1593
+ agentId: event.agentId,
1594
+ sessionKey: event.sessionKey,
1595
+ });
1596
+ pendingInboundTurns.set(scopeKey, pending);
1597
+ });
1598
+
1599
+ api.on("llm_output", (event, ctx) => {
1600
+ if (!cfg.debug.enabled || !event.usage) {
1601
+ return;
1602
+ }
1603
+
1604
+ const scope = resolveScopeFromHookContext(ctx);
1605
+ const scopeKey = `${scope.workspaceHash}|${scope.agentId}|${ctx.sessionKey ?? event.sessionId}|${event.provider}|${event.model}`;
1606
+ const snapshot = createUsageSnapshot({
1607
+ provider: event.provider,
1608
+ model: event.model,
1609
+ usage: event.usage,
1610
+ });
1611
+ const entry: UsageWindowEntry = {
1612
+ ...snapshot,
1613
+ at: Date.now(),
1614
+ runId: event.runId,
1615
+ };
1616
+ const history = appendUsageWindow(usageByRunScope.get(scopeKey) ?? [], entry);
1617
+ usageByRunScope.set(scopeKey, history);
1618
+ const summary = summarizeUsageWindow(history);
1619
+
1620
+ log.debug("memory_braid.cost.turn", {
1621
+ runId: event.runId,
1622
+ workspaceHash: scope.workspaceHash,
1623
+ agentId: scope.agentId,
1624
+ sessionKey: ctx.sessionKey,
1625
+ provider: event.provider,
1626
+ model: event.model,
1627
+ input: snapshot.input,
1628
+ output: snapshot.output,
1629
+ cacheRead: snapshot.cacheRead,
1630
+ cacheWrite: snapshot.cacheWrite,
1631
+ promptTokens: snapshot.promptTokens,
1632
+ cacheHitRate: Number(snapshot.cacheHitRate.toFixed(4)),
1633
+ cacheWriteRate: Number(snapshot.cacheWriteRate.toFixed(4)),
1634
+ estimatedCostUsd:
1635
+ typeof snapshot.estimatedCostUsd === "number"
1636
+ ? Number(snapshot.estimatedCostUsd.toFixed(6))
1637
+ : undefined,
1638
+ costEstimateBasis: snapshot.costEstimateBasis,
1639
+ });
1640
+
1641
+ log.debug("memory_braid.cost.window", {
1642
+ runId: event.runId,
1643
+ workspaceHash: scope.workspaceHash,
1644
+ agentId: scope.agentId,
1645
+ sessionKey: ctx.sessionKey,
1646
+ provider: event.provider,
1647
+ model: event.model,
1648
+ turnsSeen: summary.turnsSeen,
1649
+ window5PromptTokens: Math.round(summary.window5.avgPromptTokens),
1650
+ window5CacheRead: Math.round(summary.window5.avgCacheRead),
1651
+ window5CacheWrite: Math.round(summary.window5.avgCacheWrite),
1652
+ window5CacheHitRate: Number(summary.window5.avgCacheHitRate.toFixed(4)),
1653
+ window5CacheWriteRate: Number(summary.window5.avgCacheWriteRate.toFixed(4)),
1654
+ window5EstimatedCostUsd:
1655
+ typeof summary.window5.avgEstimatedCostUsd === "number"
1656
+ ? Number(summary.window5.avgEstimatedCostUsd.toFixed(6))
1657
+ : undefined,
1658
+ window20PromptTokens: Math.round(summary.window20.avgPromptTokens),
1659
+ window20CacheRead: Math.round(summary.window20.avgCacheRead),
1660
+ window20CacheWrite: Math.round(summary.window20.avgCacheWrite),
1661
+ window20CacheHitRate: Number(summary.window20.avgCacheHitRate.toFixed(4)),
1662
+ window20CacheWriteRate: Number(summary.window20.avgCacheWriteRate.toFixed(4)),
1663
+ window20EstimatedCostUsd:
1664
+ typeof summary.window20.avgEstimatedCostUsd === "number"
1665
+ ? Number(summary.window20.avgEstimatedCostUsd.toFixed(6))
1666
+ : undefined,
1667
+ cacheWriteTrend: summary.trends.cacheWriteRate,
1668
+ cacheHitTrend: summary.trends.cacheHitRate,
1669
+ promptTokensTrend: summary.trends.promptTokens,
1670
+ estimatedCostTrend: summary.trends.estimatedCostUsd,
1671
+ costEstimateBasis: snapshot.costEstimateBasis,
1672
+ });
1673
+
1674
+ if (summary.alerts.length > 0) {
1675
+ log.debug("memory_braid.cost.alert", {
1676
+ runId: event.runId,
1677
+ workspaceHash: scope.workspaceHash,
1678
+ agentId: scope.agentId,
1679
+ sessionKey: ctx.sessionKey,
1680
+ provider: event.provider,
1681
+ model: event.model,
1682
+ alerts: summary.alerts,
1683
+ cacheWriteTrend: summary.trends.cacheWriteRate,
1684
+ promptTokensTrend: summary.trends.promptTokens,
1685
+ estimatedCostTrend: summary.trends.estimatedCostUsd,
1686
+ window5CacheWriteRate: Number(summary.window5.avgCacheWriteRate.toFixed(4)),
1687
+ window5PromptTokens: Math.round(summary.window5.avgPromptTokens),
1688
+ window5EstimatedCostUsd:
1689
+ typeof summary.window5.avgEstimatedCostUsd === "number"
1690
+ ? Number(summary.window5.avgEstimatedCostUsd.toFixed(6))
1691
+ : undefined,
1692
+ costEstimateBasis: snapshot.costEstimateBasis,
1693
+ });
1694
+ }
1695
+ });
1696
+
1335
1697
  api.on("before_agent_start", async (event, ctx) => {
1336
1698
  const runId = log.newRunId();
1337
1699
  const scope = resolveScopeFromHookContext(ctx);
@@ -1376,7 +1738,7 @@ const memoryBraidPlugin = {
1376
1738
  }
1377
1739
  recallSeenByScope.set(scopeKey, userTurnSignature);
1378
1740
 
1379
- const toolCtx: OpenClawPluginToolContext = {
1741
+ const toolCtx: ToolContext = {
1380
1742
  config: api.config,
1381
1743
  workspaceDir: ctx.workspaceDir,
1382
1744
  agentId: ctx.agentId,
@@ -1457,8 +1819,11 @@ const memoryBraidPlugin = {
1457
1819
  }
1458
1820
 
1459
1821
  const scopeKey = resolveRunScopeKey(ctx);
1460
- const userTurnSignature = resolveLatestUserTurnSignature(event.messages);
1822
+ const pendingInboundTurn = pendingInboundTurns.get(scopeKey);
1823
+ const userTurnSignature =
1824
+ pendingInboundTurn?.messageHash ?? resolveLatestUserTurnSignature(event.messages);
1461
1825
  if (!userTurnSignature) {
1826
+ pendingInboundTurns.delete(scopeKey);
1462
1827
  log.debug("memory_braid.capture.skip", {
1463
1828
  runId,
1464
1829
  reason: "no_user_turn_signature",
@@ -1470,6 +1835,7 @@ const memoryBraidPlugin = {
1470
1835
  }
1471
1836
  const previousSignature = captureSeenByScope.get(scopeKey);
1472
1837
  if (previousSignature === userTurnSignature) {
1838
+ pendingInboundTurns.delete(scopeKey);
1473
1839
  log.debug("memory_braid.capture.skip", {
1474
1840
  runId,
1475
1841
  reason: "no_new_user_turn",
@@ -1480,21 +1846,73 @@ const memoryBraidPlugin = {
1480
1846
  return;
1481
1847
  }
1482
1848
  captureSeenByScope.set(scopeKey, userTurnSignature);
1849
+ pendingInboundTurns.delete(scopeKey);
1483
1850
 
1484
- const candidates = await extractCandidates({
1851
+ const captureInput = assembleCaptureInput({
1485
1852
  messages: event.messages,
1853
+ includeAssistant: cfg.capture.includeAssistant,
1854
+ pendingInboundTurn,
1855
+ });
1856
+ if (!captureInput) {
1857
+ log.debug("memory_braid.capture.skip", {
1858
+ runId,
1859
+ reason: "no_capture_input",
1860
+ workspaceHash: scope.workspaceHash,
1861
+ agentId: scope.agentId,
1862
+ sessionKey: scope.sessionKey,
1863
+ });
1864
+ return;
1865
+ }
1866
+
1867
+ const candidates = await extractCandidates({
1868
+ messages: captureInput.messages.map((message) => ({
1869
+ role: message.role,
1870
+ content: message.text,
1871
+ })),
1486
1872
  cfg,
1487
1873
  log,
1488
1874
  runId,
1489
1875
  });
1490
1876
  const runtimeStatePaths = await ensureRuntimeStatePaths();
1491
-
1492
- if (candidates.length === 0) {
1877
+ let provenanceSkipped = 0;
1878
+ let transcriptShapeSkipped = 0;
1879
+ const candidateEntries = candidates
1880
+ .map((candidate) => {
1881
+ if (isLikelyTranscriptLikeText(candidate.text) || isOversizedAtomicMemory(candidate.text)) {
1882
+ transcriptShapeSkipped += 1;
1883
+ return null;
1884
+ }
1885
+ const matchedSource = matchCandidateToCaptureInput(candidate.text, captureInput.messages);
1886
+ if (!matchedSource) {
1887
+ provenanceSkipped += 1;
1888
+ return null;
1889
+ }
1890
+ return {
1891
+ candidate,
1892
+ matchedSource,
1893
+ hash: sha256(normalizeForHash(candidate.text)),
1894
+ };
1895
+ })
1896
+ .filter(
1897
+ (
1898
+ entry,
1899
+ ): entry is {
1900
+ candidate: (typeof candidates)[number];
1901
+ matchedSource: (typeof captureInput.messages)[number];
1902
+ hash: string;
1903
+ } => Boolean(entry),
1904
+ );
1905
+
1906
+ if (candidateEntries.length === 0) {
1493
1907
  if (runtimeStatePaths) {
1494
1908
  await withStateLock(runtimeStatePaths.stateLockFile, async () => {
1495
1909
  const stats = await readStatsState(runtimeStatePaths);
1496
1910
  stats.capture.runs += 1;
1497
1911
  stats.capture.runsNoCandidates += 1;
1912
+ stats.capture.trustedTurns += 1;
1913
+ stats.capture.fallbackTurnSlices += captureInput.fallbackUsed ? 1 : 0;
1914
+ stats.capture.provenanceSkipped += provenanceSkipped;
1915
+ stats.capture.transcriptShapeSkipped += transcriptShapeSkipped;
1498
1916
  stats.capture.lastRunAt = new Date().toISOString();
1499
1917
  await writeStatsState(runtimeStatePaths, stats);
1500
1918
  });
@@ -1502,6 +1920,10 @@ const memoryBraidPlugin = {
1502
1920
  log.debug("memory_braid.capture.skip", {
1503
1921
  runId,
1504
1922
  reason: "no_candidates",
1923
+ capturePath: captureInput.capturePath,
1924
+ fallbackUsed: captureInput.fallbackUsed,
1925
+ provenanceSkipped,
1926
+ transcriptShapeSkipped,
1505
1927
  workspaceHash: scope.workspaceHash,
1506
1928
  agentId: scope.agentId,
1507
1929
  sessionKey: scope.sessionKey,
@@ -1521,11 +1943,6 @@ const memoryBraidPlugin = {
1521
1943
  }
1522
1944
 
1523
1945
  const thirtyDays = 30 * 24 * 60 * 60 * 1000;
1524
- const candidateEntries = candidates.map((candidate) => ({
1525
- candidate,
1526
- hash: sha256(normalizeForHash(candidate.text)),
1527
- }));
1528
-
1529
1946
  const prepared = await withStateLock(runtimeStatePaths.stateLockFile, async () => {
1530
1947
  const dedupe = await readCaptureDedupeState(runtimeStatePaths);
1531
1948
  const now = Date.now();
@@ -1565,6 +1982,8 @@ const memoryBraidPlugin = {
1565
1982
  let mem0AddAttempts = 0;
1566
1983
  let mem0AddWithId = 0;
1567
1984
  let mem0AddWithoutId = 0;
1985
+ let remoteQuarantineFiltered = 0;
1986
+ const remediationState = await readRemediationState(runtimeStatePaths);
1568
1987
  const successfulAdds: Array<{
1569
1988
  memoryId: string;
1570
1989
  hash: string;
@@ -1572,7 +1991,7 @@ const memoryBraidPlugin = {
1572
1991
  }> = [];
1573
1992
 
1574
1993
  for (const entry of prepared.pending) {
1575
- const { candidate, hash } = entry;
1994
+ const { candidate, hash, matchedSource } = entry;
1576
1995
  const metadata: Record<string, unknown> = {
1577
1996
  sourceType: "capture",
1578
1997
  workspaceHash: scope.workspaceHash,
@@ -1581,6 +2000,11 @@ const memoryBraidPlugin = {
1581
2000
  category: candidate.category,
1582
2001
  captureScore: candidate.score,
1583
2002
  extractionSource: candidate.source,
2003
+ captureOrigin: matchedSource.origin,
2004
+ captureMessageHash: matchedSource.messageHash,
2005
+ captureTurnHash: captureInput.turnHash,
2006
+ capturePath: captureInput.capturePath,
2007
+ pluginCaptureVersion: PLUGIN_CAPTURE_VERSION,
1584
2008
  contentHash: hash,
1585
2009
  indexedAt: new Date().toISOString(),
1586
2010
  };
@@ -1598,6 +2022,17 @@ const memoryBraidPlugin = {
1598
2022
  }
1599
2023
  }
1600
2024
 
2025
+ const quarantine = isQuarantinedMemory({
2026
+ ...entry.candidate,
2027
+ source: "mem0",
2028
+ snippet: entry.candidate.text,
2029
+ metadata,
2030
+ }, remediationState);
2031
+ if (quarantine.quarantined) {
2032
+ remoteQuarantineFiltered += 1;
2033
+ continue;
2034
+ }
2035
+
1601
2036
  mem0AddAttempts += 1;
1602
2037
  const addResult = await mem0.addMemory({
1603
2038
  text: candidate.text,
@@ -1673,6 +2108,11 @@ const memoryBraidPlugin = {
1673
2108
  stats.capture.mem0AddWithoutId += mem0AddWithoutId;
1674
2109
  stats.capture.entityAnnotatedCandidates += entityAnnotatedCandidates;
1675
2110
  stats.capture.totalEntitiesAttached += totalEntitiesAttached;
2111
+ stats.capture.trustedTurns += 1;
2112
+ stats.capture.fallbackTurnSlices += captureInput.fallbackUsed ? 1 : 0;
2113
+ stats.capture.provenanceSkipped += provenanceSkipped;
2114
+ stats.capture.transcriptShapeSkipped += transcriptShapeSkipped;
2115
+ stats.capture.quarantinedFiltered += remoteQuarantineFiltered;
1676
2116
  stats.capture.lastRunAt = new Date(now).toISOString();
1677
2117
 
1678
2118
  await writeCaptureDedupeState(runtimeStatePaths, dedupe);
@@ -1686,9 +2126,14 @@ const memoryBraidPlugin = {
1686
2126
  workspaceHash: scope.workspaceHash,
1687
2127
  agentId: scope.agentId,
1688
2128
  sessionKey: scope.sessionKey,
2129
+ capturePath: captureInput.capturePath,
2130
+ fallbackUsed: captureInput.fallbackUsed,
1689
2131
  candidates: candidates.length,
1690
2132
  pending: prepared.pending.length,
1691
2133
  dedupeSkipped: prepared.dedupeSkipped,
2134
+ provenanceSkipped,
2135
+ transcriptShapeSkipped,
2136
+ quarantinedFiltered: remoteQuarantineFiltered,
1692
2137
  persisted,
1693
2138
  mem0AddAttempts,
1694
2139
  mem0AddWithId,