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
package/src/settings.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
+
import { IANAZone } from "luxon";
|
|
3
4
|
import { z } from "zod/v4";
|
|
4
5
|
import { createLogger } from "./logger";
|
|
5
6
|
|
|
6
7
|
const log = createLogger("settings");
|
|
7
8
|
|
|
8
9
|
export const settingsSchema = z.object({
|
|
9
|
-
botToken: z.string(),
|
|
10
|
-
chatId: z.string().regex(/^-?\d+$/, "Must be a numeric Telegram chat ID"),
|
|
11
|
-
model: z.enum(["haiku", "sonnet", "opus"]).default("sonnet"),
|
|
12
|
-
workspace: z.string().default("~/.macroclaw-workspace"),
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
botToken: z.string().trim(),
|
|
11
|
+
chatId: z.string().trim().regex(/^-?\d+$/, "Must be a numeric Telegram chat ID"),
|
|
12
|
+
model: z.string().trim().pipe(z.enum(["haiku", "sonnet", "opus"])).default("sonnet"),
|
|
13
|
+
workspace: z.string().trim().default("~/.macroclaw-workspace"),
|
|
14
|
+
timezone: z.string().trim().refine((tz) => IANAZone.isValidZone(tz), "Must be a valid IANA timezone").default("UTC"),
|
|
15
|
+
openaiApiKey: z.string().trim().optional(),
|
|
16
|
+
logLevel: z.string().trim().pipe(z.enum(["debug", "info", "warn", "error"])).default("info"),
|
|
17
|
+
pinoramaUrl: z.string().trim().optional(),
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
export type Settings = z.infer<typeof settingsSchema>;
|
|
@@ -35,6 +37,7 @@ export class SettingsManager {
|
|
|
35
37
|
chatId: "AUTHORIZED_CHAT_ID",
|
|
36
38
|
model: "MODEL",
|
|
37
39
|
workspace: "WORKSPACE",
|
|
40
|
+
timezone: "TIMEZONE",
|
|
38
41
|
openaiApiKey: "OPENAI_API_KEY",
|
|
39
42
|
logLevel: "LOG_LEVEL",
|
|
40
43
|
pinoramaUrl: "PINORAMA_URL",
|
|
@@ -88,18 +91,24 @@ export class SettingsManager {
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
applyEnvOverrides(settings: Settings): { settings: Settings; overrides: Set<string> } {
|
|
91
|
-
const merged = { ...settings };
|
|
94
|
+
const merged: Record<string, unknown> = { ...settings };
|
|
92
95
|
const overrides = new Set<string>();
|
|
93
96
|
|
|
94
97
|
for (const [key, envVar] of Object.entries(SettingsManager.envMapping)) {
|
|
95
98
|
const value = process.env[envVar];
|
|
96
99
|
if (value !== undefined) {
|
|
97
|
-
|
|
100
|
+
merged[key] = value;
|
|
98
101
|
overrides.add(key);
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
const result = settingsSchema.safeParse(merged);
|
|
106
|
+
if (!result.success) {
|
|
107
|
+
log.error({ issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }, "settings env override validation failed");
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { settings: result.data, overrides };
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
print(settings: Settings, overrides: Set<string>): void {
|
package/src/setup.test.ts
CHANGED
|
@@ -79,7 +79,7 @@ function createMockIO(inputs: string[]): SetupIo & { written: string[] } {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
// Save/restore env vars
|
|
82
|
-
const envVars = ["TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL", "WORKSPACE", "OPENAI_API_KEY", "LOG_LEVEL"];
|
|
82
|
+
const envVars = ["TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL", "WORKSPACE", "TIMEZONE", "OPENAI_API_KEY", "LOG_LEVEL"];
|
|
83
83
|
const savedEnv: Record<string, string | undefined> = {};
|
|
84
84
|
|
|
85
85
|
beforeEach(() => {
|
|
@@ -107,12 +107,13 @@ afterEach(() => {
|
|
|
107
107
|
describe("SetupWizard", () => {
|
|
108
108
|
it("collects all required fields via prompts", async () => {
|
|
109
109
|
const io = createMockIO([
|
|
110
|
-
"123:ABC",
|
|
111
|
-
"12345678",
|
|
112
|
-
"opus",
|
|
113
|
-
"/my/ws",
|
|
114
|
-
"
|
|
115
|
-
"",
|
|
110
|
+
"123:ABC", // bot token
|
|
111
|
+
"12345678", // chat ID
|
|
112
|
+
"opus", // model
|
|
113
|
+
"/my/ws", // workspace
|
|
114
|
+
"Europe/Prague", // timezone
|
|
115
|
+
"sk-test", // openai key
|
|
116
|
+
"", // no service install
|
|
116
117
|
]);
|
|
117
118
|
|
|
118
119
|
const settings = await runSetup(io);
|
|
@@ -121,6 +122,7 @@ describe("SetupWizard", () => {
|
|
|
121
122
|
expect(settings.chatId).toBe("12345678");
|
|
122
123
|
expect(settings.model).toBe("opus");
|
|
123
124
|
expect(settings.workspace).toBe("/my/ws");
|
|
125
|
+
expect(settings.timezone).toBe("Europe/Prague");
|
|
124
126
|
expect(settings.openaiApiKey).toBe("sk-test");
|
|
125
127
|
expect(settings.logLevel).toBe("info");
|
|
126
128
|
});
|
|
@@ -130,6 +132,7 @@ describe("SetupWizard", () => {
|
|
|
130
132
|
process.env.AUTHORIZED_CHAT_ID = "99887766";
|
|
131
133
|
process.env.MODEL = "haiku";
|
|
132
134
|
process.env.WORKSPACE = "/env/ws";
|
|
135
|
+
process.env.TIMEZONE = "America/New_York";
|
|
133
136
|
process.env.OPENAI_API_KEY = "sk-env";
|
|
134
137
|
|
|
135
138
|
const io = createMockIO([
|
|
@@ -137,6 +140,7 @@ describe("SetupWizard", () => {
|
|
|
137
140
|
"", // accept default chat ID
|
|
138
141
|
"", // accept default model
|
|
139
142
|
"", // accept default workspace
|
|
143
|
+
"", // accept default timezone
|
|
140
144
|
"", // accept default openai key
|
|
141
145
|
"", // no service install
|
|
142
146
|
]);
|
|
@@ -147,6 +151,7 @@ describe("SetupWizard", () => {
|
|
|
147
151
|
expect(settings.chatId).toBe("99887766");
|
|
148
152
|
expect(settings.model).toBe("haiku");
|
|
149
153
|
expect(settings.workspace).toBe("/env/ws");
|
|
154
|
+
expect(settings.timezone).toBe("America/New_York");
|
|
150
155
|
expect(settings.openaiApiKey).toBe("sk-env");
|
|
151
156
|
});
|
|
152
157
|
|
|
@@ -156,6 +161,7 @@ describe("SetupWizard", () => {
|
|
|
156
161
|
"123",
|
|
157
162
|
"", // press enter for default model
|
|
158
163
|
"", // press enter for default workspace
|
|
164
|
+
"", // press enter for default timezone
|
|
159
165
|
"", // press enter for no openai key
|
|
160
166
|
"", // no service install
|
|
161
167
|
]);
|
|
@@ -173,6 +179,7 @@ describe("SetupWizard", () => {
|
|
|
173
179
|
"123",
|
|
174
180
|
"",
|
|
175
181
|
"",
|
|
182
|
+
"", // timezone
|
|
176
183
|
"",
|
|
177
184
|
"", // no service install
|
|
178
185
|
]);
|
|
@@ -196,6 +203,7 @@ describe("SetupWizard", () => {
|
|
|
196
203
|
"good-token", // second attempt — succeeds
|
|
197
204
|
"123",
|
|
198
205
|
"",
|
|
206
|
+
"", // timezone
|
|
199
207
|
"",
|
|
200
208
|
"",
|
|
201
209
|
"", // no service install
|
|
@@ -213,6 +221,7 @@ describe("SetupWizard", () => {
|
|
|
213
221
|
"actual-token",
|
|
214
222
|
"123",
|
|
215
223
|
"",
|
|
224
|
+
"", // timezone
|
|
216
225
|
"",
|
|
217
226
|
"",
|
|
218
227
|
"", // no service install
|
|
@@ -229,6 +238,7 @@ describe("SetupWizard", () => {
|
|
|
229
238
|
"", // empty — re-prompt
|
|
230
239
|
"456",
|
|
231
240
|
"",
|
|
241
|
+
"", // timezone
|
|
232
242
|
"",
|
|
233
243
|
"",
|
|
234
244
|
"", // no service install
|
|
@@ -245,6 +255,7 @@ describe("SetupWizard", () => {
|
|
|
245
255
|
"not-a-number", // invalid — re-prompt
|
|
246
256
|
"456",
|
|
247
257
|
"",
|
|
258
|
+
"", // timezone
|
|
248
259
|
"",
|
|
249
260
|
"",
|
|
250
261
|
"", // no service install
|
|
@@ -263,6 +274,7 @@ describe("SetupWizard", () => {
|
|
|
263
274
|
"xxx", // invalid — re-prompt
|
|
264
275
|
"opus", // valid
|
|
265
276
|
"",
|
|
277
|
+
"", // timezone
|
|
266
278
|
"",
|
|
267
279
|
"", // no service install
|
|
268
280
|
]);
|
|
@@ -279,6 +291,7 @@ describe("SetupWizard", () => {
|
|
|
279
291
|
"123",
|
|
280
292
|
"",
|
|
281
293
|
"",
|
|
294
|
+
"", // timezone
|
|
282
295
|
"",
|
|
283
296
|
"", // no service install
|
|
284
297
|
]);
|
|
@@ -297,6 +310,7 @@ describe("SetupWizard", () => {
|
|
|
297
310
|
"123",
|
|
298
311
|
"",
|
|
299
312
|
"",
|
|
313
|
+
"", // timezone
|
|
300
314
|
"",
|
|
301
315
|
"", // no service install
|
|
302
316
|
]);
|
|
@@ -317,6 +331,7 @@ it("installs service when user answers yes", async () => {
|
|
|
317
331
|
"123",
|
|
318
332
|
"",
|
|
319
333
|
"",
|
|
334
|
+
"", // timezone
|
|
320
335
|
"",
|
|
321
336
|
"y",
|
|
322
337
|
"sk-test-token", // oauth token (macOS)
|
|
@@ -336,6 +351,7 @@ it("installs service when user answers yes", async () => {
|
|
|
336
351
|
"123",
|
|
337
352
|
"",
|
|
338
353
|
"",
|
|
354
|
+
"", // timezone
|
|
339
355
|
"",
|
|
340
356
|
"n", // no to service install
|
|
341
357
|
]);
|
|
@@ -353,6 +369,7 @@ it("installs service when user answers yes", async () => {
|
|
|
353
369
|
"123",
|
|
354
370
|
"",
|
|
355
371
|
"",
|
|
372
|
+
"", // timezone
|
|
356
373
|
"",
|
|
357
374
|
"y",
|
|
358
375
|
"", // empty oauth token
|
|
@@ -373,6 +390,7 @@ it("installs service when user answers yes", async () => {
|
|
|
373
390
|
"123",
|
|
374
391
|
"",
|
|
375
392
|
"",
|
|
393
|
+
"", // timezone
|
|
376
394
|
"",
|
|
377
395
|
"yes",
|
|
378
396
|
"sk-test-token", // oauth token (macOS)
|
package/src/setup.ts
CHANGED
|
@@ -84,6 +84,13 @@ export class SetupWizard {
|
|
|
84
84
|
const defaultWorkspace = this.#default("workspace", "~/.macroclaw-workspace");
|
|
85
85
|
const workspace = await this.#askValidated("workspace", `Workspace [${defaultWorkspace}]: `, defaultWorkspace);
|
|
86
86
|
|
|
87
|
+
// Timezone
|
|
88
|
+
this.#io.write("\nLocal timezone for the agent's clock and scheduled events.\n");
|
|
89
|
+
this.#io.write("Use an IANA timezone name (e.g. Europe/Prague, America/New_York, UTC).\n\n");
|
|
90
|
+
const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
91
|
+
const defaultTimezone = this.#default("timezone", detectedTz || "UTC");
|
|
92
|
+
const timezone = await this.#askValidated("timezone", `Timezone [${defaultTimezone}]: `, defaultTimezone);
|
|
93
|
+
|
|
87
94
|
// OpenAI API key
|
|
88
95
|
this.#io.write("\nMacroclaw uses OpenAI's Whisper API to transcribe voice messages.\n");
|
|
89
96
|
this.#io.write("Without this key, voice messages will be ignored.\n\n");
|
|
@@ -99,6 +106,7 @@ export class SetupWizard {
|
|
|
99
106
|
chatId,
|
|
100
107
|
model,
|
|
101
108
|
workspace,
|
|
109
|
+
timezone,
|
|
102
110
|
openaiApiKey,
|
|
103
111
|
...(logLevel && { logLevel }),
|
|
104
112
|
});
|
|
@@ -157,7 +165,7 @@ export class SetupWizard {
|
|
|
157
165
|
let value = await this.#io.ask(prompt) || fallback;
|
|
158
166
|
while (true) {
|
|
159
167
|
const result = schema.safeParse(value);
|
|
160
|
-
if (result.success) return
|
|
168
|
+
if (result.success) return result.data as string;
|
|
161
169
|
const issue = result.error?.issues?.[0];
|
|
162
170
|
this.#io.write(`Invalid value: ${issue?.message ?? "validation failed"}. Please try again.\n`);
|
|
163
171
|
value = await this.#io.ask(prompt) || fallback;
|
|
@@ -19,7 +19,7 @@ Schedule a new event by adding it to `data/schedule.json`.
|
|
|
19
19
|
1. Run `date` to get the current date and time
|
|
20
20
|
2. Read `data/schedule.json` (create with `{"jobs": []}` if missing)
|
|
21
21
|
3. Determine job type:
|
|
22
|
-
- **Recurring** → convert to a cron expression
|
|
22
|
+
- **Recurring** → convert to a cron expression in local time. See reference below.
|
|
23
23
|
- **One-time** → compute an ISO 8601 timestamp with the user's timezone offset for `fireAt`
|
|
24
24
|
4. **Be proactive about timing**: if the user says "next week" or "tomorrow" without a specific time, pick the best time based on what you know (their routine, calendar, context)
|
|
25
25
|
5. Append the new job to the `jobs` array
|
|
@@ -30,14 +30,16 @@ Schedule a new event by adding it to `data/schedule.json`.
|
|
|
30
30
|
|
|
31
31
|
The user's timezone is in their profile (CLAUDE.md/USER.md).
|
|
32
32
|
|
|
33
|
-
**One-time events** use `fireAt`
|
|
34
|
-
- "in 5 minutes" → compute exact time, e.g. `"fireAt": "2026-03-16T10:05:00
|
|
35
|
-
- "tomorrow at 10am" → `"fireAt": "2026-03-14T10:00:00
|
|
36
|
-
- "next Monday" → `"fireAt": "2026-03-17T09:00:00
|
|
33
|
+
**One-time events** use `fireAt` in local time (no offset needed — interpreted in the configured timezone):
|
|
34
|
+
- "in 5 minutes" → compute exact time, e.g. `"fireAt": "2026-03-16T10:05:00"`
|
|
35
|
+
- "tomorrow at 10am" → `"fireAt": "2026-03-14T10:00:00"`
|
|
36
|
+
- "next Monday" → `"fireAt": "2026-03-17T09:00:00"` (pick a sensible time)
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
Only add a timezone offset when the event targets a different timezone than the configured one (e.g. the user is traveling or scheduling for another location).
|
|
39
|
+
|
|
40
|
+
**Recurring events** use `cron` in local time:
|
|
41
|
+
- "every morning" → `"cron": "0 7 * * *"`
|
|
42
|
+
- "every weekday at 9" → `"cron": "0 9 * * 1-5"`
|
|
41
43
|
- "every 30 minutes" → `"cron": "*/30 * * * *"`
|
|
42
44
|
|
|
43
45
|
## schedule.json format
|
|
@@ -60,7 +62,7 @@ Two job types, discriminated by field:
|
|
|
60
62
|
},
|
|
61
63
|
{
|
|
62
64
|
"name": "dentist-reminder",
|
|
63
|
-
"fireAt": "2026-03-15T08:00:00
|
|
65
|
+
"fireAt": "2026-03-15T08:00:00",
|
|
64
66
|
"prompt": "Reminder: call the dentist to reschedule your appointment"
|
|
65
67
|
}
|
|
66
68
|
]
|
|
@@ -72,14 +74,14 @@ Two job types, discriminated by field:
|
|
|
72
74
|
| Field | Required | Description |
|
|
73
75
|
|-------|----------|-------------|
|
|
74
76
|
| `name` | yes | Short kebab-case identifier (e.g. `dentist-reminder`). Appears in the `[Context: cron/<name>]` prefix when fired. |
|
|
75
|
-
| `cron` | for recurring | Standard cron expression (
|
|
76
|
-
| `fireAt` | for one-time | ISO 8601 timestamp
|
|
77
|
+
| `cron` | for recurring | Standard cron expression (local time). See reference below. |
|
|
78
|
+
| `fireAt` | for one-time | ISO 8601 timestamp (e.g. `2026-03-15T08:00:00`). Can include a timezone offset (e.g. `2026-03-15T08:00:00+01:00`); without one, the time is interpreted in the configured timezone. |
|
|
77
79
|
| `prompt` | yes | The message sent to the agent when the event fires. Write it as a natural instruction. |
|
|
78
80
|
| `model` | no | Override the model. Use `haiku` for cheap checks, `opus` for complex reasoning. Omit for default. |
|
|
79
81
|
|
|
80
82
|
Each job must have exactly one of `cron` or `fireAt` (not both).
|
|
81
83
|
|
|
82
|
-
## Cron expression reference (
|
|
84
|
+
## Cron expression reference (local time)
|
|
83
85
|
|
|
84
86
|
```
|
|
85
87
|
┌───────── minute (0-59)
|
|
@@ -92,11 +94,11 @@ Each job must have exactly one of `cron` or `fireAt` (not both).
|
|
|
92
94
|
```
|
|
93
95
|
|
|
94
96
|
Common patterns:
|
|
95
|
-
- `0 9 * * *` — daily at 9:00
|
|
96
|
-
- `0 7 * * 1-5` — weekdays at 7:00
|
|
97
|
+
- `0 9 * * *` — daily at 9:00
|
|
98
|
+
- `0 7 * * 1-5` — weekdays at 7:00
|
|
97
99
|
- `*/30 * * * *` — every 30 minutes
|
|
98
100
|
- `0 */2 * * *` — every 2 hours
|
|
99
|
-
- `0 9,18 * * *` — at 9:00 and 18:00
|
|
101
|
+
- `0 9,18 * * *` — at 9:00 and 18:00
|
|
100
102
|
|
|
101
103
|
## Notes
|
|
102
104
|
|