@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
package/index.ts CHANGED
@@ -74,8 +74,8 @@ import {
74
74
  decrypt,
75
75
  generateBlindIndices,
76
76
  generateContentFingerprint,
77
- } from './crypto.js';
78
- import { createApiClient, type StoreFactPayload } from './api-client.js';
77
+ } from './crypto/crypto.js';
78
+ import { createApiClient, type StoreFactPayload } from './billing/api-client.js';
79
79
  import {
80
80
  extractFacts,
81
81
  extractCrystal,
@@ -92,7 +92,7 @@ import {
92
92
  type MemoryType,
93
93
  type MemorySource,
94
94
  type MemoryScope,
95
- } from './extractor.js';
95
+ } from './extraction/extractor.js';
96
96
  import {
97
97
  initLLMClient,
98
98
  resolveLLMConfig,
@@ -102,16 +102,16 @@ import {
102
102
  getEmbeddingModelId,
103
103
  configureEmbedder,
104
104
  prefetchEmbedderBundle,
105
- } from './llm-client.js';
105
+ } from './llm/llm-client.js';
106
106
  import {
107
107
  defaultAuthProfilesRoot,
108
108
  readAllProfileKeys,
109
109
  dedupeByProvider,
110
- } from './llm-profile-reader.js';
111
- import { LSHHasher } from './lsh.js';
112
- import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS, type RerankerCandidate } from './reranker.js';
113
- import { deduplicateBatch } from './semantic-dedup.js';
114
- import { startTrajectoryPoller, type ExtractedFactLike } from './trajectory-poller.js';
110
+ } from './llm/llm-profile-reader.js';
111
+ import { LSHHasher } from './embedding/lsh.js';
112
+ import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS, type RerankerCandidate } from './embedding/reranker.js';
113
+ import { deduplicateBatch } from './extraction/semantic-dedup.js';
114
+ import { startTrajectoryPoller, type ExtractedFactLike } from './subgraph/trajectory-poller.js';
115
115
  import {
116
116
  findNearDuplicate,
117
117
  shouldSupersede,
@@ -120,9 +120,9 @@ import {
120
120
  getConsolidationThreshold,
121
121
  STORE_DEDUP_MAX_CANDIDATES,
122
122
  type DecryptedCandidate,
123
- } from './consolidation.js';
124
- import { isSubgraphMode, getSubgraphConfig, encodeFactProtobuf, submitFactOnChain, submitFactBatchOnChain, deriveSmartAccountAddress, PROTOBUF_VERSION_V4, type FactPayload } from './subgraph-store.js';
125
- import { confirmIndexed } from './confirm-indexed.js';
123
+ } from './extraction/consolidation.js';
124
+ import { isSubgraphMode, getSubgraphConfig, encodeFactProtobuf, submitFactOnChain, submitFactBatchOnChain, deriveSmartAccountAddress, PROTOBUF_VERSION_V4, type FactPayload } from './subgraph/subgraph-store.js';
125
+ import { confirmIndexed } from './subgraph/confirm-indexed.js';
126
126
  import {
127
127
  DIGEST_TRAPDOOR,
128
128
  buildCanonicalClaim,
@@ -133,7 +133,7 @@ import {
133
133
  readClaimFromBlob,
134
134
  resolveDigestMode,
135
135
  type DigestMode,
136
- } from './claims-helper.js';
136
+ } from './extraction/claims-helper.js';
137
137
  import {
138
138
  maybeInjectDigest,
139
139
  recompileDigest,
@@ -141,31 +141,31 @@ import {
141
141
  isRecompileInProgress,
142
142
  tryBeginRecompile,
143
143
  endRecompile,
144
- } from './digest-sync.js';
144
+ } from './digest/digest-sync.js';
145
145
  import {
146
146
  detectAndResolveContradictions,
147
147
  runWeightTuningLoop,
148
148
  type ResolutionDecision as ContradictionDecision,
149
- } from './contradiction-sync.js';
150
- import { searchSubgraph, searchSubgraphBroadened, getSubgraphFactCount, fetchFactById } from './subgraph-search.js';
149
+ } from './contradiction/contradiction-sync.js';
150
+ import { searchSubgraph, searchSubgraphBroadened, getSubgraphFactCount, fetchFactById } from './subgraph/subgraph-search.js';
151
151
  import {
152
152
  executePinOperation,
153
153
  validatePinArgs,
154
154
  type PinOpDeps,
155
- } from './pin.js';
155
+ } from './memory/pin.js';
156
156
  import {
157
157
  runNonInteractiveOnboard,
158
158
  type NonInteractiveOnboardResult,
159
- } from './onboarding-cli.js';
160
- import { PluginHotCache, type HotFact } from './hot-cache-wrapper.js';
159
+ } from './pairing/onboarding-cli.js';
160
+ import { PluginHotCache, type HotFact } from './memory/hot-cache-wrapper.js';
161
161
  import { CONFIG, setRecoveryPhraseOverride } from './config.js';
162
- import { buildRelayHeaders } from './relay-headers.js';
162
+ import { buildRelayHeaders } from './billing/relay-headers.js';
163
163
  import {
164
164
  readBillingCache,
165
165
  writeBillingCache,
166
166
  BILLING_CACHE_PATH,
167
167
  type BillingCache,
168
- } from './billing-cache.js';
168
+ } from './billing/billing-cache.js';
169
169
  import {
170
170
  ensureMemoryHeaderFile,
171
171
  loadCredentialsJson,
@@ -183,24 +183,45 @@ import {
183
183
  checkCredentialsFileMode,
184
184
  type OnboardingState,
185
185
  } from './fs-helpers.js';
186
- import { isRcBuild } from './qa-bug-report.js';
187
- import { decideToolGate, isGatedToolName } from './tool-gating.js';
186
+ import { isRcBuild } from './setup/qa-bug-report.js';
187
+ import { decideToolGate, isGatedToolName } from './memory/tool-gating.js';
188
188
  import {
189
189
  resolveRestartAuth,
190
190
  rejectMessageFor,
191
191
  type RestartAuthConfig,
192
- } from './restart-auth.js';
192
+ } from './pairing/restart-auth.js';
193
193
  import {
194
194
  recordInboundUser,
195
195
  getDistinctInboundUserCount,
196
196
  resolveTrackerPath,
197
- } from './inbound-user-tracker.js';
198
- import { detectFirstRun, buildWelcomePrepend, type GatewayMode } from './first-run.js';
199
- import { buildPairRoutes } from './pair-http.js';
200
- import { detectGatewayHost } from './gateway-url.js';
201
- import { registerNativeMemory, type TrNativeMemoryDeps } from './native-memory.js';
202
- import { ensureSkillRegistered } from './skill-register.js';
203
- import type { TrFact, TrPinnedFact, TrQuotaState } from './memory-runtime.js';
197
+ } from './billing/inbound-user-tracker.js';
198
+ import { detectFirstRun, buildWelcomePrepend, type GatewayMode } from './pairing/first-run.js';
199
+ import { buildPairRoutes } from './pairing/pair-http.js';
200
+ import { detectGatewayHost } from './billing/gateway-url.js';
201
+ import { registerNativeMemory, type TrNativeMemoryDeps } from './memory/native-memory.js';
202
+ import { ensureSkillRegistered } from './setup/skill-register.js';
203
+ import type { OpenClawPluginApi, MigrationFact, SmartImportContext } from './runtime/types.js';
204
+ import {
205
+ humanizeError,
206
+ buildPairingUrl,
207
+ resolveGatewayMode,
208
+ computeCandidatePool,
209
+ encryptToHex,
210
+ decryptFromHex,
211
+ textScore,
212
+ relativeTime,
213
+ } from './runtime/format-helpers.js';
214
+ import { CONFIG_SCHEMA } from './runtime/config-schema.js';
215
+ import { filterByImportance } from './extraction/importance-filter.js';
216
+ import {
217
+ handlePluginImportFrom,
218
+ handleImportStatus,
219
+ handleImportAbort,
220
+ configureImportRuntime,
221
+ isImportInProgress,
222
+ setImportInProgress,
223
+ } from './import/import-runtime.js';
224
+ import type { TrFact, TrPinnedFact, TrQuotaState } from './memory/memory-runtime.js';
204
225
  import { validateMnemonic } from '@scure/bip39';
205
226
  import { wordlist } from '@scure/bip39/wordlists/english.js';
206
227
  import crypto from 'node:crypto';
@@ -213,7 +234,7 @@ import {
213
234
  isImportStale,
214
235
  readMostRecentActiveImport,
215
236
  type ImportState,
216
- } from './import-state-manager.js';
237
+ } from './import/import-state-manager.js';
217
238
 
218
239
  // CJS-style require for the @totalreclaw/core WASM module. We keep this
219
240
  // load path lazy (only inside getSmartImportWasm() below) so a partial
@@ -226,117 +247,9 @@ import {
226
247
  const __cjsRequire = createRequire(import.meta.url);
227
248
 
228
249
  // ---------------------------------------------------------------------------
229
- // OpenClaw Plugin API type (defined locally to avoid SDK dependency)
250
+ // OpenClaw Plugin API type extracted to ./runtime/types.ts (imported above).
230
251
  // ---------------------------------------------------------------------------
231
252
 
232
- interface OpenClawPluginApi {
233
- logger: {
234
- info(...args: unknown[]): void;
235
- warn(...args: unknown[]): void;
236
- error(...args: unknown[]): void;
237
- };
238
- config?: {
239
- agents?: {
240
- defaults?: {
241
- model?: {
242
- primary?: string;
243
- };
244
- };
245
- };
246
- models?: {
247
- providers?: Record<string, {
248
- baseUrl: string;
249
- apiKey?: string;
250
- api?: string;
251
- models?: Array<{ id: string; [k: string]: unknown }>;
252
- [k: string]: unknown;
253
- }>;
254
- [k: string]: unknown;
255
- };
256
- [key: string]: unknown;
257
- };
258
- pluginConfig?: Record<string, unknown>;
259
- registerTool(tool: unknown, opts?: { name?: string; names?: string[] }): void;
260
- registerService(service: { id: string; start(): void; stop?(): void }): void;
261
- on(hookName: string, handler: (...args: unknown[]) => unknown, opts?: { priority?: number }): void;
262
- /**
263
- * 3.2.0 — register a top-level `openclaw <cmd>` subcommand. The handler
264
- * receives a commander `Command` to attach subcommands to. Output goes
265
- * straight to the user's TTY; nothing touches the LLM or the transcript.
266
- * We deliberately type `program` as `unknown` at this boundary because
267
- * we don't import the SDK's full types; the runtime shape is commander's
268
- * `Command` which we cast at the call site.
269
- */
270
- registerCli?(
271
- registrar: (ctx: { program: unknown; config?: unknown; workspaceDir?: string; logger?: unknown }) => void | Promise<void>,
272
- opts?: { commands?: string[] },
273
- ): void;
274
- /**
275
- * 3.2.0 — register a slash command (e.g. `/totalreclaw`). The handler
276
- * runs before the agent; its reply is delivered via the channel adapter.
277
- * Reply text IS appended to the session transcript (see gateway-cli
278
- * L9300-9312), so we only emit non-secret pointers.
279
- */
280
- registerCommand?(command: {
281
- name: string;
282
- description: string;
283
- acceptsArgs?: boolean;
284
- requireAuth?: boolean;
285
- handler: (ctx: {
286
- senderId?: string;
287
- channel?: string;
288
- args?: string;
289
- commandBody?: string;
290
- isAuthorizedSender?: boolean;
291
- config?: unknown;
292
- }) => { text: string } | Promise<{ text: string }>;
293
- }): void;
294
- /**
295
- * 3.3.0 — register an HTTP route on the gateway's HTTP server.
296
- * Used by the QR-pairing flow to serve the pairing page + the
297
- * encrypted-payload respond endpoint. Path is exact-match against
298
- * `new URL(req.url, ...).pathname`; no params supported.
299
- */
300
- registerHttpRoute?(params: {
301
- path: string;
302
- handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void> | void;
303
- /** OpenClaw 2026.4.2+ — required; loader silently drops the route if absent. */
304
- auth: 'gateway' | 'plugin';
305
- }): void;
306
- }
307
-
308
- // ---------------------------------------------------------------------------
309
- // Human-friendly error messages
310
- // ---------------------------------------------------------------------------
311
-
312
- /**
313
- * Translate technical error messages from the on-chain submission pipeline
314
- * into user-friendly messages. The original technical details are still
315
- * logged via api.logger — this only affects what the agent sees.
316
- */
317
- function humanizeError(rawMessage: string): string {
318
- if (rawMessage.includes('AA23')) {
319
- return 'Memory storage temporarily unavailable. Will retry next time.';
320
- }
321
- if (rawMessage.includes('AA10')) {
322
- return 'Please wait a moment before storing more memories.';
323
- }
324
- if (rawMessage.includes('AA25')) {
325
- return 'Memory storage busy. Will retry.';
326
- }
327
- if (rawMessage.includes('pm_sponsorUserOperation')) {
328
- return 'Memory storage service temporarily unavailable.';
329
- }
330
- if (/Relay returned HTTP\s*404/.test(rawMessage)) {
331
- return 'Memory service is temporarily offline.';
332
- }
333
- if (/Relay returned HTTP\s*5\d\d/.test(rawMessage)) {
334
- return 'Memory service encountered a temporary error. Will retry next time.';
335
- }
336
- // Pass through non-technical messages as-is.
337
- return rawMessage;
338
- }
339
-
340
253
  // ---------------------------------------------------------------------------
341
254
  // Persistent credential storage
342
255
  // ---------------------------------------------------------------------------
@@ -348,177 +261,7 @@ const CREDENTIALS_PATH = CONFIG.credentialsPath;
348
261
  // 3.3.0 — pairing URL resolution
349
262
  // ---------------------------------------------------------------------------
350
263
 
351
- /**
352
- * Build the full pairing URL (including `#pk=` fragment) for a fresh
353
- * pairing session. Pulls gateway config from `api.config.gateway`.
354
- *
355
- * Resolution order (3.3.1 — six-layer cascade):
356
- * 1. `plugins.entries.totalreclaw.config.publicUrl` — explicit override
357
- * 2. `gateway.remote.url` — OpenClaw's own remote-gateway URL
358
- * 3. `gateway.bind === 'custom'` + `gateway.customBindHost` + port
359
- * 4. Tailscale auto-detect — `tailscale status --json` → `https://<MagicDNS>`
360
- * (assumes `tailscale serve` proxies to the gateway port on 443)
361
- * 5. LAN auto-detect — first non-loopback, non-virtual IPv4 interface.
362
- * Emits a warning: "only works on the same network".
363
- * 6. Fallback `http://localhost:<port>` — warns with a pointer to
364
- * configure `plugins.entries.totalreclaw.config.publicUrl`.
365
- *
366
- * Always returns a working URL string; never throws. The caller (CLI or
367
- * JSON output) prints whatever we give it.
368
- */
369
- function buildPairingUrl(
370
- api: Pick<OpenClawPluginApi, 'config' | 'pluginConfig' | 'logger'>,
371
- session: { sid: string; pkGatewayB64: string },
372
- ): string {
373
- const cfg = api.config as {
374
- gateway?: {
375
- port?: number;
376
- bind?: string;
377
- customBindHost?: string;
378
- tls?: { enabled?: boolean };
379
- remote?: { url?: string };
380
- };
381
- } | undefined;
382
- const pluginCfg = (api.pluginConfig ?? {}) as { publicUrl?: string };
383
-
384
- const tlsEnabled = cfg?.gateway?.tls?.enabled === true;
385
- const scheme = tlsEnabled ? 'https' : 'http';
386
- const port = cfg?.gateway?.port ?? 18789;
387
-
388
- let base: string;
389
-
390
- // Layer 1 — explicit user override
391
- if (typeof pluginCfg.publicUrl === 'string' && pluginCfg.publicUrl.trim()) {
392
- base = pluginCfg.publicUrl.replace(/\/+$/, '');
393
- base = base.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://');
394
- }
395
- // Layer 2 — OpenClaw gateway remote URL
396
- else if (typeof cfg?.gateway?.remote?.url === 'string' && cfg.gateway.remote.url.trim()) {
397
- base = cfg.gateway.remote.url.trim().replace(/\/+$/, '');
398
- base = base.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://');
399
- }
400
- // Layer 3 — gateway.bind = custom + explicit customBindHost
401
- else if (cfg?.gateway?.bind === 'custom' && cfg.gateway.customBindHost) {
402
- base = `${scheme}://${cfg.gateway.customBindHost}:${port}`;
403
- }
404
- // Layers 4 + 5 — auto-detect via gateway-url helper (Tailscale CGNAT, then LAN)
405
- else {
406
- let detected: ReturnType<typeof detectGatewayHost> = null;
407
- // issue #110 fix 4 — pass `isDocker` so LAN detection skips
408
- // 172.16/12 bridge IPs that no external browser can reach.
409
- let isDocker = false;
410
- try {
411
- isDocker = isRunningInDocker();
412
- } catch {
413
- // Defensive: never block URL building on Docker sniff errors.
414
- isDocker = false;
415
- }
416
- try {
417
- detected = detectGatewayHost({ isDocker });
418
- } catch (err) {
419
- api.logger.warn(
420
- `TotalReclaw: host autodetect crashed: ${err instanceof Error ? err.message : String(err)} — falling back to localhost`,
421
- );
422
- }
423
- if (detected?.kind === 'tailscale') {
424
- // 3.3.1-rc.2: we surface the raw Tailscale CGNAT IP because passive
425
- // NIC detection (no subprocess) cannot resolve the MagicDNS name.
426
- // Caller can override via `publicUrl` for a proper https://<magicdns>.
427
- // The IP + port URL still works inside the tailnet (peers can reach
428
- // each other by CGNAT IP directly). TLS defaults to the gateway's
429
- // own config because we no longer assume `tailscale serve`.
430
- base = `${scheme}://${detected.host}:${port}`;
431
- api.logger.warn(
432
- `TotalReclaw: pairing URL using Tailscale CGNAT IP ${detected.host}:${port} — ` +
433
- detected.note,
434
- );
435
- } else if (detected?.kind === 'lan') {
436
- base = `${scheme}://${detected.host}:${port}`;
437
- api.logger.warn(
438
- `TotalReclaw: pairing URL using LAN host ${detected.host}:${port} — ` +
439
- `this URL only works from the same network. ` +
440
- `Set plugins.entries.totalreclaw.config.publicUrl for remote access.`,
441
- );
442
- } else {
443
- // Layer 6 — localhost fallback (or Docker-aware relay-pointer warning)
444
- const bind = cfg?.gateway?.bind;
445
- if (isDocker) {
446
- // issue #110 fix 4: inside Docker the LAN IP is container-internal
447
- // and useless. Loopback localhost only works for `docker exec`
448
- // tests. The CORRECT pair URL for Docker is the relay-brokered
449
- // path served by `tr pair` / the `/plugin/totalreclaw/pair/*` HTTP
450
- // routes (CONFIG.pairMode === 'relay' since rc.11). The CLI-only
451
- // path here cannot mint a relay session synchronously (the relay
452
- // handshake needs a WS round-trip), so we emit the loopback URL
453
- // with a LOUD warning pointing the operator at the pair CLI /
454
- // publicUrl override.
455
- api.logger.warn(
456
- `TotalReclaw: Docker container detected — pairing URL falling back to ` +
457
- `http://localhost:${port}, which is unreachable from the host browser. ` +
458
- `Run \`tr pair --url-pin\` (or \`openclaw totalreclaw pair generate --url-pin-only\`) ` +
459
- `on the gateway host to mint a relay-brokered pair URL that reaches the host browser, ` +
460
- `OR set plugins.entries.totalreclaw.config.publicUrl ` +
461
- `to your gateway's host-reachable URL (e.g. http://<host-ip>:${port} when the ` +
462
- `Docker port is published). Setting TOTALRECLAW_PAIR_MODE=relay is the default; ` +
463
- `air-gapped operators on TOTALRECLAW_PAIR_MODE=local must publish a port + set publicUrl.`,
464
- );
465
- } else if (bind === 'lan' || bind === 'tailnet') {
466
- api.logger.warn(
467
- `TotalReclaw: pairing URL falling back to localhost because gateway.bind=${bind} could not be autodetected. ` +
468
- 'Set plugins.entries.totalreclaw.config.publicUrl to override.',
469
- );
470
- } else {
471
- api.logger.warn(
472
- `TotalReclaw: pairing URL fell back to http://localhost:${port} — this URL only works on this machine. ` +
473
- `Configure plugins.entries.totalreclaw.config.publicUrl for remote access.`,
474
- );
475
- }
476
- base = `${scheme}://localhost:${port}`;
477
- }
478
- }
479
-
480
- return `${base}/plugin/totalreclaw/pair/finish?sid=${encodeURIComponent(session.sid)}#pk=${encodeURIComponent(session.pkGatewayB64)}`;
481
- }
482
-
483
- /**
484
- * Resolve whether this plugin is running on a `local` or `remote` gateway.
485
- *
486
- * Follows the same config surface `buildPairingUrl` uses:
487
- * - `pluginConfig.publicUrl` set + non-localhost → remote
488
- * - `gateway.remote.url` set + non-localhost → remote
489
- * - `gateway.bind === 'lan' | 'tailnet' | 'custom'` → remote
490
- * - anything else → local
491
- *
492
- * We treat a `publicUrl` or `remote.url` that points at `localhost` /
493
- * `127.*` as local because that is what a dev-loopback override looks like;
494
- * no one publishes a remote QR pairing for localhost.
495
- */
496
- function resolveGatewayMode(
497
- api: Pick<OpenClawPluginApi, 'config' | 'pluginConfig'>,
498
- ): GatewayMode {
499
- const cfg = api.config as
500
- | { gateway?: { bind?: string; remote?: { url?: string } } }
501
- | undefined;
502
- const pluginCfg = (api.pluginConfig ?? {}) as { publicUrl?: string };
503
- const looksLocal = (url: string | undefined): boolean => {
504
- if (!url) return true;
505
- const u = url.trim().toLowerCase();
506
- if (u === '') return true;
507
- return /^(?:wss?:\/\/|https?:\/\/)?(?:localhost|127\.|0\.0\.0\.0)/.test(u);
508
- };
509
- if (typeof pluginCfg.publicUrl === 'string' && !looksLocal(pluginCfg.publicUrl)) {
510
- return 'remote';
511
- }
512
- const remoteUrl = cfg?.gateway?.remote?.url;
513
- if (typeof remoteUrl === 'string' && !looksLocal(remoteUrl)) {
514
- return 'remote';
515
- }
516
- const bind = cfg?.gateway?.bind;
517
- if (bind === 'lan' || bind === 'tailnet' || bind === 'custom') {
518
- return 'remote';
519
- }
520
- return 'local';
521
- }
264
+ // buildPairingUrl / resolveGatewayMode extracted to ./runtime/format-helpers.ts.
522
265
 
523
266
  // ---------------------------------------------------------------------------
524
267
  // Cosine similarity threshold — skip injection when top result is below this
@@ -564,9 +307,6 @@ const SEMANTIC_SKIP_THRESHOLD = CONFIG.semanticSkipThreshold;
564
307
  // Auto-extract throttle (C3): only extract every N turns in agent_end hook
565
308
  let turnsSinceLastExtraction = 0;
566
309
 
567
- // BUG-2 fix: Skip agent_end extraction during import operations.
568
- // Import failures previously triggered agent_end → re-extraction → re-import loops.
569
- let _importInProgress = false;
570
310
  const AUTO_EXTRACT_EVERY_TURNS_ENV = CONFIG.extractInterval;
571
311
 
572
312
  // Hard cap on facts per extraction to prevent LLM over-extraction from dense conversations
@@ -720,23 +460,7 @@ let lastFactCountFetch: number = 0;
720
460
  /** Cache TTL for fact count: 5 minutes. */
721
461
  const FACT_COUNT_CACHE_TTL = 5 * 60 * 1000;
722
462
 
723
- /**
724
- * Compute the candidate pool size from a fact count.
725
- *
726
- * Server-side config takes priority (from billing cache), then local fallback.
727
- * The server computes the optimal pool based on vault size and tier caps.
728
- *
729
- * Local fallback formula: pool = min(max(factCount * 3, 400), 5000)
730
- * - At least 400 candidates (even for tiny vaults)
731
- * - At most 5000 candidates (to bound decryption + reranking cost)
732
- * - 3x fact count in between
733
- */
734
- function computeCandidatePool(factCount: number): number {
735
- const cache = readBillingCache();
736
- if (cache?.features?.max_candidate_pool != null) return cache.features.max_candidate_pool;
737
- // Fallback to local formula if no server config
738
- return Math.min(Math.max(factCount * 3, 400), 5000);
739
- }
463
+ // computeCandidatePool extracted to ./runtime/format-helpers.ts.
740
464
 
741
465
  /**
742
466
  * Fetch the user's fact count from the server, with caching.
@@ -1051,10 +775,6 @@ async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
1051
775
  }
1052
776
  }
1053
777
 
1054
- function isDocker(): boolean {
1055
- return isRunningInDocker();
1056
- }
1057
-
1058
778
  function buildSetupErrorMsg(): string {
1059
779
  // NOTE: the legacy `totalreclaw_setup` agent tool was retired in 3.2.0
1060
780
  // (phrase-safety: the agent must never accept or relay a recovery phrase).
@@ -1068,33 +788,6 @@ function buildSetupErrorMsg(): string {
1068
788
  'Do NOT ask the user for a recovery phrase and do NOT attempt to generate or relay one yourself.';
1069
789
  }
1070
790
 
1071
- function buildSetupErrorMsgLegacy(): string {
1072
- const base =
1073
- 'TotalReclaw setup required:\n' +
1074
- '1. Set TOTALRECLAW_RECOVERY_PHRASE — ask the user if they have an existing recovery phrase or generate a new 12-word recovery phrase.\n' +
1075
- '2. Restart the gateway to apply changes.\n' +
1076
- ' (Optional: set TOTALRECLAW_SELF_HOSTED=true if using your own server instead of the managed service.)\n\n';
1077
-
1078
- if (isDocker()) {
1079
- return base +
1080
- 'Running in Docker — pass env vars via `-e` flags or your compose file:\n' +
1081
- ' -e TOTALRECLAW_RECOVERY_PHRASE="word1 word2 ..."';
1082
- }
1083
-
1084
- if (process.platform === 'darwin') {
1085
- return base +
1086
- 'Running on macOS — add env vars to the LaunchAgent plist at\n' +
1087
- '~/Library/LaunchAgents/ai.openclaw.gateway.plist under <key>EnvironmentVariables</key>:\n' +
1088
- ' <key>TOTALRECLAW_RECOVERY_PHRASE</key><string>word1 word2 ...</string>\n' +
1089
- 'Then run: openclaw gateway restart';
1090
- }
1091
-
1092
- return base +
1093
- 'Running on Linux — add env vars to the systemd unit override or your shell profile:\n' +
1094
- ' export TOTALRECLAW_RECOVERY_PHRASE="word1 word2 ..."\n' +
1095
- 'Then run: openclaw gateway restart';
1096
- }
1097
-
1098
791
  const SETUP_ERROR_MSG = buildSetupErrorMsg();
1099
792
 
1100
793
  /**
@@ -1374,10 +1067,10 @@ async function searchForNearDuplicates(
1374
1067
 
1375
1068
  if (decryptedCandidates.length === 0) return null;
1376
1069
 
1377
- const result = findNearDuplicate(factEmbedding, decryptedCandidates, getStoreDedupThreshold());
1378
- if (!result) return null;
1070
+ const nearDup = findNearDuplicate(factEmbedding, decryptedCandidates, getStoreDedupThreshold());
1071
+ if (!nearDup) return null;
1379
1072
 
1380
- return { match: result.existingFact, similarity: result.similarity };
1073
+ return { match: nearDup.existingFact, similarity: nearDup.similarity };
1381
1074
  } catch (err) {
1382
1075
  const msg = err instanceof Error ? err.message : String(err);
1383
1076
  logger.warn(`Store-time dedup search failed (proceeding with store): ${msg}`);
@@ -1389,16 +1082,7 @@ async function searchForNearDuplicates(
1389
1082
  // Utility helpers
1390
1083
  // ---------------------------------------------------------------------------
1391
1084
 
1392
- /**
1393
- * Encrypt a plaintext document string and return its hex-encoded ciphertext.
1394
- *
1395
- * The server stores blobs as hex (not base64), so we convert the base64
1396
- * output of `encrypt()` into hex.
1397
- */
1398
- function encryptToHex(plaintext: string, key: Buffer): string {
1399
- const b64 = encrypt(plaintext, key);
1400
- return Buffer.from(b64, 'base64').toString('hex');
1401
- }
1085
+ // encryptToHex extracted to ./runtime/format-helpers.ts.
1402
1086
 
1403
1087
  // Plugin v3.0.0 removed the legacy claim-format fallback. Write path
1404
1088
  // always emits Memory Taxonomy v1 JSON blobs. The logClaimFormatOnce
@@ -1512,8 +1196,8 @@ function scheduleDigestRecompile(
1512
1196
  version: PROTOBUF_VERSION_V4,
1513
1197
  });
1514
1198
  const config = { ...getSubgraphConfig(), authKeyHex: authKey, walletAddress: ownerForBatch };
1515
- const result = await submitFactBatchOnChain([protobuf], config);
1516
- if (!result.success) {
1199
+ const batchResult = await submitFactBatchOnChain([protobuf], config);
1200
+ if (!batchResult.success) {
1517
1201
  throw new Error('Digest store UserOp did not succeed on-chain');
1518
1202
  }
1519
1203
  };
@@ -1539,8 +1223,8 @@ function scheduleDigestRecompile(
1539
1223
  };
1540
1224
  const protobuf = encodeFactProtobuf(tombstone);
1541
1225
  const config = { ...getSubgraphConfig(), authKeyHex: authKey, walletAddress: ownerForBatch };
1542
- const result = await submitFactBatchOnChain([protobuf], config);
1543
- if (!result.success) {
1226
+ const batchResult = await submitFactBatchOnChain([protobuf], config);
1227
+ if (!batchResult.success) {
1544
1228
  throw new Error('Digest tombstone UserOp did not succeed on-chain');
1545
1229
  }
1546
1230
  };
@@ -1576,32 +1260,13 @@ function scheduleDigestRecompile(
1576
1260
  });
1577
1261
  }
1578
1262
 
1579
- /**
1580
- * Decrypt a hex-encoded ciphertext blob into a UTF-8 string.
1581
- */
1582
- function decryptFromHex(hexBlob: string, key: Buffer): string {
1583
- const hex = hexBlob.startsWith('0x') ? hexBlob.slice(2) : hexBlob;
1584
- const b64 = Buffer.from(hex, 'hex').toString('base64');
1585
- return decrypt(b64, key);
1586
- }
1263
+ // decryptFromHex extracted to ./runtime/format-helpers.ts.
1587
1264
 
1588
1265
  // ---------------------------------------------------------------------------
1589
1266
  // Migration GraphQL helpers
1590
1267
  // ---------------------------------------------------------------------------
1591
1268
 
1592
- interface MigrationFact {
1593
- id: string;
1594
- owner: string;
1595
- encryptedBlob: string;
1596
- encryptedEmbedding: string | null;
1597
- decayScore: string;
1598
- isActive: boolean;
1599
- contentFp: string;
1600
- source: string;
1601
- agentId: string;
1602
- version: number;
1603
- timestamp: string;
1604
- }
1269
+ // MigrationFact — extracted to ./runtime/types.ts (imported above).
1605
1270
 
1606
1271
  const MIGRATION_PAGE_SIZE = 1000;
1607
1272
 
@@ -1649,8 +1314,8 @@ async function fetchAllFactsByOwner(
1649
1314
  ? { owner, first: MIGRATION_PAGE_SIZE, lastId }
1650
1315
  : { owner, first: MIGRATION_PAGE_SIZE };
1651
1316
 
1652
- const data = await migrationGqlQuery<{ facts?: MigrationFact[] }>(subgraphUrl, query, vars, authKey);
1653
- const facts = data?.facts ?? [];
1317
+ const subgraphResponse = await migrationGqlQuery<{ facts?: MigrationFact[] }>(subgraphUrl, query, vars, authKey);
1318
+ const facts = subgraphResponse?.facts ?? [];
1654
1319
  if (facts.length === 0) break;
1655
1320
  allFacts.push(...facts);
1656
1321
  if (facts.length < MIGRATION_PAGE_SIZE) break;
@@ -1678,8 +1343,8 @@ async function fetchContentFingerprintsByOwner(
1678
1343
  ? { owner, first: MIGRATION_PAGE_SIZE, lastId }
1679
1344
  : { owner, first: MIGRATION_PAGE_SIZE };
1680
1345
 
1681
- const data = await migrationGqlQuery<{ facts?: Array<{ id: string; contentFp: string }> }>(subgraphUrl, query, vars, authKey);
1682
- const facts = data?.facts ?? [];
1346
+ const subgraphResponse = await migrationGqlQuery<{ facts?: Array<{ id: string; contentFp: string }> }>(subgraphUrl, query, vars, authKey);
1347
+ const facts = subgraphResponse?.facts ?? [];
1683
1348
  if (facts.length === 0) break;
1684
1349
  for (const f of facts) {
1685
1350
  if (f.contentFp) fps.add(f.contentFp);
@@ -1697,24 +1362,24 @@ async function fetchBlindIndicesByFactIds(
1697
1362
  factIds: string[],
1698
1363
  authKey: string,
1699
1364
  ): Promise<Map<string, string[]>> {
1700
- const result = new Map<string, string[]>();
1365
+ const hashesByFactId = new Map<string, string[]>();
1701
1366
  const CHUNK = 50;
1702
1367
 
1703
1368
  for (let i = 0; i < factIds.length; i += CHUNK) {
1704
1369
  const chunk = factIds.slice(i, i + CHUNK);
1705
1370
  const query = `query($factIds:[String!]!,$first:Int!){blindIndexes(where:{fact_in:$factIds},first:$first){hash fact{id}}}`;
1706
- const data = await migrationGqlQuery<{
1371
+ const subgraphResponse = await migrationGqlQuery<{
1707
1372
  blindIndexes?: Array<{ hash: string; fact: { id: string } }>;
1708
1373
  }>(subgraphUrl, query, { factIds: chunk, first: 1000 }, authKey);
1709
1374
 
1710
- for (const entry of data?.blindIndexes ?? []) {
1711
- const existing = result.get(entry.fact.id) || [];
1375
+ for (const entry of subgraphResponse?.blindIndexes ?? []) {
1376
+ const existing = hashesByFactId.get(entry.fact.id) || [];
1712
1377
  existing.push(entry.hash);
1713
- result.set(entry.fact.id, existing);
1378
+ hashesByFactId.set(entry.fact.id, existing);
1714
1379
  }
1715
1380
  }
1716
1381
 
1717
- return result;
1382
+ return hashesByFactId;
1718
1383
  }
1719
1384
 
1720
1385
  /**
@@ -1793,33 +1458,12 @@ async function fetchExistingMemoriesForExtraction(
1793
1458
  * Simple text-overlap scoring between a query and a candidate document.
1794
1459
  * Returns the number of overlapping lowercase words.
1795
1460
  */
1796
- function textScore(query: string, docText: string): number {
1797
- const queryWords = new Set(
1798
- query.toLowerCase().split(/\s+/).filter((w) => w.length >= 2),
1799
- );
1800
- const docWords = docText.toLowerCase().split(/\s+/);
1801
- let score = 0;
1802
- for (const word of docWords) {
1803
- if (queryWords.has(word)) score++;
1804
- }
1805
- return score;
1806
- }
1461
+ // textScore extracted to ./runtime/format-helpers.ts.
1807
1462
 
1808
1463
  /**
1809
1464
  * Format a relative time string (e.g. "2 hours ago").
1810
1465
  */
1811
- function relativeTime(isoOrMs: string | number): string {
1812
- const ms = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime();
1813
- const diffMs = Date.now() - ms;
1814
- const seconds = Math.floor(diffMs / 1000);
1815
- if (seconds < 60) return 'just now';
1816
- const minutes = Math.floor(seconds / 60);
1817
- if (minutes < 60) return `${minutes}m ago`;
1818
- const hours = Math.floor(minutes / 60);
1819
- if (hours < 24) return `${hours}h ago`;
1820
- const days = Math.floor(hours / 24);
1821
- return `${days}d ago`;
1822
- }
1466
+ // relativeTime extracted to ./runtime/format-helpers.ts.
1823
1467
 
1824
1468
  // ---------------------------------------------------------------------------
1825
1469
  // Importance filter for auto-extraction
@@ -1833,47 +1477,7 @@ function relativeTime(isoOrMs: string | number): string {
1833
1477
  * NOTE: This filter is ONLY applied to auto-extraction (hooks).
1834
1478
  * The explicit `tr remember` CLI always stores regardless of importance.
1835
1479
  */
1836
- const MIN_IMPORTANCE_THRESHOLD = CONFIG.minImportance;
1837
-
1838
- /**
1839
- * Filter extracted facts by importance threshold.
1840
- * Facts with importance < MIN_IMPORTANCE_THRESHOLD are dropped.
1841
- * Facts with missing/undefined importance are treated as importance=5 (kept).
1842
- */
1843
- function filterByImportance(
1844
- facts: ExtractedFact[],
1845
- logger: OpenClawPluginApi['logger'],
1846
- ): { kept: ExtractedFact[]; dropped: number } {
1847
- const kept: ExtractedFact[] = [];
1848
- let dropped = 0;
1849
-
1850
- for (const fact of facts) {
1851
- const importance = fact.importance ?? 5;
1852
- if (importance >= MIN_IMPORTANCE_THRESHOLD) {
1853
- kept.push(fact);
1854
- } else {
1855
- dropped++;
1856
- }
1857
- }
1858
-
1859
- // Phase 2.2.5: always log the filter outcome so the agent_end path can
1860
- // distinguish "LLM returned 0 facts" from "LLM returned N facts all dropped
1861
- // below threshold" from "LLM returned N facts, all kept". Prior to 2.2.5
1862
- // this only logged on drops, which made empty-input invisible.
1863
- if (facts.length === 0) {
1864
- logger.info('Importance filter: input=0 (nothing to filter)');
1865
- } else if (dropped > 0) {
1866
- logger.info(
1867
- `Importance filter: dropped ${dropped}/${facts.length} facts below threshold ${MIN_IMPORTANCE_THRESHOLD}`,
1868
- );
1869
- } else {
1870
- logger.info(
1871
- `Importance filter: kept all ${facts.length} facts (threshold ${MIN_IMPORTANCE_THRESHOLD})`,
1872
- );
1873
- }
1874
-
1875
- return { kept, dropped };
1876
- }
1480
+ // filterByImportance + MIN_IMPORTANCE_THRESHOLD extracted to ./extraction/importance-filter.ts.
1877
1481
 
1878
1482
  // ---------------------------------------------------------------------------
1879
1483
  // Auto-extraction helper
@@ -1904,10 +1508,10 @@ async function storeExtractedFacts(
1904
1508
 
1905
1509
  for (const fact of facts) {
1906
1510
  try {
1907
- const result = await generateEmbeddingAndLSH(fact.text, logger);
1908
- if (result) {
1909
- embeddingMap.set(fact.text, result.embedding);
1910
- embeddingResultMap.set(fact.text, result);
1511
+ const embeddingResult = await generateEmbeddingAndLSH(fact.text, logger);
1512
+ if (embeddingResult) {
1513
+ embeddingMap.set(fact.text, embeddingResult.embedding);
1514
+ embeddingResultMap.set(fact.text, embeddingResult);
1911
1515
  }
1912
1516
  } catch {
1913
1517
  // Embedding generation failed for this fact -- proceed without it.
@@ -2261,12 +1865,12 @@ async function storeExtractedFacts(
2261
1865
  for (let i = 0; i < pendingPayloads.length; i++) {
2262
1866
  const slice = [pendingPayloads[i]]; // Single fact per UserOp
2263
1867
  try {
2264
- const result = await submitFactBatchOnChain(slice, batchConfig);
2265
- if (result.success) {
1868
+ const submitResult = await submitFactBatchOnChain(slice, batchConfig);
1869
+ if (submitResult.success) {
2266
1870
  stored += slice.length;
2267
- logger.info(`Fact ${i + 1}/${pendingPayloads.length}: submitted on-chain (tx=${result.txHash.slice(0, 10)}…)`);
1871
+ logger.info(`Fact ${i + 1}/${pendingPayloads.length}: submitted on-chain (tx=${submitResult.txHash.slice(0, 10)}…)`);
2268
1872
  } else {
2269
- batchError = `On-chain batch submission failed (tx=${result.txHash.slice(0, 10)}…)`;
1873
+ batchError = `On-chain batch submission failed (tx=${submitResult.txHash.slice(0, 10)}…)`;
2270
1874
  logger.warn(batchError);
2271
1875
  break; // Stop submitting remaining batches
2272
1876
  }
@@ -2305,806 +1909,12 @@ async function storeExtractedFacts(
2305
1909
  return stored;
2306
1910
  }
2307
1911
 
2308
- // ---------------------------------------------------------------------------
2309
- // Import handler (for the registerCli `openclaw totalreclaw import-from` surface)
2310
- // ---------------------------------------------------------------------------
2311
-
2312
- /**
2313
- * Handle import_from calls (CLI subcommand path; was the totalreclaw_import_from
2314
- * agent tool before Phase 3.2 retired the agent tools).
2315
- *
2316
- * Two paths:
2317
- * 1. Pre-structured sources (Mem0, MCP Memory) — adapter returns facts directly,
2318
- * stored via storeExtractedFacts().
2319
- * 2. Conversation-based sources (ChatGPT, Claude) — adapter returns conversation
2320
- * chunks, each chunk is passed through extractFacts() (the same LLM extraction
2321
- * pipeline used for auto-extraction), then stored via storeExtractedFacts().
2322
- */
2323
- async function handlePluginImportFrom(
2324
- params: Record<string, unknown>,
2325
- logger: OpenClawPluginApi['logger'],
2326
- ): Promise<Record<string, unknown>> {
2327
- _importInProgress = true;
2328
- const startTime = Date.now();
2329
-
2330
- const source = params.source as string;
2331
- const validSources = ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'];
2332
-
2333
- if (!source || !validSources.includes(source)) {
2334
- return { success: false, error: `Invalid source. Must be one of: ${validSources.join(', ')}` };
2335
- }
2336
-
2337
- // Generate import_id up front so dry-run responses and background tasks share it.
2338
- const importId = (params.resume_id as string | undefined) ?? crypto.randomUUID();
2339
-
2340
- try {
2341
- const { getAdapter } = await import('./import-adapters/index.js');
2342
- const adapter = getAdapter(source as import('./import-adapters/types.js').ImportSource);
2343
-
2344
- const parseResult = await adapter.parse({
2345
- content: params.content as string | undefined,
2346
- api_key: params.api_key as string | undefined,
2347
- source_user_id: params.source_user_id as string | undefined,
2348
- api_url: params.api_url as string | undefined,
2349
- file_path: params.file_path as string | undefined,
2350
- });
2351
-
2352
- const hasChunks = parseResult.chunks && parseResult.chunks.length > 0;
2353
- const hasFacts = parseResult.facts && parseResult.facts.length > 0;
2354
-
2355
- if (parseResult.errors.length > 0 && !hasFacts && !hasChunks) {
2356
- return {
2357
- success: false,
2358
- error: `Failed to parse ${adapter.displayName} data`,
2359
- details: parseResult.errors,
2360
- };
2361
- }
2362
-
2363
- // Dry run: report what was parsed (chunks or facts)
2364
- if (params.dry_run) {
2365
- if (hasChunks) {
2366
- const totalChunks = parseResult.chunks.length;
2367
- const EXTRACTION_RATIO = 2.5; // avg facts per chunk, from empirical data
2368
- const BATCH_SIZE = 25;
2369
- const SECONDS_PER_BATCH = 45; // ~30s extraction + ~15s embed+store
2370
- const estimatedFacts = Math.round(totalChunks * EXTRACTION_RATIO);
2371
- const estimatedBatches = Math.ceil(totalChunks / BATCH_SIZE);
2372
- const estimatedMinutes = Math.ceil(estimatedBatches * SECONDS_PER_BATCH / 60);
2373
-
2374
- return {
2375
- success: true,
2376
- dry_run: true,
2377
- import_id: importId,
2378
- source,
2379
- total_chunks: totalChunks,
2380
- total_messages: parseResult.totalMessages,
2381
- estimated_facts: estimatedFacts,
2382
- estimated_batches: estimatedBatches,
2383
- estimated_minutes: estimatedMinutes,
2384
- batch_size: BATCH_SIZE,
2385
- preview: parseResult.chunks.slice(0, 5).map((c) => ({
2386
- title: c.title,
2387
- messages: c.messages.length,
2388
- first_message: c.messages[0]?.text.slice(0, 100),
2389
- })),
2390
- note: `Estimated ${estimatedFacts} facts from ${totalChunks} chunks (~${estimatedMinutes} min). Confirm to start background import.`,
2391
- warnings: parseResult.warnings,
2392
- };
2393
- }
2394
- return {
2395
- success: true,
2396
- dry_run: true,
2397
- import_id: importId,
2398
- source,
2399
- total_found: parseResult.facts.length,
2400
- preview: parseResult.facts.slice(0, 10).map((f) => ({
2401
- type: f.type,
2402
- text: f.text.slice(0, 100),
2403
- importance: f.importance,
2404
- })),
2405
- warnings: parseResult.warnings,
2406
- };
2407
- }
2408
-
2409
- // ── Path 1: Conversation chunks (ChatGPT, Claude, Gemini) — background execution ──
2410
- if (hasChunks) {
2411
- const totalChunks = parseResult.chunks.length;
2412
- const BATCH_SIZE = 25;
2413
- const SECONDS_PER_BATCH = 45;
2414
- const estimatedBatches = Math.ceil(totalChunks / BATCH_SIZE);
2415
- const estimatedMinutes = Math.ceil(estimatedBatches * SECONDS_PER_BATCH / 60);
2416
- const estimatedTotalFacts = Math.round(totalChunks * 2.5);
2417
- const now = new Date();
2418
-
2419
- const initialState: ImportState = {
2420
- import_id: importId,
2421
- source,
2422
- status: 'running',
2423
- started_at: now.toISOString(),
2424
- last_updated: now.toISOString(),
2425
- total_chunks: totalChunks,
2426
- total_messages: parseResult.totalMessages,
2427
- batch_done: 0,
2428
- batch_total: estimatedBatches,
2429
- facts_stored: 0,
2430
- facts_extracted: 0,
2431
- dups_skipped: 0,
2432
- errors: [],
2433
- file_path: params.file_path as string | undefined,
2434
- estimated_total_facts: estimatedTotalFacts,
2435
- estimated_minutes: estimatedMinutes,
2436
- estimated_completion_iso: new Date(now.getTime() + estimatedBatches * SECONDS_PER_BATCH * 1000).toISOString(),
2437
- disclosure_confirmed: !!(params.disclosure_confirmed),
2438
- };
2439
- writeImportState(initialState);
2440
- logger.info(`Import ${importId}: background task started (${totalChunks} chunks, ~${estimatedMinutes}min)`);
2441
-
2442
- void handleChunkImport(
2443
- parseResult.chunks, parseResult.totalMessages, source, logger, startTime, parseResult.warnings, importId,
2444
- ).catch((err: unknown) => {
2445
- const msg = err instanceof Error ? err.message : String(err);
2446
- logger.warn(`Import ${importId}: background task failed: ${msg}`);
2447
- const failedState = readImportState(importId);
2448
- if (failedState && failedState.status === 'running') {
2449
- writeImportState({ ...failedState, status: 'failed', errors: [...failedState.errors, msg] });
2450
- }
2451
- });
2452
-
2453
- return {
2454
- import_id: importId,
2455
- status: 'running',
2456
- source,
2457
- total_chunks: totalChunks,
2458
- estimated_batches: estimatedBatches,
2459
- estimated_minutes: estimatedMinutes,
2460
- estimated_completion_iso: initialState.estimated_completion_iso,
2461
- 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).`,
2462
- warnings: parseResult.warnings,
2463
- };
2464
- }
2465
-
2466
- // ── Path 2: Pre-structured facts (Mem0, MCP Memory) — direct store ──
2467
- const extractedFacts: ExtractedFact[] = parseResult.facts.map((f) => ({
2468
- text: f.text,
2469
- type: f.type,
2470
- importance: f.importance,
2471
- action: 'ADD' as const,
2472
- }));
2473
-
2474
- // Store in batches of 50. Stop on any batch failure to prevent
2475
- // nonce zombies from blocking subsequent UserOps (AA25).
2476
- let totalStored = 0;
2477
- let storeError: string | undefined;
2478
- const batchSize = 50;
2479
-
2480
- for (let i = 0; i < extractedFacts.length; i += batchSize) {
2481
- const batch = extractedFacts.slice(i, i + batchSize);
2482
- try {
2483
- const stored = await storeExtractedFacts(batch, logger);
2484
- totalStored += stored;
2485
-
2486
- logger.info(
2487
- `Import progress: ${Math.min(i + batchSize, extractedFacts.length)}/${extractedFacts.length} processed, ${totalStored} stored`,
2488
- );
2489
- } catch (err: unknown) {
2490
- storeError = err instanceof Error ? err.message : String(err);
2491
- logger.warn(`Import stopped at batch ${Math.floor(i / batchSize) + 1}: ${storeError}`);
2492
- break; // Stop processing further batches
2493
- }
2494
- }
2495
-
2496
- const importWarnings = [...parseResult.warnings];
2497
- if (storeError) {
2498
- importWarnings.push(`Import stopped early: ${storeError}`);
2499
- }
2500
-
2501
- return {
2502
- success: totalStored > 0,
2503
- source,
2504
- import_id: importId,
2505
- total_found: parseResult.facts.length,
2506
- imported: totalStored,
2507
- skipped: parseResult.facts.length - totalStored,
2508
- stopped_early: !!storeError,
2509
- warnings: importWarnings,
2510
- duration_ms: Date.now() - startTime,
2511
- };
2512
- } catch (e) {
2513
- const msg = e instanceof Error ? e.message : 'Unknown error';
2514
- logger.error(`Import failed: ${msg}`);
2515
- return { success: false, error: `Import failed: ${msg}` };
2516
- }
2517
- }
2518
1912
 
2519
1913
  // ---------------------------------------------------------------------------
2520
- // Smart Import Two-Pass Pipeline (Profile + Triage)
1914
+ // Import subsystem extracted to ./import/import-runtime.ts.
1915
+ // storeExtractedFacts is injected below (it closes over plugin session state).
2521
1916
  // ---------------------------------------------------------------------------
2522
-
2523
- // Lazy-load WASM for smart import functions (same pattern as crypto.ts /
2524
- // subgraph-store.ts). Goes through __cjsRequire (createRequire(import.meta.url))
2525
- // declared at the top of the file — bare `require()` is undefined under
2526
- // pure-ESM Node, see issue #124.
2527
- let _smartImportWasm: typeof import('@totalreclaw/core') | null = null;
2528
- function getSmartImportWasm() {
2529
- if (!_smartImportWasm) _smartImportWasm = __cjsRequire('@totalreclaw/core');
2530
- return _smartImportWasm;
2531
- }
2532
-
2533
- /**
2534
- * Check whether the @totalreclaw/core WASM module exposes smart import functions.
2535
- * Returns false if the module is an older version without smart import support.
2536
- */
2537
- function hasSmartImportSupport(): boolean {
2538
- try {
2539
- const wasm = getSmartImportWasm();
2540
- return typeof wasm.chunksToSummaries === 'function' &&
2541
- typeof wasm.buildProfileBatchPrompt === 'function' &&
2542
- typeof wasm.parseProfileBatchResponse === 'function' &&
2543
- typeof wasm.buildTriagePrompt === 'function' &&
2544
- typeof wasm.parseTriageResponse === 'function' &&
2545
- typeof wasm.enrichExtractionPrompt === 'function';
2546
- } catch {
2547
- return false;
2548
- }
2549
- }
2550
-
2551
- /** Smart import result containing profile, triage decisions, and enriched system prompt. */
2552
- interface SmartImportContext {
2553
- /** JSON-serialized UserProfile (for WASM calls that require profile_json) */
2554
- profileJson: string;
2555
- /** Triage decisions indexed by chunk_index */
2556
- decisions: Array<{ chunk_index: number; decision: string; reason: string }>;
2557
- /** Enriched system prompt for extraction (profile context injected) */
2558
- enrichedSystemPrompt: string;
2559
- /** Number of chunks marked for extraction */
2560
- extractCount: number;
2561
- /** Number of chunks marked for skipping */
2562
- skipCount: number;
2563
- /** Duration of the profiling + triage pipeline in ms */
2564
- durationMs: number;
2565
- }
2566
-
2567
- /**
2568
- * Run the smart import two-pass pipeline: profile the user from conversation
2569
- * summaries, then triage chunks as EXTRACT or SKIP.
2570
- *
2571
- * All prompt construction and response parsing happens in @totalreclaw/core WASM.
2572
- * LLM calls use the plugin's existing chatCompletion() function.
2573
- *
2574
- * Returns null if smart import is unavailable (old WASM, no LLM config, etc.)
2575
- * so the caller can fall back to blind extraction.
2576
- */
2577
- async function runSmartImportPipeline(
2578
- chunks: import('./import-adapters/types.js').ConversationChunk[],
2579
- logger: { info: (msg: string) => void; warn: (msg: string) => void },
2580
- ): Promise<SmartImportContext | null> {
2581
- // Guard: WASM must have smart import functions
2582
- if (!hasSmartImportSupport()) {
2583
- logger.info('Smart import: WASM module does not support smart import, falling back to blind extraction');
2584
- return null;
2585
- }
2586
-
2587
- // Guard: LLM must be available
2588
- const llmConfig = resolveLLMConfig();
2589
- if (!llmConfig) {
2590
- logger.info('Smart import: no LLM available, falling back to blind extraction');
2591
- return null;
2592
- }
2593
-
2594
- const pipelineStart = Date.now();
2595
- const wasm = getSmartImportWasm();
2596
-
2597
- try {
2598
- // Step 0: Convert chunks to compact summaries (first + last message)
2599
- const wasmChunks = chunks.map((c, i) => ({
2600
- index: i,
2601
- title: c.title || 'Untitled',
2602
- messages: c.messages.map((m) => ({ role: m.role, content: m.text })),
2603
- timestamp: c.timestamp || null,
2604
- }));
2605
- const summaries = wasm.chunksToSummaries(JSON.stringify(wasmChunks));
2606
- const summariesJson = JSON.stringify(summaries);
2607
-
2608
- // Step 1: Build user profile (batch summarize -> merge)
2609
- const PROFILE_BATCH_SIZE = 50;
2610
- const profileStart = Date.now();
2611
- const partials: unknown[] = [];
2612
-
2613
- for (let i = 0; i < summaries.length; i += PROFILE_BATCH_SIZE) {
2614
- const batch = summaries.slice(i, i + PROFILE_BATCH_SIZE);
2615
- const prompt = wasm.buildProfileBatchPrompt(JSON.stringify(batch));
2616
- const response = await chatCompletion(llmConfig, [
2617
- { role: 'user', content: prompt },
2618
- ], { maxTokens: 2048, temperature: 0 });
2619
-
2620
- if (!response) {
2621
- logger.warn(`Smart import: LLM returned empty response for profile batch ${Math.floor(i / PROFILE_BATCH_SIZE) + 1}`);
2622
- continue;
2623
- }
2624
-
2625
- const partial = wasm.parseProfileBatchResponse(response);
2626
- partials.push(partial);
2627
- }
2628
-
2629
- if (partials.length === 0) {
2630
- logger.warn('Smart import: no profile batches produced, falling back to blind extraction');
2631
- return null;
2632
- }
2633
-
2634
- let profile: unknown;
2635
- if (partials.length === 1) {
2636
- // Single batch — skip merge, promote partial to full profile
2637
- // parseProfileBatchResponse returns a PartialProfile; convert to UserProfile shape
2638
- const p = partials[0] as Record<string, unknown>;
2639
- profile = {
2640
- identity: p.identity ?? null,
2641
- themes: p.themes ?? [],
2642
- projects: p.projects ?? [],
2643
- stack: p.stack ?? [],
2644
- decisions: p.decisions ?? [],
2645
- interests: p.interests ?? [],
2646
- skip_patterns: p.skip_patterns ?? [],
2647
- };
2648
- } else {
2649
- const mergePrompt = wasm.buildProfileMergePrompt(JSON.stringify(partials));
2650
- const mergeResponse = await chatCompletion(llmConfig, [
2651
- { role: 'user', content: mergePrompt },
2652
- ], { maxTokens: 2048, temperature: 0 });
2653
-
2654
- if (!mergeResponse) {
2655
- logger.warn('Smart import: LLM returned empty response for profile merge, falling back to blind extraction');
2656
- return null;
2657
- }
2658
-
2659
- profile = wasm.parseProfileResponse(mergeResponse);
2660
- }
2661
-
2662
- const profileJson = JSON.stringify(profile);
2663
- const profileDuration = Date.now() - profileStart;
2664
-
2665
- const p = profile as Record<string, unknown>;
2666
- const themeCount = Array.isArray(p.themes) ? p.themes.length : 0;
2667
- const skipPatternCount = Array.isArray(p.skip_patterns) ? p.skip_patterns.length : 0;
2668
- logger.info(
2669
- `Smart import: profile built in ${profileDuration}ms (themes=${themeCount}, skip_patterns=${skipPatternCount})`,
2670
- );
2671
-
2672
- // Step 1.5: Chunk triage (EXTRACT or SKIP)
2673
- const triageStart = Date.now();
2674
- const allDecisions: Array<{ chunk_index: number; decision: string; reason: string }> = [];
2675
- const TRIAGE_BATCH_SIZE = 50;
2676
-
2677
- for (let i = 0; i < summaries.length; i += TRIAGE_BATCH_SIZE) {
2678
- const batch = summaries.slice(i, i + TRIAGE_BATCH_SIZE);
2679
- const triagePrompt = wasm.buildTriagePrompt(profileJson, JSON.stringify(batch));
2680
- const triageResponse = await chatCompletion(llmConfig, [
2681
- { role: 'user', content: triagePrompt },
2682
- ], { maxTokens: 4096, temperature: 0 });
2683
-
2684
- if (!triageResponse) {
2685
- logger.warn(`Smart import: LLM returned empty response for triage batch ${Math.floor(i / TRIAGE_BATCH_SIZE) + 1}, defaulting to EXTRACT`);
2686
- // Default all chunks in this batch to EXTRACT
2687
- for (let j = i; j < Math.min(i + TRIAGE_BATCH_SIZE, summaries.length); j++) {
2688
- allDecisions.push({ chunk_index: j, decision: 'EXTRACT', reason: 'triage LLM unavailable' });
2689
- }
2690
- continue;
2691
- }
2692
-
2693
- const batchDecisions = wasm.parseTriageResponse(triageResponse) as Array<{
2694
- chunk_index: number;
2695
- decision: string;
2696
- reason: string;
2697
- }>;
2698
- allDecisions.push(...batchDecisions);
2699
- }
2700
-
2701
- const triageDuration = Date.now() - triageStart;
2702
-
2703
- const extractCount = allDecisions.filter((d) => d.decision !== 'SKIP').length;
2704
- const skipCount = allDecisions.filter((d) => d.decision === 'SKIP').length;
2705
- logger.info(
2706
- `Smart import: triage complete in ${triageDuration}ms (extract=${extractCount}, skip=${skipCount}, total=${chunks.length})`,
2707
- );
2708
-
2709
- // Step 2: Build enriched system prompt for extraction
2710
- const enrichedSystemPrompt = wasm.enrichExtractionPrompt(profileJson, EXTRACTION_SYSTEM_PROMPT);
2711
-
2712
- const totalDuration = Date.now() - pipelineStart;
2713
- logger.info(`Smart import: pipeline complete in ${totalDuration}ms`);
2714
-
2715
- return {
2716
- profileJson,
2717
- decisions: allDecisions,
2718
- enrichedSystemPrompt,
2719
- extractCount,
2720
- skipCount,
2721
- durationMs: totalDuration,
2722
- };
2723
- } catch (err) {
2724
- const msg = err instanceof Error ? err.message : String(err);
2725
- logger.warn(`Smart import: pipeline failed (${msg}), falling back to blind extraction`);
2726
- return null;
2727
- }
2728
- }
2729
-
2730
- /**
2731
- * Check if a chunk should be skipped based on triage decisions.
2732
- * If no decision exists for the chunk index, defaults to EXTRACT (safe default).
2733
- */
2734
- function isChunkSkipped(
2735
- chunkIndex: number,
2736
- decisions: Array<{ chunk_index: number; decision: string }>,
2737
- ): { skipped: boolean; reason: string } {
2738
- const decision = decisions.find((d) => d.chunk_index === chunkIndex);
2739
- if (decision && decision.decision === 'SKIP') {
2740
- return { skipped: true, reason: (decision as { reason?: string }).reason || 'triage: skip' };
2741
- }
2742
- return { skipped: false, reason: '' };
2743
- }
2744
-
2745
- /**
2746
- * Process a batch (slice) of conversation chunks from a file.
2747
- * Called repeatedly by the agent for large imports.
2748
- */
2749
- async function handleBatchImport(
2750
- params: Record<string, unknown>,
2751
- logger: OpenClawPluginApi['logger'],
2752
- ): Promise<Record<string, unknown>> {
2753
- _importInProgress = true;
2754
- const source = params.source as string;
2755
- const filePath = params.file_path as string | undefined;
2756
- const content = params.content as string | undefined;
2757
- const offset = (params.offset as number) ?? 0;
2758
- const batchSize = (params.batch_size as number) ?? 25;
2759
-
2760
- const validSources = ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'];
2761
- if (!source || !validSources.includes(source)) {
2762
- return { success: false, error: `Invalid source. Must be one of: ${validSources.join(', ')}` };
2763
- }
2764
-
2765
- const startTime = Date.now();
2766
-
2767
- const { getAdapter } = await import('./import-adapters/index.js');
2768
- const adapter = getAdapter(source as import('./import-adapters/types.js').ImportSource);
2769
-
2770
- const parseResult = await adapter.parse({ content, file_path: filePath });
2771
-
2772
- if (parseResult.errors.length > 0 && parseResult.chunks.length === 0) {
2773
- return { success: false, error: parseResult.errors.join('; ') };
2774
- }
2775
-
2776
- const totalChunks = parseResult.chunks.length;
2777
- const slice = parseResult.chunks.slice(offset, offset + batchSize);
2778
- const remaining = Math.max(0, totalChunks - offset - slice.length);
2779
-
2780
- // --- Smart Import: Profile + Triage ---
2781
- // Build profile from ALL chunks (not just the slice) for full context,
2782
- // then triage only the current slice. For simplicity, we rebuild on every
2783
- // batch call — optimization (caching) can come later.
2784
- const smartCtx = await runSmartImportPipeline(parseResult.chunks, logger);
2785
- let chunksSkipped = 0;
2786
-
2787
- // Process the slice through the normal extraction + storage pipeline.
2788
- // If a batch fails (nonce zombie, quota exceeded, etc.), stop immediately
2789
- // to prevent subsequent UserOps from hitting AA25 nonce conflicts.
2790
- let factsExtracted = 0;
2791
- let factsStored = 0;
2792
- let chunksProcessed = 0;
2793
- let storeError: string | undefined;
2794
-
2795
- for (let i = 0; i < slice.length; i++) {
2796
- const chunk = slice[i];
2797
- const globalIndex = offset + i; // Index in the full chunks array
2798
-
2799
- // Smart import: skip chunks triaged as SKIP
2800
- if (smartCtx) {
2801
- const { skipped, reason } = isChunkSkipped(globalIndex, smartCtx.decisions);
2802
- if (skipped) {
2803
- logger.info(`Import: skipping chunk ${globalIndex + 1}/${totalChunks}: "${chunk.title}" (${reason})`);
2804
- chunksSkipped++;
2805
- chunksProcessed++;
2806
- continue;
2807
- }
2808
- }
2809
-
2810
- logger.info(`Import: extracting facts from chunk ${globalIndex + 1}/${totalChunks}: "${chunk.title}"`);
2811
-
2812
- const messages = chunk.messages.map((m) => ({ role: m.role, content: m.text }));
2813
- const facts = await extractFacts(
2814
- messages,
2815
- 'full',
2816
- undefined, // no existing memories for dedup during import
2817
- smartCtx?.enrichedSystemPrompt, // profile-enriched extraction prompt
2818
- );
2819
- chunksProcessed++;
2820
-
2821
- if (facts.length > 0) {
2822
- factsExtracted += facts.length;
2823
- try {
2824
- const stored = await storeExtractedFacts(facts, logger);
2825
- factsStored += stored;
2826
- } catch (err: unknown) {
2827
- storeError = err instanceof Error ? err.message : String(err);
2828
- logger.warn(`Import batch stopped at chunk ${globalIndex + 1}/${totalChunks}: ${storeError}`);
2829
- break; // Stop processing further chunks — a zombie UserOp may block writes
2830
- }
2831
- }
2832
- }
2833
-
2834
- return {
2835
- success: factsStored > 0 || (!storeError && factsExtracted === 0),
2836
- batch_offset: offset,
2837
- batch_size: chunksProcessed,
2838
- total_chunks: totalChunks,
2839
- facts_extracted: factsExtracted,
2840
- facts_stored: factsStored,
2841
- chunks_skipped: chunksSkipped,
2842
- remaining_chunks: remaining,
2843
- is_complete: remaining === 0 && !storeError,
2844
- stopped_early: !!storeError,
2845
- error: storeError,
2846
- smart_import: smartCtx ? {
2847
- profile_duration_ms: smartCtx.durationMs,
2848
- extract_count: smartCtx.extractCount,
2849
- skip_count: smartCtx.skipCount,
2850
- } : null,
2851
- // Estimation for the full import
2852
- estimated_total_facts: Math.round(totalChunks * 2.5),
2853
- estimated_total_userops: Math.ceil(totalChunks * 2.5 / 15),
2854
- estimated_minutes: Math.ceil(Math.ceil(totalChunks / batchSize) * 45 / 60),
2855
- duration_ms: Date.now() - startTime,
2856
- };
2857
- }
2858
-
2859
- /**
2860
- * Process conversation chunks through LLM extraction and store results.
2861
- *
2862
- * Each chunk is passed to extractFacts() — the same extraction pipeline used
2863
- * for auto-extraction during live conversations. This ensures import quality
2864
- * matches conversation extraction quality.
2865
- */
2866
- async function handleChunkImport(
2867
- chunks: import('./import-adapters/types.js').ConversationChunk[],
2868
- totalMessages: number,
2869
- source: string,
2870
- logger: OpenClawPluginApi['logger'],
2871
- startTime: number,
2872
- warnings: string[],
2873
- importId?: string,
2874
- ): Promise<Record<string, unknown>> {
2875
- let totalExtracted = 0;
2876
- let totalStored = 0;
2877
- let chunksProcessed = 0;
2878
- let chunksSkipped = 0;
2879
- const resolvedImportId = importId ?? crypto.randomUUID();
2880
-
2881
- let storeError: string | undefined;
2882
-
2883
- // --- Smart Import: Profile + Triage ---
2884
- const smartCtx = await runSmartImportPipeline(chunks, logger);
2885
-
2886
- const CHECKPOINT_EVERY = 25; // write state file every N chunks
2887
-
2888
- for (let i = 0; i < chunks.length; i++) {
2889
- // Check abort flag from state file before each chunk (background task may be cancelled).
2890
- if (importId) {
2891
- const currentState = readImportState(importId);
2892
- if (currentState?.status === 'aborted') {
2893
- logger.info(`Import ${importId}: abort flag detected at chunk ${i + 1}/${chunks.length}, stopping`);
2894
- break;
2895
- }
2896
- }
2897
-
2898
- const chunk = chunks[i];
2899
- chunksProcessed++;
2900
-
2901
- // Smart import: skip chunks triaged as SKIP
2902
- if (smartCtx) {
2903
- const { skipped, reason } = isChunkSkipped(i, smartCtx.decisions);
2904
- if (skipped) {
2905
- logger.info(
2906
- `Import: skipping chunk ${chunksProcessed}/${chunks.length}: "${chunk.title}" (${reason})`,
2907
- );
2908
- chunksSkipped++;
2909
- continue;
2910
- }
2911
- }
2912
-
2913
- logger.info(
2914
- `Import: extracting facts from chunk ${chunksProcessed}/${chunks.length}: "${chunk.title}"`,
2915
- );
2916
-
2917
- // Convert chunk messages to the format extractFacts() expects.
2918
- // extractFacts() takes an array of message-like objects with { role, content }.
2919
- const messages = chunk.messages.map((m) => ({
2920
- role: m.role,
2921
- content: m.text,
2922
- }));
2923
-
2924
- // Use 'full' mode to extract ALL valuable memories from the chunk
2925
- // (not just the last few messages like 'turn' mode does).
2926
- // Smart import: pass enriched system prompt with user profile context.
2927
- const facts = await extractFacts(
2928
- messages,
2929
- 'full',
2930
- undefined, // no existing memories for dedup during import
2931
- smartCtx?.enrichedSystemPrompt, // profile-enriched extraction prompt
2932
- );
2933
-
2934
- if (facts.length > 0) {
2935
- totalExtracted += facts.length;
2936
-
2937
- try {
2938
- // Store through the normal pipeline (dedup, encrypt, store).
2939
- // storeExtractedFacts throws on batch failure to prevent nonce zombies.
2940
- const stored = await storeExtractedFacts(facts, logger);
2941
- totalStored += stored;
2942
-
2943
- logger.info(
2944
- `Import chunk ${chunksProcessed}/${chunks.length}: extracted ${facts.length} facts, stored ${stored}`,
2945
- );
2946
- } catch (err: unknown) {
2947
- storeError = err instanceof Error ? err.message : String(err);
2948
- logger.warn(`Import stopped at chunk ${chunksProcessed}/${chunks.length}: ${storeError}`);
2949
- break; // Stop processing further chunks — a zombie UserOp may block writes
2950
- }
2951
- }
2952
-
2953
- // Checkpoint state file periodically so _import_status reflects live progress.
2954
- if (importId && chunksProcessed % CHECKPOINT_EVERY === 0) {
2955
- const liveState = readImportState(importId);
2956
- if (liveState) {
2957
- const estimatedBatches = liveState.batch_total || 1;
2958
- const doneBatches = Math.floor(chunksProcessed / CHECKPOINT_EVERY);
2959
- const elapsed = Date.now() - new Date(liveState.started_at).getTime();
2960
- const secPerBatch = doneBatches > 0 ? elapsed / 1000 / doneBatches : 45;
2961
- const remaining = estimatedBatches - doneBatches;
2962
- const etaMs = remaining * secPerBatch * 1000;
2963
- writeImportState({
2964
- ...liveState,
2965
- batch_done: doneBatches,
2966
- facts_stored: totalStored,
2967
- facts_extracted: totalExtracted,
2968
- estimated_completion_iso: new Date(Date.now() + etaMs).toISOString(),
2969
- });
2970
- }
2971
- }
2972
- }
2973
-
2974
- if (totalExtracted === 0 && chunks.length > 0 && !storeError && chunksSkipped < chunks.length) {
2975
- warnings.push(
2976
- `Processed ${chunks.length} conversation chunks (${totalMessages} messages) but the LLM ` +
2977
- `did not extract any facts worth storing. This can happen if the conversations are mostly ` +
2978
- `generic/ephemeral content without personal facts, preferences, or decisions.`,
2979
- );
2980
- }
2981
-
2982
- if (storeError) {
2983
- warnings.push(`Import stopped early: ${storeError}. ${chunks.length - chunksProcessed} chunk(s) not processed.`);
2984
- }
2985
-
2986
- // Final state file write for background imports.
2987
- if (importId) {
2988
- const finalState = readImportState(importId);
2989
- if (finalState) {
2990
- const finalStatus = storeError ? 'failed' : (finalState.status === 'aborted' ? 'aborted' : 'completed');
2991
- writeImportState({
2992
- ...finalState,
2993
- status: finalStatus,
2994
- batch_done: finalState.batch_total,
2995
- facts_stored: totalStored,
2996
- facts_extracted: totalExtracted,
2997
- errors: storeError ? [...finalState.errors, storeError] : finalState.errors,
2998
- });
2999
- }
3000
- _importInProgress = false;
3001
- }
3002
-
3003
- return {
3004
- success: totalStored > 0 || totalExtracted > 0,
3005
- source,
3006
- import_id: resolvedImportId,
3007
- total_chunks: chunks.length,
3008
- chunks_processed: chunksProcessed,
3009
- chunks_skipped: chunksSkipped,
3010
- total_messages: totalMessages,
3011
- facts_extracted: totalExtracted,
3012
- imported: totalStored,
3013
- skipped: totalExtracted - totalStored,
3014
- stopped_early: !!storeError,
3015
- smart_import: smartCtx ? {
3016
- profile_duration_ms: smartCtx.durationMs,
3017
- extract_count: smartCtx.extractCount,
3018
- skip_count: smartCtx.skipCount,
3019
- } : null,
3020
- warnings,
3021
- duration_ms: Date.now() - startTime,
3022
- };
3023
- }
3024
-
3025
- // ---------------------------------------------------------------------------
3026
- // Import status + abort handlers
3027
- // ---------------------------------------------------------------------------
3028
-
3029
- async function handleImportStatus(
3030
- params: Record<string, unknown>,
3031
- logger: OpenClawPluginApi['logger'],
3032
- ): Promise<Record<string, unknown>> {
3033
- const importId = params.import_id as string | undefined;
3034
-
3035
- let state: ImportState | null;
3036
- if (importId) {
3037
- state = readImportState(importId);
3038
- if (!state) return { error: `No import found with id: ${importId}` };
3039
- } else {
3040
- state = readMostRecentActiveImport();
3041
- 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.)' };
3042
- }
3043
-
3044
- // 1h freshness guard: mark stale imports as failed and prompt user to resume.
3045
- if (state.status === 'running' && isImportStale(state)) {
3046
- writeImportState({ ...state, status: 'failed', errors: [...state.errors, 'Stale: no progress in 1h'] });
3047
- logger.info(`Import ${state.import_id}: marked stale (no progress in 1h)`);
3048
- return {
3049
- import_id: state.import_id,
3050
- status: 'failed',
3051
- stale: true,
3052
- facts_stored: state.facts_stored,
3053
- 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.',
3054
- resume_id: state.import_id,
3055
- };
3056
- }
3057
-
3058
- const now = Date.now();
3059
- const elapsedMs = now - new Date(state.started_at).getTime();
3060
- const secPerBatch = state.batch_done > 0 ? elapsedMs / 1000 / state.batch_done : 45;
3061
- const remaining = Math.max(0, state.batch_total - state.batch_done);
3062
- const etaSeconds = state.status === 'running' ? Math.round(remaining * secPerBatch) : 0;
3063
-
3064
- return {
3065
- import_id: state.import_id,
3066
- status: state.status,
3067
- batch_done: state.batch_done,
3068
- batch_total: state.batch_total,
3069
- facts_stored: state.facts_stored,
3070
- dups_skipped: state.dups_skipped,
3071
- eta_seconds: etaSeconds,
3072
- completion_iso: state.status === 'running'
3073
- ? new Date(now + etaSeconds * 1000).toISOString()
3074
- : state.last_updated,
3075
- source: state.source,
3076
- started_at: state.started_at,
3077
- errors: state.errors,
3078
- };
3079
- }
3080
-
3081
- async function handleImportAbort(
3082
- params: Record<string, unknown>,
3083
- logger: OpenClawPluginApi['logger'],
3084
- ): Promise<Record<string, unknown>> {
3085
- const importId = params.import_id as string | undefined;
3086
- if (!importId) return { error: 'import_id is required' };
3087
-
3088
- const state = readImportState(importId);
3089
- if (!state) return { error: `No import found with id: ${importId}` };
3090
-
3091
- if (state.status === 'aborted') {
3092
- return { aborted: true, idempotent: true, import_id: importId, facts_already_stored: state.facts_stored };
3093
- }
3094
- if (state.status === 'completed') {
3095
- return { error: 'Import already completed — nothing to abort', import_id: importId, facts_stored: state.facts_stored };
3096
- }
3097
-
3098
- writeImportState({ ...state, status: 'aborted' });
3099
- logger.info(`Import ${importId}: abort requested (${state.facts_stored} facts already stored)`);
3100
-
3101
- return {
3102
- aborted: true,
3103
- import_id: importId,
3104
- facts_already_stored: state.facts_stored,
3105
- message: 'Import abort requested. The background task will stop at the next chunk boundary. Already-stored facts are kept.',
3106
- };
3107
- }
1917
+ configureImportRuntime({ storeExtractedFacts });
3108
1918
 
3109
1919
  // ---------------------------------------------------------------------------
3110
1920
  // buildRecallDeps — bind the real recall pipeline into the closures the
@@ -3379,9 +2189,9 @@ function buildRecallDeps(logger: OpenClawPluginApi['logger']): TrNativeMemoryDep
3379
2189
  if (isSubgraphMode()) {
3380
2190
  if (!subgraphOwner) return null;
3381
2191
  try {
3382
- const result = await fetchFactById(subgraphOwner, id, authKeyHex);
3383
- if (!result) return null;
3384
- const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
2192
+ const fetchedFact = await fetchFactById(subgraphOwner, id, authKeyHex);
2193
+ if (!fetchedFact) return null;
2194
+ const docJson = decryptFromHex(fetchedFact.encryptedBlob, encryptionKey);
3385
2195
  if (isDigestBlob(docJson)) return null;
3386
2196
  const doc = readClaimFromBlob(docJson);
3387
2197
  return { id, plaintext: doc.text };
@@ -3454,65 +2264,7 @@ const plugin = {
3454
2264
  // `invalid config: must NOT have additional properties`, which blocked
3455
2265
  // the documented remote-pairing setup (publicUrl) and made it impossible
3456
2266
  // for a user to hand-pick an extraction model (extraction.llm.*).
3457
- configSchema: {
3458
- type: 'object',
3459
- additionalProperties: false,
3460
- properties: {
3461
- publicUrl: {
3462
- type: 'string',
3463
- description:
3464
- "Public gateway URL for QR pairing (e.g. 'https://gateway.example.com:18789'). Overrides the auto-resolution cascade in buildPairingUrl.",
3465
- },
3466
- extraction: {
3467
- type: 'object',
3468
- additionalProperties: false,
3469
- properties: {
3470
- enabled: {
3471
- type: 'boolean',
3472
- description: 'Enable/disable auto-extraction (default: true)',
3473
- },
3474
- model: {
3475
- type: 'string',
3476
- description:
3477
- "Shorthand: override just the extraction model (e.g., 'glm-4.5-flash', 'gpt-4.1-mini'). For a full provider override use extraction.llm.",
3478
- },
3479
- interval: {
3480
- type: 'number',
3481
- description: 'Number of turns between automatic extractions (default: 3)',
3482
- },
3483
- maxFactsPerExtraction: {
3484
- type: 'number',
3485
- description: 'Hard cap on facts extracted per turn (default: 15)',
3486
- },
3487
- llm: {
3488
- type: 'object',
3489
- additionalProperties: false,
3490
- description:
3491
- 'Explicit LLM override block. Highest-priority tier in the extraction-provider cascade. Any subset of provider+apiKey is enough to pin a provider.',
3492
- properties: {
3493
- provider: {
3494
- type: 'string',
3495
- description:
3496
- "Provider name: zai | openai | anthropic | gemini | google | mistral | groq | deepseek | openrouter | xai | together | cerebras.",
3497
- },
3498
- model: {
3499
- type: 'string',
3500
- description: 'Explicit model id. If omitted, deriveCheapModel(provider) picks a sensible default.',
3501
- },
3502
- apiKey: {
3503
- type: 'string',
3504
- description: 'API key for the selected provider. Required for the override to take effect.',
3505
- },
3506
- baseUrl: {
3507
- type: 'string',
3508
- description: 'Override the provider base URL (self-hosted / custom gateway setups).',
3509
- },
3510
- },
3511
- },
3512
- },
3513
- },
3514
- },
3515
- },
2267
+ configSchema: CONFIG_SCHEMA,
3516
2268
 
3517
2269
  register(api: OpenClawPluginApi) {
3518
2270
  // NOTE: the body of register() below is intentionally NOT re-indent
@@ -3851,7 +2603,7 @@ const plugin = {
3851
2603
  if (typeof api.registerCli === 'function') {
3852
2604
  api.registerCli(
3853
2605
  async ({ program }) => {
3854
- const { registerOnboardingCli } = await import('./onboarding-cli.js');
2606
+ const { registerOnboardingCli } = await import('./pairing/onboarding-cli.js');
3855
2607
  registerOnboardingCli(program as import('commander').Command, {
3856
2608
  credentialsPath: CREDENTIALS_PATH,
3857
2609
  statePath: CONFIG.onboardingStatePath,
@@ -3889,13 +2641,13 @@ const plugin = {
3889
2641
  // same relay-brokered URL surface the agent tool uses. The local
3890
2642
  // (gateway-loopback) flow is still available via `--local`. See
3891
2643
  // pair-cli.ts header for the rationale.
3892
- const { registerPairCli } = await import('./pair-cli.js');
2644
+ const { registerPairCli } = await import('./pairing/pair-cli.js');
3893
2645
  registerPairCli(program as import('commander').Command, {
3894
2646
  sessionsPath: CONFIG.pairSessionsPath,
3895
2647
  renderPairingUrl: (session) => buildPairingUrl(api, session),
3896
2648
  logger: api.logger,
3897
2649
  runRelayPairCli: async (cliMode, runOpts) => {
3898
- const { runRelayPairCli } = await import('./pair-cli-relay.js');
2650
+ const { runRelayPairCli } = await import('./pairing/pair-cli-relay.js');
3899
2651
  return runRelayPairCli(cliMode, {
3900
2652
  relayBaseUrl: CONFIG.pairRelayUrl,
3901
2653
  credentialsPath: CREDENTIALS_PATH,
@@ -3999,7 +2751,7 @@ const plugin = {
3999
2751
  }) => {
4000
2752
  try {
4001
2753
  await requireFullSetup(api.logger);
4002
- const result = await handlePluginImportFrom({
2754
+ const importResult = await handlePluginImportFrom({
4003
2755
  source,
4004
2756
  file_path: opts.file,
4005
2757
  content: opts.content,
@@ -4012,30 +2764,30 @@ const plugin = {
4012
2764
  }, api.logger);
4013
2765
 
4014
2766
  if (opts.json) {
4015
- process.stdout.write(JSON.stringify(result) + '\n');
2767
+ process.stdout.write(JSON.stringify(importResult) + '\n');
4016
2768
  } else {
4017
2769
  // Human-readable summary. The handler already returns a
4018
2770
  // `message` for chunked (background) imports; for direct
4019
2771
  // stores + dry runs, synthesize a short summary.
4020
- if (result.dry_run) {
4021
- const chunks = result.total_chunks as number | undefined;
2772
+ if (importResult.dry_run) {
2773
+ const chunks = importResult.total_chunks as number | undefined;
4022
2774
  if (chunks !== undefined) {
4023
2775
  process.stdout.write(
4024
- `Dry run: ~${result.estimated_facts} facts from ${chunks} chunks ` +
4025
- `(~${result.estimated_minutes} min). Confirm without --dry-run to start.\n`,
2776
+ `Dry run: ~${importResult.estimated_facts} facts from ${chunks} chunks ` +
2777
+ `(~${importResult.estimated_minutes} min). Confirm without --dry-run to start.\n`,
4026
2778
  );
4027
2779
  } else {
4028
2780
  process.stdout.write(
4029
- `Dry run: found ${result.total_found} facts. Confirm without --dry-run to import.\n`,
2781
+ `Dry run: found ${importResult.total_found} facts. Confirm without --dry-run to import.\n`,
4030
2782
  );
4031
2783
  }
4032
- } else if (result.import_id && result.status === 'running') {
2784
+ } else if (importResult.import_id && importResult.status === 'running') {
4033
2785
  process.stdout.write(
4034
- `${result.message}\nImport id: ${result.import_id}\n`,
2786
+ `${importResult.message}\nImport id: ${importResult.import_id}\n`,
4035
2787
  );
4036
2788
  } else {
4037
- const stored = result.imported as number | undefined;
4038
- const total = result.total_found as number | undefined;
2789
+ const stored = importResult.imported as number | undefined;
2790
+ const total = importResult.total_found as number | undefined;
4039
2791
  process.stdout.write(
4040
2792
  `Imported ${stored ?? 0}/${total ?? stored ?? 0} facts from ${source}.\n`,
4041
2793
  );
@@ -4060,25 +2812,25 @@ const plugin = {
4060
2812
  .action(async (opts: { id?: string; json?: boolean }) => {
4061
2813
  try {
4062
2814
  await requireFullSetup(api.logger);
4063
- const result = await handleImportStatus({ import_id: opts.id }, api.logger);
2815
+ const statusResult = await handleImportStatus({ import_id: opts.id }, api.logger);
4064
2816
  if (opts.json) {
4065
- process.stdout.write(JSON.stringify(result) + '\n');
2817
+ process.stdout.write(JSON.stringify(statusResult) + '\n');
4066
2818
  } else {
4067
- const status = result.status as string | undefined;
4068
- const stored = result.facts_stored as number | undefined;
4069
- const batchDone = result.batch_done as number | undefined;
4070
- const batchTotal = result.batch_total as number | undefined;
2819
+ const status = statusResult.status as string | undefined;
2820
+ const stored = statusResult.facts_stored as number | undefined;
2821
+ const batchDone = statusResult.batch_done as number | undefined;
2822
+ const batchTotal = statusResult.batch_total as number | undefined;
4071
2823
  if (status === 'no_active_import') {
4072
2824
  process.stdout.write('No active import. Start one with `openclaw totalreclaw import from <source>`.\n');
4073
2825
  } else if (status === 'running') {
4074
2826
  process.stdout.write(
4075
- `Import ${result.import_id}: running — ${stored} facts stored, ` +
2827
+ `Import ${statusResult.import_id}: running — ${stored} facts stored, ` +
4076
2828
  `batch ${batchDone}/${batchTotal}` +
4077
- (result.completion_iso ? `, ETA ${result.completion_iso}` : '') + '.\n',
2829
+ (statusResult.completion_iso ? `, ETA ${statusResult.completion_iso}` : '') + '.\n',
4078
2830
  );
4079
2831
  } else {
4080
2832
  process.stdout.write(
4081
- `Import ${result.import_id}: ${status} — ${stored ?? 0} facts stored.\n`,
2833
+ `Import ${statusResult.import_id}: ${status} — ${stored ?? 0} facts stored.\n`,
4082
2834
  );
4083
2835
  }
4084
2836
  }
@@ -4101,17 +2853,17 @@ const plugin = {
4101
2853
  .action(async (importId: string, opts: { json?: boolean }) => {
4102
2854
  try {
4103
2855
  await requireFullSetup(api.logger);
4104
- const result = await handleImportAbort({ import_id: importId }, api.logger);
2856
+ const abortResult = await handleImportAbort({ import_id: importId }, api.logger);
4105
2857
  if (opts.json) {
4106
- process.stdout.write(JSON.stringify(result) + '\n');
2858
+ process.stdout.write(JSON.stringify(abortResult) + '\n');
4107
2859
  } else {
4108
- if (result.aborted) {
2860
+ if (abortResult.aborted) {
4109
2861
  process.stdout.write(
4110
- `Import ${importId}: abort requested. ${result.facts_already_stored ?? 0} facts already stored (kept).\n`,
2862
+ `Import ${importId}: abort requested. ${abortResult.facts_already_stored ?? 0} facts already stored (kept).\n`,
4111
2863
  );
4112
2864
  } else {
4113
2865
  process.stdout.write(
4114
- `Import ${importId}: ${result.error ?? 'abort failed'}\n`,
2866
+ `Import ${importId}: ${abortResult.error ?? 'abort failed'}\n`,
4115
2867
  );
4116
2868
  }
4117
2869
  }
@@ -4158,15 +2910,15 @@ const plugin = {
4158
2910
  throw new Error(`checkout session failed (HTTP ${response.status}): ${body || response.statusText}`);
4159
2911
  }
4160
2912
 
4161
- const data = await response.json() as { checkout_url?: string };
4162
- if (!data.checkout_url) {
2913
+ const checkoutJson = await response.json() as { checkout_url?: string };
2914
+ if (!checkoutJson.checkout_url) {
4163
2915
  throw new Error('no checkout URL returned by the relay');
4164
2916
  }
4165
2917
 
4166
2918
  if (opts.json) {
4167
- process.stdout.write(JSON.stringify({ checkout_url: data.checkout_url }) + '\n');
2919
+ process.stdout.write(JSON.stringify({ checkout_url: checkoutJson.checkout_url }) + '\n');
4168
2920
  } else {
4169
- process.stdout.write(`Open this URL to upgrade to Pro: ${data.checkout_url}\n`);
2921
+ process.stdout.write(`Open this URL to upgrade to Pro: ${checkoutJson.checkout_url}\n`);
4170
2922
  }
4171
2923
  } catch (err: unknown) {
4172
2924
  const message = err instanceof Error ? err.message : String(err);
@@ -5169,8 +3921,8 @@ const plugin = {
5169
3921
 
5170
3922
  // BUG-2 fix: skip extraction if an import was in progress this turn.
5171
3923
  // Import failures were retriggering agent_end → extraction → import loops.
5172
- if (_importInProgress) {
5173
- _importInProgress = false; // auto-reset for next turn
3924
+ if (isImportInProgress()) {
3925
+ setImportInProgress(false); // auto-reset for next turn
5174
3926
  api.logger.info('agent_end: skipping extraction (import was in progress)');
5175
3927
  return { memoryHandled: true };
5176
3928
  }
@@ -5278,7 +4030,7 @@ const plugin = {
5278
4030
  logger: api.logger,
5279
4031
  ensureInitialized: () => ensureInitialized(api.logger),
5280
4032
  isPairingPending: () => needsSetup,
5281
- isImportActive: () => _importInProgress,
4033
+ isImportActive: () => isImportInProgress(),
5282
4034
  getExtractInterval,
5283
4035
  getMaxFactsPerExtraction,
5284
4036
  isDedupEnabled: isLlmDedupEnabled,