prism-mcp-server 20.2.1 → 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;
@@ -38,6 +38,12 @@ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
41
47
  function closeClient(context) {
42
48
  const activeClient = client;
43
49
  client = null;
@@ -76,9 +82,22 @@ function ensureTable() {
76
82
  prompt_tokens INTEGER,
77
83
  completion_tokens INTEGER,
78
84
  latency_ms INTEGER,
79
- ram_free_mb INTEGER
85
+ ram_free_mb INTEGER,
86
+ source_event_id TEXT
80
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
+ }
81
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`);
82
101
  })().catch((e) => {
83
102
  // Transient failures (missing dir on first run, SQLITE_BUSY) retry on
84
103
  // the next append; only repeated failure disables for the process.
@@ -100,23 +119,38 @@ export function appendInferMetric(row) {
100
119
  await ensureTable();
101
120
  if (disabled || !client)
102
121
  return;
103
- await client.execute({
104
- sql: `INSERT INTO infer_metrics
105
- (ts, caller, mode, backend, model, used_cloud, gate_outcome,
106
- refusal_reason, prompt_tokens, completion_tokens, latency_ms, ram_free_mb)
107
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
108
- args: [
109
- Date.now(), row.caller ?? "mcp", row.mode ?? null, row.backend,
110
- row.model, row.used_cloud ? 1 : 0, row.gate_outcome ?? null,
111
- row.refusal_reason ?? null, row.prompt_tokens ?? null,
112
- row.completion_tokens ?? null, row.latency_ms ?? null,
113
- row.ram_free_mb ?? null,
114
- ],
115
- });
122
+ await client.execute({ sql: INSERT_METRIC_SQL, args: metricArgs(row) });
116
123
  })().catch((e) => {
117
124
  debugLog(`[infer-ledger] append failed: ${e instanceof Error ? e.message : e}`);
118
125
  });
119
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
+ }
120
154
  /** Aggregate all persisted rows (optionally since a timestamp). */
121
155
  export async function queryInferMetrics(sinceTs) {
122
156
  try {
@@ -140,11 +174,34 @@ export async function queryInferMetrics(sinceTs) {
140
174
  sql: `SELECT backend, COUNT(*) AS n FROM infer_metrics ${where} GROUP BY backend`,
141
175
  args: whereArgs
142
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
+ });
143
189
  const r = agg.rows[0];
144
190
  const by_backend = {};
145
191
  for (const row of byB.rows) {
146
192
  by_backend[String(row.backend)] = Number(row.n);
147
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
+ }
148
205
  return {
149
206
  total: Number(r.total ?? 0),
150
207
  local: Number(r.local ?? 0),
@@ -155,6 +212,7 @@ export async function queryInferMetrics(sinceTs) {
155
212
  first_ts: r.first_ts == null ? null : Number(r.first_ts),
156
213
  last_ts: r.last_ts == null ? null : Number(r.last_ts),
157
214
  by_backend,
215
+ by_caller,
158
216
  };
159
217
  }
160
218
  catch (e) {
@@ -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
+ }
@@ -210,7 +210,24 @@ export class SynaluxStorage extends SupabaseStorage {
210
210
  user_id: userId,
211
211
  role,
212
212
  });
213
- return (result.context ?? result);
213
+ // Current portals return the canonical flat `context`. Older deployed
214
+ // portals returned `{ handoff, recent_sessions }`; normalize that envelope
215
+ // here so the formatter can still see last_summary, TODOs, version, and
216
+ // session history during a rolling portal/client upgrade.
217
+ if (result.context === null)
218
+ return null;
219
+ if (result.context && typeof result.context === "object" && !Array.isArray(result.context)) {
220
+ return result.context;
221
+ }
222
+ if (result.handoff === null)
223
+ return null;
224
+ if (result.handoff && typeof result.handoff === "object" && !Array.isArray(result.handoff)) {
225
+ return {
226
+ ...result.handoff,
227
+ recent_sessions: Array.isArray(result.recent_sessions) ? result.recent_sessions : [],
228
+ };
229
+ }
230
+ return result;
214
231
  }
215
232
  // ─── Forget memory (GDPR surgical deletion) ──────────────────
216
233
  // Phase 3 Tier A: route both soft and hard delete through the
@@ -38,6 +38,7 @@ vi.mock("../../../src/storage/configStorage.js", () => ({
38
38
  getAllSettings: vi.fn(() => Promise.resolve({})),
39
39
  getSettingSync: vi.fn(() => ""),
40
40
  initConfigStorage: vi.fn(),
41
+ refreshConfigStorageCache: vi.fn(() => Promise.resolve()),
41
42
  }));
42
43
  vi.mock("../../../src/config.js", () => ({
43
44
  PRISM_USER_ID: "test-user-id",
@@ -394,6 +395,18 @@ describe("ledgerHandlers", () => {
394
395
  await sessionLoadContextHandler(validArgs);
395
396
  expect(storage.loadContext).toHaveBeenCalledWith("test-project", "standard", "test-user-id", undefined);
396
397
  });
398
+ it.each(["quick", "standard", "deep"])("uses dashboard context depth %s when the caller omits level", async (configuredLevel) => {
399
+ mockGetSetting.mockImplementation(async (key, fallback = "") => key === "default_context_depth" ? configuredLevel : fallback);
400
+ await sessionLoadContextHandler(validArgs);
401
+ expect(storage.loadContext).toHaveBeenCalledWith("test-project", configuredLevel, "test-user-id", undefined);
402
+ });
403
+ it("lets an explicit level override the dashboard and safely ignores stale invalid dashboard state", async () => {
404
+ mockGetSetting.mockImplementation(async (key, fallback = "") => key === "default_context_depth" ? "obsolete-depth" : fallback);
405
+ await sessionLoadContextHandler({ ...validArgs, level: "deep" });
406
+ expect(storage.loadContext).toHaveBeenLastCalledWith("test-project", "deep", "test-user-id", undefined);
407
+ await sessionLoadContextHandler(validArgs);
408
+ expect(storage.loadContext).toHaveBeenLastCalledWith("test-project", "standard", "test-user-id", undefined);
409
+ });
397
410
  it("loads context at 'quick' level", async () => {
398
411
  storage.loadContext.mockResolvedValue(null);
399
412
  await sessionLoadContextHandler({ project: "test-project", level: "quick" });
@@ -26,9 +26,9 @@ export { webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, br
26
26
  // This file always exports them — server.ts decides whether to include them in the tool list.
27
27
  //
28
28
  // v0.4.0: Added SESSION_COMPACT_LEDGER_TOOL and SESSION_SEARCH_MEMORY_TOOL
29
- export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_BACKFILL_EMBEDDINGS_TOOL, SESSION_FORGET_MEMORY_TOOL, SESSION_EXPORT_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL, DEEP_STORAGE_PURGE_TOOL, SESSION_INTUITIVE_RECALL_TOOL, SESSION_BACKFILL_LINKS_TOOL, MAINTENANCE_VACUUM_TOOL, isDeepStoragePurgeArgs, SESSION_SYNTHESIZE_EDGES_TOOL, isSessionSynthesizeEdgesArgs, SESSION_COGNITIVE_ROUTE_TOOL, isSessionCognitiveRouteArgs, SESSION_TASK_ROUTE_TOOL, isSessionTaskRouteArgs, ONBOARDING_WIZARD_TOOL, EXTRACT_ENTITIES_TOOL, API_ANALYTICS_TOOL, BACKUP_DATABASE_TOOL, CONFIGURE_NOTIFICATIONS_TOOL, QUERY_MEMORY_NATURAL_TOOL } from "./sessionMemoryDefinitions.js";
29
+ export { SESSION_SAVE_LEDGER_TOOL, SESSION_SAVE_HANDOFF_TOOL, SESSION_LOAD_CONTEXT_TOOL, SESSION_BOOTSTRAP_TOOL, KNOWLEDGE_SEARCH_TOOL, KNOWLEDGE_FORGET_TOOL, SESSION_COMPACT_LEDGER_TOOL, SESSION_SEARCH_MEMORY_TOOL, MEMORY_HISTORY_TOOL, MEMORY_CHECKOUT_TOOL, SESSION_SAVE_IMAGE_TOOL, SESSION_VIEW_IMAGE_TOOL, SESSION_HEALTH_CHECK_TOOL, SESSION_BACKFILL_EMBEDDINGS_TOOL, SESSION_FORGET_MEMORY_TOOL, SESSION_EXPORT_MEMORY_TOOL, KNOWLEDGE_SET_RETENTION_TOOL, SESSION_SAVE_EXPERIENCE_TOOL, KNOWLEDGE_UPVOTE_TOOL, KNOWLEDGE_DOWNVOTE_TOOL, KNOWLEDGE_SYNC_RULES_TOOL, DEEP_STORAGE_PURGE_TOOL, SESSION_INTUITIVE_RECALL_TOOL, SESSION_BACKFILL_LINKS_TOOL, MAINTENANCE_VACUUM_TOOL, isDeepStoragePurgeArgs, SESSION_SYNTHESIZE_EDGES_TOOL, isSessionSynthesizeEdgesArgs, SESSION_COGNITIVE_ROUTE_TOOL, isSessionCognitiveRouteArgs, SESSION_TASK_ROUTE_TOOL, isSessionTaskRouteArgs, ONBOARDING_WIZARD_TOOL, EXTRACT_ENTITIES_TOOL, API_ANALYTICS_TOOL, BACKUP_DATABASE_TOOL, CONFIGURE_NOTIFICATIONS_TOOL, QUERY_MEMORY_NATURAL_TOOL } from "./sessionMemoryDefinitions.js";
30
30
  // 1. Ledger (Core CRUD & State)
31
- export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionSaveExperienceHandler, sessionSaveImageHandler, sessionViewImageHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionForgetMemoryHandler, sessionExportMemoryHandler } from "./ledgerHandlers.js";
31
+ export { sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionBootstrapHandler, sessionSaveExperienceHandler, sessionSaveImageHandler, sessionViewImageHandler, memoryHistoryHandler, memoryCheckoutHandler, sessionForgetMemoryHandler, sessionExportMemoryHandler } from "./ledgerHandlers.js";
32
32
  // 2. Graph (Semantic Search & Weighting)
33
33
  export { sessionSearchMemoryHandler, knowledgeSearchHandler, sessionIntuitiveRecallHandler, knowledgeUpvoteHandler, knowledgeDownvoteHandler, knowledgeForgetHandler, knowledgeSyncRulesHandler, sessionSynthesizeEdgesHandler, sessionCognitiveRouteHandler } from "./graphHandlers.js";
34
34
  // 3. Hygiene (Maintenance & Integrity)