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