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 +67 -0
- package/index.ts +236 -0
- package/openclaw.plugin.json +31 -0
- package/package.json +28 -0
- package/upload.ts +309 -0
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
|
+
}
|