feat: persist settings and enrich conversation context

This commit is contained in:
jjbrunton committed 2026-08-08 18:18:28 +01:00
1 parent a36723a325
commit 2c100d72b6
84 files changed
+2248 -644

No files matched your search

+20 -1
View File
@@ -157,7 +157,20 @@ export const ConfigSchema = z.object({
export type Config = z.infer<typeof ConfigSchema>;
export function loadConfig(): Config {
export interface ConfigPersistence {
load(environment?: NodeJS.ProcessEnv): Config | null;
save(config: Config): void;
}
/**
* Loads the database-backed configuration. A legacy YAML file is imported
* exactly once when the database has not yet been seeded, which makes the
* migration safe for existing installations.
*/
export function loadConfig(store?: ConfigPersistence): Config {
const storedConfig = store?.load(process.env);
if (storedConfig) return storedConfig;
const configPath = path.resolve(process.cwd(), 'config.yaml');
if (!fs.existsSync(configPath)) {
@@ -173,6 +186,11 @@ export function loadConfig(): Config {
}
rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey;
if (!rawConfig.database) rawConfig.database = {};
// The database must be located before application settings can be read, so
// its path is an environment-owned bootstrap value rather than app config.
rawConfig.database.path = process.env.DATABASE_PATH ?? './data/reddit-bot.db';
if (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
if (!rawConfig.web) rawConfig.web = {};
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
@@ -195,5 +213,6 @@ export function loadConfig(): Config {
throw new Error('WEB_DASHBOARD_TOKEN environment variable or web.authToken configuration is required when the dashboard is enabled');
}
store?.save(result.data);
return result.data;
}
+73 -64
View File
@@ -1,74 +1,83 @@
import fs from 'fs';
import path from 'path';
import YAML from 'yaml';
import type { SubredditConfig } from './index.js';
import type Database from 'better-sqlite3';
import { ConfigSchema, type Config } from './index.js';
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web'> & {
openrouter: Omit<Config['openrouter'], 'apiKey'>;
web: Omit<Config['web'], 'authToken'>;
};
/**
* Writes an updated subreddit list back to `config.yaml`, preserving the
* surrounding YAML structure, comment blocks, and per-row inline comments
* for entries that survive the update.
*
* Strategy:
* - Parse with `YAML.parseDocument` so the CST (including comments) is
* intact.
* - For each updated entry that matches an existing row by name, mutate
* the existing node's fields in place — its `.comment` (the trailing
* `# ...` annotation on the line) is preserved.
* - New entries become fresh flow-style maps appended in the caller's
* order; entries no longer in the list are dropped (and their comments
* go with them, since the comment is attached to the row).
* - Atomic write via tmp file + rename so a crash mid-write can't leave
* a half-written config on disk.
* Persists application-owned configuration in SQLite. Secrets and the
* database location remain environment-owned bootstrap settings and are
* deliberately never written to the settings record.
*/
export function persistSubreddits(
subreddits: SubredditConfig[],
configPath: string = path.resolve(process.cwd(), 'config.yaml'),
): void {
const raw = fs.readFileSync(configPath, 'utf-8');
const doc = YAML.parseDocument(raw);
export class ConfigStore {
constructor(private readonly db: Database.Database) {}
const seq = doc.getIn(['reddit', 'subreddits']) as YAML.YAMLSeq | undefined;
if (!seq || !YAML.isSeq(seq)) {
throw new Error('reddit.subreddits is not a sequence in config.yaml');
}
load(environment: NodeJS.ProcessEnv = process.env): Config | null {
const row = this.db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as
| { config_json: string }
| undefined;
if (!row) return null;
// Map existing items by their `name` so we can reuse YAMLMap nodes
// (and the trailing comments attached to them).
const existingByName = new Map<string, YAML.YAMLMap>();
for (const item of seq.items) {
if (!YAML.isMap(item)) continue;
const name = item.get('name');
if (typeof name === 'string') existingByName.set(name, item);
}
const nextItems: YAML.YAMLMap[] = [];
for (const sub of subreddits) {
const existing = existingByName.get(sub.name);
const node = existing ?? new YAML.YAMLMap();
if (!existing) {
// Match the on-disk style: flow-mapped, single-quoted strings.
node.flow = true;
node.set('name', sub.name);
let stored: unknown;
try {
stored = JSON.parse(row.config_json);
} catch (error) {
throw new Error(`Stored application configuration is not valid JSON: ${String(error)}`);
}
setOrDelete(node, 'type', sub.type === 'image' ? undefined : sub.type);
setOrDelete(node, 'weight', sub.weight);
setOrDelete(node, 'tone', sub.tone === 'default' ? undefined : sub.tone);
setOrDelete(node, 'scripted', sub.scripted);
nextItems.push(node);
return validateConfig(withBootstrapSettings(stored, environment));
}
seq.items = nextItems;
const out = doc.toString();
const tmp = configPath + '.tmp';
fs.writeFileSync(tmp, out, 'utf-8');
fs.renameSync(tmp, configPath);
}
function setOrDelete(node: YAML.YAMLMap, key: string, value: unknown): void {
if (value === undefined || value === null || value === '') {
if (node.has(key)) node.delete(key);
return;
save(config: Config): void {
const stored = toStoredConfig(config);
this.db.prepare(`
INSERT INTO app_config (id, config_json, updated_at)
VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET
config_json = excluded.config_json,
updated_at = excluded.updated_at
`).run(JSON.stringify(stored), Date.now());
}
node.set(key, value);
}
function toStoredConfig(config: Config): StoredConfig {
const { database: _database, ...applicationConfig } = config;
const { apiKey: _apiKey, ...openrouter } = applicationConfig.openrouter;
const { authToken: _authToken, ...web } = applicationConfig.web;
return { ...applicationConfig, openrouter, web };
}
function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv): unknown {
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return stored;
const record = stored as Record<string, unknown>;
const openrouter = objectValue(record.openrouter);
const web = objectValue(record.web);
return {
...record,
database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' },
openrouter: { ...openrouter, apiKey: environment.OPENROUTER_API_KEY },
web: { ...web, authToken: environment.WEB_DASHBOARD_TOKEN },
};
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function validateConfig(raw: unknown): Config {
const result = ConfigSchema.safeParse(raw);
if (!result.success) {
const errors = result.error.issues
.map((issue) => ` - ${String(issue.path.join('.'))}: ${issue.message}`)
.join('\n');
throw new Error(`Invalid stored configuration:\n${errors}`);
}
if (result.data.web.enabled && !result.data.web.authToken) {
throw new Error('WEB_DASHBOARD_TOKEN environment variable is required when the dashboard is enabled');
}
return result.data;
}