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,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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user