prism-mcp-server 20.2.0 → 20.2.2
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 +137 -15
- package/dist/cli.js +222 -34
- package/dist/config.js +14 -7
- package/dist/connect.js +854 -16
- package/dist/dashboard/server.js +9 -14
- package/dist/dashboard/settingsPolicy.js +41 -0
- package/dist/localFirstPolicy.js +20 -0
- package/dist/onboarding/wizard.js +3 -6
- package/dist/server.js +77 -65
- package/dist/session/sessionContext.js +5 -3
- package/dist/skillManifestSync.js +943 -0
- package/dist/storage/configStorage.js +110 -1
- package/dist/storage/index.js +15 -4
- package/dist/storage/inferMetricsLedger.js +89 -16
- package/dist/storage/panelMetricsSpool.js +324 -0
- package/dist/storage/sqlite.js +1 -1
- package/dist/storage/synalux.js +18 -1
- package/dist/tools/__tests__/ledgerHandlers.test.js +13 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/ledgerHandlers.js +468 -53
- package/dist/tools/prismInferHandler.js +171 -12
- package/dist/tools/sessionMemoryDefinitions.js +51 -10
- package/dist/tools/skillRouting.js +39 -7
- package/dist/tools/taskRouterHandler.js +184 -29
- package/dist/utils/entitlements.js +6 -2
- package/dist/utils/inferenceMetrics.js +22 -3
- package/dist/utils/modelPicker.js +3 -3
- package/dist/utils/synaluxJwt.js +8 -3
- package/package.json +6 -2
|
@@ -15,7 +15,9 @@ const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
|
15
15
|
// PRISM_STORAGE, PRISM_ENABLE_HIVEMIND) are read only at startup.
|
|
16
16
|
// Changing them at runtime requires a server restart to take effect.
|
|
17
17
|
// Runtime-only settings (e.g. dashboard_theme) take effect immediately.
|
|
18
|
-
const CONFIG_PATH =
|
|
18
|
+
const CONFIG_PATH = process.env.PRISM_CONFIG_PATH
|
|
19
|
+
? resolve(process.env.PRISM_CONFIG_PATH)
|
|
20
|
+
: resolve(homedir(), ".prism-mcp", "prism-config.db");
|
|
19
21
|
let configClient = null;
|
|
20
22
|
let initialized = false;
|
|
21
23
|
// ─── In-memory settings cache ──────────────────────────────────────
|
|
@@ -50,6 +52,15 @@ export async function initConfigStorage() {
|
|
|
50
52
|
value TEXT NOT NULL,
|
|
51
53
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
52
54
|
)
|
|
55
|
+
`);
|
|
56
|
+
await client.execute(`
|
|
57
|
+
CREATE TABLE IF NOT EXISTS prism_managed_skills (
|
|
58
|
+
name TEXT PRIMARY KEY,
|
|
59
|
+
digest TEXT NOT NULL,
|
|
60
|
+
generation TEXT NOT NULL,
|
|
61
|
+
owner TEXT NOT NULL DEFAULT 'prism',
|
|
62
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
63
|
+
)
|
|
53
64
|
`);
|
|
54
65
|
// Preload all rows into the cache so subsequent reads are zero-cost.
|
|
55
66
|
const rs = await client.execute("SELECT key, value FROM system_settings");
|
|
@@ -71,6 +82,104 @@ export async function initConfigStorage() {
|
|
|
71
82
|
}
|
|
72
83
|
initialized = true;
|
|
73
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Atomically applies a complete platform-skill snapshot.
|
|
87
|
+
*
|
|
88
|
+
* Ownership is recorded separately from the existing `skill:*` namespace so
|
|
89
|
+
* a tier downgrade can remove only rows previously written by Prism. Role and
|
|
90
|
+
* user-local skill rows are never inferred to be managed and are never pruned.
|
|
91
|
+
*/
|
|
92
|
+
export async function applyManagedSkillManifest(state) {
|
|
93
|
+
await initConfigStorage();
|
|
94
|
+
if (state.skills.length === 0)
|
|
95
|
+
throw new Error("Refusing to apply an empty skill manifest");
|
|
96
|
+
const client = getClient();
|
|
97
|
+
const names = state.skills.map((skill) => skill.name);
|
|
98
|
+
const placeholders = names.map(() => "?").join(", ");
|
|
99
|
+
const statements = [];
|
|
100
|
+
for (const skill of state.skills) {
|
|
101
|
+
statements.push({
|
|
102
|
+
sql: `
|
|
103
|
+
INSERT INTO system_settings (key, value, updated_at)
|
|
104
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
105
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
|
106
|
+
`,
|
|
107
|
+
args: [`skill:${skill.name}`, skill.content],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Delete content only when the ownership table proves Prism wrote it and
|
|
111
|
+
// the complete replacement snapshot no longer contains it.
|
|
112
|
+
statements.push({
|
|
113
|
+
sql: `
|
|
114
|
+
DELETE FROM system_settings
|
|
115
|
+
WHERE key IN (
|
|
116
|
+
SELECT 'skill:' || name FROM prism_managed_skills
|
|
117
|
+
WHERE name NOT IN (${placeholders})
|
|
118
|
+
)
|
|
119
|
+
`,
|
|
120
|
+
args: names,
|
|
121
|
+
});
|
|
122
|
+
statements.push({
|
|
123
|
+
sql: `DELETE FROM prism_managed_skills WHERE name NOT IN (${placeholders})`,
|
|
124
|
+
args: names,
|
|
125
|
+
});
|
|
126
|
+
for (const skill of state.skills) {
|
|
127
|
+
statements.push({
|
|
128
|
+
sql: `
|
|
129
|
+
INSERT INTO prism_managed_skills (name, digest, generation, owner, updated_at)
|
|
130
|
+
VALUES (?, ?, ?, 'prism', CURRENT_TIMESTAMP)
|
|
131
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
132
|
+
digest = excluded.digest,
|
|
133
|
+
generation = excluded.generation,
|
|
134
|
+
owner = 'prism',
|
|
135
|
+
updated_at = CURRENT_TIMESTAMP
|
|
136
|
+
`,
|
|
137
|
+
args: [skill.name, skill.digest, state.generation],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
for (const [key, value] of [
|
|
141
|
+
["skill_manifest:generation", state.generation],
|
|
142
|
+
["skill_manifest:tier", state.tier],
|
|
143
|
+
["skill_manifest:routing_version", String(state.routingVersion)],
|
|
144
|
+
["skill_manifest:owner", "prism"],
|
|
145
|
+
["skill_manifest:names", JSON.stringify(names)],
|
|
146
|
+
]) {
|
|
147
|
+
statements.push({
|
|
148
|
+
sql: `
|
|
149
|
+
INSERT INTO system_settings (key, value, updated_at)
|
|
150
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
151
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
|
152
|
+
`,
|
|
153
|
+
args: [key, value],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// libSQL batch("write") is one transaction: a failed statement rolls back
|
|
157
|
+
// content, ownership, digests, and generation together.
|
|
158
|
+
await client.batch(statements, "write");
|
|
159
|
+
// Refresh only after commit so synchronous readers never observe a future
|
|
160
|
+
// generation paired with stale skill content. A cache read failure must not
|
|
161
|
+
// make callers mistake an already-committed downgrade for a rolled-back DB
|
|
162
|
+
// write and skip native entitlement cleanup. Session loading refreshes again
|
|
163
|
+
// before activation and therefore fails closed until the DB is readable.
|
|
164
|
+
try {
|
|
165
|
+
await refreshConfigStorageCache();
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
console.error(`[configStorage] Managed skill manifest committed but cache refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Reload settings written by another Prism process into this process's cache. */
|
|
172
|
+
export async function refreshConfigStorageCache() {
|
|
173
|
+
await initConfigStorage();
|
|
174
|
+
const rs = await getClient().execute("SELECT key, value FROM system_settings");
|
|
175
|
+
const refreshed = Object.create(null);
|
|
176
|
+
for (const row of rs.rows) {
|
|
177
|
+
const key = row.key;
|
|
178
|
+
if (!PROTO_KEYS.has(key))
|
|
179
|
+
refreshed[key] = row.value;
|
|
180
|
+
}
|
|
181
|
+
settingsCache = refreshed;
|
|
182
|
+
}
|
|
74
183
|
/**
|
|
75
184
|
* Synchronous setting read — served from the in-memory cache.
|
|
76
185
|
* Returns defaultValue if the cache hasn't been populated yet (e.g. very
|
package/dist/storage/index.js
CHANGED
|
@@ -15,17 +15,18 @@ export function isValidHttpUrl(url) {
|
|
|
15
15
|
* Probe for synalux credentials: env vars first, then config DB.
|
|
16
16
|
* Returns true if usable credentials are now in process.env.
|
|
17
17
|
*/
|
|
18
|
-
async function ensureSynaluxCredentials() {
|
|
18
|
+
export async function ensureSynaluxCredentials() {
|
|
19
19
|
if (SYNALUX_CONFIGURED)
|
|
20
20
|
return true;
|
|
21
21
|
// Re-check process.env directly: SYNALUX_CONFIGURED is captured at module
|
|
22
22
|
// load, so credentials injected later by another caller would be invisible
|
|
23
23
|
// to it. Mirrors ensureSupabaseCredentials below.
|
|
24
|
-
const envUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim();
|
|
24
|
+
const envUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() || process.env.SYNALUX_BASE_URL?.trim();
|
|
25
25
|
const envKey = process.env.PRISM_SYNALUX_API_KEY?.trim();
|
|
26
26
|
if (envUrl && envKey && isValidHttpUrl(envUrl))
|
|
27
27
|
return true;
|
|
28
|
-
const url = (await getSetting("PRISM_SYNALUX_BASE_URL"))?.trim()
|
|
28
|
+
const url = (await getSetting("PRISM_SYNALUX_BASE_URL"))?.trim() ||
|
|
29
|
+
(await getSetting("SYNALUX_BASE_URL"))?.trim();
|
|
29
30
|
const key = (await getSetting("PRISM_SYNALUX_API_KEY"))?.trim();
|
|
30
31
|
if (url && key && isValidHttpUrl(url)) {
|
|
31
32
|
process.env.PRISM_SYNALUX_BASE_URL = url;
|
|
@@ -68,9 +69,19 @@ export async function getStorage() {
|
|
|
68
69
|
debugLog("[Prism Storage] PRISM_FORCE_LOCAL=true — forcing local SQLite");
|
|
69
70
|
}
|
|
70
71
|
// ─── Resolve "auto" → synalux > supabase > local ─────────────
|
|
72
|
+
// Synalux is eligible only when the portal is the entitlement source;
|
|
73
|
+
// direct Supabase remains an independent legacy backend.
|
|
71
74
|
if (requested === "auto") {
|
|
72
75
|
if (await ensureSynaluxCredentials()) {
|
|
73
|
-
|
|
76
|
+
const { getEntitlements } = await import("../utils/entitlements.js");
|
|
77
|
+
const entitlements = await getEntitlements();
|
|
78
|
+
const memoryEntitled = entitlements.features?.session_memory_unlimited;
|
|
79
|
+
if (entitlements.source !== "portal" || typeof memoryEntitled !== "boolean") {
|
|
80
|
+
throw new Error("[Prism Storage] Could not verify the Synalux cloud-memory entitlement. " +
|
|
81
|
+
"Refusing to fall back to another backend because that could split session history. " +
|
|
82
|
+
"Retry when Synalux is reachable or set PRISM_STORAGE=local explicitly.");
|
|
83
|
+
}
|
|
84
|
+
requested = memoryEntitled ? "synalux" : "local";
|
|
74
85
|
}
|
|
75
86
|
else if (await ensureSupabaseCredentials()) {
|
|
76
87
|
requested = "supabase";
|
|
@@ -38,6 +38,24 @@ let ensured = null;
|
|
|
38
38
|
let disabled = false;
|
|
39
39
|
let initFailures = 0;
|
|
40
40
|
const MAX_INIT_FAILURES = 3;
|
|
41
|
+
const LEDGER_UNAVAILABLE_ERROR = "Inference metrics ledger is unavailable";
|
|
42
|
+
const INSERT_METRIC_SQL = `INSERT OR IGNORE INTO infer_metrics
|
|
43
|
+
(ts, caller, mode, backend, model, used_cloud, gate_outcome,
|
|
44
|
+
refusal_reason, prompt_tokens, completion_tokens, latency_ms, ram_free_mb,
|
|
45
|
+
source_event_id)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
47
|
+
function closeClient(context) {
|
|
48
|
+
const activeClient = client;
|
|
49
|
+
client = null;
|
|
50
|
+
if (!activeClient)
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
activeClient.close();
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
debugLog(`[infer-ledger] ${context} close failed: ${e instanceof Error ? e.message : e}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
41
59
|
function ensureTable() {
|
|
42
60
|
if (!ensured) {
|
|
43
61
|
ensured = (async () => {
|
|
@@ -64,15 +82,28 @@ function ensureTable() {
|
|
|
64
82
|
prompt_tokens INTEGER,
|
|
65
83
|
completion_tokens INTEGER,
|
|
66
84
|
latency_ms INTEGER,
|
|
67
|
-
ram_free_mb INTEGER
|
|
85
|
+
ram_free_mb INTEGER,
|
|
86
|
+
source_event_id TEXT
|
|
68
87
|
)`);
|
|
88
|
+
// Existing ledgers predate external panel-spool ingestion. SQLite has
|
|
89
|
+
// no ADD COLUMN IF NOT EXISTS, so use the repository's established
|
|
90
|
+
// idempotent migration pattern and reject only unexpected failures.
|
|
91
|
+
try {
|
|
92
|
+
await client.execute(`ALTER TABLE infer_metrics ADD COLUMN source_event_id TEXT`);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (!(e instanceof Error) || !e.message.includes("duplicate column name"))
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
69
98
|
await client.execute(`CREATE INDEX IF NOT EXISTS idx_infer_metrics_ts ON infer_metrics (ts)`);
|
|
99
|
+
await client.execute(`CREATE UNIQUE INDEX IF NOT EXISTS idx_infer_metrics_source_event
|
|
100
|
+
ON infer_metrics (source_event_id) WHERE source_event_id IS NOT NULL`);
|
|
70
101
|
})().catch((e) => {
|
|
71
102
|
// Transient failures (missing dir on first run, SQLITE_BUSY) retry on
|
|
72
103
|
// the next append; only repeated failure disables for the process.
|
|
73
104
|
initFailures++;
|
|
74
105
|
ensured = null;
|
|
75
|
-
|
|
106
|
+
closeClient("init failure");
|
|
76
107
|
if (initFailures >= MAX_INIT_FAILURES)
|
|
77
108
|
disabled = true;
|
|
78
109
|
debugLog(`[infer-ledger] init failed (${initFailures}/${MAX_INIT_FAILURES}${disabled ? ", ledger disabled" : ", will retry"}): ${e instanceof Error ? e.message : e}`);
|
|
@@ -88,23 +119,38 @@ export function appendInferMetric(row) {
|
|
|
88
119
|
await ensureTable();
|
|
89
120
|
if (disabled || !client)
|
|
90
121
|
return;
|
|
91
|
-
await client.execute({
|
|
92
|
-
sql: `INSERT INTO infer_metrics
|
|
93
|
-
(ts, caller, mode, backend, model, used_cloud, gate_outcome,
|
|
94
|
-
refusal_reason, prompt_tokens, completion_tokens, latency_ms, ram_free_mb)
|
|
95
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
96
|
-
args: [
|
|
97
|
-
Date.now(), row.caller ?? "mcp", row.mode ?? null, row.backend,
|
|
98
|
-
row.model, row.used_cloud ? 1 : 0, row.gate_outcome ?? null,
|
|
99
|
-
row.refusal_reason ?? null, row.prompt_tokens ?? null,
|
|
100
|
-
row.completion_tokens ?? null, row.latency_ms ?? null,
|
|
101
|
-
row.ram_free_mb ?? null,
|
|
102
|
-
],
|
|
103
|
-
});
|
|
122
|
+
await client.execute({ sql: INSERT_METRIC_SQL, args: metricArgs(row) });
|
|
104
123
|
})().catch((e) => {
|
|
105
124
|
debugLog(`[infer-ledger] append failed: ${e instanceof Error ? e.message : e}`);
|
|
106
125
|
});
|
|
107
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Transactionally append externally-spooled rows and report deduplication.
|
|
129
|
+
* Unlike appendInferMetric(), this is awaited so the spool owner knows when it
|
|
130
|
+
* is safe to delete a claimed file.
|
|
131
|
+
*/
|
|
132
|
+
export async function appendInferMetricBatch(rows) {
|
|
133
|
+
if (rows.length === 0)
|
|
134
|
+
return { inserted: 0, duplicates: 0 };
|
|
135
|
+
await ensureTable();
|
|
136
|
+
if (disabled || !client)
|
|
137
|
+
throw new Error(LEDGER_UNAVAILABLE_ERROR);
|
|
138
|
+
const results = await client.batch(rows.map(row => ({ sql: INSERT_METRIC_SQL, args: metricArgs(row) })), "write");
|
|
139
|
+
const inserted = results.reduce((total, result) => total + result.rowsAffected, 0);
|
|
140
|
+
return { inserted, duplicates: rows.length - inserted };
|
|
141
|
+
}
|
|
142
|
+
function metricArgs(row) {
|
|
143
|
+
const timestamp = Number.isFinite(row.ts) && (row.ts ?? 0) > 0
|
|
144
|
+
? Math.floor(row.ts)
|
|
145
|
+
: Date.now();
|
|
146
|
+
return [
|
|
147
|
+
timestamp, row.caller ?? "mcp", row.mode ?? null, row.backend,
|
|
148
|
+
row.model, row.used_cloud ? 1 : 0, row.gate_outcome ?? null,
|
|
149
|
+
row.refusal_reason ?? null, row.prompt_tokens ?? null,
|
|
150
|
+
row.completion_tokens ?? null, row.latency_ms ?? null,
|
|
151
|
+
row.ram_free_mb ?? null, row.source_event_id ?? null,
|
|
152
|
+
];
|
|
153
|
+
}
|
|
108
154
|
/** Aggregate all persisted rows (optionally since a timestamp). */
|
|
109
155
|
export async function queryInferMetrics(sinceTs) {
|
|
110
156
|
try {
|
|
@@ -128,11 +174,34 @@ export async function queryInferMetrics(sinceTs) {
|
|
|
128
174
|
sql: `SELECT backend, COUNT(*) AS n FROM infer_metrics ${where} GROUP BY backend`,
|
|
129
175
|
args: whereArgs
|
|
130
176
|
});
|
|
177
|
+
const byC = await client.execute({
|
|
178
|
+
sql: `SELECT COALESCE(caller, 'mcp') AS caller,
|
|
179
|
+
COUNT(*) AS total,
|
|
180
|
+
SUM(CASE WHEN used_cloud = 0 THEN 1 ELSE 0 END) AS local,
|
|
181
|
+
SUM(CASE WHEN used_cloud = 1 THEN 1 ELSE 0 END) AS cloud,
|
|
182
|
+
COALESCE(SUM(prompt_tokens), 0) AS pt,
|
|
183
|
+
COALESCE(SUM(completion_tokens), 0) AS ct,
|
|
184
|
+
COALESCE(AVG(latency_ms), 0) AS avg_lat
|
|
185
|
+
FROM infer_metrics ${where}
|
|
186
|
+
GROUP BY COALESCE(caller, 'mcp')`,
|
|
187
|
+
args: whereArgs
|
|
188
|
+
});
|
|
131
189
|
const r = agg.rows[0];
|
|
132
190
|
const by_backend = {};
|
|
133
191
|
for (const row of byB.rows) {
|
|
134
192
|
by_backend[String(row.backend)] = Number(row.n);
|
|
135
193
|
}
|
|
194
|
+
const by_caller = {};
|
|
195
|
+
for (const row of byC.rows) {
|
|
196
|
+
by_caller[String(row.caller)] = {
|
|
197
|
+
total: Number(row.total ?? 0),
|
|
198
|
+
local: Number(row.local ?? 0),
|
|
199
|
+
cloud: Number(row.cloud ?? 0),
|
|
200
|
+
prompt_tokens: Number(row.pt ?? 0),
|
|
201
|
+
completion_tokens: Number(row.ct ?? 0),
|
|
202
|
+
avg_latency_ms: Math.round(Number(row.avg_lat ?? 0)),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
136
205
|
return {
|
|
137
206
|
total: Number(r.total ?? 0),
|
|
138
207
|
local: Number(r.local ?? 0),
|
|
@@ -143,6 +212,7 @@ export async function queryInferMetrics(sinceTs) {
|
|
|
143
212
|
first_ts: r.first_ts == null ? null : Number(r.first_ts),
|
|
144
213
|
last_ts: r.last_ts == null ? null : Number(r.last_ts),
|
|
145
214
|
by_backend,
|
|
215
|
+
by_caller,
|
|
146
216
|
};
|
|
147
217
|
}
|
|
148
218
|
catch (e) {
|
|
@@ -152,7 +222,10 @@ export async function queryInferMetrics(sinceTs) {
|
|
|
152
222
|
}
|
|
153
223
|
/** Test hook — reset module state so a fresh DB path/env can be exercised. */
|
|
154
224
|
export function _resetInferLedgerForTest() {
|
|
155
|
-
client
|
|
225
|
+
// Close the logical client instead of only dropping its reference.
|
|
226
|
+
// libsql 0.5.29 can still retain native prepared-statement handles until
|
|
227
|
+
// V8 GC (libsql-js#228), so this is not a synchronous file-unlock barrier.
|
|
228
|
+
closeClient("test reset");
|
|
156
229
|
ensured = null;
|
|
157
230
|
disabled = false;
|
|
158
231
|
initFailures = 0;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-safe ingestion for Synalux VS Code panel inference metrics.
|
|
3
|
+
*
|
|
4
|
+
* The extension and MCP server are separate processes, so the extension writes
|
|
5
|
+
* bounded JSONL records instead of opening Prism's SQLite database. The MCP
|
|
6
|
+
* process atomically claims the live spool, validates every field, and deletes
|
|
7
|
+
* a claim only after the ledger transaction succeeds. Stable event IDs make a
|
|
8
|
+
* replay after a crash idempotent.
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { createReadStream, promises as fs } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
14
|
+
import { createInterface } from "node:readline";
|
|
15
|
+
import { appendInferMetricBatch } from "./inferMetricsLedger.js";
|
|
16
|
+
import { debugLog } from "../utils/logger.js";
|
|
17
|
+
const PANEL_METRICS_FILE = "panel-metrics.jsonl";
|
|
18
|
+
const PANEL_METRICS_DIRECTORY = ".prism-mcp";
|
|
19
|
+
const PANEL_METRICS_PATH_ENV = "PRISM_PANEL_METRICS_PATH";
|
|
20
|
+
const LOCK_SUFFIX = ".lock";
|
|
21
|
+
const LOCK_STALE_MS = 30_000;
|
|
22
|
+
const PROCESSING_STALE_MS = 5 * 60_000;
|
|
23
|
+
const LOCK_RETRY_DELAYS_MS = [5, 10, 20, 40, 80, 160];
|
|
24
|
+
const FILE_MODE = 0o600;
|
|
25
|
+
const DIRECTORY_MODE = 0o700;
|
|
26
|
+
const EVENT_VERSION = 1;
|
|
27
|
+
const MAX_CLAIMS_PER_INGEST = 32;
|
|
28
|
+
const MAX_LINE_BYTES = 4 * 1024;
|
|
29
|
+
const BATCH_SIZE = 250;
|
|
30
|
+
const MAX_MODEL_LENGTH = 256;
|
|
31
|
+
const MAX_METRIC_VALUE = 100_000_000;
|
|
32
|
+
const MAX_LATENCY_MS = 24 * 60 * 60 * 1_000;
|
|
33
|
+
const MAX_FUTURE_SKEW_MS = 5 * 60_000;
|
|
34
|
+
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
35
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
36
|
+
const PANEL_EVENTS = new Set(["panel_served_local", "panel_escalated"]);
|
|
37
|
+
const PANEL_BACKENDS = new Set(["prism", "local", "cloud", "gemini"]);
|
|
38
|
+
const PANEL_MODES = new Set(["chat", "code"]);
|
|
39
|
+
const LOCAL_BACKENDS = new Set(["prism", "local"]);
|
|
40
|
+
const CLOUD_BACKENDS = new Set(["cloud", "gemini"]);
|
|
41
|
+
const EVENT_KEYS = new Set([
|
|
42
|
+
"v", "event_id", "ts", "event", "backend", "model", "used_cloud",
|
|
43
|
+
"mode", "prompt_tokens", "completion_tokens", "latency_ms",
|
|
44
|
+
]);
|
|
45
|
+
const EMPTY_RESULT = {
|
|
46
|
+
claimed_files: 0,
|
|
47
|
+
seen: 0,
|
|
48
|
+
valid: 0,
|
|
49
|
+
inserted: 0,
|
|
50
|
+
duplicates: 0,
|
|
51
|
+
invalid: 0,
|
|
52
|
+
failed_files: 0,
|
|
53
|
+
};
|
|
54
|
+
export function getPanelMetricsSpoolPath() {
|
|
55
|
+
const override = process.env[PANEL_METRICS_PATH_ENV]?.trim();
|
|
56
|
+
if (override)
|
|
57
|
+
return override;
|
|
58
|
+
if (process.env.PRISM_DATA_DIR)
|
|
59
|
+
return resolve(process.env.PRISM_DATA_DIR, PANEL_METRICS_FILE);
|
|
60
|
+
return resolve(homedir(), PANEL_METRICS_DIRECTORY, PANEL_METRICS_FILE);
|
|
61
|
+
}
|
|
62
|
+
/** Claim and ingest all currently available panel metric files. Never throws. */
|
|
63
|
+
export async function ingestPanelMetrics() {
|
|
64
|
+
const result = { ...EMPTY_RESULT };
|
|
65
|
+
let claims;
|
|
66
|
+
try {
|
|
67
|
+
claims = await claimAvailableFiles(getPanelMetricsSpoolPath());
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
result.failed_files++;
|
|
71
|
+
debugLog(`[panel-metrics] claim failed: ${error instanceof Error ? error.message : error}`);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
result.claimed_files = claims.length;
|
|
75
|
+
for (const claim of claims) {
|
|
76
|
+
try {
|
|
77
|
+
const fileResult = await ingestClaim(claim);
|
|
78
|
+
result.seen += fileResult.seen;
|
|
79
|
+
result.valid += fileResult.valid;
|
|
80
|
+
result.inserted += fileResult.inserted;
|
|
81
|
+
result.duplicates += fileResult.duplicates;
|
|
82
|
+
result.invalid += fileResult.invalid;
|
|
83
|
+
await fs.unlink(claim);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
result.failed_files++;
|
|
87
|
+
debugLog(`[panel-metrics] ingest failed: ${error instanceof Error ? error.message : error}`);
|
|
88
|
+
await returnClaimForRetry(claim).catch(() => undefined);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
async function ingestClaim(path) {
|
|
94
|
+
const result = { ...EMPTY_RESULT, claimed_files: 1 };
|
|
95
|
+
const input = createReadStream(path, { encoding: "utf8" });
|
|
96
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
97
|
+
let batch = [];
|
|
98
|
+
const flush = async () => {
|
|
99
|
+
if (batch.length === 0)
|
|
100
|
+
return;
|
|
101
|
+
const batchResult = await appendInferMetricBatch(batch);
|
|
102
|
+
result.inserted += batchResult.inserted;
|
|
103
|
+
result.duplicates += batchResult.duplicates;
|
|
104
|
+
batch = [];
|
|
105
|
+
};
|
|
106
|
+
for await (const line of lines) {
|
|
107
|
+
if (!line.trim())
|
|
108
|
+
continue;
|
|
109
|
+
result.seen++;
|
|
110
|
+
if (Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) {
|
|
111
|
+
result.invalid++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const event = parsePanelMetric(line);
|
|
115
|
+
if (!event) {
|
|
116
|
+
result.invalid++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
result.valid++;
|
|
120
|
+
batch.push(toLedgerRow(event));
|
|
121
|
+
if (batch.length >= BATCH_SIZE)
|
|
122
|
+
await flush();
|
|
123
|
+
}
|
|
124
|
+
await flush();
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
function parsePanelMetric(line) {
|
|
128
|
+
let value;
|
|
129
|
+
try {
|
|
130
|
+
value = JSON.parse(line);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
if (!isRecord(value) || Object.keys(value).some(key => !EVENT_KEYS.has(key)))
|
|
136
|
+
return null;
|
|
137
|
+
if (value.v !== EVENT_VERSION)
|
|
138
|
+
return null;
|
|
139
|
+
if (typeof value.event_id !== "string" || !UUID_PATTERN.test(value.event_id))
|
|
140
|
+
return null;
|
|
141
|
+
if (!isIntegerInRange(value.ts, 1, Date.now() + MAX_FUTURE_SKEW_MS))
|
|
142
|
+
return null;
|
|
143
|
+
if (typeof value.event !== "string" || !PANEL_EVENTS.has(value.event))
|
|
144
|
+
return null;
|
|
145
|
+
if (typeof value.backend !== "string" || !PANEL_BACKENDS.has(value.backend))
|
|
146
|
+
return null;
|
|
147
|
+
if (typeof value.used_cloud !== "boolean")
|
|
148
|
+
return null;
|
|
149
|
+
if (typeof value.mode !== "string" || !PANEL_MODES.has(value.mode))
|
|
150
|
+
return null;
|
|
151
|
+
if (!isNullableMetric(value.prompt_tokens) || !isNullableMetric(value.completion_tokens))
|
|
152
|
+
return null;
|
|
153
|
+
if (!isIntegerInRange(value.latency_ms, 0, MAX_LATENCY_MS))
|
|
154
|
+
return null;
|
|
155
|
+
if (value.model !== null && (typeof value.model !== "string" ||
|
|
156
|
+
value.model.length === 0 ||
|
|
157
|
+
value.model.length > MAX_MODEL_LENGTH ||
|
|
158
|
+
CONTROL_CHARACTER_PATTERN.test(value.model)))
|
|
159
|
+
return null;
|
|
160
|
+
const isEscalated = value.event === "panel_escalated";
|
|
161
|
+
if (value.used_cloud !== isEscalated)
|
|
162
|
+
return null;
|
|
163
|
+
if (isEscalated ? !CLOUD_BACKENDS.has(value.backend) : !LOCAL_BACKENDS.has(value.backend))
|
|
164
|
+
return null;
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
function toLedgerRow(event) {
|
|
168
|
+
return {
|
|
169
|
+
ts: event.ts,
|
|
170
|
+
caller: "panel",
|
|
171
|
+
mode: event.mode,
|
|
172
|
+
backend: event.backend,
|
|
173
|
+
model: event.model,
|
|
174
|
+
used_cloud: event.used_cloud,
|
|
175
|
+
gate_outcome: "success",
|
|
176
|
+
prompt_tokens: event.prompt_tokens ?? undefined,
|
|
177
|
+
completion_tokens: event.completion_tokens ?? undefined,
|
|
178
|
+
latency_ms: event.latency_ms,
|
|
179
|
+
source_event_id: event.event_id,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async function claimAvailableFiles(spoolPath) {
|
|
183
|
+
const directory = dirname(spoolPath);
|
|
184
|
+
await fs.mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
185
|
+
const lock = await acquireLock(`${spoolPath}${LOCK_SUFFIX}`);
|
|
186
|
+
if (!lock)
|
|
187
|
+
throw new Error("panel metrics spool is busy");
|
|
188
|
+
try {
|
|
189
|
+
const claims = [];
|
|
190
|
+
const base = basename(spoolPath);
|
|
191
|
+
const pendingPrefix = `${base}.pending-`;
|
|
192
|
+
const processingPrefix = `${base}.processing-`;
|
|
193
|
+
const names = await fs.readdir(directory).catch(error => {
|
|
194
|
+
if (hasErrorCode(error, "ENOENT"))
|
|
195
|
+
return [];
|
|
196
|
+
throw error;
|
|
197
|
+
});
|
|
198
|
+
for (const name of names) {
|
|
199
|
+
if (claims.length >= MAX_CLAIMS_PER_INGEST || !name.startsWith(pendingPrefix))
|
|
200
|
+
continue;
|
|
201
|
+
const claimed = await claimExisting(join(directory, name), spoolPath);
|
|
202
|
+
if (claimed)
|
|
203
|
+
claims.push(claimed);
|
|
204
|
+
}
|
|
205
|
+
for (const name of names) {
|
|
206
|
+
if (claims.length >= MAX_CLAIMS_PER_INGEST || !name.startsWith(processingPrefix))
|
|
207
|
+
continue;
|
|
208
|
+
const source = join(directory, name);
|
|
209
|
+
const stat = await fs.stat(source).catch(() => null);
|
|
210
|
+
if (!stat || Date.now() - stat.mtimeMs <= PROCESSING_STALE_MS)
|
|
211
|
+
continue;
|
|
212
|
+
const claimed = await claimExisting(source, spoolPath);
|
|
213
|
+
if (claimed)
|
|
214
|
+
claims.push(claimed);
|
|
215
|
+
}
|
|
216
|
+
if (claims.length < MAX_CLAIMS_PER_INGEST) {
|
|
217
|
+
const liveStat = await fs.stat(spoolPath).catch(error => {
|
|
218
|
+
if (hasErrorCode(error, "ENOENT"))
|
|
219
|
+
return null;
|
|
220
|
+
throw error;
|
|
221
|
+
});
|
|
222
|
+
if (liveStat?.isFile() && liveStat.size > 0) {
|
|
223
|
+
const claim = processingPath(spoolPath);
|
|
224
|
+
await fs.rename(spoolPath, claim);
|
|
225
|
+
claims.push(claim);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return claims;
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await releaseLock(lock);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function claimExisting(source, spoolPath) {
|
|
235
|
+
const target = processingPath(spoolPath);
|
|
236
|
+
try {
|
|
237
|
+
await fs.rename(source, target);
|
|
238
|
+
return target;
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (hasErrorCode(error, "ENOENT"))
|
|
242
|
+
return null;
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function returnClaimForRetry(claim) {
|
|
247
|
+
const spoolPath = getPanelMetricsSpoolPath();
|
|
248
|
+
const lock = await acquireLock(`${spoolPath}${LOCK_SUFFIX}`);
|
|
249
|
+
if (!lock)
|
|
250
|
+
return;
|
|
251
|
+
try {
|
|
252
|
+
const pending = `${spoolPath}.pending-${Date.now()}-${randomUUID()}`;
|
|
253
|
+
await fs.rename(claim, pending).catch(error => {
|
|
254
|
+
if (!hasErrorCode(error, "ENOENT"))
|
|
255
|
+
throw error;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await releaseLock(lock);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function processingPath(spoolPath) {
|
|
263
|
+
return `${spoolPath}.processing-${process.pid}-${Date.now()}-${randomUUID()}`;
|
|
264
|
+
}
|
|
265
|
+
async function acquireLock(lockPath) {
|
|
266
|
+
for (let attempt = 0; attempt <= LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
267
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
268
|
+
try {
|
|
269
|
+
const handle = await fs.open(lockPath, "wx", FILE_MODE);
|
|
270
|
+
await handle.writeFile(token, "utf8");
|
|
271
|
+
return { path: lockPath, token, handle };
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (!hasErrorCode(error, "EEXIST"))
|
|
275
|
+
return null;
|
|
276
|
+
await removeStaleLock(lockPath);
|
|
277
|
+
const delay = LOCK_RETRY_DELAYS_MS[attempt];
|
|
278
|
+
if (delay === undefined)
|
|
279
|
+
return null;
|
|
280
|
+
await wait(delay);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
async function removeStaleLock(lockPath) {
|
|
286
|
+
try {
|
|
287
|
+
const stat = await fs.stat(lockPath);
|
|
288
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
289
|
+
await fs.unlink(lockPath).catch(() => undefined);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (!hasErrorCode(error, "ENOENT"))
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function releaseLock(lock) {
|
|
298
|
+
await lock.handle.close().catch(() => undefined);
|
|
299
|
+
try {
|
|
300
|
+
const owner = await fs.readFile(lock.path, "utf8");
|
|
301
|
+
if (owner === lock.token)
|
|
302
|
+
await fs.unlink(lock.path);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (!hasErrorCode(error, "ENOENT"))
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function isRecord(value) {
|
|
310
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
311
|
+
}
|
|
312
|
+
function isNullableMetric(value) {
|
|
313
|
+
return value === null || isIntegerInRange(value, 0, MAX_METRIC_VALUE);
|
|
314
|
+
}
|
|
315
|
+
function isIntegerInRange(value, minimum, maximum) {
|
|
316
|
+
return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
|
|
317
|
+
}
|
|
318
|
+
function hasErrorCode(error, code) {
|
|
319
|
+
return typeof error === "object" && error !== null && "code" in error &&
|
|
320
|
+
error.code === code;
|
|
321
|
+
}
|
|
322
|
+
function wait(ms) {
|
|
323
|
+
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
|
|
324
|
+
}
|
package/dist/storage/sqlite.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SQLite Local Storage Backend (v2.0 — Step 2)
|
|
3
3
|
*
|
|
4
|
-
* Zero-cloud
|
|
4
|
+
* Zero-cloud local SQLite storage using @libsql/client (libSQL/SQLite).
|
|
5
5
|
* Data lives at ~/.prism-mcp/data.db — no account, no API keys, no network.
|
|
6
6
|
*
|
|
7
7
|
* ═══════════════════════════════════════════════════════════════════
|