bosun 0.36.2 → 0.36.3

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.
@@ -0,0 +1,679 @@
1
+ /**
2
+ * voice-tools.mjs — Voice-callable tools for the Realtime API.
3
+ *
4
+ * Each tool mirrors a bosun API endpoint or system operation.
5
+ * Tools use OpenAI function-calling schema.
6
+ *
7
+ * @module voice-tools
8
+ */
9
+
10
+ import { loadConfig } from "./config.mjs";
11
+ import { execPrimaryPrompt, getPrimaryAgentName, setPrimaryAgent } from "./primary-agent.mjs";
12
+
13
+ // ── Module-scope lazy imports ───────────────────────────────────────────────
14
+
15
+ let _kanbanAdapter = null;
16
+ let _sessionTracker = null;
17
+ let _fleetCoordinator = null;
18
+ let _agentSupervisor = null;
19
+ let _sharedStateManager = null;
20
+
21
+ const VALID_EXECUTORS = new Set([
22
+ "codex-sdk",
23
+ "copilot-sdk",
24
+ "claude-sdk",
25
+ "gemini-sdk",
26
+ "opencode-sdk",
27
+ ]);
28
+ const VALID_AGENT_MODES = new Set(["ask", "agent", "plan"]);
29
+
30
+ async function getKanban() {
31
+ if (!_kanbanAdapter) {
32
+ _kanbanAdapter = await import("./kanban-adapter.mjs");
33
+ }
34
+ return _kanbanAdapter;
35
+ }
36
+
37
+ async function getSessionTracker() {
38
+ if (!_sessionTracker) {
39
+ _sessionTracker = await import("./session-tracker.mjs");
40
+ }
41
+ return _sessionTracker;
42
+ }
43
+
44
+ async function getFleetCoordinator() {
45
+ if (!_fleetCoordinator) {
46
+ _fleetCoordinator = await import("./fleet-coordinator.mjs");
47
+ }
48
+ return _fleetCoordinator;
49
+ }
50
+
51
+ async function getSupervisor() {
52
+ if (!_agentSupervisor) {
53
+ _agentSupervisor = await import("./agent-supervisor.mjs");
54
+ }
55
+ return _agentSupervisor;
56
+ }
57
+
58
+ async function getSharedState() {
59
+ if (!_sharedStateManager) {
60
+ _sharedStateManager = await import("./shared-state-manager.mjs");
61
+ }
62
+ return _sharedStateManager;
63
+ }
64
+
65
+ async function getLatestVisionSummary(sessionId) {
66
+ const id = String(sessionId || "").trim();
67
+ if (!id) return "";
68
+ try {
69
+ const tracker = await getSessionTracker();
70
+ const session = tracker.getSessionById
71
+ ? tracker.getSessionById(id)
72
+ : (tracker.getSession ? tracker.getSession(id) : null);
73
+ const messages = Array.isArray(session?.messages) ? session.messages : [];
74
+ for (let i = messages.length - 1; i >= 0; i--) {
75
+ const msg = messages[i];
76
+ if (String(msg?.role || "").trim().toLowerCase() !== "system") continue;
77
+ const text = String(msg?.content || "").trim();
78
+ if (!text) continue;
79
+ if (/^\[Vision\s/i.test(text)) return text;
80
+ }
81
+ } catch {
82
+ // best effort: continue without visual context
83
+ }
84
+ return "";
85
+ }
86
+
87
+ function appendVisionSummary(message, visionSummary) {
88
+ const base = String(message || "").trim();
89
+ const summary = String(visionSummary || "").trim();
90
+ if (!base || !summary) return base;
91
+ return `${base}\n\nLive visual context from this call:\n${summary}`;
92
+ }
93
+
94
+ // ── Tool Definitions (OpenAI function-calling format) ────────────────────────
95
+
96
+ const TOOL_DEFS = [
97
+ // ── Workspace Tools ──
98
+ {
99
+ type: "function",
100
+ name: "list_tasks",
101
+ description: "List tasks from the kanban board. Returns task IDs, titles, status, and assignees.",
102
+ parameters: {
103
+ type: "object",
104
+ properties: {
105
+ status: {
106
+ type: "string",
107
+ enum: ["todo", "inprogress", "inreview", "done", "cancelled", "all"],
108
+ description: "Filter by task status. Default: all",
109
+ },
110
+ limit: {
111
+ type: "number",
112
+ description: "Max number of tasks to return. Default: 20",
113
+ },
114
+ },
115
+ },
116
+ },
117
+ {
118
+ type: "function",
119
+ name: "get_task",
120
+ description: "Get detailed information about a specific task by ID or number.",
121
+ parameters: {
122
+ type: "object",
123
+ properties: {
124
+ taskId: { type: "string", description: "Task ID or issue number" },
125
+ },
126
+ required: ["taskId"],
127
+ },
128
+ },
129
+ {
130
+ type: "function",
131
+ name: "create_task",
132
+ description: "Create a new task on the kanban board.",
133
+ parameters: {
134
+ type: "object",
135
+ properties: {
136
+ title: { type: "string", description: "Task title" },
137
+ description: { type: "string", description: "Task description/body" },
138
+ priority: { type: "string", enum: ["low", "medium", "high", "critical"] },
139
+ labels: {
140
+ type: "array",
141
+ items: { type: "string" },
142
+ description: "Labels to apply",
143
+ },
144
+ },
145
+ required: ["title"],
146
+ },
147
+ },
148
+ {
149
+ type: "function",
150
+ name: "update_task_status",
151
+ description: "Update the status of a task (move between columns).",
152
+ parameters: {
153
+ type: "object",
154
+ properties: {
155
+ taskId: { type: "string", description: "Task ID or issue number" },
156
+ status: {
157
+ type: "string",
158
+ enum: ["todo", "inprogress", "inreview", "done", "cancelled"],
159
+ },
160
+ },
161
+ required: ["taskId", "status"],
162
+ },
163
+ },
164
+ // ── Agent Tools ──
165
+ {
166
+ type: "function",
167
+ name: "delegate_to_agent",
168
+ description: "Delegate a complex task to a coding agent (codex, copilot, claude, gemini, or opencode). Use this for code changes, file creation, debugging, or any operation requiring workspace access. The agent will execute the task and return its response.",
169
+ parameters: {
170
+ type: "object",
171
+ properties: {
172
+ message: {
173
+ type: "string",
174
+ description: "The instruction to send to the agent. Be specific and detailed.",
175
+ },
176
+ executor: {
177
+ type: "string",
178
+ enum: ["codex-sdk", "copilot-sdk", "claude-sdk", "gemini-sdk", "opencode-sdk"],
179
+ description: "Which agent to use. Defaults to the configured primary agent.",
180
+ },
181
+ mode: {
182
+ type: "string",
183
+ enum: ["ask", "agent", "plan", "code", "architect"],
184
+ description: "Agent mode: code (make changes), ask (read-only), architect (plan). Default: code",
185
+ },
186
+ model: {
187
+ type: "string",
188
+ description: "Optional model override for the delegated call.",
189
+ },
190
+ },
191
+ required: ["message"],
192
+ },
193
+ },
194
+ {
195
+ type: "function",
196
+ name: "get_agent_status",
197
+ description: "Get the current status of the active coding agent (busy, idle, session info).",
198
+ parameters: { type: "object", properties: {} },
199
+ },
200
+ {
201
+ type: "function",
202
+ name: "switch_agent",
203
+ description: "Switch the active primary agent to a different executor.",
204
+ parameters: {
205
+ type: "object",
206
+ properties: {
207
+ executor: {
208
+ type: "string",
209
+ enum: ["codex-sdk", "copilot-sdk", "claude-sdk", "gemini-sdk", "opencode-sdk"],
210
+ description: "The executor to switch to",
211
+ },
212
+ },
213
+ required: ["executor"],
214
+ },
215
+ },
216
+ // ── Session Tools ──
217
+ {
218
+ type: "function",
219
+ name: "list_sessions",
220
+ description: "List active chat/agent sessions.",
221
+ parameters: {
222
+ type: "object",
223
+ properties: {
224
+ limit: { type: "number", description: "Max sessions to return. Default: 10" },
225
+ },
226
+ },
227
+ },
228
+ {
229
+ type: "function",
230
+ name: "get_session_history",
231
+ description: "Get the recent message history from a session.",
232
+ parameters: {
233
+ type: "object",
234
+ properties: {
235
+ sessionId: { type: "string", description: "Session ID to retrieve" },
236
+ limit: { type: "number", description: "Max messages. Default: 20" },
237
+ },
238
+ required: ["sessionId"],
239
+ },
240
+ },
241
+ // ── System Tools ──
242
+ {
243
+ type: "function",
244
+ name: "get_system_status",
245
+ description: "Get the overall bosun system status including agent health, task counts, and fleet info.",
246
+ parameters: { type: "object", properties: {} },
247
+ },
248
+ {
249
+ type: "function",
250
+ name: "get_fleet_status",
251
+ description: "Get fleet coordination status across workstations.",
252
+ parameters: { type: "object", properties: {} },
253
+ },
254
+ {
255
+ type: "function",
256
+ name: "run_command",
257
+ description: "Execute a bosun CLI command (e.g., 'sync', 'maintenance', 'config show').",
258
+ parameters: {
259
+ type: "object",
260
+ properties: {
261
+ command: { type: "string", description: "The bosun command to run" },
262
+ },
263
+ required: ["command"],
264
+ },
265
+ },
266
+ // ── Git/PR Tools ──
267
+ {
268
+ type: "function",
269
+ name: "get_pr_status",
270
+ description: "Get the status of open pull requests.",
271
+ parameters: {
272
+ type: "object",
273
+ properties: {
274
+ limit: { type: "number", description: "Max PRs to return. Default: 10" },
275
+ },
276
+ },
277
+ },
278
+ // ── Config Tools ──
279
+ {
280
+ type: "function",
281
+ name: "get_config",
282
+ description: "Get current bosun configuration values.",
283
+ parameters: {
284
+ type: "object",
285
+ properties: {
286
+ key: { type: "string", description: "Specific config key to retrieve. Omit for full config summary." },
287
+ },
288
+ },
289
+ },
290
+ {
291
+ type: "function",
292
+ name: "update_config",
293
+ description: "Update a bosun configuration value.",
294
+ parameters: {
295
+ type: "object",
296
+ properties: {
297
+ key: { type: "string", description: "Config key to update" },
298
+ value: { type: "string", description: "New value" },
299
+ },
300
+ required: ["key", "value"],
301
+ },
302
+ },
303
+ // ── Workspace Navigation ──
304
+ {
305
+ type: "function",
306
+ name: "search_code",
307
+ description: "Search for code patterns in the workspace.",
308
+ parameters: {
309
+ type: "object",
310
+ properties: {
311
+ query: { type: "string", description: "Search query or regex pattern" },
312
+ filePattern: { type: "string", description: "Glob pattern to filter files. E.g., '**/*.mjs'" },
313
+ maxResults: { type: "number", description: "Max results. Default: 20" },
314
+ },
315
+ required: ["query"],
316
+ },
317
+ },
318
+ {
319
+ type: "function",
320
+ name: "read_file_content",
321
+ description: "Read the content of a file in the workspace.",
322
+ parameters: {
323
+ type: "object",
324
+ properties: {
325
+ filePath: { type: "string", description: "Path relative to workspace root" },
326
+ startLine: { type: "number", description: "Start line (1-indexed)" },
327
+ endLine: { type: "number", description: "End line (1-indexed)" },
328
+ },
329
+ required: ["filePath"],
330
+ },
331
+ },
332
+ {
333
+ type: "function",
334
+ name: "list_directory",
335
+ description: "List files and directories in a workspace path.",
336
+ parameters: {
337
+ type: "object",
338
+ properties: {
339
+ path: { type: "string", description: "Directory path relative to workspace root. Default: root" },
340
+ },
341
+ },
342
+ },
343
+ // ── Monitoring ──
344
+ {
345
+ type: "function",
346
+ name: "get_recent_logs",
347
+ description: "Get recent agent or system log entries.",
348
+ parameters: {
349
+ type: "object",
350
+ properties: {
351
+ type: { type: "string", enum: ["agent", "system"], description: "Log type. Default: agent" },
352
+ lines: { type: "number", description: "Number of recent lines. Default: 50" },
353
+ },
354
+ },
355
+ },
356
+ ];
357
+
358
+ // ── Tool Execution ──────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Get tool definitions in OpenAI format.
362
+ */
363
+ export function getToolDefinitions() {
364
+ return TOOL_DEFS;
365
+ }
366
+
367
+ /**
368
+ * Execute a tool call by name with given arguments.
369
+ * @param {string} toolName
370
+ * @param {object} args
371
+ * @param {object} context — { sessionId, userId }
372
+ * @returns {Promise<{ result: string, error?: string }>}
373
+ */
374
+ export async function executeToolCall(toolName, args = {}, context = {}) {
375
+ const handler = TOOL_HANDLERS[toolName];
376
+ if (!handler) {
377
+ return { result: null, error: `Unknown tool: ${toolName}` };
378
+ }
379
+ try {
380
+ const result = await handler(args, context);
381
+ return { result: typeof result === "string" ? result : JSON.stringify(result, null, 2) };
382
+ } catch (err) {
383
+ console.error(`[voice-tools] ${toolName} error:`, err.message);
384
+ return { result: null, error: err.message };
385
+ }
386
+ }
387
+
388
+ // ── Tool Handlers ───────────────────────────────────────────────────────────
389
+
390
+ const TOOL_HANDLERS = {
391
+ async list_tasks(args) {
392
+ const kanban = await getKanban();
393
+ const status = args.status || "all";
394
+ const limit = args.limit || 20;
395
+ const tasks = await kanban.listTasks({ status: status === "all" ? undefined : status });
396
+ const limited = tasks.slice(0, limit);
397
+ return limited.map(t => ({
398
+ id: t.id || t.number,
399
+ title: t.title,
400
+ status: t.status,
401
+ assignee: t.assignee || t.assignees?.[0] || "unassigned",
402
+ labels: t.labels || [],
403
+ }));
404
+ },
405
+
406
+ async get_task(args) {
407
+ const kanban = await getKanban();
408
+ const task = await kanban.getTask(args.taskId);
409
+ if (!task) return `Task ${args.taskId} not found.`;
410
+ return {
411
+ id: task.id || task.number,
412
+ title: task.title,
413
+ status: task.status,
414
+ description: task.body || task.description || "(no description)",
415
+ assignee: task.assignee || "unassigned",
416
+ labels: task.labels || [],
417
+ createdAt: task.createdAt,
418
+ updatedAt: task.updatedAt,
419
+ };
420
+ },
421
+
422
+ async create_task(args) {
423
+ const kanban = await getKanban();
424
+ const result = await kanban.createTask({
425
+ title: args.title,
426
+ body: args.description || "",
427
+ priority: args.priority,
428
+ labels: args.labels,
429
+ });
430
+ return `Created task: ${result.title || args.title} (ID: ${result.id || result.number})`;
431
+ },
432
+
433
+ async update_task_status(args) {
434
+ const kanban = await getKanban();
435
+ await kanban.updateTaskStatus(args.taskId, args.status);
436
+ return `Task ${args.taskId} moved to ${args.status}.`;
437
+ },
438
+
439
+ async delegate_to_agent(args, context) {
440
+ const cfg = loadConfig();
441
+ const requestedExecutor = String(
442
+ args.executor || context.executor || cfg.voice?.delegateExecutor || cfg.primaryAgent || "codex-sdk",
443
+ )
444
+ .trim()
445
+ .toLowerCase();
446
+ const executor = VALID_EXECUTORS.has(requestedExecutor)
447
+ ? requestedExecutor
448
+ : (cfg.voice?.delegateExecutor || cfg.primaryAgent || "codex-sdk");
449
+
450
+ const rawMode = String(args.mode || context.mode || "agent")
451
+ .trim()
452
+ .toLowerCase();
453
+ const mode =
454
+ rawMode === "code"
455
+ ? "agent"
456
+ : rawMode === "architect"
457
+ ? "plan"
458
+ : VALID_AGENT_MODES.has(rawMode)
459
+ ? rawMode
460
+ : "agent";
461
+ const model = String(args.model || context.model || "").trim() || undefined;
462
+ const sessionId = String(context.sessionId || "").trim() || `voice-delegate-${Date.now()}`;
463
+ const sessionType = String(context.sessionType || "").trim() || (context.sessionId ? "primary" : "voice-delegate");
464
+ const visionSummary = await getLatestVisionSummary(sessionId);
465
+ const delegateMessage = appendVisionSummary(args.message, visionSummary);
466
+
467
+ // Switch agent if different from current
468
+ const currentAgent = getPrimaryAgentName();
469
+ if (executor !== currentAgent) {
470
+ setPrimaryAgent(executor);
471
+ }
472
+
473
+ try {
474
+ const result = await execPrimaryPrompt(delegateMessage, {
475
+ mode,
476
+ model,
477
+ sessionId,
478
+ sessionType,
479
+ timeoutMs: 5 * 60 * 1000, // 5 min timeout for voice delegations
480
+ onEvent: () => {
481
+ // Could broadcast progress via WebSocket here
482
+ },
483
+ });
484
+ const text = typeof result === "string"
485
+ ? result
486
+ : result?.finalResponse || result?.text || result?.message || JSON.stringify(result);
487
+ return text.length > 2000 ? text.slice(0, 2000) + "... (truncated)" : text;
488
+ } finally {
489
+ // Restore original agent if we switched
490
+ if (executor !== currentAgent) {
491
+ try { setPrimaryAgent(currentAgent); } catch { /* best effort */ }
492
+ }
493
+ }
494
+ },
495
+
496
+ async get_agent_status() {
497
+ const name = getPrimaryAgentName();
498
+ return {
499
+ activeAgent: name,
500
+ status: "available",
501
+ message: `${name} is the active primary agent.`,
502
+ };
503
+ },
504
+
505
+ async switch_agent(args) {
506
+ const previous = getPrimaryAgentName();
507
+ setPrimaryAgent(args.executor);
508
+ return `Switched primary agent from ${previous} to ${args.executor}.`;
509
+ },
510
+
511
+ async list_sessions(args) {
512
+ const tracker = await getSessionTracker();
513
+ const sessions = tracker.listSessions ? tracker.listSessions() : [];
514
+ const limit = args.limit || 10;
515
+ return sessions.slice(0, limit).map(s => ({
516
+ id: s.id || s.taskId,
517
+ type: s.type || "task",
518
+ status: s.status,
519
+ lastActive: s.lastActiveAt || s.lastActivityAt,
520
+ }));
521
+ },
522
+
523
+ async get_session_history(args) {
524
+ const tracker = await getSessionTracker();
525
+ const session = tracker.getSession ? tracker.getSession(args.sessionId) : null;
526
+ if (!session) return `Session ${args.sessionId} not found.`;
527
+ const limit = args.limit || 20;
528
+ const messages = (session.messages || []).slice(-limit);
529
+ return messages.map(m => ({
530
+ role: m.role || m.type,
531
+ content: m.content,
532
+ timestamp: m.timestamp,
533
+ }));
534
+ },
535
+
536
+ async get_system_status() {
537
+ const cfg = loadConfig();
538
+ const name = getPrimaryAgentName();
539
+ return {
540
+ primaryAgent: name,
541
+ kanbanBackend: cfg.kanbanBackend || cfg.kanban?.backend || "internal",
542
+ projectName: cfg.projectName || "unknown",
543
+ mode: cfg.mode || "generic",
544
+ voiceEnabled: cfg.voice?.enabled !== false,
545
+ };
546
+ },
547
+
548
+ async get_fleet_status() {
549
+ try {
550
+ const fleet = await getFleetCoordinator();
551
+ const status = fleet.getFleetStatus ? fleet.getFleetStatus() : {};
552
+ return status;
553
+ } catch {
554
+ return { error: "Fleet coordinator not available" };
555
+ }
556
+ },
557
+
558
+ async run_command(args) {
559
+ // Only allow safe read-only commands via voice
560
+ const safeCommands = ["status", "config show", "sync", "health", "fleet status"];
561
+ const cmd = String(args.command || "").trim().toLowerCase();
562
+ const isSafe = safeCommands.some(s => cmd.startsWith(s));
563
+ if (!isSafe) {
564
+ return `Command "${args.command}" is not allowed via voice. Safe commands: ${safeCommands.join(", ")}`;
565
+ }
566
+ return `Command "${args.command}" acknowledged. Use the UI or CLI for execution.`;
567
+ },
568
+
569
+ async get_pr_status(args) {
570
+ try {
571
+ const { execSync } = await import("node:child_process");
572
+ const limit = args.limit || 10;
573
+ const result = execSync(
574
+ `gh pr list --limit ${limit} --json number,title,state,author,url --jq ".[] | {number, title, state, author: .author.login}"`,
575
+ { encoding: "utf8", timeout: 15_000 },
576
+ );
577
+ return result.trim() || "No open pull requests.";
578
+ } catch {
579
+ return "Could not fetch PR status. Ensure gh CLI is installed.";
580
+ }
581
+ },
582
+
583
+ async get_config(args) {
584
+ const cfg = loadConfig();
585
+ if (args.key) {
586
+ const value = cfg[args.key];
587
+ return value !== undefined ? { [args.key]: value } : `Config key "${args.key}" not found.`;
588
+ }
589
+ // Return safe summary
590
+ return {
591
+ primaryAgent: cfg.primaryAgent,
592
+ mode: cfg.mode,
593
+ kanbanBackend: cfg.kanbanBackend || cfg.kanban?.backend,
594
+ projectName: cfg.projectName,
595
+ autoFixEnabled: cfg.autoFixEnabled,
596
+ watchEnabled: cfg.watchEnabled,
597
+ voiceEnabled: cfg.voice?.enabled !== false,
598
+ };
599
+ },
600
+
601
+ async update_config(args) {
602
+ return `Config update for "${args.key}" = "${args.value}" noted. Please apply via Settings UI for persistence.`;
603
+ },
604
+
605
+ async search_code(args) {
606
+ try {
607
+ const { execSync } = await import("node:child_process");
608
+ const pattern = args.filePattern ? `--include="${args.filePattern}"` : "";
609
+ const limit = args.maxResults || 20;
610
+ const result = execSync(
611
+ `grep -rn ${pattern} "${args.query}" . --max-count=${limit} 2>/dev/null || true`,
612
+ { encoding: "utf8", timeout: 10_000, cwd: process.cwd() },
613
+ );
614
+ return result.trim() || `No matches found for "${args.query}".`;
615
+ } catch {
616
+ return `Search failed for "${args.query}".`;
617
+ }
618
+ },
619
+
620
+ async read_file_content(args) {
621
+ const { readFileSync } = await import("node:fs");
622
+ const { resolve } = await import("node:path");
623
+ try {
624
+ const fullPath = resolve(process.cwd(), args.filePath);
625
+ const content = readFileSync(fullPath, "utf8");
626
+ const lines = content.split("\n");
627
+ const start = (args.startLine || 1) - 1;
628
+ const end = args.endLine || lines.length;
629
+ const slice = lines.slice(start, end).join("\n");
630
+ return slice.length > 3000 ? slice.slice(0, 3000) + "\n... (truncated)" : slice;
631
+ } catch (err) {
632
+ return `Could not read ${args.filePath}: ${err.message}`;
633
+ }
634
+ },
635
+
636
+ async list_directory(args) {
637
+ const { readdirSync, statSync } = await import("node:fs");
638
+ const { resolve, join } = await import("node:path");
639
+ try {
640
+ const dir = resolve(process.cwd(), args.path || ".");
641
+ const entries = readdirSync(dir).slice(0, 50);
642
+ return entries.map(name => {
643
+ try {
644
+ const isDir = statSync(join(dir, name)).isDirectory();
645
+ return isDir ? `${name}/` : name;
646
+ } catch {
647
+ return name;
648
+ }
649
+ });
650
+ } catch (err) {
651
+ return `Could not list ${args.path || "."}: ${err.message}`;
652
+ }
653
+ },
654
+
655
+ async get_recent_logs(args) {
656
+ const { readFileSync, readdirSync, statSync } = await import("node:fs");
657
+ const { resolve, join } = await import("node:path");
658
+ try {
659
+ const logsDir = resolve(process.cwd(), "logs");
660
+ const type = args.type || "agent";
661
+ const lines = args.lines || 50;
662
+ const files = readdirSync(logsDir)
663
+ .filter(f => f.includes(type) && f.endsWith(".log"))
664
+ .sort((a, b) => {
665
+ try {
666
+ return statSync(join(logsDir, b)).mtimeMs - statSync(join(logsDir, a)).mtimeMs;
667
+ } catch { return 0; }
668
+ });
669
+ if (!files.length) return `No ${type} logs found.`;
670
+ const content = readFileSync(join(logsDir, files[0]), "utf8");
671
+ const logLines = content.trim().split("\n");
672
+ return logLines.slice(-lines).join("\n");
673
+ } catch {
674
+ return "Could not read logs.";
675
+ }
676
+ },
677
+ };
678
+
679
+ export { TOOL_DEFS as VOICE_TOOLS };
@@ -151,11 +151,15 @@ After completing your implementation:
151
151
  }, { x: 200, y: 1200 }),
152
152
 
153
153
  node("notify-failure", "notify.telegram", "Notify Review Failed", {
154
- message: ":close: Frontend task **{{taskTitle}}** failed visual verification. Review evidence in .bosun/evidence/",
154
+ message: " Frontend task **{{taskTitle}}** failed visual verification.\n" +
155
+ "Evidence dir: `{{evidenceDir}}`\n" +
156
+ "Reason: `{{model-review.reason}}`\n" +
157
+ "Evidence count: `{{model-review.evidenceCount}}`\n" +
158
+ "Review output: {{model-review.reviewOutput}}",
155
159
  }, { x: 600, y: 1080 }),
156
160
 
157
161
  node("log-failure", "notify.log", "Log Failure", {
158
- message: "Frontend verification failed for {{taskId}}: review output in evidence dir",
162
+ message: "Frontend verification failed for {{taskId}}: evidenceDir={{evidenceDir}} reason={{model-review.reason}} evidenceCount={{model-review.evidenceCount}}",
159
163
  level: "warn",
160
164
  }, { x: 600, y: 1200 }),
161
165
  ],