@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.
- package/SKILL.md +33 -27
- package/api-client.ts +17 -9
- package/batch-gate.ts +42 -0
- package/billing-cache.ts +72 -23
- package/claims-helper.ts +2 -1
- package/config.ts +95 -13
- package/consolidation.ts +6 -13
- package/contradiction-sync.ts +19 -14
- package/credential-provider.ts +184 -0
- package/crypto.ts +27 -160
- package/dist/api-client.js +17 -9
- package/dist/batch-gate.js +40 -0
- package/dist/billing-cache.js +54 -24
- package/dist/claims-helper.js +2 -1
- package/dist/config.js +91 -13
- package/dist/consolidation.js +6 -15
- package/dist/contradiction-sync.js +15 -15
- package/dist/credential-provider.js +145 -0
- package/dist/crypto.js +17 -137
- package/dist/download-ux.js +11 -7
- package/dist/embedder-loader.js +266 -0
- package/dist/embedding.js +36 -3
- package/dist/entry.js +123 -0
- package/dist/fs-helpers.js +75 -379
- package/dist/import-adapters/gemini-adapter.js +29 -159
- package/dist/index.js +864 -2631
- package/dist/memory-runtime.js +459 -0
- package/dist/native-memory.js +123 -0
- package/dist/onboarding-cli.js +4 -8
- package/dist/pair-cli-relay.js +1 -8
- package/dist/pair-cli.js +1 -1
- package/dist/pair-crypto.js +16 -358
- package/dist/pair-http.js +147 -4
- package/dist/relay.js +140 -0
- package/dist/reranker.js +13 -8
- package/dist/semantic-dedup.js +5 -7
- package/dist/skill-register.js +97 -0
- package/dist/subgraph-search.js +3 -1
- package/dist/subgraph-store.js +348 -290
- package/dist/tool-gating.js +39 -26
- package/dist/tools.js +367 -0
- package/dist/tr-cli-export-helper.js +3 -3
- package/dist/tr-cli.js +65 -156
- package/dist/trajectory-poller.js +155 -9
- package/dist/vault-crypto.js +551 -0
- package/download-ux.ts +12 -6
- package/embedder-loader.ts +293 -1
- package/embedding.ts +43 -3
- package/entry.ts +132 -0
- package/fs-helpers.ts +93 -458
- package/import-adapters/gemini-adapter.ts +38 -183
- package/index.ts +912 -2917
- package/memory-runtime.ts +723 -0
- package/native-memory.ts +196 -0
- package/onboarding-cli.ts +3 -9
- package/openclaw.plugin.json +5 -17
- package/package.json +6 -3
- package/pair-cli-relay.ts +0 -9
- package/pair-cli.ts +1 -1
- package/pair-crypto.ts +41 -483
- package/pair-http.ts +194 -5
- package/postinstall.mjs +138 -0
- package/relay.ts +172 -0
- package/reranker.ts +13 -8
- package/semantic-dedup.ts +5 -6
- package/skill-register.ts +146 -0
- package/skill.json +1 -1
- package/subgraph-search.ts +3 -1
- package/subgraph-store.ts +367 -307
- package/tool-gating.ts +39 -26
- package/tools.ts +499 -0
- package/tr-cli-export-helper.ts +3 -3
- package/tr-cli.ts +69 -182
- package/trajectory-poller.ts +162 -10
- package/vault-crypto.ts +705 -0
- package/auto-pair-on-load.ts +0 -308
- package/dist/auto-pair-on-load.js +0 -197
- package/dist/pair-pending-injection.js +0 -125
- package/pair-pending-injection.ts +0 -205
package/tr-cli.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* tr — TotalReclaw
|
|
3
|
+
* tr — TotalReclaw CLI (explicit-write + curation surface)
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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/
|
|
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
|
|
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.
|
|
@@ -32,8 +35,6 @@
|
|
|
32
35
|
* Usage from container: `docker exec tr-openclaw node ~/.openclaw/extensions/totalreclaw/dist/tr-cli.js status --json`
|
|
33
36
|
*/
|
|
34
37
|
|
|
35
|
-
import path from 'node:path';
|
|
36
|
-
import os from 'node:os';
|
|
37
38
|
import { randomUUID } from 'node:crypto';
|
|
38
39
|
|
|
39
40
|
import { CONFIG, setRecoveryPhraseOverride } from './config.js';
|
|
@@ -43,7 +44,6 @@ import {
|
|
|
43
44
|
deriveKeys,
|
|
44
45
|
computeAuthKeyHash,
|
|
45
46
|
encrypt,
|
|
46
|
-
decrypt,
|
|
47
47
|
generateBlindIndices,
|
|
48
48
|
generateContentFingerprint,
|
|
49
49
|
} from './crypto.js';
|
|
@@ -56,10 +56,6 @@ import {
|
|
|
56
56
|
PROTOBUF_VERSION_V4,
|
|
57
57
|
type FactPayload,
|
|
58
58
|
} from './subgraph-store.js';
|
|
59
|
-
import {
|
|
60
|
-
searchSubgraph,
|
|
61
|
-
searchSubgraphBroadened,
|
|
62
|
-
} from './subgraph-search.js';
|
|
63
59
|
import { exportAllFacts } from './tr-cli-export-helper.js';
|
|
64
60
|
|
|
65
61
|
// ---------------------------------------------------------------------------
|
|
@@ -72,7 +68,7 @@ const STATE_PATH = CONFIG.onboardingStatePath;
|
|
|
72
68
|
// Auto-synced by skill/scripts/sync-version.mjs from skill/plugin/package.json::version.
|
|
73
69
|
// Do not edit by hand — running tests will catch drift but the publish workflow
|
|
74
70
|
// rewrites this constant at the start of every npm/ClawHub publish.
|
|
75
|
-
const PLUGIN_VERSION = '3.3.12-rc.
|
|
71
|
+
const PLUGIN_VERSION = '3.3.12-rc.23';
|
|
76
72
|
|
|
77
73
|
function die(msg: string, code = 1): never {
|
|
78
74
|
process.stderr.write(`tr: ${msg}\n`);
|
|
@@ -90,15 +86,6 @@ function popFlag(args: string[], flag: string): [boolean, string[]] {
|
|
|
90
86
|
return [true, [...args.slice(0, idx), ...args.slice(idx + 1)]];
|
|
91
87
|
}
|
|
92
88
|
|
|
93
|
-
/** Parse --limit N from args, returning [limit, cleanedArgs]. Default: defaultLimit. */
|
|
94
|
-
function popLimitFlag(args: string[], defaultLimit: number): [number, string[]] {
|
|
95
|
-
const idx = args.indexOf('--limit');
|
|
96
|
-
if (idx === -1 || idx + 1 >= args.length) return [defaultLimit, args];
|
|
97
|
-
const n = parseInt(args[idx + 1], 10);
|
|
98
|
-
const limit = isNaN(n) || n < 1 ? defaultLimit : n;
|
|
99
|
-
return [limit, [...args.slice(0, idx), ...args.slice(idx + 2)]];
|
|
100
|
-
}
|
|
101
|
-
|
|
102
89
|
/** Parse --format VALUE from args, returning [value, cleanedArgs]. */
|
|
103
90
|
function popOptionFlag(
|
|
104
91
|
args: string[],
|
|
@@ -120,13 +107,6 @@ function toHexBlob(plaintext: string, encryptionKey: Buffer): string {
|
|
|
120
107
|
return Buffer.from(b64, 'base64').toString('hex');
|
|
121
108
|
}
|
|
122
109
|
|
|
123
|
-
/** Inverse of toHexBlob — used by recall/export to decrypt subgraph blobs. */
|
|
124
|
-
function fromHexBlob(hexBlob: string, encryptionKey: Buffer): string {
|
|
125
|
-
const hex = hexBlob.startsWith('0x') ? hexBlob.slice(2) : hexBlob;
|
|
126
|
-
const b64 = Buffer.from(hex, 'hex').toString('base64');
|
|
127
|
-
return decrypt(b64, encryptionKey);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
110
|
// ---------------------------------------------------------------------------
|
|
131
111
|
// Core init — minimal version of index.ts initialize()
|
|
132
112
|
// ---------------------------------------------------------------------------
|
|
@@ -228,62 +208,27 @@ async function buildContext(): Promise<CliContext> {
|
|
|
228
208
|
// ---------------------------------------------------------------------------
|
|
229
209
|
|
|
230
210
|
async function cmdStatus(jsonMode: boolean): Promise<void> {
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
let loadedAgeSec: number | undefined;
|
|
237
|
-
|
|
238
|
-
try {
|
|
239
|
-
const fs = await import('node:fs');
|
|
240
|
-
const candidatePaths = [
|
|
241
|
-
path.join(os.homedir(), '.openclaw', 'extensions', 'totalreclaw', '.loaded.json'),
|
|
242
|
-
path.join(os.homedir(), '.openclaw', 'npm', 'node_modules', '@totalreclaw', 'totalreclaw', 'dist', '.loaded.json'),
|
|
243
|
-
];
|
|
244
|
-
const resolvedPath = candidatePaths.find((p) => fs.existsSync(p));
|
|
245
|
-
if (resolvedPath) {
|
|
246
|
-
const raw = fs.readFileSync(resolvedPath, 'utf-8');
|
|
247
|
-
const manifest = JSON.parse(raw) as {
|
|
248
|
-
version?: string;
|
|
249
|
-
bootCount?: number;
|
|
250
|
-
loadedAt?: number;
|
|
251
|
-
hybridMode?: boolean;
|
|
252
|
-
tools?: string[];
|
|
253
|
-
};
|
|
254
|
-
pluginVersion = manifest.version ?? PLUGIN_VERSION;
|
|
255
|
-
bootCount = manifest.bootCount;
|
|
256
|
-
hybridMode = manifest.hybridMode !== false; // default true
|
|
257
|
-
toolCount = manifest.tools?.length;
|
|
258
|
-
const ageMs = Date.now() - (manifest.loadedAt ?? 0);
|
|
259
|
-
loadedAgeSec = Math.round(ageMs / 1000);
|
|
260
|
-
}
|
|
261
|
-
} catch {
|
|
262
|
-
// Best-effort
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Check onboarding state
|
|
211
|
+
// Phase 3.4 retired the `.loaded.json` manifest — the writer was removed
|
|
212
|
+
// in 3.1 and the reader had nothing current to read. `tr status` now
|
|
213
|
+
// reports the static plugin version (from the CLI binary's own
|
|
214
|
+
// PLUGIN_VERSION constant) plus onboarding state. For per-boot history,
|
|
215
|
+
// consult the gateway log.
|
|
266
216
|
const creds = loadCredentialsJson(CREDENTIALS_PATH);
|
|
267
217
|
const onboarded = !!creds;
|
|
268
218
|
|
|
269
219
|
if (jsonMode) {
|
|
270
220
|
// JSON-first output for agent parsing
|
|
271
221
|
const out: Record<string, unknown> = {
|
|
272
|
-
version:
|
|
222
|
+
version: PLUGIN_VERSION,
|
|
273
223
|
onboarded,
|
|
274
224
|
next_step: onboarded ? 'none' : 'pair',
|
|
275
|
-
tool_count: toolCount ?? 17,
|
|
276
|
-
hybrid_mode: hybridMode,
|
|
277
225
|
};
|
|
278
|
-
if (bootCount !== undefined) out.boot_count = bootCount;
|
|
279
|
-
if (loadedAgeSec !== undefined) out.loaded_age_sec = loadedAgeSec;
|
|
280
226
|
log(JSON.stringify(out));
|
|
281
227
|
} else {
|
|
282
228
|
// Human-readable plain text for direct user CLI use
|
|
283
229
|
printStatus(CREDENTIALS_PATH, STATE_PATH, process.stdout);
|
|
284
230
|
process.stdout.write(
|
|
285
|
-
`\n plugin:
|
|
286
|
-
` hybrid-mode: ${hybridMode ? 'yes (primary — use tr <cmd> --json)' : 'no'}\n` +
|
|
231
|
+
`\n plugin: loaded (version=${PLUGIN_VERSION})\n` +
|
|
287
232
|
` hooks: before_agent_start, agent_end, message_received, before_reset\n`,
|
|
288
233
|
);
|
|
289
234
|
}
|
|
@@ -413,96 +358,6 @@ async function cmdRemember(rawArgs: string[]): Promise<void> {
|
|
|
413
358
|
}
|
|
414
359
|
}
|
|
415
360
|
|
|
416
|
-
// ---------------------------------------------------------------------------
|
|
417
|
-
// Command: recall
|
|
418
|
-
// ---------------------------------------------------------------------------
|
|
419
|
-
|
|
420
|
-
async function cmdRecall(rawArgs: string[]): Promise<void> {
|
|
421
|
-
const [jsonMode, argsAfterJson] = popFlag(rawArgs, '--json');
|
|
422
|
-
const [limit, argsAfterLimit] = popLimitFlag(argsAfterJson, 5);
|
|
423
|
-
const query = argsAfterLimit.join(' ').trim();
|
|
424
|
-
if (!query) {
|
|
425
|
-
die('Usage: tr recall [--json] [--limit N] <query>');
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const ctx = await buildContext();
|
|
429
|
-
|
|
430
|
-
// Generate word trapdoors for blind search. The CLI does not run the
|
|
431
|
-
// ONNX embedder (that's a 700 MB lazy bundle in the gateway) so we send
|
|
432
|
-
// word-only trapdoors. The reranker in the native MCP path would add LSH
|
|
433
|
-
// trapdoors on top — we live without them here in exchange for a much
|
|
434
|
-
// smaller CLI footprint.
|
|
435
|
-
const trapdoors = generateBlindIndices(query);
|
|
436
|
-
const pool = Math.max(limit * 4, 20);
|
|
437
|
-
|
|
438
|
-
try {
|
|
439
|
-
let candidates = await searchSubgraph(
|
|
440
|
-
ctx.walletAddress,
|
|
441
|
-
trapdoors,
|
|
442
|
-
pool,
|
|
443
|
-
ctx.authKeyHex,
|
|
444
|
-
);
|
|
445
|
-
|
|
446
|
-
// Always run broadened search and merge — ensures vocabulary mismatches
|
|
447
|
-
// (e.g., "preferences" vs "prefer") don't cause recall failures. This
|
|
448
|
-
// mirrors the native tool path in index.ts (line 3978).
|
|
449
|
-
try {
|
|
450
|
-
const broadened = await searchSubgraphBroadened(
|
|
451
|
-
ctx.walletAddress,
|
|
452
|
-
pool,
|
|
453
|
-
ctx.authKeyHex,
|
|
454
|
-
);
|
|
455
|
-
const seen = new Set(candidates.map((r) => r.id));
|
|
456
|
-
for (const br of broadened) {
|
|
457
|
-
if (!seen.has(br.id)) candidates.push(br);
|
|
458
|
-
}
|
|
459
|
-
} catch {
|
|
460
|
-
// best-effort; broadened-only failures shouldn't block trapdoor results
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const results: Array<{ text: string; score: number }> = [];
|
|
464
|
-
|
|
465
|
-
for (const c of candidates) {
|
|
466
|
-
try {
|
|
467
|
-
const docJson = fromHexBlob(c.encryptedBlob, ctx.encryptionKey);
|
|
468
|
-
const parsed = JSON.parse(docJson) as {
|
|
469
|
-
text?: string;
|
|
470
|
-
importance?: number;
|
|
471
|
-
metadata?: { importance?: number };
|
|
472
|
-
};
|
|
473
|
-
if (!parsed.text) continue;
|
|
474
|
-
// The CLI is intentionally simple — score by decayScore (importance
|
|
475
|
-
// proxy) instead of running the full BM25 + cosine reranker that
|
|
476
|
-
// the native MCP path uses. Agents calling the CLI typically just
|
|
477
|
-
// want the top-N by importance.
|
|
478
|
-
const decay = typeof c.decayScore === 'string'
|
|
479
|
-
? parseInt(c.decayScore, 10)
|
|
480
|
-
: (c.decayScore as unknown as number);
|
|
481
|
-
const score = Number.isFinite(decay) ? decay / 10 : 0.5;
|
|
482
|
-
results.push({ text: parsed.text, score });
|
|
483
|
-
} catch {
|
|
484
|
-
// Skip undecryptable / non-JSON (digest blobs, tombstones, etc.)
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Sort by score descending, then trim to limit.
|
|
489
|
-
results.sort((a, b) => b.score - a.score);
|
|
490
|
-
const trimmed = results.slice(0, limit);
|
|
491
|
-
|
|
492
|
-
if (jsonMode) {
|
|
493
|
-
log(JSON.stringify({ results: trimmed }));
|
|
494
|
-
} else {
|
|
495
|
-
log(`Found ${trimmed.length} result(s) for: ${query}`);
|
|
496
|
-
for (const r of trimmed) {
|
|
497
|
-
log(` [score=${r.score.toFixed(2)}] ${r.text}`);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
} catch (err) {
|
|
501
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
502
|
-
die(`recall failed: ${msg}`);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
361
|
// ---------------------------------------------------------------------------
|
|
507
362
|
// Command: forget
|
|
508
363
|
// ---------------------------------------------------------------------------
|
|
@@ -513,13 +368,13 @@ async function cmdForget(rawArgs: string[]): Promise<void> {
|
|
|
513
368
|
if (!factId) {
|
|
514
369
|
die('Usage: tr forget [--json] <factId>');
|
|
515
370
|
}
|
|
516
|
-
// UUID-v4-ish shape check — same validation
|
|
517
|
-
// tool
|
|
518
|
-
//
|
|
371
|
+
// UUID-v4-ish shape check — same validation the old totalreclaw_forget
|
|
372
|
+
// tool applied. Prevents fabricated / natural-language IDs from reaching
|
|
373
|
+
// the UserOp path and silently no-op'ing on-chain.
|
|
519
374
|
if (!/^[0-9a-f-]{8,}$/i.test(factId)) {
|
|
520
375
|
die(
|
|
521
376
|
`forget failed: "${factId.slice(0, 60)}" doesn't look like a memory ID. ` +
|
|
522
|
-
`
|
|
377
|
+
`Ask the agent to look it up via memory_search (or tr export) and pass a result's id.`,
|
|
523
378
|
);
|
|
524
379
|
}
|
|
525
380
|
|
|
@@ -640,8 +495,15 @@ async function main(): Promise<void> {
|
|
|
640
495
|
break;
|
|
641
496
|
|
|
642
497
|
case 'recall':
|
|
643
|
-
|
|
644
|
-
|
|
498
|
+
// Retired in Phase 3.3 — recall is now native via the bundled
|
|
499
|
+
// memory_search / memory_get tools (MemoryPluginCapability). Surface
|
|
500
|
+
// a clear pointer instead of falling through to "unknown command"
|
|
501
|
+
// so agents / users running stale prompts get actionable guidance.
|
|
502
|
+
die(
|
|
503
|
+
'tr recall was retired — recall is now native. ' +
|
|
504
|
+
'The agent reads memories via the memory_search tool automatically; ' +
|
|
505
|
+
'use `tr export` to dump every memory outside the agent.',
|
|
506
|
+
);
|
|
645
507
|
|
|
646
508
|
case 'forget':
|
|
647
509
|
await cmdForget(args.slice(1));
|
|
@@ -651,26 +513,51 @@ async function main(): Promise<void> {
|
|
|
651
513
|
await cmdExport(args.slice(1));
|
|
652
514
|
break;
|
|
653
515
|
|
|
516
|
+
case 'import':
|
|
517
|
+
case 'upgrade':
|
|
518
|
+
// Import + upgrade run inside the gateway process (they need the
|
|
519
|
+
// plugin runtime — extraction pipeline, smart-import WASM, module-
|
|
520
|
+
// level auth state). The standalone `tr` binary is a separate Node
|
|
521
|
+
// script that does NOT load index.ts (would pull in the entire
|
|
522
|
+
// plugin runtime). Point users at the registerCli surface instead
|
|
523
|
+
// of silently no-op'ing or falling through to "unknown command".
|
|
524
|
+
die(
|
|
525
|
+
`${cmd} is not available on the standalone \`tr\` binary. ` +
|
|
526
|
+
`Run it on the gateway host via:\n` +
|
|
527
|
+
` openclaw totalreclaw ${cmd === 'import' ? 'import from <source>' : 'upgrade'}${cmd === 'import' ? ' [--file <path>] [--dry-run] [--json]' : ' [--json]'}\n` +
|
|
528
|
+
(cmd === 'import'
|
|
529
|
+
? ` openclaw totalreclaw import status [--id <importId>] [--json]\n` +
|
|
530
|
+
` openclaw totalreclaw import abort <importId> [--json]\n` +
|
|
531
|
+
`Sources: mem0 | mcp-memory | chatgpt | claude | gemini`
|
|
532
|
+
: `Returns a Stripe checkout URL for Pro upgrade.`),
|
|
533
|
+
);
|
|
534
|
+
|
|
654
535
|
case undefined:
|
|
655
536
|
case '--help':
|
|
656
537
|
case '-h':
|
|
657
538
|
process.stdout.write(
|
|
658
|
-
`TotalReclaw
|
|
539
|
+
`TotalReclaw CLI v${PLUGIN_VERSION} (recall is native — memory_search tool)\n\n` +
|
|
659
540
|
'Usage:\n' +
|
|
660
|
-
' tr status [--json]
|
|
661
|
-
' tr pair [--json]
|
|
662
|
-
' tr remember [--json] <text>
|
|
663
|
-
' tr
|
|
664
|
-
' tr forget [--json] <factId> — tombstone a memory on-chain\n' +
|
|
541
|
+
' tr status [--json] — onboarding + plugin load state\n' +
|
|
542
|
+
' tr pair [--json] — start a relay pairing session\n' +
|
|
543
|
+
' tr remember [--json] <text> — store a memory (on-chain UserOp)\n' +
|
|
544
|
+
' tr forget [--json] <factId> — tombstone a memory on-chain\n' +
|
|
665
545
|
' tr export [--json] [--format json|markdown] — dump every memory in the vault\n\n' +
|
|
546
|
+
'Recall: NOT a CLI command. The agent recalls via the bundled memory_search tool.\n' +
|
|
547
|
+
' To dump memories outside the agent, use `tr export`.\n\n' +
|
|
548
|
+
'Import + Upgrade: NOT on the standalone `tr` binary. They run inside the gateway\n' +
|
|
549
|
+
' process via the `openclaw totalreclaw` subcommand chain:\n' +
|
|
550
|
+
' openclaw totalreclaw import from <source> [--file <path>] [--dry-run] [--json]\n' +
|
|
551
|
+
' openclaw totalreclaw import status [--id <importId>] [--json]\n' +
|
|
552
|
+
' openclaw totalreclaw import abort <importId> [--json]\n' +
|
|
553
|
+
' openclaw totalreclaw upgrade [--json]\n' +
|
|
554
|
+
' Sources: mem0 | mcp-memory | chatgpt | claude | gemini\n\n' +
|
|
666
555
|
'Flags:\n' +
|
|
667
|
-
' --json Output machine-parseable JSON (required for agent shell calls)\n' +
|
|
668
|
-
' --limit N Limit recall results (default: 5)\n\n' +
|
|
556
|
+
' --json Output machine-parseable JSON (required for agent shell calls)\n\n' +
|
|
669
557
|
'JSON output shapes:\n' +
|
|
670
558
|
' status: {"version":"...","onboarded":bool,"next_step":"pair|none","tool_count":N,"hybrid_mode":bool}\n' +
|
|
671
559
|
' pair: {"url":"...","pin":"123456","expires_at":"..."}\n' +
|
|
672
560
|
' remember: {"ok":true,"id":"...","claim_count":N}\n' +
|
|
673
|
-
' recall: {"results":[{"text":"...","score":0.8}]}\n' +
|
|
674
561
|
' forget: {"ok":true,"id":"...","tx_hash":"0x..."}\n' +
|
|
675
562
|
' export: {"count":N,"facts":[{"id":"...","text":"...","metadata":{...},"created_at":"..."}]}\n\n' +
|
|
676
563
|
'Environment:\n' +
|
package/trajectory-poller.ts
CHANGED
|
@@ -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
|
-
*
|
|
25
|
-
*
|
|
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)
|
|
@@ -40,6 +91,7 @@
|
|
|
40
91
|
import fs from 'node:fs';
|
|
41
92
|
import os from 'node:os';
|
|
42
93
|
import path from 'node:path';
|
|
94
|
+
import { fileURLToPath } from 'node:url';
|
|
43
95
|
|
|
44
96
|
// ---------------------------------------------------------------------------
|
|
45
97
|
// Public surface
|
|
@@ -126,6 +178,22 @@ export interface TrajectoryPollerHandle {
|
|
|
126
178
|
}
|
|
127
179
|
|
|
128
180
|
const DEFAULT_POLL_INTERVAL_MS = 60_000;
|
|
181
|
+
// Per-file offset tracker (poller's own source of truth for what's been
|
|
182
|
+
// consumed from each trajectory file). Today this is consulted ONLY by the
|
|
183
|
+
// poller — the agent_end / before_compaction / before_reset hooks in
|
|
184
|
+
// index.ts do NOT read or write it, so if any of those hooks ever fire
|
|
185
|
+
// alongside the poller they would double-capture from overlapping state.
|
|
186
|
+
//
|
|
187
|
+
// TODO(RC2/H2): when the flush-driven capture path is wired (host invokes
|
|
188
|
+
// flushPlanResolver → TR reads the scratch file → encrypt → on-chain) OR
|
|
189
|
+
// the upstream agent_end block is lifted, ALL capture paths MUST consult
|
|
190
|
+
// a shared last-captured-offset resolver before encrypting. The simplest
|
|
191
|
+
// shape is to expose loadState()/saveState() (or a `consumed(file)`
|
|
192
|
+
// helper) to the hook handlers and the flush callback so they skip any
|
|
193
|
+
// byte range the poller already consumed, and conversely the poller must
|
|
194
|
+
// skip any range the hooks/flush path recorded. Until then, this file is
|
|
195
|
+
// poller-private and there is no cross-path double-write risk (only the
|
|
196
|
+
// poller fires on OpenClaw 2026.5.x).
|
|
129
197
|
const STATE_FILE = path.join(os.homedir(), '.totalreclaw', 'extract-state.json');
|
|
130
198
|
/**
|
|
131
199
|
* Skip trajectory files older than this. A user who installs
|
|
@@ -137,20 +205,105 @@ const STATE_FILE = path.join(os.homedir(), '.totalreclaw', 'extract-state.json')
|
|
|
137
205
|
*/
|
|
138
206
|
const STALE_TRAJECTORY_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
139
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Module-global handle to the currently-running poller (rc.20, #402).
|
|
210
|
+
*
|
|
211
|
+
* OpenClaw's SIGUSR1 restarts are IN-PROCESS — the module cache survives, so a
|
|
212
|
+
* same-version re-register hits THIS module instance again. Without a guard,
|
|
213
|
+
* each re-register started a fresh setInterval on top of the previous one and
|
|
214
|
+
* live pollers accumulated (a fresh container boot was observed with 2 from
|
|
215
|
+
* boot double-register). `startTrajectoryPoller` stops the previous poller
|
|
216
|
+
* from this module instance before starting a new one.
|
|
217
|
+
*/
|
|
218
|
+
let activePoller: TrajectoryPollerHandle | null = null;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Path to THIS module's own file, captured once at load. Each poll tick
|
|
222
|
+
* verifies it still exists AND is the same file (same inode + mtime); if the
|
|
223
|
+
* plugin dir was removed, or the file at this path was swapped for a different
|
|
224
|
+
* one (an old version uninstalled then a new version reinstalled at the SAME
|
|
225
|
+
* path within seconds — the gateway restart is an in-process signal, so the
|
|
226
|
+
* stale module instance survives), the poller self-terminates so a zombie
|
|
227
|
+
* module instance from a stale version can't keep submitting UserOps (rc.20,
|
|
228
|
+
* #402). An existence-only check missed the same-path reinstall case (review
|
|
229
|
+
* LOW-2, observed live: OpenClaw recreates dist at the same path in ~45s, so
|
|
230
|
+
* `existsSync` stayed true and the old-version poller ran on). Tests override
|
|
231
|
+
* this path via `opts.sentinelPath`.
|
|
232
|
+
*/
|
|
233
|
+
const MODULE_SENTINEL = fileURLToPath(import.meta.url);
|
|
234
|
+
|
|
235
|
+
/** File-identity fingerprint used to detect a same-path replacement. */
|
|
236
|
+
type SentinelIdentity = { ino: number; mtimeMs: number };
|
|
237
|
+
|
|
140
238
|
/**
|
|
141
239
|
* Start the trajectory poller. Runs an initial poll after 5 s, then
|
|
142
240
|
* every `pollIntervalMs` (default 60 s). Returns a handle the caller
|
|
143
241
|
* can use to stop polling and run one-shot polls in tests.
|
|
242
|
+
*
|
|
243
|
+
* Lifecycle guards (rc.20, #402): a previously-started poller from this module
|
|
244
|
+
* instance is stopped first (singleton), and each tick self-terminates if this
|
|
245
|
+
* module's file is gone.
|
|
144
246
|
*/
|
|
145
247
|
export function startTrajectoryPoller(
|
|
146
248
|
deps: TrajectoryPollerDeps,
|
|
147
|
-
opts: { pollIntervalMs?: number; stateFile?: string } = {},
|
|
249
|
+
opts: { pollIntervalMs?: number; stateFile?: string; sentinelPath?: string } = {},
|
|
148
250
|
): TrajectoryPollerHandle {
|
|
149
251
|
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
150
252
|
const stateFile = opts.stateFile ?? STATE_FILE;
|
|
253
|
+
const sentinelPath = opts.sentinelPath ?? MODULE_SENTINEL;
|
|
254
|
+
|
|
255
|
+
// Capture the sentinel's file identity (inode + mtime) once at start so each
|
|
256
|
+
// tick can tell "same file still there" from "different file swapped in at
|
|
257
|
+
// the same path" (reinstall/upgrade). If the initial stat throws, fall back
|
|
258
|
+
// to existence-only semantics rather than crashing startup.
|
|
259
|
+
let sentinelIdentity: SentinelIdentity | null = null;
|
|
260
|
+
try {
|
|
261
|
+
const st = fs.statSync(sentinelPath);
|
|
262
|
+
sentinelIdentity = { ino: st.ino, mtimeMs: st.mtimeMs };
|
|
263
|
+
} catch {
|
|
264
|
+
sentinelIdentity = null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Singleton guard: stop any poller this module instance started earlier so a
|
|
268
|
+
// same-version re-register (in-process SIGUSR1 restart) does not stack a
|
|
269
|
+
// second live setInterval on top of the first.
|
|
270
|
+
if (activePoller) {
|
|
271
|
+
activePoller.stop();
|
|
272
|
+
deps.logger.info('extractd: previous poller stopped (re-register)');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
276
|
+
let initialTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
277
|
+
|
|
278
|
+
const stop = (): void => {
|
|
279
|
+
if (timer) clearInterval(timer);
|
|
280
|
+
if (initialTimeout) clearTimeout(initialTimeout);
|
|
281
|
+
if (activePoller === handle) activePoller = null;
|
|
282
|
+
};
|
|
151
283
|
|
|
152
284
|
const pollAndExtract = async (): Promise<void> => {
|
|
153
285
|
try {
|
|
286
|
+
// Cross-version self-termination: if this module's own file is gone, or a
|
|
287
|
+
// DIFFERENT file was swapped in at the same path (uninstall→reinstall of a
|
|
288
|
+
// newer version), stop — a zombie poller from a stale module instance must
|
|
289
|
+
// not keep capturing.
|
|
290
|
+
let sentinelStat: fs.Stats | null = null;
|
|
291
|
+
try {
|
|
292
|
+
sentinelStat = fs.statSync(sentinelPath);
|
|
293
|
+
} catch {
|
|
294
|
+
// File gone (plugin dir removed).
|
|
295
|
+
deps.logger.warn('extractd: poller self-terminated (plugin dir removed)');
|
|
296
|
+
stop();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (
|
|
300
|
+
sentinelIdentity !== null &&
|
|
301
|
+
(sentinelStat.ino !== sentinelIdentity.ino || sentinelStat.mtimeMs !== sentinelIdentity.mtimeMs)
|
|
302
|
+
) {
|
|
303
|
+
deps.logger.warn('extractd: poller self-terminated (plugin file replaced — reinstall/upgrade detected)');
|
|
304
|
+
stop();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
154
307
|
await deps.ensureInitialized();
|
|
155
308
|
if (deps.isPairingPending()) return;
|
|
156
309
|
if (deps.isImportActive()) return;
|
|
@@ -244,25 +397,24 @@ export function startTrajectoryPoller(
|
|
|
244
397
|
}
|
|
245
398
|
};
|
|
246
399
|
|
|
247
|
-
|
|
400
|
+
timer = setInterval(() => {
|
|
248
401
|
void pollAndExtract();
|
|
249
402
|
}, pollIntervalMs);
|
|
250
403
|
if (typeof timer.unref === 'function') timer.unref();
|
|
251
404
|
|
|
252
|
-
|
|
405
|
+
initialTimeout = setTimeout(() => {
|
|
253
406
|
void pollAndExtract();
|
|
254
407
|
}, 5_000);
|
|
255
408
|
if (typeof initialTimeout.unref === 'function') initialTimeout.unref();
|
|
256
409
|
|
|
257
410
|
deps.logger.info(`extractd: trajectory poller started (interval=${Math.round(pollIntervalMs / 1000)}s)`);
|
|
258
411
|
|
|
259
|
-
|
|
260
|
-
stop
|
|
261
|
-
clearInterval(timer);
|
|
262
|
-
clearTimeout(initialTimeout);
|
|
263
|
-
},
|
|
412
|
+
const handle: TrajectoryPollerHandle = {
|
|
413
|
+
stop,
|
|
264
414
|
pollOnce: pollAndExtract,
|
|
265
415
|
};
|
|
416
|
+
activePoller = handle;
|
|
417
|
+
return handle;
|
|
266
418
|
}
|
|
267
419
|
|
|
268
420
|
// ---------------------------------------------------------------------------
|