@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 +151 -0
- package/digest-sync.ts +60 -3
- package/fs-helpers.ts +417 -0
- package/index.ts +192 -84
- package/package.json +1 -1
- package/pin.ts +262 -43
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)
|
|
221
|
-
//
|
|
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)
|
|
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);
|