morph-codeseek
Advanced tools
| export { runAgent } from "./runner.js"; | ||
| export { buildInitialState } from "./initial-state.js"; | ||
| export { getSystemPrompt } from "./prompt.js"; | ||
| export type { AgentRunResult, SessionConfig, FinishResult } from "./session-config.js"; |
| export { runAgent } from "./runner.js"; | ||
| export { buildInitialState } from "./initial-state.js"; | ||
| export { getSystemPrompt } from "./prompt.js"; |
| export declare const INITIAL_STATE_MAX_DEPTH = 2; | ||
| export declare const INITIAL_STATE_MAX_RESULTS = 500; | ||
| export declare const LARGE_REPO_FILE_COUNT_THRESHOLD = 5000; | ||
| export declare function buildInitialState(repoPath: string, queryText?: string | null): Promise<string>; |
| import path from "path"; | ||
| import { analyse } from "../tools/analyse.js"; | ||
| import { formatRepoOverview } from "../utils/repo-overview.js"; | ||
| import { getTrackedFiles, searchFilesHybrid } from "../utils/file-finder.js"; | ||
| export const INITIAL_STATE_MAX_DEPTH = 2; | ||
| export const INITIAL_STATE_MAX_RESULTS = 500; | ||
| export const LARGE_REPO_FILE_COUNT_THRESHOLD = 5000; | ||
| export async function buildInitialState(repoPath, queryText) { | ||
| const resolvedPath = path.resolve(repoPath); | ||
| // First, get the total number of files to decide the analysis depth. | ||
| // This is fast as it uses a cached ripgrep call. | ||
| const allFiles = await getTrackedFiles(resolvedPath); | ||
| const isLargeRepo = allFiles.length > LARGE_REPO_FILE_COUNT_THRESHOLD; | ||
| const maxDepth = isLargeRepo ? INITIAL_STATE_MAX_DEPTH - 1 : INITIAL_STATE_MAX_DEPTH; | ||
| const overviewPromise = analyse(resolvedPath, null, INITIAL_STATE_MAX_RESULTS, maxDepth); | ||
| const searchPromise = queryText ? searchFilesHybrid(resolvedPath, queryText) : Promise.resolve([]); | ||
| const [entries, initialSearchResults] = await Promise.allSettled([overviewPromise, searchPromise]); | ||
| let overviewEntries = []; | ||
| if (entries.status === "fulfilled") { | ||
| overviewEntries = entries.value; | ||
| } | ||
| const searchResults = initialSearchResults.status === "fulfilled" ? initialSearchResults.value : null; | ||
| return formatRepoOverview(overviewEntries, resolvedPath, searchResults && searchResults.length > 0 ? searchResults : null); | ||
| } |
| export declare const SYSTEM_PROMPT = "You are a code search agent. Your task is to find relevant code snippets based on a search query.\n\n<workflow>\nYou operate in exactly 3 rounds of tool exploration, followed by a final answer:\n\n1. In each round, you can make MULTIPLE tool calls (up to 8) to search in parallel. All tool results will be returned together after each round.\n2. After your third round of tool calls, your next turn MUST be a single call to the `finish` tool with all the context you have found.\n</workflow>\n\n<tool_calling>\nYou have tools at your disposal to solve the coding task. Follow these rules regarding tool calls:\n\n### 1. `analyse` - Explore Directories\nExplore directory structure in a tree-like format.\n**Syntax:** `analyse <path> [pattern]`\n- `<path>`: Directory path to analyze (defaults to `.`)\n- `[pattern]`: Optional regex pattern to filter names\n\nFor example:\n```\nanalyse src/api\nanalyse . \"test\"\n```\n\n### 2. `read` - Read File Contents\nRead entire files or specific line ranges.\n**Syntax:** `read <path>[:start-end]`\n- `<path>`: File path to read\n- `[:start-end]`: Optional 1-based, inclusive line range\n\nFor example:\n```\nread src/main.py\nread src/database/connection.py:10-50\n```\n\n### 3. `grep` - Search with Regex\nSearch for regex patterns across files using ripgrep.\n**Syntax:** `grep '<pattern>' <path>`\n- `'<pattern>'`: Regex pattern (always wrap in single quotes)\n- `<path>`: Directory or file to search (use `.` for the repo root)\n\nFor example:\n```\ngrep 'create_user' .\ngrep 'import.*requests' src/api\ngrep 'class\\\\s+AuthService' controllers/auth.py\n```\n\n### 4. `finish` - Submit Final Answer\nSubmit your findings when complete.\n**Syntax:** `finish <file1:range1,range2...> [file2:range3...]`\n- Provide file paths with colon-separated, comma-separated line ranges\n\nFor example:\n```\nfinish src/api/auth.py:25-50,75-80 src/models/user.py:10-15\n```\n</tool_calling>\n\n<strategy>\n- Use the `analyse`, `grep`, and `read` tools to gather information about the codebase.\n- Leverage the tools smartly to make full use of their potential\n- Make parallel tool calls within each round to investigate multiple paths or files efficiently\n- Be systematic and thorough within your 3-round limit\n</strategy>\n\n<output_format>\n- Only output tool calls themselves\n- Do not include explanatory text, reasoning, or commentary\n- Each tool call should be on its own line\n- After 3 rounds of exploration, call `finish` with all relevant code snippets you found\n</output_format>\n\nBegin your exploration now to find code relevant to the query."; | ||
| export declare function getSystemPrompt(): string; |
| export const SYSTEM_PROMPT = `You are a code search agent. Your task is to find relevant code snippets based on a search query. | ||
| <workflow> | ||
| You operate in exactly 3 rounds of tool exploration, followed by a final answer: | ||
| 1. In each round, you can make MULTIPLE tool calls (up to 8) to search in parallel. All tool results will be returned together after each round. | ||
| 2. After your third round of tool calls, your next turn MUST be a single call to the \`finish\` tool with all the context you have found. | ||
| </workflow> | ||
| <tool_calling> | ||
| You have tools at your disposal to solve the coding task. Follow these rules regarding tool calls: | ||
| ### 1. \`analyse\` - Explore Directories | ||
| Explore directory structure in a tree-like format. | ||
| **Syntax:** \`analyse <path> [pattern]\` | ||
| - \`<path>\`: Directory path to analyze (defaults to \`.\`) | ||
| - \`[pattern]\`: Optional regex pattern to filter names | ||
| For example: | ||
| \`\`\` | ||
| analyse src/api | ||
| analyse . "test" | ||
| \`\`\` | ||
| ### 2. \`read\` - Read File Contents | ||
| Read entire files or specific line ranges. | ||
| **Syntax:** \`read <path>[:start-end]\` | ||
| - \`<path>\`: File path to read | ||
| - \`[:start-end]\`: Optional 1-based, inclusive line range | ||
| For example: | ||
| \`\`\` | ||
| read src/main.py | ||
| read src/database/connection.py:10-50 | ||
| \`\`\` | ||
| ### 3. \`grep\` - Search with Regex | ||
| Search for regex patterns across files using ripgrep. | ||
| **Syntax:** \`grep '<pattern>' <path>\` | ||
| - \`'<pattern>'\`: Regex pattern (always wrap in single quotes) | ||
| - \`<path>\`: Directory or file to search (use \`.\` for the repo root) | ||
| For example: | ||
| \`\`\` | ||
| grep 'create_user' . | ||
| grep 'import.*requests' src/api | ||
| grep 'class\\\\s+AuthService' controllers/auth.py | ||
| \`\`\` | ||
| ### 4. \`finish\` - Submit Final Answer | ||
| Submit your findings when complete. | ||
| **Syntax:** \`finish <file1:range1,range2...> [file2:range3...]\` | ||
| - Provide file paths with colon-separated, comma-separated line ranges | ||
| For example: | ||
| \`\`\` | ||
| finish src/api/auth.py:25-50,75-80 src/models/user.py:10-15 | ||
| \`\`\` | ||
| </tool_calling> | ||
| <strategy> | ||
| - Use the \`analyse\`, \`grep\`, and \`read\` tools to gather information about the codebase. | ||
| - Leverage the tools smartly to make full use of their potential | ||
| - Make parallel tool calls within each round to investigate multiple paths or files efficiently | ||
| - Be systematic and thorough within your 3-round limit | ||
| </strategy> | ||
| <output_format> | ||
| - Only output tool calls themselves | ||
| - Do not include explanatory text, reasoning, or commentary | ||
| - Each tool call should be on its own line | ||
| - After 3 rounds of exploration, call \`finish\` with all relevant code snippets you found | ||
| </output_format> | ||
| Begin your exploration now to find code relevant to the query.`; | ||
| export function getSystemPrompt() { | ||
| return SYSTEM_PROMPT; | ||
| } |
| import { AgentRunResult, SessionConfig } from "./session-config.js"; | ||
| type EventCallback = (event: string, payload: Record<string, unknown>) => void; | ||
| export declare function runAgent(config: SessionConfig, eventCallback?: EventCallback): Promise<AgentRunResult>; | ||
| export {}; |
| import path from "path"; | ||
| import { GrepTool, ReadTool, AnalyseTool, FinishTool, ToolOutput } from "../tools/index.js"; | ||
| import { GrepState, parseAndFilterGrepOutput, formatTurnGrepOutput } from "../tools/grep-helpers.js"; | ||
| import { ToolOutputFormatter } from "../utils/tool-output-formatter.js"; | ||
| import { LLMResponseParser, LLMResponseParseError } from "../utils/llm-response-parser.js"; | ||
| import { buildInitialState } from "./initial-state.js"; | ||
| import { getSystemPrompt } from "./prompt.js"; | ||
| import { createLLMClient } from "../llm/client.js"; | ||
| import { AGENT_CONFIG } from "../config.js"; | ||
| import { debugLogger } from "../utils/debug-logger.js"; | ||
| const formatter = new ToolOutputFormatter(); | ||
| const parser = new LLMResponseParser(); | ||
| const FINISH_REMINDER_MESSAGE = "Reminder: You are entering the final round. You MUST call the 'finish' tool now with your findings."; | ||
| const defaultEventCallback = () => { }; | ||
| async function executeOtherTool(call, tools) { | ||
| const name = (call.name ?? "").toLowerCase(); | ||
| const args = (call.arguments ?? {}); | ||
| let executionName = name; | ||
| if (name === "ls") { | ||
| executionName = "analyse"; | ||
| } | ||
| else if (name === "cat") { | ||
| executionName = "read"; | ||
| } | ||
| debugLogger.debug("agent", `Executing tool: ${name}`, { name, executionName, args }); | ||
| let output; | ||
| if (executionName === "read") { | ||
| output = await tools.readTool.call(args); | ||
| } | ||
| else if (executionName === "analyse") { | ||
| output = await tools.analyseTool.call(args); | ||
| } | ||
| else { | ||
| debugLogger.warn("agent", `Unknown tool: ${name}`); | ||
| output = new ToolOutput({ name, error: `Unknown tool '${name}'` }); | ||
| } | ||
| const dict = output.toDict(); | ||
| const formatted = formatter.format(name, args, String(dict.output ?? dict.error ?? ""), { | ||
| isError: Boolean(dict.error), | ||
| }); | ||
| debugLogger.debug("agent", `Tool execution result: ${name}`, { | ||
| name, | ||
| hasError: Boolean(dict.error), | ||
| outputLength: String(dict.output ?? "").length, | ||
| }); | ||
| return { formatted, result: output, name, args }; | ||
| } | ||
| async function executeGrepTool(call, tools) { | ||
| const args = (call.arguments ?? {}); | ||
| debugLogger.debug("agent", "Executing grep tool", { args }); | ||
| const result = await tools.grepTool.call(args); | ||
| const dict = result.toDict(); | ||
| if (dict.error) { | ||
| const payload = String(dict.output ?? dict.error); | ||
| const formatted = formatter.format("grep", args, payload, { isError: true }); | ||
| debugLogger.warn("agent", "Grep tool error", { error: dict.error, args }); | ||
| return { formatted, result, args }; | ||
| } | ||
| const rawOutput = typeof dict.output === "string" ? dict.output : ""; | ||
| const newMatches = parseAndFilterGrepOutput(rawOutput, tools.grepState); | ||
| let formattedPayload = formatTurnGrepOutput(newMatches); | ||
| if (formattedPayload === "No new matches found.") { | ||
| formattedPayload = "no new matches"; | ||
| } | ||
| const formatted = formatter.format("grep", args, formattedPayload, { isError: false }); | ||
| debugLogger.info("agent", "Grep tool executed", { | ||
| pattern: args.pattern, | ||
| path: args.path, | ||
| newMatchesCount: newMatches.length, | ||
| }); | ||
| return { formatted, result, args }; | ||
| } | ||
| async function executeFinishTool(call, tools) { | ||
| const args = (call.arguments ?? {}); | ||
| debugLogger.info("agent", "Executing finish tool", { args }); | ||
| const result = await tools.finishTool.call(args); | ||
| const dict = result.toDict(); | ||
| const payload = String(dict.output ?? dict.error ?? ""); | ||
| const formatted = formatter.format("finish", args, payload, { isError: Boolean(dict.error) }); | ||
| debugLogger.info("agent", "Finish tool executed", { | ||
| hasError: Boolean(dict.error), | ||
| payloadLength: payload.length, | ||
| }); | ||
| return { formatted, result, args }; | ||
| } | ||
| export async function runAgent(config, eventCallback) { | ||
| const repoPath = path.resolve(config.repoPath); | ||
| const callback = eventCallback ?? defaultEventCallback; | ||
| debugLogger.info("agent", "Starting agent run", { | ||
| repoPath, | ||
| query: config.query, | ||
| model: config.model, | ||
| maxRounds: AGENT_CONFIG.MAX_ROUNDS, | ||
| }); | ||
| const tools = { | ||
| grepTool: new GrepTool(repoPath), | ||
| readTool: new ReadTool(repoPath), | ||
| analyseTool: new AnalyseTool(repoPath), | ||
| finishTool: new FinishTool(), | ||
| grepState: new GrepState(), | ||
| }; | ||
| const systemPrompt = config.systemPromptOverride ?? getSystemPrompt(); | ||
| const messages = []; | ||
| // Add system prompt | ||
| const systemMessage = { role: "system", content: systemPrompt }; | ||
| messages.push(systemMessage); | ||
| debugLogger.debug("agent", "Added message to conversation", { | ||
| index: messages.length - 1, | ||
| role: "system", | ||
| contentLength: systemPrompt.length, | ||
| content: systemPrompt, | ||
| }); | ||
| // Add user query | ||
| const queryContent = `<query>${config.query}</query>`; | ||
| const queryMessage = { role: "user", content: queryContent }; | ||
| messages.push(queryMessage); | ||
| debugLogger.debug("agent", "Added message to conversation", { | ||
| index: messages.length - 1, | ||
| role: "user", | ||
| contentLength: queryContent.length, | ||
| content: queryContent, | ||
| }); | ||
| // Add initial state | ||
| const initialState = await buildInitialState(repoPath, config.query); | ||
| const stateMessage = { role: "user", content: initialState }; | ||
| messages.push(stateMessage); | ||
| debugLogger.debug("agent", "Added message to conversation", { | ||
| index: messages.length - 1, | ||
| role: "user", | ||
| contentLength: initialState.length, | ||
| content: initialState, | ||
| }); | ||
| callback("initial_state", { initial_state: initialState, repo_path: repoPath }); | ||
| const client = createLLMClient(config); | ||
| let finishResult = null; | ||
| const errors = []; | ||
| let terminationReason = "terminated"; | ||
| let usage = null; | ||
| for (let round = 1; round <= AGENT_CONFIG.MAX_ROUNDS; round += 1) { | ||
| callback("round_start", { round }); | ||
| debugLogger.info("agent", `Round ${round}/${AGENT_CONFIG.MAX_ROUNDS}`, { | ||
| round, | ||
| messageCount: messages.length, | ||
| }); | ||
| let assistantContent = ""; | ||
| try { | ||
| const result = await client.complete({ | ||
| model: config.model, | ||
| messages, | ||
| stream: config.stream, | ||
| }); | ||
| assistantContent = result.content ?? ""; | ||
| usage = result.usage ?? null; | ||
| // Add assistant response to conversation | ||
| const assistantMessage = { role: "assistant", content: assistantContent }; | ||
| messages.push(assistantMessage); | ||
| debugLogger.debug("agent", "Added message to conversation", { | ||
| index: messages.length - 1, | ||
| role: "assistant", | ||
| contentLength: assistantContent.length, | ||
| content: assistantContent, | ||
| }); | ||
| debugLogger.info("agent", `Round ${round} response received`, { | ||
| round, | ||
| contentLength: assistantContent.length, | ||
| usage, | ||
| }); | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| debugLogger.error("agent", `Round ${round} LLM error`, { round, error: message }); | ||
| errors.push({ message }); | ||
| terminationReason = "terminated"; | ||
| break; | ||
| } | ||
| let toolCalls = []; | ||
| try { | ||
| toolCalls = parser.parse(assistantContent); | ||
| debugLogger.debug("agent", `Round ${round} parsed tool calls`, { | ||
| round, | ||
| toolCallCount: toolCalls.length, | ||
| toolNames: toolCalls.map(tc => tc.name), | ||
| }); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof LLMResponseParseError) { | ||
| debugLogger.error("agent", `Round ${round} parse error`, { round, error: error.message }); | ||
| errors.push({ message: error.message }); | ||
| } | ||
| else { | ||
| debugLogger.error("agent", `Round ${round} unexpected parse error`, { round, error: String(error) }); | ||
| errors.push({ message: String(error) }); | ||
| } | ||
| terminationReason = "terminated"; | ||
| break; | ||
| } | ||
| if (toolCalls.length === 0) { | ||
| debugLogger.warn("agent", `Round ${round} no tool calls produced`, { round }); | ||
| errors.push({ message: "No tool calls produced by the model." }); | ||
| terminationReason = "terminated"; | ||
| break; | ||
| } | ||
| const limitedCalls = toolCalls.slice(0, AGENT_CONFIG.MAX_CALLS_PER_ROUND); | ||
| const finishCalls = limitedCalls.filter(call => call.name === "finish"); | ||
| const grepCalls = limitedCalls.filter(call => (call.name ?? "").toLowerCase() === "grep"); | ||
| const otherCalls = limitedCalls.filter(call => !["grep", "finish"].includes((call.name ?? "").toLowerCase())); | ||
| const formattedBlocks = []; | ||
| const otherResults = await Promise.all(otherCalls.map(call => executeOtherTool(call, tools))); | ||
| for (const result of otherResults) { | ||
| if (result.formatted.trim()) { | ||
| formattedBlocks.push(result.formatted); | ||
| } | ||
| } | ||
| for (const call of grepCalls) { | ||
| const result = await executeGrepTool(call, tools); | ||
| if (result.formatted.trim()) { | ||
| formattedBlocks.push(result.formatted); | ||
| } | ||
| } | ||
| if (formattedBlocks.length > 0) { | ||
| const combined = formattedBlocks.join("\n\n"); | ||
| // Add tool results to conversation | ||
| const toolResultMessage = { role: "user", content: combined }; | ||
| messages.push(toolResultMessage); | ||
| debugLogger.debug("agent", "Added message to conversation", { | ||
| index: messages.length - 1, | ||
| role: "user", | ||
| contentLength: combined.length, | ||
| content: combined, | ||
| }); | ||
| callback("tool_outputs", { round, content: combined }); | ||
| } | ||
| if (finishCalls.length > 0) { | ||
| debugLogger.info("agent", `Round ${round} finish tool called`, { round }); | ||
| const finish = await executeFinishTool(finishCalls[0], tools); | ||
| const dict = finish.result.toDict(); | ||
| if (dict.error) { | ||
| debugLogger.error("agent", `Round ${round} finish tool error`, { round, error: dict.error }); | ||
| errors.push({ message: dict.error }); | ||
| terminationReason = "terminated"; | ||
| } | ||
| else { | ||
| debugLogger.info("agent", "Agent completed successfully", { | ||
| round, | ||
| outputLength: typeof dict.output === "string" ? dict.output.length : 0, | ||
| }); | ||
| finishResult = { | ||
| arguments: finishCalls[0].arguments ?? {}, | ||
| output: typeof dict.output === "string" ? dict.output : "", | ||
| error: dict.error, | ||
| metadata: dict.metadata ?? undefined, | ||
| }; | ||
| terminationReason = "completed"; | ||
| } | ||
| break; | ||
| } | ||
| if (config.finishReminder && round === AGENT_CONFIG.MAX_ROUNDS - 1) { | ||
| // Add finish reminder to conversation | ||
| const reminderMessage = { role: "user", content: FINISH_REMINDER_MESSAGE }; | ||
| messages.push(reminderMessage); | ||
| debugLogger.debug("agent", "Added message to conversation", { | ||
| index: messages.length - 1, | ||
| role: "user", | ||
| contentLength: FINISH_REMINDER_MESSAGE.length, | ||
| content: FINISH_REMINDER_MESSAGE, | ||
| }); | ||
| } | ||
| } | ||
| if (!finishResult && terminationReason !== "terminated") { | ||
| terminationReason = "stopped"; | ||
| debugLogger.warn("agent", "Agent stopped without finish", { terminationReason }); | ||
| } | ||
| const clonedMessages = structuredClone(messages); | ||
| const assistantCount = messages.filter(msg => msg.role === "assistant").length; | ||
| debugLogger.info("agent", "Agent run finished", { | ||
| terminationReason, | ||
| roundsCompleted: Math.min(AGENT_CONFIG.MAX_ROUNDS, assistantCount), | ||
| errorCount: errors.length, | ||
| hasFinish: !!finishResult, | ||
| totalMessages: messages.length, | ||
| }); | ||
| // Save full conversation messages to JSON file | ||
| debugLogger.saveConversationMessages(clonedMessages); | ||
| return { | ||
| messages: clonedMessages, | ||
| initialState, | ||
| finish: finishResult, | ||
| roundsCompleted: Math.min(AGENT_CONFIG.MAX_ROUNDS, assistantCount), | ||
| totalDuration: undefined, | ||
| terminationReason, | ||
| usage, | ||
| errors, | ||
| }; | ||
| } |
| export interface SessionConfig { | ||
| hostUrl: string; | ||
| model: string; | ||
| stream: boolean; | ||
| repoPath: string; | ||
| query: string; | ||
| finishReminder: boolean; | ||
| clientType: "http" | "openai"; | ||
| systemPromptOverride?: string; | ||
| apiKey?: string; | ||
| } | ||
| export interface FinishResult { | ||
| arguments: Record<string, unknown>; | ||
| output: string; | ||
| error?: string | null; | ||
| metadata?: Record<string, unknown> | null; | ||
| } | ||
| export interface AgentRunResult { | ||
| messages: Array<Record<string, unknown>>; | ||
| initialState: string; | ||
| finish?: FinishResult | null; | ||
| roundsCompleted: number; | ||
| totalDuration?: number; | ||
| terminationReason: "completed" | "stopped" | "terminated"; | ||
| usage?: Record<string, unknown> | null; | ||
| errors: Array<Record<string, unknown>>; | ||
| } | ||
| export declare function normalizeRepoPath(repoPath: string): string; |
| import path from "path"; | ||
| export function normalizeRepoPath(repoPath) { | ||
| return path.resolve(repoPath); | ||
| } |
| import type { SessionConfig } from "../agent/session-config.js"; | ||
| export interface ChatMessage { | ||
| role: "system" | "user" | "assistant" | string; | ||
| content: string; | ||
| } | ||
| export interface CompletionPayload { | ||
| messages: ChatMessage[]; | ||
| model: string; | ||
| stream: boolean; | ||
| } | ||
| export interface CompletionResult { | ||
| content: string; | ||
| usage?: Record<string, unknown> | null; | ||
| rawResponse?: unknown; | ||
| } | ||
| export interface LLMClient { | ||
| complete(payload: CompletionPayload): Promise<CompletionResult>; | ||
| } | ||
| export declare function createLLMClient(config: SessionConfig): LLMClient; |
| import { AGENT_CONFIG } from "../config.js"; | ||
| import { debugLogger } from "../utils/debug-logger.js"; | ||
| function buildHeaders(config) { | ||
| const headers = { | ||
| "Content-Type": "application/json", | ||
| }; | ||
| if (config.apiKey) { | ||
| headers["Authorization"] = `Bearer ${config.apiKey}`; | ||
| } | ||
| return headers; | ||
| } | ||
| export function createLLMClient(config) { | ||
| const baseUrl = config.hostUrl.replace(/\/$/, ""); | ||
| debugLogger.info("llm", "Creating LLM client", { | ||
| baseUrl, | ||
| model: config.model, | ||
| }); | ||
| return { | ||
| async complete(payload) { | ||
| const body = { | ||
| model: payload.model, | ||
| messages: payload.messages, | ||
| max_tokens: AGENT_CONFIG.MAX_TOKENS, | ||
| temperature: AGENT_CONFIG.TEMPERATURE, | ||
| stream: false, | ||
| }; | ||
| // Log request summary | ||
| debugLogger.info("llm", "Sending completion request", { | ||
| model: payload.model, | ||
| messageCount: payload.messages.length, | ||
| roles: payload.messages.map(m => m.role).join(" -> "), | ||
| maxTokens: AGENT_CONFIG.MAX_TOKENS, | ||
| temperature: AGENT_CONFIG.TEMPERATURE, | ||
| }); | ||
| const startTime = Date.now(); | ||
| const response = await fetch(`${baseUrl}/v1/chat/completions`, { | ||
| method: "POST", | ||
| headers: buildHeaders(config), | ||
| body: JSON.stringify(body), | ||
| }); | ||
| const elapsedMs = Date.now() - startTime; | ||
| if (!response.ok) { | ||
| const text = await response.text(); | ||
| debugLogger.error("llm", "LLM request failed", { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| responseBody: text, | ||
| elapsedMs, | ||
| }); | ||
| throw new Error(`LLM request failed with status ${response.status}: ${text}`); | ||
| } | ||
| const data = await response.json(); | ||
| const choices = Array.isArray(data?.choices) ? data.choices : []; | ||
| const content = choices[0]?.message?.content ?? ""; | ||
| const usage = typeof data?.usage === "object" ? data.usage : null; | ||
| debugLogger.info("llm", "Completion successful", { | ||
| contentLength: content.length, | ||
| usage, | ||
| elapsedMs, | ||
| choicesCount: choices.length, | ||
| }); | ||
| // Log full response content | ||
| debugLogger.debug("llm", "Full LLM response", { | ||
| content, | ||
| finishReason: choices[0]?.finish_reason, | ||
| }); | ||
| return { content, usage, rawResponse: data }; | ||
| }, | ||
| }; | ||
| } |
| import { Tool, ToolCallArguments, ToolOutput } from "./tool-base.js"; | ||
| export declare class AnalyseTool extends Tool { | ||
| private readonly repoPath; | ||
| constructor(repoPath: string, options?: { | ||
| name?: string; | ||
| description?: string; | ||
| }); | ||
| call(args: ToolCallArguments): Promise<ToolOutput>; | ||
| private run; | ||
| } |
| import fs from "fs/promises"; | ||
| import path from "path"; | ||
| import { z } from "zod"; | ||
| import { analyse } from "./analyse.js"; | ||
| import { Tool, ToolOutput } from "./tool-base.js"; | ||
| import { ensureWithinRepo, toRepoRelative } from "../utils/path-utils.js"; | ||
| const AnalyseArgsSchema = z.object({ | ||
| path: z.string().default("."), | ||
| pattern: z.string().optional(), | ||
| max_results: z.number().int().positive().optional(), | ||
| max_depth: z.number().int().positive().optional(), | ||
| }); | ||
| function formatTreeOutput(entries, prefix = "") { | ||
| const lines = []; | ||
| const total = entries.length; | ||
| entries.forEach((entry, index) => { | ||
| const connector = index === total - 1 ? "└──" : "├──"; | ||
| const linePrefix = `${prefix}${connector} `; | ||
| const entryType = entry.type; | ||
| const name = entry.name ?? ""; | ||
| if (entryType === "dir") { | ||
| lines.push(`${linePrefix}${name}/`); | ||
| const children = Array.isArray(entry.children) ? entry.children : []; | ||
| if (children.length > 0) { | ||
| const childPrefix = prefix + (index === total - 1 ? " " : "│ "); | ||
| lines.push(...formatTreeOutput(children, childPrefix)); | ||
| } | ||
| return; | ||
| } | ||
| if (entryType === "truncate") { | ||
| lines.push(`${linePrefix}${entry.message ?? "..."}`); | ||
| return; | ||
| } | ||
| lines.push(`${linePrefix}${name}`); | ||
| }); | ||
| return lines; | ||
| } | ||
| export class AnalyseTool extends Tool { | ||
| repoPath; | ||
| constructor(repoPath, options) { | ||
| const name = options?.name ?? "analyse"; | ||
| const description = options?.description ?? | ||
| "Analyse directory entries with optional regex filtering. Like a brief `ls -R` tree with regex filtering for names."; | ||
| super({ | ||
| name, | ||
| description, | ||
| schema: { | ||
| type: "function", | ||
| function: { | ||
| name, | ||
| description, | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| path: { | ||
| type: "string", | ||
| description: "Subdirectory or file to analyse (default: '.' for repository root)", | ||
| default: ".", | ||
| }, | ||
| pattern: { | ||
| type: "string", | ||
| description: "Regex pattern to filter entry names (optional)", | ||
| }, | ||
| max_results: { | ||
| type: "integer", | ||
| description: "Maximum entries per directory node (default: 100)", | ||
| default: 100, | ||
| }, | ||
| max_depth: { | ||
| type: "integer", | ||
| description: "Maximum directory depth to traverse (default: 2)", | ||
| default: 2, | ||
| }, | ||
| }, | ||
| required: [], | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| this.repoPath = path.resolve(repoPath); | ||
| } | ||
| async call(args) { | ||
| try { | ||
| const parsed = AnalyseArgsSchema.parse({ | ||
| path: args.path ?? ".", | ||
| pattern: args.pattern, | ||
| max_results: args.max_results ?? args.maxResults ?? 100, | ||
| max_depth: args.max_depth ?? args.maxDepth ?? 2, | ||
| }); | ||
| const targetPath = path.resolve(this.repoPath, parsed.path); | ||
| try { | ||
| ensureWithinRepo(this.repoPath, targetPath); | ||
| } | ||
| catch (error) { | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| } | ||
| return await this.run(targetPath, parsed); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof z.ZodError) { | ||
| return new ToolOutput({ name: this.name, error: error.errors.map(e => e.message).join("; ") }); | ||
| } | ||
| return new ToolOutput({ name: this.name, error: `analyse error: ${error.message}` }); | ||
| } | ||
| } | ||
| async run(targetPath, args) { | ||
| try { | ||
| const entries = await analyse(targetPath, args.pattern ?? null, args.max_results ?? 100, args.max_depth ?? 2); | ||
| const stat = await fs.stat(targetPath).catch(() => null); | ||
| const isDirectory = stat?.isDirectory() ?? false; | ||
| const relativePath = toRepoRelative(this.repoPath, targetPath); | ||
| let header; | ||
| if (targetPath === this.repoPath) { | ||
| header = "."; | ||
| } | ||
| else if (isDirectory) { | ||
| header = relativePath.endsWith("/") ? relativePath : `${relativePath}/`; | ||
| } | ||
| else { | ||
| header = relativePath; | ||
| } | ||
| const lines = [header]; | ||
| lines.push(...formatTreeOutput(entries)); | ||
| return new ToolOutput({ name: this.name, output: lines.join("\n") }); | ||
| } | ||
| catch (error) { | ||
| return new ToolOutput({ name: this.name, error: String(error instanceof Error ? error.message : error) }); | ||
| } | ||
| } | ||
| } |
| export interface SimplifiedNode { | ||
| type: string; | ||
| name: string; | ||
| children?: SimplifiedNode[]; | ||
| message?: string; | ||
| } | ||
| export declare function analyse(targetPath: string, patternText: string | null, maxResults?: number, maxDepth?: number): Promise<SimplifiedNode[]>; |
| import path from "path"; | ||
| import { runRipgrep } from "../utils/ripgrep.js"; | ||
| /** | ||
| * Builds a tree structure from a flat list of file paths. | ||
| */ | ||
| function buildTreeFromPaths(paths) { | ||
| const root = { | ||
| type: 'dir', | ||
| name: 'root', | ||
| children: {}, | ||
| }; | ||
| for (const filePath of paths) { | ||
| const parts = filePath.split(path.sep).filter(p => p); | ||
| let current = root; | ||
| for (let i = 0; i < parts.length; i++) { | ||
| const part = parts[i]; | ||
| const isLast = i === parts.length - 1; | ||
| if (!current.children[part]) { | ||
| current.children[part] = { | ||
| type: isLast ? 'file' : 'dir', | ||
| name: part, | ||
| children: isLast ? undefined : {}, | ||
| }; | ||
| } | ||
| if (!isLast) { | ||
| current = current.children[part]; | ||
| } | ||
| } | ||
| } | ||
| function finalizeTree(node) { | ||
| if (!node.children) | ||
| return []; | ||
| const childrenArray = Object.values(node.children); | ||
| const result = childrenArray.map(child => { | ||
| const simplified = { | ||
| type: child.type, | ||
| name: child.name, | ||
| }; | ||
| if (child.type === 'dir' && child.children) { | ||
| simplified.children = finalizeTree(child); | ||
| } | ||
| return simplified; | ||
| }); | ||
| return result.sort((a, b) => { | ||
| if (a.type === 'dir' && b.type !== 'dir') | ||
| return -1; | ||
| if (a.type !== 'dir' && b.type === 'dir') | ||
| return 1; | ||
| return a.name.localeCompare(b.name); | ||
| }); | ||
| } | ||
| const finalRoot = { | ||
| type: root.type, | ||
| name: root.name, | ||
| children: finalizeTree(root), | ||
| }; | ||
| return finalRoot; | ||
| } | ||
| /** | ||
| * Recursively filters and truncates the tree according to pattern, depth, and result limits. | ||
| * This logic is modeled after the original fs-based implementation to maintain behavior. | ||
| */ | ||
| function filterAndTruncate(node, pattern, maxResults, maxDepth, currentDepth = 0) { | ||
| if (!node.children) { | ||
| return []; | ||
| } | ||
| const children = node.children; | ||
| const filtered = []; | ||
| for (const child of children) { | ||
| const name = child.name; | ||
| if (child.type === "dir") { | ||
| if (currentDepth + 1 > maxDepth) | ||
| continue; | ||
| const grandChildren = filterAndTruncate(child, pattern, maxResults, maxDepth, currentDepth + 1); | ||
| const matchesName = !pattern || pattern.test(name); | ||
| if (!matchesName && grandChildren.length === 0) | ||
| continue; | ||
| const entry = { type: "dir", name }; | ||
| if (grandChildren.length > 0) { | ||
| entry.children = grandChildren; | ||
| } | ||
| filtered.push(entry); | ||
| } | ||
| else { // file | ||
| if (currentDepth + 1 > maxDepth) | ||
| continue; | ||
| if (pattern && !pattern.test(name)) | ||
| continue; | ||
| filtered.push({ type: child.type, name }); | ||
| } | ||
| if (filtered.length >= maxResults) { | ||
| filtered.push({ type: "truncate", name: "", message: `Showing first ${maxResults} entries; refine path or increase limit for more` }); | ||
| break; | ||
| } | ||
| } | ||
| return filtered.slice(0, maxResults + 1); | ||
| } | ||
| export async function analyse(targetPath, patternText, maxResults = 100, maxDepth = 2) { | ||
| const { stdout, exitCode, stderr } = await runRipgrep(["--files", "--hidden", "--", "."], { cwd: targetPath }); | ||
| if (exitCode > 1) { // 0 = matches, 1 = no matches | ||
| throw new Error(`ripgrep failed with exit code ${exitCode}: ${stderr}`); | ||
| } | ||
| const allFiles = stdout.split(/\r?\n/).filter(Boolean); | ||
| const rootNode = buildTreeFromPaths(allFiles); | ||
| let pattern = null; | ||
| if (patternText) { | ||
| try { | ||
| pattern = new RegExp(patternText); | ||
| } | ||
| catch (error) { | ||
| throw new Error(`Invalid regex pattern: ${patternText}`); | ||
| } | ||
| } | ||
| return filterAndTruncate(rootNode, pattern, maxResults, maxDepth); | ||
| } |
| import { Tool, ToolCallArguments, ToolOutput } from "./tool-base.js"; | ||
| export declare class FinishTool extends Tool { | ||
| constructor(options?: { | ||
| name?: string; | ||
| description?: string; | ||
| }); | ||
| call(args: ToolCallArguments): Promise<ToolOutput>; | ||
| } |
| import { z } from "zod"; | ||
| import { Tool, ToolOutput } from "./tool-base.js"; | ||
| const LineRangeSchema = z | ||
| .array(z.number().int()) | ||
| .length(2) | ||
| .superRefine((value, ctx) => { | ||
| const [start, end] = value; | ||
| if (start < 1) { | ||
| ctx.addIssue({ code: z.ZodIssueCode.custom, message: "start line must be >= 1" }); | ||
| } | ||
| if (end < start) { | ||
| ctx.addIssue({ code: z.ZodIssueCode.custom, message: "end line must be >= start line" }); | ||
| } | ||
| }); | ||
| const FileEntrySchema = z.object({ | ||
| path: z.string(), | ||
| lines: z.array(LineRangeSchema), | ||
| }); | ||
| const FinishArgsSchema = z.object({ | ||
| files: z.array(FileEntrySchema), | ||
| }); | ||
| export class FinishTool extends Tool { | ||
| constructor(options) { | ||
| const name = options?.name ?? "finish"; | ||
| const description = options?.description ?? | ||
| "Submit your final answer with the relevant files and line ranges you found. Call this when you've identified all relevant code locations for the query. This is your final action. Do not call any other tools after this."; | ||
| super({ | ||
| name, | ||
| description, | ||
| schema: { | ||
| type: "function", | ||
| function: { | ||
| name, | ||
| description, | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| files: { | ||
| type: "array", | ||
| description: "List of relevant files with their line ranges", | ||
| items: { | ||
| type: "object", | ||
| properties: { | ||
| path: { | ||
| type: "string", | ||
| description: "File path relative to repository root", | ||
| }, | ||
| lines: { | ||
| type: "array", | ||
| description: "List of relevant line ranges [[start, end], ...]", | ||
| items: { | ||
| type: "array", | ||
| items: { type: "integer" }, | ||
| minItems: 2, | ||
| maxItems: 2, | ||
| }, | ||
| }, | ||
| }, | ||
| required: ["path", "lines"], | ||
| }, | ||
| }, | ||
| }, | ||
| required: ["files"], | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
| async call(args) { | ||
| try { | ||
| const parsed = FinishArgsSchema.parse(args); | ||
| const totalFiles = parsed.files.length; | ||
| const totalRanges = parsed.files.reduce((acc, file) => acc + file.lines.length, 0); | ||
| const lines = [`Submitted ${totalFiles} file(s) with ${totalRanges} line range(s):`, ""]; | ||
| for (const file of parsed.files) { | ||
| lines.push(` ${file.path}:`); | ||
| for (const [start, end] of file.lines) { | ||
| if (start === end) { | ||
| lines.push(` Line ${start}`); | ||
| } | ||
| else { | ||
| lines.push(` Lines ${start}-${end}`); | ||
| } | ||
| } | ||
| } | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| output: lines.join("\n"), | ||
| metadata: { | ||
| files: parsed.files, | ||
| total_files: totalFiles, | ||
| total_ranges: totalRanges, | ||
| }, | ||
| }); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof z.ZodError) { | ||
| return new ToolOutput({ name: this.name, error: `Invalid format: ${error.errors.map(e => e.message).join("; ")}` }); | ||
| } | ||
| return new ToolOutput({ name: this.name, error: `Error processing finish: ${error instanceof Error ? error.message : String(error)}` }); | ||
| } | ||
| } | ||
| } |
| export interface GrepMatch { | ||
| path: string; | ||
| lineNumber: number; | ||
| content: string; | ||
| } | ||
| export declare class GrepState { | ||
| private readonly seenLines; | ||
| isNew(path: string, lineNumber: number): boolean; | ||
| add(path: string, lineNumber: number): void; | ||
| private makeKey; | ||
| } | ||
| export declare const MAX_GREP_OUTPUT_CHARS_PER_TURN = 60000; | ||
| export declare function parseAndFilterGrepOutput(rawOutput: string, state: GrepState): GrepMatch[]; | ||
| export declare function formatTurnGrepOutput(matches: GrepMatch[], maxChars?: number): string; |
| export class GrepState { | ||
| seenLines = new Set(); | ||
| isNew(path, lineNumber) { | ||
| const key = this.makeKey(path, lineNumber); | ||
| return !this.seenLines.has(key); | ||
| } | ||
| add(path, lineNumber) { | ||
| this.seenLines.add(this.makeKey(path, lineNumber)); | ||
| } | ||
| makeKey(path, lineNumber) { | ||
| return `${path}:${lineNumber}`; | ||
| } | ||
| } | ||
| export const MAX_GREP_OUTPUT_CHARS_PER_TURN = 60_000; | ||
| function extractMatchFields(payload) { | ||
| const text = payload.replace(/\r?\n$/, ""); | ||
| if (!text || text.startsWith("[error]")) { | ||
| return null; | ||
| } | ||
| const firstSep = text.indexOf(":"); | ||
| if (firstSep === -1) { | ||
| return null; | ||
| } | ||
| let filePath = text.slice(0, firstSep).trim(); | ||
| if (!filePath) { | ||
| return null; | ||
| } | ||
| if (filePath.startsWith("./") || filePath.startsWith(".\\")) { | ||
| filePath = filePath.slice(2); | ||
| } | ||
| const remainder = text.slice(firstSep + 1); | ||
| const secondSep = remainder.indexOf(":"); | ||
| if (secondSep === -1) { | ||
| return null; | ||
| } | ||
| const linePart = remainder.slice(0, secondSep); | ||
| const lineNumber = Number.parseInt(linePart, 10); | ||
| if (!Number.isInteger(lineNumber) || lineNumber <= 0) { | ||
| return null; | ||
| } | ||
| let contentSegment = remainder.slice(secondSep + 1); | ||
| const columnSep = contentSegment.indexOf(":"); | ||
| if (columnSep !== -1 && /^\d+$/.test(contentSegment.slice(0, columnSep))) { | ||
| contentSegment = contentSegment.slice(columnSep + 1); | ||
| } | ||
| const content = contentSegment.trim(); | ||
| if (!content) { | ||
| return null; | ||
| } | ||
| return { path: filePath, lineNumber, content }; | ||
| } | ||
| export function parseAndFilterGrepOutput(rawOutput, state) { | ||
| const matches = []; | ||
| if (typeof rawOutput !== "string" || !rawOutput.trim()) { | ||
| return matches; | ||
| } | ||
| for (const line of rawOutput.split(/\r?\n/)) { | ||
| const fields = extractMatchFields(line); | ||
| if (!fields) { | ||
| continue; | ||
| } | ||
| if (state.isNew(fields.path, fields.lineNumber)) { | ||
| matches.push(fields); | ||
| state.add(fields.path, fields.lineNumber); | ||
| } | ||
| } | ||
| return matches; | ||
| } | ||
| function truncateOutput(payload, maxChars) { | ||
| if (payload.length <= maxChars) { | ||
| return payload; | ||
| } | ||
| const note = "... (output truncated)"; | ||
| const available = maxChars - note.length - 1; | ||
| if (available <= 0) { | ||
| return note; | ||
| } | ||
| if (payload.length <= available) { | ||
| return `${payload.slice(0, available).replace(/\n$/, "")}\n${note}`; | ||
| } | ||
| const core = payload.slice(0, Math.max(0, available - 1)); | ||
| const trimmed = core.replace(/\n$/, "").replace(/\s+$/, ""); | ||
| const snippet = trimmed ? `${trimmed}…` : "…"; | ||
| return `${snippet}\n${note}`; | ||
| } | ||
| export function formatTurnGrepOutput(matches, maxChars = MAX_GREP_OUTPUT_CHARS_PER_TURN) { | ||
| if (!matches || matches.length === 0) { | ||
| return "No new matches found."; | ||
| } | ||
| const matchesByFile = new Map(); | ||
| for (const match of matches) { | ||
| if (!matchesByFile.has(match.path)) { | ||
| matchesByFile.set(match.path, []); | ||
| } | ||
| matchesByFile.get(match.path).push(match); | ||
| } | ||
| const lines = []; | ||
| const sortedPaths = Array.from(matchesByFile.keys()).sort(); | ||
| sortedPaths.forEach((filePath, index) => { | ||
| if (index > 0) { | ||
| lines.push(""); | ||
| } | ||
| lines.push(filePath); | ||
| const sortedMatches = matchesByFile | ||
| .get(filePath) | ||
| .slice() | ||
| .sort((a, b) => a.lineNumber - b.lineNumber); | ||
| for (const match of sortedMatches) { | ||
| lines.push(`${match.lineNumber}:${match.content}`); | ||
| } | ||
| }); | ||
| return truncateOutput(lines.join("\n"), maxChars); | ||
| } |
| import { Tool, ToolCallArguments, ToolOutput } from "./tool-base.js"; | ||
| export declare class GrepTool extends Tool { | ||
| private readonly repoPath; | ||
| constructor(repoPath: string, options?: { | ||
| name?: string; | ||
| description?: string; | ||
| }); | ||
| call(args: ToolCallArguments): Promise<ToolOutput>; | ||
| } |
| import fs from "fs/promises"; | ||
| import path from "path"; | ||
| import { z } from "zod"; | ||
| import { Tool, ToolOutput } from "./tool-base.js"; | ||
| import { ensureWithinRepo, toRepoRelative } from "../utils/path-utils.js"; | ||
| import { runRipgrep, shuffleInPlace } from "../utils/ripgrep.js"; | ||
| import { debugLogger } from "../utils/debug-logger.js"; | ||
| const MAX_GREP_OUTPUT_CHARS = 20_000; | ||
| const DEFAULT_DESCRIPTION = "Search for regex patterns in code files using ripgrep (rg). Returns matching lines with line numbers. Use this to find specific code patterns, function definitions, or text across the codebase."; | ||
| const GrepArgsSchema = z.object({ | ||
| pattern: z.string().min(1), | ||
| path: z.string().default("."), | ||
| patterns: z.array(z.string()).optional(), | ||
| }); | ||
| function collectPatterns(primary, candidates) { | ||
| const patterns = []; | ||
| if (Array.isArray(candidates)) { | ||
| for (const entry of candidates) { | ||
| const text = String(entry ?? "").trim(); | ||
| if (text && !patterns.includes(text)) { | ||
| patterns.push(text); | ||
| } | ||
| } | ||
| } | ||
| const primaryText = primary.trim(); | ||
| if (patterns.length === 0) { | ||
| if (primaryText) { | ||
| patterns.push(primaryText); | ||
| } | ||
| } | ||
| else if (primaryText && !patterns.includes(primaryText)) { | ||
| patterns.unshift(primaryText); | ||
| } | ||
| return patterns; | ||
| } | ||
| function truncateLines(lines) { | ||
| const totalChars = lines.reduce((acc, line) => acc + line.length + 1, 0) - 1; | ||
| if (totalChars <= MAX_GREP_OUTPUT_CHARS) { | ||
| return lines.join("\n"); | ||
| } | ||
| const note = "\n... (output truncated)"; | ||
| const targetChars = MAX_GREP_OUTPUT_CHARS - note.length; | ||
| if (targetChars <= 0) { | ||
| return note.trimStart(); | ||
| } | ||
| let charsToRemove = totalChars - targetChars; | ||
| const mask = Array.from({ length: lines.length }, () => true); | ||
| const indices = [...lines.keys()]; | ||
| shuffleInPlace(indices); | ||
| for (const index of indices) { | ||
| if (charsToRemove <= 0) { | ||
| break; | ||
| } | ||
| const lineLen = lines[index]?.length ?? 0; | ||
| charsToRemove -= lineLen + 1; | ||
| mask[index] = false; | ||
| } | ||
| const outputLines = lines.filter((_, idx) => mask[idx]); | ||
| if (outputLines.length === 0) { | ||
| return note.trimStart(); | ||
| } | ||
| return `${outputLines.join("\n")}${note}`; | ||
| } | ||
| export class GrepTool extends Tool { | ||
| repoPath; | ||
| constructor(repoPath, options) { | ||
| const name = options?.name ?? "grep"; | ||
| super({ | ||
| name, | ||
| description: options?.description ?? DEFAULT_DESCRIPTION, | ||
| schema: { | ||
| type: "function", | ||
| function: { | ||
| name, | ||
| description: options?.description ?? DEFAULT_DESCRIPTION, | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| pattern: { | ||
| type: "string", | ||
| description: "Regex pattern to search for (e.g., 'def.*request', 'class\\s+\\w+', 'import.*requests')", | ||
| }, | ||
| path: { | ||
| type: "string", | ||
| description: "Subdirectory or file to search within (default: '.' for entire repo)", | ||
| default: ".", | ||
| }, | ||
| }, | ||
| required: ["pattern"], | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| this.repoPath = path.resolve(repoPath); | ||
| } | ||
| async call(args) { | ||
| debugLogger.debug("grep-tool", "Grep tool called", { args }); | ||
| try { | ||
| const parsed = GrepArgsSchema.parse({ | ||
| pattern: args.pattern, | ||
| path: args.path ?? ".", | ||
| patterns: args.patterns, | ||
| }); | ||
| const targetPath = path.resolve(this.repoPath, parsed.path); | ||
| debugLogger.debug("grep-tool", "Parsed grep arguments", { | ||
| pattern: parsed.pattern, | ||
| path: parsed.path, | ||
| targetPath, | ||
| patternsCount: parsed.patterns?.length ?? 0, | ||
| }); | ||
| try { | ||
| ensureWithinRepo(this.repoPath, targetPath); | ||
| } | ||
| catch (error) { | ||
| debugLogger.warn("grep-tool", "Path outside repo", { targetPath, repoPath: this.repoPath }); | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| } | ||
| const stat = await fs.stat(targetPath).catch(() => null); | ||
| if (!stat) { | ||
| debugLogger.warn("grep-tool", "Path does not exist", { path: parsed.path }); | ||
| return new ToolOutput({ name: this.name, error: `Path does not exist: ${parsed.path}` }); | ||
| } | ||
| const targetArg = targetPath === this.repoPath ? "." : toRepoRelative(this.repoPath, targetPath) || "."; | ||
| const patterns = collectPatterns(parsed.pattern, parsed.patterns); | ||
| if (patterns.length === 0) { | ||
| debugLogger.warn("grep-tool", "Empty pattern"); | ||
| return new ToolOutput({ name: this.name, error: "[error] Pattern must be a non-empty string." }); | ||
| } | ||
| debugLogger.info("grep-tool", "Running ripgrep", { | ||
| patternsCount: patterns.length, | ||
| patterns, | ||
| targetArg, | ||
| }); | ||
| const allLines = []; | ||
| for (const pattern of patterns) { | ||
| try { | ||
| const result = await runRipgrep([ | ||
| "--no-config", | ||
| "--no-heading", | ||
| "--with-filename", | ||
| "--line-number", | ||
| "--color=never", | ||
| "--trim", | ||
| "--max-columns=400", | ||
| "--max-columns-preview", | ||
| pattern, | ||
| targetArg, | ||
| ], { cwd: this.repoPath }); | ||
| debugLogger.debug("grep-tool", "Ripgrep result", { | ||
| pattern, | ||
| exitCode: result.exitCode, | ||
| stdoutLength: result.stdout.length, | ||
| stderrLength: result.stderr.length, | ||
| }); | ||
| if (result.exitCode === -1) { | ||
| debugLogger.error("grep-tool", "Ripgrep execution failed", { | ||
| pattern, | ||
| stderr: result.stderr, | ||
| }); | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: `[error] ${result.stderr || "ripgrep (rg) execution failed."}`, | ||
| }); | ||
| } | ||
| if (result.exitCode === 1) { | ||
| debugLogger.debug("grep-tool", "No matches for pattern", { pattern }); | ||
| continue; | ||
| } | ||
| if (result.exitCode !== 0) { | ||
| const stderr = result.stderr?.trim() || "Unknown ripgrep error."; | ||
| debugLogger.error("grep-tool", "Ripgrep error", { | ||
| pattern, | ||
| exitCode: result.exitCode, | ||
| stderr, | ||
| }); | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: `[error] ripgrep error (exit code ${result.exitCode}): ${stderr}`, | ||
| }); | ||
| } | ||
| if (result.stdout) { | ||
| const lines = result.stdout | ||
| .trim() | ||
| .split(/\r?\n/) | ||
| .filter(line => line.length > 0); | ||
| debugLogger.debug("grep-tool", "Pattern matched lines", { | ||
| pattern, | ||
| linesCount: lines.length, | ||
| }); | ||
| allLines.push(...lines); | ||
| } | ||
| } | ||
| catch (error) { | ||
| debugLogger.error("grep-tool", "Failed to execute ripgrep", { | ||
| pattern, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: `[error] Failed to execute ripgrep: ${error instanceof Error ? error.message : String(error)}`, | ||
| }); | ||
| } | ||
| } | ||
| if (allLines.length === 0) { | ||
| debugLogger.info("grep-tool", "No matches found"); | ||
| return new ToolOutput({ name: this.name, output: "" }); | ||
| } | ||
| const finalOutput = truncateLines(allLines); | ||
| debugLogger.info("grep-tool", "Grep completed successfully", { | ||
| totalLines: allLines.length, | ||
| finalOutputLength: finalOutput.length, | ||
| wasTruncated: finalOutput.includes("(output truncated)"), | ||
| }); | ||
| return new ToolOutput({ name: this.name, output: finalOutput }); | ||
| } | ||
| catch (error) { | ||
| debugLogger.error("grep-tool", "Grep tool error", { | ||
| error: error instanceof Error ? error.message : String(error), | ||
| isZodError: error instanceof z.ZodError, | ||
| }); | ||
| if (error instanceof z.ZodError) { | ||
| return new ToolOutput({ name: this.name, error: error.errors.map(e => e.message).join("; ") }); | ||
| } | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: `[error] ${error.message}`, | ||
| }); | ||
| } | ||
| } | ||
| } |
| export { AnalyseTool } from "./analyse-tool.js"; | ||
| export { ReadTool } from "./read-tool.js"; | ||
| export { GrepTool } from "./grep-tool.js"; | ||
| export { FinishTool } from "./finish-tool.js"; | ||
| export { ToolOutput, Tool } from "./tool-base.js"; | ||
| export * from "./grep-helpers.js"; |
| export { AnalyseTool } from "./analyse-tool.js"; | ||
| export { ReadTool } from "./read-tool.js"; | ||
| export { GrepTool } from "./grep-tool.js"; | ||
| export { FinishTool } from "./finish-tool.js"; | ||
| export { ToolOutput, Tool } from "./tool-base.js"; | ||
| export * from "./grep-helpers.js"; |
| import { Tool, ToolCallArguments, ToolOutput } from "./tool-base.js"; | ||
| export declare class ReadTool extends Tool { | ||
| private readonly repoPath; | ||
| constructor(repoPath: string, options?: { | ||
| name?: string; | ||
| description?: string; | ||
| }); | ||
| call(args: ToolCallArguments): Promise<ToolOutput>; | ||
| } |
| import fs from "fs/promises"; | ||
| import path from "path"; | ||
| import { z } from "zod"; | ||
| import { Tool, ToolOutput } from "./tool-base.js"; | ||
| import { ensureWithinRepo } from "../utils/path-utils.js"; | ||
| const MAX_FILE_CACHE_SIZE = 512; | ||
| const fileCache = new Map(); | ||
| async function readFileLines(filePath) { | ||
| const cached = fileCache.get(filePath); | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
| const content = await fs.readFile(filePath, { encoding: "utf-8" }); | ||
| const lines = content.split(/\r?\n/); | ||
| fileCache.set(filePath, lines); | ||
| if (fileCache.size > MAX_FILE_CACHE_SIZE) { | ||
| const firstKey = fileCache.keys().next().value; | ||
| if (firstKey) { | ||
| fileCache.delete(firstKey); | ||
| } | ||
| } | ||
| return lines; | ||
| } | ||
| const ReadArgsSchema = z.object({ | ||
| path: z.string(), | ||
| start: z.number().int().positive().optional(), | ||
| end: z.number().int().positive().optional(), | ||
| }); | ||
| export class ReadTool extends Tool { | ||
| repoPath; | ||
| constructor(repoPath, options) { | ||
| const name = options?.name ?? "read"; | ||
| const description = options?.description ?? | ||
| "Read file contents with line numbers. Optionally supply start/end lines to inspect a specific snippet."; | ||
| super({ | ||
| name, | ||
| description, | ||
| schema: { | ||
| type: "function", | ||
| function: { | ||
| name, | ||
| description, | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| path: { | ||
| type: "string", | ||
| description: "Path to the file to read (relative to repository root)", | ||
| }, | ||
| start: { | ||
| type: "integer", | ||
| minimum: 1, | ||
| description: "Optional 1-based starting line number to read from", | ||
| }, | ||
| end: { | ||
| type: "integer", | ||
| minimum: 1, | ||
| description: "Optional 1-based ending line number (inclusive)", | ||
| }, | ||
| }, | ||
| required: ["path"], | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| this.repoPath = path.resolve(repoPath); | ||
| } | ||
| async call(args) { | ||
| try { | ||
| const parsed = ReadArgsSchema.parse({ | ||
| path: args.path, | ||
| start: args.start, | ||
| end: args.end, | ||
| }); | ||
| const targetPath = path.resolve(this.repoPath, parsed.path); | ||
| try { | ||
| ensureWithinRepo(this.repoPath, targetPath); | ||
| } | ||
| catch (error) { | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| } | ||
| const stat = await fs.stat(targetPath).catch(() => null); | ||
| if (!stat) { | ||
| return new ToolOutput({ name: this.name, error: `File does not exist: ${parsed.path}` }); | ||
| } | ||
| if (!stat.isFile()) { | ||
| return new ToolOutput({ name: this.name, error: `Path is not a file: ${parsed.path}` }); | ||
| } | ||
| const startIdx = parsed.start ?? null; | ||
| const endIdx = parsed.end ?? null; | ||
| if (startIdx !== null && endIdx !== null && endIdx < startIdx) { | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: `end (${endIdx}) cannot be smaller than start (${startIdx})`, | ||
| }); | ||
| } | ||
| const lines = await readFileLines(targetPath); | ||
| const totalLines = lines.length; | ||
| const resolvedStart = startIdx ?? 1; | ||
| const resolvedEnd = Math.min(endIdx ?? totalLines, totalLines); | ||
| if (resolvedStart > totalLines && totalLines > 0) { | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: `start ${resolvedStart} exceeds file length (${totalLines} lines)`, | ||
| }); | ||
| } | ||
| const outputLines = []; | ||
| for (let lineNo = resolvedStart; lineNo <= resolvedEnd; lineNo += 1) { | ||
| const lineContent = lines[lineNo - 1] ?? ""; | ||
| outputLines.push(`${lineNo}|${lineContent}`); | ||
| } | ||
| return new ToolOutput({ name: this.name, output: outputLines.join("\n") }); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof z.ZodError) { | ||
| return new ToolOutput({ name: this.name, error: error.errors.map(e => e.message).join("; ") }); | ||
| } | ||
| return new ToolOutput({ | ||
| name: this.name, | ||
| error: `Unexpected error reading file: ${error instanceof Error ? error.message : String(error)}`, | ||
| }); | ||
| } | ||
| } | ||
| } |
| export interface ToolJson { | ||
| type: "function"; | ||
| function: { | ||
| name: string; | ||
| description: string; | ||
| parameters: unknown; | ||
| }; | ||
| } | ||
| export interface ToolCallArguments { | ||
| [key: string]: unknown; | ||
| } | ||
| export interface ToolOutputDict { | ||
| name: string; | ||
| output?: unknown; | ||
| error?: string | null; | ||
| metadata?: Record<string, unknown> | null; | ||
| } | ||
| export declare class ToolOutput { | ||
| readonly name: string; | ||
| readonly output?: unknown; | ||
| readonly error?: string | null; | ||
| readonly metadata?: Record<string, unknown> | null; | ||
| constructor(options: ToolOutputDict); | ||
| toDict(): ToolOutputDict; | ||
| toString(): string; | ||
| } | ||
| export declare abstract class Tool { | ||
| readonly name: string; | ||
| readonly description: string; | ||
| protected readonly schema: ToolJson; | ||
| protected constructor(options: { | ||
| name: string; | ||
| description: string; | ||
| schema: ToolJson; | ||
| }); | ||
| get json(): ToolJson; | ||
| abstract call(args: ToolCallArguments): Promise<ToolOutput>; | ||
| } |
| export class ToolOutput { | ||
| name; | ||
| output; | ||
| error; | ||
| metadata; | ||
| constructor(options) { | ||
| this.name = options.name; | ||
| this.output = options.output; | ||
| this.error = options.error ?? null; | ||
| this.metadata = options.metadata ?? null; | ||
| } | ||
| toDict() { | ||
| return { | ||
| name: this.name, | ||
| output: this.output, | ||
| error: this.error, | ||
| metadata: this.metadata ?? undefined, | ||
| }; | ||
| } | ||
| toString() { | ||
| if (this.error) { | ||
| return `Error: ${this.error}`; | ||
| } | ||
| if (this.output === undefined || this.output === null) { | ||
| return ""; | ||
| } | ||
| if (typeof this.output === "string") { | ||
| return this.output; | ||
| } | ||
| try { | ||
| return JSON.stringify(this.output); | ||
| } | ||
| catch { | ||
| return String(this.output); | ||
| } | ||
| } | ||
| } | ||
| export class Tool { | ||
| name; | ||
| description; | ||
| schema; | ||
| constructor(options) { | ||
| this.name = options.name; | ||
| this.description = options.description; | ||
| this.schema = options.schema; | ||
| } | ||
| get json() { | ||
| return this.schema; | ||
| } | ||
| } |
| declare class DebugLogger { | ||
| private sessionId; | ||
| private logBuffer; | ||
| private flushTimer; | ||
| private enabled; | ||
| constructor(); | ||
| private ensureDebugDir; | ||
| private formatTimestamp; | ||
| private scheduleFlush; | ||
| private flush; | ||
| private log; | ||
| private writeHumanReadable; | ||
| info(category: string, message: string, data?: unknown): void; | ||
| debug(category: string, message: string, data?: unknown): void; | ||
| warn(category: string, message: string, data?: unknown): void; | ||
| error(category: string, message: string, data?: unknown): void; | ||
| forceFlush(): void; | ||
| saveConversationMessages(messages: unknown[]): void; | ||
| getSessionId(): string; | ||
| } | ||
| export declare const debugLogger: DebugLogger; | ||
| export {}; |
| import fs from "fs"; | ||
| import path from "path"; | ||
| const DEBUG_DIR = "/lambda/nfs/embeddings/dat/workspace/swe-grep/dat_sft/morph-mcp/grep-agent/debug"; | ||
| class DebugLogger { | ||
| sessionId; | ||
| logBuffer = []; | ||
| flushTimer = null; | ||
| enabled = true; | ||
| constructor() { | ||
| this.sessionId = `session-${Date.now()}-${Math.random().toString(36).substring(7)}`; | ||
| this.ensureDebugDir(); | ||
| } | ||
| ensureDebugDir() { | ||
| try { | ||
| if (!fs.existsSync(DEBUG_DIR)) { | ||
| fs.mkdirSync(DEBUG_DIR, { recursive: true }); | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error("Failed to create debug directory:", error); | ||
| this.enabled = false; | ||
| } | ||
| } | ||
| formatTimestamp() { | ||
| const now = new Date(); | ||
| return now.toISOString(); | ||
| } | ||
| scheduleFlush() { | ||
| if (this.flushTimer) { | ||
| return; | ||
| } | ||
| this.flushTimer = setTimeout(() => { | ||
| this.flush(); | ||
| this.flushTimer = null; | ||
| }, 1000); | ||
| } | ||
| flush() { | ||
| if (!this.enabled || this.logBuffer.length === 0) { | ||
| return; | ||
| } | ||
| try { | ||
| const logFile = path.join(DEBUG_DIR, `${this.sessionId}.jsonl`); | ||
| const lines = this.logBuffer.map(entry => JSON.stringify(entry)).join("\n") + "\n"; | ||
| fs.appendFileSync(logFile, lines, "utf-8"); | ||
| this.logBuffer = []; | ||
| } | ||
| catch (error) { | ||
| console.error("Failed to write debug logs:", error); | ||
| this.enabled = false; | ||
| } | ||
| } | ||
| log(level, category, message, data) { | ||
| if (!this.enabled) { | ||
| return; | ||
| } | ||
| const entry = { | ||
| timestamp: this.formatTimestamp(), | ||
| level, | ||
| category, | ||
| message, | ||
| ...(data !== undefined && { data }), | ||
| }; | ||
| this.logBuffer.push(entry); | ||
| this.scheduleFlush(); | ||
| // Also write to a human-readable log file | ||
| this.writeHumanReadable(entry); | ||
| } | ||
| writeHumanReadable(entry) { | ||
| try { | ||
| const logFile = path.join(DEBUG_DIR, `${this.sessionId}.log`); | ||
| const levelStr = entry.level.toUpperCase().padEnd(5); | ||
| const dataStr = entry.data !== undefined ? `\n${JSON.stringify(entry.data, null, 2)}` : ""; | ||
| const line = `[${entry.timestamp}] ${levelStr} [${entry.category}] ${entry.message}${dataStr}\n`; | ||
| fs.appendFileSync(logFile, line, "utf-8"); | ||
| } | ||
| catch (error) { | ||
| // Silently fail for human-readable logs | ||
| } | ||
| } | ||
| info(category, message, data) { | ||
| this.log("info", category, message, data); | ||
| } | ||
| debug(category, message, data) { | ||
| this.log("debug", category, message, data); | ||
| } | ||
| warn(category, message, data) { | ||
| this.log("warn", category, message, data); | ||
| } | ||
| error(category, message, data) { | ||
| this.log("error", category, message, data); | ||
| } | ||
| forceFlush() { | ||
| if (this.flushTimer) { | ||
| clearTimeout(this.flushTimer); | ||
| this.flushTimer = null; | ||
| } | ||
| this.flush(); | ||
| } | ||
| saveConversationMessages(messages) { | ||
| if (!this.enabled) { | ||
| return; | ||
| } | ||
| try { | ||
| const messagesFile = path.join(DEBUG_DIR, `${this.sessionId}-messages.json`); | ||
| const content = JSON.stringify(messages, null, 2); | ||
| fs.writeFileSync(messagesFile, content, "utf-8"); | ||
| this.info("debug", "Saved conversation messages", { | ||
| file: messagesFile, | ||
| messageCount: messages.length, | ||
| }); | ||
| } | ||
| catch (error) { | ||
| this.error("debug", "Failed to save conversation messages", { error }); | ||
| } | ||
| } | ||
| getSessionId() { | ||
| return this.sessionId; | ||
| } | ||
| } | ||
| // Singleton instance | ||
| export const debugLogger = new DebugLogger(); | ||
| // Helper to ensure logs are flushed on process exit | ||
| process.on("exit", () => { | ||
| debugLogger.forceFlush(); | ||
| }); | ||
| process.on("SIGINT", () => { | ||
| debugLogger.forceFlush(); | ||
| process.exit(0); | ||
| }); | ||
| process.on("SIGTERM", () => { | ||
| debugLogger.forceFlush(); | ||
| process.exit(0); | ||
| }); |
| export declare function getHybridSearchTerms(queryText: string): [string[], string[], string[]]; | ||
| export declare function getTrackedFiles(repoPath: string): Promise<string[]>; | ||
| export declare function searchFilesHybrid(repoPath: string, queryText?: string | null, terms?: [string[], string[], string[]]): Promise<string[]>; |
| import path from "path"; | ||
| import { runRipgrep } from "./ripgrep.js"; | ||
| const STOP_WORDS = new Set(["a", "about", "above", "after", "again", "against", "all", "am", "an", "and", "any", "are", "aren't", "as", "at", "be", "because", "been", "before", "being", "below", "between", "both", "but", "by", "can't", "cannot", "could", "couldn't", "did", "didn't", "do", "does", "doesn't", "doing", "don't", "down", "during", "each", "few", "for", "from", "further", "had", "hadn't", "has", "hasn't", "have", "haven't", "having", "he", "he'd", "he'll", "he's", "her", "here", "here's", "hers", "herself", "him", "himself", "his", "how", "how's", "i", "i'd", "i'll", "i'm", "i've", "if", "in", "into", "is", "isn't", "it", "it's", "its", "itself", "let's", "me", "more", "most", "mustn't", "my", "myself", "no", "nor", "not", "of", "off", "on", "once", "only", "or", "other", "ought", "our", "ours", "ourselves", "out", "over", "own", "same", "shan't", "she", "she'd", "she'll", "she's", "should", "shouldn't", "so", "some", "such", "than", "that", "that's", "the", "their", "theirs", "them", "themselves", "then", "there", "there's", "these", "they", "they'd", "they'll", "they're", "they've", "this", "those", "through", "to", "too", "under", "until", "up", "very", "was", "wasn't", "we", "we'd", "we'll", "we're", "we've", "were", "weren't", "what", "what's", "when", "when's", "where", "where's", "which", "while", "who", "who's", "whom", "why", "why's", "with", "won't", "would", "wouldn't", "you", "you'd", "you'll", "you're", "you've", "your", "yours", "yourself", "yourselves"]); | ||
| const SUGGESTION_POOL_SIZE = 40; | ||
| const PATH_TOKEN_PATH_WEIGHT = 12; | ||
| const IDENTIFIER_TOKEN_PATH_WEIGHT = 4; | ||
| const WORD_TOKEN_PATH_WEIGHT = 2; | ||
| const PATH_TOKEN_CONTENT_WEIGHT = 12; | ||
| const IDENTIFIER_TOKEN_CONTENT_WEIGHT = 8; | ||
| const WORD_TOKEN_CONTENT_WEIGHT = 2; | ||
| const MAX_WORD_CONTENT_TERMS = 3; | ||
| const MIN_CONTENT_TERM_LENGTH = 4; | ||
| const TOKENIZER_REGEX = /`[^`]+`|[\w\-./_]+/g; | ||
| const CAMEL_SNAKE_CASE_REGEX = /([A-Z][a-z0-9]+)+|[a-z0-9]+_[a-z0-9]+/; | ||
| const TRIM_PUNCTUATION = "`\"'.,:;!?()[]{}<>"; | ||
| const trackedFilesCache = new Map(); | ||
| const trackedFilesPromises = new Map(); | ||
| const contentSearchCache = new Map(); | ||
| const contentSearchPromises = new Map(); | ||
| function normalizeRepoPath(repoPath) { | ||
| return path.resolve(repoPath); | ||
| } | ||
| function cleanToken(token) { | ||
| return token.replace(new RegExp(`[${TRIM_PUNCTUATION}]`, "g"), "").toLowerCase(); | ||
| } | ||
| function expandWordVariants(term) { | ||
| const variants = new Set(); | ||
| variants.add(term); | ||
| if (term.endsWith("ing") && term.length > 4) { | ||
| variants.add(term.slice(0, -3)); | ||
| } | ||
| if (term.endsWith("ed") && term.length > 3) { | ||
| variants.add(term.slice(0, -2)); | ||
| } | ||
| if (term.endsWith("es") && term.length > 4) { | ||
| variants.add(term.slice(0, -2)); | ||
| } | ||
| if (term.endsWith("s") && term.length > 3) { | ||
| variants.add(term.slice(0, -1)); | ||
| } | ||
| return new Set(Array.from(variants).filter(value => value.length > 1 && !STOP_WORDS.has(value))); | ||
| } | ||
| export function getHybridSearchTerms(queryText) { | ||
| const pathTerms = new Set(); | ||
| const identifierTerms = new Set(); | ||
| const wordTerms = new Set(); | ||
| const matches = queryText.match(TOKENIZER_REGEX) ?? []; | ||
| for (const rawToken of matches) { | ||
| const stripped = rawToken.trim(); | ||
| if (!stripped) { | ||
| continue; | ||
| } | ||
| const normalized = cleanToken(stripped); | ||
| if (!normalized || STOP_WORDS.has(normalized)) { | ||
| continue; | ||
| } | ||
| const rawHasInternalDot = stripped.length > 2 && stripped.slice(1, -1).includes("."); | ||
| const containsPathChars = stripped.includes("/") || | ||
| stripped.includes("-") || | ||
| stripped.startsWith(".") || | ||
| rawHasInternalDot || | ||
| normalized.includes("/") || | ||
| normalized.includes("-"); | ||
| const isBacktick = stripped.startsWith("`"); | ||
| const looksIdentifier = CAMEL_SNAKE_CASE_REGEX.test(stripped); | ||
| if (containsPathChars) { | ||
| pathTerms.add(normalized); | ||
| continue; | ||
| } | ||
| const components = normalized.includes(" ") ? normalized.split(/\s+/) : [normalized]; | ||
| if (isBacktick || looksIdentifier) { | ||
| for (const comp of components) { | ||
| if (!STOP_WORDS.has(comp)) { | ||
| identifierTerms.add(comp); | ||
| } | ||
| } | ||
| continue; | ||
| } | ||
| for (const comp of components) { | ||
| for (const variant of expandWordVariants(comp)) { | ||
| wordTerms.add(variant); | ||
| } | ||
| } | ||
| } | ||
| return [Array.from(pathTerms).sort(), Array.from(identifierTerms).sort(), Array.from(wordTerms).sort()]; | ||
| } | ||
| async function runTrackedFiles(repoPath) { | ||
| const result = await runRipgrep(["--files", "--hidden"], { cwd: repoPath }); | ||
| if (result.exitCode === 0) { | ||
| return result.stdout.split(/\r?\n/).filter(Boolean); | ||
| } | ||
| return []; | ||
| } | ||
| export async function getTrackedFiles(repoPath) { | ||
| const key = normalizeRepoPath(repoPath); | ||
| const cached = trackedFilesCache.get(key); | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
| const inFlight = trackedFilesPromises.get(key); | ||
| if (inFlight) { | ||
| return inFlight; | ||
| } | ||
| const promise = runTrackedFiles(key) | ||
| .then(files => { | ||
| trackedFilesCache.set(key, files); | ||
| trackedFilesPromises.delete(key); | ||
| return files; | ||
| }) | ||
| .catch(error => { | ||
| trackedFilesPromises.delete(key); | ||
| return []; | ||
| }); | ||
| trackedFilesPromises.set(key, promise); | ||
| return promise; | ||
| } | ||
| async function runFilesWithMatches(repoPath, term) { | ||
| const key = `${normalizeRepoPath(repoPath)}::${term}`; | ||
| const cached = contentSearchCache.get(key); | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
| const inFlight = contentSearchPromises.get(key); | ||
| if (inFlight) { | ||
| return inFlight; | ||
| } | ||
| const promise = runRipgrep(["--files-with-matches", "--hidden", "--ignore-case", "--fixed-strings", "--max-filesize", "2M", term], { cwd: normalizeRepoPath(repoPath) }) | ||
| .then(result => { | ||
| const matches = result.exitCode === 0 || result.exitCode === 1 ? result.stdout.split(/\r?\n/).filter(Boolean) : []; | ||
| contentSearchCache.set(key, matches); | ||
| contentSearchPromises.delete(key); | ||
| return matches; | ||
| }) | ||
| .catch(() => { | ||
| contentSearchPromises.delete(key); | ||
| return []; | ||
| }); | ||
| contentSearchPromises.set(key, promise); | ||
| return promise; | ||
| } | ||
| function applyPathScores(fileScores, files, pathTerms, identifierTerms, wordTerms) { | ||
| for (const filePath of files) { | ||
| const lower = filePath.toLowerCase(); | ||
| let score = 0; | ||
| for (const term of pathTerms) { | ||
| if (term && lower.includes(term)) { | ||
| score += PATH_TOKEN_PATH_WEIGHT; | ||
| } | ||
| } | ||
| for (const term of identifierTerms) { | ||
| if (term && lower.includes(term)) { | ||
| score += IDENTIFIER_TOKEN_PATH_WEIGHT; | ||
| } | ||
| } | ||
| for (const term of wordTerms) { | ||
| if (term && lower.includes(term)) { | ||
| score += WORD_TOKEN_PATH_WEIGHT; | ||
| } | ||
| } | ||
| if (score > 0) { | ||
| fileScores.set(filePath, (fileScores.get(filePath) ?? 0) + score); | ||
| } | ||
| } | ||
| } | ||
| async function applyContentScores(repoPath, fileScores, trackedFiles, terms, weight) { | ||
| const seen = new Set(); | ||
| const tasks = []; | ||
| for (const term of terms) { | ||
| if (!term || seen.has(term)) { | ||
| continue; | ||
| } | ||
| seen.add(term); | ||
| tasks.push(runFilesWithMatches(repoPath, term)); | ||
| } | ||
| const results = await Promise.all(tasks); | ||
| for (const matches of results) { | ||
| for (const filePath of matches) { | ||
| if (trackedFiles.has(filePath)) { | ||
| fileScores.set(filePath, (fileScores.get(filePath) ?? 0) + weight); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| export async function searchFilesHybrid(repoPath, queryText, terms) { | ||
| let collectedTerms = terms; | ||
| if (!collectedTerms) { | ||
| if (!queryText) { | ||
| return []; | ||
| } | ||
| collectedTerms = getHybridSearchTerms(queryText); | ||
| } | ||
| const [pathTerms, identifierTerms, wordTerms] = collectedTerms; | ||
| if ((!pathTerms || pathTerms.length === 0) && | ||
| (!identifierTerms || identifierTerms.length === 0) && | ||
| (!wordTerms || wordTerms.length === 0)) { | ||
| return []; | ||
| } | ||
| const files = await getTrackedFiles(repoPath); | ||
| if (!files || files.length === 0) { | ||
| return []; | ||
| } | ||
| const fileScores = new Map(); | ||
| const trackedSet = new Set(files); | ||
| applyPathScores(fileScores, files, pathTerms, identifierTerms, wordTerms); | ||
| const descriptiveWords = wordTerms | ||
| .filter(word => word.length >= MIN_CONTENT_TERM_LENGTH) | ||
| .sort((a, b) => b.length - a.length) | ||
| .slice(0, MAX_WORD_CONTENT_TERMS); | ||
| await Promise.all([ | ||
| applyContentScores(repoPath, fileScores, trackedSet, pathTerms, PATH_TOKEN_CONTENT_WEIGHT), | ||
| applyContentScores(repoPath, fileScores, trackedSet, identifierTerms, IDENTIFIER_TOKEN_CONTENT_WEIGHT), | ||
| applyContentScores(repoPath, fileScores, trackedSet, descriptiveWords, WORD_TOKEN_CONTENT_WEIGHT), | ||
| ]); | ||
| if (fileScores.size === 0) { | ||
| return []; | ||
| } | ||
| const sorted = Array.from(fileScores.entries()).sort((a, b) => { | ||
| const scoreDiff = b[1] - a[1]; | ||
| if (scoreDiff !== 0) { | ||
| return scoreDiff; | ||
| } | ||
| return a[0].localeCompare(b[0]); | ||
| }); | ||
| return sorted.slice(0, SUGGESTION_POOL_SIZE).map(([filePath]) => filePath); | ||
| } |
| export interface ToolCall { | ||
| name: string; | ||
| arguments: Record<string, unknown>; | ||
| } | ||
| export declare class LLMResponseParseError extends Error { | ||
| constructor(message: string); | ||
| } | ||
| export declare class LLMResponseParser { | ||
| private readonly finishSpecSplitRe; | ||
| parse(text: string): ToolCall[]; | ||
| private splitLine; | ||
| private handleAnalyse; | ||
| private handleRead; | ||
| private handleGrep; | ||
| private handleFinish; | ||
| private parseLineSpec; | ||
| private parseReadTarget; | ||
| private expandFinishToken; | ||
| } | ||
| export declare function parseLLMResponse(text: string): ToolCall[]; | ||
| export declare function isToolCall(value: unknown): value is ToolCall; |
| import stringArgv from "string-argv"; | ||
| function ensure(condition, message, ctx) { | ||
| if (!condition) { | ||
| throw new LLMResponseParseError(`Line ${ctx.lineNumber}: ${message} (got: ${ctx.raw})`); | ||
| } | ||
| } | ||
| export class LLMResponseParseError extends Error { | ||
| constructor(message) { | ||
| super(message); | ||
| this.name = "LLMResponseParseError"; | ||
| } | ||
| } | ||
| function isRecord(value) { | ||
| return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| } | ||
| export class LLMResponseParser { | ||
| finishSpecSplitRe = /,(?=[^,\s]+:)/; | ||
| parse(text) { | ||
| if (typeof text !== "string") { | ||
| throw new TypeError("Command text must be a string."); | ||
| } | ||
| const lines = text.split(/\r?\n/).map(line => line.trim()); | ||
| const commands = []; | ||
| let filesForFinish = null; | ||
| lines.forEach((line, idx) => { | ||
| if (!line || line.startsWith("#")) { | ||
| return; | ||
| } | ||
| const ctx = { lineNumber: idx + 1, raw: line }; | ||
| const parts = this.splitLine(line, ctx); | ||
| if (parts.length === 0) { | ||
| return; | ||
| } | ||
| const command = parts[0]; | ||
| switch (command) { | ||
| case "analyse": | ||
| this.handleAnalyse(parts, ctx, commands); | ||
| break; | ||
| case "read": | ||
| this.handleRead(parts, ctx, commands); | ||
| break; | ||
| case "grep": | ||
| this.handleGrep(parts, ctx, commands); | ||
| break; | ||
| case "finish": | ||
| filesForFinish = this.handleFinish(parts, ctx, filesForFinish); | ||
| break; | ||
| default: | ||
| throw new LLMResponseParseError(`Line ${ctx.lineNumber}: Unsupported command '${command}'`); | ||
| } | ||
| }); | ||
| if (filesForFinish) { | ||
| const map = filesForFinish; | ||
| const entries = [...map.entries()]; | ||
| const filesPayload = entries.map(([path, ranges]) => ({ | ||
| path, | ||
| lines: [...ranges].sort((a, b) => a[0] - b[0]), | ||
| })); | ||
| commands.push({ name: "finish", arguments: { files: filesPayload } }); | ||
| } | ||
| return commands; | ||
| } | ||
| splitLine(line, ctx) { | ||
| try { | ||
| const tokens = stringArgv(line, {}); | ||
| return tokens.filter(token => token.length > 0); | ||
| } | ||
| catch (error) { | ||
| throw new LLMResponseParseError(`Line ${ctx.lineNumber}: Unable to parse quoting (got: ${ctx.raw})`); | ||
| } | ||
| } | ||
| handleAnalyse(parts, ctx, commands) { | ||
| let pathArg = "."; | ||
| let patternArg; | ||
| if (parts.length === 2) { | ||
| pathArg = parts[1]; | ||
| } | ||
| else if (parts.length === 3) { | ||
| pathArg = parts[1]; | ||
| patternArg = parts[2]; | ||
| } | ||
| else if (parts.length > 3) { | ||
| ensure(false, "analyse accepts at most two arguments (path and pattern)", ctx); | ||
| } | ||
| if (parts.length > 1) { | ||
| ensure(Boolean(pathArg), "analyse path argument cannot be empty if provided", ctx); | ||
| } | ||
| const args = { path: pathArg }; | ||
| if (patternArg) { | ||
| args.pattern = patternArg; | ||
| } | ||
| commands.push({ name: "analyse", arguments: args }); | ||
| } | ||
| handleRead(parts, ctx, commands) { | ||
| ensure(parts.length === 2, "read requires exactly one argument (path[:range])", ctx); | ||
| const { path, metadata } = this.parseReadTarget(parts[1], ctx); | ||
| ensure(Boolean(path), "read requires a non-empty path", ctx); | ||
| commands.push({ name: "read", arguments: { path, ...metadata } }); | ||
| } | ||
| handleGrep(parts, ctx, commands) { | ||
| ensure(parts.length === 3, "grep requires both pattern and path arguments", ctx); | ||
| const pattern = parts[1]; | ||
| const path = parts[2]; | ||
| ensure(Boolean(pattern), "grep requires a non-empty pattern", ctx); | ||
| ensure(Boolean(path), "grep requires a non-empty path argument", ctx); | ||
| commands.push({ name: "grep", arguments: { pattern, path } }); | ||
| } | ||
| handleFinish(parts, ctx, filesForFinish) { | ||
| if (filesForFinish) { | ||
| ensure(false, "finish may only appear once", ctx); | ||
| } | ||
| const accumulator = new Map(); | ||
| if (parts.length >= 2) { | ||
| for (const rawSpec of parts.slice(1)) { | ||
| for (const spec of this.expandFinishToken(rawSpec)) { | ||
| if (!spec) { | ||
| continue; | ||
| } | ||
| const { path, ranges } = this.parseLineSpec(spec, ctx); | ||
| if (!accumulator.has(path)) { | ||
| accumulator.set(path, []); | ||
| } | ||
| const existing = accumulator.get(path); | ||
| for (const [start, end] of ranges) { | ||
| if (!existing.some(range => range[0] === start && range[1] === end)) { | ||
| existing.push([start, end]); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return accumulator; | ||
| } | ||
| parseLineSpec(token, ctx) { | ||
| const trimmed = token.trim(); | ||
| const colonIndex = trimmed.indexOf(":"); | ||
| ensure(colonIndex !== -1, "Finish entries must be in format path:range", ctx); | ||
| const pathRaw = trimmed.slice(0, colonIndex); | ||
| const rangesPart = trimmed.slice(colonIndex + 1); | ||
| const path = pathRaw.trim(); | ||
| ensure(Boolean(path), "Finish entries require a non-empty path", ctx); | ||
| const rangesTokens = rangesPart | ||
| .split(",") | ||
| .map(piece => piece.trim()) | ||
| .filter(piece => piece.length > 0); | ||
| ensure(rangesTokens.length > 0, "Finish entries must include at least one line range after ':'", ctx); | ||
| const ranges = []; | ||
| rangesTokens.forEach(chunk => { | ||
| const [startStr, endStr] = chunk.includes("-") ? chunk.split("-", 2) : [chunk, chunk]; | ||
| const start = Number.parseInt(startStr, 10); | ||
| const end = Number.parseInt(endStr, 10); | ||
| if (!Number.isInteger(start) || !Number.isInteger(end)) { | ||
| throw new LLMResponseParseError(`Line ${ctx.lineNumber}: Line range values must be integers (got: ${chunk})`); | ||
| } | ||
| ensure(start >= 1, "Line ranges must start at 1 or greater", ctx); | ||
| ensure(end >= start, "Line ranges must have end >= start", ctx); | ||
| ranges.push([start, end]); | ||
| }); | ||
| ensure(ranges.length > 0, "At least one range must be parsed for a finish entry.", ctx); | ||
| return { path, ranges }; | ||
| } | ||
| parseReadTarget(token, ctx) { | ||
| const colonIndex = token.indexOf(":"); | ||
| const pathRaw = colonIndex === -1 ? token : token.slice(0, colonIndex); | ||
| const rangePart = colonIndex === -1 ? undefined : token.slice(colonIndex + 1); | ||
| const path = pathRaw.trim(); | ||
| const metadata = {}; | ||
| if (rangePart !== undefined) { | ||
| const rangeToken = rangePart.trim(); | ||
| ensure(rangeToken.length > 0, "Read range cannot be empty after ':'", ctx); | ||
| const [startStr, endStr] = rangeToken.includes("-") | ||
| ? rangeToken.split("-", 2) | ||
| : [rangeToken, rangeToken]; | ||
| const start = Number.parseInt(startStr, 10); | ||
| const end = Number.parseInt(endStr, 10); | ||
| if (!Number.isInteger(start) || !Number.isInteger(end)) { | ||
| throw new LLMResponseParseError(`Line ${ctx.lineNumber}: Read ranges must contain integers (got: ${rangeToken})`); | ||
| } | ||
| ensure(start >= 1, "Read range start must be >= 1", ctx); | ||
| ensure(end >= start, "Read range end must be >= start", ctx); | ||
| metadata.start = start; | ||
| metadata.end = end; | ||
| } | ||
| return { path, metadata }; | ||
| } | ||
| expandFinishToken(token) { | ||
| const trimmed = token.trim(); | ||
| if (!trimmed) { | ||
| return []; | ||
| } | ||
| return trimmed | ||
| .split(this.finishSpecSplitRe) | ||
| .map((piece) => piece.trim()) | ||
| .filter((piece) => piece.length > 0); | ||
| } | ||
| } | ||
| const defaultParser = new LLMResponseParser(); | ||
| export function parseLLMResponse(text) { | ||
| return defaultParser.parse(text); | ||
| } | ||
| export function isToolCall(value) { | ||
| return (isRecord(value) && | ||
| typeof value.name === "string" && | ||
| typeof value.arguments === "object" && | ||
| value.arguments !== null); | ||
| } |
| export declare function normalizePath(p: string): string; | ||
| export declare function ensureWithinRepo(repoPath: string, targetPath: string): void; | ||
| export declare function toRepoRelative(repoPath: string, targetPath: string): string; |
| import path from "path"; | ||
| export function normalizePath(p) { | ||
| return path.resolve(p); | ||
| } | ||
| export function ensureWithinRepo(repoPath, targetPath) { | ||
| const repo = normalizePath(repoPath); | ||
| const target = normalizePath(targetPath); | ||
| const relative = path.relative(repo, target); | ||
| if (relative.startsWith("..") || path.isAbsolute(relative)) { | ||
| throw new Error(`Path is outside repository: ${targetPath}`); | ||
| } | ||
| } | ||
| export function toRepoRelative(repoPath, targetPath) { | ||
| const repo = normalizePath(repoPath); | ||
| const target = normalizePath(targetPath); | ||
| const relative = path.relative(repo, target); | ||
| return relative || "."; | ||
| } |
| export interface TreeEntry { | ||
| type?: string; | ||
| name?: string; | ||
| children?: TreeEntry[]; | ||
| message?: string; | ||
| } | ||
| export declare class RepoOverviewFormatter { | ||
| private readonly skipDirNames; | ||
| private readonly skipDirSuffixes; | ||
| private readonly skipFileNames; | ||
| private readonly skipFileSuffixes; | ||
| constructor(options: { | ||
| skipDirNames: Iterable<string>; | ||
| skipDirSuffixes: Iterable<string>; | ||
| skipFileNames: Iterable<string>; | ||
| skipFileSuffixes: Iterable<string>; | ||
| }); | ||
| format(entries: TreeEntry[], repoPath: string, initialSearchResults?: string[] | null): string; | ||
| private shouldSkipDir; | ||
| private shouldSkipFile; | ||
| private formatRootEntry; | ||
| private formatChildren; | ||
| private formatFileLabel; | ||
| private formatLinkLabel; | ||
| } | ||
| export declare const DEFAULT_REPO_OVERVIEW_FORMATTER: RepoOverviewFormatter; | ||
| export declare function formatRepoOverview(entries: TreeEntry[], repoPath: string, initialSearchResults?: string[] | null, formatter?: RepoOverviewFormatter): string; |
| import fs from "fs"; | ||
| import path from "path"; | ||
| function normaliseNameSet(values) { | ||
| const set = new Set(); | ||
| for (const value of values) { | ||
| set.add(value.toLowerCase()); | ||
| } | ||
| return set; | ||
| } | ||
| function normaliseSuffixTuple(values) { | ||
| return Array.from(values, value => value.toLowerCase()); | ||
| } | ||
| export class RepoOverviewFormatter { | ||
| skipDirNames; | ||
| skipDirSuffixes; | ||
| skipFileNames; | ||
| skipFileSuffixes; | ||
| constructor(options) { | ||
| this.skipDirNames = normaliseNameSet(options.skipDirNames); | ||
| this.skipDirSuffixes = normaliseSuffixTuple(options.skipDirSuffixes); | ||
| this.skipFileNames = normaliseNameSet(options.skipFileNames); | ||
| this.skipFileSuffixes = normaliseSuffixTuple(options.skipFileSuffixes); | ||
| } | ||
| format(entries, repoPath, initialSearchResults) { | ||
| const lines = ["<repo_overview>"]; | ||
| for (const entry of entries) { | ||
| lines.push(...this.formatRootEntry(entry, repoPath)); | ||
| } | ||
| lines.push("</repo_overview>"); | ||
| if (initialSearchResults && initialSearchResults.length > 0) { | ||
| const filtered = initialSearchResults | ||
| .filter(item => typeof item === "string" && item.trim().length > 0) | ||
| .map(item => item.trim()); | ||
| if (filtered.length > 0) { | ||
| if (lines.length > 2) { | ||
| lines.push(""); | ||
| } | ||
| lines.push("<initial_search_results>"); | ||
| lines.push(...filtered); | ||
| lines.push("</initial_search_results>"); | ||
| } | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| shouldSkipDir(name) { | ||
| const lowered = name.toLowerCase(); | ||
| if (this.skipDirNames.has(lowered)) { | ||
| return true; | ||
| } | ||
| return this.skipDirSuffixes.some(suffix => lowered.endsWith(suffix)); | ||
| } | ||
| shouldSkipFile(name) { | ||
| const lowered = name.toLowerCase(); | ||
| if (this.skipFileNames.has(lowered)) { | ||
| return true; | ||
| } | ||
| return this.skipFileSuffixes.some(suffix => lowered.endsWith(suffix)); | ||
| } | ||
| formatRootEntry(entry, repoPath) { | ||
| const entryType = entry.type; | ||
| const name = entry.name ?? ""; | ||
| if (entryType === "dir" && name) { | ||
| if (this.shouldSkipDir(name)) { | ||
| return []; | ||
| } | ||
| const lines = [`${name}/`]; | ||
| const children = Array.isArray(entry.children) ? entry.children : []; | ||
| if (children.length > 0) { | ||
| lines.push(...this.formatChildren(children, path.join(repoPath, name), "")); | ||
| } | ||
| return lines; | ||
| } | ||
| if (entryType === "file" && name) { | ||
| if (this.shouldSkipFile(name)) { | ||
| return []; | ||
| } | ||
| return [this.formatFileLabel(name, path.join(repoPath, name))]; | ||
| } | ||
| if (entryType === "link" && name) { | ||
| return [this.formatLinkLabel(name, path.join(repoPath, name))]; | ||
| } | ||
| if (entryType === "truncate" && entry.message) { | ||
| return [entry.message]; | ||
| } | ||
| return name ? [name] : []; | ||
| } | ||
| formatChildren(children, currentPath, prefix) { | ||
| const lines = []; | ||
| const list = children.filter(child => child && typeof child === "object"); | ||
| const total = list.length; | ||
| list.forEach((child, index) => { | ||
| const connector = index === total - 1 ? "└──" : "├──"; | ||
| const linePrefix = `${prefix}${connector} `; | ||
| const entryType = child.type; | ||
| const name = child.name ?? ""; | ||
| if (entryType === "dir" && name) { | ||
| if (this.shouldSkipDir(name)) { | ||
| return; | ||
| } | ||
| lines.push(`${linePrefix}${name}/`); | ||
| const grandChildren = Array.isArray(child.children) ? child.children : []; | ||
| if (grandChildren.length > 0) { | ||
| const nextPrefix = prefix + (index === total - 1 ? " " : "│ "); | ||
| lines.push(...this.formatChildren(grandChildren, path.join(currentPath, name), nextPrefix)); | ||
| } | ||
| return; | ||
| } | ||
| if (entryType === "file" && name) { | ||
| if (this.shouldSkipFile(name)) { | ||
| return; | ||
| } | ||
| lines.push(`${linePrefix}${this.formatFileLabel(name, path.join(currentPath, name))}`); | ||
| return; | ||
| } | ||
| if (entryType === "link" && name) { | ||
| lines.push(`${linePrefix}${this.formatLinkLabel(name, path.join(currentPath, name))}`); | ||
| return; | ||
| } | ||
| if (entryType === "truncate" && child.message) { | ||
| lines.push(`${linePrefix}${child.message}`); | ||
| return; | ||
| } | ||
| if (name) { | ||
| lines.push(`${linePrefix}${name}`); | ||
| } | ||
| }); | ||
| return lines; | ||
| } | ||
| formatFileLabel(name, filePath) { | ||
| return name || path.basename(filePath); | ||
| } | ||
| formatLinkLabel(name, filePath) { | ||
| if (!name) { | ||
| return name; | ||
| } | ||
| try { | ||
| const target = fs.readlinkSync(filePath); | ||
| return `${name}@ -> ${target}`; | ||
| } | ||
| catch { | ||
| return `${name}@`; | ||
| } | ||
| } | ||
| } | ||
| const DEFAULT_SKIP_DIR_NAMES = [ | ||
| ".git", | ||
| "__pycache__", | ||
| "node_modules", | ||
| "venv", | ||
| ".venv", | ||
| "env", | ||
| ".env", | ||
| ".cache", | ||
| ]; | ||
| const DEFAULT_SKIP_DIR_SUFFIXES = [".venv"]; | ||
| const DEFAULT_SKIP_FILE_NAMES = ["thumbs.db"]; | ||
| const DEFAULT_SKIP_FILE_SUFFIXES = [ | ||
| ".pyc", | ||
| ".pyo", | ||
| ".pyd", | ||
| ".so", | ||
| ".dll", | ||
| ".dylib", | ||
| ".class", | ||
| ".o", | ||
| ".obj", | ||
| ]; | ||
| export const DEFAULT_REPO_OVERVIEW_FORMATTER = new RepoOverviewFormatter({ | ||
| skipDirNames: DEFAULT_SKIP_DIR_NAMES, | ||
| skipDirSuffixes: DEFAULT_SKIP_DIR_SUFFIXES, | ||
| skipFileNames: DEFAULT_SKIP_FILE_NAMES, | ||
| skipFileSuffixes: DEFAULT_SKIP_FILE_SUFFIXES, | ||
| }); | ||
| export function formatRepoOverview(entries, repoPath, initialSearchResults, formatter = DEFAULT_REPO_OVERVIEW_FORMATTER) { | ||
| return formatter.format(entries, repoPath, initialSearchResults); | ||
| } |
| export interface RipgrepResult { | ||
| exitCode: number; | ||
| stdout: string; | ||
| stderr: string; | ||
| } | ||
| export declare function getRipgrepExecutable(): Promise<string>; | ||
| export declare function runRipgrep(args: string[], options: { | ||
| cwd: string; | ||
| }): Promise<RipgrepResult>; | ||
| export declare function shuffleInPlace<T>(array: T[]): void; |
| import { spawn } from "child_process"; | ||
| import { debugLogger } from "./debug-logger.js"; | ||
| let cachedExecutable = null; | ||
| /** | ||
| * Test if a ripgrep executable works by running --version | ||
| */ | ||
| async function testRipgrepExecutable(executable) { | ||
| return new Promise((resolve) => { | ||
| const child = spawn(executable, ["--version"], { | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| timeout: 2000, | ||
| }); | ||
| let hasOutput = false; | ||
| child.stdout?.once("data", () => { | ||
| hasOutput = true; | ||
| }); | ||
| child.once("close", (code, signal) => { | ||
| // Success if we got output and no signal termination | ||
| resolve(hasOutput && !signal && (code === 0 || code === null)); | ||
| }); | ||
| child.once("error", () => { | ||
| resolve(false); | ||
| }); | ||
| // Safety timeout | ||
| setTimeout(() => { | ||
| child.kill(); | ||
| resolve(false); | ||
| }, 2000); | ||
| }); | ||
| } | ||
| export async function getRipgrepExecutable() { | ||
| if (cachedExecutable) { | ||
| return cachedExecutable; | ||
| } | ||
| // Try @vscode/ripgrep first, but test if it actually works | ||
| // This is important because the bundled binary may not be compatible | ||
| // with systems using non-standard page sizes (e.g., 64KB pages on ARM64/NVIDIA systems) | ||
| try { | ||
| const module = await import("@vscode/ripgrep"); | ||
| if (module && typeof module.rgPath === "string") { | ||
| const works = await testRipgrepExecutable(module.rgPath); | ||
| if (works) { | ||
| cachedExecutable = module.rgPath; | ||
| debugLogger.debug("ripgrep", "Using @vscode/ripgrep", { | ||
| path: cachedExecutable, | ||
| }); | ||
| return cachedExecutable; | ||
| } | ||
| else { | ||
| debugLogger.warn("ripgrep", "@vscode/ripgrep binary failed compatibility test", { | ||
| path: module.rgPath, | ||
| reason: "Likely page size incompatibility (jemalloc)", | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| catch { | ||
| // fall through | ||
| } | ||
| // Fall back to system ripgrep | ||
| cachedExecutable = "rg"; | ||
| debugLogger.debug("ripgrep", "Using system ripgrep", { executable: "rg" }); | ||
| return cachedExecutable; | ||
| } | ||
| export async function runRipgrep(args, options) { | ||
| const executable = await getRipgrepExecutable(); | ||
| debugLogger.debug("ripgrep", "Spawning ripgrep process", { | ||
| executable, | ||
| args, | ||
| cwd: options.cwd, | ||
| }); | ||
| const startTime = Date.now(); | ||
| return new Promise((resolve, reject) => { | ||
| const child = spawn(executable, args, { | ||
| cwd: options.cwd, | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| }); | ||
| const stdoutChunks = []; | ||
| const stderrChunks = []; | ||
| child.stdout?.on("data", chunk => stdoutChunks.push(Buffer.from(chunk))); | ||
| child.stderr?.on("data", chunk => stderrChunks.push(Buffer.from(chunk))); | ||
| child.once("error", error => { | ||
| const elapsedMs = Date.now() - startTime; | ||
| debugLogger.error("ripgrep", "Ripgrep process error", { | ||
| error: error.message, | ||
| elapsedMs, | ||
| }); | ||
| reject(error); | ||
| }); | ||
| child.once("close", (code, signal) => { | ||
| const elapsedMs = Date.now() - startTime; | ||
| const stdout = Buffer.concat(stdoutChunks).toString("utf-8"); | ||
| const stderr = Buffer.concat(stderrChunks).toString("utf-8"); | ||
| // Log the result with truncated output preview | ||
| const stdoutPreview = stdout.length > 0 | ||
| ? stdout.split("\n").slice(0, 5).join("\n") + (stdout.split("\n").length > 5 ? "\n..." : "") | ||
| : "(empty)"; | ||
| const stderrPreview = stderr.length > 0 | ||
| ? stderr.split("\n").slice(0, 3).join("\n") | ||
| : "(empty)"; | ||
| debugLogger.debug("ripgrep", "Ripgrep process closed", { | ||
| exitCode: code, | ||
| signal, | ||
| stdoutLength: stdout.length, | ||
| stderrLength: stderr.length, | ||
| stdoutPreview, | ||
| stderrPreview, | ||
| elapsedMs, | ||
| }); | ||
| if (signal) { | ||
| debugLogger.error("ripgrep", "Ripgrep terminated by signal", { signal, elapsedMs }); | ||
| reject(new Error(`ripgrep terminated with signal ${signal}`)); | ||
| return; | ||
| } | ||
| resolve({ | ||
| exitCode: typeof code === "number" ? code : -1, | ||
| stdout, | ||
| stderr, | ||
| }); | ||
| }); | ||
| }); | ||
| } | ||
| export function shuffleInPlace(array) { | ||
| for (let i = array.length - 1; i > 0; i -= 1) { | ||
| const j = Math.floor(Math.random() * (i + 1)); | ||
| [array[i], array[j]] = [array[j], array[i]]; | ||
| } | ||
| } |
| export declare class ToolOutputFormatter { | ||
| format(toolName: string, args: Record<string, unknown> | null | undefined, output: string, options?: { | ||
| isError?: boolean; | ||
| }): string; | ||
| private formatRead; | ||
| private formatAnalyse; | ||
| private formatGrep; | ||
| private asString; | ||
| } | ||
| export declare function formatAgentToolOutput(toolName: string, args: Record<string, unknown> | null | undefined, output: string, options?: { | ||
| isError?: boolean; | ||
| }): string; | ||
| export declare function formatSftToolOutput(toolName: string, args: Record<string, unknown> | null | undefined, output: string, options?: { | ||
| isError?: boolean; | ||
| }): string; |
| export class ToolOutputFormatter { | ||
| format(toolName, args, output, options = {}) { | ||
| const name = (toolName ?? "").trim(); | ||
| if (!name) { | ||
| return ""; | ||
| } | ||
| const payload = output?.toString()?.trim?.() ?? ""; | ||
| const isError = Boolean(options.isError); | ||
| const safeArgs = args ?? {}; | ||
| if (!payload && !isError) { | ||
| return ""; | ||
| } | ||
| switch (name) { | ||
| case "read": | ||
| return this.formatRead(safeArgs, payload, isError); | ||
| case "analyse": | ||
| return this.formatAnalyse(safeArgs, payload, isError); | ||
| case "grep": | ||
| return this.formatGrep(safeArgs, payload, isError); | ||
| default: | ||
| return payload ? `<tool_output>\n${payload}\n</tool_output>` : ""; | ||
| } | ||
| } | ||
| formatRead(args, payload, isError) { | ||
| if (isError) { | ||
| return payload; | ||
| } | ||
| const path = this.asString(args.path) || "..."; | ||
| return `<file path="${path}">\n${payload}\n</file>`; | ||
| } | ||
| formatAnalyse(args, payload, isError) { | ||
| const path = this.asString(args.path) || "."; | ||
| if (isError) { | ||
| return `<analyse_results path="${path}" status="error">\n${payload}\n</analyse_results>`; | ||
| } | ||
| return `<analyse_results path="${path}">\n${payload}\n</analyse_results>`; | ||
| } | ||
| formatGrep(args, payload, isError) { | ||
| const pattern = this.asString(args.pattern); | ||
| const path = this.asString(args.path); | ||
| const attributes = []; | ||
| if (pattern !== undefined) { | ||
| attributes.push(`pattern="${pattern}"`); | ||
| } | ||
| if (path !== undefined) { | ||
| attributes.push(`path="${path}"`); | ||
| } | ||
| if (isError) { | ||
| attributes.push('status="error"'); | ||
| } | ||
| const attrText = attributes.length ? ` ${attributes.join(" ")}` : ""; | ||
| return `<grep_output${attrText}>\n${payload}\n</grep_output>`; | ||
| } | ||
| asString(value) { | ||
| if (value === null || value === undefined) { | ||
| return undefined; | ||
| } | ||
| return String(value); | ||
| } | ||
| } | ||
| const sharedFormatter = new ToolOutputFormatter(); | ||
| export function formatAgentToolOutput(toolName, args, output, options = {}) { | ||
| return sharedFormatter.format(toolName, args, output, options); | ||
| } | ||
| export function formatSftToolOutput(toolName, args, output, options = {}) { | ||
| return sharedFormatter.format(toolName, args, output, options); | ||
| } |
+6
-4
| { | ||
| "name": "morph-codeseek", | ||
| "version": "0.1.0", | ||
| "version": "0.1.1", | ||
| "description": "AI-powered code search MCP server for Cursor - find any code using natural language", | ||
@@ -30,5 +30,7 @@ "license": "MIT", | ||
| "files": [ | ||
| "dist/*.js", | ||
| "dist/*.d.ts", | ||
| "!dist/__tests__/**" | ||
| "dist/**/*.js", | ||
| "dist/**/*.d.ts", | ||
| "!dist/**/__tests__/**", | ||
| "README.md", | ||
| "LICENSE" | ||
| ], | ||
@@ -35,0 +37,0 @@ "scripts": { |
+1
-1
@@ -26,3 +26,3 @@ # Morph Grep Agent MCP Server | ||
| "command": "npx", | ||
| "args": ["-y", "@morph-llm/mcp-grep-agent"], | ||
| "args": ["-y", "morph-codeseek"], | ||
| "env": { | ||
@@ -29,0 +29,0 @@ "GREP_AGENT_API_KEY": "sk-your-morph-api-key-here" |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Unpublished package
Supply chain riskPackage version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Unpublished package
Supply chain riskPackage version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry.
Found 1 instance in 1 package
126945
416.58%48
700%2868
859.2%12
140%3
200%