@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/CLAWHUB.md +134 -0
- package/README.md +407 -64
- package/SKILL.md +1032 -0
- package/api-client.ts +5 -5
- package/claims-helper.ts +686 -0
- package/config.ts +211 -0
- package/consolidation.ts +141 -33
- package/contradiction-sync.ts +1389 -0
- package/crypto.ts +63 -261
- package/digest-sync.ts +516 -0
- package/embedding.ts +69 -46
- package/extractor.ts +1307 -84
- package/hot-cache-wrapper.ts +1 -1
- package/import-adapters/gemini-adapter.ts +243 -0
- package/import-adapters/index.ts +3 -0
- package/import-adapters/types.ts +1 -1
- package/index.ts +1887 -323
- package/llm-client.ts +106 -53
- package/lsh.ts +21 -210
- package/package.json +20 -7
- package/pin.ts +502 -0
- package/reranker.ts +96 -124
- package/skill.json +213 -0
- package/subgraph-search.ts +112 -5
- package/subgraph-store.ts +559 -275
- package/consolidation.test.ts +0 -356
- package/extractor-dedup.test.ts +0 -168
- package/import-adapters/import-adapters.test.ts +0 -1123
- package/lsh.test.ts +0 -463
- package/pocv2-e2e-test.ts +0 -917
- package/porter-stemmer.d.ts +0 -4
- package/reranker.test.ts +0 -594
- package/semantic-dedup.test.ts +0 -392
- package/setup.sh +0 -19
- package/store-dedup-wiring.test.ts +0 -186
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
|
+
}
|