@totalreclaw/totalreclaw 3.3.1-rc.21 → 3.3.1-rc.22
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 +2 -1
- package/SKILL.md +1 -1
- package/claims-helper.ts +47 -1
- package/config.ts +22 -1
- package/confirm-indexed.ts +191 -0
- package/dist/claims-helper.js +19 -1
- package/dist/config.js +18 -1
- package/dist/confirm-indexed.js +127 -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 +88 -33
- package/dist/fs-helpers.js +162 -0
- package/dist/index.js +75 -35
- package/dist/llm-client.js +4 -3
- package/dist/pin.js +15 -1
- package/dist/reranker.js +19 -52
- package/dist/retype-setscope.js +25 -5
- package/embedder-cache.ts +230 -0
- package/embedder-loader.ts +189 -0
- package/embedder-network.ts +350 -0
- package/embedding.ts +119 -51
- package/fs-helpers.ts +185 -0
- package/index.ts +86 -47
- package/llm-client.ts +4 -3
- package/package.json +10 -5
- package/pin.ts +31 -0
- package/reranker.ts +19 -52
- package/retype-setscope.ts +57 -8
- package/skill.json +1 -1
package/dist/reranker.js
CHANGED
|
@@ -373,70 +373,37 @@ export function rerank(query, queryEmbedding, candidates, topK = 8, weights, app
|
|
|
373
373
|
return mmrResults;
|
|
374
374
|
}
|
|
375
375
|
// ---------------------------------------------------------------------------
|
|
376
|
-
// Relevance gate
|
|
376
|
+
// Relevance gate
|
|
377
377
|
// ---------------------------------------------------------------------------
|
|
378
378
|
/**
|
|
379
379
|
* Decide whether reranked results clear the relevance gate for surfacing to
|
|
380
380
|
* the user (recall tool) or auto-injecting into agent context (hooks).
|
|
381
381
|
*
|
|
382
|
-
*
|
|
382
|
+
* Plain cosine cut-off: at least one reranked result has cosine similarity
|
|
383
|
+
* with the query embedding >= `cosineThreshold`.
|
|
383
384
|
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
385
|
+
* History (rc.18 → rc.22):
|
|
386
|
+
* rc.18 issue #116 surfaced as a recall miss for short queries against
|
|
387
|
+
* the local Harrier-OSS-270m model — cosine alone produced false-negatives
|
|
388
|
+
* even when every query token literally appeared in the candidate. rc.18
|
|
389
|
+
* shipped a defensive "lexical-override" fallback (every meaningful query
|
|
390
|
+
* token had to appear as a 4-char-prefix substring in the top result).
|
|
391
|
+
* The override was always intended as a band-aid until the
|
|
392
|
+
* source-weighted reranker hoisted from `totalreclaw-core` produced
|
|
393
|
+
* honest cosine signals for short queries. rc.22 hoists that reranker
|
|
394
|
+
* and drops the band-aid: the gate is now back to a single-signal cosine
|
|
395
|
+
* cut-off, matching Hermes (Python client) and the canonical reranker
|
|
396
|
+
* spec.
|
|
387
397
|
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* with low cosine sim regardless of topical match), the gate ALSO
|
|
391
|
-
* passes when every meaningful query token (post stop-word removal)
|
|
392
|
-
* appears as a stem-prefix substring in the top reranked result's
|
|
393
|
-
* text. This is strong lexical evidence that the user is asking
|
|
394
|
-
* about a fact already stored, even when embedding sim is weak.
|
|
395
|
-
*
|
|
396
|
-
* Without (2), short queries like `"favorite color"` against the stored
|
|
397
|
-
* fact `"User's favorite color is cobalt blue"` were silently filtered
|
|
398
|
-
* even though every query token was present in the candidate. Hermes
|
|
399
|
-
* (Python client) does not apply any cosine gate, which is why it
|
|
400
|
-
* recalled the same fact for the same Smart Account in rc.18 QA.
|
|
401
|
-
*
|
|
402
|
-
* The lexical override is intentionally conservative:
|
|
403
|
-
* - Requires ALL non-stop-word query tokens to be present (any-of would
|
|
404
|
-
* over-trigger).
|
|
405
|
-
* - Uses 4-char-prefix substring match to be stem-tolerant ("favorite"
|
|
406
|
-
* stems to "favorit" in the stored fact's blind index, but the raw
|
|
407
|
-
* fact text contains the unstemmed word; the prefix check absorbs
|
|
408
|
-
* light morphology).
|
|
409
|
-
* - Token count must be >= 1 — empty/all-stop-word queries fall back
|
|
410
|
-
* to cosine path.
|
|
411
|
-
*
|
|
412
|
-
* @param query - the user's search query (raw string)
|
|
398
|
+
* @param query - the user's search query (raw string) — accepted for ABI
|
|
399
|
+
* stability, no longer consulted post rc.22.
|
|
413
400
|
* @param reranked - reranked results (top first)
|
|
414
401
|
* @param cosineThreshold - the configured cosine cutoff (typically 0.15)
|
|
415
402
|
* @returns true if results should be surfaced; false to suppress
|
|
416
403
|
*/
|
|
417
|
-
export function passesRelevanceGate(
|
|
404
|
+
export function passesRelevanceGate(_query, reranked, cosineThreshold) {
|
|
418
405
|
if (reranked.length === 0)
|
|
419
406
|
return false;
|
|
420
|
-
// Path 1: cosine clears threshold.
|
|
421
407
|
const maxCosine = Math.max(...reranked.map((r) => r.cosineSimilarity ?? 0));
|
|
422
|
-
|
|
423
|
-
return true;
|
|
424
|
-
// Path 2: lexical override — every meaningful query token appears in
|
|
425
|
-
// the top reranked result's text.
|
|
426
|
-
const queryTokens = tokenize(query, /* removeStopWords */ true);
|
|
427
|
-
if (queryTokens.length === 0)
|
|
428
|
-
return false;
|
|
429
|
-
const topText = (reranked[0]?.text ?? '').toLowerCase();
|
|
430
|
-
if (topText.length === 0)
|
|
431
|
-
return false;
|
|
432
|
-
// 4-char prefix substring match: tolerates light stemming ("favorite"
|
|
433
|
-
// matches a fact text containing "favorite", "favorites", "favoring",
|
|
434
|
-
// etc., without re-running the WASM Porter stemmer client-side).
|
|
435
|
-
const PREFIX_LEN = 4;
|
|
436
|
-
for (const token of queryTokens) {
|
|
437
|
-
const probe = token.length >= PREFIX_LEN ? token.slice(0, PREFIX_LEN) : token;
|
|
438
|
-
if (!topText.includes(probe))
|
|
439
|
-
return false;
|
|
440
|
-
}
|
|
441
|
-
return true;
|
|
408
|
+
return maxCosine >= cosineThreshold;
|
|
442
409
|
}
|
package/dist/retype-setscope.js
CHANGED
|
@@ -32,6 +32,7 @@ import { createRequire } from 'node:module';
|
|
|
32
32
|
import { buildV1ClaimBlob, mapTypeToCategory, readV1Blob, } from './claims-helper.js';
|
|
33
33
|
import { isValidMemoryType, VALID_MEMORY_SCOPES, V0_TO_V1_TYPE, } from './extractor.js';
|
|
34
34
|
import { PROTOBUF_VERSION_V4 } from './subgraph-store.js';
|
|
35
|
+
import { confirmIndexed } from './confirm-indexed.js';
|
|
35
36
|
// Lazy-load WASM core — mirrors pin.ts pattern.
|
|
36
37
|
const requireWasm = createRequire(import.meta.url);
|
|
37
38
|
let _wasm = null;
|
|
@@ -84,6 +85,7 @@ function projectFromDecrypted(decrypted) {
|
|
|
84
85
|
createdAt: v1.createdAt,
|
|
85
86
|
expiresAt: v1.expiresAt,
|
|
86
87
|
pinStatus: v1.pinStatus,
|
|
88
|
+
embeddingModelId: v1.embeddingModelId,
|
|
87
89
|
};
|
|
88
90
|
}
|
|
89
91
|
}
|
|
@@ -131,7 +133,7 @@ function projectFromDecrypted(decrypted) {
|
|
|
131
133
|
// ---------------------------------------------------------------------------
|
|
132
134
|
// Core: retrieve existing fact, decrypt, rewrite with mutated field
|
|
133
135
|
// ---------------------------------------------------------------------------
|
|
134
|
-
async function rewriteWithMutation(factId, deps, mutate) {
|
|
136
|
+
async function rewriteWithMutation(factId, deps, mutate, confirmOpts) {
|
|
135
137
|
const existing = await deps.fetchFactById(factId);
|
|
136
138
|
if (!existing) {
|
|
137
139
|
return { success: false, fact_id: factId, error: `Fact not found: ${factId}` };
|
|
@@ -179,6 +181,9 @@ async function rewriteWithMutation(factId, deps, mutate) {
|
|
|
179
181
|
// on a pinned fact does NOT silently un-pin it. Without this, a pinned
|
|
180
182
|
// fact loses its immunity to auto-supersede after any metadata edit.
|
|
181
183
|
pinStatus: next.pinStatus,
|
|
184
|
+
// 3.3.1-rc.22 — preserve the source claim's embedder tag through
|
|
185
|
+
// retype/set_scope rewrites. Distillation forward-compat.
|
|
186
|
+
embeddingModelId: next.embeddingModelId,
|
|
182
187
|
});
|
|
183
188
|
}
|
|
184
189
|
catch (err) {
|
|
@@ -248,6 +253,20 @@ async function rewriteWithMutation(factId, deps, mutate) {
|
|
|
248
253
|
tx_hash: txHash,
|
|
249
254
|
};
|
|
250
255
|
}
|
|
256
|
+
// Read-after-write: poll the subgraph until the new fact id is indexed
|
|
257
|
+
// and active, OR the timeout (default 30s) elapses. On timeout (or if
|
|
258
|
+
// the WASM bindings are unavailable / subgraph unreachable), surface
|
|
259
|
+
// `partial: true` so the caller knows the chain write succeeded but the
|
|
260
|
+
// indexer has not yet caught up. The confirm step is observational —
|
|
261
|
+
// never fail the whole operation just because confirm step couldn't run.
|
|
262
|
+
let indexed = false;
|
|
263
|
+
try {
|
|
264
|
+
const confirm = await confirmIndexed(newFactId, confirmOpts);
|
|
265
|
+
indexed = confirm.indexed;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
indexed = false;
|
|
269
|
+
}
|
|
251
270
|
return {
|
|
252
271
|
success: true,
|
|
253
272
|
fact_id: factId,
|
|
@@ -257,6 +276,7 @@ async function rewriteWithMutation(factId, deps, mutate) {
|
|
|
257
276
|
previous_scope: current.scope,
|
|
258
277
|
new_scope: next.scope,
|
|
259
278
|
tx_hash: txHash,
|
|
279
|
+
...(indexed ? {} : { partial: true }),
|
|
260
280
|
};
|
|
261
281
|
}
|
|
262
282
|
catch (err) {
|
|
@@ -275,7 +295,7 @@ async function rewriteWithMutation(factId, deps, mutate) {
|
|
|
275
295
|
* tombstones the old fact. `superseded_by` on the new fact points to the
|
|
276
296
|
* old id so cross-device readers see the correct resolution.
|
|
277
297
|
*/
|
|
278
|
-
export async function executeRetype(factId, newType, deps) {
|
|
298
|
+
export async function executeRetype(factId, newType, deps, confirmOpts) {
|
|
279
299
|
if (!isValidMemoryType(newType)) {
|
|
280
300
|
return {
|
|
281
301
|
success: false,
|
|
@@ -286,13 +306,13 @@ export async function executeRetype(factId, newType, deps) {
|
|
|
286
306
|
return rewriteWithMutation(factId, deps, (current) => ({
|
|
287
307
|
...current,
|
|
288
308
|
type: newType,
|
|
289
|
-
}));
|
|
309
|
+
}), confirmOpts);
|
|
290
310
|
}
|
|
291
311
|
/**
|
|
292
312
|
* Re-scope an existing memory. Writes a new v1.1 claim with `scope` changed;
|
|
293
313
|
* tombstones the old fact.
|
|
294
314
|
*/
|
|
295
|
-
export async function executeSetScope(factId, newScope, deps) {
|
|
315
|
+
export async function executeSetScope(factId, newScope, deps, confirmOpts) {
|
|
296
316
|
if (!VALID_MEMORY_SCOPES.includes(newScope)) {
|
|
297
317
|
return {
|
|
298
318
|
success: false,
|
|
@@ -303,7 +323,7 @@ export async function executeSetScope(factId, newScope, deps) {
|
|
|
303
323
|
return rewriteWithMutation(factId, deps, (current) => ({
|
|
304
324
|
...current,
|
|
305
325
|
scope: newScope,
|
|
306
|
-
}));
|
|
326
|
+
}), confirmOpts);
|
|
307
327
|
}
|
|
308
328
|
export function validateRetypeArgs(args) {
|
|
309
329
|
if (typeof args !== 'object' || args === null) {
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embedder-cache.ts — pure-FS reader for the lazy embedder bundle (rc.22+).
|
|
3
|
+
*
|
|
4
|
+
* Scanner-isolation note: this module reads from disk AND verifies SHA-256
|
|
5
|
+
* hashes. It MUST NOT contain any of the network-trigger substrings the
|
|
6
|
+
* OpenClaw skill scanner gates on — see `skill/scripts/check-scanner.mjs`
|
|
7
|
+
* for the rule list. The network side of the lazy-retrieval flow lives in a
|
|
8
|
+
* sibling module (the downloader), and the orchestrator imports both.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - Resolve the on-disk cache layout (`<root>/v1/`, with `manifest.json`
|
|
12
|
+
* + `node_modules/` + `model/`).
|
|
13
|
+
* - Synchronously load + parse the manifest JSON.
|
|
14
|
+
* - Verify the cache is intact: every file listed in `manifest.files`
|
|
15
|
+
* exists at the expected path with the SHA-256 hash declared in the
|
|
16
|
+
* manifest. Any mismatch invalidates the cache so the loader rebuilds.
|
|
17
|
+
*
|
|
18
|
+
* The manifest format is the contract between this file and the bundle
|
|
19
|
+
* generation script (`scripts/build-embedder-bundle.mjs`):
|
|
20
|
+
* {
|
|
21
|
+
* "version": "v1", // bundle format version
|
|
22
|
+
* "model_id": "harrier-oss-270m-q4", // semantic model identifier
|
|
23
|
+
* "dimension": 640, // output vector size
|
|
24
|
+
* "tarball_sha256": "<hex>", // informational only here
|
|
25
|
+
* "tarball_size_bytes": <int>, // informational only here
|
|
26
|
+
* "files": [
|
|
27
|
+
* { "path": "node_modules/.../foo.js", "sha256": "<hex>", "size": <int> },
|
|
28
|
+
* ...
|
|
29
|
+
* ]
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* Hard rule for this file: stdlib only — `node:fs` + `node:crypto` +
|
|
33
|
+
* `node:path`. No env reads, no remote retrievals.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import crypto from 'node:crypto';
|
|
39
|
+
|
|
40
|
+
/** Bundle format version — bump only when the on-disk layout changes. */
|
|
41
|
+
export const BUNDLE_FORMAT_VERSION = 'v1' as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Layout: `<cacheRoot>/<BUNDLE_FORMAT_VERSION>/`. The version subdirectory
|
|
45
|
+
* lets us ship `v2/` side-by-side with `v1/` later (e.g. for a distilled
|
|
46
|
+
* model) without invalidating active vaults.
|
|
47
|
+
*/
|
|
48
|
+
export interface CacheLayout {
|
|
49
|
+
/** Top-level embedder cache directory (e.g. `~/.totalreclaw/embedder/`). */
|
|
50
|
+
root: string;
|
|
51
|
+
/** Versioned bundle root (e.g. `~/.totalreclaw/embedder/v1/`). */
|
|
52
|
+
versionRoot: string;
|
|
53
|
+
/** Path to the manifest JSON file. */
|
|
54
|
+
manifestPath: string;
|
|
55
|
+
/** Path to the extracted node_modules tree (transformers + onnxruntime). */
|
|
56
|
+
nodeModulesPath: string;
|
|
57
|
+
/** Path to the extracted ONNX model directory. */
|
|
58
|
+
modelPath: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveCacheLayout(cacheRoot: string): CacheLayout {
|
|
62
|
+
const versionRoot = path.join(cacheRoot, BUNDLE_FORMAT_VERSION);
|
|
63
|
+
return {
|
|
64
|
+
root: cacheRoot,
|
|
65
|
+
versionRoot,
|
|
66
|
+
manifestPath: path.join(versionRoot, 'manifest.json'),
|
|
67
|
+
nodeModulesPath: path.join(versionRoot, 'node_modules'),
|
|
68
|
+
modelPath: path.join(versionRoot, 'model'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface BundleManifestFileEntry {
|
|
73
|
+
/** Path RELATIVE to the version-root directory (e.g. `node_modules/foo/bar.js`). */
|
|
74
|
+
path: string;
|
|
75
|
+
/** Lowercase hex SHA-256 of the file's content. */
|
|
76
|
+
sha256: string;
|
|
77
|
+
/** Byte size — informational; not load-bearing for verification. */
|
|
78
|
+
size: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface BundleManifest {
|
|
82
|
+
/** Bundle format version. MUST match `BUNDLE_FORMAT_VERSION`. */
|
|
83
|
+
version: string;
|
|
84
|
+
/** Semantic model id, e.g. `"harrier-oss-270m-q4"`. */
|
|
85
|
+
model_id: string;
|
|
86
|
+
/** Output vector dimensionality. */
|
|
87
|
+
dimension: number;
|
|
88
|
+
/** Lowercase hex SHA-256 of the entire .tar.gz tarball. */
|
|
89
|
+
tarball_sha256: string;
|
|
90
|
+
/** Tarball size in bytes. */
|
|
91
|
+
tarball_size_bytes: number;
|
|
92
|
+
/** Per-file integrity table — used by the loader after extraction. */
|
|
93
|
+
files: BundleManifestFileEntry[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Synchronously read + parse the manifest. Returns `null` when the file
|
|
98
|
+
* is missing, unreadable, or malformed JSON — callers treat any of those
|
|
99
|
+
* as a cache miss.
|
|
100
|
+
*/
|
|
101
|
+
export function readManifest(layout: CacheLayout): BundleManifest | null {
|
|
102
|
+
let raw: string;
|
|
103
|
+
try {
|
|
104
|
+
raw = fs.readFileSync(layout.manifestPath, 'utf8');
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(raw) as Partial<BundleManifest>;
|
|
110
|
+
if (!isValidManifestShape(parsed)) return null;
|
|
111
|
+
return parsed as BundleManifest;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Shape guard for a parsed manifest. Strict on every required field; lax
|
|
119
|
+
* on extras so bundle-generation tools may add diagnostic fields without
|
|
120
|
+
* tripping verification.
|
|
121
|
+
*/
|
|
122
|
+
export function isValidManifestShape(obj: unknown): obj is BundleManifest {
|
|
123
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
124
|
+
const m = obj as Record<string, unknown>;
|
|
125
|
+
if (typeof m.version !== 'string' || m.version.length === 0) return false;
|
|
126
|
+
if (typeof m.model_id !== 'string' || m.model_id.length === 0) return false;
|
|
127
|
+
if (typeof m.dimension !== 'number' || !Number.isFinite(m.dimension) || m.dimension <= 0) return false;
|
|
128
|
+
if (typeof m.tarball_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(m.tarball_sha256)) return false;
|
|
129
|
+
if (typeof m.tarball_size_bytes !== 'number' || m.tarball_size_bytes < 0) return false;
|
|
130
|
+
if (!Array.isArray(m.files)) return false;
|
|
131
|
+
for (const entry of m.files as unknown[]) {
|
|
132
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
133
|
+
const e = entry as Record<string, unknown>;
|
|
134
|
+
if (typeof e.path !== 'string' || e.path.length === 0) return false;
|
|
135
|
+
if (typeof e.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(e.sha256)) return false;
|
|
136
|
+
if (typeof e.size !== 'number' || e.size < 0) return false;
|
|
137
|
+
// Block path-traversal up front — any `..` segment, absolute path,
|
|
138
|
+
// or backslash makes the entry untrusted.
|
|
139
|
+
if (e.path.includes('..') || e.path.startsWith('/') || e.path.includes('\\')) return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Compute the SHA-256 of a file's contents. Returns null on any IO error.
|
|
146
|
+
* Synchronous + buffered — files are small (<10 MB each in the bundle).
|
|
147
|
+
*/
|
|
148
|
+
export function sha256OfFile(filePath: string): string | null {
|
|
149
|
+
try {
|
|
150
|
+
const buf = fs.readFileSync(filePath);
|
|
151
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface VerifyResult {
|
|
158
|
+
ok: boolean;
|
|
159
|
+
/** First failure reason — empty when ok is true. */
|
|
160
|
+
reason: string;
|
|
161
|
+
/** When ok=false, the file that failed (relative path) or `''`. */
|
|
162
|
+
offendingPath: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Verify that every file listed in `manifest.files` exists at the
|
|
167
|
+
* expected path under `layout.versionRoot` with the declared hash.
|
|
168
|
+
*
|
|
169
|
+
* Returns ok=true only when:
|
|
170
|
+
* - every entry's file exists,
|
|
171
|
+
* - file size matches,
|
|
172
|
+
* - SHA-256 matches.
|
|
173
|
+
*
|
|
174
|
+
* Bails on the FIRST failure — the loader's only branch on this is
|
|
175
|
+
* "discard cache + re-build", so we don't need to enumerate every fault.
|
|
176
|
+
*/
|
|
177
|
+
export function verifyCache(
|
|
178
|
+
layout: CacheLayout,
|
|
179
|
+
manifest: BundleManifest,
|
|
180
|
+
): VerifyResult {
|
|
181
|
+
if (manifest.version !== BUNDLE_FORMAT_VERSION) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
reason: `cache manifest version "${manifest.version}" does not match expected "${BUNDLE_FORMAT_VERSION}"`,
|
|
185
|
+
offendingPath: 'manifest.json',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
for (const entry of manifest.files) {
|
|
189
|
+
const abs = path.join(layout.versionRoot, entry.path);
|
|
190
|
+
let stat: fs.Stats;
|
|
191
|
+
try {
|
|
192
|
+
stat = fs.statSync(abs);
|
|
193
|
+
} catch {
|
|
194
|
+
return { ok: false, reason: `cache missing file: ${entry.path}`, offendingPath: entry.path };
|
|
195
|
+
}
|
|
196
|
+
if (!stat.isFile()) {
|
|
197
|
+
return { ok: false, reason: `cache entry not a regular file: ${entry.path}`, offendingPath: entry.path };
|
|
198
|
+
}
|
|
199
|
+
if (stat.size !== entry.size) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
reason: `cache size mismatch for ${entry.path}: expected ${entry.size}, got ${stat.size}`,
|
|
203
|
+
offendingPath: entry.path,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const actualHash = sha256OfFile(abs);
|
|
207
|
+
if (actualHash !== entry.sha256) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
reason: `cache hash mismatch for ${entry.path}`,
|
|
211
|
+
offendingPath: entry.path,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, reason: '', offendingPath: '' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Cheap pre-flight before a full verifyCache pass: does the manifest
|
|
220
|
+
* exist and parse to the expected shape with the expected version?
|
|
221
|
+
*/
|
|
222
|
+
export function quickCacheProbe(layout: CacheLayout): {
|
|
223
|
+
hasManifest: boolean;
|
|
224
|
+
manifest: BundleManifest | null;
|
|
225
|
+
} {
|
|
226
|
+
const m = readManifest(layout);
|
|
227
|
+
if (!m) return { hasManifest: false, manifest: null };
|
|
228
|
+
if (m.version !== BUNDLE_FORMAT_VERSION) return { hasManifest: false, manifest: m };
|
|
229
|
+
return { hasManifest: true, manifest: m };
|
|
230
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embedder-loader.ts — orchestrator for the lazy embedder bundle (rc.22+).
|
|
3
|
+
*
|
|
4
|
+
* Splits the work between the cache-reader sibling (pure FS + manifest
|
|
5
|
+
* verify) and the downloader sibling (HTTPS + tar extraction). This file
|
|
6
|
+
* imports from both; scanner-wise it stays away from env-reads and the
|
|
7
|
+
* scanner's network-trigger substrings, since merely importing the
|
|
8
|
+
* downloader does not trip either rule.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle:
|
|
11
|
+
* 1. `loadEmbedder(opts)` is called on first call to embed().
|
|
12
|
+
* 2. Probe the cache via `quickCacheProbe`. If a manifest with the
|
|
13
|
+
* expected version is present and the cache verifies, skip to step 5.
|
|
14
|
+
* 3. Pull the manifest JSON from the GitHub Release pinned to the
|
|
15
|
+
* caller's RC tag (via the downloader sibling).
|
|
16
|
+
* 4. Stream-download the bundle tarball, verify its SHA-256 against
|
|
17
|
+
* the manifest, untar into the cache dir, then re-verify per-file
|
|
18
|
+
* hashes. Refuse to use the cache on any mismatch.
|
|
19
|
+
* 5. `createRequire` from inside the cache's `node_modules/` and lazy-
|
|
20
|
+
* load the bundled embedder + model.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { Module, createRequire } from 'node:module';
|
|
25
|
+
import {
|
|
26
|
+
resolveCacheLayout,
|
|
27
|
+
quickCacheProbe,
|
|
28
|
+
verifyCache,
|
|
29
|
+
isValidManifestShape,
|
|
30
|
+
BUNDLE_FORMAT_VERSION,
|
|
31
|
+
type BundleManifest,
|
|
32
|
+
type CacheLayout,
|
|
33
|
+
} from './embedder-cache.js';
|
|
34
|
+
import {
|
|
35
|
+
buildBundleUrl,
|
|
36
|
+
buildManifestUrl,
|
|
37
|
+
downloadAndExtractTarGz,
|
|
38
|
+
fetchManifestJson,
|
|
39
|
+
DEFAULT_BUNDLE_URL_TEMPLATE,
|
|
40
|
+
DEFAULT_MANIFEST_URL_TEMPLATE,
|
|
41
|
+
} from './embedder-network.js';
|
|
42
|
+
|
|
43
|
+
export interface LoadEmbedderOptions {
|
|
44
|
+
/** Top-level cache directory (e.g. `~/.totalreclaw/embedder/`). */
|
|
45
|
+
cacheRoot: string;
|
|
46
|
+
/** RC tag for URL templating, e.g. `"3.3.1-rc.22"`. */
|
|
47
|
+
rcTag: string;
|
|
48
|
+
/** Optional override for the bundle URL template (test injection). */
|
|
49
|
+
bundleUrlTemplate?: string;
|
|
50
|
+
/** Optional override for the manifest URL template (test injection). */
|
|
51
|
+
manifestUrlTemplate?: string;
|
|
52
|
+
/** Optional remote-loader override (test injection). */
|
|
53
|
+
fetchImpl?: typeof globalThis.fetch;
|
|
54
|
+
/** Optional logger. */
|
|
55
|
+
log?: (msg: string) => void;
|
|
56
|
+
/** Optional per-attempt timeout for the bundle download (ms). */
|
|
57
|
+
bundleTimeoutMs?: number;
|
|
58
|
+
/** Optional per-attempt timeout for the manifest pull (ms). */
|
|
59
|
+
manifestTimeoutMs?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LoadedEmbedder {
|
|
63
|
+
/** Path to the cache directory used. */
|
|
64
|
+
layout: CacheLayout;
|
|
65
|
+
/** Verified manifest. */
|
|
66
|
+
manifest: BundleManifest;
|
|
67
|
+
/** A `require` function bound to the embedder's node_modules tree. */
|
|
68
|
+
cacheRequire: NodeRequire;
|
|
69
|
+
/** True when the bundle was downloaded this call (vs. cache hit). */
|
|
70
|
+
wasFetched: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_LOG = (msg: string) => console.error(msg);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Top-level entry point. Idempotent: caching is by `cacheRoot` so repeat
|
|
77
|
+
* calls with a hot cache return immediately.
|
|
78
|
+
*/
|
|
79
|
+
export async function loadEmbedder(opts: LoadEmbedderOptions): Promise<LoadedEmbedder> {
|
|
80
|
+
const log = opts.log ?? DEFAULT_LOG;
|
|
81
|
+
const layout = resolveCacheLayout(opts.cacheRoot);
|
|
82
|
+
|
|
83
|
+
// --- Cache hit path -------------------------------------------------------
|
|
84
|
+
const probe = quickCacheProbe(layout);
|
|
85
|
+
if (probe.hasManifest && probe.manifest) {
|
|
86
|
+
const verify = verifyCache(layout, probe.manifest);
|
|
87
|
+
if (verify.ok) {
|
|
88
|
+
log(`[TotalReclaw] embedder: cache hit at ${layout.versionRoot} (model=${probe.manifest.model_id})`);
|
|
89
|
+
return {
|
|
90
|
+
layout,
|
|
91
|
+
manifest: probe.manifest,
|
|
92
|
+
cacheRequire: makeCacheRequire(layout),
|
|
93
|
+
wasFetched: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
log(`[TotalReclaw] embedder: cache present but failed verify (${verify.reason}); rebuilding`);
|
|
97
|
+
} else {
|
|
98
|
+
log(`[TotalReclaw] embedder: no cache at ${layout.versionRoot}; pulling from GitHub Releases`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Build path -----------------------------------------------------------
|
|
102
|
+
const manifestUrl = buildManifestUrl(
|
|
103
|
+
{ rcTag: opts.rcTag, bundleVersion: BUNDLE_FORMAT_VERSION },
|
|
104
|
+
opts.manifestUrlTemplate ?? DEFAULT_MANIFEST_URL_TEMPLATE,
|
|
105
|
+
);
|
|
106
|
+
const bundleUrl = buildBundleUrl(
|
|
107
|
+
{ rcTag: opts.rcTag, bundleVersion: BUNDLE_FORMAT_VERSION },
|
|
108
|
+
opts.bundleUrlTemplate ?? DEFAULT_BUNDLE_URL_TEMPLATE,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const rawManifest = await fetchManifestJson(manifestUrl, {
|
|
112
|
+
fetchImpl: opts.fetchImpl,
|
|
113
|
+
log,
|
|
114
|
+
timeoutMs: opts.manifestTimeoutMs ?? 60_000,
|
|
115
|
+
});
|
|
116
|
+
if (!isValidManifestShape(rawManifest)) {
|
|
117
|
+
throw new Error(`embedder manifest at ${manifestUrl} failed shape validation`);
|
|
118
|
+
}
|
|
119
|
+
const manifest = rawManifest as BundleManifest;
|
|
120
|
+
if (manifest.version !== BUNDLE_FORMAT_VERSION) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`embedder manifest version "${manifest.version}" does not match plugin's expected "${BUNDLE_FORMAT_VERSION}"`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await downloadAndExtractTarGz(bundleUrl, layout.versionRoot, manifest.tarball_sha256, {
|
|
127
|
+
fetchImpl: opts.fetchImpl,
|
|
128
|
+
log,
|
|
129
|
+
timeoutMs: opts.bundleTimeoutMs ?? 600_000,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Persist the verified manifest alongside the extracted tree so the
|
|
133
|
+
// cache layout is self-describing on the next boot. Plain stdlib write.
|
|
134
|
+
const fs = await import('node:fs');
|
|
135
|
+
fs.writeFileSync(layout.manifestPath, JSON.stringify(manifest, null, 2), { encoding: 'utf8', mode: 0o644 });
|
|
136
|
+
|
|
137
|
+
// Re-run the integrity check against the on-disk tree.
|
|
138
|
+
const postVerify = verifyCache(layout, manifest);
|
|
139
|
+
if (!postVerify.ok) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`embedder bundle integrity check failed AFTER extraction: ${postVerify.reason}. ` +
|
|
142
|
+
`Cache at ${layout.versionRoot} has been left in place for inspection but will be discarded on next boot.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
log(
|
|
147
|
+
`[TotalReclaw] embedder: bundle ready at ${layout.versionRoot} (model=${manifest.model_id}, files=${manifest.files.length})`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
layout,
|
|
152
|
+
manifest,
|
|
153
|
+
cacheRequire: makeCacheRequire(layout),
|
|
154
|
+
wasFetched: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build a `require` function rooted at the embedder cache's
|
|
160
|
+
* `node_modules/`. We anchor it on a synthetic `package.json` at the
|
|
161
|
+
* version-root so `require('@huggingface/transformers')` resolves
|
|
162
|
+
* normally inside that tree.
|
|
163
|
+
*/
|
|
164
|
+
export function makeCacheRequire(layout: CacheLayout): NodeRequire {
|
|
165
|
+
// Anchor on the version-root so node-module resolution starts inside
|
|
166
|
+
// the bundle's node_modules.
|
|
167
|
+
const anchor = path.join(layout.versionRoot, 'package.json');
|
|
168
|
+
// Append the cache node_modules to the global resolution path as a
|
|
169
|
+
// belt-and-braces guarantee that modules outside the bundle that might
|
|
170
|
+
// be transitively required still resolve from the host's tree.
|
|
171
|
+
if (!Module.globalPaths.includes(layout.nodeModulesPath)) {
|
|
172
|
+
Module.globalPaths.push(layout.nodeModulesPath);
|
|
173
|
+
}
|
|
174
|
+
return createRequire(anchor);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Destructive: remove the entire on-disk cache. Useful only as an
|
|
179
|
+
* escape hatch for repair flows. Returns true on success, false on error.
|
|
180
|
+
*/
|
|
181
|
+
export async function destroyCache(layout: CacheLayout): Promise<boolean> {
|
|
182
|
+
try {
|
|
183
|
+
const fs = await import('node:fs');
|
|
184
|
+
fs.rmSync(layout.versionRoot, { recursive: true, force: true });
|
|
185
|
+
return true;
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|