@persistio/openclaw-plugin 0.1.3 → 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 +131 -17
- package/openclaw.plugin.json +34 -1
- package/package.json +1 -1
- package/src/client.ts +11 -0
- package/src/index.ts +155 -19
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,72 @@ 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
|
+
}
|
|
146
247
|
const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
|
|
147
248
|
function createClient(config, recallTopK = config.recallTopK) {
|
|
148
249
|
return new PersistioClient({ ...config, recallTopK });
|
|
@@ -275,6 +376,8 @@ export default definePluginEntry({
|
|
|
275
376
|
return;
|
|
276
377
|
}
|
|
277
378
|
const client = createClient(cfg);
|
|
379
|
+
const sentMessageKeysBySession = new Map();
|
|
380
|
+
const pendingMessageKeysBySession = new Map();
|
|
278
381
|
api.registerMemoryCapability({
|
|
279
382
|
runtime: createMemoryRuntime(cfg),
|
|
280
383
|
});
|
|
@@ -307,26 +410,37 @@ export default definePluginEntry({
|
|
|
307
410
|
if (sessionId.startsWith('announce:'))
|
|
308
411
|
return;
|
|
309
412
|
const chunks = [];
|
|
310
|
-
|
|
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()) {
|
|
311
418
|
const m = msg;
|
|
312
|
-
const role = m['role'];
|
|
313
|
-
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))
|
|
314
427
|
continue;
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
}
|
|
428
|
+
const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
|
|
429
|
+
chunkKeys.push(key);
|
|
430
|
+
chunks.push({ role, content: text, timestamp: ts });
|
|
324
431
|
}
|
|
325
432
|
if (chunks.length === 0)
|
|
326
433
|
return;
|
|
327
|
-
|
|
328
|
-
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) => {
|
|
329
440
|
api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
|
|
441
|
+
})
|
|
442
|
+
.finally(() => {
|
|
443
|
+
forgetKeys(pendingKeys, chunkKeys);
|
|
330
444
|
});
|
|
331
445
|
}
|
|
332
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
|
@@ -8,6 +8,41 @@ 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
|
+
|
|
11
46
|
function resolveConfig(raw: unknown): PersistioConfig {
|
|
12
47
|
const c = (raw ?? {}) as Record<string, unknown>;
|
|
13
48
|
return {
|
|
@@ -16,6 +51,7 @@ function resolveConfig(raw: unknown): PersistioConfig {
|
|
|
16
51
|
tokenBudget: typeof c['tokenBudget'] === 'number' ? c['tokenBudget'] : 2000,
|
|
17
52
|
recallTopK: typeof c['recallTopK'] === 'number' ? c['recallTopK'] : 10,
|
|
18
53
|
recallTimeout: typeof c['recallTimeout'] === 'number' ? c['recallTimeout'] : 5000,
|
|
54
|
+
send: resolveSendConfig(c),
|
|
19
55
|
};
|
|
20
56
|
}
|
|
21
57
|
|
|
@@ -136,12 +172,22 @@ function buildMemoryBlock(bundle: RecallBundle, budget: number): string {
|
|
|
136
172
|
return lines.length > 1 ? lines.join('\n') : '';
|
|
137
173
|
}
|
|
138
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
|
+
|
|
139
185
|
/** Extract plain text from a pi-agent-core message content array */
|
|
140
|
-
function extractTextFromMessage(msg: unknown): string | null {
|
|
186
|
+
function extractTextFromMessage(msg: unknown, allowedRoles: OpenClawMessageRole[] = ['user', 'assistant']): string | null {
|
|
141
187
|
if (typeof msg !== 'object' || msg === null) return null;
|
|
142
188
|
const m = msg as Record<string, unknown>;
|
|
143
|
-
const role = m['role'];
|
|
144
|
-
if (role
|
|
189
|
+
const role = normalizeRole(m['role']);
|
|
190
|
+
if (!role || !allowedRoles.includes(role)) return null;
|
|
145
191
|
const content = m['content'];
|
|
146
192
|
if (!Array.isArray(content)) {
|
|
147
193
|
// Some messages have content as a plain string
|
|
@@ -160,6 +206,83 @@ function extractTextFromMessage(msg: unknown): string | null {
|
|
|
160
206
|
return parts.length > 0 ? parts.join(' ') : null;
|
|
161
207
|
}
|
|
162
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
|
+
|
|
163
286
|
const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
|
|
164
287
|
|
|
165
288
|
function createClient(config: PersistioConfig, recallTopK = config.recallTopK): PersistioClient {
|
|
@@ -329,6 +452,8 @@ export default definePluginEntry({
|
|
|
329
452
|
}
|
|
330
453
|
|
|
331
454
|
const client = createClient(cfg);
|
|
455
|
+
const sentMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
|
|
456
|
+
const pendingMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
|
|
332
457
|
api.registerMemoryCapability({
|
|
333
458
|
runtime: createMemoryRuntime(cfg),
|
|
334
459
|
});
|
|
@@ -360,27 +485,38 @@ export default definePluginEntry({
|
|
|
360
485
|
const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
|
|
361
486
|
if (sessionId.startsWith('announce:')) return;
|
|
362
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);
|
|
363
492
|
|
|
364
|
-
for (const msg of event.messages) {
|
|
493
|
+
for (const [index, msg] of event.messages.entries()) {
|
|
365
494
|
const m = msg as Record<string, unknown>;
|
|
366
|
-
const role = m['role'];
|
|
367
|
-
if (role
|
|
368
|
-
const text = extractTextFromMessage(msg);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
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 });
|
|
377
506
|
}
|
|
378
507
|
|
|
379
508
|
if (chunks.length === 0) return;
|
|
380
|
-
|
|
381
|
-
client.ingest(sessionId, chunks)
|
|
382
|
-
|
|
383
|
-
|
|
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
|
+
});
|
|
384
520
|
} catch (err) {
|
|
385
521
|
api.logger?.warn?.(`openclaw-persistio: agent_end error: ${String(err)}`);
|
|
386
522
|
}
|