@totalreclaw/totalreclaw 3.3.1-rc.2 → 3.3.1-rc.21
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/CHANGELOG.md +330 -0
- package/SKILL.md +50 -83
- package/api-client.ts +18 -11
- package/config.ts +117 -3
- package/crypto.ts +10 -2
- package/dist/api-client.js +226 -0
- package/dist/billing-cache.js +100 -0
- package/dist/claims-helper.js +606 -0
- package/dist/config.js +280 -0
- package/dist/consolidation.js +258 -0
- package/dist/contradiction-sync.js +1034 -0
- package/dist/crypto.js +138 -0
- package/dist/digest-sync.js +361 -0
- package/dist/download-ux.js +63 -0
- package/dist/embedding.js +86 -0
- package/dist/extractor.js +1225 -0
- package/dist/first-run.js +103 -0
- package/dist/fs-helpers.js +563 -0
- package/dist/gateway-url.js +197 -0
- package/dist/generate-mnemonic.js +13 -0
- package/dist/hot-cache-wrapper.js +101 -0
- package/dist/import-adapters/base-adapter.js +64 -0
- package/dist/import-adapters/chatgpt-adapter.js +238 -0
- package/dist/import-adapters/claude-adapter.js +114 -0
- package/dist/import-adapters/gemini-adapter.js +201 -0
- package/dist/import-adapters/index.js +26 -0
- package/dist/import-adapters/mcp-memory-adapter.js +219 -0
- package/dist/import-adapters/mem0-adapter.js +158 -0
- package/dist/import-adapters/types.js +1 -0
- package/dist/index.js +5348 -0
- package/dist/llm-client.js +686 -0
- package/dist/llm-profile-reader.js +346 -0
- package/dist/lsh.js +62 -0
- package/dist/onboarding-cli.js +750 -0
- package/dist/pair-cli.js +344 -0
- package/dist/pair-crypto.js +359 -0
- package/dist/pair-http.js +404 -0
- package/dist/pair-page.js +826 -0
- package/dist/pair-qr.js +107 -0
- package/dist/pair-remote-client.js +410 -0
- package/dist/pair-session-store.js +566 -0
- package/dist/pin.js +542 -0
- package/dist/qa-bug-report.js +301 -0
- package/dist/relay-headers.js +44 -0
- package/dist/reranker.js +442 -0
- package/dist/retype-setscope.js +348 -0
- package/dist/semantic-dedup.js +75 -0
- package/dist/subgraph-search.js +289 -0
- package/dist/subgraph-store.js +694 -0
- package/dist/tool-gating.js +58 -0
- package/download-ux.ts +91 -0
- package/embedding.ts +32 -9
- package/fs-helpers.ts +124 -0
- package/gateway-url.ts +57 -9
- package/index.ts +586 -357
- package/llm-client.ts +211 -23
- package/lsh.ts +7 -2
- package/onboarding-cli.ts +114 -1
- package/package.json +19 -5
- package/pair-cli.ts +76 -8
- package/pair-crypto.ts +34 -24
- package/pair-page.ts +28 -17
- package/pair-qr.ts +152 -0
- package/pair-remote-client.ts +540 -0
- package/qa-bug-report.ts +381 -0
- package/relay-headers.ts +50 -0
- package/reranker.ts +73 -0
- package/retype-setscope.ts +12 -0
- package/subgraph-search.ts +4 -3
- package/subgraph-store.ts +109 -16
package/dist/config.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
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
|
+
function warnRemovedEnvVars(warn = console.warn) {
|
|
48
|
+
const set = REMOVED_ENV_VARS.filter((name) => process.env[name] !== undefined);
|
|
49
|
+
if (set.length === 0)
|
|
50
|
+
return;
|
|
51
|
+
warn(`TotalReclaw: ignoring removed env var(s): ${set.join(', ')}. ` +
|
|
52
|
+
`See docs/guides/env-vars-reference.md for the v1 env var surface.`);
|
|
53
|
+
}
|
|
54
|
+
// Emit the warning once at import time. Safe because this module is loaded
|
|
55
|
+
// exactly once per process.
|
|
56
|
+
warnRemovedEnvVars();
|
|
57
|
+
/** Runtime override for recovery phrase (set by hot-reload after setup). */
|
|
58
|
+
let _recoveryPhraseOverride = null;
|
|
59
|
+
export function setRecoveryPhraseOverride(phrase) {
|
|
60
|
+
_recoveryPhraseOverride = phrase;
|
|
61
|
+
}
|
|
62
|
+
export function getRecoveryPhrase() {
|
|
63
|
+
return _recoveryPhraseOverride ?? process.env.TOTALRECLAW_RECOVERY_PHRASE ?? '';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Read the QA / observability session tag from the environment.
|
|
67
|
+
*
|
|
68
|
+
* When set, every outbound relay call adds the ``X-TotalReclaw-Session``
|
|
69
|
+
* header so relay logs (and Axiom queries) can be filtered by this tag —
|
|
70
|
+
* this is what the qa-totalreclaw skill relies on to scope log searches per
|
|
71
|
+
* QA run. When unset, returns ``null`` and the header is omitted.
|
|
72
|
+
*
|
|
73
|
+
* Read via getter (not snapshotted) so operators / test harnesses can flip
|
|
74
|
+
* the var between calls without reloading the module.
|
|
75
|
+
*
|
|
76
|
+
* Mirrors the Python-side ``RelayClient._session_id`` resolution priority.
|
|
77
|
+
* See internal#127 / `docs/guides/env-vars-reference.md`.
|
|
78
|
+
*/
|
|
79
|
+
export function getSessionId() {
|
|
80
|
+
const raw = process.env.TOTALRECLAW_SESSION_ID;
|
|
81
|
+
if (raw === undefined)
|
|
82
|
+
return null;
|
|
83
|
+
const trimmed = raw.trim();
|
|
84
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Runtime override for chain ID, set after the relay billing response is
|
|
88
|
+
* read. Free tier stays on 84532 (Base Sepolia); Pro tier flips to 100
|
|
89
|
+
* (Gnosis mainnet). The relay routes Pro writes to Gnosis, so Pro-tier
|
|
90
|
+
* UserOps MUST be signed against chain 100 — otherwise the bundler rejects
|
|
91
|
+
* the signature with AA23.
|
|
92
|
+
*
|
|
93
|
+
* See index.ts: after the billing lookup completes, call
|
|
94
|
+
* `setChainIdOverride(100)` for Pro users. Free users can leave the
|
|
95
|
+
* override unset.
|
|
96
|
+
*/
|
|
97
|
+
let _chainIdOverride = null;
|
|
98
|
+
export function setChainIdOverride(chainId) {
|
|
99
|
+
_chainIdOverride = chainId;
|
|
100
|
+
}
|
|
101
|
+
/** Reset the chain override — used by tests. */
|
|
102
|
+
export function __resetChainIdOverrideForTests() {
|
|
103
|
+
_chainIdOverride = null;
|
|
104
|
+
}
|
|
105
|
+
export const CONFIG = {
|
|
106
|
+
// Core — recoveryPhrase reads from override first, then env var.
|
|
107
|
+
// Use getRecoveryPhrase() for dynamic access; this property is for
|
|
108
|
+
// backward-compat with code that reads CONFIG.recoveryPhrase at init time.
|
|
109
|
+
get recoveryPhrase() {
|
|
110
|
+
return getRecoveryPhrase();
|
|
111
|
+
},
|
|
112
|
+
/**
|
|
113
|
+
* Optional QA / observability session tag forwarded to the relay as
|
|
114
|
+
* ``X-TotalReclaw-Session``. See `getSessionId()` above. Getter form so
|
|
115
|
+
* tests + harnesses can flip the env between calls. ``null`` when unset
|
|
116
|
+
* (header omitted).
|
|
117
|
+
*/
|
|
118
|
+
get sessionId() {
|
|
119
|
+
return getSessionId();
|
|
120
|
+
},
|
|
121
|
+
serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
|
|
122
|
+
selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
|
|
123
|
+
credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
|
|
124
|
+
// 3.2.0 onboarding state file — separate from credentials.json so it
|
|
125
|
+
// never contains secrets. Loaded on every plugin init + on every
|
|
126
|
+
// before_tool_call gate check.
|
|
127
|
+
onboardingStatePath: process.env.TOTALRECLAW_STATE_PATH || path.join(home, '.totalreclaw', 'state.json'),
|
|
128
|
+
// 3.3.0 QR-pairing session store. Separate file from both credentials.json
|
|
129
|
+
// and state.json so the session-store module does not have to touch either
|
|
130
|
+
// (keeps scanner surface isolated). Contains ephemeral x25519 secret keys
|
|
131
|
+
// for 15-min TTL windows; 0600 mode.
|
|
132
|
+
pairSessionsPath: process.env.TOTALRECLAW_PAIR_SESSIONS_PATH || path.join(home, '.totalreclaw', 'pair-sessions.json'),
|
|
133
|
+
// 3.3.1-rc.11 — pair-flow transport selector. Mirrors the Python-side
|
|
134
|
+
// `TOTALRECLAW_PAIR_MODE` env (rc.10). `'relay'` (default) routes
|
|
135
|
+
// `totalreclaw_pair` through the universal-reachability WebSocket relay at
|
|
136
|
+
// `TOTALRECLAW_PAIR_RELAY_URL`. `'local'` preserves the rc.4–rc.10 loopback
|
|
137
|
+
// HTTP flow (the plugin serves `/plugin/totalreclaw/pair/*` via
|
|
138
|
+
// `pair-http.ts`). Air-gapped / self-hosted users can pin `'local'` here.
|
|
139
|
+
pairMode: (() => {
|
|
140
|
+
const v = (process.env.TOTALRECLAW_PAIR_MODE ?? '').trim().toLowerCase();
|
|
141
|
+
return v === 'local' ? 'local' : 'relay';
|
|
142
|
+
})(),
|
|
143
|
+
// 3.3.1-rc.11 — relay base URL for the WebSocket-brokered pair flow.
|
|
144
|
+
// `wss://` preferred; `https://` is rewritten in the remote-client.
|
|
145
|
+
pairRelayUrl: (process.env.TOTALRECLAW_PAIR_RELAY_URL
|
|
146
|
+
|| 'wss://api-staging.totalreclaw.xyz').replace(/\/+$/, ''),
|
|
147
|
+
// Chain — chainId is no longer user-configurable. It is auto-detected from
|
|
148
|
+
// the relay billing response (free = Base Sepolia / 84532, Pro = Gnosis /
|
|
149
|
+
// 100). The default here is used only before the first billing lookup
|
|
150
|
+
// completes. Self-hosted users can still point at a custom DataEdge via
|
|
151
|
+
// TOTALRECLAW_DATA_EDGE_ADDRESS / TOTALRECLAW_ENTRYPOINT_ADDRESS /
|
|
152
|
+
// TOTALRECLAW_RPC_URL (undocumented; internal knobs).
|
|
153
|
+
//
|
|
154
|
+
// Reads the runtime override set by the billing auto-detect in index.ts.
|
|
155
|
+
// Falls back to 84532 (free tier / pre-billing-lookup). Must be a getter,
|
|
156
|
+
// not a literal — a literal would freeze all Pro-tier UserOps to the
|
|
157
|
+
// wrong chainId and AA23 at the bundler.
|
|
158
|
+
get chainId() {
|
|
159
|
+
return _chainIdOverride ?? 84532;
|
|
160
|
+
},
|
|
161
|
+
dataEdgeAddress: process.env.TOTALRECLAW_DATA_EDGE_ADDRESS || '',
|
|
162
|
+
entryPointAddress: process.env.TOTALRECLAW_ENTRYPOINT_ADDRESS || '',
|
|
163
|
+
rpcUrl: process.env.TOTALRECLAW_RPC_URL || '',
|
|
164
|
+
// Tuning knobs — default values used only as local fallback for
|
|
165
|
+
// self-hosted mode. Managed-service clients override these from the relay
|
|
166
|
+
// billing response via `resolveTuning(...)`.
|
|
167
|
+
// See: docs/specs/totalreclaw/client-consistency.md
|
|
168
|
+
cosineThreshold: parseFloat(process.env.TOTALRECLAW_COSINE_THRESHOLD ?? '0.15'),
|
|
169
|
+
extractInterval: parseInt(process.env.TOTALRECLAW_EXTRACT_INTERVAL ?? process.env.TOTALRECLAW_EXTRACT_EVERY_TURNS ?? '3', 10),
|
|
170
|
+
relevanceThreshold: parseFloat(process.env.TOTALRECLAW_RELEVANCE_THRESHOLD ?? '0.3'),
|
|
171
|
+
semanticSkipThreshold: parseFloat(process.env.TOTALRECLAW_SEMANTIC_SKIP_THRESHOLD ?? '0.85'),
|
|
172
|
+
cacheTtlMs: parseInt(process.env.TOTALRECLAW_CACHE_TTL_MS ?? String(5 * 60 * 1000), 10),
|
|
173
|
+
minImportance: Math.max(1, Math.min(10, Number(process.env.TOTALRECLAW_MIN_IMPORTANCE) || 6)),
|
|
174
|
+
trapdoorBatchSize: parseInt(process.env.TOTALRECLAW_TRAPDOOR_BATCH_SIZE ?? '5', 10),
|
|
175
|
+
pageSize: parseInt(process.env.TOTALRECLAW_SUBGRAPH_PAGE_SIZE ?? '1000', 10),
|
|
176
|
+
// Store-time dedup is always ON. TOTALRECLAW_STORE_DEDUP was removed in v1.
|
|
177
|
+
storeDedupEnabled: true,
|
|
178
|
+
// LLM provider API keys (read once, passed to llm-client). Model selection
|
|
179
|
+
// is entirely automatic via `deriveCheapModel(provider)` — the
|
|
180
|
+
// TOTALRECLAW_LLM_MODEL override was removed in v1.
|
|
181
|
+
llmApiKeys: {
|
|
182
|
+
zai: process.env.ZAI_API_KEY || '',
|
|
183
|
+
anthropic: process.env.ANTHROPIC_API_KEY || '',
|
|
184
|
+
openai: process.env.OPENAI_API_KEY || '',
|
|
185
|
+
gemini: process.env.GEMINI_API_KEY || '',
|
|
186
|
+
google: process.env.GOOGLE_API_KEY || '',
|
|
187
|
+
mistral: process.env.MISTRAL_API_KEY || '',
|
|
188
|
+
groq: process.env.GROQ_API_KEY || '',
|
|
189
|
+
deepseek: process.env.DEEPSEEK_API_KEY || '',
|
|
190
|
+
openrouter: process.env.OPENROUTER_API_KEY || '',
|
|
191
|
+
xai: process.env.XAI_API_KEY || '',
|
|
192
|
+
together: process.env.TOGETHER_API_KEY || '',
|
|
193
|
+
cerebras: process.env.CEREBRAS_API_KEY || '',
|
|
194
|
+
},
|
|
195
|
+
// 3.3.1-rc.3: zai base-URL override. Read via a getter so tests can
|
|
196
|
+
// mutate `process.env.ZAI_BASE_URL` between calls — the value is NOT
|
|
197
|
+
// frozen at module load. Default is the coding endpoint; the rc.3
|
|
198
|
+
// auto-fallback flips to the standard endpoint on an "Insufficient
|
|
199
|
+
// balance" 429.
|
|
200
|
+
get zaiBaseUrl() {
|
|
201
|
+
const override = process.env.ZAI_BASE_URL;
|
|
202
|
+
if (override && override.trim())
|
|
203
|
+
return override.trim().replace(/\/+$/, '');
|
|
204
|
+
return 'https://api.z.ai/api/coding/paas/v4';
|
|
205
|
+
},
|
|
206
|
+
// 3.3.1-rc.3: retry budget for chatCompletion. Default 60s covers
|
|
207
|
+
// multi-minute upstream outages. Read as a plain value (not getter)
|
|
208
|
+
// so tests that patch env need to reload the module — but the default
|
|
209
|
+
// suffices for production.
|
|
210
|
+
llmRetryBudgetMs: (() => {
|
|
211
|
+
const raw = process.env.TOTALRECLAW_LLM_RETRY_BUDGET_MS;
|
|
212
|
+
const parsed = raw ? parseInt(raw, 10) : NaN;
|
|
213
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 60_000;
|
|
214
|
+
})(),
|
|
215
|
+
// 3.3.1-rc.3: GitHub personal-access token used by the RC-gated
|
|
216
|
+
// `totalreclaw_report_qa_bug` tool. `TOTALRECLAW_QA_GITHUB_TOKEN` is
|
|
217
|
+
// the dedicated variable; `GITHUB_TOKEN` is a fallback for CI-style
|
|
218
|
+
// setups where the same token is shared across tools. Read via getter
|
|
219
|
+
// so operators can set the var after the process starts (e.g. via a
|
|
220
|
+
// dotenv reload) and the next tool call picks it up.
|
|
221
|
+
get qaGithubToken() {
|
|
222
|
+
return process.env.TOTALRECLAW_QA_GITHUB_TOKEN || process.env.GITHUB_TOKEN || '';
|
|
223
|
+
},
|
|
224
|
+
// 3.3.1-rc.14: optional target-repo override for the RC-gated QA
|
|
225
|
+
// bug-report tool. The `qa-bug-report` module enforces a
|
|
226
|
+
// "slug ends in `-internal`" rule on whatever is resolved here, so
|
|
227
|
+
// this override is only useful for forks / mirrors of the internal
|
|
228
|
+
// tracker. Leaving unset uses the production default
|
|
229
|
+
// (`p-diogo/totalreclaw-internal`). Read via getter so operators can
|
|
230
|
+
// flip the var at runtime.
|
|
231
|
+
get qaRepoOverride() {
|
|
232
|
+
return process.env.TOTALRECLAW_QA_REPO || '';
|
|
233
|
+
},
|
|
234
|
+
// 3.3.1-rc.21 (issue #128): verbose-register flag. When enabled, the
|
|
235
|
+
// plugin emits opt-in `info`-level breadcrumbs after sensitive
|
|
236
|
+
// registerTool calls (currently `totalreclaw_pair`) to help ops/QA
|
|
237
|
+
// grep gateway logs for definitive proof the tool was declared.
|
|
238
|
+
// Default OFF — the breadcrumb is debug-grade and was bleeding into
|
|
239
|
+
// `openclaw agent --json` stdout, breaking programmatic parsers.
|
|
240
|
+
// Enable with either:
|
|
241
|
+
// TOTALRECLAW_VERBOSE_REGISTER=1 (specific opt-in)
|
|
242
|
+
// TOTALRECLAW_DEBUG=1 (general debug toggle)
|
|
243
|
+
// Read via getter so flipping the env at runtime takes effect on the
|
|
244
|
+
// next gateway start without a rebuild.
|
|
245
|
+
get verboseRegister() {
|
|
246
|
+
const specific = (process.env.TOTALRECLAW_VERBOSE_REGISTER ?? '').trim().toLowerCase();
|
|
247
|
+
if (specific === '1' || specific === 'true' || specific === 'yes')
|
|
248
|
+
return true;
|
|
249
|
+
const general = (process.env.TOTALRECLAW_DEBUG ?? '').trim().toLowerCase();
|
|
250
|
+
return general === '1' || general === 'true' || general === 'yes';
|
|
251
|
+
},
|
|
252
|
+
// Paths
|
|
253
|
+
home,
|
|
254
|
+
billingCachePath: path.join(home, '.totalreclaw', 'billing-cache.json'),
|
|
255
|
+
cachePath: process.env.TOTALRECLAW_CACHE_PATH || path.join(home, '.totalreclaw', 'cache.enc'),
|
|
256
|
+
openclawWorkspace: path.join(home, '.openclaw', 'workspace'),
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Merge a billing-response tuning block with the local fallback values.
|
|
260
|
+
*
|
|
261
|
+
* Use this at the call-site that needs a threshold, passing the features
|
|
262
|
+
* blob from the billing cache. No I/O here — callers read the cache once
|
|
263
|
+
* and hand the features in.
|
|
264
|
+
*/
|
|
265
|
+
export function resolveTuning(features) {
|
|
266
|
+
return {
|
|
267
|
+
cosineThreshold: features?.cosine_threshold ?? CONFIG.cosineThreshold,
|
|
268
|
+
relevanceThreshold: features?.relevance_threshold ?? CONFIG.relevanceThreshold,
|
|
269
|
+
semanticSkipThreshold: features?.semantic_skip_threshold ?? CONFIG.semanticSkipThreshold,
|
|
270
|
+
minImportance: features?.min_importance ?? CONFIG.minImportance,
|
|
271
|
+
cacheTtlMs: features?.cache_ttl_ms ?? CONFIG.cacheTtlMs,
|
|
272
|
+
trapdoorBatchSize: features?.trapdoor_batch_size ?? CONFIG.trapdoorBatchSize,
|
|
273
|
+
pageSize: features?.subgraph_page_size ?? CONFIG.pageSize,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
// Exposed for tests that want to assert the removed-var warning behaviour.
|
|
277
|
+
export const __internal = {
|
|
278
|
+
REMOVED_ENV_VARS,
|
|
279
|
+
warnRemovedEnvVars,
|
|
280
|
+
};
|
|
@@ -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
|
+
}
|