macroclaw 0.34.0 → 0.35.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.
- package/package.json +3 -1
- package/src/app.test.ts +2 -0
- package/src/app.ts +4 -1
- package/src/claude.test.ts +8 -0
- package/src/claude.ts +5 -1
- package/src/index.ts +1 -0
- package/src/orchestrator.test.ts +5 -1
- package/src/orchestrator.ts +15 -21
- package/src/{prompts.test.ts → prompt-builder.test.ts} +67 -68
- package/src/{prompts.ts → prompt-builder.ts} +93 -77
- package/src/scheduler.test.ts +60 -24
- package/src/scheduler.ts +17 -2
- package/src/settings.test.ts +82 -1
- package/src/settings.ts +19 -10
- package/src/setup.test.ts +25 -7
- package/src/setup.ts +9 -1
- package/workspace-template/.claude/skills/schedule/SKILL.md +17 -15
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
`<event name="${escapeXml(name)}" type="${type}" session="${session}">`,
|
|
90
|
-
];
|
|
84
|
+
export class PromptBuilder {
|
|
85
|
+
readonly #timezone: string;
|
|
91
86
|
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
constructor(timezone: string) {
|
|
88
|
+
this.#timezone = timezone;
|
|
94
89
|
}
|
|
95
90
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
104
|
-
|
|
95
|
+
#localTime(): string {
|
|
96
|
+
return DateTime.now().setZone(this.#timezone).toFormat("yyyy-MM-dd'T'HH:mm");
|
|
105
97
|
}
|
|
106
98
|
|
|
107
|
-
|
|
108
|
-
|
|
99
|
+
static #escapeXml(text: string): string {
|
|
100
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
109
101
|
}
|
|
110
102
|
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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.
|
|
121
|
-
lines.push(` <file path="${
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
161
|
+
if (fields.instructions) {
|
|
162
|
+
lines.push(`<instructions>${esc(fields.instructions)}</instructions>`);
|
|
163
|
+
}
|
|
131
164
|
|
|
132
|
-
|
|
133
|
-
lines.
|
|
165
|
+
lines.push("</event>");
|
|
166
|
+
return lines.join("\n");
|
|
134
167
|
}
|
|
135
168
|
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
181
|
+
backgroundAgentStart(name: string, text: string): string {
|
|
182
|
+
return PromptBuilder.#buildXml(name, "background-agent-start", "background", this.#localTime(), { text });
|
|
183
|
+
}
|
|
169
184
|
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
197
|
+
healthCheck(name: string, targetEvent: string, instructions: string): string {
|
|
198
|
+
return PromptBuilder.#buildXml(name, "health-check", "background", this.#localTime(), { targetEvent, instructions });
|
|
199
|
+
}
|
|
184
200
|
}
|
package/src/scheduler.test.ts
CHANGED
|
@@ -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 =
|
|
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";
|
package/src/settings.test.ts
CHANGED
|
@@ -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", () => {
|