macroclaw 0.26.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.26.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
@@ -141,12 +141,12 @@ describe("App", () => {
141
141
  expect(bot.filterHandlers.has("callback_query:data")).toBe(true);
142
142
  });
143
143
 
144
- it("registers chatid, session, and bg commands", () => {
144
+ it("registers chatid, bg, and sessions commands", () => {
145
145
  const app = new App(makeConfig());
146
146
  const bot = app.bot as any;
147
147
  expect(bot.commandHandlers.has("chatid")).toBe(true);
148
- expect(bot.commandHandlers.has("session")).toBe(true);
149
148
  expect(bot.commandHandlers.has("bg")).toBe(true);
149
+ expect(bot.commandHandlers.has("sessions")).toBe(true);
150
150
  });
151
151
 
152
152
  it("registers error handler", () => {
@@ -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 () => {
@@ -576,7 +576,7 @@ describe("App", () => {
576
576
  expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Opened", callback_data: "_noop" }]] } });
577
577
  const calls = (bot.api.sendMessage as any).mock.calls;
578
578
  const text = calls[calls.length - 1][1];
579
- expect(text).toBe("Agent not found or already finished.");
579
+ expect(text).toBe("Session not found or already finished.");
580
580
  });
581
581
 
582
582
  it("handles peek: callback by routing to orchestrator.handlePeek", async () => {
@@ -599,7 +599,7 @@ describe("App", () => {
599
599
  expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Peeked", callback_data: "_noop" }]] } });
600
600
  const calls = (bot.api.sendMessage as any).mock.calls;
601
601
  const text = calls[calls.length - 1][1];
602
- expect(text).toBe("Agent not found or already finished.");
602
+ expect(text).toBe("Session not found or already finished.");
603
603
  });
604
604
 
605
605
  it("handles kill: callback by routing to orchestrator.handleKill", async () => {
@@ -622,7 +622,7 @@ describe("App", () => {
622
622
  expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({ reply_markup: { inline_keyboard: [[{ text: "✓ Killed", callback_data: "_noop" }]] } });
623
623
  const calls = (bot.api.sendMessage as any).mock.calls;
624
624
  const text = calls[calls.length - 1][1];
625
- expect(text).toBe("Agent not found or already finished.");
625
+ expect(text).toBe("Session not found or already finished.");
626
626
  });
627
627
 
628
628
  it("ignores callback_query from unauthorized chats", async () => {
@@ -676,35 +676,9 @@ describe("App", () => {
676
676
  expect(ctx.reply).toHaveBeenCalledWith("Chat ID: `12345`", { parse_mode: "Markdown" });
677
677
  });
678
678
 
679
- it("/session sends session ID via sendMessage", async () => {
680
- const app = new App(makeConfig());
681
- const bot = app.bot as any;
682
- const handler = bot.commandHandlers.get("session")!;
683
- const ctx = { chat: { id: 12345 } };
684
-
685
- handler(ctx);
686
- await new Promise((r) => setTimeout(r, 50));
687
-
688
- const calls = (bot.api.sendMessage as any).mock.calls;
689
- expect(calls.length).toBeGreaterThan(0);
690
- const text = calls[calls.length - 1][1];
691
- expect(text).toBe("Session: <code>test-session</code>");
692
- });
693
-
694
- it("/session is ignored for unauthorized chats", async () => {
695
- const app = new App(makeConfig());
696
- const bot = app.bot as any;
697
- const handler = bot.commandHandlers.get("session")!;
698
- const ctx = { chat: { id: 99999 } };
699
-
700
- handler(ctx);
701
- await new Promise((r) => setTimeout(r, 50));
702
-
703
- expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
704
- });
705
-
706
- it("/bg shows no agents via sendMessage when none are running", async () => {
707
- const app = new App(makeConfig());
679
+ it("/bg without prompt sends usage hint", async () => {
680
+ const config = makeConfig();
681
+ const app = new App(config);
708
682
  const bot = app.bot as any;
709
683
  const handler = bot.commandHandlers.get("bg")!;
710
684
  const ctx = { chat: { id: 12345 }, match: "" };
@@ -712,9 +686,9 @@ describe("App", () => {
712
686
  handler(ctx);
713
687
  await new Promise((r) => setTimeout(r, 50));
714
688
 
689
+ expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
715
690
  const calls = (bot.api.sendMessage as any).mock.calls;
716
- const text = calls[calls.length - 1][1];
717
- expect(text).toBe("No background agents running.");
691
+ expect(calls[calls.length - 1][1]).toBe("Usage: /bg &lt;prompt&gt;");
718
692
  });
719
693
 
720
694
  it("/bg with prompt spawns a background agent via sendMessage", async () => {
@@ -739,46 +713,34 @@ describe("App", () => {
739
713
  expect(claude.calls).toHaveLength(1);
740
714
  });
741
715
 
742
- it("/bg lists active background agents via sendMessage", async () => {
743
- const claude = mockClaude((): RunningQuery<unknown> => ({
744
- sessionId: `bg-${Date.now()}`,
745
- startedAt: new Date(),
746
- result: new Promise(() => {}),
747
- kill: mock(async () => {}),
748
- }));
749
- const config = makeConfig({ claude });
750
- const app = new App(config);
716
+ it("/sessions lists running sessions via sendMessage", async () => {
717
+ const app = new App(makeConfig());
751
718
  const bot = app.bot as any;
752
- const bgHandler = bot.commandHandlers.get("bg")!;
753
-
754
- const spawnCtx = { chat: { id: 12345 }, match: "long task" };
755
- bgHandler(spawnCtx);
756
- await new Promise((r) => setTimeout(r, 10));
719
+ const handler = bot.commandHandlers.get("sessions")!;
720
+ const ctx = { chat: { id: 12345 } };
757
721
 
758
- const listCtx = { chat: { id: 12345 }, match: "" };
759
- bgHandler(listCtx);
760
- await new Promise((r) => setTimeout(r, 10));
722
+ handler(ctx);
723
+ await new Promise((r) => setTimeout(r, 50));
761
724
 
762
725
  const calls = (bot.api.sendMessage as any).mock.calls;
763
- const lastText = calls[calls.length - 1][1];
764
- expect(lastText).toContain("long-task");
765
- expect(lastText).toMatch(/\d+s/);
726
+ const text = calls[calls.length - 1][1];
727
+ expect(text).toBe("No running sessions.");
766
728
  });
767
729
 
768
- it("shows 'none' when no session exists yet", async () => {
769
- if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
730
+ it("/sessions is ignored for unauthorized chats", async () => {
770
731
  const app = new App(makeConfig());
771
732
  const bot = app.bot as any;
772
- const handler = bot.commandHandlers.get("session")!;
773
- const ctx = { chat: { id: 12345 } };
733
+ const handler = bot.commandHandlers.get("sessions")!;
734
+ const ctx = { chat: { id: 99999 } };
774
735
 
775
736
  handler(ctx);
776
737
  await new Promise((r) => setTimeout(r, 50));
777
738
 
778
- const calls = (bot.api.sendMessage as any).mock.calls;
779
- const text = calls[calls.length - 1][1];
780
- expect(text).toBe("Session: <code>none</code>");
739
+ // No sendMessage calls for unauthorized
740
+ expect((bot.api.sendMessage as any).mock.calls.length).toBe(0);
781
741
  });
742
+
743
+
782
744
  });
783
745
 
784
746
  describe("error handler", () => {
@@ -803,8 +765,8 @@ describe("App", () => {
803
765
  app.start();
804
766
  expect(bot.api.setMyCommands).toHaveBeenCalledWith([
805
767
  { command: "chatid", description: "Show current chat ID" },
806
- { command: "session", description: "Show current session ID" },
807
- { command: "bg", description: "List or spawn background agents" },
768
+ { command: "bg", description: "Spawn a background agent" },
769
+ { command: "sessions", description: "List running sessions" },
808
770
  ]);
809
771
  });
810
772
  });
package/src/app.ts CHANGED
@@ -43,13 +43,13 @@ 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([
50
50
  { command: "chatid", description: "Show current chat ID" },
51
- { command: "session", description: "Show current session ID" },
52
- { command: "bg", description: "List or spawn background agents" },
51
+ { command: "bg", description: "Spawn a background agent" },
52
+ { command: "sessions", description: "List running sessions" },
53
53
  ]).catch((err) => log.error({ err }, "Failed to set commands"));
54
54
  this.#bot.start({
55
55
  onStart: (botInfo) => {
@@ -73,22 +73,22 @@ export class App {
73
73
  ctx.reply(`Chat ID: \`${ctx.chat.id}\``, { parse_mode: "Markdown" });
74
74
  });
75
75
 
76
- this.#bot.command("session", (ctx) => {
77
- if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
78
- log.debug("Command /session");
79
- this.#orchestrator.handleSessionCommand();
80
- });
81
-
82
76
  this.#bot.command("bg", (ctx) => {
83
77
  if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
84
78
  const prompt = ctx.match?.trim();
85
- if (prompt) {
86
- log.debug({ prompt }, "Command /bg spawn");
87
- this.#orchestrator.handleBackgroundCommand(prompt);
79
+ if (!prompt) {
80
+ log.debug("Command /bg without prompt");
81
+ sendResponse(this.#bot, this.#config.authorizedChatId, "Usage: /bg &lt;prompt&gt;");
88
82
  return;
89
83
  }
90
- log.debug("Command /bg list");
91
- this.#orchestrator.handleBackgroundList();
84
+ log.debug({ prompt }, "Command /bg spawn");
85
+ this.#orchestrator.handleBackgroundCommand(prompt);
86
+ });
87
+
88
+ this.#bot.command("sessions", (ctx) => {
89
+ if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
90
+ log.debug("Command /sessions");
91
+ this.#orchestrator.handleSessions();
92
92
  });
93
93
 
94
94
  this.#bot.on("message:photo", async (ctx) => {
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
+ }