/** * Runtime copilot settings. * * Precedence: saved settings file > environment > built-in defaults. * * Settings are persisted to a small JSON file rather than held only in memory, so * a model chosen while preparing a demo survives a server restart. They are * deliberately NOT written back into .env - that file holds the API key, and a * process that rewrites its own secrets file is a bad habit to build. */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); export const SETTINGS_FILE = path.join(HERE, '..', '..', '.copilot-settings.json'); /** Fields a client is allowed to change, with validation for each. */ const FIELDS = { provider: { validate: (v) => ['auto', 'openrouter', 'ollama', 'fallback'].includes(v), error: 'provider must be auto, openrouter, ollama or fallback', }, openRouterModel: { validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 200, error: 'openRouterModel must be a non-empty model id', }, ollamaModel: { validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 200, error: 'ollamaModel must be a non-empty model id', }, ollamaHost: { validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 300, error: 'ollamaHost must be a URL or host:port', }, temperature: { validate: (v) => Number.isFinite(v) && v >= 0 && v <= 2, error: 'temperature must be between 0 and 2', }, maxTokens: { validate: (v) => Number.isInteger(v) && v >= 128 && v <= 8000, error: 'maxTokens must be an integer between 128 and 8000', }, reasoningEffort: { validate: (v) => ['none', 'low', 'medium', 'high'].includes(v), error: 'reasoningEffort must be none, low, medium or high', }, }; export function defaultsFromEnv(env = process.env) { return { // 'auto' resolves by availability at detect() time. provider: (env.COPILOT_PROVIDER || 'auto').trim().toLowerCase(), openRouterModel: (env.OPENROUTER_MODEL || 'meta-llama/llama-3.3-70b-instruct').trim(), ollamaModel: (env.OLLAMA_MODEL || 'qwen3.5-4b-32k:latest').trim(), ollamaHost: (env.OLLAMA_HOST || 'http://127.0.0.1:11434').trim(), temperature: 0.3, maxTokens: 2000, // Reasoning tokens count against the output budget on most providers, so // 'low' keeps answers from being swallowed by a reasoning block and keeps // time-to-first-token usable in a live demo. reasoningEffort: 'low', }; } export function loadSettings(env = process.env) { const base = defaultsFromEnv(env); try { const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); for (const key of Object.keys(FIELDS)) { if (raw[key] !== undefined && FIELDS[key].validate(raw[key])) base[key] = raw[key]; } } catch { /* no saved settings yet, or unreadable - environment defaults stand */ } return base; } /** * Validate a patch. Returns { ok, errors, clean }. * * Unknown keys are rejected rather than ignored, so a typo in a field name fails * loudly instead of silently doing nothing. */ export function validatePatch(patch) { const errors = []; const clean = {}; for (const [key, value] of Object.entries(patch || {})) { const field = FIELDS[key]; if (!field) { errors.push(`unknown setting "${key}"`); continue; } if (!field.validate(value)) { errors.push(field.error); continue; } clean[key] = value; } return { ok: errors.length === 0, errors, clean }; } export function saveSettings(settings) { const out = {}; for (const key of Object.keys(FIELDS)) { if (settings[key] !== undefined) out[key] = settings[key]; } fs.writeFileSync(SETTINGS_FILE, `${JSON.stringify(out, null, 2)}\n`, 'utf8'); return out; } export function resetSettings() { try { fs.unlinkSync(SETTINGS_FILE); } catch { /* nothing saved - already at defaults */ } }