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