memory-braid 0.4.7 → 0.6.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/README.md +120 -4
- package/openclaw.plugin.json +32 -0
- package/package.json +1 -1
- package/src/capture.ts +315 -0
- package/src/config.ts +127 -2
- package/src/extract.ts +8 -1
- package/src/index.ts +1288 -188
- package/src/local-memory.ts +9 -4
- package/src/logger.ts +6 -1
- package/src/mem0-client.ts +295 -45
- package/src/observability.ts +269 -0
- package/src/remediation.ts +257 -0
- package/src/state.ts +39 -0
- package/src/types.ts +74 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
type UsageLike = {
|
|
2
|
+
input?: number;
|
|
3
|
+
output?: number;
|
|
4
|
+
cacheRead?: number;
|
|
5
|
+
cacheWrite?: number;
|
|
6
|
+
total?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type UsageSnapshot = {
|
|
10
|
+
input: number;
|
|
11
|
+
output: number;
|
|
12
|
+
cacheRead: number;
|
|
13
|
+
cacheWrite: number;
|
|
14
|
+
total: number;
|
|
15
|
+
promptTokens: number;
|
|
16
|
+
cacheHitRate: number;
|
|
17
|
+
cacheWriteRate: number;
|
|
18
|
+
estimatedCostUsd?: number;
|
|
19
|
+
costEstimateBasis: "estimated" | "token_only";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type TrendState = "rising" | "stable" | "improving" | "insufficient_data";
|
|
23
|
+
|
|
24
|
+
export type UsageWindowEntry = UsageSnapshot & {
|
|
25
|
+
at: number;
|
|
26
|
+
runId: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type UsageTrendSummary = {
|
|
30
|
+
turnsSeen: number;
|
|
31
|
+
window5: {
|
|
32
|
+
avgPromptTokens: number;
|
|
33
|
+
avgCacheRead: number;
|
|
34
|
+
avgCacheWrite: number;
|
|
35
|
+
avgCacheHitRate: number;
|
|
36
|
+
avgCacheWriteRate: number;
|
|
37
|
+
avgEstimatedCostUsd?: number;
|
|
38
|
+
};
|
|
39
|
+
window20: {
|
|
40
|
+
avgPromptTokens: number;
|
|
41
|
+
avgCacheRead: number;
|
|
42
|
+
avgCacheWrite: number;
|
|
43
|
+
avgCacheHitRate: number;
|
|
44
|
+
avgCacheWriteRate: number;
|
|
45
|
+
avgEstimatedCostUsd?: number;
|
|
46
|
+
};
|
|
47
|
+
trends: {
|
|
48
|
+
cacheWriteRate: TrendState;
|
|
49
|
+
cacheHitRate: TrendState;
|
|
50
|
+
promptTokens: TrendState;
|
|
51
|
+
estimatedCostUsd: TrendState;
|
|
52
|
+
};
|
|
53
|
+
alerts: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type PriceConfig = {
|
|
57
|
+
inputPerM: number;
|
|
58
|
+
outputPerM: number;
|
|
59
|
+
cacheReadPerM?: number;
|
|
60
|
+
cacheWritePerM?: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const WINDOW_LIMIT = 20;
|
|
64
|
+
|
|
65
|
+
const PRICE_CONFIGS: Array<{
|
|
66
|
+
provider: string;
|
|
67
|
+
match: RegExp;
|
|
68
|
+
price: PriceConfig;
|
|
69
|
+
}> = [
|
|
70
|
+
{
|
|
71
|
+
provider: "anthropic",
|
|
72
|
+
match: /claude-.*opus/i,
|
|
73
|
+
price: {
|
|
74
|
+
inputPerM: 15,
|
|
75
|
+
outputPerM: 75,
|
|
76
|
+
cacheReadPerM: 1.5,
|
|
77
|
+
cacheWritePerM: 18.75,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
provider: "anthropic",
|
|
82
|
+
match: /claude-.*sonnet/i,
|
|
83
|
+
price: {
|
|
84
|
+
inputPerM: 3,
|
|
85
|
+
outputPerM: 15,
|
|
86
|
+
cacheReadPerM: 0.3,
|
|
87
|
+
cacheWritePerM: 3.75,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
provider: "anthropic",
|
|
92
|
+
match: /claude-.*haiku/i,
|
|
93
|
+
price: {
|
|
94
|
+
inputPerM: 0.8,
|
|
95
|
+
outputPerM: 4,
|
|
96
|
+
cacheReadPerM: 0.08,
|
|
97
|
+
cacheWritePerM: 1,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
provider: "openai",
|
|
102
|
+
match: /^gpt-4o$/i,
|
|
103
|
+
price: {
|
|
104
|
+
inputPerM: 2.5,
|
|
105
|
+
outputPerM: 10,
|
|
106
|
+
cacheReadPerM: 1.25,
|
|
107
|
+
cacheWritePerM: 1.25,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
provider: "openai",
|
|
112
|
+
match: /^gpt-4o-mini$/i,
|
|
113
|
+
price: {
|
|
114
|
+
inputPerM: 0.15,
|
|
115
|
+
outputPerM: 0.6,
|
|
116
|
+
cacheReadPerM: 0.075,
|
|
117
|
+
cacheWritePerM: 0.075,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
function finite(value: unknown): number {
|
|
123
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function average(values: number[]): number {
|
|
127
|
+
if (values.length === 0) {
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function averageOptional(values: Array<number | undefined>): number | undefined {
|
|
134
|
+
const filtered = values.filter((value): value is number => typeof value === "number");
|
|
135
|
+
if (filtered.length === 0) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return average(filtered);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resolvePriceConfig(provider: string, model: string): PriceConfig | undefined {
|
|
142
|
+
const normalizedProvider = provider.trim().toLowerCase();
|
|
143
|
+
for (const candidate of PRICE_CONFIGS) {
|
|
144
|
+
if (candidate.provider !== normalizedProvider) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (candidate.match.test(model)) {
|
|
148
|
+
return candidate.price;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createUsageSnapshot(params: {
|
|
155
|
+
provider: string;
|
|
156
|
+
model: string;
|
|
157
|
+
usage?: UsageLike;
|
|
158
|
+
}): UsageSnapshot {
|
|
159
|
+
const input = finite(params.usage?.input);
|
|
160
|
+
const output = finite(params.usage?.output);
|
|
161
|
+
const cacheRead = finite(params.usage?.cacheRead);
|
|
162
|
+
const cacheWrite = finite(params.usage?.cacheWrite);
|
|
163
|
+
const total = finite(params.usage?.total) || input + output + cacheRead + cacheWrite;
|
|
164
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
165
|
+
const cacheBase = Math.max(1, promptTokens);
|
|
166
|
+
const price = resolvePriceConfig(params.provider, params.model);
|
|
167
|
+
const estimatedCostUsd = price
|
|
168
|
+
? (input / 1_000_000) * price.inputPerM +
|
|
169
|
+
(output / 1_000_000) * price.outputPerM +
|
|
170
|
+
(cacheRead / 1_000_000) * (price.cacheReadPerM ?? price.inputPerM) +
|
|
171
|
+
(cacheWrite / 1_000_000) * (price.cacheWritePerM ?? price.inputPerM)
|
|
172
|
+
: undefined;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
input,
|
|
176
|
+
output,
|
|
177
|
+
cacheRead,
|
|
178
|
+
cacheWrite,
|
|
179
|
+
total,
|
|
180
|
+
promptTokens,
|
|
181
|
+
cacheHitRate: cacheRead / cacheBase,
|
|
182
|
+
cacheWriteRate: cacheWrite / cacheBase,
|
|
183
|
+
estimatedCostUsd,
|
|
184
|
+
costEstimateBasis: estimatedCostUsd === undefined ? "token_only" : "estimated",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function classifyTrend(current: number | undefined, prior: number | undefined): TrendState {
|
|
189
|
+
if (typeof current !== "number" || typeof prior !== "number" || prior <= 0) {
|
|
190
|
+
return "insufficient_data";
|
|
191
|
+
}
|
|
192
|
+
if (current >= prior * 1.2) {
|
|
193
|
+
return "rising";
|
|
194
|
+
}
|
|
195
|
+
if (current <= prior * 0.85) {
|
|
196
|
+
return "improving";
|
|
197
|
+
}
|
|
198
|
+
return "stable";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function movingWindow(entries: UsageWindowEntry[], size: number): UsageWindowEntry[] {
|
|
202
|
+
return entries.slice(Math.max(0, entries.length - size));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function previousWindow(entries: UsageWindowEntry[], size: number): UsageWindowEntry[] {
|
|
206
|
+
const end = Math.max(0, entries.length - size);
|
|
207
|
+
const start = Math.max(0, end - size);
|
|
208
|
+
return entries.slice(start, end);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function summarizeWindow(entries: UsageWindowEntry[]) {
|
|
212
|
+
return {
|
|
213
|
+
avgPromptTokens: average(entries.map((entry) => entry.promptTokens)),
|
|
214
|
+
avgCacheRead: average(entries.map((entry) => entry.cacheRead)),
|
|
215
|
+
avgCacheWrite: average(entries.map((entry) => entry.cacheWrite)),
|
|
216
|
+
avgCacheHitRate: average(entries.map((entry) => entry.cacheHitRate)),
|
|
217
|
+
avgCacheWriteRate: average(entries.map((entry) => entry.cacheWriteRate)),
|
|
218
|
+
avgEstimatedCostUsd: averageOptional(entries.map((entry) => entry.estimatedCostUsd)),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function appendUsageWindow(
|
|
223
|
+
history: UsageWindowEntry[],
|
|
224
|
+
entry: UsageWindowEntry,
|
|
225
|
+
): UsageWindowEntry[] {
|
|
226
|
+
const next = [...history, entry];
|
|
227
|
+
if (next.length <= WINDOW_LIMIT) {
|
|
228
|
+
return next;
|
|
229
|
+
}
|
|
230
|
+
return next.slice(next.length - WINDOW_LIMIT);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function summarizeUsageWindow(history: UsageWindowEntry[]): UsageTrendSummary {
|
|
234
|
+
const window5Entries = movingWindow(history, 5);
|
|
235
|
+
const window20Entries = movingWindow(history, 20);
|
|
236
|
+
const prior5Entries = previousWindow(history, 5);
|
|
237
|
+
const current5 = summarizeWindow(window5Entries);
|
|
238
|
+
const current20 = summarizeWindow(window20Entries);
|
|
239
|
+
const prior5 = summarizeWindow(prior5Entries);
|
|
240
|
+
|
|
241
|
+
const cacheWriteRateTrend = classifyTrend(current5.avgCacheWriteRate, prior5.avgCacheWriteRate);
|
|
242
|
+
const cacheHitRateTrend = classifyTrend(current5.avgCacheHitRate, prior5.avgCacheHitRate);
|
|
243
|
+
const promptTokensTrend = classifyTrend(current5.avgPromptTokens, prior5.avgPromptTokens);
|
|
244
|
+
const costTrend = classifyTrend(current5.avgEstimatedCostUsd, prior5.avgEstimatedCostUsd);
|
|
245
|
+
|
|
246
|
+
const alerts: string[] = [];
|
|
247
|
+
if (cacheWriteRateTrend === "rising" && current5.avgCacheWriteRate >= 0.12) {
|
|
248
|
+
alerts.push("cache_write_rate_rising");
|
|
249
|
+
}
|
|
250
|
+
if (promptTokensTrend === "rising" && current5.avgPromptTokens >= 20_000) {
|
|
251
|
+
alerts.push("prompt_tokens_rising");
|
|
252
|
+
}
|
|
253
|
+
if (costTrend === "rising" && (current5.avgEstimatedCostUsd ?? 0) >= 0.05) {
|
|
254
|
+
alerts.push("estimated_cost_rising");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
turnsSeen: history.length,
|
|
259
|
+
window5: current5,
|
|
260
|
+
window20: current20,
|
|
261
|
+
trends: {
|
|
262
|
+
cacheWriteRate: cacheWriteRateTrend,
|
|
263
|
+
cacheHitRate: cacheHitRateTrend,
|
|
264
|
+
promptTokens: promptTokensTrend,
|
|
265
|
+
estimatedCostUsd: costTrend,
|
|
266
|
+
},
|
|
267
|
+
alerts,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { normalizeWhitespace } from "./chunking.js";
|
|
2
|
+
import { isLikelyTranscriptLikeText, isOversizedAtomicMemory } from "./capture.js";
|
|
3
|
+
import type { MemoryBraidResult, RemediationState } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type RemediationAction = "audit" | "quarantine" | "delete" | "purge-all-captured";
|
|
6
|
+
|
|
7
|
+
export type AuditReason =
|
|
8
|
+
| "legacy_capture_missing_provenance"
|
|
9
|
+
| "invalid_capture_metadata"
|
|
10
|
+
| "transcript_like_content"
|
|
11
|
+
| "oversized_capture_content";
|
|
12
|
+
|
|
13
|
+
export type AuditRecord = {
|
|
14
|
+
memory: MemoryBraidResult;
|
|
15
|
+
sourceType: string;
|
|
16
|
+
captureOrigin?: string;
|
|
17
|
+
pluginCaptureVersion?: string;
|
|
18
|
+
quarantined: boolean;
|
|
19
|
+
quarantineReason?: string;
|
|
20
|
+
suspiciousReasons: AuditReason[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AuditSummary = {
|
|
24
|
+
total: number;
|
|
25
|
+
captured: number;
|
|
26
|
+
suspicious: number;
|
|
27
|
+
quarantined: number;
|
|
28
|
+
bySourceType: Record<string, number>;
|
|
29
|
+
byCaptureOrigin: Record<string, number>;
|
|
30
|
+
byPluginVersion: Record<string, number>;
|
|
31
|
+
suspiciousByReason: Record<AuditReason, number>;
|
|
32
|
+
samples: Array<{
|
|
33
|
+
id?: string;
|
|
34
|
+
reasons: AuditReason[];
|
|
35
|
+
snippet: string;
|
|
36
|
+
}>;
|
|
37
|
+
records: AuditRecord[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
41
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
return value as Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function asString(value: unknown): string | undefined {
|
|
48
|
+
if (typeof value !== "string") {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const trimmed = value.trim();
|
|
52
|
+
return trimmed || undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isKnownCaptureOrigin(value: string | undefined): boolean {
|
|
56
|
+
return value === "external_user" || value === "assistant_derived";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isKnownCapturePath(value: string | undefined): boolean {
|
|
60
|
+
return value === "before_message_write" || value === "agent_end_last_turn";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isQuarantinedMemory(
|
|
64
|
+
record: MemoryBraidResult,
|
|
65
|
+
remediationState?: RemediationState,
|
|
66
|
+
): { quarantined: boolean; reason?: string } {
|
|
67
|
+
const metadata = asRecord(record.metadata);
|
|
68
|
+
const local = record.id ? remediationState?.quarantined?.[record.id] : undefined;
|
|
69
|
+
if (local) {
|
|
70
|
+
return {
|
|
71
|
+
quarantined: true,
|
|
72
|
+
reason: local.reason,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const status = asString(metadata.remediationStatus);
|
|
77
|
+
const quarantinedAt = asString(metadata.quarantinedAt);
|
|
78
|
+
if (status === "quarantined" || quarantinedAt) {
|
|
79
|
+
return {
|
|
80
|
+
quarantined: true,
|
|
81
|
+
reason: asString(metadata.quarantineReason),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { quarantined: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildQuarantineMetadata(
|
|
89
|
+
metadata: Record<string, unknown> | undefined,
|
|
90
|
+
reason: string,
|
|
91
|
+
quarantinedAt: string,
|
|
92
|
+
): Record<string, unknown> {
|
|
93
|
+
return {
|
|
94
|
+
...(metadata ?? {}),
|
|
95
|
+
remediationStatus: "quarantined",
|
|
96
|
+
quarantinedAt,
|
|
97
|
+
quarantineReason: reason,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function clearQuarantineMetadata(
|
|
102
|
+
metadata: Record<string, unknown> | undefined,
|
|
103
|
+
): Record<string, unknown> {
|
|
104
|
+
const next = { ...(metadata ?? {}) };
|
|
105
|
+
delete next.remediationStatus;
|
|
106
|
+
delete next.quarantinedAt;
|
|
107
|
+
delete next.quarantineReason;
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function analyzeMemoryRecord(
|
|
112
|
+
memory: MemoryBraidResult,
|
|
113
|
+
remediationState?: RemediationState,
|
|
114
|
+
): AuditRecord {
|
|
115
|
+
const metadata = asRecord(memory.metadata);
|
|
116
|
+
const sourceType = asString(metadata.sourceType) ?? "unknown";
|
|
117
|
+
const captureOrigin = asString(metadata.captureOrigin);
|
|
118
|
+
const capturePath = asString(metadata.capturePath);
|
|
119
|
+
const pluginCaptureVersion = asString(metadata.pluginCaptureVersion);
|
|
120
|
+
const suspiciousReasons: AuditReason[] = [];
|
|
121
|
+
const snippet = normalizeWhitespace(memory.snippet);
|
|
122
|
+
|
|
123
|
+
if (sourceType === "capture") {
|
|
124
|
+
const missingProvenance =
|
|
125
|
+
!captureOrigin || !pluginCaptureVersion || !capturePath;
|
|
126
|
+
if (missingProvenance) {
|
|
127
|
+
suspiciousReasons.push("legacy_capture_missing_provenance");
|
|
128
|
+
} else if (!isKnownCaptureOrigin(captureOrigin) || !isKnownCapturePath(capturePath)) {
|
|
129
|
+
suspiciousReasons.push("invalid_capture_metadata");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isLikelyTranscriptLikeText(snippet)) {
|
|
133
|
+
suspiciousReasons.push("transcript_like_content");
|
|
134
|
+
}
|
|
135
|
+
if (isOversizedAtomicMemory(snippet)) {
|
|
136
|
+
suspiciousReasons.push("oversized_capture_content");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const quarantine = isQuarantinedMemory(memory, remediationState);
|
|
141
|
+
return {
|
|
142
|
+
memory,
|
|
143
|
+
sourceType,
|
|
144
|
+
captureOrigin,
|
|
145
|
+
pluginCaptureVersion,
|
|
146
|
+
quarantined: quarantine.quarantined,
|
|
147
|
+
quarantineReason: quarantine.reason,
|
|
148
|
+
suspiciousReasons: Array.from(new Set(suspiciousReasons)),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function buildAuditSummary(params: {
|
|
153
|
+
records: MemoryBraidResult[];
|
|
154
|
+
remediationState?: RemediationState;
|
|
155
|
+
sampleLimit?: number;
|
|
156
|
+
}): AuditSummary {
|
|
157
|
+
const bySourceType: Record<string, number> = {};
|
|
158
|
+
const byCaptureOrigin: Record<string, number> = {};
|
|
159
|
+
const byPluginVersion: Record<string, number> = {};
|
|
160
|
+
const suspiciousByReason: Record<AuditReason, number> = {
|
|
161
|
+
legacy_capture_missing_provenance: 0,
|
|
162
|
+
invalid_capture_metadata: 0,
|
|
163
|
+
transcript_like_content: 0,
|
|
164
|
+
oversized_capture_content: 0,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const analyzed = params.records.map((record) => analyzeMemoryRecord(record, params.remediationState));
|
|
168
|
+
for (const record of analyzed) {
|
|
169
|
+
bySourceType[record.sourceType] = (bySourceType[record.sourceType] ?? 0) + 1;
|
|
170
|
+
if (record.captureOrigin) {
|
|
171
|
+
byCaptureOrigin[record.captureOrigin] = (byCaptureOrigin[record.captureOrigin] ?? 0) + 1;
|
|
172
|
+
} else if (record.sourceType === "capture") {
|
|
173
|
+
byCaptureOrigin["missing"] = (byCaptureOrigin.missing ?? 0) + 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (record.pluginCaptureVersion) {
|
|
177
|
+
byPluginVersion[record.pluginCaptureVersion] =
|
|
178
|
+
(byPluginVersion[record.pluginCaptureVersion] ?? 0) + 1;
|
|
179
|
+
} else if (record.sourceType === "capture") {
|
|
180
|
+
byPluginVersion["missing"] = (byPluginVersion.missing ?? 0) + 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const reason of record.suspiciousReasons) {
|
|
184
|
+
suspiciousByReason[reason] += 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const suspiciousRecords = analyzed.filter((record) => record.suspiciousReasons.length > 0);
|
|
189
|
+
const sampleLimit = Math.max(1, params.sampleLimit ?? 5);
|
|
190
|
+
return {
|
|
191
|
+
total: analyzed.length,
|
|
192
|
+
captured: analyzed.filter((record) => record.sourceType === "capture").length,
|
|
193
|
+
suspicious: suspiciousRecords.length,
|
|
194
|
+
quarantined: analyzed.filter((record) => record.quarantined).length,
|
|
195
|
+
bySourceType,
|
|
196
|
+
byCaptureOrigin,
|
|
197
|
+
byPluginVersion,
|
|
198
|
+
suspiciousByReason,
|
|
199
|
+
samples: suspiciousRecords.slice(0, sampleLimit).map((record) => ({
|
|
200
|
+
id: record.memory.id,
|
|
201
|
+
reasons: record.suspiciousReasons,
|
|
202
|
+
snippet: record.memory.snippet,
|
|
203
|
+
})),
|
|
204
|
+
records: analyzed,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function selectRemediationTargets(
|
|
209
|
+
summary: AuditSummary,
|
|
210
|
+
action: RemediationAction,
|
|
211
|
+
): AuditRecord[] {
|
|
212
|
+
if (action === "purge-all-captured") {
|
|
213
|
+
return summary.records.filter((record) => record.sourceType === "capture");
|
|
214
|
+
}
|
|
215
|
+
if (action === "audit") {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
return summary.records.filter(
|
|
219
|
+
(record) =>
|
|
220
|
+
record.sourceType === "capture" &&
|
|
221
|
+
record.suspiciousReasons.length > 0 &&
|
|
222
|
+
Boolean(record.memory.id),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function formatCounts(label: string, counts: Record<string, number>): string[] {
|
|
227
|
+
const entries = Object.entries(counts).sort((left, right) => right[1] - left[1]);
|
|
228
|
+
if (entries.length === 0) {
|
|
229
|
+
return [`${label}: n/a`];
|
|
230
|
+
}
|
|
231
|
+
return [`${label}:`, ...entries.map(([key, value]) => `- ${key}: ${value}`)];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function formatAuditSummary(summary: AuditSummary): string {
|
|
235
|
+
const lines = [
|
|
236
|
+
"Memory Braid remediation audit",
|
|
237
|
+
`- total: ${summary.total}`,
|
|
238
|
+
`- captured: ${summary.captured}`,
|
|
239
|
+
`- suspicious: ${summary.suspicious}`,
|
|
240
|
+
`- quarantined: ${summary.quarantined}`,
|
|
241
|
+
...formatCounts("By sourceType", summary.bySourceType),
|
|
242
|
+
...formatCounts("By captureOrigin", summary.byCaptureOrigin),
|
|
243
|
+
...formatCounts("By pluginCaptureVersion", summary.byPluginVersion),
|
|
244
|
+
...formatCounts("Suspicious by reason", summary.suspiciousByReason),
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
if (summary.samples.length > 0) {
|
|
248
|
+
lines.push("Samples:");
|
|
249
|
+
for (const sample of summary.samples) {
|
|
250
|
+
lines.push(
|
|
251
|
+
`- ${sample.id ?? "unknown"} [${sample.reasons.join(", ")}] ${normalizeWhitespace(sample.snippet).slice(0, 140)}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return lines.join("\n");
|
|
257
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
CaptureDedupeState,
|
|
5
5
|
LifecycleState,
|
|
6
6
|
PluginStatsState,
|
|
7
|
+
RemediationState,
|
|
7
8
|
} from "./types.js";
|
|
8
9
|
|
|
9
10
|
const DEFAULT_CAPTURE_DEDUPE: CaptureDedupeState = {
|
|
@@ -30,14 +31,36 @@ const DEFAULT_STATS: PluginStatsState = {
|
|
|
30
31
|
mem0AddWithoutId: 0,
|
|
31
32
|
entityAnnotatedCandidates: 0,
|
|
32
33
|
totalEntitiesAttached: 0,
|
|
34
|
+
trustedTurns: 0,
|
|
35
|
+
fallbackTurnSlices: 0,
|
|
36
|
+
provenanceSkipped: 0,
|
|
37
|
+
transcriptShapeSkipped: 0,
|
|
38
|
+
quarantinedFiltered: 0,
|
|
39
|
+
remediationQuarantined: 0,
|
|
40
|
+
remediationDeleted: 0,
|
|
41
|
+
agentLearningToolCalls: 0,
|
|
42
|
+
agentLearningAccepted: 0,
|
|
43
|
+
agentLearningRejectedValidation: 0,
|
|
44
|
+
agentLearningRejectedNovelty: 0,
|
|
45
|
+
agentLearningRejectedCooldown: 0,
|
|
46
|
+
agentLearningAutoCaptured: 0,
|
|
47
|
+
agentLearningAutoRejected: 0,
|
|
48
|
+
agentLearningInjected: 0,
|
|
49
|
+
agentLearningRecallHits: 0,
|
|
33
50
|
},
|
|
34
51
|
};
|
|
35
52
|
|
|
53
|
+
const DEFAULT_REMEDIATION: RemediationState = {
|
|
54
|
+
version: 1,
|
|
55
|
+
quarantined: {},
|
|
56
|
+
};
|
|
57
|
+
|
|
36
58
|
export type StatePaths = {
|
|
37
59
|
rootDir: string;
|
|
38
60
|
captureDedupeFile: string;
|
|
39
61
|
lifecycleFile: string;
|
|
40
62
|
statsFile: string;
|
|
63
|
+
remediationFile: string;
|
|
41
64
|
stateLockFile: string;
|
|
42
65
|
};
|
|
43
66
|
|
|
@@ -48,6 +71,7 @@ export function createStatePaths(stateDir: string): StatePaths {
|
|
|
48
71
|
captureDedupeFile: path.join(rootDir, "capture-dedupe.v1.json"),
|
|
49
72
|
lifecycleFile: path.join(rootDir, "lifecycle.v1.json"),
|
|
50
73
|
statsFile: path.join(rootDir, "stats.v1.json"),
|
|
74
|
+
remediationFile: path.join(rootDir, "remediation.v1.json"),
|
|
51
75
|
stateLockFile: path.join(rootDir, "state.v1.lock"),
|
|
52
76
|
};
|
|
53
77
|
}
|
|
@@ -121,6 +145,21 @@ export async function writeStatsState(paths: StatePaths, state: PluginStatsState
|
|
|
121
145
|
await writeJsonFile(paths.statsFile, state);
|
|
122
146
|
}
|
|
123
147
|
|
|
148
|
+
export async function readRemediationState(paths: StatePaths): Promise<RemediationState> {
|
|
149
|
+
const value = await readJsonFile(paths.remediationFile, DEFAULT_REMEDIATION);
|
|
150
|
+
return {
|
|
151
|
+
version: 1,
|
|
152
|
+
quarantined: { ...(value.quarantined ?? {}) },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function writeRemediationState(
|
|
157
|
+
paths: StatePaths,
|
|
158
|
+
state: RemediationState,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
await writeJsonFile(paths.remediationFile, state);
|
|
161
|
+
}
|
|
162
|
+
|
|
124
163
|
export async function withStateLock<T>(
|
|
125
164
|
lockFilePath: string,
|
|
126
165
|
fn: () => Promise<T>,
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
export type MemoryBraidSource = "local" | "mem0";
|
|
2
2
|
|
|
3
|
+
export const PLUGIN_CAPTURE_VERSION = "2026-03-provenance-v1";
|
|
4
|
+
|
|
5
|
+
export type CaptureOrigin = "external_user" | "assistant_derived";
|
|
6
|
+
|
|
7
|
+
export type CapturePath = "before_message_write" | "agent_end_last_turn";
|
|
8
|
+
|
|
9
|
+
export type MemoryOwner = "user" | "agent";
|
|
10
|
+
|
|
11
|
+
export type MemoryKind =
|
|
12
|
+
| "fact"
|
|
13
|
+
| "preference"
|
|
14
|
+
| "decision"
|
|
15
|
+
| "task"
|
|
16
|
+
| "heuristic"
|
|
17
|
+
| "lesson"
|
|
18
|
+
| "strategy"
|
|
19
|
+
| "other";
|
|
20
|
+
|
|
21
|
+
export type CaptureIntent = "observed" | "inferred" | "self_reflection" | "explicit_tool";
|
|
22
|
+
|
|
23
|
+
export type RecallTarget = "response" | "planning" | "both";
|
|
24
|
+
|
|
25
|
+
export type Stability = "ephemeral" | "session" | "durable";
|
|
26
|
+
|
|
3
27
|
export type ScopeKey = {
|
|
4
28
|
workspaceHash: string;
|
|
5
29
|
agentId: string;
|
|
@@ -62,7 +86,24 @@ export type CaptureStats = {
|
|
|
62
86
|
mem0AddWithoutId: number;
|
|
63
87
|
entityAnnotatedCandidates: number;
|
|
64
88
|
totalEntitiesAttached: number;
|
|
89
|
+
trustedTurns: number;
|
|
90
|
+
fallbackTurnSlices: number;
|
|
91
|
+
provenanceSkipped: number;
|
|
92
|
+
transcriptShapeSkipped: number;
|
|
93
|
+
quarantinedFiltered: number;
|
|
94
|
+
remediationQuarantined: number;
|
|
95
|
+
remediationDeleted: number;
|
|
96
|
+
agentLearningToolCalls: number;
|
|
97
|
+
agentLearningAccepted: number;
|
|
98
|
+
agentLearningRejectedValidation: number;
|
|
99
|
+
agentLearningRejectedNovelty: number;
|
|
100
|
+
agentLearningRejectedCooldown: number;
|
|
101
|
+
agentLearningAutoCaptured: number;
|
|
102
|
+
agentLearningAutoRejected: number;
|
|
103
|
+
agentLearningInjected: number;
|
|
104
|
+
agentLearningRecallHits: number;
|
|
65
105
|
lastRunAt?: string;
|
|
106
|
+
lastRemediationAt?: string;
|
|
66
107
|
};
|
|
67
108
|
|
|
68
109
|
export type PluginStatsState = {
|
|
@@ -76,3 +117,36 @@ export type ExtractedCandidate = {
|
|
|
76
117
|
score: number;
|
|
77
118
|
source: "heuristic" | "ml";
|
|
78
119
|
};
|
|
120
|
+
|
|
121
|
+
export type PendingInboundTurn = {
|
|
122
|
+
text: string;
|
|
123
|
+
messageHash: string;
|
|
124
|
+
receivedAt: number;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type CaptureInputMessage = {
|
|
128
|
+
role: "user" | "assistant";
|
|
129
|
+
text: string;
|
|
130
|
+
origin: CaptureOrigin;
|
|
131
|
+
messageHash: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type AssembledCaptureInput = {
|
|
135
|
+
messages: CaptureInputMessage[];
|
|
136
|
+
capturePath: CapturePath;
|
|
137
|
+
turnHash: string;
|
|
138
|
+
fallbackUsed: boolean;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type RemediationState = {
|
|
142
|
+
version: 1;
|
|
143
|
+
quarantined: Record<
|
|
144
|
+
string,
|
|
145
|
+
{
|
|
146
|
+
memoryId: string;
|
|
147
|
+
reason: string;
|
|
148
|
+
quarantinedAt: string;
|
|
149
|
+
updatedRemotely?: boolean;
|
|
150
|
+
}
|
|
151
|
+
>;
|
|
152
|
+
};
|