diff --git a/src/prompt.ts b/src/prompt.ts index 717991b..8691e7d 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,13 +1,19 @@ -import { execFileSync, execSync } from "child_process"; +import { exec, execFile } from "child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { fileURLToPath } from "url"; +import { promisify } from "util"; import ejs from "ejs"; import type { SessionMessage } from "./session"; import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; import { supportsMultimodal } from "./common/model-capabilities"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const execFileAsync = promisify(execFile) as (...args: any[]) => Promise<{ stdout: string; stderr: string }>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const execAsync = promisify(exec) as (...args: any[]) => Promise<{ stdout: string; stderr: string }>; + const COMPACT_PROMPT_BASE = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. @@ -186,105 +192,141 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { return `${COMPACT_PROMPT_BASE}\n\nconversation below:\n\n\`\`\`jsonl\n${jsonl}\n\`\`\``; } -export function getRuntimeContext(projectRoot: string, model?: string): string { - const uname = getUnameInfo(); - const shellPath = getShellPathInfo(); - const shellModeOpts = process.platform === "win32" ? { "shell mode": "git-bash" } : {}; - const runtimeVersions = getRuntimeVersionInfo(); - const env = { - "root path": projectRoot, - pwd: projectRoot, - homedir: os.homedir(), - "system info": uname, - "shell path": shellPath, - ...shellModeOpts, - ...runtimeVersions, - "command installed": { - ripgrep: checkToolInstalled("rg"), - jq: checkToolInstalled("jq"), - }, - }; - return `${getCurrentDateAndModelPrompt(model)} - -# Local Workspace Environment +// ─── Async Runtime Context (cached, non-blocking) ───────────────────────── +// The synchronous version blocks the event loop ~12s on Windows due to 5 Git +// Bash spawns. This async+cached version runs probes in parallel and caches +// the result so createSession() never blocks. -\`\`\`json -${JSON.stringify(env, null, 2)} -\`\`\``; -} +let runtimeEnvJsonPromise: Promise | null = null; +let runtimeEnvJsonCached: string | null = null; -function checkToolInstalled(tool: string): boolean { +async function getUnameInfoAsync(): Promise { try { if (process.platform === "win32") { const bashPath = findGitBashPath(); - execFileSync(bashPath, ["-lc", `command -v ${shellSingleQuote(tool)}`], { + const { stdout } = await execFileAsync(bashPath, ["-lc", "uname -a"], { encoding: "utf8", - stdio: "ignore", windowsHide: true, }); - return true; + return stdout.trim(); } - execSync(`command -v ${tool}`, { encoding: "utf8", stdio: "ignore" }); - return true; + const { stdout } = await execAsync("uname -a", { encoding: "utf8" }); + return stdout.trim(); } catch { - return false; + return `${os.type()} ${os.release()} ${os.arch()}`; } } -function getShellPathInfo(): string { +async function getCommandVersionAsync(command: string, args: string[]): Promise { try { - return resolveShellPath(); - } catch (error) { - return error instanceof Error ? error.message : String(error); + if (process.platform === "win32") { + const bashPath = findGitBashPath(); + const commandText = [command, ...args].map(shellSingleQuote).join(" "); + const { stdout } = await execFileAsync(bashPath, ["-lc", `${commandText} 2>&1`], { + encoding: "utf8", + windowsHide: true, + }); + return stdout.trim(); + } + const commandText = [command, ...args].map(shellSingleQuote).join(" "); + const { stdout } = await execAsync(`${commandText} 2>&1`, { encoding: "utf8" }); + return stdout.trim(); + } catch { + return null; } } -function shellSingleQuote(value: string): string { - return `'${value.replace(/'/g, "'\"'\"'")}'`; +async function checkToolInstalledAsync(tool: string): Promise { + try { + if (process.platform === "win32") { + const bashPath = findGitBashPath(); + await execFileAsync(bashPath, ["-lc", `command -v ${shellSingleQuote(tool)}`], { + encoding: "utf8", + stdio: "ignore", + windowsHide: true, + }); + return true; + } + await execAsync(`command -v ${tool}`, { encoding: "utf8", stdio: "ignore" }); + return true; + } catch { + return false; + } } -function getRuntimeVersionInfo(): Record { +async function getRuntimeVersionInfoAsync(): Promise> { const versions: Record = {}; - const pythonVersion = getCommandVersion("python3", ["--version"]); - const nodeVersion = getCommandVersion("node", ["--version"]); - + const [pythonVersion, nodeVersion] = await Promise.all([ + getCommandVersionAsync("python3", ["--version"]), + getCommandVersionAsync("node", ["--version"]), + ]); if (pythonVersion) { versions["python3 version"] = pythonVersion.replace(/^Python\s+/i, ""); } if (nodeVersion) { versions["node version"] = nodeVersion; } - return versions; } -function getCommandVersion(command: string, args: string[]): string | null { - try { - const commandText = [command, ...args].map(shellSingleQuote).join(" "); - if (process.platform === "win32") { - return execFileSync(findGitBashPath(), ["-lc", `${commandText} 2>&1`], { - encoding: "utf8", - windowsHide: true, - }).trim(); - } - return execSync(`${commandText} 2>&1`, { encoding: "utf8" }).trim(); - } catch { - return null; - } +async function computeRuntimeEnvJson(projectRoot: string): Promise { + const [uname, shellPath, runtimeVersions, hasRg, hasJq] = await Promise.all([ + getUnameInfoAsync(), + (async () => { + try { + return resolveShellPath(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + })(), + getRuntimeVersionInfoAsync(), + checkToolInstalledAsync("rg"), + checkToolInstalledAsync("jq"), + ]); + + const shellModeOpts = process.platform === "win32" ? { "shell mode": "git-bash" } : {}; + const env = { + "root path": projectRoot, + pwd: projectRoot, + homedir: os.homedir(), + "system info": uname, + "shell path": shellPath, + ...shellModeOpts, + ...runtimeVersions, + "command installed": { + ripgrep: hasRg, + jq: hasJq, + }, + }; + const json = JSON.stringify(env, null, 2); + runtimeEnvJsonCached = json; + return json; } -function getUnameInfo(): string { - try { - if (process.platform === "win32") { - return execFileSync(findGitBashPath(), ["-lc", "uname -a"], { - encoding: "utf8", - windowsHide: true, - }).trim(); - } - return execSync("uname -a", { encoding: "utf8" }).trim(); - } catch { - return `${os.type()} ${os.release()} ${os.arch()}`; +async function getRuntimeEnvJson(projectRoot: string): Promise { + if (runtimeEnvJsonCached) return runtimeEnvJsonCached; + if (!runtimeEnvJsonPromise) { + runtimeEnvJsonPromise = computeRuntimeEnvJson(projectRoot); } + return runtimeEnvJsonPromise; +} + +/** Start pre-computing the runtime env in the background (called at startup). */ +export function prewarmRuntimeContext(projectRoot: string): void { + void getRuntimeEnvJson(projectRoot).catch((err) => { + console.error("[prewarmRuntimeContext] Failed to pre-compute runtime environment:", err); + }); +} + +export async function getRuntimeContext(projectRoot: string, model?: string): Promise { + const envJson = await getRuntimeEnvJson(projectRoot); + return `${getCurrentDateAndModelPrompt(model)}\n\n# Local Workspace Environment\n\n\`\`\`json\n${envJson}\n\`\`\``; +} + +// ─── Sync helpers (kept for backward compat) ────────────────────────────── + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "'\"'\"'")}'`; } function getExtensionRoot(): string { diff --git a/src/session.ts b/src/session.ts index 54340e7..a3e7624 100644 --- a/src/session.ts +++ b/src/session.ts @@ -15,6 +15,7 @@ import { getRuntimeContext, getSystemPrompt, getTools, + prewarmRuntimeContext, type ToolDefinition, } from "./prompt"; import { @@ -278,6 +279,8 @@ export class SessionManager { this.onProcessStdout = options.onProcessStdout; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); + // Kick off async runtime context pre-computation while user is typing. + prewarmRuntimeContext(this.projectRoot); } async initMcpServers(servers?: Record): Promise { @@ -964,7 +967,7 @@ The candidate skills are as follows:\n\n`; const runtimeContextMessage = this.buildSystemMessage( sessionId, - getRuntimeContext(this.projectRoot, promptToolOptions.model) + await getRuntimeContext(this.projectRoot, promptToolOptions.model) ); this.appendSessionMessage(sessionId, runtimeContextMessage); diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index cc86712..044b9bb 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -55,10 +55,10 @@ test("getSystemPrompt does not include current date guidance", () => { assert.equal(prompt.includes(expected), false); }); -test("getRuntimeContext includes current date and model guidance", () => { +test("getRuntimeContext includes current date and model guidance", async () => { const now = new Date(); const expectedDate = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; - const prompt = getRuntimeContext("/tmp/project", "deepseek-v4-pro"); + const prompt = await getRuntimeContext("/tmp/project", "deepseek-v4-pro"); assert.equal(prompt.includes(expectedDate), true); assert.equal(prompt.includes("当前LLM模型为deepseek-v4-pro,对话中可通过/model命令切换模型。"), true); assert.equal(prompt.includes("# Local Workspace Environment"), true);