@totalreclaw/totalreclaw 3.3.1-rc.8 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +268 -1
  2. package/SKILL.md +29 -23
  3. package/api-client.ts +18 -11
  4. package/claims-helper.ts +47 -1
  5. package/config.ts +108 -4
  6. package/confirm-indexed.ts +191 -0
  7. package/crypto.ts +10 -2
  8. package/dist/api-client.js +226 -0
  9. package/dist/billing-cache.js +100 -0
  10. package/dist/claims-helper.js +624 -0
  11. package/dist/config.js +297 -0
  12. package/dist/confirm-indexed.js +127 -0
  13. package/dist/consolidation.js +258 -0
  14. package/dist/contradiction-sync.js +1034 -0
  15. package/dist/crypto.js +138 -0
  16. package/dist/digest-sync.js +361 -0
  17. package/dist/download-ux.js +63 -0
  18. package/dist/embedder-cache.js +185 -0
  19. package/dist/embedder-loader.js +121 -0
  20. package/dist/embedder-network.js +301 -0
  21. package/dist/embedding.js +141 -0
  22. package/dist/extractor.js +1225 -0
  23. package/dist/first-run.js +103 -0
  24. package/dist/fs-helpers.js +725 -0
  25. package/dist/gateway-url.js +197 -0
  26. package/dist/generate-mnemonic.js +13 -0
  27. package/dist/hot-cache-wrapper.js +101 -0
  28. package/dist/import-adapters/base-adapter.js +64 -0
  29. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  30. package/dist/import-adapters/claude-adapter.js +114 -0
  31. package/dist/import-adapters/gemini-adapter.js +201 -0
  32. package/dist/import-adapters/index.js +26 -0
  33. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  34. package/dist/import-adapters/mem0-adapter.js +158 -0
  35. package/dist/import-adapters/types.js +1 -0
  36. package/dist/index.js +5388 -0
  37. package/dist/llm-client.js +687 -0
  38. package/dist/llm-profile-reader.js +346 -0
  39. package/dist/lsh.js +62 -0
  40. package/dist/onboarding-cli.js +750 -0
  41. package/dist/pair-cli.js +344 -0
  42. package/dist/pair-crypto.js +359 -0
  43. package/dist/pair-http.js +404 -0
  44. package/dist/pair-page.js +826 -0
  45. package/dist/pair-qr.js +107 -0
  46. package/dist/pair-remote-client.js +410 -0
  47. package/dist/pair-session-store.js +566 -0
  48. package/dist/pin.js +556 -0
  49. package/dist/qa-bug-report.js +301 -0
  50. package/dist/relay-headers.js +44 -0
  51. package/dist/reranker.js +409 -0
  52. package/dist/retype-setscope.js +368 -0
  53. package/dist/semantic-dedup.js +75 -0
  54. package/dist/subgraph-search.js +289 -0
  55. package/dist/subgraph-store.js +694 -0
  56. package/dist/tool-gating.js +58 -0
  57. package/download-ux.ts +91 -0
  58. package/embedder-cache.ts +230 -0
  59. package/embedder-loader.ts +189 -0
  60. package/embedder-network.ts +350 -0
  61. package/embedding.ts +118 -27
  62. package/fs-helpers.ts +277 -0
  63. package/gateway-url.ts +57 -9
  64. package/index.ts +469 -250
  65. package/llm-client.ts +4 -3
  66. package/lsh.ts +7 -2
  67. package/onboarding-cli.ts +114 -1
  68. package/package.json +24 -5
  69. package/pair-cli.ts +76 -8
  70. package/pair-crypto.ts +34 -24
  71. package/pair-page.ts +28 -17
  72. package/pair-qr.ts +152 -0
  73. package/pair-remote-client.ts +540 -0
  74. package/pin.ts +31 -0
  75. package/qa-bug-report.ts +84 -2
  76. package/relay-headers.ts +50 -0
  77. package/reranker.ts +40 -0
  78. package/retype-setscope.ts +69 -8
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +4 -3
  81. package/subgraph-store.ts +15 -10
package/dist/pin.js ADDED
@@ -0,0 +1,556 @@
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
+ */
11
+ import crypto from 'node:crypto';
12
+ import { createRequire } from 'node:module';
13
+ import { buildV1ClaimBlob, mapTypeToCategory, readV1Blob, } from './claims-helper.js';
14
+ import { findLoserClaimInDecisionLog, maybeWriteFeedbackForPin, } from './contradiction-sync.js';
15
+ import { isValidMemoryType, V0_TO_V1_TYPE } from './extractor.js';
16
+ import { PROTOBUF_VERSION_V4 } from './subgraph-store.js';
17
+ import { confirmIndexed } from './confirm-indexed.js';
18
+ // Lazy-load WASM core (mirrors claims-helper.ts pattern — plays nicely under
19
+ // both the OpenClaw runtime (CJS-ish tsx) and bare Node ESM used by tests).
20
+ const requireWasm = createRequire(import.meta.url);
21
+ let _wasm = null;
22
+ function getWasm() {
23
+ if (!_wasm)
24
+ _wasm = requireWasm('@totalreclaw/core');
25
+ return _wasm;
26
+ }
27
+ /**
28
+ * Encode a FactPayload as the minimal Protobuf wire format via WASM core.
29
+ *
30
+ * The `version` field is threaded through so callers can opt into
31
+ * `PROTOBUF_VERSION_V4` (Memory Taxonomy v1) for the new-fact write and leave
32
+ * tombstone rows at the default (legacy v3). When omitted, defaults to v1
33
+ * (`PROTOBUF_VERSION_V4`) — pin/unpin is a v1 write path.
34
+ */
35
+ function encodeFactProtobufLocal(fact, version = PROTOBUF_VERSION_V4) {
36
+ const json = JSON.stringify({
37
+ id: fact.id,
38
+ timestamp: fact.timestamp,
39
+ owner: fact.owner,
40
+ encrypted_blob_hex: fact.encryptedBlob,
41
+ blind_indices: fact.blindIndices,
42
+ decay_score: fact.decayScore,
43
+ source: fact.source,
44
+ content_fp: fact.contentFp,
45
+ agent_id: fact.agentId,
46
+ encrypted_embedding: fact.encryptedEmbedding || null,
47
+ version,
48
+ });
49
+ return Buffer.from(getWasm().encodeFactProtobuf(json));
50
+ }
51
+ const SHORT_TO_HUMAN = {
52
+ a: 'active',
53
+ p: 'pinned',
54
+ s: 'superseded',
55
+ r: 'retracted',
56
+ c: 'contradicted',
57
+ };
58
+ const HUMAN_TO_SHORT = {
59
+ active: 'a',
60
+ pinned: 'p',
61
+ superseded: 's',
62
+ retracted: 'r',
63
+ contradicted: 'c',
64
+ };
65
+ /** Parse a decrypted blob into a canonical mutable Claim + current human status. */
66
+ export function parseBlobForPin(decrypted) {
67
+ let obj;
68
+ try {
69
+ obj = JSON.parse(decrypted);
70
+ }
71
+ catch {
72
+ const shortClaim = buildCanonicalObjectFromLegacy(decrypted, {});
73
+ return {
74
+ source: { kind: 'v0', claim: shortClaim },
75
+ claim: shortClaim,
76
+ currentStatus: 'active',
77
+ isLegacy: true,
78
+ };
79
+ }
80
+ // v1 payload (plugin v3.0.0+): long-form fields + schema_version "1.x".
81
+ // Preserve the v1 structure so the pin path can emit v1 on output.
82
+ if (typeof obj.text === 'string' &&
83
+ typeof obj.type === 'string' &&
84
+ typeof obj.schema_version === 'string' &&
85
+ obj.schema_version.startsWith('1.')) {
86
+ const v1 = readV1Blob(decrypted);
87
+ if (v1) {
88
+ // Current status = pinStatus if present, else active.
89
+ const human = v1.pinStatus === 'pinned' ? 'pinned' : 'active';
90
+ const shortProjection = v1ToShortKeyClaim(obj);
91
+ return {
92
+ source: {
93
+ kind: 'v1',
94
+ text: v1.text,
95
+ type: v1.type,
96
+ source: v1.source,
97
+ scope: v1.scope,
98
+ volatility: v1.volatility,
99
+ reasoning: v1.reasoning,
100
+ entities: v1.entities,
101
+ importance: v1.importance,
102
+ confidence: v1.confidence,
103
+ createdAt: v1.createdAt,
104
+ expiresAt: v1.expiresAt,
105
+ id: v1.id,
106
+ pinStatus: v1.pinStatus,
107
+ embeddingModelId: v1.embeddingModelId,
108
+ },
109
+ claim: shortProjection,
110
+ currentStatus: human,
111
+ isLegacy: false,
112
+ };
113
+ }
114
+ // readV1Blob returned null — fall through to v0 path.
115
+ }
116
+ // v0 canonical Claim — short keys present.
117
+ if (typeof obj.t === 'string' && typeof obj.c === 'string') {
118
+ const st = typeof obj.st === 'string' ? obj.st : 'a';
119
+ const human = SHORT_TO_HUMAN[st] ?? 'active';
120
+ const cloned = JSON.parse(JSON.stringify(obj));
121
+ return {
122
+ source: { kind: 'v0', claim: cloned },
123
+ claim: cloned,
124
+ currentStatus: human,
125
+ isLegacy: false,
126
+ };
127
+ }
128
+ // Legacy {text, metadata: {importance: 0-1}} shape.
129
+ if (typeof obj.text === 'string') {
130
+ const meta = obj.metadata ?? {};
131
+ const shortClaim = buildCanonicalObjectFromLegacy(obj.text, meta);
132
+ return {
133
+ source: { kind: 'v0', claim: shortClaim },
134
+ claim: shortClaim,
135
+ currentStatus: 'active',
136
+ isLegacy: true,
137
+ };
138
+ }
139
+ const shortClaim = buildCanonicalObjectFromLegacy(decrypted, {});
140
+ return {
141
+ source: { kind: 'v0', claim: shortClaim },
142
+ claim: shortClaim,
143
+ currentStatus: 'active',
144
+ isLegacy: true,
145
+ };
146
+ }
147
+ /**
148
+ * Convert a Memory Taxonomy v1 blob object into the short-key shape that
149
+ * the rest of pin.ts manipulates. Pin operations tombstone the existing
150
+ * fact and write a fresh one with the short-key format; the v1 inner blob
151
+ * is not round-tripped through pin (that would require upgrading every
152
+ * downstream read site). Since pin already rewrites the fact with new
153
+ * indices, round-trip fidelity isn't required.
154
+ */
155
+ function v1ToShortKeyClaim(v1) {
156
+ const text = typeof v1.text === 'string' ? v1.text : '';
157
+ const type = typeof v1.type === 'string' ? v1.type : 'claim';
158
+ // Map v1 type to the short category key used by the v0 format.
159
+ const category = isValidMemoryType(type) ? mapTypeToCategory(type) : 'fact';
160
+ const impNum = typeof v1.importance === 'number' ? v1.importance : 5;
161
+ const importance = Math.max(1, Math.min(10, Math.round(impNum)));
162
+ const confidence = typeof v1.confidence === 'number' ? v1.confidence : 0.85;
163
+ const source = typeof v1.source === 'string' ? v1.source : 'openclaw-plugin';
164
+ const createdAt = typeof v1.created_at === 'string' ? v1.created_at : new Date().toISOString();
165
+ const out = {
166
+ t: text,
167
+ c: category,
168
+ cf: confidence,
169
+ i: importance,
170
+ sa: source,
171
+ ea: createdAt,
172
+ };
173
+ if (Array.isArray(v1.entities) && v1.entities.length > 0) {
174
+ out.e = v1.entities
175
+ .map((e) => {
176
+ if (!e || typeof e !== 'object')
177
+ return null;
178
+ const entity = e;
179
+ const name = typeof entity.name === 'string' ? entity.name : '';
180
+ const entType = typeof entity.type === 'string' ? entity.type : 'concept';
181
+ if (!name)
182
+ return null;
183
+ const short = { n: name, tp: entType };
184
+ if (typeof entity.role === 'string' && entity.role.length > 0) {
185
+ short.r = entity.role;
186
+ }
187
+ return short;
188
+ })
189
+ .filter((e) => e !== null);
190
+ }
191
+ return out;
192
+ }
193
+ function buildCanonicalObjectFromLegacy(text, meta) {
194
+ // Phase 2.2.6: use the single-source-of-truth mapping from claims-helper
195
+ // instead of a local duplicate. Legacy blobs can carry arbitrary strings in
196
+ // `metadata.type`, so we validate via `isValidMemoryType` before mapping —
197
+ // unknown types fall back to 'fact'.
198
+ const typeStr = typeof meta.type === 'string' ? meta.type : 'fact';
199
+ const category = isValidMemoryType(typeStr) ? mapTypeToCategory(typeStr) : 'fact';
200
+ const impFloat = typeof meta.importance === 'number' ? meta.importance : 0.5;
201
+ const importance = Math.max(1, Math.min(10, Math.round(impFloat * 10)));
202
+ const source = typeof meta.source === 'string' ? meta.source : 'openclaw-plugin';
203
+ const createdAt = typeof meta.created_at === 'string' ? meta.created_at : new Date().toISOString();
204
+ return {
205
+ t: text,
206
+ c: category,
207
+ cf: 0.85,
208
+ i: importance,
209
+ sa: source,
210
+ ea: createdAt,
211
+ };
212
+ }
213
+ /**
214
+ * Project a source blob (v1 or v0 short-key) into the v1 shape needed by
215
+ * `buildV1ClaimBlob`. For v1 sources this is identity; for v0 sources we
216
+ * upgrade the category / source fields per the spec's legacy-mapping table
217
+ * (`fact|context|decision → claim`, `rule → directive`, `goal → commitment`,
218
+ * etc.). Anything we can't determine falls back to a sensible default so the
219
+ * build call doesn't throw.
220
+ */
221
+ function projectToV1(src, defaultSourceAgent) {
222
+ if (src.kind === 'v1') {
223
+ return {
224
+ text: src.text,
225
+ type: src.type,
226
+ source: src.source,
227
+ scope: src.scope,
228
+ volatility: src.volatility,
229
+ reasoning: src.reasoning,
230
+ entities: src.entities,
231
+ importance: src.importance,
232
+ confidence: src.confidence,
233
+ embeddingModelId: src.embeddingModelId,
234
+ };
235
+ }
236
+ // v0 path — upgrade short-key claim to v1.
237
+ const claim = src.claim;
238
+ const text = typeof claim.t === 'string' ? claim.t : '';
239
+ const v0Category = typeof claim.c === 'string' ? claim.c : 'fact';
240
+ // Legacy short category keys back to type names (reverse of TYPE_TO_CATEGORY_V0).
241
+ const V0_CATEGORY_TO_V0_TYPE = {
242
+ fact: 'fact',
243
+ pref: 'preference',
244
+ dec: 'decision',
245
+ epi: 'episodic',
246
+ goal: 'goal',
247
+ ctx: 'context',
248
+ sum: 'summary',
249
+ rule: 'rule',
250
+ ent: 'fact', // entity records don't round-trip as v1 claims; fall back
251
+ dig: 'summary',
252
+ claim: 'claim',
253
+ };
254
+ const v0TypeToken = V0_CATEGORY_TO_V0_TYPE[v0Category] ?? 'fact';
255
+ // Use the shared v0→v1 map for the upgrade.
256
+ const v1Type = V0_TO_V1_TYPE[v0TypeToken] ?? 'claim';
257
+ const importance = typeof claim.i === 'number'
258
+ ? Math.max(1, Math.min(10, Math.round(claim.i)))
259
+ : 5;
260
+ const confidence = typeof claim.cf === 'number' ? claim.cf : 0.85;
261
+ // v0 `sa` isn't a provenance source — it's a "source agent" string like
262
+ // "openclaw-plugin". Map heuristically: if it looks like an agent-style
263
+ // string (contains "plugin"/"agent"/"derived"), mark it as appropriate;
264
+ // otherwise default to "user-inferred" so Tier 1 reranker doesn't give it
265
+ // "user" trust (which would be wrong for legacy blobs with no provenance
266
+ // signal).
267
+ const sa = typeof claim.sa === 'string' ? claim.sa : defaultSourceAgent;
268
+ let v1Source = 'user-inferred';
269
+ const saLower = sa.toLowerCase();
270
+ if (saLower.includes('derived') || saLower.includes('digest') || saLower.includes('consolidat')) {
271
+ v1Source = 'derived';
272
+ }
273
+ else if (saLower.includes('assistant')) {
274
+ v1Source = 'assistant';
275
+ }
276
+ else if (saLower.includes('extern') || saLower.includes('mem0') || saLower.includes('import')) {
277
+ v1Source = 'external';
278
+ }
279
+ const entities = Array.isArray(claim.e)
280
+ ? claim.e
281
+ .map((e) => {
282
+ if (!e || typeof e !== 'object')
283
+ return null;
284
+ const entity = e;
285
+ const name = typeof entity.n === 'string' ? entity.n : '';
286
+ const entType = typeof entity.tp === 'string' ? entity.tp : 'concept';
287
+ if (!name)
288
+ return null;
289
+ const out = { name, type: entType };
290
+ if (typeof entity.r === 'string' && entity.r.length > 0)
291
+ out.role = entity.r;
292
+ return out;
293
+ })
294
+ .filter((x) => x !== null)
295
+ : undefined;
296
+ return {
297
+ text,
298
+ type: v1Type,
299
+ source: v1Source,
300
+ importance,
301
+ confidence,
302
+ entities,
303
+ };
304
+ }
305
+ /**
306
+ * Execute a pin or unpin operation on a single fact.
307
+ *
308
+ * The subgraph is append-only, so a status change requires writing a new fact
309
+ * with the updated status and tombstoning the old one. The new fact's `sup`
310
+ * field points to the old fact id, forming a cross-device-visible supersession
311
+ * chain. Matches MCP's `executePinOperation` byte-for-byte on the supersession
312
+ * semantics (short keys, idempotent no-op, decayScore=1.0, trapdoor regen).
313
+ */
314
+ export async function executePinOperation(factId, targetStatus, deps, reason, confirmOpts) {
315
+ // 1. Fetch the existing fact
316
+ const existing = await deps.fetchFactById(factId);
317
+ if (!existing) {
318
+ return {
319
+ success: false,
320
+ fact_id: factId,
321
+ error: `Fact not found: ${factId}`,
322
+ };
323
+ }
324
+ // 2. Decrypt + parse current status
325
+ const blobHex = existing.encryptedBlob.startsWith('0x')
326
+ ? existing.encryptedBlob.slice(2)
327
+ : existing.encryptedBlob;
328
+ let plaintext;
329
+ let recoveredFromDecisionLog = false;
330
+ try {
331
+ plaintext = deps.decryptBlob(blobHex);
332
+ }
333
+ catch (err) {
334
+ // Phase 2.1 recovery path: if the on-chain blob is a tombstone (1-byte
335
+ // `0x00` written by an auto-resolved supersede), the cipher will fail
336
+ // because the ciphertext is shorter than the auth tag. Fall back to the
337
+ // canonical Claim JSON we stashed in `decisions.jsonl` at supersede time.
338
+ // Without this fallback, the user can never override an auto-resolution
339
+ // and the weight-tuning loop never receives gradient signal.
340
+ const errMsg = err instanceof Error ? err.message : String(err);
341
+ const looksLikeTombstone = blobHex === '00' ||
342
+ blobHex === '' ||
343
+ errMsg.includes('Encrypted data too short') ||
344
+ errMsg.includes('too short') ||
345
+ errMsg.includes('Cipher');
346
+ if (!looksLikeTombstone) {
347
+ return {
348
+ success: false,
349
+ fact_id: factId,
350
+ error: `Failed to decrypt fact: ${errMsg}`,
351
+ };
352
+ }
353
+ const recovered = findLoserClaimInDecisionLog(factId);
354
+ if (!recovered) {
355
+ return {
356
+ success: false,
357
+ fact_id: factId,
358
+ error: `Failed to decrypt fact and no recovery row in decisions.jsonl: ${errMsg}. ` +
359
+ 'The fact may have been tombstoned by an auto-resolution that predates Phase 2.1 ' +
360
+ '(when loser_claim_json was added to the decision log).',
361
+ };
362
+ }
363
+ plaintext = recovered;
364
+ recoveredFromDecisionLog = true;
365
+ deps.logger?.info?.(`pin: recovered loser claim from decisions.jsonl for ${factId.slice(0, 10)}…`);
366
+ }
367
+ const parsed = parseBlobForPin(plaintext);
368
+ // Recovered claims always represent a fact the user is trying to override —
369
+ // never short-circuit the operation as idempotent because the `st` field on
370
+ // the recovered loser was whatever the original auto-resolution stored
371
+ // (typically active). Drop the previous status so the targetStatus check
372
+ // below produces a real on-chain write.
373
+ if (recoveredFromDecisionLog) {
374
+ parsed.currentStatus = 'active';
375
+ }
376
+ // 3. Idempotent early-exit
377
+ if (parsed.currentStatus === targetStatus) {
378
+ return {
379
+ success: true,
380
+ fact_id: factId,
381
+ previous_status: parsed.currentStatus,
382
+ new_status: targetStatus,
383
+ idempotent: true,
384
+ reason,
385
+ };
386
+ }
387
+ // 4. Build the new canonical v1.1 claim with pin_status + superseded_by link.
388
+ //
389
+ // The new blob is ALWAYS v1.1 shaped (schema_version "1.0", pin_status
390
+ // present) regardless of the source blob's format. v0 sources are upgraded
391
+ // to v1 on the pin path; v1 sources round-trip their metadata (source,
392
+ // scope, reasoning, entities, volatility) into the new blob.
393
+ const pinStatus = targetStatus === 'pinned' ? 'pinned' : 'unpinned';
394
+ const newFactId = crypto.randomUUID();
395
+ // Project the source blob into v1 shape. For v0 sources we upgrade on the
396
+ // fly: short-key `c` → v1 type, `sa` → source (heuristic), etc.
397
+ const v1View = projectToV1(parsed.source, deps.sourceAgent);
398
+ let canonicalJson;
399
+ try {
400
+ canonicalJson = buildV1ClaimBlob({
401
+ id: newFactId,
402
+ text: v1View.text,
403
+ type: v1View.type,
404
+ source: v1View.source,
405
+ scope: v1View.scope,
406
+ volatility: v1View.volatility,
407
+ reasoning: v1View.reasoning,
408
+ entities: v1View.entities,
409
+ importance: v1View.importance,
410
+ confidence: v1View.confidence,
411
+ createdAt: new Date().toISOString(),
412
+ supersededBy: factId,
413
+ pinStatus,
414
+ // 3.3.1-rc.22 — preserve the source claim's embedder tag through
415
+ // pin mutation. The new fact reuses the same encrypted embedding
416
+ // as the original (re-indexed via deps.regenerateBlindIndices),
417
+ // so the embedder identity must round-trip too.
418
+ embeddingModelId: v1View.embeddingModelId,
419
+ });
420
+ }
421
+ catch (err) {
422
+ return {
423
+ success: false,
424
+ fact_id: factId,
425
+ error: `Failed to build v1 claim blob: ${err instanceof Error ? err.message : String(err)}`,
426
+ };
427
+ }
428
+ // 5. Encrypt the new blob
429
+ let newBlobHex;
430
+ try {
431
+ newBlobHex = deps.encryptBlob(canonicalJson);
432
+ }
433
+ catch (err) {
434
+ return {
435
+ success: false,
436
+ fact_id: factId,
437
+ error: `Failed to encrypt updated claim: ${err instanceof Error ? err.message : String(err)}`,
438
+ };
439
+ }
440
+ // 5b. Regenerate trapdoors so the new fact is findable by the same text.
441
+ const entityNames = v1View.entities
442
+ ? v1View.entities.map((e) => e.name).filter((n) => typeof n === 'string' && n.length > 0)
443
+ : [];
444
+ let regenerated;
445
+ try {
446
+ regenerated = await deps.generateIndices(v1View.text, entityNames);
447
+ }
448
+ catch {
449
+ regenerated = { blindIndices: [] };
450
+ }
451
+ // 6. Build tombstone + new protobuf payloads.
452
+ //
453
+ // Tombstone: empty blob ('00'), empty indices, decayScore=0, source='tombstone'.
454
+ // Written at the DEFAULT protobuf version (legacy v3) because tombstone rows
455
+ // carry no inner blob — the version field is irrelevant for readers and
456
+ // writing v3 keeps round-trip compat with any pre-v1 tombstone parser.
457
+ const tombstonePayload = {
458
+ id: factId,
459
+ timestamp: new Date().toISOString(),
460
+ owner: deps.owner,
461
+ encryptedBlob: '00',
462
+ blindIndices: [],
463
+ decayScore: 0,
464
+ source: 'tombstone',
465
+ contentFp: '',
466
+ agentId: deps.sourceAgent,
467
+ };
468
+ const newPayload = {
469
+ id: newFactId,
470
+ timestamp: new Date().toISOString(),
471
+ owner: deps.owner,
472
+ encryptedBlob: newBlobHex,
473
+ blindIndices: regenerated.blindIndices,
474
+ decayScore: 1.0,
475
+ source: targetStatus === 'pinned' ? 'openclaw-plugin-pin' : 'openclaw-plugin-unpin',
476
+ contentFp: '',
477
+ agentId: deps.sourceAgent,
478
+ encryptedEmbedding: regenerated.encryptedEmbedding,
479
+ };
480
+ // Outer protobuf version: v=4 for the new v1 claim, default (legacy v3)
481
+ // for the tombstone. This is the core of the bug-2 fix — previously both
482
+ // payloads went out at version=3 and the inner blob was v0 short-key.
483
+ const payloads = [
484
+ encodeFactProtobufLocal(tombstonePayload, /* version = legacy v3 */ 3),
485
+ encodeFactProtobufLocal(newPayload, PROTOBUF_VERSION_V4),
486
+ ];
487
+ // 6b. Slice 2f: consult decisions.jsonl to see if this pin/unpin contradicts
488
+ // a prior auto-resolution. If so, append a counterexample to feedback.jsonl
489
+ // so the next digest-compile's tuning loop can nudge the weights. Voluntary
490
+ // pins (no matching decision) produce no feedback row. Never fatal.
491
+ const feedbackLogger = deps.logger ?? {
492
+ info: () => { },
493
+ warn: () => { },
494
+ };
495
+ try {
496
+ await maybeWriteFeedbackForPin(factId, targetStatus, Math.floor(Date.now() / 1000), feedbackLogger);
497
+ }
498
+ catch {
499
+ // Feedback wiring is best-effort — never block the pin op.
500
+ }
501
+ // 7. Submit both in a single batch UserOp.
502
+ try {
503
+ const { txHash, success } = await deps.submitBatch(payloads);
504
+ if (!success) {
505
+ return {
506
+ success: false,
507
+ fact_id: factId,
508
+ previous_status: parsed.currentStatus,
509
+ error: 'On-chain batch submission failed',
510
+ tx_hash: txHash,
511
+ };
512
+ }
513
+ // Read-after-write: poll the subgraph until the new (pinned/unpinned)
514
+ // fact id is indexed and active. On timeout, surface `partial: true`
515
+ // so a follow-up recall/export that races against indexer lag can
516
+ // surface a clear "still propagating" hint rather than apparent staleness.
517
+ const confirm = await confirmIndexed(newFactId, confirmOpts);
518
+ return {
519
+ success: true,
520
+ fact_id: factId,
521
+ new_fact_id: newFactId,
522
+ previous_status: parsed.currentStatus,
523
+ new_status: targetStatus,
524
+ tx_hash: txHash,
525
+ reason,
526
+ ...(confirm.indexed ? {} : { partial: true }),
527
+ };
528
+ }
529
+ catch (err) {
530
+ return {
531
+ success: false,
532
+ fact_id: factId,
533
+ previous_status: parsed.currentStatus,
534
+ error: `Failed to submit pin batch: ${err instanceof Error ? err.message : String(err)}`,
535
+ };
536
+ }
537
+ }
538
+ /** Validate the `{fact_id, reason?}` input shape for pin/unpin tool calls. */
539
+ export function validatePinArgs(args) {
540
+ if (!args || typeof args !== 'object') {
541
+ return { ok: false, factId: '', error: 'Invalid input: fact_id is required' };
542
+ }
543
+ const record = args;
544
+ const factId = record.fact_id;
545
+ if (factId === undefined || factId === null) {
546
+ return { ok: false, factId: '', error: 'Invalid input: fact_id is required' };
547
+ }
548
+ if (typeof factId !== 'string') {
549
+ return { ok: false, factId: '', error: 'Invalid input: fact_id must be a non-empty string' };
550
+ }
551
+ if (factId.trim().length === 0) {
552
+ return { ok: false, factId: '', error: 'Invalid input: fact_id must be a non-empty string' };
553
+ }
554
+ const reason = typeof record.reason === 'string' ? record.reason : undefined;
555
+ return { ok: true, factId: factId.trim(), reason, error: '' };
556
+ }