From 241cced780745cce9894780a86a58e6dedfaf99f Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 25 May 2026 10:19:39 +0100 Subject: [PATCH] refactor(prism-facet): become a pure registration framework Strip all Hangman-Lab-specific content out of PrismFacet so it can be reused by any project. Content (always router, pcexec-convention prompt, fabric-chat-injector hook) moves to the new sibling plugin ClawPrompts. Mechanism additions: - `globalThis.__prismFacet` cross-plugin API installed at module-import time (so consumers loaded before PrismFacet can still register): .addRouter(name, resolveFn) .addRule(router, key, { file }) - core/rule-store: tier rules into `persistent` (rules.json, mutated by the prompt-rules admin tool) and `external` (in-memory, registered by other plugins via the API). Persistent overrides external on conflict. - core/router-loader: addExternalRouter() for programmatic registration into the same map the file-based loader uses. - index.ts: drops registerFabricChatInjector wiring, registerBeforePromptBuild remains. Removed (now shipped from ClawPrompts): - plugin/routers/always.ts - plugin/hooks/fabric-chat-injector.ts - plugin/prompts/pcexec-convention.md - plugin/rules.json: now `{}`; ClawPrompts registers its rule externally What still lives in PrismFacet: - before_prompt_build hook (the wiring between routers/rules and the agent's system prompt) - prompt-rules admin tool (lists + mutates persistent rules) - file-based routersDir / rulesFile scanning (kept for operator ad-hoc use; ClawPrompts uses the API instead) Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/core/cross-plugin-api.ts | 83 +++++++++++++++++++++++++++ plugin/core/router-loader.ts | 18 ++++++ plugin/core/rule-store.ts | 60 ++++++++++++++++---- plugin/hooks/fabric-chat-injector.ts | 85 ---------------------------- plugin/index.ts | 27 ++++++++- plugin/prompts/.gitkeep | 0 plugin/prompts/pcexec-convention.md | 31 ---------- plugin/routers/.gitkeep | 0 plugin/routers/always.ts | 11 ---- plugin/rules.json | 4 +- rules.json | 2 +- 11 files changed, 175 insertions(+), 146 deletions(-) create mode 100644 plugin/core/cross-plugin-api.ts delete mode 100644 plugin/hooks/fabric-chat-injector.ts create mode 100644 plugin/prompts/.gitkeep delete mode 100644 plugin/prompts/pcexec-convention.md create mode 100644 plugin/routers/.gitkeep delete mode 100644 plugin/routers/always.ts diff --git a/plugin/core/cross-plugin-api.ts b/plugin/core/cross-plugin-api.ts new file mode 100644 index 0000000..e6ac5cb --- /dev/null +++ b/plugin/core/cross-plugin-api.ts @@ -0,0 +1,83 @@ +/** + * Cross-plugin API: globalThis.__prismFacet + * + * PrismFacet is the registration mechanism (routers, rules, the + * before_prompt_build hook that resolves them); other plugins (notably + * ClawPrompts) supply the actual content by calling into this API at + * their own register() time. + * + * Installation idempotence: safe to call install() multiple times — the + * function attaches the same set of methods to globalThis.__prismFacet + * if not already present. Plugin reload doesn't break consumers. + * + * If a consumer plugin (ClawPrompts) loads BEFORE PrismFacet, the + * `globalThis.__prismFacet` slot will be undefined at the consumer's + * register() time. To make load-order independence work, this module + * pre-creates the slot at module-import time too — consumers can call + * .addRouter / .addRule even if PrismFacet's register() hasn't run yet + * (the registration still lands in the shared maps). + */ +import { addExternalRouter, type RouterContext } from "./router-loader.js"; +import { addExternalRule } from "./rule-store.js"; + +const _G = globalThis as Record; + +export interface PrismFacetCrossPluginApi { + /** + * Register a router programmatically. + * @param name router name (matches the "router:" prefix of rule keys) + * @param resolve function returning a key string for the given context + * (or empty/null to skip this router for this context) + */ + addRouter( + name: string, + resolve: (ctx: RouterContext) => string | Promise, + ): void; + + /** + * Register a rule programmatically (in-memory; persistent rules.json + * entries set by the prompt-rules tool take precedence on conflict). + * @param routerName router this rule binds to + * @param key value the router must resolve to for this rule to fire + * @param prompt either { file: "/abs/path.md" } or { text: "..." } + */ + addRule( + routerName: string, + key: string, + prompt: { file?: string; text?: string }, + ): void; +} + +/** Idempotent install. Called from PrismFacet's register(). Also runs + * at module-import time below so that consumer plugins loading before + * PrismFacet still find a usable API. */ +export function installCrossPluginApi(): PrismFacetCrossPluginApi { + const existing = _G["__prismFacet"] as PrismFacetCrossPluginApi | undefined; + if (existing && typeof existing.addRouter === "function") return existing; + + const api: PrismFacetCrossPluginApi = { + addRouter(name, resolve) { + addExternalRouter(name, resolve); + }, + addRule(routerName, key, prompt) { + // Phase 1: only file-based prompts are wired through the existing + // injector. `text` inline prompts will need a tmpfile-or-store + // backend; surfaced as a TODO so an early caller failing loudly + // beats silently dropping the prompt. + if (!prompt.file) { + throw new Error( + "__prismFacet.addRule: only { file: '/abs/path.md' } is supported today; " + + "inline { text } pending injector update", + ); + } + addExternalRule(routerName, key, prompt.file); + }, + }; + _G["__prismFacet"] = api; + return api; +} + +// Eager install on module load so a consumer plugin importing into the +// same gateway process can register even if it runs before PrismFacet's +// register() does. +installCrossPluginApi(); diff --git a/plugin/core/router-loader.ts b/plugin/core/router-loader.ts index a7bf157..4dd4279 100644 --- a/plugin/core/router-loader.ts +++ b/plugin/core/router-loader.ts @@ -75,3 +75,21 @@ export function getRouters(): LoadedRouter[] { export function getRouterNames(): string[] { return Array.from(getRouterMap().keys()); } + +/** + * Cross-plugin API: register a router programmatically. Other plugins + * (e.g. ClawPrompts) call this via globalThis.__prismFacet.addRouter + * to publish a router without dropping a .ts file in PrismFacet's + * routersDir. Replaces any existing router of the same name. + */ +export function addExternalRouter( + name: string, + resolveFn: (ctx: RouterContext) => string | Promise, +): void { + const map = getRouterMap(); + map.set(name, { + name, + filePath: ``, + module: { resolve: resolveFn }, + }); +} diff --git a/plugin/core/rule-store.ts b/plugin/core/rule-store.ts index 1e1bb9b..cb543a3 100644 --- a/plugin/core/rule-store.ts +++ b/plugin/core/rule-store.ts @@ -1,16 +1,34 @@ +/** + * Two-tier rule store: persistent (operator/admin rules loaded from + * rules.json, mutated by the prompt-rules tool) and external (in-memory + * rules registered by other plugins via the __prismFacet cross-plugin + * API). External rules are NOT saved to disk — they belong to the + * registering plugin and are re-registered on every gateway start. + * + * Lookup precedence: persistent > external (operator wins over plugin + * defaults so an explicit rules.json override is honored). + */ 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 PERSISTENT_KEY = "_prismFacetRules"; +const EXTERNAL_KEY = "_prismFacetExternalRules"; const RULES_PATH_KEY = "_prismFacetRulesPath"; -function getRules(): RuleStore { - if (!_G[RULES_KEY] || typeof _G[RULES_KEY] !== "object") { - _G[RULES_KEY] = {}; +function getPersistent(): RuleStore { + if (!_G[PERSISTENT_KEY] || typeof _G[PERSISTENT_KEY] !== "object") { + _G[PERSISTENT_KEY] = {}; } - return _G[RULES_KEY] as RuleStore; + return _G[PERSISTENT_KEY] as RuleStore; +} + +function getExternal(): RuleStore { + if (!_G[EXTERNAL_KEY] || typeof _G[EXTERNAL_KEY] !== "object") { + _G[EXTERNAL_KEY] = {}; + } + return _G[EXTERNAL_KEY] as RuleStore; } function getRulesPath(): string { @@ -20,26 +38,32 @@ function getRulesPath(): string { function save(): void { const p = getRulesPath(); if (!p) return; - writeFileSync(p, JSON.stringify(getRules(), null, 2) + "\n", "utf8"); + writeFileSync(p, JSON.stringify(getPersistent(), 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; + _G[PERSISTENT_KEY] = JSON.parse(raw) as RuleStore; } catch { - _G[RULES_KEY] = {}; + _G[PERSISTENT_KEY] = {}; } } +/** Operator-facing addRule (writes through to rules.json on disk). */ export function addRule(router: string, key: string, promptFile: string): void { - getRules()[`${router}:${key}`] = promptFile; + getPersistent()[`${router}:${key}`] = promptFile; save(); } +/** Cross-plugin API: register a rule that lives in memory only. */ +export function addExternalRule(router: string, key: string, promptFile: string): void { + getExternal()[`${router}:${key}`] = promptFile; +} + export function removeRule(router: string, key: string): boolean { - const rules = getRules(); + const rules = getPersistent(); const ruleKey = `${router}:${key}`; if (!(ruleKey in rules)) return false; delete rules[ruleKey]; @@ -47,10 +71,22 @@ export function removeRule(router: string, key: string): boolean { return true; } +/** Lookup. Persistent (rules.json) overrides external (plugin defaults). */ export function getRule(router: string, key: string): string | undefined { - return getRules()[`${router}:${key}`]; + const persistentValue = getPersistent()[`${router}:${key}`]; + if (persistentValue) return persistentValue; + return getExternal()[`${router}:${key}`]; } +/** Merged view for the admin tool. Persistent overrides external. */ export function listRules(): Record { - return { ...getRules() }; + return { ...getExternal(), ...getPersistent() }; +} + +/** Admin tool needs to know which tier a rule came from. */ +export function listRulesTiered(): { + persistent: RuleStore; + external: RuleStore; +} { + return { persistent: { ...getPersistent() }, external: { ...getExternal() } }; } diff --git a/plugin/hooks/fabric-chat-injector.ts b/plugin/hooks/fabric-chat-injector.ts deleted file mode 100644 index e1a4e31..0000000 --- a/plugin/hooks/fabric-chat-injector.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Inject a "start the chat workflow" hint into the agent's system prompt - * when this turn was triggered by a message in a fabric channel. - * - * Two cases: - * - no prior chat journal for this (agent, channel) → suggest - * `workflow_start chat` (fresh). - * - mapping exists → suggest `workflow_start chat from ` - * so the conversation continues in the same linear journal. - * - * The (channel → journal) mapping is owned by Meridian and exposed via - * globalThis.__meridian.getChatJournalForChannel. Meridian writes the - * entry the first time the agent starts a chat workflow on a channel - * with no `from` argument. - * - * TODO(phase-2): once Fabric.OpenclawPlugin exposes per-channel type - * info (DM / group / triage) via a cross-plugin API, narrow this hook - * to xType === 'dm' only. Today we inject for any fabric channel — chat - * workflow itself is a no-op outside DMs, but the suggestion is noise. - */ -const _G = globalThis as Record; -const DEDUP_KEY = '_prismFacetFabricChatDedup'; - -interface MeridianBridge { - getChatJournalForChannel?: (agentId: string, channelId: string) => Promise; -} - -interface PromptCtx { - agentId?: string; - channelId?: string; - messageProvider?: string; -} - -export function registerFabricChatInjector(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: PromptCtx) => { - if (dedup.has(event as object)) return; - dedup.add(event as object); - - const agentId = ctx.agentId || ''; - const channelId = (ctx.channelId || '').trim(); - const provider = (ctx.messageProvider || '').toLowerCase(); - if (!agentId || !channelId) return; - if (provider && provider !== 'fabric') return; - // Empty provider also accepted — gateway sometimes omits the field - // even when the trigger was a fabric channel; channelId presence is - // the load-bearing signal. - - let journalId: string | null = null; - const meridian = _G['__meridian'] as MeridianBridge | undefined; - if (typeof meridian?.getChatJournalForChannel === 'function') { - try { - journalId = await meridian.getChatJournalForChannel(agentId, channelId); - } catch (err) { - api.logger.warn( - `[prism-facet] fabric-chat-injector: meridian lookup failed for ` + - `agent=${agentId} channel=${channelId}: ${String(err)}`, - ); - } - } - - const cmd = journalId - ? `\`workflow_start\` with \`workflow="chat"\` and \`from="${journalId}"\`` - : `\`workflow_start\` with \`workflow="chat"\``; - - const continuationLine = journalId - ? `This channel already has an open chat journal (\`${journalId}\`). Resume it with the \`from\` argument so the conversation history stays in one file.` - : `No prior chat journal exists for this channel yet — Meridian will create a fresh one and remember the channel→journal mapping for future turns.`; - - const segment = - `# Chat channel context\n` + - `\n` + - `This turn was triggered by a message in a fabric channel (\`${channelId}\`).\n` + - `${continuationLine}\n` + - `\n` + - `**Next action:** call ${cmd} to enter the chat workflow before doing anything else.\n`; - - return { appendSystemContext: segment }; - }); -} diff --git a/plugin/index.ts b/plugin/index.ts index 5bcb5e8..aefd1e1 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,8 +1,30 @@ +/** + * PrismFacet — prompt-rule registration framework. + * + * Provides: + * - router loader (file-based: routersDir + programmatic: __prismFacet.addRouter) + * - rule store (persistent rules.json + in-memory external rules) + * - before_prompt_build hook that resolves all loaded routers/rules + * and appends the matching prompt fragments to the agent's system + * prompt + * - prompt-rules admin tool to inspect / mutate persistent rules + * - cross-plugin API installed at module load time (globalThis.__prismFacet) + * + * PrismFacet ships NO content of its own — the routers/, prompts/, and + * rules.json directories are empty placeholders. Hangman-Lab-specific + * routers and rules live in the ClawPrompts plugin, which registers + * them at startup via the cross-plugin API. + */ import path from "node:path"; +// Importing core/cross-plugin-api.ts has a side-effect: it installs +// globalThis.__prismFacet at module-import time. Doing this side-effect +// import early — before any other plugin's register() — means a +// consumer plugin (ClawPrompts) loaded before us still finds a working +// __prismFacet to call into. +import "./core/cross-plugin-api.js"; import { loadRouters } from "./core/router-loader.js"; import { initRuleStore } from "./core/rule-store.js"; import { registerBeforePromptBuild } from "./hooks/before-prompt-build.js"; -import { registerFabricChatInjector } from "./hooks/fabric-chat-injector.js"; import { registerPromptRulesTool } from "./tools/prompt-rules.js"; interface PluginConfig { @@ -58,11 +80,10 @@ export default { // Agent session hooks: register every time (dedup inside handler) registerBeforePromptBuild(api); - registerFabricChatInjector(api); // Tools registerPromptRulesTool(api, routersDir); - api.logger.info("[prism-facet] plugin registered"); + api.logger.info("[prism-facet] plugin registered (framework only — content via ClawPrompts)"); }, }; diff --git a/plugin/prompts/.gitkeep b/plugin/prompts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/prompts/pcexec-convention.md b/plugin/prompts/pcexec-convention.md deleted file mode 100644 index 1f66186..0000000 --- a/plugin/prompts/pcexec-convention.md +++ /dev/null @@ -1,31 +0,0 @@ -# Hangman-Lab Site Convention — Shell Execution - -This claw (sim or prod) keeps Hangman-Lab site binaries at `~/.openclaw/bin/` -and **does not** symlink them into `/usr/local/bin`. Your shell tool's PATH -does not include them by default, so calling them with the codex built-in -shell yields `command not found`. - -**Rule:** any command that invokes one of these binaries MUST be run through -the `pcexec` tool, not the codex built-in shell: - -- `hf` (HarborForge CLI) -- `secret-mgr` (per-agent secret store) -- `ego-mgr` (per-agent identity store; reads `role`, `position`, `default-username`, etc.) -- `fabric-register` (Fabric account provisioning) -- `pcguard` (PaddedCell guard) -- `lock-mgr` -- `tea` - -`pcexec` injects `~/.openclaw/bin` into PATH and also wires the -`AGENT_ID`, `AGENT_WORKSPACE`, and `AGENT_VERIFY` env vars that -`secret-mgr` / `ego-mgr` need to authenticate as the calling agent. - -## Examples - -- ✅ Call the `pcexec` tool with `command: "hf calendar show --json"` -- ✅ Call the `pcexec` tool with `command: "HFT=$(secret-mgr get-secret --key hf-token); hf task list --token \"$HFT\" --json"` (the whole pipeline goes in one `pcexec` call) -- ❌ Sending `hf calendar show` to the codex built-in shell → `command not found` - -If a workflow's `Procedure` shows a raw shell snippet involving these CLIs, -pass the **whole snippet** as a single `command:` argument to `pcexec` — -don't split into multiple non-pcexec calls. diff --git a/plugin/routers/.gitkeep b/plugin/routers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/routers/always.ts b/plugin/routers/always.ts deleted file mode 100644 index 78d7e44..0000000 --- a/plugin/routers/always.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * `always` router — resolves to the constant key `"always"` for every - * agent. Pair with a rule like `always:always → ` to - * inject a prompt fragment into every agent's system prompt - * unconditionally (no ego / role / position lookup needed). - */ -import type { RouterContext } from "../core/router-loader.js"; - -export function resolve(_ctx: RouterContext): string { - return "always"; -} diff --git a/plugin/rules.json b/plugin/rules.json index 46bfccb..0967ef4 100644 --- a/plugin/rules.json +++ b/plugin/rules.json @@ -1,3 +1 @@ -{ - "always:always": "/root/.openclaw/plugins/prism-facet/prompts/pcexec-convention.md" -} +{} diff --git a/rules.json b/rules.json index 9e26dfe..0967ef4 100644 --- a/rules.json +++ b/rules.json @@ -1 +1 @@ -{} \ No newline at end of file +{}