mr-memory 3.9.1 → 3.10.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 +13 -0
- package/import-transport.js +457 -0
- package/import-transport.ts +511 -0
- package/index.js +49 -0
- package/index.ts +47 -0
- package/memoryrouter-api.js +51 -0
- package/memoryrouter-api.ts +59 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -40,6 +40,19 @@ openclaw mr upload --workspace <dir> # Custom workspace directory
|
|
|
40
40
|
openclaw mr upload --brain <dir> # Custom state dir (sessions from another agent)
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
## Historical Import (agent-driven)
|
|
44
|
+
|
|
45
|
+
Import existing OpenClaw conversation history with a strict division of labor: the server owns the extraction prompt (versioned, fetched at import time), your agent owns discovery/parsing of the local session files, and the CLI owns transport. The agent never uploads; the CLI never parses history.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
openclaw mr import --instructions # print the server's extraction prompt for your agent
|
|
49
|
+
openclaw mr import --file extract.jsonl # validate, preview, price quote, approve, upload
|
|
50
|
+
openclaw mr import --file extract.jsonl --yes # non-interactive approval
|
|
51
|
+
openclaw mr import --dismiss # permanently silence the import nudge
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Guarantees: the extract is validated locally (four fields per record, `openclaw:` prefixed ids, epoch-ms timestamps); the CLI shows record count, date range, estimated tokens, and the server price quote and requires explicit approval before any write; the import id is content-derived and every batch carries an Idempotency-Key, so re-running after any interruption replays committed batches for free and resumes. Duplicates are never re-billed. The memory key never appears in the prompt or the extract file. When a vault is new, injected context includes a one-line import suggestion until an import completes or you `--dismiss`.
|
|
55
|
+
|
|
43
56
|
## How It Works
|
|
44
57
|
|
|
45
58
|
When enabled, relay hooks retrieve memory before agent/model input and capture the direct user-to-assistant conversation at `agent_end`. Inference and provider API keys remain inside OpenClaw; the plugin does not route provider calls through MemoryRouter and is not a provider-key proxy.
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-driven historical import: the transport half (OpenClaw lane).
|
|
3
|
+
*
|
|
4
|
+
* Division of labor (locked): the SERVER owns the extraction prompt
|
|
5
|
+
* (GET /v1/memory/imports/instructions, versioned like reflection_contract);
|
|
6
|
+
* the HOST AGENT executes that prompt locally and writes a JSONL extract; THIS
|
|
7
|
+
* MODULE owns transport only: local validation, deterministic identity math
|
|
8
|
+
* (byte-for-byte with workers/src/services/import-identity.ts), create and
|
|
9
|
+
* approve, idempotent batch upload with resume, and the receipt. The agent
|
|
10
|
+
* never touches HTTP for upload and this module never parses history.
|
|
11
|
+
*
|
|
12
|
+
* The Memory Key never appears in the prompt or the extract file; it rides
|
|
13
|
+
* only in the Authorization header.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import readline from "node:readline";
|
|
18
|
+
import { importInstructions, importCreate, importApprove, importBatch, importStatus, importReceipt, importDismiss, } from "./memoryrouter-api.js";
|
|
19
|
+
export const PLATFORM = "openclaw";
|
|
20
|
+
export const PARSER_VERSION = 1;
|
|
21
|
+
export const CHUNK_VERSION = 1;
|
|
22
|
+
const BATCH_SIZE = 2000;
|
|
23
|
+
const MAX_CONTENT_CHARS = 1000000;
|
|
24
|
+
const MAX_UPLOAD_ATTEMPTS = 5;
|
|
25
|
+
const INITIAL_BACKOFF_MS = 2000;
|
|
26
|
+
const MAX_BACKOFF_MS = 30000;
|
|
27
|
+
// ── identity math (mirrors the server byte-for-byte) ─────────────────────────
|
|
28
|
+
function sha256hex(input) {
|
|
29
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
30
|
+
}
|
|
31
|
+
function sortKeys(value) {
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value.map(sortKeys);
|
|
34
|
+
if (value && typeof value === "object") {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const key of Object.keys(value).sort()) {
|
|
37
|
+
out[key] = sortKeys(value[key]);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
export function canonicalJson(value) {
|
|
44
|
+
return JSON.stringify(sortKeys(value));
|
|
45
|
+
}
|
|
46
|
+
export function normalizeText(input) {
|
|
47
|
+
return input.normalize("NFC").replace(/\r\n?/g, "\n").trim();
|
|
48
|
+
}
|
|
49
|
+
export function vaultFingerprint(memoryKey) {
|
|
50
|
+
return `mrvault_${sha256hex(memoryKey).slice(0, 16)}`;
|
|
51
|
+
}
|
|
52
|
+
function unescapeIdPart(s) {
|
|
53
|
+
return s.replace(/%3A/g, ":").replace(/%25/g, "%");
|
|
54
|
+
}
|
|
55
|
+
export function parseExternalId(id) {
|
|
56
|
+
const parts = id.split(":");
|
|
57
|
+
if (parts.length !== 4)
|
|
58
|
+
return null;
|
|
59
|
+
const [platform, conv, msg, ver] = parts;
|
|
60
|
+
if (!/^v\d+$/.test(ver))
|
|
61
|
+
return null;
|
|
62
|
+
return {
|
|
63
|
+
platform,
|
|
64
|
+
conversationId: unescapeIdPart(conv),
|
|
65
|
+
messageId: unescapeIdPart(msg),
|
|
66
|
+
parserVersion: parseInt(ver.slice(1), 10),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function recordHash(fields) {
|
|
70
|
+
return sha256hex(canonicalJson({
|
|
71
|
+
conversationId: fields.conversationId,
|
|
72
|
+
messageId: fields.messageId,
|
|
73
|
+
role: fields.role,
|
|
74
|
+
text: normalizeText(fields.text),
|
|
75
|
+
timestamp: fields.timestamp,
|
|
76
|
+
v: PARSER_VERSION,
|
|
77
|
+
})).slice(0, 32);
|
|
78
|
+
}
|
|
79
|
+
export function conversationContentHash(fields) {
|
|
80
|
+
return sha256hex(canonicalJson(fields)).slice(0, 32);
|
|
81
|
+
}
|
|
82
|
+
export function archiveContentId(conversationContentHashes) {
|
|
83
|
+
const canonical = [...conversationContentHashes].sort().join("\n");
|
|
84
|
+
return `arc_${sha256hex(`v${PARSER_VERSION}\n${canonical}`).slice(0, 24)}`;
|
|
85
|
+
}
|
|
86
|
+
export function deriveImportId(archiveId, fingerprint) {
|
|
87
|
+
return `imp_${sha256hex(`${archiveId}|${fingerprint}|p${PARSER_VERSION}|c${CHUNK_VERSION}`).slice(0, 24)}`;
|
|
88
|
+
}
|
|
89
|
+
export function previewDigest(preview) {
|
|
90
|
+
return `pvd_${sha256hex(canonicalJson(preview)).slice(0, 32)}`;
|
|
91
|
+
}
|
|
92
|
+
export function readExtract(filePath, platform = PLATFORM) {
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = readFileSync(filePath, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return { error: `could not read ${filePath}: ${err instanceof Error ? err.message : String(err)}` };
|
|
99
|
+
}
|
|
100
|
+
const records = [];
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
const lines = raw.split("\n");
|
|
103
|
+
for (let i = 0; i < lines.length; i++) {
|
|
104
|
+
const line = lines[i].trim();
|
|
105
|
+
if (!line)
|
|
106
|
+
continue;
|
|
107
|
+
const lineNo = i + 1;
|
|
108
|
+
let p;
|
|
109
|
+
try {
|
|
110
|
+
p = JSON.parse(line);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { error: `line ${lineNo}: not valid JSON` };
|
|
114
|
+
}
|
|
115
|
+
if (!p || typeof p !== "object" || Array.isArray(p))
|
|
116
|
+
return { error: `line ${lineNo}: record is not an object` };
|
|
117
|
+
const extras = Object.keys(p).filter((k) => !["external_id", "content", "timestamp", "role"].includes(k));
|
|
118
|
+
if (extras.length)
|
|
119
|
+
return { error: `line ${lineNo}: unexpected fields: ${extras.join(", ")}` };
|
|
120
|
+
if (typeof p.external_id !== "string" || !p.external_id)
|
|
121
|
+
return { error: `line ${lineNo}: external_id must be a string` };
|
|
122
|
+
const parsed = parseExternalId(p.external_id);
|
|
123
|
+
if (!parsed)
|
|
124
|
+
return { error: `line ${lineNo}: malformed external_id (expected ${platform}:<conversation>:<message>:v${PARSER_VERSION})` };
|
|
125
|
+
if (parsed.platform !== platform)
|
|
126
|
+
return { error: `line ${lineNo}: external_id platform must be ${platform}, got ${parsed.platform}` };
|
|
127
|
+
if (parsed.parserVersion !== PARSER_VERSION)
|
|
128
|
+
return { error: `line ${lineNo}: external_id version must be v${PARSER_VERSION}` };
|
|
129
|
+
if (typeof p.content !== "string" || p.content.length === 0)
|
|
130
|
+
return { error: `line ${lineNo}: content must be a non-empty string` };
|
|
131
|
+
if (p.content.length > MAX_CONTENT_CHARS)
|
|
132
|
+
return { error: `line ${lineNo}: content exceeds ${MAX_CONTENT_CHARS} chars` };
|
|
133
|
+
if (typeof p.timestamp !== "number" || !Number.isFinite(p.timestamp) || p.timestamp <= 0) {
|
|
134
|
+
return { error: `line ${lineNo}: timestamp must be a positive epoch-milliseconds number` };
|
|
135
|
+
}
|
|
136
|
+
if (p.timestamp < 1000000000000)
|
|
137
|
+
return { error: `line ${lineNo}: timestamp looks like seconds, not milliseconds (${p.timestamp})` };
|
|
138
|
+
if (p.role !== "user" && p.role !== "assistant")
|
|
139
|
+
return { error: `line ${lineNo}: role must be "user" or "assistant"` };
|
|
140
|
+
if (seen.has(p.external_id))
|
|
141
|
+
return { error: `line ${lineNo}: duplicate external_id ${p.external_id}` };
|
|
142
|
+
seen.add(p.external_id);
|
|
143
|
+
records.push({
|
|
144
|
+
externalId: p.external_id,
|
|
145
|
+
conversationId: parsed.conversationId,
|
|
146
|
+
messageId: parsed.messageId,
|
|
147
|
+
content: p.content,
|
|
148
|
+
role: p.role,
|
|
149
|
+
timestamp: p.timestamp,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (records.length === 0)
|
|
153
|
+
return { error: "the extract file contains no records" };
|
|
154
|
+
return { records };
|
|
155
|
+
}
|
|
156
|
+
// ── identity + preview ───────────────────────────────────────────────────────
|
|
157
|
+
export function computeIdentity(records, memoryKey, platform = PLATFORM) {
|
|
158
|
+
const byConversation = new Map();
|
|
159
|
+
for (const r of records) {
|
|
160
|
+
r.recordSha256 = recordHash({
|
|
161
|
+
conversationId: r.conversationId,
|
|
162
|
+
messageId: r.messageId,
|
|
163
|
+
role: r.role,
|
|
164
|
+
text: r.content,
|
|
165
|
+
timestamp: r.timestamp,
|
|
166
|
+
});
|
|
167
|
+
if (!byConversation.has(r.conversationId))
|
|
168
|
+
byConversation.set(r.conversationId, []);
|
|
169
|
+
byConversation.get(r.conversationId).push(r);
|
|
170
|
+
}
|
|
171
|
+
const convHashes = [];
|
|
172
|
+
for (const [conversationId, convRecords] of byConversation) {
|
|
173
|
+
convHashes.push(conversationContentHash({
|
|
174
|
+
conversationId,
|
|
175
|
+
canonicalPolicy: `agent-extract-${platform}`,
|
|
176
|
+
canonicalLeaf: null,
|
|
177
|
+
records: convRecords.map((r) => ({
|
|
178
|
+
externalId: r.externalId,
|
|
179
|
+
recordHash: r.recordSha256,
|
|
180
|
+
role: r.role,
|
|
181
|
+
timestamp: r.timestamp,
|
|
182
|
+
})),
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
const archiveId = archiveContentId(convHashes);
|
|
186
|
+
const fingerprint = vaultFingerprint(memoryKey);
|
|
187
|
+
const importId = deriveImportId(archiveId, fingerprint);
|
|
188
|
+
return { archiveId, fingerprint, importId, conversations: byConversation.size };
|
|
189
|
+
}
|
|
190
|
+
export function estimateTokens(records) {
|
|
191
|
+
let total = 0;
|
|
192
|
+
for (const r of records)
|
|
193
|
+
total += Math.ceil(r.content.length / 4);
|
|
194
|
+
return total;
|
|
195
|
+
}
|
|
196
|
+
export function buildPreviewSummary(records) {
|
|
197
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
198
|
+
let latest = 0;
|
|
199
|
+
let user = 0;
|
|
200
|
+
for (const r of records) {
|
|
201
|
+
if (r.role === "user")
|
|
202
|
+
user++;
|
|
203
|
+
if (r.timestamp < earliest)
|
|
204
|
+
earliest = r.timestamp;
|
|
205
|
+
if (r.timestamp > latest)
|
|
206
|
+
latest = r.timestamp;
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
records: records.length,
|
|
210
|
+
userRecords: user,
|
|
211
|
+
assistantRecords: records.length - user,
|
|
212
|
+
earliestMs: Number.isFinite(earliest) ? earliest : 0,
|
|
213
|
+
latestMs: latest,
|
|
214
|
+
estimatedTokens: estimateTokens(records),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
export function toWireRecord(r, platform = PLATFORM) {
|
|
218
|
+
return JSON.stringify({
|
|
219
|
+
external_id: r.externalId,
|
|
220
|
+
record_sha256: r.recordSha256,
|
|
221
|
+
content: r.content,
|
|
222
|
+
role: r.role,
|
|
223
|
+
timestamp: r.timestamp,
|
|
224
|
+
source_platform: platform,
|
|
225
|
+
parser_version: PARSER_VERSION,
|
|
226
|
+
chunk_version: CHUNK_VERSION,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const defaultFetchers = {
|
|
230
|
+
importInstructions,
|
|
231
|
+
importCreate,
|
|
232
|
+
importApprove,
|
|
233
|
+
importBatch,
|
|
234
|
+
importStatus,
|
|
235
|
+
importReceipt,
|
|
236
|
+
importDismiss,
|
|
237
|
+
};
|
|
238
|
+
function fmtDate(ms) {
|
|
239
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
240
|
+
return "unknown";
|
|
241
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
242
|
+
}
|
|
243
|
+
function fmtTokens(n) {
|
|
244
|
+
if (n >= 1000000)
|
|
245
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
246
|
+
if (n >= 1000)
|
|
247
|
+
return `${(n / 1000).toFixed(1)}K`;
|
|
248
|
+
return String(n);
|
|
249
|
+
}
|
|
250
|
+
async function jsonOf(res) {
|
|
251
|
+
try {
|
|
252
|
+
return (await res.json());
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return {};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function errorDetail(status, body) {
|
|
259
|
+
const parts = [body.error, body.message].filter(Boolean);
|
|
260
|
+
return parts.length ? parts.join(": ") : `HTTP ${status}`;
|
|
261
|
+
}
|
|
262
|
+
function sleep(ms) {
|
|
263
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
264
|
+
}
|
|
265
|
+
function ask(question) {
|
|
266
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
267
|
+
return new Promise((resolve) => rl.question(question, (answer) => {
|
|
268
|
+
rl.close();
|
|
269
|
+
resolve(answer.trim().toLowerCase());
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
/** Fetch and print the server-side extraction prompt for the host agent. */
|
|
273
|
+
export async function runInstructions(options) {
|
|
274
|
+
const f = { ...defaultFetchers, ...(options.fetchers || {}) };
|
|
275
|
+
const log = options.log || ((...args) => console.log(...args));
|
|
276
|
+
const res = await f.importInstructions(options.endpoint, options.key, options.platform || PLATFORM);
|
|
277
|
+
const body = await jsonOf(res);
|
|
278
|
+
if (!res.ok)
|
|
279
|
+
throw new Error(`could not fetch import instructions (${errorDetail(res.status, body)})`);
|
|
280
|
+
if (!body.prompt)
|
|
281
|
+
throw new Error("instructions response carried no prompt");
|
|
282
|
+
log(body.prompt);
|
|
283
|
+
return { version: Number(body.import_prompt_version) || 0 };
|
|
284
|
+
}
|
|
285
|
+
/** Call the dismiss endpoint so the server-side nudge stops permanently. */
|
|
286
|
+
export async function runDismiss(options) {
|
|
287
|
+
const f = { ...defaultFetchers, ...(options.fetchers || {}) };
|
|
288
|
+
const log = options.log || ((...args) => console.log(...args));
|
|
289
|
+
const res = await f.importDismiss(options.endpoint, options.key, options.platform || PLATFORM);
|
|
290
|
+
const body = await jsonOf(res);
|
|
291
|
+
if (!res.ok)
|
|
292
|
+
throw new Error(`dismiss failed (${errorDetail(res.status, body)})`);
|
|
293
|
+
log("Import suggestion dismissed permanently for this vault.");
|
|
294
|
+
}
|
|
295
|
+
/** The transport: validate, create, quote, approve, upload, receipt. */
|
|
296
|
+
export async function runImportFile(options) {
|
|
297
|
+
const f = { ...defaultFetchers, ...(options.fetchers || {}) };
|
|
298
|
+
const log = options.log || ((...args) => console.log(...args));
|
|
299
|
+
const platform = options.platform || PLATFORM;
|
|
300
|
+
if (!options.file)
|
|
301
|
+
throw new Error("--file <path> is required");
|
|
302
|
+
const parsed = readExtract(options.file, platform);
|
|
303
|
+
if (parsed.error)
|
|
304
|
+
throw new Error(`invalid extract: ${parsed.error}`);
|
|
305
|
+
const records = parsed.records;
|
|
306
|
+
const identity = computeIdentity(records, options.key, platform);
|
|
307
|
+
const preview = buildPreviewSummary(records);
|
|
308
|
+
const clientPreviewDigest = previewDigest({
|
|
309
|
+
importId: identity.importId,
|
|
310
|
+
archiveId: identity.archiveId,
|
|
311
|
+
fingerprint: identity.fingerprint,
|
|
312
|
+
parserVersion: PARSER_VERSION,
|
|
313
|
+
chunkVersion: CHUNK_VERSION,
|
|
314
|
+
expectedRecords: preview.records,
|
|
315
|
+
expectedChunks: preview.records,
|
|
316
|
+
estimatedTokens: preview.estimatedTokens,
|
|
317
|
+
selection: null,
|
|
318
|
+
categories: [],
|
|
319
|
+
});
|
|
320
|
+
const createRes = await f.importCreate(options.endpoint, options.key, {
|
|
321
|
+
import_id: identity.importId,
|
|
322
|
+
archive_content_id: identity.archiveId,
|
|
323
|
+
archive_sha256: null,
|
|
324
|
+
parser_version: PARSER_VERSION,
|
|
325
|
+
chunk_version: CHUNK_VERSION,
|
|
326
|
+
source_platform: platform,
|
|
327
|
+
vault_fingerprint: identity.fingerprint,
|
|
328
|
+
preview_digest: clientPreviewDigest,
|
|
329
|
+
expected_records: preview.records,
|
|
330
|
+
expected_chunks: preview.records,
|
|
331
|
+
estimated_tokens: preview.estimatedTokens,
|
|
332
|
+
selection: null,
|
|
333
|
+
categories: [],
|
|
334
|
+
});
|
|
335
|
+
const created = await jsonOf(createRes);
|
|
336
|
+
if (!createRes.ok)
|
|
337
|
+
throw new Error(`import preflight failed (${errorDetail(createRes.status, created)})`);
|
|
338
|
+
if (created.preview_digest && created.preview_digest !== clientPreviewDigest) {
|
|
339
|
+
throw new Error("client/server preview digest mismatch (fail closed)");
|
|
340
|
+
}
|
|
341
|
+
const quote = (created.cost_quote || {});
|
|
342
|
+
log("");
|
|
343
|
+
log("Import preview");
|
|
344
|
+
log("----------------------------");
|
|
345
|
+
log(`Platform: ${platform}`);
|
|
346
|
+
log(`Destination vault: ${identity.fingerprint} (opaque fingerprint)`);
|
|
347
|
+
log(`Conversations: ${identity.conversations}`);
|
|
348
|
+
log(`Records: ${preview.records} (user ${preview.userRecords}, assistant ${preview.assistantRecords})`);
|
|
349
|
+
log(`Date range: ${fmtDate(preview.earliestMs)} to ${fmtDate(preview.latestMs)}`);
|
|
350
|
+
log(`Estimated tokens: ${fmtTokens(preview.estimatedTokens)}`);
|
|
351
|
+
if (quote.available) {
|
|
352
|
+
log(`Estimated cost: $${((quote.estimated_cost_cents || 0) / 100).toFixed(2)} (rate $${quote.rate_per_million_usd}/1M paid tokens, free remaining ${fmtTokens(quote.free_tokens_remaining || 0)})`);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
log(`Estimated cost: unavailable (${quote.reason || "unknown"}); the server will refuse approval (fail closed)`);
|
|
356
|
+
}
|
|
357
|
+
log(`Import id: ${identity.importId}`);
|
|
358
|
+
log("");
|
|
359
|
+
log("Re-running this command is always safe: committed batches replay for free and duplicates are never re-billed.");
|
|
360
|
+
log("");
|
|
361
|
+
if (!options.yes) {
|
|
362
|
+
if (options.nonInteractive || !process.stdin.isTTY) {
|
|
363
|
+
throw new Error("approval required: re-run with --yes to confirm the upload");
|
|
364
|
+
}
|
|
365
|
+
const answer = await ask("Proceed with the upload? [y/N]: ");
|
|
366
|
+
if (answer !== "y" && answer !== "yes") {
|
|
367
|
+
log("Aborted. Nothing was uploaded.");
|
|
368
|
+
return { aborted: true };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const approveRes = await f.importApprove(options.endpoint, options.key, identity.importId, String(created.approval_digest));
|
|
372
|
+
if (!approveRes.ok) {
|
|
373
|
+
const body = await jsonOf(approveRes);
|
|
374
|
+
// A completed import cannot be re-approved; that is the fully-settled
|
|
375
|
+
// resume case. Print the receipt instead of failing.
|
|
376
|
+
if (body.error === "import_completed") {
|
|
377
|
+
const receiptRes = await f.importReceipt(options.endpoint, options.key, identity.importId);
|
|
378
|
+
let receipt = null;
|
|
379
|
+
if (receiptRes.ok) {
|
|
380
|
+
const receiptBody = await jsonOf(receiptRes);
|
|
381
|
+
receipt = receiptBody.receipt ?? null;
|
|
382
|
+
}
|
|
383
|
+
log("This exact extract was already fully imported into this vault. Nothing to upload.");
|
|
384
|
+
if (receipt) {
|
|
385
|
+
log("Receipt:");
|
|
386
|
+
log(JSON.stringify(receipt, null, 2));
|
|
387
|
+
}
|
|
388
|
+
return { importId: identity.importId, complete: true, receipt };
|
|
389
|
+
}
|
|
390
|
+
throw new Error(`approval refused (${errorDetail(approveRes.status, body)})`);
|
|
391
|
+
}
|
|
392
|
+
const batches = [];
|
|
393
|
+
const size = options.batchSize || BATCH_SIZE;
|
|
394
|
+
for (let i = 0; i < records.length; i += size)
|
|
395
|
+
batches.push(records.slice(i, i + size));
|
|
396
|
+
const totals = { stored: 0, duplicate: 0, skipped: 0, failed: 0, conflict: 0 };
|
|
397
|
+
log(`Uploading ${records.length} record(s) in ${batches.length} batch(es)...`);
|
|
398
|
+
for (let i = 0; i < batches.length; i++) {
|
|
399
|
+
const jsonl = batches[i].map((r) => toWireRecord(r, platform)).join("\n");
|
|
400
|
+
const idempotencyKey = `${identity.importId}:b${i}`;
|
|
401
|
+
let res = null;
|
|
402
|
+
let backoff = INITIAL_BACKOFF_MS;
|
|
403
|
+
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt++) {
|
|
404
|
+
try {
|
|
405
|
+
res = await f.importBatch(options.endpoint, options.key, identity.importId, i, jsonl, idempotencyKey);
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
res = null;
|
|
409
|
+
if (attempt === MAX_UPLOAD_ATTEMPTS) {
|
|
410
|
+
throw new Error(`batch ${i} failed after ${MAX_UPLOAD_ATTEMPTS} attempts: ${err instanceof Error ? err.message : String(err)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (res && (res.ok || (res.status < 500 && res.status !== 429)))
|
|
414
|
+
break;
|
|
415
|
+
if (attempt === MAX_UPLOAD_ATTEMPTS)
|
|
416
|
+
break;
|
|
417
|
+
await sleep(backoff);
|
|
418
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
419
|
+
}
|
|
420
|
+
if (!res)
|
|
421
|
+
throw new Error(`batch ${i} received no response`);
|
|
422
|
+
const body = await jsonOf(res);
|
|
423
|
+
if (res.status === 402) {
|
|
424
|
+
throw new Error("payment required: out of tokens/credits. Add credits at https://app.memoryrouter.ai and re-run to resume.");
|
|
425
|
+
}
|
|
426
|
+
if (!res.ok)
|
|
427
|
+
throw new Error(`batch ${i} failed (${errorDetail(res.status, body)}). Re-run to resume; committed batches replay for free.`);
|
|
428
|
+
const s = (body.summary || {});
|
|
429
|
+
totals.stored += s.stored || 0;
|
|
430
|
+
totals.duplicate += s.duplicate || 0;
|
|
431
|
+
totals.skipped += s.skipped || 0;
|
|
432
|
+
totals.failed += s.failed || 0;
|
|
433
|
+
totals.conflict += s.conflict || 0;
|
|
434
|
+
log(` batch ${i + 1}/${batches.length}${body.replayed ? " (replayed, free)" : ""}: stored ${s.stored || 0}, duplicate ${s.duplicate || 0}, failed ${s.failed || 0}`);
|
|
435
|
+
}
|
|
436
|
+
const statusRes = await f.importStatus(options.endpoint, options.key, identity.importId);
|
|
437
|
+
const statusBody = await jsonOf(statusRes);
|
|
438
|
+
const complete = Boolean(statusRes.ok && statusBody.complete);
|
|
439
|
+
let receipt = null;
|
|
440
|
+
if (complete) {
|
|
441
|
+
const receiptRes = await f.importReceipt(options.endpoint, options.key, identity.importId);
|
|
442
|
+
if (receiptRes.ok) {
|
|
443
|
+
const receiptBody = await jsonOf(receiptRes);
|
|
444
|
+
receipt = receiptBody.receipt ?? null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
log("");
|
|
448
|
+
log(`Done. stored=${totals.stored} duplicate=${totals.duplicate} skipped=${totals.skipped} failed=${totals.failed} conflict=${totals.conflict}`);
|
|
449
|
+
if (receipt) {
|
|
450
|
+
log("Receipt:");
|
|
451
|
+
log(JSON.stringify(receipt, null, 2));
|
|
452
|
+
}
|
|
453
|
+
else if (!complete) {
|
|
454
|
+
log("The import is not fully settled yet; re-run this command to resume/reconcile.");
|
|
455
|
+
}
|
|
456
|
+
return { importId: identity.importId, totals, complete, receipt };
|
|
457
|
+
}
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-driven historical import: the transport half (OpenClaw lane).
|
|
3
|
+
*
|
|
4
|
+
* Division of labor (locked): the SERVER owns the extraction prompt
|
|
5
|
+
* (GET /v1/memory/imports/instructions, versioned like reflection_contract);
|
|
6
|
+
* the HOST AGENT executes that prompt locally and writes a JSONL extract; THIS
|
|
7
|
+
* MODULE owns transport only: local validation, deterministic identity math
|
|
8
|
+
* (byte-for-byte with workers/src/services/import-identity.ts), create and
|
|
9
|
+
* approve, idempotent batch upload with resume, and the receipt. The agent
|
|
10
|
+
* never touches HTTP for upload and this module never parses history.
|
|
11
|
+
*
|
|
12
|
+
* The Memory Key never appears in the prompt or the extract file; it rides
|
|
13
|
+
* only in the Authorization header.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import readline from "node:readline";
|
|
19
|
+
import {
|
|
20
|
+
importInstructions,
|
|
21
|
+
importCreate,
|
|
22
|
+
importApprove,
|
|
23
|
+
importBatch,
|
|
24
|
+
importStatus,
|
|
25
|
+
importReceipt,
|
|
26
|
+
importDismiss,
|
|
27
|
+
} from "./memoryrouter-api.js";
|
|
28
|
+
|
|
29
|
+
export const PLATFORM = "openclaw";
|
|
30
|
+
export const PARSER_VERSION = 1;
|
|
31
|
+
export const CHUNK_VERSION = 1;
|
|
32
|
+
const BATCH_SIZE = 2000;
|
|
33
|
+
const MAX_CONTENT_CHARS = 1000000;
|
|
34
|
+
const MAX_UPLOAD_ATTEMPTS = 5;
|
|
35
|
+
const INITIAL_BACKOFF_MS = 2000;
|
|
36
|
+
const MAX_BACKOFF_MS = 30000;
|
|
37
|
+
|
|
38
|
+
// ── identity math (mirrors the server byte-for-byte) ─────────────────────────
|
|
39
|
+
|
|
40
|
+
function sha256hex(input: string): string {
|
|
41
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sortKeys(value: unknown): unknown {
|
|
45
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
46
|
+
if (value && typeof value === "object") {
|
|
47
|
+
const out: Record<string, unknown> = {};
|
|
48
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
49
|
+
out[key] = sortKeys((value as Record<string, unknown>)[key]);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function canonicalJson(value: unknown): string {
|
|
57
|
+
return JSON.stringify(sortKeys(value));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeText(input: string): string {
|
|
61
|
+
return input.normalize("NFC").replace(/\r\n?/g, "\n").trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function vaultFingerprint(memoryKey: string): string {
|
|
65
|
+
return `mrvault_${sha256hex(memoryKey).slice(0, 16)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unescapeIdPart(s: string): string {
|
|
69
|
+
return s.replace(/%3A/g, ":").replace(/%25/g, "%");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseExternalId(id: string): { platform: string; conversationId: string; messageId: string; parserVersion: number } | null {
|
|
73
|
+
const parts = id.split(":");
|
|
74
|
+
if (parts.length !== 4) return null;
|
|
75
|
+
const [platform, conv, msg, ver] = parts;
|
|
76
|
+
if (!/^v\d+$/.test(ver)) return null;
|
|
77
|
+
return {
|
|
78
|
+
platform,
|
|
79
|
+
conversationId: unescapeIdPart(conv),
|
|
80
|
+
messageId: unescapeIdPart(msg),
|
|
81
|
+
parserVersion: parseInt(ver.slice(1), 10),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function recordHash(fields: { conversationId: string; messageId: string; role: string; text: string; timestamp: number }): string {
|
|
86
|
+
return sha256hex(
|
|
87
|
+
canonicalJson({
|
|
88
|
+
conversationId: fields.conversationId,
|
|
89
|
+
messageId: fields.messageId,
|
|
90
|
+
role: fields.role,
|
|
91
|
+
text: normalizeText(fields.text),
|
|
92
|
+
timestamp: fields.timestamp,
|
|
93
|
+
v: PARSER_VERSION,
|
|
94
|
+
}),
|
|
95
|
+
).slice(0, 32);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function conversationContentHash(fields: {
|
|
99
|
+
conversationId: string;
|
|
100
|
+
canonicalPolicy: string;
|
|
101
|
+
canonicalLeaf: string | null;
|
|
102
|
+
records: Array<{ externalId: string; recordHash: string; role: string; timestamp: number }>;
|
|
103
|
+
}): string {
|
|
104
|
+
return sha256hex(canonicalJson(fields)).slice(0, 32);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function archiveContentId(conversationContentHashes: string[]): string {
|
|
108
|
+
const canonical = [...conversationContentHashes].sort().join("\n");
|
|
109
|
+
return `arc_${sha256hex(`v${PARSER_VERSION}\n${canonical}`).slice(0, 24)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function deriveImportId(archiveId: string, fingerprint: string): string {
|
|
113
|
+
return `imp_${sha256hex(`${archiveId}|${fingerprint}|p${PARSER_VERSION}|c${CHUNK_VERSION}`).slice(0, 24)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function previewDigest(preview: unknown): string {
|
|
117
|
+
return `pvd_${sha256hex(canonicalJson(preview)).slice(0, 32)}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── extract validation ───────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export interface ExtractRecord {
|
|
123
|
+
externalId: string;
|
|
124
|
+
conversationId: string;
|
|
125
|
+
messageId: string;
|
|
126
|
+
content: string;
|
|
127
|
+
role: "user" | "assistant";
|
|
128
|
+
timestamp: number;
|
|
129
|
+
recordSha256?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function readExtract(filePath: string, platform: string = PLATFORM): { records?: ExtractRecord[]; error?: string } {
|
|
133
|
+
let raw: string;
|
|
134
|
+
try {
|
|
135
|
+
raw = readFileSync(filePath, "utf8");
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return { error: `could not read ${filePath}: ${err instanceof Error ? err.message : String(err)}` };
|
|
138
|
+
}
|
|
139
|
+
const records: ExtractRecord[] = [];
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
const lines = raw.split("\n");
|
|
142
|
+
for (let i = 0; i < lines.length; i++) {
|
|
143
|
+
const line = lines[i].trim();
|
|
144
|
+
if (!line) continue;
|
|
145
|
+
const lineNo = i + 1;
|
|
146
|
+
let p: Record<string, unknown>;
|
|
147
|
+
try {
|
|
148
|
+
p = JSON.parse(line) as Record<string, unknown>;
|
|
149
|
+
} catch {
|
|
150
|
+
return { error: `line ${lineNo}: not valid JSON` };
|
|
151
|
+
}
|
|
152
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) return { error: `line ${lineNo}: record is not an object` };
|
|
153
|
+
const extras = Object.keys(p).filter((k) => !["external_id", "content", "timestamp", "role"].includes(k));
|
|
154
|
+
if (extras.length) return { error: `line ${lineNo}: unexpected fields: ${extras.join(", ")}` };
|
|
155
|
+
if (typeof p.external_id !== "string" || !p.external_id) return { error: `line ${lineNo}: external_id must be a string` };
|
|
156
|
+
const parsed = parseExternalId(p.external_id);
|
|
157
|
+
if (!parsed) return { error: `line ${lineNo}: malformed external_id (expected ${platform}:<conversation>:<message>:v${PARSER_VERSION})` };
|
|
158
|
+
if (parsed.platform !== platform) return { error: `line ${lineNo}: external_id platform must be ${platform}, got ${parsed.platform}` };
|
|
159
|
+
if (parsed.parserVersion !== PARSER_VERSION) return { error: `line ${lineNo}: external_id version must be v${PARSER_VERSION}` };
|
|
160
|
+
if (typeof p.content !== "string" || p.content.length === 0) return { error: `line ${lineNo}: content must be a non-empty string` };
|
|
161
|
+
if (p.content.length > MAX_CONTENT_CHARS) return { error: `line ${lineNo}: content exceeds ${MAX_CONTENT_CHARS} chars` };
|
|
162
|
+
if (typeof p.timestamp !== "number" || !Number.isFinite(p.timestamp) || p.timestamp <= 0) {
|
|
163
|
+
return { error: `line ${lineNo}: timestamp must be a positive epoch-milliseconds number` };
|
|
164
|
+
}
|
|
165
|
+
if (p.timestamp < 1000000000000) return { error: `line ${lineNo}: timestamp looks like seconds, not milliseconds (${p.timestamp})` };
|
|
166
|
+
if (p.role !== "user" && p.role !== "assistant") return { error: `line ${lineNo}: role must be "user" or "assistant"` };
|
|
167
|
+
if (seen.has(p.external_id)) return { error: `line ${lineNo}: duplicate external_id ${p.external_id}` };
|
|
168
|
+
seen.add(p.external_id);
|
|
169
|
+
records.push({
|
|
170
|
+
externalId: p.external_id,
|
|
171
|
+
conversationId: parsed.conversationId,
|
|
172
|
+
messageId: parsed.messageId,
|
|
173
|
+
content: p.content,
|
|
174
|
+
role: p.role,
|
|
175
|
+
timestamp: p.timestamp,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (records.length === 0) return { error: "the extract file contains no records" };
|
|
179
|
+
return { records };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── identity + preview ───────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
export function computeIdentity(records: ExtractRecord[], memoryKey: string, platform: string = PLATFORM) {
|
|
185
|
+
const byConversation = new Map<string, ExtractRecord[]>();
|
|
186
|
+
for (const r of records) {
|
|
187
|
+
r.recordSha256 = recordHash({
|
|
188
|
+
conversationId: r.conversationId,
|
|
189
|
+
messageId: r.messageId,
|
|
190
|
+
role: r.role,
|
|
191
|
+
text: r.content,
|
|
192
|
+
timestamp: r.timestamp,
|
|
193
|
+
});
|
|
194
|
+
if (!byConversation.has(r.conversationId)) byConversation.set(r.conversationId, []);
|
|
195
|
+
byConversation.get(r.conversationId)!.push(r);
|
|
196
|
+
}
|
|
197
|
+
const convHashes: string[] = [];
|
|
198
|
+
for (const [conversationId, convRecords] of byConversation) {
|
|
199
|
+
convHashes.push(conversationContentHash({
|
|
200
|
+
conversationId,
|
|
201
|
+
canonicalPolicy: `agent-extract-${platform}`,
|
|
202
|
+
canonicalLeaf: null,
|
|
203
|
+
records: convRecords.map((r) => ({
|
|
204
|
+
externalId: r.externalId,
|
|
205
|
+
recordHash: r.recordSha256!,
|
|
206
|
+
role: r.role,
|
|
207
|
+
timestamp: r.timestamp,
|
|
208
|
+
})),
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
const archiveId = archiveContentId(convHashes);
|
|
212
|
+
const fingerprint = vaultFingerprint(memoryKey);
|
|
213
|
+
const importId = deriveImportId(archiveId, fingerprint);
|
|
214
|
+
return { archiveId, fingerprint, importId, conversations: byConversation.size };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function estimateTokens(records: ExtractRecord[]): number {
|
|
218
|
+
let total = 0;
|
|
219
|
+
for (const r of records) total += Math.ceil(r.content.length / 4);
|
|
220
|
+
return total;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function buildPreviewSummary(records: ExtractRecord[]) {
|
|
224
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
225
|
+
let latest = 0;
|
|
226
|
+
let user = 0;
|
|
227
|
+
for (const r of records) {
|
|
228
|
+
if (r.role === "user") user++;
|
|
229
|
+
if (r.timestamp < earliest) earliest = r.timestamp;
|
|
230
|
+
if (r.timestamp > latest) latest = r.timestamp;
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
records: records.length,
|
|
234
|
+
userRecords: user,
|
|
235
|
+
assistantRecords: records.length - user,
|
|
236
|
+
earliestMs: Number.isFinite(earliest) ? earliest : 0,
|
|
237
|
+
latestMs: latest,
|
|
238
|
+
estimatedTokens: estimateTokens(records),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function toWireRecord(r: ExtractRecord, platform: string = PLATFORM): string {
|
|
243
|
+
return JSON.stringify({
|
|
244
|
+
external_id: r.externalId,
|
|
245
|
+
record_sha256: r.recordSha256,
|
|
246
|
+
content: r.content,
|
|
247
|
+
role: r.role,
|
|
248
|
+
timestamp: r.timestamp,
|
|
249
|
+
source_platform: platform,
|
|
250
|
+
parser_version: PARSER_VERSION,
|
|
251
|
+
chunk_version: CHUNK_VERSION,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── flows ────────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
type Fetchers = {
|
|
258
|
+
importInstructions: typeof importInstructions;
|
|
259
|
+
importCreate: typeof importCreate;
|
|
260
|
+
importApprove: typeof importApprove;
|
|
261
|
+
importBatch: typeof importBatch;
|
|
262
|
+
importStatus: typeof importStatus;
|
|
263
|
+
importReceipt: typeof importReceipt;
|
|
264
|
+
importDismiss: typeof importDismiss;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const defaultFetchers: Fetchers = {
|
|
268
|
+
importInstructions,
|
|
269
|
+
importCreate,
|
|
270
|
+
importApprove,
|
|
271
|
+
importBatch,
|
|
272
|
+
importStatus,
|
|
273
|
+
importReceipt,
|
|
274
|
+
importDismiss,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export interface ImportOptions {
|
|
278
|
+
endpoint: string;
|
|
279
|
+
key: string;
|
|
280
|
+
platform?: string;
|
|
281
|
+
file?: string;
|
|
282
|
+
yes?: boolean;
|
|
283
|
+
nonInteractive?: boolean;
|
|
284
|
+
batchSize?: number;
|
|
285
|
+
log?: (...args: unknown[]) => void;
|
|
286
|
+
fetchers?: Partial<Fetchers>;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function fmtDate(ms: number): string {
|
|
290
|
+
if (!Number.isFinite(ms) || ms <= 0) return "unknown";
|
|
291
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function fmtTokens(n: number): string {
|
|
295
|
+
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
296
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
|
297
|
+
return String(n);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function jsonOf(res: Response): Promise<Record<string, any>> {
|
|
301
|
+
try {
|
|
302
|
+
return (await res.json()) as Record<string, any>;
|
|
303
|
+
} catch {
|
|
304
|
+
return {};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function errorDetail(status: number, body: Record<string, unknown>): string {
|
|
309
|
+
const parts = [body.error, body.message].filter(Boolean);
|
|
310
|
+
return parts.length ? parts.join(": ") : `HTTP ${status}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function sleep(ms: number): Promise<void> {
|
|
314
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function ask(question: string): Promise<string> {
|
|
318
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
319
|
+
return new Promise((resolve) => rl.question(question, (answer) => {
|
|
320
|
+
rl.close();
|
|
321
|
+
resolve(answer.trim().toLowerCase());
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Fetch and print the server-side extraction prompt for the host agent. */
|
|
326
|
+
export async function runInstructions(options: ImportOptions): Promise<{ version: number }> {
|
|
327
|
+
const f = { ...defaultFetchers, ...(options.fetchers || {}) };
|
|
328
|
+
const log = options.log || ((...args: unknown[]) => console.log(...args));
|
|
329
|
+
const res = await f.importInstructions(options.endpoint, options.key, options.platform || PLATFORM);
|
|
330
|
+
const body = await jsonOf(res);
|
|
331
|
+
if (!res.ok) throw new Error(`could not fetch import instructions (${errorDetail(res.status, body)})`);
|
|
332
|
+
if (!body.prompt) throw new Error("instructions response carried no prompt");
|
|
333
|
+
log(body.prompt);
|
|
334
|
+
return { version: Number(body.import_prompt_version) || 0 };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Call the dismiss endpoint so the server-side nudge stops permanently. */
|
|
338
|
+
export async function runDismiss(options: ImportOptions): Promise<void> {
|
|
339
|
+
const f = { ...defaultFetchers, ...(options.fetchers || {}) };
|
|
340
|
+
const log = options.log || ((...args: unknown[]) => console.log(...args));
|
|
341
|
+
const res = await f.importDismiss(options.endpoint, options.key, options.platform || PLATFORM);
|
|
342
|
+
const body = await jsonOf(res);
|
|
343
|
+
if (!res.ok) throw new Error(`dismiss failed (${errorDetail(res.status, body)})`);
|
|
344
|
+
log("Import suggestion dismissed permanently for this vault.");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The transport: validate, create, quote, approve, upload, receipt. */
|
|
348
|
+
export async function runImportFile(options: ImportOptions): Promise<{
|
|
349
|
+
aborted?: boolean;
|
|
350
|
+
importId?: string;
|
|
351
|
+
totals?: Record<string, number>;
|
|
352
|
+
complete?: boolean;
|
|
353
|
+
receipt?: unknown;
|
|
354
|
+
}> {
|
|
355
|
+
const f = { ...defaultFetchers, ...(options.fetchers || {}) };
|
|
356
|
+
const log = options.log || ((...args: unknown[]) => console.log(...args));
|
|
357
|
+
const platform = options.platform || PLATFORM;
|
|
358
|
+
if (!options.file) throw new Error("--file <path> is required");
|
|
359
|
+
|
|
360
|
+
const parsed = readExtract(options.file, platform);
|
|
361
|
+
if (parsed.error) throw new Error(`invalid extract: ${parsed.error}`);
|
|
362
|
+
const records = parsed.records!;
|
|
363
|
+
const identity = computeIdentity(records, options.key, platform);
|
|
364
|
+
const preview = buildPreviewSummary(records);
|
|
365
|
+
|
|
366
|
+
const clientPreviewDigest = previewDigest({
|
|
367
|
+
importId: identity.importId,
|
|
368
|
+
archiveId: identity.archiveId,
|
|
369
|
+
fingerprint: identity.fingerprint,
|
|
370
|
+
parserVersion: PARSER_VERSION,
|
|
371
|
+
chunkVersion: CHUNK_VERSION,
|
|
372
|
+
expectedRecords: preview.records,
|
|
373
|
+
expectedChunks: preview.records,
|
|
374
|
+
estimatedTokens: preview.estimatedTokens,
|
|
375
|
+
selection: null,
|
|
376
|
+
categories: [],
|
|
377
|
+
});
|
|
378
|
+
const createRes = await f.importCreate(options.endpoint, options.key, {
|
|
379
|
+
import_id: identity.importId,
|
|
380
|
+
archive_content_id: identity.archiveId,
|
|
381
|
+
archive_sha256: null,
|
|
382
|
+
parser_version: PARSER_VERSION,
|
|
383
|
+
chunk_version: CHUNK_VERSION,
|
|
384
|
+
source_platform: platform,
|
|
385
|
+
vault_fingerprint: identity.fingerprint,
|
|
386
|
+
preview_digest: clientPreviewDigest,
|
|
387
|
+
expected_records: preview.records,
|
|
388
|
+
expected_chunks: preview.records,
|
|
389
|
+
estimated_tokens: preview.estimatedTokens,
|
|
390
|
+
selection: null,
|
|
391
|
+
categories: [],
|
|
392
|
+
});
|
|
393
|
+
const created = await jsonOf(createRes);
|
|
394
|
+
if (!createRes.ok) throw new Error(`import preflight failed (${errorDetail(createRes.status, created)})`);
|
|
395
|
+
if (created.preview_digest && created.preview_digest !== clientPreviewDigest) {
|
|
396
|
+
throw new Error("client/server preview digest mismatch (fail closed)");
|
|
397
|
+
}
|
|
398
|
+
const quote = (created.cost_quote || {}) as Record<string, any>;
|
|
399
|
+
|
|
400
|
+
log("");
|
|
401
|
+
log("Import preview");
|
|
402
|
+
log("----------------------------");
|
|
403
|
+
log(`Platform: ${platform}`);
|
|
404
|
+
log(`Destination vault: ${identity.fingerprint} (opaque fingerprint)`);
|
|
405
|
+
log(`Conversations: ${identity.conversations}`);
|
|
406
|
+
log(`Records: ${preview.records} (user ${preview.userRecords}, assistant ${preview.assistantRecords})`);
|
|
407
|
+
log(`Date range: ${fmtDate(preview.earliestMs)} to ${fmtDate(preview.latestMs)}`);
|
|
408
|
+
log(`Estimated tokens: ${fmtTokens(preview.estimatedTokens)}`);
|
|
409
|
+
if (quote.available) {
|
|
410
|
+
log(`Estimated cost: $${((quote.estimated_cost_cents || 0) / 100).toFixed(2)} (rate $${quote.rate_per_million_usd}/1M paid tokens, free remaining ${fmtTokens(quote.free_tokens_remaining || 0)})`);
|
|
411
|
+
} else {
|
|
412
|
+
log(`Estimated cost: unavailable (${quote.reason || "unknown"}); the server will refuse approval (fail closed)`);
|
|
413
|
+
}
|
|
414
|
+
log(`Import id: ${identity.importId}`);
|
|
415
|
+
log("");
|
|
416
|
+
log("Re-running this command is always safe: committed batches replay for free and duplicates are never re-billed.");
|
|
417
|
+
log("");
|
|
418
|
+
|
|
419
|
+
if (!options.yes) {
|
|
420
|
+
if (options.nonInteractive || !process.stdin.isTTY) {
|
|
421
|
+
throw new Error("approval required: re-run with --yes to confirm the upload");
|
|
422
|
+
}
|
|
423
|
+
const answer = await ask("Proceed with the upload? [y/N]: ");
|
|
424
|
+
if (answer !== "y" && answer !== "yes") {
|
|
425
|
+
log("Aborted. Nothing was uploaded.");
|
|
426
|
+
return { aborted: true };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const approveRes = await f.importApprove(options.endpoint, options.key, identity.importId, String(created.approval_digest));
|
|
431
|
+
if (!approveRes.ok) {
|
|
432
|
+
const body = await jsonOf(approveRes);
|
|
433
|
+
// A completed import cannot be re-approved; that is the fully-settled
|
|
434
|
+
// resume case. Print the receipt instead of failing.
|
|
435
|
+
if (body.error === "import_completed") {
|
|
436
|
+
const receiptRes = await f.importReceipt(options.endpoint, options.key, identity.importId);
|
|
437
|
+
let receipt: unknown = null;
|
|
438
|
+
if (receiptRes.ok) {
|
|
439
|
+
const receiptBody = await jsonOf(receiptRes);
|
|
440
|
+
receipt = receiptBody.receipt ?? null;
|
|
441
|
+
}
|
|
442
|
+
log("This exact extract was already fully imported into this vault. Nothing to upload.");
|
|
443
|
+
if (receipt) {
|
|
444
|
+
log("Receipt:");
|
|
445
|
+
log(JSON.stringify(receipt, null, 2));
|
|
446
|
+
}
|
|
447
|
+
return { importId: identity.importId, complete: true, receipt };
|
|
448
|
+
}
|
|
449
|
+
throw new Error(`approval refused (${errorDetail(approveRes.status, body)})`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const batches: ExtractRecord[][] = [];
|
|
453
|
+
const size = options.batchSize || BATCH_SIZE;
|
|
454
|
+
for (let i = 0; i < records.length; i += size) batches.push(records.slice(i, i + size));
|
|
455
|
+
const totals = { stored: 0, duplicate: 0, skipped: 0, failed: 0, conflict: 0 };
|
|
456
|
+
log(`Uploading ${records.length} record(s) in ${batches.length} batch(es)...`);
|
|
457
|
+
for (let i = 0; i < batches.length; i++) {
|
|
458
|
+
const jsonl = batches[i].map((r) => toWireRecord(r, platform)).join("\n");
|
|
459
|
+
const idempotencyKey = `${identity.importId}:b${i}`;
|
|
460
|
+
let res: Response | null = null;
|
|
461
|
+
let backoff = INITIAL_BACKOFF_MS;
|
|
462
|
+
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt++) {
|
|
463
|
+
try {
|
|
464
|
+
res = await f.importBatch(options.endpoint, options.key, identity.importId, i, jsonl, idempotencyKey);
|
|
465
|
+
} catch (err) {
|
|
466
|
+
res = null;
|
|
467
|
+
if (attempt === MAX_UPLOAD_ATTEMPTS) {
|
|
468
|
+
throw new Error(`batch ${i} failed after ${MAX_UPLOAD_ATTEMPTS} attempts: ${err instanceof Error ? err.message : String(err)}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (res && (res.ok || (res.status < 500 && res.status !== 429))) break;
|
|
472
|
+
if (attempt === MAX_UPLOAD_ATTEMPTS) break;
|
|
473
|
+
await sleep(backoff);
|
|
474
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
475
|
+
}
|
|
476
|
+
if (!res) throw new Error(`batch ${i} received no response`);
|
|
477
|
+
const body = await jsonOf(res);
|
|
478
|
+
if (res.status === 402) {
|
|
479
|
+
throw new Error("payment required: out of tokens/credits. Add credits at https://app.memoryrouter.ai and re-run to resume.");
|
|
480
|
+
}
|
|
481
|
+
if (!res.ok) throw new Error(`batch ${i} failed (${errorDetail(res.status, body)}). Re-run to resume; committed batches replay for free.`);
|
|
482
|
+
const s = (body.summary || {}) as Record<string, number>;
|
|
483
|
+
totals.stored += s.stored || 0;
|
|
484
|
+
totals.duplicate += s.duplicate || 0;
|
|
485
|
+
totals.skipped += s.skipped || 0;
|
|
486
|
+
totals.failed += s.failed || 0;
|
|
487
|
+
totals.conflict += s.conflict || 0;
|
|
488
|
+
log(` batch ${i + 1}/${batches.length}${body.replayed ? " (replayed, free)" : ""}: stored ${s.stored || 0}, duplicate ${s.duplicate || 0}, failed ${s.failed || 0}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const statusRes = await f.importStatus(options.endpoint, options.key, identity.importId);
|
|
492
|
+
const statusBody = await jsonOf(statusRes);
|
|
493
|
+
const complete = Boolean(statusRes.ok && statusBody.complete);
|
|
494
|
+
let receipt: unknown = null;
|
|
495
|
+
if (complete) {
|
|
496
|
+
const receiptRes = await f.importReceipt(options.endpoint, options.key, identity.importId);
|
|
497
|
+
if (receiptRes.ok) {
|
|
498
|
+
const receiptBody = await jsonOf(receiptRes);
|
|
499
|
+
receipt = receiptBody.receipt ?? null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
log("");
|
|
503
|
+
log(`Done. stored=${totals.stored} duplicate=${totals.duplicate} skipped=${totals.skipped} failed=${totals.failed} conflict=${totals.conflict}`);
|
|
504
|
+
if (receipt) {
|
|
505
|
+
log("Receipt:");
|
|
506
|
+
log(JSON.stringify(receipt, null, 2));
|
|
507
|
+
} else if (!complete) {
|
|
508
|
+
log("The import is not fully settled yet; re-run this command to resume/reconcile.");
|
|
509
|
+
}
|
|
510
|
+
return { importId: identity.importId, totals, complete, receipt };
|
|
511
|
+
}
|
package/index.js
CHANGED
|
@@ -64,6 +64,11 @@ function buildDebtNudgeLine(reflection) {
|
|
|
64
64
|
const kTokens = Math.max(1, Math.round(totalTokens / 1000));
|
|
65
65
|
return `[MemoryRouter] Consolidation suggested: ${kTokens}k unconsolidated tokens. Use the memory_reflect tool to consolidate them now.`;
|
|
66
66
|
}
|
|
67
|
+
function buildImportNudgeLine(importState) {
|
|
68
|
+
if (!importState || !importState.suggested)
|
|
69
|
+
return null;
|
|
70
|
+
return `[MemoryRouter] Historical import suggested: this machine has local conversation history not yet in your vault. Run: openclaw mr import --instructions`;
|
|
71
|
+
}
|
|
67
72
|
// ── memory_get helpers (copied from OpenClaw source — pure filesystem, no embeddings)
|
|
68
73
|
function isFileMissingError(err) {
|
|
69
74
|
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
|
|
@@ -744,6 +749,9 @@ If setting up MemoryRouter for another agent:
|
|
|
744
749
|
const nudge = buildDebtNudgeLine(data.reflection);
|
|
745
750
|
if (nudge)
|
|
746
751
|
contextText = `${contextText}\n\n${nudge}`;
|
|
752
|
+
const importNudge = buildImportNudgeLine(data.import);
|
|
753
|
+
if (importNudge)
|
|
754
|
+
contextText = `${contextText}\n\n${importNudge}`;
|
|
747
755
|
const wrapped = wrapForInjection(contextText);
|
|
748
756
|
return { appendSystemContext: wrapped };
|
|
749
757
|
}
|
|
@@ -853,6 +861,9 @@ If setting up MemoryRouter for another agent:
|
|
|
853
861
|
const nudge = buildDebtNudgeLine(data.reflection);
|
|
854
862
|
if (nudge)
|
|
855
863
|
contextText = `${contextText}\n\n${nudge}`;
|
|
864
|
+
const importNudge = buildImportNudgeLine(data.import);
|
|
865
|
+
if (importNudge)
|
|
866
|
+
contextText = `${contextText}\n\n${importNudge}`;
|
|
856
867
|
const wrapped = wrapForInjection(contextText);
|
|
857
868
|
return { appendSystemContext: wrapped };
|
|
858
869
|
}
|
|
@@ -1565,6 +1576,44 @@ If setting up MemoryRouter for another agent:
|
|
|
1565
1576
|
console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1566
1577
|
}
|
|
1567
1578
|
});
|
|
1579
|
+
mr.command("import")
|
|
1580
|
+
.description("Import historical OpenClaw conversations into your vault (agent-driven extraction + CLI transport)")
|
|
1581
|
+
.option("--instructions", "Fetch and print the server's extraction prompt for the host agent to execute")
|
|
1582
|
+
.option("--file <path>", "Upload a JSONL extract: preview, price quote, explicit approval, idempotent batches, resume")
|
|
1583
|
+
.option("--dismiss", "Permanently silence the import suggestion for this vault")
|
|
1584
|
+
.option("--yes", "Skip the interactive approval prompt (required for non-interactive --file runs)")
|
|
1585
|
+
.option("--key <key>", "Override memory key (for subagent vaults)")
|
|
1586
|
+
.action(async (opts) => {
|
|
1587
|
+
const effectiveKey = opts.key || process.env.MEMORYROUTER_API_KEY || memoryKey;
|
|
1588
|
+
if (!effectiveKey) {
|
|
1589
|
+
console.error("Not configured. Run: openclaw mr <key> or pass --key <key>");
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const modes = [Boolean(opts.instructions), opts.file !== undefined, Boolean(opts.dismiss)].filter(Boolean).length;
|
|
1593
|
+
if (modes > 1) {
|
|
1594
|
+
console.error("Choose one of --instructions, --file <path>, or --dismiss");
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
try {
|
|
1598
|
+
const transport = await import("./import-transport.js");
|
|
1599
|
+
if (opts.dismiss) {
|
|
1600
|
+
await transport.runDismiss({ endpoint, key: effectiveKey });
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
if (opts.file) {
|
|
1604
|
+
await transport.runImportFile({ endpoint, key: effectiveKey, file: opts.file, yes: Boolean(opts.yes) });
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
await transport.runInstructions({ endpoint, key: effectiveKey });
|
|
1608
|
+
console.log("");
|
|
1609
|
+
console.log("Execute the prompt above with your agent, then upload the extract:");
|
|
1610
|
+
console.log(" openclaw mr import --file <path>");
|
|
1611
|
+
}
|
|
1612
|
+
catch (err) {
|
|
1613
|
+
console.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1614
|
+
process.exitCode = 1;
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1568
1617
|
mr.command("reflect")
|
|
1569
1618
|
.description("Show reflection debt status (consolidation runs via the agent's memory_reflect tool)")
|
|
1570
1619
|
.option("--key <key>", "Override memory key (for subagent vaults)")
|
package/index.ts
CHANGED
|
@@ -84,6 +84,15 @@ function buildDebtNudgeLine(reflection: ReflectionDebt | undefined): string | nu
|
|
|
84
84
|
return `[MemoryRouter] Consolidation suggested: ${kTokens}k unconsolidated tokens. Use the memory_reflect tool to consolidate them now.`;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// ── Historical import nudge (server-owned import.suggested block; additive,
|
|
88
|
+
// no rate limit, clears server-side on import completion or dismiss)
|
|
89
|
+
type ImportSuggestedState = { suggested?: boolean; platform_hint?: string | null; suggestion?: string };
|
|
90
|
+
|
|
91
|
+
function buildImportNudgeLine(importState: ImportSuggestedState | undefined): string | null {
|
|
92
|
+
if (!importState || !importState.suggested) return null;
|
|
93
|
+
return `[MemoryRouter] Historical import suggested: this machine has local conversation history not yet in your vault. Run: openclaw mr import --instructions`;
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
// ── memory_get helpers (copied from OpenClaw source — pure filesystem, no embeddings)
|
|
88
97
|
function isFileMissingError(err: unknown): boolean {
|
|
89
98
|
return Boolean(err && typeof err === "object" && "code" in err && (err as any).code === "ENOENT");
|
|
@@ -788,6 +797,7 @@ If setting up MemoryRouter for another agent:
|
|
|
788
797
|
memory_tokens?: number;
|
|
789
798
|
metrics?: Record<string, unknown>;
|
|
790
799
|
reflection?: ReflectionDebt;
|
|
800
|
+
import?: ImportSuggestedState;
|
|
791
801
|
};
|
|
792
802
|
|
|
793
803
|
if (data.context) {
|
|
@@ -797,6 +807,8 @@ If setting up MemoryRouter for another agent:
|
|
|
797
807
|
let contextText = data.context;
|
|
798
808
|
const nudge = buildDebtNudgeLine(data.reflection);
|
|
799
809
|
if (nudge) contextText = `${contextText}\n\n${nudge}`;
|
|
810
|
+
const importNudge = buildImportNudgeLine(data.import);
|
|
811
|
+
if (importNudge) contextText = `${contextText}\n\n${importNudge}`;
|
|
800
812
|
const wrapped = wrapForInjection(contextText);
|
|
801
813
|
return { appendSystemContext: wrapped };
|
|
802
814
|
}
|
|
@@ -909,6 +921,7 @@ If setting up MemoryRouter for another agent:
|
|
|
909
921
|
memory_tokens?: number;
|
|
910
922
|
metrics?: Record<string, unknown>;
|
|
911
923
|
reflection?: ReflectionDebt;
|
|
924
|
+
import?: ImportSuggestedState;
|
|
912
925
|
};
|
|
913
926
|
|
|
914
927
|
if (data.context) {
|
|
@@ -918,6 +931,8 @@ If setting up MemoryRouter for another agent:
|
|
|
918
931
|
let contextText = data.context;
|
|
919
932
|
const nudge = buildDebtNudgeLine(data.reflection);
|
|
920
933
|
if (nudge) contextText = `${contextText}\n\n${nudge}`;
|
|
934
|
+
const importNudge = buildImportNudgeLine(data.import);
|
|
935
|
+
if (importNudge) contextText = `${contextText}\n\n${importNudge}`;
|
|
921
936
|
const wrapped = wrapForInjection(contextText);
|
|
922
937
|
return { appendSystemContext: wrapped };
|
|
923
938
|
}
|
|
@@ -1650,6 +1665,38 @@ If setting up MemoryRouter for another agent:
|
|
|
1650
1665
|
}
|
|
1651
1666
|
});
|
|
1652
1667
|
|
|
1668
|
+
mr.command("import")
|
|
1669
|
+
.description("Import historical OpenClaw conversations into your vault (agent-driven extraction + CLI transport)")
|
|
1670
|
+
.option("--instructions", "Fetch and print the server's extraction prompt for the host agent to execute")
|
|
1671
|
+
.option("--file <path>", "Upload a JSONL extract: preview, price quote, explicit approval, idempotent batches, resume")
|
|
1672
|
+
.option("--dismiss", "Permanently silence the import suggestion for this vault")
|
|
1673
|
+
.option("--yes", "Skip the interactive approval prompt (required for non-interactive --file runs)")
|
|
1674
|
+
.option("--key <key>", "Override memory key (for subagent vaults)")
|
|
1675
|
+
.action(async (opts: { instructions?: boolean; file?: string; dismiss?: boolean; yes?: boolean; key?: string }) => {
|
|
1676
|
+
const effectiveKey = opts.key || process.env.MEMORYROUTER_API_KEY || memoryKey;
|
|
1677
|
+
if (!effectiveKey) { console.error("Not configured. Run: openclaw mr <key> or pass --key <key>"); return; }
|
|
1678
|
+
const modes = [Boolean(opts.instructions), opts.file !== undefined, Boolean(opts.dismiss)].filter(Boolean).length;
|
|
1679
|
+
if (modes > 1) { console.error("Choose one of --instructions, --file <path>, or --dismiss"); return; }
|
|
1680
|
+
try {
|
|
1681
|
+
const transport = await import("./import-transport.js");
|
|
1682
|
+
if (opts.dismiss) {
|
|
1683
|
+
await transport.runDismiss({ endpoint, key: effectiveKey });
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (opts.file) {
|
|
1687
|
+
await transport.runImportFile({ endpoint, key: effectiveKey, file: opts.file, yes: Boolean(opts.yes) });
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
await transport.runInstructions({ endpoint, key: effectiveKey });
|
|
1691
|
+
console.log("");
|
|
1692
|
+
console.log("Execute the prompt above with your agent, then upload the extract:");
|
|
1693
|
+
console.log(" openclaw mr import --file <path>");
|
|
1694
|
+
} catch (err) {
|
|
1695
|
+
console.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1696
|
+
process.exitCode = 1;
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1653
1700
|
mr.command("reflect")
|
|
1654
1701
|
.description("Show reflection debt status (consolidation runs via the agent's memory_reflect tool)")
|
|
1655
1702
|
.option("--key <key>", "Override memory key (for subagent vaults)")
|
package/memoryrouter-api.js
CHANGED
|
@@ -115,6 +115,57 @@ export async function deleteMemories(endpoint, key, embeddings) {
|
|
|
115
115
|
headers: { Authorization: `Bearer ${key}` },
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
|
+
// ── Historical import wrappers (agent-driven import) ────────────────────────
|
|
119
|
+
// The server owns the extraction prompt; the CLI owns transport. The imports
|
|
120
|
+
// API is fully idempotent: create/approve/batches are safe to re-run.
|
|
121
|
+
export async function importInstructions(endpoint, key, platform, outputPath) {
|
|
122
|
+
const query = `platform=${encodeURIComponent(platform)}` + (outputPath ? `&output_path=${encodeURIComponent(outputPath)}` : "");
|
|
123
|
+
return fetch(`${endpoint}/v1/memory/imports/instructions?${query}`, {
|
|
124
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
export async function importCreate(endpoint, key, body) {
|
|
128
|
+
return fetch(`${endpoint}/v1/memory/imports`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { ...JSON_HEADERS, Authorization: `Bearer ${key}` },
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
export async function importApprove(endpoint, key, importId, approvalDigest) {
|
|
135
|
+
return fetch(`${endpoint}/v1/memory/imports/${encodeURIComponent(importId)}/approve`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { ...JSON_HEADERS, Authorization: `Bearer ${key}` },
|
|
138
|
+
body: JSON.stringify({ approval_digest: approvalDigest }),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
export async function importBatch(endpoint, key, importId, batchIndex, jsonl, idempotencyKey) {
|
|
142
|
+
return fetch(`${endpoint}/v1/memory/imports/${encodeURIComponent(importId)}/batches/${batchIndex}`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
Authorization: `Bearer ${key}`,
|
|
146
|
+
"Content-Type": "application/x-ndjson",
|
|
147
|
+
"Idempotency-Key": idempotencyKey,
|
|
148
|
+
},
|
|
149
|
+
body: jsonl,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export async function importStatus(endpoint, key, importId) {
|
|
153
|
+
return fetch(`${endpoint}/v1/memory/imports/${encodeURIComponent(importId)}`, {
|
|
154
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
export async function importReceipt(endpoint, key, importId) {
|
|
158
|
+
return fetch(`${endpoint}/v1/memory/imports/${encodeURIComponent(importId)}/receipt`, {
|
|
159
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
export async function importDismiss(endpoint, key, platform) {
|
|
163
|
+
return fetch(`${endpoint}/v1/memory/imports/dismiss-suggestion`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { ...JSON_HEADERS, Authorization: `Bearer ${key}` },
|
|
166
|
+
body: JSON.stringify({ platform }),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
118
169
|
export async function warmupMemory(endpoint, key, embeddings) {
|
|
119
170
|
return fetch(`${endpoint}/v1/memory/warmup`, {
|
|
120
171
|
method: "POST",
|
package/memoryrouter-api.ts
CHANGED
|
@@ -132,6 +132,65 @@ export async function deleteMemories(endpoint: string, key: string, embeddings?:
|
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// ── Historical import wrappers (agent-driven import) ────────────────────────
|
|
136
|
+
// The server owns the extraction prompt; the CLI owns transport. The imports
|
|
137
|
+
// API is fully idempotent: create/approve/batches are safe to re-run.
|
|
138
|
+
|
|
139
|
+
export async function importInstructions(endpoint: string, key: string, platform: string, outputPath?: string): Promise<Response> {
|
|
140
|
+
const query = `platform=${encodeURIComponent(platform)}` + (outputPath ? `&output_path=${encodeURIComponent(outputPath)}` : "");
|
|
141
|
+
return fetch(`${endpoint}/v1/memory/imports/instructions?${query}`, {
|
|
142
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function importCreate(endpoint: string, key: string, body: Record<string, unknown>): Promise<Response> {
|
|
147
|
+
return fetch(`${endpoint}/v1/memory/imports`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { ...JSON_HEADERS, Authorization: `Bearer ${key}` },
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function importApprove(endpoint: string, key: string, importId: string, approvalDigest: string): Promise<Response> {
|
|
155
|
+
return fetch(`${endpoint}/v1/memory/imports/${encodeURIComponent(importId)}/approve`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { ...JSON_HEADERS, Authorization: `Bearer ${key}` },
|
|
158
|
+
body: JSON.stringify({ approval_digest: approvalDigest }),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function importBatch(endpoint: string, key: string, importId: string, batchIndex: number, jsonl: string, idempotencyKey: string): Promise<Response> {
|
|
163
|
+
return fetch(`${endpoint}/v1/memory/imports/${encodeURIComponent(importId)}/batches/${batchIndex}`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: {
|
|
166
|
+
Authorization: `Bearer ${key}`,
|
|
167
|
+
"Content-Type": "application/x-ndjson",
|
|
168
|
+
"Idempotency-Key": idempotencyKey,
|
|
169
|
+
},
|
|
170
|
+
body: jsonl,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function importStatus(endpoint: string, key: string, importId: string): Promise<Response> {
|
|
175
|
+
return fetch(`${endpoint}/v1/memory/imports/${encodeURIComponent(importId)}`, {
|
|
176
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function importReceipt(endpoint: string, key: string, importId: string): Promise<Response> {
|
|
181
|
+
return fetch(`${endpoint}/v1/memory/imports/${encodeURIComponent(importId)}/receipt`, {
|
|
182
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function importDismiss(endpoint: string, key: string, platform: string): Promise<Response> {
|
|
187
|
+
return fetch(`${endpoint}/v1/memory/imports/dismiss-suggestion`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { ...JSON_HEADERS, Authorization: `Bearer ${key}` },
|
|
190
|
+
body: JSON.stringify({ platform }),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
135
194
|
export async function warmupMemory(endpoint: string, key: string, embeddings?: string): Promise<Response> {
|
|
136
195
|
return fetch(`${endpoint}/v1/memory/warmup`, {
|
|
137
196
|
method: "POST",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mr-memory",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"description": "Persistent memory for OpenClaw, powered by MemoryRouter. Relay-based AI agent memory across sessions, compaction, and model changes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"upload.ts",
|
|
12
12
|
"memoryrouter-api.js",
|
|
13
13
|
"memoryrouter-api.ts",
|
|
14
|
+
"import-transport.js",
|
|
15
|
+
"import-transport.ts",
|
|
14
16
|
"upload-api.js",
|
|
15
17
|
"upload-api.ts",
|
|
16
18
|
"openclaw.plugin.json"
|