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