macroclaw 0.34.0 → 0.36.0

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,4 +1,6 @@
1
- export const SYSTEM_PROMPT = `\
1
+ import { DateTime } from "luxon";
2
+
3
+ const SYSTEM_PROMPT_BASE = `\
2
4
  AI assistant running in macroclaw, an autonomous agent platform. \
3
5
  Persistent workspace at cwd with config, memory, skills. \
4
6
  Refer to workspace CLAUDE.md for identity, personality, conventions.
@@ -16,6 +18,7 @@ Architecture: message bridge connecting chat interface and scheduled tasks. \
16
18
  Persistent session — conversation history carries across messages. Workspace persists across sessions.
17
19
 
18
20
  Event format: every incoming message is wrapped in an <event> XML block. Attributes:
21
+ - time — local time when the event was created (ISO 8601, minute precision).
19
22
  - name — short identifier for this event (e.g. "check-logs", "cron-daily").
20
23
  - type — what triggered this event. One of:
21
24
  - user-message — direct user message. Content in <text>, optional <files>.
@@ -65,12 +68,6 @@ Use "silent" when check finds nothing new, "send" when noteworthy.
65
68
  MessageButtons: include a buttons field (flat array of label strings) to attach inline buttons below your message. \
66
69
  Each button gets its own row. Max 27 characters per label — if options need more detail, describe them in the message and use short labels on buttons.`;
67
70
 
68
- // --- Event builder ---
69
-
70
- function escapeXml(text: string): string {
71
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
72
- }
73
-
74
71
  interface BuildXmlFields {
75
72
  text?: string;
76
73
  files?: string[];
@@ -84,101 +81,120 @@ interface BuildXmlFields {
84
81
  result?: { text: string; files?: string[] };
85
82
  }
86
83
 
87
- function buildXml(name: string, type: string, session: string, fields: BuildXmlFields): string {
88
- const lines: string[] = [
89
- `<event name="${escapeXml(name)}" type="${type}" session="${session}">`,
90
- ];
84
+ export class PromptBuilder {
85
+ readonly #timezone: string;
91
86
 
92
- if (fields.backgroundedEvent) {
93
- lines.push(`<backgrounded-event name="${escapeXml(fields.backgroundedEvent)}" />`);
87
+ constructor(timezone: string) {
88
+ this.#timezone = timezone;
94
89
  }
95
90
 
96
- if (fields.schedule) {
97
- const attrs = [`name="${escapeXml(fields.schedule.name)}"`];
98
- if (fields.schedule.missedBy) attrs.push(`missed-by="${escapeXml(fields.schedule.missedBy)}"`);
99
- if (fields.schedule.scheduledAt) attrs.push(`scheduled-at="${escapeXml(fields.schedule.scheduledAt)}"`);
100
- lines.push(`<schedule ${attrs.join(" ")} />`);
91
+ get systemPrompt(): string {
92
+ return `${SYSTEM_PROMPT_BASE}\n\nTimezone: ${this.#timezone}. TZ env var is set — \`date\` and other CLI tools return local time.`;
101
93
  }
102
94
 
103
- if (fields.originalEvent) {
104
- lines.push(`<original-event name="${escapeXml(fields.originalEvent)}" />`);
95
+ #localTime(): string {
96
+ return DateTime.now().setZone(this.#timezone).toFormat("yyyy-MM-dd'T'HH:mm");
105
97
  }
106
98
 
107
- if (fields.targetEvent) {
108
- lines.push(`<target-event name="${escapeXml(fields.targetEvent)}" />`);
99
+ static #escapeXml(text: string): string {
100
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
109
101
  }
110
102
 
111
- if (fields.progress) {
112
- lines.push(`<progress>${escapeXml(fields.progress)}</progress>`);
113
- }
103
+ static #buildXml(name: string, type: string, session: string, time: string, fields: BuildXmlFields): string {
104
+ const esc = PromptBuilder.#escapeXml;
105
+ const lines: string[] = [
106
+ `<event time="${time}" name="${esc(name)}" type="${type}" session="${session}">`,
107
+ ];
108
+
109
+ if (fields.backgroundedEvent) {
110
+ lines.push(`<backgrounded-event name="${esc(fields.backgroundedEvent)}" />`);
111
+ }
112
+
113
+ if (fields.schedule) {
114
+ const attrs = [`name="${esc(fields.schedule.name)}"`];
115
+ if (fields.schedule.missedBy) attrs.push(`missed-by="${esc(fields.schedule.missedBy)}"`);
116
+ if (fields.schedule.scheduledAt) attrs.push(`scheduled-at="${esc(fields.schedule.scheduledAt)}"`);
117
+ lines.push(`<schedule ${attrs.join(" ")} />`);
118
+ }
119
+
120
+ if (fields.originalEvent) {
121
+ lines.push(`<original-event name="${esc(fields.originalEvent)}" />`);
122
+ }
123
+
124
+ if (fields.targetEvent) {
125
+ lines.push(`<target-event name="${esc(fields.targetEvent)}" />`);
126
+ }
127
+
128
+ if (fields.progress) {
129
+ lines.push(`<progress>${esc(fields.progress)}</progress>`);
130
+ }
131
+
132
+ if (fields.result) {
133
+ lines.push("<result>");
134
+ lines.push(`<text>${esc(fields.result.text)}</text>`);
135
+ if (fields.result.files?.length) {
136
+ lines.push("<files>");
137
+ for (const f of fields.result.files) {
138
+ lines.push(` <file path="${esc(f)}" />`);
139
+ }
140
+ lines.push("</files>");
141
+ }
142
+ lines.push("</result>");
143
+ }
144
+
145
+ if (fields.button) {
146
+ lines.push(`<button>${esc(fields.button)}</button>`);
147
+ }
148
+
149
+ if (fields.text) {
150
+ lines.push(`<text>${esc(fields.text)}</text>`);
151
+ }
114
152
 
115
- if (fields.result) {
116
- lines.push("<result>");
117
- lines.push(`<text>${escapeXml(fields.result.text)}</text>`);
118
- if (fields.result.files?.length) {
153
+ if (fields.files?.length) {
119
154
  lines.push("<files>");
120
- for (const f of fields.result.files) {
121
- lines.push(` <file path="${escapeXml(f)}" />`);
155
+ for (const f of fields.files) {
156
+ lines.push(` <file path="${esc(f)}" />`);
122
157
  }
123
158
  lines.push("</files>");
124
159
  }
125
- lines.push("</result>");
126
- }
127
160
 
128
- if (fields.button) {
129
- lines.push(`<button>${escapeXml(fields.button)}</button>`);
130
- }
161
+ if (fields.instructions) {
162
+ lines.push(`<instructions>${esc(fields.instructions)}</instructions>`);
163
+ }
131
164
 
132
- if (fields.text) {
133
- lines.push(`<text>${escapeXml(fields.text)}</text>`);
165
+ lines.push("</event>");
166
+ return lines.join("\n");
134
167
  }
135
168
 
136
- if (fields.files?.length) {
137
- lines.push("<files>");
138
- for (const f of fields.files) {
139
- lines.push(` <file path="${escapeXml(f)}" />`);
140
- }
141
- lines.push("</files>");
169
+ userMessage(name: string, text: string, opts?: { files?: string[]; backgroundedEvent?: string }): string {
170
+ return PromptBuilder.#buildXml(name, "user-message", "main", this.#localTime(), { text, files: opts?.files, backgroundedEvent: opts?.backgroundedEvent });
142
171
  }
143
172
 
144
- if (fields.instructions) {
145
- lines.push(`<instructions>${escapeXml(fields.instructions)}</instructions>`);
173
+ buttonClick(name: string, button: string, opts?: { backgroundedEvent?: string }): string {
174
+ return PromptBuilder.#buildXml(name, "button-click", "main", this.#localTime(), { button, backgroundedEvent: opts?.backgroundedEvent });
146
175
  }
147
176
 
148
- lines.push("</event>");
149
- return lines.join("\n");
150
- }
151
-
152
- // --- Per-type event builders ---
153
-
154
- export function userMessageEvent(name: string, text: string, opts?: { files?: string[]; backgroundedEvent?: string }): string {
155
- return buildXml(name, "user-message", "main", { text, files: opts?.files, backgroundedEvent: opts?.backgroundedEvent });
156
- }
157
-
158
- export function buttonClickEvent(name: string, button: string, opts?: { backgroundedEvent?: string }): string {
159
- return buildXml(name, "button-click", "main", { button, backgroundedEvent: opts?.backgroundedEvent });
160
- }
161
-
162
- export function scheduleTriggerEvent(name: string, schedule: { name: string; missedBy?: string; scheduledAt?: string }, text: string): string {
163
- return buildXml(name, "schedule-trigger", "background", { schedule, text });
164
- }
177
+ scheduleTrigger(name: string, schedule: { name: string; missedBy?: string; scheduledAt?: string }, text: string): string {
178
+ return PromptBuilder.#buildXml(name, "schedule-trigger", "background", this.#localTime(), { schedule, text });
179
+ }
165
180
 
166
- export function backgroundAgentStartEvent(name: string, text: string): string {
167
- return buildXml(name, "background-agent-start", "background", { text });
168
- }
181
+ backgroundAgentStart(name: string, text: string): string {
182
+ return PromptBuilder.#buildXml(name, "background-agent-start", "background", this.#localTime(), { text });
183
+ }
169
184
 
170
- export function backgroundAgentResultEvent(name: string, originalEvent: string, result: { text: string; files?: string[] }, instructions: string, opts?: { backgroundedEvent?: string }): string {
171
- return buildXml(name, "background-agent-result", "main", { originalEvent, result, instructions, backgroundedEvent: opts?.backgroundedEvent });
172
- }
185
+ backgroundAgentResult(name: string, originalEvent: string, result: { text: string; files?: string[] }, instructions: string, opts?: { backgroundedEvent?: string }): string {
186
+ return PromptBuilder.#buildXml(name, "background-agent-result", "main", this.#localTime(), { originalEvent, result, instructions, backgroundedEvent: opts?.backgroundedEvent });
187
+ }
173
188
 
174
- export function backgroundAgentProgressEvent(name: string, originalEvent: string, progress: string, instructions: string, opts?: { backgroundedEvent?: string }): string {
175
- return buildXml(name, "background-agent-progress", "main", { originalEvent, progress, instructions, backgroundedEvent: opts?.backgroundedEvent });
176
- }
189
+ backgroundAgentProgress(name: string, originalEvent: string, progress: string, instructions: string, opts?: { backgroundedEvent?: string }): string {
190
+ return PromptBuilder.#buildXml(name, "background-agent-progress", "main", this.#localTime(), { originalEvent, progress, instructions, backgroundedEvent: opts?.backgroundedEvent });
191
+ }
177
192
 
178
- export function peekEvent(name: string, targetEvent: string, instructions: string): string {
179
- return buildXml(name, "peek", "background", { targetEvent, instructions });
180
- }
193
+ peek(name: string, targetEvent: string, instructions: string): string {
194
+ return PromptBuilder.#buildXml(name, "peek", "background", this.#localTime(), { targetEvent, instructions });
195
+ }
181
196
 
182
- export function healthCheckEvent(name: string, targetEvent: string, instructions: string): string {
183
- return buildXml(name, "health-check", "background", { targetEvent, instructions });
197
+ healthCheck(name: string, targetEvent: string, instructions: string): string {
198
+ return PromptBuilder.#buildXml(name, "health-check", "background", this.#localTime(), { targetEvent, instructions });
199
+ }
184
200
  }
@@ -73,7 +73,7 @@ describe("Scheduler — cron jobs", () => {
73
73
  });
74
74
 
75
75
  const onJob = makeOnJob();
76
- const s = new Scheduler(TEST_DIR, { onJob });
76
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
77
77
  s.start();
78
78
  s.stop();
79
79
 
@@ -86,7 +86,7 @@ describe("Scheduler — cron jobs", () => {
86
86
  });
87
87
 
88
88
  const onJob = makeOnJob();
89
- const s = new Scheduler(TEST_DIR, { onJob });
89
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
90
90
  s.start();
91
91
  s.stop();
92
92
 
@@ -102,7 +102,7 @@ describe("Scheduler — cron jobs", () => {
102
102
  });
103
103
 
104
104
  const onJob = makeOnJob();
105
- const s = new Scheduler(TEST_DIR, { onJob });
105
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
106
106
  s.start();
107
107
  s.stop();
108
108
 
@@ -119,7 +119,7 @@ describe("Scheduler — cron jobs", () => {
119
119
  });
120
120
 
121
121
  const onJob = makeOnJob();
122
- const s = new Scheduler(TEST_DIR, { onJob });
122
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
123
123
  s.start();
124
124
  s.stop();
125
125
 
@@ -134,7 +134,7 @@ describe("Scheduler — cron jobs", () => {
134
134
  });
135
135
 
136
136
  const onJob = makeOnJob();
137
- const s = new Scheduler(TEST_DIR, { onJob });
137
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
138
138
  s.start();
139
139
  s.stop();
140
140
 
@@ -147,7 +147,7 @@ describe("Scheduler — cron jobs", () => {
147
147
  });
148
148
 
149
149
  const onJob = makeOnJob();
150
- const s = new Scheduler(TEST_DIR, { onJob });
150
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
151
151
  s.start();
152
152
  s.stop();
153
153
 
@@ -164,7 +164,7 @@ describe("Scheduler — fireAt jobs", () => {
164
164
  });
165
165
 
166
166
  const onJob = makeOnJob();
167
- const s = new Scheduler(TEST_DIR, { onJob });
167
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
168
168
  s.start();
169
169
  s.stop();
170
170
 
@@ -181,7 +181,7 @@ describe("Scheduler — fireAt jobs", () => {
181
181
  });
182
182
 
183
183
  const onJob = makeOnJob();
184
- const s = new Scheduler(TEST_DIR, { onJob });
184
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
185
185
  s.start();
186
186
  s.stop();
187
187
 
@@ -197,7 +197,7 @@ describe("Scheduler — fireAt jobs", () => {
197
197
  });
198
198
 
199
199
  const onJob = makeOnJob();
200
- const s = new Scheduler(TEST_DIR, { onJob });
200
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
201
201
  s.start();
202
202
  s.stop();
203
203
 
@@ -214,7 +214,7 @@ describe("Scheduler — fireAt jobs", () => {
214
214
  });
215
215
 
216
216
  const onJob = makeOnJob();
217
- const s = new Scheduler(TEST_DIR, { onJob });
217
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
218
218
  s.start();
219
219
  s.stop();
220
220
 
@@ -236,7 +236,7 @@ describe("Scheduler — fireAt jobs", () => {
236
236
  });
237
237
 
238
238
  const onJob = makeOnJob();
239
- const s = new Scheduler(TEST_DIR, { onJob });
239
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
240
240
  s.start();
241
241
  s.stop();
242
242
 
@@ -252,7 +252,7 @@ describe("Scheduler — fireAt jobs", () => {
252
252
  });
253
253
 
254
254
  const onJob = makeOnJob();
255
- const s = new Scheduler(TEST_DIR, { onJob });
255
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
256
256
  s.start();
257
257
  s.stop();
258
258
 
@@ -273,7 +273,7 @@ describe("Scheduler — fireAt jobs", () => {
273
273
  });
274
274
 
275
275
  const onJob = makeOnJob();
276
- const s = new Scheduler(TEST_DIR, { onJob });
276
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
277
277
  s.start();
278
278
  s.stop();
279
279
 
@@ -290,13 +290,49 @@ describe("Scheduler — fireAt jobs", () => {
290
290
  });
291
291
 
292
292
  const onJob = makeOnJob();
293
- const s = new Scheduler(TEST_DIR, { onJob });
293
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
294
294
  s.start();
295
295
  s.stop();
296
296
 
297
297
  expect(onJob).toHaveBeenCalledWith("smart", "think", "opus");
298
298
  });
299
299
 
300
+ it("interprets offset-less fireAt in the configured timezone", () => {
301
+ // Create a fireAt 30 seconds ago in Europe/Prague local time (no offset)
302
+ const now = new Date();
303
+ const pragueStr = now.toLocaleString("en-US", { timeZone: "Europe/Prague" });
304
+ const utcStr = now.toLocaleString("en-US", { timeZone: "UTC" });
305
+ const pragueOffsetMs = new Date(pragueStr).getTime() - new Date(utcStr).getTime();
306
+ // Wall-clock time in Prague 30 seconds ago
307
+ const pragueNow = new Date(now.getTime() + pragueOffsetMs - 30_000);
308
+ const naive = pragueNow.toISOString().replace("Z", "").replace(/\.\d+$/, "");
309
+
310
+ writeScheduleConfig({
311
+ jobs: [{ name: "local", fireAt: naive, prompt: "local time" }],
312
+ });
313
+
314
+ const onJob = makeOnJob();
315
+ const s = new Scheduler(TEST_DIR, { timezone: "Europe/Prague", onJob });
316
+ s.start();
317
+ s.stop();
318
+
319
+ expect(onJob).toHaveBeenCalledWith("local", "local time", undefined);
320
+ });
321
+
322
+ it("preserves explicit offset in fireAt (ignores configured timezone)", () => {
323
+ // fireAt with explicit +00:00 offset, 30s ago — should fire regardless of configured tz
324
+ writeScheduleConfig({
325
+ jobs: [{ name: "explicit", fireAt: justNowFireAt(), prompt: "with offset" }],
326
+ });
327
+
328
+ const onJob = makeOnJob();
329
+ const s = new Scheduler(TEST_DIR, { timezone: "Asia/Tokyo", onJob });
330
+ s.start();
331
+ s.stop();
332
+
333
+ expect(onJob).toHaveBeenCalledWith("explicit", "with offset", undefined);
334
+ });
335
+
300
336
  it("still fires when write-back of schedule.json fails", () => {
301
337
  writeScheduleConfig({
302
338
  jobs: [{ name: "once", fireAt: justNowFireAt(), prompt: "fire" }],
@@ -306,7 +342,7 @@ describe("Scheduler — fireAt jobs", () => {
306
342
  chmodSync(SCHEDULE_FILE, 0o444);
307
343
 
308
344
  const onJob = makeOnJob();
309
- const s = new Scheduler(TEST_DIR, { onJob });
345
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
310
346
  s.start();
311
347
  s.stop();
312
348
 
@@ -321,7 +357,7 @@ describe("Scheduler — validation and edge cases", () => {
321
357
  rmSync(SCHEDULE_FILE, { force: true });
322
358
 
323
359
  const onJob = makeOnJob();
324
- const s = new Scheduler(TEST_DIR, { onJob });
360
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
325
361
  s.start();
326
362
  s.stop();
327
363
 
@@ -332,7 +368,7 @@ describe("Scheduler — validation and edge cases", () => {
332
368
  writeFileSync(SCHEDULE_FILE, "not json{{{");
333
369
 
334
370
  const onJob = makeOnJob();
335
- const s = new Scheduler(TEST_DIR, { onJob });
371
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
336
372
  s.start();
337
373
  s.stop();
338
374
 
@@ -343,7 +379,7 @@ describe("Scheduler — validation and edge cases", () => {
343
379
  writeScheduleConfig({ jobs: "not-array" });
344
380
 
345
381
  const onJob = makeOnJob();
346
- const s = new Scheduler(TEST_DIR, { onJob });
382
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
347
383
  s.start();
348
384
  s.stop();
349
385
 
@@ -356,7 +392,7 @@ describe("Scheduler — validation and edge cases", () => {
356
392
  });
357
393
 
358
394
  const onJob = makeOnJob();
359
- const s = new Scheduler(TEST_DIR, { onJob });
395
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
360
396
  s.start();
361
397
  s.stop();
362
398
 
@@ -369,7 +405,7 @@ describe("Scheduler — validation and edge cases", () => {
369
405
  });
370
406
 
371
407
  const onJob = makeOnJob();
372
- const s = new Scheduler(TEST_DIR, { onJob });
408
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
373
409
  s.start();
374
410
  s.stop();
375
411
 
@@ -384,7 +420,7 @@ describe("Scheduler — validation and edge cases", () => {
384
420
  writeScheduleConfig({ jobs: [] });
385
421
 
386
422
  const onJob = makeOnJob();
387
- const s = new Scheduler(TEST_DIR, { onJob });
423
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
388
424
  s.start();
389
425
  s.stop(); // should not throw
390
426
  });
@@ -395,7 +431,7 @@ describe("Scheduler — validation and edge cases", () => {
395
431
  });
396
432
 
397
433
  const onJob = makeOnJob();
398
- const s = new Scheduler(TEST_DIR, { onJob });
434
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
399
435
  s.start();
400
436
  s.stop();
401
437
 
@@ -403,7 +439,7 @@ describe("Scheduler — validation and edge cases", () => {
403
439
 
404
440
  // Start again with a new instance — the lastMinute tracker is per-instance
405
441
  const onJob2 = makeOnJob();
406
- const s2 = new Scheduler(TEST_DIR, { onJob: onJob2 });
442
+ const s2 = new Scheduler(TEST_DIR, { timezone: "UTC", onJob: onJob2 });
407
443
  s2.start();
408
444
  s2.stop();
409
445
 
@@ -416,7 +452,7 @@ describe("Scheduler — validation and edge cases", () => {
416
452
  });
417
453
 
418
454
  const onJob = makeOnJob();
419
- const s = new Scheduler(TEST_DIR, { onJob });
455
+ const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
420
456
  s.start();
421
457
  s.stop();
422
458
 
package/src/scheduler.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CronExpressionParser } from "cron-parser";
4
+ import { DateTime } from "luxon";
4
5
  import { z } from "zod/v4";
5
6
  import { createLogger } from "./logger";
6
7
 
@@ -27,6 +28,7 @@ export interface MissedInfo {
27
28
  }
28
29
 
29
30
  export interface SchedulerConfig {
31
+ timezone: string;
30
32
  onJob: (name: string, prompt: string, model?: string, missed?: MissedInfo) => void;
31
33
  }
32
34
 
@@ -37,11 +39,13 @@ export class Scheduler {
37
39
  #lastMinute = -1;
38
40
  #schedulePath: string;
39
41
  #config: SchedulerConfig;
42
+ #timezone: string;
40
43
  #timer: Timer | null = null;
41
44
 
42
45
  constructor(workspace: string, config: SchedulerConfig) {
43
46
  this.#schedulePath = join(workspace, "data", "schedule.json");
44
47
  this.#config = config;
48
+ this.#timezone = config.timezone;
45
49
  }
46
50
 
47
51
  start(): void {
@@ -116,7 +120,7 @@ export class Scheduler {
116
120
 
117
121
  #evaluateCronJob(job: { name: string; cron: string; prompt: string; model?: string }, now: Date): void {
118
122
  try {
119
- const interval = CronExpressionParser.parse(job.cron);
123
+ const interval = CronExpressionParser.parse(job.cron, { tz: this.#timezone });
120
124
  const prev = interval.prev();
121
125
  const diff = Math.abs(now.getTime() - prev.getTime());
122
126
  if (diff < 60_000) {
@@ -128,11 +132,22 @@ export class Scheduler {
128
132
  }
129
133
  }
130
134
 
135
+ /** Parse a fireAt string, interpreting offset-less timestamps in the given timezone. */
136
+ static #parseFireAt(fireAt: string, timezone: string): Date {
137
+ const probe = DateTime.fromISO(fireAt, { setZone: true });
138
+ if (probe.isValid && probe.isOffsetFixed) {
139
+ // Has explicit offset (Z, +HH:MM, etc.) — use as-is
140
+ return probe.toJSDate();
141
+ }
142
+ // No offset — interpret as local time in the configured timezone
143
+ return DateTime.fromISO(fireAt, { zone: timezone }).toJSDate();
144
+ }
145
+
131
146
  #evaluateFireAtJob(
132
147
  job: { name: string; fireAt: string; prompt: string; model?: string },
133
148
  now: Date,
134
149
  ): "remove" | "keep" {
135
- const fireAt = new Date(job.fireAt);
150
+ const fireAt = Scheduler.#parseFireAt(job.fireAt, this.#timezone);
136
151
  if (Number.isNaN(fireAt.getTime())) {
137
152
  log.warn({ name: job.name, fireAt: job.fireAt }, "Invalid fireAt date");
138
153
  return "keep";
@@ -10,6 +10,7 @@ const validSettings: Settings = {
10
10
  chatId: "12345678",
11
11
  model: "sonnet",
12
12
  workspace: "~/.macroclaw-workspace",
13
+ timezone: "UTC",
13
14
  logLevel: "debug",
14
15
  };
15
16
 
@@ -39,10 +40,36 @@ describe("SettingsManager.load", () => {
39
40
  chatId: "12345678",
40
41
  model: "sonnet",
41
42
  workspace: "~/.macroclaw-workspace",
43
+ timezone: "UTC",
42
44
  logLevel: "info",
43
45
  });
44
46
  });
45
47
 
48
+ it("trims string settings loaded from file", () => {
49
+ mkdirSync(tmpDir, { recursive: true });
50
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
51
+ botToken: " 123:ABC ",
52
+ chatId: " 12345678 ",
53
+ model: " opus ",
54
+ workspace: " /custom/workspace ",
55
+ timezone: " Europe/Prague ",
56
+ openaiApiKey: " sk-test ",
57
+ logLevel: " warn ",
58
+ pinoramaUrl: " http://localhost:6200 ",
59
+ }));
60
+ const settings = new SettingsManager(tmpDir).load();
61
+ expect(settings).toEqual({
62
+ botToken: "123:ABC",
63
+ chatId: "12345678",
64
+ model: "opus",
65
+ workspace: "/custom/workspace",
66
+ timezone: "Europe/Prague",
67
+ openaiApiKey: "sk-test",
68
+ logLevel: "warn",
69
+ pinoramaUrl: "http://localhost:6200",
70
+ });
71
+ });
72
+
46
73
  it("applies defaults for optional fields", () => {
47
74
  mkdirSync(tmpDir, { recursive: true });
48
75
  writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
@@ -60,6 +87,7 @@ describe("SettingsManager.load", () => {
60
87
  chatId: "123",
61
88
  model: "opus",
62
89
  workspace: "/custom",
90
+ timezone: "UTC",
63
91
  openaiApiKey: "sk-test",
64
92
  logLevel: "info",
65
93
  pinoramaUrl: "http://localhost:6200",
@@ -123,6 +151,28 @@ describe("SettingsManager.load", () => {
123
151
  expect(mockExit).toHaveBeenCalledWith(1);
124
152
  process.exit = origExit;
125
153
  });
154
+
155
+ it("exits with code 1 when validation fails (invalid timezone)", () => {
156
+ mkdirSync(tmpDir, { recursive: true });
157
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
158
+ botToken: "tok",
159
+ chatId: "123",
160
+ timezone: "Europe/Prgaaue",
161
+ }));
162
+
163
+ const mockExit = mock(() => { throw new Error("exit"); });
164
+ const origExit = process.exit;
165
+ process.exit = mockExit as any;
166
+
167
+ try {
168
+ new SettingsManager(tmpDir).load();
169
+ } catch {
170
+ // expected
171
+ }
172
+
173
+ expect(mockExit).toHaveBeenCalledWith(1);
174
+ process.exit = origExit;
175
+ });
126
176
  });
127
177
 
128
178
  describe("SettingsManager.save", () => {
@@ -169,7 +219,7 @@ describe("SettingsManager.loadRaw", () => {
169
219
  describe("SettingsManager.applyEnvOverrides", () => {
170
220
  const envVars = [
171
221
  "TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL",
172
- "WORKSPACE", "OPENAI_API_KEY", "LOG_LEVEL", "PINORAMA_URL",
222
+ "WORKSPACE", "TIMEZONE", "OPENAI_API_KEY", "LOG_LEVEL", "PINORAMA_URL",
173
223
  ];
174
224
  const savedEnv: Record<string, string | undefined> = {};
175
225
 
@@ -223,6 +273,37 @@ describe("SettingsManager.applyEnvOverrides", () => {
223
273
  expect(overrides.has("logLevel")).toBe(true);
224
274
  expect(overrides.has("pinoramaUrl")).toBe(true);
225
275
  });
276
+
277
+ it("trims env override values before returning settings", () => {
278
+ process.env.MODEL = " opus ";
279
+ process.env.WORKSPACE = " /override/path ";
280
+ process.env.TIMEZONE = " Europe/Prague ";
281
+ process.env.LOG_LEVEL = " error ";
282
+ process.env.PINORAMA_URL = " http://override:6200 ";
283
+ const { settings } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
284
+ expect(settings.model).toBe("opus");
285
+ expect(settings.workspace).toBe("/override/path");
286
+ expect(settings.timezone).toBe("Europe/Prague");
287
+ expect(settings.logLevel).toBe("error");
288
+ expect(settings.pinoramaUrl).toBe("http://override:6200");
289
+ });
290
+
291
+ it("exits with code 1 when an env override is invalid", () => {
292
+ process.env.TIMEZONE = "Europe/Prgaaue";
293
+
294
+ const mockExit = mock(() => { throw new Error("exit"); });
295
+ const origExit = process.exit;
296
+ process.exit = mockExit as any;
297
+
298
+ try {
299
+ new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
300
+ } catch {
301
+ // expected
302
+ }
303
+
304
+ expect(mockExit).toHaveBeenCalledWith(1);
305
+ process.exit = origExit;
306
+ });
226
307
  });
227
308
 
228
309
  describe("SettingsManager.print", () => {