@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/SKILL.md +50 -83
  3. package/api-client.ts +18 -11
  4. package/config.ts +117 -3
  5. package/crypto.ts +10 -2
  6. package/dist/api-client.js +226 -0
  7. package/dist/billing-cache.js +100 -0
  8. package/dist/claims-helper.js +606 -0
  9. package/dist/config.js +280 -0
  10. package/dist/consolidation.js +258 -0
  11. package/dist/contradiction-sync.js +1034 -0
  12. package/dist/crypto.js +138 -0
  13. package/dist/digest-sync.js +361 -0
  14. package/dist/download-ux.js +63 -0
  15. package/dist/embedding.js +86 -0
  16. package/dist/extractor.js +1225 -0
  17. package/dist/first-run.js +103 -0
  18. package/dist/fs-helpers.js +563 -0
  19. package/dist/gateway-url.js +197 -0
  20. package/dist/generate-mnemonic.js +13 -0
  21. package/dist/hot-cache-wrapper.js +101 -0
  22. package/dist/import-adapters/base-adapter.js +64 -0
  23. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  24. package/dist/import-adapters/claude-adapter.js +114 -0
  25. package/dist/import-adapters/gemini-adapter.js +201 -0
  26. package/dist/import-adapters/index.js +26 -0
  27. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  28. package/dist/import-adapters/mem0-adapter.js +158 -0
  29. package/dist/import-adapters/types.js +1 -0
  30. package/dist/index.js +5348 -0
  31. package/dist/llm-client.js +686 -0
  32. package/dist/llm-profile-reader.js +346 -0
  33. package/dist/lsh.js +62 -0
  34. package/dist/onboarding-cli.js +750 -0
  35. package/dist/pair-cli.js +344 -0
  36. package/dist/pair-crypto.js +359 -0
  37. package/dist/pair-http.js +404 -0
  38. package/dist/pair-page.js +826 -0
  39. package/dist/pair-qr.js +107 -0
  40. package/dist/pair-remote-client.js +410 -0
  41. package/dist/pair-session-store.js +566 -0
  42. package/dist/pin.js +542 -0
  43. package/dist/qa-bug-report.js +301 -0
  44. package/dist/relay-headers.js +44 -0
  45. package/dist/reranker.js +442 -0
  46. package/dist/retype-setscope.js +348 -0
  47. package/dist/semantic-dedup.js +75 -0
  48. package/dist/subgraph-search.js +289 -0
  49. package/dist/subgraph-store.js +694 -0
  50. package/dist/tool-gating.js +58 -0
  51. package/download-ux.ts +91 -0
  52. package/embedding.ts +32 -9
  53. package/fs-helpers.ts +124 -0
  54. package/gateway-url.ts +57 -9
  55. package/index.ts +586 -357
  56. package/llm-client.ts +211 -23
  57. package/lsh.ts +7 -2
  58. package/onboarding-cli.ts +114 -1
  59. package/package.json +19 -5
  60. package/pair-cli.ts +76 -8
  61. package/pair-crypto.ts +34 -24
  62. package/pair-page.ts +28 -17
  63. package/pair-qr.ts +152 -0
  64. package/pair-remote-client.ts +540 -0
  65. package/qa-bug-report.ts +381 -0
  66. package/relay-headers.ts +50 -0
  67. package/reranker.ts +73 -0
  68. package/retype-setscope.ts +12 -0
  69. package/subgraph-search.ts +4 -3
  70. 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
+ }