@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.
@@ -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
- void checkHermesHealth(opts.hermesApiBaseUrl)
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";