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 +1 -1
- package/src/app.test.ts +7 -7
- package/src/app.ts +2 -2
- package/src/claude.ts +1 -1
- package/src/index.ts +4 -0
- package/src/naming.test.ts +44 -0
- package/src/naming.ts +54 -0
- package/src/orchestrator.test.ts +14 -11
- package/src/orchestrator.ts +85 -29
- package/src/prompts.test.ts +254 -6
- package/src/prompts.ts +132 -10
- package/src/scheduler.test.ts +9 -6
- package/src/scheduler.ts +10 -3
package/package.json
CHANGED
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).
|
|
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).
|
|
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("
|
|
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("
|
|
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).
|
|
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).
|
|
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
|
|
691
|
+
expect(calls[calls.length - 1][1]).toBe("Usage: /bg <prompt>");
|
|
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
|
|
81
|
+
sendResponse(this.#bot, this.#config.authorizedChatId, "Usage: /bg <prompt>");
|
|
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
|
+
}
|
package/src/orchestrator.test.ts
CHANGED
|
@@ -103,7 +103,8 @@ describe("Orchestrator", () => {
|
|
|
103
103
|
orch.handleMessage("hello");
|
|
104
104
|
await waitForProcessing();
|
|
105
105
|
|
|
106
|
-
expect(claude.calls[0].prompt).
|
|
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).
|
|
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).
|
|
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).
|
|
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("
|
|
342
|
-
expect(secondCall.prompt).toContain("
|
|
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("
|
|
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).
|
|
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("
|
|
507
|
-
expect(claude.calls[0].prompt).toContain("
|
|
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
|
|
package/src/orchestrator.ts
CHANGED
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
} from "./claude";
|
|
9
9
|
import { writeHistoryPrompt, writeHistoryResult } from "./history";
|
|
10
10
|
import { createLogger } from "./logger";
|
|
11
|
-
import {
|
|
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
|
|
121
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
382
|
-
: this.#claude.newSession(
|
|
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
|
|
package/src/prompts.test.ts
CHANGED
|
@@ -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("
|
|
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
|
|
22
|
-
expect(SYSTEM_PROMPT).toContain("
|
|
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("
|
|
25
|
-
expect(SYSTEM_PROMPT).toContain("background-agent
|
|
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 & b < c > d "e"");
|
|
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 < b & c > 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 & "b""');
|
|
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 & "b"</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 & "stuff""');
|
|
290
|
+
});
|
|
291
|
+
});
|
package/src/prompts.ts
CHANGED
|
@@ -15,28 +15,150 @@ Use raw <b>, <i>, <code>, <pre> tags. Escape &, <, > in text content as &, &
|
|
|
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
|
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
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
|
+
- type — what triggered this event. One of:
|
|
21
|
+
- user-message — direct user message. Content in <text>, optional <files>.
|
|
22
|
+
- button-click — user 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
|
|
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
|
-
|
|
54
|
+
The new event will contain a <backgrounded-event> element (see above).
|
|
34
55
|
|
|
35
|
-
Files:
|
|
36
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|
package/src/scheduler.test.ts
CHANGED
|
@@ -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]).
|
|
224
|
-
expect(call[
|
|
225
|
-
expect(call[
|
|
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]).
|
|
261
|
-
expect(call[
|
|
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,
|
|
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
|
|