import { createLanguageModelFromResolved } from "@tasks/ai"; import { markdownBacklogItems, modelProviders, roleModelRoutes, } from "@tasks/database/schema"; import { convoyObjectContentSchema, GATE_INBOX_STATUSES, } from "@tasks/shared/convoy-lifecycle"; import { providerTypeSchema, resolveModel, type ModelProvider, type RoleModelRoute, } from "@tasks/shared/model-routing"; import { TRPCError } from "@trpc/server"; import { generateText, tool } from "ai"; import { asc, eq } from "drizzle-orm"; import { z } from "zod"; import { listConvoyProjects, loadConvoyProject, } from "@/server/lib/convoy/convoy-service"; import { router, workspaceProcedure } from "@/server/trpc"; import type { WorkspaceProcedureContext } from "@/server/trpc"; type ProviderRow = typeof modelProviders.$inferSelect; type RouteRow = typeof roleModelRoutes.$inferSelect; function mapProviderRow(row: ProviderRow): ModelProvider { return { id: row.id, workspaceId: row.workspaceId, alias: row.alias, displayName: row.displayName, providerType: providerTypeSchema.parse(row.providerType), modelId: row.modelId, baseUrl: row.baseUrl, apiKeyEnvVar: row.apiKeyEnvVar, extraHeaders: row.extraHeaders, defaultParams: row.defaultParams, metadata: row.metadata, enabled: row.enabled, }; } function mapRouteRow(row: RouteRow): RoleModelRoute { return { id: row.id, workspaceId: row.workspaceId, roleId: row.roleId, providerAliasId: row.providerAliasId, enabled: row.enabled, sortOrder: row.sortOrder, }; } function readApiKey(envVarName: string): string | undefined { return process.env[envVarName]?.trim() || undefined; } function assertWorkspaceAdmin(_ctx: WorkspaceProcedureContext): void { // ops copilot is read-only; no admin check needed, but keep helper for future void _ctx; } const OPS_SYSTEM_PROMPT = "You are ops copilot for second brain OS. You help owners triage \"what needs me?\" across fleet, convoys and backlog. " + "Use the provided tools (fleetStatus, convoyGet, backlogListTree) to ground answers. " + "Always respond with links in form [workspaceSlug/convoySlug] for convoys and [workspaceSlug/planSlug/epicSlug/taskSlug] for backlog items. " + "Show fleet → needs you citations when answering gate questions. Be concise, actionable, no secrets."; const messageSchema = z.object({ role: z.enum(["user", "assistant"]), content: z.string().min(1).max(4000), }); export const opsCopilotRouter = router({ generateText: workspaceProcedure .input( z.object({ workspace: z.string().min(1), prompt: z.string().min(1).max(4000), messages: z.array(messageSchema).optional(), }), ) .mutation(async ({ ctx, input }) => { void assertWorkspaceAdmin(ctx); const providerRows = await ctx.db .select() .from(modelProviders) .where(eq(modelProviders.workspaceId, ctx.workspace.id)) .orderBy(asc(modelProviders.alias)); const routeRows = await ctx.db .select() .from(roleModelRoutes) .where(eq(roleModelRoutes.workspaceId, ctx.workspace.id)); const resolved = resolveModel({ roleId: "gate-copilot", providers: providerRows.map(mapProviderRow), routes: routeRows.map(mapRouteRow), getApiKey: readApiKey, }); if (!resolved.ok) { throw new TRPCError({ code: "BAD_REQUEST", message: resolved.error.message, }); } const modelResult = createLanguageModelFromResolved(resolved.model); if (!modelResult.ok) { throw new TRPCError({ code: "BAD_REQUEST", message: modelResult.reason, }); } // Bounded, workspace-scoped, read-only tools (<3 tools) const fleetStatusTool = tool({ description: "Get fleet gate inbox needing you for this workspace. Returns up to 10 gate items with links [workspaceSlug/convoySlug].", parameters: z.object({}), execute: async () => { const convoys = await listConvoyProjects(ctx.db, ctx.workspace.id); const gateInbox = convoys.filter((c) => (GATE_INBOX_STATUSES as readonly string[]).includes(c.status), ); const bounded = gateInbox.slice(0, 10); const payload = { workspace: ctx.workspace.slug, totalNeedingYou: gateInbox.length, items: bounded.map((c) => ({ convoyId: c.id, convoySlug: c.content?.convoySlug ?? c.title, status: c.status, classification: c.content?.classification ?? null, updatedAt: c.updatedAt.toISOString(), link: `${ctx.workspace.slug}/${c.content?.convoySlug ?? c.id}`, })), }; return JSON.stringify(payload); }, }); const convoyGetTool = tool({ description: "Get one convoy by id (workspace-scoped, read-only).", parameters: z.object({ convoyId: z.string().uuid(), }), execute: async ({ convoyId }: { convoyId: string }) => { const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, convoyId); if (!convoy) { return JSON.stringify({ error: "Convoy not found in this workspace" }); } const content = convoyObjectContentSchema.safeParse(convoy.content); const payload = { id: convoy.id, title: convoy.title, status: convoy.status ?? "draft", description: convoy.description ? convoy.description.slice(0, 1000) : null, content: content.success ? content.data : null, updatedAt: convoy.updatedAt.toISOString(), link: `${ctx.workspace.slug}/${content.success ? content.data.convoySlug : convoy.title}`, }; return JSON.stringify(payload); }, }); const backlogListTreeTool = tool({ description: "List backlog tree (plans/epics/tasks) for this workspace. Bounded to 20 items, read-only.", parameters: z.object({}), execute: async () => { const rows = await ctx.db .select({ id: markdownBacklogItems.id, kind: markdownBacklogItems.kind, slug: markdownBacklogItems.slug, planSlug: markdownBacklogItems.planSlug, epicSlug: markdownBacklogItems.epicSlug, title: markdownBacklogItems.title, status: markdownBacklogItems.status, priority: markdownBacklogItems.priority, repoPath: markdownBacklogItems.repoPath, updatedAt: markdownBacklogItems.updatedAt, }) .from(markdownBacklogItems) .where(eq(markdownBacklogItems.workspaceId, ctx.workspace.id)) .orderBy(asc(markdownBacklogItems.planSlug), asc(markdownBacklogItems.kind), asc(markdownBacklogItems.slug)); const bounded = rows.slice(0, 20); const payload = { workspace: ctx.workspace.slug, total: rows.length, items: bounded.map((r) => ({ id: r.id, kind: r.kind, slug: r.slug, planSlug: r.planSlug, epicSlug: r.epicSlug, title: r.title, status: r.status, priority: r.priority, repoPath: r.repoPath, link: `${ctx.workspace.slug}/${r.planSlug}${r.epicSlug ? `/${r.epicSlug}` : ""}/${r.slug}`, updatedAt: r.updatedAt.toISOString(), })), }; return JSON.stringify(payload); }, }); const historyMessages = (input.messages ?? []).map((m) => ({ role: m.role as "user" | "assistant", content: m.content, })); // Merge prompt as final user message; system is separate const promptMessages = [ ...historyMessages, { role: "user" as const, content: input.prompt }, ]; const system = [ OPS_SYSTEM_PROMPT, `Current workspace: ${ctx.workspace.name} (${ctx.workspace.slug})`, `Workspace id: ${ctx.workspace.id}`, ].join("\n"); const { text, toolCalls, steps } = await generateText({ model: modelResult.model, system, messages: promptMessages, tools: { fleetStatus: fleetStatusTool, convoyGet: convoyGetTool, backlogListTree: backlogListTreeTool, }, maxSteps: 3, temperature: 0.4, maxTokens: 1200, }); const trimmed = text.trim(); if (!trimmed) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Empty copilot response", }); } return { text: trimmed, resolvedAlias: resolved.resolvedAlias, modelId: resolved.model.modelId, providerType: resolved.model.providerType, toolCalls: toolCalls ?? [], steps: steps?.length ?? 0, }; }), }); // Alias for file naming: brief says copilot.ts with opsCopilot.generateText export const copilotRouter = opsCopilotRouter;