@persistio/openclaw-plugin 0.1.8 → 0.2.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/dist/index.js CHANGED
@@ -1,830 +1,381 @@
1
- import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
2
1
  import { Type } from '@sinclair/typebox';
3
- import { PersistioClient } from './client.js';
4
- import { prepareMessageForIngest, resolveIngestPolicy, shouldIngestSession, } from './ingest-policy.js';
5
- const DEFAULT_SEND_ROLES = {
6
- user: 'enabled',
7
- agent: 'enabled',
8
- tool: 'disabled',
9
- };
10
- const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
11
- const MAX_TRACKED_SESSIONS = 250;
12
- const MAX_SENT_KEYS_PER_SESSION = 2000;
13
- const RECALL_CIRCUIT_BREAKER_FAILURE_THRESHOLD = 3;
14
- const RECALL_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000;
2
+ import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
3
+ import { PersistioClient, PersistioTimeoutError } from './client.js';
4
+ import { prepareCapture } from './capture.js';
5
+ import { resolveConfig } from './config.js';
6
+ import { buildMemoryBlock, buildRecallQuery } from './memory-format.js';
7
+ const CAPTURE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
8
+ const MAX_CAPTURED_KEYS = 2000;
9
+ const MAX_CAPTURE_STORES = 250;
15
10
  const RECALL_GUARD_MARGIN_MS = 250;
16
- const DEFAULT_TOKEN_BUDGET = 400;
17
- const DEFAULT_RECALL_TOP_K = 4;
18
- const DEFAULT_RECALL_TIMEOUT_MS = 1500;
19
- const MAX_MEMORY_SEARCH_RESULTS = 8;
20
- const MAX_PROMPT_MEMORY_ITEM_CHARS = 500;
21
- const MAX_MEMORY_SNIPPET_CHARS = 360;
22
- class RecallCircuitBreaker {
23
- consecutiveFailures = 0;
11
+ const RECALL_FAILURE_THRESHOLD = 3;
12
+ const RECALL_COOLDOWN_MS = 60_000;
13
+ class CircuitBreaker {
14
+ failures = 0;
24
15
  openedUntil = 0;
25
16
  canAttempt(now = Date.now()) {
26
17
  return now >= this.openedUntil;
27
18
  }
28
- remainingMs(now = Date.now()) {
29
- return Math.max(0, this.openedUntil - now);
30
- }
31
19
  recordSuccess() {
32
- this.consecutiveFailures = 0;
20
+ this.failures = 0;
33
21
  this.openedUntil = 0;
34
22
  }
35
23
  recordFailure(now = Date.now()) {
36
- this.consecutiveFailures += 1;
37
- if (this.consecutiveFailures >= RECALL_CIRCUIT_BREAKER_FAILURE_THRESHOLD) {
38
- this.openedUntil = now + RECALL_CIRCUIT_BREAKER_COOLDOWN_MS;
24
+ this.failures += 1;
25
+ if (this.failures >= RECALL_FAILURE_THRESHOLD) {
26
+ this.openedUntil = now + RECALL_COOLDOWN_MS;
39
27
  return true;
40
28
  }
41
29
  return false;
42
30
  }
43
- }
44
- function resolveSendConfig(raw) {
45
- const send = raw['send'];
46
- const roles = typeof send === 'object' && send !== null
47
- ? send['roles']
48
- : undefined;
49
- const rawRoles = typeof roles === 'object' && roles !== null
50
- ? roles
51
- : {};
52
- return {
53
- roles: {
54
- user: rawRoles['user'] === 'disabled' ? 'disabled' : DEFAULT_SEND_ROLES.user,
55
- agent: rawRoles['agent'] === 'disabled' ? 'disabled' : DEFAULT_SEND_ROLES.agent,
56
- tool: rawRoles['tool'] === 'enabled' ? 'enabled' : DEFAULT_SEND_ROLES.tool,
57
- },
58
- };
59
- }
60
- function resolveRecallMinSimilarity(value) {
61
- return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
62
- ? value
63
- : undefined;
64
- }
65
- function resolvePositiveInteger(value, fallback) {
66
- return typeof value === 'number' && Number.isFinite(value) && value >= 1
67
- ? Math.floor(value)
68
- : fallback;
69
- }
70
- function resolveBoolean(value, fallback) {
71
- return typeof value === 'boolean' ? value : fallback;
72
- }
73
- function resolveOptionalPositiveInteger(value) {
74
- return typeof value === 'number' && Number.isFinite(value) && value >= 1
75
- ? Math.floor(value)
76
- : undefined;
77
- }
78
- function clampPositiveInteger(value, min, max) {
79
- return Math.min(max, Math.max(min, Math.floor(value)));
80
- }
81
- function resolveConfig(raw) {
82
- const c = (raw ?? {});
83
- return {
84
- baseURL: typeof c['baseURL'] === 'string' ? c['baseURL'] : '',
85
- apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
86
- tokenBudget: resolvePositiveInteger(c['tokenBudget'], DEFAULT_TOKEN_BUDGET),
87
- recallTopK: resolvePositiveInteger(c['recallTopK'], DEFAULT_RECALL_TOP_K),
88
- recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
89
- recallTimeout: resolvePositiveInteger(c['recallTimeout'], DEFAULT_RECALL_TIMEOUT_MS),
90
- recallIncludePending: resolveBoolean(c['recallIncludePending'], false),
91
- includeRelatedMemories: resolveBoolean(c['includeRelatedMemories'], false),
92
- ingest: resolveIngestPolicy(c['ingest']),
93
- send: resolveSendConfig(c),
94
- };
95
- }
96
- function estimateTokens(text) {
97
- return Math.ceil(text.length / 4);
98
- }
99
- function truncate(text, maxLength) {
100
- if (text.length <= maxLength)
101
- return text;
102
- return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
103
- }
104
- function detectTaskType(text) {
105
- const normalized = text.toLowerCase();
106
- if (/(error|bug|fail|failing|issue|broken|debug|debugging|trace|stack)/.test(normalized)) {
107
- return 'troubleshooting';
108
- }
109
- if (/(code|coding|typescript|javascript|python|implement|refactor|function|class|api|build|test)/.test(normalized)) {
110
- return 'coding';
111
- }
112
- if (/(plan|planning|roadmap|strategy|steps|milestone|timeline|organize)/.test(normalized)) {
113
- return 'planning';
114
- }
115
- if (/(write|writing|draft|edit|copy|blog|essay|summary|summarize|document)/.test(normalized)) {
116
- return 'writing';
31
+ remainingMs(now = Date.now()) {
32
+ return Math.max(0, this.openedUntil - now);
117
33
  }
118
- return 'general';
119
- }
120
- function buildRecallQuery(event) {
121
- const relevantMessages = Array.isArray(event.messages)
122
- ? event.messages
123
- .map((msg) => {
124
- if (typeof msg !== 'object' || msg === null)
125
- return null;
126
- const m = msg;
127
- const role = m['role'];
128
- if (role !== 'user' && role !== 'assistant')
129
- return null;
130
- const text = extractTextFromMessage(msg);
131
- if (!text)
132
- return null;
133
- return { role, text: text.replace(/\s+/g, ' ').trim() };
134
- })
135
- .filter((msg) => msg !== null && msg.text.length > 0)
136
- : [];
137
- const lastUserIndex = (() => {
138
- for (let i = relevantMessages.length - 1; i >= 0; i -= 1) {
139
- if (relevantMessages[i].role === 'user')
140
- return i;
141
- }
142
- return -1;
143
- })();
144
- const lastUserMessage = lastUserIndex >= 0
145
- ? relevantMessages[lastUserIndex].text
146
- : event.prompt?.replace(/\s+/g, ' ').trim() || 'recent context';
147
- const primary = truncate(lastUserMessage, 300);
148
- const contextStart = Math.max(0, lastUserIndex - 6);
149
- const contextMessages = lastUserIndex >= 0
150
- ? relevantMessages.slice(contextStart, lastUserIndex)
151
- : relevantMessages.slice(-6);
152
- const contextSummary = truncate(contextMessages
153
- .map((msg) => `${msg.role === 'user' ? 'U' : 'A'}:${msg.text}`)
154
- .join(' | '), 200);
155
- const taskType = detectTaskType(`${primary} ${event.prompt ?? ''}`);
156
- const parts = [primary];
157
- if (contextSummary.length > 0)
158
- parts.push(`Context: ${contextSummary}`);
159
- parts.push(`[task: ${taskType}]`);
160
- return truncate(parts.join('\n'), 600);
161
- }
162
- function toStringArray(value) {
163
- return Array.isArray(value)
164
- ? value.filter((item) => typeof item === 'string')
165
- : [];
166
34
  }
167
- function buildMemoryBlock(bundle, budget, relatedBundle) {
168
- if (!bundle || typeof bundle !== 'object')
169
- return '';
170
- const sections = [
171
- { title: 'Behavioural rules', items: [...toStringArray(bundle.global_user_rules), ...toStringArray(bundle.user_rules)] },
172
- { title: 'Preferences', items: toStringArray(bundle.user_preferences) },
173
- { title: 'Task patterns', items: toStringArray(bundle.task_patterns) },
174
- { title: 'Workflows', items: toStringArray(bundle.workflows) },
175
- { title: 'Project', items: toStringArray(bundle.project) },
176
- { title: 'Constraints', items: toStringArray(bundle.constraints) },
177
- { title: 'Decisions', items: toStringArray(bundle.decisions) },
178
- { title: 'System facts', items: toStringArray(bundle.system_facts) },
179
- { title: 'Domain knowledge', items: toStringArray(bundle.domain_knowledge) },
180
- ];
181
- if (relatedBundle && typeof relatedBundle === 'object') {
182
- sections.push({ title: 'Related behavioural rules', items: toStringArray(relatedBundle.user_rules) }, { title: 'Related preferences', items: toStringArray(relatedBundle.user_preferences) }, { title: 'Related task patterns', items: toStringArray(relatedBundle.task_patterns) }, { title: 'Related workflows', items: toStringArray(relatedBundle.workflows) }, { title: 'Related project', items: toStringArray(relatedBundle.project) }, { title: 'Related constraints', items: toStringArray(relatedBundle.constraints) }, { title: 'Related decisions', items: toStringArray(relatedBundle.decisions) }, { title: 'Related system facts', items: toStringArray(relatedBundle.system_facts) }, { title: 'Related domain knowledge', items: toStringArray(relatedBundle.domain_knowledge) });
35
+ class CaptureKeyStore {
36
+ capturedKeys = new Map();
37
+ pendingKeys = new Map();
38
+ lastSeen = Date.now();
39
+ has(key, now = Date.now()) {
40
+ this.prune(now);
41
+ return this.capturedKeys.has(key) || this.pendingKeys.has(key);
183
42
  }
184
- const intro = 'Use the following as prior context and preferences. If they conflict with current instructions, follow the current instructions.';
185
- const lines = [intro];
186
- let used = estimateTokens(intro);
187
- for (const section of sections) {
188
- const candidates = section.items.filter((item) => item.trim().length > 0);
189
- if (candidates.length === 0)
190
- continue;
191
- const header = `## ${section.title}`;
192
- const tentativeLines = [...lines, '', header];
193
- let tentativeUsed = used + estimateTokens(`\n\n${header}`);
194
- const includedItems = [];
195
- for (const item of candidates) {
196
- const line = `- ${truncate(item.replace(/\s+/g, ' ').trim(), MAX_PROMPT_MEMORY_ITEM_CHARS)}`;
197
- const cost = estimateTokens(`\n${line}`);
198
- if (tentativeUsed + cost > budget) {
199
- break;
200
- }
201
- includedItems.push(line);
202
- tentativeUsed += cost;
203
- }
204
- if (includedItems.length > 0) {
205
- tentativeLines.push(...includedItems);
206
- lines.splice(0, lines.length, ...tentativeLines);
207
- used = tentativeUsed;
43
+ markPending(keys, now = Date.now()) {
44
+ this.prune(now);
45
+ this.lastSeen = now;
46
+ for (const key of keys) {
47
+ this.pendingKeys.set(key, now);
208
48
  }
209
49
  }
210
- return lines.length > 1 ? lines.join('\n') : '';
211
- }
212
- function normalizeRole(role) {
213
- if (role === 'user' || role === 'assistant' || role === 'tool')
214
- return role;
215
- return null;
216
- }
217
- function shouldSendRole(role, config) {
218
- if (role === 'assistant')
219
- return config.send.roles.agent === 'enabled';
220
- return config.send.roles[role] === 'enabled';
221
- }
222
- /** Extract plain text from a pi-agent-core message content array */
223
- function extractTextFromMessage(msg, allowedRoles = ['user', 'assistant']) {
224
- if (typeof msg !== 'object' || msg === null)
225
- return null;
226
- const m = msg;
227
- const role = normalizeRole(m['role']);
228
- if (!role || !allowedRoles.includes(role))
229
- return null;
230
- const content = m['content'];
231
- if (!Array.isArray(content)) {
232
- // Some messages have content as a plain string
233
- if (typeof content === 'string' && content.length > 0)
234
- return content;
235
- return null;
236
- }
237
- const parts = [];
238
- for (const block of content) {
239
- if (typeof block === 'object' && block !== null) {
240
- const b = block;
241
- if (b['type'] === 'text' && typeof b['text'] === 'string' && b['text'].length > 0) {
242
- parts.push(b['text']);
50
+ markCaptured(keys, now = Date.now()) {
51
+ this.prune(now);
52
+ this.lastSeen = now;
53
+ for (const key of keys) {
54
+ this.pendingKeys.delete(key);
55
+ this.capturedKeys.set(key, now);
56
+ while (this.capturedKeys.size > MAX_CAPTURED_KEYS) {
57
+ const oldest = this.capturedKeys.keys().next().value;
58
+ if (!oldest)
59
+ break;
60
+ this.capturedKeys.delete(oldest);
243
61
  }
244
62
  }
245
63
  }
246
- return parts.length > 0 ? parts.join(' ') : null;
247
- }
248
- function resolveMessageTimestamp(msg) {
249
- if (typeof msg['timestamp'] === 'number')
250
- return new Date(msg['timestamp']).toISOString();
251
- if (typeof msg['timestamp'] === 'string')
252
- return msg['timestamp'];
253
- return null;
254
- }
255
- function hashString(input) {
256
- let hash = 0x811c9dc5;
257
- for (let i = 0; i < input.length; i += 1) {
258
- hash ^= input.charCodeAt(i);
259
- hash = Math.imul(hash, 0x01000193);
260
- }
261
- return (hash >>> 0).toString(16);
262
- }
263
- function buildMessageFingerprint(params) {
264
- const id = params.msg['id'];
265
- if (typeof id === 'string' && id.length > 0) {
266
- return `id:${params.sessionId}:${id}`;
267
- }
268
- const idempotencyKey = params.msg['idempotencyKey'];
269
- if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) {
270
- return `idempotency:${params.sessionId}:${idempotencyKey}`;
271
- }
272
- const timestamp = resolveMessageTimestamp(params.msg);
273
- const basis = timestamp ?? `index:${params.index}`;
274
- return `content:${params.sessionId}:${basis}:${params.role}:${hashString(params.text)}`;
275
- }
276
- function pruneSessionKeyStores(stores, now) {
277
- for (const [sessionId, store] of stores) {
278
- if (now - store.lastSeen > MESSAGE_KEY_TTL_MS)
279
- stores.delete(sessionId);
280
- }
281
- while (stores.size > MAX_TRACKED_SESSIONS) {
282
- const oldest = [...stores.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen)[0];
283
- if (!oldest)
284
- return;
285
- stores.delete(oldest[0]);
286
- }
287
- }
288
- function getSessionKeyStore(stores, sessionId, now) {
289
- pruneSessionKeyStores(stores, now);
290
- const existing = stores.get(sessionId);
291
- if (existing) {
292
- existing.lastSeen = now;
293
- return existing.keys;
294
- }
295
- const created = { keys: new Set(), lastSeen: now };
296
- stores.set(sessionId, created);
297
- return created.keys;
298
- }
299
- function rememberKeys(target, keys, limit = Number.POSITIVE_INFINITY) {
300
- for (const key of keys) {
301
- target.add(key);
302
- while (target.size > limit) {
303
- const oldest = target.values().next().value;
304
- if (!oldest)
305
- break;
306
- target.delete(oldest);
64
+ releasePending(keys, now = Date.now()) {
65
+ this.prune(now);
66
+ this.lastSeen = now;
67
+ for (const key of keys) {
68
+ this.pendingKeys.delete(key);
307
69
  }
308
70
  }
309
- }
310
- function forgetKeys(target, keys) {
311
- for (const key of keys)
312
- target.delete(key);
313
- }
314
- function summarizeOmissions(omissions) {
315
- if (omissions.length === 0)
316
- return 'none';
317
- const counts = new Map();
318
- for (const omission of omissions) {
319
- counts.set(omission.label, (counts.get(omission.label) ?? 0) + 1);
71
+ isExpired(now = Date.now()) {
72
+ this.prune(now);
73
+ return this.capturedKeys.size === 0
74
+ && this.pendingKeys.size === 0
75
+ && now - this.lastSeen > CAPTURE_KEY_TTL_MS;
320
76
  }
321
- return [...counts.entries()]
322
- .map(([label, count]) => `${label}:${count}`)
323
- .join(',');
324
- }
325
- function isTimeoutLikeError(err) {
326
- if (typeof err !== 'object' || err === null)
327
- return false;
328
- const record = err;
329
- const name = typeof record['name'] === 'string' ? record['name'] : '';
330
- if (name === 'TimeoutError' || name === 'AbortError')
331
- return true;
332
- const message = typeof record['message'] === 'string' ? record['message'].toLowerCase() : '';
333
- return message.includes('timeout') || message.includes('aborted');
334
- }
335
- async function runGuardedRecall(args) {
336
- const now = Date.now();
337
- if (!args.breaker.canAttempt(now)) {
338
- args.logger?.warn?.(`openclaw-persistio: ${args.operation} skipped; recall circuit breaker open `
339
- + `for ${args.breaker.remainingMs(now)}ms`);
340
- return args.fallback;
341
- }
342
- try {
343
- const result = await withPluginDeadline(args.operation, args.timeoutMs + RECALL_GUARD_MARGIN_MS, args.run);
344
- args.breaker.recordSuccess();
345
- return result;
346
- }
347
- catch (err) {
348
- const opened = args.breaker.recordFailure();
349
- args.logger?.warn?.(`openclaw-persistio: ${args.operation} failed open: ${String(err)}`
350
- + (opened ? `; recall circuit breaker open for ${RECALL_CIRCUIT_BREAKER_COOLDOWN_MS}ms` : ''));
351
- return args.fallback;
77
+ getLastSeen() {
78
+ return this.lastSeen;
352
79
  }
353
- }
354
- async function withPluginDeadline(operation, timeoutMs, run) {
355
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
356
- return run();
80
+ hasPending(now = Date.now()) {
81
+ this.prune(now);
82
+ return this.pendingKeys.size > 0;
357
83
  }
358
- let timeout;
359
- const deadline = new Promise((_resolve, reject) => {
360
- timeout = setTimeout(() => {
361
- const err = new Error(`Persistio ${operation} exceeded plugin deadline after ${timeoutMs}ms`);
362
- err.name = 'TimeoutError';
363
- reject(err);
364
- }, timeoutMs);
365
- });
366
- try {
367
- return await Promise.race([run(), deadline]);
368
- }
369
- finally {
370
- if (timeout)
371
- clearTimeout(timeout);
84
+ prune(now) {
85
+ for (const [key, timestamp] of this.capturedKeys.entries()) {
86
+ if (now - timestamp > CAPTURE_KEY_TTL_MS)
87
+ this.capturedKeys.delete(key);
88
+ }
89
+ for (const [key, timestamp] of this.pendingKeys.entries()) {
90
+ if (now - timestamp > CAPTURE_KEY_TTL_MS)
91
+ this.pendingKeys.delete(key);
92
+ }
372
93
  }
373
94
  }
374
- const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
375
95
  function jsonResult(payload) {
376
96
  return {
377
97
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
378
98
  details: payload,
379
99
  };
380
100
  }
381
- function buildMemorySearchUnavailableResult(error) {
101
+ function textResult(text, details = null) {
382
102
  return {
383
- results: [],
384
- disabled: true,
385
- unavailable: true,
386
- error,
387
- warning: 'Persistio memory retrieval is currently unavailable.',
388
- action: 'Continue without memory for this turn.',
389
- debug: { backend: 'builtin', provider: 'persistio' },
103
+ content: [{ type: 'text', text }],
104
+ details,
390
105
  };
391
106
  }
392
- function resolveMemorySearchLimit(params) {
393
- const requested = resolveOptionalPositiveInteger(params.maxResults) ?? params.fallback;
394
- return clampPositiveInteger(requested, 1, MAX_MEMORY_SEARCH_RESULTS);
395
- }
396
- function createClient(config, recallTopK = config.recallTopK) {
397
- return new PersistioClient({ ...config, recallTopK });
398
- }
399
- function normalizeMemoryScore(memory) {
400
- if (typeof memory.similarity === 'number' && Number.isFinite(memory.similarity)) {
401
- return memory.similarity;
402
- }
403
- if (Number.isFinite(memory.confidence)) {
404
- return memory.confidence > 1 ? memory.confidence / 100 : memory.confidence;
405
- }
406
- return 0;
407
- }
408
- function buildMemoryPath(id) {
409
- return `${PERSISTIO_MEMORY_PATH_PREFIX}${id}`;
410
- }
411
- function parseMemoryPath(relPath) {
412
- return relPath.startsWith(PERSISTIO_MEMORY_PATH_PREFIX)
413
- ? relPath.slice(PERSISTIO_MEMORY_PATH_PREFIX.length)
414
- : null;
107
+ async function guardedRecall(params) {
108
+ return (await guardedRecallOutcome(params)).value;
415
109
  }
416
- function formatMemoryDocument(memory) {
417
- const lines = [
418
- `Subject: ${memory.subject}`,
419
- `Memory ID: ${memory.id}`,
420
- `Confidence: ${memory.confidence}`,
421
- ];
422
- if (memory.categories.length > 0) {
423
- lines.push(`Categories: ${memory.categories.join(', ')}`);
110
+ async function guardedRecallOutcome(params) {
111
+ const now = Date.now();
112
+ if (!params.breaker.canAttempt(now)) {
113
+ const unavailable = `Persistio recall unavailable; circuit breaker open for ${params.breaker.remainingMs(now)}ms`;
114
+ params.logger?.warn?.(`openclaw-persistio-v2: ${params.operation} skipped; ${unavailable}`);
115
+ return { value: params.fallback, unavailable };
424
116
  }
425
- lines.push('', memory.data);
426
- return lines.join('\n');
427
- }
428
- async function probePersistio(client) {
429
117
  try {
430
- await client.recall('__openclaw_probe__');
431
- return { ok: true };
118
+ const result = await params.run();
119
+ params.breaker.recordSuccess();
120
+ return { value: result };
432
121
  }
433
122
  catch (err) {
434
- return { ok: false, error: String(err) };
123
+ const opened = params.breaker.recordFailure();
124
+ const unavailable = `Persistio recall unavailable during ${params.operation}: ${String(err)}`;
125
+ params.logger?.warn?.(`openclaw-persistio-v2: ${params.operation} failed open: ${String(err)}`
126
+ + (opened ? `; recall circuit breaker open for ${RECALL_COOLDOWN_MS}ms` : ''));
127
+ return { value: params.fallback, unavailable };
435
128
  }
436
129
  }
437
- function createMemorySearchManager(config, recallBreaker, logger) {
438
- const client = createClient(config);
439
- return {
440
- async search(query, opts) {
441
- if (opts?.sources && !opts.sources.includes('memory')) {
442
- return [];
443
- }
444
- const recallTopK = resolveMemorySearchLimit({ maxResults: opts?.maxResults, fallback: config.recallTopK });
445
- const recallClient = createClient(config, recallTopK);
446
- const memories = await runGuardedRecall({
447
- operation: 'memory search recall',
448
- timeoutMs: config.recallTimeout,
449
- fallback: [],
450
- breaker: recallBreaker,
451
- logger,
452
- run: () => recallClient.recall(query),
453
- });
454
- return memories
455
- .map((memory) => {
456
- const score = normalizeMemoryScore(memory);
457
- return {
458
- path: buildMemoryPath(memory.id),
459
- startLine: 1,
460
- endLine: 1,
461
- score,
462
- vectorScore: typeof memory.similarity === 'number' ? memory.similarity : undefined,
463
- snippet: truncate(memory.data.replace(/\s+/g, ' ').trim(), MAX_MEMORY_SNIPPET_CHARS),
464
- source: 'memory',
465
- citation: memory.subject,
466
- };
467
- })
468
- .filter((result) => opts?.minScore === undefined || result.score >= opts.minScore);
469
- },
470
- async readFile(params) {
471
- const memoryId = parseMemoryPath(params.relPath);
472
- if (!memoryId) {
473
- throw new Error(`Unsupported Persistio memory path: ${params.relPath}`);
130
+ function pruneCaptureStores(stores, now = Date.now()) {
131
+ for (const [sessionId, store] of stores.entries()) {
132
+ if (store.isExpired(now))
133
+ stores.delete(sessionId);
134
+ }
135
+ while (stores.size > MAX_CAPTURE_STORES) {
136
+ let oldestSessionId;
137
+ let oldestLastSeen = Number.POSITIVE_INFINITY;
138
+ for (const [sessionId, store] of stores.entries()) {
139
+ if (store.hasPending(now))
140
+ continue;
141
+ const lastSeen = store.getLastSeen();
142
+ if (lastSeen < oldestLastSeen) {
143
+ oldestLastSeen = lastSeen;
144
+ oldestSessionId = sessionId;
474
145
  }
475
- const memory = await client.getMemory(memoryId, { includePending: true });
476
- if (!memory) {
477
- throw new Error(`Persistio memory not found: ${memoryId}`);
146
+ }
147
+ if (!oldestSessionId) {
148
+ for (const [sessionId, store] of stores.entries()) {
149
+ const lastSeen = store.getLastSeen();
150
+ if (lastSeen < oldestLastSeen) {
151
+ oldestLastSeen = lastSeen;
152
+ oldestSessionId = sessionId;
153
+ }
478
154
  }
479
- const text = formatMemoryDocument(memory);
480
- const from = params.from ?? 1;
481
- const lines = text.split('\n');
482
- const startIndex = Math.max(0, from - 1);
483
- const requestedLines = params.lines && params.lines > 0 ? params.lines : 40;
484
- const sliced = lines.slice(startIndex, startIndex + requestedLines).join('\n');
485
- return {
486
- path: params.relPath,
487
- text: truncate(sliced, 2000),
488
- truncated: startIndex + requestedLines < lines.length || sliced.length > 2000,
489
- from,
490
- lines: requestedLines,
491
- };
492
- },
493
- status() {
494
- return {
495
- backend: 'builtin',
496
- provider: 'persistio',
497
- sources: ['memory'],
498
- vector: {
499
- enabled: true,
500
- },
501
- custom: {
502
- baseURL: config.baseURL,
503
- },
504
- };
505
- },
506
- async probeEmbeddingAvailability() {
507
- return probePersistio(client);
508
- },
509
- async probeVectorAvailability() {
510
- const probe = await probePersistio(client);
511
- return probe.ok;
512
- },
513
- };
155
+ }
156
+ if (!oldestSessionId)
157
+ break;
158
+ stores.delete(oldestSessionId);
159
+ }
160
+ }
161
+ function emptyRecallResult() {
162
+ return { memories: [], relatedMemories: [] };
514
163
  }
515
- function createMemoryRuntime(config, recallBreaker, logger) {
164
+ function serializeMemory(memory) {
516
165
  return {
517
- async getMemorySearchManager() {
518
- return {
519
- manager: createMemorySearchManager(config, recallBreaker, logger),
520
- };
521
- },
522
- resolveMemoryBackendConfig() {
523
- return { backend: 'builtin' };
524
- },
166
+ id: memory.id,
167
+ subject: memory.subject,
168
+ text: memory.data,
169
+ similarity: memory.similarity,
170
+ confidence: memory.confidence,
171
+ categories: memory.categories ?? [],
525
172
  };
526
173
  }
527
- function buildPersistioMemoryPromptSection({ availableTools }) {
528
- const hasMemorySearch = availableTools.has('memory_search');
529
- const hasMemoryGet = availableTools.has('memory_get');
530
- if (!hasMemorySearch && !hasMemoryGet)
174
+ function formatRecallToolResult(result) {
175
+ if (result.memories.length === 0 && result.relatedMemories.length === 0) {
176
+ return jsonResult({ count: 0, memories: [], related_memories: [], provider: 'persistio' });
177
+ }
178
+ return jsonResult({
179
+ count: result.memories.length,
180
+ related_count: result.relatedMemories.length,
181
+ provider: 'persistio',
182
+ memories: result.memories.map(serializeMemory),
183
+ related_memories: result.relatedMemories.map((memory) => ({
184
+ ...serializeMemory(memory),
185
+ edge_type: memory.edge_type ?? undefined,
186
+ })),
187
+ });
188
+ }
189
+ function formatUnavailableRecallResult(unavailable) {
190
+ return jsonResult({
191
+ count: 0,
192
+ related_count: 0,
193
+ memories: [],
194
+ related_memories: [],
195
+ provider: 'persistio',
196
+ unavailable: true,
197
+ warning: unavailable,
198
+ });
199
+ }
200
+ function buildPromptGuidance({ availableTools }) {
201
+ if (!availableTools.has('memory_recall'))
531
202
  return [];
532
- if (hasMemorySearch && hasMemoryGet) {
533
- return [
534
- '## Memory Recall',
535
- 'Persistio is the active memory provider. For prior work, decisions, dates, people, preferences, or todos, use memory_search first and memory_get only for a bounded exact read of a returned persistio://memory/<id> path.',
536
- '',
537
- ];
538
- }
539
203
  return [
540
- '## Memory Recall',
541
- 'Persistio is the active memory provider. Use the available memory tool for prior work, decisions, dates, people, preferences, or todos when memory context is needed.',
204
+ '## Persistio Memory',
205
+ 'Persistio provides durable behavioral memory. Use memory_recall when prior user preferences, decisions, project context, or past working style would materially improve the answer.',
206
+ 'Do not mention memory unless the user asks.',
542
207
  '',
543
208
  ];
544
209
  }
545
210
  export default definePluginEntry({
546
- id: 'openclaw-persistio',
547
- name: 'Persistio Memory',
548
- description: 'Persistent semantic memory for OpenClaw via Persistio',
211
+ id: 'openclaw-persistio-v2',
212
+ name: 'Persistio Memory v2',
213
+ description: 'OpenClaw-native long-term memory powered by Persistio',
549
214
  register(api) {
550
215
  const cfg = resolveConfig(api.pluginConfig);
551
216
  if (!cfg.baseURL || !cfg.apiKey) {
552
- api.logger?.warn?.('openclaw-persistio: baseURL and apiKey are required. Plugin disabled.');
217
+ api.logger?.warn?.('openclaw-persistio-v2: baseURL and apiKey are required. Plugin disabled.');
553
218
  return;
554
219
  }
555
- const client = createClient(cfg);
556
- const recallBreaker = new RecallCircuitBreaker();
557
- const sentMessageKeysBySession = new Map();
558
- const pendingMessageKeysBySession = new Map();
559
- api.registerMemoryCapability({
560
- promptBuilder: buildPersistioMemoryPromptSection,
561
- runtime: createMemoryRuntime(cfg, recallBreaker, api.logger),
220
+ const client = new PersistioClient(cfg);
221
+ const recallBreaker = new CircuitBreaker();
222
+ const capturedKeysBySession = new Map();
223
+ api.registerMemoryCapability?.({
224
+ promptBuilder: buildPromptGuidance,
562
225
  });
563
- // -------------------------------------------------------------------------
564
- // before_prompt_build — recall relevant memories and inject into context
565
- // Event: { prompt: string, messages: unknown[] }
566
- // Return: { appendSystemContext?: string }
567
- // -------------------------------------------------------------------------
568
- api.on('before_prompt_build', async (event) => {
569
- const query = buildRecallQuery(event);
570
- const block = await runGuardedRecall({
571
- operation: 'before_prompt_build recall',
572
- timeoutMs: cfg.recallTimeout,
573
- fallback: '',
574
- breaker: recallBreaker,
575
- logger: api.logger,
576
- run: async () => {
577
- const recall = await client.recallBundle(query, undefined, { includeRelated: cfg.includeRelatedMemories });
578
- return buildMemoryBlock(recall.bundle, cfg.tokenBudget, cfg.includeRelatedMemories ? recall.related_bundle : undefined);
579
- },
580
- });
581
- if (!block)
582
- return;
583
- return { appendSystemContext: block };
584
- }, { timeoutMs: cfg.recallTimeout + RECALL_GUARD_MARGIN_MS + 250 });
585
- // -------------------------------------------------------------------------
586
- // agent_end — ingest new turn messages (fire and forget)
587
- // Event: { runId?, messages: unknown[], success: boolean, error?, durationMs? }
588
- // Observation only — no return value.
589
- // -------------------------------------------------------------------------
590
- api.on('agent_end', async (event, context) => {
591
- try {
592
- const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
593
- if (sessionId.startsWith('announce:'))
594
- return;
595
- if (!shouldIngestSession(sessionId, cfg.ingest)) {
596
- api.logger?.debug?.(`openclaw-persistio: ingest skipped non-main session: ${sessionId}`);
597
- return;
598
- }
599
- const chunks = [];
600
- const chunkKeys = [];
601
- let agentCharsSent = 0;
602
- let originalChars = 0;
603
- let preparedChars = 0;
604
- let truncatedMessages = 0;
605
- let skippedMessages = 0;
606
- const omissions = [];
607
- const now = Date.now();
608
- const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
609
- const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
610
- for (const [index, msg] of event.messages.entries()) {
611
- const m = msg;
612
- const role = normalizeRole(m['role']);
613
- if (!role || !shouldSendRole(role, cfg))
614
- continue;
615
- const text = extractTextFromMessage(msg, ['user', 'assistant', 'tool']);
616
- if (!text || text.length === 0)
617
- continue;
618
- const key = buildMessageFingerprint({ sessionId, msg: m, role, text, index });
619
- if (sentKeys.has(key) || pendingKeys.has(key))
620
- continue;
621
- const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
622
- const prepared = prepareMessageForIngest({
623
- role,
624
- text,
625
- policy: cfg.ingest,
626
- remainingAgentChars: Math.max(0, cfg.ingest.agent.maxCharsPerTurn - agentCharsSent),
627
- remainingChunks: Math.max(0, cfg.ingest.maxChunksPerTurn - chunks.length),
628
- });
629
- originalChars += prepared.originalChars;
630
- preparedChars += prepared.preparedChars;
631
- omissions.push(...prepared.omissions);
632
- if (prepared.truncated)
633
- truncatedMessages += 1;
634
- if (prepared.chunks.length === 0) {
635
- skippedMessages += 1;
636
- continue;
637
- }
638
- chunkKeys.push(key);
639
- if (role === 'assistant') {
640
- agentCharsSent += prepared.preparedChars;
641
- }
642
- chunks.push(...prepared.chunks.map((content) => ({ role, content, timestamp: ts })));
643
- if (chunks.length >= cfg.ingest.maxChunksPerTurn)
644
- break;
645
- }
646
- if (chunks.length === 0)
647
- return;
648
- if (truncatedMessages > 0 || omissions.length > 0 || skippedMessages > 0) {
649
- api.logger?.info?.(`openclaw-persistio: ingest planned session=${sessionId} chunks=${chunks.length} `
650
- + `originalChars=${originalChars} preparedChars=${preparedChars} `
651
- + `truncatedMessages=${truncatedMessages} skippedMessages=${skippedMessages} `
652
- + `omissions=${summarizeOmissions(omissions)}`);
653
- }
654
- rememberKeys(pendingKeys, chunkKeys);
655
- client.ingest(sessionId, chunks)
656
- .then(() => {
657
- rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
658
- })
659
- .catch((err) => {
660
- if (isTimeoutLikeError(err)) {
661
- rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
662
- api.logger?.warn?.(`openclaw-persistio: ingest timeout after ${cfg.ingest.timeoutMs}ms; `
663
- + `outcome is ambiguous, suppressing retry for ${chunkKeys.length} messages in session=${sessionId}`);
664
- return;
665
- }
666
- api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
667
- })
668
- .finally(() => {
669
- forgetKeys(pendingKeys, chunkKeys);
670
- });
671
- }
672
- catch (err) {
673
- api.logger?.warn?.(`openclaw-persistio: agent_end error: ${String(err)}`);
674
- }
675
- });
676
- // -------------------------------------------------------------------------
677
- // Tools
678
- // Verified signature: api.registerTool({ name, description, parameters, execute }, opts?)
679
- // execute(_id: string, params: unknown): Promise<AgentToolResult>
680
- // AgentToolResult: { content: Array<{ type: "text", text: string }>, details: unknown }
681
- // -------------------------------------------------------------------------
682
- const memoryManager = createMemorySearchManager(cfg, recallBreaker, api.logger);
683
226
  api.registerTool({
684
- name: 'memory_search',
685
- label: 'Memory Search',
686
- description: 'Search Persistio semantic memory. Returns bounded structured results with persistio://memory/<id> paths for memory_get.',
227
+ name: 'memory_recall',
228
+ label: 'Memory Recall',
229
+ description: 'Recall relevant durable Persistio memories for user preferences, decisions, project context, and prior working style.',
687
230
  parameters: Type.Object({
688
231
  query: Type.String({ description: 'Search query' }),
689
- maxResults: Type.Optional(Type.Number({ description: 'Maximum results to return' })),
690
- minScore: Type.Optional(Type.Number({ description: 'Optional minimum score from 0 to 1' })),
691
- corpus: Type.Optional(Type.Union([
692
- Type.Literal('memory'),
693
- Type.Literal('wiki'),
694
- Type.Literal('all'),
695
- Type.Literal('sessions'),
696
- ], { description: 'Persistio supports memory corpus results' })),
232
+ limit: Type.Optional(Type.Number({ description: 'Maximum memories to return' })),
697
233
  }, { additionalProperties: false }),
698
- async execute(_id, params) {
234
+ async execute(_toolCallId, params) {
699
235
  const p = params;
700
236
  const query = typeof p.query === 'string' ? p.query.trim() : '';
701
- if (!query) {
702
- return jsonResult(buildMemorySearchUnavailableResult('memory_search requires a non-empty query'));
703
- }
704
- const maxResults = resolveMemorySearchLimit({
705
- maxResults: p.maxResults,
706
- fallback: cfg.recallTopK,
237
+ if (!query)
238
+ return jsonResult({ count: 0, memories: [], error: 'memory_recall requires a query' });
239
+ const limit = typeof p.limit === 'number' && Number.isFinite(p.limit)
240
+ ? Math.max(1, Math.min(8, Math.floor(p.limit)))
241
+ : cfg.recall.maxResults;
242
+ const memories = await guardedRecallOutcome({
243
+ operation: 'memory_recall',
244
+ breaker: recallBreaker,
245
+ logger: api.logger,
246
+ fallback: emptyRecallResult(),
247
+ run: () => client.recall(query, { maxResults: limit }),
707
248
  });
708
- const requestedCorpus = p.corpus ?? 'memory';
709
- const sources = requestedCorpus === 'sessions' || requestedCorpus === 'wiki'
710
- ? []
711
- : ['memory'];
712
- const startedAt = Date.now();
713
- try {
714
- const results = sources.length === 0
715
- ? []
716
- : await memoryManager.search(query, {
717
- maxResults,
718
- minScore: p.minScore,
719
- sources,
720
- });
721
- return jsonResult({
722
- results: results.map((result) => ({ ...result, corpus: 'memory' })),
723
- provider: 'persistio',
724
- model: undefined,
725
- fallback: false,
726
- citations: 'off',
727
- mode: 'persistio',
728
- debug: {
729
- backend: 'builtin',
730
- effectiveMode: 'persistio',
731
- requestedCorpus,
732
- searchMs: Math.max(0, Date.now() - startedAt),
733
- hits: results.length,
734
- },
735
- });
736
- }
737
- catch (err) {
738
- return jsonResult(buildMemorySearchUnavailableResult(String(err)));
739
- }
249
+ if (memories.unavailable)
250
+ return formatUnavailableRecallResult(memories.unavailable);
251
+ return formatRecallToolResult(memories.value);
740
252
  },
741
- });
253
+ }, { name: 'memory_recall' });
742
254
  api.registerTool({
743
- name: 'memory_get',
744
- label: 'Memory Get',
745
- description: 'Read a bounded exact Persistio memory document by persistio://memory/<id> path.',
255
+ name: 'memory_store',
256
+ label: 'Memory Store',
257
+ description: 'Store a deliberate durable fact, preference, decision, or project note in Persistio memory.',
746
258
  parameters: Type.Object({
747
- path: Type.String({ description: 'Memory path returned by memory_search' }),
748
- from: Type.Optional(Type.Number({ description: 'Starting line, 1-based' })),
749
- lines: Type.Optional(Type.Number({ description: 'Maximum number of lines to return' })),
750
- corpus: Type.Optional(Type.Union([
751
- Type.Literal('memory'),
752
- Type.Literal('wiki'),
753
- Type.Literal('all'),
754
- ])),
259
+ text: Type.String({ description: 'Durable information to remember' }),
260
+ subject: Type.String({ description: 'Entity, project, person, or topic this memory is about' }),
755
261
  }, { additionalProperties: false }),
756
- async execute(_id, params) {
262
+ async execute(_toolCallId, params) {
757
263
  const p = params;
758
- const path = typeof p.path === 'string' ? p.path : '';
759
- if (p.corpus === 'wiki') {
760
- return jsonResult({ path, text: '', disabled: true, error: 'Persistio does not provide a wiki corpus' });
761
- }
264
+ const text = typeof p.text === 'string' ? p.text.trim() : '';
265
+ const subject = typeof p.subject === 'string' ? p.subject.trim() : '';
266
+ if (!text || !subject)
267
+ return jsonResult({ stored: false, error: 'memory_store requires text and subject' });
762
268
  try {
763
- return jsonResult(await memoryManager.readFile({
764
- relPath: path,
765
- from: resolveOptionalPositiveInteger(p.from),
766
- lines: resolveOptionalPositiveInteger(p.lines),
767
- }));
269
+ const memory = await client.storeMemory(text, subject);
270
+ return textResult('Memory stored.', { stored: true, id: memory.id });
768
271
  }
769
272
  catch (err) {
770
- return jsonResult({ path, text: '', disabled: true, error: String(err) });
771
- }
772
- },
773
- });
774
- api.registerTool({
775
- name: 'persistio_memory_add',
776
- label: 'Add Persistio Memory',
777
- description: 'Manually store a fact in Persistio memory.',
778
- parameters: Type.Object({
779
- data: Type.String({ description: 'The fact to remember' }),
780
- subject: Type.String({ description: 'The entity or topic this fact is about' }),
781
- }),
782
- async execute(_id, params) {
783
- const p = params;
784
- try {
785
- await client.addMemory(p.data, p.subject);
786
- }
787
- catch (err) {
788
- if (isTimeoutLikeError(err)) {
789
- api.logger?.warn?.(`openclaw-persistio: persistio_memory_add timeout after ${cfg.ingest.timeoutMs}ms; outcome is ambiguous`);
790
- return {
791
- content: [{
792
- type: 'text',
793
- text: 'Memory store request timed out; it may still complete. Check persistio_memory_list before retrying.',
794
- }],
795
- details: { ambiguous: true },
796
- };
273
+ if (err instanceof PersistioTimeoutError) {
274
+ return textResult('Memory store timed out; Persistio may still have stored it. Do not retry automatically.', { stored: 'unknown', ambiguous: true, timeoutMs: cfg.capture.timeoutMs });
797
275
  }
798
276
  throw err;
799
277
  }
800
- return { content: [{ type: 'text', text: 'Memory stored.' }], details: null };
801
278
  },
802
- }, { optional: true });
279
+ }, { name: 'memory_store' });
803
280
  api.registerTool({
804
- name: 'persistio_memory_delete',
805
- label: 'Delete Persistio Memory',
806
- description: 'Delete a specific Persistio memory by its ID.',
281
+ name: 'memory_forget',
282
+ label: 'Memory Forget',
283
+ description: 'Forget a Persistio memory by id, or search candidates to forget by query.',
807
284
  parameters: Type.Object({
808
- id: Type.String({ description: 'The memory ID to delete' }),
809
- }),
810
- async execute(_id, params) {
285
+ id: Type.Optional(Type.String({ description: 'Persistio memory id to delete' })),
286
+ query: Type.Optional(Type.String({ description: 'Search query to find candidate memories' })),
287
+ }, { additionalProperties: false }),
288
+ async execute(_toolCallId, params) {
811
289
  const p = params;
812
- await client.deleteMemory(p.id);
813
- return { content: [{ type: 'text', text: 'Memory deleted.' }], details: null };
814
- },
815
- }, { optional: true });
816
- api.registerTool({
817
- name: 'persistio_memory_list',
818
- label: 'List Persistio Memories',
819
- description: 'List stored Persistio memories.',
820
- parameters: Type.Object({}),
821
- async execute(_id, _params) {
822
- const memories = await client.listMemories();
823
- const text = memories.length > 0
824
- ? memories.map(m => `[${m.id}] ${truncate(m.data.replace(/\s+/g, ' ').trim(), MAX_MEMORY_SNIPPET_CHARS)} (${m.subject})`).join('\n')
825
- : 'No memories stored.';
826
- return { content: [{ type: 'text', text }], details: null };
290
+ const id = typeof p.id === 'string' ? p.id.trim() : '';
291
+ if (id) {
292
+ await client.forgetMemory(id);
293
+ return textResult('Memory forgotten.', { forgotten: true, id });
294
+ }
295
+ const query = typeof p.query === 'string' ? p.query.trim() : '';
296
+ if (!query)
297
+ return jsonResult({ forgotten: false, error: 'memory_forget requires id or query' });
298
+ const memories = await guardedRecallOutcome({
299
+ operation: 'memory_forget candidates',
300
+ breaker: recallBreaker,
301
+ logger: api.logger,
302
+ fallback: emptyRecallResult(),
303
+ run: () => client.recall(query, { maxResults: 5 }),
304
+ });
305
+ if (memories.unavailable) {
306
+ return jsonResult({
307
+ forgotten: false,
308
+ unavailable: true,
309
+ warning: memories.unavailable,
310
+ candidates: [],
311
+ related_candidates: [],
312
+ });
313
+ }
314
+ return jsonResult({
315
+ forgotten: false,
316
+ candidates: memories.value.memories.map((memory) => ({
317
+ id: memory.id,
318
+ subject: memory.subject,
319
+ text: memory.data,
320
+ similarity: memory.similarity,
321
+ })),
322
+ related_candidates: memories.value.relatedMemories.map((memory) => ({
323
+ id: memory.id,
324
+ subject: memory.subject,
325
+ text: memory.data,
326
+ edge_type: memory.edge_type ?? undefined,
327
+ })),
328
+ });
827
329
  },
828
- }, { optional: true });
330
+ }, { name: 'memory_forget' });
331
+ api.on('before_prompt_build', async (event) => {
332
+ if (!cfg.autoRecall)
333
+ return;
334
+ const query = buildRecallQuery(event, cfg.recall.queryMaxChars);
335
+ if (!query)
336
+ return;
337
+ const block = await guardedRecall({
338
+ operation: 'autoRecall',
339
+ breaker: recallBreaker,
340
+ logger: api.logger,
341
+ fallback: '',
342
+ run: async () => {
343
+ const response = await client.recallBundle(query);
344
+ return buildMemoryBlock(response.bundle, cfg.recall.tokenBudget, response.related_bundle);
345
+ },
346
+ });
347
+ if (!block)
348
+ return;
349
+ return { prependContext: block };
350
+ }, { timeoutMs: cfg.recall.timeoutMs + RECALL_GUARD_MARGIN_MS });
351
+ api.on('agent_end', (event, context) => {
352
+ if (!cfg.autoCapture || event.success === false)
353
+ return;
354
+ const sessionId = context?.sessionKey ?? context?.sessionId ?? event.runId ?? 'unknown-session';
355
+ const now = Date.now();
356
+ pruneCaptureStores(capturedKeysBySession, now);
357
+ const store = capturedKeysBySession.get(sessionId) ?? new CaptureKeyStore();
358
+ capturedKeysBySession.set(sessionId, store);
359
+ const prepared = prepareCapture(event, cfg, {
360
+ shouldIncludeKey: (key) => !store.has(key, now),
361
+ });
362
+ if (prepared.chunks.length === 0)
363
+ return;
364
+ store.markPending(prepared.keys, now);
365
+ void client.ingest(sessionId, prepared.chunks)
366
+ .then(() => {
367
+ store.markCaptured(prepared.keys);
368
+ })
369
+ .catch((err) => {
370
+ if (err instanceof PersistioTimeoutError) {
371
+ api.logger?.warn?.(`openclaw-persistio-v2: autoCapture timed out after ${cfg.capture.timeoutMs}ms`);
372
+ store.markCaptured(prepared.keys);
373
+ return;
374
+ }
375
+ store.releasePending(prepared.keys);
376
+ api.logger?.warn?.(`openclaw-persistio-v2: autoCapture failed: ${String(err)}`);
377
+ });
378
+ });
379
+ api.logger?.info?.('openclaw-persistio-v2: registered');
829
380
  },
830
381
  });