@persistio/openclaw-plugin 0.1.3 → 0.1.5

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
@@ -25,7 +25,15 @@ Then register it in your OpenClaw config:
25
25
  "package": "@persistio/openclaw-plugin",
26
26
  "config": {
27
27
  "baseURL": "https://api.persistio.ai",
28
- "apiKey": "your-vault-api-key"
28
+ "apiKey": "your-vault-api-key",
29
+ "recallMinSimilarity": 0.3,
30
+ "send": {
31
+ "roles": {
32
+ "user": "enabled",
33
+ "agent": "enabled",
34
+ "tool": "disabled"
35
+ }
36
+ }
29
37
  }
30
38
  }
31
39
  }
@@ -41,7 +49,13 @@ Then register it in your OpenClaw config:
41
49
  | `apiKey` | string | ✅ | — | Vault API key |
42
50
  | `tokenBudget` | number | | `2000` | Max tokens to inject into the system prompt |
43
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 |
44
53
  | `recallTimeout` | number | | `5000` | HTTP timeout for recall requests (ms) |
54
+ | `send.roles.user` | `"enabled"` or `"disabled"` | | `"enabled"` | Send user messages to Persistio ingest |
55
+ | `send.roles.agent` | `"enabled"` or `"disabled"` | | `"enabled"` | Send agent/assistant messages to Persistio ingest |
56
+ | `send.roles.tool` | `"enabled"` or `"disabled"` | | `"disabled"` | Send tool messages to Persistio ingest |
57
+
58
+ `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.
45
59
 
46
60
  ## Tools exposed
47
61
 
package/dist/client.d.ts CHANGED
@@ -3,7 +3,17 @@ export interface PersistioConfig {
3
3
  apiKey: string;
4
4
  tokenBudget: number;
5
5
  recallTopK: number;
6
+ recallMinSimilarity?: number;
6
7
  recallTimeout: number;
8
+ send: PersistioSendConfig;
9
+ }
10
+ export type PersistioSendRoleStatus = 'enabled' | 'disabled';
11
+ export interface PersistioSendConfig {
12
+ roles: {
13
+ user: PersistioSendRoleStatus;
14
+ agent: PersistioSendRoleStatus;
15
+ tool: PersistioSendRoleStatus;
16
+ };
7
17
  }
8
18
  export interface PersistioMemory {
9
19
  id: string;
@@ -13,7 +23,11 @@ export interface PersistioMemory {
13
23
  categories: string[];
14
24
  confidence: number;
15
25
  }
26
+ export interface GetMemoryOptions {
27
+ includePending?: boolean;
28
+ }
16
29
  export interface RecallBundle {
30
+ global_user_rules?: string[];
17
31
  user_rules: string[];
18
32
  user_preferences: string[];
19
33
  task_patterns: string[];
@@ -26,16 +40,18 @@ export interface RecallBundle {
26
40
  }
27
41
  export interface RecallBundleResponse {
28
42
  bundle: RecallBundle;
43
+ related_bundle?: RecallBundle;
29
44
  }
30
45
  export declare class PersistioClient {
31
46
  private readonly baseURL;
32
47
  private readonly apiKey;
33
48
  private readonly recallTopK;
49
+ private readonly recallMinSimilarity?;
34
50
  private readonly recallTimeout;
35
51
  constructor(config: PersistioConfig);
36
52
  private headers;
37
53
  recall(query: string): Promise<PersistioMemory[]>;
38
- recallBundle(query: string, topK?: number): Promise<RecallBundle>;
54
+ recallBundle(query: string, topK?: number): Promise<RecallBundleResponse>;
39
55
  ingest(sessionId: string, chunks: Array<{
40
56
  role: string;
41
57
  content: string;
@@ -43,5 +59,6 @@ export declare class PersistioClient {
43
59
  }>): Promise<void>;
44
60
  addMemory(data: string, subject: string): Promise<void>;
45
61
  deleteMemory(id: string): Promise<void>;
62
+ getMemory(id: string, options?: GetMemoryOptions): Promise<PersistioMemory | null>;
46
63
  listMemories(): Promise<PersistioMemory[]>;
47
64
  }
package/dist/client.js CHANGED
@@ -2,11 +2,13 @@ export class PersistioClient {
2
2
  baseURL;
3
3
  apiKey;
4
4
  recallTopK;
5
+ recallMinSimilarity;
5
6
  recallTimeout;
6
7
  constructor(config) {
7
8
  this.baseURL = config.baseURL.replace(/\/$/, '');
8
9
  this.apiKey = config.apiKey;
9
10
  this.recallTopK = config.recallTopK;
11
+ this.recallMinSimilarity = config.recallMinSimilarity;
10
12
  this.recallTimeout = config.recallTimeout;
11
13
  }
12
14
  headers() {
@@ -16,10 +18,14 @@ export class PersistioClient {
16
18
  };
17
19
  }
18
20
  async recall(query) {
21
+ const body = { query, top_k: this.recallTopK, include_pending: true };
22
+ if (typeof this.recallMinSimilarity === 'number') {
23
+ body.min_similarity = this.recallMinSimilarity;
24
+ }
19
25
  const res = await fetch(`${this.baseURL}/v1/recall`, {
20
26
  method: 'POST',
21
27
  headers: this.headers(),
22
- body: JSON.stringify({ query, top_k: this.recallTopK }),
28
+ body: JSON.stringify(body),
23
29
  signal: AbortSignal.timeout(this.recallTimeout),
24
30
  });
25
31
  if (!res.ok)
@@ -28,16 +34,20 @@ export class PersistioClient {
28
34
  return data.memories ?? [];
29
35
  }
30
36
  async recallBundle(query, topK) {
37
+ const body = { query, top_k: topK ?? this.recallTopK, include_pending: true };
38
+ if (typeof this.recallMinSimilarity === 'number') {
39
+ body.min_similarity = this.recallMinSimilarity;
40
+ }
31
41
  const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
32
42
  method: 'POST',
33
43
  headers: this.headers(),
34
- body: JSON.stringify({ query, top_k: topK ?? this.recallTopK }),
44
+ body: JSON.stringify(body),
35
45
  signal: AbortSignal.timeout(this.recallTimeout),
36
46
  });
37
47
  if (!res.ok)
38
48
  throw new Error(`Persistio recallBundle failed: ${res.status}`);
39
49
  const data = await res.json();
40
- return data.bundle;
50
+ return data;
41
51
  }
42
52
  async ingest(sessionId, chunks) {
43
53
  if (chunks.length === 0)
@@ -67,6 +77,17 @@ export class PersistioClient {
67
77
  if (!res.ok)
68
78
  throw new Error(`Persistio deleteMemory failed: ${res.status}`);
69
79
  }
80
+ async getMemory(id, options = {}) {
81
+ const query = options.includePending ? '?include_pending=true' : '';
82
+ const res = await fetch(`${this.baseURL}/v1/memories/${id}${query}`, {
83
+ headers: this.headers(),
84
+ });
85
+ if (res.status === 404)
86
+ return null;
87
+ if (!res.ok)
88
+ throw new Error(`Persistio getMemory failed: ${res.status}`);
89
+ return await res.json();
90
+ }
70
91
  async listMemories() {
71
92
  const res = await fetch(`${this.baseURL}/v1/memories`, {
72
93
  headers: this.headers(),
package/dist/index.js CHANGED
@@ -1,6 +1,35 @@
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
+ const DEFAULT_SEND_ROLES = {
5
+ user: 'enabled',
6
+ agent: 'enabled',
7
+ tool: 'disabled',
8
+ };
9
+ const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
10
+ const MAX_TRACKED_SESSIONS = 250;
11
+ const MAX_SENT_KEYS_PER_SESSION = 2000;
12
+ function resolveSendConfig(raw) {
13
+ const send = raw['send'];
14
+ const roles = typeof send === 'object' && send !== null
15
+ ? send['roles']
16
+ : undefined;
17
+ const rawRoles = typeof roles === 'object' && roles !== null
18
+ ? roles
19
+ : {};
20
+ return {
21
+ roles: {
22
+ user: rawRoles['user'] === 'disabled' ? 'disabled' : DEFAULT_SEND_ROLES.user,
23
+ agent: rawRoles['agent'] === 'disabled' ? 'disabled' : DEFAULT_SEND_ROLES.agent,
24
+ tool: rawRoles['tool'] === 'enabled' ? 'enabled' : DEFAULT_SEND_ROLES.tool,
25
+ },
26
+ };
27
+ }
28
+ function resolveRecallMinSimilarity(value) {
29
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
30
+ ? value
31
+ : undefined;
32
+ }
4
33
  function resolveConfig(raw) {
5
34
  const c = (raw ?? {});
6
35
  return {
@@ -8,7 +37,9 @@ function resolveConfig(raw) {
8
37
  apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
9
38
  tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
10
39
  recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
40
+ recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
11
41
  recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
42
+ send: resolveSendConfig(c),
12
43
  };
13
44
  }
14
45
  function estimateTokens(text) {
@@ -77,7 +108,7 @@ function buildRecallQuery(event) {
77
108
  parts.push(`[task: ${taskType}]`);
78
109
  return truncate(parts.join('\n'), 600);
79
110
  }
80
- function buildMemoryBlock(bundle, budget) {
111
+ function buildMemoryBlock(bundle, budget, relatedBundle) {
81
112
  const sections = [
82
113
  { title: 'Behavioural rules', items: bundle.user_rules },
83
114
  { title: 'Preferences', items: bundle.user_preferences },
@@ -89,6 +120,9 @@ function buildMemoryBlock(bundle, budget) {
89
120
  { title: 'System facts', items: bundle.system_facts },
90
121
  { title: 'Domain knowledge', items: bundle.domain_knowledge },
91
122
  ];
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 });
125
+ }
92
126
  const intro = 'Use the following as prior context and preferences. If they conflict with current instructions, follow the current instructions.';
93
127
  const lines = [intro];
94
128
  let used = estimateTokens(intro);
@@ -117,13 +151,23 @@ function buildMemoryBlock(bundle, budget) {
117
151
  }
118
152
  return lines.length > 1 ? lines.join('\n') : '';
119
153
  }
154
+ function normalizeRole(role) {
155
+ if (role === 'user' || role === 'assistant' || role === 'tool')
156
+ return role;
157
+ return null;
158
+ }
159
+ function shouldSendRole(role, config) {
160
+ if (role === 'assistant')
161
+ return config.send.roles.agent === 'enabled';
162
+ return config.send.roles[role] === 'enabled';
163
+ }
120
164
  /** Extract plain text from a pi-agent-core message content array */
121
- function extractTextFromMessage(msg) {
165
+ function extractTextFromMessage(msg, allowedRoles = ['user', 'assistant']) {
122
166
  if (typeof msg !== 'object' || msg === null)
123
167
  return null;
124
168
  const m = msg;
125
- const role = m['role'];
126
- if (role !== 'user' && role !== 'assistant')
169
+ const role = normalizeRole(m['role']);
170
+ if (!role || !allowedRoles.includes(role))
127
171
  return null;
128
172
  const content = m['content'];
129
173
  if (!Array.isArray(content)) {
@@ -143,6 +187,72 @@ function extractTextFromMessage(msg) {
143
187
  }
144
188
  return parts.length > 0 ? parts.join(' ') : null;
145
189
  }
190
+ function resolveMessageTimestamp(msg) {
191
+ if (typeof msg['timestamp'] === 'number')
192
+ return new Date(msg['timestamp']).toISOString();
193
+ if (typeof msg['timestamp'] === 'string')
194
+ return msg['timestamp'];
195
+ return null;
196
+ }
197
+ function hashString(input) {
198
+ let hash = 0x811c9dc5;
199
+ for (let i = 0; i < input.length; i += 1) {
200
+ hash ^= input.charCodeAt(i);
201
+ hash = Math.imul(hash, 0x01000193);
202
+ }
203
+ return (hash >>> 0).toString(16);
204
+ }
205
+ function buildMessageFingerprint(params) {
206
+ const id = params.msg['id'];
207
+ if (typeof id === 'string' && id.length > 0) {
208
+ return `id:${params.sessionId}:${id}`;
209
+ }
210
+ const idempotencyKey = params.msg['idempotencyKey'];
211
+ if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) {
212
+ return `idempotency:${params.sessionId}:${idempotencyKey}`;
213
+ }
214
+ const timestamp = resolveMessageTimestamp(params.msg);
215
+ const basis = timestamp ?? `index:${params.index}`;
216
+ return `content:${params.sessionId}:${basis}:${params.role}:${hashString(params.text)}`;
217
+ }
218
+ function pruneSessionKeyStores(stores, now) {
219
+ for (const [sessionId, store] of stores) {
220
+ if (now - store.lastSeen > MESSAGE_KEY_TTL_MS)
221
+ stores.delete(sessionId);
222
+ }
223
+ while (stores.size > MAX_TRACKED_SESSIONS) {
224
+ const oldest = [...stores.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen)[0];
225
+ if (!oldest)
226
+ return;
227
+ stores.delete(oldest[0]);
228
+ }
229
+ }
230
+ function getSessionKeyStore(stores, sessionId, now) {
231
+ pruneSessionKeyStores(stores, now);
232
+ const existing = stores.get(sessionId);
233
+ if (existing) {
234
+ existing.lastSeen = now;
235
+ return existing.keys;
236
+ }
237
+ const created = { keys: new Set(), lastSeen: now };
238
+ stores.set(sessionId, created);
239
+ return created.keys;
240
+ }
241
+ function rememberKeys(target, keys, limit = Number.POSITIVE_INFINITY) {
242
+ for (const key of keys) {
243
+ target.add(key);
244
+ while (target.size > limit) {
245
+ const oldest = target.values().next().value;
246
+ if (!oldest)
247
+ break;
248
+ target.delete(oldest);
249
+ }
250
+ }
251
+ }
252
+ function forgetKeys(target, keys) {
253
+ for (const key of keys)
254
+ target.delete(key);
255
+ }
146
256
  const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
147
257
  function createClient(config, recallTopK = config.recallTopK) {
148
258
  return new PersistioClient({ ...config, recallTopK });
@@ -216,8 +326,7 @@ function createMemorySearchManager(config) {
216
326
  if (!memoryId) {
217
327
  throw new Error(`Unsupported Persistio memory path: ${params.relPath}`);
218
328
  }
219
- const memories = await client.listMemories();
220
- const memory = memories.find((item) => item.id === memoryId);
329
+ const memory = await client.getMemory(memoryId, { includePending: true });
221
330
  if (!memory) {
222
331
  throw new Error(`Persistio memory not found: ${memoryId}`);
223
332
  }
@@ -275,6 +384,8 @@ export default definePluginEntry({
275
384
  return;
276
385
  }
277
386
  const client = createClient(cfg);
387
+ const sentMessageKeysBySession = new Map();
388
+ const pendingMessageKeysBySession = new Map();
278
389
  api.registerMemoryCapability({
279
390
  runtime: createMemoryRuntime(cfg),
280
391
  });
@@ -286,8 +397,8 @@ export default definePluginEntry({
286
397
  api.on('before_prompt_build', async (event) => {
287
398
  try {
288
399
  const query = buildRecallQuery(event);
289
- const bundle = await client.recallBundle(query);
290
- const block = buildMemoryBlock(bundle, cfg.tokenBudget);
400
+ const recall = await client.recallBundle(query);
401
+ const block = buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
291
402
  if (!block)
292
403
  return;
293
404
  return { appendSystemContext: block };
@@ -307,26 +418,37 @@ export default definePluginEntry({
307
418
  if (sessionId.startsWith('announce:'))
308
419
  return;
309
420
  const chunks = [];
310
- for (const msg of event.messages) {
421
+ const chunkKeys = [];
422
+ const now = Date.now();
423
+ const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
424
+ const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
425
+ for (const [index, msg] of event.messages.entries()) {
311
426
  const m = msg;
312
- const role = m['role'];
313
- if (role !== 'user' && role !== 'assistant')
427
+ const role = normalizeRole(m['role']);
428
+ if (!role || !shouldSendRole(role, cfg))
429
+ continue;
430
+ const text = extractTextFromMessage(msg, ['user', 'assistant', 'tool']);
431
+ if (!text || text.length === 0)
432
+ continue;
433
+ const key = buildMessageFingerprint({ sessionId, msg: m, role, text, index });
434
+ if (sentKeys.has(key) || pendingKeys.has(key))
314
435
  continue;
315
- const text = extractTextFromMessage(msg);
316
- const ts = typeof m['timestamp'] === 'number'
317
- ? new Date(m['timestamp']).toISOString()
318
- : typeof m['timestamp'] === 'string'
319
- ? m['timestamp']
320
- : new Date().toISOString();
321
- if (text && text.length > 0) {
322
- chunks.push({ role: role, content: text, timestamp: ts });
323
- }
436
+ const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
437
+ chunkKeys.push(key);
438
+ chunks.push({ role, content: text, timestamp: ts });
324
439
  }
325
440
  if (chunks.length === 0)
326
441
  return;
327
- // Fire and forget — agent_end is async but result is ignored
328
- client.ingest(sessionId, chunks).catch((err) => {
442
+ rememberKeys(pendingKeys, chunkKeys);
443
+ client.ingest(sessionId, chunks)
444
+ .then(() => {
445
+ rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
446
+ })
447
+ .catch((err) => {
329
448
  api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
449
+ })
450
+ .finally(() => {
451
+ forgetKeys(pendingKeys, chunkKeys);
330
452
  });
331
453
  }
332
454
  catch (err) {
@@ -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.3",
5
+ "version": "0.1.5",
6
6
  "kind": "memory",
7
7
  "activation": {
8
8
  "onStartup": true
@@ -31,8 +31,46 @@
31
31
  "recallTopK": {
32
32
  "type": "number"
33
33
  },
34
+ "recallMinSimilarity": {
35
+ "type": "number",
36
+ "minimum": 0,
37
+ "maximum": 1
38
+ },
34
39
  "recallTimeout": {
35
40
  "type": "number"
41
+ },
42
+ "send": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "properties": {
46
+ "roles": {
47
+ "type": "object",
48
+ "additionalProperties": false,
49
+ "properties": {
50
+ "user": {
51
+ "type": "string",
52
+ "enum": [
53
+ "enabled",
54
+ "disabled"
55
+ ]
56
+ },
57
+ "agent": {
58
+ "type": "string",
59
+ "enum": [
60
+ "enabled",
61
+ "disabled"
62
+ ]
63
+ },
64
+ "tool": {
65
+ "type": "string",
66
+ "enum": [
67
+ "enabled",
68
+ "disabled"
69
+ ]
70
+ }
71
+ }
72
+ }
73
+ }
36
74
  }
37
75
  },
38
76
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@persistio/openclaw-plugin",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "OpenClaw plugin for Persistio \u2014 persistent semantic memory for AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -41,7 +41,8 @@
41
41
  }
42
42
  },
43
43
  "scripts": {
44
- "build": "tsc"
44
+ "build": "tsc",
45
+ "test": "node test/config-schema.test.mjs"
45
46
  },
46
47
  "dependencies": {
47
48
  "@sinclair/typebox": "^0.34.0"
package/src/client.ts CHANGED
@@ -3,7 +3,19 @@ export interface PersistioConfig {
3
3
  apiKey: string;
4
4
  tokenBudget: number;
5
5
  recallTopK: number;
6
+ recallMinSimilarity?: number;
6
7
  recallTimeout: number;
8
+ send: PersistioSendConfig;
9
+ }
10
+
11
+ export type PersistioSendRoleStatus = 'enabled' | 'disabled';
12
+
13
+ export interface PersistioSendConfig {
14
+ roles: {
15
+ user: PersistioSendRoleStatus;
16
+ agent: PersistioSendRoleStatus;
17
+ tool: PersistioSendRoleStatus;
18
+ };
7
19
  }
8
20
 
9
21
  export interface PersistioMemory {
@@ -15,7 +27,12 @@ export interface PersistioMemory {
15
27
  confidence: number;
16
28
  }
17
29
 
30
+ export interface GetMemoryOptions {
31
+ includePending?: boolean;
32
+ }
33
+
18
34
  export interface RecallBundle {
35
+ global_user_rules?: string[];
19
36
  user_rules: string[];
20
37
  user_preferences: string[];
21
38
  task_patterns: string[];
@@ -29,18 +46,21 @@ export interface RecallBundle {
29
46
 
30
47
  export interface RecallBundleResponse {
31
48
  bundle: RecallBundle;
49
+ related_bundle?: RecallBundle;
32
50
  }
33
51
 
34
52
  export class PersistioClient {
35
53
  private readonly baseURL: string;
36
54
  private readonly apiKey: string;
37
55
  private readonly recallTopK: number;
56
+ private readonly recallMinSimilarity?: number;
38
57
  private readonly recallTimeout: number;
39
58
 
40
59
  constructor(config: PersistioConfig) {
41
60
  this.baseURL = config.baseURL.replace(/\/$/, '');
42
61
  this.apiKey = config.apiKey;
43
62
  this.recallTopK = config.recallTopK;
63
+ this.recallMinSimilarity = config.recallMinSimilarity;
44
64
  this.recallTimeout = config.recallTimeout;
45
65
  }
46
66
 
@@ -52,10 +72,15 @@ export class PersistioClient {
52
72
  }
53
73
 
54
74
  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
+
55
80
  const res = await fetch(`${this.baseURL}/v1/recall`, {
56
81
  method: 'POST',
57
82
  headers: this.headers(),
58
- body: JSON.stringify({ query, top_k: this.recallTopK }),
83
+ body: JSON.stringify(body),
59
84
  signal: AbortSignal.timeout(this.recallTimeout),
60
85
  });
61
86
  if (!res.ok) throw new Error(`Persistio recall failed: ${res.status}`);
@@ -63,16 +88,21 @@ export class PersistioClient {
63
88
  return data.memories ?? [];
64
89
  }
65
90
 
66
- async recallBundle(query: string, topK?: number): Promise<RecallBundle> {
91
+ 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
+
67
97
  const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
68
98
  method: 'POST',
69
99
  headers: this.headers(),
70
- body: JSON.stringify({ query, top_k: topK ?? this.recallTopK }),
100
+ body: JSON.stringify(body),
71
101
  signal: AbortSignal.timeout(this.recallTimeout),
72
102
  });
73
103
  if (!res.ok) throw new Error(`Persistio recallBundle failed: ${res.status}`);
74
104
  const data = await res.json() as RecallBundleResponse;
75
- return data.bundle;
105
+ return data;
76
106
  }
77
107
 
78
108
  async ingest(sessionId: string, chunks: Array<{ role: string; content: string; timestamp: string }>): Promise<void> {
@@ -102,6 +132,16 @@ export class PersistioClient {
102
132
  if (!res.ok) throw new Error(`Persistio deleteMemory failed: ${res.status}`);
103
133
  }
104
134
 
135
+ 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(),
139
+ });
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
+ }
144
+
105
145
  async listMemories(): Promise<PersistioMemory[]> {
106
146
  const res = await fetch(`${this.baseURL}/v1/memories`, {
107
147
  headers: this.headers(),
package/src/index.ts CHANGED
@@ -8,6 +8,47 @@ import type {
8
8
  import { Type } from '@sinclair/typebox';
9
9
  import { PersistioClient, type PersistioConfig, type PersistioMemory, type RecallBundle } from './client.js';
10
10
 
11
+ type OpenClawMessageRole = 'user' | 'assistant' | 'tool';
12
+
13
+ interface SessionMessageKeyStore {
14
+ keys: Set<string>;
15
+ lastSeen: number;
16
+ }
17
+
18
+ const DEFAULT_SEND_ROLES: PersistioConfig['send']['roles'] = {
19
+ user: 'enabled',
20
+ agent: 'enabled',
21
+ tool: 'disabled',
22
+ };
23
+
24
+ const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
25
+ const MAX_TRACKED_SESSIONS = 250;
26
+ const MAX_SENT_KEYS_PER_SESSION = 2000;
27
+
28
+ function resolveSendConfig(raw: Record<string, unknown>): PersistioConfig['send'] {
29
+ const send = raw['send'];
30
+ const roles = typeof send === 'object' && send !== null
31
+ ? (send as Record<string, unknown>)['roles']
32
+ : undefined;
33
+ const rawRoles = typeof roles === 'object' && roles !== null
34
+ ? roles as Record<string, unknown>
35
+ : {};
36
+
37
+ return {
38
+ roles: {
39
+ user: rawRoles['user'] === 'disabled' ? 'disabled' : DEFAULT_SEND_ROLES.user,
40
+ agent: rawRoles['agent'] === 'disabled' ? 'disabled' : DEFAULT_SEND_ROLES.agent,
41
+ tool: rawRoles['tool'] === 'enabled' ? 'enabled' : DEFAULT_SEND_ROLES.tool,
42
+ },
43
+ };
44
+ }
45
+
46
+ function resolveRecallMinSimilarity(value: unknown): number | undefined {
47
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
48
+ ? value
49
+ : undefined;
50
+ }
51
+
11
52
  function resolveConfig(raw: unknown): PersistioConfig {
12
53
  const c = (raw ?? {}) as Record<string, unknown>;
13
54
  return {
@@ -15,7 +56,9 @@ function resolveConfig(raw: unknown): PersistioConfig {
15
56
  apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
16
57
  tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
17
58
  recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
59
+ recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
18
60
  recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
61
+ send: resolveSendConfig(c),
19
62
  };
20
63
  }
21
64
 
@@ -90,7 +133,7 @@ function buildRecallQuery(event: { prompt?: string; messages?: unknown[] }): str
90
133
  return truncate(parts.join('\n'), 600);
91
134
  }
92
135
 
93
- function buildMemoryBlock(bundle: RecallBundle, budget: number): string {
136
+ function buildMemoryBlock(bundle: RecallBundle, budget: number, relatedBundle?: RecallBundle): string {
94
137
  const sections: Array<{ title: string; items: string[] }> = [
95
138
  { title: 'Behavioural rules', items: bundle.user_rules },
96
139
  { title: 'Preferences', items: bundle.user_preferences },
@@ -102,6 +145,19 @@ function buildMemoryBlock(bundle: RecallBundle, budget: number): string {
102
145
  { title: 'System facts', items: bundle.system_facts },
103
146
  { title: 'Domain knowledge', items: bundle.domain_knowledge },
104
147
  ];
148
+ if (relatedBundle) {
149
+ 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 },
159
+ );
160
+ }
105
161
 
106
162
  const intro = 'Use the following as prior context and preferences. If they conflict with current instructions, follow the current instructions.';
107
163
  const lines: string[] = [intro];
@@ -136,12 +192,22 @@ function buildMemoryBlock(bundle: RecallBundle, budget: number): string {
136
192
  return lines.length > 1 ? lines.join('\n') : '';
137
193
  }
138
194
 
195
+ function normalizeRole(role: unknown): OpenClawMessageRole | null {
196
+ if (role === 'user' || role === 'assistant' || role === 'tool') return role;
197
+ return null;
198
+ }
199
+
200
+ function shouldSendRole(role: OpenClawMessageRole, config: PersistioConfig): boolean {
201
+ if (role === 'assistant') return config.send.roles.agent === 'enabled';
202
+ return config.send.roles[role] === 'enabled';
203
+ }
204
+
139
205
  /** Extract plain text from a pi-agent-core message content array */
140
- function extractTextFromMessage(msg: unknown): string | null {
206
+ function extractTextFromMessage(msg: unknown, allowedRoles: OpenClawMessageRole[] = ['user', 'assistant']): string | null {
141
207
  if (typeof msg !== 'object' || msg === null) return null;
142
208
  const m = msg as Record<string, unknown>;
143
- const role = m['role'];
144
- if (role !== 'user' && role !== 'assistant') return null;
209
+ const role = normalizeRole(m['role']);
210
+ if (!role || !allowedRoles.includes(role)) return null;
145
211
  const content = m['content'];
146
212
  if (!Array.isArray(content)) {
147
213
  // Some messages have content as a plain string
@@ -160,6 +226,83 @@ function extractTextFromMessage(msg: unknown): string | null {
160
226
  return parts.length > 0 ? parts.join(' ') : null;
161
227
  }
162
228
 
229
+ function resolveMessageTimestamp(msg: Record<string, unknown>): string | null {
230
+ if (typeof msg['timestamp'] === 'number') return new Date(msg['timestamp']).toISOString();
231
+ if (typeof msg['timestamp'] === 'string') return msg['timestamp'];
232
+ return null;
233
+ }
234
+
235
+ function hashString(input: string): string {
236
+ let hash = 0x811c9dc5;
237
+ for (let i = 0; i < input.length; i += 1) {
238
+ hash ^= input.charCodeAt(i);
239
+ hash = Math.imul(hash, 0x01000193);
240
+ }
241
+ return (hash >>> 0).toString(16);
242
+ }
243
+
244
+ function buildMessageFingerprint(params: {
245
+ sessionId: string;
246
+ msg: Record<string, unknown>;
247
+ role: OpenClawMessageRole;
248
+ text: string;
249
+ index: number;
250
+ }): string {
251
+ const id = params.msg['id'];
252
+ if (typeof id === 'string' && id.length > 0) {
253
+ return `id:${params.sessionId}:${id}`;
254
+ }
255
+
256
+ const idempotencyKey = params.msg['idempotencyKey'];
257
+ if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) {
258
+ return `idempotency:${params.sessionId}:${idempotencyKey}`;
259
+ }
260
+
261
+ const timestamp = resolveMessageTimestamp(params.msg);
262
+ const basis = timestamp ?? `index:${params.index}`;
263
+ return `content:${params.sessionId}:${basis}:${params.role}:${hashString(params.text)}`;
264
+ }
265
+
266
+ function pruneSessionKeyStores(stores: Map<string, SessionMessageKeyStore>, now: number): void {
267
+ for (const [sessionId, store] of stores) {
268
+ if (now - store.lastSeen > MESSAGE_KEY_TTL_MS) stores.delete(sessionId);
269
+ }
270
+
271
+ while (stores.size > MAX_TRACKED_SESSIONS) {
272
+ const oldest = [...stores.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen)[0];
273
+ if (!oldest) return;
274
+ stores.delete(oldest[0]);
275
+ }
276
+ }
277
+
278
+ function getSessionKeyStore(stores: Map<string, SessionMessageKeyStore>, sessionId: string, now: number): Set<string> {
279
+ pruneSessionKeyStores(stores, now);
280
+ const existing = stores.get(sessionId);
281
+ if (existing) {
282
+ existing.lastSeen = now;
283
+ return existing.keys;
284
+ }
285
+
286
+ const created: SessionMessageKeyStore = { keys: new Set(), lastSeen: now };
287
+ stores.set(sessionId, created);
288
+ return created.keys;
289
+ }
290
+
291
+ function rememberKeys(target: Set<string>, keys: string[], limit = Number.POSITIVE_INFINITY): void {
292
+ for (const key of keys) {
293
+ target.add(key);
294
+ while (target.size > limit) {
295
+ const oldest = target.values().next().value as string | undefined;
296
+ if (!oldest) break;
297
+ target.delete(oldest);
298
+ }
299
+ }
300
+ }
301
+
302
+ function forgetKeys(target: Set<string>, keys: string[]): void {
303
+ for (const key of keys) target.delete(key);
304
+ }
305
+
163
306
  const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
164
307
 
165
308
  function createClient(config: PersistioConfig, recallTopK = config.recallTopK): PersistioClient {
@@ -260,8 +403,7 @@ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager
260
403
  throw new Error(`Unsupported Persistio memory path: ${params.relPath}`);
261
404
  }
262
405
 
263
- const memories = await client.listMemories();
264
- const memory = memories.find((item) => item.id === memoryId);
406
+ const memory = await client.getMemory(memoryId, { includePending: true });
265
407
  if (!memory) {
266
408
  throw new Error(`Persistio memory not found: ${memoryId}`);
267
409
  }
@@ -329,6 +471,8 @@ export default definePluginEntry({
329
471
  }
330
472
 
331
473
  const client = createClient(cfg);
474
+ const sentMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
475
+ const pendingMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
332
476
  api.registerMemoryCapability({
333
477
  runtime: createMemoryRuntime(cfg),
334
478
  });
@@ -341,8 +485,8 @@ export default definePluginEntry({
341
485
  api.on('before_prompt_build', async (event) => {
342
486
  try {
343
487
  const query = buildRecallQuery(event);
344
- const bundle = await client.recallBundle(query);
345
- const block = buildMemoryBlock(bundle, cfg.tokenBudget);
488
+ const recall = await client.recallBundle(query);
489
+ const block = buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
346
490
  if (!block) return;
347
491
  return { appendSystemContext: block };
348
492
  } catch (err) {
@@ -360,27 +504,38 @@ export default definePluginEntry({
360
504
  const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
361
505
  if (sessionId.startsWith('announce:')) return;
362
506
  const chunks: Array<{ role: string; content: string; timestamp: string }> = [];
507
+ const chunkKeys: string[] = [];
508
+ const now = Date.now();
509
+ const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
510
+ const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
363
511
 
364
- for (const msg of event.messages) {
512
+ for (const [index, msg] of event.messages.entries()) {
365
513
  const m = msg as Record<string, unknown>;
366
- const role = m['role'];
367
- if (role !== 'user' && role !== 'assistant') continue;
368
- const text = extractTextFromMessage(msg);
369
- const ts = typeof m['timestamp'] === 'number'
370
- ? new Date(m['timestamp']).toISOString()
371
- : typeof m['timestamp'] === 'string'
372
- ? m['timestamp']
373
- : new Date().toISOString();
374
- if (text && text.length > 0) {
375
- chunks.push({ role: role as string, content: text, timestamp: ts });
376
- }
514
+ const role = normalizeRole(m['role']);
515
+ if (!role || !shouldSendRole(role, cfg)) continue;
516
+ const text = extractTextFromMessage(msg, ['user', 'assistant', 'tool']);
517
+ if (!text || text.length === 0) continue;
518
+
519
+ const key = buildMessageFingerprint({ sessionId, msg: m, role, text, index });
520
+ if (sentKeys.has(key) || pendingKeys.has(key)) continue;
521
+
522
+ const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
523
+ chunkKeys.push(key);
524
+ chunks.push({ role, content: text, timestamp: ts });
377
525
  }
378
526
 
379
527
  if (chunks.length === 0) return;
380
- // Fire and forget — agent_end is async but result is ignored
381
- client.ingest(sessionId, chunks).catch((err: unknown) => {
382
- api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
383
- });
528
+ rememberKeys(pendingKeys, chunkKeys);
529
+ client.ingest(sessionId, chunks)
530
+ .then(() => {
531
+ rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
532
+ })
533
+ .catch((err: unknown) => {
534
+ api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
535
+ })
536
+ .finally(() => {
537
+ forgetKeys(pendingKeys, chunkKeys);
538
+ });
384
539
  } catch (err) {
385
540
  api.logger?.warn?.(`openclaw-persistio: agent_end error: ${String(err)}`);
386
541
  }