mr-memory 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # mr-memory
2
+
3
+ Persistent AI memory plugin for [OpenClaw](https://github.com/openclaw/openclaw). Your AI remembers every conversation.
4
+
5
+ Powered by [MemoryRouter](https://memoryrouter.ai).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ openclaw plugins install mr-memory
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ ```bash
16
+ openclaw mr enable <your-memory-key> # Get a key at memoryrouter.ai
17
+ openclaw mr upload # Upload workspace + session history
18
+ ```
19
+
20
+ ## Commands
21
+
22
+ ```bash
23
+ openclaw mr status # Vault stats
24
+ openclaw mr upload # Upload workspace + sessions
25
+ openclaw mr upload --brain ~/.notopenclaw # Upload from a different agent
26
+ openclaw mr off # Disable
27
+ openclaw mr delete # Clear vault
28
+ openclaw mr enable <key> # Enable with key
29
+ ```
30
+
31
+ ## Upload Options
32
+
33
+ ```bash
34
+ openclaw mr upload [path] # Specific file or directory
35
+ openclaw mr upload --workspace <dir> # Custom workspace directory
36
+ openclaw mr upload --brain <dir> # Custom state dir (sessions from another agent)
37
+ ```
38
+
39
+ ## How It Works
40
+
41
+ When enabled, all LLM calls route through MemoryRouter which injects relevant past context and captures new conversations. Your provider API keys pass through untouched (BYOK).
42
+
43
+ Only direct user-to-AI conversation is stored. Tool use and subagent work are excluded automatically.
44
+
45
+ ## Config
46
+
47
+ After `openclaw mr enable <key>`, config is stored at:
48
+
49
+ ```json
50
+ {
51
+ "plugins": {
52
+ "entries": {
53
+ "memoryrouter": {
54
+ "enabled": true,
55
+ "config": {
56
+ "key": "mk_xxx",
57
+ "endpoint": "https://api.memoryrouter.ai"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ## License
66
+
67
+ MIT
package/index.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * MemoryRouter Plugin for OpenClaw
3
+ *
4
+ * Persistent AI memory via MemoryRouter (memoryrouter.ai).
5
+ * Routes LLM calls through MemoryRouter's API which injects relevant
6
+ * past context and captures conversations automatically.
7
+ *
8
+ * BYOK — provider API keys pass through untouched.
9
+ */
10
+
11
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
12
+
13
+ const DEFAULT_ENDPOINT = "https://api.memoryrouter.ai";
14
+
15
+ type MemoryRouterConfig = {
16
+ key: string;
17
+ endpoint?: string;
18
+ };
19
+
20
+ /**
21
+ * Supported provider APIs that MemoryRouter can proxy.
22
+ */
23
+ const SUPPORTED_APIS = new Set([
24
+ "anthropic-messages",
25
+ "openai-completions",
26
+ "openai-responses",
27
+ "azure-openai-responses",
28
+ "ollama",
29
+ ]);
30
+
31
+ /**
32
+ * Detect if the current LLM call is a tool-use iteration (not direct user conversation).
33
+ * Tool iterations have tool_result (Anthropic) or tool-role (OpenAI) messages
34
+ * after the last real user message.
35
+ */
36
+ function isToolUseIteration(context: { messages?: Array<{ role: string; content?: unknown }> }): boolean {
37
+ const messages = context.messages;
38
+ if (!messages || messages.length === 0) return false;
39
+
40
+ for (let i = messages.length - 1; i >= 0; i--) {
41
+ const msg = messages[i];
42
+
43
+ if (msg.role === "tool") return true;
44
+
45
+ if (msg.role === "user" && Array.isArray(msg.content)) {
46
+ const hasToolResult = (msg.content as Array<{ type?: string }>).some(
47
+ (block) => block.type === "tool_result",
48
+ );
49
+ if (hasToolResult) return true;
50
+ }
51
+
52
+ if (msg.role === "user" && typeof msg.content === "string") return false;
53
+ if (msg.role === "assistant") continue;
54
+ }
55
+
56
+ return false;
57
+ }
58
+
59
+ const memoryRouterPlugin = {
60
+ id: "memoryrouter",
61
+ name: "MemoryRouter",
62
+ description: "Persistent AI memory powered by MemoryRouter",
63
+
64
+ register(api: OpenClawPluginApi) {
65
+ const cfg = api.pluginConfig as MemoryRouterConfig | undefined;
66
+ const endpoint = cfg?.endpoint?.replace(/\/v1\/?$/, "") || DEFAULT_ENDPOINT;
67
+ const memoryKey = cfg?.key;
68
+
69
+ // ==================================================================
70
+ // Core: Route LLM calls through MemoryRouter (only when key is set)
71
+ // ==================================================================
72
+
73
+ if (memoryKey) {
74
+ api.logger.info?.(`memoryrouter: registered (endpoint: ${endpoint})`);
75
+ } else {
76
+ api.logger.info?.("memoryrouter: no key configured — run: openclaw mr enable <key>");
77
+ }
78
+
79
+ if (memoryKey) {
80
+ api.registerStreamFnWrapper((next) => {
81
+ return (model, context, options) => {
82
+ // Only proxy supported APIs
83
+ if (!SUPPORTED_APIS.has(model.api)) {
84
+ return next(model, context, options);
85
+ }
86
+
87
+ // Route through MemoryRouter
88
+ const mrModel = {
89
+ ...model,
90
+ baseUrl: model.api === "anthropic-messages"
91
+ ? endpoint // Anthropic: baseUrl is without /v1
92
+ : `${endpoint}/v1`,
93
+ };
94
+
95
+ // Detect tool iterations — don't store intermediate work
96
+ const toolIteration = isToolUseIteration(
97
+ context as { messages?: Array<{ role: string; content?: unknown }> },
98
+ );
99
+
100
+ // Inject MemoryRouter headers
101
+ const mrOptions = {
102
+ ...options,
103
+ headers: {
104
+ ...options?.headers,
105
+ "X-Memory-Key": memoryKey,
106
+ "X-Memory-Store": toolIteration ? "false" : "true",
107
+ },
108
+ };
109
+
110
+ return next(mrModel, context, mrOptions);
111
+ };
112
+ });
113
+ } // end if (memoryKey) for streamFn wrapper
114
+
115
+ // ==================================================================
116
+ // CLI Commands (always registered — even without key, for enable/off)
117
+ // ==================================================================
118
+
119
+ api.registerCli(
120
+ ({ program }) => {
121
+ const mr = program.command("mr").description("MemoryRouter memory commands");
122
+
123
+ mr.command("enable")
124
+ .description("Enable MemoryRouter with a memory key")
125
+ .argument("<key>", "Your MemoryRouter memory key (mk_xxx)")
126
+ .action(async (key: string) => {
127
+ if (!key.startsWith("mk")) {
128
+ console.error("Invalid key format. Keys start with 'mk' (e.g. mk_xxx)");
129
+ return;
130
+ }
131
+ await api.updatePluginConfig({ key });
132
+ await api.updatePluginEnabled(true);
133
+ console.log(`✓ MemoryRouter enabled. Key: ${key.slice(0, 6)}...${key.slice(-3)}`);
134
+ console.log(`\nRun: openclaw mr upload to upload your memories`);
135
+ });
136
+
137
+ mr.command("off")
138
+ .description("Disable MemoryRouter (removes key)")
139
+ .action(async () => {
140
+ await api.updatePluginConfig({});
141
+ console.log("✓ MemoryRouter disabled. LLM calls go direct to provider.");
142
+ console.log(" Key removed. Re-enable with: openclaw mr enable <key>");
143
+ });
144
+
145
+ mr.command("status")
146
+ .description("Show MemoryRouter vault stats")
147
+ .option("--json", "JSON output")
148
+ .action(async (opts) => {
149
+ if (!memoryKey) {
150
+ console.error("MemoryRouter not configured. Run: openclaw mr enable <key>");
151
+ return;
152
+ }
153
+ try {
154
+ const res = await fetch(`${endpoint}/v1/memory/stats`, {
155
+ headers: { Authorization: `Bearer ${memoryKey}` },
156
+ });
157
+ const data = await res.json() as Record<string, unknown>;
158
+
159
+ if (opts.json) {
160
+ console.log(JSON.stringify({ enabled: true, key: memoryKey, stats: data }, null, 2));
161
+ } else {
162
+ console.log("MemoryRouter Status");
163
+ console.log("───────────────────────────");
164
+ console.log(`Enabled: ✓ Yes`);
165
+ console.log(`Key: ${memoryKey.slice(0, 6)}...${memoryKey.slice(-3)}`);
166
+ console.log(`Endpoint: ${endpoint}`);
167
+ console.log("");
168
+ console.log("Vault Stats:");
169
+ const stats = data as { totalVectors?: number; totalTokens?: number };
170
+ console.log(` Memories: ${stats.totalVectors ?? 0}`);
171
+ console.log(` Tokens: ${stats.totalTokens ?? 0}`);
172
+ }
173
+ } catch (err) {
174
+ console.error(`Failed to fetch stats: ${err instanceof Error ? err.message : String(err)}`);
175
+ }
176
+ });
177
+
178
+ mr.command("upload")
179
+ .description("Upload workspace + session history to vault")
180
+ .argument("[path]", "Specific file or directory to upload")
181
+ .option("--workspace <dir>", "Workspace directory (default: cwd)")
182
+ .option("--brain <dir>", "State directory with sessions (default: ~/.openclaw)")
183
+ .action(async (targetPath: string | undefined, opts: { workspace?: string; brain?: string }) => {
184
+ if (!memoryKey) {
185
+ console.error("MemoryRouter not configured. Run: openclaw mr enable <key>");
186
+ return;
187
+ }
188
+ const os = await import("node:os");
189
+ const path = await import("node:path");
190
+ const stateDir = opts.brain ? path.resolve(opts.brain) : path.join(os.homedir(), ".openclaw");
191
+ const workspacePath = opts.workspace ? path.resolve(opts.workspace) : process.cwd();
192
+ const { runUpload } = await import("./upload.js");
193
+ await runUpload({ memoryKey, endpoint, targetPath, stateDir, workspacePath });
194
+ });
195
+
196
+ mr.command("delete")
197
+ .description("Clear all memories from vault")
198
+ .action(async () => {
199
+ if (!memoryKey) {
200
+ console.error("MemoryRouter not configured. Run: openclaw mr enable <key>");
201
+ return;
202
+ }
203
+ try {
204
+ const res = await fetch(`${endpoint}/v1/memory`, {
205
+ method: "DELETE",
206
+ headers: { Authorization: `Bearer ${memoryKey}` },
207
+ });
208
+ const data = await res.json() as { message?: string };
209
+ console.log(`✓ ${data.message || "Vault cleared"}`);
210
+ } catch (err) {
211
+ console.error(`Failed to clear vault: ${err instanceof Error ? err.message : String(err)}`);
212
+ }
213
+ });
214
+ },
215
+ { commands: ["mr"] },
216
+ );
217
+
218
+ // ==================================================================
219
+ // Service
220
+ // ==================================================================
221
+
222
+ api.registerService({
223
+ id: "memoryrouter",
224
+ start: () => {
225
+ if (memoryKey) {
226
+ api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
227
+ }
228
+ },
229
+ stop: () => {
230
+ api.logger.info?.("memoryrouter: stopped");
231
+ },
232
+ });
233
+ },
234
+ };
235
+
236
+ export default memoryRouterPlugin;
@@ -0,0 +1,31 @@
1
+ {
2
+ "id": "memoryrouter",
3
+ "uiHints": {
4
+ "key": {
5
+ "label": "Memory Key",
6
+ "sensitive": true,
7
+ "placeholder": "mk_xxx",
8
+ "help": "Your MemoryRouter API key (get one at memoryrouter.ai)"
9
+ },
10
+ "endpoint": {
11
+ "label": "API Endpoint",
12
+ "placeholder": "https://api.memoryrouter.ai/v1",
13
+ "advanced": true,
14
+ "help": "Override for self-hosted MemoryRouter"
15
+ }
16
+ },
17
+ "configSchema": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "properties": {
21
+ "key": {
22
+ "type": "string",
23
+ "pattern": "^mk[_-]"
24
+ },
25
+ "endpoint": {
26
+ "type": "string"
27
+ }
28
+ },
29
+ "required": []
30
+ }
31
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "mr-memory",
3
+ "version": "1.0.0",
4
+ "description": "MemoryRouter persistent memory plugin for OpenClaw — your AI remembers every conversation",
5
+ "type": "module",
6
+ "keywords": [
7
+ "openclaw",
8
+ "openclaw-plugin",
9
+ "memory",
10
+ "memoryrouter",
11
+ "ai-memory",
12
+ "persistent-memory",
13
+ "rag"
14
+ ],
15
+ "author": "John Rood <john@memoryrouter.ai>",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/John-Rood/MemoryRouter.git",
20
+ "directory": "integrations/openclaw-plugin"
21
+ },
22
+ "homepage": "https://memoryrouter.ai/openclaw",
23
+ "openclaw": {
24
+ "extensions": [
25
+ "./index.ts"
26
+ ]
27
+ }
28
+ }
package/upload.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * MemoryRouter Upload — File discovery, parsing, batching, and upload.
3
+ */
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+
8
+ type MemoryLine = {
9
+ content: string;
10
+ role: "user" | "assistant";
11
+ timestamp: number;
12
+ };
13
+
14
+ const MAX_ITEM_CHARS = 8000;
15
+ const TARGET_CHUNK_CHARS = 4000;
16
+ const MAX_BATCH_BYTES = 2_000_000;
17
+ const MAX_BATCH_COUNT = 100;
18
+ const BATCH_SLEEP_MS = 150;
19
+ const MAX_HTTP_RETRIES = 3;
20
+
21
+ function sleep(ms: number): Promise<void> {
22
+ return new Promise((resolve) => setTimeout(resolve, ms));
23
+ }
24
+
25
+ function chunkText(text: string, targetChars: number): string[] {
26
+ const chunks: string[] = [];
27
+ let remaining = text;
28
+
29
+ while (remaining.length > targetChars) {
30
+ let splitAt = remaining.lastIndexOf("\n\n", targetChars);
31
+ if (splitAt < targetChars * 0.5) {
32
+ splitAt = remaining.lastIndexOf("\n", targetChars);
33
+ }
34
+ if (splitAt < targetChars * 0.5) {
35
+ splitAt = remaining.lastIndexOf(" ", targetChars);
36
+ }
37
+ if (splitAt < targetChars * 0.3) {
38
+ splitAt = targetChars;
39
+ }
40
+ chunks.push(remaining.slice(0, splitAt).trim());
41
+ remaining = remaining.slice(splitAt).trim();
42
+ }
43
+ if (remaining) {
44
+ chunks.push(remaining);
45
+ }
46
+ return chunks;
47
+ }
48
+
49
+ async function fileToJsonl(filePath: string): Promise<MemoryLine[]> {
50
+ const content = await fs.readFile(filePath, "utf-8");
51
+ const stat = await fs.stat(filePath);
52
+ const timestamp = Math.floor(stat.mtimeMs);
53
+ const filename = path.basename(filePath);
54
+
55
+ const trimmed = content.trim();
56
+ if (trimmed.length < 50) return [];
57
+
58
+ if (trimmed.length <= MAX_ITEM_CHARS) {
59
+ return [{ content: `[${filename}] ${trimmed}`, role: "user", timestamp }];
60
+ }
61
+
62
+ const chunks = chunkText(trimmed, TARGET_CHUNK_CHARS);
63
+ return chunks.map((chunk, i) => ({
64
+ content: `[${filename} part ${i + 1}/${chunks.length}] ${chunk}`,
65
+ role: "user" as const,
66
+ timestamp,
67
+ }));
68
+ }
69
+
70
+ async function sessionToJsonl(filePath: string): Promise<MemoryLine[]> {
71
+ const content = await fs.readFile(filePath, "utf-8");
72
+ const lines: MemoryLine[] = [];
73
+
74
+ for (const line of content.split("\n")) {
75
+ if (!line.trim()) continue;
76
+ try {
77
+ const parsed = JSON.parse(line);
78
+ if (parsed.type !== "message") continue;
79
+ const msg = parsed.message;
80
+ if (!msg || !msg.role) continue;
81
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
82
+
83
+ let text = "";
84
+ if (typeof msg.content === "string") {
85
+ text = msg.content;
86
+ } else if (Array.isArray(msg.content)) {
87
+ text = msg.content
88
+ .filter((block: { type: string }) => block.type === "text")
89
+ .map((block: { text: string }) => block.text)
90
+ .join("\n");
91
+ }
92
+
93
+ if (!text || text.trim().length < 20) continue;
94
+
95
+ let timestamp: number;
96
+ if (typeof parsed.timestamp === "string") {
97
+ timestamp = new Date(parsed.timestamp).getTime();
98
+ } else if (typeof parsed.timestamp === "number") {
99
+ timestamp = parsed.timestamp;
100
+ } else {
101
+ timestamp = Date.now();
102
+ }
103
+
104
+ lines.push({ content: text.trim(), role: msg.role, timestamp });
105
+ } catch {
106
+ // Skip invalid lines
107
+ }
108
+ }
109
+ return lines;
110
+ }
111
+
112
+ async function exists(p: string): Promise<boolean> {
113
+ try {
114
+ await fs.access(p);
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ async function discoverFiles(workspacePath: string, stateDir: string): Promise<string[]> {
122
+ const files: string[] = [];
123
+
124
+ const memoryMd = path.join(workspacePath, "MEMORY.md");
125
+ if (await exists(memoryMd)) files.push(memoryMd);
126
+
127
+ const memoryDir = path.join(workspacePath, "memory");
128
+ if (await exists(memoryDir)) {
129
+ const allMemFiles = await fs.readdir(memoryDir, { recursive: true });
130
+ const mdFiles = (allMemFiles as string[]).filter((f) => f.endsWith(".md"));
131
+ files.push(...mdFiles.map((f) => path.join(memoryDir, f)));
132
+ }
133
+
134
+ for (const contextFile of ["AGENTS.md", "TOOLS.md"]) {
135
+ const p = path.join(workspacePath, contextFile);
136
+ if (await exists(p)) files.push(p);
137
+ }
138
+
139
+ const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
140
+ if (await exists(sessionsDir)) {
141
+ const allSessionFiles = await fs.readdir(sessionsDir);
142
+ const sessionFiles = (allSessionFiles as string[]).filter((f) => f.endsWith(".jsonl"));
143
+ files.push(...sessionFiles.map((f) => path.join(sessionsDir, f)));
144
+ }
145
+
146
+ return files;
147
+ }
148
+
149
+ async function fetchWithRetry(
150
+ url: string,
151
+ init: RequestInit,
152
+ label: string,
153
+ ): Promise<Response> {
154
+ let lastError: Error | null = null;
155
+ for (let attempt = 1; attempt <= MAX_HTTP_RETRIES; attempt++) {
156
+ try {
157
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(30000) });
158
+ if (res.ok || res.status < 500) return res;
159
+ lastError = new Error(`HTTP ${res.status}`);
160
+ } catch (err) {
161
+ lastError = err instanceof Error ? err : new Error(String(err));
162
+ }
163
+ if (attempt < MAX_HTTP_RETRIES) await sleep(1000 * attempt);
164
+ }
165
+ throw lastError ?? new Error(`${label}: failed after ${MAX_HTTP_RETRIES} attempts`);
166
+ }
167
+
168
+ export async function runUpload(params: {
169
+ memoryKey: string;
170
+ endpoint: string;
171
+ targetPath?: string;
172
+ stateDir: string;
173
+ workspacePath?: string;
174
+ }): Promise<void> {
175
+ const { memoryKey, endpoint, targetPath, stateDir } = params;
176
+ const uploadUrl = `${endpoint}/v1/memory/upload`;
177
+
178
+ // Validate API reachability
179
+ try {
180
+ const res = await fetch(`${endpoint}/health`, { signal: AbortSignal.timeout(5000) });
181
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
182
+ } catch {
183
+ console.error("Error: Could not reach MemoryRouter API.");
184
+ return;
185
+ }
186
+
187
+ const workspacePath = params.workspacePath ?? process.cwd();
188
+
189
+ let files: string[];
190
+ if (targetPath) {
191
+ const resolved = path.resolve(targetPath);
192
+ const stat = await fs.stat(resolved);
193
+ if (stat.isDirectory()) {
194
+ const allDirFiles = await fs.readdir(resolved, { recursive: true });
195
+ files = (allDirFiles as string[])
196
+ .filter((f) => f.endsWith(".md") || f.endsWith(".jsonl"))
197
+ .map((f) => path.join(resolved, f));
198
+ } else {
199
+ files = [resolved];
200
+ }
201
+ } else {
202
+ files = await discoverFiles(workspacePath, stateDir);
203
+ }
204
+
205
+ if (files.length === 0) {
206
+ console.log("No files found to upload.");
207
+ return;
208
+ }
209
+
210
+ console.log(`Uploading ${files.length} files to MemoryRouter...`);
211
+
212
+ const allLines: MemoryLine[] = [];
213
+ let skippedEmpty = 0;
214
+
215
+ for (const file of files) {
216
+ const displayName = path.basename(file);
217
+ try {
218
+ const lines = file.endsWith(".jsonl") ? await sessionToJsonl(file) : await fileToJsonl(file);
219
+ if (lines.length === 0) {
220
+ skippedEmpty++;
221
+ continue;
222
+ }
223
+ allLines.push(...lines);
224
+ console.log(` ${displayName.padEnd(40, ".")} ✓ (${lines.length} chunks)`);
225
+ } catch (err) {
226
+ console.log(` ${displayName.padEnd(40, ".")} ✗ ${err instanceof Error ? err.message : "Error"}`);
227
+ }
228
+ }
229
+
230
+ if (skippedEmpty > 0) console.log(` Skipped ${skippedEmpty} empty files`);
231
+ if (allLines.length === 0) {
232
+ console.log("\nNo content to upload.");
233
+ return;
234
+ }
235
+
236
+ // Batch
237
+ const batches: MemoryLine[][] = [];
238
+ let currentBatch: MemoryLine[] = [];
239
+ let currentBytes = 0;
240
+
241
+ for (const line of allLines) {
242
+ const lineBytes = JSON.stringify(line).length + 1;
243
+ if (currentBytes + lineBytes > MAX_BATCH_BYTES || currentBatch.length >= MAX_BATCH_COUNT) {
244
+ if (currentBatch.length > 0) batches.push(currentBatch);
245
+ currentBatch = [line];
246
+ currentBytes = lineBytes;
247
+ } else {
248
+ currentBatch.push(line);
249
+ currentBytes += lineBytes;
250
+ }
251
+ }
252
+ if (currentBatch.length > 0) batches.push(currentBatch);
253
+
254
+ console.log(`\nSending ${allLines.length} memories in ${batches.length} batches...`);
255
+
256
+ let totalProcessed = 0;
257
+ let totalFailed = 0;
258
+
259
+ for (let i = 0; i < batches.length; i++) {
260
+ if (i > 0) await sleep(BATCH_SLEEP_MS);
261
+
262
+ const batch = batches[i];
263
+ const jsonlBody = batch.map((line) => JSON.stringify(line)).join("\n");
264
+
265
+ if (batches.length > 1) {
266
+ process.stdout.write(` Batch ${i + 1}/${batches.length} (${batch.length} items)... `);
267
+ }
268
+
269
+ try {
270
+ const response = await fetchWithRetry(
271
+ uploadUrl,
272
+ {
273
+ method: "POST",
274
+ headers: {
275
+ Authorization: `Bearer ${memoryKey}`,
276
+ "Content-Type": "text/plain",
277
+ },
278
+ body: jsonlBody,
279
+ },
280
+ `Batch ${i + 1}`,
281
+ );
282
+
283
+ const result = (await response.json()) as {
284
+ stats?: { stored?: number; failed?: number; inputItems?: number };
285
+ errors?: string[];
286
+ };
287
+
288
+ const batchStored = result.stats?.stored ?? result.stats?.inputItems ?? batch.length;
289
+ const batchFailed = result.stats?.failed ?? 0;
290
+ totalProcessed += batchStored;
291
+ totalFailed += batchFailed;
292
+
293
+ if (batchFailed > 0) {
294
+ const errHint = result.errors?.[0] ? ` (${result.errors[0].slice(0, 80)})` : "";
295
+ console.log(`⚠ ${batchStored} stored, ${batchFailed} skipped${errHint}`);
296
+ } else {
297
+ console.log(`✓ ${batchStored} stored`);
298
+ }
299
+ } catch (err) {
300
+ console.log(`✗ Failed: ${err instanceof Error ? err.message : String(err)}`);
301
+ totalFailed += batch.length;
302
+ }
303
+ }
304
+
305
+ console.log(`\n✅ ${totalProcessed} vectors stored in vault`);
306
+ if (totalFailed > 0) {
307
+ console.log(`⚠️ ${totalFailed} failed (${((totalFailed / (totalProcessed + totalFailed)) * 100).toFixed(0)}%)`);
308
+ }
309
+ }