@zenithfoundry/slm-gate 1.2.1

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.
Files changed (106) hide show
  1. package/.env.example +669 -0
  2. package/LICENSE +21 -0
  3. package/README.md +317 -0
  4. package/configs/antigravity/.env.16gb.example +674 -0
  5. package/configs/antigravity/.env.24gb.example +674 -0
  6. package/configs/antigravity/.env.32gb.example +674 -0
  7. package/configs/antigravity/README.md +109 -0
  8. package/configs/claude-code/.env.16gb.example +674 -0
  9. package/configs/claude-code/.env.24gb.example +674 -0
  10. package/configs/claude-code/.env.32gb.example +674 -0
  11. package/configs/claude-code/README.md +52 -0
  12. package/configs/claude-desktop/.env.16gb.example +674 -0
  13. package/configs/claude-desktop/.env.24gb.example +674 -0
  14. package/configs/claude-desktop/.env.32gb.example +674 -0
  15. package/configs/claude-desktop/README.md +37 -0
  16. package/configs/cline-continue-opencode/.env.16gb.example +674 -0
  17. package/configs/cline-continue-opencode/.env.24gb.example +674 -0
  18. package/configs/cline-continue-opencode/.env.32gb.example +674 -0
  19. package/configs/cline-continue-opencode/README.md +34 -0
  20. package/configs/cursor/.env.16gb.example +674 -0
  21. package/configs/cursor/.env.24gb.example +674 -0
  22. package/configs/cursor/.env.32gb.example +674 -0
  23. package/configs/cursor/README.md +26 -0
  24. package/configs/generic-http/.env.16gb.example +674 -0
  25. package/configs/generic-http/.env.24gb.example +674 -0
  26. package/configs/generic-http/.env.32gb.example +674 -0
  27. package/configs/generic-http/README.md +20 -0
  28. package/configs/generic-stdio/.env.16gb.example +674 -0
  29. package/configs/generic-stdio/.env.24gb.example +674 -0
  30. package/configs/generic-stdio/.env.32gb.example +674 -0
  31. package/configs/generic-stdio/README.md +24 -0
  32. package/configs/preserve/README.md +26 -0
  33. package/configs/preserve/tls.json +61 -0
  34. package/dist/adapters/tech-lead-stack.js +38 -0
  35. package/dist/cache/index.js +173 -0
  36. package/dist/cli.js +256 -0
  37. package/dist/config.js +255 -0
  38. package/dist/dashboard/data.js +149 -0
  39. package/dist/dashboard/export.js +42 -0
  40. package/dist/dashboard/serve.js +63 -0
  41. package/dist/doctor.js +338 -0
  42. package/dist/hardware.js +126 -0
  43. package/dist/home-dir.js +39 -0
  44. package/dist/ledger/flush-lifecycle.js +50 -0
  45. package/dist/ledger/index.js +946 -0
  46. package/dist/ledger/report.js +69 -0
  47. package/dist/ledger/setup-dashboard.js +456 -0
  48. package/dist/ledger/smoke.js +37 -0
  49. package/dist/ledger/sync-config.js +177 -0
  50. package/dist/ledger/sync.js +307 -0
  51. package/dist/ledger/verify.js +185 -0
  52. package/dist/ledger/wipe-langfuse.js +130 -0
  53. package/dist/llm-gate/distill.js +239 -0
  54. package/dist/llm-gate/formats/anthropic.js +185 -0
  55. package/dist/llm-gate/formats/chat-completions.js +103 -0
  56. package/dist/llm-gate/formats/contract.js +29 -0
  57. package/dist/llm-gate/formats/gemini.js +84 -0
  58. package/dist/llm-gate/formats/internal.js +1 -0
  59. package/dist/llm-gate/formats/openai.js +77 -0
  60. package/dist/llm-gate/formats/responses.js +146 -0
  61. package/dist/llm-gate/forward.js +150 -0
  62. package/dist/llm-gate/index.js +40 -0
  63. package/dist/llm-gate/local-first.js +217 -0
  64. package/dist/llm-gate/pipeline.js +267 -0
  65. package/dist/llm-gate/server.js +289 -0
  66. package/dist/mcp-gate/ground.js +64 -0
  67. package/dist/mcp-gate/index.js +57 -0
  68. package/dist/mcp-gate/pipeline.js +252 -0
  69. package/dist/mcp-gate/server.js +302 -0
  70. package/dist/mcp-gate/tool-names.js +57 -0
  71. package/dist/models/check.js +26 -0
  72. package/dist/models/footprint.js +137 -0
  73. package/dist/models/helpers.js +91 -0
  74. package/dist/models/index.js +5 -0
  75. package/dist/models/reasoning.js +91 -0
  76. package/dist/models/roles.js +9 -0
  77. package/dist/models/slm.js +243 -0
  78. package/dist/models/types.js +1 -0
  79. package/dist/pricing/index.js +115 -0
  80. package/dist/pricing/plans.js +54 -0
  81. package/dist/pricing/providers.js +172 -0
  82. package/dist/resolver/index.js +277 -0
  83. package/dist/resolver/types.js +1 -0
  84. package/dist/setup/claim.js +41 -0
  85. package/dist/setup/gate-command.js +41 -0
  86. package/dist/setup/init.js +92 -0
  87. package/dist/setup/local-models.js +123 -0
  88. package/dist/setup/model-gate.js +220 -0
  89. package/dist/setup/notify.js +45 -0
  90. package/dist/setup/ollama-install.js +53 -0
  91. package/dist/setup/parent-watch.js +84 -0
  92. package/dist/setup/required-models.js +20 -0
  93. package/dist/setup/startup.js +132 -0
  94. package/dist/setup/tool-settings.js +101 -0
  95. package/dist/utils/backoff.js +47 -0
  96. package/dist/utils/compression.js +145 -0
  97. package/dist/utils/constants.js +22 -0
  98. package/dist/utils/duration.js +43 -0
  99. package/dist/utils/elision.js +556 -0
  100. package/dist/utils/embedding.js +32 -0
  101. package/dist/utils/entry-point.js +23 -0
  102. package/dist/utils/local-only.js +82 -0
  103. package/dist/utils/preserve-patterns.js +115 -0
  104. package/dist/utils/safety.js +30 -0
  105. package/dist/verifier/index.js +67 -0
  106. package/package.json +121 -0
package/dist/config.js ADDED
@@ -0,0 +1,255 @@
1
+ import { config } from 'dotenv';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { z } from 'zod';
6
+ import { ramPresets } from './hardware.js';
7
+ import { resolveHomeDir } from './home-dir.js';
8
+ import { getSubscriptionPlan, getValidPlanKeys, isValidPlanKey } from './pricing/plans.js';
9
+ import { isEntryPoint } from './utils/entry-point.js';
10
+ const filename = fileURLToPath(import.meta.url);
11
+ const dirname = path.dirname(filename);
12
+ const ROOT_DIR = path.resolve(dirname, '..');
13
+ // Settings and data (.env, output/): the checkout for a git clone, ~/.slm-gate for an npm install,
14
+ // or SLM_GATE_HOME (src/home-dir.ts).
15
+ const HOME_DIR = resolveHomeDir({ installDir: ROOT_DIR, env: process.env, userHome: os.homedir() });
16
+ const OUTPUT_DIR = path.join(HOME_DIR, 'output');
17
+ // Load THIS INSTALL'S .env, from HOME_DIR. Deliberately not cwd-relative:
18
+ // MCP hosts spawn the gate with cwd set to whatever workspace the user has open, so a
19
+ // bare dotenv.config() silently found nothing and left LANGFUSE_*/PROVIDER undefined
20
+ // while LEDGER_PATH's module-relative default still resolved — a gate that looked
21
+ // healthy and shipped no telemetry.
22
+ //
23
+ // The caller's cwd .env is deliberately NOT loaded as a fallback: it would pull an
24
+ // unrelated project's PROVIDER, CLOUD_API_KEY and CLOUD_MODEL into this process.
25
+ // Host-supplied process.env still wins, because dotenv never overrides an existing var.
26
+ const envFile = config({ path: path.join(HOME_DIR, '.env') });
27
+ // Zod pre-processors for env strings
28
+ const parseInteger = (fallback) => z.string().optional().transform(v => v ? parseInt(v, 10) : fallback);
29
+ const parseFloatNumber = (fallback) => z.string().optional().transform(v => v ? parseFloat(v) : fallback);
30
+ const parseBoolean = (fallback) => z.string().optional().transform(v => {
31
+ if (!v)
32
+ return fallback;
33
+ return v.toLowerCase() === 'on' || v.toLowerCase() === 'true' || v === '1';
34
+ });
35
+ const parseNumberArray = (fallback) => z.string().optional().transform(v => v ? v.split(',').map(n => parseInt(n.trim(), 10)) : fallback);
36
+ const parseDownstreamMcp = () => z.string().optional().transform(v => {
37
+ if (!v)
38
+ return null;
39
+ try {
40
+ const parsed = JSON.parse(v);
41
+ if (parsed.command) {
42
+ return { command: parsed.command, args: parsed.args, env: parsed.env };
43
+ }
44
+ if (parsed.url) {
45
+ return { url: parsed.url, headers: parsed.headers };
46
+ }
47
+ return null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ });
53
+ const envSchema = z.object({
54
+ // STEP 1
55
+ SLM_PROVIDER: z.enum(['ollama', 'openai']).default('ollama'),
56
+ OLLAMA_HOST: z.string().default('http://localhost:11434'),
57
+ OLLAMA_KEEP_ALIVE: z.string().default('12h'),
58
+ SLM_BRAIN_MODEL: z.string().optional(), // resolved below
59
+ SLM_GATE_MODEL: z.string().optional(), // resolved below
60
+ SLM_GATE_TESTING_MODEL: z.string().optional(), // resolved below, used for benchmark harness & test runs
61
+ NUM_CTX: parseInteger(8192),
62
+ TEMPERATURE: parseFloatNumber(0),
63
+ SLM_TIMEOUT_MS: parseInteger(120000),
64
+ SELF_CONSISTENCY_K: parseInteger(3),
65
+ SELF_CONSISTENCY_TEMP: parseFloatNumber(0.7),
66
+ // STEP 2
67
+ STRICTNESS_LEVELS: parseNumberArray([0, 1, 2, 3, 4, 5]),
68
+ HEADLINE_STRICTNESS: parseInteger(4),
69
+ // STEP 3
70
+ CLOUD_API_STYLE: z.enum(['openai', 'anthropic']).default('openai'),
71
+ CLOUD_BASE_URL: z.string().optional(),
72
+ CLOUD_API_KEY: z.string().optional(),
73
+ CLOUD_MODEL: z.string().optional(),
74
+ // Semantic CACHE
75
+ SEMCACHE: parseBoolean(false),
76
+ SEMCACHE_THRESHOLD: parseFloatNumber(0.95),
77
+ EMBED_MODEL: z.string().default('nomic-embed-text'),
78
+ // STEP 4
79
+ LLM_GATE_PORT: parseInteger(8787),
80
+ // When a coding tool starts slm-gate's MCP server, also start the model gate (in the background) if it
81
+ // is not running, and keep it running while tools are open. Off = you start it yourself (`slm-gate start`).
82
+ LLM_GATE_AUTOSTART: parseBoolean(true),
83
+ // Distil large command, search and listing tool results before requests leave the machine. Off makes
84
+ // the gate a plain pass-through; ongoing conversations then resend their distilled history in full
85
+ // once (one prompt-cache miss, and Claude drops its earlier thinking once).
86
+ LLM_GATE_DISTILL: parseBoolean(true),
87
+ // Offer the first request of a conversation to the local model (Step A). Off sends every request on.
88
+ LLM_GATE_LOCAL_FIRST: parseBoolean(true),
89
+ // How long a first request may wait for a local answer before it goes on; the attempt is then cancelled.
90
+ // A warm local answer (3 samples) measured 3.8 s; deciding a coding task is not eligible takes ~0.25 s.
91
+ LOCAL_ATTEMPT_BUDGET_MS: parseInteger(6000),
92
+ // Where the model gate forwards each wire format. Built in; override only when your requests
93
+ // must already go through a company gateway. The tool's own login is forwarded either way.
94
+ UPSTREAM_ANTHROPIC_URL: z.string().url().default('https://api.anthropic.com'),
95
+ UPSTREAM_OPENAI_URL: z.string().url().default('https://api.openai.com/v1'),
96
+ UPSTREAM_CHATGPT_URL: z.string().url().default('https://chatgpt.com/backend-api/codex'),
97
+ UPSTREAM_GEMINI_URL: z.string().url().default('https://generativelanguage.googleapis.com'),
98
+ DOWNSTREAM_MCP: parseDownstreamMcp(),
99
+ MCP_GATE_TRANSPORT: z.enum(['stdio', 'http']).default('stdio'),
100
+ MCP_GATE_PORT: parseInteger(8788),
101
+ // STEP 5
102
+ PROVIDER: z.enum(['gemini', 'claude', 'chatgpt']).optional(),
103
+ LEDGER_PATH: z.string().default(path.join(OUTPUT_DIR, 'ledger.sqlite')),
104
+ LANGFUSE_PUBLIC_KEY: z.string().optional(),
105
+ LANGFUSE_SECRET_KEY: z.string().optional(),
106
+ LANGFUSE_HOST: z.string().optional(),
107
+ // Langfuse environment dimension. Keeps benchmark/harness runs out of the real
108
+ // traffic view — the dashboard's Env selector filters on this. Langfuse requires
109
+ // lowercase alphanumeric plus - and _, and forbids the 'langfuse' prefix.
110
+ // Not 'default': that is where any other writer to the same Langfuse project lands
111
+ // when it sets no environment, so the gate's traffic would be mixed with theirs.
112
+ LANGFUSE_ENVIRONMENT: z.string()
113
+ .regex(/^(?!langfuse)[a-z0-9_-]+$/, "LANGFUSE_ENVIRONMENT must be lowercase alphanumeric (- and _ allowed) and must not start with 'langfuse'")
114
+ .default('slm-gate'),
115
+ SUBSCRIPTION_PLAN: z.string().optional(),
116
+ PLAN_CLAUDE: z.string().optional(),
117
+ PLAN_CHATGPT: z.string().optional(),
118
+ PLAN_GEMINI: z.string().optional(),
119
+ // STEP 6
120
+ RESOLVER_CLOUD_TIER: parseBoolean(false),
121
+ RESOLVER_CLOUD_BUDGET_USD: parseFloatNumber(0),
122
+ PROMPT_VERSION: z.string().default('v1'),
123
+ // The presets in src/hardware.ts ramPresets. Every value recommendPreset() can print must be accepted
124
+ // here, or following doctor's advice stops slm-gate from starting. slm-gate needs 16 GB of RAM or more.
125
+ RAM_PRESET: z.enum(['ram-16', 'ram-24', 'ram-32', 'ram-48', 'ram-64', 'ram-128', 'custom']).default('custom'),
126
+ TLS_ADAPTER: parseBoolean(false),
127
+ DISTILL_PRESERVE_PATH: z.string().optional().transform(v => v?.trim() || null),
128
+ DISTILL_PRESERVE_MODE: z.enum(['extend', 'replace']).default('extend'),
129
+ DISTILL_SKILLS: parseBoolean(false),
130
+ DISTILL_ADAPTIVE: parseBoolean(false),
131
+ DISTILL_ADAPTIVE_THRESHOLD: parseFloatNumber(0.86),
132
+ DISTILL_ADAPTIVE_EXPLORE_RATE: parseFloatNumber(0.15),
133
+ DISTILL_FEEDBACK_RETENTION_DAYS: parseInteger(180),
134
+ DISTILL_FEEDBACK_MAX_ROWS: parseInteger(5000),
135
+ DISTILL_MAX_TOKENS: parseInteger(2000),
136
+ DISTILL_MIN_TOKENS: parseInteger(500),
137
+ // How long the model gate may hold one request to distil its new tool results. Past it, a result is
138
+ // sent (and kept, forever) as the original. Warm local-model runs take ~0.5 s per ~600 words.
139
+ DISTILL_BUDGET_MS: parseInteger(3000),
140
+ KEEP_RECENT_TOOL_TURNS: parseInteger(2),
141
+ ELISION_MAX_ENTRIES: parseInteger(5000),
142
+ ELISION_RETENTION_DAYS: parseInteger(180),
143
+ ELISION_MAX_MB: parseInteger(500),
144
+ // How many days of data your Langfuse plan keeps (Hobby: 30). Only the dashboard text reads it: the
145
+ // cards cover this rolling window, while the local ledger keeps everything.
146
+ LANGFUSE_RETENTION_DAYS: parseInteger(30),
147
+ // ROUTING TUNE
148
+ ROUTING_TUNE: parseBoolean(false),
149
+ ROUTING_TUNE_WINDOW: parseInteger(20),
150
+ ROUTING_TUNE_MIN_SAMPLES: parseInteger(8),
151
+ ROUTING_TUNE_THRESHOLD: parseFloatNumber(0.5),
152
+ ROUTING_TUNE_EXPLORE_RATE: parseFloatNumber(0.15),
153
+ });
154
+ // A variable that is present but blank means "unset". dotenv and MCP-host env blocks both
155
+ // deliver `LEDGER_PATH=` (as shipped in every .env template) as '', and zod's .default() only
156
+ // fires on undefined — so a blank value used to defeat the absolute default and leave a
157
+ // cwd-relative './output/ledger.sqlite', which ENOENTs when the host's cwd does not exist
158
+ // (Claude Desktop). Dropping empties here fixes that for every variable in one place.
159
+ const envInput = Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== ''));
160
+ /**
161
+ * Every environment variable slm-gate's configuration reads. The automatically started model gate is
162
+ * launched without these, so it takes its settings only from slm-gate's own .env — never from the MCP env
163
+ * block of whichever coding tool happened to start it (src/setup/model-gate.ts).
164
+ */
165
+ export const CONFIG_ENV_KEYS = Object.keys(envSchema.shape);
166
+ const parsedEnv = envSchema.parse(envInput);
167
+ const preset = ramPresets[parsedEnv.RAM_PRESET] || ramPresets['custom'];
168
+ const resolvePlan = (provider) => {
169
+ // 1. PLAN_<P>
170
+ const planProviderKey = `PLAN_${provider.toUpperCase()}`;
171
+ let planKey = parsedEnv[planProviderKey];
172
+ // 2. SUBSCRIPTION_PLAN
173
+ if (!planKey && parsedEnv.SUBSCRIPTION_PLAN) {
174
+ if (parsedEnv.SUBSCRIPTION_PLAN.startsWith(provider)) {
175
+ planKey = parsedEnv.SUBSCRIPTION_PLAN;
176
+ }
177
+ }
178
+ // 3. Default
179
+ if (!planKey) {
180
+ if (provider === 'claude')
181
+ planKey = 'claude-pro';
182
+ else if (provider === 'chatgpt')
183
+ planKey = 'chatgpt-plus';
184
+ else if (provider === 'gemini')
185
+ planKey = 'gemini-pro';
186
+ }
187
+ if (planKey && !isValidPlanKey(planKey)) {
188
+ throw new Error(`Invalid plan key '${planKey}' for ${provider}. Valid keys: ${getValidPlanKeys().join(', ')}`);
189
+ }
190
+ const resolved = getSubscriptionPlan(planKey);
191
+ return {
192
+ windowMinutes: resolved.windowMinutes,
193
+ source: resolved.source,
194
+ plan: planKey
195
+ };
196
+ };
197
+ const claudePlan = resolvePlan('claude');
198
+ const chatgptPlan = resolvePlan('chatgpt');
199
+ const geminiPlan = resolvePlan('gemini');
200
+ console.error(`[config] claude plan: ${claudePlan.plan} (windowMinutes: ${claudePlan.windowMinutes})`);
201
+ console.error(`[config] chatgpt plan: ${chatgptPlan.plan} (windowMinutes: ${chatgptPlan.windowMinutes})`);
202
+ console.error(`[config] gemini plan: ${geminiPlan.plan} (windowMinutes: ${geminiPlan.windowMinutes})`);
203
+ export const CONFIG = Object.freeze({
204
+ ...parsedEnv,
205
+ ROOT_DIR,
206
+ HOME_DIR,
207
+ OUTPUT_DIR,
208
+ SLM_BRAIN_MODEL: parsedEnv.SLM_BRAIN_MODEL || preset.brain,
209
+ SLM_GATE_MODEL: parsedEnv.SLM_GATE_MODEL || preset.gate,
210
+ SLM_GATE_TESTING_MODEL: parsedEnv.SLM_GATE_TESTING_MODEL || parsedEnv.SLM_GATE_MODEL || preset.gate,
211
+ // Where every session looks for, and starts, the one shared model gate: LLM_GATE_PORT as slm-gate's own
212
+ // .env sets it. LLM_GATE_PORT above prefers the process environment, so a value in one coding tool's MCP
213
+ // env block (or the shell) would send that session to another port, where it would start a second gate.
214
+ MODEL_GATE_PORT: envSchema.shape.LLM_GATE_PORT.parse(envFile.parsed?.LLM_GATE_PORT || undefined),
215
+ RESOLVED_PLAN_CLAUDE: claudePlan,
216
+ RESOLVED_PLAN_CHATGPT: chatgptPlan,
217
+ RESOLVED_PLAN_GEMINI: geminiPlan,
218
+ });
219
+ /**
220
+ * Validates that specific keys exist and are non-empty. Throws actionable errors if missing.
221
+ * Called right before an operation that requires them, NOT on module boot.
222
+ */
223
+ export function requireKeys(keys) {
224
+ const missing = keys.filter(k => !CONFIG[k]);
225
+ if (missing.length > 0) {
226
+ throw new Error(`Missing required configuration for this operation: ${missing.join(', ')}. Please update your .env file.`);
227
+ }
228
+ }
229
+ // Config test script executed via `pnpm run config`
230
+ if (isEntryPoint(import.meta.url)) {
231
+ console.log('=== SMALL-LANGUAGE-MODEL-GATE CONFIGURATION ===');
232
+ const redactedConfig = { ...CONFIG };
233
+ if (redactedConfig.CLOUD_API_KEY)
234
+ redactedConfig.CLOUD_API_KEY = '<SET>';
235
+ else
236
+ redactedConfig.CLOUD_API_KEY = '<UNSET>';
237
+ if (redactedConfig.LANGFUSE_SECRET_KEY)
238
+ redactedConfig.LANGFUSE_SECRET_KEY = '<SET>';
239
+ else
240
+ redactedConfig.LANGFUSE_SECRET_KEY = '<UNSET>';
241
+ if (redactedConfig.LANGFUSE_PUBLIC_KEY)
242
+ redactedConfig.LANGFUSE_PUBLIC_KEY = '<SET>';
243
+ else
244
+ redactedConfig.LANGFUSE_PUBLIC_KEY = '<UNSET>';
245
+ console.log(JSON.stringify(redactedConfig, null, 2));
246
+ console.log('\n--- Summary ---');
247
+ console.log(`brain: ${CONFIG.SLM_BRAIN_MODEL} | gate: ${CONFIG.SLM_GATE_MODEL} | test: ${CONFIG.SLM_GATE_TESTING_MODEL}`);
248
+ const downstream = CONFIG.DOWNSTREAM_MCP ? (CONFIG.DOWNSTREAM_MCP.command ? `command: ${CONFIG.DOWNSTREAM_MCP.command}` : `url: ${CONFIG.DOWNSTREAM_MCP.url}`) : 'standalone';
249
+ console.log(`downstream: ${downstream}`);
250
+ const sinks = ['sqlite'];
251
+ if (CONFIG.LANGFUSE_PUBLIC_KEY && CONFIG.LANGFUSE_SECRET_KEY && CONFIG.LANGFUSE_HOST) {
252
+ sinks.push('langfuse');
253
+ }
254
+ console.log(`sinks: [${sinks.join(', ')}]`);
255
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @fileoverview The dashboard's numbers, computed from the local ledger.
3
+ *
4
+ * One JSON document drives both servings of the dashboard page: `slm-gate dashboard`
5
+ * computes it fresh per request, and `dashboard:export` bakes it into a static site.
6
+ * Everything here is aggregate — counts, token sums, minutes, dates. No prompt text,
7
+ * tool names or skill names leave the machine, so an export is safe to publish.
8
+ *
9
+ * The per-cycle view is the reason this exists locally: a provider window is a rolling
10
+ * span opened by the first request (Claude: 5 h), so cycles can only be reconstructed
11
+ * by clustering events by time — which Langfuse widgets cannot do.
12
+ *
13
+ * Benchmark runs (environment 'bench') are excluded from every savings figure and from
14
+ * routing: they are synthetic. They appear only in the accuracy split, labelled as such.
15
+ */
16
+ import Database from 'better-sqlite3';
17
+ import { CONFIG } from '../config.js';
18
+ import { formatEventForLangfuse, perEventBaselineTokens, perEventCycleMinutes, perEventTokensSaved, resolveProvider, routingOutcome, } from '../ledger/index.js';
19
+ import { getProviderRegistry } from '../pricing/providers.js';
20
+ const PROVIDERS = [
21
+ { id: 'claude', label: 'Claude' },
22
+ { id: 'chatgpt', label: 'ChatGPT' },
23
+ { id: 'gemini', label: 'Gemini' },
24
+ ];
25
+ function resolvedPlan(id) {
26
+ const plans = {
27
+ claude: CONFIG.RESOLVED_PLAN_CLAUDE,
28
+ chatgpt: CONFIG.RESOLVED_PLAN_CHATGPT,
29
+ gemini: CONFIG.RESOLVED_PLAN_GEMINI,
30
+ };
31
+ const resolved = plans[id];
32
+ return { windowMinutes: resolved.windowMinutes, plan: resolved.plan ?? id };
33
+ }
34
+ /** Monday of the event's week, UTC. */
35
+ function weekStartOf(ts) {
36
+ const date = new Date(ts);
37
+ date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7));
38
+ return date.toISOString().slice(0, 10);
39
+ }
40
+ /** The event's accuracy score (0–100) exactly as the Langfuse mirror would emit it, or null. */
41
+ function accuracyOf(e) {
42
+ const score = formatEventForLangfuse(e).scores?.find(s => s.name === 'accuracy_rate_pct');
43
+ return typeof score?.value === 'number' ? score.value : null;
44
+ }
45
+ /**
46
+ * Clusters one provider's events into window instances: a cycle opens at its first event
47
+ * and spans windowMinutes; the next event past that boundary opens the next cycle. This
48
+ * mirrors how the providers meter — the window starts on first use, not on the clock hour.
49
+ */
50
+ export function clusterCycles(params) {
51
+ const hasBudget = getProviderRegistry()[params.providerId]?.windowBudget != null;
52
+ const cycles = [];
53
+ let windowEndMs = -Infinity;
54
+ for (const e of params.events) {
55
+ const t = Date.parse(e.ts);
56
+ if (t >= windowEndMs) {
57
+ windowEndMs = t + params.windowMinutes * 60_000;
58
+ cycles.push({ start: new Date(t).toISOString(), events: 0, tokensSaved: 0, minutes: hasBudget ? 0 : null });
59
+ }
60
+ const cycle = cycles[cycles.length - 1];
61
+ cycle.events += 1;
62
+ cycle.tokensSaved += perEventTokensSaved(e);
63
+ if (cycle.minutes !== null)
64
+ cycle.minutes += perEventCycleMinutes(e, params.providerId) ?? 0;
65
+ }
66
+ return cycles.map(c => ({ ...c, minutes: c.minutes === null ? null : Number(c.minutes.toFixed(3)) }));
67
+ }
68
+ /** Pure aggregation over ledger events, so it is testable without a database. */
69
+ export function computeDashboardData(events) {
70
+ const sorted = [...events].sort((a, b) => a.ts.localeCompare(b.ts));
71
+ const real = sorted.filter(e => e.environment !== 'bench');
72
+ const bench = sorted.filter(e => e.environment === 'bench');
73
+ const routing = { resolvedLocal: 0, distilledForwarded: 0, escalatedCloud: 0 };
74
+ const days = new Map();
75
+ for (const e of real) {
76
+ const day = new Date(e.ts).toISOString().slice(0, 10);
77
+ const row = days.get(day) ?? { day, events: 0, tokensSaved: 0, baselineTokens: 0, resolvedLocal: 0, distilledForwarded: 0, escalatedCloud: 0 };
78
+ row.events += 1;
79
+ row.tokensSaved += perEventTokensSaved(e);
80
+ row.baselineTokens += perEventBaselineTokens(e);
81
+ if (e.route !== 'feedback') {
82
+ const outcome = routingOutcome(e) === 'resolved_local' ? 'resolvedLocal'
83
+ : routingOutcome(e) === 'distilled_forwarded' ? 'distilledForwarded' : 'escalatedCloud';
84
+ row[outcome] += 1;
85
+ routing[outcome] += 1;
86
+ }
87
+ days.set(day, row);
88
+ }
89
+ const accuracySlice = (slice) => {
90
+ const scores = slice.map(accuracyOf).filter((v) => v !== null);
91
+ if (scores.length === 0)
92
+ return { avgPct: null, n: 0 };
93
+ return { avgPct: Number((scores.reduce((s, v) => s + v, 0) / scores.length).toFixed(2)), n: scores.length };
94
+ };
95
+ const registry = getProviderRegistry();
96
+ const providers = PROVIDERS.map(({ id, label }) => {
97
+ const plan = resolvedPlan(id);
98
+ const mine = real.filter(e => resolveProvider(e) === id);
99
+ const cycles = clusterCycles({ events: mine, windowMinutes: plan.windowMinutes, providerId: id });
100
+ const budget = registry[id]?.windowBudget ?? null;
101
+ const weeks = new Map();
102
+ for (const e of mine) {
103
+ const weekStart = weekStartOf(e.ts);
104
+ const row = weeks.get(weekStart) ?? { weekStart, events: 0, tokensSaved: 0, minutes: budget === null ? null : 0 };
105
+ row.events += 1;
106
+ row.tokensSaved += perEventTokensSaved(e);
107
+ if (row.minutes !== null)
108
+ row.minutes = Number((row.minutes + (perEventCycleMinutes(e, id) ?? 0)).toFixed(3));
109
+ weeks.set(weekStart, row);
110
+ }
111
+ const minuteValues = cycles.map(c => c.minutes).filter((m) => m !== null).sort((a, b) => a - b);
112
+ const round = (n) => Number(n.toFixed(3));
113
+ return {
114
+ id,
115
+ label,
116
+ plan: plan.plan,
117
+ windowMinutes: plan.windowMinutes,
118
+ windowBudgetTokens: budget,
119
+ events: mine.length,
120
+ tokensSaved: mine.reduce((s, e) => s + perEventTokensSaved(e), 0),
121
+ minutesTotal: budget === null ? null : round(minuteValues.reduce((s, m) => s + m, 0)),
122
+ avgMinutesPerCycle: minuteValues.length === 0 ? null : round(minuteValues.reduce((s, m) => s + m, 0) / minuteValues.length),
123
+ medianMinutesPerCycle: minuteValues.length === 0 ? null : round(minuteValues[Math.floor(minuteValues.length / 2)]),
124
+ cycles,
125
+ weeks: [...weeks.values()].sort((a, b) => a.weekStart.localeCompare(b.weekStart)),
126
+ };
127
+ });
128
+ return {
129
+ generatedAt: new Date().toISOString(),
130
+ firstEventTs: sorted[0]?.ts ?? null,
131
+ lastEventTs: sorted[sorted.length - 1]?.ts ?? null,
132
+ realEvents: real.length,
133
+ benchEvents: bench.length,
134
+ routing,
135
+ accuracy: { real: accuracySlice(real), bench: accuracySlice(bench) },
136
+ days: [...days.values()].sort((a, b) => a.day.localeCompare(b.day)),
137
+ providers,
138
+ };
139
+ }
140
+ /** Reads the ledger read-only and aggregates it. Throws when the ledger does not exist yet. */
141
+ export function loadDashboardData() {
142
+ const db = new Database(CONFIG.LEDGER_PATH, { readonly: true, fileMustExist: true });
143
+ try {
144
+ return computeDashboardData(db.prepare('SELECT * FROM events').all());
145
+ }
146
+ finally {
147
+ db.close();
148
+ }
149
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @fileoverview Bakes the dashboard into a static site for free hosting (GitHub Pages).
3
+ *
4
+ * Writes <out>/index.html (the same page the local server serves), <out>/data.json
5
+ * (aggregates only: counts, token sums, minutes, dates — no prompts, tool names or
6
+ * skill names, so it is safe to publish; the timestamps do reveal when you work),
7
+ * and <out>/.nojekyll.
8
+ *
9
+ * Usage:
10
+ * pnpm run dashboard:export [-- --out site]
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { CONFIG } from '../config.js';
15
+ import { isEntryPoint } from '../utils/entry-point.js';
16
+ import { loadDashboardData } from './data.js';
17
+ function main() {
18
+ const args = process.argv.slice(2);
19
+ const outIdx = args.indexOf('--out');
20
+ const outDir = path.resolve(CONFIG.ROOT_DIR, outIdx >= 0 ? args[outIdx + 1] : 'site');
21
+ const data = loadDashboardData();
22
+ fs.mkdirSync(outDir, { recursive: true });
23
+ fs.copyFileSync(path.join(CONFIG.ROOT_DIR, 'src', 'dashboard', 'index.html'), path.join(outDir, 'index.html'));
24
+ fs.writeFileSync(path.join(outDir, 'data.json'), JSON.stringify(data, null, 2));
25
+ fs.writeFileSync(path.join(outDir, '.nojekyll'), '');
26
+ console.log(`Exported ${data.realEvents + data.benchEvents} events of aggregates to ${outDir}/`);
27
+ console.log('Contains numbers and dates only — no prompts, tool names or skill names.');
28
+ console.log('\nTo publish on GitHub Pages:');
29
+ console.log(' 1. git add site && commit && push (the Pages workflow deploys site/ from main)');
30
+ console.log(' 2. Once, in the repository: Settings → Pages → Source: GitHub Actions');
31
+ console.log(' 3. Your dashboard: https://<owner>.github.io/<repo>/');
32
+ console.log('\nOr view without hosting anything: open the page anywhere and drop this data.json on it.');
33
+ }
34
+ if (isEntryPoint(import.meta.url)) {
35
+ try {
36
+ main();
37
+ }
38
+ catch (err) {
39
+ console.error('dashboard:export failed:', err instanceof Error ? err.message : err);
40
+ process.exit(1);
41
+ }
42
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @fileoverview Serves the metrics dashboard locally, live from the ledger.
3
+ *
4
+ * Read-only: the ledger is opened read-only per request and nothing is written anywhere.
5
+ * Local-only via the same rule as the gate's other servers (listenOnThisComputer).
6
+ *
7
+ * Usage:
8
+ * pnpm run dashboard [-- --port 8790]
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { CONFIG } from '../config.js';
13
+ import { isEntryPoint } from '../utils/entry-point.js';
14
+ import { isLocalRequest, listenOnThisComputer } from '../utils/local-only.js';
15
+ import { loadDashboardData } from './data.js';
16
+ const PAGE_PATH = path.join(CONFIG.ROOT_DIR, 'src', 'dashboard', 'index.html');
17
+ function handler(req, res) {
18
+ if (!isLocalRequest(req.headers)) {
19
+ res.writeHead(403).end('local requests only');
20
+ return;
21
+ }
22
+ const url = (req.url ?? '/').split('?')[0];
23
+ try {
24
+ if (url === '/' || url === '/index.html') {
25
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(fs.readFileSync(PAGE_PATH));
26
+ }
27
+ else if (url === '/data.json') {
28
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(loadDashboardData()));
29
+ }
30
+ else {
31
+ res.writeHead(404).end('not found');
32
+ }
33
+ }
34
+ catch (err) {
35
+ const message = err instanceof Error ? err.message : String(err);
36
+ res.writeHead(500, { 'Content-Type': 'text/plain' }).end(/(ENOENT|does not exist)/i.test(message)
37
+ ? `No ledger at ${CONFIG.LEDGER_PATH} yet — it is created when the gate handles its first request.`
38
+ : message);
39
+ }
40
+ }
41
+ async function main() {
42
+ const args = process.argv.slice(2);
43
+ const portIdx = args.indexOf('--port');
44
+ const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 8790;
45
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
46
+ console.error('--port takes a port number.');
47
+ process.exit(1);
48
+ }
49
+ await listenOnThisComputer({
50
+ handler,
51
+ port,
52
+ onListening: () => {
53
+ console.log(`SLM Gate metrics dashboard: http://localhost:${port}`);
54
+ console.log(`Reading ${CONFIG.LEDGER_PATH} (read-only, computed fresh on every reload). Ctrl-C to stop.`);
55
+ },
56
+ });
57
+ }
58
+ if (isEntryPoint(import.meta.url)) {
59
+ main().catch(err => {
60
+ console.error('dashboard failed to start:', err instanceof Error ? err.message : err);
61
+ process.exit(1);
62
+ });
63
+ }