feat(channel-meta): expose globalThis.__fabric.getChannelType #5
24
dist/fabric/index.js
vendored
24
dist/fabric/index.js
vendored
@@ -6,6 +6,7 @@
|
|||||||
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
|
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
|
||||||
import { fabricChannelPlugin } from './src/channel.js';
|
import { fabricChannelPlugin } from './src/channel.js';
|
||||||
import { flushAllFabric } from './src/coalesce.js';
|
import { flushAllFabric } from './src/coalesce.js';
|
||||||
|
import { getChannelType, flushChannelMeta } from './src/channel-meta.js';
|
||||||
import { FabricInbound } from './src/inbound.js';
|
import { FabricInbound } from './src/inbound.js';
|
||||||
import { listEnabledFabricAccounts } from './src/accounts.js';
|
import { listEnabledFabricAccounts } from './src/accounts.js';
|
||||||
import { registerFabricTools } from './src/tools.js';
|
import { registerFabricTools } from './src/tools.js';
|
||||||
@@ -43,6 +44,29 @@ export default defineChannelPluginEntry({
|
|||||||
const client = new FabricClient(centerApiBase);
|
const client = new FabricClient(centerApiBase);
|
||||||
const identity = new IdentityRegistry(idFile);
|
const identity = new IdentityRegistry(idFile);
|
||||||
registerFabricTools({ registerTool: (d) => api.registerTool(d), logger: api.logger }, client, identity);
|
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', () => {
|
api.on('gateway_start', () => {
|
||||||
const _G = globalThis;
|
const _G = globalThis;
|
||||||
if (_G._fabricInboundStarted)
|
if (_G._fabricInboundStarted)
|
||||||
|
|||||||
105
dist/fabric/src/channel-meta.js
vendored
Normal file
105
dist/fabric/src/channel-meta.js
vendored
Normal file
@@ -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;
|
||||||
8
dist/fabric/src/inbound.js
vendored
8
dist/fabric/src/inbound.js
vendored
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
||||||
import { resolveCoalesce } from './accounts.js';
|
import { resolveCoalesce } from './accounts.js';
|
||||||
|
import { recordChannelType } from './channel-meta.js';
|
||||||
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
|
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
|
||||||
export class FabricInbound {
|
export class FabricInbound {
|
||||||
core;
|
core;
|
||||||
@@ -341,6 +342,13 @@ export class FabricInbound {
|
|||||||
const channelId = m.channelId ?? '';
|
const channelId = m.channelId ?? '';
|
||||||
if (!channelId)
|
if (!channelId)
|
||||||
return;
|
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)
|
if (m.authorUserId && m.authorUserId === selfUserId)
|
||||||
return;
|
return;
|
||||||
const key = `${agentId}:${m.messageId}`;
|
const key = `${agentId}:${m.messageId}`;
|
||||||
|
|||||||
10
dist/fabric/src/tools.js
vendored
10
dist/fabric/src/tools.js
vendored
@@ -311,10 +311,12 @@ export function registerFabricTools(api, client, identity) {
|
|||||||
api.registerTool((ctx) => ({
|
api.registerTool((ctx) => ({
|
||||||
name: 'fabric-guild-list',
|
name: 'fabric-guild-list',
|
||||||
description: 'List guilds the calling agent is a member of. Returns ' +
|
description: 'List guilds the calling agent is a member of. Returns ' +
|
||||||
'{nodeId, name, purpose, status} per row. `purpose` is a free-form ' +
|
'{nodeId, name, purpose, status} per row. ' +
|
||||||
"description of what each guild is for. Use this BEFORE " +
|
"`purpose` is a free-form description of what each guild is for — " +
|
||||||
'fabric-channel-list when a workflow asks you to pick the ' +
|
'pick the guild whose purpose matches your intent. Use this tool ' +
|
||||||
'right guild by intent (no guild ids hardcoded into workflows).',
|
'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: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|||||||
22
index.ts
22
index.ts
@@ -7,6 +7,7 @@ import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core';
|
|||||||
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core';
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core';
|
||||||
import { fabricChannelPlugin } from './src/channel.js';
|
import { fabricChannelPlugin } from './src/channel.js';
|
||||||
import { flushAllFabric } from './src/coalesce.js';
|
import { flushAllFabric } from './src/coalesce.js';
|
||||||
|
import { getChannelType, flushChannelMeta } from './src/channel-meta.js';
|
||||||
import { FabricInbound } from './src/inbound.js';
|
import { FabricInbound } from './src/inbound.js';
|
||||||
import { listEnabledFabricAccounts } from './src/accounts.js';
|
import { listEnabledFabricAccounts } from './src/accounts.js';
|
||||||
import { registerFabricTools } from './src/tools.js';
|
import { registerFabricTools } from './src/tools.js';
|
||||||
@@ -62,6 +63,27 @@ export default defineChannelPluginEntry({
|
|||||||
identity,
|
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<string, unknown>;
|
||||||
|
_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', () => {
|
api.on('gateway_start', () => {
|
||||||
const _G = globalThis as Record<string, unknown>;
|
const _G = globalThis as Record<string, unknown>;
|
||||||
if (_G._fabricInboundStarted) return;
|
if (_G._fabricInboundStarted) return;
|
||||||
|
|||||||
108
src/channel-meta.ts
Normal file
108
src/channel-meta.ts
Normal file
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let memory = new Map<string, string>();
|
||||||
|
let loaded = false;
|
||||||
|
let dirty = false;
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | 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;
|
||||||
@@ -6,6 +6,7 @@ import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-
|
|||||||
import type { FabricClient, FabricSession } from './fabric-client.js';
|
import type { FabricClient, FabricSession } from './fabric-client.js';
|
||||||
import type { IdentityRegistry } from './identity.js';
|
import type { IdentityRegistry } from './identity.js';
|
||||||
import { resolveCoalesce } from './accounts.js';
|
import { resolveCoalesce } from './accounts.js';
|
||||||
|
import { recordChannelType } from './channel-meta.js';
|
||||||
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
|
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
|
||||||
|
|
||||||
// COMPAT NOTE (openclaw v2026.5.7): the inbound path mirrors how bundled
|
// 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) => {
|
socket.on('message.created', (m: FabricMessage) => {
|
||||||
const channelId = m.channelId ?? '';
|
const channelId = m.channelId ?? '';
|
||||||
if (!channelId) return;
|
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;
|
if (m.authorUserId && m.authorUserId === selfUserId) return;
|
||||||
const key = `${agentId}:${m.messageId}`;
|
const key = `${agentId}:${m.messageId}`;
|
||||||
if (this.seen.has(key)) return;
|
if (this.seen.has(key)) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user