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:
@@ -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