Submatter/apps/web/server/routers/copilot.ts

272 lines
9 KiB
TypeScript
Raw Normal View History

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;