Files
learnharness/src/server/settings-body.ts
T
amosblomqvist f1373d5de5 feat(search): Tavily + Brave behind a search-provider port; Google CSE retires
Search port + model slots wave, chunk A (owner design, 2026-09-20):
- core/search.ts: SearchHit + SearchProvider port + SearchStatus —
  pure, browser-safe; adapters arrive as injected dependencies.
- server/search.ts: Tavily + Brave adapters (wire shapes VERIFIED
  against docs.tavily.com's OpenAPI + Brave's dashboard docs — Tavily
  auth is the Bearer header, not the legacy body api_key; Brave is
  X-Subscription-Token) + engine resolution per owner decisions: a key
  ALONE activates its engine (no engine choice needed), explicit
  agent.search.engine pins the preference (both keys: Tavily first),
  a pinned engine with a missing key is honestly unavailable — no
  silent fallback, no default engine.
- Curated keys catalog: GOOGLE_SEARCH_API_KEY/GOOGLE_CSE_ID retire
  (CSE closed to new customers, dies Jan 2027 — stored values become
  preserved unknown vars); TAVILY_API_KEY (tvly- shape) + BRAVE_API_KEY
  (length guard) join. UI keys-section TOOL_IDS now derives from the
  catalog — cannot drift.
- GET /api/search: {available, engine, reason} — the honest status for
  the settings gray-out; researcher-disable semantics ride the same
  signal (Teacher stream rewires the tool onto the port).
- agent.search settings field (schema + sanitizer + strict parser);
  web.ts's WebSearchResult is now the core SearchHit (re-exported).
2026-09-22 00:02:07 +03:00

306 lines
12 KiB
TypeScript

/**
* 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 SECTION;
* absent fields are absent overrides ({} clears everything). No merges, so
* 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, 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";
import { TOGGLEABLE_TOOLS } from "../core/pending.ts";
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
export type SettingsBodyResult<T> =
| { ok: true; settings: T }
| { ok: false; error: string };
export function parseSettingsBody(body: unknown): SettingsBodyResult<ChatSettings> {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return { ok: false, error: "Invalid JSON body" };
}
const raw = body as {
model?: { provider?: unknown; id?: unknown };
thinkingLevel?: unknown;
graphContextTokens?: unknown;
};
const settings: ChatSettings = {};
if (raw.model !== undefined) {
if (
typeof raw.model !== "object" ||
raw.model === null ||
typeof raw.model.provider !== "string" ||
typeof raw.model.id !== "string"
) {
return { ok: false, error: "model must be {provider, id}" };
}
settings.model = { provider: raw.model.provider, id: raw.model.id };
}
if (raw.thinkingLevel !== undefined) {
if (typeof raw.thinkingLevel !== "string") {
return { ok: false, error: "thinkingLevel must be a string" };
}
settings.thinkingLevel = raw.thinkingLevel as ThinkingLevel;
}
// Graph context injection budget (vision-context-injection.md): integer
// tokens, 0 (off) to GRAPH_CONTEXT_TOKENS_MAX, no silent clamping — the
// slider is the only writer, and it only ever sends multiples of the step.
if (raw.graphContextTokens !== undefined) {
if (
typeof raw.graphContextTokens !== "number" ||
!Number.isInteger(raw.graphContextTokens) ||
raw.graphContextTokens < 0 ||
raw.graphContextTokens > GRAPH_CONTEXT_TOKENS_MAX
) {
return {
ok: false,
error: `graphContextTokens must be an integer between 0 and ${GRAPH_CONTEXT_TOKENS_MAX}`,
};
}
settings.graphContextTokens = raw.graphContextTokens;
}
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) {
// Unknown tool keys are rejected at WRITE time (user decision
// 2026-09-19): the delta is inert while unread, but once the bridge
// honors it a persisted `quiz: false` would genuinely drop the quiz —
// and the definitive list is the adjudicated job-tools-only set.
// The read-side sanitizer stays lenient for forward-compat.
const supported = TOGGLEABLE_TOOLS as readonly string[];
for (const key of Object.keys(tools.map)) {
if (!supported.includes(key)) {
return { ok: false, error: `unknown tool "${key}" (supported: ${supported.join(", ")})` };
}
}
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;
if (raw.search !== undefined) {
const search = asRecord(raw.search);
if (!search) return { ok: false, error: "search must be an object" };
if (search.engine !== undefined) {
if (search.engine !== "tavily" && search.engine !== "brave") {
return { ok: false, error: 'search.engine must be "tavily" or "brave"' };
}
settings.search = { engine: search.engine };
}
}
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;