@persistio/openclaw-plugin 0.1.6 → 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/README.md CHANGED
@@ -12,7 +12,10 @@ Hooks into OpenClaw's `before_prompt_build` and `agent_end` events to automatica
12
12
  ## Installation
13
13
 
14
14
  ```bash
15
- npm install -g @persistio/openclaw-plugin
15
+ openclaw plugins install npm:@persistio/openclaw-plugin
16
+ openclaw plugins enable openclaw-persistio
17
+ openclaw gateway restart
18
+ openclaw plugins inspect openclaw-persistio --runtime --json
16
19
  ```
17
20
 
18
21
  Then register it in your OpenClaw config:
@@ -21,7 +24,8 @@ Then register it in your OpenClaw config:
21
24
  {
22
25
  "plugins": {
23
26
  "entries": {
24
- "persistio": {
27
+ "openclaw-persistio": {
28
+ "enabled": true,
25
29
  "package": "@persistio/openclaw-plugin",
26
30
  "config": {
27
31
  "baseURL": "https://api.persistio.ai",
@@ -64,6 +68,8 @@ Then register it in your OpenClaw config:
64
68
  | `send.roles.agent` | `"enabled"` or `"disabled"` | | `"enabled"` | Send agent/assistant messages to Persistio ingest |
65
69
  | `send.roles.tool` | `"enabled"` or `"disabled"` | | `"disabled"` | Send tool messages to Persistio ingest |
66
70
 
71
+ Recall is fail-open by design. If Persistio does not answer within `recallTimeout`, the plugin returns no memory for that turn instead of blocking the OpenClaw lane. After three consecutive recall/search failures it opens a 60 second circuit breaker and skips recall immediately during the cooldown. The plugin also registers a bounded `before_prompt_build` hook timeout; operators can still override this in OpenClaw with `plugins.entries.<id>.hooks.timeouts.before_prompt_build`.
72
+
67
73
  `agent_end` receives a snapshot of the active OpenClaw transcript, so the plugin deduplicates per session and only sends each user, agent, or enabled tool message once per plugin process. Deduplication keys are bounded in memory and expire after 24 hours of session inactivity.
68
74
 
69
75
  Assistant ingest is bounded before any network call. By default the plugin skips non-main `agent:*` sessions, collapses oversized code/log/diff/blob/table-shaped assistant content into omission markers, caps assistant ingest per message and per turn, then chunks all ingest content below `ingest.maxChunkChars`. Persistio still performs server-side extraction and curation; the plugin only enforces a deterministic transport-safe shape.
package/dist/client.d.ts CHANGED
@@ -44,6 +44,9 @@ export interface RecallBundleResponse {
44
44
  bundle: RecallBundle;
45
45
  related_bundle?: RecallBundle;
46
46
  }
47
+ export declare class PersistioTimeoutError extends Error {
48
+ constructor(operation: string, timeoutMs: number);
49
+ }
47
50
  export declare class PersistioClient {
48
51
  private readonly baseURL;
49
52
  private readonly apiKey;
@@ -51,6 +54,7 @@ export declare class PersistioClient {
51
54
  private readonly recallMinSimilarity?;
52
55
  private readonly recallTimeout;
53
56
  private readonly ingestTimeout;
57
+ private readonly writeTimeout;
54
58
  constructor(config: PersistioConfig);
55
59
  private headers;
56
60
  recall(query: string): Promise<PersistioMemory[]>;
package/dist/client.js CHANGED
@@ -1,3 +1,9 @@
1
+ export class PersistioTimeoutError extends Error {
2
+ constructor(operation, timeoutMs) {
3
+ super(`Persistio ${operation} timed out after ${timeoutMs}ms`);
4
+ this.name = 'TimeoutError';
5
+ }
6
+ }
1
7
  export class PersistioClient {
2
8
  baseURL;
3
9
  apiKey;
@@ -5,6 +11,7 @@ export class PersistioClient {
5
11
  recallMinSimilarity;
6
12
  recallTimeout;
7
13
  ingestTimeout;
14
+ writeTimeout;
8
15
  constructor(config) {
9
16
  this.baseURL = config.baseURL.replace(/\/$/, '');
10
17
  this.apiKey = config.apiKey;
@@ -12,6 +19,7 @@ export class PersistioClient {
12
19
  this.recallMinSimilarity = config.recallMinSimilarity;
13
20
  this.recallTimeout = config.recallTimeout;
14
21
  this.ingestTimeout = config.ingest.timeoutMs;
22
+ this.writeTimeout = config.ingest.timeoutMs;
15
23
  }
16
24
  headers() {
17
25
  return {
@@ -20,87 +28,136 @@ export class PersistioClient {
20
28
  };
21
29
  }
22
30
  async recall(query) {
23
- const body = { query, top_k: this.recallTopK, include_pending: true };
24
- if (typeof this.recallMinSimilarity === 'number') {
25
- body.min_similarity = this.recallMinSimilarity;
26
- }
27
- const res = await fetch(`${this.baseURL}/v1/recall`, {
28
- method: 'POST',
29
- headers: this.headers(),
30
- body: JSON.stringify(body),
31
- signal: AbortSignal.timeout(this.recallTimeout),
31
+ return withRequestDeadline('recall', this.recallTimeout, async (signal) => {
32
+ const body = { query, top_k: this.recallTopK, include_pending: true };
33
+ if (typeof this.recallMinSimilarity === 'number') {
34
+ body.min_similarity = this.recallMinSimilarity;
35
+ }
36
+ const res = await fetch(`${this.baseURL}/v1/recall`, {
37
+ method: 'POST',
38
+ headers: this.headers(),
39
+ body: JSON.stringify(body),
40
+ signal,
41
+ });
42
+ if (!res.ok)
43
+ throw new Error(`Persistio recall failed: ${res.status}`);
44
+ const data = await res.json();
45
+ return data.memories ?? [];
32
46
  });
33
- if (!res.ok)
34
- throw new Error(`Persistio recall failed: ${res.status}`);
35
- const data = await res.json();
36
- return data.memories ?? [];
37
47
  }
38
48
  async recallBundle(query, topK) {
39
- const body = { query, top_k: topK ?? this.recallTopK, include_pending: true };
40
- if (typeof this.recallMinSimilarity === 'number') {
41
- body.min_similarity = this.recallMinSimilarity;
42
- }
43
- const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
44
- method: 'POST',
45
- headers: this.headers(),
46
- body: JSON.stringify(body),
47
- signal: AbortSignal.timeout(this.recallTimeout),
49
+ return withRequestDeadline('recallBundle', this.recallTimeout, async (signal) => {
50
+ const body = { query, top_k: topK ?? this.recallTopK, include_pending: true };
51
+ if (typeof this.recallMinSimilarity === 'number') {
52
+ body.min_similarity = this.recallMinSimilarity;
53
+ }
54
+ const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
55
+ method: 'POST',
56
+ headers: this.headers(),
57
+ body: JSON.stringify(body),
58
+ signal,
59
+ });
60
+ if (!res.ok)
61
+ throw new Error(`Persistio recallBundle failed: ${res.status}`);
62
+ const data = await res.json();
63
+ return data;
48
64
  });
49
- if (!res.ok)
50
- throw new Error(`Persistio recallBundle failed: ${res.status}`);
51
- const data = await res.json();
52
- return data;
53
65
  }
54
66
  async ingest(sessionId, chunks) {
55
67
  if (chunks.length === 0)
56
68
  return;
57
- const res = await fetch(`${this.baseURL}/v1/ingest`, {
58
- method: 'POST',
59
- headers: this.headers(),
60
- body: JSON.stringify({ session_id: sessionId, chunks }),
61
- signal: AbortSignal.timeout(this.ingestTimeout),
69
+ await withRequestDeadline('ingest', this.ingestTimeout, async (signal) => {
70
+ const res = await fetch(`${this.baseURL}/v1/ingest`, {
71
+ method: 'POST',
72
+ headers: this.headers(),
73
+ body: JSON.stringify({ session_id: sessionId, chunks }),
74
+ signal,
75
+ });
76
+ if (!res.ok)
77
+ throw new Error(await formatHttpError('ingest', res));
62
78
  });
63
- if (!res.ok)
64
- throw new Error(await formatHttpError('ingest', res));
65
79
  }
66
80
  async addMemory(data, subject) {
67
- const res = await fetch(`${this.baseURL}/v1/memories`, {
68
- method: 'POST',
69
- headers: this.headers(),
70
- body: JSON.stringify({ data, subject }),
81
+ await withRequestDeadline('addMemory', this.writeTimeout, async (signal) => {
82
+ const res = await fetch(`${this.baseURL}/v1/memories`, {
83
+ method: 'POST',
84
+ headers: this.headers(),
85
+ body: JSON.stringify({ data, subject }),
86
+ signal,
87
+ });
88
+ if (!res.ok)
89
+ throw new Error(`Persistio addMemory failed: ${res.status}`);
71
90
  });
72
- if (!res.ok)
73
- throw new Error(`Persistio addMemory failed: ${res.status}`);
74
91
  }
75
92
  async deleteMemory(id) {
76
- const res = await fetch(`${this.baseURL}/v1/memories/${id}`, {
77
- method: 'DELETE',
78
- headers: this.headers(),
93
+ await withRequestDeadline('deleteMemory', this.writeTimeout, async (signal) => {
94
+ const res = await fetch(`${this.baseURL}/v1/memories/${id}`, {
95
+ method: 'DELETE',
96
+ headers: this.headers(),
97
+ signal,
98
+ });
99
+ if (!res.ok)
100
+ throw new Error(`Persistio deleteMemory failed: ${res.status}`);
79
101
  });
80
- if (!res.ok)
81
- throw new Error(`Persistio deleteMemory failed: ${res.status}`);
82
102
  }
83
103
  async getMemory(id, options = {}) {
84
- const query = options.includePending ? '?include_pending=true' : '';
85
- const res = await fetch(`${this.baseURL}/v1/memories/${id}${query}`, {
86
- headers: this.headers(),
104
+ return withRequestDeadline('getMemory', this.recallTimeout, async (signal) => {
105
+ const query = options.includePending ? '?include_pending=true' : '';
106
+ const res = await fetch(`${this.baseURL}/v1/memories/${id}${query}`, {
107
+ headers: this.headers(),
108
+ signal,
109
+ });
110
+ if (res.status === 404)
111
+ return null;
112
+ if (!res.ok)
113
+ throw new Error(`Persistio getMemory failed: ${res.status}`);
114
+ return await res.json();
87
115
  });
88
- if (res.status === 404)
89
- return null;
90
- if (!res.ok)
91
- throw new Error(`Persistio getMemory failed: ${res.status}`);
92
- return await res.json();
93
116
  }
94
117
  async listMemories() {
95
- const res = await fetch(`${this.baseURL}/v1/memories`, {
96
- headers: this.headers(),
118
+ return withRequestDeadline('listMemories', this.recallTimeout, async (signal) => {
119
+ const res = await fetch(`${this.baseURL}/v1/memories`, {
120
+ headers: this.headers(),
121
+ signal,
122
+ });
123
+ if (!res.ok)
124
+ throw new Error(`Persistio listMemories failed: ${res.status}`);
125
+ const data = await res.json();
126
+ return data.items ?? [];
97
127
  });
98
- if (!res.ok)
99
- throw new Error(`Persistio listMemories failed: ${res.status}`);
100
- const data = await res.json();
101
- return data.items ?? [];
102
128
  }
103
129
  }
130
+ async function withRequestDeadline(operation, timeoutMs, run) {
131
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
132
+ return run(new AbortController().signal);
133
+ }
134
+ const controller = new AbortController();
135
+ let timeout;
136
+ const deadline = new Promise((_resolve, reject) => {
137
+ timeout = setTimeout(() => {
138
+ controller.abort();
139
+ reject(new PersistioTimeoutError(operation, timeoutMs));
140
+ }, timeoutMs);
141
+ });
142
+ try {
143
+ return await Promise.race([run(controller.signal), deadline]);
144
+ }
145
+ catch (err) {
146
+ if (controller.signal.aborted && isAbortLikeError(err)) {
147
+ throw new PersistioTimeoutError(operation, timeoutMs);
148
+ }
149
+ throw err;
150
+ }
151
+ finally {
152
+ if (timeout)
153
+ clearTimeout(timeout);
154
+ }
155
+ }
156
+ function isAbortLikeError(err) {
157
+ if (!(err instanceof Error))
158
+ return false;
159
+ return err.name === 'AbortError' || err.name === 'TimeoutError';
160
+ }
104
161
  async function formatHttpError(operation, res) {
105
162
  let detail = '';
106
163
  try {
package/dist/index.js CHANGED
@@ -10,6 +10,31 @@ const DEFAULT_SEND_ROLES = {
10
10
  const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
11
11
  const MAX_TRACKED_SESSIONS = 250;
12
12
  const MAX_SENT_KEYS_PER_SESSION = 2000;
13
+ const RECALL_CIRCUIT_BREAKER_FAILURE_THRESHOLD = 3;
14
+ const RECALL_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000;
15
+ const RECALL_GUARD_MARGIN_MS = 250;
16
+ class RecallCircuitBreaker {
17
+ consecutiveFailures = 0;
18
+ openedUntil = 0;
19
+ canAttempt(now = Date.now()) {
20
+ return now >= this.openedUntil;
21
+ }
22
+ remainingMs(now = Date.now()) {
23
+ return Math.max(0, this.openedUntil - now);
24
+ }
25
+ recordSuccess() {
26
+ this.consecutiveFailures = 0;
27
+ this.openedUntil = 0;
28
+ }
29
+ recordFailure(now = Date.now()) {
30
+ this.consecutiveFailures += 1;
31
+ if (this.consecutiveFailures >= RECALL_CIRCUIT_BREAKER_FAILURE_THRESHOLD) {
32
+ this.openedUntil = now + RECALL_CIRCUIT_BREAKER_COOLDOWN_MS;
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+ }
13
38
  function resolveSendConfig(raw) {
14
39
  const send = raw['send'];
15
40
  const roles = typeof send === 'object' && send !== null
@@ -31,15 +56,20 @@ function resolveRecallMinSimilarity(value) {
31
56
  ? value
32
57
  : undefined;
33
58
  }
59
+ function resolvePositiveInteger(value, fallback) {
60
+ return typeof value === 'number' && Number.isFinite(value) && value >= 1
61
+ ? Math.floor(value)
62
+ : fallback;
63
+ }
34
64
  function resolveConfig(raw) {
35
65
  const c = (raw ?? {});
36
66
  return {
37
67
  baseURL: typeof c['baseURL'] === 'string' ? c['baseURL'] : '',
38
68
  apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
39
- tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
40
- recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
69
+ tokenBudget: resolvePositiveInteger(c['tokenBudget'], 2000),
70
+ recallTopK: resolvePositiveInteger(c['recallTopK'], 10),
41
71
  recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
42
- recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
72
+ recallTimeout: resolvePositiveInteger(c['recallTimeout'], 5000),
43
73
  ingest: resolveIngestPolicy(c['ingest']),
44
74
  send: resolveSendConfig(c),
45
75
  };
@@ -110,20 +140,27 @@ function buildRecallQuery(event) {
110
140
  parts.push(`[task: ${taskType}]`);
111
141
  return truncate(parts.join('\n'), 600);
112
142
  }
143
+ function toStringArray(value) {
144
+ return Array.isArray(value)
145
+ ? value.filter((item) => typeof item === 'string')
146
+ : [];
147
+ }
113
148
  function buildMemoryBlock(bundle, budget, relatedBundle) {
149
+ if (!bundle || typeof bundle !== 'object')
150
+ return '';
114
151
  const sections = [
115
- { title: 'Behavioural rules', items: bundle.user_rules },
116
- { title: 'Preferences', items: bundle.user_preferences },
117
- { title: 'Task patterns', items: bundle.task_patterns },
118
- { title: 'Workflows', items: bundle.workflows },
119
- { title: 'Project', items: bundle.project },
120
- { title: 'Constraints', items: bundle.constraints },
121
- { title: 'Decisions', items: bundle.decisions },
122
- { title: 'System facts', items: bundle.system_facts },
123
- { title: 'Domain knowledge', items: bundle.domain_knowledge },
152
+ { title: 'Behavioural rules', items: toStringArray(bundle.user_rules) },
153
+ { title: 'Preferences', items: toStringArray(bundle.user_preferences) },
154
+ { title: 'Task patterns', items: toStringArray(bundle.task_patterns) },
155
+ { title: 'Workflows', items: toStringArray(bundle.workflows) },
156
+ { title: 'Project', items: toStringArray(bundle.project) },
157
+ { title: 'Constraints', items: toStringArray(bundle.constraints) },
158
+ { title: 'Decisions', items: toStringArray(bundle.decisions) },
159
+ { title: 'System facts', items: toStringArray(bundle.system_facts) },
160
+ { title: 'Domain knowledge', items: toStringArray(bundle.domain_knowledge) },
124
161
  ];
125
- if (relatedBundle) {
126
- sections.push({ title: 'Related behavioural rules', items: relatedBundle.user_rules }, { title: 'Related preferences', items: relatedBundle.user_preferences }, { title: 'Related task patterns', items: relatedBundle.task_patterns }, { title: 'Related workflows', items: relatedBundle.workflows }, { title: 'Related project', items: relatedBundle.project }, { title: 'Related constraints', items: relatedBundle.constraints }, { title: 'Related decisions', items: relatedBundle.decisions }, { title: 'Related system facts', items: relatedBundle.system_facts }, { title: 'Related domain knowledge', items: relatedBundle.domain_knowledge });
162
+ if (relatedBundle && typeof relatedBundle === 'object') {
163
+ sections.push({ title: 'Related behavioural rules', items: toStringArray(relatedBundle.user_rules) }, { title: 'Related preferences', items: toStringArray(relatedBundle.user_preferences) }, { title: 'Related task patterns', items: toStringArray(relatedBundle.task_patterns) }, { title: 'Related workflows', items: toStringArray(relatedBundle.workflows) }, { title: 'Related project', items: toStringArray(relatedBundle.project) }, { title: 'Related constraints', items: toStringArray(relatedBundle.constraints) }, { title: 'Related decisions', items: toStringArray(relatedBundle.decisions) }, { title: 'Related system facts', items: toStringArray(relatedBundle.system_facts) }, { title: 'Related domain knowledge', items: toStringArray(relatedBundle.domain_knowledge) });
127
164
  }
128
165
  const intro = 'Use the following as prior context and preferences. If they conflict with current instructions, follow the current instructions.';
129
166
  const lines = [intro];
@@ -276,6 +313,45 @@ function isTimeoutLikeError(err) {
276
313
  const message = typeof record['message'] === 'string' ? record['message'].toLowerCase() : '';
277
314
  return message.includes('timeout') || message.includes('aborted');
278
315
  }
316
+ async function runGuardedRecall(args) {
317
+ const now = Date.now();
318
+ if (!args.breaker.canAttempt(now)) {
319
+ args.logger?.warn?.(`openclaw-persistio: ${args.operation} skipped; recall circuit breaker open `
320
+ + `for ${args.breaker.remainingMs(now)}ms`);
321
+ return args.fallback;
322
+ }
323
+ try {
324
+ const result = await withPluginDeadline(args.operation, args.timeoutMs + RECALL_GUARD_MARGIN_MS, args.run);
325
+ args.breaker.recordSuccess();
326
+ return result;
327
+ }
328
+ catch (err) {
329
+ const opened = args.breaker.recordFailure();
330
+ args.logger?.warn?.(`openclaw-persistio: ${args.operation} failed open: ${String(err)}`
331
+ + (opened ? `; recall circuit breaker open for ${RECALL_CIRCUIT_BREAKER_COOLDOWN_MS}ms` : ''));
332
+ return args.fallback;
333
+ }
334
+ }
335
+ async function withPluginDeadline(operation, timeoutMs, run) {
336
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
337
+ return run();
338
+ }
339
+ let timeout;
340
+ const deadline = new Promise((_resolve, reject) => {
341
+ timeout = setTimeout(() => {
342
+ const err = new Error(`Persistio ${operation} exceeded plugin deadline after ${timeoutMs}ms`);
343
+ err.name = 'TimeoutError';
344
+ reject(err);
345
+ }, timeoutMs);
346
+ });
347
+ try {
348
+ return await Promise.race([run(), deadline]);
349
+ }
350
+ finally {
351
+ if (timeout)
352
+ clearTimeout(timeout);
353
+ }
354
+ }
279
355
  const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
280
356
  function createClient(config, recallTopK = config.recallTopK) {
281
357
  return new PersistioClient({ ...config, recallTopK });
@@ -318,7 +394,7 @@ async function probePersistio(client) {
318
394
  return { ok: false, error: String(err) };
319
395
  }
320
396
  }
321
- function createMemorySearchManager(config) {
397
+ function createMemorySearchManager(config, recallBreaker, logger) {
322
398
  const client = createClient(config);
323
399
  return {
324
400
  async search(query, opts) {
@@ -327,7 +403,14 @@ function createMemorySearchManager(config) {
327
403
  }
328
404
  const recallTopK = typeof opts?.maxResults === 'number' ? opts.maxResults : config.recallTopK;
329
405
  const recallClient = createClient(config, recallTopK);
330
- const memories = await recallClient.recall(query);
406
+ const memories = await runGuardedRecall({
407
+ operation: 'memory search recall',
408
+ timeoutMs: config.recallTimeout,
409
+ fallback: [],
410
+ breaker: recallBreaker,
411
+ logger,
412
+ run: () => recallClient.recall(query),
413
+ });
331
414
  return memories
332
415
  .map((memory) => {
333
416
  const score = normalizeMemoryScore(memory);
@@ -384,11 +467,11 @@ function createMemorySearchManager(config) {
384
467
  },
385
468
  };
386
469
  }
387
- function createMemoryRuntime(config) {
470
+ function createMemoryRuntime(config, recallBreaker, logger) {
388
471
  return {
389
472
  async getMemorySearchManager() {
390
473
  return {
391
- manager: createMemorySearchManager(config),
474
+ manager: createMemorySearchManager(config, recallBreaker, logger),
392
475
  };
393
476
  },
394
477
  resolveMemoryBackendConfig() {
@@ -407,10 +490,11 @@ export default definePluginEntry({
407
490
  return;
408
491
  }
409
492
  const client = createClient(cfg);
493
+ const recallBreaker = new RecallCircuitBreaker();
410
494
  const sentMessageKeysBySession = new Map();
411
495
  const pendingMessageKeysBySession = new Map();
412
496
  api.registerMemoryCapability({
413
- runtime: createMemoryRuntime(cfg),
497
+ runtime: createMemoryRuntime(cfg, recallBreaker, api.logger),
414
498
  });
415
499
  // -------------------------------------------------------------------------
416
500
  // before_prompt_build — recall relevant memories and inject into context
@@ -418,18 +502,22 @@ export default definePluginEntry({
418
502
  // Return: { appendSystemContext?: string }
419
503
  // -------------------------------------------------------------------------
420
504
  api.on('before_prompt_build', async (event) => {
421
- try {
422
- const query = buildRecallQuery(event);
423
- const recall = await client.recallBundle(query);
424
- const block = buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
425
- if (!block)
426
- return;
427
- return { appendSystemContext: block };
428
- }
429
- catch (err) {
430
- api.logger?.warn?.(`openclaw-persistio: recall error: ${String(err)}`);
431
- }
432
- });
505
+ const query = buildRecallQuery(event);
506
+ const block = await runGuardedRecall({
507
+ operation: 'before_prompt_build recall',
508
+ timeoutMs: cfg.recallTimeout,
509
+ fallback: '',
510
+ breaker: recallBreaker,
511
+ logger: api.logger,
512
+ run: async () => {
513
+ const recall = await client.recallBundle(query);
514
+ return buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
515
+ },
516
+ });
517
+ if (!block)
518
+ return;
519
+ return { appendSystemContext: block };
520
+ }, { timeoutMs: cfg.recallTimeout + RECALL_GUARD_MARGIN_MS + 250 });
433
521
  // -------------------------------------------------------------------------
434
522
  // agent_end — ingest new turn messages (fire and forget)
435
523
  // Event: { runId?, messages: unknown[], success: boolean, error?, durationMs? }
@@ -537,10 +625,17 @@ export default definePluginEntry({
537
625
  }),
538
626
  async execute(_id, params) {
539
627
  const p = params;
540
- const overrideTopK = typeof p.top_k === 'number' ? p.top_k : cfg.recallTopK;
628
+ const overrideTopK = resolvePositiveInteger(p.top_k, cfg.recallTopK);
541
629
  const overrideCfg = { ...cfg, recallTopK: overrideTopK };
542
- const c = new PersistioClient(overrideCfg);
543
- const memories = await c.recall(p.query);
630
+ const recallClient = createClient(overrideCfg);
631
+ const memories = await runGuardedRecall({
632
+ operation: 'memory_search tool recall',
633
+ timeoutMs: cfg.recallTimeout,
634
+ fallback: [],
635
+ breaker: recallBreaker,
636
+ logger: api.logger,
637
+ run: () => recallClient.recall(p.query),
638
+ });
544
639
  const text = memories.length > 0
545
640
  ? memories.map(m => `- ${m.data} [${m.subject}]`).join('\n')
546
641
  : 'No memories found.';
@@ -557,7 +652,22 @@ export default definePluginEntry({
557
652
  }),
558
653
  async execute(_id, params) {
559
654
  const p = params;
560
- await client.addMemory(p.data, p.subject);
655
+ try {
656
+ await client.addMemory(p.data, p.subject);
657
+ }
658
+ catch (err) {
659
+ if (isTimeoutLikeError(err)) {
660
+ api.logger?.warn?.(`openclaw-persistio: memory_add timeout after ${cfg.ingest.timeoutMs}ms; outcome is ambiguous`);
661
+ return {
662
+ content: [{
663
+ type: 'text',
664
+ text: 'Memory store request timed out; it may still complete. Check memory_list before retrying.',
665
+ }],
666
+ details: { ambiguous: true },
667
+ };
668
+ }
669
+ throw err;
670
+ }
561
671
  return { content: [{ type: 'text', text: 'Memory stored.' }], details: null };
562
672
  },
563
673
  });
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-persistio",
3
3
  "name": "Persistio Memory",
4
4
  "description": "Persistent semantic memory for OpenClaw via Persistio",
5
- "version": "0.1.6",
5
+ "version": "0.1.7",
6
6
  "kind": "memory",
7
7
  "activation": {
8
8
  "onStartup": true
@@ -15,6 +15,14 @@
15
15
  "memory_list"
16
16
  ]
17
17
  },
18
+ "toolMetadata": {
19
+ "memory_delete": {
20
+ "optional": true
21
+ },
22
+ "memory_list": {
23
+ "optional": true
24
+ }
25
+ },
18
26
  "configSchema": {
19
27
  "type": "object",
20
28
  "additionalProperties": false,
@@ -26,10 +34,12 @@
26
34
  "type": "string"
27
35
  },
28
36
  "tokenBudget": {
29
- "type": "number"
37
+ "type": "number",
38
+ "minimum": 1
30
39
  },
31
40
  "recallTopK": {
32
- "type": "number"
41
+ "type": "number",
42
+ "minimum": 1
33
43
  },
34
44
  "recallMinSimilarity": {
35
45
  "type": "number",
@@ -37,20 +47,24 @@
37
47
  "maximum": 1
38
48
  },
39
49
  "recallTimeout": {
40
- "type": "number"
50
+ "type": "number",
51
+ "minimum": 1
41
52
  },
42
53
  "ingest": {
43
54
  "type": "object",
44
55
  "additionalProperties": false,
45
56
  "properties": {
46
57
  "timeoutMs": {
47
- "type": "number"
58
+ "type": "number",
59
+ "minimum": 1
48
60
  },
49
61
  "maxChunkChars": {
50
- "type": "number"
62
+ "type": "number",
63
+ "minimum": 256
51
64
  },
52
65
  "maxChunksPerTurn": {
53
- "type": "number"
66
+ "type": "number",
67
+ "minimum": 1
54
68
  },
55
69
  "skipSubagentSessions": {
56
70
  "type": "boolean"
@@ -60,7 +74,8 @@
60
74
  "additionalProperties": false,
61
75
  "properties": {
62
76
  "maxCharsPerMessage": {
63
- "type": "number"
77
+ "type": "number",
78
+ "minimum": 1
64
79
  }
65
80
  }
66
81
  },
@@ -76,22 +91,28 @@
76
91
  ]
77
92
  },
78
93
  "maxCharsPerMessage": {
79
- "type": "number"
94
+ "type": "number",
95
+ "minimum": 1
80
96
  },
81
97
  "maxCharsAfterFiltering": {
82
- "type": "number"
98
+ "type": "number",
99
+ "minimum": 1
83
100
  },
84
101
  "maxCharsPerTurn": {
85
- "type": "number"
102
+ "type": "number",
103
+ "minimum": 1
86
104
  },
87
105
  "largeBlockThresholdChars": {
88
- "type": "number"
106
+ "type": "number",
107
+ "minimum": 1
89
108
  },
90
109
  "largeBlockThresholdLines": {
91
- "type": "number"
110
+ "type": "number",
111
+ "minimum": 1
92
112
  },
93
113
  "maxTableRows": {
94
- "type": "number"
114
+ "type": "number",
115
+ "minimum": 1
95
116
  }
96
117
  }
97
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@persistio/openclaw-plugin",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "OpenClaw plugin for Persistio \u2014 persistent semantic memory for AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "scripts": {
44
44
  "build": "tsc",
45
- "test": "npm run build && node test/config-schema.test.mjs && node test/ingest-policy.test.mjs"
45
+ "test": "npm run build && node test/config-schema.test.mjs && node test/ingest-policy.test.mjs && node test/client-timeout.test.mjs"
46
46
  },
47
47
  "dependencies": {
48
48
  "@sinclair/typebox": "^0.34.0"
package/src/client.ts CHANGED
@@ -52,6 +52,13 @@ export interface RecallBundleResponse {
52
52
  related_bundle?: RecallBundle;
53
53
  }
54
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
+
55
62
  export class PersistioClient {
56
63
  private readonly baseURL: string;
57
64
  private readonly apiKey: string;
@@ -59,6 +66,7 @@ export class PersistioClient {
59
66
  private readonly recallMinSimilarity?: number;
60
67
  private readonly recallTimeout: number;
61
68
  private readonly ingestTimeout: number;
69
+ private readonly writeTimeout: number;
62
70
 
63
71
  constructor(config: PersistioConfig) {
64
72
  this.baseURL = config.baseURL.replace(/\/$/, '');
@@ -67,6 +75,7 @@ export class PersistioClient {
67
75
  this.recallMinSimilarity = config.recallMinSimilarity;
68
76
  this.recallTimeout = config.recallTimeout;
69
77
  this.ingestTimeout = config.ingest.timeoutMs;
78
+ this.writeTimeout = config.ingest.timeoutMs;
70
79
  }
71
80
 
72
81
  private headers(): Record<string, string> {
@@ -77,87 +86,141 @@ export class PersistioClient {
77
86
  }
78
87
 
79
88
  async recall(query: string): Promise<PersistioMemory[]> {
80
- const body: Record<string, unknown> = { query, top_k: this.recallTopK, include_pending: true };
81
- if (typeof this.recallMinSimilarity === 'number') {
82
- body.min_similarity = this.recallMinSimilarity;
83
- }
84
-
85
- const res = await fetch(`${this.baseURL}/v1/recall`, {
86
- method: 'POST',
87
- headers: this.headers(),
88
- body: JSON.stringify(body),
89
- 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 ?? [];
90
104
  });
91
- if (!res.ok) throw new Error(`Persistio recall failed: ${res.status}`);
92
- const data = await res.json() as { memories: PersistioMemory[] };
93
- return data.memories ?? [];
94
105
  }
95
106
 
96
107
  async recallBundle(query: string, topK?: number): Promise<RecallBundleResponse> {
97
- const body: Record<string, unknown> = { query, top_k: topK ?? this.recallTopK, include_pending: true };
98
- if (typeof this.recallMinSimilarity === 'number') {
99
- body.min_similarity = this.recallMinSimilarity;
100
- }
101
-
102
- const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
103
- method: 'POST',
104
- headers: this.headers(),
105
- body: JSON.stringify(body),
106
- 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;
107
123
  });
108
- if (!res.ok) throw new Error(`Persistio recallBundle failed: ${res.status}`);
109
- const data = await res.json() as RecallBundleResponse;
110
- return data;
111
124
  }
112
125
 
113
126
  async ingest(sessionId: string, chunks: Array<{ role: string; content: string; timestamp: string }>): Promise<void> {
114
127
  if (chunks.length === 0) return;
115
- const res = await fetch(`${this.baseURL}/v1/ingest`, {
116
- method: 'POST',
117
- headers: this.headers(),
118
- body: JSON.stringify({ session_id: sessionId, chunks }),
119
- signal: AbortSignal.timeout(this.ingestTimeout),
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));
120
136
  });
121
- if (!res.ok) throw new Error(await formatHttpError('ingest', res));
122
137
  }
123
138
 
124
139
  async addMemory(data: string, subject: string): Promise<void> {
125
- const res = await fetch(`${this.baseURL}/v1/memories`, {
126
- method: 'POST',
127
- headers: this.headers(),
128
- 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}`);
129
148
  });
130
- if (!res.ok) throw new Error(`Persistio addMemory failed: ${res.status}`);
131
149
  }
132
150
 
133
151
  async deleteMemory(id: string): Promise<void> {
134
- const res = await fetch(`${this.baseURL}/v1/memories/${id}`, {
135
- method: 'DELETE',
136
- 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}`);
137
159
  });
138
- if (!res.ok) throw new Error(`Persistio deleteMemory failed: ${res.status}`);
139
160
  }
140
161
 
141
162
  async getMemory(id: string, options: GetMemoryOptions = {}): Promise<PersistioMemory | null> {
142
- const query = options.includePending ? '?include_pending=true' : '';
143
- const res = await fetch(`${this.baseURL}/v1/memories/${id}${query}`, {
144
- 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;
145
172
  });
146
- if (res.status === 404) return null;
147
- if (!res.ok) throw new Error(`Persistio getMemory failed: ${res.status}`);
148
- return await res.json() as PersistioMemory;
149
173
  }
150
174
 
151
175
  async listMemories(): Promise<PersistioMemory[]> {
152
- const res = await fetch(`${this.baseURL}/v1/memories`, {
153
- 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 ?? [];
154
184
  });
155
- if (!res.ok) throw new Error(`Persistio listMemories failed: ${res.status}`);
156
- const data = await res.json() as { items: PersistioMemory[] };
157
- return data.items ?? [];
158
185
  }
159
186
  }
160
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
+
161
224
  async function formatHttpError(operation: string, res: Response): Promise<string> {
162
225
  let detail = '';
163
226
  try {
package/src/index.ts CHANGED
@@ -29,6 +29,41 @@ const DEFAULT_SEND_ROLES: PersistioConfig['send']['roles'] = {
29
29
  const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
30
30
  const MAX_TRACKED_SESSIONS = 250;
31
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
+ }
32
67
 
33
68
  function resolveSendConfig(raw: Record<string, unknown>): PersistioConfig['send'] {
34
69
  const send = raw['send'];
@@ -54,15 +89,21 @@ function resolveRecallMinSimilarity(value: unknown): number | undefined {
54
89
  : undefined;
55
90
  }
56
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
+
57
98
  function resolveConfig(raw: unknown): PersistioConfig {
58
99
  const c = (raw ?? {}) as Record<string, unknown>;
59
100
  return {
60
101
  baseURL: typeof c['baseURL'] === 'string' ? c['baseURL'] : '',
61
102
  apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
62
- tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
63
- recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
103
+ tokenBudget: resolvePositiveInteger(c['tokenBudget'], 2000),
104
+ recallTopK: resolvePositiveInteger(c['recallTopK'], 10),
64
105
  recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
65
- recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
106
+ recallTimeout: resolvePositiveInteger(c['recallTimeout'], 5000),
66
107
  ingest: resolveIngestPolicy(c['ingest']),
67
108
  send: resolveSendConfig(c),
68
109
  };
@@ -139,29 +180,37 @@ function buildRecallQuery(event: { prompt?: string; messages?: unknown[] }): str
139
180
  return truncate(parts.join('\n'), 600);
140
181
  }
141
182
 
142
- 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
+
143
192
  const sections: Array<{ title: string; items: string[] }> = [
144
- { title: 'Behavioural rules', items: bundle.user_rules },
145
- { title: 'Preferences', items: bundle.user_preferences },
146
- { title: 'Task patterns', items: bundle.task_patterns },
147
- { title: 'Workflows', items: bundle.workflows },
148
- { title: 'Project', items: bundle.project },
149
- { title: 'Constraints', items: bundle.constraints },
150
- { title: 'Decisions', items: bundle.decisions },
151
- { title: 'System facts', items: bundle.system_facts },
152
- { 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) },
153
202
  ];
154
- if (relatedBundle) {
203
+ if (relatedBundle && typeof relatedBundle === 'object') {
155
204
  sections.push(
156
- { title: 'Related behavioural rules', items: relatedBundle.user_rules },
157
- { title: 'Related preferences', items: relatedBundle.user_preferences },
158
- { title: 'Related task patterns', items: relatedBundle.task_patterns },
159
- { title: 'Related workflows', items: relatedBundle.workflows },
160
- { title: 'Related project', items: relatedBundle.project },
161
- { title: 'Related constraints', items: relatedBundle.constraints },
162
- { title: 'Related decisions', items: relatedBundle.decisions },
163
- { title: 'Related system facts', items: relatedBundle.system_facts },
164
- { 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) },
165
214
  );
166
215
  }
167
216
 
@@ -329,6 +378,62 @@ function isTimeoutLikeError(err: unknown): boolean {
329
378
  return message.includes('timeout') || message.includes('aborted');
330
379
  }
331
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
+
332
437
  const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
333
438
 
334
439
  function createClient(config: PersistioConfig, recallTopK = config.recallTopK): PersistioClient {
@@ -379,7 +484,11 @@ async function probePersistio(client: PersistioClient): Promise<MemoryEmbeddingP
379
484
  }
380
485
  }
381
486
 
382
- function createMemorySearchManager(config: PersistioConfig): MemorySearchManager {
487
+ function createMemorySearchManager(
488
+ config: PersistioConfig,
489
+ recallBreaker: RecallCircuitBreaker,
490
+ logger?: PluginLogger,
491
+ ): MemorySearchManager {
383
492
  const client = createClient(config);
384
493
 
385
494
  return {
@@ -400,7 +509,14 @@ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager
400
509
 
401
510
  const recallTopK = typeof opts?.maxResults === 'number' ? opts.maxResults : config.recallTopK;
402
511
  const recallClient = createClient(config, recallTopK);
403
- 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
+ });
404
520
 
405
521
  return memories
406
522
  .map((memory): MemorySearchResult => {
@@ -469,11 +585,11 @@ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager
469
585
  };
470
586
  }
471
587
 
472
- function createMemoryRuntime(config: PersistioConfig) {
588
+ function createMemoryRuntime(config: PersistioConfig, recallBreaker: RecallCircuitBreaker, logger?: PluginLogger) {
473
589
  return {
474
590
  async getMemorySearchManager() {
475
591
  return {
476
- manager: createMemorySearchManager(config),
592
+ manager: createMemorySearchManager(config, recallBreaker, logger),
477
593
  };
478
594
  },
479
595
 
@@ -497,10 +613,11 @@ export default definePluginEntry({
497
613
  }
498
614
 
499
615
  const client = createClient(cfg);
616
+ const recallBreaker = new RecallCircuitBreaker();
500
617
  const sentMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
501
618
  const pendingMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
502
619
  api.registerMemoryCapability({
503
- runtime: createMemoryRuntime(cfg),
620
+ runtime: createMemoryRuntime(cfg, recallBreaker, api.logger),
504
621
  });
505
622
 
506
623
  // -------------------------------------------------------------------------
@@ -509,16 +626,21 @@ export default definePluginEntry({
509
626
  // Return: { appendSystemContext?: string }
510
627
  // -------------------------------------------------------------------------
511
628
  api.on('before_prompt_build', async (event) => {
512
- try {
513
- const query = buildRecallQuery(event);
514
- const recall = await client.recallBundle(query);
515
- const block = buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
516
- if (!block) return;
517
- return { appendSystemContext: block };
518
- } catch (err) {
519
- api.logger?.warn?.(`openclaw-persistio: recall error: ${String(err)}`);
520
- }
521
- });
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 });
522
644
 
523
645
  // -------------------------------------------------------------------------
524
646
  // agent_end — ingest new turn messages (fire and forget)
@@ -632,10 +754,17 @@ export default definePluginEntry({
632
754
  }),
633
755
  async execute(_id, params) {
634
756
  const p = params as { query: string; top_k?: number };
635
- const overrideTopK = typeof p.top_k === 'number' ? p.top_k : cfg.recallTopK;
757
+ const overrideTopK = resolvePositiveInteger(p.top_k, cfg.recallTopK);
636
758
  const overrideCfg = { ...cfg, recallTopK: overrideTopK };
637
- const c = new PersistioClient(overrideCfg);
638
- 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
+ });
639
768
  const text = memories.length > 0
640
769
  ? memories.map(m => `- ${m.data} [${m.subject}]`).join('\n')
641
770
  : 'No memories found.';
@@ -653,7 +782,23 @@ export default definePluginEntry({
653
782
  }),
654
783
  async execute(_id, params) {
655
784
  const p = params as { data: string; subject: string };
656
- 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
+ }
657
802
  return { content: [{ type: 'text' as const, text: 'Memory stored.' }], details: null };
658
803
  },
659
804
  });