From 0d1247186821a35751724f546e05f29d554c0c45 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 2 Apr 2026 18:08:30 +0000 Subject: [PATCH] feat: add web search, file edit, MCP management, file refs, and /stop to CLI/TUI (#348) --- apps/gateway/src/agent/agent.service.ts | 2 + apps/gateway/src/agent/tools/file-tools.ts | 166 +++++- apps/gateway/src/agent/tools/index.ts | 1 + apps/gateway/src/agent/tools/search-tools.ts | 496 ++++++++++++++++++ apps/gateway/src/chat/chat.gateway.ts | 33 ++ .../commands/command-executor-p8012.spec.ts | 1 + .../src/commands/command-executor.service.ts | 94 ++++ .../src/commands/command-registry.service.ts | 17 + .../src/commands/commands.integration.spec.ts | 1 + packages/cli/src/tui/app.tsx | 64 ++- packages/cli/src/tui/commands/registry.ts | 16 + packages/cli/src/tui/file-ref.ts | 202 +++++++ packages/types/src/chat/events.ts | 6 + packages/types/src/chat/index.ts | 1 + scratchpads/cli-tui-tools-enhancement.md | 66 +++ 15 files changed, 1162 insertions(+), 4 deletions(-) create mode 100644 apps/gateway/src/agent/tools/search-tools.ts create mode 100644 packages/cli/src/tui/file-ref.ts create mode 100644 scratchpads/cli-tui-tools-enhancement.md diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index c7d74ba..ead3279 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -23,6 +23,7 @@ import { createFileTools } from './tools/file-tools.js'; import { createGitTools } from './tools/git-tools.js'; import { createShellTools } from './tools/shell-tools.js'; import { createWebTools } from './tools/web-tools.js'; +import { createSearchTools } from './tools/search-tools.js'; import type { SessionInfoDto, SessionMetrics } from './session.dto.js'; import { SystemOverrideService } from '../preferences/system-override.service.js'; import { PreferencesService } from '../preferences/preferences.service.js'; @@ -146,6 +147,7 @@ export class AgentService implements OnModuleDestroy { ...createGitTools(sandboxDir), ...createShellTools(sandboxDir), ...createWebTools(), + ...createSearchTools(), ]; } diff --git a/apps/gateway/src/agent/tools/file-tools.ts b/apps/gateway/src/agent/tools/file-tools.ts index 371d2ee..62e4512 100644 --- a/apps/gateway/src/agent/tools/file-tools.ts +++ b/apps/gateway/src/agent/tools/file-tools.ts @@ -190,5 +190,169 @@ export function createFileTools(baseDir: string): ToolDefinition[] { }, }; - return [readFileTool, writeFileTool, listDirectoryTool]; + const editFileTool: ToolDefinition = { + name: 'fs_edit_file', + label: 'Edit File', + description: + 'Make targeted text replacements in a file. Each edit replaces an exact match of oldText with newText. ' + + 'All edits are matched against the original file content (not incrementally). ' + + 'Each oldText must be unique in the file and edits must not overlap.', + parameters: Type.Object({ + path: Type.String({ + description: 'File path (relative to sandbox base or absolute within it)', + }), + edits: Type.Array( + Type.Object({ + oldText: Type.String({ + description: 'Exact text to find and replace (must be unique in the file)', + }), + newText: Type.String({ description: 'Replacement text' }), + }), + { description: 'One or more targeted replacements', minItems: 1 }, + ), + }), + async execute(_toolCallId, params) { + const { path, edits } = params as { + path: string; + edits: Array<{ oldText: string; newText: string }>; + }; + + let safePath: string; + try { + safePath = guardPath(path, baseDir); + } catch (err) { + if (err instanceof SandboxEscapeError) { + return { + content: [{ type: 'text' as const, text: `Error: ${err.message}` }], + details: undefined, + }; + } + return { + content: [{ type: 'text' as const, text: `Error: ${String(err)}` }], + details: undefined, + }; + } + + try { + const info = await stat(safePath); + if (!info.isFile()) { + return { + content: [{ type: 'text' as const, text: `Error: path is not a file: ${path}` }], + details: undefined, + }; + } + if (info.size > MAX_READ_BYTES) { + return { + content: [ + { + type: 'text' as const, + text: `Error: file too large for editing (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`, + }, + ], + details: undefined, + }; + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }], + details: undefined, + }; + } + + let content: string; + try { + content = await readFile(safePath, { encoding: 'utf8' }); + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }], + details: undefined, + }; + } + + // Validate all edits before applying any + const errors: string[] = []; + for (let i = 0; i < edits.length; i++) { + const edit = edits[i]!; + const occurrences = content.split(edit.oldText).length - 1; + if (occurrences === 0) { + errors.push(`Edit ${i + 1}: oldText not found in file`); + } else if (occurrences > 1) { + errors.push(`Edit ${i + 1}: oldText matches ${occurrences} locations (must be unique)`); + } + } + + // Check for overlapping edits + if (errors.length === 0) { + const positions = edits.map((edit, i) => ({ + index: i, + start: content.indexOf(edit.oldText), + end: content.indexOf(edit.oldText) + edit.oldText.length, + })); + positions.sort((a, b) => a.start - b.start); + for (let i = 1; i < positions.length; i++) { + if (positions[i]!.start < positions[i - 1]!.end) { + errors.push( + `Edits ${positions[i - 1]!.index + 1} and ${positions[i]!.index + 1} overlap`, + ); + } + } + } + + if (errors.length > 0) { + return { + content: [ + { + type: 'text' as const, + text: `Edit validation failed:\n${errors.join('\n')}`, + }, + ], + details: undefined, + }; + } + + // Apply edits: process from end to start to preserve positions + const positions = edits.map((edit) => ({ + edit, + start: content.indexOf(edit.oldText), + })); + positions.sort((a, b) => b.start - a.start); // reverse order + + let result = content; + for (const { edit } of positions) { + result = result.replace(edit.oldText, edit.newText); + } + + if (Buffer.byteLength(result, 'utf8') > MAX_WRITE_BYTES) { + return { + content: [ + { + type: 'text' as const, + text: `Error: resulting file too large (limit ${MAX_WRITE_BYTES} bytes)`, + }, + ], + details: undefined, + }; + } + + try { + await writeFile(safePath, result, { encoding: 'utf8' }); + return { + content: [ + { + type: 'text' as const, + text: `File edited successfully: ${path} (${edits.length} edit(s) applied)`, + }, + ], + details: undefined, + }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }], + details: undefined, + }; + } + }, + }; + + return [readFileTool, writeFileTool, listDirectoryTool, editFileTool]; } diff --git a/apps/gateway/src/agent/tools/index.ts b/apps/gateway/src/agent/tools/index.ts index 70e4388..35cf7e0 100644 --- a/apps/gateway/src/agent/tools/index.ts +++ b/apps/gateway/src/agent/tools/index.ts @@ -2,6 +2,7 @@ export { createBrainTools } from './brain-tools.js'; export { createCoordTools } from './coord-tools.js'; export { createFileTools } from './file-tools.js'; export { createGitTools } from './git-tools.js'; +export { createSearchTools } from './search-tools.js'; export { createShellTools } from './shell-tools.js'; export { createWebTools } from './web-tools.js'; export { createSkillTools } from './skill-tools.js'; diff --git a/apps/gateway/src/agent/tools/search-tools.ts b/apps/gateway/src/agent/tools/search-tools.ts new file mode 100644 index 0000000..0034115 --- /dev/null +++ b/apps/gateway/src/agent/tools/search-tools.ts @@ -0,0 +1,496 @@ +import { Type } from '@sinclair/typebox'; +import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; + +const DEFAULT_TIMEOUT_MS = 15_000; +const MAX_RESULTS = 10; +const MAX_RESPONSE_BYTES = 256 * 1024; // 256 KB + +// ─── Provider helpers ──────────────────────────────────────────────────────── + +interface SearchResult { + title: string; + url: string; + snippet: string; +} + +interface SearchResponse { + provider: string; + query: string; + results: SearchResult[]; + error?: string; +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function readLimited(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) return ''; + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > MAX_RESPONSE_BYTES) { + chunks.push(value.subarray(0, MAX_RESPONSE_BYTES - (total - value.length))); + reader.cancel(); + break; + } + chunks.push(value); + } + const combined = new Uint8Array(chunks.reduce((a, c) => a + c.length, 0)); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(combined); +} + +// ─── Brave Search ──────────────────────────────────────────────────────────── + +async function searchBrave(query: string, limit: number): Promise { + const apiKey = process.env['BRAVE_API_KEY']; + if (!apiKey) return { provider: 'brave', query, results: [], error: 'BRAVE_API_KEY not set' }; + + try { + const params = new URLSearchParams({ + q: query, + count: String(Math.min(limit, 20)), + }); + const res = await fetchWithTimeout( + `https://api.search.brave.com/res/v1/web/search?${params}`, + { headers: { 'X-Subscription-Token': apiKey, Accept: 'application/json' } }, + DEFAULT_TIMEOUT_MS, + ); + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { provider: 'brave', query, results: [], error: `HTTP ${res.status}: ${body}` }; + } + const data = (await res.json()) as { + web?: { results?: Array<{ title: string; url: string; description: string }> }; + }; + const results: SearchResult[] = (data.web?.results ?? []).slice(0, limit).map((r) => ({ + title: r.title, + url: r.url, + snippet: r.description, + })); + return { provider: 'brave', query, results }; + } catch (err) { + return { + provider: 'brave', + query, + results: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + +// ─── Tavily Search ─────────────────────────────────────────────────────────── + +async function searchTavily(query: string, limit: number): Promise { + const apiKey = process.env['TAVILY_API_KEY']; + if (!apiKey) return { provider: 'tavily', query, results: [], error: 'TAVILY_API_KEY not set' }; + + try { + const res = await fetchWithTimeout( + 'https://api.tavily.com/search', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + api_key: apiKey, + query, + max_results: Math.min(limit, 10), + include_answer: false, + }), + }, + DEFAULT_TIMEOUT_MS, + ); + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { provider: 'tavily', query, results: [], error: `HTTP ${res.status}: ${body}` }; + } + const data = (await res.json()) as { + results?: Array<{ title: string; url: string; content: string }>; + }; + const results: SearchResult[] = (data.results ?? []).slice(0, limit).map((r) => ({ + title: r.title, + url: r.url, + snippet: r.content, + })); + return { provider: 'tavily', query, results }; + } catch (err) { + return { + provider: 'tavily', + query, + results: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + +// ─── SearXNG (self-hosted) ─────────────────────────────────────────────────── + +async function searchSearxng(query: string, limit: number): Promise { + const baseUrl = process.env['SEARXNG_URL']; + if (!baseUrl) return { provider: 'searxng', query, results: [], error: 'SEARXNG_URL not set' }; + + try { + const params = new URLSearchParams({ + q: query, + format: 'json', + pageno: '1', + }); + const res = await fetchWithTimeout( + `${baseUrl.replace(/\/$/, '')}/search?${params}`, + { headers: { Accept: 'application/json' } }, + DEFAULT_TIMEOUT_MS, + ); + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { provider: 'searxng', query, results: [], error: `HTTP ${res.status}: ${body}` }; + } + const data = (await res.json()) as { + results?: Array<{ title: string; url: string; content: string }>; + }; + const results: SearchResult[] = (data.results ?? []).slice(0, limit).map((r) => ({ + title: r.title, + url: r.url, + snippet: r.content, + })); + return { provider: 'searxng', query, results }; + } catch (err) { + return { + provider: 'searxng', + query, + results: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + +// ─── DuckDuckGo (lite HTML endpoint) ───────────────────────────────────────── + +async function searchDuckDuckGo(query: string, limit: number): Promise { + try { + // Use the DuckDuckGo Instant Answer API (JSON, free, no key) + const params = new URLSearchParams({ + q: query, + format: 'json', + no_html: '1', + skip_disambig: '1', + }); + const res = await fetchWithTimeout( + `https://api.duckduckgo.com/?${params}`, + { headers: { Accept: 'application/json' } }, + DEFAULT_TIMEOUT_MS, + ); + if (!res.ok) { + return { + provider: 'duckduckgo', + query, + results: [], + error: `HTTP ${res.status}`, + }; + } + const text = await readLimited(res); + const data = JSON.parse(text) as { + AbstractText?: string; + AbstractURL?: string; + AbstractSource?: string; + RelatedTopics?: Array<{ + Text?: string; + FirstURL?: string; + Result?: string; + Topics?: Array<{ Text?: string; FirstURL?: string }>; + }>; + }; + + const results: SearchResult[] = []; + + // Main abstract result + if (data.AbstractText && data.AbstractURL) { + results.push({ + title: data.AbstractSource ?? 'DuckDuckGo Abstract', + url: data.AbstractURL, + snippet: data.AbstractText, + }); + } + + // Related topics + for (const topic of data.RelatedTopics ?? []) { + if (results.length >= limit) break; + if (topic.Text && topic.FirstURL) { + results.push({ + title: topic.Text.slice(0, 120), + url: topic.FirstURL, + snippet: topic.Text, + }); + } + // Sub-topics + for (const sub of topic.Topics ?? []) { + if (results.length >= limit) break; + if (sub.Text && sub.FirstURL) { + results.push({ + title: sub.Text.slice(0, 120), + url: sub.FirstURL, + snippet: sub.Text, + }); + } + } + } + + return { provider: 'duckduckgo', query, results: results.slice(0, limit) }; + } catch (err) { + return { + provider: 'duckduckgo', + query, + results: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + +// ─── Provider resolution ───────────────────────────────────────────────────── + +type SearchProvider = 'brave' | 'tavily' | 'searxng' | 'duckduckgo' | 'auto'; + +function getAvailableProviders(): SearchProvider[] { + const available: SearchProvider[] = []; + if (process.env['BRAVE_API_KEY']) available.push('brave'); + if (process.env['TAVILY_API_KEY']) available.push('tavily'); + if (process.env['SEARXNG_URL']) available.push('searxng'); + // DuckDuckGo is always available (no API key needed) + available.push('duckduckgo'); + return available; +} + +async function executeSearch( + provider: SearchProvider, + query: string, + limit: number, +): Promise { + switch (provider) { + case 'brave': + return searchBrave(query, limit); + case 'tavily': + return searchTavily(query, limit); + case 'searxng': + return searchSearxng(query, limit); + case 'duckduckgo': + return searchDuckDuckGo(query, limit); + case 'auto': { + // Try providers in priority order: Brave > Tavily > SearXNG > DuckDuckGo + const available = getAvailableProviders(); + for (const p of available) { + const result = await executeSearch(p, query, limit); + if (!result.error && result.results.length > 0) return result; + } + // Fall back to DuckDuckGo if everything failed + return searchDuckDuckGo(query, limit); + } + } +} + +function formatSearchResults(response: SearchResponse): string { + const lines: string[] = []; + lines.push(`Search provider: ${response.provider}`); + lines.push(`Query: "${response.query}"`); + + if (response.error) { + lines.push(`Error: ${response.error}`); + } + + if (response.results.length === 0) { + lines.push('No results found.'); + } else { + lines.push(`Results (${response.results.length}):\n`); + for (let i = 0; i < response.results.length; i++) { + const r = response.results[i]!; + lines.push(`${i + 1}. ${r.title}`); + lines.push(` URL: ${r.url}`); + lines.push(` ${r.snippet}`); + lines.push(''); + } + } + return lines.join('\n'); +} + +// ─── Tool exports ──────────────────────────────────────────────────────────── + +export function createSearchTools(): ToolDefinition[] { + const webSearch: ToolDefinition = { + name: 'web_search', + label: 'Web Search', + description: + 'Search the web using configured search providers. ' + + 'Supports Brave, Tavily, SearXNG, and DuckDuckGo. ' + + 'Use "auto" provider to pick the best available. ' + + 'DuckDuckGo is always available as a fallback (no API key needed).', + parameters: Type.Object({ + query: Type.String({ description: 'Search query' }), + provider: Type.Optional( + Type.String({ + description: + 'Search provider: "auto" (default), "brave", "tavily", "searxng", or "duckduckgo"', + }), + ), + limit: Type.Optional( + Type.Number({ description: `Max results to return (default 5, max ${MAX_RESULTS})` }), + ), + }), + async execute(_toolCallId, params) { + const { query, provider, limit } = params as { + query: string; + provider?: string; + limit?: number; + }; + + const effectiveProvider = (provider ?? 'auto') as SearchProvider; + const validProviders = ['auto', 'brave', 'tavily', 'searxng', 'duckduckgo']; + if (!validProviders.includes(effectiveProvider)) { + return { + content: [ + { + type: 'text' as const, + text: `Invalid provider "${provider}". Valid: ${validProviders.join(', ')}`, + }, + ], + details: undefined, + }; + } + + const effectiveLimit = Math.min(Math.max(limit ?? 5, 1), MAX_RESULTS); + + try { + const response = await executeSearch(effectiveProvider, query, effectiveLimit); + return { + content: [{ type: 'text' as const, text: formatSearchResults(response) }], + details: undefined, + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Search failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + details: undefined, + }; + } + }, + }; + + const webSearchNews: ToolDefinition = { + name: 'web_search_news', + label: 'Web Search (News)', + description: + 'Search for recent news articles. Uses Brave News API if available, falls back to standard search with news keywords.', + parameters: Type.Object({ + query: Type.String({ description: 'News search query' }), + limit: Type.Optional( + Type.Number({ description: `Max results (default 5, max ${MAX_RESULTS})` }), + ), + }), + async execute(_toolCallId, params) { + const { query, limit } = params as { query: string; limit?: number }; + const effectiveLimit = Math.min(Math.max(limit ?? 5, 1), MAX_RESULTS); + + // Try Brave News API first (dedicated news endpoint) + const braveKey = process.env['BRAVE_API_KEY']; + if (braveKey) { + try { + const newsParams = new URLSearchParams({ + q: query, + count: String(effectiveLimit), + }); + const res = await fetchWithTimeout( + `https://api.search.brave.com/res/v1/news/search?${newsParams}`, + { + headers: { + 'X-Subscription-Token': braveKey, + Accept: 'application/json', + }, + }, + DEFAULT_TIMEOUT_MS, + ); + if (res.ok) { + const data = (await res.json()) as { + results?: Array<{ + title: string; + url: string; + description: string; + age?: string; + }>; + }; + const results: SearchResult[] = (data.results ?? []) + .slice(0, effectiveLimit) + .map((r) => ({ + title: r.title + (r.age ? ` (${r.age})` : ''), + url: r.url, + snippet: r.description, + })); + const response: SearchResponse = { provider: 'brave-news', query, results }; + return { + content: [{ type: 'text' as const, text: formatSearchResults(response) }], + details: undefined, + }; + } + } catch { + // Fall through to generic search + } + } + + // Fallback: standard search with "news" appended + const newsQuery = `${query} news latest`; + const response = await executeSearch('auto', newsQuery, effectiveLimit); + return { + content: [{ type: 'text' as const, text: formatSearchResults(response) }], + details: undefined, + }; + }, + }; + + const searchProviders: ToolDefinition = { + name: 'web_search_providers', + label: 'List Search Providers', + description: 'List the currently available and configured web search providers.', + parameters: Type.Object({}), + async execute() { + const available = getAvailableProviders(); + const allProviders = [ + { name: 'brave', configured: !!process.env['BRAVE_API_KEY'], envVar: 'BRAVE_API_KEY' }, + { name: 'tavily', configured: !!process.env['TAVILY_API_KEY'], envVar: 'TAVILY_API_KEY' }, + { name: 'searxng', configured: !!process.env['SEARXNG_URL'], envVar: 'SEARXNG_URL' }, + { name: 'duckduckgo', configured: true, envVar: '(none — always available)' }, + ]; + + const lines = ['Search providers:\n']; + for (const p of allProviders) { + const status = p.configured ? '✓ configured' : '✗ not configured'; + lines.push(` ${p.name}: ${status} (${p.envVar})`); + } + lines.push(`\nActive providers for "auto" mode: ${available.join(', ')}`); + return { + content: [{ type: 'text' as const, text: lines.join('\n') }], + details: undefined, + }; + }, + }; + + return [webSearch, webSearchNews, searchProviders]; +} diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 521c816..7543fb4 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -18,6 +18,7 @@ import type { SlashCommandPayload, SystemReloadPayload, RoutingDecisionInfo, + AbortPayload, } from '@mosaic/types'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { AUTH } from '../auth/auth.tokens.js'; @@ -325,6 +326,38 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa }); } + @SubscribeMessage('abort') + async handleAbort( + @ConnectedSocket() client: Socket, + @MessageBody() data: AbortPayload, + ): Promise { + const conversationId = data.conversationId; + this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`); + + const session = this.agentService.getSession(conversationId); + if (!session) { + client.emit('error', { + conversationId, + error: 'No active session to abort.', + }); + return; + } + + try { + await session.piSession.abort(); + this.logger.log(`Agent session ${conversationId} aborted successfully`); + } catch (err) { + this.logger.error( + `Failed to abort session ${conversationId}`, + err instanceof Error ? err.stack : String(err), + ); + client.emit('error', { + conversationId, + error: 'Failed to abort the agent operation.', + }); + } + } + @SubscribeMessage('command:execute') async handleCommandExecute( @ConnectedSocket() client: Socket, diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index 7ff5c41..73b83ba 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -82,6 +82,7 @@ function buildService(): CommandExecutorService { mockBrain as never, null, mockChatGateway as never, + null, ); } diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index 710e35d..e46b2b2 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -7,6 +7,7 @@ import { ChatGateway } from '../chat/chat.gateway.js'; import { SessionGCService } from '../gc/session-gc.service.js'; import { SystemOverrideService } from '../preferences/system-override.service.js'; import { ReloadService } from '../reload/reload.service.js'; +import { McpClientService } from '../mcp-client/mcp-client.service.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; import { CommandRegistryService } from './command-registry.service.js'; @@ -28,6 +29,9 @@ export class CommandExecutorService { @Optional() @Inject(forwardRef(() => ChatGateway)) private readonly chatGateway: ChatGateway | null, + @Optional() + @Inject(McpClientService) + private readonly mcpClient: McpClientService | null, ) {} async execute(payload: SlashCommandPayload, userId: string): Promise { @@ -105,6 +109,8 @@ export class CommandExecutorService { }; case 'tools': return await this.handleTools(conversationId, userId); + case 'mcp': + return await this.handleMcp(args ?? null, conversationId); case 'reload': { if (!this.reloadService) { return { @@ -489,4 +495,92 @@ export class CommandExecutorService { conversationId, }; } + + private async handleMcp( + args: string | null, + conversationId: string, + ): Promise { + if (!this.mcpClient) { + return { + command: 'mcp', + conversationId, + success: false, + message: 'MCP client service is not available.', + }; + } + + const action = args?.trim().split(/\s+/)[0] ?? 'status'; + + switch (action) { + case 'status': + case 'servers': { + const statuses = this.mcpClient.getServerStatuses(); + if (statuses.length === 0) { + return { + command: 'mcp', + conversationId, + success: true, + message: + 'No MCP servers configured. Set MCP_SERVERS env var to connect external tool servers.', + }; + } + const lines = ['MCP Server Status:\n']; + for (const s of statuses) { + const status = s.connected ? '✓ connected' : '✗ disconnected'; + lines.push(` ${s.name}: ${status}`); + lines.push(` URL: ${s.url}`); + lines.push(` Tools: ${s.toolCount}`); + if (s.error) lines.push(` Error: ${s.error}`); + lines.push(''); + } + const tools = this.mcpClient.getToolDefinitions(); + if (tools.length > 0) { + lines.push(`Total bridged tools: ${tools.length}`); + lines.push(`Tool names: ${tools.map((t) => t.name).join(', ')}`); + } + return { + command: 'mcp', + conversationId, + success: true, + message: lines.join('\n'), + }; + } + + case 'reconnect': { + const serverName = args?.trim().split(/\s+/).slice(1).join(' '); + if (!serverName) { + return { + command: 'mcp', + conversationId, + success: false, + message: 'Usage: /mcp reconnect ', + }; + } + try { + await this.mcpClient.reconnectServer(serverName); + return { + command: 'mcp', + conversationId, + success: true, + message: `MCP server "${serverName}" reconnected successfully.`, + }; + } catch (err) { + return { + command: 'mcp', + conversationId, + success: false, + message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + + default: + return { + command: 'mcp', + conversationId, + success: false, + message: `Unknown MCP action: "${action}". Use: /mcp status, /mcp servers, /mcp reconnect `, + }; + } + } } diff --git a/apps/gateway/src/commands/command-registry.service.ts b/apps/gateway/src/commands/command-registry.service.ts index cb05257..5add850 100644 --- a/apps/gateway/src/commands/command-registry.service.ts +++ b/apps/gateway/src/commands/command-registry.service.ts @@ -260,6 +260,23 @@ export class CommandRegistryService implements OnModuleInit { execution: 'socket', available: true, }, + { + name: 'mcp', + description: 'Manage MCP server connections (status/reconnect/servers)', + aliases: [], + args: [ + { + name: 'action', + type: 'enum', + optional: true, + values: ['status', 'reconnect', 'servers'], + description: 'Action: status (default), reconnect , servers', + }, + ], + scope: 'agent', + execution: 'socket', + available: true, + }, { name: 'reload', description: 'Soft-reload gateway plugins and command manifest (admin)', diff --git a/apps/gateway/src/commands/commands.integration.spec.ts b/apps/gateway/src/commands/commands.integration.spec.ts index 949e203..107d578 100644 --- a/apps/gateway/src/commands/commands.integration.spec.ts +++ b/apps/gateway/src/commands/commands.integration.spec.ts @@ -65,6 +65,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService mockBrain as never, null, // reloadService (optional) null, // chatGateway (optional) + null, // mcpClient (optional) ); } diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index d81cab6..4538cfa 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -15,6 +15,7 @@ import { useConversations } from './hooks/use-conversations.js'; import { useSearch } from './hooks/use-search.js'; import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js'; import { fetchConversationMessages } from './gateway-api.js'; +import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js'; export interface TuiAppProps { gatewayUrl: string; @@ -85,6 +86,36 @@ export function TuiApp({ // combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't'). const ctrlJustFired = useRef(false); + // Wrap sendMessage to expand @file references before sending + const sendMessageWithFileRefs = useCallback( + (content: string) => { + if (!hasFileRefs(content)) { + socket.sendMessage(content); + return; + } + void expandFileRefs(content) + .then(({ expandedMessage, filesAttached, errors }) => { + for (const err of errors) { + socket.addSystemMessage(err); + } + if (filesAttached.length > 0) { + socket.addSystemMessage( + `📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`, + ); + } + socket.sendMessage(expandedMessage); + }) + .catch((err: unknown) => { + socket.addSystemMessage( + `File expansion failed: ${err instanceof Error ? err.message : String(err)}`, + ); + // Send original message without expansion + socket.sendMessage(content); + }); + }, + [socket], + ); + const handleLocalCommand = useCallback( (parsed: ParsedCommand) => { switch (parsed.command) { @@ -123,9 +154,36 @@ export function TuiApp({ socket.addSystemMessage('Failed to create new conversation.'); }); break; + case 'attach': { + if (!parsed.args) { + socket.addSystemMessage('Usage: /attach '); + break; + } + void handleAttachCommand(parsed.args) + .then(({ content, error }) => { + if (error) { + socket.addSystemMessage(`Attach error: ${error}`); + } else if (content) { + // Send the file content as a user message + socket.sendMessage(content); + } + }) + .catch((err: unknown) => { + socket.addSystemMessage( + `Attach failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + break; + } case 'stop': - // Currently no stop mechanism exposed — show feedback - socket.addSystemMessage('Stop is not available for the current session.'); + if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) { + socket.socketRef.current.emit('abort', { + conversationId: socket.conversationId, + }); + socket.addSystemMessage('Abort signal sent.'); + } else { + socket.addSystemMessage('No active stream to stop.'); + } break; case 'cost': { const u = socket.tokenUsage; @@ -348,7 +406,7 @@ export function TuiApp({ } setTuiInput(val); }} - onSubmit={socket.sendMessage} + onSubmit={sendMessageWithFileRefs} onSystemMessage={socket.addSystemMessage} onLocalCommand={handleLocalCommand} onGatewayCommand={handleGatewayCommand} diff --git a/packages/cli/src/tui/commands/registry.ts b/packages/cli/src/tui/commands/registry.ts index 623a2ca..d3c38fa 100644 --- a/packages/cli/src/tui/commands/registry.ts +++ b/packages/cli/src/tui/commands/registry.ts @@ -56,6 +56,22 @@ const LOCAL_COMMANDS: CommandDef[] = [ available: true, scope: 'core', }, + { + name: 'attach', + description: 'Attach a file to the next message (@file syntax also works inline)', + aliases: [], + args: [ + { + name: 'path', + type: 'string' as const, + optional: false, + description: 'File path to attach', + }, + ], + execution: 'local', + available: true, + scope: 'core', + }, { name: 'new', description: 'Start a new conversation', diff --git a/packages/cli/src/tui/file-ref.ts b/packages/cli/src/tui/file-ref.ts new file mode 100644 index 0000000..448004b --- /dev/null +++ b/packages/cli/src/tui/file-ref.ts @@ -0,0 +1,202 @@ +/** + * File reference expansion for TUI chat input. + * + * Detects @path/to/file patterns in user messages, reads the file contents, + * and inlines them as fenced code blocks in the message. + * + * Supports: + * - @relative/path.ts + * - @./relative/path.ts + * - @/absolute/path.ts + * - @~/home-relative/path.ts + * + * Also provides an /attach command handler. + */ + +import { readFile, stat } from 'node:fs/promises'; +import { resolve, extname, basename } from 'node:path'; +import { homedir } from 'node:os'; + +const MAX_FILE_SIZE = 256 * 1024; // 256 KB +const MAX_FILES_PER_MESSAGE = 10; + +/** + * Regex to detect @file references in user input. + * Matches @ where path starts with /, ./, ~/, or a word char, + * and continues until whitespace or end of string. + * Excludes @mentions that look like usernames (no dots/slashes). + */ +const FILE_REF_PATTERN = /(?:^|\s)@((?:\.{0,2}\/|~\/|[a-zA-Z0-9_])[^\s]+)/g; + +interface FileRefResult { + /** The expanded message text with file contents inlined */ + expandedMessage: string; + /** Files that were successfully read */ + filesAttached: string[]; + /** Errors encountered while reading files */ + errors: string[]; +} + +function resolveFilePath(ref: string): string { + if (ref.startsWith('~/')) { + return resolve(homedir(), ref.slice(2)); + } + return resolve(process.cwd(), ref); +} + +function getLanguageHint(filePath: string): string { + const ext = extname(filePath).toLowerCase(); + const map: Record = { + '.ts': 'typescript', + '.tsx': 'typescript', + '.js': 'javascript', + '.jsx': 'javascript', + '.py': 'python', + '.rb': 'ruby', + '.rs': 'rust', + '.go': 'go', + '.java': 'java', + '.c': 'c', + '.cpp': 'cpp', + '.h': 'c', + '.hpp': 'cpp', + '.cs': 'csharp', + '.sh': 'bash', + '.bash': 'bash', + '.zsh': 'zsh', + '.fish': 'fish', + '.json': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.xml': 'xml', + '.html': 'html', + '.css': 'css', + '.scss': 'scss', + '.md': 'markdown', + '.sql': 'sql', + '.graphql': 'graphql', + '.dockerfile': 'dockerfile', + '.tf': 'terraform', + '.vue': 'vue', + '.svelte': 'svelte', + }; + return map[ext] ?? ''; +} + +/** + * Check if the input contains any @file references. + */ +export function hasFileRefs(input: string): boolean { + FILE_REF_PATTERN.lastIndex = 0; + return FILE_REF_PATTERN.test(input); +} + +/** + * Expand @file references in a message by reading file contents + * and appending them as fenced code blocks. + */ +export async function expandFileRefs(input: string): Promise { + const refs: string[] = []; + FILE_REF_PATTERN.lastIndex = 0; + let match; + while ((match = FILE_REF_PATTERN.exec(input)) !== null) { + const ref = match[1]!; + if (!refs.includes(ref)) { + refs.push(ref); + } + } + + if (refs.length === 0) { + return { expandedMessage: input, filesAttached: [], errors: [] }; + } + + if (refs.length > MAX_FILES_PER_MESSAGE) { + return { + expandedMessage: input, + filesAttached: [], + errors: [`Too many file references (${refs.length}). Maximum is ${MAX_FILES_PER_MESSAGE}.`], + }; + } + + const filesAttached: string[] = []; + const errors: string[] = []; + const attachments: string[] = []; + + for (const ref of refs) { + const filePath = resolveFilePath(ref); + try { + const info = await stat(filePath); + if (!info.isFile()) { + errors.push(`@${ref}: not a file`); + continue; + } + if (info.size > MAX_FILE_SIZE) { + errors.push( + `@${ref}: file too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`, + ); + continue; + } + const content = await readFile(filePath, 'utf8'); + const lang = getLanguageHint(filePath); + const name = basename(filePath); + attachments.push(`\n📎 ${ref} (${name}):\n\`\`\`${lang}\n${content}\n\`\`\``); + filesAttached.push(ref); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Only report meaningful errors — ENOENT is common for false @mention matches + if (msg.includes('ENOENT')) { + // Check if this looks like a file path (has extension or slash) + if (ref.includes('/') || ref.includes('.')) { + errors.push(`@${ref}: file not found`); + } + // Otherwise silently skip — likely an @mention, not a file ref + } else { + errors.push(`@${ref}: ${msg}`); + } + } + } + + if (attachments.length === 0) { + return { expandedMessage: input, filesAttached, errors }; + } + + const expandedMessage = input + '\n' + attachments.join('\n'); + return { expandedMessage, filesAttached, errors }; +} + +/** + * Handle the /attach command. + * Reads a file and returns the content formatted for inclusion in the chat. + */ +export async function handleAttachCommand( + args: string, +): Promise<{ content: string; error?: string }> { + const filePath = args.trim(); + if (!filePath) { + return { content: '', error: 'Usage: /attach ' }; + } + + const resolved = resolveFilePath(filePath); + try { + const info = await stat(resolved); + if (!info.isFile()) { + return { content: '', error: `Not a file: ${filePath}` }; + } + if (info.size > MAX_FILE_SIZE) { + return { + content: '', + error: `File too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`, + }; + } + const content = await readFile(resolved, 'utf8'); + const lang = getLanguageHint(resolved); + const name = basename(resolved); + return { + content: `📎 Attached file: ${name} (${filePath})\n\`\`\`${lang}\n${content}\n\`\`\``, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: '', error: `Failed to read file: ${msg}` }; + } +} diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 313bb6c..6b12ecd 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -99,6 +99,11 @@ export interface SetThinkingPayload { level: string; } +/** Client request to abort the current agent operation */ +export interface AbortPayload { + conversationId: string; +} + /** Socket.IO typed event map: server → client */ export interface ServerToClientEvents { 'message:ack': (payload: MessageAckPayload) => void; @@ -120,4 +125,5 @@ export interface ClientToServerEvents { message: (data: ChatMessagePayload) => void; 'set:thinking': (data: SetThinkingPayload) => void; 'command:execute': (data: SlashCommandPayload) => void; + abort: (data: AbortPayload) => void; } diff --git a/packages/types/src/chat/index.ts b/packages/types/src/chat/index.ts index a8440ad..feaa002 100644 --- a/packages/types/src/chat/index.ts +++ b/packages/types/src/chat/index.ts @@ -11,6 +11,7 @@ export type { SessionInfoPayload, RoutingDecisionInfo, SetThinkingPayload, + AbortPayload, ErrorPayload, ChatMessagePayload, ServerToClientEvents, diff --git a/scratchpads/cli-tui-tools-enhancement.md b/scratchpads/cli-tui-tools-enhancement.md new file mode 100644 index 0000000..733475e --- /dev/null +++ b/scratchpads/cli-tui-tools-enhancement.md @@ -0,0 +1,66 @@ +# CLI/TUI Tools Enhancement Scratchpad + +## Objective + +Add 5 capability areas to the Mosaic CLI/TUI + gateway agent: + +1. Web search tools (multi-provider: Brave, DuckDuckGo, Tavily, SearXNG) +2. File edit tool (`fs_edit_file` with targeted text replacement) +3. MCP management TUI commands (`/mcp status`, `/mcp reconnect`, `/mcp servers`) +4. File reference in chat (`@file` syntax + `/attach` command) +5. Implement `/stop` to cancel streaming + +## Plan + +### 1. Web Search Tools (gateway agent tools) + +- Create `apps/gateway/src/agent/tools/search-tools.ts` +- Providers: Brave Search API, DuckDuckGo (HTML scraping), Tavily API, SearXNG (self-hosted) +- Each provider activated by env var (BRAVE_API_KEY, TAVILY_API_KEY, SEARXNG_URL) +- Tools: `web_search` (unified), `web_search_news` (news-specific) +- Export from `apps/gateway/src/agent/tools/index.ts` +- Wire into `agent.service.ts` buildToolsForSandbox + +### 2. File Edit Tool (gateway agent tool) + +- Add `fs_edit_file` to `apps/gateway/src/agent/tools/file-tools.ts` +- Parameters: path, edits[{oldText, newText}] — same semantics as pi's Edit tool +- Validates uniqueness of each oldText, applies all edits atomically + +### 3. MCP Management Commands (TUI + gateway) + +- Add gateway endpoints: GET /api/mcp/status, POST /api/mcp/:name/reconnect +- Add TUI gateway-api.ts functions for MCP +- Add gateway slash commands: /mcp (with subcommands status, reconnect, servers) +- Register in command manifest from gateway +- Handle in TUI via gateway command forwarding (already works) + +### 4. File Reference in Chat (@file syntax) + +- TUI-side: detect @path/to/file in input, read file contents, inline into message +- Add `/attach ` local command as alternative +- gateway-api.ts helper not needed — this is purely client-side pre-processing +- Modify InputBar or sendMessage to expand @file references before sending + +### 5. Implement /stop + +- Add `abort` event to ClientToServerEvents in @mosaic/types +- TUI sends abort event on /stop command +- Gateway chat handler aborts the Pi session prompt +- Update use-socket to support abort +- Wire /stop in app.tsx + +## Progress + +- [x] 1. Web search tools +- [x] 2. File edit tool +- [x] 3. MCP management commands +- [x] 4. File reference in chat +- [x] 5. Implement /stop + +## Risks + +- DuckDuckGo has no official API — need HTML scraping or use lite endpoint +- SearXNG needs self-hosted instance +- @file expansion could be large — need size limits +- /stop requires Pi SDK abort support — need to check API