@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,615 @@
|
|
|
1
|
+
// The `api_server`-compatible HTTP surface (ORC-9): Hermes's own client API,
|
|
2
|
+
// served by this loop, behind the same three-method adapter trait
|
|
3
|
+
// (`connect(emit)` / `send(target, content)` / `close()`).
|
|
4
|
+
//
|
|
5
|
+
// EVERY route below quotes the Hermes handler it was matched against, from
|
|
6
|
+
// `~/.hermes/hermes-agent/gateway/platforms/api_server.py` and its
|
|
7
|
+
// `api_server_runs.py` sibling at the pinned 0.21.0 tree. Where Hermes's JSON
|
|
8
|
+
// carries a field the IR has no source for, the field is returned EMPTY and
|
|
9
|
+
// named in README.md § "The api_server surface"; nothing is invented.
|
|
10
|
+
//
|
|
11
|
+
// WHERE IT LISTENS. Its own `node:http` server on
|
|
12
|
+
// `platforms.api_server.extra.host|port` — exactly Hermes's keys
|
|
13
|
+
// (`api_server.py:1515-1519`: `extra.get("host", …)` / `extra.get("port")`).
|
|
14
|
+
// It is NOT mounted on the loop's webhook listener: that one binds
|
|
15
|
+
// `gateway.webhook_port`, a different Hermes key with a different meaning, and
|
|
16
|
+
// hosting both on one socket would make one port answer for two configured
|
|
17
|
+
// addresses. `webhook.mjs` is untouched.
|
|
18
|
+
//
|
|
19
|
+
// AUTH. `Authorization: Bearer <key>` against `platforms.api_server.extra.key`
|
|
20
|
+
// — Hermes's `_check_auth` (`api_server.py:1916-1968`), whose expected key is
|
|
21
|
+
// `extra.get("key", API_SERVER_KEY)` (`:1520`). `X-Hermes-Session-Key` is NOT
|
|
22
|
+
// an auth credential in Hermes: `_parse_session_key_header` (`:2403-2440`)
|
|
23
|
+
// reads it as the caller-declared CONVERSATION scope and requires the API key
|
|
24
|
+
// to be configured before honouring it. It is read with that meaning here.
|
|
25
|
+
//
|
|
26
|
+
// NO PRIVATE PATH (§3). Everything this surface makes the loop do enters as an
|
|
27
|
+
// `inbound` event: a chat is an inbound, a run is an inbound on a fresh
|
|
28
|
+
// binding, a steer is an inbound queued behind the running turn (§4.1 step 5),
|
|
29
|
+
// a stop is the `/reset` chat command (§2.7), and an approval is the numeric
|
|
30
|
+
// reply §4.2's permission prompt asks for. The surface reads runtime state
|
|
31
|
+
// (§2.8) to build its event stream; it never writes it.
|
|
32
|
+
|
|
33
|
+
import { surfaceKeyString } from '../ir.mjs';
|
|
34
|
+
import { leaseId } from '../reducer.mjs';
|
|
35
|
+
|
|
36
|
+
/** Hermes's own defaults (`api_server.py` DEFAULT_HOST / DEFAULT_PORT). */
|
|
37
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
38
|
+
export const DEFAULT_PORT = 8090;
|
|
39
|
+
|
|
40
|
+
const MAX_BODY = 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
/** `POST /api/sessions` mints `api_<epoch>_<hex>` when the body names no id (`api_server.py:4443`). */
|
|
43
|
+
function mintSessionId(at, n) {
|
|
44
|
+
return `api_${Math.floor(Date.parse(at) / 1000)}_${n.toString(16).padStart(8, '0')}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Hermes's OpenAI-shaped error envelope (`_openai_error`). */
|
|
48
|
+
function errorBody(message, code = null, type = 'invalid_request_error') {
|
|
49
|
+
const error = { message, type };
|
|
50
|
+
if (code) error.code = code;
|
|
51
|
+
return { error };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ApiServerAdapter {
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} o
|
|
57
|
+
* @param {string} [o.platform] always `api_server`: the block name IS the platform (§2.4)
|
|
58
|
+
* @param {string} [o.profile] the profile that owns the block
|
|
59
|
+
* @param {object} [o.channel] the loaded `ChannelConfig`; `extra.key` is a `{dotenv}` ref
|
|
60
|
+
* @param {Map<string,string>} [o.vault] resolved at `connect`, never earlier
|
|
61
|
+
* @param {object} o.state the OrchestratorState: read for §2.8 runtime and for bindings
|
|
62
|
+
* @param {() => string} [o.clock]
|
|
63
|
+
* @param {(level: string, message: string) => void} [o.log]
|
|
64
|
+
*/
|
|
65
|
+
constructor({ platform = 'api_server', profile = 'default', channel = null, vault = new Map(), state = null, clock = null, log = () => {} }) {
|
|
66
|
+
this.platform = platform;
|
|
67
|
+
this.profile = profile;
|
|
68
|
+
this.channel = channel;
|
|
69
|
+
this.vault = vault;
|
|
70
|
+
this.state = state;
|
|
71
|
+
this.clock = clock ?? (() => new Date().toISOString());
|
|
72
|
+
this.log = log;
|
|
73
|
+
this.emit = null;
|
|
74
|
+
this.server = null;
|
|
75
|
+
this.port = null;
|
|
76
|
+
this.key = null;
|
|
77
|
+
this.counter = 0;
|
|
78
|
+
/** session id -> the Hermes-shaped session row this surface owns. */
|
|
79
|
+
this.sessions = new Map();
|
|
80
|
+
/** run id -> the pollable run status (`hermes.run`). */
|
|
81
|
+
this.runs = new Map();
|
|
82
|
+
/** keyString -> [resolve] for the `/chat` calls waiting on this turn's answer. */
|
|
83
|
+
this.waiting = new Map();
|
|
84
|
+
/** run id -> Set<ServerResponse> subscribed to the SSE stream. */
|
|
85
|
+
this.streams = new Map();
|
|
86
|
+
/** run id -> what the stream has already reported, so a poll never repeats itself. */
|
|
87
|
+
this.watermarks = new Map();
|
|
88
|
+
this.poll = null;
|
|
89
|
+
this.version = '0';
|
|
90
|
+
/** `node:crypto`'s constant-time compare, resolved at `connect` (this module imports no node module eagerly). */
|
|
91
|
+
this.timingSafeEqual = (a, b) => a.equals(b);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
extra(name, fallback = undefined) {
|
|
95
|
+
const e = this.channel?.extra ?? {};
|
|
96
|
+
return e[`extra.${name}`] ?? e[name] ?? fallback;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `extra.key`, a `${NAME}` reference, resolved from the vault (`.env`), then the environment, as Hermes does. */
|
|
100
|
+
resolveKey() {
|
|
101
|
+
const ref = this.channel?.credentials?.['extra.key'] ?? this.channel?.credentials?.key ?? null;
|
|
102
|
+
if (!ref) return null;
|
|
103
|
+
const name = ref.dotenv ?? ref.env;
|
|
104
|
+
return this.vault.get(name) ?? process.env[name] ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The user id every request from this surface speaks as; the profile's access (Hermes's own stores) gates it like any other sender. */
|
|
108
|
+
get sender() {
|
|
109
|
+
return String(this.extra('user_id', 'api'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ------------------------------------------------------------ the trait
|
|
113
|
+
|
|
114
|
+
async connect(emit) {
|
|
115
|
+
this.emit = emit;
|
|
116
|
+
this.key = this.resolveKey();
|
|
117
|
+
const { createServer } = await import('node:http');
|
|
118
|
+
const { readFileSync } = await import('node:fs');
|
|
119
|
+
const { fileURLToPath } = await import('node:url');
|
|
120
|
+
({ timingSafeEqual: this.timingSafeEqual } = await import('node:crypto'));
|
|
121
|
+
// `/v1/health` reports this package's real version, never a literal that
|
|
122
|
+
// goes stale the next time it is published.
|
|
123
|
+
try { this.version = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version; } catch { this.version = '0'; }
|
|
124
|
+
const host = String(this.extra('host', DEFAULT_HOST));
|
|
125
|
+
const port = Number(this.extra('port', DEFAULT_PORT));
|
|
126
|
+
// a request the router cannot handle (a malformed URL, bad percent-encoding,
|
|
127
|
+
// a close mid-request) is that request's 500, never the daemon's exit
|
|
128
|
+
this.server = createServer((req, res) => {
|
|
129
|
+
this.route(req, res).catch((error) => {
|
|
130
|
+
this.log?.('warn', `api-server: ${req.method} ${req.url}: ${error?.message ?? error}`);
|
|
131
|
+
if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'internal error' } })); }
|
|
132
|
+
else res.end();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
await new Promise((resolve, reject) => {
|
|
136
|
+
this.server.once('error', reject);
|
|
137
|
+
this.server.listen(port, host, () => { this.server.removeListener('error', reject); resolve(); });
|
|
138
|
+
});
|
|
139
|
+
this.server.unref?.();
|
|
140
|
+
this.port = this.server.address().port;
|
|
141
|
+
const every = Number(this.extra('stream_poll_ms', 10));
|
|
142
|
+
this.poll = setInterval(() => this.pump(), every);
|
|
143
|
+
this.poll.unref?.();
|
|
144
|
+
this.log('info', `api_server listening on http://${host}:${this.port}`);
|
|
145
|
+
return { state: 'up' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `Deliver(obligation)` (§5) for this platform: the loop's answer for a
|
|
150
|
+
* surface this server owns. It resolves whatever `/chat` call is waiting on
|
|
151
|
+
* that key, appends the assistant message to the session's transcript, and
|
|
152
|
+
* finishes the run when the key is a run's. Returns the handle the loop
|
|
153
|
+
* records as `Obligation.posted.message_id` (§2.6).
|
|
154
|
+
*/
|
|
155
|
+
async send(target, content) {
|
|
156
|
+
const text = content.text ?? '';
|
|
157
|
+
const keyString = surfaceKeyString(target);
|
|
158
|
+
const id = `apisrv-${++this.counter}`;
|
|
159
|
+
const at = content.at ?? this.clock();
|
|
160
|
+
const session = this.sessions.get(String(target.chat_id ?? ''));
|
|
161
|
+
if (session) session.messages.push({ id, session_id: session.id, role: 'assistant', content: text, timestamp: at });
|
|
162
|
+
const waiters = this.waiting.get(keyString);
|
|
163
|
+
if (waiters?.length) {
|
|
164
|
+
this.waiting.delete(keyString);
|
|
165
|
+
for (const resolve of waiters) resolve({ id, text, at });
|
|
166
|
+
}
|
|
167
|
+
// A run finishes on the turn's FINAL, not on any message the loop sends.
|
|
168
|
+
// §4.2 deletes `runtime.turns[lease]` before it queues the final's reply
|
|
169
|
+
// and keeps it while a permission prompt goes out mid-turn, so an empty
|
|
170
|
+
// turn slot is what tells the two apart — no new state, no new flag.
|
|
171
|
+
const lid = this.leaseFor(keyString);
|
|
172
|
+
const midTurn = Boolean(lid && this.state?.runtime?.turns?.[lid]);
|
|
173
|
+
const run = this.runs.get(String(target.chat_id ?? ''));
|
|
174
|
+
if (run && !midTurn && !['completed', 'failed', 'cancelled', 'stopping'].includes(run.status)) {
|
|
175
|
+
run.status = 'completed';
|
|
176
|
+
run.updated_at = Date.parse(at) / 1000;
|
|
177
|
+
run.last_event = 'run.completed';
|
|
178
|
+
run.output = text;
|
|
179
|
+
delete run.approval;
|
|
180
|
+
this.frame(run.run_id, { event: 'run.completed', run_id: run.run_id, timestamp: run.updated_at, output: text, usage: {} });
|
|
181
|
+
this.closeStream(run.run_id);
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, message_id: id };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async close() {
|
|
187
|
+
this.emit = null;
|
|
188
|
+
if (this.poll) { clearInterval(this.poll); this.poll = null; }
|
|
189
|
+
for (const id of [...this.streams.keys()]) this.closeStream(id);
|
|
190
|
+
for (const waiters of this.waiting.values()) for (const resolve of waiters) resolve(null);
|
|
191
|
+
this.waiting.clear();
|
|
192
|
+
if (this.server) { const s = this.server; this.server = null; await new Promise((r) => s.close(() => r())); }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ------------------------------------------------------------ the loop side
|
|
196
|
+
|
|
197
|
+
/** The surface key for one of this platform's conversations (§2.3). */
|
|
198
|
+
keyFor(chatId) {
|
|
199
|
+
return { platform: this.platform, kind: 'dm', chat_id: String(chatId) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The one door into the loop: an `inbound` event, same callback loopback gets. */
|
|
203
|
+
inbound(chatId, text, { message_id = null } = {}) {
|
|
204
|
+
if (!this.emit) throw new Error('api_server adapter is not connected');
|
|
205
|
+
return this.emit({
|
|
206
|
+
profile: this.profile,
|
|
207
|
+
key: this.keyFor(chatId),
|
|
208
|
+
sender: this.sender,
|
|
209
|
+
sender_name: null,
|
|
210
|
+
text,
|
|
211
|
+
message_id,
|
|
212
|
+
is_me: false,
|
|
213
|
+
is_bot: false,
|
|
214
|
+
mention: false,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Wait for the loop's reply on this key; `timeout_ms` from the block, else 120 s. */
|
|
219
|
+
answer(chatId) {
|
|
220
|
+
const keyString = surfaceKeyString(this.keyFor(chatId));
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
const list = this.waiting.get(keyString) ?? [];
|
|
223
|
+
list.push(resolve);
|
|
224
|
+
this.waiting.set(keyString, list);
|
|
225
|
+
const ms = Number(this.extra('timeout_ms', 120_000));
|
|
226
|
+
const t = setTimeout(() => {
|
|
227
|
+
const held = this.waiting.get(keyString) ?? [];
|
|
228
|
+
const at = held.indexOf(resolve);
|
|
229
|
+
if (at >= 0) held.splice(at, 1);
|
|
230
|
+
resolve(null);
|
|
231
|
+
}, ms);
|
|
232
|
+
t.unref?.();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The profile whose runtime is serving this key — routes may move it (§2.3). */
|
|
237
|
+
leaseFor(keyString) {
|
|
238
|
+
const rt = this.state?.runtime ?? {};
|
|
239
|
+
for (const name of Object.keys(this.state?.profiles ?? {})) {
|
|
240
|
+
const lid = leaseId(name, keyString);
|
|
241
|
+
if (rt.turns?.[lid] || rt.leases?.[lid]) return lid;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The pending permission request on this key, if the worker raised one (§2.8). */
|
|
247
|
+
pendingRequest(keyString) {
|
|
248
|
+
const pending = this.state?.runtime?.pending_requests ?? {};
|
|
249
|
+
for (const [id, p] of Object.entries(pending)) if (p.keyString === keyString) return { id, ...p };
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The run event stream, derived from §2.8 runtime state on a timer. The loop
|
|
255
|
+
* publishes no event bus and ORC-9 may not add one, so the stream watches
|
|
256
|
+
* the three things the runtime already holds for a live turn — the turn's
|
|
257
|
+
* accumulating text buffer, the pending permission requests, and (through
|
|
258
|
+
* `send`) the obligation the turn's `final` produces.
|
|
259
|
+
*/
|
|
260
|
+
pump() {
|
|
261
|
+
const stamp = Date.parse(this.clock()) / 1000;
|
|
262
|
+
for (const [runId, run] of this.runs) {
|
|
263
|
+
if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') continue;
|
|
264
|
+
const keyString = surfaceKeyString(this.keyFor(runId));
|
|
265
|
+
const mark = this.watermarks.get(runId) ?? { delta: 0, request: null };
|
|
266
|
+
const lid = this.leaseFor(keyString);
|
|
267
|
+
const buffer = lid ? this.state?.runtime?.turns?.[lid]?.buffer ?? '' : '';
|
|
268
|
+
if (buffer.length > mark.delta) {
|
|
269
|
+
this.frame(runId, { event: 'message.delta', run_id: runId, timestamp: stamp, delta: buffer.slice(mark.delta) });
|
|
270
|
+
mark.delta = buffer.length;
|
|
271
|
+
}
|
|
272
|
+
const pending = this.pendingRequest(keyString);
|
|
273
|
+
if (pending && pending.id !== mark.request) {
|
|
274
|
+
mark.request = pending.id;
|
|
275
|
+
run.status = 'waiting_for_approval';
|
|
276
|
+
run.last_event = 'approval.request';
|
|
277
|
+
// `_approval_event_choices` shape (api_server_runs.py:760-767): the
|
|
278
|
+
// choices this loop can actually answer are the worker's own options.
|
|
279
|
+
run.updated_at = stamp;
|
|
280
|
+
run.approval = { event: 'approval.request', run_id: runId, request_id: pending.id, choices: (pending.options ?? []).map((o) => o.name ?? o.kind ?? '') };
|
|
281
|
+
this.frame(runId, { ...run.approval, timestamp: stamp });
|
|
282
|
+
}
|
|
283
|
+
this.watermarks.set(runId, mark);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ------------------------------------------------------------ SSE
|
|
288
|
+
|
|
289
|
+
frame(runId, payload) {
|
|
290
|
+
const body = `data: ${JSON.stringify(payload)}\n\n`;
|
|
291
|
+
for (const res of this.streams.get(runId) ?? []) { try { res.write(body); } catch { /* client gone */ } }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
closeStream(runId) {
|
|
295
|
+
for (const res of this.streams.get(runId) ?? []) { try { res.write(': stream closed\n\n'); res.end(); } catch { /* already gone */ } }
|
|
296
|
+
this.streams.delete(runId);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ------------------------------------------------------------ routing
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Hermes compares the bearer token with `hmac.compare_digest`
|
|
303
|
+
* (`api_server.py:1957`) — a constant-time compare, so a wrong key leaks no
|
|
304
|
+
* prefix through timing. `webhook.mjs`'s signature check does the same; this
|
|
305
|
+
* is that, for the same reason.
|
|
306
|
+
*/
|
|
307
|
+
authorized(req) {
|
|
308
|
+
if (!this.key) return true; // no key configured: Hermes's own no-key branch (`_check_auth`, api_server.py:1922-1926)
|
|
309
|
+
const header = String(req.headers.authorization ?? '');
|
|
310
|
+
if (!header.startsWith('Bearer ')) return false;
|
|
311
|
+
const given = Buffer.from(header.slice(7).trim());
|
|
312
|
+
const expected = Buffer.from(this.key);
|
|
313
|
+
return given.length === expected.length && this.timingSafeEqual(given, expected);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async route(req, res) {
|
|
317
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
318
|
+
const path = url.pathname;
|
|
319
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
320
|
+
const send = (status, body, headers = {}) => {
|
|
321
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
322
|
+
res.end(JSON.stringify(body));
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// GET /v1/health — `api_server.py:3277-3281`, the ONE route with no
|
|
326
|
+
// `_check_auth`. `{"status","platform","version"}`; `platform` names the
|
|
327
|
+
// server that answers, so it is this one, not "hermes-agent".
|
|
328
|
+
if (method === 'GET' && (path === '/v1/health' || path === '/health')) {
|
|
329
|
+
return send(200, { status: 'ok', platform: 'supercode-orchestrator', version: this.version });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!this.authorized(req)) {
|
|
333
|
+
// `_check_auth`'s own body (`api_server.py:1966-1968`).
|
|
334
|
+
return send(401, { error: { message: 'Invalid gateway API key (API_SERVER_KEY)', type: 'gateway_auth_error', code: 'gateway_auth_failed' } });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// GET /v1/capabilities — `api_server.py:3427-3475`. Every flag is this
|
|
338
|
+
// loop's own answer; a surface it does not serve reads `false`, never a
|
|
339
|
+
// copy of Hermes's `true`.
|
|
340
|
+
if (method === 'GET' && path === '/v1/capabilities') {
|
|
341
|
+
return send(200, {
|
|
342
|
+
object: 'hermes.api_server.capabilities',
|
|
343
|
+
platform: 'supercode-orchestrator',
|
|
344
|
+
model: '',
|
|
345
|
+
auth: { type: 'bearer', required: Boolean(this.key) },
|
|
346
|
+
runtime: {
|
|
347
|
+
mode: 'server_agent',
|
|
348
|
+
tool_execution: 'server',
|
|
349
|
+
split_runtime: false,
|
|
350
|
+
description: 'The orchestrator opens a binding on this surface and drives the profile\'s worker through its own door (docs/ORCHESTRATOR-IR.md §4.1).',
|
|
351
|
+
},
|
|
352
|
+
features: {
|
|
353
|
+
chat_completions: false,
|
|
354
|
+
chat_completions_streaming: false,
|
|
355
|
+
responses_api: false,
|
|
356
|
+
responses_streaming: false,
|
|
357
|
+
run_submission: true,
|
|
358
|
+
runs_idempotency: { supported: false, durable: false, retention_seconds: 0 },
|
|
359
|
+
run_status: true,
|
|
360
|
+
run_events_sse: true,
|
|
361
|
+
run_stop: true,
|
|
362
|
+
run_steer: true,
|
|
363
|
+
run_approval_response: true,
|
|
364
|
+
session_create: true,
|
|
365
|
+
session_chat: true,
|
|
366
|
+
session_chat_stream: false,
|
|
367
|
+
session_messages: true,
|
|
368
|
+
},
|
|
369
|
+
session_key_header: 'X-Hermes-Session-Key',
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// POST /api/sessions — `api_server.py:4422-4541`. Body `{id|session_id,
|
|
374
|
+
// title, source, system_prompt, model}`; 409 on an id that exists; 201
|
|
375
|
+
// `{"object":"hermes.session","session":{…}}` shaped by `_session_response`
|
|
376
|
+
// (`:4283-4302`).
|
|
377
|
+
if (method === 'POST' && path === '/api/sessions') {
|
|
378
|
+
const body = await this.readJson(req);
|
|
379
|
+
if (body === null) return send(400, errorBody('Invalid JSON in request body'));
|
|
380
|
+
const at = this.clock();
|
|
381
|
+
const raw = body.id ?? body.session_id;
|
|
382
|
+
const id = raw ? String(raw).trim() : mintSessionId(at, ++this.counter);
|
|
383
|
+
if (!id || /[\r\n\0]/.test(id)) return send(400, errorBody('Invalid session ID', 'invalid_session_id'));
|
|
384
|
+
if (this.sessions.has(id)) return send(409, errorBody(`Session already exists: ${id}`, 'session_exists'));
|
|
385
|
+
this.sessions.set(id, {
|
|
386
|
+
id, source: String(body.source ?? 'api_server'), title: body.title ?? null,
|
|
387
|
+
started_at: Date.parse(at) / 1000, messages: [], system_prompt: body.system_prompt ?? null,
|
|
388
|
+
});
|
|
389
|
+
return send(201, { object: 'hermes.session', session: this.sessionResponse(id) });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const chat = /^\/api\/sessions\/([^/]+)\/chat$/.exec(path);
|
|
393
|
+
// POST /api/sessions/{id}/chat — `api_server.py:4720-4833`. Body's user
|
|
394
|
+
// turn is `message` ∥ `input` (`_session_chat_user_message`, `:931-943`);
|
|
395
|
+
// the reply is `{"object":"hermes.session.chat.completion","session_id",
|
|
396
|
+
// "message":{"role":"assistant","content"},"usage","runtime"}` with the
|
|
397
|
+
// `X-Hermes-Session-Id` header (`:4804-4833`).
|
|
398
|
+
if (method === 'POST' && chat) {
|
|
399
|
+
const id = decodeURIComponent(chat[1]);
|
|
400
|
+
if (!this.sessions.has(id)) return send(404, errorBody(`Session not found: ${id}`, 'session_not_found'));
|
|
401
|
+
const body = await this.readJson(req);
|
|
402
|
+
if (body === null) return send(400, errorBody('Invalid JSON in request body'));
|
|
403
|
+
const text = body.message ?? body.input;
|
|
404
|
+
if (typeof text !== 'string' || !text.trim()) return send(400, errorBody("Missing 'message' field", 'missing_message'));
|
|
405
|
+
const session = this.sessions.get(id);
|
|
406
|
+
const at = this.clock();
|
|
407
|
+
session.messages.push({ id: `apisrv-in-${++this.counter}`, session_id: id, role: 'user', content: text, timestamp: at });
|
|
408
|
+
const waiter = this.answer(id);
|
|
409
|
+
await this.inbound(id, text);
|
|
410
|
+
const answer = await waiter;
|
|
411
|
+
const headers = { 'X-Hermes-Session-Id': id };
|
|
412
|
+
const declared = String(req.headers['x-hermes-session-key'] ?? '').trim();
|
|
413
|
+
if (declared) headers['X-Hermes-Session-Key'] = declared;
|
|
414
|
+
return send(200, {
|
|
415
|
+
object: 'hermes.session.chat.completion',
|
|
416
|
+
session_id: id,
|
|
417
|
+
message: { role: 'assistant', content: answer?.text ?? '' },
|
|
418
|
+
usage: {}, // EMPTY: the IR records no token accounting (README)
|
|
419
|
+
runtime: {}, // EMPTY: the IR records no provider/model routing (README)
|
|
420
|
+
}, headers);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const messages = /^\/api\/sessions\/([^/]+)\/messages$/.exec(path);
|
|
424
|
+
// GET /api/sessions/{id}/messages — `api_server.py:4613-4670`.
|
|
425
|
+
// `{"object":"list","session_id","data":[…],"pagination":{limit,offset,
|
|
426
|
+
// order,returned}}`; rows shaped by `_message_response` (`:4305-4312`).
|
|
427
|
+
if (method === 'GET' && messages) {
|
|
428
|
+
const id = decodeURIComponent(messages[1]);
|
|
429
|
+
const session = this.sessions.get(id);
|
|
430
|
+
if (!session) return send(404, errorBody(`Session not found: ${id}`, 'session_not_found'));
|
|
431
|
+
const rawLimit = url.searchParams.get('limit');
|
|
432
|
+
const rawOffset = url.searchParams.get('offset') ?? '0';
|
|
433
|
+
const order = url.searchParams.get('order');
|
|
434
|
+
if (order !== null && order !== 'oldest' && order !== 'latest') return send(400, errorBody('order must be one of: oldest, latest', 'invalid_pagination'));
|
|
435
|
+
const offset = Number(rawOffset);
|
|
436
|
+
const requested = rawLimit === null ? null : Number(rawLimit);
|
|
437
|
+
if (!Number.isInteger(offset) || offset < 0 || (requested !== null && (!Number.isInteger(requested) || requested < 0))) {
|
|
438
|
+
return send(400, errorBody('limit and offset must be non-negative integers', 'invalid_pagination'));
|
|
439
|
+
}
|
|
440
|
+
const limit = requested === null ? 500 : Math.min(requested, 500);
|
|
441
|
+
const rows = session.messages.slice(offset, offset + limit);
|
|
442
|
+
return send(200, {
|
|
443
|
+
object: 'list',
|
|
444
|
+
session_id: id,
|
|
445
|
+
data: rows.map((m) => ({
|
|
446
|
+
id: m.id, session_id: m.session_id, role: m.role, content: m.content,
|
|
447
|
+
// EMPTY, all four: the IR keeps no per-message tool or token record
|
|
448
|
+
tool_call_id: null, tool_calls: null, tool_name: null, token_count: null,
|
|
449
|
+
timestamp: m.timestamp, finish_reason: null, reasoning: null,
|
|
450
|
+
})),
|
|
451
|
+
pagination: { limit, offset, order: order ?? (requested === null ? 'latest' : 'oldest'), returned: rows.length },
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// POST /v1/runs — `api_server_runs.py:405-1038`. `input` is required
|
|
456
|
+
// (`:483-485`); a fresh run answers 202 `{"run_id","status":"started",
|
|
457
|
+
// "replayed":false}` (`:1034-1038`).
|
|
458
|
+
if (method === 'POST' && path === '/v1/runs') {
|
|
459
|
+
const body = await this.readJson(req);
|
|
460
|
+
if (body === null) return send(400, errorBody('Invalid JSON'));
|
|
461
|
+
const raw = body.input;
|
|
462
|
+
const text = typeof raw === 'string' ? raw : Array.isArray(raw) ? String(raw[raw.length - 1]?.content ?? '') : '';
|
|
463
|
+
if (!text) return send(400, errorBody("Missing 'input' field"));
|
|
464
|
+
const at = this.clock();
|
|
465
|
+
const runId = `run_${Date.parse(at)}_${(++this.counter).toString(36)}`;
|
|
466
|
+
this.runs.set(runId, {
|
|
467
|
+
object: 'hermes.run', run_id: runId, status: 'running',
|
|
468
|
+
created_at: Date.parse(at) / 1000, updated_at: Date.parse(at) / 1000,
|
|
469
|
+
session_id: runId, // the run IS its binding: one job-less turn on a fresh surface
|
|
470
|
+
model: '', // EMPTY: the IR does not name the provider model here
|
|
471
|
+
last_event: 'run.started', output: null, error: null,
|
|
472
|
+
});
|
|
473
|
+
this.frame(runId, { event: 'run.started', run_id: runId, timestamp: Date.parse(at) / 1000 });
|
|
474
|
+
void this.inbound(runId, text);
|
|
475
|
+
return send(202, { run_id: runId, status: 'started', replayed: false });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const run = /^\/v1\/runs\/([^/]+)(\/(events|approval|steer|stop))?$/.exec(path);
|
|
479
|
+
if (run) {
|
|
480
|
+
const runId = decodeURIComponent(run[1]);
|
|
481
|
+
const record = this.runs.get(runId);
|
|
482
|
+
if (!record) return send(404, errorBody(`Run not found: ${runId}`, 'run_not_found'));
|
|
483
|
+
const tail = run[3] ?? null;
|
|
484
|
+
|
|
485
|
+
// GET /v1/runs/{run_id} — `api_server_runs.py:1074-1105`: the pollable
|
|
486
|
+
// status object `_set_run_status` builds (`:116-137`).
|
|
487
|
+
if (method === 'GET' && tail === null) return send(200, record);
|
|
488
|
+
|
|
489
|
+
// GET /v1/runs/{run_id}/events — `api_server_runs.py:1108-1170`:
|
|
490
|
+
// `text/event-stream`, `data: <json>` frames, `: stream closed` at the end.
|
|
491
|
+
if (method === 'GET' && tail === 'events') {
|
|
492
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
|
|
493
|
+
const set = this.streams.get(runId) ?? new Set();
|
|
494
|
+
set.add(res);
|
|
495
|
+
this.streams.set(runId, set);
|
|
496
|
+
// A late subscriber is caught up on the lifecycle it missed: the run's
|
|
497
|
+
// start, the approval it is blocked on, and its terminal event. Text
|
|
498
|
+
// deltas are live-only — the loop keeps no transcript to replay.
|
|
499
|
+
res.write(`data: ${JSON.stringify({ event: 'run.started', run_id: runId, timestamp: record.created_at })}\n\n`);
|
|
500
|
+
if (record.approval) res.write(`data: ${JSON.stringify({ ...record.approval, timestamp: record.updated_at })}\n\n`);
|
|
501
|
+
if (record.status === 'completed') { res.write(`data: ${JSON.stringify({ event: 'run.completed', run_id: runId, timestamp: record.updated_at, output: record.output ?? '', usage: {} })}\n\n`); this.closeStream(runId); }
|
|
502
|
+
req.on('close', () => { this.streams.get(runId)?.delete(res); });
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// POST /v1/runs/{run_id}/approval — `api_server_runs.py:1174-1295`.
|
|
507
|
+
// Choices `once|session|always|deny` with `approve|approved|allow`
|
|
508
|
+
// aliasing to `once` (`:1201-1203`); 409 `approval_not_active` when no
|
|
509
|
+
// approval session is open (`:1250-1258`) and `approval_not_pending`
|
|
510
|
+
// when nothing is waiting (`:1276-1284`); the body is
|
|
511
|
+
// `{"object":"hermes.run.approval_response","run_id","choice","resolved"}`.
|
|
512
|
+
if (method === 'POST' && tail === 'approval') {
|
|
513
|
+
const body = await this.readJson(req);
|
|
514
|
+
if (body === null) return send(400, errorBody('Invalid JSON'));
|
|
515
|
+
const alias = { approve: 'once', approved: 'once', allow: 'once' };
|
|
516
|
+
const asked = String(body.choice ?? '').trim().toLowerCase();
|
|
517
|
+
const choice = alias[asked] ?? asked;
|
|
518
|
+
if (!['once', 'session', 'always', 'deny'].includes(choice)) {
|
|
519
|
+
return send(400, errorBody('Invalid approval choice; expected one of: always, deny, once, session', 'invalid_approval_choice'));
|
|
520
|
+
}
|
|
521
|
+
const keyString = surfaceKeyString(this.keyFor(runId));
|
|
522
|
+
const pending = this.pendingRequest(keyString);
|
|
523
|
+
if (!pending) return send(409, errorBody(`Run has no pending approval: ${runId}`, 'approval_not_pending'));
|
|
524
|
+
// §4.2's relay asks the surface for a NUMBER; that numeric reply is the
|
|
525
|
+
// only door into `pending_requests`, so the Hermes choice is resolved
|
|
526
|
+
// to the worker's own option and answered as that number.
|
|
527
|
+
const options = pending.options ?? [];
|
|
528
|
+
const want = choice === 'deny'
|
|
529
|
+
? options.findIndex((o) => /reject|deny/i.test(o.kind ?? '') || /deny|reject/i.test(o.name ?? ''))
|
|
530
|
+
: options.findIndex((o) => /allow/i.test(o.kind ?? '') || /allow/i.test(o.name ?? ''));
|
|
531
|
+
const index = want >= 0 ? want : 0;
|
|
532
|
+
await this.inbound(runId, String(index + 1));
|
|
533
|
+
record.status = 'running';
|
|
534
|
+
record.last_event = 'approval.responded';
|
|
535
|
+
delete record.approval;
|
|
536
|
+
this.frame(runId, { event: 'approval.responded', run_id: runId, timestamp: Date.parse(this.clock()) / 1000, choice, resolved: 1 });
|
|
537
|
+
return send(200, { object: 'hermes.run.approval_response', run_id: runId, choice, resolved: 1 });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// POST /v1/runs/{run_id}/steer — `api_server_runs.py:1312-1386`. Text is
|
|
541
|
+
// `input` ∥ `message` ∥ `text` (`:1354`); only a `running` run accepts it
|
|
542
|
+
// (`:1341-1350`); body `{"object":"hermes.run.steer","run_id","accepted"}`.
|
|
543
|
+
if (method === 'POST' && tail === 'steer') {
|
|
544
|
+
const body = await this.readJson(req);
|
|
545
|
+
if (body === null) return send(400, errorBody('Invalid JSON'));
|
|
546
|
+
const text = String(body.input ?? body.message ?? body.text ?? '').trim();
|
|
547
|
+
if (!text) return send(400, errorBody("Missing non-empty steer text; expected 'input', 'message', or 'text'.", 'invalid_steer_input'));
|
|
548
|
+
if (record.status !== 'running') return send(409, errorBody(`Run is not currently accepting steer input: ${runId}`, 'run_not_accepting_steer'));
|
|
549
|
+
// §4.1 step 5: an inbound arriving during a running turn is queued
|
|
550
|
+
// behind it and sent when it ends — the IR's own steer.
|
|
551
|
+
await this.inbound(runId, text);
|
|
552
|
+
record.last_event = 'run.steered';
|
|
553
|
+
this.frame(runId, { event: 'run.steered', run_id: runId, timestamp: Date.parse(this.clock()) / 1000, accepted: true });
|
|
554
|
+
return send(200, { object: 'hermes.run.steer', run_id: runId, accepted: true });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// POST /v1/runs/{run_id}/stop — `api_server_runs.py:1390-1455`. A run
|
|
558
|
+
// already in a terminal state answers with its status (`:1425-1432`);
|
|
559
|
+
// otherwise `{"run_id","status":"stopping"}` (`:1455`).
|
|
560
|
+
if (method === 'POST' && tail === 'stop') {
|
|
561
|
+
if (['completed', 'failed', 'cancelled', 'interrupted'].includes(record.status)) return send(200, record);
|
|
562
|
+
record.status = 'stopping';
|
|
563
|
+
record.last_event = 'run.stopping';
|
|
564
|
+
record.updated_at = Date.parse(this.clock()) / 1000;
|
|
565
|
+
// §2.7's `/reset` is the loop's own "abandon this conversation": it
|
|
566
|
+
// ends the binding, which emits `runtime_close` for its worker (§4.5).
|
|
567
|
+
// Once that lands the run is terminal — Hermes's own `run.cancelled`
|
|
568
|
+
// (`api_server_runs.py:723`), not a `stopping` that never resolves.
|
|
569
|
+
await this.inbound(runId, '/reset');
|
|
570
|
+
record.status = 'cancelled';
|
|
571
|
+
record.last_event = 'run.cancelled';
|
|
572
|
+
record.updated_at = Date.parse(this.clock()) / 1000;
|
|
573
|
+
this.frame(runId, { event: 'run.cancelled', run_id: runId, timestamp: record.updated_at });
|
|
574
|
+
this.closeStream(runId);
|
|
575
|
+
return send(200, { run_id: runId, status: 'stopping' });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return send(404, errorBody(`Unknown route: ${method} ${path}`, 'not_found'));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** `_session_response`'s client-safe projection (`api_server.py:4283-4302`). */
|
|
583
|
+
sessionResponse(id) {
|
|
584
|
+
const s = this.sessions.get(id);
|
|
585
|
+
return {
|
|
586
|
+
id: s.id, source: s.source, title: s.title, started_at: s.started_at,
|
|
587
|
+
ended_at: null, end_reason: null,
|
|
588
|
+
message_count: s.messages.length,
|
|
589
|
+
// EMPTY, all of them: the IR keeps no token or cost accounting (README)
|
|
590
|
+
user_id: null, model: null, tool_call_count: null, input_tokens: null, output_tokens: null,
|
|
591
|
+
parent_session_id: null,
|
|
592
|
+
has_system_prompt: Boolean(s.system_prompt),
|
|
593
|
+
has_model_config: false,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
readJson(req) {
|
|
598
|
+
return new Promise((resolve) => {
|
|
599
|
+
const chunks = [];
|
|
600
|
+
let size = 0;
|
|
601
|
+
req.on('data', (c) => {
|
|
602
|
+
size += c.length;
|
|
603
|
+
if (size > MAX_BODY) { req.destroy(); resolve(null); return; }
|
|
604
|
+
chunks.push(c);
|
|
605
|
+
});
|
|
606
|
+
req.on('end', () => {
|
|
607
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
608
|
+
if (!body.trim()) { resolve({}); return; }
|
|
609
|
+
try { const parsed = JSON.parse(body); resolve(parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null); }
|
|
610
|
+
catch { resolve(null); }
|
|
611
|
+
});
|
|
612
|
+
req.on('error', () => resolve(null));
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|