From c4a72b13c0a4d68375aaae5259c4579914d0375d Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 18 Apr 2026 17:12:10 +0000 Subject: [PATCH 1/5] feat: PrismFacet core implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Plugin scaffold: manifest, tsconfig, package.json - Router loader with dynamic import + cache busting for hot reload - Rule store: CRUD on rules.json - Prompt injector: resolve routers → match rules → read files → inject - MCP tool: prompt_rules (add/remove/list/test/reload-routers/list-routers) - Built-in routers: agent-id (zero-dep), role, position (ego.json) - before_prompt_build hook for system prompt injection Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + openclaw.plugin.json | 20 +++++++ package-lock.json | 47 ++++++++++++++++ package.json | 14 +++++ routers/agent-id.js | 4 ++ routers/position.js | 14 +++++ routers/role.js | 14 +++++ rules.json | 1 + src/index.ts | 38 +++++++++++++ src/prompt-injector.ts | 44 +++++++++++++++ src/router-loader.ts | 53 ++++++++++++++++++ src/rule-store.ts | 45 ++++++++++++++++ src/tools.ts | 118 +++++++++++++++++++++++++++++++++++++++++ src/types.ts | 32 +++++++++++ tsconfig.json | 16 ++++++ 15 files changed, 462 insertions(+) create mode 100644 .gitignore create mode 100644 openclaw.plugin.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 routers/agent-id.js create mode 100644 routers/position.js create mode 100644 routers/role.js create mode 100644 rules.json create mode 100644 src/index.ts create mode 100644 src/prompt-injector.ts create mode 100644 src/router-loader.ts create mode 100644 src/rule-store.ts create mode 100644 src/tools.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json 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/**/*"] +} From d5c057a3f9a91bf77dd4c60a7de306bd51908c4a Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 18 Apr 2026 17:22:17 +0000 Subject: [PATCH 2/5] refactor: restructure PrismFacet per OpenClaw plugin spec - plugin/ directory structure: hooks/, tools/, core/ - export default { id, name, register } entry format - globalThis state management with lifecycle protection - WeakSet dedup on before_prompt_build hook - Tool uses inputSchema + execute (not parameters + handler) - additionalProperties: false in config schema - Core logic in plugin/core/ (no plugin-sdk dependency) - Install/uninstall script (scripts/install.mjs) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 + package.json | 6 +- {src => plugin/core}/prompt-injector.ts | 19 +-- plugin/core/router-loader.ts | 77 ++++++++++ plugin/core/rule-store.ts | 56 +++++++ plugin/hooks/before-prompt-build.ts | 25 +++ plugin/index.ts | 66 ++++++++ .../openclaw.plugin.json | 3 +- plugin/package.json | 6 + plugin/tools/prompt-rules.ts | 106 +++++++++++++ scripts/install.mjs | 143 ++++++++++++++++++ src/index.ts | 38 ----- src/router-loader.ts | 53 ------- src/rule-store.ts | 45 ------ src/tools.ts | 118 --------------- src/types.ts | 32 ---- tsconfig.json => tsconfig.plugin.json | 12 +- 17 files changed, 502 insertions(+), 306 deletions(-) rename {src => plugin/core}/prompt-injector.ts (57%) create mode 100644 plugin/core/router-loader.ts create mode 100644 plugin/core/rule-store.ts create mode 100644 plugin/hooks/before-prompt-build.ts create mode 100644 plugin/index.ts rename openclaw.plugin.json => plugin/openclaw.plugin.json (89%) create mode 100644 plugin/package.json create mode 100644 plugin/tools/prompt-rules.ts create mode 100644 scripts/install.mjs delete mode 100644 src/index.ts delete mode 100644 src/router-loader.ts delete mode 100644 src/rule-store.ts delete mode 100644 src/tools.ts delete mode 100644 src/types.ts rename tsconfig.json => tsconfig.plugin.json (54%) diff --git a/.gitignore b/.gitignore index b947077..5d2e072 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules/ dist/ +plugin/**/*.js +plugin/**/*.js.map +!routers/*.js diff --git a/package.json b/package.json index b7e9960..d17b3bc 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,10 @@ "name": "prism-facet", "version": "0.1.0", "type": "module", - "main": "dist/index.js", "scripts": { - "build": "tsc", - "watch": "tsc --watch" + "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", diff --git a/src/prompt-injector.ts b/plugin/core/prompt-injector.ts similarity index 57% rename from src/prompt-injector.ts rename to plugin/core/prompt-injector.ts index f92ab68..d235c2b 100644 --- a/src/prompt-injector.ts +++ b/plugin/core/prompt-injector.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { getRouters } from "./router-loader.js"; -import { getRulesForRouter } from "./rule-store.js"; -import type { RouterContext } from "./types.js"; +import { getRule } from "./rule-store.js"; +import type { RouterContext } from "./router-loader.js"; export interface InjectionResult { appendSystemContext?: string; @@ -9,7 +9,7 @@ export interface InjectionResult { export async function resolveInjection( ctx: RouterContext, - logger: { info: (msg: string) => void; warn: (msg: string) => void } + log: { info(msg: string): void; warn(msg: string): void } ): Promise { const routers = getRouters(); const segments: string[] = []; @@ -19,26 +19,23 @@ export async function resolveInjection( const key = await router.module.resolve(ctx); if (!key) continue; - const promptFile = getRulesForRouter(router.name, key); + const promptFile = getRule(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}`); + log.info(`[prism-facet] injecting ${router.name}:${key} → ${promptFile}`); } } catch (err) { - logger.warn(`failed to read prompt file ${promptFile}: ${String(err)}`); + log.warn(`[prism-facet] cannot read ${promptFile}: ${String(err)}`); } } catch (err) { - logger.warn(`router ${router.name} failed: ${String(err)}`); + log.warn(`[prism-facet] router ${router.name} failed: ${String(err)}`); } } if (segments.length === 0) return {}; - - return { - appendSystemContext: segments.join("\n\n---\n\n"), - }; + 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..b0fb59e --- /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/openclaw.plugin.json b/plugin/openclaw.plugin.json similarity index 89% rename from openclaw.plugin.json rename to plugin/openclaw.plugin.json index 0fd8a2b..7e7ec2e 100644 --- a/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -3,9 +3,10 @@ "name": "PrismFacet", "version": "0.1.0", "description": "Dynamic system prompt injection via routers and rules", - "main": "dist/index.js", + "main": "index.js", "configSchema": { "type": "object", + "additionalProperties": false, "properties": { "routersDir": { "type": "string", 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..829ddb5 --- /dev/null +++ b/plugin/tools/prompt-rules.ts @@ -0,0 +1,106 @@ +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).", + inputSchema: { + 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 { result: "Error: add requires router, key, and file" }; + } + addRule(router, key, file); + return { result: `Rule added: ${router}:${key} → ${file}` }; + } + case "remove": { + if (!router || !key) { + return { result: "Error: remove requires router and key" }; + } + const removed = removeRule(router, key); + return { + result: removed + ? `Rule removed: ${router}:${key}` + : `Rule not found: ${router}:${key}`, + }; + } + case "list": { + const rules = listRules(); + const entries = Object.entries(rules); + if (entries.length === 0) return { result: "No rules registered." }; + return { result: entries.map(([k, v]) => `${k} → ${v}`).join("\n") }; + } + case "test": { + if (!agent) return { result: "Error: test requires agent" }; + const result = await resolveInjection({ agentId: agent }, api.logger); + if (!result.appendSystemContext) { + return { result: `No prompts matched for agent: ${agent}` }; + } + return { result: `Matched prompts for ${agent}:\n\n${result.appendSystemContext}` }; + } + case "reload-routers": { + await loadRouters(routersDir, api.logger); + const names = getRouterNames(); + return { result: `Routers reloaded: ${names.join(", ") || "(none)"}` }; + } + case "list-routers": { + const names = getRouterNames(); + return { + result: names.length > 0 + ? `Loaded routers: ${names.join(", ")}` + : "No routers loaded.", + }; + } + default: + return { result: `Unknown action: ${action}` }; + } + }, + }); +} 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/src/index.ts b/src/index.ts deleted file mode 100644 index 57b5a57..0000000 --- a/src/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/router-loader.ts b/src/router-loader.ts deleted file mode 100644 index 2d3dc73..0000000 --- a/src/router-loader.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 3c277fd..0000000 --- a/src/rule-store.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 22274b8..0000000 --- a/src/tools.ts +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index e92398a..0000000 --- a/src/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -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.plugin.json similarity index 54% rename from tsconfig.json rename to tsconfig.plugin.json index 16ca579..0489d26 100644 --- a/tsconfig.json +++ b/tsconfig.plugin.json @@ -3,14 +3,16 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "outDir": "dist", - "rootDir": "src", - "declaration": true, + "outDir": "plugin", + "rootDir": "plugin", + "declaration": false, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true + "allowJs": true, + "emitDeclarationOnly": false }, - "include": ["src/**/*"] + "include": ["plugin/**/*.ts"], + "exclude": ["plugin/**/*.js"] } From bee3dcb84c2b994043728acf02c9262554ee0ab6 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 18 Apr 2026 19:54:20 +0000 Subject: [PATCH 3/5] fix: correct routers/rules path resolution pluginDir already points to the plugin install root, no need for .. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugin/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index b0fb59e..aa0a28d 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -38,8 +38,8 @@ export default { 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"); + 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]) { From 76ac2e959a70a8ee923b5fa34ac1e800b2f9add4 Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 21 Apr 2026 10:34:57 +0000 Subject: [PATCH 4/5] refactor: remove built-in routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrismFacet is a generic framework — routers are registered by consumers (e.g., ClawRoles), not bundled with the plugin. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 - routers/agent-id.js | 4 ---- routers/position.js | 14 -------------- routers/role.js | 14 -------------- 4 files changed, 33 deletions(-) delete mode 100644 routers/agent-id.js delete mode 100644 routers/position.js delete mode 100644 routers/role.js diff --git a/.gitignore b/.gitignore index 5d2e072..e023caa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ node_modules/ dist/ plugin/**/*.js plugin/**/*.js.map -!routers/*.js diff --git a/routers/agent-id.js b/routers/agent-id.js deleted file mode 100644 index 5576d47..0000000 --- a/routers/agent-id.js +++ /dev/null @@ -1,4 +0,0 @@ -// Zero-dependency router: returns agentId directly -export function resolve(ctx) { - return ctx.agentId || ""; -} diff --git a/routers/position.js b/routers/position.js deleted file mode 100644 index 732671c..0000000 --- a/routers/position.js +++ /dev/null @@ -1,14 +0,0 @@ -// 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 deleted file mode 100644 index e6f79d1..0000000 --- a/routers/role.js +++ /dev/null @@ -1,14 +0,0 @@ -// 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 ""; - } -} From 6a70a68c7eec90f208b1a81aa0e3c9b7c1a40377 Mon Sep 17 00:00:00 2001 From: zhi Date: Wed, 22 Apr 2026 12:04:41 +0000 Subject: [PATCH 5/5] fix: use parameters + content[] format per OpenClaw plugin spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inputSchema → parameters - { result: text } → { content: [{ type: "text", text }] } Co-Authored-By: Claude Opus 4.6 (1M context) --- plugin/tools/prompt-rules.ts | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/plugin/tools/prompt-rules.ts b/plugin/tools/prompt-rules.ts index 829ddb5..5897a6e 100644 --- a/plugin/tools/prompt-rules.ts +++ b/plugin/tools/prompt-rules.ts @@ -18,7 +18,7 @@ export function registerPromptRulesTool( "test (preview which prompts would be injected for an agent), " + "reload-routers (hot-reload all router functions), " + "list-routers (show loaded routers).", - inputSchema: { + parameters: { type: "object", properties: { action: { @@ -55,51 +55,45 @@ export function registerPromptRulesTool( switch (action) { case "add": { if (!router || !key || !file) { - return { result: "Error: add requires router, key, and file" }; + return { content: [{ type: "text", text: "Error: add requires router, key, and file" }] }; } addRule(router, key, file); - return { result: `Rule added: ${router}:${key} → ${file}` }; + return { content: [{ type: "text", text: `Rule added: ${router}:${key} → ${file}` }] }; } case "remove": { if (!router || !key) { - return { result: "Error: remove requires router and key" }; + return { content: [{ type: "text", text: "Error: remove requires router and key" }] }; } const removed = removeRule(router, key); - return { - result: removed - ? `Rule removed: ${router}:${key}` - : `Rule not found: ${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 { result: "No rules registered." }; - return { result: entries.map(([k, v]) => `${k} → ${v}`).join("\n") }; + 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 { result: "Error: test requires agent" }; + if (!agent) return { content: [{ type: "text", text: "Error: test requires agent" }] }; const result = await resolveInjection({ agentId: agent }, api.logger); if (!result.appendSystemContext) { - return { result: `No prompts matched for agent: ${agent}` }; + return { content: [{ type: "text", text: `No prompts matched for agent: ${agent}` }] }; } - return { result: `Matched prompts for ${agent}:\n\n${result.appendSystemContext}` }; + 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 { result: `Routers reloaded: ${names.join(", ") || "(none)"}` }; + return { content: [{ type: "text", text: `Routers reloaded: ${names.join(", ") || "(none)"}` }] }; } case "list-routers": { const names = getRouterNames(); - return { - result: names.length > 0 - ? `Loaded routers: ${names.join(", ")}` - : "No routers loaded.", - }; + const msg = names.length > 0 ? `Loaded routers: ${names.join(", ")}` : "No routers loaded."; + return { content: [{ type: "text", text: msg }] }; } default: - return { result: `Unknown action: ${action}` }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }] }; } }, });