import type { Dirent, Stats } from "node:fs"; import { readdir, readFile, realpath, stat } from "node:fs/promises"; import { dirname, relative, resolve, sep } from "node:path"; import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; import { ERROR_ACCESS_CHARACTER_IMPORT_PREFIX, ERROR_CLAIMED_SUMMARY_MISMATCH_PREFIX, ERROR_DUPLICATE_AND, ERROR_DUPLICATE_CHARACTER_ID_PREFIX, ERROR_DUPLICATE_CHARACTER_NAME_PREFIX, ERROR_DUPLICATE_SUFFIX, ERROR_IMPORT_NOT_FILE_OR_DIR_PREFIX, ERROR_MD_EXTENSION_PREFIX, ERROR_MD_MISSING_FIELD_FIELD_SUFFIX, ERROR_MD_MISSING_FIELD_PREFIX, ERROR_MD_MISSING_FIELD_SUFFIX, ERROR_PARSE_CHARACTER_MD_PREFIX, ERROR_READ_CHARACTER_DIR_PREFIX, ERROR_READ_CHARACTER_MD_PREFIX, } from "../shared/messages.js"; export interface CharacterSummary { characterId: string; name: string; description: string; } export interface CharacterCard extends CharacterSummary { path: string; prompt: string; } export interface CharacterImport { path: string; configPath: string; } export interface ClaimedCharacter { characterId: string; name: string; description: string; path: string; } export async function loadCharacterCard(path: string, configPath: string): Promise { const resolvedPath = resolve(path); let contents: string; try { contents = await readFile(resolvedPath, "utf8"); } catch (error) { throw new Error(`${ERROR_READ_CHARACTER_MD_PREFIX}${resolvedPath}`, { cause: error }); } let parsed: ReturnType; try { parsed = parseFrontmatter(contents); } catch (error) { throw new Error(`${ERROR_PARSE_CHARACTER_MD_PREFIX}${resolvedPath}`, { cause: error }); } const name = readRequiredString(parsed.frontmatter, "name", resolvedPath); const description = readRequiredString(parsed.frontmatter, "description", resolvedPath); return { characterId: toCharacterId(resolvedPath, configPath), name, description, path: resolvedPath, prompt: parsed.body, }; } export async function loadCharacterCards(imports: CharacterImport[]): Promise { const discovered = new Map(); for (const characterImport of imports) { const paths = await discoverImport(characterImport.path); for (const path of paths) { const canonicalPath = await realpath(path); if (!discovered.has(canonicalPath)) { discovered.set(canonicalPath, { path: resolve(path), configPath: characterImport.configPath, }); } } } const characters: CharacterCard[] = []; const names = new Map(); const characterIds = new Map(); for (const discoveredCharacter of discovered.values()) { const character = await loadCharacterCard(discoveredCharacter.path, discoveredCharacter.configPath); const existingNamePath = names.get(character.name); if (existingNamePath) { throw new Error( `${ERROR_DUPLICATE_CHARACTER_NAME_PREFIX}${character.name}${ERROR_DUPLICATE_SUFFIX}${existingNamePath}${ERROR_DUPLICATE_AND}${character.path}`, ); } const existingIdPath = characterIds.get(character.characterId); if (existingIdPath) { throw new Error( `${ERROR_DUPLICATE_CHARACTER_ID_PREFIX}${character.characterId}${ERROR_DUPLICATE_SUFFIX}${existingIdPath}${ERROR_DUPLICATE_AND}${character.path}`, ); } names.set(character.name, character.path); characterIds.set(character.characterId, character.path); characters.push(character); } return characters; } export async function loadClaimedCharacter(claimed: ClaimedCharacter): Promise { const loaded = await loadCharacterCard(claimed.path, resolve(dirname(claimed.path), "tavern.json")); if (loaded.name !== claimed.name || loaded.description !== claimed.description) { throw new Error(`${ERROR_CLAIMED_SUMMARY_MISMATCH_PREFIX}${loaded.path}`); } return { ...loaded, characterId: claimed.characterId, }; } async function discoverImport(path: string): Promise { const resolvedPath = resolve(path); let importedStat: Stats; try { importedStat = await stat(resolvedPath); } catch (error) { throw new Error(`${ERROR_ACCESS_CHARACTER_IMPORT_PREFIX}${resolvedPath}`, { cause: error }); } if (importedStat.isFile()) { if (!resolvedPath.endsWith(".md")) { throw new Error(`${ERROR_MD_EXTENSION_PREFIX}${resolvedPath}`); } return [resolvedPath]; } if (!importedStat.isDirectory()) { throw new Error(`${ERROR_IMPORT_NOT_FILE_OR_DIR_PREFIX}${resolvedPath}`); } return discoverDirectory(resolvedPath); } async function discoverDirectory(directory: string): Promise { let entries: Dirent[]; try { entries = await readdir(directory, { withFileTypes: true }); } catch (error) { throw new Error(`${ERROR_READ_CHARACTER_DIR_PREFIX}${directory}`, { cause: error }); } entries.sort((left, right) => left.name.localeCompare(right.name)); const paths: string[] = []; for (const entry of entries) { const path = resolve(directory, entry.name); if (entry.isDirectory()) { paths.push(...(await discoverDirectory(path))); } else if (entry.isFile() && entry.name.endsWith(".md")) { paths.push(path); } } return paths; } function readRequiredString(frontmatter: Record, field: string, path: string): string { const value = frontmatter[field]; if (typeof value !== "string" || value.trim() === "") { throw new Error( `${ERROR_MD_MISSING_FIELD_PREFIX}${path}${ERROR_MD_MISSING_FIELD_SUFFIX}${field}${ERROR_MD_MISSING_FIELD_FIELD_SUFFIX}`, ); } return value.trim(); } function toCharacterId(path: string, configPath: string): string { return relative(dirname(resolve(configPath)), path) .split(sep) .join("/"); }