import type { PrismaClient } from "@prisma/client"; import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js"; import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js"; export interface SlashInvocation { readonly name: string; readonly args: readonly string[]; } export interface SlashCommandRunContext { readonly invocation: SlashInvocation; readonly projectId: string; readonly chatId: string; readonly rt: FeishuRuntime; readonly sendOptions?: SendMessageOptions | undefined; } export interface SlashCommandDefinition { readonly name: "help" | "project" | "usage"; readonly usage: string; readonly summary: string; readonly details: readonly string[]; run(context: SlashCommandRunContext): Promise; } const TERMINAL_RUN_STATUSES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELED"] as const; export function parseSlashInvocation(prompt: string): SlashInvocation | null { const trimmed = prompt.trim(); if (!trimmed.startsWith("/")) return null; const tokens = trimmed.split(/\s+/); const rawCommand = tokens[0]; if (rawCommand === undefined || rawCommand.length <= 1) return null; return { name: rawCommand.slice(1), args: tokens.slice(1) }; } export function createSlashCommandRegistry( deps: { readonly prisma: PrismaClient }, ): ReadonlyMap { const commands = new Map(); const add = (command: SlashCommandDefinition): void => { if (commands.has(command.name)) throw new Error(`duplicate slash command definition: /${command.name}`); commands.set(command.name, command); }; add({ name: "help", usage: "/help [project|usage]", summary: "查看 Hub 命令。", details: ["未知 slash 命令会明确报错,不会发送给 Agent。"], run: async ({ invocation, chatId, rt, sendOptions }) => { if (invocation.args.length > 1) { await sendText(rt, chatId, "用法: /help [project|usage]", sendOptions); return; } const target = invocation.args[0]; await sendText(rt, chatId, target === undefined ? formatSlashOverview(commands) : formatSlashHelpTarget(target, commands), sendOptions); }, }); add({ name: "project", usage: "/project", summary: "打开当前项目控制台,切换角色、管理会话和项目。", details: ["不同区域按当前操作者的项目与组织权限显示。"], run: async ({ chatId, rt, sendOptions }) => { await sendText(rt, chatId, "请在绑定的项目群中使用 /project。", sendOptions); }, }); add({ name: "usage", usage: "/usage [current|project]", summary: "查看 Hub 记录的真实 Agent 用量与成本。", details: ["current 只统计当前角色的活跃会话;project 统计整个项目。"], run: async ({ invocation, projectId, chatId, rt, sendOptions }) => { const scope = invocation.args[0] ?? "current"; if (invocation.args.length > 1 || (scope !== "current" && scope !== "project")) { await sendText(rt, chatId, "用法: /usage [current|project]", sendOptions); return; } const sessionIds = scope === "project" ? undefined : await currentRoleSessionIds(deps.prisma, projectId, chatId); const runs = await deps.prisma.agentRun.findMany({ where: { projectId, ...(sessionIds === undefined ? {} : { sessionId: { in: sessionIds } }), status: { in: [...TERMINAL_RUN_STATUSES] }, finishedAt: { not: null }, }, orderBy: { finishedAt: "asc" }, select: { usageFacts: { select: { provider: true, model: true, inputTokens: true, outputTokens: true, costUsd: true, }, orderBy: { occurredAt: "asc" }, }, }, }); await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions); }, }); return commands; } async function currentRoleSessionIds( prisma: PrismaClient, projectId: string, chatId: string, ): Promise { const binding = await prisma.projectGroupBinding.findFirst({ where: { projectId, chatId, archivedAt: null }, select: { selectedRole: { select: { roleId: true } } }, }); if (binding === null) throw new Error("active project-group binding not found"); const sessions = await prisma.agentSession.findMany({ where: { projectId, roleId: binding.selectedRole.roleId, archivedAt: null }, select: { id: true }, }); return sessions.map((session) => session.id); } interface UsageRunWithFacts { readonly usageFacts: readonly { readonly provider: string; readonly model: string | null; readonly inputTokens: number | null; readonly outputTokens: number | null; readonly costUsd: unknown; }[]; } function formatUsageReport(runs: readonly UsageRunWithFacts[], scope: "current" | "project"): string { if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`; // Bucket by (fact.provider, fact.model) — ADR-0026: external capabilities // carry their own provider/model and contribute their own meter, so a run // with a main loop + an external call lands in two buckets. A run with no // cost-bearing fact is "unrecorded" (ADR-0022: missing cost ≠ zero). const buckets = new Map(); let recordedRuns = 0; let unrecordedRuns = 0; let totalInputTokens = 0; let totalOutputTokens = 0; let totalCostUsd = 0; for (const run of runs) { const facts = run.usageFacts; if (facts.length === 0 || !facts.some((f) => f.costUsd !== null && f.costUsd !== undefined)) { unrecordedRuns++; // Tokens from unrecorded runs still count toward totals (matches pre-0026). for (const f of facts) { totalInputTokens += f.inputTokens ?? 0; totalOutputTokens += f.outputTokens ?? 0; } continue; } recordedRuns++; for (const f of facts) { const costUsd = decimalToNumberOrNull(f.costUsd); if (costUsd === null) continue; const model = f.model ?? "(unknown model)"; const key = `${f.provider}\u0000${model}`; const bucket = buckets.get(key) ?? { provider: f.provider, model, facts: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, }; bucket.facts += 1; bucket.inputTokens += f.inputTokens ?? 0; bucket.outputTokens += f.outputTokens ?? 0; bucket.costUsd += costUsd; buckets.set(key, bucket); totalInputTokens += f.inputTokens ?? 0; totalOutputTokens += f.outputTokens ?? 0; totalCostUsd += costUsd; } } const lines = [ `${scope === "current" ? "当前角色会话" : "当前项目"}用量`, `已记录成本: ${formatInteger(recordedRuns)} runs · ${formatUsd(totalCostUsd)}`, `Tokens: input ${formatInteger(totalInputTokens)} / output ${formatInteger(totalOutputTokens)}`, ]; if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`); for (const bucket of buckets.values()) { lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.facts)} 次, ${formatUsd(bucket.costUsd)}`); } return lines.join("\n"); } export function formatSlashHelpTarget( target: string, commands: ReadonlyMap, ): string { const normalized = target.trim().replace(/^\/+/, ""); const command = commands.get(normalized); if (command === undefined) return [`未知 slash 命令 /${normalized}。`, "", formatSlashOverview(commands)].join("\n"); return [ `/${command.name}`, command.summary, "", `用法: ${command.usage}`, ...command.details.map((detail) => `- ${detail}`), ].join("\n"); } function formatSlashOverview(commands: ReadonlyMap): string { return [ "可用 slash 命令:", ...[...commands.values()].map((command) => `/${command.name} - ${command.summary}`), "", "普通消息会使用 /project 中选定的当前角色。", ].join("\n"); }