@persistio/openclaw-plugin 0.1.2 → 0.1.4

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,14 @@ 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
+ "send": {
30
+ "roles": {
31
+ "user": "enabled",
32
+ "agent": "enabled",
33
+ "tool": "disabled"
34
+ }
35
+ }
29
36
  }
30
37
  }
31
38
  }
@@ -42,6 +49,11 @@ Then register it in your OpenClaw config:
42
49
  | `tokenBudget` | number | | `2000` | Max tokens to inject into the system prompt |
43
50
  | `recallTopK` | number | | `10` | Number of memories to retrieve per recall |
44
51
  | `recallTimeout` | number | | `5000` | HTTP timeout for recall requests (ms) |
52
+ | `send.roles.user` | `"enabled"` or `"disabled"` | | `"enabled"` | Send user messages to Persistio ingest |
53
+ | `send.roles.agent` | `"enabled"` or `"disabled"` | | `"enabled"` | Send agent/assistant messages to Persistio ingest |
54
+ | `send.roles.tool` | `"enabled"` or `"disabled"` | | `"disabled"` | Send tool messages to Persistio ingest |
55
+
56
+ `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
57
 
46
58
  ## Tools exposed
47
59
 
package/dist/client.d.ts CHANGED
@@ -4,6 +4,15 @@ export interface PersistioConfig {
4
4
  tokenBudget: number;
5
5
  recallTopK: number;
6
6
  recallTimeout: number;
7
+ send: PersistioSendConfig;
8
+ }
9
+ export type PersistioSendRoleStatus = 'enabled' | 'disabled';
10
+ export interface PersistioSendConfig {
11
+ roles: {
12
+ user: PersistioSendRoleStatus;
13
+ agent: PersistioSendRoleStatus;
14
+ tool: PersistioSendRoleStatus;
15
+ };
7
16
  }
8
17
  export interface PersistioMemory {
9
18
  id: string;
package/dist/index.js CHANGED
@@ -1,6 +1,30 @@
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
+ }
4
28
  function resolveConfig(raw) {
5
29
  const c = (raw ?? {});
6
30
  return {
@@ -9,6 +33,7 @@ function resolveConfig(raw) {
9
33
  tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
10
34
  recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
11
35
  recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
36
+ send: resolveSendConfig(c),
12
37
  };
13
38
  }
14
39
  function estimateTokens(text) {
@@ -117,13 +142,23 @@ function buildMemoryBlock(bundle, budget) {
117
142
  }
118
143
  return lines.length > 1 ? lines.join('\n') : '';
119
144
  }
145
+ function normalizeRole(role) {
146
+ if (role === 'user' || role === 'assistant' || role === 'tool')
147
+ return role;
148
+ return null;
149
+ }
150
+ function shouldSendRole(role, config) {
151
+ if (role === 'assistant')
152
+ return config.send.roles.agent === 'enabled';
153
+ return config.send.roles[role] === 'enabled';
154
+ }
120
155
  /** Extract plain text from a pi-agent-core message content array */
121
- function extractTextFromMessage(msg) {
156
+ function extractTextFromMessage(msg, allowedRoles = ['user', 'assistant']) {
122
157
  if (typeof msg !== 'object' || msg === null)
123
158
  return null;
124
159
  const m = msg;
125
- const role = m['role'];
126
- if (role !== 'user' && role !== 'assistant')
160
+ const role = normalizeRole(m['role']);
161
+ if (!role || !allowedRoles.includes(role))
127
162
  return null;
128
163
  const content = m['content'];
129
164
  if (!Array.isArray(content)) {
@@ -143,6 +178,193 @@ function extractTextFromMessage(msg) {
143
178
  }
144
179
  return parts.length > 0 ? parts.join(' ') : null;
145
180
  }
181
+ function resolveMessageTimestamp(msg) {
182
+ if (typeof msg['timestamp'] === 'number')
183
+ return new Date(msg['timestamp']).toISOString();
184
+ if (typeof msg['timestamp'] === 'string')
185
+ return msg['timestamp'];
186
+ return null;
187
+ }
188
+ function hashString(input) {
189
+ let hash = 0x811c9dc5;
190
+ for (let i = 0; i < input.length; i += 1) {
191
+ hash ^= input.charCodeAt(i);
192
+ hash = Math.imul(hash, 0x01000193);
193
+ }
194
+ return (hash >>> 0).toString(16);
195
+ }
196
+ function buildMessageFingerprint(params) {
197
+ const id = params.msg['id'];
198
+ if (typeof id === 'string' && id.length > 0) {
199
+ return `id:${params.sessionId}:${id}`;
200
+ }
201
+ const idempotencyKey = params.msg['idempotencyKey'];
202
+ if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) {
203
+ return `idempotency:${params.sessionId}:${idempotencyKey}`;
204
+ }
205
+ const timestamp = resolveMessageTimestamp(params.msg);
206
+ const basis = timestamp ?? `index:${params.index}`;
207
+ return `content:${params.sessionId}:${basis}:${params.role}:${hashString(params.text)}`;
208
+ }
209
+ function pruneSessionKeyStores(stores, now) {
210
+ for (const [sessionId, store] of stores) {
211
+ if (now - store.lastSeen > MESSAGE_KEY_TTL_MS)
212
+ stores.delete(sessionId);
213
+ }
214
+ while (stores.size > MAX_TRACKED_SESSIONS) {
215
+ const oldest = [...stores.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen)[0];
216
+ if (!oldest)
217
+ return;
218
+ stores.delete(oldest[0]);
219
+ }
220
+ }
221
+ function getSessionKeyStore(stores, sessionId, now) {
222
+ pruneSessionKeyStores(stores, now);
223
+ const existing = stores.get(sessionId);
224
+ if (existing) {
225
+ existing.lastSeen = now;
226
+ return existing.keys;
227
+ }
228
+ const created = { keys: new Set(), lastSeen: now };
229
+ stores.set(sessionId, created);
230
+ return created.keys;
231
+ }
232
+ function rememberKeys(target, keys, limit = Number.POSITIVE_INFINITY) {
233
+ for (const key of keys) {
234
+ target.add(key);
235
+ while (target.size > limit) {
236
+ const oldest = target.values().next().value;
237
+ if (!oldest)
238
+ break;
239
+ target.delete(oldest);
240
+ }
241
+ }
242
+ }
243
+ function forgetKeys(target, keys) {
244
+ for (const key of keys)
245
+ target.delete(key);
246
+ }
247
+ const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
248
+ function createClient(config, recallTopK = config.recallTopK) {
249
+ return new PersistioClient({ ...config, recallTopK });
250
+ }
251
+ function normalizeMemoryScore(memory) {
252
+ if (typeof memory.similarity === 'number' && Number.isFinite(memory.similarity)) {
253
+ return memory.similarity;
254
+ }
255
+ if (Number.isFinite(memory.confidence)) {
256
+ return memory.confidence > 1 ? memory.confidence / 100 : memory.confidence;
257
+ }
258
+ return 0;
259
+ }
260
+ function buildMemoryPath(id) {
261
+ return `${PERSISTIO_MEMORY_PATH_PREFIX}${id}`;
262
+ }
263
+ function parseMemoryPath(relPath) {
264
+ return relPath.startsWith(PERSISTIO_MEMORY_PATH_PREFIX)
265
+ ? relPath.slice(PERSISTIO_MEMORY_PATH_PREFIX.length)
266
+ : null;
267
+ }
268
+ function formatMemoryDocument(memory) {
269
+ const lines = [
270
+ `Subject: ${memory.subject}`,
271
+ `Memory ID: ${memory.id}`,
272
+ `Confidence: ${memory.confidence}`,
273
+ ];
274
+ if (memory.categories.length > 0) {
275
+ lines.push(`Categories: ${memory.categories.join(', ')}`);
276
+ }
277
+ lines.push('', memory.data);
278
+ return lines.join('\n');
279
+ }
280
+ async function probePersistio(client) {
281
+ try {
282
+ await client.recall('__openclaw_probe__');
283
+ return { ok: true };
284
+ }
285
+ catch (err) {
286
+ return { ok: false, error: String(err) };
287
+ }
288
+ }
289
+ function createMemorySearchManager(config) {
290
+ const client = createClient(config);
291
+ return {
292
+ async search(query, opts) {
293
+ if (opts?.sources && !opts.sources.includes('memory')) {
294
+ return [];
295
+ }
296
+ const recallTopK = typeof opts?.maxResults === 'number' ? opts.maxResults : config.recallTopK;
297
+ const recallClient = createClient(config, recallTopK);
298
+ const memories = await recallClient.recall(query);
299
+ return memories
300
+ .map((memory) => {
301
+ const score = normalizeMemoryScore(memory);
302
+ return {
303
+ path: buildMemoryPath(memory.id),
304
+ startLine: 1,
305
+ endLine: 1,
306
+ score,
307
+ vectorScore: typeof memory.similarity === 'number' ? memory.similarity : undefined,
308
+ snippet: truncate(memory.data, 400),
309
+ source: 'memory',
310
+ citation: memory.subject,
311
+ };
312
+ })
313
+ .filter((result) => opts?.minScore === undefined || result.score >= opts.minScore);
314
+ },
315
+ async readFile(params) {
316
+ const memoryId = parseMemoryPath(params.relPath);
317
+ if (!memoryId) {
318
+ throw new Error(`Unsupported Persistio memory path: ${params.relPath}`);
319
+ }
320
+ const memories = await client.listMemories();
321
+ const memory = memories.find((item) => item.id === memoryId);
322
+ if (!memory) {
323
+ throw new Error(`Persistio memory not found: ${memoryId}`);
324
+ }
325
+ const text = formatMemoryDocument(memory);
326
+ return {
327
+ path: params.relPath,
328
+ text,
329
+ truncated: false,
330
+ from: params.from ?? 1,
331
+ lines: params.lines,
332
+ };
333
+ },
334
+ status() {
335
+ return {
336
+ backend: 'builtin',
337
+ provider: 'persistio',
338
+ sources: ['memory'],
339
+ vector: {
340
+ enabled: true,
341
+ },
342
+ custom: {
343
+ baseURL: config.baseURL,
344
+ },
345
+ };
346
+ },
347
+ async probeEmbeddingAvailability() {
348
+ return probePersistio(client);
349
+ },
350
+ async probeVectorAvailability() {
351
+ const probe = await probePersistio(client);
352
+ return probe.ok;
353
+ },
354
+ };
355
+ }
356
+ function createMemoryRuntime(config) {
357
+ return {
358
+ async getMemorySearchManager() {
359
+ return {
360
+ manager: createMemorySearchManager(config),
361
+ };
362
+ },
363
+ resolveMemoryBackendConfig() {
364
+ return { backend: 'builtin' };
365
+ },
366
+ };
367
+ }
146
368
  export default definePluginEntry({
147
369
  id: 'openclaw-persistio',
148
370
  name: 'Persistio Memory',
@@ -153,7 +375,12 @@ export default definePluginEntry({
153
375
  api.logger?.warn?.('openclaw-persistio: baseURL and apiKey are required. Plugin disabled.');
154
376
  return;
155
377
  }
156
- const client = new PersistioClient(cfg);
378
+ const client = createClient(cfg);
379
+ const sentMessageKeysBySession = new Map();
380
+ const pendingMessageKeysBySession = new Map();
381
+ api.registerMemoryCapability({
382
+ runtime: createMemoryRuntime(cfg),
383
+ });
157
384
  // -------------------------------------------------------------------------
158
385
  // before_prompt_build — recall relevant memories and inject into context
159
386
  // Event: { prompt: string, messages: unknown[] }
@@ -177,30 +404,43 @@ export default definePluginEntry({
177
404
  // Event: { runId?, messages: unknown[], success: boolean, error?, durationMs? }
178
405
  // Observation only — no return value.
179
406
  // -------------------------------------------------------------------------
180
- api.on('agent_end', async (event) => {
407
+ api.on('agent_end', async (event, context) => {
181
408
  try {
182
- const sessionId = event.runId ?? 'unknown-session';
409
+ const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
410
+ if (sessionId.startsWith('announce:'))
411
+ return;
183
412
  const chunks = [];
184
- for (const msg of event.messages) {
413
+ const chunkKeys = [];
414
+ const now = Date.now();
415
+ const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
416
+ const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
417
+ for (const [index, msg] of event.messages.entries()) {
185
418
  const m = msg;
186
- const role = m['role'];
187
- if (role !== 'user' && role !== 'assistant')
419
+ const role = normalizeRole(m['role']);
420
+ if (!role || !shouldSendRole(role, cfg))
421
+ continue;
422
+ const text = extractTextFromMessage(msg, ['user', 'assistant', 'tool']);
423
+ if (!text || text.length === 0)
424
+ continue;
425
+ const key = buildMessageFingerprint({ sessionId, msg: m, role, text, index });
426
+ if (sentKeys.has(key) || pendingKeys.has(key))
188
427
  continue;
189
- const text = extractTextFromMessage(msg);
190
- const ts = typeof m['timestamp'] === 'number'
191
- ? new Date(m['timestamp']).toISOString()
192
- : typeof m['timestamp'] === 'string'
193
- ? m['timestamp']
194
- : new Date().toISOString();
195
- if (text && text.length > 0) {
196
- chunks.push({ role: role, content: text, timestamp: ts });
197
- }
428
+ const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
429
+ chunkKeys.push(key);
430
+ chunks.push({ role, content: text, timestamp: ts });
198
431
  }
199
432
  if (chunks.length === 0)
200
433
  return;
201
- // Fire and forget — agent_end is async but result is ignored
202
- client.ingest(sessionId, chunks).catch((err) => {
434
+ rememberKeys(pendingKeys, chunkKeys);
435
+ client.ingest(sessionId, chunks)
436
+ .then(() => {
437
+ rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
438
+ })
439
+ .catch((err) => {
203
440
  api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
441
+ })
442
+ .finally(() => {
443
+ forgetKeys(pendingKeys, chunkKeys);
204
444
  });
205
445
  }
206
446
  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.2",
5
+ "version": "0.1.4",
6
6
  "kind": "memory",
7
7
  "activation": {
8
8
  "onStartup": true
@@ -33,6 +33,39 @@
33
33
  },
34
34
  "recallTimeout": {
35
35
  "type": "number"
36
+ },
37
+ "send": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "properties": {
41
+ "roles": {
42
+ "type": "object",
43
+ "additionalProperties": false,
44
+ "properties": {
45
+ "user": {
46
+ "type": "string",
47
+ "enum": [
48
+ "enabled",
49
+ "disabled"
50
+ ]
51
+ },
52
+ "agent": {
53
+ "type": "string",
54
+ "enum": [
55
+ "enabled",
56
+ "disabled"
57
+ ]
58
+ },
59
+ "tool": {
60
+ "type": "string",
61
+ "enum": [
62
+ "enabled",
63
+ "disabled"
64
+ ]
65
+ }
66
+ }
67
+ }
68
+ }
36
69
  }
37
70
  },
38
71
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@persistio/openclaw-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "OpenClaw plugin for Persistio \u2014 persistent semantic memory for AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/client.ts CHANGED
@@ -4,6 +4,17 @@ export interface PersistioConfig {
4
4
  tokenBudget: number;
5
5
  recallTopK: number;
6
6
  recallTimeout: number;
7
+ send: PersistioSendConfig;
8
+ }
9
+
10
+ export type PersistioSendRoleStatus = 'enabled' | 'disabled';
11
+
12
+ export interface PersistioSendConfig {
13
+ roles: {
14
+ user: PersistioSendRoleStatus;
15
+ agent: PersistioSendRoleStatus;
16
+ tool: PersistioSendRoleStatus;
17
+ };
7
18
  }
8
19
 
9
20
  export interface PersistioMemory {
package/src/index.ts CHANGED
@@ -1,6 +1,47 @@
1
1
  import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
2
+ import type {
3
+ MemoryEmbeddingProbeResult,
4
+ MemoryProviderStatus,
5
+ MemorySearchManager,
6
+ MemorySearchResult,
7
+ } from 'openclaw/plugin-sdk/memory-core-host-engine-storage';
2
8
  import { Type } from '@sinclair/typebox';
3
- import { PersistioClient, type PersistioConfig, type RecallBundle } from './client.js';
9
+ import { PersistioClient, type PersistioConfig, type PersistioMemory, type RecallBundle } from './client.js';
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
+ }
4
45
 
5
46
  function resolveConfig(raw: unknown): PersistioConfig {
6
47
  const c = (raw ?? {}) as Record<string, unknown>;
@@ -10,6 +51,7 @@ function resolveConfig(raw: unknown): PersistioConfig {
10
51
  tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
11
52
  recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
12
53
  recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
54
+ send: resolveSendConfig(c),
13
55
  };
14
56
  }
15
57
 
@@ -130,12 +172,22 @@ function buildMemoryBlock(bundle: RecallBundle, budget: number): string {
130
172
  return lines.length > 1 ? lines.join('\n') : '';
131
173
  }
132
174
 
175
+ function normalizeRole(role: unknown): OpenClawMessageRole | null {
176
+ if (role === 'user' || role === 'assistant' || role === 'tool') return role;
177
+ return null;
178
+ }
179
+
180
+ function shouldSendRole(role: OpenClawMessageRole, config: PersistioConfig): boolean {
181
+ if (role === 'assistant') return config.send.roles.agent === 'enabled';
182
+ return config.send.roles[role] === 'enabled';
183
+ }
184
+
133
185
  /** Extract plain text from a pi-agent-core message content array */
134
- function extractTextFromMessage(msg: unknown): string | null {
186
+ function extractTextFromMessage(msg: unknown, allowedRoles: OpenClawMessageRole[] = ['user', 'assistant']): string | null {
135
187
  if (typeof msg !== 'object' || msg === null) return null;
136
188
  const m = msg as Record<string, unknown>;
137
- const role = m['role'];
138
- if (role !== 'user' && role !== 'assistant') return null;
189
+ const role = normalizeRole(m['role']);
190
+ if (!role || !allowedRoles.includes(role)) return null;
139
191
  const content = m['content'];
140
192
  if (!Array.isArray(content)) {
141
193
  // Some messages have content as a plain string
@@ -154,6 +206,238 @@ function extractTextFromMessage(msg: unknown): string | null {
154
206
  return parts.length > 0 ? parts.join(' ') : null;
155
207
  }
156
208
 
209
+ function resolveMessageTimestamp(msg: Record<string, unknown>): string | null {
210
+ if (typeof msg['timestamp'] === 'number') return new Date(msg['timestamp']).toISOString();
211
+ if (typeof msg['timestamp'] === 'string') return msg['timestamp'];
212
+ return null;
213
+ }
214
+
215
+ function hashString(input: string): string {
216
+ let hash = 0x811c9dc5;
217
+ for (let i = 0; i < input.length; i += 1) {
218
+ hash ^= input.charCodeAt(i);
219
+ hash = Math.imul(hash, 0x01000193);
220
+ }
221
+ return (hash >>> 0).toString(16);
222
+ }
223
+
224
+ function buildMessageFingerprint(params: {
225
+ sessionId: string;
226
+ msg: Record<string, unknown>;
227
+ role: OpenClawMessageRole;
228
+ text: string;
229
+ index: number;
230
+ }): string {
231
+ const id = params.msg['id'];
232
+ if (typeof id === 'string' && id.length > 0) {
233
+ return `id:${params.sessionId}:${id}`;
234
+ }
235
+
236
+ const idempotencyKey = params.msg['idempotencyKey'];
237
+ if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) {
238
+ return `idempotency:${params.sessionId}:${idempotencyKey}`;
239
+ }
240
+
241
+ const timestamp = resolveMessageTimestamp(params.msg);
242
+ const basis = timestamp ?? `index:${params.index}`;
243
+ return `content:${params.sessionId}:${basis}:${params.role}:${hashString(params.text)}`;
244
+ }
245
+
246
+ function pruneSessionKeyStores(stores: Map<string, SessionMessageKeyStore>, now: number): void {
247
+ for (const [sessionId, store] of stores) {
248
+ if (now - store.lastSeen > MESSAGE_KEY_TTL_MS) stores.delete(sessionId);
249
+ }
250
+
251
+ while (stores.size > MAX_TRACKED_SESSIONS) {
252
+ const oldest = [...stores.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen)[0];
253
+ if (!oldest) return;
254
+ stores.delete(oldest[0]);
255
+ }
256
+ }
257
+
258
+ function getSessionKeyStore(stores: Map<string, SessionMessageKeyStore>, sessionId: string, now: number): Set<string> {
259
+ pruneSessionKeyStores(stores, now);
260
+ const existing = stores.get(sessionId);
261
+ if (existing) {
262
+ existing.lastSeen = now;
263
+ return existing.keys;
264
+ }
265
+
266
+ const created: SessionMessageKeyStore = { keys: new Set(), lastSeen: now };
267
+ stores.set(sessionId, created);
268
+ return created.keys;
269
+ }
270
+
271
+ function rememberKeys(target: Set<string>, keys: string[], limit = Number.POSITIVE_INFINITY): void {
272
+ for (const key of keys) {
273
+ target.add(key);
274
+ while (target.size > limit) {
275
+ const oldest = target.values().next().value as string | undefined;
276
+ if (!oldest) break;
277
+ target.delete(oldest);
278
+ }
279
+ }
280
+ }
281
+
282
+ function forgetKeys(target: Set<string>, keys: string[]): void {
283
+ for (const key of keys) target.delete(key);
284
+ }
285
+
286
+ const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
287
+
288
+ function createClient(config: PersistioConfig, recallTopK = config.recallTopK): PersistioClient {
289
+ return new PersistioClient({ ...config, recallTopK });
290
+ }
291
+
292
+ function normalizeMemoryScore(memory: PersistioMemory): number {
293
+ if (typeof memory.similarity === 'number' && Number.isFinite(memory.similarity)) {
294
+ return memory.similarity;
295
+ }
296
+ if (Number.isFinite(memory.confidence)) {
297
+ return memory.confidence > 1 ? memory.confidence / 100 : memory.confidence;
298
+ }
299
+ return 0;
300
+ }
301
+
302
+ function buildMemoryPath(id: string): string {
303
+ return `${PERSISTIO_MEMORY_PATH_PREFIX}${id}`;
304
+ }
305
+
306
+ function parseMemoryPath(relPath: string): string | null {
307
+ return relPath.startsWith(PERSISTIO_MEMORY_PATH_PREFIX)
308
+ ? relPath.slice(PERSISTIO_MEMORY_PATH_PREFIX.length)
309
+ : null;
310
+ }
311
+
312
+ function formatMemoryDocument(memory: PersistioMemory): string {
313
+ const lines = [
314
+ `Subject: ${memory.subject}`,
315
+ `Memory ID: ${memory.id}`,
316
+ `Confidence: ${memory.confidence}`,
317
+ ];
318
+
319
+ if (memory.categories.length > 0) {
320
+ lines.push(`Categories: ${memory.categories.join(', ')}`);
321
+ }
322
+
323
+ lines.push('', memory.data);
324
+ return lines.join('\n');
325
+ }
326
+
327
+ async function probePersistio(client: PersistioClient): Promise<MemoryEmbeddingProbeResult> {
328
+ try {
329
+ await client.recall('__openclaw_probe__');
330
+ return { ok: true };
331
+ } catch (err) {
332
+ return { ok: false, error: String(err) };
333
+ }
334
+ }
335
+
336
+ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager {
337
+ const client = createClient(config);
338
+
339
+ return {
340
+ async search(
341
+ query: string,
342
+ opts?: {
343
+ maxResults?: number;
344
+ minScore?: number;
345
+ sessionKey?: string;
346
+ qmdSearchModeOverride?: 'query' | 'search' | 'vsearch';
347
+ onDebug?: (debug: unknown) => void;
348
+ sources?: Array<'memory' | 'sessions'>;
349
+ },
350
+ ) {
351
+ if (opts?.sources && !opts.sources.includes('memory')) {
352
+ return [];
353
+ }
354
+
355
+ const recallTopK = typeof opts?.maxResults === 'number' ? opts.maxResults : config.recallTopK;
356
+ const recallClient = createClient(config, recallTopK);
357
+ const memories = await recallClient.recall(query);
358
+
359
+ return memories
360
+ .map((memory): MemorySearchResult => {
361
+ const score = normalizeMemoryScore(memory);
362
+ return {
363
+ path: buildMemoryPath(memory.id),
364
+ startLine: 1,
365
+ endLine: 1,
366
+ score,
367
+ vectorScore: typeof memory.similarity === 'number' ? memory.similarity : undefined,
368
+ snippet: truncate(memory.data, 400),
369
+ source: 'memory',
370
+ citation: memory.subject,
371
+ };
372
+ })
373
+ .filter((result) => opts?.minScore === undefined || result.score >= opts.minScore);
374
+ },
375
+
376
+ async readFile(params: {
377
+ relPath: string;
378
+ from?: number;
379
+ lines?: number;
380
+ }) {
381
+ const memoryId = parseMemoryPath(params.relPath);
382
+ if (!memoryId) {
383
+ throw new Error(`Unsupported Persistio memory path: ${params.relPath}`);
384
+ }
385
+
386
+ const memories = await client.listMemories();
387
+ const memory = memories.find((item) => item.id === memoryId);
388
+ if (!memory) {
389
+ throw new Error(`Persistio memory not found: ${memoryId}`);
390
+ }
391
+
392
+ const text = formatMemoryDocument(memory);
393
+ return {
394
+ path: params.relPath,
395
+ text,
396
+ truncated: false,
397
+ from: params.from ?? 1,
398
+ lines: params.lines,
399
+ };
400
+ },
401
+
402
+ status(): MemoryProviderStatus {
403
+ return {
404
+ backend: 'builtin',
405
+ provider: 'persistio',
406
+ sources: ['memory'],
407
+ vector: {
408
+ enabled: true,
409
+ },
410
+ custom: {
411
+ baseURL: config.baseURL,
412
+ },
413
+ };
414
+ },
415
+
416
+ async probeEmbeddingAvailability() {
417
+ return probePersistio(client);
418
+ },
419
+
420
+ async probeVectorAvailability() {
421
+ const probe = await probePersistio(client);
422
+ return probe.ok;
423
+ },
424
+ };
425
+ }
426
+
427
+ function createMemoryRuntime(config: PersistioConfig) {
428
+ return {
429
+ async getMemorySearchManager() {
430
+ return {
431
+ manager: createMemorySearchManager(config),
432
+ };
433
+ },
434
+
435
+ resolveMemoryBackendConfig() {
436
+ return { backend: 'builtin' as const };
437
+ },
438
+ };
439
+ }
440
+
157
441
  export default definePluginEntry({
158
442
  id: 'openclaw-persistio',
159
443
  name: 'Persistio Memory',
@@ -167,7 +451,12 @@ export default definePluginEntry({
167
451
  return;
168
452
  }
169
453
 
170
- const client = new PersistioClient(cfg);
454
+ const client = createClient(cfg);
455
+ const sentMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
456
+ const pendingMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
457
+ api.registerMemoryCapability({
458
+ runtime: createMemoryRuntime(cfg),
459
+ });
171
460
 
172
461
  // -------------------------------------------------------------------------
173
462
  // before_prompt_build — recall relevant memories and inject into context
@@ -191,31 +480,43 @@ export default definePluginEntry({
191
480
  // Event: { runId?, messages: unknown[], success: boolean, error?, durationMs? }
192
481
  // Observation only — no return value.
193
482
  // -------------------------------------------------------------------------
194
- api.on('agent_end', async (event) => {
483
+ api.on('agent_end', async (event, context) => {
195
484
  try {
196
- const sessionId = event.runId ?? 'unknown-session';
485
+ const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
486
+ if (sessionId.startsWith('announce:')) return;
197
487
  const chunks: Array<{ role: string; content: string; timestamp: string }> = [];
488
+ const chunkKeys: string[] = [];
489
+ const now = Date.now();
490
+ const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
491
+ const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
198
492
 
199
- for (const msg of event.messages) {
493
+ for (const [index, msg] of event.messages.entries()) {
200
494
  const m = msg as Record<string, unknown>;
201
- const role = m['role'];
202
- if (role !== 'user' && role !== 'assistant') continue;
203
- const text = extractTextFromMessage(msg);
204
- const ts = typeof m['timestamp'] === 'number'
205
- ? new Date(m['timestamp']).toISOString()
206
- : typeof m['timestamp'] === 'string'
207
- ? m['timestamp']
208
- : new Date().toISOString();
209
- if (text && text.length > 0) {
210
- chunks.push({ role: role as string, content: text, timestamp: ts });
211
- }
495
+ const role = normalizeRole(m['role']);
496
+ if (!role || !shouldSendRole(role, cfg)) continue;
497
+ const text = extractTextFromMessage(msg, ['user', 'assistant', 'tool']);
498
+ if (!text || text.length === 0) continue;
499
+
500
+ const key = buildMessageFingerprint({ sessionId, msg: m, role, text, index });
501
+ if (sentKeys.has(key) || pendingKeys.has(key)) continue;
502
+
503
+ const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
504
+ chunkKeys.push(key);
505
+ chunks.push({ role, content: text, timestamp: ts });
212
506
  }
213
507
 
214
508
  if (chunks.length === 0) return;
215
- // Fire and forget — agent_end is async but result is ignored
216
- client.ingest(sessionId, chunks).catch((err: unknown) => {
217
- api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
218
- });
509
+ rememberKeys(pendingKeys, chunkKeys);
510
+ client.ingest(sessionId, chunks)
511
+ .then(() => {
512
+ rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
513
+ })
514
+ .catch((err: unknown) => {
515
+ api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
516
+ })
517
+ .finally(() => {
518
+ forgetKeys(pendingKeys, chunkKeys);
519
+ });
219
520
  } catch (err) {
220
521
  api.logger?.warn?.(`openclaw-persistio: agent_end error: ${String(err)}`);
221
522
  }