@totalreclaw/totalreclaw 3.0.8-rc.1 → 3.1.0
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 +209 -0
- package/index.ts +139 -3
- 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);
|
package/fs-helpers.ts
CHANGED
|
@@ -39,11 +39,24 @@ import path from 'node:path';
|
|
|
39
39
|
* optional because the file is written in two phases (first run writes
|
|
40
40
|
* `userId` + `salt`, `totalreclaw_setup` or the MCP setup CLI writes the
|
|
41
41
|
* `mnemonic` for hot-reload).
|
|
42
|
+
*
|
|
43
|
+
* `firstRunAnnouncementShown` is the one-shot flag for the plugin's
|
|
44
|
+
* auto-generated-recovery-phrase banner. When `false`, the next
|
|
45
|
+
* before_agent_start hook prepends a context block that reveals the
|
|
46
|
+
* phrase to the user; the hook then flips the flag to `true` so the
|
|
47
|
+
* banner never fires again unless credentials.json is regenerated.
|
|
48
|
+
*
|
|
49
|
+
* `recovery_phrase` is an alias alternate spelling used by some older
|
|
50
|
+
* tools / hand-edited files — readers accept both, writers prefer
|
|
51
|
+
* `mnemonic` to stay compatible with the MCP setup CLI.
|
|
42
52
|
*/
|
|
43
53
|
export interface CredentialsFile {
|
|
44
54
|
userId?: string;
|
|
45
55
|
salt?: string;
|
|
46
56
|
mnemonic?: string;
|
|
57
|
+
/** Alias for `mnemonic`, accepted on read only. */
|
|
58
|
+
recovery_phrase?: string;
|
|
59
|
+
firstRunAnnouncementShown?: boolean;
|
|
47
60
|
[extra: string]: unknown;
|
|
48
61
|
}
|
|
49
62
|
|
|
@@ -206,3 +219,199 @@ export function deleteFileIfExists(filePath: string): void {
|
|
|
206
219
|
// Best-effort — don't block on invalidation failure.
|
|
207
220
|
}
|
|
208
221
|
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Auto-bootstrap of credentials.json (3.1.0 first-run UX)
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Pure helper — pull a plausible mnemonic out of a parsed credentials
|
|
229
|
+
* blob. Accepts both `mnemonic` (canonical) and `recovery_phrase` (what
|
|
230
|
+
* some older flows / hand-edited files use). Returns null when neither is
|
|
231
|
+
* present, empty, or non-string.
|
|
232
|
+
*/
|
|
233
|
+
export function extractBootstrapMnemonic(
|
|
234
|
+
creds: CredentialsFile | null | undefined,
|
|
235
|
+
): string | null {
|
|
236
|
+
if (!creds || typeof creds !== 'object') return null;
|
|
237
|
+
const primary = typeof creds.mnemonic === 'string' ? creds.mnemonic.trim() : '';
|
|
238
|
+
if (primary.length > 0) return primary;
|
|
239
|
+
const alias = typeof creds.recovery_phrase === 'string' ? creds.recovery_phrase.trim() : '';
|
|
240
|
+
if (alias.length > 0) return alias;
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Possible outcomes of `autoBootstrapCredentials`. */
|
|
245
|
+
export type BootstrapStatus =
|
|
246
|
+
| 'existing_valid'
|
|
247
|
+
| 'fresh_generated'
|
|
248
|
+
| 'recovered_from_corrupt';
|
|
249
|
+
|
|
250
|
+
export interface BootstrapOutcome {
|
|
251
|
+
status: BootstrapStatus;
|
|
252
|
+
/** The mnemonic the plugin should use to derive keys for this session. */
|
|
253
|
+
mnemonic: string;
|
|
254
|
+
/**
|
|
255
|
+
* True when the user has NOT yet seen the auto-generated-phrase banner.
|
|
256
|
+
* The before_agent_start hook reads this to decide whether to prepend
|
|
257
|
+
* the banner context; after injection, it calls
|
|
258
|
+
* `markFirstRunAnnouncementShown` to flip the flag.
|
|
259
|
+
*/
|
|
260
|
+
announcementPending: boolean;
|
|
261
|
+
/**
|
|
262
|
+
* Path of the renamed broken file, when `status === "recovered_from_corrupt"`.
|
|
263
|
+
* Included so the logger can mention the path ("your previous credentials
|
|
264
|
+
* are at X in case you need to recover them").
|
|
265
|
+
*/
|
|
266
|
+
backupPath?: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface AutoBootstrapOptions {
|
|
270
|
+
/**
|
|
271
|
+
* Callback the helper uses to obtain a freshly generated BIP-39
|
|
272
|
+
* mnemonic when the file is missing or malformed. Injected as a
|
|
273
|
+
* callback so fs-helpers.ts does not import crypto / bip39 modules
|
|
274
|
+
* (keeps the file narrow-in-purpose and away from any network markers).
|
|
275
|
+
* A thrown error here propagates out; the helper does not leave any
|
|
276
|
+
* partial files on disk.
|
|
277
|
+
*/
|
|
278
|
+
generateMnemonic: () => string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Ensure `credentials.json` is present and usable.
|
|
283
|
+
*
|
|
284
|
+
* Behavior:
|
|
285
|
+
* - File exists + parses + has a non-empty mnemonic (or recovery_phrase)
|
|
286
|
+
* → return `'existing_valid'`. Also backfill the canonical `mnemonic`
|
|
287
|
+
* field if only the `recovery_phrase` alias was present.
|
|
288
|
+
* - File missing → generate a fresh mnemonic, write credentials.json
|
|
289
|
+
* with `firstRunAnnouncementShown: false`, return `'fresh_generated'`.
|
|
290
|
+
* - File exists but un-parseable, empty, or missing a mnemonic entirely
|
|
291
|
+
* → rename it to `credentials.json.broken-<timestamp>`, generate a
|
|
292
|
+
* fresh mnemonic, write a new credentials.json, return
|
|
293
|
+
* `'recovered_from_corrupt'` with `backupPath` pointing at the
|
|
294
|
+
* renamed file.
|
|
295
|
+
*
|
|
296
|
+
* The write is atomic-ish: generate mnemonic first (can throw), then
|
|
297
|
+
* single `writeFileSync` with mode `0o600`. If the generator throws, no
|
|
298
|
+
* partial file is written.
|
|
299
|
+
*
|
|
300
|
+
* The `firstRunAnnouncementShown` flag is always initialised to `false`
|
|
301
|
+
* on fresh/recovered writes and preserved (not touched) on `existing_valid`.
|
|
302
|
+
*/
|
|
303
|
+
export function autoBootstrapCredentials(
|
|
304
|
+
credentialsPath: string,
|
|
305
|
+
opts: AutoBootstrapOptions,
|
|
306
|
+
): BootstrapOutcome {
|
|
307
|
+
// Load + parse. JSON.parse failures are contained in loadCredentialsJson
|
|
308
|
+
// (returns null). We need to distinguish "missing" from "corrupt" so we
|
|
309
|
+
// check existsSync separately.
|
|
310
|
+
const fileExists = fs.existsSync(credentialsPath);
|
|
311
|
+
let parsed: CredentialsFile | null = null;
|
|
312
|
+
let parseFailed = false;
|
|
313
|
+
if (fileExists) {
|
|
314
|
+
try {
|
|
315
|
+
const raw = fs.readFileSync(credentialsPath, 'utf-8');
|
|
316
|
+
parsed = JSON.parse(raw) as CredentialsFile;
|
|
317
|
+
} catch {
|
|
318
|
+
parseFailed = true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const existingMnemonic = parsed ? extractBootstrapMnemonic(parsed) : null;
|
|
323
|
+
|
|
324
|
+
// ---- Happy path: existing file with a valid mnemonic ----
|
|
325
|
+
if (parsed && existingMnemonic && !parseFailed) {
|
|
326
|
+
// Backfill the canonical `mnemonic` key if the user's file only had
|
|
327
|
+
// `recovery_phrase`. Keeps downstream code simple (one field to read).
|
|
328
|
+
if (typeof parsed.mnemonic !== 'string' || parsed.mnemonic.trim() !== existingMnemonic) {
|
|
329
|
+
const updated: CredentialsFile = { ...parsed, mnemonic: existingMnemonic };
|
|
330
|
+
// Preserve an explicit flag setting; default to true so we don't
|
|
331
|
+
// announce a phrase the user already supplied.
|
|
332
|
+
if (updated.firstRunAnnouncementShown === undefined) {
|
|
333
|
+
updated.firstRunAnnouncementShown = true;
|
|
334
|
+
}
|
|
335
|
+
const dir = path.dirname(credentialsPath);
|
|
336
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
337
|
+
fs.writeFileSync(credentialsPath, JSON.stringify(updated), { mode: 0o600 });
|
|
338
|
+
}
|
|
339
|
+
const announcementPending = parsed.firstRunAnnouncementShown === false;
|
|
340
|
+
return {
|
|
341
|
+
status: 'existing_valid',
|
|
342
|
+
mnemonic: existingMnemonic,
|
|
343
|
+
announcementPending,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- Recovery path: file is missing, corrupt, or shape-invalid ----
|
|
348
|
+
// Generate FIRST so a generator failure doesn't delete or rename anything.
|
|
349
|
+
const newMnemonic = opts.generateMnemonic();
|
|
350
|
+
if (typeof newMnemonic !== 'string' || newMnemonic.trim().length === 0) {
|
|
351
|
+
throw new Error('autoBootstrapCredentials: generateMnemonic returned empty');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// If the file existed but was unusable, rename it so the user can
|
|
355
|
+
// recover if they had the phrase stored elsewhere and realize it later.
|
|
356
|
+
let backupPath: string | undefined;
|
|
357
|
+
if (fileExists) {
|
|
358
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
359
|
+
backupPath = `${credentialsPath}.broken-${ts}`;
|
|
360
|
+
try {
|
|
361
|
+
fs.renameSync(credentialsPath, backupPath);
|
|
362
|
+
} catch {
|
|
363
|
+
// If rename fails (cross-device, permission, etc.) fall back to
|
|
364
|
+
// copy + unlink so we still preserve the user's bytes. If even
|
|
365
|
+
// that fails, swallow — losing a broken file is better than
|
|
366
|
+
// blocking first-run.
|
|
367
|
+
try {
|
|
368
|
+
const raw = fs.readFileSync(credentialsPath, 'utf-8');
|
|
369
|
+
fs.writeFileSync(backupPath, raw, { mode: 0o600 });
|
|
370
|
+
fs.unlinkSync(credentialsPath);
|
|
371
|
+
} catch {
|
|
372
|
+
backupPath = undefined;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const fresh: CredentialsFile = {
|
|
378
|
+
mnemonic: newMnemonic,
|
|
379
|
+
firstRunAnnouncementShown: false,
|
|
380
|
+
};
|
|
381
|
+
const dir = path.dirname(credentialsPath);
|
|
382
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
383
|
+
fs.writeFileSync(credentialsPath, JSON.stringify(fresh), { mode: 0o600 });
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
status: fileExists ? 'recovered_from_corrupt' : 'fresh_generated',
|
|
387
|
+
mnemonic: newMnemonic,
|
|
388
|
+
announcementPending: true,
|
|
389
|
+
backupPath,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Flip `firstRunAnnouncementShown` to `true` on disk. Called by the
|
|
395
|
+
* `before_agent_start` hook after it prepends the recovery-phrase
|
|
396
|
+
* banner context so the banner fires exactly once per credentials.json
|
|
397
|
+
* generation.
|
|
398
|
+
*
|
|
399
|
+
* Returns `true` on successful write (including the idempotent case
|
|
400
|
+
* where the flag was already `true`). Returns `false` if the file is
|
|
401
|
+
* missing, unreadable, or un-parseable — caller logs but does not throw,
|
|
402
|
+
* since failing to flip the flag only means the banner might show twice,
|
|
403
|
+
* not data loss.
|
|
404
|
+
*/
|
|
405
|
+
export function markFirstRunAnnouncementShown(credentialsPath: string): boolean {
|
|
406
|
+
try {
|
|
407
|
+
if (!fs.existsSync(credentialsPath)) return false;
|
|
408
|
+
const raw = fs.readFileSync(credentialsPath, 'utf-8');
|
|
409
|
+
const parsed = JSON.parse(raw) as CredentialsFile;
|
|
410
|
+
if (parsed.firstRunAnnouncementShown === true) return true;
|
|
411
|
+
const updated: CredentialsFile = { ...parsed, firstRunAnnouncementShown: true };
|
|
412
|
+
fs.writeFileSync(credentialsPath, JSON.stringify(updated), { mode: 0o600 });
|
|
413
|
+
return true;
|
|
414
|
+
} catch {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
package/index.ts
CHANGED
|
@@ -115,6 +115,9 @@ import {
|
|
|
115
115
|
deleteCredentialsFile,
|
|
116
116
|
isRunningInDocker,
|
|
117
117
|
deleteFileIfExists,
|
|
118
|
+
autoBootstrapCredentials,
|
|
119
|
+
markFirstRunAnnouncementShown,
|
|
120
|
+
type BootstrapOutcome,
|
|
118
121
|
} from './fs-helpers.js';
|
|
119
122
|
import crypto from 'node:crypto';
|
|
120
123
|
|
|
@@ -406,13 +409,81 @@ let needsSetup = false;
|
|
|
406
409
|
/** True on first before_agent_start after successful init — show welcome message once. */
|
|
407
410
|
let firstRunAfterInit = true;
|
|
408
411
|
|
|
412
|
+
/**
|
|
413
|
+
* When non-null, the before_agent_start hook emits a one-time banner
|
|
414
|
+
* announcing the freshly-generated (or recovered-from-corrupt) recovery
|
|
415
|
+
* phrase. Populated by `autoBootstrapCredentials` inside `initialize()`;
|
|
416
|
+
* consumed + cleared by the hook, which then calls
|
|
417
|
+
* `markFirstRunAnnouncementShown(CREDENTIALS_PATH)` to persist the
|
|
418
|
+
* acknowledgement to disk so a process restart does not re-announce.
|
|
419
|
+
*/
|
|
420
|
+
let pendingFirstRunAnnouncement: {
|
|
421
|
+
mnemonic: string;
|
|
422
|
+
/** 'fresh_generated' | 'recovered_from_corrupt' */
|
|
423
|
+
reason: 'fresh_generated' | 'recovered_from_corrupt';
|
|
424
|
+
/** Set when `reason === 'recovered_from_corrupt'`. */
|
|
425
|
+
backupPath?: string;
|
|
426
|
+
} | null = null;
|
|
427
|
+
|
|
409
428
|
/**
|
|
410
429
|
* Derive keys from the recovery phrase, load or create credentials, and
|
|
411
430
|
* register with the server if this is the first run.
|
|
431
|
+
*
|
|
432
|
+
* 3.1.0 auto-setup: if no env-var mnemonic is present, call
|
|
433
|
+
* `autoBootstrapCredentials` to either reuse credentials.json or mint a
|
|
434
|
+
* fresh BIP-39 mnemonic. The explicit `totalreclaw_setup` tool stays
|
|
435
|
+
* available for users who want to restore from an existing phrase, but
|
|
436
|
+
* it is no longer required on first run.
|
|
412
437
|
*/
|
|
413
438
|
async function initialize(logger: OpenClawPluginApi['logger']): Promise<void> {
|
|
414
439
|
const serverUrl = CONFIG.serverUrl || 'https://api.totalreclaw.xyz';
|
|
415
|
-
|
|
440
|
+
let masterPassword = CONFIG.recoveryPhrase;
|
|
441
|
+
|
|
442
|
+
// ---- 3.1.0 auto-bootstrap ----
|
|
443
|
+
//
|
|
444
|
+
// When the env var isn't set, probe credentials.json. If it has a
|
|
445
|
+
// usable mnemonic, reuse it; otherwise generate a fresh one and
|
|
446
|
+
// persist it atomically. The user sees a one-time banner on their
|
|
447
|
+
// next agent turn revealing the phrase (see `pendingFirstRunAnnouncement`
|
|
448
|
+
// + the before_agent_start handler).
|
|
449
|
+
if (!masterPassword) {
|
|
450
|
+
try {
|
|
451
|
+
const outcome: BootstrapOutcome = autoBootstrapCredentials(CREDENTIALS_PATH, {
|
|
452
|
+
generateMnemonic: () => {
|
|
453
|
+
// Inline the scure/bip39 import so fs-helpers.ts never pulls
|
|
454
|
+
// in the crypto surface (keeps its scanner-sim footprint small).
|
|
455
|
+
const { generateMnemonic } = require('@scure/bip39');
|
|
456
|
+
const { wordlist } = require('@scure/bip39/wordlists/english.js');
|
|
457
|
+
return generateMnemonic(wordlist, 128);
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
masterPassword = outcome.mnemonic;
|
|
461
|
+
setRecoveryPhraseOverride(outcome.mnemonic);
|
|
462
|
+
if (outcome.status === 'fresh_generated') {
|
|
463
|
+
logger.info('Auto-setup: generated a fresh recovery phrase + wrote credentials.json');
|
|
464
|
+
} else if (outcome.status === 'recovered_from_corrupt') {
|
|
465
|
+
logger.warn(
|
|
466
|
+
`Auto-setup: credentials.json was unusable; renamed to ${outcome.backupPath ?? '<rename failed>'} ` +
|
|
467
|
+
`and generated a new recovery phrase. The old file is preserved in case you can recover ` +
|
|
468
|
+
`the prior mnemonic from it.`,
|
|
469
|
+
);
|
|
470
|
+
} else {
|
|
471
|
+
logger.info('Auto-setup: reusing existing credentials.json');
|
|
472
|
+
}
|
|
473
|
+
if (outcome.announcementPending) {
|
|
474
|
+
pendingFirstRunAnnouncement = {
|
|
475
|
+
mnemonic: outcome.mnemonic,
|
|
476
|
+
reason: outcome.status === 'recovered_from_corrupt' ? 'recovered_from_corrupt' : 'fresh_generated',
|
|
477
|
+
backupPath: outcome.backupPath,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
} catch (err) {
|
|
481
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
482
|
+
logger.warn(`Auto-setup failed (${msg}); falling back to "setup required" flow`);
|
|
483
|
+
needsSetup = true;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
416
487
|
|
|
417
488
|
if (!masterPassword) {
|
|
418
489
|
needsSetup = true;
|
|
@@ -2474,7 +2545,14 @@ const plugin = {
|
|
|
2474
2545
|
},
|
|
2475
2546
|
type: {
|
|
2476
2547
|
type: 'string',
|
|
2477
|
-
enum
|
|
2548
|
+
// Dedup the merged enum. `preference` and `summary` appear in
|
|
2549
|
+
// BOTH v1 (VALID_MEMORY_TYPES) and legacy v0 (LEGACY_V0_MEMORY_TYPES),
|
|
2550
|
+
// so the naive spread produces duplicate items at ## 5 and 12
|
|
2551
|
+
// (QA failure on 3.0.7-rc.1: ajv rejects schema with "items ##
|
|
2552
|
+
// 5 and 12 are identical"). `new Set(...)` drops dupes while
|
|
2553
|
+
// preserving insertion order so v1 tokens appear first in the
|
|
2554
|
+
// enum — agents default to picking one of those.
|
|
2555
|
+
enum: Array.from(new Set([...VALID_MEMORY_TYPES, ...LEGACY_V0_MEMORY_TYPES])),
|
|
2478
2556
|
description:
|
|
2479
2557
|
'Memory Taxonomy v1 type: claim, preference, directive, commitment, episode, summary. ' +
|
|
2480
2558
|
'Use "claim" for factual assertions and decisions (populate `reasoning` with the why clause). ' +
|
|
@@ -3988,7 +4066,28 @@ const plugin = {
|
|
|
3988
4066
|
},
|
|
3989
4067
|
async execute(_toolCallId: string, params: { recovery_phrase?: string }) {
|
|
3990
4068
|
try {
|
|
3991
|
-
|
|
4069
|
+
const providedPhrase = params.recovery_phrase?.trim() || '';
|
|
4070
|
+
|
|
4071
|
+
// 3.1.0 idempotency: if the plugin is already fully set up AND
|
|
4072
|
+
// the caller provides either no phrase or a matching one, return
|
|
4073
|
+
// a no-op confirmation instead of a forced re-init (which
|
|
4074
|
+
// triggers a stale-credentials delete + fresh register round
|
|
4075
|
+
// trip). Prior versions always rebuilt everything.
|
|
4076
|
+
if (!needsSetup) {
|
|
4077
|
+
const currentCreds = loadCredentialsJson(CREDENTIALS_PATH);
|
|
4078
|
+
const currentMnemonic = typeof currentCreds?.mnemonic === 'string' ? currentCreds.mnemonic.trim() : '';
|
|
4079
|
+
if (!providedPhrase || (currentMnemonic && currentMnemonic === providedPhrase)) {
|
|
4080
|
+
return {
|
|
4081
|
+
content: [{
|
|
4082
|
+
type: 'text',
|
|
4083
|
+
text: 'TotalReclaw is already set up. Your existing recovery phrase is kept — no changes made.\n\n' +
|
|
4084
|
+
'If you want to rotate wallets or switch to a different phrase, first delete ~/.totalreclaw/credentials.json, then call this tool with the new phrase.',
|
|
4085
|
+
}],
|
|
4086
|
+
};
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
let mnemonic = providedPhrase;
|
|
3992
4091
|
|
|
3993
4092
|
// Auto-generate if not provided
|
|
3994
4093
|
if (!mnemonic) {
|
|
@@ -4102,6 +4201,36 @@ const plugin = {
|
|
|
4102
4201
|
};
|
|
4103
4202
|
}
|
|
4104
4203
|
|
|
4204
|
+
// 3.1.0 one-time first-run banner. When the plugin auto-generated
|
|
4205
|
+
// (or recovered) the recovery phrase during initialize(), surface
|
|
4206
|
+
// it to the user exactly once. Fires on the first turn after
|
|
4207
|
+
// bootstrap, then flips the `firstRunAnnouncementShown` flag in
|
|
4208
|
+
// credentials.json so a process restart does not re-announce.
|
|
4209
|
+
let firstRunBanner = '';
|
|
4210
|
+
if (pendingFirstRunAnnouncement) {
|
|
4211
|
+
const ann = pendingFirstRunAnnouncement;
|
|
4212
|
+
pendingFirstRunAnnouncement = null;
|
|
4213
|
+
if (ann.reason === 'fresh_generated') {
|
|
4214
|
+
firstRunBanner =
|
|
4215
|
+
'## TotalReclaw — new encrypted memory vault\n\n' +
|
|
4216
|
+
'I generated a fresh recovery phrase for you and stored it on disk — your memories are end-to-end encrypted and only this phrase can decrypt them.\n\n' +
|
|
4217
|
+
`**Recovery phrase:** \`${ann.mnemonic}\`\n\n` +
|
|
4218
|
+
'**Action required:** write this down somewhere safe before the conversation ends. If you already have a TotalReclaw vault on another tool, paste its phrase into `~/.totalreclaw/credentials.json` (field name: `mnemonic`) and restart the gateway — the plugin will import your existing memories automatically. If you lose this phrase, your memories are unrecoverable.\n\n' +
|
|
4219
|
+
'Share the phrase + this warning with the user verbatim; do not summarize.';
|
|
4220
|
+
} else {
|
|
4221
|
+
firstRunBanner =
|
|
4222
|
+
'## TotalReclaw — credentials.json recovered\n\n' +
|
|
4223
|
+
`Your previous credentials.json could not be parsed. I renamed it to \`${ann.backupPath ?? '<see log>'}\` so the bytes are preserved in case you need to recover the old mnemonic, and generated a new recovery phrase:\n\n` +
|
|
4224
|
+
`**New recovery phrase:** \`${ann.mnemonic}\`\n\n` +
|
|
4225
|
+
'**Action required:** save this phrase. If you still have the prior mnemonic, overwrite `mnemonic` in `~/.totalreclaw/credentials.json` and restart the gateway to re-import your previous memories.\n\n' +
|
|
4226
|
+
'Share this block + the warning with the user verbatim.';
|
|
4227
|
+
}
|
|
4228
|
+
// Persist acknowledgement so a process restart doesn't re-emit
|
|
4229
|
+
// the banner. Best-effort; failure only means the banner might
|
|
4230
|
+
// show twice, not data loss.
|
|
4231
|
+
markFirstRunAnnouncementShown(CREDENTIALS_PATH);
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4105
4234
|
// One-time welcome message (first conversation after setup or returning user)
|
|
4106
4235
|
let welcomeBack = '';
|
|
4107
4236
|
if (welcomeBackMessage) {
|
|
@@ -4118,6 +4247,13 @@ const plugin = {
|
|
|
4118
4247
|
welcomeBack = `\n\nTotalReclaw is active. I will automatically remember important things from our conversations and recall relevant context at the start of each session. ${tierInfo}`;
|
|
4119
4248
|
}
|
|
4120
4249
|
|
|
4250
|
+
// If we generated / recovered a recovery phrase this session,
|
|
4251
|
+
// prepend the one-time banner to welcomeBack so every downstream
|
|
4252
|
+
// return site injects it without duplicated plumbing.
|
|
4253
|
+
if (firstRunBanner) {
|
|
4254
|
+
welcomeBack = `\n\n${firstRunBanner}${welcomeBack}`;
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4121
4257
|
// Billing cache check — warn if quota is approaching limit.
|
|
4122
4258
|
let billingWarning = '';
|
|
4123
4259
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@totalreclaw/totalreclaw",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "End-to-end encrypted memory for AI agents — portable, yours forever. Automatic extraction, semantic search, and on-chain storage",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/pin.ts
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
|
-
/** Pin/unpin pure operation for OpenClaw plugin —
|
|
1
|
+
/** Pin/unpin pure operation for OpenClaw plugin — v1.1 taxonomy.
|
|
2
|
+
*
|
|
3
|
+
* As of core 2.1.1 / plugin pin path v1.1 (2026-04-19) the pin/unpin operation
|
|
4
|
+
* emits a canonical v1.1 MemoryClaimV1 JSON blob (schema_version "1.0",
|
|
5
|
+
* `pin_status` additive field) wrapped in the outer protobuf at `version = 4`.
|
|
6
|
+
* The prior behavior — emitting v0 short-key blobs at `version = 3` on the
|
|
7
|
+
* pin path — broke the v1 on-chain contract (RC QA bug #2). v0 blobs continue
|
|
8
|
+
* to be READ correctly (via parseBlobForPin's fall-through), so mixed-version
|
|
9
|
+
* vaults remain uniform from the user's point of view.
|
|
10
|
+
*/
|
|
2
11
|
|
|
3
12
|
import crypto from 'node:crypto';
|
|
4
13
|
import { createRequire } from 'node:module';
|
|
5
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
buildV1ClaimBlob,
|
|
16
|
+
mapTypeToCategory,
|
|
17
|
+
readV1Blob,
|
|
18
|
+
type PinStatus,
|
|
19
|
+
} from './claims-helper.js';
|
|
6
20
|
import {
|
|
7
21
|
findLoserClaimInDecisionLog,
|
|
8
22
|
maybeWriteFeedbackForPin,
|
|
9
23
|
type ContradictionLogger,
|
|
10
24
|
} from './contradiction-sync.js';
|
|
11
|
-
import { isValidMemoryType } from './extractor.js';
|
|
25
|
+
import { isValidMemoryType, V0_TO_V1_TYPE } from './extractor.js';
|
|
26
|
+
import type { MemoryType, MemorySource, MemoryScope, MemoryVolatility } from './extractor.js';
|
|
27
|
+
import { PROTOBUF_VERSION_V4 } from './subgraph-store.js';
|
|
12
28
|
import type { SubgraphSearchFact } from './subgraph-search.js';
|
|
13
29
|
|
|
14
30
|
// Lazy-load WASM core (mirrors claims-helper.ts pattern — plays nicely under
|
|
@@ -34,8 +50,15 @@ export interface FactPayload {
|
|
|
34
50
|
encryptedEmbedding?: string;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Encode a FactPayload as the minimal Protobuf wire format via WASM core.
|
|
55
|
+
*
|
|
56
|
+
* The `version` field is threaded through so callers can opt into
|
|
57
|
+
* `PROTOBUF_VERSION_V4` (Memory Taxonomy v1) for the new-fact write and leave
|
|
58
|
+
* tombstone rows at the default (legacy v3). When omitted, defaults to v1
|
|
59
|
+
* (`PROTOBUF_VERSION_V4`) — pin/unpin is a v1 write path.
|
|
60
|
+
*/
|
|
61
|
+
function encodeFactProtobufLocal(fact: FactPayload, version: number = PROTOBUF_VERSION_V4): Buffer {
|
|
39
62
|
const json = JSON.stringify({
|
|
40
63
|
id: fact.id,
|
|
41
64
|
timestamp: fact.timestamp,
|
|
@@ -47,6 +70,7 @@ function encodeFactProtobufLocal(fact: FactPayload): Buffer {
|
|
|
47
70
|
content_fp: fact.contentFp,
|
|
48
71
|
agent_id: fact.agentId,
|
|
49
72
|
encrypted_embedding: fact.encryptedEmbedding || null,
|
|
73
|
+
version,
|
|
50
74
|
});
|
|
51
75
|
return Buffer.from(getWasm().encodeFactProtobuf(json));
|
|
52
76
|
}
|
|
@@ -73,11 +97,49 @@ const HUMAN_TO_SHORT: Record<HumanStatus, string> = {
|
|
|
73
97
|
|
|
74
98
|
// ─── Blob parsing ─────────────────────────────────────────────────────────────
|
|
75
99
|
|
|
100
|
+
/** Shape of a v1 blob normalized for downstream pin-path rewrite. */
|
|
101
|
+
export interface V1PinBlob {
|
|
102
|
+
kind: 'v1';
|
|
103
|
+
text: string;
|
|
104
|
+
type: MemoryType;
|
|
105
|
+
source: MemorySource;
|
|
106
|
+
scope?: MemoryScope;
|
|
107
|
+
volatility?: MemoryVolatility;
|
|
108
|
+
reasoning?: string;
|
|
109
|
+
entities?: Array<{ name: string; type: string; role?: string }>;
|
|
110
|
+
importance: number;
|
|
111
|
+
confidence: number;
|
|
112
|
+
createdAt: string;
|
|
113
|
+
expiresAt?: string;
|
|
114
|
+
id?: string;
|
|
115
|
+
/** Previously-stored pin_status on the blob (v1.1). */
|
|
116
|
+
pinStatus?: PinStatus;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Shape of a v0 (short-key) blob or a legacy {text, metadata} blob. */
|
|
120
|
+
export interface V0PinBlob {
|
|
121
|
+
kind: 'v0';
|
|
122
|
+
/** The short-key claim object (with t, c, cf, i, sa, ea, ...). */
|
|
123
|
+
claim: Record<string, unknown>;
|
|
124
|
+
}
|
|
125
|
+
|
|
76
126
|
/** Result of parsing a decrypted blob for pin/unpin mutation. */
|
|
77
127
|
export interface ParsedBlob {
|
|
78
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Either a v1 normalized view or a v0 short-key claim object. Pin path
|
|
130
|
+
* always emits v1.1 on output regardless of source — see
|
|
131
|
+
* `executePinOperation`.
|
|
132
|
+
*/
|
|
133
|
+
source: V1PinBlob | V0PinBlob;
|
|
79
134
|
currentStatus: HumanStatus;
|
|
80
135
|
isLegacy: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Legacy field — kept for existing test/downstream code that dereferences
|
|
138
|
+
* `parsed.claim`. For v1 blobs this is a short-key projection (same as
|
|
139
|
+
* pre-v1.1 behavior); for v0 blobs it's the untouched short-key claim.
|
|
140
|
+
* Do NOT mutate when you intend to write — use the `source` view instead.
|
|
141
|
+
*/
|
|
142
|
+
claim: Record<string, unknown>;
|
|
81
143
|
}
|
|
82
144
|
|
|
83
145
|
/** Parse a decrypted blob into a canonical mutable Claim + current human status. */
|
|
@@ -86,26 +148,51 @@ export function parseBlobForPin(decrypted: string): ParsedBlob {
|
|
|
86
148
|
try {
|
|
87
149
|
obj = JSON.parse(decrypted) as Record<string, unknown>;
|
|
88
150
|
} catch {
|
|
151
|
+
const shortClaim = buildCanonicalObjectFromLegacy(decrypted, {});
|
|
89
152
|
return {
|
|
90
|
-
|
|
153
|
+
source: { kind: 'v0', claim: shortClaim },
|
|
154
|
+
claim: shortClaim,
|
|
91
155
|
currentStatus: 'active',
|
|
92
156
|
isLegacy: true,
|
|
93
157
|
};
|
|
94
158
|
}
|
|
95
159
|
|
|
96
160
|
// v1 payload (plugin v3.0.0+): long-form fields + schema_version "1.x".
|
|
97
|
-
//
|
|
98
|
-
// pipeline (st, sup, trapdoor regeneration) keeps working unchanged.
|
|
161
|
+
// Preserve the v1 structure so the pin path can emit v1 on output.
|
|
99
162
|
if (
|
|
100
163
|
typeof obj.text === 'string' &&
|
|
101
164
|
typeof obj.type === 'string' &&
|
|
102
165
|
typeof obj.schema_version === 'string' &&
|
|
103
166
|
obj.schema_version.startsWith('1.')
|
|
104
167
|
) {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
168
|
+
const v1 = readV1Blob(decrypted);
|
|
169
|
+
if (v1) {
|
|
170
|
+
// Current status = pinStatus if present, else active.
|
|
171
|
+
const human: HumanStatus = v1.pinStatus === 'pinned' ? 'pinned' : 'active';
|
|
172
|
+
const shortProjection = v1ToShortKeyClaim(obj);
|
|
173
|
+
return {
|
|
174
|
+
source: {
|
|
175
|
+
kind: 'v1',
|
|
176
|
+
text: v1.text,
|
|
177
|
+
type: v1.type,
|
|
178
|
+
source: v1.source,
|
|
179
|
+
scope: v1.scope,
|
|
180
|
+
volatility: v1.volatility,
|
|
181
|
+
reasoning: v1.reasoning,
|
|
182
|
+
entities: v1.entities,
|
|
183
|
+
importance: v1.importance,
|
|
184
|
+
confidence: v1.confidence,
|
|
185
|
+
createdAt: v1.createdAt,
|
|
186
|
+
expiresAt: v1.expiresAt,
|
|
187
|
+
id: v1.id,
|
|
188
|
+
pinStatus: v1.pinStatus,
|
|
189
|
+
},
|
|
190
|
+
claim: shortProjection,
|
|
191
|
+
currentStatus: human,
|
|
192
|
+
isLegacy: false,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// readV1Blob returned null — fall through to v0 path.
|
|
109
196
|
}
|
|
110
197
|
|
|
111
198
|
// v0 canonical Claim — short keys present.
|
|
@@ -113,21 +200,30 @@ export function parseBlobForPin(decrypted: string): ParsedBlob {
|
|
|
113
200
|
const st = typeof obj.st === 'string' ? obj.st : 'a';
|
|
114
201
|
const human = SHORT_TO_HUMAN[st] ?? 'active';
|
|
115
202
|
const cloned = JSON.parse(JSON.stringify(obj)) as Record<string, unknown>;
|
|
116
|
-
return {
|
|
203
|
+
return {
|
|
204
|
+
source: { kind: 'v0', claim: cloned },
|
|
205
|
+
claim: cloned,
|
|
206
|
+
currentStatus: human,
|
|
207
|
+
isLegacy: false,
|
|
208
|
+
};
|
|
117
209
|
}
|
|
118
210
|
|
|
119
211
|
// Legacy {text, metadata: {importance: 0-1}} shape.
|
|
120
212
|
if (typeof obj.text === 'string') {
|
|
121
213
|
const meta = (obj.metadata as Record<string, unknown>) ?? {};
|
|
214
|
+
const shortClaim = buildCanonicalObjectFromLegacy(obj.text, meta);
|
|
122
215
|
return {
|
|
123
|
-
|
|
216
|
+
source: { kind: 'v0', claim: shortClaim },
|
|
217
|
+
claim: shortClaim,
|
|
124
218
|
currentStatus: 'active',
|
|
125
219
|
isLegacy: true,
|
|
126
220
|
};
|
|
127
221
|
}
|
|
128
222
|
|
|
223
|
+
const shortClaim = buildCanonicalObjectFromLegacy(decrypted, {});
|
|
129
224
|
return {
|
|
130
|
-
|
|
225
|
+
source: { kind: 'v0', claim: shortClaim },
|
|
226
|
+
claim: shortClaim,
|
|
131
227
|
currentStatus: 'active',
|
|
132
228
|
isLegacy: true,
|
|
133
229
|
};
|
|
@@ -205,6 +301,113 @@ function buildCanonicalObjectFromLegacy(
|
|
|
205
301
|
};
|
|
206
302
|
}
|
|
207
303
|
|
|
304
|
+
// ─── v1 projection ────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/** v1 shape used to drive buildV1ClaimBlob from a (v0 or v1) source. */
|
|
307
|
+
interface V1Projection {
|
|
308
|
+
text: string;
|
|
309
|
+
type: MemoryType;
|
|
310
|
+
source: MemorySource;
|
|
311
|
+
scope?: MemoryScope;
|
|
312
|
+
volatility?: MemoryVolatility;
|
|
313
|
+
reasoning?: string;
|
|
314
|
+
entities?: Array<{ name: string; type: string; role?: string }>;
|
|
315
|
+
importance: number;
|
|
316
|
+
confidence: number;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Project a source blob (v1 or v0 short-key) into the v1 shape needed by
|
|
321
|
+
* `buildV1ClaimBlob`. For v1 sources this is identity; for v0 sources we
|
|
322
|
+
* upgrade the category / source fields per the spec's legacy-mapping table
|
|
323
|
+
* (`fact|context|decision → claim`, `rule → directive`, `goal → commitment`,
|
|
324
|
+
* etc.). Anything we can't determine falls back to a sensible default so the
|
|
325
|
+
* build call doesn't throw.
|
|
326
|
+
*/
|
|
327
|
+
function projectToV1(src: V1PinBlob | V0PinBlob, defaultSourceAgent: string): V1Projection {
|
|
328
|
+
if (src.kind === 'v1') {
|
|
329
|
+
return {
|
|
330
|
+
text: src.text,
|
|
331
|
+
type: src.type,
|
|
332
|
+
source: src.source,
|
|
333
|
+
scope: src.scope,
|
|
334
|
+
volatility: src.volatility,
|
|
335
|
+
reasoning: src.reasoning,
|
|
336
|
+
entities: src.entities,
|
|
337
|
+
importance: src.importance,
|
|
338
|
+
confidence: src.confidence,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// v0 path — upgrade short-key claim to v1.
|
|
343
|
+
const claim = src.claim;
|
|
344
|
+
const text = typeof claim.t === 'string' ? claim.t : '';
|
|
345
|
+
const v0Category = typeof claim.c === 'string' ? claim.c : 'fact';
|
|
346
|
+
// Legacy short category keys back to type names (reverse of TYPE_TO_CATEGORY_V0).
|
|
347
|
+
const V0_CATEGORY_TO_V0_TYPE: Record<string, string> = {
|
|
348
|
+
fact: 'fact',
|
|
349
|
+
pref: 'preference',
|
|
350
|
+
dec: 'decision',
|
|
351
|
+
epi: 'episodic',
|
|
352
|
+
goal: 'goal',
|
|
353
|
+
ctx: 'context',
|
|
354
|
+
sum: 'summary',
|
|
355
|
+
rule: 'rule',
|
|
356
|
+
ent: 'fact', // entity records don't round-trip as v1 claims; fall back
|
|
357
|
+
dig: 'summary',
|
|
358
|
+
claim: 'claim',
|
|
359
|
+
};
|
|
360
|
+
const v0TypeToken = V0_CATEGORY_TO_V0_TYPE[v0Category] ?? 'fact';
|
|
361
|
+
// Use the shared v0→v1 map for the upgrade.
|
|
362
|
+
const v1Type: MemoryType = (V0_TO_V1_TYPE as Record<string, MemoryType>)[v0TypeToken] ?? 'claim';
|
|
363
|
+
|
|
364
|
+
const importance = typeof claim.i === 'number'
|
|
365
|
+
? Math.max(1, Math.min(10, Math.round(claim.i as number)))
|
|
366
|
+
: 5;
|
|
367
|
+
const confidence = typeof claim.cf === 'number' ? (claim.cf as number) : 0.85;
|
|
368
|
+
|
|
369
|
+
// v0 `sa` isn't a provenance source — it's a "source agent" string like
|
|
370
|
+
// "openclaw-plugin". Map heuristically: if it looks like an agent-style
|
|
371
|
+
// string (contains "plugin"/"agent"/"derived"), mark it as appropriate;
|
|
372
|
+
// otherwise default to "user-inferred" so Tier 1 reranker doesn't give it
|
|
373
|
+
// "user" trust (which would be wrong for legacy blobs with no provenance
|
|
374
|
+
// signal).
|
|
375
|
+
const sa = typeof claim.sa === 'string' ? claim.sa : defaultSourceAgent;
|
|
376
|
+
let v1Source: MemorySource = 'user-inferred';
|
|
377
|
+
const saLower = sa.toLowerCase();
|
|
378
|
+
if (saLower.includes('derived') || saLower.includes('digest') || saLower.includes('consolidat')) {
|
|
379
|
+
v1Source = 'derived';
|
|
380
|
+
} else if (saLower.includes('assistant')) {
|
|
381
|
+
v1Source = 'assistant';
|
|
382
|
+
} else if (saLower.includes('extern') || saLower.includes('mem0') || saLower.includes('import')) {
|
|
383
|
+
v1Source = 'external';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const entities = Array.isArray(claim.e)
|
|
387
|
+
? (claim.e as unknown[])
|
|
388
|
+
.map((e) => {
|
|
389
|
+
if (!e || typeof e !== 'object') return null;
|
|
390
|
+
const entity = e as Record<string, unknown>;
|
|
391
|
+
const name = typeof entity.n === 'string' ? entity.n : '';
|
|
392
|
+
const entType = typeof entity.tp === 'string' ? entity.tp : 'concept';
|
|
393
|
+
if (!name) return null;
|
|
394
|
+
const out: { name: string; type: string; role?: string } = { name, type: entType };
|
|
395
|
+
if (typeof entity.r === 'string' && entity.r.length > 0) out.role = entity.r;
|
|
396
|
+
return out;
|
|
397
|
+
})
|
|
398
|
+
.filter((x): x is { name: string; type: string; role?: string } => x !== null)
|
|
399
|
+
: undefined;
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
text,
|
|
403
|
+
type: v1Type,
|
|
404
|
+
source: v1Source,
|
|
405
|
+
importance,
|
|
406
|
+
confidence,
|
|
407
|
+
entities,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
208
411
|
// ─── Pure core: executePinOperation ───────────────────────────────────────────
|
|
209
412
|
|
|
210
413
|
/** Injected dependencies for the pin operation (transport + crypto + indexing). */
|
|
@@ -338,30 +541,41 @@ export async function executePinOperation(
|
|
|
338
541
|
};
|
|
339
542
|
}
|
|
340
543
|
|
|
341
|
-
// 4. Build the new canonical claim with
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (typeof newClaimObj.sa !== 'string' || newClaimObj.sa.length === 0) {
|
|
354
|
-
newClaimObj.sa = deps.sourceAgent;
|
|
355
|
-
}
|
|
544
|
+
// 4. Build the new canonical v1.1 claim with pin_status + superseded_by link.
|
|
545
|
+
//
|
|
546
|
+
// The new blob is ALWAYS v1.1 shaped (schema_version "1.0", pin_status
|
|
547
|
+
// present) regardless of the source blob's format. v0 sources are upgraded
|
|
548
|
+
// to v1 on the pin path; v1 sources round-trip their metadata (source,
|
|
549
|
+
// scope, reasoning, entities, volatility) into the new blob.
|
|
550
|
+
const pinStatus: PinStatus = targetStatus === 'pinned' ? 'pinned' : 'unpinned';
|
|
551
|
+
const newFactId = crypto.randomUUID();
|
|
552
|
+
|
|
553
|
+
// Project the source blob into v1 shape. For v0 sources we upgrade on the
|
|
554
|
+
// fly: short-key `c` → v1 type, `sa` → source (heuristic), etc.
|
|
555
|
+
const v1View = projectToV1(parsed.source, deps.sourceAgent);
|
|
356
556
|
|
|
357
557
|
let canonicalJson: string;
|
|
358
558
|
try {
|
|
359
|
-
canonicalJson =
|
|
559
|
+
canonicalJson = buildV1ClaimBlob({
|
|
560
|
+
id: newFactId,
|
|
561
|
+
text: v1View.text,
|
|
562
|
+
type: v1View.type,
|
|
563
|
+
source: v1View.source,
|
|
564
|
+
scope: v1View.scope,
|
|
565
|
+
volatility: v1View.volatility,
|
|
566
|
+
reasoning: v1View.reasoning,
|
|
567
|
+
entities: v1View.entities,
|
|
568
|
+
importance: v1View.importance,
|
|
569
|
+
confidence: v1View.confidence,
|
|
570
|
+
createdAt: new Date().toISOString(),
|
|
571
|
+
supersededBy: factId,
|
|
572
|
+
pinStatus,
|
|
573
|
+
});
|
|
360
574
|
} catch (err) {
|
|
361
575
|
return {
|
|
362
576
|
success: false,
|
|
363
577
|
fact_id: factId,
|
|
364
|
-
error: `Failed to
|
|
578
|
+
error: `Failed to build v1 claim blob: ${err instanceof Error ? err.message : String(err)}`,
|
|
365
579
|
};
|
|
366
580
|
}
|
|
367
581
|
|
|
@@ -378,22 +592,22 @@ export async function executePinOperation(
|
|
|
378
592
|
}
|
|
379
593
|
|
|
380
594
|
// 5b. Regenerate trapdoors so the new fact is findable by the same text.
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
? (parsed.claim.e as unknown[])
|
|
384
|
-
.map((e) => (e && typeof (e as { n?: unknown }).n === 'string' ? (e as { n: string }).n : ''))
|
|
385
|
-
.filter((n): n is string => n.length > 0)
|
|
595
|
+
const entityNames: string[] = v1View.entities
|
|
596
|
+
? v1View.entities.map((e) => e.name).filter((n): n is string => typeof n === 'string' && n.length > 0)
|
|
386
597
|
: [];
|
|
387
598
|
let regenerated: { blindIndices: string[]; encryptedEmbedding?: string };
|
|
388
599
|
try {
|
|
389
|
-
regenerated = await deps.generateIndices(
|
|
600
|
+
regenerated = await deps.generateIndices(v1View.text, entityNames);
|
|
390
601
|
} catch {
|
|
391
602
|
regenerated = { blindIndices: [] };
|
|
392
603
|
}
|
|
393
604
|
|
|
394
605
|
// 6. Build tombstone + new protobuf payloads.
|
|
395
|
-
//
|
|
396
|
-
// empty indices, decayScore=0, source='tombstone'.
|
|
606
|
+
//
|
|
607
|
+
// Tombstone: empty blob ('00'), empty indices, decayScore=0, source='tombstone'.
|
|
608
|
+
// Written at the DEFAULT protobuf version (legacy v3) because tombstone rows
|
|
609
|
+
// carry no inner blob — the version field is irrelevant for readers and
|
|
610
|
+
// writing v3 keeps round-trip compat with any pre-v1 tombstone parser.
|
|
397
611
|
const tombstonePayload: FactPayload = {
|
|
398
612
|
id: factId,
|
|
399
613
|
timestamp: new Date().toISOString(),
|
|
@@ -406,7 +620,6 @@ export async function executePinOperation(
|
|
|
406
620
|
agentId: deps.sourceAgent,
|
|
407
621
|
};
|
|
408
622
|
|
|
409
|
-
const newFactId = crypto.randomUUID();
|
|
410
623
|
const newPayload: FactPayload = {
|
|
411
624
|
id: newFactId,
|
|
412
625
|
timestamp: new Date().toISOString(),
|
|
@@ -420,7 +633,13 @@ export async function executePinOperation(
|
|
|
420
633
|
encryptedEmbedding: regenerated.encryptedEmbedding,
|
|
421
634
|
};
|
|
422
635
|
|
|
423
|
-
|
|
636
|
+
// Outer protobuf version: v=4 for the new v1 claim, default (legacy v3)
|
|
637
|
+
// for the tombstone. This is the core of the bug-2 fix — previously both
|
|
638
|
+
// payloads went out at version=3 and the inner blob was v0 short-key.
|
|
639
|
+
const payloads = [
|
|
640
|
+
encodeFactProtobufLocal(tombstonePayload, /* version = legacy v3 */ 3),
|
|
641
|
+
encodeFactProtobufLocal(newPayload, PROTOBUF_VERSION_V4),
|
|
642
|
+
];
|
|
424
643
|
|
|
425
644
|
// 6b. Slice 2f: consult decisions.jsonl to see if this pin/unpin contradicts
|
|
426
645
|
// a prior auto-resolution. If so, append a counterexample to feedback.jsonl
|