The Atlas BigLaw / Big Michael — documentation bound to its code
7 documents

Big Michael in a channel

Trace an @BigMichael mention from Teams or Slack: the shared dispatcher parses the command, answers instantly or dispatches async work to the orchestrator, and proactive notifications post results back when the matter task completes.

src/bots/dispatcher.ts196 lines · parseCommand L61–66
Outline 7 symbols
1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Discover Legal
3
4/**
5 * Bot command dispatcher — shared by Teams and Slack bots.
6 *
7 * Parses `@BigMichael <command> [args]` messages, dispatches to the
8 * orchestrator, and returns a formatted Markdown response.
9 *
10 * Commands:
11 * status [matter] → matter health score + active tasks
12 * briefing [client] → generate client intelligence briefing
13 * search [query] → semantic search across the knowledge store
14 * task [description] → submit a new roundtable task
15 * run [template] → run a named workflow template
16 * help → list available commands
17 *
18 * Design: synchronous where possible (Teams Outgoing Webhook needs a
19 * response in < 5 s). Long-running commands return "Working on it…" and
20 * post back via the channel client once the task completes.
21 */
22
23import { logger } from "../logger.js";
24import type { Orchestrator } from "../orchestrator.js";
25
26// ─── Types ────────────────────────────────────────────────────────────────────
27
28export type BotPlatform = "teams" | "slack";
29
30export interface BotMessage {
31 /** Raw @-mention stripped, trimmed */
32 text: string;
33 /** Who sent the message */
34 senderName: string;
35 senderEmail?: string;
36 /** Channel or conversation context */
37 channelId?: string;
38 teamId?: string;
39 /** For threading a reply back (Slack thread_ts, Teams reply-to id) */
40 threadId?: string;
41 platform: BotPlatform;
42}
43
44export interface BotResponse {
45 /** Immediate reply text (Markdown) — sent in the same turn */
46 immediate: string;
47 /** If set, dispatch this async work and post back when done */
48 asyncWork?: () => Promise<string>;
49}
50
51// ─── Dispatcher ───────────────────────────────────────────────────────────────
52
53const BOT_NAME_RE = /^@?big[-_]?michael[\s:,]*/i;
54
55/** Strip ASCII control characters (0x00–0x1F, 0x7F) from a string. */
56function stripControls(s: string): string {
57 // eslint-disable-next-line no-control-regex
58 return s.replace(/[\x00-\x1F\x7F]/g, "");
59}
60
61export function parseCommand(raw: string): { command: string; args: string } {
62 const text = raw.replace(BOT_NAME_RE, "").trim();
63 const space = text.indexOf(" ");
64 if (space === -1) return { command: text.toLowerCase(), args: "" };
65 return { command: text.slice(0, space).toLowerCase(), args: text.slice(space + 1).trim().slice(0, 2000) };
66}
67
68export async function dispatch(
69 msg: BotMessage,
70 orch: Orchestrator,
71): Promise<BotResponse> {
72 const { command, args } = parseCommand(msg.text);
73
74 switch (command) {
75
76 case "status": {
77 const matterNumber = stripControls(args.trim()).toUpperCase();
78 if (!matterNumber) {
79 return { immediate: "Usage: `@BigMichael status [matter-number]`" };
80 }
81 const tasks = orch.listTasks().filter((t) => t.matterNumber === matterNumber);
82 if (tasks.length === 0) {
83 return { immediate: `No tasks found for matter **${matterNumber}**.` };
84 }
85 const health = orch.matterHealth.compute(
86 matterNumber,
87 tasks,
88 orch.time,
89 orch.budgetMonitor,
90 );
91 const signal = health.signal === "green" ? "🟢" : health.signal === "amber" ? "🟡" : "🔴";
92 const lines = [
93 `**Matter ${matterNumber}** — Health ${signal} ${health.score}/100`,
94 "",
95 `Budget: ${health.dimensions.budgetHealth}/100 | Deadline: ${health.dimensions.deadlineHealth}/100 | Activity: ${health.dimensions.activityFreshness}/100`,
96 "",
97 `**Active tasks:** ${tasks.filter((t) => t.status === "running").length}`,
98 `**Pending gates:** ${tasks.reduce((s, t) => s + (t.pendingGates?.length ?? 0), 0)}`,
99 ];
100 if (health.riskFactors.length > 0) {
101 lines.push("", "**Risks:**");
102 for (const r of health.riskFactors.slice(0, 3)) lines.push(`${r.message}`);
103 }
104 return { immediate: lines.join("\n") };
105 }
106
107 case "briefing": {
108 const clientRef = stripControls(args.trim()).toUpperCase();
109 if (!clientRef) {
110 return { immediate: "Usage: `@BigMichael briefing [client-name-or-number]`" };
111 }
112 const clientRecord = orch.clients.getByClientNumber(clientRef)
113 ?? orch.clients.list().find((c) => c.name.toUpperCase().includes(clientRef));
114 if (!clientRecord) {
115 return { immediate: `Client not found: **${clientRef}**. Check the client number or name.` };
116 }
117 return {
118 immediate: `Assembling briefing for **${clientRecord.name}** — scanning all sources…`,
119 asyncWork: async () => {
120 const allTasks = orch.listTasks();
121 const allEntries = await orch.time.list({});
122 const briefing = await orch.briefing.generate(
123 clientRecord,
124 allTasks,
125 allEntries as import("../types.js").TimeEntry[],
126 { knowledge: orch.knowledge },
127 );
128 return briefing.document;
129 },
130 };
131 }
132
133 case "search": {
134 if (!args) return { immediate: "Usage: `@BigMichael search [query]`" };
135 const results = await orch.knowledge.search(args, { topK: 5 });
136 const arr = Array.isArray(results) ? results as unknown as Array<Record<string, unknown>> : [];
137 if (arr.length === 0) return { immediate: `No results found for: **${args}**` };
138 const lines = [`**Knowledge search:** ${args}`, ""];
139 for (const r of arr.slice(0, 5)) {
140 const title = String(r["title"] ?? r["documentTitle"] ?? "Result");
141 const snippet = String(r["content"] ?? r["text"] ?? "").slice(0, 150);
142 lines.push(`**${title}**`, snippet, "");
143 }
144 return { immediate: lines.join("\n") };
145 }
146
147 case "task": {
148 if (!args) return { immediate: "Usage: `@BigMichael task [description]`" };
149 return {
150 immediate: `Submitting task: _${args.slice(0, 80)}_…`,
151 asyncWork: async () => {
152 const task = await orch.submitTask({
153 description: args,
154 workflowType: "roundtable",
155 });
156 return `Task submitted ✓\n**ID:** \`${task.id}\`\nUse \`@BigMichael status\` to follow progress.`;
157 },
158 };
159 }
160
161 case "run": {
162 const templateId = args.trim();
163 if (!templateId) {
164 const templates = await orch.listTemplates();
165 const list = templates.map((t) => `\`${t.id}\`${t.name}`).join("\n");
166 return { immediate: `**Available templates:**\n${list}\n\nUsage: \`@BigMichael run [template-id]\`` };
167 }
168 return {
169 immediate: `Running template \`${templateId}\``,
170 asyncWork: async () => {
171 const task = await orch.submitFromTemplate(templateId);
172 return `Template \`${templateId}\` started ✓\n**Task ID:** \`${task.id}\``;
173 },
174 };
175 }
176
177 case "help":
178 case "":
179 default:
180 return {
181 immediate: [
182 "**Big Michael** — multi-agent legal AI",
183 "",
184 "| Command | Description |",
185 "|---------|-------------|",
186 "| `status [matter]` | Matter health score + active tasks |",
187 "| `briefing [client]` | Client intelligence briefing (all sources) |",
188 "| `search [query]` | Semantic search across the knowledge store |",
189 "| `task [description]` | Submit a new roundtable AI task |",
190 "| `run [template-id]` | Run a named workflow template |",
191 "| `help` | Show this message |",
192 ].join("\n"),
193 };
194 }
195}
196