import { readFile } from "node:fs/promises"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { homedir } from "node:os"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface TopOfMindConfig { /** Literal text to inject into every turn. null means not set. */ text: string | null; /** * Path to a file whose contents are injected. * Supports `~/` prefix for home directory. null means not set. */ file: string | null; /** Whether injection is currently active. */ enabled: boolean; /** * How many turns between injections. 1 = every turn, 2 = every other, etc. * Default: 1. */ frequency: number; /** Internal turn counter (not persisted). */ turnCounter: number; } export const DEFAULT_CONFIG: TopOfMindConfig = { text: null, file: null, enabled: false, frequency: 1, turnCounter: 0, }; // --------------------------------------------------------------------------- // Path helpers // --------------------------------------------------------------------------- /** Resolve a path, expanding ~/ or bare ~ to the user's home directory. */ export function resolvePath(p: string, cwd: string): string { if (p === "~" || p.startsWith("~/")) return join(homedir(), p.slice(1)); if (p.startsWith("/")) return p; return resolve(cwd, p); } /** Get the project-level config path from a cwd. */ export function configPath(cwd: string): string { return join(cwd, ".pi", "extensions", "top-of-mind.json"); } /** Get the global (user home) config path. */ export function globalConfigPath(): string { return join(homedir(), ".pi", "extensions", "top-of-mind.json"); } // --------------------------------------------------------------------------- // File helpers // --------------------------------------------------------------------------- /** Read file contents, returning null on any error. */ export async function readFileContent(filePath: string, cwd: string): Promise { try { const resolved = resolvePath(filePath, cwd); const content = await readFile(resolved, "utf-8"); return content; } catch { return null; } } /** Read a JSON config file from disk, returning null if missing or corrupt. */ function readConfigFile(path: string): Partial | null { try { if (existsSync(path)) { return JSON.parse(readFileSync(path, "utf-8")) as Partial; } } catch { // ignore corrupt config } return null; } // --------------------------------------------------------------------------- // Environment variable overrides // --------------------------------------------------------------------------- /** * Read config overrides from environment variables. * These take the highest priority — they always win over file-based config. */ export function loadEnvOverrides(): Partial { const overrides: Partial = {}; if (process.env.PI_TOP_OF_MIND_PROMPT && process.env.PI_TOP_OF_MIND_PROMPT.trim() !== "") { overrides.text = process.env.PI_TOP_OF_MIND_PROMPT.trim(); } if (process.env.PI_TOP_OF_MIND_FILE && process.env.PI_TOP_OF_MIND_FILE.trim() !== "") { overrides.file = process.env.PI_TOP_OF_MIND_FILE.trim(); } if (process.env.PI_TOP_OF_MIND_ENABLED && process.env.PI_TOP_OF_MIND_ENABLED.trim() !== "") { const val = process.env.PI_TOP_OF_MIND_ENABLED.trim().toLowerCase(); overrides.enabled = val === "true" || val === "1" || val === "yes"; } if (process.env.PI_TOP_OF_MIND_FREQUENCY && process.env.PI_TOP_OF_MIND_FREQUENCY.trim() !== "") { const parsed = parseInt(process.env.PI_TOP_OF_MIND_FREQUENCY.trim(), 10); if (!isNaN(parsed) && parsed > 0) { overrides.frequency = parsed; } } return overrides; } // --------------------------------------------------------------------------- // Config loading / saving // --------------------------------------------------------------------------- /** * Apply a partial config layer on top of an existing config. * Uses nullish coalescing so that `undefined` fields in the layer * don't overwrite existing values (only explicit `null` or a real value does). */ function applyLayer(base: TopOfMindConfig, layer: Partial): TopOfMindConfig { return { ...base, text: layer.text !== undefined ? layer.text : base.text, file: layer.file !== undefined ? layer.file : base.file, enabled: layer.enabled !== undefined ? layer.enabled : base.enabled, frequency: layer.frequency !== undefined ? layer.frequency : base.frequency, turnCounter: layer.turnCounter !== undefined ? layer.turnCounter : base.turnCounter, }; } /** * Load config from disk with layered priority: * 1. Defaults (lowest) * 2. Global config (~/.pi/extensions/top-of-mind.json) * 3. Project config (.pi/extensions/top-of-mind.json) * 4. Environment variables (highest) */ export function loadConfig(cwd: string): TopOfMindConfig { // Start with defaults let config: TopOfMindConfig = { ...DEFAULT_CONFIG }; // Layer 1: Global config const globalRaw = readConfigFile(globalConfigPath()); if (globalRaw) { config = applyLayer(config, globalRaw); } // Layer 2: Project config const projectRaw = readConfigFile(configPath(cwd)); if (projectRaw) { config = applyLayer(config, projectRaw); } // Layer 3: Environment variables (highest priority) const envOverrides = loadEnvOverrides(); config = applyLayer(config, envOverrides); return config; } /** Save config to the project-level config file. Strips empty strings to null, strips turnCounter. */ export function saveConfig(cwd: string, config: TopOfMindConfig): void { const cp = configPath(cwd); const dir = dirname(cp); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); // Normalize: empty string → null, don't persist turn counter const toSave: Record = { text: config.text || null, file: config.file || null, enabled: config.enabled, frequency: config.frequency, }; writeFileSync(cp, JSON.stringify(toSave, null, 2), "utf-8"); } // --------------------------------------------------------------------------- // Injection helpers // --------------------------------------------------------------------------- /** Build the effective injection text from config. */ export async function buildInjectionText(config: TopOfMindConfig, cwd: string): Promise { const parts: string[] = []; if (config.text) parts.push(config.text); if (config.file) { const content = await readFileContent(config.file, cwd); if (content !== null) parts.push(content); } return parts.join("\n\n"); } /** Get the effective frequency (clamped to minimum 1). */ export function effectiveFrequency(frequency: number): number { return frequency > 0 ? frequency : 1; } /** Check if a turn counter should trigger injection based on frequency. */ export function shouldInject(turnCounter: number, frequency: number): boolean { const eff = effectiveFrequency(frequency); return turnCounter % eff === 0; } /** Check if config has any content configured. */ export function hasContent(config: TopOfMindConfig): boolean { return !!(config.text || config.file); }