/** * Config loading: project `.pi/settings.json` replaces global, and each setting * that is missing or invalid falls back to its schema default. Config field * `hashlineEdit` (drop the `pi-` prefix, camelCase). * * @module pi-hashline-edit/pi */ import { DEFAULT_MAX_LINES, getAgentDir } from "@earendil-works/pi-coding-agent"; import * as fs from "node:fs"; import * as path from "node:path"; import { Type, type Static, type TSchema } from "typebox"; import { Value } from "typebox/value"; import { HASH_LEN_MAX, HASH_LEN_MIN } from "../core/hash.ts"; import { GREP_CONTEXT_RANGE, integerRange, POSITIVE_SAFE_INTEGER } from "./schema.ts"; import { MAX_CONTENT_KIB } from "./budgets.ts"; function integerSetting( range: ReturnType, value: number, description: string, ) { return Type.Number({ ...range, default: value, description }); } // The floor exceeds measured worker startup+handshake on slower Windows hosts. const regexTimeoutSetting = integerSetting( integerRange(3_000, 300_000), 5_000, "Time limit for one isolated regex evaluation, in milliseconds.", ); /** Settings schema: the single source of setting types, bounds, and defaults. */ const configSchema = Type.Object({ enabled: Type.Boolean({ default: true, description: "Master switch: when false the extension registers no tools; Pi's built-ins remain.", }), actionFusion: Type.Boolean({ default: true, description: "Expose optional then_run commands after edit/replace/write.", }), hashLen: integerSetting( integerRange(HASH_LEN_MIN, HASH_LEN_MAX), 4, "Line hash length shared by every anchored tool.", ), shiftRadius: integerSetting( integerRange(0, 100), 15, "First-pass ±line radius before full-file recovery; 0 disables recovery.", ), read: Type.Object({ defaultLimit: integerSetting( POSITIVE_SAFE_INTEGER, DEFAULT_MAX_LINES, "Lines returned when limit is omitted.", ), maxKiB: Type.Optional( Type.Number({ ...integerRange(1, MAX_CONTENT_KIB), description: "Explicit anchored-text byte cap in KiB; omitted uses the selected model capacity.", }), ), }), grep: Type.Object({ defaultLimit: integerSetting( POSITIVE_SAFE_INTEGER, 100, "Matching lines returned when limit is omitted.", ), defaultContext: integerSetting( GREP_CONTEXT_RANGE, 0, "Context lines shown when context is omitted.", ), regexTimeoutMs: regexTimeoutSetting, }), replace: Type.Object({ regexTimeoutMs: regexTimeoutSetting, }), }); export type HashlineEditConfig = Static; function asRecord(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } /** Keep each valid leaf; replace a missing or invalid leaf with its default. */ function resolveSetting(schema: T, value: unknown): Static { if (Type.IsObject(schema)) { const source = asRecord(value); return Object.fromEntries( Object.entries(schema.properties).map(([key, property]) => [ key, resolveSetting(property, source[key]), ]), ) as Static; } return ( Value.Check(schema, value) ? value : Type.IsOptional(schema) ? undefined : Value.Create(schema) ) as Static; } export const DEFAULT_CONFIG: HashlineEditConfig = resolveSetting(configSchema, {}); /** Parse JSON directly without stripping comments (standard JSON forbids comments; on error fall back to default). */ function readSettings(filePath: string): Record { try { return asRecord(JSON.parse(fs.readFileSync(filePath, "utf-8"))); } catch { return {}; } } /** * Load config. The `hashlineEdit` in project `cwd/.pi/settings.json` replaces * the global one wholesale; missing or invalid settings use their defaults. */ export function loadConfig(cwd: string): HashlineEditConfig { const globalSettings = readSettings(path.join(getAgentDir(), "settings.json")); const projectSettings = readSettings(path.join(cwd, ".pi", "settings.json")); // Any project value replaces the global one, even null or an invalid value, which yields defaults. const settings = Object.hasOwn(projectSettings, "hashlineEdit") ? projectSettings : globalSettings; return resolveSetting(configSchema, settings.hashlineEdit); }