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