@persistio/openclaw-plugin 0.1.5 → 0.1.7
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 +19 -2
- package/dist/client.d.ts +7 -0
- package/dist/client.js +129 -57
- package/dist/index.js +212 -36
- package/dist/ingest-policy.d.ts +48 -0
- package/dist/ingest-policy.js +380 -0
- package/openclaw.plugin.json +83 -4
- package/package.json +2 -2
- package/src/client.ts +133 -51
- package/src/index.ts +264 -45
- package/src/ingest-policy.ts +508 -0
package/src/client.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PersistioIngestPolicy } from './ingest-policy.js';
|
|
2
|
+
|
|
1
3
|
export interface PersistioConfig {
|
|
2
4
|
baseURL: string;
|
|
3
5
|
apiKey: string;
|
|
@@ -5,6 +7,7 @@ export interface PersistioConfig {
|
|
|
5
7
|
recallTopK: number;
|
|
6
8
|
recallMinSimilarity?: number;
|
|
7
9
|
recallTimeout: number;
|
|
10
|
+
ingest: PersistioIngestPolicy;
|
|
8
11
|
send: PersistioSendConfig;
|
|
9
12
|
}
|
|
10
13
|
|
|
@@ -49,12 +52,21 @@ export interface RecallBundleResponse {
|
|
|
49
52
|
related_bundle?: RecallBundle;
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
export class PersistioTimeoutError extends Error {
|
|
56
|
+
constructor(operation: string, timeoutMs: number) {
|
|
57
|
+
super(`Persistio ${operation} timed out after ${timeoutMs}ms`);
|
|
58
|
+
this.name = 'TimeoutError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
export class PersistioClient {
|
|
53
63
|
private readonly baseURL: string;
|
|
54
64
|
private readonly apiKey: string;
|
|
55
65
|
private readonly recallTopK: number;
|
|
56
66
|
private readonly recallMinSimilarity?: number;
|
|
57
67
|
private readonly recallTimeout: number;
|
|
68
|
+
private readonly ingestTimeout: number;
|
|
69
|
+
private readonly writeTimeout: number;
|
|
58
70
|
|
|
59
71
|
constructor(config: PersistioConfig) {
|
|
60
72
|
this.baseURL = config.baseURL.replace(/\/$/, '');
|
|
@@ -62,6 +74,8 @@ export class PersistioClient {
|
|
|
62
74
|
this.recallTopK = config.recallTopK;
|
|
63
75
|
this.recallMinSimilarity = config.recallMinSimilarity;
|
|
64
76
|
this.recallTimeout = config.recallTimeout;
|
|
77
|
+
this.ingestTimeout = config.ingest.timeoutMs;
|
|
78
|
+
this.writeTimeout = config.ingest.timeoutMs;
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
private headers(): Record<string, string> {
|
|
@@ -72,82 +86,150 @@ export class PersistioClient {
|
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
async recall(query: string): Promise<PersistioMemory[]> {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
return withRequestDeadline('recall', this.recallTimeout, async (signal) => {
|
|
90
|
+
const body: Record<string, unknown> = { query, top_k: this.recallTopK, include_pending: true };
|
|
91
|
+
if (typeof this.recallMinSimilarity === 'number') {
|
|
92
|
+
body.min_similarity = this.recallMinSimilarity;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const res = await fetch(`${this.baseURL}/v1/recall`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: this.headers(),
|
|
98
|
+
body: JSON.stringify(body),
|
|
99
|
+
signal,
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) throw new Error(`Persistio recall failed: ${res.status}`);
|
|
102
|
+
const data = await res.json() as { memories: PersistioMemory[] };
|
|
103
|
+
return data.memories ?? [];
|
|
85
104
|
});
|
|
86
|
-
if (!res.ok) throw new Error(`Persistio recall failed: ${res.status}`);
|
|
87
|
-
const data = await res.json() as { memories: PersistioMemory[] };
|
|
88
|
-
return data.memories ?? [];
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
async recallBundle(query: string, topK?: number): Promise<RecallBundleResponse> {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
return withRequestDeadline('recallBundle', this.recallTimeout, async (signal) => {
|
|
109
|
+
const body: Record<string, unknown> = { query, top_k: topK ?? this.recallTopK, include_pending: true };
|
|
110
|
+
if (typeof this.recallMinSimilarity === 'number') {
|
|
111
|
+
body.min_similarity = this.recallMinSimilarity;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: this.headers(),
|
|
117
|
+
body: JSON.stringify(body),
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) throw new Error(`Persistio recallBundle failed: ${res.status}`);
|
|
121
|
+
const data = await res.json() as RecallBundleResponse;
|
|
122
|
+
return data;
|
|
102
123
|
});
|
|
103
|
-
if (!res.ok) throw new Error(`Persistio recallBundle failed: ${res.status}`);
|
|
104
|
-
const data = await res.json() as RecallBundleResponse;
|
|
105
|
-
return data;
|
|
106
124
|
}
|
|
107
125
|
|
|
108
126
|
async ingest(sessionId: string, chunks: Array<{ role: string; content: string; timestamp: string }>): Promise<void> {
|
|
109
127
|
if (chunks.length === 0) return;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
await withRequestDeadline('ingest', this.ingestTimeout, async (signal) => {
|
|
129
|
+
const res = await fetch(`${this.baseURL}/v1/ingest`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: this.headers(),
|
|
132
|
+
body: JSON.stringify({ session_id: sessionId, chunks }),
|
|
133
|
+
signal,
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) throw new Error(await formatHttpError('ingest', res));
|
|
114
136
|
});
|
|
115
|
-
if (!res.ok) throw new Error(`Persistio ingest failed: ${res.status}`);
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
async addMemory(data: string, subject: string): Promise<void> {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
140
|
+
await withRequestDeadline('addMemory', this.writeTimeout, async (signal) => {
|
|
141
|
+
const res = await fetch(`${this.baseURL}/v1/memories`, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: this.headers(),
|
|
144
|
+
body: JSON.stringify({ data, subject }),
|
|
145
|
+
signal,
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok) throw new Error(`Persistio addMemory failed: ${res.status}`);
|
|
123
148
|
});
|
|
124
|
-
if (!res.ok) throw new Error(`Persistio addMemory failed: ${res.status}`);
|
|
125
149
|
}
|
|
126
150
|
|
|
127
151
|
async deleteMemory(id: string): Promise<void> {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
await withRequestDeadline('deleteMemory', this.writeTimeout, async (signal) => {
|
|
153
|
+
const res = await fetch(`${this.baseURL}/v1/memories/${id}`, {
|
|
154
|
+
method: 'DELETE',
|
|
155
|
+
headers: this.headers(),
|
|
156
|
+
signal,
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) throw new Error(`Persistio deleteMemory failed: ${res.status}`);
|
|
131
159
|
});
|
|
132
|
-
if (!res.ok) throw new Error(`Persistio deleteMemory failed: ${res.status}`);
|
|
133
160
|
}
|
|
134
161
|
|
|
135
162
|
async getMemory(id: string, options: GetMemoryOptions = {}): Promise<PersistioMemory | null> {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
163
|
+
return withRequestDeadline('getMemory', this.recallTimeout, async (signal) => {
|
|
164
|
+
const query = options.includePending ? '?include_pending=true' : '';
|
|
165
|
+
const res = await fetch(`${this.baseURL}/v1/memories/${id}${query}`, {
|
|
166
|
+
headers: this.headers(),
|
|
167
|
+
signal,
|
|
168
|
+
});
|
|
169
|
+
if (res.status === 404) return null;
|
|
170
|
+
if (!res.ok) throw new Error(`Persistio getMemory failed: ${res.status}`);
|
|
171
|
+
return await res.json() as PersistioMemory;
|
|
139
172
|
});
|
|
140
|
-
if (res.status === 404) return null;
|
|
141
|
-
if (!res.ok) throw new Error(`Persistio getMemory failed: ${res.status}`);
|
|
142
|
-
return await res.json() as PersistioMemory;
|
|
143
173
|
}
|
|
144
174
|
|
|
145
175
|
async listMemories(): Promise<PersistioMemory[]> {
|
|
146
|
-
|
|
147
|
-
|
|
176
|
+
return withRequestDeadline('listMemories', this.recallTimeout, async (signal) => {
|
|
177
|
+
const res = await fetch(`${this.baseURL}/v1/memories`, {
|
|
178
|
+
headers: this.headers(),
|
|
179
|
+
signal,
|
|
180
|
+
});
|
|
181
|
+
if (!res.ok) throw new Error(`Persistio listMemories failed: ${res.status}`);
|
|
182
|
+
const data = await res.json() as { items: PersistioMemory[] };
|
|
183
|
+
return data.items ?? [];
|
|
148
184
|
});
|
|
149
|
-
if (!res.ok) throw new Error(`Persistio listMemories failed: ${res.status}`);
|
|
150
|
-
const data = await res.json() as { items: PersistioMemory[] };
|
|
151
|
-
return data.items ?? [];
|
|
152
185
|
}
|
|
153
186
|
}
|
|
187
|
+
|
|
188
|
+
async function withRequestDeadline<T>(
|
|
189
|
+
operation: string,
|
|
190
|
+
timeoutMs: number,
|
|
191
|
+
run: (signal: AbortSignal) => Promise<T>,
|
|
192
|
+
): Promise<T> {
|
|
193
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
194
|
+
return run(new AbortController().signal);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
199
|
+
|
|
200
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
201
|
+
timeout = setTimeout(() => {
|
|
202
|
+
controller.abort();
|
|
203
|
+
reject(new PersistioTimeoutError(operation, timeoutMs));
|
|
204
|
+
}, timeoutMs);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
return await Promise.race([run(controller.signal), deadline]);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (controller.signal.aborted && isAbortLikeError(err)) {
|
|
211
|
+
throw new PersistioTimeoutError(operation, timeoutMs);
|
|
212
|
+
}
|
|
213
|
+
throw err;
|
|
214
|
+
} finally {
|
|
215
|
+
if (timeout) clearTimeout(timeout);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isAbortLikeError(err: unknown): boolean {
|
|
220
|
+
if (!(err instanceof Error)) return false;
|
|
221
|
+
return err.name === 'AbortError' || err.name === 'TimeoutError';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function formatHttpError(operation: string, res: Response): Promise<string> {
|
|
225
|
+
let detail = '';
|
|
226
|
+
try {
|
|
227
|
+
detail = (await res.text()).trim().slice(0, 500);
|
|
228
|
+
} catch {
|
|
229
|
+
// Ignore response body read failures; the status is still actionable.
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return detail
|
|
233
|
+
? `Persistio ${operation} failed: ${res.status} ${detail}`
|
|
234
|
+
: `Persistio ${operation} failed: ${res.status}`;
|
|
235
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,13 @@ import type {
|
|
|
7
7
|
} from 'openclaw/plugin-sdk/memory-core-host-engine-storage';
|
|
8
8
|
import { Type } from '@sinclair/typebox';
|
|
9
9
|
import { PersistioClient, type PersistioConfig, type PersistioMemory, type RecallBundle } from './client.js';
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
prepareMessageForIngest,
|
|
12
|
+
resolveIngestPolicy,
|
|
13
|
+
shouldIngestSession,
|
|
14
|
+
type OpenClawMessageRole,
|
|
15
|
+
type OmissionSummary,
|
|
16
|
+
} from './ingest-policy.js';
|
|
12
17
|
|
|
13
18
|
interface SessionMessageKeyStore {
|
|
14
19
|
keys: Set<string>;
|
|
@@ -24,6 +29,41 @@ const DEFAULT_SEND_ROLES: PersistioConfig['send']['roles'] = {
|
|
|
24
29
|
const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
30
|
const MAX_TRACKED_SESSIONS = 250;
|
|
26
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
|
+
|
|
36
|
+
interface PluginLogger {
|
|
37
|
+
debug?: (message: string) => void;
|
|
38
|
+
warn?: (message: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class RecallCircuitBreaker {
|
|
42
|
+
private consecutiveFailures = 0;
|
|
43
|
+
private openedUntil = 0;
|
|
44
|
+
|
|
45
|
+
canAttempt(now = Date.now()): boolean {
|
|
46
|
+
return now >= this.openedUntil;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
remainingMs(now = Date.now()): number {
|
|
50
|
+
return Math.max(0, this.openedUntil - now);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
recordSuccess(): void {
|
|
54
|
+
this.consecutiveFailures = 0;
|
|
55
|
+
this.openedUntil = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
recordFailure(now = Date.now()): boolean {
|
|
59
|
+
this.consecutiveFailures += 1;
|
|
60
|
+
if (this.consecutiveFailures >= RECALL_CIRCUIT_BREAKER_FAILURE_THRESHOLD) {
|
|
61
|
+
this.openedUntil = now + RECALL_CIRCUIT_BREAKER_COOLDOWN_MS;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
27
67
|
|
|
28
68
|
function resolveSendConfig(raw: Record<string, unknown>): PersistioConfig['send'] {
|
|
29
69
|
const send = raw['send'];
|
|
@@ -49,15 +89,22 @@ function resolveRecallMinSimilarity(value: unknown): number | undefined {
|
|
|
49
89
|
: undefined;
|
|
50
90
|
}
|
|
51
91
|
|
|
92
|
+
function resolvePositiveInteger(value: unknown, fallback: number): number {
|
|
93
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 1
|
|
94
|
+
? Math.floor(value)
|
|
95
|
+
: fallback;
|
|
96
|
+
}
|
|
97
|
+
|
|
52
98
|
function resolveConfig(raw: unknown): PersistioConfig {
|
|
53
99
|
const c = (raw ?? {}) as Record<string, unknown>;
|
|
54
100
|
return {
|
|
55
101
|
baseURL: typeof c['baseURL'] === 'string' ? c['baseURL'] : '',
|
|
56
102
|
apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
|
|
57
|
-
tokenBudget:
|
|
58
|
-
recallTopK:
|
|
103
|
+
tokenBudget: resolvePositiveInteger(c['tokenBudget'], 2000),
|
|
104
|
+
recallTopK: resolvePositiveInteger(c['recallTopK'], 10),
|
|
59
105
|
recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
|
|
60
|
-
recallTimeout:
|
|
106
|
+
recallTimeout: resolvePositiveInteger(c['recallTimeout'], 5000),
|
|
107
|
+
ingest: resolveIngestPolicy(c['ingest']),
|
|
61
108
|
send: resolveSendConfig(c),
|
|
62
109
|
};
|
|
63
110
|
}
|
|
@@ -133,29 +180,37 @@ function buildRecallQuery(event: { prompt?: string; messages?: unknown[] }): str
|
|
|
133
180
|
return truncate(parts.join('\n'), 600);
|
|
134
181
|
}
|
|
135
182
|
|
|
136
|
-
function
|
|
183
|
+
function toStringArray(value: unknown): string[] {
|
|
184
|
+
return Array.isArray(value)
|
|
185
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
186
|
+
: [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildMemoryBlock(bundle: RecallBundle | undefined, budget: number, relatedBundle?: RecallBundle): string {
|
|
190
|
+
if (!bundle || typeof bundle !== 'object') return '';
|
|
191
|
+
|
|
137
192
|
const sections: Array<{ title: string; items: string[] }> = [
|
|
138
|
-
{ title: 'Behavioural rules', items: bundle.user_rules },
|
|
139
|
-
{ title: 'Preferences', items: bundle.user_preferences },
|
|
140
|
-
{ title: 'Task patterns', items: bundle.task_patterns },
|
|
141
|
-
{ title: 'Workflows', items: bundle.workflows },
|
|
142
|
-
{ title: 'Project', items: bundle.project },
|
|
143
|
-
{ title: 'Constraints', items: bundle.constraints },
|
|
144
|
-
{ title: 'Decisions', items: bundle.decisions },
|
|
145
|
-
{ title: 'System facts', items: bundle.system_facts },
|
|
146
|
-
{ title: 'Domain knowledge', items: bundle.domain_knowledge },
|
|
193
|
+
{ title: 'Behavioural rules', items: toStringArray(bundle.user_rules) },
|
|
194
|
+
{ title: 'Preferences', items: toStringArray(bundle.user_preferences) },
|
|
195
|
+
{ title: 'Task patterns', items: toStringArray(bundle.task_patterns) },
|
|
196
|
+
{ title: 'Workflows', items: toStringArray(bundle.workflows) },
|
|
197
|
+
{ title: 'Project', items: toStringArray(bundle.project) },
|
|
198
|
+
{ title: 'Constraints', items: toStringArray(bundle.constraints) },
|
|
199
|
+
{ title: 'Decisions', items: toStringArray(bundle.decisions) },
|
|
200
|
+
{ title: 'System facts', items: toStringArray(bundle.system_facts) },
|
|
201
|
+
{ title: 'Domain knowledge', items: toStringArray(bundle.domain_knowledge) },
|
|
147
202
|
];
|
|
148
|
-
if (relatedBundle) {
|
|
203
|
+
if (relatedBundle && typeof relatedBundle === 'object') {
|
|
149
204
|
sections.push(
|
|
150
|
-
{ title: 'Related behavioural rules', items: relatedBundle.user_rules },
|
|
151
|
-
{ title: 'Related preferences', items: relatedBundle.user_preferences },
|
|
152
|
-
{ title: 'Related task patterns', items: relatedBundle.task_patterns },
|
|
153
|
-
{ title: 'Related workflows', items: relatedBundle.workflows },
|
|
154
|
-
{ title: 'Related project', items: relatedBundle.project },
|
|
155
|
-
{ title: 'Related constraints', items: relatedBundle.constraints },
|
|
156
|
-
{ title: 'Related decisions', items: relatedBundle.decisions },
|
|
157
|
-
{ title: 'Related system facts', items: relatedBundle.system_facts },
|
|
158
|
-
{ title: 'Related domain knowledge', items: relatedBundle.domain_knowledge },
|
|
205
|
+
{ title: 'Related behavioural rules', items: toStringArray(relatedBundle.user_rules) },
|
|
206
|
+
{ title: 'Related preferences', items: toStringArray(relatedBundle.user_preferences) },
|
|
207
|
+
{ title: 'Related task patterns', items: toStringArray(relatedBundle.task_patterns) },
|
|
208
|
+
{ title: 'Related workflows', items: toStringArray(relatedBundle.workflows) },
|
|
209
|
+
{ title: 'Related project', items: toStringArray(relatedBundle.project) },
|
|
210
|
+
{ title: 'Related constraints', items: toStringArray(relatedBundle.constraints) },
|
|
211
|
+
{ title: 'Related decisions', items: toStringArray(relatedBundle.decisions) },
|
|
212
|
+
{ title: 'Related system facts', items: toStringArray(relatedBundle.system_facts) },
|
|
213
|
+
{ title: 'Related domain knowledge', items: toStringArray(relatedBundle.domain_knowledge) },
|
|
159
214
|
);
|
|
160
215
|
}
|
|
161
216
|
|
|
@@ -303,6 +358,82 @@ function forgetKeys(target: Set<string>, keys: string[]): void {
|
|
|
303
358
|
for (const key of keys) target.delete(key);
|
|
304
359
|
}
|
|
305
360
|
|
|
361
|
+
function summarizeOmissions(omissions: OmissionSummary[]): string {
|
|
362
|
+
if (omissions.length === 0) return 'none';
|
|
363
|
+
const counts = new Map<string, number>();
|
|
364
|
+
for (const omission of omissions) {
|
|
365
|
+
counts.set(omission.label, (counts.get(omission.label) ?? 0) + 1);
|
|
366
|
+
}
|
|
367
|
+
return [...counts.entries()]
|
|
368
|
+
.map(([label, count]) => `${label}:${count}`)
|
|
369
|
+
.join(',');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isTimeoutLikeError(err: unknown): boolean {
|
|
373
|
+
if (typeof err !== 'object' || err === null) return false;
|
|
374
|
+
const record = err as Record<string, unknown>;
|
|
375
|
+
const name = typeof record['name'] === 'string' ? record['name'] : '';
|
|
376
|
+
if (name === 'TimeoutError' || name === 'AbortError') return true;
|
|
377
|
+
const message = typeof record['message'] === 'string' ? record['message'].toLowerCase() : '';
|
|
378
|
+
return message.includes('timeout') || message.includes('aborted');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function runGuardedRecall<T>(args: {
|
|
382
|
+
operation: string;
|
|
383
|
+
timeoutMs: number;
|
|
384
|
+
fallback: T;
|
|
385
|
+
breaker: RecallCircuitBreaker;
|
|
386
|
+
logger?: PluginLogger;
|
|
387
|
+
run: () => Promise<T>;
|
|
388
|
+
}): Promise<T> {
|
|
389
|
+
const now = Date.now();
|
|
390
|
+
if (!args.breaker.canAttempt(now)) {
|
|
391
|
+
args.logger?.warn?.(
|
|
392
|
+
`openclaw-persistio: ${args.operation} skipped; recall circuit breaker open `
|
|
393
|
+
+ `for ${args.breaker.remainingMs(now)}ms`,
|
|
394
|
+
);
|
|
395
|
+
return args.fallback;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
const result = await withPluginDeadline(args.operation, args.timeoutMs + RECALL_GUARD_MARGIN_MS, args.run);
|
|
400
|
+
args.breaker.recordSuccess();
|
|
401
|
+
return result;
|
|
402
|
+
} catch (err) {
|
|
403
|
+
const opened = args.breaker.recordFailure();
|
|
404
|
+
args.logger?.warn?.(
|
|
405
|
+
`openclaw-persistio: ${args.operation} failed open: ${String(err)}`
|
|
406
|
+
+ (opened ? `; recall circuit breaker open for ${RECALL_CIRCUIT_BREAKER_COOLDOWN_MS}ms` : ''),
|
|
407
|
+
);
|
|
408
|
+
return args.fallback;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function withPluginDeadline<T>(
|
|
413
|
+
operation: string,
|
|
414
|
+
timeoutMs: number,
|
|
415
|
+
run: () => Promise<T>,
|
|
416
|
+
): Promise<T> {
|
|
417
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
418
|
+
return run();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
422
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
423
|
+
timeout = setTimeout(() => {
|
|
424
|
+
const err = new Error(`Persistio ${operation} exceeded plugin deadline after ${timeoutMs}ms`);
|
|
425
|
+
err.name = 'TimeoutError';
|
|
426
|
+
reject(err);
|
|
427
|
+
}, timeoutMs);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
return await Promise.race([run(), deadline]);
|
|
432
|
+
} finally {
|
|
433
|
+
if (timeout) clearTimeout(timeout);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
306
437
|
const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
|
|
307
438
|
|
|
308
439
|
function createClient(config: PersistioConfig, recallTopK = config.recallTopK): PersistioClient {
|
|
@@ -353,7 +484,11 @@ async function probePersistio(client: PersistioClient): Promise<MemoryEmbeddingP
|
|
|
353
484
|
}
|
|
354
485
|
}
|
|
355
486
|
|
|
356
|
-
function createMemorySearchManager(
|
|
487
|
+
function createMemorySearchManager(
|
|
488
|
+
config: PersistioConfig,
|
|
489
|
+
recallBreaker: RecallCircuitBreaker,
|
|
490
|
+
logger?: PluginLogger,
|
|
491
|
+
): MemorySearchManager {
|
|
357
492
|
const client = createClient(config);
|
|
358
493
|
|
|
359
494
|
return {
|
|
@@ -374,7 +509,14 @@ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager
|
|
|
374
509
|
|
|
375
510
|
const recallTopK = typeof opts?.maxResults === 'number' ? opts.maxResults : config.recallTopK;
|
|
376
511
|
const recallClient = createClient(config, recallTopK);
|
|
377
|
-
const memories = await
|
|
512
|
+
const memories = await runGuardedRecall({
|
|
513
|
+
operation: 'memory search recall',
|
|
514
|
+
timeoutMs: config.recallTimeout,
|
|
515
|
+
fallback: [],
|
|
516
|
+
breaker: recallBreaker,
|
|
517
|
+
logger,
|
|
518
|
+
run: () => recallClient.recall(query),
|
|
519
|
+
});
|
|
378
520
|
|
|
379
521
|
return memories
|
|
380
522
|
.map((memory): MemorySearchResult => {
|
|
@@ -443,11 +585,11 @@ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager
|
|
|
443
585
|
};
|
|
444
586
|
}
|
|
445
587
|
|
|
446
|
-
function createMemoryRuntime(config: PersistioConfig) {
|
|
588
|
+
function createMemoryRuntime(config: PersistioConfig, recallBreaker: RecallCircuitBreaker, logger?: PluginLogger) {
|
|
447
589
|
return {
|
|
448
590
|
async getMemorySearchManager() {
|
|
449
591
|
return {
|
|
450
|
-
manager: createMemorySearchManager(config),
|
|
592
|
+
manager: createMemorySearchManager(config, recallBreaker, logger),
|
|
451
593
|
};
|
|
452
594
|
},
|
|
453
595
|
|
|
@@ -471,10 +613,11 @@ export default definePluginEntry({
|
|
|
471
613
|
}
|
|
472
614
|
|
|
473
615
|
const client = createClient(cfg);
|
|
616
|
+
const recallBreaker = new RecallCircuitBreaker();
|
|
474
617
|
const sentMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
|
|
475
618
|
const pendingMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
|
|
476
619
|
api.registerMemoryCapability({
|
|
477
|
-
runtime: createMemoryRuntime(cfg),
|
|
620
|
+
runtime: createMemoryRuntime(cfg, recallBreaker, api.logger),
|
|
478
621
|
});
|
|
479
622
|
|
|
480
623
|
// -------------------------------------------------------------------------
|
|
@@ -483,16 +626,21 @@ export default definePluginEntry({
|
|
|
483
626
|
// Return: { appendSystemContext?: string }
|
|
484
627
|
// -------------------------------------------------------------------------
|
|
485
628
|
api.on('before_prompt_build', async (event) => {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
629
|
+
const query = buildRecallQuery(event);
|
|
630
|
+
const block = await runGuardedRecall({
|
|
631
|
+
operation: 'before_prompt_build recall',
|
|
632
|
+
timeoutMs: cfg.recallTimeout,
|
|
633
|
+
fallback: '',
|
|
634
|
+
breaker: recallBreaker,
|
|
635
|
+
logger: api.logger,
|
|
636
|
+
run: async () => {
|
|
637
|
+
const recall = await client.recallBundle(query);
|
|
638
|
+
return buildMemoryBlock(recall.bundle, cfg.tokenBudget, recall.related_bundle);
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
if (!block) return;
|
|
642
|
+
return { appendSystemContext: block };
|
|
643
|
+
}, { timeoutMs: cfg.recallTimeout + RECALL_GUARD_MARGIN_MS + 250 });
|
|
496
644
|
|
|
497
645
|
// -------------------------------------------------------------------------
|
|
498
646
|
// agent_end — ingest new turn messages (fire and forget)
|
|
@@ -503,8 +651,18 @@ export default definePluginEntry({
|
|
|
503
651
|
try {
|
|
504
652
|
const sessionId = context?.sessionId ?? event.runId ?? 'unknown-session';
|
|
505
653
|
if (sessionId.startsWith('announce:')) return;
|
|
654
|
+
if (!shouldIngestSession(sessionId, cfg.ingest)) {
|
|
655
|
+
api.logger?.debug?.(`openclaw-persistio: ingest skipped non-main session: ${sessionId}`);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
506
658
|
const chunks: Array<{ role: string; content: string; timestamp: string }> = [];
|
|
507
659
|
const chunkKeys: string[] = [];
|
|
660
|
+
let agentCharsSent = 0;
|
|
661
|
+
let originalChars = 0;
|
|
662
|
+
let preparedChars = 0;
|
|
663
|
+
let truncatedMessages = 0;
|
|
664
|
+
let skippedMessages = 0;
|
|
665
|
+
const omissions: OmissionSummary[] = [];
|
|
508
666
|
const now = Date.now();
|
|
509
667
|
const sentKeys = getSessionKeyStore(sentMessageKeysBySession, sessionId, now);
|
|
510
668
|
const pendingKeys = getSessionKeyStore(pendingMessageKeysBySession, sessionId, now);
|
|
@@ -520,17 +678,55 @@ export default definePluginEntry({
|
|
|
520
678
|
if (sentKeys.has(key) || pendingKeys.has(key)) continue;
|
|
521
679
|
|
|
522
680
|
const ts = resolveMessageTimestamp(m) ?? new Date().toISOString();
|
|
681
|
+
const prepared = prepareMessageForIngest({
|
|
682
|
+
role,
|
|
683
|
+
text,
|
|
684
|
+
policy: cfg.ingest,
|
|
685
|
+
remainingAgentChars: Math.max(0, cfg.ingest.agent.maxCharsPerTurn - agentCharsSent),
|
|
686
|
+
remainingChunks: Math.max(0, cfg.ingest.maxChunksPerTurn - chunks.length),
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
originalChars += prepared.originalChars;
|
|
690
|
+
preparedChars += prepared.preparedChars;
|
|
691
|
+
omissions.push(...prepared.omissions);
|
|
692
|
+
if (prepared.truncated) truncatedMessages += 1;
|
|
693
|
+
if (prepared.chunks.length === 0) {
|
|
694
|
+
skippedMessages += 1;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
|
|
523
698
|
chunkKeys.push(key);
|
|
524
|
-
|
|
699
|
+
if (role === 'assistant') {
|
|
700
|
+
agentCharsSent += prepared.preparedChars;
|
|
701
|
+
}
|
|
702
|
+
chunks.push(...prepared.chunks.map((content) => ({ role, content, timestamp: ts })));
|
|
703
|
+
|
|
704
|
+
if (chunks.length >= cfg.ingest.maxChunksPerTurn) break;
|
|
525
705
|
}
|
|
526
706
|
|
|
527
707
|
if (chunks.length === 0) return;
|
|
708
|
+
if (truncatedMessages > 0 || omissions.length > 0 || skippedMessages > 0) {
|
|
709
|
+
api.logger?.info?.(
|
|
710
|
+
`openclaw-persistio: ingest planned session=${sessionId} chunks=${chunks.length} `
|
|
711
|
+
+ `originalChars=${originalChars} preparedChars=${preparedChars} `
|
|
712
|
+
+ `truncatedMessages=${truncatedMessages} skippedMessages=${skippedMessages} `
|
|
713
|
+
+ `omissions=${summarizeOmissions(omissions)}`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
528
716
|
rememberKeys(pendingKeys, chunkKeys);
|
|
529
717
|
client.ingest(sessionId, chunks)
|
|
530
718
|
.then(() => {
|
|
531
719
|
rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
|
|
532
720
|
})
|
|
533
721
|
.catch((err: unknown) => {
|
|
722
|
+
if (isTimeoutLikeError(err)) {
|
|
723
|
+
rememberKeys(sentKeys, chunkKeys, MAX_SENT_KEYS_PER_SESSION);
|
|
724
|
+
api.logger?.warn?.(
|
|
725
|
+
`openclaw-persistio: ingest timeout after ${cfg.ingest.timeoutMs}ms; `
|
|
726
|
+
+ `outcome is ambiguous, suppressing retry for ${chunkKeys.length} messages in session=${sessionId}`,
|
|
727
|
+
);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
534
730
|
api.logger?.warn?.(`openclaw-persistio: ingest error: ${String(err)}`);
|
|
535
731
|
})
|
|
536
732
|
.finally(() => {
|
|
@@ -558,10 +754,17 @@ export default definePluginEntry({
|
|
|
558
754
|
}),
|
|
559
755
|
async execute(_id, params) {
|
|
560
756
|
const p = params as { query: string; top_k?: number };
|
|
561
|
-
const overrideTopK =
|
|
757
|
+
const overrideTopK = resolvePositiveInteger(p.top_k, cfg.recallTopK);
|
|
562
758
|
const overrideCfg = { ...cfg, recallTopK: overrideTopK };
|
|
563
|
-
const
|
|
564
|
-
const memories = await
|
|
759
|
+
const recallClient = createClient(overrideCfg);
|
|
760
|
+
const memories = await runGuardedRecall({
|
|
761
|
+
operation: 'memory_search tool recall',
|
|
762
|
+
timeoutMs: cfg.recallTimeout,
|
|
763
|
+
fallback: [],
|
|
764
|
+
breaker: recallBreaker,
|
|
765
|
+
logger: api.logger,
|
|
766
|
+
run: () => recallClient.recall(p.query),
|
|
767
|
+
});
|
|
565
768
|
const text = memories.length > 0
|
|
566
769
|
? memories.map(m => `- ${m.data} [${m.subject}]`).join('\n')
|
|
567
770
|
: 'No memories found.';
|
|
@@ -579,7 +782,23 @@ export default definePluginEntry({
|
|
|
579
782
|
}),
|
|
580
783
|
async execute(_id, params) {
|
|
581
784
|
const p = params as { data: string; subject: string };
|
|
582
|
-
|
|
785
|
+
try {
|
|
786
|
+
await client.addMemory(p.data, p.subject);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
if (isTimeoutLikeError(err)) {
|
|
789
|
+
api.logger?.warn?.(
|
|
790
|
+
`openclaw-persistio: memory_add timeout after ${cfg.ingest.timeoutMs}ms; outcome is ambiguous`,
|
|
791
|
+
);
|
|
792
|
+
return {
|
|
793
|
+
content: [{
|
|
794
|
+
type: 'text' as const,
|
|
795
|
+
text: 'Memory store request timed out; it may still complete. Check memory_list before retrying.',
|
|
796
|
+
}],
|
|
797
|
+
details: { ambiguous: true },
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
throw err;
|
|
801
|
+
}
|
|
583
802
|
return { content: [{ type: 'text' as const, text: 'Memory stored.' }], details: null };
|
|
584
803
|
},
|
|
585
804
|
});
|