@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.
@@ -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
+ }
@@ -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.4",
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.4",
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({ query, top_k: this.recallTopK }),
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<RecallBundle> {
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({ query, top_k: topK ?? this.recallTopK }),
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.bundle;
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(`Persistio ingest failed: ${res.status}`);
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
+ }