feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user