macroclaw 0.0.0-dev

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.
@@ -0,0 +1,55 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { loadSettings, newSessionId, saveSettings } from "./settings";
5
+
6
+ const tmpDir = "/tmp/macroclaw-settings-test";
7
+
8
+ afterEach(() => {
9
+ if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
10
+ });
11
+
12
+ describe("loadSettings", () => {
13
+ it("returns empty object when dir does not exist", () => {
14
+ expect(loadSettings(tmpDir)).toEqual({});
15
+ });
16
+
17
+ it("returns empty object when file does not exist", () => {
18
+ mkdirSync(tmpDir, { recursive: true });
19
+ expect(loadSettings(tmpDir)).toEqual({});
20
+ });
21
+
22
+ it("reads settings from file", () => {
23
+ mkdirSync(tmpDir, { recursive: true });
24
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ sessionId: "abc-123" }));
25
+ expect(loadSettings(tmpDir)).toEqual({ sessionId: "abc-123" });
26
+ });
27
+
28
+ it("returns empty object when file is corrupt", () => {
29
+ mkdirSync(tmpDir, { recursive: true });
30
+ writeFileSync(join(tmpDir, "settings.json"), "not json");
31
+ expect(loadSettings(tmpDir)).toEqual({});
32
+ });
33
+ });
34
+
35
+ describe("saveSettings", () => {
36
+ it("creates directory and writes file", () => {
37
+ saveSettings({ sessionId: "new-id" }, tmpDir);
38
+ const saved = loadSettings(tmpDir);
39
+ expect(saved).toEqual({ sessionId: "new-id" });
40
+ });
41
+
42
+ it("overwrites existing file", () => {
43
+ saveSettings({ sessionId: "first" }, tmpDir);
44
+ saveSettings({ sessionId: "second" }, tmpDir);
45
+ expect(loadSettings(tmpDir)).toEqual({ sessionId: "second" });
46
+ });
47
+ });
48
+
49
+ describe("newSessionId", () => {
50
+ it("returns a valid UUID", () => {
51
+ const id = newSessionId();
52
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
53
+ expect(newSessionId()).not.toBe(id);
54
+ });
55
+ });
@@ -0,0 +1,36 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { z } from "zod/v4";
5
+ import { createLogger } from "./logger";
6
+
7
+ const log = createLogger("settings");
8
+
9
+ const settingsSchema = z.object({
10
+ sessionId: z.string().optional(),
11
+ });
12
+
13
+ export type Settings = z.infer<typeof settingsSchema>;
14
+
15
+ const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
16
+
17
+ export function loadSettings(dir: string = defaultDir): Settings {
18
+ try {
19
+ const path = join(dir, "settings.json");
20
+ if (!existsSync(path)) return {};
21
+ const raw = readFileSync(path, "utf-8");
22
+ return settingsSchema.parse(JSON.parse(raw));
23
+ } catch (err) {
24
+ log.warn({ err }, "Failed to load settings.json");
25
+ return {};
26
+ }
27
+ }
28
+
29
+ export function saveSettings(settings: Settings, dir: string = defaultDir): void {
30
+ mkdirSync(dir, { recursive: true });
31
+ writeFileSync(join(dir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
32
+ }
33
+
34
+ export function newSessionId(): string {
35
+ return randomUUID();
36
+ }
package/src/stt.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import OpenAI from "openai";
4
+ import { createLogger } from "./logger";
5
+
6
+ const log = createLogger("stt");
7
+
8
+ let client: OpenAI | undefined;
9
+
10
+ function getClient(): OpenAI {
11
+ if (!client) client = new OpenAI();
12
+ return client;
13
+ }
14
+
15
+ export function isAvailable(): boolean {
16
+ return !!process.env.OPENAI_API_KEY;
17
+ }
18
+
19
+ export async function transcribe(filePath: string): Promise<string> {
20
+ const buffer = await readFile(filePath);
21
+ const file = new File([buffer], basename(filePath), { type: "audio/ogg" });
22
+
23
+ log.debug({ filePath }, "Transcribing audio");
24
+ const result = await getClient().audio.transcriptions.create({
25
+ model: "whisper-1",
26
+ file,
27
+ });
28
+
29
+ log.debug({ text: result.text }, "Transcription complete");
30
+ return result.text;
31
+ }
@@ -0,0 +1,283 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, rm } from "node:fs/promises";
4
+ import { buildInlineKeyboard, createBot, downloadFile, sendFile, sendResponse } from "./telegram";
5
+
6
+ // Mock bot API
7
+ function mockBot() {
8
+ const calls: { chatId: string; text: string; opts?: any }[] = [];
9
+ return {
10
+ api: {
11
+ sendMessage: mock(async (chatId: string, text: string, opts?: any) => {
12
+ calls.push({ chatId, text, opts });
13
+ }),
14
+ },
15
+ calls,
16
+ } as any;
17
+ }
18
+
19
+ describe("createBot", () => {
20
+ it("returns a Grammy Bot instance", () => {
21
+ // Can't fully test without a real token, but verify it returns an object
22
+ const bot = createBot("test-token");
23
+ expect(bot).toBeDefined();
24
+ expect(bot.api).toBeDefined();
25
+ });
26
+ });
27
+
28
+ describe("sendResponse", () => {
29
+ it("sends short messages in a single call", async () => {
30
+ const bot = mockBot();
31
+ await sendResponse(bot, "123", "Hello");
32
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
33
+ expect(bot.calls[0]).toEqual({ chatId: "123", text: "Hello", opts: { parse_mode: "HTML" } });
34
+ });
35
+
36
+ it("sends messages at exactly 4096 chars in a single call", async () => {
37
+ const bot = mockBot();
38
+ const text = "x".repeat(4096);
39
+ await sendResponse(bot, "123", text);
40
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
41
+ });
42
+
43
+ it("splits long messages at line boundaries", async () => {
44
+ const bot = mockBot();
45
+ // Create text with lines that force a split
46
+ const line = "a".repeat(2000);
47
+ const text = `${line}\n${line}\n${line}`; // 6002 chars total
48
+ await sendResponse(bot, "123", text);
49
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(2);
50
+ // First chunk: line1 + \n + line2 = 4001 chars
51
+ expect(bot.calls[0].text).toBe(`${line}\n${line}`);
52
+ // Second chunk: line3 = 2000 chars
53
+ expect(bot.calls[1].text).toBe(line);
54
+ });
55
+
56
+ it("hard-splits single lines exceeding 4096 chars", async () => {
57
+ const bot = mockBot();
58
+ const longLine = "z".repeat(5000);
59
+ await sendResponse(bot, "123", longLine);
60
+ // Split into 4096 + 904
61
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(2);
62
+ expect(bot.calls[0].text).toBe("z".repeat(4096));
63
+ expect(bot.calls[1].text).toBe("z".repeat(904));
64
+ });
65
+
66
+ it("handles long line preceded by a buffered chunk", async () => {
67
+ const bot = mockBot();
68
+ const shortLine = "short";
69
+ const longLine = "z".repeat(5000);
70
+ const text = `${shortLine}\n${longLine}`;
71
+ await sendResponse(bot, "123", text);
72
+ // 1: "short", 2: first 4096 of longLine, 3: remaining 904
73
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(3);
74
+ expect(bot.calls[0].text).toBe("short");
75
+ expect(bot.calls[1].text).toBe("z".repeat(4096));
76
+ expect(bot.calls[2].text).toBe("z".repeat(904));
77
+ });
78
+
79
+ it("handles line that exactly causes overflow", async () => {
80
+ const bot = mockBot();
81
+ const line1 = "a".repeat(4000);
82
+ const line2 = "b".repeat(200); // 4000 + 1 (newline) + 200 = 4201 > 4096
83
+ const text = `${line1}\n${line2}`;
84
+ await sendResponse(bot, "123", text);
85
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(2);
86
+ expect(bot.calls[0].text).toBe(line1);
87
+ expect(bot.calls[1].text).toBe(line2);
88
+ });
89
+
90
+ it("sends empty trailing chunk correctly", async () => {
91
+ const bot = mockBot();
92
+ await sendResponse(bot, "123", "hello\nworld");
93
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
94
+ expect(bot.calls[0].text).toBe("hello\nworld");
95
+ });
96
+
97
+ it("attaches buttons to a single message", async () => {
98
+ const bot = mockBot();
99
+ const buttons = ["Yes", "No"];
100
+ await sendResponse(bot, "123", "Choose:", buttons);
101
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
102
+ expect(bot.calls[0].opts.reply_markup).toBeDefined();
103
+ });
104
+
105
+ it("attaches buttons to last chunk only when splitting", async () => {
106
+ const bot = mockBot();
107
+ const line = "a".repeat(2000);
108
+ const text = `${line}\n${line}\n${line}`;
109
+ const buttons = ["Ok"];
110
+ await sendResponse(bot, "123", text, buttons);
111
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(2);
112
+ // First chunk: no buttons
113
+ expect(bot.calls[0].opts.reply_markup).toBeUndefined();
114
+ // Last chunk: has buttons
115
+ expect(bot.calls[1].opts.reply_markup).toBeDefined();
116
+ });
117
+
118
+ it("sends without keyboard when buttons is empty", async () => {
119
+ const bot = mockBot();
120
+ await sendResponse(bot, "123", "Hello", []);
121
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
122
+ expect(bot.calls[0].opts.reply_markup).toBeUndefined();
123
+ });
124
+ });
125
+
126
+ describe("buildInlineKeyboard", () => {
127
+ it("builds keyboard with rows and buttons", () => {
128
+ const kb = buildInlineKeyboard(["A", "B", "C"]);
129
+ expect(kb).toBeDefined();
130
+ // InlineKeyboard from grammy — verify it's an object with inline_keyboard
131
+ expect(kb.inline_keyboard).toBeDefined();
132
+ expect(kb.inline_keyboard.length).toBe(3);
133
+ expect(kb.inline_keyboard[0].length).toBe(1);
134
+ const btn = kb.inline_keyboard[0][0] as any;
135
+ expect(btn.text).toBe("A");
136
+ expect(btn.callback_data).toBe("A");
137
+ expect((kb.inline_keyboard[2][0] as any).text).toBe("C");
138
+ });
139
+ });
140
+
141
+ describe("downloadFile", () => {
142
+ it("downloads file to /tmp/macroclaw/inbound/<uuid>/<name>", async () => {
143
+ const fileContent = new Uint8Array([1, 2, 3]);
144
+ const mockFetch = mock(() =>
145
+ Promise.resolve(new Response(fileContent, { status: 200 })),
146
+ );
147
+ const origFetch = globalThis.fetch;
148
+ globalThis.fetch = mockFetch as any;
149
+
150
+ const bot = {
151
+ api: {
152
+ getFile: mock(async () => ({ file_path: "photos/file_42.jpg" })),
153
+ },
154
+ } as any;
155
+
156
+ const path = await downloadFile(bot, "file-id-123", "123:ABC", "photo.jpg");
157
+
158
+ expect(path).toContain("/tmp/macroclaw/inbound/");
159
+ expect(path).toEndWith("/photo.jpg");
160
+ expect(existsSync(path)).toBe(true);
161
+
162
+ const contents = await readFile(path);
163
+ expect(new Uint8Array(contents)).toEqual(fileContent);
164
+
165
+ const fetchUrl = (mockFetch as any).mock.calls[0][0];
166
+ expect(fetchUrl).toBe("https://api.telegram.org/file/bot123:ABC/photos/file_42.jpg");
167
+
168
+ // Cleanup
169
+ await rm(path, { force: true });
170
+ globalThis.fetch = origFetch;
171
+ });
172
+
173
+ it("uses file_path basename when no originalName given", async () => {
174
+ const origFetch = globalThis.fetch;
175
+ globalThis.fetch = mock(() =>
176
+ Promise.resolve(new Response("data", { status: 200 })),
177
+ ) as any;
178
+
179
+ const bot = {
180
+ api: {
181
+ getFile: mock(async () => ({ file_path: "documents/report.pdf" })),
182
+ },
183
+ } as any;
184
+
185
+ const path = await downloadFile(bot, "file-id", "token");
186
+ expect(path).toEndWith("/report.pdf");
187
+
188
+ await rm(path, { force: true });
189
+ globalThis.fetch = origFetch;
190
+ });
191
+
192
+ it("throws when Telegram returns no file_path", async () => {
193
+ const bot = {
194
+ api: { getFile: mock(async () => ({})) },
195
+ } as any;
196
+
197
+ expect(downloadFile(bot, "file-id", "token")).rejects.toThrow("no file_path");
198
+ });
199
+
200
+ it("throws when download fails", async () => {
201
+ const origFetch = globalThis.fetch;
202
+ globalThis.fetch = mock(() =>
203
+ Promise.resolve(new Response("", { status: 404 })),
204
+ ) as any;
205
+
206
+ const bot = {
207
+ api: { getFile: mock(async () => ({ file_path: "photos/x.jpg" })) },
208
+ } as any;
209
+
210
+ expect(downloadFile(bot, "file-id", "token")).rejects.toThrow("Download failed: 404");
211
+ globalThis.fetch = origFetch;
212
+ });
213
+ });
214
+
215
+ describe("sendFile", () => {
216
+ it("sends image extensions as photos", async () => {
217
+ const tmpFile = `/tmp/macroclaw-test-${Date.now()}.png`;
218
+ await Bun.write(tmpFile, "fake png");
219
+
220
+ const bot = {
221
+ api: {
222
+ sendPhoto: mock(async () => {}),
223
+ sendDocument: mock(async () => {}),
224
+ },
225
+ } as any;
226
+
227
+ await sendFile(bot, "123", tmpFile);
228
+ expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1);
229
+ expect(bot.api.sendDocument).not.toHaveBeenCalled();
230
+
231
+ await rm(tmpFile, { force: true });
232
+ });
233
+
234
+ it("sends non-image extensions as documents", async () => {
235
+ const tmpFile = `/tmp/macroclaw-test-${Date.now()}.pdf`;
236
+ await Bun.write(tmpFile, "fake pdf");
237
+
238
+ const bot = {
239
+ api: {
240
+ sendPhoto: mock(async () => {}),
241
+ sendDocument: mock(async () => {}),
242
+ },
243
+ } as any;
244
+
245
+ await sendFile(bot, "123", tmpFile);
246
+ expect(bot.api.sendDocument).toHaveBeenCalledTimes(1);
247
+ expect(bot.api.sendPhoto).not.toHaveBeenCalled();
248
+
249
+ await rm(tmpFile, { force: true });
250
+ });
251
+
252
+ it("skips missing files without sending", async () => {
253
+ const bot = {
254
+ api: {
255
+ sendPhoto: mock(async () => {}),
256
+ sendDocument: mock(async () => {}),
257
+ },
258
+ } as any;
259
+
260
+ await sendFile(bot, "123", "/tmp/nonexistent-file-xyz.txt");
261
+ expect(bot.api.sendPhoto).not.toHaveBeenCalled();
262
+ expect(bot.api.sendDocument).not.toHaveBeenCalled();
263
+ });
264
+
265
+ for (const ext of [".jpg", ".jpeg", ".gif", ".webp"]) {
266
+ it(`treats ${ext} as image`, async () => {
267
+ const tmpFile = `/tmp/macroclaw-test-${Date.now()}${ext}`;
268
+ await Bun.write(tmpFile, "fake");
269
+
270
+ const bot = {
271
+ api: {
272
+ sendPhoto: mock(async () => {}),
273
+ sendDocument: mock(async () => {}),
274
+ },
275
+ } as any;
276
+
277
+ await sendFile(bot, "123", tmpFile);
278
+ expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1);
279
+
280
+ await rm(tmpFile, { force: true });
281
+ });
282
+ }
283
+ });
@@ -0,0 +1,121 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { Bot, InlineKeyboard, InputFile } from "grammy";
6
+ import { createLogger } from "./logger";
7
+
8
+ const log = createLogger("telegram");
9
+
10
+ const MAX_LENGTH = 4096;
11
+ const INBOUND_DIR = "/tmp/macroclaw/inbound";
12
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
13
+
14
+ export function createBot(token: string) {
15
+ return new Bot(token);
16
+ }
17
+
18
+ export function buildInlineKeyboard(buttons: string[]): InlineKeyboard {
19
+ const kb = new InlineKeyboard();
20
+ for (let i = 0; i < buttons.length; i++) {
21
+ if (i > 0) kb.row();
22
+ kb.text(buttons[i], buttons[i]);
23
+ }
24
+ return kb;
25
+ }
26
+
27
+ export async function sendResponse(
28
+ bot: Bot,
29
+ chatId: string,
30
+ text: string,
31
+ buttons?: string[],
32
+ ): Promise<void> {
33
+ const opts = { parse_mode: "HTML" as const };
34
+ const replyMarkup = buttons?.length ? buildInlineKeyboard(buttons) : undefined;
35
+
36
+ if (text.length <= MAX_LENGTH) {
37
+ await bot.api.sendMessage(chatId, text, { ...opts, reply_markup: replyMarkup });
38
+ return;
39
+ }
40
+
41
+ // Split at line boundaries into chunks <= MAX_LENGTH
42
+ const lines = text.split("\n");
43
+ const chunks: string[] = [];
44
+ let chunk = "";
45
+
46
+ for (const line of lines) {
47
+ if (line.length > MAX_LENGTH) {
48
+ if (chunk) {
49
+ chunks.push(chunk);
50
+ chunk = "";
51
+ }
52
+ for (let i = 0; i < line.length; i += MAX_LENGTH) {
53
+ chunks.push(line.slice(i, i + MAX_LENGTH));
54
+ }
55
+ continue;
56
+ }
57
+
58
+ const candidate = chunk ? `${chunk}\n${line}` : line;
59
+ if (candidate.length > MAX_LENGTH) {
60
+ chunks.push(chunk);
61
+ chunk = line;
62
+ } else {
63
+ chunk = candidate;
64
+ }
65
+ }
66
+
67
+ if (chunk) {
68
+ chunks.push(chunk);
69
+ }
70
+
71
+ // Send all chunks; attach buttons to the last one only
72
+ for (let i = 0; i < chunks.length; i++) {
73
+ const isLast = i === chunks.length - 1;
74
+ await bot.api.sendMessage(chatId, chunks[i], { ...opts, reply_markup: isLast ? replyMarkup : undefined });
75
+ }
76
+ }
77
+
78
+ export async function downloadFile(
79
+ bot: Bot,
80
+ fileId: string,
81
+ token: string,
82
+ originalName?: string,
83
+ ): Promise<string> {
84
+ const file = await bot.api.getFile(fileId);
85
+ const filePath = file.file_path;
86
+ if (!filePath) throw new Error("Telegram returned no file_path");
87
+
88
+ const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
89
+ const response = await fetch(url);
90
+ if (!response.ok) throw new Error(`Download failed: ${response.status}`);
91
+
92
+ const dir = join(INBOUND_DIR, randomUUID());
93
+ await mkdir(dir, { recursive: true });
94
+ const name = originalName ?? filePath.split("/").pop() ?? "file";
95
+ const dest = join(dir, name);
96
+ await writeFile(dest, new Uint8Array(await response.arrayBuffer()));
97
+ return dest;
98
+ }
99
+
100
+ function extName(path: string): string {
101
+ const dot = path.lastIndexOf(".");
102
+ return dot === -1 ? "" : path.slice(dot).toLowerCase();
103
+ }
104
+
105
+ export async function sendFile(
106
+ bot: Bot,
107
+ chatId: string,
108
+ filePath: string,
109
+ ): Promise<void> {
110
+ if (!existsSync(filePath)) {
111
+ log.warn({ filePath }, "File not found, skipping");
112
+ return;
113
+ }
114
+
115
+ const ext = extName(filePath);
116
+ if (IMAGE_EXTENSIONS.has(ext)) {
117
+ await bot.api.sendPhoto(chatId, new InputFile(filePath));
118
+ } else {
119
+ await bot.api.sendDocument(chatId, new InputFile(filePath));
120
+ }
121
+ }
@@ -0,0 +1 @@
1
+ process.env.LOG_LEVEL = "silent";
@@ -0,0 +1,2 @@
1
+ #!/bin/bash
2
+ echo "Context is about to be compacted. Before continuing, append any new facts, preferences, or decisions you learned in this session to today's daily log (memory/$(date +%Y-%m-%d).md)."
@@ -0,0 +1,19 @@
1
+ {
2
+ "autoMemoryEnabled": false,
3
+ "permissions": {
4
+ "defaultMode": "bypassPermissions"
5
+ },
6
+ "dangerouslySkipPermissions": true,
7
+ "hooks": {
8
+ "PreCompact": [
9
+ {
10
+ "hooks": [
11
+ {
12
+ "type": "command",
13
+ "command": ".claude/hooks/pre-compact.sh"
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
19
+ }
@@ -0,0 +1,77 @@
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
@@ -0,0 +1,16 @@
1
+ {
2
+ "jobs": [
3
+ {
4
+ "name": "memory-capture",
5
+ "cron": "0 */4 * * *",
6
+ "model": "haiku",
7
+ "prompt": "Append today's noteworthy events to memory/YYYY-MM-DD.md (create the file and directory if needed). Add a ## HH:MM heading with bullet points underneath. Capture: decisions made, facts learned, user preferences expressed, tasks completed or started, problems solved. Be factual and concise — one line per item. If the file already exists, APPEND only — never overwrite. If nothing meaningful happened since the last entry, respond silently."
8
+ },
9
+ {
10
+ "name": "memory-consolidate",
11
+ "cron": "0 3 * * *",
12
+ "model": "opus",
13
+ "prompt": "Read daily logs in memory/ from the past 3 days. Distill durable knowledge into MEMORY.md: confirmed facts, recurring patterns, stable preferences, key decisions, and important context. Remove entries from MEMORY.md that are outdated or contradicted by recent logs. Keep MEMORY.md concise and organized by topic (not chronologically). Do not duplicate entries. If daily logs contain new personal facts about the user (preferences, routines, family, work, interests), update USER.md accordingly. Respond silently."
14
+ }
15
+ ]
16
+ }