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/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
- openaiApiKey: z.string().optional(),
14
- logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
15
- pinoramaUrl: z.string().optional(),
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
- (merged as Record<string, unknown>)[key] = value;
100
+ merged[key] = value;
98
101
  overrides.add(key);
99
102
  }
100
103
  }
101
104
 
102
- return { settings: merged, overrides };
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", // bot token
111
- "12345678", // chat ID
112
- "opus", // model
113
- "/my/ws", // workspace
114
- "sk-test", // openai key
115
- "", // no service install
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 value;
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 (UTC). See reference below.
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` with the user's timezone offset:
34
- - "in 5 minutes" → compute exact time, e.g. `"fireAt": "2026-03-16T10:05:00+01:00"`
35
- - "tomorrow at 10am" → `"fireAt": "2026-03-14T10:00:00+01:00"`
36
- - "next Monday" → `"fireAt": "2026-03-17T09:00:00+01:00"` (pick a sensible time)
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
- **Recurring events** use `cron` in UTC:
39
- - "every morning" → `"cron": "0 7 * * *"` (adjust for timezone)
40
- - "every weekday at 9" → `"cron": "0 7 * * 1-5"` (UTC)
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+01: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 (UTC). See reference below. |
76
- | `fireAt` | for one-time | ISO 8601 timestamp, preferably with timezone offset (e.g. `2026-03-15T08:00:00+01:00`). Any format parseable by JavaScript `Date` works. |
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 (UTC)
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 UTC
96
- - `0 7 * * 1-5` — weekdays at 7:00 UTC
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 UTC
101
+ - `0 9,18 * * *` — at 9:00 and 18:00
100
102
 
101
103
  ## Notes
102
104