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