@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.
- package/README.md +532 -0
- package/activation.mjs +270 -0
- package/adapters/api-server.mjs +615 -0
- package/adapters/chat-sdk.mjs +475 -0
- package/adapters/chunks.mjs +14 -0
- package/adapters/loopback.mjs +37 -0
- package/adapters/rh2.mjs +349 -0
- package/apply/doors.d.ts +2 -0
- package/apply/doors.mjs +292 -0
- package/apply/hermes_door.py +129 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.mjs +441 -0
- package/apply/state.mjs +142 -0
- package/bin/hermes-worker.mjs +11 -0
- package/bin/orchestrator.mjs +730 -0
- package/board.mjs +81 -0
- package/commands.mjs +37 -0
- package/cron.mjs +967 -0
- package/events.mjs +107 -0
- package/gates.mjs +281 -0
- package/hermes-cli.mjs +440 -0
- package/hermes-home.mjs +264 -0
- package/hermes-locks.mjs +188 -0
- package/hosted.mjs +81 -0
- package/index.d.ts +508 -0
- package/index.mjs +38 -0
- package/ir.mjs +205 -0
- package/kanban-worker.mjs +155 -0
- package/lease.mjs +51 -0
- package/loop.mjs +864 -0
- package/mcp/bridge.mjs +133 -0
- package/mcp/hermes-kanban-tools.json +477 -0
- package/mcp/hermes-tools.json +288 -0
- package/mcp/mount.mjs +161 -0
- package/mcp/server.mjs +160 -0
- package/mcp/tools.mjs +57 -0
- package/mcp/verbs.mjs +348 -0
- package/ontology.d.ts +557 -0
- package/operator.mjs +426 -0
- package/orchestration.mjs +166 -0
- package/package.json +51 -0
- package/reducer.mjs +792 -0
- package/webhook.mjs +263 -0
- package/worker-route.mjs +122 -0
|
@@ -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
|
+
}
|