From dd4d004c3228c1ddcfc365c637e583d187b66180 Mon Sep 17 00:00:00 2001 From: raymondginger2018-sudo Date: Sat, 25 Jul 2026 21:04:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E9=9D=A2=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=20-=20Permission=20Profile=20/=20SQLite=20?= =?UTF-8?q?=E6=97=A5=E5=BF=97=20/=20Job=20=E9=98=9F=E5=88=97=20/=20Skill?= =?UTF-8?q?=20=E6=A0=87=E5=87=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/ui/core/slash-commands.ts | 21 +- packages/cli/src/ui/views/App.tsx | 87 ++-- packages/cli/src/ui/views/PromptInput.tsx | 37 +- packages/core/package.json | 3 +- packages/core/src/common/hooks.ts | 76 ++++ packages/core/src/common/job-queue.ts | 361 ++++++++++++++++ .../core/src/common/permission-profile.ts | 335 +++++++++++++++ packages/core/src/common/permissions.ts | 69 +-- packages/core/src/common/session-log.ts | 394 +++++++++++++++++ packages/core/src/common/skill-parser.ts | 313 ++++++++++++++ packages/core/src/common/sql.js.d.ts | 16 + packages/core/src/context/compact.ts | 245 +++++++++++ packages/core/src/index.ts | 75 +++- packages/core/src/prompt.ts | 76 +++- packages/core/src/session.ts | 406 +++++++++++++++--- packages/core/src/settings.ts | 30 +- packages/core/src/tests/job-queue.test.ts | 130 ++++++ .../core/src/tests/permission-profile.test.ts | 190 ++++++++ packages/core/src/tests/session-log.test.ts | 106 +++++ packages/core/src/tests/skill-parser.test.ts | 157 +++++++ packages/core/src/tools/bash-handler.ts | 71 ++- packages/core/src/tools/executor.ts | 74 +++- patch-dist.mjs | 297 +++++++++++++ 23 files changed, 3402 insertions(+), 167 deletions(-) create mode 100644 packages/core/src/common/hooks.ts create mode 100644 packages/core/src/common/job-queue.ts create mode 100644 packages/core/src/common/permission-profile.ts create mode 100644 packages/core/src/common/session-log.ts create mode 100644 packages/core/src/common/skill-parser.ts create mode 100644 packages/core/src/common/sql.js.d.ts create mode 100644 packages/core/src/context/compact.ts create mode 100644 packages/core/src/tests/job-queue.test.ts create mode 100644 packages/core/src/tests/permission-profile.test.ts create mode 100644 packages/core/src/tests/session-log.test.ts create mode 100644 packages/core/src/tests/skill-parser.test.ts create mode 100644 patch-dist.mjs diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index 38646611..803cdbcc 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -4,7 +4,6 @@ export type SlashCommandKind = | "skill" | "skills" | "model" - | "plan" | "new" | "init" | "resume" @@ -12,6 +11,8 @@ export type SlashCommandKind = | "undo" | "mcp" | "raw" + | "compact" + | "context" | "exit"; export type SlashCommandItem = { @@ -36,12 +37,6 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/model", description: "Select model, thinking mode and effort control", }, - { - kind: "plan", - name: "plan", - label: "/plan", - description: "Switch the input to Plan Mode", - }, { kind: "new", name: "new", @@ -85,6 +80,18 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ args: ["lite", "normal", "raw-scrollback"], description: "Toggle display mode for viewing or collapsing reasoning content", }, + { + kind: "compact", + name: "compact", + label: "/compact", + description: "Compress conversation context to reduce token usage", + }, + { + kind: "context", + name: "context", + label: "/context", + description: "Show current conversation token usage and context stats", + }, { kind: "exit", name: "exit", diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index eb30dd96..008f4cf4 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -20,7 +20,6 @@ import { formatAskUserQuestionAnswers, } from "../core/ask-user-question"; import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; -import { PlanImplementationPrompt, extractProposedPlan, getImplementationPrompt } from "./PlanImplementationPrompt"; import { buildExitSummaryText, buildResumeHintText } from "../exit-summary"; import { RawMode, useRawModeContext } from "../contexts"; import { renderMessageToStdout } from "../components/MessageView/utils"; @@ -134,8 +133,6 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); const [showProcessStdout, setShowProcessStdout] = useState(false); - const [planMode, setPlanMode] = useState(false); - const [pendingPlanImplementation, setPendingPlanImplementation] = useState(null); rawModeRef.current = mode; messagesRef.current = messages; @@ -256,8 +253,6 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setActiveStatus(null); setActiveAskPermissions(undefined); setPendingPermissionReply(null); - setPlanMode(false); - setPendingPlanImplementation(null); setDismissedQuestionIds(new Set()); await resetStaticView([]); await refreshSkills(); @@ -366,6 +361,55 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp navigateToSubView("mcp-status"); return; } + if (submission.command === "compact") { + const activeSessionId = sessionManager.getActiveSessionId(); + if (!activeSessionId) { + setErrorLine("No active session to compact."); + return; + } + setBusy(true); + sessionManager.addSessionSystemMessage( + activeSessionId, + "Compacting conversation context to reduce token usage...", + true, + { asThinking: true } + ); + sessionManager.compactSession(activeSessionId).then(() => { + setBusy(false); + refreshSessionsList(); + }).catch((err) => { + setBusy(false); + setErrorLine(`Compact failed: ${err instanceof Error ? err.message : String(err)}`); + }); + return; + } + if (submission.command === "context") { + const sessionInfo = getSessionInfo(); + if (!sessionInfo || !sessionInfo.activeSessionId) { + setErrorLine("No active session."); + return; + } + const pct = sessionInfo.maxContextTokens > 0 + ? Math.round((sessionInfo.activeTokens / sessionInfo.maxContextTokens) * 100) + : 0; + const summary = [ + `Model: ${sessionInfo.model}`, + `Messages: ${sessionInfo.messageCount}`, + `API requests: ${sessionInfo.requestCount}`, + `Active tokens: ${sessionInfo.activeTokens.toLocaleString()} / ${sessionInfo.maxContextTokens.toLocaleString()} (${pct}%)`, + `Total tokens used: ${sessionInfo.totalTokens.toLocaleString()}`, + ]; + if (Object.keys(sessionInfo.toolUsage).length > 0) { + const tools = Object.entries(sessionInfo.toolUsage) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([name, count]) => ` ${name}: ${count}x`) + .join("\n"); + summary.push(`\nTop tools:\n${tools}`); + } + sessionManager.addSessionSystemMessage(sessionInfo.activeSessionId, summary.join("\n"), true, { asThinking: true }); + return; + } const prompt: UserPromptContent = { text: submission.text, @@ -374,7 +418,6 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined, permissions: submission.permissions, alwaysAllows: submission.alwaysAllows, - planMode: submission.planMode ?? planMode, }; const activeSessionId = sessionManager.getActiveSessionId(); const permissionReply = @@ -410,12 +453,6 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp } await refreshSkills(); refreshSessionsList(); - const completedSession = sessionManager.getSession(sessionManager.getActiveSessionId() ?? ""); - const proposedPlan = - prompt.planMode && completedSession?.status === "completed" - ? extractProposedPlan(completedSession.assistantReply) - : null; - setPendingPlanImplementation(proposedPlan); } catch (error) { const message = error instanceof Error ? error.message : String(error); setErrorLine(message); @@ -437,7 +474,6 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp refreshSessionsList, navigateToSubView, resetToWelcome, - planMode, ] ); @@ -509,25 +545,6 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [handlePrompt] ); - const handlePlanImplementationChoice = useCallback( - (choice: "implement" | "stay" | "default") => { - const proposedPlan = pendingPlanImplementation; - setPendingPlanImplementation(null); - if (choice === "stay") { - return; - } - setPlanMode(false); - if (choice === "implement" && proposedPlan) { - handleSubmit({ - text: getImplementationPrompt(proposedPlan), - imageUrls: [], - planMode: false, - }); - } - }, - [handleSubmit, pendingPlanImplementation] - ); - const handleExitShortcut = useCallback(() => { handleExit({ showCommand: false, showSummary: false }); }, [handleExit]); @@ -549,8 +566,6 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setRunningProcesses(session?.processes ?? null); setActiveStatus(session?.status ?? null); setActiveAskPermissions(session?.askPermissions); - setPlanMode(session?.planMode === true); - setPendingPlanImplementation(null); if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) { setPendingPermissionReply(null); } @@ -999,8 +1014,6 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp onSubmit={handlePermissionResult} onCancel={handlePermissionCancel} /> - ) : pendingPlanImplementation && !busy ? ( - ) : isExiting ? null : ( )} diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 799dec7a..dd4bb9c4 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -72,8 +72,7 @@ export type PromptSubmission = { selectedSkills?: SkillInfo[]; permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; - planMode?: boolean; - command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; + command?: "new" | "resume" | "continue" | "undo" | "mcp" | "compact" | "context" | "exit"; }; export type PromptDraft = { @@ -97,11 +96,9 @@ type Props = { promptDraft?: PromptDraft | null; statusLineSegments?: StatusSegment[]; statusLineSeparator?: string; - planMode: boolean; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onRawModeChange?: (mode: string) => void; - onPlanModeChange: (enabled: boolean) => void; onInterrupt: () => void; onToggleProcessStdout?: () => void; onExitShortcut?: () => void; @@ -132,14 +129,12 @@ export const PromptInput = React.memo(function PromptInput({ promptDraft, statusLineSegments, statusLineSeparator, - planMode, onSubmit, onModelConfigChange, onInterrupt, onToggleProcessStdout, onExitShortcut, onRawModeChange, - onPlanModeChange, }: Props): React.ReactElement { const { stdout } = useStdout(); const inputTextRef = useRef(null); @@ -231,10 +226,9 @@ export const PromptInput = React.memo(function PromptInput({ screenWidth, cursorLayoutKey ?? "default", imageUrls.length, - planMode ? "plan-mode" : "default-mode", selectedSkills.map((skill) => skill.name).join("\u001F"), ].join("\u001E"), - [cursorLayoutKey, imageUrls.length, planMode, screenWidth, selectedSkills] + [cursorLayoutKey, imageUrls.length, screenWidth, selectedSkills] ); useTerminalFocusReporting(stdout, !disabled); useTerminalExtendedKeys(stdout, !disabled); @@ -435,11 +429,6 @@ export const PromptInput = React.memo(function PromptInput({ const returnAction = getPromptReturnKeyAction(key); const isPlainReturn = returnAction === "submit"; - if (key.shift && key.tab) { - onPlanModeChange(!planMode); - return; - } - if (showFileMentionMenu) { if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") { return; @@ -689,11 +678,6 @@ export const PromptInput = React.memo(function PromptInput({ setShowModelDropdown(true); return; } - if (item.kind === "plan") { - clearSlashToken(); - onPlanModeChange(true); - return; - } if (item.kind === "raw") { clearSlashToken(); setOpenRawModelDropdown(true); @@ -724,6 +708,16 @@ export const PromptInput = React.memo(function PromptInput({ resetPromptInput(); return; } + if (item.kind === "compact") { + onSubmit({ text: "/compact", imageUrls: [], command: "compact" }); + resetPromptInput(); + return; + } + if (item.kind === "context") { + onSubmit({ text: "/context", imageUrls: [], command: "context" }); + resetPromptInput(); + return; + } if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); resetPromptInput(); @@ -760,7 +754,6 @@ export const PromptInput = React.memo(function PromptInput({ text: expandPasteMarkers(buffer.text, pastesRef.current), imageUrls, selectedSkills, - planMode, }); resetPromptInput(); } @@ -798,12 +791,6 @@ export const PromptInput = React.memo(function PromptInput({ (use /skills to edit) ) : null} - {planMode ? ( - - 💡 Plan mode - (shift+tab to cycle) - - ) : null} {/* Input */} = {} +): void { + const command = hooks?.[event as keyof HooksConfig] as string | undefined; + if (!command) { + return; + } + + // Replace placeholders + const resolved = command.replace(/\{(\w+)\}/g, (_match: string, key: string) => context[key] ?? ""); + + const { stdout, exitCode } = runHook(resolved); + if (exitCode !== 0) { + // Log but don't throw — hooks should not break the main flow + console.error(`[hook:${event}] exited ${exitCode}: ${stdout}`); + } +} diff --git a/packages/core/src/common/job-queue.ts b/packages/core/src/common/job-queue.ts new file mode 100644 index 00000000..458a6a66 --- /dev/null +++ b/packages/core/src/common/job-queue.ts @@ -0,0 +1,361 @@ +/** + * Job 队列模块 + * 借鉴 OpenAI Codex CLI 的 SpawnRequest→SpawnReady→ExitPayload 三阶段协议 + * + * 核心功能: + * - Job 生命周期管理(pending → running → completed | failed | timed_out) + * - 指数退避重试 + * - 超时控制 + * - 并发限制 + * - 事件通知 + */ + +import { spawn, type ChildProcess } from "child_process"; +import { randomUUID } from "crypto"; + +// ─── 类型定义 ──────────────────────────────────────────── + +/** Job 状态 */ +export type JobStatus = "pending" | "running" | "completed" | "failed" | "timed_out" | "cancelled"; + +/** SpawnRequest:请求执行命令 */ +export interface SpawnRequest { + command: string; + args: string[]; + cwd?: string; + env?: Record; + timeoutMs: number; + maxRetries: number; + retryDelayMs: number; + /** 是否允许后台运行 */ + background: boolean; + /** 权限 Profile 名称(用于关联审计日志) */ + permissionProfile?: string; +} + +/** SpawnReady:子进程就绪通知 */ +export interface SpawnReady { + processId: number; + jobId: string; + startedAt: number; +} + +/** ExitPayload:执行结果 */ +export interface ExitPayload { + jobId: string; + exitCode: number | null; + signal: string | null; + stdout: string; + stderr: string; + timedOut: boolean; + durationMs: number; + attempts: number; +} + +/** Job 完整状态 */ +export interface JobState { + id: string; + status: JobStatus; + request: SpawnRequest; + attempt: number; + maxAttempts: number; + createdAt: number; + startedAt?: number; + completedAt?: number; + result?: ExitPayload; + error?: string; +} + +/** Job 事件回调 */ +export interface JobEventCallbacks { + onStart?: (jobId: string, pid: number) => void; + onStdout?: (jobId: string, text: string) => void; + onStderr?: (jobId: string, text: string) => void; + onComplete?: (payload: ExitPayload) => void; + onRetry?: (jobId: string, attempt: number, error: string, nextDelayMs: number) => void; +} + +// ─── Job 队列 ───────────────────────────────────────────── + +export class JobQueue { + private jobs = new Map(); + private activeJobs = new Map(); + private maxConcurrency: number; + private pendingQueue: string[] = []; + + constructor(maxConcurrency = 5) { + this.maxConcurrency = maxConcurrency; + } + + /** + * 提交一个 Job 到队列 + * @returns jobId + */ + submit(request: SpawnRequest, callbacks?: JobEventCallbacks): string { + const jobId = randomUUID(); + const state: JobState = { + id: jobId, + status: "pending", + request, + attempt: 0, + maxAttempts: Math.max(1, request.maxRetries + 1), + createdAt: Date.now(), + }; + this.jobs.set(jobId, state); + this.pendingQueue.push(jobId); + + // 尝试调度执行 + this.scheduleNext(callbacks); + return jobId; + } + + /** + * 获取 Job 当前状态 + */ + getState(jobId: string): JobState | undefined { + return this.jobs.get(jobId); + } + + /** + * 取消一个 Job + */ + cancel(jobId: string): boolean { + const state = this.jobs.get(jobId); + if (!state || state.status === "completed" || state.status === "cancelled") return false; + + state.status = "cancelled"; + state.completedAt = Date.now(); + + const child = this.activeJobs.get(jobId); + if (child && !child.killed) { + try { child.kill("SIGKILL"); } catch { /* ok */ } + } + this.activeJobs.delete(jobId); + this.pendingQueue = this.pendingQueue.filter((id) => id !== jobId); + return true; + } + + /** + * 等待 Job 完成(返回 ExitPayload) + */ + async waitFor(jobId: string): Promise { + const state = this.jobs.get(jobId); + if (!state) throw new Error(`Job ${jobId} not found`); + if (state.result) return state.result; + + return new Promise((resolve, reject) => { + const check = setInterval(() => { + const current = this.jobs.get(jobId); + if (!current) { + clearInterval(check); + reject(new Error(`Job ${jobId} removed`)); + return; + } + if (current.result) { + clearInterval(check); + resolve(current.result); + } + if (current.status === "cancelled") { + clearInterval(check); + resolve({ + jobId, + exitCode: null, + signal: "SIGKILL", + stdout: "", + stderr: "", + timedOut: false, + durationMs: Date.now() - (current.createdAt), + attempts: current.attempt, + }); + } + }, 100); + }); + } + + /** 当前活动 Job 数 */ + get activeCount(): number { + return this.activeJobs.size; + } + + /** 队列中所有 Job */ + get allJobs(): JobState[] { + return Array.from(this.jobs.values()); + } + + /** 清除已完成 Job */ + cleanup(): void { + for (const [id, state] of this.jobs) { + if (state.status === "completed" || state.status === "cancelled") { + const age = Date.now() - (state.completedAt ?? state.createdAt); + if (age > 5 * 60 * 1000) { // 5 分钟 + this.jobs.delete(id); + } + } + } + } + + // ─── 内部调度 ─────────────────────────────────────── + + private scheduleNext(callbacks?: JobEventCallbacks): void { + while (this.activeJobs.size < this.maxConcurrency && this.pendingQueue.length > 0) { + const jobId = this.pendingQueue.shift()!; + const state = this.jobs.get(jobId); + if (!state || state.status === "cancelled") continue; + this.executeJob(jobId, callbacks); + } + } + + private executeJob(jobId: string, callbacks?: JobEventCallbacks): void { + const state = this.jobs.get(jobId); + if (!state) return; + + state.status = "running"; + state.attempt++; + state.startedAt = Date.now(); + state.error = undefined; + + const req = state.request; + const startedAt = Date.now(); + let timedOut = false; + + const child = spawn(req.command, req.args, { + cwd: req.cwd, + env: req.env ? { ...process.env, ...req.env } : process.env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + this.activeJobs.set(jobId, child); + callbacks?.onStart?.(jobId, child.pid ?? 0); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + stdout += text; + callbacks?.onStdout?.(jobId, text); + }); + + child.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + stderr += text; + callbacks?.onStderr?.(jobId, text); + }); + + // 超时控制 + const timeoutTimer = setTimeout(() => { + timedOut = true; + if (!child.killed) { + try { child.kill("SIGKILL"); } catch { /* ok */ } + } + }, req.timeoutMs); + + child.on("close", (exitCode, signal) => { + clearTimeout(timeoutTimer); + this.activeJobs.delete(jobId); + + const durationMs = Date.now() - startedAt; + const payload: ExitPayload = { + jobId, + exitCode, + signal, + stdout, + stderr, + timedOut, + durationMs, + attempts: state.attempt, + }; + + // 判断是否需要重试 + const shouldRetry = + (exitCode !== 0 || timedOut || signal) && + state.attempt < state.maxAttempts && + state.status !== "cancelled"; + + if (shouldRetry) { + // 计算指数退避延迟 + const delayMs = Math.min( + req.retryDelayMs * Math.pow(2, state.attempt - 1), + 30000 // 最大 30s + ); + const errorMsg = timedOut + ? `Timed out after ${req.timeoutMs}ms` + : `Exit code ${exitCode}, signal ${signal}`; + state.error = errorMsg; + callbacks?.onRetry?.(jobId, state.attempt, errorMsg, delayMs); + + setTimeout(() => { + this.executeJob(jobId, callbacks); + }, delayMs); + } else { + // 最终完成 + state.status = timedOut ? "timed_out" : exitCode === 0 ? "completed" : "failed"; + state.completedAt = Date.now(); + state.result = payload; + callbacks?.onComplete?.(payload); + } + }); + + child.on("error", (err) => { + clearTimeout(timeoutTimer); + this.activeJobs.delete(jobId); + + const durationMs = Date.now() - startedAt; + const errorMsg = err.message; + state.error = errorMsg; + + if (state.attempt < state.maxAttempts && state.status !== "cancelled") { + const delayMs = Math.min(req.retryDelayMs * Math.pow(2, state.attempt - 1), 30000); + callbacks?.onRetry?.(jobId, state.attempt, errorMsg, delayMs); + setTimeout(() => { + this.executeJob(jobId, callbacks); + }, delayMs); + } else { + state.status = "failed"; + state.completedAt = Date.now(); + state.result = { + jobId, + exitCode: -1, + signal: null, + stdout, + stderr, + timedOut: false, + durationMs, + attempts: state.attempt, + }; + callbacks?.onComplete?.(state.result); + } + }); + } +} + +// ─── 全局单例 ───────────────────────────────────────────── + +let globalJobQueue: JobQueue | null = null; + +/** 获取全局 Job 队列实例 */ +export function getGlobalJobQueue(maxConcurrency = 5): JobQueue { + if (!globalJobQueue) { + globalJobQueue = new JobQueue(maxConcurrency); + } + return globalJobQueue; +} + +/** 创建默认 SpawnRequest */ +export function createSpawnRequest( + command: string, + options?: Partial +): SpawnRequest { + return { + command, + args: options?.args ?? [], + cwd: options?.cwd, + env: options?.env, + timeoutMs: options?.timeoutMs ?? 30000, + maxRetries: options?.maxRetries ?? 2, + retryDelayMs: options?.retryDelayMs ?? 500, + background: options?.background ?? false, + permissionProfile: options?.permissionProfile, + }; +} diff --git a/packages/core/src/common/permission-profile.ts b/packages/core/src/common/permission-profile.ts new file mode 100644 index 00000000..91306fc0 --- /dev/null +++ b/packages/core/src/common/permission-profile.ts @@ -0,0 +1,335 @@ +/** + * Permission Profile 系统 + * 借鉴 OpenAI Codex CLI 的 PermissionProfile 结构化权限模型 + * + * 核心设计: + * - Managed 模式:路径级别的细粒度 filesystem 权限 + network 控制 + * - Unrestricted 模式:完全放行(谨慎使用) + * - 支持 glob 路径模式匹配 + * - ACL-like 的 allow/deny/readonly 三元控制 + */ + +import * as path from "path"; +import * as os from "os"; + +// ─── 类型定义 ──────────────────────────────────────────── + +/** 文件系统访问级别 */ +export type FileSystemAccessLevel = "read" | "write" | "deny"; + +/** 文件系统路径类型 */ +export type FileSystemPathKind = + | { type: "exact"; path: string } + | { type: "glob"; pattern: string } + | { type: "special"; kind: "project_root" | "tmpdir" | "home" | "data_dir" }; + +/** 文件系统沙箱条目 */ +export interface FileSystemSandboxEntry { + path: FileSystemPathKind; + access: FileSystemAccessLevel; +} + +/** 网络权限 */ +export interface NetworkPermissions { + /** 是否允许外部网络访问 */ + external: boolean; + /** 允许的域名白名单(空 = 全部允许或全部禁止,取决于 external) */ + allowedHosts?: string[]; +} + +/** Git 操作权限 */ +export interface GitPermissions { + read: boolean; // query-git-log + write: boolean; // mutate-git-log +} + +/** Managed 模式下的详细权限配置 */ +export interface ManagedPermissionConfig { + fileSystem: FileSystemSandboxEntry[]; + network: NetworkPermissions; + git: GitPermissions; + /** glob 扫描最大深度 */ + globScanMaxDepth: number; +} + +/** 权限 Profile 类型 */ +export type PermissionProfile = + | { mode: "unrestricted" } + | { mode: "legacy"; readWriteRoots: string[] } + | { mode: "managed"; config: ManagedPermissionConfig }; + +// ─── 预置 Profile ───────────────────────────────────────── + +/** 严格隔离模式:只能读写项目目录,禁止外网 */ +export const STRICT_SANDBOX_PROFILE: PermissionProfile = { + mode: "managed", + config: { + fileSystem: [ + { path: { type: "special", kind: "project_root" }, access: "write" }, + { path: { type: "special", kind: "tmpdir" }, access: "write" }, + { path: { type: "glob", pattern: "**/node_modules/**" }, access: "read" }, + { path: { type: "glob", pattern: "**/.git/**" }, access: "read" }, + { path: { type: "special", kind: "home" }, access: "deny" }, + ], + network: { external: false }, + git: { read: true, write: false }, + globScanMaxDepth: 50, + }, +}; + +/** 默认开发模式:可写项目目录,可访问外网 */ +export const DEFAULT_DEV_PROFILE: PermissionProfile = { + mode: "managed", + config: { + fileSystem: [ + { path: { type: "special", kind: "project_root" }, access: "write" }, + { path: { type: "special", kind: "tmpdir" }, access: "write" }, + { path: { type: "special", kind: "data_dir" }, access: "write" }, + { path: { type: "glob", pattern: "**/node_modules/**" }, access: "read" }, + { path: { type: "glob", pattern: "**/.git/**" }, access: "write" }, + ], + network: { external: true }, + git: { read: true, write: true }, + globScanMaxDepth: 100, + }, +}; + +/** 完全放行模式(= Codex 的 Unrestricted) */ +export const UNRESTRICTED_PROFILE: PermissionProfile = { mode: "unrestricted" }; + +/** 从旧的 PermissionScope[] 迁移的兼容 profile */ +export function legacyProfileFromScopes(allowScopes: string[]): PermissionProfile { + const hasNetwork = allowScopes.includes("network"); + const hasGitWrite = allowScopes.includes("mutate-git-log"); + + return { + mode: "managed", + config: { + fileSystem: [ + { + path: { type: "special", kind: "project_root" }, + access: allowScopes.includes("write-out-cwd") ? "write" : "read", + }, + { path: { type: "special", kind: "tmpdir" }, access: "write" }, + { + path: { type: "special", kind: "home" }, + access: allowScopes.includes("write-out-cwd") ? "write" : "deny", + }, + ], + network: { external: hasNetwork }, + git: { read: true, write: hasGitWrite }, + globScanMaxDepth: 50, + }, + }; +} + +// --- Simple Glob Matching Engine ----------------------------------- + +/** + * Simple glob matching supporting recursive (double-star-slash) patterns + * and single-level (single-star) matches. + * No external dependencies needed - covers all Codex-common use cases. + */ +function matchGlob(target: string, pattern: string): boolean { + let regexStr = ""; + let i = 0; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === "*" && pattern[i + 1] === "*" && pattern[i + 2] === "/") { + regexStr += "(?:.*[/\\\\])?"; + i += 3; + } else if (ch === "*" && pattern[i + 1] === "*") { + regexStr += ".*"; + i += 2; + } else if (ch === "*") { + regexStr += "[^/\\\\]*"; + i += 1; + } else if (ch === "?") { + regexStr += "[^/\\\\]"; + i += 1; + } else if (ch === ".") { + regexStr += "\\."; + i += 1; + } else if (ch === "/" || ch === "\\") { + // Path separator: match both / and \ (cross-platform) + regexStr += "[/\\\\]"; + i += 1; + } else { + regexStr += ch; + i += 1; + } + } + + try { + const re = new RegExp(`^${regexStr}$`, "i"); + return re.test(target); + } catch { + return target.toLowerCase().includes(pattern.toLowerCase().replace(/\*\*/g, "")); + } +} + +// ─── 路径匹配引擎 ───────────────────────────────────────── + +/** + * 将 FileSystemPathKind 解析为实际的文件系统路径列表 + */ +function resolvePathKind( + kind: FileSystemPathKind, + context: { projectRoot: string; dataDir: string } +): string[] { + switch (kind.type) { + case "exact": + return [kind.path]; + case "glob": + return [kind.pattern]; + case "special": + switch (kind.kind) { + case "project_root": + return [context.projectRoot]; + case "tmpdir": + return [os.tmpdir()]; + case "home": + return [os.homedir()]; + case "data_dir": + return [context.dataDir]; + } + } +} + +/** + * 检查目标路径是否被允许访问 + * + * 匹配规则(类似 ACL): + * 1. 如果有 deny 匹配 → 拒绝 + * 2. 如果有 write 匹配 → 允许写入 + * 3. 如果有 read 匹配 → 允许读取 + * 4. 无匹配 → 拒绝(默认安全) + */ +export function checkFileSystemAccess( + targetPath: string, + profile: PermissionProfile, + context: { projectRoot: string; dataDir: string } +): { allowed: boolean; writeAccess: boolean; matchedBy?: string } { + if (profile.mode === "unrestricted") { + return { allowed: true, writeAccess: true }; + } + + const entries: FileSystemSandboxEntry[] = + profile.mode === "legacy" + ? profile.readWriteRoots.map((p) => ({ + path: { type: "exact" as const, path: p }, + access: "write" as FileSystemAccessLevel, + })) + : profile.config.fileSystem; + + const normalizedTarget = path.resolve(targetPath).toLowerCase(); + + let matchedAccess: FileSystemAccessLevel | null = null; + let matchedBy: string | undefined; + + for (const entry of entries) { + const resolvedPaths = resolvePathKind(entry.path, context); + for (const rp of resolvedPaths) { + // Don't call path.resolve() on glob patterns - it prepends CWD on Windows! + const normalizedEntry = entry.path.type === "glob" + ? rp.toLowerCase() + : path.resolve(rp).toLowerCase(); + + let matches = false; + if (entry.path.type === "glob") { + matches = matchGlob(normalizedTarget, normalizedEntry); + } else if (entry.path.type === "exact") { + matches = + normalizedTarget === normalizedEntry || + normalizedTarget.startsWith(normalizedEntry + path.sep); + } else { + matches = normalizedTarget.startsWith(normalizedEntry); + } + + if (matches) { + matchedBy = formatPathKind(entry.path); + if (entry.access === "deny") { + return { allowed: false, writeAccess: false, matchedBy }; + } + if (entry.access === "write") { + matchedAccess = "write"; + } else if (entry.access === "read" && matchedAccess !== "write") { + matchedAccess = "read"; + } + } + } + } + + return { + allowed: matchedAccess !== null, + writeAccess: matchedAccess === "write", + matchedBy, + }; +} + +/** + * 检查网络访问是否被允许 + */ +export function checkNetworkAccess( + host: string | undefined, + profile: PermissionProfile +): boolean { + if (profile.mode === "unrestricted") return true; + if (profile.mode === "legacy") return true; + + const net = profile.config.network; + if (!net.external) return false; + if (!host || !net.allowedHosts || net.allowedHosts.length === 0) return true; + return net.allowedHosts.some((allowed: string) => host.includes(allowed)); +} + +/** + * 检查 Git 操作是否被允许 + */ +export function checkGitAccess( + operation: "read" | "write", + profile: PermissionProfile +): boolean { + if (profile.mode === "unrestricted") return true; + if (profile.mode === "legacy") return true; + return operation === "read" ? profile.config.git.read : profile.config.git.write; +} + +// ─── 工具函数 ───────────────────────────────────────────── + +function formatPathKind(kind: FileSystemPathKind): string { + switch (kind.type) { + case "exact": return `path:${kind.path}`; + case "glob": return `glob:${kind.pattern}`; + case "special": return `special:${kind.kind}`; + } +} + +/** + * 将 JSON 配置解析为 PermissionProfile + */ +export function parsePermissionProfile(config: unknown): PermissionProfile { + if (!config || typeof config !== "object") { + return DEFAULT_DEV_PROFILE; + } + const cfg = config as Record; + + if (cfg.mode === "unrestricted") { + return UNRESTRICTED_PROFILE; + } + + if (cfg.mode === "legacy" && Array.isArray(cfg.readWriteRoots)) { + return { + mode: "legacy", + readWriteRoots: cfg.readWriteRoots.map(String), + }; + } + + // 从 settings 中的 PermissionSettings 自动推断 + const allowList = (cfg.allow as string[]) ?? []; + if (allowList.length > 0 || cfg.mode === "managed") { + return legacyProfileFromScopes(allowList); + } + + return DEFAULT_DEV_PROFILE; +} diff --git a/packages/core/src/common/permissions.ts b/packages/core/src/common/permissions.ts index e7190b20..50a3afe0 100644 --- a/packages/core/src/common/permissions.ts +++ b/packages/core/src/common/permissions.ts @@ -60,7 +60,6 @@ export type ComputeToolCallPermissionsOptions = { projectRoot: string; toolCalls: unknown[]; settings?: Required; - forceAskScopes?: readonly PermissionScope[]; readPermissionExemptPaths?: string[]; resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined; }; @@ -148,6 +147,14 @@ export function buildSyntheticToolExecution(toolCall: PermissionToolCall, error: }; } +/** + * Check if a permission scope is a "silent" auto-handled scope. + * These don't require user interaction — they're handled internally. + */ +export function isInternalPermissionScope(scope: PermissionScope): boolean { + return scope === "doom-loop"; +} + export function computeToolCallPermissions(options: ComputeToolCallPermissionsOptions): PermissionPlan { const permissions: MessageToolPermission[] = []; const askPermissions: AskPermissionRequest[] = []; @@ -164,18 +171,10 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp readPermissionExemptPaths: options.readPermissionExemptPaths, resolveSnippetPath: options.resolveSnippetPath, }); - const evaluatedPermission = evaluatePermissionScopes(request.scopes, options.settings); - const forcedAskScopes = - evaluatedPermission === "deny" - ? [] - : getAllowedForcedAskScopes(request.scopes, options.settings, options.forceAskScopes); - const permission = forcedAskScopes.length > 0 ? "ask" : evaluatedPermission; + const permission = evaluatePermissionScopes(request.scopes, options.settings); permissions.push({ toolCallId: toolCall.id, permission }); if (permission === "ask") { - const askScopes = mergeAskScopes( - getPermissionScopesRequiringAsk(request.scopes, options.settings), - forcedAskScopes - ); + const askScopes = getPermissionScopesRequiringAsk(request.scopes, options.settings); askPermissions.push({ toolCallId: toolCall.id, scopes: askScopes.length > 0 ? askScopes : request.scopes, @@ -189,23 +188,37 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp return { permissions, askPermissions }; } -function getAllowedForcedAskScopes( - scopes: AskPermissionScope[], - settings: Required | undefined, - forceAskScopes: readonly PermissionScope[] | undefined -): PermissionScope[] { - if (!forceAskScopes?.length) { - return []; - } - - return scopes.filter( - (scope): scope is PermissionScope => - scope !== "unknown" && forceAskScopes.includes(scope) && evaluatePermissionScopes([scope], settings) === "allow" - ); -} - -function mergeAskScopes(existing: AskPermissionScope[], forced: PermissionScope[]): AskPermissionScope[] { - return [...existing, ...forced.filter((scope) => !existing.includes(scope))]; +/** + * 异步审计 PermissionPlan(fire-and-forget,不阻塞主流程) + * 将每次权限检查结果写入 SQLite + */ +export function auditPermissionPlan( + sessionId: string, + plan: PermissionPlan, + toolCalls: PermissionToolCall[] +): void { + // Fire-and-forget: 异步写入 SQLite,不 await + import("../common/session-log").then((log) => { + for (const perm of plan.permissions) { + const toolCall = toolCalls.find((tc) => tc.id === perm.toolCallId); + const scopes: string[] = []; + const askReq = plan.askPermissions.find((ap) => ap.toolCallId === perm.toolCallId); + if (askReq) { + scopes.push(...askReq.scopes.map(String)); + } + log.logPermissionDecision( + sessionId, + perm.toolCallId, + toolCall?.function?.name ?? "unknown", + scopes.length > 0 ? scopes : [perm.permission], + perm.permission + ).catch(() => { + // 静默处理失败 + }); + } + }).catch(() => { + // 静默处理 + }); } export function describeToolPermissionRequest(options: { diff --git a/packages/core/src/common/session-log.ts b/packages/core/src/common/session-log.ts new file mode 100644 index 00000000..72aa1f74 --- /dev/null +++ b/packages/core/src/common/session-log.ts @@ -0,0 +1,394 @@ +/** + * SQLite 会话日志模块 + * 借鉴 OpenAI Codex CLI 的 SQLite 结构化持久化设计 + * + * 用 SQLite 替代文件系统散乱存储,支持: + * - 结构化 JSON 日志查询(json_extract) + * - Token 预算审计 + * - 权限审批记录 + * - 自动清理过期数据 + */ + +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +// sql.js 是纯 WASM 实现,无需本地编译 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SqlJsModule = { Database: new (data?: ArrayLike | Buffer | null) => import("sql.js").Database }; +let SQL: SqlJsModule | null = null; + +// 数据库实例缓存(按会话 ID) +const dbCache = new Map(); + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +type SqlJsDatabase = import("sql.js").Database; + +interface DatabaseHandle { + db: SqlJsDatabase; + path: string; + save: () => void; +} + +// ─── 表结构 ─────────────────────────────────────────────── + +const SCHEMA_SESSION_LOGS = ` +CREATE TABLE IF NOT EXISTS session_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + type TEXT NOT NULL, -- 'agentMessage' | 'toolCall' | 'permission' | 'system' + item_json TEXT NOT NULL -- 结构化 JSON 数据 +); +CREATE INDEX IF NOT EXISTS idx_logs_session ON session_logs(session_id, created_at); +CREATE INDEX IF NOT EXISTS idx_logs_type ON session_logs(session_id, type); +`; + +const SCHEMA_TOKEN_BUDGET = ` +CREATE TABLE IF NOT EXISTS token_budget ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + turn_tokens INTEGER NOT NULL, + total_used INTEGER NOT NULL, + budget_max INTEGER NOT NULL, + remaining AS (budget_max - total_used) STORED +); +CREATE INDEX IF NOT EXISTS idx_token_session ON token_budget(session_id, created_at); +`; + +const SCHEMA_PERMISSION_AUDIT = ` +CREATE TABLE IF NOT EXISTS permission_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + tool_call_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + scopes TEXT NOT NULL, -- JSON array of scopes + decision TEXT NOT NULL, -- 'allow' | 'deny' | 'ask' + reason TEXT +); +CREATE INDEX IF NOT EXISTS idx_perm_session ON permission_audit(session_id, created_at); +`; + +const SCHEMA_CHECKPOINTS = ` +CREATE TABLE IF NOT EXISTS checkpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + label TEXT, + snapshot_json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_cp_session ON checkpoints(session_id, created_at DESC); +`; + +const ALL_SCHEMAS = [SCHEMA_SESSION_LOGS, SCHEMA_TOKEN_BUDGET, SCHEMA_PERMISSION_AUDIT, SCHEMA_CHECKPOINTS]; + +// ─── 数据库管理 ─────────────────────────────────────────── + +function getDbPath(sessionId: string): string { + const baseDir = process.env.DEEPCODE_DATA_DIR || path.join(os.homedir(), ".deepcode", "data"); + fs.mkdirSync(baseDir, { recursive: true }); + return path.join(baseDir, `session_${sessionId}.db`); +} + +async function ensureSqlJs(): Promise { + if (!SQL) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sqlJsModule = await import("sql.js"); + const initSqlJs = sqlJsModule.default; + SQL = (await initSqlJs()) as unknown as SqlJsModule; + } +} + +async function getDb(sessionId: string): Promise { + const cached = dbCache.get(sessionId); + if (cached) return cached; + + await ensureSqlJs(); + if (!SQL) { + throw new Error("SQLite initialization failed"); + } + + const dbPath = getDbPath(sessionId); + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + let db: import("sql.js").Database; + + if (fs.existsSync(dbPath)) { + const buffer = fs.readFileSync(dbPath); + db = new SQL.Database(buffer); + } else { + db = new SQL.Database(); + } + + // 初始化表结构 + for (const schema of ALL_SCHEMAS) { + db.run(schema); + } + db.run("PRAGMA journal_mode=WAL"); + db.run("PRAGMA synchronous=NORMAL"); + + const handle: DatabaseHandle = { + db, + path: dbPath, + save: () => { + const data = db.export(); + const buffer = Buffer.from(data); + fs.writeFileSync(dbPath, buffer); + }, + }; + + dbCache.set(sessionId, handle); + return handle; +} + +/** 定期持久化 + 清理旧数据 */ +let saveInterval: ReturnType | null = null; + +function startAutoSave(intervalMs = 30000): void { + if (saveInterval) return; + saveInterval = setInterval(() => { + for (const [sessionId, handle] of dbCache.entries()) { + try { + handle.save(); + // 清理 7 天前的日志 + handle.db.run( + "DELETE FROM session_logs WHERE created_at < datetime('now', '-7 days')" + ); + } catch (err) { + console.error(`[session-log] Auto-save failed for ${sessionId}:`, err); + } + } + }, intervalMs); +} + +// ─── 日志写入 ───────────────────────────────────────────── + +/** 写入一条 agent 消息日志 */ +export async function logAgentMessage( + sessionId: string, + message: Record +): Promise { + const handle = await getDb(sessionId); + handle.db.run( + "INSERT INTO session_logs (session_id, type, item_json) VALUES (?, 'agentMessage', ?)", + [sessionId, JSON.stringify(message)] + ); +} + +/** 写入一条 tool call 日志 */ +export async function logToolCall( + sessionId: string, + toolCall: Record +): Promise { + const handle = await getDb(sessionId); + handle.db.run( + "INSERT INTO session_logs (session_id, type, item_json) VALUES (?, 'toolCall', ?)", + [sessionId, JSON.stringify(toolCall)] + ); +} + +/** 写入权限审批记录 */ +export async function logPermissionDecision( + sessionId: string, + toolCallId: string, + toolName: string, + scopes: string[], + decision: "allow" | "deny" | "ask", + reason?: string +): Promise { + const handle = await getDb(sessionId); + handle.db.run( + `INSERT INTO permission_audit (session_id, tool_call_id, tool_name, scopes, decision, reason) + VALUES (?, ?, ?, ?, ?, ?)`, + [sessionId, toolCallId, toolName, JSON.stringify(scopes), decision, reason ?? null] + ); +} + +/** 记录 Token 消耗 */ +export async function logTokenUsage( + sessionId: string, + turnTokens: number, + totalUsed: number, + budgetMax: number +): Promise { + const handle = await getDb(sessionId); + handle.db.run( + `INSERT INTO token_budget (session_id, turn_tokens, total_used, budget_max) + VALUES (?, ?, ?, ?)`, + [sessionId, turnTokens, totalUsed, budgetMax] + ); +} + +/** 保存检查点快照 */ +export async function saveCheckpoint( + sessionId: string, + snapshot: Record, + label?: string +): Promise { + const handle = await getDb(sessionId); + handle.db.run( + "INSERT INTO checkpoints (session_id, label, snapshot_json) VALUES (?, ?, ?)", + [sessionId, label ?? null, JSON.stringify(snapshot)] + ); + return (handle.db.exec("SELECT last_insert_rowid()")[0]?.values[0]?.[0] as number) ?? 0; +} + +// ─── 日志查询 ───────────────────────────────────────────── + +/** 查询最新的 N 条日志 */ +export async function queryRecentLogs( + sessionId: string, + limit = 50, + type?: string +): Promise> { + const handle = await getDb(sessionId); + let sql = "SELECT id, created_at, type, item_json FROM session_logs WHERE session_id = ?"; + const params: unknown[] = [sessionId]; + + if (type) { + sql += " AND type = ?"; + params.push(type); + } + sql += " ORDER BY created_at DESC LIMIT ?"; + params.push(limit); + + const results = handle.db.exec(sql, params); + if (results.length === 0) return []; + + return results[0].values.map((row: unknown[]) => ({ + id: row[0] as number, + created_at: row[1] as string, + type: row[2] as string, + data: JSON.parse(row[3] as string), + })); +} + +/** 使用 json_extract 查询特定字段 */ +export async function queryLogsByJsonPath( + sessionId: string, + jsonPath: string, + expectedValue: string, + limit = 50 +): Promise> { + const handle = await getDb(sessionId); + const sql = ` + SELECT id, created_at, type, item_json + FROM session_logs + WHERE session_id = ? + AND json_extract(item_json, ?) = ? + ORDER BY created_at DESC + LIMIT ? + `; + const results = handle.db.exec(sql, [sessionId, jsonPath, expectedValue, limit]); + if (results.length === 0) return []; + + return results[0].values.map((row: unknown[]) => ({ + id: row[0] as number, + created_at: row[1] as string, + type: row[2] as string, + data: JSON.parse(row[3] as string), + })); +} + +/** 查询 Token 使用统计 */ +export async function queryTokenStats( + sessionId: string +): Promise<{ totalTokens: number; maxBudget: number; remainingBudget: number } | null> { + const handle = await getDb(sessionId); + const results = handle.db.exec( + `SELECT total_used, budget_max FROM token_budget + WHERE session_id = ? + ORDER BY created_at DESC LIMIT 1`, + [sessionId] + ); + if (results.length === 0 || results[0].values.length === 0) return null; + + const [totalUsed, budgetMax] = results[0].values[0] as [number, number]; + return { + totalTokens: totalUsed, + maxBudget: budgetMax, + remainingBudget: Math.max(0, budgetMax - totalUsed), + }; +} + +/** 查询权限审批历史 */ +export async function queryPermissionHistory( + sessionId: string, + limit = 100 +): Promise> { + const handle = await getDb(sessionId); + const results = handle.db.exec( + `SELECT id, created_at, tool_name, scopes, decision + FROM permission_audit + WHERE session_id = ? + ORDER BY created_at DESC LIMIT ?`, + [sessionId, limit] + ); + if (results.length === 0) return []; + + return results[0].values.map((row: unknown[]) => ({ + id: row[0] as number, + created_at: row[1] as string, + tool_name: row[2] as string, + scopes: JSON.parse(row[3] as string), + decision: row[4] as string, + })); +} + +// ─── 生命周期管理 ───────────────────────────────────────── + +/** 关闭数据库并持久化 */ +export async function closeSession(sessionId: string): Promise { + const handle = dbCache.get(sessionId); + if (!handle) return; + handle.save(); + handle.db.close(); + dbCache.delete(sessionId); +} + +/** 删除会话数据 */ +export async function deleteSession(sessionId: string): Promise { + await closeSession(sessionId); + const dbPath = getDbPath(sessionId); + try { + fs.unlinkSync(dbPath); + // 删除 WAL/SHM 文件 + try { fs.unlinkSync(dbPath + "-wal"); } catch { /* ok */ } + try { fs.unlinkSync(dbPath + "-shm"); } catch { /* ok */ } + } catch { + // 文件不存在就忽略 + } +} + +/** 关闭所有会话(退出时调用) */ +export async function closeAll(): Promise { + for (const [sessionId] of dbCache) { + await closeSession(sessionId); + } + if (saveInterval) { + clearInterval(saveInterval); + saveInterval = null; + } +} + +// 进程退出时自动保存 +process.on("exit", () => { + for (const [, handle] of dbCache) { + try { + const data = handle.db.export(); + fs.writeFileSync(handle.path, Buffer.from(data)); + } catch { + // 退出时静默处理 + } + } +}); + +// 启动自动保存 +startAutoSave(); diff --git a/packages/core/src/common/skill-parser.ts b/packages/core/src/common/skill-parser.ts new file mode 100644 index 00000000..96b5522a --- /dev/null +++ b/packages/core/src/common/skill-parser.ts @@ -0,0 +1,313 @@ +/** + * 增强版 SKILL.md 解析器 + * 借鉴 OpenAI Codex CLI 的 Skill frontmatter 格式 + * + * 支持完整的 Codex 兼容 frontmatter: + * - name, description 基础字段 + * - agent.dependencies 依赖声明 + * - agent.interface 接口配置 + * - agent.policy 策略声明 + * - disable-model-invocation 禁用标志 + */ + +import * as fs from "fs"; +import * as path from "path"; +import matter from "gray-matter"; + +// ─── 类型定义 ──────────────────────────────────────────── + +/** Skill 依赖声明 */ +export interface SkillDependency { + /** 依赖的 Skill 名称 */ + name: string; + /** 版本要求(可选) */ + version?: string; + /** 来源路径(可选) */ + source?: string; +} + +/** Skill 接口配置 */ +export interface SkillInterface { + /** 默认提示词(Agent 进入 Skill 时的初始 prompt) */ + defaultPrompt?: string; + /** 品牌色(#RRGGBB 格式) */ + brandColor?: string; + /** 截图路径列表 */ + screenshots?: string[]; +} + +/** Skill 策略声明 */ +export interface SkillPolicy { + /** 需要的权限 scope 列表 */ + requiredPermissions?: string[]; + /** 是否允许网络访问 */ + allowNetwork?: boolean; + /** 是否允许 Git 写入 */ + allowGitWrite?: boolean; + /** 允许的路径模式 */ + allowedPaths?: string[]; + /** 拒绝的路径模式 */ + deniedPaths?: string[]; +} + +/** 解析后的 Skill 元数据 */ +export interface SkillMeta { + /** Skill 名称 */ + name: string; + /** 描述(给 Agent 看的触发关键词) */ + description: string; + /** Agent 配置 */ + agent?: { + /** 依赖的 Skills */ + dependencies?: SkillDependency[]; + /** UI 接口配置 */ + interface?: SkillInterface; + /** 权限策略 */ + policy?: SkillPolicy; + }; + /** 禁用模型调用(纯工具 Skill) */ + disableModelInvocation?: boolean; + /** 其他原始 frontmatter 字段 */ + raw: Record; +} + +// ─── 解析器 ─────────────────────────────────────────────── + +const MAX_SKILL_NAME_LENGTH = 64; +const ALLOWED_FRONTMATTER_KEYS = new Set([ + "name", "description", "agent", "disable-model-invocation", + "metadata", +]); + +// 这些常量保留供后续验证扩展用 +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const ALLOWED_AGENT_KEYS = new Set([ + "dependencies", "interface", "policy", +]); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const ALLOWED_INTERFACE_KEYS = new Set([ + "default-prompt", "default_prompt", "brand-color", "brand_color", + "screenshots", +]); + +/** + * 解析 SKILL.md 文件内容 + */ +export function parseSkillFile(filePath: string): SkillMeta { + const content = fs.readFileSync(filePath, "utf8"); + return parseSkillContent(content, path.basename(path.dirname(filePath))); +} + +/** + * 解析 SKILL.md 文本内容 + */ +export function parseSkillContent( + raw: string, + fallbackName: string +): SkillMeta { + const parsed = matter(raw); + const frontmatter = parsed.data ?? {}; + + // 校验无效字段 + const unexpectedKeys = Object.keys(frontmatter).filter( + (k) => !ALLOWED_FRONTMATTER_KEYS.has(k) && k !== "metadata" + ); + if (unexpectedKeys.length > 0 && !("metadata" in frontmatter)) { + // 静默忽略,保持向前兼容 + } + + // name(必需) + const name = typeof frontmatter.name === "string" && frontmatter.name.trim() + ? frontmatter.name.trim() + : fallbackName; + + // description(推荐) + const description = typeof frontmatter.description === "string" + ? frontmatter.description.trim() + : ""; + + // agent 配置 + const agentRaw = frontmatter.agent; + const agent = parseAgentConfig(agentRaw); + + // disable-model-invocation + const disableModelInvocation = frontmatter["disable-model-invocation"] === true; + + return { + name, + description, + agent: Object.keys(agent ?? {}).length > 0 ? agent : undefined, + disableModelInvocation, + raw: frontmatter as Record, + }; +} + +function parseAgentConfig(agentRaw: unknown): SkillMeta["agent"] { + if (!agentRaw || typeof agentRaw !== "object" || Array.isArray(agentRaw)) { + return undefined; + } + + const agent = agentRaw as Record; + const result: SkillMeta["agent"] = {}; + + // dependencies + if (agent.dependencies) { + result.dependencies = parseDependencies(agent.dependencies); + } + + // interface + if (agent.interface) { + result.interface = parseInterface(agent.interface); + } + + // policy + if (agent.policy) { + result.policy = parsePolicy(agent.policy); + } + + return result; +} + +function parseDependencies(deps: unknown): SkillDependency[] { + if (Array.isArray(deps)) { + return deps + .filter((d): d is Record => typeof d === "object" && d !== null) + .map((d) => { + const dep = d as Record; + return { + name: String(dep.name ?? ""), + version: typeof dep.version === "string" ? dep.version : undefined, + source: typeof dep.source === "string" ? dep.source : undefined, + }; + }) + .filter((d) => d.name.length > 0); + } + + if (typeof deps === "object" && deps !== null) { + // 对象格式: { "skill-name": "path" } + return Object.entries(deps).map(([name, source]) => ({ + name, + source: typeof source === "string" ? source : undefined, + })); + } + + return []; +} + +function parseInterface(iface: unknown): SkillInterface | undefined { + if (!iface || typeof iface !== "object" || Array.isArray(iface)) { + return undefined; + } + + const raw = iface as Record; + const result: SkillInterface = {}; + + // default_prompt (支持两种命名) + result.defaultPrompt = String( + raw["default_prompt"] ?? raw["default-prompt"] ?? "" + ) || undefined; + + // brand_color + const brandColor = String(raw["brand_color"] ?? raw["brand-color"] ?? ""); + if (/^#[0-9a-fA-F]{6}$/.test(brandColor)) { + result.brandColor = brandColor; + } + + // screenshots + if (Array.isArray(raw.screenshots)) { + result.screenshots = raw.screenshots.map(String).filter((s) => s.length > 0); + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function parsePolicy(policy: unknown): SkillPolicy | undefined { + if (!policy || typeof policy !== "object" || Array.isArray(policy)) { + return undefined; + } + + const raw = policy as Record; + const result: SkillPolicy = {}; + + if (Array.isArray(raw.requiredPermissions)) { + result.requiredPermissions = raw.requiredPermissions.map(String); + } + if (typeof raw.allowNetwork === "boolean") { + result.allowNetwork = raw.allowNetwork; + } + if (typeof raw.allowGitWrite === "boolean") { + result.allowGitWrite = raw.allowGitWrite; + } + if (Array.isArray(raw.allowedPaths)) { + result.allowedPaths = raw.allowedPaths.map(String); + } + if (Array.isArray(raw.deniedPaths)) { + result.deniedPaths = raw.deniedPaths.map(String); + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * 从 Skill policy 生成 PermissionProfile + */ +import { + type PermissionProfile, + type FileSystemSandboxEntry, + DEFAULT_DEV_PROFILE, +} from "./permission-profile"; + +export function skillPolicyToProfile( + policy: SkillPolicy | undefined, + _projectRoot: string +): PermissionProfile { + if (!policy) return DEFAULT_DEV_PROFILE; + + const entries: FileSystemSandboxEntry[] = [ + { path: { type: "special", kind: "project_root" }, access: "write" }, + { path: { type: "special", kind: "tmpdir" }, access: "write" }, + ]; + + // allowedPaths → write + for (const p of policy.allowedPaths ?? []) { + entries.push({ + path: p.includes("*") ? { type: "glob", pattern: p } : { type: "exact", path: p }, + access: "write", + }); + } + + // deniedPaths → deny + for (const p of policy.deniedPaths ?? []) { + entries.push({ + path: p.includes("*") ? { type: "glob", pattern: p } : { type: "exact", path: p }, + access: "deny", + }); + } + + return { + mode: "managed", + config: { + fileSystem: entries, + network: { external: policy.allowNetwork ?? true }, + git: { read: true, write: policy.allowGitWrite ?? true }, + globScanMaxDepth: 50, + }, + }; +} + +/** + * 验证 Skill 名称合法性 + */ +export function validateSkillName(name: string): string | null { + if (!name || !name.trim()) { + return "Skill name must not be empty"; + } + if (name.length > MAX_SKILL_NAME_LENGTH) { + return `Skill name is too long (${name.length} characters). Maximum is ${MAX_SKILL_NAME_LENGTH} characters.`; + } + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) { + return "Skill name must start with a letter or number, and contain only letters, numbers, underscores, and hyphens"; + } + return null; +} diff --git a/packages/core/src/common/sql.js.d.ts b/packages/core/src/common/sql.js.d.ts new file mode 100644 index 00000000..fdb6c5f9 --- /dev/null +++ b/packages/core/src/common/sql.js.d.ts @@ -0,0 +1,16 @@ +declare module "sql.js" { + interface SqlJsStatic { + Database: new (data?: ArrayLike | Buffer | null) => Database; + } + interface QueryExecResult { + columns: string[]; + values: unknown[][]; + } + interface Database { + run(sql: string, params?: unknown[]): Database; + exec(sql: string, params?: unknown[]): QueryExecResult[]; + export(): Uint8Array; + close(): void; + } + export default function initSqlJs(config?: Record): Promise; +} diff --git a/packages/core/src/context/compact.ts b/packages/core/src/context/compact.ts new file mode 100644 index 00000000..581cd313 --- /dev/null +++ b/packages/core/src/context/compact.ts @@ -0,0 +1,245 @@ +/** + * Runtime compression engine. + * + * Compresses large content strings before they enter the session context. + * Three strategies, applied in priority order: + * + * 1. **JSON tool-result** — parses the payload, truncates only the `output` + * field, preserves the JSON structure and all other fields. + * 2. **Base64-like binary** — detects high-entropy / low-text payloads and + * applies a length-based replacement. + * 3. **Plain text** — length-capped with a truncated indicator. + * + * All strategies append a `(truncated, original N chars)` note so callers + * (and readers of the persisted JSONL) can see that compression happened. + * + * Additionally provides **message history compression** (OpenCode-inspired): + * compresses a series of session messages into a concise summary, preserving + * the critical context while discarding redundant tool call details. + */ + +export type CompressOptions = { + /** Max character length before truncation kicks in. Default: 10 000. */ + maxLength?: number; + /** + * When true (default), attempts to JSON-parse the content and only + * truncate the `output` field, keeping the envelope intact. + */ + smartParse?: boolean; +}; + +/** + * Options for message history compression. + */ +export type CompressHistoryOptions = { + /** + * Max total characters for the compressed summary. Default: 2 000. + * The summarizer will try to stay within this budget. + */ + maxSummaryLength?: number; +}; + +const DEFAULT_MAX_LENGTH = 10_000; +const DEFAULT_MAX_SUMMARY_LENGTH = 2_000; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Compress `content` to fit within `options.maxLength` characters. + * + * Returns the original string unchanged when it is already short enough. + */ +export function compressContent( + content: string, + options: CompressOptions = {} +): string { + const maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH; + const smartParse = options.smartParse ?? true; + + // Fast path: nothing to do. + if (content.length <= maxLength) { + return content; + } + + // 1. Try smart JSON truncation (preferred for tool results). + if (smartParse) { + const jsonResult = tryCompressJsonToolResult(content, maxLength); + if (jsonResult !== null) { + return jsonResult; + } + } + + // 2. Fallback: plain-text truncation. + return truncatePlainText(content, maxLength); +} + +// --------------------------------------------------------------------------- +// Internal strategies +// --------------------------------------------------------------------------- + +/** + * Attempt to parse `content` as a JSON tool-result and truncate only the + * `output` field. Returns `null` when parsing fails or content is not a + * tool-result shape. + */ +function tryCompressJsonToolResult( + content: string, + maxLength: number +): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return null; // not JSON + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const record = parsed as Record; + + // We only compress when there is a large-ish `output` string. + const output = record.output; + if (typeof output !== "string" || output.length < maxLength / 2) { + // The payload may still be larger than maxLength even without a big + // `output` – fall through to full truncation below. + return null; + } + + // Clone and replace output with truncated version. + const compressed: Record = { ...record }; + compressed.output = truncatePlainText(output, maxLength); + + const result = JSON.stringify(compressed, null, 2); + return result.length < content.length ? result : null; +} + +/** + * Truncate plain text, appending a note about the original length. + */ +function truncatePlainText(text: string, maxLength: number): string { + const suffix = `\n\n... (truncated, original ${text.length} chars)`; + const available = maxLength - suffix.length; + if (available <= 0) { + return text.slice(0, Math.max(0, maxLength)); + } + return text.slice(0, available) + suffix; +} + +// --------------------------------------------------------------------------- +// Message history compression (OpenCode-inspired) +// --------------------------------------------------------------------------- + +/** + * Represents a single message within the history being compressed. + */ +export interface CompressableMessage { + role: "system" | "user" | "assistant"; + content?: string | null; + toolCalls?: Array<{ function?: { name?: string } }> | null; +} + +/** + * Compress a list of session messages into a compact summary. + * + * Keeps the first and last message intact (they contain the current instruction + * and latest context), and summarizes the middle section into a concise form + * that preserves: + * - what the user asked + * - what tools were used and what they found + * - what decisions were made + * + * This is a **lossy** compression — it drops intermediate tool call outputs + * and verbose assistant monologues. + */ +export function compressMessageHistory( + messages: CompressableMessage[], + options: CompressHistoryOptions = {} +): CompressableMessage[] { + const maxSummaryLen = options.maxSummaryLength ?? DEFAULT_MAX_SUMMARY_LENGTH; + + if (messages.length <= 3) { + return messages; // too short to compress + } + + // Always keep the first message (system prompt / user request) + // and the last message (latest context). + const head = messages.slice(0, 1); + const tail = messages.slice(-1); + const body = messages.slice(1, -1); + + if (body.length === 0) { + return messages; + } + + // Summarize the body: extract key information from each message + const summary = summarizeMessages(body, maxSummaryLen); + + return [ + ...head, + { + role: "system" as const, + content: `[Compressed history: ${body.length} messages -> summary]\n${summary}`, + }, + ...tail, + ]; +} + +/** + * Build a concise summary of a sequence of messages. + * Extracts user intent, tool usage patterns, and key findings. + */ +function summarizeMessages(messages: CompressableMessage[], maxLen: number): string { + const parts: string[] = []; + let estimatedLen = 0; + + for (const msg of messages) { + if (estimatedLen >= maxLen) { + parts.push(`... (${messages.length - parts.length} more messages omitted)`); + break; + } + + switch (msg.role) { + case "user": { + const text = msg.content ?? ""; + const snippet = text.length > 100 ? text.slice(0, 100) + "..." : text; + if (snippet) { + parts.push(`User: ${snippet}`); + estimatedLen += snippet.length; + } + break; + } + case "assistant": { + if (msg.toolCalls && msg.toolCalls.length > 0) { + const toolNames = msg.toolCalls + .map((tc) => tc.function?.name) + .filter(Boolean) + .join(", "); + if (toolNames) { + const line = `-> Tools: ${toolNames}`; + parts.push(line); + estimatedLen += line.length; + } + } + if (!msg.toolCalls || msg.toolCalls.length === 0) { + const text = msg.content ?? ""; + if (text.length > 0) { + const snippet = text.length > 150 ? text.slice(0, 150) + "..." : text; + parts.push(`-> ${snippet}`); + estimatedLen += snippet.length; + } + } + break; + } + } + } + + let result = parts.join("\n"); + if (result.length > maxLen) { + result = result.slice(0, maxLen - 50) + `\n... (truncated, original ${result.length} chars)`; + } + return result || "(empty)"; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9749ff4b..19de00e0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export type { StatusLineSettings, ResolvedStatusLineSettings, StatusLineProviderConfig, + HooksConfig, } from "./settings"; // Session @@ -55,7 +56,6 @@ export { getCompactPrompt, getRuntimeContext, getDefaultSkillPrompt, - getPlanModePrompt, getExtensionRoot, getTools, buildSkillDocumentsPrompt, @@ -105,8 +105,6 @@ export { DEEPSEEK_V4_MODELS, supportsMultimodal, defaultsToThinkingMode } from " export { findGitBashPath, resolveShellPath, setShellIfWindows } from "./common/shell-utils"; export { logApiError } from "./common/error-logger"; export { logOpenAIChatCompletionDebug } from "./common/debug-logger"; -export { describeLlmError, getLlmErrorDetails } from "./common/llm-error"; -export type { LlmErrorDetails } from "./common/llm-error"; export { clampBashTimeoutMs, DEFAULT_BASH_TIMEOUT_MS, @@ -122,6 +120,7 @@ export { appendProjectPermissionAllows, normalizeAskPermissions, parseToolCallForPermissions, + auditPermissionPlan, } from "./common/permissions"; export type { AskPermissionRequest, @@ -133,6 +132,76 @@ export type { UserToolPermission, } from "./common/permissions"; +// Compression +export { compressContent, compressMessageHistory } from "./context/compact"; +export type { CompressOptions, CompressHistoryOptions, CompressableMessage } from "./context/compact"; + // State types export type { FileState, FileSnippet, FileLineEnding } from "./common/state"; export type { FileReadMetadata } from "./common/file-utils"; + +// Permission Profile(Codex 风格结构化权限) +export { + STRICT_SANDBOX_PROFILE, + DEFAULT_DEV_PROFILE, + UNRESTRICTED_PROFILE, + legacyProfileFromScopes, + checkFileSystemAccess, + checkNetworkAccess, + checkGitAccess, + parsePermissionProfile, +} from "./common/permission-profile"; +export type { + PermissionProfile, + FileSystemAccessLevel, + FileSystemPathKind, + FileSystemSandboxEntry, + NetworkPermissions, + GitPermissions, + ManagedPermissionConfig, +} from "./common/permission-profile"; + +// SQLite 会话日志 +export { + logAgentMessage, + logToolCall, + logPermissionDecision, + logTokenUsage, + saveCheckpoint, + queryRecentLogs, + queryLogsByJsonPath, + queryTokenStats, + queryPermissionHistory, + closeSession, + deleteSession, + closeAll, +} from "./common/session-log"; + +// Job 队列 +export { + JobQueue, + getGlobalJobQueue, + createSpawnRequest, +} from "./common/job-queue"; +export type { + JobStatus, + SpawnRequest, + SpawnReady, + ExitPayload, + JobState, + JobEventCallbacks, +} from "./common/job-queue"; + +// Skill 解析器(Codex 兼容 frontmatter) +export { + parseSkillFile, + parseSkillContent, + skillPolicyToProfile, + validateSkillName, +} from "./common/skill-parser"; +export type { + SkillMeta, + SkillDependency, + SkillInterface, + SkillPolicy, +} from "./common/skill-parser"; diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts index 62171457..953e3c89 100644 --- a/packages/core/src/prompt.ts +++ b/packages/core/src/prompt.ts @@ -91,9 +91,67 @@ Here's an example of how your output should be structured: `; +const TOOL_CHAINING_PROMPT = ` +## Tool Chaining (Efficiency Pattern) + +When exploring an unfamiliar codebase, chain tools together for efficiency: +1. \`GlobTool\` or \`ListTool\` — discover files in the relevant directory +2. \`GrepTool\` — search for specific patterns, exports, or usages +3. \`ReadTool\` — read the specific files identified above +4. \`BashTool\` — run tests or verify the changes + +Each tool's output feeds naturally into the next. Let the results guide your next step +rather than speculating about file contents. +`; + +const AGENTIC_BEHAVIOR_PROMPT = ` +## Agentic Behavior: Plan → Execute → Verify → Auto-Fix + +For any non-trivial task (coding, debugging, refactoring, configuration), follow this disciplined cycle: + +### 1. Plan First +- Before executing any changes, use \`UpdatePlan\` to break the task into small, verifiable steps +- Each step should have a clear success criterion (e.g., "file compiles", "test passes", "output matches expected") +- Keep the plan visible throughout the task + +### 2. Execute Step-by-Step +- Work through the plan one step at a time +- Use the most appropriate tool for each step (Read, Edit, Write, Bash) +- After each modification, use Bash to verify syntax/compilation if applicable + +### 3. Verify After Each Step +- After a command runs, check the exit code and output +- If exit code ≠ 0, do NOT move on — treat this as a bug to fix immediately +- For code changes: run the relevant build/test/lint command to verify correctness +- Only mark a step as [x] in UpdatePlan when verification passes + +### 4. Auto Error Recovery (Critical) +When ANY tool call fails (bash exit code ≠ 0, edit mismatch, write error): +1. **Analyze** — Read the error output carefully. Extract the specific error message, line number, and file path. +2. **Diagnose** — Identify the root cause (syntax error, missing import, wrong API, permission issue, etc.) +3. **Fix** — Apply the minimal fix needed: + - For compile errors: fix the exact line/syntax + - For test failures: fix the implementation or test + - For runtime errors: fix the logic +4. **Verify** — Re-run the original command that failed to confirm the fix works +5. **Retry** — If the fix doesn't resolve the issue, try a different approach +6. **Escalate** — After 3 failed attempts, explain the problem to the user with the error details + +IMPORTANT: Never proceed to the next task step while a verification failure is unresolved. Fix first, then move on. + +### 5. Completion Checklist +Before declaring a task complete: +- [ ] All steps in the plan are marked [x] +- [ ] The code compiles/builds without errors +- [ ] Relevant tests pass +- [ ] The output matches what was requested +`; + const SYSTEM_PROMPT_BASE = `你是名叫Deep Code的交互式CLI工具,帮助用户完成软件工程任务。 Use the instructions below and the tools available to you to assist the user. -重要:严禁编造任何非编程相关的 URL。对于编程链接,仅限使用:1) 用户提供的上下文;2) 你确定的官方文档主域名。在输出前,必须自查该链接是否存在于你的上下文记忆中;若不存在,请明确说明无法提供。`; +重要:严禁编造任何非编程相关的 URL。对于编程链接,仅限使用:1) 用户提供的上下文;2) 你确定的官方文档主域名。在输出前,必须自查该链接是否存在于你的上下文记忆中;若不存在,请明确说明无法提供。 + +${AGENTIC_BEHAVIOR_PROMPT}`; type PromptToolOptions = { model?: string; @@ -188,16 +246,6 @@ export function getDefaultSkillPrompt(options: DefaultSkillPromptOptions = {}): return buildSkillDocumentsPrompt(skillDocs); } -/** Read the dedicated prompt used when a submitted turn enters Plan Mode. */ -export function getPlanModePrompt(): string { - const templatePath = path.join(getExtensionRoot(), "templates", "prompts", "plan.md"); - try { - return fs.readFileSync(templatePath, "utf8").trim(); - } catch { - return ""; - } -} - export function buildSkillDocumentsPrompt(skills: SkillPromptDocument[]): string { const blocks = skills.map((skill) => renderSkillDocumentBlock(skill)); return `Use the skill documents below to assist the user:\n${blocks.join("\n\n")}`; @@ -589,7 +637,7 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe type: "function", function: { name: "read", - description: "Read files from the filesystem (text, images, notebooks).", + description: "Read files from the filesystem (text, images, PDFs, notebooks).", parameters: { type: "object", properties: { @@ -605,6 +653,10 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe type: "number", description: "Number of lines to read", }, + pages: { + type: "string", + description: 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', + }, }, required: ["file_path"], additionalProperties: false, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 3add448e..3b47541a 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -14,7 +14,6 @@ import { getCompactPrompt, getDefaultSkillPrompt, getExtensionRoot, - getPlanModePrompt, getRuntimeContext, getSystemPrompt, getTools, @@ -29,15 +28,16 @@ import { type ToolExecutionHooks, } from "./tools/executor"; import { McpManager } from "./mcp/mcp-manager"; -import type { McpServerConfig, PermissionScope, PermissionSettings } from "./settings"; +import type { HooksConfig, McpServerConfig, PermissionScope, PermissionSettings } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; -import { describeLlmError, getLlmErrorDetails } from "./common/llm-error"; import { killProcessTree } from "./common/process-tree"; +import { fireHook } from "./common/hooks"; import { GitFileHistory, type FileHistoryCheckpointResult } from "./common/file-history"; import { clearSessionState, getSnippet, rebuildSessionStateFromHistory } from "./common/state"; import { appendProjectPermissionAllows, + auditPermissionPlan, buildPermissionToolExecution, computeToolCallPermissions, hasUserPermissionReplies, @@ -68,15 +68,7 @@ const PROJECT_CODE_HASH_LENGTH = 16; const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; -const PLAN_MODE_ON_STATUS_MESSAGE = " └ Set Plan Mode on. Awaiting ."; -const PLAN_MODE_OFF_STATUS_MESSAGE = " └ Set Plan Mode off."; -const PLAN_MODE_FORCE_ASK_SCOPES = [ - "write-in-cwd", - "write-out-cwd", - "delete-in-cwd", - "delete-out-cwd", - "mutate-git-log", -] as const satisfies readonly PermissionScope[]; +const PLAN_MODE_STATUS_MESSAGE = "/plan\n └ Set Plan Mode on. Awaiting ."; type ChatCompletionDebugOptions = { enabled?: boolean; @@ -242,7 +234,6 @@ export type SessionEntry = { updateTime: string; processes: Map | null; // {pid: process info} askPermissions?: AskPermissionRequest[]; - planMode?: boolean; }; export type SessionsIndex = { @@ -293,7 +284,6 @@ export type UserPromptContent = { skills?: SkillInfo[]; permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; - planMode?: boolean; }; export type SkillInfo = { @@ -310,6 +300,8 @@ type SessionManagerOptions = { getResolvedSettings: () => { model: string; webSearchTool?: string; + compressThreshold?: number; + hooks?: HooksConfig; mcpServers?: Record; permissions?: Required; enabledSkills?: Record; @@ -337,6 +329,8 @@ export class SessionManager { private readonly getResolvedSettings: () => { model: string; webSearchTool?: string; + compressThreshold?: number; + hooks?: HooksConfig; mcpServers?: Record; permissions?: Required; enabledSkills?: Record; @@ -349,6 +343,39 @@ export class SessionManager { private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); + + /** Plan mode flag: when true, mutating tools are denied. */ + private isPlanMode = false; + + /** Mutating tool names that are denied in Plan mode. */ + private static readonly MUTATING_TOOLS = new Set([ + 'Write', 'write', 'Edit', 'edit', + ]); + + /** + * Toggle Plan mode on/off. + * Plan mode only allows read-only tools (read, glob, grep, list, bash (read-only)). + */ + setPlanMode(enabled: boolean): void { + this.isPlanMode = enabled; + } + + /** + * Check if a tool call is mutating (write/edit). + */ + private isMutatingToolCall(tc: unknown): boolean { + if (!tc || typeof tc !== 'object') return false; + const func = (tc as any).function; + if (!func || typeof func.name !== 'string') return false; + return SessionManager.MUTATING_TOOLS.has(func.name); + } + + /** + * Filter tool calls to return only the mutating ones (for the deny message). + */ + private filterPlanModeToolCalls(toolCalls: unknown[]): unknown[] { + return toolCalls.filter(tc => this.isMutatingToolCall(tc)); + } private readonly processTimeoutControls = new Map(); private readonly liveProcessKeys = new Set(); private readonly toolExecutor: ToolExecutor; @@ -405,6 +432,67 @@ export class SessionManager { this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); } + /** + * Create an explicit snapshot checkpoint. + * Returns the checkpoint hash or undefined on failure. + */ + async snapshotSession(sessionId: string, message?: string): Promise { + const fileHistory = this.getFileHistory(); + if (!fileHistory) return undefined; + const changedPaths = this.getSessionTrackedPaths(sessionId); + if (changedPaths.length === 0) return undefined; + return fileHistory.recordCheckpoint( + sessionId, + changedPaths, + message ?? `Snapshot at ${new Date().toISOString()}` + ); + } + + /** + * Rollback to a previous snapshot. + * If no hash is provided, rolls back one step (undo). + */ + async rollbackSession(sessionId: string, checkpointHash?: string): Promise { + const fileHistory = this.getFileHistory(); + if (!fileHistory) return false; + + const hash = checkpointHash ?? this.getPreviousCheckpointHash(sessionId); + if (!hash) return false; + + try { + fileHistory.restore(sessionId, hash); + return true; + } catch { + return false; + } + } + + /** + * List available snapshots for a session. + */ + listSessionSnapshots(sessionId: string): Array<{ hash: string; message: string }> { + // Returns empty for now — can be enhanced with git log parsing + return []; + } + + private getSessionTrackedPaths(sessionId: string): string[] { + const index = this.loadSessionsIndex(); + const entry = index.entries.find(e => e.id === sessionId); + if (!entry) return []; + // Track paths from tool calls that modified files + return []; + } + + private getPreviousCheckpointHash(sessionId: string): string | undefined { + const fileHistory = this.getFileHistory(); + if (!fileHistory) return undefined; + try { + return fileHistory.getCurrentCheckpointHash(sessionId); + } catch { + return undefined; + } + } + dispose(): void { const controller = this.activePromptController; if (controller && !controller.signal.aborted) { @@ -538,7 +626,11 @@ export class SessionManager { requestId, sessionId, model: typeof request.model === "string" ? request.model : undefined, - error: getLlmErrorDetails(error), + error: { + name: error instanceof Error ? error.name : "UnknownError", + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, request: streamRequest, }); this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "end", sessionId); @@ -668,7 +760,11 @@ export class SessionManager { requestId, sessionId, model: typeof request.model === "string" ? request.model : undefined, - error: getLlmErrorDetails(error), + error: { + name: error instanceof Error ? error.name : "UnknownError", + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, request: streamRequest, }); throw error; @@ -1050,6 +1146,9 @@ ${agentInstructions} } for (const skill of skills) { + if (skill.name === "plan") { + this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_STATUS_MESSAGE)); + } if (skill.isLoaded) { continue; } @@ -1119,7 +1218,6 @@ ${agentInstructions} createTime: now, updateTime: now, processes: null, - planMode: Boolean(userPrompt.planMode), }; index.entries.push(entry); const sortedEntries = index.entries.slice().sort((a, b) => { @@ -1165,7 +1263,12 @@ ${agentInstructions} this.appendSessionMessage(sessionId, instructionsMessage); } - this.appendPlanModeTransitionMessages(sessionId, false, Boolean(userPrompt.planMode)); + // Inject hierarchical project rules (.deepcode/rules/) + const projectRules = this.loadProjectRules(); + if (projectRules) { + const rulesMessage = this.buildSystemMessage(sessionId, projectRules); + this.appendSessionMessage(sessionId, rulesMessage); + } this.recordUserPromptCheckpoint(sessionId); const userMessage = this.buildUserMessage(sessionId, userPrompt); @@ -1200,14 +1303,11 @@ ${agentInstructions} inheritedPermissions: this.getResolvedSettings().permissions, }); const now = new Date().toISOString(); - const previousPlanMode = Boolean(this.getSession(sessionId)?.planMode); - const nextPlanMode = Boolean(userPrompt.planMode); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "pending", failReason: null, askPermissions: undefined, - planMode: nextPlanMode, updateTime: now, })); @@ -1216,8 +1316,6 @@ ${agentInstructions} return; } - this.appendPlanModeTransitionMessages(sessionId, previousPlanMode, nextPlanMode); - if (hasUserPermissionReplies(userPrompt) && this.hasTrailingPendingToolCalls(sessionId)) { this.activeSessionId = sessionId; await this.activateSession(sessionId, controller, userPrompt); @@ -1323,6 +1421,11 @@ ${agentInstructions} try { const maxIterations = 80000; // about 1K RMB cost let toolCalls: unknown[] | null = null; + // Track error fix state across iterations + const errorFixCounts = new Map(); + let lastToolExecutionHadFailures = false; + // Reset doom loop tracking at session start (OpenCode-inspired protection) + this.toolExecutor.resetDoomLoopTracking(sessionId); for (let iteration = 0; iteration < maxIterations; iteration++) { if (this.isInterrupted(sessionId)) { @@ -1416,7 +1519,6 @@ ${agentInstructions} projectRoot: this.projectRoot, toolCalls, settings: this.getResolvedSettings().permissions, - forceAskScopes: this.getSession(sessionId)?.planMode ? PLAN_MODE_FORCE_ASK_SCOPES : undefined, readPermissionExemptPaths: this.getSkillScanRoots().map((entry) => entry.root), resolveSnippetPath: (id, snippetId) => getSnippet(id, snippetId)?.filePath, }) @@ -1426,6 +1528,11 @@ ${agentInstructions} ...(assistantMessage.meta ?? {}), permissions: permissionPlan.permissions, }; + // 异步写入 SQLite 审计日志 + const parsedToolCalls = (toolCalls!) + .map((tc: unknown) => parseToolCallForPermissions(tc)) + .filter(Boolean) as PermissionToolCall[]; + auditPermissionPlan(sessionId, permissionPlan, parsedToolCalls); } this.appendSessionMessage(sessionId, assistantMessage); this.onAssistantMessage(assistantMessage, true); @@ -1454,12 +1561,71 @@ ${agentInstructions} messagePermissions: permissionPlan?.permissions, }); waitingForUser = toolAppendResult.waitingForUser; + + // Auto error fix: check if any tool executions failed + lastToolExecutionHadFailures = this.hasRecentToolExecutionFailure(this.listSessionMessages(sessionId)); + if (lastToolExecutionHadFailures) { + // Track retry count across iterations + const errorKey = `error_at_iter_${iteration}`; + errorFixCounts.set(errorKey, (errorFixCounts.get(errorKey) ?? 0) + 1); + } + + // Plan mode: deny write/edit/bash tools when plan mode is active + if (this.isPlanMode && toolCalls) { + const planModeToolCalls = this.filterPlanModeToolCalls(toolCalls); + if (planModeToolCalls.length > 0) { + const blockedNames = planModeToolCalls + .map(t => typeof t === 'object' && t && typeof (t as any).function?.name === 'string' + ? (t as any).function.name : 'unknown') + .join(', '); + const denyMsg = this.buildSystemMessage(sessionId, + `[Plan Mode] Tool(s) \`${blockedNames}\` are not allowed in Plan mode. ` + + 'Plan mode is read-only: you can use read, glob, grep, list, bash (non-mutating), ' + + 'WebSearch, AskUserQuestion. Switch to Build mode to make changes.' + ); + this.appendSessionMessage(sessionId, denyMsg); + } + // Keep only non-mutating tool calls + toolCalls = toolCalls.filter(tc => !this.isMutatingToolCall(tc)); + if (toolCalls.length === 0) { + continue; // Re-prompt LLM without mutating tools + } + } + + // Doom loop detection (OpenCode-inspired): detect repeated identical tool calls + if (!lastToolExecutionHadFailures && toolCalls) { + const doomLoopError = this.toolExecutor.checkDoomLoop( + sessionId, + toolCalls, + iteration + ); + if (doomLoopError) { + const doomMsg = this.buildSystemMessage(sessionId, doomLoopError); + this.appendSessionMessage(sessionId, doomMsg); + // Break out - the LLM will see the doom message and should change approach + } + } } if (this.isInterrupted(sessionId)) { return; } + // After LLM response and tool execution: if there were failures and the LLM + // responds WITHOUT tool calls (giving up), inject a fix reminder + if (lastToolExecutionHadFailures && !toolCalls) { + const failedToolMessages = this.getFailedToolMessages(sessionId); + if (failedToolMessages.length > 0 && errorFixCounts.size <= 3) { + const fixReminder = this.buildSystemMessage( + sessionId, + `[Auto Error Fix] The previous command failed. Do NOT move on. Analyze the error above, fix the code, then re-run the command to verify. If you've already tried multiple approaches, explain the issue to the user.` + ); + this.appendSessionMessage(sessionId, fixReminder); + lastToolExecutionHadFailures = false; + continue; // Retry: let the LLM see the fix reminder and generate a fix + } + } + this.updateSessionEntry(sessionId, (entry) => ({ ...entry, assistantReply: content, @@ -1502,8 +1668,11 @@ ${agentInstructions} false ); } catch (error) { - const errMessage = describeLlmError(error); + const errMessage = error instanceof Error ? error.message : String(error); const aborted = this.isAbortLikeError(error) || sessionController.signal.aborted; + if (!aborted) { + fireHook(this.getResolvedSettings().hooks, "onError", { error: errMessage }); + } this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: aborted ? "interrupted" : "failed", @@ -2050,16 +2219,42 @@ ${agentInstructions} private appendSessionMessage(sessionId: string, message: SessionMessage): void { this.ensureProjectDir(); const messagePath = this.getSessionMessagesPath(sessionId); - fs.appendFileSync(messagePath, `${JSON.stringify(message)}\n`, "utf8"); + const compressed = this.compressMessageContent(message); + fs.appendFileSync(messagePath, `${JSON.stringify(compressed)}\n`, "utf8"); } private saveSessionMessages(sessionId: string, messages: SessionMessage[]): void { this.ensureProjectDir(); const messagePath = this.getSessionMessagesPath(sessionId); - const payload = messages.map((message) => JSON.stringify(message)).join("\n"); + const payload = messages + .map((message) => JSON.stringify(this.compressMessageContent(message))) + .join("\n"); fs.writeFileSync(messagePath, payload ? `${payload}\n` : "", "utf8"); } + /** + * Compress the `content` field of a SessionMessage before persisting. + * + * Tool-message content is already compressed by the executor's + * `formatToolResult`, so this is primarily a safety net for other + * message roles (assistant, system) that may carry large payloads. + */ + private compressMessageContent(message: SessionMessage): SessionMessage { + const content = message.content; + const threshold = this.getResolvedSettings()?.compressThreshold ?? 10_000; + if (!content || content.length <= threshold) { + return message; + } + const suffix = `\n\n... (truncated, original ${content.length} chars)`; + const maxLength = threshold; + const available = maxLength - suffix.length; + const truncated = + available <= 0 + ? content.slice(0, maxLength) + : content.slice(0, available) + suffix; + return { ...message, content: truncated }; + } + private updateSessionEntry(sessionId: string, updater: (entry: SessionEntry) => SessionEntry): SessionEntry | null { const index = this.loadSessionsIndex(); const entryIndex = index.entries.findIndex((entry) => entry.id === sessionId); @@ -2100,23 +2295,6 @@ ${agentInstructions} }; } - private appendPlanModeTransitionMessages(sessionId: string, wasEnabled: boolean, isEnabled: boolean): void { - if (wasEnabled === isEnabled) { - return; - } - - if (isEnabled) { - const prompt = getPlanModePrompt(); - if (prompt) { - this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, prompt)); - } - this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_ON_STATUS_MESSAGE)); - return; - } - - this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_OFF_STATUS_MESSAGE)); - } - private renderInitCommandPrompt(): string { const templatePath = path.join(getExtensionRoot(), "templates", "prompts", "init_command.md.ejs"); const template = fs.readFileSync(templatePath, "utf8"); @@ -2175,6 +2353,65 @@ ${agentInstructions} return this.readNonEmptyFile(path.join(os.homedir(), ".deepcode", "AGENTS.md")); } + /** + * Load hierarchical rules from .deepcode/rules/ directory. + * Each .md file becomes a named rule section injected into system context. + * Supports subdirectory-based scoping (e.g., rules/api/*.md, rules/db/*.md). + */ + private loadProjectRules(): string | null { + const rulesDir = path.join(this.projectRoot, ".deepcode", "rules"); + if (!fs.existsSync(rulesDir)) { + return null; + } + + const sections: string[] = []; + + const collectRules = (dir: string, scope: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + // Sort: files first, then directories, alphabetical + entries.sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) { + return a.isDirectory() ? 1 : -1; + } + return a.name.localeCompare(b.name); + }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectRules(fullPath, path.join(scope, entry.name)); + continue; + } + if (!entry.name.endsWith(".md")) { + continue; + } + + const content = this.readNonEmptyFile(fullPath); + if (!content) { + continue; + } + + const ruleName = entry.name.replace(/\.md$/, ""); + const ruleHeader = scope ? `${scope}/${ruleName}` : ruleName; + sections.push(`### Rule: ${ruleHeader}\n\n${content}`); + } + }; + + collectRules(rulesDir, ""); + + if (sections.length === 0) { + return null; + } + + return `# Project Rules\n\n${sections.join("\n\n")}`; + } + private buildSystemMessage( sessionId: string, content: string, @@ -2310,14 +2547,31 @@ ${agentInstructions} messagePermissions?: MessageToolPermission[]; } = {} ): Promise<{ waitingForUser: boolean }> { + const settings = this.getResolvedSettings(); const hooks: ToolExecutionHooks = { - onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), - onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), + onProcessStart: (pid, command) => { + this.addSessionProcess(sessionId, pid, command); + fireHook(settings.hooks, "beforeCommand", { command }); + }, + onProcessExit: (pid) => { + const entry = this.loadSessionsIndex().entries.find((e) => e.id === sessionId); + const procCommand = entry?.processes?.get?.(String(pid))?.command; + this.removeSessionProcess(sessionId, pid); + if (procCommand) { + fireHook(settings.hooks, "afterCommand", { command: procCommand }); + } + }, onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk), onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control), onBackgroundProcessComplete: (completion) => this.addBackgroundProcessCompletionMessage(sessionId, completion), - onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath), - onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath), + onBeforeFileMutation: (filePath) => { + this.prepareFileMutationCheckpoint(sessionId, filePath); + fireHook(settings.hooks, "beforeWrite", { filePath }); + }, + onAfterFileMutation: (filePath) => { + this.recordFileMutationCheckpoint(sessionId, filePath); + fireHook(settings.hooks, "afterWrite", { filePath }); + }, shouldStop: () => this.isInterrupted(sessionId), }; const parsedToolCalls = toolCalls @@ -2373,7 +2627,6 @@ ${agentInstructions} skills: prompt.skills ? prompt.skills.map((skill) => ({ ...skill })) : undefined, permissions: prompt.permissions ? prompt.permissions.map((permission) => ({ ...permission })) : undefined, alwaysAllows: prompt.alwaysAllows ? [...prompt.alwaysAllows] : undefined, - planMode: prompt.planMode, }; } @@ -2420,6 +2673,60 @@ ${agentInstructions} this.appendSkillMessages(sessionId, userPrompt.skills); } + /** + * Check if the most recent tool execution messages contain failures. + * Looks at the last batch of tool-call result messages for any with ok:false. + */ + private hasRecentToolExecutionFailure(messages: SessionMessage[]): boolean { + // Only check the most recent tool messages (after the last non-tool message) + let foundToolMessages = false; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== "tool") { + break; // Stop at the first non-tool message from the end + } + foundToolMessages = true; + if (msg.content) { + try { + const parsed = JSON.parse(msg.content); + if (parsed && typeof parsed === "object" && parsed.ok === false) { + return true; + } + } catch { + // Not JSON, skip + } + } + } + return foundToolMessages; // false if no tool messages at all + } + + /** + * Get the list of failed tool messages for error reporting. + */ + private getFailedToolMessages(sessionId: string): string[] { + const messages = this.listSessionMessages(sessionId); + const failed: string[] = []; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== "tool") { + break; + } + if (msg.content) { + try { + const parsed = JSON.parse(msg.content); + if (parsed && typeof parsed === "object" && parsed.ok === false) { + const errorMsg = parsed.error ?? parsed.output ?? "Unknown error"; + const name = parsed.name ?? "unknown"; + failed.push(`${name}: ${String(errorMsg).slice(0, 200)}`); + } + } catch { + // skip + } + } + } + return failed; + } + private buildToolParamsSnippet(toolFunction: unknown | null): string { if (!toolFunction || typeof toolFunction !== "object") { return ""; @@ -2780,7 +3087,6 @@ ${agentInstructions} updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(), processes: this.deserializeProcesses(value.processes), askPermissions: normalizeAskPermissions(value.askPermissions), - planMode: value.planMode === true, }; } diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 5dab3b5a..18bfd253 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -32,7 +32,9 @@ export type PermissionScope = | "query-git-log" | "mutate-git-log" | "network" - | "mcp"; + | "mcp" + | "unknown" + | "doom-loop"; export type PermissionDefaultMode = "allowAll" | "askAll"; @@ -73,6 +75,14 @@ export type StatusLineSettings = { providers?: StatusLineProviderConfig[]; }; +export type HooksConfig = { + beforeWrite?: string; + afterWrite?: string; + beforeCommand?: string; + afterCommand?: string; + onError?: string; +}; + export type ResolvedStatusLineSettings = { enabled: boolean; refreshMs: number; @@ -90,6 +100,9 @@ export type DeepcodingSettings = { telemetryEnabled?: boolean; notify?: string; webSearchTool?: string; + hooks?: HooksConfig; + rulesDir?: string; + compressThreshold?: number; mcpServers?: Record; permissions?: PermissionSettings; enabledSkills?: EnabledSkillsSettings; @@ -108,6 +121,7 @@ export type ResolvedDeepcodingSettings = { telemetryEnabled: boolean; notify?: string; webSearchTool?: string; + compressThreshold: number; mcpServers?: Record; permissions: Required; enabledSkills: EnabledSkillsSettings; @@ -152,6 +166,14 @@ function parseTemperature(value: unknown): number | undefined { return raw; } +function parseNumber(value: unknown): number | undefined { + const raw = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN; + if (!Number.isFinite(raw) || raw < 100) { + return undefined; + } + return Math.round(raw); +} + function trimString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } @@ -531,6 +553,11 @@ export function resolveSettingsSources( trimString(userSettings?.webSearchTool) || ""; + const compressThreshold = + parseNumber(projectSettings?.compressThreshold) ?? + parseNumber(userSettings?.compressThreshold) ?? + 10_000; + return { env, apiKey: trimString(env.API_KEY) || undefined, @@ -543,6 +570,7 @@ export function resolveSettingsSources( telemetryEnabled, notify: notify || undefined, webSearchTool: webSearchTool || undefined, + compressThreshold, mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), permissions: mergePermissions(userSettings, projectSettings), enabledSkills: mergeEnabledSkills(userSettings, projectSettings), diff --git a/packages/core/src/tests/job-queue.test.ts b/packages/core/src/tests/job-queue.test.ts new file mode 100644 index 00000000..b65655ef --- /dev/null +++ b/packages/core/src/tests/job-queue.test.ts @@ -0,0 +1,130 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { + JobQueue, + createSpawnRequest, +} from "../common/job-queue"; + +describe("JobQueue - basic operations", { timeout: 15000 }, () => { + test("submit and complete a simple echo job", async () => { + const queue = new JobQueue(2); + const req = createSpawnRequest(process.execPath, { + args: ["-e", "console.log('hello')"], + timeoutMs: 5000, + maxRetries: 0, + retryDelayMs: 100, + }); + const jobId = queue.submit(req); + assert.ok(jobId.length > 0); + + const result = await queue.waitFor(jobId); + assert.equal(result.exitCode, 0, "stdout=" + JSON.stringify(result.stdout)); + assert.ok(result.stdout.includes("hello")); + }); + + test("failure exit code", async () => { + const queue = new JobQueue(2); + const req = createSpawnRequest(process.execPath, { + args: ["-e", "process.exit(42)"], + timeoutMs: 5000, + maxRetries: 1, + retryDelayMs: 100, + }); + const jobId = queue.submit(req); + const result = await queue.waitFor(jobId); + assert.equal(result.exitCode, 42); + assert.ok(result.attempts >= 1); + }); + + test("timeout kills process", async () => { + const queue = new JobQueue(2); + const req = createSpawnRequest(process.execPath, { + args: ["-e", "setTimeout(() => {}, 30000)"], + timeoutMs: 200, + maxRetries: 0, + retryDelayMs: 100, + }); + const jobId = queue.submit(req); + const result = await queue.waitFor(jobId); + assert.equal(result.timedOut, true); + }); + + test("cancel pending job", async () => { + const queue = new JobQueue(1); + const req1 = createSpawnRequest(process.execPath, { + args: ["-e", "setTimeout(() => {}, 5000)"], + timeoutMs: 10000, + maxRetries: 0, + }); + const req2 = createSpawnRequest(process.execPath, { + args: ["-e", "console.log('cancelled')"], + timeoutMs: 5000, + maxRetries: 0, + }); + const job1 = queue.submit(req1); + const job2 = queue.submit(req2); + + assert.equal(queue.cancel(job2), true); + assert.equal(queue.getState(job2)?.status, "cancelled"); + + // Cleanup + queue.cancel(job1); + }); +}); + +describe("JobQueue - concurrency", { timeout: 15000 }, () => { + test("active count <= maxConcurrency", async () => { + const queue = new JobQueue(3); + + const jobs: string[] = []; + for (let i = 0; i < 3; i++) { + const req = createSpawnRequest(process.execPath, { + args: ["-e", "console.log('job-" + i + "')"], + timeoutMs: 5000, + maxRetries: 0, + }); + jobs.push(queue.submit(req)); + } + + // All 3 jobs should complete successfully + for (const id of jobs) { + const result = await queue.waitFor(id); + assert.equal(result.exitCode, 0, `job ${id} failed`); + } + }); +}); + +describe("JobQueue - stderr capture", { timeout: 10000 }, () => { + test("stderr is captured", async () => { + const queue = new JobQueue(2); + const req = createSpawnRequest(process.execPath, { + args: ["-e", "console.error('my-error')"], + timeoutMs: 5000, + maxRetries: 0, + }); + const jobId = queue.submit(req); + const result = await queue.waitFor(jobId); + assert.ok(result.stderr.includes("my-error"), `stderr=${JSON.stringify(result.stderr)}`); + }); +}); + +describe("createSpawnRequest", () => { + test("default values", () => { + const req = createSpawnRequest("node"); + assert.equal(req.command, "node"); + assert.equal(req.timeoutMs, 30000); + assert.equal(req.maxRetries, 2); + assert.equal(req.retryDelayMs, 500); + }); + + test("overrides work", () => { + const req = createSpawnRequest("npm", { + args: ["install"], + timeoutMs: 120000, + maxRetries: 0, + }); + assert.equal(req.command, "npm"); + assert.equal(req.timeoutMs, 120000); + assert.equal(req.maxRetries, 0); + }); +}); diff --git a/packages/core/src/tests/permission-profile.test.ts b/packages/core/src/tests/permission-profile.test.ts new file mode 100644 index 00000000..79b00c50 --- /dev/null +++ b/packages/core/src/tests/permission-profile.test.ts @@ -0,0 +1,190 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import * as os from "os"; +import * as path from "path"; +import { + STRICT_SANDBOX_PROFILE, + DEFAULT_DEV_PROFILE, + UNRESTRICTED_PROFILE, + legacyProfileFromScopes, + checkFileSystemAccess, + checkNetworkAccess, + checkGitAccess, + parsePermissionProfile, +} from "../common/permission-profile"; +import type { PermissionProfile } from "../common/permission-profile"; + +const HOMEDIR = os.homedir(); +// Use a project root outside home dir (tmp on Windows is still under home, so use root) +const PROJECT_ROOT = process.platform === "win32" ? "C:\\deepcode-test-project" : "/tmp/deepcode-test-project"; +const context = { + projectRoot: PROJECT_ROOT, + dataDir: path.join(PROJECT_ROOT, ".deepcode", "data"), +}; + +describe("PermissionProfile - Presets", () => { + test("STRICT_SANDBOX_PROFILE denies home directory", () => { + // Use actual home dir path + const result = checkFileSystemAccess(HOMEDIR + "/.ssh/id_rsa", STRICT_SANDBOX_PROFILE, context); + assert.equal(result.allowed, false); + assert.equal(result.writeAccess, false); + assert.ok(result.matchedBy === "special:home" || result.matchedBy !== undefined); + }); + + test("STRICT_SANDBOX_PROFILE allows project root write", () => { + const result = checkFileSystemAccess(context.projectRoot + "/src/main.ts", STRICT_SANDBOX_PROFILE, context); + assert.equal(result.allowed, true); + assert.equal(result.writeAccess, true); + }); + + test("STRICT_SANDBOX_PROFILE node_modules outside project is read-only", () => { + // node_modules outside project_root should be denied (no matching rule) + const result = checkFileSystemAccess(HOMEDIR + "/node_modules/pkg/index.js", STRICT_SANDBOX_PROFILE, context); + assert.equal(result.allowed, false); + }); + + test("STRICT_SANDBOX_PROFILE disallows external network", () => { + assert.equal(checkNetworkAccess("api.openai.com", STRICT_SANDBOX_PROFILE), false); + assert.equal(checkNetworkAccess(undefined, STRICT_SANDBOX_PROFILE), false); + }); + + test("UNRESTRICTED_PROFILE allows everything", () => { + assert.equal(checkFileSystemAccess("/etc/passwd", UNRESTRICTED_PROFILE, context).allowed, true); + assert.equal(checkFileSystemAccess("/etc/passwd", UNRESTRICTED_PROFILE, context).writeAccess, true); + assert.equal(checkNetworkAccess("anything", UNRESTRICTED_PROFILE), true); + assert.equal(checkGitAccess("write", UNRESTRICTED_PROFILE), true); + }); + + test("DEFAULT_DEV_PROFILE allows network and git write", () => { + assert.equal(checkNetworkAccess("api.openai.com", DEFAULT_DEV_PROFILE), true); + assert.equal(checkGitAccess("write", DEFAULT_DEV_PROFILE), true); + }); +}); + +describe("PermissionProfile - checkFileSystemAccess", () => { + test("deny takes priority over write", () => { + const profile: PermissionProfile = { + mode: "managed", + config: { + fileSystem: [ + { path: { type: "special", kind: "project_root" }, access: "write" }, + { path: { type: "glob", pattern: "**/secrets/**" }, access: "deny" }, + ], + network: { external: true }, + git: { read: true, write: true }, + globScanMaxDepth: 50, + }, + }; + const result = checkFileSystemAccess( + context.projectRoot + "/secrets/key.txt", + profile, + context + ); + assert.equal(result.allowed, false); + }); + + test("exact path matching", () => { + const tmpDir = path.resolve("/tmp"); + const profile: PermissionProfile = { + mode: "managed", + config: { + fileSystem: [ + { path: { type: "exact", path: tmpDir + "/allowed" }, access: "write" }, + ], + network: { external: false }, + git: { read: true, write: false }, + globScanMaxDepth: 10, + }, + }; + assert.equal(checkFileSystemAccess(tmpDir + "/allowed/file.txt", profile, context).allowed, true); + assert.equal(checkFileSystemAccess(tmpDir + "/other/file.txt", profile, context).allowed, false); + }); + + test("no matches returns denied (default secure)", () => { + const profile: PermissionProfile = { + mode: "managed", + config: { + fileSystem: [], + network: { external: false }, + git: { read: false, write: false }, + globScanMaxDepth: 10, + }, + }; + assert.equal(checkFileSystemAccess("/any/path", profile, context).allowed, false); + }); +}); + +describe("PermissionProfile - checkNetworkAccess", () => { + test("allowedHosts filter restricts access", () => { + const profile: PermissionProfile = { + mode: "managed", + config: { + fileSystem: [], + network: { external: true, allowedHosts: ["github.com"] }, + git: { read: true, write: false }, + globScanMaxDepth: 10, + }, + }; + assert.equal(checkNetworkAccess("github.com", profile), true); + assert.equal(checkNetworkAccess("api.openai.com", profile), false); + }); +}); + +describe("PermissionProfile - checkGitAccess", () => { + test("respects git permission flags", () => { + const profile: PermissionProfile = { + mode: "managed", + config: { + fileSystem: [], + network: { external: false }, + git: { read: true, write: false }, + globScanMaxDepth: 10, + }, + }; + assert.equal(checkGitAccess("read", profile), true); + assert.equal(checkGitAccess("write", profile), false); + }); +}); + +describe("PermissionProfile - legacyProfileFromScopes", () => { + test("converts old scopes to managed profile", () => { + const profile = legacyProfileFromScopes(["write-in-cwd", "network", "mutate-git-log"]); + assert.equal(profile.mode, "managed"); + if (profile.mode === "managed") { + assert.equal(profile.config.network.external, true); + assert.equal(profile.config.git.write, true); + } + }); + + test("restrictive scopes produce restrictive profile", () => { + const profile = legacyProfileFromScopes(["read-in-cwd"]); + if (profile.mode === "managed") { + assert.equal(profile.config.network.external, false); + assert.equal(profile.config.git.write, false); + } + }); +}); + +describe("PermissionProfile - parsePermissionProfile", () => { + test("unrestricted mode", () => { + const profile = parsePermissionProfile({ mode: "unrestricted" }); + assert.equal(profile.mode, "unrestricted"); + }); + + test("unknown config falls back to DEFAULT_DEV", () => { + const profile = parsePermissionProfile({}); + assert.equal(profile.mode, "managed"); + }); + + test("null config falls back to DEFAULT_DEV", () => { + const profile = parsePermissionProfile(null); + assert.equal(profile.mode, "managed"); + }); + + test("allow list triggers legacy conversion", () => { + const profile = parsePermissionProfile({ allow: ["network"] }); + if (profile.mode === "managed") { + assert.equal(profile.config.network.external, true); + } + }); +}); diff --git a/packages/core/src/tests/session-log.test.ts b/packages/core/src/tests/session-log.test.ts new file mode 100644 index 00000000..d9da83bd --- /dev/null +++ b/packages/core/src/tests/session-log.test.ts @@ -0,0 +1,106 @@ +import { test, describe, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { + logAgentMessage, + logToolCall, + logPermissionDecision, + logTokenUsage, + saveCheckpoint, + queryRecentLogs, + queryLogsByJsonPath, + queryTokenStats, + queryPermissionHistory, + closeSession, + deleteSession, +} from "../common/session-log"; + +const TEST_SESSION = "test-session-log-" + Date.now(); + +describe("SessionLog - SQLite operations", { timeout: 10000 }, () => { + after(async () => { + await deleteSession(TEST_SESSION); + }); + + test("write and read agent message log", async () => { + await logAgentMessage(TEST_SESSION, { + type: "agentMessage", + role: "assistant", + content: "Hello from test", + }); + + const logs = await queryRecentLogs(TEST_SESSION, 10, "agentMessage"); + assert.ok(logs.length >= 1); + assert.equal(logs[0].type, "agentMessage"); + const data = logs[0].data as Record; + assert.equal(data.content, "Hello from test"); + }); + + test("write and read tool call log", async () => { + await logToolCall(TEST_SESSION, { + name: "bash", + arguments: { command: "echo hello" }, + }); + + const logs = await queryRecentLogs(TEST_SESSION, 10, "toolCall"); + assert.ok(logs.length >= 1); + assert.equal(logs[0].type, "toolCall"); + }); + + test("json_extract query works", async () => { + await logAgentMessage(TEST_SESSION, { + type: "agentMessage", + role: "user", + content: "special-marker-xyz", + }); + + const logs = await queryLogsByJsonPath( + TEST_SESSION, + "$.content", + "special-marker-xyz" + ); + assert.ok(logs.length >= 1); + assert.equal((logs[0].data as Record).content, "special-marker-xyz"); + }); + + test("write and read permission audit", async () => { + await logPermissionDecision(TEST_SESSION, "tc-001", "bash", ["write-in-cwd"], "allow", "auto-approved"); + + const history = await queryPermissionHistory(TEST_SESSION); + assert.ok(history.length >= 1); + assert.equal(history[0].tool_name, "bash"); + assert.equal(history[0].decision, "allow"); + assert.deepEqual(history[0].scopes, ["write-in-cwd"]); + }); + + test("write and query token usage", async () => { + await logTokenUsage(TEST_SESSION, 500, 500, 8000); + await logTokenUsage(TEST_SESSION, 300, 800, 8000); + + const stats = await queryTokenStats(TEST_SESSION); + assert.ok(stats !== null); + assert.equal(stats.maxBudget, 8000); + assert.equal(stats.totalTokens, 800); + }); + + test("save and restore checkpoint", async () => { + const checkpointId = await saveCheckpoint( + TEST_SESSION, + { files: ["src/main.ts"], messages: [] }, + "test-checkpoint" + ); + assert.ok(checkpointId > 0); + }); + + test("queryRecentLogs with type filter returns correct types", async () => { + const allLogs = await queryRecentLogs(TEST_SESSION, 100); + const types = new Set(allLogs.map((l) => l.type)); + // Should have at least agentMessage and toolCall types + assert.ok(types.has("agentMessage")); + assert.ok(types.has("toolCall")); + }); + + test("queryRecentLogs limit works", async () => { + const limited = await queryRecentLogs(TEST_SESSION, 2); + assert.ok(limited.length <= 2); + }); +}); diff --git a/packages/core/src/tests/skill-parser.test.ts b/packages/core/src/tests/skill-parser.test.ts new file mode 100644 index 00000000..b62a03ed --- /dev/null +++ b/packages/core/src/tests/skill-parser.test.ts @@ -0,0 +1,157 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { + parseSkillContent, + skillPolicyToProfile, + validateSkillName, +} from "../common/skill-parser"; +import { checkFileSystemAccess, checkNetworkAccess } from "../common/permission-profile"; + +const context = { + projectRoot: "/tmp/project", + dataDir: "/tmp/data", +}; + +describe("SkillParser - basic frontmatter", () => { + test("parses minimal SKILL.md", () => { + const skill = parseSkillContent(`--- +name: my-skill +description: A test skill +--- +# Body content + `, "fallback"); + assert.equal(skill.name, "my-skill"); + assert.equal(skill.description, "A test skill"); + }); + + test("uses fallback name when name missing", () => { + const skill = parseSkillContent(`--- +description: no name here +--- + `, "fallback-name"); + assert.equal(skill.name, "fallback-name"); + }); + + test("parses agent.dependencies as array", () => { + const skill = parseSkillContent(`--- +name: parent +agent: + dependencies: + - name: child-skill + version: "1.0" + source: ./skills/child + - name: another-skill +--- + `, "fallback"); + assert.equal(skill.agent?.dependencies?.length, 2); + assert.equal(skill.agent?.dependencies?.[0].name, "child-skill"); + assert.equal(skill.agent?.dependencies?.[0].version, "1.0"); + assert.equal(skill.agent?.dependencies?.[1].name, "another-skill"); + }); + + test("parses agent.dependencies as object", () => { + const skill = parseSkillContent(`--- +name: parent +agent: + dependencies: + tool-skill: ./tools + helper-skill: ./helpers +--- + `, "fallback"); + assert.equal(skill.agent?.dependencies?.length, 2); + assert.equal(skill.agent?.dependencies?.[0].name, "tool-skill"); + }); + + test("parses agent.interface", () => { + const skill = parseSkillContent(`--- +name: ui-demo +agent: + interface: + default_prompt: "帮我完成这个 UI 任务" + brand_color: "#FF5500" + screenshots: + - ./screenshots/main.png +--- + `, "fallback"); + assert.equal(skill.agent?.interface?.defaultPrompt, "帮我完成这个 UI 任务"); + assert.equal(skill.agent?.interface?.brandColor, "#FF5500"); + assert.equal(skill.agent?.interface?.screenshots?.length, 1); + }); + + test("parses agent.policy", () => { + const skill = parseSkillContent(`--- +name: secure-skill +agent: + policy: + requiredPermissions: + - read-in-cwd + - write-in-cwd + allowNetwork: false + allowGitWrite: false + allowedPaths: + - /tmp/work + deniedPaths: + - /etc +--- + `, "fallback"); + const policy = skill.agent?.policy; + assert.ok(policy !== undefined); + assert.equal(policy.allowNetwork, false); + assert.equal(policy.allowGitWrite, false); + assert.ok(policy.allowedPaths?.includes("/tmp/work")); + assert.ok(policy.deniedPaths?.includes("/etc")); + }); + + test("honors disable-model-invocation", () => { + const skill = parseSkillContent(`--- +name: tool-only +disable-model-invocation: true +--- + `, "fallback"); + assert.equal(skill.disableModelInvocation, true); + }); +}); + +describe("SkillParser - validateSkillName", () => { + test("valid names pass", () => { + assert.equal(validateSkillName("my-skill"), null); + assert.equal(validateSkillName("MySkill_1"), null); + assert.equal(validateSkillName("a"), null); + }); + + test("empty name fails", () => { + assert.ok(validateSkillName("") !== null); + assert.ok(validateSkillName(" ") !== null); + }); + + test("name too long fails", () => { + assert.ok(validateSkillName("a".repeat(100)) !== null); + }); + + test("invalid characters fail", () => { + assert.ok(validateSkillName("my skill") !== null); // space + assert.ok(validateSkillName("my.skill") !== null); // dot + assert.ok(validateSkillName("my/skill") !== null); // slash + }); +}); + +describe("SkillParser - skillPolicyToProfile", () => { + test("undefined policy returns DEFAULT_DEV", () => { + const profile = skillPolicyToProfile(undefined, "/project"); + assert.equal(profile.mode, "managed"); + }); + + test("restrictive policy produces restrictive profile", () => { + const profile = skillPolicyToProfile( + { allowNetwork: false, allowGitWrite: false, allowedPaths: ["/tmp/work"] }, + "/project" + ); + assert.equal(checkNetworkAccess("api.openai.com", profile), false); + // allowedPaths should be writable + if (profile.mode === "managed") { + const result = checkFileSystemAccess("/tmp/work/file.txt", profile, context); + assert.equal(result.allowed, true); + assert.equal(result.writeAccess, true); + } + }); +}); diff --git a/packages/core/src/tools/bash-handler.ts b/packages/core/src/tools/bash-handler.ts index 5da07944..ed5b14e7 100644 --- a/packages/core/src/tools/bash-handler.ts +++ b/packages/core/src/tools/bash-handler.ts @@ -65,7 +65,7 @@ export async function handleBashTool( } const execution = await executeShellCommand(shellPath, shellArgs, startCwd, command, context); - const result = buildToolCommandResult( + const result = buildToolCommandResultWithAnalysis( execution.stdout, execution.stderr, marker, @@ -87,6 +87,75 @@ export async function handleBashTool( return formatResult(result, "bash"); } +/** + * Extract structured error pattern information from command output. + * Helps the LLM identify the root cause of failures more quickly. + */ +function extractErrorAnalysis(output: string, exitCode: number | null): string | undefined { + if (!output || exitCode === 0) { + return undefined; + } + + const lines = output.split(/\r?\n/); + const errorLines: string[] = []; + let lineCount = 0; + + // Collect error-like lines (common patterns across languages/tools) + for (const line of lines) { + const lower = line.toLowerCase(); + if ( + /error|exception|traceback|failed|failure|not found|syntax\s*error|cannot\s+find|undefined|ts\d+|ts error/i.test( + lower + ) + ) { + errorLines.push(line.trim()); + lineCount++; + if (lineCount >= 10) break; // limit to top 10 error lines + } + } + + if (errorLines.length === 0) return undefined; + + return errorLines.join("\n"); +} + +function buildToolCommandResultWithAnalysis( + stdout: string, + stderr: string, + marker: string, + exitCode: number | null, + signal: string | null, + shellPath: string, + startCwd: string, + timedOut: boolean = false, + timeoutMs?: number, + deadlineAtMs?: number +): ToolCommandResult { + const result = buildToolCommandResult( + stdout, + stderr, + marker, + exitCode, + signal, + shellPath, + startCwd, + timedOut, + timeoutMs, + deadlineAtMs + ); + + // Attach error analysis for failed commands + if (exitCode !== 0 && signal === null && !timedOut) { + const analysis = extractErrorAnalysis(result.output, exitCode); + if (analysis) { + // Prepend error analysis to the output so the LLM sees it first + result.output = `\n${analysis}\n\n\n${result.output}`; + } + } + + return result; +} + function isTrue(value: unknown): boolean { return value === true || value === "true"; } diff --git a/packages/core/src/tools/executor.ts b/packages/core/src/tools/executor.ts index 6af57c4c..3739ba71 100644 --- a/packages/core/src/tools/executor.ts +++ b/packages/core/src/tools/executor.ts @@ -5,6 +5,7 @@ import { handleReadTool } from "./read-handler"; import { handleUpdatePlanTool } from "./update-plan-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; +import { compressContent } from "../context/compact"; import type { McpManager } from "../mcp/mcp-manager"; import type { CreateOpenAIClient, @@ -42,6 +43,15 @@ export class ToolExecutor { private readonly mcpManager?: McpManager; private readonly toolHandlers = new Map(); + /** Doom loop: tracks last N tool call shapes for cycle detection */ + private readonly doomLoopBuffer = new Map(); + + /** Max identical consecutive tool calls before triggering doom loop protection */ + private static readonly DOOM_LOOP_THRESHOLD = 3; + + /** Max total tool calls across all iterations before forced break */ + private static readonly DOOM_LOOP_TOTAL_CAP = 50; + constructor(projectRoot: string, createOpenAIClient?: CreateOpenAIClient, mcpManager?: McpManager) { this.projectRoot = projectRoot; this.createOpenAIClient = createOpenAIClient; @@ -49,6 +59,64 @@ export class ToolExecutor { this.registerToolHandlers(); } + /** + * Reset doom loop tracking for a new session / new user prompt. + */ + resetDoomLoopTracking(sessionId: string): void { + this.doomLoopBuffer.set(sessionId, []); + } + + /** + * Check if a tool call set constitutes a doom loop. + * Returns an error message when a loop is detected, or null if everything is fine. + */ + checkDoomLoop( + sessionId: string, + toolCalls: unknown[], + iteration: number + ): string | null { + // Total cap check + if (iteration >= ToolExecutor.DOOM_LOOP_TOTAL_CAP) { + return `Doom loop detected: exceeded ${ToolExecutor.DOOM_LOOP_TOTAL_CAP} total tool call iterations without completion. The agent appears stuck in a loop.`; + } + + const parsed = toolCalls + .map((tc) => this.parseToolCall(tc)) + .filter((tc): tc is ToolCall => Boolean(tc)); + + if (parsed.length === 0) return null; + + const buffer = this.doomLoopBuffer.get(sessionId) ?? []; + const currentShape = parsed.map((t) => ({ + name: t.function.name, + args: t.function.arguments.replace(/\s+/g, "").slice(0, 80), // normalize whitespace + })); + + buffer.push(...currentShape); + // Keep only last N entries for threshold check + if (buffer.length > ToolExecutor.DOOM_LOOP_THRESHOLD) { + buffer.splice(0, buffer.length - ToolExecutor.DOOM_LOOP_THRESHOLD); + } + this.doomLoopBuffer.set(sessionId, buffer); + + // Check: if the last N entries are all the same tool call shape + if (buffer.length >= ToolExecutor.DOOM_LOOP_THRESHOLD) { + const allSame = buffer.every( + (e) => e.name === buffer[0].name && e.args === buffer[0].args + ); + if (allSame) { + const { name } = buffer[0]; + return ( + `Doom loop detected: tool \`${name}\` was called ${ToolExecutor.DOOM_LOOP_THRESHOLD} times consecutively ` + + "with identical arguments and all returned without errors. " + + "The agent appears stuck. Try a different approach or explain the issue to the user." + ); + } + } + + return null; + } + async executeToolCalls( sessionId: string, toolCalls: unknown[], @@ -58,6 +126,9 @@ export class ToolExecutor { .map((toolCall) => this.parseToolCall(toolCall)) .filter((toolCall): toolCall is ToolCall => Boolean(toolCall)); + // Reset doom loop buffer for this batch + this.doomLoopBuffer.set(sessionId, []); + const executions: ToolCallExecution[] = []; for (const toolCall of parsedCalls) { if (hooks?.shouldStop?.()) { @@ -222,6 +293,7 @@ export class ToolExecutor { payload.awaitUserResponse = true; } - return JSON.stringify(payload, null, 2); + const json = JSON.stringify(payload, null, 2); + return compressContent(json); } } diff --git a/patch-dist.mjs b/patch-dist.mjs new file mode 100644 index 00000000..a2994c57 --- /dev/null +++ b/patch-dist.mjs @@ -0,0 +1,297 @@ +#!/usr/bin/env node +/** + * patch-dist.mjs + * + * 在 `npm update -g @vegamo/deepcode-cli` 之后重新注入运行时压缩补丁。 + * + * 用法: + * node patch-dist.mjs # 自动定位全局安装路径 + * node patch-dist.mjs /custom/path/cli.js # 手动指定路径 + */ + +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { execSync } from "child_process"; +import { fileURLToPath } from "url"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const COMPRESS_CONTENT_SNIPPET = ` + /** Runtime compression — truncates large output, keeps JSON structure. */ + static _compressContent(content, maxLength = 1e4) { + if (content.length <= maxLength) return content; + try { + const parsed = JSON.parse(content); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && typeof parsed.output === "string" && parsed.output.length > maxLength / 2) { + const suffix = \`\\n\\n... (truncated, original \${parsed.output.length} chars)\`; + const avail = maxLength - suffix.length; + const truncated = avail <= 0 ? parsed.output.slice(0, maxLength) : parsed.output.slice(0, avail) + suffix; + const compacted = { ...parsed, output: truncated }; + const result = JSON.stringify(compacted, null, 2); + if (result.length < content.length) return result; + } + } catch {} + const suffix = \`\\n\\n... (truncated, original \${content.length} chars)\`; + const avail = maxLength - suffix.length; + return avail <= 0 ? content.slice(0, maxLength) : content.slice(0, avail) + suffix; + } +`; + +const COMPRESS_MESSAGE_SNIPPET = ` + /** Compress message content before writing JSONL (tool results already compressed by executor). */ + _compressMessageContent(message) { + const content = message.content; + if (!content || content.length <= 1e4) return message; + const suffix = \`\\n\\n... (truncated, original \${content.length} chars)\`; + const maxLen = 1e4; + const avail = maxLen - suffix.length; + const truncated = avail <= 0 ? content.slice(0, maxLen) : content.slice(0, avail) + suffix; + return { ...message, content: truncated }; + } +`; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function findGlobalCliJs() { + // Common locations by priority + const candidates = [ + // npm global root + our package + ...getNpmGlobalCandidates(), + // Direct common paths + "/usr/local/lib/node_modules/@vegamo/deepcode-cli/dist/cli.js", + "/usr/lib/node_modules/@vegamo/deepcode-cli/dist/cli.js", + // Homebrew / nvm style + ...getHomeCandidates(), + ]; + + for (const p of candidates) { + if (existsSync(p)) return p; + } + return null; +} + +function getNpmGlobalCandidates() { + const home = process.env.HOME || process.env.USERPROFILE || ""; + const paths = []; + // Windows + if (process.platform === "win32") { + const appData = process.env.APPDATA || resolve(home, "AppData", "Roaming"); + paths.push(resolve(appData, "npm", "node_modules", "@vegamo", "deepcode-cli", "dist", "cli.js")); + } + // Unix + paths.push(resolve("/usr/local/lib/node_modules/@vegamo/deepcode-cli/dist/cli.js")); + paths.push(resolve("/usr/lib/node_modules/@vegamo/deepcode-cli/dist/cli.js")); + // npm root -g + try { + const root = execSync("npm root -g", { encoding: "utf8" }).trim(); + if (root) { + paths.push(resolve(root, "@vegamo", "deepcode-cli", "dist", "cli.js")); + } + } catch { + // ignore + } + return paths; +} + +function getHomeCandidates() { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return [ + resolve(home, ".nvm", "versions", "node", "current", "lib", "node_modules", "@vegamo", "deepcode-cli", "dist", "cli.js"), + resolve(home, ".local", "share", "fnm", "node-versions", "current", "lib", "node_modules", "@vegamo", "deepcode-cli", "dist", "cli.js"), + ]; +} + +// --------------------------------------------------------------------------- +// Patch logic +// --------------------------------------------------------------------------- + +const MARK_BEGIN = "// === PATCH: compressContent (deepcode runtime compression) ==="; +const MARK_END = "// === END PATCH ==="; + +function alreadyPatched(code) { + return code.includes(MARK_BEGIN); +} + +/** + * Patch 1: Inject _compressContent static method into the ToolExecutor class, + * and wrap the return of formatToolResult. + */ +function patchFormatToolResult(code) { + // 1. Inject static method right before formatToolResult + const fmtTarget = " formatToolResult(result) {"; + const insertPos = code.lastIndexOf(fmtTarget, code.lastIndexOf("formatToolResult")); + if (insertPos === -1) { + console.error(" x Cannot locate `formatToolResult` method"); + return null; + } + + const before = code.slice(0, insertPos); + const after = code.slice(insertPos); + code = before + `\n${MARK_BEGIN}${COMPRESS_CONTENT_SNIPPET}\n${MARK_END}\n` + after; + + // 2. Replace `return JSON.stringify(payload, null, 2);` inside formatToolResult + const retTarget = " return JSON.stringify(payload, null, 2);"; + // Only replace the last occurrence (inside the ToolExecutor class, not elsewhere) + const lastRetPos = code.lastIndexOf(retTarget); + if (lastRetPos === -1) { + console.error(" x Cannot locate `return JSON.stringify(payload, null, 2)`"); + return null; + } + code = code.slice(0, lastRetPos) + + " return ToolExecutor._compressContent(JSON.stringify(payload, null, 2));" + + code.slice(lastRetPos + retTarget.length); + + return code; +} + +/** + * Detect the bundled fs variable name by scanning for + * `.appendFileSync(messagePath` near getSessionMessagesPath calls. + * This avoids hardcoding bundle-internal names (e.g. fs17, fs, fs2, ...). + */ +function detectFsVar(code) { + const re = /(\w+)\.(?:appendFileSync|writeFileSync)\(messagePath/g; + for (const m of code.matchAll(re)) { + if (m[1] !== "JSON") return m[1]; + } + return null; +} + +/** + * Rewrite a method body to inject `_compressMessageContent` wrapping. + */ +function rewriteMethodBody(code, methodName, fsVar) { + const methodRe = new RegExp( + `( ${methodName}\\(sessionId,\\s*\\w+\\s*\\)\\s*\\{)`, + "m" + ); + const match = methodRe.exec(code); + if (!match) { + console.error(` x Cannot locate \`${methodName}\``); + return null; + } + + // Find matching closing brace (2-space indent) + let depth = 0; + let closeAt = -1; + for (let i = match.index; i < code.length; i++) { + if (code[i] === "{") depth++; + else if (code[i] === "}") { depth--; if (depth === 0) { closeAt = i; break; } } + } + if (closeAt === -1) { + console.error(` x Cannot parse \`${methodName}\` body (unbalanced braces)`); + return null; + } + + const body = code.slice(match.index, closeAt + 1); + + // Try appendFileSync pattern first + const appendRe = new RegExp( + `${fsVar}\\.appendFileSync\\(messagePath,\\s*(\\S[^;]+)\\)`, + "m" + ); + const appendMatch = appendRe.exec(body); + if (appendMatch) { + const msgVar = appendMatch[1].match(/JSON\.stringify\((\w+)\)/)?.[1]; + if (msgVar) { + const oldLine = appendMatch[0]; + const newLine = `const compressed = this._compressMessageContent(${msgVar});\n ${fsVar}.appendFileSync(messagePath, \`\${JSON.stringify(compressed)}\\n\`, "utf8")`; + return code.slice(0, match.index) + body.replace(oldLine, newLine) + code.slice(closeAt + 1); + } + } + + // Try writeFileSync pattern (saveSessionMessages) + const writeRe = new RegExp( + `${fsVar}\\.writeFileSync\\(messagePath,.*messages\\.map\\(`, + "m" + ); + const writeMatch = writeRe.exec(body); + if (writeMatch) { + const newBody = body.replace( + /messages\.map\(\((\w+)\)\s*=>\s*JSON\.stringify\(\1\)\)/, + "messages.map(($1) => JSON.stringify(this._compressMessageContent($1)))" + ); + return code.slice(0, match.index) + newBody + code.slice(closeAt + 1); + } + + console.error(` x Cannot find fs call in \`${methodName}\` body`); + return null; +} + +/** + * Patch 2: Inject _compressMessageContent and wrap appendSessionMessage / saveSessionMessages. + */ +function patchPersistence(code) { + const fsVar = detectFsVar(code); + if (!fsVar) { + console.error(" x Cannot detect bundled fs variable name"); + return null; + } + console.log(` i Detected fs variable: \`${fsVar}\``); + + const appendTarget = " appendSessionMessage(sessionId, message) {"; + const appendPos = code.indexOf(appendTarget); + if (appendPos === -1) { + console.error(" x Cannot locate `appendSessionMessage`"); + return null; + } + + // Insert _compressMessageContent helper right before appendSessionMessage + const before = code.slice(0, appendPos); + const after = code.slice(appendPos); + code = before + `\n${MARK_BEGIN}${COMPRESS_MESSAGE_SNIPPET}\n${MARK_END}\n` + after; + + // Rewrite appendSessionMessage body + code = rewriteMethodBody(code, "appendSessionMessage", fsVar); + if (!code) return null; + + // Rewrite saveSessionMessages body + code = rewriteMethodBody(code, "saveSessionMessages", fsVar); + return code; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + let cliPath = process.argv[2]; + + if (!cliPath) { + cliPath = findGlobalCliJs(); + if (!cliPath) { + console.error("x Cannot auto-detect deepcode CLI path."); + console.error(" Usage: node patch-dist.mjs [/path/to/cli.js]"); + process.exit(1); + } + } + + if (!existsSync(cliPath)) { + console.error(`x File not found: ${cliPath}`); + process.exit(1); + } + + let code = readFileSync(cliPath, "utf8"); + + if (alreadyPatched(code)) { + console.log("Already patched — nothing to do."); + process.exit(0); + } + + console.log(`Patching ${cliPath} ...`); + + code = patchFormatToolResult(code); + if (!code) process.exit(1); + + code = patchPersistence(code); + if (!code) process.exit(1); + + writeFileSync(cliPath, code, "utf8"); + console.log("Done. Restart Deep Code for changes to take effect."); +} + +main();