@persistio/openclaw-plugin 0.1.4 → 0.1.6
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 +13 -0
- package/dist/client.d.ts +12 -1
- package/dist/client.js +40 -4
- package/dist/index.js +80 -6
- package/dist/ingest-policy.d.ts +48 -0
- package/dist/ingest-policy.js +380 -0
- package/openclaw.plugin.json +64 -1
- package/package.json +3 -2
- package/src/client.ts +53 -5
- package/src/index.ts +101 -8
- package/src/ingest-policy.ts +508 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
export const DEFAULT_INGEST_POLICY = {
|
|
2
|
+
timeoutMs: 30000,
|
|
3
|
+
maxChunkChars: 6000,
|
|
4
|
+
maxChunksPerTurn: 12,
|
|
5
|
+
skipSubagentSessions: true,
|
|
6
|
+
user: {
|
|
7
|
+
maxCharsPerMessage: 24000,
|
|
8
|
+
},
|
|
9
|
+
agent: {
|
|
10
|
+
mode: 'bounded',
|
|
11
|
+
maxCharsPerMessage: 24000,
|
|
12
|
+
maxCharsAfterFiltering: 9000,
|
|
13
|
+
maxCharsPerTurn: 24000,
|
|
14
|
+
largeBlockThresholdChars: 1200,
|
|
15
|
+
largeBlockThresholdLines: 80,
|
|
16
|
+
maxTableRows: 12,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
function readNumber(value, fallback, min = 1) {
|
|
20
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= min
|
|
21
|
+
? Math.floor(value)
|
|
22
|
+
: fallback;
|
|
23
|
+
}
|
|
24
|
+
function readBoolean(value, fallback) {
|
|
25
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
26
|
+
}
|
|
27
|
+
function readObject(value) {
|
|
28
|
+
return typeof value === 'object' && value !== null
|
|
29
|
+
? value
|
|
30
|
+
: {};
|
|
31
|
+
}
|
|
32
|
+
export function resolveIngestPolicy(raw) {
|
|
33
|
+
const ingest = readObject(raw);
|
|
34
|
+
const user = readObject(ingest['user']);
|
|
35
|
+
const agent = readObject(ingest['agent']);
|
|
36
|
+
const mode = agent['mode'] === 'raw' ? 'raw' : DEFAULT_INGEST_POLICY.agent.mode;
|
|
37
|
+
return {
|
|
38
|
+
timeoutMs: readNumber(ingest['timeoutMs'], DEFAULT_INGEST_POLICY.timeoutMs),
|
|
39
|
+
maxChunkChars: readNumber(ingest['maxChunkChars'], DEFAULT_INGEST_POLICY.maxChunkChars, 256),
|
|
40
|
+
maxChunksPerTurn: readNumber(ingest['maxChunksPerTurn'], DEFAULT_INGEST_POLICY.maxChunksPerTurn),
|
|
41
|
+
skipSubagentSessions: readBoolean(ingest['skipSubagentSessions'], DEFAULT_INGEST_POLICY.skipSubagentSessions),
|
|
42
|
+
user: {
|
|
43
|
+
maxCharsPerMessage: readNumber(user['maxCharsPerMessage'], DEFAULT_INGEST_POLICY.user.maxCharsPerMessage),
|
|
44
|
+
},
|
|
45
|
+
agent: {
|
|
46
|
+
mode,
|
|
47
|
+
maxCharsPerMessage: readNumber(agent['maxCharsPerMessage'], DEFAULT_INGEST_POLICY.agent.maxCharsPerMessage),
|
|
48
|
+
maxCharsAfterFiltering: readNumber(agent['maxCharsAfterFiltering'], DEFAULT_INGEST_POLICY.agent.maxCharsAfterFiltering),
|
|
49
|
+
maxCharsPerTurn: readNumber(agent['maxCharsPerTurn'], DEFAULT_INGEST_POLICY.agent.maxCharsPerTurn),
|
|
50
|
+
largeBlockThresholdChars: readNumber(agent['largeBlockThresholdChars'], DEFAULT_INGEST_POLICY.agent.largeBlockThresholdChars),
|
|
51
|
+
largeBlockThresholdLines: readNumber(agent['largeBlockThresholdLines'], DEFAULT_INGEST_POLICY.agent.largeBlockThresholdLines),
|
|
52
|
+
maxTableRows: readNumber(agent['maxTableRows'], DEFAULT_INGEST_POLICY.agent.maxTableRows),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function shouldIngestSession(sessionId, policy) {
|
|
57
|
+
if (!policy.skipSubagentSessions)
|
|
58
|
+
return true;
|
|
59
|
+
return !sessionId.startsWith('agent:') || sessionId.startsWith('agent:main:');
|
|
60
|
+
}
|
|
61
|
+
function countLines(text) {
|
|
62
|
+
return text.length === 0 ? 0 : text.split('\n').length;
|
|
63
|
+
}
|
|
64
|
+
function marker(label, text, extra) {
|
|
65
|
+
const suffix = extra ? `, ${extra}` : '';
|
|
66
|
+
return `[${label} omitted: ${countLines(text)} lines, ${text.length} chars${suffix}]`;
|
|
67
|
+
}
|
|
68
|
+
function normalizeText(text) {
|
|
69
|
+
return text
|
|
70
|
+
.replace(/\r\n?/g, '\n')
|
|
71
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
72
|
+
.replace(/\n{4,}/g, '\n\n\n')
|
|
73
|
+
.trim();
|
|
74
|
+
}
|
|
75
|
+
function pushOmission(omissions, label, text) {
|
|
76
|
+
omissions.push({ label, chars: text.length, lines: countLines(text) });
|
|
77
|
+
}
|
|
78
|
+
function collapseLargeFencedBlocks(text, policy, omissions) {
|
|
79
|
+
return text.replace(/```([^\n`]*)\n([\s\S]*?)```/g, (block, language) => {
|
|
80
|
+
if (block.length < policy.agent.largeBlockThresholdChars &&
|
|
81
|
+
countLines(block) < policy.agent.largeBlockThresholdLines) {
|
|
82
|
+
return block;
|
|
83
|
+
}
|
|
84
|
+
pushOmission(omissions, 'Code block', block);
|
|
85
|
+
const lang = language.trim();
|
|
86
|
+
return marker('Code block', block, lang ? `language=${lang}` : undefined);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function isBase64LikeLine(line) {
|
|
90
|
+
const compact = line.trim();
|
|
91
|
+
if (compact.length < 500 || /\s/.test(compact))
|
|
92
|
+
return false;
|
|
93
|
+
if (!/^[A-Za-z0-9+/=_-]+$/.test(compact))
|
|
94
|
+
return false;
|
|
95
|
+
const alphaNumeric = compact.replace(/[^A-Za-z0-9]/g, '').length / compact.length;
|
|
96
|
+
return alphaNumeric > 0.85;
|
|
97
|
+
}
|
|
98
|
+
function collapseBase64Lines(text, omissions) {
|
|
99
|
+
return text.split('\n').map((line) => {
|
|
100
|
+
if (!isBase64LikeLine(line))
|
|
101
|
+
return line;
|
|
102
|
+
pushOmission(omissions, 'Encoded blob', line);
|
|
103
|
+
return `[Encoded blob omitted: 1 line, ${line.length} chars]`;
|
|
104
|
+
}).join('\n');
|
|
105
|
+
}
|
|
106
|
+
function looksLikeDiffStart(line) {
|
|
107
|
+
return /^diff --git\b/.test(line) || line === '*** Begin Patch';
|
|
108
|
+
}
|
|
109
|
+
function isDiffMetadataLine(line) {
|
|
110
|
+
return /^(?:index|new file mode|deleted file mode|old mode|new mode|similarity index|dissimilarity index|rename from|rename to|copy from|copy to)\b/.test(line)
|
|
111
|
+
|| /^(?:---|\+\+\+) /.test(line)
|
|
112
|
+
|| /^Binary files .+ differ$/.test(line)
|
|
113
|
+
|| /^\*\*\* (?:Add|Update|Delete) File: /.test(line)
|
|
114
|
+
|| /^\*\*\* End of File$/.test(line);
|
|
115
|
+
}
|
|
116
|
+
function isDiffBodyLine(line) {
|
|
117
|
+
return /^@@/.test(line)
|
|
118
|
+
|| /^[ +\\-]/.test(line);
|
|
119
|
+
}
|
|
120
|
+
function collapseDiffBlocks(text, policy, omissions) {
|
|
121
|
+
const lines = text.split('\n');
|
|
122
|
+
const result = [];
|
|
123
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
124
|
+
const line = lines[i];
|
|
125
|
+
if (!looksLikeDiffStart(line)) {
|
|
126
|
+
result.push(line);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const block = [line];
|
|
130
|
+
i += 1;
|
|
131
|
+
for (; i < lines.length; i += 1) {
|
|
132
|
+
const next = lines[i];
|
|
133
|
+
if (looksLikeDiffStart(next)) {
|
|
134
|
+
i -= 1;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
if (next === '*** End Patch') {
|
|
138
|
+
block.push(next);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
if (next.trim() === '') {
|
|
142
|
+
i -= 1;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (!isDiffMetadataLine(next) && !isDiffBodyLine(next)) {
|
|
146
|
+
i -= 1;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
block.push(next);
|
|
150
|
+
}
|
|
151
|
+
const blockText = block.join('\n');
|
|
152
|
+
if (blockText.length < policy.agent.largeBlockThresholdChars &&
|
|
153
|
+
block.length < policy.agent.largeBlockThresholdLines) {
|
|
154
|
+
result.push(blockText);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
pushOmission(omissions, 'Diff', blockText);
|
|
158
|
+
result.push(marker('Diff', blockText));
|
|
159
|
+
}
|
|
160
|
+
return result.join('\n');
|
|
161
|
+
}
|
|
162
|
+
function isLogLikeLine(line) {
|
|
163
|
+
return /^\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}/.test(line)
|
|
164
|
+
|| /^\s*(ERROR|WARN|INFO|DEBUG|TRACE)\b/.test(line)
|
|
165
|
+
|| /^\s*at\s+.+\(.+:\d+:\d+\)/.test(line)
|
|
166
|
+
|| /^\s*at\s+.+:\d+:\d+/.test(line)
|
|
167
|
+
|| /^Traceback \(most recent call last\):/.test(line)
|
|
168
|
+
|| /^[A-Za-z]*Error: .+/.test(line);
|
|
169
|
+
}
|
|
170
|
+
function isShellOutputLine(line) {
|
|
171
|
+
return /^\s*(PASS|FAIL|RUNS|Test Files|Tests|Duration|stderr|stdout)\b/.test(line)
|
|
172
|
+
|| /^>\s+[\w@/.-]+/.test(line)
|
|
173
|
+
|| /^\$\s+\S+/.test(line)
|
|
174
|
+
|| /^npm (ERR!|WARN|notice)\b/.test(line);
|
|
175
|
+
}
|
|
176
|
+
function collapseLineRuns(text, label, predicate, policy, omissions) {
|
|
177
|
+
const lines = text.split('\n');
|
|
178
|
+
const result = [];
|
|
179
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
180
|
+
const line = lines[i];
|
|
181
|
+
if (!predicate(line)) {
|
|
182
|
+
result.push(line);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const block = [line];
|
|
186
|
+
i += 1;
|
|
187
|
+
for (; i < lines.length; i += 1) {
|
|
188
|
+
const next = lines[i];
|
|
189
|
+
if (!predicate(next)) {
|
|
190
|
+
i -= 1;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
block.push(next);
|
|
194
|
+
}
|
|
195
|
+
const blockText = block.join('\n');
|
|
196
|
+
if (blockText.length < policy.agent.largeBlockThresholdChars &&
|
|
197
|
+
block.length < policy.agent.largeBlockThresholdLines) {
|
|
198
|
+
result.push(blockText);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
pushOmission(omissions, label, blockText);
|
|
202
|
+
const firstUsefulLine = block.find((candidate) => candidate.trim().length > 0)?.trim();
|
|
203
|
+
result.push(marker(label, blockText, firstUsefulLine ? `first="${firstUsefulLine.slice(0, 120)}"` : undefined));
|
|
204
|
+
}
|
|
205
|
+
return result.join('\n');
|
|
206
|
+
}
|
|
207
|
+
function isMarkdownTableLine(line) {
|
|
208
|
+
const trimmed = line.trim();
|
|
209
|
+
return trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.split('|').length >= 4;
|
|
210
|
+
}
|
|
211
|
+
function isMarkdownTableSeparator(line) {
|
|
212
|
+
return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
|
|
213
|
+
}
|
|
214
|
+
function truncateMarkdownTables(text, policy, omissions) {
|
|
215
|
+
const lines = text.split('\n');
|
|
216
|
+
const result = [];
|
|
217
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
218
|
+
if (!isMarkdownTableLine(lines[i]) || !lines[i + 1] || !isMarkdownTableSeparator(lines[i + 1])) {
|
|
219
|
+
result.push(lines[i]);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const table = [lines[i], lines[i + 1]];
|
|
223
|
+
i += 2;
|
|
224
|
+
for (; i < lines.length && isMarkdownTableLine(lines[i]); i += 1) {
|
|
225
|
+
table.push(lines[i]);
|
|
226
|
+
}
|
|
227
|
+
i -= 1;
|
|
228
|
+
if (table.length <= policy.agent.maxTableRows + 2) {
|
|
229
|
+
result.push(...table);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const omitted = table.slice(policy.agent.maxTableRows + 2).join('\n');
|
|
233
|
+
pushOmission(omissions, 'Table rows', omitted);
|
|
234
|
+
result.push(...table.slice(0, policy.agent.maxTableRows + 2));
|
|
235
|
+
result.push(`[Table truncated: ${table.length - policy.agent.maxTableRows - 2} more rows]`);
|
|
236
|
+
}
|
|
237
|
+
return result.join('\n');
|
|
238
|
+
}
|
|
239
|
+
function maybeCollapseWholeBlob(text, omissions) {
|
|
240
|
+
const trimmed = text.trim();
|
|
241
|
+
if (trimmed.length < 2000)
|
|
242
|
+
return text;
|
|
243
|
+
try {
|
|
244
|
+
const parsed = JSON.parse(trimmed);
|
|
245
|
+
pushOmission(omissions, 'JSON blob', text);
|
|
246
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
247
|
+
const keys = Object.keys(parsed).slice(0, 12).join(',');
|
|
248
|
+
return `[JSON blob omitted: ${countLines(text)} lines, ${text.length} chars${keys ? `, keys=${keys}` : ''}]`;
|
|
249
|
+
}
|
|
250
|
+
return marker('JSON blob', text);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Continue with XML-ish shape detection below.
|
|
254
|
+
}
|
|
255
|
+
const angleRatio = (trimmed.match(/[<>/]/g)?.length ?? 0) / trimmed.length;
|
|
256
|
+
const lineCount = countLines(trimmed);
|
|
257
|
+
if (lineCount >= 20 &&
|
|
258
|
+
angleRatio > 0.08 &&
|
|
259
|
+
/^<\??[A-Za-z!]/.test(trimmed) &&
|
|
260
|
+
/<\/[A-Za-z][^>]*>/.test(trimmed)) {
|
|
261
|
+
pushOmission(omissions, 'XML blob', text);
|
|
262
|
+
return marker('XML blob', text);
|
|
263
|
+
}
|
|
264
|
+
return text;
|
|
265
|
+
}
|
|
266
|
+
function fitToBudget(text, budget) {
|
|
267
|
+
if (text.length <= budget) {
|
|
268
|
+
return { text, truncated: false };
|
|
269
|
+
}
|
|
270
|
+
const markerText = `\n\n[Content truncated: original ${text.length} chars, kept ${budget} chars]\n\n`;
|
|
271
|
+
const available = Math.max(0, budget - markerText.length);
|
|
272
|
+
const headLength = Math.ceil(available * 0.6);
|
|
273
|
+
const tailLength = Math.max(0, available - headLength);
|
|
274
|
+
return {
|
|
275
|
+
text: `${text.slice(0, headLength).trimEnd()}${markerText}${text.slice(text.length - tailLength).trimStart()}`.trim(),
|
|
276
|
+
truncated: true,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export function filterAssistantContent(text, policy) {
|
|
280
|
+
const omissions = [];
|
|
281
|
+
let filtered = normalizeText(text);
|
|
282
|
+
if (policy.agent.mode === 'bounded') {
|
|
283
|
+
filtered = collapseLargeFencedBlocks(filtered, policy, omissions);
|
|
284
|
+
filtered = collapseDiffBlocks(filtered, policy, omissions);
|
|
285
|
+
filtered = collapseLineRuns(filtered, 'Log output', isLogLikeLine, policy, omissions);
|
|
286
|
+
filtered = collapseLineRuns(filtered, 'Command output', isShellOutputLine, policy, omissions);
|
|
287
|
+
filtered = truncateMarkdownTables(filtered, policy, omissions);
|
|
288
|
+
filtered = collapseBase64Lines(filtered, omissions);
|
|
289
|
+
filtered = maybeCollapseWholeBlob(filtered, omissions);
|
|
290
|
+
}
|
|
291
|
+
const budgeted = fitToBudget(filtered, policy.agent.maxCharsAfterFiltering);
|
|
292
|
+
return {
|
|
293
|
+
text: budgeted.text,
|
|
294
|
+
omissions,
|
|
295
|
+
truncated: budgeted.truncated,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
export function chunkText(text, maxChunkChars) {
|
|
299
|
+
const normalized = normalizeText(text);
|
|
300
|
+
if (!normalized)
|
|
301
|
+
return [];
|
|
302
|
+
const chunks = [];
|
|
303
|
+
let current = '';
|
|
304
|
+
const flush = () => {
|
|
305
|
+
if (!current.trim())
|
|
306
|
+
return;
|
|
307
|
+
chunks.push(current.trim());
|
|
308
|
+
current = '';
|
|
309
|
+
};
|
|
310
|
+
const appendUnit = (unit) => {
|
|
311
|
+
const separator = current ? '\n\n' : '';
|
|
312
|
+
if (current.length + separator.length + unit.length <= maxChunkChars) {
|
|
313
|
+
current = `${current}${separator}${unit}`;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
flush();
|
|
317
|
+
if (unit.length <= maxChunkChars) {
|
|
318
|
+
current = unit;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
for (let start = 0; start < unit.length; start += maxChunkChars) {
|
|
322
|
+
chunks.push(unit.slice(start, start + maxChunkChars).trim());
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
for (const paragraph of normalized.split(/\n{2,}/)) {
|
|
326
|
+
if (paragraph.length <= maxChunkChars) {
|
|
327
|
+
appendUnit(paragraph);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
for (const line of paragraph.split('\n')) {
|
|
331
|
+
appendUnit(line);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
flush();
|
|
335
|
+
return chunks.filter((chunk) => chunk.length > 0);
|
|
336
|
+
}
|
|
337
|
+
export function prepareMessageForIngest(input) {
|
|
338
|
+
const original = normalizeText(input.text);
|
|
339
|
+
const omissions = [];
|
|
340
|
+
let prepared = original;
|
|
341
|
+
let truncated = false;
|
|
342
|
+
if (input.role === 'assistant') {
|
|
343
|
+
const messageBudget = input.remainingAgentChars;
|
|
344
|
+
if (messageBudget <= 0 || input.remainingChunks <= 0) {
|
|
345
|
+
return {
|
|
346
|
+
chunks: [],
|
|
347
|
+
originalChars: original.length,
|
|
348
|
+
preparedChars: 0,
|
|
349
|
+
truncated: true,
|
|
350
|
+
omissions: [],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const preBudgeted = fitToBudget(prepared, input.policy.agent.maxCharsPerMessage);
|
|
354
|
+
prepared = preBudgeted.text;
|
|
355
|
+
truncated = preBudgeted.truncated;
|
|
356
|
+
const filtered = filterAssistantContent(prepared, input.policy);
|
|
357
|
+
prepared = filtered.text;
|
|
358
|
+
omissions.push(...filtered.omissions);
|
|
359
|
+
truncated = truncated || filtered.truncated || filtered.omissions.length > 0;
|
|
360
|
+
const budgeted = fitToBudget(prepared, messageBudget);
|
|
361
|
+
prepared = budgeted.text;
|
|
362
|
+
truncated = truncated || budgeted.truncated;
|
|
363
|
+
}
|
|
364
|
+
else if (input.role === 'user') {
|
|
365
|
+
const budgeted = fitToBudget(prepared, input.policy.user.maxCharsPerMessage);
|
|
366
|
+
prepared = budgeted.text;
|
|
367
|
+
truncated = budgeted.truncated;
|
|
368
|
+
}
|
|
369
|
+
const chunks = chunkText(prepared, input.policy.maxChunkChars).slice(0, input.remainingChunks);
|
|
370
|
+
if (chunks.join('\n\n').length < prepared.length) {
|
|
371
|
+
truncated = true;
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
chunks,
|
|
375
|
+
originalChars: original.length,
|
|
376
|
+
preparedChars: chunks.reduce((sum, chunk) => sum + chunk.length, 0),
|
|
377
|
+
truncated,
|
|
378
|
+
omissions,
|
|
379
|
+
};
|
|
380
|
+
}
|
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.6",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"activation": {
|
|
8
8
|
"onStartup": true
|
|
@@ -31,9 +31,72 @@
|
|
|
31
31
|
"recallTopK": {
|
|
32
32
|
"type": "number"
|
|
33
33
|
},
|
|
34
|
+
"recallMinSimilarity": {
|
|
35
|
+
"type": "number",
|
|
36
|
+
"minimum": 0,
|
|
37
|
+
"maximum": 1
|
|
38
|
+
},
|
|
34
39
|
"recallTimeout": {
|
|
35
40
|
"type": "number"
|
|
36
41
|
},
|
|
42
|
+
"ingest": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"properties": {
|
|
46
|
+
"timeoutMs": {
|
|
47
|
+
"type": "number"
|
|
48
|
+
},
|
|
49
|
+
"maxChunkChars": {
|
|
50
|
+
"type": "number"
|
|
51
|
+
},
|
|
52
|
+
"maxChunksPerTurn": {
|
|
53
|
+
"type": "number"
|
|
54
|
+
},
|
|
55
|
+
"skipSubagentSessions": {
|
|
56
|
+
"type": "boolean"
|
|
57
|
+
},
|
|
58
|
+
"user": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"additionalProperties": false,
|
|
61
|
+
"properties": {
|
|
62
|
+
"maxCharsPerMessage": {
|
|
63
|
+
"type": "number"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"agent": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"properties": {
|
|
71
|
+
"mode": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"enum": [
|
|
74
|
+
"bounded",
|
|
75
|
+
"raw"
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
"maxCharsPerMessage": {
|
|
79
|
+
"type": "number"
|
|
80
|
+
},
|
|
81
|
+
"maxCharsAfterFiltering": {
|
|
82
|
+
"type": "number"
|
|
83
|
+
},
|
|
84
|
+
"maxCharsPerTurn": {
|
|
85
|
+
"type": "number"
|
|
86
|
+
},
|
|
87
|
+
"largeBlockThresholdChars": {
|
|
88
|
+
"type": "number"
|
|
89
|
+
},
|
|
90
|
+
"largeBlockThresholdLines": {
|
|
91
|
+
"type": "number"
|
|
92
|
+
},
|
|
93
|
+
"maxTableRows": {
|
|
94
|
+
"type": "number"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
37
100
|
"send": {
|
|
38
101
|
"type": "object",
|
|
39
102
|
"additionalProperties": false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@persistio/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "OpenClaw plugin for Persistio \u2014 persistent semantic memory for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
|
-
"build": "tsc"
|
|
44
|
+
"build": "tsc",
|
|
45
|
+
"test": "npm run build && node test/config-schema.test.mjs && node test/ingest-policy.test.mjs"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"@sinclair/typebox": "^0.34.0"
|
package/src/client.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import type { PersistioIngestPolicy } from './ingest-policy.js';
|
|
2
|
+
|
|
1
3
|
export interface PersistioConfig {
|
|
2
4
|
baseURL: string;
|
|
3
5
|
apiKey: string;
|
|
4
6
|
tokenBudget: number;
|
|
5
7
|
recallTopK: number;
|
|
8
|
+
recallMinSimilarity?: number;
|
|
6
9
|
recallTimeout: number;
|
|
10
|
+
ingest: PersistioIngestPolicy;
|
|
7
11
|
send: PersistioSendConfig;
|
|
8
12
|
}
|
|
9
13
|
|
|
@@ -26,7 +30,12 @@ export interface PersistioMemory {
|
|
|
26
30
|
confidence: number;
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
export interface GetMemoryOptions {
|
|
34
|
+
includePending?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
export interface RecallBundle {
|
|
38
|
+
global_user_rules?: string[];
|
|
30
39
|
user_rules: string[];
|
|
31
40
|
user_preferences: string[];
|
|
32
41
|
task_patterns: string[];
|
|
@@ -40,19 +49,24 @@ export interface RecallBundle {
|
|
|
40
49
|
|
|
41
50
|
export interface RecallBundleResponse {
|
|
42
51
|
bundle: RecallBundle;
|
|
52
|
+
related_bundle?: RecallBundle;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
export class PersistioClient {
|
|
46
56
|
private readonly baseURL: string;
|
|
47
57
|
private readonly apiKey: string;
|
|
48
58
|
private readonly recallTopK: number;
|
|
59
|
+
private readonly recallMinSimilarity?: number;
|
|
49
60
|
private readonly recallTimeout: number;
|
|
61
|
+
private readonly ingestTimeout: number;
|
|
50
62
|
|
|
51
63
|
constructor(config: PersistioConfig) {
|
|
52
64
|
this.baseURL = config.baseURL.replace(/\/$/, '');
|
|
53
65
|
this.apiKey = config.apiKey;
|
|
54
66
|
this.recallTopK = config.recallTopK;
|
|
67
|
+
this.recallMinSimilarity = config.recallMinSimilarity;
|
|
55
68
|
this.recallTimeout = config.recallTimeout;
|
|
69
|
+
this.ingestTimeout = config.ingest.timeoutMs;
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
private headers(): Record<string, string> {
|
|
@@ -63,10 +77,15 @@ export class PersistioClient {
|
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
async recall(query: string): Promise<PersistioMemory[]> {
|
|
80
|
+
const body: Record<string, unknown> = { query, top_k: this.recallTopK, include_pending: true };
|
|
81
|
+
if (typeof this.recallMinSimilarity === 'number') {
|
|
82
|
+
body.min_similarity = this.recallMinSimilarity;
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
const res = await fetch(`${this.baseURL}/v1/recall`, {
|
|
67
86
|
method: 'POST',
|
|
68
87
|
headers: this.headers(),
|
|
69
|
-
body: JSON.stringify(
|
|
88
|
+
body: JSON.stringify(body),
|
|
70
89
|
signal: AbortSignal.timeout(this.recallTimeout),
|
|
71
90
|
});
|
|
72
91
|
if (!res.ok) throw new Error(`Persistio recall failed: ${res.status}`);
|
|
@@ -74,16 +93,21 @@ export class PersistioClient {
|
|
|
74
93
|
return data.memories ?? [];
|
|
75
94
|
}
|
|
76
95
|
|
|
77
|
-
async recallBundle(query: string, topK?: number): Promise<
|
|
96
|
+
async recallBundle(query: string, topK?: number): Promise<RecallBundleResponse> {
|
|
97
|
+
const body: Record<string, unknown> = { query, top_k: topK ?? this.recallTopK, include_pending: true };
|
|
98
|
+
if (typeof this.recallMinSimilarity === 'number') {
|
|
99
|
+
body.min_similarity = this.recallMinSimilarity;
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
const res = await fetch(`${this.baseURL}/v1/recall?format=bundle`, {
|
|
79
103
|
method: 'POST',
|
|
80
104
|
headers: this.headers(),
|
|
81
|
-
body: JSON.stringify(
|
|
105
|
+
body: JSON.stringify(body),
|
|
82
106
|
signal: AbortSignal.timeout(this.recallTimeout),
|
|
83
107
|
});
|
|
84
108
|
if (!res.ok) throw new Error(`Persistio recallBundle failed: ${res.status}`);
|
|
85
109
|
const data = await res.json() as RecallBundleResponse;
|
|
86
|
-
return data
|
|
110
|
+
return data;
|
|
87
111
|
}
|
|
88
112
|
|
|
89
113
|
async ingest(sessionId: string, chunks: Array<{ role: string; content: string; timestamp: string }>): Promise<void> {
|
|
@@ -92,8 +116,9 @@ export class PersistioClient {
|
|
|
92
116
|
method: 'POST',
|
|
93
117
|
headers: this.headers(),
|
|
94
118
|
body: JSON.stringify({ session_id: sessionId, chunks }),
|
|
119
|
+
signal: AbortSignal.timeout(this.ingestTimeout),
|
|
95
120
|
});
|
|
96
|
-
if (!res.ok) throw new Error(
|
|
121
|
+
if (!res.ok) throw new Error(await formatHttpError('ingest', res));
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
async addMemory(data: string, subject: string): Promise<void> {
|
|
@@ -113,6 +138,16 @@ export class PersistioClient {
|
|
|
113
138
|
if (!res.ok) throw new Error(`Persistio deleteMemory failed: ${res.status}`);
|
|
114
139
|
}
|
|
115
140
|
|
|
141
|
+
async getMemory(id: string, options: GetMemoryOptions = {}): Promise<PersistioMemory | null> {
|
|
142
|
+
const query = options.includePending ? '?include_pending=true' : '';
|
|
143
|
+
const res = await fetch(`${this.baseURL}/v1/memories/${id}${query}`, {
|
|
144
|
+
headers: this.headers(),
|
|
145
|
+
});
|
|
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
|
+
}
|
|
150
|
+
|
|
116
151
|
async listMemories(): Promise<PersistioMemory[]> {
|
|
117
152
|
const res = await fetch(`${this.baseURL}/v1/memories`, {
|
|
118
153
|
headers: this.headers(),
|
|
@@ -122,3 +157,16 @@ export class PersistioClient {
|
|
|
122
157
|
return data.items ?? [];
|
|
123
158
|
}
|
|
124
159
|
}
|
|
160
|
+
|
|
161
|
+
async function formatHttpError(operation: string, res: Response): Promise<string> {
|
|
162
|
+
let detail = '';
|
|
163
|
+
try {
|
|
164
|
+
detail = (await res.text()).trim().slice(0, 500);
|
|
165
|
+
} catch {
|
|
166
|
+
// Ignore response body read failures; the status is still actionable.
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return detail
|
|
170
|
+
? `Persistio ${operation} failed: ${res.status} ${detail}`
|
|
171
|
+
: `Persistio ${operation} failed: ${res.status}`;
|
|
172
|
+
}
|