import { readdirSync } from "node:fs"; import path from "node:path"; export interface RouterContext { agentId: string; } export interface RouterModule { resolve(ctx: RouterContext): string | Promise; } export interface LoadedRouter { name: string; filePath: string; module: RouterModule; } const _G = globalThis as Record; const ROUTERS_KEY = "_prismFacetRouters"; const LOAD_COUNTER_KEY = "_prismFacetLoadCounter"; function getRouterMap(): Map { if (!(_G[ROUTERS_KEY] instanceof Map)) { _G[ROUTERS_KEY] = new Map(); } return _G[ROUTERS_KEY] as Map; } function routerNameFromFile(filename: string): string { return filename.replace(/\.(ts|js|mjs)$/, ""); } export async function loadRouters( routersDir: string, log: { info(msg: string): void; warn(msg: string): void } ): Promise { const map = getRouterMap(); // Don't clear() — that would wipe externally-registered routers added // by other plugins via __prismFacet.addRouter. Instead, track which // routers came from the filesystem this run and remove only those // that disappeared. External routers (filePath sentinel) are never // touched here. const FILE_SENTINEL_PREFIX = "(); for (const [name, r] of map.entries()) { if (!r.filePath.startsWith(FILE_SENTINEL_PREFIX)) fileRoutersBefore.add(name); } if (typeof _G[LOAD_COUNTER_KEY] !== "number") _G[LOAD_COUNTER_KEY] = 0; (_G[LOAD_COUNTER_KEY] as number)++; const counter = _G[LOAD_COUNTER_KEY] as number; let files: string[]; try { files = readdirSync(routersDir).filter( (f) => /\.(ts|js|mjs)$/.test(f) && !f.startsWith(".") ); } catch { log.warn(`[prism-facet] routers directory not found: ${routersDir}`); // No file-based routers this run — drop any leftover file routers. for (const name of fileRoutersBefore) map.delete(name); return; } const seenThisRun = new Set(); for (const file of files) { const name = routerNameFromFile(file); const filePath = path.resolve(routersDir, file); try { const mod = (await import(`${filePath}?v=${counter}`)) as RouterModule; if (typeof mod.resolve !== "function") { log.warn(`[prism-facet] router ${name}: no resolve() export, skipping`); continue; } map.set(name, { name, filePath, module: mod }); seenThisRun.add(name); log.info(`[prism-facet] router loaded: ${name}`); } catch (err) { log.warn(`[prism-facet] router ${name}: failed to load — ${String(err)}`); } } // Drop file-based routers that disappeared between loads. Untouched // routers stay. for (const stale of fileRoutersBefore) { if (!seenThisRun.has(stale)) map.delete(stale); } } export function getRouters(): LoadedRouter[] { return Array.from(getRouterMap().values()); } export function getRouterNames(): string[] { return Array.from(getRouterMap().keys()); } /** * Cross-plugin API: register a router programmatically. Other plugins * (e.g. ClawPrompts) call this via globalThis.__prismFacet.addRouter * to publish a router without dropping a .ts file in PrismFacet's * routersDir. Replaces any existing router of the same name. */ export function addExternalRouter( name: string, resolveFn: (ctx: RouterContext) => string | Promise, ): void { const map = getRouterMap(); map.set(name, { name, filePath: ``, module: { resolve: resolveFn }, }); }