prism-mcp-server 5.2.0 → 5.5.0

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.
@@ -0,0 +1,258 @@
1
+ import { BRAVE_API_KEY, FIRECRAWL_API_KEY, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN, PRISM_USER_ID, PRISM_SCHOLAR_TOPICS, PRISM_ENABLE_HIVEMIND } from "../config.js";
2
+ import { getStorage } from "../storage/index.js";
3
+ import { debugLog } from "../utils/logger.js";
4
+ import { getLLMProvider } from "../utils/llm/factory.js";
5
+ import { randomUUID } from "node:crypto";
6
+ import { performWebSearchRaw } from "../utils/braveApi.js";
7
+ import { getTracer } from "../utils/telemetry.js";
8
+ import { searchYahooFree, scrapeArticleLocal } from "./freeSearch.js";
9
+ // ─── Hivemind Integration Helpers ────────────────────────────
10
+ const SCHOLAR_PROJECT = "prism-scholar";
11
+ const SCHOLAR_ROLE = "scholar";
12
+ /**
13
+ * Phase 1: Register the Scholar as a Hivemind agent and emit heartbeats.
14
+ * Shows up on the Dashboard Radar as 🧠 with the current research topic.
15
+ * Gracefully no-ops when Hivemind is disabled.
16
+ */
17
+ async function hivemindRegister(topic) {
18
+ if (!PRISM_ENABLE_HIVEMIND)
19
+ return;
20
+ try {
21
+ const storage = await getStorage();
22
+ await storage.registerAgent({
23
+ project: SCHOLAR_PROJECT,
24
+ user_id: PRISM_USER_ID,
25
+ role: SCHOLAR_ROLE,
26
+ agent_name: "Web Scholar",
27
+ status: "active",
28
+ current_task: `Researching: ${topic}`,
29
+ });
30
+ debugLog(`[WebScholar] 🐝 Registered on Hivemind Radar (topic: ${topic})`);
31
+ }
32
+ catch (err) {
33
+ debugLog(`[WebScholar] Hivemind registration failed (non-fatal): ${err}`);
34
+ }
35
+ }
36
+ async function hivemindHeartbeat(task) {
37
+ if (!PRISM_ENABLE_HIVEMIND)
38
+ return;
39
+ try {
40
+ const storage = await getStorage();
41
+ await storage.heartbeatAgent(SCHOLAR_PROJECT, PRISM_USER_ID, SCHOLAR_ROLE, task);
42
+ }
43
+ catch { /* non-fatal */ }
44
+ }
45
+ async function hivemindIdle() {
46
+ if (!PRISM_ENABLE_HIVEMIND)
47
+ return;
48
+ try {
49
+ const storage = await getStorage();
50
+ await storage.updateAgentStatus(SCHOLAR_PROJECT, PRISM_USER_ID, SCHOLAR_ROLE, "idle");
51
+ }
52
+ catch { /* non-fatal */ }
53
+ }
54
+ /**
55
+ * Phase 2: Broadcast a Telepathy alert after a successful research run.
56
+ * Active dev/qa agents will see "[🐝 SCHOLAR]" in their next tool response.
57
+ * Uses console.error to log the broadcast — the Watchdog sweep will pick up
58
+ * the Scholar's state change and generate alerts for active agents.
59
+ */
60
+ async function hivemindBroadcast(topic, articleCount) {
61
+ if (!PRISM_ENABLE_HIVEMIND)
62
+ return;
63
+ try {
64
+ const storage = await getStorage();
65
+ // Update Scholar's current_task so the Watchdog and Dashboard show the result
66
+ await storage.heartbeatAgent(SCHOLAR_PROJECT, PRISM_USER_ID, SCHOLAR_ROLE, `✅ Completed: "${topic}" — ${articleCount} articles synthesized`);
67
+ // Log the broadcast — visible to operators watching the process
68
+ console.error(`[WebScholar] 🐝 TELEPATHY: New research on "${topic}" — ` +
69
+ `${articleCount} articles synthesized. Active agents will see results in knowledge search.`);
70
+ }
71
+ catch (err) {
72
+ debugLog(`[WebScholar] Telepathy broadcast failed (non-fatal): ${err}`);
73
+ }
74
+ }
75
+ /**
76
+ * Phase 3: Task-aware topic selection.
77
+ * If Hivemind is active, check what other agents are working on and
78
+ * bias toward configured topics that overlap with their active tasks.
79
+ * Falls back to random selection if no matches or Hivemind is off.
80
+ */
81
+ async function selectTopic() {
82
+ const topics = PRISM_SCHOLAR_TOPICS;
83
+ if (!topics || topics.length === 0)
84
+ return "";
85
+ // Default: random pick
86
+ const randomPick = topics[Math.floor(Math.random() * topics.length)];
87
+ if (!PRISM_ENABLE_HIVEMIND)
88
+ return randomPick;
89
+ try {
90
+ const storage = await getStorage();
91
+ const allAgents = await storage.getAllAgents(PRISM_USER_ID);
92
+ const activeTasks = allAgents
93
+ .filter(a => a.role !== SCHOLAR_ROLE && a.status === "active" && a.current_task)
94
+ .map(a => a.current_task.toLowerCase());
95
+ if (activeTasks.length === 0)
96
+ return randomPick;
97
+ // Find configured topics that match keywords in active agent tasks
98
+ const taskText = activeTasks.join(" ");
99
+ const matched = topics.filter(t => taskText.includes(t.toLowerCase()));
100
+ if (matched.length > 0) {
101
+ const chosen = matched[Math.floor(Math.random() * matched.length)];
102
+ debugLog(`[WebScholar] 🐝 Task-aware topic: "${chosen}" (matched from active agent tasks)`);
103
+ return chosen;
104
+ }
105
+ }
106
+ catch (err) {
107
+ debugLog(`[WebScholar] Task-aware selection failed (non-fatal), using random: ${err}`);
108
+ }
109
+ return randomPick;
110
+ }
111
+ // ─── Core Pipeline ───────────────────────────────────────────
112
+ /**
113
+ * Runs the Web Scholar pipeline:
114
+ * 1. Picks a topic (task-aware when Hivemind is on)
115
+ * 2. Registers on the Hivemind Radar
116
+ * 3. Searches Brave for recent articles
117
+ * 4. Scrapes articles as markdown using Firecrawl
118
+ * 5. Summarizes the findings via LLM
119
+ * 6. Injects the summary directly into Prism's semantic ledger
120
+ * 7. Broadcasts a Telepathy alert to active agents
121
+ */
122
+ let isRunning = false;
123
+ export async function runWebScholar() {
124
+ if (isRunning) {
125
+ debugLog("[WebScholar] Skipped: already running");
126
+ return;
127
+ }
128
+ isRunning = true;
129
+ const tracer = getTracer();
130
+ const span = tracer.startSpan("background.web_scholar");
131
+ try {
132
+ const useFreeFallback = !BRAVE_API_KEY || !FIRECRAWL_API_KEY;
133
+ if (!PRISM_SCHOLAR_TOPICS || PRISM_SCHOLAR_TOPICS.length === 0) {
134
+ debugLog("[WebScholar] Skipped: No topics configured in PRISM_SCHOLAR_TOPICS");
135
+ span.setAttribute("scholar.skipped_reason", "no_topics");
136
+ return;
137
+ }
138
+ // 1. Pick a topic (task-aware when Hivemind is active)
139
+ const topic = await selectTopic();
140
+ if (!topic) {
141
+ span.setAttribute("scholar.skipped_reason", "no_topics");
142
+ return;
143
+ }
144
+ debugLog(`[WebScholar] 🧠 Starting research on topic: "${topic}"`);
145
+ span.setAttribute("scholar.topic", topic);
146
+ // 2. Register on Hivemind Radar
147
+ await hivemindRegister(topic);
148
+ // 3. Search for articles
149
+ await hivemindHeartbeat(`Searching for: ${topic}`);
150
+ let urls = [];
151
+ if (useFreeFallback) {
152
+ debugLog("[WebScholar] API keys missing, falling back to Local Free Search (Yahoo + Readability)");
153
+ const ddgResults = await searchYahooFree(topic, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN);
154
+ urls = ddgResults.map(r => r.url).filter(Boolean);
155
+ }
156
+ else {
157
+ const braveResponse = await performWebSearchRaw(topic, PRISM_SCHOLAR_MAX_ARTICLES_PER_RUN);
158
+ const braveData = JSON.parse(braveResponse);
159
+ urls = (braveData.web?.results || []).map((r) => r.url).filter(Boolean);
160
+ }
161
+ if (urls.length === 0) {
162
+ debugLog(`[WebScholar] No articles found for "${topic}"`);
163
+ span.setAttribute("scholar.skipped_reason", "no_search_results");
164
+ return;
165
+ }
166
+ debugLog(`[WebScholar] Found ${urls.length} articles. Scraping...`);
167
+ span.setAttribute("scholar.articles_found", urls.length);
168
+ // 4. Scrape each URL
169
+ await hivemindHeartbeat(`Scraping ${urls.length} articles on: ${topic}`);
170
+ const scrapedTexts = [];
171
+ for (const url of urls) {
172
+ if (useFreeFallback) {
173
+ try {
174
+ debugLog(`[WebScholar] Scraping local fallback: ${url}`);
175
+ const article = await scrapeArticleLocal(url);
176
+ const trimmed = article.content.slice(0, 15_000);
177
+ scrapedTexts.push(`Source: ${url}\nTitle: ${article.title}\n\n${trimmed}\n\n---\n`);
178
+ }
179
+ catch (err) {
180
+ console.error(`[WebScholar] Failed to locally scrape ${url}:`, err);
181
+ }
182
+ }
183
+ else {
184
+ try {
185
+ debugLog(`[WebScholar] Scraping Firecrawl: ${url}`);
186
+ const scrapeRes = await fetch("https://api.firecrawl.dev/v1/scrape", {
187
+ method: "POST",
188
+ headers: {
189
+ "Content-Type": "application/json",
190
+ "Authorization": `Bearer ${FIRECRAWL_API_KEY}`
191
+ },
192
+ body: JSON.stringify({
193
+ url,
194
+ formats: ["markdown"],
195
+ })
196
+ });
197
+ if (!scrapeRes.ok) {
198
+ console.error(`[WebScholar] Firecrawl failed for ${url}: ${scrapeRes.status}`);
199
+ continue;
200
+ }
201
+ const result = (await scrapeRes.json());
202
+ if (result.success && result.data?.markdown) {
203
+ const trimmed = result.data.markdown.slice(0, 15_000);
204
+ scrapedTexts.push(`Source: ${url}\n\n${trimmed}\n\n---\n`);
205
+ }
206
+ }
207
+ catch (err) {
208
+ console.error(`[WebScholar] Failed to scrape ${url}:`, err);
209
+ }
210
+ }
211
+ }
212
+ if (scrapedTexts.length === 0) {
213
+ debugLog(`[WebScholar] Could not extract markdown from any articles.`);
214
+ span.setAttribute("scholar.skipped_reason", "all_scrapes_failed");
215
+ return;
216
+ }
217
+ span.setAttribute("scholar.articles_scraped", scrapedTexts.length);
218
+ // 5. Summarize findings using LLM
219
+ await hivemindHeartbeat(`Synthesizing ${scrapedTexts.length} articles on: ${topic}`);
220
+ debugLog(`[WebScholar] Summarizing ${scrapedTexts.length} articles...`);
221
+ const combinedText = scrapedTexts.join("\n");
222
+ const prompt = `You are an AI research assistant. You have been asked to research the topic: "${topic}".
223
+ Read the following scraped web articles and write a comprehensive, markdown-formatted report summarizing the key findings, trends, and actionable insights. Focus heavily on facts, data, and actual content. Do NOT just list the articles. Synthesize the information.
224
+
225
+ ### Scraped Articles:
226
+ ${combinedText}`;
227
+ const llm = getLLMProvider();
228
+ const summary = await llm.generateText(prompt);
229
+ // 6. Inject the summary back into Prism memory
230
+ await hivemindHeartbeat(`Saving research to ledger: ${topic}`);
231
+ const storage = await getStorage();
232
+ await storage.saveLedger({
233
+ id: randomUUID(),
234
+ project: "prism-scholar",
235
+ conversation_id: "scholar-bg-" + Date.now(),
236
+ user_id: PRISM_USER_ID,
237
+ role: "scholar",
238
+ summary: `Autonomous Web Scholar Research: ${topic}\n\n${summary}`,
239
+ keywords: [topic, "research", "autonomous", "scholar"],
240
+ event_type: "learning",
241
+ importance: 7,
242
+ created_at: new Date().toISOString()
243
+ });
244
+ debugLog(`[WebScholar] ✅ Research complete and saved to ledger under project 'prism-scholar'.`);
245
+ span.setAttribute("scholar.success", true);
246
+ // 7. Broadcast Telepathy alert to active agents
247
+ await hivemindBroadcast(topic, scrapedTexts.length);
248
+ }
249
+ catch (err) {
250
+ console.error("[WebScholar] Pipeline failed:", err);
251
+ span.setAttribute("scholar.error", String(err));
252
+ }
253
+ finally {
254
+ await hivemindIdle();
255
+ isRunning = false;
256
+ span.end();
257
+ }
258
+ }
@@ -0,0 +1,75 @@
1
+ import { getStorage } from "../storage/index.js";
2
+ import { getDefaultCompressor, deserialize } from "../utils/turboquant.js";
3
+ import { debugLog } from "../utils/logger.js";
4
+ /**
5
+ * Perform a fast JS-space Hamming distance scan across TurboQuant compressed embeddings.
6
+ * Used exclusively for decoding SDM superposed target vectors back into ledger entries.
7
+ */
8
+ export async function decodeSdmVector(project, targetVector, matchCount = 5, similarityThreshold = 0.55) {
9
+ const t0 = performance.now();
10
+ const storage = await getStorage();
11
+ // 1. Fetch all compressed embeddings for the active project
12
+ const embeddings = await storage.getAllProjectEmbeddings(project);
13
+ if (embeddings.length === 0) {
14
+ debugLog(`[SdmDecoder] No embeddings found for project ${project}`);
15
+ return [];
16
+ }
17
+ // 2. Compress the target Float32Array into a 96-byte Uint8Array using TurboQuant
18
+ const compressor = getDefaultCompressor();
19
+ // compressor.compress takes number[]
20
+ const queryArray = Array.from(targetVector); // V8 optimized array conversion
21
+ const compressedTarget = compressor.compress(queryArray);
22
+ const targetQjl = compressedTarget.qjlSigns;
23
+ // Ensure byte length aligns with 32-bit words for fast XOR
24
+ // We copy the target Uint8Array into a perfectly aligned Int32Array buffer
25
+ const wordCount = targetQjl.length / 4;
26
+ const targetWords = new Int32Array(wordCount);
27
+ new Uint8Array(targetWords.buffer).set(targetQjl);
28
+ // 3. Scan all DB embeddings and compute Hamming distance on QJL signs
29
+ const matches = [];
30
+ // Pre-allocate scratchpad Int32Array to avoid GC pressure during decoding sweeps
31
+ const dbWords = new Int32Array(wordCount);
32
+ const dbBytes = new Uint8Array(dbWords.buffer);
33
+ for (const entry of embeddings) {
34
+ try {
35
+ const dbBuf = Buffer.from(entry.embedding_compressed.replace(/^v1\./, ""), "base64");
36
+ const compressedDb = deserialize(dbBuf);
37
+ const dbQjl = compressedDb.qjlSigns;
38
+ if (dbQjl.length !== targetQjl.length)
39
+ continue;
40
+ // Zero-allocation copy into the scratchpad
41
+ dbBytes.set(dbQjl);
42
+ let hammingDistance = 0;
43
+ for (let i = 0; i < wordCount; i++) {
44
+ // Bitwise XOR to find mismatched bits
45
+ let xor = targetWords[i] ^ dbWords[i];
46
+ // Brian Kernighan's algorithm or V8 Math.clz32 trick isn't natively popcnt,
47
+ // but simple bitwise popcount is fast enough in V8:
48
+ xor = xor - ((xor >>> 1) & 0x55555555);
49
+ xor = (xor & 0x33333333) + ((xor >>> 2) & 0x33333333);
50
+ hammingDistance += (((xor + (xor >>> 4)) & 0x0F0F0F0F) * 0x01010101) >>> 24;
51
+ }
52
+ // Convert distance to standard similarity score (1.0 = exact match)
53
+ const totalBits = targetQjl.byteLength * 8;
54
+ const similarity = 1 - (hammingDistance / totalBits);
55
+ if (similarity >= similarityThreshold) {
56
+ matches.push({
57
+ id: entry.id,
58
+ summary: entry.summary,
59
+ distance: hammingDistance,
60
+ similarity
61
+ });
62
+ }
63
+ }
64
+ catch (err) {
65
+ // Ignore malformed embeddings
66
+ continue;
67
+ }
68
+ }
69
+ // 4. Sort by highest similarity
70
+ matches.sort((a, b) => b.similarity - a.similarity);
71
+ const topMatches = matches.slice(0, matchCount);
72
+ const t1 = performance.now();
73
+ debugLog(`[SdmDecoder] Decoded target vector against ${embeddings.length} entries in ${(t1 - t0).toFixed(2)}ms`);
74
+ return topMatches;
75
+ }
@@ -0,0 +1,158 @@
1
+ import { PRISM_DEFAULT_CONFIG, getDefaultCompressor } from '../utils/turboquant.js';
2
+ // M = 10,000 hard locations per project
3
+ const SDM_M = 10000;
4
+ // D_addr = 768 bits (binary QJL string length), represented as 24 Uint32s
5
+ const D_ADDR_UINT32 = PRISM_DEFAULT_CONFIG.d / 32;
6
+ // The L1 radius threshold for "activation" (how many bits can differ)
7
+ // For 768 bits, picking a threshold like 300 gives us sparse activation
8
+ const ACTIVATION_RADIUS = 300;
9
+ // We use PRNG seeded deterministically to generate the initial hard location addresses
10
+ // This ensures that restarts produce the exact same Kanerva address space
11
+ class PRNG {
12
+ seed;
13
+ constructor(seed) {
14
+ this.seed = seed;
15
+ }
16
+ nextUInt32() {
17
+ this.seed = Math.imul(this.seed ^ (this.seed >>> 15), 1 | this.seed);
18
+ this.seed ^= this.seed + Math.imul(this.seed ^ (this.seed >>> 7), 61 | this.seed);
19
+ const v = ((this.seed ^ (this.seed >>> 14)) >>> 0);
20
+ return v;
21
+ }
22
+ }
23
+ /**
24
+ * Fast Hamming Distance over Uint32 arrays
25
+ */
26
+ export function hammingDistance(a, b) {
27
+ let sum = 0;
28
+ for (let i = 0; i < a.length; i++) {
29
+ let xor = a[i] ^ b[i];
30
+ // 32-bit popcount trick
31
+ xor -= ((xor >>> 1) & 0x55555555);
32
+ xor = (xor & 0x33333333) + ((xor >>> 2) & 0x33333333);
33
+ xor = (xor + (xor >>> 4)) & 0x0F0F0F0F;
34
+ sum += Math.imul(xor, 0x01010101) >>> 24;
35
+ }
36
+ return sum;
37
+ }
38
+ export class SparseDistributedMemory {
39
+ // Hard Locations: Addresses (M x 24 uint32)
40
+ addresses;
41
+ // Hard Locations: Counters (M x 768 float32)
42
+ counters;
43
+ constructor(seed = 42) {
44
+ this.addresses = new Array(SDM_M);
45
+ this.counters = new Array(SDM_M);
46
+ const prng = new PRNG(seed);
47
+ for (let i = 0; i < SDM_M; i++) {
48
+ const addr = new Uint32Array(D_ADDR_UINT32);
49
+ for (let j = 0; j < D_ADDR_UINT32; j++) {
50
+ addr[j] = prng.nextUInt32();
51
+ }
52
+ this.addresses[i] = addr;
53
+ this.counters[i] = new Float32Array(PRISM_DEFAULT_CONFIG.d);
54
+ }
55
+ }
56
+ /** Convert TurboQuant QJL bytes into Uint32Array for fast bit math */
57
+ blobToAddress(blob) {
58
+ const qjl = blob.qjlSigns; // Uint8Array of length 96 (768 bits)
59
+ const view = new DataView(qjl.buffer, qjl.byteOffset, qjl.byteLength);
60
+ const addr = new Uint32Array(D_ADDR_UINT32);
61
+ for (let i = 0; i < D_ADDR_UINT32; i++) {
62
+ // Needs little endian to match bit alignment expectations safely
63
+ addr[i] = view.getUint32(i * 4, true);
64
+ }
65
+ return addr;
66
+ }
67
+ /**
68
+ * Write a dense vector into the memory by routing it to activated counters
69
+ */
70
+ write(vector, k = 20) {
71
+ const compressor = getDefaultCompressor();
72
+ const blob = compressor.compress(Array.from(vector));
73
+ const address = this.blobToAddress(blob);
74
+ const activated = this.getTopK(address, k);
75
+ for (const idx of activated) {
76
+ const c = this.counters[idx];
77
+ for (let j = 0; j < PRISM_DEFAULT_CONFIG.d; j++) {
78
+ c[j] += vector[j];
79
+ }
80
+ }
81
+ }
82
+ read(queryVector, k = 20) {
83
+ const compressor = getDefaultCompressor();
84
+ const blob = compressor.compress(Array.from(queryVector));
85
+ const address = this.blobToAddress(blob);
86
+ const result = new Float32Array(PRISM_DEFAULT_CONFIG.d);
87
+ const activated = this.getTopK(address, k);
88
+ for (const idx of activated) {
89
+ const c = this.counters[idx];
90
+ for (let j = 0; j < PRISM_DEFAULT_CONFIG.d; j++) {
91
+ result[j] += c[j];
92
+ }
93
+ }
94
+ return this.l2Normalize(result);
95
+ }
96
+ getTopK(address, k) {
97
+ // Array of (dist, index)
98
+ const dists = new Array(SDM_M);
99
+ for (let i = 0; i < SDM_M; i++) {
100
+ dists[i] = { d: hammingDistance(address, this.addresses[i]), i };
101
+ }
102
+ // Partial sort to find top K
103
+ dists.sort((a, b) => a.d - b.d);
104
+ const result = new Array(k);
105
+ for (let i = 0; i < k; i++) {
106
+ result[i] = dists[i].i;
107
+ }
108
+ return result;
109
+ }
110
+ l2Normalize(vec) {
111
+ let sum = 0;
112
+ for (let i = 0; i < vec.length; i++) {
113
+ sum += vec[i] * vec[i];
114
+ }
115
+ if (sum === 0)
116
+ return vec;
117
+ const mag = Math.sqrt(sum);
118
+ for (let i = 0; i < vec.length; i++) {
119
+ vec[i] /= mag;
120
+ }
121
+ return vec;
122
+ }
123
+ /**
124
+ * Export the entire 10k x 768 counter matrix as a single 1D Float32Array
125
+ * for binary serialization to SQLite BLOB.
126
+ */
127
+ exportState() {
128
+ const state = new Float32Array(SDM_M * PRISM_DEFAULT_CONFIG.d);
129
+ for (let i = 0; i < SDM_M; i++) {
130
+ state.set(this.counters[i], i * PRISM_DEFAULT_CONFIG.d);
131
+ }
132
+ return state;
133
+ }
134
+ /**
135
+ * Import a previously serialized 1D Float32Array matrix back into
136
+ * the 2D counters array.
137
+ */
138
+ importState(state) {
139
+ if (state.length !== SDM_M * PRISM_DEFAULT_CONFIG.d) {
140
+ throw new Error(`Invalid SDM state size: expected ${SDM_M * PRISM_DEFAULT_CONFIG.d}, got ${state.length}`);
141
+ }
142
+ for (let i = 0; i < SDM_M; i++) {
143
+ // Subarray creates a fast view over the underlying buffer
144
+ this.counters[i] = state.subarray(i * PRISM_DEFAULT_CONFIG.d, (i + 1) * PRISM_DEFAULT_CONFIG.d);
145
+ }
146
+ }
147
+ }
148
+ // Global Singleton per Project in memory
149
+ const _sdmInstances = new Map();
150
+ export function getSdmEngine(projectId) {
151
+ if (!_sdmInstances.has(projectId)) {
152
+ _sdmInstances.set(projectId, new SparseDistributedMemory());
153
+ }
154
+ return _sdmInstances.get(projectId);
155
+ }
156
+ export function getAllActiveSdmProjects() {
157
+ return Array.from(_sdmInstances.keys());
158
+ }