import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { atomicWriteText, withFileLock } from "./transaction-file.ts"; export type BtwSettings = { answerMaxTokens: number; refineMaxTokens: number; toolAllowlist: string[]; toolCallBudget: number; summaryEnabled?: boolean; summaryTriggerTokens?: number; summaryRetainTokens?: number; summaryMaxTokens?: number; shortcut?: string; }; // Keep the legacy config shape assignable to existing runner seams; reports always populate shortcut. export type EffectiveBtwSettings = Required> & { shortcut?: string }; export type SettingSource = "env" | "user" | "default" | "adjusted"; export type ConfigField = { value: T; source: SettingSource; envVar?: string; envLocked: boolean; hasUserOverride: boolean; diagnostics: string[] }; export type ConfigReport = { settings: EffectiveBtwSettings; fields: { [K in keyof EffectiveBtwSettings]: ConfigField }; path: string; fileKind: "missing" | "valid" | "malformed"; diagnostics: string[] }; const TOOLS = ["read", "grep", "find", "ls"] as const; const DEFAULTS: EffectiveBtwSettings = { answerMaxTokens: 4096, refineMaxTokens: 1024, toolAllowlist: [...TOOLS], toolCallBudget: 8, summaryEnabled: true, summaryTriggerTokens: 12_000, summaryRetainTokens: 8_000, summaryMaxTokens: 1024, shortcut: "ctrl+alt+b" }; const ENV: Partial> = { answerMaxTokens: "BTW_ANSWER_MAX_TOKENS", refineMaxTokens: "BTW_REFINE_MAX_TOKENS", toolAllowlist: "BTW_TOOL_ALLOWLIST", toolCallBudget: "BTW_TOOL_BUDGET", summaryEnabled: "BTW_SUMMARY_ENABLED", summaryTriggerTokens: "BTW_SUMMARY_TRIGGER_TOKENS", summaryRetainTokens: "BTW_SUMMARY_RETAIN_TOKENS", summaryMaxTokens: "BTW_SUMMARY_MAX_TOKENS", shortcut: "BTW_SHORTCUT", }; export const configPath = (): string => join(homedir(), ".pi", "agent", "btw.json"); const isObject = (v: unknown): v is Record => Boolean(v) && typeof v === "object" && !Array.isArray(v); const posInt = (v: unknown, min = 1): number | undefined => typeof v === "number" && Number.isSafeInteger(v) && v >= min ? v : undefined; const parseBoolean = (v: unknown): boolean | undefined => { if (typeof v === "boolean") return v; if (typeof v !== "string") return undefined; const s = v.trim().toLowerCase(); return ["1", "true", "yes", "on"].includes(s) ? true : ["0", "false", "no", "off"].includes(s) ? false : undefined; }; export function canonicalShortcut(value: unknown): string | undefined { if (typeof value !== "string" || !value || /[^\x00-\x7f]|\s/.test(value)) return undefined; const pieces = value.toLowerCase().split("+"); if (pieces.some((part) => !part) || new Set(pieces).size !== pieces.length) return undefined; let base = pieces.pop()!; if (base === "esc") base = "escape"; if (base === "return") base = "enter"; if (base === "pageup") base = "pageUp"; if (base === "pagedown") base = "pageDown"; const allowed = /^(?:[a-z]|[0-9]|[`\-=\[\]\\;',./!@#$%^&*()_+|~{}:<>?]|escape|enter|tab|space|backspace|delete|insert|clear|home|end|pageUp|pageDown|up|down|left|right)$/; if (!allowed.test(base)) return undefined; const modifiers = new Set(pieces); // Pi's host matcher cannot dispatch modifier variants of Escape or function // keys reliably. Function keys are rejected entirely; shortcuts installed by // this extension must be portable, modified host-supported bases. if ([...modifiers].some((part) => !["ctrl", "shift", "alt", "super"].includes(part)) || !["ctrl", "alt", "super"].some((part) => modifiers.has(part)) || (base === "escape" && modifiers.size > 0)) return undefined; return [...["ctrl", "shift", "alt", "super"].filter((part) => modifiers.has(part)), base].join("+"); } function toolList(v: unknown): string[] | undefined { const values = Array.isArray(v) ? v : typeof v === "string" ? (v === "" ? [] : v.split(",").map((tool) => tool.trim())) : undefined; if (!values || !values.every((x) => typeof x === "string" && TOOLS.includes(x as typeof TOOLS[number])) || new Set(values).size !== values.length) return undefined; return [...values]; } const malformedFileDiagnostic = "btw settings file is malformed; using environment/default fallback and leaving it unchanged"; const readFailureDiagnostic = "btw settings file could not be read; using environment/default fallback and leaving it unchanged"; function decodeUtf8(bytes: Uint8Array): string { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } function parseFile(path: string): { value: Record; fileKind: ConfigReport["fileKind"]; readFailed?: boolean } { let bytes: Uint8Array; try { bytes = readFileSync(path); } catch (error) { // The public fileKind contract intentionally remains three-valued. A file // we cannot read is treated as unavailable, not as malformed content. return { value: {}, fileKind: "missing", ...( (error as NodeJS.ErrnoException).code === "ENOENT" ? {} : { readFailed: true }) }; } try { const value: unknown = JSON.parse(decodeUtf8(bytes)); return isObject(value) ? { value, fileKind: "valid" } : { value: {}, fileKind: "malformed" }; } catch { return { value: {}, fileKind: "malformed" }; } } type FieldName = keyof EffectiveBtwSettings; function parsed(name: FieldName, value: unknown, fromEnv = false): EffectiveBtwSettings[FieldName] | undefined { const numericValue = fromEnv && typeof value === "string" ? Number(value) : value; switch (name) { case "answerMaxTokens": case "refineMaxTokens": case "toolCallBudget": case "summaryRetainTokens": case "summaryMaxTokens": return posInt(numericValue); case "summaryTriggerTokens": return posInt(numericValue, 2); case "summaryEnabled": return parseBoolean(value); case "toolAllowlist": return fromEnv || Array.isArray(value) ? toolList(value) : undefined; case "shortcut": return canonicalShortcut(value); } } /** Resolve every field independently. Invalid higher-precedence values diagnose and fall through. */ export function getConfigReport(path = configPath(), env: NodeJS.ProcessEnv = process.env): ConfigReport { const loaded = parseFile(path); const diagnostics: string[] = loaded.fileKind === "malformed" ? [malformedFileDiagnostic] : loaded.readFailed ? [readFailureDiagnostic] : []; const fields = {} as ConfigReport["fields"]; for (const name of Object.keys(DEFAULTS) as FieldName[]) { const envVar = ENV[name]!; const fieldDiagnostics: string[] = []; const rawEnv = env[envVar]; const userValue = parsed(name, loaded.value[name]); const envValue = rawEnv === undefined ? undefined : parsed(name, rawEnv, true); if (rawEnv !== undefined && envValue === undefined) fieldDiagnostics.push(`${envVar} is invalid; using a lower-precedence value`); if (loaded.value[name] !== undefined && userValue === undefined) fieldDiagnostics.push(`${name} in btw.json is invalid; using a lower-precedence value`); const source: SettingSource = envValue !== undefined ? "env" : userValue !== undefined ? "user" : "default"; const value = (envValue ?? userValue ?? DEFAULTS[name]) as EffectiveBtwSettings[FieldName]; fields[name] = { value, source, ...(rawEnv === undefined ? {} : { envVar }), envLocked: envValue !== undefined, hasUserOverride: loaded.value[name] !== undefined, diagnostics: fieldDiagnostics } as never; diagnostics.push(...fieldDiagnostics); } const settings = Object.fromEntries((Object.keys(DEFAULTS) as FieldName[]).map((name) => [name, fields[name]!.value])) as EffectiveBtwSettings; // Relationship adjustments are deliberately visible, rather than pretending an invalid relation came from a source. if (settings.summaryRetainTokens >= settings.summaryTriggerTokens) { settings.summaryRetainTokens = Math.max(1, Math.floor(settings.summaryTriggerTokens * 2 / 3)); const field = fields.summaryRetainTokens; field.value = settings.summaryRetainTokens; field.source = "adjusted"; field.diagnostics.push("summaryRetainTokens must be less than summaryTriggerTokens; adjusted"); diagnostics.push(field.diagnostics.at(-1)!); } return { settings, fields, path, fileKind: loaded.fileKind, diagnostics }; } export function getConfig(): EffectiveBtwSettings { return getConfigReport().settings; } /** Atomically merge one known user override. Malformed settings are never overwritten. */ export async function mutateConfig(name: keyof EffectiveBtwSettings, value?: unknown, path = configPath()): Promise { if (!(name in DEFAULTS)) throw new Error("unknown btw setting"); if (value !== undefined && parsed(name, value) === undefined) throw new Error(`invalid ${name}`); await withFileLock(path, async () => { let bytes: Uint8Array | undefined; try { bytes = await readFile(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } let current: Record = {}; if (bytes !== undefined) { try { const decoded: unknown = JSON.parse(decodeUtf8(bytes)); if (!isObject(decoded)) throw new Error(); current = decoded; } catch { throw new Error("btw settings JSON is malformed; left unchanged"); } } if (value === undefined) delete current[name]; else current[name] = parsed(name, value); await atomicWriteText(path, `${JSON.stringify(current, null, 2)}\n`); }); } export const setConfig = mutateConfig;