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:
@@ -34,6 +34,11 @@ export * from "./ui-message.ts";
|
||||
export * from "./tool-parts.ts";
|
||||
export * from "./prices.ts";
|
||||
export * from "./fuzzy.ts";
|
||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
||||
// view. Browser-safe — the Wave-4 panel imports these directly.
|
||||
export * from "./settings.ts";
|
||||
export * from "./settings-resolution.ts";
|
||||
// keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe).
|
||||
export * from "./keyboard.ts";
|
||||
export * from "./coverage.ts";
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
ClusterLabeling,
|
||||
EmbeddingsLedger,
|
||||
} from "./contracts.ts";
|
||||
import type { UserSettings } from "./settings.ts";
|
||||
|
||||
// ─── ChatStore — the session record's home (data/chats) ─────────────────────
|
||||
|
||||
@@ -148,3 +149,44 @@ export interface JobStore {
|
||||
/** The job fulfilling a given tool part, if any (spawn-hook dedup). */
|
||||
findJobByToolCallId(chatId: string, toolCallId: string): JobRecord | null;
|
||||
}
|
||||
|
||||
// ─── SettingsStore — THE user settings record (settings-storage wave, 2026-09-19) ──
|
||||
|
||||
/**
|
||||
* The one record for every non-secret product preference (core/settings.ts
|
||||
* UserSettings — delta semantics, see that module's doc). The store is
|
||||
* replace-whole on the RECORD; per-section scoping is a server-band concern
|
||||
* (single process, no stale-client hazard to defend against at this seam).
|
||||
* Lazy migration (default-settings.json → the record's chat section, schema
|
||||
* version bumps) happens inside readSettings, never in the callers.
|
||||
*/
|
||||
export interface SettingsStore {
|
||||
/** The sanitized record, or null when none exists yet (first boot). */
|
||||
readSettings(): UserSettings | null;
|
||||
/** Persist the record (replace-whole). Callers pass a SANITIZED record. */
|
||||
writeSettings(settings: UserSettings): void;
|
||||
}
|
||||
|
||||
// ─── PromptOverrideStore — per-segment system-prompt overrides (2026-09-19) ──
|
||||
|
||||
/**
|
||||
* User text overriding ONE source prompt segment (the shared base or one
|
||||
* numeric fragment), stored at the data-dir seam mirroring the source
|
||||
* layout (config.PROMPTS_OVERRIDE_DIR). File presence is NOT the override:
|
||||
* the settings record's agent.prompts switch is the explicit on/off, so a
|
||||
* user can keep a customized file on disk while running the source default.
|
||||
* The segment LIST is always derived from source at runtime — see the
|
||||
* adapter (src/store/prompt-overrides.ts) for the loader rules.
|
||||
*/
|
||||
export interface PromptOverrideStore {
|
||||
/** Every override path currently on disk ("system-prompt.md",
|
||||
* "teacher/20-teaching.md", ...), POSIX-relative, sorted. */
|
||||
listOverrides(): string[];
|
||||
/** The override text, or null when the path has no file. */
|
||||
readOverride(segmentPath: string): string | null;
|
||||
/** Persist override text for a segment path (creates parents). */
|
||||
writeOverride(segmentPath: string, text: string): void;
|
||||
/** Remove the override file, if present (switches are untouched — an
|
||||
* on-switch with no file composes the source default, inert). */
|
||||
deleteOverride(segmentPath: string): void;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { createProberAgent, createTeacherAgent, type TeachingAgent } from "../agent/teacher-agent.ts";
|
||||
import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts";
|
||||
import { piEngineFor } from "../agent/models.ts";
|
||||
import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
@@ -10,7 +10,8 @@ import type { ModelRole } from "../../config.ts";
|
||||
import { claimSteers, deliverJobResult, setContextRespawn, setJobRespawn, setTurnRunner } from "./jobs.ts";
|
||||
import { recordToPiMessages } from "./pi/record-adapter.ts";
|
||||
import { createPiTurnResponse } from "./pi/ui-stream.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import { effectiveSettings, stores } from "./stores.ts";
|
||||
import { extractionEnabled, graphContextEnabled } from "../core/settings-resolution.ts";
|
||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||
import { persistTurn, triggerExtraction, triggerGraphContext, withMessageIds } from "./turn-service.ts";
|
||||
import type { CallUsage } from "../core/contracts.ts";
|
||||
@@ -137,6 +138,12 @@ export async function handleChatRequest(
|
||||
steers.length > 0 ? [...(body.messages as UIMessage[]), ...steers] : body.messages
|
||||
) as UIMessage[];
|
||||
|
||||
// THE SETTINGS VIEW (settings-storage wave, 2026-09-19): resolved fresh
|
||||
// per turn — graph gates, tool toggles, and (via the store) the record's
|
||||
// chat defaults all read from ONE small file. No caching: a stale toggle
|
||||
// after a settings PUT would be worse than the read.
|
||||
const effective = effectiveSettings();
|
||||
|
||||
// Write-ahead persistence: the request body IS the client's canonical
|
||||
// state, so it goes on disk BEFORE the stream starts. The learner's answer
|
||||
// is durable the instant the request arrives — any remount, second tab, or
|
||||
@@ -161,7 +168,12 @@ export async function handleChatRequest(
|
||||
// runner module; for every session past its start this is a no-op.
|
||||
// Extraction stays in onFinish by contrast: it depends on quiz
|
||||
// answers accumulated DURING the turn.
|
||||
triggerGraphContext(graphContext, chatId);
|
||||
// GRAPH GATES (settings-storage wave, 2026-09-19): the read channel
|
||||
// needs BOTH the master switch and the embedding rung (injection is a
|
||||
// vector query); with graph off there is no context job at all.
|
||||
if (graphContextEnabled(effective.graph)) {
|
||||
triggerGraphContext(graphContext, chatId);
|
||||
}
|
||||
|
||||
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
|
||||
// message_end events. Every model call's usage lands in the chat record's
|
||||
@@ -198,6 +210,18 @@ export async function handleChatRequest(
|
||||
// Tests inject a factory: the agent is stateless per turn (docs/
|
||||
// pi-engine-swap.md §2), so each request builds a fresh one off the
|
||||
// transcript — the factory mirrors that seam exactly.
|
||||
// CROSS-STREAM SEAM (agreed 2026-09-19, settings wave): `toolToggles`
|
||||
// rides the build opts as the user-level DELTA map (absent key =
|
||||
// enabled — quiz, ask_user_question, research, demo, visual). The
|
||||
// Teacher stream adds the field to AgentBuildOptions + the filtering in
|
||||
// createTeacherTools / the prober's registry this same wave; until that
|
||||
// merge, the extra property is structurally ignored (a harmless no-op).
|
||||
const agentOpts: AgentBuildOptions & { toolToggles?: Record<string, boolean> } = {
|
||||
engine,
|
||||
messages: recordToPiMessages(outgoing),
|
||||
onUsage: collectUsage,
|
||||
toolToggles: effective.agent.tools,
|
||||
};
|
||||
const constructed: TeachingAgent = buildAgent
|
||||
? buildAgent(outgoing)
|
||||
: teaching
|
||||
@@ -208,7 +232,7 @@ export async function handleChatRequest(
|
||||
jobStore: stores.jobStore,
|
||||
spawn: (job, input) => void jobRunner.runJob(job.id, input),
|
||||
},
|
||||
{ engine, messages: recordToPiMessages(outgoing), onUsage: collectUsage },
|
||||
agentOpts,
|
||||
)
|
||||
: createProberAgent(
|
||||
{
|
||||
@@ -216,7 +240,7 @@ export async function handleChatRequest(
|
||||
chatStore: stores.chatStore,
|
||||
deliver: (steer) => void deliverJobResult(steer),
|
||||
},
|
||||
{ engine, messages: recordToPiMessages(outgoing), onUsage: collectUsage },
|
||||
agentOpts,
|
||||
);
|
||||
|
||||
// A test-injected agent gets no usage collector (tests assert the
|
||||
@@ -253,7 +277,13 @@ export async function handleChatRequest(
|
||||
steps,
|
||||
});
|
||||
}
|
||||
triggerExtraction(stores, extraction, chatId);
|
||||
// GRAPH GATE (settings-storage wave, 2026-09-19): the write channel —
|
||||
// extraction, its auditor — dies with the master switch. (The deeper
|
||||
// pipeline knobs — embedding/clustering/labeling — gate inside the
|
||||
// pipeline and its consumers, later waves, via the same resolved view.)
|
||||
if (extractionEnabled(effective.graph)) {
|
||||
triggerExtraction(stores, extraction, chatId);
|
||||
}
|
||||
// LAST: a poller that sees not-in-flight must read an already-persisted
|
||||
// record — endTurn only after persistTurn has done its job.
|
||||
endTurn(chatId);
|
||||
|
||||
@@ -12,6 +12,20 @@ export * from "./jobs.ts";
|
||||
export * from "./stores.ts";
|
||||
export { readRenderSettings, writeRenderSettings } from "../store/render-settings.ts";
|
||||
export { readDefaultSettings, writeDefaultSettings } from "../store/default-settings.ts";
|
||||
// The settings record + prompt overrides (settings-storage wave, 2026-09-19).
|
||||
// The *Record aliases keep the app/api/settings routes explicit about which
|
||||
// seam they touch (the raw record, not the chat-section convenience view).
|
||||
export {
|
||||
readSettings as readSettingsRecord,
|
||||
writeSettings as writeSettingsRecord,
|
||||
settingsStore,
|
||||
} from "../store/settings.ts";
|
||||
export {
|
||||
promptOverrides,
|
||||
effectivePromptSegments,
|
||||
allSegmentPaths,
|
||||
isValidSegmentPath,
|
||||
} from "../store/prompt-overrides.ts";
|
||||
export * from "./settings-body.ts";
|
||||
export * from "./turn-liveness.ts";
|
||||
export * from "./observability.ts";
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getSupportedThinkingLevels, type Api, type Model } from "@earendil-work
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { MODELS } from "../../config.ts";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { settingsStore } from "../store/settings.ts";
|
||||
import type { ChatSettings } from "../core/contracts/chat.ts";
|
||||
import type {
|
||||
CatalogDefaults,
|
||||
@@ -28,14 +29,25 @@ import type {
|
||||
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
|
||||
export type ModelDto = ModelCatalogEntry;
|
||||
|
||||
/** The conversation agents' config pin — what a settings-less chat runs. */
|
||||
export function catalogDefaults(): CatalogDefaults {
|
||||
/** The conversation agents' resolved default pin (pure form — the record's
|
||||
* chat section in, CatalogDefaults out; `catalogDefaults()` below is the
|
||||
* fs wrapper). Absent fields (or no record — BYOK first boot) fall back
|
||||
* to the config pin, exactly as before. */
|
||||
export function catalogDefaultsFor(chat?: ChatSettings): CatalogDefaults {
|
||||
return {
|
||||
model: { provider: "openrouter", id: MODELS.prober.model },
|
||||
thinkingLevel: MODELS.prober.effort as ThinkingLevel,
|
||||
model: chat?.model ?? { provider: "openrouter", id: MODELS.prober.model },
|
||||
thinkingLevel: (chat?.thinkingLevel ?? MODELS.prober.effort) as ThinkingLevel,
|
||||
};
|
||||
}
|
||||
|
||||
/** The conversation agents' config pin — what a settings-less chat runs.
|
||||
* (settings-storage wave, 2026-09-19): the user's picked default rides in
|
||||
* the settings record's `chat` section; the picker's defaults row shows
|
||||
* it once chosen. */
|
||||
export function catalogDefaults(): CatalogDefaults {
|
||||
return catalogDefaultsFor(settingsStore.readSettings()?.chat);
|
||||
}
|
||||
|
||||
/** Project one catalog model to the client DTO. */
|
||||
export function toModelDto(model: Model<Api>): ModelDto {
|
||||
return {
|
||||
|
||||
+226
-10
@@ -1,23 +1,34 @@
|
||||
/**
|
||||
* The shared settings-PUT body parser — the one seam both settings routes
|
||||
* use (per-chat: app/api/chats/[id]/settings; pre-session draft:
|
||||
* app/api/settings/draft). Extracted verbatim from the per-chat route
|
||||
* (default-settings wave, 2026-09-18): identical parse, identical errors.
|
||||
* The shared settings-PUT body parsers — one seam per settings surface.
|
||||
* Extracted verbatim from the per-chat route (default-settings wave,
|
||||
* 2026-09-18): identical parse, identical errors. The section parsers
|
||||
* (settings-storage wave, 2026-09-19) follow the same house rules.
|
||||
*
|
||||
* Replace-whole semantics: the client sends the complete desired object;
|
||||
* Replace-whole semantics: the client sends the complete desired SECTION;
|
||||
* absent fields are absent overrides ({} clears everything). No merges, so
|
||||
* a stale client can never resurrect a cleared override.
|
||||
* a stale client can never resurrect a cleared override. Unknown body keys
|
||||
* are dropped (same tolerance as parseSettingsBody) — unknown DISK keys are
|
||||
* a different concern and are preserved by the sanitizer (core/settings.ts).
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { GRAPH_CONTEXT_TOKENS_MAX, type ChatSettings } from "../core";
|
||||
import { GRAPH_CONTEXT_TOKENS_MAX, isHexColor, type ChatSettings } from "../core";
|
||||
import type {
|
||||
AgentSettings,
|
||||
AppearanceSettings,
|
||||
BackgroundModelRole,
|
||||
GeneralSettings,
|
||||
GraphSettings,
|
||||
RoleModelOverride,
|
||||
} from "../core/settings.ts";
|
||||
import { BACKGROUND_MODEL_ROLES } from "../core/settings.ts";
|
||||
|
||||
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
|
||||
export type SettingsBodyResult =
|
||||
| { ok: true; settings: ChatSettings }
|
||||
export type SettingsBodyResult<T> =
|
||||
| { ok: true; settings: T }
|
||||
| { 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)) {
|
||||
return { ok: false, error: "Invalid JSON body" };
|
||||
}
|
||||
@@ -63,3 +74,208 @@ export function parseSettingsBody(body: unknown): SettingsBodyResult {
|
||||
}
|
||||
return { ok: true, settings };
|
||||
}
|
||||
|
||||
// ─── Section parsers (settings-storage wave, 2026-09-19) ────────────────────
|
||||
// One parser per settings.json section; the route maps the URL section to
|
||||
// its parser via SETTINGS_SECTIONS below. Same discipline as
|
||||
// parseSettingsBody: strict on every known field (a bad type/shape is a
|
||||
// 400, never a silent clamp/drop), unknown keys dropped.
|
||||
|
||||
function asRecord(body: unknown): Record<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;
|
||||
|
||||
@@ -6,14 +6,42 @@
|
||||
* file (and the adapter implementations in src/store/).
|
||||
*/
|
||||
|
||||
import { MODELS } from "../../config.ts";
|
||||
import { chatStore } from "../store/chats.ts";
|
||||
import { graphStore } from "../store/graph.ts";
|
||||
import { jobStore } from "../store/jobs.ts";
|
||||
import { settingsStore } from "../store/settings.ts";
|
||||
import { resolveSettings, type EffectiveSettings, type ModelRoleSpecs } from "../core/settings-resolution.ts";
|
||||
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
|
||||
export const stores = {
|
||||
chatStore,
|
||||
graphStore,
|
||||
jobStore,
|
||||
settingsStore,
|
||||
};
|
||||
|
||||
export type Stores = typeof stores;
|
||||
|
||||
/** config.ts's MODELS narrowed to the background roles — the compiled
|
||||
* factory layer the resolution reads. core stays pure (it takes this as a
|
||||
* parameter, never imports config at runtime); the prober/teacher pins
|
||||
* are chat-settings-controlled and deliberately absent here. Tests pin
|
||||
* that the role set matches core's BACKGROUND_MODEL_ROLES. */
|
||||
const BACKGROUND_MODEL_PINS: ModelRoleSpecs = {
|
||||
researcher: MODELS.researcher,
|
||||
demoBuilder: MODELS.demoBuilder,
|
||||
visualMaker: MODELS.visualMaker,
|
||||
extractor: MODELS.extractor,
|
||||
auditor: MODELS.auditor,
|
||||
labeler: MODELS.labeler,
|
||||
};
|
||||
|
||||
/**
|
||||
* The resolved view of the settings record (core/settings-resolution.ts):
|
||||
* every knob concrete, record overrides over config.ts factory pins. Read
|
||||
* fresh on every call — the record is a small file and turns are rare, so
|
||||
* no caching layer (stale toggles after a settings PUT would be worse).
|
||||
*/
|
||||
export function effectiveSettings(): EffectiveSettings {
|
||||
return resolveSettings(settingsStore.readSettings(), BACKGROUND_MODEL_PINS);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,36 @@
|
||||
/**
|
||||
* Server-side persistence for the DEFAULT settings — THE default model +
|
||||
* thinking level + knowledge-context budget (owner, 2026-09-18): the
|
||||
* new-session surfaces' controls (the home graph view's topic dock and
|
||||
* Server-side persistence for the DEFAULT CHAT settings — THE default
|
||||
* model + thinking level + knowledge-context budget (owner, 2026-09-18):
|
||||
* the new-session surfaces' controls (the home graph view's topic dock and
|
||||
* /chat/new) ARE the settings page for the default model. There is NO
|
||||
* baked-in default — BYOK first boot renders "no model" until the user
|
||||
* picks one, and the last choice IS the default from then on. The pages
|
||||
* picks one, and their last choice IS the default from then on. The pages
|
||||
* server-render from this record (initialSettings), so the controls show
|
||||
* truth on the first frame — no default-model flash ever. Per-session
|
||||
* settings live in the chat record (PUT /api/chats/:id/settings) and are
|
||||
* untouched by this; new sessions inherit the default on creation.
|
||||
*
|
||||
* This module only moves bytes: the file is written exclusively through the
|
||||
* validated route (app/api/settings/defaults — parseSettingsBody +
|
||||
* validateChatSettings gate every write), so no sanitizer runs here. On
|
||||
* read, anything unreadable/corrupt is null = first boot = "no model".
|
||||
* BACKED BY THE SETTINGS RECORD (settings-storage wave, 2026-09-19): the
|
||||
* default chat settings are the `chat` section of data/settings.json
|
||||
* (src/store/settings.ts — same delta semantics, same file, one record).
|
||||
* The legacy data/default-settings.json folds into that section on first
|
||||
* read. These signatures are unchanged so the pages and the legacy PUT
|
||||
* route (app/api/settings/defaults) keep working untouched; writes are
|
||||
* still validated by the route before they get here (parseSettingsBody +
|
||||
* validateChatSettings).
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_SETTINGS_FILE } from "../../config.ts";
|
||||
import type { ChatSettings } from "../core";
|
||||
import { readSettings, writeSettings } from "./settings.ts";
|
||||
|
||||
/** The persisted default settings, or null when never saved / unreadable. */
|
||||
/** The persisted default chat settings, or null when never saved / unreadable. */
|
||||
export function readDefaultSettings(): ChatSettings | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(DEFAULT_SETTINGS_FILE, "utf-8");
|
||||
} catch {
|
||||
return null; // never saved / unreadable → the surfaces render "no model"
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
return parsed as ChatSettings;
|
||||
} catch {
|
||||
return null; // corrupt → "no model", never crash the surfaces
|
||||
}
|
||||
return readSettings()?.chat ?? null;
|
||||
}
|
||||
|
||||
/** Persist the validated settings verbatim. */
|
||||
/** Persist the validated settings into the record's chat section — other
|
||||
* sections ride along untouched (read-modify-write on the ONE record). */
|
||||
export function writeDefaultSettings(settings: ChatSettings): void {
|
||||
fs.mkdirSync(path.dirname(DEFAULT_SETTINGS_FILE), { recursive: true });
|
||||
fs.writeFileSync(DEFAULT_SETTINGS_FILE, JSON.stringify(settings, null, "\t"), "utf-8");
|
||||
const record = readSettings();
|
||||
writeSettings({ ...(record ?? { version: 1 }), chat: settings });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user