From 33fcd177464e11915762dd4c451d8173832dd44a Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 25 May 2026 09:46:34 +0100 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20fabric-chat-injector=20=E2=80=94?= =?UTF-8?q?=20suggest=20chat=20workflow=20on=20channel=20turns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New before_prompt_build hook that appends a "next action: workflow_start chat" hint to the system prompt whenever the agent's turn was triggered by a message in a fabric channel. If Meridian (`globalThis.__meridian.getChatJournalForChannel`) reports an existing chat journal for this (agentId, channelId), the hint includes `from=""` so the agent resumes the conversation file instead of starting a fresh one each turn. Activation: - ctx.agentId AND ctx.channelId present - ctx.messageProvider in {fabric, '' (empty/omitted by gateway)} TODO(phase-2): once Fabric exposes per-channel type info (DM / group / triage) via a cross-plugin API, narrow this to xType === 'dm' only. Today we fire on any fabric channel — chat workflow is a no-op outside DMs, so the false positives are just prompt-text noise. Dedup via WeakSet keyed on the event object (same pattern as the existing before-prompt-build hook) so each turn injects at most once even when multiple harness call sites trigger the hook. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugin/hooks/fabric-chat-injector.ts | 85 ++++++++++++++++++++++++++++ plugin/index.ts | 2 + 2 files changed, 87 insertions(+) create mode 100644 plugin/hooks/fabric-chat-injector.ts diff --git a/plugin/hooks/fabric-chat-injector.ts b/plugin/hooks/fabric-chat-injector.ts new file mode 100644 index 0000000..e1a4e31 --- /dev/null +++ b/plugin/hooks/fabric-chat-injector.ts @@ -0,0 +1,85 @@ +/** + * 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 aa0a28d..5bcb5e8 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -2,6 +2,7 @@ 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 { registerFabricChatInjector } from "./hooks/fabric-chat-injector.js"; import { registerPromptRulesTool } from "./tools/prompt-rules.js"; interface PluginConfig { @@ -57,6 +58,7 @@ export default { // Agent session hooks: register every time (dedup inside handler) registerBeforePromptBuild(api); + registerFabricChatInjector(api); // Tools registerPromptRulesTool(api, routersDir);