Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feb87cf716 |
41
README.ko.md
41
README.ko.md
@@ -13,7 +13,7 @@
|
||||
- [Tools](#tools)
|
||||
- [내장 LSP Tools](#내장-lsp-tools)
|
||||
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
|
||||
- [Grep](#grep)
|
||||
- [Safe Grep](#safe-grep)
|
||||
- [내장 MCPs](#내장-mcps)
|
||||
- [기타 편의 기능](#기타-편의-기능)
|
||||
- [설정](#설정)
|
||||
@@ -136,23 +136,7 @@ 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가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
||||
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # 프로젝트 전체 컨텍스트
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src 전용 컨텍스트
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # 컴포넌트 전용 컨텍스트
|
||||
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
|
||||
```
|
||||
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
|
||||
|
||||
### Agents
|
||||
|
||||
@@ -162,22 +146,12 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
|
||||
|
||||
각 에이전트는 메인 에이전트가 알아서 호출하지만, 명시적으로 요청할 수도 있습니다:
|
||||
|
||||
```
|
||||
@oracle 한테 이 부분 설계 고민하고서 아키텍쳐 제안을 부탁해줘
|
||||
@librarian 한테 이 부분 어떻게 구현돼있길래 자꾸 안에서 동작이 바뀌는지 알려달라고 해줘
|
||||
@explore 한테 이 기능 정책 알려달라고 해줘
|
||||
```
|
||||
|
||||
에이전트의 모델, 프롬프트, 권한은 `oh-my-opencode.json`에서 커스텀할 수 있습니다. 자세한 내용은 [설정](#설정)을 참고하세요.
|
||||
|
||||
### Tools
|
||||
|
||||
#### 내장 LSP Tools
|
||||
|
||||
당신이 에디터에서 사용하는 그 기능을 다른 에이전트들은 사용하지 못합니다. Oh My OpenCode 는 당신만의 그 도구를 LLM Agent 에게 쥐어줍니다. 리팩토링하고, 탐색하고, 분석하는 모든 작업을 OpenCode 의 설정값을 그대로 사용하여 지원합니다.
|
||||
|
||||
[OpenCode 는 LSP 를 제공하지만](https://opencode.ai/docs/lsp/), 오로지 분석용으로만 제공합니다. 탐색과 리팩토링을 위한 도구는 OpenCode 와 동일한 스펙과 설정으로 Oh My OpenCode 가 제공합니다.
|
||||
|
||||
- **lsp_hover**: 위치의 타입 정보, 문서, 시그니처 가져오기
|
||||
@@ -196,16 +170,11 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
|
||||
- **ast_grep_replace**: AST 인식 코드 교체
|
||||
|
||||
#### Grep
|
||||
- **grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한). OpenCode의 내장 `grep` 도구를 대체합니다.
|
||||
#### Safe Grep
|
||||
- **safe_grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한).
|
||||
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
|
||||
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
|
||||
|
||||
#### Glob
|
||||
|
||||
- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다.
|
||||
- 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다.
|
||||
- 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다.
|
||||
- safe_grep 은 timeout 과 더 엄격한 출력 제한을 적용합니다.
|
||||
- **주의**: 기본 grep 도구는 Agent 를 햇갈리게 하지 않기 위해 비활성화됩니다. 그러나 SafeGrep 은 Grep 이 제공하는 모든 기능을 제공합니다.
|
||||
|
||||
#### 내장 MCPs
|
||||
|
||||
|
||||
42
README.md
42
README.md
@@ -13,7 +13,7 @@ English | [한국어](README.ko.md)
|
||||
- [Tools](#tools)
|
||||
- [Built-in LSP Tools](#built-in-lsp-tools)
|
||||
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
|
||||
- [Grep](#grep)
|
||||
- [Safe Grep](#safe-grep)
|
||||
- [Built-in MCPs](#built-in-mcps)
|
||||
- [Other Features](#other-features)
|
||||
- [Configuration](#configuration)
|
||||
@@ -132,23 +132,8 @@ 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, 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
|
||||
- **Session Recovery**: Automatically recovers from API errors by injecting missing tool results and correcting thinking block violations, ensuring session stability.
|
||||
- **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.
|
||||
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, collecting **all** `AGENTS.md` files along the path hierarchy. This enables nested, directory-specific instructions:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # Project-wide context
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src-specific context
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # Component-specific context
|
||||
│ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files
|
||||
```
|
||||
When reading `Button.tsx`, the hook injects contexts in order: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. Each directory's context is injected only once per session. Inspired by Claude Code's CLAUDE.md feature.
|
||||
|
||||
### Agents
|
||||
- **oracle** (`openai/gpt-5.1`): The architect. Expert in code reviews and strategy. Uses GPT-5.1 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
|
||||
@@ -157,22 +142,12 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Creates stunning UIs. Uses Gemini because its creativity and UI code generation are superior.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): A technical writing expert. Gemini is a wordsmith; it writes prose that flows naturally.
|
||||
|
||||
Each agent is automatically invoked by the main agent, but you can also explicitly request them:
|
||||
|
||||
```
|
||||
@oracle Please think through the design of this part and suggest an architecture.
|
||||
@librarian Tell me how this is implemented — why does the behavior keep changing internally?
|
||||
@explore Tell me about the policy for this feature.
|
||||
```
|
||||
|
||||
Agent models, prompts, and permissions can be customized via `oh-my-opencode.json`. See [Configuration](#configuration) for details.
|
||||
|
||||
### Tools
|
||||
|
||||
#### Built-in LSP Tools
|
||||
|
||||
The features you use in your editor—other agents cannot access them. Oh My OpenCode hands those very tools to your LLM Agent. Refactoring, navigation, and analysis are all supported using the same OpenCode configuration.
|
||||
|
||||
[OpenCode provides LSP](https://opencode.ai/docs/lsp/), but only for analysis. Oh My OpenCode equips you with navigation and refactoring tools matching the same specification.
|
||||
|
||||
- **lsp_hover**: Get type info, docs, signatures at position
|
||||
@@ -192,17 +167,12 @@ The features you use in your editor—other agents cannot access them. Oh My Ope
|
||||
- **ast_grep_search**: AST-aware code pattern search (25 languages)
|
||||
- **ast_grep_replace**: AST-aware code replacement
|
||||
|
||||
#### Grep
|
||||
#### Safe Grep
|
||||
|
||||
- **grep**: Content search with safety limits (5min timeout, 10MB output). Overrides OpenCode's built-in `grep` tool.
|
||||
- **safe_grep**: Content search with safety limits (5min timeout, 10MB output).
|
||||
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
|
||||
- This tool enforces strict limits and completely replaces the built-in `grep`.
|
||||
|
||||
#### Glob
|
||||
|
||||
- **glob**: File pattern matching with timeout protection (60s). Overrides OpenCode's built-in `glob` tool.
|
||||
- The default `glob` lacks timeout. If ripgrep hangs, it waits indefinitely.
|
||||
- This tool enforces timeouts and kills the process on expiration.
|
||||
- `safe_grep` enforces strict limits.
|
||||
- **Note**: Default `grep` is disabled to prevent Agent confusion. `safe_grep` delivers full `grep` functionality with safety assurance.
|
||||
|
||||
#### Built-in MCPs
|
||||
|
||||
|
||||
12
bun.lock
12
bun.lock
@@ -7,14 +7,12 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.4",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -64,7 +62,7 @@
|
||||
|
||||
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
|
||||
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.4", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-vsbdLMQYJJNDV/baTDnNqqg/MZwA+9nz7TE6Mybj8zjZVTCn4ZivH4hAdD5p4fLxhGZEJ5x1UDmXA6pAGA7lHA=="],
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
|
||||
|
||||
@@ -100,16 +98,10 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"oh-my-opencode": ["oh-my-opencode@0.1.30", "", { "dependencies": { "@ast-grep/cli": "^0.40.0", "@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" }, "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-pXGGgL/7Jcz3yuGJJTI72BKern2egwfRz2LQZTBq+jl+pNCybOvGvXtFmR+WGlF8O3ZjL1wIHypBbIVuHOBzxg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"oh-my-opencode/@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
|
||||
}
|
||||
}
|
||||
|
||||
82
notepad.md
82
notepad.md
@@ -59,85 +59,3 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-08 18:56] - Task 1: Remove unused import formatWorkspaceEdit from LSP tools
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - simple import cleanup task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Removed only `formatWorkspaceEdit` from import list at line 17
|
||||
- Kept all other imports intact (formatCodeActions, applyWorkspaceEdit, formatApplyResult remain)
|
||||
- Verified the function exists in utils.ts:212 but is truly unused in tools.ts
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified for remaining tasks
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, bundled 200 modules
|
||||
- Ran: `rg "formatWorkspaceEdit" src/tools/lsp/tools.ts` → no matches (confirmed removal)
|
||||
|
||||
### LEARNINGS
|
||||
- Convention: This project uses `bun run typecheck` (tsc --noEmit) and `bun run build` for verification
|
||||
- The `formatWorkspaceEdit` function still exists in utils.ts - it's exported but just not used in tools.ts
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-08 19:00] - Task 2: Remove unused ThinkingPart interface and fallbackRevertStrategy function
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - both items were genuinely unused (no callers found)
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Removed `ThinkingPart` interface (lines 37-40) - defined but never referenced
|
||||
- Removed `fallbackRevertStrategy` function (lines 189-244) - defined but never called
|
||||
- Added comment explaining removal reason as per task requirements
|
||||
- Kept `ThinkingPartType`, `prependThinkingPart`, `stripThinkingParts` - these are different items and ARE used
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, bundled 200 modules
|
||||
- Ran: `rg "ThinkingPart" src/hooks/session-recovery/` → only related types/functions found, interface removed
|
||||
- Ran: `rg "fallbackRevertStrategy" src/hooks/session-recovery/` → only comment found, function removed
|
||||
- Ran: `rg "createSessionRecoveryHook" src/hooks/` → exports intact
|
||||
|
||||
### LEARNINGS
|
||||
- `ThinkingPart` interface vs `ThinkingPartType` type vs `prependThinkingPart` function - different entities, verify before removing
|
||||
- `fallbackRevertStrategy` was likely a planned feature that never got integrated into the recovery flow
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-08 19:04] - Task 3: Remove unused builtinMcps export from MCP module
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - `builtinMcps` export was genuinely unused (no external importers)
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Removed `export const builtinMcps = allBuiltinMcps` from line 24
|
||||
- Kept `allBuiltinMcps` const - used internally by `createBuiltinMcps` function
|
||||
- Kept `createBuiltinMcps` function - actively used in src/index.ts:89
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, bundled 200 modules
|
||||
- Ran: `rg "builtinMcps" src/mcp/index.ts` → no matches (export removed)
|
||||
- Ran: `rg "createBuiltinMcps" src/mcp/index.ts` → function still exists
|
||||
|
||||
### LEARNINGS
|
||||
- `createBuiltinMcps` function vs `builtinMcps` export - function is used, direct export is not
|
||||
- Internal const `allBuiltinMcps` should be kept since it's referenced by the function
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.20",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -44,14 +44,12 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.4",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { AutoCompactState } from "./types"
|
||||
|
||||
type Client = {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown>
|
||||
summarize: (opts: {
|
||||
path: { id: string }
|
||||
body: { providerID: string; modelID: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLastAssistant(
|
||||
sessionID: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const resp = await (client as Client).session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data
|
||||
if (!Array.isArray(data)) return null
|
||||
|
||||
const reversed = [...data].reverse()
|
||||
const last = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>
|
||||
const info = msg.info as Record<string, unknown> | undefined
|
||||
return info?.role === "assistant"
|
||||
})
|
||||
if (!last) return null
|
||||
return (last as { info?: Record<string, unknown> }).info ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
autoCompactState: AutoCompactState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const providerID = msg.providerID as string | undefined
|
||||
const modelID = msg.modelID as string | undefined
|
||||
|
||||
if (providerID && modelID) {
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).tui.submitPrompt({ query: { directory } })
|
||||
} catch {}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
autoCompactState.errorDataBySession.delete(sessionID)
|
||||
} catch {}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
|
||||
function createAutoCompactState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
||||
const autoCompactState = createAutoCompactState()
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const parsed = parseAnthropicTokenLimitError(props?.error)
|
||||
if (parsed) {
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
const parsed = parseAnthropicTokenLimitError(info.error)
|
||||
if (parsed) {
|
||||
parsed.providerID = info.providerID as string | undefined
|
||||
parsed.modelID = info.modelID as string | undefined
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||
if (errorData?.providerID && errorData?.modelID) {
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
{ providerID: errorData.providerID, modelID: errorData.modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
if (!lastAssistant) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAssistant.summary === true) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (!lastAssistant.modelID || !lastAssistant.providerID) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(sessionID, lastAssistant, autoCompactState, ctx.client, ctx.directory)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { ParsedTokenLimitError } from "./types"
|
||||
|
||||
interface AnthropicErrorData {
|
||||
type: "error"
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
const TOKEN_LIMIT_PATTERNS = [
|
||||
/(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i,
|
||||
/prompt.*?(\d+).*?tokens.*?exceeds.*?(\d+)/i,
|
||||
/(\d+).*?tokens.*?limit.*?(\d+)/i,
|
||||
/context.*?length.*?(\d+).*?maximum.*?(\d+)/i,
|
||||
/max.*?context.*?(\d+).*?but.*?(\d+)/i,
|
||||
]
|
||||
|
||||
const TOKEN_LIMIT_KEYWORDS = [
|
||||
"prompt is too long",
|
||||
"is too long",
|
||||
"context_length_exceeded",
|
||||
"max_tokens",
|
||||
"token limit",
|
||||
"context length",
|
||||
"too many tokens",
|
||||
]
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
for (const pattern of TOKEN_LIMIT_PATTERNS) {
|
||||
const match = message.match(pattern)
|
||||
if (match) {
|
||||
const num1 = parseInt(match[1], 10)
|
||||
const num2 = parseInt(match[2], 10)
|
||||
return num1 > num2 ? { current: num1, max: num2 } : { current: num2, max: num1 }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||
if (typeof err === "string") {
|
||||
if (isTokenLimitError(err)) {
|
||||
const tokens = extractTokensFromMessage(err)
|
||||
return {
|
||||
currentTokens: tokens?.current ?? 0,
|
||||
maxTokens: tokens?.max ?? 0,
|
||||
errorType: "token_limit_exceeded_string",
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!err || typeof err !== "object") return null
|
||||
|
||||
const errObj = err as Record<string, unknown>
|
||||
|
||||
const dataObj = errObj.data as Record<string, unknown> | undefined
|
||||
const responseBody = dataObj?.responseBody
|
||||
const errorMessage = errObj.message as string | undefined
|
||||
const errorData = errObj.error as Record<string, unknown> | undefined
|
||||
const nestedError = errorData?.error as Record<string, unknown> | undefined
|
||||
|
||||
const textSources: string[] = []
|
||||
|
||||
if (typeof responseBody === "string") textSources.push(responseBody)
|
||||
if (typeof errorMessage === "string") textSources.push(errorMessage)
|
||||
if (typeof errorData?.message === "string") textSources.push(errorData.message as string)
|
||||
if (typeof errObj.body === "string") textSources.push(errObj.body as string)
|
||||
if (typeof errObj.details === "string") textSources.push(errObj.details as string)
|
||||
if (typeof errObj.reason === "string") textSources.push(errObj.reason as string)
|
||||
if (typeof errObj.description === "string") textSources.push(errObj.description as string)
|
||||
if (typeof nestedError?.message === "string") textSources.push(nestedError.message as string)
|
||||
if (typeof dataObj?.message === "string") textSources.push(dataObj.message as string)
|
||||
if (typeof dataObj?.error === "string") textSources.push(dataObj.error as string)
|
||||
|
||||
if (textSources.length === 0) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(errObj)
|
||||
if (isTokenLimitError(jsonStr)) {
|
||||
textSources.push(jsonStr)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const combinedText = textSources.join(" ")
|
||||
if (!isTokenLimitError(combinedText)) return null
|
||||
|
||||
if (typeof responseBody === "string") {
|
||||
try {
|
||||
const jsonPatterns = [
|
||||
/data:\s*(\{[\s\S]*?\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
|
||||
/(\{[\s\S]*?"error"[\s\S]*?\})/,
|
||||
]
|
||||
|
||||
for (const pattern of jsonPatterns) {
|
||||
const dataMatch = responseBody.match(pattern)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
const jsonData: AnthropicErrorData = JSON.parse(dataMatch[1])
|
||||
const message = jsonData.error?.message || ""
|
||||
const tokens = extractTokensFromMessage(message)
|
||||
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
requestId: jsonData.request_id,
|
||||
errorType: jsonData.error?.type || "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const bedrockJson = JSON.parse(responseBody)
|
||||
if (typeof bedrockJson.message === "string" && isTokenLimitError(bedrockJson.message)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "bedrock_input_too_long",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const text of textSources) {
|
||||
const tokens = extractTokensFromMessage(text)
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
errorType: "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTokenLimitError(combinedText)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "token_limit_exceeded_unknown",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export interface ParsedTokenLimitError {
|
||||
currentTokens: number
|
||||
maxTokens: number
|
||||
requestId?: string
|
||||
errorType: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
}
|
||||
@@ -15,13 +15,36 @@ function debugLog(...args: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function getPlatformPackageName(): string | null {
|
||||
const platform = process.platform as Platform
|
||||
const arch = process.arch
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
"darwin-arm64": "@code-yeongyu/comment-checker-darwin-arm64",
|
||||
"darwin-x64": "@code-yeongyu/comment-checker-darwin-x64",
|
||||
"linux-arm64": "@code-yeongyu/comment-checker-linux-arm64",
|
||||
"linux-x64": "@code-yeongyu/comment-checker-linux-x64",
|
||||
"win32-x64": "@code-yeongyu/comment-checker-windows-x64",
|
||||
}
|
||||
|
||||
return platformMap[`${platform}-${arch}`] ?? null
|
||||
}
|
||||
|
||||
function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously find comment-checker binary path.
|
||||
* Checks installed packages, homebrew, cache, and system PATH.
|
||||
* Does NOT trigger download.
|
||||
*/
|
||||
function findCommentCheckerPathSync(): string | null {
|
||||
const binaryName = getBinaryName()
|
||||
|
||||
// 1. Try to find from @code-yeongyu/comment-checker package
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
|
||||
@@ -36,12 +59,46 @@ function findCommentCheckerPathSync(): string | null {
|
||||
debugLog("main package not installed")
|
||||
}
|
||||
|
||||
// 2. Try platform-specific package directly (legacy, for backwards compatibility)
|
||||
const platformPkg = getPlatformPackageName()
|
||||
if (platformPkg) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkgPath = require.resolve(`${platformPkg}/package.json`)
|
||||
const pkgDir = dirname(pkgPath)
|
||||
const binaryPath = join(pkgDir, "bin", binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
debugLog("found binary in platform package:", binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
debugLog("platform package not installed:", platformPkg)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try homebrew installation (macOS)
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = [
|
||||
"/opt/homebrew/bin/comment-checker",
|
||||
"/usr/local/bin/comment-checker",
|
||||
]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path)) {
|
||||
debugLog("found binary via homebrew:", path)
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try cached binary (lazy download location)
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("found binary in cache:", cachedPath)
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
// 5. Try system PATH (as fallback)
|
||||
debugLog("no binary found in known locations")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -72,10 +72,6 @@ PRIORITY-BASED ACTION GUIDELINES:
|
||||
\t-> Make the code itself clearer so it can be understood without comments/docstrings.
|
||||
\t-> For verbose docstrings: refactor code to be self-documenting instead of adding lengthy explanations.
|
||||
|
||||
CODE SMELL WARNING: Using comments as visual separators (e.g., "// =========", "# ---", "// *** Section ***")
|
||||
is a code smell. If you need separators, your file is too long or poorly organized.
|
||||
Refactor into smaller modules or use proper code organization instead of comment-based section dividers.
|
||||
|
||||
MANDATORY REQUIREMENT: You must acknowledge this hook message and take one of the above actions.
|
||||
Review in the above priority order and take the corresponding action EVERY TIME this appears.
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const AGENTS_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-agents",
|
||||
);
|
||||
export const AGENTS_FILENAME = "AGENTS.md";
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
loadInjectedPaths,
|
||||
saveInjectedPaths,
|
||||
clearInjectedPaths,
|
||||
} from "./storage";
|
||||
import { AGENTS_FILENAME } from "./constants";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
|
||||
function getSessionCache(sessionID: string): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function resolveFilePath(title: string): string | null {
|
||||
if (!title) return null;
|
||||
if (title.startsWith("/")) return title;
|
||||
return resolve(ctx.directory, title);
|
||||
}
|
||||
|
||||
function findAgentsMdUp(startDir: string): string[] {
|
||||
const found: string[] = [];
|
||||
let current = startDir;
|
||||
|
||||
while (true) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (existsSync(agentsPath)) {
|
||||
found.push(agentsPath);
|
||||
}
|
||||
|
||||
if (current === ctx.directory) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(ctx.directory)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return found.reverse();
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "read") return;
|
||||
|
||||
const filePath = resolveFilePath(output.title);
|
||||
if (!filePath) return;
|
||||
|
||||
const dir = dirname(filePath);
|
||||
const cache = getSessionCache(input.sessionID);
|
||||
const agentsPaths = findAgentsMdUp(dir);
|
||||
|
||||
const toInject: { path: string; content: string }[] = [];
|
||||
|
||||
for (const agentsPath of agentsPaths) {
|
||||
const agentsDir = dirname(agentsPath);
|
||||
if (cache.has(agentsDir)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentsPath, "utf-8");
|
||||
toInject.push({ path: agentsPath, content });
|
||||
cache.add(agentsDir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
for (const { path, content } of toInject) {
|
||||
output.output += `\n\n[Directory Context: ${path}]\n${content}`;
|
||||
}
|
||||
|
||||
saveInjectedPaths(input.sessionID, cache);
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { AGENTS_INJECTOR_STORAGE } from "./constants";
|
||||
import type { InjectedPathsData } from "./types";
|
||||
|
||||
function getStoragePath(sessionID: string): string {
|
||||
return join(AGENTS_INJECTOR_STORAGE, `${sessionID}.json`);
|
||||
}
|
||||
|
||||
export function loadInjectedPaths(sessionID: string): Set<string> {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (!existsSync(filePath)) return new Set();
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const data: InjectedPathsData = JSON.parse(content);
|
||||
return new Set(data.injectedPaths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
|
||||
if (!existsSync(AGENTS_INJECTOR_STORAGE)) {
|
||||
mkdirSync(AGENTS_INJECTOR_STORAGE, { recursive: true });
|
||||
}
|
||||
|
||||
const data: InjectedPathsData = {
|
||||
sessionID,
|
||||
injectedPaths: [...paths],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export function clearInjectedPaths(sessionID: string): void {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface InjectedPathsData {
|
||||
sessionID: string;
|
||||
injectedPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const EMPTY_RESPONSE_WARNING = `[Task Empty Response Warning]
|
||||
|
||||
Task invocation completed but returned no response. This indicates the agent either:
|
||||
- Failed to execute properly
|
||||
- Did not terminate correctly
|
||||
- Returned an empty result
|
||||
|
||||
Note: The call has already completed - you are NOT waiting for a response. Proceed accordingly.`
|
||||
|
||||
export function createEmptyTaskResponseDetectorHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool !== "Task") return
|
||||
|
||||
const responseText = output.output?.trim() ?? ""
|
||||
|
||||
if (responseText === "") {
|
||||
output.output = EMPTY_RESPONSE_WARNING
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
18
src/hooks/grep-blocker.ts
Normal file
18
src/hooks/grep-blocker.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const BLOCKED_MESSAGE =
|
||||
"Error: [BLOCKED] grep has no timeout and can freeze the system. " +
|
||||
"It is permanently disabled. Use 'safe_grep' instead."
|
||||
|
||||
export function createGrepBlocker(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
_output: { args: unknown }
|
||||
) => {
|
||||
if (input.tool === "grep") {
|
||||
throw new Error(BLOCKED_MESSAGE)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4
|
||||
const TARGET_MAX_TOKENS = 50_000
|
||||
|
||||
interface AssistantMessageInfo {
|
||||
role: "assistant"
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageWrapper {
|
||||
info: { role: string } & Partial<AssistantMessageInfo>
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
|
||||
}
|
||||
|
||||
function truncateToTokenLimit(output: string, maxTokens: number): { result: string; truncated: boolean } {
|
||||
const currentTokens = estimateTokens(output)
|
||||
|
||||
if (currentTokens <= maxTokens) {
|
||||
return { result: output, truncated: false }
|
||||
}
|
||||
|
||||
const lines = output.split("\n")
|
||||
|
||||
if (lines.length <= 3) {
|
||||
const maxChars = maxTokens * CHARS_PER_TOKEN_ESTIMATE
|
||||
return {
|
||||
result: output.slice(0, maxChars) + "\n\n[Output truncated due to context window limit]",
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
const headerLines = lines.slice(0, 3)
|
||||
const contentLines = lines.slice(3)
|
||||
|
||||
const headerText = headerLines.join("\n")
|
||||
const headerTokens = estimateTokens(headerText)
|
||||
const availableTokens = maxTokens - headerTokens - 50
|
||||
|
||||
if (availableTokens <= 0) {
|
||||
return {
|
||||
result: headerText + "\n\n[Content truncated due to context window limit]",
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
let resultLines: string[] = []
|
||||
let currentTokenCount = 0
|
||||
|
||||
for (const line of contentLines) {
|
||||
const lineTokens = estimateTokens(line + "\n")
|
||||
if (currentTokenCount + lineTokens > availableTokens) {
|
||||
break
|
||||
}
|
||||
resultLines.push(line)
|
||||
currentTokenCount += lineTokens
|
||||
}
|
||||
|
||||
const truncatedContent = [...headerLines, ...resultLines].join("\n")
|
||||
const removedCount = contentLines.length - resultLines.length
|
||||
|
||||
return {
|
||||
result: truncatedContent + `\n\n[${removedCount} more lines truncated due to context window limit]`,
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function createGrepOutputTruncatorHook(ctx: PluginInput) {
|
||||
const GREP_TOOLS = ["safe_grep", "Grep"]
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (!GREP_TOOLS.includes(input.tool)) return
|
||||
|
||||
const { sessionID } = input
|
||||
|
||||
try {
|
||||
const response = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messages = (response.data ?? response) as MessageWrapper[]
|
||||
|
||||
const assistantMessages = messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.map((m) => m.info as AssistantMessageInfo)
|
||||
|
||||
if (assistantMessages.length === 0) return
|
||||
|
||||
const totalInputTokens = assistantMessages.reduce((sum, m) => {
|
||||
const inputTokens = m.tokens?.input ?? 0
|
||||
const cacheReadTokens = m.tokens?.cache?.read ?? 0
|
||||
return sum + inputTokens + cacheReadTokens
|
||||
}, 0)
|
||||
|
||||
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - totalInputTokens
|
||||
|
||||
const maxOutputTokens = Math.min(
|
||||
remainingTokens * 0.5,
|
||||
TARGET_MAX_TOKENS
|
||||
)
|
||||
|
||||
if (maxOutputTokens <= 0) {
|
||||
output.output = "[Output suppressed - context window exhausted]"
|
||||
return
|
||||
}
|
||||
|
||||
const { result, truncated } = truncateToTokenLimit(output.output, maxOutputTokens)
|
||||
if (truncated) {
|
||||
output.output = result
|
||||
}
|
||||
} catch {
|
||||
// Graceful degradation
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
||||
export { createContextWindowMonitorHook } from "./context-window-monitor";
|
||||
export { createSessionNotification } from "./session-notification";
|
||||
export { createSessionRecoveryHook } from "./session-recovery";
|
||||
export { createCommentCheckerHooks } from "./comment-checker";
|
||||
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
|
||||
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
|
||||
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
||||
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
|
||||
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
export { createContextWindowMonitorHook } from "./context-window-monitor"
|
||||
export { createSessionNotification } from "./session-notification"
|
||||
export { createSessionRecoveryHook } from "./session-recovery"
|
||||
export { createCommentCheckerHooks } from "./comment-checker"
|
||||
|
||||
461
src/hooks/session-recovery.ts
Normal file
461
src/hooks/session-recovery.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Session Recovery - Message State Error Recovery
|
||||
*
|
||||
* Handles FOUR specific scenarios:
|
||||
* 1. tool_use block exists without tool_result
|
||||
* - Recovery: inject tool_result with "cancelled" content
|
||||
*
|
||||
* 2. Thinking block order violation (first block must be thinking)
|
||||
* - Recovery: prepend empty thinking block
|
||||
*
|
||||
* 3. Thinking disabled but message contains thinking blocks
|
||||
* - Recovery: strip thinking/redacted_thinking blocks
|
||||
*
|
||||
* 4. Empty content message (non-empty content required)
|
||||
* - Recovery: delete the empty message via revert
|
||||
*/
|
||||
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
parentID?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
interface ToolUsePart {
|
||||
type: "tool_use"
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ThinkingPart {
|
||||
type: "thinking"
|
||||
thinking: string
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
id?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface MessageData {
|
||||
info?: MessageInfo
|
||||
parts?: MessagePart[]
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (!error) return ""
|
||||
if (typeof error === "string") return error.toLowerCase()
|
||||
const errorObj = error as { data?: { message?: string }; message?: string }
|
||||
return (errorObj.data?.message || errorObj.message || "").toLowerCase()
|
||||
}
|
||||
|
||||
function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (message.includes("tool_use") && message.includes("tool_result")) {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
|
||||
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
|
||||
return "thinking_disabled_violation"
|
||||
}
|
||||
|
||||
if (message.includes("non-empty content") || message.includes("must have non-empty content")) {
|
||||
return "empty_content_message"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractToolUseIds(parts: MessagePart[]): string[] {
|
||||
return parts.filter((p): p is ToolUsePart => p.type === "tool_use" && !!p.id).map((p) => p.id)
|
||||
}
|
||||
|
||||
async function recoverToolResultMissing(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const parts = failedAssistantMsg.parts || []
|
||||
const toolUseIds = extractToolUseIds(parts)
|
||||
|
||||
if (toolUseIds.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const toolResultParts = toolUseIds.map((id) => ({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: id,
|
||||
content: "Operation cancelled by user (ESC pressed)",
|
||||
}))
|
||||
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
// @ts-expect-error - SDK types may not include tool_result parts, but runtime accepts it
|
||||
body: { parts: toolResultParts },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverThinkingBlockOrder(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
const messageID = failedAssistantMsg.info?.id
|
||||
if (!messageID) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existingParts = failedAssistantMsg.parts || []
|
||||
const patchedParts: MessagePart[] = [{ type: "thinking", thinking: "" } as ThinkingPart, ...existingParts]
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.message?.update?.({
|
||||
path: { id: messageID },
|
||||
body: { parts: patchedParts },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// message.update not available
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.session.patch?.({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
messageID,
|
||||
parts: patchedParts,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// session.patch not available
|
||||
}
|
||||
|
||||
return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory)
|
||||
}
|
||||
|
||||
async function recoverThinkingDisabledViolation(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const messageID = failedAssistantMsg.info?.id
|
||||
if (!messageID) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existingParts = failedAssistantMsg.parts || []
|
||||
const strippedParts = existingParts.filter((p) => p.type !== "thinking" && p.type !== "redacted_thinking")
|
||||
|
||||
if (strippedParts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
|
||||
|
||||
function hasNonEmptyOutput(msg: MessageData): boolean {
|
||||
const parts = msg.parts
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
return parts.some((p) => {
|
||||
if (THINKING_TYPES.has(p.type)) return false
|
||||
if (p.type === "step-start" || p.type === "step-finish") return false
|
||||
if (p.type === "text" && p.text && p.text.trim()) return true
|
||||
if (p.type === "tool_use" && p.id) return true
|
||||
if (p.type === "tool_result") return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function findEmptyContentMessage(msgs: MessageData[]): MessageData | null {
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
const msg = msgs[i]
|
||||
const isLastMessage = i === msgs.length - 1
|
||||
const isAssistant = msg.info?.role === "assistant"
|
||||
|
||||
if (isLastMessage && isAssistant) continue
|
||||
|
||||
if (!hasNonEmptyOutput(msg)) {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function recoverEmptyContentMessage(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function fallbackRevertStrategy(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
const parentMsgID = failedAssistantMsg.info?.parentID
|
||||
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
if (!msgs || msgs.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let targetUserMsg: MessageData | null = null
|
||||
if (parentMsgID) {
|
||||
targetUserMsg = msgs.find((m) => m.info?.id === parentMsgID) ?? null
|
||||
}
|
||||
if (!targetUserMsg) {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].info?.role === "user") {
|
||||
targetUserMsg = msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUserMsg?.parts?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: targetUserMsg.info?.id ?? "" },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const textParts = targetUserMsg.parts
|
||||
.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => ({ type: "text" as const, text: p.text ?? "" }))
|
||||
|
||||
if (textParts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: textParts },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
const processingErrors = new Set<string>()
|
||||
let onAbortCallback: ((sessionID: string) => void) | null = null
|
||||
|
||||
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
|
||||
onAbortCallback = callback
|
||||
}
|
||||
|
||||
const isRecoverableError = (error: unknown): boolean => {
|
||||
return detectErrorType(error) !== null
|
||||
}
|
||||
|
||||
const handleSessionRecovery = async (info: MessageInfo): Promise<boolean> => {
|
||||
if (!info || info.role !== "assistant" || !info.error) return false
|
||||
|
||||
const errorType = detectErrorType(info.error)
|
||||
if (!errorType) return false
|
||||
|
||||
const sessionID = info.sessionID
|
||||
const assistantMsgID = info.id
|
||||
|
||||
if (!sessionID || !assistantMsgID) return false
|
||||
if (processingErrors.has(assistantMsgID)) return false
|
||||
processingErrors.add(assistantMsgID)
|
||||
|
||||
try {
|
||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
|
||||
if (onAbortCallback) {
|
||||
onAbortCallback(sessionID)
|
||||
}
|
||||
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
|
||||
const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID)
|
||||
if (!failedMsg) {
|
||||
return false
|
||||
}
|
||||
|
||||
const toastTitles: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Tool Crash Recovery",
|
||||
thinking_block_order: "Thinking Block Recovery",
|
||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||
empty_content_message: "Empty Message Recovery",
|
||||
}
|
||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Injecting cancelled tool results...",
|
||||
thinking_block_order: "Fixing message structure...",
|
||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
||||
empty_content_message: "Deleting empty message...",
|
||||
}
|
||||
const toastTitle = toastTitles[errorType]
|
||||
const toastMessage = toastMessages[errorType]
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: toastTitle,
|
||||
message: toastMessage,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
let success = false
|
||||
|
||||
if (errorType === "tool_result_missing") {
|
||||
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "thinking_block_order") {
|
||||
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory)
|
||||
} else if (errorType === "thinking_disabled_violation") {
|
||||
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "empty_content_message") {
|
||||
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory)
|
||||
}
|
||||
|
||||
return success
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
processingErrors.delete(assistantMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSessionRecovery,
|
||||
isRecoverableError,
|
||||
setOnAbortCallback,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { join } from "node:path"
|
||||
import { xdgData } from "xdg-basedir"
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
|
||||
export const META_TYPES = new Set(["step-start", "step-finish"])
|
||||
export const CONTENT_TYPES = new Set(["text", "tool", "tool_use", "tool_result"])
|
||||
@@ -1,281 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findMessagesWithOrphanThinking,
|
||||
findMessagesWithThinkingBlocks,
|
||||
injectTextPart,
|
||||
prependThinkingPart,
|
||||
stripThinkingParts,
|
||||
} from "./storage"
|
||||
import type { MessageData } from "./types"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
type RecoveryErrorType =
|
||||
| "tool_result_missing"
|
||||
| "thinking_block_order"
|
||||
| "thinking_disabled_violation"
|
||||
| "empty_content_message"
|
||||
| null
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
parentID?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
interface ToolUsePart {
|
||||
type: "tool_use"
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
id?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (!error) return ""
|
||||
if (typeof error === "string") return error.toLowerCase()
|
||||
const errorObj = error as {
|
||||
data?: { message?: string }
|
||||
message?: string
|
||||
error?: { message?: string }
|
||||
}
|
||||
return (errorObj.data?.message || errorObj.error?.message || errorObj.message || "").toLowerCase()
|
||||
}
|
||||
|
||||
function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (message.includes("tool_use") && message.includes("tool_result")) {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
|
||||
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
|
||||
return "thinking_disabled_violation"
|
||||
}
|
||||
|
||||
if (message.includes("non-empty content") || message.includes("must have non-empty content")) {
|
||||
return "empty_content_message"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractToolUseIds(parts: MessagePart[]): string[] {
|
||||
return parts.filter((p): p is ToolUsePart => p.type === "tool_use" && !!p.id).map((p) => p.id)
|
||||
}
|
||||
|
||||
async function recoverToolResultMissing(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const parts = failedAssistantMsg.parts || []
|
||||
const toolUseIds = extractToolUseIds(parts)
|
||||
|
||||
if (toolUseIds.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const toolResultParts = toolUseIds.map((id) => ({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: id,
|
||||
content: "Operation cancelled by user (ESC pressed)",
|
||||
}))
|
||||
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
// @ts-expect-error - SDK types may not include tool_result parts
|
||||
body: { parts: toolResultParts },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverThinkingBlockOrder(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData,
|
||||
_directory: string
|
||||
): Promise<boolean> {
|
||||
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
|
||||
|
||||
if (orphanMessages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let anySuccess = false
|
||||
for (const messageID of orphanMessages) {
|
||||
if (prependThinkingPart(sessionID, messageID)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
async function recoverThinkingDisabledViolation(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
|
||||
|
||||
if (messagesWithThinking.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let anySuccess = false
|
||||
for (const messageID of messagesWithThinking) {
|
||||
if (stripThinkingParts(messageID)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
async function recoverEmptyContentMessage(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
_directory: string
|
||||
): Promise<boolean> {
|
||||
const emptyMessageIDs = findEmptyMessages(sessionID)
|
||||
|
||||
if (emptyMessageIDs.length === 0) {
|
||||
const fallbackID = failedAssistantMsg.info?.id
|
||||
if (!fallbackID) return false
|
||||
return injectTextPart(sessionID, fallbackID, "(interrupted)")
|
||||
}
|
||||
|
||||
let anySuccess = false
|
||||
for (const messageID of emptyMessageIDs) {
|
||||
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
// NOTE: fallbackRevertStrategy was removed (2025-12-08)
|
||||
// Reason: Function was defined but never called - no error recovery paths used it.
|
||||
// All error types have dedicated recovery functions (recoverToolResultMissing,
|
||||
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
|
||||
|
||||
export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
const processingErrors = new Set<string>()
|
||||
let onAbortCallback: ((sessionID: string) => void) | null = null
|
||||
|
||||
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
|
||||
onAbortCallback = callback
|
||||
}
|
||||
|
||||
const isRecoverableError = (error: unknown): boolean => {
|
||||
return detectErrorType(error) !== null
|
||||
}
|
||||
|
||||
const handleSessionRecovery = async (info: MessageInfo): Promise<boolean> => {
|
||||
if (!info || info.role !== "assistant" || !info.error) return false
|
||||
|
||||
const errorType = detectErrorType(info.error)
|
||||
if (!errorType) return false
|
||||
|
||||
const sessionID = info.sessionID
|
||||
const assistantMsgID = info.id
|
||||
|
||||
if (!sessionID || !assistantMsgID) return false
|
||||
if (processingErrors.has(assistantMsgID)) return false
|
||||
processingErrors.add(assistantMsgID)
|
||||
|
||||
try {
|
||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
|
||||
if (onAbortCallback) {
|
||||
onAbortCallback(sessionID)
|
||||
}
|
||||
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
|
||||
const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID)
|
||||
if (!failedMsg) {
|
||||
return false
|
||||
}
|
||||
|
||||
const toastTitles: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Tool Crash Recovery",
|
||||
thinking_block_order: "Thinking Block Recovery",
|
||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||
empty_content_message: "Empty Message Recovery",
|
||||
}
|
||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Injecting cancelled tool results...",
|
||||
thinking_block_order: "Fixing message structure...",
|
||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
||||
empty_content_message: "Fixing empty message...",
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: toastTitles[errorType],
|
||||
message: toastMessages[errorType],
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
let success = false
|
||||
|
||||
if (errorType === "tool_result_missing") {
|
||||
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "thinking_block_order") {
|
||||
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory)
|
||||
} else if (errorType === "thinking_disabled_violation") {
|
||||
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "empty_content_message") {
|
||||
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory)
|
||||
}
|
||||
|
||||
return success
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
processingErrors.delete(assistantMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSessionRecovery,
|
||||
isRecoverableError,
|
||||
setOnAbortCallback,
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"
|
||||
import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"
|
||||
|
||||
export function generatePartId(): string {
|
||||
const timestamp = Date.now().toString(16)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `prt_${timestamp}${random}`
|
||||
}
|
||||
|
||||
export function getMessageDir(sessionID: string): string {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return ""
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) {
|
||||
return directPath
|
||||
}
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) {
|
||||
return sessionPath
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function readMessages(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))
|
||||
}
|
||||
|
||||
export function readParts(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
|
||||
}
|
||||
|
||||
export function hasContent(part: StoredPart): boolean {
|
||||
if (THINKING_TYPES.has(part.type)) return false
|
||||
if (META_TYPES.has(part.type)) return false
|
||||
|
||||
if (part.type === "text") {
|
||||
const textPart = part as StoredTextPart
|
||||
return !!(textPart.text?.trim())
|
||||
}
|
||||
|
||||
if (part.type === "tool" || part.type === "tool_use") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (part.type === "tool_result") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function messageHasContent(messageID: string): boolean {
|
||||
const parts = readParts(messageID)
|
||||
return parts.some(hasContent)
|
||||
}
|
||||
|
||||
export function injectTextPart(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: StoredTextPart = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function findEmptyMessages(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const emptyIds: string[] = []
|
||||
|
||||
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
|
||||
|
||||
if (!messageHasContent(msg.id)) {
|
||||
emptyIds.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyIds
|
||||
}
|
||||
|
||||
export function findFirstEmptyMessage(sessionID: string): string | null {
|
||||
const emptyIds = findEmptyMessages(sessionID)
|
||||
return emptyIds.length > 0 ? emptyIds[0] : null
|
||||
}
|
||||
|
||||
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
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 = readParts(msg.id)
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
if (hasThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
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 = readParts(msg.id)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const firstPart = sortedParts[0]
|
||||
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
||||
|
||||
if (hasThinking && !firstIsThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
const partId = `prt_0000000000_thinking`
|
||||
const part = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function stripThinkingParts(messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return false
|
||||
|
||||
let anyRemoved = false
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const filePath = join(partDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredPart
|
||||
if (THINKING_TYPES.has(part.type)) {
|
||||
unlinkSync(filePath)
|
||||
anyRemoved = true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return anyRemoved
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
export type ThinkingPartType = "thinking" | "redacted_thinking" | "reasoning"
|
||||
export type MetaPartType = "step-start" | "step-finish"
|
||||
export type ContentPartType = "text" | "tool" | "tool_use" | "tool_result"
|
||||
|
||||
export interface StoredMessageMeta {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user" | "assistant"
|
||||
parentID?: string
|
||||
time?: {
|
||||
created: number
|
||||
completed?: number
|
||||
}
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export interface StoredTextPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
ignored?: boolean
|
||||
}
|
||||
|
||||
export interface StoredToolPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: {
|
||||
status: "pending" | "running" | "completed" | "error"
|
||||
input: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredReasoningPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "reasoning"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface StoredStepPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "step-start" | "step-finish"
|
||||
}
|
||||
|
||||
export type StoredPart = StoredTextPart | StoredToolPart | StoredReasoningPart | StoredStepPart | {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MessageData {
|
||||
info?: {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
parentID?: string
|
||||
error?: unknown
|
||||
}
|
||||
parts?: Array<{
|
||||
type: string
|
||||
id?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
callID?: string
|
||||
}>
|
||||
}
|
||||
@@ -7,13 +7,18 @@ interface Todo {
|
||||
id: string
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO ENFORCEMENT]
|
||||
|
||||
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
||||
Your todo list is NOT complete. There are still incomplete tasks remaining.
|
||||
|
||||
- Proceed without asking for permission
|
||||
- Mark each task complete when finished
|
||||
- Do not stop until all tasks are done`
|
||||
CRITICAL INSTRUCTION:
|
||||
- You MUST NOT stop working until ALL todos are marked as completed
|
||||
- Continue working on the next pending task immediately
|
||||
- Work honestly and diligently to finish every task
|
||||
- Do NOT ask for permission to continue - just proceed with the work
|
||||
- Mark each task as completed as soon as you finish it
|
||||
|
||||
Resume your work NOW.`
|
||||
|
||||
function detectInterrupt(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
@@ -108,7 +113,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${incomplete.length}/${todos.length} tasks remaining]`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
167
src/index.ts
167
src/index.ts
@@ -1,70 +1,57 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { createBuiltinAgents } from "./agents";
|
||||
import {
|
||||
createTodoContinuationEnforcer,
|
||||
createContextWindowMonitorHook,
|
||||
createSessionRecoveryHook,
|
||||
createCommentCheckerHooks,
|
||||
createGrepOutputTruncatorHook,
|
||||
createDirectoryAgentsInjectorHook,
|
||||
createEmptyTaskResponseDetectorHook,
|
||||
} from "./hooks";
|
||||
import { updateTerminalTitle } from "./features/terminal";
|
||||
import { builtinTools } from "./tools";
|
||||
import { createBuiltinMcps } from "./mcp";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { createBuiltinAgents } from "./agents"
|
||||
import { createTodoContinuationEnforcer, createContextWindowMonitorHook, createSessionRecoveryHook, createCommentCheckerHooks } from "./hooks"
|
||||
import { updateTerminalTitle } from "./features/terminal"
|
||||
import { builtinTools } from "./tools"
|
||||
import { createBuiltinMcps } from "./mcp"
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
|
||||
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
|
||||
const configPaths = [
|
||||
path.join(directory, "oh-my-opencode.json"),
|
||||
path.join(directory, ".oh-my-opencode.json"),
|
||||
];
|
||||
]
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = JSON.parse(content);
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const rawConfig = JSON.parse(content)
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[oh-my-opencode] Config validation error in ${configPath}:`,
|
||||
);
|
||||
console.error(`[oh-my-opencode] Config validation error in ${configPath}:`)
|
||||
for (const issue of result.error.issues) {
|
||||
console.error(` - ${issue.path.join(".")}: ${issue.message}`);
|
||||
console.error(` - ${issue.path.join(".")}: ${issue.message}`)
|
||||
}
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
|
||||
return result.data;
|
||||
return result.data
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors, use defaults
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
|
||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
|
||||
const sessionRecovery = createSessionRecoveryHook(ctx);
|
||||
const commentChecker = createCommentCheckerHooks();
|
||||
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
|
||||
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
|
||||
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx)
|
||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx)
|
||||
const sessionRecovery = createSessionRecoveryHook(ctx)
|
||||
const commentChecker = createCommentCheckerHooks()
|
||||
|
||||
updateTerminalTitle({ sessionId: "main" });
|
||||
updateTerminalTitle({ sessionId: "main" })
|
||||
|
||||
const pluginConfig = loadPluginConfig(ctx.directory);
|
||||
const pluginConfig = loadPluginConfig(ctx.directory)
|
||||
|
||||
let mainSessionID: string | undefined;
|
||||
let currentSessionID: string | undefined;
|
||||
let currentSessionTitle: string | undefined;
|
||||
let mainSessionID: string | undefined
|
||||
let currentSessionID: string | undefined
|
||||
let currentSessionTitle: string | undefined
|
||||
|
||||
return {
|
||||
tool: builtinTools,
|
||||
@@ -72,79 +59,75 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
config: async (config) => {
|
||||
const agents = createBuiltinAgents(
|
||||
pluginConfig.disabled_agents,
|
||||
pluginConfig.agents,
|
||||
);
|
||||
pluginConfig.agents
|
||||
)
|
||||
|
||||
config.agent = {
|
||||
...config.agent,
|
||||
...agents,
|
||||
};
|
||||
}
|
||||
config.tools = {
|
||||
...config.tools,
|
||||
};
|
||||
grep: false,
|
||||
}
|
||||
config.mcp = {
|
||||
...config.mcp,
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
event: async (input) => {
|
||||
await todoContinuationEnforcer(input);
|
||||
await contextWindowMonitor.event(input);
|
||||
await directoryAgentsInjector.event(input);
|
||||
await todoContinuationEnforcer(input)
|
||||
await contextWindowMonitor.event(input)
|
||||
|
||||
const { event } = input;
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
const { event } = input
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.created") {
|
||||
const sessionInfo = props?.info as
|
||||
| { id?: string; title?: string; parentID?: string }
|
||||
| undefined;
|
||||
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined
|
||||
if (!sessionInfo?.parentID) {
|
||||
mainSessionID = sessionInfo?.id;
|
||||
currentSessionID = sessionInfo?.id;
|
||||
currentSessionTitle = sessionInfo?.title;
|
||||
mainSessionID = sessionInfo?.id
|
||||
currentSessionID = sessionInfo?.id
|
||||
currentSessionTitle = sessionInfo?.title
|
||||
updateTerminalTitle({
|
||||
sessionId: currentSessionID || "main",
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.updated") {
|
||||
const sessionInfo = props?.info as
|
||||
| { id?: string; title?: string; parentID?: string }
|
||||
| undefined;
|
||||
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined
|
||||
if (!sessionInfo?.parentID) {
|
||||
currentSessionID = sessionInfo?.id;
|
||||
currentSessionTitle = sessionInfo?.title;
|
||||
currentSessionID = sessionInfo?.id
|
||||
currentSessionTitle = sessionInfo?.title
|
||||
updateTerminalTitle({
|
||||
sessionId: currentSessionID || "main",
|
||||
status: "processing",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id === mainSessionID) {
|
||||
mainSessionID = undefined;
|
||||
currentSessionID = undefined;
|
||||
currentSessionTitle = undefined;
|
||||
mainSessionID = undefined
|
||||
currentSessionID = undefined
|
||||
currentSessionTitle = undefined
|
||||
updateTerminalTitle({
|
||||
sessionId: "main",
|
||||
status: "idle",
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const error = props?.error;
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const error = props?.error
|
||||
|
||||
if (sessionRecovery.isRecoverableError(error)) {
|
||||
const messageInfo = {
|
||||
@@ -152,18 +135,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
role: "assistant" as const,
|
||||
sessionID,
|
||||
error,
|
||||
};
|
||||
const recovered =
|
||||
await sessionRecovery.handleSessionRecovery(messageInfo);
|
||||
}
|
||||
const recovered = await sessionRecovery.handleSessionRecovery(messageInfo)
|
||||
|
||||
if (recovered && sessionID && sessionID === mainSessionID) {
|
||||
await ctx.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: ctx.directory },
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,25 +153,25 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
status: "error",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID && sessionID === mainSessionID) {
|
||||
updateTerminalTitle({
|
||||
sessionId: sessionID,
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (input, output) => {
|
||||
await commentChecker["tool.execute.before"](input, output);
|
||||
await commentChecker["tool.execute.before"](input, output)
|
||||
|
||||
if (input.sessionID === mainSessionID) {
|
||||
updateTerminalTitle({
|
||||
@@ -200,16 +180,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
currentTool: input.tool,
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
});
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
await grepOutputTruncator["tool.execute.after"](input, output);
|
||||
await contextWindowMonitor["tool.execute.after"](input, output);
|
||||
await commentChecker["tool.execute.after"](input, output);
|
||||
await directoryAgentsInjector["tool.execute.after"](input, output);
|
||||
await emptyTaskResponseDetector["tool.execute.after"](input, output);
|
||||
await contextWindowMonitor["tool.execute.after"](input, output)
|
||||
await commentChecker["tool.execute.after"](input, output)
|
||||
|
||||
if (input.sessionID === mainSessionID) {
|
||||
updateTerminalTitle({
|
||||
@@ -217,13 +194,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
});
|
||||
})
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default OhMyOpenCodePlugin;
|
||||
export default OhMyOpenCodePlugin
|
||||
|
||||
export type {
|
||||
OhMyOpenCodeConfig,
|
||||
@@ -231,4 +208,4 @@ export type {
|
||||
AgentOverrideConfig,
|
||||
AgentOverrides,
|
||||
McpName,
|
||||
} from "./config";
|
||||
} from "./config"
|
||||
|
||||
@@ -20,3 +20,5 @@ export function createBuiltinMcps(disabledMcps: McpName[] = []) {
|
||||
|
||||
return mcps
|
||||
}
|
||||
|
||||
export const builtinMcps = allBuiltinMcps
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { spawn } from "bun"
|
||||
import { existsSync } from "fs"
|
||||
import {
|
||||
getSgCliPath,
|
||||
setSgCliPath,
|
||||
findSgCliPathSync,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
DEFAULT_MAX_MATCHES,
|
||||
} from "./constants"
|
||||
import { getSgCliPath, setSgCliPath, findSgCliPathSync } from "./constants"
|
||||
import { ensureAstGrepBinary } from "./downloader"
|
||||
import type { CliMatch, CliLanguage, SgResult } from "./types"
|
||||
import type { CliMatch, CliLanguage } from "./types"
|
||||
|
||||
export interface RunOptions {
|
||||
pattern: string
|
||||
@@ -61,7 +54,26 @@ export function startBackgroundInit(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
interface SpawnResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
async function spawnSg(cliPath: string, args: string[]): Promise<SpawnResult> {
|
||||
const proc = spawn([cliPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<CliMatch[]> {
|
||||
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
||||
|
||||
if (options.rewrite) {
|
||||
@@ -93,129 +105,55 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = DEFAULT_TIMEOUT_MS
|
||||
|
||||
const proc = spawn([cliPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error(`Search timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
proc.exited.then(() => clearTimeout(id))
|
||||
})
|
||||
|
||||
let stdout: string
|
||||
let stderr: string
|
||||
let exitCode: number
|
||||
|
||||
let result: SpawnResult
|
||||
try {
|
||||
stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||
stderr = await new Response(proc.stderr).text()
|
||||
exitCode = await proc.exited
|
||||
result = await spawnSg(cliPath, args)
|
||||
} catch (e) {
|
||||
const error = e as Error
|
||||
if (error.message?.includes("timeout")) {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "timeout",
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
const nodeError = e as NodeJS.ErrnoException
|
||||
const error = e as NodeJS.ErrnoException
|
||||
if (
|
||||
nodeError.code === "ENOENT" ||
|
||||
nodeError.message?.includes("ENOENT") ||
|
||||
nodeError.message?.includes("not found")
|
||||
error.code === "ENOENT" ||
|
||||
error.message?.includes("ENOENT") ||
|
||||
error.message?.includes("not found")
|
||||
) {
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
return runSg(options)
|
||||
result = await spawnSg(downloadedPath, args)
|
||||
} else {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error:
|
||||
`ast-grep CLI binary not found.\n\n` +
|
||||
throw new Error(
|
||||
`ast-grep CLI binary not found.\n\n` +
|
||||
`Auto-download failed. Manual install options:\n` +
|
||||
` bun add -D @ast-grep/cli\n` +
|
||||
` cargo install ast-grep --locked\n` +
|
||||
` brew install ast-grep`,
|
||||
}
|
||||
` brew install ast-grep`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error: `Failed to spawn ast-grep: ${error.message}`,
|
||||
} else {
|
||||
throw new Error(`Failed to spawn ast-grep: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const { stdout, stderr, exitCode } = result
|
||||
|
||||
if (exitCode !== 0 && stdout.trim() === "") {
|
||||
if (stderr.includes("No files found")) {
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
return []
|
||||
}
|
||||
if (stderr.trim()) {
|
||||
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
|
||||
throw new Error(stderr.trim())
|
||||
}
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
return []
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
return []
|
||||
}
|
||||
|
||||
const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||
const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||
|
||||
let matches: CliMatch[] = []
|
||||
try {
|
||||
matches = JSON.parse(outputToProcess) as CliMatch[]
|
||||
return JSON.parse(stdout) as CliMatch[]
|
||||
} catch {
|
||||
if (outputTruncated) {
|
||||
try {
|
||||
const lastValidIndex = outputToProcess.lastIndexOf("}")
|
||||
if (lastValidIndex > 0) {
|
||||
const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
|
||||
if (bracketIndex > 0) {
|
||||
const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
|
||||
matches = JSON.parse(truncatedJson) as CliMatch[]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "max_output_bytes",
|
||||
error: "Output too large and could not be parsed",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
|
||||
const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
|
||||
|
||||
return {
|
||||
matches: finalMatches,
|
||||
totalMatches,
|
||||
truncated: outputTruncated || matchesTruncated,
|
||||
truncatedReason: outputTruncated ? "max_output_bytes" : matchesTruncated ? "max_matches" : undefined,
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { existsSync } from "fs"
|
||||
import { getCachedBinaryPath } from "./downloader"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function isValidBinary(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).size > 10000
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatformPackageName(): string | null {
|
||||
const platform = process.platform as Platform
|
||||
const arch = process.arch
|
||||
@@ -33,18 +25,13 @@ function getPlatformPackageName(): string | null {
|
||||
export function findSgCliPathSync(): string | null {
|
||||
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath && isValidBinary(cachedPath)) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@ast-grep/cli/package.json")
|
||||
const cliDir = dirname(cliPkgPath)
|
||||
const sgPath = join(cliDir, binaryName)
|
||||
|
||||
if (existsSync(sgPath) && isValidBinary(sgPath)) {
|
||||
if (existsSync(sgPath)) {
|
||||
return sgPath
|
||||
}
|
||||
} catch {
|
||||
@@ -60,7 +47,7 @@ export function findSgCliPathSync(): string | null {
|
||||
const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
||||
const binaryPath = join(pkgDir, astGrepName)
|
||||
|
||||
if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
|
||||
if (existsSync(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
@@ -71,12 +58,17 @@ export function findSgCliPathSync(): string | null {
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path) && isValidBinary(path)) {
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -135,10 +127,6 @@ export const CLI_LANGUAGES = [
|
||||
export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const
|
||||
|
||||
// Language to file extensions mapping
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
export const DEFAULT_MAX_MATCHES = 500
|
||||
|
||||
export const LANG_EXTENSIONS: Record<string, string[]> = {
|
||||
bash: [".bash", ".sh", ".zsh", ".bats"],
|
||||
c: [".c", ".h"],
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { formatSearchResult, formatReplaceResult } from "./utils"
|
||||
import type { CliLanguage } from "./types"
|
||||
import { analyzeCode, transformCode, getRootInfo } from "./napi"
|
||||
import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult } from "./utils"
|
||||
import type { CliLanguage, NapiLanguage } from "./types"
|
||||
|
||||
function showOutputToUser(context: unknown, output: string): void {
|
||||
const ctx = context as { metadata?: (input: { metadata: { output: string } }) => void }
|
||||
@@ -14,12 +15,10 @@ function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
|
||||
|
||||
if (lang === "python") {
|
||||
if (src.startsWith("class ") && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
return `💡 Hint: Python class patterns need body. Try "class $NAME" or include body with $$$BODY`
|
||||
}
|
||||
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
return `💡 Hint: Python function patterns need body. Try "def $FUNC($$$):\\n $$$BODY"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +47,7 @@ export const ast_grep_search = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await runSg({
|
||||
const matches = await runSg({
|
||||
pattern: args.pattern,
|
||||
lang: args.lang as CliLanguage,
|
||||
paths: args.paths,
|
||||
@@ -56,9 +55,9 @@ export const ast_grep_search = tool({
|
||||
context: args.context,
|
||||
})
|
||||
|
||||
let output = formatSearchResult(result)
|
||||
let output = formatSearchResult(matches)
|
||||
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
if (matches.length === 0) {
|
||||
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
|
||||
if (hint) {
|
||||
output += `\n\n${hint}`
|
||||
@@ -90,7 +89,7 @@ export const ast_grep_replace = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await runSg({
|
||||
const matches = await runSg({
|
||||
pattern: args.pattern,
|
||||
rewrite: args.rewrite,
|
||||
lang: args.lang as CliLanguage,
|
||||
@@ -98,7 +97,7 @@ export const ast_grep_replace = tool({
|
||||
globs: args.globs,
|
||||
updateAll: args.dryRun === false,
|
||||
})
|
||||
const output = formatReplaceResult(result, args.dryRun !== false)
|
||||
const output = formatReplaceResult(matches, args.dryRun !== false)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
@@ -109,4 +108,83 @@ export const ast_grep_replace = tool({
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_languages = tool({
|
||||
description:
|
||||
"List all supported languages for ast-grep tools with their file extensions. " +
|
||||
"Use this to determine valid language options.",
|
||||
args: {},
|
||||
execute: async (_args, context) => {
|
||||
const lines: string[] = [`Supported Languages (${CLI_LANGUAGES.length}):`]
|
||||
for (const lang of CLI_LANGUAGES) {
|
||||
const exts = LANG_EXTENSIONS[lang]?.join(", ") || ""
|
||||
lines.push(` ${lang}: ${exts}`)
|
||||
}
|
||||
lines.push("")
|
||||
lines.push(`NAPI (in-memory) languages: ${NAPI_LANGUAGES.join(", ")}`)
|
||||
const output = lines.join("\n")
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_analyze = tool({
|
||||
description:
|
||||
"Parse code and extract AST structure with pattern matching (in-memory). " +
|
||||
"Extracts meta-variable bindings. Only for: html, javascript, tsx, css, typescript. " +
|
||||
"Use for detailed code analysis without file I/O.",
|
||||
args: {
|
||||
code: tool.schema.string().describe("Source code to analyze"),
|
||||
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language (html, javascript, tsx, css, typescript)"),
|
||||
pattern: tool.schema.string().optional().describe("Pattern to find (omit for root structure)"),
|
||||
extractMetaVars: tool.schema.boolean().optional().describe("Extract meta-variable bindings (default: true)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
if (!args.pattern) {
|
||||
const info = getRootInfo(args.code, args.lang as NapiLanguage)
|
||||
const output = `Root kind: ${info.kind}\nChildren: ${info.childCount}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
|
||||
const results = analyzeCode(args.code, args.lang as NapiLanguage, args.pattern, args.extractMetaVars !== false)
|
||||
const output = formatAnalyzeResult(results, args.extractMetaVars !== false)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_transform = tool({
|
||||
description:
|
||||
"Transform code in-memory using AST-aware rewriting. " +
|
||||
"Only for: html, javascript, tsx, css, typescript. " +
|
||||
"Returns transformed code without writing to filesystem.",
|
||||
args: {
|
||||
code: tool.schema.string().describe("Source code to transform"),
|
||||
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language"),
|
||||
pattern: tool.schema.string().describe("Pattern to match"),
|
||||
rewrite: tool.schema.string().describe("Replacement (can use $VAR from pattern)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const { transformed, editCount } = transformCode(
|
||||
args.code,
|
||||
args.lang as NapiLanguage,
|
||||
args.pattern,
|
||||
args.rewrite
|
||||
)
|
||||
const output = formatTransformResult(args.code, transformed, editCount)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -51,11 +51,3 @@ export interface TransformResult {
|
||||
transformed: string
|
||||
editCount: number
|
||||
}
|
||||
|
||||
export interface SgResult {
|
||||
matches: CliMatch[]
|
||||
totalMatches: number
|
||||
truncated: boolean
|
||||
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import type { CliMatch, AnalyzeResult, SgResult } from "./types"
|
||||
import type { CliMatch, AnalyzeResult } from "./types"
|
||||
|
||||
export function formatSearchResult(result: SgResult): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
export function formatSearchResult(matches: CliMatch[]): string {
|
||||
if (matches.length === 0) {
|
||||
return "No matches found"
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
const lines: string[] = [`Found ${matches.length} match(es):\n`]
|
||||
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`⚠️ Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
for (const match of matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.lines.trim()}`)
|
||||
@@ -32,30 +17,15 @@ export function formatSearchResult(result: SgResult): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
export function formatReplaceResult(matches: CliMatch[], isDryRun: boolean): string {
|
||||
if (matches.length === 0) {
|
||||
return "No matches found to replace"
|
||||
}
|
||||
|
||||
const prefix = isDryRun ? "[DRY RUN] " : ""
|
||||
const lines: string[] = []
|
||||
const lines: string[] = [`${prefix}${matches.length} replacement(s):\n`]
|
||||
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`⚠️ Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`${prefix}${result.matches.length} replacement(s):\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
for (const match of matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.text}`)
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { spawn } from "bun"
|
||||
import {
|
||||
resolveGrepCli,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_LIMIT,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
RG_FILES_FLAGS,
|
||||
} from "./constants"
|
||||
import type { GlobOptions, GlobResult, FileMatch } from "./types"
|
||||
import { stat } from "node:fs/promises"
|
||||
|
||||
function buildRgArgs(options: GlobOptions): string[] {
|
||||
const args: string[] = [
|
||||
...RG_FILES_FLAGS,
|
||||
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
|
||||
]
|
||||
|
||||
if (options.hidden) args.push("--hidden")
|
||||
if (options.noIgnore) args.push("--no-ignore")
|
||||
|
||||
args.push(`--glob=${options.pattern}`)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function buildFindArgs(options: GlobOptions): string[] {
|
||||
const args: string[] = ["."]
|
||||
|
||||
const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)
|
||||
args.push("-maxdepth", String(maxDepth))
|
||||
|
||||
args.push("-type", "f")
|
||||
args.push("-name", options.pattern)
|
||||
|
||||
if (!options.hidden) {
|
||||
args.push("-not", "-path", "*/.*")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function getFileMtime(filePath: string): Promise<number> {
|
||||
try {
|
||||
const stats = await stat(filePath)
|
||||
return stats.mtime.getTime()
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRgFiles(options: GlobOptions): Promise<GlobResult> {
|
||||
const cli = resolveGrepCli()
|
||||
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||
const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT)
|
||||
|
||||
const isRg = cli.backend === "rg"
|
||||
const args = isRg ? buildRgArgs(options) : buildFindArgs(options)
|
||||
|
||||
const paths = options.paths?.length ? options.paths : ["."]
|
||||
if (isRg) {
|
||||
args.push(...paths)
|
||||
}
|
||||
|
||||
const cwd = paths[0] || "."
|
||||
|
||||
const proc = spawn([cli.path, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: isRg ? undefined : cwd,
|
||||
})
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error(`Glob search timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
proc.exited.then(() => clearTimeout(id))
|
||||
})
|
||||
|
||||
try {
|
||||
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode > 1 && stderr.trim()) {
|
||||
return {
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
truncated: false,
|
||||
error: stderr.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
const truncatedOutput = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||
const outputToProcess = truncatedOutput ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||
|
||||
const lines = outputToProcess.trim().split("\n").filter(Boolean)
|
||||
|
||||
const files: FileMatch[] = []
|
||||
let truncated = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
|
||||
const filePath = isRg ? line : `${cwd}/${line}`
|
||||
const mtime = await getFileMtime(filePath)
|
||||
files.push({ path: filePath, mtime })
|
||||
}
|
||||
|
||||
files.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
return {
|
||||
files,
|
||||
totalFiles: files.length,
|
||||
truncated: truncated || truncatedOutput,
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
truncated: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export { resolveGrepCli, type GrepBackend } from "../grep/constants"
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||
export const DEFAULT_LIMIT = 100
|
||||
export const DEFAULT_MAX_DEPTH = 20
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export const RG_FILES_FLAGS = [
|
||||
"--files",
|
||||
"--color=never",
|
||||
"--glob=!.git/*",
|
||||
] as const
|
||||
@@ -1,3 +0,0 @@
|
||||
import { glob } from "./tools"
|
||||
|
||||
export { glob }
|
||||
@@ -1,36 +0,0 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { runRgFiles } from "./cli"
|
||||
import { formatGlobResult } from "./utils"
|
||||
|
||||
export const glob = tool({
|
||||
description:
|
||||
"Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " +
|
||||
"Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " +
|
||||
"Returns matching file paths sorted by modification time. " +
|
||||
"Use this tool when you need to find files by name patterns.",
|
||||
args: {
|
||||
pattern: tool.schema.string().describe("The glob pattern to match files against"),
|
||||
path: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The directory to search in. If not specified, the current working directory will be used. " +
|
||||
"IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - " +
|
||||
"simply omit it for the default behavior. Must be a valid directory path if provided."
|
||||
),
|
||||
},
|
||||
execute: async (args) => {
|
||||
try {
|
||||
const paths = args.path ? [args.path] : undefined
|
||||
|
||||
const result = await runRgFiles({
|
||||
pattern: args.pattern,
|
||||
paths,
|
||||
})
|
||||
|
||||
return formatGlobResult(result)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
export interface FileMatch {
|
||||
path: string
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export interface GlobResult {
|
||||
files: FileMatch[]
|
||||
totalFiles: number
|
||||
truncated: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface GlobOptions {
|
||||
pattern: string
|
||||
paths?: string[]
|
||||
hidden?: boolean
|
||||
noIgnore?: boolean
|
||||
maxDepth?: number
|
||||
timeout?: number
|
||||
limit?: number
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { GlobResult } from "./types"
|
||||
|
||||
export function formatGlobResult(result: GlobResult): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.files.length === 0) {
|
||||
return "No files found"
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(`Found ${result.totalFiles} file(s)`)
|
||||
lines.push("")
|
||||
|
||||
for (const file of result.files) {
|
||||
lines.push(file.path)
|
||||
}
|
||||
|
||||
if (result.truncated) {
|
||||
lines.push("")
|
||||
lines.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { grep } from "./tools"
|
||||
|
||||
export { grep }
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
ast_grep_replace,
|
||||
} from "./ast-grep"
|
||||
|
||||
import { grep } from "./grep"
|
||||
import { glob } from "./glob"
|
||||
import { safe_grep } from "./safe-grep"
|
||||
|
||||
export const builtinTools = {
|
||||
lsp_hover,
|
||||
@@ -34,6 +33,5 @@ export const builtinTools = {
|
||||
lsp_code_action_resolve,
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
grep,
|
||||
glob,
|
||||
safe_grep,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFileSync } from "fs"
|
||||
import { extname, resolve } from "path"
|
||||
import type { ResolvedServer } from "./config"
|
||||
import { getLanguageId } from "./config"
|
||||
import type { Diagnostic } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
client: LSPClient
|
||||
@@ -156,7 +155,6 @@ export class LSPClient {
|
||||
private openedFiles = new Set<string>()
|
||||
private stderrBuffer: string[] = []
|
||||
private processExited = false
|
||||
private diagnosticsStore = new Map<string, Diagnostic[]>()
|
||||
|
||||
constructor(
|
||||
private root: string,
|
||||
@@ -292,11 +290,7 @@ export class LSPClient {
|
||||
try {
|
||||
const msg = JSON.parse(content)
|
||||
|
||||
if ("method" in msg && !("id" in msg)) {
|
||||
if (msg.method === "textDocument/publishDiagnostics" && msg.params?.uri) {
|
||||
this.diagnosticsStore.set(msg.params.uri, msg.params.diagnostics ?? [])
|
||||
}
|
||||
} else if ("id" in msg && "method" in msg) {
|
||||
if ("id" in msg && "method" in msg) {
|
||||
this.handleServerRequest(msg.id, msg.method, msg.params)
|
||||
} else if ("id" in msg && this.pending.has(msg.id)) {
|
||||
const handler = this.pending.get(msg.id)!
|
||||
@@ -353,14 +347,9 @@ export class LSPClient {
|
||||
this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`)
|
||||
}
|
||||
|
||||
private handleServerRequest(id: number | string, method: string, params?: unknown): void {
|
||||
private handleServerRequest(id: number | string, method: string, _params?: unknown): void {
|
||||
if (method === "workspace/configuration") {
|
||||
const items = (params as { items?: Array<{ section?: string }> })?.items ?? []
|
||||
const result = items.map((item) => {
|
||||
if (item.section === "json") return { validate: { enable: true } }
|
||||
return {}
|
||||
})
|
||||
this.respond(id, result)
|
||||
this.respond(id, [{}])
|
||||
} else if (method === "client/registerCapability") {
|
||||
this.respond(id, null)
|
||||
} else if (method === "window/workDoneProgress/create") {
|
||||
@@ -423,9 +412,7 @@ export class LSPClient {
|
||||
...this.server.initialization,
|
||||
})
|
||||
this.notify("initialized")
|
||||
this.notify("workspace/didChangeConfiguration", {
|
||||
settings: { json: { validate: { enable: true } } },
|
||||
})
|
||||
this.notify("workspace/didChangeConfiguration", { settings: {} })
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
@@ -490,23 +477,13 @@ export class LSPClient {
|
||||
return this.send("workspace/symbol", { query })
|
||||
}
|
||||
|
||||
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
|
||||
async diagnostics(filePath: string): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
const uri = `file://${absPath}`
|
||||
await this.openFile(absPath)
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
try {
|
||||
const result = await this.send("textDocument/diagnostic", {
|
||||
textDocument: { uri },
|
||||
})
|
||||
if (result && typeof result === "object" && "items" in result) {
|
||||
return result as { items: Diagnostic[] }
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return { items: this.diagnosticsStore.get(uri) ?? [] }
|
||||
return this.send("textDocument/diagnostic", {
|
||||
textDocument: { uri: `file://${absPath}` },
|
||||
})
|
||||
}
|
||||
|
||||
async prepareRename(filePath: string, line: number, character: number): Promise<unknown> {
|
||||
@@ -568,6 +545,5 @@ export class LSPClient {
|
||||
this.proc?.kill()
|
||||
this.proc = null
|
||||
this.processExited = true
|
||||
this.diagnosticsStore.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,6 @@ export const SEVERITY_MAP: Record<number, string> = {
|
||||
4: "hint",
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_REFERENCES = 200
|
||||
export const DEFAULT_MAX_SYMBOLS = 200
|
||||
export const DEFAULT_MAX_DIAGNOSTICS = 200
|
||||
|
||||
export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
|
||||
typescript: {
|
||||
command: ["typescript-language-server", "--stdio"],
|
||||
@@ -175,18 +171,4 @@ export const EXT_TO_LANG: Record<string, string> = {
|
||||
".astro": "astro",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".json": "json",
|
||||
".jsonc": "jsonc",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".less": "less",
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".zsh": "shellscript",
|
||||
".fish": "fish",
|
||||
".md": "markdown",
|
||||
".tf": "terraform",
|
||||
".tfvars": "terraform",
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { getAllServers } from "./config"
|
||||
import {
|
||||
DEFAULT_MAX_REFERENCES,
|
||||
DEFAULT_MAX_SYMBOLS,
|
||||
DEFAULT_MAX_DIAGNOSTICS,
|
||||
} from "./constants"
|
||||
import {
|
||||
withLspClient,
|
||||
formatHoverResult,
|
||||
@@ -14,6 +9,7 @@ import {
|
||||
formatDiagnostic,
|
||||
filterDiagnosticsBySeverity,
|
||||
formatPrepareRenameResult,
|
||||
formatWorkspaceEdit,
|
||||
formatCodeActions,
|
||||
applyWorkspaceEdit,
|
||||
formatApplyResult,
|
||||
@@ -116,14 +112,7 @@ export const lsp_find_references = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const truncated = total > DEFAULT_MAX_REFERENCES
|
||||
const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result
|
||||
const lines = limited.map(formatLocation)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
const output = result.map(formatLocation).join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
@@ -149,21 +138,13 @@ export const lsp_document_symbols = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const truncated = total > DEFAULT_MAX_SYMBOLS
|
||||
const limited = truncated ? result.slice(0, DEFAULT_MAX_SYMBOLS) : result
|
||||
|
||||
const lines: string[] = []
|
||||
if (truncated) {
|
||||
lines.push(`Found ${total} symbols (showing first ${DEFAULT_MAX_SYMBOLS}):`)
|
||||
}
|
||||
|
||||
if ("range" in limited[0]) {
|
||||
lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)))
|
||||
let output: string
|
||||
if ("range" in result[0]) {
|
||||
output = (result as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)).join("\n")
|
||||
} else {
|
||||
lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo))
|
||||
output = (result as SymbolInfo[]).map(formatSymbolInfo).join("\n")
|
||||
}
|
||||
return lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
@@ -190,15 +171,8 @@ export const lsp_workspace_symbols = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS)
|
||||
const truncated = total > limit
|
||||
const limited = result.slice(0, limit)
|
||||
const lines = limited.map(formatSymbolInfo)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} symbols (showing first ${limit}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
const limited = args.limit ? result.slice(0, args.limit) : result
|
||||
const output = limited.map(formatSymbolInfo).join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
@@ -239,14 +213,7 @@ export const lsp_diagnostics = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const total = diagnostics.length
|
||||
const truncated = total > DEFAULT_MAX_DIAGNOSTICS
|
||||
const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics
|
||||
const lines = limited.map(formatDiagnostic)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
const output = diagnostics.map(formatDiagnostic).join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
Diagnostic,
|
||||
PrepareRenameResult,
|
||||
PrepareRenameDefaultBehavior,
|
||||
Range,
|
||||
WorkspaceEdit,
|
||||
TextEdit,
|
||||
CodeAction,
|
||||
@@ -166,35 +165,21 @@ export function filterDiagnosticsBySeverity(
|
||||
}
|
||||
|
||||
export function formatPrepareRenameResult(
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | 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"
|
||||
}
|
||||
|
||||
// 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}`
|
||||
}
|
||||
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 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"
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
|
||||
}
|
||||
|
||||
export function formatTextEdit(edit: TextEdit): string {
|
||||
|
||||
3
src/tools/safe-grep/index.ts
Normal file
3
src/tools/safe-grep/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { safe_grep } from "./tools"
|
||||
|
||||
export { safe_grep }
|
||||
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { runRg } from "./cli"
|
||||
import { formatGrepResult } from "./utils"
|
||||
|
||||
export const grep = tool({
|
||||
export const safe_grep = tool({
|
||||
description:
|
||||
"Fast content search tool with safety limits (60s timeout, 10MB output). " +
|
||||
"Searches file contents using regular expressions. " +
|
||||
@@ -1,4 +0,0 @@
|
||||
## Root Level Rules
|
||||
|
||||
- Root rule 1
|
||||
- Root rule 2
|
||||
@@ -1,3 +0,0 @@
|
||||
export const config = {
|
||||
strict: true
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
## Nested Level Rules
|
||||
|
||||
- Nested rule 1 (더 specific)
|
||||
- Nested rule 2
|
||||
@@ -1 +0,0 @@
|
||||
export const deep = true
|
||||
@@ -1,3 +0,0 @@
|
||||
export function greet(name: string): string {
|
||||
return `Hello, ${name}!`
|
||||
}
|
||||
Reference in New Issue
Block a user