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