macroclaw 0.27.0 → 0.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.test.ts CHANGED
@@ -166,7 +166,7 @@ describe("App", () => {
166
166
  await new Promise((r) => setTimeout(r, 50));
167
167
 
168
168
  const claude = config.claude as Claude & { calls: CallInfo[] };
169
- expect(claude.calls[0].prompt).toBe("hello");
169
+ expect(claude.calls[0].prompt).toContain("<text>hello</text>");
170
170
  });
171
171
 
172
172
  it("ignores messages from unauthorized chats", async () => {
@@ -219,7 +219,7 @@ describe("App", () => {
219
219
  await new Promise((r) => setTimeout(r, 50));
220
220
 
221
221
  const claude = config.claude as Claude & { calls: CallInfo[] };
222
- expect(claude.calls[0].prompt).toBe("bg: research pricing");
222
+ expect(claude.calls[0].prompt).toContain("<text>bg: research pricing</text>");
223
223
  });
224
224
 
225
225
  it("sends error wrapped in response", async () => {
@@ -269,7 +269,7 @@ describe("App", () => {
269
269
  expect(bot.api.getFile).toHaveBeenCalledWith("large");
270
270
  const claude = config.claude as Claude & { calls: CallInfo[] };
271
271
  expect(claude.calls).toHaveLength(1);
272
- expect(claude.calls[0].prompt).toContain("[File:");
272
+ expect(claude.calls[0].prompt).toContain("<file path=");
273
273
 
274
274
  globalThis.fetch = origFetch;
275
275
  });
@@ -297,7 +297,7 @@ describe("App", () => {
297
297
  expect(bot.api.getFile).toHaveBeenCalledWith("doc-id");
298
298
  const claude = config.claude as Claude & { calls: CallInfo[] };
299
299
  expect(claude.calls).toHaveLength(1);
300
- expect(claude.calls[0].prompt).toContain("[File:");
300
+ expect(claude.calls[0].prompt).toContain("<file path=");
301
301
 
302
302
  globalThis.fetch = origFetch;
303
303
  });
@@ -363,7 +363,7 @@ describe("App", () => {
363
363
 
364
364
  const claude = config.claude as Claude & { calls: CallInfo[] };
365
365
  expect(claude.calls).toHaveLength(1);
366
- expect(claude.calls[0].prompt).toBe("hello from voice");
366
+ expect(claude.calls[0].prompt).toContain("<text>hello from voice</text>");
367
367
 
368
368
  globalThis.fetch = origFetch;
369
369
  });
@@ -532,7 +532,7 @@ describe("App", () => {
532
532
  expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Yes", callback_data: "_noop" }]] } });
533
533
  const claude = config.claude as Claude & { calls: CallInfo[] };
534
534
  expect(claude.calls).toHaveLength(1);
535
- expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
535
+ expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
536
536
  });
537
537
 
538
538
  it("handles _dismiss callback by removing reply markup", async () => {
@@ -688,7 +688,7 @@ describe("App", () => {
688
688
 
689
689
  expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
690
690
  const calls = (bot.api.sendMessage as any).mock.calls;
691
- expect(calls[calls.length - 1][1]).toBe("Usage: /bg <prompt>");
691
+ expect(calls[calls.length - 1][1]).toBe("Usage: /bg &lt;prompt&gt;");
692
692
  });
693
693
 
694
694
  it("/bg with prompt spawns a background agent via sendMessage", async () => {
package/src/app.ts CHANGED
@@ -43,7 +43,7 @@ export class App {
43
43
  start() {
44
44
  log.info("Starting macroclaw...");
45
45
  const scheduler = new Scheduler(this.#config.workspace, {
46
- onJob: (name, prompt, model) => this.#orchestrator.handleCron(name, prompt, model),
46
+ onJob: (name, prompt, model, missed) => this.#orchestrator.handleCron(name, prompt, model, missed),
47
47
  });
48
48
  scheduler.start();
49
49
  this.#bot.api.setMyCommands([
@@ -78,7 +78,7 @@ export class App {
78
78
  const prompt = ctx.match?.trim();
79
79
  if (!prompt) {
80
80
  log.debug("Command /bg without prompt");
81
- sendResponse(this.#bot, this.#config.authorizedChatId, "Usage: /bg <prompt>");
81
+ sendResponse(this.#bot, this.#config.authorizedChatId, "Usage: /bg &lt;prompt&gt;");
82
82
  return;
83
83
  }
84
84
  log.debug({ prompt }, "Command /bg spawn");
package/src/claude.ts CHANGED
@@ -123,7 +123,7 @@ export class Claude {
123
123
  if (systemPrompt) args.push("--append-system-prompt", systemPrompt);
124
124
  args.push(prompt);
125
125
 
126
- log.debug({ model, sessionId, promptLen: prompt.length, mode: mode.kind, hasSystemPrompt: !!systemPrompt }, "Sending to Claude");
126
+ log.debug({ model, sessionId, promptLen: prompt.length, mode: mode.kind, hasSystemPrompt: !!systemPrompt, prompt }, "Sending to Claude");
127
127
 
128
128
  const proc = Bun.spawn(args, { cwd: this.#workspace, env, stdout: "pipe", stderr: "pipe" });
129
129
  const startedAt = new Date();
package/src/index.ts CHANGED
@@ -8,6 +8,10 @@ import { SpeechToText } from "./speech-to-text";
8
8
  export async function start(): Promise<void> {
9
9
  const log = createLogger("index");
10
10
 
11
+ process.on("unhandledRejection", (err) => {
12
+ log.error({ err }, "Unhandled rejection");
13
+ });
14
+
11
15
  const mgr = new SettingsManager();
12
16
  const settings = mgr.load();
13
17
  const { settings: resolved, overrides } = mgr.applyEnvOverrides(settings);
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateName } from "./naming";
3
+
4
+ describe("generateName", () => {
5
+ test("extracts content words from English prompt", () => {
6
+ expect(generateName("please research the best coffee shops in Prague")).toBe("research-best-coffee-shops");
7
+ });
8
+
9
+ test("extracts content words from Czech prompt", () => {
10
+ expect(generateName("najdi nejlepsi kavárny v Praze a porovnej ceny")).toBe("najdi-nejlepsi-kavrny-praze");
11
+ });
12
+
13
+ test("returns 'task' for stop-words-only prompt", () => {
14
+ expect(generateName("please do this for me")).toBe("task");
15
+ });
16
+
17
+ test("returns 'task' for empty prompt", () => {
18
+ expect(generateName("")).toBe("task");
19
+ });
20
+
21
+ test("strips non-alphanumeric characters", () => {
22
+ expect(generateName("fix bug #123 in auth-service!")).toBe("fix-bug-123-auth");
23
+ });
24
+
25
+ test("respects maxWords parameter", () => {
26
+ expect(generateName("deploy new redis cluster with monitoring", 2)).toBe("deploy-new");
27
+ });
28
+
29
+ test("skips single-character words", () => {
30
+ expect(generateName("a b c deploy x y z")).toBe("deploy");
31
+ });
32
+
33
+ test("handles mixed English and Czech", () => {
34
+ expect(generateName("zkontroluj jestli je deploy hotovy")).toBe("zkontroluj-jestli-deploy-hotovy");
35
+ });
36
+
37
+ test("respects maxLength and drops words that would exceed it", () => {
38
+ expect(generateName("deploy infrastructure monitoring cluster", 4, 25)).toBe("deploy-infrastructure");
39
+ });
40
+
41
+ test("returns 'task' for very long single nonsense word", () => {
42
+ expect(generateName("a".repeat(500))).toBe("task");
43
+ });
44
+ });
package/src/naming.ts ADDED
@@ -0,0 +1,54 @@
1
+ /** Generates a short kebab-case name from a prompt by extracting content words. */
2
+
3
+ const STOP_WORDS = new Set([
4
+ // English
5
+ "a", "about", "above", "after", "again", "all", "also", "am", "an", "and",
6
+ "any", "are", "as", "at", "be", "because", "been", "before", "being",
7
+ "between", "both", "but", "by", "can", "could", "did", "do", "does",
8
+ "doing", "down", "during", "each", "few", "for", "from", "further", "get",
9
+ "got", "had", "has", "have", "having", "he", "her", "here", "hers",
10
+ "herself", "him", "himself", "his", "how", "i", "if", "in", "into", "is",
11
+ "it", "its", "itself", "just", "know", "let", "like", "make", "may", "me",
12
+ "might", "more", "most", "must", "my", "myself", "need", "no", "nor", "not",
13
+ "now", "of", "off", "on", "once", "only", "or", "other", "our", "ours",
14
+ "ourselves", "out", "over", "own", "please", "really", "right", "same",
15
+ "shall", "she", "should", "so", "some", "such", "take", "than", "that",
16
+ "the", "their", "theirs", "them", "themselves", "then", "there", "these",
17
+ "they", "this", "those", "through", "to", "too", "under", "until", "up",
18
+ "us", "very", "want", "was", "we", "were", "what", "when", "where", "which",
19
+ "while", "who", "whom", "why", "will", "with", "would", "you", "your",
20
+ "yours", "yourself", "yourselves",
21
+ // Czech
22
+ "a", "aby", "aj", "ale", "ani", "ano", "asi", "az", "bez", "bude", "budem",
23
+ "budes", "by", "byl", "byla", "byli", "bylo", "byt", "ci", "co", "dal",
24
+ "dane", "do", "ho", "i", "ja", "jak", "jako", "je", "jeho", "jej", "jeji",
25
+ "jen", "jeste", "ji", "jich", "jim", "jine", "jiz", "jsem", "jses", "jsi",
26
+ "jsme", "jsou", "jste", "k", "kam", "kde", "kdo", "kdyz", "ke", "ktera",
27
+ "ktere", "kteri", "kterou", "ktery", "ma", "mam", "mate", "me", "mezi",
28
+ "mi", "mit", "mne", "mnou", "moc", "moje", "moji", "mu", "muze", "my",
29
+ "na", "nad", "nam", "nami", "nas", "nasi", "ne", "nebo", "nebot", "necht",
30
+ "nejsou", "neni", "nez", "nic", "nim", "o", "od", "on", "ona", "oni",
31
+ "ono", "pak", "po", "pod", "podle", "pokud", "potom", "pouze", "prave",
32
+ "pro", "proc", "proto", "protoze", "prvni", "pred", "presto", "pri", "sam",
33
+ "se", "si", "sice", "sve", "svou", "svuj", "svych", "svym", "svymi", "ta",
34
+ "tak", "take", "takze", "tato", "te", "tedy", "ten", "tento", "ti", "tim",
35
+ "to", "toho", "tohle", "tom", "tomu", "tu", "tuto", "tvuj", "ty", "tyto",
36
+ "u", "uz", "v", "vam", "vas", "vase", "ve", "vsak", "vse", "vsech",
37
+ "vsechno", "vsichni", "z", "za", "zda", "zde", "ze",
38
+ ]);
39
+
40
+ export function generateName(prompt: string, maxWords = 4, maxLength = 40): string {
41
+ const words = prompt
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9\s-]/g, "")
44
+ .replace(/-/g, " ")
45
+ .split(/\s+/)
46
+ .filter((w) => w.length > 1 && !STOP_WORDS.has(w));
47
+ let name = "";
48
+ for (const word of words.slice(0, maxWords)) {
49
+ const candidate = name ? `${name}-${word}` : word;
50
+ if (candidate.length > maxLength) break;
51
+ name = candidate;
52
+ }
53
+ return name || "task";
54
+ }
@@ -103,7 +103,8 @@ describe("Orchestrator", () => {
103
103
  orch.handleMessage("hello");
104
104
  await waitForProcessing();
105
105
 
106
- expect(claude.calls[0].prompt).toBe("hello");
106
+ expect(claude.calls[0].prompt).toContain('type="user-message"');
107
+ expect(claude.calls[0].prompt).toContain("<text>hello</text>");
107
108
  });
108
109
 
109
110
  it("prepends file references for user requests", async () => {
@@ -113,7 +114,9 @@ describe("Orchestrator", () => {
113
114
  orch.handleMessage("check this", ["/tmp/photo.jpg", "/tmp/doc.pdf"]);
114
115
  await waitForProcessing();
115
116
 
116
- expect(claude.calls[0].prompt).toBe("[File: /tmp/photo.jpg]\n[File: /tmp/doc.pdf]\ncheck this");
117
+ expect(claude.calls[0].prompt).toContain('<file path="/tmp/photo.jpg" />');
118
+ expect(claude.calls[0].prompt).toContain('<file path="/tmp/doc.pdf" />');
119
+ expect(claude.calls[0].prompt).toContain("<text>check this</text>");
117
120
  });
118
121
 
119
122
  it("sends only file references when message is empty", async () => {
@@ -123,7 +126,8 @@ describe("Orchestrator", () => {
123
126
  orch.handleMessage("", ["/tmp/photo.jpg"]);
124
127
  await waitForProcessing();
125
128
 
126
- expect(claude.calls[0].prompt).toBe("[File: /tmp/photo.jpg]");
129
+ expect(claude.calls[0].prompt).toContain('<file path="/tmp/photo.jpg" />');
130
+ expect(claude.calls[0].prompt).not.toContain("<text>");
127
131
  });
128
132
 
129
133
  it("builds button click prompt", async () => {
@@ -133,7 +137,7 @@ describe("Orchestrator", () => {
133
137
  orch.handleButton("Yes");
134
138
  await waitForProcessing();
135
139
 
136
- expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
140
+ expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
137
141
  });
138
142
  });
139
143
 
@@ -338,9 +342,8 @@ describe("Orchestrator", () => {
338
342
  expect(callCount).toBe(2);
339
343
  const secondCall = claude.calls[1];
340
344
  expect(secondCall.method).toBe("forkSession");
341
- expect(secondCall.prompt).toContain("[Context: previous task");
342
- expect(secondCall.prompt).toContain("moved to background]");
343
- expect(secondCall.prompt).toContain("second");
345
+ expect(secondCall.prompt).toContain("<backgrounded-event");
346
+ expect(secondCall.prompt).toContain("<text>second</text>");
344
347
 
345
348
  // Resolve both
346
349
  resolve2(queryResult({ action: "send", message: "second done", actionReason: "ok" }, "q2-sid"));
@@ -405,7 +408,7 @@ describe("Orchestrator", () => {
405
408
 
406
409
  expect(callCount).toBe(2);
407
410
  const userCall = claude.calls[1];
408
- expect(userCall.prompt).toContain("[Context: previous task");
411
+ expect(userCall.prompt).toContain("<backgrounded-event");
409
412
  expect(userCall.prompt).toContain("follow up");
410
413
  expect(responses.map((r) => r.message)).toContain("forked result");
411
414
  });
@@ -451,7 +454,7 @@ describe("Orchestrator", () => {
451
454
  orch.handleButton("Yes");
452
455
  await waitForProcessing();
453
456
 
454
- expect(claude.calls[0].prompt).toBe('[Context: button-click] User tapped "Yes"');
457
+ expect(claude.calls[0].prompt).toContain('<button>Yes</button>');
455
458
  expect(responses[0].message).toBe("button response");
456
459
  });
457
460
 
@@ -503,8 +506,8 @@ describe("Orchestrator", () => {
503
506
  await waitForProcessing();
504
507
 
505
508
  expect(claude.calls[0].method).toBe("forkSession");
506
- expect(claude.calls[0].prompt).toContain("[Context: background-agent/cron-daily-check]");
507
- expect(claude.calls[0].prompt).toContain("[Context: cron/daily-check] Check for updates");
509
+ expect(claude.calls[0].prompt).toContain('<schedule name="daily-check" />');
510
+ expect(claude.calls[0].prompt).toContain("<text>Check for updates</text>");
508
511
  expect(claude.calls[0].model).toBe("haiku");
509
512
  });
510
513
 
@@ -8,7 +8,8 @@ import {
8
8
  } from "./claude";
9
9
  import { writeHistoryPrompt, writeHistoryResult } from "./history";
10
10
  import { createLogger } from "./logger";
11
- import { SYSTEM_PROMPT } from "./prompts";
11
+ import { generateName } from "./naming";
12
+ import { buildEvent, type EventInput, SYSTEM_PROMPT } from "./prompts";
12
13
  import { Queue } from "./queue";
13
14
  import { loadSessions, saveSessions } from "./sessions";
14
15
 
@@ -58,7 +59,6 @@ export interface OrchestratorResponse {
58
59
 
59
60
  type OrchestratorRequest =
60
61
  | { type: "user"; message: string; files?: string[] }
61
- | { type: "cron"; name: string; prompt: string; model?: string }
62
62
  | { type: "background-agent-result"; name: string; response: AgentOutput }
63
63
  | { type: "button"; label: string };
64
64
 
@@ -115,14 +115,20 @@ export class Orchestrator {
115
115
  this.#queue.push({ type: "button", label });
116
116
  }
117
117
 
118
- handleCron(name: string, prompt: string, model?: string): void {
118
+ handleCron(name: string, prompt: string, model?: string, missed?: { missedBy: string; scheduledAt: string }): void {
119
119
  const cronName = `cron-${name}`;
120
- const cronPrompt = `[Context: cron/${name}] ${prompt}`;
121
- this.#spawnBackground(cronName, cronPrompt, model ?? this.#config.model);
120
+ const formatted = buildEvent({
121
+ name: cronName,
122
+ type: "schedule-trigger",
123
+ session: "background",
124
+ schedule: { name, missedBy: missed?.missedBy, scheduledAt: missed?.scheduledAt },
125
+ text: prompt,
126
+ });
127
+ this.#spawnBackgroundRaw(cronName, prompt, formatted, model ?? this.#config.model);
122
128
  }
123
129
 
124
130
  handleBackgroundCommand(prompt: string): void {
125
- const name = prompt.slice(0, 30).replace(/\s+/g, "-");
131
+ const name = generateName(prompt);
126
132
  this.#spawnBackground(name, prompt, this.#config.model);
127
133
  this.#callOnResponse({ message: `Background agent "${escapeHtml(name)}" started.` });
128
134
  }
@@ -184,10 +190,16 @@ export class Orchestrator {
184
190
  this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(session.name)}</b>...` });
185
191
 
186
192
  try {
187
- const startedAt = session.query.startedAt.toISOString();
193
+ const prompt = buildEvent({
194
+ name: `peek-${session.name}`,
195
+ type: "peek",
196
+ session: "background",
197
+ targetEvent: session.name,
198
+ instructions: `Only consider progress since the "${session.name}" event. Brief status update: done, in progress, remaining. 2-3 sentences max, plain text.`,
199
+ });
188
200
  const query = this.#claude.forkSession(
189
201
  sessionId,
190
- `This session started at ${startedAt}. Only consider events after that time. Give a brief status update: what has been done so far, what's currently happening, and what's remaining. 2-3 sentences max.`,
202
+ prompt,
191
203
  textResultType,
192
204
  { model: "haiku" },
193
205
  );
@@ -249,13 +261,12 @@ export class Orchestrator {
249
261
 
250
262
  await writeHistoryPrompt(request);
251
263
 
252
- let prompt = this.#formatPrompt(request);
253
- if (movedToBackground) {
254
- const truncated = movedToBackground.length > 100 ? `${movedToBackground.slice(0, 100)}...` : movedToBackground;
255
- prompt = `[Context: previous task "${truncated}" moved to background]\n${prompt}`;
256
- }
264
+ const label = Orchestrator.#requestLabel(request);
265
+ const name = generateName(label);
266
+ const backgroundedName = movedToBackground ? mainInfo?.name : undefined;
267
+ const prompt = this.#formatPrompt(request, name, backgroundedName);
257
268
 
258
- this.#startMainQuery(prompt, this.#config.model);
269
+ this.#startMainQuery(name, prompt, this.#config.model);
259
270
  }
260
271
 
261
272
  // --- Response delivery ---
@@ -289,7 +300,7 @@ export class Orchestrator {
289
300
 
290
301
  // --- Main session query ---
291
302
 
292
- #startMainQuery(prompt: string, model: string | undefined): void {
303
+ #startMainQuery(name: string, prompt: string, model: string | undefined): void {
293
304
  const opts = { model };
294
305
  let query: RunningQuery<AgentOutput>;
295
306
 
@@ -302,7 +313,6 @@ export class Orchestrator {
302
313
  }
303
314
 
304
315
  const sid = query.sessionId;
305
- const name = prompt.slice(0, 30).replace(/\s+/g, "-");
306
316
  this.#runningSessions.set(sid, { name, prompt, model, query, lastMessageAt: new Date() });
307
317
 
308
318
  if (sid !== this.#mainSessionId) {
@@ -342,19 +352,56 @@ export class Orchestrator {
342
352
  );
343
353
  }
344
354
 
345
- #formatPrompt(request: OrchestratorRequest): string {
355
+ #formatPrompt(request: OrchestratorRequest, name: string, backgroundedEvent?: string): string {
356
+ let input: EventInput;
357
+
346
358
  switch (request.type) {
347
- case "user": {
348
- if (!request.files?.length) return request.message;
349
- const prefix = request.files.map((f) => `[File: ${f}]`).join("\n");
350
- return request.message ? `${prefix}\n${request.message}` : prefix;
351
- }
352
- case "cron":
353
- return `[Context: cron/${request.name}] ${request.prompt}`;
359
+ case "user":
360
+ input = {
361
+ name,
362
+ type: "user-message",
363
+ session: "main",
364
+ text: request.message || undefined,
365
+ files: request.files,
366
+ backgroundedEvent,
367
+ };
368
+ break;
354
369
  case "background-agent-result":
355
- return `[Context: background-result/${request.name}] ${request.response.message || "[No output]"}`;
370
+ input = {
371
+ name,
372
+ type: "background-agent-result",
373
+ session: "main",
374
+ originalEvent: request.name,
375
+ result: {
376
+ text: request.response.message || "[No output]",
377
+ files: request.response.files,
378
+ },
379
+ backgroundedEvent,
380
+ instructions: "Forward this result to the user (action=\"send\"). Summarize or add context from the conversation as appropriate.",
381
+ };
382
+ break;
356
383
  case "button":
357
- return `[Context: button-click] User tapped "${request.label}"`;
384
+ input = {
385
+ name,
386
+ type: "button-click",
387
+ session: "main",
388
+ button: request.label,
389
+ backgroundedEvent,
390
+ };
391
+ break;
392
+ }
393
+
394
+ return buildEvent(input);
395
+ }
396
+
397
+ static #requestLabel(request: OrchestratorRequest): string {
398
+ switch (request.type) {
399
+ case "user":
400
+ return request.message;
401
+ case "background-agent-result":
402
+ return `bg:${request.name}`;
403
+ case "button":
404
+ return `btn:${request.label}`;
358
405
  }
359
406
  }
360
407
 
@@ -376,10 +423,19 @@ export class Orchestrator {
376
423
  // --- Background management ---
377
424
 
378
425
  #spawnBackground(name: string, prompt: string, model: string | undefined) {
379
- const bgPrompt = `[Context: background-agent/${name}] ${prompt}`;
426
+ const formatted = buildEvent({
427
+ name,
428
+ type: "background-agent-start",
429
+ session: "background",
430
+ text: prompt,
431
+ });
432
+ this.#spawnBackgroundRaw(name, prompt, formatted, model);
433
+ }
434
+
435
+ #spawnBackgroundRaw(name: string, prompt: string, formatted: string, model: string | undefined) {
380
436
  const query = this.#mainSessionId
381
- ? this.#claude.forkSession(this.#mainSessionId, bgPrompt, responseResultType, { model })
382
- : this.#claude.newSession(bgPrompt, responseResultType, { model });
437
+ ? this.#claude.forkSession(this.#mainSessionId, formatted, responseResultType, { model })
438
+ : this.#claude.newSession(formatted, responseResultType, { model });
383
439
  this.#registerBackground(name, prompt, model, query);
384
440
  }
385
441
 
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { SYSTEM_PROMPT } from "./prompts";
2
+ import { buildEvent, escapeXml, SYSTEM_PROMPT } from "./prompts";
3
3
 
4
4
  describe("SYSTEM_PROMPT", () => {
5
5
  it("contains key sections", () => {
6
6
  expect(SYSTEM_PROMPT).toContain("macroclaw");
7
7
  expect(SYSTEM_PROMPT).toContain("Structured output");
8
- expect(SYSTEM_PROMPT).toContain("Context tags");
8
+ expect(SYSTEM_PROMPT).toContain("Event format");
9
9
  expect(SYSTEM_PROMPT).toContain("Background agents");
10
10
  expect(SYSTEM_PROMPT).toContain("Cron");
11
11
  expect(SYSTEM_PROMPT).toContain("Buttons");
@@ -18,11 +18,19 @@ describe("SYSTEM_PROMPT", () => {
18
18
  expect(SYSTEM_PROMPT).toContain("<b>");
19
19
  });
20
20
 
21
- it("documents all context tag types", () => {
22
- expect(SYSTEM_PROMPT).toContain("cron/<name>");
21
+ it("documents all event types", () => {
22
+ expect(SYSTEM_PROMPT).toContain("user-message");
23
23
  expect(SYSTEM_PROMPT).toContain("button-click");
24
- expect(SYSTEM_PROMPT).toContain("background-result/<name>");
25
- expect(SYSTEM_PROMPT).toContain("background-agent/<name>");
24
+ expect(SYSTEM_PROMPT).toContain("schedule-trigger");
25
+ expect(SYSTEM_PROMPT).toContain("background-agent-start");
26
+ expect(SYSTEM_PROMPT).toContain("background-agent-result");
27
+ expect(SYSTEM_PROMPT).toContain("peek");
28
+ });
29
+
30
+ it("documents backgrounded events", () => {
31
+ expect(SYSTEM_PROMPT).toContain("backgrounded-event");
32
+ expect(SYSTEM_PROMPT).toContain("moved to background");
33
+ expect(SYSTEM_PROMPT).toContain("Do not re-execute");
26
34
  });
27
35
 
28
36
  it("contains structured output reinforcement", () => {
@@ -41,3 +49,243 @@ describe("SYSTEM_PROMPT", () => {
41
49
  expect(SYSTEM_PROMPT).toContain("opus");
42
50
  });
43
51
  });
52
+
53
+ describe("escapeXml", () => {
54
+ it("escapes &, <, >, \"", () => {
55
+ expect(escapeXml('a & b < c > d "e"')).toBe("a &amp; b &lt; c &gt; d &quot;e&quot;");
56
+ });
57
+
58
+ it("returns plain text unchanged", () => {
59
+ expect(escapeXml("hello world")).toBe("hello world");
60
+ });
61
+ });
62
+
63
+ describe("buildEvent", () => {
64
+ it("builds user message event", () => {
65
+ const result = buildEvent({
66
+ name: "check-logs",
67
+ type: "user-message",
68
+ session: "main",
69
+ text: "hello",
70
+ });
71
+ expect(result).toStartWith('<event name="check-logs" type="user-message" session="main">');
72
+ expect(result).toContain("<text>hello</text>");
73
+ expect(result).toEndWith("</event>");
74
+ });
75
+
76
+ it("builds user message with files", () => {
77
+ const result = buildEvent({
78
+ name: "analyze-photo",
79
+ type: "user-message",
80
+ session: "main",
81
+ text: "what's in this image?",
82
+ files: ["/tmp/photo.jpg", "/tmp/doc.pdf"],
83
+ });
84
+ expect(result).toContain("<text>what's in this image?</text>");
85
+ expect(result).toContain("<files>");
86
+ expect(result).toContain('<file path="/tmp/photo.jpg" />');
87
+ expect(result).toContain('<file path="/tmp/doc.pdf" />');
88
+ expect(result).toContain("</files>");
89
+ });
90
+
91
+ it("builds user message with files only (no text)", () => {
92
+ const result = buildEvent({
93
+ name: "task",
94
+ type: "user-message",
95
+ session: "main",
96
+ files: ["/tmp/photo.jpg"],
97
+ });
98
+ expect(result).not.toContain("<text>");
99
+ expect(result).toContain('<file path="/tmp/photo.jpg" />');
100
+ });
101
+
102
+ it("builds user message with backgrounded event", () => {
103
+ const result = buildEvent({
104
+ name: "check-logs",
105
+ type: "user-message",
106
+ session: "main",
107
+ backgroundedEvent: "deploy-cluster",
108
+ text: "check the logs",
109
+ });
110
+ expect(result).toContain('<backgrounded-event name="deploy-cluster" />');
111
+ expect(result).toContain("<text>check the logs</text>");
112
+ });
113
+
114
+ it("places backgrounded-event before text", () => {
115
+ const result = buildEvent({
116
+ name: "check-logs",
117
+ type: "user-message",
118
+ session: "main",
119
+ backgroundedEvent: "deploy",
120
+ text: "hello",
121
+ });
122
+ const bgIdx = result.indexOf("backgrounded-event");
123
+ const textIdx = result.indexOf("<text>");
124
+ expect(bgIdx).toBeLessThan(textIdx);
125
+ });
126
+
127
+ it("builds button click event", () => {
128
+ const result = buildEvent({
129
+ name: "btn-yes",
130
+ type: "button-click",
131
+ session: "main",
132
+ button: "Yes",
133
+ });
134
+ expect(result).toContain('type="button-click"');
135
+ expect(result).toContain("<button>Yes</button>");
136
+ expect(result).not.toContain("<text>");
137
+ });
138
+
139
+ it("builds button click with backgrounded event", () => {
140
+ const result = buildEvent({
141
+ name: "btn-yes",
142
+ type: "button-click",
143
+ session: "main",
144
+ button: "Yes",
145
+ backgroundedEvent: "deploy-cluster",
146
+ });
147
+ expect(result).toContain('<backgrounded-event name="deploy-cluster" />');
148
+ expect(result).toContain("<button>Yes</button>");
149
+ });
150
+
151
+ it("builds schedule trigger event", () => {
152
+ const result = buildEvent({
153
+ name: "cron-daily",
154
+ type: "schedule-trigger",
155
+ session: "background",
156
+ schedule: { name: "daily" },
157
+ text: "check updates",
158
+ });
159
+ expect(result).toContain('type="schedule-trigger"');
160
+ expect(result).toContain('session="background"');
161
+ expect(result).toContain('<schedule name="daily" />');
162
+ expect(result).toContain("<text>check updates</text>");
163
+ });
164
+
165
+ it("builds missed schedule trigger with attributes", () => {
166
+ const result = buildEvent({
167
+ name: "cron-reminder",
168
+ type: "schedule-trigger",
169
+ session: "background",
170
+ schedule: { name: "reminder", missedBy: "15m", scheduledAt: "2026-03-20T06:00:00Z" },
171
+ text: "buy milk",
172
+ });
173
+ expect(result).toContain('missed-by="15m"');
174
+ expect(result).toContain('scheduled-at="2026-03-20T06:00:00Z"');
175
+ expect(result).toContain("<text>buy milk</text>");
176
+ });
177
+
178
+ it("builds background agent start event", () => {
179
+ const result = buildEvent({
180
+ name: "research",
181
+ type: "background-agent-start",
182
+ session: "background",
183
+ text: "find papers about transformers",
184
+ });
185
+ expect(result).toContain('type="background-agent-start"');
186
+ expect(result).toContain('session="background"');
187
+ expect(result).toContain("<text>find papers about transformers</text>");
188
+ });
189
+
190
+ it("builds background agent result (text only)", () => {
191
+ const result = buildEvent({
192
+ name: "bg-research",
193
+ type: "background-agent-result",
194
+ session: "main",
195
+ originalEvent: "research",
196
+ result: { text: "found 3 papers" },
197
+ });
198
+ expect(result).toContain('type="background-agent-result"');
199
+ expect(result).toContain('<original-event name="research" />');
200
+ expect(result).toContain("<result>");
201
+ expect(result).toContain("<text>found 3 papers</text>");
202
+ expect(result).toContain("</result>");
203
+ expect(result).not.toContain("<files>");
204
+ });
205
+
206
+ it("builds background agent result with files", () => {
207
+ const result = buildEvent({
208
+ name: "bg-research",
209
+ type: "background-agent-result",
210
+ session: "main",
211
+ originalEvent: "research",
212
+ result: { text: "here are the screenshots", files: ["/tmp/screenshot.png"] },
213
+ });
214
+ expect(result).toContain("<result>");
215
+ expect(result).toContain("<text>here are the screenshots</text>");
216
+ expect(result).toContain('<file path="/tmp/screenshot.png" />');
217
+ expect(result).toContain("</result>");
218
+ });
219
+
220
+ it("builds peek event with instructions", () => {
221
+ const result = buildEvent({
222
+ name: "peek-deploy",
223
+ type: "peek",
224
+ session: "background",
225
+ targetEvent: "deploy",
226
+ instructions: "Brief status update.",
227
+ });
228
+ expect(result).toContain('type="peek"');
229
+ expect(result).toContain('<target-event name="deploy" />');
230
+ expect(result).toContain("<instructions>Brief status update.</instructions>");
231
+ expect(result).not.toContain("<text>");
232
+ });
233
+
234
+ it("includes instructions in event", () => {
235
+ const result = buildEvent({
236
+ name: "bg-research",
237
+ type: "background-agent-result",
238
+ session: "main",
239
+ originalEvent: "research",
240
+ result: { text: "done" },
241
+ instructions: "Forward to user.",
242
+ });
243
+ expect(result).toContain("<instructions>Forward to user.</instructions>");
244
+ // instructions come last, before </event>
245
+ const instrIdx = result.indexOf("<instructions>");
246
+ const closeIdx = result.indexOf("</event>");
247
+ expect(instrIdx).toBeLessThan(closeIdx);
248
+ expect(instrIdx).toBeGreaterThan(result.indexOf("</result>"));
249
+ });
250
+
251
+ it("escapes XML in text content", () => {
252
+ const result = buildEvent({
253
+ name: "test",
254
+ type: "user-message",
255
+ session: "main",
256
+ text: "a < b & c > d",
257
+ });
258
+ expect(result).toContain("<text>a &lt; b &amp; c &gt; d</text>");
259
+ });
260
+
261
+ it("escapes XML in name attribute", () => {
262
+ const result = buildEvent({
263
+ name: 'a & "b"',
264
+ type: "user-message",
265
+ session: "main",
266
+ text: "test",
267
+ });
268
+ expect(result).toContain('name="a &amp; &quot;b&quot;"');
269
+ });
270
+
271
+ it("escapes XML in button label", () => {
272
+ const result = buildEvent({
273
+ name: "btn",
274
+ type: "button-click",
275
+ session: "main",
276
+ button: 'a & "b"',
277
+ });
278
+ expect(result).toContain("<button>a &amp; &quot;b&quot;</button>");
279
+ });
280
+
281
+ it("escapes XML in backgrounded event name", () => {
282
+ const result = buildEvent({
283
+ name: "test",
284
+ type: "user-message",
285
+ session: "main",
286
+ backgroundedEvent: 'task & "stuff"',
287
+ text: "hello",
288
+ });
289
+ expect(result).toContain('backgrounded-event name="task &amp; &quot;stuff&quot;"');
290
+ });
291
+ });
package/src/prompts.ts CHANGED
@@ -15,28 +15,150 @@ Use raw <b>, <i>, <code>, <pre> tags. Escape &, <, > in text content as &amp;, &
15
15
  Architecture: message bridge connecting chat interface and scheduled tasks. \
16
16
  Persistent session — conversation history carries across messages. Workspace persists across sessions.
17
17
 
18
- Context tags: messages may be prefixed with [Context: <type>]. Types:
19
- - cron/<name>automated scheduled task. Prefer action="silent" when nothing noteworthy.
20
- - button-clickuser tapped an inline keyboard button.
21
- - background-result/<name>output from a background agent you spawned. Decide whether to relay or handle silently.
22
- - background-agent/<name>you are a background agent. Complete task, return result.
23
- - previous task "<prompt>" moved to background a long-running task was demoted. Mention briefly if relevant.
18
+ Event format: every incoming message is wrapped in an <event> XML block. Attributes:
19
+ - name — short identifier for this event (e.g. "check-logs", "cron-daily").
20
+ - typewhat triggered this event. One of:
21
+ - user-messagedirect user message. Content in <text>, optional <files>.
22
+ - button-clickuser tapped an inline button. Label in <button>.
23
+ - schedule-trigger — automated scheduled task. Contains <schedule> with name and optional missed-by/scheduled-at attributes. Prefer action="silent" when nothing noteworthy.
24
+ - background-agent-start — you are a background agent. Complete the task in <text> and return a result.
25
+ - background-agent-result — a background agent has finished. Contains <original-event name="..." /> linking to the agent that produced it, and a <result> block with <text> and optional <files>. Always use action="send" — the user expects to see the outcome. Summarize, relay, or add additional context from the conversation as appropriate.
26
+ - peek — status check on a running session. Contains <target-event name="..." /> identifying the event being peeked at. Only consider progress since that event started. Respond with a brief status update (2-3 sentences): what has been done, what's happening now, what's remaining. Return plain text, not structured output.
27
+ - session — "main" (primary conversation) or "background" (background agent).
28
+
29
+ Backgrounded events: when a new message arrives while a previous task is still running, \
30
+ the running task is automatically moved to a background session. The new event will contain \
31
+ a <backgrounded-event name="..." /> element referencing the task that was moved. \
32
+ This is informational — the backgrounded task continues running independently. \
33
+ Do not re-execute or act on the backgrounded task; focus on the new event's content.
34
+
35
+ Inner elements:
36
+ - <text> — the message text or task description.
37
+ - <files> — list of <file path="..." /> attachments. Read/view at those paths.
38
+ - <button> — the label of the tapped button.
39
+ - <schedule> — cron job metadata (name, missed-by, scheduled-at attributes).
40
+ - <backgrounded-event name="..." /> — a previously running task moved to background (see above).
41
+ - <original-event name="..." /> — in background-agent-result, links to the agent that produced the result.
42
+ - <target-event name="..." /> — in peek, identifies the event being checked on.
43
+ - <result> — wraps the output from a completed background agent. Contains <text> and optional <files>.
44
+ - <instructions> — inline guidance for how to handle this specific event. Always follow these instructions.
24
45
 
25
46
  Background agents: spawn alongside any response via backgroundAgents array:
26
47
  backgroundAgents: [{ name: "label", prompt: "task", model: "haiku" }]
27
- Each runs in same workspace, forked session. Result fed back as [Context: background-result/<name>].
48
+ Each runs in same workspace, forked session. Result fed back as background-agent-result event.
28
49
  Models: haiku (fast/cheap), sonnet (balanced, default), opus (complex reasoning).
29
50
  User can spawn directly with /bg command. Use for long-running tasks that shouldn't block.
30
51
 
31
52
  Session routing: if a new message arrives while your session is busy for over 1 minute, \
32
53
  the running task is automatically moved to background and a new session is forked. \
33
- You may see a [Context: previous task "..." moved to background] prefix when this happens.
54
+ The new event will contain a <backgrounded-event> element (see above).
34
55
 
35
- Files: attachments listed as [File: /path] prefixes. Read/view at those paths. \
36
- Send files via files array (absolute paths). Images (.png/.jpg/.jpeg/.gif/.webp) as photos, rest as documents. 50MB limit.
56
+ Files: send files via files array (absolute paths). \
57
+ Images (.png/.jpg/.jpeg/.gif/.webp) as photos, rest as documents. 50MB limit.
37
58
 
38
59
  Cron: jobs in data/schedule.json (hot-reloaded). Cron jobs always run as background sessions. \
39
60
  Use "silent" when check finds nothing new, "send" when noteworthy.
40
61
 
41
62
  MessageButtons: include a buttons field (flat array of label strings) to attach inline buttons below your message. \
42
63
  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.`;
64
+
65
+ // --- Event builder ---
66
+
67
+ export function escapeXml(text: string): string {
68
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
69
+ }
70
+
71
+ export type SessionType = "main" | "background";
72
+
73
+ export type EventType =
74
+ | "user-message"
75
+ | "button-click"
76
+ | "schedule-trigger"
77
+ | "background-agent-start"
78
+ | "background-agent-result"
79
+ | "peek";
80
+
81
+ export interface EventInput {
82
+ name: string;
83
+ type: EventType;
84
+ session: SessionType;
85
+ text?: string;
86
+ files?: string[];
87
+ button?: string;
88
+ schedule?: { name: string; missedBy?: string; scheduledAt?: string };
89
+ backgroundedEvent?: string;
90
+ originalEvent?: string;
91
+ targetEvent?: string;
92
+ instructions?: string;
93
+ result?: { text: string; files?: string[] };
94
+ }
95
+
96
+ export function buildEvent(input: EventInput): string {
97
+ const lines: string[] = [
98
+ `<event name="${escapeXml(input.name)}" type="${input.type}" session="${input.session}">`,
99
+ ];
100
+
101
+ // Backgrounded event (always before content for visibility)
102
+ if (input.backgroundedEvent) {
103
+ lines.push(`<backgrounded-event name="${escapeXml(input.backgroundedEvent)}" />`);
104
+ }
105
+
106
+ // Schedule metadata
107
+ if (input.schedule) {
108
+ const attrs = [`name="${escapeXml(input.schedule.name)}"`];
109
+ if (input.schedule.missedBy) attrs.push(`missed-by="${escapeXml(input.schedule.missedBy)}"`);
110
+ if (input.schedule.scheduledAt) attrs.push(`scheduled-at="${escapeXml(input.schedule.scheduledAt)}"`);
111
+ lines.push(`<schedule ${attrs.join(" ")} />`);
112
+ }
113
+
114
+ // Original event reference (for background-agent-result)
115
+ if (input.originalEvent) {
116
+ lines.push(`<original-event name="${escapeXml(input.originalEvent)}" />`);
117
+ }
118
+
119
+ // Target event reference (for peek)
120
+ if (input.targetEvent) {
121
+ lines.push(`<target-event name="${escapeXml(input.targetEvent)}" />`);
122
+ }
123
+
124
+ // Result block (for background-agent-result)
125
+ if (input.result) {
126
+ lines.push("<result>");
127
+ lines.push(`<text>${escapeXml(input.result.text)}</text>`);
128
+ if (input.result.files?.length) {
129
+ lines.push("<files>");
130
+ for (const f of input.result.files) {
131
+ lines.push(` <file path="${escapeXml(f)}" />`);
132
+ }
133
+ lines.push("</files>");
134
+ }
135
+ lines.push("</result>");
136
+ }
137
+
138
+ // Button label
139
+ if (input.button) {
140
+ lines.push(`<button>${escapeXml(input.button)}</button>`);
141
+ }
142
+
143
+ // Text content
144
+ if (input.text) {
145
+ lines.push(`<text>${escapeXml(input.text)}</text>`);
146
+ }
147
+
148
+ // Files
149
+ if (input.files?.length) {
150
+ lines.push("<files>");
151
+ for (const f of input.files) {
152
+ lines.push(` <file path="${escapeXml(f)}" />`);
153
+ }
154
+ lines.push("</files>");
155
+ }
156
+
157
+ // Instructions (inline guidance for long sessions)
158
+ if (input.instructions) {
159
+ lines.push(`<instructions>${escapeXml(input.instructions)}</instructions>`);
160
+ }
161
+
162
+ lines.push("</event>");
163
+ return lines.join("\n");
164
+ }
@@ -17,7 +17,7 @@ function readScheduleConfig() {
17
17
  }
18
18
 
19
19
  function makeOnJob() {
20
- return mock((_name: string, _prompt: string, _model?: string) => {});
20
+ return mock((_name: string, _prompt: string, _model?: string, _missed?: { missedBy: string; scheduledAt: string }) => {});
21
21
  }
22
22
 
23
23
  // Build a cron expression that matches the current minute
@@ -169,6 +169,7 @@ describe("Scheduler — fireAt jobs", () => {
169
169
  s.stop();
170
170
 
171
171
  expect(onJob).toHaveBeenCalledWith("now", "do it", undefined);
172
+ expect(onJob.mock.calls[0][3]).toBeUndefined();
172
173
  });
173
174
 
174
175
  it("removes one-shot job after firing", () => {
@@ -220,9 +221,10 @@ describe("Scheduler — fireAt jobs", () => {
220
221
  expect(onJob).toHaveBeenCalledTimes(1);
221
222
  const call = onJob.mock.calls[0];
222
223
  expect(call[0]).toBe("reminder");
223
- expect(call[1]).toContain("[missed event, should have fired");
224
- expect(call[1]).toContain("min ago at");
225
- expect(call[1]).toContain("buy milk");
224
+ expect(call[1]).toBe("buy milk");
225
+ expect(call[3]).toBeDefined();
226
+ expect(call[3]!.missedBy).toMatch(/^\d+m$/);
227
+ expect(call[3]!.scheduledAt).toBeDefined();
226
228
  });
227
229
 
228
230
  it("removes missed one-shot job after firing", () => {
@@ -257,8 +259,9 @@ describe("Scheduler — fireAt jobs", () => {
257
259
  expect(onJob).toHaveBeenCalledTimes(1);
258
260
  const call = onJob.mock.calls[0];
259
261
  expect(call[0]).toBe("recent");
260
- expect(call[1]).toContain("[missed event, should have fired");
261
- expect(call[1]).toContain("still valid");
262
+ expect(call[1]).toBe("still valid");
263
+ expect(call[3]).toBeDefined();
264
+ expect(call[3]!.missedBy).toMatch(/^\d+m$/);
262
265
  });
263
266
 
264
267
  it("discards stale one-shot job (older than a week) without firing", () => {
package/src/scheduler.ts CHANGED
@@ -21,8 +21,13 @@ const scheduleConfigSchema = z.object({
21
21
  type ScheduleConfig = z.infer<typeof scheduleConfigSchema>;
22
22
  type Job = z.infer<typeof jobSchema>;
23
23
 
24
+ export interface MissedInfo {
25
+ missedBy: string;
26
+ scheduledAt: string;
27
+ }
28
+
24
29
  export interface SchedulerConfig {
25
- onJob: (name: string, prompt: string, model?: string) => void;
30
+ onJob: (name: string, prompt: string, model?: string, missed?: MissedInfo) => void;
26
31
  }
27
32
 
28
33
  const TICK_INTERVAL = 10_000; // 10 seconds
@@ -148,9 +153,11 @@ export class Scheduler {
148
153
 
149
154
  if (diff <= MAX_MISSED_MS) {
150
155
  const missedMinutes = Math.round(diff / 60_000);
151
- const missedPrompt = `[missed event, should have fired ${missedMinutes} min ago at ${job.fireAt}] ${job.prompt}`;
152
156
  log.info({ name: job.name, missedMinutes, fireAt: job.fireAt }, "Firing missed one-shot job");
153
- this.#config.onJob(job.name, missedPrompt, job.model);
157
+ this.#config.onJob(job.name, job.prompt, job.model, {
158
+ missedBy: `${missedMinutes}m`,
159
+ scheduledAt: job.fireAt,
160
+ });
154
161
  return "remove";
155
162
  }
156
163