feat: ask ai discord bot (#6842)
Signed-off-by: The-Best-Codes <bestcodes.official@gmail.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { marked } from "marked";
|
||||
|
||||
const MAX_DISCORD_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Chunks markdown text intelligently, respecting markdown structure.
|
||||
* Avoids splitting code blocks, lists, and other block elements when possible.
|
||||
* Falls back to character-based splitting for oversized blocks.
|
||||
*
|
||||
* @param markdown - The markdown text to chunk
|
||||
* @param maxLength - Maximum length per chunk (default: 2000 for Discord)
|
||||
* @returns Array of markdown chunks
|
||||
*/
|
||||
export function chunkMarkdown(
|
||||
markdown: string,
|
||||
maxLength: number = MAX_DISCORD_LENGTH,
|
||||
): string[] {
|
||||
// If text is short enough, return as-is
|
||||
if (markdown.length <= maxLength) {
|
||||
return [markdown];
|
||||
}
|
||||
|
||||
const tokens = marked.lexer(markdown);
|
||||
const chunks: string[] = [];
|
||||
let currentChunk = "";
|
||||
|
||||
for (const token of tokens) {
|
||||
const tokenText = token.raw;
|
||||
|
||||
// If adding this token would exceed the limit
|
||||
if ((currentChunk + tokenText).length > maxLength) {
|
||||
// Save current chunk if it has content
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = "";
|
||||
}
|
||||
|
||||
// If the token itself is too large, we have to split it
|
||||
if (tokenText.length > maxLength) {
|
||||
// Fall back to character-based splitting for this oversized block
|
||||
const splits = characterSplit(tokenText, maxLength);
|
||||
chunks.push(...splits.slice(0, -1));
|
||||
currentChunk = splits[splits.length - 1];
|
||||
} else {
|
||||
// Token fits in a new chunk
|
||||
currentChunk = tokenText;
|
||||
}
|
||||
} else {
|
||||
// Token fits in current chunk
|
||||
currentChunk += tokenText;
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining content
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Character-based splitting with word boundary awareness.
|
||||
* Used as a fallback for oversized markdown blocks.
|
||||
*
|
||||
* @param text - The text to split
|
||||
* @param maxLength - Maximum length per chunk
|
||||
* @returns Array of text chunks
|
||||
*/
|
||||
function characterSplit(text: string, maxLength: number): string[] {
|
||||
if (text.length <= maxLength) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > maxLength) {
|
||||
let splitIndex = maxLength;
|
||||
const spaceIndex = remaining.lastIndexOf(" ", maxLength);
|
||||
|
||||
// If there's a space in the last 20% of the chunk, split there
|
||||
if (spaceIndex > maxLength * 0.8) {
|
||||
splitIndex = spaceIndex;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitIndex));
|
||||
remaining = remaining.slice(splitIndex).trimStart();
|
||||
}
|
||||
|
||||
if (remaining) {
|
||||
chunks.push(remaining);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { stepCountIs, streamText } from "ai";
|
||||
import type { Message, ThreadChannel } from "discord.js";
|
||||
import { model } from "../../clients/ai";
|
||||
import { logger } from "../logger";
|
||||
import { chunkMarkdown } from "./chunk-markdown";
|
||||
import { SYSTEM_PROMPT } from "./system-prompt";
|
||||
import { ToolTracker } from "./tool-tracker";
|
||||
import { aiTools } from "./tools";
|
||||
|
||||
export interface MessageHistoryItem {
|
||||
author: string;
|
||||
content: string;
|
||||
isBot: boolean;
|
||||
}
|
||||
|
||||
export interface AnswerQuestionOptions {
|
||||
question: string;
|
||||
thread: ThreadChannel;
|
||||
userId: string;
|
||||
messageHistory?: MessageHistoryItem[];
|
||||
statusMessage?: Message;
|
||||
}
|
||||
|
||||
export async function answerQuestion({
|
||||
question,
|
||||
thread,
|
||||
userId,
|
||||
messageHistory,
|
||||
statusMessage,
|
||||
}: AnswerQuestionOptions): Promise<void> {
|
||||
try {
|
||||
let prompt = question;
|
||||
if (messageHistory && messageHistory.length > 0) {
|
||||
const historyContext = messageHistory
|
||||
.slice(0, -1)
|
||||
.map((msg) => `${msg.author}: ${msg.content}`)
|
||||
.join("\n");
|
||||
|
||||
if (historyContext) {
|
||||
prompt = `# Previous conversation\n${historyContext}\n\n# New message\n${messageHistory[messageHistory.length - 1].author}: ${question}`;
|
||||
}
|
||||
}
|
||||
|
||||
const tracker = new ToolTracker();
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt,
|
||||
tools: aiTools,
|
||||
maxOutputTokens: 2048,
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
|
||||
for await (const event of result.fullStream) {
|
||||
if (event.type === "tool-call") {
|
||||
if (event.toolName === "search_docs" && statusMessage) {
|
||||
try {
|
||||
await statusMessage.edit("Searching the docs...");
|
||||
} catch (error) {
|
||||
logger.verbose("Failed to update status message:", error);
|
||||
}
|
||||
} else if (event.toolName === "view_docs" && statusMessage) {
|
||||
const input = event.input as { filePaths?: string | string[] };
|
||||
const filePaths = input.filePaths;
|
||||
const pathArray = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
const pagesText = pathArray.length === 1 ? "page" : "pages";
|
||||
try {
|
||||
await statusMessage.edit(
|
||||
`Viewing ${pathArray.length} ${pagesText}...`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.verbose("Failed to update status message:", error);
|
||||
}
|
||||
}
|
||||
} else if (event.type === "tool-result") {
|
||||
if (event.toolName === "search_docs") {
|
||||
const resultText = String(event.output);
|
||||
const fileMatches = resultText.match(/\*\*[^*]+\*\*/g) || [];
|
||||
tracker.recordSearchCall(fileMatches.map((_, i) => `result_${i}`));
|
||||
} else if (event.toolName === "view_docs") {
|
||||
const input = event.input as { filePaths?: string | string[] };
|
||||
const filePaths = input.filePaths;
|
||||
const pathArray = Array.isArray(filePaths)
|
||||
? filePaths
|
||||
: filePaths
|
||||
? [filePaths]
|
||||
: [];
|
||||
if (pathArray.length > 0) {
|
||||
tracker.recordViewCall(pathArray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (statusMessage) {
|
||||
try {
|
||||
const summary = tracker.getSummary();
|
||||
await statusMessage.edit(summary || "Just a sec...");
|
||||
} catch (error) {
|
||||
logger.verbose("Failed to update final status message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const fullText = await result.text;
|
||||
const chunks = chunkMarkdown(fullText);
|
||||
for (const chunk of chunks) {
|
||||
await thread.send(chunk);
|
||||
}
|
||||
|
||||
const totalUsage = await result.usage;
|
||||
const { totalTokens } = totalUsage;
|
||||
logger.verbose(
|
||||
`Answered question for user ${userId}, tokens: ${totalTokens}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to answer question:", error);
|
||||
await thread.send(
|
||||
"Sorry, I encountered an error while researching your question. Please try again.",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const SYSTEM_PROMPT = `You are a helpful assistant in the goose Discord server.
|
||||
Your role is to provide assistance and answer questions about codename goose, an open-source AI agent developed by Block. codename goose's website is \`https://block.github.io/goose\`. Your answers should be short and to the point. Always assume that a user's question is related to codename goose unless they specifically state otherwise. DO NOT capitalize "goose" or "codename goose".
|
||||
|
||||
When answering questions about goose:
|
||||
1. Use the \`search_docs\` tool to find relevant documentation
|
||||
2. Use the \`view_docs\` tool to read documentation (read all relevant files to get the full picture)
|
||||
3. Cite the documentation source in your response (using its Web URL)
|
||||
|
||||
When providing links, wrap the URL in angle brackets (e.g., \`<https://example.com>\` or \`[Example](<https://example.com>)\`) to prevent excessive link previews. Do not use backtick characters around the URL.`;
|
||||
@@ -0,0 +1,39 @@
|
||||
export class ToolTracker {
|
||||
private searchCalls: number = 0;
|
||||
private searchResults: Set<string> = new Set();
|
||||
private viewedPaths: Set<string> = new Set();
|
||||
|
||||
recordSearchCall(results: string[]): void {
|
||||
this.searchCalls++;
|
||||
results.forEach((result) => this.searchResults.add(result));
|
||||
}
|
||||
|
||||
recordViewCall(filePaths: string | string[]): void {
|
||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
paths.forEach((path) => this.viewedPaths.add(path));
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (this.searchCalls > 0) {
|
||||
const resultCount = this.searchResults.size;
|
||||
const timesText = this.searchCalls === 1 ? "time" : "times";
|
||||
const resultsText = resultCount === 1 ? "result" : "results";
|
||||
parts.push(
|
||||
`searched ${this.searchCalls} ${timesText} with ${resultCount} ${resultsText}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.viewedPaths.size > 0) {
|
||||
const pageCount = this.viewedPaths.size;
|
||||
const pagesText = pageCount === 1 ? "page" : "pages";
|
||||
parts.push(`viewed ${pageCount} ${pagesText}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return "";
|
||||
|
||||
const firstPart = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
||||
return parts.length === 1 ? firstPart : firstPart + ", " + parts[1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import fs from "fs";
|
||||
import MiniSearch from "minisearch";
|
||||
import path from "path";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export interface SearchResult {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
score: number;
|
||||
preview: string;
|
||||
lineCount: number;
|
||||
webUrl: string;
|
||||
}
|
||||
|
||||
interface DocFile {
|
||||
id: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
lineCount: number;
|
||||
}
|
||||
|
||||
let miniSearch: MiniSearch<DocFile> | null = null;
|
||||
|
||||
function getDocsDir(): string {
|
||||
return process.env.DOCS_PATH || path.join(process.cwd(), "docs");
|
||||
}
|
||||
|
||||
function initializeSearch(): MiniSearch<DocFile> {
|
||||
if (miniSearch) {
|
||||
return miniSearch;
|
||||
}
|
||||
|
||||
const docsDir = path.resolve(getDocsDir());
|
||||
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
logger.warn(`Docs directory not found at ${docsDir}`);
|
||||
miniSearch = new MiniSearch({
|
||||
fields: ["content", "fileName", "path"],
|
||||
storeFields: ["path", "fileName", "content", "lineCount"],
|
||||
});
|
||||
return miniSearch;
|
||||
}
|
||||
|
||||
const docs: DocFile[] = [];
|
||||
|
||||
function walkDir(dir: string) {
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (file === "assets" || file === "docker") {
|
||||
continue;
|
||||
}
|
||||
walkDir(filePath);
|
||||
} else {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const relativePath = path.relative(docsDir, filePath);
|
||||
const docFile: DocFile = {
|
||||
id: relativePath,
|
||||
path: relativePath,
|
||||
fileName: file,
|
||||
content,
|
||||
lineCount: content.split("\n").length,
|
||||
};
|
||||
docs.push(docFile);
|
||||
} catch (error) {
|
||||
logger.error(`Error reading file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error walking directory ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
walkDir(docsDir);
|
||||
|
||||
miniSearch = new MiniSearch({
|
||||
fields: ["content", "fileName", "path"],
|
||||
storeFields: ["path", "fileName", "content", "lineCount"],
|
||||
});
|
||||
|
||||
miniSearch.addAll(docs);
|
||||
logger.verbose(`Loaded ${docs.length} documentation files`);
|
||||
|
||||
return miniSearch;
|
||||
}
|
||||
|
||||
function generateWebUrl(filePath: string): string {
|
||||
const baseUrl = "https://block.github.io/goose/docs";
|
||||
// Remove file extension for the URL path
|
||||
const urlPath = filePath.replace(/\.[^/.]+$/, "");
|
||||
return `${baseUrl}/${urlPath}`;
|
||||
}
|
||||
|
||||
function getPreview(content: string, maxLength: number = 200): string {
|
||||
const withoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, "");
|
||||
const lines = withoutFrontmatter.split("\n");
|
||||
let preview = "";
|
||||
|
||||
for (const line of lines) {
|
||||
const cleanLine = line
|
||||
.replace(/^#+\s+/, "")
|
||||
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
|
||||
.replace(/[*_]/g, "")
|
||||
.trim();
|
||||
|
||||
if (
|
||||
cleanLine &&
|
||||
!cleanLine.startsWith("import") &&
|
||||
!cleanLine.startsWith("export")
|
||||
) {
|
||||
preview = cleanLine;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (preview.length > maxLength) {
|
||||
preview = preview.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
return preview || "(No preview available)";
|
||||
}
|
||||
|
||||
export function searchDocs(query: string, limit: number = 5): SearchResult[] {
|
||||
const search = initializeSearch();
|
||||
const results = search.search(query).slice(0, limit);
|
||||
|
||||
if (results.length === 0) {
|
||||
logger.verbose(`Search for "${query}" returned no results`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchResults: SearchResult[] = results.map((result) => ({
|
||||
filePath: result.path,
|
||||
fileName: result.fileName,
|
||||
score: result.score,
|
||||
preview: getPreview(result.content),
|
||||
lineCount: result.lineCount,
|
||||
webUrl: generateWebUrl(result.path),
|
||||
}));
|
||||
|
||||
logger.verbose(
|
||||
`Search for "${query}" returned ${searchResults.length} results`,
|
||||
);
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
export function reloadDocsCache(): void {
|
||||
miniSearch = null;
|
||||
initializeSearch();
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
function getDocsDir(): string {
|
||||
return process.env.DOCS_PATH || path.join(process.cwd(), "docs");
|
||||
}
|
||||
|
||||
function generateWebUrl(filePath: string): string {
|
||||
const baseUrl = "https://block.github.io/goose/docs";
|
||||
// Remove file extension for the URL path
|
||||
const urlPath = filePath.replace(/\.[^/.]+$/, "");
|
||||
return `${baseUrl}/${urlPath}`;
|
||||
}
|
||||
|
||||
function findDocFile(partialPath: string): string | null {
|
||||
const docsDir = getDocsDir();
|
||||
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchTerm = partialPath.toLowerCase().replace(/\.md$/, "");
|
||||
let foundPath: string | null = null;
|
||||
|
||||
function walkDir(dir: string) {
|
||||
if (foundPath) return;
|
||||
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (file === "assets" || file === "docker") {
|
||||
continue;
|
||||
}
|
||||
walkDir(filePath);
|
||||
} else {
|
||||
const relativePath = path.relative(docsDir, filePath);
|
||||
if (relativePath.toLowerCase().includes(searchTerm)) {
|
||||
foundPath = relativePath;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDir(docsDir);
|
||||
return foundPath;
|
||||
}
|
||||
|
||||
function getDocChunk(
|
||||
filePath: string,
|
||||
startLine: number = 0,
|
||||
lineCount: number = 100,
|
||||
): { fileName: string; content: string; webUrl: string } {
|
||||
const docsDir = path.resolve(getDocsDir());
|
||||
const fullPath = path.join(docsDir, filePath);
|
||||
|
||||
const normalizedPath = path.resolve(fullPath);
|
||||
if (!normalizedPath.startsWith(docsDir)) {
|
||||
throw new Error("Invalid file path - directory traversal not allowed");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`Documentation file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
const actualStartLine = Math.max(0, Math.min(startLine, lines.length - 1));
|
||||
const actualEndLine = Math.min(actualStartLine + lineCount, lines.length);
|
||||
const chunkLines = lines.slice(actualStartLine, actualEndLine);
|
||||
const chunkContent = chunkLines.join("\n");
|
||||
|
||||
const fileName = path.basename(fullPath);
|
||||
|
||||
return {
|
||||
content: chunkContent,
|
||||
fileName,
|
||||
webUrl: generateWebUrl(filePath),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("ENOENT")) {
|
||||
throw new Error(`Documentation file not found: ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function viewDocs(
|
||||
filePaths: string | string[],
|
||||
startLine: number = 0,
|
||||
lineCount: number = 100,
|
||||
): string {
|
||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
|
||||
const docs = paths.map((filePath) => {
|
||||
let resolvedPath = filePath;
|
||||
// Check if file has an extension; if not, search for it
|
||||
if (!path.extname(filePath)) {
|
||||
const found = findDocFile(filePath);
|
||||
if (found) {
|
||||
resolvedPath = found;
|
||||
}
|
||||
}
|
||||
return getDocChunk(resolvedPath, startLine, lineCount);
|
||||
});
|
||||
|
||||
return docs
|
||||
.map(
|
||||
(doc) =>
|
||||
`**${doc.fileName}**\nWeb URL: <${doc.webUrl}>\n\`\`\`\n${doc.content}\n\`\`\``,
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { logger } from "../../logger";
|
||||
import { searchDocs } from "./docs-search";
|
||||
import { viewDocs } from "./docs-viewer";
|
||||
|
||||
export const aiTools = {
|
||||
search_docs: tool({
|
||||
description: "Search the goose documentation for relevant information",
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"Search query for the documentation (example: 'sessions', 'tool management')",
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Maximum number of results to return (default 5)"),
|
||||
}),
|
||||
execute: async ({ query, limit = 5 }) => {
|
||||
const results = searchDocs(query, limit);
|
||||
logger.verbose(
|
||||
`Searched docs for "${query}", found ${results.length} results`,
|
||||
);
|
||||
|
||||
if (results.length === 0) {
|
||||
return "No relevant documentation found for your query. Try different keywords.";
|
||||
}
|
||||
|
||||
return results
|
||||
.map(
|
||||
(r) =>
|
||||
`**${r.fileName}** (${r.filePath})\nPreview: ${r.preview}\nWeb URL: <${r.webUrl}>`,
|
||||
)
|
||||
.join("\n\n");
|
||||
},
|
||||
}),
|
||||
view_docs: tool({
|
||||
description: "View documentation file(s)",
|
||||
inputSchema: z.object({
|
||||
filePaths: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.describe(
|
||||
"Path or array of paths to documentation files (example: 'quickstart.md' or ['guides/managing-projects.md', 'api/overview.md'])",
|
||||
),
|
||||
startLine: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Starting line number (0-indexed, default 0)"),
|
||||
lineCount: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Number of lines to show (default 100)"),
|
||||
}),
|
||||
execute: async ({ filePaths, startLine = 0, lineCount = 100 }) => {
|
||||
try {
|
||||
const result = viewDocs(filePaths, startLine, lineCount);
|
||||
const count = Array.isArray(filePaths) ? filePaths.length : 1;
|
||||
logger.verbose(`Viewed ${count} documentation file(s)`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error(`Error viewing docs: ${errorMsg}`);
|
||||
return `Error viewing documentation: ${errorMsg}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
import consola from "consola";
|
||||
export { consola as logger };
|
||||
Reference in New Issue
Block a user