@totalreclaw/totalreclaw 3.3.1-rc.9 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +249 -1
  2. package/SKILL.md +29 -23
  3. package/api-client.ts +18 -11
  4. package/claims-helper.ts +47 -1
  5. package/config.ts +108 -4
  6. package/confirm-indexed.ts +191 -0
  7. package/crypto.ts +10 -2
  8. package/dist/api-client.js +226 -0
  9. package/dist/billing-cache.js +100 -0
  10. package/dist/claims-helper.js +624 -0
  11. package/dist/config.js +297 -0
  12. package/dist/confirm-indexed.js +127 -0
  13. package/dist/consolidation.js +258 -0
  14. package/dist/contradiction-sync.js +1034 -0
  15. package/dist/crypto.js +138 -0
  16. package/dist/digest-sync.js +361 -0
  17. package/dist/download-ux.js +63 -0
  18. package/dist/embedder-cache.js +185 -0
  19. package/dist/embedder-loader.js +121 -0
  20. package/dist/embedder-network.js +301 -0
  21. package/dist/embedding.js +141 -0
  22. package/dist/extractor.js +1225 -0
  23. package/dist/first-run.js +103 -0
  24. package/dist/fs-helpers.js +725 -0
  25. package/dist/gateway-url.js +197 -0
  26. package/dist/generate-mnemonic.js +13 -0
  27. package/dist/hot-cache-wrapper.js +101 -0
  28. package/dist/import-adapters/base-adapter.js +64 -0
  29. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  30. package/dist/import-adapters/claude-adapter.js +114 -0
  31. package/dist/import-adapters/gemini-adapter.js +201 -0
  32. package/dist/import-adapters/index.js +26 -0
  33. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  34. package/dist/import-adapters/mem0-adapter.js +158 -0
  35. package/dist/import-adapters/types.js +1 -0
  36. package/dist/index.js +5388 -0
  37. package/dist/llm-client.js +687 -0
  38. package/dist/llm-profile-reader.js +346 -0
  39. package/dist/lsh.js +62 -0
  40. package/dist/onboarding-cli.js +750 -0
  41. package/dist/pair-cli.js +344 -0
  42. package/dist/pair-crypto.js +359 -0
  43. package/dist/pair-http.js +404 -0
  44. package/dist/pair-page.js +826 -0
  45. package/dist/pair-qr.js +107 -0
  46. package/dist/pair-remote-client.js +410 -0
  47. package/dist/pair-session-store.js +566 -0
  48. package/dist/pin.js +556 -0
  49. package/dist/qa-bug-report.js +301 -0
  50. package/dist/relay-headers.js +44 -0
  51. package/dist/reranker.js +409 -0
  52. package/dist/retype-setscope.js +368 -0
  53. package/dist/semantic-dedup.js +75 -0
  54. package/dist/subgraph-search.js +289 -0
  55. package/dist/subgraph-store.js +694 -0
  56. package/dist/tool-gating.js +58 -0
  57. package/download-ux.ts +91 -0
  58. package/embedder-cache.ts +230 -0
  59. package/embedder-loader.ts +189 -0
  60. package/embedder-network.ts +350 -0
  61. package/embedding.ts +118 -27
  62. package/fs-helpers.ts +277 -0
  63. package/gateway-url.ts +57 -9
  64. package/index.ts +469 -250
  65. package/llm-client.ts +4 -3
  66. package/lsh.ts +7 -2
  67. package/onboarding-cli.ts +114 -1
  68. package/package.json +24 -5
  69. package/pair-cli.ts +76 -8
  70. package/pair-crypto.ts +34 -24
  71. package/pair-page.ts +28 -17
  72. package/pair-qr.ts +152 -0
  73. package/pair-remote-client.ts +540 -0
  74. package/pin.ts +31 -0
  75. package/qa-bug-report.ts +84 -2
  76. package/relay-headers.ts +50 -0
  77. package/reranker.ts +40 -0
  78. package/retype-setscope.ts +69 -8
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +4 -3
  81. package/subgraph-store.ts +15 -10
package/dist/config.js ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Plugin configuration — centralized env var reads.
3
+ * This file ONLY reads process.env. No network calls, no I/O.
4
+ * Other modules import config values from here.
5
+ *
6
+ * OpenClaw's security scanner flags files that contain BOTH process.env reads
7
+ * AND network calls. By centralizing all env reads here, no other file needs
8
+ * to touch process.env directly.
9
+ *
10
+ * v1 env var cleanup — see `docs/guides/env-vars-reference.md`.
11
+ * Removed user-facing vars: TOTALRECLAW_CHAIN_ID, TOTALRECLAW_EMBEDDING_MODEL,
12
+ * TOTALRECLAW_STORE_DEDUP, TOTALRECLAW_LLM_MODEL, TOTALRECLAW_TAXONOMY_VERSION.
13
+ *
14
+ * NOTE: ``TOTALRECLAW_SESSION_ID`` was in the removed list during the v1
15
+ * cleanup and silently rejected with a warning. That broke Axiom log tracing
16
+ * for QA — the qa-totalreclaw skill prescribes setting the var so relay logs
17
+ * are searchable by ``X-TotalReclaw-Session``. Restored as a SUPPORTED
18
+ * variable: read here, forwarded as the ``X-TotalReclaw-Session`` header on
19
+ * every outbound relay call. Mirrors the Python-side fix
20
+ * (`python/src/totalreclaw/agent/state.py`, v2.0.2). See internal#127.
21
+ * Removed legacy gates: TOTALRECLAW_CLAIM_FORMAT, TOTALRECLAW_DIGEST_MODE,
22
+ * TOTALRECLAW_AUTO_RESOLVE_MODE (the last one moved to an internal debug
23
+ * module; see `contradiction-sync.ts`).
24
+ *
25
+ * Tuning knobs (cosine threshold, min importance, cache TTL, etc.) are now
26
+ * delivered via the relay billing response. Env-var fallbacks are kept only
27
+ * for self-hosted deployments where the server may not surface those values.
28
+ */
29
+ import path from 'node:path';
30
+ const home = process.env.HOME ?? '/home/node';
31
+ /**
32
+ * Removed env vars — warn once per process if still set so operators know
33
+ * their config is a no-op. The removal list matches `docs/guides/env-vars-reference.md`.
34
+ */
35
+ const REMOVED_ENV_VARS = [
36
+ 'TOTALRECLAW_CHAIN_ID',
37
+ 'TOTALRECLAW_EMBEDDING_MODEL',
38
+ 'TOTALRECLAW_STORE_DEDUP',
39
+ 'TOTALRECLAW_LLM_MODEL',
40
+ // NOTE: TOTALRECLAW_SESSION_ID was here before; restored as SUPPORTED
41
+ // (forwarded as X-TotalReclaw-Session header). Do NOT add it back to this
42
+ // list — see file header + internal#127.
43
+ 'TOTALRECLAW_TAXONOMY_VERSION',
44
+ 'TOTALRECLAW_CLAIM_FORMAT',
45
+ 'TOTALRECLAW_DIGEST_MODE',
46
+ ];
47
+ // Migration guide URL — kept as a constant so the regression test can assert
48
+ // the exact link text in the warning. Pointing at GitHub raw-blob is more
49
+ // useful than the relative repo path: operators copying the warning out of
50
+ // stderr usually do not have the repo cloned. rc.22 finding #4.
51
+ export const ENV_VARS_REFERENCE_URL = 'https://github.com/p-diogo/totalreclaw/blob/main/docs/guides/env-vars-reference.md';
52
+ function warnRemovedEnvVars(warn = console.warn) {
53
+ const set = REMOVED_ENV_VARS.filter((name) => process.env[name] !== undefined);
54
+ if (set.length === 0)
55
+ return;
56
+ warn(`TotalReclaw: ignoring removed env var(s): ${set.join(', ')}. ` +
57
+ `Migration guide: ${ENV_VARS_REFERENCE_URL}`);
58
+ }
59
+ // Emit the warning once at import time. Safe because this module is loaded
60
+ // exactly once per process.
61
+ warnRemovedEnvVars();
62
+ /** Runtime override for recovery phrase (set by hot-reload after setup). */
63
+ let _recoveryPhraseOverride = null;
64
+ export function setRecoveryPhraseOverride(phrase) {
65
+ _recoveryPhraseOverride = phrase;
66
+ }
67
+ export function getRecoveryPhrase() {
68
+ return _recoveryPhraseOverride ?? process.env.TOTALRECLAW_RECOVERY_PHRASE ?? '';
69
+ }
70
+ /**
71
+ * Read the QA / observability session tag from the environment.
72
+ *
73
+ * When set, every outbound relay call adds the ``X-TotalReclaw-Session``
74
+ * header so relay logs (and Axiom queries) can be filtered by this tag —
75
+ * this is what the qa-totalreclaw skill relies on to scope log searches per
76
+ * QA run. When unset, returns ``null`` and the header is omitted.
77
+ *
78
+ * Read via getter (not snapshotted) so operators / test harnesses can flip
79
+ * the var between calls without reloading the module.
80
+ *
81
+ * Mirrors the Python-side ``RelayClient._session_id`` resolution priority.
82
+ * See internal#127 / `docs/guides/env-vars-reference.md`.
83
+ */
84
+ export function getSessionId() {
85
+ const raw = process.env.TOTALRECLAW_SESSION_ID;
86
+ if (raw === undefined)
87
+ return null;
88
+ const trimmed = raw.trim();
89
+ return trimmed.length > 0 ? trimmed : null;
90
+ }
91
+ /**
92
+ * Runtime override for chain ID, set after the relay billing response is
93
+ * read. Free tier stays on 84532 (Base Sepolia); Pro tier flips to 100
94
+ * (Gnosis mainnet). The relay routes Pro writes to Gnosis, so Pro-tier
95
+ * UserOps MUST be signed against chain 100 — otherwise the bundler rejects
96
+ * the signature with AA23.
97
+ *
98
+ * See index.ts: after the billing lookup completes, call
99
+ * `setChainIdOverride(100)` for Pro users. Free users can leave the
100
+ * override unset.
101
+ */
102
+ let _chainIdOverride = null;
103
+ export function setChainIdOverride(chainId) {
104
+ _chainIdOverride = chainId;
105
+ }
106
+ /** Reset the chain override — used by tests. */
107
+ export function __resetChainIdOverrideForTests() {
108
+ _chainIdOverride = null;
109
+ }
110
+ export const CONFIG = {
111
+ // Core — recoveryPhrase reads from override first, then env var.
112
+ // Use getRecoveryPhrase() for dynamic access; this property is for
113
+ // backward-compat with code that reads CONFIG.recoveryPhrase at init time.
114
+ get recoveryPhrase() {
115
+ return getRecoveryPhrase();
116
+ },
117
+ /**
118
+ * Optional QA / observability session tag forwarded to the relay as
119
+ * ``X-TotalReclaw-Session``. See `getSessionId()` above. Getter form so
120
+ * tests + harnesses can flip the env between calls. ``null`` when unset
121
+ * (header omitted).
122
+ */
123
+ get sessionId() {
124
+ return getSessionId();
125
+ },
126
+ serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
127
+ selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
128
+ credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
129
+ // 3.2.0 onboarding state file — separate from credentials.json so it
130
+ // never contains secrets. Loaded on every plugin init + on every
131
+ // before_tool_call gate check.
132
+ onboardingStatePath: process.env.TOTALRECLAW_STATE_PATH || path.join(home, '.totalreclaw', 'state.json'),
133
+ // 3.3.0 QR-pairing session store. Separate file from both credentials.json
134
+ // and state.json so the session-store module does not have to touch either
135
+ // (keeps scanner surface isolated). Contains ephemeral x25519 secret keys
136
+ // for 15-min TTL windows; 0600 mode.
137
+ pairSessionsPath: process.env.TOTALRECLAW_PAIR_SESSIONS_PATH || path.join(home, '.totalreclaw', 'pair-sessions.json'),
138
+ // 3.3.1-rc.11 — pair-flow transport selector. Mirrors the Python-side
139
+ // `TOTALRECLAW_PAIR_MODE` env (rc.10). `'relay'` (default) routes
140
+ // `totalreclaw_pair` through the universal-reachability WebSocket relay at
141
+ // `TOTALRECLAW_PAIR_RELAY_URL`. `'local'` preserves the rc.4–rc.10 loopback
142
+ // HTTP flow (the plugin serves `/plugin/totalreclaw/pair/*` via
143
+ // `pair-http.ts`). Air-gapped / self-hosted users can pin `'local'` here.
144
+ pairMode: (() => {
145
+ const v = (process.env.TOTALRECLAW_PAIR_MODE ?? '').trim().toLowerCase();
146
+ return v === 'local' ? 'local' : 'relay';
147
+ })(),
148
+ // 3.3.1-rc.11 — relay base URL for the WebSocket-brokered pair flow.
149
+ // `wss://` preferred; `https://` is rewritten in the remote-client.
150
+ pairRelayUrl: (process.env.TOTALRECLAW_PAIR_RELAY_URL
151
+ || 'wss://api-staging.totalreclaw.xyz').replace(/\/+$/, ''),
152
+ // Chain — chainId is no longer user-configurable. It is auto-detected from
153
+ // the relay billing response (free = Base Sepolia / 84532, Pro = Gnosis /
154
+ // 100). The default here is used only before the first billing lookup
155
+ // completes. Self-hosted users can still point at a custom DataEdge via
156
+ // TOTALRECLAW_DATA_EDGE_ADDRESS / TOTALRECLAW_ENTRYPOINT_ADDRESS /
157
+ // TOTALRECLAW_RPC_URL (undocumented; internal knobs).
158
+ //
159
+ // Reads the runtime override set by the billing auto-detect in index.ts.
160
+ // Falls back to 84532 (free tier / pre-billing-lookup). Must be a getter,
161
+ // not a literal — a literal would freeze all Pro-tier UserOps to the
162
+ // wrong chainId and AA23 at the bundler.
163
+ get chainId() {
164
+ return _chainIdOverride ?? 84532;
165
+ },
166
+ dataEdgeAddress: process.env.TOTALRECLAW_DATA_EDGE_ADDRESS || '',
167
+ entryPointAddress: process.env.TOTALRECLAW_ENTRYPOINT_ADDRESS || '',
168
+ rpcUrl: process.env.TOTALRECLAW_RPC_URL || '',
169
+ // Tuning knobs — default values used only as local fallback for
170
+ // self-hosted mode. Managed-service clients override these from the relay
171
+ // billing response via `resolveTuning(...)`.
172
+ // See: docs/specs/totalreclaw/client-consistency.md
173
+ cosineThreshold: parseFloat(process.env.TOTALRECLAW_COSINE_THRESHOLD ?? '0.15'),
174
+ extractInterval: parseInt(process.env.TOTALRECLAW_EXTRACT_INTERVAL ?? process.env.TOTALRECLAW_EXTRACT_EVERY_TURNS ?? '3', 10),
175
+ relevanceThreshold: parseFloat(process.env.TOTALRECLAW_RELEVANCE_THRESHOLD ?? '0.3'),
176
+ semanticSkipThreshold: parseFloat(process.env.TOTALRECLAW_SEMANTIC_SKIP_THRESHOLD ?? '0.85'),
177
+ cacheTtlMs: parseInt(process.env.TOTALRECLAW_CACHE_TTL_MS ?? String(5 * 60 * 1000), 10),
178
+ minImportance: Math.max(1, Math.min(10, Number(process.env.TOTALRECLAW_MIN_IMPORTANCE) || 6)),
179
+ trapdoorBatchSize: parseInt(process.env.TOTALRECLAW_TRAPDOOR_BATCH_SIZE ?? '5', 10),
180
+ pageSize: parseInt(process.env.TOTALRECLAW_SUBGRAPH_PAGE_SIZE ?? '1000', 10),
181
+ // Store-time dedup is always ON. TOTALRECLAW_STORE_DEDUP was removed in v1.
182
+ storeDedupEnabled: true,
183
+ // LLM provider API keys (read once, passed to llm-client). Model selection
184
+ // is entirely automatic via `deriveCheapModel(provider)` — the
185
+ // TOTALRECLAW_LLM_MODEL override was removed in v1.
186
+ llmApiKeys: {
187
+ zai: process.env.ZAI_API_KEY || '',
188
+ anthropic: process.env.ANTHROPIC_API_KEY || '',
189
+ openai: process.env.OPENAI_API_KEY || '',
190
+ gemini: process.env.GEMINI_API_KEY || '',
191
+ google: process.env.GOOGLE_API_KEY || '',
192
+ mistral: process.env.MISTRAL_API_KEY || '',
193
+ groq: process.env.GROQ_API_KEY || '',
194
+ deepseek: process.env.DEEPSEEK_API_KEY || '',
195
+ openrouter: process.env.OPENROUTER_API_KEY || '',
196
+ xai: process.env.XAI_API_KEY || '',
197
+ together: process.env.TOGETHER_API_KEY || '',
198
+ cerebras: process.env.CEREBRAS_API_KEY || '',
199
+ },
200
+ // 3.3.1-rc.3: zai base-URL override. Read via a getter so tests can
201
+ // mutate `process.env.ZAI_BASE_URL` between calls — the value is NOT
202
+ // frozen at module load. Default is the coding endpoint; the rc.3
203
+ // auto-fallback flips to the standard endpoint on an "Insufficient
204
+ // balance" 429.
205
+ get zaiBaseUrl() {
206
+ const override = process.env.ZAI_BASE_URL;
207
+ if (override && override.trim())
208
+ return override.trim().replace(/\/+$/, '');
209
+ return 'https://api.z.ai/api/coding/paas/v4';
210
+ },
211
+ // 3.3.1-rc.3: retry budget for chatCompletion. Default 60s covers
212
+ // multi-minute upstream outages. Read as a plain value (not getter)
213
+ // so tests that patch env need to reload the module — but the default
214
+ // suffices for production.
215
+ llmRetryBudgetMs: (() => {
216
+ const raw = process.env.TOTALRECLAW_LLM_RETRY_BUDGET_MS;
217
+ const parsed = raw ? parseInt(raw, 10) : NaN;
218
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 60_000;
219
+ })(),
220
+ // 3.3.1-rc.3: GitHub personal-access token used by the RC-gated
221
+ // `totalreclaw_report_qa_bug` tool. `TOTALRECLAW_QA_GITHUB_TOKEN` is
222
+ // the dedicated variable; `GITHUB_TOKEN` is a fallback for CI-style
223
+ // setups where the same token is shared across tools. Read via getter
224
+ // so operators can set the var after the process starts (e.g. via a
225
+ // dotenv reload) and the next tool call picks it up.
226
+ get qaGithubToken() {
227
+ return process.env.TOTALRECLAW_QA_GITHUB_TOKEN || process.env.GITHUB_TOKEN || '';
228
+ },
229
+ // 3.3.1-rc.14: optional target-repo override for the RC-gated QA
230
+ // bug-report tool. The `qa-bug-report` module enforces a
231
+ // "slug ends in `-internal`" rule on whatever is resolved here, so
232
+ // this override is only useful for forks / mirrors of the internal
233
+ // tracker. Leaving unset uses the production default
234
+ // (`p-diogo/totalreclaw-internal`). Read via getter so operators can
235
+ // flip the var at runtime.
236
+ get qaRepoOverride() {
237
+ return process.env.TOTALRECLAW_QA_REPO || '';
238
+ },
239
+ // 3.3.1-rc.21 (issue #128): verbose-register flag. When enabled, the
240
+ // plugin emits opt-in `info`-level breadcrumbs after sensitive
241
+ // registerTool calls (currently `totalreclaw_pair`) to help ops/QA
242
+ // grep gateway logs for definitive proof the tool was declared.
243
+ // Default OFF — the breadcrumb is debug-grade and was bleeding into
244
+ // `openclaw agent --json` stdout, breaking programmatic parsers.
245
+ // Enable with either:
246
+ // TOTALRECLAW_VERBOSE_REGISTER=1 (specific opt-in)
247
+ // TOTALRECLAW_DEBUG=1 (general debug toggle)
248
+ // Read via getter so flipping the env at runtime takes effect on the
249
+ // next gateway start without a rebuild.
250
+ get verboseRegister() {
251
+ const specific = (process.env.TOTALRECLAW_VERBOSE_REGISTER ?? '').trim().toLowerCase();
252
+ if (specific === '1' || specific === 'true' || specific === 'yes')
253
+ return true;
254
+ const general = (process.env.TOTALRECLAW_DEBUG ?? '').trim().toLowerCase();
255
+ return general === '1' || general === 'true' || general === 'yes';
256
+ },
257
+ // Paths
258
+ home,
259
+ billingCachePath: path.join(home, '.totalreclaw', 'billing-cache.json'),
260
+ cachePath: process.env.TOTALRECLAW_CACHE_PATH || path.join(home, '.totalreclaw', 'cache.enc'),
261
+ openclawWorkspace: path.join(home, '.openclaw', 'workspace'),
262
+ // 3.3.1-rc.22 — lazy embedder bundle cache. The embedder
263
+ // (`@huggingface/transformers` + `onnxruntime-node` + the q4 ONNX
264
+ // model) is no longer shipped inside the plugin tarball; it is fetched
265
+ // on first `embed()` call from a versioned GitHub Release and cached
266
+ // here. Separate path from `cachePath` (encrypted vault cache) so the
267
+ // two never collide. See `embedder-loader.ts`.
268
+ embedderCachePath: process.env.TOTALRECLAW_EMBEDDER_CACHE_PATH || path.join(home, '.totalreclaw', 'embedder'),
269
+ // 3.3.1-rc.22 — override the GitHub-Releases URL templates. Only useful
270
+ // for air-gapped / mirror deployments and self-hosted CI. Empty string
271
+ // falls back to the static defaults baked into the embedder code path.
272
+ embedderBundleUrlTemplate: process.env.TOTALRECLAW_EMBEDDER_BUNDLE_URL || '',
273
+ embedderManifestUrlTemplate: process.env.TOTALRECLAW_EMBEDDER_MANIFEST_URL || '',
274
+ };
275
+ /**
276
+ * Merge a billing-response tuning block with the local fallback values.
277
+ *
278
+ * Use this at the call-site that needs a threshold, passing the features
279
+ * blob from the billing cache. No I/O here — callers read the cache once
280
+ * and hand the features in.
281
+ */
282
+ export function resolveTuning(features) {
283
+ return {
284
+ cosineThreshold: features?.cosine_threshold ?? CONFIG.cosineThreshold,
285
+ relevanceThreshold: features?.relevance_threshold ?? CONFIG.relevanceThreshold,
286
+ semanticSkipThreshold: features?.semantic_skip_threshold ?? CONFIG.semanticSkipThreshold,
287
+ minImportance: features?.min_importance ?? CONFIG.minImportance,
288
+ cacheTtlMs: features?.cache_ttl_ms ?? CONFIG.cacheTtlMs,
289
+ trapdoorBatchSize: features?.trapdoor_batch_size ?? CONFIG.trapdoorBatchSize,
290
+ pageSize: features?.subgraph_page_size ?? CONFIG.pageSize,
291
+ };
292
+ }
293
+ // Exposed for tests that want to assert the removed-var warning behaviour.
294
+ export const __internal = {
295
+ REMOVED_ENV_VARS,
296
+ warnRemovedEnvVars,
297
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Read-after-write primitive — confirm a fact id has been indexed by the
3
+ * subgraph after an on-chain mutation (retype / set_scope / pin / unpin /
4
+ * forget).
5
+ *
6
+ * Wraps the pure-compute halves exported by `@totalreclaw/core`
7
+ * (`wasmConfirmIndexedQuery`, `wasmConfirmIndexedParse`) in a host-side
8
+ * polling loop. Subgraph indexer lag on Gnosis production runs 5-30s; this
9
+ * helper polls every `pollIntervalMs` (default 1000ms) up to `timeoutMs`
10
+ * (default 30000ms).
11
+ *
12
+ * Why this exists
13
+ * ---------------
14
+ * Pre-fix, mutation tools returned `{success: true}` based on the bundler
15
+ * ack alone. A user who immediately ran `totalreclaw_export` would see the
16
+ * pre-mutation state, because the subgraph indexer hadn't yet observed the
17
+ * L1 inclusion. Confusing UX, root cause of rc.18 finding #117.
18
+ *
19
+ * Post-fix, mutation tools call `confirmIndexed(newFactId)` after submitting
20
+ * the batched UserOp; on success they return normally, on timeout they
21
+ * return `{success: true, partial: true, ...}` with the chain write
22
+ * acknowledged but the indexer-level confirmation withheld.
23
+ *
24
+ * Mnemonic isolation: this helper never touches the mnemonic, encryption
25
+ * key, or any decrypted blob. Only reads the public {id, isActive,
26
+ * blockNumber} of a fact.
27
+ */
28
+ import { createRequire } from 'node:module';
29
+ import { getSubgraphConfig } from './subgraph-store.js';
30
+ import { buildRelayHeaders } from './relay-headers.js';
31
+ const requireWasm = createRequire(import.meta.url);
32
+ let _wasm = null;
33
+ function getWasm() {
34
+ if (!_wasm)
35
+ _wasm = requireWasm('@totalreclaw/core');
36
+ return _wasm;
37
+ }
38
+ /**
39
+ * Poll the subgraph until the new fact id is indexed-and-active, or the
40
+ * timeout elapses. Returns a result object describing the outcome — never
41
+ * throws on indexer-level transient errors; the caller decides whether to
42
+ * surface a `partial: true` flag based on `result.indexed`.
43
+ *
44
+ * The host's submitBatch already returned a tx hash before this is called,
45
+ * so on `indexed: false` the on-chain write is still acknowledged — just not
46
+ * yet visible in the read API.
47
+ */
48
+ export async function confirmIndexed(factId, options = {}) {
49
+ // WASM bindings may be unavailable (e.g. core@<2.3.0 not yet published).
50
+ // In that case the chain write has still succeeded — confirm step is
51
+ // observational only. Return `indexed: false` so callers surface
52
+ // `partial: true` rather than fail the whole tool invocation.
53
+ let wasm;
54
+ let query;
55
+ let pollIntervalMs;
56
+ let timeoutMs;
57
+ try {
58
+ wasm = getWasm();
59
+ pollIntervalMs = options.pollIntervalMs ?? Number(wasm.wasmConfirmIndexedDefaultPollMs?.() ?? 1000);
60
+ timeoutMs = options.timeoutMs ?? Number(wasm.wasmConfirmIndexedDefaultTimeoutMs?.() ?? 30000);
61
+ query = wasm.wasmConfirmIndexedQuery();
62
+ }
63
+ catch (err) {
64
+ return {
65
+ indexed: false,
66
+ attempts: 0,
67
+ elapsedMs: 0,
68
+ lastError: `confirm-indexed wasm bindings unavailable: ${err instanceof Error ? err.message : String(err)}`,
69
+ };
70
+ }
71
+ const subgraphUrl = options.subgraphUrl ?? `${getSubgraphConfig().relayUrl}/v1/subgraph`;
72
+ const overrides = {
73
+ 'Content-Type': 'application/json',
74
+ };
75
+ if (options.authKeyHex)
76
+ overrides['Authorization'] = `Bearer ${options.authKeyHex}`;
77
+ const headers = buildRelayHeaders(overrides);
78
+ const body = JSON.stringify({ query, variables: { id: factId } });
79
+ const poster = options.poster ??
80
+ (async (url, b, h) => {
81
+ const r = await fetch(url, { method: 'POST', headers: h, body: b });
82
+ return { ok: r.ok, status: r.status, text: () => r.text() };
83
+ });
84
+ const expect = options.expect ?? 'active';
85
+ const start = Date.now();
86
+ let attempts = 0;
87
+ let lastError;
88
+ while (Date.now() - start < timeoutMs) {
89
+ attempts++;
90
+ try {
91
+ const r = await poster(subgraphUrl, body, headers);
92
+ if (r.ok) {
93
+ const txt = await r.text();
94
+ try {
95
+ // wasmConfirmIndexedParse returns `true` when fact is present AND
96
+ // isActive==true. For `expect: 'inactive'` we invert: a `false`
97
+ // (fact missing OR present-but-inactive) is the resolution signal.
98
+ const isActive = wasm.wasmConfirmIndexedParse(txt);
99
+ const resolved = expect === 'active' ? isActive : !isActive;
100
+ if (resolved) {
101
+ return { indexed: true, attempts, elapsedMs: Date.now() - start };
102
+ }
103
+ }
104
+ catch (parseErr) {
105
+ lastError = parseErr instanceof Error ? parseErr.message : String(parseErr);
106
+ }
107
+ }
108
+ else {
109
+ lastError = `HTTP ${r.status}`;
110
+ }
111
+ }
112
+ catch (err) {
113
+ lastError = err instanceof Error ? err.message : String(err);
114
+ }
115
+ // Sleep before the next attempt — but only if there's still budget.
116
+ const remaining = timeoutMs - (Date.now() - start);
117
+ if (remaining <= 0)
118
+ break;
119
+ await new Promise((res) => setTimeout(res, Math.min(pollIntervalMs, remaining)));
120
+ }
121
+ return {
122
+ indexed: false,
123
+ attempts,
124
+ elapsedMs: Date.now() - start,
125
+ lastError,
126
+ };
127
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * TotalReclaw Plugin - Memory Consolidation & Near-Duplicate Detection
3
+ *
4
+ * Provides cross-session / cross-vault deduplication of stored facts using
5
+ * cosine similarity on their embeddings. Unlike semantic-dedup.ts (which
6
+ * handles within-batch dedup at threshold 0.9), this module handles:
7
+ *
8
+ * 1. Store-time dedup — before writing a new fact, check whether a
9
+ * near-duplicate already exists in the vault (findNearDuplicate).
10
+ * 2. Supersede logic — when a near-duplicate is found, decide whether
11
+ * the new fact should replace or be skipped (shouldSupersede).
12
+ * 3. Bulk consolidation — cluster all facts in the vault and identify
13
+ * groups of near-duplicates for cleanup (clusterFacts).
14
+ *
15
+ * Delegates core computation to `@totalreclaw/core` Rust WASM module where
16
+ * bindings are available. `shouldSupersede` uses the core directly.
17
+ * `findNearDuplicate` and `clusterFacts` use the core's `findBestNearDuplicate`
18
+ * and `clusterFacts` WASM functions when available, falling back to local
19
+ * implementations that use WASM-backed `cosineSimilarity`.
20
+ *
21
+ * Threshold helpers remain local (they read process.env).
22
+ */
23
+ import { createRequire } from 'node:module';
24
+ import { cosineSimilarity } from './reranker.js';
25
+ // ---------------------------------------------------------------------------
26
+ // Lazy-load WASM core (mirrors claims-helper.ts / contradiction-sync.ts
27
+ // pattern — plays nicely under both the OpenClaw runtime (CJS-ish tsx) and
28
+ // bare Node ESM used by tests).
29
+ // ---------------------------------------------------------------------------
30
+ const requireWasm = createRequire(import.meta.url);
31
+ let _wasm = null;
32
+ function getWasm() {
33
+ if (!_wasm)
34
+ _wasm = requireWasm('@totalreclaw/core');
35
+ return _wasm;
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Configuration
39
+ // ---------------------------------------------------------------------------
40
+ /**
41
+ * Get the cosine similarity threshold for store-time dedup.
42
+ *
43
+ * Configurable via TOTALRECLAW_STORE_DEDUP_THRESHOLD env var.
44
+ * Must be a number in [0, 1]. Falls back to 0.85 if invalid or unset.
45
+ */
46
+ export function getStoreDedupThreshold() {
47
+ const envVal = process.env.TOTALRECLAW_STORE_DEDUP_THRESHOLD;
48
+ if (envVal !== undefined) {
49
+ const parsed = parseFloat(envVal);
50
+ if (!isNaN(parsed) && parsed >= 0 && parsed <= 1)
51
+ return parsed;
52
+ }
53
+ return 0.85;
54
+ }
55
+ /**
56
+ * Get the cosine similarity threshold for bulk consolidation clustering.
57
+ *
58
+ * Configurable via TOTALRECLAW_CONSOLIDATION_THRESHOLD env var.
59
+ * Must be a number in [0, 1]. Falls back to 0.88 if invalid or unset.
60
+ */
61
+ export function getConsolidationThreshold() {
62
+ const envVal = process.env.TOTALRECLAW_CONSOLIDATION_THRESHOLD;
63
+ if (envVal !== undefined) {
64
+ const parsed = parseFloat(envVal);
65
+ if (!isNaN(parsed) && parsed >= 0 && parsed <= 1)
66
+ return parsed;
67
+ }
68
+ return 0.88;
69
+ }
70
+ /** Maximum candidates to compare against during store-time dedup. */
71
+ export const STORE_DEDUP_MAX_CANDIDATES = 200;
72
+ // ---------------------------------------------------------------------------
73
+ // Store-time dedup
74
+ // ---------------------------------------------------------------------------
75
+ /**
76
+ * Find the best near-duplicate match for a new fact among existing candidates.
77
+ *
78
+ * Compares the new fact's embedding against all candidates using cosine
79
+ * similarity. Returns the candidate with the highest similarity above the
80
+ * threshold, or null if no match is found.
81
+ *
82
+ * Candidates without embeddings are skipped (fail-safe).
83
+ *
84
+ * @param newFactEmbedding - Embedding vector for the new fact
85
+ * @param candidates - Existing facts to compare against
86
+ * @param threshold - Cosine similarity threshold (e.g. 0.85)
87
+ * @returns - Best match above threshold, or null
88
+ */
89
+ export function findNearDuplicate(newFactEmbedding, candidates, threshold) {
90
+ const wasm = getWasm();
91
+ // Use core's findBestNearDuplicate if available (added in core >=1.5.0;
92
+ // guaranteed present in core >=2.0.0 which this plugin depends on).
93
+ if (typeof wasm.findBestNearDuplicate === 'function') {
94
+ const existing = candidates
95
+ .filter((c) => c.embedding && c.embedding.length > 0)
96
+ .map((c) => ({ id: c.id, embedding: c.embedding }));
97
+ if (existing.length === 0)
98
+ return null;
99
+ const resultJs = wasm.findBestNearDuplicate(JSON.stringify(newFactEmbedding), JSON.stringify(existing), threshold);
100
+ if (resultJs == null)
101
+ return null;
102
+ const result = typeof resultJs === 'string' ? JSON.parse(resultJs) : resultJs;
103
+ const matched = candidates.find((c) => c.id === result.fact_id);
104
+ if (!matched)
105
+ return null;
106
+ return { existingFact: matched, similarity: result.similarity };
107
+ }
108
+ // Fallback: local loop using WASM-backed cosineSimilarity. Defensive only
109
+ // — core >=2.0.0 always exposes findBestNearDuplicate.
110
+ let bestMatch = null;
111
+ for (const candidate of candidates) {
112
+ if (!candidate.embedding || candidate.embedding.length === 0)
113
+ continue;
114
+ const similarity = cosineSimilarity(newFactEmbedding, candidate.embedding);
115
+ if (similarity >= threshold) {
116
+ if (!bestMatch || similarity > bestMatch.similarity) {
117
+ bestMatch = { existingFact: candidate, similarity };
118
+ }
119
+ }
120
+ }
121
+ return bestMatch;
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Supersede logic
125
+ // ---------------------------------------------------------------------------
126
+ /**
127
+ * Decide whether a new fact should supersede an existing near-duplicate.
128
+ *
129
+ * - Higher importance wins.
130
+ * - Equal importance: new fact supersedes (newer is preferred).
131
+ *
132
+ * Delegates to `@totalreclaw/core` WASM `shouldSupersede`.
133
+ *
134
+ * @param newImportance - Importance score of the new fact
135
+ * @param existingFact - The existing near-duplicate candidate
136
+ * @returns - 'supersede' if new fact should replace, 'skip' otherwise
137
+ */
138
+ export function shouldSupersede(newImportance, existingFact) {
139
+ const wasm = getWasm();
140
+ return wasm.shouldSupersede(newImportance, existingFact.importance) ? 'supersede' : 'skip';
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Bulk consolidation
144
+ // ---------------------------------------------------------------------------
145
+ /**
146
+ * Cluster facts by semantic similarity using greedy single-pass clustering.
147
+ *
148
+ * Delegates to `@totalreclaw/core` WASM `clusterFacts` which performs the
149
+ * same greedy single-pass algorithm and representative selection. The WASM
150
+ * function returns ID-only clusters; this wrapper maps IDs back to full
151
+ * `DecryptedCandidate` objects for callers.
152
+ *
153
+ * Only returns clusters that have duplicates (i.e. more than one member).
154
+ * Facts without embeddings are not clustered.
155
+ *
156
+ * @param facts - All facts to cluster
157
+ * @param threshold - Cosine similarity threshold (e.g. 0.88)
158
+ * @returns - Clusters with duplicates (representative + duplicates)
159
+ */
160
+ export function clusterFacts(facts, threshold) {
161
+ const wasm = getWasm();
162
+ // Use core's clusterFacts if available (added in core >=1.5.0;
163
+ // guaranteed present in core >=2.0.0 which this plugin depends on).
164
+ if (typeof wasm.clusterFacts === 'function') {
165
+ // Build ConsolidationCandidate JSON for WASM (snake_case fields).
166
+ const wasmCandidates = facts
167
+ .filter((f) => f.embedding && f.embedding.length > 0)
168
+ .map((f) => ({
169
+ id: f.id,
170
+ text: f.text,
171
+ embedding: f.embedding,
172
+ importance: f.importance,
173
+ decay_score: f.decayScore,
174
+ created_at: f.createdAt,
175
+ version: f.version,
176
+ }));
177
+ if (wasmCandidates.length === 0)
178
+ return [];
179
+ const resultJs = wasm.clusterFacts(JSON.stringify(wasmCandidates), threshold);
180
+ // WASM returns a JSON string: [{ representative: string, duplicates: string[] }]
181
+ const wasmClusters = typeof resultJs === 'string' ? JSON.parse(resultJs) : resultJs;
182
+ // Build a lookup map for fast ID -> DecryptedCandidate resolution.
183
+ const byId = new Map();
184
+ for (const f of facts)
185
+ byId.set(f.id, f);
186
+ // Map ID-only clusters back to full DecryptedCandidate objects.
187
+ // Filter out singleton clusters (no duplicates) to match the pre-WASM
188
+ // plugin contract — callers rely on `clusters.length === 0` when nothing
189
+ // duplicates anything.
190
+ const result = [];
191
+ for (const wc of wasmClusters) {
192
+ const rep = byId.get(wc.representative);
193
+ if (!rep)
194
+ continue;
195
+ const dups = wc.duplicates
196
+ .map((id) => byId.get(id))
197
+ .filter((d) => d !== undefined);
198
+ if (dups.length > 0) {
199
+ result.push({ representative: rep, duplicates: dups });
200
+ }
201
+ }
202
+ return result;
203
+ }
204
+ // Fallback: local greedy single-pass clustering using WASM-backed
205
+ // cosineSimilarity. Defensive only — core >=2.0.0 always exposes clusterFacts.
206
+ const clusters = [];
207
+ for (const fact of facts) {
208
+ if (!fact.embedding || fact.embedding.length === 0)
209
+ continue;
210
+ let assigned = false;
211
+ for (const cluster of clusters) {
212
+ const seed = cluster.members[0];
213
+ if (!seed.embedding)
214
+ continue;
215
+ const similarity = cosineSimilarity(fact.embedding, seed.embedding);
216
+ if (similarity >= threshold) {
217
+ cluster.members.push(fact);
218
+ assigned = true;
219
+ break;
220
+ }
221
+ }
222
+ if (!assigned) {
223
+ clusters.push({ members: [fact] });
224
+ }
225
+ }
226
+ const result = [];
227
+ for (const cluster of clusters) {
228
+ if (cluster.members.length < 2)
229
+ continue;
230
+ const representative = pickRepresentative(cluster.members);
231
+ const duplicates = cluster.members.filter((m) => m !== representative);
232
+ result.push({ representative, duplicates });
233
+ }
234
+ return result;
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Local helpers (used only in fallback paths)
238
+ // ---------------------------------------------------------------------------
239
+ /**
240
+ * Pick the best representative from a group of near-duplicate facts.
241
+ *
242
+ * Tiebreak order:
243
+ * 1. Highest decayScore
244
+ * 2. Most recent (highest createdAt)
245
+ * 3. Longest text
246
+ */
247
+ function pickRepresentative(facts) {
248
+ let best = facts[0];
249
+ for (let i = 1; i < facts.length; i++) {
250
+ const f = facts[i];
251
+ if (f.decayScore > best.decayScore ||
252
+ (f.decayScore === best.decayScore && f.createdAt > best.createdAt) ||
253
+ (f.decayScore === best.decayScore && f.createdAt === best.createdAt && f.text.length > best.text.length)) {
254
+ best = f;
255
+ }
256
+ }
257
+ return best;
258
+ }