@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/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 (issue #116)
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
- * Two-signal acceptance rule, addressing issue #116 (rc.18 finding F1):
382
+ * Plain cosine cut-off: at least one reranked result has cosine similarity
383
+ * with the query embedding >= `cosineThreshold`.
383
384
  *
384
- * 1. **Cosine path** at least one reranked result has cosine similarity
385
- * with the query embedding >= `cosineThreshold`. This is the existing
386
- * semantic-relevance gate and remains the primary signal.
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
- * 2. **Lexical override** when cosine is below threshold (e.g. short
389
- * queries against the local Harrier-OSS-270m model produce embeddings
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(query, reranked, cosineThreshold) {
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
- if (maxCosine >= cosineThreshold)
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
  }
@@ -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
+ }