@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
package/adapters/rh2.mjs
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// The RH2 adapter: a Runhuman Room conversation as an ordinary channel, the
|
|
2
|
+
// same three-method trait as every adapter — connect(emit) / send(target,
|
|
3
|
+
// content) / close(). The orchestrator holds an RH2 session (the bot
|
|
4
|
+
// principal's automation token), is a member of the Rooms it follows and a
|
|
5
|
+
// participant of their conversations, and reads and answers there the way it
|
|
6
|
+
// does in a Slack channel. RH2's own rule keeps a coding seat and this loop
|
|
7
|
+
// from answering each other: a seat is never woken by an agent's message, and
|
|
8
|
+
// this adapter turns only a person's line (kind `human`, or a trigger's
|
|
9
|
+
// `automation`) into an `inbound`.
|
|
10
|
+
//
|
|
11
|
+
// Doors (RH2 `/api/v3`, bearer + `x-rh2-organization`):
|
|
12
|
+
// GET /rooms the Rooms to follow
|
|
13
|
+
// GET /rooms/:room/view members (names), conversations (permissions, nextSequence)
|
|
14
|
+
// GET /rooms/:room/conversations/:c/messages?after=&limit=
|
|
15
|
+
// POST /rooms/:room/conversations/:c/messages {body, correlationId, kind: "agent", parentMessageId?}
|
|
16
|
+
// PATCH /rooms/:room/conversations/:c/messages/:id {body} — a streamed reply grows, a progress line updates, by edit
|
|
17
|
+
// POST /rooms/:room/conversations/:c/presence {state: "typing"} — best effort, forgotten on 404
|
|
18
|
+
//
|
|
19
|
+
// The block:
|
|
20
|
+
// platforms:
|
|
21
|
+
// rh2:
|
|
22
|
+
// enabled: true
|
|
23
|
+
// token: ${RH2_SESSION_TOKEN} # the bot principal's session (loads as credentials.token;
|
|
24
|
+
// # a nested `credentials:` block is not read as one)
|
|
25
|
+
// extra:
|
|
26
|
+
// api_url: https://pilot.runhuman.com # or the boundary's RH2 route
|
|
27
|
+
// organization: peakhealth
|
|
28
|
+
// headers: '{"x-twins-key":"…"}' # optional extra request headers (JSON)
|
|
29
|
+
// rooms: room_a,room_b # optional; default every active Room the bot is in
|
|
30
|
+
// principal_id: prn_… # the bot's own principal (is_me / mentions)
|
|
31
|
+
// poll_seconds: 2
|
|
32
|
+
//
|
|
33
|
+
// The surface key is `{platform: 'rh2', kind: 'channel', chat_id: '<roomId>'}`: a Room is one surface, so the agent it
|
|
34
|
+
// seats is one session however many of the Room's conversations it takes part in. A line wakes it where RH2 says the bot
|
|
35
|
+
// answers every line (its participation, `answers: all`) or where the line mentions it; the line arrives labelled with its
|
|
36
|
+
// conversation, and a reply goes back to the conversation of the Room's last line (`<roomId>/<conversationId>` names one)
|
|
37
|
+
// unless the agent begins it with a conversation's label, `[Work]`, which sends it there.
|
|
38
|
+
import { chunks } from './chunks.mjs';
|
|
39
|
+
|
|
40
|
+
const DEFAULT_POLL_MS = 2000;
|
|
41
|
+
const ROOM_REFRESH_MS = 30_000;
|
|
42
|
+
const MAX_BODY = 4000;
|
|
43
|
+
const MESSAGE_PAGE = 100;
|
|
44
|
+
const CLOCK_SKEW_MS = 60_000;
|
|
45
|
+
const ECHO_MEMORY = 500;
|
|
46
|
+
|
|
47
|
+
export class Rh2Adapter {
|
|
48
|
+
constructor({ platform = 'rh2', profile = 'default', channel = null, vault = new Map(), log = () => {}, fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
49
|
+
this.platform = platform;
|
|
50
|
+
this.profile = profile;
|
|
51
|
+
this.channel = channel;
|
|
52
|
+
this.vault = vault;
|
|
53
|
+
this.log = log;
|
|
54
|
+
this.fetch = fetchImpl;
|
|
55
|
+
this.sleep = sleep;
|
|
56
|
+
this.emit = null;
|
|
57
|
+
this.running = false;
|
|
58
|
+
/** conversation id -> { roomId, after } */
|
|
59
|
+
this.cursors = new Map();
|
|
60
|
+
/** room id -> { name, names: Map<principalId, displayName>, conversations: Map<id, name> } */
|
|
61
|
+
this.rooms = new Map();
|
|
62
|
+
/** roomId -> the conversation of the last line that woke the Room's session: where a reply goes. */
|
|
63
|
+
this.replyTo = new Map();
|
|
64
|
+
this.roomsRefreshedAt = 0;
|
|
65
|
+
this.ownMessageIds = [];
|
|
66
|
+
this.postedIn = new Map(); // message id -> the conversation it was posted in, for its edits
|
|
67
|
+
this.presenceDoor = null; // unknown until the first try
|
|
68
|
+
this.loop = null;
|
|
69
|
+
/** chat_id -> typing heartbeat timer: from a person's line until this adapter's reply (or three minutes). */
|
|
70
|
+
this.typingTimers = new Map();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The block's `extra:` map, whose keys the loader prefixes with `extra.`. */
|
|
74
|
+
extra(name, fallback = undefined) {
|
|
75
|
+
const e = this.channel?.extra ?? {};
|
|
76
|
+
return e[`extra.${name}`] ?? e[name] ?? fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The bearer: `credentials.token` resolved from the vault (a ref, never a value in the model). */
|
|
80
|
+
token() {
|
|
81
|
+
const ref = this.channel?.credentials?.token ?? this.channel?.credentials?.['extra.token'];
|
|
82
|
+
if (ref) {
|
|
83
|
+
const name = ref.dotenv ?? ref.env;
|
|
84
|
+
const value = this.vault.get(name) ?? (ref.env ? process.env[ref.env] : undefined);
|
|
85
|
+
if (value === undefined) throw new Error(`platforms.${this.platform}.token: ref ${name} is not in the vault`);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
const direct = this.extra('token') ?? process.env.RH2_SESSION_TOKEN;
|
|
89
|
+
if (!direct) throw new Error(`platforms.${this.platform}: no token (credentials.token or RH2_SESSION_TOKEN)`);
|
|
90
|
+
return direct;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
apiUrl() { return String(this.extra('api_url') ?? process.env.RH2_API_URL ?? '').replace(/\/+$/, ''); }
|
|
94
|
+
organization() { return String(this.extra('organization') ?? process.env.RH2_ORGANIZATION ?? ''); }
|
|
95
|
+
principalId() { return String(this.extra('principal_id') ?? process.env.RH2_PRINCIPAL_ID ?? ''); }
|
|
96
|
+
pollMs() { const n = Number(this.extra('poll_seconds', process.env.RH2_POLL_SECONDS)); return Number.isFinite(n) && n > 0 ? Math.max(500, n * 1000) : DEFAULT_POLL_MS; }
|
|
97
|
+
followedRooms() {
|
|
98
|
+
const raw = this.extra('rooms') ?? process.env.RH2_ROOMS ?? '';
|
|
99
|
+
return (Array.isArray(raw) ? raw : String(raw).split(',')).map((s) => String(s).trim()).filter(Boolean);
|
|
100
|
+
}
|
|
101
|
+
headers() {
|
|
102
|
+
let extra = {};
|
|
103
|
+
const raw = this.extra('headers') ?? process.env.RH2_API_HEADERS;
|
|
104
|
+
if (raw) {
|
|
105
|
+
try { extra = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { this.log('warn', `${this.platform}: headers is not a JSON object; ignored`); }
|
|
106
|
+
}
|
|
107
|
+
return { authorization: `Bearer ${this.token()}`, 'x-rh2-organization': this.organization(), 'content-type': 'application/json', ...extra };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async get(path, params = {}) {
|
|
111
|
+
const url = new URL(`${this.apiUrl()}/api/v3${path}`);
|
|
112
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
|
113
|
+
const res = await this.fetch(url, { headers: this.headers() });
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const error = new Error(`GET ${path} -> HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
116
|
+
error.status = res.status;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
return (await res.json()).data ?? {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async post(path, body) {
|
|
123
|
+
const res = await this.fetch(`${this.apiUrl()}/api/v3${path}`, { method: 'POST', headers: this.headers(), body: JSON.stringify(body) });
|
|
124
|
+
return res;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------- lifecycle
|
|
128
|
+
|
|
129
|
+
async connect(emit) {
|
|
130
|
+
this.emit = emit;
|
|
131
|
+
if (!this.apiUrl() || !this.organization()) throw new Error(`platforms.${this.platform}: api_url and organization are required`);
|
|
132
|
+
this.token();
|
|
133
|
+
await this.refreshRooms();
|
|
134
|
+
this.running = true;
|
|
135
|
+
this.loop = this.pollLoop();
|
|
136
|
+
this.log('info', `${this.platform}: following ${this.cursors.size} conversation(s) in ${this.organization()}`);
|
|
137
|
+
return { state: 'up' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async close() {
|
|
141
|
+
this.running = false;
|
|
142
|
+
this.emit = null;
|
|
143
|
+
for (const chatId of [...this.typingTimers.keys()]) this.stopTyping(chatId);
|
|
144
|
+
if (this.loop) { try { await this.loop; } catch { /* stopping */ } this.loop = null; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------- reads
|
|
148
|
+
|
|
149
|
+
async refreshRooms() {
|
|
150
|
+
const previous = this.roomsRefreshedAt || undefined;
|
|
151
|
+
this.roomsRefreshedAt = Date.now();
|
|
152
|
+
let roomIds = this.followedRooms();
|
|
153
|
+
if (roomIds.length === 0) {
|
|
154
|
+
const listing = await this.get('/rooms');
|
|
155
|
+
const rooms = Array.isArray(listing) ? listing : listing.rooms ?? [];
|
|
156
|
+
roomIds = rooms.map((entry) => entry.room ?? entry).filter((room) => room?.state === 'active' && room.roomId).map((room) => String(room.roomId));
|
|
157
|
+
}
|
|
158
|
+
const live = new Set();
|
|
159
|
+
for (const roomId of roomIds) {
|
|
160
|
+
let view;
|
|
161
|
+
try { view = await this.get(`/rooms/${roomId}/view`); } catch (error) {
|
|
162
|
+
// A refused credential is the bot's, not the Room's: following nothing while reporting up would hide it.
|
|
163
|
+
if (error.status === 401) throw error;
|
|
164
|
+
this.log('debug', `${this.platform}: Room ${roomId} not readable: ${error.message}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const room = view.room ?? {};
|
|
168
|
+
if (room.state && room.state !== 'active') continue;
|
|
169
|
+
const names = new Map((view.members ?? []).filter((m) => m.principalId).map((m) => [String(m.principalId), String(m.displayName ?? m.principalId)]));
|
|
170
|
+
const conversations = new Map();
|
|
171
|
+
for (const conversation of view.conversations ?? []) {
|
|
172
|
+
if (!conversation.conversationId || !(conversation.permissions ?? []).includes('conversation.read')) continue;
|
|
173
|
+
const participation = (conversation.agentParticipants ?? []).find((p) => String(p.principalId) === this.principalId());
|
|
174
|
+
conversations.set(conversation.conversationId, { name: String(conversation.name ?? conversation.conversationId), answers: participation?.answers ?? null });
|
|
175
|
+
live.add(conversation.conversationId);
|
|
176
|
+
if (!this.cursors.has(conversation.conversationId)) {
|
|
177
|
+
// At start, a conversation is read from its current end: no backlog is answered. One that becomes
|
|
178
|
+
// readable later is one the bot was just brought into, and whoever brought it in may already have
|
|
179
|
+
// spoken (a brief posted right after the join): it is read from the start, and a line counts from
|
|
180
|
+
// the previous refresh on, less a margin for this clock against RH2's.
|
|
181
|
+
this.cursors.set(conversation.conversationId, previous === undefined
|
|
182
|
+
? { roomId, after: Math.max(0, Number(conversation.nextSequence ?? 1) - 1) }
|
|
183
|
+
: { roomId, after: 0, since: previous - CLOCK_SKEW_MS });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.rooms.set(roomId, { name: String(room.name ?? roomId), names, conversations });
|
|
187
|
+
}
|
|
188
|
+
for (const id of [...this.cursors.keys()]) if (!live.has(id)) this.cursors.delete(id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async pollLoop() {
|
|
192
|
+
let failures = 0;
|
|
193
|
+
while (this.running) {
|
|
194
|
+
try {
|
|
195
|
+
if (Date.now() - this.roomsRefreshedAt >= ROOM_REFRESH_MS) await this.refreshRooms();
|
|
196
|
+
for (const [conversationId, cursor] of [...this.cursors.entries()]) {
|
|
197
|
+
const data = await this.get(`/rooms/${cursor.roomId}/conversations/${conversationId}/messages`, { after: cursor.after, limit: MESSAGE_PAGE });
|
|
198
|
+
const messages = data.messages ?? [];
|
|
199
|
+
let after = cursor.after;
|
|
200
|
+
for (const message of messages) {
|
|
201
|
+
const sequence = Number(message.sequence ?? 0);
|
|
202
|
+
if (sequence > after) after = sequence;
|
|
203
|
+
if (cursor.since !== undefined && !(Date.parse(message.createdAt ?? '') >= cursor.since)) continue;
|
|
204
|
+
this.onMessage(cursor.roomId, conversationId, message);
|
|
205
|
+
}
|
|
206
|
+
// The catch-up bound holds until the history is read through.
|
|
207
|
+
const since = cursor.since !== undefined && messages.length === MESSAGE_PAGE ? cursor.since : undefined;
|
|
208
|
+
this.cursors.set(conversationId, { roomId: cursor.roomId, after, ...(since === undefined ? {} : { since }) });
|
|
209
|
+
}
|
|
210
|
+
failures = 0;
|
|
211
|
+
await this.sleep(this.pollMs());
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!this.running) return;
|
|
214
|
+
failures += 1;
|
|
215
|
+
const delay = Math.min(60_000, 2000 * 2 ** Math.min(failures, 5));
|
|
216
|
+
this.log('warn', `${this.platform}: poll error ${error.message}; retrying in ${delay / 1000}s`);
|
|
217
|
+
await this.sleep(delay);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------- inbound
|
|
223
|
+
|
|
224
|
+
onMessage(roomId, conversationId, message) {
|
|
225
|
+
if (!this.emit) return;
|
|
226
|
+
const messageId = message.messageId === undefined ? null : String(message.messageId);
|
|
227
|
+
const author = String(message.authorPrincipalId ?? '');
|
|
228
|
+
const me = this.principalId();
|
|
229
|
+
const isMe = (messageId !== null && this.ownMessageIds.includes(messageId)) || (me !== '' && author === me);
|
|
230
|
+
const kind = String(message.kind ?? '');
|
|
231
|
+
// A person's line (or a trigger's) is a turn; an agent's or the Room's own notice is not.
|
|
232
|
+
if (!isMe && kind !== 'human' && kind !== 'automation') return;
|
|
233
|
+
const text = String(message.body ?? '');
|
|
234
|
+
if (!isMe && text.trim() === '') return;
|
|
235
|
+
const room = this.rooms.get(roomId);
|
|
236
|
+
const conversation = room?.conversations.get(conversationId);
|
|
237
|
+
const mentions = Array.isArray(message.mentions) ? message.mentions.map(String) : [];
|
|
238
|
+
const mentioned = me !== '' && mentions.includes(me);
|
|
239
|
+
// RH2 decides who hears a line: the bot answers where it takes every line, and elsewhere only when named.
|
|
240
|
+
if (!isMe && conversation?.answers !== 'all' && !mentioned) return;
|
|
241
|
+
if (!isMe) this.replyTo.set(roomId, conversationId);
|
|
242
|
+
// A person's line opens a turn: show typing here until the reply lands (the loop has no
|
|
243
|
+
// typing effect by design — the adapter, which knows both ends of the turn, carries it).
|
|
244
|
+
if (!isMe) this.startTyping(`${roomId}/${conversationId}`);
|
|
245
|
+
const labelled = room && room.conversations.size > 1 && conversation ? `[${conversation.name}] ${text}` : text;
|
|
246
|
+
this.emit({
|
|
247
|
+
profile: this.profile,
|
|
248
|
+
key: { platform: this.platform, kind: 'channel', chat_id: roomId },
|
|
249
|
+
sender: author || 'unknown',
|
|
250
|
+
sender_name: room?.names.get(author) ?? null,
|
|
251
|
+
text: labelled,
|
|
252
|
+
at: typeof message.createdAt === 'string' ? message.createdAt : new Date().toISOString(),
|
|
253
|
+
message_id: messageId,
|
|
254
|
+
is_me: isMe,
|
|
255
|
+
is_bot: kind === 'agent' || kind === 'automation',
|
|
256
|
+
mention: mentioned,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------- outbound
|
|
261
|
+
|
|
262
|
+
split(chatId) {
|
|
263
|
+
const [roomId, conversationId] = String(chatId).split('/');
|
|
264
|
+
if (conversationId) return { roomId, conversationId };
|
|
265
|
+
const last = this.replyTo.get(roomId);
|
|
266
|
+
if (last) return { roomId, conversationId: last };
|
|
267
|
+
for (const [id, cursor] of this.cursors) if (cursor.roomId === roomId) return { roomId, conversationId: id };
|
|
268
|
+
return { roomId, conversationId: undefined };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
startTyping(chatId) {
|
|
272
|
+
this.stopTyping(chatId);
|
|
273
|
+
const target = { platform: this.platform, kind: 'channel', chat_id: chatId };
|
|
274
|
+
const startedAt = Date.now();
|
|
275
|
+
const tick = () => { if (Date.now() - startedAt > 180_000) { this.stopTyping(chatId); return; } void this.typing(target); };
|
|
276
|
+
tick();
|
|
277
|
+
this.typingTimers.set(chatId, setInterval(tick, 5000));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
stopTyping(chatId) {
|
|
281
|
+
const timer = this.typingTimers.get(chatId);
|
|
282
|
+
if (timer) { clearInterval(timer); this.typingTimers.delete(chatId); }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Where a message goes: the conversation the agent names by beginning it with `[<name>]`, as each line reaches it
|
|
287
|
+
* labelled, or else the one it last answered. A turn a tester's line opened is still the agent's to narrate in Work.
|
|
288
|
+
*/
|
|
289
|
+
addressed(roomId, conversationId, text) {
|
|
290
|
+
const named = /^\[([^\]\n]{1,64})\]\s*/.exec(text);
|
|
291
|
+
const room = this.rooms.get(roomId);
|
|
292
|
+
if (!named || !room) return { conversationId, text };
|
|
293
|
+
for (const [id, conversation] of room.conversations) {
|
|
294
|
+
if (conversation.name.toLowerCase() === named[1].trim().toLowerCase()) return { conversationId: id, text: text.slice(named[0].length) };
|
|
295
|
+
}
|
|
296
|
+
return { conversationId, text };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async send(target, content) {
|
|
300
|
+
this.stopTyping(String(target.chat_id));
|
|
301
|
+
const split = this.split(target.chat_id);
|
|
302
|
+
if (!split.conversationId) throw new Error(`${this.platform}: no conversation for ${target.chat_id}`);
|
|
303
|
+
const { roomId } = split;
|
|
304
|
+
const { conversationId, text } = this.addressed(roomId, split.conversationId, String(content.text ?? ''));
|
|
305
|
+
// A reply longer than one line holds goes out as consecutive lines, split where a paragraph (or a line) ends.
|
|
306
|
+
let messageId;
|
|
307
|
+
for (const [index, part] of chunks(text, MAX_BODY).entries()) {
|
|
308
|
+
const body = { body: part, correlationId: `orchestrator:${crypto.randomUUID()}`, kind: 'agent' };
|
|
309
|
+
if (content.reply_to && index === 0) body.parentMessageId = String(content.reply_to);
|
|
310
|
+
const res = await this.post(`/rooms/${roomId}/conversations/${conversationId}/messages`, body);
|
|
311
|
+
if (!res.ok) throw new Error(`${this.platform}: post -> HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
312
|
+
const posted = (await res.json()).data ?? {};
|
|
313
|
+
messageId = String(posted.messageId ?? crypto.randomUUID());
|
|
314
|
+
this.ownMessageIds.push(messageId);
|
|
315
|
+
this.postedIn.set(messageId, conversationId);
|
|
316
|
+
if (this.postedIn.size > ECHO_MEMORY) this.postedIn.delete(this.postedIn.keys().next().value);
|
|
317
|
+
if (this.ownMessageIds.length > ECHO_MEMORY) this.ownMessageIds.splice(0, this.ownMessageIds.length - ECHO_MEMORY);
|
|
318
|
+
const cursor = this.cursors.get(conversationId);
|
|
319
|
+
const sequence = Number(posted.sequence ?? 0);
|
|
320
|
+
if (cursor && sequence > cursor.after) this.cursors.set(conversationId, { roomId: cursor.roomId, after: sequence });
|
|
321
|
+
}
|
|
322
|
+
return { ok: true, message_id: messageId };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Replace one of this adapter's own messages in place (RH2 `PATCH …/messages/:id`): a streamed reply grows, a progress line is updated. */
|
|
326
|
+
async edit(target, messageId, content) {
|
|
327
|
+
const split = this.split(target.chat_id);
|
|
328
|
+
// An edit goes to the conversation the message was posted in, whichever the agent named then.
|
|
329
|
+
const posted = this.postedIn.get(String(messageId)) ?? split.conversationId;
|
|
330
|
+
if (!posted) throw new Error(`${this.platform}: no conversation for ${target.chat_id}`);
|
|
331
|
+
const { roomId } = split;
|
|
332
|
+
const { conversationId, text } = { conversationId: posted, text: this.addressed(roomId, posted, String(content.text ?? '')).text };
|
|
333
|
+
const res = await this.fetch(`${this.apiUrl()}/api/v3/rooms/${roomId}/conversations/${conversationId}/messages/${encodeURIComponent(messageId)}`, { method: 'PATCH', headers: this.headers(), body: JSON.stringify({ body: text.slice(0, MAX_BODY) }) });
|
|
334
|
+
if (!res.ok) throw new Error(`${this.platform}: edit -> HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
335
|
+
return { ok: true, message_id: messageId };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Presence `typing` on the conversation; the door is tried once and forgotten on 404. */
|
|
339
|
+
async typing(target) {
|
|
340
|
+
if (this.presenceDoor === false) return;
|
|
341
|
+
const { roomId, conversationId } = this.split(target.chat_id);
|
|
342
|
+
if (!conversationId) return;
|
|
343
|
+
try {
|
|
344
|
+
const res = await this.post(`/rooms/${roomId}/conversations/${conversationId}/presence`, { state: 'typing' });
|
|
345
|
+
if (res.status === 404) { this.presenceDoor = false; this.log('info', `${this.platform}: RH2 has no conversation presence door; typing disabled`); }
|
|
346
|
+
else if (res.ok) this.presenceDoor = true;
|
|
347
|
+
} catch (error) { this.log('debug', `${this.platform}: typing: ${error.message}`); }
|
|
348
|
+
}
|
|
349
|
+
}
|
package/apply/doors.d.ts
ADDED
package/apply/doors.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// The applier's doors (docs/plans/content-spec-status.md §7 "The door"): every change goes through
|
|
2
|
+
// the harness's own functions, never its files. One shape per gateway:
|
|
3
|
+
//
|
|
4
|
+
// list() -> [{ id, name, managed, lifecycle, created_at }] managed: the fields apply
|
|
5
|
+
// owns, in the harness's own names, read raw from its store
|
|
6
|
+
// fields(spec, inference) -> the declaration in those names (throws when the record cannot be expressed)
|
|
7
|
+
// differs(want, live) -> the names whose declared value differs from the live one, compared by
|
|
8
|
+
// meaning (a schedule by kind and period, never by its display text)
|
|
9
|
+
// create(key, want) / update(id, patch) / pause(id) / remove(id) / capture(id)
|
|
10
|
+
// readInference(keys) / writeInference(values) (Hermes only)
|
|
11
|
+
//
|
|
12
|
+
// Hermes: `hermes_door.py` under Hermes's own interpreter (its cron module, notepad, config save), locally
|
|
13
|
+
// or inside a container. The orchestrator: its operator verbs, through the running daemon's socket when it
|
|
14
|
+
// is up and the package's cold path when it is not; its folder is read through the package's own `load`.
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { createConnection } from 'node:net';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const MARK = '@@supercode-apply@@';
|
|
24
|
+
/** A door call's ceiling: Hermes's own functions answer in well under this. */
|
|
25
|
+
const DOOR_TIMEOUT_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
/** Hermes's schedule string for a declared schedule. */
|
|
28
|
+
export function hermesSchedule(schedule) {
|
|
29
|
+
switch (schedule?.kind) {
|
|
30
|
+
case 'interval': return `every ${schedule.minutes}m`;
|
|
31
|
+
case 'cron': return String(schedule.expr);
|
|
32
|
+
case 'once': return String(schedule.run_at);
|
|
33
|
+
default: throw new Error(`schedule.kind ${JSON.stringify(schedule?.kind)} is not interval | cron | once`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Two schedules are the same when they fire the same way, whatever text made them. */
|
|
38
|
+
export function sameSchedule(want, live) {
|
|
39
|
+
if (!want || !live || typeof live !== 'object') return false;
|
|
40
|
+
if (want.kind !== live.kind) return false;
|
|
41
|
+
if (want.kind === 'interval') return Number(want.minutes) === Number(live.minutes);
|
|
42
|
+
if (want.kind === 'cron') return String(want.expr).trim() === String(live.expr ?? '').trim();
|
|
43
|
+
if (want.kind === 'once') return Date.parse(want.run_at) === Date.parse(live.run_at ?? '');
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The declared model name resolved through Inference to (provider, model), or refused. */
|
|
48
|
+
export function namedModel(spec, inference) {
|
|
49
|
+
if (!spec.model) return { model: null, provider: null };
|
|
50
|
+
const m = inference?.models?.[spec.model];
|
|
51
|
+
if (!m) throw new Error(`model ${JSON.stringify(spec.model)} is not a named model in inference.models`);
|
|
52
|
+
return { model: m.model, provider: m.provider ?? null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// empty and absent are one value in both stores (Hermes keeps '' and null interchangeably for text)
|
|
56
|
+
const norm = (v) => (v === '' || v === undefined ? null : Array.isArray(v) && v.length === 0 ? null : v);
|
|
57
|
+
const sameValue = (a, b) => JSON.stringify(norm(a)) === JSON.stringify(norm(b));
|
|
58
|
+
|
|
59
|
+
export function differs(want, live) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const [field, value] of Object.entries(want)) {
|
|
62
|
+
const same = field === 'schedule' ? sameSchedule(value, live.schedule) : sameValue(value, live[field]);
|
|
63
|
+
if (!same) out.push(field);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The declaration in a door's field names: only what the job declares is
|
|
70
|
+
* managed (an undeclared delivery, workdir or model stays the harness's
|
|
71
|
+
* default and is never overwritten); the schedule always is.
|
|
72
|
+
*/
|
|
73
|
+
function declaredOnly(spec, inference, rename) {
|
|
74
|
+
const want = { schedule: spec.schedule };
|
|
75
|
+
for (const field of ['prompt', 'skills', 'deliver', 'workdir', 'repeat', 'script', 'no_agent', 'monitor_script', 'monitor_url', 'toolsets', 'context_from']) {
|
|
76
|
+
if (spec[field] !== undefined) want[rename[field] ?? field] = spec[field];
|
|
77
|
+
}
|
|
78
|
+
if (spec.model !== undefined) Object.assign(want, namedModel(spec, inference));
|
|
79
|
+
return want;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Where a Hermes install's interpreter and source are: given, else read off the
|
|
84
|
+
* `hermes` on PATH — the one the gateway runs. Two launcher shapes exist: the
|
|
85
|
+
* installer's shell script (`exec "<python>" "<source>/hermes"`) and pip's
|
|
86
|
+
* console script (a `#!<python>` shebang), whose interpreter is asked where the
|
|
87
|
+
* `cron` package it imports lives.
|
|
88
|
+
*/
|
|
89
|
+
export function locateHermes({ python = null, source = null, env = process.env } = {}) {
|
|
90
|
+
if (python && source) return { python, source };
|
|
91
|
+
// every `hermes` on PATH, in order: an image may put a shell shim first (Hermes's own image drops privileges for
|
|
92
|
+
// `docker exec` callers in /opt/hermes/bin/hermes) ahead of the Python launcher it hands to
|
|
93
|
+
const launchers = [];
|
|
94
|
+
for (const dir of (env.PATH ?? '').split(':')) {
|
|
95
|
+
const candidate = dir ? join(dir, 'hermes') : null;
|
|
96
|
+
if (candidate && existsSync(candidate) && !launchers.includes(candidate)) launchers.push(candidate);
|
|
97
|
+
}
|
|
98
|
+
if (!launchers.length) throw new Error('no hermes on PATH; pass the install (python and source) explicitly');
|
|
99
|
+
const reasons = [];
|
|
100
|
+
for (const launcher of launchers) {
|
|
101
|
+
let text;
|
|
102
|
+
try { text = readFileSync(launcher, 'utf8'); } catch (error) { reasons.push(`${launcher}: ${error.message}`); continue; }
|
|
103
|
+
const exec = /exec\s+"([^"]+python[^"]*)"\s+"([^"]+)"/.exec(text);
|
|
104
|
+
if (exec) return { python: exec[1], source: dirname(exec[2]) };
|
|
105
|
+
const shebang = /^#!\s*(\S*python[^\s]*)/.exec(text);
|
|
106
|
+
if (shebang) {
|
|
107
|
+
const where = spawnSync(shebang[1], ['-c', 'import os, cron.jobs; print(os.path.dirname(os.path.dirname(os.path.abspath(cron.jobs.__file__))))'], { encoding: 'utf8', timeout: DOOR_TIMEOUT_MS });
|
|
108
|
+
const found = where.stdout?.trim();
|
|
109
|
+
if (where.status === 0 && found) return { python: shebang[1], source: found };
|
|
110
|
+
reasons.push(`${launcher}: its interpreter ${shebang[1]} cannot import Hermes's cron package: ${(where.stderr || where.error?.message || '').trim().slice(-300)}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
reasons.push(`${launcher} is not a Hermes launcher this applier can read`);
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`${reasons.join('; ')}; pass the install explicitly`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Where the door's Python runs. Locally: Hermes's interpreter with its source
|
|
120
|
+
* on the path. In a container (Open Autonomy's executor): `docker exec` into
|
|
121
|
+
* it, the door script passed with `-c`, the request on stdin, HERMES_HOME the
|
|
122
|
+
* container's path. Either way the applier and its state stay on the host.
|
|
123
|
+
*/
|
|
124
|
+
export function localRunner({ python, source, prefix = [] }) {
|
|
125
|
+
// `prefix` runs the door as another user (a privilege drop such as setpriv), so what Hermes writes stays the agent's
|
|
126
|
+
const argv = [...prefix, python, join(HERE, 'hermes_door.py')];
|
|
127
|
+
return (home, input) => spawnSync(argv[0], argv.slice(1), {
|
|
128
|
+
input, encoding: 'utf8', timeout: DOOR_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, cwd: source,
|
|
129
|
+
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: resolve(home), PYTHONPATH: source },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function containerRunner({ container, user = 'hermes', python = '/opt/hermes/.venv/bin/python', source = '/opt/hermes', docker = 'docker' }) {
|
|
134
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(String(container ?? ''))) throw new Error('a container name or id is required');
|
|
135
|
+
const script = readFileSync(join(HERE, 'hermes_door.py'), 'utf8');
|
|
136
|
+
return (home, input) => spawnSync(docker, ['exec', '-i', '--user', user, '-e', `HERMES_HOME=${home}`, '-e', `PYTHONPATH=${source}`, '-w', source, container, python, '-c', script], {
|
|
137
|
+
input, encoding: 'utf8', timeout: DOOR_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function hermesDoor({ home, python = null, source = null, runner = null, prefix = [] }) {
|
|
142
|
+
const run = runner ?? localRunner({ python, source, prefix });
|
|
143
|
+
const call = (op, args = {}) => {
|
|
144
|
+
const out = run(home, JSON.stringify({ op, args }));
|
|
145
|
+
const line = (out.stdout ?? '').split('\n').find((l) => l.startsWith(MARK));
|
|
146
|
+
if (!line) throw new Error(`Hermes answered nothing for ${op}: ${(out.stderr || out.error?.message || '').trim().slice(-400)}`);
|
|
147
|
+
const answer = JSON.parse(line.slice(MARK.length));
|
|
148
|
+
if (!answer.ok) throw new Error(answer.error);
|
|
149
|
+
return answer.result;
|
|
150
|
+
};
|
|
151
|
+
const read = (job) => ({
|
|
152
|
+
id: job.id,
|
|
153
|
+
name: job.name ?? null,
|
|
154
|
+
managed: {
|
|
155
|
+
schedule: job.schedule ?? null, prompt: job.prompt ?? null, skills: job.skills ?? null, deliver: job.deliver ?? null,
|
|
156
|
+
model: job.model ?? null, provider: job.provider ?? null, workdir: job.workdir ?? null, repeat: job.repeat?.times ?? null,
|
|
157
|
+
script: job.script ?? null, no_agent: Boolean(job.no_agent), monitor_script: job.monitor_script ?? null,
|
|
158
|
+
monitor_url: job.monitor_url ?? null, enabled_toolsets: job.enabled_toolsets ?? null, context_from: job.context_from ?? null,
|
|
159
|
+
},
|
|
160
|
+
lifecycle: job.state === 'completed' ? 'completed' : job.state === 'error' ? 'error' : job.state === 'paused' || job.enabled === false ? 'paused' : 'scheduled',
|
|
161
|
+
created_at: job.created_at ?? null,
|
|
162
|
+
});
|
|
163
|
+
const list = () => call('list').map(read);
|
|
164
|
+
// every answer is read back the way list() reads, so a read-back never differs from a later list by form
|
|
165
|
+
const one = (id) => {
|
|
166
|
+
const row = list().find((r) => r.id === id);
|
|
167
|
+
if (!row) throw new Error(`Hermes no longer has job ${id}`);
|
|
168
|
+
return row;
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
target: 'hermes',
|
|
172
|
+
list,
|
|
173
|
+
fields(spec, inference) {
|
|
174
|
+
// Hermes evaluates cron in one zone per home: a trigger's own zone cannot be honoured (plan §3.2)
|
|
175
|
+
if (spec.schedule?.kind === 'cron' && spec.schedule.tz) throw new Error('Hermes evaluates cron in the home\'s one zone; a per-trigger tz is refused');
|
|
176
|
+
hermesSchedule(spec.schedule);
|
|
177
|
+
return declaredOnly(spec, inference, { toolsets: 'enabled_toolsets' });
|
|
178
|
+
},
|
|
179
|
+
differs,
|
|
180
|
+
create(key, want) {
|
|
181
|
+
const kwargs = { ...want, name: key, schedule: hermesSchedule(want.schedule), repeat: want.repeat };
|
|
182
|
+
return one(call('create', { kwargs }).id);
|
|
183
|
+
},
|
|
184
|
+
update(id, patch) {
|
|
185
|
+
const updates = { ...patch };
|
|
186
|
+
if ('schedule' in updates) updates.schedule = hermesSchedule(updates.schedule);
|
|
187
|
+
if ('repeat' in updates) updates.repeat = updates.repeat ?? 'forever';
|
|
188
|
+
if ('prompt' in updates) updates.prompt = updates.prompt ?? '';
|
|
189
|
+
// null skills make Hermes rebuild the list from the legacy `skill` field (_apply_skill_fields): [] clears it
|
|
190
|
+
if ('skills' in updates && updates.skills === null) updates.skills = [];
|
|
191
|
+
call('update', { id, updates });
|
|
192
|
+
return one(id);
|
|
193
|
+
},
|
|
194
|
+
pause(id) { call('pause', { id, reason: 'declared paused' }); return one(id); },
|
|
195
|
+
remove: (id) => call('remove', { id }),
|
|
196
|
+
capture: (id) => call('capture', { id }),
|
|
197
|
+
readInference: (keys) => call('config_read', { keys }),
|
|
198
|
+
writeInference: (values) => call('config_write', { values }),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One operator line to a running daemon's socket: null when no daemon listens, the answer otherwise. */
|
|
203
|
+
function viaSocket(socket, line) {
|
|
204
|
+
if (!existsSync(socket)) return Promise.resolve(null);
|
|
205
|
+
return new Promise((done, fail) => {
|
|
206
|
+
const client = createConnection(socket);
|
|
207
|
+
let buffer = '';
|
|
208
|
+
let connected = false;
|
|
209
|
+
const timer = setTimeout(() => { client.destroy(); fail(new Error(`the orchestrator daemon did not answer ${line.op} in ${DOOR_TIMEOUT_MS / 1000}s; not retried on the cold path, which could apply it twice`)); }, DOOR_TIMEOUT_MS);
|
|
210
|
+
client.on('connect', () => { connected = true; client.write(`${JSON.stringify(line)}\n`); });
|
|
211
|
+
client.on('data', (d) => { buffer += d; });
|
|
212
|
+
client.on('end', () => {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
try { done(JSON.parse(buffer.trim().split('\n').pop())); } catch { fail(new Error(`the orchestrator daemon's answer to ${line.op} is unreadable; not retried`)); }
|
|
215
|
+
});
|
|
216
|
+
// a socket file with nobody behind it is a daemon that is down: the cold path is safe then
|
|
217
|
+
client.on('error', (error) => { clearTimeout(timer); if (connected) fail(error); else done(null); });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function orchestratorDoor({ root, profile = 'default', load, socket = null }) {
|
|
222
|
+
const bin = join(HERE, '..', 'bin', 'orchestrator.mjs');
|
|
223
|
+
const op = async (name, args) => {
|
|
224
|
+
const live = await viaSocket(socket ?? join(root, 'orchestrator.sock'), { op: name, args, profile });
|
|
225
|
+
const answer = live ?? (() => {
|
|
226
|
+
const run = spawnSync(process.execPath, [bin, name, '--root', root, '--profile', profile, '--json', JSON.stringify(args)], { encoding: 'utf8', timeout: DOOR_TIMEOUT_MS });
|
|
227
|
+
return JSON.parse((run.stdout ?? '').trim().split('\n').pop() || '{"ok":false,"error":"the orchestrator answered nothing"}');
|
|
228
|
+
})();
|
|
229
|
+
if (!answer.ok) throw new Error(answer.error);
|
|
230
|
+
return answer.result;
|
|
231
|
+
};
|
|
232
|
+
const deliver = (target) => (target?.kind === 'explicit' ? [target.platform, target.chat_id, target.thread_id].filter(Boolean).join(':') : target?.kind ?? null);
|
|
233
|
+
const read = (job) => ({
|
|
234
|
+
id: job.id,
|
|
235
|
+
name: job.residue?.name ?? null,
|
|
236
|
+
managed: {
|
|
237
|
+
schedule: job.schedule ?? null, prompt: job.prompt ?? null, skills: job.skills ?? null, deliver: deliver(job.deliver),
|
|
238
|
+
model: job.model ?? null, workdir: job.workdir ?? null, repeat: job.repeat?.times ?? null,
|
|
239
|
+
script: job.residue?.script ?? null, no_agent: job.residue?.no_agent === true, monitor_script: job.residue?.monitor_script ?? null,
|
|
240
|
+
monitor_url: job.residue?.monitor_url ?? null, context_from: job.context_from ?? null,
|
|
241
|
+
},
|
|
242
|
+
lifecycle: !job.enabled ? (job.schedule?.kind === 'once' && !job.next_run_at && job.last_run_at ? 'completed' : 'paused') : 'scheduled',
|
|
243
|
+
created_at: job.created_at ?? null,
|
|
244
|
+
});
|
|
245
|
+
const list = async () => {
|
|
246
|
+
const { state } = await load(root);
|
|
247
|
+
const p = state.profiles[profile];
|
|
248
|
+
if (!p) throw new Error(`the orchestrator at ${root} has no profile ${profile}`);
|
|
249
|
+
return Object.values(p.jobs).map(read);
|
|
250
|
+
};
|
|
251
|
+
const one = async (id) => {
|
|
252
|
+
const row = (await list()).find((r) => r.id === id);
|
|
253
|
+
if (!row) throw new Error(`the orchestrator no longer has job ${id}`);
|
|
254
|
+
return row;
|
|
255
|
+
};
|
|
256
|
+
/** What jobs.update can change; a change to anything else is refused per record, never dropped. */
|
|
257
|
+
const UPDATABLE = new Set(['schedule', 'prompt', 'deliver', 'model', 'workdir', 'script', 'no_agent', 'monitor_script', 'monitor_url']);
|
|
258
|
+
return {
|
|
259
|
+
target: 'orchestrator',
|
|
260
|
+
list,
|
|
261
|
+
fields(spec, inference) {
|
|
262
|
+
if (spec.toolsets) throw new Error('the orchestrator has no per-job toolsets; a restriction it cannot honour refuses the record');
|
|
263
|
+
const want = declaredOnly(spec, inference, {});
|
|
264
|
+
delete want.provider; // the orchestrator's worker takes the model alone
|
|
265
|
+
return want;
|
|
266
|
+
},
|
|
267
|
+
differs,
|
|
268
|
+
async create(key, want) {
|
|
269
|
+
const made = await op('jobs.create', { ...want, name: key, enabled: true });
|
|
270
|
+
return one(made.job.id);
|
|
271
|
+
},
|
|
272
|
+
async update(id, patch) {
|
|
273
|
+
const refused = Object.keys(patch).filter((f) => !UPDATABLE.has(f));
|
|
274
|
+
if (refused.length) throw new Error(`the orchestrator's jobs.update cannot change ${refused.join(', ')}; the record is refused, not half-applied`);
|
|
275
|
+
const args = { id, ...patch };
|
|
276
|
+
for (const k of ['script', 'monitor_script', 'monitor_url']) if (k in args && args[k] === null) args[k] = '';
|
|
277
|
+
await op('jobs.update', args);
|
|
278
|
+
return one(id);
|
|
279
|
+
},
|
|
280
|
+
async pause(id) { await op('jobs.pause', { id }); return one(id); },
|
|
281
|
+
remove: (id) => op('jobs.delete', { id }),
|
|
282
|
+
capture: async (id) => {
|
|
283
|
+
const { state } = await load(root);
|
|
284
|
+
return { fires: (state.profiles[profile]?.fires ?? []).filter((f) => f.job_id === id) };
|
|
285
|
+
},
|
|
286
|
+
readInference: null,
|
|
287
|
+
writeInference: null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The job fields the applier owns: the union both doors read (a door that has no such field reads null). */
|
|
292
|
+
export const MANAGED_JOB_FIELDS = Object.freeze(['schedule', 'prompt', 'skills', 'deliver', 'model', 'provider', 'workdir', 'repeat', 'script', 'no_agent', 'monitor_script', 'monitor_url', 'enabled_toolsets', 'context_from']);
|