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 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.2 β€” Shell Injection Fix (Git Drift Detection)**
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 { execSync } from "child_process";
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
- // 'ps -o ppid= -p PID' returns just the parent PID
69
- const ppid = execSync(`ps -o ppid= -p ${pid}`, { encoding: "utf8" }).trim();
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.2",
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
- }