diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/openclaw.plugin.json b/openclaw.plugin.json new file mode 100644 index 0000000..0fd8a2b --- /dev/null +++ b/openclaw.plugin.json @@ -0,0 +1,20 @@ +{ + "id": "prism-facet", + "name": "PrismFacet", + "version": "0.1.0", + "description": "Dynamic system prompt injection via routers and rules", + "main": "dist/index.js", + "configSchema": { + "type": "object", + "properties": { + "routersDir": { + "type": "string", + "description": "Directory containing router .ts/.js files (default: {pluginDir}/routers)" + }, + "rulesFile": { + "type": "string", + "description": "Path to rules.json (default: {pluginDir}/rules.json)" + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bfdc61b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "prism-facet", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "prism-facet", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b7e9960 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "prism-facet", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.5.0" + } +} diff --git a/routers/agent-id.js b/routers/agent-id.js new file mode 100644 index 0000000..5576d47 --- /dev/null +++ b/routers/agent-id.js @@ -0,0 +1,4 @@ +// Zero-dependency router: returns agentId directly +export function resolve(ctx) { + return ctx.agentId || ""; +} diff --git a/routers/position.js b/routers/position.js new file mode 100644 index 0000000..732671c --- /dev/null +++ b/routers/position.js @@ -0,0 +1,14 @@ +// Reads agent position from ego.json (optional dependency on PaddedCell/ego-mgr) +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; + +const EGO_PATH = `${homedir()}/.openclaw/ego.json`; + +export function resolve(ctx) { + try { + const ego = JSON.parse(readFileSync(EGO_PATH, "utf8")); + return ego["agent-scope"]?.[ctx.agentId]?.position || ""; + } catch { + return ""; + } +} diff --git a/routers/role.js b/routers/role.js new file mode 100644 index 0000000..e6f79d1 --- /dev/null +++ b/routers/role.js @@ -0,0 +1,14 @@ +// Reads agent role from ego.json (optional dependency on PaddedCell/ego-mgr) +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; + +const EGO_PATH = `${homedir()}/.openclaw/ego.json`; + +export function resolve(ctx) { + try { + const ego = JSON.parse(readFileSync(EGO_PATH, "utf8")); + return ego["agent-scope"]?.[ctx.agentId]?.role || ""; + } catch { + return ""; + } +} diff --git a/rules.json b/rules.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/rules.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..57b5a57 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,38 @@ +import path from "node:path"; +import { loadRouters } from "./router-loader.js"; +import { initRuleStore } from "./rule-store.js"; +import { resolveInjection } from "./prompt-injector.js"; +import { registerTools } from "./tools.js"; +import type { PluginApi, PluginConfig } from "./types.js"; + +export function register(api: PluginApi, config?: PluginConfig) { + const pluginDir = path.dirname(new URL(import.meta.url).pathname); + const routersDir = config?.routersDir || path.resolve(pluginDir, "..", "routers"); + const rulesFile = config?.rulesFile || path.resolve(pluginDir, "..", "rules.json"); + + api.logger.info(`[prism-facet] initializing (routers: ${routersDir}, rules: ${rulesFile})`); + + // Initialize rule store + initRuleStore(rulesFile); + + // Load routers + loadRouters(routersDir, api.logger).catch((err) => { + api.logger.error(`[prism-facet] failed to load routers: ${String(err)}`); + }); + + // Register before_prompt_build hook + api.on("before_prompt_build", async (_event: unknown, ctx: { agentId?: string }) => { + const agentId = ctx.agentId || ""; + if (!agentId) return; + + const result = await resolveInjection({ agentId }, api.logger); + if (result.appendSystemContext) { + return { appendSystemContext: result.appendSystemContext }; + } + }); + + // Register MCP tools + registerTools(api, routersDir); + + api.logger.info("[prism-facet] plugin registered"); +} diff --git a/src/prompt-injector.ts b/src/prompt-injector.ts new file mode 100644 index 0000000..f92ab68 --- /dev/null +++ b/src/prompt-injector.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { getRouters } from "./router-loader.js"; +import { getRulesForRouter } from "./rule-store.js"; +import type { RouterContext } from "./types.js"; + +export interface InjectionResult { + appendSystemContext?: string; +} + +export async function resolveInjection( + ctx: RouterContext, + logger: { info: (msg: string) => void; warn: (msg: string) => void } +): Promise { + const routers = getRouters(); + const segments: string[] = []; + + for (const router of routers) { + try { + const key = await router.module.resolve(ctx); + if (!key) continue; + + const promptFile = getRulesForRouter(router.name, key); + if (!promptFile) continue; + + try { + const content = readFileSync(promptFile, "utf8").trim(); + if (content) { + segments.push(content); + logger.info(`injecting ${router.name}:${key} → ${promptFile}`); + } + } catch (err) { + logger.warn(`failed to read prompt file ${promptFile}: ${String(err)}`); + } + } catch (err) { + logger.warn(`router ${router.name} failed: ${String(err)}`); + } + } + + if (segments.length === 0) return {}; + + return { + appendSystemContext: segments.join("\n\n---\n\n"), + }; +} diff --git a/src/router-loader.ts b/src/router-loader.ts new file mode 100644 index 0000000..2d3dc73 --- /dev/null +++ b/src/router-loader.ts @@ -0,0 +1,53 @@ +import { readdirSync } from "node:fs"; +import path from "node:path"; +import type { LoadedRouter, RouterModule } from "./types.js"; + +const loadedRouters: Map = new Map(); +let loadCounter = 0; + +function routerNameFromFile(filename: string): string { + return filename.replace(/\.(ts|js|mjs)$/, ""); +} + +export async function loadRouters( + routersDir: string, + logger: { info: (msg: string) => void; warn: (msg: string) => void } +): Promise { + loadedRouters.clear(); + loadCounter++; + + let files: string[]; + try { + files = readdirSync(routersDir).filter((f) => + /\.(ts|js|mjs)$/.test(f) && !f.startsWith(".") + ); + } catch { + logger.warn(`routers directory not found: ${routersDir}`); + return; + } + + for (const file of files) { + const name = routerNameFromFile(file); + const filePath = path.resolve(routersDir, file); + try { + // Cache-bust: append query param so Node re-imports the module + const mod = (await import(`${filePath}?v=${loadCounter}`)) as RouterModule; + if (typeof mod.resolve !== "function") { + logger.warn(`router ${name}: no resolve() export, skipping`); + continue; + } + loadedRouters.set(name, { name, filePath, module: mod }); + logger.info(`router loaded: ${name}`); + } catch (err) { + logger.warn(`router ${name}: failed to load — ${String(err)}`); + } + } +} + +export function getRouters(): LoadedRouter[] { + return Array.from(loadedRouters.values()); +} + +export function getRouterNames(): string[] { + return Array.from(loadedRouters.keys()); +} diff --git a/src/rule-store.ts b/src/rule-store.ts new file mode 100644 index 0000000..3c277fd --- /dev/null +++ b/src/rule-store.ts @@ -0,0 +1,45 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import type { RuleStore } from "./types.js"; + +let rules: RuleStore = {}; +let rulesFilePath = ""; + +export function initRuleStore(filePath: string): void { + rulesFilePath = filePath; + try { + const raw = readFileSync(filePath, "utf8"); + rules = JSON.parse(raw) as RuleStore; + } catch { + rules = {}; + } +} + +function save(): void { + if (!rulesFilePath) return; + writeFileSync(rulesFilePath, JSON.stringify(rules, null, 2) + "\n", "utf8"); +} + +export function addRule(router: string, key: string, promptFile: string): void { + rules[`${router}:${key}`] = promptFile; + save(); +} + +export function removeRule(router: string, key: string): boolean { + const ruleKey = `${router}:${key}`; + if (!(ruleKey in rules)) return false; + delete rules[ruleKey]; + save(); + return true; +} + +export function getRule(router: string, key: string): string | undefined { + return rules[`${router}:${key}`]; +} + +export function listRules(): Record { + return { ...rules }; +} + +export function getRulesForRouter(router: string, key: string): string | undefined { + return rules[`${router}:${key}`]; +} diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..22274b8 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,118 @@ +import { addRule, removeRule, listRules } from "./rule-store.js"; +import { loadRouters, getRouterNames } from "./router-loader.js"; +import { resolveInjection } from "./prompt-injector.js"; +import type { PluginApi } from "./types.js"; + +export function registerTools( + api: PluginApi, + routersDir: string +): void { + api.registerTool({ + name: "prompt_rules", + description: + "Manage PrismFacet prompt injection rules. " + + "Actions: add (register a rule), remove (delete a rule), list (show all rules), " + + "test (preview which prompts would be injected for an agent), " + + "reload-routers (hot-reload all router functions), list-routers (show loaded routers).", + parameters: { + type: "object", + properties: { + action: { + type: "string", + enum: [ + "add", + "remove", + "list", + "test", + "reload-routers", + "list-routers", + ], + description: "The action to perform", + }, + router: { + type: "string", + description: "Router name (for add/remove)", + }, + key: { + type: "string", + description: "Rule key (for add/remove)", + }, + file: { + type: "string", + description: "Prompt file path (for add)", + }, + agent: { + type: "string", + description: "Agent ID (for test)", + }, + }, + required: ["action"], + }, + handler: async (params: { + action: string; + router?: string; + key?: string; + file?: string; + agent?: string; + }) => { + switch (params.action) { + case "add": { + if (!params.router || !params.key || !params.file) { + return "Error: add requires --router, --key, and --file"; + } + addRule(params.router, params.key, params.file); + return `Rule added: ${params.router}:${params.key} → ${params.file}`; + } + + case "remove": { + if (!params.router || !params.key) { + return "Error: remove requires --router and --key"; + } + const removed = removeRule(params.router, params.key); + return removed + ? `Rule removed: ${params.router}:${params.key}` + : `Rule not found: ${params.router}:${params.key}`; + } + + case "list": { + const rules = listRules(); + const entries = Object.entries(rules); + if (entries.length === 0) return "No rules registered."; + return entries + .map(([k, v]) => `${k} → ${v}`) + .join("\n"); + } + + case "test": { + if (!params.agent) { + return "Error: test requires --agent"; + } + const result = await resolveInjection( + { agentId: params.agent }, + api.logger + ); + if (!result.appendSystemContext) { + return `No prompts matched for agent: ${params.agent}`; + } + return `Matched prompts for ${params.agent}:\n\n${result.appendSystemContext}`; + } + + case "reload-routers": { + await loadRouters(routersDir, api.logger); + const names = getRouterNames(); + return `Routers reloaded: ${names.join(", ") || "(none)"}`; + } + + case "list-routers": { + const names = getRouterNames(); + return names.length > 0 + ? `Loaded routers: ${names.join(", ")}` + : "No routers loaded."; + } + + default: + return `Unknown action: ${params.action}`; + } + }, + }); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e92398a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,32 @@ +export interface RouterContext { + agentId: string; +} + +export interface RouterModule { + resolve(ctx: RouterContext): string | Promise; +} + +export interface LoadedRouter { + name: string; + filePath: string; + module: RouterModule; +} + +export interface RuleStore { + [ruleKey: string]: string; // "router:key" → prompt file path +} + +export interface PluginApi { + logger: { + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; + }; + on(hook: string, handler: (...args: any[]) => any): void; + registerTool(def: any): void; +} + +export interface PluginConfig { + routersDir?: string; + rulesFile?: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..16ca579 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +}