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