diff --git a/dist/fabric/index.js b/dist/fabric/index.js index 247dbed..a33e497 100644 --- a/dist/fabric/index.js +++ b/dist/fabric/index.js @@ -12,10 +12,16 @@ import { registerFabricTools } from './src/tools.js'; import { FabricClient } from './src/fabric-client.js'; import { IdentityRegistry } from './src/identity.js'; import { syncFabricCommands } from './src/command-sync.js'; +import { PresenceSync } from './src/presence-sync.js'; import path from 'node:path'; import os from 'node:os'; let runtimeRef = null; let inbound = null; +let presence = null; +// Periodic re-harvest of presence accounts so newly-connected agents +// (registered through tool-based identity flow AFTER initial start) +// get picked up. Cleared on gateway_stop. +let presenceRefreshTimer = null; export { fabricChannelPlugin } from './src/channel.js'; export default defineChannelPluginEntry({ id: 'fabric', @@ -57,7 +63,25 @@ export default defineChannelPluginEntry({ return; } inbound = new FabricInbound(runtimeRef, api.config, client, identity, api.logger, accounts); - void inbound.start(); + // start() resolves once all accounts have attempted login; per- + // agent failures are logged but don't reject. Once it resolves we + // can harvest the presence accounts (those that DID log in have + // their fabricUserId + first guild endpoint populated). + void inbound.start().then(() => { + if (!inbound) + return; + presence = new PresenceSync(api.logger); + presence.setAccounts(inbound.getPresenceAccounts()); + presence.start(); + api.logger.info(`fabric: presence-sync started for ${inbound.getPresenceAccounts().length} account(s)`); + // Re-harvest every 5 min: catches agents added via tool-based + // identity provisioning after gateway_start (recruitment flow). + // setAccounts is idempotent — duplicates collapse on agentId. + presenceRefreshTimer = setInterval(() => { + if (inbound && presence) + presence.setAccounts(inbound.getPresenceAccounts()); + }, 5 * 60_000); + }); api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`); void syncFabricCommands(client, cfg, accounts, api.logger); }); @@ -67,6 +91,12 @@ export default defineChannelPluginEntry({ // BEFORE deliver()). gateway_stop only flushes any leftover buffer. api.on('gateway_stop', () => { void flushAllFabric(); + if (presenceRefreshTimer) { + clearInterval(presenceRefreshTimer); + presenceRefreshTimer = null; + } + presence?.stop(); + presence = null; inbound?.stop(); inbound = null; }); diff --git a/dist/fabric/src/fabric-client.js b/dist/fabric/src/fabric-client.js index 35ef0c0..d2f0dc4 100644 --- a/dist/fabric/src/fabric-client.js +++ b/dist/fabric/src/fabric-client.js @@ -63,6 +63,11 @@ export class FabricClient { createChannel(guildEndpoint, guildToken, body) { return this.post(`${guildEndpoint}/api/channels`, body, guildToken); } + // PATCH /api/channels/:id — backend currently only patches `purpose`. + // Caller must be a member of the channel (or any user if public). + setChannelPurpose(guildEndpoint, guildToken, channelId, purpose) { + return this.req('PATCH', `${guildEndpoint}/api/channels/${channelId}`, guildToken, { purpose }); + } closeChannel(guildEndpoint, guildToken, channelId) { return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken); } @@ -105,4 +110,31 @@ export class FabricClient { removeCanvas(endpoint, token, channelId) { return this.req('DELETE', this.canvasUrl(endpoint, channelId), token); } + // ---- channel discovery + message read (used by the agent-facing + // fabric-channel-list / fabric-message-history tools) ---- + /** + * List channels in a guild visible to the calling user. Backend + * filters to public + channels the user is a member of. + */ + listChannels(guildEndpoint, guildToken, guildNodeId) { + return this.req('GET', `${guildEndpoint}/api/channels?guildId=${encodeURIComponent(guildNodeId)}`, guildToken); + } + /** + * Page through a channel's message history by `seq`. + * + * Backend defaults: 50 / call, max 200. The `seq` field starts at 1 + * per channel; pass `seqFrom=channel.lastSeq - N + 1` to get the + * tail. Page metadata in the response describes what to ask next. + */ + listMessages(guildEndpoint, guildToken, channelId, opts = {}) { + const qs = new URLSearchParams(); + if (opts.seqFrom !== undefined) + qs.set('seq_from', String(opts.seqFrom)); + if (opts.seqTo !== undefined) + qs.set('seq_to', String(opts.seqTo)); + if (opts.limit !== undefined) + qs.set('limit', String(opts.limit)); + const url = `${guildEndpoint}/api/channels/${channelId}/messages` + (qs.toString() ? `?${qs}` : ''); + return this.req('GET', url, guildToken); + } } diff --git a/dist/fabric/src/inbound.js b/dist/fabric/src/inbound.js index ac56400..f15c827 100644 --- a/dist/fabric/src/inbound.js +++ b/dist/fabric/src/inbound.js @@ -28,6 +28,125 @@ export class FabricInbound { // Re-login per agent on a short TTL to keep a fresh token. tokenCache = new Map(); static TOKEN_TTL_MS = 8 * 60 * 1000; + // Per-channel serial work queue. Every inbound socket message for a + // channel awaits the previous task for that same channel, so model + // turns never interleave. Map key = channelId; value is the tail of + // the chain (an in-flight promise the next task awaits). + // + // Why per-channel and not per-agent: a single agent may sit in + // several triage / general channels; we want each channel to flow at + // its own speed but the SAME channel's traffic to be strictly serial. + // For dm and discuss the queue also serialises but those traditionally + // had at-most-one-in-flight anyway via the turn engine. + channelChains = new Map(); + // Agent.status snapshot cache (5s TTL) — keeps the HF /calendar/ + // agent/status round-trip off the hot path for back-to-back triage + // messages. Short TTL because status flips are rare-but-meaningful. + agentStatusCache = new Map(); + static AGENT_STATUS_TTL_MS = 5_000; + // Triage messages that arrived while the on-duty agent wasn't on_call + // — sit here until either (a) the agent becomes on_call and the next + // triage arrival drains them, or (b) the gateway restarts (lost; ok + // because the underlying Fabric messages are persisted and re-fetched + // on agent reconnect's history sweep). + pendingTriageGated = []; + // Schedule `task` to run after every previous task on the same + // channel has completed. Returns the promise so callers can await + // their own result if they need to; the chain itself is fire-and- + // forget from the socket.on handler. + enqueueChannelTask(channelId, task) { + const prev = this.channelChains.get(channelId) ?? Promise.resolve(); + const next = prev.then(task).catch((err) => { + this.log.warn(`fabric: per-channel task failed channel=${channelId}: ${String(err)}`); + }); + this.channelChains.set(channelId, next); + // Best-effort cleanup so the Map doesn't grow without bound for + // long-running gateways: drop the entry when the chain settles, but + // only if it's still the latest reference (newer enqueue may have + // overwritten it in the meantime). + void next.finally(() => { + if (this.channelChains.get(channelId) === next) { + this.channelChains.delete(channelId); + } + }); + return next; + } + // Hit HF backend to check whether `agentId` is currently on_call. + // Cached for 5s. Failures (network, 404, etc.) are treated as "not + // on_call" — triage stays gated rather than risking a confused wake. + async checkAgentOnCall(agentId) { + const cached = this.agentStatusCache.get(agentId); + if (cached && Date.now() - cached.at < FabricInbound.AGENT_STATUS_TTL_MS) { + return cached.onCall; + } + const base = (process.env.HF_API_BASE_URL ?? '').trim() || 'https://monitor.hangman-lab.top'; + // CLAW_IDENTIFIER resolution priority: + // 1. HF_CLAW_IDENTIFIER env (operator override) + // 2. openclaw config `plugins.harbor-forge.identifier` (what the HF + // plugin itself uses — keeps the two in sync without an extra + // env per service unit) + // 3. os.hostname() last-resort fallback (often wrong: e.g. sim + // container hostname is `server.t2` but HF agent row has + // `claw_identifier=sim-t2`; matching is mandatory for the HF + // backend's _require_agent() check) + let claw = (process.env.HF_CLAW_IDENTIFIER ?? '').trim(); + if (!claw) { + try { + // openclaw config shape (verified in sim): + // { plugins: { entries: { 'harbor-forge': { config: { identifier } } } } } + const cfg = this.cfg; + const fromCfg = cfg?.plugins?.entries?.['harbor-forge']?.config?.identifier; + if (fromCfg && typeof fromCfg === 'string' && fromCfg.trim()) { + claw = fromCfg.trim(); + } + } + catch { + /* fall through to hostname */ + } + } + if (!claw) { + claw = (await import('os')).hostname(); + } + let onCall = false; + try { + const url = `${base.replace(/\/$/, '')}/calendar/agent/status?agent_id=${encodeURIComponent(agentId)}`; + const res = await fetch(url, { + headers: { 'X-Agent-ID': agentId, 'X-Claw-Identifier': claw }, + }); + if (res.ok) { + const data = (await res.json()); + onCall = (data.status ?? '').toLowerCase() === 'on_call'; + } + } + catch (err) { + this.log.warn(`fabric: HF status check failed agent=${agentId}: ${String(err)}`); + } + this.agentStatusCache.set(agentId, { onCall, at: Date.now() }); + return onCall; + } + // FIFO drain of all triage-gated messages for `agentId` (called when + // we just learned they're on_call). Each drained message is dispatched + // through its own channel chain so per-channel serial order is kept. + async drainGatedFor(agentId) { + const keep = []; + const drain = []; + for (const item of this.pendingTriageGated) { + if (item.agentId === agentId) + drain.push(item); + else + keep.push(item); + } + if (drain.length === 0) + return; + this.pendingTriageGated = keep; + for (const item of drain) { + this.log.info(`fabric: triage drain agent=${item.agentId} channel=${item.channelId} msg=${item.m.messageId}`); + // Re-enqueue via the per-channel chain so ordering is preserved. + this.enqueueChannelTask(item.channelId, async () => { + await this.dispatch(item.agentId, item.g, item.channelId, item.m, item.session); + }); + } + } // Return a fresh guild access token for the agent, re-authenticating with // the agent's Fabric API key when the cached session is stale. Falls back // to the connect-time session token if re-login fails. @@ -90,8 +209,48 @@ export class FabricInbound { s.disconnect(); this.sockets = []; } + /** + * Per-account metadata harvested during `start()` — used by + * PresenceSync to know where to push each agent's HF status. + * + * `fabricUserId` is filled from `session.user.id` after agent-login. + * `guildBaseUrl` is the FIRST guild the agent is connected to (multi- + * guild presence push is a future concern; for sim/prod-v1 each agent + * is in one guild). + * + * Returns ONLY agents that successfully connected — failed-login + * agents have no fabricUserId yet and are excluded. + */ + getPresenceAccounts() { + const out = []; + for (const entry of this.identity.list()) { + if (!entry.fabricUserId) + continue; + const presenceGuildUrl = this.firstGuildEndpointByAgent.get(entry.agentId); + if (!presenceGuildUrl) + continue; + out.push({ + agentId: entry.agentId, + fabricUserId: entry.fabricUserId, + guildBaseUrl: presenceGuildUrl, + fabricApiKey: entry.fabricApiKey, + }); + } + return out; + } + // Filled by connectAgent for each (agent, guild). Tracks ONLY the first + // guild per agent (used as the presence-push target). + firstGuildEndpointByAgent = new Map(); async connectAgent(agentId, session) { const selfUserId = session.user.id; + // First-guild capture for presence-sync push target. session.guilds is + // already in priority order from Center; we take the first one with a + // valid endpoint and stop. Multi-guild presence is a future concern. + if (!this.firstGuildEndpointByAgent.has(agentId)) { + const firstGuild = session.guilds.find((g) => typeof g.endpoint === 'string' && g.endpoint.length > 0); + if (firstGuild) + this.firstGuildEndpointByAgent.set(agentId, firstGuild.endpoint); + } for (const g of session.guilds) { const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token; if (!tok) @@ -190,7 +349,31 @@ export class FabricInbound { this.seen.add(key); if (this.seen.size > 5000) this.seen.clear(); - void this.dispatch(agentId, g, channelId, m, session); + // Per-channel serial queue. Prevents concurrent model turns for + // the same channel — important for triage where a second wake + // arriving mid-reply would interleave with the in-flight one. + this.enqueueChannelTask(channelId, async () => { + // Triage on_call gate: if the on-duty agent isn't currently + // on_call per HF, don't dispatch yet — just sit on the + // per-channel queue. Subsequent triage messages will recheck; + // when the agent becomes on_call, the next arrival drains. + // + // Also handles: triage + wake=true must verify status before + // committing to a model turn. Non-triage and triage observer + // (wake=false) skip the gate. + if (m.xType === 'triage' && m.wakeup === true) { + const onCall = await this.checkAgentOnCall(agentId); + if (!onCall) { + this.log.info(`fabric: triage wake gated (agent=${agentId} not on_call) — re-queue msg=${m.messageId}`); + this.pendingTriageGated.push({ agentId, g, channelId, m, session }); + return; + } + // Drain any previously-gated messages (FIFO) before this one, + // now that we know the agent is on_call. + await this.drainGatedFor(agentId); + } + await this.dispatch(agentId, g, channelId, m, session); + }); }); socket.connect(); this.sockets.push(socket); @@ -281,6 +464,17 @@ export class FabricInbound { // any message that isn't the agent's own (already filtered above) is // always delivered to the model. if (m.xType !== 'dm' && m.wakeup !== true) { + // Triage exception: non-wake messages (admin observer) MUST NOT + // enter the agent's session at all. The next time the agent + // wakes for a triage message, their context should contain only + // their own past wakeups + their own outgoing messages — never + // the observer-only chatter from other agents. For non-triage + // channels keep the legacy "record-as-history" so a later wake + // sees the full channel conversation. + if (m.xType === 'triage') { + this.log.info(`fabric: triage observer skip agent=${agentId} channel=${channelId} msg=${m.messageId}`); + return; + } const ctxPayload = core.channel.reply.finalizeInboundContext(baseCtx); await core.channel.session.recordInboundSession({ storePath, diff --git a/dist/fabric/src/presence-sync.js b/dist/fabric/src/presence-sync.js new file mode 100644 index 0000000..c7a6921 --- /dev/null +++ b/dist/fabric/src/presence-sync.js @@ -0,0 +1,84 @@ +/** + * presence-sync — read each connected agent's HF status (via the + * cross-plugin `globalThis.__hfAgentStatus.get(agentId)` exposed by + * HarborForge.OpenclawPlugin) and push diffs to Fabric.Backend.Guild + * `PUT /agents/:userId/presence` so the backend can apply busy-discard + * on `announce`-type channel deliveries. + * + * Push model: we only PUT when an agent's status actually changes + * (since the last push). The HF-side accessor has its own TTL cache + * to absorb the every-30s polling. + * + * If HF plugin isn't loaded (`__hfAgentStatus` undefined), the loop + * is a no-op — Fabric backend defaults presence to 'unknown' which is + * treated as not-busy. Announce-channel delivery still works; busy + * filtering simply doesn't kick in. + */ +export class PresenceSync { + logger; + timer = null; + lastStatus = new Map(); // by agentId + accounts = new Map(); + constructor(logger) { + this.logger = logger; + } + setAccounts(accounts) { + this.accounts.clear(); + for (const a of accounts) + this.accounts.set(a.agentId, a); + } + start(intervalMs = 30_000) { + if (this.timer) + return; + this.timer = setInterval(() => { + this.tick().catch((err) => this.logger.warn(`fabric: presence-sync error: ${String(err)}`)); + }, intervalMs); + // run once immediately so initial state lands fast + void this.tick(); + } + stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + async tick() { + const bridge = globalThis['__hfAgentStatus']; + if (!bridge || typeof bridge.get !== 'function') + return; // HF plugin not loaded — skip + for (const [agentId, acct] of this.accounts) { + let status; + try { + status = await bridge.get(agentId); + } + catch { + continue; + } + if (!status) + continue; + if (this.lastStatus.get(agentId) === status) + continue; // no change → no PUT + try { + const url = `${acct.guildBaseUrl.replace(/\/$/, '')}/agents/${encodeURIComponent(acct.fabricUserId)}/presence`; + const res = await fetch(url, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + 'x-api-key': acct.fabricApiKey, + }, + body: JSON.stringify({ status, source: 'hf-plugin' }), + }); + if (res.ok) { + this.lastStatus.set(agentId, status); + this.logger.info(`fabric: presence-sync ${agentId} → ${status}`); + } + else { + this.logger.warn(`fabric: presence-sync PUT ${agentId} failed: ${res.status}`); + } + } + catch (err) { + this.logger.warn(`fabric: presence-sync PUT ${agentId} threw: ${String(err)}`); + } + } + } +} diff --git a/dist/fabric/src/tools.js b/dist/fabric/src/tools.js index 04b03df..a9d3506 100644 --- a/dist/fabric/src/tools.js +++ b/dist/fabric/src/tools.js @@ -25,7 +25,9 @@ export function registerFabricTools(api, client, identity) { // or via static config (channels.fabric.accounts.). const makeCreate = (kind) => api.registerTool((ctx) => ({ name: `create-${kind}-channel`, - description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`, + description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` + + 'Optionally pass `purpose` to describe what this channel is for — ' + + 'agents browse channels by purpose via fabric-channel-list.', parameters: { type: 'object', additionalProperties: false, @@ -37,6 +39,13 @@ export function registerFabricTools(api, client, identity) { memberUserIds: { type: 'array', items: { type: 'string' } }, onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' }, listeners: { type: 'array', items: { type: 'string' } }, + purpose: { + type: 'string', + description: "Free-form description of what this channel is for. Optional but " + + 'strongly recommended so other agents can find this channel by ' + + 'intent (via fabric-channel-list). Can be edited later with ' + + 'fabric-channel-set-purpose.', + }, }, }, execute: async (p) => { @@ -50,6 +59,7 @@ export function registerFabricTools(api, client, identity) { xType: X_BY_KIND[kind], isPublic: p.isPublic ?? false, memberUserIds: p.memberUserIds ?? [], + ...(p.purpose !== undefined ? { purpose: p.purpose } : {}), }); return { ok: true, channelId: ch.id }; }, @@ -202,4 +212,250 @@ export function registerFabricTools(api, client, identity) { } }, })); + // ----------------------------------------------------------------- + // fabric-send-message: post a message into a specific channel. + // + // Unlike a normal channel reply (which goes back to whatever channel + // woke the agent), this lets the agent proactively initiate text into + // any channel they are a member of — e.g. ARD broadcasting daily + // workload to #agents-room, or triage agent following up on an + // already-routed task by commenting in #updates. + // ----------------------------------------------------------------- + api.registerTool((ctx) => ({ + name: 'fabric-send-message', + description: 'Send a text message into a specific Fabric channel. Author is the calling agent. ' + + 'Requires guildNodeId + channelId + content. Returns {ok, messageId, seq}.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['guildNodeId', 'channelId', 'content'], + properties: { + guildNodeId: { type: 'string' }, + channelId: { type: 'string' }, + content: { type: 'string', description: 'Message body (markdown supported by the renderer).' }, + }, + }, + execute: async (p) => { + const agentId = ctx.agentId; + if (!agentId) + return { ok: false, error: 'no agent context' }; + const { session, guild, token } = await ctxGuild(agentId, p.guildNodeId); + const res = (await client.postMessage(guild.endpoint, token, p.channelId, p.content, session.user.id)); + return { ok: true, messageId: res.messageId, seq: res.seq }; + }, + })); + // ----------------------------------------------------------------- + // fabric-channel-list: enumerate channels the calling agent can see + // in a given guild. Backend filters to public channels + channels the + // agent is a member of. Returns id / name / xType per channel so the + // agent can pick a channelId for fabric-send-message etc. + // ----------------------------------------------------------------- + api.registerTool((ctx) => ({ + name: 'fabric-channel-list', + description: 'List channels visible to the calling agent in a guild. Optional ' + + 'nameFilter does a case-insensitive substring match client-side. ' + + 'Use this to find a channelId before fabric-send-message / fabric-message-history.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['guildNodeId'], + properties: { + guildNodeId: { type: 'string' }, + nameFilter: { type: 'string', description: 'optional substring match on channel name (case-insensitive)' }, + xType: { + type: 'string', + enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'], + description: 'optional filter by x_type', + }, + includeClosed: { type: 'boolean', description: 'default false — closed channels filtered out' }, + }, + }, + execute: async (p) => { + const agentId = ctx.agentId; + if (!agentId) + return { ok: false, error: 'no agent context' }; + const { guild, token } = await ctxGuild(agentId, p.guildNodeId); + const all = await client.listChannels(guild.endpoint, token, p.guildNodeId); + const needle = (p.nameFilter ?? '').toLowerCase(); + const filtered = all.filter((c) => { + if (!p.includeClosed && c.closed) + return false; + if (p.xType && c.xType !== p.xType) + return false; + if (needle && !c.name.toLowerCase().includes(needle)) + return false; + return true; + }); + return { + ok: true, + count: filtered.length, + channels: filtered.map((c) => ({ + id: c.id, + name: c.name, + xType: c.xType, + isPublic: c.isPublic, + closed: c.closed, + lastSeq: c.lastSeq, + purpose: c.purpose ?? null, + })), + }; + }, + })); + // ----------------------------------------------------------------- + // fabric-guild-list: enumerate guilds the calling agent belongs to. + // Each row carries `purpose` — free-form description of what the + // guild is for (admin-set). Use this as the first step when a + // workflow says "find the right guild for X" — pick by purpose, + // then fabric-channel-list to find the right channel inside it. + // ----------------------------------------------------------------- + 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).', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + nameFilter: { + type: 'string', + description: 'optional case-insensitive substring match on guild name', + }, + purposeFilter: { + type: 'string', + description: 'optional case-insensitive substring match on guild purpose ' + + '(e.g. "debate", "announcements")', + }, + }, + }, + execute: async (p) => { + const agentId = ctx.agentId; + if (!agentId) + return { ok: false, error: 'no agent context' }; + const entry = identity.findByAgentId(agentId); + if (!entry) + return { ok: false, error: `agent ${agentId} not registered` }; + const session = await client.agentLogin(entry.fabricApiKey); + const nameNeedle = (p.nameFilter ?? '').toLowerCase(); + const purposeNeedle = (p.purposeFilter ?? '').toLowerCase(); + const guilds = session.guilds.filter((g) => { + if (nameNeedle && !g.name.toLowerCase().includes(nameNeedle)) + return false; + if (purposeNeedle) { + const purp = (g.purpose ?? '').toLowerCase(); + if (!purp.includes(purposeNeedle)) + return false; + } + return true; + }); + return { + ok: true, + count: guilds.length, + guilds: guilds.map((g) => ({ + nodeId: g.nodeId, + name: g.name, + status: g.status, + purpose: g.purpose ?? null, + })), + }; + }, + })); + // ----------------------------------------------------------------- + // fabric-channel-set-purpose: set/update a channel's free-form + // purpose description. Caller must be a channel member (or the + // channel must be public). Use this to backfill purpose on existing + // channels, or to refine it after a channel's role evolves. + // ----------------------------------------------------------------- + api.registerTool((ctx) => ({ + name: 'fabric-channel-set-purpose', + description: "Set or update a channel's free-form purpose description. " + + 'Channel membership required (or the channel must be public). ' + + 'Pass empty string to clear. Use this to make a channel ' + + 'discoverable to other agents via fabric-channel-list.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['guildNodeId', 'channelId', 'purpose'], + properties: { + guildNodeId: { type: 'string' }, + channelId: { type: 'string' }, + purpose: { + type: 'string', + description: "What this channel is for. Pass '' (empty string) to clear.", + }, + }, + }, + execute: async (p) => { + const agentId = ctx.agentId; + if (!agentId) + return { ok: false, error: 'no agent context' }; + const { guild, token } = await ctxGuild(agentId, p.guildNodeId); + const res = await client.setChannelPurpose(guild.endpoint, token, p.channelId, p.purpose); + return { ok: true, channel: res }; + }, + })); + // ----------------------------------------------------------------- + // fabric-message-history: read a channel's recent message history by + // `seq`. Tail-by-default: when `seqFrom`/`seqTo` are omitted, returns + // the last `limit` messages (limit defaults to 20, max 200). + // + // Use cases: catch-up on a channel that was muted while the agent was + // gated; verify a previous message went through; lookup recent + // duplicates before opening a new task in triage. + // ----------------------------------------------------------------- + api.registerTool((ctx) => ({ + name: 'fabric-message-history', + description: "Read a channel's recent message history. Omit seqFrom/seqTo to " + + 'tail (last `limit` messages, default 20, max 200). Backend ' + + 'requires the calling agent to be a channel participant.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['guildNodeId', 'channelId'], + properties: { + guildNodeId: { type: 'string' }, + channelId: { type: 'string' }, + seqFrom: { type: 'integer', minimum: 1, description: 'inclusive lower bound; default = tail' }, + seqTo: { type: 'integer', minimum: 1, description: 'inclusive upper bound; default = channel head' }, + limit: { type: 'integer', minimum: 1, maximum: 200, description: 'default 20' }, + }, + }, + execute: async (p) => { + const agentId = ctx.agentId; + if (!agentId) + return { ok: false, error: 'no agent context' }; + const { guild, token } = await ctxGuild(agentId, p.guildNodeId); + const limit = p.limit ?? 20; + // Tail mode: discover channel head via channel listing, then ask + // for [head-limit+1, head]. Avoids needing the agent to know seq. + let seqFrom = p.seqFrom; + let seqTo = p.seqTo; + if (seqFrom === undefined && seqTo === undefined) { + const channels = await client.listChannels(guild.endpoint, token, p.guildNodeId); + const ch = channels.find((c) => c.id === p.channelId); + const head = ch?.lastSeq ?? 0; + seqFrom = Math.max(1, head - limit + 1); + seqTo = head; + } + const res = await client.listMessages(guild.endpoint, token, p.channelId, { + seqFrom, + seqTo, + limit, + }); + return { + ok: true, + page: res.page, + messages: res.items.map((m) => ({ + messageId: m.messageId, + seq: m.seq, + authorUserId: m.authorUserId, + content: m.content, + createdAt: m.createdAt, + isDeleted: m.isDeleted, + })), + }; + }, + })); } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 11dab16..21ac613 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -19,7 +19,9 @@ "fabric-channel", "fabric-send-message", "fabric-channel-list", - "fabric-message-history" + "fabric-message-history", + "fabric-guild-list", + "fabric-channel-set-purpose" ] }, "configSchema": { diff --git a/src/fabric-client.ts b/src/fabric-client.ts index eac0761..e503182 100644 --- a/src/fabric-client.ts +++ b/src/fabric-client.ts @@ -6,7 +6,15 @@ export type FabricSession = { accessToken: string; refreshToken: string; user: { id: string; email: string; name: string }; - guilds: Array<{ nodeId: string; name: string; endpoint: string; status: string }>; + guilds: Array<{ + nodeId: string; + name: string; + endpoint: string; + status: string; + // free-form description of this guild's role; admin-set on Center. + // null when the admin hasn't filled it in yet. + purpose?: string | null; + }>; guildAccessTokens: Array<{ guildNodeId: string; token: string }>; }; @@ -100,11 +108,30 @@ export class FabricClient { memberUserIds?: string[]; onDuty?: string; listeners?: string[]; + // free-form purpose; optional. Existing agents can also set/update + // it later via setChannelPurpose(). + purpose?: string; }, ): Promise<{ id: string }> { return this.post(`${guildEndpoint}/api/channels`, body, guildToken); } + // PATCH /api/channels/:id — backend currently only patches `purpose`. + // Caller must be a member of the channel (or any user if public). + setChannelPurpose( + guildEndpoint: string, + guildToken: string, + channelId: string, + purpose: string, + ): Promise<{ id: string; name: string; xType: string; purpose: string | null }> { + return this.req( + 'PATCH', + `${guildEndpoint}/api/channels/${channelId}`, + guildToken, + { purpose }, + ); + } + closeChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise { return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken); } @@ -212,6 +239,7 @@ export class FabricClient { closed: boolean; lastSeq: number; createdAt: string; + purpose?: string | null; }>> { return this.req( 'GET', diff --git a/src/tools.ts b/src/tools.ts index 1585f39..6377097 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -46,7 +46,10 @@ export function registerFabricTools( const makeCreate = (kind: 'chat' | 'work' | 'report' | 'discussion') => api.registerTool((ctx: Ctx) => ({ name: `create-${kind}-channel`, - description: `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}).`, + description: + `Create a Fabric ${kind} channel (x_type=${X_BY_KIND[kind]}). ` + + 'Optionally pass `purpose` to describe what this channel is for — ' + + 'agents browse channels by purpose via fabric-channel-list.', parameters: { type: 'object', additionalProperties: false, @@ -58,6 +61,14 @@ export function registerFabricTools( memberUserIds: { type: 'array', items: { type: 'string' } }, onDuty: { type: 'string', description: 'required for triage-like flows (unused for these kinds)' }, listeners: { type: 'array', items: { type: 'string' } }, + purpose: { + type: 'string', + description: + "Free-form description of what this channel is for. Optional but " + + 'strongly recommended so other agents can find this channel by ' + + 'intent (via fabric-channel-list). Can be edited later with ' + + 'fabric-channel-set-purpose.', + }, }, }, execute: async (p: { @@ -65,6 +76,7 @@ export function registerFabricTools( name: string; isPublic?: boolean; memberUserIds?: string[]; + purpose?: string; }) => { const agentId = ctx.agentId; if (!agentId) return { ok: false, error: 'no agent context' }; @@ -75,6 +87,7 @@ export function registerFabricTools( xType: X_BY_KIND[kind], isPublic: p.isPublic ?? false, memberUserIds: p.memberUserIds ?? [], + ...(p.purpose !== undefined ? { purpose: p.purpose } : {}), }); return { ok: true, channelId: ch.id }; }, @@ -337,11 +350,112 @@ export function registerFabricTools( isPublic: c.isPublic, closed: c.closed, lastSeq: c.lastSeq, + purpose: c.purpose ?? null, })), }; }, })); + // ----------------------------------------------------------------- + // fabric-guild-list: enumerate guilds the calling agent belongs to. + // Each row carries `purpose` — free-form description of what the + // guild is for (admin-set). Use this as the first step when a + // workflow says "find the right guild for X" — pick by purpose, + // then fabric-channel-list to find the right channel inside it. + // ----------------------------------------------------------------- + api.registerTool((ctx: 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).', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + nameFilter: { + type: 'string', + description: 'optional case-insensitive substring match on guild name', + }, + purposeFilter: { + type: 'string', + description: + 'optional case-insensitive substring match on guild purpose ' + + '(e.g. "debate", "announcements")', + }, + }, + }, + execute: async (p: { nameFilter?: string; purposeFilter?: string }) => { + const agentId = ctx.agentId; + if (!agentId) return { ok: false, error: 'no agent context' }; + const entry = identity.findByAgentId(agentId); + if (!entry) return { ok: false, error: `agent ${agentId} not registered` }; + const session = await client.agentLogin(entry.fabricApiKey); + const nameNeedle = (p.nameFilter ?? '').toLowerCase(); + const purposeNeedle = (p.purposeFilter ?? '').toLowerCase(); + const guilds = session.guilds.filter((g) => { + if (nameNeedle && !g.name.toLowerCase().includes(nameNeedle)) return false; + if (purposeNeedle) { + const purp = (g.purpose ?? '').toLowerCase(); + if (!purp.includes(purposeNeedle)) return false; + } + return true; + }); + return { + ok: true, + count: guilds.length, + guilds: guilds.map((g) => ({ + nodeId: g.nodeId, + name: g.name, + status: g.status, + purpose: g.purpose ?? null, + })), + }; + }, + })); + + // ----------------------------------------------------------------- + // fabric-channel-set-purpose: set/update a channel's free-form + // purpose description. Caller must be a channel member (or the + // channel must be public). Use this to backfill purpose on existing + // channels, or to refine it after a channel's role evolves. + // ----------------------------------------------------------------- + api.registerTool((ctx: Ctx) => ({ + name: 'fabric-channel-set-purpose', + description: + "Set or update a channel's free-form purpose description. " + + 'Channel membership required (or the channel must be public). ' + + 'Pass empty string to clear. Use this to make a channel ' + + 'discoverable to other agents via fabric-channel-list.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['guildNodeId', 'channelId', 'purpose'], + properties: { + guildNodeId: { type: 'string' }, + channelId: { type: 'string' }, + purpose: { + type: 'string', + description: "What this channel is for. Pass '' (empty string) to clear.", + }, + }, + }, + execute: async (p: { guildNodeId: string; channelId: string; purpose: string }) => { + const agentId = ctx.agentId; + if (!agentId) return { ok: false, error: 'no agent context' }; + const { guild, token } = await ctxGuild(agentId, p.guildNodeId); + const res = await client.setChannelPurpose( + guild.endpoint, + token, + p.channelId, + p.purpose, + ); + return { ok: true, channel: res }; + }, + })); + // ----------------------------------------------------------------- // fabric-message-history: read a channel's recent message history by // `seq`. Tail-by-default: when `seqFrom`/`seqTo` are omitted, returns