prism-mcp-server 8.0.2 → 9.0.4

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.
@@ -615,6 +615,9 @@ export function renderDashboardHTML(version) {
615
615
  </div>
616
616
 
617
617
  <div id="content" class="grid grid-main fade-in">
618
+ <div style="grid-column: 1 / -1; display: flex; align-items: center; gap: 1rem; margin-bottom: -1rem; padding: 0 1rem;">
619
+ <h1 id="activeProjectTitle" style="margin: 0; font-size: 2.2rem; color: var(--text-primary); text-shadow: 0 0 15px rgba(168, 85, 247, 0.4); font-weight: 700; letter-spacing: -0.02em;"></h1>
620
+ </div>
618
621
  <!-- Left Column -->
619
622
  <div class="grid" style="align-content: start;">
620
623
  <!-- Current State -->
@@ -1048,12 +1051,70 @@ export function renderDashboardHTML(version) {
1048
1051
  <div class="setting-label">Storage Backend</div>
1049
1052
  <div class="setting-desc">Switch between SQLite and Supabase</div>
1050
1053
  </div>
1051
- <select id="storageBackendSelect" onchange="window.saveBootSetting('PRISM_STORAGE', this.value)" style="padding: 0.2rem 0.4rem; background: var(--bg-hover); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; font-size: 0.85rem; font-family: var(--font-mono); cursor: pointer;">
1054
+ <select id="storageBackendSelect" onchange="onStorageProviderChange(this.value)" style="padding: 0.2rem 0.4rem; background: var(--bg-hover); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; font-size: 0.85rem; font-family: var(--font-mono); cursor: pointer;">
1052
1055
  <option value="local">SQLite</option>
1053
1056
  <option value="supabase">Supabase</option>
1054
1057
  </select>
1055
1058
  </div>
1056
1059
 
1060
+ <!-- Supabase fields -->
1061
+ <div id="provider-fields-supabase" style="display:none; padding: 1rem; background: rgba(139,92,246,0.05); border-radius: var(--radius-sm); border: 1px solid var(--border-glass); margin-top: 0.5rem;">
1062
+ <div class="setting-section" style="margin-top:0">Supabase Connection Setup</div>
1063
+ <div style="font-size:0.75rem; color:var(--text-muted); margin-bottom: 1rem; line-height: 1.5;">
1064
+ Configure your Supabase backend. This will run the migration schema to set up tables automatically before saving credentials.
1065
+ </div>
1066
+
1067
+ <div class="setting-row" style="border:none; padding:0.25rem 0">
1068
+ <div>
1069
+ <div class="setting-label">Supabase URL</div>
1070
+ </div>
1071
+ <input type="text" id="input-supabase-url"
1072
+ placeholder="https://xyz.supabase.co"
1073
+ style="padding: 0.3rem 0.5rem; background: var(--bg-hover); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; font-size: 0.85rem; font-family: var(--font-mono); width: 220px;" />
1074
+ </div>
1075
+ <div class="setting-row" style="border:none; padding:0.25rem 0">
1076
+ <div>
1077
+ <div class="setting-label">Service Role Key</div>
1078
+ </div>
1079
+ <input type="password" id="input-supabase-key"
1080
+ placeholder="eyJh..."
1081
+ style="padding: 0.3rem 0.5rem; background: var(--bg-hover); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; font-size: 0.85rem; font-family: var(--font-mono); width: 220px;" />
1082
+ </div>
1083
+ <div class="setting-row" style="border:none; padding:0.25rem 0">
1084
+ <div>
1085
+ <div class="setting-label">Database Password</div>
1086
+ <div class="setting-desc">Needed once to bootstrap tables. Never saved.</div>
1087
+ </div>
1088
+ <input type="password" id="input-supabase-dbpass"
1089
+ placeholder="••••••••"
1090
+ style="padding: 0.3rem 0.5rem; background: var(--bg-hover); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; font-size: 0.85rem; font-family: var(--font-mono); width: 220px;" />
1091
+ </div>
1092
+
1093
+ <div style="margin-top: 1rem; display: flex; align-items: center; gap: 0.5rem;">
1094
+ <button onclick="setupSupabase()" id="btn-setup-supabase" style="background: var(--accent-purple); color: white; border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s;">
1095
+ Set Up &amp; Migrate
1096
+ </button>
1097
+ <span id="setup-supabase-status" style="font-size: 0.8rem; font-weight: 500;"></span>
1098
+ </div>
1099
+
1100
+ <!-- Migration progress bar (hidden until setup starts) -->
1101
+ <div id="migration-progress-wrap" style="display:none; margin-top: 0.75rem;">
1102
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 0.35rem;">
1103
+ <span id="migration-step-label" style="font-size:0.75rem; color:var(--text-muted); font-weight:500;">Initializing…</span>
1104
+ <span id="migration-pct-label" style="font-size:0.75rem; color:var(--accent-purple); font-weight:700;">0%</span>
1105
+ </div>
1106
+ <div style="height:6px; border-radius:99px; background:var(--bg-hover); overflow:hidden;">
1107
+ <div id="migration-progress-bar"
1108
+ style="height:100%; width:0%; border-radius:99px;
1109
+ background: linear-gradient(90deg, #7c3aed, #a78bfa);
1110
+ transition: width 0.4s cubic-bezier(0.4,0,0.2,1);"></div>
1111
+ </div>
1112
+ <!-- Step dots -->
1113
+ <div id="migration-step-dots" style="display:flex; gap:0.35rem; margin-top:0.5rem; flex-wrap:wrap;"></div>
1114
+ </div>
1115
+
1116
+ </div>
1117
+
1057
1118
  <div class="setting-row" style="align-items:flex-start">
1058
1119
  <div>
1059
1120
  <div class="setting-label">Auto-Load Projects</div>
@@ -1810,6 +1871,7 @@ function loadProject() {
1810
1871
  document.getElementById('welcome').style.display = 'none';
1811
1872
  document.getElementById('content').style.display = 'none';
1812
1873
  document.getElementById('loading').style.display = 'block';
1874
+ document.getElementById('activeProjectTitle').textContent = "📁 " + project;
1813
1875
  _a.label = 1;
1814
1876
  case 1:
1815
1877
  _a.trys.push([1, 9, 10, 11]);
@@ -3196,6 +3258,146 @@ function onEmbeddingProviderChange(value) {
3196
3258
  refreshAnthropicWarning(textVal, value);
3197
3259
  saveBootSetting('embedding_provider', value);
3198
3260
  }
3261
+
3262
+ function onStorageProviderChange(value) {
3263
+ var supFields = document.getElementById('provider-fields-supabase');
3264
+ if (supFields) supFields.style.display = value === 'supabase' ? '' : 'none';
3265
+
3266
+ // Only auto-save if switching to local. Supabase is saved via the migrate button.
3267
+ if (value === 'local') {
3268
+ saveBootSetting('PRISM_STORAGE', value);
3269
+ }
3270
+ }
3271
+
3272
+ function setupSupabase() {
3273
+ var url = document.getElementById('input-supabase-url').value.trim();
3274
+ var serviceKey = document.getElementById('input-supabase-key').value.trim();
3275
+ var dbPassword = document.getElementById('input-supabase-dbpass').value.trim();
3276
+ var statusEl = document.getElementById('setup-supabase-status');
3277
+ var btn = document.getElementById('btn-setup-supabase');
3278
+
3279
+ if (!url || !serviceKey || !dbPassword) {
3280
+ statusEl.innerText = "All fields required.";
3281
+ statusEl.style.color = "var(--accent-rose)";
3282
+ return;
3283
+ }
3284
+ if (url.indexOf('https://') !== 0) {
3285
+ statusEl.innerText = "URL must start with https://";
3286
+ statusEl.style.color = "var(--accent-rose)";
3287
+ return;
3288
+ }
3289
+ if (serviceKey.indexOf('eyJ') !== 0) {
3290
+ statusEl.innerText = "Service key appears invalid. Expected JWT.";
3291
+ statusEl.style.color = "var(--accent-rose)";
3292
+ return;
3293
+ }
3294
+
3295
+ // Reset UI state
3296
+ statusEl.innerText = "Connecting & Migrating…";
3297
+ statusEl.style.color = "var(--text-muted)";
3298
+ btn.disabled = true;
3299
+ btn.style.opacity = "0.5";
3300
+
3301
+ // Show progress bar
3302
+ var progressWrap = document.getElementById('migration-progress-wrap');
3303
+ var progressBar = document.getElementById('migration-progress-bar');
3304
+ var stepLabel = document.getElementById('migration-step-label');
3305
+ var pctLabel = document.getElementById('migration-pct-label');
3306
+ var stepDots = document.getElementById('migration-step-dots');
3307
+ progressWrap.style.display = '';
3308
+ progressBar.style.width = '0%';
3309
+ stepDots.innerHTML = '';
3310
+
3311
+ // Helper: update bar
3312
+ function setProgress(pct, label, ok) {
3313
+ progressBar.style.width = pct + '%';
3314
+ if (ok === false) progressBar.style.background = 'linear-gradient(90deg,#dc2626,#f87171)';
3315
+ stepLabel.innerText = label;
3316
+ pctLabel.innerText = Math.round(pct) + '%';
3317
+ }
3318
+
3319
+ // Helper: add step dot
3320
+ function addDot(label, state) {
3321
+ var dot = document.createElement('span');
3322
+ var colors = { pending: 'var(--text-muted)', ok: 'var(--accent-green)', err: 'var(--accent-rose)' };
3323
+ var icons = { pending: '○', ok: '✓', err: '×' };
3324
+ dot.title = label;
3325
+ dot.style.cssText = 'display:inline-flex;align-items:center;gap:2px;font-size:0.7rem;color:' + colors[state] + ';font-weight:600;';
3326
+ dot.innerText = icons[state] + ' ' + label;
3327
+ stepDots.appendChild(dot);
3328
+ }
3329
+
3330
+ setProgress(5, 'Connecting to Supabase…');
3331
+
3332
+ // Subscribe to server-sent progress events for this session
3333
+ var evtKey = Date.now().toString();
3334
+ var sse = new EventSource('/api/migration/progress?key=' + evtKey);
3335
+ var sseTimeout = setTimeout(function() {
3336
+ if (sse) { sse.close(); sse = null; }
3337
+ }, 120000); // 2-min safety timeout
3338
+
3339
+ if (sse) {
3340
+ sse.onmessage = function(e) {
3341
+ try {
3342
+ var msg = JSON.parse(e.data);
3343
+ // msg: { step, total, label, pct?, done?, error? }
3344
+ var pct = msg.pct !== undefined ? msg.pct : Math.round((msg.step / msg.total) * 100);
3345
+ if (msg.error) {
3346
+ setProgress(pct, '⚠ ' + msg.label, false);
3347
+ addDot(msg.label, 'err');
3348
+ } else if (msg.done) {
3349
+ setProgress(100, '✓ All migrations applied');
3350
+ addDot('Done', 'ok');
3351
+ if (sse) { sse.close(); sse = null; }
3352
+ clearTimeout(sseTimeout);
3353
+ } else {
3354
+ setProgress(pct, msg.label);
3355
+ if (msg.step > 1) addDot(msg.prevLabel || msg.label, 'ok');
3356
+ }
3357
+ } catch(_) {}
3358
+ };
3359
+ sse.onerror = function() {
3360
+ // SSE closed server-side after completion — normal
3361
+ if (sse) { sse.close(); sse = null; }
3362
+ clearTimeout(sseTimeout);
3363
+ };
3364
+ }
3365
+
3366
+ fetch('/api/setup-supabase', {
3367
+ method: 'POST',
3368
+ headers: { 'Content-Type': 'application/json' },
3369
+ body: JSON.stringify({ url: url, serviceKey: serviceKey, dbPassword: dbPassword, progressKey: evtKey })
3370
+ })
3371
+ .then(function(res) {
3372
+ return res.json().then(function(data) {
3373
+ if (res.ok && data.ok) {
3374
+ setProgress(100, '✓ All done!');
3375
+ statusEl.innerText = "✓ Configured. Restart Prism.";
3376
+ statusEl.style.color = "var(--accent-green)";
3377
+ setTimeout(function() {
3378
+ alert("Supabase configured!\\n\\nRestart Prism MCP server for changes to take effect.");
3379
+ }, 300);
3380
+ } else {
3381
+ var errMsg = data.error || "Setup failed.";
3382
+ setProgress(progressBar ? parseFloat(progressBar.style.width) : 0, '⚠ ' + errMsg, false);
3383
+ statusEl.innerText = errMsg;
3384
+ statusEl.style.color = "var(--accent-rose)";
3385
+ }
3386
+ });
3387
+ })
3388
+ .catch(function() {
3389
+ setProgress(0, 'Network error', false);
3390
+ statusEl.innerText = "Network error.";
3391
+ statusEl.style.color = "var(--accent-rose)";
3392
+ })
3393
+ .finally(function() {
3394
+ btn.disabled = false;
3395
+ btn.style.opacity = "1";
3396
+ if (sse) { sse.close(); sse = null; }
3397
+ clearTimeout(sseTimeout);
3398
+ });
3399
+ }
3400
+
3199
3401
  // Shows/hides the Anthropic+auto warning.
3200
3402
  // Warning appears when: text=anthropic AND embedding=auto (auto-bridges to Gemini).
3201
3403
  function refreshAnthropicWarning(textVal, embedVal) {
@@ -3438,6 +3640,14 @@ function loadSettings() {
3438
3640
  // Storage Backend
3439
3641
  if (s.PRISM_STORAGE) {
3440
3642
  document.getElementById('storageBackendSelect').value = s.PRISM_STORAGE;
3643
+ var supFields = document.getElementById('provider-fields-supabase');
3644
+ if (supFields) supFields.style.display = s.PRISM_STORAGE === 'supabase' ? '' : 'none';
3645
+ }
3646
+ if (s.SUPABASE_URL) {
3647
+ document.getElementById('input-supabase-url').value = s.SUPABASE_URL;
3648
+ }
3649
+ if (s.SUPABASE_SERVICE_KEY) {
3650
+ document.getElementById('input-supabase-key').placeholder = '(key saved — paste to update)';
3441
3651
  }
3442
3652
  // Agent Identity
3443
3653
  if (s.default_role)
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Cognitive Budget — Token-Economic RL (v9.0)
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * PURPOSE:
6
+ * Implements a strict token economy for agent memory operations.
7
+ * Instead of having infinite memory budgets, agents must learn to
8
+ * save high-signal, compressed entries — through physics, not prompts.
9
+ *
10
+ * ECONOMY DESIGN:
11
+ * - Budget is PERSISTENT (stored in session_handoffs.cognitive_budget)
12
+ * - Budget belongs to the PROJECT, not the ephemeral session
13
+ * - This prevents the "Reset Exploit" (close & reopen to get free tokens)
14
+ * - Revenue comes from Universal Basic Income (time-based) + success bonuses
15
+ * - No retrieval-based earning (prevents the "Minting Exploit" / search spam)
16
+ *
17
+ * COST MULTIPLIERS:
18
+ * Incoming entry surprisal determines the budget cost multiplier:
19
+ * - Low surprisal (boilerplate): 2.0× cost — penalizes "I updated CSS"
20
+ * - Normal surprisal: 1.0× cost — standard rate
21
+ * - High surprisal (novel): 0.5× cost — rewards novel insights
22
+ *
23
+ * GRACEFUL DEGRADATION:
24
+ * Budget exhaustion produces a WARNING in the MCP response but NEVER
25
+ * blocks the SQL insert. We never lose agent work due to verbosity.
26
+ *
27
+ * MINIMUM BASE COST:
28
+ * Empty/trivial summaries still bleed the budget (10 token minimum)
29
+ * to prevent zero-cost gaming with empty saves.
30
+ *
31
+ * UBI (UNIVERSAL BASIC INCOME):
32
+ * Instead of earning through arbitrary search spam, agents earn
33
+ * budget passively through time elapsed since last save:
34
+ * - +100 tokens per hour since last ledger save (capped at +500/session)
35
+ * - +200 bonus for a `success` experience event
36
+ * - +100 bonus for a `learning` experience event
37
+ *
38
+ * FILES THAT IMPORT THIS:
39
+ * - src/tools/ledgerHandlers.ts (budget tracking + diagnostics)
40
+ * - src/tools/ledgerHandlers.ts (budget persistence in handoff)
41
+ * ═══════════════════════════════════════════════════════════════════
42
+ */
43
+ // ─── Constants ────────────────────────────────────────────────
44
+ /** Default initial budget per project (tokens) */
45
+ export const DEFAULT_BUDGET_SIZE = 2000;
46
+ /** Minimum base cost per save operation (tokens) — prevents zero-cost gaming */
47
+ export const MINIMUM_BASE_COST = 10;
48
+ /** UBI: tokens earned per hour since last save */
49
+ export const UBI_TOKENS_PER_HOUR = 100;
50
+ /** UBI: maximum tokens earnable via time-based UBI per session */
51
+ export const UBI_MAX_PER_SESSION = 500;
52
+ /** Bonus tokens for saving a `success` experience event */
53
+ export const SUCCESS_BONUS = 200;
54
+ /** Bonus tokens for saving a `learning` experience event */
55
+ export const LEARNING_BONUS = 100;
56
+ /** Budget warning threshold (below this, show advisory) */
57
+ export const LOW_BUDGET_THRESHOLD = 300;
58
+ // ─── Cost Multipliers ────────────────────────────────────────
59
+ /** Surprisal thresholds for cost multiplier tiers */
60
+ export const BOILERPLATE_THRESHOLD = 0.2;
61
+ export const NOVEL_THRESHOLD = 0.7;
62
+ /**
63
+ * Compute the cost multiplier based on content surprisal.
64
+ *
65
+ * - Low surprisal (< 0.2): 2.0× — penalizes boilerplate
66
+ * - Normal surprisal (0.2 - 0.7): 1.0× — standard rate
67
+ * - High surprisal (> 0.7): 0.5× — rewards novel insights
68
+ *
69
+ * @param surprisal - Surprisal score in [0.0, 1.0]
70
+ * @returns Cost multiplier
71
+ */
72
+ export function computeCostMultiplier(surprisal) {
73
+ if (!Number.isFinite(surprisal))
74
+ return 1.0;
75
+ if (surprisal < BOILERPLATE_THRESHOLD)
76
+ return 2.0;
77
+ if (surprisal > NOVEL_THRESHOLD)
78
+ return 0.5;
79
+ return 1.0;
80
+ }
81
+ // ─── Token Counting ───────────────────────────────────────────
82
+ /**
83
+ * Estimate token count from text using the standard 1 token ≈ 4 chars.
84
+ * Enforces the minimum base cost to prevent zero-cost gaming.
85
+ *
86
+ * @param text - The text to estimate tokens for
87
+ * @returns Estimated token count (minimum: MINIMUM_BASE_COST)
88
+ */
89
+ export function estimateTokens(text) {
90
+ if (!text || text.trim().length === 0)
91
+ return MINIMUM_BASE_COST;
92
+ return Math.max(MINIMUM_BASE_COST, Math.ceil(text.length / 4));
93
+ }
94
+ // ─── UBI Calculator ───────────────────────────────────────────
95
+ /**
96
+ * Compute Universal Basic Income tokens earned since last save.
97
+ *
98
+ * @param lastSaveTime - ISO timestamp of last ledger save (or null if first save)
99
+ * @param currentTime - Current time (default: now)
100
+ * @returns Tokens earned via UBI (capped at UBI_MAX_PER_SESSION)
101
+ */
102
+ export function computeUBI(lastSaveTime, currentTime = new Date()) {
103
+ if (!lastSaveTime)
104
+ return 0; // First save — no UBI
105
+ const lastSave = new Date(lastSaveTime);
106
+ if (isNaN(lastSave.getTime()))
107
+ return 0;
108
+ const hoursSinceLastSave = (currentTime.getTime() - lastSave.getTime()) / (1000 * 60 * 60);
109
+ if (hoursSinceLastSave <= 0)
110
+ return 0;
111
+ // NOTE: Do NOT use Math.floor here — it destroys fractional earnings.
112
+ // An agent saving every 15 min computes floor(0.25 * 100) = 0 tokens.
113
+ // Since cognitive_budget is REAL (SQLite) / float8 (Postgres), fractional
114
+ // values are natively supported. Only round at the UI display layer.
115
+ const earned = hoursSinceLastSave * UBI_TOKENS_PER_HOUR;
116
+ return Math.min(earned, UBI_MAX_PER_SESSION);
117
+ }
118
+ /**
119
+ * Compute bonus tokens for specific experience event types.
120
+ *
121
+ * @param eventType - The experience event type
122
+ * @returns Bonus tokens to add to budget
123
+ */
124
+ export function computeEventBonus(eventType) {
125
+ switch (eventType) {
126
+ case 'success': return SUCCESS_BONUS;
127
+ case 'learning': return LEARNING_BONUS;
128
+ default: return 0;
129
+ }
130
+ }
131
+ // ─── Budget Manager ───────────────────────────────────────────
132
+ /**
133
+ * Stateless budget operations.
134
+ *
135
+ * The budget is stored as a number in session_handoffs.cognitive_budget.
136
+ * These functions compute the new balance — they don't persist anything.
137
+ * The caller (ledgerHandlers.ts) is responsible for persistence.
138
+ */
139
+ /**
140
+ * Process a budget spend operation.
141
+ *
142
+ * @param currentBalance - Current budget balance
143
+ * @param rawTokenCost - Raw token cost of the entry
144
+ * @param surprisal - Surprisal score of the content [0, 1]
145
+ * @param budgetSize - Initial budget size (for diagnostics)
146
+ * @returns BudgetResult with new balance, warnings, and diagnostics
147
+ */
148
+ export function spendBudget(currentBalance, rawTokenCost, surprisal, budgetSize = DEFAULT_BUDGET_SIZE) {
149
+ const safeCost = Math.max(MINIMUM_BASE_COST, rawTokenCost);
150
+ const multiplier = computeCostMultiplier(surprisal);
151
+ const adjustedCost = Math.ceil(safeCost * multiplier);
152
+ const newBalance = currentBalance - adjustedCost;
153
+ const remaining = Math.max(0, newBalance);
154
+ let warning;
155
+ if (newBalance <= 0) {
156
+ warning = `⚠️ Cognitive budget exhausted (${remaining}/${budgetSize} tokens). ` +
157
+ 'Consider saving more concise, high-signal entries. ' +
158
+ 'Budget recovers passively over time (+100 tokens/hour).';
159
+ }
160
+ else if (newBalance < LOW_BUDGET_THRESHOLD) {
161
+ warning = `⚡ Cognitive budget running low (${remaining}/${budgetSize} tokens). ` +
162
+ 'Prioritize novel, dense entries to reduce cost.';
163
+ }
164
+ return {
165
+ allowed: true, // Always allow — graceful degradation
166
+ spent: adjustedCost,
167
+ remaining,
168
+ warning,
169
+ surprisal,
170
+ costMultiplier: multiplier,
171
+ };
172
+ }
173
+ /**
174
+ * Apply Universal Basic Income + event bonuses to a budget balance.
175
+ *
176
+ * @param currentBalance - Current budget balance
177
+ * @param lastSaveTime - ISO timestamp of last save
178
+ * @param eventType - Optional event type for bonus
179
+ * @param budgetSize - Maximum budget cap
180
+ * @returns New balance after UBI + bonuses (capped at budgetSize)
181
+ */
182
+ export function applyEarnings(currentBalance, lastSaveTime, eventType, budgetSize = DEFAULT_BUDGET_SIZE) {
183
+ const ubiEarned = computeUBI(lastSaveTime);
184
+ const bonusEarned = computeEventBonus(eventType);
185
+ // Cap at initial budget size — can't exceed maximum
186
+ const newBalance = Math.min(budgetSize, currentBalance + ubiEarned + bonusEarned);
187
+ return { newBalance, ubiEarned, bonusEarned };
188
+ }
189
+ /**
190
+ * Format budget diagnostics for inclusion in MCP response text.
191
+ *
192
+ * @param result - The BudgetResult from spendBudget()
193
+ * @param budgetSize - Initial budget size
194
+ * @param ubiEarned - Tokens earned from UBI this operation
195
+ * @param bonusEarned - Tokens earned from event bonus
196
+ * @returns Formatted diagnostic string
197
+ */
198
+ export function formatBudgetDiagnostics(result, budgetSize = DEFAULT_BUDGET_SIZE, ubiEarned = 0, bonusEarned = 0) {
199
+ const parts = [];
200
+ // Budget line
201
+ const barLength = 20;
202
+ const fillLength = Math.round((result.remaining / budgetSize) * barLength);
203
+ const bar = '█'.repeat(Math.max(0, fillLength)) + '░'.repeat(Math.max(0, barLength - fillLength));
204
+ parts.push(`💰 Budget: ${bar} ${result.remaining}/${budgetSize}`);
205
+ // Surprisal line
206
+ if (result.surprisal !== undefined) {
207
+ const surprisalLabel = result.surprisal < BOILERPLATE_THRESHOLD ? 'boilerplate'
208
+ : result.surprisal > NOVEL_THRESHOLD ? 'novel'
209
+ : 'standard';
210
+ parts.push(`📊 Surprisal: ${result.surprisal.toFixed(2)} (${surprisalLabel}) — cost: ${result.costMultiplier?.toFixed(1)}×`);
211
+ }
212
+ // Cost line
213
+ parts.push(`🪙 Spent: ${result.spent} tokens`);
214
+ // Earnings line (if any)
215
+ if (ubiEarned > 0 || bonusEarned > 0) {
216
+ const earningParts = [];
217
+ if (ubiEarned > 0)
218
+ earningParts.push(`+${Math.round(ubiEarned)} UBI`);
219
+ if (bonusEarned > 0)
220
+ earningParts.push(`+${Math.round(bonusEarned)} bonus`);
221
+ parts.push(`📈 Earned: ${earningParts.join(', ')}`);
222
+ }
223
+ return parts.join('\n');
224
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Surprisal Gate — Vector-Based Novelty Scoring (v9.0)
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * PURPOSE:
6
+ * Computes the information-theoretic "surprisal" of an incoming
7
+ * memory entry by measuring its semantic distance from recent entries.
8
+ *
9
+ * WHY NOT TF-IDF:
10
+ * A naive TF-IDF approach would require downloading all summaries
11
+ * into V8 memory and running a custom JS tokenizer. On projects with
12
+ * 10K+ entries (common after Universal Import), this blocks the
13
+ * Node.js event loop for seconds, causing MCP handshake timeouts.
14
+ *
15
+ * VECTOR-BASED SURPRISAL:
16
+ * Surprisal = 1 - max_similarity_to_recent_entries
17
+ *
18
+ * When the agent tries to save an entry, we embed the summary
19
+ * (already happening in the save flow) and query the DB for the
20
+ * single most similar entry from the last 7 days.
21
+ *
22
+ * - Similarity 0.95 → Surprisal 0.05 → "You're repeating yourself" → 2× cost
23
+ * - Similarity 0.40 → Surprisal 0.60 → "Completely novel thought" → 0.5× cost
24
+ *
25
+ * This uses the existing native sqlite-vec index, takes < 5ms,
26
+ * uses zero extra memory, and is far more accurate than word counting.
27
+ *
28
+ * FILES THAT IMPORT THIS:
29
+ * - src/tools/ledgerHandlers.ts (surprisal computation during save)
30
+ * ═══════════════════════════════════════════════════════════════════
31
+ */
32
+ import { debugLog } from "../utils/logger.js";
33
+ // ─── Constants ────────────────────────────────────────────────
34
+ /** Maximum age of entries to compare against (days) */
35
+ export const RECENCY_WINDOW_DAYS = 7;
36
+ /** Number of similar entries to fetch for comparison */
37
+ export const TOP_K = 1;
38
+ /** Similarity above which content is considered boilerplate */
39
+ export const BOILERPLATE_SIMILARITY = 0.80;
40
+ /** Similarity below which content is considered novel */
41
+ export const NOVEL_SIMILARITY = 0.30;
42
+ // ─── Core Computation ─────────────────────────────────────────
43
+ /**
44
+ * Compute surprisal from a semantic similarity score.
45
+ *
46
+ * This is the pure math core — no I/O. The caller is responsible
47
+ * for running the actual vector search to find maxSimilarity.
48
+ *
49
+ * @param maxSimilarity - Cosine similarity to the most similar recent entry (0-1)
50
+ * @returns SurprisalResult with classification
51
+ */
52
+ export function computeSurprisal(maxSimilarity) {
53
+ // Guard: no recent entries found (first entry in project) → maximum novelty
54
+ if (!Number.isFinite(maxSimilarity) || maxSimilarity < 0) {
55
+ return {
56
+ surprisal: 1.0,
57
+ maxSimilarity: 0.0,
58
+ isBoilerplate: false,
59
+ isNovel: true,
60
+ };
61
+ }
62
+ // Clamp to [0, 1]
63
+ const clamped = Math.min(1.0, Math.max(0.0, maxSimilarity));
64
+ const surprisal = 1.0 - clamped;
65
+ return {
66
+ surprisal,
67
+ maxSimilarity: clamped,
68
+ isBoilerplate: clamped >= BOILERPLATE_SIMILARITY,
69
+ isNovel: clamped <= NOVEL_SIMILARITY,
70
+ };
71
+ }
72
+ /**
73
+ * Compute surprisal using the existing storage backend's vector search.
74
+ *
75
+ * This is the integration wrapper. It:
76
+ * 1. Takes the query embedding (already generated for the save flow)
77
+ * 2. Finds the most similar recent entry via sqlite-vec
78
+ * 3. Computes surprisal = 1 - max_similarity
79
+ *
80
+ * Falls back to surprisal=0.5 (neutral) on any error, to avoid
81
+ * blocking saves due to search failures.
82
+ *
83
+ * @param searchFn - The storage backend's searchMemory function
84
+ * @param queryEmbedding - JSON-stringified embedding of the new entry
85
+ * @param project - Project scope
86
+ * @param userId - Tenant ID
87
+ * @returns SurprisalResult
88
+ */
89
+ export async function computeVectorSurprisal(searchFn, queryEmbedding, project, userId) {
90
+ try {
91
+ // Search for the single most similar recent entry
92
+ // Using a very low threshold (0.0) to get the closest match regardless
93
+ const results = await searchFn({
94
+ queryEmbedding,
95
+ project,
96
+ limit: TOP_K,
97
+ similarityThreshold: 0.0, // Get closest match regardless of distance
98
+ userId,
99
+ });
100
+ if (results.length === 0) {
101
+ // No existing entries → fully novel
102
+ debugLog('[surprisal] No recent entries found — maximum novelty');
103
+ return computeSurprisal(-1);
104
+ }
105
+ const maxSimilarity = results[0].similarity;
106
+ debugLog(`[surprisal] Max similarity to recent entries: ${maxSimilarity.toFixed(3)}`);
107
+ return computeSurprisal(maxSimilarity);
108
+ }
109
+ catch (err) {
110
+ // Non-fatal: fall back to neutral surprisal
111
+ debugLog(`[surprisal] Vector search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
112
+ return {
113
+ surprisal: 0.5,
114
+ maxSimilarity: 0.5,
115
+ isBoilerplate: false,
116
+ isNovel: false,
117
+ };
118
+ }
119
+ }
@@ -107,6 +107,11 @@ export async function propagateActivation(anchors, linkFetcher, config = {}) {
107
107
  const hopDistance = new Map();
108
108
  // Track visited edges to prevent cyclic re-traversal
109
109
  const visitedEdges = new Set();
110
+ // v9.0: Track energy flow weights for valence propagation
111
+ // Maps targetId → Array<{ sourceId, weight }> representing which nodes
112
+ // contributed energy and how much. Used by propagateValence() to compute
113
+ // energy-weighted average valence for discovered nodes.
114
+ const flowWeights = new Map();
110
115
  // Total edges traversed for telemetry
111
116
  let totalEdgesTraversed = 0;
112
117
  // Initialize with anchor scores
@@ -120,6 +125,7 @@ export async function propagateActivation(anchors, linkFetcher, config = {}) {
120
125
  return {
121
126
  results,
122
127
  telemetry: buildTelemetry(results, anchors, 0, 0, startMs),
128
+ flowWeights: new Map(),
123
129
  };
124
130
  }
125
131
  // ─── Propagation Loop ────────────────────────────────────
@@ -138,13 +144,20 @@ export async function propagateActivation(anchors, linkFetcher, config = {}) {
138
144
  break;
139
145
  }
140
146
  totalEdgesTraversed += edges.length;
141
- // Compute out-degree per active source node (for fan effect)
147
+ // Compute out-degree per active source node (for forward fan effect)
142
148
  const outDegree = new Map();
143
149
  for (const edge of edges) {
144
150
  if (activeNodes.has(edge.source_id)) {
145
151
  outDegree.set(edge.source_id, (outDegree.get(edge.source_id) || 0) + 1);
146
152
  }
147
153
  }
154
+ // Compute in-degree per active target node (for backward fan effect)
155
+ const inDegree = new Map();
156
+ for (const edge of edges) {
157
+ if (activeNodes.has(edge.target_id)) {
158
+ inDegree.set(edge.target_id, (inDegree.get(edge.target_id) || 0) + 1);
159
+ }
160
+ }
148
161
  // Next-iteration activation: starts with current values (activation persists)
149
162
  const nextNodes = new Map(activeNodes);
150
163
  for (const edge of edges) {
@@ -159,6 +172,10 @@ export async function propagateActivation(anchors, linkFetcher, config = {}) {
159
172
  const dampedFan = Math.log(degree + Math.E);
160
173
  const flow = cfg.spreadFactor * (strength * sourceEnergy / dampedFan);
161
174
  nextNodes.set(edge.target_id, (nextNodes.get(edge.target_id) || 0) + flow);
175
+ // v9.0: Record flow for valence propagation
176
+ if (!flowWeights.has(edge.target_id))
177
+ flowWeights.set(edge.target_id, []);
178
+ flowWeights.get(edge.target_id).push({ sourceId: edge.source_id, weight: flow });
162
179
  // Track hop distance (minimum)
163
180
  const sourceHops = hopDistance.get(edge.source_id) ?? 0;
164
181
  const currentTargetHops = hopDistance.get(edge.target_id);
@@ -171,8 +188,16 @@ export async function propagateActivation(anchors, linkFetcher, config = {}) {
171
188
  if (activeNodes.has(edge.target_id) && !visitedEdges.has(reverseEdgeKey)) {
172
189
  visitedEdges.add(reverseEdgeKey);
173
190
  const targetEnergy = activeNodes.get(edge.target_id);
174
- const flow = (cfg.spreadFactor * 0.5) * (strength * targetEnergy);
191
+ // Dampened fan effect for backward flow: prevents hub nodes with many
192
+ // inbound edges from blasting energy to all sources equally.
193
+ const inDegreeCount = inDegree.get(edge.target_id) || 1;
194
+ const dampedFanBack = Math.log(inDegreeCount + Math.E);
195
+ const flow = (cfg.spreadFactor * 0.5) * (strength * targetEnergy / dampedFanBack);
175
196
  nextNodes.set(edge.source_id, (nextNodes.get(edge.source_id) || 0) + flow);
197
+ // v9.0: Record backward flow for valence propagation
198
+ if (!flowWeights.has(edge.source_id))
199
+ flowWeights.set(edge.source_id, []);
200
+ flowWeights.get(edge.source_id).push({ sourceId: edge.target_id, weight: flow });
176
201
  // Track hop distance for backward discoveries
177
202
  const targetHops = hopDistance.get(edge.target_id) ?? 0;
178
203
  const currentSourceHops = hopDistance.get(edge.source_id);
@@ -190,6 +215,7 @@ export async function propagateActivation(anchors, linkFetcher, config = {}) {
190
215
  return {
191
216
  results,
192
217
  telemetry: buildTelemetry(results, anchors, cfg.iterations, totalEdgesTraversed, startMs),
218
+ flowWeights,
193
219
  };
194
220
  }
195
221
  // ─── Helpers ──────────────────────────────────────────────────