@totalreclaw/totalreclaw 3.3.12 → 3.3.13

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.
Files changed (128) hide show
  1. package/SKILL.md +25 -8
  2. package/{billing-cache.ts → billing/billing-cache.ts} +1 -1
  3. package/{relay-headers.ts → billing/relay-headers.ts} +1 -1
  4. package/{tr-cli-export-helper.ts → cli/tr-cli-export-helper.ts} +5 -5
  5. package/{tr-cli.ts → cli/tr-cli.ts} +20 -20
  6. package/{contradiction-sync.ts → contradiction/contradiction-sync.ts} +13 -13
  7. package/{digest-sync.ts → digest/digest-sync.ts} +32 -2
  8. package/dist/{billing-cache.js → billing/billing-cache.js} +1 -1
  9. package/dist/{relay-headers.js → billing/relay-headers.js} +1 -1
  10. package/dist/{tr-cli-export-helper.js → cli/tr-cli-export-helper.js} +5 -5
  11. package/dist/{tr-cli.js → cli/tr-cli.js} +19 -19
  12. package/dist/{contradiction-sync.js → contradiction/contradiction-sync.js} +13 -13
  13. package/dist/{digest-sync.js → digest/digest-sync.js} +32 -2
  14. package/dist/{download-ux.js → embedding/download-ux.js} +1 -1
  15. package/dist/{reranker.js → embedding/reranker.js} +13 -4
  16. package/dist/{claims-helper.js → extraction/claims-helper.js} +54 -58
  17. package/dist/extraction/consolidation.js +170 -0
  18. package/dist/{extractor.js → extraction/extractor.js} +17 -17
  19. package/dist/extraction/importance-filter.js +38 -0
  20. package/dist/{semantic-dedup.js → extraction/semantic-dedup.js} +2 -2
  21. package/dist/{batch-gate.js → import/batch-gate.js} +1 -1
  22. package/dist/import/import-runtime.js +692 -0
  23. package/dist/import-adapters/chatgpt-adapter.js +7 -7
  24. package/dist/import-adapters/gemini-adapter.js +13 -13
  25. package/dist/import-adapters/mem0-adapter.js +10 -10
  26. package/dist/index.js +111 -1094
  27. package/dist/{llm-client.js → llm/llm-client.js} +2 -2
  28. package/dist/{hot-cache-wrapper.js → memory/hot-cache-wrapper.js} +5 -5
  29. package/dist/{memory-runtime.js → memory/memory-runtime.js} +1 -1
  30. package/dist/{pin.js → memory/pin.js} +18 -18
  31. package/dist/{retype-setscope.js → memory/retype-setscope.js} +19 -19
  32. package/dist/{credential-provider.js → pairing/credential-provider.js} +2 -2
  33. package/dist/{first-run.js → pairing/first-run.js} +1 -1
  34. package/dist/{onboarding-cli.js → pairing/onboarding-cli.js} +20 -20
  35. package/dist/{pair-cli-relay.js → pairing/pair-cli-relay.js} +10 -10
  36. package/dist/{pair-crypto.js → pairing/pair-crypto.js} +1 -1
  37. package/dist/{pair-http.js → pairing/pair-http.js} +3 -3
  38. package/dist/{pair-remote-client.js → pairing/pair-remote-client.js} +10 -10
  39. package/dist/{pair-session-store.js → pairing/pair-session-store.js} +4 -4
  40. package/dist/runtime/config-schema.js +59 -0
  41. package/dist/runtime/format-helpers.js +257 -0
  42. package/dist/runtime/types.js +11 -0
  43. package/dist/{confirm-indexed.js → subgraph/confirm-indexed.js} +1 -1
  44. package/dist/{subgraph-search.js → subgraph/subgraph-search.js} +14 -14
  45. package/dist/{subgraph-store.js → subgraph/subgraph-store.js} +6 -6
  46. package/{download-ux.ts → embedding/download-ux.ts} +1 -1
  47. package/{reranker.ts → embedding/reranker.ts} +13 -4
  48. package/{claims-helper.ts → extraction/claims-helper.ts} +52 -60
  49. package/{consolidation.ts → extraction/consolidation.ts} +63 -154
  50. package/{extractor.ts → extraction/extractor.ts} +18 -17
  51. package/extraction/importance-filter.ts +50 -0
  52. package/{semantic-dedup.ts → extraction/semantic-dedup.ts} +2 -2
  53. package/{batch-gate.ts → import/batch-gate.ts} +1 -1
  54. package/import/import-runtime.ts +853 -0
  55. package/import-adapters/chatgpt-adapter.ts +7 -7
  56. package/import-adapters/gemini-adapter.ts +13 -13
  57. package/import-adapters/mem0-adapter.ts +10 -10
  58. package/index.ts +129 -1377
  59. package/{llm-client.ts → llm/llm-client.ts} +2 -2
  60. package/{hot-cache-wrapper.ts → memory/hot-cache-wrapper.ts} +5 -5
  61. package/{memory-runtime.ts → memory/memory-runtime.ts} +1 -1
  62. package/{pin.ts → memory/pin.ts} +20 -20
  63. package/{retype-setscope.ts → memory/retype-setscope.ts} +21 -21
  64. package/{tool-gating.ts → memory/tool-gating.ts} +1 -1
  65. package/package.json +6 -6
  66. package/{credential-provider.ts → pairing/credential-provider.ts} +2 -2
  67. package/{first-run.ts → pairing/first-run.ts} +1 -1
  68. package/{onboarding-cli.ts → pairing/onboarding-cli.ts} +20 -20
  69. package/{pair-cli-relay.ts → pairing/pair-cli-relay.ts} +10 -10
  70. package/{pair-crypto.ts → pairing/pair-crypto.ts} +1 -1
  71. package/{pair-http.ts → pairing/pair-http.ts} +3 -3
  72. package/{pair-remote-client.ts → pairing/pair-remote-client.ts} +10 -10
  73. package/{pair-session-store.ts → pairing/pair-session-store.ts} +4 -4
  74. package/runtime/config-schema.ts +64 -0
  75. package/runtime/format-helpers.ts +293 -0
  76. package/runtime/types.ts +118 -0
  77. package/skill.json +44 -10
  78. package/{confirm-indexed.ts → subgraph/confirm-indexed.ts} +1 -1
  79. package/{subgraph-search.ts → subgraph/subgraph-search.ts} +14 -14
  80. package/{subgraph-store.ts → subgraph/subgraph-store.ts} +10 -10
  81. package/dist/consolidation.js +0 -249
  82. /package/{api-client.ts → billing/api-client.ts} +0 -0
  83. /package/{gateway-url.ts → billing/gateway-url.ts} +0 -0
  84. /package/{inbound-user-tracker.ts → billing/inbound-user-tracker.ts} +0 -0
  85. /package/{relay.ts → billing/relay.ts} +0 -0
  86. /package/{crypto.ts → crypto/crypto.ts} +0 -0
  87. /package/{vault-crypto.ts → crypto/vault-crypto.ts} +0 -0
  88. /package/dist/{api-client.js → billing/api-client.js} +0 -0
  89. /package/dist/{gateway-url.js → billing/gateway-url.js} +0 -0
  90. /package/dist/{inbound-user-tracker.js → billing/inbound-user-tracker.js} +0 -0
  91. /package/dist/{relay.js → billing/relay.js} +0 -0
  92. /package/dist/{crypto.js → crypto/crypto.js} +0 -0
  93. /package/dist/{vault-crypto.js → crypto/vault-crypto.js} +0 -0
  94. /package/dist/{embedder-cache.js → embedding/embedder-cache.js} +0 -0
  95. /package/dist/{embedder-loader.js → embedding/embedder-loader.js} +0 -0
  96. /package/dist/{embedder-network.js → embedding/embedder-network.js} +0 -0
  97. /package/dist/{embedding.js → embedding/embedding.js} +0 -0
  98. /package/dist/{lsh.js → embedding/lsh.js} +0 -0
  99. /package/dist/{import-state-manager.js → import/import-state-manager.js} +0 -0
  100. /package/dist/{llm-profile-reader.js → llm/llm-profile-reader.js} +0 -0
  101. /package/dist/{native-memory.js → memory/native-memory.js} +0 -0
  102. /package/dist/{tool-gating.js → memory/tool-gating.js} +0 -0
  103. /package/dist/{tools.js → memory/tools.js} +0 -0
  104. /package/dist/{generate-mnemonic.js → pairing/generate-mnemonic.js} +0 -0
  105. /package/dist/{pair-cli.js → pairing/pair-cli.js} +0 -0
  106. /package/dist/{pair-page.js → pairing/pair-page.js} +0 -0
  107. /package/dist/{pair-qr.js → pairing/pair-qr.js} +0 -0
  108. /package/dist/{restart-auth.js → pairing/restart-auth.js} +0 -0
  109. /package/dist/{qa-bug-report.js → setup/qa-bug-report.js} +0 -0
  110. /package/dist/{skill-register.js → setup/skill-register.js} +0 -0
  111. /package/dist/{trajectory-poller.js → subgraph/trajectory-poller.js} +0 -0
  112. /package/{embedder-cache.ts → embedding/embedder-cache.ts} +0 -0
  113. /package/{embedder-loader.ts → embedding/embedder-loader.ts} +0 -0
  114. /package/{embedder-network.ts → embedding/embedder-network.ts} +0 -0
  115. /package/{embedding.ts → embedding/embedding.ts} +0 -0
  116. /package/{lsh.ts → embedding/lsh.ts} +0 -0
  117. /package/{import-state-manager.ts → import/import-state-manager.ts} +0 -0
  118. /package/{llm-profile-reader.ts → llm/llm-profile-reader.ts} +0 -0
  119. /package/{native-memory.ts → memory/native-memory.ts} +0 -0
  120. /package/{tools.ts → memory/tools.ts} +0 -0
  121. /package/{generate-mnemonic.ts → pairing/generate-mnemonic.ts} +0 -0
  122. /package/{pair-cli.ts → pairing/pair-cli.ts} +0 -0
  123. /package/{pair-page.ts → pairing/pair-page.ts} +0 -0
  124. /package/{pair-qr.ts → pairing/pair-qr.ts} +0 -0
  125. /package/{restart-auth.ts → pairing/restart-auth.ts} +0 -0
  126. /package/{qa-bug-report.ts → setup/qa-bug-report.ts} +0 -0
  127. /package/{skill-register.ts → setup/skill-register.ts} +0 -0
  128. /package/{trajectory-poller.ts → subgraph/trajectory-poller.ts} +0 -0
@@ -0,0 +1,692 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Import subsystem — extracted from index.ts.
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // The CLI import surface (`openclaw totalreclaw import from|status|abort`):
6
+ // adapter parsing, the two-pass smart-import pipeline (profile + triage via
7
+ // @totalreclaw/core WASM), background batch/chunk execution, and import-state
8
+ // bookkeeping. Self-contained except for `storeExtractedFacts` (which closes
9
+ // over the plugin session keys and stays in index.ts) — injected via
10
+ // configureImportRuntime(). Owns the `_importInProgress` flag that the
11
+ // agent_end hook reads through isImportInProgress()/setImportInProgress().
12
+ //
13
+ // No environment-variable reads live here (env stays in config.ts / entry.ts per the
14
+ // OpenClaw env-harvesting scanner rule).
15
+ import { createRequire } from 'node:module';
16
+ import crypto from 'node:crypto';
17
+ import { writeImportState, readImportState, isImportStale, readMostRecentActiveImport, } from './import-state-manager.js';
18
+ import { extractFacts, EXTRACTION_SYSTEM_PROMPT, } from '../extraction/extractor.js';
19
+ import { resolveLLMConfig, chatCompletion } from '../llm/llm-client.js';
20
+ const __cjsRequire = createRequire(import.meta.url);
21
+ let _storeExtractedFacts = null;
22
+ export function configureImportRuntime(deps) {
23
+ _storeExtractedFacts = deps.storeExtractedFacts;
24
+ }
25
+ function storeExtractedFacts(facts, logger, sourceOverride) {
26
+ if (!_storeExtractedFacts)
27
+ throw new Error('import-runtime: configureImportRuntime() not called');
28
+ return _storeExtractedFacts(facts, logger, sourceOverride);
29
+ }
30
+ // BUG-2 fix: Skip agent_end extraction during import operations. Import
31
+ // failures previously triggered agent_end -> re-extraction -> re-import loops.
32
+ // The agent_end hook in index.ts reads this via the getter/setter below.
33
+ let _importInProgress = false;
34
+ export function isImportInProgress() {
35
+ return _importInProgress;
36
+ }
37
+ export function setImportInProgress(value) {
38
+ _importInProgress = value;
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // Import handler (for the registerCli `openclaw totalreclaw import-from` surface)
42
+ // ---------------------------------------------------------------------------
43
+ /**
44
+ * Handle import_from calls (CLI subcommand path; was the totalreclaw_import_from
45
+ * agent tool before Phase 3.2 retired the agent tools).
46
+ *
47
+ * Two paths:
48
+ * 1. Pre-structured sources (Mem0, MCP Memory) — adapter returns facts directly,
49
+ * stored via storeExtractedFacts().
50
+ * 2. Conversation-based sources (ChatGPT, Claude) — adapter returns conversation
51
+ * chunks, each chunk is passed through extractFacts() (the same LLM extraction
52
+ * pipeline used for auto-extraction), then stored via storeExtractedFacts().
53
+ */
54
+ export async function handlePluginImportFrom(params, logger) {
55
+ _importInProgress = true;
56
+ const startTime = Date.now();
57
+ const source = params.source;
58
+ const validSources = ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'];
59
+ if (!source || !validSources.includes(source)) {
60
+ return { success: false, error: `Invalid source. Must be one of: ${validSources.join(', ')}` };
61
+ }
62
+ // Generate import_id up front so dry-run responses and background tasks share it.
63
+ const importId = params.resume_id ?? crypto.randomUUID();
64
+ try {
65
+ const { getAdapter } = await import('../import-adapters/index.js');
66
+ const adapter = getAdapter(source);
67
+ const parseResult = await adapter.parse({
68
+ content: params.content,
69
+ api_key: params.api_key,
70
+ source_user_id: params.source_user_id,
71
+ api_url: params.api_url,
72
+ file_path: params.file_path,
73
+ });
74
+ const hasChunks = parseResult.chunks && parseResult.chunks.length > 0;
75
+ const hasFacts = parseResult.facts && parseResult.facts.length > 0;
76
+ if (parseResult.errors.length > 0 && !hasFacts && !hasChunks) {
77
+ return {
78
+ success: false,
79
+ error: `Failed to parse ${adapter.displayName} data`,
80
+ details: parseResult.errors,
81
+ };
82
+ }
83
+ // Dry run: report what was parsed (chunks or facts)
84
+ if (params.dry_run) {
85
+ if (hasChunks) {
86
+ const totalChunks = parseResult.chunks.length;
87
+ const EXTRACTION_RATIO = 2.5; // avg facts per chunk, from empirical data
88
+ const BATCH_SIZE = 25;
89
+ const SECONDS_PER_BATCH = 45; // ~30s extraction + ~15s embed+store
90
+ const estimatedFacts = Math.round(totalChunks * EXTRACTION_RATIO);
91
+ const estimatedBatches = Math.ceil(totalChunks / BATCH_SIZE);
92
+ const estimatedMinutes = Math.ceil(estimatedBatches * SECONDS_PER_BATCH / 60);
93
+ return {
94
+ success: true,
95
+ dry_run: true,
96
+ import_id: importId,
97
+ source,
98
+ total_chunks: totalChunks,
99
+ total_messages: parseResult.totalMessages,
100
+ estimated_facts: estimatedFacts,
101
+ estimated_batches: estimatedBatches,
102
+ estimated_minutes: estimatedMinutes,
103
+ batch_size: BATCH_SIZE,
104
+ preview: parseResult.chunks.slice(0, 5).map((c) => ({
105
+ title: c.title,
106
+ messages: c.messages.length,
107
+ first_message: c.messages[0]?.text.slice(0, 100),
108
+ })),
109
+ note: `Estimated ${estimatedFacts} facts from ${totalChunks} chunks (~${estimatedMinutes} min). Confirm to start background import.`,
110
+ warnings: parseResult.warnings,
111
+ };
112
+ }
113
+ return {
114
+ success: true,
115
+ dry_run: true,
116
+ import_id: importId,
117
+ source,
118
+ total_found: parseResult.facts.length,
119
+ preview: parseResult.facts.slice(0, 10).map((f) => ({
120
+ type: f.type,
121
+ text: f.text.slice(0, 100),
122
+ importance: f.importance,
123
+ })),
124
+ warnings: parseResult.warnings,
125
+ };
126
+ }
127
+ // ── Path 1: Conversation chunks (ChatGPT, Claude, Gemini) — background execution ──
128
+ if (hasChunks) {
129
+ const totalChunks = parseResult.chunks.length;
130
+ const BATCH_SIZE = 25;
131
+ const SECONDS_PER_BATCH = 45;
132
+ const estimatedBatches = Math.ceil(totalChunks / BATCH_SIZE);
133
+ const estimatedMinutes = Math.ceil(estimatedBatches * SECONDS_PER_BATCH / 60);
134
+ const estimatedTotalFacts = Math.round(totalChunks * 2.5);
135
+ const now = new Date();
136
+ const initialState = {
137
+ import_id: importId,
138
+ source,
139
+ status: 'running',
140
+ started_at: now.toISOString(),
141
+ last_updated: now.toISOString(),
142
+ total_chunks: totalChunks,
143
+ total_messages: parseResult.totalMessages,
144
+ batch_done: 0,
145
+ batch_total: estimatedBatches,
146
+ facts_stored: 0,
147
+ facts_extracted: 0,
148
+ dups_skipped: 0,
149
+ errors: [],
150
+ file_path: params.file_path,
151
+ estimated_total_facts: estimatedTotalFacts,
152
+ estimated_minutes: estimatedMinutes,
153
+ estimated_completion_iso: new Date(now.getTime() + estimatedBatches * SECONDS_PER_BATCH * 1000).toISOString(),
154
+ disclosure_confirmed: !!(params.disclosure_confirmed),
155
+ };
156
+ writeImportState(initialState);
157
+ logger.info(`Import ${importId}: background task started (${totalChunks} chunks, ~${estimatedMinutes}min)`);
158
+ void handleChunkImport(parseResult.chunks, parseResult.totalMessages, source, logger, startTime, parseResult.warnings, importId).catch((err) => {
159
+ const msg = err instanceof Error ? err.message : String(err);
160
+ logger.warn(`Import ${importId}: background task failed: ${msg}`);
161
+ const failedState = readImportState(importId);
162
+ if (failedState && failedState.status === 'running') {
163
+ writeImportState({ ...failedState, status: 'failed', errors: [...failedState.errors, msg] });
164
+ }
165
+ });
166
+ return {
167
+ import_id: importId,
168
+ status: 'running',
169
+ source,
170
+ total_chunks: totalChunks,
171
+ estimated_batches: estimatedBatches,
172
+ estimated_minutes: estimatedMinutes,
173
+ estimated_completion_iso: initialState.estimated_completion_iso,
174
+ message: `Import started in background. ~${estimatedMinutes} min for ${totalChunks} chunks. Check progress with \`openclaw totalreclaw import status\` on the gateway host (or \`import status --id ${importId} --json\` from an agent shell).`,
175
+ warnings: parseResult.warnings,
176
+ };
177
+ }
178
+ // ── Path 2: Pre-structured facts (Mem0, MCP Memory) — direct store ──
179
+ const extractedFacts = parseResult.facts.map((f) => ({
180
+ text: f.text,
181
+ type: f.type,
182
+ importance: f.importance,
183
+ action: 'ADD',
184
+ }));
185
+ // Store in batches of 50. Stop on any batch failure to prevent
186
+ // nonce zombies from blocking subsequent UserOps (AA25).
187
+ let totalStored = 0;
188
+ let storeError;
189
+ const batchSize = 50;
190
+ for (let i = 0; i < extractedFacts.length; i += batchSize) {
191
+ const batch = extractedFacts.slice(i, i + batchSize);
192
+ try {
193
+ const stored = await storeExtractedFacts(batch, logger);
194
+ totalStored += stored;
195
+ logger.info(`Import progress: ${Math.min(i + batchSize, extractedFacts.length)}/${extractedFacts.length} processed, ${totalStored} stored`);
196
+ }
197
+ catch (err) {
198
+ storeError = err instanceof Error ? err.message : String(err);
199
+ logger.warn(`Import stopped at batch ${Math.floor(i / batchSize) + 1}: ${storeError}`);
200
+ break; // Stop processing further batches
201
+ }
202
+ }
203
+ const importWarnings = [...parseResult.warnings];
204
+ if (storeError) {
205
+ importWarnings.push(`Import stopped early: ${storeError}`);
206
+ }
207
+ return {
208
+ success: totalStored > 0,
209
+ source,
210
+ import_id: importId,
211
+ total_found: parseResult.facts.length,
212
+ imported: totalStored,
213
+ skipped: parseResult.facts.length - totalStored,
214
+ stopped_early: !!storeError,
215
+ warnings: importWarnings,
216
+ duration_ms: Date.now() - startTime,
217
+ };
218
+ }
219
+ catch (e) {
220
+ const msg = e instanceof Error ? e.message : 'Unknown error';
221
+ logger.error(`Import failed: ${msg}`);
222
+ return { success: false, error: `Import failed: ${msg}` };
223
+ }
224
+ }
225
+ // ---------------------------------------------------------------------------
226
+ // Smart Import — Two-Pass Pipeline (Profile + Triage)
227
+ // ---------------------------------------------------------------------------
228
+ // Lazy-load WASM for smart import functions (same pattern as crypto.ts /
229
+ // subgraph-store.ts). Goes through __cjsRequire (createRequire(import.meta.url))
230
+ // declared at the top of the file — bare `require()` is undefined under
231
+ // pure-ESM Node, see issue #124.
232
+ let _smartImportWasm = null;
233
+ function getSmartImportWasm() {
234
+ if (!_smartImportWasm)
235
+ _smartImportWasm = __cjsRequire('@totalreclaw/core');
236
+ return _smartImportWasm;
237
+ }
238
+ /**
239
+ * Check whether the @totalreclaw/core WASM module exposes smart import functions.
240
+ * Returns false if the module is an older version without smart import support.
241
+ */
242
+ function hasSmartImportSupport() {
243
+ try {
244
+ const wasm = getSmartImportWasm();
245
+ return typeof wasm.chunksToSummaries === 'function' &&
246
+ typeof wasm.buildProfileBatchPrompt === 'function' &&
247
+ typeof wasm.parseProfileBatchResponse === 'function' &&
248
+ typeof wasm.buildTriagePrompt === 'function' &&
249
+ typeof wasm.parseTriageResponse === 'function' &&
250
+ typeof wasm.enrichExtractionPrompt === 'function';
251
+ }
252
+ catch {
253
+ return false;
254
+ }
255
+ }
256
+ // SmartImportContext — extracted to ./runtime/types.ts (imported above).
257
+ /**
258
+ * Run the smart import two-pass pipeline: profile the user from conversation
259
+ * summaries, then triage chunks as EXTRACT or SKIP.
260
+ *
261
+ * All prompt construction and response parsing happens in @totalreclaw/core WASM.
262
+ * LLM calls use the plugin's existing chatCompletion() function.
263
+ *
264
+ * Returns null if smart import is unavailable (old WASM, no LLM config, etc.)
265
+ * so the caller can fall back to blind extraction.
266
+ */
267
+ async function runSmartImportPipeline(chunks, logger) {
268
+ // Guard: WASM must have smart import functions
269
+ if (!hasSmartImportSupport()) {
270
+ logger.info('Smart import: WASM module does not support smart import, falling back to blind extraction');
271
+ return null;
272
+ }
273
+ // Guard: LLM must be available
274
+ const llmConfig = resolveLLMConfig();
275
+ if (!llmConfig) {
276
+ logger.info('Smart import: no LLM available, falling back to blind extraction');
277
+ return null;
278
+ }
279
+ const pipelineStart = Date.now();
280
+ const wasm = getSmartImportWasm();
281
+ try {
282
+ // Step 0: Convert chunks to compact summaries (first + last message)
283
+ const wasmChunks = chunks.map((c, i) => ({
284
+ index: i,
285
+ title: c.title || 'Untitled',
286
+ messages: c.messages.map((m) => ({ role: m.role, content: m.text })),
287
+ timestamp: c.timestamp || null,
288
+ }));
289
+ const summaries = wasm.chunksToSummaries(JSON.stringify(wasmChunks));
290
+ const summariesJson = JSON.stringify(summaries);
291
+ // Step 1: Build user profile (batch summarize -> merge)
292
+ const PROFILE_BATCH_SIZE = 50;
293
+ const profileStart = Date.now();
294
+ const partials = [];
295
+ for (let i = 0; i < summaries.length; i += PROFILE_BATCH_SIZE) {
296
+ const batch = summaries.slice(i, i + PROFILE_BATCH_SIZE);
297
+ const prompt = wasm.buildProfileBatchPrompt(JSON.stringify(batch));
298
+ const response = await chatCompletion(llmConfig, [
299
+ { role: 'user', content: prompt },
300
+ ], { maxTokens: 2048, temperature: 0 });
301
+ if (!response) {
302
+ logger.warn(`Smart import: LLM returned empty response for profile batch ${Math.floor(i / PROFILE_BATCH_SIZE) + 1}`);
303
+ continue;
304
+ }
305
+ const partial = wasm.parseProfileBatchResponse(response);
306
+ partials.push(partial);
307
+ }
308
+ if (partials.length === 0) {
309
+ logger.warn('Smart import: no profile batches produced, falling back to blind extraction');
310
+ return null;
311
+ }
312
+ let profile;
313
+ if (partials.length === 1) {
314
+ // Single batch — skip merge, promote partial to full profile
315
+ // parseProfileBatchResponse returns a PartialProfile; convert to UserProfile shape
316
+ const p = partials[0];
317
+ profile = {
318
+ identity: p.identity ?? null,
319
+ themes: p.themes ?? [],
320
+ projects: p.projects ?? [],
321
+ stack: p.stack ?? [],
322
+ decisions: p.decisions ?? [],
323
+ interests: p.interests ?? [],
324
+ skip_patterns: p.skip_patterns ?? [],
325
+ };
326
+ }
327
+ else {
328
+ const mergePrompt = wasm.buildProfileMergePrompt(JSON.stringify(partials));
329
+ const mergeResponse = await chatCompletion(llmConfig, [
330
+ { role: 'user', content: mergePrompt },
331
+ ], { maxTokens: 2048, temperature: 0 });
332
+ if (!mergeResponse) {
333
+ logger.warn('Smart import: LLM returned empty response for profile merge, falling back to blind extraction');
334
+ return null;
335
+ }
336
+ profile = wasm.parseProfileResponse(mergeResponse);
337
+ }
338
+ const profileJson = JSON.stringify(profile);
339
+ const profileDuration = Date.now() - profileStart;
340
+ const p = profile;
341
+ const themeCount = Array.isArray(p.themes) ? p.themes.length : 0;
342
+ const skipPatternCount = Array.isArray(p.skip_patterns) ? p.skip_patterns.length : 0;
343
+ logger.info(`Smart import: profile built in ${profileDuration}ms (themes=${themeCount}, skip_patterns=${skipPatternCount})`);
344
+ // Step 1.5: Chunk triage (EXTRACT or SKIP)
345
+ const triageStart = Date.now();
346
+ const allDecisions = [];
347
+ const TRIAGE_BATCH_SIZE = 50;
348
+ for (let i = 0; i < summaries.length; i += TRIAGE_BATCH_SIZE) {
349
+ const batch = summaries.slice(i, i + TRIAGE_BATCH_SIZE);
350
+ const triagePrompt = wasm.buildTriagePrompt(profileJson, JSON.stringify(batch));
351
+ const triageResponse = await chatCompletion(llmConfig, [
352
+ { role: 'user', content: triagePrompt },
353
+ ], { maxTokens: 4096, temperature: 0 });
354
+ if (!triageResponse) {
355
+ logger.warn(`Smart import: LLM returned empty response for triage batch ${Math.floor(i / TRIAGE_BATCH_SIZE) + 1}, defaulting to EXTRACT`);
356
+ // Default all chunks in this batch to EXTRACT
357
+ for (let j = i; j < Math.min(i + TRIAGE_BATCH_SIZE, summaries.length); j++) {
358
+ allDecisions.push({ chunk_index: j, decision: 'EXTRACT', reason: 'triage LLM unavailable' });
359
+ }
360
+ continue;
361
+ }
362
+ const batchDecisions = wasm.parseTriageResponse(triageResponse);
363
+ allDecisions.push(...batchDecisions);
364
+ }
365
+ const triageDuration = Date.now() - triageStart;
366
+ const extractCount = allDecisions.filter((d) => d.decision !== 'SKIP').length;
367
+ const skipCount = allDecisions.filter((d) => d.decision === 'SKIP').length;
368
+ logger.info(`Smart import: triage complete in ${triageDuration}ms (extract=${extractCount}, skip=${skipCount}, total=${chunks.length})`);
369
+ // Step 2: Build enriched system prompt for extraction
370
+ const enrichedSystemPrompt = wasm.enrichExtractionPrompt(profileJson, EXTRACTION_SYSTEM_PROMPT);
371
+ const totalDuration = Date.now() - pipelineStart;
372
+ logger.info(`Smart import: pipeline complete in ${totalDuration}ms`);
373
+ return {
374
+ profileJson,
375
+ decisions: allDecisions,
376
+ enrichedSystemPrompt,
377
+ extractCount,
378
+ skipCount,
379
+ durationMs: totalDuration,
380
+ };
381
+ }
382
+ catch (err) {
383
+ const msg = err instanceof Error ? err.message : String(err);
384
+ logger.warn(`Smart import: pipeline failed (${msg}), falling back to blind extraction`);
385
+ return null;
386
+ }
387
+ }
388
+ /**
389
+ * Check if a chunk should be skipped based on triage decisions.
390
+ * If no decision exists for the chunk index, defaults to EXTRACT (safe default).
391
+ */
392
+ function isChunkSkipped(chunkIndex, decisions) {
393
+ const decision = decisions.find((d) => d.chunk_index === chunkIndex);
394
+ if (decision && decision.decision === 'SKIP') {
395
+ return { skipped: true, reason: decision.reason || 'triage: skip' };
396
+ }
397
+ return { skipped: false, reason: '' };
398
+ }
399
+ /**
400
+ * Process a batch (slice) of conversation chunks from a file.
401
+ * Called repeatedly by the agent for large imports.
402
+ */
403
+ async function handleBatchImport(params, logger) {
404
+ _importInProgress = true;
405
+ const source = params.source;
406
+ const filePath = params.file_path;
407
+ const content = params.content;
408
+ const offset = params.offset ?? 0;
409
+ const batchSize = params.batch_size ?? 25;
410
+ const validSources = ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'];
411
+ if (!source || !validSources.includes(source)) {
412
+ return { success: false, error: `Invalid source. Must be one of: ${validSources.join(', ')}` };
413
+ }
414
+ const startTime = Date.now();
415
+ const { getAdapter } = await import('../import-adapters/index.js');
416
+ const adapter = getAdapter(source);
417
+ const parseResult = await adapter.parse({ content, file_path: filePath });
418
+ if (parseResult.errors.length > 0 && parseResult.chunks.length === 0) {
419
+ return { success: false, error: parseResult.errors.join('; ') };
420
+ }
421
+ const totalChunks = parseResult.chunks.length;
422
+ const slice = parseResult.chunks.slice(offset, offset + batchSize);
423
+ const remaining = Math.max(0, totalChunks - offset - slice.length);
424
+ // --- Smart Import: Profile + Triage ---
425
+ // Build profile from ALL chunks (not just the slice) for full context,
426
+ // then triage only the current slice. For simplicity, we rebuild on every
427
+ // batch call — optimization (caching) can come later.
428
+ const smartCtx = await runSmartImportPipeline(parseResult.chunks, logger);
429
+ let chunksSkipped = 0;
430
+ // Process the slice through the normal extraction + storage pipeline.
431
+ // If a batch fails (nonce zombie, quota exceeded, etc.), stop immediately
432
+ // to prevent subsequent UserOps from hitting AA25 nonce conflicts.
433
+ let factsExtracted = 0;
434
+ let factsStored = 0;
435
+ let chunksProcessed = 0;
436
+ let storeError;
437
+ for (let i = 0; i < slice.length; i++) {
438
+ const chunk = slice[i];
439
+ const globalIndex = offset + i; // Index in the full chunks array
440
+ // Smart import: skip chunks triaged as SKIP
441
+ if (smartCtx) {
442
+ const { skipped, reason } = isChunkSkipped(globalIndex, smartCtx.decisions);
443
+ if (skipped) {
444
+ logger.info(`Import: skipping chunk ${globalIndex + 1}/${totalChunks}: "${chunk.title}" (${reason})`);
445
+ chunksSkipped++;
446
+ chunksProcessed++;
447
+ continue;
448
+ }
449
+ }
450
+ logger.info(`Import: extracting facts from chunk ${globalIndex + 1}/${totalChunks}: "${chunk.title}"`);
451
+ const messages = chunk.messages.map((m) => ({ role: m.role, content: m.text }));
452
+ const facts = await extractFacts(messages, 'full', undefined, // no existing memories for dedup during import
453
+ smartCtx?.enrichedSystemPrompt);
454
+ chunksProcessed++;
455
+ if (facts.length > 0) {
456
+ factsExtracted += facts.length;
457
+ try {
458
+ const stored = await storeExtractedFacts(facts, logger);
459
+ factsStored += stored;
460
+ }
461
+ catch (err) {
462
+ storeError = err instanceof Error ? err.message : String(err);
463
+ logger.warn(`Import batch stopped at chunk ${globalIndex + 1}/${totalChunks}: ${storeError}`);
464
+ break; // Stop processing further chunks — a zombie UserOp may block writes
465
+ }
466
+ }
467
+ }
468
+ return {
469
+ success: factsStored > 0 || (!storeError && factsExtracted === 0),
470
+ batch_offset: offset,
471
+ batch_size: chunksProcessed,
472
+ total_chunks: totalChunks,
473
+ facts_extracted: factsExtracted,
474
+ facts_stored: factsStored,
475
+ chunks_skipped: chunksSkipped,
476
+ remaining_chunks: remaining,
477
+ is_complete: remaining === 0 && !storeError,
478
+ stopped_early: !!storeError,
479
+ error: storeError,
480
+ smart_import: smartCtx ? {
481
+ profile_duration_ms: smartCtx.durationMs,
482
+ extract_count: smartCtx.extractCount,
483
+ skip_count: smartCtx.skipCount,
484
+ } : null,
485
+ // Estimation for the full import
486
+ estimated_total_facts: Math.round(totalChunks * 2.5),
487
+ estimated_total_userops: Math.ceil(totalChunks * 2.5 / 15),
488
+ estimated_minutes: Math.ceil(Math.ceil(totalChunks / batchSize) * 45 / 60),
489
+ duration_ms: Date.now() - startTime,
490
+ };
491
+ }
492
+ /**
493
+ * Process conversation chunks through LLM extraction and store results.
494
+ *
495
+ * Each chunk is passed to extractFacts() — the same extraction pipeline used
496
+ * for auto-extraction during live conversations. This ensures import quality
497
+ * matches conversation extraction quality.
498
+ */
499
+ async function handleChunkImport(chunks, totalMessages, source, logger, startTime, warnings, importId) {
500
+ let totalExtracted = 0;
501
+ let totalStored = 0;
502
+ let chunksProcessed = 0;
503
+ let chunksSkipped = 0;
504
+ const resolvedImportId = importId ?? crypto.randomUUID();
505
+ let storeError;
506
+ // --- Smart Import: Profile + Triage ---
507
+ const smartCtx = await runSmartImportPipeline(chunks, logger);
508
+ const CHECKPOINT_EVERY = 25; // write state file every N chunks
509
+ for (let i = 0; i < chunks.length; i++) {
510
+ // Check abort flag from state file before each chunk (background task may be cancelled).
511
+ if (importId) {
512
+ const currentState = readImportState(importId);
513
+ if (currentState?.status === 'aborted') {
514
+ logger.info(`Import ${importId}: abort flag detected at chunk ${i + 1}/${chunks.length}, stopping`);
515
+ break;
516
+ }
517
+ }
518
+ const chunk = chunks[i];
519
+ chunksProcessed++;
520
+ // Smart import: skip chunks triaged as SKIP
521
+ if (smartCtx) {
522
+ const { skipped, reason } = isChunkSkipped(i, smartCtx.decisions);
523
+ if (skipped) {
524
+ logger.info(`Import: skipping chunk ${chunksProcessed}/${chunks.length}: "${chunk.title}" (${reason})`);
525
+ chunksSkipped++;
526
+ continue;
527
+ }
528
+ }
529
+ logger.info(`Import: extracting facts from chunk ${chunksProcessed}/${chunks.length}: "${chunk.title}"`);
530
+ // Convert chunk messages to the format extractFacts() expects.
531
+ // extractFacts() takes an array of message-like objects with { role, content }.
532
+ const messages = chunk.messages.map((m) => ({
533
+ role: m.role,
534
+ content: m.text,
535
+ }));
536
+ // Use 'full' mode to extract ALL valuable memories from the chunk
537
+ // (not just the last few messages like 'turn' mode does).
538
+ // Smart import: pass enriched system prompt with user profile context.
539
+ const facts = await extractFacts(messages, 'full', undefined, // no existing memories for dedup during import
540
+ smartCtx?.enrichedSystemPrompt);
541
+ if (facts.length > 0) {
542
+ totalExtracted += facts.length;
543
+ try {
544
+ // Store through the normal pipeline (dedup, encrypt, store).
545
+ // storeExtractedFacts throws on batch failure to prevent nonce zombies.
546
+ const stored = await storeExtractedFacts(facts, logger);
547
+ totalStored += stored;
548
+ logger.info(`Import chunk ${chunksProcessed}/${chunks.length}: extracted ${facts.length} facts, stored ${stored}`);
549
+ }
550
+ catch (err) {
551
+ storeError = err instanceof Error ? err.message : String(err);
552
+ logger.warn(`Import stopped at chunk ${chunksProcessed}/${chunks.length}: ${storeError}`);
553
+ break; // Stop processing further chunks — a zombie UserOp may block writes
554
+ }
555
+ }
556
+ // Checkpoint state file periodically so _import_status reflects live progress.
557
+ if (importId && chunksProcessed % CHECKPOINT_EVERY === 0) {
558
+ const liveState = readImportState(importId);
559
+ if (liveState) {
560
+ const estimatedBatches = liveState.batch_total || 1;
561
+ const doneBatches = Math.floor(chunksProcessed / CHECKPOINT_EVERY);
562
+ const elapsed = Date.now() - new Date(liveState.started_at).getTime();
563
+ const secPerBatch = doneBatches > 0 ? elapsed / 1000 / doneBatches : 45;
564
+ const remaining = estimatedBatches - doneBatches;
565
+ const etaMs = remaining * secPerBatch * 1000;
566
+ writeImportState({
567
+ ...liveState,
568
+ batch_done: doneBatches,
569
+ facts_stored: totalStored,
570
+ facts_extracted: totalExtracted,
571
+ estimated_completion_iso: new Date(Date.now() + etaMs).toISOString(),
572
+ });
573
+ }
574
+ }
575
+ }
576
+ if (totalExtracted === 0 && chunks.length > 0 && !storeError && chunksSkipped < chunks.length) {
577
+ warnings.push(`Processed ${chunks.length} conversation chunks (${totalMessages} messages) but the LLM ` +
578
+ `did not extract any facts worth storing. This can happen if the conversations are mostly ` +
579
+ `generic/ephemeral content without personal facts, preferences, or decisions.`);
580
+ }
581
+ if (storeError) {
582
+ warnings.push(`Import stopped early: ${storeError}. ${chunks.length - chunksProcessed} chunk(s) not processed.`);
583
+ }
584
+ // Final state file write for background imports.
585
+ if (importId) {
586
+ const finalState = readImportState(importId);
587
+ if (finalState) {
588
+ const finalStatus = storeError ? 'failed' : (finalState.status === 'aborted' ? 'aborted' : 'completed');
589
+ writeImportState({
590
+ ...finalState,
591
+ status: finalStatus,
592
+ batch_done: finalState.batch_total,
593
+ facts_stored: totalStored,
594
+ facts_extracted: totalExtracted,
595
+ errors: storeError ? [...finalState.errors, storeError] : finalState.errors,
596
+ });
597
+ }
598
+ _importInProgress = false;
599
+ }
600
+ return {
601
+ success: totalStored > 0 || totalExtracted > 0,
602
+ source,
603
+ import_id: resolvedImportId,
604
+ total_chunks: chunks.length,
605
+ chunks_processed: chunksProcessed,
606
+ chunks_skipped: chunksSkipped,
607
+ total_messages: totalMessages,
608
+ facts_extracted: totalExtracted,
609
+ imported: totalStored,
610
+ skipped: totalExtracted - totalStored,
611
+ stopped_early: !!storeError,
612
+ smart_import: smartCtx ? {
613
+ profile_duration_ms: smartCtx.durationMs,
614
+ extract_count: smartCtx.extractCount,
615
+ skip_count: smartCtx.skipCount,
616
+ } : null,
617
+ warnings,
618
+ duration_ms: Date.now() - startTime,
619
+ };
620
+ }
621
+ // ---------------------------------------------------------------------------
622
+ // Import status + abort handlers
623
+ // ---------------------------------------------------------------------------
624
+ export async function handleImportStatus(params, logger) {
625
+ const importId = params.import_id;
626
+ let state;
627
+ if (importId) {
628
+ state = readImportState(importId);
629
+ if (!state)
630
+ return { error: `No import found with id: ${importId}` };
631
+ }
632
+ else {
633
+ state = readMostRecentActiveImport();
634
+ if (!state)
635
+ return { status: 'no_active_import', message: 'No active import found. Start one with `openclaw totalreclaw import from <source>` on the gateway host. (Auto-resume still picks up running imports on gateway restart.)' };
636
+ }
637
+ // 1h freshness guard: mark stale imports as failed and prompt user to resume.
638
+ if (state.status === 'running' && isImportStale(state)) {
639
+ writeImportState({ ...state, status: 'failed', errors: [...state.errors, 'Stale: no progress in 1h'] });
640
+ logger.info(`Import ${state.import_id}: marked stale (no progress in 1h)`);
641
+ return {
642
+ import_id: state.import_id,
643
+ status: 'failed',
644
+ stale: true,
645
+ facts_stored: state.facts_stored,
646
+ message: 'Import appears stale — no progress in 1 hour. Resume it with `openclaw totalreclaw import from <source> --file <path> --resume ' + state.import_id + '` on the gateway host, or restart the gateway to trigger auto-resume.',
647
+ resume_id: state.import_id,
648
+ };
649
+ }
650
+ const now = Date.now();
651
+ const elapsedMs = now - new Date(state.started_at).getTime();
652
+ const secPerBatch = state.batch_done > 0 ? elapsedMs / 1000 / state.batch_done : 45;
653
+ const remaining = Math.max(0, state.batch_total - state.batch_done);
654
+ const etaSeconds = state.status === 'running' ? Math.round(remaining * secPerBatch) : 0;
655
+ return {
656
+ import_id: state.import_id,
657
+ status: state.status,
658
+ batch_done: state.batch_done,
659
+ batch_total: state.batch_total,
660
+ facts_stored: state.facts_stored,
661
+ dups_skipped: state.dups_skipped,
662
+ eta_seconds: etaSeconds,
663
+ completion_iso: state.status === 'running'
664
+ ? new Date(now + etaSeconds * 1000).toISOString()
665
+ : state.last_updated,
666
+ source: state.source,
667
+ started_at: state.started_at,
668
+ errors: state.errors,
669
+ };
670
+ }
671
+ export async function handleImportAbort(params, logger) {
672
+ const importId = params.import_id;
673
+ if (!importId)
674
+ return { error: 'import_id is required' };
675
+ const state = readImportState(importId);
676
+ if (!state)
677
+ return { error: `No import found with id: ${importId}` };
678
+ if (state.status === 'aborted') {
679
+ return { aborted: true, idempotent: true, import_id: importId, facts_already_stored: state.facts_stored };
680
+ }
681
+ if (state.status === 'completed') {
682
+ return { error: 'Import already completed — nothing to abort', import_id: importId, facts_stored: state.facts_stored };
683
+ }
684
+ writeImportState({ ...state, status: 'aborted' });
685
+ logger.info(`Import ${importId}: abort requested (${state.facts_stored} facts already stored)`);
686
+ return {
687
+ aborted: true,
688
+ import_id: importId,
689
+ facts_already_stored: state.facts_stored,
690
+ message: 'Import abort requested. The background task will stop at the next chunk boundary. Already-stored facts are kept.',
691
+ };
692
+ }