@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.
- package/CHANGELOG.md +268 -1
- package/SKILL.md +29 -23
- package/api-client.ts +18 -11
- package/claims-helper.ts +47 -1
- package/config.ts +108 -4
- package/confirm-indexed.ts +191 -0
- package/crypto.ts +10 -2
- package/dist/api-client.js +226 -0
- package/dist/billing-cache.js +100 -0
- package/dist/claims-helper.js +624 -0
- package/dist/config.js +297 -0
- package/dist/confirm-indexed.js +127 -0
- package/dist/consolidation.js +258 -0
- package/dist/contradiction-sync.js +1034 -0
- package/dist/crypto.js +138 -0
- package/dist/digest-sync.js +361 -0
- package/dist/download-ux.js +63 -0
- package/dist/embedder-cache.js +185 -0
- package/dist/embedder-loader.js +121 -0
- package/dist/embedder-network.js +301 -0
- package/dist/embedding.js +141 -0
- package/dist/extractor.js +1225 -0
- package/dist/first-run.js +103 -0
- package/dist/fs-helpers.js +725 -0
- package/dist/gateway-url.js +197 -0
- package/dist/generate-mnemonic.js +13 -0
- package/dist/hot-cache-wrapper.js +101 -0
- package/dist/import-adapters/base-adapter.js +64 -0
- package/dist/import-adapters/chatgpt-adapter.js +238 -0
- package/dist/import-adapters/claude-adapter.js +114 -0
- package/dist/import-adapters/gemini-adapter.js +201 -0
- package/dist/import-adapters/index.js +26 -0
- package/dist/import-adapters/mcp-memory-adapter.js +219 -0
- package/dist/import-adapters/mem0-adapter.js +158 -0
- package/dist/import-adapters/types.js +1 -0
- package/dist/index.js +5388 -0
- package/dist/llm-client.js +687 -0
- package/dist/llm-profile-reader.js +346 -0
- package/dist/lsh.js +62 -0
- package/dist/onboarding-cli.js +750 -0
- package/dist/pair-cli.js +344 -0
- package/dist/pair-crypto.js +359 -0
- package/dist/pair-http.js +404 -0
- package/dist/pair-page.js +826 -0
- package/dist/pair-qr.js +107 -0
- package/dist/pair-remote-client.js +410 -0
- package/dist/pair-session-store.js +566 -0
- package/dist/pin.js +556 -0
- package/dist/qa-bug-report.js +301 -0
- package/dist/relay-headers.js +44 -0
- package/dist/reranker.js +409 -0
- package/dist/retype-setscope.js +368 -0
- package/dist/semantic-dedup.js +75 -0
- package/dist/subgraph-search.js +289 -0
- package/dist/subgraph-store.js +694 -0
- package/dist/tool-gating.js +58 -0
- package/download-ux.ts +91 -0
- package/embedder-cache.ts +230 -0
- package/embedder-loader.ts +189 -0
- package/embedder-network.ts +350 -0
- package/embedding.ts +118 -27
- package/fs-helpers.ts +277 -0
- package/gateway-url.ts +57 -9
- package/index.ts +469 -250
- package/llm-client.ts +4 -3
- package/lsh.ts +7 -2
- package/onboarding-cli.ts +114 -1
- package/package.json +24 -5
- package/pair-cli.ts +76 -8
- package/pair-crypto.ts +34 -24
- package/pair-page.ts +28 -17
- package/pair-qr.ts +152 -0
- package/pair-remote-client.ts +540 -0
- package/pin.ts +31 -0
- package/qa-bug-report.ts +84 -2
- package/relay-headers.ts +50 -0
- package/reranker.ts +40 -0
- package/retype-setscope.ts +69 -8
- package/skill.json +1 -1
- package/subgraph-search.ts +4 -3
- package/subgraph-store.ts +15 -10
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TotalReclaw Plugin — Knowledge Graph helpers for the write path.
|
|
3
|
+
*
|
|
4
|
+
* Builds canonical Claim JSON from an ExtractedFact, generates entity
|
|
5
|
+
* trapdoors for blind search, and resolves the claim-format feature flag.
|
|
6
|
+
*
|
|
7
|
+
* The canonical Claim schema uses compact short keys (t, c, cf, i, sa, ea, e, ...)
|
|
8
|
+
* and is produced byte-identically across Rust, WASM, and Python via
|
|
9
|
+
* `canonicalizeClaim()` in @totalreclaw/core.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { isValidMemoryType, isValidMemoryTypeV1, V0_TO_V1_TYPE, VALID_MEMORY_SCOPES, VALID_MEMORY_SOURCES, VALID_MEMORY_VOLATILITIES, VALID_MEMORY_TYPES_V1, } from './extractor.js';
|
|
14
|
+
// Lazy-load WASM. We use createRequire so this module loads cleanly 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 = null;
|
|
18
|
+
function getWasm() {
|
|
19
|
+
if (!_wasm)
|
|
20
|
+
_wasm = requireWasm('@totalreclaw/core');
|
|
21
|
+
return _wasm;
|
|
22
|
+
}
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Category mapping (ExtractedFact.type → compact Claim category short key)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Legacy v0 type → compact category mapping. Kept for reading pre-v1 vault
|
|
27
|
+
// entries that stored the short-form category as the decrypted `c` key.
|
|
28
|
+
const TYPE_TO_CATEGORY_V0 = {
|
|
29
|
+
fact: 'fact',
|
|
30
|
+
preference: 'pref',
|
|
31
|
+
decision: 'dec',
|
|
32
|
+
episodic: 'epi',
|
|
33
|
+
goal: 'goal',
|
|
34
|
+
context: 'ctx',
|
|
35
|
+
summary: 'sum',
|
|
36
|
+
rule: 'rule',
|
|
37
|
+
};
|
|
38
|
+
// v1 type → compact category mapping for recall display. These short keys
|
|
39
|
+
// remain the display-layer category tags (e.g. `[rule]`, `[fact]`) that the
|
|
40
|
+
// recall tool surfaces, so the v1 types map onto the v0 category keys.
|
|
41
|
+
const TYPE_TO_CATEGORY_V1 = {
|
|
42
|
+
claim: 'claim',
|
|
43
|
+
preference: 'pref',
|
|
44
|
+
directive: 'rule', // v1 directive → v0 category "rule" for display
|
|
45
|
+
commitment: 'goal', // v1 commitment → v0 category "goal" for display
|
|
46
|
+
episode: 'epi',
|
|
47
|
+
summary: 'sum',
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Map any memory type (v1 or legacy v0) to the compact category short key.
|
|
51
|
+
*
|
|
52
|
+
* v1 types take priority; unknown tokens fall through to the v0 table for
|
|
53
|
+
* pre-v1 vault entries; anything else returns `'fact'`.
|
|
54
|
+
*/
|
|
55
|
+
export function mapTypeToCategory(type) {
|
|
56
|
+
if (type in TYPE_TO_CATEGORY_V1)
|
|
57
|
+
return TYPE_TO_CATEGORY_V1[type];
|
|
58
|
+
return TYPE_TO_CATEGORY_V0[type] ?? 'fact';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Construct a canonical Claim JSON string from an ExtractedFact.
|
|
62
|
+
*
|
|
63
|
+
* As of plugin v3.0.0, this unconditionally emits a Memory Taxonomy v1 JSON
|
|
64
|
+
* blob (schema_version "1.0") — forwarded to `buildCanonicalClaimV1`. The
|
|
65
|
+
* legacy v0 short-key {t, c, i, sa, ea} format is no longer produced on the
|
|
66
|
+
* write path.
|
|
67
|
+
*
|
|
68
|
+
* When `fact.source` is missing we default it to `'user-inferred'` so a
|
|
69
|
+
* misconfigured extraction hook doesn't drop the write. The outer protobuf
|
|
70
|
+
* wrapper's `version` field MUST be set to 4 when storing the returned
|
|
71
|
+
* payload (see `subgraph-store.ts::encodeFactProtobuf`).
|
|
72
|
+
*/
|
|
73
|
+
export function buildCanonicalClaim(input) {
|
|
74
|
+
const { fact, importance, extractedAt, embeddingModelId } = input;
|
|
75
|
+
// Defensive: ensure fact.source is always populated before v1 validation.
|
|
76
|
+
// `applyProvenanceFilterLax` should have set this upstream; this is the
|
|
77
|
+
// belt-and-suspenders fallback for explicit tool paths / legacy callers.
|
|
78
|
+
const factWithSource = fact.source
|
|
79
|
+
? fact
|
|
80
|
+
: { ...fact, source: 'user-inferred' };
|
|
81
|
+
return buildCanonicalClaimV1({
|
|
82
|
+
fact: factWithSource,
|
|
83
|
+
importance,
|
|
84
|
+
createdAt: extractedAt,
|
|
85
|
+
embeddingModelId,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// v1 Claim payload builder (Phase 3 — plugin v3.0.0)
|
|
90
|
+
//
|
|
91
|
+
// Produces a MemoryClaimV1-shaped JSON payload matching
|
|
92
|
+
// `docs/specs/totalreclaw/memory-taxonomy-v1.md`.
|
|
93
|
+
//
|
|
94
|
+
// The v1 payload uses long field names + a schema_version marker so that
|
|
95
|
+
// decrypt logic can discriminate between v0 short-key claims and v1 claims
|
|
96
|
+
// without any external hint. The protobuf outer wrapper sets `version = 4`
|
|
97
|
+
// when writing v1 payloads — see `subgraph-store.ts`.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
export const V1_SCHEMA_VERSION = '1.0';
|
|
100
|
+
/**
|
|
101
|
+
* Build a v1 MemoryClaimV1 JSON blob.
|
|
102
|
+
*
|
|
103
|
+
* Throws if the fact does not have a valid v1 `source` set — v1 requires
|
|
104
|
+
* every claim to carry provenance (the whole taxonomy depends on it).
|
|
105
|
+
*
|
|
106
|
+
* The build pipeline:
|
|
107
|
+
* 1. Build the full v1 payload object (including plugin-only extras like
|
|
108
|
+
* `volatility` and `schema_version`).
|
|
109
|
+
* 2. Send the core-required subset through `validateMemoryClaimV1` for
|
|
110
|
+
* schema enforcement (throws on invalid type/source/missing id).
|
|
111
|
+
* 3. Emit the FULL payload (core canonical fields + plugin extras) as the
|
|
112
|
+
* final stored JSON so round-trip preserves client-side state.
|
|
113
|
+
*
|
|
114
|
+
* Plugin-only extras (not round-tripped by core's validator as of v2.0.0):
|
|
115
|
+
* - `schema_version` — version marker the decrypt path reads
|
|
116
|
+
* - `volatility` — stable | updatable | ephemeral (re-scored after extraction)
|
|
117
|
+
*
|
|
118
|
+
* The outer protobuf wrapper's `version` field must be set to 4 when storing
|
|
119
|
+
* the returned payload (see subgraph-store.ts).
|
|
120
|
+
*/
|
|
121
|
+
export function buildCanonicalClaimV1(input) {
|
|
122
|
+
const { fact, importance, createdAt, supersededBy, expiresAt } = input;
|
|
123
|
+
const id = input.id ?? crypto.randomUUID();
|
|
124
|
+
if (!fact.source) {
|
|
125
|
+
throw new Error('buildCanonicalClaimV1: fact.source is required (v1 taxonomy mandates provenance)');
|
|
126
|
+
}
|
|
127
|
+
if (!VALID_MEMORY_SOURCES.includes(fact.source)) {
|
|
128
|
+
throw new Error(`buildCanonicalClaimV1: invalid source "${fact.source}"`);
|
|
129
|
+
}
|
|
130
|
+
const type = normalizeToV1Type(fact.type);
|
|
131
|
+
const resolvedCreatedAt = createdAt ?? new Date().toISOString();
|
|
132
|
+
const resolvedImportance = Math.max(1, Math.min(10, Math.round(importance)));
|
|
133
|
+
// Core-canonical subset sent through validateMemoryClaimV1. Core strips
|
|
134
|
+
// fields it doesn't understand, so we send it the subset it accepts and
|
|
135
|
+
// re-attach client-side extras to the final payload.
|
|
136
|
+
const corePayload = {
|
|
137
|
+
id,
|
|
138
|
+
text: fact.text,
|
|
139
|
+
type,
|
|
140
|
+
source: fact.source,
|
|
141
|
+
created_at: resolvedCreatedAt,
|
|
142
|
+
importance: resolvedImportance,
|
|
143
|
+
};
|
|
144
|
+
if (fact.scope && VALID_MEMORY_SCOPES.includes(fact.scope)) {
|
|
145
|
+
corePayload.scope = fact.scope;
|
|
146
|
+
}
|
|
147
|
+
if (fact.reasoning && fact.reasoning.length > 0) {
|
|
148
|
+
corePayload.reasoning = fact.reasoning.slice(0, 256);
|
|
149
|
+
}
|
|
150
|
+
if (fact.entities && fact.entities.length > 0) {
|
|
151
|
+
corePayload.entities = fact.entities.slice(0, 8).map((e) => {
|
|
152
|
+
const entity = { name: e.name, type: e.type };
|
|
153
|
+
if (e.role)
|
|
154
|
+
entity.role = e.role;
|
|
155
|
+
return entity;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (typeof fact.confidence === 'number') {
|
|
159
|
+
corePayload.confidence = Math.max(0, Math.min(1, fact.confidence));
|
|
160
|
+
}
|
|
161
|
+
if (expiresAt)
|
|
162
|
+
corePayload.expires_at = expiresAt;
|
|
163
|
+
if (supersededBy)
|
|
164
|
+
corePayload.superseded_by = supersededBy;
|
|
165
|
+
// v1.1 pin_status — additive field; only emitted when the caller opts in.
|
|
166
|
+
if (input.pinStatus === 'pinned' || input.pinStatus === 'unpinned') {
|
|
167
|
+
corePayload.pin_status = input.pinStatus;
|
|
168
|
+
}
|
|
169
|
+
// Validate through core — throws on invalid type / source / missing id.
|
|
170
|
+
const validated = getWasm().validateMemoryClaimV1(JSON.stringify(corePayload));
|
|
171
|
+
const canonical = JSON.parse(validated);
|
|
172
|
+
// Re-attach plugin-only extras not round-tripped by core's validator.
|
|
173
|
+
canonical.schema_version = V1_SCHEMA_VERSION;
|
|
174
|
+
if (fact.volatility && VALID_MEMORY_VOLATILITIES.includes(fact.volatility)) {
|
|
175
|
+
canonical.volatility = fact.volatility;
|
|
176
|
+
}
|
|
177
|
+
// 3.3.1-rc.22 — forward-compat embedder marker. Plugin-only field;
|
|
178
|
+
// survives the core validator via re-attach. Future distillation
|
|
179
|
+
// detects this on read to re-embed selectively without forcing a
|
|
180
|
+
// vault-wide rebuild.
|
|
181
|
+
if (typeof input.embeddingModelId === 'string' && input.embeddingModelId.length > 0) {
|
|
182
|
+
canonical.embedding_model_id = input.embeddingModelId;
|
|
183
|
+
}
|
|
184
|
+
return JSON.stringify(canonical);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Build a v1.1 canonical claim JSON string, validated through the core
|
|
188
|
+
* `validateMemoryClaimV1` WASM export.
|
|
189
|
+
*
|
|
190
|
+
* Output is UTF-8 JSON ready for encryption as the inner blob of a
|
|
191
|
+
* protobuf-v4 fact (outer `version = 4`). Field ordering follows the core
|
|
192
|
+
* validator, so the result is byte-identical to what MCP's equivalent helper
|
|
193
|
+
* produces for the same inputs (cross-client parity).
|
|
194
|
+
*
|
|
195
|
+
* Throws on malformed input (missing required field, invalid enum value).
|
|
196
|
+
*/
|
|
197
|
+
export function buildV1ClaimBlob(input) {
|
|
198
|
+
if (!VALID_MEMORY_SOURCES.includes(input.source)) {
|
|
199
|
+
throw new Error(`buildV1ClaimBlob: invalid source "${input.source}"`);
|
|
200
|
+
}
|
|
201
|
+
if (!isValidMemoryType(input.type)) {
|
|
202
|
+
throw new Error(`buildV1ClaimBlob: invalid type "${input.type}"`);
|
|
203
|
+
}
|
|
204
|
+
const corePayload = {
|
|
205
|
+
id: input.id ?? crypto.randomUUID(),
|
|
206
|
+
text: input.text,
|
|
207
|
+
type: input.type,
|
|
208
|
+
source: input.source,
|
|
209
|
+
created_at: input.createdAt ?? new Date().toISOString(),
|
|
210
|
+
};
|
|
211
|
+
if (input.scope && VALID_MEMORY_SCOPES.includes(input.scope)) {
|
|
212
|
+
corePayload.scope = input.scope;
|
|
213
|
+
}
|
|
214
|
+
if (input.reasoning && input.reasoning.length > 0) {
|
|
215
|
+
corePayload.reasoning = input.reasoning.slice(0, 256);
|
|
216
|
+
}
|
|
217
|
+
if (input.entities && input.entities.length > 0) {
|
|
218
|
+
corePayload.entities = input.entities.slice(0, 8).map((e) => {
|
|
219
|
+
const entity = { name: e.name, type: e.type };
|
|
220
|
+
if (e.role)
|
|
221
|
+
entity.role = e.role;
|
|
222
|
+
return entity;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (typeof input.importance === 'number') {
|
|
226
|
+
corePayload.importance = Math.max(1, Math.min(10, Math.round(input.importance)));
|
|
227
|
+
}
|
|
228
|
+
if (typeof input.confidence === 'number') {
|
|
229
|
+
corePayload.confidence = Math.max(0, Math.min(1, input.confidence));
|
|
230
|
+
}
|
|
231
|
+
if (input.expiresAt)
|
|
232
|
+
corePayload.expires_at = input.expiresAt;
|
|
233
|
+
if (input.supersededBy)
|
|
234
|
+
corePayload.superseded_by = input.supersededBy;
|
|
235
|
+
if (input.pinStatus === 'pinned' || input.pinStatus === 'unpinned') {
|
|
236
|
+
corePayload.pin_status = input.pinStatus;
|
|
237
|
+
}
|
|
238
|
+
// Validate via core — throws on invalid shape.
|
|
239
|
+
const validated = getWasm().validateMemoryClaimV1(JSON.stringify(corePayload));
|
|
240
|
+
const canonical = JSON.parse(validated);
|
|
241
|
+
canonical.schema_version = V1_SCHEMA_VERSION;
|
|
242
|
+
if (input.volatility && VALID_MEMORY_VOLATILITIES.includes(input.volatility)) {
|
|
243
|
+
canonical.volatility = input.volatility;
|
|
244
|
+
}
|
|
245
|
+
// 3.3.1-rc.22 — see `buildCanonicalClaimV1` comment.
|
|
246
|
+
if (typeof input.embeddingModelId === 'string' && input.embeddingModelId.length > 0) {
|
|
247
|
+
canonical.embedding_model_id = input.embeddingModelId;
|
|
248
|
+
}
|
|
249
|
+
return JSON.stringify(canonical);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Normalize any type token (v0 or v1) to a v1 type. Uses the v0→v1 mapping
|
|
253
|
+
* for legacy tokens; passes through when already v1.
|
|
254
|
+
*/
|
|
255
|
+
export function normalizeToV1Type(type) {
|
|
256
|
+
if (isValidMemoryType(type))
|
|
257
|
+
return type;
|
|
258
|
+
return V0_TO_V1_TYPE[type] ?? 'claim';
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Heuristic: does a decrypted blob look like a v1 JSON payload?
|
|
262
|
+
*
|
|
263
|
+
* We check for the schema_version marker + the long-form `text` field.
|
|
264
|
+
* Falls back false on any parse error.
|
|
265
|
+
*/
|
|
266
|
+
export function isV1Blob(decrypted) {
|
|
267
|
+
try {
|
|
268
|
+
const obj = JSON.parse(decrypted);
|
|
269
|
+
return (typeof obj === 'object' &&
|
|
270
|
+
obj !== null &&
|
|
271
|
+
typeof obj.text === 'string' &&
|
|
272
|
+
typeof obj.type === 'string' &&
|
|
273
|
+
typeof obj.schema_version === 'string' &&
|
|
274
|
+
obj.schema_version.startsWith('1.'));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
export function readV1Blob(decrypted) {
|
|
281
|
+
try {
|
|
282
|
+
const obj = JSON.parse(decrypted);
|
|
283
|
+
if (typeof obj.schema_version !== 'string' || !obj.schema_version.startsWith('1.')) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
const text = typeof obj.text === 'string' ? obj.text : '';
|
|
287
|
+
const rawType = typeof obj.type === 'string' ? obj.type : 'claim';
|
|
288
|
+
const type = isValidMemoryTypeV1(rawType) ? rawType : 'claim';
|
|
289
|
+
const rawSource = typeof obj.source === 'string' ? obj.source : 'user-inferred';
|
|
290
|
+
const source = VALID_MEMORY_SOURCES.includes(rawSource)
|
|
291
|
+
? rawSource
|
|
292
|
+
: 'user-inferred';
|
|
293
|
+
const rawScope = typeof obj.scope === 'string' ? obj.scope : 'unspecified';
|
|
294
|
+
const scope = VALID_MEMORY_SCOPES.includes(rawScope)
|
|
295
|
+
? rawScope
|
|
296
|
+
: 'unspecified';
|
|
297
|
+
const rawVolatility = typeof obj.volatility === 'string' ? obj.volatility : 'updatable';
|
|
298
|
+
const volatility = VALID_MEMORY_VOLATILITIES.includes(rawVolatility)
|
|
299
|
+
? rawVolatility
|
|
300
|
+
: 'updatable';
|
|
301
|
+
const impRaw = typeof obj.importance === 'number' ? obj.importance : 5;
|
|
302
|
+
const importance = Math.max(1, Math.min(10, Math.round(impRaw)));
|
|
303
|
+
const confRaw = typeof obj.confidence === 'number' ? obj.confidence : 0.85;
|
|
304
|
+
const confidence = Math.max(0, Math.min(1, confRaw));
|
|
305
|
+
const result = {
|
|
306
|
+
text,
|
|
307
|
+
type,
|
|
308
|
+
source,
|
|
309
|
+
scope,
|
|
310
|
+
volatility,
|
|
311
|
+
importance,
|
|
312
|
+
confidence,
|
|
313
|
+
createdAt: typeof obj.created_at === 'string' ? obj.created_at : '',
|
|
314
|
+
};
|
|
315
|
+
if (typeof obj.reasoning === 'string' && obj.reasoning.length > 0) {
|
|
316
|
+
result.reasoning = obj.reasoning;
|
|
317
|
+
}
|
|
318
|
+
if (Array.isArray(obj.entities)) {
|
|
319
|
+
result.entities = obj.entities.filter((e) => !!e &&
|
|
320
|
+
typeof e === 'object' &&
|
|
321
|
+
typeof e.name === 'string' &&
|
|
322
|
+
typeof e.type === 'string');
|
|
323
|
+
}
|
|
324
|
+
if (typeof obj.expires_at === 'string')
|
|
325
|
+
result.expiresAt = obj.expires_at;
|
|
326
|
+
if (typeof obj.superseded_by === 'string')
|
|
327
|
+
result.supersededBy = obj.superseded_by;
|
|
328
|
+
if (typeof obj.id === 'string')
|
|
329
|
+
result.id = obj.id;
|
|
330
|
+
if (typeof obj.pin_status === 'string') {
|
|
331
|
+
const ps = obj.pin_status;
|
|
332
|
+
if (ps === 'pinned' || ps === 'unpinned') {
|
|
333
|
+
result.pinStatus = ps;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// 3.3.1-rc.22 — pull the embedder identity tag through. Plugin-only
|
|
337
|
+
// field added by `buildCanonicalClaimV1` / `buildV1ClaimBlob` after
|
|
338
|
+
// core validation.
|
|
339
|
+
if (typeof obj.embedding_model_id === 'string' && obj.embedding_model_id.length > 0) {
|
|
340
|
+
result.embeddingModelId = obj.embedding_model_id;
|
|
341
|
+
}
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Suppress unused-import lint warnings for VALID_MEMORY_TYPES_V1 — it is
|
|
349
|
+
// exported from extractor.ts for downstream clients and kept in scope here
|
|
350
|
+
// so future v1 helpers can reuse it without re-importing.
|
|
351
|
+
void VALID_MEMORY_TYPES_V1;
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Back-compat alias: buildCanonicalClaimRouted
|
|
354
|
+
//
|
|
355
|
+
// Plugin v3.0.0 removed the v0/v1 taxonomy toggle (`TOTALRECLAW_TAXONOMY_VERSION`
|
|
356
|
+
// env var) — all extraction + write paths emit v1 unconditionally. This
|
|
357
|
+
// alias is kept so any external caller that imports the Phase-3 rollout
|
|
358
|
+
// name keeps compiling; it simply forwards to `buildCanonicalClaim`.
|
|
359
|
+
//
|
|
360
|
+
// @deprecated Use `buildCanonicalClaim` directly.
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
export function buildCanonicalClaimRouted(input) {
|
|
363
|
+
return buildCanonicalClaim(input);
|
|
364
|
+
}
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Digest helpers (Stage 3b read path)
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
/**
|
|
369
|
+
* Well-known blind index marker used to locate digest claims on the subgraph.
|
|
370
|
+
* Computed as plain SHA-256("type:digest") — same primitive as word trapdoors
|
|
371
|
+
* so it lives in the existing `blindIndices` array. The `type:` namespace
|
|
372
|
+
* prefix keeps it distinct from any user word trapdoor.
|
|
373
|
+
*/
|
|
374
|
+
export const DIGEST_TRAPDOOR = crypto
|
|
375
|
+
.createHash('sha256')
|
|
376
|
+
.update('type:digest')
|
|
377
|
+
.digest('hex');
|
|
378
|
+
/** Compact category short key for digest claims (ClaimCategory::Digest). */
|
|
379
|
+
export const DIGEST_CATEGORY = 'dig';
|
|
380
|
+
/** Distinctive source marker so operators can grep for digest writes. */
|
|
381
|
+
export const DIGEST_SOURCE_AGENT = 'openclaw-plugin-digest';
|
|
382
|
+
/**
|
|
383
|
+
* Hard ceiling on claim count for LLM-assisted digest compilation.
|
|
384
|
+
* Above this, we skip the LLM entirely and use the template path to keep
|
|
385
|
+
* token cost bounded. See plan §9 and Stage 3b design question #3.
|
|
386
|
+
*/
|
|
387
|
+
export const DIGEST_CLAIM_CAP = 200;
|
|
388
|
+
/**
|
|
389
|
+
* Digest injection is always ON in v1. The TOTALRECLAW_DIGEST_MODE env var
|
|
390
|
+
* was removed — the G-pipeline ships a digest on every recall with an LLM
|
|
391
|
+
* template fallback baked into the digest compiler. Kept as a function
|
|
392
|
+
* returning `'on'` so legacy call-sites continue to compile.
|
|
393
|
+
*
|
|
394
|
+
* @deprecated v1 always returns `'on'`.
|
|
395
|
+
*/
|
|
396
|
+
export function resolveDigestMode() {
|
|
397
|
+
return 'on';
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Internal kill-switch for the auto-resolution loop.
|
|
401
|
+
*
|
|
402
|
+
* - `active` (default, unset, unknown): full detection + auto-resolution.
|
|
403
|
+
* - `off`: skip contradiction detection entirely; Phase 1 behaviour.
|
|
404
|
+
* - `shadow`: detect + log decisions, but do not apply them (debug only).
|
|
405
|
+
*
|
|
406
|
+
* @internal Not public config — emergency kill-switch only.
|
|
407
|
+
*/
|
|
408
|
+
export function resolveAutoResolveMode() {
|
|
409
|
+
const raw = (process.env.TOTALRECLAW_AUTO_RESOLVE_MODE ?? '').trim().toLowerCase();
|
|
410
|
+
if (raw === 'off')
|
|
411
|
+
return 'off';
|
|
412
|
+
if (raw === 'shadow')
|
|
413
|
+
return 'shadow';
|
|
414
|
+
return 'active';
|
|
415
|
+
}
|
|
416
|
+
export function readClaimFromBlob(decryptedJson) {
|
|
417
|
+
try {
|
|
418
|
+
const obj = JSON.parse(decryptedJson);
|
|
419
|
+
// v1 payload: long-form fields + schema_version "1.x"
|
|
420
|
+
if (typeof obj.text === 'string' &&
|
|
421
|
+
typeof obj.type === 'string' &&
|
|
422
|
+
typeof obj.schema_version === 'string' &&
|
|
423
|
+
obj.schema_version.startsWith('1.')) {
|
|
424
|
+
const importance = typeof obj.importance === 'number'
|
|
425
|
+
? Math.max(1, Math.min(10, Math.round(obj.importance)))
|
|
426
|
+
: 5;
|
|
427
|
+
return {
|
|
428
|
+
text: obj.text,
|
|
429
|
+
importance,
|
|
430
|
+
category: mapTypeToCategory(obj.type),
|
|
431
|
+
metadata: {
|
|
432
|
+
type: obj.type,
|
|
433
|
+
source: typeof obj.source === 'string' ? obj.source : 'user-inferred',
|
|
434
|
+
scope: typeof obj.scope === 'string' ? obj.scope : 'unspecified',
|
|
435
|
+
volatility: typeof obj.volatility === 'string' ? obj.volatility : 'updatable',
|
|
436
|
+
reasoning: typeof obj.reasoning === 'string' ? obj.reasoning : undefined,
|
|
437
|
+
// v1.1: surface pin_status verbatim for downstream (recall display +
|
|
438
|
+
// export). Absent ⇒ undefined (receivers treat as "unpinned").
|
|
439
|
+
pin_status: typeof obj.pin_status === 'string' ? obj.pin_status : undefined,
|
|
440
|
+
importance: importance / 10,
|
|
441
|
+
created_at: typeof obj.created_at === 'string' ? obj.created_at : '',
|
|
442
|
+
schema_version: obj.schema_version,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
// New canonical Claim format: short keys
|
|
447
|
+
if (typeof obj.t === 'string' && typeof obj.c === 'string') {
|
|
448
|
+
const importance = typeof obj.i === 'number' ? Math.max(1, Math.min(10, Math.round(obj.i))) : 5;
|
|
449
|
+
return {
|
|
450
|
+
text: obj.t,
|
|
451
|
+
importance,
|
|
452
|
+
category: obj.c,
|
|
453
|
+
metadata: {
|
|
454
|
+
type: obj.c,
|
|
455
|
+
importance: importance / 10,
|
|
456
|
+
source: typeof obj.sa === 'string' ? obj.sa : 'auto-extraction',
|
|
457
|
+
created_at: typeof obj.ea === 'string' ? obj.ea : '',
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
// Legacy plugin {text, metadata: {importance: 0-1}} format
|
|
462
|
+
if (typeof obj.text === 'string') {
|
|
463
|
+
const meta = obj.metadata ?? {};
|
|
464
|
+
const impFloat = typeof meta.importance === 'number' ? meta.importance : 0.5;
|
|
465
|
+
const importance = Math.max(1, Math.min(10, Math.round(impFloat * 10)));
|
|
466
|
+
return {
|
|
467
|
+
text: obj.text,
|
|
468
|
+
importance,
|
|
469
|
+
category: typeof meta.type === 'string' ? meta.type : 'fact',
|
|
470
|
+
metadata: meta,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
// fall through
|
|
476
|
+
}
|
|
477
|
+
return { text: decryptedJson, importance: 5, category: 'fact', metadata: {} };
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Wrap a serialized Digest JSON as a canonical Claim so it can be encrypted
|
|
481
|
+
* and stored on-chain via the same pipeline as regular facts.
|
|
482
|
+
*
|
|
483
|
+
* Stores the raw Digest JSON as the claim's `t` (text) field. Reader path
|
|
484
|
+
* is `parseClaimOrLegacy(decrypted) → extractDigestFromClaim`.
|
|
485
|
+
*
|
|
486
|
+
* Digest claims deliberately carry no entity refs — otherwise entity
|
|
487
|
+
* trapdoors would surface the digest blob in normal recall queries.
|
|
488
|
+
*/
|
|
489
|
+
export function buildDigestClaim(input) {
|
|
490
|
+
const { digestJson, compiledAt } = input;
|
|
491
|
+
const claim = {
|
|
492
|
+
t: digestJson,
|
|
493
|
+
c: DIGEST_CATEGORY,
|
|
494
|
+
cf: 1.0,
|
|
495
|
+
i: 10,
|
|
496
|
+
sa: DIGEST_SOURCE_AGENT,
|
|
497
|
+
ea: compiledAt,
|
|
498
|
+
};
|
|
499
|
+
return getWasm().canonicalizeClaim(JSON.stringify(claim));
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Parse a canonical Claim JSON (produced by parseClaimOrLegacy) and, if it is
|
|
503
|
+
* a digest claim, return the wrapped Digest object. Returns null if the claim
|
|
504
|
+
* is not of category `dig` or if the inner JSON fails to parse.
|
|
505
|
+
*/
|
|
506
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
507
|
+
export function extractDigestFromClaim(canonicalClaimJson) {
|
|
508
|
+
let claim;
|
|
509
|
+
try {
|
|
510
|
+
claim = JSON.parse(canonicalClaimJson);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
if (claim.c !== DIGEST_CATEGORY || typeof claim.t !== 'string')
|
|
516
|
+
return null;
|
|
517
|
+
try {
|
|
518
|
+
const digest = JSON.parse(claim.t);
|
|
519
|
+
// Minimal shape check: a Digest must at least have prompt_text.
|
|
520
|
+
if (typeof digest !== 'object' || digest === null)
|
|
521
|
+
return null;
|
|
522
|
+
if (typeof digest.prompt_text !== 'string')
|
|
523
|
+
return null;
|
|
524
|
+
return digest;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Lightweight check: does this decrypted blob look like a digest claim?
|
|
532
|
+
* Used to filter digest blobs out of user-facing recall results.
|
|
533
|
+
*
|
|
534
|
+
* Accepts both canonical Claim JSON (`{c:"dig",...}`) and the already-parsed
|
|
535
|
+
* form; returns false for legacy `{text, metadata}` docs and any parse error.
|
|
536
|
+
*/
|
|
537
|
+
export function isDigestBlob(decrypted) {
|
|
538
|
+
try {
|
|
539
|
+
const obj = JSON.parse(decrypted);
|
|
540
|
+
return obj && typeof obj === 'object' && obj.c === DIGEST_CATEGORY;
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Hours between two timestamps.
|
|
548
|
+
*
|
|
549
|
+
* Returns `Infinity` when `compiledAtIso` is unparseable (forces a recompile,
|
|
550
|
+
* which is the safe default when we can't trust the stored timestamp). Returns
|
|
551
|
+
* 0 for future dates (clock-skew defensive).
|
|
552
|
+
*/
|
|
553
|
+
export function hoursSince(compiledAtIso, nowMs) {
|
|
554
|
+
const then = Date.parse(compiledAtIso);
|
|
555
|
+
if (Number.isNaN(then))
|
|
556
|
+
return Infinity;
|
|
557
|
+
const deltaMs = nowMs - then;
|
|
558
|
+
if (deltaMs <= 0)
|
|
559
|
+
return 0;
|
|
560
|
+
return deltaMs / (1000 * 60 * 60);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* The digest is stale if new claims have been written since it was compiled.
|
|
564
|
+
* Both inputs are Unix seconds.
|
|
565
|
+
*
|
|
566
|
+
* Falsely-equal or regressing values (clock skew, empty vault) return false —
|
|
567
|
+
* we only recompile on strictly-newer evidence.
|
|
568
|
+
*/
|
|
569
|
+
export function isDigestStale(digestVersion, currentMaxCreatedAtUnix) {
|
|
570
|
+
return currentMaxCreatedAtUnix > digestVersion;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Recompile guard (plan §15.10):
|
|
574
|
+
* trigger if countNewClaims >= 10 OR hoursSinceCompilation >= 24.
|
|
575
|
+
*
|
|
576
|
+
* The caller is still responsible for the in-memory "in progress" flag
|
|
577
|
+
* (see digest-sync.ts) — this is a pure predicate.
|
|
578
|
+
*/
|
|
579
|
+
export function shouldRecompile(input) {
|
|
580
|
+
const { countNewClaims, hoursSinceCompilation } = input;
|
|
581
|
+
return countNewClaims >= 10 || hoursSinceCompilation >= 24;
|
|
582
|
+
}
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// Entity trapdoors
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
/**
|
|
587
|
+
* Compute a single entity trapdoor: sha256("entity:" + normalized_name) as hex.
|
|
588
|
+
*
|
|
589
|
+
* Uses the same primitive (plain SHA-256, not HMAC) as word / stem trapdoors in
|
|
590
|
+
* `generateBlindIndices()`. The `entity:` prefix namespaces the result so a
|
|
591
|
+
* user called "postgresql" never collides with the word trapdoor for the token
|
|
592
|
+
* "postgresql". The search path must construct queries with the same prefix.
|
|
593
|
+
*
|
|
594
|
+
* Rationale for plain SHA-256 vs HMAC: the existing word trapdoor implementation
|
|
595
|
+
* in `rust/totalreclaw-core/src/blind.rs` uses plain SHA-256 of the normalized
|
|
596
|
+
* token (no dedup_key). For entity trapdoors to appear in the same blindIndices
|
|
597
|
+
* array and be findable by the current search pipeline, they must use the same
|
|
598
|
+
* primitive. Adopting HMAC for entities alone would break search consistency.
|
|
599
|
+
*/
|
|
600
|
+
export function computeEntityTrapdoor(name) {
|
|
601
|
+
const normalized = getWasm().normalizeEntityName(name);
|
|
602
|
+
return crypto
|
|
603
|
+
.createHash('sha256')
|
|
604
|
+
.update('entity:' + normalized)
|
|
605
|
+
.digest('hex');
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Compute entity trapdoors for every entity on a fact, deduplicated.
|
|
609
|
+
* Returns an empty array when the fact has no entities.
|
|
610
|
+
*/
|
|
611
|
+
export function computeEntityTrapdoors(entities) {
|
|
612
|
+
if (!entities || entities.length === 0)
|
|
613
|
+
return [];
|
|
614
|
+
const seen = new Set();
|
|
615
|
+
const out = [];
|
|
616
|
+
for (const e of entities) {
|
|
617
|
+
const td = computeEntityTrapdoor(e.name);
|
|
618
|
+
if (!seen.has(td)) {
|
|
619
|
+
seen.add(td);
|
|
620
|
+
out.push(td);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|