memoryrouter-codex 1.2.1 → 1.3.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 +14 -2
- package/bin/cli.cjs +24 -1
- package/package.json +1 -1
- package/runtime/api.cjs +62 -1
- package/runtime/hook.cjs +16 -8
- package/runtime/import-identity.cjs +113 -0
- package/runtime/import.cjs +421 -0
- package/runtime/memory.cjs +1 -0
package/README.md
CHANGED
|
@@ -128,9 +128,21 @@ Automatic memories use an envelope containing project handle, stable memory hand
|
|
|
128
128
|
|
|
129
129
|
Set `retrievalScope` to `"vault"` in the private config only if you intentionally want unscoped automatic recall.
|
|
130
130
|
|
|
131
|
-
## Historical import
|
|
131
|
+
## Historical import (agent-driven)
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
Prior Codex conversations on this machine can be imported with a strict division of labor: the server owns the extraction prompt (versioned, fetched at import time), Codex itself owns discovery/parsing of its own session files, and this package owns transport. The agent never uploads; this CLI never parses history.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
memoryrouter-codex import --instructions # print the server's extraction prompt
|
|
137
|
+
memoryrouter-codex import --instructions --run # or run it headless via codex exec
|
|
138
|
+
memoryrouter-codex import --file extract.jsonl # validate, preview, quote, approve, upload
|
|
139
|
+
memoryrouter-codex import --file extract.jsonl --yes # non-interactive approval
|
|
140
|
+
memoryrouter-codex import --dismiss # permanently silence the import nudge
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Guarantees: the extract is validated locally (four fields per record, `codex:` 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 API 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`.
|
|
144
|
+
|
|
145
|
+
ChatGPT archive imports remain a separate flow (the `memoryrouter` ChatGPT CLI). Remote MCP OAuth connection by itself never imports history.
|
|
134
146
|
|
|
135
147
|
## Handles, deduplication, retries, and forgetting
|
|
136
148
|
|
package/bin/cli.cjs
CHANGED
|
@@ -23,7 +23,7 @@ function parseArgs(argv) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function usage() {
|
|
26
|
-
return `memoryrouter-codex ${INTEGRATION_VERSION}\n\nUsage:\n memoryrouter-codex init [--scope user|project] [--auth oauth|api-key]\n [--project-dir PATH] [--api-key-stdin]\n [--no-hooks] [--no-agents]\n memoryrouter-codex doctor [--scope user|project] [--project-dir PATH] [--offline]\n memoryrouter-codex uninstall [--scope user|project] [--project-dir PATH]\n memoryrouter-codex reflect [--tier 1|2] [--batches N|all] [--max-tokens N] [--dry-run]\n memoryrouter-codex mcp-server\n\nReflect:\n Consolidates unconsolidated memories into reflections using Codex itself as\n the model (codex exec, you pay your own inference). Server-authored\n instructions; resumable; no local state. --dry-run prints the checkout and\n lets the lease expire without committing.\n\nAuth modes:\n oauth Current remote MCP at ${'https://mcp.memoryrouter.ai/mcp'} (default).\n Run \`codex mcp login memoryrouter\` after init. Hooks additionally\n require MEMORYROUTER_API_KEY or a key saved with --api-key-stdin.\n api-key Local stdio MCP fallback plus automatic hooks. The key is read only\n from the environment/private config, never from project files.\n\nSecure key setup:\n printf '%s' "$MEMORYROUTER_API_KEY" | memoryrouter-codex init --api-key-stdin\n`;
|
|
26
|
+
return `memoryrouter-codex ${INTEGRATION_VERSION}\n\nUsage:\n memoryrouter-codex init [--scope user|project] [--auth oauth|api-key]\n [--project-dir PATH] [--api-key-stdin]\n [--no-hooks] [--no-agents]\n memoryrouter-codex doctor [--scope user|project] [--project-dir PATH] [--offline]\n memoryrouter-codex uninstall [--scope user|project] [--project-dir PATH]\n memoryrouter-codex reflect [--tier 1|2] [--batches N|all] [--max-tokens N] [--dry-run]\n memoryrouter-codex import [--instructions [--run]] [--file PATH] [--dismiss] [--yes]\n memoryrouter-codex mcp-server\n\nImport:\n Import historical Codex conversations into your vault. --instructions\n fetches the server's extraction prompt for the host agent to execute\n (--run spawns it via codex exec); --file uploads a JSONL extract with a\n preview, price quote, explicit approval (--yes for non-interactive),\n idempotent batches, and resume; --dismiss silences the import nudge\n permanently. Re-running --file is always safe: committed batches replay\n for free and duplicates are never re-billed.\n\nReflect:\n Consolidates unconsolidated memories into reflections using Codex itself as\n the model (codex exec, you pay your own inference). Server-authored\n instructions; resumable; no local state. --dry-run prints the checkout and\n lets the lease expire without committing.\n\nAuth modes:\n oauth Current remote MCP at ${'https://mcp.memoryrouter.ai/mcp'} (default).\n Run \`codex mcp login memoryrouter\` after init. Hooks additionally\n require MEMORYROUTER_API_KEY or a key saved with --api-key-stdin.\n api-key Local stdio MCP fallback plus automatic hooks. The key is read only\n from the environment/private config, never from project files.\n\nSecure key setup:\n printf '%s' "$MEMORYROUTER_API_KEY" | memoryrouter-codex init --api-key-stdin\n`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function optionsFromArgs(args) {
|
|
@@ -77,6 +77,29 @@ async function main() {
|
|
|
77
77
|
process.exitCode = result.ok ? 0 : 1;
|
|
78
78
|
return;
|
|
79
79
|
}
|
|
80
|
+
if (command === 'import') {
|
|
81
|
+
const { loadConfig, getApiKey } = require('../runtime/config.cjs');
|
|
82
|
+
const { runInstructions, runDismiss, runImportFile } = require('../runtime/import.cjs');
|
|
83
|
+
const config = loadConfig(process.env);
|
|
84
|
+
const key = getApiKey(process.env, config);
|
|
85
|
+
if (!key) throw new Error('MemoryRouter API key is not configured. Run `memoryrouter-codex init` or set MEMORYROUTER_API_KEY.');
|
|
86
|
+
const modes = [Boolean(args.instructions), args.file !== undefined, Boolean(args.dismiss)].filter(Boolean).length;
|
|
87
|
+
if (modes > 1) throw new Error('Choose one of --instructions, --file <path>, or --dismiss');
|
|
88
|
+
if (args.dismiss) {
|
|
89
|
+
await runDismiss({ key });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (args.file) {
|
|
93
|
+
await runImportFile({ key, file: String(args.file), yes: Boolean(args.yes) });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await runInstructions({ key, spawn: Boolean(args.run) });
|
|
97
|
+
if (!args.run) {
|
|
98
|
+
process.stdout.write('\nExecute the prompt above with your agent (or re-run with --instructions --run to spawn codex exec).\n');
|
|
99
|
+
process.stdout.write('Then upload the extract: memoryrouter-codex import --file <path>\n');
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
80
103
|
const options = optionsFromArgs(args);
|
|
81
104
|
if (command === 'init') {
|
|
82
105
|
const result = init(options);
|
package/package.json
CHANGED
package/runtime/api.cjs
CHANGED
|
@@ -8,6 +8,7 @@ async function request(method, endpoint, options = {}) {
|
|
|
8
8
|
const {
|
|
9
9
|
key,
|
|
10
10
|
body,
|
|
11
|
+
rawBody,
|
|
11
12
|
headers = {},
|
|
12
13
|
timeoutMs = 7000,
|
|
13
14
|
retries = method === 'GET' || endpoint.endsWith('/search') ? 2 : 0,
|
|
@@ -26,7 +27,7 @@ async function request(method, endpoint, options = {}) {
|
|
|
26
27
|
'User-Agent': `memoryrouter-codex/${INTEGRATION_VERSION}`,
|
|
27
28
|
...headers,
|
|
28
29
|
},
|
|
29
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
30
|
+
body: rawBody !== undefined ? rawBody : body === undefined ? undefined : JSON.stringify(body),
|
|
30
31
|
signal: controller.signal,
|
|
31
32
|
});
|
|
32
33
|
let data = null;
|
|
@@ -146,6 +147,59 @@ function debtProbe(key, options = {}) {
|
|
|
146
147
|
}, options);
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
// Historical import wrappers. The server owns the extraction prompt; the CLI
|
|
151
|
+
// owns transport. Create/approve/batches run with retries: 0 at this layer;
|
|
152
|
+
// the import loop applies its own bounded backoff around batch uploads.
|
|
153
|
+
|
|
154
|
+
function importInstructions(key, platform, options = {}) {
|
|
155
|
+
const query = `platform=${encodeURIComponent(platform)}`
|
|
156
|
+
+ (options.outputPath ? `&output_path=${encodeURIComponent(options.outputPath)}` : '');
|
|
157
|
+
return request('GET', `/v1/memory/imports/instructions?${query}`, {
|
|
158
|
+
...options, key, timeoutMs: options.timeoutMs || 15000, retries: 1,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function importCreate(key, body, options = {}) {
|
|
163
|
+
return request('POST', '/v1/memory/imports', {
|
|
164
|
+
...options, key, body, timeoutMs: options.timeoutMs || 20000, retries: 0,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function importApprove(key, importId, approvalDigest, options = {}) {
|
|
169
|
+
return request('POST', `/v1/memory/imports/${encodeURIComponent(importId)}/approve`, {
|
|
170
|
+
...options, key, body: { approval_digest: approvalDigest }, timeoutMs: options.timeoutMs || 20000, retries: 0,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function importBatch(key, importId, batchIndex, jsonl, idempotencyKey, options = {}) {
|
|
175
|
+
return request('POST', `/v1/memory/imports/${encodeURIComponent(importId)}/batches/${batchIndex}`, {
|
|
176
|
+
...options,
|
|
177
|
+
key,
|
|
178
|
+
rawBody: jsonl,
|
|
179
|
+
headers: { 'Content-Type': 'application/x-ndjson', 'Idempotency-Key': idempotencyKey },
|
|
180
|
+
timeoutMs: options.timeoutMs || 120000,
|
|
181
|
+
retries: 0,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function importStatus(key, importId, options = {}) {
|
|
186
|
+
return request('GET', `/v1/memory/imports/${encodeURIComponent(importId)}`, {
|
|
187
|
+
...options, key, timeoutMs: options.timeoutMs || 15000, retries: 1,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function importReceipt(key, importId, options = {}) {
|
|
192
|
+
return request('GET', `/v1/memory/imports/${encodeURIComponent(importId)}/receipt`, {
|
|
193
|
+
...options, key, timeoutMs: options.timeoutMs || 15000, retries: 1,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function importDismiss(key, platform, options = {}) {
|
|
198
|
+
return request('POST', '/v1/memory/imports/dismiss-suggestion', {
|
|
199
|
+
...options, key, body: { platform }, timeoutMs: options.timeoutMs || 10000, retries: 0,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
149
203
|
module.exports = {
|
|
150
204
|
sleep,
|
|
151
205
|
request,
|
|
@@ -160,4 +214,11 @@ module.exports = {
|
|
|
160
214
|
deleteIds,
|
|
161
215
|
dateSearch,
|
|
162
216
|
debtProbe,
|
|
217
|
+
importInstructions,
|
|
218
|
+
importCreate,
|
|
219
|
+
importApprove,
|
|
220
|
+
importBatch,
|
|
221
|
+
importStatus,
|
|
222
|
+
importReceipt,
|
|
223
|
+
importDismiss,
|
|
163
224
|
};
|
package/runtime/hook.cjs
CHANGED
|
@@ -46,13 +46,21 @@ function estimateDebtTokens(reflection) {
|
|
|
46
46
|
// only when a context is being emitted anyway, and counted inside the
|
|
47
47
|
// existing context char cap so it can never push memory content out.
|
|
48
48
|
// Fires on every run with a non-null suggestion.
|
|
49
|
-
function maybeAppendNudge(context, reflection, sessionId, hookContext) {
|
|
50
|
-
if (!context || !
|
|
51
|
-
const tokens = estimateDebtTokens(reflection);
|
|
52
|
-
const nudge = `\n\n[MemoryRouter] Consolidation suggested: ${Math.round(tokens / 1000)}k unconsolidated tokens. Run: memoryrouter-codex reflect`;
|
|
49
|
+
function maybeAppendNudge(context, reflection, sessionId, hookContext, importState) {
|
|
50
|
+
if (!context || !sessionId) return context;
|
|
53
51
|
const maxChars = Number(hookContext.config?.contextChars || 12000);
|
|
54
|
-
if (
|
|
55
|
-
|
|
52
|
+
if (reflection && reflection.suggestion) {
|
|
53
|
+
const tokens = estimateDebtTokens(reflection);
|
|
54
|
+
const nudge = `\n\n[MemoryRouter] Consolidation suggested: ${Math.round(tokens / 1000)}k unconsolidated tokens. Run: memoryrouter-codex reflect`;
|
|
55
|
+
if (context.length + nudge.length <= maxChars) context += nudge;
|
|
56
|
+
}
|
|
57
|
+
// Import nudge: same server-owned contract (import.suggested on the search
|
|
58
|
+
// response), same cap rules, no rate limit; clears on import or dismiss.
|
|
59
|
+
if (importState && importState.suggested) {
|
|
60
|
+
const nudge = '\n\n[MemoryRouter] Historical import suggested: this machine has local conversation history not yet in your vault. Run: memoryrouter-codex import --instructions';
|
|
61
|
+
if (context.length + nudge.length <= maxChars) context += nudge;
|
|
62
|
+
}
|
|
63
|
+
return context;
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
function turnContent(input, state) {
|
|
@@ -87,7 +95,7 @@ async function handleInject(input, context) {
|
|
|
87
95
|
memories: result.memories?.length || 0,
|
|
88
96
|
status: result.status,
|
|
89
97
|
}, context.env);
|
|
90
|
-
emitContext('UserPromptSubmit', maybeAppendNudge(result.context, result.reflection, input.session_id, context));
|
|
98
|
+
emitContext('UserPromptSubmit', maybeAppendNudge(result.context, result.reflection, input.session_id, context, result.importState));
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
async function handleSessionStart(input, context) {
|
|
@@ -102,7 +110,7 @@ async function handleSessionStart(input, context) {
|
|
|
102
110
|
project: context.project.handle,
|
|
103
111
|
memories: result.memories?.length || 0,
|
|
104
112
|
}, context.env);
|
|
105
|
-
emitContext('SessionStart', maybeAppendNudge(result.context, result.reflection, input.session_id, context));
|
|
113
|
+
emitContext('SessionStart', maybeAppendNudge(result.context, result.reflection, input.session_id, context, result.importState));
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
async function captureTurn(input, context, partial = false) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Client-side mirror of the server import identity math
|
|
3
|
+
// (workers/src/services/import-identity.ts). Byte-for-byte: the server
|
|
4
|
+
// recomputes every hash from the wire fields and rejects any mismatch, so
|
|
5
|
+
// these formulas MUST stay in lockstep with the server. If either side
|
|
6
|
+
// changes, change the other and bump PARSER_VERSION.
|
|
7
|
+
//
|
|
8
|
+
// IMPORTANT: none of these functions ever incorporate the raw Memory Key.
|
|
9
|
+
// The destination is represented only by an opaque fingerprint.
|
|
10
|
+
|
|
11
|
+
const { createHash } = require('crypto');
|
|
12
|
+
|
|
13
|
+
const PARSER_VERSION = 1;
|
|
14
|
+
const CHUNK_VERSION = 1;
|
|
15
|
+
const SOURCE_PLATFORM = 'codex';
|
|
16
|
+
|
|
17
|
+
function sha256hex(input) {
|
|
18
|
+
return createHash('sha256').update(input, 'utf8').digest('hex');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sortKeys(value) {
|
|
22
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
23
|
+
if (value && typeof value === 'object') {
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key]);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Canonical JSON: recursively key-sorted. Identical to the server. */
|
|
32
|
+
function canonicalJson(value) {
|
|
33
|
+
return JSON.stringify(sortKeys(value));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Deterministic text normalization. Identical to the server normalizeText. */
|
|
37
|
+
function normalizeText(input) {
|
|
38
|
+
return input.normalize('NFC').replace(/\r\n?/g, '\n').trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Opaque, non-reversible destination fingerprint. */
|
|
42
|
+
function vaultFingerprint(memoryKey) {
|
|
43
|
+
return `mrvault_${sha256hex(memoryKey).slice(0, 16)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function unescapeIdPart(s) {
|
|
47
|
+
return s.replace(/%3A/g, ':').replace(/%25/g, '%');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Recover (platform, conversationId, messageId, parserVersion) or null. */
|
|
51
|
+
function parseExternalId(id) {
|
|
52
|
+
const parts = id.split(':');
|
|
53
|
+
if (parts.length !== 4) return null;
|
|
54
|
+
const [platform, conv, msg, ver] = parts;
|
|
55
|
+
if (!/^v\d+$/.test(ver)) return null;
|
|
56
|
+
return {
|
|
57
|
+
platform,
|
|
58
|
+
conversationId: unescapeIdPart(conv),
|
|
59
|
+
messageId: unescapeIdPart(msg),
|
|
60
|
+
parserVersion: parseInt(ver.slice(1), 10),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Content-bound record hash. Mirrors the server computeRecordHash exactly. */
|
|
65
|
+
function recordHash(fields) {
|
|
66
|
+
return sha256hex(
|
|
67
|
+
canonicalJson({
|
|
68
|
+
conversationId: fields.conversationId,
|
|
69
|
+
messageId: fields.messageId,
|
|
70
|
+
role: fields.role,
|
|
71
|
+
text: normalizeText(fields.text),
|
|
72
|
+
timestamp: fields.timestamp,
|
|
73
|
+
v: PARSER_VERSION,
|
|
74
|
+
}),
|
|
75
|
+
).slice(0, 32);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Per-conversation content hash. Mirrors the chatgpt CLI conversationContentHash. */
|
|
79
|
+
function conversationContentHash(fields) {
|
|
80
|
+
return sha256hex(canonicalJson(fields)).slice(0, 32);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Content identity of the whole extract, independent of read order. */
|
|
84
|
+
function archiveContentId(conversationContentHashes) {
|
|
85
|
+
const canonical = [...conversationContentHashes].sort().join('\n');
|
|
86
|
+
return `arc_${sha256hex(`v${PARSER_VERSION}\n${canonical}`).slice(0, 24)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Deterministic import id: same extract to the same vault = same import. */
|
|
90
|
+
function deriveImportId(archiveId, fingerprint) {
|
|
91
|
+
return `imp_${sha256hex(`${archiveId}|${fingerprint}|p${PARSER_VERSION}|c${CHUNK_VERSION}`).slice(0, 24)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Client-computed preview digest; the server recomputes and must agree. */
|
|
95
|
+
function previewDigest(preview) {
|
|
96
|
+
return `pvd_${sha256hex(canonicalJson(preview)).slice(0, 32)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
PARSER_VERSION,
|
|
101
|
+
CHUNK_VERSION,
|
|
102
|
+
SOURCE_PLATFORM,
|
|
103
|
+
sha256hex,
|
|
104
|
+
canonicalJson,
|
|
105
|
+
normalizeText,
|
|
106
|
+
vaultFingerprint,
|
|
107
|
+
parseExternalId,
|
|
108
|
+
recordHash,
|
|
109
|
+
conversationContentHash,
|
|
110
|
+
archiveContentId,
|
|
111
|
+
deriveImportId,
|
|
112
|
+
previewDigest,
|
|
113
|
+
};
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Agent-driven historical import: the transport half.
|
|
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 the server), create/approve, idempotent batch upload
|
|
9
|
+
// with resume, and the receipt. The agent never touches HTTP for upload and
|
|
10
|
+
// this module never parses conversation history.
|
|
11
|
+
//
|
|
12
|
+
// The Memory Key never appears in the prompt or the extract file; it rides
|
|
13
|
+
// only in the Authorization header via lib/api.js.
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
const readline = require('readline');
|
|
18
|
+
const defaultApi = require('./api.cjs');
|
|
19
|
+
const ids = require('./import-identity.cjs');
|
|
20
|
+
|
|
21
|
+
const PLATFORM = 'codex';
|
|
22
|
+
const BATCH_SIZE = 2000; // server maximum per batch
|
|
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
|
+
|
|
28
|
+
function sleep(ms) {
|
|
29
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fmtDate(ms) {
|
|
33
|
+
if (!Number.isFinite(ms) || ms <= 0) return 'unknown';
|
|
34
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fmtTokens(n) {
|
|
38
|
+
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
39
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
|
40
|
+
return String(n);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function apiErrorDetail(res) {
|
|
44
|
+
const data = res && res.data;
|
|
45
|
+
if (data && typeof data === 'object') {
|
|
46
|
+
const parts = [data.error, data.message].filter(Boolean);
|
|
47
|
+
if (parts.length) return parts.join(': ');
|
|
48
|
+
}
|
|
49
|
+
return `HTTP ${res && res.status}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read + validate the JSONL extract. Four fields per record, platform-prefixed
|
|
54
|
+
* external_id, numeric epoch-ms timestamps, role user/assistant, non-empty
|
|
55
|
+
* content. Returns { records } or { error } (first offending line reported).
|
|
56
|
+
*/
|
|
57
|
+
function readExtract(filePath, platform = PLATFORM) {
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { error: `could not read ${filePath}: ${err.message}` };
|
|
63
|
+
}
|
|
64
|
+
const records = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
const lines = raw.split('\n');
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const line = lines[i].trim();
|
|
69
|
+
if (!line) continue;
|
|
70
|
+
const lineNo = i + 1;
|
|
71
|
+
let p;
|
|
72
|
+
try {
|
|
73
|
+
p = JSON.parse(line);
|
|
74
|
+
} catch {
|
|
75
|
+
return { error: `line ${lineNo}: not valid JSON` };
|
|
76
|
+
}
|
|
77
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return { error: `line ${lineNo}: record is not an object` };
|
|
78
|
+
const extras = Object.keys(p).filter((k) => !['external_id', 'content', 'timestamp', 'role'].includes(k));
|
|
79
|
+
if (extras.length) return { error: `line ${lineNo}: unexpected fields: ${extras.join(', ')}` };
|
|
80
|
+
if (typeof p.external_id !== 'string' || !p.external_id) return { error: `line ${lineNo}: external_id must be a string` };
|
|
81
|
+
const parsed = ids.parseExternalId(p.external_id);
|
|
82
|
+
if (!parsed) return { error: `line ${lineNo}: malformed external_id (expected ${platform}:<conversation>:<message>:v${ids.PARSER_VERSION})` };
|
|
83
|
+
if (parsed.platform !== platform) return { error: `line ${lineNo}: external_id platform must be ${platform}, got ${parsed.platform}` };
|
|
84
|
+
if (parsed.parserVersion !== ids.PARSER_VERSION) return { error: `line ${lineNo}: external_id version must be v${ids.PARSER_VERSION}` };
|
|
85
|
+
if (typeof p.content !== 'string' || p.content.length === 0) return { error: `line ${lineNo}: content must be a non-empty string` };
|
|
86
|
+
if (p.content.length > MAX_CONTENT_CHARS) return { error: `line ${lineNo}: content exceeds ${MAX_CONTENT_CHARS} chars` };
|
|
87
|
+
if (typeof p.timestamp !== 'number' || !Number.isFinite(p.timestamp) || p.timestamp <= 0) {
|
|
88
|
+
return { error: `line ${lineNo}: timestamp must be a positive epoch-milliseconds number` };
|
|
89
|
+
}
|
|
90
|
+
if (p.timestamp < 1000000000000) return { error: `line ${lineNo}: timestamp looks like seconds, not milliseconds (${p.timestamp})` };
|
|
91
|
+
if (p.role !== 'user' && p.role !== 'assistant') return { error: `line ${lineNo}: role must be "user" or "assistant"` };
|
|
92
|
+
if (seen.has(p.external_id)) return { error: `line ${lineNo}: duplicate external_id ${p.external_id}` };
|
|
93
|
+
seen.add(p.external_id);
|
|
94
|
+
records.push({
|
|
95
|
+
externalId: p.external_id,
|
|
96
|
+
conversationId: parsed.conversationId,
|
|
97
|
+
messageId: parsed.messageId,
|
|
98
|
+
content: p.content,
|
|
99
|
+
role: p.role,
|
|
100
|
+
timestamp: p.timestamp,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (records.length === 0) return { error: 'the extract file contains no records' };
|
|
104
|
+
return { records };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Deterministic identity for the whole extract: per-record hashes, sorted
|
|
109
|
+
* per-conversation content hashes, archive id, and the vault-bound import id.
|
|
110
|
+
* Same math as the chatgpt CLI (cli/src/chatgpt/ids.ts), so re-running the
|
|
111
|
+
* command replays the same import and committed batches return for free.
|
|
112
|
+
*/
|
|
113
|
+
function computeIdentity(records, memoryKey, platform = PLATFORM) {
|
|
114
|
+
const byConversation = new Map();
|
|
115
|
+
for (const r of records) {
|
|
116
|
+
r.recordSha256 = ids.recordHash({
|
|
117
|
+
conversationId: r.conversationId,
|
|
118
|
+
messageId: r.messageId,
|
|
119
|
+
role: r.role,
|
|
120
|
+
text: r.content,
|
|
121
|
+
timestamp: r.timestamp,
|
|
122
|
+
});
|
|
123
|
+
if (!byConversation.has(r.conversationId)) byConversation.set(r.conversationId, []);
|
|
124
|
+
byConversation.get(r.conversationId).push(r);
|
|
125
|
+
}
|
|
126
|
+
const convHashes = [];
|
|
127
|
+
for (const [conversationId, convRecords] of byConversation) {
|
|
128
|
+
convHashes.push(ids.conversationContentHash({
|
|
129
|
+
conversationId,
|
|
130
|
+
canonicalPolicy: `agent-extract-${platform}`,
|
|
131
|
+
canonicalLeaf: null,
|
|
132
|
+
records: convRecords.map((r) => ({
|
|
133
|
+
externalId: r.externalId,
|
|
134
|
+
recordHash: r.recordSha256,
|
|
135
|
+
role: r.role,
|
|
136
|
+
timestamp: r.timestamp,
|
|
137
|
+
})),
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
const archiveId = ids.archiveContentId(convHashes);
|
|
141
|
+
const fingerprint = ids.vaultFingerprint(memoryKey);
|
|
142
|
+
const importId = ids.deriveImportId(archiveId, fingerprint);
|
|
143
|
+
return { archiveId, fingerprint, importId, conversations: byConversation.size };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function estimateTokens(records) {
|
|
147
|
+
let total = 0;
|
|
148
|
+
for (const r of records) total += Math.ceil(r.content.length / 4);
|
|
149
|
+
return total;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildPreviewSummary(records) {
|
|
153
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
154
|
+
let latest = 0;
|
|
155
|
+
let user = 0;
|
|
156
|
+
for (const r of records) {
|
|
157
|
+
if (r.role === 'user') user++;
|
|
158
|
+
if (r.timestamp < earliest) earliest = r.timestamp;
|
|
159
|
+
if (r.timestamp > latest) latest = r.timestamp;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
records: records.length,
|
|
163
|
+
userRecords: user,
|
|
164
|
+
assistantRecords: records.length - user,
|
|
165
|
+
earliestMs: Number.isFinite(earliest) ? earliest : 0,
|
|
166
|
+
latestMs: latest,
|
|
167
|
+
estimatedTokens: estimateTokens(records),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function toWireRecord(r, platform = PLATFORM) {
|
|
172
|
+
return JSON.stringify({
|
|
173
|
+
external_id: r.externalId,
|
|
174
|
+
record_sha256: r.recordSha256,
|
|
175
|
+
content: r.content,
|
|
176
|
+
role: r.role,
|
|
177
|
+
timestamp: r.timestamp,
|
|
178
|
+
source_platform: platform,
|
|
179
|
+
parser_version: ids.PARSER_VERSION,
|
|
180
|
+
chunk_version: ids.CHUNK_VERSION,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function chunkArray(arr, size) {
|
|
185
|
+
const out = [];
|
|
186
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ask(question) {
|
|
191
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
192
|
+
return new Promise((resolve) => rl.question(question, (answer) => {
|
|
193
|
+
rl.close();
|
|
194
|
+
resolve(answer.trim().toLowerCase());
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch and print (or execute) the server-side extraction prompt. With
|
|
200
|
+
* spawn=true the prompt runs through the local Codex CLI (codex exec),
|
|
201
|
+
* following the reflect verb's spawn pattern; the model does the extraction
|
|
202
|
+
* work and the user then runs `import --file <path>`.
|
|
203
|
+
*/
|
|
204
|
+
async function runInstructions(options = {}) {
|
|
205
|
+
const key = options.key;
|
|
206
|
+
if (!key) throw new Error('a Memory Key is required');
|
|
207
|
+
const api = options.api || defaultApi;
|
|
208
|
+
const log = options.log || ((...args) => console.log(...args));
|
|
209
|
+
const res = await api.importInstructions(key, options.platform || PLATFORM, { outputPath: options.outputPath });
|
|
210
|
+
if (!res.ok) throw new Error(`could not fetch import instructions (${apiErrorDetail(res)})`);
|
|
211
|
+
const data = res.data || {};
|
|
212
|
+
if (!data.prompt) throw new Error('instructions response carried no prompt');
|
|
213
|
+
|
|
214
|
+
if (options.spawn) {
|
|
215
|
+
const spawn = options.spawnSync || spawnSync;
|
|
216
|
+
log(`Running the extraction prompt through codex exec (import prompt v${data.import_prompt_version})...`);
|
|
217
|
+
const result = spawn('codex', ['exec', '--skip-git-repo-check', data.prompt], {
|
|
218
|
+
encoding: 'utf8',
|
|
219
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
220
|
+
timeout: 30 * 60 * 1000,
|
|
221
|
+
});
|
|
222
|
+
if (result.error) throw new Error(`could not run the codex CLI: ${result.error.message}`);
|
|
223
|
+
log(String(result.stdout || ''));
|
|
224
|
+
if (result.status !== 0) {
|
|
225
|
+
throw new Error(`codex CLI exited ${result.status}: ${String(result.stderr || '').slice(0, 400)}`);
|
|
226
|
+
}
|
|
227
|
+
log('');
|
|
228
|
+
log(`Extraction finished. Next: ${data.next_command}`);
|
|
229
|
+
return { spawned: true, version: data.import_prompt_version };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
log(data.prompt);
|
|
233
|
+
return { spawned: false, version: data.import_prompt_version };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Call the dismiss endpoint so the server-side nudge stops permanently. */
|
|
237
|
+
async function runDismiss(options = {}) {
|
|
238
|
+
const key = options.key;
|
|
239
|
+
if (!key) throw new Error('a Memory Key is required');
|
|
240
|
+
const api = options.api || defaultApi;
|
|
241
|
+
const log = options.log || ((...args) => console.log(...args));
|
|
242
|
+
const res = await api.importDismiss(key, options.platform || PLATFORM);
|
|
243
|
+
if (!res.ok) throw new Error(`dismiss failed (${apiErrorDetail(res)})`);
|
|
244
|
+
log('Import suggestion dismissed permanently for this vault.');
|
|
245
|
+
return res.data;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The transport: validate the extract, compute identity, create the import
|
|
250
|
+
* (deterministic import_id -> re-runs resume for free), show the preview +
|
|
251
|
+
* server price quote, require approval, upload batches with Idempotency-Keys
|
|
252
|
+
* and bounded backoff, then print the receipt.
|
|
253
|
+
*/
|
|
254
|
+
async function runImportFile(options = {}) {
|
|
255
|
+
const key = options.key;
|
|
256
|
+
if (!key) throw new Error('a Memory Key is required');
|
|
257
|
+
if (!options.file) throw new Error('--file <path> is required');
|
|
258
|
+
const api = options.api || defaultApi;
|
|
259
|
+
const log = options.log || ((...args) => console.log(...args));
|
|
260
|
+
const platform = options.platform || PLATFORM;
|
|
261
|
+
|
|
262
|
+
// 1. Validate locally (fail fast, never partially upload a malformed file).
|
|
263
|
+
const parsed = readExtract(options.file, platform);
|
|
264
|
+
if (parsed.error) throw new Error(`invalid extract: ${parsed.error}`);
|
|
265
|
+
const records = parsed.records;
|
|
266
|
+
const identity = computeIdentity(records, key, platform);
|
|
267
|
+
const preview = buildPreviewSummary(records);
|
|
268
|
+
|
|
269
|
+
// 2. Create / preflight (idempotent: same content + vault = same import).
|
|
270
|
+
const previewDigest = ids.previewDigest({
|
|
271
|
+
importId: identity.importId,
|
|
272
|
+
archiveId: identity.archiveId,
|
|
273
|
+
fingerprint: identity.fingerprint,
|
|
274
|
+
parserVersion: ids.PARSER_VERSION,
|
|
275
|
+
chunkVersion: ids.CHUNK_VERSION,
|
|
276
|
+
expectedRecords: preview.records,
|
|
277
|
+
expectedChunks: preview.records,
|
|
278
|
+
estimatedTokens: preview.estimatedTokens,
|
|
279
|
+
selection: null,
|
|
280
|
+
categories: [],
|
|
281
|
+
});
|
|
282
|
+
const createRes = await api.importCreate(key, {
|
|
283
|
+
import_id: identity.importId,
|
|
284
|
+
archive_content_id: identity.archiveId,
|
|
285
|
+
archive_sha256: null,
|
|
286
|
+
parser_version: ids.PARSER_VERSION,
|
|
287
|
+
chunk_version: ids.CHUNK_VERSION,
|
|
288
|
+
source_platform: platform,
|
|
289
|
+
vault_fingerprint: identity.fingerprint,
|
|
290
|
+
preview_digest: previewDigest,
|
|
291
|
+
expected_records: preview.records,
|
|
292
|
+
expected_chunks: preview.records,
|
|
293
|
+
estimated_tokens: preview.estimatedTokens,
|
|
294
|
+
selection: null,
|
|
295
|
+
categories: [],
|
|
296
|
+
});
|
|
297
|
+
if (!createRes.ok) throw new Error(`import preflight failed (${apiErrorDetail(createRes)})`);
|
|
298
|
+
const created = createRes.data || {};
|
|
299
|
+
if (created.preview_digest && created.preview_digest !== previewDigest) {
|
|
300
|
+
throw new Error('client/server preview digest mismatch (fail closed)');
|
|
301
|
+
}
|
|
302
|
+
const quote = created.cost_quote || {};
|
|
303
|
+
|
|
304
|
+
// 3. Preview + quote for the user.
|
|
305
|
+
log('');
|
|
306
|
+
log('Import preview');
|
|
307
|
+
log('----------------------------');
|
|
308
|
+
log(`Platform: ${platform}`);
|
|
309
|
+
log(`Destination vault: ${identity.fingerprint} (opaque fingerprint)`);
|
|
310
|
+
log(`Conversations: ${identity.conversations}`);
|
|
311
|
+
log(`Records: ${preview.records} (user ${preview.userRecords}, assistant ${preview.assistantRecords})`);
|
|
312
|
+
log(`Date range: ${fmtDate(preview.earliestMs)} to ${fmtDate(preview.latestMs)}`);
|
|
313
|
+
log(`Estimated tokens: ${fmtTokens(preview.estimatedTokens)}`);
|
|
314
|
+
if (quote.available) {
|
|
315
|
+
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)})`);
|
|
316
|
+
} else {
|
|
317
|
+
log(`Estimated cost: unavailable (${quote.reason || 'unknown'}); the server will refuse approval (fail closed)`);
|
|
318
|
+
}
|
|
319
|
+
log(`Import id: ${identity.importId}`);
|
|
320
|
+
log('');
|
|
321
|
+
log('Re-running this command is always safe: committed batches replay for free and duplicates are never re-billed.');
|
|
322
|
+
log('');
|
|
323
|
+
|
|
324
|
+
// 4. Explicit approval.
|
|
325
|
+
if (!options.yes) {
|
|
326
|
+
if (options.nonInteractive || !process.stdin.isTTY) {
|
|
327
|
+
throw new Error('approval required: re-run with --yes to confirm the upload');
|
|
328
|
+
}
|
|
329
|
+
const answer = await ask('Proceed with the upload? [y/N]: ');
|
|
330
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
331
|
+
log('Aborted. Nothing was uploaded.');
|
|
332
|
+
return { aborted: true };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const approveRes = await api.importApprove(key, identity.importId, created.approval_digest);
|
|
337
|
+
if (!approveRes.ok) {
|
|
338
|
+
// A completed import cannot be re-approved; that is the fully-settled
|
|
339
|
+
// resume case. Print the receipt instead of failing.
|
|
340
|
+
const reason = approveRes.data && approveRes.data.error;
|
|
341
|
+
if (reason === 'import_completed') {
|
|
342
|
+
const receiptRes = await api.importReceipt(key, identity.importId);
|
|
343
|
+
const receipt = receiptRes.ok && receiptRes.data ? receiptRes.data.receipt || null : null;
|
|
344
|
+
log('This exact extract was already fully imported into this vault. Nothing to upload.');
|
|
345
|
+
if (receipt) {
|
|
346
|
+
log('Receipt:');
|
|
347
|
+
log(JSON.stringify(receipt, null, 2));
|
|
348
|
+
}
|
|
349
|
+
return { importId: identity.importId, alreadyComplete: true, complete: true, receipt };
|
|
350
|
+
}
|
|
351
|
+
throw new Error(`approval refused (${apiErrorDetail(approveRes)})`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 5. Idempotent batch upload with bounded backoff. Resume is free: a batch
|
|
355
|
+
// index that was already durably committed replays its stored response.
|
|
356
|
+
const batches = chunkArray(records, options.batchSize || BATCH_SIZE);
|
|
357
|
+
const totals = { stored: 0, duplicate: 0, skipped: 0, failed: 0, conflict: 0 };
|
|
358
|
+
log(`Uploading ${records.length} record(s) in ${batches.length} batch(es)...`);
|
|
359
|
+
for (let i = 0; i < batches.length; i++) {
|
|
360
|
+
const jsonl = batches[i].map((r) => toWireRecord(r, platform)).join('\n');
|
|
361
|
+
const idempotencyKey = `${identity.importId}:b${i}`;
|
|
362
|
+
let res = null;
|
|
363
|
+
let backoff = INITIAL_BACKOFF_MS;
|
|
364
|
+
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt++) {
|
|
365
|
+
try {
|
|
366
|
+
res = await api.importBatch(key, identity.importId, i, jsonl, idempotencyKey);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
res = null;
|
|
369
|
+
if (attempt === MAX_UPLOAD_ATTEMPTS) throw new Error(`batch ${i} failed after ${MAX_UPLOAD_ATTEMPTS} attempts: ${err.message}`);
|
|
370
|
+
}
|
|
371
|
+
if (res && (res.ok || (res.status < 500 && res.status !== 429))) break;
|
|
372
|
+
if (attempt === MAX_UPLOAD_ATTEMPTS) break;
|
|
373
|
+
await sleep(backoff);
|
|
374
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
375
|
+
}
|
|
376
|
+
if (!res) throw new Error(`batch ${i} received no response`);
|
|
377
|
+
if (res.status === 402) {
|
|
378
|
+
throw new Error('payment required: out of tokens/credits. Add credits at https://app.memoryrouter.ai and re-run to resume.');
|
|
379
|
+
}
|
|
380
|
+
if (!res.ok) throw new Error(`batch ${i} failed (${apiErrorDetail(res)}). Re-run to resume; committed batches replay for free.`);
|
|
381
|
+
const body = res.data || {};
|
|
382
|
+
const s = body.summary || {};
|
|
383
|
+
totals.stored += s.stored || 0;
|
|
384
|
+
totals.duplicate += s.duplicate || 0;
|
|
385
|
+
totals.skipped += s.skipped || 0;
|
|
386
|
+
totals.failed += s.failed || 0;
|
|
387
|
+
totals.conflict += s.conflict || 0;
|
|
388
|
+
log(` batch ${i + 1}/${batches.length}${body.replayed ? ' (replayed, free)' : ''}: stored ${s.stored || 0}, duplicate ${s.duplicate || 0}, failed ${s.failed || 0}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 6. Receipt.
|
|
392
|
+
const statusRes = await api.importStatus(key, identity.importId);
|
|
393
|
+
const complete = Boolean(statusRes.ok && statusRes.data && statusRes.data.complete);
|
|
394
|
+
let receipt = null;
|
|
395
|
+
if (complete) {
|
|
396
|
+
const receiptRes = await api.importReceipt(key, identity.importId);
|
|
397
|
+
if (receiptRes.ok && receiptRes.data) receipt = receiptRes.data.receipt || null;
|
|
398
|
+
}
|
|
399
|
+
log('');
|
|
400
|
+
log(`Done. stored=${totals.stored} duplicate=${totals.duplicate} skipped=${totals.skipped} failed=${totals.failed} conflict=${totals.conflict}`);
|
|
401
|
+
if (receipt) {
|
|
402
|
+
log('Receipt:');
|
|
403
|
+
log(JSON.stringify(receipt, null, 2));
|
|
404
|
+
} else if (!complete) {
|
|
405
|
+
log('The import is not fully settled yet; re-run this command to resume/reconcile.');
|
|
406
|
+
}
|
|
407
|
+
return { importId: identity.importId, totals, complete, receipt };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
module.exports = {
|
|
411
|
+
PLATFORM,
|
|
412
|
+
BATCH_SIZE,
|
|
413
|
+
readExtract,
|
|
414
|
+
computeIdentity,
|
|
415
|
+
buildPreviewSummary,
|
|
416
|
+
estimateTokens,
|
|
417
|
+
toWireRecord,
|
|
418
|
+
runInstructions,
|
|
419
|
+
runDismiss,
|
|
420
|
+
runImportFile,
|
|
421
|
+
};
|
package/runtime/memory.cjs
CHANGED
|
@@ -132,6 +132,7 @@ async function searchScoped(query, options = {}) {
|
|
|
132
132
|
project,
|
|
133
133
|
memories,
|
|
134
134
|
reflection: response.data?.reflection || null,
|
|
135
|
+
importState: response.data?.import || null,
|
|
135
136
|
context: formatContext(memories, project.handle, Number(config.contextChars || 12000)),
|
|
136
137
|
};
|
|
137
138
|
}
|