From c5429129d9a4c8275223f804f11e7ba7edcd4154 Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 25 May 2026 11:28:36 +0100 Subject: [PATCH] feat(channel-meta): expose globalThis.__fabric.getChannelType for narrow gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbound `message.created` already carries `xType` (dm / triage / group / broadcast / etc.) — record it in a per-channel cache so other plugins can answer "is this channel a DM?" without poking the Center API. New module src/channel-meta.ts: - in-memory Map - lazily loaded from ~/.openclaw/fabric-channel-meta.json on first access (so first-ever DM after a fresh gateway start still hits cache from the previous run) - debounced 250ms flush on dirty; force-flush on gateway_stop - recordChannelType(channelId, xType): called from inbound - getChannelType(channelId): null if unknown — caller MUST treat null as "don't know", NOT as "assume DM" (would re-introduce the false- positive on group channels we're trying to eliminate) Wiring: - inbound.ts socket.on('message.created'): records xType BEFORE the self-author / dedup gates (channel type is observer-agnostic) - index.ts: installs globalThis.__fabric = { getChannelType } on registerFull(); flushes on gateway_stop Consumer: ClawPrompts' fabric-chat-injector will start gating its prompt injection on getChannelType(channelId) === 'dm' (companion PR on ClawPrompts). Removes the phase-1 "any fabric channel" false-positive. Co-Authored-By: Claude Opus 4.7 (1M context) --- dist/fabric/index.js | 24 +++++++ dist/fabric/src/channel-meta.js | 105 +++++++++++++++++++++++++++++++ dist/fabric/src/inbound.js | 8 +++ dist/fabric/src/tools.js | 10 +-- index.ts | 22 +++++++ src/channel-meta.ts | 108 ++++++++++++++++++++++++++++++++ src/inbound.ts | 8 +++ 7 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 dist/fabric/src/channel-meta.js create mode 100644 src/channel-meta.ts diff --git a/dist/fabric/index.js b/dist/fabric/index.js index a33e497..b9be039 100644 --- a/dist/fabric/index.js +++ b/dist/fabric/index.js @@ -6,6 +6,7 @@ import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core'; import { fabricChannelPlugin } from './src/channel.js'; import { flushAllFabric } from './src/coalesce.js'; +import { getChannelType, flushChannelMeta } from './src/channel-meta.js'; import { FabricInbound } from './src/inbound.js'; import { listEnabledFabricAccounts } from './src/accounts.js'; import { registerFabricTools } from './src/tools.js'; @@ -43,6 +44,29 @@ export default defineChannelPluginEntry({ const client = new FabricClient(centerApiBase); const identity = new IdentityRegistry(idFile); registerFabricTools({ registerTool: (d) => api.registerTool(d), logger: api.logger }, client, identity); + // Cross-plugin API: globalThis.__fabric + // Consumed by ClawPrompts' fabric-chat-injector to narrow its prompt + // injection to DM-typed channels only. The channel-meta cache is + // populated lazily from inbound (message.created carries xType) and + // persisted to ~/.openclaw/fabric-channel-meta.json — so even the + // very first DM after a fresh gateway start hits cache from the + // previous run rather than firing the injector on the wrong type. + // + // null return = channel never seen (cache cold). Callers MUST NOT + // fall back to "assume DM" — fail closed on unknown. + { + const _G = globalThis; + _G['__fabric'] = { getChannelType }; + // Flush channel-meta cache when the gateway shuts down so + // recently-recorded xType entries don't get lost. + api.on('gateway_stop', () => { + try { + flushChannelMeta(); + } + catch { /* ignore */ } + }); + api.logger.info('fabric: __fabric cross-plugin API installed (getChannelType)'); + } api.on('gateway_start', () => { const _G = globalThis; if (_G._fabricInboundStarted) diff --git a/dist/fabric/src/channel-meta.js b/dist/fabric/src/channel-meta.js new file mode 100644 index 0000000..953cb33 --- /dev/null +++ b/dist/fabric/src/channel-meta.js @@ -0,0 +1,105 @@ +/** + * Channel-meta cache. Records (channelId → xType) for every fabric + * channel the gateway has seen at least one inbound message in. + * + * Populated lazily from inbound (`recordChannelType` is called for + * every `message.created` event with non-empty `xType`). Persisted to + * `~/.openclaw/fabric-channel-meta.json` so the cache survives + * gateway restarts (so the very first DM after restart still gets the + * right xType without waiting for a fresh inbound). + * + * Exposed cross-plugin via `globalThis.__fabric.getChannelType`. Used + * by ClawPrompts' fabric-chat-injector to narrow its prompt injection + * to xType==='dm' only. + * + * Failure mode: lookup misses (channel never seen / inbound dropped + * xType) return null. Callers MUST treat null as "unknown" — DO NOT + * fall back to "assume DM" or you re-introduce the false-positive on + * group channels. + */ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +const CACHE_FILE = join(homedir(), '.openclaw', 'fabric-channel-meta.json'); +let memory = new Map(); +let loaded = false; +let dirty = false; +let flushTimer = null; +function load() { + if (loaded) + return; + loaded = true; + try { + if (!existsSync(CACHE_FILE)) + return; + const raw = readFileSync(CACHE_FILE, 'utf8'); + const parsed = JSON.parse(raw); + for (const [k, v] of Object.entries(parsed.channels ?? {})) { + if (typeof k === 'string' && typeof v === 'string') + memory.set(k, v); + } + } + catch { + // ignore — start with empty cache on corruption + } +} +function scheduleFlush() { + if (flushTimer) + return; + // Debounce writes — many inbound messages may arrive in a burst. + // 250ms coalesces them; on gateway_stop the channel plugin can force + // a synchronous flush via flushChannelMeta(). + flushTimer = setTimeout(() => { + flushTimer = null; + if (!dirty) + return; + dirty = false; + flushSync(); + }, 250); +} +function flushSync() { + try { + const dir = dirname(CACHE_FILE); + if (!existsSync(dir)) + mkdirSync(dir, { recursive: true }); + const out = { channels: Object.fromEntries(memory) }; + const tmp = CACHE_FILE + '.tmp'; + writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8'); + renameSync(tmp, CACHE_FILE); + } + catch { + // swallow — cache is an optimization; loss-on-write is recoverable + } +} +/** Called by inbound on every message.created. xType empty → no-op. */ +export function recordChannelType(channelId, xType) { + if (!channelId || !xType) + return; + load(); + const existing = memory.get(channelId); + if (existing === xType) + return; + memory.set(channelId, xType); + dirty = true; + scheduleFlush(); +} +/** Cross-plugin lookup. null when channel never seen / unknown. */ +export function getChannelType(channelId) { + if (!channelId) + return null; + load(); + return memory.get(channelId) ?? null; +} +/** Force-flush — called on plugin shutdown to make sure recently + * recorded entries hit disk before the gateway dies. */ +export function flushChannelMeta() { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (dirty) { + dirty = false; + flushSync(); + } +} +export const CHANNEL_META_PATH = CACHE_FILE; diff --git a/dist/fabric/src/inbound.js b/dist/fabric/src/inbound.js index f15c827..7a4033b 100644 --- a/dist/fabric/src/inbound.js +++ b/dist/fabric/src/inbound.js @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { io } from 'socket.io-client'; import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch'; import { resolveCoalesce } from './accounts.js'; +import { recordChannelType } from './channel-meta.js'; import { enqueueDelivery, flushFabricForChannel } from './coalesce.js'; export class FabricInbound { core; @@ -341,6 +342,13 @@ export class FabricInbound { const channelId = m.channelId ?? ''; if (!channelId) return; + // Record xType into the channel-meta cache before self-author + // / dedup gates — channel type doesn't depend on who sent the + // message, and recording it on observer-only triage messages + // is still useful (the next consumer asking + // __fabric.getChannelType wants the answer regardless of + // whether THIS message was delivered to an agent). + recordChannelType(channelId, m.xType); if (m.authorUserId && m.authorUserId === selfUserId) return; const key = `${agentId}:${m.messageId}`; diff --git a/dist/fabric/src/tools.js b/dist/fabric/src/tools.js index adb4f63..723813f 100644 --- a/dist/fabric/src/tools.js +++ b/dist/fabric/src/tools.js @@ -311,10 +311,12 @@ export function registerFabricTools(api, client, identity) { api.registerTool((ctx) => ({ name: 'fabric-guild-list', description: 'List guilds the calling agent is a member of. Returns ' + - '{nodeId, name, purpose, status} per row. `purpose` is a free-form ' + - "description of what each guild is for. Use this BEFORE " + - 'fabric-channel-list when a workflow asks you to pick the ' + - 'right guild by intent (no guild ids hardcoded into workflows).', + '{nodeId, name, purpose, status} per row. ' + + "`purpose` is a free-form description of what each guild is for — " + + 'pick the guild whose purpose matches your intent. Use this tool ' + + 'BEFORE fabric-channel-list when a workflow asks you to pick the ' + + 'right guild by intent (e.g. "find a guild whose purpose mentions ' + + 'debate broadcasts" → then list its announce-type channels).', parameters: { type: 'object', additionalProperties: false, diff --git a/index.ts b/index.ts index fb3247d..06bd5ce 100644 --- a/index.ts +++ b/index.ts @@ -7,6 +7,7 @@ import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core'; import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core'; import { fabricChannelPlugin } from './src/channel.js'; import { flushAllFabric } from './src/coalesce.js'; +import { getChannelType, flushChannelMeta } from './src/channel-meta.js'; import { FabricInbound } from './src/inbound.js'; import { listEnabledFabricAccounts } from './src/accounts.js'; import { registerFabricTools } from './src/tools.js'; @@ -62,6 +63,27 @@ export default defineChannelPluginEntry({ identity, ); + // Cross-plugin API: globalThis.__fabric + // Consumed by ClawPrompts' fabric-chat-injector to narrow its prompt + // injection to DM-typed channels only. The channel-meta cache is + // populated lazily from inbound (message.created carries xType) and + // persisted to ~/.openclaw/fabric-channel-meta.json — so even the + // very first DM after a fresh gateway start hits cache from the + // previous run rather than firing the injector on the wrong type. + // + // null return = channel never seen (cache cold). Callers MUST NOT + // fall back to "assume DM" — fail closed on unknown. + { + const _G = globalThis as Record; + _G['__fabric'] = { getChannelType }; + // Flush channel-meta cache when the gateway shuts down so + // recently-recorded xType entries don't get lost. + api.on('gateway_stop', () => { + try { flushChannelMeta(); } catch { /* ignore */ } + }); + api.logger.info('fabric: __fabric cross-plugin API installed (getChannelType)'); + } + api.on('gateway_start', () => { const _G = globalThis as Record; if (_G._fabricInboundStarted) return; diff --git a/src/channel-meta.ts b/src/channel-meta.ts new file mode 100644 index 0000000..a813119 --- /dev/null +++ b/src/channel-meta.ts @@ -0,0 +1,108 @@ +/** + * Channel-meta cache. Records (channelId → xType) for every fabric + * channel the gateway has seen at least one inbound message in. + * + * Populated lazily from inbound (`recordChannelType` is called for + * every `message.created` event with non-empty `xType`). Persisted to + * `~/.openclaw/fabric-channel-meta.json` so the cache survives + * gateway restarts (so the very first DM after restart still gets the + * right xType without waiting for a fresh inbound). + * + * Exposed cross-plugin via `globalThis.__fabric.getChannelType`. Used + * by ClawPrompts' fabric-chat-injector to narrow its prompt injection + * to xType==='dm' only. + * + * Failure mode: lookup misses (channel never seen / inbound dropped + * xType) return null. Callers MUST treat null as "unknown" — DO NOT + * fall back to "assume DM" or you re-introduce the false-positive on + * group channels. + */ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +const CACHE_FILE = join(homedir(), '.openclaw', 'fabric-channel-meta.json'); + +interface ChannelMetaFile { + // channelId → xType ('dm' | 'triage' | 'group' | etc.) + channels: Record; +} + +let memory = new Map(); +let loaded = false; +let dirty = false; +let flushTimer: ReturnType | null = null; + +function load(): void { + if (loaded) return; + loaded = true; + try { + if (!existsSync(CACHE_FILE)) return; + const raw = readFileSync(CACHE_FILE, 'utf8'); + const parsed = JSON.parse(raw) as ChannelMetaFile; + for (const [k, v] of Object.entries(parsed.channels ?? {})) { + if (typeof k === 'string' && typeof v === 'string') memory.set(k, v); + } + } catch { + // ignore — start with empty cache on corruption + } +} + +function scheduleFlush(): void { + if (flushTimer) return; + // Debounce writes — many inbound messages may arrive in a burst. + // 250ms coalesces them; on gateway_stop the channel plugin can force + // a synchronous flush via flushChannelMeta(). + flushTimer = setTimeout(() => { + flushTimer = null; + if (!dirty) return; + dirty = false; + flushSync(); + }, 250); +} + +function flushSync(): void { + try { + const dir = dirname(CACHE_FILE); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const out: ChannelMetaFile = { channels: Object.fromEntries(memory) }; + const tmp = CACHE_FILE + '.tmp'; + writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8'); + renameSync(tmp, CACHE_FILE); + } catch { + // swallow — cache is an optimization; loss-on-write is recoverable + } +} + +/** Called by inbound on every message.created. xType empty → no-op. */ +export function recordChannelType(channelId: string, xType: string | undefined): void { + if (!channelId || !xType) return; + load(); + const existing = memory.get(channelId); + if (existing === xType) return; + memory.set(channelId, xType); + dirty = true; + scheduleFlush(); +} + +/** Cross-plugin lookup. null when channel never seen / unknown. */ +export function getChannelType(channelId: string): string | null { + if (!channelId) return null; + load(); + return memory.get(channelId) ?? null; +} + +/** Force-flush — called on plugin shutdown to make sure recently + * recorded entries hit disk before the gateway dies. */ +export function flushChannelMeta(): void { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (dirty) { + dirty = false; + flushSync(); + } +} + +export const CHANNEL_META_PATH = CACHE_FILE; diff --git a/src/inbound.ts b/src/inbound.ts index 3cafa01..a34ee59 100644 --- a/src/inbound.ts +++ b/src/inbound.ts @@ -6,6 +6,7 @@ import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply- import type { FabricClient, FabricSession } from './fabric-client.js'; import type { IdentityRegistry } from './identity.js'; import { resolveCoalesce } from './accounts.js'; +import { recordChannelType } from './channel-meta.js'; import { enqueueDelivery, flushFabricForChannel } from './coalesce.js'; // COMPAT NOTE (openclaw v2026.5.7): the inbound path mirrors how bundled @@ -401,6 +402,13 @@ export class FabricInbound { socket.on('message.created', (m: FabricMessage) => { const channelId = m.channelId ?? ''; if (!channelId) return; + // Record xType into the channel-meta cache before self-author + // / dedup gates — channel type doesn't depend on who sent the + // message, and recording it on observer-only triage messages + // is still useful (the next consumer asking + // __fabric.getChannelType wants the answer regardless of + // whether THIS message was delivered to an agent). + recordChannelType(channelId, m.xType); if (m.authorUserId && m.authorUserId === selfUserId) return; const key = `${agentId}:${m.messageId}`; if (this.seen.has(key)) return;