@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 +13 -1
- package/dist/client.d.ts +9 -0
- package/dist/index.js +260 -20
- package/openclaw.plugin.json +34 -1
- package/package.json +1 -1
- package/src/client.ts +11 -0
- package/src/index.ts +324 -23
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
202
|
-
client.ingest(sessionId, chunks)
|
|
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) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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
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
|
|
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 =
|
|
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
|
|
203
|
-
const text = extractTextFromMessage(msg);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
216
|
-
client.ingest(sessionId, chunks)
|
|
217
|
-
|
|
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
|
}
|