prism-mcp-server 19.2.4 → 19.2.6
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 +34 -0
- package/dist/server.js +11 -5
- package/dist/tools/ledgerHandlers.js +94 -9
- package/dist/tools/v12Handlers.js +4 -4
- package/dist/utils/analytics.js +159 -136
- package/dist/utils/ddLogger.js +1 -0
- package/dist/utils/notifier.js +54 -17
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -412,6 +412,40 @@ prism_infer({
|
|
|
412
412
|
|
|
413
413
|
Full TypeScript signatures live in [`src/tools/`](src/tools/); architecture in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
|
414
414
|
|
|
415
|
+
### `inference_metrics` — see your local-model usage on demand
|
|
416
|
+
|
|
417
|
+
Call `inference_metrics` anytime mid-session to see how many `prism_infer` calls ran locally vs cloud, with actual token counts:
|
|
418
|
+
|
|
419
|
+
```
|
|
420
|
+
📊 Inference Metrics — local-model delegation (this session):
|
|
421
|
+
Total calls: 5 — Local: 5 (100%) | Cloud: 0 (0%)
|
|
422
|
+
Tokens: 1,240 in + 380 out = 1,620 total
|
|
423
|
+
Avg latency: 420ms
|
|
424
|
+
By model:
|
|
425
|
+
prism-coder:27b: 3 calls, 1,100 tokens, avg 520ms
|
|
426
|
+
prism-coder:9b: 2 calls, 520 tokens, avg 270ms
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
The same block also appears automatically in `session_save_ledger` and `session_save_handoff` responses at session end.
|
|
430
|
+
|
|
431
|
+
**Note:** This tracks `prism_infer` delegation only — not your host model's (Claude's) own token spend. For that, use Claude Code's `/cost` command.
|
|
432
|
+
|
|
433
|
+
### Local-model delegation (opt-in)
|
|
434
|
+
|
|
435
|
+
By default, your AI agent (Claude, Cursor, etc.) handles everything itself. You can optionally enable delegation so the agent offloads cheap, verifiable sub-tasks to local Ollama models at $0:
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
# Enable via Prism config
|
|
439
|
+
prism config set delegation_enabled true
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
When enabled, the agent's task router may delegate qualifying work — bulk classification, field extraction, mechanical formatting — to `prism_infer` instead of using cloud tokens. The agent always verifies the result and redoes it itself if quality is degraded.
|
|
443
|
+
|
|
444
|
+
**Guardrails:**
|
|
445
|
+
- **Off by default** — enforced in code, not just convention
|
|
446
|
+
- **Never delegates:** code/text that ships to the user, security/safety logic, planning/reasoning, anything where a silent quality drop isn't obvious
|
|
447
|
+
- **Always verifies:** checks `quality_gate_failed` and `used_cloud` before trusting local output
|
|
448
|
+
|
|
415
449
|
<details>
|
|
416
450
|
<summary>How Prism survives context compaction</summary>
|
|
417
451
|
|
package/dist/server.js
CHANGED
|
@@ -80,6 +80,7 @@ import { getTracer, initTelemetry } from "./utils/telemetry.js";
|
|
|
80
80
|
import { context as otelContext, trace, SpanStatusCode } from "@opentelemetry/api";
|
|
81
81
|
import { ddInfo, ddError as ddLogError } from "./utils/ddLogger.js";
|
|
82
82
|
import { inferenceMetricsHandler } from "./utils/inferenceMetrics.js";
|
|
83
|
+
import { recordInvocation } from "./utils/analytics.js";
|
|
83
84
|
// ─── Import Tool Definitions (schemas) and Handlers (implementations) ─────
|
|
84
85
|
import { WEB_SEARCH_TOOL, BRAVE_WEB_SEARCH_CODE_MODE_TOOL, LOCAL_SEARCH_TOOL, BRAVE_LOCAL_SEARCH_CODE_MODE_TOOL, CODE_MODE_TRANSFORM_TOOL, BRAVE_ANSWERS_TOOL, RESEARCH_PAPER_ANALYSIS_TOOL, webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, braveLocalSearchCodeModeHandler, codeModeTransformHandler, braveAnswersHandler, researchPaperAnalysisHandler, } from "./tools/index.js";
|
|
85
86
|
// Session memory tools — only used if Supabase is configured
|
|
@@ -975,7 +976,9 @@ export function createServer() {
|
|
|
975
976
|
};
|
|
976
977
|
}
|
|
977
978
|
rootSpan.setStatus({ code: SpanStatusCode.OK });
|
|
978
|
-
|
|
979
|
+
const _ddDuration = Date.now() - _ddStart;
|
|
980
|
+
ddInfo("mcp.tool.success", { tool: name, project: args?.project, durationMs: _ddDuration });
|
|
981
|
+
recordInvocation(name, String(args?.project ?? ""), args, JSON.stringify(result?.content?.[0]?.text ?? "").slice(0, 2000), _ddDuration, true);
|
|
979
982
|
// ═══ v5.3: Hivemind Watchdog Alert Injection (Telepathy) ═══
|
|
980
983
|
// CRITICAL: Append alerts DIRECTLY to tool response content
|
|
981
984
|
// so the LLM actually reads them. sendLoggingMessage goes to
|
|
@@ -1015,13 +1018,16 @@ export function createServer() {
|
|
|
1015
1018
|
return result;
|
|
1016
1019
|
}
|
|
1017
1020
|
catch (error) {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
1022
|
+
const _ddErrDuration = Date.now() - _ddStart;
|
|
1023
|
+
console.error(`Error in tool handler: ${errMsg}`);
|
|
1024
|
+
ddLogError("mcp.tool.error", error instanceof Error ? error : undefined, { tool: name, project: args?.project, durationMs: _ddErrDuration });
|
|
1025
|
+
rootSpan.recordException(error instanceof Error ? error : new Error(errMsg));
|
|
1021
1026
|
rootSpan.setStatus({
|
|
1022
1027
|
code: SpanStatusCode.ERROR,
|
|
1023
|
-
message:
|
|
1028
|
+
message: errMsg,
|
|
1024
1029
|
});
|
|
1030
|
+
recordInvocation(name, String(args?.project ?? ""), args, "", _ddErrDuration, false, errMsg);
|
|
1025
1031
|
return {
|
|
1026
1032
|
content: [
|
|
1027
1033
|
{
|
|
@@ -40,8 +40,8 @@ isSessionForgetMemoryArgs, // v3.1: TTL retention policy type guard
|
|
|
40
40
|
isSessionSaveExperienceArgs, isSessionExportMemoryArgs, normalizeExportFormat, isSessionSaveImageArgs, isSessionViewImageArgs } from "./sessionMemoryDefinitions.js";
|
|
41
41
|
// v4.2: File system access for knowledge_sync_rules
|
|
42
42
|
import { writeFile } from "node:fs/promises";
|
|
43
|
-
import { existsSync } from "node:fs";
|
|
44
|
-
import { join } from "node:path";
|
|
43
|
+
import { existsSync, realpathSync, mkdirSync } from "node:fs";
|
|
44
|
+
import { join, sep } from "node:path";
|
|
45
45
|
// v3.1: In-memory debounce lock for auto-compaction.
|
|
46
46
|
// Prevents multiple concurrent Gemini compaction tasks for the same project
|
|
47
47
|
// when many agents call session_save_ledger at the same time.
|
|
@@ -1448,7 +1448,13 @@ export async function sessionExportMemoryHandler(args) {
|
|
|
1448
1448
|
// to select the correct .obsidian/.logseq sidecar config.
|
|
1449
1449
|
const requestedFormat = rawFormat;
|
|
1450
1450
|
const format = normalizeExportFormat(rawFormat);
|
|
1451
|
-
// Validate output directory
|
|
1451
|
+
// Validate output directory — path traversal confinement (v4.3 security)
|
|
1452
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp";
|
|
1453
|
+
const defaultExportDir = join(homeDir, ".prism-mcp", "exports");
|
|
1454
|
+
// Create default export directory if it doesn't exist
|
|
1455
|
+
if (!existsSync(defaultExportDir)) {
|
|
1456
|
+
mkdirSync(defaultExportDir, { recursive: true });
|
|
1457
|
+
}
|
|
1452
1458
|
if (!existsSync(output_dir)) {
|
|
1453
1459
|
return {
|
|
1454
1460
|
content: [{
|
|
@@ -1458,6 +1464,72 @@ export async function sessionExportMemoryHandler(args) {
|
|
|
1458
1464
|
isError: true,
|
|
1459
1465
|
};
|
|
1460
1466
|
}
|
|
1467
|
+
// Resolve symlinks and ".." to get the canonical path
|
|
1468
|
+
const resolvedDir = realpathSync(output_dir);
|
|
1469
|
+
// Build allow-list of safe export roots
|
|
1470
|
+
const allowedRoots = [
|
|
1471
|
+
defaultExportDir,
|
|
1472
|
+
process.cwd(),
|
|
1473
|
+
];
|
|
1474
|
+
// Scratch root under tmp — but NOT the world-writable tmp root itself.
|
|
1475
|
+
// os.tmpdir() (typically /tmp, mode 1777) is readable and writable by every
|
|
1476
|
+
// local principal: allowing it as an export root would (a) leak unredacted
|
|
1477
|
+
// ledger/handoff contents to other users and (b) let a co-resident attacker
|
|
1478
|
+
// pre-plant a symlink at the predictable export filename and have writeFile
|
|
1479
|
+
// follow it (arbitrary-file overwrite). Confine to an owner-only subdir.
|
|
1480
|
+
const tmpExportDir = join(os.tmpdir(), ".prism-mcp", "exports");
|
|
1481
|
+
if (!existsSync(tmpExportDir)) {
|
|
1482
|
+
mkdirSync(tmpExportDir, { recursive: true, mode: 0o700 });
|
|
1483
|
+
}
|
|
1484
|
+
allowedRoots.push(realpathSync(tmpExportDir));
|
|
1485
|
+
if (process.env.PRISM_EXPORT_ROOT) {
|
|
1486
|
+
const exportRoot = process.env.PRISM_EXPORT_ROOT;
|
|
1487
|
+
if (existsSync(exportRoot)) {
|
|
1488
|
+
allowedRoots.push(realpathSync(exportRoot));
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
// Reject sensitive directories (resolve each so macOS /etc → /private/etc matches)
|
|
1492
|
+
const sensitiveRoots = [
|
|
1493
|
+
join(homeDir, ".ssh"),
|
|
1494
|
+
join(homeDir, ".gnupg"),
|
|
1495
|
+
"/etc",
|
|
1496
|
+
"/var",
|
|
1497
|
+
"/etc/cron.d",
|
|
1498
|
+
"/etc/cron.daily",
|
|
1499
|
+
"/etc/sudoers.d",
|
|
1500
|
+
].map(p => existsSync(p) ? realpathSync(p) : p);
|
|
1501
|
+
// Check allow-list first — explicitly configured roots take precedence
|
|
1502
|
+
const isAllowedRoot = (dir) => allowedRoots.some((root) => {
|
|
1503
|
+
const resolvedRoot = existsSync(root) ? realpathSync(root) : root;
|
|
1504
|
+
return dir === resolvedRoot || dir.startsWith(resolvedRoot + sep);
|
|
1505
|
+
});
|
|
1506
|
+
for (const sensitive of sensitiveRoots) {
|
|
1507
|
+
if (resolvedDir === sensitive || resolvedDir.startsWith(sensitive + sep)) {
|
|
1508
|
+
// Allow explicitly configured roots even under sensitive parents
|
|
1509
|
+
// (e.g. tmpExportDir under /private/var on macOS, or PRISM_EXPORT_ROOT)
|
|
1510
|
+
if (!isAllowedRoot(resolvedDir)) {
|
|
1511
|
+
return {
|
|
1512
|
+
content: [{
|
|
1513
|
+
type: "text",
|
|
1514
|
+
text: `Error: output_dir resolves to a sensitive system directory ("${resolvedDir}"). Export denied.`,
|
|
1515
|
+
}],
|
|
1516
|
+
isError: true,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
// Verify resolved path falls under an allowed root
|
|
1522
|
+
if (!isAllowedRoot(resolvedDir)) {
|
|
1523
|
+
return {
|
|
1524
|
+
content: [{
|
|
1525
|
+
type: "text",
|
|
1526
|
+
text: `Error: output_dir "${resolvedDir}" is outside allowed export roots. ` +
|
|
1527
|
+
`Allowed: ${allowedRoots.join(", ")}. ` +
|
|
1528
|
+
`Set PRISM_EXPORT_ROOT env var to add a custom root.`,
|
|
1529
|
+
}],
|
|
1530
|
+
isError: true,
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1461
1533
|
const storage = await getStorage();
|
|
1462
1534
|
const exportedFiles = [];
|
|
1463
1535
|
try {
|
|
@@ -1527,14 +1599,27 @@ export async function sessionExportMemoryHandler(args) {
|
|
|
1527
1599
|
else {
|
|
1528
1600
|
content = JSON.stringify(exportPayload, null, 2);
|
|
1529
1601
|
}
|
|
1530
|
-
|
|
1531
|
-
|
|
1602
|
+
// Exclusive create (flag: "wx") refuses to write if the path already
|
|
1603
|
+
// exists — including a symlink pre-planted at the predictable export
|
|
1604
|
+
// name. On a genuine collision fall back to a timestamped unique name.
|
|
1605
|
+
let finalPath = outputPath;
|
|
1606
|
+
try {
|
|
1607
|
+
await writeFile(finalPath, content, { flag: "wx" });
|
|
1532
1608
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1609
|
+
catch (e) {
|
|
1610
|
+
if (e && e.code === "EEXIST") {
|
|
1611
|
+
const dot = filename.lastIndexOf(".");
|
|
1612
|
+
const stem = dot > 0 ? filename.slice(0, dot) : filename;
|
|
1613
|
+
const extPart = dot > 0 ? filename.slice(dot) : "";
|
|
1614
|
+
finalPath = join(output_dir, `${stem}-${Date.now()}${extPart}`);
|
|
1615
|
+
await writeFile(finalPath, content, { flag: "wx" });
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
throw e;
|
|
1619
|
+
}
|
|
1535
1620
|
}
|
|
1536
|
-
exportedFiles.push(
|
|
1537
|
-
debugLog(`[session_export_memory] Wrote ${content.length} bytes to ${
|
|
1621
|
+
exportedFiles.push(finalPath);
|
|
1622
|
+
debugLog(`[session_export_memory] Wrote ${content.length} bytes to ${finalPath}`);
|
|
1538
1623
|
}
|
|
1539
1624
|
const plural = exportedFiles.length > 1 ? "files" : "file";
|
|
1540
1625
|
return {
|
|
@@ -114,10 +114,10 @@ export async function extractEntitiesHandler(args) {
|
|
|
114
114
|
}
|
|
115
115
|
// ─── API Analytics Handler ───────────────────────────────────
|
|
116
116
|
export async function apiAnalyticsHandler(args) {
|
|
117
|
-
const {
|
|
117
|
+
const { scope, project, days } = args;
|
|
118
118
|
try {
|
|
119
119
|
const analytics = await import("../utils/analytics.js");
|
|
120
|
-
if (
|
|
120
|
+
if (scope === "system" || !scope) {
|
|
121
121
|
const dashboard = await analytics.getSystemAnalytics(days || 30);
|
|
122
122
|
return {
|
|
123
123
|
content: [{
|
|
@@ -130,7 +130,7 @@ export async function apiAnalyticsHandler(args) {
|
|
|
130
130
|
}],
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
|
-
if (
|
|
133
|
+
if (scope === "project" && project) {
|
|
134
134
|
const projectStats = await analytics.getProjectAnalytics(project, days || 30);
|
|
135
135
|
return {
|
|
136
136
|
content: [{
|
|
@@ -149,7 +149,7 @@ export async function apiAnalyticsHandler(args) {
|
|
|
149
149
|
type: "text",
|
|
150
150
|
text: JSON.stringify({
|
|
151
151
|
status: "ok",
|
|
152
|
-
message: "Use
|
|
152
|
+
message: "Use scope='system' for aggregate stats or scope='project' with project='name' for per-project stats.",
|
|
153
153
|
}, null, 2),
|
|
154
154
|
}],
|
|
155
155
|
};
|
package/dist/utils/analytics.js
CHANGED
|
@@ -1,100 +1,123 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* API Usage Analytics — Per-Project Call Tracking
|
|
3
3
|
*
|
|
4
4
|
* Tracks every MCP tool invocation with timing, token estimates,
|
|
5
|
-
* and project association.
|
|
6
|
-
*
|
|
5
|
+
* and project association. Uses @libsql/client (same as the rest
|
|
6
|
+
* of prism's local storage layer).
|
|
7
7
|
*
|
|
8
|
-
* Storage:
|
|
9
|
-
* Zero external dependencies — no cloud telemetry.
|
|
8
|
+
* Storage: `api_analytics` table in ~/.prism-mcp/data.db
|
|
10
9
|
*/
|
|
11
10
|
import { debugLog } from "./logger.js";
|
|
11
|
+
import { createClient } from "@libsql/client";
|
|
12
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
12
13
|
import { homedir } from "node:os";
|
|
13
|
-
import { join } from "node:path";
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
// ─── DB Connection ──────────────────────────────────────────
|
|
16
|
+
let _db = null;
|
|
17
|
+
let _tableReady = false;
|
|
18
|
+
function getDbPath() {
|
|
19
|
+
return process.env.PRISM_ANALYTICS_DB_PATH
|
|
20
|
+
|| join(homedir(), ".prism-mcp", "data.db");
|
|
21
|
+
}
|
|
22
|
+
function getDb() {
|
|
23
|
+
if (_db)
|
|
24
|
+
return _db;
|
|
25
|
+
const dbPath = getDbPath();
|
|
26
|
+
const dir = dirname(dbPath);
|
|
27
|
+
if (!existsSync(dir))
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
_db = createClient({ url: `file:${dbPath}` });
|
|
30
|
+
return _db;
|
|
31
|
+
}
|
|
32
|
+
async function ensureTable() {
|
|
33
|
+
if (_tableReady)
|
|
34
|
+
return;
|
|
35
|
+
await getDb().execute("PRAGMA journal_mode=WAL");
|
|
36
|
+
await getDb().execute(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS api_analytics (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
tool TEXT NOT NULL,
|
|
40
|
+
project TEXT NOT NULL,
|
|
41
|
+
timestamp TEXT NOT NULL,
|
|
42
|
+
duration_ms INTEGER NOT NULL,
|
|
43
|
+
input_tokens INTEGER NOT NULL,
|
|
44
|
+
output_tokens INTEGER NOT NULL,
|
|
45
|
+
success INTEGER NOT NULL,
|
|
46
|
+
error_message TEXT
|
|
47
|
+
)
|
|
48
|
+
`);
|
|
49
|
+
_tableReady = true;
|
|
50
|
+
}
|
|
51
|
+
/** Reset DB connection (for tests). */
|
|
52
|
+
export function _resetDb() {
|
|
53
|
+
_db = null;
|
|
54
|
+
_tableReady = false;
|
|
17
55
|
}
|
|
18
56
|
// ─── In-Memory Buffer ────────────────────────────────────────
|
|
19
|
-
// Batch writes to SQLite every N invocations or M seconds.
|
|
20
57
|
const BUFFER = [];
|
|
21
58
|
const FLUSH_THRESHOLD = 25;
|
|
22
59
|
const FLUSH_INTERVAL_MS = 30_000;
|
|
23
60
|
let flushTimer = null;
|
|
24
|
-
// ─── Token Estimation ────────────────────────────────────────
|
|
25
|
-
// Rough heuristic: 1 token ≈ 4 characters
|
|
26
61
|
function estimateTokens(text) {
|
|
27
62
|
return Math.ceil(text.length / 4);
|
|
28
63
|
}
|
|
29
64
|
// ─── Recording ───────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
export function recordInvocation(tool, project, args, response, durationMs, success, errorMessage) {
|
|
66
|
+
// Called before the tool response return in both success and error paths
|
|
67
|
+
// of server dispatch — a throw here would swallow the tool result. Isolate.
|
|
68
|
+
try {
|
|
69
|
+
const invocation = {
|
|
70
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
71
|
+
tool,
|
|
72
|
+
project: project || "unknown",
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
durationMs,
|
|
75
|
+
inputTokens: estimateTokens(JSON.stringify(args || {})),
|
|
76
|
+
outputTokens: estimateTokens(response || ""),
|
|
77
|
+
success,
|
|
78
|
+
errorMessage,
|
|
79
|
+
};
|
|
80
|
+
BUFFER.push(invocation);
|
|
81
|
+
if (BUFFER.length >= FLUSH_THRESHOLD) {
|
|
82
|
+
void flushBuffer();
|
|
83
|
+
}
|
|
84
|
+
if (!flushTimer) {
|
|
85
|
+
flushTimer = setTimeout(() => {
|
|
86
|
+
flushTimer = null;
|
|
87
|
+
void flushBuffer();
|
|
88
|
+
}, FLUSH_INTERVAL_MS);
|
|
89
|
+
if (typeof flushTimer === "object" && "unref" in flushTimer) {
|
|
90
|
+
flushTimer.unref();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
51
93
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
flushTimer = setTimeout(() => {
|
|
55
|
-
flushBuffer();
|
|
56
|
-
flushTimer = null;
|
|
57
|
-
}, FLUSH_INTERVAL_MS);
|
|
94
|
+
catch (err) {
|
|
95
|
+
debugLog(`Analytics recordInvocation skipped: ${err}`);
|
|
58
96
|
}
|
|
59
97
|
}
|
|
60
|
-
/**
|
|
61
|
-
* Flush buffered invocations to storage.
|
|
62
|
-
*/
|
|
63
98
|
export async function flushBuffer() {
|
|
64
99
|
if (BUFFER.length === 0)
|
|
65
100
|
return 0;
|
|
66
101
|
const batch = BUFFER.splice(0, BUFFER.length);
|
|
67
102
|
try {
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
const db = new Database(getAnalyticsDbPath());
|
|
71
|
-
// Ensure table exists
|
|
72
|
-
db.exec(`
|
|
73
|
-
CREATE TABLE IF NOT EXISTS api_analytics (
|
|
74
|
-
id TEXT PRIMARY KEY,
|
|
75
|
-
tool TEXT NOT NULL,
|
|
76
|
-
project TEXT NOT NULL,
|
|
77
|
-
timestamp TEXT NOT NULL,
|
|
78
|
-
duration_ms INTEGER NOT NULL,
|
|
79
|
-
input_tokens INTEGER NOT NULL,
|
|
80
|
-
output_tokens INTEGER NOT NULL,
|
|
81
|
-
success INTEGER NOT NULL,
|
|
82
|
-
error_message TEXT
|
|
83
|
-
)
|
|
84
|
-
`);
|
|
85
|
-
const stmt = db.prepare(`
|
|
86
|
-
INSERT OR IGNORE INTO api_analytics
|
|
87
|
-
(id, tool, project, timestamp, duration_ms, input_tokens, output_tokens, success, error_message)
|
|
88
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
89
|
-
`);
|
|
103
|
+
await ensureTable();
|
|
104
|
+
const db = getDb();
|
|
90
105
|
for (const inv of batch) {
|
|
91
|
-
|
|
106
|
+
await db.execute({
|
|
107
|
+
sql: `INSERT OR IGNORE INTO api_analytics
|
|
108
|
+
(id, tool, project, timestamp, duration_ms, input_tokens, output_tokens, success, error_message)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
110
|
+
args: [
|
|
111
|
+
inv.id, inv.tool, inv.project, inv.timestamp,
|
|
112
|
+
inv.durationMs, inv.inputTokens, inv.outputTokens,
|
|
113
|
+
inv.success ? 1 : 0, inv.errorMessage || null,
|
|
114
|
+
],
|
|
115
|
+
});
|
|
92
116
|
}
|
|
93
|
-
debugLog(`Analytics: flushed ${batch.length} invocations
|
|
117
|
+
debugLog(`Analytics: flushed ${batch.length} invocations`);
|
|
94
118
|
return batch.length;
|
|
95
119
|
}
|
|
96
120
|
catch (err) {
|
|
97
|
-
// Re-add to buffer on failure, cap max size to prevent unbounded growth
|
|
98
121
|
BUFFER.unshift(...batch);
|
|
99
122
|
if (BUFFER.length > 1000)
|
|
100
123
|
BUFFER.splice(1000);
|
|
@@ -103,51 +126,51 @@ export async function flushBuffer() {
|
|
|
103
126
|
}
|
|
104
127
|
}
|
|
105
128
|
// ─── Query Functions ─────────────────────────────────────────
|
|
106
|
-
/**
|
|
107
|
-
* Get analytics for a specific project.
|
|
108
|
-
*/
|
|
109
129
|
export async function getProjectAnalytics(project, days = 30) {
|
|
110
|
-
await flushBuffer();
|
|
130
|
+
await flushBuffer();
|
|
111
131
|
try {
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
132
|
+
await ensureTable();
|
|
133
|
+
const db = getDb();
|
|
134
|
+
const since = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
135
|
+
const stats = await db.execute({
|
|
136
|
+
sql: `SELECT
|
|
137
|
+
COUNT(*) as total_calls,
|
|
138
|
+
AVG(CASE WHEN success = 1 THEN 1.0 ELSE 0.0 END) as success_rate,
|
|
139
|
+
AVG(duration_ms) as avg_duration,
|
|
140
|
+
SUM(input_tokens) as total_input,
|
|
141
|
+
SUM(output_tokens) as total_output,
|
|
142
|
+
MIN(timestamp) as period_start,
|
|
143
|
+
MAX(timestamp) as period_end
|
|
144
|
+
FROM api_analytics
|
|
145
|
+
WHERE project = ? AND timestamp >= ?`,
|
|
146
|
+
args: [project, since],
|
|
147
|
+
});
|
|
148
|
+
const s = stats.rows[0];
|
|
149
|
+
const topTools = await db.execute({
|
|
150
|
+
sql: `SELECT tool, COUNT(*) as count
|
|
151
|
+
FROM api_analytics
|
|
152
|
+
WHERE project = ? AND timestamp >= ?
|
|
153
|
+
GROUP BY tool ORDER BY count DESC LIMIT 10`,
|
|
154
|
+
args: [project, since],
|
|
155
|
+
});
|
|
156
|
+
const callsByDay = await db.execute({
|
|
157
|
+
sql: `SELECT DATE(timestamp) as date, COUNT(*) as count
|
|
158
|
+
FROM api_analytics
|
|
159
|
+
WHERE project = ? AND timestamp >= ?
|
|
160
|
+
GROUP BY DATE(timestamp) ORDER BY date`,
|
|
161
|
+
args: [project, since],
|
|
162
|
+
});
|
|
140
163
|
return {
|
|
141
164
|
project,
|
|
142
|
-
totalCalls:
|
|
143
|
-
successRate:
|
|
144
|
-
avgDurationMs: Math.round(
|
|
145
|
-
totalInputTokens:
|
|
146
|
-
totalOutputTokens:
|
|
147
|
-
topTools: topTools.map((r) => ({ tool: r.tool, count: r.count })),
|
|
148
|
-
callsByDay: callsByDay.map((r) => ({ date: r.date, count: r.count })),
|
|
149
|
-
periodStart:
|
|
150
|
-
periodEnd:
|
|
165
|
+
totalCalls: Number(s?.total_calls) || 0,
|
|
166
|
+
successRate: Number(s?.success_rate) || 0,
|
|
167
|
+
avgDurationMs: Math.round(Number(s?.avg_duration) || 0),
|
|
168
|
+
totalInputTokens: Number(s?.total_input) || 0,
|
|
169
|
+
totalOutputTokens: Number(s?.total_output) || 0,
|
|
170
|
+
topTools: topTools.rows.map((r) => ({ tool: r.tool, count: Number(r.count) })),
|
|
171
|
+
callsByDay: callsByDay.rows.map((r) => ({ date: r.date, count: Number(r.count) })),
|
|
172
|
+
periodStart: s?.period_start || since,
|
|
173
|
+
periodEnd: s?.period_end || new Date().toISOString(),
|
|
151
174
|
};
|
|
152
175
|
}
|
|
153
176
|
catch (err) {
|
|
@@ -161,37 +184,37 @@ export async function getProjectAnalytics(project, days = 30) {
|
|
|
161
184
|
};
|
|
162
185
|
}
|
|
163
186
|
}
|
|
164
|
-
/**
|
|
165
|
-
* Get system-wide analytics across all projects.
|
|
166
|
-
*/
|
|
167
187
|
export async function getSystemAnalytics(days = 30) {
|
|
168
188
|
await flushBuffer();
|
|
169
189
|
try {
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
await ensureTable();
|
|
191
|
+
const db = getDb();
|
|
192
|
+
const since = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
193
|
+
const stats = await db.execute({
|
|
194
|
+
sql: `SELECT COUNT(*) as total, COUNT(DISTINCT project) as projects,
|
|
195
|
+
AVG(CASE WHEN success = 1 THEN 1.0 ELSE 0.0 END) as success_rate,
|
|
196
|
+
AVG(duration_ms) as avg_duration
|
|
197
|
+
FROM api_analytics WHERE timestamp >= ?`,
|
|
198
|
+
args: [since],
|
|
199
|
+
});
|
|
200
|
+
const s = stats.rows[0];
|
|
201
|
+
const topProjects = await db.execute({
|
|
202
|
+
sql: `SELECT project, COUNT(*) as calls FROM api_analytics
|
|
203
|
+
WHERE timestamp >= ? GROUP BY project ORDER BY calls DESC LIMIT 10`,
|
|
204
|
+
args: [since],
|
|
205
|
+
});
|
|
206
|
+
const topTools = await db.execute({
|
|
207
|
+
sql: `SELECT tool, COUNT(*) as calls FROM api_analytics
|
|
208
|
+
WHERE timestamp >= ? GROUP BY tool ORDER BY calls DESC LIMIT 10`,
|
|
209
|
+
args: [since],
|
|
210
|
+
});
|
|
188
211
|
return {
|
|
189
|
-
totalProjects:
|
|
190
|
-
totalCalls:
|
|
191
|
-
globalSuccessRate:
|
|
192
|
-
avgDurationMs: Math.round(
|
|
193
|
-
topProjects: topProjects.map((r) => ({ project: r.project, calls: r.calls })),
|
|
194
|
-
topTools: topTools.map((r) => ({ tool: r.tool, calls: r.calls })),
|
|
212
|
+
totalProjects: Number(s?.projects) || 0,
|
|
213
|
+
totalCalls: Number(s?.total) || 0,
|
|
214
|
+
globalSuccessRate: Number(s?.success_rate) || 0,
|
|
215
|
+
avgDurationMs: Math.round(Number(s?.avg_duration) || 0),
|
|
216
|
+
topProjects: topProjects.rows.map((r) => ({ project: r.project, calls: Number(r.calls) })),
|
|
217
|
+
topTools: topTools.rows.map((r) => ({ tool: r.tool, calls: Number(r.calls) })),
|
|
195
218
|
callsByHour: [],
|
|
196
219
|
};
|
|
197
220
|
}
|
|
@@ -202,4 +225,4 @@ export async function getSystemAnalytics(days = 30) {
|
|
|
202
225
|
};
|
|
203
226
|
}
|
|
204
227
|
}
|
|
205
|
-
debugLog("
|
|
228
|
+
debugLog("API analytics module loaded (@libsql/client)");
|
package/dist/utils/ddLogger.js
CHANGED
|
@@ -18,6 +18,7 @@ const CONTEXT_ALLOWLIST = new Set([
|
|
|
18
18
|
"ceiling_clamped", "requested_tokens", "effective_tokens", "tokens_clamped",
|
|
19
19
|
"cloud_requested", "cloud_allowed", "cloud_blocked",
|
|
20
20
|
"verify_requested", "verify_allowed", "verify_blocked",
|
|
21
|
+
"tool", "project", "success", "durationMs",
|
|
21
22
|
]);
|
|
22
23
|
const queue = [];
|
|
23
24
|
let flushTimer = null;
|
package/dist/utils/notifier.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* Zero external dependencies — uses native fetch API.
|
|
12
12
|
*/
|
|
13
13
|
import { debugLog } from "./logger.js";
|
|
14
|
+
import { lookup } from "node:dns/promises";
|
|
15
|
+
import { Agent } from "undici";
|
|
14
16
|
// ─── Default Config ──────────────────────────────────────────
|
|
15
17
|
const DEFAULT_CONFIG = {
|
|
16
18
|
enabled: false,
|
|
@@ -99,43 +101,62 @@ function isPrivateIP(ip) {
|
|
|
99
101
|
return true;
|
|
100
102
|
return false;
|
|
101
103
|
}
|
|
102
|
-
function
|
|
104
|
+
async function validateUrl(url) {
|
|
103
105
|
try {
|
|
104
106
|
const parsed = new URL(url);
|
|
105
|
-
// Block non-HTTP(S) schemes
|
|
106
107
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
107
|
-
return false;
|
|
108
|
+
return { allowed: false };
|
|
108
109
|
const hostname = parsed.hostname.toLowerCase();
|
|
109
|
-
// Block localhost variants
|
|
110
110
|
if (hostname === "localhost" || hostname === "localhost.localdomain")
|
|
111
|
-
return false;
|
|
112
|
-
// Block .internal, .local, .arpa TLDs
|
|
111
|
+
return { allowed: false };
|
|
113
112
|
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.)
|
|
113
|
+
return { allowed: false };
|
|
116
114
|
if (isPrivateIP(hostname))
|
|
117
|
-
return false;
|
|
118
|
-
// Block bracketed IPv6
|
|
115
|
+
return { allowed: false };
|
|
119
116
|
if (hostname.startsWith("[") && isPrivateIP(hostname))
|
|
120
|
-
return false;
|
|
121
|
-
|
|
117
|
+
return { allowed: false };
|
|
118
|
+
try {
|
|
119
|
+
const addrs = await lookup(hostname, { all: true });
|
|
120
|
+
for (const { address } of addrs) {
|
|
121
|
+
if (isPrivateIP(address))
|
|
122
|
+
return { allowed: false };
|
|
123
|
+
}
|
|
124
|
+
// Return the first validated address so senders can pin DNS
|
|
125
|
+
return { allowed: true, resolvedAddr: addrs[0].address, resolvedFamily: addrs[0].family };
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { allowed: false };
|
|
129
|
+
}
|
|
122
130
|
}
|
|
123
131
|
catch {
|
|
124
|
-
return false;
|
|
132
|
+
return { allowed: false };
|
|
125
133
|
}
|
|
126
134
|
}
|
|
135
|
+
function pinnedDispatcher(address, family) {
|
|
136
|
+
return new Agent({
|
|
137
|
+
connect: {
|
|
138
|
+
lookup: (_hostname, _opts, cb) => {
|
|
139
|
+
cb(null, address, family);
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
127
144
|
// ─── Channel Senders ─────────────────────────────────────────
|
|
128
145
|
async function sendWebhook(url, payload) {
|
|
129
|
-
|
|
146
|
+
const check = await validateUrl(url);
|
|
147
|
+
if (!check.allowed) {
|
|
130
148
|
debugLog(`Webhook URL blocked by SSRF policy: ${url}`);
|
|
131
149
|
return false;
|
|
132
150
|
}
|
|
151
|
+
const dispatcher = pinnedDispatcher(check.resolvedAddr, check.resolvedFamily);
|
|
133
152
|
try {
|
|
134
153
|
const response = await fetch(url, {
|
|
135
154
|
method: "POST",
|
|
136
155
|
headers: { "Content-Type": "application/json" },
|
|
137
156
|
body: JSON.stringify(payload),
|
|
157
|
+
redirect: "error",
|
|
138
158
|
signal: AbortSignal.timeout(10_000),
|
|
159
|
+
dispatcher,
|
|
139
160
|
});
|
|
140
161
|
return response.ok;
|
|
141
162
|
}
|
|
@@ -143,9 +164,13 @@ async function sendWebhook(url, payload) {
|
|
|
143
164
|
debugLog(`Webhook notification failed: ${err}`);
|
|
144
165
|
return false;
|
|
145
166
|
}
|
|
167
|
+
finally {
|
|
168
|
+
await dispatcher.close().catch(() => { });
|
|
169
|
+
}
|
|
146
170
|
}
|
|
147
171
|
async function sendSlack(webhookUrl, payload) {
|
|
148
|
-
|
|
172
|
+
const check = await validateUrl(webhookUrl);
|
|
173
|
+
if (!check.allowed) {
|
|
149
174
|
debugLog(`Slack webhook URL blocked by SSRF policy: ${webhookUrl}`);
|
|
150
175
|
return false;
|
|
151
176
|
}
|
|
@@ -186,12 +211,15 @@ async function sendSlack(webhookUrl, payload) {
|
|
|
186
211
|
: []),
|
|
187
212
|
],
|
|
188
213
|
};
|
|
214
|
+
const dispatcher = pinnedDispatcher(check.resolvedAddr, check.resolvedFamily);
|
|
189
215
|
try {
|
|
190
216
|
const response = await fetch(webhookUrl, {
|
|
191
217
|
method: "POST",
|
|
192
218
|
headers: { "Content-Type": "application/json" },
|
|
193
219
|
body: JSON.stringify(slackPayload),
|
|
220
|
+
redirect: "error",
|
|
194
221
|
signal: AbortSignal.timeout(10_000),
|
|
222
|
+
dispatcher,
|
|
195
223
|
});
|
|
196
224
|
return response.ok;
|
|
197
225
|
}
|
|
@@ -199,13 +227,17 @@ async function sendSlack(webhookUrl, payload) {
|
|
|
199
227
|
debugLog(`Slack notification failed: ${err}`);
|
|
200
228
|
return false;
|
|
201
229
|
}
|
|
230
|
+
finally {
|
|
231
|
+
await dispatcher.close().catch(() => { });
|
|
232
|
+
}
|
|
202
233
|
}
|
|
203
234
|
async function sendEmail(endpoint, payload) {
|
|
204
|
-
|
|
235
|
+
const check = await validateUrl(endpoint);
|
|
236
|
+
if (!check.allowed) {
|
|
205
237
|
debugLog(`Email endpoint URL blocked by SSRF policy: ${endpoint}`);
|
|
206
238
|
return false;
|
|
207
239
|
}
|
|
208
|
-
|
|
240
|
+
const dispatcher = pinnedDispatcher(check.resolvedAddr, check.resolvedFamily);
|
|
209
241
|
try {
|
|
210
242
|
const response = await fetch(endpoint, {
|
|
211
243
|
method: "POST",
|
|
@@ -216,7 +248,9 @@ async function sendEmail(endpoint, payload) {
|
|
|
216
248
|
project: payload.project,
|
|
217
249
|
details: payload.details,
|
|
218
250
|
}),
|
|
251
|
+
redirect: "error",
|
|
219
252
|
signal: AbortSignal.timeout(10_000),
|
|
253
|
+
dispatcher,
|
|
220
254
|
});
|
|
221
255
|
return response.ok;
|
|
222
256
|
}
|
|
@@ -224,6 +258,9 @@ async function sendEmail(endpoint, payload) {
|
|
|
224
258
|
debugLog(`Email notification failed: ${err}`);
|
|
225
259
|
return false;
|
|
226
260
|
}
|
|
261
|
+
finally {
|
|
262
|
+
await dispatcher.close().catch(() => { });
|
|
263
|
+
}
|
|
227
264
|
}
|
|
228
265
|
// ─── Public API ──────────────────────────────────────────────
|
|
229
266
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "19.2.
|
|
3
|
+
"version": "19.2.6",
|
|
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, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local-first storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
|
|
6
6
|
"module": "index.ts",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@types/mozilla-readability": "^0.2.1",
|
|
106
106
|
"@types/turndown": "^5.0.6",
|
|
107
107
|
"dotenv": "^17.4.2",
|
|
108
|
-
"tsx": "^4.
|
|
108
|
+
"tsx": "^4.22.4",
|
|
109
109
|
"vitest": "^4.1.1"
|
|
110
110
|
},
|
|
111
111
|
"peerDependencies": {
|
|
@@ -129,9 +129,10 @@
|
|
|
129
129
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
130
130
|
"@mozilla/readability": "^0.6.0",
|
|
131
131
|
"@opentelemetry/api": "^1.9.1",
|
|
132
|
-
"@opentelemetry/
|
|
133
|
-
"@opentelemetry/
|
|
134
|
-
"@opentelemetry/
|
|
132
|
+
"@opentelemetry/core": "^2.8.0",
|
|
133
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
|
134
|
+
"@opentelemetry/resources": "^2.8.0",
|
|
135
|
+
"@opentelemetry/sdk-trace-node": "^2.8.0",
|
|
135
136
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
|
136
137
|
"@supabase/supabase-js": "^2.99.3",
|
|
137
138
|
"cheerio": "^1.2.0",
|