@volter/supercode-orchestrator 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ // The Chat SDK sidecar (ORC-9): one adapter instance per (platform, account),
2
+ // mounting `vercel/chat` behind the same three-method trait every adapter has
3
+ // (`connect(emit)` / `send(target, content)` / `close()`).
4
+ //
5
+ // PIN. `chat@4.39.0` (npm package name is `chat`, repo `github.com/vercel/chat`;
6
+ // the contracts quoted below were read at repo HEAD `043386b` by ORC-1 F4 and
7
+ // re-verified against the installed 4.39.0 `.d.ts`). The platform packages
8
+ // (`@chat-adapter/slack`, `@chat-adapter/telegram`, …) are NOT dependencies of
9
+ // this package: each is imported lazily at `connect` from the profile's own
10
+ // `platforms:` block, so a home that names none pulls none.
11
+ //
12
+ // WHY A SIDECAR AND NOT A TRANSPORT REWRITE (docs/ORCHESTRATOR-IR.md §7,
13
+ // `docs/interop/research/orc1-fact-finders-2026-09-04.md` §F4). The SDK's
14
+ // handler contract is `(thread, message)`: the SURFACE identity lives on the
15
+ // `Thread` and the CONTENT on the `Message`. Everything the IR's `inbound`
16
+ // event needs is therefore read off the `Thread` — never off the `Message` —
17
+ // and nothing reaches the reducer by any other door (§3: "An adapter that
18
+ // cannot express something as an `inbound` event does not get a private path
19
+ // into the loop").
20
+ //
21
+ // THE MAPPING, field by field (left: IR §3 `inbound` / §2.1 `Attachment`;
22
+ // right: the Chat SDK member quoted in F4):
23
+ //
24
+ // key.platform thread.adapter.name (types.ts:438)
25
+ // key.chat_id thread.channelId (types.ts:1246)
26
+ // key.thread_id thread.id (types.ts:1065, Postable.id)
27
+ // key.kind thread.isDM -> "dm" (types.ts:1067)
28
+ // else thread.channelVisibility (types.ts:27-31) — see chatType()
29
+ // sender message.author.userId (types.ts:1648)
30
+ // sender_name message.author.userName (types.ts:1649)
31
+ // is_me message.author.isMe (types.ts:1644)
32
+ // is_bot message.author.isBot (types.ts:1643; "unknown" -> null)
33
+ // mention message.isMention (message.ts:40)
34
+ // message_id message.id (message.ts:38)
35
+ // at message.metadata.dateSent (types.ts:1671)
36
+ // text message.text (message.ts:51, formatting stripped)
37
+ // attachments[].* message.attachments[].* (types.ts:1833-1862)
38
+ // attachments[].ref attachment.fetchMetadata (the re-fetch instruction, verbatim)
39
+ //
40
+ // OUTBOUND. `send` is `thread.post(text)` (types.ts:1083-1086), `thread.reply`
41
+ // when the obligation names a `reply_to` (types.ts:1646), and
42
+ // `adapter.postChannelMessage(channelId, …)` (types.ts:941) for a target with
43
+ // no thread. It returns `{ message_id }` — the platform's handle for what was
44
+ // posted — which `loop.mjs`'s `deliver` records as `Obligation.posted`
45
+ // (§2.6, F4 finding 3).
46
+ //
47
+ // KNOWN GAP, not papered over: `loop.mjs`'s `deliver` projects the obligation
48
+ // down to `{ text, at }` before calling `send`, so `OutboundContent.reply_to`
49
+ // and `attachments` are never populated by the loop today even though §2.1
50
+ // declares them and this adapter honours both when a caller supplies them.
51
+ // Closing it is a `deliver` change, out of ORC-9's one-line budget.
52
+ //
53
+ // CONCURRENCY. The SDK has its own per-thread queue with five strategies
54
+ // (`ConcurrencyStrategy`, types.ts:905-910) and the IR has its own (§4.1 step
55
+ // 5 / §2.8 `queues`). Two queues on one surface is F4 finding 11, so the
56
+ // sidecar defaults the SDK's to `"concurrent"` — no SDK-side locking — and
57
+ // leaves the IR's queue as the single one. `extra.concurrency` overrides it.
58
+
59
+ import { existsSync, readFileSync } from 'node:fs';
60
+ import { join } from 'node:path';
61
+ import { parseDotenv } from '../orchestration.mjs';
62
+ import { TOKEN_SCOPES, acquireTokenLock, releaseTokenLock } from '../hermes-locks.mjs';
63
+ import { chunks } from './chunks.mjs';
64
+
65
+ /** The Chat SDK version this adapter was written against and pinned to. */
66
+ export const CHAT_SDK_VERSION = '4.39.0';
67
+
68
+ /**
69
+ * A Hermes-shaped `platforms:` block (no `credentials:` refs) for the three chat
70
+ * platforms Hermes serves: the factory options each takes, read from the
71
+ * profile's own `.env` under Hermes's variable names (`gateway/config.py`),
72
+ * and the resident ingress Hermes itself uses — Slack Socket Mode (Hermes
73
+ * requires `SLACK_APP_TOKEN`), Telegram long polling, the Discord gateway.
74
+ * Option names are the platform packages' own (their READMEs, read
75
+ * 2026-09-23). Unverified end to end on this box: the platform packages are
76
+ * not installed here and no platform tokens exist.
77
+ */
78
+ export const HERMES_PLATFORMS = Object.freeze({
79
+ slack: { env: { botToken: 'SLACK_BOT_TOKEN', appToken: 'SLACK_APP_TOKEN', signingSecret: 'SLACK_SIGNING_SECRET' }, options: { mode: 'socket' } },
80
+ telegram: { env: { botToken: 'TELEGRAM_BOT_TOKEN', userName: 'TELEGRAM_BOT_USERNAME' }, options: { mode: 'polling' } },
81
+ discord: { env: { botToken: 'DISCORD_BOT_TOKEN', publicKey: 'DISCORD_PUBLIC_KEY', applicationId: 'DISCORD_APPLICATION_ID' }, options: {}, gateway: true },
82
+ });
83
+
84
+ /** The most characters a platform takes in one message (Discord's API refuses more; Telegram's Bot API caps 4096). */
85
+ const MESSAGE_LIMIT = Object.freeze({ discord: 2000, telegram: 4096 });
86
+
87
+ /** How long one Discord gateway listen runs before the adapter starts the next. */
88
+ export const GATEWAY_SLICE_MS = 10 * 60_000;
89
+
90
+ /** The npm package names this adapter imports lazily, never at module load. */
91
+ export const CHAT_SDK_PACKAGE = 'chat';
92
+ export const CHAT_SDK_STATE_PACKAGE = '@chat-adapter/state-memory';
93
+
94
+ /**
95
+ * `kind` (§2.3's four-value enum) from the SDK's three-way surface split.
96
+ *
97
+ * F4 finding 4 records that `isDM` / `channelVisibility` / the
98
+ * Thread-vs-Channel structure "does not project onto the IR enum without a
99
+ * rule the IR would have to invent", and names `"external"` (Slack Connect) as
100
+ * having no IR value. This IS that rule, decided here and nowhere else:
101
+ *
102
+ * isDM -> "dm"
103
+ * channelVisibility private -> "group" (a closed room with named members)
104
+ * workspace|external|unknown-> "channel" (an open room; external is a
105
+ * channel shared with another org,
106
+ * not a different kind of surface)
107
+ *
108
+ * `thread` is deliberately not produced: a thread is carried by the key's own
109
+ * `thread_id`, which is always set (`Postable.id`), so using it as a kind
110
+ * as well would double-encode one fact.
111
+ */
112
+ export function chatType(thread) {
113
+ if (thread?.isDM) return 'dm';
114
+ return thread?.channelVisibility === 'private' ? 'group' : 'channel';
115
+ }
116
+
117
+ /** One SDK `Attachment` (types.ts:1833-1862) -> the IR's (§2.1). */
118
+ export function toAttachment(a) {
119
+ const out = { kind: a.type };
120
+ if (a.name) out.name = a.name;
121
+ if (a.mimeType) out.mime = a.mimeType;
122
+ if (typeof a.size === 'number') out.size = a.size;
123
+ if (a.url) out.url = a.url;
124
+ // §2.1: "`ref` = adapter data to re-fetch (Chat SDK fetchMetadata …)"
125
+ if (a.fetchMetadata) out.ref = { ...a.fetchMetadata };
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * `(thread, message)` -> the `inbound` payload the loop's `emit` takes.
131
+ * Pure, and exported so a test can assert the mapping without a Chat instance.
132
+ */
133
+ export function toInbound({ profile, thread, message }) {
134
+ const isBot = message.author?.isBot;
135
+ return {
136
+ profile,
137
+ key: {
138
+ platform: thread.adapter.name,
139
+ kind: chatType(thread),
140
+ chat_id: String(thread.channelId ?? thread.id),
141
+ thread_id: String(thread.id),
142
+ },
143
+ sender: String(message.author?.userId ?? ''),
144
+ sender_name: message.author?.userName ?? null,
145
+ text: message.text ?? '',
146
+ attachments: (message.attachments ?? []).map(toAttachment),
147
+ at: new Date(message.metadata?.dateSent ?? Date.now()).toISOString(),
148
+ message_id: String(message.id),
149
+ is_me: Boolean(message.author?.isMe),
150
+ // `isBot: boolean | "unknown"` — "unknown" is not false, so it is null here
151
+ is_bot: isBot === 'unknown' || isBot === undefined ? null : Boolean(isBot),
152
+ mention: Boolean(message.isMention),
153
+ };
154
+ }
155
+
156
+ export class ChatSdkAdapter {
157
+ /**
158
+ * @param {object} o
159
+ * @param {string} o.platform the `platforms.<name>` block's name; also the SDK adapter slug unless `extra.slug` says otherwise
160
+ * @param {string} o.profile the profile that owns this (platform, account)
161
+ * @param {object} [o.channel] the loaded `ChannelConfig` (§2.4): credentials by ref, `extra` verbatim
162
+ * @param {Map<string,string>} [o.vault] resolves `${NAME}` references at connect, never before
163
+ * @param {object} [o.sdkAdapter] an already-built SDK `Adapter` (tests inject one; production builds it from `channel`)
164
+ * @param {object} [o.chat] an already-built `Chat` (tests may inject one instead of an adapter)
165
+ * @param {object} [o.state] an SDK `StateAdapter`; defaults to `@chat-adapter/state-memory`
166
+ * @param {(level: string, message: string) => void} [o.log]
167
+ */
168
+ constructor({ platform, profile = 'default', profileDir = null, channel = null, vault = new Map(), sdkAdapter = null, chat = null, state = null, log = () => {} }) {
169
+ this.platform = platform;
170
+ this.profile = profile;
171
+ /** The owning profile's folder: a Hermes-shaped block reads its own `.env` there, never another profile's. */
172
+ this.profileDir = profileDir;
173
+ this.channel = channel;
174
+ this.vault = vault;
175
+ this.sdkAdapter = sdkAdapter;
176
+ this.chat = chat;
177
+ this.state = state;
178
+ this.log = log;
179
+ this.emit = null;
180
+ /** thread_id -> the live `Thread`, so `send` posts through the SDK's own door. */
181
+ this.threads = new Map();
182
+ }
183
+
184
+ /** The block's `extra:` map, whose keys the loader prefixes with `extra.` (the world codec's channel decoder). */
185
+ extra(name, fallback = undefined) {
186
+ const e = this.channel?.extra ?? {};
187
+ return e[`extra.${name}`] ?? e[name] ?? fallback;
188
+ }
189
+
190
+ /**
191
+ * The factory options for the platform package: the credential KEY NAMES in
192
+ * the profile block are the SDK factory's own option names, resolved from
193
+ * the vault here and never earlier (§2.4: the model holds refs, not values).
194
+ * supercode renames nothing.
195
+ */
196
+ async credentials() {
197
+ const slug = String(this.extra('slug', this.platform));
198
+ const hermes = HERMES_PLATFORMS[slug];
199
+ if (hermes && !Object.keys(this.channel?.credentials ?? {}).length) {
200
+ // Hermes's own variables, from the owning profile's own .env only (a
201
+ // named profile's token never serves the root's block, Hermes #59739)
202
+ const file = this.profileDir ? join(this.profileDir, '.env') : null;
203
+ const env = file && existsSync(file) ? parseDotenv(readFileSync(file, 'utf8')) : new Map();
204
+ const out = { ...hermes.options };
205
+ for (const [option, name] of Object.entries(hermes.env)) {
206
+ const value = env.get(name);
207
+ if (value !== undefined && value !== '') out[option] = value;
208
+ }
209
+ if (!out.botToken) throw new Error(`platforms.${this.platform}: ${hermes.env.botToken} is not in ${file ?? "the profile's .env"}`);
210
+ // Hermes connects Slack only over Socket Mode, and requires the app token for it
211
+ if (slug === 'slack' && !out.appToken) throw new Error(`platforms.${this.platform}: SLACK_APP_TOKEN is not in ${file}; Socket Mode needs it, as Hermes does`);
212
+ // The Discord package needs the application id and public key, which
213
+ // Hermes never asks for: Discord's own application record gives both
214
+ if (slug === 'discord' && (!out.applicationId || !out.publicKey)) {
215
+ let app;
216
+ try {
217
+ const res = await fetch('https://discord.com/api/v10/applications/@me', { headers: { authorization: `Bot ${out.botToken}` }, signal: AbortSignal.timeout(15_000) });
218
+ if (res.status === 401 || res.status === 403) throw new Error(`Discord refused DISCORD_BOT_TOKEN (${res.status})`);
219
+ if (!res.ok) throw Object.assign(new Error(`Discord answered ${res.status} for the application record`), { transient: true });
220
+ app = await res.json();
221
+ } catch (error) {
222
+ if (error.name === 'TimeoutError' || error.name === 'TypeError') throw Object.assign(new Error(`Discord unreachable: ${error.message}`), { transient: true });
223
+ throw error;
224
+ }
225
+ out.applicationId ??= app.id;
226
+ out.publicKey ??= app.verify_key;
227
+ }
228
+ return out;
229
+ }
230
+ const out = {};
231
+ for (const [key, ref] of Object.entries(this.channel?.credentials ?? {})) {
232
+ const name = ref.dotenv ?? ref.env;
233
+ const value = this.vault.get(name) ?? process.env[name];
234
+ if (value === undefined) throw new Error(`platforms.${this.platform}.${key}: \${${name}} is in neither the home's .env nor the environment`);
235
+ // `extra.foo` in the block is the factory option `foo`
236
+ out[key.startsWith('extra.') ? key.slice(6) : key] = value;
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * Build the SDK platform adapter named by this block. `extra.slug` picks the
243
+ * registry entry when the block's name is an account label rather than a
244
+ * platform ("slack-eng" -> `extra.slug: slack`); otherwise the block's name
245
+ * IS the slug, which is why one block is one (platform, account).
246
+ */
247
+ async buildSdkAdapter() {
248
+ const slug = String(this.extra('slug', this.platform));
249
+ const { getAdapter } = await import(/* @vite-ignore */ `${CHAT_SDK_PACKAGE}/adapters`);
250
+ const entry = getAdapter(slug);
251
+ if (!entry || entry.type !== 'platform') throw new Error(`chat-sdk: \`${slug}\` is not a Chat SDK platform adapter`);
252
+ let mod;
253
+ try {
254
+ mod = await import(/* @vite-ignore */ entry.packageName);
255
+ } catch (error) {
256
+ throw new Error(`chat-sdk: ${entry.packageName} is not installed (${error.message})`);
257
+ }
258
+ const factory = mod[entry.factoryExport];
259
+ if (typeof factory !== 'function') throw new Error(`chat-sdk: ${entry.packageName} exports no ${entry.factoryExport}`);
260
+ const options = await this.credentials();
261
+ this.lockToken(slug, options);
262
+ return factory(options);
263
+ }
264
+
265
+ /**
266
+ * One gateway per bot token on this machine, as Hermes's adapters hold it: the token's scoped lock in
267
+ * Hermes's own lock directory and record, so a Hermes gateway on any home refuses the token while this
268
+ * adapter serves it, and this adapter refuses a token a live Hermes gateway serves.
269
+ */
270
+ lockToken(slug, options) {
271
+ const lock = TOKEN_SCOPES[slug];
272
+ const identity = lock && options[lock.option];
273
+ if (!identity) return;
274
+ const got = acquireTokenLock({ scope: lock.scope, identity, home: this.profileDir ?? process.cwd(), platform: slug });
275
+ if (!got.ok) {
276
+ const who = got.holder?.pid ? `pid ${got.holder.pid}${got.holder.hermes_home ? ` serving ${got.holder.hermes_home}` : ''}` : 'another gateway';
277
+ throw new Error(`${slug}'s token is served by ${who}; one gateway serves a token at a time`);
278
+ }
279
+ this.tokenLock = { scope: lock.scope, identity };
280
+ }
281
+
282
+ async buildState() {
283
+ if (this.state) return this.state;
284
+ const { createMemoryState } = await import(/* @vite-ignore */ CHAT_SDK_STATE_PACKAGE);
285
+ return createMemoryState();
286
+ }
287
+
288
+ /**
289
+ * Mount the sidecar. Every handler door the SDK offers for an inbound human
290
+ * message is registered onto the SAME mapping, so a DM, a fresh @-mention
291
+ * and a follow-up in a subscribed thread all become one `inbound`.
292
+ */
293
+ async connect(emit) {
294
+ this.emit = emit;
295
+ if (!this.chat) {
296
+ try {
297
+ const { Chat } = await import(/* @vite-ignore */ CHAT_SDK_PACKAGE);
298
+ const sdkAdapter = this.sdkAdapter ?? (await this.buildSdkAdapter());
299
+ this.sdkAdapter = sdkAdapter;
300
+ this.chat = new Chat({
301
+ adapters: { [sdkAdapter.name]: sdkAdapter },
302
+ state: await this.buildState(),
303
+ // F4 finding 11: the IR owns the queue, so the SDK's is off by default
304
+ concurrency: this.extra('concurrency', 'concurrent'),
305
+ logger: this.extra('logger', 'silent'),
306
+ });
307
+ this.builtChat = true;
308
+ } catch (error) {
309
+ this.emit = null;
310
+ // a transient failure (a platform unreachable) is retried by the loop
311
+ if (error.transient) throw error;
312
+ // A missing platform package or credential is this ADAPTER not set
313
+ // up, not the daemon failing to start: reported down and not
314
+ // configured (§3), so the surface stays visibly unconnected, no retry
315
+ // is spent on it, and Hermes's preflight does not count it connected.
316
+ this.log('error', `chat-sdk ${this.platform}: ${error.message}`);
317
+ return { state: 'down', error: error.message, configured: false };
318
+ }
319
+ }
320
+ if (!this.handlersOn) {
321
+ // registered once: a retried connect must not answer each message twice
322
+ const handle = (thread, message) => this.handle(thread, message);
323
+ this.chat.onDirectMessage(handle);
324
+ this.chat.onNewMention(handle);
325
+ this.chat.onSubscribedMessage(handle);
326
+ // Hermes's free-response channels answer without a mention; only they admit an unmentioned message
327
+ if (this.discordRules()?.free.size) this.chat.onNewMessage(/[\s\S]*/, (thread, message) => this.handle(thread, message, { unmentioned: true }));
328
+ this.handlersOn = true;
329
+ }
330
+ try {
331
+ await this.chat.initialize();
332
+ } catch (error) {
333
+ // the SDK keeps a rejected initialize; a Chat this adapter built is
334
+ // rebuilt on the loop's retry
335
+ if (this.builtChat) { this.chat = null; this.handlersOn = false; }
336
+ throw error;
337
+ }
338
+ if (HERMES_PLATFORMS[String(this.extra('slug', this.platform))]?.gateway) this.listenGateway();
339
+ return { state: 'up' };
340
+ }
341
+
342
+ /**
343
+ * Discord's messages arrive only over its gateway websocket, which the
344
+ * package runs for a bounded duration per call (built for serverless crons);
345
+ * a resident daemon, as Hermes is, starts the next slice when one ends,
346
+ * until close.
347
+ */
348
+ listenGateway() {
349
+ if (this.gateway) return;
350
+ const abort = new AbortController();
351
+ this.gateway = abort;
352
+ const run = async () => {
353
+ let backoff = 0;
354
+ while (!abort.signal.aborted && typeof this.sdkAdapter?.startGatewayListener === 'function') {
355
+ const tasks = [];
356
+ const began = Date.now();
357
+ let why = null;
358
+ try {
359
+ const res = await this.sdkAdapter.startGatewayListener({ waitUntil: (task) => { tasks.push(task); } }, GATEWAY_SLICE_MS, abort.signal);
360
+ if (res && typeof res.status === 'number' && res.status >= 400) why = `the listener answered ${res.status}`;
361
+ await Promise.allSettled(tasks);
362
+ } catch (error) {
363
+ why = error.message;
364
+ }
365
+ if (abort.signal.aborted) break;
366
+ // a slice that ended well before its time (a refused login, a
367
+ // dropped socket) is retried with backoff, never in a tight loop
368
+ if (why || Date.now() - began < GATEWAY_SLICE_MS * 0.9) {
369
+ backoff = Math.min(backoff ? backoff * 2 : 5_000, 300_000);
370
+ this.log('warn', `chat-sdk ${this.platform}: gateway listener ended early (${why ?? 'before its slice'}); retrying in ${backoff / 1000}s`);
371
+ await new Promise((r) => {
372
+ const stop = () => { clearTimeout(t); r(); };
373
+ const t = setTimeout(() => { abort.signal.removeEventListener('abort', stop); r(); }, backoff);
374
+ t.unref?.();
375
+ abort.signal.addEventListener('abort', stop, { once: true });
376
+ });
377
+ } else backoff = 0;
378
+ }
379
+ };
380
+ void run().catch((error) => this.log('error', `chat-sdk ${this.platform}: gateway: ${error.message}`));
381
+ }
382
+
383
+ /**
384
+ * The one door from the SDK into the loop. Exported behaviour, not private:
385
+ * it builds an `inbound` payload and hands it to the loop's `emit`, which is
386
+ * the same callback `LoopbackAdapter` gets.
387
+ */
388
+ handle(thread, message, { unmentioned = false } = {}) {
389
+ if (!this.emit) throw new Error('chat-sdk adapter is not connected');
390
+ const inbound = toInbound({ profile: this.profile, thread, message });
391
+ // one message reaches several handlers (a mention in a free channel): it is one turn
392
+ const seen = (this.seen ??= new Set());
393
+ if (seen.has(inbound.message_id)) return undefined;
394
+ if (!this.admitDiscord(inbound, unmentioned)) return undefined;
395
+ seen.add(inbound.message_id);
396
+ if (seen.size > 512) seen.delete(seen.values().next().value);
397
+ this.threads.set(String(thread.id), thread);
398
+ return this.emit(inbound);
399
+ }
400
+
401
+ /**
402
+ * Hermes's Discord channel rules, from the profile's own `.env`: `DISCORD_ALLOWED_CHANNELS`,
403
+ * `DISCORD_IGNORED_CHANNELS`, `DISCORD_FREE_RESPONSE_CHANNELS` (comma lists, `*` for every channel).
404
+ * Null for any other platform.
405
+ */
406
+ discordRules() {
407
+ if (this.rules !== undefined) return this.rules;
408
+ if (String(this.extra('slug', this.platform)) !== 'discord') return (this.rules = null);
409
+ const file = this.profileDir ? join(this.profileDir, '.env') : null;
410
+ const env = file && existsSync(file) ? parseDotenv(readFileSync(file, 'utf8')) : new Map();
411
+ const list = (name) => new Set(String(env.get(name) ?? process.env[name] ?? '').split(',').map((v) => v.trim()).filter(Boolean));
412
+ return (this.rules = { allowed: list('DISCORD_ALLOWED_CHANNELS'), ignored: list('DISCORD_IGNORED_CHANNELS'), free: list('DISCORD_FREE_RESPONSE_CHANNELS') });
413
+ }
414
+
415
+ /** Whether Hermes's Discord adapter would take this message: DMs always; else not ignored, allowed, and mentioned unless free. */
416
+ admitDiscord(inbound, unmentioned) {
417
+ const rules = this.discordRules();
418
+ if (!rules || inbound.key.kind === 'dm') return true;
419
+ const ids = new Set([inbound.key.chat_id, inbound.key.thread_id].filter(Boolean).map(String));
420
+ const hit = (set) => set.has('*') || [...ids].some((id) => set.has(id) || [...set].some((c) => id.endsWith(`:${c}`)));
421
+ if (rules.ignored.size && hit(rules.ignored)) return false;
422
+ if (rules.allowed.size && !hit(rules.allowed)) return false;
423
+ if (unmentioned && !hit(rules.free)) return false;
424
+ return true;
425
+ }
426
+
427
+ /**
428
+ * `Deliver(obligation)` (§5). Returns the platform's handle so the loop can
429
+ * record `Obligation.posted.message_id` (§2.6) — the thing §4.2's
430
+ * edit-in-place would edit.
431
+ */
432
+ async send(target, content) {
433
+ const threadId = target.thread_id ? String(target.thread_id) : null;
434
+ const thread = threadId ? this.threads.get(threadId) : null;
435
+ if (!thread && !threadId && typeof this.sdkAdapter?.postChannelMessage !== 'function') {
436
+ throw new Error(`chat-sdk: ${this.platform} cannot post to a channel without a thread`);
437
+ }
438
+ // A platform that caps a message's length gets the text in parts, in order, as Hermes's own adapters send it
439
+ // (Discord refuses past 2000 characters, and a fire's report was cut off there). The first part is the handle.
440
+ const limit = MESSAGE_LIMIT[this.platform];
441
+ let first;
442
+ for (const [index, text] of (limit ? chunks(content.text ?? '', limit) : [content.text ?? '']).entries()) {
443
+ let sent;
444
+ if (thread) {
445
+ sent = content.reply_to && index === 0 ? await thread.reply(String(content.reply_to), text) : await thread.post(text);
446
+ } else if (threadId) {
447
+ sent = await this.sdkAdapter.postMessage(threadId, text);
448
+ } else {
449
+ sent = await this.sdkAdapter.postChannelMessage(this.channelId(String(target.chat_id)), text);
450
+ }
451
+ first ??= sent;
452
+ }
453
+ return first?.id ? { ok: true, message_id: String(first.id) } : { ok: true };
454
+ }
455
+
456
+ /**
457
+ * A chat as the Chat SDK names it. Hermes keeps a Discord channel as its bare id (`DISCORD_HOME_CHANNEL`,
458
+ * `/sethome`), and the Discord package posts only to its own `discord:<guild>:<channel>` form, which it
459
+ * builds with `encodeThreadId` and `@me` for an unknown guild (it posts by the channel part alone).
460
+ */
461
+ channelId(chat) {
462
+ const slug = String(this.extra('slug', this.platform));
463
+ if (slug !== 'discord' || chat.includes(':') || typeof this.sdkAdapter?.encodeThreadId !== 'function') return chat;
464
+ return this.sdkAdapter.encodeThreadId({ guildId: '@me', channelId: chat });
465
+ }
466
+
467
+ async close() {
468
+ this.gateway?.abort();
469
+ this.gateway = null;
470
+ this.emit = null;
471
+ this.threads.clear();
472
+ try { await this.chat?.shutdown(); } catch (error) { this.log('warn', `chat-sdk shutdown: ${error.message}`); }
473
+ if (this.tokenLock) { releaseTokenLock(this.tokenLock); this.tokenLock = null; }
474
+ }
475
+ }
@@ -0,0 +1,14 @@
1
+ // A message cut into parts a platform will take: at most `max` characters each, ending where a paragraph, else a
2
+ // line, else a word ends. Shared by every adapter whose platform caps a message's length.
3
+ export function chunks(text, max) {
4
+ const parts = [];
5
+ let rest = text;
6
+ while (rest.length > max) {
7
+ const window = rest.slice(0, max);
8
+ const at = [window.lastIndexOf('\n\n'), window.lastIndexOf('\n'), window.lastIndexOf(' ')].find((i) => i > max / 2) ?? max;
9
+ parts.push(rest.slice(0, at).trimEnd());
10
+ rest = rest.slice(at).trimStart();
11
+ }
12
+ parts.push(rest);
13
+ return parts;
14
+ }
@@ -0,0 +1,37 @@
1
+ // The loopback adapter: an in-memory channel for tests and for the week
2
+ // comparison (docs/ORCHESTRATOR-IR.md §7). Inbound is injected by the test;
3
+ // outbound lands in an outbox the test reads. Same trait as every adapter:
4
+ // connect(emit) / send(target, content) / close()
5
+
6
+ export class LoopbackAdapter {
7
+ constructor({ platform = 'loopback', profile = 'default', failSends = 0 } = {}) {
8
+ this.platform = platform;
9
+ this.profile = profile;
10
+ this.outbox = [];
11
+ this.failSends = failSends;
12
+ this.emit = null;
13
+ }
14
+
15
+ async connect(emit) {
16
+ this.emit = emit;
17
+ return { state: 'up' };
18
+ }
19
+
20
+ /** Inject an inbound message as the platform would deliver it. */
21
+ inject({ kind = 'dm', chat_id = '1', thread_id, sender = 'u1', sender_name = null, text, at, guild_id = null }) {
22
+ if (!this.emit) throw new Error('loopback not connected');
23
+ const key = { platform: this.platform, kind, chat_id: String(chat_id) };
24
+ if (thread_id) key.thread_id = String(thread_id);
25
+ return this.emit({ profile: this.profile, key, sender, sender_name, text, at, guild_id });
26
+ }
27
+
28
+ async send(target, content) {
29
+ if (this.failSends > 0) { this.failSends -= 1; throw new Error('loopback send failed (injected)'); }
30
+ this.outbox.push({ target: { ...target }, text: content.text, at: content.at ?? null });
31
+ return { ok: true };
32
+ }
33
+
34
+ async close() {
35
+ this.emit = null;
36
+ }
37
+ }