diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e023caa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +plugin/**/*.js +plugin/**/*.js.map 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..d17b3bc --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "prism-facet", + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "tsc --project tsconfig.plugin.json", + "install-plugin": "node scripts/install.mjs --install", + "uninstall-plugin": "node scripts/install.mjs --uninstall" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.5.0" + } +} diff --git a/plugin/core/prompt-injector.ts b/plugin/core/prompt-injector.ts new file mode 100644 index 0000000..d235c2b --- /dev/null +++ b/plugin/core/prompt-injector.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { getRouters } from "./router-loader.js"; +import { getRule } from "./rule-store.js"; +import type { RouterContext } from "./router-loader.js"; + +export interface InjectionResult { + appendSystemContext?: string; +} + +export async function resolveInjection( + ctx: RouterContext, + log: { 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 = getRule(router.name, key); + if (!promptFile) continue; + + try { + const content = readFileSync(promptFile, "utf8").trim(); + if (content) { + segments.push(content); + log.info(`[prism-facet] injecting ${router.name}:${key} → ${promptFile}`); + } + } catch (err) { + log.warn(`[prism-facet] cannot read ${promptFile}: ${String(err)}`); + } + } catch (err) { + log.warn(`[prism-facet] router ${router.name} failed: ${String(err)}`); + } + } + + if (segments.length === 0) return {}; + return { appendSystemContext: segments.join("\n\n---\n\n") }; +} diff --git a/plugin/core/router-loader.ts b/plugin/core/router-loader.ts new file mode 100644 index 0000000..a7bf157 --- /dev/null +++ b/plugin/core/router-loader.ts @@ -0,0 +1,77 @@ +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(); + map.clear(); + + 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}`); + return; + } + + 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 }); + log.info(`[prism-facet] router loaded: ${name}`); + } catch (err) { + log.warn(`[prism-facet] router ${name}: failed to load — ${String(err)}`); + } + } +} + +export function getRouters(): LoadedRouter[] { + return Array.from(getRouterMap().values()); +} + +export function getRouterNames(): string[] { + return Array.from(getRouterMap().keys()); +} diff --git a/plugin/core/rule-store.ts b/plugin/core/rule-store.ts new file mode 100644 index 0000000..1e1bb9b --- /dev/null +++ b/plugin/core/rule-store.ts @@ -0,0 +1,56 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +export type RuleStore = Record; // "router:key" → prompt file path + +const _G = globalThis as Record; +const RULES_KEY = "_prismFacetRules"; +const RULES_PATH_KEY = "_prismFacetRulesPath"; + +function getRules(): RuleStore { + if (!_G[RULES_KEY] || typeof _G[RULES_KEY] !== "object") { + _G[RULES_KEY] = {}; + } + return _G[RULES_KEY] as RuleStore; +} + +function getRulesPath(): string { + return (_G[RULES_PATH_KEY] as string) || ""; +} + +function save(): void { + const p = getRulesPath(); + if (!p) return; + writeFileSync(p, JSON.stringify(getRules(), null, 2) + "\n", "utf8"); +} + +export function initRuleStore(filePath: string): void { + _G[RULES_PATH_KEY] = filePath; + try { + const raw = readFileSync(filePath, "utf8"); + _G[RULES_KEY] = JSON.parse(raw) as RuleStore; + } catch { + _G[RULES_KEY] = {}; + } +} + +export function addRule(router: string, key: string, promptFile: string): void { + getRules()[`${router}:${key}`] = promptFile; + save(); +} + +export function removeRule(router: string, key: string): boolean { + const rules = getRules(); + 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 getRules()[`${router}:${key}`]; +} + +export function listRules(): Record { + return { ...getRules() }; +} diff --git a/plugin/hooks/before-prompt-build.ts b/plugin/hooks/before-prompt-build.ts new file mode 100644 index 0000000..5f23c50 --- /dev/null +++ b/plugin/hooks/before-prompt-build.ts @@ -0,0 +1,25 @@ +import { resolveInjection } from "../core/prompt-injector.js"; + +const _G = globalThis as Record; +const DEDUP_KEY = "_prismFacetBPBDedup"; + +export function registerBeforePromptBuild(api: { + on(hook: string, handler: (...args: any[]) => any): void; + logger: { info(msg: string): void; warn(msg: string): void }; +}): void { + if (!(_G[DEDUP_KEY] instanceof WeakSet)) _G[DEDUP_KEY] = new WeakSet(); + const dedup = _G[DEDUP_KEY] as WeakSet; + + api.on("before_prompt_build", async (event: unknown, ctx: { agentId?: string }) => { + if (dedup.has(event as object)) return; + dedup.add(event as object); + + const agentId = ctx.agentId || ""; + if (!agentId) return; + + const result = await resolveInjection({ agentId }, api.logger); + if (result.appendSystemContext) { + return { appendSystemContext: result.appendSystemContext }; + } + }); +} diff --git a/plugin/index.ts b/plugin/index.ts new file mode 100644 index 0000000..aa0a28d --- /dev/null +++ b/plugin/index.ts @@ -0,0 +1,66 @@ +import path from "node:path"; +import { loadRouters } from "./core/router-loader.js"; +import { initRuleStore } from "./core/rule-store.js"; +import { registerBeforePromptBuild } from "./hooks/before-prompt-build.js"; +import { registerPromptRulesTool } from "./tools/prompt-rules.js"; + +interface PluginConfig { + routersDir?: string; + rulesFile?: string; +} + +interface OpenClawPluginApi { + 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; + config?: PluginConfig; +} + +const _G = globalThis as Record; +const LIFECYCLE_KEY = "_prismFacetGatewayLifecycleRegistered"; + +function normalizeConfig(api: OpenClawPluginApi): PluginConfig { + const raw = (api as any).config ?? {}; + return { + routersDir: typeof raw.routersDir === "string" ? raw.routersDir : undefined, + rulesFile: typeof raw.rulesFile === "string" ? raw.rulesFile : undefined, + }; +} + +export default { + id: "prism-facet", + name: "PrismFacet", + + register(api: OpenClawPluginApi) { + const config = normalizeConfig(api); + 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"); + + // Gateway lifecycle: init once + if (!_G[LIFECYCLE_KEY]) { + _G[LIFECYCLE_KEY] = true; + + initRuleStore(rulesFile); + loadRouters(routersDir, api.logger).catch((err) => { + api.logger.error(`[prism-facet] failed to load routers: ${String(err)}`); + }); + + api.on("gateway_stop", () => { + _G[LIFECYCLE_KEY] = false; + }); + } + + // Agent session hooks: register every time (dedup inside handler) + registerBeforePromptBuild(api); + + // Tools + registerPromptRulesTool(api, routersDir); + + api.logger.info("[prism-facet] plugin registered"); + }, +}; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json new file mode 100644 index 0000000..7e7ec2e --- /dev/null +++ b/plugin/openclaw.plugin.json @@ -0,0 +1,21 @@ +{ + "id": "prism-facet", + "name": "PrismFacet", + "version": "0.1.0", + "description": "Dynamic system prompt injection via routers and rules", + "main": "index.js", + "configSchema": { + "type": "object", + "additionalProperties": false, + "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/plugin/package.json b/plugin/package.json new file mode 100644 index 0000000..d702479 --- /dev/null +++ b/plugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "prism-facet", + "version": "0.1.0", + "type": "module", + "main": "index.js" +} diff --git a/plugin/tools/prompt-rules.ts b/plugin/tools/prompt-rules.ts new file mode 100644 index 0000000..5897a6e --- /dev/null +++ b/plugin/tools/prompt-rules.ts @@ -0,0 +1,100 @@ +import { addRule, removeRule, listRules } from "../core/rule-store.js"; +import { loadRouters, getRouterNames } from "../core/router-loader.js"; +import { resolveInjection } from "../core/prompt-injector.js"; + +export function registerPromptRulesTool( + api: { + registerTool(def: any): void; + logger: { info(msg: string): void; warn(msg: string): void }; + }, + routersDir: string +): void { + api.registerTool({ + name: "prompt_rules", + description: + "Manage PrismFacet prompt injection rules. " + + "Actions: add (register a rule mapping router:key to a prompt file), " + + "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: "Absolute path to prompt file (for add)", + }, + agent: { + type: "string", + description: "Agent ID to test (for test)", + }, + }, + required: ["action"], + }, + execute: async (_toolCallId: string, params: Record) => { + const action = params.action as string; + const router = params.router as string | undefined; + const key = params.key as string | undefined; + const file = params.file as string | undefined; + const agent = params.agent as string | undefined; + + switch (action) { + case "add": { + if (!router || !key || !file) { + return { content: [{ type: "text", text: "Error: add requires router, key, and file" }] }; + } + addRule(router, key, file); + return { content: [{ type: "text", text: `Rule added: ${router}:${key} → ${file}` }] }; + } + case "remove": { + if (!router || !key) { + return { content: [{ type: "text", text: "Error: remove requires router and key" }] }; + } + const removed = removeRule(router, key); + const msg = removed ? `Rule removed: ${router}:${key}` : `Rule not found: ${router}:${key}`; + return { content: [{ type: "text", text: msg }] }; + } + case "list": { + const rules = listRules(); + const entries = Object.entries(rules); + if (entries.length === 0) return { content: [{ type: "text", text: "No rules registered." }] }; + return { content: [{ type: "text", text: entries.map(([k, v]) => `${k} → ${v}`).join("\n") }] }; + } + case "test": { + if (!agent) return { content: [{ type: "text", text: "Error: test requires agent" }] }; + const result = await resolveInjection({ agentId: agent }, api.logger); + if (!result.appendSystemContext) { + return { content: [{ type: "text", text: `No prompts matched for agent: ${agent}` }] }; + } + return { content: [{ type: "text", text: `Matched prompts for ${agent}:\n\n${result.appendSystemContext}` }] }; + } + case "reload-routers": { + await loadRouters(routersDir, api.logger); + const names = getRouterNames(); + return { content: [{ type: "text", text: `Routers reloaded: ${names.join(", ") || "(none)"}` }] }; + } + case "list-routers": { + const names = getRouterNames(); + const msg = names.length > 0 ? `Loaded routers: ${names.join(", ")}` : "No routers loaded."; + return { content: [{ type: "text", text: msg }] }; + } + default: + return { content: [{ type: "text", text: `Unknown action: ${action}` }] }; + } + }, + }); +} 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/scripts/install.mjs b/scripts/install.mjs new file mode 100644 index 0000000..3723d91 --- /dev/null +++ b/scripts/install.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projRoot = path.resolve(__dirname, ".."); +const pluginSrcDir = path.join(projRoot, "plugin"); +const routersSrcDir = path.join(projRoot, "routers"); + +const PLUGIN_ID = "prism-facet"; +const ocDir = path.join(process.env.HOME || "~", ".openclaw"); +const pluginsDir = path.join(ocDir, "plugins"); +const installDir = path.join(pluginsDir, PLUGIN_ID); +const configPath = path.join(ocDir, "openclaw.json"); + +const args = process.argv.slice(2); +const action = args[0]; + +if (!action || !["--install", "--uninstall", "--update"].includes(action)) { + console.log("Usage: node scripts/install.mjs --install | --uninstall | --update"); + process.exit(1); +} + +function readConfig() { + return JSON.parse(fs.readFileSync(configPath, "utf8")); +} + +function writeConfig(cfg) { + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8"); +} + +function setIfMissing(obj, key, value) { + if (obj[key] === undefined || obj[key] === null) { + obj[key] = value; + } +} + +function buildPlugin() { + console.log("Building plugin..."); + // Compile TypeScript from plugin/ to plugin/ (in-place, JS output) + execSync("npx tsc --project tsconfig.plugin.json", { cwd: projRoot, stdio: "inherit" }); +} + +function install() { + // 1. Build + buildPlugin(); + + // 2. Clean and copy plugin to install dir + if (fs.existsSync(installDir)) { + fs.rmSync(installDir, { recursive: true }); + } + fs.mkdirSync(installDir, { recursive: true }); + + // Copy compiled plugin files + copyDirRecursive(pluginSrcDir, installDir, [".ts"]); + + // Copy routers alongside plugin + const routersInstallDir = path.join(installDir, "routers"); + if (fs.existsSync(routersSrcDir)) { + fs.mkdirSync(routersInstallDir, { recursive: true }); + copyDirRecursive(routersSrcDir, routersInstallDir); + } + + // Create empty rules.json if not exists + const rulesPath = path.join(installDir, "rules.json"); + if (!fs.existsSync(rulesPath)) { + fs.writeFileSync(rulesPath, "{}\n", "utf8"); + } + + // 3. Update openclaw.json + const cfg = readConfig(); + const plugins = cfg.plugins ??= {}; + const allow = plugins.allow ??= []; + const loadPaths = (plugins.load ??= {}).paths ??= []; + const entries = plugins.entries ??= {}; + + if (!allow.includes(PLUGIN_ID)) allow.push(PLUGIN_ID); + if (!loadPaths.includes(installDir)) loadPaths.push(installDir); + + const entry = entries[PLUGIN_ID] ??= {}; + setIfMissing(entry, "enabled", true); + const hooks = entry.hooks ??= {}; + setIfMissing(hooks, "allowPromptInjection", true); + + writeConfig(cfg); + console.log(`Installed ${PLUGIN_ID} to ${installDir}`); + console.log("Restart gateway: openclaw gateway restart"); +} + +function uninstall() { + const cfg = readConfig(); + const plugins = cfg.plugins ?? {}; + + // Remove from allow + if (Array.isArray(plugins.allow)) { + plugins.allow = plugins.allow.filter((id) => id !== PLUGIN_ID); + } + + // Remove entry + if (plugins.entries) delete plugins.entries[PLUGIN_ID]; + + // Remove load path + if (plugins.load?.paths) { + plugins.load.paths = plugins.load.paths.filter((p) => !p.includes(PLUGIN_ID)); + } + + writeConfig(cfg); + + // Remove install dir + if (fs.existsSync(installDir)) { + fs.rmSync(installDir, { recursive: true }); + console.log(`Removed ${installDir}`); + } + + console.log(`Uninstalled ${PLUGIN_ID}`); +} + +function copyDirRecursive(src, dest, excludeExts = []) { + if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules") continue; + copyDirRecursive(srcPath, destPath, excludeExts); + } else { + if (excludeExts.some((ext) => entry.name.endsWith(ext))) continue; + fs.copyFileSync(srcPath, destPath); + } + } +} + +switch (action) { + case "--install": + case "--update": + install(); + break; + case "--uninstall": + uninstall(); + break; +} diff --git a/tsconfig.plugin.json b/tsconfig.plugin.json new file mode 100644 index 0000000..0489d26 --- /dev/null +++ b/tsconfig.plugin.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "plugin", + "rootDir": "plugin", + "declaration": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "emitDeclarationOnly": false + }, + "include": ["plugin/**/*.ts"], + "exclude": ["plugin/**/*.js"] +}