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.
@@ -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 = resolve(homedir(), ".prism-mcp", "prism-config.db");
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
@@ -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
- requested = "synalux";
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
- client = null;
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 = null;
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
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * SQLite Local Storage Backend (v2.0 — Step 2)
3
3
  *
4
- * Zero-cloud, local-first storage using @libsql/client (libSQL/SQLite).
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
  * ═══════════════════════════════════════════════════════════════════