@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/hermes-cli.mjs
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// Hermes's own tools beside a worker (docs/plans/hermes-compat.md row 7). A home is a Hermes home, and the
|
|
2
|
+
// skills it carries call Hermes's CLI (`hermes kanban …`, `hermes cron notepad …`, `hermes cron doctor`)
|
|
3
|
+
// against it, so a worker of any harness runs with `HERMES_HOME` set to its profile's folder and the
|
|
4
|
+
// pinned `hermes` on its `PATH`; and what Hermes's scheduler records about a failed run (its incidents,
|
|
5
|
+
// in `cron/executions.db`) the orchestrator records through Hermes's own function.
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { parseDotenv } from './orchestration.mjs';
|
|
10
|
+
import { locateHermes } from './apply/doors.mjs';
|
|
11
|
+
|
|
12
|
+
/** Hermes's interpreter over `code`, `payload` on stdin, without blocking the daemon: `{ status, stdout, stderr }`. */
|
|
13
|
+
function python(install, code, payload, env, timeout) {
|
|
14
|
+
return new Promise((done) => {
|
|
15
|
+
const child = spawn(install.python, ['-c', code], { cwd: install.source, env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
16
|
+
let stdout = '', stderr = '';
|
|
17
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
|
|
18
|
+
child.stdout.setEncoding('utf8').on('data', (c) => { stdout += c; });
|
|
19
|
+
child.stderr.setEncoding('utf8').on('data', (c) => { stderr = (stderr + c).slice(-4000); });
|
|
20
|
+
child.on('error', (error) => { clearTimeout(timer); done({ status: null, stdout, stderr: error.message }); });
|
|
21
|
+
child.on('close', (status) => { clearTimeout(timer); done({ status, stdout, stderr }); });
|
|
22
|
+
child.stdin.end(JSON.stringify(payload));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let located;
|
|
27
|
+
/** The pinned Hermes on this daemon's `PATH`: `{ bin, python, source }`, or null when there is none. */
|
|
28
|
+
export function hermesInstall() {
|
|
29
|
+
if (located !== undefined) return located;
|
|
30
|
+
try {
|
|
31
|
+
const which = spawnSync('sh', ['-c', 'command -v hermes'], { encoding: 'utf8' }).stdout.trim();
|
|
32
|
+
located = which ? { bin: dirname(which), ...locateHermes() } : null;
|
|
33
|
+
} catch {
|
|
34
|
+
located = null;
|
|
35
|
+
}
|
|
36
|
+
return located;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The environment a non-Hermes worker adds for Hermes's CLI: its profile's home, and `hermes` on `PATH`. */
|
|
40
|
+
export function hermesToolEnv(profileDir) {
|
|
41
|
+
const install = hermesInstall();
|
|
42
|
+
const env = { HERMES_HOME: profileDir };
|
|
43
|
+
if (install?.bin) env.PATH = `${install.bin}:${process.env.PATH ?? ''}`;
|
|
44
|
+
return env;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Record a failed run as Hermes's scheduler does: `cron.incidents.upsert_incident`, one incident per job
|
|
49
|
+
* and error signature, in the profile's `cron/executions.db`. Answers the incident id, or null (and why)
|
|
50
|
+
* when Hermes is not installed or refused.
|
|
51
|
+
*/
|
|
52
|
+
export function recordIncident(profileDir, { job_id, job_name = null, error }) {
|
|
53
|
+
const install = hermesInstall();
|
|
54
|
+
if (!install?.python) return { id: null, why: 'no Hermes install on PATH' };
|
|
55
|
+
const code = 'import json,sys\nfrom cron.incidents import upsert_incident\na=json.loads(sys.stdin.read())\nprint(json.dumps(upsert_incident(a["job_id"], a["error"], job_name=a.get("job_name"))))';
|
|
56
|
+
const run = spawnSync(install.python, ['-c', code], {
|
|
57
|
+
input: JSON.stringify({ job_id, job_name, error: String(error ?? 'failed') }),
|
|
58
|
+
encoding: 'utf8', timeout: 30_000, cwd: install.source,
|
|
59
|
+
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source },
|
|
60
|
+
});
|
|
61
|
+
if (run.status !== 0) return { id: null, why: (run.stderr || run.error?.message || '').trim().slice(-300) };
|
|
62
|
+
const [id] = JSON.parse(run.stdout.trim().split('\n').pop());
|
|
63
|
+
return { id };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Access through Hermes's own pairing store and writers (docs/plans/hermes-compat.md row 8): a code is
|
|
67
|
+
// issued and approved by `gateway.pairing.PairingStore` (salted hashes, its expiry, rate limit and
|
|
68
|
+
// lockout), an approval mirrored into the platform's allowlist as Hermes mirrors it; an allowlist entry is
|
|
69
|
+
// `<PLATFORM>_ALLOWED_USERS` in the profile's `.env` (`hermes_cli.config.save_env_value`); an admin is the
|
|
70
|
+
// platform's `extra.allow_admin_from` in `config.yaml` (`save_config`).
|
|
71
|
+
const ACCESS_OPS = `
|
|
72
|
+
import json, os, sys
|
|
73
|
+
a = json.loads(sys.stdin.read()); op = a["op"]
|
|
74
|
+
from gateway.pairing import PairingStore, _read_allowlist_env, _sync_allowlist_remove, _allowlist_env_for_platform
|
|
75
|
+
store = PairingStore()
|
|
76
|
+
def ids(v): return [x.strip() for x in (v or "").split(",") if x.strip()]
|
|
77
|
+
out = None
|
|
78
|
+
if op == "issue":
|
|
79
|
+
# Hermes's gateway: a rate-limited sender is answered with nothing; a refused code with the busy line,
|
|
80
|
+
# its rate limit recorded so the next messages are silent
|
|
81
|
+
if store._is_rate_limited(a["platform"], a["user_id"]):
|
|
82
|
+
out = {"silent": True}
|
|
83
|
+
else:
|
|
84
|
+
code = store.generate_code(a["platform"], a["user_id"], a.get("user_name") or "")
|
|
85
|
+
if not code:
|
|
86
|
+
store._record_rate_limit(a["platform"], a["user_id"])
|
|
87
|
+
out = {"code": code}
|
|
88
|
+
elif op == "approve":
|
|
89
|
+
for p in a["platforms"]:
|
|
90
|
+
got = store.approve_code(p, a["code"])
|
|
91
|
+
if got:
|
|
92
|
+
out = {"platform": p, **got}
|
|
93
|
+
break
|
|
94
|
+
elif op == "pending":
|
|
95
|
+
out = store.list_pending()
|
|
96
|
+
elif op in ("allow", "revoke"):
|
|
97
|
+
var = _allowlist_env_for_platform(a["platform"]) or (a["platform"].upper() + "_ALLOWED_USERS")
|
|
98
|
+
current = ids(_read_allowlist_env(var))
|
|
99
|
+
if op == "allow":
|
|
100
|
+
if a["user_id"] not in current:
|
|
101
|
+
from hermes_cli.config import save_env_value
|
|
102
|
+
save_env_value(var, ",".join(current + [a["user_id"]]))
|
|
103
|
+
else:
|
|
104
|
+
store.revoke(a["platform"], a["user_id"])
|
|
105
|
+
_sync_allowlist_remove(a["platform"], a["user_id"])
|
|
106
|
+
out = True
|
|
107
|
+
elif op in ("admin_add", "admin_remove"):
|
|
108
|
+
from hermes_cli.config import load_config, save_config
|
|
109
|
+
cfg = load_config()
|
|
110
|
+
extra = cfg.setdefault("platforms", {}).setdefault(a["platform"], {}).setdefault("extra", {})
|
|
111
|
+
admins = [str(x) for x in (extra.get("allow_admin_from") or [])]
|
|
112
|
+
if op == "admin_add" and a["user_id"] not in admins: admins.append(a["user_id"])
|
|
113
|
+
if op == "admin_remove": admins = [x for x in admins if x != a["user_id"]]
|
|
114
|
+
extra["allow_admin_from"] = admins
|
|
115
|
+
save_config(cfg)
|
|
116
|
+
out = True
|
|
117
|
+
print("@@access@@" + json.dumps(out))
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One access operation through Hermes's own functions against a profile's home: `issue` (answers
|
|
122
|
+
* `{ code }`, `{ code: null }` when Hermes refuses one, or `{ silent: true }` for a rate-limited sender), `approve` (answers
|
|
123
|
+
* `{ platform, user_id, user_name }` or null), `pending`, `allow`, `revoke`, `admin_add`, `admin_remove`.
|
|
124
|
+
* Throws when Hermes is not installed: access lives in Hermes's stores.
|
|
125
|
+
*/
|
|
126
|
+
export function hermesAccess(profileDir, op, args = {}) {
|
|
127
|
+
const install = hermesInstall();
|
|
128
|
+
if (!install?.python) throw new Error(`access ${op} needs Hermes's pairing store and writers, and no Hermes is installed on PATH`);
|
|
129
|
+
const envFile = join(profileDir, '.env');
|
|
130
|
+
const dotenv = existsSync(envFile) ? Object.fromEntries(parseDotenv(readFileSync(envFile, 'utf8'))) : {};
|
|
131
|
+
const run = spawnSync(install.python, ['-c', ACCESS_OPS], {
|
|
132
|
+
input: JSON.stringify({ op, ...args }), encoding: 'utf8', timeout: 30_000, cwd: install.source,
|
|
133
|
+
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', ...dotenv, HERMES_HOME: profileDir, PYTHONPATH: install.source },
|
|
134
|
+
});
|
|
135
|
+
const line = (run.stdout ?? '').split('\n').find((l) => l.startsWith('@@access@@'));
|
|
136
|
+
if (!line) throw new Error(`Hermes answered nothing for access ${op}: ${(run.stderr || run.error?.message || '').trim().slice(-300)}`);
|
|
137
|
+
return JSON.parse(line.slice('@@access@@'.length));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A worker's transcript mirrored into the home's Hermes session store (docs/plans/hermes-compat.md row 11),
|
|
141
|
+
// through Hermes's own `SessionDB`: the session row (`ensure_session`, the entry's session id, its chat and
|
|
142
|
+
// origin, `model_config._supercode_mirror` naming the worker's own session and how many rows the mirror
|
|
143
|
+
// wrote) and its messages (`replace_messages`, one atomic rewrite under Hermes's turn lease), so
|
|
144
|
+
// `hermes sessions` lists and resumes the conversation. A row Hermes has written to since the last mirror
|
|
145
|
+
// (its message count past the mirror's) is Hermes continuing the conversation: the mirror never rewrites
|
|
146
|
+
// it, and the worker's next start takes Hermes's turns over (the loop's `continueFromHermes`).
|
|
147
|
+
const MIRROR = `
|
|
148
|
+
import json, sys
|
|
149
|
+
a = json.loads(sys.stdin.read()); op = a["op"]
|
|
150
|
+
from hermes_state import SessionDB
|
|
151
|
+
db = SessionDB()
|
|
152
|
+
def mark(row):
|
|
153
|
+
try: return (json.loads(row.get("model_config") or "{}") or {}).get("_supercode_mirror")
|
|
154
|
+
except Exception: return None
|
|
155
|
+
def config(row):
|
|
156
|
+
try: return json.loads(row.get("model_config") or "{}") or {}
|
|
157
|
+
except Exception: return {}
|
|
158
|
+
row = db.get_session(a["id"])
|
|
159
|
+
m = mark(row) if row is not None else None
|
|
160
|
+
ahead = row is not None and (m is None or (m.get("messages") is not None and (row.get("message_count") or 0) > m["messages"]))
|
|
161
|
+
out = None
|
|
162
|
+
if op == "state":
|
|
163
|
+
out = {"exists": row is not None, "ahead": ahead, "messages": row.get("message_count") if row else None}
|
|
164
|
+
elif op == "seal":
|
|
165
|
+
if row is not None:
|
|
166
|
+
cfg = config(row); cfg["_supercode_mirror"] = {**(m or {}), "messages": row.get("message_count") or 0, "continued_as": a["continued_as"]}
|
|
167
|
+
db.update_session_meta(a["id"], json.dumps(cfg))
|
|
168
|
+
out = True
|
|
169
|
+
elif ahead:
|
|
170
|
+
out = {"diverged": row.get("message_count")}
|
|
171
|
+
else:
|
|
172
|
+
db.ensure_session(a["id"], source=a["source"], model=a.get("model"), model_config=a["model_config"],
|
|
173
|
+
session_key=a.get("session_key"), chat_id=a.get("chat_id"), chat_type=a.get("chat_type"),
|
|
174
|
+
thread_id=a.get("thread_id"), user_id=a.get("user_id"), cwd=a.get("cwd"),
|
|
175
|
+
parent_session_id=a.get("parent"), origin_json=json.dumps(a.get("origin") or {}))
|
|
176
|
+
db.replace_messages(a["id"], a["messages"], reject_active_turn_lease=True)
|
|
177
|
+
row = db.get_session(a["id"])
|
|
178
|
+
cfg = config(row); cfg["_supercode_mirror"] = {**a["model_config"]["_supercode_mirror"], "messages": row.get("message_count") or 0}
|
|
179
|
+
db.update_session_meta(a["id"], json.dumps(cfg))
|
|
180
|
+
if a.get("end"): db.end_session(a["id"], a["end"])
|
|
181
|
+
out = {"mirrored": row.get("message_count") or 0}
|
|
182
|
+
print("@@mirror@@" + json.dumps(out))
|
|
183
|
+
`;
|
|
184
|
+
|
|
185
|
+
async function runMirror(profileDir, payload) {
|
|
186
|
+
const install = hermesInstall();
|
|
187
|
+
if (!install?.python) return { error: 'no Hermes install on PATH' };
|
|
188
|
+
const run = await python(install, MIRROR, payload, { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source }, 60_000);
|
|
189
|
+
const line = run.stdout.split('\n').find((l) => l.startsWith('@@mirror@@'));
|
|
190
|
+
if (!line) return { error: run.stderr.trim().slice(-300) };
|
|
191
|
+
return { out: JSON.parse(line.slice('@@mirror@@'.length)) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether Hermes has carried a mirrored conversation past its mirror: `{ exists, ahead, messages }`, or
|
|
196
|
+
* null when Hermes is not installed or unreadable.
|
|
197
|
+
*/
|
|
198
|
+
export async function mirrorState(profileDir, id) {
|
|
199
|
+
return (await runMirror(profileDir, { op: 'state', id })).out ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A mirror Hermes continued, closed once its turns went on as `continuedAs`: listed no more, never rewritten. */
|
|
203
|
+
export async function sealMirror(profileDir, id, continuedAs) {
|
|
204
|
+
return (await runMirror(profileDir, { op: 'seal', id, continued_as: continuedAs })).out === true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** One Supercode-normalized message (OpenAI chat shape) as a Hermes `messages` row. */
|
|
208
|
+
function hermesMessage(m) {
|
|
209
|
+
const text = Array.isArray(m.content)
|
|
210
|
+
? m.content.map((part) => (typeof part === 'string' ? part : part?.text ?? '')).filter(Boolean).join('\n')
|
|
211
|
+
: m.content ?? null;
|
|
212
|
+
const at = Date.parse(m.metadata?.timestamp ?? '');
|
|
213
|
+
return {
|
|
214
|
+
role: m.role, content: text,
|
|
215
|
+
...(m.tool_calls?.length ? { tool_calls: m.tool_calls } : {}),
|
|
216
|
+
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
|
217
|
+
...(m.name ? { tool_name: m.name } : {}),
|
|
218
|
+
...(Number.isFinite(at) ? { timestamp: at / 1000 } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Mirror a worker session into the profile's Hermes store under `id` (its routing entry's session id).
|
|
224
|
+
* `binding` supplies the chat; `session` is the worker's transcript as Supercode loaded it; `parent` the
|
|
225
|
+
* Hermes session it continues. Answers `{ mirrored }` (rows written), `{ diverged }` (Hermes's row is
|
|
226
|
+
* ahead: nothing written) or `{ mirrored: null, why }`.
|
|
227
|
+
*/
|
|
228
|
+
export async function mirrorSession(profileDir, { id, sessionKey, binding, session, locator = null, parent = null, end = null }) {
|
|
229
|
+
const key = binding.key ?? {};
|
|
230
|
+
const got = await runMirror(profileDir, {
|
|
231
|
+
op: 'mirror', id, parent, end, source: key.platform ?? 'orchestrator', model: session.model ?? null, cwd: session.cwd ?? null,
|
|
232
|
+
model_config: { _supercode_mirror: { harness: binding.worker?.harness ?? session.source, session_id: session.session_id ?? null, locator, ...(binding.recurrence ? { recurrence: { job_id: binding.recurrence.job_id, kind: binding.recurrence.kind ?? 'cron' } } : {}) } },
|
|
233
|
+
session_key: sessionKey, chat_id: key.chat_id ?? null, chat_type: key.kind ?? null, thread_id: key.thread_id ?? null, user_id: key.participant_id ?? null,
|
|
234
|
+
origin: { platform: key.platform ?? null, chat_id: key.chat_id ?? null, chat_type: key.kind ?? null, thread_id: key.thread_id ?? null },
|
|
235
|
+
messages: (session.messages ?? []).filter((m) => m.role !== 'system').map(hermesMessage),
|
|
236
|
+
});
|
|
237
|
+
return got.out ?? { mirrored: null, why: got.error };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Hermes's own tools against a profile's home (docs/plans/hermes-compat.md row 13): `session_search`,
|
|
241
|
+
// `memory`, `skills_list` and `skill_view` dispatched through Hermes's registry, their handlers given what
|
|
242
|
+
// Hermes's agent gives them (the memory store built as `agent_init` builds it, the current session); the
|
|
243
|
+
// memory snapshot Hermes's agent puts in its system prompt at session start
|
|
244
|
+
// (`format_for_system_prompt`, each enabled store); and Hermes's schedule parser (`cron.jobs.parse_schedule`).
|
|
245
|
+
const HERMES_TOOL = `
|
|
246
|
+
import json, sys
|
|
247
|
+
a = json.loads(sys.stdin.read()); op = a["op"]
|
|
248
|
+
from hermes_cli.config import load_config
|
|
249
|
+
cfg = load_config()
|
|
250
|
+
def memory_store():
|
|
251
|
+
from tools.memory_tool import MemoryStore, get_builtin_memory_config, get_builtin_memory_store_flags
|
|
252
|
+
mc = get_builtin_memory_config(cfg); me, ue = get_builtin_memory_store_flags(cfg)
|
|
253
|
+
if not (me or ue): return None, me, ue
|
|
254
|
+
store = MemoryStore(memory_char_limit=mc.get("memory_char_limit", 2200), user_char_limit=mc.get("user_char_limit", 1375), memory_enabled=me, user_profile_enabled=ue)
|
|
255
|
+
store.load_from_disk()
|
|
256
|
+
return store, me, ue
|
|
257
|
+
if op == "snapshot":
|
|
258
|
+
store, me, ue = memory_store()
|
|
259
|
+
blocks = []
|
|
260
|
+
if store and me: blocks.append(store.format_for_system_prompt("memory"))
|
|
261
|
+
if store and ue: blocks.append(store.format_for_system_prompt("user"))
|
|
262
|
+
out = "\\n\\n".join(b for b in blocks if b)
|
|
263
|
+
elif op == "approval":
|
|
264
|
+
from tools.approval import detect_dangerous_command, _get_cron_approval_mode, _get_single_query_approval_mode, _get_approval_mode
|
|
265
|
+
danger = detect_dangerous_command(a["command"]) if a.get("command") else (False, None, None)
|
|
266
|
+
if not danger[0]:
|
|
267
|
+
out = {"verdict": "allow", "why": None}
|
|
268
|
+
else:
|
|
269
|
+
ctx = a.get("context")
|
|
270
|
+
mode = _get_cron_approval_mode() if ctx == "cron" else _get_single_query_approval_mode() if ctx == "single_query" else ("approve" if _get_approval_mode() == "off" else "ask")
|
|
271
|
+
out = {"verdict": "allow" if mode == "approve" else ("ask" if mode == "ask" else "deny"), "why": danger[2]}
|
|
272
|
+
# Hermes's own words to the model for a denial, so it finds another way (tools/approval.py)
|
|
273
|
+
if out["verdict"] == "deny" and ctx == "cron":
|
|
274
|
+
out["message"] = (f"BLOCKED: Command flagged as dangerous ({danger[2]}) but cron jobs run without a user present to approve it. "
|
|
275
|
+
"Find an alternative approach that avoids this command. To allow dangerous commands in cron jobs, set approvals.cron_mode: approve in config.yaml.")
|
|
276
|
+
elif out["verdict"] == "deny":
|
|
277
|
+
out["message"] = (f"BLOCKED: Command flagged as dangerous ({danger[2]}) but single-query mode (-q) runs without a user present to approve it. "
|
|
278
|
+
"Find an alternative approach that avoids this command. To allow dangerous commands in single-query mode, set approvals.single_query_mode: approve in config.yaml.")
|
|
279
|
+
elif op == "kanban_prompt":
|
|
280
|
+
from agent.prompt_builder import KANBAN_GUIDANCE
|
|
281
|
+
from agent.skill_commands import build_preloaded_skills_prompt
|
|
282
|
+
skills = build_preloaded_skills_prompt(a.get("skills") or [], task_id=a.get("task"))[0] if a.get("skills") else ""
|
|
283
|
+
out = {"guidance": KANBAN_GUIDANCE, "skills": skills or ""}
|
|
284
|
+
elif op == "schedule":
|
|
285
|
+
from cron.jobs import parse_schedule
|
|
286
|
+
try: out = parse_schedule(a["text"])
|
|
287
|
+
except ValueError as error: out = {"error": str(error)}
|
|
288
|
+
else:
|
|
289
|
+
import tools.memory_tool, tools.session_search_tool, tools.skills_tool, tools.kanban_tools
|
|
290
|
+
from tools.registry import registry
|
|
291
|
+
kw = {}
|
|
292
|
+
if a["name"] == "memory": kw["store"] = memory_store()[0]
|
|
293
|
+
if a["name"] == "session_search": kw["current_session_id"] = a.get("session_id")
|
|
294
|
+
out = registry.dispatch(a["name"], a.get("args") or {}, **kw)
|
|
295
|
+
print("@@hermes@@" + json.dumps(out))
|
|
296
|
+
`;
|
|
297
|
+
|
|
298
|
+
const hermesEnv = (install, profileDir) => ({ PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source });
|
|
299
|
+
|
|
300
|
+
function hermesAnswer(what, run) {
|
|
301
|
+
const line = run.stdout.split('\n').find((l) => l.startsWith('@@hermes@@'));
|
|
302
|
+
if (!line) throw new Error(`Hermes answered nothing for ${what}: ${run.stderr.trim().slice(-300)}`);
|
|
303
|
+
return JSON.parse(line.slice('@@hermes@@'.length));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function runHermes(profileDir, payload, env = {}) {
|
|
307
|
+
const install = hermesInstall();
|
|
308
|
+
if (!install?.python) throw new Error(`${payload.name ?? payload.op} is Hermes's, and no Hermes is installed on PATH`);
|
|
309
|
+
return hermesAnswer(payload.name ?? payload.op, await python(install, HERMES_TOOL, payload, { ...hermesEnv(install, profileDir), ...env }, 60_000));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The environment Hermes's dispatcher gives the worker it spawns for a task (`kanban_db._default_spawn`):
|
|
314
|
+
* the task, its run and claim, the board's database, workspaces and slug, the author its comments carry.
|
|
315
|
+
*/
|
|
316
|
+
export const KANBAN_ENV = Object.freeze([
|
|
317
|
+
'HERMES_KANBAN_TASK', 'HERMES_KANBAN_WORKSPACE', 'HERMES_KANBAN_BRANCH', 'HERMES_KANBAN_RUN_ID',
|
|
318
|
+
'HERMES_KANBAN_CLAIM_LOCK', 'HERMES_KANBAN_DB', 'HERMES_KANBAN_WORKSPACES_ROOT', 'HERMES_KANBAN_BOARD',
|
|
319
|
+
'HERMES_KANBAN_GOAL_MODE', 'HERMES_KANBAN_GOAL_MAX_TURNS', 'HERMES_PROFILE', 'HERMES_TENANT', 'HERMES_SESSION_SOURCE',
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* One of Hermes's tools, called as Hermes's agent calls it; answers the handler's own result (Hermes's
|
|
324
|
+
* text). `env` is a dispatched worker's task environment, for the board's tools.
|
|
325
|
+
*/
|
|
326
|
+
export function hermesTool(profileDir, name, args, { sessionId = null, env = null } = {}) {
|
|
327
|
+
return runHermes(profileDir, { op: 'tool', name, args, session_id: sessionId }, env ?? {});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Hermes's approval rule for a worker's request (tools/approval.py): a shell command Hermes's own
|
|
332
|
+
* `detect_dangerous_command` calls safe runs without asking, as it does under Hermes, and so does every
|
|
333
|
+
* other tool (Hermes's file tools are not gated); a dangerous one is asked in a conversation (unless
|
|
334
|
+
* `approvals.mode: off`), and answered by `approvals.cron_mode` in a fire and `approvals.single_query_mode`
|
|
335
|
+
* in a dispatched worker, each deny by default. Answers `{ verdict: allow|ask|deny, why }`, or null without
|
|
336
|
+
* Hermes (the worker's own prompt is then relayed as it stands).
|
|
337
|
+
*/
|
|
338
|
+
export async function hermesApproval(profileDir, { command = null, context = 'conversation' } = {}) {
|
|
339
|
+
if (!hermesInstall()?.python) return null;
|
|
340
|
+
return runHermes(profileDir, { op: 'approval', command, context });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* What Hermes's agent adds to a dispatched worker's system prompt: `KANBAN_GUIDANCE` (the task protocol)
|
|
345
|
+
* and the task's force-loaded skills (`build_preloaded_skills_prompt`, as `hermes --skills` loads them).
|
|
346
|
+
*/
|
|
347
|
+
export function kanbanPrompt(profileDir, { skills = [], task = null, env = {} } = {}) {
|
|
348
|
+
return runHermes(profileDir, { op: 'kanban_prompt', skills, task }, env);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The memory Hermes's agent would carry into a session starting now, as its system prompt carries it; '' for none. */
|
|
352
|
+
export async function memorySnapshot(profileDir) {
|
|
353
|
+
if (!hermesInstall()?.python) return '';
|
|
354
|
+
return runHermes(profileDir, { op: 'snapshot' });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A schedule string as Hermes parses it (`{ kind: once|interval|cron, … }`), or null when no Hermes is
|
|
359
|
+
* installed; throws Hermes's own refusal. Synchronous: schedules are parsed where a job is built.
|
|
360
|
+
*/
|
|
361
|
+
export function hermesSchedule(profileDir, text) {
|
|
362
|
+
const install = hermesInstall();
|
|
363
|
+
if (!install?.python) return null;
|
|
364
|
+
const run = spawnSync(install.python, ['-c', HERMES_TOOL], { input: JSON.stringify({ op: 'schedule', text }), encoding: 'utf8', timeout: 30_000, cwd: install.source, env: hermesEnv(install, profileDir) });
|
|
365
|
+
const out = hermesAnswer('schedule', { stdout: run.stdout ?? '', stderr: run.stderr || run.error?.message || '' });
|
|
366
|
+
if (out?.error) throw new Error(out.error);
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// One tick of Hermes's board dispatcher, as a Hermes gateway runs it (gateway/kanban_watchers.py
|
|
371
|
+
// `_kanban_dispatcher_watcher`): the home's `kanban:` settings read as the gateway reads them, then
|
|
372
|
+
// `kanban_db.dispatch_once` on every board. Hermes claims, reclaims, promotes and spawns; its spawn runs
|
|
373
|
+
// `HERMES_BIN`, which the caller sets to the orchestrator's worker command.
|
|
374
|
+
const KANBAN_TICK = `
|
|
375
|
+
import json, sys
|
|
376
|
+
a = json.loads(sys.stdin.read())
|
|
377
|
+
from hermes_cli.config import load_config
|
|
378
|
+
from hermes_cli import kanban_db as kb
|
|
379
|
+
cfg = ((load_config() or {}).get("kanban") or {})
|
|
380
|
+
def pos_int(v):
|
|
381
|
+
try: v = int(v)
|
|
382
|
+
except (TypeError, ValueError): return None
|
|
383
|
+
return v if v >= 1 else None
|
|
384
|
+
max_in_progress = kb.resolve_max_in_progress(pos_int(cfg.get("max_in_progress")))
|
|
385
|
+
try: failure_limit = int(cfg.get("failure_limit", kb.DEFAULT_FAILURE_LIMIT))
|
|
386
|
+
except (TypeError, ValueError): failure_limit = kb.DEFAULT_FAILURE_LIMIT
|
|
387
|
+
if failure_limit < 1: failure_limit = kb.DEFAULT_FAILURE_LIMIT
|
|
388
|
+
try: stale = int(cfg.get("dispatch_stale_timeout_seconds", 0) or 0)
|
|
389
|
+
except (TypeError, ValueError): stale = 0
|
|
390
|
+
default_assignee = (cfg.get("default_assignee") or "").strip() or None
|
|
391
|
+
try: boards = kb.list_boards(include_archived=False)
|
|
392
|
+
except Exception: boards = [kb.read_board_metadata(kb.DEFAULT_BOARD)]
|
|
393
|
+
out = []
|
|
394
|
+
for b in boards:
|
|
395
|
+
slug = b.get("slug") or kb.DEFAULT_BOARD
|
|
396
|
+
conn = None
|
|
397
|
+
try:
|
|
398
|
+
conn = kb.connect(board=slug)
|
|
399
|
+
r = kb.dispatch_once(conn, board=slug, max_spawn=cfg.get("max_spawn"), max_in_progress=max_in_progress,
|
|
400
|
+
failure_limit=failure_limit, stale_timeout_seconds=stale, default_assignee=default_assignee,
|
|
401
|
+
max_in_progress_per_profile=pos_int(cfg.get("max_in_progress_per_profile")),
|
|
402
|
+
reconcile_orphans=bool(cfg.get("reconcile_orphans", True)))
|
|
403
|
+
count = lambda sql: conn.execute(sql).fetchone()[0]
|
|
404
|
+
out.append({"board": slug, "spawned": [list(x) for x in r.spawned], "reclaimed": r.reclaimed, "crashed": r.crashed,
|
|
405
|
+
"timed_out": r.timed_out, "skipped_locked": bool(getattr(r, "skipped_locked", False)),
|
|
406
|
+
"subscriptions": count("SELECT COUNT(*) FROM kanban_notify_subs"), "triage": count("SELECT COUNT(*) FROM tasks WHERE status = 'triage'")})
|
|
407
|
+
except Exception as error:
|
|
408
|
+
out.append({"board": slug, "error": str(error)})
|
|
409
|
+
finally:
|
|
410
|
+
if conn is not None: conn.close()
|
|
411
|
+
print("@@hermes@@" + json.dumps(out, default=str))
|
|
412
|
+
`;
|
|
413
|
+
|
|
414
|
+
/** One dispatcher tick over the board at `root` (Hermes's `kanban_home`), `env` adding `HERMES_BIN` and the like. */
|
|
415
|
+
export async function kanbanTick(root, env = {}) {
|
|
416
|
+
const install = hermesInstall();
|
|
417
|
+
if (!install?.python) throw new Error('the board is Hermes\'s, and no Hermes is installed on PATH');
|
|
418
|
+
return hermesAnswer('kanban dispatch', await python(install, KANBAN_TICK, {}, { ...hermesEnv(install, root), ...env }, 120_000));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Hermes's MCP trust gate for a relayed tool call (tools/mcp_tool.py `_trust_gate_check`): a tool of one of the
|
|
423
|
+
* profile's servers configured anything but `trust: full` (unset is full) is asked in a conversation and denied
|
|
424
|
+
* with nobody present. Answers a verdict, or null when the call is not such a server's.
|
|
425
|
+
*/
|
|
426
|
+
export function untrustedServerVerdict(profile, tool, context, { mcp = false } = {}) {
|
|
427
|
+
const servers = profile?.residue?.config?.mcp_servers ?? {};
|
|
428
|
+
// the configured name the tool is prefixed with (a name may itself hold `__`: the longest match is its)
|
|
429
|
+
const server = Object.keys(servers).filter((name) => (tool ?? '').startsWith(`mcp__${name}__`)).sort((a, b) => b.length - a.length)[0]
|
|
430
|
+
?? (/^mcp__(.+?)__/.exec(tool ?? '')?.[1] ?? null);
|
|
431
|
+
// an MCP approval that names no tool cannot be judged: it fails closed, as Hermes's unknown trust does
|
|
432
|
+
if (!server && mcp) {
|
|
433
|
+
const why = 'an MCP tool call that names no tool';
|
|
434
|
+
return context === 'conversation' ? { verdict: 'ask', why } : { verdict: 'deny', why, message: `BLOCKED: ${why} cannot be approved with nobody present. Find an alternative approach.` };
|
|
435
|
+
}
|
|
436
|
+
const trust = server ? servers[server]?.trust : undefined;
|
|
437
|
+
if (!server || trust === undefined || trust === null || String(trust).trim().toLowerCase() === 'full') return null;
|
|
438
|
+
const why = `server ${server} is configured 'trust: untrusted'`;
|
|
439
|
+
return context === 'conversation' ? { verdict: 'ask', why } : { verdict: 'deny', why, message: `BLOCKED: ${why}, and its tools need approval, but nobody is present to approve it. Find an alternative approach.` };
|
|
440
|
+
}
|