diff --git a/commands.json b/commands.json index 531bfa8..ac582f7 100644 --- a/commands.json +++ b/commands.json @@ -87,6 +87,20 @@ } ] }, + { + "name": "research", + "type": 1, + "description": "Research a topic using web search.", + "options": [ + { + "name": "prompt", + "description": "The topic or question to research.", + "type": 3, + "required": true, + "max_length": 2000 + } + ] + }, { "name": "query", "type": 1, diff --git a/src/commands/research.ts b/src/commands/research.ts new file mode 100644 index 0000000..69ace58 --- /dev/null +++ b/src/commands/research.ts @@ -0,0 +1,144 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + AttachmentBuilder, + MessageFlags, + type ChatInputCommandInteraction, +} from "discord.js"; +import { ids } from "../config/ids.js"; +import { anthropic } from "../utils/anthropic.js"; +import { logger } from "../utils/logger.js"; +import { amariPersonality } from "../utils/makeAiRequest.js"; +import type { Amari } from "../interfaces/amari.js"; +import type { ContentBlock } from "@anthropic-ai/sdk/resources/messages.js"; + +const researchSystemPrompt = `${amariPersonality}\n\nYour role in this` + + " conversation is to research the user's query thoroughly using web search." + + " Provide a comprehensive, well-structured response with cited sources." + + " Format your response clearly with headers and sections where" + + " appropriate."; + +/** + * Formats a single content block into a markdown string. + * @param block - The content block to format. + * @returns A formatted markdown string. + */ +const formatBlock = (block: ContentBlock): string => { + if (block.type === "text") { + if ((block.citations?.length ?? 0) > 0) { + const sources = (block.citations ?? []). + filter((citation) => { + return citation.type === "web_search_result_location"; + }). + map((citation) => { + return `- [${citation.title ?? "Unknown Title"}](${citation.url})`; + }). + join("\n"); + return `${block.text}\n\n**Sources:**\n${sources}`; + } + return block.text; + } + if (block.type === "server_tool_use") { + return `> 🔍 *Searching: ${JSON.stringify(block.input)}*`; + } + if (block.type === "web_search_tool_result") { + if (!Array.isArray(block.content)) { + return ""; + } + const links = block.content. + map((entry) => { + return `[${entry.title}](${entry.url})`; + }). + join(", "); + return `> 📄 *Found: ${links}*`; + } + if (block.type === "thinking") { + return `> 💭 *${block.thinking}*`; + } + return ""; +}; + +/** + * Builds a markdown buffer from the research prompt and API response content. + * @param prompt - The original research prompt. + * @param content - The content blocks returned from the API. + * @returns A Buffer containing the formatted markdown. + */ +const buildMarkdownFile = ( + prompt: string, + content: Array, +): Buffer => { + const markdown = [ + `# Research: ${prompt}`, + "", + ...content. + map((block) => { + return formatBlock(block); + }). + filter((block) => { + return block.length > 0; + }), + ].join("\n\n"); + return Buffer.from(markdown, "utf-8"); +}; + +/** + * Runs a web-search-backed research query and returns the result as a + * markdown file attachment. + * @param _amari - The Amari instance (unused but kept for handler consistency). + * @param interaction - The Discord slash command interaction. + */ +export const research = async( + _amari: Amari, + interaction: ChatInputCommandInteraction, +): Promise => { + if (interaction.user.id !== ids.users.naomi) { + await interaction.reply({ + content: "This command is restricted to Naomi.", + flags: [ MessageFlags.Ephemeral ], + }); + return; + } + + const prompt = interaction.options.getString("prompt", true); + + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + + try { + const response = await anthropic.messages.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. + max_tokens: 3000, + messages: [ { content: prompt, role: "user" } ], + model: "claude-sonnet-4-6", + system: researchSystemPrompt, + tools: [ { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Anthropic API field. + max_uses: 5, + name: "web_search", + type: "web_search_20250305", + } ], + }); + + const file = new AttachmentBuilder( + buildMarkdownFile(prompt, response.content), + { name: "research.md" }, + ); + + await logger.metric("research_query", 1, { user: interaction.user.id }); + await interaction.editReply({ + content: "Here are your research results!", + files: [ file ], + }); + } catch (error) { + await logger.error("research command", error instanceof Error + ? error + : new Error(String(error))); + await interaction.editReply({ + content: "Something went wrong whilst running the research query.", + }); + } +}; diff --git a/src/events/handleInteractionCreate.ts b/src/events/handleInteractionCreate.ts index 72be571..3f1d47a 100644 --- a/src/events/handleInteractionCreate.ts +++ b/src/events/handleInteractionCreate.ts @@ -15,6 +15,7 @@ import { forwardToOwner } from "../commands/forwardToOwner.js"; import { onboardMentee } from "../commands/onboardMentee.js"; import { query } from "../commands/query.js"; import { remind } from "../commands/remind.js"; +import { research } from "../commands/research.js"; import { ids } from "../config/ids.js"; import type { Amari } from "../interfaces/amari.js"; @@ -46,6 +47,10 @@ const handleChatInputCommand = ( } if (commandName === "query") { void query(amari, interaction); + return; + } + if (commandName === "research") { + void research(amari, interaction); } };