entroly-wasm 0.19.4 → 0.19.8

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/js/server.js CHANGED
@@ -10,6 +10,7 @@ const { CheckpointManager, persistIndex, loadIndex } = require('./checkpoint');
10
10
  const { autoIndex, startIncrementalWatcher } = require('./auto_index');
11
11
  const { startAutotuneDaemon, FeedbackJournal, TaskProfileOptimizer } = require('./autotune');
12
12
  const { VaultManager } = require('./vault');
13
+ const { getTracker } = require('./value_tracker');
13
14
  const { EpistemicRouter, BeliefCompiler, VerificationEngine, ChangePipeline, FlowOrchestrator, compileDocs, exportTrainingData } = require('./cogops');
14
15
  const { WorkspaceChangeListener } = require('./workspace');
15
16
  const { SkillEngine } = require('./skills');
@@ -26,6 +27,11 @@ class EntrolyMCPServer {
26
27
  this.engine = new WasmEntrolyEngine();
27
28
  this.turnCounter = 0;
28
29
 
30
+ // Shared cross-runtime telemetry sink — SAME file/schema/dir as the
31
+ // Python tracker, so npm users' value shows up on the same dashboard
32
+ // as pip/MCP/SDK users. Fail-open: never let telemetry break a tool.
33
+ try { this.valueTracker = getTracker(); } catch (_) { this.valueTracker = null; }
34
+
29
35
  // Feedback journal — persists episodes for cross-session autotune
30
36
  this.feedbackJournal = new FeedbackJournal(this.config.checkpointDir);
31
37
  // Task-conditioned weight profiles (novel: per-task-type optimization)
@@ -112,6 +118,8 @@ class EntrolyMCPServer {
112
118
  // ── Analysis Tools (2) ──
113
119
  { name: 'repo_file_map', description: 'Return the canonical Entroly file map across Python, Rust core, and WASM repos with ownership roles.', inputSchema: { type: 'object', properties: { format: { type: 'string', description: 'Output format: markdown or json', default: 'markdown' } } } },
114
120
  { name: 'prefetch_related', description: 'Predict which files the LLM will need next based on co-access patterns and dependency graph.', inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Current file being worked on' }, source_content: { type: 'string', description: 'Content of current file for import analysis', default: '' }, language: { type: 'string', description: 'Programming language', default: '' } }, required: ['file_path'] } },
121
+ // ── Hallucination Detection Tool ──
122
+ { name: 'verify_response', description: 'Verify an AI-generated response for hallucination. Computes entity coverage gap, hedging curvature, and entropy consistency. Returns fused_risk [0.0=safe, 1.0=hallucinated], verdict (pass/warn/flag), and flagged claims. All local — zero LLM calls.', inputSchema: { type: 'object', properties: { response: { type: 'string', description: 'The AI-generated text to verify' }, context: { type: 'string', description: 'Source context provided to the AI', default: '' }, prompt: { type: 'string', description: 'Original user prompt', default: '' } }, required: ['response'] } },
115
123
  ];
116
124
  }
117
125
 
@@ -130,6 +138,18 @@ class EntrolyMCPServer {
130
138
  const profile = this.taskProfiles.applyToEngine(this.engine, query);
131
139
  const result = this.engine.optimize(budget, query);
132
140
  result._taskProfile = { taskType: profile.taskType, confidence: profile.confidence };
141
+ // Record value to the shared sink (mirrors entroly/server.py).
142
+ try {
143
+ const ts = (result && result.tokens_saved) || 0;
144
+ if (this.valueTracker && ts > 0) {
145
+ this.valueTracker.record({
146
+ tokensSaved: ts,
147
+ model: (result && result.model) || '',
148
+ duplicates: (result && result.duplicates_caught) || 0,
149
+ optimized: true,
150
+ });
151
+ }
152
+ } catch (_) { /* never fail optimization for telemetry */ }
133
153
  const state = this.engine.export_state();
134
154
  this._lastOptCtx = { weights: { w_r: state.w_recency, w_f: state.w_frequency, w_s: state.w_semantic, w_e: state.w_entropy }, selectedSources: (result.selected || []).map(s => s.source).filter(Boolean), selectedCount: result.selected_count || 0, tokenBudget: budget, query, turn: this.turnCounter };
135
155
  if (this.checkpointMgr.shouldAutoCheckpoint()) { try { persistIndex(this.engine, this.indexPath); this.checkpointMgr.save({ engine_state: state, turn: this.turnCounter }); } catch {} }
@@ -137,7 +157,9 @@ class EntrolyMCPServer {
137
157
  }
138
158
 
139
159
  case 'recall_relevant':
140
- return this.engine.recall(args.query, args.top_k || 5);
160
+ // Use BM25-backed relevance recall, not SimHash near-duplicate recall.
161
+ // This mirrors the Python MCP server's recall_relevant path.
162
+ return this.engine.recall_auto(args.query, args.top_k || 5);
141
163
 
142
164
  case 'record_outcome': {
143
165
  const ids = (args.fragment_ids || '').split(',').map(s => s.trim()).filter(Boolean);
@@ -181,7 +203,21 @@ class EntrolyMCPServer {
181
203
  case 'entroly_dashboard': {
182
204
  const stats = this.engine.stats();
183
205
  const explanation = this.engine.explain_selection();
184
- return { stats, explanation, turn: this.turnCounter };
206
+ // npm users live in their MCP client (no Python dashboard), so
207
+ // surface the persisted cross-session value here directly.
208
+ let value = null;
209
+ try {
210
+ if (this.valueTracker) {
211
+ const tr = this.valueTracker.getTrends();
212
+ value = {
213
+ lifetime: tr.lifetime,
214
+ today: (tr.daily.slice(-1)[0]) || null,
215
+ recent_activity: tr.activity.slice(0, 10),
216
+ data_source: 'shared telemetry file (cross-runtime)',
217
+ };
218
+ }
219
+ } catch (_) { /* fail-open */ }
220
+ return { stats, explanation, value, turn: this.turnCounter };
185
221
  }
186
222
 
187
223
  // ── CogOps Epistemic Tools ──
@@ -327,6 +363,71 @@ class EntrolyMCPServer {
327
363
  return { file: args.file_path, language: args.language || 'unknown', predicted_files: imports.slice(0, 10), dep_graph: depStats, engine: 'javascript' };
328
364
  }
329
365
 
366
+ // ── Hallucination Detection ──
367
+ case 'verify_response': {
368
+ const resp = args.response || '';
369
+ const ctx = args.context || '';
370
+ const prm = args.prompt || '';
371
+
372
+ // Entity coverage gap: extract identifiers from context vs response
373
+ const ctxEntities = new Set((ctx + ' ' + prm).match(/[a-zA-Z_][a-zA-Z0-9_.]{2,}/g) || []);
374
+ const respEntities = (resp.match(/[a-zA-Z_][a-zA-Z0-9_.]{2,}/g) || []);
375
+ const respUnique = [...new Set(respEntities)];
376
+ let grounded = 0;
377
+ const flaggedClaims = [];
378
+ for (const ent of respUnique) {
379
+ if (ctxEntities.has(ent) || ent.length <= 3) {
380
+ grounded++;
381
+ } else {
382
+ flaggedClaims.push({ claim: ent, score: 1.0 });
383
+ }
384
+ }
385
+ const entityGap = respUnique.length > 0 ? 1.0 - (grounded / respUnique.length) : 0;
386
+
387
+ // ECE-like: hedging language curvature
388
+ const hedgeWords = ['might', 'could', 'possibly', 'perhaps', 'maybe', 'uncertain', 'likely', 'probably', 'appears to', 'seems'];
389
+ const wordCount = resp.split(/\s+/).length;
390
+ let hedgeCount = 0;
391
+ for (const h of hedgeWords) { hedgeCount += (resp.toLowerCase().match(new RegExp(h, 'g')) || []).length; }
392
+ const hedgeCurvature = Math.min(1.0, hedgeCount / Math.max(wordCount, 1) * 20);
393
+
394
+ // EPR-like: entropy consistency (char-bigram)
395
+ const bigrams = {};
396
+ for (let i = 0; i < resp.length - 1; i++) {
397
+ const bg = resp.slice(i, i + 2);
398
+ bigrams[bg] = (bigrams[bg] || 0) + 1;
399
+ }
400
+ const total = Object.values(bigrams).reduce((a, b) => a + b, 0) || 1;
401
+ let entropy = 0;
402
+ for (const c of Object.values(bigrams)) {
403
+ const p = c / total;
404
+ if (p > 0) entropy -= p * Math.log2(p);
405
+ }
406
+ // Normalize: high entropy is normal for text, low entropy is suspicious
407
+ const maxEntropy = Math.log2(Math.min(Object.keys(bigrams).length, 500)) || 1;
408
+ const eprRisk = Math.max(0, 1.0 - entropy / maxEntropy);
409
+
410
+ // Fuse (same weights as proxy)
411
+ const fused = Math.max(0, Math.min(1.0,
412
+ 0.80 * entityGap + 0.08 * hedgeCurvature + 0.07 * eprRisk + 0.05 * 0
413
+ ));
414
+
415
+ let verdict, recommendation;
416
+ if (fused < 0.15) { verdict = 'pass'; recommendation = 'Accept — response appears well-grounded'; }
417
+ else if (fused < 0.40) { verdict = 'warn'; recommendation = 'Review — some claims may not be grounded'; }
418
+ else { verdict = 'flag'; recommendation = 'Reject or rephrase — high hallucination risk'; }
419
+
420
+ return {
421
+ fused_risk: Math.round(fused * 10000) / 10000,
422
+ verdict,
423
+ recommendation,
424
+ witness: { entity_coverage_gap: Math.round(entityGap * 10000) / 10000, total_entities: respUnique.length, grounded_count: grounded },
425
+ ece: { hedging_curvature: Math.round(hedgeCurvature * 10000) / 10000, hedge_count: hedgeCount },
426
+ epr: { entropy_risk: Math.round(eprRisk * 10000) / 10000, bigram_entropy: Math.round(entropy * 100) / 100 },
427
+ flagged_claims: flaggedClaims.slice(0, 10),
428
+ };
429
+ }
430
+
330
431
  default:
331
432
  throw new Error(`Unknown tool: ${name}`);
332
433
  }
@@ -1,13 +1,20 @@
1
1
  /**
2
- * ValueTracker — JS port of entroly/value_tracker.py
2
+ * ValueTracker — JS port of entroly/value_tracker.py (schema v3)
3
3
  *
4
4
  * Persistent, lifetime-savings accounting with the self-funded evolution
5
5
  * budget invariant:
6
6
  *
7
7
  * C_spent(t) ≤ τ · S(t) (τ = 5%)
8
8
  *
9
- * Any token-costing evolution step MUST gate on `getEvolutionBudget().canEvolve`.
10
- * The tracker is atomic-write safe and survives process restarts.
9
+ * CROSS-RUNTIME CONTRACT: this writes the SAME file, SAME directory and
10
+ * SAME JSON schema as the Python tracker so that one shared dashboard
11
+ * shows value no matter whether the user installed via pip (MCP/proxy),
12
+ * the SDK, or npm (this wasm runtime). The directory resolution and the
13
+ * UTC date-key formats below MUST stay byte-identical to
14
+ * entroly/value_tracker.py — diverging them silently re-creates the
15
+ * "blank dashboard for npm users" bug.
16
+ *
17
+ * Atomic-write safe; survives process restarts.
11
18
  */
12
19
 
13
20
  'use strict';
@@ -18,35 +25,22 @@ const os = require('os');
18
25
 
19
26
  const EVOLUTION_TAX_RATE = 0.05;
20
27
  const FILE_NAME = 'value_tracker.json';
28
+ const ACTIVITY_NAME = 'activity.jsonl';
29
+ const SCHEMA_VERSION = 3;
30
+ const MAX_DAILY = 90, MAX_WEEKLY = 52, MAX_MONTHLY = 24, MAX_ACTIVITY = 200;
21
31
 
22
32
  // Per-model $/1M tokens — kept in sync with entroly/value_tracker.py (×1000).
23
33
  const COST_PER_M = {
24
34
  default: 3.0,
25
- // OpenAI
26
- 'gpt-4o': 2.5,
27
- 'gpt-4o-mini': 0.15,
28
- 'gpt-4-turbo': 10.0,
29
- 'gpt-4': 30.0,
30
- 'gpt-3.5-turbo': 0.5,
31
- 'o1': 15.0,
32
- 'o1-mini': 3.0,
33
- 'o3': 10.0,
34
- 'o3-mini': 1.1,
35
- 'o4-mini': 1.1,
36
- // Anthropic
37
- 'claude-opus-4': 15.0,
38
- 'claude-sonnet-4': 3.0,
39
- 'claude-haiku-4': 0.8,
40
- 'claude-3-5-sonnet': 3.0,
41
- 'claude-3-5-haiku': 0.8,
42
- // Google
43
- 'gemini-2.5-pro': 1.25,
44
- 'gemini-2.5-flash': 0.075,
45
- 'gemini-1.5-pro': 1.25,
46
- 'gemini-1.5-flash': 0.075,
35
+ 'gpt-4o': 2.5, 'gpt-4o-mini': 0.15, 'gpt-4-turbo': 10.0, 'gpt-4': 30.0,
36
+ 'gpt-3.5-turbo': 0.5, 'o1': 15.0, 'o1-mini': 3.0, 'o3': 10.0,
37
+ 'o3-mini': 1.1, 'o4-mini': 1.1,
38
+ 'claude-opus-4': 15.0, 'claude-sonnet-4': 3.0, 'claude-haiku-4': 0.8,
39
+ 'claude-3-5-sonnet': 3.0, 'claude-3-5-haiku': 0.8,
40
+ 'gemini-2.5-pro': 1.25, 'gemini-2.5-flash': 0.075,
41
+ 'gemini-1.5-pro': 1.25, 'gemini-1.5-flash': 0.075,
47
42
  };
48
43
 
49
- // Old→new name aliases so 'claude-3-opus' hits the same rate as Python.
50
44
  const MODEL_ALIASES = {
51
45
  'claude-3-opus': 'claude-opus-4',
52
46
  'claude-3-sonnet': 'claude-sonnet-4',
@@ -60,11 +54,10 @@ function estimateCost(tokens, model = '') {
60
54
  for (const [alias, canonical] of Object.entries(MODEL_ALIASES)) {
61
55
  if (m.startsWith(alias)) { m = canonical + m.slice(alias.length); break; }
62
56
  }
63
- // Longest-prefix match prevents 'gpt-4o' eating 'gpt-4o-mini'.
64
- const keys = Object.keys(COST_PER_M).filter(k => k !== 'default').sort((a, b) => b.length - a.length);
57
+ const keys = Object.keys(COST_PER_M).filter(k => k !== 'default')
58
+ .sort((a, b) => b.length - a.length);
65
59
  const key = (m && keys.find(k => m.startsWith(k)));
66
60
  if (!key && model) {
67
- // One-time warning per unknown model — stderr, doesn't pollute stdout.
68
61
  if (!estimateCost._warned) estimateCost._warned = new Set();
69
62
  if (!estimateCost._warned.has(model)) {
70
63
  estimateCost._warned.add(model);
@@ -74,6 +67,29 @@ function estimateCost(tokens, model = '') {
74
67
  return (tokens / 1_000_000) * COST_PER_M[key || 'default'];
75
68
  }
76
69
 
70
+ // ── UTC date keys — MUST match Python time.gmtime()-based keys ──────────
71
+
72
+ function _pad(n) { return String(n).padStart(2, '0'); }
73
+
74
+ function _dayKey(d = new Date()) {
75
+ return `${d.getUTCFullYear()}-${_pad(d.getUTCMonth() + 1)}-${_pad(d.getUTCDate())}`;
76
+ }
77
+
78
+ function _monthKey(d = new Date()) {
79
+ return `${d.getUTCFullYear()}-${_pad(d.getUTCMonth() + 1)}`;
80
+ }
81
+
82
+ function _weekKey(d = new Date()) {
83
+ // ISO-8601 week, matching Python datetime.date.isocalendar().
84
+ const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
85
+ const day = t.getUTCDay() || 7; // Mon=1..Sun=7
86
+ t.setUTCDate(t.getUTCDate() + 4 - day); // nearest Thursday
87
+ const isoYear = t.getUTCFullYear();
88
+ const yearStart = new Date(Date.UTC(isoYear, 0, 1));
89
+ const week = Math.ceil((((t - yearStart) / 86400000) + 1) / 7);
90
+ return `${isoYear}-W${_pad(week)}`;
91
+ }
92
+
77
93
  function atomicWrite(filePath, content) {
78
94
  const tmp = filePath + '.tmp-' + process.pid;
79
95
  fs.writeFileSync(tmp, content, 'utf8');
@@ -82,10 +98,16 @@ function atomicWrite(filePath, content) {
82
98
 
83
99
  class ValueTracker {
84
100
  constructor(dataDir = null) {
85
- this._dir = dataDir || path.join(os.homedir(), '.entroly');
101
+ // Honor ENTROLY_DIR exactly like Python's _default_dir(); else
102
+ // ~/.entroly. NOT cwd/.entroly — that was the npm/py path split.
103
+ this._dir = dataDir
104
+ || process.env.ENTROLY_DIR
105
+ || path.join(os.homedir(), '.entroly');
86
106
  fs.mkdirSync(this._dir, { recursive: true });
87
107
  this._path = path.join(this._dir, FILE_NAME);
88
- this._data = this._load();
108
+ this._activityPath = path.join(this._dir, ACTIVITY_NAME);
109
+ this._data = this._migrate(this._load());
110
+ this._activity = this._loadActivity();
89
111
  }
90
112
 
91
113
  _load() {
@@ -101,36 +123,143 @@ class ValueTracker {
101
123
  _defaults() {
102
124
  const now = Date.now() / 1000;
103
125
  return {
104
- version: 2,
126
+ version: SCHEMA_VERSION,
105
127
  lifetime: {
106
- tokens_saved: 0,
107
- cost_saved_usd: 0.0,
108
- requests_optimized: 0,
109
- first_seen: now,
110
- last_seen: now,
111
- evolution_spent_usd: 0.0,
112
- evolution_attempts: 0,
128
+ tokens_saved: 0, cost_saved_usd: 0.0,
129
+ requests_optimized: 0, requests_total: 0, duplicates_caught: 0,
130
+ first_seen: now, last_seen: now,
131
+ evolution_spent_usd: 0.0, evolution_attempts: 0,
113
132
  evolution_successes: 0,
133
+ hallucinations_blocked: 0, routing_saved_usd: 0.0,
134
+ routing_decisions: 0,
114
135
  },
136
+ daily: {}, weekly: {}, monthly: {},
115
137
  };
116
138
  }
117
139
 
140
+ _migrate(data) {
141
+ // Backward-compatible forward migration (v2 → v3): backfill any
142
+ // missing keys/buckets without touching existing counters.
143
+ const base = this._defaults();
144
+ data.lifetime = data.lifetime || {};
145
+ for (const [k, v] of Object.entries(base.lifetime)) {
146
+ if (!(k in data.lifetime)) data.lifetime[k] = v;
147
+ }
148
+ for (const b of ['daily', 'weekly', 'monthly']) {
149
+ if (!data[b] || typeof data[b] !== 'object') data[b] = {};
150
+ }
151
+ data.version = SCHEMA_VERSION;
152
+ return data;
153
+ }
154
+
155
+ _loadActivity() {
156
+ if (!fs.existsSync(this._activityPath)) return [];
157
+ try {
158
+ const out = [];
159
+ for (const line of fs.readFileSync(this._activityPath, 'utf8').split('\n')) {
160
+ const s = line.trim();
161
+ if (!s) continue;
162
+ try { out.push(JSON.parse(s)); } catch (_) { /* skip */ }
163
+ }
164
+ return out.slice(-MAX_ACTIVITY);
165
+ } catch (_) { return []; }
166
+ }
167
+
118
168
  _save() {
119
169
  try { atomicWrite(this._path, JSON.stringify(this._data, null, 2)); }
120
170
  catch (_) { /* best-effort */ }
121
171
  }
122
172
 
123
- record({ tokensSaved = 0, model = '', optimized = true } = {}) {
173
+ _saveActivity() {
174
+ try {
175
+ this._activity = this._activity.slice(-MAX_ACTIVITY);
176
+ const content = this._activity.map(e => JSON.stringify(e)).join('\n')
177
+ + (this._activity.length ? '\n' : '');
178
+ atomicWrite(this._activityPath, content);
179
+ } catch (_) { /* best-effort */ }
180
+ }
181
+
182
+ _bump(bucketName, key, tokens, cost) {
183
+ const bucket = this._data[bucketName] || (this._data[bucketName] = {});
184
+ if (!bucket[key]) bucket[key] = { tokens_saved: 0, cost_saved: 0.0, requests: 0 };
185
+ bucket[key].tokens_saved += tokens;
186
+ bucket[key].cost_saved = +(bucket[key].cost_saved + cost).toFixed(6);
187
+ bucket[key].requests += 1;
188
+ const limit = { daily: MAX_DAILY, weekly: MAX_WEEKLY, monthly: MAX_MONTHLY }[bucketName];
189
+ const ks = Object.keys(bucket);
190
+ if (ks.length > limit) {
191
+ ks.sort();
192
+ for (const old of ks.slice(0, ks.length - limit)) delete bucket[old];
193
+ }
194
+ }
195
+
196
+ record({ tokensSaved = 0, model = '', duplicates = 0, optimized = true } = {}) {
124
197
  const cost = estimateCost(tokensSaved, model);
198
+ const now = new Date();
125
199
  const lt = this._data.lifetime;
126
200
  lt.tokens_saved += tokensSaved;
127
201
  lt.cost_saved_usd = +(lt.cost_saved_usd + cost).toFixed(6);
202
+ lt.requests_total = (lt.requests_total || 0) + 1;
128
203
  if (optimized) lt.requests_optimized += 1;
204
+ lt.duplicates_caught = (lt.duplicates_caught || 0) + duplicates;
129
205
  lt.last_seen = Date.now() / 1000;
206
+ this._bump('daily', _dayKey(now), tokensSaved, cost);
207
+ this._bump('weekly', _weekKey(now), tokensSaved, cost);
208
+ this._bump('monthly', _monthKey(now), tokensSaved, cost);
130
209
  this._save();
210
+ this._activity.push({
211
+ ts: +(Date.now() / 1000).toFixed(3),
212
+ kind: 'optimize',
213
+ summary: `Optimized request: saved ${tokensSaved.toLocaleString()} tokens`
214
+ + (model ? ` (${model})` : ''),
215
+ tokens_saved: tokensSaved,
216
+ cost_saved_usd: +cost.toFixed(6),
217
+ model: model || '',
218
+ duplicates,
219
+ source: 'npm',
220
+ });
221
+ this._saveActivity();
131
222
  return { tokensSaved, costSaved: cost };
132
223
  }
133
224
 
225
+ recordEvent(kind, summary, opts = {}) {
226
+ try {
227
+ const row = {
228
+ ts: +(Date.now() / 1000).toFixed(3),
229
+ kind: String(kind),
230
+ summary: String(summary).slice(0, 240),
231
+ source: opts.source || 'npm',
232
+ };
233
+ if (opts.tokensSaved) row.tokens_saved = opts.tokensSaved | 0;
234
+ if (opts.costSavedUsd) row.cost_saved_usd = +Number(opts.costSavedUsd).toFixed(6);
235
+ if (opts.model) row.model = opts.model;
236
+ this._activity.push(row);
237
+ this._saveActivity();
238
+ } catch (_) { /* fail-open */ }
239
+ }
240
+
241
+ recordHallucinationBlocked(n = 1, detail = '') {
242
+ try {
243
+ this._data.lifetime.hallucinations_blocked =
244
+ (this._data.lifetime.hallucinations_blocked || 0) + (n | 0);
245
+ this._save();
246
+ this.recordEvent('hallucination',
247
+ detail || `Blocked ${n} unsupported claim(s)`, { source: 'witness' });
248
+ } catch (_) { /* fail-open */ }
249
+ }
250
+
251
+ recordRoutingSaving(costSavedUsd, chosenModel = '', detail = '') {
252
+ try {
253
+ const lt = this._data.lifetime;
254
+ lt.routing_saved_usd = +((lt.routing_saved_usd || 0) + Number(costSavedUsd)).toFixed(6);
255
+ lt.routing_decisions = (lt.routing_decisions || 0) + 1;
256
+ this._save();
257
+ this.recordEvent('routing',
258
+ detail || `Routed to ${chosenModel || 'cheaper model'}`,
259
+ { source: 'ravs', costSavedUsd, model: chosenModel });
260
+ } catch (_) { /* fail-open */ }
261
+ }
262
+
134
263
  getEvolutionBudget() {
135
264
  const lt = this._data.lifetime;
136
265
  const lifetimeSaved = lt.cost_saved_usd || 0;
@@ -152,14 +281,9 @@ class ValueTracker {
152
281
  const currentSpent = lt.evolution_spent_usd || 0;
153
282
  const totalEarned = lifetimeSaved * EVOLUTION_TAX_RATE;
154
283
  const available = totalEarned - currentSpent;
155
-
156
284
  if (costUsd > available + 0.001) {
157
- return {
158
- status: 'rejected',
159
- remainingUsd: +Math.max(0, available).toFixed(6),
160
- };
285
+ return { status: 'rejected', remainingUsd: +Math.max(0, available).toFixed(6) };
161
286
  }
162
-
163
287
  lt.evolution_spent_usd = +(currentSpent + costUsd).toFixed(6);
164
288
  lt.evolution_attempts = (lt.evolution_attempts || 0) + 1;
165
289
  if (success) lt.evolution_successes = (lt.evolution_successes || 0) + 1;
@@ -170,12 +294,43 @@ class ValueTracker {
170
294
  };
171
295
  }
172
296
 
297
+ // ── Read APIs for the entroly_dashboard MCP tool ───────────────────────
298
+
299
+ _sortedBucket(name, lastN) {
300
+ const b = this._data[name] || {};
301
+ return Object.keys(b).sort().slice(-lastN).map(k => ({ key: k, ...b[k] }));
302
+ }
303
+
304
+ getTrends() {
305
+ return {
306
+ lifetime: { ...this._data.lifetime },
307
+ daily: this._sortedBucket('daily', 30),
308
+ weekly: this._sortedBucket('weekly', 12),
309
+ monthly: this._sortedBucket('monthly', 12),
310
+ activity: this._activity.slice(-50).reverse(),
311
+ };
312
+ }
313
+
314
+ getActivity(lastN = 50) {
315
+ return this._activity.slice(-lastN).reverse();
316
+ }
317
+
173
318
  stats() {
174
319
  return {
175
320
  lifetime: { ...this._data.lifetime },
176
321
  budget: this.getEvolutionBudget(),
322
+ activity: this.getActivity(20),
177
323
  };
178
324
  }
179
325
  }
180
326
 
181
- module.exports = { ValueTracker, EVOLUTION_TAX_RATE, estimateCost };
327
+ // Module-level singleton, mirroring Python get_tracker().
328
+ let _tracker = null;
329
+ function getTracker() {
330
+ if (!_tracker) _tracker = new ValueTracker();
331
+ return _tracker;
332
+ }
333
+
334
+ module.exports = {
335
+ ValueTracker, EVOLUTION_TAX_RATE, estimateCost, getTracker,
336
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entroly-wasm",
3
- "version": "0.19.4",
3
+ "version": "0.19.8",
4
4
  "description": "Information-theoretic context optimization for AI coding agents. Zero-friction, pure WebAssembly - no Python dependency.",
5
5
  "main": "index.js",
6
6
  "types": "pkg/entroly_wasm.d.ts",
@@ -93,6 +93,22 @@ export class WasmEntrolyEngine {
93
93
  * Semantic recall of relevant fragments.
94
94
  */
95
95
  recall(query: string, top_k: number): any;
96
+ /**
97
+ * AUTO retrieval — zero-config: relevance recall is *always* a
98
+ * relevance need, so route to BM25, never the SimHash `recall()`.
99
+ * Mirrors entroly-core's `recall_auto` exactly so every surface
100
+ * (pip / Python-MCP / npm-wasm) ships ONE behaviour.
101
+ */
102
+ recall_auto(query: string, top_k: number): any;
103
+ /**
104
+ * BM25 relevance retrieval — byte-identical behaviour to
105
+ * entroly-core's `recall_bm25` (same ported `bm25.rs`, same schema
106
+ * incl. the auditable score breakdown). This is the npm/wasm half
107
+ * of the cross-runtime parity fix: SimHash `recall()` is a
108
+ * near-duplicate metric (~0.44 R@1); a real BM25 is the correct
109
+ * tool (~1.0). pip / Python-MCP / npm now behave the same.
110
+ */
111
+ recall_bm25(query: string, top_k: number): any;
96
112
  /**
97
113
  * Record failed outcome for specific fragment IDs.
98
114
  */
@@ -209,6 +209,38 @@ class WasmEntrolyEngine {
209
209
  const ret = wasm.wasmentrolyengine_recall(this.__wbg_ptr, ptr0, len0, top_k);
210
210
  return ret;
211
211
  }
212
+ /**
213
+ * AUTO retrieval — zero-config: relevance recall is *always* a
214
+ * relevance need, so route to BM25, never the SimHash `recall()`.
215
+ * Mirrors entroly-core's `recall_auto` exactly so every surface
216
+ * (pip / Python-MCP / npm-wasm) ships ONE behaviour.
217
+ * @param {string} query
218
+ * @param {number} top_k
219
+ * @returns {any}
220
+ */
221
+ recall_auto(query, top_k) {
222
+ const ptr0 = passStringToWasm0(query, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
223
+ const len0 = WASM_VECTOR_LEN;
224
+ const ret = wasm.wasmentrolyengine_recall_auto(this.__wbg_ptr, ptr0, len0, top_k);
225
+ return ret;
226
+ }
227
+ /**
228
+ * BM25 relevance retrieval — byte-identical behaviour to
229
+ * entroly-core's `recall_bm25` (same ported `bm25.rs`, same schema
230
+ * incl. the auditable score breakdown). This is the npm/wasm half
231
+ * of the cross-runtime parity fix: SimHash `recall()` is a
232
+ * near-duplicate metric (~0.44 R@1); a real BM25 is the correct
233
+ * tool (~1.0). pip / Python-MCP / npm now behave the same.
234
+ * @param {string} query
235
+ * @param {number} top_k
236
+ * @returns {any}
237
+ */
238
+ recall_bm25(query, top_k) {
239
+ const ptr0 = passStringToWasm0(query, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
240
+ const len0 = WASM_VECTOR_LEN;
241
+ const ret = wasm.wasmentrolyengine_recall_bm25(this.__wbg_ptr, ptr0, len0, top_k);
242
+ return ret;
243
+ }
212
244
  /**
213
245
  * Record failed outcome for specific fragment IDs.
214
246
  * @param {string} fragment_ids_json
Binary file