@totalreclaw/totalreclaw 3.3.1-rc.8 → 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 +268 -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
@@ -0,0 +1,624 @@
1
+ /**
2
+ * TotalReclaw Plugin — Knowledge Graph helpers for the write path.
3
+ *
4
+ * Builds canonical Claim JSON from an ExtractedFact, generates entity
5
+ * trapdoors for blind search, and resolves the claim-format feature flag.
6
+ *
7
+ * The canonical Claim schema uses compact short keys (t, c, cf, i, sa, ea, e, ...)
8
+ * and is produced byte-identically across Rust, WASM, and Python via
9
+ * `canonicalizeClaim()` in @totalreclaw/core.
10
+ */
11
+ import crypto from 'node:crypto';
12
+ import { createRequire } from 'node:module';
13
+ import { isValidMemoryType, isValidMemoryTypeV1, V0_TO_V1_TYPE, VALID_MEMORY_SCOPES, VALID_MEMORY_SOURCES, VALID_MEMORY_VOLATILITIES, VALID_MEMORY_TYPES_V1, } from './extractor.js';
14
+ // Lazy-load WASM. We use createRequire so this module loads cleanly under
15
+ // both the OpenClaw runtime (CJS-ish tsx) and bare Node ESM (used by tests).
16
+ const requireWasm = createRequire(import.meta.url);
17
+ let _wasm = null;
18
+ function getWasm() {
19
+ if (!_wasm)
20
+ _wasm = requireWasm('@totalreclaw/core');
21
+ return _wasm;
22
+ }
23
+ // ---------------------------------------------------------------------------
24
+ // Category mapping (ExtractedFact.type → compact Claim category short key)
25
+ // ---------------------------------------------------------------------------
26
+ // Legacy v0 type → compact category mapping. Kept for reading pre-v1 vault
27
+ // entries that stored the short-form category as the decrypted `c` key.
28
+ const TYPE_TO_CATEGORY_V0 = {
29
+ fact: 'fact',
30
+ preference: 'pref',
31
+ decision: 'dec',
32
+ episodic: 'epi',
33
+ goal: 'goal',
34
+ context: 'ctx',
35
+ summary: 'sum',
36
+ rule: 'rule',
37
+ };
38
+ // v1 type → compact category mapping for recall display. These short keys
39
+ // remain the display-layer category tags (e.g. `[rule]`, `[fact]`) that the
40
+ // recall tool surfaces, so the v1 types map onto the v0 category keys.
41
+ const TYPE_TO_CATEGORY_V1 = {
42
+ claim: 'claim',
43
+ preference: 'pref',
44
+ directive: 'rule', // v1 directive → v0 category "rule" for display
45
+ commitment: 'goal', // v1 commitment → v0 category "goal" for display
46
+ episode: 'epi',
47
+ summary: 'sum',
48
+ };
49
+ /**
50
+ * Map any memory type (v1 or legacy v0) to the compact category short key.
51
+ *
52
+ * v1 types take priority; unknown tokens fall through to the v0 table for
53
+ * pre-v1 vault entries; anything else returns `'fact'`.
54
+ */
55
+ export function mapTypeToCategory(type) {
56
+ if (type in TYPE_TO_CATEGORY_V1)
57
+ return TYPE_TO_CATEGORY_V1[type];
58
+ return TYPE_TO_CATEGORY_V0[type] ?? 'fact';
59
+ }
60
+ /**
61
+ * Construct a canonical Claim JSON string from an ExtractedFact.
62
+ *
63
+ * As of plugin v3.0.0, this unconditionally emits a Memory Taxonomy v1 JSON
64
+ * blob (schema_version "1.0") — forwarded to `buildCanonicalClaimV1`. The
65
+ * legacy v0 short-key {t, c, i, sa, ea} format is no longer produced on the
66
+ * write path.
67
+ *
68
+ * When `fact.source` is missing we default it to `'user-inferred'` so a
69
+ * misconfigured extraction hook doesn't drop the write. The outer protobuf
70
+ * wrapper's `version` field MUST be set to 4 when storing the returned
71
+ * payload (see `subgraph-store.ts::encodeFactProtobuf`).
72
+ */
73
+ export function buildCanonicalClaim(input) {
74
+ const { fact, importance, extractedAt, embeddingModelId } = input;
75
+ // Defensive: ensure fact.source is always populated before v1 validation.
76
+ // `applyProvenanceFilterLax` should have set this upstream; this is the
77
+ // belt-and-suspenders fallback for explicit tool paths / legacy callers.
78
+ const factWithSource = fact.source
79
+ ? fact
80
+ : { ...fact, source: 'user-inferred' };
81
+ return buildCanonicalClaimV1({
82
+ fact: factWithSource,
83
+ importance,
84
+ createdAt: extractedAt,
85
+ embeddingModelId,
86
+ });
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // v1 Claim payload builder (Phase 3 — plugin v3.0.0)
90
+ //
91
+ // Produces a MemoryClaimV1-shaped JSON payload matching
92
+ // `docs/specs/totalreclaw/memory-taxonomy-v1.md`.
93
+ //
94
+ // The v1 payload uses long field names + a schema_version marker so that
95
+ // decrypt logic can discriminate between v0 short-key claims and v1 claims
96
+ // without any external hint. The protobuf outer wrapper sets `version = 4`
97
+ // when writing v1 payloads — see `subgraph-store.ts`.
98
+ // ---------------------------------------------------------------------------
99
+ export const V1_SCHEMA_VERSION = '1.0';
100
+ /**
101
+ * Build a v1 MemoryClaimV1 JSON blob.
102
+ *
103
+ * Throws if the fact does not have a valid v1 `source` set — v1 requires
104
+ * every claim to carry provenance (the whole taxonomy depends on it).
105
+ *
106
+ * The build pipeline:
107
+ * 1. Build the full v1 payload object (including plugin-only extras like
108
+ * `volatility` and `schema_version`).
109
+ * 2. Send the core-required subset through `validateMemoryClaimV1` for
110
+ * schema enforcement (throws on invalid type/source/missing id).
111
+ * 3. Emit the FULL payload (core canonical fields + plugin extras) as the
112
+ * final stored JSON so round-trip preserves client-side state.
113
+ *
114
+ * Plugin-only extras (not round-tripped by core's validator as of v2.0.0):
115
+ * - `schema_version` — version marker the decrypt path reads
116
+ * - `volatility` — stable | updatable | ephemeral (re-scored after extraction)
117
+ *
118
+ * The outer protobuf wrapper's `version` field must be set to 4 when storing
119
+ * the returned payload (see subgraph-store.ts).
120
+ */
121
+ export function buildCanonicalClaimV1(input) {
122
+ const { fact, importance, createdAt, supersededBy, expiresAt } = input;
123
+ const id = input.id ?? crypto.randomUUID();
124
+ if (!fact.source) {
125
+ throw new Error('buildCanonicalClaimV1: fact.source is required (v1 taxonomy mandates provenance)');
126
+ }
127
+ if (!VALID_MEMORY_SOURCES.includes(fact.source)) {
128
+ throw new Error(`buildCanonicalClaimV1: invalid source "${fact.source}"`);
129
+ }
130
+ const type = normalizeToV1Type(fact.type);
131
+ const resolvedCreatedAt = createdAt ?? new Date().toISOString();
132
+ const resolvedImportance = Math.max(1, Math.min(10, Math.round(importance)));
133
+ // Core-canonical subset sent through validateMemoryClaimV1. Core strips
134
+ // fields it doesn't understand, so we send it the subset it accepts and
135
+ // re-attach client-side extras to the final payload.
136
+ const corePayload = {
137
+ id,
138
+ text: fact.text,
139
+ type,
140
+ source: fact.source,
141
+ created_at: resolvedCreatedAt,
142
+ importance: resolvedImportance,
143
+ };
144
+ if (fact.scope && VALID_MEMORY_SCOPES.includes(fact.scope)) {
145
+ corePayload.scope = fact.scope;
146
+ }
147
+ if (fact.reasoning && fact.reasoning.length > 0) {
148
+ corePayload.reasoning = fact.reasoning.slice(0, 256);
149
+ }
150
+ if (fact.entities && fact.entities.length > 0) {
151
+ corePayload.entities = fact.entities.slice(0, 8).map((e) => {
152
+ const entity = { name: e.name, type: e.type };
153
+ if (e.role)
154
+ entity.role = e.role;
155
+ return entity;
156
+ });
157
+ }
158
+ if (typeof fact.confidence === 'number') {
159
+ corePayload.confidence = Math.max(0, Math.min(1, fact.confidence));
160
+ }
161
+ if (expiresAt)
162
+ corePayload.expires_at = expiresAt;
163
+ if (supersededBy)
164
+ corePayload.superseded_by = supersededBy;
165
+ // v1.1 pin_status — additive field; only emitted when the caller opts in.
166
+ if (input.pinStatus === 'pinned' || input.pinStatus === 'unpinned') {
167
+ corePayload.pin_status = input.pinStatus;
168
+ }
169
+ // Validate through core — throws on invalid type / source / missing id.
170
+ const validated = getWasm().validateMemoryClaimV1(JSON.stringify(corePayload));
171
+ const canonical = JSON.parse(validated);
172
+ // Re-attach plugin-only extras not round-tripped by core's validator.
173
+ canonical.schema_version = V1_SCHEMA_VERSION;
174
+ if (fact.volatility && VALID_MEMORY_VOLATILITIES.includes(fact.volatility)) {
175
+ canonical.volatility = fact.volatility;
176
+ }
177
+ // 3.3.1-rc.22 — forward-compat embedder marker. Plugin-only field;
178
+ // survives the core validator via re-attach. Future distillation
179
+ // detects this on read to re-embed selectively without forcing a
180
+ // vault-wide rebuild.
181
+ if (typeof input.embeddingModelId === 'string' && input.embeddingModelId.length > 0) {
182
+ canonical.embedding_model_id = input.embeddingModelId;
183
+ }
184
+ return JSON.stringify(canonical);
185
+ }
186
+ /**
187
+ * Build a v1.1 canonical claim JSON string, validated through the core
188
+ * `validateMemoryClaimV1` WASM export.
189
+ *
190
+ * Output is UTF-8 JSON ready for encryption as the inner blob of a
191
+ * protobuf-v4 fact (outer `version = 4`). Field ordering follows the core
192
+ * validator, so the result is byte-identical to what MCP's equivalent helper
193
+ * produces for the same inputs (cross-client parity).
194
+ *
195
+ * Throws on malformed input (missing required field, invalid enum value).
196
+ */
197
+ export function buildV1ClaimBlob(input) {
198
+ if (!VALID_MEMORY_SOURCES.includes(input.source)) {
199
+ throw new Error(`buildV1ClaimBlob: invalid source "${input.source}"`);
200
+ }
201
+ if (!isValidMemoryType(input.type)) {
202
+ throw new Error(`buildV1ClaimBlob: invalid type "${input.type}"`);
203
+ }
204
+ const corePayload = {
205
+ id: input.id ?? crypto.randomUUID(),
206
+ text: input.text,
207
+ type: input.type,
208
+ source: input.source,
209
+ created_at: input.createdAt ?? new Date().toISOString(),
210
+ };
211
+ if (input.scope && VALID_MEMORY_SCOPES.includes(input.scope)) {
212
+ corePayload.scope = input.scope;
213
+ }
214
+ if (input.reasoning && input.reasoning.length > 0) {
215
+ corePayload.reasoning = input.reasoning.slice(0, 256);
216
+ }
217
+ if (input.entities && input.entities.length > 0) {
218
+ corePayload.entities = input.entities.slice(0, 8).map((e) => {
219
+ const entity = { name: e.name, type: e.type };
220
+ if (e.role)
221
+ entity.role = e.role;
222
+ return entity;
223
+ });
224
+ }
225
+ if (typeof input.importance === 'number') {
226
+ corePayload.importance = Math.max(1, Math.min(10, Math.round(input.importance)));
227
+ }
228
+ if (typeof input.confidence === 'number') {
229
+ corePayload.confidence = Math.max(0, Math.min(1, input.confidence));
230
+ }
231
+ if (input.expiresAt)
232
+ corePayload.expires_at = input.expiresAt;
233
+ if (input.supersededBy)
234
+ corePayload.superseded_by = input.supersededBy;
235
+ if (input.pinStatus === 'pinned' || input.pinStatus === 'unpinned') {
236
+ corePayload.pin_status = input.pinStatus;
237
+ }
238
+ // Validate via core — throws on invalid shape.
239
+ const validated = getWasm().validateMemoryClaimV1(JSON.stringify(corePayload));
240
+ const canonical = JSON.parse(validated);
241
+ canonical.schema_version = V1_SCHEMA_VERSION;
242
+ if (input.volatility && VALID_MEMORY_VOLATILITIES.includes(input.volatility)) {
243
+ canonical.volatility = input.volatility;
244
+ }
245
+ // 3.3.1-rc.22 — see `buildCanonicalClaimV1` comment.
246
+ if (typeof input.embeddingModelId === 'string' && input.embeddingModelId.length > 0) {
247
+ canonical.embedding_model_id = input.embeddingModelId;
248
+ }
249
+ return JSON.stringify(canonical);
250
+ }
251
+ /**
252
+ * Normalize any type token (v0 or v1) to a v1 type. Uses the v0→v1 mapping
253
+ * for legacy tokens; passes through when already v1.
254
+ */
255
+ export function normalizeToV1Type(type) {
256
+ if (isValidMemoryType(type))
257
+ return type;
258
+ return V0_TO_V1_TYPE[type] ?? 'claim';
259
+ }
260
+ /**
261
+ * Heuristic: does a decrypted blob look like a v1 JSON payload?
262
+ *
263
+ * We check for the schema_version marker + the long-form `text` field.
264
+ * Falls back false on any parse error.
265
+ */
266
+ export function isV1Blob(decrypted) {
267
+ try {
268
+ const obj = JSON.parse(decrypted);
269
+ return (typeof obj === 'object' &&
270
+ obj !== null &&
271
+ typeof obj.text === 'string' &&
272
+ typeof obj.type === 'string' &&
273
+ typeof obj.schema_version === 'string' &&
274
+ obj.schema_version.startsWith('1.'));
275
+ }
276
+ catch {
277
+ return false;
278
+ }
279
+ }
280
+ export function readV1Blob(decrypted) {
281
+ try {
282
+ const obj = JSON.parse(decrypted);
283
+ if (typeof obj.schema_version !== 'string' || !obj.schema_version.startsWith('1.')) {
284
+ return null;
285
+ }
286
+ const text = typeof obj.text === 'string' ? obj.text : '';
287
+ const rawType = typeof obj.type === 'string' ? obj.type : 'claim';
288
+ const type = isValidMemoryTypeV1(rawType) ? rawType : 'claim';
289
+ const rawSource = typeof obj.source === 'string' ? obj.source : 'user-inferred';
290
+ const source = VALID_MEMORY_SOURCES.includes(rawSource)
291
+ ? rawSource
292
+ : 'user-inferred';
293
+ const rawScope = typeof obj.scope === 'string' ? obj.scope : 'unspecified';
294
+ const scope = VALID_MEMORY_SCOPES.includes(rawScope)
295
+ ? rawScope
296
+ : 'unspecified';
297
+ const rawVolatility = typeof obj.volatility === 'string' ? obj.volatility : 'updatable';
298
+ const volatility = VALID_MEMORY_VOLATILITIES.includes(rawVolatility)
299
+ ? rawVolatility
300
+ : 'updatable';
301
+ const impRaw = typeof obj.importance === 'number' ? obj.importance : 5;
302
+ const importance = Math.max(1, Math.min(10, Math.round(impRaw)));
303
+ const confRaw = typeof obj.confidence === 'number' ? obj.confidence : 0.85;
304
+ const confidence = Math.max(0, Math.min(1, confRaw));
305
+ const result = {
306
+ text,
307
+ type,
308
+ source,
309
+ scope,
310
+ volatility,
311
+ importance,
312
+ confidence,
313
+ createdAt: typeof obj.created_at === 'string' ? obj.created_at : '',
314
+ };
315
+ if (typeof obj.reasoning === 'string' && obj.reasoning.length > 0) {
316
+ result.reasoning = obj.reasoning;
317
+ }
318
+ if (Array.isArray(obj.entities)) {
319
+ result.entities = obj.entities.filter((e) => !!e &&
320
+ typeof e === 'object' &&
321
+ typeof e.name === 'string' &&
322
+ typeof e.type === 'string');
323
+ }
324
+ if (typeof obj.expires_at === 'string')
325
+ result.expiresAt = obj.expires_at;
326
+ if (typeof obj.superseded_by === 'string')
327
+ result.supersededBy = obj.superseded_by;
328
+ if (typeof obj.id === 'string')
329
+ result.id = obj.id;
330
+ if (typeof obj.pin_status === 'string') {
331
+ const ps = obj.pin_status;
332
+ if (ps === 'pinned' || ps === 'unpinned') {
333
+ result.pinStatus = ps;
334
+ }
335
+ }
336
+ // 3.3.1-rc.22 — pull the embedder identity tag through. Plugin-only
337
+ // field added by `buildCanonicalClaimV1` / `buildV1ClaimBlob` after
338
+ // core validation.
339
+ if (typeof obj.embedding_model_id === 'string' && obj.embedding_model_id.length > 0) {
340
+ result.embeddingModelId = obj.embedding_model_id;
341
+ }
342
+ return result;
343
+ }
344
+ catch {
345
+ return null;
346
+ }
347
+ }
348
+ // Suppress unused-import lint warnings for VALID_MEMORY_TYPES_V1 — it is
349
+ // exported from extractor.ts for downstream clients and kept in scope here
350
+ // so future v1 helpers can reuse it without re-importing.
351
+ void VALID_MEMORY_TYPES_V1;
352
+ // ---------------------------------------------------------------------------
353
+ // Back-compat alias: buildCanonicalClaimRouted
354
+ //
355
+ // Plugin v3.0.0 removed the v0/v1 taxonomy toggle (`TOTALRECLAW_TAXONOMY_VERSION`
356
+ // env var) — all extraction + write paths emit v1 unconditionally. This
357
+ // alias is kept so any external caller that imports the Phase-3 rollout
358
+ // name keeps compiling; it simply forwards to `buildCanonicalClaim`.
359
+ //
360
+ // @deprecated Use `buildCanonicalClaim` directly.
361
+ // ---------------------------------------------------------------------------
362
+ export function buildCanonicalClaimRouted(input) {
363
+ return buildCanonicalClaim(input);
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // Digest helpers (Stage 3b read path)
367
+ // ---------------------------------------------------------------------------
368
+ /**
369
+ * Well-known blind index marker used to locate digest claims on the subgraph.
370
+ * Computed as plain SHA-256("type:digest") — same primitive as word trapdoors
371
+ * so it lives in the existing `blindIndices` array. The `type:` namespace
372
+ * prefix keeps it distinct from any user word trapdoor.
373
+ */
374
+ export const DIGEST_TRAPDOOR = crypto
375
+ .createHash('sha256')
376
+ .update('type:digest')
377
+ .digest('hex');
378
+ /** Compact category short key for digest claims (ClaimCategory::Digest). */
379
+ export const DIGEST_CATEGORY = 'dig';
380
+ /** Distinctive source marker so operators can grep for digest writes. */
381
+ export const DIGEST_SOURCE_AGENT = 'openclaw-plugin-digest';
382
+ /**
383
+ * Hard ceiling on claim count for LLM-assisted digest compilation.
384
+ * Above this, we skip the LLM entirely and use the template path to keep
385
+ * token cost bounded. See plan §9 and Stage 3b design question #3.
386
+ */
387
+ export const DIGEST_CLAIM_CAP = 200;
388
+ /**
389
+ * Digest injection is always ON in v1. The TOTALRECLAW_DIGEST_MODE env var
390
+ * was removed — the G-pipeline ships a digest on every recall with an LLM
391
+ * template fallback baked into the digest compiler. Kept as a function
392
+ * returning `'on'` so legacy call-sites continue to compile.
393
+ *
394
+ * @deprecated v1 always returns `'on'`.
395
+ */
396
+ export function resolveDigestMode() {
397
+ return 'on';
398
+ }
399
+ /**
400
+ * Internal kill-switch for the auto-resolution loop.
401
+ *
402
+ * - `active` (default, unset, unknown): full detection + auto-resolution.
403
+ * - `off`: skip contradiction detection entirely; Phase 1 behaviour.
404
+ * - `shadow`: detect + log decisions, but do not apply them (debug only).
405
+ *
406
+ * @internal Not public config — emergency kill-switch only.
407
+ */
408
+ export function resolveAutoResolveMode() {
409
+ const raw = (process.env.TOTALRECLAW_AUTO_RESOLVE_MODE ?? '').trim().toLowerCase();
410
+ if (raw === 'off')
411
+ return 'off';
412
+ if (raw === 'shadow')
413
+ return 'shadow';
414
+ return 'active';
415
+ }
416
+ export function readClaimFromBlob(decryptedJson) {
417
+ try {
418
+ const obj = JSON.parse(decryptedJson);
419
+ // v1 payload: long-form fields + schema_version "1.x"
420
+ if (typeof obj.text === 'string' &&
421
+ typeof obj.type === 'string' &&
422
+ typeof obj.schema_version === 'string' &&
423
+ obj.schema_version.startsWith('1.')) {
424
+ const importance = typeof obj.importance === 'number'
425
+ ? Math.max(1, Math.min(10, Math.round(obj.importance)))
426
+ : 5;
427
+ return {
428
+ text: obj.text,
429
+ importance,
430
+ category: mapTypeToCategory(obj.type),
431
+ metadata: {
432
+ type: obj.type,
433
+ source: typeof obj.source === 'string' ? obj.source : 'user-inferred',
434
+ scope: typeof obj.scope === 'string' ? obj.scope : 'unspecified',
435
+ volatility: typeof obj.volatility === 'string' ? obj.volatility : 'updatable',
436
+ reasoning: typeof obj.reasoning === 'string' ? obj.reasoning : undefined,
437
+ // v1.1: surface pin_status verbatim for downstream (recall display +
438
+ // export). Absent ⇒ undefined (receivers treat as "unpinned").
439
+ pin_status: typeof obj.pin_status === 'string' ? obj.pin_status : undefined,
440
+ importance: importance / 10,
441
+ created_at: typeof obj.created_at === 'string' ? obj.created_at : '',
442
+ schema_version: obj.schema_version,
443
+ },
444
+ };
445
+ }
446
+ // New canonical Claim format: short keys
447
+ if (typeof obj.t === 'string' && typeof obj.c === 'string') {
448
+ const importance = typeof obj.i === 'number' ? Math.max(1, Math.min(10, Math.round(obj.i))) : 5;
449
+ return {
450
+ text: obj.t,
451
+ importance,
452
+ category: obj.c,
453
+ metadata: {
454
+ type: obj.c,
455
+ importance: importance / 10,
456
+ source: typeof obj.sa === 'string' ? obj.sa : 'auto-extraction',
457
+ created_at: typeof obj.ea === 'string' ? obj.ea : '',
458
+ },
459
+ };
460
+ }
461
+ // Legacy plugin {text, metadata: {importance: 0-1}} format
462
+ if (typeof obj.text === 'string') {
463
+ const meta = obj.metadata ?? {};
464
+ const impFloat = typeof meta.importance === 'number' ? meta.importance : 0.5;
465
+ const importance = Math.max(1, Math.min(10, Math.round(impFloat * 10)));
466
+ return {
467
+ text: obj.text,
468
+ importance,
469
+ category: typeof meta.type === 'string' ? meta.type : 'fact',
470
+ metadata: meta,
471
+ };
472
+ }
473
+ }
474
+ catch {
475
+ // fall through
476
+ }
477
+ return { text: decryptedJson, importance: 5, category: 'fact', metadata: {} };
478
+ }
479
+ /**
480
+ * Wrap a serialized Digest JSON as a canonical Claim so it can be encrypted
481
+ * and stored on-chain via the same pipeline as regular facts.
482
+ *
483
+ * Stores the raw Digest JSON as the claim's `t` (text) field. Reader path
484
+ * is `parseClaimOrLegacy(decrypted) → extractDigestFromClaim`.
485
+ *
486
+ * Digest claims deliberately carry no entity refs — otherwise entity
487
+ * trapdoors would surface the digest blob in normal recall queries.
488
+ */
489
+ export function buildDigestClaim(input) {
490
+ const { digestJson, compiledAt } = input;
491
+ const claim = {
492
+ t: digestJson,
493
+ c: DIGEST_CATEGORY,
494
+ cf: 1.0,
495
+ i: 10,
496
+ sa: DIGEST_SOURCE_AGENT,
497
+ ea: compiledAt,
498
+ };
499
+ return getWasm().canonicalizeClaim(JSON.stringify(claim));
500
+ }
501
+ /**
502
+ * Parse a canonical Claim JSON (produced by parseClaimOrLegacy) and, if it is
503
+ * a digest claim, return the wrapped Digest object. Returns null if the claim
504
+ * is not of category `dig` or if the inner JSON fails to parse.
505
+ */
506
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
507
+ export function extractDigestFromClaim(canonicalClaimJson) {
508
+ let claim;
509
+ try {
510
+ claim = JSON.parse(canonicalClaimJson);
511
+ }
512
+ catch {
513
+ return null;
514
+ }
515
+ if (claim.c !== DIGEST_CATEGORY || typeof claim.t !== 'string')
516
+ return null;
517
+ try {
518
+ const digest = JSON.parse(claim.t);
519
+ // Minimal shape check: a Digest must at least have prompt_text.
520
+ if (typeof digest !== 'object' || digest === null)
521
+ return null;
522
+ if (typeof digest.prompt_text !== 'string')
523
+ return null;
524
+ return digest;
525
+ }
526
+ catch {
527
+ return null;
528
+ }
529
+ }
530
+ /**
531
+ * Lightweight check: does this decrypted blob look like a digest claim?
532
+ * Used to filter digest blobs out of user-facing recall results.
533
+ *
534
+ * Accepts both canonical Claim JSON (`{c:"dig",...}`) and the already-parsed
535
+ * form; returns false for legacy `{text, metadata}` docs and any parse error.
536
+ */
537
+ export function isDigestBlob(decrypted) {
538
+ try {
539
+ const obj = JSON.parse(decrypted);
540
+ return obj && typeof obj === 'object' && obj.c === DIGEST_CATEGORY;
541
+ }
542
+ catch {
543
+ return false;
544
+ }
545
+ }
546
+ /**
547
+ * Hours between two timestamps.
548
+ *
549
+ * Returns `Infinity` when `compiledAtIso` is unparseable (forces a recompile,
550
+ * which is the safe default when we can't trust the stored timestamp). Returns
551
+ * 0 for future dates (clock-skew defensive).
552
+ */
553
+ export function hoursSince(compiledAtIso, nowMs) {
554
+ const then = Date.parse(compiledAtIso);
555
+ if (Number.isNaN(then))
556
+ return Infinity;
557
+ const deltaMs = nowMs - then;
558
+ if (deltaMs <= 0)
559
+ return 0;
560
+ return deltaMs / (1000 * 60 * 60);
561
+ }
562
+ /**
563
+ * The digest is stale if new claims have been written since it was compiled.
564
+ * Both inputs are Unix seconds.
565
+ *
566
+ * Falsely-equal or regressing values (clock skew, empty vault) return false —
567
+ * we only recompile on strictly-newer evidence.
568
+ */
569
+ export function isDigestStale(digestVersion, currentMaxCreatedAtUnix) {
570
+ return currentMaxCreatedAtUnix > digestVersion;
571
+ }
572
+ /**
573
+ * Recompile guard (plan §15.10):
574
+ * trigger if countNewClaims >= 10 OR hoursSinceCompilation >= 24.
575
+ *
576
+ * The caller is still responsible for the in-memory "in progress" flag
577
+ * (see digest-sync.ts) — this is a pure predicate.
578
+ */
579
+ export function shouldRecompile(input) {
580
+ const { countNewClaims, hoursSinceCompilation } = input;
581
+ return countNewClaims >= 10 || hoursSinceCompilation >= 24;
582
+ }
583
+ // ---------------------------------------------------------------------------
584
+ // Entity trapdoors
585
+ // ---------------------------------------------------------------------------
586
+ /**
587
+ * Compute a single entity trapdoor: sha256("entity:" + normalized_name) as hex.
588
+ *
589
+ * Uses the same primitive (plain SHA-256, not HMAC) as word / stem trapdoors in
590
+ * `generateBlindIndices()`. The `entity:` prefix namespaces the result so a
591
+ * user called "postgresql" never collides with the word trapdoor for the token
592
+ * "postgresql". The search path must construct queries with the same prefix.
593
+ *
594
+ * Rationale for plain SHA-256 vs HMAC: the existing word trapdoor implementation
595
+ * in `rust/totalreclaw-core/src/blind.rs` uses plain SHA-256 of the normalized
596
+ * token (no dedup_key). For entity trapdoors to appear in the same blindIndices
597
+ * array and be findable by the current search pipeline, they must use the same
598
+ * primitive. Adopting HMAC for entities alone would break search consistency.
599
+ */
600
+ export function computeEntityTrapdoor(name) {
601
+ const normalized = getWasm().normalizeEntityName(name);
602
+ return crypto
603
+ .createHash('sha256')
604
+ .update('entity:' + normalized)
605
+ .digest('hex');
606
+ }
607
+ /**
608
+ * Compute entity trapdoors for every entity on a fact, deduplicated.
609
+ * Returns an empty array when the fact has no entities.
610
+ */
611
+ export function computeEntityTrapdoors(entities) {
612
+ if (!entities || entities.length === 0)
613
+ return [];
614
+ const seen = new Set();
615
+ const out = [];
616
+ for (const e of entities) {
617
+ const td = computeEntityTrapdoor(e.name);
618
+ if (!seen.has(td)) {
619
+ seen.add(td);
620
+ out.push(td);
621
+ }
622
+ }
623
+ return out;
624
+ }