@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/mcp/tools.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The orchestrator's model-facing tool table (docs/ORCHESTRATOR-IR.md §7; docs/plans/hermes-compat.md row 13).
|
|
2
|
+
//
|
|
3
|
+
// The worker's model sees Hermes's own tools: `hermes-tools.json` is the pinned Hermes's (v2026.8.31)
|
|
4
|
+
// registry schemas, name, description and parameters, as its model receives them (`registry.get_schema`,
|
|
5
|
+
// and `SEND_MESSAGE_SCHEMA` for `send_message`, which Hermes registers per platform). One word differs:
|
|
6
|
+
// `cronjob.script` names "the home's scripts/" where Hermes prints its own home's path. Regenerated from
|
|
7
|
+
// the pin when Hermes moves, never edited by hand.
|
|
8
|
+
//
|
|
9
|
+
// What serves each: `cronjob` and `send_message` are the orchestrator's (its jobs and deliveries are in
|
|
10
|
+
// its state); `session_search`, `memory`, `skills_list` and `skill_view` are Hermes's own handlers
|
|
11
|
+
// against the profile's home (`hermes-cli.mjs` `hermesTool`), since the sessions, memories and skills
|
|
12
|
+
// they read are that home's. `session_handoff` is the orchestrator's `/handoff` as a tool; Hermes has no
|
|
13
|
+
// tool for it.
|
|
14
|
+
//
|
|
15
|
+
// The table is shared by `server.mjs` (which publishes it over MCP) and `bridge.mjs` (which dispatches it
|
|
16
|
+
// against the orchestrator's state), so a verb cannot be published without an implementation.
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
/** MCP protocol version this server speaks — the same revision `crates/harness/src/mcp.rs` names. */
|
|
21
|
+
export const PROTOCOL_VERSION = '2025-06-18';
|
|
22
|
+
|
|
23
|
+
/** The name the worker's harness registers these tools under. */
|
|
24
|
+
export const SERVER_NAME = 'orchestrator';
|
|
25
|
+
|
|
26
|
+
const HERMES_TOOLS = JSON.parse(readFileSync(new URL('./hermes-tools.json', import.meta.url), 'utf8'));
|
|
27
|
+
|
|
28
|
+
export const TOOLS = Object.freeze([
|
|
29
|
+
...HERMES_TOOLS,
|
|
30
|
+
{
|
|
31
|
+
name: 'session_handoff',
|
|
32
|
+
// The `/handoff <platform>` chat command (§4.5), as a tool.
|
|
33
|
+
description:
|
|
34
|
+
'Hand this session off to another platform: marks the binding pending and posts a pickup notice to the '
|
|
35
|
+
+ "profile's home chat on that platform.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
to: { type: 'string', description: 'The platform to hand off to, e.g. "telegram".' },
|
|
40
|
+
note: { type: 'string', description: 'Optional line to include in the pickup notice.' },
|
|
41
|
+
},
|
|
42
|
+
required: ['to'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The board's tools, for a worker a dispatch spawned for a task (docs/plans/hermes-compat.md row 14):
|
|
49
|
+
* Hermes's fourteen `kanban_*`, from the pin's registry as `hermes-tools.json` is, published only where
|
|
50
|
+
* the task is in the door's environment (`HERMES_KANBAN_TASK`), as Hermes's own gate publishes them.
|
|
51
|
+
*/
|
|
52
|
+
export const KANBAN_TOOLS = Object.freeze(JSON.parse(readFileSync(new URL('./hermes-kanban-tools.json', import.meta.url), 'utf8')));
|
|
53
|
+
|
|
54
|
+
/** The tools Hermes's own handlers serve, against the profile's home. */
|
|
55
|
+
export const HERMES_SERVED = Object.freeze(['session_search', 'memory', 'skills_list', 'skill_view']);
|
|
56
|
+
|
|
57
|
+
export const TOOL_NAMES = Object.freeze(TOOLS.map((t) => t.name));
|
package/mcp/verbs.mjs
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// What each model-facing tool DOES, against the orchestrator's one state value
|
|
2
|
+
// (docs/ORCHESTRATOR-IR.md §7). `mcp/tools.mjs` publishes the table; this
|
|
3
|
+
// module is the other half — a verb cannot be published without landing here.
|
|
4
|
+
//
|
|
5
|
+
// Every verb runs inside the loop: it mutates the state value and returns the
|
|
6
|
+
// effects (§5) the loop then performs on its own queue, so a tool call is
|
|
7
|
+
// ordered against inbound messages and ticks like anything else. It performs
|
|
8
|
+
// no I/O of its own except Hermes's: the tools Hermes's own handlers serve
|
|
9
|
+
// (`session_search`, `memory`, `skills_list`, `skill_view`) and its schedule
|
|
10
|
+
// parser, each against the profile's home.
|
|
11
|
+
//
|
|
12
|
+
// Every result names what it did in `ran`.
|
|
13
|
+
|
|
14
|
+
import { homeChannel } from '../ir.mjs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { decodeContextFrom, parseTarget, renderTarget, resolveRoute, surfaceKeyString } from '../ir.mjs';
|
|
19
|
+
import { liveBinding } from '../reducer.mjs';
|
|
20
|
+
import * as events from '../events.mjs';
|
|
21
|
+
import { compactStamp, gateFields, homeZone, nextRunAt, parseCron, payloadError, scheduleError, scriptPathError } from '../cron.mjs';
|
|
22
|
+
import { applyOperator, hermesJobArgs } from '../operator.mjs';
|
|
23
|
+
import { hermesSchedule, hermesTool } from '../hermes-cli.mjs';
|
|
24
|
+
import { modelError } from '../activation.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* cron/jobs.py `_normalize_workdir`, transcribed: returns the absolute,
|
|
28
|
+
* symlink-resolved path string, or null when unset/blank; throws Hermes's own
|
|
29
|
+
* messages on relative, missing, or non-directory input.
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeWorkdir(workdir) {
|
|
32
|
+
if (workdir === undefined || workdir === null) return null;
|
|
33
|
+
const raw = String(workdir).trim();
|
|
34
|
+
if (!raw) return null;
|
|
35
|
+
const expanded = raw === '~' || raw.startsWith('~/') ? join(homedir(), raw.slice(1)) : raw;
|
|
36
|
+
if (!expanded.startsWith('/')) {
|
|
37
|
+
throw new Error(`Cron workdir must be an absolute path (got '${raw}'). Cron jobs run detached from any shell cwd, so relative paths are ambiguous.`);
|
|
38
|
+
}
|
|
39
|
+
if (!existsSync(expanded)) throw new Error(`Cron workdir does not exist: ${expanded}`);
|
|
40
|
+
const resolved = realpathSync(expanded);
|
|
41
|
+
if (!statSync(resolved).isDirectory()) throw new Error(`Cron workdir is not a directory: ${resolved}`);
|
|
42
|
+
return resolved;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Inverse of `surfaceKeyString` (`platform|kind|chat_id|thread_id|participant_id`). */
|
|
46
|
+
export function keyFromString(text) {
|
|
47
|
+
if (!text) return null;
|
|
48
|
+
const p = String(text).split('|');
|
|
49
|
+
if (!p[0] || !p[1]) return null;
|
|
50
|
+
const key = { platform: p[0], kind: p[1] };
|
|
51
|
+
if (p[2]) key.chat_id = p[2];
|
|
52
|
+
if (p[3]) key.thread_id = p[3];
|
|
53
|
+
if (p[4]) key.participant_id = p[4];
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which profile a surface belongs to, by the same rule `handleInbound` uses
|
|
59
|
+
* (§4.1 step 2): the profile that owns the adapter resolves its routes, and an
|
|
60
|
+
* unrouted key stays with the owner. This is what makes another profile's
|
|
61
|
+
* surface refusable rather than merely undocumented.
|
|
62
|
+
*/
|
|
63
|
+
export function routedProfile(state, key) {
|
|
64
|
+
const profiles = Object.values(state.profiles);
|
|
65
|
+
const owner = profiles.find((p) => p.channels && p.channels[key.platform]) ?? state.profiles.default ?? profiles[0];
|
|
66
|
+
if (!owner) return null;
|
|
67
|
+
return resolveRoute(owner.routes, key) ?? owner.name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const UNIT_MINUTES = { m: 1, min: 1, mins: 1, minute: 1, minutes: 1, h: 60, hr: 60, hour: 60, hours: 60, d: 1440, day: 1440, days: 1440 };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Hermes's `schedule` string, or the IR's typed `Schedule` (§2.6), to the typed
|
|
74
|
+
* form. Four of Hermes's five documented forms are read here; any other string
|
|
75
|
+
* (natural day/time, "every monday 9am") is Hermes's own parser's
|
|
76
|
+
* (`cron.jobs.parse_schedule`, through the pinned Hermes against `home`), whose
|
|
77
|
+
* answer is one of the same three kinds. A cron without a zone runs on the
|
|
78
|
+
* home's clock (`homeZone`), as under Hermes.
|
|
79
|
+
*/
|
|
80
|
+
export function parseSchedule(spec, now, home = null) {
|
|
81
|
+
// a model that serializes the typed form as a JSON string (measured in
|
|
82
|
+
// ORC-8's paid run: `"{\"kind\": \"interval\", \"minutes\": 120}"`) means the
|
|
83
|
+
// typed form; parse it rather than refuse the call
|
|
84
|
+
if (typeof spec === 'string' && /^\s*\{/.test(spec)) {
|
|
85
|
+
try { spec = JSON.parse(spec); } catch { /* not JSON: fall through to the string forms */ }
|
|
86
|
+
}
|
|
87
|
+
if (spec && typeof spec === 'object') {
|
|
88
|
+
switch (spec.kind) {
|
|
89
|
+
case 'once':
|
|
90
|
+
if (!Number.isFinite(Date.parse(spec.run_at))) throw new Error('schedule.run_at must be an instant');
|
|
91
|
+
return { kind: 'once', run_at: new Date(Date.parse(spec.run_at)).toISOString() };
|
|
92
|
+
case 'interval':
|
|
93
|
+
if (!(spec.minutes > 0)) throw new Error('schedule.minutes must be a positive number');
|
|
94
|
+
return { kind: 'interval', minutes: Number(spec.minutes) };
|
|
95
|
+
case 'cron': {
|
|
96
|
+
// the expression AND the zone: a zone `Intl` does not know is refused
|
|
97
|
+
// here, by name, not discovered after the job's first run (§4.3)
|
|
98
|
+
const cron = { kind: 'cron', expr: String(spec.expr ?? ''), ...(spec.tz ? { tz: String(spec.tz) } : {}) };
|
|
99
|
+
const broken = scheduleError(cron);
|
|
100
|
+
if (broken) throw new Error(broken);
|
|
101
|
+
return cron;
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
throw new Error(`schedule.kind must be once | interval | cron, got ${JSON.stringify(spec.kind)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const raw = String(spec ?? '').trim();
|
|
108
|
+
const t = raw.toLowerCase();
|
|
109
|
+
const once = /^in\s+(\d+)\s*([a-z]+)$/.exec(t);
|
|
110
|
+
if (once) {
|
|
111
|
+
const minutes = Number(once[1]) * (UNIT_MINUTES[once[2]] ?? NaN);
|
|
112
|
+
if (!Number.isFinite(minutes)) throw new Error(`schedule ${JSON.stringify(raw)}: unknown unit ${JSON.stringify(once[2])}`);
|
|
113
|
+
return { kind: 'once', run_at: new Date(Date.parse(now) + minutes * 60000).toISOString() };
|
|
114
|
+
}
|
|
115
|
+
const every = /^(?:every\s+)?(\d*)\s*([a-z]+)$/.exec(t);
|
|
116
|
+
if (every && UNIT_MINUTES[every[2]] !== undefined) {
|
|
117
|
+
return { kind: 'interval', minutes: (every[1] ? Number(every[1]) : 1) * UNIT_MINUTES[every[2]] };
|
|
118
|
+
}
|
|
119
|
+
if (raw.split(/\s+/).filter(Boolean).length === 5) {
|
|
120
|
+
parseCron(raw);
|
|
121
|
+
return { kind: 'cron', expr: raw };
|
|
122
|
+
}
|
|
123
|
+
const at = Date.parse(raw);
|
|
124
|
+
if (Number.isFinite(at)) return { kind: 'once', run_at: new Date(at).toISOString() };
|
|
125
|
+
const hermes = home && raw ? hermesSchedule(home, raw) : null;
|
|
126
|
+
if (hermes) return parseSchedule({ kind: hermes.kind, run_at: hermes.run_at, minutes: hermes.minutes, expr: hermes.expr }, now);
|
|
127
|
+
throw new Error(
|
|
128
|
+
`schedule ${JSON.stringify(raw)} is not a schedule: use '30m' / 'every 2h', 'in 30m', five-field cron `
|
|
129
|
+
+ "('0 9 * * *'), an ISO timestamp, or the typed {kind: once|interval|cron} form. Day/time phrasing "
|
|
130
|
+
+ "such as 'every monday 9am' is read by Hermes's parser, and none is installed here — write it as cron.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A chat key out of Hermes's `deliver`/`target` grammar, resolved against this profile. */
|
|
135
|
+
function targetKey(profile, word, origin) {
|
|
136
|
+
const parsed = parseTarget('mcp/send_message', 'target', word);
|
|
137
|
+
if (!parsed || parsed.kind === 'origin') {
|
|
138
|
+
if (!origin) throw new Error('this session has no surface to answer; name a target');
|
|
139
|
+
return { ...origin };
|
|
140
|
+
}
|
|
141
|
+
if (parsed.kind === 'home') {
|
|
142
|
+
const home = homeChannel(profile);
|
|
143
|
+
if (!home) throw new Error(`profile ${profile.name} has no home channel (/sethome)`);
|
|
144
|
+
return home;
|
|
145
|
+
}
|
|
146
|
+
if (parsed.kind === 'local') throw new Error("send_message does not write files; use a cron job's deliver: 'local'");
|
|
147
|
+
if (!parsed.chat_id) {
|
|
148
|
+
const home = homeChannel(profile, parsed.platform);
|
|
149
|
+
if (home) return home;
|
|
150
|
+
throw new Error(`no home chat on ${parsed.platform}; name it as ${parsed.platform}:<chat_id>`);
|
|
151
|
+
}
|
|
152
|
+
const key = { platform: parsed.platform, kind: 'dm', chat_id: parsed.chat_id };
|
|
153
|
+
if (parsed.thread_id) key.thread_id = parsed.thread_id;
|
|
154
|
+
return key;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Where `send_message` can reach from this profile, in its target grammar: each home chat, each live conversation. */
|
|
158
|
+
function sendTargets(state, profile) {
|
|
159
|
+
const rows = new Map();
|
|
160
|
+
for (const [platform, channel] of Object.entries(profile.channels ?? {})) {
|
|
161
|
+
const home = channel?.extra?.home_channel;
|
|
162
|
+
if (home?.chat_id) rows.set(platform, { target: platform, platform, chat_id: String(home.chat_id), name: home.name ?? null, home: true });
|
|
163
|
+
}
|
|
164
|
+
for (const b of Object.values(profile.bindings)) {
|
|
165
|
+
const k = b.key;
|
|
166
|
+
if (b.ended_at || !k?.chat_id || ['cron', 'webhook', 'local'].includes(k.platform)) continue;
|
|
167
|
+
const target = [k.platform, k.chat_id, k.thread_id].filter(Boolean).join(':');
|
|
168
|
+
if (!rows.has(target) && routedProfile(state, k) === profile.name) rows.set(target, { target, platform: k.platform, chat_id: k.chat_id, kind: k.kind, home: false });
|
|
169
|
+
}
|
|
170
|
+
return [...rows.values()];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function jobId(profile, now) {
|
|
174
|
+
const stem = `job_${compactStamp(now)}`;
|
|
175
|
+
if (!profile.jobs[stem]) return stem;
|
|
176
|
+
for (let n = 2; ; n++) if (!profile.jobs[`${stem}_${n}`]) return `${stem}_${n}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The per-profile harness home the loop activates a worker with (`activationEnv`). */
|
|
180
|
+
export function harnessHomes(profile) {
|
|
181
|
+
const harness = profile.worker?.harness;
|
|
182
|
+
if (!harness) return undefined;
|
|
183
|
+
const home = join(profile.dir, harness);
|
|
184
|
+
// `HarnessHomes`/`SkillHomes` key each harness by its snake_case id; hermes
|
|
185
|
+
// is keyed by its state.db path, whose parent is the HERMES_HOME.
|
|
186
|
+
return { [harness.replace(/-/g, '_')]: harness === 'hermes' ? join(home, 'state.db') : home };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------- the verbs
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {object} orch the running Orchestrator
|
|
193
|
+
* @param {object} call { verb, args, profile, surface }
|
|
194
|
+
* @returns {Promise<{result: object, effects: object[]}>}
|
|
195
|
+
*/
|
|
196
|
+
export async function runVerb(orch, { verb, args = {}, profile: profileName, surface }) {
|
|
197
|
+
const state = orch.state;
|
|
198
|
+
const profile = state.profiles[profileName];
|
|
199
|
+
if (!profile) throw new Error(`unknown profile ${profileName}`);
|
|
200
|
+
const now = orch.clock();
|
|
201
|
+
const binding = surface ? profile.bindings[surface] ?? null : null;
|
|
202
|
+
const key = binding?.key ?? keyFromString(surface);
|
|
203
|
+
const effects = [];
|
|
204
|
+
|
|
205
|
+
switch (verb) {
|
|
206
|
+
case 'cronjob': {
|
|
207
|
+
// Hermes's one cron tool (tools/cronjob_tools.py `cronjob`), its actions on the orchestrator's jobs
|
|
208
|
+
const action = String(args.action ?? '').trim().toLowerCase();
|
|
209
|
+
if (action === 'create') {
|
|
210
|
+
args = hermesJobArgs(args);
|
|
211
|
+
// the model's door: Hermes's path rule for scripts, then its payload rule
|
|
212
|
+
for (const key of ['script', 'monitor_script']) { const bad = scriptPathError(args[key]); if (bad) throw new Error(bad); }
|
|
213
|
+
const gate = gateFields(args);
|
|
214
|
+
const empty = payloadError(args, gate);
|
|
215
|
+
if (empty) throw new Error(empty);
|
|
216
|
+
// Hermes's tool, stricter than create_job: an agent job needs a prompt or skills
|
|
217
|
+
const prompt = typeof args.prompt === 'string' && args.prompt.trim();
|
|
218
|
+
const skilled = Array.isArray(args.skills) && args.skills.some((x) => String(x).trim());
|
|
219
|
+
if (!gate.no_agent && !prompt && !skilled) throw new Error('an agent job needs a prompt or skills (a script alone needs no_agent)');
|
|
220
|
+
const schedule = parseSchedule(args.schedule, now, profile.dir);
|
|
221
|
+
// Hermes's tool contract (cron/jobs.py `_normalize_workdir`, measured in
|
|
222
|
+
// ORC-8's week): the workdir is trimmed, `~`-expanded, refused when
|
|
223
|
+
// relative (jobs run detached from any shell cwd), symlink-RESOLVED, and
|
|
224
|
+
// must be an existing directory. The same model call must store the same
|
|
225
|
+
// string here, or the two worlds' jobs.json disagree on `/tmp` vs
|
|
226
|
+
// `/private/tmp`. Only an operator-authored job (config/jobs.json) may be
|
|
227
|
+
// profile-relative.
|
|
228
|
+
const workdir = normalizeWorkdir(args.workdir);
|
|
229
|
+
const unusable = modelError(profile.worker?.harness, args.model ? String(args.model) : null);
|
|
230
|
+
if (unusable) throw new Error(unusable);
|
|
231
|
+
const id = jobId(profile, now);
|
|
232
|
+
const job = {
|
|
233
|
+
id,
|
|
234
|
+
schedule,
|
|
235
|
+
prompt: typeof args.prompt === 'string' ? args.prompt : '',
|
|
236
|
+
workdir,
|
|
237
|
+
model: args.model ? String(args.model) : null,
|
|
238
|
+
skills: Array.isArray(args.skills) ? args.skills.map(String) : [],
|
|
239
|
+
context_from: decodeContextFrom('cronjob', 'context_from', args.context_from),
|
|
240
|
+
deliver: parseTarget('mcp/cronjob', 'deliver', args.deliver) ?? { kind: 'origin' },
|
|
241
|
+
failure_deliver: parseTarget('mcp/cronjob', 'failure_deliver', args.failure_deliver),
|
|
242
|
+
// `origin` is captured from the binding, never from an argument: a job
|
|
243
|
+
// created in this conversation delivers back to this conversation.
|
|
244
|
+
origin: key ? { platform: key.platform, chat_type: key.kind ?? null, chat_id: key.chat_id ?? null, thread_id: key.thread_id ?? null } : null,
|
|
245
|
+
attach_to_session: typeof args.attach_to_session === 'boolean' ? args.attach_to_session : null,
|
|
246
|
+
// Hermes's `repeat`: an integer is "run N times", "forever"/absent is unlimited (`cron/jobs.py:812-830`)
|
|
247
|
+
repeat: Number.isInteger(args.repeat) && args.repeat > 0 ? { times: args.repeat, completed: 0 } : null,
|
|
248
|
+
enabled: true,
|
|
249
|
+
next_run_at: nextRunAt(schedule, now, homeZone(profile)),
|
|
250
|
+
last_run_at: null,
|
|
251
|
+
last_status: null,
|
|
252
|
+
created_at: now,
|
|
253
|
+
// Hermes's `name` has no IR field; it rides the residue channel verbatim (§1 rule 6).
|
|
254
|
+
residue: { ...(args.name ? { name: String(args.name) } : {}), ...(Array.isArray(args.enabled_toolsets) && args.enabled_toolsets.length ? { enabled_toolsets: args.enabled_toolsets.map(String) } : {}), ...gate },
|
|
255
|
+
};
|
|
256
|
+
await orch.dispatch(events.operator({ verb: 'jobs_create', profile: profile.name, args: { job }, at: now }));
|
|
257
|
+
return {
|
|
258
|
+
effects,
|
|
259
|
+
result: {
|
|
260
|
+
ran: `created cron job ${id} (${describeSchedule(schedule)}) in profile ${profile.name}, delivering to ${renderTarget(job.deliver)}`,
|
|
261
|
+
job_id: id,
|
|
262
|
+
next_run_at: job.next_run_at,
|
|
263
|
+
origin: job.origin,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (action === 'list') {
|
|
268
|
+
const jobs = Object.values(profile.jobs).map((j) => ({
|
|
269
|
+
job_id: j.id,
|
|
270
|
+
name: j.residue?.name ?? null,
|
|
271
|
+
schedule: describeSchedule(j.schedule),
|
|
272
|
+
enabled: j.enabled,
|
|
273
|
+
deliver: renderTarget(j.deliver),
|
|
274
|
+
next_run_at: j.next_run_at,
|
|
275
|
+
last_status: j.last_status,
|
|
276
|
+
prompt: j.prompt,
|
|
277
|
+
}));
|
|
278
|
+
return { effects, result: { ran: `listed ${jobs.length} cron job(s) in profile ${profile.name}`, jobs } };
|
|
279
|
+
}
|
|
280
|
+
const op = { update: 'jobs.update', pause: 'jobs.pause', resume: 'jobs.resume', run: 'jobs.run', remove: 'jobs.delete' }[action];
|
|
281
|
+
if (!op) throw new Error(`action must be one of create, list, update, pause, resume, remove, run (got ${JSON.stringify(args.action ?? '')})`);
|
|
282
|
+
const id = String(args.job_id ?? '').trim();
|
|
283
|
+
if (!id) throw new Error(`job_id is required for ${action}; list first — never guess job IDs`);
|
|
284
|
+
if (!profile.jobs[id]) throw new Error(`no job ${JSON.stringify(id)} in profile ${profile.name}; list first`);
|
|
285
|
+
if (action === 'run' && typeof args.prompt === 'string' && args.prompt.trim()) {
|
|
286
|
+
throw new Error("run's transient prompt is not served here; run the job as it stands, or update its prompt");
|
|
287
|
+
}
|
|
288
|
+
const opArgs = action === 'update' ? { ...hermesJobArgs(args, profile.jobs[id]), id } : { id };
|
|
289
|
+
if (action === 'update') {
|
|
290
|
+
delete opArgs.action; delete opArgs.job_id;
|
|
291
|
+
if (typeof opArgs.workdir === 'string' && opArgs.workdir.trim()) opArgs.workdir = normalizeWorkdir(opArgs.workdir);
|
|
292
|
+
for (const key of ['script', 'monitor_script']) { const bad = opArgs[key] ? scriptPathError(opArgs[key]) : null; if (bad) throw new Error(bad); }
|
|
293
|
+
}
|
|
294
|
+
const answer = await applyOperator(state, op, opArgs, profile.name, now, (event) => orch.dispatch(event));
|
|
295
|
+
if (!answer.ok) throw new Error(answer.error);
|
|
296
|
+
return { effects, result: answer.result };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case 'send_message': {
|
|
300
|
+
// Hermes's send_message: `send` (the default) and `list`; reactions are not a door these adapters have
|
|
301
|
+
const action = String(args.action ?? 'send').trim().toLowerCase() || 'send';
|
|
302
|
+
if (action === 'list') {
|
|
303
|
+
const targets = sendTargets(state, profile);
|
|
304
|
+
return { effects, result: { ran: `listed ${targets.length} target(s) for profile ${profile.name}`, targets } };
|
|
305
|
+
}
|
|
306
|
+
if (action === 'react' || action === 'unreact') throw new Error(`send_message action ${action} is not served by this orchestrator's platform adapters`);
|
|
307
|
+
if (action !== 'send') throw new Error(`action must be one of send, list, react, unreact (got ${JSON.stringify(args.action)})`);
|
|
308
|
+
if (typeof args.message !== 'string' || !args.message) throw new Error('message is required');
|
|
309
|
+
const target = targetKey(profile, args.target, key);
|
|
310
|
+
const owner = routedProfile(state, target);
|
|
311
|
+
if (owner && owner !== profile.name) {
|
|
312
|
+
throw new Error(`${surfaceKeyString(target)} belongs to profile ${owner}, not ${profile.name}; a session may only message its own profile's surfaces`);
|
|
313
|
+
}
|
|
314
|
+
const fx = await orch.dispatch(events.operator({ verb: 'send_message', profile: profile.name, args: { key: target, text: args.message }, at: now }));
|
|
315
|
+
const obligationId = fx.find((e) => e.type === 'deliver')?.obligation_id ?? null;
|
|
316
|
+
return { effects, result: { ran: `queued a message to ${surfaceKeyString(target)}`, obligation_id: obligationId } };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
case 'session_search':
|
|
320
|
+
case 'memory':
|
|
321
|
+
case 'skills_list':
|
|
322
|
+
case 'skill_view': {
|
|
323
|
+
// Hermes's own handler against this profile's home: its sessions (the workers' mirrored beside
|
|
324
|
+
// Hermes's), its memories, its skills; answered in Hermes's words
|
|
325
|
+
const verbatim = await hermesTool(profile.dir, verb, args, { sessionId: binding?.worker?.session_id ?? null });
|
|
326
|
+
return { effects, result: { ran: `${verb} in profile ${profile.name}`, verbatim: typeof verbatim === 'string' ? verbatim : JSON.stringify(verbatim) } };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
case 'session_handoff': {
|
|
330
|
+
const to = args.to ? String(args.to) : '';
|
|
331
|
+
if (!to) throw new Error('to is required');
|
|
332
|
+
const b = surface ? liveBinding(profile, surface) : null;
|
|
333
|
+
if (!b) throw new Error('No live session to hand off.');
|
|
334
|
+
await orch.dispatch(events.operator({ verb: 'session_handoff', profile: profile.name, args: { keyString: surface, to, note: args.note ? String(args.note) : null }, at: now }));
|
|
335
|
+
if (b.handoff?.state !== 'pending') return { effects, error: `Cannot hand off: ${b.handoff?.error ?? 'unknown reason'}.` };
|
|
336
|
+
return { effects, result: { ran: `handed off to ${to}; the binding is pending pickup`, session_id: b.worker.session_id } };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
default:
|
|
340
|
+
throw new Error(`unknown verb ${verb}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function describeSchedule(s) {
|
|
345
|
+
if (s.kind === 'once') return `once at ${s.run_at}`;
|
|
346
|
+
if (s.kind === 'interval') return `every ${s.minutes} min`;
|
|
347
|
+
return `cron ${s.expr}${s.tz ? ` ${s.tz}` : ''}`;
|
|
348
|
+
}
|