@persistio/openclaw-plugin 0.1.6 → 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 +8 -2
- package/dist/client.d.ts +4 -0
- package/dist/client.js +115 -58
- package/dist/index.js +145 -35
- package/openclaw.plugin.json +35 -14
- package/package.json +2 -2
- package/src/client.ts +115 -52
- package/src/index.ts +187 -42
package/README.md
CHANGED
|
@@ -12,7 +12,10 @@ Hooks into OpenClaw's `before_prompt_build` and `agent_end` events to automatica
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
|
|
15
|
+
openclaw plugins install npm:@persistio/openclaw-plugin
|
|
16
|
+
openclaw plugins enable openclaw-persistio
|
|
17
|
+
openclaw gateway restart
|
|
18
|
+
openclaw plugins inspect openclaw-persistio --runtime --json
|
|
16
19
|
```
|
|
17
20
|
|
|
18
21
|
Then register it in your OpenClaw config:
|
|
@@ -21,7 +24,8 @@ Then register it in your OpenClaw config:
|
|
|
21
24
|
{
|
|
22
25
|
"plugins": {
|
|
23
26
|
"entries": {
|
|
24
|
-
"persistio": {
|
|
27
|
+
"openclaw-persistio": {
|
|
28
|
+
"enabled": true,
|
|
25
29
|
"package": "@persistio/openclaw-plugin",
|
|
26
30
|
"config": {
|
|
27
31
|
"baseURL": "https://api.persistio.ai",
|
|
@@ -64,6 +68,8 @@ Then register it in your OpenClaw config:
|
|
|
64
68
|
| `send.roles.agent` | `"enabled"` or `"disabled"` | | `"enabled"` | Send agent/assistant messages to Persistio ingest |
|
|
65
69
|
| `send.roles.tool` | `"enabled"` or `"disabled"` | | `"disabled"` | Send tool messages to Persistio ingest |
|
|
66
70
|
|
|
71
|
+
Recall is fail-open by design. If Persistio does not answer within `recallTimeout`, the plugin returns no memory for that turn instead of blocking the OpenClaw lane. After three consecutive recall/search failures it opens a 60 second circuit breaker and skips recall immediately during the cooldown. The plugin also registers a bounded `before_prompt_build` hook timeout; operators can still override this in OpenClaw with `plugins.entries.<id>.hooks.timeouts.before_prompt_build`.
|
|
72
|
+
|
|
67
73
|
`agent_end` receives a snapshot of the active OpenClaw transcript, so the plugin deduplicates per session and only sends each user, agent, or enabled tool message once per plugin process. Deduplication keys are bounded in memory and expire after 24 hours of session inactivity.
|
|
68
74
|
|
|
69
75
|
Assistant ingest is bounded before any network call. By default the plugin skips non-main `agent:*` sessions, collapses oversized code/log/diff/blob/table-shaped assistant content into omission markers, caps assistant ingest per message and per turn, then chunks all ingest content below `ingest.maxChunkChars`. Persistio still performs server-side extraction and curation; the plugin only enforces a deterministic transport-safe shape.
|
package/dist/client.d.ts
CHANGED
|
@@ -44,6 +44,9 @@ export interface RecallBundleResponse {
|
|
|
44
44
|
bundle: RecallBundle;
|
|
45
45
|
related_bundle?: RecallBundle;
|
|
46
46
|
}
|
|
47
|
+
export declare class PersistioTimeoutError extends Error {
|
|
48
|
+
constructor(operation: string, timeoutMs: number);
|
|
49
|
+
}
|
|
47
50
|
export declare class PersistioClient {
|
|
48
51
|
private readonly baseURL;
|
|
49
52
|
private readonly apiKey;
|
|
@@ -51,6 +54,7 @@ export declare class PersistioClient {
|
|
|
51
54
|
private readonly recallMinSimilarity?;
|
|
52
55
|
private readonly recallTimeout;
|
|
53
56
|
private readonly ingestTimeout;
|
|
57
|
+
private readonly writeTimeout;
|
|
54
58
|
constructor(config: PersistioConfig);
|
|
55
59
|
private headers;
|
|
56
60
|
recall(query: string): Promise<PersistioMemory[]>;
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export class PersistioTimeoutError extends Error {
|
|
2
|
+
constructor(operation, timeoutMs) {
|
|
3
|
+
super(`Persistio ${operation} timed out after ${timeoutMs}ms`);
|
|
4
|
+
this.name = 'TimeoutError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
1
7
|
export class PersistioClient {
|
|
2
8
|
baseURL;
|
|
3
9
|
apiKey;
|
|
@@ -5,6 +11,7 @@ export class PersistioClient {
|
|
|
5
11
|
recallMinSimilarity;
|
|
6
12
|
recallTimeout;
|
|
7
13
|
ingestTimeout;
|
|
14
|
+
writeTimeout;
|
|
8
15
|
constructor(config) {
|
|
9
16
|
this.baseURL = config.baseURL.replace(/\/$/, '');
|
|
10
17
|
this.apiKey = config.apiKey;
|
|
@@ -12,6 +19,7 @@ export class PersistioClient {
|
|
|
12
19
|
this.recallMinSimilarity = config.recallMinSimilarity;
|
|
13
20
|
this.recallTimeout = config.recallTimeout;
|
|
14
21
|
this.ingestTimeout = config.ingest.timeoutMs;
|
|
22
|
+
this.writeTimeout = config.ingest.timeoutMs;
|
|
15
23
|
}
|
|
16
24
|
headers() {
|
|
17
25
|
return {
|
|
@@ -20,87 +28,136 @@ export class PersistioClient {
|
|
|
20
28
|
};
|
|
21
29
|
}
|
|
22
30
|
async recall(query) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
return withRequestDeadline('recall', this.recallTimeout, async (signal) => {
|
|
32
|
+
const body = { query, top_k: this.recallTopK, include_pending: true };
|
|
33
|
+
if (typeof this.recallMinSimilarity === 'number') {
|
|
34
|
+
body.min_similarity = this.recallMinSimilarity;
|
|
35
|
+
}
|
|
36
|
+
const res = await fetch(`${this.baseURL}/v1/recall`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: this.headers(),
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
signal,
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new Error(`Persistio recall failed: ${res.status}`);
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
return data.memories ?? [];
|
|
32
46
|
});
|
|
33
|
-
if (!res.ok)
|
|
34
|
-
throw new Error(`Persistio recall failed: ${res.status}`);
|
|
35
|
-
const data = await res.json();
|
|
36
|
-
return data.memories ?? [];
|
|
37
47
|
}
|
|
38
48
|
async recallBundle(query, topK) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
return withRequestDeadline('recallBundle', this.recallTimeout, async (signal) => {
|
|
50
|
+
const body = { query, top_k: topK ?? this.recallTopK, include_pending: true };
|
|
51
|
+
if (typeof this.recallMinSimilarity === 'number') {
|
|
52
|
+
body.min_similarity = this.recallMinSimilarity;
|
|
53
|
+
}
|
|
54
|
+
const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: this.headers(),
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
signal,
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
throw new Error(`Persistio recallBundle failed: ${res.status}`);
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
return data;
|
|
48
64
|
});
|
|
49
|
-
if (!res.ok)
|
|
50
|
-
throw new Error(`Persistio recallBundle failed: ${res.status}`);
|
|
51
|
-
const data = await res.json();
|
|
52
|
-
return data;
|
|
53
65
|
}
|
|
54
66
|
async ingest(sessionId, chunks) {
|
|
55
67
|
if (chunks.length === 0)
|
|
56
68
|
return;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
await withRequestDeadline('ingest', this.ingestTimeout, async (signal) => {
|
|
70
|
+
const res = await fetch(`${this.baseURL}/v1/ingest`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: this.headers(),
|
|
73
|
+
body: JSON.stringify({ session_id: sessionId, chunks }),
|
|
74
|
+
signal,
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new Error(await formatHttpError('ingest', res));
|
|
62
78
|
});
|
|
63
|
-
if (!res.ok)
|
|
64
|
-
throw new Error(await formatHttpError('ingest', res));
|
|
65
79
|
}
|
|
66
80
|
async addMemory(data, subject) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
await withRequestDeadline('addMemory', this.writeTimeout, async (signal) => {
|
|
82
|
+
const res = await fetch(`${this.baseURL}/v1/memories`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: this.headers(),
|
|
85
|
+
body: JSON.stringify({ data, subject }),
|
|
86
|
+
signal,
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok)
|
|
89
|
+
throw new Error(`Persistio addMemory failed: ${res.status}`);
|
|
71
90
|
});
|
|
72
|
-
if (!res.ok)
|
|
73
|
-
throw new Error(`Persistio addMemory failed: ${res.status}`);
|
|
74
91
|
}
|
|
75
92
|
async deleteMemory(id) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
await withRequestDeadline('deleteMemory', this.writeTimeout, async (signal) => {
|
|
94
|
+
const res = await fetch(`${this.baseURL}/v1/memories/${id}`, {
|
|
95
|
+
method: 'DELETE',
|
|
96
|
+
headers: this.headers(),
|
|
97
|
+
signal,
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok)
|
|
100
|
+
throw new Error(`Persistio deleteMemory failed: ${res.status}`);
|
|
79
101
|
});
|
|
80
|
-
if (!res.ok)
|
|
81
|
-
throw new Error(`Persistio deleteMemory failed: ${res.status}`);
|
|
82
102
|
}
|
|
83
103
|
async getMemory(id, options = {}) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
return withRequestDeadline('getMemory', this.recallTimeout, async (signal) => {
|
|
105
|
+
const query = options.includePending ? '?include_pending=true' : '';
|
|
106
|
+
const res = await fetch(`${this.baseURL}/v1/memories/${id}${query}`, {
|
|
107
|
+
headers: this.headers(),
|
|
108
|
+
signal,
|
|
109
|
+
});
|
|
110
|
+
if (res.status === 404)
|
|
111
|
+
return null;
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
throw new Error(`Persistio getMemory failed: ${res.status}`);
|
|
114
|
+
return await res.json();
|
|
87
115
|
});
|
|
88
|
-
if (res.status === 404)
|
|
89
|
-
return null;
|
|
90
|
-
if (!res.ok)
|
|
91
|
-
throw new Error(`Persistio getMemory failed: ${res.status}`);
|
|
92
|
-
return await res.json();
|
|
93
116
|
}
|
|
94
117
|
async listMemories() {
|
|
95
|
-
|
|
96
|
-
|
|
118
|
+
return withRequestDeadline('listMemories', this.recallTimeout, async (signal) => {
|
|
119
|
+
const res = await fetch(`${this.baseURL}/v1/memories`, {
|
|
120
|
+
headers: this.headers(),
|
|
121
|
+
signal,
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok)
|
|
124
|
+
throw new Error(`Persistio listMemories failed: ${res.status}`);
|
|
125
|
+
const data = await res.json();
|
|
126
|
+
return data.items ?? [];
|
|
97
127
|
});
|
|
98
|
-
if (!res.ok)
|
|
99
|
-
throw new Error(`Persistio listMemories failed: ${res.status}`);
|
|
100
|
-
const data = await res.json();
|
|
101
|
-
return data.items ?? [];
|
|
102
128
|
}
|
|
103
129
|
}
|
|
130
|
+
async function withRequestDeadline(operation, timeoutMs, run) {
|
|
131
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
132
|
+
return run(new AbortController().signal);
|
|
133
|
+
}
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
let timeout;
|
|
136
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
137
|
+
timeout = setTimeout(() => {
|
|
138
|
+
controller.abort();
|
|
139
|
+
reject(new PersistioTimeoutError(operation, timeoutMs));
|
|
140
|
+
}, timeoutMs);
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
return await Promise.race([run(controller.signal), deadline]);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (controller.signal.aborted && isAbortLikeError(err)) {
|
|
147
|
+
throw new PersistioTimeoutError(operation, timeoutMs);
|
|
148
|
+
}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
if (timeout)
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function isAbortLikeError(err) {
|
|
157
|
+
if (!(err instanceof Error))
|
|
158
|
+
return false;
|
|
159
|
+
return err.name === 'AbortError' || err.name === 'TimeoutError';
|
|
160
|
+
}
|
|
104
161
|
async function formatHttpError(operation, res) {
|
|
105
162
|
let detail = '';
|
|
106
163
|
try {
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,31 @@ const DEFAULT_SEND_ROLES = {
|
|
|
10
10
|
const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
11
|
const MAX_TRACKED_SESSIONS = 250;
|
|
12
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;
|
|
15
|
+
const RECALL_GUARD_MARGIN_MS = 250;
|
|
16
|
+
class RecallCircuitBreaker {
|
|
17
|
+
consecutiveFailures = 0;
|
|
18
|
+
openedUntil = 0;
|
|
19
|
+
canAttempt(now = Date.now()) {
|
|
20
|
+
return now >= this.openedUntil;
|
|
21
|
+
}
|
|
22
|
+
remainingMs(now = Date.now()) {
|
|
23
|
+
return Math.max(0, this.openedUntil - now);
|
|
24
|
+
}
|
|
25
|
+
recordSuccess() {
|
|
26
|
+
this.consecutiveFailures = 0;
|
|
27
|
+
this.openedUntil = 0;
|
|
28
|
+
}
|
|
29
|
+
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;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
13
38
|
function resolveSendConfig(raw) {
|
|
14
39
|
const send = raw['send'];
|
|
15
40
|
const roles = typeof send === 'object' && send !== null
|
|
@@ -31,15 +56,20 @@ function resolveRecallMinSimilarity(value) {
|
|
|
31
56
|
? value
|
|
32
57
|
: undefined;
|
|
33
58
|
}
|
|
59
|
+
function resolvePositiveInteger(value, fallback) {
|
|
60
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 1
|
|
61
|
+
? Math.floor(value)
|
|
62
|
+
: fallback;
|
|
63
|
+
}
|
|
34
64
|
function resolveConfig(raw) {
|
|
35
65
|
const c = (raw ?? {});
|
|
36
66
|
return {
|
|
37
67
|
baseURL: typeof c['baseURL'] === 'string' ? c['baseURL'] : '',
|
|
38
68
|
apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
|
|
39
|
-
tokenBudget:
|
|
40
|
-
recallTopK:
|
|
69
|
+
tokenBudget: resolvePositiveInteger(c['tokenBudget'], 2000),
|
|
70
|
+
recallTopK: resolvePositiveInteger(c['recallTopK'], 10),
|
|
41
71
|
recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
|
|
42
|
-
recallTimeout:
|
|
72
|
+
recallTimeout: resolvePositiveInteger(c['recallTimeout'], 5000),
|
|
43
73
|
ingest: resolveIngestPolicy(c['ingest']),
|
|
44
74
|
send: resolveSendConfig(c),
|
|
45
75
|
};
|
|
@@ -110,20 +140,27 @@ function buildRecallQuery(event) {
|
|
|
110
140
|
parts.push(`[task: ${taskType}]`);
|
|
111
141
|
return truncate(parts.join('\n'), 600);
|
|
112
142
|
}
|
|
143
|
+
function toStringArray(value) {
|
|
144
|
+
return Array.isArray(value)
|
|
145
|
+
? value.filter((item) => typeof item === 'string')
|
|
146
|
+
: [];
|
|
147
|
+
}
|
|
113
148
|
function buildMemoryBlock(bundle, budget, relatedBundle) {
|
|
149
|
+
if (!bundle || typeof bundle !== 'object')
|
|
150
|
+
return '';
|
|
114
151
|
const sections = [
|
|
115
|
-
{ title: 'Behavioural rules', items: bundle.user_rules },
|
|
116
|
-
{ title: 'Preferences', items: bundle.user_preferences },
|
|
117
|
-
{ title: 'Task patterns', items: bundle.task_patterns },
|
|
118
|
-
{ title: 'Workflows', items: bundle.workflows },
|
|
119
|
-
{ title: 'Project', items: bundle.project },
|
|
120
|
-
{ title: 'Constraints', items: bundle.constraints },
|
|
121
|
-
{ title: 'Decisions', items: bundle.decisions },
|
|
122
|
-
{ title: 'System facts', items: bundle.system_facts },
|
|
123
|
-
{ title: 'Domain knowledge', items: bundle.domain_knowledge },
|
|
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) },
|
|
124
161
|
];
|
|
125
|
-
if (relatedBundle) {
|
|
126
|
-
sections.push({ title: 'Related behavioural rules', items: relatedBundle.user_rules }, { title: 'Related preferences', items: relatedBundle.user_preferences }, { title: 'Related task patterns', items: relatedBundle.task_patterns }, { title: 'Related workflows', items: relatedBundle.workflows }, { title: 'Related project', items: relatedBundle.project }, { title: 'Related constraints', items: relatedBundle.constraints }, { title: 'Related decisions', items: relatedBundle.decisions }, { title: 'Related system facts', items: relatedBundle.system_facts }, { title: 'Related domain knowledge', items: relatedBundle.domain_knowledge });
|
|
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) });
|
|
127
164
|
}
|
|
128
165
|
const intro = 'Use the following as prior context and preferences. If they conflict with current instructions, follow the current instructions.';
|
|
129
166
|
const lines = [intro];
|
|
@@ -276,6 +313,45 @@ function isTimeoutLikeError(err) {
|
|
|
276
313
|
const message = typeof record['message'] === 'string' ? record['message'].toLowerCase() : '';
|
|
277
314
|
return message.includes('timeout') || message.includes('aborted');
|
|
278
315
|
}
|
|
316
|
+
async function runGuardedRecall(args) {
|
|
317
|
+
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;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const result = await withPluginDeadline(args.operation, args.timeoutMs + RECALL_GUARD_MARGIN_MS, args.run);
|
|
325
|
+
args.breaker.recordSuccess();
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
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);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
279
355
|
const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
|
|
280
356
|
function createClient(config, recallTopK = config.recallTopK) {
|
|
281
357
|
return new PersistioClient({ ...config, recallTopK });
|
|
@@ -318,7 +394,7 @@ async function probePersistio(client) {
|
|
|
318
394
|
return { ok: false, error: String(err) };
|
|
319
395
|
}
|
|
320
396
|
}
|
|
321
|
-
function createMemorySearchManager(config) {
|
|
397
|
+
function createMemorySearchManager(config, recallBreaker, logger) {
|
|
322
398
|
const client = createClient(config);
|
|
323
399
|
return {
|
|
324
400
|
async search(query, opts) {
|
|
@@ -327,7 +403,14 @@ function createMemorySearchManager(config) {
|
|
|
327
403
|
}
|
|
328
404
|
const recallTopK = typeof opts?.maxResults === 'number' ? opts.maxResults : config.recallTopK;
|
|
329
405
|
const recallClient = createClient(config, recallTopK);
|
|
330
|
-
const memories = await
|
|
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
|
+
});
|
|
331
414
|
return memories
|
|
332
415
|
.map((memory) => {
|
|
333
416
|
const score = normalizeMemoryScore(memory);
|
|
@@ -384,11 +467,11 @@ function createMemorySearchManager(config) {
|
|
|
384
467
|
},
|
|
385
468
|
};
|
|
386
469
|
}
|
|
387
|
-
function createMemoryRuntime(config) {
|
|
470
|
+
function createMemoryRuntime(config, recallBreaker, logger) {
|
|
388
471
|
return {
|
|
389
472
|
async getMemorySearchManager() {
|
|
390
473
|
return {
|
|
391
|
-
manager: createMemorySearchManager(config),
|
|
474
|
+
manager: createMemorySearchManager(config, recallBreaker, logger),
|
|
392
475
|
};
|
|
393
476
|
},
|
|
394
477
|
resolveMemoryBackendConfig() {
|
|
@@ -407,10 +490,11 @@ export default definePluginEntry({
|
|
|
407
490
|
return;
|
|
408
491
|
}
|
|
409
492
|
const client = createClient(cfg);
|
|
493
|
+
const recallBreaker = new RecallCircuitBreaker();
|
|
410
494
|
const sentMessageKeysBySession = new Map();
|
|
411
495
|
const pendingMessageKeysBySession = new Map();
|
|
412
496
|
api.registerMemoryCapability({
|
|
413
|
-
runtime: createMemoryRuntime(cfg),
|
|
497
|
+
runtime: createMemoryRuntime(cfg, recallBreaker, api.logger),
|
|
414
498
|
});
|
|
415
499
|
// -------------------------------------------------------------------------
|
|
416
500
|
// before_prompt_build — recall relevant memories and inject into context
|
|
@@ -418,18 +502,22 @@ export default definePluginEntry({
|
|
|
418
502
|
// Return: { appendSystemContext?: string }
|
|
419
503
|
// -------------------------------------------------------------------------
|
|
420
504
|
api.on('before_prompt_build', async (event) => {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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 });
|
|
433
521
|
// -------------------------------------------------------------------------
|
|
434
522
|
// agent_end — ingest new turn messages (fire and forget)
|
|
435
523
|
// Event: { runId?, messages: unknown[], success: boolean, error?, durationMs? }
|
|
@@ -537,10 +625,17 @@ export default definePluginEntry({
|
|
|
537
625
|
}),
|
|
538
626
|
async execute(_id, params) {
|
|
539
627
|
const p = params;
|
|
540
|
-
const overrideTopK =
|
|
628
|
+
const overrideTopK = resolvePositiveInteger(p.top_k, cfg.recallTopK);
|
|
541
629
|
const overrideCfg = { ...cfg, recallTopK: overrideTopK };
|
|
542
|
-
const
|
|
543
|
-
const memories = await
|
|
630
|
+
const recallClient = createClient(overrideCfg);
|
|
631
|
+
const memories = await runGuardedRecall({
|
|
632
|
+
operation: 'memory_search tool recall',
|
|
633
|
+
timeoutMs: cfg.recallTimeout,
|
|
634
|
+
fallback: [],
|
|
635
|
+
breaker: recallBreaker,
|
|
636
|
+
logger: api.logger,
|
|
637
|
+
run: () => recallClient.recall(p.query),
|
|
638
|
+
});
|
|
544
639
|
const text = memories.length > 0
|
|
545
640
|
? memories.map(m => `- ${m.data} [${m.subject}]`).join('\n')
|
|
546
641
|
: 'No memories found.';
|
|
@@ -557,7 +652,22 @@ export default definePluginEntry({
|
|
|
557
652
|
}),
|
|
558
653
|
async execute(_id, params) {
|
|
559
654
|
const p = params;
|
|
560
|
-
|
|
655
|
+
try {
|
|
656
|
+
await client.addMemory(p.data, p.subject);
|
|
657
|
+
}
|
|
658
|
+
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
|
+
};
|
|
668
|
+
}
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
561
671
|
return { content: [{ type: 'text', text: 'Memory stored.' }], details: null };
|
|
562
672
|
},
|
|
563
673
|
});
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-persistio",
|
|
3
3
|
"name": "Persistio Memory",
|
|
4
4
|
"description": "Persistent semantic memory for OpenClaw via Persistio",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.7",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"activation": {
|
|
8
8
|
"onStartup": true
|
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
"memory_list"
|
|
16
16
|
]
|
|
17
17
|
},
|
|
18
|
+
"toolMetadata": {
|
|
19
|
+
"memory_delete": {
|
|
20
|
+
"optional": true
|
|
21
|
+
},
|
|
22
|
+
"memory_list": {
|
|
23
|
+
"optional": true
|
|
24
|
+
}
|
|
25
|
+
},
|
|
18
26
|
"configSchema": {
|
|
19
27
|
"type": "object",
|
|
20
28
|
"additionalProperties": false,
|
|
@@ -26,10 +34,12 @@
|
|
|
26
34
|
"type": "string"
|
|
27
35
|
},
|
|
28
36
|
"tokenBudget": {
|
|
29
|
-
"type": "number"
|
|
37
|
+
"type": "number",
|
|
38
|
+
"minimum": 1
|
|
30
39
|
},
|
|
31
40
|
"recallTopK": {
|
|
32
|
-
"type": "number"
|
|
41
|
+
"type": "number",
|
|
42
|
+
"minimum": 1
|
|
33
43
|
},
|
|
34
44
|
"recallMinSimilarity": {
|
|
35
45
|
"type": "number",
|
|
@@ -37,20 +47,24 @@
|
|
|
37
47
|
"maximum": 1
|
|
38
48
|
},
|
|
39
49
|
"recallTimeout": {
|
|
40
|
-
"type": "number"
|
|
50
|
+
"type": "number",
|
|
51
|
+
"minimum": 1
|
|
41
52
|
},
|
|
42
53
|
"ingest": {
|
|
43
54
|
"type": "object",
|
|
44
55
|
"additionalProperties": false,
|
|
45
56
|
"properties": {
|
|
46
57
|
"timeoutMs": {
|
|
47
|
-
"type": "number"
|
|
58
|
+
"type": "number",
|
|
59
|
+
"minimum": 1
|
|
48
60
|
},
|
|
49
61
|
"maxChunkChars": {
|
|
50
|
-
"type": "number"
|
|
62
|
+
"type": "number",
|
|
63
|
+
"minimum": 256
|
|
51
64
|
},
|
|
52
65
|
"maxChunksPerTurn": {
|
|
53
|
-
"type": "number"
|
|
66
|
+
"type": "number",
|
|
67
|
+
"minimum": 1
|
|
54
68
|
},
|
|
55
69
|
"skipSubagentSessions": {
|
|
56
70
|
"type": "boolean"
|
|
@@ -60,7 +74,8 @@
|
|
|
60
74
|
"additionalProperties": false,
|
|
61
75
|
"properties": {
|
|
62
76
|
"maxCharsPerMessage": {
|
|
63
|
-
"type": "number"
|
|
77
|
+
"type": "number",
|
|
78
|
+
"minimum": 1
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
81
|
},
|
|
@@ -76,22 +91,28 @@
|
|
|
76
91
|
]
|
|
77
92
|
},
|
|
78
93
|
"maxCharsPerMessage": {
|
|
79
|
-
"type": "number"
|
|
94
|
+
"type": "number",
|
|
95
|
+
"minimum": 1
|
|
80
96
|
},
|
|
81
97
|
"maxCharsAfterFiltering": {
|
|
82
|
-
"type": "number"
|
|
98
|
+
"type": "number",
|
|
99
|
+
"minimum": 1
|
|
83
100
|
},
|
|
84
101
|
"maxCharsPerTurn": {
|
|
85
|
-
"type": "number"
|
|
102
|
+
"type": "number",
|
|
103
|
+
"minimum": 1
|
|
86
104
|
},
|
|
87
105
|
"largeBlockThresholdChars": {
|
|
88
|
-
"type": "number"
|
|
106
|
+
"type": "number",
|
|
107
|
+
"minimum": 1
|
|
89
108
|
},
|
|
90
109
|
"largeBlockThresholdLines": {
|
|
91
|
-
"type": "number"
|
|
110
|
+
"type": "number",
|
|
111
|
+
"minimum": 1
|
|
92
112
|
},
|
|
93
113
|
"maxTableRows": {
|
|
94
|
-
"type": "number"
|
|
114
|
+
"type": "number",
|
|
115
|
+
"minimum": 1
|
|
95
116
|
}
|
|
96
117
|
}
|
|
97
118
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@persistio/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "OpenClaw plugin for Persistio \u2014 persistent semantic memory for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsc",
|
|
45
|
-
"test": "npm run build && node test/config-schema.test.mjs && node test/ingest-policy.test.mjs"
|
|
45
|
+
"test": "npm run build && node test/config-schema.test.mjs && node test/ingest-policy.test.mjs && node test/client-timeout.test.mjs"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@sinclair/typebox": "^0.34.0"
|
package/src/client.ts
CHANGED
|
@@ -52,6 +52,13 @@ export interface RecallBundleResponse {
|
|
|
52
52
|
related_bundle?: RecallBundle;
|
|
53
53
|
}
|
|
54
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
|
+
|
|
55
62
|
export class PersistioClient {
|
|
56
63
|
private readonly baseURL: string;
|
|
57
64
|
private readonly apiKey: string;
|
|
@@ -59,6 +66,7 @@ export class PersistioClient {
|
|
|
59
66
|
private readonly recallMinSimilarity?: number;
|
|
60
67
|
private readonly recallTimeout: number;
|
|
61
68
|
private readonly ingestTimeout: number;
|
|
69
|
+
private readonly writeTimeout: number;
|
|
62
70
|
|
|
63
71
|
constructor(config: PersistioConfig) {
|
|
64
72
|
this.baseURL = config.baseURL.replace(/\/$/, '');
|
|
@@ -67,6 +75,7 @@ export class PersistioClient {
|
|
|
67
75
|
this.recallMinSimilarity = config.recallMinSimilarity;
|
|
68
76
|
this.recallTimeout = config.recallTimeout;
|
|
69
77
|
this.ingestTimeout = config.ingest.timeoutMs;
|
|
78
|
+
this.writeTimeout = config.ingest.timeoutMs;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
private headers(): Record<string, string> {
|
|
@@ -77,87 +86,141 @@ export class PersistioClient {
|
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
async recall(query: string): Promise<PersistioMemory[]> {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 ?? [];
|
|
90
104
|
});
|
|
91
|
-
if (!res.ok) throw new Error(`Persistio recall failed: ${res.status}`);
|
|
92
|
-
const data = await res.json() as { memories: PersistioMemory[] };
|
|
93
|
-
return data.memories ?? [];
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
async recallBundle(query: string, topK?: number): Promise<RecallBundleResponse> {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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;
|
|
107
123
|
});
|
|
108
|
-
if (!res.ok) throw new Error(`Persistio recallBundle failed: ${res.status}`);
|
|
109
|
-
const data = await res.json() as RecallBundleResponse;
|
|
110
|
-
return data;
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
async ingest(sessionId: string, chunks: Array<{ role: string; content: string; timestamp: string }>): Promise<void> {
|
|
114
127
|
if (chunks.length === 0) return;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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));
|
|
120
136
|
});
|
|
121
|
-
if (!res.ok) throw new Error(await formatHttpError('ingest', res));
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
async addMemory(data: string, subject: string): Promise<void> {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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}`);
|
|
129
148
|
});
|
|
130
|
-
if (!res.ok) throw new Error(`Persistio addMemory failed: ${res.status}`);
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
async deleteMemory(id: string): Promise<void> {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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}`);
|
|
137
159
|
});
|
|
138
|
-
if (!res.ok) throw new Error(`Persistio deleteMemory failed: ${res.status}`);
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
async getMemory(id: string, options: GetMemoryOptions = {}): Promise<PersistioMemory | null> {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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;
|
|
145
172
|
});
|
|
146
|
-
if (res.status === 404) return null;
|
|
147
|
-
if (!res.ok) throw new Error(`Persistio getMemory failed: ${res.status}`);
|
|
148
|
-
return await res.json() as PersistioMemory;
|
|
149
173
|
}
|
|
150
174
|
|
|
151
175
|
async listMemories(): Promise<PersistioMemory[]> {
|
|
152
|
-
|
|
153
|
-
|
|
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 ?? [];
|
|
154
184
|
});
|
|
155
|
-
if (!res.ok) throw new Error(`Persistio listMemories failed: ${res.status}`);
|
|
156
|
-
const data = await res.json() as { items: PersistioMemory[] };
|
|
157
|
-
return data.items ?? [];
|
|
158
185
|
}
|
|
159
186
|
}
|
|
160
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
|
+
|
|
161
224
|
async function formatHttpError(operation: string, res: Response): Promise<string> {
|
|
162
225
|
let detail = '';
|
|
163
226
|
try {
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,41 @@ const DEFAULT_SEND_ROLES: PersistioConfig['send']['roles'] = {
|
|
|
29
29
|
const MESSAGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
30
30
|
const MAX_TRACKED_SESSIONS = 250;
|
|
31
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
|
+
}
|
|
32
67
|
|
|
33
68
|
function resolveSendConfig(raw: Record<string, unknown>): PersistioConfig['send'] {
|
|
34
69
|
const send = raw['send'];
|
|
@@ -54,15 +89,21 @@ function resolveRecallMinSimilarity(value: unknown): number | undefined {
|
|
|
54
89
|
: undefined;
|
|
55
90
|
}
|
|
56
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
|
+
|
|
57
98
|
function resolveConfig(raw: unknown): PersistioConfig {
|
|
58
99
|
const c = (raw ?? {}) as Record<string, unknown>;
|
|
59
100
|
return {
|
|
60
101
|
baseURL: typeof c['baseURL'] === 'string' ? c['baseURL'] : '',
|
|
61
102
|
apiKey: typeof c['apiKey'] === 'string' ? c['apiKey'] : '',
|
|
62
|
-
tokenBudget:
|
|
63
|
-
recallTopK:
|
|
103
|
+
tokenBudget: resolvePositiveInteger(c['tokenBudget'], 2000),
|
|
104
|
+
recallTopK: resolvePositiveInteger(c['recallTopK'], 10),
|
|
64
105
|
recallMinSimilarity: resolveRecallMinSimilarity(c['recallMinSimilarity']),
|
|
65
|
-
recallTimeout:
|
|
106
|
+
recallTimeout: resolvePositiveInteger(c['recallTimeout'], 5000),
|
|
66
107
|
ingest: resolveIngestPolicy(c['ingest']),
|
|
67
108
|
send: resolveSendConfig(c),
|
|
68
109
|
};
|
|
@@ -139,29 +180,37 @@ function buildRecallQuery(event: { prompt?: string; messages?: unknown[] }): str
|
|
|
139
180
|
return truncate(parts.join('\n'), 600);
|
|
140
181
|
}
|
|
141
182
|
|
|
142
|
-
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
|
+
|
|
143
192
|
const sections: Array<{ title: string; items: string[] }> = [
|
|
144
|
-
{ title: 'Behavioural rules', items: bundle.user_rules },
|
|
145
|
-
{ title: 'Preferences', items: bundle.user_preferences },
|
|
146
|
-
{ title: 'Task patterns', items: bundle.task_patterns },
|
|
147
|
-
{ title: 'Workflows', items: bundle.workflows },
|
|
148
|
-
{ title: 'Project', items: bundle.project },
|
|
149
|
-
{ title: 'Constraints', items: bundle.constraints },
|
|
150
|
-
{ title: 'Decisions', items: bundle.decisions },
|
|
151
|
-
{ title: 'System facts', items: bundle.system_facts },
|
|
152
|
-
{ 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) },
|
|
153
202
|
];
|
|
154
|
-
if (relatedBundle) {
|
|
203
|
+
if (relatedBundle && typeof relatedBundle === 'object') {
|
|
155
204
|
sections.push(
|
|
156
|
-
{ title: 'Related behavioural rules', items: relatedBundle.user_rules },
|
|
157
|
-
{ title: 'Related preferences', items: relatedBundle.user_preferences },
|
|
158
|
-
{ title: 'Related task patterns', items: relatedBundle.task_patterns },
|
|
159
|
-
{ title: 'Related workflows', items: relatedBundle.workflows },
|
|
160
|
-
{ title: 'Related project', items: relatedBundle.project },
|
|
161
|
-
{ title: 'Related constraints', items: relatedBundle.constraints },
|
|
162
|
-
{ title: 'Related decisions', items: relatedBundle.decisions },
|
|
163
|
-
{ title: 'Related system facts', items: relatedBundle.system_facts },
|
|
164
|
-
{ 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) },
|
|
165
214
|
);
|
|
166
215
|
}
|
|
167
216
|
|
|
@@ -329,6 +378,62 @@ function isTimeoutLikeError(err: unknown): boolean {
|
|
|
329
378
|
return message.includes('timeout') || message.includes('aborted');
|
|
330
379
|
}
|
|
331
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
|
+
|
|
332
437
|
const PERSISTIO_MEMORY_PATH_PREFIX = 'persistio://memory/';
|
|
333
438
|
|
|
334
439
|
function createClient(config: PersistioConfig, recallTopK = config.recallTopK): PersistioClient {
|
|
@@ -379,7 +484,11 @@ async function probePersistio(client: PersistioClient): Promise<MemoryEmbeddingP
|
|
|
379
484
|
}
|
|
380
485
|
}
|
|
381
486
|
|
|
382
|
-
function createMemorySearchManager(
|
|
487
|
+
function createMemorySearchManager(
|
|
488
|
+
config: PersistioConfig,
|
|
489
|
+
recallBreaker: RecallCircuitBreaker,
|
|
490
|
+
logger?: PluginLogger,
|
|
491
|
+
): MemorySearchManager {
|
|
383
492
|
const client = createClient(config);
|
|
384
493
|
|
|
385
494
|
return {
|
|
@@ -400,7 +509,14 @@ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager
|
|
|
400
509
|
|
|
401
510
|
const recallTopK = typeof opts?.maxResults === 'number' ? opts.maxResults : config.recallTopK;
|
|
402
511
|
const recallClient = createClient(config, recallTopK);
|
|
403
|
-
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
|
+
});
|
|
404
520
|
|
|
405
521
|
return memories
|
|
406
522
|
.map((memory): MemorySearchResult => {
|
|
@@ -469,11 +585,11 @@ function createMemorySearchManager(config: PersistioConfig): MemorySearchManager
|
|
|
469
585
|
};
|
|
470
586
|
}
|
|
471
587
|
|
|
472
|
-
function createMemoryRuntime(config: PersistioConfig) {
|
|
588
|
+
function createMemoryRuntime(config: PersistioConfig, recallBreaker: RecallCircuitBreaker, logger?: PluginLogger) {
|
|
473
589
|
return {
|
|
474
590
|
async getMemorySearchManager() {
|
|
475
591
|
return {
|
|
476
|
-
manager: createMemorySearchManager(config),
|
|
592
|
+
manager: createMemorySearchManager(config, recallBreaker, logger),
|
|
477
593
|
};
|
|
478
594
|
},
|
|
479
595
|
|
|
@@ -497,10 +613,11 @@ export default definePluginEntry({
|
|
|
497
613
|
}
|
|
498
614
|
|
|
499
615
|
const client = createClient(cfg);
|
|
616
|
+
const recallBreaker = new RecallCircuitBreaker();
|
|
500
617
|
const sentMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
|
|
501
618
|
const pendingMessageKeysBySession = new Map<string, SessionMessageKeyStore>();
|
|
502
619
|
api.registerMemoryCapability({
|
|
503
|
-
runtime: createMemoryRuntime(cfg),
|
|
620
|
+
runtime: createMemoryRuntime(cfg, recallBreaker, api.logger),
|
|
504
621
|
});
|
|
505
622
|
|
|
506
623
|
// -------------------------------------------------------------------------
|
|
@@ -509,16 +626,21 @@ export default definePluginEntry({
|
|
|
509
626
|
// Return: { appendSystemContext?: string }
|
|
510
627
|
// -------------------------------------------------------------------------
|
|
511
628
|
api.on('before_prompt_build', async (event) => {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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 });
|
|
522
644
|
|
|
523
645
|
// -------------------------------------------------------------------------
|
|
524
646
|
// agent_end — ingest new turn messages (fire and forget)
|
|
@@ -632,10 +754,17 @@ export default definePluginEntry({
|
|
|
632
754
|
}),
|
|
633
755
|
async execute(_id, params) {
|
|
634
756
|
const p = params as { query: string; top_k?: number };
|
|
635
|
-
const overrideTopK =
|
|
757
|
+
const overrideTopK = resolvePositiveInteger(p.top_k, cfg.recallTopK);
|
|
636
758
|
const overrideCfg = { ...cfg, recallTopK: overrideTopK };
|
|
637
|
-
const
|
|
638
|
-
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
|
+
});
|
|
639
768
|
const text = memories.length > 0
|
|
640
769
|
? memories.map(m => `- ${m.data} [${m.subject}]`).join('\n')
|
|
641
770
|
: 'No memories found.';
|
|
@@ -653,7 +782,23 @@ export default definePluginEntry({
|
|
|
653
782
|
}),
|
|
654
783
|
async execute(_id, params) {
|
|
655
784
|
const p = params as { data: string; subject: string };
|
|
656
|
-
|
|
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
|
+
}
|
|
657
802
|
return { content: [{ type: 'text' as const, text: 'Memory stored.' }], details: null };
|
|
658
803
|
},
|
|
659
804
|
});
|