@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/index.ts
CHANGED
|
@@ -8,37 +8,55 @@
|
|
|
8
8
|
/**
|
|
9
9
|
* TotalReclaw Plugin for OpenClaw
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* -
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
11
|
+
* Phase 3 — OpenClaw native integration. The agent reads memories via the
|
|
12
|
+
* bundled NATIVE tools `memory_search` / `memory_get`, registered through
|
|
13
|
+
* the host's MemoryPluginCapability by `registerNativeMemory` (see
|
|
14
|
+
* `native-memory.ts`). The TrMemorySearchManager adapter binds those tools
|
|
15
|
+
* to TotalReclaw's encrypted subgraph + decrypt + reranker pipeline.
|
|
16
|
+
*
|
|
17
|
+
* The legacy `totalreclaw_*` agent tools (remember / recall / forget / export
|
|
18
|
+
* / status / pin / unpin / retype / set_scope / import_from / import_status
|
|
19
|
+
* / import_abort / upgrade / migrate / onboarding_start / setup /
|
|
20
|
+
* report_qa_bug) were RETIRED in Task 3.2. Their capabilities now live on:
|
|
21
|
+
* - read (recall): native `memory_search` / `memory_get`.
|
|
22
|
+
* - explicit write: CLI `tr remember` (the conventional memory
|
|
23
|
+
* contract ships no agent-facing write tool;
|
|
24
|
+
* auto-extraction handles implicit writes).
|
|
25
|
+
* - curation/lifecycle: CLI `tr forget` / `tr export` + the
|
|
26
|
+
* registerCli `openclaw totalreclaw ...` surface.
|
|
27
|
+
* - import/upgrade: registerCli `openclaw totalreclaw import from ...`
|
|
28
|
+
* / `import status` / `import abort` / `upgrade`
|
|
29
|
+
* (3.3.13 — restored as CLI surfaces; the handlers
|
|
30
|
+
* stayed in this file post-3.2 but had no entry
|
|
31
|
+
* point). NOT on the standalone `tr` binary —
|
|
32
|
+
* import needs the full extraction pipeline that
|
|
33
|
+
* only loads inside the gateway runtime.
|
|
34
|
+
* - onboarding/pair: CLI `tr pair` + the 4 `/plugin/totalreclaw/pair/*`
|
|
35
|
+
* HTTP routes (registerHttpRoute bundle).
|
|
36
|
+
*
|
|
37
|
+
* Hooks registered here:
|
|
38
|
+
* - `before_agent_start` — injects relevant memories into the agent's
|
|
39
|
+
* context (via the MemoryPluginCapability's promptBuilder) and a
|
|
40
|
+
* non-secret onboarding hint when the user has not paired yet.
|
|
41
|
+
* - `before_tool_call` — gates the native `memory_search` / `memory_get`
|
|
42
|
+
* tools behind onboarding state `active` so an unpaired agent gets an
|
|
43
|
+
* actionable pointer to `tr pair --url-pin` instead of the adapter's
|
|
44
|
+
* silent fail-soft empty result (see `tool-gating.ts`).
|
|
45
|
+
* - `agent_end`, `message_received`, `before_reset` — auto-extraction
|
|
46
|
+
* + digest bookkeeping (unchanged).
|
|
27
47
|
*
|
|
28
48
|
* Also registers:
|
|
29
|
-
* - `
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* - `
|
|
35
|
-
*
|
|
36
|
-
* the user's terminal; the phrase never enters an LLM request or a
|
|
37
|
-
* session transcript.
|
|
49
|
+
* - `registerCli` subcommand `openclaw totalreclaw ...` — pair / onboard /
|
|
50
|
+
* status / pin / unpin / retype / set_scope / import / export. The
|
|
51
|
+
* `onboard` subcommand is the ONLY surface that generates or accepts a
|
|
52
|
+
* recovery phrase; it lives entirely on the user's terminal and the
|
|
53
|
+
* phrase never enters an LLM request or a session transcript.
|
|
54
|
+
* - `registerHttpRoute` — the 4 QR-pair endpoints under
|
|
55
|
+
* `/plugin/totalreclaw/pair/*` (browser-facing, plugin-auth).
|
|
38
56
|
* - `registerCommand` slash command `/totalreclaw {onboard,status}` — a
|
|
39
|
-
* non-secret pointer
|
|
57
|
+
* non-secret pointer to the CLI wizard.
|
|
40
58
|
*
|
|
41
|
-
* Security:
|
|
59
|
+
* Security: the recovery phrase NEVER appears in tool responses,
|
|
42
60
|
* `prependContext` blocks, slash-command replies, or any other surface that
|
|
43
61
|
* is sent to the LLM provider or persisted to the session transcript. See
|
|
44
62
|
* `docs/plans/2026-04-20-plugin-320-secure-onboarding.md` in the internal
|
|
@@ -135,13 +153,6 @@ import {
|
|
|
135
153
|
validatePinArgs,
|
|
136
154
|
type PinOpDeps,
|
|
137
155
|
} from './pin.js';
|
|
138
|
-
import {
|
|
139
|
-
executeRetype,
|
|
140
|
-
executeSetScope,
|
|
141
|
-
validateRetypeArgs,
|
|
142
|
-
validateSetScopeArgs,
|
|
143
|
-
type RetypeSetScopeDeps,
|
|
144
|
-
} from './retype-setscope.js';
|
|
145
156
|
import {
|
|
146
157
|
runNonInteractiveOnboard,
|
|
147
158
|
type NonInteractiveOnboardResult,
|
|
@@ -169,16 +180,9 @@ import {
|
|
|
169
180
|
detectPartialInstall,
|
|
170
181
|
clearPartialInstallMarker,
|
|
171
182
|
patchOpenClawConfig,
|
|
172
|
-
writePluginManifest,
|
|
173
|
-
writePluginError,
|
|
174
|
-
readPluginLoadedManifest,
|
|
175
183
|
checkCredentialsFileMode,
|
|
176
|
-
deletePairPendingFile,
|
|
177
|
-
defaultPairPendingPath,
|
|
178
184
|
type OnboardingState,
|
|
179
185
|
} from './fs-helpers.js';
|
|
180
|
-
import { maybeStartAutoPair } from './auto-pair-on-load.js';
|
|
181
|
-
import { installBeforeAgentStartHook as installPairPendingHook } from './pair-pending-injection.js';
|
|
182
186
|
import { isRcBuild } from './qa-bug-report.js';
|
|
183
187
|
import { decideToolGate, isGatedToolName } from './tool-gating.js';
|
|
184
188
|
import {
|
|
@@ -194,6 +198,9 @@ import {
|
|
|
194
198
|
import { detectFirstRun, buildWelcomePrepend, type GatewayMode } from './first-run.js';
|
|
195
199
|
import { buildPairRoutes } from './pair-http.js';
|
|
196
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
204
|
import { validateMnemonic } from '@scure/bip39';
|
|
198
205
|
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
199
206
|
import crypto from 'node:crypto';
|
|
@@ -439,16 +446,18 @@ function buildPairingUrl(
|
|
|
439
446
|
// issue #110 fix 4: inside Docker the LAN IP is container-internal
|
|
440
447
|
// and useless. Loopback localhost only works for `docker exec`
|
|
441
448
|
// tests. The CORRECT pair URL for Docker is the relay-brokered
|
|
442
|
-
// path served by
|
|
443
|
-
// === 'relay' since rc.11). The CLI-only
|
|
444
|
-
// relay session synchronously (the relay
|
|
445
|
-
// round-trip), so we emit the loopback URL
|
|
446
|
-
// pointing the operator at the
|
|
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.
|
|
447
455
|
api.logger.warn(
|
|
448
456
|
`TotalReclaw: Docker container detected — pairing URL falling back to ` +
|
|
449
457
|
`http://localhost:${port}, which is unreachable from the host browser. ` +
|
|
450
|
-
`
|
|
451
|
-
`
|
|
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 ` +
|
|
452
461
|
`to your gateway's host-reachable URL (e.g. http://<host-ip>:${port} when the ` +
|
|
453
462
|
`Docker port is published). Setting TOTALRECLAW_PAIR_MODE=relay is the default; ` +
|
|
454
463
|
`air-gapped operators on TOTALRECLAW_PAIR_MODE=local must publish a port + set publicUrl.`,
|
|
@@ -661,6 +670,11 @@ function getMaxFactsPerExtraction(): number {
|
|
|
661
670
|
}
|
|
662
671
|
const cache = readBillingCache();
|
|
663
672
|
if (cache?.features?.max_facts_per_extraction != null) return cache.features.max_facts_per_extraction;
|
|
673
|
+
// Env override is read in config.ts (env access is centralized there so
|
|
674
|
+
// the env-harvesting scanner-sim stays a no-op for index.ts).
|
|
675
|
+
if (CONFIG.maxFactsPerExtraction && CONFIG.maxFactsPerExtraction > 0) {
|
|
676
|
+
return CONFIG.maxFactsPerExtraction;
|
|
677
|
+
}
|
|
664
678
|
return MAX_FACTS_PER_EXTRACTION;
|
|
665
679
|
}
|
|
666
680
|
|
|
@@ -678,9 +692,10 @@ const MEMORY_HEADER = `# Memory
|
|
|
678
692
|
|
|
679
693
|
> **TotalReclaw is active.** Your encrypted memories are loaded automatically
|
|
680
694
|
> at the start of each conversation — no need to search this file for them.
|
|
681
|
-
>
|
|
682
|
-
>
|
|
683
|
-
> This file is for workspace-level
|
|
695
|
+
> Recall is automatic via the memory_search tool; to explicitly store a fact,
|
|
696
|
+
> run \`tr remember "<text>"\` in a shell. Do NOT write user facts,
|
|
697
|
+
> preferences, or decisions to this file. This file is for workspace-level
|
|
698
|
+
> notes only (non-sensitive).
|
|
684
699
|
|
|
685
700
|
`;
|
|
686
701
|
|
|
@@ -846,13 +861,12 @@ async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
|
|
|
846
861
|
|
|
847
862
|
if (!masterPassword) {
|
|
848
863
|
needsSetup = true;
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
855
|
-
logger.info('TotalReclaw: no credentials found; auto-pair-on-load will surface a pair URL.');
|
|
864
|
+
// No credentials yet — pairing is user-initiated via `tr pair` or the
|
|
865
|
+
// `/plugin/totalreclaw/pair/*` HTTP route (QR scan flow). The plugin
|
|
866
|
+
// does not auto-open a pair session on load (the 3.3.13 auto-pair-on-
|
|
867
|
+
// load state machine was retired in Phase 3.4 — pairing is now
|
|
868
|
+
// explicitly user-triggered per the native-integration design).
|
|
869
|
+
logger.info('TotalReclaw: no credentials found. Run `tr pair` (or ask the agent to) to complete setup.');
|
|
856
870
|
return;
|
|
857
871
|
}
|
|
858
872
|
|
|
@@ -992,12 +1006,16 @@ async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
|
|
|
992
1006
|
const billingData = await resp.json() as Record<string, unknown>;
|
|
993
1007
|
const tier = billingData.tier as string;
|
|
994
1008
|
const expiresAt = billingData.expires_at as string | undefined;
|
|
995
|
-
// Populate billing cache for future use.
|
|
1009
|
+
// Populate billing cache for future use. Copy the relay's
|
|
1010
|
+
// authoritative chain_id + data_edge_address so they land on disk and
|
|
1011
|
+
// drive the runtime chain + DataEdge overrides verbatim (#402, #460).
|
|
996
1012
|
writeBillingCache({
|
|
997
1013
|
tier: tier || 'free',
|
|
998
1014
|
free_writes_used: (billingData.free_writes_used as number) ?? 0,
|
|
999
1015
|
free_writes_limit: (billingData.free_writes_limit as number) ?? 0,
|
|
1000
1016
|
features: billingData.features as BillingCache['features'] | undefined,
|
|
1017
|
+
chain_id: billingData.chain_id as number | undefined,
|
|
1018
|
+
data_edge_address: billingData.data_edge_address as string | undefined,
|
|
1001
1019
|
checked_at: Date.now(),
|
|
1002
1020
|
});
|
|
1003
1021
|
if (tier === 'pro' && expiresAt) {
|
|
@@ -1038,10 +1056,16 @@ function isDocker(): boolean {
|
|
|
1038
1056
|
}
|
|
1039
1057
|
|
|
1040
1058
|
function buildSetupErrorMsg(): string {
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1059
|
+
// NOTE: the legacy `totalreclaw_setup` agent tool was retired in 3.2.0
|
|
1060
|
+
// (phrase-safety: the agent must never accept or relay a recovery phrase).
|
|
1061
|
+
// The ONLY setup surface is the QR-pair flow: the agent cannot mint a
|
|
1062
|
+
// pair URL itself, so it must direct the user to the CLI. This message is
|
|
1063
|
+
// thrown by `requireFullSetup` (currently only reached via dead 3.2 tool
|
|
1064
|
+
// handlers; kept accurate so any future caller gets the correct pointer).
|
|
1065
|
+
return 'TotalReclaw setup required. Pairing is QR-only — the recovery phrase is generated and encrypted in-browser and never enters this chat.\n\n' +
|
|
1066
|
+
'Run `tr pair --url-pin` on the gateway host (or `openclaw totalreclaw pair generate --url-pin-only`) ' +
|
|
1067
|
+
'and hand the user the returned `url` and `pin`. The user opens the URL in a browser to complete pairing. ' +
|
|
1068
|
+
'Do NOT ask the user for a recovery phrase and do NOT attempt to generate or relay one yourself.';
|
|
1045
1069
|
}
|
|
1046
1070
|
|
|
1047
1071
|
function buildSetupErrorMsgLegacy(): string {
|
|
@@ -1077,8 +1101,9 @@ const SETUP_ERROR_MSG = buildSetupErrorMsg();
|
|
|
1077
1101
|
* Ensure `initialize()` has completed (runs at most once).
|
|
1078
1102
|
*
|
|
1079
1103
|
* If `needsSetup` is true after init, attempts a hot-reload from
|
|
1080
|
-
* credentials.json in case the mnemonic was written there by
|
|
1081
|
-
*
|
|
1104
|
+
* credentials.json in case the mnemonic was just written there by the
|
|
1105
|
+
* pair-completion HTTP route (`/plugin/totalreclaw/pair/respond` →
|
|
1106
|
+
* `completePairing`) or the `tr pair` CLI on another process.
|
|
1082
1107
|
*/
|
|
1083
1108
|
async function ensureInitialized(logger: OpenClawPluginApi['logger']): Promise<void> {
|
|
1084
1109
|
if (!initPromise) {
|
|
@@ -1087,7 +1112,7 @@ async function ensureInitialized(logger: OpenClawPluginApi['logger']): Promise<v
|
|
|
1087
1112
|
await initPromise;
|
|
1088
1113
|
|
|
1089
1114
|
// Hot-reload: if setup is still needed, check if credentials.json
|
|
1090
|
-
// now has a mnemonic (written by
|
|
1115
|
+
// now has a mnemonic (written by the pair HTTP route / `tr pair` CLI).
|
|
1091
1116
|
if (needsSetup) {
|
|
1092
1117
|
await attemptHotReload(logger);
|
|
1093
1118
|
}
|
|
@@ -1097,9 +1122,9 @@ async function ensureInitialized(logger: OpenClawPluginApi['logger']): Promise<v
|
|
|
1097
1122
|
* Attempt to hot-reload credentials from credentials.json.
|
|
1098
1123
|
*
|
|
1099
1124
|
* Called when `needsSetup` is true — checks if credentials.json contains
|
|
1100
|
-
* a mnemonic (written by the
|
|
1101
|
-
* If found, re-derives keys and completes
|
|
1102
|
-
* a gateway restart.
|
|
1125
|
+
* a mnemonic (written by the pair-completion HTTP route or `tr pair` CLI
|
|
1126
|
+
* on another process). If found, re-derives keys and completes
|
|
1127
|
+
* initialization without requiring a gateway restart.
|
|
1103
1128
|
*/
|
|
1104
1129
|
async function attemptHotReload(logger: OpenClawPluginApi['logger']): Promise<void> {
|
|
1105
1130
|
try {
|
|
@@ -1125,9 +1150,18 @@ async function attemptHotReload(logger: OpenClawPluginApi['logger']): Promise<vo
|
|
|
1125
1150
|
/**
|
|
1126
1151
|
* Force re-initialization with a specific mnemonic.
|
|
1127
1152
|
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1153
|
+
* LEGACY (Phase 3.2): the only caller was the `totalreclaw_setup` agent
|
|
1154
|
+
* tool, which was retired because accepting a recovery phrase via an agent
|
|
1155
|
+
* tool violated phrase-safety (the phrase must never enter an LLM context).
|
|
1156
|
+
* The function currently has NO callers; it is retained because the
|
|
1157
|
+
* credential-rotation invariant documented below still describes a real
|
|
1158
|
+
* trap for any future credential-rotating surface, and removing the body
|
|
1159
|
+
* would lose that institutional knowledge. Safe to delete once confirmed
|
|
1160
|
+
* unused across the whole plugin tree.
|
|
1161
|
+
*
|
|
1162
|
+
* Clears stale credentials from disk so that `initialize()` treats this as
|
|
1163
|
+
* a fresh registration and persists the NEW mnemonic + freshly derived
|
|
1164
|
+
* salt/userId.
|
|
1131
1165
|
*
|
|
1132
1166
|
* Without clearing credentials.json first, `initialize()` would load the
|
|
1133
1167
|
* OLD salt and userId, derive keys from (new mnemonic + old salt), skip
|
|
@@ -1797,7 +1831,7 @@ function relativeTime(isoOrMs: string | number): string {
|
|
|
1797
1831
|
* Configurable via TOTALRECLAW_MIN_IMPORTANCE env var (default: 3).
|
|
1798
1832
|
*
|
|
1799
1833
|
* NOTE: This filter is ONLY applied to auto-extraction (hooks).
|
|
1800
|
-
* The explicit `
|
|
1834
|
+
* The explicit `tr remember` CLI always stores regardless of importance.
|
|
1801
1835
|
*/
|
|
1802
1836
|
const MIN_IMPORTANCE_THRESHOLD = CONFIG.minImportance;
|
|
1803
1837
|
|
|
@@ -2219,7 +2253,7 @@ async function storeExtractedFacts(
|
|
|
2219
2253
|
// Submit subgraph payloads one fact at a time (sequential single-call UserOps).
|
|
2220
2254
|
// Batch executeBatch UserOps have persistent gas estimation issues on Base Sepolia
|
|
2221
2255
|
// that cause on-chain reverts. Single-fact UserOps use the simpler submitFactOnChain
|
|
2222
|
-
// path which works reliably (same path
|
|
2256
|
+
// path which works reliably (same path the `tr remember` CLI uses). Each submission
|
|
2223
2257
|
// polls for receipt (120s) before proceeding, so nonce is consumed before the next.
|
|
2224
2258
|
let batchError: string | undefined;
|
|
2225
2259
|
if (pendingPayloads.length > 0 && isSubgraphMode()) {
|
|
@@ -2272,11 +2306,12 @@ async function storeExtractedFacts(
|
|
|
2272
2306
|
}
|
|
2273
2307
|
|
|
2274
2308
|
// ---------------------------------------------------------------------------
|
|
2275
|
-
// Import handler (for
|
|
2309
|
+
// Import handler (for the registerCli `openclaw totalreclaw import-from` surface)
|
|
2276
2310
|
// ---------------------------------------------------------------------------
|
|
2277
2311
|
|
|
2278
2312
|
/**
|
|
2279
|
-
* Handle import_from
|
|
2313
|
+
* Handle import_from calls (CLI subcommand path; was the totalreclaw_import_from
|
|
2314
|
+
* agent tool before Phase 3.2 retired the agent tools).
|
|
2280
2315
|
*
|
|
2281
2316
|
* Two paths:
|
|
2282
2317
|
* 1. Pre-structured sources (Mem0, MCP Memory) — adapter returns facts directly,
|
|
@@ -2423,7 +2458,7 @@ async function handlePluginImportFrom(
|
|
|
2423
2458
|
estimated_batches: estimatedBatches,
|
|
2424
2459
|
estimated_minutes: estimatedMinutes,
|
|
2425
2460
|
estimated_completion_iso: initialState.estimated_completion_iso,
|
|
2426
|
-
message: `Import started in background. ~${estimatedMinutes} min for ${totalChunks} chunks.
|
|
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).`,
|
|
2427
2462
|
warnings: parseResult.warnings,
|
|
2428
2463
|
};
|
|
2429
2464
|
}
|
|
@@ -3003,7 +3038,7 @@ async function handleImportStatus(
|
|
|
3003
3038
|
if (!state) return { error: `No import found with id: ${importId}` };
|
|
3004
3039
|
} else {
|
|
3005
3040
|
state = readMostRecentActiveImport();
|
|
3006
|
-
if (!state) return { status: 'no_active_import', message: 'No active import found. Start one with
|
|
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.)' };
|
|
3007
3042
|
}
|
|
3008
3043
|
|
|
3009
3044
|
// 1h freshness guard: mark stale imports as failed and prompt user to resume.
|
|
@@ -3015,7 +3050,7 @@ async function handleImportStatus(
|
|
|
3015
3050
|
status: 'failed',
|
|
3016
3051
|
stale: true,
|
|
3017
3052
|
facts_stored: state.facts_stored,
|
|
3018
|
-
message: 'Import appears stale — no progress in 1 hour. Resume with
|
|
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.',
|
|
3019
3054
|
resume_id: state.import_id,
|
|
3020
3055
|
};
|
|
3021
3056
|
}
|
|
@@ -3071,6 +3106,339 @@ async function handleImportAbort(
|
|
|
3071
3106
|
};
|
|
3072
3107
|
}
|
|
3073
3108
|
|
|
3109
|
+
// ---------------------------------------------------------------------------
|
|
3110
|
+
// buildRecallDeps — bind the real recall pipeline into the closures the
|
|
3111
|
+
// native MemoryPluginCapability wiring helper (Task 2.7) consumes.
|
|
3112
|
+
//
|
|
3113
|
+
// WHY THIS LIVES IN index.ts (not in native-memory.ts):
|
|
3114
|
+
// The real `recall` / `getById` closures must reach unexported index.ts
|
|
3115
|
+
// helpers (generateBlindIndices, generateEmbedding, getLSHHasher,
|
|
3116
|
+
// computeCandidatePool, isDigestBlob, readClaimFromBlob, searchSubgraph,
|
|
3117
|
+
// searchSubgraphBroadened, getSubgraphFactCount, fetchFactById,
|
|
3118
|
+
// ensureInitialized) AND module-level state (authKeyHex, encryptionKey,
|
|
3119
|
+
// userId, subgraphOwner). Lifting these out of index.ts is a high
|
|
3120
|
+
// blast-radius refactor with scanner-trap risk — out of scope for 2.7.
|
|
3121
|
+
//
|
|
3122
|
+
// native-memory.ts (the wiring helper) is pure orchestration and stays
|
|
3123
|
+
// scanner-trivial; the closures stay here where the rest of the plugin's
|
|
3124
|
+
// network surface lives.
|
|
3125
|
+
//
|
|
3126
|
+
// LAZY CONTEXT RESOLUTION:
|
|
3127
|
+
// The paired-account context (authKeyHex / encryptionKey / userId /
|
|
3128
|
+
// subgraphOwner) is NOT resolved synchronously at register() time. It is
|
|
3129
|
+
// populated by `initialize()` on the first tool/hook call via
|
|
3130
|
+
// `ensureInitialized()`. So each closure calls `ensureInitialized(logger)`
|
|
3131
|
+
// internally before touching the module-level state — the same lazy-init
|
|
3132
|
+
// seam the retired totalreclaw_recall tool used to use (then via
|
|
3133
|
+
// `requireFullSetup`).
|
|
3134
|
+
//
|
|
3135
|
+
// If setup is incomplete (no credentials), `ensureInitialized` returns
|
|
3136
|
+
// with `needsSetup=true`; the closures then surface a typed error
|
|
3137
|
+
// (`getMemorySearchManager` will return `{ manager: null, error }` from
|
|
3138
|
+
// the runtime wrapper, which the tools convert into the disabled-result
|
|
3139
|
+
// payload the agent recognizes).
|
|
3140
|
+
//
|
|
3141
|
+
// SCANNER NOTE:
|
|
3142
|
+
// This file (index.ts) is NOT scanner-clean — it is the plugin's network
|
|
3143
|
+
// surface and contains the env+net pair legitimately (centralized via
|
|
3144
|
+
// config.ts reads + relay.ts). The closures here CALL the scanner-clean
|
|
3145
|
+
// subgraph-search / vault-crypto / reranker modules but do not add any
|
|
3146
|
+
// NEW env-harvesting or exfiltration pattern: they read only the
|
|
3147
|
+
// already-resolved module-level state. `check-scanner` was already
|
|
3148
|
+
// non-zero on index.ts before this task (the file legitimately pairs
|
|
3149
|
+
// config reads with network calls); the closures do not change that
|
|
3150
|
+
// posture. The NEW files (native-memory.ts, register-native.test.ts)
|
|
3151
|
+
// are scanner-clean by construction (verified).
|
|
3152
|
+
// ---------------------------------------------------------------------------
|
|
3153
|
+
|
|
3154
|
+
/**
|
|
3155
|
+
* Build the deps for the native MemoryPluginCapability. Returns the
|
|
3156
|
+
* `recall` / `getById` closures bound to the real subgraph + decrypt +
|
|
3157
|
+
* reranker pipeline, plus optional `quota` / `pinned` prompt-builder inputs
|
|
3158
|
+
* (currently defaulted — see TODO(task 2.7b / H1 gate) below).
|
|
3159
|
+
*
|
|
3160
|
+
* `logger` is threaded in so the closures can call `ensureInitialized` (the
|
|
3161
|
+
* lazy-init seam used by every other tool handler in this file).
|
|
3162
|
+
*
|
|
3163
|
+
* @param logger the OpenClaw plugin logger (forwarded into ensureInitialized)
|
|
3164
|
+
*/
|
|
3165
|
+
function buildRecallDeps(logger: OpenClawPluginApi['logger']): TrNativeMemoryDeps {
|
|
3166
|
+
// -------------------------------------------------------------------
|
|
3167
|
+
// recall(): the load-bearing closure. This is the search/decrypt/rerank
|
|
3168
|
+
// pipeline that backs the native memory_search tool via the
|
|
3169
|
+
// TrMemorySearchManager adapter. It replaced the retired totalreclaw_recall
|
|
3170
|
+
// agent tool handler (Phase 3.2) MINUS the tool-level result formatting +
|
|
3171
|
+
// hot-cache bookkeeping (those are tool concerns; the native memory
|
|
3172
|
+
// pipeline only needs the TrFact[]). Returns TrFact[] shaped for the
|
|
3173
|
+
// TrMemorySearchManager adapter (memory-runtime.ts).
|
|
3174
|
+
// -------------------------------------------------------------------
|
|
3175
|
+
const recall: TrNativeMemoryDeps['recall'] = async (
|
|
3176
|
+
query,
|
|
3177
|
+
opts,
|
|
3178
|
+
): Promise<TrFact[]> => {
|
|
3179
|
+
// Lazy-init: this is the first seam the closure hits. If the user
|
|
3180
|
+
// is not paired, ensureInitialized returns with needsSetup=true; we
|
|
3181
|
+
// surface that as an empty result (the before_tool_call gate in
|
|
3182
|
+
// tool-gating.ts normally intercepts memory_search BEFORE this runs
|
|
3183
|
+
// when state != active, but fail-soft here too — a search with no
|
|
3184
|
+
// credentials returning [] is benign; the agent treats empty
|
|
3185
|
+
// results as "no memories found").
|
|
3186
|
+
await ensureInitialized(logger);
|
|
3187
|
+
|
|
3188
|
+
// Guard: if setup is incomplete OR we're missing the pipeline state,
|
|
3189
|
+
// return []. This is fail-soft: the user sees "no memories" rather
|
|
3190
|
+
// than a thrown error out of the memory_search tool boundary.
|
|
3191
|
+
if (needsSetup || !encryptionKey || !authKeyHex) return [];
|
|
3192
|
+
// subgraphOwner may be null on SA-derivation failure (see initialize()).
|
|
3193
|
+
// The subgraph path requires a non-null owner (Bytes!); if missing,
|
|
3194
|
+
// we cannot run recall — return [].
|
|
3195
|
+
if (isSubgraphMode() && !subgraphOwner) return [];
|
|
3196
|
+
|
|
3197
|
+
const k = Math.min(opts?.maxResults ?? 8, 20);
|
|
3198
|
+
|
|
3199
|
+
// 1. Generate word trapdoors (blind indices for the query).
|
|
3200
|
+
const wordTrapdoors = generateBlindIndices(query);
|
|
3201
|
+
|
|
3202
|
+
// 2. Generate query embedding + LSH trapdoors (may fail gracefully).
|
|
3203
|
+
let queryEmbedding: number[] | null = null;
|
|
3204
|
+
let lshTrapdoors: string[] = [];
|
|
3205
|
+
try {
|
|
3206
|
+
queryEmbedding = await generateEmbedding(query, { isQuery: true });
|
|
3207
|
+
const hasher = getLSHHasher(logger);
|
|
3208
|
+
if (hasher && queryEmbedding) {
|
|
3209
|
+
lshTrapdoors = hasher.hash(queryEmbedding);
|
|
3210
|
+
}
|
|
3211
|
+
} catch (err) {
|
|
3212
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3213
|
+
logger.warn(`native recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
// 3. Merge word + LSH trapdoors.
|
|
3217
|
+
const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
|
|
3218
|
+
if (allTrapdoors.length === 0) return [];
|
|
3219
|
+
|
|
3220
|
+
// 4. Build reranker candidates from the decrypted subgraph results.
|
|
3221
|
+
const rerankerCandidates: RerankerCandidate[] = [];
|
|
3222
|
+
|
|
3223
|
+
if (isSubgraphMode()) {
|
|
3224
|
+
// --- Subgraph search path (the canonical path for managed installs) ---
|
|
3225
|
+
const factCount = await getSubgraphFactCount(subgraphOwner || userId!, authKeyHex);
|
|
3226
|
+
const pool = computeCandidatePool(factCount);
|
|
3227
|
+
let subgraphResults = await searchSubgraph(subgraphOwner || userId!, allTrapdoors, pool, authKeyHex);
|
|
3228
|
+
|
|
3229
|
+
// Broadened search + merge — vocabulary-mismatch safety net (mirrors
|
|
3230
|
+
// the recall tool: ensures "preferences" still matches "prefer").
|
|
3231
|
+
try {
|
|
3232
|
+
const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId!, pool, authKeyHex);
|
|
3233
|
+
const existingIds = new Set(subgraphResults.map((r) => r.id));
|
|
3234
|
+
for (const br of broadenedResults) {
|
|
3235
|
+
if (!existingIds.has(br.id)) subgraphResults.push(br);
|
|
3236
|
+
}
|
|
3237
|
+
} catch {
|
|
3238
|
+
// best-effort
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
for (const result of subgraphResults) {
|
|
3242
|
+
try {
|
|
3243
|
+
const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
|
|
3244
|
+
if (isDigestBlob(docJson)) continue;
|
|
3245
|
+
const doc = readClaimFromBlob(docJson);
|
|
3246
|
+
|
|
3247
|
+
let decryptedEmbedding: number[] | undefined;
|
|
3248
|
+
if (result.encryptedEmbedding) {
|
|
3249
|
+
try {
|
|
3250
|
+
decryptedEmbedding = JSON.parse(
|
|
3251
|
+
decryptFromHex(result.encryptedEmbedding, encryptionKey),
|
|
3252
|
+
);
|
|
3253
|
+
} catch {
|
|
3254
|
+
// embedding decryption failed -- proceed without it
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
// Dim-mismatch fallback: regenerate the embedding from text so
|
|
3259
|
+
// the reranker's cosine component stays meaningful across model
|
|
3260
|
+
// upgrades. Mirrors the recall tool exactly.
|
|
3261
|
+
if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
|
|
3262
|
+
try {
|
|
3263
|
+
decryptedEmbedding = await generateEmbedding(doc.text);
|
|
3264
|
+
} catch {
|
|
3265
|
+
decryptedEmbedding = undefined;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
rerankerCandidates.push({
|
|
3270
|
+
id: result.id,
|
|
3271
|
+
text: doc.text,
|
|
3272
|
+
embedding: decryptedEmbedding,
|
|
3273
|
+
importance: doc.importance / 10,
|
|
3274
|
+
createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
|
|
3275
|
+
// Retrieval v2 Tier 1 source — surfaced so applySourceWeights
|
|
3276
|
+
// could multiply the final RRF score (left false here to match
|
|
3277
|
+
// the recall tool's current behavior; TODO(task 2.7b): wire
|
|
3278
|
+
// source weighting for the native path at the H1 QA gate).
|
|
3279
|
+
source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
|
|
3280
|
+
});
|
|
3281
|
+
} catch {
|
|
3282
|
+
// Skip candidates we cannot decrypt (corrupted / wrong key).
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
} else {
|
|
3286
|
+
// --- Server search path (legacy / self-hosted) ---
|
|
3287
|
+
// The non-subgraph path uses apiClient.search. The native memory
|
|
3288
|
+
// pipeline is intended for managed (subgraph) installs, but we keep
|
|
3289
|
+
// parity with the recall tool so self-hosted users get recall too.
|
|
3290
|
+
if (!apiClient || !userId) return [];
|
|
3291
|
+
const factCount = await getFactCount(logger);
|
|
3292
|
+
const pool = computeCandidatePool(factCount);
|
|
3293
|
+
const candidates = await apiClient.search(userId, allTrapdoors, pool, authKeyHex);
|
|
3294
|
+
|
|
3295
|
+
for (const candidate of candidates) {
|
|
3296
|
+
try {
|
|
3297
|
+
const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey);
|
|
3298
|
+
if (isDigestBlob(docJson)) continue;
|
|
3299
|
+
const doc = readClaimFromBlob(docJson);
|
|
3300
|
+
|
|
3301
|
+
let decryptedEmbedding: number[] | undefined;
|
|
3302
|
+
if (candidate.encrypted_embedding) {
|
|
3303
|
+
try {
|
|
3304
|
+
decryptedEmbedding = JSON.parse(
|
|
3305
|
+
decryptFromHex(candidate.encrypted_embedding, encryptionKey),
|
|
3306
|
+
);
|
|
3307
|
+
} catch {
|
|
3308
|
+
// embedding decryption failed
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
|
|
3313
|
+
try {
|
|
3314
|
+
decryptedEmbedding = await generateEmbedding(doc.text);
|
|
3315
|
+
} catch {
|
|
3316
|
+
decryptedEmbedding = undefined;
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
rerankerCandidates.push({
|
|
3321
|
+
id: candidate.fact_id,
|
|
3322
|
+
text: doc.text,
|
|
3323
|
+
embedding: decryptedEmbedding,
|
|
3324
|
+
importance: doc.importance / 10,
|
|
3325
|
+
createdAt: typeof candidate.timestamp === 'number'
|
|
3326
|
+
? candidate.timestamp / 1000
|
|
3327
|
+
: new Date(candidate.timestamp).getTime() / 1000,
|
|
3328
|
+
source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
|
|
3329
|
+
});
|
|
3330
|
+
} catch {
|
|
3331
|
+
// Skip candidates we cannot decrypt.
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// 5. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
|
|
3337
|
+
const queryIntent = detectQueryIntent(query);
|
|
3338
|
+
const reranked = rerank(
|
|
3339
|
+
query,
|
|
3340
|
+
queryEmbedding ?? [],
|
|
3341
|
+
rerankerCandidates,
|
|
3342
|
+
k,
|
|
3343
|
+
INTENT_WEIGHTS[queryIntent],
|
|
3344
|
+
// applySourceWeights=false — matches the recall tool's current
|
|
3345
|
+
// behavior. TODO(task 2.7b / H1 gate): flip to true for the native
|
|
3346
|
+
// path so Retrieval v2 Tier 1 source weighting takes effect.
|
|
3347
|
+
false,
|
|
3348
|
+
);
|
|
3349
|
+
|
|
3350
|
+
// 6. Map RerankerResult -> TrFact. The score field is rrfScore (the
|
|
3351
|
+
// final fused + weighted score the manager's defensive sort uses).
|
|
3352
|
+
return reranked.map((m) => ({
|
|
3353
|
+
id: m.id,
|
|
3354
|
+
plaintext: m.text,
|
|
3355
|
+
score: m.rrfScore,
|
|
3356
|
+
// pinned is intentionally not surfaced here today — pinned status
|
|
3357
|
+
// lives in claim metadata and there's no clean read-side aggregate
|
|
3358
|
+
// to lift in this task. See getById + pinned TODO below.
|
|
3359
|
+
}));
|
|
3360
|
+
};
|
|
3361
|
+
|
|
3362
|
+
// -------------------------------------------------------------------
|
|
3363
|
+
// getById(): the load-bearing reverse-path closure. Mirrors the
|
|
3364
|
+
// pin/unpin tool's fetchFactById -> decrypt pattern (the read-back
|
|
3365
|
+
// reverse-path for memory_get). Returns { id, plaintext } or null.
|
|
3366
|
+
// -------------------------------------------------------------------
|
|
3367
|
+
const getById: TrNativeMemoryDeps['getById'] = async (
|
|
3368
|
+
id,
|
|
3369
|
+
): Promise<{ id: string; plaintext: string } | null> => {
|
|
3370
|
+
await ensureInitialized(logger);
|
|
3371
|
+
|
|
3372
|
+
// Fail-soft on missing setup / encryption key.
|
|
3373
|
+
if (needsSetup || !encryptionKey || !authKeyHex) return null;
|
|
3374
|
+
|
|
3375
|
+
// The subgraph path is the canonical one; fetchFactById resolves the
|
|
3376
|
+
// fact by UUID and guards against owner mismatch (defense-in-depth
|
|
3377
|
+
// against stale IDs from another user's recall results — see
|
|
3378
|
+
// subgraph-search.ts fetchFactById docstring).
|
|
3379
|
+
if (isSubgraphMode()) {
|
|
3380
|
+
if (!subgraphOwner) return null;
|
|
3381
|
+
try {
|
|
3382
|
+
const result = await fetchFactById(subgraphOwner, id, authKeyHex);
|
|
3383
|
+
if (!result) return null;
|
|
3384
|
+
const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
|
|
3385
|
+
if (isDigestBlob(docJson)) return null;
|
|
3386
|
+
const doc = readClaimFromBlob(docJson);
|
|
3387
|
+
return { id, plaintext: doc.text };
|
|
3388
|
+
} catch {
|
|
3389
|
+
return null;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
// Server-path: apiClient doesn't expose a clean get-by-id; fall back
|
|
3394
|
+
// to a recall-style lookup using the id as a single trapdoor. This is
|
|
3395
|
+
// a degenerate path for self-hosted installs and rarely hit (the
|
|
3396
|
+
// native memory pipeline targets managed subgraph installs). Document
|
|
3397
|
+
// rather than gold-plate.
|
|
3398
|
+
// TODO(task 2.7b / H1 gate): wire apiClient get-by-id if/when exposed.
|
|
3399
|
+
if (!apiClient || !userId) return null;
|
|
3400
|
+
try {
|
|
3401
|
+
const candidates = await apiClient.search(userId, [id], 10, authKeyHex);
|
|
3402
|
+
const hit = candidates.find((c) => c.fact_id === id);
|
|
3403
|
+
if (!hit) return null;
|
|
3404
|
+
const docJson = decryptFromHex(hit.encrypted_blob, encryptionKey);
|
|
3405
|
+
if (isDigestBlob(docJson)) return null;
|
|
3406
|
+
const doc = readClaimFromBlob(docJson);
|
|
3407
|
+
return { id, plaintext: doc.text };
|
|
3408
|
+
} catch {
|
|
3409
|
+
return null;
|
|
3410
|
+
}
|
|
3411
|
+
};
|
|
3412
|
+
|
|
3413
|
+
// -------------------------------------------------------------------
|
|
3414
|
+
// quota + pinned: prompt-builder inputs. These drive the warning /
|
|
3415
|
+
// pinned-facts blocks in buildPromptSection (memory-runtime.ts).
|
|
3416
|
+
//
|
|
3417
|
+
// TODO(task 2.7b / H1 QA gate): bind these to the real paired-account
|
|
3418
|
+
// state. The hooks to lift are:
|
|
3419
|
+
// - quota: readBillingCache() in billing-cache.ts exposes
|
|
3420
|
+
// { free_writes_used, free_writes_limit } — when used/limit > 0.8
|
|
3421
|
+
// pass { usedPct }, and on a recently-observed 403 pass { denied }.
|
|
3422
|
+
// The billing cache is refreshed by the trajectory-poller after
|
|
3423
|
+
// each capture attempt; today we default to undefined so no
|
|
3424
|
+
// warning fires (fail-quiet — better than a false warning).
|
|
3425
|
+
// - pinned: there is no clean read-side `fetchPinnedFacts(owner)`
|
|
3426
|
+
// aggregate today. pin.ts writes pinned status into claim metadata;
|
|
3427
|
+
// a pinned-facts read would need either (a) a subgraph query
|
|
3428
|
+
// filtering on the pinned status, or (b) reuse of the hot-cache
|
|
3429
|
+
// pinned list. Both are extraction work — out of scope for 2.7.
|
|
3430
|
+
// Default to [] (no pinned block emitted).
|
|
3431
|
+
//
|
|
3432
|
+
// Returning undefined / [] here is the documented correct default. The
|
|
3433
|
+
// wiring helper accepts a deps object without quota/pinned, and the
|
|
3434
|
+
// prompt builder emits no warning / no pinned block in that case.
|
|
3435
|
+
// -------------------------------------------------------------------
|
|
3436
|
+
const quota: TrQuotaState | undefined = undefined;
|
|
3437
|
+
const pinned: TrPinnedFact[] | undefined = undefined;
|
|
3438
|
+
|
|
3439
|
+
return { recall, getById, quota, pinned };
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3074
3442
|
// ---------------------------------------------------------------------------
|
|
3075
3443
|
// Plugin definition
|
|
3076
3444
|
// ---------------------------------------------------------------------------
|
|
@@ -3147,68 +3515,7 @@ const plugin = {
|
|
|
3147
3515
|
},
|
|
3148
3516
|
|
|
3149
3517
|
register(api: OpenClawPluginApi) {
|
|
3150
|
-
//
|
|
3151
|
-
// 3.3.2-rc.1 (issue #186) — load manifest instrumentation
|
|
3152
|
-
// ---------------------------------------------------------------
|
|
3153
|
-
//
|
|
3154
|
-
// Capture every `api.registerTool({name, ...})` call so we can write
|
|
3155
|
-
// a `.loaded.json` manifest at the end of register(). Wrap the body
|
|
3156
|
-
// in try/catch so a register-time throw produces `.error.json` for
|
|
3157
|
-
// agent-side filesystem verification (the CLI hangs in some Docker
|
|
3158
|
-
// setups — issue #182 — so the manifest is the canonical "did the
|
|
3159
|
-
// plugin load?" probe).
|
|
3160
|
-
//
|
|
3161
|
-
// Implementation: we intercept the api.registerTool method by
|
|
3162
|
-
// wrapping it on the api object passed in. The wrapper inspects the
|
|
3163
|
-
// `name` field (every TR registerTool call sets it) and forwards
|
|
3164
|
-
// verbatim. NO behavior change to the SDK call — the original method
|
|
3165
|
-
// is invoked with original args and `this` binding.
|
|
3166
|
-
//
|
|
3167
|
-
// Synchronous writes ONLY (see writePluginManifest doc): the SDK
|
|
3168
|
-
// freezes plugin registries the moment register() returns; an async
|
|
3169
|
-
// write would race that freeze.
|
|
3170
|
-
const _registeredToolNames: string[] = [];
|
|
3171
|
-
const _originalRegisterTool = api.registerTool.bind(api);
|
|
3172
|
-
// 3.3.8-rc.1 HYBRID MODE (OpenClaw 2026.5.2 issue #223 workaround):
|
|
3173
|
-
// The tool-policy-pipeline in OC 2026.5.2 strips non-bundled plugin tools
|
|
3174
|
-
// before they reach the agent's session toolset. registerTool() calls
|
|
3175
|
-
// succeed and tools are declared in contracts.tools, so the PLUGIN LOADS.
|
|
3176
|
-
// But tool calls never reach execute() — the pipeline discards them before
|
|
3177
|
-
// the agent's toolset is built.
|
|
3178
|
-
//
|
|
3179
|
-
// Strategy: keep all registerTool() calls intact so the plugin loader can
|
|
3180
|
-
// verify the contracts.tools declaration and load the plugin (hooks fire).
|
|
3181
|
-
// The `tr` CLI binary (dist/tr-cli.js) provides the alternative execution
|
|
3182
|
-
// path. Agent runs `tr remember|recall|status|pair` from shell; tool calls
|
|
3183
|
-
// are dead-letter but hooks (before_agent_start, agent_end, message_received,
|
|
3184
|
-
// before_reset) still fire via the unbroken hook code path.
|
|
3185
|
-
//
|
|
3186
|
-
// NOTE: do NOT no-op registerTool here — OC 2026.5.2 validates the
|
|
3187
|
-
// contracts.tools declaration against registered tools at load time and
|
|
3188
|
-
// drops the plugin (unloads it) if no tools match. Confirmed empirically:
|
|
3189
|
-
// no-op'ing registerTool causes the gateway to log "4 plugins" instead of
|
|
3190
|
-
// "5 plugins" after restart (plugin excluded from active set).
|
|
3191
|
-
//
|
|
3192
|
-
// TODO: when OC ships a fix for issue #223, restore tool-call routing
|
|
3193
|
-
// and remove the tr-cli.ts CLI layer. The bin/tr field in package.json
|
|
3194
|
-
// can stay as a convenience CLI regardless.
|
|
3195
|
-
api.registerTool = (tool: unknown, opts?: { name?: string; names?: string[] }) => {
|
|
3196
|
-
try {
|
|
3197
|
-
const t = tool as { name?: unknown } | null | undefined;
|
|
3198
|
-
if (t && typeof t === 'object' && typeof t.name === 'string' && t.name.length > 0) {
|
|
3199
|
-
_registeredToolNames.push(t.name);
|
|
3200
|
-
}
|
|
3201
|
-
} catch {
|
|
3202
|
-
// Manifest is diagnostic; never let bookkeeping break tool registration.
|
|
3203
|
-
}
|
|
3204
|
-
_originalRegisterTool(tool, opts);
|
|
3205
|
-
};
|
|
3206
|
-
|
|
3207
|
-
// Lazily resolved inside the try below — needed by both the manifest
|
|
3208
|
-
// write and the error path. `dist/` after build, package root in tests.
|
|
3209
|
-
let _pluginDirForManifest: string | null = null;
|
|
3210
|
-
|
|
3211
|
-
// NOTE: the body of register() below is intentionally NOT re-indented
|
|
3518
|
+
// NOTE: the body of register() below is intentionally NOT re-indent
|
|
3212
3519
|
// under this `try` block — re-indenting would touch every line in a
|
|
3213
3520
|
// 3,500-line function and obscure the actual hotfix diff. The closing
|
|
3214
3521
|
// `} catch (registerErr: unknown) { ... }` is at the very end of
|
|
@@ -3218,9 +3525,11 @@ const plugin = {
|
|
|
3218
3525
|
// RC-build detection (3.3.1-rc.3)
|
|
3219
3526
|
// ---------------------------------------------------------------
|
|
3220
3527
|
//
|
|
3221
|
-
// `isRcBuild` reads the plugin's own version string.
|
|
3222
|
-
// `
|
|
3223
|
-
//
|
|
3528
|
+
// `isRcBuild` reads the plugin's own version string. The resulting
|
|
3529
|
+
// `rcMode` flag is currently logged but has no gating effect after
|
|
3530
|
+
// Task 3.2 retired the RC-only `totalreclaw_report_qa_bug` agent tool
|
|
3531
|
+
// (the only former consumer). The flag is retained for the log line and
|
|
3532
|
+
// any future RC-gated diagnostic surface. The version is resolved via
|
|
3224
3533
|
// `readPluginVersion` from fs-helpers.ts (scanner-safe, pure-fs).
|
|
3225
3534
|
let rcMode = false;
|
|
3226
3535
|
// Plugin version resolved from package.json once at register time. Reused
|
|
@@ -3238,7 +3547,6 @@ const plugin = {
|
|
|
3238
3547
|
// `require('node:url')` — undefined under bare-ESM Node, broke the
|
|
3239
3548
|
// before_agent_start hook in the published rc.20 bundle (issue #124).
|
|
3240
3549
|
const pluginDir = nodePath.dirname(fileURLToPath(import.meta.url));
|
|
3241
|
-
_pluginDirForManifest = pluginDir; // captured for #186 .loaded.json/.error.json
|
|
3242
3550
|
pluginVersion = readPluginVersion(pluginDir);
|
|
3243
3551
|
rcMode = isRcBuild(pluginVersion);
|
|
3244
3552
|
if (rcMode) {
|
|
@@ -3296,10 +3604,10 @@ const plugin = {
|
|
|
3296
3604
|
|
|
3297
3605
|
// 3.3.3-rc.1 (issue #187 — ONNX decouple): kick off a non-blocking
|
|
3298
3606
|
// bundle prefetch so the ~700 MB embedder tarball starts streaming
|
|
3299
|
-
// as soon as the gateway boots, BEFORE the user completes
|
|
3300
|
-
// `
|
|
3301
|
-
// pair-completion gate the previous flow
|
|
3302
|
-
// `requireFullSetup()` -> first `generateEmbedding()` call.
|
|
3607
|
+
// as soon as the gateway boots, BEFORE the user completes pairing
|
|
3608
|
+
// (`tr pair` / the `/plugin/totalreclaw/pair/*` HTTP route). Decouples
|
|
3609
|
+
// the model download from the pair-completion gate the previous flow
|
|
3610
|
+
// imposed via `requireFullSetup()` -> first `generateEmbedding()` call.
|
|
3303
3611
|
// Fire-and-forget — never awaits, never throws on failure (the next
|
|
3304
3612
|
// `generateEmbedding()` call retries via the same idempotent path).
|
|
3305
3613
|
// Disabled when `TOTALRECLAW_DISABLE_EMBEDDER_PREFETCH=1` (CI / tests
|
|
@@ -3353,14 +3661,16 @@ const plugin = {
|
|
|
3353
3661
|
}
|
|
3354
3662
|
|
|
3355
3663
|
// 3.3.9-rc.2 (issues #225 + #226): auto-patch openclaw.json for
|
|
3356
|
-
// OpenClaw 2026.5.x.
|
|
3357
|
-
//
|
|
3664
|
+
// OpenClaw 2026.5.x. Required config keys not auto-applied by
|
|
3665
|
+
// `openclaw plugins install` in 2026.5.x:
|
|
3358
3666
|
//
|
|
3359
|
-
//
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
//
|
|
3363
|
-
//
|
|
3667
|
+
// NOTE (rc.20, #402): patchOpenClawConfig now applies only the two
|
|
3668
|
+
// keys below. Retired: the memory slot (plugins.slots.memory — OpenClaw
|
|
3669
|
+
// 2026.6.8 claims it natively on install/enable), the installs
|
|
3670
|
+
// self-heal (plugins.installs — native install owns it; a fabricated
|
|
3671
|
+
// record fails the host's schema validation), and the plugins.allow
|
|
3672
|
+
// self-append + plugins.bundledDiscovery="compat" pair (native install
|
|
3673
|
+
// manages the allowlist).
|
|
3364
3674
|
//
|
|
3365
3675
|
// 2. plugins.entries.totalreclaw.hooks.allowConversationAccess = true
|
|
3366
3676
|
// Non-bundled plugins in 2026.5.x require this flag to receive
|
|
@@ -3379,9 +3689,9 @@ const plugin = {
|
|
|
3379
3689
|
// openclaw.json at startup, not dynamically). We emit a warn so
|
|
3380
3690
|
// the user and ops scripts know to trigger a restart.
|
|
3381
3691
|
try {
|
|
3382
|
-
//
|
|
3383
|
-
//
|
|
3384
|
-
//
|
|
3692
|
+
// pluginVersion is still passed for signature stability; as of rc.20
|
|
3693
|
+
// (#402) patchOpenClawConfig no longer consumes it (the Fix #6 installs
|
|
3694
|
+
// self-heal it fed was retired).
|
|
3385
3695
|
const patchResult = patchOpenClawConfig(undefined, pluginVersion ?? undefined);
|
|
3386
3696
|
if (patchResult === 'patched') {
|
|
3387
3697
|
// 3.3.12-rc.6 (auto-QA finding 2026-05-09): previously we only
|
|
@@ -3416,9 +3726,7 @@ const plugin = {
|
|
|
3416
3726
|
// pattern.
|
|
3417
3727
|
api.logger.warn(
|
|
3418
3728
|
'TotalReclaw: updated openclaw.json with required 2026.5.x keys ' +
|
|
3419
|
-
'(
|
|
3420
|
-
'channels.telegram.streaming.mode + plugins.bundledDiscovery + ' +
|
|
3421
|
-
'plugins.allow + plugins.installs.totalreclaw self-heal). ' +
|
|
3729
|
+
'(hooks.allowConversationAccess + channels.telegram.streaming.mode). ' +
|
|
3422
3730
|
'Auto-restarting gateway via SIGUSR1 to apply.',
|
|
3423
3731
|
);
|
|
3424
3732
|
setTimeout(() => {
|
|
@@ -3436,9 +3744,10 @@ const plugin = {
|
|
|
3436
3744
|
} else if (patchResult === 'error') {
|
|
3437
3745
|
api.logger.warn(
|
|
3438
3746
|
'TotalReclaw: failed to auto-patch openclaw.json for OpenClaw 2026.5.x ' +
|
|
3439
|
-
'compatibility. If memory hooks are silently disabled, add
|
|
3440
|
-
'manually: plugins.
|
|
3441
|
-
'
|
|
3747
|
+
'compatibility. If memory hooks are silently disabled, add this key ' +
|
|
3748
|
+
'manually: plugins.entries.totalreclaw.hooks.allowConversationAccess=true. ' +
|
|
3749
|
+
'(The memory slot is set by OpenClaw itself on plugin install/enable; ' +
|
|
3750
|
+
'if the slot is wrong, run: openclaw plugins enable totalreclaw)',
|
|
3442
3751
|
);
|
|
3443
3752
|
}
|
|
3444
3753
|
// 'unchanged' and 'skipped' are silent — no log needed.
|
|
@@ -3466,79 +3775,6 @@ const plugin = {
|
|
|
3466
3775
|
}
|
|
3467
3776
|
}
|
|
3468
3777
|
|
|
3469
|
-
// ---------------------------------------------------------------
|
|
3470
|
-
// 3.3.13 — auto-pair-on-load (Pop-OS hallucination fix)
|
|
3471
|
-
// ---------------------------------------------------------------
|
|
3472
|
-
//
|
|
3473
|
-
// If credentials.json is missing, open a relay pair session NOW and
|
|
3474
|
-
// write URL + PIN + sid to ~/.totalreclaw/.pair-pending.json. The
|
|
3475
|
-
// before_agent_start hook installed below reads that file and tells
|
|
3476
|
-
// the agent the EXACT values to surface — the agent no longer guesses.
|
|
3477
|
-
//
|
|
3478
|
-
// Fire-and-forget so a relay outage never blocks plugin load. The
|
|
3479
|
-
// sentinel write happens BEFORE the hook is registered, but the WS
|
|
3480
|
-
// listener and credentials.json write run in the background after
|
|
3481
|
-
// register() returns.
|
|
3482
|
-
void (async () => {
|
|
3483
|
-
try {
|
|
3484
|
-
const result = await maybeStartAutoPair({
|
|
3485
|
-
credentialsPath: CREDENTIALS_PATH,
|
|
3486
|
-
pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
|
|
3487
|
-
onboardingStatePath: CONFIG.onboardingStatePath,
|
|
3488
|
-
relayBaseUrl: CONFIG.pairRelayUrl,
|
|
3489
|
-
pluginVersion: pluginVersion ?? '3.3.0',
|
|
3490
|
-
logger: api.logger,
|
|
3491
|
-
});
|
|
3492
|
-
switch (result.status) {
|
|
3493
|
-
case 'creds_exist':
|
|
3494
|
-
// No-op — happy path; user is already set up.
|
|
3495
|
-
break;
|
|
3496
|
-
case 'pending_reused':
|
|
3497
|
-
api.logger.info(
|
|
3498
|
-
'TotalReclaw: setup pending. Reusing existing pair URL at ~/.totalreclaw/.pair-pending.json — agent will surface URL+PIN to user on next turn.',
|
|
3499
|
-
);
|
|
3500
|
-
break;
|
|
3501
|
-
case 'started':
|
|
3502
|
-
api.logger.info(
|
|
3503
|
-
'TotalReclaw: setup pending. Pair URL written to ~/.totalreclaw/.pair-pending.json — agent will surface URL+PIN to user on next turn.',
|
|
3504
|
-
);
|
|
3505
|
-
break;
|
|
3506
|
-
case 'failed':
|
|
3507
|
-
api.logger.warn(
|
|
3508
|
-
`TotalReclaw: setup pending. Auto-pair failed (${result.error}). User must run \`tr pair --json\` manually OR call the totalreclaw_pair tool.`,
|
|
3509
|
-
);
|
|
3510
|
-
break;
|
|
3511
|
-
}
|
|
3512
|
-
} catch (err: unknown) {
|
|
3513
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3514
|
-
api.logger.warn(
|
|
3515
|
-
`TotalReclaw: setup pending. Auto-pair failed (${msg}). User must run \`tr pair --json\` manually OR call the totalreclaw_pair tool.`,
|
|
3516
|
-
);
|
|
3517
|
-
}
|
|
3518
|
-
})();
|
|
3519
|
-
|
|
3520
|
-
// Install the before_agent_start hook that reads the sentinel and
|
|
3521
|
-
// injects URL + PIN into the agent's context verbatim. Registered
|
|
3522
|
-
// here so it lives alongside the other hook registrations in
|
|
3523
|
-
// register(); the hook body itself never blocks on plugin load.
|
|
3524
|
-
try {
|
|
3525
|
-
installPairPendingHook(api, {
|
|
3526
|
-
credentialsPath: CREDENTIALS_PATH,
|
|
3527
|
-
pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
|
|
3528
|
-
autoPairDepsFactory: () => ({
|
|
3529
|
-
credentialsPath: CREDENTIALS_PATH,
|
|
3530
|
-
pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
|
|
3531
|
-
onboardingStatePath: CONFIG.onboardingStatePath,
|
|
3532
|
-
relayBaseUrl: CONFIG.pairRelayUrl,
|
|
3533
|
-
pluginVersion: pluginVersion ?? '3.3.0',
|
|
3534
|
-
logger: api.logger,
|
|
3535
|
-
}),
|
|
3536
|
-
});
|
|
3537
|
-
} catch (err: unknown) {
|
|
3538
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3539
|
-
api.logger.warn(`TotalReclaw: failed to register pair-pending-injection hook: ${msg}`);
|
|
3540
|
-
}
|
|
3541
|
-
|
|
3542
3778
|
// ---------------------------------------------------------------
|
|
3543
3779
|
// LLM client initialization (auto-detect provider from OpenClaw config)
|
|
3544
3780
|
// ---------------------------------------------------------------
|
|
@@ -3682,6 +3918,267 @@ const plugin = {
|
|
|
3682
3918
|
});
|
|
3683
3919
|
},
|
|
3684
3920
|
});
|
|
3921
|
+
|
|
3922
|
+
// ---------------------------------------------------------------
|
|
3923
|
+
// 3.3.13 — `openclaw totalreclaw import ...` + `upgrade`
|
|
3924
|
+
//
|
|
3925
|
+
// Phase 3.2 retired the totalreclaw_import_from / import_status /
|
|
3926
|
+
// import_abort / upgrade agent tools (recall is native; the rest
|
|
3927
|
+
// became CLI/HTTP surfaces). The handlers stayed in this file
|
|
3928
|
+
// (auto-resume still calls handlePluginImportFrom on gateway
|
|
3929
|
+
// restart) but had NO user-facing entry point — users could not
|
|
3930
|
+
// START a new import, only auto-resume worked. This wiring closes
|
|
3931
|
+
// that gap.
|
|
3932
|
+
//
|
|
3933
|
+
// Why this lives on the `openclaw totalreclaw` subcommand chain
|
|
3934
|
+
// (NOT the standalone `tr` CLI binary): the import handler reaches
|
|
3935
|
+
// module-level state (authKeyHex / encryptionKey / subgraphOwner)
|
|
3936
|
+
// populated by initialize(), plus storeExtractedFacts +
|
|
3937
|
+
// extractFacts + runSmartImportPipeline. The `tr` binary
|
|
3938
|
+
// (tr-cli.ts) is a standalone Node script that does NOT import
|
|
3939
|
+
// the plugin runtime; importing index.ts from it would pull in
|
|
3940
|
+
// the entire gateway runtime. The registerCli subcommand runs
|
|
3941
|
+
// INSIDE the gateway process, so the handlers are directly in
|
|
3942
|
+
// scope — same pattern as `onboard` / `status` / `pair`.
|
|
3943
|
+
//
|
|
3944
|
+
// JSON output: every subcommand accepts --json and emits a single
|
|
3945
|
+
// machine-parseable JSON line on stdout (agent-driven use). Plain
|
|
3946
|
+
// text is for direct user CLI use.
|
|
3947
|
+
//
|
|
3948
|
+
// rc.20 (#402): the import/upgrade wiring below referenced a bare
|
|
3949
|
+
// `tr` that was never declared in THIS callback scope —
|
|
3950
|
+
// registerOnboardingCli and registerPairCli each declare their own
|
|
3951
|
+
// LOCAL `tr`, invisible here. That undeclared reference threw
|
|
3952
|
+
// `ReferenceError: tr is not defined` the moment OpenClaw ran the
|
|
3953
|
+
// callback, killing EVERY `openclaw totalreclaw <sub>` command
|
|
3954
|
+
// (dead since the 3.3.13 import/upgrade restoration; shipped in
|
|
3955
|
+
// rc.19 + rc.20). The build is `tsc --noCheck`, so the type checker
|
|
3956
|
+
// never caught it. Resolve the command group the same way
|
|
3957
|
+
// registerPairCli does — registerOnboardingCli always created it, so
|
|
3958
|
+
// this find() succeeds; the guard is belt-and-braces.
|
|
3959
|
+
const tr = program.commands.find((c: any) => c.name() === 'totalreclaw');
|
|
3960
|
+
if (!tr) {
|
|
3961
|
+
api.logger.warn(
|
|
3962
|
+
'TotalReclaw: `totalreclaw` CLI group not found after onboarding/pair registration — ' +
|
|
3963
|
+
'skipping import/upgrade wiring. `openclaw totalreclaw import`/`upgrade` will be unavailable.',
|
|
3964
|
+
);
|
|
3965
|
+
return;
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
const importCmd = tr.command('import')
|
|
3969
|
+
.description(
|
|
3970
|
+
'Import memories from another tool (Mem0, MCP Memory, ChatGPT, Claude, Gemini). ' +
|
|
3971
|
+
'Subcommands: `import status`, `import abort`.',
|
|
3972
|
+
);
|
|
3973
|
+
|
|
3974
|
+
importCmd
|
|
3975
|
+
.command('from', { isDefault: true })
|
|
3976
|
+
.description(
|
|
3977
|
+
'Start an import from a source tool. Conversation sources (ChatGPT/Claude/Gemini) ' +
|
|
3978
|
+
'run in the background; poll with `import status`. Pre-structured sources (Mem0/MCP) ' +
|
|
3979
|
+
'store synchronously.',
|
|
3980
|
+
)
|
|
3981
|
+
.argument('<source>', 'mem0 | mcp-memory | chatgpt | claude | gemini')
|
|
3982
|
+
.option('--file <path>', 'Path to the source file on disk')
|
|
3983
|
+
.option('--content <text>', 'Inline source content (JSON/JSONL/CSV/text)')
|
|
3984
|
+
.option('--api-key <key>', 'API key for the source (used once, never stored)')
|
|
3985
|
+
.option('--source-user-id <id>', 'User/agent ID in the source system')
|
|
3986
|
+
.option('--api-url <url>', 'API base URL override (self-hosted instances)')
|
|
3987
|
+
.option('--dry-run', 'Parse + report without storing')
|
|
3988
|
+
.option('--resume <importId>', 'Resume a previously-started import by id')
|
|
3989
|
+
.option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
|
|
3990
|
+
.action(async (source: string, opts: {
|
|
3991
|
+
file?: string;
|
|
3992
|
+
content?: string;
|
|
3993
|
+
apiKey?: string;
|
|
3994
|
+
sourceUserId?: string;
|
|
3995
|
+
apiUrl?: string;
|
|
3996
|
+
dryRun?: boolean;
|
|
3997
|
+
resume?: string;
|
|
3998
|
+
json?: boolean;
|
|
3999
|
+
}) => {
|
|
4000
|
+
try {
|
|
4001
|
+
await requireFullSetup(api.logger);
|
|
4002
|
+
const result = await handlePluginImportFrom({
|
|
4003
|
+
source,
|
|
4004
|
+
file_path: opts.file,
|
|
4005
|
+
content: opts.content,
|
|
4006
|
+
api_key: opts.apiKey,
|
|
4007
|
+
source_user_id: opts.sourceUserId,
|
|
4008
|
+
api_url: opts.apiUrl,
|
|
4009
|
+
dry_run: opts.dryRun,
|
|
4010
|
+
resume_id: opts.resume,
|
|
4011
|
+
disclosure_confirmed: true,
|
|
4012
|
+
}, api.logger);
|
|
4013
|
+
|
|
4014
|
+
if (opts.json) {
|
|
4015
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
4016
|
+
} else {
|
|
4017
|
+
// Human-readable summary. The handler already returns a
|
|
4018
|
+
// `message` for chunked (background) imports; for direct
|
|
4019
|
+
// stores + dry runs, synthesize a short summary.
|
|
4020
|
+
if (result.dry_run) {
|
|
4021
|
+
const chunks = result.total_chunks as number | undefined;
|
|
4022
|
+
if (chunks !== undefined) {
|
|
4023
|
+
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`,
|
|
4026
|
+
);
|
|
4027
|
+
} else {
|
|
4028
|
+
process.stdout.write(
|
|
4029
|
+
`Dry run: found ${result.total_found} facts. Confirm without --dry-run to import.\n`,
|
|
4030
|
+
);
|
|
4031
|
+
}
|
|
4032
|
+
} else if (result.import_id && result.status === 'running') {
|
|
4033
|
+
process.stdout.write(
|
|
4034
|
+
`${result.message}\nImport id: ${result.import_id}\n`,
|
|
4035
|
+
);
|
|
4036
|
+
} else {
|
|
4037
|
+
const stored = result.imported as number | undefined;
|
|
4038
|
+
const total = result.total_found as number | undefined;
|
|
4039
|
+
process.stdout.write(
|
|
4040
|
+
`Imported ${stored ?? 0}/${total ?? stored ?? 0} facts from ${source}.\n`,
|
|
4041
|
+
);
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
} catch (err: unknown) {
|
|
4045
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4046
|
+
if (opts.json) {
|
|
4047
|
+
process.stdout.write(JSON.stringify({ success: false, error: message }) + '\n');
|
|
4048
|
+
} else {
|
|
4049
|
+
process.stderr.write(`import failed: ${message}\n`);
|
|
4050
|
+
}
|
|
4051
|
+
process.exit(1);
|
|
4052
|
+
}
|
|
4053
|
+
});
|
|
4054
|
+
|
|
4055
|
+
importCmd
|
|
4056
|
+
.command('status')
|
|
4057
|
+
.description('Check progress of a background import. Omit --id for the most recent active import.')
|
|
4058
|
+
.option('--id <importId>', 'Import id (from `import from`). Omit for most-recent active.')
|
|
4059
|
+
.option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
|
|
4060
|
+
.action(async (opts: { id?: string; json?: boolean }) => {
|
|
4061
|
+
try {
|
|
4062
|
+
await requireFullSetup(api.logger);
|
|
4063
|
+
const result = await handleImportStatus({ import_id: opts.id }, api.logger);
|
|
4064
|
+
if (opts.json) {
|
|
4065
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
4066
|
+
} 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;
|
|
4071
|
+
if (status === 'no_active_import') {
|
|
4072
|
+
process.stdout.write('No active import. Start one with `openclaw totalreclaw import from <source>`.\n');
|
|
4073
|
+
} else if (status === 'running') {
|
|
4074
|
+
process.stdout.write(
|
|
4075
|
+
`Import ${result.import_id}: running — ${stored} facts stored, ` +
|
|
4076
|
+
`batch ${batchDone}/${batchTotal}` +
|
|
4077
|
+
(result.completion_iso ? `, ETA ${result.completion_iso}` : '') + '.\n',
|
|
4078
|
+
);
|
|
4079
|
+
} else {
|
|
4080
|
+
process.stdout.write(
|
|
4081
|
+
`Import ${result.import_id}: ${status} — ${stored ?? 0} facts stored.\n`,
|
|
4082
|
+
);
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
} catch (err: unknown) {
|
|
4086
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4087
|
+
if (opts.json) {
|
|
4088
|
+
process.stdout.write(JSON.stringify({ error: message }) + '\n');
|
|
4089
|
+
} else {
|
|
4090
|
+
process.stderr.write(`import status failed: ${message}\n`);
|
|
4091
|
+
}
|
|
4092
|
+
process.exit(1);
|
|
4093
|
+
}
|
|
4094
|
+
});
|
|
4095
|
+
|
|
4096
|
+
importCmd
|
|
4097
|
+
.command('abort')
|
|
4098
|
+
.description('Cancel a running background import. Already-stored facts are kept.')
|
|
4099
|
+
.argument('<importId>', 'Import id to abort (from `import from` or `import status`)')
|
|
4100
|
+
.option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
|
|
4101
|
+
.action(async (importId: string, opts: { json?: boolean }) => {
|
|
4102
|
+
try {
|
|
4103
|
+
await requireFullSetup(api.logger);
|
|
4104
|
+
const result = await handleImportAbort({ import_id: importId }, api.logger);
|
|
4105
|
+
if (opts.json) {
|
|
4106
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
4107
|
+
} else {
|
|
4108
|
+
if (result.aborted) {
|
|
4109
|
+
process.stdout.write(
|
|
4110
|
+
`Import ${importId}: abort requested. ${result.facts_already_stored ?? 0} facts already stored (kept).\n`,
|
|
4111
|
+
);
|
|
4112
|
+
} else {
|
|
4113
|
+
process.stdout.write(
|
|
4114
|
+
`Import ${importId}: ${result.error ?? 'abort failed'}\n`,
|
|
4115
|
+
);
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
} catch (err: unknown) {
|
|
4119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4120
|
+
if (opts.json) {
|
|
4121
|
+
process.stdout.write(JSON.stringify({ error: message }) + '\n');
|
|
4122
|
+
} else {
|
|
4123
|
+
process.stderr.write(`import abort failed: ${message}\n`);
|
|
4124
|
+
}
|
|
4125
|
+
process.exit(1);
|
|
4126
|
+
}
|
|
4127
|
+
});
|
|
4128
|
+
|
|
4129
|
+
// `openclaw totalreclaw upgrade` — Stripe checkout URL for Pro.
|
|
4130
|
+
// Restores the retired totalreclaw_upgrade agent tool (cd21176).
|
|
4131
|
+
// Self-contained: POST /v1/billing/checkout → checkout_url.
|
|
4132
|
+
tr.command('upgrade')
|
|
4133
|
+
.description('Get a Stripe checkout URL to upgrade to TotalReclaw Pro (unlimited memories on Gnosis mainnet).')
|
|
4134
|
+
.option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
|
|
4135
|
+
.action(async (opts: { json?: boolean }) => {
|
|
4136
|
+
try {
|
|
4137
|
+
await requireFullSetup(api.logger);
|
|
4138
|
+
|
|
4139
|
+
if (!authKeyHex) {
|
|
4140
|
+
throw new Error('Auth credentials are not available. Pair first (`openclaw totalreclaw pair`).');
|
|
4141
|
+
}
|
|
4142
|
+
const walletAddr = subgraphOwner || userId || '';
|
|
4143
|
+
if (!walletAddr) {
|
|
4144
|
+
throw new Error('Wallet address not available. Ensure the plugin is fully initialized.');
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
const response = await fetch(`${CONFIG.serverUrl}/v1/billing/checkout`, {
|
|
4148
|
+
method: 'POST',
|
|
4149
|
+
headers: buildRelayHeaders({
|
|
4150
|
+
'Authorization': `Bearer ${authKeyHex}`,
|
|
4151
|
+
'Content-Type': 'application/json',
|
|
4152
|
+
}),
|
|
4153
|
+
body: JSON.stringify({ wallet_address: walletAddr, tier: 'pro' }),
|
|
4154
|
+
});
|
|
4155
|
+
|
|
4156
|
+
if (!response.ok) {
|
|
4157
|
+
const body = await response.text().catch(() => '');
|
|
4158
|
+
throw new Error(`checkout session failed (HTTP ${response.status}): ${body || response.statusText}`);
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
const data = await response.json() as { checkout_url?: string };
|
|
4162
|
+
if (!data.checkout_url) {
|
|
4163
|
+
throw new Error('no checkout URL returned by the relay');
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
if (opts.json) {
|
|
4167
|
+
process.stdout.write(JSON.stringify({ checkout_url: data.checkout_url }) + '\n');
|
|
4168
|
+
} else {
|
|
4169
|
+
process.stdout.write(`Open this URL to upgrade to Pro: ${data.checkout_url}\n`);
|
|
4170
|
+
}
|
|
4171
|
+
} catch (err: unknown) {
|
|
4172
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4173
|
+
api.logger.error(`openclaw totalreclaw upgrade failed: ${message}`);
|
|
4174
|
+
if (opts.json) {
|
|
4175
|
+
process.stdout.write(JSON.stringify({ error: message }) + '\n');
|
|
4176
|
+
} else {
|
|
4177
|
+
process.stderr.write(`upgrade failed: ${humanizeError(message)}\n`);
|
|
4178
|
+
}
|
|
4179
|
+
process.exit(1);
|
|
4180
|
+
}
|
|
4181
|
+
});
|
|
3685
4182
|
},
|
|
3686
4183
|
{ commands: ['totalreclaw'] },
|
|
3687
4184
|
);
|
|
@@ -3721,6 +4218,17 @@ const plugin = {
|
|
|
3721
4218
|
sessionsPath: CONFIG.pairSessionsPath,
|
|
3722
4219
|
apiBase: '/plugin/totalreclaw/pair',
|
|
3723
4220
|
logger: api.logger,
|
|
4221
|
+
// 3.3.14 — wire the relay URL so buildPairRoutes exposes the
|
|
4222
|
+
// in-process `/pair/init` route. The gateway process opens the
|
|
4223
|
+
// relay WebSocket directly (via openRemotePairSession from
|
|
4224
|
+
// pair-remote-client.ts), eliminating the 30s-subprocess-kill
|
|
4225
|
+
// 502 that the CLI path (tr pair) hit when OpenClaw's shell
|
|
4226
|
+
// tool killed the subprocess mid-pair. relayBaseUrl is sourced
|
|
4227
|
+
// from CONFIG.pairRelayUrl (config.ts reads it from the env
|
|
4228
|
+
// once, centrally) — never read from the environment inside
|
|
4229
|
+
// pair-http.ts (scanner-surface rule).
|
|
4230
|
+
relayBaseUrl: CONFIG.pairRelayUrl,
|
|
4231
|
+
initPairMode: 'either',
|
|
3724
4232
|
validateMnemonic: (p) => validateMnemonic(p, wordlist),
|
|
3725
4233
|
completePairing: async ({ mnemonic }) => {
|
|
3726
4234
|
// Write credentials.json + flip state to 'active' via
|
|
@@ -3764,11 +4272,6 @@ const plugin = {
|
|
|
3764
4272
|
credentialsCreatedAt: new Date().toISOString(),
|
|
3765
4273
|
version: pluginVersion ?? '3.3.0',
|
|
3766
4274
|
});
|
|
3767
|
-
// 3.3.13 — sentinel cleanup. credentials.json is now the source
|
|
3768
|
-
// of truth; the auto-pair-on-load .pair-pending.json sentinel
|
|
3769
|
-
// must be removed so the before_agent_start hook stops surfacing
|
|
3770
|
-
// the URL + PIN on subsequent turns.
|
|
3771
|
-
deletePairPendingFile(defaultPairPendingPath(CREDENTIALS_PATH));
|
|
3772
4275
|
return { state: 'active' };
|
|
3773
4276
|
},
|
|
3774
4277
|
});
|
|
@@ -3786,7 +4289,19 @@ const plugin = {
|
|
|
3786
4289
|
api.registerHttpRoute!({ path: bundle.startPath, handler: bundle.handlers.start, auth: 'plugin' });
|
|
3787
4290
|
api.registerHttpRoute!({ path: bundle.respondPath, handler: bundle.handlers.respond, auth: 'plugin' });
|
|
3788
4291
|
api.registerHttpRoute!({ path: bundle.statusPath, handler: bundle.handlers.status, auth: 'plugin' });
|
|
3789
|
-
|
|
4292
|
+
// 3.3.14 — in-process pair trigger. The bundle exposes initPath +
|
|
4293
|
+
// handlers.init ONLY when relayBaseUrl is wired (always true here,
|
|
4294
|
+
// since CONFIG.pairRelayUrl has a built-in default). Registered
|
|
4295
|
+
// with auth: 'plugin' (same as the other pair routes) so the
|
|
4296
|
+
// agent's localhost curl reaches it without a gateway bearer
|
|
4297
|
+
// token. The route opens the relay WS in the gateway process →
|
|
4298
|
+
// survives shell-tool timeouts, retries, SIGUSR1 reloads.
|
|
4299
|
+
if (bundle.initPath && bundle.handlers.init) {
|
|
4300
|
+
api.registerHttpRoute!({ path: bundle.initPath, handler: bundle.handlers.init, auth: 'plugin' });
|
|
4301
|
+
api.logger.info('TotalReclaw: registered 5 QR-pairing HTTP routes synchronously (incl. in-process /pair/init)');
|
|
4302
|
+
} else {
|
|
4303
|
+
api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously (in-process /pair/init not wired — no relay URL)');
|
|
4304
|
+
}
|
|
3790
4305
|
} else {
|
|
3791
4306
|
api.logger.warn(
|
|
3792
4307
|
'api.registerHttpRoute is unavailable on this OpenClaw version — /totalreclaw pair will not work. ' +
|
|
@@ -3872,48 +4387,18 @@ const plugin = {
|
|
|
3872
4387
|
};
|
|
3873
4388
|
}
|
|
3874
4389
|
if (sub === 'diag') {
|
|
3875
|
-
// 3.3.7-rc.1
|
|
3876
|
-
// tool
|
|
3877
|
-
//
|
|
3878
|
-
//
|
|
3879
|
-
//
|
|
3880
|
-
//
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
// file says boot=N+1, the chat session is stale and a
|
|
3888
|
-
// /totalreclaw-restart is warranted.
|
|
3889
|
-
try {
|
|
3890
|
-
const m = _pluginDirForManifest
|
|
3891
|
-
? readPluginLoadedManifest(_pluginDirForManifest)
|
|
3892
|
-
: null;
|
|
3893
|
-
if (!m) {
|
|
3894
|
-
return {
|
|
3895
|
-
text:
|
|
3896
|
-
'TotalReclaw diag:\n' +
|
|
3897
|
-
` pid=${process.pid}\n` +
|
|
3898
|
-
` version=${pluginVersion ?? 'unknown'}\n` +
|
|
3899
|
-
' loaded-manifest: NOT FOUND (register() may have failed — check .error.json)',
|
|
3900
|
-
};
|
|
3901
|
-
}
|
|
3902
|
-
const stalePid = typeof m.pid === 'number' && m.pid !== process.pid;
|
|
3903
|
-
return {
|
|
3904
|
-
text:
|
|
3905
|
-
'TotalReclaw diag:\n' +
|
|
3906
|
-
` current pid=${process.pid}\n` +
|
|
3907
|
-
` manifest pid=${m.pid ?? '?'}${stalePid ? ' (STALE — file from prior boot, register() did NOT run in this process)' : ''}\n` +
|
|
3908
|
-
` version=${m.version ?? 'unknown'}\n` +
|
|
3909
|
-
` boot count=${m.bootCount ?? '?'}\n` +
|
|
3910
|
-
` boot at=${m.bootAt ?? '?'}\n` +
|
|
3911
|
-
` tools registered=${m.tools?.length ?? 0}`,
|
|
3912
|
-
};
|
|
3913
|
-
} catch (err: unknown) {
|
|
3914
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3915
|
-
return { text: `TotalReclaw diag: error reading manifest (${msg})` };
|
|
3916
|
-
}
|
|
4390
|
+
// Diagnostic surface. The 3.3.7-rc.1 `.loaded.json` manifest
|
|
4391
|
+
// (boot count + pid + tool count) was retired in Phase 3.4 —
|
|
4392
|
+
// the writer was removed in 3.1 and the reader had nothing
|
|
4393
|
+
// current to read. `/totalreclaw diag` now reports pid + the
|
|
4394
|
+
// in-memory plugin version only. For richer boot history,
|
|
4395
|
+
// consult the gateway logs.
|
|
4396
|
+
return {
|
|
4397
|
+
text:
|
|
4398
|
+
'TotalReclaw diag:\n' +
|
|
4399
|
+
` pid=${process.pid}\n` +
|
|
4400
|
+
` version=${pluginVersion ?? 'unknown'}\n`,
|
|
4401
|
+
};
|
|
3917
4402
|
}
|
|
3918
4403
|
return {
|
|
3919
4404
|
text:
|
|
@@ -4074,2566 +4559,20 @@ const plugin = {
|
|
|
4074
4559
|
);
|
|
4075
4560
|
|
|
4076
4561
|
// ---------------------------------------------------------------
|
|
4077
|
-
//
|
|
4078
|
-
// ---------------------------------------------------------------
|
|
4079
|
-
|
|
4080
|
-
api.registerTool(
|
|
4081
|
-
{
|
|
4082
|
-
name: 'totalreclaw_remember',
|
|
4083
|
-
label: 'Remember',
|
|
4084
|
-
description:
|
|
4085
|
-
'Store a memory in the encrypted vault. Use this when the user shares important information worth remembering.',
|
|
4086
|
-
parameters: {
|
|
4087
|
-
type: 'object',
|
|
4088
|
-
properties: {
|
|
4089
|
-
text: {
|
|
4090
|
-
type: 'string',
|
|
4091
|
-
description: 'The memory text to store',
|
|
4092
|
-
},
|
|
4093
|
-
type: {
|
|
4094
|
-
type: 'string',
|
|
4095
|
-
// Dedup the merged enum. `preference` and `summary` appear in
|
|
4096
|
-
// BOTH v1 (VALID_MEMORY_TYPES) and legacy v0 (LEGACY_V0_MEMORY_TYPES),
|
|
4097
|
-
// so the naive spread produces duplicate items at ## 5 and 12
|
|
4098
|
-
// (QA failure on 3.0.7-rc.1: ajv rejects schema with "items ##
|
|
4099
|
-
// 5 and 12 are identical"). `new Set(...)` drops dupes while
|
|
4100
|
-
// preserving insertion order so v1 tokens appear first in the
|
|
4101
|
-
// enum — agents default to picking one of those.
|
|
4102
|
-
enum: Array.from(new Set([...VALID_MEMORY_TYPES, ...LEGACY_V0_MEMORY_TYPES])),
|
|
4103
|
-
description:
|
|
4104
|
-
'Memory Taxonomy v1 type: claim, preference, directive, commitment, episode, summary. ' +
|
|
4105
|
-
'Use "claim" for factual assertions and decisions (populate `reasoning` with the why clause). ' +
|
|
4106
|
-
'Use "directive" for imperative rules ("always X", "never Y"), "commitment" for future intent, ' +
|
|
4107
|
-
'and "episode" for notable events. Legacy v0 tokens (fact, decision, episodic, goal, context, ' +
|
|
4108
|
-
'rule) are silently coerced to their v1 equivalents. Default: claim.',
|
|
4109
|
-
},
|
|
4110
|
-
source: {
|
|
4111
|
-
type: 'string',
|
|
4112
|
-
enum: [...VALID_MEMORY_SOURCES],
|
|
4113
|
-
description:
|
|
4114
|
-
'v1 provenance tag. "user" = user explicitly stated it, "user-inferred" = inferred from user ' +
|
|
4115
|
-
'signals, "assistant" = assistant-authored (downgrade unless user affirmed), "external" / ' +
|
|
4116
|
-
'"derived" = rare. Explicit remembers default to "user".',
|
|
4117
|
-
},
|
|
4118
|
-
scope: {
|
|
4119
|
-
type: 'string',
|
|
4120
|
-
enum: [...VALID_MEMORY_SCOPES],
|
|
4121
|
-
description:
|
|
4122
|
-
'v1 life-domain scope: work, personal, health, family, creative, finance, misc, unspecified. ' +
|
|
4123
|
-
'Default: unspecified.',
|
|
4124
|
-
},
|
|
4125
|
-
reasoning: {
|
|
4126
|
-
type: 'string',
|
|
4127
|
-
description:
|
|
4128
|
-
'For type=claim expressing a decision, the WHY clause ("because Y"). Max 256 chars. ' +
|
|
4129
|
-
'Omit for non-decision claims.',
|
|
4130
|
-
maxLength: 256,
|
|
4131
|
-
},
|
|
4132
|
-
importance: {
|
|
4133
|
-
type: 'number',
|
|
4134
|
-
minimum: 1,
|
|
4135
|
-
maximum: 10,
|
|
4136
|
-
description: 'Importance score 1-10 (default: 8 for explicit remember)',
|
|
4137
|
-
},
|
|
4138
|
-
entities: {
|
|
4139
|
-
type: 'array',
|
|
4140
|
-
description:
|
|
4141
|
-
'Named entities this memory is about (people, projects, tools, companies, concepts, places). ' +
|
|
4142
|
-
'Supplying entities enables Phase 2 contradiction detection against existing facts about the same entity. ' +
|
|
4143
|
-
'Omit if unclear — a best-effort fallback will still store the memory.',
|
|
4144
|
-
items: {
|
|
4145
|
-
type: 'object',
|
|
4146
|
-
properties: {
|
|
4147
|
-
name: { type: 'string' },
|
|
4148
|
-
type: {
|
|
4149
|
-
type: 'string',
|
|
4150
|
-
enum: ['person', 'project', 'tool', 'company', 'concept', 'place'],
|
|
4151
|
-
},
|
|
4152
|
-
role: { type: 'string' },
|
|
4153
|
-
},
|
|
4154
|
-
required: ['name', 'type'],
|
|
4155
|
-
additionalProperties: false,
|
|
4156
|
-
},
|
|
4157
|
-
},
|
|
4158
|
-
},
|
|
4159
|
-
required: ['text'],
|
|
4160
|
-
additionalProperties: false,
|
|
4161
|
-
},
|
|
4162
|
-
async execute(
|
|
4163
|
-
_toolCallId: string,
|
|
4164
|
-
params: {
|
|
4165
|
-
text: string;
|
|
4166
|
-
type?: string;
|
|
4167
|
-
source?: string;
|
|
4168
|
-
scope?: string;
|
|
4169
|
-
reasoning?: string;
|
|
4170
|
-
importance?: number;
|
|
4171
|
-
entities?: Array<{ name: string; type: string; role?: string }>;
|
|
4172
|
-
},
|
|
4173
|
-
) {
|
|
4174
|
-
try {
|
|
4175
|
-
await requireFullSetup(api.logger);
|
|
4176
|
-
|
|
4177
|
-
// v1 taxonomy: route explicit remembers through the same canonical
|
|
4178
|
-
// store path that auto-extraction uses (`storeExtractedFacts`). This
|
|
4179
|
-
// emits a Memory Taxonomy v1 JSON blob, generates entity trapdoors,
|
|
4180
|
-
// and runs through the Phase 2 contradiction-resolution pipeline.
|
|
4181
|
-
//
|
|
4182
|
-
// Accept legacy v0 tokens on input and coerce to v1 via
|
|
4183
|
-
// `normalizeToV1Type` so agents that still emit the pre-v3
|
|
4184
|
-
// taxonomy keep working.
|
|
4185
|
-
const rawType = typeof params.type === 'string' ? params.type.toLowerCase() : 'claim';
|
|
4186
|
-
const memoryType: MemoryType = isValidMemoryType(rawType)
|
|
4187
|
-
? rawType
|
|
4188
|
-
: normalizeToV1Type(rawType);
|
|
4189
|
-
|
|
4190
|
-
// Source defaults to 'user' for explicit remembers (the user is
|
|
4191
|
-
// the author by definition). Ignored if the caller passes an
|
|
4192
|
-
// invalid value.
|
|
4193
|
-
const rawSource = typeof params.source === 'string' ? params.source.toLowerCase() : 'user';
|
|
4194
|
-
const memorySource: MemorySource =
|
|
4195
|
-
(VALID_MEMORY_SOURCES as readonly string[]).includes(rawSource)
|
|
4196
|
-
? (rawSource as MemorySource)
|
|
4197
|
-
: 'user';
|
|
4198
|
-
|
|
4199
|
-
const rawScope = typeof params.scope === 'string' ? params.scope.toLowerCase() : 'unspecified';
|
|
4200
|
-
const memoryScope: MemoryScope =
|
|
4201
|
-
(VALID_MEMORY_SCOPES as readonly string[]).includes(rawScope)
|
|
4202
|
-
? (rawScope as MemoryScope)
|
|
4203
|
-
: 'unspecified';
|
|
4204
|
-
|
|
4205
|
-
const reasoning =
|
|
4206
|
-
typeof params.reasoning === 'string' && params.reasoning.length > 0
|
|
4207
|
-
? params.reasoning.slice(0, 256)
|
|
4208
|
-
: undefined;
|
|
4209
|
-
|
|
4210
|
-
// Explicit remember defaults to importance 8 (above auto-extraction's
|
|
4211
|
-
// typical 6-7), so store-time dedup's shouldSupersede prefers the
|
|
4212
|
-
// explicit call when it collides with an auto-extracted claim.
|
|
4213
|
-
const importance = Math.max(1, Math.min(10, params.importance ?? 8));
|
|
4214
|
-
|
|
4215
|
-
const validatedEntities: ExtractedEntity[] = Array.isArray(params.entities)
|
|
4216
|
-
? params.entities
|
|
4217
|
-
.map((e) => parseEntity(e))
|
|
4218
|
-
.filter((e): e is ExtractedEntity => e !== null)
|
|
4219
|
-
: [];
|
|
4220
|
-
|
|
4221
|
-
const fact: ExtractedFact = {
|
|
4222
|
-
text: params.text.slice(0, 512),
|
|
4223
|
-
type: memoryType,
|
|
4224
|
-
source: memorySource,
|
|
4225
|
-
scope: memoryScope,
|
|
4226
|
-
reasoning,
|
|
4227
|
-
importance,
|
|
4228
|
-
action: 'ADD',
|
|
4229
|
-
confidence: 1.0, // user explicitly asked to remember — highest confidence
|
|
4230
|
-
};
|
|
4231
|
-
if (validatedEntities.length > 0) fact.entities = validatedEntities;
|
|
4232
|
-
|
|
4233
|
-
const stored = await storeExtractedFacts([fact], api.logger, 'explicit');
|
|
4234
|
-
api.logger.info(
|
|
4235
|
-
`totalreclaw_remember: routed to storeExtractedFacts (stored=${stored}, entities=${validatedEntities.length})`,
|
|
4236
|
-
);
|
|
4237
|
-
|
|
4238
|
-
if (stored === 0) {
|
|
4239
|
-
// Dedup or supersession consumed the write. Treat as success from
|
|
4240
|
-
// the user's perspective — the memory's content is already in the
|
|
4241
|
-
// vault (possibly under a different ID).
|
|
4242
|
-
return {
|
|
4243
|
-
content: [
|
|
4244
|
-
{
|
|
4245
|
-
type: 'text',
|
|
4246
|
-
text: 'Memory noted (matched existing content in vault).',
|
|
4247
|
-
},
|
|
4248
|
-
],
|
|
4249
|
-
};
|
|
4250
|
-
}
|
|
4251
|
-
|
|
4252
|
-
return {
|
|
4253
|
-
content: [{ type: 'text', text: 'Memory encrypted and stored.' }],
|
|
4254
|
-
};
|
|
4255
|
-
} catch (err: unknown) {
|
|
4256
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4257
|
-
api.logger.error(`totalreclaw_remember failed: ${message}`);
|
|
4258
|
-
return {
|
|
4259
|
-
content: [{ type: 'text', text: `Failed to store memory: ${humanizeError(message)}` }],
|
|
4260
|
-
};
|
|
4261
|
-
}
|
|
4262
|
-
},
|
|
4263
|
-
},
|
|
4264
|
-
{ name: 'totalreclaw_remember' },
|
|
4265
|
-
);
|
|
4266
|
-
|
|
4267
|
-
// ---------------------------------------------------------------
|
|
4268
|
-
// Tool: totalreclaw_recall
|
|
4269
|
-
// ---------------------------------------------------------------
|
|
4270
|
-
|
|
4271
|
-
api.registerTool(
|
|
4272
|
-
{
|
|
4273
|
-
name: 'totalreclaw_recall',
|
|
4274
|
-
label: 'Recall',
|
|
4275
|
-
description:
|
|
4276
|
-
'Search the encrypted memory vault. Returns the most relevant memories matching the query.',
|
|
4277
|
-
parameters: {
|
|
4278
|
-
type: 'object',
|
|
4279
|
-
properties: {
|
|
4280
|
-
query: {
|
|
4281
|
-
type: 'string',
|
|
4282
|
-
description: 'Search query text',
|
|
4283
|
-
},
|
|
4284
|
-
k: {
|
|
4285
|
-
type: 'number',
|
|
4286
|
-
minimum: 1,
|
|
4287
|
-
maximum: 20,
|
|
4288
|
-
description: 'Number of results to return (default: 8)',
|
|
4289
|
-
},
|
|
4290
|
-
},
|
|
4291
|
-
required: ['query'],
|
|
4292
|
-
additionalProperties: false,
|
|
4293
|
-
},
|
|
4294
|
-
async execute(_toolCallId: string, params: { query: string; k?: number }) {
|
|
4295
|
-
try {
|
|
4296
|
-
await requireFullSetup(api.logger);
|
|
4297
|
-
|
|
4298
|
-
const k = Math.min(params.k ?? 8, 20);
|
|
4299
|
-
|
|
4300
|
-
// 1. Generate word trapdoors (blind indices for the query).
|
|
4301
|
-
const wordTrapdoors = generateBlindIndices(params.query);
|
|
4302
|
-
|
|
4303
|
-
// 2. Generate query embedding + LSH trapdoors (may fail gracefully).
|
|
4304
|
-
let queryEmbedding: number[] | null = null;
|
|
4305
|
-
let lshTrapdoors: string[] = [];
|
|
4306
|
-
try {
|
|
4307
|
-
queryEmbedding = await generateEmbedding(params.query, { isQuery: true });
|
|
4308
|
-
const hasher = getLSHHasher(api.logger);
|
|
4309
|
-
if (hasher && queryEmbedding) {
|
|
4310
|
-
lshTrapdoors = hasher.hash(queryEmbedding);
|
|
4311
|
-
}
|
|
4312
|
-
} catch (err) {
|
|
4313
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
4314
|
-
api.logger.warn(`Recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
|
|
4315
|
-
}
|
|
4316
|
-
|
|
4317
|
-
// 3. Merge word trapdoors + LSH trapdoors.
|
|
4318
|
-
const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
|
|
4319
|
-
|
|
4320
|
-
if (allTrapdoors.length === 0) {
|
|
4321
|
-
return {
|
|
4322
|
-
content: [{ type: 'text', text: 'No searchable terms in query.' }],
|
|
4323
|
-
details: { count: 0, memories: [] },
|
|
4324
|
-
};
|
|
4325
|
-
}
|
|
4326
|
-
|
|
4327
|
-
// 4. Request more candidates than needed so we can re-rank client-side.
|
|
4328
|
-
// 5. Decrypt candidates (text + embeddings) and build reranker input.
|
|
4329
|
-
const rerankerCandidates: RerankerCandidate[] = [];
|
|
4330
|
-
const metaMap = new Map<string, { metadata: Record<string, unknown>; timestamp: number }>();
|
|
4331
|
-
|
|
4332
|
-
if (isSubgraphMode()) {
|
|
4333
|
-
// --- Subgraph search path ---
|
|
4334
|
-
const factCount = await getSubgraphFactCount(subgraphOwner || userId!, authKeyHex!);
|
|
4335
|
-
const pool = computeCandidatePool(factCount);
|
|
4336
|
-
let subgraphResults = await searchSubgraph(subgraphOwner || userId!, allTrapdoors, pool, authKeyHex!);
|
|
4337
|
-
|
|
4338
|
-
// Always run broadened search and merge — ensures vocabulary mismatches
|
|
4339
|
-
// (e.g., "preferences" vs "prefer") don't cause recall failures.
|
|
4340
|
-
// The reranker handles scoring; extra cost is ~1 GraphQL query per recall.
|
|
4341
|
-
try {
|
|
4342
|
-
const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId!, pool, authKeyHex!);
|
|
4343
|
-
// Merge broadened results with existing (deduplicate by ID)
|
|
4344
|
-
const existingIds = new Set(subgraphResults.map(r => r.id));
|
|
4345
|
-
for (const br of broadenedResults) {
|
|
4346
|
-
if (!existingIds.has(br.id)) {
|
|
4347
|
-
subgraphResults.push(br);
|
|
4348
|
-
}
|
|
4349
|
-
}
|
|
4350
|
-
} catch { /* best-effort */ }
|
|
4351
|
-
|
|
4352
|
-
for (const result of subgraphResults) {
|
|
4353
|
-
try {
|
|
4354
|
-
const docJson = decryptFromHex(result.encryptedBlob, encryptionKey!);
|
|
4355
|
-
if (isDigestBlob(docJson)) continue;
|
|
4356
|
-
const doc = readClaimFromBlob(docJson);
|
|
4357
|
-
|
|
4358
|
-
let decryptedEmbedding: number[] | undefined;
|
|
4359
|
-
if (result.encryptedEmbedding) {
|
|
4360
|
-
try {
|
|
4361
|
-
decryptedEmbedding = JSON.parse(
|
|
4362
|
-
decryptFromHex(result.encryptedEmbedding, encryptionKey!),
|
|
4363
|
-
);
|
|
4364
|
-
} catch {
|
|
4365
|
-
// Embedding decryption failed -- proceed without it.
|
|
4366
|
-
}
|
|
4367
|
-
}
|
|
4368
|
-
|
|
4369
|
-
if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
|
|
4370
|
-
try {
|
|
4371
|
-
decryptedEmbedding = await generateEmbedding(doc.text);
|
|
4372
|
-
} catch {
|
|
4373
|
-
decryptedEmbedding = undefined;
|
|
4374
|
-
}
|
|
4375
|
-
}
|
|
4376
|
-
|
|
4377
|
-
rerankerCandidates.push({
|
|
4378
|
-
id: result.id,
|
|
4379
|
-
text: doc.text,
|
|
4380
|
-
embedding: decryptedEmbedding,
|
|
4381
|
-
importance: doc.importance / 10,
|
|
4382
|
-
createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
|
|
4383
|
-
// Retrieval v2 Tier 1: surface v1 source so applySourceWeights
|
|
4384
|
-
// can multiply the final RRF score by the source weight.
|
|
4385
|
-
source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
|
|
4386
|
-
});
|
|
4387
|
-
|
|
4388
|
-
metaMap.set(result.id, {
|
|
4389
|
-
metadata: doc.metadata ?? {},
|
|
4390
|
-
timestamp: Date.now(),
|
|
4391
|
-
category: doc.category,
|
|
4392
|
-
});
|
|
4393
|
-
} catch {
|
|
4394
|
-
// Skip candidates we cannot decrypt.
|
|
4395
|
-
}
|
|
4396
|
-
}
|
|
4397
|
-
|
|
4398
|
-
// Update hot cache with top results for instant auto-recall.
|
|
4399
|
-
try {
|
|
4400
|
-
if (!pluginHotCache && encryptionKey) {
|
|
4401
|
-
const config = getSubgraphConfig();
|
|
4402
|
-
pluginHotCache = new PluginHotCache(config.cachePath, encryptionKey.toString('hex'));
|
|
4403
|
-
pluginHotCache.load();
|
|
4404
|
-
}
|
|
4405
|
-
if (pluginHotCache) {
|
|
4406
|
-
const hotFacts: HotFact[] = rerankerCandidates.map((c) => {
|
|
4407
|
-
const meta = metaMap.get(c.id);
|
|
4408
|
-
const importance = meta?.metadata.importance
|
|
4409
|
-
? Math.round((meta.metadata.importance as number) * 10)
|
|
4410
|
-
: 5;
|
|
4411
|
-
return { id: c.id, text: c.text, importance };
|
|
4412
|
-
});
|
|
4413
|
-
pluginHotCache.setHotFacts(hotFacts);
|
|
4414
|
-
pluginHotCache.setFactCount(rerankerCandidates.length);
|
|
4415
|
-
pluginHotCache.flush();
|
|
4416
|
-
}
|
|
4417
|
-
} catch {
|
|
4418
|
-
// Hot cache update is best-effort -- don't fail the recall.
|
|
4419
|
-
}
|
|
4420
|
-
} else {
|
|
4421
|
-
// --- Server search path (existing behavior) ---
|
|
4422
|
-
const factCount = await getFactCount(api.logger);
|
|
4423
|
-
const pool = computeCandidatePool(factCount);
|
|
4424
|
-
const candidates = await apiClient!.search(
|
|
4425
|
-
userId!,
|
|
4426
|
-
allTrapdoors,
|
|
4427
|
-
pool,
|
|
4428
|
-
authKeyHex!,
|
|
4429
|
-
);
|
|
4430
|
-
|
|
4431
|
-
for (const candidate of candidates) {
|
|
4432
|
-
try {
|
|
4433
|
-
const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey!);
|
|
4434
|
-
if (isDigestBlob(docJson)) continue;
|
|
4435
|
-
const doc = readClaimFromBlob(docJson);
|
|
4436
|
-
|
|
4437
|
-
let decryptedEmbedding: number[] | undefined;
|
|
4438
|
-
if (candidate.encrypted_embedding) {
|
|
4439
|
-
try {
|
|
4440
|
-
decryptedEmbedding = JSON.parse(
|
|
4441
|
-
decryptFromHex(candidate.encrypted_embedding, encryptionKey!),
|
|
4442
|
-
);
|
|
4443
|
-
} catch {
|
|
4444
|
-
// Embedding decryption failed -- proceed without it.
|
|
4445
|
-
}
|
|
4446
|
-
}
|
|
4447
|
-
|
|
4448
|
-
if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
|
|
4449
|
-
try {
|
|
4450
|
-
decryptedEmbedding = await generateEmbedding(doc.text);
|
|
4451
|
-
} catch {
|
|
4452
|
-
decryptedEmbedding = undefined;
|
|
4453
|
-
}
|
|
4454
|
-
}
|
|
4455
|
-
|
|
4456
|
-
rerankerCandidates.push({
|
|
4457
|
-
id: candidate.fact_id,
|
|
4458
|
-
text: doc.text,
|
|
4459
|
-
embedding: decryptedEmbedding,
|
|
4460
|
-
importance: doc.importance / 10,
|
|
4461
|
-
createdAt: typeof candidate.timestamp === 'number'
|
|
4462
|
-
? candidate.timestamp / 1000
|
|
4463
|
-
: new Date(candidate.timestamp).getTime() / 1000,
|
|
4464
|
-
source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
|
|
4465
|
-
});
|
|
4466
|
-
|
|
4467
|
-
metaMap.set(candidate.fact_id, {
|
|
4468
|
-
metadata: doc.metadata ?? {},
|
|
4469
|
-
timestamp: candidate.timestamp,
|
|
4470
|
-
category: doc.category,
|
|
4471
|
-
});
|
|
4472
|
-
} catch {
|
|
4473
|
-
// Skip candidates we cannot decrypt (e.g. corrupted data).
|
|
4474
|
-
}
|
|
4475
|
-
}
|
|
4476
|
-
}
|
|
4477
|
-
|
|
4478
|
-
// 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
|
|
4479
|
-
const queryIntent = detectQueryIntent(params.query);
|
|
4480
|
-
const reranked = rerank(
|
|
4481
|
-
params.query,
|
|
4482
|
-
queryEmbedding ?? [],
|
|
4483
|
-
rerankerCandidates,
|
|
4484
|
-
k,
|
|
4485
|
-
INTENT_WEIGHTS[queryIntent],
|
|
4486
|
-
/* applySourceWeights (Retrieval v2 Tier 1) */ true,
|
|
4487
|
-
);
|
|
4488
|
-
|
|
4489
|
-
if (reranked.length === 0) {
|
|
4490
|
-
return {
|
|
4491
|
-
content: [{ type: 'text', text: 'No memories found matching your query.' }],
|
|
4492
|
-
details: { count: 0, memories: [] },
|
|
4493
|
-
};
|
|
4494
|
-
}
|
|
4495
|
-
|
|
4496
|
-
// 6b. Relevance gate removed in rc.22 -- core's intent-weighted
|
|
4497
|
-
// RRF + Tier 1 source weighting handles short queries via the
|
|
4498
|
-
// BM25 component, making the rc.18 cosine + lexical-override
|
|
4499
|
-
// band-aid (issue #116) redundant.
|
|
4500
|
-
|
|
4501
|
-
// 7. Format results.
|
|
4502
|
-
const lines = reranked.map((m, i) => {
|
|
4503
|
-
const meta = metaMap.get(m.id);
|
|
4504
|
-
const imp = meta?.metadata.importance
|
|
4505
|
-
? ` (importance: ${Math.round((meta.metadata.importance as number) * 10)}/10)`
|
|
4506
|
-
: '';
|
|
4507
|
-
const age = meta ? relativeTime(meta.timestamp) : '';
|
|
4508
|
-
const typeTag = meta?.category ? `[${meta.category}] ` : '';
|
|
4509
|
-
return `${i + 1}. ${typeTag}${m.text}${imp} -- ${age} [ID: ${m.id}]`;
|
|
4510
|
-
});
|
|
4511
|
-
|
|
4512
|
-
const formatted = lines.join('\n');
|
|
4513
|
-
|
|
4514
|
-
return {
|
|
4515
|
-
content: [{ type: 'text', text: formatted }],
|
|
4516
|
-
details: {
|
|
4517
|
-
count: reranked.length,
|
|
4518
|
-
memories: reranked.map((m) => ({
|
|
4519
|
-
factId: m.id,
|
|
4520
|
-
text: m.text,
|
|
4521
|
-
})),
|
|
4522
|
-
},
|
|
4523
|
-
};
|
|
4524
|
-
} catch (err: unknown) {
|
|
4525
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4526
|
-
api.logger.error(`totalreclaw_recall failed: ${message}`);
|
|
4527
|
-
return {
|
|
4528
|
-
content: [{ type: 'text', text: `Failed to search memories: ${humanizeError(message)}` }],
|
|
4529
|
-
};
|
|
4530
|
-
}
|
|
4531
|
-
},
|
|
4532
|
-
},
|
|
4533
|
-
{ name: 'totalreclaw_recall' },
|
|
4534
|
-
);
|
|
4535
|
-
|
|
4536
|
-
// ---------------------------------------------------------------
|
|
4537
|
-
// Tool: totalreclaw_forget
|
|
4538
|
-
// ---------------------------------------------------------------
|
|
4539
|
-
|
|
4540
|
-
api.registerTool(
|
|
4541
|
-
{
|
|
4542
|
-
name: 'totalreclaw_forget',
|
|
4543
|
-
label: 'Forget',
|
|
4544
|
-
description:
|
|
4545
|
-
'Delete a specific memory. Use when the user asks to forget, delete, or remove ' +
|
|
4546
|
-
'something specific (e.g. "forget that I live in Porto", "delete the memory about my old job"). ' +
|
|
4547
|
-
'Writes an on-chain tombstone — the delete is permanent and propagates across all devices. ' +
|
|
4548
|
-
'If the user names the memory in natural language instead of an ID, FIRST call ' +
|
|
4549
|
-
'`totalreclaw_recall` with their phrase as the query, then pass the top result\'s `id` as ' +
|
|
4550
|
-
'`factId`. Non-reversible.',
|
|
4551
|
-
parameters: {
|
|
4552
|
-
type: 'object',
|
|
4553
|
-
properties: {
|
|
4554
|
-
factId: {
|
|
4555
|
-
type: 'string',
|
|
4556
|
-
description:
|
|
4557
|
-
'The UUID of the memory to delete. Get this from a prior `totalreclaw_recall` result — ' +
|
|
4558
|
-
'the `memories[i].id` field. Never invent a factId; if you don\'t have one, call recall first.',
|
|
4559
|
-
},
|
|
4560
|
-
},
|
|
4561
|
-
required: ['factId'],
|
|
4562
|
-
additionalProperties: false,
|
|
4563
|
-
},
|
|
4564
|
-
async execute(_toolCallId: string, params: { factId: string }) {
|
|
4565
|
-
try {
|
|
4566
|
-
await requireFullSetup(api.logger);
|
|
4567
|
-
|
|
4568
|
-
// Validate factId shape BEFORE any on-chain work. Prevents
|
|
4569
|
-
// silent no-op when the LLM fabricates a non-UUID factId —
|
|
4570
|
-
// the classic failure mode from 3.3.1-rc.1 QA where the
|
|
4571
|
-
// agent replied "Done" without calling the tool at all, OR
|
|
4572
|
-
// called the tool with a plain natural-language string.
|
|
4573
|
-
const factId = typeof params.factId === 'string' ? params.factId.trim() : '';
|
|
4574
|
-
if (!factId) {
|
|
4575
|
-
return {
|
|
4576
|
-
content: [{
|
|
4577
|
-
type: 'text',
|
|
4578
|
-
text:
|
|
4579
|
-
'Cannot forget without a memory ID. Call `totalreclaw_recall` first with ' +
|
|
4580
|
-
'the user\'s phrasing as the query — the top result\'s `id` field is the ' +
|
|
4581
|
-
'factId to pass here.',
|
|
4582
|
-
}],
|
|
4583
|
-
details: { deleted: false, error: 'missing-fact-id' },
|
|
4584
|
-
};
|
|
4585
|
-
}
|
|
4586
|
-
// UUID-v4-ish shape check (loose — accepts any hex-dashed id).
|
|
4587
|
-
// Prevents cases like `factId: "that I live in Porto"` from
|
|
4588
|
-
// reaching the UserOp path and silently failing on-chain.
|
|
4589
|
-
const looksLikeFactId = /^[0-9a-f-]{8,}$/i.test(factId);
|
|
4590
|
-
if (!looksLikeFactId) {
|
|
4591
|
-
api.logger.warn(
|
|
4592
|
-
`totalreclaw_forget: rejected likely-invalid factId "${factId.slice(0, 40)}" ` +
|
|
4593
|
-
`— expected a UUID from a prior recall result, not natural language.`,
|
|
4594
|
-
);
|
|
4595
|
-
return {
|
|
4596
|
-
content: [{
|
|
4597
|
-
type: 'text',
|
|
4598
|
-
text:
|
|
4599
|
-
`"${factId.slice(0, 60)}" doesn\'t look like a memory ID. Call ` +
|
|
4600
|
-
'`totalreclaw_recall` first with the user\'s phrasing as the query, then ' +
|
|
4601
|
-
'pass the top result\'s `id` field (a hex UUID) as `factId`.',
|
|
4602
|
-
}],
|
|
4603
|
-
details: { deleted: false, error: 'invalid-fact-id' },
|
|
4604
|
-
};
|
|
4605
|
-
}
|
|
4606
|
-
|
|
4607
|
-
if (isSubgraphMode()) {
|
|
4608
|
-
// On-chain tombstone: write a minimal protobuf with decayScore=0
|
|
4609
|
-
// The subgraph picks this up and sets isActive=false.
|
|
4610
|
-
//
|
|
4611
|
-
// 3.3.1-rc.2 fix: route through submitFactBatchOnChain with a
|
|
4612
|
-
// single-payload batch so we share the tombstone codepath the
|
|
4613
|
-
// pin/unpin flow uses (that flow is known-good and the QA
|
|
4614
|
-
// confirms pin works). Also write at legacy v3 (NOT v4) so the
|
|
4615
|
-
// subgraph handler matches the source="tombstone" + version=3
|
|
4616
|
-
// shape the contradiction/pin tombstones use.
|
|
4617
|
-
const config = { ...getSubgraphConfig(), authKeyHex: authKeyHex!, walletAddress: subgraphOwner ?? undefined };
|
|
4618
|
-
const tombstone: FactPayload = {
|
|
4619
|
-
id: factId,
|
|
4620
|
-
timestamp: new Date().toISOString(),
|
|
4621
|
-
owner: subgraphOwner || userId!,
|
|
4622
|
-
encryptedBlob: '00', // minimal 1-byte placeholder
|
|
4623
|
-
blindIndices: [],
|
|
4624
|
-
decayScore: 0,
|
|
4625
|
-
source: 'tombstone',
|
|
4626
|
-
contentFp: '',
|
|
4627
|
-
agentId: 'openclaw-plugin-forget',
|
|
4628
|
-
// Deliberately NO version: field → uses the default (legacy v3).
|
|
4629
|
-
// The pin/unpin tombstones use v3 (see pin.ts:611-621) — we
|
|
4630
|
-
// MUST match that shape or the subgraph may not flip isActive.
|
|
4631
|
-
};
|
|
4632
|
-
const protobuf = encodeFactProtobuf(tombstone);
|
|
4633
|
-
const result = await submitFactBatchOnChain([protobuf], config);
|
|
4634
|
-
if (!result.success) {
|
|
4635
|
-
throw new Error(`On-chain tombstone failed (tx=${result.txHash?.slice(0, 10) || 'none'}…)`);
|
|
4636
|
-
}
|
|
4637
|
-
api.logger.info(`Tombstone written for ${factId}: tx=${result.txHash}`);
|
|
4638
|
-
// Read-after-write: poll the subgraph until the original fact id
|
|
4639
|
-
// is no longer active (forget flips isActive=false). On timeout
|
|
4640
|
-
// surface `partial: true` so the agent can explain the chain
|
|
4641
|
-
// write succeeded but the subgraph is still propagating.
|
|
4642
|
-
const confirm = await confirmIndexed(factId, {
|
|
4643
|
-
expect: 'inactive',
|
|
4644
|
-
authKeyHex: authKeyHex!,
|
|
4645
|
-
});
|
|
4646
|
-
return {
|
|
4647
|
-
content: [{
|
|
4648
|
-
type: 'text',
|
|
4649
|
-
text: confirm.indexed
|
|
4650
|
-
? `Memory ${factId} deleted on-chain and confirmed by the subgraph (tx: ${result.txHash}).`
|
|
4651
|
-
: `Memory ${factId} deleted on-chain (tx: ${result.txHash}). ` +
|
|
4652
|
-
'The subgraph indexer is still propagating the change — ' +
|
|
4653
|
-
'recall/export may briefly show the memory as still active.',
|
|
4654
|
-
}],
|
|
4655
|
-
details: {
|
|
4656
|
-
deleted: true,
|
|
4657
|
-
txHash: result.txHash,
|
|
4658
|
-
factId,
|
|
4659
|
-
...(confirm.indexed ? {} : { partial: true }),
|
|
4660
|
-
},
|
|
4661
|
-
};
|
|
4662
|
-
} else {
|
|
4663
|
-
await apiClient!.deleteFact(factId, authKeyHex!);
|
|
4664
|
-
return {
|
|
4665
|
-
content: [{ type: 'text', text: `Memory ${factId} deleted` }],
|
|
4666
|
-
details: { deleted: true, factId },
|
|
4667
|
-
};
|
|
4668
|
-
}
|
|
4669
|
-
} catch (err: unknown) {
|
|
4670
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4671
|
-
api.logger.error(`totalreclaw_forget failed: ${message}`);
|
|
4672
|
-
return {
|
|
4673
|
-
content: [{ type: 'text', text: `Failed to delete memory: ${humanizeError(message)}` }],
|
|
4674
|
-
};
|
|
4675
|
-
}
|
|
4676
|
-
},
|
|
4677
|
-
},
|
|
4678
|
-
{ name: 'totalreclaw_forget' },
|
|
4679
|
-
);
|
|
4680
|
-
|
|
4681
|
-
// ---------------------------------------------------------------
|
|
4682
|
-
// Tool: totalreclaw_export
|
|
4683
|
-
// ---------------------------------------------------------------
|
|
4684
|
-
|
|
4685
|
-
api.registerTool(
|
|
4686
|
-
{
|
|
4687
|
-
name: 'totalreclaw_export',
|
|
4688
|
-
label: 'Export',
|
|
4689
|
-
description:
|
|
4690
|
-
'Export all stored memories. Decrypts every memory and returns them as JSON or Markdown.',
|
|
4691
|
-
parameters: {
|
|
4692
|
-
type: 'object',
|
|
4693
|
-
properties: {
|
|
4694
|
-
format: {
|
|
4695
|
-
type: 'string',
|
|
4696
|
-
enum: ['json', 'markdown'],
|
|
4697
|
-
description: 'Output format (default: json)',
|
|
4698
|
-
},
|
|
4699
|
-
},
|
|
4700
|
-
additionalProperties: false,
|
|
4701
|
-
},
|
|
4702
|
-
async execute(_toolCallId: string, params: { format?: string }) {
|
|
4703
|
-
try {
|
|
4704
|
-
await requireFullSetup(api.logger);
|
|
4705
|
-
|
|
4706
|
-
const format = params.format ?? 'json';
|
|
4707
|
-
|
|
4708
|
-
// Paginate through all facts.
|
|
4709
|
-
const allFacts: Array<{
|
|
4710
|
-
id: string;
|
|
4711
|
-
text: string;
|
|
4712
|
-
metadata: Record<string, unknown>;
|
|
4713
|
-
created_at: string;
|
|
4714
|
-
}> = [];
|
|
4715
|
-
|
|
4716
|
-
if (isSubgraphMode()) {
|
|
4717
|
-
// Query subgraph for all active facts (cursor-based pagination via id_gt)
|
|
4718
|
-
const config = getSubgraphConfig();
|
|
4719
|
-
const relayUrl = config.relayUrl;
|
|
4720
|
-
const PAGE_SIZE = 1000;
|
|
4721
|
-
let lastId = '';
|
|
4722
|
-
const owner = subgraphOwner || userId || '';
|
|
4723
|
-
console.error(`[TotalReclaw Export] owner=${owner} subgraphOwner=${subgraphOwner} userId=${userId} relayUrl=${relayUrl} authKey=${authKeyHex ? authKeyHex.slice(0, 8) + '...' : 'MISSING'} isSubgraph=${isSubgraphMode()}`);
|
|
4724
|
-
|
|
4725
|
-
while (true) {
|
|
4726
|
-
const hasLastId = lastId !== '';
|
|
4727
|
-
const query = hasLastId
|
|
4728
|
-
? `query($owner:Bytes!,$first:Int!,$lastId:String!){facts(where:{owner:$owner,isActive:true,id_gt:$lastId},first:$first,orderBy:id,orderDirection:asc){id encryptedBlob timestamp sequenceId}}`
|
|
4729
|
-
: `query($owner:Bytes!,$first:Int!){facts(where:{owner:$owner,isActive:true},first:$first,orderBy:id,orderDirection:asc){id encryptedBlob timestamp sequenceId}}`;
|
|
4730
|
-
const variables: Record<string, unknown> = hasLastId
|
|
4731
|
-
? { owner, first: PAGE_SIZE, lastId }
|
|
4732
|
-
: { owner, first: PAGE_SIZE };
|
|
4733
|
-
|
|
4734
|
-
const res = await fetch(`${relayUrl}/v1/subgraph`, {
|
|
4735
|
-
method: 'POST',
|
|
4736
|
-
headers: buildRelayHeaders({
|
|
4737
|
-
'Content-Type': 'application/json',
|
|
4738
|
-
...(authKeyHex ? { Authorization: `Bearer ${authKeyHex}` } : {}),
|
|
4739
|
-
}),
|
|
4740
|
-
body: JSON.stringify({ query, variables }),
|
|
4741
|
-
});
|
|
4742
|
-
|
|
4743
|
-
const json = (await res.json()) as {
|
|
4744
|
-
data?: { facts?: Array<{ id: string; encryptedBlob: string; source: string; agentId: string; timestamp: string; sequenceId: string }> };
|
|
4745
|
-
error?: string;
|
|
4746
|
-
errors?: Array<{ message: string }>;
|
|
4747
|
-
};
|
|
4748
|
-
// Surface relay/subgraph errors instead of silently returning empty
|
|
4749
|
-
if (json.error || json.errors) {
|
|
4750
|
-
const errMsg = json.error || json.errors?.map(e => e.message).join('; ') || 'Unknown error';
|
|
4751
|
-
api.logger.error(`Export subgraph query failed: ${errMsg} (owner=${owner}, status=${res.status})`);
|
|
4752
|
-
return {
|
|
4753
|
-
content: [{ type: 'text', text: `Export failed: ${errMsg}` }],
|
|
4754
|
-
};
|
|
4755
|
-
}
|
|
4756
|
-
const facts = json?.data?.facts || [];
|
|
4757
|
-
if (facts.length === 0) break;
|
|
4758
|
-
|
|
4759
|
-
for (const fact of facts) {
|
|
4760
|
-
try {
|
|
4761
|
-
let hexBlob = fact.encryptedBlob;
|
|
4762
|
-
if (hexBlob.startsWith('0x')) hexBlob = hexBlob.slice(2);
|
|
4763
|
-
const docJson = decryptFromHex(hexBlob, encryptionKey!);
|
|
4764
|
-
if (isDigestBlob(docJson)) continue;
|
|
4765
|
-
const doc = readClaimFromBlob(docJson);
|
|
4766
|
-
allFacts.push({
|
|
4767
|
-
id: fact.id,
|
|
4768
|
-
text: doc.text,
|
|
4769
|
-
metadata: doc.metadata,
|
|
4770
|
-
created_at: new Date(parseInt(fact.timestamp) * 1000).toISOString(),
|
|
4771
|
-
});
|
|
4772
|
-
} catch {
|
|
4773
|
-
// Skip facts we cannot decrypt
|
|
4774
|
-
}
|
|
4775
|
-
}
|
|
4776
|
-
|
|
4777
|
-
if (facts.length < PAGE_SIZE) break;
|
|
4778
|
-
lastId = facts[facts.length - 1].id;
|
|
4779
|
-
}
|
|
4780
|
-
} else {
|
|
4781
|
-
// HTTP server mode — paginate through PostgreSQL facts
|
|
4782
|
-
let cursor: string | undefined;
|
|
4783
|
-
let hasMore = true;
|
|
4784
|
-
|
|
4785
|
-
while (hasMore) {
|
|
4786
|
-
const page = await apiClient!.exportFacts(authKeyHex!, 1000, cursor);
|
|
4787
|
-
|
|
4788
|
-
for (const fact of page.facts) {
|
|
4789
|
-
try {
|
|
4790
|
-
const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey!);
|
|
4791
|
-
if (isDigestBlob(docJson)) continue;
|
|
4792
|
-
const doc = readClaimFromBlob(docJson);
|
|
4793
|
-
allFacts.push({
|
|
4794
|
-
id: fact.id,
|
|
4795
|
-
text: doc.text,
|
|
4796
|
-
metadata: doc.metadata,
|
|
4797
|
-
created_at: fact.created_at,
|
|
4798
|
-
});
|
|
4799
|
-
} catch {
|
|
4800
|
-
// Skip facts we cannot decrypt.
|
|
4801
|
-
}
|
|
4802
|
-
}
|
|
4803
|
-
|
|
4804
|
-
cursor = page.cursor ?? undefined;
|
|
4805
|
-
hasMore = page.has_more;
|
|
4806
|
-
}
|
|
4807
|
-
}
|
|
4808
|
-
|
|
4809
|
-
// Format output.
|
|
4810
|
-
let formatted: string;
|
|
4811
|
-
|
|
4812
|
-
if (format === 'markdown') {
|
|
4813
|
-
if (allFacts.length === 0) {
|
|
4814
|
-
formatted = '*No memories stored.*';
|
|
4815
|
-
} else {
|
|
4816
|
-
const lines = allFacts.map((f, i) => {
|
|
4817
|
-
const meta = f.metadata;
|
|
4818
|
-
const type = (meta.type as string) ?? 'fact';
|
|
4819
|
-
const imp = meta.importance
|
|
4820
|
-
? ` (importance: ${Math.round((meta.importance as number) * 10)}/10)`
|
|
4821
|
-
: '';
|
|
4822
|
-
return `${i + 1}. **[${type}]** ${f.text}${imp} \n _ID: ${f.id} | Created: ${f.created_at}_`;
|
|
4823
|
-
});
|
|
4824
|
-
formatted = `# Exported Memories (${allFacts.length})\n\n${lines.join('\n')}`;
|
|
4825
|
-
}
|
|
4826
|
-
} else {
|
|
4827
|
-
formatted = JSON.stringify(allFacts, null, 2);
|
|
4828
|
-
}
|
|
4829
|
-
|
|
4830
|
-
return {
|
|
4831
|
-
content: [{ type: 'text', text: formatted }],
|
|
4832
|
-
details: { count: allFacts.length },
|
|
4833
|
-
};
|
|
4834
|
-
} catch (err: unknown) {
|
|
4835
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4836
|
-
api.logger.error(`totalreclaw_export failed: ${message}`);
|
|
4837
|
-
return {
|
|
4838
|
-
content: [{ type: 'text', text: `Failed to export memories: ${humanizeError(message)}` }],
|
|
4839
|
-
};
|
|
4840
|
-
}
|
|
4841
|
-
},
|
|
4842
|
-
},
|
|
4843
|
-
{ name: 'totalreclaw_export' },
|
|
4844
|
-
);
|
|
4845
|
-
|
|
4846
|
-
// ---------------------------------------------------------------
|
|
4847
|
-
// Tool: totalreclaw_status
|
|
4848
|
-
// ---------------------------------------------------------------
|
|
4849
|
-
|
|
4850
|
-
api.registerTool(
|
|
4851
|
-
{
|
|
4852
|
-
name: 'totalreclaw_status',
|
|
4853
|
-
label: 'Status',
|
|
4854
|
-
description:
|
|
4855
|
-
'Check TotalReclaw billing and subscription status — tier, writes used, reset date.',
|
|
4856
|
-
parameters: {
|
|
4857
|
-
type: 'object',
|
|
4858
|
-
properties: {},
|
|
4859
|
-
additionalProperties: false,
|
|
4860
|
-
},
|
|
4861
|
-
async execute() {
|
|
4862
|
-
try {
|
|
4863
|
-
await requireFullSetup(api.logger);
|
|
4864
|
-
|
|
4865
|
-
if (!authKeyHex) {
|
|
4866
|
-
return {
|
|
4867
|
-
content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
|
|
4868
|
-
};
|
|
4869
|
-
}
|
|
4870
|
-
|
|
4871
|
-
const serverUrl = CONFIG.serverUrl;
|
|
4872
|
-
const walletAddr = subgraphOwner || userId || '';
|
|
4873
|
-
const response = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
|
|
4874
|
-
method: 'GET',
|
|
4875
|
-
headers: buildRelayHeaders({
|
|
4876
|
-
'Authorization': `Bearer ${authKeyHex}`,
|
|
4877
|
-
'Accept': 'application/json',
|
|
4878
|
-
}),
|
|
4879
|
-
});
|
|
4880
|
-
|
|
4881
|
-
if (!response.ok) {
|
|
4882
|
-
const body = await response.text().catch(() => '');
|
|
4883
|
-
return {
|
|
4884
|
-
content: [{ type: 'text', text: `Failed to fetch billing status (HTTP ${response.status}): ${body || response.statusText}` }],
|
|
4885
|
-
};
|
|
4886
|
-
}
|
|
4887
|
-
|
|
4888
|
-
const data = await response.json() as Record<string, unknown>;
|
|
4889
|
-
const tier = (data.tier as string) || 'free';
|
|
4890
|
-
const freeWritesUsed = (data.free_writes_used as number) ?? 0;
|
|
4891
|
-
const freeWritesLimit = (data.free_writes_limit as number) ?? 0;
|
|
4892
|
-
const freeWritesResetAt = data.free_writes_reset_at as string | undefined;
|
|
4893
|
-
|
|
4894
|
-
// Update billing cache on success.
|
|
4895
|
-
writeBillingCache({
|
|
4896
|
-
tier,
|
|
4897
|
-
free_writes_used: freeWritesUsed,
|
|
4898
|
-
free_writes_limit: freeWritesLimit,
|
|
4899
|
-
features: data.features as BillingCache['features'] | undefined,
|
|
4900
|
-
checked_at: Date.now(),
|
|
4901
|
-
});
|
|
4902
|
-
|
|
4903
|
-
// 3.3.1 (internal#130) — surface the Smart Account / scope
|
|
4904
|
-
// address so the user can do subgraph queries, BaseScan
|
|
4905
|
-
// lookups, and cross-client portability checks BEFORE any
|
|
4906
|
-
// chain write completes. Resolution priority:
|
|
4907
|
-
// 1. In-memory `subgraphOwner` (already derived earlier).
|
|
4908
|
-
// 2. credentials.json `scope_address` (persisted at pair).
|
|
4909
|
-
// 3. Lazy derive now from the loaded mnemonic + cache it
|
|
4910
|
-
// back to credentials.json so the next call is free.
|
|
4911
|
-
let scopeAddress: string | undefined =
|
|
4912
|
-
subgraphOwner ?? undefined;
|
|
4913
|
-
if (!scopeAddress) {
|
|
4914
|
-
try {
|
|
4915
|
-
const credsCache = loadCredentialsJson(CREDENTIALS_PATH);
|
|
4916
|
-
if (credsCache?.scope_address && typeof credsCache.scope_address === 'string') {
|
|
4917
|
-
scopeAddress = credsCache.scope_address;
|
|
4918
|
-
} else if (credsCache?.mnemonic && typeof credsCache.mnemonic === 'string') {
|
|
4919
|
-
scopeAddress = await deriveSmartAccountAddress(
|
|
4920
|
-
credsCache.mnemonic,
|
|
4921
|
-
CONFIG.chainId,
|
|
4922
|
-
);
|
|
4923
|
-
if (scopeAddress) {
|
|
4924
|
-
writeCredentialsJson(CREDENTIALS_PATH, { ...credsCache, scope_address: scopeAddress });
|
|
4925
|
-
}
|
|
4926
|
-
}
|
|
4927
|
-
} catch (deriveErr) {
|
|
4928
|
-
api.logger.warn(
|
|
4929
|
-
`totalreclaw_status: scope_address lookup failed: ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`,
|
|
4930
|
-
);
|
|
4931
|
-
}
|
|
4932
|
-
}
|
|
4933
|
-
|
|
4934
|
-
const tierLabel = tier === 'pro' ? 'Pro' : 'Free';
|
|
4935
|
-
const lines: string[] = [
|
|
4936
|
-
`Tier: ${tierLabel}`,
|
|
4937
|
-
`Writes: ${freeWritesUsed}/${freeWritesLimit} used this month`,
|
|
4938
|
-
];
|
|
4939
|
-
if (freeWritesResetAt) {
|
|
4940
|
-
lines.push(`Resets: ${new Date(freeWritesResetAt).toLocaleDateString()}`);
|
|
4941
|
-
}
|
|
4942
|
-
if (scopeAddress) {
|
|
4943
|
-
lines.push(`Smart Account: ${scopeAddress}`);
|
|
4944
|
-
}
|
|
4945
|
-
if (tier !== 'pro') {
|
|
4946
|
-
lines.push(`Pricing: https://totalreclaw.xyz/pricing`);
|
|
4947
|
-
}
|
|
4948
|
-
|
|
4949
|
-
return {
|
|
4950
|
-
content: [{ type: 'text', text: lines.join('\n') }],
|
|
4951
|
-
details: {
|
|
4952
|
-
tier,
|
|
4953
|
-
free_writes_used: freeWritesUsed,
|
|
4954
|
-
free_writes_limit: freeWritesLimit,
|
|
4955
|
-
scope_address: scopeAddress,
|
|
4956
|
-
},
|
|
4957
|
-
};
|
|
4958
|
-
} catch (err: unknown) {
|
|
4959
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4960
|
-
api.logger.error(`totalreclaw_status failed: ${message}`);
|
|
4961
|
-
return {
|
|
4962
|
-
content: [{ type: 'text', text: `Failed to check status: ${humanizeError(message)}` }],
|
|
4963
|
-
};
|
|
4964
|
-
}
|
|
4965
|
-
},
|
|
4966
|
-
},
|
|
4967
|
-
{ name: 'totalreclaw_status' },
|
|
4968
|
-
);
|
|
4969
|
-
|
|
4970
|
-
// ---------------------------------------------------------------
|
|
4971
|
-
// Tool: totalreclaw_preload_embedder (3.3.3-rc.1 — issue #187)
|
|
4972
|
-
// ---------------------------------------------------------------
|
|
4973
|
-
//
|
|
4974
|
-
// Decouples the ~700 MB embedder bundle download from the pair-
|
|
4975
|
-
// completion gate. The agent can call this BEFORE
|
|
4976
|
-
// `totalreclaw_pair` to pre-flight disk space, kick off the
|
|
4977
|
-
// download, and surface the completion state. The non-blocking
|
|
4978
|
-
// prefetch in `register()` already starts the download
|
|
4979
|
-
// unconditionally; this tool is an explicit on-demand hook for
|
|
4980
|
-
// agents that want to confirm completion (or trigger a retry on
|
|
4981
|
-
// network failure) without first completing pair.
|
|
4982
|
-
//
|
|
4983
|
-
// Behavior:
|
|
4984
|
-
// - Disk-space pre-flight: refuses if free disk < 500 MB on the
|
|
4985
|
-
// embedder cache mount. Surfaces the path + free bytes so the
|
|
4986
|
-
// user can clear space.
|
|
4987
|
-
// - Triggers `prefetchEmbedderBundle()` (idempotent — cache hit
|
|
4988
|
-
// returns immediately).
|
|
4989
|
-
// - Returns a structured success message with status:
|
|
4990
|
-
// `cache_hit | fetched | failed`.
|
|
4991
|
-
//
|
|
4992
|
-
// Phrase-safety: this tool does NOT touch credentials.json,
|
|
4993
|
-
// mnemonics, or keys. It only touches `~/.totalreclaw/embedder/`
|
|
4994
|
-
// and the GitHub Releases CDN.
|
|
4995
|
-
|
|
4996
|
-
api.registerTool(
|
|
4997
|
-
{
|
|
4998
|
-
name: 'totalreclaw_preload_embedder',
|
|
4999
|
-
label: 'Preload Embedder',
|
|
5000
|
-
description:
|
|
5001
|
-
'Download the local-embedding model bundle ahead of pair completion. Use this when the user wants to set up TotalReclaw on a slow connection or run an offline-after-setup workflow. Returns success once the ~700 MB bundle is cached at ~/.totalreclaw/embedder/.',
|
|
5002
|
-
parameters: {
|
|
5003
|
-
type: 'object',
|
|
5004
|
-
properties: {},
|
|
5005
|
-
additionalProperties: false,
|
|
5006
|
-
},
|
|
5007
|
-
async execute() {
|
|
5008
|
-
try {
|
|
5009
|
-
// Disk-space pre-flight: refuse if < 500 MB free on the
|
|
5010
|
-
// embedder cache mount. Best-effort — if statfs fails, we
|
|
5011
|
-
// proceed without the pre-flight rather than blocking.
|
|
5012
|
-
const fsModule = await import('node:fs');
|
|
5013
|
-
const cacheRoot = CONFIG.embedderCachePath;
|
|
5014
|
-
const REQUIRED_BYTES = 500 * 1024 * 1024;
|
|
5015
|
-
try {
|
|
5016
|
-
// Find the deepest existing parent so statfs has a real
|
|
5017
|
-
// mount to measure (loadEmbedder will mkdir under it
|
|
5018
|
-
// anyway).
|
|
5019
|
-
let probeDir = cacheRoot;
|
|
5020
|
-
while (true) {
|
|
5021
|
-
try {
|
|
5022
|
-
fsModule.statSync(probeDir);
|
|
5023
|
-
break;
|
|
5024
|
-
} catch {
|
|
5025
|
-
const parent = nodePath.dirname(probeDir);
|
|
5026
|
-
if (parent === probeDir) break;
|
|
5027
|
-
probeDir = parent;
|
|
5028
|
-
}
|
|
5029
|
-
}
|
|
5030
|
-
const stats = (fsModule as unknown as {
|
|
5031
|
-
statfsSync?: (p: string) => { bavail: bigint | number; bsize: bigint | number };
|
|
5032
|
-
}).statfsSync?.(probeDir);
|
|
5033
|
-
if (stats) {
|
|
5034
|
-
const bavail = typeof stats.bavail === 'bigint' ? Number(stats.bavail) : stats.bavail;
|
|
5035
|
-
const bsize = typeof stats.bsize === 'bigint' ? Number(stats.bsize) : stats.bsize;
|
|
5036
|
-
const freeBytes = bavail * bsize;
|
|
5037
|
-
if (freeBytes > 0 && freeBytes < REQUIRED_BYTES) {
|
|
5038
|
-
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
|
5039
|
-
return {
|
|
5040
|
-
content: [{
|
|
5041
|
-
type: 'text',
|
|
5042
|
-
text: `Insufficient free disk space for embedder bundle. Required: 500 MB. Available at ${probeDir}: ${freeMb} MB. Free up space and retry.`,
|
|
5043
|
-
}],
|
|
5044
|
-
details: { status: 'failed', reason: 'disk_space', free_mb: freeMb, required_mb: 500, cache_root: cacheRoot },
|
|
5045
|
-
};
|
|
5046
|
-
}
|
|
5047
|
-
}
|
|
5048
|
-
} catch {
|
|
5049
|
-
// statfs probe failed — surface a soft warning in logs
|
|
5050
|
-
// but proceed with the download anyway.
|
|
5051
|
-
api.logger.info('totalreclaw_preload_embedder: disk-space probe unavailable, proceeding without pre-flight');
|
|
5052
|
-
}
|
|
5053
|
-
|
|
5054
|
-
// Trigger the prefetch. This is idempotent (cache hit returns
|
|
5055
|
-
// immediately) so it's safe to invoke even when the
|
|
5056
|
-
// background prefetch from register() already completed.
|
|
5057
|
-
const result = await prefetchEmbedderBundle({
|
|
5058
|
-
log: (msg) => api.logger.info(msg),
|
|
5059
|
-
});
|
|
5060
|
-
const human =
|
|
5061
|
-
result === 'fetched'
|
|
5062
|
-
? `Embedder bundle downloaded and cached at ${cacheRoot}. Subsequent embedding calls run in-memory.`
|
|
5063
|
-
: `Embedder bundle already cached at ${cacheRoot} — no download needed.`;
|
|
5064
|
-
return {
|
|
5065
|
-
content: [{ type: 'text', text: human }],
|
|
5066
|
-
details: { status: result, cache_root: cacheRoot },
|
|
5067
|
-
};
|
|
5068
|
-
} catch (err: unknown) {
|
|
5069
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5070
|
-
api.logger.error(`totalreclaw_preload_embedder failed: ${message}`);
|
|
5071
|
-
return {
|
|
5072
|
-
content: [{
|
|
5073
|
-
type: 'text',
|
|
5074
|
-
text: `Embedder bundle preload failed: ${humanizeError(message)}. The plugin will retry on first embedding call.`,
|
|
5075
|
-
}],
|
|
5076
|
-
details: { status: 'failed', reason: 'fetch_error', message },
|
|
5077
|
-
};
|
|
5078
|
-
}
|
|
5079
|
-
},
|
|
5080
|
-
},
|
|
5081
|
-
{ name: 'totalreclaw_preload_embedder' },
|
|
5082
|
-
);
|
|
5083
|
-
|
|
5084
|
-
// ---------------------------------------------------------------
|
|
5085
|
-
// Tool: totalreclaw_consolidate
|
|
5086
|
-
// ---------------------------------------------------------------
|
|
5087
|
-
|
|
5088
|
-
api.registerTool(
|
|
5089
|
-
{
|
|
5090
|
-
name: 'totalreclaw_consolidate',
|
|
5091
|
-
label: 'Consolidate',
|
|
5092
|
-
description:
|
|
5093
|
-
'Deduplicate and merge related memories. Self-hosted mode only.',
|
|
5094
|
-
parameters: {
|
|
5095
|
-
type: 'object',
|
|
5096
|
-
properties: {
|
|
5097
|
-
dry_run: {
|
|
5098
|
-
type: 'boolean',
|
|
5099
|
-
description: 'Preview only (default: false)',
|
|
5100
|
-
},
|
|
5101
|
-
},
|
|
5102
|
-
additionalProperties: false,
|
|
5103
|
-
},
|
|
5104
|
-
async execute(_toolCallId: string, params: { dry_run?: boolean }) {
|
|
5105
|
-
try {
|
|
5106
|
-
await requireFullSetup(api.logger);
|
|
5107
|
-
|
|
5108
|
-
const dryRun = params.dry_run ?? false;
|
|
5109
|
-
|
|
5110
|
-
// Consolidation is only available in centralized (HTTP server) mode.
|
|
5111
|
-
if (isSubgraphMode()) {
|
|
5112
|
-
return {
|
|
5113
|
-
content: [{ type: 'text', text: 'Consolidation is currently only available in centralized mode.' }],
|
|
5114
|
-
};
|
|
5115
|
-
}
|
|
5116
|
-
|
|
5117
|
-
if (!apiClient || !authKeyHex || !encryptionKey) {
|
|
5118
|
-
return {
|
|
5119
|
-
content: [{ type: 'text', text: 'Plugin not fully initialized. Cannot consolidate.' }],
|
|
5120
|
-
};
|
|
5121
|
-
}
|
|
5122
|
-
|
|
5123
|
-
// 1. Export all facts (paginated, max 10 pages of 1000).
|
|
5124
|
-
const allDecrypted: DecryptedCandidate[] = [];
|
|
5125
|
-
let cursor: string | undefined;
|
|
5126
|
-
let hasMore = true;
|
|
5127
|
-
let pageCount = 0;
|
|
5128
|
-
const MAX_PAGES = 10;
|
|
5129
|
-
|
|
5130
|
-
while (hasMore && pageCount < MAX_PAGES) {
|
|
5131
|
-
const page = await apiClient.exportFacts(authKeyHex, 1000, cursor);
|
|
5132
|
-
|
|
5133
|
-
for (const fact of page.facts) {
|
|
5134
|
-
try {
|
|
5135
|
-
const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey);
|
|
5136
|
-
if (isDigestBlob(docJson)) continue;
|
|
5137
|
-
const doc = readClaimFromBlob(docJson);
|
|
5138
|
-
|
|
5139
|
-
let embedding: number[] | null = null;
|
|
5140
|
-
try {
|
|
5141
|
-
embedding = await generateEmbedding(doc.text);
|
|
5142
|
-
} catch { /* skip — fact will not be clustered */ }
|
|
5143
|
-
|
|
5144
|
-
allDecrypted.push({
|
|
5145
|
-
id: fact.id,
|
|
5146
|
-
text: doc.text,
|
|
5147
|
-
embedding,
|
|
5148
|
-
importance: doc.importance,
|
|
5149
|
-
decayScore: fact.decay_score,
|
|
5150
|
-
createdAt: new Date(fact.created_at).getTime(),
|
|
5151
|
-
version: fact.version,
|
|
5152
|
-
});
|
|
5153
|
-
} catch {
|
|
5154
|
-
// Skip undecryptable facts.
|
|
5155
|
-
}
|
|
5156
|
-
}
|
|
5157
|
-
|
|
5158
|
-
cursor = page.cursor ?? undefined;
|
|
5159
|
-
hasMore = page.has_more;
|
|
5160
|
-
pageCount++;
|
|
5161
|
-
}
|
|
5162
|
-
|
|
5163
|
-
if (allDecrypted.length === 0) {
|
|
5164
|
-
return {
|
|
5165
|
-
content: [{ type: 'text', text: 'No memories found to consolidate.' }],
|
|
5166
|
-
};
|
|
5167
|
-
}
|
|
5168
|
-
|
|
5169
|
-
// 2. Cluster by cosine similarity.
|
|
5170
|
-
const clusters = clusterFacts(allDecrypted, getConsolidationThreshold());
|
|
5171
|
-
|
|
5172
|
-
if (clusters.length === 0) {
|
|
5173
|
-
return {
|
|
5174
|
-
content: [{ type: 'text', text: `Scanned ${allDecrypted.length} memories — no near-duplicates found.` }],
|
|
5175
|
-
};
|
|
5176
|
-
}
|
|
5177
|
-
|
|
5178
|
-
// 3. Build report.
|
|
5179
|
-
const totalDuplicates = clusters.reduce((sum, c) => sum + c.duplicates.length, 0);
|
|
5180
|
-
const reportLines: string[] = [
|
|
5181
|
-
`Scanned ${allDecrypted.length} memories.`,
|
|
5182
|
-
`Found ${clusters.length} cluster(s) with ${totalDuplicates} duplicate(s).`,
|
|
5183
|
-
'',
|
|
5184
|
-
];
|
|
5185
|
-
|
|
5186
|
-
const displayClusters = clusters.slice(0, 10);
|
|
5187
|
-
for (let i = 0; i < displayClusters.length; i++) {
|
|
5188
|
-
const cluster = displayClusters[i];
|
|
5189
|
-
reportLines.push(`Cluster ${i + 1}: KEEP "${cluster.representative.text.slice(0, 80)}…"`);
|
|
5190
|
-
for (const dup of cluster.duplicates) {
|
|
5191
|
-
reportLines.push(` - REMOVE "${dup.text.slice(0, 80)}…" (ID: ${dup.id})`);
|
|
5192
|
-
}
|
|
5193
|
-
}
|
|
5194
|
-
if (clusters.length > 10) {
|
|
5195
|
-
reportLines.push(`... and ${clusters.length - 10} more cluster(s).`);
|
|
5196
|
-
}
|
|
5197
|
-
|
|
5198
|
-
// 4. If not dry_run, batch-delete duplicates.
|
|
5199
|
-
if (!dryRun) {
|
|
5200
|
-
const idsToDelete = clusters.flatMap((c) => c.duplicates.map((d) => d.id));
|
|
5201
|
-
const BATCH_SIZE = 500;
|
|
5202
|
-
let totalDeleted = 0;
|
|
5203
|
-
|
|
5204
|
-
for (let i = 0; i < idsToDelete.length; i += BATCH_SIZE) {
|
|
5205
|
-
const batch = idsToDelete.slice(i, i + BATCH_SIZE);
|
|
5206
|
-
const deleted = await apiClient.batchDelete(batch, authKeyHex);
|
|
5207
|
-
totalDeleted += deleted;
|
|
5208
|
-
}
|
|
5209
|
-
|
|
5210
|
-
reportLines.push('');
|
|
5211
|
-
reportLines.push(`Deleted ${totalDeleted} duplicate memories.`);
|
|
5212
|
-
} else {
|
|
5213
|
-
reportLines.push('');
|
|
5214
|
-
reportLines.push('DRY RUN — no memories were deleted. Run without dry_run to apply.');
|
|
5215
|
-
}
|
|
5216
|
-
|
|
5217
|
-
return {
|
|
5218
|
-
content: [{ type: 'text', text: reportLines.join('\n') }],
|
|
5219
|
-
details: {
|
|
5220
|
-
scanned: allDecrypted.length,
|
|
5221
|
-
clusters: clusters.length,
|
|
5222
|
-
duplicates: totalDuplicates,
|
|
5223
|
-
dry_run: dryRun,
|
|
5224
|
-
},
|
|
5225
|
-
};
|
|
5226
|
-
} catch (err: unknown) {
|
|
5227
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5228
|
-
api.logger.error(`totalreclaw_consolidate failed: ${message}`);
|
|
5229
|
-
return {
|
|
5230
|
-
content: [{ type: 'text', text: `Failed to consolidate memories: ${humanizeError(message)}` }],
|
|
5231
|
-
};
|
|
5232
|
-
}
|
|
5233
|
-
},
|
|
5234
|
-
},
|
|
5235
|
-
{ name: 'totalreclaw_consolidate' },
|
|
5236
|
-
);
|
|
5237
|
-
|
|
5238
|
-
// ---------------------------------------------------------------
|
|
5239
|
-
// Helper: build PinOpDeps bound to the live plugin state
|
|
5240
|
-
// ---------------------------------------------------------------
|
|
5241
|
-
// Wires the pure pin/unpin operation to the managed-service transport +
|
|
5242
|
-
// crypto layer. Mirrors MCP's buildPinDepsFromState and Python's
|
|
5243
|
-
// _change_claim_status argument plumbing.
|
|
5244
|
-
const buildPinDeps = (): PinOpDeps => {
|
|
5245
|
-
const owner = subgraphOwner || userId || '';
|
|
5246
|
-
const config = {
|
|
5247
|
-
...getSubgraphConfig(),
|
|
5248
|
-
authKeyHex: authKeyHex!,
|
|
5249
|
-
walletAddress: subgraphOwner ?? undefined,
|
|
5250
|
-
};
|
|
5251
|
-
return {
|
|
5252
|
-
owner,
|
|
5253
|
-
sourceAgent: 'openclaw-plugin',
|
|
5254
|
-
fetchFactById: (factId: string) => fetchFactById(owner, factId, authKeyHex!),
|
|
5255
|
-
decryptBlob: (hex: string) => decryptFromHex(hex, encryptionKey!),
|
|
5256
|
-
encryptBlob: (plaintext: string) => encryptToHex(plaintext, encryptionKey!),
|
|
5257
|
-
submitBatch: async (payloads: Buffer[]) => {
|
|
5258
|
-
const result = await submitFactBatchOnChain(payloads, config);
|
|
5259
|
-
return { txHash: result.txHash, success: result.success };
|
|
5260
|
-
},
|
|
5261
|
-
generateIndices: async (text: string, entityNames: string[]) => {
|
|
5262
|
-
if (!text) return { blindIndices: [] };
|
|
5263
|
-
const wordIndices = generateBlindIndices(text);
|
|
5264
|
-
let lshIndices: string[] = [];
|
|
5265
|
-
let encryptedEmbedding: string | undefined;
|
|
5266
|
-
try {
|
|
5267
|
-
const embedding = await generateEmbedding(text);
|
|
5268
|
-
const hasher = getLSHHasher(api.logger);
|
|
5269
|
-
if (hasher) lshIndices = hasher.hash(embedding);
|
|
5270
|
-
encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey!);
|
|
5271
|
-
} catch {
|
|
5272
|
-
// Best-effort: word + entity trapdoors alone still surface the claim.
|
|
5273
|
-
}
|
|
5274
|
-
const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
|
|
5275
|
-
return {
|
|
5276
|
-
blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
|
|
5277
|
-
encryptedEmbedding,
|
|
5278
|
-
};
|
|
5279
|
-
},
|
|
5280
|
-
};
|
|
5281
|
-
};
|
|
5282
|
-
|
|
5283
|
-
// ---------------------------------------------------------------
|
|
5284
|
-
// Tool: totalreclaw_pin
|
|
5285
|
-
// ---------------------------------------------------------------
|
|
5286
|
-
|
|
5287
|
-
api.registerTool(
|
|
5288
|
-
{
|
|
5289
|
-
name: 'totalreclaw_pin',
|
|
5290
|
-
label: 'Pin',
|
|
5291
|
-
description:
|
|
5292
|
-
'Pin a memory so the auto-resolution engine will never override or supersede it. ' +
|
|
5293
|
-
"Use when the user explicitly confirms a claim is still valid after you or another agent " +
|
|
5294
|
-
"tried to retract/contradict it (e.g. 'wait, I still use Vim sometimes'). " +
|
|
5295
|
-
'Takes fact_id (from a prior recall result). Pinning is idempotent — pinning an already-pinned ' +
|
|
5296
|
-
'claim is a no-op. Cross-device: the pin propagates via the on-chain supersession chain.',
|
|
5297
|
-
parameters: {
|
|
5298
|
-
type: 'object',
|
|
5299
|
-
properties: {
|
|
5300
|
-
fact_id: {
|
|
5301
|
-
type: 'string',
|
|
5302
|
-
description: 'The ID of the fact to pin (from a totalreclaw_recall result).',
|
|
5303
|
-
},
|
|
5304
|
-
reason: {
|
|
5305
|
-
type: 'string',
|
|
5306
|
-
description: 'Optional human-readable reason for pinning (logged locally for tuning).',
|
|
5307
|
-
},
|
|
5308
|
-
},
|
|
5309
|
-
required: ['fact_id'],
|
|
5310
|
-
additionalProperties: false,
|
|
5311
|
-
},
|
|
5312
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
5313
|
-
try {
|
|
5314
|
-
await requireFullSetup(api.logger);
|
|
5315
|
-
if (!isSubgraphMode()) {
|
|
5316
|
-
return {
|
|
5317
|
-
content: [{
|
|
5318
|
-
type: 'text',
|
|
5319
|
-
text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
|
|
5320
|
-
}],
|
|
5321
|
-
};
|
|
5322
|
-
}
|
|
5323
|
-
const validation = validatePinArgs(params);
|
|
5324
|
-
if (!validation.ok) {
|
|
5325
|
-
return { content: [{ type: 'text', text: validation.error }] };
|
|
5326
|
-
}
|
|
5327
|
-
const deps = buildPinDeps();
|
|
5328
|
-
const result = await executePinOperation(validation.factId, 'pinned', deps, validation.reason);
|
|
5329
|
-
if (result.success && result.idempotent) {
|
|
5330
|
-
api.logger.info(`totalreclaw_pin: ${result.fact_id} already pinned (no-op)`);
|
|
5331
|
-
return {
|
|
5332
|
-
content: [{ type: 'text', text: `Memory ${result.fact_id} is already pinned.` }],
|
|
5333
|
-
details: result,
|
|
5334
|
-
};
|
|
5335
|
-
}
|
|
5336
|
-
if (result.success) {
|
|
5337
|
-
api.logger.info(`totalreclaw_pin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
|
|
5338
|
-
return {
|
|
5339
|
-
content: [{
|
|
5340
|
-
type: 'text',
|
|
5341
|
-
text: `Pinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
|
|
5342
|
-
}],
|
|
5343
|
-
details: result,
|
|
5344
|
-
};
|
|
5345
|
-
}
|
|
5346
|
-
api.logger.error(`totalreclaw_pin failed: ${result.error}`);
|
|
5347
|
-
return {
|
|
5348
|
-
content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
|
|
5349
|
-
details: result,
|
|
5350
|
-
};
|
|
5351
|
-
} catch (err: unknown) {
|
|
5352
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5353
|
-
api.logger.error(`totalreclaw_pin failed: ${message}`);
|
|
5354
|
-
return {
|
|
5355
|
-
content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(message)}` }],
|
|
5356
|
-
};
|
|
5357
|
-
}
|
|
5358
|
-
},
|
|
5359
|
-
},
|
|
5360
|
-
{ name: 'totalreclaw_pin' },
|
|
5361
|
-
);
|
|
5362
|
-
|
|
5363
|
-
// ---------------------------------------------------------------
|
|
5364
|
-
// Tool: totalreclaw_unpin
|
|
5365
|
-
// ---------------------------------------------------------------
|
|
5366
|
-
|
|
5367
|
-
api.registerTool(
|
|
5368
|
-
{
|
|
5369
|
-
name: 'totalreclaw_unpin',
|
|
5370
|
-
label: 'Unpin',
|
|
5371
|
-
description:
|
|
5372
|
-
'Remove the pin from a previously pinned memory, returning it to active status so the ' +
|
|
5373
|
-
'auto-resolution engine can supersede or retract it again. Takes fact_id. Idempotent — ' +
|
|
5374
|
-
'unpinning a non-pinned claim is a no-op.',
|
|
5375
|
-
parameters: {
|
|
5376
|
-
type: 'object',
|
|
5377
|
-
properties: {
|
|
5378
|
-
fact_id: {
|
|
5379
|
-
type: 'string',
|
|
5380
|
-
description: 'The ID of the fact to unpin (from a totalreclaw_recall result).',
|
|
5381
|
-
},
|
|
5382
|
-
},
|
|
5383
|
-
required: ['fact_id'],
|
|
5384
|
-
additionalProperties: false,
|
|
5385
|
-
},
|
|
5386
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
5387
|
-
try {
|
|
5388
|
-
await requireFullSetup(api.logger);
|
|
5389
|
-
if (!isSubgraphMode()) {
|
|
5390
|
-
return {
|
|
5391
|
-
content: [{
|
|
5392
|
-
type: 'text',
|
|
5393
|
-
text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
|
|
5394
|
-
}],
|
|
5395
|
-
};
|
|
5396
|
-
}
|
|
5397
|
-
const validation = validatePinArgs(params);
|
|
5398
|
-
if (!validation.ok) {
|
|
5399
|
-
return { content: [{ type: 'text', text: validation.error }] };
|
|
5400
|
-
}
|
|
5401
|
-
const deps = buildPinDeps();
|
|
5402
|
-
const result = await executePinOperation(validation.factId, 'active', deps);
|
|
5403
|
-
if (result.success && result.idempotent) {
|
|
5404
|
-
api.logger.info(`totalreclaw_unpin: ${result.fact_id} already active (no-op)`);
|
|
5405
|
-
return {
|
|
5406
|
-
content: [{ type: 'text', text: `Memory ${result.fact_id} is not pinned.` }],
|
|
5407
|
-
details: result,
|
|
5408
|
-
};
|
|
5409
|
-
}
|
|
5410
|
-
if (result.success) {
|
|
5411
|
-
api.logger.info(`totalreclaw_unpin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
|
|
5412
|
-
return {
|
|
5413
|
-
content: [{
|
|
5414
|
-
type: 'text',
|
|
5415
|
-
text: `Unpinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
|
|
5416
|
-
}],
|
|
5417
|
-
details: result,
|
|
5418
|
-
};
|
|
5419
|
-
}
|
|
5420
|
-
api.logger.error(`totalreclaw_unpin failed: ${result.error}`);
|
|
5421
|
-
return {
|
|
5422
|
-
content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
|
|
5423
|
-
details: result,
|
|
5424
|
-
};
|
|
5425
|
-
} catch (err: unknown) {
|
|
5426
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5427
|
-
api.logger.error(`totalreclaw_unpin failed: ${message}`);
|
|
5428
|
-
return {
|
|
5429
|
-
content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(message)}` }],
|
|
5430
|
-
};
|
|
5431
|
-
}
|
|
5432
|
-
},
|
|
5433
|
-
},
|
|
5434
|
-
{ name: 'totalreclaw_unpin' },
|
|
5435
|
-
);
|
|
5436
|
-
|
|
5437
|
-
// ---------------------------------------------------------------
|
|
5438
|
-
// Shared deps for retype + set_scope (same shape as pin deps).
|
|
5439
|
-
// Built lazily so the closure captures the current encryption key /
|
|
5440
|
-
// subgraph owner at call time rather than at register() time.
|
|
5441
|
-
// ---------------------------------------------------------------
|
|
5442
|
-
const buildRetypeSetScopeDeps = (): RetypeSetScopeDeps => {
|
|
5443
|
-
const owner = subgraphOwner || userId || '';
|
|
5444
|
-
const config = {
|
|
5445
|
-
...getSubgraphConfig(),
|
|
5446
|
-
authKeyHex: authKeyHex!,
|
|
5447
|
-
walletAddress: subgraphOwner ?? undefined,
|
|
5448
|
-
};
|
|
5449
|
-
return {
|
|
5450
|
-
owner,
|
|
5451
|
-
sourceAgent: 'openclaw-plugin',
|
|
5452
|
-
fetchFactById: (factId: string) => fetchFactById(owner, factId, authKeyHex!),
|
|
5453
|
-
decryptBlob: (hex: string) => decryptFromHex(hex, encryptionKey!),
|
|
5454
|
-
encryptBlob: (plaintext: string) => encryptToHex(plaintext, encryptionKey!),
|
|
5455
|
-
submitBatch: async (payloads: Buffer[]) => {
|
|
5456
|
-
const result = await submitFactBatchOnChain(payloads, config);
|
|
5457
|
-
return { txHash: result.txHash, success: result.success };
|
|
5458
|
-
},
|
|
5459
|
-
generateIndices: async (text: string, entityNames: string[]) => {
|
|
5460
|
-
if (!text) return { blindIndices: [] };
|
|
5461
|
-
const wordIndices = generateBlindIndices(text);
|
|
5462
|
-
let lshIndices: string[] = [];
|
|
5463
|
-
let encryptedEmbedding: string | undefined;
|
|
5464
|
-
try {
|
|
5465
|
-
const embedding = await generateEmbedding(text);
|
|
5466
|
-
const hasher = getLSHHasher(api.logger);
|
|
5467
|
-
if (hasher) lshIndices = hasher.hash(embedding);
|
|
5468
|
-
encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey!);
|
|
5469
|
-
} catch {
|
|
5470
|
-
// Best-effort: word + entity trapdoors alone still surface the claim.
|
|
5471
|
-
}
|
|
5472
|
-
const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
|
|
5473
|
-
return {
|
|
5474
|
-
blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
|
|
5475
|
-
encryptedEmbedding,
|
|
5476
|
-
};
|
|
5477
|
-
},
|
|
5478
|
-
};
|
|
5479
|
-
};
|
|
5480
|
-
|
|
5481
|
-
// ---------------------------------------------------------------
|
|
5482
|
-
// Tool: totalreclaw_retype (3.3.1-rc.2 — agent-facing taxonomy edit)
|
|
5483
|
-
// ---------------------------------------------------------------
|
|
5484
|
-
|
|
5485
|
-
api.registerTool(
|
|
5486
|
-
{
|
|
5487
|
-
name: 'totalreclaw_retype',
|
|
5488
|
-
label: 'Retype',
|
|
5489
|
-
description:
|
|
5490
|
-
'Reclassify an existing memory from one taxonomy type to another (claim / preference / ' +
|
|
5491
|
-
'directive / commitment / episode / summary). Use when the user corrects a memory\'s ' +
|
|
5492
|
-
'category — e.g. "that\'s actually a preference, not a fact" or "file this as a ' +
|
|
5493
|
-
'commitment, not a claim". Writes a new v1.1 blob with the updated type and tombstones ' +
|
|
5494
|
-
'the old fact on-chain.\n\n' +
|
|
5495
|
-
'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
|
|
5496
|
-
'find the fact_id, then pass it here with the new type.',
|
|
5497
|
-
parameters: {
|
|
5498
|
-
type: 'object',
|
|
5499
|
-
properties: {
|
|
5500
|
-
fact_id: {
|
|
5501
|
-
type: 'string',
|
|
5502
|
-
description:
|
|
5503
|
-
'The UUID of the memory to reclassify. Get this from a prior ' +
|
|
5504
|
-
'`totalreclaw_recall` result.',
|
|
5505
|
-
},
|
|
5506
|
-
new_type: {
|
|
5507
|
-
type: 'string',
|
|
5508
|
-
enum: ['claim', 'preference', 'directive', 'commitment', 'episode', 'summary'],
|
|
5509
|
-
description:
|
|
5510
|
-
'The new taxonomy type. claim=factual statement, preference=opinion/like/dislike, ' +
|
|
5511
|
-
'directive=instruction, commitment=promise/plan, episode=event, summary=aggregate.',
|
|
5512
|
-
},
|
|
5513
|
-
},
|
|
5514
|
-
required: ['fact_id', 'new_type'],
|
|
5515
|
-
additionalProperties: false,
|
|
5516
|
-
},
|
|
5517
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
5518
|
-
try {
|
|
5519
|
-
await requireFullSetup(api.logger);
|
|
5520
|
-
if (!isSubgraphMode()) {
|
|
5521
|
-
return {
|
|
5522
|
-
content: [{
|
|
5523
|
-
type: 'text',
|
|
5524
|
-
text: 'Retype is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
|
|
5525
|
-
}],
|
|
5526
|
-
};
|
|
5527
|
-
}
|
|
5528
|
-
const validation = validateRetypeArgs(params);
|
|
5529
|
-
if (!validation.ok) {
|
|
5530
|
-
return { content: [{ type: 'text', text: validation.error }] };
|
|
5531
|
-
}
|
|
5532
|
-
const deps = buildRetypeSetScopeDeps();
|
|
5533
|
-
const result = await executeRetype(validation.factId, validation.newType, deps);
|
|
5534
|
-
if (result.success) {
|
|
5535
|
-
api.logger.info(
|
|
5536
|
-
`totalreclaw_retype: ${result.fact_id} (${result.previous_type} → ${result.new_type}) → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`,
|
|
5537
|
-
);
|
|
5538
|
-
return {
|
|
5539
|
-
content: [{
|
|
5540
|
-
type: 'text',
|
|
5541
|
-
text:
|
|
5542
|
-
`Retyped memory ${result.fact_id} from ${result.previous_type} to ${result.new_type}. ` +
|
|
5543
|
-
`New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
|
|
5544
|
-
}],
|
|
5545
|
-
details: result,
|
|
5546
|
-
};
|
|
5547
|
-
}
|
|
5548
|
-
api.logger.error(`totalreclaw_retype failed: ${result.error}`);
|
|
5549
|
-
return {
|
|
5550
|
-
content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(result.error ?? 'unknown error')}` }],
|
|
5551
|
-
details: result,
|
|
5552
|
-
};
|
|
5553
|
-
} catch (err: unknown) {
|
|
5554
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5555
|
-
api.logger.error(`totalreclaw_retype failed: ${message}`);
|
|
5556
|
-
return {
|
|
5557
|
-
content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(message)}` }],
|
|
5558
|
-
};
|
|
5559
|
-
}
|
|
5560
|
-
},
|
|
5561
|
-
},
|
|
5562
|
-
{ name: 'totalreclaw_retype' },
|
|
5563
|
-
);
|
|
5564
|
-
|
|
5565
|
-
// ---------------------------------------------------------------
|
|
5566
|
-
// Tool: totalreclaw_set_scope (3.3.1-rc.2 — agent-facing scope edit)
|
|
5567
|
-
// ---------------------------------------------------------------
|
|
5568
|
-
|
|
5569
|
-
api.registerTool(
|
|
5570
|
-
{
|
|
5571
|
-
name: 'totalreclaw_set_scope',
|
|
5572
|
-
label: 'Set Scope',
|
|
5573
|
-
description:
|
|
5574
|
-
'Move an existing memory to a different scope (work / personal / health / family / ' +
|
|
5575
|
-
'creative / finance / misc / unspecified). Use when the user re-categorizes a memory\'s ' +
|
|
5576
|
-
'domain — e.g. "put that under work", "this is a health thing", "move this to personal". ' +
|
|
5577
|
-
'Writes a new v1.1 blob with the updated scope and tombstones the old fact on-chain.\n\n' +
|
|
5578
|
-
'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
|
|
5579
|
-
'find the fact_id, then pass it here with the new scope.',
|
|
5580
|
-
parameters: {
|
|
5581
|
-
type: 'object',
|
|
5582
|
-
properties: {
|
|
5583
|
-
fact_id: {
|
|
5584
|
-
type: 'string',
|
|
5585
|
-
description:
|
|
5586
|
-
'The UUID of the memory to rescope. Get this from a prior `totalreclaw_recall` result.',
|
|
5587
|
-
},
|
|
5588
|
-
new_scope: {
|
|
5589
|
-
type: 'string',
|
|
5590
|
-
enum: ['work', 'personal', 'health', 'family', 'creative', 'finance', 'misc', 'unspecified'],
|
|
5591
|
-
description:
|
|
5592
|
-
'The new scope. Used for filtered recall — e.g. "recall work-related memories only".',
|
|
5593
|
-
},
|
|
5594
|
-
},
|
|
5595
|
-
required: ['fact_id', 'new_scope'],
|
|
5596
|
-
additionalProperties: false,
|
|
5597
|
-
},
|
|
5598
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
5599
|
-
try {
|
|
5600
|
-
await requireFullSetup(api.logger);
|
|
5601
|
-
if (!isSubgraphMode()) {
|
|
5602
|
-
return {
|
|
5603
|
-
content: [{
|
|
5604
|
-
type: 'text',
|
|
5605
|
-
text: 'Set-scope is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
|
|
5606
|
-
}],
|
|
5607
|
-
};
|
|
5608
|
-
}
|
|
5609
|
-
const validation = validateSetScopeArgs(params);
|
|
5610
|
-
if (!validation.ok) {
|
|
5611
|
-
return { content: [{ type: 'text', text: validation.error }] };
|
|
5612
|
-
}
|
|
5613
|
-
const deps = buildRetypeSetScopeDeps();
|
|
5614
|
-
const result = await executeSetScope(validation.factId, validation.newScope, deps);
|
|
5615
|
-
if (result.success) {
|
|
5616
|
-
api.logger.info(
|
|
5617
|
-
`totalreclaw_set_scope: ${result.fact_id} (${result.previous_scope ?? 'unspecified'} → ${result.new_scope}) → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`,
|
|
5618
|
-
);
|
|
5619
|
-
return {
|
|
5620
|
-
content: [{
|
|
5621
|
-
type: 'text',
|
|
5622
|
-
text:
|
|
5623
|
-
`Moved memory ${result.fact_id} from scope "${result.previous_scope ?? 'unspecified'}" to "${result.new_scope}". ` +
|
|
5624
|
-
`New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
|
|
5625
|
-
}],
|
|
5626
|
-
details: result,
|
|
5627
|
-
};
|
|
5628
|
-
}
|
|
5629
|
-
api.logger.error(`totalreclaw_set_scope failed: ${result.error}`);
|
|
5630
|
-
return {
|
|
5631
|
-
content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(result.error ?? 'unknown error')}` }],
|
|
5632
|
-
details: result,
|
|
5633
|
-
};
|
|
5634
|
-
} catch (err: unknown) {
|
|
5635
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5636
|
-
api.logger.error(`totalreclaw_set_scope failed: ${message}`);
|
|
5637
|
-
return {
|
|
5638
|
-
content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(message)}` }],
|
|
5639
|
-
};
|
|
5640
|
-
}
|
|
5641
|
-
},
|
|
5642
|
-
},
|
|
5643
|
-
{ name: 'totalreclaw_set_scope' },
|
|
5644
|
-
);
|
|
5645
|
-
|
|
5646
|
-
// ---------------------------------------------------------------
|
|
5647
|
-
// Tool: totalreclaw_import_from
|
|
5648
|
-
// ---------------------------------------------------------------
|
|
5649
|
-
|
|
5650
|
-
api.registerTool(
|
|
5651
|
-
{
|
|
5652
|
-
name: 'totalreclaw_import_from',
|
|
5653
|
-
label: 'Import From',
|
|
5654
|
-
description:
|
|
5655
|
-
'Import memories from other AI memory tools (Mem0, MCP Memory Server, ChatGPT, Claude, Gemini, MemoClaw, or generic JSON/CSV). ' +
|
|
5656
|
-
'Provide the source name and either an API key, file content, or file path. ' +
|
|
5657
|
-
'Use dry_run=true to preview before importing. Idempotent — safe to run multiple times.',
|
|
5658
|
-
parameters: {
|
|
5659
|
-
type: 'object',
|
|
5660
|
-
properties: {
|
|
5661
|
-
source: {
|
|
5662
|
-
type: 'string',
|
|
5663
|
-
enum: ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'],
|
|
5664
|
-
description: 'The source system to import from (gemini: Google Takeout HTML; chatgpt: conversations.json or memory text; claude: memory text)',
|
|
5665
|
-
},
|
|
5666
|
-
api_key: {
|
|
5667
|
-
type: 'string',
|
|
5668
|
-
description: 'API key for the source system (used once, never stored)',
|
|
5669
|
-
},
|
|
5670
|
-
source_user_id: {
|
|
5671
|
-
type: 'string',
|
|
5672
|
-
description: 'User or agent ID in the source system',
|
|
5673
|
-
},
|
|
5674
|
-
content: {
|
|
5675
|
-
type: 'string',
|
|
5676
|
-
description: 'File content (JSON, JSONL, or CSV)',
|
|
5677
|
-
},
|
|
5678
|
-
file_path: {
|
|
5679
|
-
type: 'string',
|
|
5680
|
-
description: 'Path to the file on disk',
|
|
5681
|
-
},
|
|
5682
|
-
namespace: {
|
|
5683
|
-
type: 'string',
|
|
5684
|
-
description: 'Target namespace (default: "imported")',
|
|
5685
|
-
},
|
|
5686
|
-
dry_run: {
|
|
5687
|
-
type: 'boolean',
|
|
5688
|
-
description: 'Preview without importing',
|
|
5689
|
-
},
|
|
5690
|
-
},
|
|
5691
|
-
required: ['source'],
|
|
5692
|
-
},
|
|
5693
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
5694
|
-
try {
|
|
5695
|
-
await requireFullSetup(api.logger);
|
|
5696
|
-
return handlePluginImportFrom(params, api.logger);
|
|
5697
|
-
} catch (err: unknown) {
|
|
5698
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5699
|
-
return { error: message };
|
|
5700
|
-
}
|
|
5701
|
-
},
|
|
5702
|
-
},
|
|
5703
|
-
{ name: 'totalreclaw_import_from' },
|
|
5704
|
-
);
|
|
5705
|
-
|
|
5706
|
-
// ---------------------------------------------------------------
|
|
5707
|
-
// Tool: totalreclaw_import_batch
|
|
5708
|
-
// ---------------------------------------------------------------
|
|
5709
|
-
|
|
5710
|
-
api.registerTool(
|
|
5711
|
-
{
|
|
5712
|
-
name: 'totalreclaw_import_batch',
|
|
5713
|
-
label: 'Import Batch',
|
|
5714
|
-
description:
|
|
5715
|
-
'Process one batch of a large import. Call repeatedly with increasing offset until is_complete=true.',
|
|
5716
|
-
parameters: {
|
|
5717
|
-
type: 'object',
|
|
5718
|
-
properties: {
|
|
5719
|
-
source: {
|
|
5720
|
-
type: 'string',
|
|
5721
|
-
enum: ['gemini', 'chatgpt', 'claude'],
|
|
5722
|
-
description: 'Source format',
|
|
5723
|
-
},
|
|
5724
|
-
file_path: {
|
|
5725
|
-
type: 'string',
|
|
5726
|
-
description: 'Path to source file',
|
|
5727
|
-
},
|
|
5728
|
-
content: {
|
|
5729
|
-
type: 'string',
|
|
5730
|
-
description: 'File content (text sources)',
|
|
5731
|
-
},
|
|
5732
|
-
offset: {
|
|
5733
|
-
type: 'number',
|
|
5734
|
-
description: 'Starting chunk index (0-based)',
|
|
5735
|
-
},
|
|
5736
|
-
batch_size: {
|
|
5737
|
-
type: 'number',
|
|
5738
|
-
description: 'Chunks per call (default 25)',
|
|
5739
|
-
},
|
|
5740
|
-
},
|
|
5741
|
-
required: ['source'],
|
|
5742
|
-
},
|
|
5743
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
5744
|
-
try {
|
|
5745
|
-
await requireFullSetup(api.logger);
|
|
5746
|
-
return handleBatchImport(params, api.logger);
|
|
5747
|
-
} catch (err: unknown) {
|
|
5748
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5749
|
-
return { error: message };
|
|
5750
|
-
}
|
|
5751
|
-
},
|
|
5752
|
-
},
|
|
5753
|
-
{ name: 'totalreclaw_import_batch' },
|
|
5754
|
-
);
|
|
5755
|
-
|
|
5756
|
-
// ---------------------------------------------------------------
|
|
5757
|
-
// Tool: totalreclaw_import_status
|
|
5758
|
-
// ---------------------------------------------------------------
|
|
5759
|
-
|
|
5760
|
-
api.registerTool(
|
|
5761
|
-
{
|
|
5762
|
-
name: 'totalreclaw_import_status',
|
|
5763
|
-
label: 'Import Status',
|
|
5764
|
-
description:
|
|
5765
|
-
'Check the progress of a background import. ' +
|
|
5766
|
-
'If import_id is omitted, returns the most recent active import. ' +
|
|
5767
|
-
'Returns status (running/completed/failed/aborted), batch progress, facts stored, and ETA. ' +
|
|
5768
|
-
'Use this when the user asks "how\'s the import?" or "is it done yet?".',
|
|
5769
|
-
parameters: {
|
|
5770
|
-
type: 'object',
|
|
5771
|
-
properties: {
|
|
5772
|
-
import_id: {
|
|
5773
|
-
type: 'string',
|
|
5774
|
-
description: 'The import ID returned by totalreclaw_import_from. Omit for most recent active import.',
|
|
5775
|
-
},
|
|
5776
|
-
},
|
|
5777
|
-
additionalProperties: false,
|
|
5778
|
-
},
|
|
5779
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
5780
|
-
try {
|
|
5781
|
-
await requireFullSetup(api.logger);
|
|
5782
|
-
return handleImportStatus(params, api.logger);
|
|
5783
|
-
} catch (err: unknown) {
|
|
5784
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5785
|
-
return { error: message };
|
|
5786
|
-
}
|
|
5787
|
-
},
|
|
5788
|
-
},
|
|
5789
|
-
{ name: 'totalreclaw_import_status' },
|
|
5790
|
-
);
|
|
5791
|
-
|
|
5792
|
-
// ---------------------------------------------------------------
|
|
5793
|
-
// Tool: totalreclaw_import_abort
|
|
5794
|
-
// ---------------------------------------------------------------
|
|
5795
|
-
|
|
5796
|
-
api.registerTool(
|
|
5797
|
-
{
|
|
5798
|
-
name: 'totalreclaw_import_abort',
|
|
5799
|
-
label: 'Abort Import',
|
|
5800
|
-
description:
|
|
5801
|
-
'Cancel a running background import. Already-stored facts are kept (import is idempotent via fingerprint dedup). ' +
|
|
5802
|
-
'The background task will stop at the next chunk boundary after receiving the abort signal. ' +
|
|
5803
|
-
'Use when the user says "stop the import" or "cancel the import".',
|
|
5804
|
-
parameters: {
|
|
5805
|
-
type: 'object',
|
|
5806
|
-
properties: {
|
|
5807
|
-
import_id: {
|
|
5808
|
-
type: 'string',
|
|
5809
|
-
description: 'The import ID to abort (from totalreclaw_import_from or totalreclaw_import_status).',
|
|
5810
|
-
},
|
|
5811
|
-
},
|
|
5812
|
-
required: ['import_id'],
|
|
5813
|
-
additionalProperties: false,
|
|
5814
|
-
},
|
|
5815
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
5816
|
-
try {
|
|
5817
|
-
await requireFullSetup(api.logger);
|
|
5818
|
-
return handleImportAbort(params, api.logger);
|
|
5819
|
-
} catch (err: unknown) {
|
|
5820
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5821
|
-
return { error: message };
|
|
5822
|
-
}
|
|
5823
|
-
},
|
|
5824
|
-
},
|
|
5825
|
-
{ name: 'totalreclaw_import_abort' },
|
|
5826
|
-
);
|
|
5827
|
-
|
|
5828
|
-
// ---------------------------------------------------------------
|
|
5829
|
-
// Tool: totalreclaw_upgrade
|
|
5830
|
-
// ---------------------------------------------------------------
|
|
5831
|
-
|
|
5832
|
-
api.registerTool(
|
|
5833
|
-
{
|
|
5834
|
-
name: 'totalreclaw_upgrade',
|
|
5835
|
-
label: 'Upgrade to Pro',
|
|
5836
|
-
description:
|
|
5837
|
-
'Upgrade to TotalReclaw Pro for unlimited encrypted memories. ' +
|
|
5838
|
-
'Returns a Stripe checkout URL for the user to complete payment via credit/debit card.',
|
|
5839
|
-
parameters: {
|
|
5840
|
-
type: 'object',
|
|
5841
|
-
properties: {},
|
|
5842
|
-
additionalProperties: false,
|
|
5843
|
-
},
|
|
5844
|
-
async execute() {
|
|
5845
|
-
try {
|
|
5846
|
-
await requireFullSetup(api.logger);
|
|
5847
|
-
|
|
5848
|
-
if (!authKeyHex) {
|
|
5849
|
-
return {
|
|
5850
|
-
content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
|
|
5851
|
-
};
|
|
5852
|
-
}
|
|
5853
|
-
|
|
5854
|
-
const serverUrl = CONFIG.serverUrl;
|
|
5855
|
-
const walletAddr = subgraphOwner || userId || '';
|
|
5856
|
-
|
|
5857
|
-
if (!walletAddr) {
|
|
5858
|
-
return {
|
|
5859
|
-
content: [{ type: 'text', text: 'Wallet address not available. Please ensure the plugin is fully initialized.' }],
|
|
5860
|
-
};
|
|
5861
|
-
}
|
|
5862
|
-
|
|
5863
|
-
const response = await fetch(`${serverUrl}/v1/billing/checkout`, {
|
|
5864
|
-
method: 'POST',
|
|
5865
|
-
headers: buildRelayHeaders({
|
|
5866
|
-
'Authorization': `Bearer ${authKeyHex}`,
|
|
5867
|
-
'Content-Type': 'application/json',
|
|
5868
|
-
}),
|
|
5869
|
-
body: JSON.stringify({
|
|
5870
|
-
wallet_address: walletAddr,
|
|
5871
|
-
tier: 'pro',
|
|
5872
|
-
}),
|
|
5873
|
-
});
|
|
5874
|
-
|
|
5875
|
-
if (!response.ok) {
|
|
5876
|
-
const body = await response.text().catch(() => '');
|
|
5877
|
-
return {
|
|
5878
|
-
content: [{ type: 'text', text: `Failed to create checkout session (HTTP ${response.status}): ${body || response.statusText}` }],
|
|
5879
|
-
};
|
|
5880
|
-
}
|
|
5881
|
-
|
|
5882
|
-
const data = await response.json() as { checkout_url?: string };
|
|
5883
|
-
|
|
5884
|
-
if (!data.checkout_url) {
|
|
5885
|
-
return {
|
|
5886
|
-
content: [{ type: 'text', text: 'Failed to create checkout session: no checkout URL returned.' }],
|
|
5887
|
-
};
|
|
5888
|
-
}
|
|
5889
|
-
|
|
5890
|
-
return {
|
|
5891
|
-
content: [{ type: 'text', text: `Open this URL to upgrade to Pro: ${data.checkout_url}` }],
|
|
5892
|
-
details: { checkout_url: data.checkout_url },
|
|
5893
|
-
};
|
|
5894
|
-
} catch (err: unknown) {
|
|
5895
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5896
|
-
api.logger.error(`totalreclaw_upgrade failed: ${message}`);
|
|
5897
|
-
return {
|
|
5898
|
-
content: [{ type: 'text', text: `Failed to create checkout session: ${humanizeError(message)}` }],
|
|
5899
|
-
};
|
|
5900
|
-
}
|
|
5901
|
-
},
|
|
5902
|
-
},
|
|
5903
|
-
{ name: 'totalreclaw_upgrade' },
|
|
5904
|
-
);
|
|
5905
|
-
|
|
5906
|
-
// ---------------------------------------------------------------
|
|
5907
|
-
// Tool: totalreclaw_migrate
|
|
5908
|
-
// ---------------------------------------------------------------
|
|
5909
|
-
|
|
5910
|
-
api.registerTool(
|
|
5911
|
-
{
|
|
5912
|
-
name: 'totalreclaw_migrate',
|
|
5913
|
-
label: 'Migrate Testnet to Mainnet',
|
|
5914
|
-
description:
|
|
5915
|
-
'Migrate memories from testnet (Base Sepolia) to mainnet (Gnosis) after upgrading to Pro. ' +
|
|
5916
|
-
'Dry-run by default — set confirm=true to execute. Idempotent: re-running skips already-migrated facts.',
|
|
5917
|
-
parameters: {
|
|
5918
|
-
type: 'object',
|
|
5919
|
-
properties: {
|
|
5920
|
-
confirm: {
|
|
5921
|
-
type: 'boolean',
|
|
5922
|
-
description: 'Set to true to execute the migration. Without it, returns a dry-run preview.',
|
|
5923
|
-
default: false,
|
|
5924
|
-
},
|
|
5925
|
-
},
|
|
5926
|
-
additionalProperties: false,
|
|
5927
|
-
},
|
|
5928
|
-
async execute(_params: { confirm?: boolean }) {
|
|
5929
|
-
try {
|
|
5930
|
-
await requireFullSetup(api.logger);
|
|
5931
|
-
|
|
5932
|
-
if (!authKeyHex || !subgraphOwner) {
|
|
5933
|
-
return {
|
|
5934
|
-
content: [{ type: 'text', text: 'Plugin not fully initialized. Ensure TOTALRECLAW_RECOVERY_PHRASE is set.' }],
|
|
5935
|
-
};
|
|
5936
|
-
}
|
|
5937
|
-
|
|
5938
|
-
if (!isSubgraphMode()) {
|
|
5939
|
-
return {
|
|
5940
|
-
content: [{ type: 'text', text: 'Migration is only available with the managed service (subgraph mode).' }],
|
|
5941
|
-
};
|
|
5942
|
-
}
|
|
5943
|
-
|
|
5944
|
-
const confirm = _params?.confirm === true;
|
|
5945
|
-
const serverUrl = CONFIG.serverUrl;
|
|
5946
|
-
|
|
5947
|
-
// 1. Check billing tier
|
|
5948
|
-
const billingResp = await fetch(
|
|
5949
|
-
`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(subgraphOwner)}`,
|
|
5950
|
-
{
|
|
5951
|
-
method: 'GET',
|
|
5952
|
-
headers: buildRelayHeaders({
|
|
5953
|
-
'Authorization': `Bearer ${authKeyHex}`,
|
|
5954
|
-
'Content-Type': 'application/json',
|
|
5955
|
-
}),
|
|
5956
|
-
},
|
|
5957
|
-
);
|
|
5958
|
-
if (!billingResp.ok) {
|
|
5959
|
-
return { content: [{ type: 'text', text: `Failed to check billing tier (HTTP ${billingResp.status}).` }] };
|
|
5960
|
-
}
|
|
5961
|
-
const billingData = await billingResp.json() as { tier: string };
|
|
5962
|
-
if (billingData.tier !== 'pro') {
|
|
5963
|
-
return {
|
|
5964
|
-
content: [{ type: 'text', text: 'Migration requires Pro tier. Use totalreclaw_upgrade to upgrade first.' }],
|
|
5965
|
-
};
|
|
5966
|
-
}
|
|
5967
|
-
|
|
5968
|
-
// 2. Fetch testnet facts via relay (chain=testnet query param)
|
|
5969
|
-
const testnetSubgraphUrl = `${serverUrl}/v1/subgraph?chain=testnet`;
|
|
5970
|
-
const mainnetSubgraphUrl = `${serverUrl}/v1/subgraph`;
|
|
5971
|
-
|
|
5972
|
-
api.logger.info('Fetching testnet facts...');
|
|
5973
|
-
const testnetFacts = await fetchAllFactsByOwner(testnetSubgraphUrl, subgraphOwner, authKeyHex);
|
|
5974
|
-
|
|
5975
|
-
if (testnetFacts.length === 0) {
|
|
5976
|
-
return {
|
|
5977
|
-
content: [{ type: 'text', text: 'No facts found on testnet. Nothing to migrate.' }],
|
|
5978
|
-
};
|
|
5979
|
-
}
|
|
5980
|
-
|
|
5981
|
-
// 3. Check mainnet for existing facts (idempotency)
|
|
5982
|
-
api.logger.info('Checking mainnet for existing facts...');
|
|
5983
|
-
const mainnetFps = await fetchContentFingerprintsByOwner(mainnetSubgraphUrl, subgraphOwner, authKeyHex);
|
|
5984
|
-
const factsToMigrate = testnetFacts.filter(f => !f.contentFp || !mainnetFps.has(f.contentFp));
|
|
5985
|
-
const alreadyOnMainnet = testnetFacts.length - factsToMigrate.length;
|
|
5986
|
-
|
|
5987
|
-
// 4. Dry-run
|
|
5988
|
-
if (!confirm) {
|
|
5989
|
-
const msg = factsToMigrate.length === 0
|
|
5990
|
-
? `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.`
|
|
5991
|
-
: `Found ${factsToMigrate.length} facts to migrate from testnet to Gnosis mainnet (${alreadyOnMainnet} already on mainnet). Call with confirm=true to proceed.`;
|
|
5992
|
-
return {
|
|
5993
|
-
content: [{ type: 'text', text: msg }],
|
|
5994
|
-
details: {
|
|
5995
|
-
mode: 'dry_run',
|
|
5996
|
-
testnet_facts: testnetFacts.length,
|
|
5997
|
-
already_on_mainnet: alreadyOnMainnet,
|
|
5998
|
-
to_migrate: factsToMigrate.length,
|
|
5999
|
-
},
|
|
6000
|
-
};
|
|
6001
|
-
}
|
|
6002
|
-
|
|
6003
|
-
// 5. Execute migration
|
|
6004
|
-
if (factsToMigrate.length === 0) {
|
|
6005
|
-
return {
|
|
6006
|
-
content: [{ type: 'text', text: `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.` }],
|
|
6007
|
-
};
|
|
6008
|
-
}
|
|
6009
|
-
|
|
6010
|
-
// Fetch blind indices
|
|
6011
|
-
api.logger.info(`Fetching blind indices for ${factsToMigrate.length} facts...`);
|
|
6012
|
-
const factIds = factsToMigrate.map(f => f.id);
|
|
6013
|
-
const blindIndicesMap = await fetchBlindIndicesByFactIds(testnetSubgraphUrl, factIds, authKeyHex);
|
|
6014
|
-
|
|
6015
|
-
// Build protobuf payloads
|
|
6016
|
-
const payloads: Buffer[] = [];
|
|
6017
|
-
for (const fact of factsToMigrate) {
|
|
6018
|
-
const blobHex = fact.encryptedBlob.startsWith('0x') ? fact.encryptedBlob.slice(2) : fact.encryptedBlob;
|
|
6019
|
-
const indices = blindIndicesMap.get(fact.id) || [];
|
|
6020
|
-
const factPayload: FactPayload = {
|
|
6021
|
-
id: fact.id,
|
|
6022
|
-
timestamp: new Date().toISOString(),
|
|
6023
|
-
owner: subgraphOwner,
|
|
6024
|
-
encryptedBlob: blobHex,
|
|
6025
|
-
blindIndices: indices,
|
|
6026
|
-
decayScore: parseFloat(fact.decayScore) || 0.5,
|
|
6027
|
-
source: fact.source || 'migration',
|
|
6028
|
-
contentFp: fact.contentFp || '',
|
|
6029
|
-
agentId: fact.agentId || 'openclaw-plugin',
|
|
6030
|
-
encryptedEmbedding: fact.encryptedEmbedding || undefined,
|
|
6031
|
-
version: PROTOBUF_VERSION_V4,
|
|
6032
|
-
};
|
|
6033
|
-
payloads.push(encodeFactProtobuf(factPayload));
|
|
6034
|
-
}
|
|
6035
|
-
|
|
6036
|
-
// Batch submit (15 per UserOp)
|
|
6037
|
-
const BATCH_SIZE = 15;
|
|
6038
|
-
const batchConfig = { ...getSubgraphConfig(), authKeyHex: authKeyHex!, walletAddress: subgraphOwner ?? undefined };
|
|
6039
|
-
let migrated = 0;
|
|
6040
|
-
let failedBatches = 0;
|
|
6041
|
-
|
|
6042
|
-
for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
|
|
6043
|
-
const batch = payloads.slice(i, i + BATCH_SIZE);
|
|
6044
|
-
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
|
6045
|
-
const totalBatches = Math.ceil(payloads.length / BATCH_SIZE);
|
|
6046
|
-
api.logger.info(`Migrating batch ${batchNum}/${totalBatches} (${batch.length} facts)...`);
|
|
6047
|
-
|
|
6048
|
-
try {
|
|
6049
|
-
const result = await submitFactBatchOnChain(batch, batchConfig);
|
|
6050
|
-
if (result.success) {
|
|
6051
|
-
migrated += batch.length;
|
|
6052
|
-
} else {
|
|
6053
|
-
failedBatches++;
|
|
6054
|
-
}
|
|
6055
|
-
} catch (err: unknown) {
|
|
6056
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
6057
|
-
api.logger.error(`Migration batch ${batchNum} failed: ${msg}`);
|
|
6058
|
-
failedBatches++;
|
|
6059
|
-
}
|
|
6060
|
-
}
|
|
6061
|
-
|
|
6062
|
-
const resultMsg = failedBatches === 0
|
|
6063
|
-
? `Successfully migrated ${migrated} memories from testnet to Gnosis mainnet.`
|
|
6064
|
-
: `Migrated ${migrated}/${factsToMigrate.length} memories. ${failedBatches} batch(es) failed — re-run to retry (idempotent).`;
|
|
6065
|
-
|
|
6066
|
-
return {
|
|
6067
|
-
content: [{ type: 'text', text: resultMsg }],
|
|
6068
|
-
details: {
|
|
6069
|
-
mode: 'executed',
|
|
6070
|
-
testnet_facts: testnetFacts.length,
|
|
6071
|
-
already_on_mainnet: alreadyOnMainnet,
|
|
6072
|
-
to_migrate: factsToMigrate.length,
|
|
6073
|
-
migrated,
|
|
6074
|
-
failed_batches: failedBatches,
|
|
6075
|
-
},
|
|
6076
|
-
};
|
|
6077
|
-
} catch (err: unknown) {
|
|
6078
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
6079
|
-
api.logger.error(`totalreclaw_migrate failed: ${message}`);
|
|
6080
|
-
return {
|
|
6081
|
-
content: [{ type: 'text', text: `Migration failed: ${humanizeError(message)}` }],
|
|
6082
|
-
};
|
|
6083
|
-
}
|
|
6084
|
-
},
|
|
6085
|
-
},
|
|
6086
|
-
{ name: 'totalreclaw_migrate' },
|
|
6087
|
-
);
|
|
6088
|
-
|
|
6089
|
-
// ---------------------------------------------------------------
|
|
6090
|
-
// Tools: totalreclaw_setup + totalreclaw_onboarding_start —
|
|
6091
|
-
// REMOVED in 3.3.1-rc.5 (phrase-safety carve-out closure).
|
|
6092
|
-
// ---------------------------------------------------------------
|
|
6093
|
-
//
|
|
6094
|
-
// rc.4 left these two registrations in place as *neutered* stubs —
|
|
6095
|
-
// ``totalreclaw_setup`` rejected any ``recovery_phrase`` argument
|
|
6096
|
-
// and returned a CLI-pointer message; ``totalreclaw_onboarding_start``
|
|
6097
|
-
// was already pointer-only. Neither path could leak a phrase in
|
|
6098
|
-
// rc.4, but the rc.4 auto-QA (2026-04-22) flagged them as future-
|
|
6099
|
-
// regression surface: any future patch that re-enables phrase
|
|
6100
|
-
// acceptance (e.g. a flag-driven "power-user" path) would silently
|
|
6101
|
-
// re-open the leak, and their mere presence in the tool registry
|
|
6102
|
-
// keeps signalling to agents that "phrase handling happens here".
|
|
6103
|
-
//
|
|
6104
|
-
// Per ``project_phrase_safety_rule.md`` the ONLY approved agent-
|
|
6105
|
-
// facilitated setup surface is ``totalreclaw_pair`` (browser-side
|
|
6106
|
-
// crypto keeps the phrase out of the LLM round-trip by construction).
|
|
6107
|
-
// rc.5 deletes both registrations outright. The underlying CLI
|
|
6108
|
-
// wizard (``openclaw totalreclaw onboard``) is unchanged — users
|
|
6109
|
-
// run it in their own terminal, outside any agent shell.
|
|
6110
|
-
//
|
|
6111
|
-
// Audit assertion: ``phrase-safety-registry.test.ts`` asserts
|
|
6112
|
-
// neither name is present in the ``api.registerTool`` call list.
|
|
6113
|
-
// Re-adding either fails CI.
|
|
6114
|
-
//
|
|
6115
|
-
// Historical tombstone (so LLM-assisted contributors don't re-add
|
|
6116
|
-
// the former shape from training-data memory): rc.4 registered two
|
|
6117
|
-
// tools by the names "totalreclaw_setup" and
|
|
6118
|
-
// "totalreclaw_onboarding_start" as pointer-only stubs. Both were
|
|
6119
|
-
// deleted in rc.5. Do not re-introduce.
|
|
6120
|
-
|
|
6121
|
-
// ---------------------------------------------------------------
|
|
6122
|
-
// Tool: totalreclaw_onboard — REMOVED in 3.3.1-rc.4 (phrase-safety).
|
|
6123
|
-
//
|
|
6124
|
-
// rc.3 shipped a `totalreclaw_onboard` agent tool that generated a
|
|
6125
|
-
// fresh BIP-39 mnemonic in-process, wrote it to credentials.json,
|
|
6126
|
-
// and returned `{scope_address, credentials_path}` to the agent.
|
|
6127
|
-
// `emitPhrase: false` kept the mnemonic OUT of the tool's return
|
|
6128
|
-
// payload, but NOTHING ARCHITECTURALLY PREVENTED leakage — a future
|
|
6129
|
-
// patch could regress the flag, a different code path could echo
|
|
6130
|
-
// the mnemonic in a log/error message the agent captures, or the
|
|
6131
|
-
// mere existence of the tool implied to agents that "generating a
|
|
6132
|
-
// phrase here is fine" (it isn't).
|
|
6133
|
-
//
|
|
6134
|
-
// Per ``project_phrase_safety_rule.md``
|
|
6135
|
-
// (memory file in p-diogo/totalreclaw-internal — absolute rule:
|
|
6136
|
-
// "recovery phrase MUST NEVER cross the LLM context in ANY form"),
|
|
6137
|
-
// phrase-generating agent tools are forbidden. The ONLY approved
|
|
6138
|
-
// agent-facilitated setup surface is ``totalreclaw_pair`` (browser-
|
|
6139
|
-
// side crypto keeps the phrase out of the LLM round-trip by
|
|
6140
|
-
// construction). The underlying ``runNonInteractiveOnboard`` code
|
|
6141
|
-
// path is still reachable via the CLI ``openclaw totalreclaw onboard``
|
|
6142
|
-
// — that path runs in the user's own terminal, OUTSIDE any agent
|
|
6143
|
-
// shell, so phrase stdout never feeds back into LLM context.
|
|
6144
|
-
//
|
|
6145
|
-
// Audit assertion: ``tool-gating.test.ts`` enforces the removal —
|
|
6146
|
-
// any future re-registration of ``totalreclaw_onboard`` (or any
|
|
6147
|
-
// phrase-generating variant like ``totalreclaw_onboard_generate``,
|
|
6148
|
-
// ``totalreclaw_restore_phrase``) fails CI.
|
|
6149
|
-
// ---------------------------------------------------------------
|
|
6150
|
-
|
|
6151
|
-
// ---------------------------------------------------------------
|
|
6152
|
-
// Tool: totalreclaw_pair (3.3.1-rc.2 — agent-callable pair-generate)
|
|
6153
|
-
//
|
|
6154
|
-
// Creates a pairing session (browser-mediated recovery-phrase sync),
|
|
6155
|
-
// returns the URL + PIN + QR ASCII to the agent. The agent relays
|
|
6156
|
-
// these to the user (paste-URL or scan-QR flow). The phrase itself
|
|
6157
|
-
// NEVER crosses the gateway — the pair-http endpoint does the E2EE
|
|
6158
|
-
// handshake with the browser pair-page.
|
|
6159
|
-
// ---------------------------------------------------------------
|
|
6160
|
-
api.registerTool(
|
|
6161
|
-
{
|
|
6162
|
-
name: 'totalreclaw_pair',
|
|
6163
|
-
label: 'Set up the user\'s TotalReclaw account (encrypted, browser-side)',
|
|
6164
|
-
description:
|
|
6165
|
-
'Set up the user\'s TotalReclaw account (encrypted, browser-side recovery-phrase ' +
|
|
6166
|
-
'generation or import). Starts a brief remote session so the user can create or ' +
|
|
6167
|
-
'import a TotalReclaw recovery phrase from their phone or another browser. ' +
|
|
6168
|
-
'Returns an account-setup URL, a 6-digit PIN, and an ASCII QR code that the agent ' +
|
|
6169
|
-
'relays to the user. The recovery phrase itself is generated/entered in the ' +
|
|
6170
|
-
'BROWSER and uploaded end-to-end encrypted to this gateway — it NEVER touches ' +
|
|
6171
|
-
'the LLM provider or the chat transcript.\n\n' +
|
|
6172
|
-
'This is the CANONICAL agent-facilitated account-setup surface — use it whenever ' +
|
|
6173
|
-
'the user asks you to set up TotalReclaw, regardless of whether they have ' +
|
|
6174
|
-
'terminal access. Narrate it to the user as "setting up your TotalReclaw ' +
|
|
6175
|
-
'account". Browser-side crypto keeps the recovery phrase out of the LLM context ' +
|
|
6176
|
-
'entirely. If a user explicitly prefers local-terminal account setup with no ' +
|
|
6177
|
-
'browser, point them at `totalreclaw_onboarding_start` (a pointer to the CLI ' +
|
|
6178
|
-
'wizard they run on their own terminal, NOT through your shell tool).\n\n' +
|
|
6179
|
-
'Tool name `totalreclaw_pair` is kept for backward compatibility — function-wise ' +
|
|
6180
|
-
'this is the account-setup tool.',
|
|
6181
|
-
parameters: {
|
|
6182
|
-
type: 'object',
|
|
6183
|
-
properties: {
|
|
6184
|
-
mode: {
|
|
6185
|
-
type: 'string',
|
|
6186
|
-
enum: ['generate', 'import'],
|
|
6187
|
-
description:
|
|
6188
|
-
'"generate" = the browser will create a NEW recovery phrase. "import" = the ' +
|
|
6189
|
-
'browser will accept an EXISTING phrase that the user pastes in their browser ' +
|
|
6190
|
-
'(never through chat).',
|
|
6191
|
-
default: 'generate',
|
|
6192
|
-
},
|
|
6193
|
-
},
|
|
6194
|
-
additionalProperties: false,
|
|
6195
|
-
},
|
|
6196
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
6197
|
-
const rawMode = params?.mode;
|
|
6198
|
-
const mode: 'generate' | 'import' =
|
|
6199
|
-
rawMode === 'import' ? 'import' : 'generate';
|
|
6200
|
-
const pairMode = CONFIG.pairMode;
|
|
6201
|
-
try {
|
|
6202
|
-
// 3.3.1-rc.11 — relay-brokered pair by default (universal reachability).
|
|
6203
|
-
// `TOTALRECLAW_PAIR_MODE=local` preserves the rc.4–rc.10 loopback flow
|
|
6204
|
-
// for air-gapped / self-hosted setups. Both paths return the same
|
|
6205
|
-
// tool payload (`{url, pin, expires_at_ms, qr_*, mode, instructions}`);
|
|
6206
|
-
// only the URL origin differs.
|
|
6207
|
-
let url: string;
|
|
6208
|
-
let pin: string;
|
|
6209
|
-
let sidOrToken: string;
|
|
6210
|
-
let expiresAtMs: number;
|
|
6211
|
-
let localSession: import('./pair-session-store.js').PairSession | undefined;
|
|
6212
|
-
|
|
6213
|
-
if (pairMode === 'relay') {
|
|
6214
|
-
const { openRemotePairSession, awaitPhraseUpload } = await import(
|
|
6215
|
-
'./pair-remote-client.js'
|
|
6216
|
-
);
|
|
6217
|
-
const remoteSession = await openRemotePairSession({
|
|
6218
|
-
relayBaseUrl: CONFIG.pairRelayUrl,
|
|
6219
|
-
mode: mode === 'generate' ? 'generate' : 'import',
|
|
6220
|
-
});
|
|
6221
|
-
url = remoteSession.url;
|
|
6222
|
-
pin = remoteSession.pin;
|
|
6223
|
-
sidOrToken = remoteSession.token;
|
|
6224
|
-
// Relay sends ISO-8601; convert to ms for tool payload parity.
|
|
6225
|
-
const parsed = Date.parse(remoteSession.expiresAt);
|
|
6226
|
-
expiresAtMs = Number.isFinite(parsed)
|
|
6227
|
-
? parsed
|
|
6228
|
-
: Date.now() + 5 * 60_000;
|
|
6229
|
-
// Background task — writes credentials.json + flips state when
|
|
6230
|
-
// the browser completes the flow. Tool handler returns
|
|
6231
|
-
// immediately so the agent can tell the user the URL + PIN.
|
|
6232
|
-
//
|
|
6233
|
-
// 3.3.4-rc.2 (Pedro QA — pair flow stuck-session) — wrap the
|
|
6234
|
-
// WS-await in a 60s hard timeout. The relay drops sessions on
|
|
6235
|
-
// `ws_close` (separately patched relay-side); without this
|
|
6236
|
-
// bound the background task hangs in `waitNextMessage` for the
|
|
6237
|
-
// full 5-minute session TTL before resolving, and downstream
|
|
6238
|
-
// tooling that polls the gateway for completion sees stale
|
|
6239
|
-
// `processing` state at 129s/159s/189s. 60s is the user-side
|
|
6240
|
-
// deadline we surface in chat: long enough for a slow scan-
|
|
6241
|
-
// and-paste, short enough that a fresh URL request is the
|
|
6242
|
-
// obvious next step. Structured `timed_out` error in the log
|
|
6243
|
-
// lets ops grep for the failure mode independently of generic
|
|
6244
|
-
// ws-close errors.
|
|
6245
|
-
const PAIR_TOOL_HARD_TIMEOUT_MS = 60_000;
|
|
6246
|
-
void (async () => {
|
|
6247
|
-
try {
|
|
6248
|
-
const phraseUploadPromise = awaitPhraseUpload(remoteSession, {
|
|
6249
|
-
phraseValidator: (p: string) =>
|
|
6250
|
-
validateMnemonic(p, wordlist),
|
|
6251
|
-
completePairing: async ({ mnemonic }) => {
|
|
6252
|
-
try {
|
|
6253
|
-
// 3.3.1 (internal#130) — derive + persist the
|
|
6254
|
-
// Smart Account address now so the user can see
|
|
6255
|
-
// it immediately after pair, before any chain
|
|
6256
|
-
// write. Mnemonic stays in this scope (already
|
|
6257
|
-
// on disk in credentials.json); only the
|
|
6258
|
-
// derived public scope_address is added.
|
|
6259
|
-
let scopeAddress: string | undefined;
|
|
6260
|
-
try {
|
|
6261
|
-
scopeAddress = await deriveSmartAccountAddress(
|
|
6262
|
-
mnemonic,
|
|
6263
|
-
CONFIG.chainId,
|
|
6264
|
-
);
|
|
6265
|
-
} catch (deriveErr) {
|
|
6266
|
-
api.logger.warn(
|
|
6267
|
-
`totalreclaw_pair(relay): scope_address derivation failed (will retry lazily): ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`,
|
|
6268
|
-
);
|
|
6269
|
-
}
|
|
6270
|
-
const creds =
|
|
6271
|
-
loadCredentialsJson(CREDENTIALS_PATH) ?? {};
|
|
6272
|
-
const next: typeof creds = { ...creds, mnemonic };
|
|
6273
|
-
if (scopeAddress) next.scope_address = scopeAddress;
|
|
6274
|
-
if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
|
|
6275
|
-
return { state: 'error', error: 'credentials_write_failed' };
|
|
6276
|
-
}
|
|
6277
|
-
setRecoveryPhraseOverride(mnemonic);
|
|
6278
|
-
writeOnboardingState(CONFIG.onboardingStatePath, {
|
|
6279
|
-
onboardingState: 'active',
|
|
6280
|
-
createdBy: mode === 'generate' ? 'generate' : 'import',
|
|
6281
|
-
credentialsCreatedAt: new Date().toISOString(),
|
|
6282
|
-
version: pluginVersion ?? '3.3.0',
|
|
6283
|
-
});
|
|
6284
|
-
// 3.3.13 — sentinel cleanup; see auto-pair-on-load.ts.
|
|
6285
|
-
deletePairPendingFile(defaultPairPendingPath(CREDENTIALS_PATH));
|
|
6286
|
-
api.logger.info(
|
|
6287
|
-
`totalreclaw_pair(relay): session ${remoteSession.token.slice(0, 8)}… completed; credentials written${scopeAddress ? ` (scope_address=${scopeAddress})` : ''}`,
|
|
6288
|
-
);
|
|
6289
|
-
return { state: 'active' };
|
|
6290
|
-
} catch (err: unknown) {
|
|
6291
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
6292
|
-
api.logger.error(
|
|
6293
|
-
`totalreclaw_pair(relay): completePairing failed: ${msg}`,
|
|
6294
|
-
);
|
|
6295
|
-
return { state: 'error', error: msg };
|
|
6296
|
-
}
|
|
6297
|
-
},
|
|
6298
|
-
// 3.3.4-rc.2 — also pass through to awaitPhraseUpload so its
|
|
6299
|
-
// internal `waitNextMessage` timer matches the outer race.
|
|
6300
|
-
timeoutMs: PAIR_TOOL_HARD_TIMEOUT_MS,
|
|
6301
|
-
});
|
|
6302
|
-
// 3.3.4-rc.2 — outer Promise.race guard. Resolves to a
|
|
6303
|
-
// sentinel ({ status: 'timed_out', ... }) so the catch
|
|
6304
|
-
// handler can distinguish a hard-timeout from a generic
|
|
6305
|
-
// ws-close error and surface it explicitly.
|
|
6306
|
-
const TIMEOUT_SENTINEL: {
|
|
6307
|
-
status: 'timed_out';
|
|
6308
|
-
message: string;
|
|
6309
|
-
} = {
|
|
6310
|
-
status: 'timed_out',
|
|
6311
|
-
message:
|
|
6312
|
-
`Pair flow timed out (${PAIR_TOOL_HARD_TIMEOUT_MS / 1000}s) — generate a new URL with totalreclaw_pair.`,
|
|
6313
|
-
};
|
|
6314
|
-
let hardTimer: ReturnType<typeof setTimeout> | undefined;
|
|
6315
|
-
const hardTimeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>(
|
|
6316
|
-
(resolve) => {
|
|
6317
|
-
hardTimer = setTimeout(
|
|
6318
|
-
() => resolve(TIMEOUT_SENTINEL),
|
|
6319
|
-
PAIR_TOOL_HARD_TIMEOUT_MS,
|
|
6320
|
-
);
|
|
6321
|
-
},
|
|
6322
|
-
);
|
|
6323
|
-
try {
|
|
6324
|
-
const raced = await Promise.race([
|
|
6325
|
-
phraseUploadPromise,
|
|
6326
|
-
hardTimeoutPromise,
|
|
6327
|
-
]);
|
|
6328
|
-
if (
|
|
6329
|
-
raced &&
|
|
6330
|
-
typeof raced === 'object' &&
|
|
6331
|
-
(raced as { status?: unknown }).status === 'timed_out'
|
|
6332
|
-
) {
|
|
6333
|
-
api.logger.warn(
|
|
6334
|
-
`totalreclaw_pair(relay): hard timeout — ${(raced as typeof TIMEOUT_SENTINEL).message} (token=${remoteSession.token.slice(0, 8)}…)`,
|
|
6335
|
-
);
|
|
6336
|
-
}
|
|
6337
|
-
} finally {
|
|
6338
|
-
if (hardTimer) clearTimeout(hardTimer);
|
|
6339
|
-
}
|
|
6340
|
-
} catch (bgErr: unknown) {
|
|
6341
|
-
// Expected on TTL expiry / user-aborts — log at warn, not error.
|
|
6342
|
-
const bgMsg = bgErr instanceof Error ? bgErr.message : String(bgErr);
|
|
6343
|
-
api.logger.warn(
|
|
6344
|
-
`totalreclaw_pair(relay): background task ended for token=${remoteSession.token.slice(0, 8)}…: ${bgMsg}`,
|
|
6345
|
-
);
|
|
6346
|
-
}
|
|
6347
|
-
})();
|
|
6348
|
-
} else {
|
|
6349
|
-
// Local loopback path (rc.10 behaviour).
|
|
6350
|
-
const { createPairSession } = await import('./pair-session-store.js');
|
|
6351
|
-
const { generateGatewayKeypair } = await import('./pair-crypto.js');
|
|
6352
|
-
const kp = generateGatewayKeypair();
|
|
6353
|
-
const session = await createPairSession(CONFIG.pairSessionsPath, {
|
|
6354
|
-
mode,
|
|
6355
|
-
operatorContext: { channel: 'agent' },
|
|
6356
|
-
rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
|
|
6357
|
-
rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
|
|
6358
|
-
});
|
|
6359
|
-
url = buildPairingUrl(api, session);
|
|
6360
|
-
pin = session.secondaryCode;
|
|
6361
|
-
sidOrToken = session.sid;
|
|
6362
|
-
expiresAtMs = session.expiresAtMs;
|
|
6363
|
-
localSession = session;
|
|
6364
|
-
}
|
|
6365
|
-
|
|
6366
|
-
// QR renderers — same for both modes; input is the URL string.
|
|
6367
|
-
const { defaultRenderQr } = await import('./pair-cli.js');
|
|
6368
|
-
const qrAscii = await new Promise<string>((resolve) => {
|
|
6369
|
-
let settled = false;
|
|
6370
|
-
const t = setTimeout(() => {
|
|
6371
|
-
if (!settled) {
|
|
6372
|
-
settled = true;
|
|
6373
|
-
resolve('');
|
|
6374
|
-
}
|
|
6375
|
-
}, 5_000);
|
|
6376
|
-
try {
|
|
6377
|
-
defaultRenderQr(url, (ascii: string) => {
|
|
6378
|
-
if (settled) return;
|
|
6379
|
-
settled = true;
|
|
6380
|
-
clearTimeout(t);
|
|
6381
|
-
resolve(ascii);
|
|
6382
|
-
});
|
|
6383
|
-
} catch {
|
|
6384
|
-
if (settled) return;
|
|
6385
|
-
settled = true;
|
|
6386
|
-
clearTimeout(t);
|
|
6387
|
-
resolve('');
|
|
6388
|
-
}
|
|
6389
|
-
});
|
|
6390
|
-
|
|
6391
|
-
// 3.3.1-rc.5 — PNG + Unicode QR for multi-transport rendering.
|
|
6392
|
-
let qrPngB64 = '';
|
|
6393
|
-
let qrUnicode = '';
|
|
6394
|
-
try {
|
|
6395
|
-
const { encodePng, encodeUnicode } = await import('./pair-qr.js');
|
|
6396
|
-
const [pngBuf, uni] = await Promise.all([
|
|
6397
|
-
encodePng(url),
|
|
6398
|
-
encodeUnicode(url),
|
|
6399
|
-
]);
|
|
6400
|
-
qrPngB64 = pngBuf.toString('base64');
|
|
6401
|
-
qrUnicode = uni;
|
|
6402
|
-
} catch (qrErr: unknown) {
|
|
6403
|
-
api.logger.warn(
|
|
6404
|
-
`totalreclaw_pair: QR encode failed (non-fatal): ${
|
|
6405
|
-
qrErr instanceof Error ? qrErr.message : String(qrErr)
|
|
6406
|
-
}`,
|
|
6407
|
-
);
|
|
6408
|
-
}
|
|
6409
|
-
|
|
6410
|
-
api.logger.info(
|
|
6411
|
-
`totalreclaw_pair: session ${sidOrToken.slice(0, 8)}… mode=${mode} transport=${pairMode} url=${url} qr_png=${qrPngB64.length} qr_unicode=${qrUnicode.length}`,
|
|
6412
|
-
);
|
|
6413
|
-
// Voidly reference localSession so TS does not flag the unused
|
|
6414
|
-
// local-branch binding. Future rc.12 diagnostics can expose
|
|
6415
|
-
// `session.mode` / `session.status` separately.
|
|
6416
|
-
void localSession;
|
|
6417
|
-
return {
|
|
6418
|
-
content: [{
|
|
6419
|
-
type: 'text',
|
|
6420
|
-
text:
|
|
6421
|
-
`Pairing session started.\n\n` +
|
|
6422
|
-
`URL: ${url}\n\n` +
|
|
6423
|
-
`PIN (type this into the browser): ${pin}\n\n` +
|
|
6424
|
-
(qrAscii ? `QR code:\n\n${qrAscii}\n\n` : '') +
|
|
6425
|
-
`Instructions for the user:\n` +
|
|
6426
|
-
`1. Open the URL above on their phone or another browser (scan the QR or copy-paste).\n` +
|
|
6427
|
-
`2. ` +
|
|
6428
|
-
(mode === 'generate'
|
|
6429
|
-
? `The browser will generate a NEW 12-word recovery phrase and ask the user to write it down + retype 3 words before finalizing.`
|
|
6430
|
-
: `The browser will accept an EXISTING phrase that the user pastes in the browser (never through chat).`) +
|
|
6431
|
-
`\n3. Enter the 6-digit PIN shown above into the browser.\n` +
|
|
6432
|
-
`4. The encrypted phrase uploads to this gateway — it NEVER touches the LLM.\n` +
|
|
6433
|
-
`5. Come back to chat once the browser says "Pairing complete".\n\n` +
|
|
6434
|
-
`This session expires in ~5 minutes. Run this tool again if you need a fresh URL.`,
|
|
6435
|
-
}],
|
|
6436
|
-
details: {
|
|
6437
|
-
sid: sidOrToken,
|
|
6438
|
-
url,
|
|
6439
|
-
pin,
|
|
6440
|
-
mode,
|
|
6441
|
-
expires_at_ms: expiresAtMs,
|
|
6442
|
-
qr_ascii: qrAscii,
|
|
6443
|
-
qr_png_b64: qrPngB64,
|
|
6444
|
-
qr_unicode: qrUnicode,
|
|
6445
|
-
// rc.11 — surface the transport so downstream tooling (QA
|
|
6446
|
-
// harness asserters, telemetry) can confirm which path
|
|
6447
|
-
// served the URL. Either 'relay' or 'local'.
|
|
6448
|
-
transport: pairMode,
|
|
6449
|
-
},
|
|
6450
|
-
};
|
|
6451
|
-
} catch (err: unknown) {
|
|
6452
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
6453
|
-
api.logger.error(`totalreclaw_pair failed: ${message}`);
|
|
6454
|
-
return {
|
|
6455
|
-
content: [{ type: 'text', text: `Failed to start pairing session: ${humanizeError(message)}` }],
|
|
6456
|
-
details: { error: message },
|
|
6457
|
-
};
|
|
6458
|
-
}
|
|
6459
|
-
},
|
|
6460
|
-
},
|
|
6461
|
-
{ name: 'totalreclaw_pair' },
|
|
6462
|
-
);
|
|
6463
|
-
// 3.3.1-rc.20 (issue #110): explicit post-registration breadcrumb so
|
|
6464
|
-
// ops/QA can grep gateway logs for definitive proof the tool was
|
|
6465
|
-
// declared. If the agent then reports the tool is missing from its
|
|
6466
|
-
// tool list, the gap is upstream OpenClaw tool propagation, not our
|
|
6467
|
-
// plugin — see issue #110 fix 3 + PR #102 (CLI fallback).
|
|
6468
|
-
//
|
|
6469
|
-
// 3.3.1-rc.21 (issue #128): the breadcrumb is debug-grade — it was
|
|
6470
|
-
// bleeding into `openclaw agent --json` stdout, breaking programmatic
|
|
6471
|
-
// parsers that expect the JSON-RPC body to be the only thing on the
|
|
6472
|
-
// wire. Gated behind `TOTALRECLAW_VERBOSE_REGISTER` (or the general
|
|
6473
|
-
// `TOTALRECLAW_DEBUG` toggle) so ops can opt back in when chasing
|
|
6474
|
-
// a tool-injection regression. Default OFF — clean stdout for users.
|
|
6475
|
-
if (CONFIG.verboseRegister) {
|
|
6476
|
-
api.logger.info(
|
|
6477
|
-
'TotalReclaw: registerTool(totalreclaw_pair) returned. If the agent does not see it in its tool list ' +
|
|
6478
|
-
'after gateway restart, the issue is upstream tool injection (containerized agents) — fall back to ' +
|
|
6479
|
-
'`openclaw totalreclaw pair generate --url-pin-only` (PR #102) or `openclaw totalreclaw onboard --pair-only`.',
|
|
6480
|
-
);
|
|
6481
|
-
}
|
|
6482
|
-
|
|
6483
|
-
// ---------------------------------------------------------------
|
|
6484
|
-
// Tool: totalreclaw_report_qa_bug (3.3.1-rc.3 — RC-gated)
|
|
6485
|
-
//
|
|
6486
|
-
// Lets the agent file a structured QA-bug issue to
|
|
6487
|
-
// `p-diogo/totalreclaw-internal` during RC testing. Only registered
|
|
6488
|
-
// when the plugin version contains `-rc.` — stable users never see it.
|
|
6489
|
-
//
|
|
6490
|
-
// Secrets (recovery phrases, API keys, Telegram bot tokens) are
|
|
6491
|
-
// redacted inside `postQaBugIssue` before the POST. The agent should
|
|
6492
|
-
// still avoid passing raw secrets — see SKILL.md addendum.
|
|
6493
|
-
// ---------------------------------------------------------------
|
|
6494
|
-
if (rcMode) {
|
|
6495
|
-
api.registerTool(
|
|
6496
|
-
{
|
|
6497
|
-
name: 'totalreclaw_report_qa_bug',
|
|
6498
|
-
label: 'File a QA bug issue (RC builds only)',
|
|
6499
|
-
description:
|
|
6500
|
-
'File a structured QA bug report to the internal tracker. RC-only; never available in stable builds. ' +
|
|
6501
|
-
'Do NOT call auto-file — ask the user first before invoking. The tool redacts recovery phrases, API keys, ' +
|
|
6502
|
-
'and Telegram bot tokens from all free-text fields before posting, but the agent SHOULD still avoid ' +
|
|
6503
|
-
'passing raw secrets.',
|
|
6504
|
-
parameters: {
|
|
6505
|
-
type: 'object',
|
|
6506
|
-
properties: {
|
|
6507
|
-
integration: {
|
|
6508
|
-
type: 'string',
|
|
6509
|
-
enum: ['plugin', 'hermes', 'nanoclaw', 'mcp', 'relay', 'clawhub', 'docs', 'other'],
|
|
6510
|
-
description: 'Which TotalReclaw surface is affected.',
|
|
6511
|
-
},
|
|
6512
|
-
rc_version: {
|
|
6513
|
-
type: 'string',
|
|
6514
|
-
description: 'Exact RC version string (e.g. "3.3.1-rc.3" or "2.3.1rc3").',
|
|
6515
|
-
},
|
|
6516
|
-
severity: {
|
|
6517
|
-
type: 'string',
|
|
6518
|
-
enum: ['blocker', 'high', 'medium', 'low'],
|
|
6519
|
-
description: 'blocker=release blocked, high=major UX failure, medium=annoying, low=polish.',
|
|
6520
|
-
},
|
|
6521
|
-
title: {
|
|
6522
|
-
type: 'string',
|
|
6523
|
-
description: 'Short summary, <60 chars. Prefix "[qa-bug]" is added automatically.',
|
|
6524
|
-
maxLength: 60,
|
|
6525
|
-
},
|
|
6526
|
-
symptom: {
|
|
6527
|
-
type: 'string',
|
|
6528
|
-
description: 'What happened (redacted automatically).',
|
|
6529
|
-
},
|
|
6530
|
-
expected: {
|
|
6531
|
-
type: 'string',
|
|
6532
|
-
description: 'What should have happened.',
|
|
6533
|
-
},
|
|
6534
|
-
repro: {
|
|
6535
|
-
type: 'string',
|
|
6536
|
-
description: 'Reproduction steps (redacted automatically).',
|
|
6537
|
-
},
|
|
6538
|
-
logs: {
|
|
6539
|
-
type: 'string',
|
|
6540
|
-
description: 'Log excerpts / error messages (redacted automatically).',
|
|
6541
|
-
},
|
|
6542
|
-
environment: {
|
|
6543
|
-
type: 'string',
|
|
6544
|
-
description: 'Host, Docker/native, OpenClaw version, LLM provider, etc.',
|
|
6545
|
-
},
|
|
6546
|
-
},
|
|
6547
|
-
required: [
|
|
6548
|
-
'integration',
|
|
6549
|
-
'rc_version',
|
|
6550
|
-
'severity',
|
|
6551
|
-
'title',
|
|
6552
|
-
'symptom',
|
|
6553
|
-
'expected',
|
|
6554
|
-
'repro',
|
|
6555
|
-
'logs',
|
|
6556
|
-
'environment',
|
|
6557
|
-
],
|
|
6558
|
-
additionalProperties: false,
|
|
6559
|
-
},
|
|
6560
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
6561
|
-
try {
|
|
6562
|
-
const { postQaBugIssue } = await import('./qa-bug-report.js');
|
|
6563
|
-
// The token is resolved via CONFIG (config.ts) so index.ts
|
|
6564
|
-
// stays clean of env-harvesting triggers.
|
|
6565
|
-
const token = CONFIG.qaGithubToken;
|
|
6566
|
-
if (!token) {
|
|
6567
|
-
return {
|
|
6568
|
-
content: [{
|
|
6569
|
-
type: 'text',
|
|
6570
|
-
text:
|
|
6571
|
-
'Cannot file QA bug: no GitHub token found. The operator must export ' +
|
|
6572
|
-
'TOTALRECLAW_QA_GITHUB_TOKEN (or GITHUB_TOKEN) with `repo` scope to enable ' +
|
|
6573
|
-
'agent-filed bug reports during RC testing.',
|
|
6574
|
-
}],
|
|
6575
|
-
details: { error: 'missing_github_token' },
|
|
6576
|
-
};
|
|
6577
|
-
}
|
|
6578
|
-
// rc.14 — `repo` is resolved inside `postQaBugIssue` via
|
|
6579
|
-
// `resolveQaRepo(...)`, which reads `TOTALRECLAW_QA_REPO` and
|
|
6580
|
-
// refuses any slug that isn't a `-internal` fork. Pass the
|
|
6581
|
-
// config-surfaced override so env reads stay in config.ts.
|
|
6582
|
-
const repoOverride = CONFIG.qaRepoOverride || undefined;
|
|
6583
|
-
const result = await postQaBugIssue(
|
|
6584
|
-
params as unknown as import('./qa-bug-report.js').QaBugArgs,
|
|
6585
|
-
{
|
|
6586
|
-
githubToken: token,
|
|
6587
|
-
repo: repoOverride,
|
|
6588
|
-
logger: api.logger,
|
|
6589
|
-
},
|
|
6590
|
-
);
|
|
6591
|
-
return {
|
|
6592
|
-
content: [{
|
|
6593
|
-
type: 'text',
|
|
6594
|
-
text: `Filed QA bug #${result.issue_number}: ${result.issue_url}`,
|
|
6595
|
-
}],
|
|
6596
|
-
details: { issue_url: result.issue_url, issue_number: result.issue_number },
|
|
6597
|
-
};
|
|
6598
|
-
} catch (err: unknown) {
|
|
6599
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
6600
|
-
api.logger.error(`totalreclaw_report_qa_bug failed: ${message}`);
|
|
6601
|
-
return {
|
|
6602
|
-
content: [{
|
|
6603
|
-
type: 'text',
|
|
6604
|
-
text: `Failed to file QA bug: ${message}`,
|
|
6605
|
-
}],
|
|
6606
|
-
details: { error: message },
|
|
6607
|
-
};
|
|
6608
|
-
}
|
|
6609
|
-
},
|
|
6610
|
-
},
|
|
6611
|
-
{ name: 'totalreclaw_report_qa_bug' },
|
|
6612
|
-
);
|
|
6613
|
-
// 3.3.1-rc.21 (issue #128): demote the registration-confirmation
|
|
6614
|
-
// breadcrumb to verbose-only. Same `--json` stdout pollution risk
|
|
6615
|
-
// as the totalreclaw_pair breadcrumb above; ops can opt back in
|
|
6616
|
-
// via TOTALRECLAW_VERBOSE_REGISTER / TOTALRECLAW_DEBUG.
|
|
6617
|
-
if (CONFIG.verboseRegister) {
|
|
6618
|
-
api.logger.info(
|
|
6619
|
-
'totalreclaw_report_qa_bug registered (RC build — this tool is hidden in stable releases).',
|
|
6620
|
-
);
|
|
6621
|
-
}
|
|
6622
|
-
}
|
|
6623
|
-
|
|
6624
|
-
// ---------------------------------------------------------------
|
|
6625
|
-
// Hook: before_tool_call (3.2.0 memory-tool gate)
|
|
4562
|
+
// Hook: before_tool_call (memory-tool gate)
|
|
6626
4563
|
// ---------------------------------------------------------------
|
|
6627
4564
|
//
|
|
6628
|
-
//
|
|
6629
|
-
//
|
|
6630
|
-
// pointer to the CLI
|
|
4565
|
+
// Phase 3.3: gates the bundled NATIVE memory tools (memory_search,
|
|
4566
|
+
// memory_get) until onboarding state is `active`. The blockReason string
|
|
4567
|
+
// is LLM-visible but carries no secret — it's a pointer to the CLI pair
|
|
4568
|
+
// surface (`tr pair --url-pin`). Without this gate, an unpaired agent
|
|
4569
|
+
// would hit the adapter's fail-soft empty-result path and surface
|
|
4570
|
+
// "no memories found" with no actionable guidance.
|
|
6631
4571
|
//
|
|
6632
|
-
// Non-gated tools:
|
|
6633
|
-
//
|
|
6634
|
-
//
|
|
6635
|
-
//
|
|
6636
|
-
// return their own routing messages.
|
|
4572
|
+
// Non-gated tools: every other tool the agent sees (read/write helpers,
|
|
4573
|
+
// the host's own tools, etc.). The pair surface is intentionally not
|
|
4574
|
+
// gated — users must be able to start onboarding before the vault is
|
|
4575
|
+
// active.
|
|
6637
4576
|
//
|
|
6638
4577
|
// Decision logic lives in `tool-gating.ts` so it's unit-testable
|
|
6639
4578
|
// without a full plugin host.
|
|
@@ -6795,18 +4734,18 @@ const plugin = {
|
|
|
6795
4734
|
'## TotalReclaw setup pending\n\n' +
|
|
6796
4735
|
'TotalReclaw encrypted memory is installed but not yet set up on this machine. ' +
|
|
6797
4736
|
'If the user asks about memory features or wants to configure TotalReclaw, ' +
|
|
6798
|
-
'
|
|
6799
|
-
'
|
|
6800
|
-
'
|
|
6801
|
-
'
|
|
6802
|
-
'
|
|
6803
|
-
'
|
|
6804
|
-
'
|
|
4737
|
+
'pairing is QR-only and CLI-driven (there is no agent tool for it). Run ' +
|
|
4738
|
+
'`tr pair --url-pin` in a shell on the gateway host — it emits a single JSON ' +
|
|
4739
|
+
'line `{v,url,pin,expires_at_ms}` with no phrase material. Hand the user the ' +
|
|
4740
|
+
'`url` and `pin`; they open the URL in a browser where the recovery phrase is ' +
|
|
4741
|
+
'generated and encrypted in-browser and never leaves their device in plaintext. ' +
|
|
4742
|
+
'Once pairing completes, memory_search/memory_get unlock automatically and this ' +
|
|
4743
|
+
'banner stops appearing.\n\n' +
|
|
6805
4744
|
'**Do NOT** attempt to generate, display, or relay a recovery phrase in chat. ' +
|
|
6806
4745
|
'**Do NOT** run `openclaw totalreclaw onboard` — that CLI emits the recovery ' +
|
|
6807
|
-
'phrase on stdout and would leak it into the LLM transcript. Use `pair` ' +
|
|
6808
|
-
'(
|
|
6809
|
-
'running it directly in their own local terminal.',
|
|
4746
|
+
'phrase on stdout and would leak it into the LLM transcript. Use `tr pair --url-pin` ' +
|
|
4747
|
+
'(or `openclaw totalreclaw pair generate --url-pin-only`) instead; `onboard` is ' +
|
|
4748
|
+
'reserved for users running it directly in their own local terminal.',
|
|
6810
4749
|
};
|
|
6811
4750
|
}
|
|
6812
4751
|
|
|
@@ -6822,7 +4761,7 @@ const plugin = {
|
|
|
6822
4761
|
const tier = cache?.tier || 'free';
|
|
6823
4762
|
const tierInfo = tier === 'pro'
|
|
6824
4763
|
? 'You are on the **Pro** tier — unlimited memories, permanently stored on Gnosis mainnet.'
|
|
6825
|
-
: 'You are on the **Free** tier — memories stored on testnet.
|
|
4764
|
+
: 'You are on the **Free** tier — memories stored on testnet. (Upgrade to Pro: run `openclaw totalreclaw upgrade` on the gateway host for a Stripe checkout URL.)';
|
|
6826
4765
|
welcomeBack = `\n\nTotalReclaw is active. I will automatically remember important things from our conversations and recall relevant context at the start of each session. ${tierInfo}`;
|
|
6827
4766
|
}
|
|
6828
4767
|
|
|
@@ -6845,6 +4784,10 @@ const plugin = {
|
|
|
6845
4784
|
free_writes_used: (billingData.free_writes_used as number) ?? 0,
|
|
6846
4785
|
free_writes_limit: (billingData.free_writes_limit as number) ?? 0,
|
|
6847
4786
|
features: billingData.features as BillingCache['features'] | undefined,
|
|
4787
|
+
// Relay's authoritative chain_id → drives the chain override verbatim (#402).
|
|
4788
|
+
chain_id: billingData.chain_id as number | undefined,
|
|
4789
|
+
// Relay's authoritative data_edge_address → drives the DataEdge override verbatim (#460).
|
|
4790
|
+
data_edge_address: billingData.data_edge_address as string | undefined,
|
|
6848
4791
|
checked_at: Date.now(),
|
|
6849
4792
|
};
|
|
6850
4793
|
writeBillingCache(cache);
|
|
@@ -6853,7 +4796,7 @@ const plugin = {
|
|
|
6853
4796
|
if (cache && cache.free_writes_limit > 0) {
|
|
6854
4797
|
const usageRatio = cache.free_writes_used / cache.free_writes_limit;
|
|
6855
4798
|
if (usageRatio >= QUOTA_WARNING_THRESHOLD) {
|
|
6856
|
-
billingWarning = `\n\nTotalReclaw quota warning: ${cache.free_writes_used}/${cache.free_writes_limit}
|
|
4799
|
+
billingWarning = `\n\nTotalReclaw quota warning: ${cache.free_writes_used}/${cache.free_writes_limit} memories used this month (${Math.round(usageRatio * 100)}%). Visit https://totalreclaw.xyz/pricing to upgrade.`;
|
|
6857
4800
|
}
|
|
6858
4801
|
}
|
|
6859
4802
|
} catch {
|
|
@@ -7001,7 +4944,7 @@ const plugin = {
|
|
|
7001
4944
|
|
|
7002
4945
|
// 5. Decrypt subgraph results and build reranker input.
|
|
7003
4946
|
const rerankerCandidates: RerankerCandidate[] = [];
|
|
7004
|
-
const hookMetaMap = new Map<string, { importance: number; age: string }>();
|
|
4947
|
+
const hookMetaMap = new Map<string, { importance: number; age: string; category?: string }>();
|
|
7005
4948
|
|
|
7006
4949
|
for (const result of subgraphResults) {
|
|
7007
4950
|
try {
|
|
@@ -7050,7 +4993,7 @@ const plugin = {
|
|
|
7050
4993
|
rerankerCandidates,
|
|
7051
4994
|
8,
|
|
7052
4995
|
INTENT_WEIGHTS[hookQueryIntent],
|
|
7053
|
-
/* applySourceWeights (Retrieval v2 Tier 1) */
|
|
4996
|
+
/* applySourceWeights (Retrieval v2 Tier 1) */ false,
|
|
7054
4997
|
);
|
|
7055
4998
|
|
|
7056
4999
|
// Update hot cache with reranked results.
|
|
@@ -7076,15 +5019,19 @@ const plugin = {
|
|
|
7076
5019
|
|
|
7077
5020
|
// Relevance gate removed in rc.22 (see recall tool comment).
|
|
7078
5021
|
|
|
7079
|
-
// 7. Build context string.
|
|
7080
|
-
const
|
|
5022
|
+
// 7. Build context string using core's unified recall formatter (adds dates + header).
|
|
5023
|
+
const recallItems = reranked.map((m) => {
|
|
7081
5024
|
const meta = hookMetaMap.get(m.id);
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7085
|
-
|
|
5025
|
+
return {
|
|
5026
|
+
category: meta?.category ?? 'claim',
|
|
5027
|
+
text: m.text,
|
|
5028
|
+
created_at: m.createdAt ?? 0,
|
|
5029
|
+
};
|
|
7086
5030
|
});
|
|
7087
|
-
const contextString =
|
|
5031
|
+
const contextString = getSmartImportWasm().formatRecallContext(
|
|
5032
|
+
JSON.stringify(recallItems),
|
|
5033
|
+
BigInt(Math.floor(Date.now() / 1000)),
|
|
5034
|
+
);
|
|
7088
5035
|
|
|
7089
5036
|
return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
|
|
7090
5037
|
}
|
|
@@ -7125,7 +5072,7 @@ const plugin = {
|
|
|
7125
5072
|
|
|
7126
5073
|
// 5. Decrypt candidates (text + embeddings) and build reranker input.
|
|
7127
5074
|
const rerankerCandidates: RerankerCandidate[] = [];
|
|
7128
|
-
const hookMetaMap = new Map<string, { importance: number; age: string }>();
|
|
5075
|
+
const hookMetaMap = new Map<string, { importance: number; age: string; category?: string }>();
|
|
7129
5076
|
|
|
7130
5077
|
for (const candidate of candidates) {
|
|
7131
5078
|
try {
|
|
@@ -7174,21 +5121,24 @@ const plugin = {
|
|
|
7174
5121
|
rerankerCandidates,
|
|
7175
5122
|
8,
|
|
7176
5123
|
INTENT_WEIGHTS[srvHookIntent],
|
|
7177
|
-
/* applySourceWeights (Retrieval v2 Tier 1) */
|
|
5124
|
+
/* applySourceWeights (Retrieval v2 Tier 1) */ false,
|
|
7178
5125
|
);
|
|
7179
5126
|
|
|
7180
5127
|
if (reranked.length === 0) return undefined;
|
|
7181
5128
|
|
|
7182
5129
|
// Relevance gate removed in rc.22 (see recall tool comment).
|
|
7183
5130
|
|
|
7184
|
-
// 7. Build context string.
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
});
|
|
7191
|
-
const contextString =
|
|
5131
|
+
// 7. Build context string using core's unified recall formatter (adds dates + header).
|
|
5132
|
+
// Server mode has no category metadata, so we use 'claim' as default.
|
|
5133
|
+
const srvRecallItems = reranked.map((m) => ({
|
|
5134
|
+
category: 'claim',
|
|
5135
|
+
text: m.text,
|
|
5136
|
+
created_at: m.createdAt ?? 0,
|
|
5137
|
+
}));
|
|
5138
|
+
const contextString = getSmartImportWasm().formatRecallContext(
|
|
5139
|
+
JSON.stringify(srvRecallItems),
|
|
5140
|
+
BigInt(Math.floor(Date.now() / 1000)),
|
|
5141
|
+
);
|
|
7192
5142
|
|
|
7193
5143
|
return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
|
|
7194
5144
|
} catch (err: unknown) {
|
|
@@ -7314,6 +5264,14 @@ const plugin = {
|
|
|
7314
5264
|
// surface (scanner constraint: a single file may not contain both
|
|
7315
5265
|
// fs.read* AND outbound-request trigger words). Deps are passed in
|
|
7316
5266
|
// here with neutral aliases for the same reason.
|
|
5267
|
+
//
|
|
5268
|
+
// Lifecycle (rc.20, #402): register() can run more than once per process
|
|
5269
|
+
// (OpenClaw's SIGUSR1 restarts are IN-PROCESS, so the module cache and any
|
|
5270
|
+
// running poller survive). No guard is needed here — startTrajectoryPoller
|
|
5271
|
+
// holds a module-global singleton and stops the previous poller before
|
|
5272
|
+
// starting a new one, and each tick self-terminates if the plugin's own
|
|
5273
|
+
// module file is gone (uninstalled/replaced). This prevents the poller
|
|
5274
|
+
// accumulation + zombie-old-version submitters seen on pop-os.
|
|
7317
5275
|
// ---------------------------------------------------------------
|
|
7318
5276
|
|
|
7319
5277
|
startTrajectoryPoller({
|
|
@@ -7459,61 +5417,98 @@ const plugin = {
|
|
|
7459
5417
|
);
|
|
7460
5418
|
|
|
7461
5419
|
// ---------------------------------------------------------------
|
|
7462
|
-
//
|
|
5420
|
+
// OpenClaw native memory integration (Task 2.7) — register TR as the
|
|
5421
|
+
// memory backend: the MemoryPluginCapability + the memory_search /
|
|
5422
|
+
// memory_get tools the active-memory sub-agent drives.
|
|
7463
5423
|
// ---------------------------------------------------------------
|
|
7464
5424
|
//
|
|
7465
|
-
//
|
|
7466
|
-
//
|
|
7467
|
-
//
|
|
7468
|
-
//
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
5425
|
+
// This is THE integration point. For TR to BE the memory backend (not
|
|
5426
|
+
// just a tool plugin), it must register all four against TR's own
|
|
5427
|
+
// pipeline:
|
|
5428
|
+
// 1. api.registerMemoryCapability({ promptBuilder, flushPlanResolver, runtime })
|
|
5429
|
+
// 2. api.registerTool(() => createMemorySearchTool(runtime), { names: ['memory_search'] })
|
|
5430
|
+
// 3. api.registerTool(() => createMemoryGetTool(runtime), { names: ['memory_get'] })
|
|
5431
|
+
//
|
|
5432
|
+
// These registerTool calls go through the real host api.registerTool
|
|
5433
|
+
// directly (the 3.3.2-rc.1 monkey-patch + .loaded.json manifest
|
|
5434
|
+
// machinery were removed in Phase 3 — the conventional names survive
|
|
5435
|
+
// the tool-policy strip in OC 2026.5.x, so the declare-and-dead-letter
|
|
5436
|
+
// dance + manifest are obsolete).
|
|
5437
|
+
//
|
|
5438
|
+
// Deps: buildRecallDeps captures `api.logger` so the closures can call
|
|
5439
|
+
// ensureInitialized lazily on first use. The paired-account context
|
|
5440
|
+
// (authKeyHex / encryptionKey / userId / subgraphOwner) is NOT
|
|
5441
|
+
// resolved here — it's populated by initialize() on the first tool
|
|
5442
|
+
// call. The closures call ensureInitialized internally (see
|
|
5443
|
+
// buildRecallDeps docstring).
|
|
5444
|
+
//
|
|
5445
|
+
// Scanner note: this call is fine inside register() because
|
|
5446
|
+
// register() itself is not scanner-clean (the file pairs config reads
|
|
5447
|
+
// with network calls legitimately). The scanner-clean surface is
|
|
5448
|
+
// native-memory.ts (the wiring helper), which never touches env or
|
|
5449
|
+
// net primitives.
|
|
5450
|
+
//
|
|
5451
|
+
// Graceful degradation: the wiring is wrapped in try/catch so a
|
|
5452
|
+
// failure in the native memory pipeline cannot block plugin load.
|
|
5453
|
+
// NOTE (Phase 3.3): the legacy totalreclaw_* agent tools that used to
|
|
5454
|
+
// serve as the capture fallback were RETIRED in Task 3.2. If this
|
|
5455
|
+
// registration fails, the agent has NO memory surface until the cause
|
|
5456
|
+
// is fixed and the gateway restarted. The before_tool_call gate stays
|
|
5457
|
+
// armed (memory_search/memory_get are simply never registered), and
|
|
5458
|
+
// auto-extraction hooks still fire on the message_received / agent_end
|
|
5459
|
+
// cadence — they write to the subgraph directly, so memories keep
|
|
5460
|
+
// getting captured even if the agent can't read them mid-session.
|
|
5461
|
+
try {
|
|
5462
|
+
registerNativeMemory(api, buildRecallDeps(api.logger));
|
|
5463
|
+
api.logger.info('TotalReclaw: registered native MemoryPluginCapability + memory_search/memory_get tools');
|
|
5464
|
+
} catch (err: unknown) {
|
|
5465
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5466
|
+
api.logger.warn(
|
|
5467
|
+
`TotalReclaw: native memory capability registration failed — agent memory_search/memory_get UNAVAILABLE until fixed: ${msg}`,
|
|
5468
|
+
);
|
|
7489
5469
|
}
|
|
5470
|
+
|
|
5471
|
+
// ---------------------------------------------------------------
|
|
5472
|
+
// Skill auto-register (rc.17 QA finding: plugin installs but the
|
|
5473
|
+
// SKILL.md playbook does not — agents skipped the separate
|
|
5474
|
+
// `openclaw skills install totalreclaw` step and ended up without
|
|
5475
|
+
// pairing / recall instructions). Mirror the bundled SKILL.md +
|
|
5476
|
+
// skill.json from the package root into the workspace skills dir so
|
|
5477
|
+
// OpenClaw's workspace skill scanner discovers them on the next
|
|
5478
|
+
// gateway load. A single `openclaw plugins install` is now enough
|
|
5479
|
+
// for both plugin + skill. Idempotent + never throws (see
|
|
5480
|
+
// skill-register.ts). Lives in a scanner-clean helper because
|
|
5481
|
+
// index.ts already pairs env-derived config with network calls, so
|
|
5482
|
+
// the disk I/O must stay out of this file.
|
|
5483
|
+
// ---------------------------------------------------------------
|
|
5484
|
+
try {
|
|
5485
|
+
// Re-resolve the dist dir here: the earlier `pluginDir` const
|
|
5486
|
+
// lives inside its own inner try/catch scope and is not visible
|
|
5487
|
+
// this far down. The call is pure + cheap (URL parse + dirname).
|
|
5488
|
+
ensureSkillRegistered({
|
|
5489
|
+
pluginDir: nodePath.dirname(fileURLToPath(import.meta.url)),
|
|
5490
|
+
skillsDir: nodePath.join(CONFIG.openclawWorkspace, 'skills'),
|
|
5491
|
+
logger: api.logger,
|
|
5492
|
+
});
|
|
5493
|
+
} catch (err: unknown) {
|
|
5494
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5495
|
+
api.logger.warn(`TotalReclaw: skill auto-register failed (non-fatal): ${msg}`);
|
|
5496
|
+
}
|
|
5497
|
+
|
|
7490
5498
|
} catch (registerErr: unknown) {
|
|
7491
5499
|
// ---------------------------------------------------------------
|
|
7492
|
-
//
|
|
5500
|
+
// register() threw — best-effort log then re-throw so the SDK sees
|
|
5501
|
+
// the original failure. (3.3.2-rc.1 used to write a `.error.json`
|
|
5502
|
+
// marker here; that machinery was retired in Phase 3.4 along with
|
|
5503
|
+
// the `.loaded.json` success manifest. The gateway log is now the
|
|
5504
|
+
// source of truth for register() failures.)
|
|
7493
5505
|
// ---------------------------------------------------------------
|
|
7494
|
-
//
|
|
7495
|
-
// Some surface threw out of register(). Drop a structured error
|
|
7496
|
-
// marker the agent can grep. Best-effort logging then re-throw so
|
|
7497
|
-
// the SDK sees the original failure.
|
|
7498
5506
|
const errMsg = registerErr instanceof Error ? registerErr.message : String(registerErr);
|
|
7499
|
-
const errStack = registerErr instanceof Error ? registerErr.stack : undefined;
|
|
7500
5507
|
try {
|
|
7501
5508
|
api.logger.error(`TotalReclaw: register() threw: ${errMsg}`);
|
|
7502
5509
|
} catch {
|
|
7503
5510
|
// Logger may be unavailable (very early failure path).
|
|
7504
5511
|
}
|
|
7505
|
-
if (_pluginDirForManifest) {
|
|
7506
|
-
try {
|
|
7507
|
-
writePluginError(_pluginDirForManifest, {
|
|
7508
|
-
loadedAt: Date.now(),
|
|
7509
|
-
error: errMsg,
|
|
7510
|
-
stack: errStack,
|
|
7511
|
-
version: 'unknown',
|
|
7512
|
-
});
|
|
7513
|
-
} catch {
|
|
7514
|
-
// Best-effort.
|
|
7515
|
-
}
|
|
7516
|
-
}
|
|
7517
5512
|
throw registerErr;
|
|
7518
5513
|
}
|
|
7519
5514
|
},
|