/** * The shared settings-PUT body parsers — one seam per settings surface. * Extracted verbatim from the per-chat route (default-settings wave, * 2026-09-18): identical parse, identical errors. The section parsers * (settings-storage wave, 2026-09-19) follow the same house rules. * * Replace-whole semantics: the client sends the complete desired SECTION; * absent fields are absent overrides ({} clears everything). No merges, so * a stale client can never resurrect a cleared override. Unknown body keys * are dropped (same tolerance as parseSettingsBody) — unknown DISK keys are * a different concern and are preserved by the sanitizer (core/settings.ts). */ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { isHexColor, isLanguage, isSttLanguage, type ChatSettings } from "../core"; import type { AgentSettings, AppearanceSettings, BackgroundModelRole, CompactionSettings, GeneralSettings, GraphSettings, ModelsSettings, RoleModelOverride, } from "../core/settings.ts"; import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; import { WRITABLE_TOOL_KEYS } from "../core/pending.ts"; /** Parsed-or-rejected — `ok: false` carries the 400's error message. */ export type SettingsBodyResult = | { ok: true; settings: T } | { ok: false; error: string }; export function parseSettingsBody(body: unknown): SettingsBodyResult { if (!body || typeof body !== "object" || Array.isArray(body)) { return { ok: false, error: "Invalid JSON body" }; } const raw = body as { model?: { provider?: unknown; id?: unknown }; thinkingLevel?: unknown; }; const settings: ChatSettings = {}; if (raw.model !== undefined) { if ( typeof raw.model !== "object" || raw.model === null || typeof raw.model.provider !== "string" || typeof raw.model.id !== "string" ) { return { ok: false, error: "model must be {provider, id}" }; } settings.model = { provider: raw.model.provider, id: raw.model.id }; } if (raw.thinkingLevel !== undefined) { if (typeof raw.thinkingLevel !== "string") { return { ok: false, error: "thinkingLevel must be a string" }; } settings.thinkingLevel = raw.thinkingLevel as ThinkingLevel; } // Wave 7 killed the graph-context injection (owner: zero configuration // beyond chat models — the prober reads its map through tools now); its // budget dial is gone with it. A stale client sending the old key has // it silently dropped here (forward-compatible, like any unknown key). return { ok: true, settings }; } // ─── Section parsers (settings-storage wave, 2026-09-19) ──────────────────── // One parser per settings.json section; the route maps the URL section to // its parser via SETTINGS_SECTIONS below. Same discipline as // parseSettingsBody: strict on every known field (a bad type/shape is a // 400, never a silent clamp/drop), unknown keys dropped. function asRecord(body: unknown): Record | null { if (!body || typeof body !== "object" || Array.isArray(body)) return null; return body as Record; } /** Independent interface/STT language choices and automatic updates. */ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; const settings: GeneralSettings = {}; if (raw.autoUpdate !== undefined) { if (typeof raw.autoUpdate !== "boolean") { return { ok: false, error: "autoUpdate must be a boolean" }; } settings.autoUpdate = raw.autoUpdate; } if (raw.language !== undefined) { if (!isLanguage(raw.language)) return { ok: false, error: 'language must be "en" or "ru"' }; settings.language = raw.language; } if (raw.sttLanguage !== undefined) { if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' }; settings.sttLanguage = raw.sttLanguage; } return { ok: true, settings }; } /** agent.models: only the six known background roles; each override's * fields mirror ChatSettings (model pair + independent thinkingLevel). */ function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } { const raw = asRecord(value); if (!raw) return { ok: false, error: `${role} must be an object` }; const override: RoleModelOverride = {}; if (raw.model !== undefined) { if ( typeof raw.model !== "object" || raw.model === null || typeof (raw.model as Record).provider !== "string" || typeof (raw.model as Record).id !== "string" ) { return { ok: false, error: `${role}.model must be {provider, id}` }; } override.model = { provider: (raw.model as { provider: string }).provider, id: (raw.model as { id: string }).id, }; } if (raw.thinkingLevel !== undefined) { if (typeof raw.thinkingLevel !== "string") { return { ok: false, error: `${role}.thinkingLevel must be a string` }; } override.thinkingLevel = raw.thinkingLevel as ThinkingLevel; } return { ok: true, override }; } /** A boolean map (tools / prompts switches): every value must be boolean. */ function parseBooleanMap(value: unknown, field: string): { map?: Record; error?: string } { if (value === undefined) return {}; const raw = asRecord(value); if (!raw) return { error: `${field} must be an object` }; const out: Record = {}; for (const [key, entry] of Object.entries(raw)) { if (typeof entry !== "boolean") return { error: `${field}.${key} must be a boolean` }; out[key] = entry; } return { map: out }; } /** agent: { models?, tools?, prompts? } — model/thinking validity against * the catalog is checked by the route (validateChatSettings discipline); * prompt switch-on membership is checked by the route (needs the store). */ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; const settings: AgentSettings = {}; if (raw.models !== undefined) { const models = asRecord(raw.models); if (!models) return { ok: false, error: "models must be an object" }; const known = BACKGROUND_MODEL_ROLES as readonly string[]; const parsed: NonNullable = {}; for (const [role, value] of Object.entries(models)) { if (!known.includes(role)) { return { ok: false, error: `unknown role "${role}" (supported: ${known.join(", ")})` }; } const override = parseRoleOverride(value, role); if (!override.ok) return { ok: false, error: override.error }; if (Object.keys(override.override).length) parsed[role as BackgroundModelRole] = override.override; } if (Object.keys(parsed).length) settings.models = parsed; } const tools = parseBooleanMap(raw.tools, "tools"); if (tools.error) return { ok: false, error: tools.error }; if (tools.map) { // Unknown tool keys are rejected at WRITE time (user decision // 2026-09-19): the delta is inert while unread, but once the bridge // honors it a persisted bogus key would drift. The writable set is // the job tools + the quizzing-mechanics switch (explain, owner // 2026-09-27). The read-side sanitizer stays lenient for // forward-compat. const supported = WRITABLE_TOOL_KEYS as readonly string[]; for (const key of Object.keys(tools.map)) { if (!supported.includes(key)) { return { ok: false, error: `unknown tool "${key}" (supported: ${supported.join(", ")})` }; } } settings.tools = tools.map; } const prompts = parseBooleanMap(raw.prompts, "prompts"); if (prompts.error) return { ok: false, error: prompts.error }; if (prompts.map) settings.prompts = prompts.map; if (raw.search !== undefined) { const search = asRecord(raw.search); if (!search) return { ok: false, error: "search must be an object" }; if (search.engine !== undefined) { if (search.engine !== "tavily" && search.engine !== "brave") { return { ok: false, error: 'search.engine must be "tavily" or "brave"' }; } settings.search = { engine: search.engine }; } } // Prober graph tools (wave 7, owner ruling 2026-09-23): an agent-section // subsection, not a tool-toggles key — the mechanism is agent capability // (tool registration), the subject is the graph. Strict write side: // object shape only; `enabled` boolean when present. Absent = ON. if (raw.graphTools !== undefined) { const graphTools = asRecord(raw.graphTools); if (!graphTools) return { ok: false, error: "graphTools must be an object" }; if (graphTools.enabled !== undefined && typeof graphTools.enabled !== "boolean") { return { ok: false, error: "graphTools.enabled must be a boolean" }; } settings.graphTools = graphTools.enabled === undefined ? {} : { enabled: graphTools.enabled }; } return { ok: true, settings }; } /** A hex-or-null color field (appearance colors): null = default. */ function parseHexOrNull(value: unknown, field: string): { color?: string | null; error?: string } { if (value === null) return { color: null }; if (typeof value !== "string" || !isHexColor(value)) { return { error: `${field} must be a #hex color or null` }; } return { color: value }; } /** appearance: { mode?, background?, fontFamily?, proseFontSize?, * sessionNameSerif?, mdRendering? } */ export function parseAppearanceSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; const settings: AppearanceSettings = {}; if (raw.mode !== undefined) { if (raw.mode !== "dark" && raw.mode !== "light") { return { ok: false, error: "mode must be \"dark\" or \"light\"" }; } settings.mode = raw.mode; } if (raw.background !== undefined) { const parsed = parseHexOrNull(raw.background, "background"); if (parsed.error) return { ok: false, error: parsed.error }; settings.background = parsed.color ?? null; } if (raw.fontFamily !== undefined) { if (raw.fontFamily !== null && (typeof raw.fontFamily !== "string" || !raw.fontFamily.trim())) { return { ok: false, error: "fontFamily must be a non-empty string or null" }; } settings.fontFamily = raw.fontFamily as string | null; } if (raw.proseFontSize !== undefined) { if ( typeof raw.proseFontSize !== "number" || !Number.isInteger(raw.proseFontSize) || raw.proseFontSize < 12 || raw.proseFontSize > 24 ) { return { ok: false, error: "proseFontSize must be an integer between 12 and 24" }; } settings.proseFontSize = raw.proseFontSize; } if (raw.sessionNameSerif !== undefined) { if (typeof raw.sessionNameSerif !== "boolean") { return { ok: false, error: "sessionNameSerif must be a boolean" }; } settings.sessionNameSerif = raw.sessionNameSerif; } if (raw.mdRendering !== undefined) { const md = asRecord(raw.mdRendering); if (!md) return { ok: false, error: "mdRendering must be an object" }; const rendering: NonNullable = {}; if (md.headingWeight !== undefined) { if ( typeof md.headingWeight !== "number" || !Number.isInteger(md.headingWeight) || md.headingWeight < 100 || md.headingWeight > 900 ) { return { ok: false, error: "mdRendering.headingWeight must be an integer between 100 and 900" }; } rendering.headingWeight = md.headingWeight; } if (md.boldColor !== undefined) { const parsed = parseHexOrNull(md.boldColor, "mdRendering.boldColor"); if (parsed.error) return { ok: false, error: parsed.error }; rendering.boldColor = parsed.color ?? null; } if (md.italicColor !== undefined) { const parsed = parseHexOrNull(md.italicColor, "mdRendering.italicColor"); if (parsed.error) return { ok: false, error: parsed.error }; rendering.italicColor = parsed.color ?? null; } if (Object.keys(rendering).length) settings.mdRendering = rendering; } return { ok: true, settings }; } /** graph: the collapsed section { enabled? } (wave 7, owner ruling * 2026-09-23): the master switch ONLY — the embedding/clustering/labeling * rungs and the BYOK embeddingModel died with the provider embedder * (clustering is the free birth-session partition, labels are session * names). Old-shape keys in a body are ignored, not rejected. */ export function parseGraphSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; // Wave 7 collapse (owner ruling 2026-09-23) + the extraction-UX // redesign (2026-09-24): the section is the master switch and the // chunk budget. PUT is replace-whole — a body like {enabled: true} IS // the whole section (extractionChunkTokens absent = factory default at // resolution); the dead rungs' keys in old records are unread (the // sanitizer drops them; see sanitizeGraph). const settings: GraphSettings = {}; if (raw.enabled !== undefined) { if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" }; settings.enabled = raw.enabled; } if (raw.rendering !== undefined) { if (typeof raw.rendering !== "boolean") return { ok: false, error: "rendering must be a boolean" }; settings.rendering = raw.rendering; } if (raw.extractionChunkTokens !== undefined) { if (typeof raw.extractionChunkTokens !== "number" || !Number.isFinite(raw.extractionChunkTokens)) { return { ok: false, error: "extractionChunkTokens must be a number" }; } const floored = Math.floor(raw.extractionChunkTokens); if (floored < EXTRACTION_CHUNK_TOKENS_FLOOR) { // the floor's rationale lives on GraphSettings.extractionChunkTokens: // every run pays the graph-carrying prompt overhead — penny-batches // cost more per extracted token than the content is worth. return { ok: false, error: `extractionChunkTokens must be at least ${EXTRACTION_CHUNK_TOKENS_FLOOR} (smaller batches waste the per-run graph overhead)`, }; } settings.extractionChunkTokens = floored; } return { ok: true, settings }; } /** models: { strong?, cheap?, vision? } — the slot tiers (model slots * wave, 2026-09-20). Each slot is the same shape as a role override; a * present-but-empty slot object is rejected (absent slot = unset; say * what you mean). Catalog validity is checked by the route * (validateChatSettings discipline). */ export function parseModelsSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; const settings: ModelsSettings = {}; for (const key of Object.keys(raw)) { const known = MODEL_SLOTS as readonly string[]; if (!known.includes(key)) { return { ok: false, error: `unknown slot "${key}" (supported: ${known.join(", ")})` }; } const parsed = parseRoleOverride(raw[key], key); if (!parsed.ok) return { ok: false, error: parsed.error }; // BYOK (owner, 2026-09-22): a slot IS a model choice — no factory // floor exists, so a thinkingLevel alone can never run; every slot // write must carry the model (the level is an optional rider). An // absent slot key clears the tier (replace-whole section). if (!parsed.override.model) { return { ok: false, error: `slot "${key}" must set a model (thinkingLevel alone cannot run — absent slot = unset)` }; } const value = asRecord(raw[key])!; if (value.fastMode !== undefined && typeof value.fastMode !== "boolean") { return { ok: false, error: `${key}.fastMode must be a boolean` }; } if (value.fastMode === true && parsed.override.model.provider !== "chatgpt") { return { ok: false, error: "Fast mode is available only for ChatGPT models." }; } settings[key as (typeof MODEL_SLOTS)[number]] = { ...parsed.override, ...(typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}), }; } return { ok: true, settings }; } /** compaction (owner round 14): the context-compaction threshold — the * projected-token budget over which a session summarizes its own history * into the compaction marker. Strict write side: a present * thresholdTokens must be a number at or above the floor (the floor is * anti-churn: each compaction keeps a ~20k-token recent tail, so a * smaller threshold would re-summarize the tail it just wrote). Absent = * factory default 150k at resolution. Replace-whole, like every * section. */ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; const settings: CompactionSettings = {}; if (raw.thresholdTokens !== undefined) { if (typeof raw.thresholdTokens !== "number" || !Number.isFinite(raw.thresholdTokens)) { return { ok: false, error: "thresholdTokens must be a number" }; } const floored = Math.floor(raw.thresholdTokens); if (floored < COMPACTION_THRESHOLD_FLOOR) { return { ok: false, error: `thresholdTokens must be at least ${COMPACTION_THRESHOLD_FLOOR} (compaction keeps a ~20k-token recent tail — smaller budgets would churn it)`, }; } settings.thresholdTokens = floored; } return { ok: true, settings }; } /** The section→parser map for PUT /api/settings/[section] (the route also * carries the special prompts sub-resource). */ export const SETTINGS_SECTIONS = { general: parseGeneralSettingsBody, chat: parseSettingsBody, agent: parseAgentSettingsBody, appearance: parseAppearanceSettingsBody, graph: parseGraphSettingsBody, compaction: parseCompactionSettingsBody, models: parseModelsSettingsBody, } as const; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;