113 lines
4.3 KiB
TypeScript
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}`);
|
|
}
|
|
}
|