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.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/bin/macroclaw.js +2 -0
- package/package.json +41 -0
- package/src/app.test.ts +699 -0
- package/src/app.ts +164 -0
- package/src/claude.integration-test.ts +108 -0
- package/src/claude.test.ts +247 -0
- package/src/claude.ts +136 -0
- package/src/cron.test.ts +265 -0
- package/src/cron.ts +108 -0
- package/src/history.test.ts +92 -0
- package/src/history.ts +37 -0
- package/src/index.ts +42 -0
- package/src/logger.test.ts +33 -0
- package/src/logger.ts +28 -0
- package/src/orchestrator.test.ts +631 -0
- package/src/orchestrator.ts +396 -0
- package/src/prompts.test.ts +43 -0
- package/src/prompts.ts +48 -0
- package/src/queue.test.ts +150 -0
- package/src/queue.ts +42 -0
- package/src/settings.test.ts +55 -0
- package/src/settings.ts +36 -0
- package/src/stt.ts +31 -0
- package/src/telegram.test.ts +283 -0
- package/src/telegram.ts +121 -0
- package/src/test-setup.ts +1 -0
- package/workspace-template/.claude/hooks/pre-compact.sh +2 -0
- package/workspace-template/.claude/settings.json +19 -0
- package/workspace-template/.claude/skills/add-cronjob/SKILL.md +77 -0
- package/workspace-template/.macroclaw/cron.json +16 -0
- package/workspace-template/CLAUDE.md +97 -0
- package/workspace-template/MEMORY.md +3 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import { Claude, type ClaudeDeferredResult, ClaudeParseError, ClaudeProcessError, type ClaudeResult, isDeferred } from "./claude";
|
|
3
|
+
import { logPrompt, logResult } from "./history";
|
|
4
|
+
import { createLogger } from "./logger";
|
|
5
|
+
import { BG_TIMEOUT, CRON_TIMEOUT, MAIN_TIMEOUT, SYSTEM_PROMPT } from "./prompts";
|
|
6
|
+
import { Queue } from "./queue";
|
|
7
|
+
import { loadSettings, newSessionId, type Settings, saveSettings } from "./settings";
|
|
8
|
+
|
|
9
|
+
const log = createLogger("orchestrator");
|
|
10
|
+
|
|
11
|
+
// --- Response schema (owned by orchestrator) ---
|
|
12
|
+
// Flat object (no oneOf/discriminatedUnion) — Claude CLI --json-schema requires a single top-level object.
|
|
13
|
+
|
|
14
|
+
const backgroundAgentSchema = z.object({
|
|
15
|
+
name: z.string().describe("Label for the background agent"),
|
|
16
|
+
prompt: z.string().describe("The prompt/task for the background agent"),
|
|
17
|
+
model: z.enum(["haiku", "sonnet", "opus"]).describe("Model to use for the background agent").optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const claudeResponseSchema = z.object({
|
|
21
|
+
action: z.enum(["send", "silent"]).describe("'send' to reply to the user, 'silent' to do nothing"),
|
|
22
|
+
actionReason: z.string().describe("Why the agent chose this action (logged, not sent)"),
|
|
23
|
+
message: z.string().describe("The message to send to Telegram (required when action is 'send')").optional(),
|
|
24
|
+
files: z.array(z.string()).describe("Absolute paths to files to send to Telegram").optional(),
|
|
25
|
+
buttons: z.array(z.string()).describe("Button labels to show below the message").optional(),
|
|
26
|
+
backgroundAgents: z.array(backgroundAgentSchema).describe("Background agents to spawn alongside this response").optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type ClaudeResponseInternal = z.infer<typeof claudeResponseSchema>;
|
|
30
|
+
|
|
31
|
+
const jsonSchema = JSON.stringify(z.toJSONSchema(claudeResponseSchema, { target: "jsonSchema7" }));
|
|
32
|
+
|
|
33
|
+
// --- Public response type ---
|
|
34
|
+
|
|
35
|
+
export interface OrchestratorResponse {
|
|
36
|
+
message: string;
|
|
37
|
+
files?: string[];
|
|
38
|
+
buttons?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Internal request types ---
|
|
42
|
+
|
|
43
|
+
type OrchestratorRequest =
|
|
44
|
+
| { type: "user"; message: string; files?: string[] }
|
|
45
|
+
| { type: "cron"; name: string; prompt: string; model?: string }
|
|
46
|
+
| { type: "background-agent-result"; name: string; response: ClaudeResponseInternal; sessionId?: string }
|
|
47
|
+
| { type: "background-agent"; name: string; prompt: string; model?: string }
|
|
48
|
+
| { type: "button"; label: string };
|
|
49
|
+
|
|
50
|
+
function escapeHtml(text: string): string {
|
|
51
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Background tracking ---
|
|
55
|
+
|
|
56
|
+
interface BackgroundInfo {
|
|
57
|
+
name: string;
|
|
58
|
+
sessionId: string;
|
|
59
|
+
startTime: Date;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Orchestrator ---
|
|
63
|
+
|
|
64
|
+
export interface OrchestratorConfig {
|
|
65
|
+
model?: string;
|
|
66
|
+
workspace: string;
|
|
67
|
+
settingsDir?: string;
|
|
68
|
+
onResponse: (response: OrchestratorResponse) => Promise<void>;
|
|
69
|
+
claude?: Claude;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface BuiltRequest {
|
|
73
|
+
prompt: string;
|
|
74
|
+
model: string | undefined;
|
|
75
|
+
timeout: number;
|
|
76
|
+
files?: string[];
|
|
77
|
+
useMainSession: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface CallResult {
|
|
81
|
+
response: ClaudeResponseInternal;
|
|
82
|
+
sessionId: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class Orchestrator {
|
|
86
|
+
#claude: Claude;
|
|
87
|
+
#settings: Settings;
|
|
88
|
+
#sessionId: string;
|
|
89
|
+
#sessionFlag: "--resume" | "--session-id";
|
|
90
|
+
#sessionResolved = false;
|
|
91
|
+
#config: OrchestratorConfig;
|
|
92
|
+
#active = new Map<string, BackgroundInfo>();
|
|
93
|
+
#queue: Queue<OrchestratorRequest>;
|
|
94
|
+
|
|
95
|
+
constructor(config: OrchestratorConfig) {
|
|
96
|
+
this.#config = config;
|
|
97
|
+
this.#claude = config.claude ?? new Claude({ workspace: config.workspace, jsonSchema });
|
|
98
|
+
this.#settings = loadSettings(config.settingsDir);
|
|
99
|
+
this.#queue = new Queue<OrchestratorRequest>();
|
|
100
|
+
this.#queue.setHandler((request) => this.#handleRequest(request));
|
|
101
|
+
|
|
102
|
+
if (this.#settings.sessionId) {
|
|
103
|
+
this.#sessionId = this.#settings.sessionId;
|
|
104
|
+
this.#sessionFlag = "--resume";
|
|
105
|
+
} else {
|
|
106
|
+
this.#sessionId = newSessionId();
|
|
107
|
+
this.#sessionFlag = "--session-id";
|
|
108
|
+
saveSettings({ sessionId: this.#sessionId }, config.settingsDir);
|
|
109
|
+
log.info({ sessionId: this.#sessionId }, "Created new session");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Public handle methods ---
|
|
114
|
+
|
|
115
|
+
handleMessage(message: string, files?: string[]): void {
|
|
116
|
+
this.#queue.push({ type: "user", message, files });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
handleButton(label: string): void {
|
|
120
|
+
this.#queue.push({ type: "button", label });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
handleCron(name: string, prompt: string, model?: string): void {
|
|
124
|
+
this.#queue.push({ type: "cron", name, prompt, model });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
handleBackgroundCommand(prompt: string): void {
|
|
128
|
+
const name = prompt.slice(0, 30).replace(/\s+/g, "-");
|
|
129
|
+
this.#spawnBackground(name, prompt, this.#config.model);
|
|
130
|
+
this.#callOnResponse({ message: `Background agent "${escapeHtml(name)}" started.` });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
handleBackgroundList(): void {
|
|
134
|
+
const agents = [...this.#active.values()];
|
|
135
|
+
if (agents.length === 0) {
|
|
136
|
+
this.#callOnResponse({ message: "No background agents running." });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const lines = agents.map((a) => {
|
|
140
|
+
const elapsed = Math.round((Date.now() - a.startTime.getTime()) / 1000);
|
|
141
|
+
return `- ${escapeHtml(a.name)} (${elapsed}s)`;
|
|
142
|
+
});
|
|
143
|
+
this.#callOnResponse({ message: lines.join("\n") });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
handleSessionCommand(): void {
|
|
147
|
+
this.#callOnResponse({ message: `Session: <code>${this.#sessionId}</code>` });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Internal queue handler ---
|
|
151
|
+
|
|
152
|
+
async #handleRequest(request: OrchestratorRequest): Promise<void> {
|
|
153
|
+
log.debug({ type: request.type }, "Incoming request");
|
|
154
|
+
|
|
155
|
+
// Background result with matching session ID: deliver directly without Claude round-trip
|
|
156
|
+
if (request.type === "background-agent-result" && "sessionId" in request && request.sessionId === this.#sessionId) {
|
|
157
|
+
log.debug({ name: request.name }, "Background result on current session, applying directly");
|
|
158
|
+
await this.#deliverClaudeResponse(request.response);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Fork session if a backgrounded task is running on the main session
|
|
163
|
+
const needsFork = (request.type === "user" || request.type === "button") && this.#active.has(this.#sessionId);
|
|
164
|
+
|
|
165
|
+
const startTime = new Date();
|
|
166
|
+
const rawResponse = await this.#processRequest(request, needsFork ? { forkSession: true } : undefined);
|
|
167
|
+
if (isDeferred(rawResponse)) {
|
|
168
|
+
const name = request.type === "user" ? request.message.slice(0, 30).replace(/\s+/g, "-")
|
|
169
|
+
: request.type === "cron" ? `cron-${request.name}`
|
|
170
|
+
: "task";
|
|
171
|
+
log.info({ name, sessionId: rawResponse.sessionId }, "Request backgrounded due to timeout");
|
|
172
|
+
this.#callOnResponse({ message: "This is taking longer, continuing in the background." });
|
|
173
|
+
this.#adoptBackground(name, rawResponse.sessionId, startTime, rawResponse.completion.then(
|
|
174
|
+
(r) => {
|
|
175
|
+
const msg = r.structuredOutput ? String((r.structuredOutput as Record<string, unknown>).message ?? "") : (r.result ?? "");
|
|
176
|
+
return { action: "send" as const, message: msg, actionReason: "deferred-completed" };
|
|
177
|
+
},
|
|
178
|
+
(err) => ({ action: "send" as const, message: `[Error] ${err}`, actionReason: "deferred-failed" }),
|
|
179
|
+
));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
log.debug({ action: rawResponse.action, actionReason: rawResponse.actionReason }, "Response");
|
|
184
|
+
await this.#deliverClaudeResponse(rawResponse);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async #deliverClaudeResponse(response: ClaudeResponseInternal): Promise<void> {
|
|
188
|
+
if (response.action === "send") {
|
|
189
|
+
this.#callOnResponse({
|
|
190
|
+
message: response.message || "[No output]",
|
|
191
|
+
files: response.files,
|
|
192
|
+
buttons: response.buttons,
|
|
193
|
+
});
|
|
194
|
+
} else {
|
|
195
|
+
log.debug("Silent response");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (response.backgroundAgents?.length) {
|
|
199
|
+
for (const agent of response.backgroundAgents) {
|
|
200
|
+
const agentModel = agent.model ?? this.#config.model;
|
|
201
|
+
this.#spawnBackground(agent.name, agent.prompt, agentModel);
|
|
202
|
+
this.#callOnResponse({ message: `Background agent "${escapeHtml(agent.name)}" started.` });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#callOnResponse(response: OrchestratorResponse): void {
|
|
208
|
+
this.#config.onResponse(response).catch((err) => {
|
|
209
|
+
log.error({ err }, "onResponse callback failed");
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Internal background management ---
|
|
214
|
+
|
|
215
|
+
#spawnBackground(name: string, prompt: string, model: string | undefined) {
|
|
216
|
+
const sessionId = newSessionId();
|
|
217
|
+
const info: BackgroundInfo = { name, sessionId, startTime: new Date() };
|
|
218
|
+
this.#active.set(sessionId, info);
|
|
219
|
+
|
|
220
|
+
log.debug({ name, sessionId }, "Starting background agent");
|
|
221
|
+
|
|
222
|
+
this.#processRequest({ type: "background-agent", name, prompt, model }).then(
|
|
223
|
+
async (rawResponse) => {
|
|
224
|
+
let response: ClaudeResponseInternal;
|
|
225
|
+
if (isDeferred(rawResponse)) {
|
|
226
|
+
try {
|
|
227
|
+
const r = await rawResponse.completion;
|
|
228
|
+
response = { action: "send", message: String(r.structuredOutput ?? r.result ?? ""), actionReason: "deferred-completed" };
|
|
229
|
+
} catch (err) {
|
|
230
|
+
response = { action: "send", message: `[Error] ${err}`, actionReason: "deferred-failed" };
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
response = rawResponse;
|
|
234
|
+
}
|
|
235
|
+
this.#active.delete(sessionId);
|
|
236
|
+
log.debug({ name, message: response.message }, "Background agent finished");
|
|
237
|
+
this.#queue.push({ type: "background-agent-result", name, response });
|
|
238
|
+
},
|
|
239
|
+
(err) => {
|
|
240
|
+
this.#active.delete(sessionId);
|
|
241
|
+
log.error({ name, err }, "Background agent failed");
|
|
242
|
+
this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-agent-failed" } });
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#adoptBackground(name: string, sessionId: string, startTime: Date, completion: Promise<ClaudeResponseInternal>) {
|
|
248
|
+
const info: BackgroundInfo = { name, sessionId, startTime };
|
|
249
|
+
this.#active.set(sessionId, info);
|
|
250
|
+
|
|
251
|
+
log.debug({ name, sessionId }, "Adopting backgrounded task");
|
|
252
|
+
|
|
253
|
+
// completion is pre-wrapped with .then(ok, err) at the call site, so it never rejects
|
|
254
|
+
completion.then((response) => {
|
|
255
|
+
this.#active.delete(sessionId);
|
|
256
|
+
log.debug({ name, message: response.message }, "Adopted task finished");
|
|
257
|
+
this.#queue.push({ type: "background-agent-result", name, response, sessionId });
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- Core Claude processing ---
|
|
262
|
+
|
|
263
|
+
async #processRequest(request: OrchestratorRequest, options?: { forkSession?: boolean }): Promise<ClaudeResponseInternal | ClaudeDeferredResult> {
|
|
264
|
+
const built = this.#buildRequest(request);
|
|
265
|
+
await logPrompt(request);
|
|
266
|
+
|
|
267
|
+
if (built.useMainSession) {
|
|
268
|
+
let result = await this.#callClaude(built, this.#sessionFlag, this.#sessionId, options?.forkSession);
|
|
269
|
+
|
|
270
|
+
// Session resolution: if resume failed on first call, create new session
|
|
271
|
+
if (!isDeferred(result) && !this.#sessionResolved && this.#sessionFlag === "--resume" && result.response.actionReason === "process-error") {
|
|
272
|
+
this.#sessionId = newSessionId();
|
|
273
|
+
log.info({ sessionId: this.#sessionId }, "Resume failed, created new session");
|
|
274
|
+
this.#sessionFlag = "--session-id";
|
|
275
|
+
saveSettings({ sessionId: this.#sessionId }, this.#config.settingsDir);
|
|
276
|
+
result = await this.#callClaude(built, this.#sessionFlag, this.#sessionId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (isDeferred(result)) return result;
|
|
280
|
+
|
|
281
|
+
// Update session ID from response (important after fork)
|
|
282
|
+
if (result.sessionId && result.sessionId !== this.#sessionId) {
|
|
283
|
+
log.info({ oldSessionId: this.#sessionId, newSessionId: result.sessionId }, "Session forked, updating session ID");
|
|
284
|
+
this.#sessionId = result.sessionId;
|
|
285
|
+
saveSettings({ sessionId: this.#sessionId }, this.#config.settingsDir);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Mark resolved on first success
|
|
289
|
+
if (!this.#sessionResolved && result.response.actionReason !== "process-error") {
|
|
290
|
+
this.#sessionResolved = true;
|
|
291
|
+
this.#sessionFlag = "--resume";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await logResult(result.response);
|
|
295
|
+
return result.response;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// background-agent: fork from main session for full context
|
|
299
|
+
log.debug({ name: (request as { name: string }).name }, "Processing background-agent (forked session)");
|
|
300
|
+
const bgResult = await this.#callClaude(built, "--resume", this.#sessionId, true);
|
|
301
|
+
if (isDeferred(bgResult)) return bgResult;
|
|
302
|
+
await logResult(bgResult.response);
|
|
303
|
+
return bgResult.response;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#buildRequest(request: OrchestratorRequest): BuiltRequest {
|
|
307
|
+
switch (request.type) {
|
|
308
|
+
case "user":
|
|
309
|
+
return {
|
|
310
|
+
prompt: this.#buildPromptWithFiles(request.message, request.files),
|
|
311
|
+
model: this.#config.model,
|
|
312
|
+
timeout: MAIN_TIMEOUT,
|
|
313
|
+
useMainSession: true,
|
|
314
|
+
};
|
|
315
|
+
case "cron":
|
|
316
|
+
return {
|
|
317
|
+
prompt: `[Context: cron/${request.name}] ${request.prompt}`,
|
|
318
|
+
model: request.model ?? this.#config.model,
|
|
319
|
+
timeout: CRON_TIMEOUT,
|
|
320
|
+
useMainSession: true,
|
|
321
|
+
};
|
|
322
|
+
case "background-agent-result":
|
|
323
|
+
return {
|
|
324
|
+
prompt: `[Context: background-result/${request.name}] ${request.response.message || "[No output]"}`,
|
|
325
|
+
model: this.#config.model,
|
|
326
|
+
timeout: MAIN_TIMEOUT,
|
|
327
|
+
useMainSession: true,
|
|
328
|
+
};
|
|
329
|
+
case "button":
|
|
330
|
+
return {
|
|
331
|
+
prompt: `[Context: button-click] User tapped "${request.label}"`,
|
|
332
|
+
model: this.#config.model,
|
|
333
|
+
timeout: MAIN_TIMEOUT,
|
|
334
|
+
useMainSession: true,
|
|
335
|
+
};
|
|
336
|
+
case "background-agent":
|
|
337
|
+
return {
|
|
338
|
+
prompt: `[Context: background-agent/${request.name}] ${request.prompt}`,
|
|
339
|
+
model: request.model ?? this.#config.model,
|
|
340
|
+
timeout: BG_TIMEOUT,
|
|
341
|
+
useMainSession: false,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#buildPromptWithFiles(message: string, files?: string[]): string {
|
|
347
|
+
if (!files?.length) return message;
|
|
348
|
+
const prefix = files.map((f) => `[File: ${f}]`).join("\n");
|
|
349
|
+
return message ? `${prefix}\n${message}` : prefix;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#validateResponse(raw: unknown): ClaudeResponseInternal {
|
|
353
|
+
const parsed = claudeResponseSchema.safeParse(raw);
|
|
354
|
+
if (parsed.success) return parsed.data;
|
|
355
|
+
log.warn({ error: parsed.error.message }, "structured_output failed validation");
|
|
356
|
+
const rawObj = raw as Record<string, unknown> | null;
|
|
357
|
+
const msg = typeof rawObj?.message === "string" ? rawObj.message : JSON.stringify(raw);
|
|
358
|
+
return { action: "send", message: msg, actionReason: "validation-failed" };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#resultToCallResult(result: ClaudeResult): CallResult {
|
|
362
|
+
if (result.structuredOutput) {
|
|
363
|
+
return { response: this.#validateResponse(result.structuredOutput), sessionId: result.sessionId };
|
|
364
|
+
}
|
|
365
|
+
log.error({ hasResult: !!result.result }, "No structured_output in response");
|
|
366
|
+
const raw = result.result ? escapeHtml(result.result) : "";
|
|
367
|
+
const msg = raw ? `[No structured output] ${raw}` : "[No output]";
|
|
368
|
+
return { response: { action: "send", message: msg, actionReason: "no-structured-output" }, sessionId: result.sessionId };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async #callClaude(built: BuiltRequest, flag: "--resume" | "--session-id", sid: string, forkSession?: boolean): Promise<CallResult | ClaudeDeferredResult> {
|
|
372
|
+
try {
|
|
373
|
+
const result = await this.#claude.run({
|
|
374
|
+
prompt: built.prompt,
|
|
375
|
+
sessionFlag: flag,
|
|
376
|
+
sessionId: sid,
|
|
377
|
+
forkSession,
|
|
378
|
+
model: built.model,
|
|
379
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
380
|
+
timeoutMs: built.timeout,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (isDeferred(result)) return result;
|
|
384
|
+
|
|
385
|
+
return this.#resultToCallResult(result);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
if (err instanceof ClaudeProcessError) {
|
|
388
|
+
return { response: { action: "send", message: `[Error] Claude exited with code ${err.exitCode}:\n${err.stderr}`, actionReason: "process-error" }, sessionId: sid };
|
|
389
|
+
}
|
|
390
|
+
if (err instanceof ClaudeParseError) {
|
|
391
|
+
return { response: { action: "send", message: `[JSON Error] ${err.raw}`, actionReason: "json-parse-failed" }, sessionId: sid };
|
|
392
|
+
}
|
|
393
|
+
throw err;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { SYSTEM_PROMPT } from "./prompts";
|
|
3
|
+
|
|
4
|
+
describe("SYSTEM_PROMPT", () => {
|
|
5
|
+
it("contains key sections", () => {
|
|
6
|
+
expect(SYSTEM_PROMPT).toContain("macroclaw");
|
|
7
|
+
expect(SYSTEM_PROMPT).toContain("Structured output");
|
|
8
|
+
expect(SYSTEM_PROMPT).toContain("Context tags");
|
|
9
|
+
expect(SYSTEM_PROMPT).toContain("Background agents");
|
|
10
|
+
expect(SYSTEM_PROMPT).toContain("Cron");
|
|
11
|
+
expect(SYSTEM_PROMPT).toContain("Buttons");
|
|
12
|
+
expect(SYSTEM_PROMPT).toContain("Files");
|
|
13
|
+
expect(SYSTEM_PROMPT).toContain("Timeouts");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("contains HTML formatting instructions", () => {
|
|
17
|
+
expect(SYSTEM_PROMPT).toContain("HTML parse mode");
|
|
18
|
+
expect(SYSTEM_PROMPT).toContain("<b>");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("documents all context tag types", () => {
|
|
22
|
+
expect(SYSTEM_PROMPT).toContain("cron/<name>");
|
|
23
|
+
expect(SYSTEM_PROMPT).toContain("button-click");
|
|
24
|
+
expect(SYSTEM_PROMPT).toContain("background-result/<name>");
|
|
25
|
+
expect(SYSTEM_PROMPT).toContain("background-agent/<name>");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("contains structured output reinforcement", () => {
|
|
29
|
+
expect(SYSTEM_PROMPT).toContain("StructuredOutput tool");
|
|
30
|
+
expect(SYSTEM_PROMPT).toContain("actionReason");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("contains no personal names", () => {
|
|
34
|
+
expect(SYSTEM_PROMPT).not.toContain("Alfread");
|
|
35
|
+
expect(SYSTEM_PROMPT).not.toContain("Michal");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("documents background agent model options", () => {
|
|
39
|
+
expect(SYSTEM_PROMPT).toContain("haiku");
|
|
40
|
+
expect(SYSTEM_PROMPT).toContain("sonnet");
|
|
41
|
+
expect(SYSTEM_PROMPT).toContain("opus");
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const MAIN_TIMEOUT = 60_000;
|
|
2
|
+
export const CRON_TIMEOUT = 300_000;
|
|
3
|
+
export const BG_TIMEOUT = 1_800_000;
|
|
4
|
+
|
|
5
|
+
const fmtMin = (ms: number) => {
|
|
6
|
+
const m = ms / 60_000;
|
|
7
|
+
return `${m} minute${m > 1 ? "s" : ""}`;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const SYSTEM_PROMPT = `\
|
|
11
|
+
AI assistant running in macroclaw, an autonomous agent platform. \
|
|
12
|
+
Persistent workspace at cwd with config, memory, skills. \
|
|
13
|
+
Refer to workspace CLAUDE.md for identity, personality, conventions.
|
|
14
|
+
|
|
15
|
+
Responses delivered as chat messages. Keep concise and direct.
|
|
16
|
+
|
|
17
|
+
Structured output: you have a StructuredOutput tool. When you are done processing, \
|
|
18
|
+
call StructuredOutput to deliver your final response. \
|
|
19
|
+
Required fields: action ("send"|"silent"), actionReason. Include message when action="send".
|
|
20
|
+
|
|
21
|
+
Formatting: message field sent to Telegram with HTML parse mode. \
|
|
22
|
+
Use raw <b>, <i>, <code>, <pre> tags. Escape &, <, > in text content as &, <, >.
|
|
23
|
+
|
|
24
|
+
Architecture: message bridge connecting chat interface and scheduled tasks. \
|
|
25
|
+
Persistent session — conversation history carries across messages. Workspace persists across sessions.
|
|
26
|
+
|
|
27
|
+
Context tags: messages may be prefixed with [Context: <type>]. Types:
|
|
28
|
+
- cron/<name> — automated scheduled task. Prefer action="silent" when nothing noteworthy.
|
|
29
|
+
- button-click — user tapped an inline keyboard button.
|
|
30
|
+
- background-result/<name> — output from a background agent you spawned. Decide whether to relay or handle silently.
|
|
31
|
+
- background-agent/<name> — you are a background agent. Complete task, return result. Cannot spawn sub-agents.
|
|
32
|
+
|
|
33
|
+
Background agents: spawn alongside any response via backgroundAgents array:
|
|
34
|
+
backgroundAgents: [{ name: "label", prompt: "task", model: "haiku" }]
|
|
35
|
+
Each runs in same workspace, fresh session. Result fed back as [Context: background-result/<name>].
|
|
36
|
+
Models: haiku (fast/cheap), sonnet (balanced, default), opus (complex reasoning).
|
|
37
|
+
User can spawn directly with "bg:" prefix. Use for long-running tasks that shouldn't block.
|
|
38
|
+
|
|
39
|
+
Files: attachments listed as [File: /path] prefixes. Read/view at those paths. \
|
|
40
|
+
Send files via files array (absolute paths). Images (.png/.jpg/.jpeg/.gif/.webp) as photos, rest as documents. 50MB limit.
|
|
41
|
+
|
|
42
|
+
Timeouts: user=${fmtMin(MAIN_TIMEOUT)}, cron=${fmtMin(CRON_TIMEOUT)}, background=${fmtMin(BG_TIMEOUT)}. \
|
|
43
|
+
On timeout, task continues in background automatically. Spawn background agents proactively for long tasks.
|
|
44
|
+
|
|
45
|
+
Cron: jobs in .macroclaw/cron.json (hot-reloaded). Use "silent" when check finds nothing new, "send" when noteworthy.
|
|
46
|
+
|
|
47
|
+
MessageButtons: include a buttons field (flat array of label strings) to attach inline buttons below your message. \
|
|
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.`;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { Queue } from "./queue";
|
|
3
|
+
|
|
4
|
+
interface TestItem {
|
|
5
|
+
value: string;
|
|
6
|
+
extra?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("Queue", () => {
|
|
10
|
+
it("processes items in FIFO order", async () => {
|
|
11
|
+
const queue = new Queue<TestItem>();
|
|
12
|
+
const results: string[] = [];
|
|
13
|
+
queue.setHandler(async (item) => {
|
|
14
|
+
results.push(item.value);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
queue.push({ value: "first" });
|
|
18
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
19
|
+
|
|
20
|
+
queue.push({ value: "second" });
|
|
21
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
22
|
+
|
|
23
|
+
expect(results).toEqual(["first", "second"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("processes queued items serially", async () => {
|
|
27
|
+
const queue = new Queue<TestItem>();
|
|
28
|
+
const order: string[] = [];
|
|
29
|
+
let resolveSecond: (() => void) | null = null;
|
|
30
|
+
|
|
31
|
+
queue.setHandler(async (item) => {
|
|
32
|
+
if (item.value === "slow") {
|
|
33
|
+
await new Promise<void>((r) => {
|
|
34
|
+
resolveSecond = r;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
order.push(item.value);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
queue.push({ value: "slow" });
|
|
41
|
+
queue.push({ value: "fast" });
|
|
42
|
+
|
|
43
|
+
// "fast" should be queued, not processed yet
|
|
44
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
45
|
+
expect(order).toEqual([]);
|
|
46
|
+
expect(queue.isProcessing).toBe(true);
|
|
47
|
+
|
|
48
|
+
// Release the slow handler
|
|
49
|
+
resolveSecond!();
|
|
50
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
51
|
+
|
|
52
|
+
expect(order).toEqual(["slow", "fast"]);
|
|
53
|
+
expect(queue.isProcessing).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does nothing without a handler", async () => {
|
|
57
|
+
const queue = new Queue<TestItem>();
|
|
58
|
+
queue.push({ value: "orphan" });
|
|
59
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
60
|
+
expect(queue.length).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("reports length correctly", () => {
|
|
64
|
+
const queue = new Queue<TestItem>();
|
|
65
|
+
expect(queue.length).toBe(0);
|
|
66
|
+
// No handler set, so items accumulate
|
|
67
|
+
queue.push({ value: "a" });
|
|
68
|
+
queue.push({ value: "b" });
|
|
69
|
+
expect(queue.length).toBe(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reports isProcessing correctly", async () => {
|
|
73
|
+
const queue = new Queue<TestItem>();
|
|
74
|
+
expect(queue.isProcessing).toBe(false);
|
|
75
|
+
|
|
76
|
+
let resolve: (() => void) | null = null;
|
|
77
|
+
queue.setHandler(async () => {
|
|
78
|
+
await new Promise<void>((r) => {
|
|
79
|
+
resolve = r;
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
queue.push({ value: "msg" });
|
|
84
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
85
|
+
expect(queue.isProcessing).toBe(true);
|
|
86
|
+
|
|
87
|
+
resolve!();
|
|
88
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
89
|
+
expect(queue.isProcessing).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not re-enter process when already processing", async () => {
|
|
93
|
+
const queue = new Queue<TestItem>();
|
|
94
|
+
let callCount = 0;
|
|
95
|
+
let resolve: (() => void) | null = null;
|
|
96
|
+
|
|
97
|
+
queue.setHandler(async () => {
|
|
98
|
+
callCount++;
|
|
99
|
+
if (callCount === 1) {
|
|
100
|
+
await new Promise<void>((r) => {
|
|
101
|
+
resolve = r;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
queue.push({ value: "a" });
|
|
107
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
108
|
+
|
|
109
|
+
// Push while processing - process() will be called but should no-op
|
|
110
|
+
queue.push({ value: "b" });
|
|
111
|
+
expect(queue.isProcessing).toBe(true);
|
|
112
|
+
|
|
113
|
+
resolve!();
|
|
114
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
115
|
+
// Both items should have been processed
|
|
116
|
+
expect(callCount).toBe(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("continues processing after handler throws", async () => {
|
|
120
|
+
const queue = new Queue<TestItem>();
|
|
121
|
+
const results: string[] = [];
|
|
122
|
+
queue.setHandler(async (item) => {
|
|
123
|
+
if (item.value === "bad") throw new Error("handler failed");
|
|
124
|
+
results.push(item.value);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
queue.push({ value: "first" });
|
|
128
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
129
|
+
|
|
130
|
+
queue.push({ value: "bad" });
|
|
131
|
+
queue.push({ value: "third" });
|
|
132
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
133
|
+
|
|
134
|
+
expect(results).toEqual(["first", "third"]);
|
|
135
|
+
expect(queue.isProcessing).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("passes all fields through in queue item", async () => {
|
|
139
|
+
const queue = new Queue<TestItem>();
|
|
140
|
+
let received: TestItem | undefined;
|
|
141
|
+
queue.setHandler(async (item) => {
|
|
142
|
+
received = item;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
queue.push({ value: "test", extra: 42 });
|
|
146
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
147
|
+
|
|
148
|
+
expect(received).toEqual({ value: "test", extra: 42 });
|
|
149
|
+
});
|
|
150
|
+
});
|
package/src/queue.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createLogger } from "./logger";
|
|
2
|
+
|
|
3
|
+
const log = createLogger("queue");
|
|
4
|
+
|
|
5
|
+
export class Queue<T> {
|
|
6
|
+
#items: T[] = [];
|
|
7
|
+
#processing = false;
|
|
8
|
+
#handler: ((item: T) => Promise<void>) | null = null;
|
|
9
|
+
|
|
10
|
+
setHandler(fn: (item: T) => Promise<void>) {
|
|
11
|
+
this.#handler = fn;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
push(item: T) {
|
|
15
|
+
this.#items.push(item);
|
|
16
|
+
this.process();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async process() {
|
|
20
|
+
if (this.#processing || !this.#handler) return;
|
|
21
|
+
this.#processing = true;
|
|
22
|
+
|
|
23
|
+
while (this.#items.length > 0) {
|
|
24
|
+
const item = this.#items.shift() as T;
|
|
25
|
+
try {
|
|
26
|
+
await this.#handler(item);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
log.error({ err }, "Handler error");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.#processing = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get length() {
|
|
36
|
+
return this.#items.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get isProcessing() {
|
|
40
|
+
return this.#processing;
|
|
41
|
+
}
|
|
42
|
+
}
|