From 74db072d633c55ed5180dc98a67cd39a339b1b85 Mon Sep 17 00:00:00 2001 From: Jair Date: Sun, 31 May 2026 21:54:41 +0800 Subject: [PATCH 1/2] Add notification sound toggle and per-log sound cues --- bridge-browser/index.html | 4 + .../src/background/attention_sound.ts | 7 +- bridge-browser/src/background/messages.ts | 52 ++++++++++++- bridge-browser/src/content/main.ts | 4 + bridge-browser/src/modules/logger.ts | 22 ++++++ bridge-browser/src/offscreen/audio.ts | 78 +++++++++++++++---- bridge-browser/src/popup/index.ts | 33 +++++++- bridge-browser/src/types.ts | 3 + 8 files changed, 185 insertions(+), 18 deletions(-) diff --git a/bridge-browser/index.html b/bridge-browser/index.html index 5c44055..7963d18 100644 --- a/bridge-browser/index.html +++ b/bridge-browser/index.html @@ -155,6 +155,10 @@

Show Floating Log + diff --git a/bridge-browser/src/background/attention_sound.ts b/bridge-browser/src/background/attention_sound.ts index 856e8d9..075c682 100644 --- a/bridge-browser/src/background/attention_sound.ts +++ b/bridge-browser/src/background/attention_sound.ts @@ -3,13 +3,18 @@ import { getErrorMessage } from './errors'; const OFFSCREEN_DOCUMENT_PATH = "offscreen.html"; const PLAY_ATTENTION_SOUND = "PLAY_ATTENTION_SOUND"; +export type LogSoundType = "info" | "success" | "warn" | "error" | "action"; + let creatingOffscreenDocument: Promise | null = null; -export async function playAttentionSound(): Promise<{ success: boolean; error?: string }> { +export async function playAttentionSound( + logType?: LogSoundType +): Promise<{ success: boolean; error?: string }> { try { await ensureOffscreenDocument(); const response: unknown = await chrome.runtime.sendMessage({ type: PLAY_ATTENTION_SOUND, + logType, }); if (isRecord(response) && response.success === false) { diff --git a/bridge-browser/src/background/messages.ts b/bridge-browser/src/background/messages.ts index ee4e32b..3b3781c 100644 --- a/bridge-browser/src/background/messages.ts +++ b/bridge-browser/src/background/messages.ts @@ -1,5 +1,5 @@ import { isMessageRequest, type MessageRequest } from '../types'; -import { playAttentionSound } from './attention_sound'; +import { playAttentionSound, type LogSoundType } from './attention_sound'; import { bindSession, handleHandshake } from './connection'; import { getErrorMessage } from './errors'; import { executeTool } from './gateway'; @@ -37,6 +37,12 @@ function dispatchRuntimeMessage( case "SET_LOG_VISIBLE": handleSetLogVisible(request, currentTabId, sendResponse); return true; + case "SET_LOG_SOUND_ENABLED": + handleSetLogSoundEnabled(request, currentTabId, sendResponse); + return true; + case "PLAY_LOG_SOUND": + respondAsync(playLogSound(request), sendResponse); + return true; case "REQUEST_USER_ATTENTION": respondAsync(requestUserAttention(request, sender), sendResponse); return true; @@ -72,10 +78,14 @@ function handleGetStatus( } respondAsync( - getSession(targetTabId).then((session) => ({ + Promise.all([ + getSession(targetTabId), + chrome.storage.sync.get(["logSoundEnabled"]) as Promise>, + ]).then(([session, syncItems]) => ({ connected: Boolean(session), port: session?.port, showLog: session?.showLog ?? false, + soundEnabled: syncItems.logSoundEnabled === true, workspaceId: session?.workspaceId ?? 'global', })), sendResponse @@ -106,6 +116,44 @@ function handleSetLogVisible( ); } +function handleSetLogSoundEnabled( + request: MessageRequest, + currentTabId: number | null | undefined, + sendResponse: SendResponse +): void { + const targetTabId = request.tabId ?? currentTabId; + const soundEnabled = request.soundEnabled === true; + + respondAsync( + chrome.storage.sync.set({ logSoundEnabled: soundEnabled }).then(() => { + if (targetTabId) { + void chrome.tabs.sendMessage(targetTabId, { + type: "SET_LOG_SOUND_ENABLED", + soundEnabled, + }).catch(ignoreRuntimeError); + } + return { success: true }; + }), + sendResponse + ); +} + +async function playLogSound(request: MessageRequest): Promise<{ success: boolean; error?: string }> { + if (!isLogSoundType(request.logType)) { + return { success: false, error: "Unsupported log sound type." }; + } + + return playAttentionSound(request.logType); +} + +function isLogSoundType(value: unknown): value is LogSoundType { + return value === "info" || + value === "success" || + value === "warn" || + value === "error" || + value === "action"; +} + function handleConnectExisting( request: MessageRequest, currentTabId: number | null | undefined, diff --git a/bridge-browser/src/content/main.ts b/bridge-browser/src/content/main.ts index be4b11b..f634143 100644 --- a/bridge-browser/src/content/main.ts +++ b/bridge-browser/src/content/main.ts @@ -80,6 +80,9 @@ chrome.runtime.onMessage.addListener((request: unknown, _sender, sendResponse): Logger.toggle(show); Logger.log("Logger Visible: " + show, "info"); } + if (request.type === "SET_LOG_SOUND_ENABLED") { + Logger.setSoundEnabled(request.soundEnabled === true); + } if (request.type === "STATUS_UPDATE") { const wasConnected = isClientConnected; isClientConnected = request.connected === true; @@ -443,6 +446,7 @@ function startObserver() { // 2. Check initial status chrome.runtime.sendMessage({ type: "GET_STATUS" }, async (response: unknown) => { if (isStatusResponse(response) && response.connected) { + Logger.setSoundEnabled(response.soundEnabled === true); isClientConnected = true; if (typeof response.workspaceId === "string") { currentWorkspaceId = response.workspaceId; diff --git a/bridge-browser/src/modules/logger.ts b/bridge-browser/src/modules/logger.ts index 808e7a2..7b7c385 100644 --- a/bridge-browser/src/modules/logger.ts +++ b/bridge-browser/src/modules/logger.ts @@ -21,6 +21,7 @@ export const Logger = { filterButtons: new Map(), activeTypes: new Set(["summary"]), isMinimized: false, + soundEnabled: false, init() { if (this.el) {return;} @@ -291,7 +292,24 @@ export const Logger = { } }, + setSoundEnabled(enabled: boolean) { + this.soundEnabled = enabled; + }, + + playLogSound(type: LoggerLogType) { + if (!this.soundEnabled || !isStandardLogType(type)) {return;} + + try { + chrome.runtime.sendMessage({ type: "PLAY_LOG_SOUND", logType: type }, () => { + void chrome.runtime.lastError; + }); + } catch { + // Logger may be reused outside an extension context during local testing. + } + }, + log(msg: string, type: LoggerLogType = "info") { + this.playLogSound(type); if (!this.el || this.el.style.display === "none") {return;} const line = document.createElement("div"); line.className = "line"; @@ -319,3 +337,7 @@ export const Logger = { } }, }; + +function isStandardLogType(type: LoggerLogType): type is typeof STANDARD_LOG_TYPES[number] { + return STANDARD_LOG_TYPES.includes(type as typeof STANDARD_LOG_TYPES[number]); +} diff --git a/bridge-browser/src/offscreen/audio.ts b/bridge-browser/src/offscreen/audio.ts index bf6c635..8faa818 100644 --- a/bridge-browser/src/offscreen/audio.ts +++ b/bridge-browser/src/offscreen/audio.ts @@ -1,5 +1,38 @@ const PLAY_ATTENTION_SOUND = "PLAY_ATTENTION_SOUND"; +type LogSoundType = "info" | "success" | "warn" | "error" | "action"; + +type ToneStep = { + frequency: number; + duration: number; + gap?: number; + type?: OscillatorType; +}; + +const SOUND_PATTERNS: Record = { + info: [ + { frequency: 660, duration: 0.12 }, + { frequency: 880, duration: 0.16 }, + ], + success: [ + { frequency: 784, duration: 0.12 }, + { frequency: 1046.5, duration: 0.22 }, + ], + warn: [ + { frequency: 523.25, duration: 0.14, type: "triangle" }, + { frequency: 392, duration: 0.24, type: "triangle" }, + ], + error: [ + { frequency: 220, duration: 0.16, type: "square" }, + { frequency: 196, duration: 0.26, type: "square" }, + ], + action: [ + { frequency: 880, duration: 0.08 }, + { frequency: 987.77, duration: 0.08 }, + { frequency: 1174.66, duration: 0.16 }, + ], +}; + let audioContext: AudioContext | null = null; chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { @@ -7,7 +40,8 @@ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { return false; } - void playAttentionSound() + const logType = getLogSoundType(request.logType); + void playAttentionSound(logType) .then(() => sendResponse({ success: true })) .catch((error) => { sendResponse({ @@ -19,25 +53,30 @@ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { return true; }); -async function playAttentionSound(): Promise { +async function playAttentionSound(logType: LogSoundType): Promise { const context = getAudioContext(); if (context.state === "suspended") { await context.resume(); } + const pattern = SOUND_PATTERNS[logType]; const start = context.currentTime + 0.01; const output = context.createGain(); + const totalDuration = pattern.reduce((sum, tone) => sum + tone.duration + (tone.gap ?? 0.03), 0); output.gain.setValueAtTime(0.0001, start); - output.gain.linearRampToValueAtTime(0.12, start + 0.02); - output.gain.exponentialRampToValueAtTime(0.0001, start + 0.48); + output.gain.linearRampToValueAtTime(0.1, start + 0.02); + output.gain.exponentialRampToValueAtTime(0.0001, start + totalDuration + 0.06); output.connect(context.destination); - playTone(context, output, 880, start, 0.16); - playTone(context, output, 1174.66, start + 0.16, 0.24); + let cursor = start; + for (const tone of pattern) { + playTone(context, output, tone, cursor); + cursor += tone.duration + (tone.gap ?? 0.03); + } window.setTimeout(() => { output.disconnect(); - }, 650); + }, Math.ceil((totalDuration + 0.2) * 1000)); } function getAudioContext(): AudioContext { @@ -61,24 +100,23 @@ function getWebkitAudioContext(): typeof AudioContext | undefined { function playTone( context: AudioContext, output: AudioNode, - frequency: number, - start: number, - duration: number + tone: ToneStep, + start: number ): void { const oscillator = context.createOscillator(); const gain = context.createGain(); - oscillator.type = "sine"; - oscillator.frequency.setValueAtTime(frequency, start); + oscillator.type = tone.type ?? "sine"; + oscillator.frequency.setValueAtTime(tone.frequency, start); gain.gain.setValueAtTime(0.0001, start); gain.gain.linearRampToValueAtTime(1, start + 0.01); - gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); + gain.gain.exponentialRampToValueAtTime(0.0001, start + tone.duration); oscillator.connect(gain); gain.connect(output); oscillator.start(start); - oscillator.stop(start + duration + 0.03); + oscillator.stop(start + tone.duration + 0.03); oscillator.addEventListener( "ended", () => { @@ -89,6 +127,18 @@ function playTone( ); } +function getLogSoundType(value: unknown): LogSoundType { + return isLogSoundType(value) ? value : "action"; +} + +function isLogSoundType(value: unknown): value is LogSoundType { + return value === "info" || + value === "success" || + value === "warn" || + value === "error" || + value === "action"; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/bridge-browser/src/popup/index.ts b/bridge-browser/src/popup/index.ts index 28e2246..8ad4ca0 100644 --- a/bridge-browser/src/popup/index.ts +++ b/bridge-browser/src/popup/index.ts @@ -9,6 +9,7 @@ type PopupElements = { manualInitBtn: HTMLButtonElement; autoSendInput: HTMLInputElement; showLogInput: HTMLInputElement; + soundInput: HTMLInputElement; availableView: HTMLElement; gatewayList: HTMLElement; title: HTMLElement; @@ -16,6 +17,7 @@ type PopupElements = { portLabel: HTMLElement; autoSendLabel: HTMLElement; showLogLabel: HTMLElement; + soundLabel: HTMLElement; availableGateways: HTMLElement; disconnectedTitle: HTMLElement; installedTitle: HTMLElement; @@ -53,6 +55,7 @@ const UI: Record> = { manual_init_unavailable: "Cannot initialize here", auto_send: "Auto Send Message", show_log: "Show Floating Log", + notification_sound: "Notification Sound", available_gateways: "⚡ Available Gateways", disconnected: "🔴 Disconnected", installed_title: "👉 Already Installed?", @@ -74,6 +77,7 @@ const UI: Record> = { manual_init_unavailable: "当前页面无法初始化", auto_send: "自动发送消息", show_log: "显示悬浮日志", + notification_sound: "提示音", available_gateways: "⚡ 可用网关", disconnected: "🔴 未连接", installed_title: "👉 已安装?", @@ -123,6 +127,7 @@ function getPopupElements(): PopupElements { manualInitBtn: document.getElementById("manualInitBtn") as HTMLButtonElement, autoSendInput: document.getElementById("autoSend") as HTMLInputElement, showLogInput: document.getElementById("showLog") as HTMLInputElement, + soundInput: document.getElementById("logSound") as HTMLInputElement, availableView: document.getElementById("availableView") as HTMLElement, gatewayList: document.getElementById("gatewayList") as HTMLElement, title: document.getElementById("title") as HTMLElement, @@ -130,6 +135,7 @@ function getPopupElements(): PopupElements { portLabel: document.getElementById("portLabel") as HTMLElement, autoSendLabel: document.getElementById("autoSendLabel") as HTMLElement, showLogLabel: document.getElementById("showLogLabel") as HTMLElement, + soundLabel: document.getElementById("soundLabel") as HTMLElement, availableGateways: document.getElementById("availableGateways") as HTMLElement, disconnectedTitle: document.getElementById("disconnectedTitle") as HTMLElement, installedTitle: document.getElementById("installedTitle") as HTMLElement, @@ -148,6 +154,7 @@ function initializeLabels(context: PopupContext): void { elements.manualInitBtn.title = t("manual_init_title"); elements.autoSendLabel.textContent = t("auto_send"); elements.showLogLabel.textContent = t("show_log"); + elements.soundLabel.textContent = t("notification_sound"); elements.availableGateways.innerHTML = ` ${t("available_gateways").replace(/^⚡\s*/, "")}`; elements.disconnectedTitle.textContent = t("disconnected"); elements.installedTitle.textContent = t("installed_title"); @@ -184,12 +191,16 @@ function requestCurrentStatus(currentTab: chrome.tabs.Tab, currentTabId: number, ); } -function showConnectedStatus(response: { port?: number; showLog?: boolean }, elements: PopupElements): void { +function showConnectedStatus( + response: { port?: number; showLog?: boolean; soundEnabled?: boolean }, + elements: PopupElements +): void { elements.connectedView.classList.remove("hidden"); elements.disconnectedView.classList.add("hidden"); elements.statusDot.classList.add("online"); elements.portDisplay.innerText = String(response.port ?? ""); elements.showLogInput.checked = response.showLog === true; + elements.soundInput.checked = response.soundEnabled === true; } function showDisconnectedStatus(currentUrl: string, currentTabId: number, context: PopupContext): void { @@ -295,6 +306,7 @@ function bindPopupControls(currentTabId: number, context: PopupContext): void { bindManualInitButton(currentTabId, context); bindAutoSendToggle(context.elements.autoSendInput); bindLogToggle(currentTabId, context.elements.showLogInput); + bindSoundToggle(currentTabId, context.elements.soundInput); } function bindManualInitButton(currentTabId: number, context: PopupContext): void { @@ -389,3 +401,22 @@ function bindLogToggle(currentTabId: number, showLogInput: HTMLInputElement): vo }); }); } + +function bindSoundToggle(currentTabId: number, soundInput: HTMLInputElement): void { + chrome.storage.sync.get(["logSoundEnabled"], (items: Record) => { + if (typeof items.logSoundEnabled === "boolean") { + soundInput.checked = items.logSoundEnabled; + } + }); + + soundInput.addEventListener("change", () => { + const soundEnabled = soundInput.checked; + void chrome.storage.sync.set({ logSoundEnabled: soundEnabled }); + void chrome.tabs.sendMessage(currentTabId, { + type: "SET_LOG_SOUND_ENABLED", + soundEnabled, + }).catch(() => { + void chrome.runtime.lastError; + }); + }); +} diff --git a/bridge-browser/src/types.ts b/bridge-browser/src/types.ts index 788e8be..5b09ad0 100644 --- a/bridge-browser/src/types.ts +++ b/bridge-browser/src/types.ts @@ -19,6 +19,8 @@ export interface MessageRequest { message?: string; onlyWhenWindowInBackground?: boolean; playSound?: boolean; + soundEnabled?: boolean; + logType?: string; connected?: boolean; payload?: ToolExecutionPayload; } @@ -34,6 +36,7 @@ export interface StatusResponse { error?: string; port?: number; showLog?: boolean; + soundEnabled?: boolean; workspaceId?: string; } From f76cc4dd6bd7f5e16008ab48fe4a76369149786e Mon Sep 17 00:00:00 2001 From: Jair Date: Sun, 31 May 2026 21:54:41 +0800 Subject: [PATCH 2/2] Add notification sound toggle and per-log sound cues --- bridge-browser/bridge_marker.js | 4 + bridge-browser/index.html | 4 + bridge-browser/manifest.json | 4 +- bridge-browser/package.json | 2 +- .../src/background/attention_sound.ts | 7 +- bridge-browser/src/background/messages.ts | 52 +- bridge-browser/src/content/init_context.ts | 4 +- bridge-browser/src/content/main.ts | 47 +- .../src/content/tool_call_tracker.ts | 124 +++-- bridge-browser/src/content/tool_executor.ts | 187 ++++--- .../src/content/tool_request_registry.ts | 263 ++++++--- bridge-browser/src/modules/logger.ts | 22 + bridge-browser/src/offscreen/audio.ts | 78 ++- bridge-browser/src/popup/index.ts | 33 +- bridge-browser/src/types.ts | 3 + changelogs/en/v0.9.0.md | 23 + changelogs/en/v0.9.1.md | 6 + changelogs/zh/v0.9.0.md | 23 + changelogs/zh/v0.9.1.md | 6 + doc/BUILTIN_TOOLS.md | 8 +- doc/BUILTIN_TOOLS_en.md | 4 +- doc/MCP_GUIDE.md | 2 +- doc/MCP_GUIDE_en.md | 2 +- doc/READ_FILE_GUIDE.md | 185 +++++++ doc/SEARCH_TOOLS_GUIDE.md | 508 ++++++++++++++++++ gateway-vscode/package.json | 4 +- gateway-vscode/prompts/error_hint_en.md | 4 +- gateway-vscode/prompts/error_hint_zh.md | 4 +- gateway-vscode/prompts/init_en.md | 2 +- gateway-vscode/prompts/init_zh.md | 2 +- gateway-vscode/prompts/prompt_en.md | 88 ++- gateway-vscode/prompts/prompt_zh.md | 88 ++- gateway-vscode/prompts/train_en.md | 2 +- gateway-vscode/prompts/train_zh.md | 2 +- gateway-vscode/src/gateway/bridgeRoute.ts | 74 ++- gateway-vscode/src/platforms.ts | 21 +- gateway-vscode/src/schemaValidation.ts | 2 +- gateway-vscode/src/tools/editFileTool.ts | 18 +- gateway-vscode/src/tools/filesystemUtils.ts | 89 ++- gateway-vscode/src/tools/globUtils.ts | 69 +++ .../src/tools/readFileLineStream.ts | 4 +- .../src/tools/readFileOutputLimit.ts | 150 ++++++ gateway-vscode/src/tools/readFilePrefix.ts | 116 ++++ gateway-vscode/src/tools/readFileTool.ts | 449 ++++++++-------- gateway-vscode/src/tools/ripgrep.ts | 86 +++ .../src/tools/searchCodeFallback.ts | 146 +++++ .../src/tools/searchCodeGitFiles.ts | 87 +++ .../src/tools/searchCodeRipgrepOutput.ts | 75 +++ .../src/tools/searchCodeRipgrepPaths.ts | 177 ++++++ gateway-vscode/src/tools/searchCodeTool.ts | 346 +++--------- gateway-vscode/src/tools/searchCodeTypes.ts | 11 + gateway-vscode/src/tools/searchCodeUtils.ts | 176 ++++++ .../src/tools/searchFilesPatterns.ts | 38 -- .../src/tools/searchFilesRipgrepArgs.ts | 18 + gateway-vscode/src/tools/searchFilesTool.ts | 257 +++++++-- .../src/unit-test/editFileTool.test.ts | 92 ++++ .../src/unit-test/readFileTool.test.ts | 177 ++++-- .../src/unit-test/searchCodeTool.test.ts | 173 ++++++ .../src/unit-test/searchFilesTool.test.ts | 74 ++- 59 files changed, 3795 insertions(+), 927 deletions(-) create mode 100644 bridge-browser/bridge_marker.js create mode 100644 changelogs/en/v0.9.0.md create mode 100644 changelogs/en/v0.9.1.md create mode 100644 changelogs/zh/v0.9.0.md create mode 100644 changelogs/zh/v0.9.1.md create mode 100644 doc/READ_FILE_GUIDE.md create mode 100644 doc/SEARCH_TOOLS_GUIDE.md create mode 100644 gateway-vscode/src/tools/globUtils.ts create mode 100644 gateway-vscode/src/tools/readFileOutputLimit.ts create mode 100644 gateway-vscode/src/tools/readFilePrefix.ts create mode 100644 gateway-vscode/src/tools/ripgrep.ts create mode 100644 gateway-vscode/src/tools/searchCodeFallback.ts create mode 100644 gateway-vscode/src/tools/searchCodeGitFiles.ts create mode 100644 gateway-vscode/src/tools/searchCodeRipgrepOutput.ts create mode 100644 gateway-vscode/src/tools/searchCodeRipgrepPaths.ts create mode 100644 gateway-vscode/src/tools/searchCodeTypes.ts create mode 100644 gateway-vscode/src/tools/searchCodeUtils.ts delete mode 100644 gateway-vscode/src/tools/searchFilesPatterns.ts create mode 100644 gateway-vscode/src/tools/searchFilesRipgrepArgs.ts create mode 100644 gateway-vscode/src/unit-test/editFileTool.test.ts create mode 100644 gateway-vscode/src/unit-test/searchCodeTool.test.ts diff --git a/bridge-browser/bridge_marker.js b/bridge-browser/bridge_marker.js new file mode 100644 index 0000000..0b16faf --- /dev/null +++ b/bridge-browser/bridge_marker.js @@ -0,0 +1,4 @@ +(() => { + document.documentElement.setAttribute('data-extension-installed', 'true'); + window.dispatchEvent(new Event('webcode-bridge-extension-installed')); +})(); diff --git a/bridge-browser/index.html b/bridge-browser/index.html index 5c44055..7963d18 100644 --- a/bridge-browser/index.html +++ b/bridge-browser/index.html @@ -155,6 +155,10 @@

Show Floating Log + diff --git a/bridge-browser/manifest.json b/bridge-browser/manifest.json index 13a29fa..e4bb15a 100644 --- a/bridge-browser/manifest.json +++ b/bridge-browser/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "__MSG_extensionName__", - "version": "0.8.4", + "version": "0.9.1", "description": "__MSG_extensionDescription__", "default_locale": "en", "icons": { @@ -68,7 +68,7 @@ "http://localhost:34575/*", "http://localhost:34576/*" ], - "js": ["src/content/bridge_loader.ts"], + "js": ["bridge_marker.js", "src/content/bridge_loader.ts"], "run_at": "document_start" }, { diff --git a/bridge-browser/package.json b/bridge-browser/package.json index c4d5c93..7f918d0 100644 --- a/bridge-browser/package.json +++ b/bridge-browser/package.json @@ -1,7 +1,7 @@ { "name": "bridge-browser", "displayName": "webcode bridge", - "version": "0.8.4", + "version": "0.9.1", "type": "module", "scripts": { "dev": "vite", diff --git a/bridge-browser/src/background/attention_sound.ts b/bridge-browser/src/background/attention_sound.ts index 856e8d9..075c682 100644 --- a/bridge-browser/src/background/attention_sound.ts +++ b/bridge-browser/src/background/attention_sound.ts @@ -3,13 +3,18 @@ import { getErrorMessage } from './errors'; const OFFSCREEN_DOCUMENT_PATH = "offscreen.html"; const PLAY_ATTENTION_SOUND = "PLAY_ATTENTION_SOUND"; +export type LogSoundType = "info" | "success" | "warn" | "error" | "action"; + let creatingOffscreenDocument: Promise | null = null; -export async function playAttentionSound(): Promise<{ success: boolean; error?: string }> { +export async function playAttentionSound( + logType?: LogSoundType +): Promise<{ success: boolean; error?: string }> { try { await ensureOffscreenDocument(); const response: unknown = await chrome.runtime.sendMessage({ type: PLAY_ATTENTION_SOUND, + logType, }); if (isRecord(response) && response.success === false) { diff --git a/bridge-browser/src/background/messages.ts b/bridge-browser/src/background/messages.ts index ee4e32b..3b3781c 100644 --- a/bridge-browser/src/background/messages.ts +++ b/bridge-browser/src/background/messages.ts @@ -1,5 +1,5 @@ import { isMessageRequest, type MessageRequest } from '../types'; -import { playAttentionSound } from './attention_sound'; +import { playAttentionSound, type LogSoundType } from './attention_sound'; import { bindSession, handleHandshake } from './connection'; import { getErrorMessage } from './errors'; import { executeTool } from './gateway'; @@ -37,6 +37,12 @@ function dispatchRuntimeMessage( case "SET_LOG_VISIBLE": handleSetLogVisible(request, currentTabId, sendResponse); return true; + case "SET_LOG_SOUND_ENABLED": + handleSetLogSoundEnabled(request, currentTabId, sendResponse); + return true; + case "PLAY_LOG_SOUND": + respondAsync(playLogSound(request), sendResponse); + return true; case "REQUEST_USER_ATTENTION": respondAsync(requestUserAttention(request, sender), sendResponse); return true; @@ -72,10 +78,14 @@ function handleGetStatus( } respondAsync( - getSession(targetTabId).then((session) => ({ + Promise.all([ + getSession(targetTabId), + chrome.storage.sync.get(["logSoundEnabled"]) as Promise>, + ]).then(([session, syncItems]) => ({ connected: Boolean(session), port: session?.port, showLog: session?.showLog ?? false, + soundEnabled: syncItems.logSoundEnabled === true, workspaceId: session?.workspaceId ?? 'global', })), sendResponse @@ -106,6 +116,44 @@ function handleSetLogVisible( ); } +function handleSetLogSoundEnabled( + request: MessageRequest, + currentTabId: number | null | undefined, + sendResponse: SendResponse +): void { + const targetTabId = request.tabId ?? currentTabId; + const soundEnabled = request.soundEnabled === true; + + respondAsync( + chrome.storage.sync.set({ logSoundEnabled: soundEnabled }).then(() => { + if (targetTabId) { + void chrome.tabs.sendMessage(targetTabId, { + type: "SET_LOG_SOUND_ENABLED", + soundEnabled, + }).catch(ignoreRuntimeError); + } + return { success: true }; + }), + sendResponse + ); +} + +async function playLogSound(request: MessageRequest): Promise<{ success: boolean; error?: string }> { + if (!isLogSoundType(request.logType)) { + return { success: false, error: "Unsupported log sound type." }; + } + + return playAttentionSound(request.logType); +} + +function isLogSoundType(value: unknown): value is LogSoundType { + return value === "info" || + value === "success" || + value === "warn" || + value === "error" || + value === "action"; +} + function handleConnectExisting( request: MessageRequest, currentTabId: number | null | undefined, diff --git a/bridge-browser/src/content/init_context.ts b/bridge-browser/src/content/init_context.ts index 88e00fb..846c0bb 100644 --- a/bridge-browser/src/content/init_context.ts +++ b/bridge-browser/src/content/init_context.ts @@ -35,8 +35,8 @@ export async function buildWebcodeInitPrompt(options: WebcodeInitPromptOptions = executeInitToolCall("list_skills"), ]); - finalPrompt += `\n\n# Available Tools\n\`\`\`json\n${escapeInlineNewlines(toolsResult)}\n\`\`\``; - finalPrompt += `\n\n# Available Skills\n\`\`\`json\n${escapeInlineNewlines(skillsResult)}\n\`\`\``; + finalPrompt += `\n\n# ${BRANDING.productName} Available Tools\n\`\`\`json\n${escapeInlineNewlines(toolsResult)}\n\`\`\``; + finalPrompt += `\n\n# ${BRANDING.productName} Available Skills\n\`\`\`json\n${escapeInlineNewlines(skillsResult)}\n\`\`\``; } catch (error) { Logger.log(`Initialization data fetch failed: ${getErrorMessage(error)}`, "error"); finalPrompt += `\n\n# Initialization Note\nFailed to fetch the tool or skill list. Re-run \`${PROTOCOL.initToolName}\` if needed.`; diff --git a/bridge-browser/src/content/main.ts b/bridge-browser/src/content/main.ts index be4b11b..99a3354 100644 --- a/bridge-browser/src/content/main.ts +++ b/bridge-browser/src/content/main.ts @@ -80,6 +80,9 @@ chrome.runtime.onMessage.addListener((request: unknown, _sender, sendResponse): Logger.toggle(show); Logger.log("Logger Visible: " + show, "info"); } + if (request.type === "SET_LOG_SOUND_ENABLED") { + Logger.setSoundEnabled(request.soundEnabled === true); + } if (request.type === "STATUS_UPDATE") { const wasConnected = isClientConnected; isClientConnected = request.connected === true; @@ -186,7 +189,7 @@ chrome.storage.onChanged.addListener((changes, namespace) => { // === 主循环逻辑 === -// 统一管理 request_id 的生命周期:已发现、执行中、结果缓存、已回填,以及当前轮次去重。 +// 统一管理工具调用内部 requestKey 的生命周期:已发现、执行中、结果缓存、已回填,以及当前轮次去重。 const requestRegistry = new ToolRequestRegistry(); let lastProgressLogTime = 0; let lastProgressStatus = ""; @@ -233,12 +236,12 @@ function scheduleMainLoop(delayMs: number): void { * 扫描当前页面最新一轮 AI 回复中的工具调用,并在本轮工具都结束后把结果回填给输入框。 * * 这个循环可能由页面 DOM 变化、工具执行完成、协议错误稳定性检查等多个入口触发。它只处理 - * 最新的一条 AI 消息,避免历史消息反复触发工具;每个工具调用再用 request_id 做去重、执行 + * 最新的一条 AI 消息,避免历史消息反复触发工具;每个工具调用再用内部 requestKey 做去重、执行 * 状态跟踪和结果回填标记。 * * 整体流程: * 1. 找到最新 AI 消息里的候选代码块。 - * 2. 解析工具调用协议,给缺失 request_id 的调用生成稳定 ID。 + * 2. 解析工具调用协议,给调用生成稳定的模型 request_id 和内部 requestKey。 * 3. 新调用进入执行路径,已知调用只刷新视觉状态。 * 4. 当前轮次所有工具都有结果后,按页面顺序合并结果并写回输入框。 * 5. 如果 AI 还在输出、工具还没完成、或 JSON 还没稳定,则安排下一次检查。 @@ -255,10 +258,10 @@ function runMainLoop() { const { messageIndex, codeElements } = latestCodeBlocks; - // 当前轮次对象只记录本次扫描看到的 request_id;去重、排序和已回填过滤由 registry 统一处理。 + // 当前轮次对象只记录本次扫描看到的 requestKey;去重、排序和已回填过滤由 registry 统一处理。 const currentTurn = requestRegistry.createTurn(); - codeElements.forEach((codeEl) => { + codeElements.forEach((codeEl, codeBlockIndex) => { const textContent = (codeEl.textContent ?? "").trim(); if (!looksLikeToolCall(textContent)) { return; } @@ -271,17 +274,22 @@ function runMainLoop() { UI.clearVisualState(codeEl as HTMLElement); } - // request_id 是跨扫描周期识别同一个工具调用的关键。缺失时会基于调用内容生成稳定 ID。 - const requestId = toolCallTracker.ensurePayloadRequestId(payload, codeEl as HTMLElement, messageIndex); - toolCallTracker.clearProtocolErrorFeedbackState(requestId); - currentTurn.add(requestId); + // requestKey 是跨扫描周期识别同一个工具调用的内部键。模型 request_id 只保留给 result 协议。 + const requestIdentity = toolCallTracker.ensurePayloadRequestIdentity( + payload, + codeEl as HTMLElement, + messageIndex, + codeBlockIndex + ); + toolCallTracker.clearProtocolErrorFeedbackState(requestIdentity.requestKey); + currentTurn.add(requestIdentity.requestKey); - const isProcessing = requestRegistry.isRunning(requestId); - const isKnown = requestRegistry.hasSeen(requestId); + const isProcessing = requestRegistry.isRunning(requestIdentity.requestKey); + const isKnown = requestRegistry.hasSeen(requestIdentity.requestKey); if (!isKnown) { // 新发现的工具调用只进入执行路径一次,后续扫描只会根据 registry 中的执行状态刷新视觉状态。 - requestRegistry.markRunning(requestId); + requestRegistry.markRunning(requestIdentity.requestKey); // 页面上出现新工具调用时,先取消已有自动发送,避免还没写回工具结果就把输入框发出去。 UI.cancelAutoSend(); @@ -291,7 +299,7 @@ function runMainLoop() { Logger.log(`${t("captured")}: ${payload.name}`, "info"); logToolSummary(payload); - toolExecutor.execute(payload); + toolExecutor.execute(payload, requestIdentity); } else { // 已知调用不重复执行,只根据 registry 判断它还在处理中还是已经完成。 if (isProcessing) { @@ -302,12 +310,18 @@ function runMainLoop() { } } catch (error) { // 流式输出中 JSON 可能暂时不完整。tracker 会先等待文本稳定,确认失败后才回填协议错误。 - const requestId = toolCallTracker.handleProtocolErrorBlock(codeEl as HTMLElement, textContent, messageIndex, error); - currentTurn.add(requestId); + const requestIdentity = toolCallTracker.handleProtocolErrorBlock( + codeEl as HTMLElement, + textContent, + messageIndex, + codeBlockIndex, + error + ); + currentTurn.add(requestIdentity?.requestKey ?? null); } }); - // 只处理当前轮次里还没有写回过的请求。已 flush 的 request_id 不会再次写入输入框。 + // 只处理当前轮次里还没有写回过的请求。已 flush 的 requestKey 不会再次写入输入框。 const unflushedBatch = currentTurn.getUnflushedBatch(); if (unflushedBatch.hasRequests) { @@ -443,6 +457,7 @@ function startObserver() { // 2. Check initial status chrome.runtime.sendMessage({ type: "GET_STATUS" }, async (response: unknown) => { if (isStatusResponse(response) && response.connected) { + Logger.setSoundEnabled(response.soundEnabled === true); isClientConnected = true; if (typeof response.workspaceId === "string") { currentWorkspaceId = response.workspaceId; diff --git a/bridge-browser/src/content/tool_call_tracker.ts b/bridge-browser/src/content/tool_call_tracker.ts index 1db4420..d779928 100644 --- a/bridge-browser/src/content/tool_call_tracker.ts +++ b/bridge-browser/src/content/tool_call_tracker.ts @@ -5,7 +5,7 @@ import * as UI from "../modules/ui"; import { ToolCallProtocolError, type ParsedToolCallPayload } from "../modules/toolCallProtocol"; import { showUserAttentionNotification } from "../modules/user_attention"; import type { ToolExecutionPayload } from "../types"; -import { type ToolRequestRegistry } from "./tool_request_registry"; +import { type ToolRequestIdentity, type ToolRequestRegistry } from "./tool_request_registry"; interface BlockState { text: string; @@ -26,46 +26,60 @@ export class ToolCallTracker { public constructor(private readonly options: ToolCallTrackerOptions) {} - public ensurePayloadRequestId( + public ensurePayloadRequestIdentity( payload: ParsedToolCallPayload, codeEl: HTMLElement, - messageIndex: number - ): string { + messageIndex: number, + codeBlockIndex: number + ): ToolRequestIdentity { const explicitRequestId = normalizeRequestId(payload.request_id); if (explicitRequestId) { codeEl.dataset.mcpRequestId = explicitRequestId; delete codeEl.dataset.mcpCallSignature; + delete codeEl.dataset.mcpCallScope; payload.request_id = explicitRequestId; - return explicitRequestId; + return { + requestId: explicitRequestId, + requestKey: ensureElementRequestKey(codeEl, explicitRequestId, messageIndex, codeBlockIndex), + }; } const signature = buildToolCallSignature(payload); + const scope = getRequestScope(messageIndex, codeBlockIndex); const cachedRequestId = codeEl.dataset.mcpRequestId; const cachedSignature = codeEl.dataset.mcpCallSignature; - const syntheticRequestId = cachedRequestId && cachedSignature === signature + const cachedScope = codeEl.dataset.mcpCallScope; + const syntheticRequestId = cachedRequestId?.startsWith("req_auto_") && + cachedSignature === signature && + cachedScope === scope ? cachedRequestId - : `req_auto_${messageIndex}_${hashStableString(signature)}`; + : `req_auto_${messageIndex}_${codeBlockIndex}_${hashStableString(signature)}`; codeEl.dataset.mcpRequestId = syntheticRequestId; codeEl.dataset.mcpCallSignature = signature; + codeEl.dataset.mcpCallScope = scope; payload.request_id = syntheticRequestId; - return syntheticRequestId; + return { + requestId: syntheticRequestId, + requestKey: ensureElementRequestKey(codeEl, syntheticRequestId, messageIndex, codeBlockIndex), + }; } - public clearProtocolErrorFeedbackState(requestId: string): void { - if (!this.protocolErrorFeedbackRequests.delete(requestId)) {return;} - this.options.requestRegistry.clearProtocolFeedbackResult(requestId); + public clearProtocolErrorFeedbackState(requestKey: string): void { + if (!this.protocolErrorFeedbackRequests.delete(requestKey)) {return;} + this.options.requestRegistry.clearProtocolFeedbackResult(requestKey); } public handleProtocolErrorBlock( codeEl: HTMLElement, textContent: string, messageIndex: number, + codeBlockIndex: number, error: unknown - ): string | null { + ): ToolRequestIdentity | null { const now = Date.now(); const state = this.blockStates.get(codeEl); - const requestId = getProtocolErrorRequestId(textContent, codeEl, messageIndex); + const identity = getProtocolErrorIdentity(textContent, codeEl, messageIndex, codeBlockIndex); if (state?.text !== textContent) { this.blockStates.set(codeEl, { @@ -86,15 +100,15 @@ export class ToolCallTracker { } if (!state.errorNotified) { - this.notifyProtocolError(codeEl, requestId, error); + this.notifyProtocolError(codeEl, identity, error); state.errorNotified = true; this.blockStates.set(codeEl, state); } - return requestId; + return identity; } - private notifyProtocolError(codeEl: HTMLElement, requestId: string, error: unknown): void { + private notifyProtocolError(codeEl: HTMLElement, identity: ToolRequestIdentity, error: unknown): void { const message = buildProtocolErrorMessage(error); Logger.log(`Tool call protocol error: ${message}`, "error"); UI.markVisualError(codeEl); @@ -103,9 +117,12 @@ export class ToolCallTracker { message: "Invalid tool call format. Returned guidance to the model.", }); - if (!this.options.requestRegistry.hasSeen(requestId) && !this.protocolErrorFeedbackRequests.has(requestId)) { - this.protocolErrorFeedbackRequests.add(requestId); - this.options.requestRegistry.saveToolResult(requestId, message, true); + if ( + !this.options.requestRegistry.hasSeen(identity.requestKey) && + !this.protocolErrorFeedbackRequests.has(identity.requestKey) + ) { + this.protocolErrorFeedbackRequests.add(identity.requestKey); + this.options.requestRegistry.saveToolResult(identity.requestKey, identity.requestId, message, true); } } @@ -182,25 +199,66 @@ function hashStableString(value: string): string { return (hash >>> 0).toString(36); } -function getProtocolErrorRequestId( - textContent: string, +function ensureElementRequestKey( codeEl: HTMLElement, - messageIndex: number + requestId: string, + messageIndex: number, + codeBlockIndex: number ): string { + const seed = stableStringify({ + codeBlockIndex, + messageIndex, + request_id: requestId, + }); + const cachedRequestKey = codeEl.dataset.mcpRequestKey; + const cachedRequestKeySeed = codeEl.dataset.mcpRequestKeySeed; + if (cachedRequestKey && cachedRequestKeySeed === seed) { + return cachedRequestKey; + } + + const requestKey = `req_key_${messageIndex}_${codeBlockIndex}_${hashStableString(seed)}`; + codeEl.dataset.mcpRequestKey = requestKey; + codeEl.dataset.mcpRequestKeySeed = seed; + return requestKey; +} + +function getProtocolErrorIdentity( + textContent: string, + codeEl: HTMLElement, + messageIndex: number, + codeBlockIndex: number +): ToolRequestIdentity { const explicitRequestId = extractRequestIdCandidate(textContent); if (explicitRequestId) { codeEl.dataset.mcpRequestId = explicitRequestId; - return explicitRequestId; + return { + requestId: explicitRequestId, + requestKey: ensureElementRequestKey(codeEl, explicitRequestId, messageIndex, codeBlockIndex), + }; } const cachedRequestId = codeEl.dataset.mcpRequestId; - if (cachedRequestId?.startsWith("req_invalid_")) { - return cachedRequestId; + const textSignature = hashStableString(textContent); + const scope = getRequestScope(messageIndex, codeBlockIndex); + if ( + cachedRequestId?.startsWith("req_invalid_") && + codeEl.dataset.mcpInvalidSignature === textSignature && + codeEl.dataset.mcpInvalidScope === scope + ) { + return { + requestId: cachedRequestId, + requestKey: ensureElementRequestKey(codeEl, cachedRequestId, messageIndex, codeBlockIndex), + }; } - const syntheticRequestId = `req_invalid_${messageIndex}_${hashStableString(textContent)}`; + const syntheticRequestId = `req_invalid_${messageIndex}_${codeBlockIndex}_${textSignature}`; codeEl.dataset.mcpRequestId = syntheticRequestId; - return syntheticRequestId; + codeEl.dataset.mcpInvalidSignature = textSignature; + codeEl.dataset.mcpInvalidScope = scope; + return { + requestId: syntheticRequestId, + requestKey: ensureElementRequestKey(codeEl, syntheticRequestId, messageIndex, codeBlockIndex), + }; } function extractRequestIdCandidate(textContent: string): string | null { @@ -208,6 +266,10 @@ function extractRequestIdCandidate(textContent: string): string | null { return normalizeRequestId(match?.[1]); } +function getRequestScope(messageIndex: number, codeBlockIndex: number): string { + return `${messageIndex}:${codeBlockIndex}`; +} + function buildProtocolErrorMessage(error: unknown): string { const issues = error instanceof ToolCallProtocolError ? error.issues @@ -216,8 +278,8 @@ function buildProtocolErrorMessage(error: unknown): string { ? "工具调用已被 webcode 拒绝,未请求 VS Code,也未执行任何工具。" : "The tool call was rejected by webcode before contacting VS Code. No tool was executed."; const nextStep = i18n.lang === "zh" - ? "请重新输出一个新的 JSON 工具调用代码块。顶层只能包含 mcp_action、name、purpose、arguments、request_id;name 和 purpose 必填。当前工具有入参时,arguments 必须严格匹配该工具的 inputSchema。" - : "Regenerate a new JSON tool-call code block. Top-level fields may only be mcp_action, name, purpose, arguments, and request_id; name and purpose are required. When the selected tool has inputs, arguments must exactly match that tool's inputSchema."; + ? "请重新输出一个新的 JSON 工具调用代码块。顶层只能包含 mcp_action、name、purpose、arguments、request_id;name 和 purpose 必填。request_id 必须是本会话中每次工具调用的新值。当前工具有入参时,arguments 必须严格匹配该工具的 inputSchema。" + : "Regenerate a new JSON tool-call code block. Top-level fields may only be mcp_action, name, purpose, arguments, and request_id; name and purpose are required. request_id must be new for every tool call in this conversation. When the selected tool has inputs, arguments must exactly match that tool's inputSchema."; const issueList = issues.map((issue) => `- ${issue}`).join("\n"); const formatHint = getDefaultProtocolErrorHint(); return `${intro}\n\nProblems:\n${issueList}\n\n${nextStep}\n\n${formatHint}`; @@ -233,7 +295,7 @@ function getDefaultProtocolErrorHint(): string { "arguments": { "key": "value" }, - "request_id": "step_1" + "request_id": "turn_unique_step_1" } \`\`\` @@ -243,7 +305,7 @@ Initialization tool format: "mcp_action": "call", "name": "${PROTOCOL.initToolName}", "purpose": "Initialize webcode for this conversation", - "request_id": "step_1" + "request_id": "init_unique_1" } \`\`\``; } diff --git a/bridge-browser/src/content/tool_executor.ts b/bridge-browser/src/content/tool_executor.ts index feefbd0..8893e40 100644 --- a/bridge-browser/src/content/tool_executor.ts +++ b/bridge-browser/src/content/tool_executor.ts @@ -12,7 +12,7 @@ import { persistApprovalRule, type ApprovalState, } from "./approval_policy"; -import { type ToolRequestRegistry } from "./tool_request_registry"; +import { type ToolRequestIdentity, type ToolRequestRegistry } from "./tool_request_registry"; interface ToolExecutorOptions { getSelectors: () => SiteSelectors | null; @@ -29,44 +29,52 @@ interface ToolExecutionResponse { } interface QueuedApprovalRequest { - payload: ToolExecutionPayload; - requestId: string; + request: ToolExecutionRequest; reject: (error: Error) => void; resolve: (approved: boolean) => void; } +interface ToolExecutionRequest { + identity: ToolRequestIdentity; + payload: ToolExecutionPayload; +} + export class ToolExecutor { private readonly approvalQueue: QueuedApprovalRequest[] = []; private readonly pendingApprovals = new Map>(); - private readonly toolExecutionQueue: ToolExecutionPayload[] = []; + private readonly toolExecutionQueue: ToolExecutionRequest[] = []; private isApprovalQueueRunning = false; private isToolExecutionQueueRunning = false; public constructor(private readonly options: ToolExecutorOptions) {} - public execute(payload: ToolExecutionPayload): void { - // Normal capture flow assigns a stable request_id in ToolCallTracker before execution. - void this.queueApprovalIfNeeded(payload); - this.toolExecutionQueue.push(payload); + public execute(payload: ToolExecutionPayload, identity: ToolRequestIdentity): void { + const request: ToolExecutionRequest = { + identity, + payload, + }; + + // Normal capture flow assigns a stable identity in ToolCallTracker before execution. + void this.queueApprovalIfNeeded(request); + this.toolExecutionQueue.push(request); void this.processToolExecutionQueue(); } - private queueApprovalIfNeeded(payload: ToolExecutionPayload): Promise | null { - if (!this.needsApproval(payload)) {return null;} + private queueApprovalIfNeeded(request: ToolExecutionRequest): Promise | null { + if (!this.needsApproval(request.payload)) {return null;} - const requestId = getRequestId(payload); - const existingApproval = this.pendingApprovals.get(requestId); + const requestKey = request.identity.requestKey; + const existingApproval = this.pendingApprovals.get(requestKey); if (existingApproval) {return existingApproval;} const approval = new Promise((resolve, reject) => { this.approvalQueue.push({ - payload, - requestId, + request, reject: (error) => reject(error), resolve, }); }); - this.pendingApprovals.set(requestId, approval); + this.pendingApprovals.set(requestKey, approval); void this.processApprovalQueue(); return approval; @@ -82,17 +90,17 @@ export class ToolExecutor { if (!approvalRequest) {continue;} try { - if (!this.needsApproval(approvalRequest.payload)) { - this.pendingApprovals.delete(approvalRequest.requestId); + if (!this.needsApproval(approvalRequest.request.payload)) { + this.pendingApprovals.delete(approvalRequest.request.identity.requestKey); approvalRequest.resolve(true); continue; } - Logger.log(`${t("hitl_intercept")}: ${approvalRequest.payload.name}`, "warn"); - const approved = await this.requestToolApproval(approvalRequest.payload); + Logger.log(`${t("hitl_intercept")}: ${approvalRequest.request.payload.name}`, "warn"); + const approved = await this.requestToolApproval(approvalRequest.request); approvalRequest.resolve(approved); } catch (error) { - this.pendingApprovals.delete(approvalRequest.requestId); + this.pendingApprovals.delete(approvalRequest.request.identity.requestKey); approvalRequest.reject(toError(error)); } } @@ -110,13 +118,13 @@ export class ToolExecutor { this.isToolExecutionQueueRunning = true; try { while (this.toolExecutionQueue.length > 0) { - const payload = this.toolExecutionQueue.shift(); - if (!payload) {continue;} + const request = this.toolExecutionQueue.shift(); + if (!request) {continue;} try { - await this.runQueuedTool(payload); + await this.runQueuedTool(request); } catch (error) { - this.failQueuedTool(payload, error); + this.failQueuedTool(request, error); } } } finally { @@ -127,37 +135,37 @@ export class ToolExecutor { } } - private async runQueuedTool(payload: ToolExecutionPayload): Promise { - if (payload.name === PROTOCOL.initToolName) { - await this.initializeWebcode(payload); + private async runQueuedTool(request: ToolExecutionRequest): Promise { + if (request.payload.name === PROTOCOL.initToolName) { + await this.initializeWebcode(request); return; } - if (isBootstrapOnlyToolName(payload.name)) { - this.rejectBootstrapOnlyTool(payload); + if (isBootstrapOnlyToolName(request.payload.name)) { + this.rejectBootstrapOnlyTool(request); return; } - const approved = await this.waitForApproval(payload); + const approved = await this.waitForApproval(request); if (!approved) {return;} - await this.performExecution(payload); + await this.performExecution(request); } - private async waitForApproval(payload: ToolExecutionPayload): Promise { - const requestId = getRequestId(payload); - if (!this.needsApproval(payload)) { - this.pendingApprovals.delete(requestId); + private async waitForApproval(request: ToolExecutionRequest): Promise { + const requestKey = request.identity.requestKey; + if (!this.needsApproval(request.payload)) { + this.pendingApprovals.delete(requestKey); return true; } - const approval = this.pendingApprovals.get(requestId) ?? this.queueApprovalIfNeeded(payload); + const approval = this.pendingApprovals.get(requestKey) ?? this.queueApprovalIfNeeded(request); if (!approval) {return true;} try { return await approval; } finally { - this.pendingApprovals.delete(requestId); + this.pendingApprovals.delete(requestKey); } } @@ -166,12 +174,17 @@ export class ToolExecutor { return !isPayloadApproved(payload, this.options.getApprovalState()); } - private failQueuedTool(payload: ToolExecutionPayload, error: unknown): void { - const requestId = getRequestId(payload); + private failQueuedTool(request: ToolExecutionRequest, error: unknown): void { const message = getErrorMessage(error) || "Tool execution failed."; - this.options.requestRegistry.markSettled(requestId); + this.options.requestRegistry.markSettled(request.identity.requestKey); Logger.log(`${t("exec_fail")}: ${message}`, "error"); - this.options.requestRegistry.saveToolResult(requestId, message, true); + this.options.requestRegistry.saveToolResult( + request.identity.requestKey, + request.identity.requestId, + message, + true, + request.payload.name + ); this.options.scheduleMainLoop(50); } @@ -180,14 +193,13 @@ export class ToolExecutor { * * 初始化工具不会走远端普通工具的完整回调链,它会在这里聚合项目规则、工具列表和技能列表, * 然后直接写入 request registry。由于 registry 状态变化本身不会触发页面 DOM 变化,必须在 - * 状态写完后调用 scheduleMainLoop,让主循环发现该 request_id 已完成并把初始化内容写回输入框。 + * 状态写完后调用 scheduleMainLoop,让主循环发现该 requestKey 已完成并把初始化内容写回输入框。 */ - private async initializeWebcode(payload: ToolExecutionPayload): Promise { - const requestId = getRequestId(payload); + private async initializeWebcode(request: ToolExecutionRequest): Promise { const finalPrompt = await buildWebcodeInitPrompt(); - this.options.requestRegistry.saveRawResult(requestId, finalPrompt); - this.options.requestRegistry.markSettled(requestId); + this.options.requestRegistry.saveRawResult(request.identity.requestKey, finalPrompt); + this.options.requestRegistry.markSettled(request.identity.requestKey); // 给当前调用栈一点时间收尾,再让主循环批处理回填,和普通工具完成路径保持一致。 this.options.scheduleMainLoop(50); } @@ -196,22 +208,27 @@ export class ToolExecutor { * 发送真实工具调用到扩展后台,并在后台响应后调度主循环回填。 * * 后台工具执行完成时,content script 只会收到这个回调;页面 DOM 不会因为 registry 更新 - * 自动变化。这里先把 request_id 标记为已结束,再把成功或失败结果写入 registry, + * 自动变化。这里先把 requestKey 标记为已结束,再把成功或失败结果写入 registry, * 最后通过 scheduleMainLoop 通知主循环重新计算当前轮次是否已经全部完成。 */ - private performExecution(payload: ToolExecutionPayload): Promise { + private performExecution(request: ToolExecutionRequest): Promise { return new Promise((resolve, reject) => { try { chrome.runtime.sendMessage( - { type: "EXECUTE_TOOL", payload }, + { type: "EXECUTE_TOOL", payload: request.payload }, (response: unknown) => { - const requestId = getRequestId(payload); - this.options.requestRegistry.markSettled(requestId); + this.options.requestRegistry.markSettled(request.identity.requestKey); if (chrome.runtime.lastError) { const errorMessage = chrome.runtime.lastError.message ?? "Tool execution failed."; Logger.log(`${t("exec_fail")}: ${errorMessage}`, "error"); - this.options.requestRegistry.saveToolResult(requestId, errorMessage, true); + this.options.requestRegistry.saveToolResult( + request.identity.requestKey, + request.identity.requestId, + errorMessage, + true, + request.payload.name + ); this.options.scheduleMainLoop(50); resolve(); return; @@ -219,12 +236,24 @@ export class ToolExecutor { const result = normalizeToolResponse(response); if (result.success) { - Logger.log(`${t("exec_success")}: ${payload.name}`, "success"); - const outputContent = formatSuccessfulResult(payload.name, result.data); - this.options.requestRegistry.saveToolResult(requestId, outputContent); + Logger.log(`${t("exec_success")}: ${request.payload.name}`, "success"); + const outputContent = formatSuccessfulResult(request.payload.name, result.data); + this.options.requestRegistry.saveToolResult( + request.identity.requestKey, + request.identity.requestId, + outputContent, + false, + request.payload.name + ); } else { Logger.log(`${t("exec_fail")}: ${result.error}`, "error"); - this.options.requestRegistry.saveToolResult(requestId, result.error ?? "Tool execution failed.", true); + this.options.requestRegistry.saveToolResult( + request.identity.requestKey, + request.identity.requestId, + result.error ?? "Tool execution failed.", + true, + request.payload.name + ); } // 工具完成不会触发 MutationObserver,需要主动安排一次扫描来推动批量回填。 @@ -238,50 +267,56 @@ export class ToolExecutor { }); } - private rejectBootstrapOnlyTool(payload: ToolExecutionPayload): void { - const requestId = getRequestId(payload); + private rejectBootstrapOnlyTool(request: ToolExecutionRequest): void { const message = i18n.lang === "zh" ? [ - `工具 ${payload.name} 仅供 ${BRANDING.productName} 初始化使用,不能由模型直接调用。`, + `工具 ${request.payload.name} 仅供 ${BRANDING.productName} 初始化使用,不能由模型直接调用。`, "请根据已初始化的工具和技能列表继续。", ].join("") : [ - `Tool ${payload.name} is reserved for ${BRANDING.productName} initialization and cannot be called directly by the model.`, + `Tool ${request.payload.name} is reserved for ${BRANDING.productName} initialization and cannot be called directly by the model.`, " Continue with the initialized tool and skill lists.", ].join(""); - this.options.requestRegistry.markSettled(requestId); + this.options.requestRegistry.markSettled(request.identity.requestKey); Logger.log(`${t("exec_fail")}: ${message}`, "error"); - this.options.requestRegistry.saveToolResult(requestId, message, true); + this.options.requestRegistry.saveToolResult( + request.identity.requestKey, + request.identity.requestId, + message, + true, + request.payload.name + ); this.options.scheduleMainLoop(50); } - private requestToolApproval(payload: ToolExecutionPayload): Promise { + private requestToolApproval(request: ToolExecutionRequest): Promise { return new Promise((resolve) => { UI.showConfirmationModal( - payload, + request.payload, (scope) => { this.focusInput(); if (scope) { - persistApprovalRule(payload, scope, this.options.getApprovalState()); + persistApprovalRule(request.payload, scope, this.options.getApprovalState()); void chrome.storage.local.set({ [`allowed_tools_${this.options.getWorkspaceId()}`]: buildStoredApprovalEntries(this.options.getApprovalState()), }); - Logger.log(`⚡ Approval saved for '${getApprovalLabel(payload, scope)}' in this workspace`, "action"); + Logger.log(`⚡ Approval saved for '${getApprovalLabel(request.payload, scope)}' in this workspace`, "action"); } resolve(true); }, (reason) => { - const requestId = getRequestId(payload); - this.options.requestRegistry.markSettled(requestId); + this.options.requestRegistry.markSettled(request.identity.requestKey); this.focusInput(); - Logger.log(`${t("hitl_rejected")}: ${payload.name}`, "error"); + Logger.log(`${t("hitl_rejected")}: ${request.payload.name}`, "error"); this.options.requestRegistry.saveToolResult( - requestId, + request.identity.requestKey, + request.identity.requestId, `User rejected execution. Reason: ${reason || "No reason provided."}`, - true + true, + request.payload.name ); this.options.scheduleMainLoop(50); resolve(false); @@ -336,10 +371,6 @@ function stringifyToolData(data: unknown, fallback: string): string { return fallback; } -function getRequestId(payload: ToolExecutionPayload): string { - return normalizeRequestId(payload.request_id) ?? "unknown_id"; -} - function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -351,9 +382,3 @@ function toError(error: unknown): Error { function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } - -function normalizeRequestId(value: unknown): string | null { - if (typeof value !== "string") {return null;} - const trimmed = value.trim(); - return trimmed || null; -} diff --git a/bridge-browser/src/content/tool_request_registry.ts b/bridge-browser/src/content/tool_request_registry.ts index d8b2436..6ad70f1 100644 --- a/bridge-browser/src/content/tool_request_registry.ts +++ b/bridge-browser/src/content/tool_request_registry.ts @@ -2,7 +2,7 @@ import { type McpResponse } from "@webcode/shared"; import { i18n } from "../modules/i18n"; /** - * 当前页面扫描轮次中,仍需要等待或回填的一组 request_id。 + * 当前页面扫描轮次中,仍需要等待或回填的一组 requestKey。 * * 这里的 ids 已经排除了 flushedRequests 中的 ID,因此只代表“当前最新 AI 回复里还没写回过结果” * 的工具调用。completedCount/totalCount 用于主循环判断是否可以进行一次批量回填。 @@ -18,7 +18,7 @@ export interface UnflushedRequestBatch { /** * 从 bufferedResults 中按页面顺序取出的待回填结果。 * - * hasOutput 表示至少有一段非空文本需要写回输入框;hasAnyResult 表示至少有 request_id 已经 + * hasOutput 表示至少有一段非空文本需要写回输入框;hasAnyResult 表示至少有 requestKey 已经 * 产出结果,即使结果是空字符串也算。空字符串用于不需要回填正文但仍要推进生命周期的路径。 */ export interface BufferedResultBatch { @@ -30,22 +30,52 @@ export interface BufferedResultBatch { } /** - * 管理工具调用 request_id 的完整生命周期。 + * 一次工具调用在 bridge 内部使用的稳定身份。 * - * main.ts 每次扫描页面时只负责发现工具调用和推进 UI;这个 registry 负责记录 request_id + * requestId 是模型协议里的 request_id,会写回 MCP result。requestKey 是 bridge 自己生成的 + * 唯一生命周期键,用来避免模型复用 request_id 时把多个工具调用合并成同一个请求。 + */ +export interface ToolRequestIdentity { + requestId: string; + requestKey: string; +} + +type BufferedResult = + | { + content: string; + kind: "raw"; + } + | { + content: string; + isError: boolean; + kind: "tool"; + requestId: string; + systemNote?: string; + toolName?: string; + }; + +interface DuplicateResultContext { + occurrence: number; + total: number; +} + +/** + * 管理工具调用内部 requestKey 的完整生命周期。 + * + * main.ts 每次扫描页面时只负责发现工具调用和推进 UI;这个 registry 负责记录 requestKey * 是否已经见过、是否还在执行、是否已有结果、是否已经回填过。这样主循环不用直接操作多个 * Set/Map,也能避免重复执行工具或重复写回结果。 */ export class ToolRequestRegistry { /** - * 所有已经进入执行路径的 request_id。 + * 所有已经进入执行路径的 requestKey。 * * 用来区分“新发现的工具调用”和“之前扫描过的工具调用”。同一个代码块在流式输出或 DOM */ private readonly seenRequests = new Set(); /** - * 已经写回输入框或确认无需写回文本的 request_id。 + * 已经写回输入框或确认无需写回文本的 requestKey。 * * 主循环每次都会重新扫描最新消息。写回过的 ID 放在这里,可以防止 deliverResult 触发 DOM * 变化后再次把同一份工具结果写进输入框。 @@ -55,15 +85,15 @@ export class ToolRequestRegistry { /** * 已完成但尚未回填的工具结果。 * - * key 是 request_id,value 是准备写回输入框的字符串。普通工具结果会包装成标准 + * key 是 requestKey,value 是准备写回输入框的字符串。普通工具结果会包装成标准 * ```json 代码块;初始化工具等特殊路径可以通过 saveRawResult 写入原始文本。 */ - private readonly bufferedResults = new Map(); + private readonly bufferedResults = new Map(); /** - * 正在执行或等待用户审批的 request_id。 + * 正在执行或等待用户审批的 requestKey。 * - * 工具开始执行时加入,后台返回、用户拒绝、或客户端路径完成时移除。某个 request_id 只有 + * 工具开始执行时加入,后台返回、用户拒绝、或客户端路径完成时移除。某个 requestKey 只有 * 同时“不在 runningRequests 中”且“bufferedResults 中已有结果”,才算完成。 */ private readonly runningRequests = new Set(); @@ -78,48 +108,48 @@ export class ToolRequestRegistry { /** * 创建一次页面扫描轮次的临时收集器。 * - * ToolRequestTurn 只保存本次扫描看到的 request_id 及其顺序;跨轮次状态仍由 registry 持有。 + * ToolRequestTurn 只保存本次扫描看到的 requestKey 及其顺序;跨轮次状态仍由 registry 持有。 */ public createTurn(): ToolRequestTurn { return new ToolRequestTurn(this); } /** - * 判断 request_id 是否已经进入过执行路径。 + * 判断 requestKey 是否已经进入过执行路径。 * * 返回 true 时,调用方应该只刷新视觉状态,不能再次执行工具。 */ - public hasSeen(requestId: string): boolean { - return this.seenRequests.has(requestId); + public hasSeen(requestKey: string): boolean { + return this.seenRequests.has(requestKey); } /** - * 标记 request_id 已进入执行状态。 + * 标记 requestKey 已进入执行状态。 * * 这个方法同时写入 seenRequests 和 runningRequests:seenRequests 防止重复执行, * runningRequests 表示该工具还没产生可回填结果。 */ - public markRunning(requestId: string): void { - this.seenRequests.add(requestId); - this.runningRequests.add(requestId); + public markRunning(requestKey: string): void { + this.seenRequests.add(requestKey); + this.runningRequests.add(requestKey); } /** - * 判断 request_id 是否仍在执行或等待审批。 + * 判断 requestKey 是否仍在执行或等待审批。 * * 主循环用这个结果决定代码块显示“处理中”还是“已完成”的视觉状态。 */ - public isRunning(requestId: string): boolean { - return this.runningRequests.has(requestId); + public isRunning(requestKey: string): boolean { + return this.runningRequests.has(requestKey); } /** - * 标记 request_id 的执行阶段已经结束。 + * 标记 requestKey 的执行阶段已经结束。 * * 结束不等于已经回填;调用方通常会随后调用 saveToolResult 或 saveRawResult 写入结果。 */ - public markSettled(requestId: string): void { - this.runningRequests.delete(requestId); + public markSettled(requestKey: string): void { + this.runningRequests.delete(requestKey); } /** @@ -127,8 +157,11 @@ export class ToolRequestRegistry { * * 适用于初始化工具这类需要返回长提示正文的特殊路径,不再额外包一层 MCP result JSON。 */ - public saveRawResult(requestId: string, content: string): void { - this.bufferedResults.set(requestId, content); + public saveRawResult(requestKey: string, content: string): void { + this.bufferedResults.set(requestKey, { + content, + kind: "raw", + }); } /** @@ -137,46 +170,49 @@ export class ToolRequestRegistry { * isError 为 true 时写入 error 字段,否则写入 output 字段。这里也负责周期性附加工具调用 * 格式提醒,避免长对话中模型逐渐偏离协议。 */ - public saveToolResult(requestId: string, content: string, isError = false): void { - const responseJson: McpResponse = { - mcp_action: "result", - request_id: requestId, - status: isError ? "error" : "success", - }; - if (isError) { - responseJson.error = content; - } else { - responseJson.output = content; - } - + public saveToolResult( + requestKey: string, + requestId: string, + content: string, + isError = false, + toolName?: string + ): void { this.toolCallCount++; + let systemNote: string | undefined; if (this.toolCallCount > 0 && this.toolCallCount % 5 === 0) { - responseJson.system_note = i18n.resources.train ?? getDefaultToolCallReminder(); + systemNote = i18n.resources.train ?? getDefaultToolCallReminder(); } - this.bufferedResults.set(requestId, formatJsonCodeBlock(responseJson)); + this.bufferedResults.set(requestKey, { + content, + isError, + kind: "tool", + requestId, + systemNote, + toolName, + }); } /** * 清理此前为协议错误生成的反馈结果。 * * 流式输出中,一个代码块可能先被判定为协议错误,随后又补全成有效工具调用。有效解析后要 - * 移除旧的错误反馈,避免同一个 request_id 既执行工具又回填旧错误。 + * 移除旧的错误反馈,避免同一个 requestKey 既执行工具又回填旧错误。 */ - public clearProtocolFeedbackResult(requestId: string): void { - this.flushedRequests.delete(requestId); - this.bufferedResults.delete(requestId); + public clearProtocolFeedbackResult(requestKey: string): void { + this.flushedRequests.delete(requestKey); + this.bufferedResults.delete(requestKey); } /** - * 从当前扫描轮次的 request_id 中筛出尚未回填的一批。 + * 从当前扫描轮次的 requestKey 中筛出尚未回填的一批。 * * 返回值包含完成数量和总数。主循环只在 isComplete 为 true 时回填,确保同一轮出现的工具 * 调用尽量合并成一次结果写入,而不是哪个先完成就先写哪个。 */ - public getUnflushedBatch(requestIds: readonly string[]): UnflushedRequestBatch { - const ids = requestIds.filter((id) => !this.flushedRequests.has(id)); - const completedCount = ids.filter((id) => this.isComplete(id)).length; + public getUnflushedBatch(requestKeys: readonly string[]): UnflushedRequestBatch { + const ids = requestKeys.filter((key) => !this.flushedRequests.has(key)); + const completedCount = ids.filter((key) => this.isComplete(key)).length; return { ids, @@ -190,25 +226,31 @@ export class ToolRequestRegistry { /** * 按当前页面顺序组装一批已缓存结果。 * - * requestIds 的顺序来自 ToolRequestTurn,因此和 AI 回复中工具调用出现的顺序一致。空字符串 + * requestKeys 的顺序来自 ToolRequestTurn,因此和 AI 回复中工具调用出现的顺序一致。空字符串 * 结果不会进入 output,但仍会让 hasAnyResult 为 true,供调用方标记对应请求已处理。 */ - public buildBufferedResultBatch(requestIds: readonly string[]): BufferedResultBatch { + public buildBufferedResultBatch(requestKeys: readonly string[]): BufferedResultBatch { const orderedResults: string[] = []; let hasAnyResult = false; + const toolRequestIdCounts = this.countToolRequestIds(requestKeys); + const toolRequestIdOccurrences = new Map(); - requestIds.forEach((id) => { - if (!this.bufferedResults.has(id)) {return;} + requestKeys.forEach((key) => { + const bufferedResult = this.bufferedResults.get(key); + if (!bufferedResult) {return;} hasAnyResult = true; - const result = this.bufferedResults.get(id); + const result = this.formatBufferedResult( + bufferedResult, + this.getDuplicateResultContext(bufferedResult, toolRequestIdCounts, toolRequestIdOccurrences) + ); if (result) { orderedResults.push(result); } }); return { - ids: [...requestIds], + ids: [...requestKeys], output: orderedResults.join("\n\n"), outputCount: orderedResults.length, hasOutput: orderedResults.length > 0, @@ -217,63 +259,120 @@ export class ToolRequestRegistry { } /** - * 标记一组 request_id 已完成回填或无需回填。 + * 标记一组 requestKey 已完成回填或无需回填。 * * 这会删除对应缓存结果,并写入 flushedRequests。后续页面扫描再次看到这些 ID 时,主循环会 * 跳过批处理,避免重复写回。 */ - public markFlushed(requestIds: readonly string[]): void { - requestIds.forEach((id) => { - this.bufferedResults.delete(id); - this.flushedRequests.add(id); + public markFlushed(requestKeys: readonly string[]): void { + requestKeys.forEach((key) => { + this.bufferedResults.delete(key); + this.flushedRequests.add(key); }); } /** - * 判断某个 request_id 是否已经具备回填条件。 + * 判断某个 requestKey 是否已经具备回填条件。 * * 完成条件必须同时满足:不再执行中,并且已经有结果缓存。这样可以区分“工具还在跑”和 * “工具完成但结果为空字符串”的情况。 */ - private isComplete(requestId: string): boolean { - return !this.runningRequests.has(requestId) && this.bufferedResults.has(requestId); + private isComplete(requestKey: string): boolean { + return !this.runningRequests.has(requestKey) && this.bufferedResults.has(requestKey); + } + + private countToolRequestIds(requestKeys: readonly string[]): Map { + const counts = new Map(); + requestKeys.forEach((key) => { + const bufferedResult = this.bufferedResults.get(key); + if (bufferedResult?.kind !== "tool") {return;} + counts.set(bufferedResult.requestId, (counts.get(bufferedResult.requestId) ?? 0) + 1); + }); + return counts; + } + + private getDuplicateResultContext( + bufferedResult: BufferedResult, + requestIdCounts: ReadonlyMap, + requestIdOccurrences: Map + ): DuplicateResultContext | undefined { + if (bufferedResult.kind !== "tool") {return undefined;} + + const total = requestIdCounts.get(bufferedResult.requestId) ?? 0; + if (total <= 1) {return undefined;} + + const occurrence = (requestIdOccurrences.get(bufferedResult.requestId) ?? 0) + 1; + requestIdOccurrences.set(bufferedResult.requestId, occurrence); + return { + occurrence, + total, + }; + } + + private formatBufferedResult( + bufferedResult: BufferedResult, + duplicateContext?: DuplicateResultContext + ): string { + if (bufferedResult.kind === "raw") { + return bufferedResult.content; + } + + const content = duplicateContext + ? addDuplicateRequestContext(bufferedResult.content, bufferedResult, duplicateContext) + : bufferedResult.content; + const responseJson: McpResponse = { + mcp_action: "result", + request_id: bufferedResult.requestId, + status: bufferedResult.isError ? "error" : "success", + }; + + if (bufferedResult.isError) { + responseJson.error = content; + } else { + responseJson.output = content; + } + if (bufferedResult.systemNote) { + responseJson.system_note = bufferedResult.systemNote; + } + + return formatJsonCodeBlock(responseJson); } } /** - * 一次 runMainLoop 扫描过程中的 request_id 收集器。 + * 一次 runMainLoop 扫描过程中的 requestKey 收集器。 * * 它只保存本轮扫描看到的 ID,并保持页面出现顺序。生命周期很短,每次 runMainLoop 都会创建 * 新实例;跨轮次的执行/回填状态由 ToolRequestRegistry 管理。 */ export class ToolRequestTurn { /** - * 当前扫描轮次内按页面顺序出现的 request_id。 + * 当前扫描轮次内按页面顺序出现的 requestKey。 * * 后续回填会按这个顺序合并结果,保证多工具调用结果顺序和 AI 原始请求顺序一致。 */ - private readonly requestIds: string[] = []; + private readonly requestKeys: string[] = []; /** * 当前扫描轮次内的去重集合。 * - * 同一个 request_id 可能因为重复代码块、协议错误反馈或 DOM 结构变化被看到多次;Set 用来 - * 保证 requestIds 中只出现一次。 + * 同一个 requestKey 可能因为重复代码块、协议错误反馈或 DOM 结构变化被看到多次;Set 用来 + * 保证 requestKeys 中只出现一次。 */ - private readonly requestIdSet = new Set(); + private readonly requestKeySet = new Set(); public constructor(private readonly registry: ToolRequestRegistry) {} /** - * 记录本轮扫描看到的一个 request_id。 + * 记录本轮扫描看到的一个 requestKey。 * * null 表示当前代码块暂时没有可用 ID,例如还在等待流式 JSON 稳定;这种情况直接忽略。 */ - public add(requestId: string | null): void { - if (!requestId || this.requestIdSet.has(requestId)) {return;} + public add(requestKey: string | null): void { + if (!requestKey || this.requestKeySet.has(requestKey)) {return;} - this.requestIds.push(requestId); - this.requestIdSet.add(requestId); + this.requestKeys.push(requestKey); + this.requestKeySet.add(requestKey); } /** @@ -282,7 +381,7 @@ export class ToolRequestTurn { * 具体的已回填过滤和完成状态计算交给 registry,这个对象只提供本轮 ID 的有序列表。 */ public getUnflushedBatch(): UnflushedRequestBatch { - return this.registry.getUnflushedBatch(this.requestIds); + return this.registry.getUnflushedBatch(this.requestKeys); } } @@ -297,9 +396,23 @@ function formatJsonCodeBlock(responseJson: McpResponse): string { )}\n\`\`\``; } +function addDuplicateRequestContext( + content: string, + bufferedResult: Extract, + duplicateContext: DuplicateResultContext +): string { + const toolLabel = bufferedResult.toolName ? ` for tool "${bufferedResult.toolName}"` : ""; + const prefix = [ + `webcode note: duplicate request_id "${bufferedResult.requestId}" result`, + `${duplicateContext.occurrence}/${duplicateContext.total}${toolLabel}.`, + ].join(" "); + + return content ? `${prefix}\n\n${content}` : prefix; +} + /** * 当本地提示词资源还没加载到训练提示时,使用这个兜底协议提醒。 */ function getDefaultToolCallReminder(): string { - return "[System] Reminder: Tool calls MUST use this JSON format: {\"mcp_action\":\"call\", \"name\": \"tool_name\", \"purpose\": \"reason\", \"arguments\": {...}}."; + return "[System] Reminder: Tool calls MUST use this JSON format: {\"mcp_action\":\"call\", \"name\": \"tool_name\", \"purpose\": \"reason\", \"arguments\": {...}, \"request_id\": \"turn_unique_step_x\"}. request_id must be new for every tool call in this conversation."; } diff --git a/bridge-browser/src/modules/logger.ts b/bridge-browser/src/modules/logger.ts index 808e7a2..7b7c385 100644 --- a/bridge-browser/src/modules/logger.ts +++ b/bridge-browser/src/modules/logger.ts @@ -21,6 +21,7 @@ export const Logger = { filterButtons: new Map(), activeTypes: new Set(["summary"]), isMinimized: false, + soundEnabled: false, init() { if (this.el) {return;} @@ -291,7 +292,24 @@ export const Logger = { } }, + setSoundEnabled(enabled: boolean) { + this.soundEnabled = enabled; + }, + + playLogSound(type: LoggerLogType) { + if (!this.soundEnabled || !isStandardLogType(type)) {return;} + + try { + chrome.runtime.sendMessage({ type: "PLAY_LOG_SOUND", logType: type }, () => { + void chrome.runtime.lastError; + }); + } catch { + // Logger may be reused outside an extension context during local testing. + } + }, + log(msg: string, type: LoggerLogType = "info") { + this.playLogSound(type); if (!this.el || this.el.style.display === "none") {return;} const line = document.createElement("div"); line.className = "line"; @@ -319,3 +337,7 @@ export const Logger = { } }, }; + +function isStandardLogType(type: LoggerLogType): type is typeof STANDARD_LOG_TYPES[number] { + return STANDARD_LOG_TYPES.includes(type as typeof STANDARD_LOG_TYPES[number]); +} diff --git a/bridge-browser/src/offscreen/audio.ts b/bridge-browser/src/offscreen/audio.ts index bf6c635..8faa818 100644 --- a/bridge-browser/src/offscreen/audio.ts +++ b/bridge-browser/src/offscreen/audio.ts @@ -1,5 +1,38 @@ const PLAY_ATTENTION_SOUND = "PLAY_ATTENTION_SOUND"; +type LogSoundType = "info" | "success" | "warn" | "error" | "action"; + +type ToneStep = { + frequency: number; + duration: number; + gap?: number; + type?: OscillatorType; +}; + +const SOUND_PATTERNS: Record = { + info: [ + { frequency: 660, duration: 0.12 }, + { frequency: 880, duration: 0.16 }, + ], + success: [ + { frequency: 784, duration: 0.12 }, + { frequency: 1046.5, duration: 0.22 }, + ], + warn: [ + { frequency: 523.25, duration: 0.14, type: "triangle" }, + { frequency: 392, duration: 0.24, type: "triangle" }, + ], + error: [ + { frequency: 220, duration: 0.16, type: "square" }, + { frequency: 196, duration: 0.26, type: "square" }, + ], + action: [ + { frequency: 880, duration: 0.08 }, + { frequency: 987.77, duration: 0.08 }, + { frequency: 1174.66, duration: 0.16 }, + ], +}; + let audioContext: AudioContext | null = null; chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { @@ -7,7 +40,8 @@ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { return false; } - void playAttentionSound() + const logType = getLogSoundType(request.logType); + void playAttentionSound(logType) .then(() => sendResponse({ success: true })) .catch((error) => { sendResponse({ @@ -19,25 +53,30 @@ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { return true; }); -async function playAttentionSound(): Promise { +async function playAttentionSound(logType: LogSoundType): Promise { const context = getAudioContext(); if (context.state === "suspended") { await context.resume(); } + const pattern = SOUND_PATTERNS[logType]; const start = context.currentTime + 0.01; const output = context.createGain(); + const totalDuration = pattern.reduce((sum, tone) => sum + tone.duration + (tone.gap ?? 0.03), 0); output.gain.setValueAtTime(0.0001, start); - output.gain.linearRampToValueAtTime(0.12, start + 0.02); - output.gain.exponentialRampToValueAtTime(0.0001, start + 0.48); + output.gain.linearRampToValueAtTime(0.1, start + 0.02); + output.gain.exponentialRampToValueAtTime(0.0001, start + totalDuration + 0.06); output.connect(context.destination); - playTone(context, output, 880, start, 0.16); - playTone(context, output, 1174.66, start + 0.16, 0.24); + let cursor = start; + for (const tone of pattern) { + playTone(context, output, tone, cursor); + cursor += tone.duration + (tone.gap ?? 0.03); + } window.setTimeout(() => { output.disconnect(); - }, 650); + }, Math.ceil((totalDuration + 0.2) * 1000)); } function getAudioContext(): AudioContext { @@ -61,24 +100,23 @@ function getWebkitAudioContext(): typeof AudioContext | undefined { function playTone( context: AudioContext, output: AudioNode, - frequency: number, - start: number, - duration: number + tone: ToneStep, + start: number ): void { const oscillator = context.createOscillator(); const gain = context.createGain(); - oscillator.type = "sine"; - oscillator.frequency.setValueAtTime(frequency, start); + oscillator.type = tone.type ?? "sine"; + oscillator.frequency.setValueAtTime(tone.frequency, start); gain.gain.setValueAtTime(0.0001, start); gain.gain.linearRampToValueAtTime(1, start + 0.01); - gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); + gain.gain.exponentialRampToValueAtTime(0.0001, start + tone.duration); oscillator.connect(gain); gain.connect(output); oscillator.start(start); - oscillator.stop(start + duration + 0.03); + oscillator.stop(start + tone.duration + 0.03); oscillator.addEventListener( "ended", () => { @@ -89,6 +127,18 @@ function playTone( ); } +function getLogSoundType(value: unknown): LogSoundType { + return isLogSoundType(value) ? value : "action"; +} + +function isLogSoundType(value: unknown): value is LogSoundType { + return value === "info" || + value === "success" || + value === "warn" || + value === "error" || + value === "action"; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/bridge-browser/src/popup/index.ts b/bridge-browser/src/popup/index.ts index 28e2246..8ad4ca0 100644 --- a/bridge-browser/src/popup/index.ts +++ b/bridge-browser/src/popup/index.ts @@ -9,6 +9,7 @@ type PopupElements = { manualInitBtn: HTMLButtonElement; autoSendInput: HTMLInputElement; showLogInput: HTMLInputElement; + soundInput: HTMLInputElement; availableView: HTMLElement; gatewayList: HTMLElement; title: HTMLElement; @@ -16,6 +17,7 @@ type PopupElements = { portLabel: HTMLElement; autoSendLabel: HTMLElement; showLogLabel: HTMLElement; + soundLabel: HTMLElement; availableGateways: HTMLElement; disconnectedTitle: HTMLElement; installedTitle: HTMLElement; @@ -53,6 +55,7 @@ const UI: Record> = { manual_init_unavailable: "Cannot initialize here", auto_send: "Auto Send Message", show_log: "Show Floating Log", + notification_sound: "Notification Sound", available_gateways: "⚡ Available Gateways", disconnected: "🔴 Disconnected", installed_title: "👉 Already Installed?", @@ -74,6 +77,7 @@ const UI: Record> = { manual_init_unavailable: "当前页面无法初始化", auto_send: "自动发送消息", show_log: "显示悬浮日志", + notification_sound: "提示音", available_gateways: "⚡ 可用网关", disconnected: "🔴 未连接", installed_title: "👉 已安装?", @@ -123,6 +127,7 @@ function getPopupElements(): PopupElements { manualInitBtn: document.getElementById("manualInitBtn") as HTMLButtonElement, autoSendInput: document.getElementById("autoSend") as HTMLInputElement, showLogInput: document.getElementById("showLog") as HTMLInputElement, + soundInput: document.getElementById("logSound") as HTMLInputElement, availableView: document.getElementById("availableView") as HTMLElement, gatewayList: document.getElementById("gatewayList") as HTMLElement, title: document.getElementById("title") as HTMLElement, @@ -130,6 +135,7 @@ function getPopupElements(): PopupElements { portLabel: document.getElementById("portLabel") as HTMLElement, autoSendLabel: document.getElementById("autoSendLabel") as HTMLElement, showLogLabel: document.getElementById("showLogLabel") as HTMLElement, + soundLabel: document.getElementById("soundLabel") as HTMLElement, availableGateways: document.getElementById("availableGateways") as HTMLElement, disconnectedTitle: document.getElementById("disconnectedTitle") as HTMLElement, installedTitle: document.getElementById("installedTitle") as HTMLElement, @@ -148,6 +154,7 @@ function initializeLabels(context: PopupContext): void { elements.manualInitBtn.title = t("manual_init_title"); elements.autoSendLabel.textContent = t("auto_send"); elements.showLogLabel.textContent = t("show_log"); + elements.soundLabel.textContent = t("notification_sound"); elements.availableGateways.innerHTML = ` ${t("available_gateways").replace(/^⚡\s*/, "")}`; elements.disconnectedTitle.textContent = t("disconnected"); elements.installedTitle.textContent = t("installed_title"); @@ -184,12 +191,16 @@ function requestCurrentStatus(currentTab: chrome.tabs.Tab, currentTabId: number, ); } -function showConnectedStatus(response: { port?: number; showLog?: boolean }, elements: PopupElements): void { +function showConnectedStatus( + response: { port?: number; showLog?: boolean; soundEnabled?: boolean }, + elements: PopupElements +): void { elements.connectedView.classList.remove("hidden"); elements.disconnectedView.classList.add("hidden"); elements.statusDot.classList.add("online"); elements.portDisplay.innerText = String(response.port ?? ""); elements.showLogInput.checked = response.showLog === true; + elements.soundInput.checked = response.soundEnabled === true; } function showDisconnectedStatus(currentUrl: string, currentTabId: number, context: PopupContext): void { @@ -295,6 +306,7 @@ function bindPopupControls(currentTabId: number, context: PopupContext): void { bindManualInitButton(currentTabId, context); bindAutoSendToggle(context.elements.autoSendInput); bindLogToggle(currentTabId, context.elements.showLogInput); + bindSoundToggle(currentTabId, context.elements.soundInput); } function bindManualInitButton(currentTabId: number, context: PopupContext): void { @@ -389,3 +401,22 @@ function bindLogToggle(currentTabId: number, showLogInput: HTMLInputElement): vo }); }); } + +function bindSoundToggle(currentTabId: number, soundInput: HTMLInputElement): void { + chrome.storage.sync.get(["logSoundEnabled"], (items: Record) => { + if (typeof items.logSoundEnabled === "boolean") { + soundInput.checked = items.logSoundEnabled; + } + }); + + soundInput.addEventListener("change", () => { + const soundEnabled = soundInput.checked; + void chrome.storage.sync.set({ logSoundEnabled: soundEnabled }); + void chrome.tabs.sendMessage(currentTabId, { + type: "SET_LOG_SOUND_ENABLED", + soundEnabled, + }).catch(() => { + void chrome.runtime.lastError; + }); + }); +} diff --git a/bridge-browser/src/types.ts b/bridge-browser/src/types.ts index 788e8be..5b09ad0 100644 --- a/bridge-browser/src/types.ts +++ b/bridge-browser/src/types.ts @@ -19,6 +19,8 @@ export interface MessageRequest { message?: string; onlyWhenWindowInBackground?: boolean; playSound?: boolean; + soundEnabled?: boolean; + logType?: string; connected?: boolean; payload?: ToolExecutionPayload; } @@ -34,6 +36,7 @@ export interface StatusResponse { error?: string; port?: number; showLog?: boolean; + soundEnabled?: boolean; workspaceId?: string; } diff --git a/changelogs/en/v0.9.0.md b/changelogs/en/v0.9.0.md new file mode 100644 index 0000000..6a6da73 --- /dev/null +++ b/changelogs/en/v0.9.0.md @@ -0,0 +1,23 @@ +# v0.9.0 (2026-05-31) + +### Features +- **Qwen Platform Support**: Add built-in support for launching and working with Qwen as a supported AI platform. (#41) +- **Streamlined Initialization**: Simplify direct webcode initialization and restore manual popup initialization for cases that need explicit setup. (#29, #31) +- **Terminal Workflows**: Run terminal commands through VS Code terminal profiles, support delayed terminal reads, and show queued HITL approvals earlier. (#34, #35) +- **File Creation**: Allow `write_file` to create missing parent directories while preserving guardrails around unsafe paths. (#35) + +### Improvements +- **Terminal Safety Policies**: Split terminal command risk policy layers and tighten terminal path, profile, interrupt, and status handling. (#32, #35) +- **Skill Directory Configuration**: Make skill directories additive while removing bundled skills from the default scan paths. (#33) +- **Search Output Limits**: Limit `search_code` output from generated files so large results stay usable. (#38) +- **Protocol Guidance**: Sync and simplify prompt protocol examples and error hints. (#28) + +### Fixes +- **Request ID Handling**: Detect duplicate tool request ids and refresh reused request key caches to avoid collisions across tool calls. (#40) +- **Bridge Install Detection**: Prevent race conditions while detecting browser bridge installation state. (#39) +- **Gateway Connection Recovery**: Respond correctly when an existing gateway connection is invalid. (#28) +- **Popup and Path Guards**: Keep the popup port label inline, guard manual initialization status reset, and avoid duplicate path option checks. (#29, #31, #32) + +### Engineering +- **Lint Cleanup**: Clear structural and non-structural lint warnings and enforce lint warnings as errors. (#28) +- **JSON Repair Refactor**: Split the JSON repair module for clearer maintenance boundaries. (#28) diff --git a/changelogs/en/v0.9.1.md b/changelogs/en/v0.9.1.md new file mode 100644 index 0000000..807f2f9 --- /dev/null +++ b/changelogs/en/v0.9.1.md @@ -0,0 +1,6 @@ +# v0.9.1 (2026-05-31) + +### Fixes +- **Tool Search Reliability**: Make ripgrep path discovery platform-aware, support universal bundles, and improve bundled ripgrep detection so `search_code` can find a working binary more reliably across environments. (#42) +- **Search Glob Fallbacks**: Improve fallback glob handling for `search_code` so searches stay useful when include/exclude glob behavior differs by platform or runtime. (#42) +- **Edit File Output Handling**: Harden `edit_file` output handling to keep patch and replacement results stable and easier for agents to consume. (#42) diff --git a/changelogs/zh/v0.9.0.md b/changelogs/zh/v0.9.0.md new file mode 100644 index 0000000..3a05506 --- /dev/null +++ b/changelogs/zh/v0.9.0.md @@ -0,0 +1,23 @@ +# v0.9.0 (2026-05-31) + +### 功能 +- **Qwen 平台支持**:新增内置 Qwen 平台支持,可直接作为受支持的 AI 平台启动和使用。 (#41) +- **初始化流程简化**:简化直接 webcode 初始化流程,并恢复需要显式设置时使用的弹窗手动初始化能力。 (#29, #31) +- **终端工作流**:通过 VS Code 终端配置运行终端命令,支持延迟读取终端输出,并更早展示排队中的 HITL 审批。 (#34, #35) +- **文件创建**:允许 `write_file` 自动创建缺失的父目录,同时保留不安全路径防护。 (#35) + +### 改进 +- **终端安全策略**:拆分终端命令风险策略层,并收紧终端路径、配置、打断与状态处理。 (#32, #35) +- **Skill 目录配置**:将 skill 目录配置改为追加式,同时从默认扫描路径中移除内置 skills。 (#33) +- **搜索输出限制**:限制 `search_code` 对生成文件的输出,避免大结果影响可用性。 (#38) +- **协议指引**:同步并简化提示词协议示例与错误提示。 (#28) + +### 修复 +- **请求 ID 处理**:检测重复的工具请求 ID,并刷新复用的请求键缓存,避免跨工具调用发生碰撞。 (#40) +- **Bridge 安装检测**:避免检测浏览器 bridge 安装状态时发生竞态。 (#39) +- **Gateway 连接恢复**:当已有 gateway 连接无效时正确响应。 (#28) +- **弹窗与路径防护**:保持弹窗端口标签同行显示,保护手动初始化状态重置,并避免重复检查路径选项。 (#29, #31, #32) + +### 工程 +- **Lint 清理**:清理结构性与非结构性 lint 警告,并将 lint 警告作为错误处理。 (#28) +- **JSON 修复重构**:拆分 JSON 修复模块,让维护边界更清晰。 (#28) diff --git a/changelogs/zh/v0.9.1.md b/changelogs/zh/v0.9.1.md new file mode 100644 index 0000000..408cef9 --- /dev/null +++ b/changelogs/zh/v0.9.1.md @@ -0,0 +1,6 @@ +# v0.9.1 (2026-05-31) + +### 修复 +- **工具搜索可靠性**:让 ripgrep 路径发现具备平台感知能力,支持 universal bundle,并改进内置 ripgrep 检测,使 `search_code` 在不同环境中更可靠地找到可用二进制文件。 (#42) +- **搜索 Glob 回退**:改进 `search_code` 的回退 glob 处理,使 include/exclude glob 行为在不同平台或运行时存在差异时,搜索仍保持可用。 (#42) +- **编辑文件输出处理**:加固 `edit_file` 输出处理,让 patch 与替换结果更稳定,也更易于 agent 消费。 (#42) diff --git a/doc/BUILTIN_TOOLS.md b/doc/BUILTIN_TOOLS.md index 89573a1..00804f5 100644 --- a/doc/BUILTIN_TOOLS.md +++ b/doc/BUILTIN_TOOLS.md @@ -14,10 +14,10 @@ | 工具名 | 作用 | | --- | --- | -| `read_file` | 读取 workspace 内的 UTF-8 文本文件,可用 `head`、`tail`、`start_line`、`end_line`、`show_line_numbers` 读取指定范围并显示行号。未指定范围时会对大文件默认截断,可用 `force` 强制全量返回。 | +| `read_file` | 读取 workspace 内的 UTF-8 文本文件,可用 `head`、`tail`、`start_line`、`end_line`、`show_line_numbers` 读取指定范围并显示行号。 | | `write_file` | 创建或完全覆盖 workspace 内的 UTF-8 文本文件。 | | `edit_file` | 对 workspace 内文本文件做精确文本替换或应用 unified diff patch,可用 `dryRun` 返回 diff 预览。 | -| `search_files` | 按文件名或相对路径搜索文件,基于 VS Code 文件搜索,支持简单 glob。 | +| `search_files` | 按文件名或相对路径搜索文件,优先使用 ripgrep 文件枚举,支持子串、glob、默认不区分大小写。 | | `search_code` | 基于 ripgrep 在 workspace 文本文件中搜索代码内容,返回相对路径、行号和命中行。 | | `execute_command` | 在后台执行短生命周期 POSIX/bash 命令并返回 stdout/stderr/exitCode,适用于构建、测试、git、包管理器和项目脚本。读取或搜索文件应优先使用 `read_file`、`search_files`、`search_code`。 | | `run_in_terminal` | 在真实 VS Code 集成终端中运行命令,立即返回 `session_id`,适合 `pnpm dev`、watch、开发服务器等常驻任务或需要用户可见输出的任务。支持动态发现并选择常用 terminal profile,例如 `default`、`git-bash`、`pwsh`、`powershell`;工具描述会在初始化时列出当前环境可用项。明显破坏性、提权或 shell 逃逸类命令会按所选 shell 类型在执行前被拒绝。 | @@ -25,6 +25,10 @@ `run_in_terminal` 和 `terminal_session` 的 profile 发现、shell integration 输出采集、`stop`/`close` 语义和安全策略见 [run_in_terminal 与 terminal_session 设计说明](RUN_IN_TERMINAL_GUIDE.md)。 +`read_file` 的参数语义、读取模型、输出保护和 metadata 行为见 [read_file 设计说明](READ_FILE_GUIDE.md)。 + +`search_files` 和 `search_code` 的 query、match、大小写、排除规则和 ripgrep/fallback 行为见 [search_files 与 search_code 设计说明](SEARCH_TOOLS_GUIDE.md)。 + ## 2. Bootstrap-only tools 这些工具只供 VS Code 网关和浏览器插件初始化会话时使用,不会出现在 Available Tools 中,模型直接调用时会被浏览器插件拒绝。 diff --git a/doc/BUILTIN_TOOLS_en.md b/doc/BUILTIN_TOOLS_en.md index ba2aa87..6e1cb52 100644 --- a/doc/BUILTIN_TOOLS_en.md +++ b/doc/BUILTIN_TOOLS_en.md @@ -15,10 +15,10 @@ Bare tool names only belong to these local tools. Tools exposed by third-party M | Tool | Purpose | | --- | --- | -| `read_file` | Reads UTF-8 text files inside the workspace. Supports `head`, `tail`, `start_line`, `end_line`, and `show_line_numbers` for ranged reads and line numbers. Large files are truncated by default when no range is specified; use `force` to return the full file. | +| `read_file` | Reads UTF-8 text files inside the workspace. Supports `head`, `tail`, `start_line`, `end_line`, and `show_line_numbers` for ranged reads and line numbers. | | `write_file` | Creates or fully overwrites UTF-8 text files inside the workspace. | | `edit_file` | Applies exact text replacements or unified diff patches to text files inside the workspace. Use `dryRun` to return a diff preview. | -| `search_files` | Searches files by filename or relative path using VS Code file search, with simple glob support. | +| `search_files` | Searches files by filename or relative path using ripgrep file listing first, with substring and glob matching that is case-insensitive by default. | | `search_code` | Searches workspace text files with ripgrep and returns relative paths, line numbers, and matching lines. | | `execute_command` | Runs short-lived POSIX/bash commands in the background and returns stdout, stderr, and exitCode. It is intended for builds, tests, git, package managers, and project scripts. Prefer `read_file`, `search_files`, and `search_code` for reading or searching files. | | `run_in_terminal` | Starts a long-running POSIX shell command in a visible VS Code terminal session and immediately returns a `session_id`. It is intended for persistent tasks or output that should stay visible to the user. On Windows, Git Bash is required, and commands should use bash/POSIX syntax instead of cmd.exe or PowerShell syntax. Clearly destructive, privileged, or shell-escape commands are rejected before execution. | diff --git a/doc/MCP_GUIDE.md b/doc/MCP_GUIDE.md index e0edffc..07bb6b3 100644 --- a/doc/MCP_GUIDE.md +++ b/doc/MCP_GUIDE.md @@ -145,7 +145,7 @@ chrome-devtools: "name": "chrome-devtools:", "purpose": "Use Chrome DevTools MCP for the browser task.", "arguments": {}, - "request_id": "step_1" + "request_id": "turn_unique_step_1" } ``` diff --git a/doc/MCP_GUIDE_en.md b/doc/MCP_GUIDE_en.md index 49c3e93..250902c 100644 --- a/doc/MCP_GUIDE_en.md +++ b/doc/MCP_GUIDE_en.md @@ -145,7 +145,7 @@ When calling a third-party tool, use the full name shown in Available Tools. Exa "name": "chrome-devtools:", "purpose": "Use Chrome DevTools MCP for the browser task.", "arguments": {}, - "request_id": "step_1" + "request_id": "turn_unique_step_1" } ``` diff --git a/doc/READ_FILE_GUIDE.md b/doc/READ_FILE_GUIDE.md new file mode 100644 index 0000000..082f4e7 --- /dev/null +++ b/doc/READ_FILE_GUIDE.md @@ -0,0 +1,185 @@ +# read_file 设计说明 + +本文说明 `read_file` 内置工具的目标、参数语义、读取模型、输出保护和已知边界,方便开发者维护,也方便用户理解 AI 读取 workspace 文件时发生了什么。 + +## 背景 + +`read_file` 是 webcode 提供给 AI 的专用文件读取工具。它替代了用 `execute_command` 调 `cat`、`sed`、`nl` 等 shell 命令查看文件的做法,目标是让文件读取具备稳定的参数语义、必要时的结构化 metadata 和上下文保护。 + +早期版本的保护策略主要针对“未指定范围”的大文件读取:默认只返回前 400 行,超过 64KB 的文件只读取前 64KB 前缀。这个策略能避免上下文爆炸,但显式传入 `head`、`tail` 或 `start_line/end_line` 时不经过同一套输出限制,容易在误传大范围时返回过多内容。 + +优化后的目标是: + +- 工具入参保持简单清晰。 +- 默认读取和范围读取共用同一套输出保护。 +- 截断只在真正发生时通过结果文本和 metadata 告知 AI。 +- 大文件读取尽量流式处理,避免无意义地把完整文件读入内存。 + +## 工具定位 + +`read_file` 用于读取 workspace 内的 UTF-8 文本文件。 + +适合: + +- 查看源码、配置、文档。 +- 根据 `search_code` 返回的行号读取上下文。 +- 读取文件开头或末尾。 +- 在编辑前确认目标文件内容。 + +不适合: + +- 搜索文件名:使用 `search_files`。 +- 搜索文件内容:使用 `search_code`。 +- 读取二进制文件、图片、压缩包。 +- 读取极大文件的完整内容。 + +## 参数语义 + +常用参数: + +| 参数 | 说明 | +| --- | --- | +| `path` | 必填。workspace 相对路径或 workspace 内绝对路径。 | +| `head` | 可选。读取文件开头 N 行。 | +| `tail` | 可选。读取文件末尾 N 行。 | +| `start_line` | 可选。1-based 起始行,必须和 `end_line` 一起使用。 | +| `end_line` | 可选。1-based 结束行,包含该行,必须和 `start_line` 一起使用。 | +| `show_line_numbers` | 可选。返回文本中给每行加 `12: ` 形式的行号前缀。 | + +互斥规则: + +- `head` 和 `tail` 不能同时使用。 +- `head` 或 `tail` 不能和 `start_line/end_line` 混用。 +- `start_line` 和 `end_line` 必须成对出现。 +- `start_line` 必须小于等于 `end_line`。 + +## 推荐用法 + +读取文件开头: + +```json +{ + "path": "gateway-vscode/src/tools/readFileTool.ts", + "head": 120, + "show_line_numbers": true +} +``` + +读取文件末尾: + +```json +{ + "path": "logs/app.log", + "tail": 100 +} +``` + +读取明确行号范围: + +```json +{ + "path": "gateway-vscode/src/tools/readFileTool.ts", + "start_line": 120, + "end_line": 220, + "show_line_numbers": true +} +``` + +## 读取模型 + +`read_file` 执行时先用 `fs.stat` 获取文件大小,然后按参数选择读取路径。 + +### 无行选择参数 + +如果没有 `head`、`tail`、`start_line/end_line`: + +- 文件不超过输出字节上限时,读取完整文件后统一做输出限制。 +- 文件超过输出字节上限时,只读取前缀,再统一做输出限制。 + +前缀读取会处理 UTF-8 边界,避免在多字节字符中间截断。 + +### `head` 和 `start_line/end_line` + +大文件会走正向流式读取: + +- `head` 从第 1 行开始读到目标行数。 +- `start_line/end_line` 从文件开头扫描到目标范围。 +- 到达目标结束行后提前停止。 + +### `tail` + +`tail` 需要知道文件末尾的最后 N 行,因此当前实现会流式扫描完整文件,只保留最近 N 行。 + +## 输出保护 + +`read_file` 对所有返回路径使用统一输出限制: + +```text +最多 1000 行 +最多 128KB 文本 +``` + +只要输出超过任一限制,就会截断返回内容,并在文本末尾追加提示。 + +截断原因写入 metadata: + +| `reason` | 含义 | +| --- | --- | +| `line_limit` | 超过最大返回行数。 | +| `byte_limit` | 超过最大返回字节数。 | +| `line_and_byte_limit` | 同时触发行数和字节限制。 | + +这些限制是内部保护,不作为工具入参暴露。AI 如果需要更多内容,应使用更窄的 `start_line/end_line`、`head` 或 `tail` 分段读取。 + +## 返回 metadata + +`read_file` 默认只返回文本内容。只有发生截断时,才通过 `structuredContent` 返回简短 metadata。 + +主要字段: + +| 字段 | 说明 | +| --- | --- | +| `truncated` | 固定为 `true`,表示发生输出截断。 | +| `reason` | 截断原因。 | +| `lineCountKnown` | `lineCount` 是否可信。 | +| `lineCount` | 文件总行数,只有已知时返回。 | +| `returnedLines` | 实际返回的行号范围。 | +| `returnedBytes` | 截断时返回文本的字节数。 | +| `fileBytes` | 文件大小。 | + +`lineCountKnown` 很重要:当工具只读取大文件前缀或提前停止流式扫描时,可能无法知道文件总行数。 + +未截断时不返回 `structuredContent`,避免 `read_file` 作为高频工具在上下文中反复制造重复 metadata。 + +## 行号行为 + +`show_line_numbers: true` 会在返回文本中给每行加 1-based 行号前缀,例如: + +```text +12: const value = readConfig(); +``` + +这个格式适合定位和代码审查,但不是文件原文。后续使用 `write_file` 或 `edit_file` 时不能把行号前缀写回文件。 + +## 实现拆分 + +相关实现位于: + +- `gateway-vscode/src/tools/readFileTool.ts`:工具 schema、参数校验、metadata 和读取流程。 +- `gateway-vscode/src/tools/readFileLineStream.ts`:按行流式读取 `head`、`tail`、范围。 +- `gateway-vscode/src/tools/readFileOutputLimit.ts`:统一输出限制、截断原因和行号格式化。 +- `gateway-vscode/src/tools/readFilePrefix.ts`:大文件前缀读取和 UTF-8 边界处理。 + +## 已知边界 + +- `read_file` 只按 UTF-8 文本处理文件,不做编码自动探测。 +- `tail` 当前需要扫描完整文件,对超大日志会比正向范围读取更慢。 +- `lineCount` 在前缀读取或提前停止的范围读取中可能未知。 +- 输出限制会尽量保留完整行;如果单行超过字节上限,会截断该行文本。 +- `show_line_numbers` 改变返回文本格式,不适合作为编辑原文直接使用。 + +## 后续可扩展方向 + +- 对 `tail` 增加反向分块读取,减少超大日志的扫描成本。 +- 增加轻量文件画像工具或 metadata-only 模式,返回行数、最长行、是否疑似二进制等信息。 +- 为 Markdown 或 TypeScript 文件提供可选 outline,帮助 AI 先了解文件结构再选择范围。 diff --git a/doc/SEARCH_TOOLS_GUIDE.md b/doc/SEARCH_TOOLS_GUIDE.md new file mode 100644 index 0000000..e6a8884 --- /dev/null +++ b/doc/SEARCH_TOOLS_GUIDE.md @@ -0,0 +1,508 @@ +# search_files 与 search_code 设计说明 + +本文说明 `search_files` 和 `search_code` 两个内置搜索工具的目标、参数语义、底层实现和已知边界,方便开发者维护,也方便用户理解 AI 在 workspace 中查找文件和代码时发生了什么。 + +## 背景 + +webcode 早期的文件查找和代码查找采用了两套不同实现: + +- `search_files` 基于 VS Code `workspace.findFiles`,先用 query 生成 include glob,再做二次过滤。 +- `search_code` 基于 ripgrep 搜索文件内容,并在 ripgrep 不可用时使用进程内 fallback。 + +这会导致 `search_files` 在一些场景下不够稳定。例如用户输入 `webfetch`,实际目录叫 `WebFetchTool`,二次过滤虽然不区分大小写,但 VS Code include glob 阶段可能已经没有把候选文件取回来。 + +优化后的目标是: + +- 文件搜索和代码搜索都优先复用 ripgrep。 +- 工具参数表达清晰,不暴露 shell 命令和跨平台 quoting 细节。 +- 搜索结果不因是否安装了 ripgrep 而改变语义。 +- 默认限制输出量,避免一次搜索占满模型上下文。 + +## 工具定位 + +### search_files + +`search_files` 用于按文件名或 workspace 相对路径查找文件。它不读取文件内容,只返回匹配文件路径。 + +适合: + +- 不确定文件准确路径时先定位文件。 +- 查找某类文件,例如测试文件、配置文件、组件文件。 +- 列出某个目录下可读文件。 +- 在编辑或读取前确认目标路径。 + +### search_code + +`search_code` 用于搜索文件内容。它返回 workspace 相对路径、行号和命中行。 + +适合: + +- 查找函数、变量、配置项、错误信息等文本。 +- 根据入口关键词追踪调用链。 +- 在读取大文件前先定位相关行号。 +- 搜索打包后或长文件中的关键片段。 + +## search_files 参数语义 + +常用参数: + +| 参数 | 说明 | +| --- | --- | +| `path` | 搜索根目录,默认 `.`。必须是 workspace 内的单个目录。 | +| `query` | 文件名或相对路径查询,默认 `*`,表示列出 `path` 下文件。 | +| `match` | query 解释方式:`auto`、`substring`、`glob`,默认 `auto`。 | +| `case_sensitive` | 是否区分大小写,默认 `false`。 | +| `max_results` | 最多返回多少个匹配文件,默认 200。 | +| `exclude_patterns` | 额外排除的 glob 模式,会和内置默认排除目录合并,完整列表见“排除规则”。通常按本次 `path` 搜索根下的相对路径生效;裸名称会扩展为任意层级匹配。 | + +### path + +`path` 是搜索根目录,不是匹配条件。 + +示例: + +```json +{ + "path": "gateway-vscode/src/tools", + "query": "*" +} +``` + +这表示列出 `gateway-vscode/src/tools` 下符合规则的文件。 + +支持: + +- `.`:workspace 根目录。 +- `gateway-vscode/src/tools`:workspace 相对目录。 +- workspace 内的绝对路径。 + +不支持: + +- 多个目录。 +- `|` 分隔目录。 +- glob 目录,例如 `src/**`。 +- 指向文件的路径。 + +### query + +`query` 是文件名或相对路径匹配条件。未提供或为空时等价于 `*`。 + +`query: "*"` 表示列出文件,不是普通星号字符。 + +`query: "."` 只是字面量点号,通常会匹配带扩展名的文件,但不等价于当前目录。 + +`query: "foo|bar"` 中的 `|` 不表示 OR,只是普通字符。简单多选应使用 glob brace,例如 `*{foo,bar}*`。 + +## search_files match 模式 + +### auto + +默认模式。规则是: + +- query 含 `*`、`?`、`{`、`}` 时按 glob 解释。 +- 其他情况按普通子串解释。 + +示例: + +```json +{ + "query": "searchFiles" +} +``` + +按普通子串搜索。 + +```json +{ + "query": "**/*.test.ts" +} +``` + +按 glob 搜索。 + +### substring + +强制把 query 当普通字面子串。不会解释 `*`、`?`、`{}`。 + +示例: + +```json +{ + "query": "WebFetch", + "match": "substring" +} +``` + +默认不区分大小写,因此也能匹配 `webfetch`、`WEBFETCH` 等大小写变体。 + +### glob + +强制把 query 当 glob。 + +常见示例: + +| query | 含义 | +| --- | --- | +| `*` | 匹配所有文件。 | +| `*.ts` | 匹配当前搜索根下所有 `.ts` 文件,实际对文件名也会匹配。 | +| `**/*.test.ts` | 匹配所有测试文件。 | +| `src/**/*.ts` | 匹配 `src` 下所有层级的 `.ts` 文件。 | +| `*.{ts,tsx}` | 匹配 `.ts` 或 `.tsx` 文件。 | +| `*{foo,bar}*` | 匹配路径或文件名中包含 `foo` 或 `bar` 的文件。 | + +## search_code 参数语义 + +常用参数: + +| 参数 | 说明 | +| --- | --- | +| `path` | 搜索根目录,默认 `.`。必须是 workspace 内目录。 | +| `query` | 要搜索的文本。 | +| `match` | query 解释方式:`substring` 或 `regex`,默认 `substring`。 | +| `include` | 可选 include glob,例如 `**/*.ts`。 | +| `case_sensitive` | 是否区分大小写,默认 `false`。 | +| `max_results` | 最多返回多少条命中行,默认 100。 | +| `max_line_chars` | 每条命中行最多返回多少字符,默认 500。 | +| `exclude_patterns` | 额外排除的 glob 模式,会和内置默认排除目录合并,完整列表见“排除规则”。通常按本次 `path` 搜索根下的相对路径生效;裸名称会扩展为任意层级匹配。 | + +`search_code` 不再使用 `use_regex`。是否启用正则只由 `match` 控制。 + +## search_code match 模式 + +### substring + +默认模式。query 按字面文本做“包含匹配”。 + +示例: + +```json +{ + "query": "preventDefault" +} +``` + +可以命中: + +```ts +event.preventDefault(); +``` + +在该模式下: + +- `.` 是普通点号。 +- `|` 是普通竖线。 +- `*` 是普通星号。 +- `(`、`)`、`[`、`]` 都是普通字符。 + +### regex + +`match: "regex"` 时,query 按 ripgrep 正则表达式解释。 + +示例: + +```json +{ + "query": "preventDefault|stopPropagation", + "match": "regex" +} +``` + +可以匹配 `preventDefault` 或 `stopPropagation`。 + +更多示例: + +| query | 含义 | +| --- | --- | +| `function\\s+\\w+` | 匹配函数声明片段。 | +| `search_(files|code)` | 匹配 `search_files` 或 `search_code`。 | +| `\\brequest_id\\b` | 匹配完整词 `request_id`。 | + +注意:ripgrep 可用时使用 ripgrep 正则;ripgrep 不可用时 fallback 使用 JavaScript `RegExp`。两者在少数高级正则语法上可能不完全一致。 + +## 大小写规则 + +两个搜索工具默认都不区分大小写: + +```json +{ + "query": "webfetch" +} +``` + +可以匹配 `WebFetchTool`。 + +需要严格大小写时传: + +```json +{ + "query": "WebFetch", + "case_sensitive": true +} +``` + +## 排除规则 + +`exclude_patterns` 不会覆盖默认排除项,而是与默认排除项合并。 + +内置默认排除目录包括: + +- `.git` +- `node_modules` +- `.pnpm-store` +- `.vscode-test` +- `.next` +- `.nuxt` +- `.svelte-kit` +- `.turbo` +- `.cache` +- `.parcel-cache` +- `.pytest_cache` +- `.mypy_cache` +- `.ruff_cache` +- `.tox` +- `.venv` +- `venv` +- `.gradle` +- `dist` +- `out` +- `build` +- `target` +- `coverage` + +因此实际排除集合是: + +```text +内置默认排除目录 + exclude_patterns +``` + +### 默认排除目录的生效范围 + +默认排除目录用于阻止搜索从当前搜索根继续递归进入这些目录。它不会阻止用户把 `path` 直接设置到这些目录内部。 + +例如,从 workspace 根目录搜索: + +```json +{ + "path": ".", + "query": "react" +} +``` + +默认不会进入 `node_modules`。 + +但明确搜索某个库: + +```json +{ + "path": "node_modules/react", + "query": "*" +} +``` + +可以列出 `node_modules/react` 下的文件。此时默认排除规则不会因为搜索根本身位于 `node_modules` 而拒绝整个搜索;但如果这个目录里面还有嵌套的 `node_modules`、`dist`、`build` 等默认排除目录,仍会被跳过。 + +### exclude_patterns 的匹配基准 + +`exclude_patterns` 推荐按本次 `path` 搜索根下的相对路径来写。 + +例如: + +```json +{ + "path": "gateway-vscode/src", + "query": "*.ts", + "exclude_patterns": ["**/*.test.ts"] +} +``` + +会排除 `gateway-vscode/src` 下任意层级的 `.test.ts`。 + +如果 pattern 不含 `/` 且不含 glob 语法,会被扩展为“任意层级同名文件或目录”: + +```json +{ + "exclude_patterns": ["fixtures"] +} +``` + +会按类似下面的规则处理: + +```text +fixtures +**/fixtures +**/fixtures/** +``` + +因此裸名称适合排除某个目录名或文件名。 + +如果 pattern 包含 `/`,它通常按搜索根相对路径匹配: + +```json +{ + "path": "node_modules/react", + "exclude_patterns": ["cjs/**"] +} +``` + +会排除 `node_modules/react/cjs` 下的文件。 + +需要注意:如果已经把 `path` 设到某个子目录里,再写 workspace 根目录风格的排除路径,可能不会按预期生效。 + +例如: + +```json +{ + "path": "node_modules/react", + "exclude_patterns": ["node_modules/react/cjs/**"] +} +``` + +对 `search_code` 来说,这类 pattern 通常不会匹配,因为 ripgrep 看到的是相对 `node_modules/react` 的路径,例如 `cjs/react.development.js`,而不是 `node_modules/react/cjs/react.development.js`。 + +推荐写法是: + +```json +{ + "path": "node_modules/react", + "exclude_patterns": ["cjs/**"] +} +``` + +`search_files` 在最终结果过滤时会同时检查搜索根相对路径和 workspace 相对路径,因此对某些 workspace 根目录风格 pattern 更宽容;但为了让 `search_files` 和 `search_code` 心智一致,仍推荐按当前 `path` 内的相对路径写。 + +示例: + +```json +{ + "query": "*.ts", + "exclude_patterns": ["**/*.test.ts"] +} +``` + +这会在默认排除目录之外,再排除所有 `.test.ts` 文件。 + +### rg ignore 文件行为 + +`exclude_patterns` 是 webcode 工具参数;`.gitignore`、`.ignore`、`.rgignore` 是 ripgrep/git 的 ignore 机制。两个搜索工具在这里有意保持不同策略: + +| 工具 | ripgrep ignore 行为 | 原因 | +| --- | --- | --- | +| `search_files` | 使用 `rg --files --no-ignore`,不尊重 `.gitignore`、`.ignore`、`.rgignore` 或全局 ignore。 | 文件发现要尽量完整,并和 fallback walker 行为一致。 | +| `search_code` | 使用 ripgrep 默认 ignore 行为,会尊重 `.gitignore`、`.ignore`、`.rgignore` 等。 | 内容搜索更容易扫到大量生成文件或依赖源码,默认遵守项目 ignore 更稳。 | + +因此: + +- 想“发现文件是否存在”,优先用 `search_files`。 +- 想“搜索被 ignore 文件的内容”,如果已知路径,优先用 `read_file` 直接读取。 +- 想看默认排除目录里的依赖源码,把 `path` 直接设置到具体库目录,例如 `node_modules/react`。 + +## ripgrep 与 fallback + +两个工具都优先使用 ripgrep。ripgrep 发现顺序是: + +1. `webcodeGateway.ripgrep.path` 用户配置。 +2. VS Code bundled ripgrep。 +3. PATH 中的 `rg`。 + +如果 ripgrep 启动失败: + +- `search_code` 使用进程内文本扫描 fallback。 +- `search_files` 使用 workspace 文件遍历 fallback。 + +fallback 的目标是保持工具可用,但速度和能力可能弱于 ripgrep。 + +## search_files 与 .gitignore + +`search_files` 的 ripgrep 文件枚举会传 `--no-ignore`。这意味着它不依赖 `.gitignore`、`.ignore`、`.rgignore` 或全局 ignore 文件决定候选列表。 + +原因是 fallback walker 本身不读取 `.gitignore`。如果 ripgrep 路径尊重 `.gitignore`,而 fallback 路径不尊重,就会出现“有 rg 时搜不到、没 rg 时搜得到”的不一致。 + +因此 `search_files` 的文件可见性由 webcode 自己控制: + +```text +workspace 范围 + 内置默认排除目录 + exclude_patterns +``` + +而不是由 git ignore 文件控制。 + +## 输出格式 + +### search_files + +返回匹配文件的 workspace 相对路径,每行一个: + +```text +gateway-vscode/src/tools/searchFilesTool.ts +gateway-vscode/src/unit-test/searchFilesTool.test.ts +``` + +无匹配时,会返回搜索参数摘要和常见误用提示。例如: + +```text +No matches found. +Searched path: . +Query: . +Match: auto (substring) +Case sensitive: false +Hint: query "." matches a literal dot. Use query "*" to list files. +``` + +### search_code + +返回格式是: + +```text +relative/path.ts:123: matching line text +``` + +长行会围绕命中位置截断,并提示省略字符数量,避免大文件或打包文件撑爆上下文。 + +## 推荐使用方式 + +先用 `search_files` 定位文件,再用 `read_file` 读取: + +```json +{ + "query": "searchFilesTool", + "match": "substring" +} +``` + +找到文件后: + +```json +{ + "path": "gateway-vscode/src/tools/searchFilesTool.ts", + "start_line": 1, + "end_line": 120, + "show_line_numbers": true +} +``` + +先用 `search_code` 定位行号,再用 `read_file` 读取上下文: + +```json +{ + "query": "createRipgrepFilesArgs", + "match": "substring" +} +``` + +然后按返回行号读取附近范围。 + +## 已知边界 + +- `search_code` 当前只返回命中行,不返回前后文。需要上下文时继续用 `read_file` 按行号读取。 +- `search_files` 暂不支持 `queries: []` 多 query 字段。简单 OR 可用 glob brace,例如 `*{foo,bar}*`。 +- `path` 只支持单个目录,不支持多目录或 glob 目录。 +- `exclude_patterns` 只能增加排除项,不能关闭内置默认排除目录。 +- `search_code` fallback 会跳过过大的文件和二进制文件,能力弱于 ripgrep。 +- `search_code` 正则 fallback 使用 JavaScript `RegExp`,和 ripgrep 正则存在少数语法差异。 + +## 后续可扩展方向 + +- 给 `search_code` 增加 `context_lines`,直接返回命中行前后文。 +- 给 `search_files` 增加 `queries`,支持多个查询条件,不依赖 glob brace。 +- 增加 `use_default_excludes: false`,允许高级用户搜索 `dist`、`build` 等默认排除目录。 +- 增加更结构化的返回内容,例如总候选数、是否截断、实际匹配模式。 +- 为 `search_code` 正则模式补充更多 ripgrep 与 fallback 差异测试。 diff --git a/gateway-vscode/package.json b/gateway-vscode/package.json index 202e918..1fe1409 100644 --- a/gateway-vscode/package.json +++ b/gateway-vscode/package.json @@ -9,7 +9,7 @@ }, "license": "MIT", "icon": "images/icon.png", - "version": "0.8.4", + "version": "0.9.1", "engines": { "vscode": "^1.106.1" }, @@ -137,7 +137,7 @@ "webcodeGateway.ripgrep.path": { "type": "string", "default": "", - "description": "Optional absolute path to ripgrep for search_code. Leave empty to use VS Code's bundled ripgrep or rg from PATH." + "description": "Optional absolute path to ripgrep for search_files and search_code. Leave empty to use VS Code's bundled ripgrep or rg from PATH." }, "webcodeGateway.skillDirectories": { "type": "array", diff --git a/gateway-vscode/prompts/error_hint_en.md b/gateway-vscode/prompts/error_hint_en.md index 3778297..c0dceb7 100644 --- a/gateway-vscode/prompts/error_hint_en.md +++ b/gateway-vscode/prompts/error_hint_en.md @@ -1,6 +1,6 @@ ❌ **Format Error Warning** -Your model response content does not meet the requirements. Top-level fields may only be `mcp_action`, `name`, `purpose`, `arguments`, and `request_id`. `name` and `purpose` are required. If the selected tool has inputs, `arguments` must exactly match that tool's `inputSchema`. +Your model response content does not meet the requirements. Top-level fields may only be `mcp_action`, `name`, `purpose`, `arguments`, and `request_id`. `name` and `purpose` are required. If the selected tool has inputs, `arguments` must exactly match that tool's `inputSchema`. `request_id` must be new for every tool call in this conversation. ```json { @@ -10,7 +10,7 @@ Your model response content does not meet the requirements. Top-level fields may "arguments": { "key": "value" }, - "request_id": "step_x" + "request_id": "turn_unique_step_x" } ``` diff --git a/gateway-vscode/prompts/error_hint_zh.md b/gateway-vscode/prompts/error_hint_zh.md index 1313185..fbd6dfd 100644 --- a/gateway-vscode/prompts/error_hint_zh.md +++ b/gateway-vscode/prompts/error_hint_zh.md @@ -1,6 +1,6 @@ ❌ **格式错误警告 (Format Error)** -你的模型响应内容不符合要求。顶层字段只能包含 `mcp_action`、`name`、`purpose`、`arguments`、`request_id`。`name` 和 `purpose` 必填;如果所选工具有入参,`arguments` 必须严格匹配该工具的 `inputSchema`。 +你的模型响应内容不符合要求。顶层字段只能包含 `mcp_action`、`name`、`purpose`、`arguments`、`request_id`。`name` 和 `purpose` 必填;如果所选工具有入参,`arguments` 必须严格匹配该工具的 `inputSchema`。`request_id` 必须是本会话中每次工具调用的新值。 ```json { @@ -10,7 +10,7 @@ "arguments": { "key": "value" }, - "request_id": "step_x" + "request_id": "turn_unique_step_x" } ``` diff --git a/gateway-vscode/prompts/init_en.md b/gateway-vscode/prompts/init_en.md index 56f8066..728a092 100644 --- a/gateway-vscode/prompts/init_en.md +++ b/gateway-vscode/prompts/init_en.md @@ -8,6 +8,6 @@ Initialization command: "mcp_action": "call", "name": "{{INIT_TOOL_NAME}}", "purpose": "Initialize {{PRODUCT_NAME}} for this conversation", - "request_id": "step_1" + "request_id": "init_unique_1" } ``` diff --git a/gateway-vscode/prompts/init_zh.md b/gateway-vscode/prompts/init_zh.md index 803c53e..6a56788 100644 --- a/gateway-vscode/prompts/init_zh.md +++ b/gateway-vscode/prompts/init_zh.md @@ -8,6 +8,6 @@ "mcp_action": "call", "name": "{{INIT_TOOL_NAME}}", "purpose": "初始化本次会话的 {{PRODUCT_NAME}}", - "request_id": "step_1" + "request_id": "init_unique_1" } ``` diff --git a/gateway-vscode/prompts/prompt_en.md b/gateway-vscode/prompts/prompt_en.md index a9f8ae8..5bc41fa 100644 --- a/gateway-vscode/prompts/prompt_en.md +++ b/gateway-vscode/prompts/prompt_en.md @@ -1,12 +1,57 @@ # Role Setup -You are an AI assistant. In this session, the user has mounted new capabilities to interact with the local environment (via JSON commands). -These tools and skills are your extended capabilities, and the specific functions (such as file operations, code management, etc.) are dynamically configured. Please judge flexibly whether to call these tools to assist in completing tasks according to the user's specific needs. +You are an AI assistant. This conversation already has {{PRODUCT_NAME}} attached. +{{PRODUCT_NAME}} connects the web AI to the user's local VS Code workspace and exposes local tools, third-party MCP tools, and workspace Skills through the JSON protocol below. +These capabilities are dynamically configured. The current {{PRODUCT_NAME}} Available Tools and {{PRODUCT_NAME}} Available Skills in context are the source of truth. + +# {{PRODUCT_NAME}} Capabilities +{{PRODUCT_NAME}} provides capabilities in the user's local VS Code environment, not in the web AI platform's built-in remote sandbox. +It can typically be used to: +- Read, search, create, and modify files inside the user's local VS Code workspace. +- Run project scripts, builds, tests, package-manager commands, and git commands. +- Use third-party MCP server tools configured locally by the user. +- Load and follow Skills exposed by the current workspace. + +Do not send the initialization command again; this prompt already contains the {{PRODUCT_NAME}} protocol, rules, and available-capability context. + +# Environment Boundary +You may see both web AI platform built-in tools and {{PRODUCT_NAME}} tools exposed through the JSON protocol. They do not run in the same environment. + +- Web AI platform built-in tools run in the platform's own remote environment or sandbox. They cannot access the user's local VS Code workspace, real file paths, git state, dependency environment, terminal sessions, local MCP servers, or local Skills. +- {{PRODUCT_NAME}} tools are called through the JSON protocol defined in this prompt. They are the only trusted channel for accessing the user's local VS Code workspace, local files, project commands, git, MCP servers, and Skills. +- Do not treat paths, files, command output, or Python results from the web AI sandbox as the real state of the user's local VS Code workspace. Any user-project state must be confirmed through tools in {{PRODUCT_NAME}} Available Tools. + +# Tool Selection Priority +When the task involves any of the following, you must prioritize {{PRODUCT_NAME}} JSON protocol tools. Do not use the web AI platform's built-in Python, shell, computer, filesystem, or sandbox tools: +- The user's local VS Code workspace, repository, files, paths, or directories. +- Reading, searching, modifying, creating, or deleting local files. +- Running project scripts, builds, tests, package managers, or git commands. +- Using MCP servers or Skills from the current workspace. +- Checking whether dependencies are installed, ports are occupied, tests pass, or what the actual workspace state is. + +Only consider web AI platform built-in tools when the task clearly needs public internet information, general knowledge lookup, or pure computation unrelated to the user's local workspace. +Public internet information is only for external fact lookup. It must not replace confirmation of local project files, dependency versions, test results, or runtime state. + +# Local Task Workflow +When the user asks you to analyze, modify, or test the current project: +1. First use `search_files`, `search_code`, and `read_file` to understand the existing implementation. +2. Read the relevant files before editing them. Do not edit code from memory or guesses. +3. Prefer `edit_file` for existing files. Use `write_file` only when creating a new file or when the user explicitly asks for a complete rewrite. +4. After editing, choose builds, tests, lint, or relevant project scripts based on the risk of the change. +5. If a tool returns an error, first correct the tool call or implementation based on that error. Do not invent a successful result. + +# Coding Task Behavior +- Unless the user explicitly asks to discuss, plan, or explain, complete the task directly when feasible. +- Follow the current codebase's existing structure, naming, style, and toolchain. Do not introduce unnecessary abstractions. +- Keep changes focused on the user's request. Do not proactively fix unrelated issues; if you find unrelated risks, briefly mention them in the final reply. +- For verification, start with the build, test, or lint command most directly related to the change, then expand only as risk requires. +- When finished, briefly state what changed, what was verified, and any remaining gaps or risk. # Protocol When calling tools, you must output a **JSON code block**, not plain text or inline JSON. ## 1. Request Format (You send to plugin) Top-level fields may only be `mcp_action`, `name`, `purpose`, `arguments`, and `request_id`. `name` and `purpose` are required. If the selected tool has inputs, `arguments` must exactly match that tool's `inputSchema`. +Every tool call must use a new `request_id` that has not appeared earlier in this conversation. Do not reuse `step_1`, `step_2`, or any previous value in later replies. Use the tool `name` exactly as listed. Local/internal tools use bare names such as `read_file`; third-party MCP tools use `server:tool` names such as `github:search_repositories`. ```json @@ -17,7 +62,7 @@ Use the tool `name` exactly as listed. Local/internal tools use bare names such "arguments": { "key": "value" }, - "request_id": "step_x" + "request_id": "turn_ab12_step_x" } ``` @@ -26,14 +71,14 @@ After execution, the plugin will return the result in the following format: ```json { "mcp_action": "result", - "request_id": "step_x", + "request_id": "turn_ab12_step_x", "output": "File content or command execution result..." } ``` # Core Rules -1. **No Guessing**: Do not assume you have a tool. Rely on the Available Tools list already present in the current context. -2. **Sequential Execution**: You can output multiple JSON blocks at once to call multiple tools. webcode will execute them one by one in appearance order and return the results in a batch after all of them finish. Note: One JSON block cannot contain multiple tool calls; each tool call should be in a separate JSON block. +1. **No Guessing**: Do not assume you have a tool. Rely on the {{PRODUCT_NAME}} Available Tools list already present in the current context. Even if the web AI interface shows other tools, tasks involving the local VS Code workspace must use {{PRODUCT_NAME}} Available Tools as the source of truth. +2. **Sequential Execution**: You can output multiple JSON blocks at once to call multiple tools. {{PRODUCT_NAME}} will execute them one by one in appearance order and return the results in a batch after all of them finish. Note: One JSON block cannot contain multiple tool calls; each tool call should be in a separate JSON block. Only output multiple JSON blocks in the same reply when the calls are independent. If a later call depends on an earlier result, wait for the plugin result first. Correct example: ```json { @@ -43,7 +88,7 @@ Correct example: "arguments": { "command": "git tag --list --sort=-v:refname" }, - "request_id": "step_1" + "request_id": "turn_ab12_step_1" } ``` ```json @@ -54,7 +99,7 @@ Correct example: "arguments": { "command": "git status --short" }, - "request_id": "step_2" + "request_id": "turn_ab12_step_2" } ``` Incorrect example: @@ -66,7 +111,7 @@ Incorrect example: "arguments": { "command": "git tag --list --sort=-v:refname" }, - "request_id": "step_1" + "request_id": "turn_ab12_step_1" }, { "mcp_action": "call", @@ -75,14 +120,31 @@ Incorrect example: "arguments": { "command": "git status --short" }, - "request_id": "step_2" + "request_id": "turn_ab12_step_2" }] ``` 3. **No Questions Alongside Tool Calls**: If your current reply includes any tool call, do not ask the user a question in the same reply. The next message will usually be a tool result, so the user cannot answer you first. 4. **Tool Grouping**: The tool list is grouped by server source, and every available tool is shown with its full definition in the `tools` array. Third-party MCP tool names include their server prefix (`server:tool`); bare names are reserved for local/internal tools. -5. **Prefer Dedicated File Tools**: For workspace file discovery, use `search_files`. For code or text search, use `search_code`. For reading file content or specific line ranges, use `read_file`. Do not use `execute_command` with shell commands such as `grep`, `rg`, `find`, `cat`, `sed`, `awk`, or `nl` just to inspect files. +5. **Prefer Dedicated File Tools**: For workspace file discovery, use `search_files`. For code or text search, use `search_code`. For reading file content or specific line ranges, use `read_file`. Prefer `edit_file` for existing files. Use `write_file` only when creating a new file or when the user explicitly asks for a complete rewrite. Do not use `execute_command` with shell commands such as `grep`, `rg`, `find`, `cat`, `sed`, `awk`, or `nl` just to inspect files. 6. **Command Tool Scope**: Use `execute_command` for builds, tests, package managers, git commands, and project scripts. Use `run_in_terminal` only for long-running or visible terminal work. -7. **Skills & Progressive Loading**: If the initialization context includes Available Skills, the current workspace exposes local skills. - - When the user needs a workflow, template, domain guide, installation help, or other specialized capability, choose the appropriate skill from Available Skills by `name`, `description`, and path metadata. +7. **Skills & Progressive Loading**: If the initialization context includes {{PRODUCT_NAME}} Available Skills, the current workspace exposes local skills. + - When the user needs a workflow, template, domain guide, installation help, or other specialized capability, choose the appropriate skill from {{PRODUCT_NAME}} Available Skills by `name`, `description`, and path metadata. - Before using a skill, call `read_file` with that entry's `skillFilePath` to read the corresponding `SKILL.md`. Do not infer the instructions from the name alone. - If `SKILL.md` references text resources under `references/`, `templates/`, or similar directories, load them on demand with `read_file`; if it requires running `scripts/` or project scripts, use `execute_command` for short tasks and `run_in_terminal` for long-running or visible terminal work. +8. **Path Rules**: Paths passed to file tools should preferably be workspace-relative paths and use `/` separators. Do not pass web AI sandbox paths, temporary paths, or guessed absolute paths to local file tools. +9. **Destructive Operation Constraint**: Do not proactively run clearly destructive operations, such as deleting many files, emptying directories, resetting git history, force-pushing, or installing or uninstalling dependencies, unless the user explicitly asks for it or you first get confirmation. + +# Tool Usage Examples And Counterexamples +Counterexamples: +- Do not use the web AI platform's built-in Python to read `/mnt/data`, `/workspace`, or similar paths to infer the user's project files. +- Do not use the web AI platform's built-in shell to run `ls`, `cat`, `pytest`, or `npm test` to inspect the user's local repository. +- Do not use the web AI platform's built-in browser or computer tools to pretend to operate on VS Code files. + +Correct examples: +- Find workspace files: use `search_files`. +- Search code or text content: use `search_code`. +- Read file content: use `read_file`. +- Modify existing files: prefer `edit_file`. +- Create new files or completely rewrite files: use `write_file`. +- Build, test, run git, package managers, and project scripts: use `execute_command`. +- Run long-lived services or visible terminal work: use `run_in_terminal`. diff --git a/gateway-vscode/prompts/prompt_zh.md b/gateway-vscode/prompts/prompt_zh.md index 1f50058..8356ba0 100644 --- a/gateway-vscode/prompts/prompt_zh.md +++ b/gateway-vscode/prompts/prompt_zh.md @@ -1,12 +1,57 @@ # 角色设定 -你是一个 AI 助手。在此会话中,用户为你挂载了与本地环境交互的新能力(通过 JSON 指令)。 -这些工具和 skills 是你的扩展能力,具体包含的功能(如文件操作、代码管理等)是动态配置的。请根据用户的具体需求,灵活判断是否调用这些工具来辅助完成任务。 +你是一个 AI 助手。本次会话已经挂载了 {{PRODUCT_NAME}}。 +{{PRODUCT_NAME}} 将网页 AI 与用户本地 VS Code 工作区连接起来,并通过下方 JSON 协议为你提供本地工具、第三方 MCP 工具和工作区 Skills。 +这些能力是动态配置的,具体以当前上下文中的 {{PRODUCT_NAME}} Available Tools 和 {{PRODUCT_NAME}} Available Skills 为准。 + +# {{PRODUCT_NAME}} 能力说明 +{{PRODUCT_NAME}} 提供的是用户本地 VS Code 环境中的能力,不是网页 AI 平台自带的远程沙箱能力。 +它通常可以用于: +- 读取、搜索、创建、修改用户本地 VS Code 工作区内的文件。 +- 运行项目脚本、构建、测试、包管理器命令和 git 命令。 +- 使用用户在本地配置的第三方 MCP server 工具。 +- 按需加载和遵循当前工作区提供的 Skills。 + +不要再次发送初始化命令;当前提示词已经包含 {{PRODUCT_NAME}} 的协议、规则和可用能力上下文。 + +# 环境边界 +你可能同时看到网页 AI 平台自带工具和 {{PRODUCT_NAME}} 通过 JSON 协议提供的工具。二者不在同一个环境中。 + +- 网页 AI 平台自带工具运行在平台自己的远程环境或沙箱中,不能访问用户本地 VS Code 工作区、真实文件路径、git 状态、依赖环境、终端会话、本地 MCP server 或本地 Skills。 +- {{PRODUCT_NAME}} 工具通过本提示词规定的 JSON 协议调用,是你访问用户本地 VS Code 工作区、本地文件、项目命令、git、MCP server 和 Skills 的唯一可信通道。 +- 不要把网页 AI 沙箱中的路径、文件、命令输出或 Python 运行结果当作用户本地 VS Code 工作区的真实状态。凡是涉及用户项目状态,必须通过 {{PRODUCT_NAME}} Available Tools 中的工具确认。 + +# 工具选择优先级 +当任务涉及以下内容时,必须优先使用 {{PRODUCT_NAME}} JSON 协议工具,不要使用网页 AI 平台自带的 Python、shell、computer、文件系统或沙箱工具: +- 用户本地 VS Code 工作区、仓库、文件、路径或目录。 +- 读取、搜索、修改、创建、删除本地文件。 +- 运行项目脚本、构建、测试、包管理器或 git 命令。 +- 使用当前工作区的 MCP server 或 Skills。 +- 判断依赖是否安装、端口是否占用、测试是否通过、工作区实际状态。 + +只有在任务明确需要公共互联网信息、通用知识查询,或与用户本地工作区无关的纯计算时,才考虑使用网页 AI 平台自带工具。 +公共互联网信息只能用于外部事实查询,不能替代对本地项目文件、依赖版本、测试结果和运行状态的确认。 + +# 本地任务工作流 +当用户请求分析、修改、测试当前项目时: +1. 先用 `search_files`、`search_code`、`read_file` 了解现有实现。 +2. 修改文件前先读取相关文件,不要凭记忆或猜测改代码。 +3. 修改已有文件优先使用 `edit_file`;只有在创建新文件,或用户明确要求完整重写文件时,才使用 `write_file`。 +4. 修改后根据任务风险选择运行构建、测试、lint 或相关项目脚本。 +5. 如果工具返回错误,先根据错误修正调用或实现,不要编造成功结果。 + +# 编码任务行为准则 +- 除非用户明确要求讨论、计划或解释,否则在可行范围内直接完成任务。 +- 修改时遵循当前代码库已有结构、命名、风格和工具链,不引入不必要的新抽象。 +- 保持改动聚焦于用户请求,不主动修复无关问题;如发现无关风险,可在最终回复中简要说明。 +- 验证时优先运行与改动最相关、范围最小的构建、测试或 lint,再按风险扩大范围。 +- 完成后简洁说明改动内容、验证结果,以及任何未完成事项或残余风险。 # 通信协议 (Protocol) 调用工具时,必须输出 **JSON 代码块**,不能使用普通文本或行内 JSON。 ## 1. 请求格式 (你发送给插件) 顶层字段只能包含 `mcp_action`、`name`、`purpose`、`arguments`、`request_id`。`name` 和 `purpose` 必填;如果所选工具有入参,`arguments` 必须严格匹配该工具的 `inputSchema`。 +每一次工具调用都必须使用一个此前在本会话中从未出现过的新 `request_id`。不要在后续回复中复用 `step_1`、`step_2` 或任何旧值。 工具 `name` 必须和工具列表中展示的一致。本地/内置工具使用裸名,例如 `read_file`;第三方 MCP 工具使用 `server:tool` 名称,例如 `github:search_repositories`。 ```json @@ -17,7 +62,7 @@ "arguments": { "key": "value" }, - "request_id": "step_x" + "request_id": "turn_ab12_step_x" } ``` @@ -26,14 +71,14 @@ ```json { "mcp_action": "result", - "request_id": "step_x", + "request_id": "turn_ab12_step_x", "output": "这里是文件内容或命令执行结果..." } ``` # 核心规则 -1. **严禁猜测**:不要假设自己拥有某个工具,一切以当前上下文中的 Available Tools 列表为准。 -2. **顺序执行**:你可以一次性输出多个 JSON 块来调用多个工具,webcode 会按出现顺序逐个执行,并在全部完成后批量返回结果。注意:不能一个 JSON 块包含多个工具调用,每个工具调用应该在一个单独的 JSON 块中。 +1. **严禁猜测**:不要假设自己拥有某个工具,一切以当前上下文中的 {{PRODUCT_NAME}} Available Tools 列表为准。即使网页 AI 界面显示了其他工具,只要用户任务涉及本地 VS Code 工作区,也必须以 {{PRODUCT_NAME}} Available Tools 为准。 +2. **顺序执行**:你可以一次性输出多个 JSON 块来调用多个工具,{{PRODUCT_NAME}} 会按出现顺序逐个执行,并在全部完成后批量返回结果。注意:不能一个 JSON 块包含多个工具调用,每个工具调用应该在一个单独的 JSON 块中。只有多个工具调用彼此独立时,才可以在同一回复中连续输出多个 JSON 块;如果后一个调用依赖前一个调用的结果,必须先等待插件返回结果。 正例: ```json { @@ -43,7 +88,7 @@ "arguments": { "command": "git tag --list --sort=-v:refname" }, - "request_id": "step_1" + "request_id": "turn_ab12_step_1" } ``` ```json @@ -54,7 +99,7 @@ "arguments": { "command": "git status --short" }, - "request_id": "step_2" + "request_id": "turn_ab12_step_2" } ``` 反例: @@ -66,7 +111,7 @@ "arguments": { "command": "git tag --list --sort=-v:refname" }, - "request_id": "step_1" + "request_id": "turn_ab12_step_1" }, { "mcp_action": "call", @@ -75,14 +120,31 @@ "arguments": { "command": "git status --short" }, - "request_id": "step_2" + "request_id": "turn_ab12_step_2" }] ``` 3. **不要夹带问句**:如果你本次回复中包含任何工具调用,就不要同时向用户提问。因为下一次返回通常会是工具执行结果,用户无法先回答你的问题。 4. **工具分组**:工具列表按服务器来源分组,所有可用工具都会在 `tools` 数组中直接展示完整定义。第三方 MCP 工具名会带有服务器前缀(`server:tool`);裸名只保留给本地/内置工具。 -5. **优先使用专用文件工具**:查找工作区文件用 `search_files`。搜索代码或文本内容用 `search_code`。读取文件内容或指定行范围用 `read_file`。不要为了查看文件而用 `execute_command` 执行 `grep`、`rg`、`find`、`cat`、`sed`、`awk`、`nl` 等 shell 命令。 +5. **优先使用专用文件工具**:查找工作区文件用 `search_files`。搜索代码或文本内容用 `search_code`。读取文件内容或指定行范围用 `read_file`。修改已有文件优先用 `edit_file`;只有创建新文件,或用户明确要求完整重写文件时,才用 `write_file`。不要为了查看文件而用 `execute_command` 执行 `grep`、`rg`、`find`、`cat`、`sed`、`awk`、`nl` 等 shell 命令。 6. **命令工具适用范围**:`execute_command` 用于构建、测试、包管理器、git 命令和项目脚本。只有长时间运行或需要可见终端输出时才使用 `run_in_terminal`。 -7. **Skills 与渐进式加载**:如果初始化上下文中存在 Available Skills,说明当前工作区提供了本地 skills。 - - 在用户需要工作流、模板、领域指南、安装说明或专用能力时,先根据 Available Skills 的 `name`、`description` 和路径信息选择合适的 skill。 +7. **Skills 与渐进式加载**:如果初始化上下文中存在 {{PRODUCT_NAME}} Available Skills,说明当前工作区提供了本地 skills。 + - 在用户需要工作流、模板、领域指南、安装说明或专用能力时,先根据 {{PRODUCT_NAME}} Available Skills 的 `name`、`description` 和路径信息选择合适的 skill。 - 在真正使用某个 skill 之前,使用该条目的 `skillFilePath` 调用 `read_file` 读取对应 `SKILL.md`,不要仅凭名字猜测规则。 - 如果 `SKILL.md` 提到了 `references/`、`templates/` 等文本附属文件,再按需用 `read_file` 读取;如果需要运行 `scripts/` 或项目脚本,短任务用 `execute_command`,长时间运行或需要可见终端输出时用 `run_in_terminal`。 +8. **路径规范**:传给文件工具的路径应优先使用工作区相对路径,并使用 `/` 分隔。不要把网页 AI 沙箱路径、临时路径或猜测出的绝对路径传给本地文件工具。 +9. **破坏性操作约束**:不要主动执行明显破坏性操作,例如删除大量文件、清空目录、重置 git 历史、强制推送、安装或卸载依赖,除非用户明确要求或先获得确认。 + +# 工具使用示例与反例 +反例: +- 不要用网页 AI 平台自带 Python 读取 `/mnt/data`、`/workspace` 等路径来判断用户项目文件。 +- 不要用网页 AI 平台自带 shell 执行 `ls`、`cat`、`pytest`、`npm test` 来检查用户本地仓库。 +- 不要用网页 AI 平台自带 browser 或 computer 工具假装操作 VS Code 文件。 + +正例: +- 查找工作区文件:使用 `search_files`。 +- 搜索代码或文本内容:使用 `search_code`。 +- 读取文件内容:使用 `read_file`。 +- 修改已有文件:优先使用 `edit_file`。 +- 创建新文件或完整重写文件:使用 `write_file`。 +- 构建、测试、git、包管理器和项目脚本:使用 `execute_command`。 +- 长时间运行服务或需要可见终端:使用 `run_in_terminal`。 diff --git a/gateway-vscode/prompts/train_en.md b/gateway-vscode/prompts/train_en.md index 9e25917..e6996da 100644 --- a/gateway-vscode/prompts/train_en.md +++ b/gateway-vscode/prompts/train_en.md @@ -1 +1 @@ -[System] Reminder: Tool calls MUST use this JSON format: {"mcp_action":"call", "name": "tool_name", "purpose": "reason", "arguments": {...}, "request_id": "step_x"}. Top-level fields may only be mcp_action, name, purpose, arguments, and request_id. name and purpose are required. If a tool has inputs, arguments must exactly match its inputSchema. +[System] Reminder: Tool calls MUST use this JSON format: {"mcp_action":"call", "name": "tool_name", "purpose": "reason", "arguments": {...}, "request_id": "turn_unique_step_x"}. request_id must be new for every tool call in this conversation. Top-level fields may only be mcp_action, name, purpose, arguments, and request_id. name and purpose are required. If a tool has inputs, arguments must exactly match its inputSchema. diff --git a/gateway-vscode/prompts/train_zh.md b/gateway-vscode/prompts/train_zh.md index 28626e8..6cbc572 100644 --- a/gateway-vscode/prompts/train_zh.md +++ b/gateway-vscode/prompts/train_zh.md @@ -1 +1 @@ -[系统提示] 请保持工具调用格式:{"mcp_action":"call", "name": "tool_name", "purpose": "原因", "arguments": {...}, "request_id": "step_x"}。工具调用顶层字段只能包含 mcp_action、name、purpose、arguments、request_id。name 和 purpose 必填;如果工具有入参,arguments 必须严格匹配该工具的 inputSchema。 +[系统提示] 请保持工具调用格式:{"mcp_action":"call", "name": "tool_name", "purpose": "原因", "arguments": {...}, "request_id": "turn_unique_step_x"}。request_id 必须是本会话中每次工具调用的新值。工具调用顶层字段只能包含 mcp_action、name、purpose、arguments、request_id。name 和 purpose 必填;如果工具有入参,arguments 必须严格匹配该工具的 inputSchema。 diff --git a/gateway-vscode/src/gateway/bridgeRoute.ts b/gateway-vscode/src/gateway/bridgeRoute.ts index f3e53bb..f151ba5 100644 --- a/gateway-vscode/src/gateway/bridgeRoute.ts +++ b/gateway-vscode/src/gateway/bridgeRoute.ts @@ -187,21 +187,73 @@ function renderBridgeScript(): string { document.getElementById('install-github-button').textContent = bridgeI18n.githubButton; document.getElementById('install-warn').textContent = bridgeI18n.installWarn; - // 检测逻辑:等待 1.5 秒 - setTimeout(() => { - // 1. 检查插件是否打上了标记 + const installDetectionTimeoutMs = 10000; + const installDetectionPollMs = 100; + const bridgeSignalEventName = 'webcode-bridge-extension-installed'; + let installDetectionSettled = false; + let installDetectionTimer = null; + let installDetectionPollTimer = null; + let installDetectionObserver = null; + + function hasBridgeExtensionSignal() { const isInstalled = document.documentElement.getAttribute('data-extension-installed') === 'true'; - - // 2. 双重保险:检查页面内容是否已经被插件修改(例如出现了冲突提示) const bridgeState = document.body.dataset.bridgeState; - const isBusyOrConflict = bridgeState === 'conflict' || bridgeState === 'switching' || bridgeState === 'connected'; + const handledByExtension = bridgeState === 'conflict' + || bridgeState === 'switching' + || bridgeState === 'connected' + || bridgeState === 'error'; + + return isInstalled || handledByExtension; + } + + function stopInstallDetection() { + if (installDetectionSettled) { + return; + } - // 只有在既没安装,也没发生冲突的情况下,才显示安装引导 - if (!isInstalled && !isBusyOrConflict) { - document.getElementById('main-card').style.display = 'none'; - document.getElementById('install-guide').style.display = 'block'; + installDetectionSettled = true; + if (installDetectionTimer !== null) { + window.clearTimeout(installDetectionTimer); + } + if (installDetectionPollTimer !== null) { + window.clearInterval(installDetectionPollTimer); + } + if (installDetectionObserver) { + installDetectionObserver.disconnect(); } - }, 1500); + window.removeEventListener(bridgeSignalEventName, checkBridgeExtensionSignal); + } + + function checkBridgeExtensionSignal() { + if (!installDetectionSettled && hasBridgeExtensionSignal()) { + stopInstallDetection(); + } + } + + function showInstallGuideIfNoExtensionSignal() { + if (hasBridgeExtensionSignal()) { + stopInstallDetection(); + return; + } + + stopInstallDetection(); + document.getElementById('main-card').style.display = 'none'; + document.getElementById('install-guide').style.display = 'block'; + } + + window.addEventListener(bridgeSignalEventName, checkBridgeExtensionSignal); + installDetectionObserver = new MutationObserver(checkBridgeExtensionSignal); + installDetectionObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-extension-installed'] + }); + installDetectionObserver.observe(document.body, { + attributes: true, + attributeFilter: ['data-bridge-state'] + }); + installDetectionPollTimer = window.setInterval(checkBridgeExtensionSignal, installDetectionPollMs); + installDetectionTimer = window.setTimeout(showInstallGuideIfNoExtensionSignal, installDetectionTimeoutMs); + checkBridgeExtensionSignal(); `; } diff --git a/gateway-vscode/src/platforms.ts b/gateway-vscode/src/platforms.ts index d499959..6a40c5b 100644 --- a/gateway-vscode/src/platforms.ts +++ b/gateway-vscode/src/platforms.ts @@ -6,7 +6,7 @@ export interface AISiteConfig { selectors?: Partial; } -export type BuiltinPlatformId = 'chatgpt' | 'gemini' | 'aistudio' | 'deepseek' | 'glm' | 'claude'; +export type BuiltinPlatformId = 'chatgpt' | 'gemini' | 'aistudio' | 'deepseek' | 'glm' | 'claude' | 'qwen'; export interface SiteSelectors { messageBlocks: string; @@ -122,6 +122,25 @@ const BUILTIN_PLATFORMS: BuiltinPlatformDefinition[] = [ sendButton: 'button[aria-label="Send message"]', stopButton: 'button[aria-label="Stop response"]', } + }, + { + id: 'qwen', + defaultSite: { + name: 'Qwen', + address: 'https://chat.qwen.ai/', + showQuickLaunch: true + }, + addressIncludes: ['chat.qwen.ai', 'qwen.ai'], + selectors: { + messageBlocks: '.qwen-chat-message-assistant', + codeBlocks: [ + '.qwen-chat-message-assistant .qwen-markdown-code-body', + '.qwen-chat-message-assistant pre code' + ].join(', '), + inputArea: 'textarea.message-input-textarea', + sendButton: '.chat-prompt-send-button .send-button, .message-input-right-button-send .omni-button-content-btn', + stopButton: '.chat-prompt-send-button .stop-button', + } } ]; diff --git a/gateway-vscode/src/schemaValidation.ts b/gateway-vscode/src/schemaValidation.ts index fce7dae..42bcbd8 100644 --- a/gateway-vscode/src/schemaValidation.ts +++ b/gateway-vscode/src/schemaValidation.ts @@ -40,7 +40,7 @@ export function formatToolArgumentValidationError(toolName: string, schema: unkn name: toolName, purpose: 'Brief justification for this action', arguments: {}, - request_id: 'step_1' + request_id: 'turn_unique_step_1' }, null, 2), '```' ].join('\n'); diff --git a/gateway-vscode/src/tools/editFileTool.ts b/gateway-vscode/src/tools/editFileTool.ts index 562d7d6..618f042 100644 --- a/gateway-vscode/src/tools/editFileTool.ts +++ b/gateway-vscode/src/tools/editFileTool.ts @@ -32,6 +32,11 @@ type ParsedHunk = { }; const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; +const MISSING_HUNK_ERROR_MESSAGE = [ + 'Patch must contain at least one unified diff hunk starting with @@.', + 'Use edits for exact text replacements, or provide a standard unified diff hunk such as', + '@@ -1,2 +1,2 @@ with context, removed (-), and added (+) lines.' +].join(' '); export const editFileTool: LocalTool = { serverId: 'internal', @@ -129,12 +134,21 @@ function applyExactEdits(originalContent: string, edits: FileEdit[]): string { throw new Error(`Found ${matches} matches for edit oldText. Make oldText more specific or set replaceAll to true.`); } - modifiedContent = modifiedContent.replace(oldText, newText); + modifiedContent = replaceFirstLiteral(modifiedContent, oldText, newText); } return modifiedContent; } +function replaceFirstLiteral(content: string, searchText: string, replacementText: string): string { + const index = content.indexOf(searchText); + if (index < 0) { + return content; + } + + return content.slice(0, index) + replacementText + content.slice(index + searchText.length); +} + function countOccurrences(content: string, searchText: string): number { let count = 0; let index = content.indexOf(searchText); @@ -194,7 +208,7 @@ function parseUnifiedPatch(patch: string): PatchHunk[] { } if (hunks.length === 0) { - throw new Error('Patch must contain at least one unified diff hunk starting with @@.'); + throw new Error(MISSING_HUNK_ERROR_MESSAGE); } return hunks; diff --git a/gateway-vscode/src/tools/filesystemUtils.ts b/gateway-vscode/src/tools/filesystemUtils.ts index e323699..179da1b 100644 --- a/gateway-vscode/src/tools/filesystemUtils.ts +++ b/gateway-vscode/src/tools/filesystemUtils.ts @@ -3,6 +3,7 @@ import type { Dirent } from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as crypto from 'crypto'; +import { expandGlobBraceAlternation } from './globUtils'; export type WorkspacePathOptions = { forWrite?: boolean; @@ -20,6 +21,13 @@ type WalkContext = { options: WalkOptions; }; +export type FileQueryMatchMode = 'auto' | 'substring' | 'glob'; + +export type FileQueryOptions = { + caseSensitive?: boolean; + matchMode?: FileQueryMatchMode; +}; + type DiffBounds = { prefix: number; suffix: number; @@ -33,9 +41,24 @@ export const DEFAULT_EXCLUDED_DIRECTORIES = [ '.git', 'node_modules', '.pnpm-store', + '.vscode-test', + '.next', + '.nuxt', + '.svelte-kit', + '.turbo', + '.cache', + '.parcel-cache', + '.pytest_cache', + '.mypy_cache', + '.ruff_cache', + '.tox', + '.venv', + 'venv', + '.gradle', 'dist', 'out', 'build', + 'target', 'coverage' ] as const; @@ -208,30 +231,55 @@ function matchesIncludePattern(includePattern: string | undefined, normalizedRel matchesPattern(entryName, includePattern); } -export function matchesFileQuery(relativePath: string, fileName: string, query: string): boolean { +export function matchesFileQuery( + relativePath: string, + fileName: string, + query: string, + options: FileQueryOptions = {} +): boolean { const normalizedQuery = query.trim(); if (!normalizedQuery) { return false; } - if (hasGlobSyntax(normalizedQuery)) { - return matchesPattern(relativePath, normalizedQuery) || matchesPattern(fileName, normalizedQuery); + if (resolveFileQueryMatchMode(normalizedQuery, options.matchMode) === 'glob') { + return matchesPattern(relativePath, normalizedQuery, options.caseSensitive) || + matchesPattern(fileName, normalizedQuery, options.caseSensitive); } - const lowerQuery = normalizedQuery.toLowerCase(); - return relativePath.toLowerCase().includes(lowerQuery) || fileName.toLowerCase().includes(lowerQuery); + return includesFileQuery(relativePath, normalizedQuery, options.caseSensitive) || + includesFileQuery(fileName, normalizedQuery, options.caseSensitive); } -export function matchesPattern(value: string, pattern: string): boolean { - const normalizedValue = toPosixPath(value); - if (pattern.startsWith('**/') && matchesCompiledPattern(normalizedValue, pattern.slice(3))) { - return true; +export function resolveFileQueryMatchMode(query: string, matchMode: FileQueryMatchMode = 'auto'): 'substring' | 'glob' { + if (matchMode === 'substring' || matchMode === 'glob') { + return matchMode; } - return matchesCompiledPattern(normalizedValue, pattern); + return hasGlobSyntax(query) ? 'glob' : 'substring'; +} + +function includesFileQuery(value: string, query: string, caseSensitive?: boolean): boolean { + if (caseSensitive) { + return value.includes(query); + } + + return value.toLowerCase().includes(query.toLowerCase()); +} + +export function matchesPattern(value: string, pattern: string, caseSensitive = false): boolean { + const normalizedValue = toPosixPath(value); + return expandGlobBraceAlternation(pattern).some(expandedPattern => { + if (expandedPattern.startsWith('**/') && matchesCompiledPattern(normalizedValue, expandedPattern.slice(3), caseSensitive)) { + return true; + } + + return matchesCompiledPattern(normalizedValue, expandedPattern, caseSensitive); + }); } -function matchesCompiledPattern(normalizedValue: string, pattern: string): boolean { + +function matchesCompiledPattern(normalizedValue: string, pattern: string, caseSensitive: boolean): boolean { const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*\*/g, '\0') @@ -239,7 +287,7 @@ function matchesCompiledPattern(normalizedValue: string, pattern: string): boole .replace(/\?/g, '[^/]') .replace(/\0/g, '.*'); - return new RegExp(`^${escaped}$`, 'i').test(normalizedValue); + return new RegExp(`^${escaped}$`, caseSensitive ? '' : 'i').test(normalizedValue); } export function matchesAnyPattern(value: string, patterns: string[]): boolean { @@ -250,7 +298,7 @@ export function toPosixPath(value: string): string { return value.replace(/\\/g, '/'); } -export function createUnifiedDiff(originalContent: string, newContent: string, filepath: string): string { +export function createUnifiedDiff(originalContent: string, newContent: string, filepath: string, maxChars = 20000): string { const originalLines = normalizeLineEndings(originalContent).split('\n'); const newLines = normalizeLineEndings(newContent).split('\n'); @@ -262,7 +310,18 @@ export function createUnifiedDiff(originalContent: string, newContent: string, f const hunkLines = createDiffHeader(filepath, bounds); appendDiffBody(hunkLines, originalLines, newLines, bounds); - return `\`\`\`diff\n${hunkLines.join('\n')}\n\`\`\``; + return limitUnifiedDiffOutput('```diff\n' + hunkLines.join('\n') + '\n```', maxChars); +} + +function limitUnifiedDiffOutput(diff: string, maxChars: number): string { + const safeMaxChars = Math.max(200, Math.floor(maxChars)); + if (diff.length <= safeMaxChars) { + return diff; + } + + const truncationNotice = '\n[diff output truncated to ' + safeMaxChars + ' characters; use read_file with line ranges or make a smaller edit to inspect the full change.]\n```'; + const prefixLength = Math.max(0, safeMaxChars - truncationNotice.length); + return diff.slice(0, prefixLength) + truncationNotice; } function getDiffBounds(originalLines: string[], newLines: string[]): DiffBounds { @@ -343,7 +402,7 @@ export function getStringArrayArg(value: unknown): string[] { } function hasGlobSyntax(value: string): boolean { - return /[*?]/.test(value); + return /[*?{}]/.test(value); } function hasErrorCode(error: unknown, code: string): boolean { diff --git a/gateway-vscode/src/tools/globUtils.ts b/gateway-vscode/src/tools/globUtils.ts new file mode 100644 index 0000000..77a7ff0 --- /dev/null +++ b/gateway-vscode/src/tools/globUtils.ts @@ -0,0 +1,69 @@ +export function expandGlobBraceAlternation(pattern: string): string[] { + const startIndex = pattern.indexOf('{'); + if (startIndex < 0) { + return [pattern]; + } + + const endIndex = findGlobBraceEnd(pattern, startIndex); + if (endIndex < 0) { + return [pattern]; + } + + const alternatives = splitGlobBraceAlternatives(pattern.slice(startIndex + 1, endIndex)); + if (alternatives.length < 2 || alternatives.some(alternative => alternative === '')) { + return [pattern]; + } + + const prefix = pattern.slice(0, startIndex); + const suffix = pattern.slice(endIndex + 1); + return alternatives.flatMap(alternative => expandGlobBraceAlternation(prefix + alternative + suffix)); +} + +function findGlobBraceEnd(pattern: string, startIndex: number): number { + let depth = 0; + + for (let index = startIndex + 1; index < pattern.length; index++) { + const character = pattern[index]; + if (character === '{') { + depth++; + continue; + } + if (character === '}') { + if (depth === 0) { + return index; + } + depth--; + } + } + + return -1; +} + +function splitGlobBraceAlternatives(value: string): string[] { + const alternatives: string[] = []; + let current = ''; + let depth = 0; + + for (const character of value) { + if (character === '{') { + depth++; + current += character; + continue; + } + if (character === '}') { + depth = Math.max(0, depth - 1); + current += character; + continue; + } + if (character === ',' && depth === 0) { + alternatives.push(current); + current = ''; + continue; + } + + current += character; + } + + alternatives.push(current); + return alternatives; +} diff --git a/gateway-vscode/src/tools/readFileLineStream.ts b/gateway-vscode/src/tools/readFileLineStream.ts index 036b466..40ae159 100644 --- a/gateway-vscode/src/tools/readFileLineStream.ts +++ b/gateway-vscode/src/tools/readFileLineStream.ts @@ -112,8 +112,8 @@ async function streamForwardSelectedLines( } } - if (completed && sawBytes && finishLine(false)) { - completed = false; + if (completed && sawBytes) { + finishLine(false); } return { diff --git a/gateway-vscode/src/tools/readFileOutputLimit.ts b/gateway-vscode/src/tools/readFileOutputLimit.ts new file mode 100644 index 0000000..bf46f65 --- /dev/null +++ b/gateway-vscode/src/tools/readFileOutputLimit.ts @@ -0,0 +1,150 @@ +export const READ_FILE_OUTPUT_MAX_BYTES = 128 * 1024; +export const READ_FILE_OUTPUT_MAX_LINES = 1000; + +export type ReadFileTruncationReason = 'line_limit' | 'byte_limit' | 'line_and_byte_limit'; + +export type LimitedReadFileOutput = { + text: string; + firstLineNumber: number; + returnedLineCount: number; + lineLimitApplied: boolean; + byteLimitApplied: boolean; + truncationReason?: ReadFileTruncationReason; +}; + +export function formatReadFileLines(lines: string[], firstLineNumber: number, showLineNumbers: boolean): string { + if (!showLineNumbers) { + return lines.join('\n'); + } + + return lines + .map((line, index) => `${firstLineNumber + index}: ${line}`) + .join('\n'); +} + +export function formatLimitedReadFileOutput( + lines: string[], + firstLineNumber: number, + showLineNumbers: boolean, + limits: { + byteLimitAlreadyApplied?: boolean; + lineLimitAlreadyApplied?: boolean; + preserveLastLines?: boolean; + } = {} +): LimitedReadFileOutput { + const preserveLastLines = limits.preserveLastLines === true; + const lineLimited = preserveLastLines + ? lines.slice(Math.max(lines.length - READ_FILE_OUTPUT_MAX_LINES, 0)) + : lines.slice(0, READ_FILE_OUTPUT_MAX_LINES); + const lineLimitedFirstLineNumber = preserveLastLines + ? firstLineNumber + lines.length - lineLimited.length + : firstLineNumber; + const lineLimitApplied = limits.lineLimitAlreadyApplied === true || lines.length > lineLimited.length; + const byteLimited = limitTextToMaxBytes( + lineLimited, + lineLimitedFirstLineNumber, + showLineNumbers, + preserveLastLines + ); + const byteLimitApplied = limits.byteLimitAlreadyApplied === true || byteLimited.byteLimitApplied; + + return { + text: byteLimited.text, + firstLineNumber: byteLimited.firstLineNumber, + returnedLineCount: byteLimited.returnedLineCount, + lineLimitApplied, + byteLimitApplied, + truncationReason: getReadFileTruncationReason(lineLimitApplied, byteLimitApplied) + }; +} + +function limitTextToMaxBytes( + lines: string[], + firstLineNumber: number, + showLineNumbers: boolean, + preserveLastLines: boolean +): Pick { + const text = formatReadFileLines(lines, firstLineNumber, showLineNumbers); + if (Buffer.byteLength(text, 'utf8') <= READ_FILE_OUTPUT_MAX_BYTES) { + return { text, firstLineNumber, returnedLineCount: lines.length, byteLimitApplied: false }; + } + + const completeLineCount = getMaxCompleteLineCountWithinByteLimit( + lines, + firstLineNumber, + showLineNumbers, + preserveLastLines + ); + if (completeLineCount > 0) { + const returnedLines = preserveLastLines ? lines.slice(lines.length - completeLineCount) : lines.slice(0, completeLineCount); + const returnedFirstLineNumber = preserveLastLines + ? firstLineNumber + lines.length - completeLineCount + : firstLineNumber; + return { + text: formatReadFileLines(returnedLines, returnedFirstLineNumber, showLineNumbers), + firstLineNumber: returnedFirstLineNumber, + returnedLineCount: completeLineCount, + byteLimitApplied: true + }; + } + + const truncatedLineIndex = preserveLastLines ? lines.length - 1 : 0; + const truncatedFirstLineNumber = firstLineNumber + truncatedLineIndex; + return { + text: truncateUtf8Text(formatReadFileLines([lines[truncatedLineIndex]], truncatedFirstLineNumber, showLineNumbers)), + firstLineNumber: truncatedFirstLineNumber, + returnedLineCount: lines.length > 0 ? 1 : 0, + byteLimitApplied: true + }; +} + +function getMaxCompleteLineCountWithinByteLimit( + lines: string[], + firstLineNumber: number, + showLineNumbers: boolean, + preserveLastLines: boolean +): number { + let low = 0; + let high = lines.length; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + const returnedLines = preserveLastLines ? lines.slice(lines.length - mid) : lines.slice(0, mid); + const returnedFirstLineNumber = preserveLastLines + ? firstLineNumber + lines.length - mid + : firstLineNumber; + const text = formatReadFileLines(returnedLines, returnedFirstLineNumber, showLineNumbers); + if (Buffer.byteLength(text, 'utf8') <= READ_FILE_OUTPUT_MAX_BYTES) { + low = mid; + } else { + high = mid - 1; + } + } + return low; +} + +function truncateUtf8Text(text: string): string { + let bytes = 0; + let endIndex = 0; + for (const character of text) { + const nextBytes = bytes + Buffer.byteLength(character, 'utf8'); + if (nextBytes > READ_FILE_OUTPUT_MAX_BYTES) { + break; + } + bytes = nextBytes; + endIndex += character.length; + } + return text.slice(0, endIndex); +} + +function getReadFileTruncationReason( + lineLimitApplied: boolean, + byteLimitApplied: boolean +): ReadFileTruncationReason | undefined { + if (lineLimitApplied && byteLimitApplied) { + return 'line_and_byte_limit'; + } + if (lineLimitApplied) { + return 'line_limit'; + } + return byteLimitApplied ? 'byte_limit' : undefined; +} diff --git a/gateway-vscode/src/tools/readFilePrefix.ts b/gateway-vscode/src/tools/readFilePrefix.ts new file mode 100644 index 0000000..4fe8b13 --- /dev/null +++ b/gateway-vscode/src/tools/readFilePrefix.ts @@ -0,0 +1,116 @@ +import * as fs from 'fs/promises'; + +export async function readFilePrefix(filePath: string, maxBytes: number, readChunkBytes = maxBytes): Promise { + const handle = await fs.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(maxBytes); + let totalBytesRead = 0; + + while (totalBytesRead < maxBytes) { + const bytesToRead = Math.min(readChunkBytes, maxBytes - totalBytesRead); + const { bytesRead } = await handle.read(buffer, totalBytesRead, bytesToRead, totalBytesRead); + if (bytesRead === 0) { + break; + } + totalBytesRead += bytesRead; + } + + const boundary = totalBytesRead === maxBytes + ? getUtf8PrefixBoundary(buffer, totalBytesRead) + : totalBytesRead; + return buffer.subarray(0, boundary).toString('utf8'); + } finally { + await handle.close(); + } +} + +function getUtf8PrefixBoundary(buffer: Buffer, length: number): number { + if (length <= 0) { + return 0; + } + + const minLeadIndex = Math.max(0, length - 4); + let leadIndex = length - 1; + while (leadIndex >= minLeadIndex && isUtf8ContinuationByte(buffer[leadIndex])) { + leadIndex--; + } + if (leadIndex < minLeadIndex) { + return length; + } + + const sequenceLength = getUtf8SequenceLength(buffer[leadIndex]); + if (sequenceLength === 0) { + return length; + } + + const availableBytes = length - leadIndex; + if (availableBytes >= sequenceLength) { + return length; + } + + return isPotentialUtf8Prefix(buffer, leadIndex, availableBytes, sequenceLength) ? leadIndex : length; +} + +function isUtf8ContinuationByte(byte: number): boolean { + return (byte & 0b11000000) === 0b10000000; +} + +function getUtf8SequenceLength(byte: number): number { + if ((byte & 0b10000000) === 0) { + return 1; + } + if (byte >= 0xC2 && byte <= 0xDF) { + return 2; + } + if (byte >= 0xE0 && byte <= 0xEF) { + return 3; + } + if (byte >= 0xF0 && byte <= 0xF4) { + return 4; + } + return 0; +} + +function isPotentialUtf8Prefix( + buffer: Buffer, + leadIndex: number, + availableBytes: number, + sequenceLength: number +): boolean { + if (availableBytes <= 0 || availableBytes >= sequenceLength) { + return false; + } + + if (sequenceLength === 2 || availableBytes === 1) { + return true; + } + + const leadByte = buffer[leadIndex]; + const secondByte = buffer[leadIndex + 1]; + if (!isValidUtf8SecondByte(leadByte, secondByte)) { + return false; + } + + return sequenceLength === 3 || availableBytes === 2 || isUtf8ContinuationByte(buffer[leadIndex + 2]); +} + +function isValidUtf8SecondByte(leadByte: number, secondByte: number): boolean { + if (!isUtf8ContinuationByte(secondByte)) { + return false; + } + + if (leadByte === 0xE0) { + return secondByte >= 0xA0 && secondByte <= 0xBF; + } + if (leadByte === 0xED) { + return secondByte >= 0x80 && secondByte <= 0x9F; + } + if (leadByte === 0xF0) { + return secondByte >= 0x90 && secondByte <= 0xBF; + } + if (leadByte === 0xF4) { + return secondByte >= 0x80 && secondByte <= 0x8F; + } + + return true; +} diff --git a/gateway-vscode/src/tools/readFileTool.ts b/gateway-vscode/src/tools/readFileTool.ts index 869b995..8fc3f82 100644 --- a/gateway-vscode/src/tools/readFileTool.ts +++ b/gateway-vscode/src/tools/readFileTool.ts @@ -1,26 +1,32 @@ import * as fs from 'fs/promises'; import type { LocalTool } from './types'; import { normalizeLineEndings, resolveWorkspacePath } from './filesystemUtils'; -import { readSelectedFileLines, type LineSelectionOptions } from './readFileLineStream'; - -const DEFAULT_AUTO_READ_MAX_BYTES = 64 * 1024; -const DEFAULT_AUTO_READ_MAX_LINES = 400; +import { readFilePrefix } from './readFilePrefix'; +import { readSelectedFileLines, type LineSelectionOptions, type SelectedFileLines } from './readFileLineStream'; +import { + formatLimitedReadFileOutput, + READ_FILE_OUTPUT_MAX_BYTES, + READ_FILE_OUTPUT_MAX_LINES, + type LimitedReadFileOutput, + type ReadFileTruncationReason +} from './readFileOutputLimit'; + +export { readFilePrefix } from './readFilePrefix'; export const readFileTool: LocalTool = { serverId: 'internal', definition: { name: 'read_file', - description: 'Read a UTF-8 text file inside the current VS Code workspace. Large files are truncated by default unless head, tail, start_line/end_line, or force is provided. Use show_line_numbers to inspect code without shell commands like cat, sed, or nl.', + description: 'Read a UTF-8 text file inside the current VS Code workspace. Supports head, tail, start_line/end_line, and show_line_numbers to inspect code without shell commands.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Workspace-relative or absolute path to the file.' }, head: { type: 'integer', minimum: 1, description: 'Optional number of lines to read from the start of the file.' }, tail: { type: 'integer', minimum: 1, description: 'Optional number of lines to read from the end of the file.' }, - start_line: { type: 'integer', minimum: 1, description: 'Optional 1-based first line to read. Must be used without head or tail.' }, - end_line: { type: 'integer', minimum: 1, description: 'Optional 1-based last line to read, inclusive. Must be used without head or tail.' }, - show_line_numbers: { type: 'boolean', description: 'Prefix each returned line with its 1-based line number. Default: false.', default: false }, - force: { type: 'boolean', description: 'If true, return the requested content even when the file exceeds the default automatic read limit. Default: false.', default: false } + start_line: { type: 'integer', minimum: 1, description: 'Optional 1-based first line to read. Must be used with end_line and without head or tail.' }, + end_line: { type: 'integer', minimum: 1, description: 'Optional 1-based last line to read, inclusive. Must be used with start_line and without head or tail.' }, + show_line_numbers: { type: 'boolean', description: 'Prefix each returned line with its 1-based line number. Default: false.', default: false } }, required: ['path'] }, @@ -32,7 +38,7 @@ export const readFileTool: LocalTool = { const result = await readFileContent(filePath, fileStats.size, args); return { content: [{ type: 'text', text: result.text }], - structuredContent: result.metadata + ...(result.metadata === undefined ? {} : { structuredContent: result.metadata }) }; } }; @@ -43,17 +49,49 @@ export function selectReadFileContent(content: string, args: Record { const lineOptions = getLineSelectionOptions(args); - if (args.force === true || fileBytes <= DEFAULT_AUTO_READ_MAX_BYTES) { - return selectReadFileResult(normalizeLineEndings(await fs.readFile(filePath, 'utf8')), args, { fileBytes }); - } - if (hasLineSelection(lineOptions)) { return readSelectedFileContent(filePath, args, lineOptions, fileBytes); } - const content = normalizeLineEndings(await readFilePrefix(filePath, DEFAULT_AUTO_READ_MAX_BYTES)); + if (fileBytes <= READ_FILE_OUTPUT_MAX_BYTES) { + return selectReadFileResult(normalizeLineEndings(await fs.readFile(filePath, 'utf8')), args, { fileBytes }); + } + + const content = normalizeLineEndings(await readFilePrefix(filePath, READ_FILE_OUTPUT_MAX_BYTES)); return selectReadFileResult(content, args, { fileBytes, - forceTruncated: true, + prefixLimitApplied: true, + byteLimitApplied: true, totalLineCountKnown: false }); } @@ -82,76 +121,74 @@ export async function readFileContent( export function selectReadFileResult( content: string, args: Record, - options: { - fileBytes?: number; - forceTruncated?: boolean; - totalLineCountKnown?: boolean; - } = {} + options: ReadFileResultOptions = {} ): ReadFileResult { const lines = splitLines(content); - const selection = resolveLineSelection(lines.length, args); + const lineOptions = getLineSelectionOptions(args); + const selection = resolveLineSelection(lines.length, lineOptions); const showLineNumbers = args.show_line_numbers === true; - const forceTruncated = options.forceTruncated === true; + const prefixLimitApplied = options.prefixLimitApplied === true; const totalLineCountKnown = options.totalLineCountKnown !== false; if (selection) { - const selected = formatSelectedLines( + const limited = formatLimitedReadFileOutput( lines.slice(selection.startIndex, selection.endIndex), selection.startIndex + 1, - showLineNumbers + showLineNumbers, + { preserveLastLines: lineOptions.tail !== undefined } ); - return { - text: selected, - metadata: { - mode: 'range', - truncated: false, - lineCount: totalLineCountKnown ? lines.length : undefined, - returnedLines: getReturnedLineRange(selection), - fileBytes: options.fileBytes - } - }; + return buildReadFileResult(limited, { + fileBytes: options.fileBytes, + lineCountKnown: totalLineCountKnown, + lineCount: totalLineCountKnown ? lines.length : undefined, + returnedLines: getReturnedLineRange(limited.firstLineNumber, limited.returnedLineCount) + }); } - if (!forceTruncated && shouldReturnFullContent(lines, args, options.fileBytes)) { - return { - text: showLineNumbers ? formatSelectedLines(lines, 1, true) : content, - metadata: { - mode: 'full', - truncated: false, - lineCount: lines.length, - returnedLines: { - start: lines.length > 0 ? 1 : 0, - end: lines.length - }, - fileBytes: options.fileBytes - } - }; + const limited = formatLimitedReadFileOutput(lines, 1, showLineNumbers, { + byteLimitAlreadyApplied: options.byteLimitApplied === true + }); + + if (!prefixLimitApplied && limited.truncationReason === undefined) { + return { text: limited.text }; } - const truncatedLineCount = Math.min(lines.length, DEFAULT_AUTO_READ_MAX_LINES); - const truncatedLines = lines.slice(0, truncatedLineCount); - const text = appendTruncationNotice( - formatSelectedLines(truncatedLines, 1, showLineNumbers), - { - fileBytes: options.fileBytes, - returnedLines: truncatedLineCount, - lineCount: totalLineCountKnown ? lines.length : undefined - } - ); + return buildReadFileResult(limited, { + fileBytes: options.fileBytes, + lineCountKnown: totalLineCountKnown, + lineCount: totalLineCountKnown ? lines.length : undefined, + returnedLines: getReturnedLineRange(limited.firstLineNumber, limited.returnedLineCount) + }); +} + +function buildReadFileResult(limited: LimitedReadFileOutput, details: ReadFileResultDetails): ReadFileResult { + if (limited.truncationReason === undefined) { + return { text: limited.text }; + } + const text = formatReadFileResultText(limited, details.fileBytes, details.lineCount); return { text, - metadata: { - mode: 'truncated', - truncated: true, - lineCount: totalLineCountKnown ? lines.length : undefined, - returnedLines: { - start: truncatedLineCount > 0 ? 1 : 0, - end: truncatedLineCount - }, + metadata: createReadFileMetadata({ + reason: limited.truncationReason, + lineCountKnown: details.lineCountKnown, + lineCount: details.lineCount, + returnedLines: details.returnedLines, returnedBytes: Buffer.byteLength(text, 'utf8'), - fileBytes: options.fileBytes - } + fileBytes: details.fileBytes + }) + }; +} + +function createReadFileMetadata(details: ReadFileTruncationDetails): ReadFileMetadata { + return { + truncated: true, + reason: details.reason, + returnedLines: details.returnedLines, + lineCountKnown: details.lineCountKnown, + lineCount: details.lineCount, + returnedBytes: details.returnedBytes, + fileBytes: details.fileBytes }; } @@ -160,9 +197,8 @@ type LineSelection = { endIndex: number; }; -function resolveLineSelection(lineCount: number, args: Record): LineSelection | null { - const { head, tail, startLine, endLine } = getLineSelectionOptions(args); - +function resolveLineSelection(lineCount: number, options: LineSelectionOptions): LineSelection | null { + const { head, tail, startLine, endLine } = options; if (head !== undefined) { return { startIndex: 0, endIndex: Math.min(head, lineCount) }; } @@ -172,51 +208,59 @@ function resolveLineSelection(lineCount: number, args: Record): return { startIndex, endIndex: lineCount }; } - if (startLine !== undefined || endLine !== undefined) { - const startIndex = Math.min(Math.max((startLine ?? 1) - 1, 0), lineCount); + if (startLine !== undefined && endLine !== undefined) { + const startIndex = Math.min(Math.max(startLine - 1, 0), lineCount); return { startIndex, - endIndex: Math.min(endLine ?? lineCount, lineCount) + endIndex: Math.min(endLine, lineCount) }; } return null; } -function getReturnedLineRange(selection: LineSelection): { start: number; end: number } { - if (selection.startIndex >= selection.endIndex) { +function getReturnedLineRange(firstLineNumber: number, returnedLineCount: number): { start: number; end: number } { + if (returnedLineCount <= 0) { return { start: 0, end: 0 }; } return { - start: selection.startIndex + 1, - end: selection.endIndex + start: firstLineNumber, + end: firstLineNumber + returnedLineCount - 1 }; } +function formatReadFileResultText(limited: LimitedReadFileOutput, fileBytes?: number, lineCount?: number): string { + if (limited.truncationReason === undefined) { + return limited.text; + } + + return appendTruncationNotice(limited.text, { + fileBytes, + returnedLines: limited.returnedLineCount, + lineCount, + truncationReason: limited.truncationReason + }); +} + async function readSelectedFileContent( filePath: string, args: Record, options: LineSelectionOptions, fileBytes: number ): Promise { - const selected = await readSelectedFileLines(filePath, options); + const selected = await readSelectedFileLines(filePath, capLineSelectionOptions(options)); const showLineNumbers = args.show_line_numbers === true; - const text = formatSelectedLines(selected.lines, selected.startLine, showLineNumbers); - - return { - text, - metadata: { - mode: 'range', - truncated: false, - lineCount: selected.lineCount, - returnedLines: { - start: selected.lines.length > 0 ? selected.startLine : 0, - end: selected.lines.length > 0 ? selected.startLine + selected.lines.length - 1 : 0 - }, - fileBytes - } - }; + const limited = formatLimitedReadFileOutput(selected.lines, selected.startLine, showLineNumbers, { + lineLimitAlreadyApplied: didLineSelectionHitOutputLimit(options, selected), + preserveLastLines: options.tail !== undefined + }); + return buildReadFileResult(limited, { + fileBytes, + lineCountKnown: selected.lineCount !== undefined, + lineCount: selected.lineCount, + returnedLines: getReturnedLineRange(limited.firstLineNumber, limited.returnedLineCount) + }); } function getLineSelectionOptions(args: Record): LineSelectionOptions { @@ -237,167 +281,99 @@ function hasLineSelection(options: LineSelectionOptions): boolean { options.endLine !== undefined; } -function shouldReturnFullContent(lines: string[], args: Record, fileBytes?: number): boolean { - return args.force === true || - ((fileBytes === undefined || fileBytes <= DEFAULT_AUTO_READ_MAX_BYTES) && lines.length <= DEFAULT_AUTO_READ_MAX_LINES); -} - -function assertCompatibleLineOptions(options: { - head?: number; - tail?: number; - startLine?: number; - endLine?: number; -}): void { - const { head, tail, startLine, endLine } = options; - const hasRange = startLine !== undefined || endLine !== undefined; - if (head !== undefined && tail !== undefined) { - throw new Error('Cannot specify both head and tail.'); +function capLineSelectionOptions(options: LineSelectionOptions): LineSelectionOptions { + if (options.head !== undefined) { + return { ...options, head: Math.min(options.head, READ_FILE_OUTPUT_MAX_LINES) }; } - - if ((head !== undefined || tail !== undefined) && hasRange) { - throw new Error('Cannot specify head or tail with start_line or end_line.'); + if (options.tail !== undefined) { + return { ...options, tail: Math.min(options.tail, READ_FILE_OUTPUT_MAX_LINES) }; } - - if (startLine !== undefined && endLine !== undefined && startLine > endLine) { - throw new Error('start_line must be less than or equal to end_line.'); + if (options.startLine !== undefined || options.endLine !== undefined) { + const startLine = options.startLine ?? 1; + const maxEndLine = startLine + READ_FILE_OUTPUT_MAX_LINES - 1; + return { ...options, endLine: Math.min(options.endLine ?? maxEndLine, maxEndLine) }; } + return options; } -function getPositiveIntegerArg(value: unknown): number | undefined { - if (typeof value !== 'number') { - return undefined; +function didLineSelectionHitOutputLimit(options: LineSelectionOptions, selected: SelectedFileLines): boolean { + const requestedLineCount = getRequestedLineCount(options); + if (requestedLineCount <= READ_FILE_OUTPUT_MAX_LINES || selected.lines.length < READ_FILE_OUTPUT_MAX_LINES) { + return false; } - - if (!Number.isInteger(value) || value < 1) { - throw new Error('Line options must be positive integers.'); + if (options.tail !== undefined) { + return selected.lineCount === undefined || selected.lineCount > READ_FILE_OUTPUT_MAX_LINES; } - - return value; + if (options.head !== undefined) { + return selected.lineCount === undefined || selected.lineCount > READ_FILE_OUTPUT_MAX_LINES; + } + return didRangeSelectionHitOutputLimit(options, selected); } -function formatSelectedLines(lines: string[], firstLineNumber: number, showLineNumbers: boolean): string { - if (!showLineNumbers) { - return lines.join('\n'); +function didRangeSelectionHitOutputLimit(options: LineSelectionOptions, selected: SelectedFileLines): boolean { + if (selected.lineCount === undefined) { + return true; } - - return lines - .map((line, index) => `${firstLineNumber + index}: ${line}`) - .join('\n'); + const startLine = options.startLine ?? 1; + const requestedEndLine = options.endLine ?? selected.lineCount; + const availableEndLine = Math.min(requestedEndLine, selected.lineCount); + return Math.max(availableEndLine - startLine + 1, 0) > READ_FILE_OUTPUT_MAX_LINES; } -export async function readFilePrefix(filePath: string, maxBytes: number, readChunkBytes = maxBytes): Promise { - const handle = await fs.open(filePath, 'r'); - try { - const buffer = Buffer.alloc(maxBytes); - let totalBytesRead = 0; - - while (totalBytesRead < maxBytes) { - const bytesToRead = Math.min(readChunkBytes, maxBytes - totalBytesRead); - const { bytesRead } = await handle.read(buffer, totalBytesRead, bytesToRead, totalBytesRead); - if (bytesRead === 0) { - break; - } - totalBytesRead += bytesRead; - } - - const boundary = totalBytesRead === maxBytes - ? getUtf8PrefixBoundary(buffer, totalBytesRead) - : totalBytesRead; - return buffer.subarray(0, boundary).toString('utf8'); - } finally { - await handle.close(); +function getRequestedLineCount(options: LineSelectionOptions): number { + if (options.head !== undefined) { + return options.head; + } + if (options.tail !== undefined) { + return options.tail; } + const startLine = options.startLine ?? 1; + return options.endLine === undefined ? Number.POSITIVE_INFINITY : options.endLine - startLine + 1; } -function getUtf8PrefixBoundary(buffer: Buffer, length: number): number { - if (length <= 0) { - return 0; +function assertCompatibleLineOptions(options: { + head?: number; + tail?: number; + startLine?: number; + endLine?: number; +}): void { + const { head, tail, startLine, endLine } = options; + const hasRange = startLine !== undefined || endLine !== undefined; + if (hasBothHeadAndTail(head, tail)) { + throw new Error('Cannot specify both head and tail.'); } - const minLeadIndex = Math.max(0, length - 4); - let leadIndex = length - 1; - while (leadIndex >= minLeadIndex && isUtf8ContinuationByte(buffer[leadIndex])) { - leadIndex--; - } - if (leadIndex < minLeadIndex) { - return length; + if (hasHeadOrTailWithRange(head, tail, hasRange)) { + throw new Error('Cannot specify head or tail with start_line or end_line.'); } - const sequenceLength = getUtf8SequenceLength(buffer[leadIndex]); - if (sequenceLength === 0) { - return length; + if (hasRange && (startLine === undefined || endLine === undefined)) { + throw new Error('start_line and end_line must be specified together.'); } - const availableBytes = length - leadIndex; - if (availableBytes >= sequenceLength) { - return length; + if (startLine !== undefined && endLine !== undefined && startLine > endLine) { + throw new Error('start_line must be less than or equal to end_line.'); } - - return isPotentialUtf8Prefix(buffer, leadIndex, availableBytes, sequenceLength) ? leadIndex : length; } -function isUtf8ContinuationByte(byte: number): boolean { - return (byte & 0b11000000) === 0b10000000; +function hasBothHeadAndTail(head: number | undefined, tail: number | undefined): boolean { + return head !== undefined && tail !== undefined; } -function getUtf8SequenceLength(byte: number): number { - if ((byte & 0b10000000) === 0) { - return 1; - } - if (byte >= 0xC2 && byte <= 0xDF) { - return 2; - } - if (byte >= 0xE0 && byte <= 0xEF) { - return 3; - } - if (byte >= 0xF0 && byte <= 0xF4) { - return 4; - } - return 0; +function hasHeadOrTailWithRange(head: number | undefined, tail: number | undefined, hasRange: boolean): boolean { + return (head !== undefined || tail !== undefined) && hasRange; } -function isPotentialUtf8Prefix( - buffer: Buffer, - leadIndex: number, - availableBytes: number, - sequenceLength: number -): boolean { - if (availableBytes <= 0 || availableBytes >= sequenceLength) { - return false; - } - - if (sequenceLength === 2 || availableBytes === 1) { - return true; - } - - const leadByte = buffer[leadIndex]; - const secondByte = buffer[leadIndex + 1]; - if (!isValidUtf8SecondByte(leadByte, secondByte)) { - return false; - } - - return sequenceLength === 3 || availableBytes === 2 || isUtf8ContinuationByte(buffer[leadIndex + 2]); -} - -function isValidUtf8SecondByte(leadByte: number, secondByte: number): boolean { - if (!isUtf8ContinuationByte(secondByte)) { - return false; +function getPositiveIntegerArg(value: unknown): number | undefined { + if (typeof value !== 'number') { + return undefined; } - if (leadByte === 0xE0) { - return secondByte >= 0xA0 && secondByte <= 0xBF; - } - if (leadByte === 0xED) { - return secondByte >= 0x80 && secondByte <= 0x9F; - } - if (leadByte === 0xF0) { - return secondByte >= 0x90 && secondByte <= 0xBF; - } - if (leadByte === 0xF4) { - return secondByte >= 0x80 && secondByte <= 0x8F; + if (!Number.isInteger(value) || value < 1) { + throw new Error('Line options must be positive integers.'); } - return true; + return value; } function splitLines(content: string): string[] { @@ -413,14 +389,15 @@ function appendTruncationNotice( fileBytes?: number; returnedLines: number; lineCount?: number; + truncationReason: ReadFileTruncationReason; } ): string { const totalLinesText = details.lineCount === undefined ? '' : ` of ${details.lineCount}`; const sizeText = details.fileBytes === undefined ? '' : ` File size: ${formatBytes(details.fileBytes)}.`; const notice = [ '', - `[read_file] File is large; returned lines 1-${details.returnedLines}${totalLinesText} because no line range was provided.${sizeText}`, - `[read_file] Use start_line/end_line, head, tail, or force: true to read more.` + `[read_file] Output truncated; returned ${details.returnedLines} line(s)${totalLinesText}. Reason: ${details.truncationReason}.${sizeText}`, + `[read_file] Use a narrower line range with start_line/end_line, head, or tail to read more.` ].join('\n'); return content ? `${content}\n${notice}` : notice.trimStart(); diff --git a/gateway-vscode/src/tools/ripgrep.ts b/gateway-vscode/src/tools/ripgrep.ts new file mode 100644 index 0000000..a5ee949 --- /dev/null +++ b/gateway-vscode/src/tools/ripgrep.ts @@ -0,0 +1,86 @@ +import * as fs from 'fs'; +import * as vscode from 'vscode'; +import { getRipgrepBinaryName, getVSCodeRipgrepCandidates } from './searchCodeRipgrepPaths'; + +export type RipgrepCommand = { + command: string; + source: string; + checkedLocations: string[]; +}; + +export class RipgrepUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'RipgrepUnavailableError'; + } +} + +export function resolveRipgrepCommand(): RipgrepCommand { + const checkedLocations: string[] = []; + + const configuredPath = getConfiguredRipgrepPath(); + if (configuredPath) { + checkedLocations.push(`webcodeGateway.ripgrep.path: ${configuredPath}`); + if (fs.existsSync(configuredPath)) { + return { + command: configuredPath, + source: 'webcodeGateway.ripgrep.path', + checkedLocations + }; + } + } else { + checkedLocations.push('webcodeGateway.ripgrep.path: not set'); + } + + const vscodeRipgrepCandidates = getVSCodeRipgrepCandidates( + vscode.env.appRoot, + process.env.PATH, + process.platform, + process.arch + ); + + for (const candidate of vscodeRipgrepCandidates) { + checkedLocations.push(`VS Code bundled ripgrep: ${candidate}`); + if (fs.existsSync(candidate)) { + return { + command: candidate, + source: 'VS Code bundled ripgrep', + checkedLocations + }; + } + } + + if (vscodeRipgrepCandidates.length === 0) { + checkedLocations.push('VS Code bundled ripgrep: vscode.env.appRoot is not available'); + } + + const pathCommand = getRipgrepBinaryName(process.platform); + checkedLocations.push(`PATH command: ${pathCommand}`); + return { + command: pathCommand, + source: 'PATH', + checkedLocations + }; +} + +export function createRipgrepStartError(error: Error, rgCommand: RipgrepCommand): Error { + const code = (error as NodeJS.ErrnoException).code; + const detail = code ? `${error.message} (${code})` : error.message; + return new RipgrepUnavailableError( + [ + `Could not start ripgrep from ${rgCommand.source}: ${detail}`, + `Platform: ${process.platform}-${process.arch}`, + 'Checked:', + ...rgCommand.checkedLocations.map(location => `- ${location}`), + 'Install ripgrep so rg is on PATH, or set webcodeGateway.ripgrep.path to the absolute path of rg/rg.exe.' + ].join('\n') + ); +} + +function getConfiguredRipgrepPath(): string | undefined { + const configuredPath = vscode.workspace + .getConfiguration('webcodeGateway') + .get('ripgrep.path', '') + .trim(); + return configuredPath || undefined; +} diff --git a/gateway-vscode/src/tools/searchCodeFallback.ts b/gateway-vscode/src/tools/searchCodeFallback.ts new file mode 100644 index 0000000..39c0fa6 --- /dev/null +++ b/gateway-vscode/src/tools/searchCodeFallback.ts @@ -0,0 +1,146 @@ +import * as fsPromises from 'fs/promises'; +import * as path from 'path'; +import { + matchesAnyPattern, + matchesPattern, + normalizeLineEndings, + toPosixPath, + walkWorkspaceFiles +} from './filesystemUtils'; +import { listGitSearchFiles, type SearchCandidateFile } from './searchCodeGitFiles'; +import { + createRipgrepExcludeGlobs, + formatSearchCodeMatch, + type SearchMatchRange +} from './searchCodeUtils'; +import type { SearchCodeOptions } from './searchCodeTypes'; + +const MAX_FALLBACK_FILE_SIZE_BYTES = 1024 * 1024 * 2; +type FallbackMatcher = (line: string) => SearchMatchRange | null; + +export function createSearchCodeFallbackNotice(): string { + return [ + 'Notice: ripgrep is unavailable, so search_code is using the in-process fallback.', + 'Fallback scope: fixed-string search and JavaScript RegExp search are supported.', + 'File globs support *, ?, **, and simple comma brace alternation such as *.{js,ts}.', + 'The fallback scans git-tracked files when available, otherwise readable workspace files.', + 'Files larger than ' + MAX_FALLBACK_FILE_SIZE_BYTES + ' bytes and binary-looking files are skipped.' + ].join('\n'); +} + +export async function searchCodeInProcess(options: SearchCodeOptions): Promise { + const matcher = createFallbackMatcher(options.query, { + caseSensitive: options.caseSensitive, + useRegex: options.useRegex + }); + const matches: string[] = []; + const gitFiles = await listGitSearchFiles(options.searchRoot); + + if (gitFiles) { + const excludePatterns = createRipgrepExcludeGlobs(options.excludePatterns); + for (const candidate of gitFiles) { + if (shouldSkipFallbackCandidate(candidate, options, excludePatterns)) { + continue; + } + + const shouldStop = await appendInProcessFileMatches( + candidate.filePath, + candidate.relativeToSearchRoot, + options, + matcher, + matches + ); + if (shouldStop) { + break; + } + } + + return matches; + } + + await walkWorkspaceFiles(options.searchRoot, async (filePath, relativeToSearchRoot) => { + return appendInProcessFileMatches(filePath, relativeToSearchRoot, options, matcher, matches); + }, { + excludePatterns: options.excludePatterns, + includePattern: options.includePattern + }); + + return matches; +} + +async function appendInProcessFileMatches( + filePath: string, + relativeToSearchRoot: string, + options: SearchCodeOptions, + matcher: FallbackMatcher, + matches: string[] +): Promise { + if ( + options.includePattern && + !matchesPattern(relativeToSearchRoot, options.includePattern) && + !matchesPattern(path.basename(filePath), options.includePattern) + ) { + return false; + } + + const stats = await fsPromises.stat(filePath).catch(() => null); + if (!stats?.isFile() || stats.size > MAX_FALLBACK_FILE_SIZE_BYTES) { + return false; + } + + const rawContent = await fsPromises.readFile(filePath, 'utf8').catch(() => null); + if (rawContent == null || rawContent.includes('\0')) { + return false; + } + + const lines = normalizeLineEndings(rawContent).split('\n'); + for (let index = 0; index < lines.length; index++) { + const matchRange = matcher(lines[index]); + if (!matchRange) { + continue; + } + + const relativePath = toPosixPath(path.relative(options.workspaceRoot, filePath)); + const lineText = lines[index]; + matches.push(formatSearchCodeMatch(relativePath, index + 1, lineText, options, matchRange)); + if (matches.length >= options.maxResults) { + return true; + } + } + + return false; +} + +function shouldSkipFallbackCandidate( + candidate: SearchCandidateFile, + options: SearchCodeOptions, + excludePatterns: string[] +): boolean { + if (matchesAnyPattern(candidate.relativeToSearchRoot, excludePatterns)) { + return true; + } + + return Boolean( + options.includePattern && + !matchesPattern(candidate.relativeToSearchRoot, options.includePattern) && + !matchesPattern(path.basename(candidate.filePath), options.includePattern) + ); +} + +function createFallbackMatcher(query: string, options: { caseSensitive: boolean; useRegex: boolean }): FallbackMatcher { + if (options.useRegex) { + const flags = options.caseSensitive ? '' : 'i'; + const regex = new RegExp(query, flags); + return line => { + const match = regex.exec(line); + return match ? { start: match.index, end: match.index + match[0].length } : null; + }; + } + + const needle = options.caseSensitive ? query : query.toLowerCase(); + return line => { + const haystack = options.caseSensitive ? line : line.toLowerCase(); + const start = haystack.indexOf(needle); + return start >= 0 ? { start, end: start + needle.length } : null; + }; +} diff --git a/gateway-vscode/src/tools/searchCodeGitFiles.ts b/gateway-vscode/src/tools/searchCodeGitFiles.ts new file mode 100644 index 0000000..2063b68 --- /dev/null +++ b/gateway-vscode/src/tools/searchCodeGitFiles.ts @@ -0,0 +1,87 @@ +import { spawn } from 'child_process'; +import * as path from 'path'; +import { toPosixPath } from './filesystemUtils'; + +export type SearchCandidateFile = { + filePath: string; + relativeToSearchRoot: string; +}; + +export async function listGitSearchFiles(searchRoot: string): Promise { + const output = await runGitListFiles(searchRoot); + if (output === null) { + return null; + } + + const candidates: SearchCandidateFile[] = []; + for (const rawPath of output.split('\0')) { + const candidate = createSearchCandidate(searchRoot, rawPath); + if (candidate) { + candidates.push(candidate); + } + } + + return candidates; +} + +async function runGitListFiles(searchRoot: string): Promise { + return new Promise(resolve => { + const child = spawn('git', [ + '-C', + searchRoot, + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '-z', + '--', + '.' + ], { + cwd: searchRoot, + windowsHide: true + }); + let spawnFailed = false; + let stdout = ''; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + + child.on('error', () => { + spawnFailed = true; + resolve(null); + }); + child.on('close', code => { + if (!spawnFailed) { + resolve(code === 0 ? stdout : null); + } + }); + }); +} + +export function createSearchCandidate(searchRoot: string, rawPath: string): SearchCandidateFile | null { + if (rawPath === '') { + return null; + } + + const relativeToSearchRoot = toPosixPath(rawPath).replace(/^\.\//, ''); + if (!relativeToSearchRoot || relativeToSearchRoot.includes('\0') || path.isAbsolute(relativeToSearchRoot)) { + return null; + } + + const filePath = path.resolve(searchRoot, relativeToSearchRoot); + if (!isInsideDirectory(searchRoot, filePath)) { + return null; + } + + return { + filePath, + relativeToSearchRoot + }; +} + +function isInsideDirectory(rootPath: string, candidatePath: string): boolean { + const relative = path.relative(rootPath, candidatePath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} diff --git a/gateway-vscode/src/tools/searchCodeRipgrepOutput.ts b/gateway-vscode/src/tools/searchCodeRipgrepOutput.ts new file mode 100644 index 0000000..c060607 --- /dev/null +++ b/gateway-vscode/src/tools/searchCodeRipgrepOutput.ts @@ -0,0 +1,75 @@ +import * as path from 'path'; +import { toPosixPath } from './filesystemUtils'; +import { + byteRangeToStringRange, + formatSearchCodeMatch, + type SearchMatchRange +} from './searchCodeUtils'; +import type { SearchCodeOptions } from './searchCodeTypes'; + +type RipgrepMatchMessage = { + type: string; + data?: { + path?: { text?: string }; + lines?: { text?: string }; + line_number?: number; + submatches?: RipgrepSubmatch[]; + }; +}; + +type RipgrepSubmatch = { + match?: { text?: string }; + start?: number; + end?: number; +}; + +type RipgrepSubmatchWithRange = RipgrepSubmatch & { + start: number; + end: number; +}; + +export function appendRipgrepMatch(line: string, options: SearchCodeOptions, matches: string[]): void { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + let message: RipgrepMatchMessage; + try { + message = JSON.parse(trimmed) as RipgrepMatchMessage; + } catch { + return; + } + + if (message.type !== 'match' || !message.data?.path?.text || typeof message.data.line_number !== 'number') { + return; + } + + const rawPath = message.data.path.text; + const absolutePath = path.isAbsolute(rawPath) + ? rawPath + : path.resolve(options.searchRoot, rawPath); + const relativePath = toPosixPath(path.relative(options.workspaceRoot, absolutePath)); + const lineText = stripLineEnding(message.data.lines?.text ?? ''); + const matchRange = getRipgrepMatchRange(lineText, message); + matches.push(formatSearchCodeMatch(relativePath, message.data.line_number, lineText, options, matchRange)); +} + +function getRipgrepMatchRange( + lineText: string, + message: RipgrepMatchMessage +): SearchMatchRange | undefined { + const submatch = message.data?.submatches?.find((item): item is RipgrepSubmatchWithRange => + typeof item.start === 'number' && + typeof item.end === 'number' + ); + if (!submatch) { + return undefined; + } + + return byteRangeToStringRange(lineText, submatch.start, submatch.end); +} + +function stripLineEnding(text: string): string { + return text.replace(/\r?\n$/, ''); +} diff --git a/gateway-vscode/src/tools/searchCodeRipgrepPaths.ts b/gateway-vscode/src/tools/searchCodeRipgrepPaths.ts new file mode 100644 index 0000000..4af8f59 --- /dev/null +++ b/gateway-vscode/src/tools/searchCodeRipgrepPaths.ts @@ -0,0 +1,177 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export function getVSCodeRipgrepCandidates( + appRoot: string | undefined, + pathValue: string | undefined, + platform: NodeJS.Platform, + arch: string +): string[] { + const binaryName = getRipgrepBinaryName(platform); + const platformArchDirectory = getRipgrepPlatformArchDirectory(platform, arch); + return uniqueStrings(getVSCodeAppRootCandidates(appRoot, pathValue ?? '', platform).flatMap(candidateAppRoot => [ + joinPlatformPath(platform, candidateAppRoot, 'node_modules.asar.unpacked', '@vscode', 'ripgrep', 'bin', binaryName), + joinPlatformPath(platform, candidateAppRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', binaryName), + joinPlatformPath(platform, candidateAppRoot, 'node_modules.asar.unpacked', 'vscode-ripgrep', 'bin', binaryName), + joinPlatformPath(platform, candidateAppRoot, 'node_modules', 'vscode-ripgrep', 'bin', binaryName), + joinPlatformPath(platform, candidateAppRoot, 'node_modules.asar.unpacked', '@vscode', 'ripgrep-universal', 'bin', platformArchDirectory, binaryName), + joinPlatformPath(platform, candidateAppRoot, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', platformArchDirectory, binaryName) + ])); +} + +function getVSCodeAppRootCandidates( + appRoot: string | undefined, + pathValue: string, + platform: NodeJS.Platform +): string[] { + return uniqueStrings([ + ...(appRoot ? [appRoot] : []), + ...getVSCodeAppRootCandidatesFromPath(pathValue, platform), + ...getDefaultVSCodeAppRootCandidates(platform) + ]).map(candidate => resolvePlatformPath(platform, candidate)); +} + +export function getVSCodeAppRootCandidatesFromPath(pathValue: string, platform: NodeJS.Platform): string[] { + const commandNames = getVSCodeCommandNames(platform); + const candidates: string[] = []; + + for (const pathEntry of splitPathValue(pathValue, platform)) { + const normalizedPathEntry = normalizePathEntry(pathEntry, platform); + if (looksLikeVSCodeBinDirectory(normalizedPathEntry)) { + candidates.push(...inferVSCodeAppRootsFromBinDirectory(normalizedPathEntry, platform)); + } + + for (const commandName of commandNames) { + const commandPath = joinPlatformPath(platform, normalizedPathEntry, commandName); + if (!fs.existsSync(commandPath)) { + continue; + } + + const realCommandPath = getRealPathIfAvailable(commandPath); + candidates.push(...inferVSCodeAppRootsFromBinDirectory(getPlatformPath(platform).dirname(realCommandPath), platform)); + } + } + + return uniqueStrings(candidates); +} + +export function getRipgrepBinaryName(platform: string): string { + return platform === 'win32' ? 'rg.exe' : 'rg'; +} + +function getRipgrepPlatformArchDirectory(platform: NodeJS.Platform, arch: string): string { + return `${platform}-${arch}`; +} + +function splitPathValue(pathValue: string, platform: NodeJS.Platform): string[] { + if (platform === 'win32') { + return pathValue + .split(';') + .filter(Boolean) + .flatMap(splitWindowsPathEntry); + } + + return pathValue.split(path.delimiter).filter(Boolean); +} + +function splitWindowsPathEntry(pathEntry: string): string[] { + if (!pathEntry.startsWith('/')) { + return [pathEntry]; + } + + return pathEntry.split(/:(?=\/[a-zA-Z]\/)/).filter(Boolean); +} + +function normalizePathEntry(pathEntry: string, platform: NodeJS.Platform): string { + if (platform === 'win32') { + const msysPathMatch = pathEntry.match(/^\/([a-zA-Z])\/(.*)$/); + if (msysPathMatch) { + return msysPathMatch[1].toUpperCase() + ':\\' + msysPathMatch[2].replace(/\//g, '\\'); + } + } + + return pathEntry; +} + +function looksLikeVSCodeBinDirectory(binDirectory: string): boolean { + const normalized = binDirectory.replace(/\\/g, '/').toLowerCase(); + return normalized.endsWith('/bin') && + /(?:visual studio code|microsoft vs code|vs code|vscode|vscodium|cursor)/.test(normalized); +} + +function inferVSCodeAppRootsFromBinDirectory(binDirectory: string, platform: NodeJS.Platform): string[] { + const parentDirectory = resolvePlatformPath(platform, binDirectory, '..'); + return [ + parentDirectory, + joinPlatformPath(platform, parentDirectory, 'resources', 'app') + ]; +} + +function getVSCodeCommandNames(platform: NodeJS.Platform): string[] { + return platform === 'win32' + ? ['code.cmd', 'code-insiders.cmd', 'codium.cmd', 'cursor.cmd'] + : ['code', 'code-insiders', 'codium', 'cursor']; +} + +function getDefaultVSCodeAppRootCandidates(platform: NodeJS.Platform): string[] { + if (platform === 'win32') { + return getWindowsDefaultVSCodeAppRootCandidates(); + } + if (platform === 'darwin') { + return [ + '/Applications/Visual Studio Code.app/Contents/Resources/app', + '/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app', + '/Applications/VSCodium.app/Contents/Resources/app', + '/Applications/Cursor.app/Contents/Resources/app' + ]; + } + if (platform === 'linux') { + return [ + '/usr/share/code/resources/app', + '/usr/share/code-insiders/resources/app', + '/usr/share/codium/resources/app', + '/opt/visual-studio-code/resources/app', + '/snap/code/current/usr/share/code/resources/app' + ]; + } + + return []; +} + +function getWindowsDefaultVSCodeAppRootCandidates(): string[] { + const roots = [process.env.LOCALAPPDATA, process.env.ProgramFiles, process.env['ProgramFiles(x86)']] + .filter((item): item is string => typeof item === 'string' && item.length > 0); + + return roots.flatMap(root => [ + path.win32.join(root, 'Programs', 'Microsoft VS Code', 'resources', 'app'), + path.win32.join(root, 'Programs', 'Microsoft VS Code Insiders', 'resources', 'app'), + path.win32.join(root, 'Microsoft VS Code', 'resources', 'app'), + path.win32.join(root, 'Microsoft VS Code Insiders', 'resources', 'app'), + path.win32.join(root, 'VSCodium', 'resources', 'app'), + path.win32.join(root, 'Cursor', 'resources', 'app') + ]); +} + +function joinPlatformPath(platform: NodeJS.Platform, ...paths: string[]): string { + return getPlatformPath(platform).join(...paths); +} + +function resolvePlatformPath(platform: NodeJS.Platform, ...paths: string[]): string { + return getPlatformPath(platform).resolve(...paths); +} + +function getPlatformPath(platform: NodeJS.Platform): path.PlatformPath { + return platform === 'win32' ? path.win32 : path.posix; +} + +function getRealPathIfAvailable(filePath: string): string { + try { + return fs.realpathSync(filePath); + } catch { + return filePath; + } +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values)); +} diff --git a/gateway-vscode/src/tools/searchCodeTool.ts b/gateway-vscode/src/tools/searchCodeTool.ts index e3e0f6c..4b5907b 100644 --- a/gateway-vscode/src/tools/searchCodeTool.ts +++ b/gateway-vscode/src/tools/searchCodeTool.ts @@ -1,38 +1,80 @@ import { spawn } from 'child_process'; -import * as fs from 'fs'; -import * as fsPromises from 'fs/promises'; -import * as path from 'path'; -import * as vscode from 'vscode'; +import type * as vscode from 'vscode'; import type { LocalTool } from './types'; import { textResult } from './result'; +import { DEFAULT_EXCLUDED_DIRECTORIES, getNumberArg, getStringArrayArg, resolveWorkspaceDirectory } from './filesystemUtils'; +import { createSearchCodeFallbackNotice, searchCodeInProcess } from './searchCodeFallback'; +import { appendRipgrepMatch } from './searchCodeRipgrepOutput'; +import { createRipgrepStartError, resolveRipgrepCommand, RipgrepUnavailableError } from './ripgrep'; import { - DEFAULT_EXCLUDED_DIRECTORIES, - getNumberArg, - getStringArrayArg, - matchesPattern, - normalizeLineEndings, - resolveWorkspaceDirectory, - toPosixPath, - walkWorkspaceFiles -} from './filesystemUtils'; - -const MAX_FALLBACK_FILE_SIZE_BYTES = 1024 * 1024 * 2; + createRipgrepExcludeGlobs, + DEFAULT_MATCH_LINE_MAX_CHARS, + getBoundedSearchLineMaxChars, + getSearchCodeMatchMode, + MAX_MATCH_LINE_MAX_CHARS, + MIN_MATCH_LINE_MAX_CHARS, + normalizeIncludeGlob, +} from './searchCodeUtils'; +import type { SearchCodeOptions } from './searchCodeTypes'; + +const DEFAULT_EXCLUDED_DIRECTORY_NAMES = DEFAULT_EXCLUDED_DIRECTORIES.join(', '); export const searchCodeTool: LocalTool = { serverId: 'internal', definition: { name: 'search_code', - description: 'Search text content inside workspace files using ripgrep. Returns relative file paths with line numbers and matching lines.', + description: [ + 'Search text content inside workspace files using ripgrep.', + 'Returns relative file paths with line numbers and matching lines.' + ].join(' '), inputSchema: { type: 'object', properties: { - query: { type: 'string', minLength: 1, description: 'Text or ripgrep regular expression to search for.' }, + query: { + type: 'string', + minLength: 1, + description: [ + 'Text to search for.', + 'match "substring" searches for a literal contained substring.', + 'match "regex" treats this as a ripgrep regular expression.' + ].join(' ') + }, path: { type: 'string', description: 'Optional workspace directory to search. Defaults to ".".' }, include: { type: 'string', description: 'Optional glob for files to include, for example "**/*.ts".' }, case_sensitive: { type: 'boolean', description: 'Whether matching is case-sensitive. Default: false.', default: false }, - use_regex: { type: 'boolean', description: 'Treat query as a ripgrep regular expression. Default: false.', default: false }, - max_results: { type: 'integer', minimum: 1, maximum: 500, description: 'Maximum matching lines to return. Default: 100.', default: 100 }, - exclude_patterns: { type: 'array', items: { type: 'string' }, description: 'Glob patterns to exclude.' } + match: { + type: 'string', + enum: ['substring', 'regex'], + description: 'How to interpret query. "substring" is a literal contained substring; "regex" is a ripgrep regular expression. Default: substring.', + default: 'substring' + }, + max_results: { + type: 'integer', + minimum: 1, + maximum: 500, + description: 'Maximum matching lines to return. Default: 100.', + default: 100 + }, + max_line_chars: { + type: 'integer', + minimum: MIN_MATCH_LINE_MAX_CHARS, + maximum: MAX_MATCH_LINE_MAX_CHARS, + description: [ + 'Maximum characters to return from each matching line.', + 'Long lines are cropped around the match. Default: 500.' + ].join(' '), + default: DEFAULT_MATCH_LINE_MAX_CHARS + }, + exclude_patterns: { + type: 'array', + items: { type: 'string' }, + description: [ + 'Additional glob patterns to exclude, merged with the default excluded directory names.', + `Default excluded directory names: ${DEFAULT_EXCLUDED_DIRECTORY_NAMES}.`, + 'Patterns are matched against paths under the search root; bare names match anywhere.', + 'search_code uses ripgrep default ignore behavior, so .gitignore/.ignore may also exclude files.' + ].join(' ') + } }, required: ['query'] }, @@ -52,60 +94,43 @@ export const searchCodeTool: LocalTool = { includePattern: typeof args.include === 'string' ? args.include : undefined, excludePatterns, caseSensitive: args.case_sensitive === true, - useRegex: args.use_regex === true + useRegex: getSearchCodeMatchMode(args.match) === 'regex', + matchLineMaxChars: getBoundedSearchLineMaxChars(args.max_line_chars) }; - const matches = await runRipgrepWithFallback(options); + const matches = await runRipgrepWithFallback(options, context.outputChannel); return textResult(matches.length > 0 ? matches.join('\n') : 'No matches found.'); } }; -type RipgrepOptions = { - searchRoot: string; - workspaceRoot: string; - query: string; - maxResults: number; - includePattern?: string; - excludePatterns: string[]; - caseSensitive: boolean; - useRegex: boolean; -}; - -type RipgrepMatchMessage = { - type: string; - data?: { - path?: { text?: string }; - lines?: { text?: string }; - line_number?: number; - }; -}; - -type RipgrepCommand = { - command: string; - source: string; - checkedLocations: string[]; -}; - -class RipgrepUnavailableError extends Error { - constructor(message: string) { - super(message); - this.name = 'RipgrepUnavailableError'; - } -} - -async function runRipgrepWithFallback(options: RipgrepOptions): Promise { +async function runRipgrepWithFallback( + options: SearchCodeOptions, + outputChannel: vscode.OutputChannel +): Promise { try { return await runRipgrep(options); } catch (error) { if (error instanceof RipgrepUnavailableError) { - return searchCodeInProcess(options); + logRipgrepFallback(outputChannel, error); + const matches = await searchCodeInProcess(options); + return [ + createSearchCodeFallbackNotice(), + ...(matches.length > 0 ? matches : ['No matches found.']) + ]; } throw error; } } -async function runRipgrep(options: RipgrepOptions): Promise { +function logRipgrepFallback(outputChannel: vscode.OutputChannel, error: RipgrepUnavailableError): void { + outputChannel.appendLine('[search_code] ripgrep unavailable; using in-process fallback.'); + for (const line of error.message.split('\n')) { + outputChannel.appendLine(`[search_code] ${line}`); + } +} + +async function runRipgrep(options: SearchCodeOptions): Promise { const rgCommand = resolveRipgrepCommand(); const args = createRipgrepArgs(options); const matches: string[] = []; @@ -167,149 +192,7 @@ async function runRipgrep(options: RipgrepOptions): Promise { }); } -function resolveRipgrepCommand(): RipgrepCommand { - const checkedLocations: string[] = []; - - const configuredPath = getConfiguredRipgrepPath(); - if (configuredPath) { - checkedLocations.push(`webcodeGateway.ripgrep.path: ${configuredPath}`); - if (fs.existsSync(configuredPath)) { - return { - command: configuredPath, - source: 'webcodeGateway.ripgrep.path', - checkedLocations - }; - } - } else { - checkedLocations.push('webcodeGateway.ripgrep.path: not set'); - } - - const vscodeRipgrepCandidates = getVSCodeRipgrepCandidates(); - - for (const candidate of vscodeRipgrepCandidates) { - checkedLocations.push(`VS Code bundled ripgrep: ${candidate}`); - if (fs.existsSync(candidate)) { - return { - command: candidate, - source: 'VS Code bundled ripgrep', - checkedLocations - }; - } - } - - if (vscodeRipgrepCandidates.length === 0) { - checkedLocations.push('VS Code bundled ripgrep: vscode.env.appRoot is not available'); - } - - const pathCommand = getRipgrepBinaryName(process.platform); - checkedLocations.push(`PATH command: ${pathCommand}`); - return { - command: pathCommand, - source: 'PATH', - checkedLocations - }; -} - -function getVSCodeRipgrepCandidates(): string[] { - const binaryName = getRipgrepBinaryName(process.platform); - if (!vscode.env.appRoot) { - return []; - } - - return [ - path.join(vscode.env.appRoot, 'node_modules.asar.unpacked', '@vscode', 'ripgrep', 'bin', binaryName), - path.join(vscode.env.appRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', binaryName), - path.join(vscode.env.appRoot, 'node_modules.asar.unpacked', 'vscode-ripgrep', 'bin', binaryName), - path.join(vscode.env.appRoot, 'node_modules', 'vscode-ripgrep', 'bin', binaryName) - ]; -} - -function getConfiguredRipgrepPath(): string | undefined { - const configuredPath = vscode.workspace - .getConfiguration('webcodeGateway') - .get('ripgrep.path', '') - .trim(); - return configuredPath || undefined; -} - -function getRipgrepBinaryName(platform: string): string { - return platform === 'win32' ? 'rg.exe' : 'rg'; -} - -function createRipgrepStartError(error: Error, rgCommand: RipgrepCommand): Error { - const code = (error as NodeJS.ErrnoException).code; - const detail = code ? `${error.message} (${code})` : error.message; - return new RipgrepUnavailableError( - [ - `search_code could not start ripgrep from ${rgCommand.source}: ${detail}`, - `Platform: ${process.platform}-${process.arch}`, - 'Checked:', - ...rgCommand.checkedLocations.map(location => `- ${location}`), - 'To enable search_code, install ripgrep so rg is on PATH, or set webcodeGateway.ripgrep.path to the absolute path of rg/rg.exe.' - ].join('\n') - ); -} - -async function searchCodeInProcess(options: RipgrepOptions): Promise { - const matcher = createFallbackMatcher(options.query, { - caseSensitive: options.caseSensitive, - useRegex: options.useRegex - }); - const matches: string[] = []; - - await walkWorkspaceFiles(options.searchRoot, async (filePath, relativeToSearchRoot) => { - if ( - options.includePattern && - !matchesPattern(relativeToSearchRoot, options.includePattern) && - !matchesPattern(path.basename(filePath), options.includePattern) - ) { - return false; - } - - const stats = await fsPromises.stat(filePath); - if (stats.size > MAX_FALLBACK_FILE_SIZE_BYTES) { - return false; - } - - const rawContent = await fsPromises.readFile(filePath, 'utf8').catch(() => null); - if (rawContent == null || rawContent.includes('\0')) { - return false; - } - - const lines = normalizeLineEndings(rawContent).split('\n'); - for (let index = 0; index < lines.length; index++) { - if (!matcher(lines[index])) { - continue; - } - - const relativePath = toPosixPath(path.relative(options.workspaceRoot, filePath)); - matches.push(`${relativePath}:${index + 1}: ${lines[index].trimEnd()}`); - if (matches.length >= options.maxResults) { - return true; - } - } - - return false; - }, { - excludePatterns: options.excludePatterns, - includePattern: options.includePattern - }); - - return matches; -} - -function createFallbackMatcher(query: string, options: { caseSensitive: boolean; useRegex: boolean }): (line: string) => boolean { - if (options.useRegex) { - const flags = options.caseSensitive ? '' : 'i'; - const regex = new RegExp(query, flags); - return line => regex.test(line); - } - - const needle = options.caseSensitive ? query : query.toLowerCase(); - return line => (options.caseSensitive ? line : line.toLowerCase()).includes(needle); -} - -function createRipgrepArgs(options: RipgrepOptions): string[] { +function createRipgrepArgs(options: SearchCodeOptions): string[] { const args = [ '--json', '--line-number', @@ -338,64 +221,3 @@ function createRipgrepArgs(options: RipgrepOptions): string[] { args.push('--regexp', options.query, '.'); return args; } - -function appendRipgrepMatch(line: string, options: RipgrepOptions, matches: string[]): void { - const trimmed = line.trim(); - if (!trimmed) { - return; - } - - let message: RipgrepMatchMessage; - try { - message = JSON.parse(trimmed) as RipgrepMatchMessage; - } catch { - return; - } - - if (message.type !== 'match' || !message.data?.path?.text || typeof message.data.line_number !== 'number') { - return; - } - - const rawPath = message.data.path.text; - const absolutePath = path.isAbsolute(rawPath) - ? rawPath - : path.resolve(options.searchRoot, rawPath); - const relativePath = toPosixPath(path.relative(options.workspaceRoot, absolutePath)); - const lineText = (message.data.lines?.text ?? '').replace(/\r?\n$/, '').trimEnd(); - matches.push(`${relativePath}:${message.data.line_number}: ${lineText}`); -} - -function normalizeIncludeGlob(pattern: string | undefined): string | undefined { - const normalized = typeof pattern === 'string' ? toPosixPath(pattern.trim()) : ''; - if (!normalized) { - return undefined; - } - - return normalized.includes('/') ? normalized : `**/${normalized}`; -} - -function createRipgrepExcludeGlobs(excludePatterns: string[]): string[] { - return [ - ...DEFAULT_EXCLUDED_DIRECTORIES.flatMap(directory => [ - `${directory}/**`, - `**/${directory}/**` - ]), - ...excludePatterns.flatMap(expandUserExcludePattern) - ]; -} - -function expandUserExcludePattern(pattern: string): string[] { - const normalized = toPosixPath(pattern.trim()); - if (!normalized) { - return []; - } - if (normalized.includes('/') || hasGlobSyntax(normalized)) { - return [normalized]; - } - - return [normalized, `**/${normalized}`, `**/${normalized}/**`]; -} - -function hasGlobSyntax(value: string): boolean { - return /[*?[\]{}]/.test(value); -} diff --git a/gateway-vscode/src/tools/searchCodeTypes.ts b/gateway-vscode/src/tools/searchCodeTypes.ts new file mode 100644 index 0000000..ce99780 --- /dev/null +++ b/gateway-vscode/src/tools/searchCodeTypes.ts @@ -0,0 +1,11 @@ +export type SearchCodeOptions = { + searchRoot: string; + workspaceRoot: string; + query: string; + maxResults: number; + includePattern?: string; + excludePatterns: string[]; + caseSensitive: boolean; + useRegex: boolean; + matchLineMaxChars: number; +}; diff --git a/gateway-vscode/src/tools/searchCodeUtils.ts b/gateway-vscode/src/tools/searchCodeUtils.ts new file mode 100644 index 0000000..9bd4e5c --- /dev/null +++ b/gateway-vscode/src/tools/searchCodeUtils.ts @@ -0,0 +1,176 @@ +import { + DEFAULT_EXCLUDED_DIRECTORIES, + getNumberArg, + toPosixPath +} from './filesystemUtils'; + +export const DEFAULT_MATCH_LINE_MAX_CHARS = 500; +export const MIN_MATCH_LINE_MAX_CHARS = 80; +export const MAX_MATCH_LINE_MAX_CHARS = 5000; + +export type SearchMatchRange = { + start: number; + end: number; +}; + +export type SearchCodeMatchMode = 'substring' | 'regex'; + +type SearchLineFormatOptions = { + query: string; + caseSensitive: boolean; + useRegex: boolean; + matchLineMaxChars: number; +}; + +export function getBoundedSearchLineMaxChars(value: unknown): number { + const numberValue = Math.floor(getNumberArg(value, DEFAULT_MATCH_LINE_MAX_CHARS)); + return Math.min(MAX_MATCH_LINE_MAX_CHARS, Math.max(MIN_MATCH_LINE_MAX_CHARS, numberValue)); +} + +export function getSearchCodeMatchMode(value: unknown): SearchCodeMatchMode { + if (value === undefined || value === 'substring') { + return 'substring'; + } + if (value === 'regex') { + return 'regex'; + } + + throw new Error('match must be "substring" or "regex".'); +} + +export function normalizeIncludeGlob(pattern: string | undefined): string | undefined { + const normalized = typeof pattern === 'string' ? toPosixPath(pattern.trim()) : ''; + if (!normalized) { + return undefined; + } + + return normalized.includes('/') ? normalized : `**/${normalized}`; +} + +export function createRipgrepExcludeGlobs(excludePatterns: string[]): string[] { + return [ + ...DEFAULT_EXCLUDED_DIRECTORIES.flatMap(directory => [ + `${directory}/**`, + `**/${directory}/**` + ]), + ...excludePatterns.flatMap(expandUserExcludePattern) + ]; +} + +export function formatSearchCodeMatch( + relativePath: string, + lineNumber: number, + lineText: string, + options: SearchLineFormatOptions, + matchRange?: SearchMatchRange +): string { + const range = matchRange ?? findLineMatchRange(lineText, options); + return `${relativePath}:${lineNumber}: ${truncateSearchMatchLine(lineText, options.matchLineMaxChars, range)}`; +} + +export function byteRangeToStringRange( + text: string, + startByte: number, + endByte: number +): SearchMatchRange | undefined { + const start = byteOffsetToStringIndex(text, startByte); + const end = byteOffsetToStringIndex(text, endByte); + if (start === undefined || end === undefined || end < start) { + return undefined; + } + + return { start, end }; +} + +export function truncateSearchMatchLine( + lineText: string, + maxChars: number, + matchRange?: SearchMatchRange +): string { + if (lineText.length <= maxChars) { + return lineText; + } + + const safeMaxChars = Math.max(1, Math.floor(maxChars)); + const start = getSearchPreviewStart(lineText.length, safeMaxChars, matchRange); + const end = Math.min(start + safeMaxChars, lineText.length); + const prefix = start > 0 ? `[...${start} chars omitted...] ` : ''; + const suffix = end < lineText.length ? ` [...${lineText.length - end} chars omitted...]` : ''; + return `${prefix}${lineText.slice(start, end)}${suffix}`; +} + +function expandUserExcludePattern(pattern: string): string[] { + const normalized = toPosixPath(pattern.trim()); + if (!normalized) { + return []; + } + if (normalized.includes('/') || hasGlobSyntax(normalized)) { + return [normalized]; + } + + return [normalized, `**/${normalized}`, `**/${normalized}/**`]; +} + +function hasGlobSyntax(value: string): boolean { + return /[*?[\]{}]/.test(value); +} + +function byteOffsetToStringIndex(text: string, byteOffset: number): number | undefined { + if (!Number.isInteger(byteOffset) || byteOffset < 0) { + return undefined; + } + + let bytes = 0; + for (let index = 0; index < text.length; index++) { + if (bytes >= byteOffset) { + return index; + } + + const codePoint = text.codePointAt(index); + if (codePoint === undefined) { + return undefined; + } + + const character = String.fromCodePoint(codePoint); + const nextBytes = bytes + Buffer.byteLength(character, 'utf8'); + if (nextBytes > byteOffset) { + return index; + } + + bytes = nextBytes; + if (codePoint > 0xFFFF) { + index++; + } + } + + return bytes >= byteOffset ? text.length : undefined; +} + +function getSearchPreviewStart(lineLength: number, maxChars: number, matchRange?: SearchMatchRange): number { + if (!matchRange) { + return 0; + } + + const matchStart = Math.min(Math.max(matchRange.start, 0), lineLength); + const matchEnd = Math.min(Math.max(matchRange.end, matchStart), lineLength); + const matchCenter = Math.floor((matchStart + matchEnd) / 2); + const preferredStart = matchCenter - Math.floor(maxChars / 2); + return Math.min(Math.max(preferredStart, 0), Math.max(lineLength - maxChars, 0)); +} + +function findLineMatchRange(lineText: string, options: SearchLineFormatOptions): SearchMatchRange | undefined { + if (!options.useRegex) { + const haystack = options.caseSensitive ? lineText : lineText.toLowerCase(); + const needle = options.caseSensitive ? options.query : options.query.toLowerCase(); + const start = haystack.indexOf(needle); + return start >= 0 ? { start, end: start + needle.length } : undefined; + } + + try { + const regex = new RegExp(options.query, options.caseSensitive ? '' : 'i'); + const match = regex.exec(lineText); + return match ? { start: match.index, end: match.index + match[0].length } : undefined; + } catch { + return undefined; + } +} diff --git a/gateway-vscode/src/tools/searchFilesPatterns.ts b/gateway-vscode/src/tools/searchFilesPatterns.ts deleted file mode 100644 index aede9fe..0000000 --- a/gateway-vscode/src/tools/searchFilesPatterns.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { DEFAULT_EXCLUDED_DIRECTORIES, toPosixPath } from './filesystemUtils'; - -export function createFileSearchIncludePattern(query: string): string { - if (hasGlobSyntax(query)) { - return query.includes('/') ? query : `**/${query}`; - } - - const escapedQuery = escapeFindFilesLiteral(query); - if (query.includes('/')) { - return `{**/*${escapedQuery}*,**/*${escapedQuery}*/**}`; - } - - return `**/*${escapedQuery}*`; -} - -export function createFindFilesExcludePattern(excludePatterns: string[]): string | undefined { - const patterns = [ - ...DEFAULT_EXCLUDED_DIRECTORIES.map(directory => `**/${directory}/**`), - ...excludePatterns.map(pattern => toPosixPath(pattern.trim())).filter(Boolean) - ]; - - if (patterns.length === 0) { - return undefined; - } - if (patterns.length === 1) { - return patterns[0]; - } - - return `{${patterns.join(',')}}`; -} - -function hasGlobSyntax(value: string): boolean { - return /[*?{}]/.test(value); -} - -function escapeFindFilesLiteral(value: string): string { - return value.replace(/[\[\]]/g, character => (character === '[' ? '[[]' : '[]]')); -} diff --git a/gateway-vscode/src/tools/searchFilesRipgrepArgs.ts b/gateway-vscode/src/tools/searchFilesRipgrepArgs.ts new file mode 100644 index 0000000..2f2d49b --- /dev/null +++ b/gateway-vscode/src/tools/searchFilesRipgrepArgs.ts @@ -0,0 +1,18 @@ +import { createRipgrepExcludeGlobs } from './searchCodeUtils'; + +export function createRipgrepFilesArgs(excludePatterns: string[]): string[] { + const args = [ + '--files', + '--hidden', + '--no-ignore', + '--no-messages', + '--sort', + 'path' + ]; + + for (const pattern of createRipgrepExcludeGlobs(excludePatterns)) { + args.push('--glob', `!${pattern}`); + } + + return args; +} diff --git a/gateway-vscode/src/tools/searchFilesTool.ts b/gateway-vscode/src/tools/searchFilesTool.ts index 6162a43..861fdfb 100644 --- a/gateway-vscode/src/tools/searchFilesTool.ts +++ b/gateway-vscode/src/tools/searchFilesTool.ts @@ -1,16 +1,24 @@ +import { spawn } from 'child_process'; import * as path from 'path'; -import * as vscode from 'vscode'; +import type * as vscode from 'vscode'; import type { LocalTool } from './types'; import { textResult } from './result'; import { + DEFAULT_EXCLUDED_DIRECTORIES, + type FileQueryMatchMode, getNumberArg, getStringArrayArg, matchesAnyPattern, matchesFileQuery, + resolveFileQueryMatchMode, resolveWorkspaceDirectory, - toPosixPath + toPosixPath, + walkWorkspaceFiles } from './filesystemUtils'; -import { createFileSearchIncludePattern, createFindFilesExcludePattern } from './searchFilesPatterns'; +import { createRipgrepStartError, resolveRipgrepCommand, RipgrepUnavailableError } from './ripgrep'; +import { createRipgrepFilesArgs } from './searchFilesRipgrepArgs'; + +const DEFAULT_EXCLUDED_DIRECTORY_NAMES = DEFAULT_EXCLUDED_DIRECTORIES.join(', '); export const searchFilesTool: LocalTool = { serverId: 'internal', @@ -20,49 +28,226 @@ export const searchFilesTool: LocalTool = { inputSchema: { type: 'object', properties: { - query: { type: 'string', minLength: 1, description: 'Filename substring or glob pattern, for example "gateway.ts" or "**/*.test.ts".' }, + query: { type: 'string', minLength: 1, description: 'Filename/path substring or glob pattern. Default: "*", which lists files under path.' }, path: { type: 'string', description: 'Optional workspace directory to search. Defaults to ".".' }, + match: { + type: 'string', + enum: ['auto', 'substring', 'glob'], + description: 'How to interpret query. auto treats * ? { } as glob syntax and everything else as a substring. Default: auto.', + default: 'auto' + }, + case_sensitive: { type: 'boolean', description: 'Whether matching is case-sensitive. Default: false.', default: false }, max_results: { type: 'integer', minimum: 1, maximum: 500, description: 'Maximum matches to return. Default: 200.', default: 200 }, - exclude_patterns: { type: 'array', items: { type: 'string' }, description: 'Glob patterns to exclude.' } - }, - required: ['query'] + exclude_patterns: { + type: 'array', + items: { type: 'string' }, + description: [ + 'Additional glob patterns to exclude, merged with the default excluded directory names.', + `Default excluded directory names: ${DEFAULT_EXCLUDED_DIRECTORY_NAMES}.`, + 'Patterns are matched against paths under the search root; bare names match anywhere.', + 'search_files ignores .gitignore/.ignore via ripgrep --no-ignore.' + ].join(' ') + } + } }, annotations: { readOnlyHint: true } }, async execute(args, context) { const searchRoot = await resolveWorkspaceDirectory(context.workspaceRoot, args.path ?? '.'); const workspaceRoot = context.workspaceRoot ?? searchRoot; - const query = toPosixPath(String(args.query).trim()); - const maxResults = getNumberArg(args.max_results, 200); - const excludePatterns = getStringArrayArg(args.exclude_patterns); - const includePattern = createFileSearchIncludePattern(query); - const excludePattern = createFindFilesExcludePattern(excludePatterns); - const uris = await vscode.workspace.findFiles( - new vscode.RelativePattern(searchRoot, includePattern), - excludePattern ? new vscode.RelativePattern(searchRoot, excludePattern) : undefined, - maxResults - ); - - const matches: string[] = []; - for (const uri of uris.sort((left, right) => left.fsPath.localeCompare(right.fsPath))) { - const filePath = uri.fsPath; - const relativeToSearchRoot = toPosixPath(path.relative(searchRoot, filePath)); - const relativeToWorkspace = toPosixPath(path.relative(workspaceRoot, filePath)); - const fileName = path.basename(filePath); - if ( - matchesAnyPattern(relativeToSearchRoot, excludePatterns) || - matchesAnyPattern(relativeToWorkspace, excludePatterns) || - !matchesFileQuery(relativeToSearchRoot, fileName, query) - ) { - continue; + const options: SearchFilesOptions = { + searchRoot, + workspaceRoot, + query: normalizeSearchFilesQuery(args.query), + matchMode: getSearchFilesMatchMode(args.match), + caseSensitive: args.case_sensitive === true, + maxResults: getNumberArg(args.max_results, 200), + excludePatterns: getStringArrayArg(args.exclude_patterns) + }; + const matches = await searchFilePathsWithFallback(options, context.outputChannel); + + return textResult(matches.length > 0 ? matches.join('\n') : formatNoMatchesMessage(options)); + } +}; + +type SearchFilesOptions = { + searchRoot: string; + workspaceRoot: string; + query: string; + matchMode: FileQueryMatchMode; + caseSensitive: boolean; + maxResults: number; + excludePatterns: string[]; +}; + +async function searchFilePathsWithFallback( + options: SearchFilesOptions, + outputChannel: vscode.OutputChannel +): Promise { + try { + return await runRipgrepFiles(options); + } catch (error) { + if (!(error instanceof RipgrepUnavailableError)) { + throw error; + } + + outputChannel.appendLine('[search_files] ripgrep unavailable; using workspace file walker.'); + for (const line of error.message.split('\n')) { + outputChannel.appendLine(`[search_files] ${line}`); + } + return searchFilesInProcess(options); + } +} + +async function runRipgrepFiles(options: SearchFilesOptions): Promise { + const rgCommand = resolveRipgrepCommand(); + const args = createRipgrepFilesArgs(options.excludePatterns); + const matches: string[] = []; + let stdoutBuffer = ''; + let stderr = ''; + let limitReached = false; + + return new Promise((resolve, reject) => { + const child = spawn(rgCommand.command, args, { + cwd: options.searchRoot, + windowsHide: true + }); + let spawnFailed = false; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdoutBuffer += chunk; + + let newlineIndex = stdoutBuffer.indexOf('\n'); + while (newlineIndex >= 0) { + const line = stdoutBuffer.slice(0, newlineIndex); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + appendFileSearchMatch(line, options, matches); + + if (matches.length >= options.maxResults) { + limitReached = true; + child.kill(); + break; + } + + newlineIndex = stdoutBuffer.indexOf('\n'); } + }); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); - matches.push(relativeToWorkspace); - if (matches.length >= maxResults) { - break; + child.on('error', error => { + spawnFailed = true; + reject(createRipgrepStartError(error, rgCommand)); + }); + child.on('close', code => { + if (spawnFailed) { + return; + } + if (stdoutBuffer.length > 0 && matches.length < options.maxResults) { + appendFileSearchMatch(stdoutBuffer, options, matches); } - } - return textResult(matches.length > 0 ? matches.join('\n') : 'No matches found.'); + if (code === 0 || code === 1 || limitReached) { + resolve(matches.slice(0, options.maxResults)); + return; + } + + reject(new Error(`search_files failed: ${stderr.trim() || `ripgrep exited with code ${code ?? 'unknown'}`}`)); + }); + }); +} + +async function searchFilesInProcess(options: SearchFilesOptions): Promise { + const matches: string[] = []; + await walkWorkspaceFiles(options.searchRoot, (filePath, relativeToSearchRoot) => { + appendWorkspaceFileSearchMatch(filePath, relativeToSearchRoot, options, matches); + return Promise.resolve(false); + }, { + excludePatterns: options.excludePatterns + }); + + return matches.sort((left, right) => left.localeCompare(right)).slice(0, options.maxResults); +} + +function appendFileSearchMatch(line: string, options: SearchFilesOptions, matches: string[]): void { + const relativeToSearchRoot = toPosixPath(line.replace(/\r$/, '')); + if (!relativeToSearchRoot) { + return; } -}; + + appendWorkspaceFileSearchMatch( + path.resolve(options.searchRoot, relativeToSearchRoot), + relativeToSearchRoot, + options, + matches + ); +} + +function appendWorkspaceFileSearchMatch( + filePath: string, + relativeToSearchRoot: string, + options: SearchFilesOptions, + matches: string[] +): void { + const relativeToWorkspace = toPosixPath(path.relative(options.workspaceRoot, filePath)); + const fileName = getPosixBasename(relativeToSearchRoot); + if ( + matchesAnyPattern(relativeToSearchRoot, options.excludePatterns) || + matchesAnyPattern(relativeToWorkspace, options.excludePatterns) || + !matchesFileQuery(relativeToSearchRoot, fileName, options.query, { + caseSensitive: options.caseSensitive, + matchMode: options.matchMode + }) + ) { + return; + } + + matches.push(relativeToWorkspace); +} + +function normalizeSearchFilesQuery(value: unknown): string { + if (typeof value !== 'string') { + return '*'; + } + + const query = toPosixPath(value.trim()); + return query || '*'; +} + +function getSearchFilesMatchMode(value: unknown): FileQueryMatchMode { + return value === 'substring' || value === 'glob' ? value : 'auto'; +} + +function getPosixBasename(value: string): string { + const parts = toPosixPath(value).split('/'); + return parts[parts.length - 1] ?? value; +} + +function formatNoMatchesMessage(options: SearchFilesOptions): string { + const effectiveMatchMode = resolveFileQueryMatchMode(options.query, options.matchMode); + const lines = [ + 'No matches found.', + `Searched path: ${formatSearchRoot(options)}`, + `Query: ${options.query}`, + `Match: ${options.matchMode}${options.matchMode === 'auto' ? ` (${effectiveMatchMode})` : ''}`, + `Case sensitive: ${options.caseSensitive}` + ]; + + if (options.query === '.') { + lines.push('Hint: query "." matches a literal dot. Use query "*" to list files.'); + } + if (options.query.includes('|')) { + lines.push('Hint: "|" is treated literally. Use glob braces like "*{foo,bar}*" for simple alternatives.'); + } + + return lines.join('\n'); +} + +function formatSearchRoot(options: SearchFilesOptions): string { + const relative = toPosixPath(path.relative(options.workspaceRoot, options.searchRoot)); + return relative || '.'; +} diff --git a/gateway-vscode/src/unit-test/editFileTool.test.ts b/gateway-vscode/src/unit-test/editFileTool.test.ts new file mode 100644 index 0000000..de340a7 --- /dev/null +++ b/gateway-vscode/src/unit-test/editFileTool.test.ts @@ -0,0 +1,92 @@ +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { createUnifiedDiff } from '../tools/filesystemUtils'; +import { editFileTool } from '../tools/editFileTool'; +import type { ToolExecutionContext, ToolResult } from '../tools/types'; + +suite('Edit File Tool', () => { + test('preserves dollar replacement tokens as literal text', async () => { + await withTempWorkspace(async workspaceRoot => { + const relativePath = 'sample.txt'; + const targetPath = path.join(workspaceRoot, relativePath); + await fs.writeFile(targetPath, 'old\n', 'utf8'); + + await editFileTool.execute( + { + path: relativePath, + edits: [{ oldText: 'old\n', newText: 'literal $& and $$\n' }] + }, + { workspaceRoot } as ToolExecutionContext + ); + + assert.strictEqual(await fs.readFile(targetPath, 'utf8'), 'literal $& and $$\n'); + }); + }); + + test('explains how to recover from a patch without hunks', async () => { + await withTempWorkspace(async workspaceRoot => { + const relativePath = 'sample.txt'; + await fs.writeFile(path.join(workspaceRoot, relativePath), 'old\n', 'utf8'); + + await assert.rejects( + editFileTool.execute( + { + path: relativePath, + patch: '--- sample.txt\n+++ sample.txt\n-old\n+new\n' + }, + { workspaceRoot } as ToolExecutionContext + ), + /Use edits for exact text replacements/ + ); + }); + }); + + test('truncates very large generated diffs', () => { + const originalContent = 'start\n' + 'a'.repeat(30000) + '\nend\n'; + const newContent = 'start\n' + 'b'.repeat(30000) + '\nend\n'; + const diff = createUnifiedDiff(originalContent, newContent, 'sample.txt', 1000); + + assert.ok(diff.includes('diff output truncated')); + assert.ok(diff.length <= 1000); + assert.ok(diff.endsWith('\n```')); + }); + + test('returns truncated dry-run diffs without writing the file', async () => { + await withTempWorkspace(async workspaceRoot => { + const relativePath = 'sample.txt'; + const targetPath = path.join(workspaceRoot, relativePath); + const originalContent = 'start\n' + 'a'.repeat(30000) + '\nend\n'; + await fs.writeFile(targetPath, originalContent, 'utf8'); + + const result = await editFileTool.execute( + { + path: relativePath, + edits: [{ oldText: 'a'.repeat(30000), newText: 'b'.repeat(30000) }], + dryRun: true + }, + { workspaceRoot } as ToolExecutionContext + ); + + assert.ok(getResultText(result).includes('diff output truncated')); + assert.strictEqual(await fs.readFile(targetPath, 'utf8'), originalContent); + }); + }); +}); + +async function withTempWorkspace(callback: (workspaceRoot: string) => Promise): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'edit-file-tool-workspace-')); + try { + return await callback(tempDir); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +function getResultText(result: ToolResult): string { + return result.content + .map(item => typeof item.text === 'string' ? item.text : '') + .join('\n'); +} diff --git a/gateway-vscode/src/unit-test/readFileTool.test.ts b/gateway-vscode/src/unit-test/readFileTool.test.ts index dec02bb..5d52f9f 100644 --- a/gateway-vscode/src/unit-test/readFileTool.test.ts +++ b/gateway-vscode/src/unit-test/readFileTool.test.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { resolveWorkspacePath } from '../tools/filesystemUtils'; import { readFileContent, readFilePrefix, selectReadFileContent, selectReadFileResult } from '../tools/readFileTool'; +import { READ_FILE_OUTPUT_MAX_BYTES, READ_FILE_OUTPUT_MAX_LINES } from '../tools/readFileOutputLimit'; suite('Read File Tool', () => { const content = ['alpha', 'bravo', 'charlie', 'delta'].join('\n'); @@ -29,67 +30,155 @@ suite('Read File Tool', () => { ); }); - test('clamps head metadata to the available line count', () => { + test('clamps head output to the available line count', () => { const result = selectReadFileResult(content, { head: 20 }); assert.strictEqual(result.text, content); - assert.deepStrictEqual(result.metadata.returnedLines, { - start: 1, - end: 4 - }); + assert.strictEqual(result.metadata, undefined); }); test('returns an empty range when start_line is past EOF', () => { - const result = selectReadFileResult(content, { start_line: 20 }); + const result = selectReadFileResult(content, { start_line: 20, end_line: 25 }); assert.strictEqual(result.text, ''); - assert.deepStrictEqual(result.metadata.returnedLines, { - start: 0, - end: 0 - }); + assert.strictEqual(result.metadata, undefined); }); test('rejects mixed head and range selectors', () => { assert.throws( - () => selectReadFileContent(content, { head: 2, start_line: 2 }), + () => selectReadFileContent(content, { head: 2, start_line: 2, end_line: 3 }), /Cannot specify head or tail with start_line or end_line/ ); }); + test('rejects partial line ranges', () => { + assert.throws( + () => selectReadFileContent(content, { start_line: 2 }), + /start_line and end_line must be specified together/ + ); + }); + test('returns full content for small files without a line selector', () => { const result = selectReadFileResult(content, {}, { fileBytes: Buffer.byteLength(content, 'utf8') }); assert.strictEqual(result.text, content); - assert.deepStrictEqual(result.metadata, { - mode: 'full', - truncated: false, - lineCount: 4, - returnedLines: { - start: 1, - end: 4 - }, - fileBytes: Buffer.byteLength(content, 'utf8') - }); + assert.strictEqual(result.metadata, undefined); }); test('truncates large line counts when no selector is provided', () => { - const largeContent = Array.from({ length: 401 }, (_, index) => `line ${index + 1}`).join('\n'); + const largeContent = Array.from({ length: READ_FILE_OUTPUT_MAX_LINES + 1 }, (_, index) => `line ${index + 1}`).join('\n'); const result = selectReadFileResult(largeContent, {}, { fileBytes: Buffer.byteLength(largeContent, 'utf8') }); + const metadata = requireReadFileMetadata(result); + + assert.strictEqual(metadata.truncated, true); + assert.strictEqual(metadata.reason, 'line_limit'); + assert.strictEqual(metadata.lineCountKnown, true); + assert.strictEqual(metadata.lineCount, READ_FILE_OUTPUT_MAX_LINES + 1); + assert.strictEqual(metadata.returnedLines.end, READ_FILE_OUTPUT_MAX_LINES); + assert.ok(result.text.includes(`line ${READ_FILE_OUTPUT_MAX_LINES}`)); + assert.ok(!result.text.includes(`line ${READ_FILE_OUTPUT_MAX_LINES + 1}`)); + assert.ok(result.text.includes('Use a narrower line range with start_line/end_line, head, or tail to read more.')); + }); + + test('reports byte-limit truncation when only the prefix is read', async () => { + await withTempFile('x'.repeat(READ_FILE_OUTPUT_MAX_BYTES + 1024), async filePath => { + const fileStats = await fs.stat(filePath); + const result = await readFileContent(filePath, fileStats.size, {}); + const metadata = requireReadFileMetadata(result); + + assert.strictEqual(metadata.truncated, true); + assert.strictEqual(metadata.reason, 'byte_limit'); + assert.strictEqual(metadata.lineCountKnown, false); + assert.strictEqual(metadata.lineCount, undefined); + }); + }); + + test('reports byte and line truncation when both output limits apply', async () => { + const largeContent = Array.from({ length: READ_FILE_OUTPUT_MAX_LINES + 500 }, (_, index) => { + return `line ${index + 1} ${'x'.repeat(90)}`; + }).join('\n'); + + await withTempFile(largeContent, async filePath => { + const fileStats = await fs.stat(filePath); + const result = await readFileContent(filePath, fileStats.size, {}); + const metadata = requireReadFileMetadata(result); + + assert.strictEqual(metadata.truncated, true); + assert.strictEqual(metadata.reason, 'line_and_byte_limit'); + assert.strictEqual(metadata.lineCountKnown, false); + assert.strictEqual(metadata.returnedLines.end, READ_FILE_OUTPUT_MAX_LINES); + }); + }); + + test('applies the line output limit to explicit ranges', () => { + const largeContent = Array.from({ length: READ_FILE_OUTPUT_MAX_LINES + 1 }, (_, index) => `line ${index + 1}`).join('\n'); + const result = selectReadFileResult(largeContent, { + start_line: 1, + end_line: READ_FILE_OUTPUT_MAX_LINES + 1 + }); + const metadata = requireReadFileMetadata(result); + + assert.strictEqual(metadata.truncated, true); + assert.strictEqual(metadata.reason, 'line_limit'); + assert.deepStrictEqual(metadata.returnedLines, { + start: 1, + end: READ_FILE_OUTPUT_MAX_LINES + }); + }); + + test('applies the byte output limit to explicit ranges', () => { + const largeContent = `${'x'.repeat(READ_FILE_OUTPUT_MAX_BYTES + 1024)}\nsecond`; + const result = selectReadFileResult(largeContent, { + start_line: 1, + end_line: 2 + }); + const metadata = requireReadFileMetadata(result); - assert.ok(result.metadata.truncated); - assert.strictEqual(result.metadata.returnedLines.end, 400); - assert.ok(result.text.includes('line 400')); - assert.ok(!result.text.includes('line 401')); - assert.ok(result.text.includes('Use start_line/end_line, head, tail, or force: true to read more.')); + assert.strictEqual(metadata.truncated, true); + assert.strictEqual(metadata.reason, 'byte_limit'); + assert.deepStrictEqual(metadata.returnedLines, { + start: 1, + end: 1 + }); + assert.ok(!result.text.includes('second')); }); - test('force bypasses automatic truncation', () => { - const largeContent = Array.from({ length: 401 }, (_, index) => `line ${index + 1}`).join('\n'); - const result = selectReadFileResult(largeContent, { force: true }, { fileBytes: Buffer.byteLength(largeContent, 'utf8') }); + test('preserves the file tail when tail output hits the byte limit', async () => { + const largeContent = Array.from({ length: 300 }, (_, index) => { + return `line ${index + 1} ${'x'.repeat(900)}`; + }).join('\n'); + + await withTempFile(largeContent, async filePath => { + const fileStats = await fs.stat(filePath); + const result = await readFileContent(filePath, fileStats.size, { + tail: 200, + show_line_numbers: true + }); + const metadata = requireReadFileMetadata(result); - assert.strictEqual(result.metadata.mode, 'full'); - assert.strictEqual(result.metadata.truncated, false); - assert.strictEqual(result.text, largeContent); + assert.strictEqual(metadata.truncated, true); + assert.strictEqual(metadata.reason, 'byte_limit'); + assert.strictEqual(metadata.returnedLines.end, 300); + assert.ok(metadata.returnedLines.start > 101); + assert.ok(result.text.includes('300: line 300')); + assert.ok(!result.text.includes('101: line 101')); + }); + }); + + test('does not truncate a capped head read that reaches exact EOF', async () => { + const exactLimitContent = Array.from({ length: READ_FILE_OUTPUT_MAX_LINES }, (_, index) => { + return `line ${index + 1}`; + }).join('\n'); + + await withTempFile(exactLimitContent, async filePath => { + const fileStats = await fs.stat(filePath); + const result = await readFileContent(filePath, fileStats.size, { + head: READ_FILE_OUTPUT_MAX_LINES + 1 + }); + + assert.strictEqual(result.text, exactLimitContent); + assert.strictEqual(result.metadata, undefined); + }); }); test('streams line ranges from large files instead of loading full content', async () => { @@ -114,11 +203,7 @@ suite('Read File Tool', () => { `1202: line 1202 ${'x'.repeat(80)}` ].join('\n') ); - assert.deepStrictEqual(result.metadata.returnedLines, { - start: 1200, - end: 1202 - }); - assert.strictEqual(result.metadata.lineCount, undefined); + assert.strictEqual(result.metadata, undefined); }); }); @@ -189,3 +274,19 @@ async function withTempFileBytes(content: Buffer, callback: (filePath: string await fs.rm(tempDir, { recursive: true, force: true }); } } + +type TestReadFileMetadata = { + truncated: true; + reason: string; + lineCountKnown: boolean; + lineCount?: number; + returnedLines: { + start: number; + end: number; + }; +}; + +function requireReadFileMetadata(result: { metadata?: TestReadFileMetadata }): TestReadFileMetadata { + assert.ok(result.metadata); + return result.metadata; +} diff --git a/gateway-vscode/src/unit-test/searchCodeTool.test.ts b/gateway-vscode/src/unit-test/searchCodeTool.test.ts new file mode 100644 index 0000000..a152203 --- /dev/null +++ b/gateway-vscode/src/unit-test/searchCodeTool.test.ts @@ -0,0 +1,173 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { matchesPattern } from '../tools/filesystemUtils'; +import { createSearchCodeFallbackNotice } from '../tools/searchCodeFallback'; +import { + getVSCodeAppRootCandidatesFromPath, + getVSCodeRipgrepCandidates +} from '../tools/searchCodeRipgrepPaths'; +import { createSearchCandidate } from '../tools/searchCodeGitFiles'; +import { appendRipgrepMatch } from '../tools/searchCodeRipgrepOutput'; +import type { SearchCodeOptions } from '../tools/searchCodeTypes'; +import { + createRipgrepExcludeGlobs, + getSearchCodeMatchMode, + normalizeIncludeGlob, + truncateSearchMatchLine +} from '../tools/searchCodeUtils'; + +suite('Search Code Tool', () => { + test('treats bare include names as recursive globs', () => { + assert.strictEqual(normalizeIncludeGlob('package.json'), '**/package.json'); + assert.strictEqual(normalizeIncludeGlob('gateway-vscode/package.json'), 'gateway-vscode/package.json'); + }); + + test('matches fallback include globs with brace alternation', () => { + const includePattern = '**/*.{js,ts,jsx,tsx}'; + + assert.ok(matchesPattern('src/modules/logger.ts', includePattern)); + assert.ok(matchesPattern('logger.ts', includePattern)); + assert.ok(matchesPattern('src/App.jsx', includePattern)); + assert.ok(!matchesPattern('src/styles/logger.css', includePattern)); + }); + + test('describes fallback search capabilities when ripgrep is unavailable', () => { + const notice = createSearchCodeFallbackNotice(); + + assert.ok(notice.includes('ripgrep is unavailable')); + assert.ok(notice.includes('in-process fallback')); + assert.ok(notice.includes('simple comma brace alternation')); + assert.ok(notice.includes('JavaScript RegExp')); + }); + + test('infers VS Code app roots from Windows PATH bin directories', () => { + const candidates = getVSCodeAppRootCandidatesFromPath( + path.win32.join('D:\\', 'Microsoft VS Code', 'bin'), + 'win32' + ); + + assert.ok(candidates.includes(path.win32.join('D:\\', 'Microsoft VS Code', 'resources', 'app'))); + }); + + test('infers VS Code app roots from MSYS-style Windows PATH entries', () => { + const candidates = getVSCodeAppRootCandidatesFromPath( + '/d/Microsoft VS Code/bin:/c/Users/example/bin', + 'win32' + ); + + assert.ok(candidates.includes(path.win32.join('D:\\', 'Microsoft VS Code', 'resources', 'app'))); + }); + + test('includes VS Code ripgrep-universal platform arch candidates', () => { + const appRoot = path.win32.join('D:\\', 'Microsoft VS Code', 'resources', 'app'); + const candidates = getVSCodeRipgrepCandidates(appRoot, '', 'win32', 'x64'); + + assert.ok(candidates.includes(path.win32.join( + appRoot, + 'node_modules', + '@vscode', + 'ripgrep-universal', + 'bin', + 'win32-x64', + 'rg.exe' + ))); + }); + + test('excludes common generated and test artifact directories by default', () => { + const globs = createRipgrepExcludeGlobs([]); + + assert.ok(globs.includes('.vscode-test/**')); + assert.ok(globs.includes('**/.vscode-test/**')); + assert.ok(globs.includes('.next/**')); + assert.ok(globs.includes('target/**')); + }); + + test('crops long matching lines around the match', () => { + const prefix = 'a'.repeat(120); + const suffix = 'b'.repeat(120); + const query = '"version"'; + const line = `${prefix}${query}${suffix}`; + const result = truncateSearchMatchLine(line, 80, { + start: prefix.length, + end: prefix.length + query.length + }); + + assert.ok(result.includes(query)); + assert.ok(result.startsWith('[...')); + assert.ok(result.endsWith('chars omitted...]')); + assert.ok(result.length < line.length); + }); + + test('uses ripgrep submatch offsets when cropping regex matches', () => { + const prefix = '你'.repeat(100); + const needle = 'needle'; + const line = `${prefix}${needle}${'b'.repeat(100)}`; + const start = Buffer.byteLength(prefix, 'utf8'); + const message = { + type: 'match', + data: { + path: { text: 'src/sample.txt' }, + lines: { text: `${line}\n` }, + line_number: 7, + submatches: [{ + match: { text: needle }, + start, + end: start + Buffer.byteLength(needle, 'utf8') + }] + } + }; + const matches: string[] = []; + + appendRipgrepMatch(JSON.stringify(message), createOptions({ + query: '(?Pneedle)', + useRegex: true, + matchLineMaxChars: 80 + }), matches); + + assert.strictEqual(matches.length, 1); + assert.ok(matches[0].includes(needle)); + assert.ok(matches[0].includes('chars omitted')); + }); + + test('preserves git ls-files paths with leading or trailing spaces', () => { + const root = path.resolve('workspace-root'); + + assert.strictEqual( + createSearchCandidate(root, './ leading-space.ts')?.relativeToSearchRoot, + ' leading-space.ts' + ); + assert.strictEqual( + createSearchCandidate(root, 'trailing-space.ts ')?.relativeToSearchRoot, + 'trailing-space.ts ' + ); + assert.strictEqual(createSearchCandidate(root, ''), null); + }); + + test('uses substring search by default', () => { + assert.strictEqual(getSearchCodeMatchMode(undefined), 'substring'); + assert.strictEqual(getSearchCodeMatchMode('substring'), 'substring'); + }); + + test('uses regex search when match is regex', () => { + assert.strictEqual(getSearchCodeMatchMode('regex'), 'regex'); + }); + + test('rejects invalid search code match modes', () => { + assert.throws(() => getSearchCodeMatchMode('glob'), /match must be "substring" or "regex"/); + }); +}); + +function createOptions(overrides: Partial = {}): SearchCodeOptions { + const root = path.resolve('workspace-root'); + return { + searchRoot: root, + workspaceRoot: root, + query: 'needle', + maxResults: 100, + excludePatterns: [], + caseSensitive: true, + useRegex: false, + matchLineMaxChars: 500, + ...overrides + }; +} diff --git a/gateway-vscode/src/unit-test/searchFilesTool.test.ts b/gateway-vscode/src/unit-test/searchFilesTool.test.ts index 5b6f9e7..d9dc1a2 100644 --- a/gateway-vscode/src/unit-test/searchFilesTool.test.ts +++ b/gateway-vscode/src/unit-test/searchFilesTool.test.ts @@ -1,33 +1,73 @@ import * as assert from 'assert'; -import { createFileSearchIncludePattern } from '../tools/searchFilesPatterns'; -import { matchesFileQuery } from '../tools/filesystemUtils'; +import { matchesFileQuery, resolveFileQueryMatchMode } from '../tools/filesystemUtils'; +import { createRipgrepFilesArgs } from '../tools/searchFilesRipgrepArgs'; suite('Search Files Tool', () => { - test('narrows plain path queries before result limits', () => { + test('matches plain file queries against file paths and names', () => { + assert.strictEqual(matchesFileQuery('src/tools/searchFilesTool.ts', 'searchFilesTool.ts', 'tools/searchFiles'), true); + assert.strictEqual(matchesFileQuery('src/tools/searchFilesTool.ts', 'searchFilesTool.ts', 'searchFilesTool.ts'), true); + }); + + test('treats bracketed filenames as literal file queries', () => { + assert.strictEqual(matchesFileQuery('app/[id].tsx', '[id].tsx', '[id].tsx'), true); + assert.strictEqual(matchesFileQuery('app/[id]/page.tsx', 'page.tsx', '[id]'), true); + }); + + test('matches plain file queries case-insensitively by default', () => { + assert.strictEqual( + matchesFileQuery('src/WebFetchTool/index.ts', 'index.ts', 'webfetch'), + true + ); + }); + + test('can match plain file queries case-sensitively', () => { + assert.strictEqual( + matchesFileQuery('src/WebFetchTool/index.ts', 'index.ts', 'webfetch', { caseSensitive: true }), + false + ); assert.strictEqual( - createFileSearchIncludePattern('gateway-vscode/src/tools/searchFilesTool.ts'), - '{**/*gateway-vscode/src/tools/searchFilesTool.ts*,**/*gateway-vscode/src/tools/searchFilesTool.ts*/**}' + matchesFileQuery('src/WebFetchTool/index.ts', 'index.ts', 'WebFetch', { caseSensitive: true }), + true ); }); - test('keeps filename substring queries focused on matching names', () => { - assert.strictEqual(createFileSearchIncludePattern('searchFilesTool.ts'), '**/*searchFilesTool.ts*'); + test('uses star as the list-all query', () => { + assert.strictEqual(matchesFileQuery('src/sample.ts', 'sample.ts', '*'), true); + assert.strictEqual(resolveFileQueryMatchMode('*', 'auto'), 'glob'); }); - test('escapes bracketed filenames in literal include patterns', () => { - assert.strictEqual(createFileSearchIncludePattern('[id].tsx'), '**/*[[]id[]].tsx*'); + test('treats dot as a literal substring query', () => { + assert.strictEqual(matchesFileQuery('src/sample.ts', 'sample.ts', '.'), true); + assert.strictEqual(matchesFileQuery('src/LICENSE', 'LICENSE', '.'), false); + assert.strictEqual(resolveFileQueryMatchMode('.', 'auto'), 'substring'); + }); + + test('treats pipe as a literal substring query', () => { + assert.strictEqual(matchesFileQuery('src/foo.ts', 'foo.ts', 'foo|bar'), false); + assert.strictEqual(matchesFileQuery('src/foo|bar.ts', 'foo|bar.ts', 'foo|bar'), true); + }); + + test('matches glob brace alternatives in file queries', () => { + assert.strictEqual(matchesFileQuery('src/App.tsx', 'App.tsx', '*.{ts,tsx}'), true); + assert.strictEqual(matchesFileQuery('src/App.jsx', 'App.jsx', '*.{ts,tsx}'), false); + assert.strictEqual(resolveFileQueryMatchMode('*.{ts,tsx}', 'auto'), 'glob'); + }); + + test('matches explicit path globs', () => { + assert.strictEqual(matchesFileQuery('src/tools/searchFilesTool.ts', 'searchFilesTool.ts', 'src/**/*.ts'), true); + assert.strictEqual(matchesFileQuery('test/tools/searchFilesTool.ts', 'searchFilesTool.ts', 'src/**/*.ts'), false); + }); + + test('allows forcing substring mode for glob-looking queries', () => { assert.strictEqual( - createFileSearchIncludePattern('app/[id]/page.tsx'), - '{**/*app/[[]id[]]/page.tsx*,**/*app/[[]id[]]/page.tsx*/**}' + matchesFileQuery('src/*.{ts,tsx}.md', '*.{ts,tsx}.md', '*.{ts,tsx}', { matchMode: 'substring' }), + true ); }); - test('treats bracketed filenames as literal file queries', () => { - assert.strictEqual(matchesFileQuery('app/[id].tsx', '[id].tsx', '[id].tsx'), true); - assert.strictEqual(matchesFileQuery('app/[id]/page.tsx', 'page.tsx', '[id]'), true); - }); + test('lists files ignored by gitignore with ripgrep', () => { + const args = createRipgrepFilesArgs([]); - test('preserves explicit glob path queries', () => { - assert.strictEqual(createFileSearchIncludePattern('src/**/*.ts'), 'src/**/*.ts'); + assert.ok(args.includes('--no-ignore')); }); });