maestro/src/engine/piece-catalog.ts
2026-06-03 05:08:00 +00:00

113 lines
4.3 KiB
TypeScript

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<string, { ts: number; entries: CatalogEntry[] }>();
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<string, unknown> | null;
return [{
name: f.replace(/\.yaml$/, ''),
description: typeof doc?.description === 'string' ? doc.description : '',
keywords: Array.isArray((doc?.triggers as Record<string, unknown> | 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<string, unknown> | null;
return [{
name: f.replace(/\.yaml$/, ''),
description: typeof doc?.description === 'string' ? doc.description : '',
keywords: Array.isArray((doc?.triggers as Record<string, unknown> | 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<string, CatalogEntry>(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}`);
}
}