|
| 1 | +// The fake daemon's island methods (spec 31): the cards that wait, built |
| 2 | +// from the fake chat and questions with the phone's rules (28.5), and what |
| 3 | +// the crews did since a moment, counted like the daemon's `today.rs`. |
| 4 | + |
| 5 | +import type { FakeBotloft, Handlers } from "./fake"; |
| 6 | +import type { ApprovalCard, BusyBot, QuestionCard } from "./protocol.gen"; |
| 7 | + |
| 8 | +/** The longest text a card shows whole (the daemon's `CARD_TEXT_MAX`). */ |
| 9 | +const CARD_TEXT_MAX = 8 * 1024; |
| 10 | + |
| 11 | +/** The tools a card may allow; the rest is answered in the Botloft. */ |
| 12 | +const MAY_ALLOW = new Set([ |
| 13 | + "Bash", |
| 14 | + "PowerShell", |
| 15 | + "WebFetch", |
| 16 | + "WebSearch", |
| 17 | + "Read", |
| 18 | + "Write", |
| 19 | + "Edit", |
| 20 | + "MultiEdit", |
| 21 | + "NotebookEdit", |
| 22 | + "ExitPlanMode", |
| 23 | + "mcp__botloft__show_plan", |
| 24 | +]); |
| 25 | + |
| 26 | +/** What to read before allowing, and whether it is not all of the request. */ |
| 27 | +function textOf(input: string): { text: string; cut: boolean } { |
| 28 | + let parsed: unknown; |
| 29 | + try { |
| 30 | + parsed = JSON.parse(input); |
| 31 | + } catch { |
| 32 | + return { text: input.slice(0, CARD_TEXT_MAX), cut: true }; |
| 33 | + } |
| 34 | + const fields = parsed as { command?: unknown; plan?: unknown }; |
| 35 | + const text = |
| 36 | + typeof fields.command === "string" |
| 37 | + ? fields.command |
| 38 | + : typeof fields.plan === "string" |
| 39 | + ? fields.plan |
| 40 | + : input; |
| 41 | + return text.length > CARD_TEXT_MAX |
| 42 | + ? { text: text.slice(0, CARD_TEXT_MAX), cut: true } |
| 43 | + : { text, cut: false }; |
| 44 | +} |
| 45 | + |
| 46 | +export function islandHandlers(fake: FakeBotloft): Pick<Handlers, "cards.list" | "activity.today"> { |
| 47 | + const who = (botId: string) => { |
| 48 | + const bot = fake.bot(botId, false); |
| 49 | + return { bot: { name: bot.name, color: bot.color }, crew: fake.crew(bot.crewId, false).name }; |
| 50 | + }; |
| 51 | + return { |
| 52 | + "cards.list": () => { |
| 53 | + const approvals: ApprovalCard[] = []; |
| 54 | + for (const item of fake.chat.items) { |
| 55 | + if (item.body.kind !== "approval" || item.body.status !== "pending") { |
| 56 | + continue; |
| 57 | + } |
| 58 | + approvals.push({ |
| 59 | + approvalId: item.body.approvalId, |
| 60 | + ...who(item.botId), |
| 61 | + createdAt: item.createdAt, |
| 62 | + toolName: item.body.toolName, |
| 63 | + summary: item.body.summary, |
| 64 | + ...(item.body.explanation !== null && { explanation: item.body.explanation }), |
| 65 | + ...textOf(item.body.input), |
| 66 | + atComputer: !MAY_ALLOW.has(item.body.toolName), |
| 67 | + }); |
| 68 | + } |
| 69 | + const questions: QuestionCard[] = fake.questions.asked |
| 70 | + .map((entry) => entry.question) |
| 71 | + .filter((question) => question.status === "open") |
| 72 | + .map((question) => ({ |
| 73 | + questionId: question.id, |
| 74 | + ...who(question.botId), |
| 75 | + createdAt: question.createdAt, |
| 76 | + text: question.text, |
| 77 | + options: question.options, |
| 78 | + })); |
| 79 | + const oldest = (a: { createdAt: number }, b: { createdAt: number }) => |
| 80 | + a.createdAt - b.createdAt; |
| 81 | + return { approvals: approvals.sort(oldest), questions: questions.sort(oldest) }; |
| 82 | + }, |
| 83 | + "activity.today": ({ since }) => { |
| 84 | + const tasks = [...fake.conversation.tasks.values()].filter((task) => task.updatedAt >= since); |
| 85 | + const files = new Set<string>(); |
| 86 | + const lines = { added: 0, removed: 0 }; |
| 87 | + let requestsAnswered = 0; |
| 88 | + const work = new Map<string, number>(); |
| 89 | + for (const item of fake.chat.items) { |
| 90 | + const body = item.body; |
| 91 | + if ( |
| 92 | + body.kind === "tool" && |
| 93 | + item.createdAt >= since && |
| 94 | + body.status === "done" && |
| 95 | + body.lines |
| 96 | + ) { |
| 97 | + if (body.file) files.add(body.file); |
| 98 | + lines.added += body.lines.added; |
| 99 | + lines.removed += body.lines.removed; |
| 100 | + } |
| 101 | + if (body.kind === "approval" && item.updatedAt >= since) { |
| 102 | + if (body.status === "allowed" || body.status === "denied") requestsAnswered += 1; |
| 103 | + } |
| 104 | + if (body.kind === "turn" && item.createdAt >= since) { |
| 105 | + work.set(item.botId, (work.get(item.botId) ?? 0) + body.durationMs); |
| 106 | + } |
| 107 | + } |
| 108 | + const [top] = [...work.entries()].filter(([, ms]) => ms > 0).sort((a, b) => b[1] - a[1]); |
| 109 | + let busiest: BusyBot | null = null; |
| 110 | + if (top) { |
| 111 | + const bot = fake.bot(top[0], false); |
| 112 | + busiest = { |
| 113 | + botId: bot.id, |
| 114 | + name: bot.name, |
| 115 | + color: bot.color, |
| 116 | + crew: fake.crew(bot.crewId, false).name, |
| 117 | + workMs: top[1], |
| 118 | + }; |
| 119 | + } |
| 120 | + return { |
| 121 | + tasksDone: tasks.filter((task) => task.status === "done").length, |
| 122 | + tasksFailed: tasks.filter((task) => task.status === "failed" || task.status === "expired") |
| 123 | + .length, |
| 124 | + botMessages: fake.conversation.messages.filter( |
| 125 | + (message) => message.fromKind === "bot" && message.createdAt >= since, |
| 126 | + ).length, |
| 127 | + requestsAnswered, |
| 128 | + filesEdited: files.size, |
| 129 | + lines, |
| 130 | + busiest, |
| 131 | + }; |
| 132 | + }, |
| 133 | + }; |
| 134 | +} |
0 commit comments