@persistio/openclaw-plugin 0.1.7 → 0.2.0
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 +86 -53
- package/dist/capture.d.ts +17 -0
- package/dist/capture.js +112 -0
- package/dist/client.d.ts +35 -51
- package/dist/client.js +45 -70
- package/dist/config.d.ts +29 -0
- package/dist/config.js +86 -0
- package/dist/index.js +303 -623
- package/dist/memory-format.d.ts +8 -0
- package/dist/memory-format.js +121 -0
- package/openclaw.plugin.json +69 -95
- package/package.json +10 -11
- package/src/capture.ts +132 -0
- package/src/client.ts +72 -111
- package/src/config.ts +125 -0
- package/src/index.ts +308 -718
- package/src/memory-format.ts +127 -0
- package/dist/ingest-policy.d.ts +0 -48
- package/dist/ingest-policy.js +0 -380
- package/src/ingest-policy.ts +0 -508
package/dist/index.js
CHANGED
|
@@ -1,701 +1,381 @@
|
|
|
1
|
-
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
|
|
2
1
|
import { Type } from '@sinclair/typebox';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const MAX_TRACKED_SESSIONS = 250;
|
|
12
|
-
const MAX_SENT_KEYS_PER_SESSION = 2000;
|
|
13
|
-
const RECALL_CIRCUIT_BREAKER_FAILURE_THRESHOLD = 3;
|
|
14
|
-
const RECALL_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000;
|
|
2
|
+
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
|
|
3
|
+
import { PersistioClient, PersistioTimeoutError } from './client.js';
|
|
4
|
+
import { prepareCapture } from './capture.js';
|
|
5
|
+
import { resolveConfig } from './config.js';
|
|
6
|
+
import { buildMemoryBlock, buildRecallQuery } from './memory-format.js';
|
|
7
|
+
const CAPTURE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const MAX_CAPTURED_KEYS = 2000;
|
|
9
|
+
const MAX_CAPTURE_STORES = 250;
|
|
15
10
|
const RECALL_GUARD_MARGIN_MS = 250;
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
const RECALL_FAILURE_THRESHOLD = 3;
|
|
12
|
+
const RECALL_COOLDOWN_MS = 60_000;
|
|
13
|
+
class CircuitBreaker {
|
|
14
|
+
failures = 0;
|
|
18
15
|
openedUntil = 0;
|
|
19
16
|
canAttempt(now = Date.now()) {
|
|
20
17
|
return now >= this.openedUntil;
|
|
21
18
|
}
|
|
22
|
-
remainingMs(now = Date.now()) {
|
|
23
|
-
return Math.max(0, this.openedUntil - now);
|
|
24
|
-
}
|
|
25
19
|
recordSuccess() {
|
|
26
|
-
this.
|
|
20
|
+
this.failures = 0;
|
|
27
21
|
this.openedUntil = 0;
|
|
28
22
|
}
|
|
29
23
|
recordFailure(now = Date.now()) {
|
|
30
|
-
this.
|
|
31
|
-
if (this.
|
|
32
|
-
this.openedUntil = now +
|
|
24
|
+
this.failures += 1;
|
|
25
|
+
if (this.failures >= RECALL_FAILURE_THRESHOLD) {
|
|
26
|
+
this.openedUntil = now + RECALL_COOLDOWN_MS;
|
|
33
27
|
return true;
|
|
34
28
|
}
|
|
35
29
|
return false;
|
|
36
30
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const send = raw['send'];
|
|
40
|
-
const roles = typeof send === 'object' && send !== null
|
|
41
|
-
? send['roles']
|
|
42
|
-
: undefined;
|
|
43
|
-
const rawRoles = typeof roles === 'object' && roles !== null
|
|
44
|
-
? roles
|
|
45
|
-
: {};
|
|
46
|
-
return {
|
|
47
|
-
roles: {
|
|
48
|
-
user: rawRoles['user'] === 'disabled' ? 'disabled' : DEFAULT_SEND_ROLES.user,
|
|
49
|
-
agent: rawRoles['agent'] === 'disabled' ? 'disabled' : DEFAULT_SEND_ROLES.agent,
|
|
50
|
-
tool: rawRoles['tool'] === 'enabled' ? 'enabled' : DEFAULT_SEND_ROLES.tool,
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
function resolveRecallMinSimilarity(value) {
|
|
55
|
-
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
|
|
56
|
-
? value
|
|
57
|
-
: undefined;
|
|
58
|
-
}
|
|
59
|
-
function resolvePositiveInteger(value, fallback) {
|
|
60
|
-
return typeof value === 'number' && Number.isFinite(value) && value >= 1
|
|
61
|
-
? Math.floor(value)
|
|
62
|
-
: fallback;
|
|
63
|
-
}
|
|
64
|
-
function resolveConfig(raw) {
|
|
65
|
-
const c = (raw ?? {});
|
|
66
|
-
return {
|
|
67
|
-
baseURL: typeof c['baseURL'] === 'string' ? c['baseURL'] : '',
|
|
68
|
-
apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
|
|
69
|
-
tokenBudget: resolvePositiveInteger(c['tokenBudget'], 2000),
|
|
70
|
-
recallTopK: resolvePositiveInteger(c['recallTopK'], 10),
|
|
71
|
-
recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
|
|
72
|
-
recallTimeout: resolvePositiveInteger(c['recallTimeout'], 5000),
|
|
73
|
-
ingest: resolveIngestPolicy(c['ingest']),
|
|
74
|
-
send: resolveSendConfig(c),
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
function estimateTokens(text) {
|
|
78
|
-
return Math.ceil(text.length / 4);
|
|
79
|
-
}
|
|
80
|
-
function truncate(text, maxLength) {
|
|
81
|
-
if (text.length <= maxLength)
|
|
82
|
-
return text;
|
|
83
|
-
return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
84
|
-
}
|
|
85
|
-
function detectTaskType(text) {
|
|
86
|
-
const normalized = text.toLowerCase();
|
|
87
|
-
if (/(error|bug|fail|failing|issue|broken|debug|debugging|trace|stack)/.test(normalized)) {
|
|
88
|
-
return 'troubleshooting';
|
|
89
|
-
}
|
|
90
|
-
if (/(code|coding|typescript|javascript|python|implement|refactor|function|class|api|build|test)/.test(normalized)) {
|
|
91
|
-
return 'coding';
|
|
92
|
-
}
|
|
93
|
-
if (/(plan|planning|roadmap|strategy|steps|milestone|timeline|organize)/.test(normalized)) {
|
|
94
|
-
return 'planning';
|
|
95
|
-
}
|
|
96
|
-
if (/(write|writing|draft|edit|copy|blog|essay|summary|summarize|document)/.test(normalized)) {
|
|
97
|
-
return 'writing';
|
|
31
|
+
remainingMs(now = Date.now()) {
|
|
32
|
+
return Math.max(0, this.openedUntil - now);
|
|
98
33
|
}
|
|
99
|
-
return 'general';
|
|
100
|
-
}
|
|
101
|
-
function buildRecallQuery(event) {
|
|
102
|
-
const relevantMessages = Array.isArray(event.messages)
|
|
103
|
-
? event.messages
|
|
104
|
-
.map((msg) => {
|
|
105
|
-
if (typeof msg !== 'object' || msg === null)
|
|
106
|
-
return null;
|
|
107
|
-
const m = msg;
|
|
108
|
-
const role = m['role'];
|
|
109
|
-
if (role !== 'user' && role !== 'assistant')
|
|
110
|
-
return null;
|
|
111
|
-
const text = extractTextFromMessage(msg);
|
|
112
|
-
if (!text)
|
|
113
|
-
return null;
|
|
114
|
-
return { role, text: text.replace(/\s+/g, ' ').trim() };
|
|
115
|
-
})
|
|
116
|
-
.filter((msg) => msg !== null && msg.text.length > 0)
|
|
117
|
-
: [];
|
|
118
|
-
const lastUserIndex = (() => {
|
|
119
|
-
for (let i = relevantMessages.length - 1; i >= 0; i -= 1) {
|
|
120
|
-
if (relevantMessages[i].role === 'user')
|
|
121
|
-
return i;
|
|
122
|
-
}
|
|
123
|
-
return -1;
|
|
124
|
-
})();
|
|
125
|
-
const lastUserMessage = lastUserIndex >= 0
|
|
126
|
-
? relevantMessages[lastUserIndex].text
|
|
127
|
-
: event.prompt?.replace(/\s+/g, ' ').trim() || 'recent context';
|
|
128
|
-
const primary = truncate(lastUserMessage, 300);
|
|
129
|
-
const contextStart = Math.max(0, lastUserIndex - 6);
|
|
130
|
-
const contextMessages = lastUserIndex >= 0
|
|
131
|
-
? relevantMessages.slice(contextStart, lastUserIndex)
|
|
132
|
-
: relevantMessages.slice(-6);
|
|
133
|
-
const contextSummary = truncate(contextMessages
|
|
134
|
-
.map((msg) => `${msg.role === 'user' ? 'U' : 'A'}:${msg.text}`)
|
|
135
|
-
.join(' | '), 200);
|
|
136
|
-
const taskType = detectTaskType(`${primary} ${event.prompt ?? ''}`);
|
|
137
|
-
const parts = [primary];
|
|
138
|
-
if (contextSummary.length > 0)
|
|
139
|
-
parts.push(`Context: ${contextSummary}`);
|
|
140
|
-
parts.push(`[task: ${taskType}]`);
|
|
141
|
-
return truncate(parts.join('\n'), 600);
|
|
142
34
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return '';
|
|
151
|
-
const sections = [
|
|
152
|
-
{ title: 'Behavioural rules', items: toStringArray(bundle.user_rules) },
|
|
153
|
-
{ title: 'Preferences', items: toStringArray(bundle.user_preferences) },
|
|
154
|
-
{ title: 'Task patterns', items: toStringArray(bundle.task_patterns) },
|
|
155
|
-
{ title: 'Workflows', items: toStringArray(bundle.workflows) },
|
|
156
|
-
{ title: 'Project', items: toStringArray(bundle.project) },
|
|
157
|
-
{ title: 'Constraints', items: toStringArray(bundle.constraints) },
|
|
158
|
-
{ title: 'Decisions', items: toStringArray(bundle.decisions) },
|
|
159
|
-
{ title: 'System facts', items: toStringArray(bundle.system_facts) },
|
|
160
|
-
{ title: 'Domain knowledge', items: toStringArray(bundle.domain_knowledge) },
|
|
161
|
-
];
|
|
162
|
-
if (relatedBundle && typeof relatedBundle === 'object') {
|
|
163
|
-
sections.push({ title: 'Related behavioural rules', items: toStringArray(relatedBundle.user_rules) }, { title: 'Related preferences', items: toStringArray(relatedBundle.user_preferences) }, { title: 'Related task patterns', items: toStringArray(relatedBundle.task_patterns) }, { title: 'Related workflows', items: toStringArray(relatedBundle.workflows) }, { title: 'Related project', items: toStringArray(relatedBundle.project) }, { title: 'Related constraints', items: toStringArray(relatedBundle.constraints) }, { title: 'Related decisions', items: toStringArray(relatedBundle.decisions) }, { title: 'Related system facts', items: toStringArray(relatedBundle.system_facts) }, { title: 'Related domain knowledge', items: toStringArray(relatedBundle.domain_knowledge) });
|
|
35
|
+
class CaptureKeyStore {
|
|
36
|
+
capturedKeys = new Map();
|
|
37
|
+
pendingKeys = new Map();
|
|
38
|
+
lastSeen = Date.now();
|
|
39
|
+
has(key, now = Date.now()) {
|
|
40
|
+
this.prune(now);
|
|
41
|
+
return this.capturedKeys.has(key) || this.pendingKeys.has(key);
|
|
164
42
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (candidates.length === 0)
|
|
171
|
-
continue;
|
|
172
|
-
const header = `## ${section.title}`;
|
|
173
|
-
const tentativeLines = [...lines, '', header];
|
|
174
|
-
let tentativeUsed = used + estimateTokens(`\n\n${header}`);
|
|
175
|
-
const includedItems = [];
|
|
176
|
-
for (const item of candidates) {
|
|
177
|
-
const line = `- ${item}`;
|
|
178
|
-
const cost = estimateTokens(`\n${line}`);
|
|
179
|
-
if (tentativeUsed + cost > budget) {
|
|
180
|
-
return lines.length > 1 ? lines.join('\n') : '';
|
|
181
|
-
}
|
|
182
|
-
includedItems.push(line);
|
|
183
|
-
tentativeUsed += cost;
|
|
184
|
-
}
|
|
185
|
-
if (includedItems.length > 0) {
|
|
186
|
-
tentativeLines.push(...includedItems);
|
|
187
|
-
lines.splice(0, lines.length, ...tentativeLines);
|
|
188
|
-
used = tentativeUsed;
|
|
43
|
+
markPending(keys, now = Date.now()) {
|
|
44
|
+
this.prune(now);
|
|
45
|
+
this.lastSeen = now;
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
this.pendingKeys.set(key, now);
|
|
189
48
|
}
|
|
190
49
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
/** Extract plain text from a pi-agent-core message content array */
|
|
204
|
-
function extractTextFromMessage(msg, allowedRoles = ['user', 'assistant']) {
|
|
205
|
-
if (typeof msg !== 'object' || msg === null)
|
|
206
|
-
return null;
|
|
207
|
-
const m = msg;
|
|
208
|
-
const role = normalizeRole(m['role']);
|
|
209
|
-
if (!role || !allowedRoles.includes(role))
|
|
210
|
-
return null;
|
|
211
|
-
const content = m['content'];
|
|
212
|
-
if (!Array.isArray(content)) {
|
|
213
|
-
// Some messages have content as a plain string
|
|
214
|
-
if (typeof content === 'string' && content.length > 0)
|
|
215
|
-
return content;
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
const parts = [];
|
|
219
|
-
for (const block of content) {
|
|
220
|
-
if (typeof block === 'object' && block !== null) {
|
|
221
|
-
const b = block;
|
|
222
|
-
if (b['type'] === 'text' && typeof b['text'] === 'string' && b['text'].length > 0) {
|
|
223
|
-
parts.push(b['text']);
|
|
50
|
+
markCaptured(keys, now = Date.now()) {
|
|
51
|
+
this.prune(now);
|
|
52
|
+
this.lastSeen = now;
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
this.pendingKeys.delete(key);
|
|
55
|
+
this.capturedKeys.set(key, now);
|
|
56
|
+
while (this.capturedKeys.size > MAX_CAPTURED_KEYS) {
|
|
57
|
+
const oldest = this.capturedKeys.keys().next().value;
|
|
58
|
+
if (!oldest)
|
|
59
|
+
break;
|
|
60
|
+
this.capturedKeys.delete(oldest);
|
|
224
61
|
}
|
|
225
62
|
}
|
|
226
63
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return msg['timestamp'];
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
function hashString(input) {
|
|
237
|
-
let hash = 0x811c9dc5;
|
|
238
|
-
for (let i = 0; i < input.length; i += 1) {
|
|
239
|
-
hash ^= input.charCodeAt(i);
|
|
240
|
-
hash = Math.imul(hash, 0x01000193);
|
|
241
|
-
}
|
|
242
|
-
return (hash >>> 0).toString(16);
|
|
243
|
-
}
|
|
244
|
-
function buildMessageFingerprint(params) {
|
|
245
|
-
const id = params.msg['id'];
|
|
246
|
-
if (typeof id === 'string' && id.length > 0) {
|
|
247
|
-
return `id:${params.sessionId}:${id}`;
|
|
64
|
+
releasePending(keys, now = Date.now()) {
|
|
65
|
+
this.prune(now);
|
|
66
|
+
this.lastSeen = now;
|
|
67
|
+
for (const key of keys) {
|
|
68
|
+
this.pendingKeys.delete(key);
|
|
69
|
+
}
|
|
248
70
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return
|
|
71
|
+
isExpired(now = Date.now()) {
|
|
72
|
+
this.prune(now);
|
|
73
|
+
return this.capturedKeys.size === 0
|
|
74
|
+
&& this.pendingKeys.size === 0
|
|
75
|
+
&& now - this.lastSeen > CAPTURE_KEY_TTL_MS;
|
|
252
76
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return `content:${params.sessionId}:${basis}:${params.role}:${hashString(params.text)}`;
|
|
256
|
-
}
|
|
257
|
-
function pruneSessionKeyStores(stores, now) {
|
|
258
|
-
for (const [sessionId, store] of stores) {
|
|
259
|
-
if (now - store.lastSeen > MESSAGE_KEY_TTL_MS)
|
|
260
|
-
stores.delete(sessionId);
|
|
77
|
+
getLastSeen() {
|
|
78
|
+
return this.lastSeen;
|
|
261
79
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return;
|
|
266
|
-
stores.delete(oldest[0]);
|
|
80
|
+
hasPending(now = Date.now()) {
|
|
81
|
+
this.prune(now);
|
|
82
|
+
return this.pendingKeys.size > 0;
|
|
267
83
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const created = { keys: new Set(), lastSeen: now };
|
|
277
|
-
stores.set(sessionId, created);
|
|
278
|
-
return created.keys;
|
|
279
|
-
}
|
|
280
|
-
function rememberKeys(target, keys, limit = Number.POSITIVE_INFINITY) {
|
|
281
|
-
for (const key of keys) {
|
|
282
|
-
target.add(key);
|
|
283
|
-
while (target.size > limit) {
|
|
284
|
-
const oldest = target.values().next().value;
|
|
285
|
-
if (!oldest)
|
|
286
|
-
break;
|
|
287
|
-
target.delete(oldest);
|
|
84
|
+
prune(now) {
|
|
85
|
+
for (const [key, timestamp] of this.capturedKeys.entries()) {
|
|
86
|
+
if (now - timestamp > CAPTURE_KEY_TTL_MS)
|
|
87
|
+
this.capturedKeys.delete(key);
|
|
88
|
+
}
|
|
89
|
+
for (const [key, timestamp] of this.pendingKeys.entries()) {
|
|
90
|
+
if (now - timestamp > CAPTURE_KEY_TTL_MS)
|
|
91
|
+
this.pendingKeys.delete(key);
|
|
288
92
|
}
|
|
289
93
|
}
|
|
290
94
|
}
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
|
|
95
|
+
function jsonResult(payload) {
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
98
|
+
details: payload,
|
|
99
|
+
};
|
|
294
100
|
}
|
|
295
|
-
function
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
counts.set(omission.label, (counts.get(omission.label) ?? 0) + 1);
|
|
301
|
-
}
|
|
302
|
-
return [...counts.entries()]
|
|
303
|
-
.map(([label, count]) => `${label}:${count}`)
|
|
304
|
-
.join(',');
|
|
101
|
+
function textResult(text, details = null) {
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: 'text', text }],
|
|
104
|
+
details,
|
|
105
|
+
};
|
|
305
106
|
}
|
|
306
|
-
function
|
|
307
|
-
|
|
308
|
-
return false;
|
|
309
|
-
const record = err;
|
|
310
|
-
const name = typeof record['name'] === 'string' ? record['name'] : '';
|
|
311
|
-
if (name === 'TimeoutError' || name === 'AbortError')
|
|
312
|
-
return true;
|
|
313
|
-
const message = typeof record['message'] === 'string' ? record['message'].toLowerCase() : '';
|
|
314
|
-
return message.includes('timeout') || message.includes('aborted');
|
|
107
|
+
async function guardedRecall(params) {
|
|
108
|
+
return (await guardedRecallOutcome(params)).value;
|
|
315
109
|
}
|
|
316
|
-
async function
|
|
110
|
+
async function guardedRecallOutcome(params) {
|
|
317
111
|
const now = Date.now();
|
|
318
|
-
if (!
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
return
|
|
112
|
+
if (!params.breaker.canAttempt(now)) {
|
|
113
|
+
const unavailable = `Persistio recall unavailable; circuit breaker open for ${params.breaker.remainingMs(now)}ms`;
|
|
114
|
+
params.logger?.warn?.(`openclaw-persistio-v2: ${params.operation} skipped; ${unavailable}`);
|
|
115
|
+
return { value: params.fallback, unavailable };
|
|
322
116
|
}
|
|
323
117
|
try {
|
|
324
|
-
const result = await
|
|
325
|
-
|
|
326
|
-
return result;
|
|
118
|
+
const result = await params.run();
|
|
119
|
+
params.breaker.recordSuccess();
|
|
120
|
+
return { value: result };
|
|
327
121
|
}
|
|
328
122
|
catch (err) {
|
|
329
|
-
const opened =
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
async function withPluginDeadline(operation, timeoutMs, run) {
|
|
336
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
337
|
-
return run();
|
|
338
|
-
}
|
|
339
|
-
let timeout;
|
|
340
|
-
const deadline = new Promise((_resolve, reject) => {
|
|
341
|
-
timeout = setTimeout(() => {
|
|
342
|
-
const err = new Error(`Persistio ${operation} exceeded plugin deadline after ${timeoutMs}ms`);
|
|
343
|
-
err.name = 'TimeoutError';
|
|
344
|
-
reject(err);
|
|
345
|
-
}, timeoutMs);
|
|
346
|
-
});
|
|
347
|
-
try {
|
|
348
|
-
return await Promise.race([run(), deadline]);
|
|
349
|
-
}
|
|
350
|
-
finally {
|
|
351
|
-
if (timeout)
|
|
352
|
-
clearTimeout(timeout);
|
|
123
|
+
const opened = params.breaker.recordFailure();
|
|
124
|
+
const unavailable = `Persistio recall unavailable during ${params.operation}: ${String(err)}`;
|
|
125
|
+
params.logger?.warn?.(`openclaw-persistio-v2: ${params.operation} failed open: ${String(err)}`
|
|
126
|
+
+ (opened ? `; recall circuit breaker open for ${RECALL_COOLDOWN_MS}ms` : ''));
|
|
127
|
+
return { value: params.fallback, unavailable };
|
|
353
128
|
}
|
|
354
129
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
function normalizeMemoryScore(memory) {
|
|
360
|
-
if (typeof memory.similarity === 'number' && Number.isFinite(memory.similarity)) {
|
|
361
|
-
return memory.similarity;
|
|
130
|
+
function pruneCaptureStores(stores, now = Date.now()) {
|
|
131
|
+
for (const [sessionId, store] of stores.entries()) {
|
|
132
|
+
if (store.isExpired(now))
|
|
133
|
+
stores.delete(sessionId);
|
|
362
134
|
}
|
|
363
|
-
|
|
364
|
-
|
|
135
|
+
while (stores.size > MAX_CAPTURE_STORES) {
|
|
136
|
+
let oldestSessionId;
|
|
137
|
+
let oldestLastSeen = Number.POSITIVE_INFINITY;
|
|
138
|
+
for (const [sessionId, store] of stores.entries()) {
|
|
139
|
+
if (store.hasPending(now))
|
|
140
|
+
continue;
|
|
141
|
+
const lastSeen = store.getLastSeen();
|
|
142
|
+
if (lastSeen < oldestLastSeen) {
|
|
143
|
+
oldestLastSeen = lastSeen;
|
|
144
|
+
oldestSessionId = sessionId;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!oldestSessionId) {
|
|
148
|
+
for (const [sessionId, store] of stores.entries()) {
|
|
149
|
+
const lastSeen = store.getLastSeen();
|
|
150
|
+
if (lastSeen < oldestLastSeen) {
|
|
151
|
+
oldestLastSeen = lastSeen;
|
|
152
|
+
oldestSessionId = sessionId;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!oldestSessionId)
|
|
157
|
+
break;
|
|
158
|
+
stores.delete(oldestSessionId);
|
|
365
159
|
}
|
|
366
|
-
return 0;
|
|
367
|
-
}
|
|
368
|
-
function buildMemoryPath(id) {
|
|
369
|
-
return `${PERSISTIO_MEMORY_PATH_PREFIX}${id}`;
|
|
370
160
|
}
|
|
371
|
-
function
|
|
372
|
-
return
|
|
373
|
-
? relPath.slice(PERSISTIO_MEMORY_PATH_PREFIX.length)
|
|
374
|
-
: null;
|
|
161
|
+
function emptyRecallResult() {
|
|
162
|
+
return { memories: [], relatedMemories: [] };
|
|
375
163
|
}
|
|
376
|
-
function
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
385
|
-
lines.push('', memory.data);
|
|
386
|
-
return lines.join('\n');
|
|
164
|
+
function serializeMemory(memory) {
|
|
165
|
+
return {
|
|
166
|
+
id: memory.id,
|
|
167
|
+
subject: memory.subject,
|
|
168
|
+
text: memory.data,
|
|
169
|
+
similarity: memory.similarity,
|
|
170
|
+
confidence: memory.confidence,
|
|
171
|
+
categories: memory.categories ?? [],
|
|
172
|
+
};
|
|
387
173
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return { ok: true };
|
|
392
|
-
}
|
|
393
|
-
catch (err) {
|
|
394
|
-
return { ok: false, error: String(err) };
|
|
174
|
+
function formatRecallToolResult(result) {
|
|
175
|
+
if (result.memories.length === 0 && result.relatedMemories.length === 0) {
|
|
176
|
+
return jsonResult({ count: 0, memories: [], related_memories: [], provider: 'persistio' });
|
|
395
177
|
}
|
|
178
|
+
return jsonResult({
|
|
179
|
+
count: result.memories.length,
|
|
180
|
+
related_count: result.relatedMemories.length,
|
|
181
|
+
provider: 'persistio',
|
|
182
|
+
memories: result.memories.map(serializeMemory),
|
|
183
|
+
related_memories: result.relatedMemories.map((memory) => ({
|
|
184
|
+
...serializeMemory(memory),
|
|
185
|
+
edge_type: memory.edge_type ?? undefined,
|
|
186
|
+
})),
|
|
187
|
+
});
|
|
396
188
|
}
|
|
397
|
-
function
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
operation: 'memory search recall',
|
|
408
|
-
timeoutMs: config.recallTimeout,
|
|
409
|
-
fallback: [],
|
|
410
|
-
breaker: recallBreaker,
|
|
411
|
-
logger,
|
|
412
|
-
run: () => recallClient.recall(query),
|
|
413
|
-
});
|
|
414
|
-
return memories
|
|
415
|
-
.map((memory) => {
|
|
416
|
-
const score = normalizeMemoryScore(memory);
|
|
417
|
-
return {
|
|
418
|
-
path: buildMemoryPath(memory.id),
|
|
419
|
-
startLine: 1,
|
|
420
|
-
endLine: 1,
|
|
421
|
-
score,
|
|
422
|
-
vectorScore: typeof memory.similarity === 'number' ? memory.similarity : undefined,
|
|
423
|
-
snippet: truncate(memory.data, 400),
|
|
424
|
-
source: 'memory',
|
|
425
|
-
citation: memory.subject,
|
|
426
|
-
};
|
|
427
|
-
})
|
|
428
|
-
.filter((result) => opts?.minScore === undefined || result.score >= opts.minScore);
|
|
429
|
-
},
|
|
430
|
-
async readFile(params) {
|
|
431
|
-
const memoryId = parseMemoryPath(params.relPath);
|
|
432
|
-
if (!memoryId) {
|
|
433
|
-
throw new Error(`Unsupported Persistio memory path: ${params.relPath}`);
|
|
434
|
-
}
|
|
435
|
-
const memory = await client.getMemory(memoryId, { includePending: true });
|
|
436
|
-
if (!memory) {
|
|
437
|
-
throw new Error(`Persistio memory not found: ${memoryId}`);
|
|
438
|
-
}
|
|
439
|
-
const text = formatMemoryDocument(memory);
|
|
440
|
-
return {
|
|
441
|
-
path: params.relPath,
|
|
442
|
-
text,
|
|
443
|
-
truncated: false,
|
|
444
|
-
from: params.from ?? 1,
|
|
445
|
-
lines: params.lines,
|
|
446
|
-
};
|
|
447
|
-
},
|
|
448
|
-
status() {
|
|
449
|
-
return {
|
|
450
|
-
backend: 'builtin',
|
|
451
|
-
provider: 'persistio',
|
|
452
|
-
sources: ['memory'],
|
|
453
|
-
vector: {
|
|
454
|
-
enabled: true,
|
|
455
|
-
},
|
|
456
|
-
custom: {
|
|
457
|
-
baseURL: config.baseURL,
|
|
458
|
-
},
|
|
459
|
-
};
|
|
460
|
-
},
|
|
461
|
-
async probeEmbeddingAvailability() {
|
|
462
|
-
return probePersistio(client);
|
|
463
|
-
},
|
|
464
|
-
async probeVectorAvailability() {
|
|
465
|
-
const probe = await probePersistio(client);
|
|
466
|
-
return probe.ok;
|
|
467
|
-
},
|
|
468
|
-
};
|
|
189
|
+
function formatUnavailableRecallResult(unavailable) {
|
|
190
|
+
return jsonResult({
|
|
191
|
+
count: 0,
|
|
192
|
+
related_count: 0,
|
|
193
|
+
memories: [],
|
|
194
|
+
related_memories: [],
|
|
195
|
+
provider: 'persistio',
|
|
196
|
+
unavailable: true,
|
|
197
|
+
warning: unavailable,
|
|
198
|
+
});
|
|
469
199
|
}
|
|
470
|
-
function
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
},
|
|
480
|
-
};
|
|
200
|
+
function buildPromptGuidance({ availableTools }) {
|
|
201
|
+
if (!availableTools.has('memory_recall'))
|
|
202
|
+
return [];
|
|
203
|
+
return [
|
|
204
|
+
'## Persistio Memory',
|
|
205
|
+
'Persistio provides durable behavioral memory. Use memory_recall when prior user preferences, decisions, project context, or past working style would materially improve the answer.',
|
|
206
|
+
'Do not mention memory unless the user asks.',
|
|
207
|
+
'',
|
|
208
|
+
];
|
|
481
209
|
}
|
|
482
210
|
export default definePluginEntry({
|
|
483
|
-
id: 'openclaw-persistio',
|
|
484
|
-
name: 'Persistio Memory',
|
|
485
|
-
description: '
|
|
211
|
+
id: 'openclaw-persistio-v2',
|
|
212
|
+
name: 'Persistio Memory v2',
|
|
213
|
+
description: 'OpenClaw-native long-term memory powered by Persistio',
|
|
486
214
|
register(api) {
|
|
487
215
|
const cfg = resolveConfig(api.pluginConfig);
|
|
488
216
|
if (!cfg.baseURL || !cfg.apiKey) {
|
|
489
|
-
api.logger?.warn?.('openclaw-persistio: baseURL and apiKey are required. Plugin disabled.');
|
|
217
|
+
api.logger?.warn?.('openclaw-persistio-v2: baseURL and apiKey are required. Plugin disabled.');
|
|
490
218
|
return;
|
|
491
219
|
}
|
|
492
|
-
const client =
|
|
493
|
-
const recallBreaker = new
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
runtime: createMemoryRuntime(cfg, recallBreaker, api.logger),
|
|
220
|
+
const client = new PersistioClient(cfg);
|
|
221
|
+
const recallBreaker = new CircuitBreaker();
|
|
222
|
+
const capturedKeysBySession = new Map();
|
|
223
|
+
api.registerMemoryCapability?.({
|
|
224
|
+
promptBuilder: buildPromptGuidance,
|
|
498
225
|
});
|
|
499
|
-
// -------------------------------------------------------------------------
|
|
500
|
-
// before_prompt_build — recall relevant memories and inject into context
|
|
501
|
-
// Event: { prompt: string, messages: unknown[] }
|
|
502
|
-
// Return: { appendSystemContext?: string }
|
|
503
|
-
// -------------------------------------------------------------------------
|
|
504
|
-
api.on('before_prompt_build', async (event) => {
|
|
505
|
-
const query = buildRecallQuery(event);
|
|
506
|
-
const block = await runGuardedRecall({
|
|
507
|
-
operation: 'before_prompt_build recall',
|
|
508
|
-
timeoutMs: cfg.recallTimeout,
|
|
509
|
-
fallback: '',
|
|
510
|
-
breaker: recallBreaker,
|
|
511
|
-
logger: api.logger,
|
|
512
|
-
run: async () => {
|
|
513
|
-
const recall = await client.recallBundle(query);
|
|
514
|
-
return buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
|
|
515
|
-
},
|
|
516
|
-
});
|
|
517
|
-
if (!block)
|
|
518
|
-
return;
|
|
519
|
-
return { appendSystemContext: block };
|
|
520
|
-
}, { timeoutMs: cfg.recallTimeout + RECALL_GUARD_MARGIN_MS + 250 });
|
|
521
|
-
// -------------------------------------------------------------------------
|
|
522
|
-
// agent_end — ingest new turn messages (fire and forget)
|
|
523
|
-
// Event: { runId?, messages: unknown[], success: boolean, error?, durationMs? }
|
|
524
|
-
// Observation only — no return value.
|
|
525
|
-
// -------------------------------------------------------------------------
|
|
526
|
-
api.on('agent_end', async (event, context) => {
|
|
527
|
-
try {
|
|
528
|
-
const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
|
|
529
|
-
if (sessionId.startsWith('announce:'))
|
|
530
|
-
return;
|
|
531
|
-
if (!shouldIngestSession(sessionId, cfg.ingest)) {
|
|
532
|
-
api.logger?.debug?.(`openclaw-persistio: ingest skipped non-main session: ${sessionId}`);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
const chunks = [];
|
|
536
|
-
const chunkKeys = [];
|
|
537
|
-
let agentCharsSent = 0;
|
|
538
|
-
let originalChars = 0;
|
|
539
|
-
let preparedChars = 0;
|
|
540
|
-
let truncatedMessages = 0;
|
|
541
|
-
let skippedMessages = 0;
|
|
542
|
-
const omissions = [];
|
|
543
|
-
const now = Date.now();
|
|
544
|
-
const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
|
|
545
|
-
const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
|
|
546
|
-
for (const [index, msg] of event.messages.entries()) {
|
|
547
|
-
const m = msg;
|
|
548
|
-
const role = normalizeRole(m['role']);
|
|
549
|
-
if (!role || !shouldSendRole(role, cfg))
|
|
550
|
-
continue;
|
|
551
|
-
const text = extractTextFromMessage(msg, ['user', 'assistant', 'tool']);
|
|
552
|
-
if (!text || text.length === 0)
|
|
553
|
-
continue;
|
|
554
|
-
const key = buildMessageFingerprint({ sessionId, msg: m, role, text, index });
|
|
555
|
-
if (sentKeys.has(key) || pendingKeys.has(key))
|
|
556
|
-
continue;
|
|
557
|
-
const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
|
|
558
|
-
const prepared = prepareMessageForIngest({
|
|
559
|
-
role,
|
|
560
|
-
text,
|
|
561
|
-
policy: cfg.ingest,
|
|
562
|
-
remainingAgentChars: Math.max(0, cfg.ingest.agent.maxCharsPerTurn - agentCharsSent),
|
|
563
|
-
remainingChunks: Math.max(0, cfg.ingest.maxChunksPerTurn - chunks.length),
|
|
564
|
-
});
|
|
565
|
-
originalChars += prepared.originalChars;
|
|
566
|
-
preparedChars += prepared.preparedChars;
|
|
567
|
-
omissions.push(...prepared.omissions);
|
|
568
|
-
if (prepared.truncated)
|
|
569
|
-
truncatedMessages += 1;
|
|
570
|
-
if (prepared.chunks.length === 0) {
|
|
571
|
-
skippedMessages += 1;
|
|
572
|
-
continue;
|
|
573
|
-
}
|
|
574
|
-
chunkKeys.push(key);
|
|
575
|
-
if (role === 'assistant') {
|
|
576
|
-
agentCharsSent += prepared.preparedChars;
|
|
577
|
-
}
|
|
578
|
-
chunks.push(...prepared.chunks.map((content) => ({ role, content, timestamp: ts })));
|
|
579
|
-
if (chunks.length >= cfg.ingest.maxChunksPerTurn)
|
|
580
|
-
break;
|
|
581
|
-
}
|
|
582
|
-
if (chunks.length === 0)
|
|
583
|
-
return;
|
|
584
|
-
if (truncatedMessages > 0 || omissions.length > 0 || skippedMessages > 0) {
|
|
585
|
-
api.logger?.info?.(`openclaw-persistio: ingest planned session=${sessionId} chunks=${chunks.length} `
|
|
586
|
-
+ `originalChars=${originalChars} preparedChars=${preparedChars} `
|
|
587
|
-
+ `truncatedMessages=${truncatedMessages} skippedMessages=${skippedMessages} `
|
|
588
|
-
+ `omissions=${summarizeOmissions(omissions)}`);
|
|
589
|
-
}
|
|
590
|
-
rememberKeys(pendingKeys, chunkKeys);
|
|
591
|
-
client.ingest(sessionId, chunks)
|
|
592
|
-
.then(() => {
|
|
593
|
-
rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
|
|
594
|
-
})
|
|
595
|
-
.catch((err) => {
|
|
596
|
-
if (isTimeoutLikeError(err)) {
|
|
597
|
-
rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
|
|
598
|
-
api.logger?.warn?.(`openclaw-persistio: ingest timeout after ${cfg.ingest.timeoutMs}ms; `
|
|
599
|
-
+ `outcome is ambiguous, suppressing retry for ${chunkKeys.length} messages in session=${sessionId}`);
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
|
|
603
|
-
})
|
|
604
|
-
.finally(() => {
|
|
605
|
-
forgetKeys(pendingKeys, chunkKeys);
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
catch (err) {
|
|
609
|
-
api.logger?.warn?.(`openclaw-persistio: agent_end error: ${String(err)}`);
|
|
610
|
-
}
|
|
611
|
-
});
|
|
612
|
-
// -------------------------------------------------------------------------
|
|
613
|
-
// Tools
|
|
614
|
-
// Verified signature: api.registerTool({ name, description, parameters, execute }, opts?)
|
|
615
|
-
// execute(_id: string, params: unknown): Promise<AgentToolResult>
|
|
616
|
-
// AgentToolResult: { content: Array<{ type: "text", text: string }>, details: unknown }
|
|
617
|
-
// -------------------------------------------------------------------------
|
|
618
226
|
api.registerTool({
|
|
619
|
-
name: '
|
|
620
|
-
label: '
|
|
621
|
-
description: '
|
|
227
|
+
name: 'memory_recall',
|
|
228
|
+
label: 'Memory Recall',
|
|
229
|
+
description: 'Recall relevant durable Persistio memories for user preferences, decisions, project context, and prior working style.',
|
|
622
230
|
parameters: Type.Object({
|
|
623
|
-
query: Type.String({ description: '
|
|
624
|
-
|
|
625
|
-
}),
|
|
626
|
-
async execute(
|
|
231
|
+
query: Type.String({ description: 'Search query' }),
|
|
232
|
+
limit: Type.Optional(Type.Number({ description: 'Maximum memories to return' })),
|
|
233
|
+
}, { additionalProperties: false }),
|
|
234
|
+
async execute(_toolCallId, params) {
|
|
627
235
|
const p = params;
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
236
|
+
const query = typeof p.query === 'string' ? p.query.trim() : '';
|
|
237
|
+
if (!query)
|
|
238
|
+
return jsonResult({ count: 0, memories: [], error: 'memory_recall requires a query' });
|
|
239
|
+
const limit = typeof p.limit === 'number' && Number.isFinite(p.limit)
|
|
240
|
+
? Math.max(1, Math.min(8, Math.floor(p.limit)))
|
|
241
|
+
: cfg.recall.maxResults;
|
|
242
|
+
const memories = await guardedRecallOutcome({
|
|
243
|
+
operation: 'memory_recall',
|
|
635
244
|
breaker: recallBreaker,
|
|
636
245
|
logger: api.logger,
|
|
637
|
-
|
|
246
|
+
fallback: emptyRecallResult(),
|
|
247
|
+
run: () => client.recall(query, { maxResults: limit }),
|
|
638
248
|
});
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
return { content: [{ type: 'text', text }], details: null };
|
|
249
|
+
if (memories.unavailable)
|
|
250
|
+
return formatUnavailableRecallResult(memories.unavailable);
|
|
251
|
+
return formatRecallToolResult(memories.value);
|
|
643
252
|
},
|
|
644
|
-
});
|
|
253
|
+
}, { name: 'memory_recall' });
|
|
645
254
|
api.registerTool({
|
|
646
|
-
name: '
|
|
647
|
-
label: '
|
|
648
|
-
description: '
|
|
255
|
+
name: 'memory_store',
|
|
256
|
+
label: 'Memory Store',
|
|
257
|
+
description: 'Store a deliberate durable fact, preference, decision, or project note in Persistio memory.',
|
|
649
258
|
parameters: Type.Object({
|
|
650
|
-
|
|
651
|
-
subject: Type.String({ description: '
|
|
652
|
-
}),
|
|
653
|
-
async execute(
|
|
259
|
+
text: Type.String({ description: 'Durable information to remember' }),
|
|
260
|
+
subject: Type.String({ description: 'Entity, project, person, or topic this memory is about' }),
|
|
261
|
+
}, { additionalProperties: false }),
|
|
262
|
+
async execute(_toolCallId, params) {
|
|
654
263
|
const p = params;
|
|
264
|
+
const text = typeof p.text === 'string' ? p.text.trim() : '';
|
|
265
|
+
const subject = typeof p.subject === 'string' ? p.subject.trim() : '';
|
|
266
|
+
if (!text || !subject)
|
|
267
|
+
return jsonResult({ stored: false, error: 'memory_store requires text and subject' });
|
|
655
268
|
try {
|
|
656
|
-
await client.
|
|
269
|
+
const memory = await client.storeMemory(text, subject);
|
|
270
|
+
return textResult('Memory stored.', { stored: true, id: memory.id });
|
|
657
271
|
}
|
|
658
272
|
catch (err) {
|
|
659
|
-
if (
|
|
660
|
-
|
|
661
|
-
return {
|
|
662
|
-
content: [{
|
|
663
|
-
type: 'text',
|
|
664
|
-
text: 'Memory store request timed out; it may still complete. Check memory_list before retrying.',
|
|
665
|
-
}],
|
|
666
|
-
details: { ambiguous: true },
|
|
667
|
-
};
|
|
273
|
+
if (err instanceof PersistioTimeoutError) {
|
|
274
|
+
return textResult('Memory store timed out; Persistio may still have stored it. Do not retry automatically.', { stored: 'unknown', ambiguous: true, timeoutMs: cfg.capture.timeoutMs });
|
|
668
275
|
}
|
|
669
276
|
throw err;
|
|
670
277
|
}
|
|
671
|
-
return { content: [{ type: 'text', text: 'Memory stored.' }], details: null };
|
|
672
278
|
},
|
|
673
|
-
});
|
|
279
|
+
}, { name: 'memory_store' });
|
|
674
280
|
api.registerTool({
|
|
675
|
-
name: '
|
|
676
|
-
label: '
|
|
677
|
-
description: '
|
|
281
|
+
name: 'memory_forget',
|
|
282
|
+
label: 'Memory Forget',
|
|
283
|
+
description: 'Forget a Persistio memory by id, or search candidates to forget by query.',
|
|
678
284
|
parameters: Type.Object({
|
|
679
|
-
id: Type.String({ description: '
|
|
680
|
-
|
|
681
|
-
|
|
285
|
+
id: Type.Optional(Type.String({ description: 'Persistio memory id to delete' })),
|
|
286
|
+
query: Type.Optional(Type.String({ description: 'Search query to find candidate memories' })),
|
|
287
|
+
}, { additionalProperties: false }),
|
|
288
|
+
async execute(_toolCallId, params) {
|
|
682
289
|
const p = params;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
:
|
|
697
|
-
|
|
290
|
+
const id = typeof p.id === 'string' ? p.id.trim() : '';
|
|
291
|
+
if (id) {
|
|
292
|
+
await client.forgetMemory(id);
|
|
293
|
+
return textResult('Memory forgotten.', { forgotten: true, id });
|
|
294
|
+
}
|
|
295
|
+
const query = typeof p.query === 'string' ? p.query.trim() : '';
|
|
296
|
+
if (!query)
|
|
297
|
+
return jsonResult({ forgotten: false, error: 'memory_forget requires id or query' });
|
|
298
|
+
const memories = await guardedRecallOutcome({
|
|
299
|
+
operation: 'memory_forget candidates',
|
|
300
|
+
breaker: recallBreaker,
|
|
301
|
+
logger: api.logger,
|
|
302
|
+
fallback: emptyRecallResult(),
|
|
303
|
+
run: () => client.recall(query, { maxResults: 5 }),
|
|
304
|
+
});
|
|
305
|
+
if (memories.unavailable) {
|
|
306
|
+
return jsonResult({
|
|
307
|
+
forgotten: false,
|
|
308
|
+
unavailable: true,
|
|
309
|
+
warning: memories.unavailable,
|
|
310
|
+
candidates: [],
|
|
311
|
+
related_candidates: [],
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return jsonResult({
|
|
315
|
+
forgotten: false,
|
|
316
|
+
candidates: memories.value.memories.map((memory) => ({
|
|
317
|
+
id: memory.id,
|
|
318
|
+
subject: memory.subject,
|
|
319
|
+
text: memory.data,
|
|
320
|
+
similarity: memory.similarity,
|
|
321
|
+
})),
|
|
322
|
+
related_candidates: memories.value.relatedMemories.map((memory) => ({
|
|
323
|
+
id: memory.id,
|
|
324
|
+
subject: memory.subject,
|
|
325
|
+
text: memory.data,
|
|
326
|
+
edge_type: memory.edge_type ?? undefined,
|
|
327
|
+
})),
|
|
328
|
+
});
|
|
698
329
|
},
|
|
699
|
-
}, {
|
|
330
|
+
}, { name: 'memory_forget' });
|
|
331
|
+
api.on('before_prompt_build', async (event) => {
|
|
332
|
+
if (!cfg.autoRecall)
|
|
333
|
+
return;
|
|
334
|
+
const query = buildRecallQuery(event, cfg.recall.queryMaxChars);
|
|
335
|
+
if (!query)
|
|
336
|
+
return;
|
|
337
|
+
const block = await guardedRecall({
|
|
338
|
+
operation: 'autoRecall',
|
|
339
|
+
breaker: recallBreaker,
|
|
340
|
+
logger: api.logger,
|
|
341
|
+
fallback: '',
|
|
342
|
+
run: async () => {
|
|
343
|
+
const response = await client.recallBundle(query);
|
|
344
|
+
return buildMemoryBlock(response.bundle, cfg.recall.tokenBudget, response.related_bundle);
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
if (!block)
|
|
348
|
+
return;
|
|
349
|
+
return { prependContext: block };
|
|
350
|
+
}, { timeoutMs: cfg.recall.timeoutMs + RECALL_GUARD_MARGIN_MS });
|
|
351
|
+
api.on('agent_end', (event, context) => {
|
|
352
|
+
if (!cfg.autoCapture || event.success === false)
|
|
353
|
+
return;
|
|
354
|
+
const sessionId = context?.sessionKey ?? context?.sessionId ?? event.runId ?? 'unknown-session';
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
pruneCaptureStores(capturedKeysBySession, now);
|
|
357
|
+
const store = capturedKeysBySession.get(sessionId) ?? new CaptureKeyStore();
|
|
358
|
+
capturedKeysBySession.set(sessionId, store);
|
|
359
|
+
const prepared = prepareCapture(event, cfg, {
|
|
360
|
+
shouldIncludeKey: (key) => !store.has(key, now),
|
|
361
|
+
});
|
|
362
|
+
if (prepared.chunks.length === 0)
|
|
363
|
+
return;
|
|
364
|
+
store.markPending(prepared.keys, now);
|
|
365
|
+
void client.ingest(sessionId, prepared.chunks)
|
|
366
|
+
.then(() => {
|
|
367
|
+
store.markCaptured(prepared.keys);
|
|
368
|
+
})
|
|
369
|
+
.catch((err) => {
|
|
370
|
+
if (err instanceof PersistioTimeoutError) {
|
|
371
|
+
api.logger?.warn?.(`openclaw-persistio-v2: autoCapture timed out after ${cfg.capture.timeoutMs}ms`);
|
|
372
|
+
store.markCaptured(prepared.keys);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
store.releasePending(prepared.keys);
|
|
376
|
+
api.logger?.warn?.(`openclaw-persistio-v2: autoCapture failed: ${String(err)}`);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
api.logger?.info?.('openclaw-persistio-v2: registered');
|
|
700
380
|
},
|
|
701
381
|
});
|