@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/embedding.js CHANGED
@@ -1,26 +1,38 @@
1
1
  /**
2
- * TotalReclaw Plugin - Local Embedding via @huggingface/transformers
2
+ * TotalReclaw Plugin - Local Embedding via lazy GitHub-Releases bundle
3
3
  *
4
- * Generates text embeddings locally using an ONNX model. No API key needed,
5
- * no data leaves the machine. Preserves the E2EE guarantee.
4
+ * Generates text embeddings locally using an ONNX model. Preserves the
5
+ * E2EE guarantee embeddings are computed on the user's machine and
6
+ * never leave it. The model itself, plus the heavy native dependencies
7
+ * (`@huggingface/transformers`, `onnxruntime-node`), is fetched on
8
+ * first use from a versioned GitHub Release tarball rather than shipped
9
+ * inside the npm/ClawHub plugin tarball.
6
10
  *
7
- * Locked to Harrier-OSS-v1-270M (640d, q4, ~344MB, pre-pooled). Changing the
8
- * embedding model breaks search across an existing vault, so the
9
- * `TOTALRECLAW_EMBEDDING_MODEL` user-facing env var was removed in v1.
11
+ * Why lazy retrieval (rc.22):
12
+ * rc.21 OOM-killed the OpenClaw gateway during `openclaw plugins install`
13
+ * on a 3.7 GB Hetzner VPS the heavy native deps required ~700 MB+
14
+ * peak install RAM, and a partial install left orphaned
15
+ * `~/.openclaw/extensions/.openclaw-install-stage-*` directories that
16
+ * the loader then auto-discovered on every boot, crashing the CLI.
17
+ * rc.22 splits the heavy bits out of the install path: the plugin
18
+ * tarball stays ~5-10 MB (ClawHub-friendly), the model + native deps
19
+ * are downloaded lazily when the user actually invokes a memory tool,
20
+ * and per-turn OOM is recoverable in a way install-time OOM is not.
10
21
  *
11
- * Dependencies: @huggingface/transformers
22
+ * Locked to Harrier-OSS-v1-270M (640d, q4, ~344MB, pre-pooled). Changing
23
+ * the embedding model breaks search across an existing vault, so the
24
+ * `TOTALRECLAW_EMBEDDING_MODEL` user-facing env var was removed in v1.
12
25
  *
13
- * Download UX (rc.16, fixes #92):
14
- * First-call download is wrapped via `downloadWithUX` from `download-ux.ts`
15
- * configurable timeout (`TOTALRECLAW_ONNX_INSTALL_TIMEOUT`, default 600s),
16
- * 60s keep-alive, 3-attempt exponential-backoff retry, loud actionable
17
- * failure. Slow-bandwidth hosts no longer see a silent freeze.
26
+ * Forward-compat (rc.22): every claim is tagged with `embedding_model_id`
27
+ * (see `getEmbeddingModelId()`) so a future distillation can be detected
28
+ * and rescoped per claim without breaking the active vault.
18
29
  */
19
- // @ts-ignore - @huggingface/transformers types may not be perfect
20
- import { AutoTokenizer, AutoModel, pipeline } from '@huggingface/transformers';
21
- import { downloadWithUX, getDownloadTimeoutMs } from './download-ux.js';
30
+ import os from 'node:os';
31
+ import path from 'node:path';
32
+ import { loadEmbedder } from './embedder-loader.js';
22
33
  const HARRIER_MODEL = {
23
- id: 'onnx-community/harrier-oss-v1-270m-ONNX',
34
+ semanticId: 'harrier-oss-270m-q4',
35
+ hfId: 'onnx-community/harrier-oss-v1-270m-ONNX',
24
36
  dims: 640,
25
37
  pooling: 'sentence_embedding',
26
38
  size: '~344MB',
@@ -29,7 +41,24 @@ const HARRIER_MODEL = {
29
41
  function getModelConfig() {
30
42
  return HARRIER_MODEL;
31
43
  }
32
- /** Lazily initialized model instances. */
44
+ let runtimeConfig = null;
45
+ export function configureEmbedder(cfg) {
46
+ runtimeConfig = cfg;
47
+ }
48
+ /**
49
+ * Default cache root. Used when `configureEmbedder()` was not called —
50
+ * production code always calls it from index.ts; tests may rely on this
51
+ * default.
52
+ */
53
+ function defaultCacheRoot() {
54
+ return path.join(os.homedir(), '.totalreclaw', 'embedder');
55
+ }
56
+ function activeRuntimeConfig() {
57
+ if (runtimeConfig)
58
+ return runtimeConfig;
59
+ return { cacheRoot: defaultCacheRoot(), rcTag: '0.0.0-dev' };
60
+ }
61
+ /** Lazily initialized state. */
33
62
  let pipelineExtractor = null;
34
63
  let autoTokenizer = null;
35
64
  let autoModel = null;
@@ -37,39 +66,53 @@ let activeModel = null;
37
66
  /**
38
67
  * Generate an embedding vector for the given text.
39
68
  *
40
- * On first call, downloads and loads the ONNX model (cached after download).
41
- * Subsequent calls reuse the loaded model and run in ~100ms.
69
+ * On first call, downloads the embedder bundle (transformers + onnxruntime
70
+ * + the q4 ONNX model) from the pinned GitHub Release, verifies the
71
+ * tarball SHA-256 against the manifest, extracts to
72
+ * `~/.totalreclaw/embedder/v1/`, then loads the model into memory.
73
+ * Subsequent calls reuse the loaded model and run in ~100 ms.
42
74
  */
43
75
  export async function generateEmbedding(text, options) {
44
76
  if (!activeModel) {
45
77
  activeModel = getModelConfig();
46
- const timeoutSec = Math.floor(getDownloadTimeoutMs() / 1000);
47
- console.error(`[TotalReclaw] Downloading embedding model (${activeModel.size}) this may take a few minutes on slower connections. Please wait.`);
48
- console.error(`[TotalReclaw] One-time setup. Per-attempt timeout: ${timeoutSec}s (configurable via TOTALRECLAW_ONNX_INSTALL_TIMEOUT). Cached after first download.`);
78
+ const cfg = activeRuntimeConfig();
79
+ console.error(`[TotalReclaw] Embedding model first-call: fetching bundle ${activeModel.size} from GitHub Releases for v${cfg.rcTag} (cached at ${cfg.cacheRoot}).`);
80
+ const loaded = await loadEmbedder({
81
+ cacheRoot: cfg.cacheRoot,
82
+ rcTag: cfg.rcTag,
83
+ });
84
+ if (loaded.manifest.dimension !== activeModel.dims) {
85
+ throw new Error(`embedder bundle dimension ${loaded.manifest.dimension} does not match plugin-expected ${activeModel.dims}. ` +
86
+ `Refusing to use mismatched embedder — vector space drift would corrupt cosine search.`);
87
+ }
88
+ if (loaded.manifest.model_id !== activeModel.semanticId) {
89
+ console.error(`[TotalReclaw] WARNING: bundled model_id "${loaded.manifest.model_id}" != plugin-expected "${activeModel.semanticId}". Continuing — distillation forward-compat path.`);
90
+ }
91
+ // Resolve the transformers entrypoint via the cache-bound require.
92
+ // The bundled package was generated by `scripts/build-embedder-bundle.mjs`
93
+ // and lives at `<cache>/v1/node_modules/@huggingface/transformers`.
94
+ const transformers = loaded.cacheRequire('@huggingface/transformers');
95
+ const { AutoTokenizer, AutoModel, pipeline } = transformers;
49
96
  if (activeModel.pooling === 'sentence_embedding') {
50
- // Harrier: use AutoModel (pipeline doesn't support sentence_embedding output)
51
- autoTokenizer = await downloadWithUX('tokenizer', () => AutoTokenizer.from_pretrained(activeModel.id));
52
- autoModel = await downloadWithUX('embedding model', () => AutoModel.from_pretrained(activeModel.id, {
97
+ autoTokenizer = await AutoTokenizer.from_pretrained(activeModel.hfId);
98
+ autoModel = await AutoModel.from_pretrained(activeModel.hfId, {
53
99
  dtype: activeModel.dtype,
54
- }));
100
+ });
55
101
  }
56
102
  else {
57
- // e5-small / Qwen: use pipeline
58
- pipelineExtractor = await downloadWithUX('embedding pipeline', () => pipeline('feature-extraction', activeModel.id, {
103
+ pipelineExtractor = await pipeline('feature-extraction', activeModel.hfId, {
59
104
  dtype: activeModel.dtype,
60
- }));
105
+ });
61
106
  }
62
- console.error('[TotalReclaw] Embedding model ready. Future startups will be instant.');
107
+ console.error('[TotalReclaw] Embedding model ready. Future calls are in-memory.');
63
108
  }
64
109
  const model = activeModel;
65
110
  if (model.pooling === 'sentence_embedding') {
66
- // Harrier: pre-pooled, pre-normalized output
67
111
  const inputs = await autoTokenizer(text, { return_tensors: 'pt', padding: true });
68
112
  const output = await autoModel(inputs);
69
113
  return Array.from(output.sentence_embedding.data);
70
114
  }
71
115
  else {
72
- // Pipeline models: use pooling option
73
116
  const input = model.pooling === 'mean' && options?.isQuery
74
117
  ? `query: ${text}`
75
118
  : text;
@@ -79,8 +122,20 @@ export async function generateEmbedding(text, options) {
79
122
  }
80
123
  /**
81
124
  * Get the embedding vector dimensionality.
82
- * Returns 640 (default/Harrier), 384 (small), or 1024 (large) depending on model selection.
125
+ * Returns 640 for Harrier-OSS-270M-q4.
83
126
  */
84
127
  export function getEmbeddingDims() {
85
128
  return getModelConfig().dims;
86
129
  }
130
+ /**
131
+ * Get the semantic embedding-model id stamped on each new claim (rc.22+).
132
+ *
133
+ * Forward-compat marker: if a future plugin version distills to a smaller
134
+ * model, claims tagged with the prior id can be re-embedded selectively
135
+ * instead of forcing a vault-wide rebuild. Defaults to the v1 Harrier id —
136
+ * plugin code always tags new claims via this constant, never trusts the
137
+ * model id from a downloaded bundle for write-time tagging.
138
+ */
139
+ export function getEmbeddingModelId() {
140
+ return getModelConfig().semanticId;
141
+ }
@@ -295,6 +295,168 @@ export function cleanupInstallStagingDirs(pluginDir, _now = Date.now) {
295
295
  return removed;
296
296
  }
297
297
  // ---------------------------------------------------------------------------
298
+ // Partial-install detection (rc.22 finding #5)
299
+ // ---------------------------------------------------------------------------
300
+ /**
301
+ * Marker filename written into the plugin directory at register-time. Its
302
+ * presence means a prior install was interrupted before the plugin successfully
303
+ * loaded — a confirmed-broken half-state that the next `openclaw plugins
304
+ * install` retry can detect and clean.
305
+ *
306
+ * Conceptually the marker is dropped BEFORE npm install completes (the
307
+ * complementary npm script removes it on success) and additionally
308
+ * re-asserted at register-time as a second-line check. If you see this file
309
+ * in `<extensionsDir>/totalreclaw/`, the install never reached register()
310
+ * AND the marker drop wasn't undone.
311
+ *
312
+ * Constants are exported so the npm preinstall/cleanup scripts in
313
+ * `package.json` use the same name as the runtime detector.
314
+ */
315
+ export const PARTIAL_INSTALL_MARKER = '.tr-partial-install';
316
+ /** Package name we own — used to confirm a directory is OUR plugin, not a stray. */
317
+ export const PLUGIN_PACKAGE_NAME = '@totalreclaw/totalreclaw';
318
+ /**
319
+ * Inspect a plugin install directory to decide whether it is fully installed,
320
+ * a corrupted half-state from an interrupted install, or someone else's
321
+ * plugin. Pure filesystem inspection; never deletes anything.
322
+ *
323
+ * Background — rc.22 finding #5
324
+ * ------------------------------
325
+ * After a partial `openclaw plugins install @totalreclaw/totalreclaw` (e.g.
326
+ * the auto-gateway-restart kills npm mid-build), `extensions/totalreclaw/`
327
+ * survives with a populated package.json but a missing or empty `dist/`. The
328
+ * agent's recovery retry then fires another install; OpenClaw's plugin
329
+ * loader scans `extensions/` and tries to register the half-state as a "hook
330
+ * pack", failing with the cryptic "package.json missing openclaw.hooks". The
331
+ * fix: detect the partial state up-front so the retry can wipe + reinstall
332
+ * instead of cargo-culting a confused error.
333
+ *
334
+ * Decision rules
335
+ * --------------
336
+ * 1. `pluginRootDir` does not exist → `'absent'`.
337
+ * 2. package.json missing or unparsable → `'foreign'` (don't touch).
338
+ * 3. package.json `name !== '@totalreclaw/totalreclaw'` → `'foreign'`.
339
+ * 4. `<root>/.tr-partial-install` exists → `'partial'` (the canonical signal).
340
+ * 5. `<root>/dist/index.js` missing → `'partial'` (build never finished).
341
+ * 6. otherwise → `'clean'`.
342
+ *
343
+ * The function is intentionally conservative: it returns `'foreign'` on any
344
+ * ambiguous read. Callers should NEVER auto-wipe a `'foreign'` directory.
345
+ *
346
+ * @param pluginRootDir Absolute path to the suspect plugin dir, e.g.
347
+ * `~/.openclaw/extensions/totalreclaw`.
348
+ */
349
+ export function detectPartialInstall(pluginRootDir) {
350
+ const reasons = [];
351
+ // Rule 1 — absent dir.
352
+ let rootStat;
353
+ try {
354
+ rootStat = fs.statSync(pluginRootDir);
355
+ }
356
+ catch {
357
+ return { status: 'absent', reasons: ['directory does not exist'] };
358
+ }
359
+ if (!rootStat.isDirectory()) {
360
+ return { status: 'foreign', reasons: ['path exists but is not a directory'] };
361
+ }
362
+ // Rules 2-3 — package.json must claim our name.
363
+ const pkgJsonPath = path.join(pluginRootDir, 'package.json');
364
+ let pkgRaw;
365
+ try {
366
+ pkgRaw = fs.readFileSync(pkgJsonPath, 'utf-8');
367
+ }
368
+ catch {
369
+ return { status: 'foreign', reasons: ['package.json missing or unreadable'] };
370
+ }
371
+ let parsed;
372
+ try {
373
+ parsed = JSON.parse(pkgRaw);
374
+ }
375
+ catch {
376
+ return { status: 'foreign', reasons: ['package.json is not valid JSON'] };
377
+ }
378
+ if (parsed.name !== PLUGIN_PACKAGE_NAME) {
379
+ return {
380
+ status: 'foreign',
381
+ reasons: [`package.json declares "${String(parsed.name)}" not "${PLUGIN_PACKAGE_NAME}"`],
382
+ };
383
+ }
384
+ // Rule 4 — explicit partial marker wins.
385
+ const markerPath = path.join(pluginRootDir, PARTIAL_INSTALL_MARKER);
386
+ if (fs.existsSync(markerPath)) {
387
+ reasons.push(`${PARTIAL_INSTALL_MARKER} marker present (preinstall fired, postinstall did not)`);
388
+ }
389
+ // Rule 5 — dist/index.js must exist for the loader to register.
390
+ const distIndex = path.join(pluginRootDir, 'dist', 'index.js');
391
+ if (!fs.existsSync(distIndex)) {
392
+ reasons.push('dist/index.js missing (build artifact absent)');
393
+ }
394
+ if (reasons.length > 0) {
395
+ return { status: 'partial', reasons };
396
+ }
397
+ return { status: 'clean', reasons: [] };
398
+ }
399
+ /**
400
+ * Wipe a partial-install directory so the next `openclaw plugins install`
401
+ * starts from a blank slate. Only acts when `detectPartialInstall(...)`
402
+ * returns `'partial'` — `'foreign'` and `'clean'` are no-ops by design.
403
+ *
404
+ * Returns `true` if the directory was wiped, `false` otherwise.
405
+ *
406
+ * SAFETY: this helper is the only place that recursively deletes a plugin
407
+ * dir. It refuses to act on `'foreign'` and `'clean'` results so a
408
+ * misconfigured caller can never wipe a healthy install or someone else's
409
+ * plugin.
410
+ */
411
+ export function wipePartialInstall(pluginRootDir) {
412
+ const detection = detectPartialInstall(pluginRootDir);
413
+ if (detection.status !== 'partial')
414
+ return false;
415
+ try {
416
+ fs.rmSync(pluginRootDir, { recursive: true, force: true });
417
+ return true;
418
+ }
419
+ catch {
420
+ return false;
421
+ }
422
+ }
423
+ /**
424
+ * Drop the `.tr-partial-install` marker into `pluginRootDir`. Idempotent
425
+ * (overwrites any existing marker) and best-effort — returns `true` on
426
+ * success, `false` if the dir doesn't exist or write fails. Used by the
427
+ * `preinstall` npm script and (defensively) by the runtime if the npm
428
+ * preinstall/cleanup script pair did not fire.
429
+ */
430
+ export function writePartialInstallMarker(pluginRootDir) {
431
+ try {
432
+ if (!fs.existsSync(pluginRootDir))
433
+ return false;
434
+ fs.writeFileSync(path.join(pluginRootDir, PARTIAL_INSTALL_MARKER), '');
435
+ return true;
436
+ }
437
+ catch {
438
+ return false;
439
+ }
440
+ }
441
+ /**
442
+ * Remove the partial-install marker. Called by the `postinstall` script and
443
+ * (defensively) at register-time once we've confirmed the load succeeded.
444
+ * Returns `true` if a marker was removed, `false` if there was nothing to
445
+ * remove.
446
+ */
447
+ export function clearPartialInstallMarker(pluginRootDir) {
448
+ try {
449
+ const markerPath = path.join(pluginRootDir, PARTIAL_INSTALL_MARKER);
450
+ if (!fs.existsSync(markerPath))
451
+ return false;
452
+ fs.unlinkSync(markerPath);
453
+ return true;
454
+ }
455
+ catch {
456
+ return false;
457
+ }
458
+ }
459
+ // ---------------------------------------------------------------------------
298
460
  // Auto-bootstrap of credentials.json (3.1.0 first-run UX)
299
461
  // ---------------------------------------------------------------------------
300
462
  /**
package/dist/index.js CHANGED
@@ -48,13 +48,14 @@
48
48
  import { deriveKeys, deriveLshSeed, computeAuthKeyHash, encrypt, decrypt, generateBlindIndices, generateContentFingerprint, } from './crypto.js';
49
49
  import { createApiClient } from './api-client.js';
50
50
  import { extractFacts, extractDebrief, isValidMemoryType, parseEntity, VALID_MEMORY_TYPES, LEGACY_V0_MEMORY_TYPES, VALID_MEMORY_SOURCES, VALID_MEMORY_SCOPES, EXTRACTION_SYSTEM_PROMPT, extractFactsForCompaction, } from './extractor.js';
51
- import { initLLMClient, resolveLLMConfig, chatCompletion, generateEmbedding, getEmbeddingDims } from './llm-client.js';
51
+ import { initLLMClient, resolveLLMConfig, chatCompletion, generateEmbedding, getEmbeddingDims, getEmbeddingModelId, configureEmbedder, } from './llm-client.js';
52
52
  import { defaultAuthProfilesRoot, readAllProfileKeys, dedupeByProvider, } from './llm-profile-reader.js';
53
53
  import { LSHHasher } from './lsh.js';
54
- import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS, passesRelevanceGate } from './reranker.js';
54
+ import { rerank, cosineSimilarity, detectQueryIntent, INTENT_WEIGHTS } from './reranker.js';
55
55
  import { deduplicateBatch } from './semantic-dedup.js';
56
56
  import { findNearDuplicate, shouldSupersede, clusterFacts, getStoreDedupThreshold, getConsolidationThreshold, STORE_DEDUP_MAX_CANDIDATES, } from './consolidation.js';
57
57
  import { isSubgraphMode, getSubgraphConfig, encodeFactProtobuf, submitFactBatchOnChain, deriveSmartAccountAddress, PROTOBUF_VERSION_V4 } from './subgraph-store.js';
58
+ import { confirmIndexed } from './confirm-indexed.js';
58
59
  import { DIGEST_TRAPDOOR, buildCanonicalClaim, computeEntityTrapdoor, computeEntityTrapdoors, isDigestBlob, normalizeToV1Type, readClaimFromBlob, resolveDigestMode, } from './claims-helper.js';
59
60
  import { maybeInjectDigest, recompileDigest, fetchAllActiveClaims, isRecompileInProgress, tryBeginRecompile, endRecompile, } from './digest-sync.js';
60
61
  import { detectAndResolveContradictions, runWeightTuningLoop, } from './contradiction-sync.js';
@@ -65,7 +66,7 @@ import { PluginHotCache } from './hot-cache-wrapper.js';
65
66
  import { CONFIG, setRecoveryPhraseOverride } from './config.js';
66
67
  import { buildRelayHeaders } from './relay-headers.js';
67
68
  import { readBillingCache, writeBillingCache, BILLING_CACHE_PATH, } from './billing-cache.js';
68
- import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, } from './fs-helpers.js';
69
+ import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, clearPartialInstallMarker, } from './fs-helpers.js';
69
70
  import { isRcBuild } from './qa-bug-report.js';
70
71
  import { decideToolGate, isGatedToolName } from './tool-gating.js';
71
72
  import { detectFirstRun, buildWelcomePrepend } from './first-run.js';
@@ -1548,6 +1549,11 @@ async function storeExtractedFacts(facts, logger, sourceOverride) {
1548
1549
  fact: factForBlob,
1549
1550
  importance: effectiveImportance,
1550
1551
  sourceAgent: factSource,
1552
+ // 3.3.1-rc.22 — tag every new claim with the active embedder id
1553
+ // so future distillation can rescore selectively. Plugin-only
1554
+ // field; survives the core validator strip via re-attach in
1555
+ // `buildCanonicalClaimV1`.
1556
+ embeddingModelId: getEmbeddingModelId(),
1551
1557
  });
1552
1558
  const factId = crypto.randomUUID();
1553
1559
  // Phase 2 Slice 2d: contradiction detection + auto-resolution.
@@ -2342,6 +2348,47 @@ const plugin = {
2342
2348
  // Best-effort — already swallowed inside the helper, but keep this
2343
2349
  // outer try as belt-and-braces against future helper changes.
2344
2350
  }
2351
+ // 3.3.1-rc.22 — wire the lazy-embedder runtime config so the first
2352
+ // `generateEmbedding()` call knows where to cache the bundle and
2353
+ // which RC's GitHub Release to fetch from. `pluginVersion` may be
2354
+ // `null` if package.json is unreadable; the embedder defaults to
2355
+ // a "0.0.0-dev" tag in that case.
2356
+ try {
2357
+ configureEmbedder({
2358
+ cacheRoot: CONFIG.embedderCachePath,
2359
+ rcTag: pluginVersion ?? '0.0.0-dev',
2360
+ });
2361
+ }
2362
+ catch (err) {
2363
+ const msg = err instanceof Error ? err.message : String(err);
2364
+ api.logger.warn(`TotalReclaw: configureEmbedder failed (will use defaults): ${msg}`);
2365
+ }
2366
+ // 3.3.1-rc.22 (rc.21 finding #5): self-heal partial-install marker.
2367
+ // The `preinstall` npm script writes `.tr-partial-install`; the
2368
+ // `postinstall` script removes it on a successful install. If we
2369
+ // have gotten this far the loader did register us — meaning the
2370
+ // install succeeded enough to be useful — so any lingering marker
2371
+ // (e.g. npm ran preinstall but postinstall misfired) is stale.
2372
+ // Clear it so the next retry's detector does not see a false positive.
2373
+ //
2374
+ // 3.3.1-rc.22 (rc.21 finding #6) — gateway/reload upstream caveat:
2375
+ // OpenClaw's config-watcher fires `gateway/reload` when
2376
+ // `plugins.entries.totalreclaw` mutates (e.g. mid-install). In-flight
2377
+ // CLI clients see `1006 abnormal closure` and start a 600-second wait.
2378
+ // Proper fix is upstream OpenClaw FR. Plugin-side mitigation = these
2379
+ // helper calls MUST be idempotent under repeated register() calls
2380
+ // triggered by reload chatter. Asserted by
2381
+ // `install-reload-idempotency.test.ts`.
2382
+ try {
2383
+ const pluginRoot = nodePath.resolve(pluginDir, '..');
2384
+ const cleared = clearPartialInstallMarker(pluginRoot);
2385
+ if (cleared) {
2386
+ api.logger.info(`TotalReclaw: cleared stale .tr-partial-install marker (rc.22 finding #5)`);
2387
+ }
2388
+ }
2389
+ catch {
2390
+ // Best-effort. Helper logs internally and never throws.
2391
+ }
2345
2392
  }
2346
2393
  catch {
2347
2394
  rcMode = false;
@@ -2994,21 +3041,10 @@ const plugin = {
2994
3041
  details: { count: 0, memories: [] },
2995
3042
  };
2996
3043
  }
2997
- // 6b. Relevance gate surface results when EITHER the top match
2998
- // clears the cosine threshold OR every meaningful query token
2999
- // appears in the top result's text (lexical override).
3000
- // Issue #116 (rc.18 finding F1): short queries like
3001
- // "favorite color" produce embeddings with low cosine sim
3002
- // against the local Harrier-OSS-270m model even when the
3003
- // stored fact text contains every query token.
3004
- if (!passesRelevanceGate(params.query, reranked, COSINE_THRESHOLD)) {
3005
- const maxCosine = Math.max(...reranked.map((r) => r.cosineSimilarity ?? 0));
3006
- api.logger.info(`Recall: relevance gate filtered results (max cosine=${maxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD}, no lexical override)`);
3007
- return {
3008
- content: [{ type: 'text', text: 'No relevant memories found for this query.' }],
3009
- details: { count: 0, memories: [] },
3010
- };
3011
- }
3044
+ // 6b. Relevance gate removed in rc.22 -- core's intent-weighted
3045
+ // RRF + Tier 1 source weighting handles short queries via the
3046
+ // BM25 component, making the rc.18 cosine + lexical-override
3047
+ // band-aid (issue #116) redundant.
3012
3048
  // 7. Format results.
3013
3049
  const lines = reranked.map((m, i) => {
3014
3050
  const meta = metaMap.get(m.id);
@@ -3132,13 +3168,29 @@ const plugin = {
3132
3168
  throw new Error(`On-chain tombstone failed (tx=${result.txHash?.slice(0, 10) || 'none'}…)`);
3133
3169
  }
3134
3170
  api.logger.info(`Tombstone written for ${factId}: tx=${result.txHash}`);
3171
+ // Read-after-write: poll the subgraph until the original fact id
3172
+ // is no longer active (forget flips isActive=false). On timeout
3173
+ // surface `partial: true` so the agent can explain the chain
3174
+ // write succeeded but the subgraph is still propagating.
3175
+ const confirm = await confirmIndexed(factId, {
3176
+ expect: 'inactive',
3177
+ authKeyHex: authKeyHex,
3178
+ });
3135
3179
  return {
3136
3180
  content: [{
3137
3181
  type: 'text',
3138
- text: `Memory ${factId} deleted on-chain (tx: ${result.txHash}). ` +
3139
- 'The subgraph will reflect isActive=false within ~30 seconds.',
3182
+ text: confirm.indexed
3183
+ ? `Memory ${factId} deleted on-chain and confirmed by the subgraph (tx: ${result.txHash}).`
3184
+ : `Memory ${factId} deleted on-chain (tx: ${result.txHash}). ` +
3185
+ 'The subgraph indexer is still propagating the change — ' +
3186
+ 'recall/export may briefly show the memory as still active.',
3140
3187
  }],
3141
- details: { deleted: true, txHash: result.txHash, factId },
3188
+ details: {
3189
+ deleted: true,
3190
+ txHash: result.txHash,
3191
+ factId,
3192
+ ...(confirm.indexed ? {} : { partial: true }),
3193
+ },
3142
3194
  };
3143
3195
  }
3144
3196
  else {
@@ -5019,13 +5071,7 @@ const plugin = {
5019
5071
  lastQueryEmbedding = queryEmbedding;
5020
5072
  if (reranked.length === 0)
5021
5073
  return undefined;
5022
- // 6b. Relevance gate see recall tool above for the cosine +
5023
- // lexical-override rule (issue #116).
5024
- if (!passesRelevanceGate(evt.prompt, reranked, COSINE_THRESHOLD)) {
5025
- const hookMaxCosine = Math.max(...reranked.map((r) => r.cosineSimilarity ?? 0));
5026
- api.logger.info(`Hook: relevance gate filtered results (max cosine=${hookMaxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD}, no lexical override)`);
5027
- return undefined;
5028
- }
5074
+ // Relevance gate removed in rc.22 (see recall tool comment).
5029
5075
  // 7. Build context string.
5030
5076
  const lines = reranked.map((m, i) => {
5031
5077
  const meta = hookMetaMap.get(m.id);
@@ -5108,13 +5154,7 @@ const plugin = {
5108
5154
  /* applySourceWeights (Retrieval v2 Tier 1) */ true);
5109
5155
  if (reranked.length === 0)
5110
5156
  return undefined;
5111
- // Relevance gate see recall tool for the cosine + lexical-override
5112
- // rule (issue #116).
5113
- if (!passesRelevanceGate(evt.prompt, reranked, COSINE_THRESHOLD)) {
5114
- const srvMaxCosine = Math.max(...reranked.map((r) => r.cosineSimilarity ?? 0));
5115
- api.logger.info(`Hook: relevance gate filtered results (max cosine=${srvMaxCosine.toFixed(3)}, threshold=${COSINE_THRESHOLD}, no lexical override)`);
5116
- return undefined;
5117
- }
5157
+ // Relevance gate removed in rc.22 (see recall tool comment).
5118
5158
  // 7. Build context string.
5119
5159
  const lines = reranked.map((m, i) => {
5120
5160
  const meta = hookMetaMap.get(m.id);
@@ -681,6 +681,7 @@ async function chatCompletionAnthropic(config, messages, maxTokens, temperature,
681
681
  // Embedding (re-exported from local ONNX module)
682
682
  // ---------------------------------------------------------------------------
683
683
  // Embeddings are now generated locally via @huggingface/transformers
684
- // (Harrier-OSS-v1-270M ONNX model). No API key needed.
685
- // See embedding.ts for implementation details.
686
- export { generateEmbedding, getEmbeddingDims } from './embedding.js';
684
+ // (Harrier-OSS-v1-270M ONNX model). No API key needed. The native deps +
685
+ // model are lazy-fetched from a pinned GitHub Release on first call —
686
+ // see embedding.ts + embedder-loader.ts.
687
+ export { generateEmbedding, getEmbeddingDims, getEmbeddingModelId, configureEmbedder } from './embedding.js';
package/dist/pin.js CHANGED
@@ -14,6 +14,7 @@ import { buildV1ClaimBlob, mapTypeToCategory, readV1Blob, } from './claims-helpe
14
14
  import { findLoserClaimInDecisionLog, maybeWriteFeedbackForPin, } from './contradiction-sync.js';
15
15
  import { isValidMemoryType, V0_TO_V1_TYPE } from './extractor.js';
16
16
  import { PROTOBUF_VERSION_V4 } from './subgraph-store.js';
17
+ import { confirmIndexed } from './confirm-indexed.js';
17
18
  // Lazy-load WASM core (mirrors claims-helper.ts pattern — plays nicely under
18
19
  // both the OpenClaw runtime (CJS-ish tsx) and bare Node ESM used by tests).
19
20
  const requireWasm = createRequire(import.meta.url);
@@ -103,6 +104,7 @@ export function parseBlobForPin(decrypted) {
103
104
  expiresAt: v1.expiresAt,
104
105
  id: v1.id,
105
106
  pinStatus: v1.pinStatus,
107
+ embeddingModelId: v1.embeddingModelId,
106
108
  },
107
109
  claim: shortProjection,
108
110
  currentStatus: human,
@@ -228,6 +230,7 @@ function projectToV1(src, defaultSourceAgent) {
228
230
  entities: src.entities,
229
231
  importance: src.importance,
230
232
  confidence: src.confidence,
233
+ embeddingModelId: src.embeddingModelId,
231
234
  };
232
235
  }
233
236
  // v0 path — upgrade short-key claim to v1.
@@ -308,7 +311,7 @@ function projectToV1(src, defaultSourceAgent) {
308
311
  * chain. Matches MCP's `executePinOperation` byte-for-byte on the supersession
309
312
  * semantics (short keys, idempotent no-op, decayScore=1.0, trapdoor regen).
310
313
  */
311
- export async function executePinOperation(factId, targetStatus, deps, reason) {
314
+ export async function executePinOperation(factId, targetStatus, deps, reason, confirmOpts) {
312
315
  // 1. Fetch the existing fact
313
316
  const existing = await deps.fetchFactById(factId);
314
317
  if (!existing) {
@@ -408,6 +411,11 @@ export async function executePinOperation(factId, targetStatus, deps, reason) {
408
411
  createdAt: new Date().toISOString(),
409
412
  supersededBy: factId,
410
413
  pinStatus,
414
+ // 3.3.1-rc.22 — preserve the source claim's embedder tag through
415
+ // pin mutation. The new fact reuses the same encrypted embedding
416
+ // as the original (re-indexed via deps.regenerateBlindIndices),
417
+ // so the embedder identity must round-trip too.
418
+ embeddingModelId: v1View.embeddingModelId,
411
419
  });
412
420
  }
413
421
  catch (err) {
@@ -502,6 +510,11 @@ export async function executePinOperation(factId, targetStatus, deps, reason) {
502
510
  tx_hash: txHash,
503
511
  };
504
512
  }
513
+ // Read-after-write: poll the subgraph until the new (pinned/unpinned)
514
+ // fact id is indexed and active. On timeout, surface `partial: true`
515
+ // so a follow-up recall/export that races against indexer lag can
516
+ // surface a clear "still propagating" hint rather than apparent staleness.
517
+ const confirm = await confirmIndexed(newFactId, confirmOpts);
505
518
  return {
506
519
  success: true,
507
520
  fact_id: factId,
@@ -510,6 +523,7 @@ export async function executePinOperation(factId, targetStatus, deps, reason) {
510
523
  new_status: targetStatus,
511
524
  tx_hash: txHash,
512
525
  reason,
526
+ ...(confirm.indexed ? {} : { partial: true }),
513
527
  };
514
528
  }
515
529
  catch (err) {