/**
* pi-aftc-toolset — persistent configuration module.
*
* One file, one concern: `config.json` holds cross-session USER
* PREFERENCES that persist forever (until the user changes the
* value). Loaded on every session start. Survives /reload, /new,
* fresh pi startup, and machine reboot.
*
* Currently tracked preferences:
* - footerTimeframe (Today / 3h / 6h / 24h / 2d / 3d / 7d / 28d)
* - footerEnabled (footer widget on/off)
* - responseDividerEnabled (response divider on/off)
* - thinkProcessingEnabled (inline … → ThinkingContent block)
* - "aftc-intro" (AFTC startup wordmark animation on/off)
*
* SSH connection records are intentionally stored separately in `ssh.json`.
* `config.json` is created with `DEFAULT_PREFERENCES` on first access
* if it doesn't already exist. It is ONLY re-written when one of those
* preference actually changes (via `setPreference`) — never on a
* timer, never on every turn, never on shutdown.
*
* All operations are best-effort. Errors are logged and the call falls
* back to defaults rather than crashing pi. The file lives under
* `.pi-aftc-toolset/`, which is gitignored and npm-ignored as a whole —
* it is never committed or shipped; fresh installs and updates simply
* re-create it from `DEFAULT_PREFERENCES` on first access.
*
* Atomic writes: each save goes through a tmp file + rename so a crash
* mid-write can't leave the file half-written.
*
* Self-contained module — no event subscriptions, no cross-module
* imports. Feature modules import `getPreference` / `setPreference`.
*
* See `config.readme.md` for the full contract.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getConfigJson, getDataDir } from "./paths";
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
/**
* User preferences that persist across all session boundaries.
* Each field is optional on disk (so a partial or older config.json
* still loads cleanly) but always populated in memory after the
* defaults-merge in `loadPreferencesInternal`.
*/
export interface Preferences {
/** Footer 4th-line time window: today | 3h | 6h | 24h | 2d | 3d | 7d | 28d. */
footerTimeframe?: string;
/** Whether the footer widget is currently shown. */
footerEnabled?: boolean;
/** Whether the response divider is currently shown. */
responseDividerEnabled?: boolean;
/** Whether the think-parser hook converts inline …
* text tags in assistant messages into proper ThinkingContent
* blocks. Off by default — users opt in via
* /aftc-enable-think-processing. */
thinkProcessingEnabled?: boolean;
/** Whether the AFTC startup wordmark animation is shown. */
"aftc-intro"?: boolean;
}
// ─────────────────────────────────────────────────────────────────────────────
// Defaults — the single source of truth for a fresh config.json
// ─────────────────────────────────────────────────────────────────────────────
/**
* Default preferences. Used to:
* - generate a brand-new config.json on first access
* (`ensureConfigFile`), and
* - merge against a partial / older config.json so missing keys
* always get their default value.
*
* No schema version is tracked. Adding new preference fields just
* means users on older files get the new field's default until they
* change it; existing saved values are never discarded.
*
* Keep this object in sync with the `Preferences` interface above
* and with the setPreference call sites in the extension
* (footer-widget.ts, response.ts, think-parser.ts, intro.ts).
*/
export const DEFAULT_PREFERENCES: Preferences = {
footerTimeframe: "3d",
footerEnabled: true,
responseDividerEnabled: true,
thinkProcessingEnabled: false,
"aftc-intro": true,
};
// ─────────────────────────────────────────────────────────────────────────────
// Preferences (config.json) - ensure, read, write, cache
// ─────────────────────────────────────────────────────────────────────────────
let cachedPreferences: Preferences | null = null;
/**
* Create config.json with `DEFAULT_PREFERENCES` if it doesn't exist
* yet. Called lazily on the first `loadPreferencesInternal`. Also
* creates the parent data dir if needed. Best-effort: any I/O error
* is logged and swallowed so pi still boots (the in-memory cache
* falls back to defaults).
*/
function ensureConfigFile(): void {
const filePath = getConfigJson();
try {
if (!fs.existsSync(filePath)) {
const legacyPath = path.join(getDataDir(), "state.json");
if (fs.existsSync(legacyPath)) {
fs.renameSync(legacyPath, filePath);
return;
}
const dataDir = path.dirname(filePath);
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const tmpPath = filePath + ".tmp";
fs.writeFileSync(tmpPath, JSON.stringify(DEFAULT_PREFERENCES, null, 2), "utf-8");
fs.renameSync(tmpPath, filePath);
}
} catch (err) {
console.log(`[aftc-toolset] config.json ensure error: ${(err as Error).message}`);
}
}
function loadPreferencesInternal(): Preferences {
if (cachedPreferences !== null) return cachedPreferences;
ensureConfigFile();
const filePath = getConfigJson();
try {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as Preferences;
if (!parsed || typeof parsed !== "object") {
cachedPreferences = { ...DEFAULT_PREFERENCES };
return cachedPreferences;
}
// Merge so missing keys still get their defaults. Existing
// saved values are preserved even if the file has extra
// unknown fields (e.g. a leftover `version` from an earlier
// release is silently ignored).
//
// Unlike most defaults, the intro preference is written back
// immediately when missing. This migrates config.json files from
// versions released before the startup animation toggle existed,
// making the default explicit and preserving it for later sessions.
const savedIntroEnabled = parsed["aftc-intro"];
const needsIntroMigration = typeof savedIntroEnabled !== "boolean";
cachedPreferences = {
...DEFAULT_PREFERENCES,
...parsed,
"aftc-intro": needsIntroMigration
? DEFAULT_PREFERENCES["aftc-intro"]
: savedIntroEnabled,
};
if (needsIntroMigration) savePreferencesInternal(cachedPreferences);
return cachedPreferences;
} catch (err) {
console.log(`[aftc-toolset] config.json read/parse error: ${(err as Error).message}`);
cachedPreferences = { ...DEFAULT_PREFERENCES };
return cachedPreferences;
}
}
function savePreferencesInternal(prefs: Preferences): void {
cachedPreferences = prefs;
const filePath = getConfigJson();
const dataDir = path.dirname(filePath);
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
// Atomic write: tmp + rename so a crash mid-write can't leave
// the file half-written.
const tmpPath = filePath + ".tmp";
fs.writeFileSync(tmpPath, JSON.stringify(prefs, null, 2), "utf-8");
fs.renameSync(tmpPath, filePath);
} catch (err) {
console.log(`[aftc-toolset] config.json write error: ${(err as Error).message}`);
}
}
/** Clear the in-memory cache so the next read hits disk. Test-only. */
export function _resetPreferencesCacheForTests(): void {
cachedPreferences = null;
}
/**
* Read a single preference. Returns the saved value if present,
* otherwise the supplied default. Type-safe - the return type is
* inferred from the default.
*/
export function getPreference>(
key: K,
defaultValue: Preferences[K],
): Preferences[K] {
const prefs = loadPreferencesInternal();
const value = prefs[key];
// `value` is typed as Preferences[K] but on disk it could be
// anything if the file was hand-edited. Fall back to the default
// when the saved value is undefined.
return (value === undefined ? defaultValue : value) as Preferences[K];
}
/**
* Persist a single preference. Updates the cache and writes
* config.json atomically. Best-effort: errors are logged, never
* thrown. This is the ONLY path that writes config.json after the
* initial ensure — call it when footerEnabled / footerTimeframe /
* responseDividerEnabled, thinkProcessingEnabled, or "aftc-intro"
* actually changes.
*/
export function setPreference>(
key: K,
value: Preferences[K],
): void {
const prefs = loadPreferencesInternal();
prefs[key] = value;
savePreferencesInternal(prefs);
}
// ─────────────────────────────────────────────────────────────────────────────
// Data dir re-export (tests / other modules may want it)
// ─────────────────────────────────────────────────────────────────────────────
/** Test helper: re-export the data dir so tests can verify cleanup. */
export function _dataDirForTests(): string {
return getDataDir();
}