@elizaos/plugin-cron 2.0.0-alpha.6 → 2.0.0-alpha.8

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.
@@ -28,275 +28,657 @@ __export(otto_exports, {
28
28
  validateScheduleTimestamp: () => validateScheduleTimestamp
29
29
  });
30
30
 
31
- // src/otto/parse.ts
32
- var ISO_TZ_RE = /(Z|[+-]\d{2}:?\d{2})$/i;
33
- var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
34
- var ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T/;
35
- function normalizeUtcIso(raw) {
36
- if (ISO_TZ_RE.test(raw)) {
37
- return raw;
38
- }
39
- if (ISO_DATE_RE.test(raw)) {
40
- return `${raw}T00:00:00Z`;
41
- }
42
- if (ISO_DATE_TIME_RE.test(raw)) {
43
- return `${raw}Z`;
44
- }
45
- return raw;
46
- }
47
- function parseAbsoluteTimeMs(input) {
48
- const raw = input.trim();
49
- if (!raw) {
50
- return null;
31
+ // src/otto/delivery.ts
32
+ function normalizeChannel(value) {
33
+ if (typeof value !== "string") {
34
+ return void 0;
51
35
  }
52
- if (/^\d+$/.test(raw)) {
53
- const n = Number(raw);
54
- if (Number.isFinite(n) && n > 0) {
55
- return Math.floor(n);
56
- }
36
+ const trimmed = value.trim().toLowerCase();
37
+ if (!trimmed) {
38
+ return void 0;
57
39
  }
58
- const parsed = Date.parse(normalizeUtcIso(raw));
59
- return Number.isFinite(parsed) ? parsed : null;
40
+ return trimmed;
60
41
  }
61
-
62
- // src/otto/payload-migration.ts
63
- function readString(value) {
42
+ function normalizeTo(value) {
64
43
  if (typeof value !== "string") {
65
44
  return void 0;
66
45
  }
67
- return value;
68
- }
69
- function normalizeChannel(value) {
70
- return value.trim().toLowerCase();
46
+ const trimmed = value.trim();
47
+ return trimmed ? trimmed : void 0;
71
48
  }
72
- function migrateLegacyCronPayload(payload) {
73
- let mutated = false;
74
- const channelValue = readString(payload.channel);
75
- const providerValue = readString(payload.provider);
76
- const nextChannel = typeof channelValue === "string" && channelValue.trim().length > 0 ? normalizeChannel(channelValue) : typeof providerValue === "string" && providerValue.trim().length > 0 ? normalizeChannel(providerValue) : "";
77
- if (nextChannel) {
78
- if (channelValue !== nextChannel) {
79
- payload.channel = nextChannel;
80
- mutated = true;
81
- }
82
- }
83
- if ("provider" in payload) {
84
- delete payload.provider;
85
- mutated = true;
49
+ function resolveCronDeliveryPlan(job) {
50
+ const payload = job.payload.kind === "agentTurn" ? job.payload : null;
51
+ const delivery = job.delivery;
52
+ const hasDelivery = delivery && typeof delivery === "object";
53
+ const rawMode = hasDelivery ? delivery.mode : void 0;
54
+ const mode = rawMode === "announce" ? "announce" : rawMode === "none" ? "none" : rawMode === "deliver" ? "announce" : void 0;
55
+ const payloadChannel = normalizeChannel(payload?.channel);
56
+ const payloadTo = normalizeTo(payload?.to);
57
+ const deliveryChannel = normalizeChannel(
58
+ delivery?.channel
59
+ );
60
+ const deliveryTo = normalizeTo(
61
+ delivery?.to
62
+ );
63
+ const channel = deliveryChannel ?? payloadChannel ?? "last";
64
+ const to = deliveryTo ?? payloadTo;
65
+ if (hasDelivery) {
66
+ const resolvedMode = mode ?? "none";
67
+ return {
68
+ mode: resolvedMode,
69
+ channel,
70
+ to,
71
+ source: "delivery",
72
+ requested: resolvedMode === "announce"
73
+ };
86
74
  }
87
- return mutated;
75
+ const legacyMode = payload?.deliver === true ? "explicit" : payload?.deliver === false ? "off" : "auto";
76
+ const hasExplicitTarget = Boolean(to);
77
+ const requested = legacyMode === "explicit" || legacyMode === "auto" && hasExplicitTarget;
78
+ return {
79
+ mode: requested ? "announce" : "none",
80
+ channel,
81
+ to,
82
+ source: "payload",
83
+ requested
84
+ };
88
85
  }
89
86
 
90
- // src/otto/normalize.ts
91
- var DEFAULT_OPTIONS = {
92
- applyDefaults: false
93
- };
94
- function isRecord(value) {
95
- return typeof value === "object" && value !== null && !Array.isArray(value);
87
+ // src/otto/detect.ts
88
+ function isOttoPayload(payload) {
89
+ const kind = payload.kind;
90
+ return kind === "systemEvent" || kind === "agentTurn";
96
91
  }
97
- function coerceSchedule(schedule) {
98
- const next = { ...schedule };
99
- const kind = typeof schedule.kind === "string" ? schedule.kind : void 0;
100
- const atMsRaw = schedule.atMs;
101
- const atRaw = schedule.at;
102
- const atString = typeof atRaw === "string" ? atRaw.trim() : "";
103
- const parsedAtMs = typeof atMsRaw === "number" ? atMsRaw : typeof atMsRaw === "string" ? parseAbsoluteTimeMs(atMsRaw) : atString ? parseAbsoluteTimeMs(atString) : null;
104
- if (!kind) {
105
- if (typeof schedule.atMs === "number" || typeof schedule.at === "string" || typeof schedule.atMs === "string") {
106
- next.kind = "at";
107
- } else if (typeof schedule.everyMs === "number") {
108
- next.kind = "every";
109
- } else if (typeof schedule.expr === "string") {
110
- next.kind = "cron";
111
- }
92
+
93
+ // src/otto/executor.ts
94
+ import {
95
+ ChannelType as ChannelType2,
96
+ logger as logger3,
97
+ stringToUuid as stringToUuid2
98
+ } from "@elizaos/core";
99
+ import { v4 as uuidv43 } from "uuid";
100
+
101
+ // src/heartbeat/delivery.ts
102
+ import {
103
+ logger
104
+ } from "@elizaos/core";
105
+ import { v4 as uuidv4 } from "uuid";
106
+ var LAST_ROUTE_COMPONENT_TYPE = "last_delivery_route";
107
+ var INTERNAL_SOURCES = /* @__PURE__ */ new Set(["cron", "webhook", "heartbeat", "internal"]);
108
+ async function readLastRoute(runtime) {
109
+ const component = await runtime.getComponent(
110
+ runtime.agentId,
111
+ LAST_ROUTE_COMPONENT_TYPE
112
+ );
113
+ if (!component?.data) {
114
+ return null;
112
115
  }
113
- if (atString) {
114
- next.at = parsedAtMs ? new Date(parsedAtMs).toISOString() : atString;
115
- } else if (parsedAtMs !== null) {
116
- next.at = new Date(parsedAtMs).toISOString();
116
+ const data = component.data;
117
+ const source = typeof data.source === "string" ? data.source : "";
118
+ if (!source) {
119
+ return null;
117
120
  }
118
- if ("atMs" in next) {
119
- delete next.atMs;
121
+ return {
122
+ source,
123
+ channelId: typeof data.channelId === "string" ? data.channelId : void 0
124
+ };
125
+ }
126
+ async function writeLastRoute(runtime, target) {
127
+ const existing = await runtime.getComponent(
128
+ runtime.agentId,
129
+ LAST_ROUTE_COMPONENT_TYPE
130
+ );
131
+ const data = {
132
+ source: target.source,
133
+ channelId: target.channelId ?? null,
134
+ updatedAt: Date.now()
135
+ };
136
+ if (existing) {
137
+ await runtime.updateComponent({
138
+ ...existing,
139
+ data
140
+ });
141
+ } else {
142
+ await runtime.createComponent({
143
+ id: uuidv4(),
144
+ entityId: runtime.agentId,
145
+ type: LAST_ROUTE_COMPONENT_TYPE,
146
+ data,
147
+ createdAt: Date.now()
148
+ });
120
149
  }
121
- return next;
122
150
  }
123
- function coercePayload(payload) {
124
- const next = { ...payload };
125
- migrateLegacyCronPayload(next);
126
- return next;
151
+ async function scanRoomsForExternalTarget(runtime) {
152
+ const rooms = await runtime.getRooms(runtime.agentId).catch(() => []);
153
+ for (const room of rooms) {
154
+ if (room.source && !INTERNAL_SOURCES.has(room.source)) {
155
+ return {
156
+ source: room.source,
157
+ channelId: room.channelId ?? void 0
158
+ };
159
+ }
160
+ }
161
+ return null;
127
162
  }
128
- function coerceDelivery(delivery) {
129
- const next = { ...delivery };
130
- if (typeof delivery.mode === "string") {
131
- const mode = delivery.mode.trim().toLowerCase();
132
- next.mode = mode === "deliver" ? "announce" : mode;
163
+ async function resolveDeliveryTarget(runtime, channel, to) {
164
+ if (channel !== "last") {
165
+ return { source: channel, channelId: to };
133
166
  }
134
- if (typeof delivery.channel === "string") {
135
- const trimmed = delivery.channel.trim().toLowerCase();
136
- if (trimmed) {
137
- next.channel = trimmed;
138
- } else {
139
- delete next.channel;
167
+ const stored = await readLastRoute(runtime);
168
+ if (stored) {
169
+ if (to) {
170
+ return { source: stored.source, channelId: to };
140
171
  }
172
+ return stored;
141
173
  }
142
- if (typeof delivery.to === "string") {
143
- const trimmed = delivery.to.trim();
144
- if (trimmed) {
145
- next.to = trimmed;
146
- } else {
147
- delete next.to;
174
+ const scanned = await scanRoomsForExternalTarget(runtime);
175
+ if (scanned) {
176
+ logger.debug(
177
+ `[Delivery] Resolved "last" via room scan: ${scanned.source}:${scanned.channelId ?? "(default)"}`
178
+ );
179
+ if (to) {
180
+ return { source: scanned.source, channelId: to };
148
181
  }
182
+ return scanned;
149
183
  }
150
- return next;
184
+ return null;
151
185
  }
152
- function hasLegacyDeliveryHints(payload) {
153
- if (typeof payload.deliver === "boolean") {
154
- return true;
155
- }
156
- if (typeof payload.bestEffortDeliver === "boolean") {
157
- return true;
186
+ async function deliverToTarget(runtime, content, channel, to, bestEffort) {
187
+ const target = await resolveDeliveryTarget(runtime, channel, to);
188
+ if (!target) {
189
+ const msg = `No delivery target resolved for channel "${channel}"`;
190
+ if (bestEffort) {
191
+ logger.warn(`[Delivery] ${msg}`);
192
+ return null;
193
+ }
194
+ throw new Error(msg);
158
195
  }
159
- if (typeof payload.to === "string" && payload.to.trim()) {
160
- return true;
196
+ const deliveryError = await runtime.sendMessageToTarget(
197
+ {
198
+ source: target.source,
199
+ channelId: target.channelId
200
+ },
201
+ content
202
+ ).then(
203
+ () => null,
204
+ (err) => err
205
+ );
206
+ if (deliveryError) {
207
+ if (bestEffort) {
208
+ logger.warn(
209
+ `[Delivery] Best-effort delivery failed to ${target.source}: ${deliveryError.message}`
210
+ );
211
+ return null;
212
+ }
213
+ throw deliveryError;
161
214
  }
162
- return false;
163
- }
164
- function buildDeliveryFromLegacyPayload(payload) {
165
- const deliver = payload.deliver;
166
- const mode = deliver === false ? "none" : "announce";
167
- const channelRaw = typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : "";
168
- const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
169
- const next = { mode };
170
- if (channelRaw) {
171
- next.channel = channelRaw;
172
- }
173
- if (toRaw) {
174
- next.to = toRaw;
175
- }
176
- if (typeof payload.bestEffortDeliver === "boolean") {
177
- next.bestEffort = payload.bestEffortDeliver;
215
+ await writeLastRoute(runtime, target).catch((err) => {
216
+ logger.debug(`[Delivery] Failed to persist last route: ${err.message}`);
217
+ });
218
+ logger.info(
219
+ `[Delivery] Delivered to ${target.source}${target.channelId ? `:${target.channelId}` : ""}`
220
+ );
221
+ return target;
222
+ }
223
+
224
+ // src/heartbeat/queue.ts
225
+ var queues = /* @__PURE__ */ new Map();
226
+ function agentQueue(agentId) {
227
+ let q = queues.get(agentId);
228
+ if (!q) {
229
+ q = [];
230
+ queues.set(agentId, q);
178
231
  }
179
- return next;
232
+ return q;
180
233
  }
181
- function stripLegacyDeliveryFields(payload) {
182
- if ("deliver" in payload) {
183
- delete payload.deliver;
234
+ function pushSystemEvent(agentId, text, source) {
235
+ agentQueue(agentId).push({ text, source, ts: Date.now() });
236
+ }
237
+ function drainSystemEvents(agentId) {
238
+ const q = queues.get(agentId);
239
+ if (!q || q.length === 0) {
240
+ return [];
184
241
  }
185
- if ("channel" in payload) {
186
- delete payload.channel;
242
+ const events = [...q];
243
+ q.length = 0;
244
+ return events;
245
+ }
246
+ function pendingEventCount(agentId) {
247
+ return queues.get(agentId)?.length ?? 0;
248
+ }
249
+
250
+ // src/heartbeat/worker.ts
251
+ import fs from "node:fs/promises";
252
+ import path from "node:path";
253
+ import {
254
+ ChannelType,
255
+ logger as logger2,
256
+ stringToUuid
257
+ } from "@elizaos/core";
258
+ import { v4 as uuidv42 } from "uuid";
259
+
260
+ // src/heartbeat/config.ts
261
+ var DEFAULT_EVERY_MS = 30 * 60 * 1e3;
262
+ var DEFAULT_PROMPT_FILE = "HEARTBEAT.md";
263
+ function parseDurationToMs(raw) {
264
+ const match = raw.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day|ms)?$/i);
265
+ if (!match) {
266
+ return DEFAULT_EVERY_MS;
187
267
  }
188
- if ("to" in payload) {
189
- delete payload.to;
268
+ const value = Number.parseFloat(match[1]);
269
+ const unit = (match[2] ?? "m").toLowerCase();
270
+ switch (unit) {
271
+ case "ms":
272
+ return value;
273
+ case "s":
274
+ case "sec":
275
+ return value * 1e3;
276
+ case "m":
277
+ case "min":
278
+ return value * 6e4;
279
+ case "h":
280
+ case "hr":
281
+ return value * 36e5;
282
+ case "d":
283
+ case "day":
284
+ return value * 864e5;
285
+ default:
286
+ return value * 6e4;
190
287
  }
191
- if ("bestEffortDeliver" in payload) {
192
- delete payload.bestEffortDeliver;
288
+ }
289
+ function parseActiveHours(raw) {
290
+ const start = typeof raw.start === "string" ? raw.start.trim() : "";
291
+ const end = typeof raw.end === "string" ? raw.end.trim() : "";
292
+ if (!start || !end) {
293
+ return null;
193
294
  }
295
+ return { start, end };
194
296
  }
195
- function unwrapJob(raw) {
196
- if (isRecord(raw.data)) {
197
- return raw.data;
297
+ function resolveHeartbeatConfig(runtime) {
298
+ const settings = runtime.character?.settings ?? {};
299
+ const hb = settings.heartbeat ?? {};
300
+ const everyRaw = typeof hb.every === "string" ? hb.every : "";
301
+ const everyMs = everyRaw ? parseDurationToMs(everyRaw) : DEFAULT_EVERY_MS;
302
+ const activeHours = hb.activeHours && typeof hb.activeHours === "object" ? parseActiveHours(hb.activeHours) : null;
303
+ const target = typeof hb.target === "string" ? hb.target.trim() : "last";
304
+ const promptFile = typeof hb.prompt === "string" && hb.prompt.trim() ? hb.prompt.trim() : DEFAULT_PROMPT_FILE;
305
+ const enabled = hb.enabled !== false;
306
+ return { everyMs, activeHours, target, promptFile, enabled };
307
+ }
308
+ function isWithinActiveHours(activeHours) {
309
+ if (!activeHours) {
310
+ return true;
198
311
  }
199
- if (isRecord(raw.job)) {
200
- return raw.job;
312
+ const now = /* @__PURE__ */ new Date();
313
+ const hh = now.getHours();
314
+ const mm = now.getMinutes();
315
+ const currentMinutes = hh * 60 + mm;
316
+ const [startH, startM] = activeHours.start.split(":").map(Number);
317
+ const [endH, endM] = activeHours.end.split(":").map(Number);
318
+ const startMinutes = (startH ?? 0) * 60 + (startM ?? 0);
319
+ const endMinutes = (endH ?? 0) * 60 + (endM ?? 0);
320
+ if (startMinutes <= endMinutes) {
321
+ return currentMinutes >= startMinutes && currentMinutes < endMinutes;
201
322
  }
202
- return raw;
323
+ return currentMinutes >= startMinutes || currentMinutes < endMinutes;
203
324
  }
204
- function defaultSanitizeAgentId(raw) {
205
- return raw.trim().toLowerCase();
325
+
326
+ // src/heartbeat/worker.ts
327
+ var HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK";
328
+ var HEARTBEAT_ROOM_KEY = "heartbeat:main";
329
+ var HEARTBEAT_WORKER_NAME = "heartbeat";
330
+ async function readHeartbeatFile(runtime, filename) {
331
+ const settings = runtime.character?.settings ?? {};
332
+ const workspace = typeof settings.workspace === "string" ? settings.workspace : process.cwd();
333
+ const filePath = path.resolve(workspace, filename);
334
+ const content = await fs.readFile(filePath, "utf-8").catch(() => null);
335
+ return content;
206
336
  }
207
- function normalizeCronJobInput(raw, options = DEFAULT_OPTIONS) {
208
- if (!isRecord(raw)) {
209
- return null;
337
+ function buildHeartbeatPrompt(heartbeatMd, events) {
338
+ const parts = [];
339
+ parts.push("[Heartbeat]");
340
+ if (heartbeatMd) {
341
+ parts.push("");
342
+ parts.push("# HEARTBEAT.md");
343
+ parts.push(heartbeatMd.trim());
344
+ parts.push("");
345
+ parts.push("Follow the instructions in HEARTBEAT.md strictly.");
210
346
  }
211
- const base = unwrapJob(raw);
212
- const next = { ...base };
213
- const sanitizeAgentId = options.sanitizeAgentId ?? defaultSanitizeAgentId;
214
- if ("agentId" in base) {
215
- const agentId = base.agentId;
216
- if (agentId === null) {
217
- next.agentId = null;
218
- } else if (typeof agentId === "string") {
219
- const trimmed = agentId.trim();
220
- if (trimmed) {
221
- next.agentId = sanitizeAgentId(trimmed);
222
- } else {
223
- delete next.agentId;
224
- }
347
+ if (events.length > 0) {
348
+ parts.push("");
349
+ parts.push("## System events since last heartbeat");
350
+ for (const ev of events) {
351
+ const age = Math.round((Date.now() - ev.ts) / 1e3);
352
+ parts.push(`- [${ev.source}, ${age}s ago] ${ev.text}`);
225
353
  }
226
354
  }
227
- if ("enabled" in base) {
228
- const enabled = base.enabled;
229
- if (typeof enabled === "boolean") {
230
- next.enabled = enabled;
231
- } else if (typeof enabled === "string") {
232
- const trimmed = enabled.trim().toLowerCase();
233
- if (trimmed === "true") {
234
- next.enabled = true;
235
- }
236
- if (trimmed === "false") {
237
- next.enabled = false;
238
- }
239
- }
355
+ parts.push("");
356
+ parts.push(
357
+ 'If nothing requires your attention right now, reply with exactly "HEARTBEAT_OK" and nothing else.'
358
+ );
359
+ return parts.join("\n");
360
+ }
361
+ function isHeartbeatOk(text) {
362
+ const trimmed = text.trim();
363
+ return trimmed === HEARTBEAT_OK_TOKEN || trimmed.startsWith(`${HEARTBEAT_OK_TOKEN}
364
+ `) || trimmed.startsWith(`${HEARTBEAT_OK_TOKEN} `);
365
+ }
366
+ async function ensureHeartbeatRoom(runtime) {
367
+ const roomId = stringToUuid(
368
+ `${runtime.agentId}-${HEARTBEAT_ROOM_KEY}`
369
+ );
370
+ const existing = await runtime.getRoom(roomId);
371
+ if (!existing) {
372
+ await runtime.createRoom({
373
+ id: roomId,
374
+ name: "Heartbeat",
375
+ source: "cron",
376
+ type: ChannelType.GROUP,
377
+ channelId: HEARTBEAT_ROOM_KEY,
378
+ worldId: runtime.agentId
379
+ });
380
+ await runtime.addParticipant(runtime.agentId, roomId);
240
381
  }
241
- if (isRecord(base.schedule)) {
242
- next.schedule = coerceSchedule(base.schedule);
382
+ return roomId;
383
+ }
384
+ async function runHeartbeatTick(runtime, config) {
385
+ if (!isWithinActiveHours(config.activeHours)) {
386
+ logger2.debug("[Heartbeat] Outside active hours, skipping");
387
+ return;
243
388
  }
244
- if (isRecord(base.payload)) {
245
- next.payload = coercePayload(base.payload);
389
+ const events = drainSystemEvents(runtime.agentId);
390
+ const heartbeatMd = await readHeartbeatFile(runtime, config.promptFile);
391
+ if (!heartbeatMd && events.length === 0) {
392
+ logger2.debug("[Heartbeat] No HEARTBEAT.md and no pending events, skipping");
393
+ return;
246
394
  }
247
- if (isRecord(base.delivery)) {
248
- next.delivery = coerceDelivery(base.delivery);
395
+ const promptText = buildHeartbeatPrompt(heartbeatMd, events);
396
+ const roomId = await ensureHeartbeatRoom(runtime);
397
+ const messageId = uuidv42();
398
+ const memory = {
399
+ id: messageId,
400
+ entityId: runtime.agentId,
401
+ roomId,
402
+ agentId: runtime.agentId,
403
+ content: { text: promptText },
404
+ createdAt: Date.now()
405
+ };
406
+ let responseText = "";
407
+ const callback = async (response) => {
408
+ if (response.text) {
409
+ responseText += response.text;
410
+ }
411
+ return [];
412
+ };
413
+ logger2.info(
414
+ `[Heartbeat] Running tick (${events.length} pending events, HEARTBEAT.md: ${heartbeatMd ? "yes" : "no"})`
415
+ );
416
+ if (!runtime.messageService) {
417
+ throw new Error("Message service is not available");
249
418
  }
250
- if (isRecord(base.isolation)) {
251
- delete next.isolation;
419
+ await runtime.messageService.handleMessage(runtime, memory, callback);
420
+ if (!responseText.trim() || isHeartbeatOk(responseText)) {
421
+ logger2.debug(
422
+ "[Heartbeat] Agent responded HEARTBEAT_OK, suppressing delivery"
423
+ );
424
+ return;
252
425
  }
253
- if (options.applyDefaults) {
254
- if (!next.wakeMode) {
255
- next.wakeMode = "next-heartbeat";
256
- }
257
- if (typeof next.enabled !== "boolean") {
258
- next.enabled = true;
259
- }
260
- if (!next.sessionTarget && isRecord(next.payload)) {
261
- const kind = typeof next.payload.kind === "string" ? next.payload.kind : "";
262
- if (kind === "systemEvent") {
263
- next.sessionTarget = "main";
264
- }
265
- if (kind === "agentTurn") {
266
- next.sessionTarget = "isolated";
267
- }
426
+ logger2.info(
427
+ `[Heartbeat] Agent has something to say, delivering to target "${config.target}"`
428
+ );
429
+ if (config.target === "none") {
430
+ return;
431
+ }
432
+ let channel;
433
+ let to;
434
+ if (config.target === "last" || config.target === "none") {
435
+ channel = config.target;
436
+ } else {
437
+ const colonIdx = config.target.indexOf(":");
438
+ if (colonIdx === -1) {
439
+ channel = config.target;
440
+ } else {
441
+ channel = config.target.slice(0, colonIdx);
442
+ to = config.target.slice(colonIdx + 1) || void 0;
268
443
  }
269
- if ("schedule" in next && isRecord(next.schedule) && next.schedule.kind === "at" && !("deleteAfterRun" in next)) {
270
- next.deleteAfterRun = true;
444
+ }
445
+ if (channel === "none") {
446
+ return;
447
+ }
448
+ await deliverToTarget(
449
+ runtime,
450
+ { text: responseText },
451
+ channel,
452
+ to,
453
+ true
454
+ // bestEffort for heartbeat -- don't crash the tick on delivery failure
455
+ );
456
+ }
457
+ var heartbeatWorker = {
458
+ name: HEARTBEAT_WORKER_NAME,
459
+ async execute(runtime, _options, _task) {
460
+ const config = resolveHeartbeatConfig(runtime);
461
+ if (!config.enabled) {
462
+ return void 0;
271
463
  }
272
- const payload = isRecord(next.payload) ? next.payload : null;
273
- const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";
274
- const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : "";
275
- const isIsolatedAgentTurn = sessionTarget === "isolated" || sessionTarget === "" && payloadKind === "agentTurn";
276
- const hasDelivery = "delivery" in next && next.delivery !== void 0;
277
- const hasLegacyDelivery = payload ? hasLegacyDeliveryHints(payload) : false;
278
- if (!hasDelivery && isIsolatedAgentTurn && payloadKind === "agentTurn") {
279
- if (payload && hasLegacyDelivery) {
280
- next.delivery = buildDeliveryFromLegacyPayload(payload);
281
- stripLegacyDeliveryFields(payload);
282
- } else {
283
- next.delivery = { mode: "announce" };
464
+ await runHeartbeatTick(runtime, config);
465
+ return void 0;
466
+ }
467
+ };
468
+ async function startHeartbeat(runtime) {
469
+ const config = resolveHeartbeatConfig(runtime);
470
+ if (!config.enabled) {
471
+ logger2.info("[Heartbeat] Disabled via config");
472
+ return;
473
+ }
474
+ runtime.registerTaskWorker(heartbeatWorker);
475
+ const existingTasks = await runtime.getTasks({
476
+ roomId: runtime.agentId,
477
+ tags: ["heartbeat", "queue", "repeat"],
478
+ agentIds: [runtime.agentId]
479
+ });
480
+ const alreadyExists = existingTasks.some(
481
+ (t) => t.name === HEARTBEAT_WORKER_NAME
482
+ );
483
+ if (!alreadyExists) {
484
+ await runtime.createTask({
485
+ name: HEARTBEAT_WORKER_NAME,
486
+ description: "Periodic agent heartbeat \u2013 reads HEARTBEAT.md and checks system events",
487
+ roomId: runtime.agentId,
488
+ worldId: runtime.agentId,
489
+ tags: ["heartbeat", "queue", "repeat"],
490
+ metadata: {
491
+ updateInterval: config.everyMs,
492
+ updatedAt: Date.now(),
493
+ blocking: true
284
494
  }
285
- }
495
+ });
496
+ logger2.info(
497
+ `[Heartbeat] Created recurring task (every ${Math.round(config.everyMs / 1e3)}s)`
498
+ );
499
+ } else {
500
+ logger2.info("[Heartbeat] Recurring task already exists");
286
501
  }
287
- return next;
288
502
  }
289
- function normalizeCronJobCreate(raw, options) {
290
- return normalizeCronJobInput(raw, {
291
- applyDefaults: true,
292
- ...options
503
+ async function wakeHeartbeatNow(runtime) {
504
+ runtime.registerTaskWorker(heartbeatWorker);
505
+ await runtime.createTask({
506
+ name: HEARTBEAT_WORKER_NAME,
507
+ description: "Immediate heartbeat wake",
508
+ roomId: runtime.agentId,
509
+ worldId: runtime.agentId,
510
+ tags: ["heartbeat", "queue"],
511
+ metadata: {
512
+ updatedAt: 0,
513
+ // ensures it runs immediately
514
+ blocking: false
515
+ }
293
516
  });
517
+ logger2.info("[Heartbeat] Queued immediate wake");
294
518
  }
295
- function normalizeCronJobPatch(raw, options) {
296
- return normalizeCronJobInput(raw, {
297
- applyDefaults: false,
298
- ...options
519
+
520
+ // src/types.ts
521
+ var DEFAULT_CRON_CONFIG = {
522
+ minIntervalMs: 1e4,
523
+ // 10 seconds minimum
524
+ maxJobsPerAgent: 100,
525
+ defaultTimeoutMs: 3e5,
526
+ // 5 minutes
527
+ catchUpMissedJobs: false,
528
+ catchUpWindowMs: 36e5,
529
+ // 1 hour
530
+ timerCheckIntervalMs: 1e3
531
+ // 1 second
532
+ };
533
+
534
+ // src/otto/executor.ts
535
+ function withTimeout(promise, timeoutMs) {
536
+ let timer;
537
+ const timeout = new Promise((_, reject) => {
538
+ timer = setTimeout(
539
+ () => reject(new Error("Job execution timeout")),
540
+ timeoutMs
541
+ );
542
+ });
543
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
544
+ }
545
+ async function executeSystemEvent(runtime, job, payload) {
546
+ const startedAtMs = Date.now();
547
+ pushSystemEvent(runtime.agentId, payload.text, `cron:${job.id}`);
548
+ logger3.info(
549
+ `[Otto Executor] Queued system event for heartbeat: "${payload.text.slice(0, 80)}"`
550
+ );
551
+ if (job.wakeMode === "now") {
552
+ await wakeHeartbeatNow(runtime);
553
+ }
554
+ return {
555
+ status: "ok",
556
+ durationMs: Date.now() - startedAtMs,
557
+ output: `System event queued (wake: ${job.wakeMode})`
558
+ };
559
+ }
560
+ async function ensureCronRoom(runtime, jobId, jobName) {
561
+ const roomKey = `cron:${jobId}`;
562
+ const roomId = stringToUuid2(`${runtime.agentId}-${roomKey}`);
563
+ const existing = await runtime.getRoom(roomId);
564
+ if (!existing) {
565
+ await runtime.createRoom({
566
+ id: roomId,
567
+ name: `Cron: ${jobName}`,
568
+ source: "cron",
569
+ type: ChannelType2.GROUP,
570
+ channelId: roomKey
571
+ });
572
+ await runtime.addParticipant(runtime.agentId, roomId);
573
+ }
574
+ return roomId;
575
+ }
576
+ async function executeAgentTurn(runtime, job, payload, config) {
577
+ const startedAtMs = Date.now();
578
+ const timeoutMs = payload.timeoutSeconds ? payload.timeoutSeconds * 1e3 : config.defaultTimeoutMs ?? DEFAULT_CRON_CONFIG.defaultTimeoutMs;
579
+ const roomId = await ensureCronRoom(runtime, job.id, job.name);
580
+ const promptPrefix = `[cron:${job.id} ${job.name}]`;
581
+ const messageText = `${promptPrefix} ${payload.message}`;
582
+ const messageId = uuidv43();
583
+ const memory = {
584
+ id: messageId,
585
+ entityId: runtime.agentId,
586
+ roomId,
587
+ agentId: runtime.agentId,
588
+ content: { text: messageText },
589
+ createdAt: Date.now()
590
+ };
591
+ let responseText = "";
592
+ const callback = async (response) => {
593
+ if (response.text) {
594
+ responseText += response.text;
595
+ }
596
+ return [];
597
+ };
598
+ let status = "ok";
599
+ let error;
600
+ if (!runtime.messageService) {
601
+ return {
602
+ status: "error",
603
+ durationMs: Date.now() - startedAtMs,
604
+ error: "Message service is not available"
605
+ };
606
+ }
607
+ const runPromise = runtime.messageService.handleMessage(
608
+ runtime,
609
+ memory,
610
+ callback
611
+ );
612
+ await withTimeout(runPromise, timeoutMs).catch((err) => {
613
+ if (err.message === "Job execution timeout") {
614
+ status = "timeout";
615
+ error = "Execution timed out";
616
+ } else {
617
+ status = "error";
618
+ error = err.message;
619
+ }
299
620
  });
621
+ const durationMs = Date.now() - startedAtMs;
622
+ if (status !== "ok") {
623
+ return { status, durationMs, error };
624
+ }
625
+ const plan = resolveCronDeliveryPlan(job);
626
+ if (plan.requested && responseText.trim() && responseText.trim() !== "HEARTBEAT_OK") {
627
+ logger3.info(
628
+ `[Otto Executor] Delivering response for "${job.name}" to ${plan.channel}${plan.to ? `:${plan.to}` : ""}`
629
+ );
630
+ const deliveryError = await deliverToTarget(
631
+ runtime,
632
+ { text: responseText },
633
+ plan.channel,
634
+ plan.to,
635
+ job.delivery?.bestEffort
636
+ ).then(
637
+ () => null,
638
+ (err) => err
639
+ );
640
+ if (deliveryError) {
641
+ return {
642
+ status: "error",
643
+ durationMs: Date.now() - startedAtMs,
644
+ output: responseText,
645
+ error: `Delivery failed: ${deliveryError.message}`
646
+ };
647
+ }
648
+ }
649
+ if (responseText.trim() && responseText.trim() !== "HEARTBEAT_OK") {
650
+ const summary = responseText.length > 200 ? `${responseText.slice(0, 200)}\u2026` : responseText;
651
+ pushSystemEvent(
652
+ runtime.agentId,
653
+ `[Cron "${job.name}" completed] ${summary}`,
654
+ `cron:${job.id}`
655
+ );
656
+ if (job.wakeMode === "now") {
657
+ await wakeHeartbeatNow(runtime);
658
+ }
659
+ }
660
+ return {
661
+ status: "ok",
662
+ durationMs,
663
+ output: responseText || void 0
664
+ };
665
+ }
666
+ async function executeOttoJob(runtime, job, config) {
667
+ const { payload } = job;
668
+ switch (payload.kind) {
669
+ case "systemEvent":
670
+ return executeSystemEvent(runtime, job, payload);
671
+ case "agentTurn":
672
+ return executeAgentTurn(runtime, job, payload, config);
673
+ default: {
674
+ const kind = payload.kind;
675
+ return {
676
+ status: "error",
677
+ durationMs: 0,
678
+ error: `Unknown Otto payload kind: ${kind}`
679
+ };
680
+ }
681
+ }
300
682
  }
301
683
 
302
684
  // src/otto/job-utils.ts
@@ -310,7 +692,9 @@ function assertSupportedJobSpec(job) {
310
692
  }
311
693
  function assertDeliverySupport(job) {
312
694
  if (job.delivery && job.sessionTarget !== "isolated") {
313
- throw new Error('cron delivery config is only supported for sessionTarget="isolated"');
695
+ throw new Error(
696
+ 'cron delivery config is only supported for sessionTarget="isolated"'
697
+ );
314
698
  }
315
699
  }
316
700
  function normalizeRequiredName(name) {
@@ -362,943 +746,602 @@ function applyJobPatch(job, patch) {
362
746
  if (!patch.delivery && patch.payload?.kind === "agentTurn") {
363
747
  const legacyDeliveryPatch = buildLegacyDeliveryPatch(patch.payload);
364
748
  if (legacyDeliveryPatch && job.sessionTarget === "isolated" && job.payload.kind === "agentTurn") {
365
- job.delivery = mergeCronDelivery(job.delivery, legacyDeliveryPatch);
366
- }
367
- }
368
- if (patch.delivery) {
369
- job.delivery = mergeCronDelivery(job.delivery, patch.delivery);
370
- }
371
- if (job.sessionTarget === "main" && job.delivery) {
372
- job.delivery = void 0;
373
- }
374
- if (patch.state) {
375
- job.state = { ...job.state, ...patch.state };
376
- }
377
- if ("agentId" in patch) {
378
- job.agentId = normalizeOptionalAgentId(patch.agentId);
379
- }
380
- assertSupportedJobSpec(job);
381
- assertDeliverySupport(job);
382
- }
383
- function mergeCronPayload(existing, patch) {
384
- if (patch.kind !== existing.kind) {
385
- return buildPayloadFromPatch(patch);
386
- }
387
- if (patch.kind === "systemEvent") {
388
- if (existing.kind !== "systemEvent") {
389
- return buildPayloadFromPatch(patch);
390
- }
391
- const text = typeof patch.text === "string" ? patch.text : existing.text;
392
- return { kind: "systemEvent", text };
393
- }
394
- if (existing.kind !== "agentTurn") {
395
- return buildPayloadFromPatch(patch);
396
- }
397
- const next = { ...existing };
398
- if (typeof patch.message === "string") {
399
- next.message = patch.message;
400
- }
401
- if (typeof patch.model === "string") {
402
- next.model = patch.model;
403
- }
404
- if (typeof patch.thinking === "string") {
405
- next.thinking = patch.thinking;
406
- }
407
- if (typeof patch.timeoutSeconds === "number") {
408
- next.timeoutSeconds = patch.timeoutSeconds;
409
- }
410
- if (typeof patch.deliver === "boolean") {
411
- next.deliver = patch.deliver;
412
- }
413
- if (typeof patch.channel === "string") {
414
- next.channel = patch.channel;
415
- }
416
- if (typeof patch.to === "string") {
417
- next.to = patch.to;
418
- }
419
- if (typeof patch.bestEffortDeliver === "boolean") {
420
- next.bestEffortDeliver = patch.bestEffortDeliver;
421
- }
422
- return next;
423
- }
424
- function buildLegacyDeliveryPatch(payload) {
425
- const deliver = payload.deliver;
426
- const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
427
- const hasLegacyHints = typeof deliver === "boolean" || typeof payload.bestEffortDeliver === "boolean" || Boolean(toRaw);
428
- if (!hasLegacyHints) {
429
- return null;
430
- }
431
- const patch = {};
432
- let hasPatch = false;
433
- if (deliver === false) {
434
- patch.mode = "none";
435
- hasPatch = true;
436
- } else if (deliver === true || toRaw) {
437
- patch.mode = "announce";
438
- hasPatch = true;
439
- }
440
- if (typeof payload.channel === "string") {
441
- const channel = payload.channel.trim().toLowerCase();
442
- patch.channel = channel ? channel : void 0;
443
- hasPatch = true;
444
- }
445
- if (typeof payload.to === "string") {
446
- patch.to = payload.to.trim();
447
- hasPatch = true;
448
- }
449
- if (typeof payload.bestEffortDeliver === "boolean") {
450
- patch.bestEffort = payload.bestEffortDeliver;
451
- hasPatch = true;
452
- }
453
- return hasPatch ? patch : null;
454
- }
455
- function buildPayloadFromPatch(patch) {
456
- if (patch.kind === "systemEvent") {
457
- if (typeof patch.text !== "string" || patch.text.length === 0) {
458
- throw new Error('cron.update payload.kind="systemEvent" requires text');
459
- }
460
- return { kind: "systemEvent", text: patch.text };
461
- }
462
- if (typeof patch.message !== "string" || patch.message.length === 0) {
463
- throw new Error('cron.update payload.kind="agentTurn" requires message');
464
- }
465
- return {
466
- kind: "agentTurn",
467
- message: patch.message,
468
- model: patch.model,
469
- thinking: patch.thinking,
470
- timeoutSeconds: patch.timeoutSeconds,
471
- deliver: patch.deliver,
472
- channel: patch.channel,
473
- to: patch.to,
474
- bestEffortDeliver: patch.bestEffortDeliver
475
- };
476
- }
477
- function mergeCronDelivery(existing, patch) {
478
- const next = {
479
- mode: existing?.mode ?? "none",
480
- channel: existing?.channel,
481
- to: existing?.to,
482
- bestEffort: existing?.bestEffort
483
- };
484
- if (typeof patch.mode === "string") {
485
- next.mode = patch.mode === "deliver" ? "announce" : patch.mode;
486
- }
487
- if ("channel" in patch) {
488
- const channel = typeof patch.channel === "string" ? patch.channel.trim() : "";
489
- next.channel = channel ? channel : void 0;
490
- }
491
- if ("to" in patch) {
492
- const to = typeof patch.to === "string" ? patch.to.trim() : "";
493
- next.to = to ? to : void 0;
494
- }
495
- if (typeof patch.bestEffort === "boolean") {
496
- next.bestEffort = patch.bestEffort;
497
- }
498
- return next;
499
- }
500
-
501
- // src/otto/validate-timestamp.ts
502
- var ONE_MINUTE_MS = 60 * 1e3;
503
- var TEN_YEARS_MS = 10 * 365.25 * 24 * 60 * 60 * 1e3;
504
- function validateScheduleTimestamp(schedule, nowMs = Date.now()) {
505
- if (schedule.kind !== "at") {
506
- return { ok: true };
507
- }
508
- const atRaw = typeof schedule.at === "string" ? schedule.at.trim() : "";
509
- const atMs = atRaw ? parseAbsoluteTimeMs(atRaw) : null;
510
- if (atMs === null || !Number.isFinite(atMs)) {
511
- return {
512
- ok: false,
513
- message: `Invalid schedule.at: expected ISO-8601 timestamp (got ${String(schedule.at)})`
514
- };
515
- }
516
- const diffMs = atMs - nowMs;
517
- if (diffMs < -ONE_MINUTE_MS) {
518
- const nowDate = new Date(nowMs).toISOString();
519
- const atDate = new Date(atMs).toISOString();
520
- const minutesAgo = Math.floor(-diffMs / ONE_MINUTE_MS);
521
- return {
522
- ok: false,
523
- message: `schedule.at is in the past: ${atDate} (${minutesAgo} minutes ago). Current time: ${nowDate}`
524
- };
525
- }
526
- if (diffMs > TEN_YEARS_MS) {
527
- const atDate = new Date(atMs).toISOString();
528
- const yearsAhead = Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1e3));
529
- return {
530
- ok: false,
531
- message: `schedule.at is too far in the future: ${atDate} (${yearsAhead} years ahead). Maximum allowed: 10 years`
532
- };
533
- }
534
- return { ok: true };
535
- }
536
-
537
- // src/otto/run-log.ts
538
- import fs from "node:fs/promises";
539
- import path from "node:path";
540
- function resolveCronRunLogPath(params) {
541
- const storePath = path.resolve(params.storePath);
542
- const dir = path.dirname(storePath);
543
- return path.join(dir, "runs", `${params.jobId}.jsonl`);
544
- }
545
- var writesByPath = /* @__PURE__ */ new Map();
546
- async function pruneIfNeeded(filePath, opts) {
547
- const stat = await fs.stat(filePath).catch(() => null);
548
- if (!stat || stat.size <= opts.maxBytes) {
549
- return;
550
- }
551
- const raw = await fs.readFile(filePath, "utf-8").catch(() => "");
552
- const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
553
- const kept = lines.slice(Math.max(0, lines.length - opts.keepLines));
554
- const tmp = `${filePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
555
- await fs.writeFile(tmp, `${kept.join("\n")}
556
- `, "utf-8");
557
- await fs.rename(tmp, filePath);
558
- }
559
- async function appendCronRunLog(filePath, entry, opts) {
560
- const resolved = path.resolve(filePath);
561
- const prev = writesByPath.get(resolved) ?? Promise.resolve();
562
- const next = prev.catch(() => void 0).then(async () => {
563
- await fs.mkdir(path.dirname(resolved), { recursive: true });
564
- await fs.appendFile(resolved, `${JSON.stringify(entry)}
565
- `, "utf-8");
566
- await pruneIfNeeded(resolved, {
567
- maxBytes: opts?.maxBytes ?? 2e6,
568
- keepLines: opts?.keepLines ?? 2e3
569
- });
570
- });
571
- writesByPath.set(resolved, next);
572
- await next;
573
- }
574
- async function readCronRunLogEntries(filePath, opts) {
575
- const limit = Math.max(1, Math.min(5e3, Math.floor(opts?.limit ?? 200)));
576
- const jobId = opts?.jobId?.trim() || void 0;
577
- const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => "");
578
- if (!raw.trim()) {
579
- return [];
580
- }
581
- const parsed = [];
582
- const lines = raw.split("\n");
583
- for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) {
584
- const line = lines[i]?.trim();
585
- if (!line) {
586
- continue;
587
- }
588
- try {
589
- const obj = JSON.parse(line);
590
- if (!obj || typeof obj !== "object") {
591
- continue;
592
- }
593
- if (obj.action !== "finished") {
594
- continue;
595
- }
596
- if (typeof obj.jobId !== "string" || obj.jobId.trim().length === 0) {
597
- continue;
598
- }
599
- if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) {
600
- continue;
601
- }
602
- if (jobId && obj.jobId !== jobId) {
603
- continue;
604
- }
605
- parsed.push(obj);
606
- } catch {
607
- }
608
- }
609
- return [...parsed].reverse();
610
- }
611
-
612
- // src/otto/store.ts
613
- import fs2 from "node:fs";
614
- import os from "node:os";
615
- import path2 from "node:path";
616
- function resolveCronStorePath(storePath) {
617
- if (storePath?.trim()) {
618
- const raw = storePath.trim();
619
- if (raw.startsWith("~")) {
620
- return path2.resolve(raw.replace("~", os.homedir()));
749
+ job.delivery = mergeCronDelivery(job.delivery, legacyDeliveryPatch);
621
750
  }
622
- return path2.resolve(raw);
623
751
  }
624
- return void 0;
752
+ if (patch.delivery) {
753
+ job.delivery = mergeCronDelivery(job.delivery, patch.delivery);
754
+ }
755
+ if (job.sessionTarget === "main" && job.delivery) {
756
+ job.delivery = void 0;
757
+ }
758
+ if (patch.state) {
759
+ job.state = { ...job.state, ...patch.state };
760
+ }
761
+ if ("agentId" in patch) {
762
+ job.agentId = normalizeOptionalAgentId(
763
+ patch.agentId
764
+ );
765
+ }
766
+ assertSupportedJobSpec(job);
767
+ assertDeliverySupport(job);
625
768
  }
626
- async function loadCronStore(storePath) {
627
- try {
628
- const raw = await fs2.promises.readFile(storePath, "utf-8");
629
- let parsed;
630
- try {
631
- const JSON5 = await import("./dist-KM2GC6Y3.js").then((m) => m.default);
632
- parsed = JSON5.parse(raw);
633
- } catch {
634
- parsed = JSON.parse(raw);
769
+ function mergeCronPayload(existing, patch) {
770
+ if (patch.kind !== existing.kind) {
771
+ return buildPayloadFromPatch(patch);
772
+ }
773
+ if (patch.kind === "systemEvent") {
774
+ if (existing.kind !== "systemEvent") {
775
+ return buildPayloadFromPatch(patch);
635
776
  }
636
- const jobs = Array.isArray(parsed?.jobs) ? parsed?.jobs : [];
637
- return {
638
- version: 1,
639
- jobs: jobs.filter(Boolean)
640
- };
641
- } catch {
642
- return { version: 1, jobs: [] };
777
+ const text = typeof patch.text === "string" ? patch.text : existing.text;
778
+ return { kind: "systemEvent", text };
643
779
  }
644
- }
645
- async function saveCronStore(storePath, store) {
646
- await fs2.promises.mkdir(path2.dirname(storePath), { recursive: true });
647
- const tmp = `${storePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
648
- const json = JSON.stringify(store, null, 2);
649
- await fs2.promises.writeFile(tmp, json, "utf-8");
650
- await fs2.promises.rename(tmp, storePath);
651
- try {
652
- await fs2.promises.copyFile(storePath, `${storePath}.bak`);
653
- } catch {
780
+ if (existing.kind !== "agentTurn") {
781
+ return buildPayloadFromPatch(patch);
654
782
  }
655
- }
656
-
657
- // src/otto/delivery.ts
658
- function normalizeChannel2(value) {
659
- if (typeof value !== "string") {
660
- return void 0;
783
+ const next = { ...existing };
784
+ if (typeof patch.message === "string") {
785
+ next.message = patch.message;
661
786
  }
662
- const trimmed = value.trim().toLowerCase();
663
- if (!trimmed) {
664
- return void 0;
787
+ if (typeof patch.model === "string") {
788
+ next.model = patch.model;
665
789
  }
666
- return trimmed;
667
- }
668
- function normalizeTo(value) {
669
- if (typeof value !== "string") {
670
- return void 0;
790
+ if (typeof patch.thinking === "string") {
791
+ next.thinking = patch.thinking;
671
792
  }
672
- const trimmed = value.trim();
673
- return trimmed ? trimmed : void 0;
674
- }
675
- function resolveCronDeliveryPlan(job) {
676
- const payload = job.payload.kind === "agentTurn" ? job.payload : null;
677
- const delivery = job.delivery;
678
- const hasDelivery = delivery && typeof delivery === "object";
679
- const rawMode = hasDelivery ? delivery.mode : void 0;
680
- const mode = rawMode === "announce" ? "announce" : rawMode === "none" ? "none" : rawMode === "deliver" ? "announce" : void 0;
681
- const payloadChannel = normalizeChannel2(payload?.channel);
682
- const payloadTo = normalizeTo(payload?.to);
683
- const deliveryChannel = normalizeChannel2(
684
- delivery?.channel
685
- );
686
- const deliveryTo = normalizeTo(delivery?.to);
687
- const channel = deliveryChannel ?? payloadChannel ?? "last";
688
- const to = deliveryTo ?? payloadTo;
689
- if (hasDelivery) {
690
- const resolvedMode = mode ?? "none";
691
- return {
692
- mode: resolvedMode,
693
- channel,
694
- to,
695
- source: "delivery",
696
- requested: resolvedMode === "announce"
697
- };
793
+ if (typeof patch.timeoutSeconds === "number") {
794
+ next.timeoutSeconds = patch.timeoutSeconds;
698
795
  }
699
- const legacyMode = payload?.deliver === true ? "explicit" : payload?.deliver === false ? "off" : "auto";
700
- const hasExplicitTarget = Boolean(to);
701
- const requested = legacyMode === "explicit" || legacyMode === "auto" && hasExplicitTarget;
702
- return {
703
- mode: requested ? "announce" : "none",
704
- channel,
705
- to,
706
- source: "payload",
707
- requested
708
- };
709
- }
710
-
711
- // src/otto/detect.ts
712
- function isOttoPayload(payload) {
713
- const kind = payload.kind;
714
- return kind === "systemEvent" || kind === "agentTurn";
715
- }
716
-
717
- // src/otto/executor.ts
718
- import {
719
- logger as logger3,
720
- stringToUuid as stringToUuid2,
721
- ChannelType as ChannelType2
722
- } from "@elizaos/core";
723
- import { v4 as uuidv43 } from "uuid";
724
-
725
- // src/types.ts
726
- var DEFAULT_CRON_CONFIG = {
727
- minIntervalMs: 1e4,
728
- // 10 seconds minimum
729
- maxJobsPerAgent: 100,
730
- defaultTimeoutMs: 3e5,
731
- // 5 minutes
732
- catchUpMissedJobs: false,
733
- catchUpWindowMs: 36e5,
734
- // 1 hour
735
- timerCheckIntervalMs: 1e3
736
- // 1 second
737
- };
738
-
739
- // src/heartbeat/queue.ts
740
- var queues = /* @__PURE__ */ new Map();
741
- function agentQueue(agentId) {
742
- let q = queues.get(agentId);
743
- if (!q) {
744
- q = [];
745
- queues.set(agentId, q);
796
+ if (typeof patch.deliver === "boolean") {
797
+ next.deliver = patch.deliver;
746
798
  }
747
- return q;
748
- }
749
- function pushSystemEvent(agentId, text, source) {
750
- agentQueue(agentId).push({ text, source, ts: Date.now() });
751
- }
752
- function drainSystemEvents(agentId) {
753
- const q = queues.get(agentId);
754
- if (!q || q.length === 0) {
755
- return [];
799
+ if (typeof patch.channel === "string") {
800
+ next.channel = patch.channel;
756
801
  }
757
- const events = [...q];
758
- q.length = 0;
759
- return events;
760
- }
761
- function pendingEventCount(agentId) {
762
- return queues.get(agentId)?.length ?? 0;
763
- }
764
-
765
- // src/heartbeat/worker.ts
766
- import {
767
- logger as logger2,
768
- stringToUuid,
769
- ChannelType
770
- } from "@elizaos/core";
771
- import { v4 as uuidv42 } from "uuid";
772
-
773
- // src/heartbeat/config.ts
774
- var DEFAULT_EVERY_MS = 30 * 60 * 1e3;
775
- var DEFAULT_PROMPT_FILE = "HEARTBEAT.md";
776
- function parseDurationToMs(raw) {
777
- const match = raw.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day|ms)?$/i);
778
- if (!match) {
779
- return DEFAULT_EVERY_MS;
802
+ if (typeof patch.to === "string") {
803
+ next.to = patch.to;
780
804
  }
781
- const value = Number.parseFloat(match[1]);
782
- const unit = (match[2] ?? "m").toLowerCase();
783
- switch (unit) {
784
- case "ms":
785
- return value;
786
- case "s":
787
- case "sec":
788
- return value * 1e3;
789
- case "m":
790
- case "min":
791
- return value * 6e4;
792
- case "h":
793
- case "hr":
794
- return value * 36e5;
795
- case "d":
796
- case "day":
797
- return value * 864e5;
798
- default:
799
- return value * 6e4;
805
+ if (typeof patch.bestEffortDeliver === "boolean") {
806
+ next.bestEffortDeliver = patch.bestEffortDeliver;
800
807
  }
808
+ return next;
801
809
  }
802
- function parseActiveHours(raw) {
803
- const start = typeof raw.start === "string" ? raw.start.trim() : "";
804
- const end = typeof raw.end === "string" ? raw.end.trim() : "";
805
- if (!start || !end) {
810
+ function buildLegacyDeliveryPatch(payload) {
811
+ const deliver = payload.deliver;
812
+ const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
813
+ const hasLegacyHints = typeof deliver === "boolean" || typeof payload.bestEffortDeliver === "boolean" || Boolean(toRaw);
814
+ if (!hasLegacyHints) {
806
815
  return null;
807
816
  }
808
- return { start, end };
817
+ const patch = {};
818
+ let hasPatch = false;
819
+ if (deliver === false) {
820
+ patch.mode = "none";
821
+ hasPatch = true;
822
+ } else if (deliver === true || toRaw) {
823
+ patch.mode = "announce";
824
+ hasPatch = true;
825
+ }
826
+ if (typeof payload.channel === "string") {
827
+ const channel = payload.channel.trim().toLowerCase();
828
+ patch.channel = channel ? channel : void 0;
829
+ hasPatch = true;
830
+ }
831
+ if (typeof payload.to === "string") {
832
+ patch.to = payload.to.trim();
833
+ hasPatch = true;
834
+ }
835
+ if (typeof payload.bestEffortDeliver === "boolean") {
836
+ patch.bestEffort = payload.bestEffortDeliver;
837
+ hasPatch = true;
838
+ }
839
+ return hasPatch ? patch : null;
809
840
  }
810
- function resolveHeartbeatConfig(runtime) {
811
- const settings = runtime.character?.settings ?? {};
812
- const hb = settings.heartbeat ?? {};
813
- const everyRaw = typeof hb.every === "string" ? hb.every : "";
814
- const everyMs = everyRaw ? parseDurationToMs(everyRaw) : DEFAULT_EVERY_MS;
815
- const activeHours = hb.activeHours && typeof hb.activeHours === "object" ? parseActiveHours(hb.activeHours) : null;
816
- const target = typeof hb.target === "string" ? hb.target.trim() : "last";
817
- const promptFile = typeof hb.prompt === "string" && hb.prompt.trim() ? hb.prompt.trim() : DEFAULT_PROMPT_FILE;
818
- const enabled = hb.enabled !== false;
819
- return { everyMs, activeHours, target, promptFile, enabled };
841
+ function buildPayloadFromPatch(patch) {
842
+ if (patch.kind === "systemEvent") {
843
+ if (typeof patch.text !== "string" || patch.text.length === 0) {
844
+ throw new Error('cron.update payload.kind="systemEvent" requires text');
845
+ }
846
+ return { kind: "systemEvent", text: patch.text };
847
+ }
848
+ if (typeof patch.message !== "string" || patch.message.length === 0) {
849
+ throw new Error('cron.update payload.kind="agentTurn" requires message');
850
+ }
851
+ return {
852
+ kind: "agentTurn",
853
+ message: patch.message,
854
+ model: patch.model,
855
+ thinking: patch.thinking,
856
+ timeoutSeconds: patch.timeoutSeconds,
857
+ deliver: patch.deliver,
858
+ channel: patch.channel,
859
+ to: patch.to,
860
+ bestEffortDeliver: patch.bestEffortDeliver
861
+ };
820
862
  }
821
- function isWithinActiveHours(activeHours) {
822
- if (!activeHours) {
823
- return true;
863
+ function mergeCronDelivery(existing, patch) {
864
+ const next = {
865
+ mode: existing?.mode ?? "none",
866
+ channel: existing?.channel,
867
+ to: existing?.to,
868
+ bestEffort: existing?.bestEffort
869
+ };
870
+ if (typeof patch.mode === "string") {
871
+ next.mode = patch.mode === "deliver" ? "announce" : patch.mode;
824
872
  }
825
- const now = /* @__PURE__ */ new Date();
826
- const hh = now.getHours();
827
- const mm = now.getMinutes();
828
- const currentMinutes = hh * 60 + mm;
829
- const [startH, startM] = activeHours.start.split(":").map(Number);
830
- const [endH, endM] = activeHours.end.split(":").map(Number);
831
- const startMinutes = (startH ?? 0) * 60 + (startM ?? 0);
832
- const endMinutes = (endH ?? 0) * 60 + (endM ?? 0);
833
- if (startMinutes <= endMinutes) {
834
- return currentMinutes >= startMinutes && currentMinutes < endMinutes;
873
+ if ("channel" in patch) {
874
+ const channel = typeof patch.channel === "string" ? patch.channel.trim() : "";
875
+ next.channel = channel ? channel : void 0;
835
876
  }
836
- return currentMinutes >= startMinutes || currentMinutes < endMinutes;
877
+ if ("to" in patch) {
878
+ const to = typeof patch.to === "string" ? patch.to.trim() : "";
879
+ next.to = to ? to : void 0;
880
+ }
881
+ if (typeof patch.bestEffort === "boolean") {
882
+ next.bestEffort = patch.bestEffort;
883
+ }
884
+ return next;
837
885
  }
838
886
 
839
- // src/heartbeat/delivery.ts
840
- import {
841
- logger
842
- } from "@elizaos/core";
843
- import { v4 as uuidv4 } from "uuid";
844
- var LAST_ROUTE_COMPONENT_TYPE = "last_delivery_route";
845
- var INTERNAL_SOURCES = /* @__PURE__ */ new Set(["cron", "webhook", "heartbeat", "internal"]);
846
- async function readLastRoute(runtime) {
847
- const component = await runtime.getComponent(
848
- runtime.agentId,
849
- LAST_ROUTE_COMPONENT_TYPE
850
- );
851
- if (!component?.data) {
852
- return null;
887
+ // src/otto/parse.ts
888
+ var ISO_TZ_RE = /(Z|[+-]\d{2}:?\d{2})$/i;
889
+ var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
890
+ var ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T/;
891
+ function normalizeUtcIso(raw) {
892
+ if (ISO_TZ_RE.test(raw)) {
893
+ return raw;
853
894
  }
854
- const data = component.data;
855
- const source = typeof data.source === "string" ? data.source : "";
856
- if (!source) {
857
- return null;
895
+ if (ISO_DATE_RE.test(raw)) {
896
+ return `${raw}T00:00:00Z`;
858
897
  }
859
- return {
860
- source,
861
- channelId: typeof data.channelId === "string" ? data.channelId : void 0
862
- };
863
- }
864
- async function writeLastRoute(runtime, target) {
865
- const existing = await runtime.getComponent(
866
- runtime.agentId,
867
- LAST_ROUTE_COMPONENT_TYPE
868
- );
869
- const data = {
870
- source: target.source,
871
- channelId: target.channelId ?? null,
872
- updatedAt: Date.now()
873
- };
874
- if (existing) {
875
- await runtime.updateComponent({
876
- ...existing,
877
- data
878
- });
879
- } else {
880
- await runtime.createComponent({
881
- id: uuidv4(),
882
- entityId: runtime.agentId,
883
- type: LAST_ROUTE_COMPONENT_TYPE,
884
- data,
885
- createdAt: Date.now()
886
- });
898
+ if (ISO_DATE_TIME_RE.test(raw)) {
899
+ return `${raw}Z`;
887
900
  }
901
+ return raw;
888
902
  }
889
- async function scanRoomsForExternalTarget(runtime) {
890
- const rooms = await runtime.getRooms(runtime.agentId).catch(() => []);
891
- for (const room of rooms) {
892
- if (room.source && !INTERNAL_SOURCES.has(room.source)) {
893
- return {
894
- source: room.source,
895
- channelId: room.channelId ?? void 0
896
- };
903
+ function parseAbsoluteTimeMs(input) {
904
+ const raw = input.trim();
905
+ if (!raw) {
906
+ return null;
907
+ }
908
+ if (/^\d+$/.test(raw)) {
909
+ const n = Number(raw);
910
+ if (Number.isFinite(n) && n > 0) {
911
+ return Math.floor(n);
897
912
  }
898
913
  }
899
- return null;
914
+ const parsed = Date.parse(normalizeUtcIso(raw));
915
+ return Number.isFinite(parsed) ? parsed : null;
900
916
  }
901
- async function resolveDeliveryTarget(runtime, channel, to) {
902
- if (channel !== "last") {
903
- return { source: channel, channelId: to };
917
+
918
+ // src/otto/payload-migration.ts
919
+ function readString(value) {
920
+ if (typeof value !== "string") {
921
+ return void 0;
904
922
  }
905
- const stored = await readLastRoute(runtime);
906
- if (stored) {
907
- if (to) {
908
- return { source: stored.source, channelId: to };
923
+ return value;
924
+ }
925
+ function normalizeChannel2(value) {
926
+ return value.trim().toLowerCase();
927
+ }
928
+ function migrateLegacyCronPayload(payload) {
929
+ let mutated = false;
930
+ const channelValue = readString(payload.channel);
931
+ const providerValue = readString(payload.provider);
932
+ const nextChannel = typeof channelValue === "string" && channelValue.trim().length > 0 ? normalizeChannel2(channelValue) : typeof providerValue === "string" && providerValue.trim().length > 0 ? normalizeChannel2(providerValue) : "";
933
+ if (nextChannel) {
934
+ if (channelValue !== nextChannel) {
935
+ payload.channel = nextChannel;
936
+ mutated = true;
909
937
  }
910
- return stored;
911
938
  }
912
- const scanned = await scanRoomsForExternalTarget(runtime);
913
- if (scanned) {
914
- logger.debug(
915
- `[Delivery] Resolved "last" via room scan: ${scanned.source}:${scanned.channelId ?? "(default)"}`
916
- );
917
- if (to) {
918
- return { source: scanned.source, channelId: to };
919
- }
920
- return scanned;
939
+ if ("provider" in payload) {
940
+ delete payload.provider;
941
+ mutated = true;
921
942
  }
922
- return null;
943
+ return mutated;
923
944
  }
924
- async function deliverToTarget(runtime, content, channel, to, bestEffort) {
925
- const target = await resolveDeliveryTarget(runtime, channel, to);
926
- if (!target) {
927
- const msg = `No delivery target resolved for channel "${channel}"`;
928
- if (bestEffort) {
929
- logger.warn(`[Delivery] ${msg}`);
930
- return null;
945
+
946
+ // src/otto/normalize.ts
947
+ var DEFAULT_OPTIONS = {
948
+ applyDefaults: false
949
+ };
950
+ function isRecord(value) {
951
+ return typeof value === "object" && value !== null && !Array.isArray(value);
952
+ }
953
+ function coerceSchedule(schedule) {
954
+ const next = { ...schedule };
955
+ const kind = typeof schedule.kind === "string" ? schedule.kind : void 0;
956
+ const atMsRaw = schedule.atMs;
957
+ const atRaw = schedule.at;
958
+ const atString = typeof atRaw === "string" ? atRaw.trim() : "";
959
+ const parsedAtMs = typeof atMsRaw === "number" ? atMsRaw : typeof atMsRaw === "string" ? parseAbsoluteTimeMs(atMsRaw) : atString ? parseAbsoluteTimeMs(atString) : null;
960
+ if (!kind) {
961
+ if (typeof schedule.atMs === "number" || typeof schedule.at === "string" || typeof schedule.atMs === "string") {
962
+ next.kind = "at";
963
+ } else if (typeof schedule.everyMs === "number") {
964
+ next.kind = "every";
965
+ } else if (typeof schedule.expr === "string") {
966
+ next.kind = "cron";
931
967
  }
932
- throw new Error(msg);
933
968
  }
934
- const deliveryError = await runtime.sendMessageToTarget(
935
- { source: target.source, channelId: target.channelId },
936
- content
937
- ).then(() => null, (err) => err);
938
- if (deliveryError) {
939
- if (bestEffort) {
940
- logger.warn(
941
- `[Delivery] Best-effort delivery failed to ${target.source}: ${deliveryError.message}`
942
- );
943
- return null;
944
- }
945
- throw deliveryError;
969
+ if (atString) {
970
+ next.at = parsedAtMs ? new Date(parsedAtMs).toISOString() : atString;
971
+ } else if (parsedAtMs !== null) {
972
+ next.at = new Date(parsedAtMs).toISOString();
946
973
  }
947
- await writeLastRoute(runtime, target).catch((err) => {
948
- logger.debug(`[Delivery] Failed to persist last route: ${err.message}`);
949
- });
950
- logger.info(
951
- `[Delivery] Delivered to ${target.source}${target.channelId ? `:${target.channelId}` : ""}`
952
- );
953
- return target;
974
+ if ("atMs" in next) {
975
+ delete next.atMs;
976
+ }
977
+ return next;
954
978
  }
955
-
956
- // src/heartbeat/worker.ts
957
- import fs3 from "node:fs/promises";
958
- import path3 from "node:path";
959
- var HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK";
960
- var HEARTBEAT_ROOM_KEY = "heartbeat:main";
961
- var HEARTBEAT_WORKER_NAME = "heartbeat";
962
- async function readHeartbeatFile(runtime, filename) {
963
- const settings = runtime.character?.settings ?? {};
964
- const workspace = typeof settings.workspace === "string" ? settings.workspace : process.cwd();
965
- const filePath = path3.resolve(workspace, filename);
966
- const content = await fs3.readFile(filePath, "utf-8").catch(() => null);
967
- return content;
979
+ function coercePayload(payload) {
980
+ const next = { ...payload };
981
+ migrateLegacyCronPayload(next);
982
+ return next;
968
983
  }
969
- function buildHeartbeatPrompt(heartbeatMd, events) {
970
- const parts = [];
971
- parts.push("[Heartbeat]");
972
- if (heartbeatMd) {
973
- parts.push("");
974
- parts.push("# HEARTBEAT.md");
975
- parts.push(heartbeatMd.trim());
976
- parts.push("");
977
- parts.push("Follow the instructions in HEARTBEAT.md strictly.");
984
+ function coerceDelivery(delivery) {
985
+ const next = { ...delivery };
986
+ if (typeof delivery.mode === "string") {
987
+ const mode = delivery.mode.trim().toLowerCase();
988
+ next.mode = mode === "deliver" ? "announce" : mode;
978
989
  }
979
- if (events.length > 0) {
980
- parts.push("");
981
- parts.push("## System events since last heartbeat");
982
- for (const ev of events) {
983
- const age = Math.round((Date.now() - ev.ts) / 1e3);
984
- parts.push(`- [${ev.source}, ${age}s ago] ${ev.text}`);
990
+ if (typeof delivery.channel === "string") {
991
+ const trimmed = delivery.channel.trim().toLowerCase();
992
+ if (trimmed) {
993
+ next.channel = trimmed;
994
+ } else {
995
+ delete next.channel;
996
+ }
997
+ }
998
+ if (typeof delivery.to === "string") {
999
+ const trimmed = delivery.to.trim();
1000
+ if (trimmed) {
1001
+ next.to = trimmed;
1002
+ } else {
1003
+ delete next.to;
985
1004
  }
986
1005
  }
987
- parts.push("");
988
- parts.push(
989
- 'If nothing requires your attention right now, reply with exactly "HEARTBEAT_OK" and nothing else.'
990
- );
991
- return parts.join("\n");
1006
+ return next;
992
1007
  }
993
- function isHeartbeatOk(text) {
994
- const trimmed = text.trim();
995
- return trimmed === HEARTBEAT_OK_TOKEN || trimmed.startsWith(HEARTBEAT_OK_TOKEN + "\n") || trimmed.startsWith(HEARTBEAT_OK_TOKEN + " ");
1008
+ function hasLegacyDeliveryHints(payload) {
1009
+ if (typeof payload.deliver === "boolean") {
1010
+ return true;
1011
+ }
1012
+ if (typeof payload.bestEffortDeliver === "boolean") {
1013
+ return true;
1014
+ }
1015
+ if (typeof payload.to === "string" && payload.to.trim()) {
1016
+ return true;
1017
+ }
1018
+ return false;
996
1019
  }
997
- async function ensureHeartbeatRoom(runtime) {
998
- const roomId = stringToUuid(`${runtime.agentId}-${HEARTBEAT_ROOM_KEY}`);
999
- const existing = await runtime.getRoom(roomId);
1000
- if (!existing) {
1001
- await runtime.createRoom({
1002
- id: roomId,
1003
- name: "Heartbeat",
1004
- source: "cron",
1005
- type: ChannelType.GROUP,
1006
- channelId: HEARTBEAT_ROOM_KEY,
1007
- worldId: runtime.agentId
1008
- });
1009
- await runtime.addParticipant(runtime.agentId, roomId);
1020
+ function buildDeliveryFromLegacyPayload(payload) {
1021
+ const deliver = payload.deliver;
1022
+ const mode = deliver === false ? "none" : "announce";
1023
+ const channelRaw = typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : "";
1024
+ const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
1025
+ const next = { mode };
1026
+ if (channelRaw) {
1027
+ next.channel = channelRaw;
1010
1028
  }
1011
- return roomId;
1029
+ if (toRaw) {
1030
+ next.to = toRaw;
1031
+ }
1032
+ if (typeof payload.bestEffortDeliver === "boolean") {
1033
+ next.bestEffort = payload.bestEffortDeliver;
1034
+ }
1035
+ return next;
1012
1036
  }
1013
- async function runHeartbeatTick(runtime, config) {
1014
- if (!isWithinActiveHours(config.activeHours)) {
1015
- logger2.debug("[Heartbeat] Outside active hours, skipping");
1016
- return;
1037
+ function stripLegacyDeliveryFields(payload) {
1038
+ if ("deliver" in payload) {
1039
+ delete payload.deliver;
1017
1040
  }
1018
- const events = drainSystemEvents(runtime.agentId);
1019
- const heartbeatMd = await readHeartbeatFile(runtime, config.promptFile);
1020
- if (!heartbeatMd && events.length === 0) {
1021
- logger2.debug("[Heartbeat] No HEARTBEAT.md and no pending events, skipping");
1022
- return;
1041
+ if ("channel" in payload) {
1042
+ delete payload.channel;
1023
1043
  }
1024
- const promptText = buildHeartbeatPrompt(heartbeatMd, events);
1025
- const roomId = await ensureHeartbeatRoom(runtime);
1026
- const messageId = uuidv42();
1027
- const memory = {
1028
- id: messageId,
1029
- entityId: runtime.agentId,
1030
- roomId,
1031
- agentId: runtime.agentId,
1032
- content: { text: promptText },
1033
- createdAt: Date.now()
1034
- };
1035
- let responseText = "";
1036
- const callback = async (response) => {
1037
- if (response.text) {
1038
- responseText += response.text;
1039
- }
1040
- return [];
1041
- };
1042
- logger2.info(
1043
- `[Heartbeat] Running tick (${events.length} pending events, HEARTBEAT.md: ${heartbeatMd ? "yes" : "no"})`
1044
- );
1045
- await runtime.messageService.handleMessage(runtime, memory, callback);
1046
- if (!responseText.trim() || isHeartbeatOk(responseText)) {
1047
- logger2.debug("[Heartbeat] Agent responded HEARTBEAT_OK, suppressing delivery");
1048
- return;
1044
+ if ("to" in payload) {
1045
+ delete payload.to;
1049
1046
  }
1050
- logger2.info(`[Heartbeat] Agent has something to say, delivering to target "${config.target}"`);
1051
- if (config.target === "none") {
1052
- return;
1047
+ if ("bestEffortDeliver" in payload) {
1048
+ delete payload.bestEffortDeliver;
1053
1049
  }
1054
- let channel;
1055
- let to;
1056
- if (config.target === "last" || config.target === "none") {
1057
- channel = config.target;
1058
- } else {
1059
- const colonIdx = config.target.indexOf(":");
1060
- if (colonIdx === -1) {
1061
- channel = config.target;
1062
- } else {
1063
- channel = config.target.slice(0, colonIdx);
1064
- to = config.target.slice(colonIdx + 1) || void 0;
1065
- }
1050
+ }
1051
+ function unwrapJob(raw) {
1052
+ if (isRecord(raw.data)) {
1053
+ return raw.data;
1066
1054
  }
1067
- if (channel === "none") {
1068
- return;
1055
+ if (isRecord(raw.job)) {
1056
+ return raw.job;
1069
1057
  }
1070
- await deliverToTarget(
1071
- runtime,
1072
- { text: responseText },
1073
- channel,
1074
- to,
1075
- true
1076
- // bestEffort for heartbeat -- don't crash the tick on delivery failure
1077
- );
1058
+ return raw;
1078
1059
  }
1079
- var heartbeatWorker = {
1080
- name: HEARTBEAT_WORKER_NAME,
1081
- async execute(runtime, _options, _task) {
1082
- const config = resolveHeartbeatConfig(runtime);
1083
- if (!config.enabled) {
1084
- return;
1060
+ function defaultSanitizeAgentId(raw) {
1061
+ return raw.trim().toLowerCase();
1062
+ }
1063
+ function normalizeCronJobInput(raw, options = DEFAULT_OPTIONS) {
1064
+ if (!isRecord(raw)) {
1065
+ return null;
1066
+ }
1067
+ const base = unwrapJob(raw);
1068
+ const next = { ...base };
1069
+ const sanitizeAgentId = options.sanitizeAgentId ?? defaultSanitizeAgentId;
1070
+ if ("agentId" in base) {
1071
+ const agentId = base.agentId;
1072
+ if (agentId === null) {
1073
+ next.agentId = null;
1074
+ } else if (typeof agentId === "string") {
1075
+ const trimmed = agentId.trim();
1076
+ if (trimmed) {
1077
+ next.agentId = sanitizeAgentId(trimmed);
1078
+ } else {
1079
+ delete next.agentId;
1080
+ }
1085
1081
  }
1086
- await runHeartbeatTick(runtime, config);
1087
1082
  }
1088
- };
1089
- async function startHeartbeat(runtime) {
1090
- const config = resolveHeartbeatConfig(runtime);
1091
- if (!config.enabled) {
1092
- logger2.info("[Heartbeat] Disabled via config");
1093
- return;
1083
+ if ("enabled" in base) {
1084
+ const enabled = base.enabled;
1085
+ if (typeof enabled === "boolean") {
1086
+ next.enabled = enabled;
1087
+ } else if (typeof enabled === "string") {
1088
+ const trimmed = enabled.trim().toLowerCase();
1089
+ if (trimmed === "true") {
1090
+ next.enabled = true;
1091
+ }
1092
+ if (trimmed === "false") {
1093
+ next.enabled = false;
1094
+ }
1095
+ }
1094
1096
  }
1095
- runtime.registerTaskWorker(heartbeatWorker);
1096
- const existingTasks = await runtime.getTasks({
1097
- roomId: runtime.agentId,
1098
- tags: ["heartbeat", "queue", "repeat"]
1099
- });
1100
- const alreadyExists = existingTasks.some(
1101
- (t) => t.name === HEARTBEAT_WORKER_NAME
1102
- );
1103
- if (!alreadyExists) {
1104
- await runtime.createTask({
1105
- name: HEARTBEAT_WORKER_NAME,
1106
- description: "Periodic agent heartbeat \u2013 reads HEARTBEAT.md and checks system events",
1107
- roomId: runtime.agentId,
1108
- worldId: runtime.agentId,
1109
- tags: ["heartbeat", "queue", "repeat"],
1110
- metadata: {
1111
- updateInterval: config.everyMs,
1112
- updatedAt: Date.now(),
1113
- blocking: true
1097
+ if (isRecord(base.schedule)) {
1098
+ next.schedule = coerceSchedule(base.schedule);
1099
+ }
1100
+ if (isRecord(base.payload)) {
1101
+ next.payload = coercePayload(base.payload);
1102
+ }
1103
+ if (isRecord(base.delivery)) {
1104
+ next.delivery = coerceDelivery(base.delivery);
1105
+ }
1106
+ if (isRecord(base.isolation)) {
1107
+ delete next.isolation;
1108
+ }
1109
+ if (options.applyDefaults) {
1110
+ if (!next.wakeMode) {
1111
+ next.wakeMode = "next-heartbeat";
1112
+ }
1113
+ if (typeof next.enabled !== "boolean") {
1114
+ next.enabled = true;
1115
+ }
1116
+ if (!next.sessionTarget && isRecord(next.payload)) {
1117
+ const kind = typeof next.payload.kind === "string" ? next.payload.kind : "";
1118
+ if (kind === "systemEvent") {
1119
+ next.sessionTarget = "main";
1114
1120
  }
1115
- });
1116
- logger2.info(
1117
- `[Heartbeat] Created recurring task (every ${Math.round(config.everyMs / 1e3)}s)`
1118
- );
1119
- } else {
1120
- logger2.info("[Heartbeat] Recurring task already exists");
1121
+ if (kind === "agentTurn") {
1122
+ next.sessionTarget = "isolated";
1123
+ }
1124
+ }
1125
+ if ("schedule" in next && isRecord(next.schedule) && next.schedule.kind === "at" && !("deleteAfterRun" in next)) {
1126
+ next.deleteAfterRun = true;
1127
+ }
1128
+ const payload = isRecord(next.payload) ? next.payload : null;
1129
+ const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";
1130
+ const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : "";
1131
+ const isIsolatedAgentTurn = sessionTarget === "isolated" || sessionTarget === "" && payloadKind === "agentTurn";
1132
+ const hasDelivery = "delivery" in next && next.delivery !== void 0;
1133
+ const hasLegacyDelivery = payload ? hasLegacyDeliveryHints(payload) : false;
1134
+ if (!hasDelivery && isIsolatedAgentTurn && payloadKind === "agentTurn") {
1135
+ if (payload && hasLegacyDelivery) {
1136
+ next.delivery = buildDeliveryFromLegacyPayload(payload);
1137
+ stripLegacyDeliveryFields(payload);
1138
+ } else {
1139
+ next.delivery = { mode: "announce" };
1140
+ }
1141
+ }
1121
1142
  }
1143
+ return next;
1122
1144
  }
1123
- async function wakeHeartbeatNow(runtime) {
1124
- runtime.registerTaskWorker(heartbeatWorker);
1125
- await runtime.createTask({
1126
- name: HEARTBEAT_WORKER_NAME,
1127
- description: "Immediate heartbeat wake",
1128
- roomId: runtime.agentId,
1129
- worldId: runtime.agentId,
1130
- tags: ["heartbeat", "queue"],
1131
- metadata: {
1132
- updatedAt: 0,
1133
- // ensures it runs immediately
1134
- blocking: false
1135
- }
1145
+ function normalizeCronJobCreate(raw, options) {
1146
+ return normalizeCronJobInput(raw, {
1147
+ applyDefaults: true,
1148
+ ...options
1149
+ });
1150
+ }
1151
+ function normalizeCronJobPatch(raw, options) {
1152
+ return normalizeCronJobInput(raw, {
1153
+ applyDefaults: false,
1154
+ ...options
1136
1155
  });
1137
- logger2.info("[Heartbeat] Queued immediate wake");
1138
1156
  }
1139
1157
 
1140
- // src/otto/executor.ts
1141
- function withTimeout(promise, timeoutMs) {
1142
- let timer;
1143
- const timeout = new Promise((_, reject) => {
1144
- timer = setTimeout(() => reject(new Error("Job execution timeout")), timeoutMs);
1145
- });
1146
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
1158
+ // src/otto/run-log.ts
1159
+ import fs2 from "node:fs/promises";
1160
+ import path2 from "node:path";
1161
+ function resolveCronRunLogPath(params) {
1162
+ const storePath = path2.resolve(params.storePath);
1163
+ const dir = path2.dirname(storePath);
1164
+ return path2.join(dir, "runs", `${params.jobId}.jsonl`);
1147
1165
  }
1148
- async function executeSystemEvent(runtime, job, payload) {
1149
- const startedAtMs = Date.now();
1150
- pushSystemEvent(runtime.agentId, payload.text, `cron:${job.id}`);
1151
- logger3.info(`[Otto Executor] Queued system event for heartbeat: "${payload.text.slice(0, 80)}"`);
1152
- if (job.wakeMode === "now") {
1153
- await wakeHeartbeatNow(runtime);
1166
+ var writesByPath = /* @__PURE__ */ new Map();
1167
+ async function pruneIfNeeded(filePath, opts) {
1168
+ const stat = await fs2.stat(filePath).catch(() => null);
1169
+ if (!stat || stat.size <= opts.maxBytes) {
1170
+ return;
1154
1171
  }
1155
- return {
1156
- status: "ok",
1157
- durationMs: Date.now() - startedAtMs,
1158
- output: `System event queued (wake: ${job.wakeMode})`
1159
- };
1172
+ const raw = await fs2.readFile(filePath, "utf-8").catch(() => "");
1173
+ const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
1174
+ const kept = lines.slice(Math.max(0, lines.length - opts.keepLines));
1175
+ const tmp = `${filePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
1176
+ await fs2.writeFile(tmp, `${kept.join("\n")}
1177
+ `, "utf-8");
1178
+ await fs2.rename(tmp, filePath);
1160
1179
  }
1161
- async function ensureCronRoom(runtime, jobId, jobName) {
1162
- const roomKey = `cron:${jobId}`;
1163
- const roomId = stringToUuid2(`${runtime.agentId}-${roomKey}`);
1164
- const existing = await runtime.getRoom(roomId);
1165
- if (!existing) {
1166
- await runtime.createRoom({
1167
- id: roomId,
1168
- name: `Cron: ${jobName}`,
1169
- source: "cron",
1170
- type: ChannelType2.GROUP,
1171
- channelId: roomKey
1180
+ async function appendCronRunLog(filePath, entry, opts) {
1181
+ const resolved = path2.resolve(filePath);
1182
+ const prev = writesByPath.get(resolved) ?? Promise.resolve();
1183
+ const next = prev.catch(() => void 0).then(async () => {
1184
+ await fs2.mkdir(path2.dirname(resolved), { recursive: true });
1185
+ await fs2.appendFile(resolved, `${JSON.stringify(entry)}
1186
+ `, "utf-8");
1187
+ await pruneIfNeeded(resolved, {
1188
+ maxBytes: opts?.maxBytes ?? 2e6,
1189
+ keepLines: opts?.keepLines ?? 2e3
1172
1190
  });
1173
- await runtime.addParticipant(runtime.agentId, roomId);
1174
- }
1175
- return roomId;
1191
+ });
1192
+ writesByPath.set(resolved, next);
1193
+ await next;
1176
1194
  }
1177
- async function executeAgentTurn(runtime, job, payload, config) {
1178
- const startedAtMs = Date.now();
1179
- const timeoutMs = payload.timeoutSeconds ? payload.timeoutSeconds * 1e3 : config.defaultTimeoutMs ?? DEFAULT_CRON_CONFIG.defaultTimeoutMs;
1180
- const roomId = await ensureCronRoom(runtime, job.id, job.name);
1181
- const promptPrefix = `[cron:${job.id} ${job.name}]`;
1182
- const messageText = `${promptPrefix} ${payload.message}`;
1183
- const messageId = uuidv43();
1184
- const memory = {
1185
- id: messageId,
1186
- entityId: runtime.agentId,
1187
- roomId,
1188
- agentId: runtime.agentId,
1189
- content: { text: messageText },
1190
- createdAt: Date.now()
1191
- };
1192
- let responseText = "";
1193
- const callback = async (response) => {
1194
- if (response.text) {
1195
- responseText += response.text;
1196
- }
1195
+ async function readCronRunLogEntries(filePath, opts) {
1196
+ const limit = Math.max(1, Math.min(5e3, Math.floor(opts?.limit ?? 200)));
1197
+ const jobId = opts?.jobId?.trim() || void 0;
1198
+ const raw = await fs2.readFile(path2.resolve(filePath), "utf-8").catch(() => "");
1199
+ if (!raw.trim()) {
1197
1200
  return [];
1198
- };
1199
- let status = "ok";
1200
- let error;
1201
- const runPromise = runtime.messageService.handleMessage(runtime, memory, callback);
1202
- await withTimeout(runPromise, timeoutMs).catch((err) => {
1203
- if (err.message === "Job execution timeout") {
1204
- status = "timeout";
1205
- error = "Execution timed out";
1206
- } else {
1207
- status = "error";
1208
- error = err.message;
1209
- }
1210
- });
1211
- const durationMs = Date.now() - startedAtMs;
1212
- if (status !== "ok") {
1213
- return { status, durationMs, error };
1214
1201
  }
1215
- const plan = resolveCronDeliveryPlan(job);
1216
- if (plan.requested && responseText.trim() && responseText.trim() !== "HEARTBEAT_OK") {
1217
- logger3.info(
1218
- `[Otto Executor] Delivering response for "${job.name}" to ${plan.channel}${plan.to ? `:${plan.to}` : ""}`
1219
- );
1220
- const deliveryError = await deliverToTarget(
1221
- runtime,
1222
- { text: responseText },
1223
- plan.channel,
1224
- plan.to,
1225
- job.delivery?.bestEffort
1226
- ).then(() => null, (err) => err);
1227
- if (deliveryError) {
1228
- return {
1229
- status: "error",
1230
- durationMs: Date.now() - startedAtMs,
1231
- output: responseText,
1232
- error: `Delivery failed: ${deliveryError.message}`
1233
- };
1202
+ const parsed = [];
1203
+ const lines = raw.split("\n");
1204
+ for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) {
1205
+ const line = lines[i]?.trim();
1206
+ if (!line) {
1207
+ continue;
1208
+ }
1209
+ try {
1210
+ const obj = JSON.parse(line);
1211
+ if (!obj || typeof obj !== "object") {
1212
+ continue;
1213
+ }
1214
+ if (obj.action !== "finished") {
1215
+ continue;
1216
+ }
1217
+ if (typeof obj.jobId !== "string" || obj.jobId.trim().length === 0) {
1218
+ continue;
1219
+ }
1220
+ if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) {
1221
+ continue;
1222
+ }
1223
+ if (jobId && obj.jobId !== jobId) {
1224
+ continue;
1225
+ }
1226
+ parsed.push(obj);
1227
+ } catch {
1234
1228
  }
1235
1229
  }
1236
- if (responseText.trim() && responseText.trim() !== "HEARTBEAT_OK") {
1237
- const summary = responseText.length > 200 ? `${responseText.slice(0, 200)}\u2026` : responseText;
1238
- pushSystemEvent(
1239
- runtime.agentId,
1240
- `[Cron "${job.name}" completed] ${summary}`,
1241
- `cron:${job.id}`
1242
- );
1243
- if (job.wakeMode === "now") {
1244
- await wakeHeartbeatNow(runtime);
1230
+ return [...parsed].reverse();
1231
+ }
1232
+
1233
+ // src/otto/store.ts
1234
+ import fs3 from "node:fs";
1235
+ import os from "node:os";
1236
+ import path3 from "node:path";
1237
+ function resolveCronStorePath(storePath) {
1238
+ if (storePath?.trim()) {
1239
+ const raw = storePath.trim();
1240
+ if (raw.startsWith("~")) {
1241
+ return path3.resolve(raw.replace("~", os.homedir()));
1245
1242
  }
1243
+ return path3.resolve(raw);
1246
1244
  }
1247
- return {
1248
- status: "ok",
1249
- durationMs,
1250
- output: responseText || void 0
1251
- };
1245
+ return void 0;
1252
1246
  }
1253
- async function executeOttoJob(runtime, job, config) {
1254
- const { payload } = job;
1255
- switch (payload.kind) {
1256
- case "systemEvent":
1257
- return executeSystemEvent(runtime, job, payload);
1258
- case "agentTurn":
1259
- return executeAgentTurn(runtime, job, payload, config);
1260
- default: {
1261
- const kind = payload.kind;
1262
- return {
1263
- status: "error",
1264
- durationMs: 0,
1265
- error: `Unknown Otto payload kind: ${kind}`
1266
- };
1247
+ async function loadCronStore(storePath) {
1248
+ try {
1249
+ const raw = await fs3.promises.readFile(storePath, "utf-8");
1250
+ let parsed;
1251
+ try {
1252
+ const JSON5 = await import("./dist-MAGERK67.js").then((m) => m.default);
1253
+ parsed = JSON5.parse(raw);
1254
+ } catch {
1255
+ parsed = JSON.parse(raw);
1267
1256
  }
1257
+ const jobs = Array.isArray(parsed?.jobs) ? parsed?.jobs : [];
1258
+ return {
1259
+ version: 1,
1260
+ jobs: jobs.filter(Boolean)
1261
+ };
1262
+ } catch {
1263
+ return { version: 1, jobs: [] };
1264
+ }
1265
+ }
1266
+ async function saveCronStore(storePath, store) {
1267
+ await fs3.promises.mkdir(path3.dirname(storePath), { recursive: true });
1268
+ const tmp = `${storePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
1269
+ const json = JSON.stringify(store, null, 2);
1270
+ await fs3.promises.writeFile(tmp, json, "utf-8");
1271
+ await fs3.promises.rename(tmp, storePath);
1272
+ try {
1273
+ await fs3.promises.copyFile(storePath, `${storePath}.bak`);
1274
+ } catch {
1275
+ }
1276
+ }
1277
+
1278
+ // src/otto/validate-timestamp.ts
1279
+ var ONE_MINUTE_MS = 60 * 1e3;
1280
+ var TEN_YEARS_MS = 10 * 365.25 * 24 * 60 * 60 * 1e3;
1281
+ function validateScheduleTimestamp(schedule, nowMs = Date.now()) {
1282
+ if (schedule.kind !== "at") {
1283
+ return { ok: true };
1284
+ }
1285
+ const atRaw = typeof schedule.at === "string" ? schedule.at.trim() : "";
1286
+ const atMs = atRaw ? parseAbsoluteTimeMs(atRaw) : null;
1287
+ if (atMs === null || !Number.isFinite(atMs)) {
1288
+ return {
1289
+ ok: false,
1290
+ message: `Invalid schedule.at: expected ISO-8601 timestamp (got ${String(schedule.at)})`
1291
+ };
1292
+ }
1293
+ const diffMs = atMs - nowMs;
1294
+ if (diffMs < -ONE_MINUTE_MS) {
1295
+ const nowDate = new Date(nowMs).toISOString();
1296
+ const atDate = new Date(atMs).toISOString();
1297
+ const minutesAgo = Math.floor(-diffMs / ONE_MINUTE_MS);
1298
+ return {
1299
+ ok: false,
1300
+ message: `schedule.at is in the past: ${atDate} (${minutesAgo} minutes ago). Current time: ${nowDate}`
1301
+ };
1302
+ }
1303
+ if (diffMs > TEN_YEARS_MS) {
1304
+ const atDate = new Date(atMs).toISOString();
1305
+ const yearsAhead = Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1e3));
1306
+ return {
1307
+ ok: false,
1308
+ message: `schedule.at is too far in the future: ${atDate} (${yearsAhead} years ahead). Maximum allowed: 10 years`
1309
+ };
1268
1310
  }
1311
+ return { ok: true };
1269
1312
  }
1270
1313
 
1271
1314
  export {
1272
1315
  DEFAULT_CRON_CONFIG,
1273
- isOttoPayload,
1274
- resolveCronDeliveryPlan,
1316
+ resolveHeartbeatConfig,
1317
+ isWithinActiveHours,
1275
1318
  pushSystemEvent,
1276
1319
  drainSystemEvents,
1277
1320
  pendingEventCount,
1278
- resolveHeartbeatConfig,
1279
- isWithinActiveHours,
1280
1321
  HEARTBEAT_WORKER_NAME,
1281
1322
  heartbeatWorker,
1282
1323
  startHeartbeat,
1283
1324
  wakeHeartbeatNow,
1325
+ resolveCronDeliveryPlan,
1326
+ isOttoPayload,
1284
1327
  executeOttoJob,
1285
- parseAbsoluteTimeMs,
1286
- migrateLegacyCronPayload,
1287
- normalizeCronJobInput,
1288
- normalizeCronJobCreate,
1289
- normalizeCronJobPatch,
1290
1328
  assertSupportedJobSpec,
1291
1329
  assertDeliverySupport,
1292
1330
  normalizeRequiredName,
1293
1331
  normalizeOptionalText,
1294
1332
  normalizeOptionalAgentId,
1295
1333
  applyJobPatch,
1296
- validateScheduleTimestamp,
1334
+ parseAbsoluteTimeMs,
1335
+ migrateLegacyCronPayload,
1336
+ normalizeCronJobInput,
1337
+ normalizeCronJobCreate,
1338
+ normalizeCronJobPatch,
1297
1339
  resolveCronRunLogPath,
1298
1340
  appendCronRunLog,
1299
1341
  readCronRunLogEntries,
1300
1342
  resolveCronStorePath,
1301
1343
  loadCronStore,
1302
1344
  saveCronStore,
1345
+ validateScheduleTimestamp,
1303
1346
  otto_exports
1304
1347
  };