prism-mcp-server 15.7.3 → 16.0.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.
- package/README.md +87 -36
- package/dist/aba-protocol.js +2 -2
- package/dist/dashboard/server.js +4 -5
- package/dist/dashboard/ui.js +5 -5
- package/dist/dashboard/webhookRouter.js +96 -19
- package/dist/hivemindWatchdog.js +1 -1
- package/dist/storage/configStorage.js +1 -1
- package/dist/tools/commonHelpers.js +2 -0
- package/dist/tools/ingestHandler.js +14 -2
- package/dist/tools/ledgerHandlers.js +2 -1
- package/dist/tools/skillRouting.js +1 -1
- package/dist/utils/analytics.js +1 -1
- package/dist/utils/ddLogger.js +74 -0
- package/dist/utils/llm/adapters/gemini.js +52 -1
- package/dist/utils/llm/adapters/openai.js +38 -2
- package/dist/utils/localLlm.js +1 -1
- package/dist/utils/logger.js +13 -4
- package/dist/utils/notifier.js +63 -21
- package/dist/utils/universalImporter.js +12 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -124,53 +124,78 @@ Three entry points:
|
|
|
124
124
|
|
|
125
125
|
See [KNOWLEDGE_INGESTION.md](docs/KNOWLEDGE_INGESTION.md) for full setup guide.
|
|
126
126
|
|
|
127
|
-
###
|
|
127
|
+
### Routing accuracy
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
**Head-to-head: prism-coder:14b vs Claude Opus** (25-case benchmark, production system prompt, May 2026):
|
|
130
130
|
|
|
131
|
-
|
|
|
132
|
-
|
|
133
|
-
| **
|
|
134
|
-
|
|
|
131
|
+
| Metric | prism-coder:14b | Claude Opus 4 |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| **Overall accuracy** | **96% (24/25)** | 88% (22/25) |
|
|
134
|
+
| **Tool routing** (15 tests) | **93% (14/15)** | 80% (12/15) |
|
|
135
|
+
| **Abstention** (10 tests) | **100% (10/10)** | **100% (10/10)** |
|
|
136
|
+
| **Avg latency** | **0.8s** | 5.5s |
|
|
137
|
+
| **Cost per query** | **$0** | ~$0.017 |
|
|
138
|
+
| **Annual @ 1K/day** | **$0** | ~$6,100 |
|
|
135
139
|
|
|
136
|
-
|
|
140
|
+
prism-coder:14b beats Opus on tool routing — 7x faster, free, runs offline.
|
|
137
141
|
|
|
138
|
-
|
|
142
|
+
**eval_300** (300 cases, 17 tools + NO_TOOL, 9 categories, 3-seed validated):
|
|
139
143
|
|
|
140
|
-
|
|
144
|
+
| Model | eval_300 strict | Size | Latency |
|
|
145
|
+
|---|---|---|---|
|
|
146
|
+
| **prism-coder:32b** | **300/300 (100%)** | 19 GB | ~1.4s |
|
|
147
|
+
| **prism-coder:14b** | **299/300 (99.7%)** | 9 GB | ~0.8s |
|
|
148
|
+
| **prism-coder:4b** | **300/300 (100%)** | 2.5 GB | ~0.5s |
|
|
149
|
+
| **prism-coder:1.7b** | **300/300 (100%)** | 2.2 GB | ~1.6s |
|
|
141
150
|
|
|
142
|
-
|
|
143
|
-
|---|---|---|---|---|---|---|
|
|
144
|
-
| Claude Sonnet 4 | **99%** | ~$0.01 | 3.2s | Cloud | 100% | 83% |
|
|
145
|
-
| **prism-coder:32b** swe14 | **100.0%** | **$0** | 1.4s | Mac 24GB+ | **100%** | **100%** |
|
|
146
|
-
| **prism-coder:8b** v36 | **100.0%** | **$0** | **0.8s** | iPhone/iPad 8GB | **100%** | **100%** |
|
|
147
|
-
| **prism-coder:14b** v36 | **100.0%** | **$0** | **1.1s** | Mac 24GB+ / iPad Pro 16GB | **100%** | **100%** |
|
|
148
|
-
| Claude Opus 4.7 | **98.3%** | ~$0.05 | 3.0s | Cloud | 100% | 83% |
|
|
149
|
-
| **prism-coder:1.7b** v42 | **100.0%** | **$0** | 1.6s | Any device | **100%** | **100%** |
|
|
150
|
-
| **14B→32B cascade** | **100.0%** | **~$0** | ~1.1s¹ | Mac 24GB+ | **100%** | **100%** |
|
|
151
|
+
Categories: abstention, adversarial traps, cascade, disambiguation, edge cases, multi-intent, natural phrasing, parameter extraction, verifier prompts.
|
|
151
152
|
|
|
152
|
-
|
|
153
|
+
**What this means**: a child in a hospital without WiFi, a nonverbal adult on an airplane, or a family on a budget gets Claude-grade routing accuracy with zero cloud dependency — the AAC path routes correctly **100% of the time across all tiers**.
|
|
153
154
|
|
|
154
|
-
**
|
|
155
|
+
**What it does NOT mean**: these scores measure routing precision on a 17-tool taxonomy, not general intelligence. Claude outperforms on everything outside this task. The value is **offline reliability at zero cost**, not replacing Claude. Code and clinical knowledge come from RAG via `knowledge_search`.
|
|
155
156
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
| **prism-coder:32b** swe14 | **300/300 (100%)** | abstention 20/20, adversarial 70/70, cascade 25/25, disambiguation 40/40, edge_case 25/25, multi_intent 20/20, natural_phrasing 50/50, param_extraction 25/25, verifier 25/25 |
|
|
159
|
-
| **prism-coder:14b** s17 | **299/300 (99.7%)** | 1 failure in adversarial_trap |
|
|
157
|
+
### 🔍 L3 Grounding Verifier
|
|
158
|
+
When `prism_infer` receives an `evidence` payload, the grounding verifier automatically checks the model's response against the provided evidence before returning to the caller. Unverified or hallucinated claims are flagged. This is the third layer (L3) of the cascade — after tool routing (L1) and confidence gating (L2).
|
|
160
159
|
|
|
161
|
-
|
|
160
|
+
### ⚡ Zero-search retrieval *(new in v15.8)*
|
|
161
|
+
Holographic Reduced Representations (HRR) via Rust WASM for instant memory retrieval without a database query.
|
|
162
162
|
|
|
163
|
-
**
|
|
163
|
+
**Three adaptive strategies:**
|
|
164
|
+
- **GloVe embeddings** (offline, 50K words) — 87% Top-1 accuracy, stable at 200+ concepts
|
|
165
|
+
- **API embeddings** (Gemini/Voyage) — 90%+ accuracy when online
|
|
166
|
+
- **NeurIPS 2021 projection** — unit-modulus normalization for numerical stability
|
|
164
167
|
|
|
165
|
-
**
|
|
168
|
+
**Retrieval cascade:** HRR (~0.2ms) → FTS5 (~50ms) → Supabase (~200ms)
|
|
166
169
|
|
|
167
|
-
|
|
170
|
+
| Metric | HRR (WASM) | FTS5 | Supabase Vector |
|
|
171
|
+
|--------|-----------|------|-----------------|
|
|
172
|
+
| Latency | **0.2ms** | 50ms | 200ms |
|
|
173
|
+
| Speedup | **1x** | 250x slower | 1000x slower |
|
|
174
|
+
| Offline | **Yes** | Yes | No |
|
|
175
|
+
| Accuracy (GloVe) | **87% Top-1** | 95%+ | 95%+ |
|
|
176
|
+
| Hologram size | **8KB** | Index varies | Cloud |
|
|
168
177
|
|
|
169
|
-
|
|
170
|
-
|
|
178
|
+
HRR acts as Tier 0 — if confidence is high, FTS5 is skipped entirely. Falls through gracefully when HRR has no match. 97 dedicated tests (72 system + 25 API/client). Built with Rust + `rustfft` + `wasm-bindgen` (229KB binary).
|
|
179
|
+
|
|
180
|
+
**HRR AAC prediction benchmark** — real-world impact on Prism AAC word prediction (10 scenarios, 54 integration tests):
|
|
171
181
|
|
|
172
|
-
|
|
173
|
-
|
|
182
|
+
| Scenario | Baseline Top-1 | +HRR Top-1 | Top-1 Lift | MRR Lift |
|
|
183
|
+
|----------|---------------|------------|-----------|----------|
|
|
184
|
+
| Core AAC phrases | 36.7% | 46.7% | **+27.3%** | +6.0% |
|
|
185
|
+
| Personal vocabulary | 70.4% | 81.5% | **+15.8%** | +9.2% |
|
|
186
|
+
| Mixed (all phrases) | 47.2% | 56.9% | **+20.6%** | +5.7% |
|
|
187
|
+
| Cross-session recall | 80.0% | 80.0% | +0.0% | +0.0% |
|
|
188
|
+
|
|
189
|
+
Top-1 = correct word is tile #1. MRR = Mean Reciprocal Rank. Zero Top-5 regressions in any scenario. HRR encodes bigrams + trigrams from every spoken phrase; probes take ~0.2ms — safe on every keystroke. All Synalux apps (clinical, AAC, PrismCoach) share HRR via the portal `/api/v1/hrr` endpoint.
|
|
190
|
+
|
|
191
|
+
**Competitive comparison:**
|
|
192
|
+
|
|
193
|
+
| System | Retrieval | Offline | Cost | Latency |
|
|
194
|
+
|--------|-----------|---------|------|---------|
|
|
195
|
+
| **Prism Coder** | **HRR + FTS5 + Supabase cascade** | **Yes** | **$0** | **0.2ms** |
|
|
196
|
+
| Mem0 | Vector DB (Qdrant/Pinecone) | No | $249/mo | ~100ms |
|
|
197
|
+
| Zep | Vector DB + temporal graph | No | $99/mo | ~80ms |
|
|
198
|
+
| Hermes (NousResearch) | HRR + SQLite | Yes | Free | ~5ms |
|
|
174
199
|
|
|
175
200
|
### 🌐 Multi-agent Hivemind
|
|
176
201
|
Multiple AI agents share the same Mind Palace. Each agent has a role (dev / qa / pm / etc.) and sees scoped context. Heartbeat + roster for coordination.
|
|
@@ -423,10 +448,32 @@ As of v14.0.0, Prism's algorithm exports are a **stable public contract** under
|
|
|
423
448
|
| [PrismAAC](https://github.com/dcostenco/prism-aac) | Spreading-activation phrase ranking (recency × frequency × per-user history). Caregiver corrections auto-harvest into the personalization corpus via the audit-hooks postflight harvester. The on-device 7B model + this algorithm stack is what makes PrismAAC defensible. |
|
|
424
449
|
| Synalux portal | Tier-aware model routing using experience bias on prior outcomes per fingerprint. HIPAA-compliant clinical scribe with on-device-first privacy guarantees. |
|
|
425
450
|
|
|
451
|
+
## CLI Reference
|
|
452
|
+
|
|
453
|
+
Prism Coder includes a CLI for session management, code review, and sync operations.
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
prism load <project> # Load session context (same as session_load_context MCP tool)
|
|
457
|
+
prism save # Save session state (ledger + handoff)
|
|
458
|
+
prism ledger <project> # Save a session log entry (same as session_save_ledger)
|
|
459
|
+
prism handoff <project> # Update live project state for next session
|
|
460
|
+
prism push # Push local SQLite data to Supabase cloud
|
|
461
|
+
prism sync # Cross-backend data synchronization
|
|
462
|
+
prism search <query> # Search code across repos (exact, regex, symbol, semantic)
|
|
463
|
+
prism review <files...> # AI code review — security, performance, style
|
|
464
|
+
prism scan <files...> # Security scan — secrets, licenses, Dockerfile
|
|
465
|
+
prism dora # Show DORA metrics for current project
|
|
466
|
+
prism scm # Source control, AI review, security scanning
|
|
467
|
+
prism verify # Manage the verification harness
|
|
468
|
+
prism status # Check verification state and config drift
|
|
469
|
+
prism generate # Bless current rubric as canonical
|
|
470
|
+
prism register-models # Alias dcostenco/prism-coder:* → prism-coder:*
|
|
471
|
+
```
|
|
472
|
+
|
|
426
473
|
## Testing
|
|
427
474
|
|
|
428
475
|
```bash
|
|
429
|
-
npm test #
|
|
476
|
+
npm test # 2,418 test cases across 81 files (vitest)
|
|
430
477
|
npm test -- --coverage # coverage report
|
|
431
478
|
python3 tests/benchmarks/prism-routing-100/benchmark.py --models 1b7 14b 32b
|
|
432
479
|
```
|
|
@@ -434,12 +481,16 @@ python3 tests/benchmarks/prism-routing-100/benchmark.py --models 1b7 14b 32b
|
|
|
434
481
|
**Pinned in CI** — 327 tests enforce every constant: ACT-R decay `d=0.25`, spreading-activation hybrid score `0.7/0.3`, experience bias `MIN_SAMPLES=5` / `MAX_BIAS_CAP=0.15`, graph-metrics warning ratios `0.20 / 0.30 / 0.40`, compaction's 25KB prompt-budget. CI catches divergence automatically.
|
|
435
482
|
|
|
436
483
|
**Coverage areas**:
|
|
437
|
-
- HRR (
|
|
438
|
-
-
|
|
484
|
+
- HRR zero-search retrieval (97 tests: 3 embedding strategies, edge cases, persistence, adaptive cascade, API client, chat integration)
|
|
485
|
+
- Knowledge ingestion (32 tests: chunker, Q&A gen, webhook, security, storage round-trip)
|
|
486
|
+
- Prism infer cascade (110 tests: tier selection, cloud fallback, grounding verifier)
|
|
487
|
+
- Compaction handler (rollup creation, concurrency guard, LLM failure)
|
|
488
|
+
- Model picker (20 tests: 14b default ceiling, 4b verifier, RAM gating)
|
|
489
|
+
- Storage round-trip (12 architectural guard tests preventing bypass)
|
|
439
490
|
- BCBA skill integration
|
|
440
491
|
- Deep storage tier
|
|
441
492
|
- Dashboard rendering
|
|
442
|
-
- Routing benchmarks (
|
|
493
|
+
- Routing benchmarks (eval_300: 300 cases, 17 tools)
|
|
443
494
|
|
|
444
495
|
## Migration
|
|
445
496
|
|
package/dist/aba-protocol.js
CHANGED
|
@@ -70,7 +70,7 @@ export const RULE7_VSCODE = [
|
|
|
70
70
|
].join('\n');
|
|
71
71
|
// ─── Assemblers ─────────────────────────────────────────────────
|
|
72
72
|
/** Assemble the full ABA protocol for Cloud Portal */
|
|
73
|
-
|
|
73
|
+
function _unused_buildCloudPrompt(toolsSection) {
|
|
74
74
|
return [
|
|
75
75
|
toolsSection,
|
|
76
76
|
'',
|
|
@@ -106,7 +106,7 @@ export function sanitizeUserInput(text) {
|
|
|
106
106
|
return sanitizeMcpOutput(text);
|
|
107
107
|
}
|
|
108
108
|
/** Wrap user input in <user_input> tags after sanitization */
|
|
109
|
-
|
|
109
|
+
function _unused_wrapUserInput(text) {
|
|
110
110
|
const safe = sanitizeUserInput(text);
|
|
111
111
|
return `<user_input>\n${safe}\n</user_input>`;
|
|
112
112
|
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -870,11 +870,10 @@ return false;}
|
|
|
870
870
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
871
871
|
return res.end(JSON.stringify({ error: "filename and content are required" }));
|
|
872
872
|
}
|
|
873
|
-
// Write uploaded content to a temp
|
|
874
|
-
const tmpDir = path.join(os.tmpdir(), "prism-import");
|
|
875
|
-
|
|
876
|
-
const
|
|
877
|
-
const tmpFile = path.join(tmpDir, `upload-${Date.now()}-${safeFilename}`);
|
|
873
|
+
// Write uploaded content to a secure temp directory
|
|
874
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "prism-import-"));
|
|
875
|
+
const safeFilename = path.basename(filename).replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
876
|
+
const tmpFile = path.join(tmpDir, safeFilename);
|
|
878
877
|
fs.writeFileSync(tmpFile, content, "utf-8");
|
|
879
878
|
try {
|
|
880
879
|
const { universalImporter } = await import("../utils/universalImporter.js");
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -1842,8 +1842,8 @@ function loadPipelines() {
|
|
|
1842
1842
|
var maxIter = (p.parsedSpec && p.parsedSpec.maxIterations) ? p.parsedSpec.maxIterations : '?';
|
|
1843
1843
|
html += '<div style="padding:0.75rem 1rem;background:rgba(15,23,42,0.6);border-radius:8px;border-left:3px solid ' + statusColor + ';">';
|
|
1844
1844
|
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.35rem">';
|
|
1845
|
-
html += '<span style="font-weight:600;color:var(--text-primary)">' + emoji + ' ' + p.status + '</span>';
|
|
1846
|
-
html += '<span style="font-size:0.7rem;font-family:var(--font-mono);color:var(--text-muted)">' + p.id.slice(0, 8) + '…</span>';
|
|
1845
|
+
html += '<span style="font-weight:600;color:var(--text-primary)">' + emoji + ' ' + escapeHtml(p.status) + '</span>';
|
|
1846
|
+
html += '<span style="font-size:0.7rem;font-family:var(--font-mono);color:var(--text-muted)">' + escapeHtml(p.id.slice(0, 8)) + '…</span>';
|
|
1847
1847
|
html += '</div>';
|
|
1848
1848
|
html += '<div style="font-size:0.82rem;color:var(--text-secondary);margin-bottom:0.35rem">' + escapeHtml(objective) + '</div>';
|
|
1849
1849
|
html += '<div style="display:flex;gap:1rem;font-size:0.72rem;color:var(--text-muted);flex-wrap:wrap">';
|
|
@@ -1856,7 +1856,7 @@ function loadPipelines() {
|
|
|
1856
1856
|
html += '<div style="font-size:0.72rem;color:var(--accent-rose);margin-top:0.35rem;padding:0.3rem 0.5rem;background:rgba(244,63,94,0.08);border-radius:4px">⚠ ' + escapeHtml(p.error.slice(0, 200)) + '</div>';
|
|
1857
1857
|
}
|
|
1858
1858
|
if (isActive) {
|
|
1859
|
-
html += '<div style="margin-top:0.5rem"><button onclick="abortPipeline(this.dataset.id)" data-id=
|
|
1859
|
+
html += '<div style="margin-top:0.5rem"><button onclick="abortPipeline(this.dataset.id)" data-id=''' + escapeHtml(p.id) + ''' class="cleanup-btn" style="font-size:0.72rem">🛑 Abort Pipeline</button></div>';
|
|
1860
1860
|
}
|
|
1861
1861
|
html += '</div>';
|
|
1862
1862
|
}
|
|
@@ -4021,7 +4021,7 @@ function loadSchedulerStatus() {
|
|
|
4021
4021
|
parts.push('</div>');
|
|
4022
4022
|
errors = [t.ttlSweep.error, t.importanceDecay.error, t.compaction.error, t.deepPurge.error].filter(Boolean);
|
|
4023
4023
|
if (errors.length > 0) {
|
|
4024
|
-
parts.push('<div style="color:var(--accent-rose);margin-top:0.3rem;font-size:0.7rem">⚠️ ' + errors.join(' | ') + '</div>');
|
|
4024
|
+
parts.push('<div style="color:var(--accent-rose);margin-top:0.3rem;font-size:0.7rem">⚠️ ' + errors.map(escapeHtml).join(' | ') + '</div>');
|
|
4025
4025
|
}
|
|
4026
4026
|
parts.push('</div>');
|
|
4027
4027
|
}
|
|
@@ -4199,7 +4199,7 @@ function loadGraphMetrics() {
|
|
|
4199
4199
|
lastRoute = m.cognitive.last_route || '—';
|
|
4200
4200
|
lastConcept = m.cognitive.last_concept || '(none)';
|
|
4201
4201
|
lastConf = m.cognitive.last_confidence !== null ? Math.round(m.cognitive.last_confidence * 100) + '%' : '—';
|
|
4202
|
-
parts.push('<br>Last: ' + lastRoute + ' → ' + lastConcept + ' (' + lastConf + ')');
|
|
4202
|
+
parts.push('<br>Last: ' + escapeHtml(lastRoute) + ' → ' + escapeHtml(lastConcept) + ' (' + lastConf + ')');
|
|
4203
4203
|
parts.push('<br><span style="color:var(--text-muted)">' + timeAgo(m.cognitive.last_run_at) + '</span>');
|
|
4204
4204
|
}
|
|
4205
4205
|
parts.push('</div>');
|
|
@@ -18,12 +18,41 @@
|
|
|
18
18
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
19
19
|
import { handleGitHubWebhook } from "../tools/ingestHandler.js";
|
|
20
20
|
import { debugLog } from "../utils/logger.js";
|
|
21
|
+
import { ddInfo, ddWarn } from "../utils/ddLogger.js";
|
|
21
22
|
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || "";
|
|
22
23
|
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
|
|
24
|
+
const PRISM_INGEST_API_KEY = process.env.PRISM_INGEST_API_KEY || "";
|
|
25
|
+
const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
|
26
|
+
// ─── Rate Limiting (in-memory, per-IP) ─────────────────────────
|
|
27
|
+
const rateLimitMap = new Map();
|
|
28
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
29
|
+
const RATE_LIMIT_MAX = 10;
|
|
30
|
+
function checkRateLimit(ip) {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const entry = rateLimitMap.get(ip);
|
|
33
|
+
if (!entry || now > entry.resetAt) {
|
|
34
|
+
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
entry.count++;
|
|
38
|
+
return entry.count <= RATE_LIMIT_MAX;
|
|
39
|
+
}
|
|
40
|
+
// Cleanup stale entries every 5 minutes
|
|
41
|
+
setInterval(() => {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
for (const [ip, entry] of rateLimitMap) {
|
|
44
|
+
if (now > entry.resetAt)
|
|
45
|
+
rateLimitMap.delete(ip);
|
|
46
|
+
}
|
|
47
|
+
}, 300_000);
|
|
23
48
|
// ─── Signature Verification ────────────────────────────────────
|
|
24
49
|
function verifySignature(payload, signature) {
|
|
25
50
|
if (!WEBHOOK_SECRET) {
|
|
26
|
-
|
|
51
|
+
if (IS_PRODUCTION) {
|
|
52
|
+
debugLog("[webhook] GITHUB_WEBHOOK_SECRET not set in production — rejecting");
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
debugLog("[webhook] GITHUB_WEBHOOK_SECRET not set — accepting (dev mode only)");
|
|
27
56
|
return true;
|
|
28
57
|
}
|
|
29
58
|
if (!signature)
|
|
@@ -38,8 +67,45 @@ function verifySignature(payload, signature) {
|
|
|
38
67
|
return false;
|
|
39
68
|
}
|
|
40
69
|
}
|
|
70
|
+
// ─── Input Validation ──────────────────────────────────────────
|
|
71
|
+
const REPO_NAME_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
|
|
72
|
+
const SAFE_PATH_RE = /^[a-zA-Z0-9._\-\/]+$/;
|
|
73
|
+
function validateRepoName(name) {
|
|
74
|
+
return REPO_NAME_RE.test(name) && !name.includes("..");
|
|
75
|
+
}
|
|
76
|
+
function validateFilePath(path) {
|
|
77
|
+
return SAFE_PATH_RE.test(path) && !path.includes("..") && !path.startsWith("/");
|
|
78
|
+
}
|
|
79
|
+
// ─── Ingest API Auth ───────────────────────────────────────────
|
|
80
|
+
function verifyIngestAuth(authHeader) {
|
|
81
|
+
if (!authHeader)
|
|
82
|
+
return false;
|
|
83
|
+
if (!PRISM_INGEST_API_KEY && !WEBHOOK_SECRET) {
|
|
84
|
+
if (IS_PRODUCTION)
|
|
85
|
+
return false;
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
const expectedKey = PRISM_INGEST_API_KEY || WEBHOOK_SECRET;
|
|
89
|
+
const token = authHeader.replace(/^Bearer\s+/i, "");
|
|
90
|
+
if (token.length !== expectedKey.length)
|
|
91
|
+
return false;
|
|
92
|
+
try {
|
|
93
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(expectedKey));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
41
99
|
// ─── Fetch File Content from GitHub API ─────────────────────────
|
|
42
100
|
async function fetchFileFromGitHub(repoFullName, filePath, ref) {
|
|
101
|
+
if (!validateRepoName(repoFullName)) {
|
|
102
|
+
debugLog(`[webhook] Invalid repo name rejected: ${repoFullName}`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (!validateFilePath(filePath)) {
|
|
106
|
+
debugLog(`[webhook] Invalid file path rejected: ${filePath}`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
43
109
|
const headers = {
|
|
44
110
|
"Accept": "application/vnd.github.v3.raw",
|
|
45
111
|
"User-Agent": "prism-mcp-webhook",
|
|
@@ -48,8 +114,8 @@ async function fetchFileFromGitHub(repoFullName, filePath, ref) {
|
|
|
48
114
|
headers["Authorization"] = `Bearer ${GITHUB_TOKEN}`;
|
|
49
115
|
}
|
|
50
116
|
try {
|
|
51
|
-
const url = `https://api.github.com/repos/${repoFullName}/contents/${filePath}?ref=${ref}`;
|
|
52
|
-
const res = await fetch(url, { headers });
|
|
117
|
+
const url = `https://api.github.com/repos/${encodeURIComponent(repoFullName.split("/")[0])}/${encodeURIComponent(repoFullName.split("/")[1])}/contents/${filePath.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`;
|
|
118
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
|
|
53
119
|
if (!res.ok)
|
|
54
120
|
return null;
|
|
55
121
|
return await res.text();
|
|
@@ -79,6 +145,13 @@ function readBody(req, maxBytes = 10_000_000) {
|
|
|
79
145
|
export async function handleWebhookRequest(req, res, pathname) {
|
|
80
146
|
// ── GitHub Webhook ─────────────────────────────────────────
|
|
81
147
|
if (pathname === "/api/github/webhook" && req.method === "POST") {
|
|
148
|
+
const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || "unknown";
|
|
149
|
+
if (!checkRateLimit(`wh:${ip}`)) {
|
|
150
|
+
ddWarn("webhook.rate_limited", { ip, endpoint: "github" });
|
|
151
|
+
res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "60" });
|
|
152
|
+
res.end(JSON.stringify({ error: "Rate limit exceeded" }));
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
82
155
|
try {
|
|
83
156
|
const body = await readBody(req);
|
|
84
157
|
const signature = req.headers["x-hub-signature-256"];
|
|
@@ -89,7 +162,13 @@ export async function handleWebhookRequest(req, res, pathname) {
|
|
|
89
162
|
}
|
|
90
163
|
const event = req.headers["x-github-event"] || "unknown";
|
|
91
164
|
const payload = JSON.parse(body);
|
|
92
|
-
|
|
165
|
+
if (!payload.repository?.full_name || !validateRepoName(payload.repository.full_name)) {
|
|
166
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
167
|
+
res.end(JSON.stringify({ error: "Invalid repository name" }));
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
debugLog(`[webhook] GitHub event: ${event}, repo: ${payload.repository.full_name}`);
|
|
171
|
+
ddInfo("webhook.github.received", { event, repo: payload.repository.full_name });
|
|
93
172
|
const result = await handleGitHubWebhook(event, payload, fetchFileFromGitHub);
|
|
94
173
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
95
174
|
res.end(JSON.stringify(result));
|
|
@@ -98,22 +177,27 @@ export async function handleWebhookRequest(req, res, pathname) {
|
|
|
98
177
|
const msg = err instanceof Error ? err.message : String(err);
|
|
99
178
|
debugLog(`[webhook] Error: ${msg}`);
|
|
100
179
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
101
|
-
res.end(JSON.stringify({ ok: false, message:
|
|
180
|
+
res.end(JSON.stringify({ ok: false, message: "Internal error" }));
|
|
102
181
|
}
|
|
103
182
|
return true;
|
|
104
183
|
}
|
|
105
184
|
// ── Generic Ingest API (open interface) ────────────────────
|
|
106
185
|
if (pathname === "/api/v1/prism/ingest" && req.method === "POST") {
|
|
186
|
+
const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || "unknown";
|
|
187
|
+
if (!checkRateLimit(`ingest:${ip}`)) {
|
|
188
|
+
res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "60" });
|
|
189
|
+
res.end(JSON.stringify({ error: "Rate limit exceeded" }));
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
107
192
|
try {
|
|
108
|
-
const body = await readBody(req);
|
|
109
|
-
const payload = JSON.parse(body);
|
|
110
|
-
// Minimal auth: require API key or JWT in Authorization header
|
|
111
193
|
const auth = req.headers["authorization"] || "";
|
|
112
|
-
if (!auth
|
|
194
|
+
if (!verifyIngestAuth(auth)) {
|
|
113
195
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
114
|
-
res.end(JSON.stringify({ error: "
|
|
196
|
+
res.end(JSON.stringify({ error: "Invalid or missing API key" }));
|
|
115
197
|
return true;
|
|
116
198
|
}
|
|
199
|
+
const body = await readBody(req);
|
|
200
|
+
const payload = JSON.parse(body);
|
|
117
201
|
const { ingestKnowledge } = await import("../tools/ingestHandler.js");
|
|
118
202
|
const result = await ingestKnowledge({
|
|
119
203
|
project: payload.project || "default",
|
|
@@ -128,7 +212,7 @@ export async function handleWebhookRequest(req, res, pathname) {
|
|
|
128
212
|
catch (err) {
|
|
129
213
|
const msg = err instanceof Error ? err.message : String(err);
|
|
130
214
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
131
|
-
res.end(JSON.stringify({ ok: false, message:
|
|
215
|
+
res.end(JSON.stringify({ ok: false, message: "Internal error" }));
|
|
132
216
|
}
|
|
133
217
|
return true;
|
|
134
218
|
}
|
|
@@ -139,14 +223,7 @@ export async function handleWebhookRequest(req, res, pathname) {
|
|
|
139
223
|
status: "ready",
|
|
140
224
|
secret_configured: !!WEBHOOK_SECRET,
|
|
141
225
|
github_token_configured: !!GITHUB_TOKEN,
|
|
142
|
-
|
|
143
|
-
step1: "Set GITHUB_WEBHOOK_SECRET environment variable",
|
|
144
|
-
step2: "In GitHub: Settings → Webhooks → Add webhook",
|
|
145
|
-
step3: "Payload URL: https://your-domain/api/github/webhook",
|
|
146
|
-
step4: "Content type: application/json",
|
|
147
|
-
step5: "Secret: (same as GITHUB_WEBHOOK_SECRET)",
|
|
148
|
-
step6: "Events: Just the push event",
|
|
149
|
-
},
|
|
226
|
+
ingest_key_configured: !!PRISM_INGEST_API_KEY,
|
|
150
227
|
}));
|
|
151
228
|
return true;
|
|
152
229
|
}
|
package/dist/hivemindWatchdog.js
CHANGED
|
@@ -66,7 +66,7 @@ export function drainAlerts(project) {
|
|
|
66
66
|
/**
|
|
67
67
|
* Get count of pending alerts (for testing/debugging).
|
|
68
68
|
*/
|
|
69
|
-
|
|
69
|
+
function _unused_getPendingAlertCount() {
|
|
70
70
|
return pendingAlerts.size;
|
|
71
71
|
}
|
|
72
72
|
// ─── Watchdog Lifecycle ──────────────────────────────────────
|
|
@@ -117,7 +117,7 @@ export async function setSetting(key, value) {
|
|
|
117
117
|
args: [key, value],
|
|
118
118
|
});
|
|
119
119
|
// Keep the cache in sync so getSettingSync() reflects the new value immediately.
|
|
120
|
-
if (settingsCache) {
|
|
120
|
+
if (settingsCache && typeof key === "string" && !["__proto__", "constructor", "prototype"].includes(key)) {
|
|
121
121
|
settingsCache[key] = value;
|
|
122
122
|
}
|
|
123
123
|
return; // Success — exit
|
|
@@ -48,6 +48,8 @@ export function applySentinelBlock(existingContent, rulesBlock) {
|
|
|
48
48
|
export function redactSettings(settings) {
|
|
49
49
|
const redacted = {};
|
|
50
50
|
for (const [k, v] of Object.entries(settings || {})) {
|
|
51
|
+
if (typeof k !== "string" || k === "__proto__" || k === "constructor" || k === "prototype")
|
|
52
|
+
continue;
|
|
51
53
|
redacted[k] = REDACT_PATTERNS.some(p => p.test(k)) ? "**REDACTED**" : v;
|
|
52
54
|
}
|
|
53
55
|
return redacted;
|
|
@@ -98,8 +98,20 @@ async function generateQAPairs(chunk, source) {
|
|
|
98
98
|
export async function ingestKnowledge(args) {
|
|
99
99
|
const { project, source_label, chunk_size = 4000, } = args;
|
|
100
100
|
let content = args.content || "";
|
|
101
|
-
if (args.file_path
|
|
102
|
-
|
|
101
|
+
if (args.file_path) {
|
|
102
|
+
const resolved = require("path").resolve(args.file_path);
|
|
103
|
+
const blocked = ["/etc", "/var", "/usr", "/sys", "/proc", "/dev", "/root",
|
|
104
|
+
"/.ssh", "/.env", "/.git/config", "/private/etc"].some(p => resolved.startsWith(p) || resolved.includes("/."));
|
|
105
|
+
if (blocked) {
|
|
106
|
+
return {
|
|
107
|
+
project, source: source_label || "unknown", chunks_processed: 0,
|
|
108
|
+
entries_created: 0, status: "failed",
|
|
109
|
+
errors: ["Path not allowed: system/hidden files are blocked"],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (existsSync(resolved)) {
|
|
113
|
+
content = readFileSync(resolved, "utf-8");
|
|
114
|
+
}
|
|
103
115
|
}
|
|
104
116
|
if (!content || content.trim().length < 100) {
|
|
105
117
|
return {
|
|
@@ -1433,7 +1433,8 @@ export async function sessionExportMemoryHandler(args) {
|
|
|
1433
1433
|
};
|
|
1434
1434
|
// Serialize
|
|
1435
1435
|
const ext = format === "markdown" ? "md" : format === "vault" ? "zip" : "json";
|
|
1436
|
-
const
|
|
1436
|
+
const safeProject = project.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1437
|
+
const filename = `prism-export-${safeProject}-${dateSuffix}.${ext}`;
|
|
1437
1438
|
const outputPath = join(output_dir, filename);
|
|
1438
1439
|
let content;
|
|
1439
1440
|
if (format === "vault") {
|
|
@@ -29,7 +29,7 @@ let cached = null;
|
|
|
29
29
|
let inflight = null;
|
|
30
30
|
async function fetchOnce() {
|
|
31
31
|
try {
|
|
32
|
-
const res = await fetch(`${SYNALUX_BASE}/
|
|
32
|
+
const res = await fetch(`${SYNALUX_BASE}/.well-known/prism/skills-routing.json`, {
|
|
33
33
|
headers: { Accept: 'application/json' },
|
|
34
34
|
// Routing is on every session_load_context, must not block long.
|
|
35
35
|
signal: AbortSignal.timeout(2_500),
|
package/dist/utils/analytics.js
CHANGED
|
@@ -33,7 +33,7 @@ function estimateTokens(text) {
|
|
|
33
33
|
* Call this from server.ts after each tool handler completes.
|
|
34
34
|
* Uses a write buffer to avoid per-call SQLite overhead.
|
|
35
35
|
*/
|
|
36
|
-
|
|
36
|
+
function _unused_recordInvocation(tool, project, args, response, durationMs, success, errorMessage) {
|
|
37
37
|
const invocation = {
|
|
38
38
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
39
39
|
tool,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datadog Server-Side Logger
|
|
3
|
+
*
|
|
4
|
+
* Sends structured logs to Datadog HTTP Logs API.
|
|
5
|
+
* No agent needed — direct HTTPS POST to intake.
|
|
6
|
+
*
|
|
7
|
+
* Env: DD_API_KEY, DD_SITE (default datadoghq.com)
|
|
8
|
+
*/
|
|
9
|
+
const DD_API_KEY = process.env.DD_API_KEY || "";
|
|
10
|
+
const DD_SITE = process.env.DD_SITE || "datadoghq.com";
|
|
11
|
+
const SERVICE = "prism-mcp";
|
|
12
|
+
const INTAKE_URL = `https://http-intake.logs.${DD_SITE}/api/v2/logs`;
|
|
13
|
+
const queue = [];
|
|
14
|
+
let flushTimer = null;
|
|
15
|
+
const FLUSH_INTERVAL_MS = 5_000;
|
|
16
|
+
const MAX_BATCH = 50;
|
|
17
|
+
function scheduleFlush() {
|
|
18
|
+
if (flushTimer)
|
|
19
|
+
return;
|
|
20
|
+
flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS);
|
|
21
|
+
}
|
|
22
|
+
async function flush() {
|
|
23
|
+
flushTimer = null;
|
|
24
|
+
if (queue.length === 0 || !DD_API_KEY)
|
|
25
|
+
return;
|
|
26
|
+
const batch = queue.splice(0, MAX_BATCH);
|
|
27
|
+
try {
|
|
28
|
+
await fetch(INTAKE_URL, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
"DD-API-KEY": DD_API_KEY,
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify(batch),
|
|
35
|
+
signal: AbortSignal.timeout(5_000),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Silent — don't crash the app if DD is unreachable
|
|
40
|
+
}
|
|
41
|
+
if (queue.length > 0)
|
|
42
|
+
scheduleFlush();
|
|
43
|
+
}
|
|
44
|
+
export function ddLog(level, message, context) {
|
|
45
|
+
if (!DD_API_KEY)
|
|
46
|
+
return;
|
|
47
|
+
queue.push({
|
|
48
|
+
ddsource: "nodejs",
|
|
49
|
+
ddtags: `env:${process.env.NODE_ENV || "development"},service:${SERVICE}`,
|
|
50
|
+
hostname: process.env.HOSTNAME || "prism-mcp",
|
|
51
|
+
service: SERVICE,
|
|
52
|
+
status: level,
|
|
53
|
+
message,
|
|
54
|
+
...context,
|
|
55
|
+
timestamp: new Date().toISOString(),
|
|
56
|
+
});
|
|
57
|
+
scheduleFlush();
|
|
58
|
+
}
|
|
59
|
+
export function ddError(message, error, context) {
|
|
60
|
+
ddLog("error", message, {
|
|
61
|
+
...context,
|
|
62
|
+
error: error ? {
|
|
63
|
+
message: error.message,
|
|
64
|
+
stack: error.stack?.split("\n").slice(0, 5).join("\n"),
|
|
65
|
+
name: error.name,
|
|
66
|
+
} : undefined,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function ddInfo(message, context) {
|
|
70
|
+
ddLog("info", message, context);
|
|
71
|
+
}
|
|
72
|
+
export function ddWarn(message, context) {
|
|
73
|
+
ddLog("warn", message, context);
|
|
74
|
+
}
|
|
@@ -77,17 +77,67 @@ export class GeminiAdapter {
|
|
|
77
77
|
return result.response.text();
|
|
78
78
|
}
|
|
79
79
|
// ─── Embedding Generation ────────────────────────────────────────────────
|
|
80
|
+
static _embeddingCache = new Map();
|
|
81
|
+
static _inflight = new Map();
|
|
82
|
+
static EMBED_CACHE_MAX = 256;
|
|
83
|
+
static EMBED_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
84
|
+
getCachedEmbedding(key) {
|
|
85
|
+
const entry = GeminiAdapter._embeddingCache.get(key);
|
|
86
|
+
if (!entry)
|
|
87
|
+
return null;
|
|
88
|
+
if (Date.now() - entry.ts > GeminiAdapter.EMBED_CACHE_TTL_MS) {
|
|
89
|
+
GeminiAdapter._embeddingCache.delete(key);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// Move to tail for LRU on read
|
|
93
|
+
GeminiAdapter._embeddingCache.delete(key);
|
|
94
|
+
GeminiAdapter._embeddingCache.set(key, entry);
|
|
95
|
+
return entry.embedding;
|
|
96
|
+
}
|
|
97
|
+
setCachedEmbedding(key, embedding) {
|
|
98
|
+
// Delete-then-set moves the key to tail for correct LRU eviction
|
|
99
|
+
GeminiAdapter._embeddingCache.delete(key);
|
|
100
|
+
if (GeminiAdapter._embeddingCache.size >= GeminiAdapter.EMBED_CACHE_MAX) {
|
|
101
|
+
const oldest = GeminiAdapter._embeddingCache.keys().next().value;
|
|
102
|
+
if (oldest !== undefined)
|
|
103
|
+
GeminiAdapter._embeddingCache.delete(oldest);
|
|
104
|
+
}
|
|
105
|
+
GeminiAdapter._embeddingCache.set(key, { embedding, ts: Date.now() });
|
|
106
|
+
}
|
|
80
107
|
async generateEmbedding(text) {
|
|
81
108
|
// Guard: empty string would produce a useless/degenerate embedding.
|
|
82
109
|
// Better to fail loudly here than store a zero-vector in the DB.
|
|
83
110
|
if (!text || !text.trim()) {
|
|
84
111
|
throw new Error("Cannot generate embedding for empty text.");
|
|
85
112
|
}
|
|
113
|
+
const trimmedText = text.trim();
|
|
114
|
+
const cacheKey = `${trimmedText.substring(0, 500)}|L${trimmedText.length}`;
|
|
115
|
+
const cached = this.getCachedEmbedding(cacheKey);
|
|
116
|
+
if (cached) {
|
|
117
|
+
debugLog(`[GeminiAdapter] Embedding cache HIT`);
|
|
118
|
+
return cached;
|
|
119
|
+
}
|
|
120
|
+
// In-flight dedup: if another call is already generating this embedding, await it
|
|
121
|
+
const inflight = GeminiAdapter._inflight.get(cacheKey);
|
|
122
|
+
if (inflight) {
|
|
123
|
+
debugLog(`[GeminiAdapter] Embedding in-flight dedup HIT`);
|
|
124
|
+
return inflight;
|
|
125
|
+
}
|
|
126
|
+
const promise = this._generateEmbeddingImpl(trimmedText, cacheKey);
|
|
127
|
+
GeminiAdapter._inflight.set(cacheKey, promise);
|
|
128
|
+
try {
|
|
129
|
+
return await promise;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
GeminiAdapter._inflight.delete(cacheKey);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async _generateEmbeddingImpl(inputTextRaw, cacheKey) {
|
|
86
136
|
// ── Truncation Guard ───────────────────────────────────────────────────
|
|
87
137
|
// gemini-embedding-001 has a ~2048 token context window.
|
|
88
138
|
// Long session summaries (esp. code-heavy ones) can easily exceed this.
|
|
89
139
|
// We truncate proactively rather than let the API return a 400 error.
|
|
90
|
-
let inputText =
|
|
140
|
+
let inputText = inputTextRaw;
|
|
91
141
|
if (inputText.length > MAX_EMBEDDING_CHARS) {
|
|
92
142
|
debugLog(`[GeminiAdapter] Embedding input truncated from ${inputText.length}` +
|
|
93
143
|
` to ~${MAX_EMBEDDING_CHARS} chars (word-safe)`);
|
|
@@ -130,6 +180,7 @@ export class GeminiAdapter {
|
|
|
130
180
|
throw new Error(`Embedding dimension mismatch: expected ${EMBEDDING_DIMS},` +
|
|
131
181
|
` got ${values?.length ?? "unknown"}`);
|
|
132
182
|
}
|
|
183
|
+
this.setCachedEmbedding(cacheKey, values);
|
|
133
184
|
return values;
|
|
134
185
|
}
|
|
135
186
|
// ─── Image Description (VLM) ─────────────────────────────────────────────
|
|
@@ -102,18 +102,47 @@ export class OpenAIAdapter {
|
|
|
102
102
|
return response.choices[0]?.message?.content ?? "";
|
|
103
103
|
}
|
|
104
104
|
// ─── Embedding Generation ────────────────────────────────────────────────
|
|
105
|
+
static _embeddingCache = new Map();
|
|
106
|
+
static _inflight = new Map();
|
|
107
|
+
static EMBED_CACHE_MAX = 256;
|
|
108
|
+
static EMBED_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
105
109
|
async generateEmbedding(text) {
|
|
106
110
|
// Guard: empty input produces a degenerate embedding — fail loudly.
|
|
107
111
|
if (!text || !text.trim()) {
|
|
108
112
|
throw new Error("Cannot generate embedding for empty text.");
|
|
109
113
|
}
|
|
110
|
-
|
|
114
|
+
const trimmedText = text.trim();
|
|
115
|
+
const cacheKey = `${trimmedText.substring(0, 500)}|L${trimmedText.length}`;
|
|
116
|
+
const entry = OpenAIAdapter._embeddingCache.get(cacheKey);
|
|
117
|
+
if (entry && Date.now() - entry.ts < OpenAIAdapter.EMBED_CACHE_TTL_MS) {
|
|
118
|
+
debugLog(`[OpenAIAdapter] Embedding cache HIT`);
|
|
119
|
+
// Move to tail for LRU on read
|
|
120
|
+
OpenAIAdapter._embeddingCache.delete(cacheKey);
|
|
121
|
+
OpenAIAdapter._embeddingCache.set(cacheKey, entry);
|
|
122
|
+
return entry.embedding;
|
|
123
|
+
}
|
|
124
|
+
// In-flight dedup
|
|
125
|
+
const inflight = OpenAIAdapter._inflight.get(cacheKey);
|
|
126
|
+
if (inflight) {
|
|
127
|
+
debugLog(`[OpenAIAdapter] Embedding in-flight dedup HIT`);
|
|
128
|
+
return inflight;
|
|
129
|
+
}
|
|
130
|
+
const promise = this._generateEmbeddingImpl(trimmedText, cacheKey);
|
|
131
|
+
OpenAIAdapter._inflight.set(cacheKey, promise);
|
|
132
|
+
try {
|
|
133
|
+
return await promise;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
OpenAIAdapter._inflight.delete(cacheKey);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async _generateEmbeddingImpl(inputTextRaw, cacheKey) {
|
|
111
140
|
const model = getSettingSync("openai_embedding_model", "text-embedding-3-small");
|
|
112
141
|
// ── Truncation Guard ───────────────────────────────────────────────────
|
|
113
142
|
// text-embedding-3-small accepts up to 8191 tokens.
|
|
114
143
|
// We apply the same preventive truncation as GeminiAdapter so behavior
|
|
115
144
|
// is consistent regardless of which provider is active.
|
|
116
|
-
let inputText =
|
|
145
|
+
let inputText = inputTextRaw;
|
|
117
146
|
if (inputText.length > MAX_EMBEDDING_CHARS) {
|
|
118
147
|
debugLog(`[OpenAIAdapter] Embedding input truncated from ${inputText.length}` +
|
|
119
148
|
` to ~${MAX_EMBEDDING_CHARS} chars (word-safe)`);
|
|
@@ -148,6 +177,13 @@ export class OpenAIAdapter {
|
|
|
148
177
|
`If using a local model, use one that natively outputs ${EMBEDDING_DIMS} dims ` +
|
|
149
178
|
`(e.g. nomic-embed-text) or supports the Matryoshka 'dimensions' parameter.`);
|
|
150
179
|
}
|
|
180
|
+
OpenAIAdapter._embeddingCache.delete(cacheKey);
|
|
181
|
+
if (OpenAIAdapter._embeddingCache.size >= OpenAIAdapter.EMBED_CACHE_MAX) {
|
|
182
|
+
const oldest = OpenAIAdapter._embeddingCache.keys().next().value;
|
|
183
|
+
if (oldest !== undefined)
|
|
184
|
+
OpenAIAdapter._embeddingCache.delete(oldest);
|
|
185
|
+
}
|
|
186
|
+
OpenAIAdapter._embeddingCache.set(cacheKey, { embedding, ts: Date.now() });
|
|
151
187
|
return embedding;
|
|
152
188
|
}
|
|
153
189
|
// ─── Image Description (VLM) ─────────────────────────────────────────────
|
package/dist/utils/localLlm.js
CHANGED
|
@@ -201,7 +201,7 @@ export async function callLocalLlm(userPrompt, model = PRISM_LOCAL_LLM_MODEL, sy
|
|
|
201
201
|
*
|
|
202
202
|
* @returns true if Ollama responds to /api/tags within 3 seconds.
|
|
203
203
|
*/
|
|
204
|
-
|
|
204
|
+
async function _unused_isLocalLlmAvailable() {
|
|
205
205
|
if (!PRISM_LOCAL_LLM_ENABLED)
|
|
206
206
|
return false;
|
|
207
207
|
try {
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { PRISM_DEBUG_LOGGING } from "../config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Sanitize a string for safe logging — strips control characters,
|
|
4
|
+
* newlines, and ANSI escape sequences that could be used for log
|
|
5
|
+
* injection or terminal escape attacks.
|
|
6
|
+
*/
|
|
7
|
+
function sanitizeForLog(msg) {
|
|
8
|
+
return msg
|
|
9
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") // control chars (keep \n \r \t)
|
|
10
|
+
.replace(/\r?\n/g, " ⏎ ") // newlines → visible marker
|
|
11
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); // ANSI escape sequences
|
|
12
|
+
}
|
|
2
13
|
/**
|
|
3
14
|
* Logs a message to stderr only if PRISM_DEBUG_LOGGING is true.
|
|
4
|
-
*
|
|
5
|
-
* that should be hidden from users by default but remain available
|
|
6
|
-
* for troubleshooting.
|
|
15
|
+
* Input is sanitized to prevent log injection attacks.
|
|
7
16
|
*/
|
|
8
17
|
export function debugLog(message) {
|
|
9
18
|
if (PRISM_DEBUG_LOGGING) {
|
|
10
|
-
console.error(message);
|
|
19
|
+
console.error(sanitizeForLog(message));
|
|
11
20
|
}
|
|
12
21
|
}
|
package/dist/utils/notifier.js
CHANGED
|
@@ -47,6 +47,58 @@ function meetsMinSeverity(severity) {
|
|
|
47
47
|
SEVERITY_ORDER.indexOf(currentConfig.minSeverity));
|
|
48
48
|
}
|
|
49
49
|
// ─── SSRF Protection ─────────────────────────────────────────
|
|
50
|
+
function isPrivateIP(ip) {
|
|
51
|
+
// Normalize: strip brackets for IPv6
|
|
52
|
+
const clean = ip.replace(/^\[|\]$/g, "").toLowerCase();
|
|
53
|
+
// IPv6 loopback and unspecified
|
|
54
|
+
if (clean === "::1" || clean === "::" || clean === "0:0:0:0:0:0:0:1" || clean === "0:0:0:0:0:0:0:0")
|
|
55
|
+
return true;
|
|
56
|
+
// IPv4-mapped IPv6 — two forms:
|
|
57
|
+
// Dotted: ::ffff:127.0.0.1 → extract IPv4 directly
|
|
58
|
+
// Hex: ::ffff:7f00:1 (Node normalizes dotted to this) → decode hex groups
|
|
59
|
+
const v4mapped = clean.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
60
|
+
const v4hex = clean.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
61
|
+
let ipv4 = clean;
|
|
62
|
+
if (v4mapped) {
|
|
63
|
+
ipv4 = v4mapped[1];
|
|
64
|
+
}
|
|
65
|
+
else if (v4hex) {
|
|
66
|
+
const hi = parseInt(v4hex[1], 16);
|
|
67
|
+
const lo = parseInt(v4hex[2], 16);
|
|
68
|
+
ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
69
|
+
}
|
|
70
|
+
// Parse as IPv4 — handles decimal, but reject octal/hex by requiring standard dotted-quad
|
|
71
|
+
const parts = ipv4.split(".");
|
|
72
|
+
if (parts.length === 4) {
|
|
73
|
+
const nums = parts.map(p => {
|
|
74
|
+
if (!/^\d{1,3}$/.test(p))
|
|
75
|
+
return -1;
|
|
76
|
+
return parseInt(p, 10);
|
|
77
|
+
});
|
|
78
|
+
if (nums.every(n => n >= 0 && n <= 255)) {
|
|
79
|
+
const [a, b] = nums;
|
|
80
|
+
if (a === 0)
|
|
81
|
+
return true; // 0.0.0.0/8
|
|
82
|
+
if (a === 10)
|
|
83
|
+
return true; // 10.0.0.0/8
|
|
84
|
+
if (a === 127)
|
|
85
|
+
return true; // 127.0.0.0/8 (all loopback)
|
|
86
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
87
|
+
return true; // 172.16.0.0/12
|
|
88
|
+
if (a === 192 && b === 168)
|
|
89
|
+
return true; // 192.168.0.0/16
|
|
90
|
+
if (a === 169 && b === 254)
|
|
91
|
+
return true; // 169.254.0.0/16 link-local
|
|
92
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
93
|
+
return true; // 100.64.0.0/10 CGNAT
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Reject non-standard IP formats (octal 0177.0.0.1, hex 0x7f000001, decimal 2130706433)
|
|
97
|
+
// If it looks like a number or has 0x/0 prefix, block it
|
|
98
|
+
if (/^0x[0-9a-f]+$/i.test(clean) || /^0\d+$/.test(clean) || /^\d{4,}$/.test(clean))
|
|
99
|
+
return true;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
50
102
|
function isAllowedUrl(url) {
|
|
51
103
|
try {
|
|
52
104
|
const parsed = new URL(url);
|
|
@@ -54,32 +106,22 @@ function isAllowedUrl(url) {
|
|
|
54
106
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
55
107
|
return false;
|
|
56
108
|
const hostname = parsed.hostname.toLowerCase();
|
|
57
|
-
// Block localhost
|
|
58
|
-
if (hostname === "localhost" || hostname === "
|
|
109
|
+
// Block localhost variants
|
|
110
|
+
if (hostname === "localhost" || hostname === "localhost.localdomain")
|
|
59
111
|
return false;
|
|
60
|
-
// Block .internal
|
|
61
|
-
if (hostname.endsWith(".internal") || hostname.endsWith(".local"))
|
|
112
|
+
// Block .internal, .local, .arpa TLDs
|
|
113
|
+
if (hostname.endsWith(".internal") || hostname.endsWith(".local") || hostname.endsWith(".arpa"))
|
|
114
|
+
return false;
|
|
115
|
+
// Block private/loopback IPs (covers 0.0.0.0, 127.x, 10.x, 172.16-31.x, 192.168.x, ::1, etc.)
|
|
116
|
+
if (isPrivateIP(hostname))
|
|
117
|
+
return false;
|
|
118
|
+
// Block bracketed IPv6
|
|
119
|
+
if (hostname.startsWith("[") && isPrivateIP(hostname))
|
|
62
120
|
return false;
|
|
63
|
-
// Block private IP ranges
|
|
64
|
-
const ipParts = hostname.split(".").map(Number);
|
|
65
|
-
if (ipParts.length === 4 && ipParts.every(p => Number.isFinite(p))) {
|
|
66
|
-
// 10.0.0.0/8
|
|
67
|
-
if (ipParts[0] === 10)
|
|
68
|
-
return false;
|
|
69
|
-
// 172.16.0.0/12
|
|
70
|
-
if (ipParts[0] === 172 && ipParts[1] >= 16 && ipParts[1] <= 31)
|
|
71
|
-
return false;
|
|
72
|
-
// 192.168.0.0/16
|
|
73
|
-
if (ipParts[0] === 192 && ipParts[1] === 168)
|
|
74
|
-
return false;
|
|
75
|
-
// 169.254.0.0/16 (link-local)
|
|
76
|
-
if (ipParts[0] === 169 && ipParts[1] === 254)
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
121
|
return true;
|
|
80
122
|
}
|
|
81
123
|
catch {
|
|
82
|
-
return false;
|
|
124
|
+
return false;
|
|
83
125
|
}
|
|
84
126
|
}
|
|
85
127
|
// ─── Channel Senders ─────────────────────────────────────────
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* For ambiguous files, --format= is mandatory.
|
|
37
37
|
* ═══════════════════════════════════════════════════════════════════
|
|
38
38
|
*/
|
|
39
|
+
import { debugLog } from "./logger.js";
|
|
39
40
|
import { getStorage } from "../storage/index.js";
|
|
40
41
|
import { claudeAdapter } from "./migration/claudeAdapter.js";
|
|
41
42
|
import { geminiAdapter } from "./migration/geminiAdapter.js";
|
|
@@ -128,16 +129,16 @@ export async function universalImporter(options) {
|
|
|
128
129
|
if (sniffed) {
|
|
129
130
|
adapter = adapters.find((a) => a.id === sniffed);
|
|
130
131
|
if (adapter) {
|
|
131
|
-
|
|
132
|
+
debugLog(`🔍 Auto-detected format: ${sniffed} (via content sniffing)`);
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
if (!adapter) {
|
|
136
137
|
throw new Error(`Could not determine adapter for file: ${filePathArg}. Use --format to specify.`);
|
|
137
138
|
}
|
|
138
|
-
|
|
139
|
+
debugLog(`🚀 Starting migration from ${adapter.id} to Prism...`);
|
|
139
140
|
if (dryRun)
|
|
140
|
-
|
|
141
|
+
debugLog("⚠️ DRY RUN MODE - storage writes disabled.");
|
|
141
142
|
// ── Storage + Concurrency ──────────────────────────────────────
|
|
142
143
|
const storage = await getStorage();
|
|
143
144
|
const limit = pLimit(5);
|
|
@@ -169,7 +170,7 @@ export async function universalImporter(options) {
|
|
|
169
170
|
conversationCount++;
|
|
170
171
|
if (verbose) {
|
|
171
172
|
const turnCount = turns.length;
|
|
172
|
-
|
|
173
|
+
debugLog(`📦 Conversation #${conversationCount}: ${turnCount} turns (${sessionDate}) → ${conversationId}`);
|
|
173
174
|
}
|
|
174
175
|
if (dryRun) {
|
|
175
176
|
successCount += turns.length;
|
|
@@ -188,7 +189,7 @@ export async function universalImporter(options) {
|
|
|
188
189
|
if (existing.length > 0) {
|
|
189
190
|
skipCount += turns.length;
|
|
190
191
|
if (verbose) {
|
|
191
|
-
|
|
192
|
+
debugLog(`⏭️ Skipping duplicate: ${conversationId}`);
|
|
192
193
|
}
|
|
193
194
|
return;
|
|
194
195
|
}
|
|
@@ -229,13 +230,13 @@ export async function universalImporter(options) {
|
|
|
229
230
|
// ── Final Flush ──────────────────────────────────────────────
|
|
230
231
|
// Flush the last conversation (no trailing time gap to trigger it)
|
|
231
232
|
await flushConversation();
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
debugLog("\n✅ Migration complete!");
|
|
234
|
+
debugLog(` Conversations: ${conversationCount}`);
|
|
235
|
+
debugLog(` Turns processed: ${successCount}`);
|
|
235
236
|
if (skipCount > 0)
|
|
236
|
-
|
|
237
|
+
debugLog(` Skipped (dup): ${skipCount}`);
|
|
237
238
|
if (failCount > 0)
|
|
238
|
-
|
|
239
|
+
debugLog(` Failed: ${failCount}`);
|
|
239
240
|
return { successCount, failCount, skipCount, conversationCount };
|
|
240
241
|
}
|
|
241
242
|
catch (err) {
|
|
@@ -261,7 +262,7 @@ async function runCLI() {
|
|
|
261
262
|
const dryRun = args.includes("--dry-run") || args.includes("-d");
|
|
262
263
|
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
263
264
|
if (!filePathArg) {
|
|
264
|
-
|
|
265
|
+
debugLog(`
|
|
265
266
|
Prism Universal History Importer
|
|
266
267
|
Usage: node universalImporter.js <file> [options]
|
|
267
268
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "16.0.0",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
5
|
"description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 54 Agent Skills, Zero-Search HDC/HRR retrieval, HIPAA-hardened local-first storage, SLERP-optimized GRPO alignment) plus the prism-coder:7b / 14b open-weights LLM fleet.",
|
|
6
6
|
"module": "index.ts",
|