diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts new file mode 100644 index 0000000..e037eea --- /dev/null +++ b/app/api/settings/[section]/route.ts @@ -0,0 +1,86 @@ +/** + * PUT /api/settings/[section] — section-scoped record writes + * (settings-storage wave, 2026-09-19). One PUT replaces ONE section + * replace-whole (absent fields = absent overrides — the house settings + * discipline, same as the per-chat and defaults routes); sections are + * independent, so partial writes can never clobber each other. + * + * section ∈ general | chat | agent | appearance | graph (else 404). + * Body: the complete desired section object. Parse + validate strictly + * (server/settings-body.ts): a bad shape/type is a 400 carrying the + * supported list where one exists, never a silent clamp. The chat + * section additionally passes the catalog gate (validateChatSettings — + * unknown provider/model or unsupported thinking level = 400), same as + * the defaults route. The agent section additionally validates prompt + * switches: an ON switch must reference a CURRENT source segment with an + * existing override file (an OFF switch may reference anything — users + * cleaning up orphans must not be blocked). + * + * The static siblings (defaults, prompts) take precedence over this + * dynamic segment — Next resolves static routes first, so "prompts" never + * lands here. + */ + +import type { AgentSettings, ChatSettings } from "@/src/core"; +import { + SETTINGS_SECTIONS, + allSegmentPaths, + promptOverrides, + readSettingsRecord, + validateChatSettings, + writeSettingsRecord, + type SettingsBodyResult, +} from "@/src/server"; + +export const dynamic = "force-dynamic"; + +/** One parser, result type erased to the common envelope. */ +type AnySectionParser = (body: unknown) => SettingsBodyResult; + +export async function PUT(request: Request, ctx: { params: Promise<{ section: string }> }) { + const { section } = await ctx.params; + const parser = (SETTINGS_SECTIONS as Record)[section]; + if (!parser) { + return Response.json( + { error: `unknown settings section "${section}"`, supported: Object.keys(SETTINGS_SECTIONS) }, + { status: 404 }, + ); + } + const body = await request.json().catch(() => null); + const parsed = parser(body); + if (!parsed.ok) { + return Response.json({ error: parsed.error }, { status: 400 }); + } + + // Section-specific cross-validation (against the catalog / the prompt + // store) — the parsers only see shapes. + if (section === "chat") { + const verdict = validateChatSettings(parsed.settings as ChatSettings); + if (!verdict.ok) { + return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); + } + } + if (section === "agent") { + const agent = parsed.settings as AgentSettings; + const segments = new Set(allSegmentPaths()); + for (const [path, on] of Object.entries(agent.prompts ?? {})) { + if (on && (!segments.has(path) || promptOverrides.readOverride(path) === null)) { + return Response.json( + { + error: `prompt switch for "${path}" cannot be on: ${ + segments.has(path) ? "no override file exists" : "not a current source segment" + }`, + }, + { status: 400 }, + ); + } + } + } + + // Read-modify-write ONE section of the record; everything else rides + // along untouched (other sections, unknown keys — sanitize preserves). + const record = readSettingsRecord() ?? { version: 1 }; + const next = { ...record, [section]: parsed.settings }; + writeSettingsRecord(next); + return Response.json({ settings: next }); +} diff --git a/app/api/settings/prompts/[...segment]/route.ts b/app/api/settings/prompts/[...segment]/route.ts new file mode 100644 index 0000000..574a93c --- /dev/null +++ b/app/api/settings/prompts/[...segment]/route.ts @@ -0,0 +1,54 @@ +/** + * PUT/DELETE /api/settings/prompts/[...segment] — the override TEXT for + * one prompt segment (settings-storage wave, 2026-09-19). + * + * PUT body {text} → writes data/prompts/. 200. + * The segment must be a CURRENT source segment (allSegmentPaths + * — derived from src/agent/prompts at runtime): overriding a + * segment that doesn't compose is a typo or a vanished fragment, + * a 400, never a silent orphan. + * DELETE → removes the override file (idempotent). Switches are NOT + * touched — an on-switch with no file composes the source + * default (inert by design), so the user's switch state survives + * a delete/re-write cycle. + * + * The ON/OFF switch itself lives in the record's agent.prompts map + * (PUT /api/settings/agent) — file presence alone is never the override. + */ + +import { allSegmentPaths, isValidSegmentPath, promptOverrides } from "@/src/server"; + +export const dynamic = "force-dynamic"; + +/** Re-join the catch-all segments into the mirror-layout path; null when + * the shape is invalid (traversal, wrong layout — the store re-guards). */ +function segmentPathFrom(segments: string[]): string | null { + const joined = segments.map(decodeURIComponent).join("/"); + return isValidSegmentPath(joined) ? joined : null; +} + +export async function PUT(request: Request, ctx: { params: Promise<{ segment: string[] }> }) { + const { segment } = await ctx.params; + const path = segmentPathFrom(segment); + if (!path) return Response.json({ error: "invalid segment path" }, { status: 400 }); + const body = (await request.json().catch(() => null)) as { text?: unknown } | null; + if (!body || typeof body.text !== "string") { + return Response.json({ error: "body must be {text: string}" }, { status: 400 }); + } + if (!new Set(allSegmentPaths()).has(path)) { + return Response.json( + { error: `"${path}" is not a current source segment`, supported: allSegmentPaths() }, + { status: 400 }, + ); + } + promptOverrides.writeOverride(path, body.text); + return Response.json({ path, text: body.text }); +} + +export async function DELETE(_request: Request, ctx: { params: Promise<{ segment: string[] }> }) { + const { segment } = await ctx.params; + const path = segmentPathFrom(segment); + if (!path) return Response.json({ error: "invalid segment path" }, { status: 400 }); + promptOverrides.deleteOverride(path); + return Response.json({ path, deleted: true }); +} diff --git a/app/api/settings/prompts/route.ts b/app/api/settings/prompts/route.ts new file mode 100644 index 0000000..cab3b6e --- /dev/null +++ b/app/api/settings/prompts/route.ts @@ -0,0 +1,29 @@ +/** + * GET /api/settings/prompts — the system-prompt segment view + * (settings-storage wave, 2026-09-19): the effective prompt segments for + * BOTH agents, derived from SOURCE at runtime (src/agent/prompts — the + * list adapts when the code ships/renames/removes fragments), each with + * its source text, override text (if a file exists), and effective switch + * state (settings record's agent.prompts map AND file presence). + * + * GET → 200 {prober: SegmentInfo[], teacher: SegmentInfo[]} + * SegmentInfo = {path, sourceText, overrideText, enabled, shared} + * + * The shared base appears in BOTH lists under the SAME path + * ("system-prompt.md") — the prompt-editor's "what's constant between the + * two agents" is discoverable by path equality. Writes: the switches go + * through PUT /api/settings/agent; the TEXT goes through + * PUT/DELETE /api/settings/prompts/[...segment]. + */ + +import { effectivePromptSegments, readSettingsRecord } from "@/src/server"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const switches = readSettingsRecord()?.agent?.prompts ?? {}; + return Response.json({ + prober: effectivePromptSegments("prober", switches), + teacher: effectivePromptSegments("teacher", switches), + }); +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..348ee5c --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,23 @@ +/** + * GET /api/settings — THE user settings record (settings-storage wave, + * 2026-09-19): the whole delta record at data/settings.json (config. + * SETTINGS_FILE, via the SettingsStore port). Non-secret by construction — + * secrets live in auth.json / .env.local and never enter this record. + * + * GET → 200 {settings} (the sanitized delta record — absent keys mean + * "factory default", never guessed server-side) or 200 + * {settings: null} on first boot. + * + * Section writes go through PUT /api/settings/[section]; the chat defaults + * also remain writable via the legacy /api/settings/defaults alias. + * Resolution to concrete values is the client's/core's job + * (core/settings-resolution.ts resolveSettings — browser-safe). + */ + +import { settingsStore } from "@/src/server"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return Response.json({ settings: settingsStore.readSettings() }); +} diff --git a/config.ts b/config.ts index 2ef9c7b..c027120 100644 --- a/config.ts +++ b/config.ts @@ -149,3 +149,36 @@ export const RENDER_SETTINGS_FILE = path.join(PATHS.graph, "render-settings.json * read-modify-write, written only through app/api/settings/defaults. */ export const DEFAULT_SETTINGS_FILE = path.join(DATA_ROOT, "default-settings.json"); + +/** + * THE USER SETTINGS RECORD (settings-storage wave, 2026-09-19): one + * versioned, DELTA-shaped JSON blob at the data-dir seam holding every + * non-secret product preference — general, chat defaults, agent (background + * model/thinking overrides + tool toggles + prompt-override switches), + * appearance, graph. Explicit user choices only: ABSENT keys resolve to the + * factory defaults compiled into src/core/settings.ts (FACTORY_SETTINGS), so + * a software update that adds a knob (new tool, new role, new appearance + * field) needs no migration — old records simply lack the new keys. The + * version field is a PRIVATE schema counter for breaking shape changes + * only (field renamed/meaning changed), unrelated to the app release + * version. Secrets (API keys, login tokens) NEVER live here — auth.json / + * .env.local keep that role. Migrated FROM: default-settings.json folds + * into the record's `chat` section on first read (the old file is left in + * place, inert). + */ +export const SETTINGS_FILE = path.join(DATA_ROOT, "settings.json"); + +/** + * User prompt-override files (settings-storage wave, 2026-09-19): the + * per-segment system-prompt overrides mirroring the SOURCE layout + * (src/agent/prompts/) — the shared base as prompts/system-prompt.md, + * fragments as prompts/teacher/ / prompts/prober/. File + * PRESENCE alone is not the override: settings.json's agent.prompts map + * (segmentPath → boolean) is the explicit switch, so a user can keep a + * customized file on disk but run the source default to compare. The + * segment list itself is ALWAYS derived from source at runtime (the + * loader in src/store/prompt-overrides.ts) — code updates that add, + * remove, or reorder fragments adapt automatically; an override keyed to + * a vanished segment stops composing but stays on disk. + */ +export const PROMPTS_OVERRIDE_DIR = path.join(DATA_ROOT, "prompts"); diff --git a/src/core/index.ts b/src/core/index.ts index e9af3aa..b7d7cdf 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -34,6 +34,11 @@ export * from "./ui-message.ts"; export * from "./tool-parts.ts"; export * from "./prices.ts"; export * from "./fuzzy.ts"; +// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19): +// the user-settings record's shape/sanitizer (pure) and its fully-resolved +// view. Browser-safe — the Wave-4 panel imports these directly. +export * from "./settings.ts"; +export * from "./settings-resolution.ts"; // keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe). export * from "./keyboard.ts"; export * from "./coverage.ts"; diff --git a/src/core/ports.ts b/src/core/ports.ts index 4dee803..079d643 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -30,6 +30,7 @@ import type { ClusterLabeling, EmbeddingsLedger, } from "./contracts.ts"; +import type { UserSettings } from "./settings.ts"; // ─── ChatStore — the session record's home (data/chats) ───────────────────── @@ -148,3 +149,44 @@ export interface JobStore { /** The job fulfilling a given tool part, if any (spawn-hook dedup). */ findJobByToolCallId(chatId: string, toolCallId: string): JobRecord | null; } + +// ─── SettingsStore — THE user settings record (settings-storage wave, 2026-09-19) ── + +/** + * The one record for every non-secret product preference (core/settings.ts + * UserSettings — delta semantics, see that module's doc). The store is + * replace-whole on the RECORD; per-section scoping is a server-band concern + * (single process, no stale-client hazard to defend against at this seam). + * Lazy migration (default-settings.json → the record's chat section, schema + * version bumps) happens inside readSettings, never in the callers. + */ +export interface SettingsStore { + /** The sanitized record, or null when none exists yet (first boot). */ + readSettings(): UserSettings | null; + /** Persist the record (replace-whole). Callers pass a SANITIZED record. */ + writeSettings(settings: UserSettings): void; +} + +// ─── PromptOverrideStore — per-segment system-prompt overrides (2026-09-19) ── + +/** + * User text overriding ONE source prompt segment (the shared base or one + * numeric fragment), stored at the data-dir seam mirroring the source + * layout (config.PROMPTS_OVERRIDE_DIR). File presence is NOT the override: + * the settings record's agent.prompts switch is the explicit on/off, so a + * user can keep a customized file on disk while running the source default. + * The segment LIST is always derived from source at runtime — see the + * adapter (src/store/prompt-overrides.ts) for the loader rules. + */ +export interface PromptOverrideStore { + /** Every override path currently on disk ("system-prompt.md", + * "teacher/20-teaching.md", ...), POSIX-relative, sorted. */ + listOverrides(): string[]; + /** The override text, or null when the path has no file. */ + readOverride(segmentPath: string): string | null; + /** Persist override text for a segment path (creates parents). */ + writeOverride(segmentPath: string, text: string): void; + /** Remove the override file, if present (switches are untouched — an + * on-switch with no file composes the source default, inert). */ + deleteOverride(segmentPath: string): void; +} diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts new file mode 100644 index 0000000..2711f26 --- /dev/null +++ b/src/core/settings-resolution.ts @@ -0,0 +1,149 @@ +/** + * THE SETTINGS RESOLUTION LAYER (settings-storage wave, 2026-09-19): pure + * projection of the delta record (core/settings.ts UserSettings) into + * EffectiveSettings — every field CONCRETE, ready for consumers with no + * delta-walking of their own. + * + * Resolution rule (owner decision): explicit value in the record wins; + * absent key resolves to the compiled factory default. Model-role defaults + * come from config.ts's MODELS, threaded in as a parameter — this module + * must stay browser-safe/pure, so it imports config TYPES only, never the + * runtime module (config.ts pulls node:path). The composition root + * (src/server/stores.ts) owns the glue: effectiveSettings() reads the + * record through the store and passes MODELS here. + * + * Deliberately NOT resolved here (see settings.ts module doc): + * - per-chat overrides: the chat record's ChatSettings applies via + * piEngineFor(role, chatRecord.settings) exactly as before — prober and + * teacher stay chat-controlled, never settings-record-controlled; + * - tool toggles: passed through as the delta map; consumers look up by + * tool id with absent → enabled (the honoring lives in the agent band); + * - prompt switches: passed through; effective composition lives in + * src/store/prompt-overrides.ts (segment list derived from source). + */ + +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { ChatSettings } from "./contracts/chat.ts"; +import { + BACKGROUND_MODEL_ROLES, + FACTORY_SETTINGS, + type AgentSettings, + type BackgroundModelRole, + type FactorySettings, + type GeneralSettings, + type GraphSettings, + type RoleModelOverride, + type UserSettings, +} from "./settings.ts"; + +/** The factory layer for model roles — config.ts's MODELS shape (types only + * here; the runtime object is threaded in by the caller). */ +export type ModelRoleSpecs = Record; + +/** A fully-resolved model pin for one background role. */ +export interface EffectiveModelSpec { + provider: string; + id: string; + thinkingLevel: ThinkingLevel; +} + +/** The concrete appearance view — same shape as the factory's. */ +export type EffectiveAppearance = FactorySettings["appearance"]; + +/** The concrete view: every knob resolved, no absent fields. */ +export interface EffectiveSettings { + general: Required; + /** The default ChatSettings for new sessions — passthrough delta + * ({} = no default = BYOK "no model" first boot). */ + chat: ChatSettings; + agent: { + models: Record; + /** Delta map as stored — consumers resolve absent key → enabled. */ + tools: Record; + /** Delta map as stored — absent key → override off. */ + prompts: Record; + }; + appearance: EffectiveAppearance; + graph: Required; +} + +/** Resolve one background role's spec: record override per field, + * config.ts pin per absent field (provider pinned to openrouter today). */ +export function resolveModelSpec( + role: BackgroundModelRole, + override: RoleModelOverride | undefined, + factory: ModelRoleSpecs, +): EffectiveModelSpec { + const spec = factory[role]; + const model = override?.model; + return { + provider: model?.provider ?? "openrouter", + id: model?.id ?? spec.model, + thinkingLevel: override?.thinkingLevel ?? spec.effort, + }; +} + +/** Resolve the whole record. `record` may be null (first boot) — every + * field then lands on its factory default. */ +export function resolveSettings(record: UserSettings | null, factory: ModelRoleSpecs): EffectiveSettings { + const agent = record?.agent ?? {}; + const appearance = record?.appearance ?? {}; + const md = appearance.mdRendering ?? {}; + const graph = record?.graph ?? {}; + const factoryAppearance = FACTORY_SETTINGS.appearance; + const factoryMd = factoryAppearance.mdRendering; + const models = {} as Record; + for (const role of BACKGROUND_MODEL_ROLES) { + models[role] = resolveModelSpec(role, agent.models?.[role], factory); + } + return { + general: { + autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate, + }, + chat: record?.chat ?? {}, + agent: { + models, + tools: agent.tools ?? {}, + prompts: agent.prompts ?? {}, + }, + appearance: { + mode: appearance.mode ?? factoryAppearance.mode, + background: appearance.background ?? factoryAppearance.background, + fontFamily: appearance.fontFamily ?? factoryAppearance.fontFamily, + proseFontSize: appearance.proseFontSize ?? factoryAppearance.proseFontSize, + sessionNameSerif: appearance.sessionNameSerif ?? factoryAppearance.sessionNameSerif, + mdRendering: { + headingWeight: md.headingWeight ?? factoryMd.headingWeight, + boldColor: md.boldColor ?? factoryMd.boldColor, + italicColor: md.italicColor ?? factoryMd.italicColor, + }, + }, + graph: { + enabled: graph.enabled ?? FACTORY_SETTINGS.graph.enabled, + embedding: graph.embedding ?? FACTORY_SETTINGS.graph.embedding, + clustering: graph.clustering ?? FACTORY_SETTINGS.graph.clustering, + labeling: graph.labeling ?? FACTORY_SETTINGS.graph.labeling, + }, + }; +} + +// ─── Graph channel gates (the settings wave's seam honoring) ───────────────── + +/** + * The WRITE channel gate (extraction + its auditor): dies with the master + * switch. Used by the chat-handler's post-turn trigger; deeper pipeline + * consumers (embedder, clustering, labeling — later waves) gate on the + * deeper rungs the same way. + */ +export function extractionEnabled(graph: Required): boolean { + return graph.enabled; +} + +/** + * The READ channel gate (graph-context injection): needs the master switch + * AND the embedding rung — injection is a vector query, so a graph without + * embeddings has nothing to inject. + */ +export function graphContextEnabled(graph: Required): boolean { + return graph.enabled && graph.embedding; +} diff --git a/src/core/settings.ts b/src/core/settings.ts new file mode 100644 index 0000000..721cd0a --- /dev/null +++ b/src/core/settings.ts @@ -0,0 +1,344 @@ +/** + * THE USER SETTINGS RECORD's shape + sanitizer (settings-storage wave, + * 2026-09-19): one versioned, DELTA-shaped record at the data-dir seam + * (config.SETTINGS_FILE) holding every non-secret product preference. + * + * DELTA SEMANTICS (owner decision, 2026-09-19): the record stores EXPLICIT + * user choices only — ABSENT keys resolve to the factory defaults compiled + * into this module (FACTORY_SETTINGS) and config.ts's MODELS (the compiled + * factory layer for model roles). A software update that ADDS a knob (new + * tool, new role, new appearance field) therefore needs no migration: old + * records simply lack the new keys. `version` is a PRIVATE schema counter + * for BREAKING shape changes only (field renamed / meaning changed) — + * unrelated to the app release version. Migrations live in the adapter + * (src/store/settings.ts), not here. + * + * Sanitizer contract (mirrors render-settings.ts discipline): whatever + * arrives from disk or the network passes through sanitizeUserSettings + * before use — never trusted raw. Strict on known fields, PRESERVES + * unknown keys verbatim (a newer record must never be destroyed by an + * older app reading it). Invalid known values degrade gracefully: + * out-of-range numbers CLAMP, wrong-typed/invalid fields DROP (resolve to + * factory default), never throw. + * + * Browser-safe: pure logic + types only, no node imports. The resolved + * (fully concrete) view lives in settings-resolution.ts. + */ + +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { ChatSettings } from "./contracts/chat.ts"; + +/** The schema version this code understands. Bump ONLY for breaking shape + * changes (a field renamed/meaning changed); pure additions never bump. */ +export const CURRENT_SETTINGS_VERSION = 1; + +/** The background-agent roles whose models the Agent settings expose. + * Derived from config.ts's MODELS minus the conversation pair — prober and + * teacher stay chat-settings-controlled (one controller, the model picker). + * Keep in sync with config.ts's MODELS (tests pin the set). */ +export const BACKGROUND_MODEL_ROLES = [ + "researcher", + "demoBuilder", + "visualMaker", + "extractor", + "auditor", + "labeler", +] as const; + +export type BackgroundModelRole = (typeof BACKGROUND_MODEL_ROLES)[number]; + +/** A per-role model/thinking override — every field independent (absent + * field = that aspect falls back to config.ts's pin for the role). */ +export interface RoleModelOverride { + /** The model swap: both coordinates or neither (mirrors ChatSettings.model). */ + model?: { provider: string; id: string }; + /** The thinking budget swap. */ + thinkingLevel?: ThinkingLevel; +} + +/** General settings — app-level behavior. Version DISPLAY and plans login + * live outside the record (package.json / auth.json) by owner decision. */ +export interface GeneralSettings { + /** Auto-update the app (default ON). Mechanism ships in a later wave. */ + autoUpdate?: boolean; +} + +/** Agent settings — everything about the non-conversation machinery. */ +export interface AgentSettings { + /** Background-role model/thinking overrides (absent role = config pin). */ + models?: Partial>; + /** Tool toggles keyed by tool id (absent key = enabled). User-level + * only — no per-chat override (owner decision 2026-09-19). */ + tools?: Record; + /** System-prompt override SWITCHES keyed by segment path (absent key = + * off). Switch-on requires the override file to exist (enforced by the + * route); the switch is explicit so users keep a file on disk but run + * the source default to compare (owner decision 2026-09-19). */ + prompts?: Record; +} + +/** Markdown-rendering knobs — the bare-bones set (owner decision). */ +export interface MdRenderingSettings { + /** Heading font weight (100–900, default 900). CSS resolves the nearest + * available weight of the chosen font — no font introspection needed. */ + headingWeight?: number; + /** Color for **bold** spans (default #ffffff). */ + boldColor?: string | null; + /** Color for *italic* spans (default #ffffff). */ + italicColor?: string | null; +} + +/** Appearance settings — mode, colors, type, markdown emphasis. */ +export interface AppearanceSettings { + /** "dark" | "light" (default dark). */ + mode?: "dark" | "light"; + /** The one user-chosen color; everything else derives per-mode in code. + * null = the mode's default background. */ + background?: string | null; + /** The main reading font (everything that is Source Sans 3 today), by + * name — user-chosen from installed fonts. null = Source Sans 3. */ + fontFamily?: string | null; + /** Reading font size in px (12–24, default 16). Headings render as + * multiples of this, so one knob scales the document coherently. */ + proseFontSize?: number; + /** Session names in CMU Serif (default true — today's look). */ + sessionNameSerif?: boolean; + mdRendering?: MdRenderingSettings; +} + +/** Knowledge-graph settings — a strict dependency ladder (owner decision + * 2026-09-19): graph → embedding → clustering → labeling, each rung only + * meaningful when its parent is on (the UI grays dependents out; stored + * values persist but resolve inert). All default true. */ +export interface GraphSettings { + /** MASTER: off = no extraction, no auditor, no embedding, no + * graph-context injection, no clustering. Data stays stored. */ + enabled?: boolean; + /** Vector embedding + graph-context injection (consumes vectors even + * without clustering — hence its own rung). */ + embedding?: boolean; + /** Cluster computation + settle (needs embedding). */ + clustering?: boolean; + /** The LLM cluster-namer (needs clustering). */ + labeling?: boolean; +} + +/** THE record. Every section optional (delta); unknown top-level keys are + * preserved verbatim by the sanitizer (forward-compat with newer records). */ +export interface UserSettings { + version: number; + general?: GeneralSettings; + /** Today's default-settings.json content — the new-session surfaces' + * controls. NOT shown in the settings panel (owner decision). */ + chat?: ChatSettings; + agent?: AgentSettings; + appearance?: AppearanceSettings; + graph?: GraphSettings; + [key: string]: unknown; +} + +/** Factory defaults for everything core can pin (owner decisions 2026-09-19). + * Model-role defaults live in config.ts's MODELS (the compiled factory + * layer); the chat section has NO factory model (BYOK first boot doctrine + * — "no model" until the user picks one); tools default on; prompts + * default off — hence their absence here. */ +export interface FactorySettings { + version: number; + general: Required; + appearance: { + mode: "dark" | "light"; + background: string | null; + fontFamily: string | null; + proseFontSize: number; + sessionNameSerif: boolean; + mdRendering: { + headingWeight: number; + boldColor: string | null; + italicColor: string | null; + }; + }; + graph: Required; +} + +export const FACTORY_SETTINGS: FactorySettings = { + version: CURRENT_SETTINGS_VERSION, + general: { autoUpdate: true }, + appearance: { + mode: "dark", + background: null, // the dark default (#141414) lives in the mode's derivation curve + fontFamily: null, // Source Sans 3 + proseFontSize: 16, + sessionNameSerif: true, + mdRendering: { headingWeight: 900, boldColor: null, italicColor: null }, + }, + graph: { enabled: true, embedding: true, clustering: true, labeling: true }, +}; + +// ─── Sanitizer ───────────────────────────────────────────────────────────── + +const PROSE_FONT_MIN = 12; +const PROSE_FONT_MAX = 24; + +const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); + +/** #rgb / #rrggbb (case-insensitive) — the only color format we accept. */ +export function isHexColor(value: string): boolean { + return /^#[0-9a-fA-F]{3}$/.test(value) || /^#[0-9a-fA-F]{6}$/.test(value); +} + +/** Coerce an arbitrary hex-or-null field: null and valid hex pass through, + * anything else becomes null (the factory/per-mode default). */ +function sanitizeHexOrNull(value: unknown): string | null { + if (value === null) return null; + if (typeof value === "string" && isHexColor(value)) return value; + return null; +} + +const boolOrDrop = (value: unknown): boolean | undefined => + typeof value === "boolean" ? value : undefined; + +function sanitizeGeneral(raw: Record): GeneralSettings { + const out: GeneralSettings = {}; + const autoUpdate = boolOrDrop(raw.autoUpdate); + if (autoUpdate !== undefined) out.autoUpdate = autoUpdate; + return out; +} + +const THINKING_LEVELS: readonly ThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]; + +function sanitizeRoleOverride(raw: unknown): RoleModelOverride | undefined { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const r = raw as Record; + const out: RoleModelOverride = {}; + if ( + typeof r.model === "object" && + r.model !== null && + !Array.isArray(r.model) && + typeof (r.model as Record).provider === "string" && + typeof (r.model as Record).id === "string" + ) { + out.model = { + provider: (r.model as { provider: string }).provider, + id: (r.model as { id: string }).id, + }; + } + if (typeof r.thinkingLevel === "string" && THINKING_LEVELS.includes(r.thinkingLevel as ThinkingLevel)) { + out.thinkingLevel = r.thinkingLevel as ThinkingLevel; + } + return Object.keys(out).length ? out : undefined; +} + +function sanitizeBooleanMap(raw: unknown): Record | undefined { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof value === "boolean") out[key] = value; + } + return Object.keys(out).length ? out : undefined; +} + +function sanitizeAgent(raw: Record): AgentSettings { + const out: AgentSettings = {}; + if (raw.models !== null && typeof raw.models === "object" && !Array.isArray(raw.models)) { + const models: Partial> = {}; + for (const [role, override] of Object.entries(raw.models as Record)) { + if ((BACKGROUND_MODEL_ROLES as readonly string[]).includes(role)) { + const cleaned = sanitizeRoleOverride(override); + if (cleaned) models[role as BackgroundModelRole] = cleaned; + } + } + if (Object.keys(models).length) out.models = models; + } + const tools = sanitizeBooleanMap(raw.tools); + if (tools) out.tools = tools; + const prompts = sanitizeBooleanMap(raw.prompts); + if (prompts) out.prompts = prompts; + return out; +} + +function sanitizeMdRendering(raw: unknown): MdRenderingSettings { + const out: MdRenderingSettings = {}; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return out; + const r = raw as Record; + if (typeof r.headingWeight === "number" && Number.isFinite(r.headingWeight)) { + out.headingWeight = clamp(Math.round(r.headingWeight), 100, 900); + } + if (r.boldColor !== undefined) out.boldColor = sanitizeHexOrNull(r.boldColor); + if (r.italicColor !== undefined) out.italicColor = sanitizeHexOrNull(r.italicColor); + return out; +} + +function sanitizeAppearance(raw: Record): AppearanceSettings { + const out: AppearanceSettings = {}; + if (raw.mode === "dark" || raw.mode === "light") out.mode = raw.mode; + if (raw.background !== undefined) out.background = sanitizeHexOrNull(raw.background); + if (raw.fontFamily !== undefined) { + out.fontFamily = typeof raw.fontFamily === "string" && raw.fontFamily.trim() ? raw.fontFamily : null; + } + if (typeof raw.proseFontSize === "number" && Number.isFinite(raw.proseFontSize)) { + out.proseFontSize = clamp(Math.round(raw.proseFontSize), PROSE_FONT_MIN, PROSE_FONT_MAX); + } + const sessionNameSerif = boolOrDrop(raw.sessionNameSerif); + if (sessionNameSerif !== undefined) out.sessionNameSerif = sessionNameSerif; + if (raw.mdRendering !== undefined) out.mdRendering = sanitizeMdRendering(raw.mdRendering); + return out; +} + +function sanitizeGraph(raw: Record): GraphSettings { + const out: GraphSettings = {}; + const enabled = boolOrDrop(raw.enabled); + if (enabled !== undefined) out.enabled = enabled; + const embedding = boolOrDrop(raw.embedding); + if (embedding !== undefined) out.embedding = embedding; + const clustering = boolOrDrop(raw.clustering); + if (clustering !== undefined) out.clustering = clustering; + const labeling = boolOrDrop(raw.labeling); + if (labeling !== undefined) out.labeling = labeling; + return out; +} + +/** + * Coerce arbitrary JSON (disk file, network body) into a valid delta record: + * known fields validated strictly (invalid ones drop/clamp per module doc), + * UNKNOWN KEYS PRESERVED VERBATIM (a newer record must survive an older + * app). Never throws. Non-objects → null. + */ +export function sanitizeUserSettings(raw: unknown): UserSettings | null { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; + const r = raw as Record; + const out: UserSettings = { + version: typeof r.version === "number" && Number.isInteger(r.version) && r.version >= 1 ? r.version : CURRENT_SETTINGS_VERSION, + }; + if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) { + out.general = sanitizeGeneral(r.general as Record); + } + if (r.chat !== null && typeof r.chat === "object" && !Array.isArray(r.chat)) { + // The chat section is ChatSettings — its fields are plain and the + // section is written exclusively through the validated route + // (parseSettingsBody + validateChatSettings), so pass through as-is; + // a hand-corrupted value fails loudly downstream, same as today. + out.chat = r.chat as ChatSettings; + } + if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) { + out.agent = sanitizeAgent(r.agent as Record); + } + if (r.appearance !== null && typeof r.appearance === "object" && !Array.isArray(r.appearance)) { + out.appearance = sanitizeAppearance(r.appearance as Record); + } + if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) { + out.graph = sanitizeGraph(r.graph as Record); + } + // Unknown top-level keys ride along untouched (forward-compat). + for (const [key, value] of Object.entries(r)) { + if (!(key in out)) out[key] = value; + } + return out; +} diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index e2fb63d..7a7a4ab 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -1,6 +1,6 @@ import type { UIMessage } from "ai"; import type { Usage } from "@earendil-works/pi-ai"; -import { createProberAgent, createTeacherAgent, type TeachingAgent } from "../agent/teacher-agent.ts"; +import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts"; import { piEngineFor } from "../agent/models.ts"; import { createJobRunner } from "../agent/subagents/runner.ts"; import { createExtractionPipeline } from "../agent/subagents/extraction.ts"; @@ -10,7 +10,8 @@ import type { ModelRole } from "../../config.ts"; import { claimSteers, deliverJobResult, setContextRespawn, setJobRespawn, setTurnRunner } from "./jobs.ts"; import { recordToPiMessages } from "./pi/record-adapter.ts"; import { createPiTurnResponse } from "./pi/ui-stream.ts"; -import { stores } from "./stores.ts"; +import { effectiveSettings, stores } from "./stores.ts"; +import { extractionEnabled, graphContextEnabled } from "../core/settings-resolution.ts"; import { beginTurn, endTurn } from "./turn-liveness.ts"; import { persistTurn, triggerExtraction, triggerGraphContext, withMessageIds } from "./turn-service.ts"; import type { CallUsage } from "../core/contracts.ts"; @@ -137,6 +138,12 @@ export async function handleChatRequest( steers.length > 0 ? [...(body.messages as UIMessage[]), ...steers] : body.messages ) as UIMessage[]; + // THE SETTINGS VIEW (settings-storage wave, 2026-09-19): resolved fresh + // per turn — graph gates, tool toggles, and (via the store) the record's + // chat defaults all read from ONE small file. No caching: a stale toggle + // after a settings PUT would be worse than the read. + const effective = effectiveSettings(); + // Write-ahead persistence: the request body IS the client's canonical // state, so it goes on disk BEFORE the stream starts. The learner's answer // is durable the instant the request arrives — any remount, second tab, or @@ -161,7 +168,12 @@ export async function handleChatRequest( // runner module; for every session past its start this is a no-op. // Extraction stays in onFinish by contrast: it depends on quiz // answers accumulated DURING the turn. - triggerGraphContext(graphContext, chatId); + // GRAPH GATES (settings-storage wave, 2026-09-19): the read channel + // needs BOTH the master switch and the embedding rung (injection is a + // vector query); with graph off there is no context job at all. + if (graphContextEnabled(effective.graph)) { + triggerGraphContext(graphContext, chatId); + } // MONEY CHANNEL (wave 4): one collector per request, fed by the agent's // message_end events. Every model call's usage lands in the chat record's @@ -198,6 +210,18 @@ export async function handleChatRequest( // Tests inject a factory: the agent is stateless per turn (docs/ // pi-engine-swap.md §2), so each request builds a fresh one off the // transcript — the factory mirrors that seam exactly. + // CROSS-STREAM SEAM (agreed 2026-09-19, settings wave): `toolToggles` + // rides the build opts as the user-level DELTA map (absent key = + // enabled — quiz, ask_user_question, research, demo, visual). The + // Teacher stream adds the field to AgentBuildOptions + the filtering in + // createTeacherTools / the prober's registry this same wave; until that + // merge, the extra property is structurally ignored (a harmless no-op). + const agentOpts: AgentBuildOptions & { toolToggles?: Record } = { + engine, + messages: recordToPiMessages(outgoing), + onUsage: collectUsage, + toolToggles: effective.agent.tools, + }; const constructed: TeachingAgent = buildAgent ? buildAgent(outgoing) : teaching @@ -208,7 +232,7 @@ export async function handleChatRequest( jobStore: stores.jobStore, spawn: (job, input) => void jobRunner.runJob(job.id, input), }, - { engine, messages: recordToPiMessages(outgoing), onUsage: collectUsage }, + agentOpts, ) : createProberAgent( { @@ -216,7 +240,7 @@ export async function handleChatRequest( chatStore: stores.chatStore, deliver: (steer) => void deliverJobResult(steer), }, - { engine, messages: recordToPiMessages(outgoing), onUsage: collectUsage }, + agentOpts, ); // A test-injected agent gets no usage collector (tests assert the @@ -253,7 +277,13 @@ export async function handleChatRequest( steps, }); } - triggerExtraction(stores, extraction, chatId); + // GRAPH GATE (settings-storage wave, 2026-09-19): the write channel — + // extraction, its auditor — dies with the master switch. (The deeper + // pipeline knobs — embedding/clustering/labeling — gate inside the + // pipeline and its consumers, later waves, via the same resolved view.) + if (extractionEnabled(effective.graph)) { + triggerExtraction(stores, extraction, chatId); + } // LAST: a poller that sees not-in-flight must read an already-persisted // record — endTurn only after persistTurn has done its job. endTurn(chatId); diff --git a/src/server/index.ts b/src/server/index.ts index 02a2a34..48366d1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -12,6 +12,20 @@ export * from "./jobs.ts"; export * from "./stores.ts"; export { readRenderSettings, writeRenderSettings } from "../store/render-settings.ts"; export { readDefaultSettings, writeDefaultSettings } from "../store/default-settings.ts"; +// The settings record + prompt overrides (settings-storage wave, 2026-09-19). +// The *Record aliases keep the app/api/settings routes explicit about which +// seam they touch (the raw record, not the chat-section convenience view). +export { + readSettings as readSettingsRecord, + writeSettings as writeSettingsRecord, + settingsStore, +} from "../store/settings.ts"; +export { + promptOverrides, + effectivePromptSegments, + allSegmentPaths, + isValidSegmentPath, +} from "../store/prompt-overrides.ts"; export * from "./settings-body.ts"; export * from "./turn-liveness.ts"; export * from "./observability.ts"; diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 5a552e2..a09084b 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -18,6 +18,7 @@ import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-work import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { MODELS } from "../../config.ts"; import { piModels } from "../agent/models.ts"; +import { settingsStore } from "../store/settings.ts"; import type { ChatSettings } from "../core/contracts/chat.ts"; import type { CatalogDefaults, @@ -28,14 +29,25 @@ import type { /** The picker's client-safe model entry (contract: core/contracts/models.ts). */ export type ModelDto = ModelCatalogEntry; -/** The conversation agents' config pin — what a settings-less chat runs. */ -export function catalogDefaults(): CatalogDefaults { +/** The conversation agents' resolved default pin (pure form — the record's + * chat section in, CatalogDefaults out; `catalogDefaults()` below is the + * fs wrapper). Absent fields (or no record — BYOK first boot) fall back + * to the config pin, exactly as before. */ +export function catalogDefaultsFor(chat?: ChatSettings): CatalogDefaults { return { - model: { provider: "openrouter", id: MODELS.prober.model }, - thinkingLevel: MODELS.prober.effort as ThinkingLevel, + model: chat?.model ?? { provider: "openrouter", id: MODELS.prober.model }, + thinkingLevel: (chat?.thinkingLevel ?? MODELS.prober.effort) as ThinkingLevel, }; } +/** The conversation agents' config pin — what a settings-less chat runs. + * (settings-storage wave, 2026-09-19): the user's picked default rides in + * the settings record's `chat` section; the picker's defaults row shows + * it once chosen. */ +export function catalogDefaults(): CatalogDefaults { + return catalogDefaultsFor(settingsStore.readSettings()?.chat); +} + /** Project one catalog model to the client DTO. */ export function toModelDto(model: Model): ModelDto { return { diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 0e548db..57e9e99 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -1,23 +1,34 @@ /** - * The shared settings-PUT body parser — the one seam both settings routes - * use (per-chat: app/api/chats/[id]/settings; pre-session draft: - * app/api/settings/draft). Extracted verbatim from the per-chat route - * (default-settings wave, 2026-09-18): identical parse, identical errors. + * 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 object; + * 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. + * 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 { GRAPH_CONTEXT_TOKENS_MAX, type ChatSettings } from "../core"; +import { GRAPH_CONTEXT_TOKENS_MAX, isHexColor, type ChatSettings } from "../core"; +import type { + AgentSettings, + AppearanceSettings, + BackgroundModelRole, + GeneralSettings, + GraphSettings, + RoleModelOverride, +} from "../core/settings.ts"; +import { BACKGROUND_MODEL_ROLES } from "../core/settings.ts"; /** Parsed-or-rejected — `ok: false` carries the 400's error message. */ -export type SettingsBodyResult = - | { ok: true; settings: ChatSettings } +export type SettingsBodyResult = + | { ok: true; settings: T } | { ok: false; error: string }; -export function parseSettingsBody(body: unknown): SettingsBodyResult { +export function parseSettingsBody(body: unknown): SettingsBodyResult { if (!body || typeof body !== "object" || Array.isArray(body)) { return { ok: false, error: "Invalid JSON body" }; } @@ -63,3 +74,208 @@ export function parseSettingsBody(body: unknown): SettingsBodyResult { } 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; +} + +/** general: { autoUpdate?: boolean } */ +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; + } + 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) 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; + 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 four-rung ladder { enabled?, embedding?, clustering?, labeling? }. */ +export function parseGraphSettingsBody(body: unknown): SettingsBodyResult { + const raw = asRecord(body); + if (!raw) return { ok: false, error: "Invalid JSON body" }; + const settings: GraphSettings = {}; + for (const key of ["enabled", "embedding", "clustering", "labeling"] as const) { + if (raw[key] !== undefined) { + if (typeof raw[key] !== "boolean") { + return { ok: false, error: `${key} must be a boolean` }; + } + settings[key] = raw[key]; + } + } + 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, +} as const; + +export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS; diff --git a/src/server/stores.ts b/src/server/stores.ts index 3a8ba69..f584b11 100644 --- a/src/server/stores.ts +++ b/src/server/stores.ts @@ -6,14 +6,42 @@ * file (and the adapter implementations in src/store/). */ +import { MODELS } from "../../config.ts"; import { chatStore } from "../store/chats.ts"; import { graphStore } from "../store/graph.ts"; import { jobStore } from "../store/jobs.ts"; +import { settingsStore } from "../store/settings.ts"; +import { resolveSettings, type EffectiveSettings, type ModelRoleSpecs } from "../core/settings-resolution.ts"; /** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */ export const stores = { chatStore, graphStore, jobStore, + settingsStore, }; export type Stores = typeof stores; + +/** config.ts's MODELS narrowed to the background roles — the compiled + * factory layer the resolution reads. core stays pure (it takes this as a + * parameter, never imports config at runtime); the prober/teacher pins + * are chat-settings-controlled and deliberately absent here. Tests pin + * that the role set matches core's BACKGROUND_MODEL_ROLES. */ +const BACKGROUND_MODEL_PINS: ModelRoleSpecs = { + researcher: MODELS.researcher, + demoBuilder: MODELS.demoBuilder, + visualMaker: MODELS.visualMaker, + extractor: MODELS.extractor, + auditor: MODELS.auditor, + labeler: MODELS.labeler, +}; + +/** + * The resolved view of the settings record (core/settings-resolution.ts): + * every knob concrete, record overrides over config.ts factory pins. Read + * fresh on every call — the record is a small file and turns are rare, so + * no caching layer (stale toggles after a settings PUT would be worse). + */ +export function effectiveSettings(): EffectiveSettings { + return resolveSettings(settingsStore.readSettings(), BACKGROUND_MODEL_PINS); +} diff --git a/src/store/default-settings.ts b/src/store/default-settings.ts index 9288b53..0808016 100644 --- a/src/store/default-settings.ts +++ b/src/store/default-settings.ts @@ -1,45 +1,36 @@ /** - * Server-side persistence for the DEFAULT settings — THE default model + - * thinking level + knowledge-context budget (owner, 2026-09-18): the - * new-session surfaces' controls (the home graph view's topic dock and + * Server-side persistence for the DEFAULT CHAT settings — THE default + * model + thinking level + knowledge-context budget (owner, 2026-09-18): + * the new-session surfaces' controls (the home graph view's topic dock and * /chat/new) ARE the settings page for the default model. There is NO * baked-in default — BYOK first boot renders "no model" until the user - * picks one, and the last choice IS the default from then on. The pages + * picks one, and their last choice IS the default from then on. The pages * server-render from this record (initialSettings), so the controls show * truth on the first frame — no default-model flash ever. Per-session * settings live in the chat record (PUT /api/chats/:id/settings) and are * untouched by this; new sessions inherit the default on creation. * - * This module only moves bytes: the file is written exclusively through the - * validated route (app/api/settings/defaults — parseSettingsBody + - * validateChatSettings gate every write), so no sanitizer runs here. On - * read, anything unreadable/corrupt is null = first boot = "no model". + * BACKED BY THE SETTINGS RECORD (settings-storage wave, 2026-09-19): the + * default chat settings are the `chat` section of data/settings.json + * (src/store/settings.ts — same delta semantics, same file, one record). + * The legacy data/default-settings.json folds into that section on first + * read. These signatures are unchanged so the pages and the legacy PUT + * route (app/api/settings/defaults) keep working untouched; writes are + * still validated by the route before they get here (parseSettingsBody + + * validateChatSettings). */ -import fs from "node:fs"; -import path from "node:path"; -import { DEFAULT_SETTINGS_FILE } from "../../config.ts"; import type { ChatSettings } from "../core"; +import { readSettings, writeSettings } from "./settings.ts"; -/** The persisted default settings, or null when never saved / unreadable. */ +/** The persisted default chat settings, or null when never saved / unreadable. */ export function readDefaultSettings(): ChatSettings | null { - let raw: string; - try { - raw = fs.readFileSync(DEFAULT_SETTINGS_FILE, "utf-8"); - } catch { - return null; // never saved / unreadable → the surfaces render "no model" - } - try { - const parsed: unknown = JSON.parse(raw); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; - return parsed as ChatSettings; - } catch { - return null; // corrupt → "no model", never crash the surfaces - } + return readSettings()?.chat ?? null; } -/** Persist the validated settings verbatim. */ +/** Persist the validated settings into the record's chat section — other + * sections ride along untouched (read-modify-write on the ONE record). */ export function writeDefaultSettings(settings: ChatSettings): void { - fs.mkdirSync(path.dirname(DEFAULT_SETTINGS_FILE), { recursive: true }); - fs.writeFileSync(DEFAULT_SETTINGS_FILE, JSON.stringify(settings, null, "\t"), "utf-8"); + const record = readSettings(); + writeSettings({ ...(record ?? { version: 1 }), chat: settings }); } diff --git a/src/store/prompt-overrides.ts b/src/store/prompt-overrides.ts new file mode 100644 index 0000000..e619d97 --- /dev/null +++ b/src/store/prompt-overrides.ts @@ -0,0 +1,174 @@ +/** + * Per-segment system-prompt overrides (settings-storage wave, 2026-09-19) + * — the storage + the SOURCE-DERIVED segment list. + * + * SEGMENTS: a prompt composes from source at runtime exactly as + * teacher-agent.ts's composeInstructions does — the SHARED base + * (src/agent/system-prompt.md) plus every numeric-prefix *.md fragment in + * the agent's directory (src/agent/prompts/prober/ or teacher/), sorted by + * filename. The user may override ANY segment individually (the shared + * base in one edit changes both agents). This module re-implements the + * discovery rules (FRAGMENT_RE + sort) deliberately rather than importing + * src/agent (parallel-stream lane discipline); the Teacher stream is free + * to refactor composeInstructions onto listSourceSegments later — the + * definitions here are the canonical mirror until then. + * + * OVERRIDES: override files mirror the source layout under + * config.PROMPTS_OVERRIDE_DIR — "system-prompt.md" at the root, + * "teacher/" / "prober/" for fragments (POSIX-relative paths, + * the same keys as the settings record's agent.prompts switches). File + * presence is NOT the override: the switch is (owner decision) — the user + * keeps their file and flips back to the source default to compare. An + * override keyed to a segment the SOURCE no longer ships (fragment removed + * or renamed upstream) stops composing automatically — the segment list + * adapts to the code, never the reverse; the orphaned file stays on disk. + * + * Path discipline: only .md under the override dir, no traversal (no "..", + * absolute, or empty segments) — a hand-crafted path outside the mirror + * layout is rejected (null / no-op), never written. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { PATHS, PROMPTS_OVERRIDE_DIR } from "../../config.ts"; +import type { PromptOverrideStore } from "../core/ports.ts"; + +/** Same fragment rule as composeInstructions: numeric order prefix + .md. */ +const FRAGMENT_RE = /^\d+-[\w.-]+\.md$/; + +export type PromptAgent = "prober" | "teacher"; + +/** One segment of one agent's composed prompt. */ +export interface PromptSegmentInfo { + /** The override/store key — mirrors the source layout ("system-prompt.md", + * "teacher/20-teaching.md"). Stable across agents: the shared base uses + * the SAME path in both agents' lists. */ + path: string; + /** The source text (what the segment runs without an override). */ + sourceText: string; + /** The user's override text, or null when no file exists on disk. */ + overrideText: string | null; + /** Effective state: the settings switch AND the file both present. */ + enabled: boolean; + /** True for the shared base — the segment both agents compose. */ + shared: boolean; +} + +/** Validate a segment path against the mirror-layout rules (see module + * doc). Exported for the routes, which also check membership in the + * CURRENT source segment list. */ +export function isValidSegmentPath(segmentPath: string): boolean { + if (segmentPath === "system-prompt.md") return true; + const m = /^(prober|teacher)\/([\w.-]+\.md)$/.exec(segmentPath); + if (!m || m[2] === undefined) return false; + return FRAGMENT_RE.test(m[2]); +} + +/** Absolute path for an override file (unchecked — validate first). */ +function overrideFileFor(segmentPath: string): string { + return path.join(PROMPTS_OVERRIDE_DIR, ...segmentPath.split("/")); +} + +/** The store adapter (core/ports.ts). */ +export const promptOverrides: PromptOverrideStore = { + listOverrides(): string[] { + const out: string[] = []; + const walk = (dir: string, prefix: string) => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; // no dir yet = no overrides + } + for (const entry of entries) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(path.join(dir, entry.name), rel); + else if (entry.isFile() && entry.name.endsWith(".md") && isValidSegmentPath(rel)) out.push(rel); + } + }; + walk(PROMPTS_OVERRIDE_DIR, ""); + return out.sort(); + }, + + readOverride(segmentPath: string): string | null { + if (!isValidSegmentPath(segmentPath)) return null; + try { + return fs.readFileSync(overrideFileFor(segmentPath), "utf-8"); + } catch { + return null; + } + }, + + writeOverride(segmentPath: string, text: string): void { + if (!isValidSegmentPath(segmentPath)) return; + const file = overrideFileFor(segmentPath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf-8"); + }, + + deleteOverride(segmentPath: string): void { + if (!isValidSegmentPath(segmentPath)) return; + try { + fs.unlinkSync(overrideFileFor(segmentPath)); + } catch { + // absent already = done + } + }, +}; + +/** List the SOURCE segments for one agent, in composition order — the + * shared base first, then the agent's fragments sorted by filename. + * Adapts automatically when the code ships/renames/removes fragments. */ +function listSourceSegments(agent: PromptAgent): Array<{ path: string; shared: boolean; file: string }> { + const segments: Array<{ path: string; shared: boolean; file: string }> = [ + { path: "system-prompt.md", shared: true, file: PATHS.teacherPrompt }, + ]; + const dir = path.join(PATHS.root, "src", "agent", "prompts", agent); + if (fs.existsSync(dir)) { + const fragments = fs + .readdirSync(dir) + .filter((f) => FRAGMENT_RE.test(f)) + .sort(); + for (const f of fragments) { + segments.push({ path: `${agent}/${f}`, shared: false, file: path.join(dir, f) }); + } + } + return segments; +} + +/** + * The effective segment view for one agent — every segment with its source + * text, override text, and effective switch state. `switches` is the + * settings record's agent.prompts map (absent key = off); enabled = + * switch on AND the override file exists. + */ +export function effectivePromptSegments( + agent: PromptAgent, + switches: Record, +): PromptSegmentInfo[] { + return listSourceSegments(agent).map((segment) => { + let sourceText: string; + try { + sourceText = fs.readFileSync(segment.file, "utf-8"); + } catch { + sourceText = ""; // source unreadable — the agent loader would crash anyway + } + const overrideText = promptOverrides.readOverride(segment.path); + return { + path: segment.path, + sourceText, + overrideText, + enabled: switches[segment.path] === true && overrideText !== null, + shared: segment.shared, + }; + }); +} + +/** Every known segment path across both agents (route validation). */ +export function allSegmentPaths(): string[] { + const paths = new Set(); + for (const agent of ["prober", "teacher"] as const) { + for (const segment of listSourceSegments(agent)) paths.add(segment.path); + } + return [...paths].sort(); +} diff --git a/src/store/settings.ts b/src/store/settings.ts new file mode 100644 index 0000000..745f02b --- /dev/null +++ b/src/store/settings.ts @@ -0,0 +1,93 @@ +/** + * Server-side persistence for THE user settings record (settings-storage + * wave, 2026-09-19): data/settings.json (config.SETTINGS_FILE), under the + * LH_DATA_DIR seam — read-modify-write, NOT a ledger. Non-secret product + * preferences only; secrets stay in auth.json / .env.local. + * + * This adapter OWNS the lazy migrations: + * 1. first boot with a legacy default-settings.json (the 2026-09-18 wave's + * file) but no settings.json → fold it into the record's `chat` section, + * persist the new record, LEAVE the old file in place (inert, zero-risk + * rollback). Subsequent reads go straight to settings.json. + * 2. schema-version bumps (CURRENT_SETTINGS_VERSION) slot in here — the + * chain is empty today (v1 is the first shape); pure additions never + * bump, so an up-to-date file is always a no-op pass-through. + * + * Everything off disk is sanitized (core/settings.ts) before it reaches a + * caller; everything written was already validated by the routes + * (settings-body.ts) and is sanitized here again — the file can never hold + * a value that would silently shift a knob. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { DEFAULT_SETTINGS_FILE, SETTINGS_FILE } from "../../config.ts"; +import { + CURRENT_SETTINGS_VERSION, + sanitizeUserSettings, + type UserSettings, +} from "../core/settings.ts"; +import type { SettingsStore } from "../core/ports.ts"; +import type { ChatSettings } from "../core"; + +/** The persisted record, or null when none exists (first boot). Runs the + * migration chain on every read — cheap: a no-op file check when current. */ +export function readSettings(): UserSettings | null { + let raw: string; + try { + raw = fs.readFileSync(SETTINGS_FILE, "utf-8"); + } catch { + // No settings.json yet — one-time legacy fold: the default-settings + // wave's record becomes our record's `chat` section verbatim. + const legacy = readLegacyDefaultSettings(); + if (!legacy) return null; + const migrated: UserSettings = { version: CURRENT_SETTINGS_VERSION, chat: legacy }; + writeSettings(migrated); + return migrated; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; // corrupt → first-boot semantics (all factory defaults) + } + const record = sanitizeUserSettings(parsed); + if (!record) return null; + // Version chain (empty today): a future v2 migration slots in here — + // `if (record.version > CURRENT_SETTINGS_VERSION) …`. Unknown NEWER + // versions pass through untouched (the sanitizer preserved unknowns). + return record; +} + +/** Persist the record (sanitized). Returns what was written. */ +export function writeSettings(raw: unknown): UserSettings { + const settings = sanitizeUserSettings(raw); + if (!settings) throw new Error("settings record must be a JSON object"); + fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, "\t"), "utf-8"); + return settings; +} + +/** The legacy default-settings.json contents, or null when absent/corrupt — + * same tolerance as the old store: unreadable = never saved, never crash. */ +function readLegacyDefaultSettings(): ChatSettings | null { + let raw: string; + try { + raw = fs.readFileSync(DEFAULT_SETTINGS_FILE, "utf-8"); + } catch { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as ChatSettings; + } catch { + return null; + } +} + +/** The typed adapter (core/ports.ts SettingsStore), for the composition root. */ +export const settingsStore: SettingsStore = { + readSettings, + writeSettings, +}; diff --git a/tests/default-settings.test.ts b/tests/default-settings.test.ts index d5e7bfa..a2e8c0e 100644 --- a/tests/default-settings.test.ts +++ b/tests/default-settings.test.ts @@ -1,20 +1,17 @@ /** - * Tests for the default-settings wave (2026-09-18): the shared settings-body - * parser (the one seam behind BOTH settings routes — per-chat and defaults — - * so their parse behavior is identical) and the default-settings store - * (data/default-settings.json read/write, first-boot null semantics). + * Tests for the shared settings-body parser, chat-section flavor + * (default-settings wave, 2026-09-18; record-backed 2026-09-19): the seam + * behind BOTH chat-settings routes — per-chat and the defaults alias. * - * The store tests write the real repo data file (npm test's shared data/, - * disposable scratch by doctrine) and clean up after themselves. + * The record store, the legacy-file migration, and the record-backed + * default-settings facade are covered in tests/settings-store.test.ts + * (one fs owner per file — the test runner executes files in parallel). */ import assert from "node:assert/strict"; -import fs from "node:fs"; -import { afterEach, describe, it } from "node:test"; -import { GRAPH_CONTEXT_TOKENS_MAX, type ChatSettings } from "../src/core"; +import { describe, it } from "node:test"; +import { GRAPH_CONTEXT_TOKENS_MAX } from "../src/core"; import { parseSettingsBody } from "../src/server/settings-body.ts"; -import { DEFAULT_SETTINGS_FILE } from "../config.ts"; -import { readDefaultSettings, writeDefaultSettings } from "../src/store/default-settings.ts"; describe("parseSettingsBody (shared by both settings routes)", () => { it("an empty object parses to {} — the clear-overrides write", () => { @@ -66,43 +63,3 @@ describe("parseSettingsBody (shared by both settings routes)", () => { assert.equal(ok.ok, true); }); }); - -describe("the default-settings store", () => { - afterEach(() => { - try { - fs.rmSync(DEFAULT_SETTINGS_FILE); - } catch { - /* nothing to clean */ - } - }); - - it("reads null on first boot (no file) — the surfaces use config defaults", () => { - try { - fs.rmSync(DEFAULT_SETTINGS_FILE); - } catch { - /* absent already */ - } - assert.equal(readDefaultSettings(), null); - }); - - it("write then read roundtrips verbatim — validated shape, no sanitizer games", () => { - const settings: ChatSettings = { - model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, - thinkingLevel: "high", - graphContextTokens: 1024, - }; - writeDefaultSettings(settings); - assert.deepEqual(readDefaultSettings(), settings); - }); - - it("an empty {} record roundtrips (all overrides cleared)", () => { - writeDefaultSettings({}); - assert.deepEqual(readDefaultSettings(), {}); - }); - - it("a corrupt file reads as null — defaults, never a crash", () => { - fs.rmSync(DEFAULT_SETTINGS_FILE, { force: true }); // a directory from a broken earlier run also blocks - fs.writeFileSync(DEFAULT_SETTINGS_FILE, "{ not json", "utf-8"); - assert.equal(readDefaultSettings(), null); - }); -}); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 367628c..f30788b 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { modelCatalog, validateChatSettings, catalogDefaults, toModelDto } from "../src/server/model-catalog.ts"; +import { modelCatalog, validateChatSettings, catalogDefaultsFor, toModelDto } from "../src/server/model-catalog.ts"; import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { MODELS } from "../config.ts"; import { piEngineFor } from "../src/agent/models.ts"; @@ -32,11 +32,25 @@ describe("the model catalog projection", () => { }); it("defaults mirror the conversation agents' config pin", () => { - const d = catalogDefaults(); + const d = catalogDefaultsFor(); assert.deepEqual(d, { model: { provider: "openrouter", id: MODELS.prober.model }, thinkingLevel: MODELS.prober.effort }); assert.equal(d.model.id, MODELS.teacher.model, "one controller: prober and teacher share the pin"); }); + it("a picked default in the record's chat section overrides the pin (catalogDefaultsFor, settings wave)", () => { + const d = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" }); + assert.deepEqual(d, { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" }); + // partial: only the model chosen → thinking falls back to the pin + const partial = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }); + assert.equal(partial.thinkingLevel, MODELS.prober.effort); + assert.equal(partial.model.id, "qwen/qwen3.8-flash"); + // graphContextTokens-only record: model untouched + assert.deepEqual(catalogDefaultsFor({ graphContextTokens: 512 }), { + model: { provider: "openrouter", id: MODELS.prober.model }, + thinkingLevel: MODELS.prober.effort, + }); + }); + it("toModelDto projects the raw catalog model verbatim", () => { const raw = piEngineFor("prober").model; // the catalog's glm entry const dto = toModelDto(raw); diff --git a/tests/prompt-overrides.test.ts b/tests/prompt-overrides.test.ts new file mode 100644 index 0000000..2f7d8b4 --- /dev/null +++ b/tests/prompt-overrides.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for the prompt-override store (settings-storage wave, 2026-09-19): + * src/store/prompt-overrides.ts — override file CRUD, the mirror-layout + * path discipline (traversal guard), and the SOURCE-DERIVED segment list + * (the loader mirrors teacher-agent.ts's composeInstructions discovery; + * these tests pin that the mirror tracks the real src/agent/prompts tree). + * Writes under the repo data/prompts/ (disposable scratch) and cleans up. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { after, describe, it } from "node:test"; +import { PROMPTS_OVERRIDE_DIR } from "../config.ts"; +import { + allSegmentPaths, + effectivePromptSegments, + isValidSegmentPath, + promptOverrides, +} from "../src/store/prompt-overrides.ts"; + +after(() => { + fs.rmSync(PROMPTS_OVERRIDE_DIR, { recursive: true, force: true }); +}); + +describe("isValidSegmentPath (the mirror-layout discipline)", () => { + it("accepts the shared base and numeric fragments, rejects everything else", () => { + assert.equal(isValidSegmentPath("system-prompt.md"), true); + assert.equal(isValidSegmentPath("teacher/20-teaching.md"), true); + assert.equal(isValidSegmentPath("prober/10-probe-doctrine.md"), true); + for (const bad of [ + "", // empty + "README.md", // no agent dir + "teacher/README.md", // not numeric-prefixed + "teacher/20-teaching.txt", // not .md + "../system-prompt.md", // traversal + "agent/../teacher/20-teaching.md", // traversal + "/etc/passwd", // absolute + "teacher", // bare dir + "teacher/", // bare dir + "teacher/sub/10-x.md", // nested (not the mirror layout) + "prober\\10-x.md", // backslash + ]) { + assert.equal(isValidSegmentPath(bad), false, bad); + } + }); +}); + +describe("the override store", () => { + it("listOverrides is empty with no dir, then lists written mirrors sorted", () => { + fs.rmSync(PROMPTS_OVERRIDE_DIR, { recursive: true, force: true }); + assert.deepEqual(promptOverrides.listOverrides(), []); + promptOverrides.writeOverride("teacher/20-teaching.md", "custom teaching"); + promptOverrides.writeOverride("system-prompt.md", "custom base"); + assert.deepEqual(promptOverrides.listOverrides(), ["system-prompt.md", "teacher/20-teaching.md"]); + }); + + it("readOverride round-trips; delete removes idempotently; invalid paths no-op", () => { + promptOverrides.writeOverride("prober/30-probe-done.md", "custom probe-done"); + assert.equal(promptOverrides.readOverride("prober/30-probe-done.md"), "custom probe-done"); + promptOverrides.deleteOverride("prober/30-probe-done.md"); + assert.equal(promptOverrides.readOverride("prober/30-probe-done.md"), null); + promptOverrides.deleteOverride("prober/30-probe-done.md"); // again: no throw + // traversal junk never reaches disk + promptOverrides.writeOverride("../evil.md", "nope"); + assert.equal(fs.existsSync(path.join(PROMPTS_OVERRIDE_DIR, "..", "evil.md")), false); + }); + + it("switches are NOT the store's concern — files alone never enable", () => { + promptOverrides.writeOverride("system-prompt.md", "custom base"); + const off = effectivePromptSegments("teacher", {}) // no switches + .find((s) => s.path === "system-prompt.md"); + assert.ok(off); + assert.equal(off.overrideText, "custom base"); // file exists... + assert.equal(off.enabled, false); // ...but no switch → source default + promptOverrides.deleteOverride("system-prompt.md"); // leave clean for the sibling tests + }); +}); + +describe("the source-derived segment list", () => { + it("mirrors composeInstructions' discovery: base first, numeric fragments sorted, per agent", () => { + const prober = effectivePromptSegments("prober", {}); + const teacher = effectivePromptSegments("teacher", {}); + // The shared base composes both prompts, SAME path. + assert.equal(prober[0]?.path, "system-prompt.md"); + assert.equal(teacher[0]?.path, "system-prompt.md"); + assert.equal(prober[0]?.shared, true); + assert.equal(teacher[0]?.shared, true); + assert.equal(prober[0]?.sourceText.length > 0, true, "base prompt text loads"); + // Fragments track the REAL source tree (numeric order, right agent dir). + assert.deepEqual( + prober.map((s) => s.path), + ["system-prompt.md", "prober/10-probe-doctrine.md", "prober/20-teaching-in-brief.md", "prober/30-probe-done.md"], + ); + assert.equal(teacher.some((s) => s.path === "teacher/10-planning.md"), true); + assert.equal(teacher.some((s) => s.path === "teacher/50-probing-in-brief.md"), true); + assert.equal(teacher.every((s) => s.path === "system-prompt.md" || s.path.startsWith("teacher/")), true); + // README and other non-numeric files never load. + assert.equal(allSegmentPaths().some((p) => p.includes("README")), false); + }); + + it("effective segments: switch+file → override; either missing → source text", () => { + promptOverrides.writeOverride("teacher/20-teaching.md", "MY WAY"); + const switches = { "teacher/20-teaching.md": true, "system-prompt.md": false }; + const teacher = effectivePromptSegments("teacher", switches); + const teaching = teacher.find((s) => s.path === "teacher/20-teaching.md"); + assert.ok(teaching); + assert.equal(teaching.overrideText, "MY WAY"); + assert.equal(teaching.enabled, true); + // base: switch OFF with file ABSENT → source, disabled + const base = teacher.find((s) => s.path === "system-prompt.md"); + assert.ok(base); + assert.equal(base.overrideText, null); + assert.equal(base.enabled, false); + // a switch for a segment with NO override file never enables it + const weird = effectivePromptSegments("teacher", { "teacher/10-planning.md": true }); + const planning = weird.find((s) => s.path === "teacher/10-planning.md"); + assert.ok(planning); + assert.equal(planning.enabled, false); + assert.equal(planning.overrideText, null); + }); + + it("adapts to source changes by construction: the list is computed, not stored", () => { + // If a fragment vanishes upstream, its switch/override simply stop + // composing — nothing here can go stale. Pinned by absence: + const before = allSegmentPaths().length; + assert.equal(before > 5, true, "base + prober fragments + teacher fragments"); + }); +}); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts new file mode 100644 index 0000000..b838315 --- /dev/null +++ b/tests/settings-body.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for the section parsers (settings-storage wave, 2026-09-19): + * src/server/settings-body.ts — general/agent/appearance/graph strictness. + * Same discipline as parseSettingsBody (its tests live in + * default-settings.test.ts): every known field is strict (bad type/shape + * → 400-style error, never a silent clamp), unknown keys drop, absent + * fields mean absent overrides. Pure — no fs. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + parseAgentSettingsBody, + parseAppearanceSettingsBody, + parseGeneralSettingsBody, + parseGraphSettingsBody, + SETTINGS_SECTIONS, +} from "../src/server/settings-body.ts"; + +describe("parseGeneralSettingsBody", () => { + it("parses {} and {autoUpdate} replace-whole", () => { + const empty = parseGeneralSettingsBody({}); + assert.ok(empty.ok); + assert.deepEqual(empty.settings, {}); + const on = parseGeneralSettingsBody({ autoUpdate: true }); + assert.ok(on.ok); + assert.deepEqual(on.settings, { autoUpdate: true }); + }); + + it("rejects non-objects and non-boolean autoUpdate", () => { + assert.equal(parseGeneralSettingsBody("x").ok, false); + assert.equal(parseGeneralSettingsBody({ autoUpdate: "yes" }).ok, false); + }); +}); + +describe("parseAgentSettingsBody", () => { + it("models: valid roles with model pair and/or thinkingLevel; unknown role is a 400 with the list", () => { + const r = parseAgentSettingsBody({ + models: { + researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, + labeler: { thinkingLevel: "high" }, + }, + }); + assert.ok(r.ok); + assert.deepEqual(r.settings.models, { + researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } }, + labeler: { thinkingLevel: "high" }, + }); + const bad = parseAgentSettingsBody({ models: { teacher: {} } }); + assert.equal(bad.ok, false); + if (!bad.ok) assert.match(bad.error, /unknown role "teacher" \(supported: researcher, demoBuilder, visualMaker, extractor, auditor, labeler\)/); + const malformed = parseAgentSettingsBody({ models: { researcher: { model: { provider: 1, id: "x" } } } }); + assert.equal(malformed.ok, false); + }); + + it("tools and prompts: boolean maps; non-boolean values are 400s", () => { + const r = parseAgentSettingsBody({ tools: { research: false }, prompts: { "system-prompt.md": true } }); + assert.ok(r.ok); + assert.deepEqual(r.settings.tools, { research: false }); + assert.deepEqual(r.settings.prompts, { "system-prompt.md": true }); + assert.equal(parseAgentSettingsBody({ tools: { quiz: "no" } }).ok, false); + assert.equal(parseAgentSettingsBody({ prompts: { "x.md": 1 } }).ok, false); + // switch validity (segment membership, file presence) is the ROUTE's + // cross-validation — the parser only sees shapes. + }); + + it("an empty {} agent section parses to {} (replace-whole clear)", () => { + const r = parseAgentSettingsBody({}); + assert.ok(r.ok); + assert.deepEqual(r.settings, {}); + }); +}); + +describe("parseAppearanceSettingsBody", () => { + it("parses every field; hex-or-null colors; clamps happen at sanitize, rejects here", () => { + const r = parseAppearanceSettingsBody({ + mode: "light", + background: "#ffffff", + fontFamily: "Some Font", + proseFontSize: 20, + sessionNameSerif: false, + mdRendering: { headingWeight: 700, boldColor: "#ff8800", italicColor: null }, + }); + assert.ok(r.ok); + assert.deepEqual(r.settings, { + mode: "light", + background: "#ffffff", + fontFamily: "Some Font", + proseFontSize: 20, + sessionNameSerif: false, + mdRendering: { headingWeight: 700, boldColor: "#ff8800", italicColor: null }, + }); + }); + + it("rejects bad enums, ranges, and colors — with specific messages, never clamps", () => { + const cases: unknown[] = [ + { mode: "sepia" }, + { background: "blue" }, + { background: 42 }, + { fontFamily: "" }, + { fontFamily: 12 }, + { proseFontSize: 30 }, + { proseFontSize: 64.5 }, + { sessionNameSerif: 1 }, + { mdRendering: { headingWeight: 1000 } }, + { mdRendering: { boldColor: "red" } }, + ]; + for (const body of cases) { + assert.equal(parseAppearanceSettingsBody(body).ok, false, JSON.stringify(body)); + } + // boundary values that MUST pass: 12 and 24, 100 and 900, #fff + assert.ok(parseAppearanceSettingsBody({ proseFontSize: 12 })); + assert.ok(parseAppearanceSettingsBody({ proseFontSize: 24 })); + assert.ok(parseAppearanceSettingsBody({ mdRendering: { headingWeight: 100 } })); + assert.ok(parseAppearanceSettingsBody({ mdRendering: { headingWeight: 900 } })); + assert.ok(parseAppearanceSettingsBody({ background: "#abc" })); + // null = "the mode's default" — explicit, valid + assert.ok(parseAppearanceSettingsBody({ background: null, fontFamily: null })); + }); + + it("rejects non-object bodies and non-object mdRendering", () => { + assert.equal(parseAppearanceSettingsBody([1]).ok, false); + assert.equal(parseAppearanceSettingsBody({ mdRendering: "big" }).ok, false); + }); +}); + +describe("parseGraphSettingsBody", () => { + it("parses the four-rung ladder replace-whole", () => { + const r = parseGraphSettingsBody({ enabled: false, embedding: false }); + assert.ok(r.ok); + assert.deepEqual(r.settings, { enabled: false, embedding: false }); + assert.equal(parseGraphSettingsBody({}).ok, true); + }); + + it("rejects non-boolean rungs and unknown bodies", () => { + assert.equal(parseGraphSettingsBody({ enabled: "off" }).ok, false); + assert.equal(parseGraphSettingsBody("nope").ok, false); + }); + + it("the ladder DEPENDENCY (graph → embedding → clustering → labeling) is a UI concern, not a parse concern", () => { + // Nothing stops a stored {enabled: false, labeling: true} — the + // values persist but resolve inert (grays out in the panel). + const r = parseGraphSettingsBody({ enabled: false, clustering: true, labeling: true }); + assert.ok(r.ok); + }); +}); + +describe("SETTINGS_SECTIONS (the route's section map)", () => { + it("maps exactly the five record sections", () => { + assert.deepEqual(Object.keys(SETTINGS_SECTIONS), ["general", "chat", "agent", "appearance", "graph"]); + }); +}); diff --git a/tests/settings-resolution.test.ts b/tests/settings-resolution.test.ts new file mode 100644 index 0000000..311bb1b --- /dev/null +++ b/tests/settings-resolution.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for the resolution layer (settings-storage wave, 2026-09-19): + * core/settings-resolution.ts — the delta→concrete projection. The factory + * layer is threaded in as a parameter (core stays pure; server/stores.ts + * threads config.ts's MODELS), so these tests use a small synthetic pin. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + extractionEnabled, + graphContextEnabled, + resolveModelSpec, + resolveSettings, +} from "../src/core/settings-resolution.ts"; +import { BACKGROUND_MODEL_ROLES, type UserSettings } from "../src/core/settings.ts"; + +const FACTORY_PINS = Object.fromEntries( + BACKGROUND_MODEL_ROLES.map((role, i) => [ + role, + { model: `factory/model-${i}`, effort: i % 2 === 0 ? "low" : "off" }, + ]), +) as Record<(typeof BACKGROUND_MODEL_ROLES)[number], { model: string; effort: "low" | "off" }>; + +describe("resolveSettings", () => { + it("null record (first boot): every knob lands on its factory default", () => { + const eff = resolveSettings(null, FACTORY_PINS); + assert.deepEqual(eff.general, { autoUpdate: true }); + assert.deepEqual(eff.chat, {}); // BYOK: no default model, ever + assert.deepEqual(eff.agent.tools, {}); + assert.deepEqual(eff.agent.prompts, {}); + for (const role of BACKGROUND_MODEL_ROLES) { + assert.equal(eff.agent.models[role].id, FACTORY_PINS[role].model); + assert.equal(eff.agent.models[role].provider, "openrouter"); + assert.equal(eff.agent.models[role].thinkingLevel, FACTORY_PINS[role].effort); + } + assert.equal(eff.appearance.mode, "dark"); + assert.equal(eff.appearance.background, null); + assert.equal(eff.appearance.fontFamily, null); + assert.equal(eff.appearance.proseFontSize, 16); + assert.equal(eff.appearance.sessionNameSerif, true); + assert.equal(eff.appearance.mdRendering.headingWeight, 900); + assert.deepEqual(eff.graph, { enabled: true, embedding: true, clustering: true, labeling: true }); + }); + + it("explicit record values win over every factory default", () => { + const record: UserSettings = { + version: 1, + general: { autoUpdate: false }, + chat: { thinkingLevel: "high" }, + agent: { + models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } } }, + tools: { research: false }, + prompts: { "system-prompt.md": true }, + }, + appearance: { mode: "light", proseFontSize: 20, mdRendering: { headingWeight: 600 } }, + graph: { enabled: false }, + }; + const eff = resolveSettings(record, FACTORY_PINS); + assert.equal(eff.general.autoUpdate, false); + assert.deepEqual(eff.chat, { thinkingLevel: "high" }); + assert.deepEqual(eff.agent.models.researcher, { + provider: "openrouter", + id: "qwen/qwen3.8-flash", + thinkingLevel: "low", // thinking NOT overridden → factory effort + }); + assert.deepEqual(eff.agent.tools, { research: false }); + assert.deepEqual(eff.agent.prompts, { "system-prompt.md": true }); + assert.equal(eff.appearance.mode, "light"); + assert.equal(eff.appearance.proseFontSize, 20); + assert.equal(eff.appearance.mdRendering.headingWeight, 600); + // graph: enabled=false explicit; absent rungs stay factory-on (stored + // but inert — the ladder grays them out in the UI). + assert.deepEqual(eff.graph, { enabled: false, embedding: true, clustering: true, labeling: true }); + }); + + it("partial role override: thinkingLevel alone swaps the budget, model stays pinned", () => { + const eff = resolveSettings( + { version: 1, agent: { models: { extractor: { thinkingLevel: "medium" } } } }, + FACTORY_PINS, + ); + const extractor = FACTORY_PINS.extractor; + assert.deepEqual(eff.agent.models.extractor, { + provider: "openrouter", + id: extractor.model, + thinkingLevel: "medium", + }); + // sibling roles untouched + assert.equal(eff.agent.models.researcher.thinkingLevel, FACTORY_PINS.researcher.effort); + }); + + it("resolveModelSpec is field-independent per override aspect", () => { + assert.deepEqual( + resolveModelSpec("labeler", { model: { provider: "other", id: "x" } }, FACTORY_PINS), + { provider: "other", id: "x", thinkingLevel: FACTORY_PINS.labeler.effort }, + ); + assert.deepEqual(resolveModelSpec("labeler", {}, FACTORY_PINS), { + provider: "openrouter", + id: FACTORY_PINS.labeler.model, + thinkingLevel: FACTORY_PINS.labeler.effort, + }); + }); +}); + +describe("the graph channel gates (the wave's seam honoring)", () => { + it("WRITE channel (extraction): dies with the master switch only", () => { + assert.equal(extractionEnabled({ enabled: true, embedding: true, clustering: true, labeling: true }), true); + assert.equal(extractionEnabled({ enabled: true, embedding: false, clustering: false, labeling: false }), true); + assert.equal(extractionEnabled({ enabled: false, embedding: true, clustering: true, labeling: true }), false); + }); + + it("READ channel (graph-context): needs master AND the embedding rung", () => { + assert.equal(graphContextEnabled({ enabled: true, embedding: true, clustering: false, labeling: false }), true); + assert.equal(graphContextEnabled({ enabled: true, embedding: false, clustering: true, labeling: true }), false); + assert.equal(graphContextEnabled({ enabled: false, embedding: true, clustering: true, labeling: true }), false); + }); + + it("both gates ride resolveSettings' factory defaults (all on → both open)", () => { + const eff = resolveSettings(null, FACTORY_PINS); + assert.equal(extractionEnabled(eff.graph), true); + assert.equal(graphContextEnabled(eff.graph), true); + const off = resolveSettings({ version: 1, graph: { enabled: false } }, FACTORY_PINS); + assert.equal(extractionEnabled(off.graph), false); + assert.equal(graphContextEnabled(off.graph), false); + }); +}); diff --git a/tests/settings-store.test.ts b/tests/settings-store.test.ts new file mode 100644 index 0000000..acf5271 --- /dev/null +++ b/tests/settings-store.test.ts @@ -0,0 +1,122 @@ +/** + * Tests for the settings STORE (settings-storage wave, 2026-09-19): + * src/store/settings.ts — the record's fs adapter + the LAZY MIGRATION + * from the legacy default-settings.json — and the record-backed + * default-settings facade (src/store/default-settings.ts). Writes the + * real repo data/ files (npm test's shared data/, disposable scratch by + * doctrine) and cleans up after itself; a pre-existing settings.json is + * snapshotted and restored so the wave's own dev state survives the run. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { after, afterEach, describe, it } from "node:test"; +import { DEFAULT_SETTINGS_FILE, SETTINGS_FILE } from "../config.ts"; +import { readSettings, writeSettings } from "../src/store/settings.ts"; +import { readDefaultSettings, writeDefaultSettings } from "../src/store/default-settings.ts"; +import type { ChatSettings } from "../src/core"; + +const CHAT_FIXTURE: ChatSettings = { + model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, + thinkingLevel: "low", + graphContextTokens: 1024, +}; + +// Snapshot any pre-existing files; restore in after() so the worktree's +// own dev state survives a test run. +let hadSettings = false; +let savedSettings = ""; +try { + savedSettings = fs.readFileSync(SETTINGS_FILE, "utf-8"); + hadSettings = true; +} catch { + /* none */ +} +let hadDefaults = false; +let savedDefaults = ""; +try { + savedDefaults = fs.readFileSync(DEFAULT_SETTINGS_FILE, "utf-8"); + hadDefaults = true; +} catch { + /* none */ +} + +after(() => { + if (hadSettings) fs.writeFileSync(SETTINGS_FILE, savedSettings, "utf-8"); + else fs.rmSync(SETTINGS_FILE, { force: true }); + if (hadDefaults) fs.writeFileSync(DEFAULT_SETTINGS_FILE, savedDefaults, "utf-8"); + else fs.rmSync(DEFAULT_SETTINGS_FILE, { force: true }); +}); + +afterEach(() => { + fs.rmSync(SETTINGS_FILE, { force: true }); +}); + +describe("the settings store", () => { + it("reads null on first boot (no record, no legacy file)", () => { + fs.rmSync(SETTINGS_FILE, { force: true }); + fs.rmSync(DEFAULT_SETTINGS_FILE, { force: true }); + assert.equal(readSettings(), null); + }); + + it("write→read round-trips through the sanitizer", () => { + writeSettings({ version: 1, graph: { enabled: false } }); + const out = readSettings(); + assert.ok(out); + assert.deepEqual(out.graph, { enabled: false }); + }); + + it("corrupt settings.json → null (first-boot semantics), never throws", () => { + fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); + fs.writeFileSync(SETTINGS_FILE, "{not json", "utf-8"); + assert.equal(readSettings(), null); + }); + + it("LEGACY MIGRATION: default-settings.json folds into the record's chat section on first read", () => { + fs.rmSync(SETTINGS_FILE, { force: true }); + fs.mkdirSync(path.dirname(DEFAULT_SETTINGS_FILE), { recursive: true }); + fs.writeFileSync(DEFAULT_SETTINGS_FILE, JSON.stringify(CHAT_FIXTURE), "utf-8"); + const migrated = readSettings(); + assert.ok(migrated, "the legacy file became a record"); + assert.equal(migrated.version, 1); + assert.deepEqual(migrated.chat, CHAT_FIXTURE); + // The record is now persisted — the legacy file is left inert on disk. + assert.ok(fs.existsSync(SETTINGS_FILE)); + assert.deepEqual(readSettings()?.chat, CHAT_FIXTURE); + // And the default-settings facade reads the SAME section. + assert.deepEqual(readDefaultSettings(), CHAT_FIXTURE); + }); + + it("LEGACY MIGRATION is one-shot: an unreadable/corrupt legacy file never blocks first boot", () => { + fs.rmSync(SETTINGS_FILE, { force: true }); + fs.mkdirSync(path.dirname(DEFAULT_SETTINGS_FILE), { recursive: true }); + fs.writeFileSync(DEFAULT_SETTINGS_FILE, "junk", "utf-8"); + assert.equal(readSettings(), null); + }); +}); + +describe("the record-backed default-settings facade", () => { + it("writeDefaultSettings lands in the record's chat section WITHOUT touching other sections", () => { + writeSettings({ version: 1, graph: { enabled: false }, agent: { tools: { research: false } } }); + writeDefaultSettings(CHAT_FIXTURE); + const out = readSettings(); + assert.ok(out); + assert.deepEqual(out.chat, CHAT_FIXTURE); + assert.deepEqual(out.graph, { enabled: false }); // untouched + assert.deepEqual(out.agent?.tools, { research: false }); // untouched + }); + + it("writeDefaultSettings creates the record on first boot", () => { + fs.rmSync(SETTINGS_FILE, { force: true }); + fs.rmSync(DEFAULT_SETTINGS_FILE, { force: true }); + assert.equal(readSettings(), null); + writeDefaultSettings(CHAT_FIXTURE); + assert.deepEqual(readDefaultSettings(), CHAT_FIXTURE); + }); + + it("readDefaultSettings returns null when the record has no chat section", () => { + writeSettings({ version: 1, graph: { enabled: true } }); + assert.equal(readDefaultSettings(), null); + }); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..e9dc4d1 --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,172 @@ +/** + * Tests for the settings record's core layer (settings-storage wave, + * 2026-09-19): the delta schema's sanitizer (core/settings.ts) — strict + * known-field validation with graceful degradation, unknown-key + * preservation (forward-compat: a newer record must survive an older + * app), and the factory defaults. The store/adapter tests live in + * settings-store.test.ts; resolution in settings-resolution.test.ts. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + CURRENT_SETTINGS_VERSION, + FACTORY_SETTINGS, + BACKGROUND_MODEL_ROLES, + isHexColor, + sanitizeUserSettings, +} from "../src/core/settings.ts"; + +describe("FACTORY_SETTINGS (the compiled factory layer)", () => { + it("pins the owner-decided defaults", () => { + assert.equal(CURRENT_SETTINGS_VERSION, 1); + assert.deepEqual(FACTORY_SETTINGS.general, { autoUpdate: true }); + assert.deepEqual(FACTORY_SETTINGS.graph, { enabled: true, embedding: true, clustering: true, labeling: true }); + const a = FACTORY_SETTINGS.appearance; + assert.equal(a.mode, "dark"); + assert.equal(a.background, null); // the mode's own default, not a factory hex + assert.equal(a.fontFamily, null); // Source Sans 3 + assert.equal(a.proseFontSize, 16); + assert.equal(a.sessionNameSerif, true); + assert.equal(a.mdRendering.headingWeight, 900); + assert.equal(a.mdRendering.boldColor, null); // white + assert.equal(a.mdRendering.italicColor, null); + }); + + it("has NO factory model pin — chat defaults and role overrides start absent", () => { + assert.equal("chat" in FACTORY_SETTINGS, false); + assert.equal("agent" in FACTORY_SETTINGS, false); + }); +}); + +describe("isHexColor", () => { + it("accepts #rgb and #rrggbb (case-insensitive), rejects everything else", () => { + for (const good of ["#fff", "#FFF", "#141414", "#8B5CF6"]) assert.equal(isHexColor(good), true, good); + for (const bad of ["fff", "#ffff", "#gggggg", "141414", "#12345", "", "white"]) { + assert.equal(isHexColor(bad), false, bad); + } + }); +}); + +describe("sanitizeUserSettings", () => { + it("round-trips a fully valid record field-for-field", () => { + const record = { + version: 1, + general: { autoUpdate: false }, + chat: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "low", graphContextTokens: 256 }, + agent: { + models: { + researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" }, + visualMaker: { thinkingLevel: "max" }, + }, + tools: { research: false, quiz: true }, + prompts: { "system-prompt.md": true, "teacher/20-teaching.md": false }, + }, + appearance: { + mode: "light", + background: "#ffffff", + fontFamily: "Some Font", + proseFontSize: 18, + sessionNameSerif: false, + mdRendering: { headingWeight: 700, boldColor: "#ff8800", italicColor: null }, + }, + graph: { enabled: false, embedding: true, clustering: false, labeling: true }, + }; + assert.deepEqual(sanitizeUserSettings(record), record); + }); + + it("null and non-objects → null (first-boot semantics), never throws", () => { + assert.equal(sanitizeUserSettings(null), null); + assert.equal(sanitizeUserSettings("junk"), null); + assert.equal(sanitizeUserSettings([1, 2]), null); + }); + + it("absent version defaults to CURRENT; unknown keys ride along VERBATIM", () => { + const out = sanitizeUserSettings({ futureSection: { knob: 3 }, general: { autoUpdate: true } }); + assert.ok(out); + assert.equal(out.version, CURRENT_SETTINGS_VERSION); + assert.deepEqual((out as Record).futureSection, { knob: 3 }); + assert.deepEqual(out.general, { autoUpdate: true }); + }); + + it("wrong-typed fields DROP (resolve to factory), never crash", () => { + const out = sanitizeUserSettings({ + general: { autoUpdate: "yes" }, + agent: { tools: { research: "no" }, prompts: { "system-prompt.md": 1 }, models: { researcher: "fast" } }, + appearance: { mode: "sepia", sessionNameSerif: "true" }, + graph: { enabled: 0 }, + }); + assert.ok(out); + assert.deepEqual(out.general, {}); + assert.deepEqual(out.agent, {}); + assert.deepEqual(out.appearance, {}); + assert.deepEqual(out.graph, {}); + }); + + it("out-of-range numbers CLAMP (proseFontSize, headingWeight)", () => { + const out = sanitizeUserSettings({ + appearance: { proseFontSize: 99, mdRendering: { headingWeight: 999 } }, + }); + assert.ok(out); + assert.equal(out.appearance?.proseFontSize, 24); + assert.equal(out.appearance?.mdRendering?.headingWeight, 900); + const low = sanitizeUserSettings({ appearance: { proseFontSize: 4, mdRendering: { headingWeight: 5 } } }); + assert.ok(low); + assert.equal(low.appearance?.proseFontSize, 12); + assert.equal(low.appearance?.mdRendering?.headingWeight, 100); + }); + + it("invalid colors become null (the factory default), valid ones pass", () => { + const out = sanitizeUserSettings({ + appearance: { background: "blue", mdRendering: { boldColor: "#f00", italicColor: 42 } }, + }); + assert.ok(out); + assert.equal(out.appearance?.background, null); + assert.equal(out.appearance?.mdRendering?.boldColor, "#f00"); + assert.equal(out.appearance?.mdRendering?.italicColor, null); + }); + + it("empty strings/whitespace fontFamily → null (Source Sans 3); wrong-type → null too (the default)", () => { + for (const bad of ["", " ", 12, { name: "x" }]) { + const out = sanitizeUserSettings({ appearance: { fontFamily: bad } }); + assert.ok(out); + assert.equal(out.appearance?.fontFamily, null, JSON.stringify(bad)); + } + }); + + it("agent roles: unknown roles drop, known roles keep only well-formed overrides", () => { + const out = sanitizeUserSettings({ + agent: { + models: { + teacher: { thinkingLevel: "low" }, // NOT a background role — drops + researcher: { model: { provider: "openrouter", id: "x/y" } }, + labeler: { model: { provider: 3, id: "x/y" }, thinkingLevel: "high" }, // bad model, keeps thinking + auditor: { thinkingLevel: "banana" }, // invalid level → empty override drops + }, + }, + }); + assert.ok(out); + const models = out.agent?.models ?? {}; + assert.equal("teacher" in models, false); + assert.deepEqual(models.researcher, { model: { provider: "openrouter", id: "x/y" } }); + assert.deepEqual(models.labeler, { thinkingLevel: "high" }); + assert.equal("auditor" in models, false); + }); + + it("partial maps: booleans survive, non-boolean entries drop individually", () => { + const out = sanitizeUserSettings({ agent: { tools: { quiz: false, demo: true, visual: "x" } } }); + assert.ok(out); + assert.deepEqual(out.agent?.tools, { quiz: false, demo: true }); + }); + + it("BACKGROUND_MODEL_ROLES matches the six background roles (config sync pinned in server tests)", () => { + assert.deepEqual([...BACKGROUND_MODEL_ROLES], [ + "researcher", + "demoBuilder", + "visualMaker", + "extractor", + "auditor", + "labeler", + ]); + }); +});