macroclaw 0.17.0 → 0.18.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/README.md +1 -1
- package/package.json +1 -1
- package/src/cron.test.ts +104 -34
- package/src/cron.ts +16 -7
- package/src/prompts.ts +1 -1
- package/src/setup.ts +1 -1
- package/workspace-template/.claude/skills/schedule/SKILL.md +102 -0
- package/workspace-template/CLAUDE.md +4 -4
- package/workspace-template/.claude/skills/add-cronjob/SKILL.md +0 -77
- /package/workspace-template/{.macroclaw/cron.json → data/schedule.json} +0 -0
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ Macroclaw follows a **thin platform, rich workspace** design:
|
|
|
118
118
|
**Workspace** — the intelligence layer, initialized from [`workspace-template/`](workspace-template/):
|
|
119
119
|
- [`CLAUDE.md`](workspace-template/CLAUDE.md) — agent behavior, conventions, response style
|
|
120
120
|
- [`.claude/skills/`](workspace-template/.claude/skills/) — teachable capabilities
|
|
121
|
-
- [
|
|
121
|
+
- [`data/schedule.json`](workspace-template/data/schedule.json) — scheduled event definitions
|
|
122
122
|
- [`MEMORY.md`](workspace-template/MEMORY.md) — persistent memory
|
|
123
123
|
|
|
124
124
|
### Where does a new feature belong?
|
package/package.json
CHANGED
package/src/cron.test.ts
CHANGED
|
@@ -4,16 +4,16 @@ import { join } from "node:path";
|
|
|
4
4
|
import { CronScheduler } from "./cron";
|
|
5
5
|
|
|
6
6
|
const TEST_DIR = join(import.meta.dir, "..", ".test-workspace-cron");
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
const SCHEDULE_DIR = join(TEST_DIR, "data");
|
|
8
|
+
const SCHEDULE_FILE = join(SCHEDULE_DIR, "schedule.json");
|
|
9
9
|
|
|
10
|
-
function
|
|
11
|
-
mkdirSync(
|
|
12
|
-
writeFileSync(
|
|
10
|
+
function writeScheduleConfig(config: any) {
|
|
11
|
+
mkdirSync(SCHEDULE_DIR, { recursive: true });
|
|
12
|
+
writeFileSync(SCHEDULE_FILE, JSON.stringify(config));
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function
|
|
16
|
-
return JSON.parse(readFileSync(
|
|
15
|
+
function readScheduleConfig() {
|
|
16
|
+
return JSON.parse(readFileSync(SCHEDULE_FILE, "utf-8"));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function makeOnJob() {
|
|
@@ -33,8 +33,14 @@ function nonMatchingCron(): string {
|
|
|
33
33
|
return `${otherMinute} ${(now.getHours() + 12) % 24} * * *`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Build a cron expression that matched N minutes ago
|
|
37
|
+
function minutesAgoCron(minutesAgo: number): string {
|
|
38
|
+
const past = new Date(Date.now() - minutesAgo * 60_000);
|
|
39
|
+
return `${past.getMinutes()} ${past.getHours()} ${past.getDate()} ${past.getMonth() + 1} *`;
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
beforeEach(() => {
|
|
37
|
-
mkdirSync(
|
|
43
|
+
mkdirSync(SCHEDULE_DIR, { recursive: true });
|
|
38
44
|
});
|
|
39
45
|
|
|
40
46
|
afterEach(() => {
|
|
@@ -43,7 +49,7 @@ afterEach(() => {
|
|
|
43
49
|
|
|
44
50
|
describe("CronScheduler", () => {
|
|
45
51
|
it("calls onJob for matching cron job", () => {
|
|
46
|
-
|
|
52
|
+
writeScheduleConfig({
|
|
47
53
|
jobs: [{ name: "test-job", cron: currentMinuteCron(), prompt: "do something" }],
|
|
48
54
|
});
|
|
49
55
|
|
|
@@ -56,7 +62,7 @@ describe("CronScheduler", () => {
|
|
|
56
62
|
});
|
|
57
63
|
|
|
58
64
|
it("does not call onJob for non-matching jobs", () => {
|
|
59
|
-
|
|
65
|
+
writeScheduleConfig({
|
|
60
66
|
jobs: [{ name: "later", cron: nonMatchingCron(), prompt: "not now" }],
|
|
61
67
|
});
|
|
62
68
|
|
|
@@ -68,8 +74,8 @@ describe("CronScheduler", () => {
|
|
|
68
74
|
expect(onJob).not.toHaveBeenCalled();
|
|
69
75
|
});
|
|
70
76
|
|
|
71
|
-
it("silently skips when
|
|
72
|
-
rmSync(
|
|
77
|
+
it("silently skips when schedule.json does not exist", () => {
|
|
78
|
+
rmSync(SCHEDULE_FILE, { force: true });
|
|
73
79
|
|
|
74
80
|
const onJob = makeOnJob();
|
|
75
81
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
@@ -80,7 +86,7 @@ describe("CronScheduler", () => {
|
|
|
80
86
|
});
|
|
81
87
|
|
|
82
88
|
it("does not call onJob on malformed JSON", () => {
|
|
83
|
-
writeFileSync(
|
|
89
|
+
writeFileSync(SCHEDULE_FILE, "not json{{{");
|
|
84
90
|
|
|
85
91
|
const onJob = makeOnJob();
|
|
86
92
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
@@ -91,7 +97,7 @@ describe("CronScheduler", () => {
|
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
it("does not call onJob when jobs is not an array", () => {
|
|
94
|
-
|
|
100
|
+
writeScheduleConfig({ jobs: "not-array" });
|
|
95
101
|
|
|
96
102
|
const onJob = makeOnJob();
|
|
97
103
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
@@ -102,7 +108,7 @@ describe("CronScheduler", () => {
|
|
|
102
108
|
});
|
|
103
109
|
|
|
104
110
|
it("skips invalid cron expression and processes valid jobs", () => {
|
|
105
|
-
|
|
111
|
+
writeScheduleConfig({
|
|
106
112
|
jobs: [
|
|
107
113
|
{ name: "bad", cron: "invalid cron", prompt: "bad" },
|
|
108
114
|
{ name: "good", cron: currentMinuteCron(), prompt: "good" },
|
|
@@ -119,7 +125,7 @@ describe("CronScheduler", () => {
|
|
|
119
125
|
});
|
|
120
126
|
|
|
121
127
|
it("stop clears the interval", () => {
|
|
122
|
-
|
|
128
|
+
writeScheduleConfig({ jobs: [] });
|
|
123
129
|
|
|
124
130
|
const onJob = makeOnJob();
|
|
125
131
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
@@ -128,7 +134,7 @@ describe("CronScheduler", () => {
|
|
|
128
134
|
});
|
|
129
135
|
|
|
130
136
|
it("only evaluates once per minute", () => {
|
|
131
|
-
|
|
137
|
+
writeScheduleConfig({
|
|
132
138
|
jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "once" }],
|
|
133
139
|
});
|
|
134
140
|
|
|
@@ -149,7 +155,7 @@ describe("CronScheduler", () => {
|
|
|
149
155
|
});
|
|
150
156
|
|
|
151
157
|
it("handles multiple matching jobs", () => {
|
|
152
|
-
|
|
158
|
+
writeScheduleConfig({
|
|
153
159
|
jobs: [
|
|
154
160
|
{ name: "first", cron: currentMinuteCron(), prompt: "first" },
|
|
155
161
|
{ name: "second", cron: currentMinuteCron(), prompt: "second" },
|
|
@@ -167,7 +173,7 @@ describe("CronScheduler", () => {
|
|
|
167
173
|
});
|
|
168
174
|
|
|
169
175
|
it("passes model override to onJob", () => {
|
|
170
|
-
|
|
176
|
+
writeScheduleConfig({
|
|
171
177
|
jobs: [{ name: "smart", cron: currentMinuteCron(), prompt: "think hard", model: "opus" }],
|
|
172
178
|
});
|
|
173
179
|
|
|
@@ -180,7 +186,7 @@ describe("CronScheduler", () => {
|
|
|
180
186
|
});
|
|
181
187
|
|
|
182
188
|
it("removes non-recurring job after it fires", () => {
|
|
183
|
-
|
|
189
|
+
writeScheduleConfig({
|
|
184
190
|
jobs: [
|
|
185
191
|
{ name: "once", cron: currentMinuteCron(), prompt: "one-time", recurring: false },
|
|
186
192
|
{ name: "always", cron: currentMinuteCron(), prompt: "forever" },
|
|
@@ -194,13 +200,13 @@ describe("CronScheduler", () => {
|
|
|
194
200
|
|
|
195
201
|
expect(onJob).toHaveBeenCalledTimes(2);
|
|
196
202
|
|
|
197
|
-
const updated =
|
|
203
|
+
const updated = readScheduleConfig();
|
|
198
204
|
expect(updated.jobs).toHaveLength(1);
|
|
199
205
|
expect(updated.jobs[0].name).toBe("always");
|
|
200
206
|
});
|
|
201
207
|
|
|
202
208
|
it("keeps recurring jobs (default behavior)", () => {
|
|
203
|
-
|
|
209
|
+
writeScheduleConfig({
|
|
204
210
|
jobs: [{ name: "keeper", cron: currentMinuteCron(), prompt: "stay" }],
|
|
205
211
|
});
|
|
206
212
|
|
|
@@ -209,13 +215,13 @@ describe("CronScheduler", () => {
|
|
|
209
215
|
cron.start();
|
|
210
216
|
cron.stop();
|
|
211
217
|
|
|
212
|
-
const updated =
|
|
218
|
+
const updated = readScheduleConfig();
|
|
213
219
|
expect(updated.jobs).toHaveLength(1);
|
|
214
220
|
expect(updated.jobs[0].name).toBe("keeper");
|
|
215
221
|
});
|
|
216
222
|
|
|
217
223
|
it("keeps jobs with recurring: true", () => {
|
|
218
|
-
|
|
224
|
+
writeScheduleConfig({
|
|
219
225
|
jobs: [{ name: "explicit", cron: currentMinuteCron(), prompt: "stay", recurring: true }],
|
|
220
226
|
});
|
|
221
227
|
|
|
@@ -224,32 +230,30 @@ describe("CronScheduler", () => {
|
|
|
224
230
|
cron.start();
|
|
225
231
|
cron.stop();
|
|
226
232
|
|
|
227
|
-
const updated =
|
|
233
|
+
const updated = readScheduleConfig();
|
|
228
234
|
expect(updated.jobs).toHaveLength(1);
|
|
229
235
|
});
|
|
230
236
|
|
|
231
|
-
it("still fires job when write-back of
|
|
232
|
-
|
|
233
|
-
writeCronConfig({
|
|
237
|
+
it("still fires job when write-back of schedule.json fails", () => {
|
|
238
|
+
writeScheduleConfig({
|
|
234
239
|
jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "fire", recurring: false }],
|
|
235
240
|
});
|
|
236
241
|
|
|
237
|
-
// Make cron.json read-only so writeFileSync fails
|
|
238
242
|
const { chmodSync } = require("node:fs");
|
|
239
|
-
chmodSync(
|
|
243
|
+
chmodSync(SCHEDULE_FILE, 0o444);
|
|
240
244
|
|
|
241
245
|
const onJob = makeOnJob();
|
|
242
246
|
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
243
247
|
cron.start();
|
|
244
248
|
cron.stop();
|
|
245
249
|
|
|
246
|
-
chmodSync(
|
|
250
|
+
chmodSync(SCHEDULE_FILE, 0o644);
|
|
247
251
|
|
|
248
252
|
expect(onJob).toHaveBeenCalledTimes(1);
|
|
249
253
|
});
|
|
250
254
|
|
|
251
255
|
it("does not write file when no non-recurring jobs fired", () => {
|
|
252
|
-
|
|
256
|
+
writeScheduleConfig({
|
|
253
257
|
jobs: [{ name: "recurring", cron: nonMatchingCron(), prompt: "nope", recurring: false }],
|
|
254
258
|
});
|
|
255
259
|
|
|
@@ -258,8 +262,74 @@ describe("CronScheduler", () => {
|
|
|
258
262
|
cron.start();
|
|
259
263
|
cron.stop();
|
|
260
264
|
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
const updated = readScheduleConfig();
|
|
266
|
+
expect(updated.jobs).toHaveLength(1);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("missed non-recurring events", () => {
|
|
271
|
+
it("fires missed non-recurring job with missed prefix", () => {
|
|
272
|
+
writeScheduleConfig({
|
|
273
|
+
jobs: [{ name: "reminder", cron: minutesAgoCron(3), prompt: "buy milk", recurring: false }],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const onJob = makeOnJob();
|
|
277
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
278
|
+
cron.start();
|
|
279
|
+
cron.stop();
|
|
280
|
+
|
|
281
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
282
|
+
const call = onJob.mock.calls[0];
|
|
283
|
+
expect(call[0]).toBe("reminder");
|
|
284
|
+
expect(call[1]).toContain("[missed event, should have fired");
|
|
285
|
+
expect(call[1]).toContain("min ago at");
|
|
286
|
+
expect(call[1]).toContain("buy milk");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("removes missed non-recurring job after firing", () => {
|
|
290
|
+
writeScheduleConfig({
|
|
291
|
+
jobs: [
|
|
292
|
+
{ name: "missed", cron: minutesAgoCron(3), prompt: "do it", recurring: false },
|
|
293
|
+
{ name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const onJob = makeOnJob();
|
|
298
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
299
|
+
cron.start();
|
|
300
|
+
cron.stop();
|
|
301
|
+
|
|
302
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
303
|
+
const updated = readScheduleConfig();
|
|
304
|
+
expect(updated.jobs).toHaveLength(1);
|
|
305
|
+
expect(updated.jobs[0].name).toBe("keeper");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("does not fire missed recurring jobs", () => {
|
|
309
|
+
writeScheduleConfig({
|
|
310
|
+
jobs: [{ name: "recurring", cron: minutesAgoCron(3), prompt: "repeat" }],
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const onJob = makeOnJob();
|
|
314
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
315
|
+
cron.start();
|
|
316
|
+
cron.stop();
|
|
317
|
+
|
|
318
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("does not fire non-recurring jobs older than threshold", () => {
|
|
322
|
+
writeScheduleConfig({
|
|
323
|
+
jobs: [{ name: "old", cron: minutesAgoCron(10), prompt: "too late", recurring: false }],
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const onJob = makeOnJob();
|
|
327
|
+
const cron = new CronScheduler(TEST_DIR, { onJob });
|
|
328
|
+
cron.start();
|
|
329
|
+
cron.stop();
|
|
330
|
+
|
|
331
|
+
expect(onJob).not.toHaveBeenCalled();
|
|
332
|
+
const updated = readScheduleConfig();
|
|
263
333
|
expect(updated.jobs).toHaveLength(1);
|
|
264
334
|
});
|
|
265
335
|
});
|
package/src/cron.ts
CHANGED
|
@@ -25,15 +25,16 @@ export interface CronSchedulerConfig {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const TICK_INTERVAL = 10_000; // 10 seconds
|
|
28
|
+
const MISSED_THRESHOLD = 300_000; // 5 minutes
|
|
28
29
|
|
|
29
30
|
export class CronScheduler {
|
|
30
31
|
#lastMinute = -1;
|
|
31
|
-
#
|
|
32
|
+
#schedulePath: string;
|
|
32
33
|
#config: CronSchedulerConfig;
|
|
33
34
|
#timer: Timer | null = null;
|
|
34
35
|
|
|
35
36
|
constructor(workspace: string, config: CronSchedulerConfig) {
|
|
36
|
-
this.#
|
|
37
|
+
this.#schedulePath = join(workspace, "data", "schedule.json");
|
|
37
38
|
this.#config = config;
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -59,16 +60,16 @@ export class CronScheduler {
|
|
|
59
60
|
|
|
60
61
|
let config: CronConfig;
|
|
61
62
|
try {
|
|
62
|
-
const raw = readFileSync(this.#
|
|
63
|
+
const raw = readFileSync(this.#schedulePath, "utf-8");
|
|
63
64
|
const parsed = cronConfigSchema.safeParse(JSON.parse(raw));
|
|
64
65
|
if (!parsed.success) {
|
|
65
|
-
log.warn("
|
|
66
|
+
log.warn("schedule.json: 'jobs' is not an array");
|
|
66
67
|
return;
|
|
67
68
|
}
|
|
68
69
|
config = parsed.data;
|
|
69
70
|
} catch (err) {
|
|
70
71
|
if (err instanceof Error && "code" in err && err.code === "ENOENT") return;
|
|
71
|
-
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to read
|
|
72
|
+
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to read schedule.json");
|
|
72
73
|
return;
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -87,6 +88,14 @@ export class CronScheduler {
|
|
|
87
88
|
if (job.recurring === false) {
|
|
88
89
|
firedNonRecurring.push(i);
|
|
89
90
|
}
|
|
91
|
+
} else if (job.recurring === false && diff < MISSED_THRESHOLD) {
|
|
92
|
+
// Non-recurring job missed (service was down) — fire with missed prefix
|
|
93
|
+
const missedMinutes = Math.round(diff / 60_000);
|
|
94
|
+
const firedAt = prev.toISOString();
|
|
95
|
+
const missedPrompt = `[missed event, should have fired ${missedMinutes} min ago at ${firedAt}] ${job.prompt}`;
|
|
96
|
+
log.info({ name: job.name, missedMinutes, firedAt }, "Firing missed non-recurring job");
|
|
97
|
+
this.#config.onJob(job.name, missedPrompt, job.model);
|
|
98
|
+
firedNonRecurring.push(i);
|
|
90
99
|
}
|
|
91
100
|
} catch (err) {
|
|
92
101
|
log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
|
|
@@ -99,9 +108,9 @@ export class CronScheduler {
|
|
|
99
108
|
config.jobs.splice(firedNonRecurring[i], 1);
|
|
100
109
|
}
|
|
101
110
|
try {
|
|
102
|
-
writeFileSync(this.#
|
|
111
|
+
writeFileSync(this.#schedulePath, `${JSON.stringify(config, null, 2)}\n`);
|
|
103
112
|
} catch (err) {
|
|
104
|
-
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to write
|
|
113
|
+
log.warn({ err: err instanceof Error ? err.message : err }, "Failed to write schedule.json");
|
|
105
114
|
}
|
|
106
115
|
}
|
|
107
116
|
}
|
package/src/prompts.ts
CHANGED
|
@@ -42,7 +42,7 @@ Send files via files array (absolute paths). Images (.png/.jpg/.jpeg/.gif/.webp)
|
|
|
42
42
|
Timeouts: user=${fmtMin(MAIN_TIMEOUT)}, cron=${fmtMin(CRON_TIMEOUT)}, background=${fmtMin(BG_TIMEOUT)}. \
|
|
43
43
|
On timeout, task continues in background automatically. Spawn background agents proactively for long tasks.
|
|
44
44
|
|
|
45
|
-
Cron: jobs in
|
|
45
|
+
Cron: jobs in data/schedule.json (hot-reloaded). Use "silent" when check finds nothing new, "send" when noteworthy.
|
|
46
46
|
|
|
47
47
|
MessageButtons: include a buttons field (flat array of label strings) to attach inline buttons below your message. \
|
|
48
48
|
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.`;
|
package/src/setup.ts
CHANGED
|
@@ -80,7 +80,7 @@ export class SetupWizard {
|
|
|
80
80
|
|
|
81
81
|
// Workspace
|
|
82
82
|
this.#io.write("\nThe workspace directory where Claude Code runs — instructions, skills,\n");
|
|
83
|
-
this.#io.write("memory, and
|
|
83
|
+
this.#io.write("memory, and scheduled events all live here.\n\n");
|
|
84
84
|
const defaultWorkspace = this.#default("workspace", "~/.macroclaw-workspace");
|
|
85
85
|
const workspace = await this.#askValidated("workspace", `Workspace [${defaultWorkspace}]: `, defaultWorkspace);
|
|
86
86
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: schedule
|
|
3
|
+
description: >
|
|
4
|
+
Schedule events, reminders, and recurring tasks. Use when the user wants to:
|
|
5
|
+
set a reminder, schedule something for later, create a recurring task,
|
|
6
|
+
set up a periodic check, automate a prompt on a schedule, or plan a
|
|
7
|
+
one-time or repeating event at a specific time.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Schedule a new event by adding it to `data/schedule.json`.
|
|
11
|
+
|
|
12
|
+
## When to use this
|
|
13
|
+
|
|
14
|
+
- **Reminders**: "remind me to call the dentist tomorrow at 10"
|
|
15
|
+
- **One-time events**: "send the weekly report this Friday at 5pm"
|
|
16
|
+
- **Recurring tasks**: "check my email every 30 minutes"
|
|
17
|
+
- **Periodic prompts**: "give me a morning summary every weekday at 9"
|
|
18
|
+
- **Scheduled checks**: "monitor the deploy status every 5 minutes for the next hour"
|
|
19
|
+
- **Future actions**: "next Monday, ask me how the presentation went"
|
|
20
|
+
|
|
21
|
+
## How to schedule
|
|
22
|
+
|
|
23
|
+
1. Read `data/schedule.json` (create with `{"jobs": []}` if missing)
|
|
24
|
+
2. Convert the user's request to a cron expression (see reference below)
|
|
25
|
+
3. **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)
|
|
26
|
+
4. Append the new job to the `jobs` array
|
|
27
|
+
5. Write the updated file
|
|
28
|
+
6. Confirm: what was scheduled, when it will fire, and offer to adjust
|
|
29
|
+
|
|
30
|
+
## Natural language → cron
|
|
31
|
+
|
|
32
|
+
Convert user intent to cron expressions. The user's timezone is in their profile (CLAUDE.md/USER.md) — convert to UTC for cron.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
- "in 5 minutes" → compute the exact minute/hour, use a one-shot with `recurring: false`
|
|
36
|
+
- "tomorrow at 10am" → `0 8 14 3 *` (if user is UTC+2, March 13), `recurring: false`
|
|
37
|
+
- "every morning" → `0 7 * * *` (adjust for timezone)
|
|
38
|
+
- "every weekday at 9" → `0 7 * * 1-5` (UTC)
|
|
39
|
+
- "next Monday" → specific date cron, `recurring: false`
|
|
40
|
+
- "every 30 minutes" → `*/30 * * * *`
|
|
41
|
+
|
|
42
|
+
## schedule.json format
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"jobs": [
|
|
47
|
+
{
|
|
48
|
+
"name": "morning-summary",
|
|
49
|
+
"cron": "0 7 * * 1-5",
|
|
50
|
+
"prompt": "Give me a morning summary of my tasks"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "email-check",
|
|
54
|
+
"cron": "*/30 * * * *",
|
|
55
|
+
"prompt": "Check if any important emails arrived",
|
|
56
|
+
"model": "haiku"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "dentist-reminder",
|
|
60
|
+
"cron": "0 8 15 3 *",
|
|
61
|
+
"prompt": "Reminder: call the dentist to reschedule your appointment",
|
|
62
|
+
"recurring": false
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Job fields
|
|
69
|
+
|
|
70
|
+
| Field | Required | Description |
|
|
71
|
+
|-------|----------|-------------|
|
|
72
|
+
| `name` | yes | Short kebab-case identifier (e.g. `dentist-reminder`). Appears in the `[Context: cron/<name>]` prefix when fired. |
|
|
73
|
+
| `cron` | yes | Standard cron expression (UTC). See reference below. |
|
|
74
|
+
| `prompt` | yes | The message sent to the agent when the event fires. Write it as a natural instruction. |
|
|
75
|
+
| `recurring` | no | Defaults to `true`. Set to `false` for one-time events — they're automatically removed after firing. |
|
|
76
|
+
| `model` | no | Override the model. Use `haiku` for cheap checks, `opus` for complex reasoning. Omit for default. |
|
|
77
|
+
|
|
78
|
+
## Cron expression reference (UTC)
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
┌───────── minute (0-59)
|
|
82
|
+
│ ┌─────── hour (0-23)
|
|
83
|
+
│ │ ┌───── day of month (1-31)
|
|
84
|
+
│ │ │ ┌─── month (1-12)
|
|
85
|
+
│ │ │ │ ┌─ day of week (0-7, 0 and 7 = Sunday)
|
|
86
|
+
│ │ │ │ │
|
|
87
|
+
* * * * *
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Common patterns:
|
|
91
|
+
- `0 9 * * *` — daily at 9:00 UTC
|
|
92
|
+
- `0 7 * * 1-5` — weekdays at 7:00 UTC
|
|
93
|
+
- `*/30 * * * *` — every 30 minutes
|
|
94
|
+
- `0 */2 * * *` — every 2 hours
|
|
95
|
+
- `0 9,18 * * *` — at 9:00 and 18:00 UTC
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
- Changes are hot-reloaded — no restart needed
|
|
100
|
+
- File location: `<workspace>/data/schedule.json`
|
|
101
|
+
- One-shot events (`recurring: false`) are cleaned up automatically after firing
|
|
102
|
+
- Missed one-shot events (e.g. service was down) are fired with a `[missed event]` prefix when the service restarts
|
|
@@ -57,14 +57,14 @@ Don't ask permission. Just do it.
|
|
|
57
57
|
- bullet points (plain text bullet character)
|
|
58
58
|
- No markdown syntax. No # headings. No [links](url). No *stars*.
|
|
59
59
|
|
|
60
|
-
##
|
|
60
|
+
## Scheduled Events
|
|
61
61
|
|
|
62
|
-
Messages prefixed with `[
|
|
62
|
+
Scheduled events are created by the `schedule` skill and stored in `data/schedule.json`. Messages prefixed with `[Context: cron/<name>]` are automated scheduled events. The agent decides whether to respond:
|
|
63
63
|
|
|
64
64
|
- **action: "send"** — the response goes to Telegram
|
|
65
65
|
- **action: "silent"** — the response is logged but not sent
|
|
66
66
|
|
|
67
|
-
Use `silent` when a
|
|
67
|
+
Use `silent` when a scheduled check finds nothing new. Only send when there's something worth reading.
|
|
68
68
|
|
|
69
69
|
## Skills
|
|
70
70
|
|
|
@@ -86,7 +86,7 @@ When creating new skills, always put them in `.claude/skills/` within this works
|
|
|
86
86
|
Structure:
|
|
87
87
|
- `.claude/skills/` — local agent skills
|
|
88
88
|
- `memory/` — daily logs (YYYY-MM-DD.md)
|
|
89
|
-
-
|
|
89
|
+
- `data/schedule.json` — scheduled events and reminders (hot-reloaded, no restart needed) (use schedule skill to modify)
|
|
90
90
|
|
|
91
91
|
## Safety
|
|
92
92
|
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: add-cronjob
|
|
3
|
-
description: Add a new scheduled cron job. Use when the user wants to schedule a recurring prompt, add a periodic task, or set up automated messages.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Add a new cron job to `.macroclaw/cron.json` in this workspace.
|
|
7
|
-
|
|
8
|
-
## Steps
|
|
9
|
-
|
|
10
|
-
1. Read the current `.macroclaw/cron.json` file (create it if missing with `{"jobs": []}`)
|
|
11
|
-
2. Ask the user what prompt to run and when (if not already specified)
|
|
12
|
-
3. Build a standard cron expression for the schedule
|
|
13
|
-
4. Append the new job to the `jobs` array
|
|
14
|
-
5. Write the updated file
|
|
15
|
-
6. Confirm what was added and when it will next run
|
|
16
|
-
|
|
17
|
-
## cron.json format
|
|
18
|
-
|
|
19
|
-
```json
|
|
20
|
-
{
|
|
21
|
-
"jobs": [
|
|
22
|
-
{
|
|
23
|
-
"name": "morning-summary",
|
|
24
|
-
"cron": "0 9 * * *",
|
|
25
|
-
"prompt": "Give me a morning summary of my tasks"
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"name": "email-check",
|
|
29
|
-
"cron": "*/30 * * * *",
|
|
30
|
-
"prompt": "Check if any important emails arrived",
|
|
31
|
-
"model": "haiku"
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
"name": "weekly-report",
|
|
35
|
-
"cron": "0 17 * * 5",
|
|
36
|
-
"prompt": "Generate a weekly report of completed tasks",
|
|
37
|
-
"recurring": false
|
|
38
|
-
}
|
|
39
|
-
]
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Job fields
|
|
44
|
-
|
|
45
|
-
| Field | Required | Description |
|
|
46
|
-
|-------|----------|-------------|
|
|
47
|
-
| `name` | yes | Short identifier for the job. Appears in the `[Tool: cron/<name>]` prefix so the agent knows which job triggered the prompt. Use kebab-case (e.g. `morning-summary`). |
|
|
48
|
-
| `cron` | yes | Standard cron expression defining when the job runs. See reference below. |
|
|
49
|
-
| `prompt` | yes | The message sent to Claude when the job fires. Write it as if you're typing a message in Telegram — the agent will receive and act on it. |
|
|
50
|
-
| `recurring` | no | Whether the job repeats. Defaults to `true`. Set to `false` for one-shot jobs that should fire once and be automatically removed from cron.json. Good for reminders ("remind me to call the dentist tomorrow at 10") or one-time scheduled events ("send the weekly report this Friday"). |
|
|
51
|
-
| `model` | no | Override the model for this specific job. Omit to use the default model (set via `MODEL` in `.env`), which is best for normal interactive tasks. Use `haiku` for cheap/fast routine checks (email polling, status pings). Use `opus` only when the task genuinely needs deeper reasoning. |
|
|
52
|
-
|
|
53
|
-
## Cron expression reference
|
|
54
|
-
|
|
55
|
-
```
|
|
56
|
-
┌───────── minute (0-59)
|
|
57
|
-
│ ┌─────── hour (0-23)
|
|
58
|
-
│ │ ┌───── day of month (1-31)
|
|
59
|
-
│ │ │ ┌─── month (1-12)
|
|
60
|
-
│ │ │ │ ┌─ day of week (0-7, 0 and 7 = Sunday)
|
|
61
|
-
│ │ │ │ │
|
|
62
|
-
* * * * *
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Common patterns:
|
|
66
|
-
- `0 9 * * *` — daily at 9:00
|
|
67
|
-
- `0 9 * * 1-5` — weekdays at 9:00
|
|
68
|
-
- `*/30 * * * *` — every 30 minutes
|
|
69
|
-
- `0 */2 * * *` — every 2 hours
|
|
70
|
-
- `0 9,18 * * *` — at 9:00 and 18:00
|
|
71
|
-
|
|
72
|
-
## Notes
|
|
73
|
-
|
|
74
|
-
- Changes are hot-reloaded — no restart needed
|
|
75
|
-
- File location: `<workspace>/.macroclaw/cron.json`
|
|
76
|
-
- Prompts are injected into the conversation with a `[Tool: cron/<name>]` prefix
|
|
77
|
-
- One-shot jobs (`recurring: false`) are cleaned up automatically after firing
|
|
File without changes
|