@rethinkingstudio/clawpilot 2.1.0-beta.3 → 2.1.0-beta.5
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/dist/commands/pair.js +93 -3
- package/dist/commands/pair.js.map +1 -1
- package/dist/commands/run.js +3 -1
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/status.js +29 -20
- package/dist/commands/status.js.map +1 -1
- package/dist/config/config.d.ts +2 -0
- package/dist/config/config.js +32 -0
- package/dist/config/config.js.map +1 -1
- package/dist/hermes/api-client.d.ts +26 -2
- package/dist/hermes/api-client.js +146 -4
- package/dist/hermes/api-client.js.map +1 -1
- package/dist/i18n/index.js +4 -0
- package/dist/i18n/index.js.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/relay/hermes-relay-manager.d.ts +1 -0
- package/dist/relay/hermes-relay-manager.js +553 -14
- package/dist/relay/hermes-relay-manager.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,17 +1,30 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
1
4
|
import { WebSocket } from "ws";
|
|
2
5
|
import { handleLocalCommand } from "../commands/local-handlers.js";
|
|
3
6
|
import { handleProviderCommand } from "../commands/provider-handlers.js";
|
|
4
7
|
import { getServicePlatform } from "../platform/service-manager.js";
|
|
5
|
-
import { checkHermesHealth, listHermesModels, streamHermesChat } from "../hermes/api-client.js";
|
|
8
|
+
import { checkHermesHealth, createHermesJob, deleteHermesJob, listHermesJobs, listHermesModels, pauseHermesJob, resumeHermesJob, runHermesJob, streamHermesChat, updateHermesJob, } from "../hermes/api-client.js";
|
|
6
9
|
import { HermesSessionStore } from "../hermes/session-store.js";
|
|
7
10
|
const DEFAULT_HERMES_MODEL = "hermes-agent";
|
|
8
11
|
const DEFAULT_CONTEXT_TOKENS = 200_000;
|
|
12
|
+
const HERMES_CRON_OUTPUT_DIR = join(homedir(), ".hermes", "cron", "output");
|
|
13
|
+
const HERMES_HOME_DIR = join(homedir(), ".hermes");
|
|
14
|
+
const HERMES_SOUL_PATH = join(HERMES_HOME_DIR, "SOUL.md");
|
|
15
|
+
const HERMES_HEALTHCHECK_FAST_INTERVAL_MS = 5_000;
|
|
16
|
+
const HERMES_HEALTHCHECK_SLOW_INTERVAL_MS = 30_000;
|
|
17
|
+
const HERMES_HEALTHCHECK_MAX_BACKOFF_MS = 30_000;
|
|
9
18
|
export async function runHermesRelayManager(opts) {
|
|
10
19
|
const wsUrl = buildRelayUrl(opts.relayServerUrl, opts.gatewayId, opts.relaySecret);
|
|
11
20
|
const sessionStore = new HermesSessionStore();
|
|
12
21
|
const activeRuns = new Map();
|
|
13
22
|
const activeRunBySessionKey = new Map();
|
|
14
23
|
const lastSessionState = new Map();
|
|
24
|
+
let runtimeOnline = false;
|
|
25
|
+
let healthTimer = null;
|
|
26
|
+
let healthProbeInFlight = false;
|
|
27
|
+
let currentHealthIntervalMs = HERMES_HEALTHCHECK_FAST_INTERVAL_MS;
|
|
15
28
|
return new Promise((resolve) => {
|
|
16
29
|
let relayWs;
|
|
17
30
|
try {
|
|
@@ -27,6 +40,54 @@ export async function runHermesRelayManager(opts) {
|
|
|
27
40
|
relayWs.send(JSON.stringify(msg));
|
|
28
41
|
}
|
|
29
42
|
}
|
|
43
|
+
async function probeHermesHealth() {
|
|
44
|
+
if (healthProbeInFlight)
|
|
45
|
+
return;
|
|
46
|
+
healthProbeInFlight = true;
|
|
47
|
+
try {
|
|
48
|
+
await checkHermesHealth(opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
49
|
+
if (!runtimeOnline) {
|
|
50
|
+
runtimeOnline = true;
|
|
51
|
+
console.log("[runtime:hermes] api connected");
|
|
52
|
+
send({ type: "gateway_connected" });
|
|
53
|
+
}
|
|
54
|
+
setHealthCheckInterval(HERMES_HEALTHCHECK_SLOW_INTERVAL_MS);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
58
|
+
if (runtimeOnline) {
|
|
59
|
+
runtimeOnline = false;
|
|
60
|
+
console.log(`[runtime:hermes] api disconnected: ${reason}`);
|
|
61
|
+
send({ type: "gateway_disconnected", reason });
|
|
62
|
+
}
|
|
63
|
+
setHealthCheckInterval(Math.min(currentHealthIntervalMs < HERMES_HEALTHCHECK_FAST_INTERVAL_MS
|
|
64
|
+
? HERMES_HEALTHCHECK_FAST_INTERVAL_MS
|
|
65
|
+
: currentHealthIntervalMs * 2, HERMES_HEALTHCHECK_MAX_BACKOFF_MS));
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
healthProbeInFlight = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function startHealthChecks() {
|
|
72
|
+
currentHealthIntervalMs = HERMES_HEALTHCHECK_FAST_INTERVAL_MS;
|
|
73
|
+
setHealthCheckInterval(currentHealthIntervalMs);
|
|
74
|
+
void probeHermesHealth();
|
|
75
|
+
}
|
|
76
|
+
function setHealthCheckInterval(intervalMs) {
|
|
77
|
+
if (healthTimer && currentHealthIntervalMs === intervalMs)
|
|
78
|
+
return;
|
|
79
|
+
stopHealthChecks();
|
|
80
|
+
currentHealthIntervalMs = intervalMs;
|
|
81
|
+
healthTimer = setInterval(() => {
|
|
82
|
+
void probeHermesHealth();
|
|
83
|
+
}, currentHealthIntervalMs);
|
|
84
|
+
}
|
|
85
|
+
function stopHealthChecks() {
|
|
86
|
+
if (healthTimer) {
|
|
87
|
+
clearInterval(healthTimer);
|
|
88
|
+
healthTimer = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
30
91
|
function upsertSessionSnapshot(sessionKey, patch = {}) {
|
|
31
92
|
const now = Date.now();
|
|
32
93
|
const existing = lastSessionState.get(sessionKey);
|
|
@@ -48,7 +109,7 @@ export async function runHermesRelayManager(opts) {
|
|
|
48
109
|
return next;
|
|
49
110
|
}
|
|
50
111
|
async function respondModels(requestId) {
|
|
51
|
-
const models = await listHermesModels(opts.hermesApiBaseUrl);
|
|
112
|
+
const models = await listHermesModels(opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
52
113
|
send({
|
|
53
114
|
type: "res",
|
|
54
115
|
id: requestId,
|
|
@@ -119,6 +180,121 @@ export async function runHermesRelayManager(opts) {
|
|
|
119
180
|
},
|
|
120
181
|
});
|
|
121
182
|
}
|
|
183
|
+
async function respondTodayCronMessages(requestId) {
|
|
184
|
+
const now = new Date();
|
|
185
|
+
const jobs = await listHermesJobs(opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
186
|
+
const messages = jobs
|
|
187
|
+
.map((job) => toTodayCronMessage(job, now))
|
|
188
|
+
.filter((value) => value !== null)
|
|
189
|
+
.sort((lhs, rhs) => lhs.createdAt - rhs.createdAt);
|
|
190
|
+
send({
|
|
191
|
+
type: "res",
|
|
192
|
+
id: requestId,
|
|
193
|
+
ok: true,
|
|
194
|
+
payload: { messages },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async function respondCronList(requestId) {
|
|
198
|
+
const jobs = await listHermesJobs(opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
199
|
+
send({
|
|
200
|
+
type: "res",
|
|
201
|
+
id: requestId,
|
|
202
|
+
ok: true,
|
|
203
|
+
payload: {
|
|
204
|
+
jobs: jobs.map(toCronJobPayload),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async function respondCronAdd(requestId, params) {
|
|
209
|
+
const payload = normalizeCronCreatePayload(params);
|
|
210
|
+
const job = await createHermesJob(payload, opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
211
|
+
send({ type: "res", id: requestId, ok: true, payload: { job: toCronJobPayload(job) } });
|
|
212
|
+
}
|
|
213
|
+
async function respondCronUpdate(requestId, params) {
|
|
214
|
+
const { id, patch } = normalizeCronUpdatePayload(params);
|
|
215
|
+
let job;
|
|
216
|
+
if (typeof patch.enabled === "boolean" && Object.keys(patch).length === 1) {
|
|
217
|
+
job = patch.enabled
|
|
218
|
+
? await resumeHermesJob(id, opts.hermesApiBaseUrl, opts.hermesApiKey)
|
|
219
|
+
: await pauseHermesJob(id, opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
job = await updateHermesJob(id, patch, opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
223
|
+
}
|
|
224
|
+
send({ type: "res", id: requestId, ok: true, payload: { job: toCronJobPayload(job) } });
|
|
225
|
+
}
|
|
226
|
+
async function respondCronRemove(requestId, params) {
|
|
227
|
+
const id = normalizeCronJobId(params);
|
|
228
|
+
await deleteHermesJob(id, opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
229
|
+
send({ type: "res", id: requestId, ok: true, payload: { ok: true } });
|
|
230
|
+
}
|
|
231
|
+
async function respondCronRun(requestId, params) {
|
|
232
|
+
const id = normalizeCronJobId(params);
|
|
233
|
+
const job = await runHermesJob(id, opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
234
|
+
send({ type: "res", id: requestId, ok: true, payload: { job: toCronJobPayload(job) } });
|
|
235
|
+
}
|
|
236
|
+
async function respondCronRuns(requestId, params) {
|
|
237
|
+
const id = normalizeCronJobId(params);
|
|
238
|
+
const jobs = await listHermesJobs(opts.hermesApiBaseUrl, opts.hermesApiKey);
|
|
239
|
+
const job = jobs.find((item) => item.id === id);
|
|
240
|
+
const entries = buildCronRunEntries(job);
|
|
241
|
+
send({
|
|
242
|
+
type: "res",
|
|
243
|
+
id: requestId,
|
|
244
|
+
ok: true,
|
|
245
|
+
payload: {
|
|
246
|
+
entries,
|
|
247
|
+
nextOffset: null,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
async function respondCronMessageHistory(requestId, params) {
|
|
252
|
+
const id = normalizeCronJobId(params);
|
|
253
|
+
const limit = normalizeOptionalLimit(params);
|
|
254
|
+
send({
|
|
255
|
+
type: "res",
|
|
256
|
+
id: requestId,
|
|
257
|
+
ok: true,
|
|
258
|
+
payload: {
|
|
259
|
+
jobId: id,
|
|
260
|
+
sessionKey: `agent:main:cron:${id}`,
|
|
261
|
+
messages: readHermesJobHistory(id, limit),
|
|
262
|
+
hasMore: false,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function respondRuntimeFileGet(requestId, params) {
|
|
267
|
+
const fileName = normalizeRuntimeFileName(params);
|
|
268
|
+
const content = existsSync(HERMES_SOUL_PATH) ? readFileSync(HERMES_SOUL_PATH, "utf8") : "";
|
|
269
|
+
send({
|
|
270
|
+
type: "res",
|
|
271
|
+
id: requestId,
|
|
272
|
+
ok: true,
|
|
273
|
+
payload: {
|
|
274
|
+
file: {
|
|
275
|
+
name: fileName,
|
|
276
|
+
content,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function respondRuntimeFileSet(requestId, params) {
|
|
282
|
+
const { name, content } = normalizeRuntimeFileWrite(params);
|
|
283
|
+
mkdirSync(HERMES_HOME_DIR, { recursive: true });
|
|
284
|
+
writeFileSync(HERMES_SOUL_PATH, content, "utf8");
|
|
285
|
+
send({
|
|
286
|
+
type: "res",
|
|
287
|
+
id: requestId,
|
|
288
|
+
ok: true,
|
|
289
|
+
payload: {
|
|
290
|
+
ok: true,
|
|
291
|
+
file: {
|
|
292
|
+
name,
|
|
293
|
+
content,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
122
298
|
function abortRun(runId, sessionKey) {
|
|
123
299
|
const resolvedRunId = runId ?? (sessionKey ? activeRunBySessionKey.get(sessionKey) : undefined);
|
|
124
300
|
if (!resolvedRunId)
|
|
@@ -161,6 +337,7 @@ export async function runHermesRelayManager(opts) {
|
|
|
161
337
|
try {
|
|
162
338
|
const result = await streamHermesChat({
|
|
163
339
|
baseUrl: opts.hermesApiBaseUrl,
|
|
340
|
+
apiKey: opts.hermesApiKey,
|
|
164
341
|
sessionId: existing?.hermesSessionId,
|
|
165
342
|
model: nextModel,
|
|
166
343
|
userMessage: message,
|
|
@@ -245,18 +422,7 @@ export async function runHermesRelayManager(opts) {
|
|
|
245
422
|
console.log(`Connected to relay server for Hermes (gatewayId=${opts.gatewayId})`);
|
|
246
423
|
send({ type: "relay_hello", platform: getServicePlatform() });
|
|
247
424
|
opts.onConnected?.();
|
|
248
|
-
|
|
249
|
-
.then(() => {
|
|
250
|
-
console.log("[runtime:hermes] api connected");
|
|
251
|
-
send({ type: "gateway_connected" });
|
|
252
|
-
})
|
|
253
|
-
.catch((error) => {
|
|
254
|
-
console.log(`[runtime:hermes] api disconnected: ${error instanceof Error ? error.message : String(error)}`);
|
|
255
|
-
send({
|
|
256
|
-
type: "gateway_disconnected",
|
|
257
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
258
|
-
});
|
|
259
|
-
});
|
|
425
|
+
startHealthChecks();
|
|
260
426
|
});
|
|
261
427
|
relayWs.on("message", async (raw) => {
|
|
262
428
|
let msg;
|
|
@@ -309,6 +475,46 @@ export async function runHermesRelayManager(opts) {
|
|
|
309
475
|
if (requestId)
|
|
310
476
|
respondSessions(requestId);
|
|
311
477
|
return;
|
|
478
|
+
case "cron.list":
|
|
479
|
+
if (requestId)
|
|
480
|
+
await respondCronList(requestId);
|
|
481
|
+
return;
|
|
482
|
+
case "cron.add":
|
|
483
|
+
if (requestId)
|
|
484
|
+
await respondCronAdd(requestId, msg.params ?? {});
|
|
485
|
+
return;
|
|
486
|
+
case "cron.update":
|
|
487
|
+
if (requestId)
|
|
488
|
+
await respondCronUpdate(requestId, msg.params ?? {});
|
|
489
|
+
return;
|
|
490
|
+
case "cron.remove":
|
|
491
|
+
if (requestId)
|
|
492
|
+
await respondCronRemove(requestId, msg.params ?? {});
|
|
493
|
+
return;
|
|
494
|
+
case "cron.run":
|
|
495
|
+
if (requestId)
|
|
496
|
+
await respondCronRun(requestId, msg.params ?? {});
|
|
497
|
+
return;
|
|
498
|
+
case "cron.runs":
|
|
499
|
+
if (requestId)
|
|
500
|
+
await respondCronRuns(requestId, msg.params ?? {});
|
|
501
|
+
return;
|
|
502
|
+
case "cron.messages.today":
|
|
503
|
+
if (requestId)
|
|
504
|
+
await respondTodayCronMessages(requestId);
|
|
505
|
+
return;
|
|
506
|
+
case "cron.messages.history":
|
|
507
|
+
if (requestId)
|
|
508
|
+
await respondCronMessageHistory(requestId, msg.params ?? {});
|
|
509
|
+
return;
|
|
510
|
+
case "runtime.files.get":
|
|
511
|
+
if (requestId)
|
|
512
|
+
respondRuntimeFileGet(requestId, msg.params ?? {});
|
|
513
|
+
return;
|
|
514
|
+
case "runtime.files.set":
|
|
515
|
+
if (requestId)
|
|
516
|
+
respondRuntimeFileSet(requestId, msg.params ?? {});
|
|
517
|
+
return;
|
|
312
518
|
case "chat.send":
|
|
313
519
|
await handleChatSend((msg.params ?? {}));
|
|
314
520
|
return;
|
|
@@ -352,6 +558,7 @@ export async function runHermesRelayManager(opts) {
|
|
|
352
558
|
}
|
|
353
559
|
});
|
|
354
560
|
relayWs.on("close", (code, reason) => {
|
|
561
|
+
stopHealthChecks();
|
|
355
562
|
console.log(`Hermes relay connection closed: ${code} ${reason.toString()}`);
|
|
356
563
|
opts.onDisconnected?.();
|
|
357
564
|
resolve(code !== 4000);
|
|
@@ -361,6 +568,338 @@ export async function runHermesRelayManager(opts) {
|
|
|
361
568
|
});
|
|
362
569
|
});
|
|
363
570
|
}
|
|
571
|
+
function toTodayCronMessage(job, now) {
|
|
572
|
+
const runAt = parseHermesDate(job.lastRunAt ?? job.updatedAt ?? null);
|
|
573
|
+
if (!runAt || !isSameLocalDay(runAt, now))
|
|
574
|
+
return null;
|
|
575
|
+
const output = readLatestHermesJobOutput(job.id);
|
|
576
|
+
const createdAtMs = output?.timestampMs ?? runAt.getTime();
|
|
577
|
+
const createdAt = Math.floor(createdAtMs / 1000);
|
|
578
|
+
const content = (output?.content?.trim() || job.prompt?.trim() || "").trim();
|
|
579
|
+
return {
|
|
580
|
+
id: stableInt(`${job.id}:${createdAt}`),
|
|
581
|
+
jobId: job.name?.trim() || job.id,
|
|
582
|
+
sessionKey: `agent:main:cron:${job.id}`,
|
|
583
|
+
runId: `${job.id}:${createdAt}`,
|
|
584
|
+
role: "assistant",
|
|
585
|
+
content,
|
|
586
|
+
createdAt,
|
|
587
|
+
updatedAt: createdAt,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function toCronJobPayload(job) {
|
|
591
|
+
const schedule = toCronSchedulePayload(job);
|
|
592
|
+
const output = readLatestHermesJobOutput(job.id);
|
|
593
|
+
const lastRunAtMs = toUnixMs(job.lastRunAt) ?? (output ? Math.floor(output.timestampMs) : undefined);
|
|
594
|
+
const updatedAtMs = toUnixMs(job.updatedAt) ?? Date.now();
|
|
595
|
+
return {
|
|
596
|
+
id: job.id,
|
|
597
|
+
agentId: null,
|
|
598
|
+
name: job.name?.trim() || job.id,
|
|
599
|
+
description: null,
|
|
600
|
+
enabled: job.enabled ?? true,
|
|
601
|
+
deleteAfterRun: false,
|
|
602
|
+
createdAtMs: toUnixMs(job.createdAt) ?? updatedAtMs,
|
|
603
|
+
updatedAtMs,
|
|
604
|
+
schedule,
|
|
605
|
+
sessionTarget: "isolated",
|
|
606
|
+
wakeMode: "next-heartbeat",
|
|
607
|
+
payload: {
|
|
608
|
+
kind: "agentTurn",
|
|
609
|
+
message: job.prompt ?? "",
|
|
610
|
+
},
|
|
611
|
+
delivery: {
|
|
612
|
+
mode: "none",
|
|
613
|
+
channel: null,
|
|
614
|
+
to: null,
|
|
615
|
+
bestEffort: null,
|
|
616
|
+
},
|
|
617
|
+
state: {
|
|
618
|
+
nextRunAtMs: toUnixMs(job.nextRunAt),
|
|
619
|
+
runningAtMs: job.state === "running" ? Date.now() : null,
|
|
620
|
+
lastRunAtMs,
|
|
621
|
+
lastStatus: normalizeJobStatus(job.lastStatus, job.state),
|
|
622
|
+
lastError: job.lastError ?? null,
|
|
623
|
+
lastDurationMs: null,
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function toCronSchedulePayload(job) {
|
|
628
|
+
const schedule = job.schedule ?? {};
|
|
629
|
+
const kind = typeof schedule.kind === "string" ? schedule.kind : null;
|
|
630
|
+
switch (kind) {
|
|
631
|
+
case "cron":
|
|
632
|
+
return {
|
|
633
|
+
kind: "cron",
|
|
634
|
+
expr: typeof schedule.expr === "string" ? schedule.expr : (job.scheduleDisplay ?? "* * * * *"),
|
|
635
|
+
tz: null,
|
|
636
|
+
};
|
|
637
|
+
case "interval": {
|
|
638
|
+
const minutes = typeof schedule.minutes === "number" ? schedule.minutes : 60;
|
|
639
|
+
return {
|
|
640
|
+
kind: "every",
|
|
641
|
+
everyMs: minutes * 60_000,
|
|
642
|
+
anchorMs: null,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
case "once":
|
|
646
|
+
return {
|
|
647
|
+
kind: "at",
|
|
648
|
+
at: typeof schedule.run_at === "string" ? schedule.run_at : typeof schedule.runAt === "string" ? schedule.runAt : new Date().toISOString(),
|
|
649
|
+
};
|
|
650
|
+
default:
|
|
651
|
+
return {
|
|
652
|
+
kind: "cron",
|
|
653
|
+
expr: "0 9 * * *",
|
|
654
|
+
tz: null,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function normalizeJobStatus(lastStatus, state) {
|
|
659
|
+
if (lastStatus === "ok" || lastStatus === "error")
|
|
660
|
+
return lastStatus;
|
|
661
|
+
if (state === "completed")
|
|
662
|
+
return "ok";
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
function toUnixMs(value) {
|
|
666
|
+
const date = parseHermesDate(value ?? null);
|
|
667
|
+
return date ? date.getTime() : null;
|
|
668
|
+
}
|
|
669
|
+
function normalizeCronCreatePayload(params) {
|
|
670
|
+
const raw = asRecord(params);
|
|
671
|
+
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
|
672
|
+
const schedule = cronScheduleString(raw.schedule);
|
|
673
|
+
const payload = asRecord(raw.payload);
|
|
674
|
+
const prompt = typeof payload.message === "string" ? payload.message.trim() : "";
|
|
675
|
+
if (!name || !schedule || !prompt) {
|
|
676
|
+
throw new Error("invalid cron.add payload");
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
name,
|
|
680
|
+
schedule,
|
|
681
|
+
prompt,
|
|
682
|
+
deliver: "local",
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function normalizeCronUpdatePayload(params) {
|
|
686
|
+
const raw = asRecord(params);
|
|
687
|
+
const id = normalizeCronJobId(params);
|
|
688
|
+
const patchInput = asRecord(raw.patch);
|
|
689
|
+
const patch = {};
|
|
690
|
+
if (typeof patchInput.name === "string" && patchInput.name.trim()) {
|
|
691
|
+
patch.name = patchInput.name.trim();
|
|
692
|
+
}
|
|
693
|
+
if ("enabled" in patchInput && typeof patchInput.enabled === "boolean") {
|
|
694
|
+
patch.enabled = patchInput.enabled;
|
|
695
|
+
}
|
|
696
|
+
if ("schedule" in patchInput) {
|
|
697
|
+
patch.schedule = cronSchedulePayload(patchInput.schedule);
|
|
698
|
+
}
|
|
699
|
+
const payload = asRecord(patchInput.payload);
|
|
700
|
+
if (typeof payload.message === "string") {
|
|
701
|
+
patch.prompt = payload.message.trim();
|
|
702
|
+
}
|
|
703
|
+
if (Object.keys(patch).length === 0) {
|
|
704
|
+
throw new Error("invalid cron.update patch");
|
|
705
|
+
}
|
|
706
|
+
return { id, patch };
|
|
707
|
+
}
|
|
708
|
+
function normalizeCronJobId(params) {
|
|
709
|
+
const raw = asRecord(params);
|
|
710
|
+
const id = typeof raw.id === "string" ? raw.id.trim() : "";
|
|
711
|
+
if (!id)
|
|
712
|
+
throw new Error("missing cron job id");
|
|
713
|
+
return id;
|
|
714
|
+
}
|
|
715
|
+
function normalizeOptionalLimit(params) {
|
|
716
|
+
const raw = asRecord(params);
|
|
717
|
+
const value = typeof raw.limit === "number" ? raw.limit : Number.parseInt(String(raw.limit ?? "50"), 10);
|
|
718
|
+
return Number.isFinite(value) ? Math.max(1, Math.min(value, 200)) : 50;
|
|
719
|
+
}
|
|
720
|
+
function asRecord(value) {
|
|
721
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
722
|
+
}
|
|
723
|
+
function cronScheduleString(rawSchedule) {
|
|
724
|
+
const schedule = asRecord(rawSchedule);
|
|
725
|
+
switch (schedule.kind) {
|
|
726
|
+
case "cron": {
|
|
727
|
+
const expr = typeof schedule.expr === "string" ? schedule.expr.trim() : "";
|
|
728
|
+
if (!expr)
|
|
729
|
+
throw new Error("missing cron expression");
|
|
730
|
+
return expr;
|
|
731
|
+
}
|
|
732
|
+
case "every": {
|
|
733
|
+
const everyMs = typeof schedule.everyMs === "number" ? schedule.everyMs : 0;
|
|
734
|
+
if (everyMs <= 0)
|
|
735
|
+
throw new Error("invalid interval");
|
|
736
|
+
return `every ${formatDurationMs(everyMs)}`;
|
|
737
|
+
}
|
|
738
|
+
case "at": {
|
|
739
|
+
const at = typeof schedule.at === "string" ? schedule.at.trim() : "";
|
|
740
|
+
if (!at)
|
|
741
|
+
throw new Error("missing schedule.at");
|
|
742
|
+
return at;
|
|
743
|
+
}
|
|
744
|
+
default:
|
|
745
|
+
throw new Error("unsupported cron schedule");
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
function cronSchedulePayload(rawSchedule) {
|
|
749
|
+
const schedule = asRecord(rawSchedule);
|
|
750
|
+
switch (schedule.kind) {
|
|
751
|
+
case "cron": {
|
|
752
|
+
const expr = typeof schedule.expr === "string" ? schedule.expr.trim() : "";
|
|
753
|
+
if (!expr)
|
|
754
|
+
throw new Error("missing cron expression");
|
|
755
|
+
return { kind: "cron", expr, display: expr };
|
|
756
|
+
}
|
|
757
|
+
case "every": {
|
|
758
|
+
const everyMs = typeof schedule.everyMs === "number" ? schedule.everyMs : 0;
|
|
759
|
+
if (everyMs <= 0)
|
|
760
|
+
throw new Error("invalid interval");
|
|
761
|
+
return {
|
|
762
|
+
kind: "interval",
|
|
763
|
+
minutes: Math.max(1, Math.round(everyMs / 60_000)),
|
|
764
|
+
display: `every ${formatDurationMs(everyMs)}`,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
case "at": {
|
|
768
|
+
const at = typeof schedule.at === "string" ? schedule.at.trim() : "";
|
|
769
|
+
if (!at)
|
|
770
|
+
throw new Error("missing schedule.at");
|
|
771
|
+
return {
|
|
772
|
+
kind: "once",
|
|
773
|
+
run_at: at,
|
|
774
|
+
display: `once at ${at}`,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
default:
|
|
778
|
+
throw new Error("unsupported cron schedule");
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
function formatDurationMs(ms) {
|
|
782
|
+
if (ms % 86_400_000 === 0)
|
|
783
|
+
return `${ms / 86_400_000}d`;
|
|
784
|
+
if (ms % 3_600_000 === 0)
|
|
785
|
+
return `${ms / 3_600_000}h`;
|
|
786
|
+
return `${Math.max(1, Math.round(ms / 60_000))}m`;
|
|
787
|
+
}
|
|
788
|
+
function buildCronRunEntries(job) {
|
|
789
|
+
if (!job)
|
|
790
|
+
return [];
|
|
791
|
+
const outputs = readHermesJobOutputs(job.id).map((entry) => ({
|
|
792
|
+
ts: entry.timestampMs,
|
|
793
|
+
jobId: job.id,
|
|
794
|
+
jobName: job.name ?? null,
|
|
795
|
+
action: "run",
|
|
796
|
+
status: "ok",
|
|
797
|
+
durationMs: null,
|
|
798
|
+
runAtMs: entry.timestampMs,
|
|
799
|
+
error: null,
|
|
800
|
+
summary: summarizeHermesOutput(entry.content),
|
|
801
|
+
deliveryStatus: null,
|
|
802
|
+
model: job.model ?? null,
|
|
803
|
+
usage: null,
|
|
804
|
+
sessionKey: `agent:main:cron:${job.id}`,
|
|
805
|
+
}));
|
|
806
|
+
if (outputs.length > 0)
|
|
807
|
+
return outputs.sort((lhs, rhs) => Number(rhs.ts) - Number(lhs.ts));
|
|
808
|
+
const lastRunAtMs = toUnixMs(job.lastRunAt);
|
|
809
|
+
if (!lastRunAtMs)
|
|
810
|
+
return [];
|
|
811
|
+
return [{
|
|
812
|
+
ts: lastRunAtMs,
|
|
813
|
+
jobId: job.id,
|
|
814
|
+
jobName: job.name ?? null,
|
|
815
|
+
action: "run",
|
|
816
|
+
status: normalizeJobStatus(job.lastStatus, job.state) ?? "ok",
|
|
817
|
+
durationMs: null,
|
|
818
|
+
runAtMs: lastRunAtMs,
|
|
819
|
+
error: job.lastError ?? null,
|
|
820
|
+
summary: summarizeHermesOutput(job.prompt ?? ""),
|
|
821
|
+
deliveryStatus: null,
|
|
822
|
+
model: job.model ?? null,
|
|
823
|
+
usage: null,
|
|
824
|
+
sessionKey: `agent:main:cron:${job.id}`,
|
|
825
|
+
}];
|
|
826
|
+
}
|
|
827
|
+
function readHermesJobHistory(jobId, limit) {
|
|
828
|
+
return readHermesJobOutputs(jobId)
|
|
829
|
+
.sort((lhs, rhs) => lhs.timestampMs - rhs.timestampMs)
|
|
830
|
+
.slice(-limit)
|
|
831
|
+
.map((entry, index) => ({
|
|
832
|
+
id: stableInt(`${jobId}:${entry.timestampMs}:${index}`),
|
|
833
|
+
role: "assistant",
|
|
834
|
+
content: entry.content,
|
|
835
|
+
runId: `${jobId}:${entry.timestampMs}`,
|
|
836
|
+
idempotencyKey: null,
|
|
837
|
+
createdAt: Math.floor(entry.timestampMs / 1000),
|
|
838
|
+
attachments: [],
|
|
839
|
+
}));
|
|
840
|
+
}
|
|
841
|
+
function readHermesJobOutputs(jobId) {
|
|
842
|
+
const jobDir = join(HERMES_CRON_OUTPUT_DIR, jobId);
|
|
843
|
+
if (!existsSync(jobDir))
|
|
844
|
+
return [];
|
|
845
|
+
return readdirSync(jobDir)
|
|
846
|
+
.filter((name) => name.endsWith(".md"))
|
|
847
|
+
.map((name) => {
|
|
848
|
+
const fullPath = join(jobDir, name);
|
|
849
|
+
const stats = statSync(fullPath);
|
|
850
|
+
return {
|
|
851
|
+
content: readFileSync(fullPath, "utf8"),
|
|
852
|
+
timestampMs: stats.mtimeMs,
|
|
853
|
+
};
|
|
854
|
+
})
|
|
855
|
+
.sort((lhs, rhs) => rhs.timestampMs - lhs.timestampMs);
|
|
856
|
+
}
|
|
857
|
+
function summarizeHermesOutput(content) {
|
|
858
|
+
const summary = content
|
|
859
|
+
.replace(/\r\n/g, "\n")
|
|
860
|
+
.split("\n")
|
|
861
|
+
.map((line) => line.trim())
|
|
862
|
+
.find(Boolean);
|
|
863
|
+
return summary ? summary.slice(0, 180) : null;
|
|
864
|
+
}
|
|
865
|
+
function normalizeRuntimeFileName(params) {
|
|
866
|
+
const raw = asRecord(params);
|
|
867
|
+
const name = typeof raw.name === "string" ? raw.name.trim() : "";
|
|
868
|
+
if (name !== "SOUL.md") {
|
|
869
|
+
throw new Error("unsupported runtime file");
|
|
870
|
+
}
|
|
871
|
+
return name;
|
|
872
|
+
}
|
|
873
|
+
function normalizeRuntimeFileWrite(params) {
|
|
874
|
+
const raw = asRecord(params);
|
|
875
|
+
const name = normalizeRuntimeFileName(params);
|
|
876
|
+
const content = typeof raw.content === "string" ? raw.content : "";
|
|
877
|
+
return { name, content };
|
|
878
|
+
}
|
|
879
|
+
function parseHermesDate(value) {
|
|
880
|
+
if (!value)
|
|
881
|
+
return null;
|
|
882
|
+
const date = new Date(value);
|
|
883
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
884
|
+
}
|
|
885
|
+
function isSameLocalDay(lhs, rhs) {
|
|
886
|
+
return lhs.getFullYear() === rhs.getFullYear()
|
|
887
|
+
&& lhs.getMonth() === rhs.getMonth()
|
|
888
|
+
&& lhs.getDate() === rhs.getDate();
|
|
889
|
+
}
|
|
890
|
+
function readLatestHermesJobOutput(jobId) {
|
|
891
|
+
const latest = readHermesJobOutputs(jobId)[0];
|
|
892
|
+
if (!latest)
|
|
893
|
+
return null;
|
|
894
|
+
return latest;
|
|
895
|
+
}
|
|
896
|
+
function stableInt(value) {
|
|
897
|
+
let hash = 0;
|
|
898
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
899
|
+
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
|
|
900
|
+
}
|
|
901
|
+
return Math.abs(hash) || 1;
|
|
902
|
+
}
|
|
364
903
|
function deriveSessionLabel(sessionKey) {
|
|
365
904
|
if (sessionKey === "agent:main:main")
|
|
366
905
|
return "Main";
|