Compare commits
2 Commits
c330571bcb
...
81a10f2a1f
| Author | SHA1 | Date | |
|---|---|---|---|
| 81a10f2a1f | |||
| c5429129d9 |
24
dist/fabric/index.js
vendored
24
dist/fabric/index.js
vendored
@@ -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)
|
||||
|
||||
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 { 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}`;
|
||||
|
||||
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) => ({
|
||||
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,
|
||||
|
||||
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 { 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<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', () => {
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
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 { 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;
|
||||
|
||||
Reference in New Issue
Block a user