zhi-2026-04-18 #1

Merged
hzhang merged 5 commits from zhi-2026-04-18 into main 2026-05-01 07:21:25 +00:00
15 changed files with 462 additions and 0 deletions
Showing only changes of commit c4a72b13c0 - Show all commits

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

20
openclaw.plugin.json Normal file
View File

@@ -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)"
}
}
}
}

47
package-lock.json generated Normal file
View File

@@ -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"
}
}
}

14
package.json Normal file
View File

@@ -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"
}
}

4
routers/agent-id.js Normal file
View File

@@ -0,0 +1,4 @@
// Zero-dependency router: returns agentId directly
export function resolve(ctx) {
return ctx.agentId || "";
}

14
routers/position.js Normal file
View File

@@ -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 "";
}
}

14
routers/role.js Normal file
View File

@@ -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 "";
}
}

1
rules.json Normal file
View File

@@ -0,0 +1 @@
{}

38
src/index.ts Normal file
View File

@@ -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");
}

44
src/prompt-injector.ts Normal file
View File

@@ -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<InjectionResult> {
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"),
};
}

53
src/router-loader.ts Normal file
View File

@@ -0,0 +1,53 @@
import { readdirSync } from "node:fs";
import path from "node:path";
import type { LoadedRouter, RouterModule } from "./types.js";
const loadedRouters: Map<string, LoadedRouter> = 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<void> {
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());
}

45
src/rule-store.ts Normal file
View File

@@ -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<string, string> {
return { ...rules };
}
export function getRulesForRouter(router: string, key: string): string | undefined {
return rules[`${router}:${key}`];
}

118
src/tools.ts Normal file
View File

@@ -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}`;
}
},
});
}

32
src/types.ts Normal file
View File

@@ -0,0 +1,32 @@
export interface RouterContext {
agentId: string;
}
export interface RouterModule {
resolve(ctx: RouterContext): string | Promise<string>;
}
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;
}

16
tsconfig.json Normal file
View File

@@ -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/**/*"]
}