@persistio/openclaw-plugin 0.1.4 → 0.1.6

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
@@ -26,6 +26,7 @@ Then register it in your OpenClaw config:
26
26
  "config": {
27
27
  "baseURL": "https://api.persistio.ai",
28
28
  "apiKey": "your-vault-api-key",
29
+ "recallMinSimilarity": 0.3,
29
30
  "send": {
30
31
  "roles": {
31
32
  "user": "enabled",
@@ -48,13 +49,25 @@ Then register it in your OpenClaw config:
48
49
  | `apiKey` | string | ✅ | — | Vault API key |
49
50
  | `tokenBudget` | number | | `2000` | Max tokens to inject into the system prompt |
50
51
  | `recallTopK` | number | | `10` | Number of memories to retrieve per recall |
52
+ | `recallMinSimilarity` | number from `0` to `1` | | Persistio server default | Optional semantic recall quality floor |
51
53
  | `recallTimeout` | number | | `5000` | HTTP timeout for recall requests (ms) |
54
+ | `ingest.timeoutMs` | number | | `30000` | HTTP timeout for ingest requests (ms). Timed-out requests are treated as ambiguous and not retried automatically |
55
+ | `ingest.maxChunkChars` | number | | `6000` | Maximum characters per chunk sent to Persistio |
56
+ | `ingest.maxChunksPerTurn` | number | | `12` | Maximum chunks sent from a single OpenClaw turn |
57
+ | `ingest.skipSubagentSessions` | boolean | | `true` | Skip `agent:*` sessions unless they are `agent:main:*` |
58
+ | `ingest.user.maxCharsPerMessage` | number | | `24000` | Maximum user-message characters considered for ingest before chunking |
59
+ | `ingest.agent.mode` | `"bounded"` or `"raw"` | | `"bounded"` | Assistant ingest shaping mode. `bounded` collapses obvious large noisy blocks before chunking |
60
+ | `ingest.agent.maxCharsPerMessage` | number | | `24000` | Maximum assistant-message characters considered after filtering |
61
+ | `ingest.agent.maxCharsAfterFiltering` | number | | `9000` | Maximum assistant-message characters retained after deterministic filtering |
62
+ | `ingest.agent.maxCharsPerTurn` | number | | `24000` | Maximum assistant-message characters sent from one turn |
52
63
  | `send.roles.user` | `"enabled"` or `"disabled"` | | `"enabled"` | Send user messages to Persistio ingest |
53
64
  | `send.roles.agent` | `"enabled"` or `"disabled"` | | `"enabled"` | Send agent/assistant messages to Persistio ingest |
54
65
  | `send.roles.tool` | `"enabled"` or `"disabled"` | | `"disabled"` | Send tool messages to Persistio ingest |
55
66
 
56
67
  `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.
57
68
 
69
+ 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.
70
+
58
71
  ## Tools exposed
59
72
 
60
73
  | Tool | Description |
package/dist/client.d.ts CHANGED
@@ -1,9 +1,12 @@
1
+ import type { PersistioIngestPolicy } from './ingest-policy.js';
1
2
  export interface PersistioConfig {
2
3
  baseURL: string;
3
4
  apiKey: string;
4
5
  tokenBudget: number;
5
6
  recallTopK: number;
7
+ recallMinSimilarity?: number;
6
8
  recallTimeout: number;
9
+ ingest: PersistioIngestPolicy;
7
10
  send: PersistioSendConfig;
8
11
  }
9
12
  export type PersistioSendRoleStatus = 'enabled' | 'disabled';
@@ -22,7 +25,11 @@ export interface PersistioMemory {
22
25
  categories: string[];
23
26
  confidence: number;
24
27
  }
28
+ export interface GetMemoryOptions {
29
+ includePending?: boolean;
30
+ }
25
31
  export interface RecallBundle {
32
+ global_user_rules?: string[];
26
33
  user_rules: string[];
27
34
  user_preferences: string[];
28
35
  task_patterns: string[];
@@ -35,16 +42,19 @@ export interface RecallBundle {
35
42
  }
36
43
  export interface RecallBundleResponse {
37
44
  bundle: RecallBundle;
45
+ related_bundle?: RecallBundle;
38
46
  }
39
47
  export declare class PersistioClient {
40
48
  private readonly baseURL;
41
49
  private readonly apiKey;
42
50
  private readonly recallTopK;
51
+ private readonly recallMinSimilarity?;
43
52
  private readonly recallTimeout;
53
+ private readonly ingestTimeout;
44
54
  constructor(config: PersistioConfig);
45
55
  private headers;
46
56
  recall(query: string): Promise<PersistioMemory[]>;
47
- recallBundle(query: string, topK?: number): Promise<RecallBundle>;
57
+ recallBundle(query: string, topK?: number): Promise<RecallBundleResponse>;
48
58
  ingest(sessionId: string, chunks: Array<{
49
59
  role: string;
50
60
  content: string;
@@ -52,5 +62,6 @@ export declare class PersistioClient {
52
62
  }>): Promise<void>;
53
63
  addMemory(data: string, subject: string): Promise<void>;
54
64
  deleteMemory(id: string): Promise<void>;
65
+ getMemory(id: string, options?: GetMemoryOptions): Promise<PersistioMemory | null>;
55
66
  listMemories(): Promise<PersistioMemory[]>;
56
67
  }
package/dist/client.js CHANGED
@@ -2,12 +2,16 @@ export class PersistioClient {
2
2
  baseURL;
3
3
  apiKey;
4
4
  recallTopK;
5
+ recallMinSimilarity;
5
6
  recallTimeout;
7
+ ingestTimeout;
6
8
  constructor(config) {
7
9
  this.baseURL = config.baseURL.replace(/\/$/, '');
8
10
  this.apiKey = config.apiKey;
9
11
  this.recallTopK = config.recallTopK;
12
+ this.recallMinSimilarity = config.recallMinSimilarity;
10
13
  this.recallTimeout = config.recallTimeout;
14
+ this.ingestTimeout = config.ingest.timeoutMs;
11
15
  }
12
16
  headers() {
13
17
  return {
@@ -16,10 +20,14 @@ export class PersistioClient {
16
20
  };
17
21
  }
18
22
  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
+ }
19
27
  const res = await fetch(`${this.baseURL}/v1/recall`, {
20
28
  method: 'POST',
21
29
  headers: this.headers(),
22
- body: JSON.stringify({ query, top_k: this.recallTopK }),
30
+ body: JSON.stringify(body),
23
31
  signal: AbortSignal.timeout(this.recallTimeout),
24
32
  });
25
33
  if (!res.ok)
@@ -28,16 +36,20 @@ export class PersistioClient {
28
36
  return data.memories ?? [];
29
37
  }
30
38
  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
+ }
31
43
  const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
32
44
  method: 'POST',
33
45
  headers: this.headers(),
34
- body: JSON.stringify({ query, top_k: topK ?? this.recallTopK }),
46
+ body: JSON.stringify(body),
35
47
  signal: AbortSignal.timeout(this.recallTimeout),
36
48
  });
37
49
  if (!res.ok)
38
50
  throw new Error(`Persistio recallBundle failed: ${res.status}`);
39
51
  const data = await res.json();
40
- return data.bundle;
52
+ return data;
41
53
  }
42
54
  async ingest(sessionId, chunks) {
43
55
  if (chunks.length === 0)
@@ -46,9 +58,10 @@ export class PersistioClient {
46
58
  method: 'POST',
47
59
  headers: this.headers(),
48
60
  body: JSON.stringify({ session_id: sessionId, chunks }),
61
+ signal: AbortSignal.timeout(this.ingestTimeout),
49
62
  });
50
63
  if (!res.ok)
51
- throw new Error(`Persistio ingest failed: ${res.status}`);
64
+ throw new Error(await formatHttpError('ingest', res));
52
65
  }
53
66
  async addMemory(data, subject) {
54
67
  const res = await fetch(`${this.baseURL}/v1/memories`, {
@@ -67,6 +80,17 @@ export class PersistioClient {
67
80
  if (!res.ok)
68
81
  throw new Error(`Persistio deleteMemory failed: ${res.status}`);
69
82
  }
83
+ 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(),
87
+ });
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
+ }
70
94
  async listMemories() {
71
95
  const res = await fetch(`${this.baseURL}/v1/memories`, {
72
96
  headers: this.headers(),
@@ -77,3 +101,15 @@ export class PersistioClient {
77
101
  return data.items ?? [];
78
102
  }
79
103
  }
104
+ async function formatHttpError(operation, res) {
105
+ let detail = '';
106
+ try {
107
+ detail = (await res.text()).trim().slice(0, 500);
108
+ }
109
+ catch {
110
+ // Ignore response body read failures; the status is still actionable.
111
+ }
112
+ return detail
113
+ ? `Persistio ${operation} failed: ${res.status} ${detail}`
114
+ : `Persistio ${operation} failed: ${res.status}`;
115
+ }
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',
@@ -25,6 +26,11 @@ function resolveSendConfig(raw) {
25
26
  },
26
27
  };
27
28
  }
29
+ function resolveRecallMinSimilarity(value) {
30
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
31
+ ? value
32
+ : undefined;
33
+ }
28
34
  function resolveConfig(raw) {
29
35
  const c = (raw ?? {});
30
36
  return {
@@ -32,7 +38,9 @@ function resolveConfig(raw) {
32
38
  apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
33
39
  tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
34
40
  recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
41
+ recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
35
42
  recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
43
+ ingest: resolveIngestPolicy(c['ingest']),
36
44
  send: resolveSendConfig(c),
37
45
  };
38
46
  }
@@ -102,7 +110,7 @@ function buildRecallQuery(event) {
102
110
  parts.push(`[task: ${taskType}]`);
103
111
  return truncate(parts.join('\n'), 600);
104
112
  }
105
- function buildMemoryBlock(bundle, budget) {
113
+ function buildMemoryBlock(bundle, budget, relatedBundle) {
106
114
  const sections = [
107
115
  { title: 'Behavioural rules', items: bundle.user_rules },
108
116
  { title: 'Preferences', items: bundle.user_preferences },
@@ -114,6 +122,9 @@ function buildMemoryBlock(bundle, budget) {
114
122
  { title: 'System facts', items: bundle.system_facts },
115
123
  { title: 'Domain knowledge', items: bundle.domain_knowledge },
116
124
  ];
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 });
127
+ }
117
128
  const intro = 'Use the following as prior context and preferences. If they conflict with current instructions, follow the current instructions.';
118
129
  const lines = [intro];
119
130
  let used = estimateTokens(intro);
@@ -244,6 +255,27 @@ function forgetKeys(target, keys) {
244
255
  for (const key of keys)
245
256
  target.delete(key);
246
257
  }
258
+ function summarizeOmissions(omissions) {
259
+ if (omissions.length === 0)
260
+ return 'none';
261
+ const counts = new Map();
262
+ for (const omission of omissions) {
263
+ counts.set(omission.label, (counts.get(omission.label) ?? 0) + 1);
264
+ }
265
+ return [...counts.entries()]
266
+ .map(([label, count]) => `${label}:${count}`)
267
+ .join(',');
268
+ }
269
+ function isTimeoutLikeError(err) {
270
+ if (typeof err !== 'object' || err === null)
271
+ return false;
272
+ const record = err;
273
+ const name = typeof record['name'] === 'string' ? record['name'] : '';
274
+ if (name === 'TimeoutError' || name === 'AbortError')
275
+ return true;
276
+ const message = typeof record['message'] === 'string' ? record['message'].toLowerCase() : '';
277
+ return message.includes('timeout') || message.includes('aborted');
278
+ }
247
279
  const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
248
280
  function createClient(config, recallTopK = config.recallTopK) {
249
281
  return new PersistioClient({ ...config, recallTopK });
@@ -317,8 +349,7 @@ function createMemorySearchManager(config) {
317
349
  if (!memoryId) {
318
350
  throw new Error(`Unsupported Persistio memory path: ${params.relPath}`);
319
351
  }
320
- const memories = await client.listMemories();
321
- const memory = memories.find((item) => item.id === memoryId);
352
+ const memory = await client.getMemory(memoryId, { includePending: true });
322
353
  if (!memory) {
323
354
  throw new Error(`Persistio memory not found: ${memoryId}`);
324
355
  }
@@ -389,8 +420,8 @@ export default definePluginEntry({
389
420
  api.on('before_prompt_build', async (event) => {
390
421
  try {
391
422
  const query = buildRecallQuery(event);
392
- const bundle = await client.recallBundle(query);
393
- const block = buildMemoryBlock(bundle, cfg.tokenBudget);
423
+ const recall = await client.recallBundle(query);
424
+ const block = buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
394
425
  if (!block)
395
426
  return;
396
427
  return { appendSystemContext: block };
@@ -409,8 +440,18 @@ export default definePluginEntry({
409
440
  const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
410
441
  if (sessionId.startsWith('announce:'))
411
442
  return;
443
+ if (!shouldIngestSession(sessionId, cfg.ingest)) {
444
+ api.logger?.debug?.(`openclaw-persistio: ingest skipped non-main session: ${sessionId}`);
445
+ return;
446
+ }
412
447
  const chunks = [];
413
448
  const chunkKeys = [];
449
+ let agentCharsSent = 0;
450
+ let originalChars = 0;
451
+ let preparedChars = 0;
452
+ let truncatedMessages = 0;
453
+ let skippedMessages = 0;
454
+ const omissions = [];
414
455
  const now = Date.now();
415
456
  const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
416
457
  const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
@@ -426,17 +467,50 @@ export default definePluginEntry({
426
467
  if (sentKeys.has(key) || pendingKeys.has(key))
427
468
  continue;
428
469
  const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
470
+ const prepared = prepareMessageForIngest({
471
+ role,
472
+ text,
473
+ policy: cfg.ingest,
474
+ remainingAgentChars: Math.max(0, cfg.ingest.agent.maxCharsPerTurn - agentCharsSent),
475
+ remainingChunks: Math.max(0, cfg.ingest.maxChunksPerTurn - chunks.length),
476
+ });
477
+ originalChars += prepared.originalChars;
478
+ preparedChars += prepared.preparedChars;
479
+ omissions.push(...prepared.omissions);
480
+ if (prepared.truncated)
481
+ truncatedMessages += 1;
482
+ if (prepared.chunks.length === 0) {
483
+ skippedMessages += 1;
484
+ continue;
485
+ }
429
486
  chunkKeys.push(key);
430
- chunks.push({ role, content: text, timestamp: ts });
487
+ if (role === 'assistant') {
488
+ agentCharsSent += prepared.preparedChars;
489
+ }
490
+ chunks.push(...prepared.chunks.map((content) => ({ role, content, timestamp: ts })));
491
+ if (chunks.length >= cfg.ingest.maxChunksPerTurn)
492
+ break;
431
493
  }
432
494
  if (chunks.length === 0)
433
495
  return;
496
+ if (truncatedMessages > 0 || omissions.length > 0 || skippedMessages > 0) {
497
+ api.logger?.info?.(`openclaw-persistio: ingest planned session=${sessionId} chunks=${chunks.length} `
498
+ + `originalChars=${originalChars} preparedChars=${preparedChars} `
499
+ + `truncatedMessages=${truncatedMessages} skippedMessages=${skippedMessages} `
500
+ + `omissions=${summarizeOmissions(omissions)}`);
501
+ }
434
502
  rememberKeys(pendingKeys, chunkKeys);
435
503
  client.ingest(sessionId, chunks)
436
504
  .then(() => {
437
505
  rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
438
506
  })
439
507
  .catch((err) => {
508
+ if (isTimeoutLikeError(err)) {
509
+ rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
510
+ api.logger?.warn?.(`openclaw-persistio: ingest timeout after ${cfg.ingest.timeoutMs}ms; `
511
+ + `outcome is ambiguous, suppressing retry for ${chunkKeys.length} messages in session=${sessionId}`);
512
+ return;
513
+ }
440
514
  api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
441
515
  })
442
516
  .finally(() => {
@@ -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;