UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

480 lines (442 loc) • 18.2 kB
/** * Owner for the `kxm.config.v1` terminal editor. * * The panel publishes this contribution and routes every write back here. * `listSections` re-reads the merged files on each publish. `set` and `clear` * validate the field and the value, then call the config owner. A refusal * throws before either owner call, so the file bytes stay as they were. */ import { existsSync, readFileSync } from "node:fs"; import { parse } from "yaml"; import { KXM_TUI_CLEAR_ACTION, KXM_TUI_SET_ACTION, createKxmTuiRegistry, createKxmTuiPiPanel, createPlainKxmTuiTheme, runKxmTuiPanel, type KxmTuiContribution, type KxmTuiField, type KxmTuiSectionView, } from "@kontextmind/tui"; import { deleteKxmConfigValue, getKxmConfigValue, kxmConfigFileForScope, loadKxmConfig, setKxmConfigValue, userConfigDirForEnv, type KxmResolvedConfig, } from "./config.ts"; export const KXM_CONFIG_EDITOR_SOURCE = "kxm/config"; export type KxmConfigEditorScope = "project" | "user"; const INTEGER_TEXT = /^(0|[1-9][0-9]*)$/; const DECIMAL_TEXT = /^(0|[1-9][0-9]*)(\.[0-9]{1,6})?$/; /** Workflow and harness ids already in use, including `@`, with no whitespace. */ const TEXT_VALUE = /^[A-Za-z0-9@][A-Za-z0-9@._:/-]{0,127}$/; interface EnumSpec { readonly kind: "enum"; readonly choices: readonly string[]; } interface NumberSpec { readonly kind: "number"; readonly integer: boolean; readonly min: number; readonly max: number; } interface TextSpec { readonly kind: "text"; } type ValueSpec = EnumSpec | NumberSpec | TextSpec; interface ConfigEditorFieldSpec { readonly id: string; readonly label: string; readonly keyPath: string; readonly spec: ValueSpec; readonly note?: string; } interface ConfigEditorSectionSpec { readonly id: string; readonly title: string; readonly order: number; readonly fields: readonly ConfigEditorFieldSpec[]; } const SCREEN_CHOICES = ["agents", "tasks", "workflows", "plans", "inbox", "procs", "spend"] as const; const SECTIONS: readonly ConfigEditorSectionSpec[] = [ { id: "user", title: "User", order: 10, fields: [ { id: "user-theme", label: "Theme", keyPath: "user.theme", spec: { kind: "enum", choices: ["dark", "light", "minimal"] } }, { id: "user-token-budget", label: "Token budget", keyPath: "user.tokenBudget", spec: { kind: "number", integer: true, min: 1, max: 2_000_000 } }, ], }, { id: "defaults", title: "Defaults", order: 20, fields: [ { id: "defaults-workflow", label: "Workflow", keyPath: "defaults.workflow", spec: { kind: "text" } }, { id: "defaults-harness", label: "Harness", keyPath: "defaults.harness", spec: { kind: "text" } }, ], }, { id: "dash", title: "Dashboard", order: 30, fields: [ { id: "dash-screen", label: "Default screen", keyPath: "dash.defaultScreen", spec: { kind: "enum", choices: SCREEN_CHOICES } }, { id: "dash-refresh", label: "Refresh interval", keyPath: "dash.refreshIntervalMs", spec: { kind: "number", integer: true, min: 250, max: 3_600_000 } }, { id: "dash-auto-open", label: "Auto-open", keyPath: "dash.autoOpen", spec: { kind: "enum", choices: ["true", "false"] } }, ], }, { id: "sync", title: "Sync", order: 40, fields: [ { id: "sync-tracker", label: "Default tracker", keyPath: "sync.defaultTracker", spec: { kind: "enum", choices: ["github", "jira", "none"] } }, ], }, { id: "hub", title: "Hub", order: 50, fields: [ { id: "hub-auto-start", label: "Auto-start", keyPath: "hub.autoStart", spec: { kind: "enum", choices: ["background", "off"] }, note: "Hub URLs and key references stay out of this panel.", }, ], }, { id: "improvement", title: "Improvement", order: 60, fields: [ { id: "improvement-policy", label: "Promotion policy", keyPath: "improvement.promotionPolicy", spec: { kind: "enum", choices: ["manual_pr", "critic_quorum", "auto_threshold"] } }, { id: "improvement-half-life", label: "Telemetry half-life", keyPath: "improvement.telemetryHalfLifeDays", spec: { kind: "number", integer: false, min: 0.000001, max: 3650 } }, { id: "improvement-min-runs", label: "Minimum runs", keyPath: "improvement.autoThreshold.minRuns", spec: { kind: "number", integer: true, min: 1, max: 1_000_000 } }, { id: "improvement-min-pass-rate", label: "Minimum pass rate", keyPath: "improvement.autoThreshold.minPassRate", spec: { kind: "number", integer: false, min: 0, max: 1 } }, { id: "improvement-min-cost-savings", label: "Minimum cost savings", keyPath: "improvement.autoThreshold.minCostSavings", spec: { kind: "number", integer: false, min: 0, max: 1_000_000 } }, ], }, { id: "routing", title: "Routing", order: 70, fields: [ { id: "routing-shadow", label: "Shadow execution", keyPath: "routing.shadowExecution.enabled", spec: { kind: "enum", choices: ["true", "false"] }, note: "Strategy, fallback, and context limits stay in role and route files.", }, { id: "routing-sample-rate", label: "Sample rate", keyPath: "routing.shadowExecution.sampleRate", spec: { kind: "number", integer: false, min: 0, max: 1 } }, { id: "routing-breaker-mode", label: "Breaker mode", keyPath: "routing.circuitBreaker.mode", spec: { kind: "enum", choices: ["soft_demotion", "quarantine"] } }, { id: "routing-failure-threshold", label: "Failure threshold", keyPath: "routing.circuitBreaker.failureThreshold", spec: { kind: "number", integer: true, min: 1, max: 10_000 } }, { id: "routing-window-seconds", label: "Window seconds", keyPath: "routing.circuitBreaker.windowSeconds", spec: { kind: "number", integer: true, min: 1, max: 31_536_000 } }, { id: "routing-cooldown-seconds", label: "Cooldown seconds", keyPath: "routing.circuitBreaker.cooldownSeconds", spec: { kind: "number", integer: true, min: 0, max: 31_536_000 } }, { id: "routing-penalty", label: "Penalty multiplier", keyPath: "routing.circuitBreaker.penaltyMultiplier", spec: { kind: "number", integer: false, min: 0.000001, max: 1000 } }, ], }, { id: "telemetry", title: "Telemetry", order: 80, fields: [ { id: "telemetry-federated", label: "Federated", keyPath: "telemetry.federated", spec: { kind: "enum", choices: ["true", "false"] } }, { id: "telemetry-anonymize", label: "Anonymize", keyPath: "telemetry.anonymize", spec: { kind: "enum", choices: ["true", "false"] } }, ], }, ]; export class KxmConfigEditorError extends Error { readonly code = "config_edit_refused"; constructor(message: string) { super(message); this.name = "KxmConfigEditorError"; } } export interface KxmConfigEditorTarget { readonly cwd: string; readonly scope: KxmConfigEditorScope; readonly userConfigDir?: string | undefined; } export function kxmConfigEditorKeyPaths(): readonly string[] { return SECTIONS.flatMap((section) => section.fields.map((field) => field.keyPath)); } export function createKxmConfigEditorContribution(target: KxmConfigEditorTarget): KxmTuiContribution { return { source: KXM_CONFIG_EDITOR_SOURCE, republishAfterWrite: true, listSections: () => listConfigEditorSections(target), handlers: { [KXM_TUI_SET_ACTION]: (input) => { writeConfigEditorValue(target, input.sectionId, input.fieldId, input.value); }, [KXM_TUI_CLEAR_ACTION]: (input) => { clearConfigEditorValue(target, input.sectionId, input.fieldId); }, }, describeError: (error) => (error instanceof Error && error.message ? error.message : "config edit failed"), }; } function scopeFile(target: KxmConfigEditorTarget): string { return kxmConfigFileForScope(target.cwd, target.scope, target.userConfigDir); } function scopePhrase(scope: KxmConfigEditorScope): string { return scope === "project" ? "the project file .kxm/config.yaml" : "the user file config.yaml"; } function listConfigEditorSections(target: KxmConfigEditorTarget): KxmTuiSectionView[] { const config = loadKxmConfig(target.cwd, { ...(target.userConfigDir === undefined ? {} : { userConfigDir: target.userConfigDir }), layers: target.scope === "user" ? "user" : "all", }); const owned = readScopeDocument(scopeFile(target)); const where = scopePhrase(target.scope); return SECTIONS.map((section) => { const fields = section.fields.map((field) => publishField(field, config, owned, where)); const diagnosis = fields.find((field) => field.statusText)?.statusText; return { source: KXM_CONFIG_EDITOR_SOURCE, id: section.id, title: section.title, order: section.order, detail: `kxm.config.v1 · ${target.scope} scope. Re-read from disk after each write.`, ...(diagnosis === undefined ? {} : { notice: diagnosis, noticeLevel: "error" as const }), fields, }; }); } function publishField( field: ConfigEditorFieldSpec, config: KxmResolvedConfig, owned: Record<string, unknown> | undefined, where: string, ): KxmTuiField { const effective = getKxmConfigValue(config, field.keyPath); const stored = ownValue(owned, field.keyPath); const shown = displayScalar(effective); const inherited = !stored.present; const diagnosis = storedDiagnosis(field, stored, effective); const detail = [ `Writes ${where}.`, inherited ? "Inherited." : "Set in this scope.", "Clear removes only this override.", field.note ?? "", diagnosis ?? "", ].filter((part) => part.length > 0).join(" "); const choices = field.spec.kind === "enum" ? field.spec.choices.map((choice) => ({ id: choice, label: choice })) : undefined; return { id: field.id, label: field.label, kind: field.spec.kind === "enum" ? "enum" : "text", keyPath: field.keyPath, detail, status: "ready", ...(shown === undefined ? {} : { value: shown }), ...(choices === undefined ? {} : { choices }), ...(diagnosis === undefined ? {} : { statusText: diagnosis }), }; } function displayScalar(value: unknown): string | undefined { if (typeof value === "string" && value.length > 0) return value; if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number" && Number.isFinite(value)) return String(value); return undefined; } function isBooleanEnum(spec: ValueSpec): boolean { return spec.kind === "enum" && spec.choices.length === 2 && spec.choices.every((choice) => choice === "true" || choice === "false"); } /** A display string such as `"true"` is not a boolean. The stored YAML type has to match the descriptor. */ function storedTypeMatches(field: ConfigEditorFieldSpec, value: unknown): boolean { if (field.spec.kind === "number") { return typeof value === "number" && Number.isFinite(value) && (!field.spec.integer || Number.isInteger(value)); } if (isBooleanEnum(field.spec)) return typeof value === "boolean"; return typeof value === "string"; } function storedDiagnosis( field: ConfigEditorFieldSpec, stored: { present: boolean; value: unknown }, effective: unknown, ): string | undefined { if (!stored.present) return undefined; if (!storedTypeMatches(field, stored.value)) return "stored type does not match this field"; if (field.spec.kind === "enum" && !isBooleanEnum(field.spec) && !field.spec.choices.includes(stored.value as string)) { return "stored value is not a supported scalar"; } if (field.spec.kind === "number") { const value = stored.value as number; if (value < field.spec.min || value > field.spec.max) return "stored value is not a supported scalar"; } if (stored.value !== effective) return "stored value is not a supported scalar"; return undefined; } function writeConfigEditorValue(target: KxmConfigEditorTarget, sectionId: string, fieldId: string, raw: string | undefined): void { const field = requireField(sectionId, fieldId); const value = parseEditorValue(field, raw); setKxmConfigValue(target.cwd, field.keyPath, value, { scope: target.scope, ...(target.userConfigDir === undefined ? {} : { userConfigDir: target.userConfigDir }), }); } function clearConfigEditorValue(target: KxmConfigEditorTarget, sectionId: string, fieldId: string): void { const field = requireField(sectionId, fieldId); deleteKxmConfigValue(target.cwd, field.keyPath, { scope: target.scope, ...(target.userConfigDir === undefined ? {} : { userConfigDir: target.userConfigDir }), }); } function requireField(sectionId: string, fieldId: string): ConfigEditorFieldSpec { const section = SECTIONS.find((item) => item.id === sectionId); const field = section?.fields.find((item) => item.id === fieldId); if (!field) throw new KxmConfigEditorError("unknown configuration field"); return field; } function parseEditorValue(field: ConfigEditorFieldSpec, raw: string | undefined): string | number | boolean { if (raw === undefined) throw new KxmConfigEditorError("a value is required"); if (raw.length === 0 || raw.length > 128 || /[\u0000-\u001f\u007f]/u.test(raw)) { throw new KxmConfigEditorError("value must be a single bounded line"); } if (field.spec.kind === "enum") { if (!field.spec.choices.includes(raw)) throw new KxmConfigEditorError("value is not a supported choice"); if (isBooleanEnum(field.spec)) return raw === "true"; return raw; } if (field.spec.kind === "text") { if (!TEXT_VALUE.test(raw)) throw new KxmConfigEditorError("value must be a single bounded line"); return raw; } if (!(field.spec.integer ? INTEGER_TEXT : DECIMAL_TEXT).test(raw)) { throw new KxmConfigEditorError("value must be a finite number in range"); } const parsed = Number(raw); if (!Number.isFinite(parsed) || (field.spec.integer && !Number.isInteger(parsed)) || parsed < field.spec.min || parsed > field.spec.max) { throw new KxmConfigEditorError("value must be a finite number in range"); } return parsed; } function readScopeDocument(file: string): Record<string, unknown> | undefined { if (!existsSync(file)) return undefined; const parsed = parse(readFileSync(file, "utf8")) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new KxmConfigEditorError(`config file ${file} must be a mapping`); } return parsed as Record<string, unknown>; } function ownValue(root: Record<string, unknown> | undefined, keyPath: string): { present: boolean; value: unknown } { if (!root) return { present: false, value: undefined }; let cursor: unknown = root; for (const part of keyPath.split(".")) { if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return { present: false, value: undefined }; const record = cursor as Record<string, unknown>; if (!Object.prototype.hasOwnProperty.call(record, part)) return { present: false, value: undefined }; cursor = record[part]; } return { present: true, value: cursor }; } const EDITOR_HELP = [ "↑↓ move · enter edits · x clears this scope's override · q quits", "Writes are checked, saved, then re-read. A refused edit leaves the file unchanged.", ] as const; const FRAME_NOW_MS = Date.UTC(2026, 9, 11); export async function runKxmConfigEditor(input: { readonly target: KxmConfigEditorTarget; readonly stdout: (text: string) => void; readonly isTty: boolean; readonly dryRun: boolean; }): Promise<number> { const registry = createKxmTuiRegistry(); try { registry.register(createKxmConfigEditorContribution(input.target)); registry.getSections(); const failed = registry.getIssues()[0]; if (failed) throw new KxmConfigEditorError(failed.message); const interactive = input.isTty && !input.dryRun; return await runKxmTuiPanel({ registry, title: "KXM configuration", breadcrumb: `kxm.config.v1 · ${input.target.scope} scope`, helpLines: EDITOR_HELP, stdout: input.stdout, isTty: interactive, ...(interactive ? {} : { theme: createPlainKxmTuiTheme() }), now: () => FRAME_NOW_MS, }); } finally { registry.dispose(); } } interface ConfigPanelUi { notify?: ((message: string, type?: "error" | "info" | "warning") => void) | undefined; custom?: (( factory: ( tui: { requestRender(): void }, theme: { fg(token: string, text: string): string; bg(token: string, text: string): string }, keybindings: unknown, done: (result: undefined) => void, ) => { render(width: number): string[]; handleInput(data: string): void; invalidate(): void }, options?: { overlay?: boolean }, ) => Promise<unknown>) | undefined; } /** Mount the project-scope editor on Pi's generic custom panel. Only a TUI session may open it. */ export async function openKxmProjectConfigPanel( ui: { notify?: (message: string, type?: "error" | "info" | "warning") => void; custom?: unknown }, cwd: string, env: NodeJS.ProcessEnv = process.env, mode?: string, ): Promise<void> { if (mode !== "tui") { ui.notify?.( "kxm: /kxm config opens only in the Pi TUI. Nothing was written. Run kxm config edit --scope project", "warning", ); return; } if (typeof ui.custom !== "function") { ui.notify?.( "kxm: /kxm config needs Pi's custom panel. Open the repository in the Pi TUI, or run kxm config edit --scope project", "warning", ); return; } const custom = ui.custom as NonNullable<ConfigPanelUi["custom"]>; const registry = createKxmTuiRegistry(); const userConfigDir = userConfigDirForEnv(env); try { registry.register(createKxmConfigEditorContribution({ cwd, scope: "project", ...(userConfigDir === undefined ? {} : { userConfigDir }), })); await custom( (tui, theme, _keybindings, done) => createKxmTuiPiPanel({ registry, tui, theme, title: "KXM configuration", breadcrumb: "kxm.config.v1 · project scope", helpLines: EDITOR_HELP, done: () => { registry.dispose(); done(undefined); }, }), { overlay: true }, ); } finally { registry.dispose(); } }