Files
CapaKraken/packages/api/src/router/assistant-tools/comments.ts
T

199 lines
6.2 KiB
TypeScript

import { COMMENT_ENTITY_TYPE_VALUES, type CommentEntityType } from "@capakraken/shared";
import type { TRPCContext } from "../../trpc.js";
import { getCommentToolEntityDescription, getCommentToolScopeSentence } from "../../lib/comment-entity-registry.js";
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
type AssistantToolErrorResult = { error: string };
type CommentRecord = {
id: string;
body: string;
resolved: boolean;
createdAt: Date;
author: {
name: string | null;
email: string;
};
};
type CommentListRecord = CommentRecord & {
replies: CommentRecord[];
};
type CommentsDeps = {
createCommentCaller: (ctx: TRPCContext) => {
list: (params: {
entityType: CommentEntityType;
entityId: string;
}) => Promise<CommentListRecord[]>;
create: (params: {
entityType: CommentEntityType;
entityId: string;
body: string;
}) => Promise<CommentRecord>;
resolve: (params: {
id: string;
resolved: boolean;
}) => Promise<CommentRecord>;
};
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
toAssistantCommentCreationError: (
error: unknown,
) => AssistantToolErrorResult | null;
toAssistantCommentResolveError: (
error: unknown,
) => AssistantToolErrorResult | null;
};
export const commentReadToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "list_comments",
description: `List comments (with replies) for a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required.`,
parameters: {
type: "object",
properties: {
entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() },
entityId: { type: "string", description: "Entity ID" },
},
required: ["entityType", "entityId"],
},
},
},
], {});
export const commentMutationToolDefinitions: ToolDef[] = withToolAccess([
{
type: "function",
function: {
name: "create_comment",
description: `Add a comment to a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required. Supports @mentions. Always confirm with the user first.`,
parameters: {
type: "object",
properties: {
entityType: { type: "string", enum: [...COMMENT_ENTITY_TYPE_VALUES], description: getCommentToolEntityDescription() },
entityId: { type: "string", description: "Entity ID" },
body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." },
},
required: ["entityType", "entityId", "body"],
},
},
},
{
type: "function",
function: {
name: "resolve_comment",
description: `Mark a comment as resolved (or unresolve it) on a supported comment-enabled entity. ${getCommentToolScopeSentence()} Entity visibility is required, and only the comment author or an admin can change resolution.`,
parameters: {
type: "object",
properties: {
commentId: { type: "string", description: "Comment ID to resolve" },
resolved: { type: "boolean", description: "Set to true to resolve, false to unresolve. Default: true" },
},
required: ["commentId"],
},
},
},
], {});
export function createCommentExecutors(
deps: CommentsDeps,
): Record<string, ToolExecutor> {
return {
async list_comments(
params: { entityType: CommentEntityType; entityId: string },
ctx: ToolContext,
) {
const caller = deps.createCommentCaller(deps.createScopedCallerContext(ctx));
const comments = await caller.list({
entityType: params.entityType,
entityId: params.entityId,
});
return comments.map((comment) => ({
id: comment.id,
author: comment.author.name ?? comment.author.email,
body: comment.body,
resolved: comment.resolved,
createdAt: comment.createdAt.toISOString(),
replyCount: comment.replies.length,
replies: comment.replies.map((reply) => ({
id: reply.id,
author: reply.author.name ?? reply.author.email,
body: reply.body,
resolved: reply.resolved,
createdAt: reply.createdAt.toISOString(),
})),
}));
},
async create_comment(
params: { entityType: CommentEntityType; entityId: string; body: string },
ctx: ToolContext,
) {
if (params.body.length === 0) {
return { error: "Comment body is required." };
}
if (params.body.length > 10_000) {
return { error: "Comment body must be at most 10000 characters." };
}
const caller = deps.createCommentCaller(deps.createScopedCallerContext(ctx));
let comment;
try {
comment = await caller.create({
entityType: params.entityType,
entityId: params.entityId,
body: params.body,
});
} catch (error) {
const mapped = deps.toAssistantCommentCreationError(error);
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["comment"],
id: comment.id,
author: comment.author.name ?? comment.author.email,
body: comment.body,
createdAt: comment.createdAt.toISOString(),
};
},
async resolve_comment(
params: { commentId: string; resolved?: boolean },
ctx: ToolContext,
) {
const caller = deps.createCommentCaller(deps.createScopedCallerContext(ctx));
let updated;
try {
updated = await caller.resolve({
id: params.commentId,
resolved: params.resolved !== false,
});
} catch (error) {
const mapped = deps.toAssistantCommentResolveError(error);
if (mapped) {
return mapped;
}
throw error;
}
return {
__action: "invalidate",
scope: ["comment"],
id: updated.id,
resolved: updated.resolved,
author: updated.author.name ?? updated.author.email,
body: updated.body.slice(0, 100),
};
},
};
}