memory-braid 0.4.6 → 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,6 +97,75 @@ function resolveScopeFromHookContext(ctx: {
63
97
  };
64
98
  }
65
99
 
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;
107
+ }
108
+
109
+ function resolveCommandScope(config?: unknown): {
110
+ workspaceHash: string;
111
+ agentId?: string;
112
+ sessionKey?: string;
113
+ } {
114
+ return {
115
+ workspaceHash: workspaceHashFromDir(resolveWorkspaceDirFromConfig(config)),
116
+ };
117
+ }
118
+
119
+ function resolveLatestUserTurnSignature(messages?: unknown[]): string | undefined {
120
+ if (!Array.isArray(messages) || messages.length === 0) {
121
+ return undefined;
122
+ }
123
+
124
+ const normalized = normalizeHookMessages(messages);
125
+ for (let i = normalized.length - 1; i >= 0; i -= 1) {
126
+ const message = normalized[i];
127
+ if (!message || message.role !== "user") {
128
+ continue;
129
+ }
130
+ const hashSource = normalizeForHash(message.text);
131
+ if (!hashSource) {
132
+ continue;
133
+ }
134
+ return `${i}:${sha256(hashSource)}`;
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ function resolvePromptTurnSignature(prompt: string): string | undefined {
140
+ const normalized = normalizeForHash(prompt);
141
+ if (!normalized) {
142
+ return undefined;
143
+ }
144
+ return `prompt:${sha256(normalized)}`;
145
+ }
146
+
147
+ function resolveRunScopeKey(ctx: { agentId?: string; sessionKey?: string }): string {
148
+ const agentId = (ctx.agentId ?? "main").trim() || "main";
149
+ const sessionKey = (ctx.sessionKey ?? "main").trim() || "main";
150
+ return `${agentId}|${sessionKey}`;
151
+ }
152
+
153
+ function isExcludedAutoMemorySession(sessionKey?: string): boolean {
154
+ const normalized = (sessionKey ?? "").trim().toLowerCase();
155
+ if (!normalized) {
156
+ return false;
157
+ }
158
+ return (
159
+ normalized.startsWith("cron:") ||
160
+ normalized.includes(":cron:") ||
161
+ normalized.includes(":subagent:") ||
162
+ normalized.startsWith("subagent:") ||
163
+ normalized.includes(":acp:") ||
164
+ normalized.startsWith("acp:") ||
165
+ normalized.startsWith("temp:")
166
+ );
167
+ }
168
+
66
169
  function formatRelevantMemories(results: MemoryBraidResult[], maxChars = 600): string {
67
170
  const lines = results.map((entry, index) => {
68
171
  const sourceLabel = entry.source === "local" ? "local" : "mem0";
@@ -563,15 +666,19 @@ function applyTemporalDecayToMem0(params: {
563
666
  }
564
667
 
565
668
  function resolveLifecycleReferenceTs(entry: LifecycleEntry, reinforceOnRecall: boolean): number {
566
- const capturedTs = Number.isFinite(entry.lastCapturedAt)
567
- ? entry.lastCapturedAt
568
- : Number.isFinite(entry.createdAt)
569
- ? entry.createdAt
570
- : 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;
571
675
  if (!reinforceOnRecall) {
572
676
  return capturedTs;
573
677
  }
574
- 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;
575
682
  return Math.max(capturedTs, recalledTs);
576
683
  }
577
684
 
@@ -751,7 +858,7 @@ async function runHybridRecall(params: {
751
858
  cfg: ReturnType<typeof parseConfig>;
752
859
  mem0: Mem0Adapter;
753
860
  log: MemoryBraidLogger;
754
- ctx: OpenClawPluginToolContext;
861
+ ctx: ToolContext;
755
862
  statePaths?: StatePaths | null;
756
863
  query: string;
757
864
  toolCallId?: string;
@@ -809,9 +916,21 @@ async function runHybridRecall(params: {
809
916
  scope,
810
917
  runId: params.runId,
811
918
  });
919
+ const remediationState = params.statePaths
920
+ ? await readRemediationState(params.statePaths)
921
+ : undefined;
922
+ let quarantinedFiltered = 0;
812
923
  const mem0Search = mem0Raw.filter((result) => {
813
924
  const sourceType = asRecord(result.metadata).sourceType;
814
- 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;
815
934
  });
816
935
  let mem0ForMerge = mem0Search;
817
936
  if (params.cfg.timeDecay.enabled) {
@@ -861,6 +980,7 @@ async function runHybridRecall(params: {
861
980
  sessionKey: scope.sessionKey,
862
981
  workspaceHash: scope.workspaceHash,
863
982
  inputCount: mem0Search.length,
983
+ quarantinedFiltered,
864
984
  adjusted: qualityAdjusted.adjusted,
865
985
  overlapBoosted: qualityAdjusted.overlapBoosted,
866
986
  overlapPenalized: qualityAdjusted.overlapPenalized,
@@ -929,6 +1049,188 @@ async function runHybridRecall(params: {
929
1049
  };
930
1050
  }
931
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
+
932
1234
  const memoryBraidPlugin = {
933
1235
  id: "memory-braid",
934
1236
  name: "Memory Braid",
@@ -944,6 +1246,10 @@ const memoryBraidPlugin = {
944
1246
  const entityExtraction = new EntityExtractionManager(cfg.entityExtraction, log, {
945
1247
  stateDir: initialStateDir,
946
1248
  });
1249
+ const recallSeenByScope = new Map<string, string>();
1250
+ const captureSeenByScope = new Map<string, string>();
1251
+ const pendingInboundTurns = new Map<string, PendingInboundTurn>();
1252
+ const usageByRunScope = new Map<string, UsageWindowEntry[]>();
947
1253
 
948
1254
  let lifecycleTimer: NodeJS.Timeout | null = null;
949
1255
  let statePaths: StatePaths | null = null;
@@ -1032,8 +1338,10 @@ const memoryBraidPlugin = {
1032
1338
  };
1033
1339
 
1034
1340
  const getTool = {
1035
- ...local.getTool,
1036
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,
1037
1345
  execute: async (
1038
1346
  toolCallId: string,
1039
1347
  args: Record<string, unknown>,
@@ -1059,14 +1367,14 @@ const memoryBraidPlugin = {
1059
1367
  },
1060
1368
  };
1061
1369
 
1062
- return [searchTool, getTool];
1370
+ return [searchTool, getTool] as never;
1063
1371
  },
1064
1372
  { names: ["memory_search", "memory_get"] },
1065
1373
  );
1066
1374
 
1067
1375
  api.registerCommand({
1068
1376
  name: "memorybraid",
1069
- description: "Memory Braid status, stats, lifecycle cleanup, and entity extraction warmup.",
1377
+ description: "Memory Braid status, stats, remediation, lifecycle cleanup, and entity extraction warmup.",
1070
1378
  acceptsArgs: true,
1071
1379
  handler: async (ctx) => {
1072
1380
  const args = ctx.args?.trim() ?? "";
@@ -1140,7 +1448,15 @@ const memoryBraidPlugin = {
1140
1448
  `- mem0AddAttempts: ${capture.mem0AddAttempts}`,
1141
1449
  `- mem0AddWithId: ${capture.mem0AddWithId} (${mem0SuccessRate})`,
1142
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}`,
1143
1458
  `- lastRunAt: ${capture.lastRunAt ?? "n/a"}`,
1459
+ `- lastRemediationAt: ${capture.lastRemediationAt ?? "n/a"}`,
1144
1460
  "",
1145
1461
  "Lifecycle:",
1146
1462
  `- enabled: ${cfg.lifecycle.enabled}`,
@@ -1158,6 +1474,44 @@ const memoryBraidPlugin = {
1158
1474
  };
1159
1475
  }
1160
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
+
1161
1515
  if (action === "cleanup") {
1162
1516
  if (!cfg.lifecycle.enabled) {
1163
1517
  return {
@@ -1224,18 +1578,167 @@ const memoryBraidPlugin = {
1224
1578
  }
1225
1579
 
1226
1580
  return {
1227
- 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]]",
1228
1583
  };
1229
1584
  },
1230
1585
  });
1231
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
+
1232
1697
  api.on("before_agent_start", async (event, ctx) => {
1233
1698
  const runId = log.newRunId();
1699
+ const scope = resolveScopeFromHookContext(ctx);
1700
+ if (isExcludedAutoMemorySession(ctx.sessionKey)) {
1701
+ log.debug("memory_braid.search.skip", {
1702
+ runId,
1703
+ reason: "session_scope_excluded",
1704
+ workspaceHash: scope.workspaceHash,
1705
+ agentId: scope.agentId,
1706
+ sessionKey: scope.sessionKey,
1707
+ });
1708
+ return;
1709
+ }
1710
+
1234
1711
  const recallQuery = sanitizeRecallQuery(event.prompt);
1235
1712
  if (!recallQuery) {
1236
1713
  return;
1237
1714
  }
1238
- const toolCtx: OpenClawPluginToolContext = {
1715
+ const scopeKey = resolveRunScopeKey(ctx);
1716
+ const userTurnSignature =
1717
+ resolveLatestUserTurnSignature(event.messages) ?? resolvePromptTurnSignature(recallQuery);
1718
+ if (!userTurnSignature) {
1719
+ log.debug("memory_braid.search.skip", {
1720
+ runId,
1721
+ reason: "no_user_turn_signature",
1722
+ workspaceHash: scope.workspaceHash,
1723
+ agentId: scope.agentId,
1724
+ sessionKey: scope.sessionKey,
1725
+ });
1726
+ return;
1727
+ }
1728
+ const previousSignature = recallSeenByScope.get(scopeKey);
1729
+ if (previousSignature === userTurnSignature) {
1730
+ log.debug("memory_braid.search.skip", {
1731
+ runId,
1732
+ reason: "no_new_user_turn",
1733
+ workspaceHash: scope.workspaceHash,
1734
+ agentId: scope.agentId,
1735
+ sessionKey: scope.sessionKey,
1736
+ });
1737
+ return;
1738
+ }
1739
+ recallSeenByScope.set(scopeKey, userTurnSignature);
1740
+
1741
+ const toolCtx: ToolContext = {
1239
1742
  config: api.config,
1240
1743
  workspaceDir: ctx.workspaceDir,
1241
1744
  agentId: ctx.agentId,
@@ -1264,7 +1767,6 @@ const memoryBraidPlugin = {
1264
1767
  limit: cfg.recall.injectTopK,
1265
1768
  });
1266
1769
  if (selected.injected.length === 0) {
1267
- const scope = resolveScopeFromHookContext(ctx);
1268
1770
  log.debug("memory_braid.search.inject", {
1269
1771
  runId,
1270
1772
  agentId: scope.agentId,
@@ -1281,7 +1783,6 @@ const memoryBraidPlugin = {
1281
1783
  }
1282
1784
 
1283
1785
  const prependContext = formatRelevantMemories(selected.injected, cfg.debug.maxSnippetChars);
1284
- const scope = resolveScopeFromHookContext(ctx);
1285
1786
  log.debug("memory_braid.search.inject", {
1286
1787
  runId,
1287
1788
  agentId: scope.agentId,
@@ -1306,20 +1807,112 @@ const memoryBraidPlugin = {
1306
1807
  }
1307
1808
  const runId = log.newRunId();
1308
1809
  const scope = resolveScopeFromHookContext(ctx);
1309
- const candidates = await extractCandidates({
1810
+ if (isExcludedAutoMemorySession(ctx.sessionKey)) {
1811
+ log.debug("memory_braid.capture.skip", {
1812
+ runId,
1813
+ reason: "session_scope_excluded",
1814
+ workspaceHash: scope.workspaceHash,
1815
+ agentId: scope.agentId,
1816
+ sessionKey: scope.sessionKey,
1817
+ });
1818
+ return;
1819
+ }
1820
+
1821
+ const scopeKey = resolveRunScopeKey(ctx);
1822
+ const pendingInboundTurn = pendingInboundTurns.get(scopeKey);
1823
+ const userTurnSignature =
1824
+ pendingInboundTurn?.messageHash ?? resolveLatestUserTurnSignature(event.messages);
1825
+ if (!userTurnSignature) {
1826
+ pendingInboundTurns.delete(scopeKey);
1827
+ log.debug("memory_braid.capture.skip", {
1828
+ runId,
1829
+ reason: "no_user_turn_signature",
1830
+ workspaceHash: scope.workspaceHash,
1831
+ agentId: scope.agentId,
1832
+ sessionKey: scope.sessionKey,
1833
+ });
1834
+ return;
1835
+ }
1836
+ const previousSignature = captureSeenByScope.get(scopeKey);
1837
+ if (previousSignature === userTurnSignature) {
1838
+ pendingInboundTurns.delete(scopeKey);
1839
+ log.debug("memory_braid.capture.skip", {
1840
+ runId,
1841
+ reason: "no_new_user_turn",
1842
+ workspaceHash: scope.workspaceHash,
1843
+ agentId: scope.agentId,
1844
+ sessionKey: scope.sessionKey,
1845
+ });
1846
+ return;
1847
+ }
1848
+ captureSeenByScope.set(scopeKey, userTurnSignature);
1849
+ pendingInboundTurns.delete(scopeKey);
1850
+
1851
+ const captureInput = assembleCaptureInput({
1310
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
+ })),
1311
1872
  cfg,
1312
1873
  log,
1313
1874
  runId,
1314
1875
  });
1315
1876
  const runtimeStatePaths = await ensureRuntimeStatePaths();
1316
-
1317
- 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) {
1318
1907
  if (runtimeStatePaths) {
1319
1908
  await withStateLock(runtimeStatePaths.stateLockFile, async () => {
1320
1909
  const stats = await readStatsState(runtimeStatePaths);
1321
1910
  stats.capture.runs += 1;
1322
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;
1323
1916
  stats.capture.lastRunAt = new Date().toISOString();
1324
1917
  await writeStatsState(runtimeStatePaths, stats);
1325
1918
  });
@@ -1327,6 +1920,10 @@ const memoryBraidPlugin = {
1327
1920
  log.debug("memory_braid.capture.skip", {
1328
1921
  runId,
1329
1922
  reason: "no_candidates",
1923
+ capturePath: captureInput.capturePath,
1924
+ fallbackUsed: captureInput.fallbackUsed,
1925
+ provenanceSkipped,
1926
+ transcriptShapeSkipped,
1330
1927
  workspaceHash: scope.workspaceHash,
1331
1928
  agentId: scope.agentId,
1332
1929
  sessionKey: scope.sessionKey,
@@ -1346,11 +1943,6 @@ const memoryBraidPlugin = {
1346
1943
  }
1347
1944
 
1348
1945
  const thirtyDays = 30 * 24 * 60 * 60 * 1000;
1349
- const candidateEntries = candidates.map((candidate) => ({
1350
- candidate,
1351
- hash: sha256(normalizeForHash(candidate.text)),
1352
- }));
1353
-
1354
1946
  const prepared = await withStateLock(runtimeStatePaths.stateLockFile, async () => {
1355
1947
  const dedupe = await readCaptureDedupeState(runtimeStatePaths);
1356
1948
  const now = Date.now();
@@ -1390,6 +1982,8 @@ const memoryBraidPlugin = {
1390
1982
  let mem0AddAttempts = 0;
1391
1983
  let mem0AddWithId = 0;
1392
1984
  let mem0AddWithoutId = 0;
1985
+ let remoteQuarantineFiltered = 0;
1986
+ const remediationState = await readRemediationState(runtimeStatePaths);
1393
1987
  const successfulAdds: Array<{
1394
1988
  memoryId: string;
1395
1989
  hash: string;
@@ -1397,7 +1991,7 @@ const memoryBraidPlugin = {
1397
1991
  }> = [];
1398
1992
 
1399
1993
  for (const entry of prepared.pending) {
1400
- const { candidate, hash } = entry;
1994
+ const { candidate, hash, matchedSource } = entry;
1401
1995
  const metadata: Record<string, unknown> = {
1402
1996
  sourceType: "capture",
1403
1997
  workspaceHash: scope.workspaceHash,
@@ -1406,6 +2000,11 @@ const memoryBraidPlugin = {
1406
2000
  category: candidate.category,
1407
2001
  captureScore: candidate.score,
1408
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,
1409
2008
  contentHash: hash,
1410
2009
  indexedAt: new Date().toISOString(),
1411
2010
  };
@@ -1423,6 +2022,17 @@ const memoryBraidPlugin = {
1423
2022
  }
1424
2023
  }
1425
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
+
1426
2036
  mem0AddAttempts += 1;
1427
2037
  const addResult = await mem0.addMemory({
1428
2038
  text: candidate.text,
@@ -1498,6 +2108,11 @@ const memoryBraidPlugin = {
1498
2108
  stats.capture.mem0AddWithoutId += mem0AddWithoutId;
1499
2109
  stats.capture.entityAnnotatedCandidates += entityAnnotatedCandidates;
1500
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;
1501
2116
  stats.capture.lastRunAt = new Date(now).toISOString();
1502
2117
 
1503
2118
  await writeCaptureDedupeState(runtimeStatePaths, dedupe);
@@ -1511,9 +2126,14 @@ const memoryBraidPlugin = {
1511
2126
  workspaceHash: scope.workspaceHash,
1512
2127
  agentId: scope.agentId,
1513
2128
  sessionKey: scope.sessionKey,
2129
+ capturePath: captureInput.capturePath,
2130
+ fallbackUsed: captureInput.fallbackUsed,
1514
2131
  candidates: candidates.length,
1515
2132
  pending: prepared.pending.length,
1516
2133
  dedupeSkipped: prepared.dedupeSkipped,
2134
+ provenanceSkipped,
2135
+ transcriptShapeSkipped,
2136
+ quarantinedFiltered: remoteQuarantineFiltered,
1517
2137
  persisted,
1518
2138
  mem0AddAttempts,
1519
2139
  mem0AddWithId,