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,265 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { CronScheduler } from "./cron";
5
+
6
+ const TEST_DIR = join(import.meta.dir, "..", ".test-workspace-cron");
7
+ const CRON_DIR = join(TEST_DIR, ".macroclaw");
8
+ const CRON_FILE = join(CRON_DIR, "cron.json");
9
+
10
+ function writeCronConfig(config: any) {
11
+ mkdirSync(CRON_DIR, { recursive: true });
12
+ writeFileSync(CRON_FILE, JSON.stringify(config));
13
+ }
14
+
15
+ function readCronConfig() {
16
+ return JSON.parse(readFileSync(CRON_FILE, "utf-8"));
17
+ }
18
+
19
+ function makeOnJob() {
20
+ return mock((_name: string, _prompt: string, _model?: string) => {});
21
+ }
22
+
23
+ // Build a cron expression that matches the current minute
24
+ function currentMinuteCron(): string {
25
+ const now = new Date();
26
+ return `${now.getMinutes()} ${now.getHours()} * * *`;
27
+ }
28
+
29
+ // Build a cron expression that never matches now
30
+ function nonMatchingCron(): string {
31
+ const now = new Date();
32
+ const otherMinute = (now.getMinutes() + 30) % 60;
33
+ return `${otherMinute} ${(now.getHours() + 12) % 24} * * *`;
34
+ }
35
+
36
+ beforeEach(() => {
37
+ mkdirSync(CRON_DIR, { recursive: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ rmSync(TEST_DIR, { recursive: true, force: true });
42
+ });
43
+
44
+ describe("CronScheduler", () => {
45
+ it("calls onJob for matching cron job", () => {
46
+ writeCronConfig({
47
+ jobs: [{ name: "test-job", cron: currentMinuteCron(), prompt: "do something" }],
48
+ });
49
+
50
+ const onJob = makeOnJob();
51
+ const cron = new CronScheduler(TEST_DIR, { onJob });
52
+ cron.start();
53
+ cron.stop();
54
+
55
+ expect(onJob).toHaveBeenCalledWith("test-job", "do something", undefined);
56
+ });
57
+
58
+ it("does not call onJob for non-matching jobs", () => {
59
+ writeCronConfig({
60
+ jobs: [{ name: "later", cron: nonMatchingCron(), prompt: "not now" }],
61
+ });
62
+
63
+ const onJob = makeOnJob();
64
+ const cron = new CronScheduler(TEST_DIR, { onJob });
65
+ cron.start();
66
+ cron.stop();
67
+
68
+ expect(onJob).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it("silently skips when cron.json does not exist", () => {
72
+ rmSync(CRON_FILE, { force: true });
73
+
74
+ const onJob = makeOnJob();
75
+ const cron = new CronScheduler(TEST_DIR, { onJob });
76
+ cron.start();
77
+ cron.stop();
78
+
79
+ expect(onJob).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("does not call onJob on malformed JSON", () => {
83
+ writeFileSync(CRON_FILE, "not json{{{");
84
+
85
+ const onJob = makeOnJob();
86
+ const cron = new CronScheduler(TEST_DIR, { onJob });
87
+ cron.start();
88
+ cron.stop();
89
+
90
+ expect(onJob).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it("does not call onJob when jobs is not an array", () => {
94
+ writeCronConfig({ jobs: "not-array" });
95
+
96
+ const onJob = makeOnJob();
97
+ const cron = new CronScheduler(TEST_DIR, { onJob });
98
+ cron.start();
99
+ cron.stop();
100
+
101
+ expect(onJob).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("skips invalid cron expression and processes valid jobs", () => {
105
+ writeCronConfig({
106
+ jobs: [
107
+ { name: "bad", cron: "invalid cron", prompt: "bad" },
108
+ { name: "good", cron: currentMinuteCron(), prompt: "good" },
109
+ ],
110
+ });
111
+
112
+ const onJob = makeOnJob();
113
+ const cron = new CronScheduler(TEST_DIR, { onJob });
114
+ cron.start();
115
+ cron.stop();
116
+
117
+ expect(onJob).toHaveBeenCalledTimes(1);
118
+ expect(onJob).toHaveBeenCalledWith("good", "good", undefined);
119
+ });
120
+
121
+ it("stop clears the interval", () => {
122
+ writeCronConfig({ jobs: [] });
123
+
124
+ const onJob = makeOnJob();
125
+ const cron = new CronScheduler(TEST_DIR, { onJob });
126
+ cron.start();
127
+ cron.stop(); // should not throw
128
+ });
129
+
130
+ it("only evaluates once per minute", () => {
131
+ writeCronConfig({
132
+ jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "once" }],
133
+ });
134
+
135
+ const onJob = makeOnJob();
136
+ const cron = new CronScheduler(TEST_DIR, { onJob });
137
+ cron.start();
138
+ cron.stop();
139
+
140
+ expect(onJob).toHaveBeenCalledTimes(1);
141
+
142
+ // Start again with a new instance — the lastMinute tracker is per-instance
143
+ const onJob2 = makeOnJob();
144
+ const cron2 = new CronScheduler(TEST_DIR, { onJob: onJob2 });
145
+ cron2.start();
146
+ cron2.stop();
147
+
148
+ expect(onJob2).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it("handles multiple matching jobs", () => {
152
+ writeCronConfig({
153
+ jobs: [
154
+ { name: "first", cron: currentMinuteCron(), prompt: "first" },
155
+ { name: "second", cron: currentMinuteCron(), prompt: "second" },
156
+ ],
157
+ });
158
+
159
+ const onJob = makeOnJob();
160
+ const cron = new CronScheduler(TEST_DIR, { onJob });
161
+ cron.start();
162
+ cron.stop();
163
+
164
+ expect(onJob).toHaveBeenCalledTimes(2);
165
+ expect(onJob).toHaveBeenCalledWith("first", "first", undefined);
166
+ expect(onJob).toHaveBeenCalledWith("second", "second", undefined);
167
+ });
168
+
169
+ it("passes model override to onJob", () => {
170
+ writeCronConfig({
171
+ jobs: [{ name: "smart", cron: currentMinuteCron(), prompt: "think hard", model: "opus" }],
172
+ });
173
+
174
+ const onJob = makeOnJob();
175
+ const cron = new CronScheduler(TEST_DIR, { onJob });
176
+ cron.start();
177
+ cron.stop();
178
+
179
+ expect(onJob).toHaveBeenCalledWith("smart", "think hard", "opus");
180
+ });
181
+
182
+ it("removes non-recurring job after it fires", () => {
183
+ writeCronConfig({
184
+ jobs: [
185
+ { name: "once", cron: currentMinuteCron(), prompt: "one-time", recurring: false },
186
+ { name: "always", cron: currentMinuteCron(), prompt: "forever" },
187
+ ],
188
+ });
189
+
190
+ const onJob = makeOnJob();
191
+ const cron = new CronScheduler(TEST_DIR, { onJob });
192
+ cron.start();
193
+ cron.stop();
194
+
195
+ expect(onJob).toHaveBeenCalledTimes(2);
196
+
197
+ const updated = readCronConfig();
198
+ expect(updated.jobs).toHaveLength(1);
199
+ expect(updated.jobs[0].name).toBe("always");
200
+ });
201
+
202
+ it("keeps recurring jobs (default behavior)", () => {
203
+ writeCronConfig({
204
+ jobs: [{ name: "keeper", cron: currentMinuteCron(), prompt: "stay" }],
205
+ });
206
+
207
+ const onJob = makeOnJob();
208
+ const cron = new CronScheduler(TEST_DIR, { onJob });
209
+ cron.start();
210
+ cron.stop();
211
+
212
+ const updated = readCronConfig();
213
+ expect(updated.jobs).toHaveLength(1);
214
+ expect(updated.jobs[0].name).toBe("keeper");
215
+ });
216
+
217
+ it("keeps jobs with recurring: true", () => {
218
+ writeCronConfig({
219
+ jobs: [{ name: "explicit", cron: currentMinuteCron(), prompt: "stay", recurring: true }],
220
+ });
221
+
222
+ const onJob = makeOnJob();
223
+ const cron = new CronScheduler(TEST_DIR, { onJob });
224
+ cron.start();
225
+ cron.stop();
226
+
227
+ const updated = readCronConfig();
228
+ expect(updated.jobs).toHaveLength(1);
229
+ });
230
+
231
+ it("still fires job when write-back of cron.json fails", () => {
232
+ // Write config to a path that will be read successfully
233
+ writeCronConfig({
234
+ jobs: [{ name: "once", cron: currentMinuteCron(), prompt: "fire", recurring: false }],
235
+ });
236
+
237
+ // Make cron.json read-only so writeFileSync fails
238
+ const { chmodSync } = require("node:fs");
239
+ chmodSync(CRON_FILE, 0o444);
240
+
241
+ const onJob = makeOnJob();
242
+ const cron = new CronScheduler(TEST_DIR, { onJob });
243
+ cron.start();
244
+ cron.stop();
245
+
246
+ chmodSync(CRON_FILE, 0o644);
247
+
248
+ expect(onJob).toHaveBeenCalledTimes(1);
249
+ });
250
+
251
+ it("does not write file when no non-recurring jobs fired", () => {
252
+ writeCronConfig({
253
+ jobs: [{ name: "recurring", cron: nonMatchingCron(), prompt: "nope", recurring: false }],
254
+ });
255
+
256
+ const onJob = makeOnJob();
257
+ const cron = new CronScheduler(TEST_DIR, { onJob });
258
+ cron.start();
259
+ cron.stop();
260
+
261
+ // File should remain unchanged (job still present since it didn't fire)
262
+ const updated = readCronConfig();
263
+ expect(updated.jobs).toHaveLength(1);
264
+ });
265
+ });
package/src/cron.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CronExpressionParser } from "cron-parser";
4
+ import { z } from "zod/v4";
5
+ import { createLogger } from "./logger";
6
+
7
+ const log = createLogger("cron");
8
+
9
+ const cronJobSchema = z.object({
10
+ name: z.string(),
11
+ cron: z.string(),
12
+ prompt: z.string(),
13
+ recurring: z.boolean().optional(),
14
+ model: z.string().optional(),
15
+ });
16
+
17
+ const cronConfigSchema = z.object({
18
+ jobs: z.array(cronJobSchema),
19
+ });
20
+
21
+ type CronConfig = z.infer<typeof cronConfigSchema>;
22
+
23
+ export interface CronSchedulerConfig {
24
+ onJob: (name: string, prompt: string, model?: string) => void;
25
+ }
26
+
27
+ const TICK_INTERVAL = 10_000; // 10 seconds
28
+
29
+ export class CronScheduler {
30
+ #lastMinute = -1;
31
+ #cronPath: string;
32
+ #config: CronSchedulerConfig;
33
+ #timer: Timer | null = null;
34
+
35
+ constructor(workspace: string, config: CronSchedulerConfig) {
36
+ this.#cronPath = join(workspace, ".macroclaw", "cron.json");
37
+ this.#config = config;
38
+ }
39
+
40
+ start(): void {
41
+ this.#tick();
42
+ this.#timer = setInterval(() => this.#tick(), TICK_INTERVAL);
43
+ }
44
+
45
+ stop(): void {
46
+ if (this.#timer) {
47
+ clearInterval(this.#timer);
48
+ this.#timer = null;
49
+ }
50
+ }
51
+
52
+ #tick(): void {
53
+ const now = new Date();
54
+ const currentMinute = now.getMinutes() + now.getHours() * 60 + now.getDate() * 1440 + now.getMonth() * 43200;
55
+
56
+ // Only evaluate once per minute
57
+ if (currentMinute === this.#lastMinute) return;
58
+ this.#lastMinute = currentMinute;
59
+
60
+ let config: CronConfig;
61
+ try {
62
+ const raw = readFileSync(this.#cronPath, "utf-8");
63
+ const parsed = cronConfigSchema.safeParse(JSON.parse(raw));
64
+ if (!parsed.success) {
65
+ log.warn("cron.json: 'jobs' is not an array");
66
+ return;
67
+ }
68
+ config = parsed.data;
69
+ } catch (err) {
70
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") return;
71
+ log.warn({ err: err instanceof Error ? err.message : err }, "Failed to read cron.json");
72
+ return;
73
+ }
74
+
75
+ const firedNonRecurring: number[] = [];
76
+
77
+ for (let i = 0; i < config.jobs.length; i++) {
78
+ const job = config.jobs[i];
79
+ try {
80
+ const interval = CronExpressionParser.parse(job.cron);
81
+ const prev = interval.prev();
82
+ const diff = Math.abs(now.getTime() - prev.getTime());
83
+ // Match if the previous occurrence is within the current minute
84
+ if (diff < 60_000) {
85
+ log.debug({ name: job.name, cron: job.cron }, "Cron job triggered");
86
+ this.#config.onJob(job.name, job.prompt, job.model);
87
+ if (job.recurring === false) {
88
+ firedNonRecurring.push(i);
89
+ }
90
+ }
91
+ } catch (err) {
92
+ log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
93
+ }
94
+ }
95
+
96
+ // Remove fired non-recurring jobs (iterate in reverse to preserve indices)
97
+ if (firedNonRecurring.length > 0) {
98
+ for (let i = firedNonRecurring.length - 1; i >= 0; i--) {
99
+ config.jobs.splice(firedNonRecurring[i], 1);
100
+ }
101
+ try {
102
+ writeFileSync(this.#cronPath, `${JSON.stringify(config, null, 2)}\n`);
103
+ } catch (err) {
104
+ log.warn({ err: err instanceof Error ? err.message : err }, "Failed to write cron.json");
105
+ }
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,92 @@
1
+ import { afterAll, beforeEach, describe, expect, it, spyOn } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import { logPrompt, logResult } from "./history";
4
+
5
+ const mockMkdir = spyOn(fs, "mkdir").mockResolvedValue(undefined);
6
+ const mockAppendFile = spyOn(fs, "appendFile").mockResolvedValue(undefined);
7
+
8
+ beforeEach(() => {
9
+ mockMkdir.mockClear();
10
+ mockAppendFile.mockClear();
11
+ });
12
+
13
+ afterAll(() => {
14
+ mockMkdir.mockRestore();
15
+ mockAppendFile.mockRestore();
16
+ });
17
+
18
+ describe("logPrompt", () => {
19
+ it("writes a prompt entry as JSONL", async () => {
20
+ const request = { type: "user", message: "hello" };
21
+ await logPrompt(request);
22
+
23
+ expect(mockMkdir).toHaveBeenCalledTimes(1);
24
+ expect(mockMkdir.mock.calls[0][1]).toEqual({ recursive: true });
25
+
26
+ expect(mockAppendFile).toHaveBeenCalledTimes(1);
27
+ const written = mockAppendFile.mock.calls[0][1] as string;
28
+ const parsed = JSON.parse(written);
29
+ expect(parsed.type).toBe("prompt");
30
+ expect(parsed.request).toEqual(request);
31
+ expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
32
+ });
33
+
34
+ it("writes cron request", async () => {
35
+ const request = { type: "cron", name: "daily", prompt: "check" };
36
+ await logPrompt(request);
37
+
38
+ const parsed = JSON.parse(mockAppendFile.mock.calls[0][1] as string);
39
+ expect(parsed.request).toEqual(request);
40
+ });
41
+ });
42
+
43
+ describe("logResult", () => {
44
+ it("writes a result entry as JSONL", async () => {
45
+ const response = { action: "send", message: "hi", actionReason: "ok" };
46
+ await logResult(response);
47
+
48
+ expect(mockAppendFile).toHaveBeenCalledTimes(1);
49
+ const written = mockAppendFile.mock.calls[0][1] as string;
50
+ const parsed = JSON.parse(written);
51
+ expect(parsed.type).toBe("result");
52
+ expect(parsed.response).toEqual(response);
53
+ expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
54
+ });
55
+
56
+ it("writes silent response", async () => {
57
+ const response = { action: "silent", actionReason: "nothing new" };
58
+ await logResult(response);
59
+
60
+ const parsed = JSON.parse(mockAppendFile.mock.calls[0][1] as string);
61
+ expect(parsed.response).toEqual(response);
62
+ });
63
+ });
64
+
65
+ describe("file path", () => {
66
+ it("uses today's date in YYYY-MM-DD.jsonl format", async () => {
67
+ await logPrompt({ type: "user", message: "test" });
68
+
69
+ const filePath = mockAppendFile.mock.calls[0][0] as string;
70
+ const today = new Date().toISOString().slice(0, 10);
71
+ expect(filePath).toContain(`${today}.jsonl`);
72
+ expect(filePath).toContain(".macroclaw/history/");
73
+ });
74
+ });
75
+
76
+ describe("mkdir", () => {
77
+ it("creates history directory recursively", async () => {
78
+ await logPrompt({ type: "user", message: "test" });
79
+
80
+ const dirPath = mockMkdir.mock.calls[0][0] as string;
81
+ expect(dirPath).toContain(".macroclaw/history");
82
+ });
83
+ });
84
+
85
+ describe("error handling", () => {
86
+ it("logs errors but does not throw", async () => {
87
+ mockAppendFile.mockRejectedValueOnce(new Error("disk full"));
88
+
89
+ // Should not throw
90
+ await logPrompt({ type: "user", message: "test" });
91
+ });
92
+ });
package/src/history.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { createLogger } from "./logger";
4
+
5
+ const log = createLogger("history");
6
+
7
+ // Minimal types for history logging — intentionally kept broad to avoid coupling
8
+ type HistoryRequest = { type: string; [key: string]: unknown };
9
+ type HistoryResponse = { action: string; actionReason: string; [key: string]: unknown };
10
+
11
+ type HistoryEntry =
12
+ | { ts: string; type: "prompt"; request: HistoryRequest }
13
+ | { ts: string; type: "result"; response: HistoryResponse };
14
+
15
+ const historyDir = resolve(process.env.HOME || "~", ".macroclaw", "history");
16
+
17
+ function todayFile(): string {
18
+ const date = new Date().toISOString().slice(0, 10);
19
+ return join(historyDir, `${date}.jsonl`);
20
+ }
21
+
22
+ async function append(entry: HistoryEntry): Promise<void> {
23
+ try {
24
+ await mkdir(historyDir, { recursive: true });
25
+ await appendFile(todayFile(), `${JSON.stringify(entry)}\n`);
26
+ } catch (err) {
27
+ log.error({ err }, "Failed to write history entry");
28
+ }
29
+ }
30
+
31
+ export async function logPrompt(request: HistoryRequest): Promise<void> {
32
+ await append({ ts: new Date().toISOString(), type: "prompt", request });
33
+ }
34
+
35
+ export async function logResult(response: HistoryResponse): Promise<void> {
36
+ await append({ ts: new Date().toISOString(), type: "result", response });
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { cpSync, existsSync, readdirSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { App, type AppConfig } from "./app";
4
+ import { createLogger, initLogger } from "./logger";
5
+
6
+ await initLogger();
7
+ const log = createLogger("index");
8
+
9
+ function requireEnv(name: string): string {
10
+ const value = process.env[name];
11
+ if (!value) {
12
+ log.error({ name }, "Missing environment variable");
13
+ process.exit(1);
14
+ }
15
+ return value;
16
+ }
17
+
18
+ const defaultWorkspace = resolve(process.env.HOME || "~", ".macroclaw-workspace");
19
+ const workspace = process.env.WORKSPACE || defaultWorkspace;
20
+
21
+ function initWorkspace(workspace: string) {
22
+ const templateDir = join(dirname(import.meta.dir), "workspace-template");
23
+ const exists = existsSync(workspace);
24
+ const empty = exists && readdirSync(workspace).length === 0;
25
+
26
+ if (!exists || empty) {
27
+ log.info({ workspace }, "Initializing workspace from template");
28
+ cpSync(templateDir, workspace, { recursive: true });
29
+ log.info("Workspace initialized");
30
+ }
31
+ }
32
+
33
+ initWorkspace(workspace);
34
+
35
+ const config: AppConfig = {
36
+ botToken: requireEnv("TELEGRAM_BOT_TOKEN"),
37
+ authorizedChatId: requireEnv("AUTHORIZED_CHAT_ID"),
38
+ workspace,
39
+ model: process.env.MODEL,
40
+ };
41
+
42
+ new App(config).start();
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+
3
+ const mockPinoramaTransport = mock(() => ({ write: () => {} }));
4
+
5
+ mock.module("pinorama-transport", () => ({
6
+ default: mockPinoramaTransport,
7
+ }));
8
+
9
+ const { createLogger, initLogger } = await import("./logger");
10
+
11
+ describe("createLogger", () => {
12
+ it("returns a pino child logger with module field", () => {
13
+ const log = createLogger("test-module");
14
+ expect(log).toBeDefined();
15
+ expect(log.bindings().module).toBe("test-module");
16
+ });
17
+ });
18
+
19
+ describe("initLogger", () => {
20
+ it("adds pinorama transport when PINORAMA_URL is set", async () => {
21
+ process.env.PINORAMA_URL = "http://localhost:6200/pinorama";
22
+ await initLogger();
23
+ expect(mockPinoramaTransport).toHaveBeenCalledWith({ url: "http://localhost:6200/pinorama" });
24
+ delete process.env.PINORAMA_URL;
25
+ });
26
+
27
+ it("does nothing when PINORAMA_URL is not set", async () => {
28
+ delete process.env.PINORAMA_URL;
29
+ mockPinoramaTransport.mockClear();
30
+ await initLogger();
31
+ expect(mockPinoramaTransport).not.toHaveBeenCalled();
32
+ });
33
+ });
package/src/logger.ts ADDED
@@ -0,0 +1,28 @@
1
+ import pino from "pino";
2
+ import pretty from "pino-pretty";
3
+
4
+ const prettyStream = pretty({
5
+ ignore: "pid,hostname",
6
+ messageFormat: "[{module}] {msg}",
7
+ });
8
+
9
+ const level = (process.env.LOG_LEVEL || "debug") as pino.Level;
10
+
11
+ const streams: pino.StreamEntry[] = [{ level, stream: prettyStream }];
12
+
13
+ const logger = pino(
14
+ { level: process.env.LOG_LEVEL || "debug" },
15
+ pino.multistream(streams),
16
+ );
17
+
18
+ export async function initLogger(): Promise<void> {
19
+ const pinoramaUrl = process.env.PINORAMA_URL;
20
+ if (pinoramaUrl) {
21
+ const { default: pinoramaTransport } = await import("pinorama-transport");
22
+ streams.push({ level, stream: pinoramaTransport({ url: pinoramaUrl }) });
23
+ }
24
+ }
25
+
26
+ export function createLogger(module: string) {
27
+ return logger.child({ module });
28
+ }