import { readFileSync, readdirSync, existsSync } from 'fs'; import { join } from 'path'; import { parse as parseYaml } from 'yaml'; import { userPiecesDir } from '../user-folder/paths.js'; import { logger } from '../logger.js'; export interface CatalogEntry { name: string; description: string; keywords: string[]; source: 'builtin' | 'custom'; } /** * Loads built-in pieces once at construction time and layers per-user custom * pieces (data/users/{userId}/pieces/*.yaml) on top with a 60-second TTL * cache. Custom pieces with the same name as a built-in win (override). * * Usage: * const catalog = new PieceCatalog('pieces', config.userFolderRoot ?? './data/users'); * const entries = catalog.getForUser(userId); // used by classifyPiece * catalog.invalidate(userId); // called after silent-fork / reflection write */ export class PieceCatalog { private builtins: CatalogEntry[] = []; private cache = new Map(); private readonly ttlMs = 60_000; constructor( private readonly builtinDir: string, private readonly dataDir: string, ) { this.loadBuiltins(); } private loadBuiltins(): void { if (!existsSync(this.builtinDir)) { logger.warn(`[piece-catalog] builtinDir not found: ${this.builtinDir}`); return; } this.builtins = readdirSync(this.builtinDir) .filter(f => f.endsWith('.yaml')) .flatMap(f => { try { const doc = parseYaml(readFileSync(join(this.builtinDir, f), 'utf-8')) as Record | null; return [{ name: f.replace(/\.yaml$/, ''), description: typeof doc?.description === 'string' ? doc.description : '', keywords: Array.isArray((doc?.triggers as Record | null)?.keywords) ? (doc!.triggers as { keywords: string[] }).keywords : [], source: 'builtin' as const, }]; } catch (e) { logger.warn(`[piece-catalog] failed to parse builtin piece ${f}: ${e}`); return []; } }); logger.info(`[piece-catalog] loaded ${this.builtins.length} builtin pieces from ${this.builtinDir}`); } /** * Returns the merged catalog for userId: built-ins layered with any custom * pieces the user has in data/users/{userId}/pieces/. Result is cached for * ttlMs (60 s) and invalidated by invalidate(). */ getForUser(userId: string): CatalogEntry[] { const cached = this.cache.get(userId); if (cached && Date.now() - cached.ts < this.ttlMs) return cached.entries; const userDir = userPiecesDir(this.dataDir, userId); const overrides: CatalogEntry[] = existsSync(userDir) ? readdirSync(userDir) .filter(f => f.endsWith('.yaml')) .flatMap(f => { try { const doc = parseYaml(readFileSync(join(userDir, f), 'utf-8')) as Record | null; return [{ name: f.replace(/\.yaml$/, ''), description: typeof doc?.description === 'string' ? doc.description : '', keywords: Array.isArray((doc?.triggers as Record | null)?.keywords) ? (doc!.triggers as { keywords: string[] }).keywords : [], source: 'custom' as const, }]; } catch (e) { logger.warn(`[piece-catalog] failed to parse user piece ${f} for userId=${userId}: ${e}`); return []; } }) : []; // Built-ins are the base; custom pieces with the same name override. const byName = new Map(this.builtins.map(b => [b.name, b])); for (const o of overrides) byName.set(o.name, o); const entries = Array.from(byName.values()); this.cache.set(userId, { ts: Date.now(), entries }); logger.debug(`[piece-catalog] getForUser userId=${userId} builtins=${this.builtins.length} overrides=${overrides.length} total=${entries.length}`); return entries; } /** * Drops the TTL cache entry for userId so the next getForUser() call * re-reads disk. Call after silent-fork, reflection piece write, or * any manual edit to the user's custom pieces directory. */ invalidate(userId: string): void { this.cache.delete(userId); logger.debug(`[piece-catalog] invalidated cache for userId=${userId}`); } }