@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 +15 -1
- package/dist/client.d.ts +18 -1
- package/dist/client.js +24 -3
- package/dist/index.js +144 -22
- package/openclaw.plugin.json +39 -1
- package/package.json +3 -2
- package/src/client.ts +44 -4
- package/src/index.ts +179 -24
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<
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
328
|
-
client.ingest(sessionId, chunks)
|
|
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) {
|
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.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
|
+
"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(
|
|
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<
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
368
|
-
const text = extractTextFromMessage(msg);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
381
|
-
client.ingest(sessionId, chunks)
|
|
382
|
-
|
|
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
|
}
|