import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import type { AutoCompactIntegration } from "./state"; /** JSON key in global/project settings.json (SPEC §7.3). */ export const SKILL_REINJECT_SETTINGS_KEY = "skillReinject"; export type PartialSkillReinjectSettings = Partial; const AUTO_COMPACT_INTEGRATION_VALUES: readonly AutoCompactIntegration[] = [ "auto", "defer", "immediate", "off", ]; /** Global/project skillReinject.* settings (SPEC §7.3). */ export interface SkillReinjectSettings { enabled: boolean; trackReadPaths: boolean; triggerTurn: boolean; reinjectOnManualCompaction: boolean; autoCompactIntegration: AutoCompactIntegration; suffix: string; /** Soft warn threshold; omit for unlimited with default warn above 3 (SPEC §15). */ maxSkills?: number; /** Verbose reinject filter logging via ui.notify (Phase 14 / B-002). */ debug: boolean; /** When true, only re-inject skills present in resourceLoader (Phase 14 / B-002). */ requireRegistered: boolean; } /** Defaults from SPEC §7.3 — extension off until explicitly enabled. */ export const DEFAULT_SKILL_REINJECT_SETTINGS: Readonly = { enabled: false, trackReadPaths: true, triggerTurn: false, reinjectOnManualCompaction: false, autoCompactIntegration: "auto", suffix: "[skill-reinject] Re-applied after compaction.", debug: false, requireRegistered: false, }; export function createDefaultSettings(): SkillReinjectSettings { return { ...DEFAULT_SKILL_REINJECT_SETTINGS }; } function isAutoCompactIntegration(value: unknown): value is AutoCompactIntegration { return ( typeof value === "string" && (AUTO_COMPACT_INTEGRATION_VALUES as readonly string[]).includes(value) ); } /** Parse unknown JSON; invalid fields are ignored. */ export function parseSkillReinjectPartial(raw: unknown): PartialSkillReinjectSettings { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return {}; } const obj = raw as Record; const result: PartialSkillReinjectSettings = {}; if (typeof obj.enabled === "boolean") { result.enabled = obj.enabled; } if (typeof obj.trackReadPaths === "boolean") { result.trackReadPaths = obj.trackReadPaths; } if (typeof obj.triggerTurn === "boolean") { result.triggerTurn = obj.triggerTurn; } if (typeof obj.reinjectOnManualCompaction === "boolean") { result.reinjectOnManualCompaction = obj.reinjectOnManualCompaction; } if (isAutoCompactIntegration(obj.autoCompactIntegration)) { result.autoCompactIntegration = obj.autoCompactIntegration; } if (typeof obj.suffix === "string") { result.suffix = obj.suffix; } if (typeof obj.maxSkills === "number" && Number.isInteger(obj.maxSkills) && obj.maxSkills > 0) { result.maxSkills = obj.maxSkills; } if (typeof obj.debug === "boolean") { result.debug = obj.debug; } if (typeof obj.requireRegistered === "boolean") { result.requireRegistered = obj.requireRegistered; } return result; } /** Project overrides global; both layers merge onto defaults (SPEC §7.3). */ export function mergeSkillReinjectSettings( global: PartialSkillReinjectSettings, project: PartialSkillReinjectSettings, ): SkillReinjectSettings { return { ...createDefaultSettings(), ...global, ...project, }; } function extractSkillReinject(settings: object): PartialSkillReinjectSettings { return parseSkillReinjectPartial( (settings as Record)[SKILL_REINJECT_SETTINGS_KEY], ); } /** Merged global + project settings (SPEC §7.3). Sync file read; avoids SettingsManager / isProjectTrusted() blocking in RPC hooks. */ export function readSettings(ctx: ExtensionContext): SkillReinjectSettings { const global = extractSkillReinject(readSettingsFile(join(getAgentDir(), "settings.json"))); const projectPath = join(ctx.cwd, ".pi/settings.json"); const project = existsSync(projectPath) ? extractSkillReinject(readSettingsFile(projectPath)) : {}; return mergeSkillReinjectSettings(global, project); } function readSettingsFile(settingsPath: string): Record { if (!existsSync(settingsPath)) { return {}; } try { const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8")); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return {}; } return parsed as Record; } catch { return {}; } } /** Merge skillReinject into a settings.json file without replacing unrelated keys (SPEC §7.3). */ export function mergeSkillReinjectIntoSettingsFile( settingsPath: string, partial: PartialSkillReinjectSettings, ): void { const current = readSettingsFile(settingsPath); const existing = parseSkillReinjectPartial(current[SKILL_REINJECT_SETTINGS_KEY]); current[SKILL_REINJECT_SETTINGS_KEY] = { ...existing, ...partial }; mkdirSync(dirname(settingsPath), { recursive: true }); writeFileSync(settingsPath, `${JSON.stringify(current, null, 2)}\n`, "utf8"); } /** Persist partial skillReinject settings to global ~/.pi/agent/settings.json (SPEC §7.3). */ export function writeGlobalSettings(partial: PartialSkillReinjectSettings): void { mergeSkillReinjectIntoSettingsFile(join(getAgentDir(), "settings.json"), partial); } /** Resolved re-inject delivery mode after integration settings (SPEC §6.5.3). */ export type ReinjectDeliveryMode = "defer" | "immediate"; /** Session override wins over global enabled default (SPEC §5.1). */ export function effectiveEnabled( sessionOverride: boolean | null, settings: SkillReinjectSettings, ): boolean { return sessionOverride ?? settings.enabled; } /** Resolve delivery mode from integration setting, pi-auto-compact detect, and triggerTurn (SPEC §6.5.3). */ export function effectiveIntegration( settings: SkillReinjectSettings, autoCompactDetected: boolean, sessionIntegrationOverride?: AutoCompactIntegration | null, ): ReinjectDeliveryMode { const integration = sessionIntegrationOverride ?? settings.autoCompactIntegration; switch (integration) { case "defer": return "defer"; case "immediate": return "immediate"; case "off": return settings.triggerTurn ? "immediate" : "defer"; case "auto": default: if (autoCompactDetected) { return "defer"; } return settings.triggerTurn ? "immediate" : "defer"; } }