@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/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
2
2
  import { Type } from '@sinclair/typebox';
3
3
  import { PersistioClient } from './client.js';
4
+ import { prepareMessageForIngest, resolveIngestPolicy, shouldIngestSession, } from './ingest-policy.js';
4
5
  const DEFAULT_SEND_ROLES = {
5
6
  user: 'enabled',
6
7
  agent: 'enabled',
@@ -9,6 +10,31 @@ const DEFAULT_SEND_ROLES = {
9
10
  const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
10
11
  const MAX_TRACKED_SESSIONS = 250;
11
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
+ }
12
38
  function resolveSendConfig(raw) {
13
39
  const send = raw['send'];
14
40
  const roles = typeof send === 'object' && send !== null
@@ -30,15 +56,21 @@ function resolveRecallMinSimilarity(value) {
30
56
  ? value
31
57
  : undefined;
32
58
  }
59
+ function resolvePositiveInteger(value, fallback) {
60
+ return typeof value === 'number' && Number.isFinite(value) && value >= 1
61
+ ? Math.floor(value)
62
+ : fallback;
63
+ }
33
64
  function resolveConfig(raw) {
34
65
  const c = (raw ?? {});
35
66
  return {
36
67
  baseURL: typeof c['baseURL'] === 'string' ? c['baseURL'] : '',
37
68
  apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
38
- tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
39
- recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
69
+ tokenBudget: resolvePositiveInteger(c['tokenBudget'], 2000),
70
+ recallTopK: resolvePositiveInteger(c['recallTopK'], 10),
40
71
  recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
41
- recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
72
+ recallTimeout: resolvePositiveInteger(c['recallTimeout'], 5000),
73
+ ingest: resolveIngestPolicy(c['ingest']),
42
74
  send: resolveSendConfig(c),
43
75
  };
44
76
  }
@@ -108,20 +140,27 @@ function buildRecallQuery(event) {
108
140
  parts.push(`[task: ${taskType}]`);
109
141
  return truncate(parts.join('\n'), 600);
110
142
  }
143
+ function toStringArray(value) {
144
+ return Array.isArray(value)
145
+ ? value.filter((item) => typeof item === 'string')
146
+ : [];
147
+ }
111
148
  function buildMemoryBlock(bundle, budget, relatedBundle) {
149
+ if (!bundle || typeof bundle !== 'object')
150
+ return '';
112
151
  const sections = [
113
- { title: 'Behavioural rules', items: bundle.user_rules },
114
- { title: 'Preferences', items: bundle.user_preferences },
115
- { title: 'Task patterns', items: bundle.task_patterns },
116
- { title: 'Workflows', items: bundle.workflows },
117
- { title: 'Project', items: bundle.project },
118
- { title: 'Constraints', items: bundle.constraints },
119
- { title: 'Decisions', items: bundle.decisions },
120
- { title: 'System facts', items: bundle.system_facts },
121
- { 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) },
122
161
  ];
123
- if (relatedBundle) {
124
- 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) });
125
164
  }
126
165
  const intro = 'Use the following as prior context and preferences. If they conflict with current instructions, follow the current instructions.';
127
166
  const lines = [intro];
@@ -253,6 +292,66 @@ function forgetKeys(target, keys) {
253
292
  for (const key of keys)
254
293
  target.delete(key);
255
294
  }
295
+ function summarizeOmissions(omissions) {
296
+ if (omissions.length === 0)
297
+ return 'none';
298
+ const counts = new Map();
299
+ for (const omission of omissions) {
300
+ counts.set(omission.label, (counts.get(omission.label) ?? 0) + 1);
301
+ }
302
+ return [...counts.entries()]
303
+ .map(([label, count]) => `${label}:${count}`)
304
+ .join(',');
305
+ }
306
+ function isTimeoutLikeError(err) {
307
+ if (typeof err !== 'object' || err === null)
308
+ return false;
309
+ const record = err;
310
+ const name = typeof record['name'] === 'string' ? record['name'] : '';
311
+ if (name === 'TimeoutError' || name === 'AbortError')
312
+ return true;
313
+ const message = typeof record['message'] === 'string' ? record['message'].toLowerCase() : '';
314
+ return message.includes('timeout') || message.includes('aborted');
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
+ }
256
355
  const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
257
356
  function createClient(config, recallTopK = config.recallTopK) {
258
357
  return new PersistioClient({ ...config, recallTopK });
@@ -295,7 +394,7 @@ async function probePersistio(client) {
295
394
  return { ok: false, error: String(err) };
296
395
  }
297
396
  }
298
- function createMemorySearchManager(config) {
397
+ function createMemorySearchManager(config, recallBreaker, logger) {
299
398
  const client = createClient(config);
300
399
  return {
301
400
  async search(query, opts) {
@@ -304,7 +403,14 @@ function createMemorySearchManager(config) {
304
403
  }
305
404
  const recallTopK = typeof opts?.maxResults === 'number' ? opts.maxResults : config.recallTopK;
306
405
  const recallClient = createClient(config, recallTopK);
307
- 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
+ });
308
414
  return memories
309
415
  .map((memory) => {
310
416
  const score = normalizeMemoryScore(memory);
@@ -361,11 +467,11 @@ function createMemorySearchManager(config) {
361
467
  },
362
468
  };
363
469
  }
364
- function createMemoryRuntime(config) {
470
+ function createMemoryRuntime(config, recallBreaker, logger) {
365
471
  return {
366
472
  async getMemorySearchManager() {
367
473
  return {
368
- manager: createMemorySearchManager(config),
474
+ manager: createMemorySearchManager(config, recallBreaker, logger),
369
475
  };
370
476
  },
371
477
  resolveMemoryBackendConfig() {
@@ -384,10 +490,11 @@ export default definePluginEntry({
384
490
  return;
385
491
  }
386
492
  const client = createClient(cfg);
493
+ const recallBreaker = new RecallCircuitBreaker();
387
494
  const sentMessageKeysBySession = new Map();
388
495
  const pendingMessageKeysBySession = new Map();
389
496
  api.registerMemoryCapability({
390
- runtime: createMemoryRuntime(cfg),
497
+ runtime: createMemoryRuntime(cfg, recallBreaker, api.logger),
391
498
  });
392
499
  // -------------------------------------------------------------------------
393
500
  // before_prompt_build — recall relevant memories and inject into context
@@ -395,18 +502,22 @@ export default definePluginEntry({
395
502
  // Return: { appendSystemContext?: string }
396
503
  // -------------------------------------------------------------------------
397
504
  api.on('before_prompt_build', async (event) => {
398
- try {
399
- const query = buildRecallQuery(event);
400
- const recall = await client.recallBundle(query);
401
- const block = buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
402
- if (!block)
403
- return;
404
- return { appendSystemContext: block };
405
- }
406
- catch (err) {
407
- api.logger?.warn?.(`openclaw-persistio: recall error: ${String(err)}`);
408
- }
409
- });
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 });
410
521
  // -------------------------------------------------------------------------
411
522
  // agent_end — ingest new turn messages (fire and forget)
412
523
  // Event: { runId?, messages: unknown[], success: boolean, error?, durationMs? }
@@ -417,8 +528,18 @@ export default definePluginEntry({
417
528
  const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
418
529
  if (sessionId.startsWith('announce:'))
419
530
  return;
531
+ if (!shouldIngestSession(sessionId, cfg.ingest)) {
532
+ api.logger?.debug?.(`openclaw-persistio: ingest skipped non-main session: ${sessionId}`);
533
+ return;
534
+ }
420
535
  const chunks = [];
421
536
  const chunkKeys = [];
537
+ let agentCharsSent = 0;
538
+ let originalChars = 0;
539
+ let preparedChars = 0;
540
+ let truncatedMessages = 0;
541
+ let skippedMessages = 0;
542
+ const omissions = [];
422
543
  const now = Date.now();
423
544
  const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
424
545
  const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
@@ -434,17 +555,50 @@ export default definePluginEntry({
434
555
  if (sentKeys.has(key) || pendingKeys.has(key))
435
556
  continue;
436
557
  const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
558
+ const prepared = prepareMessageForIngest({
559
+ role,
560
+ text,
561
+ policy: cfg.ingest,
562
+ remainingAgentChars: Math.max(0, cfg.ingest.agent.maxCharsPerTurn - agentCharsSent),
563
+ remainingChunks: Math.max(0, cfg.ingest.maxChunksPerTurn - chunks.length),
564
+ });
565
+ originalChars += prepared.originalChars;
566
+ preparedChars += prepared.preparedChars;
567
+ omissions.push(...prepared.omissions);
568
+ if (prepared.truncated)
569
+ truncatedMessages += 1;
570
+ if (prepared.chunks.length === 0) {
571
+ skippedMessages += 1;
572
+ continue;
573
+ }
437
574
  chunkKeys.push(key);
438
- chunks.push({ role, content: text, timestamp: ts });
575
+ if (role === 'assistant') {
576
+ agentCharsSent += prepared.preparedChars;
577
+ }
578
+ chunks.push(...prepared.chunks.map((content) => ({ role, content, timestamp: ts })));
579
+ if (chunks.length >= cfg.ingest.maxChunksPerTurn)
580
+ break;
439
581
  }
440
582
  if (chunks.length === 0)
441
583
  return;
584
+ if (truncatedMessages > 0 || omissions.length > 0 || skippedMessages > 0) {
585
+ api.logger?.info?.(`openclaw-persistio: ingest planned session=${sessionId} chunks=${chunks.length} `
586
+ + `originalChars=${originalChars} preparedChars=${preparedChars} `
587
+ + `truncatedMessages=${truncatedMessages} skippedMessages=${skippedMessages} `
588
+ + `omissions=${summarizeOmissions(omissions)}`);
589
+ }
442
590
  rememberKeys(pendingKeys, chunkKeys);
443
591
  client.ingest(sessionId, chunks)
444
592
  .then(() => {
445
593
  rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
446
594
  })
447
595
  .catch((err) => {
596
+ if (isTimeoutLikeError(err)) {
597
+ rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
598
+ api.logger?.warn?.(`openclaw-persistio: ingest timeout after ${cfg.ingest.timeoutMs}ms; `
599
+ + `outcome is ambiguous, suppressing retry for ${chunkKeys.length} messages in session=${sessionId}`);
600
+ return;
601
+ }
448
602
  api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
449
603
  })
450
604
  .finally(() => {
@@ -471,10 +625,17 @@ export default definePluginEntry({
471
625
  }),
472
626
  async execute(_id, params) {
473
627
  const p = params;
474
- const overrideTopK = typeof p.top_k === 'number' ? p.top_k : cfg.recallTopK;
628
+ const overrideTopK = resolvePositiveInteger(p.top_k, cfg.recallTopK);
475
629
  const overrideCfg = { ...cfg, recallTopK: overrideTopK };
476
- const c = new PersistioClient(overrideCfg);
477
- 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
+ });
478
639
  const text = memories.length > 0
479
640
  ? memories.map(m => `- ${m.data} [${m.subject}]`).join('\n')
480
641
  : 'No memories found.';
@@ -491,7 +652,22 @@ export default definePluginEntry({
491
652
  }),
492
653
  async execute(_id, params) {
493
654
  const p = params;
494
- 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
+ }
495
671
  return { content: [{ type: 'text', text: 'Memory stored.' }], details: null };
496
672
  },
497
673
  });
@@ -0,0 +1,48 @@
1
+ export type OpenClawMessageRole = 'user' | 'assistant' | 'tool';
2
+ export interface PersistioIngestPolicy {
3
+ timeoutMs: number;
4
+ maxChunkChars: number;
5
+ maxChunksPerTurn: number;
6
+ skipSubagentSessions: boolean;
7
+ user: {
8
+ maxCharsPerMessage: number;
9
+ };
10
+ agent: {
11
+ mode: 'bounded' | 'raw';
12
+ maxCharsPerMessage: number;
13
+ maxCharsAfterFiltering: number;
14
+ maxCharsPerTurn: number;
15
+ largeBlockThresholdChars: number;
16
+ largeBlockThresholdLines: number;
17
+ maxTableRows: number;
18
+ };
19
+ }
20
+ export interface OmissionSummary {
21
+ label: string;
22
+ chars: number;
23
+ lines: number;
24
+ }
25
+ export interface PreparedIngestMessage {
26
+ chunks: string[];
27
+ originalChars: number;
28
+ preparedChars: number;
29
+ truncated: boolean;
30
+ omissions: OmissionSummary[];
31
+ }
32
+ export interface PrepareMessageInput {
33
+ role: OpenClawMessageRole;
34
+ text: string;
35
+ policy: PersistioIngestPolicy;
36
+ remainingAgentChars: number;
37
+ remainingChunks: number;
38
+ }
39
+ export declare const DEFAULT_INGEST_POLICY: PersistioIngestPolicy;
40
+ export declare function resolveIngestPolicy(raw: unknown): PersistioIngestPolicy;
41
+ export declare function shouldIngestSession(sessionId: string, policy: PersistioIngestPolicy): boolean;
42
+ export declare function filterAssistantContent(text: string, policy: PersistioIngestPolicy): {
43
+ text: string;
44
+ omissions: OmissionSummary[];
45
+ truncated: boolean;
46
+ };
47
+ export declare function chunkText(text: string, maxChunkChars: number): string[];
48
+ export declare function prepareMessageForIngest(input: PrepareMessageInput): PreparedIngestMessage;