prism-mcp-server 9.4.2 β 9.4.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.
- package/README.md +2 -1
- package/dist/cli.js +0 -0
- package/dist/lifecycle.js +7 -3
- package/dist/server.js +0 -0
- package/dist/utils/universalImporter.js +0 -0
- package/package.json +1 -3
- package/dist/memory/cognitiveBudget.js +0 -224
- package/dist/memory/surprisalGate.js +0 -119
- package/dist/memory/synapseEngine.js +0 -248
- package/dist/memory/valenceEngine.js +0 -234
- package/dist/utils/llm/adapters/ollama.js +0 -153
package/README.md
CHANGED
|
@@ -826,8 +826,9 @@ The Generator strips the `console.log`, resubmits, and the next `EVALUATE` retur
|
|
|
826
826
|
|
|
827
827
|
## π What's New
|
|
828
828
|
|
|
829
|
-
> **Current release: v9.4.
|
|
829
|
+
> **Current release: v9.4.3 β ESM Bundling Fix (async_hooks)**
|
|
830
830
|
|
|
831
|
+
- π§ **v9.4.3 β ESM Bundling Fix:** Bundled dist had inlined OpenTelemetry CJS `require("async_hooks")` into ESM chunks, causing `Dynamic require of "async_hooks" is not supported` at runtime. Rebuilt with `tsc`. Affects CLI, session save/load, and MCP server startup.
|
|
831
832
|
- π **v9.4.2 β Shell Injection Fix:** Deep code review found shell injection in `getGitDrift()` β `oldSha` was interpolated into `execSync` template string. Fixed with SHA format validation + `execFileSync` (no shell). Defense-in-depth.
|
|
832
833
|
- π **v9.4.1 β Security Hardening & Bidirectional Sync:** Two-pass adversarial audit found 18 vulnerabilities (4C/5H/9M) β 17 fixed. Critical: fail-closed rate limiter, path traversal guards, error sanitization. High: plan name alignment (revenue fix), CORS allowlist, settings injection prevention. New: bidirectional `prism sync push` CLI command pushes local SQLite β Supabase, JWT enrichment eliminates N+1 DB queries, concurrency counter guaranteed via `try/finally`, 10MB request body limits.
|
|
833
834
|
- π― **v9.3.0 β TurboQuant ResidualNorm Tiebreaker:** Configurable ranking optimization for Tier-2 search. When compressed cosine scores are within Ξ΅ of each other, prefers the candidate with lower `residualNorm` (more trustworthy compressed representation). `PRISM_TURBOQUANT_TIEBREAKER_EPSILON=0.005` gives +2pp R@1, +1pp R@5. Empirically validated at N=5K with A/B test. 1066 tests, 0 regressions. Inspired by [@m13v's suggestion](https://github.com/xiaowu0162/LongMemEval/issues/31).
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/lifecycle.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import * as fs from "fs";
|
|
9
9
|
import * as path from "path";
|
|
10
10
|
import * as os from "os";
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from "child_process";
|
|
12
12
|
import { closeConfigStorage } from "./storage/configStorage.js";
|
|
13
13
|
import { getStorage } from "./storage/index.js";
|
|
14
14
|
import { shutdownTelemetry } from "./utils/telemetry.js";
|
|
@@ -65,8 +65,12 @@ function isOrphanProcess(pid) {
|
|
|
65
65
|
return false;
|
|
66
66
|
}
|
|
67
67
|
try {
|
|
68
|
-
//
|
|
69
|
-
|
|
68
|
+
// SECURITY: Use execFileSync (no shell) to prevent command injection.
|
|
69
|
+
// The PID comes from a file that could be tampered with by another process.
|
|
70
|
+
const ppid = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
timeout: 5000,
|
|
73
|
+
}).trim();
|
|
70
74
|
return ppid === "1";
|
|
71
75
|
}
|
|
72
76
|
catch {
|
package/dist/server.js
CHANGED
|
File without changes
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "9.4.
|
|
3
|
+
"version": "9.4.4",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-mcp",
|
|
5
5
|
"description": "The Mind Palace for AI Agents β a true Cognitive Architecture with Hebbian learning (episodicβsemantic consolidation), ACT-R spreading activation (multi-hop causal reasoning), uncertainty-aware rejection gates (agents that know when they don't know), adversarial evaluation (anti-sycophancy), fail-closed Dark Factory pipelines, persistent memory (SQLite/Supabase), multi-agent Hivemind, time travel & visual dashboard. Zero-config local mode.",
|
|
6
6
|
"module": "index.ts",
|
|
@@ -102,7 +102,6 @@
|
|
|
102
102
|
},
|
|
103
103
|
"dependencies": {
|
|
104
104
|
"@anthropic-ai/sdk": "^0.81.0",
|
|
105
|
-
"@google-cloud/discoveryengine": "^2.5.3",
|
|
106
105
|
"@google/generative-ai": "^0.24.1",
|
|
107
106
|
"@libsql/client": "^0.17.2",
|
|
108
107
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
@@ -116,7 +115,6 @@
|
|
|
116
115
|
"@tavily/core": "^0.6.0",
|
|
117
116
|
"cheerio": "^1.2.0",
|
|
118
117
|
"commander": "^14.0.3",
|
|
119
|
-
"dotenv": "^16.5.0",
|
|
120
118
|
"fflate": "^0.8.2",
|
|
121
119
|
"jose": "^6.2.2",
|
|
122
120
|
"jsdom": "^29.0.1",
|
|
@@ -1,224 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,119 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Synapse β Spreading Activation Engine (v8.0)
|
|
3
|
-
*
|
|
4
|
-
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
5
|
-
* PURPOSE:
|
|
6
|
-
* Multi-hop energy propagation through the memory_links graph.
|
|
7
|
-
* Replaces both the old SQL-coupled spreadingActivation.ts AND the
|
|
8
|
-
* shallow 1-hop candidateScopedSpreadingActivation() from v7.0.
|
|
9
|
-
*
|
|
10
|
-
* PAPER BASIS:
|
|
11
|
-
* ACT-R spreading activation (Anderson, 2004) extended with:
|
|
12
|
-
* - Dampened fan effect: 1/ln(degree + e)
|
|
13
|
-
* - Asymmetric bidirectional flow (forward 100%, backward 50%)
|
|
14
|
-
* - Lateral inhibition (soft cap per iteration, hard cap on output)
|
|
15
|
-
* - Visited-edge tracking to prevent cyclic energy amplification
|
|
16
|
-
*
|
|
17
|
-
* DESIGN:
|
|
18
|
-
* All functions are PURE β zero I/O, zero imports from storage or SQL.
|
|
19
|
-
* Link data is fetched via a LinkFetcher callback injected by the caller
|
|
20
|
-
* (SqliteStorage, SupabaseStorage, or test mock).
|
|
21
|
-
* NOTE: debugLog (stderr diagnostic) is the sole side effect β it only
|
|
22
|
-
* fires when PRISM_DEBUG_LOGGING=true and does not affect correctness.
|
|
23
|
-
*
|
|
24
|
-
* PERFORMANCE:
|
|
25
|
-
* Bounded by O(T Γ softCap) where T=iterations (default 3) and
|
|
26
|
-
* softCap=20. Worst case: 3 Γ 20 node expansions = 60 link fetches.
|
|
27
|
-
* Typical latency: 5-15ms on SQLite.
|
|
28
|
-
*
|
|
29
|
-
* FILES THAT IMPORT THIS:
|
|
30
|
-
* - src/storage/sqlite.ts (search methods)
|
|
31
|
-
* - src/tools/graphHandlers.ts (ACT-R re-ranking pipeline)
|
|
32
|
-
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
33
|
-
*/
|
|
34
|
-
import { debugLog } from "../utils/logger.js";
|
|
35
|
-
// βββ Default Configuration ββββββββββββββββββββββββββββββββββββ
|
|
36
|
-
export const DEFAULT_SYNAPSE_CONFIG = {
|
|
37
|
-
iterations: 3,
|
|
38
|
-
spreadFactor: 0.8,
|
|
39
|
-
lateralInhibition: 7,
|
|
40
|
-
softCap: 20,
|
|
41
|
-
};
|
|
42
|
-
// βββ Sigmoid Normalization ββββββββββββββββββββββββββββββββββββ
|
|
43
|
-
/**
|
|
44
|
-
* Squash activation energy into [0, 1] using a parameterized sigmoid.
|
|
45
|
-
*
|
|
46
|
-
* This prevents raw activation energy from overpowering the similarity
|
|
47
|
-
* component in the hybrid score. Without normalization, a node with
|
|
48
|
-
* 10 inbound paths could accumulate energy > 5.0, making the 0.3 weight
|
|
49
|
-
* mathematically dominate the 0.7 similarity weight.
|
|
50
|
-
*
|
|
51
|
-
* Calibration:
|
|
52
|
-
* midpoint = 0.5 β an activation of 0.5 maps to sigmoid output 0.5
|
|
53
|
-
* steepness = 2.0 β moderate discrimination
|
|
54
|
-
*
|
|
55
|
-
* @param energy - Raw accumulated activation energy
|
|
56
|
-
* @returns Normalized energy in (0, 1)
|
|
57
|
-
*/
|
|
58
|
-
export function normalizeActivationEnergy(energy) {
|
|
59
|
-
if (!Number.isFinite(energy)) {
|
|
60
|
-
return energy > 0 ? 1.0 : 0.0;
|
|
61
|
-
}
|
|
62
|
-
const midpoint = 0.5;
|
|
63
|
-
const steepness = 2.0;
|
|
64
|
-
const exponent = -steepness * (energy - midpoint);
|
|
65
|
-
if (exponent > 500)
|
|
66
|
-
return 0;
|
|
67
|
-
if (exponent < -500)
|
|
68
|
-
return 1;
|
|
69
|
-
return 1 / (1 + Math.exp(exponent));
|
|
70
|
-
}
|
|
71
|
-
// βββ Core Engine ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
72
|
-
/**
|
|
73
|
-
* Propagate activation energy through the memory_links graph.
|
|
74
|
-
*
|
|
75
|
-
* Algorithm:
|
|
76
|
-
* 1. Initialize active nodes with anchor similarity scores
|
|
77
|
-
* 2. For each iteration:
|
|
78
|
-
* a. Fetch ALL links connected to active nodes (via LinkFetcher)
|
|
79
|
-
* b. Compute per-source fan effect: 1 / ln(out-degree + e)
|
|
80
|
-
* c. Forward flow: source β target at S Γ strength Γ sourceEnergy / dampedFan
|
|
81
|
-
* d. Backward flow: target β source at (S Γ 0.5) Γ strength Γ targetEnergy
|
|
82
|
-
* e. Track visited edges to prevent cyclic re-traversal
|
|
83
|
-
* f. Soft lateral inhibition: keep top-softCap nodes
|
|
84
|
-
* 3. Final lateral inhibition: return top-M nodes
|
|
85
|
-
*
|
|
86
|
-
* @param anchors - Map of entry ID β initial activation (typically similarity score)
|
|
87
|
-
* @param linkFetcher - Async callback to batch-fetch links
|
|
88
|
-
* @param config - Engine configuration (uses defaults if omitted)
|
|
89
|
-
* @returns Array of SynapseResult sorted by activationEnergy descending
|
|
90
|
-
*/
|
|
91
|
-
export async function propagateActivation(anchors, linkFetcher, config = {}) {
|
|
92
|
-
const startMs = performance.now();
|
|
93
|
-
const cfg = { ...DEFAULT_SYNAPSE_CONFIG, ...config };
|
|
94
|
-
// Validate: spreadFactor must be < 1.0 for convergence guarantee
|
|
95
|
-
if (cfg.spreadFactor >= 1.0) {
|
|
96
|
-
debugLog(`[synapse] WARNING: spreadFactor=${cfg.spreadFactor} >= 1.0, clamping to 0.99`);
|
|
97
|
-
cfg.spreadFactor = 0.99;
|
|
98
|
-
}
|
|
99
|
-
// Clamp minimums to prevent silent result drops
|
|
100
|
-
if (cfg.lateralInhibition < 1)
|
|
101
|
-
cfg.lateralInhibition = 1;
|
|
102
|
-
if (cfg.softCap < 1)
|
|
103
|
-
cfg.softCap = 1;
|
|
104
|
-
// State: current activation energy per node
|
|
105
|
-
let activeNodes = new Map();
|
|
106
|
-
// Track minimum hop distance from any anchor
|
|
107
|
-
const hopDistance = new Map();
|
|
108
|
-
// Track visited edges to prevent cyclic re-traversal
|
|
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();
|
|
115
|
-
// Total edges traversed for telemetry
|
|
116
|
-
let totalEdgesTraversed = 0;
|
|
117
|
-
// Initialize with anchor scores
|
|
118
|
-
for (const [id, score] of anchors) {
|
|
119
|
-
activeNodes.set(id, score);
|
|
120
|
-
hopDistance.set(id, 0);
|
|
121
|
-
}
|
|
122
|
-
// Short-circuit: no iterations = return anchors as-is
|
|
123
|
-
if (cfg.iterations <= 0 || anchors.size === 0) {
|
|
124
|
-
const results = buildResults(activeNodes, anchors, hopDistance, cfg.lateralInhibition);
|
|
125
|
-
return {
|
|
126
|
-
results,
|
|
127
|
-
telemetry: buildTelemetry(results, anchors, 0, 0, startMs),
|
|
128
|
-
flowWeights: new Map(),
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
// βββ Propagation Loop ββββββββββββββββββββββββββββββββββββ
|
|
132
|
-
for (let t = 0; t < cfg.iterations; t++) {
|
|
133
|
-
const currentIds = Array.from(activeNodes.keys());
|
|
134
|
-
if (currentIds.length === 0)
|
|
135
|
-
break;
|
|
136
|
-
// Fetch ALL links connected to currently active nodes
|
|
137
|
-
// No global LIMIT β engine controls explosion via softCap
|
|
138
|
-
let edges;
|
|
139
|
-
try {
|
|
140
|
-
edges = await linkFetcher(currentIds);
|
|
141
|
-
}
|
|
142
|
-
catch (err) {
|
|
143
|
-
debugLog(`[synapse] Link fetch failed at iteration ${t} (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
146
|
-
totalEdgesTraversed += edges.length;
|
|
147
|
-
// Compute out-degree per active source node (for forward fan effect)
|
|
148
|
-
const outDegree = new Map();
|
|
149
|
-
for (const edge of edges) {
|
|
150
|
-
if (activeNodes.has(edge.source_id)) {
|
|
151
|
-
outDegree.set(edge.source_id, (outDegree.get(edge.source_id) || 0) + 1);
|
|
152
|
-
}
|
|
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
|
-
}
|
|
161
|
-
// Next-iteration activation: starts with current values (activation persists)
|
|
162
|
-
const nextNodes = new Map(activeNodes);
|
|
163
|
-
for (const edge of edges) {
|
|
164
|
-
const edgeKey = `${edge.source_id}->${edge.target_id}`;
|
|
165
|
-
const strength = Number.isFinite(edge.strength) ? Math.max(0, Math.min(1, edge.strength)) : 0;
|
|
166
|
-
// ββ Forward flow: source is active, flows to target ββ
|
|
167
|
-
if (activeNodes.has(edge.source_id) && !visitedEdges.has(edgeKey)) {
|
|
168
|
-
visitedEdges.add(edgeKey);
|
|
169
|
-
const sourceEnergy = activeNodes.get(edge.source_id);
|
|
170
|
-
const degree = outDegree.get(edge.source_id) || 1;
|
|
171
|
-
// Dampened fan effect: prevents hub nodes from broadcasting equally
|
|
172
|
-
const dampedFan = Math.log(degree + Math.E);
|
|
173
|
-
const flow = cfg.spreadFactor * (strength * sourceEnergy / dampedFan);
|
|
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 });
|
|
179
|
-
// Track hop distance (minimum)
|
|
180
|
-
const sourceHops = hopDistance.get(edge.source_id) ?? 0;
|
|
181
|
-
const currentTargetHops = hopDistance.get(edge.target_id);
|
|
182
|
-
if (currentTargetHops === undefined || sourceHops + 1 < currentTargetHops) {
|
|
183
|
-
hopDistance.set(edge.target_id, sourceHops + 1);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
// ββ Backward flow: target is active, flows backward to source at 50% ββ
|
|
187
|
-
const reverseEdgeKey = `${edge.target_id}->${edge.source_id}`;
|
|
188
|
-
if (activeNodes.has(edge.target_id) && !visitedEdges.has(reverseEdgeKey)) {
|
|
189
|
-
visitedEdges.add(reverseEdgeKey);
|
|
190
|
-
const targetEnergy = activeNodes.get(edge.target_id);
|
|
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);
|
|
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 });
|
|
201
|
-
// Track hop distance for backward discoveries
|
|
202
|
-
const targetHops = hopDistance.get(edge.target_id) ?? 0;
|
|
203
|
-
const currentSourceHops = hopDistance.get(edge.source_id);
|
|
204
|
-
if (currentSourceHops === undefined || targetHops + 1 < currentSourceHops) {
|
|
205
|
-
hopDistance.set(edge.source_id, targetHops + 1);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
// Soft lateral inhibition: keep only top-softCap nodes to prevent explosion
|
|
210
|
-
const sorted = Array.from(nextNodes.entries()).sort((a, b) => b[1] - a[1]);
|
|
211
|
-
activeNodes = new Map(sorted.slice(0, cfg.softCap));
|
|
212
|
-
}
|
|
213
|
-
// βββ Final Output ββββββββββββββββββββββββββββββββββββββββ
|
|
214
|
-
const results = buildResults(activeNodes, anchors, hopDistance, cfg.lateralInhibition);
|
|
215
|
-
return {
|
|
216
|
-
results,
|
|
217
|
-
telemetry: buildTelemetry(results, anchors, cfg.iterations, totalEdgesTraversed, startMs),
|
|
218
|
-
flowWeights,
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
222
|
-
function buildResults(activeNodes, anchors, hopDistance, lateralInhibition) {
|
|
223
|
-
// Sort by activation energy descending, then apply hard lateral inhibition
|
|
224
|
-
const sorted = Array.from(activeNodes.entries())
|
|
225
|
-
.sort((a, b) => b[1] - a[1])
|
|
226
|
-
.slice(0, lateralInhibition);
|
|
227
|
-
return sorted.map(([id, energy]) => ({
|
|
228
|
-
id,
|
|
229
|
-
activationEnergy: energy,
|
|
230
|
-
hopsFromAnchor: hopDistance.get(id) ?? 0,
|
|
231
|
-
isDiscovered: !anchors.has(id),
|
|
232
|
-
}));
|
|
233
|
-
}
|
|
234
|
-
function buildTelemetry(results, anchors, iterations, edgesTraversed, startMs) {
|
|
235
|
-
const energies = results.map(r => r.activationEnergy);
|
|
236
|
-
const discovered = results.filter(r => !anchors.has(r.id)).length;
|
|
237
|
-
return {
|
|
238
|
-
nodesReturned: results.length,
|
|
239
|
-
nodesDiscovered: discovered,
|
|
240
|
-
maxActivationEnergy: energies.length > 0 ? Math.max(...energies) : 0,
|
|
241
|
-
avgActivationEnergy: energies.length > 0
|
|
242
|
-
? energies.reduce((a, b) => a + b, 0) / energies.length
|
|
243
|
-
: 0,
|
|
244
|
-
iterationsPerformed: iterations,
|
|
245
|
-
edgesTraversed,
|
|
246
|
-
durationMs: Math.round(performance.now() - startMs),
|
|
247
|
-
};
|
|
248
|
-
}
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Valence Engine β Affect-Tagged Memory (v9.0)
|
|
3
|
-
*
|
|
4
|
-
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
5
|
-
* PURPOSE:
|
|
6
|
-
* Implements Affective Cognitive Routing β every memory gets a
|
|
7
|
-
* "gut feeling" score from -1.0 (trauma) to +1.0 (success).
|
|
8
|
-
* Agents get warned when approaching historically problematic
|
|
9
|
-
* topics, and get green-light signals for proven-successful paths.
|
|
10
|
-
*
|
|
11
|
-
* AFFECTIVE SALIENCE PRINCIPLE:
|
|
12
|
-
* In human psychology, highly emotional memories β both extreme
|
|
13
|
-
* joy and extreme trauma β are retrieved MORE easily, not less.
|
|
14
|
-
* Therefore, the retrieval score uses |valence| (absolute magnitude)
|
|
15
|
-
* to BOOST salience, while the SIGN (Β±) is used purely for
|
|
16
|
-
* prompt injection / UX warnings.
|
|
17
|
-
*
|
|
18
|
-
* This prevents the Valence Retrieval Paradox where a failure
|
|
19
|
-
* memory gets pushed below the retrieval threshold, causing the
|
|
20
|
-
* agent to repeat the exact same mistake.
|
|
21
|
-
*
|
|
22
|
-
* DESIGN:
|
|
23
|
-
* All functions are PURE β zero I/O, zero imports from storage.
|
|
24
|
-
* Valence propagation through the Synapse graph uses energy-weighted
|
|
25
|
-
* transfer with fan-dampened flow and strict [-1, 1] clamping.
|
|
26
|
-
*
|
|
27
|
-
* FILES THAT IMPORT THIS:
|
|
28
|
-
* - src/storage/sqlite.ts (auto-derive valence on save)
|
|
29
|
-
* - src/tools/graphHandlers.ts (hybrid scoring + UX warnings)
|
|
30
|
-
* - src/memory/synapseEngine.ts (valence propagation)
|
|
31
|
-
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
32
|
-
*/
|
|
33
|
-
// βββ Valence Derivation βββββββββββββββββββββββββββββββββββββββ
|
|
34
|
-
/**
|
|
35
|
-
* Deterministic mapping from experience event type to valence score.
|
|
36
|
-
*
|
|
37
|
-
* | Event Type | Valence | Rationale |
|
|
38
|
-
* |---------------------|---------|------------------------------------|
|
|
39
|
-
* | success | +0.8 | Positive reinforcement |
|
|
40
|
-
* | failure | -0.8 | Strong negative signal |
|
|
41
|
-
* | correction | -0.6 | User had to fix agent |
|
|
42
|
-
* | learning | +0.4 | New knowledge acquired |
|
|
43
|
-
* | validation_result | Β±0.6 | Pass β +0.6, Fail β -0.6 |
|
|
44
|
-
* | session / default | 0.0 | Neutral β no sentiment signal |
|
|
45
|
-
*
|
|
46
|
-
* @param eventType - The experience event type from session_ledger
|
|
47
|
-
* @param notes - Optional notes field (for validation_result pass/fail)
|
|
48
|
-
* @returns Valence score in [-1.0, +1.0]
|
|
49
|
-
*/
|
|
50
|
-
export function deriveValence(eventType, notes) {
|
|
51
|
-
if (!eventType || eventType === 'session')
|
|
52
|
-
return 0.0;
|
|
53
|
-
switch (eventType) {
|
|
54
|
-
case 'success':
|
|
55
|
-
return 0.8;
|
|
56
|
-
case 'failure':
|
|
57
|
-
return -0.8;
|
|
58
|
-
case 'correction':
|
|
59
|
-
return -0.6;
|
|
60
|
-
case 'learning':
|
|
61
|
-
return 0.4;
|
|
62
|
-
case 'validation_result':
|
|
63
|
-
// Check notes for pass/fail indication
|
|
64
|
-
if (notes) {
|
|
65
|
-
const lower = notes.toLowerCase();
|
|
66
|
-
if (lower.includes('pass') || lower.includes('success') || lower.includes('green')) {
|
|
67
|
-
return 0.6;
|
|
68
|
-
}
|
|
69
|
-
if (lower.includes('fail') || lower.includes('error') || lower.includes('blocked')) {
|
|
70
|
-
return -0.6;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
// Ambiguous validation result β slightly negative (cautious)
|
|
74
|
-
return -0.2;
|
|
75
|
-
default:
|
|
76
|
-
return 0.0;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
// βββ Retrieval Salience (Magnitude-Based) βββββββββββββββββββββ
|
|
80
|
-
/**
|
|
81
|
-
* Compute the retrieval salience boost from valence.
|
|
82
|
-
*
|
|
83
|
-
* Uses ABSOLUTE MAGNITUDE β both extreme positive and extreme negative
|
|
84
|
-
* memories are more salient (more retrievable). The sign is preserved
|
|
85
|
-
* separately for UX warnings.
|
|
86
|
-
*
|
|
87
|
-
* @param valence - Raw valence score in [-1.0, +1.0]
|
|
88
|
-
* @returns Salience boost in [0.0, 1.0]
|
|
89
|
-
*/
|
|
90
|
-
export function valenceSalience(valence) {
|
|
91
|
-
if (valence == null || !Number.isFinite(valence))
|
|
92
|
-
return 0.0;
|
|
93
|
-
return Math.min(1.0, Math.abs(valence));
|
|
94
|
-
}
|
|
95
|
-
// βββ UX Warning / Signal Tags βββββββββββββββββββββββββββββββββ
|
|
96
|
-
/**
|
|
97
|
-
* Format a valence score into a human-readable emoji tag for display
|
|
98
|
-
* in search results and context output.
|
|
99
|
-
*
|
|
100
|
-
* @param valence - Raw valence score in [-1.0, +1.0]
|
|
101
|
-
* @returns Emoji tag string, or empty string for neutral
|
|
102
|
-
*/
|
|
103
|
-
export function formatValenceTag(valence) {
|
|
104
|
-
if (valence == null || !Number.isFinite(valence))
|
|
105
|
-
return '';
|
|
106
|
-
if (valence <= -0.5)
|
|
107
|
-
return 'π΄';
|
|
108
|
-
if (valence <= -0.2)
|
|
109
|
-
return 'π ';
|
|
110
|
-
if (valence >= 0.5)
|
|
111
|
-
return 'π’';
|
|
112
|
-
if (valence >= 0.2)
|
|
113
|
-
return 'π΅';
|
|
114
|
-
return 'π‘'; // Neutral zone (-0.2 to +0.2)
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Determine if a set of retrieved memories should trigger a
|
|
118
|
-
* negative valence warning in the response.
|
|
119
|
-
*
|
|
120
|
-
* @param avgValence - Average valence across top results
|
|
121
|
-
* @param threshold - Warning threshold (default: -0.3)
|
|
122
|
-
* @returns true if the agent should be warned about historical friction
|
|
123
|
-
*/
|
|
124
|
-
export function shouldWarnNegativeValence(avgValence, threshold = -0.3) {
|
|
125
|
-
return Number.isFinite(avgValence) && avgValence < threshold;
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Generate a contextual warning message based on average valence.
|
|
129
|
-
*
|
|
130
|
-
* @param avgValence - Average valence across top results
|
|
131
|
-
* @returns Warning/signal string to inject into MCP response, or null
|
|
132
|
-
*/
|
|
133
|
-
export function generateValenceWarning(avgValence) {
|
|
134
|
-
if (!Number.isFinite(avgValence))
|
|
135
|
-
return null;
|
|
136
|
-
if (avgValence < -0.5) {
|
|
137
|
-
return 'β οΈ **Caution:** This topic is strongly correlated with historical failures and corrections. Consider reviewing past decisions before proceeding.';
|
|
138
|
-
}
|
|
139
|
-
if (avgValence < -0.3) {
|
|
140
|
-
return 'β οΈ **Warning:** This area has mixed historical outcomes. Approach with awareness of prior friction.';
|
|
141
|
-
}
|
|
142
|
-
if (avgValence > 0.5) {
|
|
143
|
-
return 'π’ **High Signal:** This path has historically led to successful outcomes.';
|
|
144
|
-
}
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Propagate valence through Synapse activation results.
|
|
149
|
-
*
|
|
150
|
-
* Each node's propagated valence is computed as the energy-weighted
|
|
151
|
-
* average of its sources' valence, with fan-dampening to prevent
|
|
152
|
-
* hub explosion. The final value is strictly clamped to [-1.0, +1.0].
|
|
153
|
-
*
|
|
154
|
-
* IMPORTANT β Fan-Dampening:
|
|
155
|
-
* If 50 neutral nodes point to 1 negative node, the negative valence
|
|
156
|
-
* must NOT multiply to -50.0. The incoming valence is averaged over
|
|
157
|
-
* the fan-in count, then clamped.
|
|
158
|
-
*
|
|
159
|
-
* Algorithm:
|
|
160
|
-
* For each non-anchor node with incoming energy flows:
|
|
161
|
-
* propagatedValence = Ξ£(flow_weight Γ source_valence) / Ξ£(flow_weight)
|
|
162
|
-
* Clamped to [-1.0, +1.0].
|
|
163
|
-
*
|
|
164
|
-
* Anchor nodes retain their original valence unchanged.
|
|
165
|
-
*
|
|
166
|
-
* @param synapseResults - Node IDs with their activation energy from Synapse
|
|
167
|
-
* @param valenceLookup - Map from entry ID β raw valence (from DB)
|
|
168
|
-
* @param flowWeights - Map from `targetId` β Array<{ sourceId, weight }> representing
|
|
169
|
-
* the energy flows that contributed to each node's activation
|
|
170
|
-
* @returns Map from entry ID β propagated valence
|
|
171
|
-
*/
|
|
172
|
-
export function propagateValence(synapseResults, valenceLookup, flowWeights) {
|
|
173
|
-
const result = new Map();
|
|
174
|
-
for (const node of synapseResults) {
|
|
175
|
-
// Anchor nodes: use their direct valence
|
|
176
|
-
if (!node.isDiscovered) {
|
|
177
|
-
const directValence = valenceLookup.get(node.id) ?? 0.0;
|
|
178
|
-
result.set(node.id, clampValence(directValence));
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
// Discovered nodes: compute energy-weighted average from source flows
|
|
182
|
-
const flows = flowWeights?.get(node.id);
|
|
183
|
-
if (!flows || flows.length === 0) {
|
|
184
|
-
// No flow data β use direct valence if available, else neutral
|
|
185
|
-
result.set(node.id, clampValence(valenceLookup.get(node.id) ?? 0.0));
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
let weightedValenceSum = 0;
|
|
189
|
-
let totalWeight = 0;
|
|
190
|
-
for (const flow of flows) {
|
|
191
|
-
const sourceValence = valenceLookup.get(flow.sourceId) ?? result.get(flow.sourceId) ?? 0.0;
|
|
192
|
-
const absWeight = Math.abs(flow.weight);
|
|
193
|
-
weightedValenceSum += absWeight * sourceValence;
|
|
194
|
-
totalWeight += absWeight;
|
|
195
|
-
}
|
|
196
|
-
const propagated = totalWeight > 0 ? weightedValenceSum / totalWeight : 0.0;
|
|
197
|
-
result.set(node.id, clampValence(propagated));
|
|
198
|
-
}
|
|
199
|
-
return result;
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Clamp a valence value to the valid range [-1.0, +1.0].
|
|
203
|
-
* Returns 0.0 for non-finite values.
|
|
204
|
-
*/
|
|
205
|
-
export function clampValence(v) {
|
|
206
|
-
if (!Number.isFinite(v))
|
|
207
|
-
return 0.0;
|
|
208
|
-
return Math.max(-1.0, Math.min(1.0, v));
|
|
209
|
-
}
|
|
210
|
-
// βββ Hybrid Score Component βββββββββββββββββββββββββββββββββββ
|
|
211
|
-
/**
|
|
212
|
-
* Compute the hybrid retrieval score incorporating valence salience.
|
|
213
|
-
*
|
|
214
|
-
* Formula: 0.65 Γ similarity + 0.25 Γ normalizedActivation + 0.1 Γ |valence|
|
|
215
|
-
*
|
|
216
|
-
* The valence component uses ABSOLUTE MAGNITUDE β both extreme positive
|
|
217
|
-
* and extreme negative memories get a retrieval boost. Only the sign
|
|
218
|
-
* matters for UX warnings, not for ranking.
|
|
219
|
-
*
|
|
220
|
-
* @param similarity - Semantic similarity score [0, 1]
|
|
221
|
-
* @param normalizedActivation - Sigmoid-normalized activation energy [0, 1]
|
|
222
|
-
* @param valence - Raw valence score [-1, +1]
|
|
223
|
-
* @param weights - Optional weight overrides
|
|
224
|
-
* @returns Hybrid score in [0, 1]
|
|
225
|
-
*/
|
|
226
|
-
export function computeHybridScoreWithValence(similarity, normalizedActivation, valence, weights = {}) {
|
|
227
|
-
const wSim = weights.similarity ?? 0.65;
|
|
228
|
-
const wAct = weights.activation ?? 0.25;
|
|
229
|
-
const wVal = weights.valence ?? 0.10;
|
|
230
|
-
const safeSim = Number.isFinite(similarity) ? Math.max(0, Math.min(1, similarity)) : 0;
|
|
231
|
-
const safeAct = Number.isFinite(normalizedActivation) ? Math.max(0, Math.min(1, normalizedActivation)) : 0;
|
|
232
|
-
const safeVal = valenceSalience(valence); // Already returns [0, 1] magnitude
|
|
233
|
-
return wSim * safeSim + wAct * safeAct + wVal * safeVal;
|
|
234
|
-
}
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ollama Adapter (v1.0 β nomic-embed-text)
|
|
3
|
-
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
4
|
-
* PURPOSE:
|
|
5
|
-
* Implements LLMProvider using Ollama's native /api/embed REST endpoint for
|
|
6
|
-
* fully local, zero-cost text embeddings. No API key required β Ollama runs
|
|
7
|
-
* on localhost.
|
|
8
|
-
*
|
|
9
|
-
* TEXT GENERATION:
|
|
10
|
-
* This adapter is embeddings-only. generateText() throws an explicit error.
|
|
11
|
-
* Set text_provider separately (anthropic, openai, or gemini).
|
|
12
|
-
*
|
|
13
|
-
* EMBEDDING DIMENSION PARITY (768 dims):
|
|
14
|
-
* Prism's SQLite (sqlite-vec) and Supabase (pgvector) schemas define
|
|
15
|
-
* embedding columns as EXACTLY 768 dimensions.
|
|
16
|
-
*
|
|
17
|
-
* nomic-embed-text natively outputs 768 dims β zero truncation needed.
|
|
18
|
-
* It is the recommended default local model for Prism.
|
|
19
|
-
*
|
|
20
|
-
* SUPPORTED MODELS (all confirmed 768-dim via Ollama):
|
|
21
|
-
* nomic-embed-text β 768 dims, 274MB, best quality/size trade-off β
DEFAULT
|
|
22
|
-
* nomic-embed-text:v1.5 β 768 dims, 274MB, same (stable alias)
|
|
23
|
-
*
|
|
24
|
-
* Models to AVOID with this adapter (wrong dim count):
|
|
25
|
-
* mxbai-embed-large β 1024 dims β (use OpenAIAdapter instead)
|
|
26
|
-
* all-minilm β 384 dims β
|
|
27
|
-
* snowflake-arctic-embed β varies β
|
|
28
|
-
*
|
|
29
|
-
* BATCH EMBEDDINGS:
|
|
30
|
-
* Uses /api/embed (plural) which is the official Ollama batch endpoint
|
|
31
|
-
* introduced in Ollama β₯ 0.3.0. Falls back gracefully for older versions.
|
|
32
|
-
*
|
|
33
|
-
* CONFIG KEYS (Prism dashboard "AI Providers" tab OR environment variables):
|
|
34
|
-
* ollama_base_url β Base URL of Ollama server (default: http://localhost:11434)
|
|
35
|
-
* ollama_model β Embedding model (default: nomic-embed-text)
|
|
36
|
-
*
|
|
37
|
-
* USAGE:
|
|
38
|
-
* In the Prism dashboard, set:
|
|
39
|
-
* embedding_provider = ollama
|
|
40
|
-
* Optionally set ollama_base_url and ollama_model to override defaults.
|
|
41
|
-
*
|
|
42
|
-
* API REFERENCE:
|
|
43
|
-
* https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings
|
|
44
|
-
*/
|
|
45
|
-
import { getSettingSync } from "../../../storage/configStorage.js";
|
|
46
|
-
import { debugLog } from "../../logger.js";
|
|
47
|
-
// βββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
48
|
-
// Must match Prism's DB schema (sqlite-vec and pgvector column sizes).
|
|
49
|
-
const EMBEDDING_DIMS = 768;
|
|
50
|
-
// Generous character cap β nomic-embed-text has an 8192-token context window.
|
|
51
|
-
const MAX_EMBEDDING_CHARS = 8000;
|
|
52
|
-
const DEFAULT_BASE_URL = "http://localhost:11434";
|
|
53
|
-
const DEFAULT_MODEL = "nomic-embed-text";
|
|
54
|
-
// Connection retry settings β handles the common "forgot to start Ollama" race.
|
|
55
|
-
const MAX_RETRIES = 2;
|
|
56
|
-
const RETRY_DELAY_MS = 500;
|
|
57
|
-
// βββ Adapter βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
58
|
-
export class OllamaAdapter {
|
|
59
|
-
baseUrl;
|
|
60
|
-
model;
|
|
61
|
-
constructor() {
|
|
62
|
-
this.baseUrl = getSettingSync("ollama_base_url", DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
63
|
-
this.model = getSettingSync("ollama_model", DEFAULT_MODEL);
|
|
64
|
-
debugLog(`[OllamaAdapter] Initialized β baseUrl=${this.baseUrl}, model=${this.model}`);
|
|
65
|
-
}
|
|
66
|
-
// βββ Text Generation (Not Supported) ββββββββββββββββββββββββββββββββββββ
|
|
67
|
-
async generateText(_prompt, _systemInstruction) {
|
|
68
|
-
throw new Error("OllamaAdapter does not support text generation. " +
|
|
69
|
-
"Set text_provider to 'anthropic', 'openai', or 'gemini' in the dashboard.");
|
|
70
|
-
}
|
|
71
|
-
// βββ Batch Embedding Generation βββββββββββββββββββββββββββββββββββββββββ
|
|
72
|
-
async generateEmbeddings(texts) {
|
|
73
|
-
if (!texts || texts.length === 0)
|
|
74
|
-
return [];
|
|
75
|
-
const model = this.model;
|
|
76
|
-
// Word-safe truncation β consistent with Voyage and OpenAI adapters.
|
|
77
|
-
const truncatedTexts = texts.map(text => {
|
|
78
|
-
if (text.length > MAX_EMBEDDING_CHARS) {
|
|
79
|
-
const cut = text.slice(0, MAX_EMBEDDING_CHARS);
|
|
80
|
-
const lastSpace = cut.lastIndexOf(" ");
|
|
81
|
-
return lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
|
|
82
|
-
}
|
|
83
|
-
return text;
|
|
84
|
-
});
|
|
85
|
-
debugLog(`[OllamaAdapter] generateEmbeddings β model=${model}, count=${truncatedTexts.length}`);
|
|
86
|
-
// Retry loop β catches ECONNREFUSED when Ollama service hasn't started yet.
|
|
87
|
-
let response;
|
|
88
|
-
let lastError = null;
|
|
89
|
-
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
90
|
-
try {
|
|
91
|
-
response = await fetch(`${this.baseUrl}/api/embed`, {
|
|
92
|
-
method: "POST",
|
|
93
|
-
headers: { "Content-Type": "application/json" },
|
|
94
|
-
body: JSON.stringify({ model, input: truncatedTexts }),
|
|
95
|
-
});
|
|
96
|
-
if (!response.ok) {
|
|
97
|
-
const errorText = await response.text().catch(() => "unknown error");
|
|
98
|
-
throw new Error(`[OllamaAdapter] /api/embed request failed β status=${response.status}: ${errorText}. ` +
|
|
99
|
-
`Make sure Ollama is running (ollama serve) and '${model}' has been pulled (ollama pull ${model}).`);
|
|
100
|
-
}
|
|
101
|
-
// Success β break out of retry loop.
|
|
102
|
-
lastError = null;
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
105
|
-
catch (err) {
|
|
106
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
107
|
-
const isNetworkError = lastError.message.includes("ECONNREFUSED") ||
|
|
108
|
-
lastError.message.includes("fetch failed") ||
|
|
109
|
-
lastError.message.includes("ECONNRESET");
|
|
110
|
-
if (isNetworkError && attempt < MAX_RETRIES) {
|
|
111
|
-
debugLog(`[OllamaAdapter] Connection failed (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ` +
|
|
112
|
-
`${lastError.message.substring(0, 80)}. Retrying in ${RETRY_DELAY_MS}ms...`);
|
|
113
|
-
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
throw lastError;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
if (lastError)
|
|
120
|
-
throw lastError;
|
|
121
|
-
const data = (await response.json());
|
|
122
|
-
const embeddings = data?.embeddings;
|
|
123
|
-
if (!Array.isArray(embeddings) || embeddings.length === 0) {
|
|
124
|
-
throw new Error(`[OllamaAdapter] Empty embeddings response from model '${model}'.`);
|
|
125
|
-
}
|
|
126
|
-
if (embeddings.length !== texts.length) {
|
|
127
|
-
throw new Error(`[OllamaAdapter] Response length mismatch β expected ${texts.length}, got ${embeddings.length}.`);
|
|
128
|
-
}
|
|
129
|
-
// Validate dimensions and slice if model returned > 768 (shouldn't happen
|
|
130
|
-
// with nomic-embed-text but guards against model swaps).
|
|
131
|
-
return embeddings.map((emb, i) => {
|
|
132
|
-
if (emb.length > EMBEDDING_DIMS) {
|
|
133
|
-
debugLog(`[OllamaAdapter] Embedding[${i}] has ${emb.length} dims β truncating to ${EMBEDDING_DIMS}. ` +
|
|
134
|
-
`Consider using a model that natively outputs ${EMBEDDING_DIMS} dims (e.g. nomic-embed-text).`);
|
|
135
|
-
return emb.slice(0, EMBEDDING_DIMS);
|
|
136
|
-
}
|
|
137
|
-
if (emb.length !== EMBEDDING_DIMS) {
|
|
138
|
-
throw new Error(`[OllamaAdapter] Dimension mismatch at index ${i}: expected ${EMBEDDING_DIMS}, ` +
|
|
139
|
-
`got ${emb.length}. Model '${model}' is not compatible with Prism's 768-dim schema. ` +
|
|
140
|
-
`Use nomic-embed-text which natively outputs 768 dims.`);
|
|
141
|
-
}
|
|
142
|
-
return emb;
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
// βββ Single Embedding (delegates to batch) βββββββββββββββββββββββββββββββ
|
|
146
|
-
async generateEmbedding(text) {
|
|
147
|
-
if (!text || !text.trim()) {
|
|
148
|
-
throw new Error("[OllamaAdapter] generateEmbedding called with empty text.");
|
|
149
|
-
}
|
|
150
|
-
const results = await this.generateEmbeddings([text]);
|
|
151
|
-
return results[0];
|
|
152
|
-
}
|
|
153
|
-
}
|