@totalreclaw/totalreclaw 3.0.7-rc.1 → 3.1.0-rc.2

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/claims-helper.ts CHANGED
@@ -142,6 +142,16 @@ export function buildCanonicalClaim(input: BuildClaimInput): string {
142
142
 
143
143
  export const V1_SCHEMA_VERSION = '1.0' as const;
144
144
 
145
+ /**
146
+ * v1.1 pin state — `"pinned"` = user explicitly pinned (immune to
147
+ * auto-supersede); `"unpinned"` (or absence) = standard behavior.
148
+ *
149
+ * Surface of the `pin_status` field added in spec v1.1 (2026-04-19). Writers
150
+ * that understand v1.1 emit this field on the pin path; readers at 1.0 / 1.1
151
+ * that see it MUST honor `"pinned"` as immunity from auto-supersede.
152
+ */
153
+ export type PinStatus = 'pinned' | 'unpinned';
154
+
145
155
  export interface BuildClaimV1Input {
146
156
  /** The extracted fact in v1 shape. Must have `type` as a MemoryTypeV1 token. */
147
157
  fact: ExtractedFact;
@@ -156,6 +166,13 @@ export interface BuildClaimV1Input {
156
166
  /** Stable claim ID. Defaults to crypto.randomUUID() at the call site; keep the
157
167
  * same ID for both the blob and the on-chain fact id. */
158
168
  id?: string;
169
+ /**
170
+ * v1.1 pin state. When `"pinned"`, the claim is immune to auto-supersede.
171
+ * Omitted or `"unpinned"` both mean unpinned (field is additive — absence
172
+ * equivalent to `"unpinned"` on the wire). Surfaced in the final JSON only
173
+ * when provided.
174
+ */
175
+ pinStatus?: PinStatus;
159
176
  }
160
177
 
161
178
  /**
@@ -226,6 +243,10 @@ export function buildCanonicalClaimV1(input: BuildClaimV1Input): string {
226
243
  }
227
244
  if (expiresAt) corePayload.expires_at = expiresAt;
228
245
  if (supersededBy) corePayload.superseded_by = supersededBy;
246
+ // v1.1 pin_status — additive field; only emitted when the caller opts in.
247
+ if (input.pinStatus === 'pinned' || input.pinStatus === 'unpinned') {
248
+ corePayload.pin_status = input.pinStatus;
249
+ }
229
250
 
230
251
  // Validate through core — throws on invalid type / source / missing id.
231
252
  const validated = getWasm().validateMemoryClaimV1(JSON.stringify(corePayload)) as string;
@@ -240,6 +261,122 @@ export function buildCanonicalClaimV1(input: BuildClaimV1Input): string {
240
261
  return JSON.stringify(canonical);
241
262
  }
242
263
 
264
+ // ---------------------------------------------------------------------------
265
+ // buildV1ClaimBlob — lightweight v1 blob builder for pin / retype / set_scope
266
+ //
267
+ // Unlike buildCanonicalClaimV1 (which consumes a full ExtractedFact), this
268
+ // helper takes raw primitives and is the right entry point when synthesizing
269
+ // a new blob from a previously-decrypted one — e.g. the pin path rewrites the
270
+ // blob with an updated pin_status field and everything else preserved.
271
+ //
272
+ // The output is a v1.1 JSON payload with schema_version "1.0" (v1.1 is
273
+ // additive; on-wire schema_version is unchanged per spec). The outer protobuf
274
+ // wrapper MUST be written at version 4 — see `subgraph-store.ts`.
275
+ // ---------------------------------------------------------------------------
276
+
277
+ export interface BuildV1ClaimBlobInput {
278
+ /** Human-readable fact text (5-512 UTF-8 chars). */
279
+ text: string;
280
+ /** v1 memory type. Must be one of the 6 canonical values. */
281
+ type: MemoryType;
282
+ /** Provenance per spec §provenance-filter. */
283
+ source: MemorySource;
284
+ /** Optional stable UUID; defaults to randomUUID(). */
285
+ id?: string;
286
+ /** Optional creation timestamp (ISO 8601 UTC); defaults to now. */
287
+ createdAt?: string;
288
+ /** Optional scope (defaults to omitted → "unspecified"). */
289
+ scope?: MemoryScope;
290
+ /** Optional volatility (defaults to omitted → "updatable"). */
291
+ volatility?: MemoryVolatility;
292
+ /** Optional reasoning clause for decision-style claims. */
293
+ reasoning?: string;
294
+ /** Optional structured entities. */
295
+ entities?: ExtractedEntity[];
296
+ /** Optional expiration timestamp. */
297
+ expiresAt?: string;
298
+ /** Optional importance (1-10, advisory). */
299
+ importance?: number;
300
+ /** Optional confidence (0-1). */
301
+ confidence?: number;
302
+ /** Optional superseded-by chain pointer. */
303
+ supersededBy?: string;
304
+ /**
305
+ * Optional v1.1 pin state.
306
+ *
307
+ * - `"pinned"` → user explicitly pinned; the claim MUST NOT be auto-superseded.
308
+ * - `"unpinned"` → explicit unpin (resets a previous pin).
309
+ * - Undefined/omitted → field not emitted; receivers treat as unpinned.
310
+ *
311
+ * Callers are free to pass `"unpinned"` to create an explicit un-pin
312
+ * supersede event, or to pass `undefined` to leave the field absent on a
313
+ * non-pin write.
314
+ */
315
+ pinStatus?: PinStatus;
316
+ }
317
+
318
+ /**
319
+ * Build a v1.1 canonical claim JSON string, validated through the core
320
+ * `validateMemoryClaimV1` WASM export.
321
+ *
322
+ * Output is UTF-8 JSON ready for encryption as the inner blob of a
323
+ * protobuf-v4 fact (outer `version = 4`). Field ordering follows the core
324
+ * validator, so the result is byte-identical to what MCP's equivalent helper
325
+ * produces for the same inputs (cross-client parity).
326
+ *
327
+ * Throws on malformed input (missing required field, invalid enum value).
328
+ */
329
+ export function buildV1ClaimBlob(input: BuildV1ClaimBlobInput): string {
330
+ if (!(VALID_MEMORY_SOURCES as readonly string[]).includes(input.source)) {
331
+ throw new Error(`buildV1ClaimBlob: invalid source "${input.source}"`);
332
+ }
333
+ if (!isValidMemoryType(input.type)) {
334
+ throw new Error(`buildV1ClaimBlob: invalid type "${input.type}"`);
335
+ }
336
+
337
+ const corePayload: Record<string, unknown> = {
338
+ id: input.id ?? crypto.randomUUID(),
339
+ text: input.text,
340
+ type: input.type,
341
+ source: input.source,
342
+ created_at: input.createdAt ?? new Date().toISOString(),
343
+ };
344
+
345
+ if (input.scope && (VALID_MEMORY_SCOPES as readonly string[]).includes(input.scope)) {
346
+ corePayload.scope = input.scope;
347
+ }
348
+ if (input.reasoning && input.reasoning.length > 0) {
349
+ corePayload.reasoning = input.reasoning.slice(0, 256);
350
+ }
351
+ if (input.entities && input.entities.length > 0) {
352
+ corePayload.entities = input.entities.slice(0, 8).map((e) => {
353
+ const entity: Record<string, unknown> = { name: e.name, type: e.type };
354
+ if (e.role) entity.role = e.role;
355
+ return entity;
356
+ });
357
+ }
358
+ if (typeof input.importance === 'number') {
359
+ corePayload.importance = Math.max(1, Math.min(10, Math.round(input.importance)));
360
+ }
361
+ if (typeof input.confidence === 'number') {
362
+ corePayload.confidence = Math.max(0, Math.min(1, input.confidence));
363
+ }
364
+ if (input.expiresAt) corePayload.expires_at = input.expiresAt;
365
+ if (input.supersededBy) corePayload.superseded_by = input.supersededBy;
366
+ if (input.pinStatus === 'pinned' || input.pinStatus === 'unpinned') {
367
+ corePayload.pin_status = input.pinStatus;
368
+ }
369
+
370
+ // Validate via core — throws on invalid shape.
371
+ const validated = getWasm().validateMemoryClaimV1(JSON.stringify(corePayload)) as string;
372
+ const canonical = JSON.parse(validated) as Record<string, unknown>;
373
+ canonical.schema_version = V1_SCHEMA_VERSION;
374
+ if (input.volatility && (VALID_MEMORY_VOLATILITIES as readonly string[]).includes(input.volatility)) {
375
+ canonical.volatility = input.volatility;
376
+ }
377
+ return JSON.stringify(canonical);
378
+ }
379
+
243
380
  /**
244
381
  * Normalize any type token (v0 or v1) to a v1 type. Uses the v0→v1 mapping
245
382
  * for legacy tokens; passes through when already v1.
@@ -289,6 +426,11 @@ export interface V1BlobReadResult {
289
426
  expiresAt?: string;
290
427
  supersededBy?: string;
291
428
  id?: string;
429
+ /**
430
+ * v1.1 pin state. Absent when the blob was written by a v1.0 client or
431
+ * when the writer explicitly omitted the field (treated as `"unpinned"`).
432
+ */
433
+ pinStatus?: PinStatus;
292
434
  }
293
435
 
294
436
  export function readV1Blob(decrypted: string): V1BlobReadResult | null {
@@ -349,6 +491,12 @@ export function readV1Blob(decrypted: string): V1BlobReadResult | null {
349
491
  if (typeof obj.expires_at === 'string') result.expiresAt = obj.expires_at;
350
492
  if (typeof obj.superseded_by === 'string') result.supersededBy = obj.superseded_by;
351
493
  if (typeof obj.id === 'string') result.id = obj.id;
494
+ if (typeof obj.pin_status === 'string') {
495
+ const ps = obj.pin_status;
496
+ if (ps === 'pinned' || ps === 'unpinned') {
497
+ result.pinStatus = ps;
498
+ }
499
+ }
352
500
 
353
501
  return result;
354
502
  } catch {
@@ -484,6 +632,9 @@ export function readClaimFromBlob(decryptedJson: string): BlobReadResult {
484
632
  scope: typeof obj.scope === 'string' ? obj.scope : 'unspecified',
485
633
  volatility: typeof obj.volatility === 'string' ? obj.volatility : 'updatable',
486
634
  reasoning: typeof obj.reasoning === 'string' ? obj.reasoning : undefined,
635
+ // v1.1: surface pin_status verbatim for downstream (recall display +
636
+ // export). Absent ⇒ undefined (receivers treat as "unpinned").
637
+ pin_status: typeof obj.pin_status === 'string' ? obj.pin_status : undefined,
487
638
  importance: importance / 10,
488
639
  created_at: typeof obj.created_at === 'string' ? obj.created_at : '',
489
640
  schema_version: obj.schema_version,
package/digest-sync.ts CHANGED
@@ -73,6 +73,44 @@ export interface CompileDigestCoreInput {
73
73
  logger: DigestLogger;
74
74
  }
75
75
 
76
+ // ---------------------------------------------------------------------------
77
+ // Stub / tombstone blob detection
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Is this subgraph-stored blob a supersede tombstone or other non-content
82
+ * stub? The 3.0.7-rc.1 QA found that 7 of 25 facts on the QA wallet had
83
+ * `encryptedBlob == "0x00"` — a 1-byte stub written as a supersede
84
+ * tombstone. The digest pipeline attempted to decrypt these unconditionally
85
+ * and produced 5 `Digest: decrypt failed … Encrypted data too short`
86
+ * warnings per QA window.
87
+ *
88
+ * We deliberately ONLY short-circuit shapes that cannot plausibly contain
89
+ * a real XChaCha20-Poly1305 payload — a valid ciphertext must be at
90
+ * least 40 bytes (24B nonce + 16B tag). We stay conservative about
91
+ * "short-but-non-stub" blobs: if someone's wire format changes and we
92
+ * see a 30-byte blob, that's a legitimate decrypt-failure case worth
93
+ * logging as a WARN, not silently skipping. So the check is:
94
+ *
95
+ * - Empty string → stub
96
+ * - Just the `0x` / `0X` prefix → stub
97
+ * - All-zero hex (e.g. "0x00", "00") → stub (explicit tombstone)
98
+ *
99
+ * Anything else falls through to the decrypt attempt.
100
+ *
101
+ * Called from both `loadLatestDigest` (digest read path) and
102
+ * `fetchAllActiveClaims` (digest recompile path).
103
+ */
104
+ export function isStubBlob(hex: string): boolean {
105
+ if (typeof hex !== 'string') return true;
106
+ const stripped = (hex.startsWith('0x') || hex.startsWith('0X')) ? hex.slice(2) : hex;
107
+ if (stripped.length === 0) return true;
108
+ // All-zero hex is the explicit tombstone shape the relay emits
109
+ // when marking a fact superseded (seen as "0x00" on the QA wallet,
110
+ // but any "00...00" of any length is semantically the same).
111
+ return /^0+$/i.test(stripped);
112
+ }
113
+
76
114
  // ---------------------------------------------------------------------------
77
115
  // Recompile-in-progress guard (in-memory, per-process)
78
116
  // ---------------------------------------------------------------------------
@@ -217,16 +255,30 @@ export async function loadLatestDigest(
217
255
  }
218
256
  if (!results || results.length === 0) return null;
219
257
 
220
- // Pick the highest createdAt (client-generated Unix seconds). Fall back to
221
- // timestamp (block time) when createdAt is missing.
258
+ // Pick the highest createdAt (client-generated Unix seconds) among rows
259
+ // with a real (non-stub) blob. Stub blobs are supersede tombstones —
260
+ // see `isStubBlob` above; attempting to decrypt one produces a noisy
261
+ // `Digest: decrypt failed … Encrypted data too short` WARN. We filter
262
+ // them out pre-ranking so we prefer a slightly-older real digest over
263
+ // a newer tombstone. If EVERY candidate is a stub, return null quietly.
222
264
  let best: { id: string; encryptedBlob: string; createdAt: number } | null = null;
265
+ let stubCount = 0;
223
266
  for (const r of results) {
267
+ if (isStubBlob(r.encryptedBlob)) {
268
+ stubCount++;
269
+ continue;
270
+ }
224
271
  const createdAt = parseInt(r.createdAt ?? r.timestamp ?? '0', 10) || 0;
225
272
  if (!best || createdAt > best.createdAt) {
226
273
  best = { id: r.id, encryptedBlob: r.encryptedBlob, createdAt };
227
274
  }
228
275
  }
229
- if (!best) return null;
276
+ if (!best) {
277
+ if (stubCount > 0) {
278
+ logger.info(`Digest: all ${stubCount} candidates were tombstone stubs — no digest available`);
279
+ }
280
+ return null;
281
+ }
230
282
 
231
283
  try {
232
284
  const decrypted = deps.decryptFromHex(best.encryptedBlob, encryptionKey);
@@ -349,6 +401,11 @@ export async function fetchAllActiveClaims(
349
401
  const claimsOut: unknown[] = [];
350
402
  for (const row of rows) {
351
403
  if (row.isActive === false) continue;
404
+ // Stub / tombstone blobs (encryptedBlob == "0x00") will always fail
405
+ // decrypt with `Encrypted data too short`. Skip pre-decrypt so we
406
+ // don't spin up a WASM call path per stub — the QA wallet had 7 of
407
+ // 25 facts as stubs, so this matters for recompile cost too.
408
+ if (isStubBlob(row.encryptedBlob)) continue;
352
409
  try {
353
410
  const decrypted = deps.decryptFromHex(row.encryptedBlob, encryptionKey);
354
411
  const canonicalJson = getWasm().parseClaimOrLegacy(decrypted);