macroclaw 0.27.0 → 0.29.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.29.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
 
@@ -917,6 +920,154 @@ describe("Orchestrator", () => {
917
920
  });
918
921
  });
919
922
 
923
+ describe("health checks", () => {
924
+ it("runs health check after interval and reports finished agent", async () => {
925
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
926
+ const { query: bgQuery } = pendingQuery("bg-sid");
927
+
928
+ let callCount = 0;
929
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
930
+ callCount++;
931
+ if (callCount === 1) return bgQuery; // background agent spawn
932
+ if (info.prompt.includes("health-check")) {
933
+ return resolvedQuery({
934
+ finished: true,
935
+ output: { action: "send", message: "task complete", actionReason: "done" },
936
+ }, "hc-sid");
937
+ }
938
+ // Main session processes the background-agent-result
939
+ return resolvedQuery({ action: "send", message: "relayed", actionReason: "ok" });
940
+ });
941
+
942
+ const { orch } = makeOrchestrator(claude, {
943
+ healthCheckInterval: 50,
944
+ healthCheckTimeout: 5000,
945
+ });
946
+
947
+ orch.handleBackgroundCommand("long task");
948
+ await waitForProcessing(200);
949
+
950
+ // Health check should have fired, detected finished, killed original, and pushed result
951
+ expect(callCount).toBeGreaterThanOrEqual(2);
952
+ const hcCall = claude.calls.find((c: CallInfo) => c.prompt.includes("health-check"));
953
+ expect(hcCall).toBeDefined();
954
+ expect(hcCall!.model).toBe("haiku");
955
+ });
956
+
957
+ it("reports progress and schedules next check when not finished", async () => {
958
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
959
+ const { query: bgQuery } = pendingQuery("bg-sid");
960
+
961
+ let hcCount = 0;
962
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
963
+ if (info.prompt.includes("health-check")) {
964
+ hcCount++;
965
+ return resolvedQuery({ finished: false, progress: "still working" }, "hc-sid");
966
+ }
967
+ if (info.prompt.includes("background-agent-result")) {
968
+ return resolvedQuery({ action: "silent", message: "ok", actionReason: "progress" });
969
+ }
970
+ return bgQuery; // background agent spawn
971
+ });
972
+
973
+ const { orch } = makeOrchestrator(claude, {
974
+ healthCheckInterval: 50,
975
+ healthCheckTimeout: 5000,
976
+ });
977
+
978
+ orch.handleBackgroundCommand("long task");
979
+ // Wait for two health check cycles
980
+ await waitForProcessing(250);
981
+
982
+ expect(hcCount).toBeGreaterThanOrEqual(2);
983
+ });
984
+
985
+ it("kills unresponsive agent on health check timeout", async () => {
986
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
987
+ const { query: bgQuery } = pendingQuery("bg-sid");
988
+
989
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
990
+ if (info.prompt.includes("health-check")) {
991
+ // Never resolves — simulates unresponsive agent
992
+ return { sessionId: "hc-sid", startedAt: new Date(), result: new Promise(() => {}), kill: mock(async () => {}) };
993
+ }
994
+ return bgQuery;
995
+ });
996
+
997
+ const { orch, responses } = makeOrchestrator(claude, {
998
+ healthCheckInterval: 30,
999
+ healthCheckTimeout: 60,
1000
+ });
1001
+
1002
+ orch.handleBackgroundCommand("stuck task");
1003
+ await waitForProcessing(200);
1004
+
1005
+ const killMsg = responses.find((r) => r.message.includes("unresponsive"));
1006
+ expect(killMsg).toBeDefined();
1007
+ });
1008
+
1009
+ it("does not run health checks when interval is 0", async () => {
1010
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1011
+ const { query: bgQuery } = pendingQuery("bg-sid");
1012
+
1013
+ const claude = mockClaude((): RunningQuery<unknown> => bgQuery);
1014
+ const { orch } = makeOrchestrator(claude, { healthCheckInterval: 0 });
1015
+
1016
+ orch.handleBackgroundCommand("some task");
1017
+ await waitForProcessing(100);
1018
+
1019
+ // Only the spawn call, no health check fork
1020
+ expect(claude.calls).toHaveLength(1);
1021
+ });
1022
+
1023
+ it("clears health check timer when session is killed", async () => {
1024
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1025
+ const { query: bgQuery } = pendingQuery("bg-sid");
1026
+
1027
+ let hcCount = 0;
1028
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
1029
+ if (info.prompt.includes("health-check")) hcCount++;
1030
+ return bgQuery;
1031
+ });
1032
+
1033
+ const { orch } = makeOrchestrator(claude, { healthCheckInterval: 100 });
1034
+
1035
+ orch.handleBackgroundCommand("killable task");
1036
+ await waitForProcessing();
1037
+
1038
+ // Get the session ID and kill it before health check fires
1039
+ orch.handleKill("bg-sid");
1040
+ await waitForProcessing(200);
1041
+
1042
+ expect(hcCount).toBe(0);
1043
+ });
1044
+
1045
+ it("stops health check if session completes organically before timer", async () => {
1046
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
1047
+ const { query: bgQuery, resolve: resolveBg } = pendingQuery("bg-sid");
1048
+
1049
+ let hcCount = 0;
1050
+ let callCount = 0;
1051
+ const claude = mockClaude((info: CallInfo): RunningQuery<unknown> => {
1052
+ callCount++;
1053
+ if (info.prompt.includes("health-check")) { hcCount++; return pendingQuery().query; }
1054
+ if (callCount === 1) return bgQuery;
1055
+ return resolvedQuery({ action: "send", message: "processed", actionReason: "ok" });
1056
+ });
1057
+
1058
+ const { orch } = makeOrchestrator(claude, { healthCheckInterval: 200 });
1059
+
1060
+ orch.handleBackgroundCommand("fast task");
1061
+ await waitForProcessing();
1062
+
1063
+ // Complete before health check fires
1064
+ resolveBg(queryResult({ action: "send", message: "done", actionReason: "done" }));
1065
+ await waitForProcessing(350);
1066
+
1067
+ expect(hcCount).toBe(0);
1068
+ });
1069
+ });
1070
+
920
1071
  describe("onResponse error handling", () => {
921
1072
  it("logs error and does not throw when onResponse callback fails", async () => {
922
1073
  const claude = mockClaude({ action: "send", message: "hello", actionReason: "ok" });