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