@totalreclaw/totalreclaw 3.3.12-rc.9 → 3.3.12

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 (79) hide show
  1. package/SKILL.md +33 -27
  2. package/api-client.ts +17 -9
  3. package/batch-gate.ts +42 -0
  4. package/billing-cache.ts +72 -23
  5. package/claims-helper.ts +2 -1
  6. package/config.ts +95 -13
  7. package/consolidation.ts +6 -13
  8. package/contradiction-sync.ts +19 -14
  9. package/credential-provider.ts +184 -0
  10. package/crypto.ts +27 -160
  11. package/dist/api-client.js +17 -9
  12. package/dist/batch-gate.js +40 -0
  13. package/dist/billing-cache.js +54 -24
  14. package/dist/claims-helper.js +2 -1
  15. package/dist/config.js +91 -13
  16. package/dist/consolidation.js +6 -15
  17. package/dist/contradiction-sync.js +15 -15
  18. package/dist/credential-provider.js +145 -0
  19. package/dist/crypto.js +17 -137
  20. package/dist/download-ux.js +11 -7
  21. package/dist/embedder-loader.js +266 -0
  22. package/dist/embedding.js +36 -3
  23. package/dist/entry.js +123 -0
  24. package/dist/fs-helpers.js +75 -379
  25. package/dist/import-adapters/gemini-adapter.js +29 -159
  26. package/dist/index.js +864 -2631
  27. package/dist/memory-runtime.js +459 -0
  28. package/dist/native-memory.js +123 -0
  29. package/dist/onboarding-cli.js +4 -8
  30. package/dist/pair-cli-relay.js +1 -8
  31. package/dist/pair-cli.js +1 -1
  32. package/dist/pair-crypto.js +16 -358
  33. package/dist/pair-http.js +147 -4
  34. package/dist/relay.js +140 -0
  35. package/dist/reranker.js +13 -8
  36. package/dist/semantic-dedup.js +5 -7
  37. package/dist/skill-register.js +97 -0
  38. package/dist/subgraph-search.js +3 -1
  39. package/dist/subgraph-store.js +348 -290
  40. package/dist/tool-gating.js +39 -26
  41. package/dist/tools.js +367 -0
  42. package/dist/tr-cli-export-helper.js +3 -3
  43. package/dist/tr-cli.js +65 -156
  44. package/dist/trajectory-poller.js +155 -9
  45. package/dist/vault-crypto.js +551 -0
  46. package/download-ux.ts +12 -6
  47. package/embedder-loader.ts +293 -1
  48. package/embedding.ts +43 -3
  49. package/entry.ts +132 -0
  50. package/fs-helpers.ts +93 -458
  51. package/import-adapters/gemini-adapter.ts +38 -183
  52. package/index.ts +912 -2917
  53. package/memory-runtime.ts +723 -0
  54. package/native-memory.ts +196 -0
  55. package/onboarding-cli.ts +3 -9
  56. package/openclaw.plugin.json +5 -17
  57. package/package.json +6 -3
  58. package/pair-cli-relay.ts +0 -9
  59. package/pair-cli.ts +1 -1
  60. package/pair-crypto.ts +41 -483
  61. package/pair-http.ts +194 -5
  62. package/postinstall.mjs +138 -0
  63. package/relay.ts +172 -0
  64. package/reranker.ts +13 -8
  65. package/semantic-dedup.ts +5 -6
  66. package/skill-register.ts +146 -0
  67. package/skill.json +1 -1
  68. package/subgraph-search.ts +3 -1
  69. package/subgraph-store.ts +367 -307
  70. package/tool-gating.ts +39 -26
  71. package/tools.ts +499 -0
  72. package/tr-cli-export-helper.ts +3 -3
  73. package/tr-cli.ts +69 -182
  74. package/trajectory-poller.ts +162 -10
  75. package/vault-crypto.ts +705 -0
  76. package/auto-pair-on-load.ts +0 -308
  77. package/dist/auto-pair-on-load.js +0 -197
  78. package/dist/pair-pending-injection.js +0 -125
  79. package/pair-pending-injection.ts +0 -205
package/dist/tr-cli.js CHANGED
@@ -1,12 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * tr — TotalReclaw hybrid CLI (3.3.9-rc.1 primary architecture)
3
+ * tr — TotalReclaw CLI (explicit-write + curation surface)
4
4
  *
5
- * OpenClaw 2026.5.2 has a tool-policy-pipeline bug (issue #223) that strips non-bundled plugin
6
- * tools before they reach the agent toolset. In 3.3.9-rc.1, this CLI is the PRIMARY path for
7
- * all agent memory operations (not a fallback). The agent runs `tr <cmd> --json` from shell;
8
- * hooks (before_agent_start, agent_end, message_received, before_reset) continue via the
9
- * unbroken hook code path.
5
+ * Scope (Phase 3.3 OpenClaw native integration): recall is now NATIVE.
6
+ * The agent reads memories via OpenClaw's bundled `memory_search` /
7
+ * `memory_get` tools (backed by the MemoryPluginCapability + TrMemorySearchManager
8
+ * adapter registered in `index.ts`). This CLI no longer ships a recall path.
9
+ *
10
+ * What's still CLI-only (no native agent-facing surface):
11
+ * - explicit write (`tr remember`) — the conventional memory contract has no
12
+ * agent-facing write tool; auto-extraction stores facts via hooks.
13
+ * - curation / lifecycle (`tr forget`, `tr export`).
14
+ * - onboarding + pairing (`tr status`, `tr pair`).
10
15
  *
11
16
  * Phrase-safety: this CLI reads credentials.json (mnemonic at rest) but NEVER
12
17
  * prints the mnemonic to stdout, stderr, or any log. Phrase only enters via QR-pair
@@ -16,14 +21,12 @@
16
21
  * tr status [--json] — print onboarding + credentials state
17
22
  * tr pair [--json] — start a relay pairing session, print URL+PIN+QR
18
23
  * tr remember [--json] <text> — store a memory in the encrypted vault (on-chain)
19
- * tr recall [--json] [--limit N] <query> — search the encrypted vault (subgraph)
20
- * tr forget [--json] <factId> — tombstone a memory on-chain
24
+ * tr forget [--json] <factId> — tombstone a memory on-chain (find the id via memory_search)
21
25
  * tr export [--json] [--format json|markdown] — dump all memories from the subgraph
22
26
  *
23
- * 3.3.12-rc.4 — switched remember/recall/forget/export from `/v1/store` and
27
+ * 3.3.12-rc.4 — switched remember/forget/export from `/v1/store` and
24
28
  * `/v1/search` (those endpoints were removed during the on-chain pivot —
25
- * relay returns 404) to the on-chain UserOp + subgraph paths used by the
26
- * native MCP tools (`totalreclaw_remember`, `totalreclaw_recall`, etc).
29
+ * relay returns 404) to the on-chain UserOp + subgraph paths.
27
30
  *
28
31
  * --json flag: all agent-facing CLI calls MUST use --json for clean machine-parseable output.
29
32
  * Plain text mode is for direct user CLI use only.
@@ -31,16 +34,13 @@
31
34
  * Install: wired via package.json `bin.tr` → dist/tr-cli.js
32
35
  * Usage from container: `docker exec tr-openclaw node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js status --json`
33
36
  */
34
- import path from 'node:path';
35
- import os from 'node:os';
36
37
  import { randomUUID } from 'node:crypto';
37
38
  import { CONFIG, setRecoveryPhraseOverride } from './config.js';
38
39
  import { loadCredentialsJson } from './fs-helpers.js';
39
40
  import { printStatus } from './onboarding-cli.js';
40
- import { deriveKeys, computeAuthKeyHash, encrypt, decrypt, generateBlindIndices, generateContentFingerprint, } from './crypto.js';
41
+ import { deriveKeys, computeAuthKeyHash, encrypt, generateBlindIndices, generateContentFingerprint, } from './crypto.js';
41
42
  import { createApiClient } from './api-client.js';
42
43
  import { encodeFactProtobuf, submitFactBatchOnChain, deriveSmartAccountAddress, getSubgraphConfig, PROTOBUF_VERSION_V4, } from './subgraph-store.js';
43
- import { searchSubgraph, searchSubgraphBroadened, } from './subgraph-search.js';
44
44
  import { exportAllFacts } from './tr-cli-export-helper.js';
45
45
  // ---------------------------------------------------------------------------
46
46
  // Helpers
@@ -51,7 +51,7 @@ const STATE_PATH = CONFIG.onboardingStatePath;
51
51
  // Auto-synced by skill/scripts/sync-version.mjs from skill/plugin/package.json::version.
52
52
  // Do not edit by hand — running tests will catch drift but the publish workflow
53
53
  // rewrites this constant at the start of every npm/ClawHub publish.
54
- const PLUGIN_VERSION = '3.3.12-rc.9';
54
+ const PLUGIN_VERSION = '3.3.12-rc.23';
55
55
  function die(msg, code = 1) {
56
56
  process.stderr.write(`tr: ${msg}\n`);
57
57
  process.exit(code);
@@ -66,15 +66,6 @@ function popFlag(args, flag) {
66
66
  return [false, args];
67
67
  return [true, [...args.slice(0, idx), ...args.slice(idx + 1)]];
68
68
  }
69
- /** Parse --limit N from args, returning [limit, cleanedArgs]. Default: defaultLimit. */
70
- function popLimitFlag(args, defaultLimit) {
71
- const idx = args.indexOf('--limit');
72
- if (idx === -1 || idx + 1 >= args.length)
73
- return [defaultLimit, args];
74
- const n = parseInt(args[idx + 1], 10);
75
- const limit = isNaN(n) || n < 1 ? defaultLimit : n;
76
- return [limit, [...args.slice(0, idx), ...args.slice(idx + 2)]];
77
- }
78
69
  /** Parse --format VALUE from args, returning [value, cleanedArgs]. */
79
70
  function popOptionFlag(args, flag, defaultValue) {
80
71
  const idx = args.indexOf(flag);
@@ -91,12 +82,6 @@ function toHexBlob(plaintext, encryptionKey) {
91
82
  const b64 = encrypt(plaintext, encryptionKey);
92
83
  return Buffer.from(b64, 'base64').toString('hex');
93
84
  }
94
- /** Inverse of toHexBlob — used by recall/export to decrypt subgraph blobs. */
95
- function fromHexBlob(hexBlob, encryptionKey) {
96
- const hex = hexBlob.startsWith('0x') ? hexBlob.slice(2) : hexBlob;
97
- const b64 = Buffer.from(hex, 'hex').toString('base64');
98
- return decrypt(b64, encryptionKey);
99
- }
100
85
  async function buildContext() {
101
86
  const creds = loadCredentialsJson(CREDENTIALS_PATH);
102
87
  if (!creds) {
@@ -176,56 +161,26 @@ async function buildContext() {
176
161
  // Command: status
177
162
  // ---------------------------------------------------------------------------
178
163
  async function cmdStatus(jsonMode) {
179
- // Probe plugin manifest for version/hybridMode/toolCount.
180
- let pluginVersion;
181
- let bootCount;
182
- let hybridMode = true; // default true in 3.3.9-rc.1 (hybrid-primary)
183
- let toolCount;
184
- let loadedAgeSec;
185
- try {
186
- const fs = await import('node:fs');
187
- const candidatePaths = [
188
- path.join(os.homedir(), '.openclaw', 'extensions', 'totalreclaw', '.loaded.json'),
189
- path.join(os.homedir(), '.openclaw', 'npm', 'node_modules', '@totalreclaw', 'totalreclaw', 'dist', '.loaded.json'),
190
- ];
191
- const resolvedPath = candidatePaths.find((p) => fs.existsSync(p));
192
- if (resolvedPath) {
193
- const raw = fs.readFileSync(resolvedPath, 'utf-8');
194
- const manifest = JSON.parse(raw);
195
- pluginVersion = manifest.version ?? PLUGIN_VERSION;
196
- bootCount = manifest.bootCount;
197
- hybridMode = manifest.hybridMode !== false; // default true
198
- toolCount = manifest.tools?.length;
199
- const ageMs = Date.now() - (manifest.loadedAt ?? 0);
200
- loadedAgeSec = Math.round(ageMs / 1000);
201
- }
202
- }
203
- catch {
204
- // Best-effort
205
- }
206
- // Check onboarding state
164
+ // Phase 3.4 retired the `.loaded.json` manifest the writer was removed
165
+ // in 3.1 and the reader had nothing current to read. `tr status` now
166
+ // reports the static plugin version (from the CLI binary's own
167
+ // PLUGIN_VERSION constant) plus onboarding state. For per-boot history,
168
+ // consult the gateway log.
207
169
  const creds = loadCredentialsJson(CREDENTIALS_PATH);
208
170
  const onboarded = !!creds;
209
171
  if (jsonMode) {
210
172
  // JSON-first output for agent parsing
211
173
  const out = {
212
- version: pluginVersion ?? PLUGIN_VERSION,
174
+ version: PLUGIN_VERSION,
213
175
  onboarded,
214
176
  next_step: onboarded ? 'none' : 'pair',
215
- tool_count: toolCount ?? 17,
216
- hybrid_mode: hybridMode,
217
177
  };
218
- if (bootCount !== undefined)
219
- out.boot_count = bootCount;
220
- if (loadedAgeSec !== undefined)
221
- out.loaded_age_sec = loadedAgeSec;
222
178
  log(JSON.stringify(out));
223
179
  }
224
180
  else {
225
181
  // Human-readable plain text for direct user CLI use
226
182
  printStatus(CREDENTIALS_PATH, STATE_PATH, process.stdout);
227
- process.stdout.write(`\n plugin: ${pluginVersion ? `loaded (version=${pluginVersion}` + (bootCount !== undefined ? ` bootCount=${bootCount}` : '') + (loadedAgeSec !== undefined ? ` loaded=${loadedAgeSec}s ago` : '') + ')' : 'not found in .loaded.json'}\n` +
228
- ` hybrid-mode: ${hybridMode ? 'yes (primary — use tr <cmd> --json)' : 'no'}\n` +
183
+ process.stdout.write(`\n plugin: loaded (version=${PLUGIN_VERSION})\n` +
229
184
  ` hooks: before_agent_start, agent_end, message_received, before_reset\n`);
230
185
  }
231
186
  }
@@ -337,79 +292,6 @@ async function cmdRemember(rawArgs) {
337
292
  }
338
293
  }
339
294
  // ---------------------------------------------------------------------------
340
- // Command: recall
341
- // ---------------------------------------------------------------------------
342
- async function cmdRecall(rawArgs) {
343
- const [jsonMode, argsAfterJson] = popFlag(rawArgs, '--json');
344
- const [limit, argsAfterLimit] = popLimitFlag(argsAfterJson, 5);
345
- const query = argsAfterLimit.join(' ').trim();
346
- if (!query) {
347
- die('Usage: tr recall [--json] [--limit N] <query>');
348
- }
349
- const ctx = await buildContext();
350
- // Generate word trapdoors for blind search. The CLI does not run the
351
- // ONNX embedder (that's a 700 MB lazy bundle in the gateway) so we send
352
- // word-only trapdoors. The reranker in the native MCP path would add LSH
353
- // trapdoors on top — we live without them here in exchange for a much
354
- // smaller CLI footprint.
355
- const trapdoors = generateBlindIndices(query);
356
- const pool = Math.max(limit * 4, 20);
357
- try {
358
- let candidates = await searchSubgraph(ctx.walletAddress, trapdoors, pool, ctx.authKeyHex);
359
- // Always run broadened search and merge — ensures vocabulary mismatches
360
- // (e.g., "preferences" vs "prefer") don't cause recall failures. This
361
- // mirrors the native tool path in index.ts (line 3978).
362
- try {
363
- const broadened = await searchSubgraphBroadened(ctx.walletAddress, pool, ctx.authKeyHex);
364
- const seen = new Set(candidates.map((r) => r.id));
365
- for (const br of broadened) {
366
- if (!seen.has(br.id))
367
- candidates.push(br);
368
- }
369
- }
370
- catch {
371
- // best-effort; broadened-only failures shouldn't block trapdoor results
372
- }
373
- const results = [];
374
- for (const c of candidates) {
375
- try {
376
- const docJson = fromHexBlob(c.encryptedBlob, ctx.encryptionKey);
377
- const parsed = JSON.parse(docJson);
378
- if (!parsed.text)
379
- continue;
380
- // The CLI is intentionally simple — score by decayScore (importance
381
- // proxy) instead of running the full BM25 + cosine reranker that
382
- // the native MCP path uses. Agents calling the CLI typically just
383
- // want the top-N by importance.
384
- const decay = typeof c.decayScore === 'string'
385
- ? parseInt(c.decayScore, 10)
386
- : c.decayScore;
387
- const score = Number.isFinite(decay) ? decay / 10 : 0.5;
388
- results.push({ text: parsed.text, score });
389
- }
390
- catch {
391
- // Skip undecryptable / non-JSON (digest blobs, tombstones, etc.)
392
- }
393
- }
394
- // Sort by score descending, then trim to limit.
395
- results.sort((a, b) => b.score - a.score);
396
- const trimmed = results.slice(0, limit);
397
- if (jsonMode) {
398
- log(JSON.stringify({ results: trimmed }));
399
- }
400
- else {
401
- log(`Found ${trimmed.length} result(s) for: ${query}`);
402
- for (const r of trimmed) {
403
- log(` [score=${r.score.toFixed(2)}] ${r.text}`);
404
- }
405
- }
406
- }
407
- catch (err) {
408
- const msg = err instanceof Error ? err.message : String(err);
409
- die(`recall failed: ${msg}`);
410
- }
411
- }
412
- // ---------------------------------------------------------------------------
413
295
  // Command: forget
414
296
  // ---------------------------------------------------------------------------
415
297
  async function cmdForget(rawArgs) {
@@ -418,12 +300,12 @@ async function cmdForget(rawArgs) {
418
300
  if (!factId) {
419
301
  die('Usage: tr forget [--json] <factId>');
420
302
  }
421
- // UUID-v4-ish shape check — same validation as the native totalreclaw_forget
422
- // tool (index.ts line 4225). Prevents fabricated / natural-language IDs
423
- // from reaching the UserOp path and silently no-op'ing on-chain.
303
+ // UUID-v4-ish shape check — same validation the old totalreclaw_forget
304
+ // tool applied. Prevents fabricated / natural-language IDs from reaching
305
+ // the UserOp path and silently no-op'ing on-chain.
424
306
  if (!/^[0-9a-f-]{8,}$/i.test(factId)) {
425
307
  die(`forget failed: "${factId.slice(0, 60)}" doesn't look like a memory ID. ` +
426
- `Run \`tr recall --json <query>\` first and pass a result's id.`);
308
+ `Ask the agent to look it up via memory_search (or tr export) and pass a result's id.`);
427
309
  }
428
310
  const ctx = await buildContext();
429
311
  // Tombstone shape (pin/unpin & native forget use the same one — see
@@ -521,33 +403,60 @@ async function main() {
521
403
  await cmdRemember(args.slice(1));
522
404
  break;
523
405
  case 'recall':
524
- await cmdRecall(args.slice(1));
525
- break;
406
+ // Retired in Phase 3.3 — recall is now native via the bundled
407
+ // memory_search / memory_get tools (MemoryPluginCapability). Surface
408
+ // a clear pointer instead of falling through to "unknown command"
409
+ // so agents / users running stale prompts get actionable guidance.
410
+ die('tr recall was retired — recall is now native. ' +
411
+ 'The agent reads memories via the memory_search tool automatically; ' +
412
+ 'use `tr export` to dump every memory outside the agent.');
526
413
  case 'forget':
527
414
  await cmdForget(args.slice(1));
528
415
  break;
529
416
  case 'export':
530
417
  await cmdExport(args.slice(1));
531
418
  break;
419
+ case 'import':
420
+ case 'upgrade':
421
+ // Import + upgrade run inside the gateway process (they need the
422
+ // plugin runtime — extraction pipeline, smart-import WASM, module-
423
+ // level auth state). The standalone `tr` binary is a separate Node
424
+ // script that does NOT load index.ts (would pull in the entire
425
+ // plugin runtime). Point users at the registerCli surface instead
426
+ // of silently no-op'ing or falling through to "unknown command".
427
+ die(`${cmd} is not available on the standalone \`tr\` binary. ` +
428
+ `Run it on the gateway host via:\n` +
429
+ ` openclaw totalreclaw ${cmd === 'import' ? 'import from <source>' : 'upgrade'}${cmd === 'import' ? ' [--file <path>] [--dry-run] [--json]' : ' [--json]'}\n` +
430
+ (cmd === 'import'
431
+ ? ` openclaw totalreclaw import status [--id <importId>] [--json]\n` +
432
+ ` openclaw totalreclaw import abort <importId> [--json]\n` +
433
+ `Sources: mem0 | mcp-memory | chatgpt | claude | gemini`
434
+ : `Returns a Stripe checkout URL for Pro upgrade.`));
532
435
  case undefined:
533
436
  case '--help':
534
437
  case '-h':
535
- process.stdout.write(`TotalReclaw hybrid CLI v${PLUGIN_VERSION} (primary modeOpenClaw 2026.5.2+)\n\n` +
438
+ process.stdout.write(`TotalReclaw CLI v${PLUGIN_VERSION} (recall is native memory_search tool)\n\n` +
536
439
  'Usage:\n' +
537
- ' tr status [--json] — onboarding + plugin load state\n' +
538
- ' tr pair [--json] — start a relay pairing session\n' +
539
- ' tr remember [--json] <text> — store a memory (on-chain UserOp)\n' +
540
- ' tr recall [--json] [--limit N] <query> search memories (default limit: 5)\n' +
541
- ' tr forget [--json] <factId> — tombstone a memory on-chain\n' +
440
+ ' tr status [--json] — onboarding + plugin load state\n' +
441
+ ' tr pair [--json] — start a relay pairing session\n' +
442
+ ' tr remember [--json] <text> — store a memory (on-chain UserOp)\n' +
443
+ ' tr forget [--json] <factId> tombstone a memory on-chain\n' +
542
444
  ' tr export [--json] [--format json|markdown] — dump every memory in the vault\n\n' +
445
+ 'Recall: NOT a CLI command. The agent recalls via the bundled memory_search tool.\n' +
446
+ ' To dump memories outside the agent, use `tr export`.\n\n' +
447
+ 'Import + Upgrade: NOT on the standalone `tr` binary. They run inside the gateway\n' +
448
+ ' process via the `openclaw totalreclaw` subcommand chain:\n' +
449
+ ' openclaw totalreclaw import from <source> [--file <path>] [--dry-run] [--json]\n' +
450
+ ' openclaw totalreclaw import status [--id <importId>] [--json]\n' +
451
+ ' openclaw totalreclaw import abort <importId> [--json]\n' +
452
+ ' openclaw totalreclaw upgrade [--json]\n' +
453
+ ' Sources: mem0 | mcp-memory | chatgpt | claude | gemini\n\n' +
543
454
  'Flags:\n' +
544
- ' --json Output machine-parseable JSON (required for agent shell calls)\n' +
545
- ' --limit N Limit recall results (default: 5)\n\n' +
455
+ ' --json Output machine-parseable JSON (required for agent shell calls)\n\n' +
546
456
  'JSON output shapes:\n' +
547
457
  ' status: {"version":"...","onboarded":bool,"next_step":"pair|none","tool_count":N,"hybrid_mode":bool}\n' +
548
458
  ' pair: {"url":"...","pin":"123456","expires_at":"..."}\n' +
549
459
  ' remember: {"ok":true,"id":"...","claim_count":N}\n' +
550
- ' recall: {"results":[{"text":"...","score":0.8}]}\n' +
551
460
  ' forget: {"ok":true,"id":"...","tx_hash":"0x..."}\n' +
552
461
  ' export: {"count":N,"facts":[{"id":"...","text":"...","metadata":{...},"created_at":"..."}]}\n\n' +
553
462
  'Environment:\n' +
@@ -21,8 +21,59 @@
21
21
  * existing extraction pipeline. Per-file byte-offset is tracked in
22
22
  * ~/.totalreclaw/extract-state.json so we never re-process lines.
23
23
  *
24
- * When the upstream OpenClaw bug is fixed, the agent_end hook starts
25
- * firing again both paths can coexist with offset-based dedup.
24
+ * RC1 capture strategy (Phase 4, 2026-06-22):
25
+ * RC1 capture = POLLER-PRIMARY, FLUSH-SHADOWED. This poller is the
26
+ * capture workhorse. OpenClaw's host-facing `flushPlanResolver`
27
+ * (wired in native-memory.ts via buildFlushPlan) returns TR's
28
+ * extraction plan so the memory slot is "complete" and the host CAN
29
+ * drive flushes — but TR's encrypt→on-chain capture does NOT depend
30
+ * on the host invoking the flush. The flush-driven capture path
31
+ * (host flush → read scratch file → encrypt → on-chain) is NOT
32
+ * wired on TR's side today; that is H2/RC2-gated. The poller
33
+ * guarantees capture works regardless of host flush cadence: even
34
+ * if the host never flushes, the poller still captures on its own
35
+ * 60 s schedule. This is the graceful-degradation stance —
36
+ * recall-native + capture-poller meets both primary bars even if
37
+ * H2 (host flush cadence) fails at the RC1 QA gate.
38
+ *
39
+ * RC2 retires this poller IF H2 confirms the host invokes
40
+ * flushPlanResolver at useful cadence. If H2 fails, the poller
41
+ * stays as the long-term capture mechanism.
42
+ *
43
+ * Idempotency / offset-dedup:
44
+ * The poller is idempotent across poller-restart and gateway-reload.
45
+ * `extract-state.json` is the single source of truth for "what's
46
+ * been consumed from each trajectory file": loadState() reads it at
47
+ * the start of every poll iteration, parseNewMessages() only reads
48
+ * bytes past the recorded offset, and the file is rewritten (via
49
+ * saveState) only when at least one file's offset advanced. A
50
+ * poller that crashes mid-extraction loses at most the in-flight
51
+ * pass (state was not yet saved); the next poller re-reads from the
52
+ * last persisted offset and re-runs the extraction — duplicate
53
+ * facts are caught downstream by the dedup pass, and re-extraction
54
+ * of the same slice is bounded to one retry (state then advances).
55
+ *
56
+ * `parseNewMessages` caps newOffset at the last full newline so a
57
+ * partially-flushed trailing line is re-read on the next poll
58
+ * rather than dropped or double-counted.
59
+ *
60
+ * Cross-path double-write (NOT a risk today; RC2 work):
61
+ * Today the poller is the only capture path that actually fires.
62
+ * The agent_end / before_compaction / before_reset hook handlers
63
+ * in index.ts still REGISTER storeExtractedFacts callbacks, but on
64
+ * OpenClaw 2026.5.x the host never fires those events for
65
+ * non-bundled plugins (the bug that motivated this poller), so in
66
+ * practice the poller is the sole capture path. If a future
67
+ * OpenClaw release un-blocks the hooks (or when the RC2
68
+ * flush-driven capture path is wired), BOTH the poller AND the
69
+ * parallel path would capture from overlapping state with NO
70
+ * shared dedup — the hooks do NOT consult `extract-state.json`.
71
+ * That is acceptable for RC1 (only the poller fires) but becomes
72
+ * a real double-write risk at RC2. See the TODO(RC2/H2) marker on
73
+ * STATE_FILE below for where the shared last-captured-offset
74
+ * resolver would live. The earlier claim that "both paths can
75
+ * coexist with offset-based dedup" was aspirational; no such
76
+ * shared offset exists today.
26
77
  *
27
78
  * Module boundary (scanner constraint):
28
79
  * This file does disk I/O (fs.read* on trajectory files + state file)
@@ -39,7 +90,24 @@
39
90
  import fs from 'node:fs';
40
91
  import os from 'node:os';
41
92
  import path from 'node:path';
93
+ import { fileURLToPath } from 'node:url';
42
94
  const DEFAULT_POLL_INTERVAL_MS = 60_000;
95
+ // Per-file offset tracker (poller's own source of truth for what's been
96
+ // consumed from each trajectory file). Today this is consulted ONLY by the
97
+ // poller — the agent_end / before_compaction / before_reset hooks in
98
+ // index.ts do NOT read or write it, so if any of those hooks ever fire
99
+ // alongside the poller they would double-capture from overlapping state.
100
+ //
101
+ // TODO(RC2/H2): when the flush-driven capture path is wired (host invokes
102
+ // flushPlanResolver → TR reads the scratch file → encrypt → on-chain) OR
103
+ // the upstream agent_end block is lifted, ALL capture paths MUST consult
104
+ // a shared last-captured-offset resolver before encrypting. The simplest
105
+ // shape is to expose loadState()/saveState() (or a `consumed(file)`
106
+ // helper) to the hook handlers and the flush callback so they skip any
107
+ // byte range the poller already consumed, and conversely the poller must
108
+ // skip any range the hooks/flush path recorded. Until then, this file is
109
+ // poller-private and there is no cross-path double-write risk (only the
110
+ // poller fires on OpenClaw 2026.5.x).
43
111
  const STATE_FILE = path.join(os.homedir(), '.totalreclaw', 'extract-state.json');
44
112
  /**
45
113
  * Skip trajectory files older than this. A user who installs
@@ -50,16 +118,95 @@ const STATE_FILE = path.join(os.homedir(), '.totalreclaw', 'extract-state.json')
50
118
  * re-scanned, and skip the extraction path entirely.
51
119
  */
52
120
  const STALE_TRAJECTORY_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
121
+ /**
122
+ * Module-global handle to the currently-running poller (rc.20, #402).
123
+ *
124
+ * OpenClaw's SIGUSR1 restarts are IN-PROCESS — the module cache survives, so a
125
+ * same-version re-register hits THIS module instance again. Without a guard,
126
+ * each re-register started a fresh setInterval on top of the previous one and
127
+ * live pollers accumulated (a fresh container boot was observed with 2 from
128
+ * boot double-register). `startTrajectoryPoller` stops the previous poller
129
+ * from this module instance before starting a new one.
130
+ */
131
+ let activePoller = null;
132
+ /**
133
+ * Path to THIS module's own file, captured once at load. Each poll tick
134
+ * verifies it still exists AND is the same file (same inode + mtime); if the
135
+ * plugin dir was removed, or the file at this path was swapped for a different
136
+ * one (an old version uninstalled then a new version reinstalled at the SAME
137
+ * path within seconds — the gateway restart is an in-process signal, so the
138
+ * stale module instance survives), the poller self-terminates so a zombie
139
+ * module instance from a stale version can't keep submitting UserOps (rc.20,
140
+ * #402). An existence-only check missed the same-path reinstall case (review
141
+ * LOW-2, observed live: OpenClaw recreates dist at the same path in ~45s, so
142
+ * `existsSync` stayed true and the old-version poller ran on). Tests override
143
+ * this path via `opts.sentinelPath`.
144
+ */
145
+ const MODULE_SENTINEL = fileURLToPath(import.meta.url);
53
146
  /**
54
147
  * Start the trajectory poller. Runs an initial poll after 5 s, then
55
148
  * every `pollIntervalMs` (default 60 s). Returns a handle the caller
56
149
  * can use to stop polling and run one-shot polls in tests.
150
+ *
151
+ * Lifecycle guards (rc.20, #402): a previously-started poller from this module
152
+ * instance is stopped first (singleton), and each tick self-terminates if this
153
+ * module's file is gone.
57
154
  */
58
155
  export function startTrajectoryPoller(deps, opts = {}) {
59
156
  const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
60
157
  const stateFile = opts.stateFile ?? STATE_FILE;
158
+ const sentinelPath = opts.sentinelPath ?? MODULE_SENTINEL;
159
+ // Capture the sentinel's file identity (inode + mtime) once at start so each
160
+ // tick can tell "same file still there" from "different file swapped in at
161
+ // the same path" (reinstall/upgrade). If the initial stat throws, fall back
162
+ // to existence-only semantics rather than crashing startup.
163
+ let sentinelIdentity = null;
164
+ try {
165
+ const st = fs.statSync(sentinelPath);
166
+ sentinelIdentity = { ino: st.ino, mtimeMs: st.mtimeMs };
167
+ }
168
+ catch {
169
+ sentinelIdentity = null;
170
+ }
171
+ // Singleton guard: stop any poller this module instance started earlier so a
172
+ // same-version re-register (in-process SIGUSR1 restart) does not stack a
173
+ // second live setInterval on top of the first.
174
+ if (activePoller) {
175
+ activePoller.stop();
176
+ deps.logger.info('extractd: previous poller stopped (re-register)');
177
+ }
178
+ let timer;
179
+ let initialTimeout;
180
+ const stop = () => {
181
+ if (timer)
182
+ clearInterval(timer);
183
+ if (initialTimeout)
184
+ clearTimeout(initialTimeout);
185
+ if (activePoller === handle)
186
+ activePoller = null;
187
+ };
61
188
  const pollAndExtract = async () => {
62
189
  try {
190
+ // Cross-version self-termination: if this module's own file is gone, or a
191
+ // DIFFERENT file was swapped in at the same path (uninstall→reinstall of a
192
+ // newer version), stop — a zombie poller from a stale module instance must
193
+ // not keep capturing.
194
+ let sentinelStat = null;
195
+ try {
196
+ sentinelStat = fs.statSync(sentinelPath);
197
+ }
198
+ catch {
199
+ // File gone (plugin dir removed).
200
+ deps.logger.warn('extractd: poller self-terminated (plugin dir removed)');
201
+ stop();
202
+ return;
203
+ }
204
+ if (sentinelIdentity !== null &&
205
+ (sentinelStat.ino !== sentinelIdentity.ino || sentinelStat.mtimeMs !== sentinelIdentity.mtimeMs)) {
206
+ deps.logger.warn('extractd: poller self-terminated (plugin file replaced — reinstall/upgrade detected)');
207
+ stop();
208
+ return;
209
+ }
63
210
  await deps.ensureInitialized();
64
211
  if (deps.isPairingPending())
65
212
  return;
@@ -149,24 +296,23 @@ export function startTrajectoryPoller(deps, opts = {}) {
149
296
  deps.logger.error(`extractd: poll iteration failed: ${msg}`);
150
297
  }
151
298
  };
152
- const timer = setInterval(() => {
299
+ timer = setInterval(() => {
153
300
  void pollAndExtract();
154
301
  }, pollIntervalMs);
155
302
  if (typeof timer.unref === 'function')
156
303
  timer.unref();
157
- const initialTimeout = setTimeout(() => {
304
+ initialTimeout = setTimeout(() => {
158
305
  void pollAndExtract();
159
306
  }, 5_000);
160
307
  if (typeof initialTimeout.unref === 'function')
161
308
  initialTimeout.unref();
162
309
  deps.logger.info(`extractd: trajectory poller started (interval=${Math.round(pollIntervalMs / 1000)}s)`);
163
- return {
164
- stop: () => {
165
- clearInterval(timer);
166
- clearTimeout(initialTimeout);
167
- },
310
+ const handle = {
311
+ stop,
168
312
  pollOnce: pollAndExtract,
169
313
  };
314
+ activePoller = handle;
315
+ return handle;
170
316
  }
171
317
  // ---------------------------------------------------------------------------
172
318
  // Filesystem scan + trajectory parser