@persistio/openclaw-plugin 0.1.5 → 0.1.7

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/client.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { PersistioIngestPolicy } from './ingest-policy.js';
2
+
1
3
  export interface PersistioConfig {
2
4
  baseURL: string;
3
5
  apiKey: string;
@@ -5,6 +7,7 @@ export interface PersistioConfig {
5
7
  recallTopK: number;
6
8
  recallMinSimilarity?: number;
7
9
  recallTimeout: number;
10
+ ingest: PersistioIngestPolicy;
8
11
  send: PersistioSendConfig;
9
12
  }
10
13
 
@@ -49,12 +52,21 @@ export interface RecallBundleResponse {
49
52
  related_bundle?: RecallBundle;
50
53
  }
51
54
 
55
+ export class PersistioTimeoutError extends Error {
56
+ constructor(operation: string, timeoutMs: number) {
57
+ super(`Persistio ${operation} timed out after ${timeoutMs}ms`);
58
+ this.name = 'TimeoutError';
59
+ }
60
+ }
61
+
52
62
  export class PersistioClient {
53
63
  private readonly baseURL: string;
54
64
  private readonly apiKey: string;
55
65
  private readonly recallTopK: number;
56
66
  private readonly recallMinSimilarity?: number;
57
67
  private readonly recallTimeout: number;
68
+ private readonly ingestTimeout: number;
69
+ private readonly writeTimeout: number;
58
70
 
59
71
  constructor(config: PersistioConfig) {
60
72
  this.baseURL = config.baseURL.replace(/\/$/, '');
@@ -62,6 +74,8 @@ export class PersistioClient {
62
74
  this.recallTopK = config.recallTopK;
63
75
  this.recallMinSimilarity = config.recallMinSimilarity;
64
76
  this.recallTimeout = config.recallTimeout;
77
+ this.ingestTimeout = config.ingest.timeoutMs;
78
+ this.writeTimeout = config.ingest.timeoutMs;
65
79
  }
66
80
 
67
81
  private headers(): Record<string, string> {
@@ -72,82 +86,150 @@ export class PersistioClient {
72
86
  }
73
87
 
74
88
  async recall(query: string): Promise<PersistioMemory[]> {
75
- const body: Record<string, unknown> = { query, top_k: this.recallTopK, include_pending: true };
76
- if (typeof this.recallMinSimilarity === 'number') {
77
- body.min_similarity = this.recallMinSimilarity;
78
- }
79
-
80
- const res = await fetch(`${this.baseURL}/v1/recall`, {
81
- method: 'POST',
82
- headers: this.headers(),
83
- body: JSON.stringify(body),
84
- signal: AbortSignal.timeout(this.recallTimeout),
89
+ return withRequestDeadline('recall', this.recallTimeout, async (signal) => {
90
+ const body: Record<string, unknown> = { query, top_k: this.recallTopK, include_pending: true };
91
+ if (typeof this.recallMinSimilarity === 'number') {
92
+ body.min_similarity = this.recallMinSimilarity;
93
+ }
94
+
95
+ const res = await fetch(`${this.baseURL}/v1/recall`, {
96
+ method: 'POST',
97
+ headers: this.headers(),
98
+ body: JSON.stringify(body),
99
+ signal,
100
+ });
101
+ if (!res.ok) throw new Error(`Persistio recall failed: ${res.status}`);
102
+ const data = await res.json() as { memories: PersistioMemory[] };
103
+ return data.memories ?? [];
85
104
  });
86
- if (!res.ok) throw new Error(`Persistio recall failed: ${res.status}`);
87
- const data = await res.json() as { memories: PersistioMemory[] };
88
- return data.memories ?? [];
89
105
  }
90
106
 
91
107
  async recallBundle(query: string, topK?: number): Promise<RecallBundleResponse> {
92
- const body: Record<string, unknown> = { query, top_k: topK ?? this.recallTopK, include_pending: true };
93
- if (typeof this.recallMinSimilarity === 'number') {
94
- body.min_similarity = this.recallMinSimilarity;
95
- }
96
-
97
- const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
98
- method: 'POST',
99
- headers: this.headers(),
100
- body: JSON.stringify(body),
101
- signal: AbortSignal.timeout(this.recallTimeout),
108
+ return withRequestDeadline('recallBundle', this.recallTimeout, async (signal) => {
109
+ const body: Record<string, unknown> = { query, top_k: topK ?? this.recallTopK, include_pending: true };
110
+ if (typeof this.recallMinSimilarity === 'number') {
111
+ body.min_similarity = this.recallMinSimilarity;
112
+ }
113
+
114
+ const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
115
+ method: 'POST',
116
+ headers: this.headers(),
117
+ body: JSON.stringify(body),
118
+ signal,
119
+ });
120
+ if (!res.ok) throw new Error(`Persistio recallBundle failed: ${res.status}`);
121
+ const data = await res.json() as RecallBundleResponse;
122
+ return data;
102
123
  });
103
- if (!res.ok) throw new Error(`Persistio recallBundle failed: ${res.status}`);
104
- const data = await res.json() as RecallBundleResponse;
105
- return data;
106
124
  }
107
125
 
108
126
  async ingest(sessionId: string, chunks: Array<{ role: string; content: string; timestamp: string }>): Promise<void> {
109
127
  if (chunks.length === 0) return;
110
- const res = await fetch(`${this.baseURL}/v1/ingest`, {
111
- method: 'POST',
112
- headers: this.headers(),
113
- body: JSON.stringify({ session_id: sessionId, chunks }),
128
+ await withRequestDeadline('ingest', this.ingestTimeout, async (signal) => {
129
+ const res = await fetch(`${this.baseURL}/v1/ingest`, {
130
+ method: 'POST',
131
+ headers: this.headers(),
132
+ body: JSON.stringify({ session_id: sessionId, chunks }),
133
+ signal,
134
+ });
135
+ if (!res.ok) throw new Error(await formatHttpError('ingest', res));
114
136
  });
115
- if (!res.ok) throw new Error(`Persistio ingest failed: ${res.status}`);
116
137
  }
117
138
 
118
139
  async addMemory(data: string, subject: string): Promise<void> {
119
- const res = await fetch(`${this.baseURL}/v1/memories`, {
120
- method: 'POST',
121
- headers: this.headers(),
122
- body: JSON.stringify({ data, subject }),
140
+ await withRequestDeadline('addMemory', this.writeTimeout, async (signal) => {
141
+ const res = await fetch(`${this.baseURL}/v1/memories`, {
142
+ method: 'POST',
143
+ headers: this.headers(),
144
+ body: JSON.stringify({ data, subject }),
145
+ signal,
146
+ });
147
+ if (!res.ok) throw new Error(`Persistio addMemory failed: ${res.status}`);
123
148
  });
124
- if (!res.ok) throw new Error(`Persistio addMemory failed: ${res.status}`);
125
149
  }
126
150
 
127
151
  async deleteMemory(id: string): Promise<void> {
128
- const res = await fetch(`${this.baseURL}/v1/memories/${id}`, {
129
- method: 'DELETE',
130
- headers: this.headers(),
152
+ await withRequestDeadline('deleteMemory', this.writeTimeout, async (signal) => {
153
+ const res = await fetch(`${this.baseURL}/v1/memories/${id}`, {
154
+ method: 'DELETE',
155
+ headers: this.headers(),
156
+ signal,
157
+ });
158
+ if (!res.ok) throw new Error(`Persistio deleteMemory failed: ${res.status}`);
131
159
  });
132
- if (!res.ok) throw new Error(`Persistio deleteMemory failed: ${res.status}`);
133
160
  }
134
161
 
135
162
  async getMemory(id: string, options: GetMemoryOptions = {}): Promise<PersistioMemory | null> {
136
- const query = options.includePending ? '?include_pending=true' : '';
137
- const res = await fetch(`${this.baseURL}/v1/memories/${id}${query}`, {
138
- headers: this.headers(),
163
+ return withRequestDeadline('getMemory', this.recallTimeout, async (signal) => {
164
+ const query = options.includePending ? '?include_pending=true' : '';
165
+ const res = await fetch(`${this.baseURL}/v1/memories/${id}${query}`, {
166
+ headers: this.headers(),
167
+ signal,
168
+ });
169
+ if (res.status === 404) return null;
170
+ if (!res.ok) throw new Error(`Persistio getMemory failed: ${res.status}`);
171
+ return await res.json() as PersistioMemory;
139
172
  });
140
- if (res.status === 404) return null;
141
- if (!res.ok) throw new Error(`Persistio getMemory failed: ${res.status}`);
142
- return await res.json() as PersistioMemory;
143
173
  }
144
174
 
145
175
  async listMemories(): Promise<PersistioMemory[]> {
146
- const res = await fetch(`${this.baseURL}/v1/memories`, {
147
- headers: this.headers(),
176
+ return withRequestDeadline('listMemories', this.recallTimeout, async (signal) => {
177
+ const res = await fetch(`${this.baseURL}/v1/memories`, {
178
+ headers: this.headers(),
179
+ signal,
180
+ });
181
+ if (!res.ok) throw new Error(`Persistio listMemories failed: ${res.status}`);
182
+ const data = await res.json() as { items: PersistioMemory[] };
183
+ return data.items ?? [];
148
184
  });
149
- if (!res.ok) throw new Error(`Persistio listMemories failed: ${res.status}`);
150
- const data = await res.json() as { items: PersistioMemory[] };
151
- return data.items ?? [];
152
185
  }
153
186
  }
187
+
188
+ async function withRequestDeadline<T>(
189
+ operation: string,
190
+ timeoutMs: number,
191
+ run: (signal: AbortSignal) => Promise<T>,
192
+ ): Promise<T> {
193
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
194
+ return run(new AbortController().signal);
195
+ }
196
+
197
+ const controller = new AbortController();
198
+ let timeout: ReturnType<typeof setTimeout> | undefined;
199
+
200
+ const deadline = new Promise<never>((_resolve, reject) => {
201
+ timeout = setTimeout(() => {
202
+ controller.abort();
203
+ reject(new PersistioTimeoutError(operation, timeoutMs));
204
+ }, timeoutMs);
205
+ });
206
+
207
+ try {
208
+ return await Promise.race([run(controller.signal), deadline]);
209
+ } catch (err) {
210
+ if (controller.signal.aborted && isAbortLikeError(err)) {
211
+ throw new PersistioTimeoutError(operation, timeoutMs);
212
+ }
213
+ throw err;
214
+ } finally {
215
+ if (timeout) clearTimeout(timeout);
216
+ }
217
+ }
218
+
219
+ function isAbortLikeError(err: unknown): boolean {
220
+ if (!(err instanceof Error)) return false;
221
+ return err.name === 'AbortError' || err.name === 'TimeoutError';
222
+ }
223
+
224
+ async function formatHttpError(operation: string, res: Response): Promise<string> {
225
+ let detail = '';
226
+ try {
227
+ detail = (await res.text()).trim().slice(0, 500);
228
+ } catch {
229
+ // Ignore response body read failures; the status is still actionable.
230
+ }
231
+
232
+ return detail
233
+ ? `Persistio ${operation} failed: ${res.status} ${detail}`
234
+ : `Persistio ${operation} failed: ${res.status}`;
235
+ }
package/src/index.ts CHANGED
@@ -7,8 +7,13 @@ import type {
7
7
  } from 'openclaw/plugin-sdk/memory-core-host-engine-storage';
8
8
  import { Type } from '@sinclair/typebox';
9
9
  import { PersistioClient, type PersistioConfig, type PersistioMemory, type RecallBundle } from './client.js';
10
-
11
- type OpenClawMessageRole = 'user' | 'assistant' | 'tool';
10
+ import {
11
+ prepareMessageForIngest,
12
+ resolveIngestPolicy,
13
+ shouldIngestSession,
14
+ type OpenClawMessageRole,
15
+ type OmissionSummary,
16
+ } from './ingest-policy.js';
12
17
 
13
18
  interface SessionMessageKeyStore {
14
19
  keys: Set<string>;
@@ -24,6 +29,41 @@ const DEFAULT_SEND_ROLES: PersistioConfig['send']['roles'] = {
24
29
  const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
25
30
  const MAX_TRACKED_SESSIONS = 250;
26
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
+
36
+ interface PluginLogger {
37
+ debug?: (message: string) => void;
38
+ warn?: (message: string) => void;
39
+ }
40
+
41
+ class RecallCircuitBreaker {
42
+ private consecutiveFailures = 0;
43
+ private openedUntil = 0;
44
+
45
+ canAttempt(now = Date.now()): boolean {
46
+ return now >= this.openedUntil;
47
+ }
48
+
49
+ remainingMs(now = Date.now()): number {
50
+ return Math.max(0, this.openedUntil - now);
51
+ }
52
+
53
+ recordSuccess(): void {
54
+ this.consecutiveFailures = 0;
55
+ this.openedUntil = 0;
56
+ }
57
+
58
+ 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;
62
+ return true;
63
+ }
64
+ return false;
65
+ }
66
+ }
27
67
 
28
68
  function resolveSendConfig(raw: Record<string, unknown>): PersistioConfig['send'] {
29
69
  const send = raw['send'];
@@ -49,15 +89,22 @@ function resolveRecallMinSimilarity(value: unknown): number | undefined {
49
89
  : undefined;
50
90
  }
51
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
+
52
98
  function resolveConfig(raw: unknown): PersistioConfig {
53
99
  const c = (raw ?? {}) as Record<string, unknown>;
54
100
  return {
55
101
  baseURL: typeof c['baseURL'] === 'string' ? c['baseURL'] : '',
56
102
  apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
57
- tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
58
- recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
103
+ tokenBudget: resolvePositiveInteger(c['tokenBudget'], 2000),
104
+ recallTopK: resolvePositiveInteger(c['recallTopK'], 10),
59
105
  recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
60
- recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
106
+ recallTimeout: resolvePositiveInteger(c['recallTimeout'], 5000),
107
+ ingest: resolveIngestPolicy(c['ingest']),
61
108
  send: resolveSendConfig(c),
62
109
  };
63
110
  }
@@ -133,29 +180,37 @@ function buildRecallQuery(event: { prompt?: string; messages?: unknown[] }): str
133
180
  return truncate(parts.join('\n'), 600);
134
181
  }
135
182
 
136
- function buildMemoryBlock(bundle: RecallBundle, budget: number, relatedBundle?: RecallBundle): string {
183
+ function toStringArray(value: unknown): string[] {
184
+ return Array.isArray(value)
185
+ ? value.filter((item): item is string => typeof item === 'string')
186
+ : [];
187
+ }
188
+
189
+ function buildMemoryBlock(bundle: RecallBundle | undefined, budget: number, relatedBundle?: RecallBundle): string {
190
+ if (!bundle || typeof bundle !== 'object') return '';
191
+
137
192
  const sections: Array<{ title: string; items: string[] }> = [
138
- { title: 'Behavioural rules', items: bundle.user_rules },
139
- { title: 'Preferences', items: bundle.user_preferences },
140
- { title: 'Task patterns', items: bundle.task_patterns },
141
- { title: 'Workflows', items: bundle.workflows },
142
- { title: 'Project', items: bundle.project },
143
- { title: 'Constraints', items: bundle.constraints },
144
- { title: 'Decisions', items: bundle.decisions },
145
- { title: 'System facts', items: bundle.system_facts },
146
- { title: 'Domain knowledge', items: bundle.domain_knowledge },
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) },
147
202
  ];
148
- if (relatedBundle) {
203
+ if (relatedBundle && typeof relatedBundle === 'object') {
149
204
  sections.push(
150
- { title: 'Related behavioural rules', items: relatedBundle.user_rules },
151
- { title: 'Related preferences', items: relatedBundle.user_preferences },
152
- { title: 'Related task patterns', items: relatedBundle.task_patterns },
153
- { title: 'Related workflows', items: relatedBundle.workflows },
154
- { title: 'Related project', items: relatedBundle.project },
155
- { title: 'Related constraints', items: relatedBundle.constraints },
156
- { title: 'Related decisions', items: relatedBundle.decisions },
157
- { title: 'Related system facts', items: relatedBundle.system_facts },
158
- { title: 'Related domain knowledge', items: relatedBundle.domain_knowledge },
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) },
159
214
  );
160
215
  }
161
216
 
@@ -303,6 +358,82 @@ function forgetKeys(target: Set<string>, keys: string[]): void {
303
358
  for (const key of keys) target.delete(key);
304
359
  }
305
360
 
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(',');
370
+ }
371
+
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');
379
+ }
380
+
381
+ async function runGuardedRecall<T>(args: {
382
+ operation: string;
383
+ timeoutMs: number;
384
+ fallback: T;
385
+ breaker: RecallCircuitBreaker;
386
+ logger?: PluginLogger;
387
+ run: () => Promise<T>;
388
+ }): Promise<T> {
389
+ 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;
396
+ }
397
+
398
+ try {
399
+ const result = await withPluginDeadline(args.operation, args.timeoutMs + RECALL_GUARD_MARGIN_MS, args.run);
400
+ args.breaker.recordSuccess();
401
+ return result;
402
+ } 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` : ''),
407
+ );
408
+ return args.fallback;
409
+ }
410
+ }
411
+
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();
419
+ }
420
+
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
+
306
437
  const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
307
438
 
308
439
  function createClient(config: PersistioConfig, recallTopK = config.recallTopK): PersistioClient {
@@ -353,7 +484,11 @@ async function probePersistio(client: PersistioClient): Promise<MemoryEmbeddingP
353
484
  }
354
485
  }
355
486
 
356
- function createMemorySearchManager(config: PersistioConfig): MemorySearchManager {
487
+ function createMemorySearchManager(
488
+ config: PersistioConfig,
489
+ recallBreaker: RecallCircuitBreaker,
490
+ logger?: PluginLogger,
491
+ ): MemorySearchManager {
357
492
  const client = createClient(config);
358
493
 
359
494
  return {
@@ -374,7 +509,14 @@ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager
374
509
 
375
510
  const recallTopK = typeof opts?.maxResults === 'number' ? opts.maxResults : config.recallTopK;
376
511
  const recallClient = createClient(config, recallTopK);
377
- const memories = await recallClient.recall(query);
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
+ });
378
520
 
379
521
  return memories
380
522
  .map((memory): MemorySearchResult => {
@@ -443,11 +585,11 @@ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager
443
585
  };
444
586
  }
445
587
 
446
- function createMemoryRuntime(config: PersistioConfig) {
588
+ function createMemoryRuntime(config: PersistioConfig, recallBreaker: RecallCircuitBreaker, logger?: PluginLogger) {
447
589
  return {
448
590
  async getMemorySearchManager() {
449
591
  return {
450
- manager: createMemorySearchManager(config),
592
+ manager: createMemorySearchManager(config, recallBreaker, logger),
451
593
  };
452
594
  },
453
595
 
@@ -471,10 +613,11 @@ export default definePluginEntry({
471
613
  }
472
614
 
473
615
  const client = createClient(cfg);
616
+ const recallBreaker = new RecallCircuitBreaker();
474
617
  const sentMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
475
618
  const pendingMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
476
619
  api.registerMemoryCapability({
477
- runtime: createMemoryRuntime(cfg),
620
+ runtime: createMemoryRuntime(cfg, recallBreaker, api.logger),
478
621
  });
479
622
 
480
623
  // -------------------------------------------------------------------------
@@ -483,16 +626,21 @@ export default definePluginEntry({
483
626
  // Return: { appendSystemContext?: string }
484
627
  // -------------------------------------------------------------------------
485
628
  api.on('before_prompt_build', async (event) => {
486
- try {
487
- const query = buildRecallQuery(event);
488
- const recall = await client.recallBundle(query);
489
- const block = buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
490
- if (!block) return;
491
- return { appendSystemContext: block };
492
- } catch (err) {
493
- api.logger?.warn?.(`openclaw-persistio: recall error: ${String(err)}`);
494
- }
495
- });
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 });
496
644
 
497
645
  // -------------------------------------------------------------------------
498
646
  // agent_end — ingest new turn messages (fire and forget)
@@ -503,8 +651,18 @@ export default definePluginEntry({
503
651
  try {
504
652
  const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
505
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
+ }
506
658
  const chunks: Array<{ role: string; content: string; timestamp: string }> = [];
507
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[] = [];
508
666
  const now = Date.now();
509
667
  const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
510
668
  const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
@@ -520,17 +678,55 @@ export default definePluginEntry({
520
678
  if (sentKeys.has(key) || pendingKeys.has(key)) continue;
521
679
 
522
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
+ }
697
+
523
698
  chunkKeys.push(key);
524
- chunks.push({ role, content: text, timestamp: ts });
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;
525
705
  }
526
706
 
527
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
+ }
528
716
  rememberKeys(pendingKeys, chunkKeys);
529
717
  client.ingest(sessionId, chunks)
530
718
  .then(() => {
531
719
  rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
532
720
  })
533
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
+ }
534
730
  api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
535
731
  })
536
732
  .finally(() => {
@@ -558,10 +754,17 @@ export default definePluginEntry({
558
754
  }),
559
755
  async execute(_id, params) {
560
756
  const p = params as { query: string; top_k?: number };
561
- const overrideTopK = typeof p.top_k === 'number' ? p.top_k : cfg.recallTopK;
757
+ const overrideTopK = resolvePositiveInteger(p.top_k, cfg.recallTopK);
562
758
  const overrideCfg = { ...cfg, recallTopK: overrideTopK };
563
- const c = new PersistioClient(overrideCfg);
564
- const memories = await c.recall(p.query);
759
+ const recallClient = createClient(overrideCfg);
760
+ const memories = await runGuardedRecall({
761
+ operation: 'memory_search tool recall',
762
+ timeoutMs: cfg.recallTimeout,
763
+ fallback: [],
764
+ breaker: recallBreaker,
765
+ logger: api.logger,
766
+ run: () => recallClient.recall(p.query),
767
+ });
565
768
  const text = memories.length > 0
566
769
  ? memories.map(m => `- ${m.data} [${m.subject}]`).join('\n')
567
770
  : 'No memories found.';
@@ -579,7 +782,23 @@ export default definePluginEntry({
579
782
  }),
580
783
  async execute(_id, params) {
581
784
  const p = params as { data: string; subject: string };
582
- await client.addMemory(p.data, p.subject);
785
+ try {
786
+ await client.addMemory(p.data, p.subject);
787
+ } catch (err) {
788
+ if (isTimeoutLikeError(err)) {
789
+ api.logger?.warn?.(
790
+ `openclaw-persistio: memory_add timeout after ${cfg.ingest.timeoutMs}ms; outcome is ambiguous`,
791
+ );
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
+ }
800
+ throw err;
801
+ }
583
802
  return { content: [{ type: 'text' as const, text: 'Memory stored.' }], details: null };
584
803
  },
585
804
  });