From bd3086194fd949080a3643db3e386792af5e4d47 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 18 Sep 2026 09:37:20 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20no=20baked-in=20default=20model=20?= =?UTF-8?q?=E2=80=94=20the=20new-session=20controls=20ARE=20the=20default?= =?UTF-8?q?=20settings=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner reframe (2026-09-18): the model + thinking + context controls on the home graph view and /chat/new are not a draft overlay on a baked-in default — they ARE the settings page for the default model, and the user's last choice IS the default. BYOK first boot has no keys and possibly no OpenRouter, so there is NO default to show: the controls render 'no model' ('—') until the first pick. The flash was the deeper symptom: the surfaces rendered from client state that started empty and fetched truth afterward, so every visit flashed the config pin (GLM/low — a dev artifact) for a few frames. Fix at the root: both pages are now force-dynamic server components that read the record (readDefaultSettings → initialSettings) and hand it to the client — the first frame shows truth. Dynamic routes aren't cached client-side (staleTimes.dynamic = 0), so every navigation re-reads the record; the useDefaultSettings hook keeps only save-side state (optimistic set + PUT). The GET-on-mount and its touch-guard are gone. ModelControl gains noDefaultModel (the default surfaces only): unset settings render no model/level, never the catalog pin; the thinking menu no-ops until a model exists. Chat surfaces keep the catalog fallback — the engine runs the config pin when a session record has no override, so the control keeps showing what will actually run. Renamed draft→default throughout: data/default-settings.json, GET/PUT /api/settings/defaults, useDefaultSettings. New sessions still inherit the default on creation exactly as before. Live-verified: first-boot 'no model' with zero flash, picker + Shift+Tab cycle, values held across session→/chat/new→home roundtrips and a reload (10×100ms samples, never the old default), zero console errors. --- app/api/chats/[id]/settings/route.ts | 2 +- app/api/settings/{draft => defaults}/route.ts | 28 ++++----- app/chat/new/page.tsx | 10 ++- app/page.tsx | 9 ++- components/default-settings.ts | 44 +++++++++++++ components/draft-settings.ts | 62 ------------------- components/home.tsx | 15 ++--- components/model-picker.tsx | 18 +++++- components/new-session.tsx | 19 +++--- components/stats-bar.tsx | 9 +++ config.ts | 12 ++-- src/server/index.ts | 2 +- src/server/settings-body.ts | 2 +- src/store/default-settings.ts | 45 ++++++++++++++ src/store/draft-settings.ts | 45 -------------- ...tings.test.ts => default-settings.test.ts} | 34 +++++----- 16 files changed, 188 insertions(+), 168 deletions(-) rename app/api/settings/{draft => defaults}/route.ts (52%) create mode 100644 components/default-settings.ts delete mode 100644 components/draft-settings.ts create mode 100644 src/store/default-settings.ts delete mode 100644 src/store/draft-settings.ts rename tests/{draft-settings.test.ts => default-settings.test.ts} (75%) diff --git a/app/api/chats/[id]/settings/route.ts b/app/api/chats/[id]/settings/route.ts index a22926b..0dd3999 100644 --- a/app/api/chats/[id]/settings/route.ts +++ b/app/api/chats/[id]/settings/route.ts @@ -11,7 +11,7 @@ import type { ChatSettings } from "@/src/core"; * by construction (agents are stateless per turn). * * The body parsing itself is shared with the draft route - * (src/server/settings-body.ts, draft-settings wave 2026-09-18). + * (src/server/settings-body.ts, default-settings wave 2026-09-18). */ export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; diff --git a/app/api/settings/draft/route.ts b/app/api/settings/defaults/route.ts similarity index 52% rename from app/api/settings/draft/route.ts rename to app/api/settings/defaults/route.ts index 83e90ff..6ffd0ce 100644 --- a/app/api/settings/draft/route.ts +++ b/app/api/settings/defaults/route.ts @@ -1,29 +1,29 @@ /** - * GET/PUT /api/settings/draft — server persistence for the DRAFT (pre- - * session) settings: the model + thinking-level + knowledge-context - * selections made on the new-session surfaces (the home graph view's topic - * dock and /chat/new). Both surfaces share ONE record (owner, 2026-09-18): - * whatever the learner last left the controls on is what the next visit - * shows. The file lives at data/draft-settings.json - * (config.DRAFT_SETTINGS_FILE). + * GET/PUT /api/settings/defaults — server persistence for the DEFAULT + * settings: THE default model + thinking level + knowledge-context + * budget. The new-session surfaces' controls (the home graph view's + * topic dock and /chat/new) ARE the settings page for the default model + * (owner, 2026-09-18): 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. Both surfaces share ONE record; the file + * lives at data/default-settings.json (config.DEFAULT_SETTINGS_FILE). * - * GET → 200 {settings} once saved, 200 null on first boot (the surfaces - * keep config-pinned defaults until the learner chooses something). + * GET → 200 {settings} once saved, 200 null on first boot ("no model"). * PUT → 200 {settings} — the VALIDATED copy persisted verbatim. The exact * per-chat contract (parseSettingsBody + the catalog gate): unknown * provider/model or an unsupported thinking level is a 400 carrying * the supported list, never a silent clamp. * * Per-session settings (PUT /api/chats/:id/settings) are a separate record - * untouched by this — the draft choice still rides along on session - * creation exactly as before. + * untouched by this — the default still rides along on session creation + * exactly as before. */ -import { readDraftSettings, writeDraftSettings, parseSettingsBody, validateChatSettings } from "@/src/server"; +import { readDefaultSettings, writeDefaultSettings, parseSettingsBody, validateChatSettings } from "@/src/server"; export const dynamic = "force-dynamic"; export async function GET() { - return Response.json(readDraftSettings()); + return Response.json(readDefaultSettings()); } export async function PUT(request: Request) { @@ -34,6 +34,6 @@ export async function PUT(request: Request) { if (!verdict.ok) { return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); } - writeDraftSettings(parsed.settings); + writeDefaultSettings(parsed.settings); return Response.json({ settings: parsed.settings }); } diff --git a/app/chat/new/page.tsx b/app/chat/new/page.tsx index cd0f0b4..63ffe9c 100644 --- a/app/chat/new/page.tsx +++ b/app/chat/new/page.tsx @@ -1,4 +1,5 @@ import { NewSession } from "@/components/new-session"; +import { readDefaultSettings } from "@/src/server"; export const dynamic = "force-dynamic"; @@ -8,7 +9,12 @@ export const dynamic = "force-dynamic"; * chat; on send it creates the chat (title = that message) and redirects to * the real page, whose initialPrompt path delivers the message as turn 1. * Static segment beats the dynamic [id] route — "new" is never a chat id. + * + * The default settings record is server-rendered in (readDefaultSettings → + * initialSettings): the controls show truth on the first frame, no + * default-model flash (the default IS the user's last choice — "no model" + * only before the first-ever pick). */ export default function NewSessionPage() { - return ; -} \ No newline at end of file + return ; +} diff --git a/app/page.tsx b/app/page.tsx index 2acb7bd..e61fc67 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,12 @@ import { Home } from "@/components/home"; +import { readDefaultSettings } from "@/src/server"; + +// Dynamic: the default settings record is server-rendered into the page — +// the model + thinking + context controls show truth on the FIRST frame +// (no default-model flash), and every navigation re-reads the record +// (dynamic routes aren't cached client-side). +export const dynamic = "force-dynamic"; export default function HomePage() { - return ; + return ; } diff --git a/components/default-settings.ts b/components/default-settings.ts new file mode 100644 index 0000000..5ae592a --- /dev/null +++ b/components/default-settings.ts @@ -0,0 +1,44 @@ +"use client"; + +/** + * THE default settings (owner, 2026-09-18): the new-session surfaces' + * model + thinking + knowledge-context controls are the settings page for + * the default model. There is NO baked-in default — BYOK first boot shows + * "no model" until the user picks one, and their last choice IS the + * default from then on. Both surfaces (the home graph view's topic dock + * and /chat/new) share ONE server-side record (GET/PUT + * /api/settings/defaults → data/default-settings.json). + * + * No loading state, no flash: the PAGES server-render the record + * (readDefaultSettings → initialSettings), so the controls show truth on + * the first frame — dynamic routes aren't cached client-side (staleTimes + * .dynamic = 0), so every navigation gets fresh server truth. This hook + * only holds the state between saves: initialSettings seeds it, and every + * change applies locally (optimistic — the controls only send valid + * values: the picker offers catalog models, the slider sends its stops) + * and persists fire-and-forget; a failed write logs, and the next + * navigation re-reads the persisted truth. + */ + +import { useCallback, useState } from "react"; +import type { ChatSettings } from "@/src/core"; + +export function useDefaultSettings(initialSettings: ChatSettings | null): { + settings: ChatSettings | undefined; + save: (settings: ChatSettings) => void; +} { + const [settings, setSettings] = useState(initialSettings ?? undefined); + + const save = useCallback((next: ChatSettings) => { + setSettings(next); + void fetch("/api/settings/defaults", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(next), + }).catch((err: unknown) => { + console.error("default settings write failed:", err); + }); + }, []); + + return { settings, save }; +} diff --git a/components/draft-settings.ts b/components/draft-settings.ts deleted file mode 100644 index 1ce7203..0000000 --- a/components/draft-settings.ts +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -/** - * The DRAFT (pre-session) settings hook — the shared persistence behind - * the two new-session surfaces (the home graph view's topic dock and - * /chat/new). The model + thinking + knowledge-context choices live in - * ONE server-side record (GET/PUT /api/settings/draft → - * data/draft-settings.json), so whatever the learner last left the - * controls on is what every later visit shows (owner, draft-settings wave - * 2026-09-18): navigating away and back keeps the feel. Per-session - * settings are a different record (the chat's own) — untouched here. - * - * Contract: settings === undefined until the first GET resolves — the - * controls render config-pinned defaults in that window, same as today. - * The GET is touch-guarded: if the learner changes something before the - * load lands, the loaded copy is dropped, never clobbering the choice. - * Saves are optimistic (the controls only ever send valid values — the - * picker offers catalog models, the slider sends its stops) and - * fire-and-forget; a failed write logs, and the next mount falls back to - * the persisted truth. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; -import type { ChatSettings } from "@/src/core"; - -export function useDraftSettings(): { - settings: ChatSettings | undefined; - save: (settings: ChatSettings) => void; -} { - const [settings, setSettings] = useState(undefined); - const touchedRef = useRef(false); - - useEffect(() => { - let cancelled = false; - fetch("/api/settings/draft") - .then((res) => (res.ok ? (res.json() as Promise) : null)) - .then((loaded) => { - // Never clobber a choice made while the load was in flight. - if (!cancelled && loaded !== null && !touchedRef.current) setSettings(loaded); - }) - .catch(() => { - /* transient — defaults stand, the next change rewrites the record */ - }); - return () => { - cancelled = true; - }; - }, []); - - const save = useCallback((next: ChatSettings) => { - touchedRef.current = true; - setSettings(next); - void fetch("/api/settings/draft", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(next), - }).catch((err: unknown) => { - console.error("draft settings write failed:", err); - }); - }, []); - - return { settings, save }; -} diff --git a/components/home.tsx b/components/home.tsx index 7746e03..ccafd8a 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { CornerDownLeftIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control"; -import { useDraftSettings } from "@/components/draft-settings"; +import { useDefaultSettings } from "@/components/default-settings"; import { DictationBar, useDictation } from "@/components/dictation"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; @@ -17,7 +17,7 @@ import type { ChatSettings, TurnUsage } from "@/src/core"; * under the header and the new-session dock, both of which float over it * with no gradient scrims. Sessions live in the toggleable sidebar. */ -export function Home() { +export function Home({ initialSettings }: { initialSettings: ChatSettings | null }) { const router = useRouter(); const [topic, setTopic] = useState(""); // Voice dictation (STT wave, 2026-09-17): finals append at the end of the @@ -26,13 +26,13 @@ export function Home() { append: (t) => setTopic((v) => (v ? v.replace(/\s+$/, "") + " " + t : t)), }); const [busy, setBusy] = useState(false); - // The model+thinking+context selection made pre-chat — the SHARED draft - // record (draft-settings wave, 2026-09-18): both new-session surfaces - // (this topic dock and /chat/new) read and write ONE persisted choice, - // so what the learner last left the controls on is what shows on return. + // THE default settings (owner, 2026-09-18): this topic dock IS the + // settings page for the default model — no baked-in default exists; the + // user's last choice (server-rendered in via initialSettings, shared + // with /chat/new through data/default-settings.json) IS the default. // Still carried to the chat POST then PUT-on-create (the model picker // defers its save when chatId is empty; see components/model-picker). - const { settings, save: saveDraft } = useDraftSettings(); + const { settings, save: saveDraft } = useDefaultSettings(initialSettings); const start = async () => { const t = topic.trim(); @@ -143,6 +143,7 @@ export function Home() { dictationSlot={} turns={[] as TurnUsage[]} chatId="" + noDefaultModel settings={settings} onSettingsSaved={saveDraft} leftSlot={ diff --git a/components/model-picker.tsx b/components/model-picker.tsx index b49441b..4fc08cd 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -86,6 +86,7 @@ export function ModelControl({ settings, onSaved, fallbackModelId, + noDefaultModel, }: { chatId: string; /** The record's current settings (undefined = config pin). */ @@ -95,6 +96,11 @@ export function ModelControl({ /** The last turn's actual model (from the turns log) — the label until * the catalog (with config's defaults) finishes loading. */ fallbackModelId?: string; + /** Default-surfaces only (owner, 2026-09-18): NO baked-in default — + * unset settings render "no model"/"—" (BYOK first boot), never the + * config pin. Chat surfaces keep the catalog-default fallback (the + * engine runs the pin, so the control shows what will run). */ + noDefaultModel?: boolean; }) { const catalog = useModelCatalog(); const [open, setOpen] = useState<"model" | "thinking" | null>(null); @@ -109,9 +115,12 @@ export function ModelControl({ const listRef = useRef(null); const defaults = catalog?.defaults; - const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId; - const effectiveLevel = - settings?.thinkingLevel ?? defaults?.thinkingLevel; + const effectiveId = noDefaultModel + ? settings?.model?.id + : (settings?.model?.id ?? defaults?.model.id ?? fallbackModelId); + const effectiveLevel = noDefaultModel + ? settings?.thinkingLevel + : (settings?.thinkingLevel ?? defaults?.thinkingLevel); const entry = catalog?.models.find((m) => m.id === effectiveId); const levels = entry?.supportedLevels ?? []; @@ -250,6 +259,9 @@ export function ModelControl({ type="button" data-testid="stats-thinking" onClick={() => { + // No model yet (noDefaultModel first boot) → nothing to set a + // level FOR: the click is a no-op, never an empty menu. + if (entry === undefined) return; setOpen((o) => (o === "thinking" ? null : "thinking")); setError(null); }} diff --git a/components/new-session.tsx b/components/new-session.tsx index 7945e14..3c2f210 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -6,7 +6,7 @@ import { Conversation, ConversationContent, ConversationEmptyState } from "@/com import { Composer } from "@/components/composer"; import { DictationBar, useDictation } from "@/components/dictation"; import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control"; -import { useDraftSettings } from "@/components/draft-settings"; +import { useDefaultSettings } from "@/components/default-settings"; import { PhaseChip } from "@/components/phase-divider"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; @@ -124,6 +124,7 @@ export function DraftShell({ dictationSlot={} turns={[]} chatId="" + noDefaultModel settings={settings} onSettingsSaved={onSettingsSaved} // The session-data slot (owner, 2026-09-15): pre-chat the left @@ -147,17 +148,17 @@ export function DraftShell({ ); } -export function NewSession() { +export function NewSession({ initialSettings }: { initialSettings: ChatSettings | null }) { const router = useRouter(); const [text, setText] = useState(""); const [busy, setBusy] = useState(false); - // The pre-chat model+thinking+context selection — the SHARED draft - // record (draft-settings wave, 2026-09-18): /chat/new and the home - // topic dock read and write ONE persisted choice, so what the learner - // last left the controls on is what shows on return. ModelControl with - // an empty chatId never PUTs — it hands the choice up; it still rides - // the session POST then PUT-on-create. - const { settings, save: saveDraft } = useDraftSettings(); + // THE default settings (owner, 2026-09-18): this surface and the home + // topic dock ARE the settings page for the default model — no baked-in + // default exists; the user's last choice (server-rendered in via + // initialSettings, shared with home through data/default-settings.json) + // IS the default. ModelControl with an empty chatId never PUTs — it + // hands the choice up; it still rides the session POST then PUT-on-create. + const { settings, save: saveDraft } = useDefaultSettings(initialSettings); const send = useCallback(() => { if (!text.trim() || busy) return; diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 8fc5f55..7df4f7e 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -34,11 +34,19 @@ export function StatsBar({ onSettingsSaved, leftSlot, dictationSlot, + noDefaultModel, }: { turns: TurnUsage[]; chatId: string; settings?: ChatSettings; onSettingsSaved: (settings: ChatSettings) => void; + /** Default-surfaces only (owner, 2026-09-18): there is NO baked-in + * default model — BYOK first boot shows "no model" until the user + * picks one, and that pick IS the default from then on. Chat surfaces + * keep the catalog-default fallback (the engine runs the config pin + * when a session record has no override — the control shows what will + * actually run). */ + noDefaultModel?: boolean; /** Pre-chat only: replaces the (empty) session-data summary — home and * the /chat/new draft mount the knowledge-context slider here. */ leftSlot?: ReactNode; @@ -175,6 +183,7 @@ export function StatsBar({ settings={settings} onSaved={onSettingsSaved} fallbackModelId={lastModel} + noDefaultModel={noDefaultModel} /> ); diff --git a/config.ts b/config.ts index e341bb1..d866f8a 100644 --- a/config.ts +++ b/config.ts @@ -134,9 +134,11 @@ export const EMBEDDINGS_FILE = path.join(PATHS.graph, "embeddings.json"); export const RENDER_SETTINGS_FILE = path.join(PATHS.graph, "render-settings.json"); /** - * DRAFT (pre-session) settings — the model + thinking + knowledge-context - * selections shared by the two new-session surfaces (the home graph view - * and /chat/new). One small JSON blob, read-modify-write, written only - * through the validated app/api/settings/draft route. + * DEFAULT settings — THE default model + thinking level + knowledge-context + * budget (owner, 2026-09-18): the new-session surfaces' controls are the + * settings page for the default model. There is NO baked-in default — + * BYOK first boot shows "no model" until the user picks one, and their + * last choice IS the default from then on. One small JSON blob, + * read-modify-write, written only through app/api/settings/defaults. */ -export const DRAFT_SETTINGS_FILE = path.join(DATA_ROOT, "draft-settings.json"); +export const DEFAULT_SETTINGS_FILE = path.join(DATA_ROOT, "default-settings.json"); diff --git a/src/server/index.ts b/src/server/index.ts index 6ee915d..02a2a34 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,7 +11,7 @@ export * from "./commit-positions.ts"; export * from "./jobs.ts"; export * from "./stores.ts"; export { readRenderSettings, writeRenderSettings } from "../store/render-settings.ts"; -export { readDraftSettings, writeDraftSettings } from "../store/draft-settings.ts"; +export { readDefaultSettings, writeDefaultSettings } from "../store/default-settings.ts"; export * from "./settings-body.ts"; export * from "./turn-liveness.ts"; export * from "./observability.ts"; diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index ad207de..0e548db 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -2,7 +2,7 @@ * 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 - * (draft-settings wave, 2026-09-18): identical parse, identical errors. + * (default-settings wave, 2026-09-18): identical parse, identical errors. * * Replace-whole semantics: the client sends the complete desired object; * absent fields are absent overrides ({} clears everything). No merges, so diff --git a/src/store/default-settings.ts b/src/store/default-settings.ts new file mode 100644 index 0000000..9288b53 --- /dev/null +++ b/src/store/default-settings.ts @@ -0,0 +1,45 @@ +/** + * 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 + * /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 + * 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". + */ + +import fs from "node:fs"; +import path from "node:path"; +import { DEFAULT_SETTINGS_FILE } from "../../config.ts"; +import type { ChatSettings } from "../core"; + +/** The persisted default 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 + } +} + +/** Persist the validated settings verbatim. */ +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"); +} diff --git a/src/store/draft-settings.ts b/src/store/draft-settings.ts deleted file mode 100644 index 8d5277a..0000000 --- a/src/store/draft-settings.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Server-side persistence for the DRAFT (pre-session) settings — the - * model + thinking-level + knowledge-context choices made on the - * new-session surfaces (the home graph view's topic dock and /chat/new). - * One small JSON blob: data/draft-settings.json, read-modify-write. - * - * Both surfaces share ONE record (owner, draft-settings wave 2026-09-18): - * whatever the learner last left the controls on is what the next visit - * shows — navigating away and back must not reset the feel. Per-session - * settings live in the chat record (PUT /api/chats/:id/settings) and are - * untouched by this. - * - * This module only moves bytes: the file is written exclusively through the - * validated route (app/api/settings/draft — parseSettingsBody + - * validateChatSettings gate every write), so no sanitizer runs here. On - * read, anything unreadable/corrupt is null = first boot = config defaults. - */ - -import fs from "node:fs"; -import path from "node:path"; -import { DRAFT_SETTINGS_FILE } from "../../config.ts"; -import type { ChatSettings } from "../core"; - -/** The persisted draft settings, or null when never saved / unreadable. */ -export function readDraftSettings(): ChatSettings | null { - let raw: string; - try { - raw = fs.readFileSync(DRAFT_SETTINGS_FILE, "utf-8"); - } catch { - return null; // never saved / unreadable → the surfaces use config defaults - } - 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 → defaults, never crash the surfaces - } -} - -/** Persist the validated settings verbatim. */ -export function writeDraftSettings(settings: ChatSettings): void { - fs.mkdirSync(path.dirname(DRAFT_SETTINGS_FILE), { recursive: true }); - fs.writeFileSync(DRAFT_SETTINGS_FILE, JSON.stringify(settings, null, "\t"), "utf-8"); -} diff --git a/tests/draft-settings.test.ts b/tests/default-settings.test.ts similarity index 75% rename from tests/draft-settings.test.ts rename to tests/default-settings.test.ts index ca0e734..d5e7bfa 100644 --- a/tests/draft-settings.test.ts +++ b/tests/default-settings.test.ts @@ -1,8 +1,8 @@ /** - * Tests for the draft-settings wave (2026-09-18): the shared settings-body - * parser (the one seam behind BOTH settings routes — per-chat and draft — - * so their parse behavior is identical) and the draft-settings store - * (data/draft-settings.json read/write, first-boot null semantics). + * 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). * * The store tests write the real repo data file (npm test's shared data/, * disposable scratch by doctrine) and clean up after themselves. @@ -13,8 +13,8 @@ import fs from "node:fs"; import { afterEach, describe, it } from "node:test"; import { GRAPH_CONTEXT_TOKENS_MAX, type ChatSettings } from "../src/core"; import { parseSettingsBody } from "../src/server/settings-body.ts"; -import { DRAFT_SETTINGS_FILE } from "../config.ts"; -import { readDraftSettings, writeDraftSettings } from "../src/store/draft-settings.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", () => { @@ -67,10 +67,10 @@ describe("parseSettingsBody (shared by both settings routes)", () => { }); }); -describe("the draft-settings store", () => { +describe("the default-settings store", () => { afterEach(() => { try { - fs.rmSync(DRAFT_SETTINGS_FILE); + fs.rmSync(DEFAULT_SETTINGS_FILE); } catch { /* nothing to clean */ } @@ -78,11 +78,11 @@ describe("the draft-settings store", () => { it("reads null on first boot (no file) — the surfaces use config defaults", () => { try { - fs.rmSync(DRAFT_SETTINGS_FILE); + fs.rmSync(DEFAULT_SETTINGS_FILE); } catch { /* absent already */ } - assert.equal(readDraftSettings(), null); + assert.equal(readDefaultSettings(), null); }); it("write then read roundtrips verbatim — validated shape, no sanitizer games", () => { @@ -91,18 +91,18 @@ describe("the draft-settings store", () => { thinkingLevel: "high", graphContextTokens: 1024, }; - writeDraftSettings(settings); - assert.deepEqual(readDraftSettings(), settings); + writeDefaultSettings(settings); + assert.deepEqual(readDefaultSettings(), settings); }); it("an empty {} record roundtrips (all overrides cleared)", () => { - writeDraftSettings({}); - assert.deepEqual(readDraftSettings(), {}); + writeDefaultSettings({}); + assert.deepEqual(readDefaultSettings(), {}); }); it("a corrupt file reads as null — defaults, never a crash", () => { - fs.rmSync(DRAFT_SETTINGS_FILE, { force: true }); // a directory from a broken earlier run also blocks - fs.writeFileSync(DRAFT_SETTINGS_FILE, "{ not json", "utf-8"); - assert.equal(readDraftSettings(), null); + 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); }); });