@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/dist/index.js
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
|
|
@@ -49,29 +67,24 @@
|
|
|
49
67
|
*/
|
|
50
68
|
import { deriveKeys, deriveLshSeed, computeAuthKeyHash, encrypt, decrypt, generateBlindIndices, generateContentFingerprint, } from './crypto.js';
|
|
51
69
|
import { createApiClient } from './api-client.js';
|
|
52
|
-
import { extractFacts, extractCrystal,
|
|
70
|
+
import { extractFacts, extractCrystal, EXTRACTION_SYSTEM_PROMPT, extractFactsForCompaction, } from './extractor.js';
|
|
53
71
|
import { initLLMClient, resolveLLMConfig, chatCompletion, generateEmbedding, getEmbeddingDims, getEmbeddingModelId, configureEmbedder, prefetchEmbedderBundle, } from './llm-client.js';
|
|
54
72
|
import { defaultAuthProfilesRoot, readAllProfileKeys, dedupeByProvider, } from './llm-profile-reader.js';
|
|
55
73
|
import { LSHHasher } from './lsh.js';
|
|
56
74
|
import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS } from './reranker.js';
|
|
57
75
|
import { deduplicateBatch } from './semantic-dedup.js';
|
|
58
76
|
import { startTrajectoryPoller } from './trajectory-poller.js';
|
|
59
|
-
import { findNearDuplicate, shouldSupersede,
|
|
77
|
+
import { findNearDuplicate, shouldSupersede, getStoreDedupThreshold, STORE_DEDUP_MAX_CANDIDATES, } from './consolidation.js';
|
|
60
78
|
import { isSubgraphMode, getSubgraphConfig, encodeFactProtobuf, submitFactBatchOnChain, deriveSmartAccountAddress, PROTOBUF_VERSION_V4 } from './subgraph-store.js';
|
|
61
|
-
import {
|
|
62
|
-
import { DIGEST_TRAPDOOR, buildCanonicalClaim, computeEntityTrapdoor, computeEntityTrapdoors, isDigestBlob, normalizeToV1Type, readClaimFromBlob, resolveDigestMode, } from './claims-helper.js';
|
|
79
|
+
import { DIGEST_TRAPDOOR, buildCanonicalClaim, computeEntityTrapdoors, isDigestBlob, readClaimFromBlob, resolveDigestMode, } from './claims-helper.js';
|
|
63
80
|
import { maybeInjectDigest, recompileDigest, fetchAllActiveClaims, isRecompileInProgress, tryBeginRecompile, endRecompile, } from './digest-sync.js';
|
|
64
81
|
import { detectAndResolveContradictions, runWeightTuningLoop, } from './contradiction-sync.js';
|
|
65
82
|
import { searchSubgraph, searchSubgraphBroadened, getSubgraphFactCount, fetchFactById } from './subgraph-search.js';
|
|
66
|
-
import { executePinOperation, validatePinArgs, } from './pin.js';
|
|
67
|
-
import { executeRetype, executeSetScope, validateRetypeArgs, validateSetScopeArgs, } from './retype-setscope.js';
|
|
68
83
|
import { PluginHotCache } from './hot-cache-wrapper.js';
|
|
69
84
|
import { CONFIG, setRecoveryPhraseOverride } from './config.js';
|
|
70
85
|
import { buildRelayHeaders } from './relay-headers.js';
|
|
71
86
|
import { readBillingCache, writeBillingCache, BILLING_CACHE_PATH, } from './billing-cache.js';
|
|
72
|
-
import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, clearPartialInstallMarker, patchOpenClawConfig,
|
|
73
|
-
import { maybeStartAutoPair } from './auto-pair-on-load.js';
|
|
74
|
-
import { installBeforeAgentStartHook as installPairPendingHook } from './pair-pending-injection.js';
|
|
87
|
+
import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, clearPartialInstallMarker, patchOpenClawConfig, checkCredentialsFileMode, } from './fs-helpers.js';
|
|
75
88
|
import { isRcBuild } from './qa-bug-report.js';
|
|
76
89
|
import { decideToolGate, isGatedToolName } from './tool-gating.js';
|
|
77
90
|
import { resolveRestartAuth, rejectMessageFor, } from './restart-auth.js';
|
|
@@ -79,6 +92,8 @@ import { recordInboundUser, getDistinctInboundUserCount, resolveTrackerPath, } f
|
|
|
79
92
|
import { detectFirstRun, buildWelcomePrepend } from './first-run.js';
|
|
80
93
|
import { buildPairRoutes } from './pair-http.js';
|
|
81
94
|
import { detectGatewayHost } from './gateway-url.js';
|
|
95
|
+
import { registerNativeMemory } from './native-memory.js';
|
|
96
|
+
import { ensureSkillRegistered } from './skill-register.js';
|
|
82
97
|
import { validateMnemonic } from '@scure/bip39';
|
|
83
98
|
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
84
99
|
import crypto from 'node:crypto';
|
|
@@ -215,15 +230,17 @@ function buildPairingUrl(api, session) {
|
|
|
215
230
|
// issue #110 fix 4: inside Docker the LAN IP is container-internal
|
|
216
231
|
// and useless. Loopback localhost only works for `docker exec`
|
|
217
232
|
// tests. The CORRECT pair URL for Docker is the relay-brokered
|
|
218
|
-
// path served by
|
|
219
|
-
// === 'relay' since rc.11). The CLI-only
|
|
220
|
-
// relay session synchronously (the relay
|
|
221
|
-
// round-trip), so we emit the loopback URL
|
|
222
|
-
// pointing the operator at the
|
|
233
|
+
// path served by `tr pair` / the `/plugin/totalreclaw/pair/*` HTTP
|
|
234
|
+
// routes (CONFIG.pairMode === 'relay' since rc.11). The CLI-only
|
|
235
|
+
// path here cannot mint a relay session synchronously (the relay
|
|
236
|
+
// handshake needs a WS round-trip), so we emit the loopback URL
|
|
237
|
+
// with a LOUD warning pointing the operator at the pair CLI /
|
|
238
|
+
// publicUrl override.
|
|
223
239
|
api.logger.warn(`TotalReclaw: Docker container detected — pairing URL falling back to ` +
|
|
224
240
|
`http://localhost:${port}, which is unreachable from the host browser. ` +
|
|
225
|
-
`
|
|
226
|
-
`
|
|
241
|
+
`Run \`tr pair --url-pin\` (or \`openclaw totalreclaw pair generate --url-pin-only\`) ` +
|
|
242
|
+
`on the gateway host to mint a relay-brokered pair URL that reaches the host browser, ` +
|
|
243
|
+
`OR set plugins.entries.totalreclaw.config.publicUrl ` +
|
|
227
244
|
`to your gateway's host-reachable URL (e.g. http://<host-ip>:${port} when the ` +
|
|
228
245
|
`Docker port is published). Setting TOTALRECLAW_PAIR_MODE=relay is the default; ` +
|
|
229
246
|
`air-gapped operators on TOTALRECLAW_PAIR_MODE=local must publish a port + set publicUrl.`);
|
|
@@ -406,6 +423,11 @@ function getMaxFactsPerExtraction() {
|
|
|
406
423
|
const cache = readBillingCache();
|
|
407
424
|
if (cache?.features?.max_facts_per_extraction != null)
|
|
408
425
|
return cache.features.max_facts_per_extraction;
|
|
426
|
+
// Env override is read in config.ts (env access is centralized there so
|
|
427
|
+
// the env-harvesting scanner-sim stays a no-op for index.ts).
|
|
428
|
+
if (CONFIG.maxFactsPerExtraction && CONFIG.maxFactsPerExtraction > 0) {
|
|
429
|
+
return CONFIG.maxFactsPerExtraction;
|
|
430
|
+
}
|
|
409
431
|
return MAX_FACTS_PER_EXTRACTION;
|
|
410
432
|
}
|
|
411
433
|
/**
|
|
@@ -422,9 +444,10 @@ const MEMORY_HEADER = `# Memory
|
|
|
422
444
|
|
|
423
445
|
> **TotalReclaw is active.** Your encrypted memories are loaded automatically
|
|
424
446
|
> at the start of each conversation — no need to search this file for them.
|
|
425
|
-
>
|
|
426
|
-
>
|
|
427
|
-
> This file is for workspace-level
|
|
447
|
+
> Recall is automatic via the memory_search tool; to explicitly store a fact,
|
|
448
|
+
> run \`tr remember "<text>"\` in a shell. Do NOT write user facts,
|
|
449
|
+
> preferences, or decisions to this file. This file is for workspace-level
|
|
450
|
+
> notes only (non-sensitive).
|
|
428
451
|
|
|
429
452
|
`;
|
|
430
453
|
function ensureMemoryHeader(logger) {
|
|
@@ -574,13 +597,12 @@ async function initialize(logger) {
|
|
|
574
597
|
}
|
|
575
598
|
if (!masterPassword) {
|
|
576
599
|
needsSetup = true;
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
logger.info('TotalReclaw: no credentials found; auto-pair-on-load will surface a pair URL.');
|
|
600
|
+
// No credentials yet — pairing is user-initiated via `tr pair` or the
|
|
601
|
+
// `/plugin/totalreclaw/pair/*` HTTP route (QR scan flow). The plugin
|
|
602
|
+
// does not auto-open a pair session on load (the 3.3.13 auto-pair-on-
|
|
603
|
+
// load state machine was retired in Phase 3.4 — pairing is now
|
|
604
|
+
// explicitly user-triggered per the native-integration design).
|
|
605
|
+
logger.info('TotalReclaw: no credentials found. Run `tr pair` (or ask the agent to) to complete setup.');
|
|
584
606
|
return;
|
|
585
607
|
}
|
|
586
608
|
apiClient = createApiClient(serverUrl);
|
|
@@ -712,12 +734,16 @@ async function initialize(logger) {
|
|
|
712
734
|
const billingData = await resp.json();
|
|
713
735
|
const tier = billingData.tier;
|
|
714
736
|
const expiresAt = billingData.expires_at;
|
|
715
|
-
// Populate billing cache for future use.
|
|
737
|
+
// Populate billing cache for future use. Copy the relay's
|
|
738
|
+
// authoritative chain_id + data_edge_address so they land on disk and
|
|
739
|
+
// drive the runtime chain + DataEdge overrides verbatim (#402, #460).
|
|
716
740
|
writeBillingCache({
|
|
717
741
|
tier: tier || 'free',
|
|
718
742
|
free_writes_used: billingData.free_writes_used ?? 0,
|
|
719
743
|
free_writes_limit: billingData.free_writes_limit ?? 0,
|
|
720
744
|
features: billingData.features,
|
|
745
|
+
chain_id: billingData.chain_id,
|
|
746
|
+
data_edge_address: billingData.data_edge_address,
|
|
721
747
|
checked_at: Date.now(),
|
|
722
748
|
});
|
|
723
749
|
if (tier === 'pro' && expiresAt) {
|
|
@@ -751,10 +777,16 @@ function isDocker() {
|
|
|
751
777
|
return isRunningInDocker();
|
|
752
778
|
}
|
|
753
779
|
function buildSetupErrorMsg() {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
780
|
+
// NOTE: the legacy `totalreclaw_setup` agent tool was retired in 3.2.0
|
|
781
|
+
// (phrase-safety: the agent must never accept or relay a recovery phrase).
|
|
782
|
+
// The ONLY setup surface is the QR-pair flow: the agent cannot mint a
|
|
783
|
+
// pair URL itself, so it must direct the user to the CLI. This message is
|
|
784
|
+
// thrown by `requireFullSetup` (currently only reached via dead 3.2 tool
|
|
785
|
+
// handlers; kept accurate so any future caller gets the correct pointer).
|
|
786
|
+
return 'TotalReclaw setup required. Pairing is QR-only — the recovery phrase is generated and encrypted in-browser and never enters this chat.\n\n' +
|
|
787
|
+
'Run `tr pair --url-pin` on the gateway host (or `openclaw totalreclaw pair generate --url-pin-only`) ' +
|
|
788
|
+
'and hand the user the returned `url` and `pin`. The user opens the URL in a browser to complete pairing. ' +
|
|
789
|
+
'Do NOT ask the user for a recovery phrase and do NOT attempt to generate or relay one yourself.';
|
|
758
790
|
}
|
|
759
791
|
function buildSetupErrorMsgLegacy() {
|
|
760
792
|
const base = 'TotalReclaw setup required:\n' +
|
|
@@ -783,8 +815,9 @@ const SETUP_ERROR_MSG = buildSetupErrorMsg();
|
|
|
783
815
|
* Ensure `initialize()` has completed (runs at most once).
|
|
784
816
|
*
|
|
785
817
|
* If `needsSetup` is true after init, attempts a hot-reload from
|
|
786
|
-
* credentials.json in case the mnemonic was written there by
|
|
787
|
-
*
|
|
818
|
+
* credentials.json in case the mnemonic was just written there by the
|
|
819
|
+
* pair-completion HTTP route (`/plugin/totalreclaw/pair/respond` →
|
|
820
|
+
* `completePairing`) or the `tr pair` CLI on another process.
|
|
788
821
|
*/
|
|
789
822
|
async function ensureInitialized(logger) {
|
|
790
823
|
if (!initPromise) {
|
|
@@ -792,7 +825,7 @@ async function ensureInitialized(logger) {
|
|
|
792
825
|
}
|
|
793
826
|
await initPromise;
|
|
794
827
|
// Hot-reload: if setup is still needed, check if credentials.json
|
|
795
|
-
// now has a mnemonic (written by
|
|
828
|
+
// now has a mnemonic (written by the pair HTTP route / `tr pair` CLI).
|
|
796
829
|
if (needsSetup) {
|
|
797
830
|
await attemptHotReload(logger);
|
|
798
831
|
}
|
|
@@ -801,9 +834,9 @@ async function ensureInitialized(logger) {
|
|
|
801
834
|
* Attempt to hot-reload credentials from credentials.json.
|
|
802
835
|
*
|
|
803
836
|
* Called when `needsSetup` is true — checks if credentials.json contains
|
|
804
|
-
* a mnemonic (written by the
|
|
805
|
-
* If found, re-derives keys and completes
|
|
806
|
-
* a gateway restart.
|
|
837
|
+
* a mnemonic (written by the pair-completion HTTP route or `tr pair` CLI
|
|
838
|
+
* on another process). If found, re-derives keys and completes
|
|
839
|
+
* initialization without requiring a gateway restart.
|
|
807
840
|
*/
|
|
808
841
|
async function attemptHotReload(logger) {
|
|
809
842
|
try {
|
|
@@ -827,9 +860,18 @@ async function attemptHotReload(logger) {
|
|
|
827
860
|
/**
|
|
828
861
|
* Force re-initialization with a specific mnemonic.
|
|
829
862
|
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
863
|
+
* LEGACY (Phase 3.2): the only caller was the `totalreclaw_setup` agent
|
|
864
|
+
* tool, which was retired because accepting a recovery phrase via an agent
|
|
865
|
+
* tool violated phrase-safety (the phrase must never enter an LLM context).
|
|
866
|
+
* The function currently has NO callers; it is retained because the
|
|
867
|
+
* credential-rotation invariant documented below still describes a real
|
|
868
|
+
* trap for any future credential-rotating surface, and removing the body
|
|
869
|
+
* would lose that institutional knowledge. Safe to delete once confirmed
|
|
870
|
+
* unused across the whole plugin tree.
|
|
871
|
+
*
|
|
872
|
+
* Clears stale credentials from disk so that `initialize()` treats this as
|
|
873
|
+
* a fresh registration and persists the NEW mnemonic + freshly derived
|
|
874
|
+
* salt/userId.
|
|
833
875
|
*
|
|
834
876
|
* Without clearing credentials.json first, `initialize()` would load the
|
|
835
877
|
* OLD salt and userId, derive keys from (new mnemonic + old salt), skip
|
|
@@ -1400,7 +1442,7 @@ function relativeTime(isoOrMs) {
|
|
|
1400
1442
|
* Configurable via TOTALRECLAW_MIN_IMPORTANCE env var (default: 3).
|
|
1401
1443
|
*
|
|
1402
1444
|
* NOTE: This filter is ONLY applied to auto-extraction (hooks).
|
|
1403
|
-
* The explicit `
|
|
1445
|
+
* The explicit `tr remember` CLI always stores regardless of importance.
|
|
1404
1446
|
*/
|
|
1405
1447
|
const MIN_IMPORTANCE_THRESHOLD = CONFIG.minImportance;
|
|
1406
1448
|
/**
|
|
@@ -1768,7 +1810,7 @@ async function storeExtractedFacts(facts, logger, sourceOverride) {
|
|
|
1768
1810
|
// Submit subgraph payloads one fact at a time (sequential single-call UserOps).
|
|
1769
1811
|
// Batch executeBatch UserOps have persistent gas estimation issues on Base Sepolia
|
|
1770
1812
|
// that cause on-chain reverts. Single-fact UserOps use the simpler submitFactOnChain
|
|
1771
|
-
// path which works reliably (same path
|
|
1813
|
+
// path which works reliably (same path the `tr remember` CLI uses). Each submission
|
|
1772
1814
|
// polls for receipt (120s) before proceeding, so nonce is consumed before the next.
|
|
1773
1815
|
let batchError;
|
|
1774
1816
|
if (pendingPayloads.length > 0 && isSubgraphMode()) {
|
|
@@ -1820,10 +1862,11 @@ async function storeExtractedFacts(facts, logger, sourceOverride) {
|
|
|
1820
1862
|
return stored;
|
|
1821
1863
|
}
|
|
1822
1864
|
// ---------------------------------------------------------------------------
|
|
1823
|
-
// Import handler (for
|
|
1865
|
+
// Import handler (for the registerCli `openclaw totalreclaw import-from` surface)
|
|
1824
1866
|
// ---------------------------------------------------------------------------
|
|
1825
1867
|
/**
|
|
1826
|
-
* Handle import_from
|
|
1868
|
+
* Handle import_from calls (CLI subcommand path; was the totalreclaw_import_from
|
|
1869
|
+
* agent tool before Phase 3.2 retired the agent tools).
|
|
1827
1870
|
*
|
|
1828
1871
|
* Two paths:
|
|
1829
1872
|
* 1. Pre-structured sources (Mem0, MCP Memory) — adapter returns facts directly,
|
|
@@ -1952,7 +1995,7 @@ async function handlePluginImportFrom(params, logger) {
|
|
|
1952
1995
|
estimated_batches: estimatedBatches,
|
|
1953
1996
|
estimated_minutes: estimatedMinutes,
|
|
1954
1997
|
estimated_completion_iso: initialState.estimated_completion_iso,
|
|
1955
|
-
message: `Import started in background. ~${estimatedMinutes} min for ${totalChunks} chunks.
|
|
1998
|
+
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).`,
|
|
1956
1999
|
warnings: parseResult.warnings,
|
|
1957
2000
|
};
|
|
1958
2001
|
}
|
|
@@ -2412,7 +2455,7 @@ async function handleImportStatus(params, logger) {
|
|
|
2412
2455
|
else {
|
|
2413
2456
|
state = readMostRecentActiveImport();
|
|
2414
2457
|
if (!state)
|
|
2415
|
-
return { status: 'no_active_import', message: 'No active import found. Start one with
|
|
2458
|
+
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.)' };
|
|
2416
2459
|
}
|
|
2417
2460
|
// 1h freshness guard: mark stale imports as failed and prompt user to resume.
|
|
2418
2461
|
if (state.status === 'running' && isImportStale(state)) {
|
|
@@ -2423,7 +2466,7 @@ async function handleImportStatus(params, logger) {
|
|
|
2423
2466
|
status: 'failed',
|
|
2424
2467
|
stale: true,
|
|
2425
2468
|
facts_stored: state.facts_stored,
|
|
2426
|
-
message: 'Import appears stale — no progress in 1 hour. Resume with
|
|
2469
|
+
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.',
|
|
2427
2470
|
resume_id: state.import_id,
|
|
2428
2471
|
};
|
|
2429
2472
|
}
|
|
@@ -2471,6 +2514,323 @@ async function handleImportAbort(params, logger) {
|
|
|
2471
2514
|
};
|
|
2472
2515
|
}
|
|
2473
2516
|
// ---------------------------------------------------------------------------
|
|
2517
|
+
// buildRecallDeps — bind the real recall pipeline into the closures the
|
|
2518
|
+
// native MemoryPluginCapability wiring helper (Task 2.7) consumes.
|
|
2519
|
+
//
|
|
2520
|
+
// WHY THIS LIVES IN index.ts (not in native-memory.ts):
|
|
2521
|
+
// The real `recall` / `getById` closures must reach unexported index.ts
|
|
2522
|
+
// helpers (generateBlindIndices, generateEmbedding, getLSHHasher,
|
|
2523
|
+
// computeCandidatePool, isDigestBlob, readClaimFromBlob, searchSubgraph,
|
|
2524
|
+
// searchSubgraphBroadened, getSubgraphFactCount, fetchFactById,
|
|
2525
|
+
// ensureInitialized) AND module-level state (authKeyHex, encryptionKey,
|
|
2526
|
+
// userId, subgraphOwner). Lifting these out of index.ts is a high
|
|
2527
|
+
// blast-radius refactor with scanner-trap risk — out of scope for 2.7.
|
|
2528
|
+
//
|
|
2529
|
+
// native-memory.ts (the wiring helper) is pure orchestration and stays
|
|
2530
|
+
// scanner-trivial; the closures stay here where the rest of the plugin's
|
|
2531
|
+
// network surface lives.
|
|
2532
|
+
//
|
|
2533
|
+
// LAZY CONTEXT RESOLUTION:
|
|
2534
|
+
// The paired-account context (authKeyHex / encryptionKey / userId /
|
|
2535
|
+
// subgraphOwner) is NOT resolved synchronously at register() time. It is
|
|
2536
|
+
// populated by `initialize()` on the first tool/hook call via
|
|
2537
|
+
// `ensureInitialized()`. So each closure calls `ensureInitialized(logger)`
|
|
2538
|
+
// internally before touching the module-level state — the same lazy-init
|
|
2539
|
+
// seam the retired totalreclaw_recall tool used to use (then via
|
|
2540
|
+
// `requireFullSetup`).
|
|
2541
|
+
//
|
|
2542
|
+
// If setup is incomplete (no credentials), `ensureInitialized` returns
|
|
2543
|
+
// with `needsSetup=true`; the closures then surface a typed error
|
|
2544
|
+
// (`getMemorySearchManager` will return `{ manager: null, error }` from
|
|
2545
|
+
// the runtime wrapper, which the tools convert into the disabled-result
|
|
2546
|
+
// payload the agent recognizes).
|
|
2547
|
+
//
|
|
2548
|
+
// SCANNER NOTE:
|
|
2549
|
+
// This file (index.ts) is NOT scanner-clean — it is the plugin's network
|
|
2550
|
+
// surface and contains the env+net pair legitimately (centralized via
|
|
2551
|
+
// config.ts reads + relay.ts). The closures here CALL the scanner-clean
|
|
2552
|
+
// subgraph-search / vault-crypto / reranker modules but do not add any
|
|
2553
|
+
// NEW env-harvesting or exfiltration pattern: they read only the
|
|
2554
|
+
// already-resolved module-level state. `check-scanner` was already
|
|
2555
|
+
// non-zero on index.ts before this task (the file legitimately pairs
|
|
2556
|
+
// config reads with network calls); the closures do not change that
|
|
2557
|
+
// posture. The NEW files (native-memory.ts, register-native.test.ts)
|
|
2558
|
+
// are scanner-clean by construction (verified).
|
|
2559
|
+
// ---------------------------------------------------------------------------
|
|
2560
|
+
/**
|
|
2561
|
+
* Build the deps for the native MemoryPluginCapability. Returns the
|
|
2562
|
+
* `recall` / `getById` closures bound to the real subgraph + decrypt +
|
|
2563
|
+
* reranker pipeline, plus optional `quota` / `pinned` prompt-builder inputs
|
|
2564
|
+
* (currently defaulted — see TODO(task 2.7b / H1 gate) below).
|
|
2565
|
+
*
|
|
2566
|
+
* `logger` is threaded in so the closures can call `ensureInitialized` (the
|
|
2567
|
+
* lazy-init seam used by every other tool handler in this file).
|
|
2568
|
+
*
|
|
2569
|
+
* @param logger the OpenClaw plugin logger (forwarded into ensureInitialized)
|
|
2570
|
+
*/
|
|
2571
|
+
function buildRecallDeps(logger) {
|
|
2572
|
+
// -------------------------------------------------------------------
|
|
2573
|
+
// recall(): the load-bearing closure. This is the search/decrypt/rerank
|
|
2574
|
+
// pipeline that backs the native memory_search tool via the
|
|
2575
|
+
// TrMemorySearchManager adapter. It replaced the retired totalreclaw_recall
|
|
2576
|
+
// agent tool handler (Phase 3.2) MINUS the tool-level result formatting +
|
|
2577
|
+
// hot-cache bookkeeping (those are tool concerns; the native memory
|
|
2578
|
+
// pipeline only needs the TrFact[]). Returns TrFact[] shaped for the
|
|
2579
|
+
// TrMemorySearchManager adapter (memory-runtime.ts).
|
|
2580
|
+
// -------------------------------------------------------------------
|
|
2581
|
+
const recall = async (query, opts) => {
|
|
2582
|
+
// Lazy-init: this is the first seam the closure hits. If the user
|
|
2583
|
+
// is not paired, ensureInitialized returns with needsSetup=true; we
|
|
2584
|
+
// surface that as an empty result (the before_tool_call gate in
|
|
2585
|
+
// tool-gating.ts normally intercepts memory_search BEFORE this runs
|
|
2586
|
+
// when state != active, but fail-soft here too — a search with no
|
|
2587
|
+
// credentials returning [] is benign; the agent treats empty
|
|
2588
|
+
// results as "no memories found").
|
|
2589
|
+
await ensureInitialized(logger);
|
|
2590
|
+
// Guard: if setup is incomplete OR we're missing the pipeline state,
|
|
2591
|
+
// return []. This is fail-soft: the user sees "no memories" rather
|
|
2592
|
+
// than a thrown error out of the memory_search tool boundary.
|
|
2593
|
+
if (needsSetup || !encryptionKey || !authKeyHex)
|
|
2594
|
+
return [];
|
|
2595
|
+
// subgraphOwner may be null on SA-derivation failure (see initialize()).
|
|
2596
|
+
// The subgraph path requires a non-null owner (Bytes!); if missing,
|
|
2597
|
+
// we cannot run recall — return [].
|
|
2598
|
+
if (isSubgraphMode() && !subgraphOwner)
|
|
2599
|
+
return [];
|
|
2600
|
+
const k = Math.min(opts?.maxResults ?? 8, 20);
|
|
2601
|
+
// 1. Generate word trapdoors (blind indices for the query).
|
|
2602
|
+
const wordTrapdoors = generateBlindIndices(query);
|
|
2603
|
+
// 2. Generate query embedding + LSH trapdoors (may fail gracefully).
|
|
2604
|
+
let queryEmbedding = null;
|
|
2605
|
+
let lshTrapdoors = [];
|
|
2606
|
+
try {
|
|
2607
|
+
queryEmbedding = await generateEmbedding(query, { isQuery: true });
|
|
2608
|
+
const hasher = getLSHHasher(logger);
|
|
2609
|
+
if (hasher && queryEmbedding) {
|
|
2610
|
+
lshTrapdoors = hasher.hash(queryEmbedding);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
catch (err) {
|
|
2614
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2615
|
+
logger.warn(`native recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
|
|
2616
|
+
}
|
|
2617
|
+
// 3. Merge word + LSH trapdoors.
|
|
2618
|
+
const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
|
|
2619
|
+
if (allTrapdoors.length === 0)
|
|
2620
|
+
return [];
|
|
2621
|
+
// 4. Build reranker candidates from the decrypted subgraph results.
|
|
2622
|
+
const rerankerCandidates = [];
|
|
2623
|
+
if (isSubgraphMode()) {
|
|
2624
|
+
// --- Subgraph search path (the canonical path for managed installs) ---
|
|
2625
|
+
const factCount = await getSubgraphFactCount(subgraphOwner || userId, authKeyHex);
|
|
2626
|
+
const pool = computeCandidatePool(factCount);
|
|
2627
|
+
let subgraphResults = await searchSubgraph(subgraphOwner || userId, allTrapdoors, pool, authKeyHex);
|
|
2628
|
+
// Broadened search + merge — vocabulary-mismatch safety net (mirrors
|
|
2629
|
+
// the recall tool: ensures "preferences" still matches "prefer").
|
|
2630
|
+
try {
|
|
2631
|
+
const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId, pool, authKeyHex);
|
|
2632
|
+
const existingIds = new Set(subgraphResults.map((r) => r.id));
|
|
2633
|
+
for (const br of broadenedResults) {
|
|
2634
|
+
if (!existingIds.has(br.id))
|
|
2635
|
+
subgraphResults.push(br);
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
catch {
|
|
2639
|
+
// best-effort
|
|
2640
|
+
}
|
|
2641
|
+
for (const result of subgraphResults) {
|
|
2642
|
+
try {
|
|
2643
|
+
const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
|
|
2644
|
+
if (isDigestBlob(docJson))
|
|
2645
|
+
continue;
|
|
2646
|
+
const doc = readClaimFromBlob(docJson);
|
|
2647
|
+
let decryptedEmbedding;
|
|
2648
|
+
if (result.encryptedEmbedding) {
|
|
2649
|
+
try {
|
|
2650
|
+
decryptedEmbedding = JSON.parse(decryptFromHex(result.encryptedEmbedding, encryptionKey));
|
|
2651
|
+
}
|
|
2652
|
+
catch {
|
|
2653
|
+
// embedding decryption failed -- proceed without it
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
// Dim-mismatch fallback: regenerate the embedding from text so
|
|
2657
|
+
// the reranker's cosine component stays meaningful across model
|
|
2658
|
+
// upgrades. Mirrors the recall tool exactly.
|
|
2659
|
+
if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
|
|
2660
|
+
try {
|
|
2661
|
+
decryptedEmbedding = await generateEmbedding(doc.text);
|
|
2662
|
+
}
|
|
2663
|
+
catch {
|
|
2664
|
+
decryptedEmbedding = undefined;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
rerankerCandidates.push({
|
|
2668
|
+
id: result.id,
|
|
2669
|
+
text: doc.text,
|
|
2670
|
+
embedding: decryptedEmbedding,
|
|
2671
|
+
importance: doc.importance / 10,
|
|
2672
|
+
createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
|
|
2673
|
+
// Retrieval v2 Tier 1 source — surfaced so applySourceWeights
|
|
2674
|
+
// could multiply the final RRF score (left false here to match
|
|
2675
|
+
// the recall tool's current behavior; TODO(task 2.7b): wire
|
|
2676
|
+
// source weighting for the native path at the H1 QA gate).
|
|
2677
|
+
source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
catch {
|
|
2681
|
+
// Skip candidates we cannot decrypt (corrupted / wrong key).
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
else {
|
|
2686
|
+
// --- Server search path (legacy / self-hosted) ---
|
|
2687
|
+
// The non-subgraph path uses apiClient.search. The native memory
|
|
2688
|
+
// pipeline is intended for managed (subgraph) installs, but we keep
|
|
2689
|
+
// parity with the recall tool so self-hosted users get recall too.
|
|
2690
|
+
if (!apiClient || !userId)
|
|
2691
|
+
return [];
|
|
2692
|
+
const factCount = await getFactCount(logger);
|
|
2693
|
+
const pool = computeCandidatePool(factCount);
|
|
2694
|
+
const candidates = await apiClient.search(userId, allTrapdoors, pool, authKeyHex);
|
|
2695
|
+
for (const candidate of candidates) {
|
|
2696
|
+
try {
|
|
2697
|
+
const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey);
|
|
2698
|
+
if (isDigestBlob(docJson))
|
|
2699
|
+
continue;
|
|
2700
|
+
const doc = readClaimFromBlob(docJson);
|
|
2701
|
+
let decryptedEmbedding;
|
|
2702
|
+
if (candidate.encrypted_embedding) {
|
|
2703
|
+
try {
|
|
2704
|
+
decryptedEmbedding = JSON.parse(decryptFromHex(candidate.encrypted_embedding, encryptionKey));
|
|
2705
|
+
}
|
|
2706
|
+
catch {
|
|
2707
|
+
// embedding decryption failed
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
|
|
2711
|
+
try {
|
|
2712
|
+
decryptedEmbedding = await generateEmbedding(doc.text);
|
|
2713
|
+
}
|
|
2714
|
+
catch {
|
|
2715
|
+
decryptedEmbedding = undefined;
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
rerankerCandidates.push({
|
|
2719
|
+
id: candidate.fact_id,
|
|
2720
|
+
text: doc.text,
|
|
2721
|
+
embedding: decryptedEmbedding,
|
|
2722
|
+
importance: doc.importance / 10,
|
|
2723
|
+
createdAt: typeof candidate.timestamp === 'number'
|
|
2724
|
+
? candidate.timestamp / 1000
|
|
2725
|
+
: new Date(candidate.timestamp).getTime() / 1000,
|
|
2726
|
+
source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
catch {
|
|
2730
|
+
// Skip candidates we cannot decrypt.
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
// 5. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
|
|
2735
|
+
const queryIntent = detectQueryIntent(query);
|
|
2736
|
+
const reranked = rerank(query, queryEmbedding ?? [], rerankerCandidates, k, INTENT_WEIGHTS[queryIntent],
|
|
2737
|
+
// applySourceWeights=false — matches the recall tool's current
|
|
2738
|
+
// behavior. TODO(task 2.7b / H1 gate): flip to true for the native
|
|
2739
|
+
// path so Retrieval v2 Tier 1 source weighting takes effect.
|
|
2740
|
+
false);
|
|
2741
|
+
// 6. Map RerankerResult -> TrFact. The score field is rrfScore (the
|
|
2742
|
+
// final fused + weighted score the manager's defensive sort uses).
|
|
2743
|
+
return reranked.map((m) => ({
|
|
2744
|
+
id: m.id,
|
|
2745
|
+
plaintext: m.text,
|
|
2746
|
+
score: m.rrfScore,
|
|
2747
|
+
// pinned is intentionally not surfaced here today — pinned status
|
|
2748
|
+
// lives in claim metadata and there's no clean read-side aggregate
|
|
2749
|
+
// to lift in this task. See getById + pinned TODO below.
|
|
2750
|
+
}));
|
|
2751
|
+
};
|
|
2752
|
+
// -------------------------------------------------------------------
|
|
2753
|
+
// getById(): the load-bearing reverse-path closure. Mirrors the
|
|
2754
|
+
// pin/unpin tool's fetchFactById -> decrypt pattern (the read-back
|
|
2755
|
+
// reverse-path for memory_get). Returns { id, plaintext } or null.
|
|
2756
|
+
// -------------------------------------------------------------------
|
|
2757
|
+
const getById = async (id) => {
|
|
2758
|
+
await ensureInitialized(logger);
|
|
2759
|
+
// Fail-soft on missing setup / encryption key.
|
|
2760
|
+
if (needsSetup || !encryptionKey || !authKeyHex)
|
|
2761
|
+
return null;
|
|
2762
|
+
// The subgraph path is the canonical one; fetchFactById resolves the
|
|
2763
|
+
// fact by UUID and guards against owner mismatch (defense-in-depth
|
|
2764
|
+
// against stale IDs from another user's recall results — see
|
|
2765
|
+
// subgraph-search.ts fetchFactById docstring).
|
|
2766
|
+
if (isSubgraphMode()) {
|
|
2767
|
+
if (!subgraphOwner)
|
|
2768
|
+
return null;
|
|
2769
|
+
try {
|
|
2770
|
+
const result = await fetchFactById(subgraphOwner, id, authKeyHex);
|
|
2771
|
+
if (!result)
|
|
2772
|
+
return null;
|
|
2773
|
+
const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
|
|
2774
|
+
if (isDigestBlob(docJson))
|
|
2775
|
+
return null;
|
|
2776
|
+
const doc = readClaimFromBlob(docJson);
|
|
2777
|
+
return { id, plaintext: doc.text };
|
|
2778
|
+
}
|
|
2779
|
+
catch {
|
|
2780
|
+
return null;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
// Server-path: apiClient doesn't expose a clean get-by-id; fall back
|
|
2784
|
+
// to a recall-style lookup using the id as a single trapdoor. This is
|
|
2785
|
+
// a degenerate path for self-hosted installs and rarely hit (the
|
|
2786
|
+
// native memory pipeline targets managed subgraph installs). Document
|
|
2787
|
+
// rather than gold-plate.
|
|
2788
|
+
// TODO(task 2.7b / H1 gate): wire apiClient get-by-id if/when exposed.
|
|
2789
|
+
if (!apiClient || !userId)
|
|
2790
|
+
return null;
|
|
2791
|
+
try {
|
|
2792
|
+
const candidates = await apiClient.search(userId, [id], 10, authKeyHex);
|
|
2793
|
+
const hit = candidates.find((c) => c.fact_id === id);
|
|
2794
|
+
if (!hit)
|
|
2795
|
+
return null;
|
|
2796
|
+
const docJson = decryptFromHex(hit.encrypted_blob, encryptionKey);
|
|
2797
|
+
if (isDigestBlob(docJson))
|
|
2798
|
+
return null;
|
|
2799
|
+
const doc = readClaimFromBlob(docJson);
|
|
2800
|
+
return { id, plaintext: doc.text };
|
|
2801
|
+
}
|
|
2802
|
+
catch {
|
|
2803
|
+
return null;
|
|
2804
|
+
}
|
|
2805
|
+
};
|
|
2806
|
+
// -------------------------------------------------------------------
|
|
2807
|
+
// quota + pinned: prompt-builder inputs. These drive the warning /
|
|
2808
|
+
// pinned-facts blocks in buildPromptSection (memory-runtime.ts).
|
|
2809
|
+
//
|
|
2810
|
+
// TODO(task 2.7b / H1 QA gate): bind these to the real paired-account
|
|
2811
|
+
// state. The hooks to lift are:
|
|
2812
|
+
// - quota: readBillingCache() in billing-cache.ts exposes
|
|
2813
|
+
// { free_writes_used, free_writes_limit } — when used/limit > 0.8
|
|
2814
|
+
// pass { usedPct }, and on a recently-observed 403 pass { denied }.
|
|
2815
|
+
// The billing cache is refreshed by the trajectory-poller after
|
|
2816
|
+
// each capture attempt; today we default to undefined so no
|
|
2817
|
+
// warning fires (fail-quiet — better than a false warning).
|
|
2818
|
+
// - pinned: there is no clean read-side `fetchPinnedFacts(owner)`
|
|
2819
|
+
// aggregate today. pin.ts writes pinned status into claim metadata;
|
|
2820
|
+
// a pinned-facts read would need either (a) a subgraph query
|
|
2821
|
+
// filtering on the pinned status, or (b) reuse of the hot-cache
|
|
2822
|
+
// pinned list. Both are extraction work — out of scope for 2.7.
|
|
2823
|
+
// Default to [] (no pinned block emitted).
|
|
2824
|
+
//
|
|
2825
|
+
// Returning undefined / [] here is the documented correct default. The
|
|
2826
|
+
// wiring helper accepts a deps object without quota/pinned, and the
|
|
2827
|
+
// prompt builder emits no warning / no pinned block in that case.
|
|
2828
|
+
// -------------------------------------------------------------------
|
|
2829
|
+
const quota = undefined;
|
|
2830
|
+
const pinned = undefined;
|
|
2831
|
+
return { recall, getById, quota, pinned };
|
|
2832
|
+
}
|
|
2833
|
+
// ---------------------------------------------------------------------------
|
|
2474
2834
|
// Plugin definition
|
|
2475
2835
|
// ---------------------------------------------------------------------------
|
|
2476
2836
|
const plugin = {
|
|
@@ -2540,67 +2900,7 @@ const plugin = {
|
|
|
2540
2900
|
},
|
|
2541
2901
|
},
|
|
2542
2902
|
register(api) {
|
|
2543
|
-
//
|
|
2544
|
-
// 3.3.2-rc.1 (issue #186) — load manifest instrumentation
|
|
2545
|
-
// ---------------------------------------------------------------
|
|
2546
|
-
//
|
|
2547
|
-
// Capture every `api.registerTool({name, ...})` call so we can write
|
|
2548
|
-
// a `.loaded.json` manifest at the end of register(). Wrap the body
|
|
2549
|
-
// in try/catch so a register-time throw produces `.error.json` for
|
|
2550
|
-
// agent-side filesystem verification (the CLI hangs in some Docker
|
|
2551
|
-
// setups — issue #182 — so the manifest is the canonical "did the
|
|
2552
|
-
// plugin load?" probe).
|
|
2553
|
-
//
|
|
2554
|
-
// Implementation: we intercept the api.registerTool method by
|
|
2555
|
-
// wrapping it on the api object passed in. The wrapper inspects the
|
|
2556
|
-
// `name` field (every TR registerTool call sets it) and forwards
|
|
2557
|
-
// verbatim. NO behavior change to the SDK call — the original method
|
|
2558
|
-
// is invoked with original args and `this` binding.
|
|
2559
|
-
//
|
|
2560
|
-
// Synchronous writes ONLY (see writePluginManifest doc): the SDK
|
|
2561
|
-
// freezes plugin registries the moment register() returns; an async
|
|
2562
|
-
// write would race that freeze.
|
|
2563
|
-
const _registeredToolNames = [];
|
|
2564
|
-
const _originalRegisterTool = api.registerTool.bind(api);
|
|
2565
|
-
// 3.3.8-rc.1 HYBRID MODE (OpenClaw 2026.5.2 issue #223 workaround):
|
|
2566
|
-
// The tool-policy-pipeline in OC 2026.5.2 strips non-bundled plugin tools
|
|
2567
|
-
// before they reach the agent's session toolset. registerTool() calls
|
|
2568
|
-
// succeed and tools are declared in contracts.tools, so the PLUGIN LOADS.
|
|
2569
|
-
// But tool calls never reach execute() — the pipeline discards them before
|
|
2570
|
-
// the agent's toolset is built.
|
|
2571
|
-
//
|
|
2572
|
-
// Strategy: keep all registerTool() calls intact so the plugin loader can
|
|
2573
|
-
// verify the contracts.tools declaration and load the plugin (hooks fire).
|
|
2574
|
-
// The `tr` CLI binary (dist/tr-cli.js) provides the alternative execution
|
|
2575
|
-
// path. Agent runs `tr remember|recall|status|pair` from shell; tool calls
|
|
2576
|
-
// are dead-letter but hooks (before_agent_start, agent_end, message_received,
|
|
2577
|
-
// before_reset) still fire via the unbroken hook code path.
|
|
2578
|
-
//
|
|
2579
|
-
// NOTE: do NOT no-op registerTool here — OC 2026.5.2 validates the
|
|
2580
|
-
// contracts.tools declaration against registered tools at load time and
|
|
2581
|
-
// drops the plugin (unloads it) if no tools match. Confirmed empirically:
|
|
2582
|
-
// no-op'ing registerTool causes the gateway to log "4 plugins" instead of
|
|
2583
|
-
// "5 plugins" after restart (plugin excluded from active set).
|
|
2584
|
-
//
|
|
2585
|
-
// TODO: when OC ships a fix for issue #223, restore tool-call routing
|
|
2586
|
-
// and remove the tr-cli.ts CLI layer. The bin/tr field in package.json
|
|
2587
|
-
// can stay as a convenience CLI regardless.
|
|
2588
|
-
api.registerTool = (tool, opts) => {
|
|
2589
|
-
try {
|
|
2590
|
-
const t = tool;
|
|
2591
|
-
if (t && typeof t === 'object' && typeof t.name === 'string' && t.name.length > 0) {
|
|
2592
|
-
_registeredToolNames.push(t.name);
|
|
2593
|
-
}
|
|
2594
|
-
}
|
|
2595
|
-
catch {
|
|
2596
|
-
// Manifest is diagnostic; never let bookkeeping break tool registration.
|
|
2597
|
-
}
|
|
2598
|
-
_originalRegisterTool(tool, opts);
|
|
2599
|
-
};
|
|
2600
|
-
// Lazily resolved inside the try below — needed by both the manifest
|
|
2601
|
-
// write and the error path. `dist/` after build, package root in tests.
|
|
2602
|
-
let _pluginDirForManifest = null;
|
|
2603
|
-
// NOTE: the body of register() below is intentionally NOT re-indented
|
|
2903
|
+
// NOTE: the body of register() below is intentionally NOT re-indent
|
|
2604
2904
|
// under this `try` block — re-indenting would touch every line in a
|
|
2605
2905
|
// 3,500-line function and obscure the actual hotfix diff. The closing
|
|
2606
2906
|
// `} catch (registerErr: unknown) { ... }` is at the very end of
|
|
@@ -2610,9 +2910,11 @@ const plugin = {
|
|
|
2610
2910
|
// RC-build detection (3.3.1-rc.3)
|
|
2611
2911
|
// ---------------------------------------------------------------
|
|
2612
2912
|
//
|
|
2613
|
-
// `isRcBuild` reads the plugin's own version string.
|
|
2614
|
-
// `
|
|
2615
|
-
//
|
|
2913
|
+
// `isRcBuild` reads the plugin's own version string. The resulting
|
|
2914
|
+
// `rcMode` flag is currently logged but has no gating effect after
|
|
2915
|
+
// Task 3.2 retired the RC-only `totalreclaw_report_qa_bug` agent tool
|
|
2916
|
+
// (the only former consumer). The flag is retained for the log line and
|
|
2917
|
+
// any future RC-gated diagnostic surface. The version is resolved via
|
|
2616
2918
|
// `readPluginVersion` from fs-helpers.ts (scanner-safe, pure-fs).
|
|
2617
2919
|
let rcMode = false;
|
|
2618
2920
|
// Plugin version resolved from package.json once at register time. Reused
|
|
@@ -2630,7 +2932,6 @@ const plugin = {
|
|
|
2630
2932
|
// `require('node:url')` — undefined under bare-ESM Node, broke the
|
|
2631
2933
|
// before_agent_start hook in the published rc.20 bundle (issue #124).
|
|
2632
2934
|
const pluginDir = nodePath.dirname(fileURLToPath(import.meta.url));
|
|
2633
|
-
_pluginDirForManifest = pluginDir; // captured for #186 .loaded.json/.error.json
|
|
2634
2935
|
pluginVersion = readPluginVersion(pluginDir);
|
|
2635
2936
|
rcMode = isRcBuild(pluginVersion);
|
|
2636
2937
|
if (rcMode) {
|
|
@@ -2684,10 +2985,10 @@ const plugin = {
|
|
|
2684
2985
|
}
|
|
2685
2986
|
// 3.3.3-rc.1 (issue #187 — ONNX decouple): kick off a non-blocking
|
|
2686
2987
|
// bundle prefetch so the ~700 MB embedder tarball starts streaming
|
|
2687
|
-
// as soon as the gateway boots, BEFORE the user completes
|
|
2688
|
-
// `
|
|
2689
|
-
// pair-completion gate the previous flow
|
|
2690
|
-
// `requireFullSetup()` -> first `generateEmbedding()` call.
|
|
2988
|
+
// as soon as the gateway boots, BEFORE the user completes pairing
|
|
2989
|
+
// (`tr pair` / the `/plugin/totalreclaw/pair/*` HTTP route). Decouples
|
|
2990
|
+
// the model download from the pair-completion gate the previous flow
|
|
2991
|
+
// imposed via `requireFullSetup()` -> first `generateEmbedding()` call.
|
|
2691
2992
|
// Fire-and-forget — never awaits, never throws on failure (the next
|
|
2692
2993
|
// `generateEmbedding()` call retries via the same idempotent path).
|
|
2693
2994
|
// Disabled when `TOTALRECLAW_DISABLE_EMBEDDER_PREFETCH=1` (CI / tests
|
|
@@ -2736,14 +3037,16 @@ const plugin = {
|
|
|
2736
3037
|
// Best-effort. Helper logs internally and never throws.
|
|
2737
3038
|
}
|
|
2738
3039
|
// 3.3.9-rc.2 (issues #225 + #226): auto-patch openclaw.json for
|
|
2739
|
-
// OpenClaw 2026.5.x.
|
|
2740
|
-
//
|
|
3040
|
+
// OpenClaw 2026.5.x. Required config keys not auto-applied by
|
|
3041
|
+
// `openclaw plugins install` in 2026.5.x:
|
|
2741
3042
|
//
|
|
2742
|
-
//
|
|
2743
|
-
//
|
|
2744
|
-
//
|
|
2745
|
-
//
|
|
2746
|
-
//
|
|
3043
|
+
// NOTE (rc.20, #402): patchOpenClawConfig now applies only the two
|
|
3044
|
+
// keys below. Retired: the memory slot (plugins.slots.memory — OpenClaw
|
|
3045
|
+
// 2026.6.8 claims it natively on install/enable), the installs
|
|
3046
|
+
// self-heal (plugins.installs — native install owns it; a fabricated
|
|
3047
|
+
// record fails the host's schema validation), and the plugins.allow
|
|
3048
|
+
// self-append + plugins.bundledDiscovery="compat" pair (native install
|
|
3049
|
+
// manages the allowlist).
|
|
2747
3050
|
//
|
|
2748
3051
|
// 2. plugins.entries.totalreclaw.hooks.allowConversationAccess = true
|
|
2749
3052
|
// Non-bundled plugins in 2026.5.x require this flag to receive
|
|
@@ -2762,9 +3065,9 @@ const plugin = {
|
|
|
2762
3065
|
// openclaw.json at startup, not dynamically). We emit a warn so
|
|
2763
3066
|
// the user and ops scripts know to trigger a restart.
|
|
2764
3067
|
try {
|
|
2765
|
-
//
|
|
2766
|
-
//
|
|
2767
|
-
//
|
|
3068
|
+
// pluginVersion is still passed for signature stability; as of rc.20
|
|
3069
|
+
// (#402) patchOpenClawConfig no longer consumes it (the Fix #6 installs
|
|
3070
|
+
// self-heal it fed was retired).
|
|
2768
3071
|
const patchResult = patchOpenClawConfig(undefined, pluginVersion ?? undefined);
|
|
2769
3072
|
if (patchResult === 'patched') {
|
|
2770
3073
|
// 3.3.12-rc.6 (auto-QA finding 2026-05-09): previously we only
|
|
@@ -2798,9 +3101,7 @@ const plugin = {
|
|
|
2798
3101
|
// (registered ~400 lines below) under the same scanner-safe
|
|
2799
3102
|
// pattern.
|
|
2800
3103
|
api.logger.warn('TotalReclaw: updated openclaw.json with required 2026.5.x keys ' +
|
|
2801
|
-
'(
|
|
2802
|
-
'channels.telegram.streaming.mode + plugins.bundledDiscovery + ' +
|
|
2803
|
-
'plugins.allow + plugins.installs.totalreclaw self-heal). ' +
|
|
3104
|
+
'(hooks.allowConversationAccess + channels.telegram.streaming.mode). ' +
|
|
2804
3105
|
'Auto-restarting gateway via SIGUSR1 to apply.');
|
|
2805
3106
|
setTimeout(() => {
|
|
2806
3107
|
try {
|
|
@@ -2816,9 +3117,10 @@ const plugin = {
|
|
|
2816
3117
|
}
|
|
2817
3118
|
else if (patchResult === 'error') {
|
|
2818
3119
|
api.logger.warn('TotalReclaw: failed to auto-patch openclaw.json for OpenClaw 2026.5.x ' +
|
|
2819
|
-
'compatibility. If memory hooks are silently disabled, add
|
|
2820
|
-
'manually: plugins.
|
|
2821
|
-
'
|
|
3120
|
+
'compatibility. If memory hooks are silently disabled, add this key ' +
|
|
3121
|
+
'manually: plugins.entries.totalreclaw.hooks.allowConversationAccess=true. ' +
|
|
3122
|
+
'(The memory slot is set by OpenClaw itself on plugin install/enable; ' +
|
|
3123
|
+
'if the slot is wrong, run: openclaw plugins enable totalreclaw)');
|
|
2822
3124
|
}
|
|
2823
3125
|
// 'unchanged' and 'skipped' are silent — no log needed.
|
|
2824
3126
|
}
|
|
@@ -2844,71 +3146,6 @@ const plugin = {
|
|
|
2844
3146
|
}
|
|
2845
3147
|
}
|
|
2846
3148
|
// ---------------------------------------------------------------
|
|
2847
|
-
// 3.3.13 — auto-pair-on-load (Pop-OS hallucination fix)
|
|
2848
|
-
// ---------------------------------------------------------------
|
|
2849
|
-
//
|
|
2850
|
-
// If credentials.json is missing, open a relay pair session NOW and
|
|
2851
|
-
// write URL + PIN + sid to ~/.totalreclaw/.pair-pending.json. The
|
|
2852
|
-
// before_agent_start hook installed below reads that file and tells
|
|
2853
|
-
// the agent the EXACT values to surface — the agent no longer guesses.
|
|
2854
|
-
//
|
|
2855
|
-
// Fire-and-forget so a relay outage never blocks plugin load. The
|
|
2856
|
-
// sentinel write happens BEFORE the hook is registered, but the WS
|
|
2857
|
-
// listener and credentials.json write run in the background after
|
|
2858
|
-
// register() returns.
|
|
2859
|
-
void (async () => {
|
|
2860
|
-
try {
|
|
2861
|
-
const result = await maybeStartAutoPair({
|
|
2862
|
-
credentialsPath: CREDENTIALS_PATH,
|
|
2863
|
-
pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
|
|
2864
|
-
onboardingStatePath: CONFIG.onboardingStatePath,
|
|
2865
|
-
relayBaseUrl: CONFIG.pairRelayUrl,
|
|
2866
|
-
pluginVersion: pluginVersion ?? '3.3.0',
|
|
2867
|
-
logger: api.logger,
|
|
2868
|
-
});
|
|
2869
|
-
switch (result.status) {
|
|
2870
|
-
case 'creds_exist':
|
|
2871
|
-
// No-op — happy path; user is already set up.
|
|
2872
|
-
break;
|
|
2873
|
-
case 'pending_reused':
|
|
2874
|
-
api.logger.info('TotalReclaw: setup pending. Reusing existing pair URL at ~/.totalreclaw/.pair-pending.json — agent will surface URL+PIN to user on next turn.');
|
|
2875
|
-
break;
|
|
2876
|
-
case 'started':
|
|
2877
|
-
api.logger.info('TotalReclaw: setup pending. Pair URL written to ~/.totalreclaw/.pair-pending.json — agent will surface URL+PIN to user on next turn.');
|
|
2878
|
-
break;
|
|
2879
|
-
case 'failed':
|
|
2880
|
-
api.logger.warn(`TotalReclaw: setup pending. Auto-pair failed (${result.error}). User must run \`tr pair --json\` manually OR call the totalreclaw_pair tool.`);
|
|
2881
|
-
break;
|
|
2882
|
-
}
|
|
2883
|
-
}
|
|
2884
|
-
catch (err) {
|
|
2885
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2886
|
-
api.logger.warn(`TotalReclaw: setup pending. Auto-pair failed (${msg}). User must run \`tr pair --json\` manually OR call the totalreclaw_pair tool.`);
|
|
2887
|
-
}
|
|
2888
|
-
})();
|
|
2889
|
-
// Install the before_agent_start hook that reads the sentinel and
|
|
2890
|
-
// injects URL + PIN into the agent's context verbatim. Registered
|
|
2891
|
-
// here so it lives alongside the other hook registrations in
|
|
2892
|
-
// register(); the hook body itself never blocks on plugin load.
|
|
2893
|
-
try {
|
|
2894
|
-
installPairPendingHook(api, {
|
|
2895
|
-
credentialsPath: CREDENTIALS_PATH,
|
|
2896
|
-
pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
|
|
2897
|
-
autoPairDepsFactory: () => ({
|
|
2898
|
-
credentialsPath: CREDENTIALS_PATH,
|
|
2899
|
-
pendingPath: defaultPairPendingPath(CREDENTIALS_PATH),
|
|
2900
|
-
onboardingStatePath: CONFIG.onboardingStatePath,
|
|
2901
|
-
relayBaseUrl: CONFIG.pairRelayUrl,
|
|
2902
|
-
pluginVersion: pluginVersion ?? '3.3.0',
|
|
2903
|
-
logger: api.logger,
|
|
2904
|
-
}),
|
|
2905
|
-
});
|
|
2906
|
-
}
|
|
2907
|
-
catch (err) {
|
|
2908
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2909
|
-
api.logger.warn(`TotalReclaw: failed to register pair-pending-injection hook: ${msg}`);
|
|
2910
|
-
}
|
|
2911
|
-
// ---------------------------------------------------------------
|
|
2912
3149
|
// LLM client initialization (auto-detect provider from OpenClaw config)
|
|
2913
3150
|
// ---------------------------------------------------------------
|
|
2914
3151
|
//
|
|
@@ -3039,6 +3276,242 @@ const plugin = {
|
|
|
3039
3276
|
});
|
|
3040
3277
|
},
|
|
3041
3278
|
});
|
|
3279
|
+
// ---------------------------------------------------------------
|
|
3280
|
+
// 3.3.13 — `openclaw totalreclaw import ...` + `upgrade`
|
|
3281
|
+
//
|
|
3282
|
+
// Phase 3.2 retired the totalreclaw_import_from / import_status /
|
|
3283
|
+
// import_abort / upgrade agent tools (recall is native; the rest
|
|
3284
|
+
// became CLI/HTTP surfaces). The handlers stayed in this file
|
|
3285
|
+
// (auto-resume still calls handlePluginImportFrom on gateway
|
|
3286
|
+
// restart) but had NO user-facing entry point — users could not
|
|
3287
|
+
// START a new import, only auto-resume worked. This wiring closes
|
|
3288
|
+
// that gap.
|
|
3289
|
+
//
|
|
3290
|
+
// Why this lives on the `openclaw totalreclaw` subcommand chain
|
|
3291
|
+
// (NOT the standalone `tr` CLI binary): the import handler reaches
|
|
3292
|
+
// module-level state (authKeyHex / encryptionKey / subgraphOwner)
|
|
3293
|
+
// populated by initialize(), plus storeExtractedFacts +
|
|
3294
|
+
// extractFacts + runSmartImportPipeline. The `tr` binary
|
|
3295
|
+
// (tr-cli.ts) is a standalone Node script that does NOT import
|
|
3296
|
+
// the plugin runtime; importing index.ts from it would pull in
|
|
3297
|
+
// the entire gateway runtime. The registerCli subcommand runs
|
|
3298
|
+
// INSIDE the gateway process, so the handlers are directly in
|
|
3299
|
+
// scope — same pattern as `onboard` / `status` / `pair`.
|
|
3300
|
+
//
|
|
3301
|
+
// JSON output: every subcommand accepts --json and emits a single
|
|
3302
|
+
// machine-parseable JSON line on stdout (agent-driven use). Plain
|
|
3303
|
+
// text is for direct user CLI use.
|
|
3304
|
+
//
|
|
3305
|
+
// rc.20 (#402): the import/upgrade wiring below referenced a bare
|
|
3306
|
+
// `tr` that was never declared in THIS callback scope —
|
|
3307
|
+
// registerOnboardingCli and registerPairCli each declare their own
|
|
3308
|
+
// LOCAL `tr`, invisible here. That undeclared reference threw
|
|
3309
|
+
// `ReferenceError: tr is not defined` the moment OpenClaw ran the
|
|
3310
|
+
// callback, killing EVERY `openclaw totalreclaw <sub>` command
|
|
3311
|
+
// (dead since the 3.3.13 import/upgrade restoration; shipped in
|
|
3312
|
+
// rc.19 + rc.20). The build is `tsc --noCheck`, so the type checker
|
|
3313
|
+
// never caught it. Resolve the command group the same way
|
|
3314
|
+
// registerPairCli does — registerOnboardingCli always created it, so
|
|
3315
|
+
// this find() succeeds; the guard is belt-and-braces.
|
|
3316
|
+
const tr = program.commands.find((c) => c.name() === 'totalreclaw');
|
|
3317
|
+
if (!tr) {
|
|
3318
|
+
api.logger.warn('TotalReclaw: `totalreclaw` CLI group not found after onboarding/pair registration — ' +
|
|
3319
|
+
'skipping import/upgrade wiring. `openclaw totalreclaw import`/`upgrade` will be unavailable.');
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
const importCmd = tr.command('import')
|
|
3323
|
+
.description('Import memories from another tool (Mem0, MCP Memory, ChatGPT, Claude, Gemini). ' +
|
|
3324
|
+
'Subcommands: `import status`, `import abort`.');
|
|
3325
|
+
importCmd
|
|
3326
|
+
.command('from', { isDefault: true })
|
|
3327
|
+
.description('Start an import from a source tool. Conversation sources (ChatGPT/Claude/Gemini) ' +
|
|
3328
|
+
'run in the background; poll with `import status`. Pre-structured sources (Mem0/MCP) ' +
|
|
3329
|
+
'store synchronously.')
|
|
3330
|
+
.argument('<source>', 'mem0 | mcp-memory | chatgpt | claude | gemini')
|
|
3331
|
+
.option('--file <path>', 'Path to the source file on disk')
|
|
3332
|
+
.option('--content <text>', 'Inline source content (JSON/JSONL/CSV/text)')
|
|
3333
|
+
.option('--api-key <key>', 'API key for the source (used once, never stored)')
|
|
3334
|
+
.option('--source-user-id <id>', 'User/agent ID in the source system')
|
|
3335
|
+
.option('--api-url <url>', 'API base URL override (self-hosted instances)')
|
|
3336
|
+
.option('--dry-run', 'Parse + report without storing')
|
|
3337
|
+
.option('--resume <importId>', 'Resume a previously-started import by id')
|
|
3338
|
+
.option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
|
|
3339
|
+
.action(async (source, opts) => {
|
|
3340
|
+
try {
|
|
3341
|
+
await requireFullSetup(api.logger);
|
|
3342
|
+
const result = await handlePluginImportFrom({
|
|
3343
|
+
source,
|
|
3344
|
+
file_path: opts.file,
|
|
3345
|
+
content: opts.content,
|
|
3346
|
+
api_key: opts.apiKey,
|
|
3347
|
+
source_user_id: opts.sourceUserId,
|
|
3348
|
+
api_url: opts.apiUrl,
|
|
3349
|
+
dry_run: opts.dryRun,
|
|
3350
|
+
resume_id: opts.resume,
|
|
3351
|
+
disclosure_confirmed: true,
|
|
3352
|
+
}, api.logger);
|
|
3353
|
+
if (opts.json) {
|
|
3354
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
3355
|
+
}
|
|
3356
|
+
else {
|
|
3357
|
+
// Human-readable summary. The handler already returns a
|
|
3358
|
+
// `message` for chunked (background) imports; for direct
|
|
3359
|
+
// stores + dry runs, synthesize a short summary.
|
|
3360
|
+
if (result.dry_run) {
|
|
3361
|
+
const chunks = result.total_chunks;
|
|
3362
|
+
if (chunks !== undefined) {
|
|
3363
|
+
process.stdout.write(`Dry run: ~${result.estimated_facts} facts from ${chunks} chunks ` +
|
|
3364
|
+
`(~${result.estimated_minutes} min). Confirm without --dry-run to start.\n`);
|
|
3365
|
+
}
|
|
3366
|
+
else {
|
|
3367
|
+
process.stdout.write(`Dry run: found ${result.total_found} facts. Confirm without --dry-run to import.\n`);
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
else if (result.import_id && result.status === 'running') {
|
|
3371
|
+
process.stdout.write(`${result.message}\nImport id: ${result.import_id}\n`);
|
|
3372
|
+
}
|
|
3373
|
+
else {
|
|
3374
|
+
const stored = result.imported;
|
|
3375
|
+
const total = result.total_found;
|
|
3376
|
+
process.stdout.write(`Imported ${stored ?? 0}/${total ?? stored ?? 0} facts from ${source}.\n`);
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
catch (err) {
|
|
3381
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3382
|
+
if (opts.json) {
|
|
3383
|
+
process.stdout.write(JSON.stringify({ success: false, error: message }) + '\n');
|
|
3384
|
+
}
|
|
3385
|
+
else {
|
|
3386
|
+
process.stderr.write(`import failed: ${message}\n`);
|
|
3387
|
+
}
|
|
3388
|
+
process.exit(1);
|
|
3389
|
+
}
|
|
3390
|
+
});
|
|
3391
|
+
importCmd
|
|
3392
|
+
.command('status')
|
|
3393
|
+
.description('Check progress of a background import. Omit --id for the most recent active import.')
|
|
3394
|
+
.option('--id <importId>', 'Import id (from `import from`). Omit for most-recent active.')
|
|
3395
|
+
.option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
|
|
3396
|
+
.action(async (opts) => {
|
|
3397
|
+
try {
|
|
3398
|
+
await requireFullSetup(api.logger);
|
|
3399
|
+
const result = await handleImportStatus({ import_id: opts.id }, api.logger);
|
|
3400
|
+
if (opts.json) {
|
|
3401
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
3402
|
+
}
|
|
3403
|
+
else {
|
|
3404
|
+
const status = result.status;
|
|
3405
|
+
const stored = result.facts_stored;
|
|
3406
|
+
const batchDone = result.batch_done;
|
|
3407
|
+
const batchTotal = result.batch_total;
|
|
3408
|
+
if (status === 'no_active_import') {
|
|
3409
|
+
process.stdout.write('No active import. Start one with `openclaw totalreclaw import from <source>`.\n');
|
|
3410
|
+
}
|
|
3411
|
+
else if (status === 'running') {
|
|
3412
|
+
process.stdout.write(`Import ${result.import_id}: running — ${stored} facts stored, ` +
|
|
3413
|
+
`batch ${batchDone}/${batchTotal}` +
|
|
3414
|
+
(result.completion_iso ? `, ETA ${result.completion_iso}` : '') + '.\n');
|
|
3415
|
+
}
|
|
3416
|
+
else {
|
|
3417
|
+
process.stdout.write(`Import ${result.import_id}: ${status} — ${stored ?? 0} facts stored.\n`);
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
catch (err) {
|
|
3422
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3423
|
+
if (opts.json) {
|
|
3424
|
+
process.stdout.write(JSON.stringify({ error: message }) + '\n');
|
|
3425
|
+
}
|
|
3426
|
+
else {
|
|
3427
|
+
process.stderr.write(`import status failed: ${message}\n`);
|
|
3428
|
+
}
|
|
3429
|
+
process.exit(1);
|
|
3430
|
+
}
|
|
3431
|
+
});
|
|
3432
|
+
importCmd
|
|
3433
|
+
.command('abort')
|
|
3434
|
+
.description('Cancel a running background import. Already-stored facts are kept.')
|
|
3435
|
+
.argument('<importId>', 'Import id to abort (from `import from` or `import status`)')
|
|
3436
|
+
.option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
|
|
3437
|
+
.action(async (importId, opts) => {
|
|
3438
|
+
try {
|
|
3439
|
+
await requireFullSetup(api.logger);
|
|
3440
|
+
const result = await handleImportAbort({ import_id: importId }, api.logger);
|
|
3441
|
+
if (opts.json) {
|
|
3442
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
3443
|
+
}
|
|
3444
|
+
else {
|
|
3445
|
+
if (result.aborted) {
|
|
3446
|
+
process.stdout.write(`Import ${importId}: abort requested. ${result.facts_already_stored ?? 0} facts already stored (kept).\n`);
|
|
3447
|
+
}
|
|
3448
|
+
else {
|
|
3449
|
+
process.stdout.write(`Import ${importId}: ${result.error ?? 'abort failed'}\n`);
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
catch (err) {
|
|
3454
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3455
|
+
if (opts.json) {
|
|
3456
|
+
process.stdout.write(JSON.stringify({ error: message }) + '\n');
|
|
3457
|
+
}
|
|
3458
|
+
else {
|
|
3459
|
+
process.stderr.write(`import abort failed: ${message}\n`);
|
|
3460
|
+
}
|
|
3461
|
+
process.exit(1);
|
|
3462
|
+
}
|
|
3463
|
+
});
|
|
3464
|
+
// `openclaw totalreclaw upgrade` — Stripe checkout URL for Pro.
|
|
3465
|
+
// Restores the retired totalreclaw_upgrade agent tool (cd21176).
|
|
3466
|
+
// Self-contained: POST /v1/billing/checkout → checkout_url.
|
|
3467
|
+
tr.command('upgrade')
|
|
3468
|
+
.description('Get a Stripe checkout URL to upgrade to TotalReclaw Pro (unlimited memories on Gnosis mainnet).')
|
|
3469
|
+
.option('--json', 'Emit machine-parseable JSON (required for agent shell calls)')
|
|
3470
|
+
.action(async (opts) => {
|
|
3471
|
+
try {
|
|
3472
|
+
await requireFullSetup(api.logger);
|
|
3473
|
+
if (!authKeyHex) {
|
|
3474
|
+
throw new Error('Auth credentials are not available. Pair first (`openclaw totalreclaw pair`).');
|
|
3475
|
+
}
|
|
3476
|
+
const walletAddr = subgraphOwner || userId || '';
|
|
3477
|
+
if (!walletAddr) {
|
|
3478
|
+
throw new Error('Wallet address not available. Ensure the plugin is fully initialized.');
|
|
3479
|
+
}
|
|
3480
|
+
const response = await fetch(`${CONFIG.serverUrl}/v1/billing/checkout`, {
|
|
3481
|
+
method: 'POST',
|
|
3482
|
+
headers: buildRelayHeaders({
|
|
3483
|
+
'Authorization': `Bearer ${authKeyHex}`,
|
|
3484
|
+
'Content-Type': 'application/json',
|
|
3485
|
+
}),
|
|
3486
|
+
body: JSON.stringify({ wallet_address: walletAddr, tier: 'pro' }),
|
|
3487
|
+
});
|
|
3488
|
+
if (!response.ok) {
|
|
3489
|
+
const body = await response.text().catch(() => '');
|
|
3490
|
+
throw new Error(`checkout session failed (HTTP ${response.status}): ${body || response.statusText}`);
|
|
3491
|
+
}
|
|
3492
|
+
const data = await response.json();
|
|
3493
|
+
if (!data.checkout_url) {
|
|
3494
|
+
throw new Error('no checkout URL returned by the relay');
|
|
3495
|
+
}
|
|
3496
|
+
if (opts.json) {
|
|
3497
|
+
process.stdout.write(JSON.stringify({ checkout_url: data.checkout_url }) + '\n');
|
|
3498
|
+
}
|
|
3499
|
+
else {
|
|
3500
|
+
process.stdout.write(`Open this URL to upgrade to Pro: ${data.checkout_url}\n`);
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
catch (err) {
|
|
3504
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3505
|
+
api.logger.error(`openclaw totalreclaw upgrade failed: ${message}`);
|
|
3506
|
+
if (opts.json) {
|
|
3507
|
+
process.stdout.write(JSON.stringify({ error: message }) + '\n');
|
|
3508
|
+
}
|
|
3509
|
+
else {
|
|
3510
|
+
process.stderr.write(`upgrade failed: ${humanizeError(message)}\n`);
|
|
3511
|
+
}
|
|
3512
|
+
process.exit(1);
|
|
3513
|
+
}
|
|
3514
|
+
});
|
|
3042
3515
|
}, { commands: ['totalreclaw'] });
|
|
3043
3516
|
}
|
|
3044
3517
|
else {
|
|
@@ -3074,6 +3547,17 @@ const plugin = {
|
|
|
3074
3547
|
sessionsPath: CONFIG.pairSessionsPath,
|
|
3075
3548
|
apiBase: '/plugin/totalreclaw/pair',
|
|
3076
3549
|
logger: api.logger,
|
|
3550
|
+
// 3.3.14 — wire the relay URL so buildPairRoutes exposes the
|
|
3551
|
+
// in-process `/pair/init` route. The gateway process opens the
|
|
3552
|
+
// relay WebSocket directly (via openRemotePairSession from
|
|
3553
|
+
// pair-remote-client.ts), eliminating the 30s-subprocess-kill
|
|
3554
|
+
// 502 that the CLI path (tr pair) hit when OpenClaw's shell
|
|
3555
|
+
// tool killed the subprocess mid-pair. relayBaseUrl is sourced
|
|
3556
|
+
// from CONFIG.pairRelayUrl (config.ts reads it from the env
|
|
3557
|
+
// once, centrally) — never read from the environment inside
|
|
3558
|
+
// pair-http.ts (scanner-surface rule).
|
|
3559
|
+
relayBaseUrl: CONFIG.pairRelayUrl,
|
|
3560
|
+
initPairMode: 'either',
|
|
3077
3561
|
validateMnemonic: (p) => validateMnemonic(p, wordlist),
|
|
3078
3562
|
completePairing: async ({ mnemonic }) => {
|
|
3079
3563
|
// Write credentials.json + flip state to 'active' via
|
|
@@ -3117,11 +3601,6 @@ const plugin = {
|
|
|
3117
3601
|
credentialsCreatedAt: new Date().toISOString(),
|
|
3118
3602
|
version: pluginVersion ?? '3.3.0',
|
|
3119
3603
|
});
|
|
3120
|
-
// 3.3.13 — sentinel cleanup. credentials.json is now the source
|
|
3121
|
-
// of truth; the auto-pair-on-load .pair-pending.json sentinel
|
|
3122
|
-
// must be removed so the before_agent_start hook stops surfacing
|
|
3123
|
-
// the URL + PIN on subsequent turns.
|
|
3124
|
-
deletePairPendingFile(defaultPairPendingPath(CREDENTIALS_PATH));
|
|
3125
3604
|
return { state: 'active' };
|
|
3126
3605
|
},
|
|
3127
3606
|
});
|
|
@@ -3139,7 +3618,20 @@ const plugin = {
|
|
|
3139
3618
|
api.registerHttpRoute({ path: bundle.startPath, handler: bundle.handlers.start, auth: 'plugin' });
|
|
3140
3619
|
api.registerHttpRoute({ path: bundle.respondPath, handler: bundle.handlers.respond, auth: 'plugin' });
|
|
3141
3620
|
api.registerHttpRoute({ path: bundle.statusPath, handler: bundle.handlers.status, auth: 'plugin' });
|
|
3142
|
-
|
|
3621
|
+
// 3.3.14 — in-process pair trigger. The bundle exposes initPath +
|
|
3622
|
+
// handlers.init ONLY when relayBaseUrl is wired (always true here,
|
|
3623
|
+
// since CONFIG.pairRelayUrl has a built-in default). Registered
|
|
3624
|
+
// with auth: 'plugin' (same as the other pair routes) so the
|
|
3625
|
+
// agent's localhost curl reaches it without a gateway bearer
|
|
3626
|
+
// token. The route opens the relay WS in the gateway process →
|
|
3627
|
+
// survives shell-tool timeouts, retries, SIGUSR1 reloads.
|
|
3628
|
+
if (bundle.initPath && bundle.handlers.init) {
|
|
3629
|
+
api.registerHttpRoute({ path: bundle.initPath, handler: bundle.handlers.init, auth: 'plugin' });
|
|
3630
|
+
api.logger.info('TotalReclaw: registered 5 QR-pairing HTTP routes synchronously (incl. in-process /pair/init)');
|
|
3631
|
+
}
|
|
3632
|
+
else {
|
|
3633
|
+
api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously (in-process /pair/init not wired — no relay URL)');
|
|
3634
|
+
}
|
|
3143
3635
|
}
|
|
3144
3636
|
else {
|
|
3145
3637
|
api.logger.warn('api.registerHttpRoute is unavailable on this OpenClaw version — /totalreclaw pair will not work. ' +
|
|
@@ -3221,47 +3713,17 @@ const plugin = {
|
|
|
3221
3713
|
};
|
|
3222
3714
|
}
|
|
3223
3715
|
if (sub === 'diag') {
|
|
3224
|
-
// 3.3.7-rc.1
|
|
3225
|
-
// tool
|
|
3226
|
-
//
|
|
3227
|
-
//
|
|
3228
|
-
//
|
|
3229
|
-
//
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
// surfaces should agree; if chat says boot=N but the
|
|
3236
|
-
// file says boot=N+1, the chat session is stale and a
|
|
3237
|
-
// /totalreclaw-restart is warranted.
|
|
3238
|
-
try {
|
|
3239
|
-
const m = _pluginDirForManifest
|
|
3240
|
-
? readPluginLoadedManifest(_pluginDirForManifest)
|
|
3241
|
-
: null;
|
|
3242
|
-
if (!m) {
|
|
3243
|
-
return {
|
|
3244
|
-
text: 'TotalReclaw diag:\n' +
|
|
3245
|
-
` pid=${process.pid}\n` +
|
|
3246
|
-
` version=${pluginVersion ?? 'unknown'}\n` +
|
|
3247
|
-
' loaded-manifest: NOT FOUND (register() may have failed — check .error.json)',
|
|
3248
|
-
};
|
|
3249
|
-
}
|
|
3250
|
-
const stalePid = typeof m.pid === 'number' && m.pid !== process.pid;
|
|
3251
|
-
return {
|
|
3252
|
-
text: 'TotalReclaw diag:\n' +
|
|
3253
|
-
` current pid=${process.pid}\n` +
|
|
3254
|
-
` manifest pid=${m.pid ?? '?'}${stalePid ? ' (STALE — file from prior boot, register() did NOT run in this process)' : ''}\n` +
|
|
3255
|
-
` version=${m.version ?? 'unknown'}\n` +
|
|
3256
|
-
` boot count=${m.bootCount ?? '?'}\n` +
|
|
3257
|
-
` boot at=${m.bootAt ?? '?'}\n` +
|
|
3258
|
-
` tools registered=${m.tools?.length ?? 0}`,
|
|
3259
|
-
};
|
|
3260
|
-
}
|
|
3261
|
-
catch (err) {
|
|
3262
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3263
|
-
return { text: `TotalReclaw diag: error reading manifest (${msg})` };
|
|
3264
|
-
}
|
|
3716
|
+
// Diagnostic surface. The 3.3.7-rc.1 `.loaded.json` manifest
|
|
3717
|
+
// (boot count + pid + tool count) was retired in Phase 3.4 —
|
|
3718
|
+
// the writer was removed in 3.1 and the reader had nothing
|
|
3719
|
+
// current to read. `/totalreclaw diag` now reports pid + the
|
|
3720
|
+
// in-memory plugin version only. For richer boot history,
|
|
3721
|
+
// consult the gateway logs.
|
|
3722
|
+
return {
|
|
3723
|
+
text: 'TotalReclaw diag:\n' +
|
|
3724
|
+
` pid=${process.pid}\n` +
|
|
3725
|
+
` version=${pluginVersion ?? 'unknown'}\n`,
|
|
3726
|
+
};
|
|
3265
3727
|
}
|
|
3266
3728
|
return {
|
|
3267
3729
|
text: 'TotalReclaw slash commands:\n' +
|
|
@@ -3407,2297 +3869,20 @@ const plugin = {
|
|
|
3407
3869
|
return undefined;
|
|
3408
3870
|
}, { priority: 5 });
|
|
3409
3871
|
// ---------------------------------------------------------------
|
|
3410
|
-
//
|
|
3411
|
-
// ---------------------------------------------------------------
|
|
3412
|
-
api.registerTool({
|
|
3413
|
-
name: 'totalreclaw_remember',
|
|
3414
|
-
label: 'Remember',
|
|
3415
|
-
description: 'Store a memory in the encrypted vault. Use this when the user shares important information worth remembering.',
|
|
3416
|
-
parameters: {
|
|
3417
|
-
type: 'object',
|
|
3418
|
-
properties: {
|
|
3419
|
-
text: {
|
|
3420
|
-
type: 'string',
|
|
3421
|
-
description: 'The memory text to store',
|
|
3422
|
-
},
|
|
3423
|
-
type: {
|
|
3424
|
-
type: 'string',
|
|
3425
|
-
// Dedup the merged enum. `preference` and `summary` appear in
|
|
3426
|
-
// BOTH v1 (VALID_MEMORY_TYPES) and legacy v0 (LEGACY_V0_MEMORY_TYPES),
|
|
3427
|
-
// so the naive spread produces duplicate items at ## 5 and 12
|
|
3428
|
-
// (QA failure on 3.0.7-rc.1: ajv rejects schema with "items ##
|
|
3429
|
-
// 5 and 12 are identical"). `new Set(...)` drops dupes while
|
|
3430
|
-
// preserving insertion order so v1 tokens appear first in the
|
|
3431
|
-
// enum — agents default to picking one of those.
|
|
3432
|
-
enum: Array.from(new Set([...VALID_MEMORY_TYPES, ...LEGACY_V0_MEMORY_TYPES])),
|
|
3433
|
-
description: 'Memory Taxonomy v1 type: claim, preference, directive, commitment, episode, summary. ' +
|
|
3434
|
-
'Use "claim" for factual assertions and decisions (populate `reasoning` with the why clause). ' +
|
|
3435
|
-
'Use "directive" for imperative rules ("always X", "never Y"), "commitment" for future intent, ' +
|
|
3436
|
-
'and "episode" for notable events. Legacy v0 tokens (fact, decision, episodic, goal, context, ' +
|
|
3437
|
-
'rule) are silently coerced to their v1 equivalents. Default: claim.',
|
|
3438
|
-
},
|
|
3439
|
-
source: {
|
|
3440
|
-
type: 'string',
|
|
3441
|
-
enum: [...VALID_MEMORY_SOURCES],
|
|
3442
|
-
description: 'v1 provenance tag. "user" = user explicitly stated it, "user-inferred" = inferred from user ' +
|
|
3443
|
-
'signals, "assistant" = assistant-authored (downgrade unless user affirmed), "external" / ' +
|
|
3444
|
-
'"derived" = rare. Explicit remembers default to "user".',
|
|
3445
|
-
},
|
|
3446
|
-
scope: {
|
|
3447
|
-
type: 'string',
|
|
3448
|
-
enum: [...VALID_MEMORY_SCOPES],
|
|
3449
|
-
description: 'v1 life-domain scope: work, personal, health, family, creative, finance, misc, unspecified. ' +
|
|
3450
|
-
'Default: unspecified.',
|
|
3451
|
-
},
|
|
3452
|
-
reasoning: {
|
|
3453
|
-
type: 'string',
|
|
3454
|
-
description: 'For type=claim expressing a decision, the WHY clause ("because Y"). Max 256 chars. ' +
|
|
3455
|
-
'Omit for non-decision claims.',
|
|
3456
|
-
maxLength: 256,
|
|
3457
|
-
},
|
|
3458
|
-
importance: {
|
|
3459
|
-
type: 'number',
|
|
3460
|
-
minimum: 1,
|
|
3461
|
-
maximum: 10,
|
|
3462
|
-
description: 'Importance score 1-10 (default: 8 for explicit remember)',
|
|
3463
|
-
},
|
|
3464
|
-
entities: {
|
|
3465
|
-
type: 'array',
|
|
3466
|
-
description: 'Named entities this memory is about (people, projects, tools, companies, concepts, places). ' +
|
|
3467
|
-
'Supplying entities enables Phase 2 contradiction detection against existing facts about the same entity. ' +
|
|
3468
|
-
'Omit if unclear — a best-effort fallback will still store the memory.',
|
|
3469
|
-
items: {
|
|
3470
|
-
type: 'object',
|
|
3471
|
-
properties: {
|
|
3472
|
-
name: { type: 'string' },
|
|
3473
|
-
type: {
|
|
3474
|
-
type: 'string',
|
|
3475
|
-
enum: ['person', 'project', 'tool', 'company', 'concept', 'place'],
|
|
3476
|
-
},
|
|
3477
|
-
role: { type: 'string' },
|
|
3478
|
-
},
|
|
3479
|
-
required: ['name', 'type'],
|
|
3480
|
-
additionalProperties: false,
|
|
3481
|
-
},
|
|
3482
|
-
},
|
|
3483
|
-
},
|
|
3484
|
-
required: ['text'],
|
|
3485
|
-
additionalProperties: false,
|
|
3486
|
-
},
|
|
3487
|
-
async execute(_toolCallId, params) {
|
|
3488
|
-
try {
|
|
3489
|
-
await requireFullSetup(api.logger);
|
|
3490
|
-
// v1 taxonomy: route explicit remembers through the same canonical
|
|
3491
|
-
// store path that auto-extraction uses (`storeExtractedFacts`). This
|
|
3492
|
-
// emits a Memory Taxonomy v1 JSON blob, generates entity trapdoors,
|
|
3493
|
-
// and runs through the Phase 2 contradiction-resolution pipeline.
|
|
3494
|
-
//
|
|
3495
|
-
// Accept legacy v0 tokens on input and coerce to v1 via
|
|
3496
|
-
// `normalizeToV1Type` so agents that still emit the pre-v3
|
|
3497
|
-
// taxonomy keep working.
|
|
3498
|
-
const rawType = typeof params.type === 'string' ? params.type.toLowerCase() : 'claim';
|
|
3499
|
-
const memoryType = isValidMemoryType(rawType)
|
|
3500
|
-
? rawType
|
|
3501
|
-
: normalizeToV1Type(rawType);
|
|
3502
|
-
// Source defaults to 'user' for explicit remembers (the user is
|
|
3503
|
-
// the author by definition). Ignored if the caller passes an
|
|
3504
|
-
// invalid value.
|
|
3505
|
-
const rawSource = typeof params.source === 'string' ? params.source.toLowerCase() : 'user';
|
|
3506
|
-
const memorySource = VALID_MEMORY_SOURCES.includes(rawSource)
|
|
3507
|
-
? rawSource
|
|
3508
|
-
: 'user';
|
|
3509
|
-
const rawScope = typeof params.scope === 'string' ? params.scope.toLowerCase() : 'unspecified';
|
|
3510
|
-
const memoryScope = VALID_MEMORY_SCOPES.includes(rawScope)
|
|
3511
|
-
? rawScope
|
|
3512
|
-
: 'unspecified';
|
|
3513
|
-
const reasoning = typeof params.reasoning === 'string' && params.reasoning.length > 0
|
|
3514
|
-
? params.reasoning.slice(0, 256)
|
|
3515
|
-
: undefined;
|
|
3516
|
-
// Explicit remember defaults to importance 8 (above auto-extraction's
|
|
3517
|
-
// typical 6-7), so store-time dedup's shouldSupersede prefers the
|
|
3518
|
-
// explicit call when it collides with an auto-extracted claim.
|
|
3519
|
-
const importance = Math.max(1, Math.min(10, params.importance ?? 8));
|
|
3520
|
-
const validatedEntities = Array.isArray(params.entities)
|
|
3521
|
-
? params.entities
|
|
3522
|
-
.map((e) => parseEntity(e))
|
|
3523
|
-
.filter((e) => e !== null)
|
|
3524
|
-
: [];
|
|
3525
|
-
const fact = {
|
|
3526
|
-
text: params.text.slice(0, 512),
|
|
3527
|
-
type: memoryType,
|
|
3528
|
-
source: memorySource,
|
|
3529
|
-
scope: memoryScope,
|
|
3530
|
-
reasoning,
|
|
3531
|
-
importance,
|
|
3532
|
-
action: 'ADD',
|
|
3533
|
-
confidence: 1.0, // user explicitly asked to remember — highest confidence
|
|
3534
|
-
};
|
|
3535
|
-
if (validatedEntities.length > 0)
|
|
3536
|
-
fact.entities = validatedEntities;
|
|
3537
|
-
const stored = await storeExtractedFacts([fact], api.logger, 'explicit');
|
|
3538
|
-
api.logger.info(`totalreclaw_remember: routed to storeExtractedFacts (stored=${stored}, entities=${validatedEntities.length})`);
|
|
3539
|
-
if (stored === 0) {
|
|
3540
|
-
// Dedup or supersession consumed the write. Treat as success from
|
|
3541
|
-
// the user's perspective — the memory's content is already in the
|
|
3542
|
-
// vault (possibly under a different ID).
|
|
3543
|
-
return {
|
|
3544
|
-
content: [
|
|
3545
|
-
{
|
|
3546
|
-
type: 'text',
|
|
3547
|
-
text: 'Memory noted (matched existing content in vault).',
|
|
3548
|
-
},
|
|
3549
|
-
],
|
|
3550
|
-
};
|
|
3551
|
-
}
|
|
3552
|
-
return {
|
|
3553
|
-
content: [{ type: 'text', text: 'Memory encrypted and stored.' }],
|
|
3554
|
-
};
|
|
3555
|
-
}
|
|
3556
|
-
catch (err) {
|
|
3557
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3558
|
-
api.logger.error(`totalreclaw_remember failed: ${message}`);
|
|
3559
|
-
return {
|
|
3560
|
-
content: [{ type: 'text', text: `Failed to store memory: ${humanizeError(message)}` }],
|
|
3561
|
-
};
|
|
3562
|
-
}
|
|
3563
|
-
},
|
|
3564
|
-
}, { name: 'totalreclaw_remember' });
|
|
3565
|
-
// ---------------------------------------------------------------
|
|
3566
|
-
// Tool: totalreclaw_recall
|
|
3872
|
+
// Hook: before_tool_call (memory-tool gate)
|
|
3567
3873
|
// ---------------------------------------------------------------
|
|
3568
|
-
api.registerTool({
|
|
3569
|
-
name: 'totalreclaw_recall',
|
|
3570
|
-
label: 'Recall',
|
|
3571
|
-
description: 'Search the encrypted memory vault. Returns the most relevant memories matching the query.',
|
|
3572
|
-
parameters: {
|
|
3573
|
-
type: 'object',
|
|
3574
|
-
properties: {
|
|
3575
|
-
query: {
|
|
3576
|
-
type: 'string',
|
|
3577
|
-
description: 'Search query text',
|
|
3578
|
-
},
|
|
3579
|
-
k: {
|
|
3580
|
-
type: 'number',
|
|
3581
|
-
minimum: 1,
|
|
3582
|
-
maximum: 20,
|
|
3583
|
-
description: 'Number of results to return (default: 8)',
|
|
3584
|
-
},
|
|
3585
|
-
},
|
|
3586
|
-
required: ['query'],
|
|
3587
|
-
additionalProperties: false,
|
|
3588
|
-
},
|
|
3589
|
-
async execute(_toolCallId, params) {
|
|
3590
|
-
try {
|
|
3591
|
-
await requireFullSetup(api.logger);
|
|
3592
|
-
const k = Math.min(params.k ?? 8, 20);
|
|
3593
|
-
// 1. Generate word trapdoors (blind indices for the query).
|
|
3594
|
-
const wordTrapdoors = generateBlindIndices(params.query);
|
|
3595
|
-
// 2. Generate query embedding + LSH trapdoors (may fail gracefully).
|
|
3596
|
-
let queryEmbedding = null;
|
|
3597
|
-
let lshTrapdoors = [];
|
|
3598
|
-
try {
|
|
3599
|
-
queryEmbedding = await generateEmbedding(params.query, { isQuery: true });
|
|
3600
|
-
const hasher = getLSHHasher(api.logger);
|
|
3601
|
-
if (hasher && queryEmbedding) {
|
|
3602
|
-
lshTrapdoors = hasher.hash(queryEmbedding);
|
|
3603
|
-
}
|
|
3604
|
-
}
|
|
3605
|
-
catch (err) {
|
|
3606
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3607
|
-
api.logger.warn(`Recall: embedding/LSH generation failed (using word-only trapdoors): ${msg}`);
|
|
3608
|
-
}
|
|
3609
|
-
// 3. Merge word trapdoors + LSH trapdoors.
|
|
3610
|
-
const allTrapdoors = [...wordTrapdoors, ...lshTrapdoors];
|
|
3611
|
-
if (allTrapdoors.length === 0) {
|
|
3612
|
-
return {
|
|
3613
|
-
content: [{ type: 'text', text: 'No searchable terms in query.' }],
|
|
3614
|
-
details: { count: 0, memories: [] },
|
|
3615
|
-
};
|
|
3616
|
-
}
|
|
3617
|
-
// 4. Request more candidates than needed so we can re-rank client-side.
|
|
3618
|
-
// 5. Decrypt candidates (text + embeddings) and build reranker input.
|
|
3619
|
-
const rerankerCandidates = [];
|
|
3620
|
-
const metaMap = new Map();
|
|
3621
|
-
if (isSubgraphMode()) {
|
|
3622
|
-
// --- Subgraph search path ---
|
|
3623
|
-
const factCount = await getSubgraphFactCount(subgraphOwner || userId, authKeyHex);
|
|
3624
|
-
const pool = computeCandidatePool(factCount);
|
|
3625
|
-
let subgraphResults = await searchSubgraph(subgraphOwner || userId, allTrapdoors, pool, authKeyHex);
|
|
3626
|
-
// Always run broadened search and merge — ensures vocabulary mismatches
|
|
3627
|
-
// (e.g., "preferences" vs "prefer") don't cause recall failures.
|
|
3628
|
-
// The reranker handles scoring; extra cost is ~1 GraphQL query per recall.
|
|
3629
|
-
try {
|
|
3630
|
-
const broadenedResults = await searchSubgraphBroadened(subgraphOwner || userId, pool, authKeyHex);
|
|
3631
|
-
// Merge broadened results with existing (deduplicate by ID)
|
|
3632
|
-
const existingIds = new Set(subgraphResults.map(r => r.id));
|
|
3633
|
-
for (const br of broadenedResults) {
|
|
3634
|
-
if (!existingIds.has(br.id)) {
|
|
3635
|
-
subgraphResults.push(br);
|
|
3636
|
-
}
|
|
3637
|
-
}
|
|
3638
|
-
}
|
|
3639
|
-
catch { /* best-effort */ }
|
|
3640
|
-
for (const result of subgraphResults) {
|
|
3641
|
-
try {
|
|
3642
|
-
const docJson = decryptFromHex(result.encryptedBlob, encryptionKey);
|
|
3643
|
-
if (isDigestBlob(docJson))
|
|
3644
|
-
continue;
|
|
3645
|
-
const doc = readClaimFromBlob(docJson);
|
|
3646
|
-
let decryptedEmbedding;
|
|
3647
|
-
if (result.encryptedEmbedding) {
|
|
3648
|
-
try {
|
|
3649
|
-
decryptedEmbedding = JSON.parse(decryptFromHex(result.encryptedEmbedding, encryptionKey));
|
|
3650
|
-
}
|
|
3651
|
-
catch {
|
|
3652
|
-
// Embedding decryption failed -- proceed without it.
|
|
3653
|
-
}
|
|
3654
|
-
}
|
|
3655
|
-
if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
|
|
3656
|
-
try {
|
|
3657
|
-
decryptedEmbedding = await generateEmbedding(doc.text);
|
|
3658
|
-
}
|
|
3659
|
-
catch {
|
|
3660
|
-
decryptedEmbedding = undefined;
|
|
3661
|
-
}
|
|
3662
|
-
}
|
|
3663
|
-
rerankerCandidates.push({
|
|
3664
|
-
id: result.id,
|
|
3665
|
-
text: doc.text,
|
|
3666
|
-
embedding: decryptedEmbedding,
|
|
3667
|
-
importance: doc.importance / 10,
|
|
3668
|
-
createdAt: result.timestamp ? parseInt(result.timestamp, 10) : undefined,
|
|
3669
|
-
// Retrieval v2 Tier 1: surface v1 source so applySourceWeights
|
|
3670
|
-
// can multiply the final RRF score by the source weight.
|
|
3671
|
-
source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
|
|
3672
|
-
});
|
|
3673
|
-
metaMap.set(result.id, {
|
|
3674
|
-
metadata: doc.metadata ?? {},
|
|
3675
|
-
timestamp: Date.now(),
|
|
3676
|
-
category: doc.category,
|
|
3677
|
-
});
|
|
3678
|
-
}
|
|
3679
|
-
catch {
|
|
3680
|
-
// Skip candidates we cannot decrypt.
|
|
3681
|
-
}
|
|
3682
|
-
}
|
|
3683
|
-
// Update hot cache with top results for instant auto-recall.
|
|
3684
|
-
try {
|
|
3685
|
-
if (!pluginHotCache && encryptionKey) {
|
|
3686
|
-
const config = getSubgraphConfig();
|
|
3687
|
-
pluginHotCache = new PluginHotCache(config.cachePath, encryptionKey.toString('hex'));
|
|
3688
|
-
pluginHotCache.load();
|
|
3689
|
-
}
|
|
3690
|
-
if (pluginHotCache) {
|
|
3691
|
-
const hotFacts = rerankerCandidates.map((c) => {
|
|
3692
|
-
const meta = metaMap.get(c.id);
|
|
3693
|
-
const importance = meta?.metadata.importance
|
|
3694
|
-
? Math.round(meta.metadata.importance * 10)
|
|
3695
|
-
: 5;
|
|
3696
|
-
return { id: c.id, text: c.text, importance };
|
|
3697
|
-
});
|
|
3698
|
-
pluginHotCache.setHotFacts(hotFacts);
|
|
3699
|
-
pluginHotCache.setFactCount(rerankerCandidates.length);
|
|
3700
|
-
pluginHotCache.flush();
|
|
3701
|
-
}
|
|
3702
|
-
}
|
|
3703
|
-
catch {
|
|
3704
|
-
// Hot cache update is best-effort -- don't fail the recall.
|
|
3705
|
-
}
|
|
3706
|
-
}
|
|
3707
|
-
else {
|
|
3708
|
-
// --- Server search path (existing behavior) ---
|
|
3709
|
-
const factCount = await getFactCount(api.logger);
|
|
3710
|
-
const pool = computeCandidatePool(factCount);
|
|
3711
|
-
const candidates = await apiClient.search(userId, allTrapdoors, pool, authKeyHex);
|
|
3712
|
-
for (const candidate of candidates) {
|
|
3713
|
-
try {
|
|
3714
|
-
const docJson = decryptFromHex(candidate.encrypted_blob, encryptionKey);
|
|
3715
|
-
if (isDigestBlob(docJson))
|
|
3716
|
-
continue;
|
|
3717
|
-
const doc = readClaimFromBlob(docJson);
|
|
3718
|
-
let decryptedEmbedding;
|
|
3719
|
-
if (candidate.encrypted_embedding) {
|
|
3720
|
-
try {
|
|
3721
|
-
decryptedEmbedding = JSON.parse(decryptFromHex(candidate.encrypted_embedding, encryptionKey));
|
|
3722
|
-
}
|
|
3723
|
-
catch {
|
|
3724
|
-
// Embedding decryption failed -- proceed without it.
|
|
3725
|
-
}
|
|
3726
|
-
}
|
|
3727
|
-
if (decryptedEmbedding && decryptedEmbedding.length !== getEmbeddingDims()) {
|
|
3728
|
-
try {
|
|
3729
|
-
decryptedEmbedding = await generateEmbedding(doc.text);
|
|
3730
|
-
}
|
|
3731
|
-
catch {
|
|
3732
|
-
decryptedEmbedding = undefined;
|
|
3733
|
-
}
|
|
3734
|
-
}
|
|
3735
|
-
rerankerCandidates.push({
|
|
3736
|
-
id: candidate.fact_id,
|
|
3737
|
-
text: doc.text,
|
|
3738
|
-
embedding: decryptedEmbedding,
|
|
3739
|
-
importance: doc.importance / 10,
|
|
3740
|
-
createdAt: typeof candidate.timestamp === 'number'
|
|
3741
|
-
? candidate.timestamp / 1000
|
|
3742
|
-
: new Date(candidate.timestamp).getTime() / 1000,
|
|
3743
|
-
source: typeof doc.metadata?.source === 'string' ? doc.metadata.source : undefined,
|
|
3744
|
-
});
|
|
3745
|
-
metaMap.set(candidate.fact_id, {
|
|
3746
|
-
metadata: doc.metadata ?? {},
|
|
3747
|
-
timestamp: candidate.timestamp,
|
|
3748
|
-
category: doc.category,
|
|
3749
|
-
});
|
|
3750
|
-
}
|
|
3751
|
-
catch {
|
|
3752
|
-
// Skip candidates we cannot decrypt (e.g. corrupted data).
|
|
3753
|
-
}
|
|
3754
|
-
}
|
|
3755
|
-
}
|
|
3756
|
-
// 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
|
|
3757
|
-
const queryIntent = detectQueryIntent(params.query);
|
|
3758
|
-
const reranked = rerank(params.query, queryEmbedding ?? [], rerankerCandidates, k, INTENT_WEIGHTS[queryIntent],
|
|
3759
|
-
/* applySourceWeights (Retrieval v2 Tier 1) */ true);
|
|
3760
|
-
if (reranked.length === 0) {
|
|
3761
|
-
return {
|
|
3762
|
-
content: [{ type: 'text', text: 'No memories found matching your query.' }],
|
|
3763
|
-
details: { count: 0, memories: [] },
|
|
3764
|
-
};
|
|
3765
|
-
}
|
|
3766
|
-
// 6b. Relevance gate removed in rc.22 -- core's intent-weighted
|
|
3767
|
-
// RRF + Tier 1 source weighting handles short queries via the
|
|
3768
|
-
// BM25 component, making the rc.18 cosine + lexical-override
|
|
3769
|
-
// band-aid (issue #116) redundant.
|
|
3770
|
-
// 7. Format results.
|
|
3771
|
-
const lines = reranked.map((m, i) => {
|
|
3772
|
-
const meta = metaMap.get(m.id);
|
|
3773
|
-
const imp = meta?.metadata.importance
|
|
3774
|
-
? ` (importance: ${Math.round(meta.metadata.importance * 10)}/10)`
|
|
3775
|
-
: '';
|
|
3776
|
-
const age = meta ? relativeTime(meta.timestamp) : '';
|
|
3777
|
-
const typeTag = meta?.category ? `[${meta.category}] ` : '';
|
|
3778
|
-
return `${i + 1}. ${typeTag}${m.text}${imp} -- ${age} [ID: ${m.id}]`;
|
|
3779
|
-
});
|
|
3780
|
-
const formatted = lines.join('\n');
|
|
3781
|
-
return {
|
|
3782
|
-
content: [{ type: 'text', text: formatted }],
|
|
3783
|
-
details: {
|
|
3784
|
-
count: reranked.length,
|
|
3785
|
-
memories: reranked.map((m) => ({
|
|
3786
|
-
factId: m.id,
|
|
3787
|
-
text: m.text,
|
|
3788
|
-
})),
|
|
3789
|
-
},
|
|
3790
|
-
};
|
|
3791
|
-
}
|
|
3792
|
-
catch (err) {
|
|
3793
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3794
|
-
api.logger.error(`totalreclaw_recall failed: ${message}`);
|
|
3795
|
-
return {
|
|
3796
|
-
content: [{ type: 'text', text: `Failed to search memories: ${humanizeError(message)}` }],
|
|
3797
|
-
};
|
|
3798
|
-
}
|
|
3799
|
-
},
|
|
3800
|
-
}, { name: 'totalreclaw_recall' });
|
|
3801
|
-
// ---------------------------------------------------------------
|
|
3802
|
-
// Tool: totalreclaw_forget
|
|
3803
|
-
// ---------------------------------------------------------------
|
|
3804
|
-
api.registerTool({
|
|
3805
|
-
name: 'totalreclaw_forget',
|
|
3806
|
-
label: 'Forget',
|
|
3807
|
-
description: 'Delete a specific memory. Use when the user asks to forget, delete, or remove ' +
|
|
3808
|
-
'something specific (e.g. "forget that I live in Porto", "delete the memory about my old job"). ' +
|
|
3809
|
-
'Writes an on-chain tombstone — the delete is permanent and propagates across all devices. ' +
|
|
3810
|
-
'If the user names the memory in natural language instead of an ID, FIRST call ' +
|
|
3811
|
-
'`totalreclaw_recall` with their phrase as the query, then pass the top result\'s `id` as ' +
|
|
3812
|
-
'`factId`. Non-reversible.',
|
|
3813
|
-
parameters: {
|
|
3814
|
-
type: 'object',
|
|
3815
|
-
properties: {
|
|
3816
|
-
factId: {
|
|
3817
|
-
type: 'string',
|
|
3818
|
-
description: 'The UUID of the memory to delete. Get this from a prior `totalreclaw_recall` result — ' +
|
|
3819
|
-
'the `memories[i].id` field. Never invent a factId; if you don\'t have one, call recall first.',
|
|
3820
|
-
},
|
|
3821
|
-
},
|
|
3822
|
-
required: ['factId'],
|
|
3823
|
-
additionalProperties: false,
|
|
3824
|
-
},
|
|
3825
|
-
async execute(_toolCallId, params) {
|
|
3826
|
-
try {
|
|
3827
|
-
await requireFullSetup(api.logger);
|
|
3828
|
-
// Validate factId shape BEFORE any on-chain work. Prevents
|
|
3829
|
-
// silent no-op when the LLM fabricates a non-UUID factId —
|
|
3830
|
-
// the classic failure mode from 3.3.1-rc.1 QA where the
|
|
3831
|
-
// agent replied "Done" without calling the tool at all, OR
|
|
3832
|
-
// called the tool with a plain natural-language string.
|
|
3833
|
-
const factId = typeof params.factId === 'string' ? params.factId.trim() : '';
|
|
3834
|
-
if (!factId) {
|
|
3835
|
-
return {
|
|
3836
|
-
content: [{
|
|
3837
|
-
type: 'text',
|
|
3838
|
-
text: 'Cannot forget without a memory ID. Call `totalreclaw_recall` first with ' +
|
|
3839
|
-
'the user\'s phrasing as the query — the top result\'s `id` field is the ' +
|
|
3840
|
-
'factId to pass here.',
|
|
3841
|
-
}],
|
|
3842
|
-
details: { deleted: false, error: 'missing-fact-id' },
|
|
3843
|
-
};
|
|
3844
|
-
}
|
|
3845
|
-
// UUID-v4-ish shape check (loose — accepts any hex-dashed id).
|
|
3846
|
-
// Prevents cases like `factId: "that I live in Porto"` from
|
|
3847
|
-
// reaching the UserOp path and silently failing on-chain.
|
|
3848
|
-
const looksLikeFactId = /^[0-9a-f-]{8,}$/i.test(factId);
|
|
3849
|
-
if (!looksLikeFactId) {
|
|
3850
|
-
api.logger.warn(`totalreclaw_forget: rejected likely-invalid factId "${factId.slice(0, 40)}" ` +
|
|
3851
|
-
`— expected a UUID from a prior recall result, not natural language.`);
|
|
3852
|
-
return {
|
|
3853
|
-
content: [{
|
|
3854
|
-
type: 'text',
|
|
3855
|
-
text: `"${factId.slice(0, 60)}" doesn\'t look like a memory ID. Call ` +
|
|
3856
|
-
'`totalreclaw_recall` first with the user\'s phrasing as the query, then ' +
|
|
3857
|
-
'pass the top result\'s `id` field (a hex UUID) as `factId`.',
|
|
3858
|
-
}],
|
|
3859
|
-
details: { deleted: false, error: 'invalid-fact-id' },
|
|
3860
|
-
};
|
|
3861
|
-
}
|
|
3862
|
-
if (isSubgraphMode()) {
|
|
3863
|
-
// On-chain tombstone: write a minimal protobuf with decayScore=0
|
|
3864
|
-
// The subgraph picks this up and sets isActive=false.
|
|
3865
|
-
//
|
|
3866
|
-
// 3.3.1-rc.2 fix: route through submitFactBatchOnChain with a
|
|
3867
|
-
// single-payload batch so we share the tombstone codepath the
|
|
3868
|
-
// pin/unpin flow uses (that flow is known-good and the QA
|
|
3869
|
-
// confirms pin works). Also write at legacy v3 (NOT v4) so the
|
|
3870
|
-
// subgraph handler matches the source="tombstone" + version=3
|
|
3871
|
-
// shape the contradiction/pin tombstones use.
|
|
3872
|
-
const config = { ...getSubgraphConfig(), authKeyHex: authKeyHex, walletAddress: subgraphOwner ?? undefined };
|
|
3873
|
-
const tombstone = {
|
|
3874
|
-
id: factId,
|
|
3875
|
-
timestamp: new Date().toISOString(),
|
|
3876
|
-
owner: subgraphOwner || userId,
|
|
3877
|
-
encryptedBlob: '00', // minimal 1-byte placeholder
|
|
3878
|
-
blindIndices: [],
|
|
3879
|
-
decayScore: 0,
|
|
3880
|
-
source: 'tombstone',
|
|
3881
|
-
contentFp: '',
|
|
3882
|
-
agentId: 'openclaw-plugin-forget',
|
|
3883
|
-
// Deliberately NO version: field → uses the default (legacy v3).
|
|
3884
|
-
// The pin/unpin tombstones use v3 (see pin.ts:611-621) — we
|
|
3885
|
-
// MUST match that shape or the subgraph may not flip isActive.
|
|
3886
|
-
};
|
|
3887
|
-
const protobuf = encodeFactProtobuf(tombstone);
|
|
3888
|
-
const result = await submitFactBatchOnChain([protobuf], config);
|
|
3889
|
-
if (!result.success) {
|
|
3890
|
-
throw new Error(`On-chain tombstone failed (tx=${result.txHash?.slice(0, 10) || 'none'}…)`);
|
|
3891
|
-
}
|
|
3892
|
-
api.logger.info(`Tombstone written for ${factId}: tx=${result.txHash}`);
|
|
3893
|
-
// Read-after-write: poll the subgraph until the original fact id
|
|
3894
|
-
// is no longer active (forget flips isActive=false). On timeout
|
|
3895
|
-
// surface `partial: true` so the agent can explain the chain
|
|
3896
|
-
// write succeeded but the subgraph is still propagating.
|
|
3897
|
-
const confirm = await confirmIndexed(factId, {
|
|
3898
|
-
expect: 'inactive',
|
|
3899
|
-
authKeyHex: authKeyHex,
|
|
3900
|
-
});
|
|
3901
|
-
return {
|
|
3902
|
-
content: [{
|
|
3903
|
-
type: 'text',
|
|
3904
|
-
text: confirm.indexed
|
|
3905
|
-
? `Memory ${factId} deleted on-chain and confirmed by the subgraph (tx: ${result.txHash}).`
|
|
3906
|
-
: `Memory ${factId} deleted on-chain (tx: ${result.txHash}). ` +
|
|
3907
|
-
'The subgraph indexer is still propagating the change — ' +
|
|
3908
|
-
'recall/export may briefly show the memory as still active.',
|
|
3909
|
-
}],
|
|
3910
|
-
details: {
|
|
3911
|
-
deleted: true,
|
|
3912
|
-
txHash: result.txHash,
|
|
3913
|
-
factId,
|
|
3914
|
-
...(confirm.indexed ? {} : { partial: true }),
|
|
3915
|
-
},
|
|
3916
|
-
};
|
|
3917
|
-
}
|
|
3918
|
-
else {
|
|
3919
|
-
await apiClient.deleteFact(factId, authKeyHex);
|
|
3920
|
-
return {
|
|
3921
|
-
content: [{ type: 'text', text: `Memory ${factId} deleted` }],
|
|
3922
|
-
details: { deleted: true, factId },
|
|
3923
|
-
};
|
|
3924
|
-
}
|
|
3925
|
-
}
|
|
3926
|
-
catch (err) {
|
|
3927
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3928
|
-
api.logger.error(`totalreclaw_forget failed: ${message}`);
|
|
3929
|
-
return {
|
|
3930
|
-
content: [{ type: 'text', text: `Failed to delete memory: ${humanizeError(message)}` }],
|
|
3931
|
-
};
|
|
3932
|
-
}
|
|
3933
|
-
},
|
|
3934
|
-
}, { name: 'totalreclaw_forget' });
|
|
3935
|
-
// ---------------------------------------------------------------
|
|
3936
|
-
// Tool: totalreclaw_export
|
|
3937
|
-
// ---------------------------------------------------------------
|
|
3938
|
-
api.registerTool({
|
|
3939
|
-
name: 'totalreclaw_export',
|
|
3940
|
-
label: 'Export',
|
|
3941
|
-
description: 'Export all stored memories. Decrypts every memory and returns them as JSON or Markdown.',
|
|
3942
|
-
parameters: {
|
|
3943
|
-
type: 'object',
|
|
3944
|
-
properties: {
|
|
3945
|
-
format: {
|
|
3946
|
-
type: 'string',
|
|
3947
|
-
enum: ['json', 'markdown'],
|
|
3948
|
-
description: 'Output format (default: json)',
|
|
3949
|
-
},
|
|
3950
|
-
},
|
|
3951
|
-
additionalProperties: false,
|
|
3952
|
-
},
|
|
3953
|
-
async execute(_toolCallId, params) {
|
|
3954
|
-
try {
|
|
3955
|
-
await requireFullSetup(api.logger);
|
|
3956
|
-
const format = params.format ?? 'json';
|
|
3957
|
-
// Paginate through all facts.
|
|
3958
|
-
const allFacts = [];
|
|
3959
|
-
if (isSubgraphMode()) {
|
|
3960
|
-
// Query subgraph for all active facts (cursor-based pagination via id_gt)
|
|
3961
|
-
const config = getSubgraphConfig();
|
|
3962
|
-
const relayUrl = config.relayUrl;
|
|
3963
|
-
const PAGE_SIZE = 1000;
|
|
3964
|
-
let lastId = '';
|
|
3965
|
-
const owner = subgraphOwner || userId || '';
|
|
3966
|
-
console.error(`[TotalReclaw Export] owner=${owner} subgraphOwner=${subgraphOwner} userId=${userId} relayUrl=${relayUrl} authKey=${authKeyHex ? authKeyHex.slice(0, 8) + '...' : 'MISSING'} isSubgraph=${isSubgraphMode()}`);
|
|
3967
|
-
while (true) {
|
|
3968
|
-
const hasLastId = lastId !== '';
|
|
3969
|
-
const query = hasLastId
|
|
3970
|
-
? `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}}`
|
|
3971
|
-
: `query($owner:Bytes!,$first:Int!){facts(where:{owner:$owner,isActive:true},first:$first,orderBy:id,orderDirection:asc){id encryptedBlob timestamp sequenceId}}`;
|
|
3972
|
-
const variables = hasLastId
|
|
3973
|
-
? { owner, first: PAGE_SIZE, lastId }
|
|
3974
|
-
: { owner, first: PAGE_SIZE };
|
|
3975
|
-
const res = await fetch(`${relayUrl}/v1/subgraph`, {
|
|
3976
|
-
method: 'POST',
|
|
3977
|
-
headers: buildRelayHeaders({
|
|
3978
|
-
'Content-Type': 'application/json',
|
|
3979
|
-
...(authKeyHex ? { Authorization: `Bearer ${authKeyHex}` } : {}),
|
|
3980
|
-
}),
|
|
3981
|
-
body: JSON.stringify({ query, variables }),
|
|
3982
|
-
});
|
|
3983
|
-
const json = (await res.json());
|
|
3984
|
-
// Surface relay/subgraph errors instead of silently returning empty
|
|
3985
|
-
if (json.error || json.errors) {
|
|
3986
|
-
const errMsg = json.error || json.errors?.map(e => e.message).join('; ') || 'Unknown error';
|
|
3987
|
-
api.logger.error(`Export subgraph query failed: ${errMsg} (owner=${owner}, status=${res.status})`);
|
|
3988
|
-
return {
|
|
3989
|
-
content: [{ type: 'text', text: `Export failed: ${errMsg}` }],
|
|
3990
|
-
};
|
|
3991
|
-
}
|
|
3992
|
-
const facts = json?.data?.facts || [];
|
|
3993
|
-
if (facts.length === 0)
|
|
3994
|
-
break;
|
|
3995
|
-
for (const fact of facts) {
|
|
3996
|
-
try {
|
|
3997
|
-
let hexBlob = fact.encryptedBlob;
|
|
3998
|
-
if (hexBlob.startsWith('0x'))
|
|
3999
|
-
hexBlob = hexBlob.slice(2);
|
|
4000
|
-
const docJson = decryptFromHex(hexBlob, encryptionKey);
|
|
4001
|
-
if (isDigestBlob(docJson))
|
|
4002
|
-
continue;
|
|
4003
|
-
const doc = readClaimFromBlob(docJson);
|
|
4004
|
-
allFacts.push({
|
|
4005
|
-
id: fact.id,
|
|
4006
|
-
text: doc.text,
|
|
4007
|
-
metadata: doc.metadata,
|
|
4008
|
-
created_at: new Date(parseInt(fact.timestamp) * 1000).toISOString(),
|
|
4009
|
-
});
|
|
4010
|
-
}
|
|
4011
|
-
catch {
|
|
4012
|
-
// Skip facts we cannot decrypt
|
|
4013
|
-
}
|
|
4014
|
-
}
|
|
4015
|
-
if (facts.length < PAGE_SIZE)
|
|
4016
|
-
break;
|
|
4017
|
-
lastId = facts[facts.length - 1].id;
|
|
4018
|
-
}
|
|
4019
|
-
}
|
|
4020
|
-
else {
|
|
4021
|
-
// HTTP server mode — paginate through PostgreSQL facts
|
|
4022
|
-
let cursor;
|
|
4023
|
-
let hasMore = true;
|
|
4024
|
-
while (hasMore) {
|
|
4025
|
-
const page = await apiClient.exportFacts(authKeyHex, 1000, cursor);
|
|
4026
|
-
for (const fact of page.facts) {
|
|
4027
|
-
try {
|
|
4028
|
-
const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey);
|
|
4029
|
-
if (isDigestBlob(docJson))
|
|
4030
|
-
continue;
|
|
4031
|
-
const doc = readClaimFromBlob(docJson);
|
|
4032
|
-
allFacts.push({
|
|
4033
|
-
id: fact.id,
|
|
4034
|
-
text: doc.text,
|
|
4035
|
-
metadata: doc.metadata,
|
|
4036
|
-
created_at: fact.created_at,
|
|
4037
|
-
});
|
|
4038
|
-
}
|
|
4039
|
-
catch {
|
|
4040
|
-
// Skip facts we cannot decrypt.
|
|
4041
|
-
}
|
|
4042
|
-
}
|
|
4043
|
-
cursor = page.cursor ?? undefined;
|
|
4044
|
-
hasMore = page.has_more;
|
|
4045
|
-
}
|
|
4046
|
-
}
|
|
4047
|
-
// Format output.
|
|
4048
|
-
let formatted;
|
|
4049
|
-
if (format === 'markdown') {
|
|
4050
|
-
if (allFacts.length === 0) {
|
|
4051
|
-
formatted = '*No memories stored.*';
|
|
4052
|
-
}
|
|
4053
|
-
else {
|
|
4054
|
-
const lines = allFacts.map((f, i) => {
|
|
4055
|
-
const meta = f.metadata;
|
|
4056
|
-
const type = meta.type ?? 'fact';
|
|
4057
|
-
const imp = meta.importance
|
|
4058
|
-
? ` (importance: ${Math.round(meta.importance * 10)}/10)`
|
|
4059
|
-
: '';
|
|
4060
|
-
return `${i + 1}. **[${type}]** ${f.text}${imp} \n _ID: ${f.id} | Created: ${f.created_at}_`;
|
|
4061
|
-
});
|
|
4062
|
-
formatted = `# Exported Memories (${allFacts.length})\n\n${lines.join('\n')}`;
|
|
4063
|
-
}
|
|
4064
|
-
}
|
|
4065
|
-
else {
|
|
4066
|
-
formatted = JSON.stringify(allFacts, null, 2);
|
|
4067
|
-
}
|
|
4068
|
-
return {
|
|
4069
|
-
content: [{ type: 'text', text: formatted }],
|
|
4070
|
-
details: { count: allFacts.length },
|
|
4071
|
-
};
|
|
4072
|
-
}
|
|
4073
|
-
catch (err) {
|
|
4074
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4075
|
-
api.logger.error(`totalreclaw_export failed: ${message}`);
|
|
4076
|
-
return {
|
|
4077
|
-
content: [{ type: 'text', text: `Failed to export memories: ${humanizeError(message)}` }],
|
|
4078
|
-
};
|
|
4079
|
-
}
|
|
4080
|
-
},
|
|
4081
|
-
}, { name: 'totalreclaw_export' });
|
|
4082
|
-
// ---------------------------------------------------------------
|
|
4083
|
-
// Tool: totalreclaw_status
|
|
4084
|
-
// ---------------------------------------------------------------
|
|
4085
|
-
api.registerTool({
|
|
4086
|
-
name: 'totalreclaw_status',
|
|
4087
|
-
label: 'Status',
|
|
4088
|
-
description: 'Check TotalReclaw billing and subscription status — tier, writes used, reset date.',
|
|
4089
|
-
parameters: {
|
|
4090
|
-
type: 'object',
|
|
4091
|
-
properties: {},
|
|
4092
|
-
additionalProperties: false,
|
|
4093
|
-
},
|
|
4094
|
-
async execute() {
|
|
4095
|
-
try {
|
|
4096
|
-
await requireFullSetup(api.logger);
|
|
4097
|
-
if (!authKeyHex) {
|
|
4098
|
-
return {
|
|
4099
|
-
content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
|
|
4100
|
-
};
|
|
4101
|
-
}
|
|
4102
|
-
const serverUrl = CONFIG.serverUrl;
|
|
4103
|
-
const walletAddr = subgraphOwner || userId || '';
|
|
4104
|
-
const response = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(walletAddr)}`, {
|
|
4105
|
-
method: 'GET',
|
|
4106
|
-
headers: buildRelayHeaders({
|
|
4107
|
-
'Authorization': `Bearer ${authKeyHex}`,
|
|
4108
|
-
'Accept': 'application/json',
|
|
4109
|
-
}),
|
|
4110
|
-
});
|
|
4111
|
-
if (!response.ok) {
|
|
4112
|
-
const body = await response.text().catch(() => '');
|
|
4113
|
-
return {
|
|
4114
|
-
content: [{ type: 'text', text: `Failed to fetch billing status (HTTP ${response.status}): ${body || response.statusText}` }],
|
|
4115
|
-
};
|
|
4116
|
-
}
|
|
4117
|
-
const data = await response.json();
|
|
4118
|
-
const tier = data.tier || 'free';
|
|
4119
|
-
const freeWritesUsed = data.free_writes_used ?? 0;
|
|
4120
|
-
const freeWritesLimit = data.free_writes_limit ?? 0;
|
|
4121
|
-
const freeWritesResetAt = data.free_writes_reset_at;
|
|
4122
|
-
// Update billing cache on success.
|
|
4123
|
-
writeBillingCache({
|
|
4124
|
-
tier,
|
|
4125
|
-
free_writes_used: freeWritesUsed,
|
|
4126
|
-
free_writes_limit: freeWritesLimit,
|
|
4127
|
-
features: data.features,
|
|
4128
|
-
checked_at: Date.now(),
|
|
4129
|
-
});
|
|
4130
|
-
// 3.3.1 (internal#130) — surface the Smart Account / scope
|
|
4131
|
-
// address so the user can do subgraph queries, BaseScan
|
|
4132
|
-
// lookups, and cross-client portability checks BEFORE any
|
|
4133
|
-
// chain write completes. Resolution priority:
|
|
4134
|
-
// 1. In-memory `subgraphOwner` (already derived earlier).
|
|
4135
|
-
// 2. credentials.json `scope_address` (persisted at pair).
|
|
4136
|
-
// 3. Lazy derive now from the loaded mnemonic + cache it
|
|
4137
|
-
// back to credentials.json so the next call is free.
|
|
4138
|
-
let scopeAddress = subgraphOwner ?? undefined;
|
|
4139
|
-
if (!scopeAddress) {
|
|
4140
|
-
try {
|
|
4141
|
-
const credsCache = loadCredentialsJson(CREDENTIALS_PATH);
|
|
4142
|
-
if (credsCache?.scope_address && typeof credsCache.scope_address === 'string') {
|
|
4143
|
-
scopeAddress = credsCache.scope_address;
|
|
4144
|
-
}
|
|
4145
|
-
else if (credsCache?.mnemonic && typeof credsCache.mnemonic === 'string') {
|
|
4146
|
-
scopeAddress = await deriveSmartAccountAddress(credsCache.mnemonic, CONFIG.chainId);
|
|
4147
|
-
if (scopeAddress) {
|
|
4148
|
-
writeCredentialsJson(CREDENTIALS_PATH, { ...credsCache, scope_address: scopeAddress });
|
|
4149
|
-
}
|
|
4150
|
-
}
|
|
4151
|
-
}
|
|
4152
|
-
catch (deriveErr) {
|
|
4153
|
-
api.logger.warn(`totalreclaw_status: scope_address lookup failed: ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`);
|
|
4154
|
-
}
|
|
4155
|
-
}
|
|
4156
|
-
const tierLabel = tier === 'pro' ? 'Pro' : 'Free';
|
|
4157
|
-
const lines = [
|
|
4158
|
-
`Tier: ${tierLabel}`,
|
|
4159
|
-
`Writes: ${freeWritesUsed}/${freeWritesLimit} used this month`,
|
|
4160
|
-
];
|
|
4161
|
-
if (freeWritesResetAt) {
|
|
4162
|
-
lines.push(`Resets: ${new Date(freeWritesResetAt).toLocaleDateString()}`);
|
|
4163
|
-
}
|
|
4164
|
-
if (scopeAddress) {
|
|
4165
|
-
lines.push(`Smart Account: ${scopeAddress}`);
|
|
4166
|
-
}
|
|
4167
|
-
if (tier !== 'pro') {
|
|
4168
|
-
lines.push(`Pricing: https://totalreclaw.xyz/pricing`);
|
|
4169
|
-
}
|
|
4170
|
-
return {
|
|
4171
|
-
content: [{ type: 'text', text: lines.join('\n') }],
|
|
4172
|
-
details: {
|
|
4173
|
-
tier,
|
|
4174
|
-
free_writes_used: freeWritesUsed,
|
|
4175
|
-
free_writes_limit: freeWritesLimit,
|
|
4176
|
-
scope_address: scopeAddress,
|
|
4177
|
-
},
|
|
4178
|
-
};
|
|
4179
|
-
}
|
|
4180
|
-
catch (err) {
|
|
4181
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4182
|
-
api.logger.error(`totalreclaw_status failed: ${message}`);
|
|
4183
|
-
return {
|
|
4184
|
-
content: [{ type: 'text', text: `Failed to check status: ${humanizeError(message)}` }],
|
|
4185
|
-
};
|
|
4186
|
-
}
|
|
4187
|
-
},
|
|
4188
|
-
}, { name: 'totalreclaw_status' });
|
|
4189
|
-
// ---------------------------------------------------------------
|
|
4190
|
-
// Tool: totalreclaw_preload_embedder (3.3.3-rc.1 — issue #187)
|
|
4191
|
-
// ---------------------------------------------------------------
|
|
4192
|
-
//
|
|
4193
|
-
// Decouples the ~700 MB embedder bundle download from the pair-
|
|
4194
|
-
// completion gate. The agent can call this BEFORE
|
|
4195
|
-
// `totalreclaw_pair` to pre-flight disk space, kick off the
|
|
4196
|
-
// download, and surface the completion state. The non-blocking
|
|
4197
|
-
// prefetch in `register()` already starts the download
|
|
4198
|
-
// unconditionally; this tool is an explicit on-demand hook for
|
|
4199
|
-
// agents that want to confirm completion (or trigger a retry on
|
|
4200
|
-
// network failure) without first completing pair.
|
|
4201
|
-
//
|
|
4202
|
-
// Behavior:
|
|
4203
|
-
// - Disk-space pre-flight: refuses if free disk < 500 MB on the
|
|
4204
|
-
// embedder cache mount. Surfaces the path + free bytes so the
|
|
4205
|
-
// user can clear space.
|
|
4206
|
-
// - Triggers `prefetchEmbedderBundle()` (idempotent — cache hit
|
|
4207
|
-
// returns immediately).
|
|
4208
|
-
// - Returns a structured success message with status:
|
|
4209
|
-
// `cache_hit | fetched | failed`.
|
|
4210
|
-
//
|
|
4211
|
-
// Phrase-safety: this tool does NOT touch credentials.json,
|
|
4212
|
-
// mnemonics, or keys. It only touches `~/.totalreclaw/embedder/`
|
|
4213
|
-
// and the GitHub Releases CDN.
|
|
4214
|
-
api.registerTool({
|
|
4215
|
-
name: 'totalreclaw_preload_embedder',
|
|
4216
|
-
label: 'Preload Embedder',
|
|
4217
|
-
description: '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/.',
|
|
4218
|
-
parameters: {
|
|
4219
|
-
type: 'object',
|
|
4220
|
-
properties: {},
|
|
4221
|
-
additionalProperties: false,
|
|
4222
|
-
},
|
|
4223
|
-
async execute() {
|
|
4224
|
-
try {
|
|
4225
|
-
// Disk-space pre-flight: refuse if < 500 MB free on the
|
|
4226
|
-
// embedder cache mount. Best-effort — if statfs fails, we
|
|
4227
|
-
// proceed without the pre-flight rather than blocking.
|
|
4228
|
-
const fsModule = await import('node:fs');
|
|
4229
|
-
const cacheRoot = CONFIG.embedderCachePath;
|
|
4230
|
-
const REQUIRED_BYTES = 500 * 1024 * 1024;
|
|
4231
|
-
try {
|
|
4232
|
-
// Find the deepest existing parent so statfs has a real
|
|
4233
|
-
// mount to measure (loadEmbedder will mkdir under it
|
|
4234
|
-
// anyway).
|
|
4235
|
-
let probeDir = cacheRoot;
|
|
4236
|
-
while (true) {
|
|
4237
|
-
try {
|
|
4238
|
-
fsModule.statSync(probeDir);
|
|
4239
|
-
break;
|
|
4240
|
-
}
|
|
4241
|
-
catch {
|
|
4242
|
-
const parent = nodePath.dirname(probeDir);
|
|
4243
|
-
if (parent === probeDir)
|
|
4244
|
-
break;
|
|
4245
|
-
probeDir = parent;
|
|
4246
|
-
}
|
|
4247
|
-
}
|
|
4248
|
-
const stats = fsModule.statfsSync?.(probeDir);
|
|
4249
|
-
if (stats) {
|
|
4250
|
-
const bavail = typeof stats.bavail === 'bigint' ? Number(stats.bavail) : stats.bavail;
|
|
4251
|
-
const bsize = typeof stats.bsize === 'bigint' ? Number(stats.bsize) : stats.bsize;
|
|
4252
|
-
const freeBytes = bavail * bsize;
|
|
4253
|
-
if (freeBytes > 0 && freeBytes < REQUIRED_BYTES) {
|
|
4254
|
-
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
|
4255
|
-
return {
|
|
4256
|
-
content: [{
|
|
4257
|
-
type: 'text',
|
|
4258
|
-
text: `Insufficient free disk space for embedder bundle. Required: 500 MB. Available at ${probeDir}: ${freeMb} MB. Free up space and retry.`,
|
|
4259
|
-
}],
|
|
4260
|
-
details: { status: 'failed', reason: 'disk_space', free_mb: freeMb, required_mb: 500, cache_root: cacheRoot },
|
|
4261
|
-
};
|
|
4262
|
-
}
|
|
4263
|
-
}
|
|
4264
|
-
}
|
|
4265
|
-
catch {
|
|
4266
|
-
// statfs probe failed — surface a soft warning in logs
|
|
4267
|
-
// but proceed with the download anyway.
|
|
4268
|
-
api.logger.info('totalreclaw_preload_embedder: disk-space probe unavailable, proceeding without pre-flight');
|
|
4269
|
-
}
|
|
4270
|
-
// Trigger the prefetch. This is idempotent (cache hit returns
|
|
4271
|
-
// immediately) so it's safe to invoke even when the
|
|
4272
|
-
// background prefetch from register() already completed.
|
|
4273
|
-
const result = await prefetchEmbedderBundle({
|
|
4274
|
-
log: (msg) => api.logger.info(msg),
|
|
4275
|
-
});
|
|
4276
|
-
const human = result === 'fetched'
|
|
4277
|
-
? `Embedder bundle downloaded and cached at ${cacheRoot}. Subsequent embedding calls run in-memory.`
|
|
4278
|
-
: `Embedder bundle already cached at ${cacheRoot} — no download needed.`;
|
|
4279
|
-
return {
|
|
4280
|
-
content: [{ type: 'text', text: human }],
|
|
4281
|
-
details: { status: result, cache_root: cacheRoot },
|
|
4282
|
-
};
|
|
4283
|
-
}
|
|
4284
|
-
catch (err) {
|
|
4285
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4286
|
-
api.logger.error(`totalreclaw_preload_embedder failed: ${message}`);
|
|
4287
|
-
return {
|
|
4288
|
-
content: [{
|
|
4289
|
-
type: 'text',
|
|
4290
|
-
text: `Embedder bundle preload failed: ${humanizeError(message)}. The plugin will retry on first embedding call.`,
|
|
4291
|
-
}],
|
|
4292
|
-
details: { status: 'failed', reason: 'fetch_error', message },
|
|
4293
|
-
};
|
|
4294
|
-
}
|
|
4295
|
-
},
|
|
4296
|
-
}, { name: 'totalreclaw_preload_embedder' });
|
|
4297
|
-
// ---------------------------------------------------------------
|
|
4298
|
-
// Tool: totalreclaw_consolidate
|
|
4299
|
-
// ---------------------------------------------------------------
|
|
4300
|
-
api.registerTool({
|
|
4301
|
-
name: 'totalreclaw_consolidate',
|
|
4302
|
-
label: 'Consolidate',
|
|
4303
|
-
description: 'Deduplicate and merge related memories. Self-hosted mode only.',
|
|
4304
|
-
parameters: {
|
|
4305
|
-
type: 'object',
|
|
4306
|
-
properties: {
|
|
4307
|
-
dry_run: {
|
|
4308
|
-
type: 'boolean',
|
|
4309
|
-
description: 'Preview only (default: false)',
|
|
4310
|
-
},
|
|
4311
|
-
},
|
|
4312
|
-
additionalProperties: false,
|
|
4313
|
-
},
|
|
4314
|
-
async execute(_toolCallId, params) {
|
|
4315
|
-
try {
|
|
4316
|
-
await requireFullSetup(api.logger);
|
|
4317
|
-
const dryRun = params.dry_run ?? false;
|
|
4318
|
-
// Consolidation is only available in centralized (HTTP server) mode.
|
|
4319
|
-
if (isSubgraphMode()) {
|
|
4320
|
-
return {
|
|
4321
|
-
content: [{ type: 'text', text: 'Consolidation is currently only available in centralized mode.' }],
|
|
4322
|
-
};
|
|
4323
|
-
}
|
|
4324
|
-
if (!apiClient || !authKeyHex || !encryptionKey) {
|
|
4325
|
-
return {
|
|
4326
|
-
content: [{ type: 'text', text: 'Plugin not fully initialized. Cannot consolidate.' }],
|
|
4327
|
-
};
|
|
4328
|
-
}
|
|
4329
|
-
// 1. Export all facts (paginated, max 10 pages of 1000).
|
|
4330
|
-
const allDecrypted = [];
|
|
4331
|
-
let cursor;
|
|
4332
|
-
let hasMore = true;
|
|
4333
|
-
let pageCount = 0;
|
|
4334
|
-
const MAX_PAGES = 10;
|
|
4335
|
-
while (hasMore && pageCount < MAX_PAGES) {
|
|
4336
|
-
const page = await apiClient.exportFacts(authKeyHex, 1000, cursor);
|
|
4337
|
-
for (const fact of page.facts) {
|
|
4338
|
-
try {
|
|
4339
|
-
const docJson = decryptFromHex(fact.encrypted_blob, encryptionKey);
|
|
4340
|
-
if (isDigestBlob(docJson))
|
|
4341
|
-
continue;
|
|
4342
|
-
const doc = readClaimFromBlob(docJson);
|
|
4343
|
-
let embedding = null;
|
|
4344
|
-
try {
|
|
4345
|
-
embedding = await generateEmbedding(doc.text);
|
|
4346
|
-
}
|
|
4347
|
-
catch { /* skip — fact will not be clustered */ }
|
|
4348
|
-
allDecrypted.push({
|
|
4349
|
-
id: fact.id,
|
|
4350
|
-
text: doc.text,
|
|
4351
|
-
embedding,
|
|
4352
|
-
importance: doc.importance,
|
|
4353
|
-
decayScore: fact.decay_score,
|
|
4354
|
-
createdAt: new Date(fact.created_at).getTime(),
|
|
4355
|
-
version: fact.version,
|
|
4356
|
-
});
|
|
4357
|
-
}
|
|
4358
|
-
catch {
|
|
4359
|
-
// Skip undecryptable facts.
|
|
4360
|
-
}
|
|
4361
|
-
}
|
|
4362
|
-
cursor = page.cursor ?? undefined;
|
|
4363
|
-
hasMore = page.has_more;
|
|
4364
|
-
pageCount++;
|
|
4365
|
-
}
|
|
4366
|
-
if (allDecrypted.length === 0) {
|
|
4367
|
-
return {
|
|
4368
|
-
content: [{ type: 'text', text: 'No memories found to consolidate.' }],
|
|
4369
|
-
};
|
|
4370
|
-
}
|
|
4371
|
-
// 2. Cluster by cosine similarity.
|
|
4372
|
-
const clusters = clusterFacts(allDecrypted, getConsolidationThreshold());
|
|
4373
|
-
if (clusters.length === 0) {
|
|
4374
|
-
return {
|
|
4375
|
-
content: [{ type: 'text', text: `Scanned ${allDecrypted.length} memories — no near-duplicates found.` }],
|
|
4376
|
-
};
|
|
4377
|
-
}
|
|
4378
|
-
// 3. Build report.
|
|
4379
|
-
const totalDuplicates = clusters.reduce((sum, c) => sum + c.duplicates.length, 0);
|
|
4380
|
-
const reportLines = [
|
|
4381
|
-
`Scanned ${allDecrypted.length} memories.`,
|
|
4382
|
-
`Found ${clusters.length} cluster(s) with ${totalDuplicates} duplicate(s).`,
|
|
4383
|
-
'',
|
|
4384
|
-
];
|
|
4385
|
-
const displayClusters = clusters.slice(0, 10);
|
|
4386
|
-
for (let i = 0; i < displayClusters.length; i++) {
|
|
4387
|
-
const cluster = displayClusters[i];
|
|
4388
|
-
reportLines.push(`Cluster ${i + 1}: KEEP "${cluster.representative.text.slice(0, 80)}…"`);
|
|
4389
|
-
for (const dup of cluster.duplicates) {
|
|
4390
|
-
reportLines.push(` - REMOVE "${dup.text.slice(0, 80)}…" (ID: ${dup.id})`);
|
|
4391
|
-
}
|
|
4392
|
-
}
|
|
4393
|
-
if (clusters.length > 10) {
|
|
4394
|
-
reportLines.push(`... and ${clusters.length - 10} more cluster(s).`);
|
|
4395
|
-
}
|
|
4396
|
-
// 4. If not dry_run, batch-delete duplicates.
|
|
4397
|
-
if (!dryRun) {
|
|
4398
|
-
const idsToDelete = clusters.flatMap((c) => c.duplicates.map((d) => d.id));
|
|
4399
|
-
const BATCH_SIZE = 500;
|
|
4400
|
-
let totalDeleted = 0;
|
|
4401
|
-
for (let i = 0; i < idsToDelete.length; i += BATCH_SIZE) {
|
|
4402
|
-
const batch = idsToDelete.slice(i, i + BATCH_SIZE);
|
|
4403
|
-
const deleted = await apiClient.batchDelete(batch, authKeyHex);
|
|
4404
|
-
totalDeleted += deleted;
|
|
4405
|
-
}
|
|
4406
|
-
reportLines.push('');
|
|
4407
|
-
reportLines.push(`Deleted ${totalDeleted} duplicate memories.`);
|
|
4408
|
-
}
|
|
4409
|
-
else {
|
|
4410
|
-
reportLines.push('');
|
|
4411
|
-
reportLines.push('DRY RUN — no memories were deleted. Run without dry_run to apply.');
|
|
4412
|
-
}
|
|
4413
|
-
return {
|
|
4414
|
-
content: [{ type: 'text', text: reportLines.join('\n') }],
|
|
4415
|
-
details: {
|
|
4416
|
-
scanned: allDecrypted.length,
|
|
4417
|
-
clusters: clusters.length,
|
|
4418
|
-
duplicates: totalDuplicates,
|
|
4419
|
-
dry_run: dryRun,
|
|
4420
|
-
},
|
|
4421
|
-
};
|
|
4422
|
-
}
|
|
4423
|
-
catch (err) {
|
|
4424
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4425
|
-
api.logger.error(`totalreclaw_consolidate failed: ${message}`);
|
|
4426
|
-
return {
|
|
4427
|
-
content: [{ type: 'text', text: `Failed to consolidate memories: ${humanizeError(message)}` }],
|
|
4428
|
-
};
|
|
4429
|
-
}
|
|
4430
|
-
},
|
|
4431
|
-
}, { name: 'totalreclaw_consolidate' });
|
|
4432
|
-
// ---------------------------------------------------------------
|
|
4433
|
-
// Helper: build PinOpDeps bound to the live plugin state
|
|
4434
|
-
// ---------------------------------------------------------------
|
|
4435
|
-
// Wires the pure pin/unpin operation to the managed-service transport +
|
|
4436
|
-
// crypto layer. Mirrors MCP's buildPinDepsFromState and Python's
|
|
4437
|
-
// _change_claim_status argument plumbing.
|
|
4438
|
-
const buildPinDeps = () => {
|
|
4439
|
-
const owner = subgraphOwner || userId || '';
|
|
4440
|
-
const config = {
|
|
4441
|
-
...getSubgraphConfig(),
|
|
4442
|
-
authKeyHex: authKeyHex,
|
|
4443
|
-
walletAddress: subgraphOwner ?? undefined,
|
|
4444
|
-
};
|
|
4445
|
-
return {
|
|
4446
|
-
owner,
|
|
4447
|
-
sourceAgent: 'openclaw-plugin',
|
|
4448
|
-
fetchFactById: (factId) => fetchFactById(owner, factId, authKeyHex),
|
|
4449
|
-
decryptBlob: (hex) => decryptFromHex(hex, encryptionKey),
|
|
4450
|
-
encryptBlob: (plaintext) => encryptToHex(plaintext, encryptionKey),
|
|
4451
|
-
submitBatch: async (payloads) => {
|
|
4452
|
-
const result = await submitFactBatchOnChain(payloads, config);
|
|
4453
|
-
return { txHash: result.txHash, success: result.success };
|
|
4454
|
-
},
|
|
4455
|
-
generateIndices: async (text, entityNames) => {
|
|
4456
|
-
if (!text)
|
|
4457
|
-
return { blindIndices: [] };
|
|
4458
|
-
const wordIndices = generateBlindIndices(text);
|
|
4459
|
-
let lshIndices = [];
|
|
4460
|
-
let encryptedEmbedding;
|
|
4461
|
-
try {
|
|
4462
|
-
const embedding = await generateEmbedding(text);
|
|
4463
|
-
const hasher = getLSHHasher(api.logger);
|
|
4464
|
-
if (hasher)
|
|
4465
|
-
lshIndices = hasher.hash(embedding);
|
|
4466
|
-
encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey);
|
|
4467
|
-
}
|
|
4468
|
-
catch {
|
|
4469
|
-
// Best-effort: word + entity trapdoors alone still surface the claim.
|
|
4470
|
-
}
|
|
4471
|
-
const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
|
|
4472
|
-
return {
|
|
4473
|
-
blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
|
|
4474
|
-
encryptedEmbedding,
|
|
4475
|
-
};
|
|
4476
|
-
},
|
|
4477
|
-
};
|
|
4478
|
-
};
|
|
4479
|
-
// ---------------------------------------------------------------
|
|
4480
|
-
// Tool: totalreclaw_pin
|
|
4481
|
-
// ---------------------------------------------------------------
|
|
4482
|
-
api.registerTool({
|
|
4483
|
-
name: 'totalreclaw_pin',
|
|
4484
|
-
label: 'Pin',
|
|
4485
|
-
description: 'Pin a memory so the auto-resolution engine will never override or supersede it. ' +
|
|
4486
|
-
"Use when the user explicitly confirms a claim is still valid after you or another agent " +
|
|
4487
|
-
"tried to retract/contradict it (e.g. 'wait, I still use Vim sometimes'). " +
|
|
4488
|
-
'Takes fact_id (from a prior recall result). Pinning is idempotent — pinning an already-pinned ' +
|
|
4489
|
-
'claim is a no-op. Cross-device: the pin propagates via the on-chain supersession chain.',
|
|
4490
|
-
parameters: {
|
|
4491
|
-
type: 'object',
|
|
4492
|
-
properties: {
|
|
4493
|
-
fact_id: {
|
|
4494
|
-
type: 'string',
|
|
4495
|
-
description: 'The ID of the fact to pin (from a totalreclaw_recall result).',
|
|
4496
|
-
},
|
|
4497
|
-
reason: {
|
|
4498
|
-
type: 'string',
|
|
4499
|
-
description: 'Optional human-readable reason for pinning (logged locally for tuning).',
|
|
4500
|
-
},
|
|
4501
|
-
},
|
|
4502
|
-
required: ['fact_id'],
|
|
4503
|
-
additionalProperties: false,
|
|
4504
|
-
},
|
|
4505
|
-
async execute(_toolCallId, params) {
|
|
4506
|
-
try {
|
|
4507
|
-
await requireFullSetup(api.logger);
|
|
4508
|
-
if (!isSubgraphMode()) {
|
|
4509
|
-
return {
|
|
4510
|
-
content: [{
|
|
4511
|
-
type: 'text',
|
|
4512
|
-
text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
|
|
4513
|
-
}],
|
|
4514
|
-
};
|
|
4515
|
-
}
|
|
4516
|
-
const validation = validatePinArgs(params);
|
|
4517
|
-
if (!validation.ok) {
|
|
4518
|
-
return { content: [{ type: 'text', text: validation.error }] };
|
|
4519
|
-
}
|
|
4520
|
-
const deps = buildPinDeps();
|
|
4521
|
-
const result = await executePinOperation(validation.factId, 'pinned', deps, validation.reason);
|
|
4522
|
-
if (result.success && result.idempotent) {
|
|
4523
|
-
api.logger.info(`totalreclaw_pin: ${result.fact_id} already pinned (no-op)`);
|
|
4524
|
-
return {
|
|
4525
|
-
content: [{ type: 'text', text: `Memory ${result.fact_id} is already pinned.` }],
|
|
4526
|
-
details: result,
|
|
4527
|
-
};
|
|
4528
|
-
}
|
|
4529
|
-
if (result.success) {
|
|
4530
|
-
api.logger.info(`totalreclaw_pin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
|
|
4531
|
-
return {
|
|
4532
|
-
content: [{
|
|
4533
|
-
type: 'text',
|
|
4534
|
-
text: `Pinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
|
|
4535
|
-
}],
|
|
4536
|
-
details: result,
|
|
4537
|
-
};
|
|
4538
|
-
}
|
|
4539
|
-
api.logger.error(`totalreclaw_pin failed: ${result.error}`);
|
|
4540
|
-
return {
|
|
4541
|
-
content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
|
|
4542
|
-
details: result,
|
|
4543
|
-
};
|
|
4544
|
-
}
|
|
4545
|
-
catch (err) {
|
|
4546
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4547
|
-
api.logger.error(`totalreclaw_pin failed: ${message}`);
|
|
4548
|
-
return {
|
|
4549
|
-
content: [{ type: 'text', text: `Failed to pin memory: ${humanizeError(message)}` }],
|
|
4550
|
-
};
|
|
4551
|
-
}
|
|
4552
|
-
},
|
|
4553
|
-
}, { name: 'totalreclaw_pin' });
|
|
4554
|
-
// ---------------------------------------------------------------
|
|
4555
|
-
// Tool: totalreclaw_unpin
|
|
4556
|
-
// ---------------------------------------------------------------
|
|
4557
|
-
api.registerTool({
|
|
4558
|
-
name: 'totalreclaw_unpin',
|
|
4559
|
-
label: 'Unpin',
|
|
4560
|
-
description: 'Remove the pin from a previously pinned memory, returning it to active status so the ' +
|
|
4561
|
-
'auto-resolution engine can supersede or retract it again. Takes fact_id. Idempotent — ' +
|
|
4562
|
-
'unpinning a non-pinned claim is a no-op.',
|
|
4563
|
-
parameters: {
|
|
4564
|
-
type: 'object',
|
|
4565
|
-
properties: {
|
|
4566
|
-
fact_id: {
|
|
4567
|
-
type: 'string',
|
|
4568
|
-
description: 'The ID of the fact to unpin (from a totalreclaw_recall result).',
|
|
4569
|
-
},
|
|
4570
|
-
},
|
|
4571
|
-
required: ['fact_id'],
|
|
4572
|
-
additionalProperties: false,
|
|
4573
|
-
},
|
|
4574
|
-
async execute(_toolCallId, params) {
|
|
4575
|
-
try {
|
|
4576
|
-
await requireFullSetup(api.logger);
|
|
4577
|
-
if (!isSubgraphMode()) {
|
|
4578
|
-
return {
|
|
4579
|
-
content: [{
|
|
4580
|
-
type: 'text',
|
|
4581
|
-
text: 'Pin/unpin is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
|
|
4582
|
-
}],
|
|
4583
|
-
};
|
|
4584
|
-
}
|
|
4585
|
-
const validation = validatePinArgs(params);
|
|
4586
|
-
if (!validation.ok) {
|
|
4587
|
-
return { content: [{ type: 'text', text: validation.error }] };
|
|
4588
|
-
}
|
|
4589
|
-
const deps = buildPinDeps();
|
|
4590
|
-
const result = await executePinOperation(validation.factId, 'active', deps);
|
|
4591
|
-
if (result.success && result.idempotent) {
|
|
4592
|
-
api.logger.info(`totalreclaw_unpin: ${result.fact_id} already active (no-op)`);
|
|
4593
|
-
return {
|
|
4594
|
-
content: [{ type: 'text', text: `Memory ${result.fact_id} is not pinned.` }],
|
|
4595
|
-
details: result,
|
|
4596
|
-
};
|
|
4597
|
-
}
|
|
4598
|
-
if (result.success) {
|
|
4599
|
-
api.logger.info(`totalreclaw_unpin: ${result.fact_id} → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
|
|
4600
|
-
return {
|
|
4601
|
-
content: [{
|
|
4602
|
-
type: 'text',
|
|
4603
|
-
text: `Unpinned memory ${result.fact_id}. New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
|
|
4604
|
-
}],
|
|
4605
|
-
details: result,
|
|
4606
|
-
};
|
|
4607
|
-
}
|
|
4608
|
-
api.logger.error(`totalreclaw_unpin failed: ${result.error}`);
|
|
4609
|
-
return {
|
|
4610
|
-
content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(result.error ?? 'unknown error')}` }],
|
|
4611
|
-
details: result,
|
|
4612
|
-
};
|
|
4613
|
-
}
|
|
4614
|
-
catch (err) {
|
|
4615
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4616
|
-
api.logger.error(`totalreclaw_unpin failed: ${message}`);
|
|
4617
|
-
return {
|
|
4618
|
-
content: [{ type: 'text', text: `Failed to unpin memory: ${humanizeError(message)}` }],
|
|
4619
|
-
};
|
|
4620
|
-
}
|
|
4621
|
-
},
|
|
4622
|
-
}, { name: 'totalreclaw_unpin' });
|
|
4623
|
-
// ---------------------------------------------------------------
|
|
4624
|
-
// Shared deps for retype + set_scope (same shape as pin deps).
|
|
4625
|
-
// Built lazily so the closure captures the current encryption key /
|
|
4626
|
-
// subgraph owner at call time rather than at register() time.
|
|
4627
|
-
// ---------------------------------------------------------------
|
|
4628
|
-
const buildRetypeSetScopeDeps = () => {
|
|
4629
|
-
const owner = subgraphOwner || userId || '';
|
|
4630
|
-
const config = {
|
|
4631
|
-
...getSubgraphConfig(),
|
|
4632
|
-
authKeyHex: authKeyHex,
|
|
4633
|
-
walletAddress: subgraphOwner ?? undefined,
|
|
4634
|
-
};
|
|
4635
|
-
return {
|
|
4636
|
-
owner,
|
|
4637
|
-
sourceAgent: 'openclaw-plugin',
|
|
4638
|
-
fetchFactById: (factId) => fetchFactById(owner, factId, authKeyHex),
|
|
4639
|
-
decryptBlob: (hex) => decryptFromHex(hex, encryptionKey),
|
|
4640
|
-
encryptBlob: (plaintext) => encryptToHex(plaintext, encryptionKey),
|
|
4641
|
-
submitBatch: async (payloads) => {
|
|
4642
|
-
const result = await submitFactBatchOnChain(payloads, config);
|
|
4643
|
-
return { txHash: result.txHash, success: result.success };
|
|
4644
|
-
},
|
|
4645
|
-
generateIndices: async (text, entityNames) => {
|
|
4646
|
-
if (!text)
|
|
4647
|
-
return { blindIndices: [] };
|
|
4648
|
-
const wordIndices = generateBlindIndices(text);
|
|
4649
|
-
let lshIndices = [];
|
|
4650
|
-
let encryptedEmbedding;
|
|
4651
|
-
try {
|
|
4652
|
-
const embedding = await generateEmbedding(text);
|
|
4653
|
-
const hasher = getLSHHasher(api.logger);
|
|
4654
|
-
if (hasher)
|
|
4655
|
-
lshIndices = hasher.hash(embedding);
|
|
4656
|
-
encryptedEmbedding = encryptToHex(JSON.stringify(embedding), encryptionKey);
|
|
4657
|
-
}
|
|
4658
|
-
catch {
|
|
4659
|
-
// Best-effort: word + entity trapdoors alone still surface the claim.
|
|
4660
|
-
}
|
|
4661
|
-
const entityTrapdoors = entityNames.map((n) => computeEntityTrapdoor(n));
|
|
4662
|
-
return {
|
|
4663
|
-
blindIndices: [...wordIndices, ...lshIndices, ...entityTrapdoors],
|
|
4664
|
-
encryptedEmbedding,
|
|
4665
|
-
};
|
|
4666
|
-
},
|
|
4667
|
-
};
|
|
4668
|
-
};
|
|
4669
|
-
// ---------------------------------------------------------------
|
|
4670
|
-
// Tool: totalreclaw_retype (3.3.1-rc.2 — agent-facing taxonomy edit)
|
|
4671
|
-
// ---------------------------------------------------------------
|
|
4672
|
-
api.registerTool({
|
|
4673
|
-
name: 'totalreclaw_retype',
|
|
4674
|
-
label: 'Retype',
|
|
4675
|
-
description: 'Reclassify an existing memory from one taxonomy type to another (claim / preference / ' +
|
|
4676
|
-
'directive / commitment / episode / summary). Use when the user corrects a memory\'s ' +
|
|
4677
|
-
'category — e.g. "that\'s actually a preference, not a fact" or "file this as a ' +
|
|
4678
|
-
'commitment, not a claim". Writes a new v1.1 blob with the updated type and tombstones ' +
|
|
4679
|
-
'the old fact on-chain.\n\n' +
|
|
4680
|
-
'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
|
|
4681
|
-
'find the fact_id, then pass it here with the new type.',
|
|
4682
|
-
parameters: {
|
|
4683
|
-
type: 'object',
|
|
4684
|
-
properties: {
|
|
4685
|
-
fact_id: {
|
|
4686
|
-
type: 'string',
|
|
4687
|
-
description: 'The UUID of the memory to reclassify. Get this from a prior ' +
|
|
4688
|
-
'`totalreclaw_recall` result.',
|
|
4689
|
-
},
|
|
4690
|
-
new_type: {
|
|
4691
|
-
type: 'string',
|
|
4692
|
-
enum: ['claim', 'preference', 'directive', 'commitment', 'episode', 'summary'],
|
|
4693
|
-
description: 'The new taxonomy type. claim=factual statement, preference=opinion/like/dislike, ' +
|
|
4694
|
-
'directive=instruction, commitment=promise/plan, episode=event, summary=aggregate.',
|
|
4695
|
-
},
|
|
4696
|
-
},
|
|
4697
|
-
required: ['fact_id', 'new_type'],
|
|
4698
|
-
additionalProperties: false,
|
|
4699
|
-
},
|
|
4700
|
-
async execute(_toolCallId, params) {
|
|
4701
|
-
try {
|
|
4702
|
-
await requireFullSetup(api.logger);
|
|
4703
|
-
if (!isSubgraphMode()) {
|
|
4704
|
-
return {
|
|
4705
|
-
content: [{
|
|
4706
|
-
type: 'text',
|
|
4707
|
-
text: 'Retype is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
|
|
4708
|
-
}],
|
|
4709
|
-
};
|
|
4710
|
-
}
|
|
4711
|
-
const validation = validateRetypeArgs(params);
|
|
4712
|
-
if (!validation.ok) {
|
|
4713
|
-
return { content: [{ type: 'text', text: validation.error }] };
|
|
4714
|
-
}
|
|
4715
|
-
const deps = buildRetypeSetScopeDeps();
|
|
4716
|
-
const result = await executeRetype(validation.factId, validation.newType, deps);
|
|
4717
|
-
if (result.success) {
|
|
4718
|
-
api.logger.info(`totalreclaw_retype: ${result.fact_id} (${result.previous_type} → ${result.new_type}) → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
|
|
4719
|
-
return {
|
|
4720
|
-
content: [{
|
|
4721
|
-
type: 'text',
|
|
4722
|
-
text: `Retyped memory ${result.fact_id} from ${result.previous_type} to ${result.new_type}. ` +
|
|
4723
|
-
`New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
|
|
4724
|
-
}],
|
|
4725
|
-
details: result,
|
|
4726
|
-
};
|
|
4727
|
-
}
|
|
4728
|
-
api.logger.error(`totalreclaw_retype failed: ${result.error}`);
|
|
4729
|
-
return {
|
|
4730
|
-
content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(result.error ?? 'unknown error')}` }],
|
|
4731
|
-
details: result,
|
|
4732
|
-
};
|
|
4733
|
-
}
|
|
4734
|
-
catch (err) {
|
|
4735
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4736
|
-
api.logger.error(`totalreclaw_retype failed: ${message}`);
|
|
4737
|
-
return {
|
|
4738
|
-
content: [{ type: 'text', text: `Failed to retype memory: ${humanizeError(message)}` }],
|
|
4739
|
-
};
|
|
4740
|
-
}
|
|
4741
|
-
},
|
|
4742
|
-
}, { name: 'totalreclaw_retype' });
|
|
4743
|
-
// ---------------------------------------------------------------
|
|
4744
|
-
// Tool: totalreclaw_set_scope (3.3.1-rc.2 — agent-facing scope edit)
|
|
4745
|
-
// ---------------------------------------------------------------
|
|
4746
|
-
api.registerTool({
|
|
4747
|
-
name: 'totalreclaw_set_scope',
|
|
4748
|
-
label: 'Set Scope',
|
|
4749
|
-
description: 'Move an existing memory to a different scope (work / personal / health / family / ' +
|
|
4750
|
-
'creative / finance / misc / unspecified). Use when the user re-categorizes a memory\'s ' +
|
|
4751
|
-
'domain — e.g. "put that under work", "this is a health thing", "move this to personal". ' +
|
|
4752
|
-
'Writes a new v1.1 blob with the updated scope and tombstones the old fact on-chain.\n\n' +
|
|
4753
|
-
'If the user names the memory in natural language, FIRST call `totalreclaw_recall` to ' +
|
|
4754
|
-
'find the fact_id, then pass it here with the new scope.',
|
|
4755
|
-
parameters: {
|
|
4756
|
-
type: 'object',
|
|
4757
|
-
properties: {
|
|
4758
|
-
fact_id: {
|
|
4759
|
-
type: 'string',
|
|
4760
|
-
description: 'The UUID of the memory to rescope. Get this from a prior `totalreclaw_recall` result.',
|
|
4761
|
-
},
|
|
4762
|
-
new_scope: {
|
|
4763
|
-
type: 'string',
|
|
4764
|
-
enum: ['work', 'personal', 'health', 'family', 'creative', 'finance', 'misc', 'unspecified'],
|
|
4765
|
-
description: 'The new scope. Used for filtered recall — e.g. "recall work-related memories only".',
|
|
4766
|
-
},
|
|
4767
|
-
},
|
|
4768
|
-
required: ['fact_id', 'new_scope'],
|
|
4769
|
-
additionalProperties: false,
|
|
4770
|
-
},
|
|
4771
|
-
async execute(_toolCallId, params) {
|
|
4772
|
-
try {
|
|
4773
|
-
await requireFullSetup(api.logger);
|
|
4774
|
-
if (!isSubgraphMode()) {
|
|
4775
|
-
return {
|
|
4776
|
-
content: [{
|
|
4777
|
-
type: 'text',
|
|
4778
|
-
text: 'Set-scope is only supported with the managed service. Self-hosted mode does not yet implement the status-flip supersession flow.',
|
|
4779
|
-
}],
|
|
4780
|
-
};
|
|
4781
|
-
}
|
|
4782
|
-
const validation = validateSetScopeArgs(params);
|
|
4783
|
-
if (!validation.ok) {
|
|
4784
|
-
return { content: [{ type: 'text', text: validation.error }] };
|
|
4785
|
-
}
|
|
4786
|
-
const deps = buildRetypeSetScopeDeps();
|
|
4787
|
-
const result = await executeSetScope(validation.factId, validation.newScope, deps);
|
|
4788
|
-
if (result.success) {
|
|
4789
|
-
api.logger.info(`totalreclaw_set_scope: ${result.fact_id} (${result.previous_scope ?? 'unspecified'} → ${result.new_scope}) → ${result.new_fact_id} (tx ${result.tx_hash?.slice(0, 10)})`);
|
|
4790
|
-
return {
|
|
4791
|
-
content: [{
|
|
4792
|
-
type: 'text',
|
|
4793
|
-
text: `Moved memory ${result.fact_id} from scope "${result.previous_scope ?? 'unspecified'}" to "${result.new_scope}". ` +
|
|
4794
|
-
`New fact id: ${result.new_fact_id} (tx: ${result.tx_hash}).`,
|
|
4795
|
-
}],
|
|
4796
|
-
details: result,
|
|
4797
|
-
};
|
|
4798
|
-
}
|
|
4799
|
-
api.logger.error(`totalreclaw_set_scope failed: ${result.error}`);
|
|
4800
|
-
return {
|
|
4801
|
-
content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(result.error ?? 'unknown error')}` }],
|
|
4802
|
-
details: result,
|
|
4803
|
-
};
|
|
4804
|
-
}
|
|
4805
|
-
catch (err) {
|
|
4806
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4807
|
-
api.logger.error(`totalreclaw_set_scope failed: ${message}`);
|
|
4808
|
-
return {
|
|
4809
|
-
content: [{ type: 'text', text: `Failed to set scope: ${humanizeError(message)}` }],
|
|
4810
|
-
};
|
|
4811
|
-
}
|
|
4812
|
-
},
|
|
4813
|
-
}, { name: 'totalreclaw_set_scope' });
|
|
4814
|
-
// ---------------------------------------------------------------
|
|
4815
|
-
// Tool: totalreclaw_import_from
|
|
4816
|
-
// ---------------------------------------------------------------
|
|
4817
|
-
api.registerTool({
|
|
4818
|
-
name: 'totalreclaw_import_from',
|
|
4819
|
-
label: 'Import From',
|
|
4820
|
-
description: 'Import memories from other AI memory tools (Mem0, MCP Memory Server, ChatGPT, Claude, Gemini, MemoClaw, or generic JSON/CSV). ' +
|
|
4821
|
-
'Provide the source name and either an API key, file content, or file path. ' +
|
|
4822
|
-
'Use dry_run=true to preview before importing. Idempotent — safe to run multiple times.',
|
|
4823
|
-
parameters: {
|
|
4824
|
-
type: 'object',
|
|
4825
|
-
properties: {
|
|
4826
|
-
source: {
|
|
4827
|
-
type: 'string',
|
|
4828
|
-
enum: ['mem0', 'mcp-memory', 'chatgpt', 'claude', 'gemini'],
|
|
4829
|
-
description: 'The source system to import from (gemini: Google Takeout HTML; chatgpt: conversations.json or memory text; claude: memory text)',
|
|
4830
|
-
},
|
|
4831
|
-
api_key: {
|
|
4832
|
-
type: 'string',
|
|
4833
|
-
description: 'API key for the source system (used once, never stored)',
|
|
4834
|
-
},
|
|
4835
|
-
source_user_id: {
|
|
4836
|
-
type: 'string',
|
|
4837
|
-
description: 'User or agent ID in the source system',
|
|
4838
|
-
},
|
|
4839
|
-
content: {
|
|
4840
|
-
type: 'string',
|
|
4841
|
-
description: 'File content (JSON, JSONL, or CSV)',
|
|
4842
|
-
},
|
|
4843
|
-
file_path: {
|
|
4844
|
-
type: 'string',
|
|
4845
|
-
description: 'Path to the file on disk',
|
|
4846
|
-
},
|
|
4847
|
-
namespace: {
|
|
4848
|
-
type: 'string',
|
|
4849
|
-
description: 'Target namespace (default: "imported")',
|
|
4850
|
-
},
|
|
4851
|
-
dry_run: {
|
|
4852
|
-
type: 'boolean',
|
|
4853
|
-
description: 'Preview without importing',
|
|
4854
|
-
},
|
|
4855
|
-
},
|
|
4856
|
-
required: ['source'],
|
|
4857
|
-
},
|
|
4858
|
-
async execute(_toolCallId, params) {
|
|
4859
|
-
try {
|
|
4860
|
-
await requireFullSetup(api.logger);
|
|
4861
|
-
return handlePluginImportFrom(params, api.logger);
|
|
4862
|
-
}
|
|
4863
|
-
catch (err) {
|
|
4864
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4865
|
-
return { error: message };
|
|
4866
|
-
}
|
|
4867
|
-
},
|
|
4868
|
-
}, { name: 'totalreclaw_import_from' });
|
|
4869
|
-
// ---------------------------------------------------------------
|
|
4870
|
-
// Tool: totalreclaw_import_batch
|
|
4871
|
-
// ---------------------------------------------------------------
|
|
4872
|
-
api.registerTool({
|
|
4873
|
-
name: 'totalreclaw_import_batch',
|
|
4874
|
-
label: 'Import Batch',
|
|
4875
|
-
description: 'Process one batch of a large import. Call repeatedly with increasing offset until is_complete=true.',
|
|
4876
|
-
parameters: {
|
|
4877
|
-
type: 'object',
|
|
4878
|
-
properties: {
|
|
4879
|
-
source: {
|
|
4880
|
-
type: 'string',
|
|
4881
|
-
enum: ['gemini', 'chatgpt', 'claude'],
|
|
4882
|
-
description: 'Source format',
|
|
4883
|
-
},
|
|
4884
|
-
file_path: {
|
|
4885
|
-
type: 'string',
|
|
4886
|
-
description: 'Path to source file',
|
|
4887
|
-
},
|
|
4888
|
-
content: {
|
|
4889
|
-
type: 'string',
|
|
4890
|
-
description: 'File content (text sources)',
|
|
4891
|
-
},
|
|
4892
|
-
offset: {
|
|
4893
|
-
type: 'number',
|
|
4894
|
-
description: 'Starting chunk index (0-based)',
|
|
4895
|
-
},
|
|
4896
|
-
batch_size: {
|
|
4897
|
-
type: 'number',
|
|
4898
|
-
description: 'Chunks per call (default 25)',
|
|
4899
|
-
},
|
|
4900
|
-
},
|
|
4901
|
-
required: ['source'],
|
|
4902
|
-
},
|
|
4903
|
-
async execute(_toolCallId, params) {
|
|
4904
|
-
try {
|
|
4905
|
-
await requireFullSetup(api.logger);
|
|
4906
|
-
return handleBatchImport(params, api.logger);
|
|
4907
|
-
}
|
|
4908
|
-
catch (err) {
|
|
4909
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4910
|
-
return { error: message };
|
|
4911
|
-
}
|
|
4912
|
-
},
|
|
4913
|
-
}, { name: 'totalreclaw_import_batch' });
|
|
4914
|
-
// ---------------------------------------------------------------
|
|
4915
|
-
// Tool: totalreclaw_import_status
|
|
4916
|
-
// ---------------------------------------------------------------
|
|
4917
|
-
api.registerTool({
|
|
4918
|
-
name: 'totalreclaw_import_status',
|
|
4919
|
-
label: 'Import Status',
|
|
4920
|
-
description: 'Check the progress of a background import. ' +
|
|
4921
|
-
'If import_id is omitted, returns the most recent active import. ' +
|
|
4922
|
-
'Returns status (running/completed/failed/aborted), batch progress, facts stored, and ETA. ' +
|
|
4923
|
-
'Use this when the user asks "how\'s the import?" or "is it done yet?".',
|
|
4924
|
-
parameters: {
|
|
4925
|
-
type: 'object',
|
|
4926
|
-
properties: {
|
|
4927
|
-
import_id: {
|
|
4928
|
-
type: 'string',
|
|
4929
|
-
description: 'The import ID returned by totalreclaw_import_from. Omit for most recent active import.',
|
|
4930
|
-
},
|
|
4931
|
-
},
|
|
4932
|
-
additionalProperties: false,
|
|
4933
|
-
},
|
|
4934
|
-
async execute(_toolCallId, params) {
|
|
4935
|
-
try {
|
|
4936
|
-
await requireFullSetup(api.logger);
|
|
4937
|
-
return handleImportStatus(params, api.logger);
|
|
4938
|
-
}
|
|
4939
|
-
catch (err) {
|
|
4940
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4941
|
-
return { error: message };
|
|
4942
|
-
}
|
|
4943
|
-
},
|
|
4944
|
-
}, { name: 'totalreclaw_import_status' });
|
|
4945
|
-
// ---------------------------------------------------------------
|
|
4946
|
-
// Tool: totalreclaw_import_abort
|
|
4947
|
-
// ---------------------------------------------------------------
|
|
4948
|
-
api.registerTool({
|
|
4949
|
-
name: 'totalreclaw_import_abort',
|
|
4950
|
-
label: 'Abort Import',
|
|
4951
|
-
description: 'Cancel a running background import. Already-stored facts are kept (import is idempotent via fingerprint dedup). ' +
|
|
4952
|
-
'The background task will stop at the next chunk boundary after receiving the abort signal. ' +
|
|
4953
|
-
'Use when the user says "stop the import" or "cancel the import".',
|
|
4954
|
-
parameters: {
|
|
4955
|
-
type: 'object',
|
|
4956
|
-
properties: {
|
|
4957
|
-
import_id: {
|
|
4958
|
-
type: 'string',
|
|
4959
|
-
description: 'The import ID to abort (from totalreclaw_import_from or totalreclaw_import_status).',
|
|
4960
|
-
},
|
|
4961
|
-
},
|
|
4962
|
-
required: ['import_id'],
|
|
4963
|
-
additionalProperties: false,
|
|
4964
|
-
},
|
|
4965
|
-
async execute(_toolCallId, params) {
|
|
4966
|
-
try {
|
|
4967
|
-
await requireFullSetup(api.logger);
|
|
4968
|
-
return handleImportAbort(params, api.logger);
|
|
4969
|
-
}
|
|
4970
|
-
catch (err) {
|
|
4971
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4972
|
-
return { error: message };
|
|
4973
|
-
}
|
|
4974
|
-
},
|
|
4975
|
-
}, { name: 'totalreclaw_import_abort' });
|
|
4976
|
-
// ---------------------------------------------------------------
|
|
4977
|
-
// Tool: totalreclaw_upgrade
|
|
4978
|
-
// ---------------------------------------------------------------
|
|
4979
|
-
api.registerTool({
|
|
4980
|
-
name: 'totalreclaw_upgrade',
|
|
4981
|
-
label: 'Upgrade to Pro',
|
|
4982
|
-
description: 'Upgrade to TotalReclaw Pro for unlimited encrypted memories. ' +
|
|
4983
|
-
'Returns a Stripe checkout URL for the user to complete payment via credit/debit card.',
|
|
4984
|
-
parameters: {
|
|
4985
|
-
type: 'object',
|
|
4986
|
-
properties: {},
|
|
4987
|
-
additionalProperties: false,
|
|
4988
|
-
},
|
|
4989
|
-
async execute() {
|
|
4990
|
-
try {
|
|
4991
|
-
await requireFullSetup(api.logger);
|
|
4992
|
-
if (!authKeyHex) {
|
|
4993
|
-
return {
|
|
4994
|
-
content: [{ type: 'text', text: 'Auth credentials are not available. Please initialize first.' }],
|
|
4995
|
-
};
|
|
4996
|
-
}
|
|
4997
|
-
const serverUrl = CONFIG.serverUrl;
|
|
4998
|
-
const walletAddr = subgraphOwner || userId || '';
|
|
4999
|
-
if (!walletAddr) {
|
|
5000
|
-
return {
|
|
5001
|
-
content: [{ type: 'text', text: 'Wallet address not available. Please ensure the plugin is fully initialized.' }],
|
|
5002
|
-
};
|
|
5003
|
-
}
|
|
5004
|
-
const response = await fetch(`${serverUrl}/v1/billing/checkout`, {
|
|
5005
|
-
method: 'POST',
|
|
5006
|
-
headers: buildRelayHeaders({
|
|
5007
|
-
'Authorization': `Bearer ${authKeyHex}`,
|
|
5008
|
-
'Content-Type': 'application/json',
|
|
5009
|
-
}),
|
|
5010
|
-
body: JSON.stringify({
|
|
5011
|
-
wallet_address: walletAddr,
|
|
5012
|
-
tier: 'pro',
|
|
5013
|
-
}),
|
|
5014
|
-
});
|
|
5015
|
-
if (!response.ok) {
|
|
5016
|
-
const body = await response.text().catch(() => '');
|
|
5017
|
-
return {
|
|
5018
|
-
content: [{ type: 'text', text: `Failed to create checkout session (HTTP ${response.status}): ${body || response.statusText}` }],
|
|
5019
|
-
};
|
|
5020
|
-
}
|
|
5021
|
-
const data = await response.json();
|
|
5022
|
-
if (!data.checkout_url) {
|
|
5023
|
-
return {
|
|
5024
|
-
content: [{ type: 'text', text: 'Failed to create checkout session: no checkout URL returned.' }],
|
|
5025
|
-
};
|
|
5026
|
-
}
|
|
5027
|
-
return {
|
|
5028
|
-
content: [{ type: 'text', text: `Open this URL to upgrade to Pro: ${data.checkout_url}` }],
|
|
5029
|
-
details: { checkout_url: data.checkout_url },
|
|
5030
|
-
};
|
|
5031
|
-
}
|
|
5032
|
-
catch (err) {
|
|
5033
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5034
|
-
api.logger.error(`totalreclaw_upgrade failed: ${message}`);
|
|
5035
|
-
return {
|
|
5036
|
-
content: [{ type: 'text', text: `Failed to create checkout session: ${humanizeError(message)}` }],
|
|
5037
|
-
};
|
|
5038
|
-
}
|
|
5039
|
-
},
|
|
5040
|
-
}, { name: 'totalreclaw_upgrade' });
|
|
5041
|
-
// ---------------------------------------------------------------
|
|
5042
|
-
// Tool: totalreclaw_migrate
|
|
5043
|
-
// ---------------------------------------------------------------
|
|
5044
|
-
api.registerTool({
|
|
5045
|
-
name: 'totalreclaw_migrate',
|
|
5046
|
-
label: 'Migrate Testnet to Mainnet',
|
|
5047
|
-
description: 'Migrate memories from testnet (Base Sepolia) to mainnet (Gnosis) after upgrading to Pro. ' +
|
|
5048
|
-
'Dry-run by default — set confirm=true to execute. Idempotent: re-running skips already-migrated facts.',
|
|
5049
|
-
parameters: {
|
|
5050
|
-
type: 'object',
|
|
5051
|
-
properties: {
|
|
5052
|
-
confirm: {
|
|
5053
|
-
type: 'boolean',
|
|
5054
|
-
description: 'Set to true to execute the migration. Without it, returns a dry-run preview.',
|
|
5055
|
-
default: false,
|
|
5056
|
-
},
|
|
5057
|
-
},
|
|
5058
|
-
additionalProperties: false,
|
|
5059
|
-
},
|
|
5060
|
-
async execute(_params) {
|
|
5061
|
-
try {
|
|
5062
|
-
await requireFullSetup(api.logger);
|
|
5063
|
-
if (!authKeyHex || !subgraphOwner) {
|
|
5064
|
-
return {
|
|
5065
|
-
content: [{ type: 'text', text: 'Plugin not fully initialized. Ensure TOTALRECLAW_RECOVERY_PHRASE is set.' }],
|
|
5066
|
-
};
|
|
5067
|
-
}
|
|
5068
|
-
if (!isSubgraphMode()) {
|
|
5069
|
-
return {
|
|
5070
|
-
content: [{ type: 'text', text: 'Migration is only available with the managed service (subgraph mode).' }],
|
|
5071
|
-
};
|
|
5072
|
-
}
|
|
5073
|
-
const confirm = _params?.confirm === true;
|
|
5074
|
-
const serverUrl = CONFIG.serverUrl;
|
|
5075
|
-
// 1. Check billing tier
|
|
5076
|
-
const billingResp = await fetch(`${serverUrl}/v1/billing/status?wallet_address=${encodeURIComponent(subgraphOwner)}`, {
|
|
5077
|
-
method: 'GET',
|
|
5078
|
-
headers: buildRelayHeaders({
|
|
5079
|
-
'Authorization': `Bearer ${authKeyHex}`,
|
|
5080
|
-
'Content-Type': 'application/json',
|
|
5081
|
-
}),
|
|
5082
|
-
});
|
|
5083
|
-
if (!billingResp.ok) {
|
|
5084
|
-
return { content: [{ type: 'text', text: `Failed to check billing tier (HTTP ${billingResp.status}).` }] };
|
|
5085
|
-
}
|
|
5086
|
-
const billingData = await billingResp.json();
|
|
5087
|
-
if (billingData.tier !== 'pro') {
|
|
5088
|
-
return {
|
|
5089
|
-
content: [{ type: 'text', text: 'Migration requires Pro tier. Use totalreclaw_upgrade to upgrade first.' }],
|
|
5090
|
-
};
|
|
5091
|
-
}
|
|
5092
|
-
// 2. Fetch testnet facts via relay (chain=testnet query param)
|
|
5093
|
-
const testnetSubgraphUrl = `${serverUrl}/v1/subgraph?chain=testnet`;
|
|
5094
|
-
const mainnetSubgraphUrl = `${serverUrl}/v1/subgraph`;
|
|
5095
|
-
api.logger.info('Fetching testnet facts...');
|
|
5096
|
-
const testnetFacts = await fetchAllFactsByOwner(testnetSubgraphUrl, subgraphOwner, authKeyHex);
|
|
5097
|
-
if (testnetFacts.length === 0) {
|
|
5098
|
-
return {
|
|
5099
|
-
content: [{ type: 'text', text: 'No facts found on testnet. Nothing to migrate.' }],
|
|
5100
|
-
};
|
|
5101
|
-
}
|
|
5102
|
-
// 3. Check mainnet for existing facts (idempotency)
|
|
5103
|
-
api.logger.info('Checking mainnet for existing facts...');
|
|
5104
|
-
const mainnetFps = await fetchContentFingerprintsByOwner(mainnetSubgraphUrl, subgraphOwner, authKeyHex);
|
|
5105
|
-
const factsToMigrate = testnetFacts.filter(f => !f.contentFp || !mainnetFps.has(f.contentFp));
|
|
5106
|
-
const alreadyOnMainnet = testnetFacts.length - factsToMigrate.length;
|
|
5107
|
-
// 4. Dry-run
|
|
5108
|
-
if (!confirm) {
|
|
5109
|
-
const msg = factsToMigrate.length === 0
|
|
5110
|
-
? `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.`
|
|
5111
|
-
: `Found ${factsToMigrate.length} facts to migrate from testnet to Gnosis mainnet (${alreadyOnMainnet} already on mainnet). Call with confirm=true to proceed.`;
|
|
5112
|
-
return {
|
|
5113
|
-
content: [{ type: 'text', text: msg }],
|
|
5114
|
-
details: {
|
|
5115
|
-
mode: 'dry_run',
|
|
5116
|
-
testnet_facts: testnetFacts.length,
|
|
5117
|
-
already_on_mainnet: alreadyOnMainnet,
|
|
5118
|
-
to_migrate: factsToMigrate.length,
|
|
5119
|
-
},
|
|
5120
|
-
};
|
|
5121
|
-
}
|
|
5122
|
-
// 5. Execute migration
|
|
5123
|
-
if (factsToMigrate.length === 0) {
|
|
5124
|
-
return {
|
|
5125
|
-
content: [{ type: 'text', text: `All ${testnetFacts.length} testnet facts already exist on mainnet. Nothing to migrate.` }],
|
|
5126
|
-
};
|
|
5127
|
-
}
|
|
5128
|
-
// Fetch blind indices
|
|
5129
|
-
api.logger.info(`Fetching blind indices for ${factsToMigrate.length} facts...`);
|
|
5130
|
-
const factIds = factsToMigrate.map(f => f.id);
|
|
5131
|
-
const blindIndicesMap = await fetchBlindIndicesByFactIds(testnetSubgraphUrl, factIds, authKeyHex);
|
|
5132
|
-
// Build protobuf payloads
|
|
5133
|
-
const payloads = [];
|
|
5134
|
-
for (const fact of factsToMigrate) {
|
|
5135
|
-
const blobHex = fact.encryptedBlob.startsWith('0x') ? fact.encryptedBlob.slice(2) : fact.encryptedBlob;
|
|
5136
|
-
const indices = blindIndicesMap.get(fact.id) || [];
|
|
5137
|
-
const factPayload = {
|
|
5138
|
-
id: fact.id,
|
|
5139
|
-
timestamp: new Date().toISOString(),
|
|
5140
|
-
owner: subgraphOwner,
|
|
5141
|
-
encryptedBlob: blobHex,
|
|
5142
|
-
blindIndices: indices,
|
|
5143
|
-
decayScore: parseFloat(fact.decayScore) || 0.5,
|
|
5144
|
-
source: fact.source || 'migration',
|
|
5145
|
-
contentFp: fact.contentFp || '',
|
|
5146
|
-
agentId: fact.agentId || 'openclaw-plugin',
|
|
5147
|
-
encryptedEmbedding: fact.encryptedEmbedding || undefined,
|
|
5148
|
-
version: PROTOBUF_VERSION_V4,
|
|
5149
|
-
};
|
|
5150
|
-
payloads.push(encodeFactProtobuf(factPayload));
|
|
5151
|
-
}
|
|
5152
|
-
// Batch submit (15 per UserOp)
|
|
5153
|
-
const BATCH_SIZE = 15;
|
|
5154
|
-
const batchConfig = { ...getSubgraphConfig(), authKeyHex: authKeyHex, walletAddress: subgraphOwner ?? undefined };
|
|
5155
|
-
let migrated = 0;
|
|
5156
|
-
let failedBatches = 0;
|
|
5157
|
-
for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
|
|
5158
|
-
const batch = payloads.slice(i, i + BATCH_SIZE);
|
|
5159
|
-
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
|
5160
|
-
const totalBatches = Math.ceil(payloads.length / BATCH_SIZE);
|
|
5161
|
-
api.logger.info(`Migrating batch ${batchNum}/${totalBatches} (${batch.length} facts)...`);
|
|
5162
|
-
try {
|
|
5163
|
-
const result = await submitFactBatchOnChain(batch, batchConfig);
|
|
5164
|
-
if (result.success) {
|
|
5165
|
-
migrated += batch.length;
|
|
5166
|
-
}
|
|
5167
|
-
else {
|
|
5168
|
-
failedBatches++;
|
|
5169
|
-
}
|
|
5170
|
-
}
|
|
5171
|
-
catch (err) {
|
|
5172
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
5173
|
-
api.logger.error(`Migration batch ${batchNum} failed: ${msg}`);
|
|
5174
|
-
failedBatches++;
|
|
5175
|
-
}
|
|
5176
|
-
}
|
|
5177
|
-
const resultMsg = failedBatches === 0
|
|
5178
|
-
? `Successfully migrated ${migrated} memories from testnet to Gnosis mainnet.`
|
|
5179
|
-
: `Migrated ${migrated}/${factsToMigrate.length} memories. ${failedBatches} batch(es) failed — re-run to retry (idempotent).`;
|
|
5180
|
-
return {
|
|
5181
|
-
content: [{ type: 'text', text: resultMsg }],
|
|
5182
|
-
details: {
|
|
5183
|
-
mode: 'executed',
|
|
5184
|
-
testnet_facts: testnetFacts.length,
|
|
5185
|
-
already_on_mainnet: alreadyOnMainnet,
|
|
5186
|
-
to_migrate: factsToMigrate.length,
|
|
5187
|
-
migrated,
|
|
5188
|
-
failed_batches: failedBatches,
|
|
5189
|
-
},
|
|
5190
|
-
};
|
|
5191
|
-
}
|
|
5192
|
-
catch (err) {
|
|
5193
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5194
|
-
api.logger.error(`totalreclaw_migrate failed: ${message}`);
|
|
5195
|
-
return {
|
|
5196
|
-
content: [{ type: 'text', text: `Migration failed: ${humanizeError(message)}` }],
|
|
5197
|
-
};
|
|
5198
|
-
}
|
|
5199
|
-
},
|
|
5200
|
-
}, { name: 'totalreclaw_migrate' });
|
|
5201
|
-
// ---------------------------------------------------------------
|
|
5202
|
-
// Tools: totalreclaw_setup + totalreclaw_onboarding_start —
|
|
5203
|
-
// REMOVED in 3.3.1-rc.5 (phrase-safety carve-out closure).
|
|
5204
|
-
// ---------------------------------------------------------------
|
|
5205
|
-
//
|
|
5206
|
-
// rc.4 left these two registrations in place as *neutered* stubs —
|
|
5207
|
-
// ``totalreclaw_setup`` rejected any ``recovery_phrase`` argument
|
|
5208
|
-
// and returned a CLI-pointer message; ``totalreclaw_onboarding_start``
|
|
5209
|
-
// was already pointer-only. Neither path could leak a phrase in
|
|
5210
|
-
// rc.4, but the rc.4 auto-QA (2026-04-22) flagged them as future-
|
|
5211
|
-
// regression surface: any future patch that re-enables phrase
|
|
5212
|
-
// acceptance (e.g. a flag-driven "power-user" path) would silently
|
|
5213
|
-
// re-open the leak, and their mere presence in the tool registry
|
|
5214
|
-
// keeps signalling to agents that "phrase handling happens here".
|
|
5215
3874
|
//
|
|
5216
|
-
//
|
|
5217
|
-
//
|
|
5218
|
-
//
|
|
5219
|
-
//
|
|
5220
|
-
//
|
|
5221
|
-
//
|
|
3875
|
+
// Phase 3.3: gates the bundled NATIVE memory tools (memory_search,
|
|
3876
|
+
// memory_get) until onboarding state is `active`. The blockReason string
|
|
3877
|
+
// is LLM-visible but carries no secret — it's a pointer to the CLI pair
|
|
3878
|
+
// surface (`tr pair --url-pin`). Without this gate, an unpaired agent
|
|
3879
|
+
// would hit the adapter's fail-soft empty-result path and surface
|
|
3880
|
+
// "no memories found" with no actionable guidance.
|
|
5222
3881
|
//
|
|
5223
|
-
//
|
|
5224
|
-
//
|
|
5225
|
-
//
|
|
5226
|
-
//
|
|
5227
|
-
// Historical tombstone (so LLM-assisted contributors don't re-add
|
|
5228
|
-
// the former shape from training-data memory): rc.4 registered two
|
|
5229
|
-
// tools by the names "totalreclaw_setup" and
|
|
5230
|
-
// "totalreclaw_onboarding_start" as pointer-only stubs. Both were
|
|
5231
|
-
// deleted in rc.5. Do not re-introduce.
|
|
5232
|
-
// ---------------------------------------------------------------
|
|
5233
|
-
// Tool: totalreclaw_onboard — REMOVED in 3.3.1-rc.4 (phrase-safety).
|
|
5234
|
-
//
|
|
5235
|
-
// rc.3 shipped a `totalreclaw_onboard` agent tool that generated a
|
|
5236
|
-
// fresh BIP-39 mnemonic in-process, wrote it to credentials.json,
|
|
5237
|
-
// and returned `{scope_address, credentials_path}` to the agent.
|
|
5238
|
-
// `emitPhrase: false` kept the mnemonic OUT of the tool's return
|
|
5239
|
-
// payload, but NOTHING ARCHITECTURALLY PREVENTED leakage — a future
|
|
5240
|
-
// patch could regress the flag, a different code path could echo
|
|
5241
|
-
// the mnemonic in a log/error message the agent captures, or the
|
|
5242
|
-
// mere existence of the tool implied to agents that "generating a
|
|
5243
|
-
// phrase here is fine" (it isn't).
|
|
5244
|
-
//
|
|
5245
|
-
// Per ``project_phrase_safety_rule.md``
|
|
5246
|
-
// (memory file in p-diogo/totalreclaw-internal — absolute rule:
|
|
5247
|
-
// "recovery phrase MUST NEVER cross the LLM context in ANY form"),
|
|
5248
|
-
// phrase-generating agent tools are forbidden. The ONLY approved
|
|
5249
|
-
// agent-facilitated setup surface is ``totalreclaw_pair`` (browser-
|
|
5250
|
-
// side crypto keeps the phrase out of the LLM round-trip by
|
|
5251
|
-
// construction). The underlying ``runNonInteractiveOnboard`` code
|
|
5252
|
-
// path is still reachable via the CLI ``openclaw totalreclaw onboard``
|
|
5253
|
-
// — that path runs in the user's own terminal, OUTSIDE any agent
|
|
5254
|
-
// shell, so phrase stdout never feeds back into LLM context.
|
|
5255
|
-
//
|
|
5256
|
-
// Audit assertion: ``tool-gating.test.ts`` enforces the removal —
|
|
5257
|
-
// any future re-registration of ``totalreclaw_onboard`` (or any
|
|
5258
|
-
// phrase-generating variant like ``totalreclaw_onboard_generate``,
|
|
5259
|
-
// ``totalreclaw_restore_phrase``) fails CI.
|
|
5260
|
-
// ---------------------------------------------------------------
|
|
5261
|
-
// ---------------------------------------------------------------
|
|
5262
|
-
// Tool: totalreclaw_pair (3.3.1-rc.2 — agent-callable pair-generate)
|
|
5263
|
-
//
|
|
5264
|
-
// Creates a pairing session (browser-mediated recovery-phrase sync),
|
|
5265
|
-
// returns the URL + PIN + QR ASCII to the agent. The agent relays
|
|
5266
|
-
// these to the user (paste-URL or scan-QR flow). The phrase itself
|
|
5267
|
-
// NEVER crosses the gateway — the pair-http endpoint does the E2EE
|
|
5268
|
-
// handshake with the browser pair-page.
|
|
5269
|
-
// ---------------------------------------------------------------
|
|
5270
|
-
api.registerTool({
|
|
5271
|
-
name: 'totalreclaw_pair',
|
|
5272
|
-
label: 'Set up the user\'s TotalReclaw account (encrypted, browser-side)',
|
|
5273
|
-
description: 'Set up the user\'s TotalReclaw account (encrypted, browser-side recovery-phrase ' +
|
|
5274
|
-
'generation or import). Starts a brief remote session so the user can create or ' +
|
|
5275
|
-
'import a TotalReclaw recovery phrase from their phone or another browser. ' +
|
|
5276
|
-
'Returns an account-setup URL, a 6-digit PIN, and an ASCII QR code that the agent ' +
|
|
5277
|
-
'relays to the user. The recovery phrase itself is generated/entered in the ' +
|
|
5278
|
-
'BROWSER and uploaded end-to-end encrypted to this gateway — it NEVER touches ' +
|
|
5279
|
-
'the LLM provider or the chat transcript.\n\n' +
|
|
5280
|
-
'This is the CANONICAL agent-facilitated account-setup surface — use it whenever ' +
|
|
5281
|
-
'the user asks you to set up TotalReclaw, regardless of whether they have ' +
|
|
5282
|
-
'terminal access. Narrate it to the user as "setting up your TotalReclaw ' +
|
|
5283
|
-
'account". Browser-side crypto keeps the recovery phrase out of the LLM context ' +
|
|
5284
|
-
'entirely. If a user explicitly prefers local-terminal account setup with no ' +
|
|
5285
|
-
'browser, point them at `totalreclaw_onboarding_start` (a pointer to the CLI ' +
|
|
5286
|
-
'wizard they run on their own terminal, NOT through your shell tool).\n\n' +
|
|
5287
|
-
'Tool name `totalreclaw_pair` is kept for backward compatibility — function-wise ' +
|
|
5288
|
-
'this is the account-setup tool.',
|
|
5289
|
-
parameters: {
|
|
5290
|
-
type: 'object',
|
|
5291
|
-
properties: {
|
|
5292
|
-
mode: {
|
|
5293
|
-
type: 'string',
|
|
5294
|
-
enum: ['generate', 'import'],
|
|
5295
|
-
description: '"generate" = the browser will create a NEW recovery phrase. "import" = the ' +
|
|
5296
|
-
'browser will accept an EXISTING phrase that the user pastes in their browser ' +
|
|
5297
|
-
'(never through chat).',
|
|
5298
|
-
default: 'generate',
|
|
5299
|
-
},
|
|
5300
|
-
},
|
|
5301
|
-
additionalProperties: false,
|
|
5302
|
-
},
|
|
5303
|
-
async execute(_toolCallId, params) {
|
|
5304
|
-
const rawMode = params?.mode;
|
|
5305
|
-
const mode = rawMode === 'import' ? 'import' : 'generate';
|
|
5306
|
-
const pairMode = CONFIG.pairMode;
|
|
5307
|
-
try {
|
|
5308
|
-
// 3.3.1-rc.11 — relay-brokered pair by default (universal reachability).
|
|
5309
|
-
// `TOTALRECLAW_PAIR_MODE=local` preserves the rc.4–rc.10 loopback flow
|
|
5310
|
-
// for air-gapped / self-hosted setups. Both paths return the same
|
|
5311
|
-
// tool payload (`{url, pin, expires_at_ms, qr_*, mode, instructions}`);
|
|
5312
|
-
// only the URL origin differs.
|
|
5313
|
-
let url;
|
|
5314
|
-
let pin;
|
|
5315
|
-
let sidOrToken;
|
|
5316
|
-
let expiresAtMs;
|
|
5317
|
-
let localSession;
|
|
5318
|
-
if (pairMode === 'relay') {
|
|
5319
|
-
const { openRemotePairSession, awaitPhraseUpload } = await import('./pair-remote-client.js');
|
|
5320
|
-
const remoteSession = await openRemotePairSession({
|
|
5321
|
-
relayBaseUrl: CONFIG.pairRelayUrl,
|
|
5322
|
-
mode: mode === 'generate' ? 'generate' : 'import',
|
|
5323
|
-
});
|
|
5324
|
-
url = remoteSession.url;
|
|
5325
|
-
pin = remoteSession.pin;
|
|
5326
|
-
sidOrToken = remoteSession.token;
|
|
5327
|
-
// Relay sends ISO-8601; convert to ms for tool payload parity.
|
|
5328
|
-
const parsed = Date.parse(remoteSession.expiresAt);
|
|
5329
|
-
expiresAtMs = Number.isFinite(parsed)
|
|
5330
|
-
? parsed
|
|
5331
|
-
: Date.now() + 5 * 60_000;
|
|
5332
|
-
// Background task — writes credentials.json + flips state when
|
|
5333
|
-
// the browser completes the flow. Tool handler returns
|
|
5334
|
-
// immediately so the agent can tell the user the URL + PIN.
|
|
5335
|
-
//
|
|
5336
|
-
// 3.3.4-rc.2 (Pedro QA — pair flow stuck-session) — wrap the
|
|
5337
|
-
// WS-await in a 60s hard timeout. The relay drops sessions on
|
|
5338
|
-
// `ws_close` (separately patched relay-side); without this
|
|
5339
|
-
// bound the background task hangs in `waitNextMessage` for the
|
|
5340
|
-
// full 5-minute session TTL before resolving, and downstream
|
|
5341
|
-
// tooling that polls the gateway for completion sees stale
|
|
5342
|
-
// `processing` state at 129s/159s/189s. 60s is the user-side
|
|
5343
|
-
// deadline we surface in chat: long enough for a slow scan-
|
|
5344
|
-
// and-paste, short enough that a fresh URL request is the
|
|
5345
|
-
// obvious next step. Structured `timed_out` error in the log
|
|
5346
|
-
// lets ops grep for the failure mode independently of generic
|
|
5347
|
-
// ws-close errors.
|
|
5348
|
-
const PAIR_TOOL_HARD_TIMEOUT_MS = 60_000;
|
|
5349
|
-
void (async () => {
|
|
5350
|
-
try {
|
|
5351
|
-
const phraseUploadPromise = awaitPhraseUpload(remoteSession, {
|
|
5352
|
-
phraseValidator: (p) => validateMnemonic(p, wordlist),
|
|
5353
|
-
completePairing: async ({ mnemonic }) => {
|
|
5354
|
-
try {
|
|
5355
|
-
// 3.3.1 (internal#130) — derive + persist the
|
|
5356
|
-
// Smart Account address now so the user can see
|
|
5357
|
-
// it immediately after pair, before any chain
|
|
5358
|
-
// write. Mnemonic stays in this scope (already
|
|
5359
|
-
// on disk in credentials.json); only the
|
|
5360
|
-
// derived public scope_address is added.
|
|
5361
|
-
let scopeAddress;
|
|
5362
|
-
try {
|
|
5363
|
-
scopeAddress = await deriveSmartAccountAddress(mnemonic, CONFIG.chainId);
|
|
5364
|
-
}
|
|
5365
|
-
catch (deriveErr) {
|
|
5366
|
-
api.logger.warn(`totalreclaw_pair(relay): scope_address derivation failed (will retry lazily): ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`);
|
|
5367
|
-
}
|
|
5368
|
-
const creds = loadCredentialsJson(CREDENTIALS_PATH) ?? {};
|
|
5369
|
-
const next = { ...creds, mnemonic };
|
|
5370
|
-
if (scopeAddress)
|
|
5371
|
-
next.scope_address = scopeAddress;
|
|
5372
|
-
if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
|
|
5373
|
-
return { state: 'error', error: 'credentials_write_failed' };
|
|
5374
|
-
}
|
|
5375
|
-
setRecoveryPhraseOverride(mnemonic);
|
|
5376
|
-
writeOnboardingState(CONFIG.onboardingStatePath, {
|
|
5377
|
-
onboardingState: 'active',
|
|
5378
|
-
createdBy: mode === 'generate' ? 'generate' : 'import',
|
|
5379
|
-
credentialsCreatedAt: new Date().toISOString(),
|
|
5380
|
-
version: pluginVersion ?? '3.3.0',
|
|
5381
|
-
});
|
|
5382
|
-
// 3.3.13 — sentinel cleanup; see auto-pair-on-load.ts.
|
|
5383
|
-
deletePairPendingFile(defaultPairPendingPath(CREDENTIALS_PATH));
|
|
5384
|
-
api.logger.info(`totalreclaw_pair(relay): session ${remoteSession.token.slice(0, 8)}… completed; credentials written${scopeAddress ? ` (scope_address=${scopeAddress})` : ''}`);
|
|
5385
|
-
return { state: 'active' };
|
|
5386
|
-
}
|
|
5387
|
-
catch (err) {
|
|
5388
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
5389
|
-
api.logger.error(`totalreclaw_pair(relay): completePairing failed: ${msg}`);
|
|
5390
|
-
return { state: 'error', error: msg };
|
|
5391
|
-
}
|
|
5392
|
-
},
|
|
5393
|
-
// 3.3.4-rc.2 — also pass through to awaitPhraseUpload so its
|
|
5394
|
-
// internal `waitNextMessage` timer matches the outer race.
|
|
5395
|
-
timeoutMs: PAIR_TOOL_HARD_TIMEOUT_MS,
|
|
5396
|
-
});
|
|
5397
|
-
// 3.3.4-rc.2 — outer Promise.race guard. Resolves to a
|
|
5398
|
-
// sentinel ({ status: 'timed_out', ... }) so the catch
|
|
5399
|
-
// handler can distinguish a hard-timeout from a generic
|
|
5400
|
-
// ws-close error and surface it explicitly.
|
|
5401
|
-
const TIMEOUT_SENTINEL = {
|
|
5402
|
-
status: 'timed_out',
|
|
5403
|
-
message: `Pair flow timed out (${PAIR_TOOL_HARD_TIMEOUT_MS / 1000}s) — generate a new URL with totalreclaw_pair.`,
|
|
5404
|
-
};
|
|
5405
|
-
let hardTimer;
|
|
5406
|
-
const hardTimeoutPromise = new Promise((resolve) => {
|
|
5407
|
-
hardTimer = setTimeout(() => resolve(TIMEOUT_SENTINEL), PAIR_TOOL_HARD_TIMEOUT_MS);
|
|
5408
|
-
});
|
|
5409
|
-
try {
|
|
5410
|
-
const raced = await Promise.race([
|
|
5411
|
-
phraseUploadPromise,
|
|
5412
|
-
hardTimeoutPromise,
|
|
5413
|
-
]);
|
|
5414
|
-
if (raced &&
|
|
5415
|
-
typeof raced === 'object' &&
|
|
5416
|
-
raced.status === 'timed_out') {
|
|
5417
|
-
api.logger.warn(`totalreclaw_pair(relay): hard timeout — ${raced.message} (token=${remoteSession.token.slice(0, 8)}…)`);
|
|
5418
|
-
}
|
|
5419
|
-
}
|
|
5420
|
-
finally {
|
|
5421
|
-
if (hardTimer)
|
|
5422
|
-
clearTimeout(hardTimer);
|
|
5423
|
-
}
|
|
5424
|
-
}
|
|
5425
|
-
catch (bgErr) {
|
|
5426
|
-
// Expected on TTL expiry / user-aborts — log at warn, not error.
|
|
5427
|
-
const bgMsg = bgErr instanceof Error ? bgErr.message : String(bgErr);
|
|
5428
|
-
api.logger.warn(`totalreclaw_pair(relay): background task ended for token=${remoteSession.token.slice(0, 8)}…: ${bgMsg}`);
|
|
5429
|
-
}
|
|
5430
|
-
})();
|
|
5431
|
-
}
|
|
5432
|
-
else {
|
|
5433
|
-
// Local loopback path (rc.10 behaviour).
|
|
5434
|
-
const { createPairSession } = await import('./pair-session-store.js');
|
|
5435
|
-
const { generateGatewayKeypair } = await import('./pair-crypto.js');
|
|
5436
|
-
const kp = generateGatewayKeypair();
|
|
5437
|
-
const session = await createPairSession(CONFIG.pairSessionsPath, {
|
|
5438
|
-
mode,
|
|
5439
|
-
operatorContext: { channel: 'agent' },
|
|
5440
|
-
rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
|
|
5441
|
-
rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
|
|
5442
|
-
});
|
|
5443
|
-
url = buildPairingUrl(api, session);
|
|
5444
|
-
pin = session.secondaryCode;
|
|
5445
|
-
sidOrToken = session.sid;
|
|
5446
|
-
expiresAtMs = session.expiresAtMs;
|
|
5447
|
-
localSession = session;
|
|
5448
|
-
}
|
|
5449
|
-
// QR renderers — same for both modes; input is the URL string.
|
|
5450
|
-
const { defaultRenderQr } = await import('./pair-cli.js');
|
|
5451
|
-
const qrAscii = await new Promise((resolve) => {
|
|
5452
|
-
let settled = false;
|
|
5453
|
-
const t = setTimeout(() => {
|
|
5454
|
-
if (!settled) {
|
|
5455
|
-
settled = true;
|
|
5456
|
-
resolve('');
|
|
5457
|
-
}
|
|
5458
|
-
}, 5_000);
|
|
5459
|
-
try {
|
|
5460
|
-
defaultRenderQr(url, (ascii) => {
|
|
5461
|
-
if (settled)
|
|
5462
|
-
return;
|
|
5463
|
-
settled = true;
|
|
5464
|
-
clearTimeout(t);
|
|
5465
|
-
resolve(ascii);
|
|
5466
|
-
});
|
|
5467
|
-
}
|
|
5468
|
-
catch {
|
|
5469
|
-
if (settled)
|
|
5470
|
-
return;
|
|
5471
|
-
settled = true;
|
|
5472
|
-
clearTimeout(t);
|
|
5473
|
-
resolve('');
|
|
5474
|
-
}
|
|
5475
|
-
});
|
|
5476
|
-
// 3.3.1-rc.5 — PNG + Unicode QR for multi-transport rendering.
|
|
5477
|
-
let qrPngB64 = '';
|
|
5478
|
-
let qrUnicode = '';
|
|
5479
|
-
try {
|
|
5480
|
-
const { encodePng, encodeUnicode } = await import('./pair-qr.js');
|
|
5481
|
-
const [pngBuf, uni] = await Promise.all([
|
|
5482
|
-
encodePng(url),
|
|
5483
|
-
encodeUnicode(url),
|
|
5484
|
-
]);
|
|
5485
|
-
qrPngB64 = pngBuf.toString('base64');
|
|
5486
|
-
qrUnicode = uni;
|
|
5487
|
-
}
|
|
5488
|
-
catch (qrErr) {
|
|
5489
|
-
api.logger.warn(`totalreclaw_pair: QR encode failed (non-fatal): ${qrErr instanceof Error ? qrErr.message : String(qrErr)}`);
|
|
5490
|
-
}
|
|
5491
|
-
api.logger.info(`totalreclaw_pair: session ${sidOrToken.slice(0, 8)}… mode=${mode} transport=${pairMode} url=${url} qr_png=${qrPngB64.length} qr_unicode=${qrUnicode.length}`);
|
|
5492
|
-
// Voidly reference localSession so TS does not flag the unused
|
|
5493
|
-
// local-branch binding. Future rc.12 diagnostics can expose
|
|
5494
|
-
// `session.mode` / `session.status` separately.
|
|
5495
|
-
void localSession;
|
|
5496
|
-
return {
|
|
5497
|
-
content: [{
|
|
5498
|
-
type: 'text',
|
|
5499
|
-
text: `Pairing session started.\n\n` +
|
|
5500
|
-
`URL: ${url}\n\n` +
|
|
5501
|
-
`PIN (type this into the browser): ${pin}\n\n` +
|
|
5502
|
-
(qrAscii ? `QR code:\n\n${qrAscii}\n\n` : '') +
|
|
5503
|
-
`Instructions for the user:\n` +
|
|
5504
|
-
`1. Open the URL above on their phone or another browser (scan the QR or copy-paste).\n` +
|
|
5505
|
-
`2. ` +
|
|
5506
|
-
(mode === 'generate'
|
|
5507
|
-
? `The browser will generate a NEW 12-word recovery phrase and ask the user to write it down + retype 3 words before finalizing.`
|
|
5508
|
-
: `The browser will accept an EXISTING phrase that the user pastes in the browser (never through chat).`) +
|
|
5509
|
-
`\n3. Enter the 6-digit PIN shown above into the browser.\n` +
|
|
5510
|
-
`4. The encrypted phrase uploads to this gateway — it NEVER touches the LLM.\n` +
|
|
5511
|
-
`5. Come back to chat once the browser says "Pairing complete".\n\n` +
|
|
5512
|
-
`This session expires in ~5 minutes. Run this tool again if you need a fresh URL.`,
|
|
5513
|
-
}],
|
|
5514
|
-
details: {
|
|
5515
|
-
sid: sidOrToken,
|
|
5516
|
-
url,
|
|
5517
|
-
pin,
|
|
5518
|
-
mode,
|
|
5519
|
-
expires_at_ms: expiresAtMs,
|
|
5520
|
-
qr_ascii: qrAscii,
|
|
5521
|
-
qr_png_b64: qrPngB64,
|
|
5522
|
-
qr_unicode: qrUnicode,
|
|
5523
|
-
// rc.11 — surface the transport so downstream tooling (QA
|
|
5524
|
-
// harness asserters, telemetry) can confirm which path
|
|
5525
|
-
// served the URL. Either 'relay' or 'local'.
|
|
5526
|
-
transport: pairMode,
|
|
5527
|
-
},
|
|
5528
|
-
};
|
|
5529
|
-
}
|
|
5530
|
-
catch (err) {
|
|
5531
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5532
|
-
api.logger.error(`totalreclaw_pair failed: ${message}`);
|
|
5533
|
-
return {
|
|
5534
|
-
content: [{ type: 'text', text: `Failed to start pairing session: ${humanizeError(message)}` }],
|
|
5535
|
-
details: { error: message },
|
|
5536
|
-
};
|
|
5537
|
-
}
|
|
5538
|
-
},
|
|
5539
|
-
}, { name: 'totalreclaw_pair' });
|
|
5540
|
-
// 3.3.1-rc.20 (issue #110): explicit post-registration breadcrumb so
|
|
5541
|
-
// ops/QA can grep gateway logs for definitive proof the tool was
|
|
5542
|
-
// declared. If the agent then reports the tool is missing from its
|
|
5543
|
-
// tool list, the gap is upstream OpenClaw tool propagation, not our
|
|
5544
|
-
// plugin — see issue #110 fix 3 + PR #102 (CLI fallback).
|
|
5545
|
-
//
|
|
5546
|
-
// 3.3.1-rc.21 (issue #128): the breadcrumb is debug-grade — it was
|
|
5547
|
-
// bleeding into `openclaw agent --json` stdout, breaking programmatic
|
|
5548
|
-
// parsers that expect the JSON-RPC body to be the only thing on the
|
|
5549
|
-
// wire. Gated behind `TOTALRECLAW_VERBOSE_REGISTER` (or the general
|
|
5550
|
-
// `TOTALRECLAW_DEBUG` toggle) so ops can opt back in when chasing
|
|
5551
|
-
// a tool-injection regression. Default OFF — clean stdout for users.
|
|
5552
|
-
if (CONFIG.verboseRegister) {
|
|
5553
|
-
api.logger.info('TotalReclaw: registerTool(totalreclaw_pair) returned. If the agent does not see it in its tool list ' +
|
|
5554
|
-
'after gateway restart, the issue is upstream tool injection (containerized agents) — fall back to ' +
|
|
5555
|
-
'`openclaw totalreclaw pair generate --url-pin-only` (PR #102) or `openclaw totalreclaw onboard --pair-only`.');
|
|
5556
|
-
}
|
|
5557
|
-
// ---------------------------------------------------------------
|
|
5558
|
-
// Tool: totalreclaw_report_qa_bug (3.3.1-rc.3 — RC-gated)
|
|
5559
|
-
//
|
|
5560
|
-
// Lets the agent file a structured QA-bug issue to
|
|
5561
|
-
// `p-diogo/totalreclaw-internal` during RC testing. Only registered
|
|
5562
|
-
// when the plugin version contains `-rc.` — stable users never see it.
|
|
5563
|
-
//
|
|
5564
|
-
// Secrets (recovery phrases, API keys, Telegram bot tokens) are
|
|
5565
|
-
// redacted inside `postQaBugIssue` before the POST. The agent should
|
|
5566
|
-
// still avoid passing raw secrets — see SKILL.md addendum.
|
|
5567
|
-
// ---------------------------------------------------------------
|
|
5568
|
-
if (rcMode) {
|
|
5569
|
-
api.registerTool({
|
|
5570
|
-
name: 'totalreclaw_report_qa_bug',
|
|
5571
|
-
label: 'File a QA bug issue (RC builds only)',
|
|
5572
|
-
description: 'File a structured QA bug report to the internal tracker. RC-only; never available in stable builds. ' +
|
|
5573
|
-
'Do NOT call auto-file — ask the user first before invoking. The tool redacts recovery phrases, API keys, ' +
|
|
5574
|
-
'and Telegram bot tokens from all free-text fields before posting, but the agent SHOULD still avoid ' +
|
|
5575
|
-
'passing raw secrets.',
|
|
5576
|
-
parameters: {
|
|
5577
|
-
type: 'object',
|
|
5578
|
-
properties: {
|
|
5579
|
-
integration: {
|
|
5580
|
-
type: 'string',
|
|
5581
|
-
enum: ['plugin', 'hermes', 'nanoclaw', 'mcp', 'relay', 'clawhub', 'docs', 'other'],
|
|
5582
|
-
description: 'Which TotalReclaw surface is affected.',
|
|
5583
|
-
},
|
|
5584
|
-
rc_version: {
|
|
5585
|
-
type: 'string',
|
|
5586
|
-
description: 'Exact RC version string (e.g. "3.3.1-rc.3" or "2.3.1rc3").',
|
|
5587
|
-
},
|
|
5588
|
-
severity: {
|
|
5589
|
-
type: 'string',
|
|
5590
|
-
enum: ['blocker', 'high', 'medium', 'low'],
|
|
5591
|
-
description: 'blocker=release blocked, high=major UX failure, medium=annoying, low=polish.',
|
|
5592
|
-
},
|
|
5593
|
-
title: {
|
|
5594
|
-
type: 'string',
|
|
5595
|
-
description: 'Short summary, <60 chars. Prefix "[qa-bug]" is added automatically.',
|
|
5596
|
-
maxLength: 60,
|
|
5597
|
-
},
|
|
5598
|
-
symptom: {
|
|
5599
|
-
type: 'string',
|
|
5600
|
-
description: 'What happened (redacted automatically).',
|
|
5601
|
-
},
|
|
5602
|
-
expected: {
|
|
5603
|
-
type: 'string',
|
|
5604
|
-
description: 'What should have happened.',
|
|
5605
|
-
},
|
|
5606
|
-
repro: {
|
|
5607
|
-
type: 'string',
|
|
5608
|
-
description: 'Reproduction steps (redacted automatically).',
|
|
5609
|
-
},
|
|
5610
|
-
logs: {
|
|
5611
|
-
type: 'string',
|
|
5612
|
-
description: 'Log excerpts / error messages (redacted automatically).',
|
|
5613
|
-
},
|
|
5614
|
-
environment: {
|
|
5615
|
-
type: 'string',
|
|
5616
|
-
description: 'Host, Docker/native, OpenClaw version, LLM provider, etc.',
|
|
5617
|
-
},
|
|
5618
|
-
},
|
|
5619
|
-
required: [
|
|
5620
|
-
'integration',
|
|
5621
|
-
'rc_version',
|
|
5622
|
-
'severity',
|
|
5623
|
-
'title',
|
|
5624
|
-
'symptom',
|
|
5625
|
-
'expected',
|
|
5626
|
-
'repro',
|
|
5627
|
-
'logs',
|
|
5628
|
-
'environment',
|
|
5629
|
-
],
|
|
5630
|
-
additionalProperties: false,
|
|
5631
|
-
},
|
|
5632
|
-
async execute(_toolCallId, params) {
|
|
5633
|
-
try {
|
|
5634
|
-
const { postQaBugIssue } = await import('./qa-bug-report.js');
|
|
5635
|
-
// The token is resolved via CONFIG (config.ts) so index.ts
|
|
5636
|
-
// stays clean of env-harvesting triggers.
|
|
5637
|
-
const token = CONFIG.qaGithubToken;
|
|
5638
|
-
if (!token) {
|
|
5639
|
-
return {
|
|
5640
|
-
content: [{
|
|
5641
|
-
type: 'text',
|
|
5642
|
-
text: 'Cannot file QA bug: no GitHub token found. The operator must export ' +
|
|
5643
|
-
'TOTALRECLAW_QA_GITHUB_TOKEN (or GITHUB_TOKEN) with `repo` scope to enable ' +
|
|
5644
|
-
'agent-filed bug reports during RC testing.',
|
|
5645
|
-
}],
|
|
5646
|
-
details: { error: 'missing_github_token' },
|
|
5647
|
-
};
|
|
5648
|
-
}
|
|
5649
|
-
// rc.14 — `repo` is resolved inside `postQaBugIssue` via
|
|
5650
|
-
// `resolveQaRepo(...)`, which reads `TOTALRECLAW_QA_REPO` and
|
|
5651
|
-
// refuses any slug that isn't a `-internal` fork. Pass the
|
|
5652
|
-
// config-surfaced override so env reads stay in config.ts.
|
|
5653
|
-
const repoOverride = CONFIG.qaRepoOverride || undefined;
|
|
5654
|
-
const result = await postQaBugIssue(params, {
|
|
5655
|
-
githubToken: token,
|
|
5656
|
-
repo: repoOverride,
|
|
5657
|
-
logger: api.logger,
|
|
5658
|
-
});
|
|
5659
|
-
return {
|
|
5660
|
-
content: [{
|
|
5661
|
-
type: 'text',
|
|
5662
|
-
text: `Filed QA bug #${result.issue_number}: ${result.issue_url}`,
|
|
5663
|
-
}],
|
|
5664
|
-
details: { issue_url: result.issue_url, issue_number: result.issue_number },
|
|
5665
|
-
};
|
|
5666
|
-
}
|
|
5667
|
-
catch (err) {
|
|
5668
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5669
|
-
api.logger.error(`totalreclaw_report_qa_bug failed: ${message}`);
|
|
5670
|
-
return {
|
|
5671
|
-
content: [{
|
|
5672
|
-
type: 'text',
|
|
5673
|
-
text: `Failed to file QA bug: ${message}`,
|
|
5674
|
-
}],
|
|
5675
|
-
details: { error: message },
|
|
5676
|
-
};
|
|
5677
|
-
}
|
|
5678
|
-
},
|
|
5679
|
-
}, { name: 'totalreclaw_report_qa_bug' });
|
|
5680
|
-
// 3.3.1-rc.21 (issue #128): demote the registration-confirmation
|
|
5681
|
-
// breadcrumb to verbose-only. Same `--json` stdout pollution risk
|
|
5682
|
-
// as the totalreclaw_pair breadcrumb above; ops can opt back in
|
|
5683
|
-
// via TOTALRECLAW_VERBOSE_REGISTER / TOTALRECLAW_DEBUG.
|
|
5684
|
-
if (CONFIG.verboseRegister) {
|
|
5685
|
-
api.logger.info('totalreclaw_report_qa_bug registered (RC build — this tool is hidden in stable releases).');
|
|
5686
|
-
}
|
|
5687
|
-
}
|
|
5688
|
-
// ---------------------------------------------------------------
|
|
5689
|
-
// Hook: before_tool_call (3.2.0 memory-tool gate)
|
|
5690
|
-
// ---------------------------------------------------------------
|
|
5691
|
-
//
|
|
5692
|
-
// Blocks every memory tool until onboarding state is `active`. The
|
|
5693
|
-
// `blockReason` string is LLM-visible but carries no secret — it's a
|
|
5694
|
-
// pointer to the CLI wizard.
|
|
5695
|
-
//
|
|
5696
|
-
// Non-gated tools: totalreclaw_upgrade, totalreclaw_migrate,
|
|
5697
|
-
// totalreclaw_onboarding_start, totalreclaw_setup (deprecated).
|
|
5698
|
-
// Billing tools work pre-onboarding because they help the user reach a
|
|
5699
|
-
// Pro tier before they have memories to store; setup-adjacent tools
|
|
5700
|
-
// return their own routing messages.
|
|
3882
|
+
// Non-gated tools: every other tool the agent sees (read/write helpers,
|
|
3883
|
+
// the host's own tools, etc.). The pair surface is intentionally not
|
|
3884
|
+
// gated — users must be able to start onboarding before the vault is
|
|
3885
|
+
// active.
|
|
5701
3886
|
//
|
|
5702
3887
|
// Decision logic lives in `tool-gating.ts` so it's unit-testable
|
|
5703
3888
|
// without a full plugin host.
|
|
@@ -5848,18 +4033,18 @@ const plugin = {
|
|
|
5848
4033
|
'## TotalReclaw setup pending\n\n' +
|
|
5849
4034
|
'TotalReclaw encrypted memory is installed but not yet set up on this machine. ' +
|
|
5850
4035
|
'If the user asks about memory features or wants to configure TotalReclaw, ' +
|
|
5851
|
-
'
|
|
5852
|
-
'
|
|
5853
|
-
'
|
|
5854
|
-
'
|
|
5855
|
-
'
|
|
5856
|
-
'
|
|
5857
|
-
'
|
|
4036
|
+
'pairing is QR-only and CLI-driven (there is no agent tool for it). Run ' +
|
|
4037
|
+
'`tr pair --url-pin` in a shell on the gateway host — it emits a single JSON ' +
|
|
4038
|
+
'line `{v,url,pin,expires_at_ms}` with no phrase material. Hand the user the ' +
|
|
4039
|
+
'`url` and `pin`; they open the URL in a browser where the recovery phrase is ' +
|
|
4040
|
+
'generated and encrypted in-browser and never leaves their device in plaintext. ' +
|
|
4041
|
+
'Once pairing completes, memory_search/memory_get unlock automatically and this ' +
|
|
4042
|
+
'banner stops appearing.\n\n' +
|
|
5858
4043
|
'**Do NOT** attempt to generate, display, or relay a recovery phrase in chat. ' +
|
|
5859
4044
|
'**Do NOT** run `openclaw totalreclaw onboard` — that CLI emits the recovery ' +
|
|
5860
|
-
'phrase on stdout and would leak it into the LLM transcript. Use `pair` ' +
|
|
5861
|
-
'(
|
|
5862
|
-
'running it directly in their own local terminal.',
|
|
4045
|
+
'phrase on stdout and would leak it into the LLM transcript. Use `tr pair --url-pin` ' +
|
|
4046
|
+
'(or `openclaw totalreclaw pair generate --url-pin-only`) instead; `onboard` is ' +
|
|
4047
|
+
'reserved for users running it directly in their own local terminal.',
|
|
5863
4048
|
};
|
|
5864
4049
|
}
|
|
5865
4050
|
// One-time welcome message (first conversation after setup or returning user)
|
|
@@ -5875,7 +4060,7 @@ const plugin = {
|
|
|
5875
4060
|
const tier = cache?.tier || 'free';
|
|
5876
4061
|
const tierInfo = tier === 'pro'
|
|
5877
4062
|
? 'You are on the **Pro** tier — unlimited memories, permanently stored on Gnosis mainnet.'
|
|
5878
|
-
: 'You are on the **Free** tier — memories stored on testnet.
|
|
4063
|
+
: '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.)';
|
|
5879
4064
|
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}`;
|
|
5880
4065
|
}
|
|
5881
4066
|
// Billing cache check — warn if quota is approaching limit.
|
|
@@ -5897,6 +4082,10 @@ const plugin = {
|
|
|
5897
4082
|
free_writes_used: billingData.free_writes_used ?? 0,
|
|
5898
4083
|
free_writes_limit: billingData.free_writes_limit ?? 0,
|
|
5899
4084
|
features: billingData.features,
|
|
4085
|
+
// Relay's authoritative chain_id → drives the chain override verbatim (#402).
|
|
4086
|
+
chain_id: billingData.chain_id,
|
|
4087
|
+
// Relay's authoritative data_edge_address → drives the DataEdge override verbatim (#460).
|
|
4088
|
+
data_edge_address: billingData.data_edge_address,
|
|
5900
4089
|
checked_at: Date.now(),
|
|
5901
4090
|
};
|
|
5902
4091
|
writeBillingCache(cache);
|
|
@@ -5905,7 +4094,7 @@ const plugin = {
|
|
|
5905
4094
|
if (cache && cache.free_writes_limit > 0) {
|
|
5906
4095
|
const usageRatio = cache.free_writes_used / cache.free_writes_limit;
|
|
5907
4096
|
if (usageRatio >= QUOTA_WARNING_THRESHOLD) {
|
|
5908
|
-
billingWarning = `\n\nTotalReclaw quota warning: ${cache.free_writes_used}/${cache.free_writes_limit}
|
|
4097
|
+
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.`;
|
|
5909
4098
|
}
|
|
5910
4099
|
}
|
|
5911
4100
|
}
|
|
@@ -6076,7 +4265,7 @@ const plugin = {
|
|
|
6076
4265
|
// 6. Re-rank with BM25 + cosine + intent-weighted RRF fusion.
|
|
6077
4266
|
const hookQueryIntent = detectQueryIntent(evt.prompt);
|
|
6078
4267
|
const reranked = rerank(evt.prompt, queryEmbedding ?? [], rerankerCandidates, 8, INTENT_WEIGHTS[hookQueryIntent],
|
|
6079
|
-
/* applySourceWeights (Retrieval v2 Tier 1) */
|
|
4268
|
+
/* applySourceWeights (Retrieval v2 Tier 1) */ false);
|
|
6080
4269
|
// Update hot cache with reranked results.
|
|
6081
4270
|
try {
|
|
6082
4271
|
if (pluginHotCache) {
|
|
@@ -6098,15 +4287,16 @@ const plugin = {
|
|
|
6098
4287
|
if (reranked.length === 0)
|
|
6099
4288
|
return undefined;
|
|
6100
4289
|
// Relevance gate removed in rc.22 (see recall tool comment).
|
|
6101
|
-
// 7. Build context string.
|
|
6102
|
-
const
|
|
4290
|
+
// 7. Build context string using core's unified recall formatter (adds dates + header).
|
|
4291
|
+
const recallItems = reranked.map((m) => {
|
|
6103
4292
|
const meta = hookMetaMap.get(m.id);
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
4293
|
+
return {
|
|
4294
|
+
category: meta?.category ?? 'claim',
|
|
4295
|
+
text: m.text,
|
|
4296
|
+
created_at: m.createdAt ?? 0,
|
|
4297
|
+
};
|
|
6108
4298
|
});
|
|
6109
|
-
const contextString =
|
|
4299
|
+
const contextString = getSmartImportWasm().formatRecallContext(JSON.stringify(recallItems), BigInt(Math.floor(Date.now() / 1000)));
|
|
6110
4300
|
return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
|
|
6111
4301
|
}
|
|
6112
4302
|
// --- Server mode (existing behavior) ---
|
|
@@ -6177,18 +4367,18 @@ const plugin = {
|
|
|
6177
4367
|
// 6. Re-rank with BM25 + cosine + RRF fusion (intent-weighted).
|
|
6178
4368
|
const srvHookIntent = detectQueryIntent(evt.prompt);
|
|
6179
4369
|
const reranked = rerank(evt.prompt, queryEmbedding ?? [], rerankerCandidates, 8, INTENT_WEIGHTS[srvHookIntent],
|
|
6180
|
-
/* applySourceWeights (Retrieval v2 Tier 1) */
|
|
4370
|
+
/* applySourceWeights (Retrieval v2 Tier 1) */ false);
|
|
6181
4371
|
if (reranked.length === 0)
|
|
6182
4372
|
return undefined;
|
|
6183
4373
|
// Relevance gate removed in rc.22 (see recall tool comment).
|
|
6184
|
-
// 7. Build context string.
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
});
|
|
6191
|
-
const contextString =
|
|
4374
|
+
// 7. Build context string using core's unified recall formatter (adds dates + header).
|
|
4375
|
+
// Server mode has no category metadata, so we use 'claim' as default.
|
|
4376
|
+
const srvRecallItems = reranked.map((m) => ({
|
|
4377
|
+
category: 'claim',
|
|
4378
|
+
text: m.text,
|
|
4379
|
+
created_at: m.createdAt ?? 0,
|
|
4380
|
+
}));
|
|
4381
|
+
const contextString = getSmartImportWasm().formatRecallContext(JSON.stringify(srvRecallItems), BigInt(Math.floor(Date.now() / 1000)));
|
|
6192
4382
|
return { prependContext: consumeBannerForPrepend() + contextString + welcomeBack + billingWarning };
|
|
6193
4383
|
}
|
|
6194
4384
|
catch (err) {
|
|
@@ -6285,6 +4475,14 @@ const plugin = {
|
|
|
6285
4475
|
// surface (scanner constraint: a single file may not contain both
|
|
6286
4476
|
// fs.read* AND outbound-request trigger words). Deps are passed in
|
|
6287
4477
|
// here with neutral aliases for the same reason.
|
|
4478
|
+
//
|
|
4479
|
+
// Lifecycle (rc.20, #402): register() can run more than once per process
|
|
4480
|
+
// (OpenClaw's SIGUSR1 restarts are IN-PROCESS, so the module cache and any
|
|
4481
|
+
// running poller survive). No guard is needed here — startTrajectoryPoller
|
|
4482
|
+
// holds a module-global singleton and stops the previous poller before
|
|
4483
|
+
// starting a new one, and each tick self-terminates if the plugin's own
|
|
4484
|
+
// module file is gone (uninstalled/replaced). This prevents the poller
|
|
4485
|
+
// accumulation + zombie-old-version submitters seen on pop-os.
|
|
6288
4486
|
// ---------------------------------------------------------------
|
|
6289
4487
|
startTrajectoryPoller({
|
|
6290
4488
|
logger: api.logger,
|
|
@@ -6406,63 +4604,98 @@ const plugin = {
|
|
|
6406
4604
|
}
|
|
6407
4605
|
}, { priority: 5 });
|
|
6408
4606
|
// ---------------------------------------------------------------
|
|
6409
|
-
//
|
|
4607
|
+
// OpenClaw native memory integration (Task 2.7) — register TR as the
|
|
4608
|
+
// memory backend: the MemoryPluginCapability + the memory_search /
|
|
4609
|
+
// memory_get tools the active-memory sub-agent drives.
|
|
6410
4610
|
// ---------------------------------------------------------------
|
|
6411
4611
|
//
|
|
6412
|
-
//
|
|
6413
|
-
//
|
|
6414
|
-
//
|
|
6415
|
-
//
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
4612
|
+
// This is THE integration point. For TR to BE the memory backend (not
|
|
4613
|
+
// just a tool plugin), it must register all four against TR's own
|
|
4614
|
+
// pipeline:
|
|
4615
|
+
// 1. api.registerMemoryCapability({ promptBuilder, flushPlanResolver, runtime })
|
|
4616
|
+
// 2. api.registerTool(() => createMemorySearchTool(runtime), { names: ['memory_search'] })
|
|
4617
|
+
// 3. api.registerTool(() => createMemoryGetTool(runtime), { names: ['memory_get'] })
|
|
4618
|
+
//
|
|
4619
|
+
// These registerTool calls go through the real host api.registerTool
|
|
4620
|
+
// directly (the 3.3.2-rc.1 monkey-patch + .loaded.json manifest
|
|
4621
|
+
// machinery were removed in Phase 3 — the conventional names survive
|
|
4622
|
+
// the tool-policy strip in OC 2026.5.x, so the declare-and-dead-letter
|
|
4623
|
+
// dance + manifest are obsolete).
|
|
4624
|
+
//
|
|
4625
|
+
// Deps: buildRecallDeps captures `api.logger` so the closures can call
|
|
4626
|
+
// ensureInitialized lazily on first use. The paired-account context
|
|
4627
|
+
// (authKeyHex / encryptionKey / userId / subgraphOwner) is NOT
|
|
4628
|
+
// resolved here — it's populated by initialize() on the first tool
|
|
4629
|
+
// call. The closures call ensureInitialized internally (see
|
|
4630
|
+
// buildRecallDeps docstring).
|
|
4631
|
+
//
|
|
4632
|
+
// Scanner note: this call is fine inside register() because
|
|
4633
|
+
// register() itself is not scanner-clean (the file pairs config reads
|
|
4634
|
+
// with network calls legitimately). The scanner-clean surface is
|
|
4635
|
+
// native-memory.ts (the wiring helper), which never touches env or
|
|
4636
|
+
// net primitives.
|
|
4637
|
+
//
|
|
4638
|
+
// Graceful degradation: the wiring is wrapped in try/catch so a
|
|
4639
|
+
// failure in the native memory pipeline cannot block plugin load.
|
|
4640
|
+
// NOTE (Phase 3.3): the legacy totalreclaw_* agent tools that used to
|
|
4641
|
+
// serve as the capture fallback were RETIRED in Task 3.2. If this
|
|
4642
|
+
// registration fails, the agent has NO memory surface until the cause
|
|
4643
|
+
// is fixed and the gateway restarted. The before_tool_call gate stays
|
|
4644
|
+
// armed (memory_search/memory_get are simply never registered), and
|
|
4645
|
+
// auto-extraction hooks still fire on the message_received / agent_end
|
|
4646
|
+
// cadence — they write to the subgraph directly, so memories keep
|
|
4647
|
+
// getting captured even if the agent can't read them mid-session.
|
|
4648
|
+
try {
|
|
4649
|
+
registerNativeMemory(api, buildRecallDeps(api.logger));
|
|
4650
|
+
api.logger.info('TotalReclaw: registered native MemoryPluginCapability + memory_search/memory_get tools');
|
|
4651
|
+
}
|
|
4652
|
+
catch (err) {
|
|
4653
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4654
|
+
api.logger.warn(`TotalReclaw: native memory capability registration failed — agent memory_search/memory_get UNAVAILABLE until fixed: ${msg}`);
|
|
4655
|
+
}
|
|
4656
|
+
// ---------------------------------------------------------------
|
|
4657
|
+
// Skill auto-register (rc.17 QA finding: plugin installs but the
|
|
4658
|
+
// SKILL.md playbook does not — agents skipped the separate
|
|
4659
|
+
// `openclaw skills install totalreclaw` step and ended up without
|
|
4660
|
+
// pairing / recall instructions). Mirror the bundled SKILL.md +
|
|
4661
|
+
// skill.json from the package root into the workspace skills dir so
|
|
4662
|
+
// OpenClaw's workspace skill scanner discovers them on the next
|
|
4663
|
+
// gateway load. A single `openclaw plugins install` is now enough
|
|
4664
|
+
// for both plugin + skill. Idempotent + never throws (see
|
|
4665
|
+
// skill-register.ts). Lives in a scanner-clean helper because
|
|
4666
|
+
// index.ts already pairs env-derived config with network calls, so
|
|
4667
|
+
// the disk I/O must stay out of this file.
|
|
4668
|
+
// ---------------------------------------------------------------
|
|
4669
|
+
try {
|
|
4670
|
+
// Re-resolve the dist dir here: the earlier `pluginDir` const
|
|
4671
|
+
// lives inside its own inner try/catch scope and is not visible
|
|
4672
|
+
// this far down. The call is pure + cheap (URL parse + dirname).
|
|
4673
|
+
ensureSkillRegistered({
|
|
4674
|
+
pluginDir: nodePath.dirname(fileURLToPath(import.meta.url)),
|
|
4675
|
+
skillsDir: nodePath.join(CONFIG.openclawWorkspace, 'skills'),
|
|
4676
|
+
logger: api.logger,
|
|
4677
|
+
});
|
|
4678
|
+
}
|
|
4679
|
+
catch (err) {
|
|
4680
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4681
|
+
api.logger.warn(`TotalReclaw: skill auto-register failed (non-fatal): ${msg}`);
|
|
6435
4682
|
}
|
|
6436
4683
|
}
|
|
6437
4684
|
catch (registerErr) {
|
|
6438
4685
|
// ---------------------------------------------------------------
|
|
6439
|
-
//
|
|
4686
|
+
// register() threw — best-effort log then re-throw so the SDK sees
|
|
4687
|
+
// the original failure. (3.3.2-rc.1 used to write a `.error.json`
|
|
4688
|
+
// marker here; that machinery was retired in Phase 3.4 along with
|
|
4689
|
+
// the `.loaded.json` success manifest. The gateway log is now the
|
|
4690
|
+
// source of truth for register() failures.)
|
|
6440
4691
|
// ---------------------------------------------------------------
|
|
6441
|
-
//
|
|
6442
|
-
// Some surface threw out of register(). Drop a structured error
|
|
6443
|
-
// marker the agent can grep. Best-effort logging then re-throw so
|
|
6444
|
-
// the SDK sees the original failure.
|
|
6445
4692
|
const errMsg = registerErr instanceof Error ? registerErr.message : String(registerErr);
|
|
6446
|
-
const errStack = registerErr instanceof Error ? registerErr.stack : undefined;
|
|
6447
4693
|
try {
|
|
6448
4694
|
api.logger.error(`TotalReclaw: register() threw: ${errMsg}`);
|
|
6449
4695
|
}
|
|
6450
4696
|
catch {
|
|
6451
4697
|
// Logger may be unavailable (very early failure path).
|
|
6452
4698
|
}
|
|
6453
|
-
if (_pluginDirForManifest) {
|
|
6454
|
-
try {
|
|
6455
|
-
writePluginError(_pluginDirForManifest, {
|
|
6456
|
-
loadedAt: Date.now(),
|
|
6457
|
-
error: errMsg,
|
|
6458
|
-
stack: errStack,
|
|
6459
|
-
version: 'unknown',
|
|
6460
|
-
});
|
|
6461
|
-
}
|
|
6462
|
-
catch {
|
|
6463
|
-
// Best-effort.
|
|
6464
|
-
}
|
|
6465
|
-
}
|
|
6466
4699
|
throw registerErr;
|
|
6467
4700
|
}
|
|
6468
4701
|
},
|