Project Files
src / loader.ts
/**
* @file loader.ts
* @description Persona profile loader — loads persona definitions from JSON files
* in the profiles/ directory and validates them against the schema.
*
* Conventions:
* - Profiles are stored as individual .json files in profiles/
* - Each file must validate against PersonaProfile
* - The loader caches loaded profiles in memory
*/
import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import type { PersonaProfile } from "./types";
import { z } from "zod";
// ---------------------------------------------------------------------------
// Zod schemas for profile validation
// ---------------------------------------------------------------------------
const ExampleDialogSchema = z
.object({
role: z.enum(["user", "assistant"]),
content: z.string().min(1),
})
.strict();
const PersonaProfileLoaderSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().min(1),
systemPrompt: z.string().min(1),
greeting: z.string().optional(),
tags: z.array(z.string()).optional(),
examples: z.array(ExampleDialogSchema).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
.strict();
// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------
let _profilesDir: string | null = null;
let _cache: Map<string, PersonaProfile> | null = null;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Initialise the loader with the path to the profiles directory.
* Must be called before any load operations.
*/
export function initLoader(profilesDir: string): void {
_profilesDir = profilesDir;
_cache = null; // invalidate cache on re-init
}
/**
* Load all persona profiles from the profiles directory.
* Caches the result so subsequent calls are fast.
*
* Returns an array of PersonaProfile objects.
* Throws if the profiles directory does not exist or contains invalid files.
*/
export function loadAllProfiles(): PersonaProfile[] {
if (!_profilesDir) {
throw new Error(
"Loader not initialised. Call initLoader(profilesDir) first.",
);
}
if (_cache) {
return Array.from(_cache.values());
}
if (!existsSync(_profilesDir)) {
throw new Error(
`Profiles directory not found: ${_profilesDir}. ` +
"Create the directory and add at least one persona JSON file.",
);
}
const files = readdirSync(_profilesDir).filter(
(f) => f.endsWith(".json") && !f.startsWith("_"),
);
const profiles = new Map<string, PersonaProfile>();
for (const file of files) {
const filePath = join(_profilesDir, file);
const raw = readFileSync(filePath, "utf-8");
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Failed to parse JSON in "${file}": invalid JSON.`);
}
const validationResult = PersonaProfileLoaderSchema.safeParse(parsed);
if (!validationResult.success) {
const issues = validationResult.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
throw new Error(`Validation failed for "${file}": ${issues}`);
}
profiles.set(validationResult.data.id, validationResult.data);
}
_cache = profiles;
return Array.from(profiles.values());
}
/**
* Load a single persona profile by ID.
* Returns null if no profile with that ID exists.
*/
export function loadProfile(id: string): PersonaProfile | null {
const profiles = loadAllProfiles();
return profiles.find((p) => p.id === id) ?? null;
}
/**
* Reload profiles from disk (invalidates cache).
*/
export function reloadProfiles(): PersonaProfile[] {
_cache = null;
return loadAllProfiles();
}
// ---------------------------------------------------------------------------
// Profile persistence
// ---------------------------------------------------------------------------
/**
* Save a persona profile to disk as `{profilesDir}/{id}.json`.
*
* @param profile — The validated persona profile to persist.
* @param overwrite — If false and the file already exists, throws.
* @returns The absolute file path and whether an existing file was overwritten.
*/
export function saveProfileToDisk(
profile: PersonaProfile,
overwrite: boolean,
): { filePath: string; overwritten: boolean } {
if (!_profilesDir) {
throw new Error(
"Loader not initialised. Call initLoader(profilesDir) first.",
);
}
const filePath = join(_profilesDir, `${profile.id}.json`);
const exists = existsSync(filePath);
if (exists && !overwrite) {
throw new Error(
`Profile "${profile.id}" already exists at ${filePath}. ` +
"Set overwrite=true to replace it.",
);
}
const json = JSON.stringify(profile, null, 2);
writeFileSync(filePath, json, "utf-8");
// Invalidate and reload the cache so subsequent reads are consistent
reloadProfiles();
return { filePath, overwritten: exists };
}