wave 2 (platform stream): settings storage & plumbing foundation (P2)

- src/core/settings.ts: versioned DELTA record schema + sanitizer (unknown keys preserved)
  — general/chat/agent{models,tools,prompts}/appearance/graph sections
- src/core/settings-resolution.ts: pure resolveSettings (factory defaults → user delta),
  extraction/graph-context channel predicates
- src/store/settings.ts + prompt-overrides.ts: SettingsStore/PromptOverrideStore adapters
  wired through stores.ts (effectiveSettings() fresh per call); lazy migration of
  default-settings.json into record.chat
- app/api/settings/** routes (record, section bodies, prompt overrides per segment)
- chat-handler: graph gates at trigger call-sites + toolToggles injection seam
- config.ts: SETTINGS_FILE + PROMPTS_OVERRIDE_DIR paths

587/587 tests; typecheck/lint/build clean
This commit is contained in:
amosblomqvist
2026-09-19 18:05:30 +03:00
parent 36c5d19437
commit cb4fe25c89
24 changed files with 2096 additions and 101 deletions
+86
View File
@@ -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<unknown>;
export async function PUT(request: Request, ctx: { params: Promise<{ section: string }> }) {
const { section } = await ctx.params;
const parser = (SETTINGS_SECTIONS as Record<string, AnySectionParser>)[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 });
}
@@ -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/<segment path mirror>. 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 });
}
+29
View File
@@ -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),
});
}
+23
View File
@@ -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() });
}
+33
View File
@@ -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. * read-modify-write, written only through app/api/settings/defaults.
*/ */
export const DEFAULT_SETTINGS_FILE = path.join(DATA_ROOT, "default-settings.json"); 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/<file> / prompts/prober/<file>. 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");
+5
View File
@@ -34,6 +34,11 @@ export * from "./ui-message.ts";
export * from "./tool-parts.ts"; export * from "./tool-parts.ts";
export * from "./prices.ts"; export * from "./prices.ts";
export * from "./fuzzy.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). // keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe).
export * from "./keyboard.ts"; export * from "./keyboard.ts";
export * from "./coverage.ts"; export * from "./coverage.ts";
+42
View File
@@ -30,6 +30,7 @@ import type {
ClusterLabeling, ClusterLabeling,
EmbeddingsLedger, EmbeddingsLedger,
} from "./contracts.ts"; } from "./contracts.ts";
import type { UserSettings } from "./settings.ts";
// ─── ChatStore — the session record's home (data/chats) ───────────────────── // ─── 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). */ /** The job fulfilling a given tool part, if any (spawn-hook dedup). */
findJobByToolCallId(chatId: string, toolCallId: string): JobRecord | null; 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;
}
+149
View File
@@ -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<BackgroundModelRole, { model: string; effort: ThinkingLevel }>;
/** 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<GeneralSettings>;
/** The default ChatSettings for new sessions — passthrough delta
* ({} = no default = BYOK "no model" first boot). */
chat: ChatSettings;
agent: {
models: Record<BackgroundModelRole, EffectiveModelSpec>;
/** Delta map as stored — consumers resolve absent key → enabled. */
tools: Record<string, boolean>;
/** Delta map as stored — absent key → override off. */
prompts: Record<string, boolean>;
};
appearance: EffectiveAppearance;
graph: Required<GraphSettings>;
}
/** 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<BackgroundModelRole, EffectiveModelSpec>;
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<GraphSettings>): 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<GraphSettings>): boolean {
return graph.enabled && graph.embedding;
}
+344
View File
@@ -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<Record<BackgroundModelRole, RoleModelOverride>>;
/** Tool toggles keyed by tool id (absent key = enabled). User-level
* only — no per-chat override (owner decision 2026-09-19). */
tools?: Record<string, boolean>;
/** 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<string, boolean>;
}
/** 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<GeneralSettings>;
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<GraphSettings>;
}
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<string, unknown>): 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<string, unknown>;
const out: RoleModelOverride = {};
if (
typeof r.model === "object" &&
r.model !== null &&
!Array.isArray(r.model) &&
typeof (r.model as Record<string, unknown>).provider === "string" &&
typeof (r.model as Record<string, unknown>).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<string, boolean> | undefined {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const out: Record<string, boolean> = {};
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<string, unknown>): AgentSettings {
const out: AgentSettings = {};
if (raw.models !== null && typeof raw.models === "object" && !Array.isArray(raw.models)) {
const models: Partial<Record<BackgroundModelRole, RoleModelOverride>> = {};
for (const [role, override] of Object.entries(raw.models as Record<string, unknown>)) {
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<string, unknown>;
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<string, unknown>): 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<string, unknown>): 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<string, unknown>;
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<string, unknown>);
}
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<string, unknown>);
}
if (r.appearance !== null && typeof r.appearance === "object" && !Array.isArray(r.appearance)) {
out.appearance = sanitizeAppearance(r.appearance as Record<string, unknown>);
}
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
}
// 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;
}
+36 -6
View File
@@ -1,6 +1,6 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import type { Usage } from "@earendil-works/pi-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 { piEngineFor } from "../agent/models.ts";
import { createJobRunner } from "../agent/subagents/runner.ts"; import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.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 { claimSteers, deliverJobResult, setContextRespawn, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { recordToPiMessages } from "./pi/record-adapter.ts"; import { recordToPiMessages } from "./pi/record-adapter.ts";
import { createPiTurnResponse } from "./pi/ui-stream.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 { beginTurn, endTurn } from "./turn-liveness.ts";
import { persistTurn, triggerExtraction, triggerGraphContext, withMessageIds } from "./turn-service.ts"; import { persistTurn, triggerExtraction, triggerGraphContext, withMessageIds } from "./turn-service.ts";
import type { CallUsage } from "../core/contracts.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 steers.length > 0 ? [...(body.messages as UIMessage[]), ...steers] : body.messages
) as UIMessage[]; ) 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 // 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 // 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 // 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. // runner module; for every session past its start this is a no-op.
// Extraction stays in onFinish by contrast: it depends on quiz // Extraction stays in onFinish by contrast: it depends on quiz
// answers accumulated DURING the turn. // 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 // 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 // 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/ // 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 // pi-engine-swap.md §2), so each request builds a fresh one off the
// transcript — the factory mirrors that seam exactly. // 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<string, boolean> } = {
engine,
messages: recordToPiMessages(outgoing),
onUsage: collectUsage,
toolToggles: effective.agent.tools,
};
const constructed: TeachingAgent = buildAgent const constructed: TeachingAgent = buildAgent
? buildAgent(outgoing) ? buildAgent(outgoing)
: teaching : teaching
@@ -208,7 +232,7 @@ export async function handleChatRequest(
jobStore: stores.jobStore, jobStore: stores.jobStore,
spawn: (job, input) => void jobRunner.runJob(job.id, input), spawn: (job, input) => void jobRunner.runJob(job.id, input),
}, },
{ engine, messages: recordToPiMessages(outgoing), onUsage: collectUsage }, agentOpts,
) )
: createProberAgent( : createProberAgent(
{ {
@@ -216,7 +240,7 @@ export async function handleChatRequest(
chatStore: stores.chatStore, chatStore: stores.chatStore,
deliver: (steer) => void deliverJobResult(steer), deliver: (steer) => void deliverJobResult(steer),
}, },
{ engine, messages: recordToPiMessages(outgoing), onUsage: collectUsage }, agentOpts,
); );
// A test-injected agent gets no usage collector (tests assert the // A test-injected agent gets no usage collector (tests assert the
@@ -253,7 +277,13 @@ export async function handleChatRequest(
steps, 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 // LAST: a poller that sees not-in-flight must read an already-persisted
// record — endTurn only after persistTurn has done its job. // record — endTurn only after persistTurn has done its job.
endTurn(chatId); endTurn(chatId);
+14
View File
@@ -12,6 +12,20 @@ export * from "./jobs.ts";
export * from "./stores.ts"; export * from "./stores.ts";
export { readRenderSettings, writeRenderSettings } from "../store/render-settings.ts"; export { readRenderSettings, writeRenderSettings } from "../store/render-settings.ts";
export { readDefaultSettings, writeDefaultSettings } from "../store/default-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 "./settings-body.ts";
export * from "./turn-liveness.ts"; export * from "./turn-liveness.ts";
export * from "./observability.ts"; export * from "./observability.ts";
+16 -4
View File
@@ -18,6 +18,7 @@ import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-work
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { MODELS } from "../../config.ts"; import { MODELS } from "../../config.ts";
import { piModels } from "../agent/models.ts"; import { piModels } from "../agent/models.ts";
import { settingsStore } from "../store/settings.ts";
import type { ChatSettings } from "../core/contracts/chat.ts"; import type { ChatSettings } from "../core/contracts/chat.ts";
import type { import type {
CatalogDefaults, CatalogDefaults,
@@ -28,14 +29,25 @@ import type {
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */ /** The picker's client-safe model entry (contract: core/contracts/models.ts). */
export type ModelDto = ModelCatalogEntry; export type ModelDto = ModelCatalogEntry;
/** The conversation agents' config pin — what a settings-less chat runs. */ /** The conversation agents' resolved default pin (pure form — the record's
export function catalogDefaults(): CatalogDefaults { * 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 { return {
model: { provider: "openrouter", id: MODELS.prober.model }, model: chat?.model ?? { provider: "openrouter", id: MODELS.prober.model },
thinkingLevel: MODELS.prober.effort as ThinkingLevel, 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. */ /** Project one catalog model to the client DTO. */
export function toModelDto(model: Model<Api>): ModelDto { export function toModelDto(model: Model<Api>): ModelDto {
return { return {
+226 -10
View File
@@ -1,23 +1,34 @@
/** /**
* The shared settings-PUT body parser — the one seam both settings routes * The shared settings-PUT body parsers — one seam per settings surface.
* use (per-chat: app/api/chats/[id]/settings; pre-session draft: * Extracted verbatim from the per-chat route (default-settings wave,
* app/api/settings/draft). Extracted verbatim from the per-chat route * 2026-09-18): identical parse, identical errors. The section parsers
* (default-settings wave, 2026-09-18): identical parse, identical errors. * (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 * 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 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. */ /** Parsed-or-rejected — `ok: false` carries the 400's error message. */
export type SettingsBodyResult = export type SettingsBodyResult<T> =
| { ok: true; settings: ChatSettings } | { ok: true; settings: T }
| { ok: false; error: string }; | { ok: false; error: string };
export function parseSettingsBody(body: unknown): SettingsBodyResult { export function parseSettingsBody(body: unknown): SettingsBodyResult<ChatSettings> {
if (!body || typeof body !== "object" || Array.isArray(body)) { if (!body || typeof body !== "object" || Array.isArray(body)) {
return { ok: false, error: "Invalid JSON body" }; return { ok: false, error: "Invalid JSON body" };
} }
@@ -63,3 +74,208 @@ export function parseSettingsBody(body: unknown): SettingsBodyResult {
} }
return { ok: true, settings }; 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<string, unknown> | null {
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
return body as Record<string, unknown>;
}
/** general: { autoUpdate?: boolean } */
export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<GeneralSettings> {
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<string, unknown>).provider !== "string" ||
typeof (raw.model as Record<string, unknown>).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<string, boolean>; error?: string } {
if (value === undefined) return {};
const raw = asRecord(value);
if (!raw) return { error: `${field} must be an object` };
const out: Record<string, boolean> = {};
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<AgentSettings> {
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<AgentSettings["models"]> = {};
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<AppearanceSettings> {
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<AppearanceSettings["mdRendering"]> = {};
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<GraphSettings> {
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;
+28
View File
@@ -6,14 +6,42 @@
* file (and the adapter implementations in src/store/). * file (and the adapter implementations in src/store/).
*/ */
import { MODELS } from "../../config.ts";
import { chatStore } from "../store/chats.ts"; import { chatStore } from "../store/chats.ts";
import { graphStore } from "../store/graph.ts"; import { graphStore } from "../store/graph.ts";
import { jobStore } from "../store/jobs.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. */ /** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
export const stores = { export const stores = {
chatStore, chatStore,
graphStore, graphStore,
jobStore, jobStore,
settingsStore,
}; };
export type Stores = typeof stores; 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);
}
+19 -28
View File
@@ -1,45 +1,36 @@
/** /**
* Server-side persistence for the DEFAULT settings — THE default model + * Server-side persistence for the DEFAULT CHAT settings — THE default
* thinking level + knowledge-context budget (owner, 2026-09-18): the * model + thinking level + knowledge-context budget (owner, 2026-09-18):
* new-session surfaces' controls (the home graph view's topic dock and * 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 * /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 * 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 * server-render from this record (initialSettings), so the controls show
* truth on the first frame — no default-model flash ever. Per-session * 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 * settings live in the chat record (PUT /api/chats/:id/settings) and are
* untouched by this; new sessions inherit the default on creation. * untouched by this; new sessions inherit the default on creation.
* *
* This module only moves bytes: the file is written exclusively through the * BACKED BY THE SETTINGS RECORD (settings-storage wave, 2026-09-19): the
* validated route (app/api/settings/defaults — parseSettingsBody + * default chat settings are the `chat` section of data/settings.json
* validateChatSettings gate every write), so no sanitizer runs here. On * (src/store/settings.ts — same delta semantics, same file, one record).
* read, anything unreadable/corrupt is null = first boot = "no model". * 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 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 { export function readDefaultSettings(): ChatSettings | null {
let raw: string; return readSettings()?.chat ?? null;
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. */ /** 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 { export function writeDefaultSettings(settings: ChatSettings): void {
fs.mkdirSync(path.dirname(DEFAULT_SETTINGS_FILE), { recursive: true }); const record = readSettings();
fs.writeFileSync(DEFAULT_SETTINGS_FILE, JSON.stringify(settings, null, "\t"), "utf-8"); writeSettings({ ...(record ?? { version: 1 }), chat: settings });
} }
+174
View File
@@ -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/<file>" / "prober/<file>" 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<string, boolean>,
): 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<string>();
for (const agent of ["prober", "teacher"] as const) {
for (const segment of listSourceSegments(agent)) paths.add(segment.path);
}
return [...paths].sort();
}
+93
View File
@@ -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,
};
+8 -51
View File
@@ -1,20 +1,17 @@
/** /**
* Tests for the default-settings wave (2026-09-18): the shared settings-body * Tests for the shared settings-body parser, chat-section flavor
* parser (the one seam behind BOTH settings routes — per-chat and defaults — * (default-settings wave, 2026-09-18; record-backed 2026-09-19): the seam
* so their parse behavior is identical) and the default-settings store * behind BOTH chat-settings routes — per-chat and the defaults alias.
* (data/default-settings.json read/write, first-boot null semantics).
* *
* The store tests write the real repo data file (npm test's shared data/, * The record store, the legacy-file migration, and the record-backed
* disposable scratch by doctrine) and clean up after themselves. * 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 assert from "node:assert/strict";
import fs from "node:fs"; import { describe, it } from "node:test";
import { afterEach, describe, it } from "node:test"; import { GRAPH_CONTEXT_TOKENS_MAX } from "../src/core";
import { GRAPH_CONTEXT_TOKENS_MAX, type ChatSettings } from "../src/core";
import { parseSettingsBody } from "../src/server/settings-body.ts"; 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)", () => { describe("parseSettingsBody (shared by both settings routes)", () => {
it("an empty object parses to {} — the clear-overrides write", () => { 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); 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);
});
});
+16 -2
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; 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 { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { MODELS } from "../config.ts"; import { MODELS } from "../config.ts";
import { piEngineFor } from "../src/agent/models.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", () => { 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.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"); 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", () => { it("toModelDto projects the raw catalog model verbatim", () => {
const raw = piEngineFor("prober").model; // the catalog's glm entry const raw = piEngineFor("prober").model; // the catalog's glm entry
const dto = toModelDto(raw); const dto = toModelDto(raw);
+129
View File
@@ -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");
});
});
+152
View File
@@ -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"]);
});
});
+126
View File
@@ -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);
});
});
+122
View File
@@ -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);
});
});
+172
View File
@@ -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<string, unknown>).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",
]);
});
});