diff --git a/README.ko.md b/README.ko.md index f623a34..114c035 100644 --- a/README.ko.md +++ b/README.ko.md @@ -136,6 +136,11 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다. - **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다. - 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다. - **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows). +- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다: + - **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입 + - **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가 + - **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거 + - **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입 - **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다. ### Agents diff --git a/README.md b/README.md index 09f7439..d7de0e9 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,11 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI - **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway". - **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output. - **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows). -- **Session Recovery**: Automatically recovers from API errors by injecting missing tool results and correcting thinking block violations, ensuring session stability. +- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios: + - **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results + - **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block + - **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks + - **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem - **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts. ### Agents diff --git a/bun.lock b/bun.lock index 9fde555..cdd8c46 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@ast-grep/napi": "^0.40.0", "@code-yeongyu/comment-checker": "^0.4.1", "@opencode-ai/plugin": "^1.0.7", + "xdg-basedir": "^5.1.0", "zod": "^4.1.8", }, "devDependencies": { @@ -102,6 +103,8 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], } } diff --git a/package.json b/package.json index 208fb6b..9522d34 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@ast-grep/napi": "^0.40.0", "@code-yeongyu/comment-checker": "^0.4.1", "@opencode-ai/plugin": "^1.0.7", + "xdg-basedir": "^5.1.0", "zod": "^4.1.8" }, "devDependencies": { diff --git a/src/hooks/session-recovery.ts b/src/hooks/session-recovery.ts index 32f5627..7de6842 100644 --- a/src/hooks/session-recovery.ts +++ b/src/hooks/session-recovery.ts @@ -12,14 +12,21 @@ * - Recovery: strip thinking/redacted_thinking blocks * * 4. Empty content message (non-empty content required) - * - Recovery: delete the empty message via revert + * - Recovery: inject text part directly via filesystem */ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { xdgData } from "xdg-basedir" import type { PluginInput } from "@opencode-ai/plugin" import type { createOpencodeClient } from "@opencode-ai/sdk" type Client = ReturnType +const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage") +const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") +const PART_STORAGE = join(OPENCODE_STORAGE, "part") + type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null interface MessageInfo { @@ -215,6 +222,140 @@ async function recoverThinkingDisabledViolation( } const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"]) +const META_TYPES = new Set(["step-start", "step-finish"]) + +interface StoredMessageMeta { + id: string + sessionID: string + role: string + parentID?: string +} + +interface StoredPart { + id: string + sessionID: string + messageID: string + type: string + text?: string +} + +function generatePartId(): string { + const timestamp = Date.now().toString(16) + const random = Math.random().toString(36).substring(2, 10) + return `prt_${timestamp}${random}` +} + +function getMessageDir(sessionID: string): string { + const projectHash = readdirSync(MESSAGE_STORAGE).find((dir) => { + const sessionDir = join(MESSAGE_STORAGE, dir) + try { + return readdirSync(sessionDir).some((f) => f.includes(sessionID.replace("ses_", ""))) + } catch { + return false + } + }) + + if (projectHash) { + return join(MESSAGE_STORAGE, projectHash, sessionID) + } + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) { + return sessionPath + } + } + + return "" +} + +function readMessagesFromStorage(sessionID: string): StoredMessageMeta[] { + const messageDir = getMessageDir(sessionID) + if (!messageDir || !existsSync(messageDir)) return [] + + const messages: StoredMessageMeta[] = [] + for (const file of readdirSync(messageDir)) { + if (!file.endsWith(".json")) continue + try { + const content = readFileSync(join(messageDir, file), "utf-8") + messages.push(JSON.parse(content)) + } catch { + continue + } + } + + return messages.sort((a, b) => a.id.localeCompare(b.id)) +} + +function readPartsFromStorage(messageID: string): StoredPart[] { + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) return [] + + const parts: StoredPart[] = [] + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const content = readFileSync(join(partDir, file), "utf-8") + parts.push(JSON.parse(content)) + } catch { + continue + } + } + + return parts +} + +function injectTextPartToStorage(sessionID: string, messageID: string, text: string): boolean { + const partDir = join(PART_STORAGE, messageID) + + if (!existsSync(partDir)) { + mkdirSync(partDir, { recursive: true }) + } + + const partId = generatePartId() + const part: StoredPart = { + id: partId, + sessionID, + messageID, + type: "text", + text, + } + + try { + writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) + return true + } catch { + return false + } +} + +function findEmptyContentMessageFromStorage(sessionID: string): string | null { + const messages = readMessagesFromStorage(sessionID) + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (msg.role !== "assistant") continue + + const isLastMessage = i === messages.length - 1 + if (isLastMessage) continue + + const parts = readPartsFromStorage(msg.id) + const hasContent = parts.some((p) => { + if (THINKING_TYPES.has(p.type)) return false + if (META_TYPES.has(p.type)) return false + if (p.type === "text" && p.text?.trim()) return true + if (p.type === "tool_use") return true + if (p.type === "tool_result") return true + return false + }) + + if (!hasContent && parts.length > 0) { + return msg.id + } + } + + return null +} function hasNonEmptyOutput(msg: MessageData): boolean { const parts = msg.parts @@ -246,65 +387,15 @@ function findEmptyContentMessage(msgs: MessageData[]): MessageData | null { } async function recoverEmptyContentMessage( - client: Client, + _client: Client, sessionID: string, failedAssistantMsg: MessageData, - directory: string + _directory: string ): Promise { - try { - const messagesResp = await client.session.messages({ - path: { id: sessionID }, - query: { directory }, - }) - const msgs = (messagesResp as { data?: MessageData[] }).data + const emptyMessageID = findEmptyContentMessageFromStorage(sessionID) || failedAssistantMsg.info?.id + if (!emptyMessageID) return false - if (!msgs || msgs.length === 0) return false - - const emptyMsg = findEmptyContentMessage(msgs) || failedAssistantMsg - const messageID = emptyMsg.info?.id - if (!messageID) return false - - const existingParts = emptyMsg.parts || [] - const hasOnlyThinkingOrMeta = existingParts.length > 0 && existingParts.every( - (p) => THINKING_TYPES.has(p.type) || p.type === "step-start" || p.type === "step-finish" - ) - - if (hasOnlyThinkingOrMeta) { - const strippedParts: MessagePart[] = [{ type: "text", text: "(interrupted)" }] - - try { - // @ts-expect-error - Experimental API - await client.message?.update?.({ - path: { id: messageID }, - body: { parts: strippedParts }, - }) - return true - } catch { - // message.update not available - } - - try { - // @ts-expect-error - Experimental API - await client.session.patch?.({ - path: { id: sessionID }, - body: { messageID, parts: strippedParts }, - }) - return true - } catch { - // session.patch not available - } - } - - const revertTargetID = emptyMsg.info?.parentID || messageID - await client.session.revert({ - path: { id: sessionID }, - body: { messageID: revertTargetID }, - query: { directory }, - }) - return true - } catch { - return false - } + return injectTextPartToStorage(sessionID, emptyMessageID, "(interrupted)") } async function fallbackRevertStrategy( diff --git a/src/tools/lsp/utils.ts b/src/tools/lsp/utils.ts index 750dcf6..e227d64 100644 --- a/src/tools/lsp/utils.ts +++ b/src/tools/lsp/utils.ts @@ -12,6 +12,7 @@ import type { Diagnostic, PrepareRenameResult, PrepareRenameDefaultBehavior, + Range, WorkspaceEdit, TextEdit, CodeAction, @@ -165,21 +166,35 @@ export function filterDiagnosticsBySeverity( } export function formatPrepareRenameResult( - result: PrepareRenameResult | PrepareRenameDefaultBehavior | null + result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null ): string { if (!result) return "Cannot rename at this position" + // Case 1: { defaultBehavior: boolean } if ("defaultBehavior" in result) { return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position" } - const startLine = result.range.start.line + 1 - const startChar = result.range.start.character - const endLine = result.range.end.line + 1 - const endChar = result.range.end.character - const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : "" + // Case 2: { range: Range, placeholder?: string } + if ("range" in result && result.range) { + const startLine = result.range.start.line + 1 + const startChar = result.range.start.character + const endLine = result.range.end.line + 1 + const endChar = result.range.end.character + const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : "" + return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}` + } - return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}` + // Case 3: Range directly (has start/end but no range property) + if ("start" in result && "end" in result) { + const startLine = result.start.line + 1 + const startChar = result.start.character + const endLine = result.end.line + 1 + const endChar = result.end.character + return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}` + } + + return "Cannot rename at this position" } export function formatTextEdit(edit: TextEdit): string {