@totalreclaw/totalreclaw 1.6.0 → 3.0.6

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/pin.ts ADDED
@@ -0,0 +1,502 @@
1
+ /** Pin/unpin pure operation for OpenClaw plugin — Slice 2e-plugin, Phase 2. */
2
+
3
+ import crypto from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { mapTypeToCategory } from './claims-helper.js';
6
+ import {
7
+ findLoserClaimInDecisionLog,
8
+ maybeWriteFeedbackForPin,
9
+ type ContradictionLogger,
10
+ } from './contradiction-sync.js';
11
+ import { isValidMemoryType } from './extractor.js';
12
+ import type { SubgraphSearchFact } from './subgraph-search.js';
13
+
14
+ // Lazy-load WASM core (mirrors claims-helper.ts pattern — plays nicely 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: typeof import('@totalreclaw/core') | null = null;
18
+ function getWasm(): typeof import('@totalreclaw/core') {
19
+ if (!_wasm) _wasm = requireWasm('@totalreclaw/core');
20
+ return _wasm!;
21
+ }
22
+
23
+ /** Minimal FactPayload shape — kept local so pin.ts doesn't pull in subgraph-store.ts (which uses plain require() and breaks ESM tests). */
24
+ export interface FactPayload {
25
+ id: string;
26
+ timestamp: string;
27
+ owner: string;
28
+ encryptedBlob: string;
29
+ blindIndices: string[];
30
+ decayScore: number;
31
+ source: string;
32
+ contentFp: string;
33
+ agentId: string;
34
+ encryptedEmbedding?: string;
35
+ }
36
+
37
+ /** Encode a FactPayload as the minimal Protobuf wire format via WASM core. */
38
+ function encodeFactProtobufLocal(fact: FactPayload): Buffer {
39
+ const json = JSON.stringify({
40
+ id: fact.id,
41
+ timestamp: fact.timestamp,
42
+ owner: fact.owner,
43
+ encrypted_blob_hex: fact.encryptedBlob,
44
+ blind_indices: fact.blindIndices,
45
+ decay_score: fact.decayScore,
46
+ source: fact.source,
47
+ content_fp: fact.contentFp,
48
+ agent_id: fact.agentId,
49
+ encrypted_embedding: fact.encryptedEmbedding || null,
50
+ });
51
+ return Buffer.from(getWasm().encodeFactProtobuf(json));
52
+ }
53
+
54
+ // ─── Status types ─────────────────────────────────────────────────────────────
55
+
56
+ export type HumanStatus = 'active' | 'pinned' | 'superseded' | 'retracted' | 'contradicted';
57
+
58
+ const SHORT_TO_HUMAN: Record<string, HumanStatus> = {
59
+ a: 'active',
60
+ p: 'pinned',
61
+ s: 'superseded',
62
+ r: 'retracted',
63
+ c: 'contradicted',
64
+ };
65
+
66
+ const HUMAN_TO_SHORT: Record<HumanStatus, string> = {
67
+ active: 'a',
68
+ pinned: 'p',
69
+ superseded: 's',
70
+ retracted: 'r',
71
+ contradicted: 'c',
72
+ };
73
+
74
+ // ─── Blob parsing ─────────────────────────────────────────────────────────────
75
+
76
+ /** Result of parsing a decrypted blob for pin/unpin mutation. */
77
+ export interface ParsedBlob {
78
+ claim: Record<string, unknown>;
79
+ currentStatus: HumanStatus;
80
+ isLegacy: boolean;
81
+ }
82
+
83
+ /** Parse a decrypted blob into a canonical mutable Claim + current human status. */
84
+ export function parseBlobForPin(decrypted: string): ParsedBlob {
85
+ let obj: Record<string, unknown>;
86
+ try {
87
+ obj = JSON.parse(decrypted) as Record<string, unknown>;
88
+ } catch {
89
+ return {
90
+ claim: buildCanonicalObjectFromLegacy(decrypted, {}),
91
+ currentStatus: 'active',
92
+ isLegacy: true,
93
+ };
94
+ }
95
+
96
+ // v1 payload (plugin v3.0.0+): long-form fields + schema_version "1.x".
97
+ // Convert to the short-key shape pin.ts operates on so the rest of the
98
+ // pipeline (st, sup, trapdoor regeneration) keeps working unchanged.
99
+ if (
100
+ typeof obj.text === 'string' &&
101
+ typeof obj.type === 'string' &&
102
+ typeof obj.schema_version === 'string' &&
103
+ obj.schema_version.startsWith('1.')
104
+ ) {
105
+ const shortObj = v1ToShortKeyClaim(obj);
106
+ const st = typeof shortObj.st === 'string' ? shortObj.st : 'a';
107
+ const human = SHORT_TO_HUMAN[st] ?? 'active';
108
+ return { claim: shortObj, currentStatus: human, isLegacy: false };
109
+ }
110
+
111
+ // v0 canonical Claim — short keys present.
112
+ if (typeof obj.t === 'string' && typeof obj.c === 'string') {
113
+ const st = typeof obj.st === 'string' ? obj.st : 'a';
114
+ const human = SHORT_TO_HUMAN[st] ?? 'active';
115
+ const cloned = JSON.parse(JSON.stringify(obj)) as Record<string, unknown>;
116
+ return { claim: cloned, currentStatus: human, isLegacy: false };
117
+ }
118
+
119
+ // Legacy {text, metadata: {importance: 0-1}} shape.
120
+ if (typeof obj.text === 'string') {
121
+ const meta = (obj.metadata as Record<string, unknown>) ?? {};
122
+ return {
123
+ claim: buildCanonicalObjectFromLegacy(obj.text, meta),
124
+ currentStatus: 'active',
125
+ isLegacy: true,
126
+ };
127
+ }
128
+
129
+ return {
130
+ claim: buildCanonicalObjectFromLegacy(decrypted, {}),
131
+ currentStatus: 'active',
132
+ isLegacy: true,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Convert a Memory Taxonomy v1 blob object into the short-key shape that
138
+ * the rest of pin.ts manipulates. Pin operations tombstone the existing
139
+ * fact and write a fresh one with the short-key format; the v1 inner blob
140
+ * is not round-tripped through pin (that would require upgrading every
141
+ * downstream read site). Since pin already rewrites the fact with new
142
+ * indices, round-trip fidelity isn't required.
143
+ */
144
+ function v1ToShortKeyClaim(v1: Record<string, unknown>): Record<string, unknown> {
145
+ const text = typeof v1.text === 'string' ? v1.text : '';
146
+ const type = typeof v1.type === 'string' ? v1.type : 'claim';
147
+ // Map v1 type to the short category key used by the v0 format.
148
+ const category = isValidMemoryType(type) ? mapTypeToCategory(type) : 'fact';
149
+ const impNum = typeof v1.importance === 'number' ? v1.importance : 5;
150
+ const importance = Math.max(1, Math.min(10, Math.round(impNum)));
151
+ const confidence = typeof v1.confidence === 'number' ? v1.confidence : 0.85;
152
+ const source = typeof v1.source === 'string' ? v1.source : 'openclaw-plugin';
153
+ const createdAt = typeof v1.created_at === 'string' ? v1.created_at : new Date().toISOString();
154
+
155
+ const out: Record<string, unknown> = {
156
+ t: text,
157
+ c: category,
158
+ cf: confidence,
159
+ i: importance,
160
+ sa: source,
161
+ ea: createdAt,
162
+ };
163
+
164
+ if (Array.isArray(v1.entities) && v1.entities.length > 0) {
165
+ out.e = (v1.entities as unknown[])
166
+ .map((e) => {
167
+ if (!e || typeof e !== 'object') return null;
168
+ const entity = e as Record<string, unknown>;
169
+ const name = typeof entity.name === 'string' ? entity.name : '';
170
+ const entType = typeof entity.type === 'string' ? entity.type : 'concept';
171
+ if (!name) return null;
172
+ const short: Record<string, unknown> = { n: name, tp: entType };
173
+ if (typeof entity.role === 'string' && entity.role.length > 0) {
174
+ short.r = entity.role;
175
+ }
176
+ return short;
177
+ })
178
+ .filter((e): e is Record<string, unknown> => e !== null);
179
+ }
180
+
181
+ return out;
182
+ }
183
+
184
+ function buildCanonicalObjectFromLegacy(
185
+ text: string,
186
+ meta: Record<string, unknown>,
187
+ ): Record<string, unknown> {
188
+ // Phase 2.2.6: use the single-source-of-truth mapping from claims-helper
189
+ // instead of a local duplicate. Legacy blobs can carry arbitrary strings in
190
+ // `metadata.type`, so we validate via `isValidMemoryType` before mapping —
191
+ // unknown types fall back to 'fact'.
192
+ const typeStr = typeof meta.type === 'string' ? meta.type : 'fact';
193
+ const category = isValidMemoryType(typeStr) ? mapTypeToCategory(typeStr) : 'fact';
194
+ const impFloat = typeof meta.importance === 'number' ? meta.importance : 0.5;
195
+ const importance = Math.max(1, Math.min(10, Math.round(impFloat * 10)));
196
+ const source = typeof meta.source === 'string' ? meta.source : 'openclaw-plugin';
197
+ const createdAt = typeof meta.created_at === 'string' ? meta.created_at : new Date().toISOString();
198
+ return {
199
+ t: text,
200
+ c: category,
201
+ cf: 0.85,
202
+ i: importance,
203
+ sa: source,
204
+ ea: createdAt,
205
+ };
206
+ }
207
+
208
+ // ─── Pure core: executePinOperation ───────────────────────────────────────────
209
+
210
+ /** Injected dependencies for the pin operation (transport + crypto + indexing). */
211
+ export interface PinOpDeps {
212
+ owner: string;
213
+ sourceAgent: string;
214
+ fetchFactById: (factId: string) => Promise<SubgraphSearchFact | null>;
215
+ decryptBlob: (hexEncryptedBlob: string) => string;
216
+ encryptBlob: (plaintext: string) => string; // returns hex
217
+ submitBatch: (protobufPayloads: Buffer[]) => Promise<{ txHash: string; success: boolean }>;
218
+ /**
219
+ * Regenerate blind indices + encrypted embedding for the pinned claim text.
220
+ * The new fact must have its own trapdoors pointing to its content so
221
+ * trapdoor-based recall keeps finding it after the old fact is tombstoned.
222
+ * Returns empty indices / undefined embedding on failure — caller tolerates.
223
+ */
224
+ generateIndices: (text: string, entityNames: string[]) => Promise<{
225
+ blindIndices: string[];
226
+ encryptedEmbedding?: string;
227
+ }>;
228
+ /**
229
+ * Optional logger used by the Slice 2f feedback wiring. When omitted, a
230
+ * silent logger is used so existing callers remain unchanged.
231
+ */
232
+ logger?: ContradictionLogger;
233
+ }
234
+
235
+ export interface PinOpResult {
236
+ success: boolean;
237
+ fact_id: string;
238
+ new_fact_id?: string;
239
+ previous_status?: HumanStatus;
240
+ new_status?: HumanStatus;
241
+ idempotent?: boolean;
242
+ tx_hash?: string;
243
+ reason?: string;
244
+ error?: string;
245
+ }
246
+
247
+ /**
248
+ * Execute a pin or unpin operation on a single fact.
249
+ *
250
+ * The subgraph is append-only, so a status change requires writing a new fact
251
+ * with the updated status and tombstoning the old one. The new fact's `sup`
252
+ * field points to the old fact id, forming a cross-device-visible supersession
253
+ * chain. Matches MCP's `executePinOperation` byte-for-byte on the supersession
254
+ * semantics (short keys, idempotent no-op, decayScore=1.0, trapdoor regen).
255
+ */
256
+ export async function executePinOperation(
257
+ factId: string,
258
+ targetStatus: 'pinned' | 'active',
259
+ deps: PinOpDeps,
260
+ reason?: string,
261
+ ): Promise<PinOpResult> {
262
+ // 1. Fetch the existing fact
263
+ const existing = await deps.fetchFactById(factId);
264
+ if (!existing) {
265
+ return {
266
+ success: false,
267
+ fact_id: factId,
268
+ error: `Fact not found: ${factId}`,
269
+ };
270
+ }
271
+
272
+ // 2. Decrypt + parse current status
273
+ const blobHex = existing.encryptedBlob.startsWith('0x')
274
+ ? existing.encryptedBlob.slice(2)
275
+ : existing.encryptedBlob;
276
+ let plaintext: string;
277
+ let recoveredFromDecisionLog = false;
278
+ try {
279
+ plaintext = deps.decryptBlob(blobHex);
280
+ } catch (err) {
281
+ // Phase 2.1 recovery path: if the on-chain blob is a tombstone (1-byte
282
+ // `0x00` written by an auto-resolved supersede), the cipher will fail
283
+ // because the ciphertext is shorter than the auth tag. Fall back to the
284
+ // canonical Claim JSON we stashed in `decisions.jsonl` at supersede time.
285
+ // Without this fallback, the user can never override an auto-resolution
286
+ // and the weight-tuning loop never receives gradient signal.
287
+ const errMsg = err instanceof Error ? err.message : String(err);
288
+ const looksLikeTombstone =
289
+ blobHex === '00' ||
290
+ blobHex === '' ||
291
+ errMsg.includes('Encrypted data too short') ||
292
+ errMsg.includes('too short') ||
293
+ errMsg.includes('Cipher');
294
+ if (!looksLikeTombstone) {
295
+ return {
296
+ success: false,
297
+ fact_id: factId,
298
+ error: `Failed to decrypt fact: ${errMsg}`,
299
+ };
300
+ }
301
+ const recovered = findLoserClaimInDecisionLog(factId);
302
+ if (!recovered) {
303
+ return {
304
+ success: false,
305
+ fact_id: factId,
306
+ error:
307
+ `Failed to decrypt fact and no recovery row in decisions.jsonl: ${errMsg}. ` +
308
+ 'The fact may have been tombstoned by an auto-resolution that predates Phase 2.1 ' +
309
+ '(when loser_claim_json was added to the decision log).',
310
+ };
311
+ }
312
+ plaintext = recovered;
313
+ recoveredFromDecisionLog = true;
314
+ deps.logger?.info?.(
315
+ `pin: recovered loser claim from decisions.jsonl for ${factId.slice(0, 10)}…`,
316
+ );
317
+ }
318
+
319
+ const parsed = parseBlobForPin(plaintext);
320
+ // Recovered claims always represent a fact the user is trying to override —
321
+ // never short-circuit the operation as idempotent because the `st` field on
322
+ // the recovered loser was whatever the original auto-resolution stored
323
+ // (typically active). Drop the previous status so the targetStatus check
324
+ // below produces a real on-chain write.
325
+ if (recoveredFromDecisionLog) {
326
+ parsed.currentStatus = 'active';
327
+ }
328
+
329
+ // 3. Idempotent early-exit
330
+ if (parsed.currentStatus === targetStatus) {
331
+ return {
332
+ success: true,
333
+ fact_id: factId,
334
+ previous_status: parsed.currentStatus,
335
+ new_status: targetStatus,
336
+ idempotent: true,
337
+ reason,
338
+ };
339
+ }
340
+
341
+ // 4. Build the new canonical claim with updated status + supersedes link
342
+ const newClaimObj = { ...parsed.claim };
343
+ if (targetStatus === 'active') {
344
+ // Active is the canonical default — omit `st` entirely.
345
+ delete newClaimObj.st;
346
+ } else {
347
+ newClaimObj.st = HUMAN_TO_SHORT[targetStatus];
348
+ }
349
+ newClaimObj.sup = factId;
350
+ // Refresh extraction timestamp so downstream consumers can tell this is a new event.
351
+ newClaimObj.ea = new Date().toISOString();
352
+ // Carry source agent forward if present, else stamp it.
353
+ if (typeof newClaimObj.sa !== 'string' || newClaimObj.sa.length === 0) {
354
+ newClaimObj.sa = deps.sourceAgent;
355
+ }
356
+
357
+ let canonicalJson: string;
358
+ try {
359
+ canonicalJson = getWasm().canonicalizeClaim(JSON.stringify(newClaimObj));
360
+ } catch (err) {
361
+ return {
362
+ success: false,
363
+ fact_id: factId,
364
+ error: `Failed to canonicalize updated claim: ${err instanceof Error ? err.message : String(err)}`,
365
+ };
366
+ }
367
+
368
+ // 5. Encrypt the new blob
369
+ let newBlobHex: string;
370
+ try {
371
+ newBlobHex = deps.encryptBlob(canonicalJson);
372
+ } catch (err) {
373
+ return {
374
+ success: false,
375
+ fact_id: factId,
376
+ error: `Failed to encrypt updated claim: ${err instanceof Error ? err.message : String(err)}`,
377
+ };
378
+ }
379
+
380
+ // 5b. Regenerate trapdoors so the new fact is findable by the same text.
381
+ const newClaimText = typeof parsed.claim.t === 'string' ? parsed.claim.t : '';
382
+ const entityNames: string[] = Array.isArray(parsed.claim.e)
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)
386
+ : [];
387
+ let regenerated: { blindIndices: string[]; encryptedEmbedding?: string };
388
+ try {
389
+ regenerated = await deps.generateIndices(newClaimText, entityNames);
390
+ } catch {
391
+ regenerated = { blindIndices: [] };
392
+ }
393
+
394
+ // 6. Build tombstone + new protobuf payloads.
395
+ // Plugin tombstone convention matches the rest of the plugin: `encryptedBlob: '00'`,
396
+ // empty indices, decayScore=0, source='tombstone'.
397
+ const tombstonePayload: FactPayload = {
398
+ id: factId,
399
+ timestamp: new Date().toISOString(),
400
+ owner: deps.owner,
401
+ encryptedBlob: '00',
402
+ blindIndices: [],
403
+ decayScore: 0,
404
+ source: 'tombstone',
405
+ contentFp: '',
406
+ agentId: deps.sourceAgent,
407
+ };
408
+
409
+ const newFactId = crypto.randomUUID();
410
+ const newPayload: FactPayload = {
411
+ id: newFactId,
412
+ timestamp: new Date().toISOString(),
413
+ owner: deps.owner,
414
+ encryptedBlob: newBlobHex,
415
+ blindIndices: regenerated.blindIndices,
416
+ decayScore: 1.0,
417
+ source: targetStatus === 'pinned' ? 'openclaw-plugin-pin' : 'openclaw-plugin-unpin',
418
+ contentFp: '',
419
+ agentId: deps.sourceAgent,
420
+ encryptedEmbedding: regenerated.encryptedEmbedding,
421
+ };
422
+
423
+ const payloads = [encodeFactProtobufLocal(tombstonePayload), encodeFactProtobufLocal(newPayload)];
424
+
425
+ // 6b. Slice 2f: consult decisions.jsonl to see if this pin/unpin contradicts
426
+ // a prior auto-resolution. If so, append a counterexample to feedback.jsonl
427
+ // so the next digest-compile's tuning loop can nudge the weights. Voluntary
428
+ // pins (no matching decision) produce no feedback row. Never fatal.
429
+ const feedbackLogger: ContradictionLogger = deps.logger ?? {
430
+ info: () => {},
431
+ warn: () => {},
432
+ };
433
+ try {
434
+ await maybeWriteFeedbackForPin(
435
+ factId,
436
+ targetStatus,
437
+ Math.floor(Date.now() / 1000),
438
+ feedbackLogger,
439
+ );
440
+ } catch {
441
+ // Feedback wiring is best-effort — never block the pin op.
442
+ }
443
+
444
+ // 7. Submit both in a single batch UserOp.
445
+ try {
446
+ const { txHash, success } = await deps.submitBatch(payloads);
447
+ if (!success) {
448
+ return {
449
+ success: false,
450
+ fact_id: factId,
451
+ previous_status: parsed.currentStatus,
452
+ error: 'On-chain batch submission failed',
453
+ tx_hash: txHash,
454
+ };
455
+ }
456
+ return {
457
+ success: true,
458
+ fact_id: factId,
459
+ new_fact_id: newFactId,
460
+ previous_status: parsed.currentStatus,
461
+ new_status: targetStatus,
462
+ tx_hash: txHash,
463
+ reason,
464
+ };
465
+ } catch (err) {
466
+ return {
467
+ success: false,
468
+ fact_id: factId,
469
+ previous_status: parsed.currentStatus,
470
+ error: `Failed to submit pin batch: ${err instanceof Error ? err.message : String(err)}`,
471
+ };
472
+ }
473
+ }
474
+
475
+ // ─── Input validation ─────────────────────────────────────────────────────────
476
+
477
+ export interface PinArgsValid {
478
+ ok: boolean;
479
+ factId: string;
480
+ reason?: string;
481
+ error: string;
482
+ }
483
+
484
+ /** Validate the `{fact_id, reason?}` input shape for pin/unpin tool calls. */
485
+ export function validatePinArgs(args: unknown): PinArgsValid {
486
+ if (!args || typeof args !== 'object') {
487
+ return { ok: false, factId: '', error: 'Invalid input: fact_id is required' };
488
+ }
489
+ const record = args as Record<string, unknown>;
490
+ const factId = record.fact_id;
491
+ if (factId === undefined || factId === null) {
492
+ return { ok: false, factId: '', error: 'Invalid input: fact_id is required' };
493
+ }
494
+ if (typeof factId !== 'string') {
495
+ return { ok: false, factId: '', error: 'Invalid input: fact_id must be a non-empty string' };
496
+ }
497
+ if (factId.trim().length === 0) {
498
+ return { ok: false, factId: '', error: 'Invalid input: fact_id must be a non-empty string' };
499
+ }
500
+ const reason = typeof record.reason === 'string' ? record.reason : undefined;
501
+ return { ok: true, factId: factId.trim(), reason, error: '' };
502
+ }