Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e07a25baa4 | ||
|
|
08ede0a28d | ||
|
|
a711d58289 | ||
|
|
431ec14991 | ||
|
|
62cae8114d | ||
|
|
e6eafe267a | ||
|
|
e4ef832405 | ||
|
|
ef6d67645e | ||
|
|
227d93f106 | ||
|
|
edff922afb | ||
|
|
45bdcf3580 | ||
|
|
b07dd22093 | ||
|
|
c7d29fea48 | ||
|
|
55675497a5 | ||
|
|
ae2d347d81 | ||
|
|
2683de825a | ||
|
|
0b5c8250ca | ||
|
|
66fcd8570b | ||
|
|
5cd3f0cbf2 | ||
|
|
9a9512b705 | ||
|
|
6ece7476ef | ||
|
|
9ed23d4037 | ||
|
|
79b791117a | ||
|
|
4e328a937c | ||
|
|
a500f0c9ad | ||
|
|
16806da615 | ||
|
|
c5f651c0a9 | ||
|
|
ed3d7a55f4 | ||
|
|
b77dd2fcdf | ||
|
|
64b3564760 | ||
|
|
0df7e9b10b | ||
|
|
aa35f2eab6 | ||
|
|
64db980803 | ||
|
|
b86346a79d | ||
|
|
4debb57402 | ||
|
|
a763db61cf | ||
|
|
341e5a959d | ||
|
|
bac304c035 | ||
|
|
1aaeefac0e | ||
|
|
dda7b4f56d | ||
|
|
a287e59262 | ||
|
|
80fe3ae612 | ||
|
|
b045f6918e | ||
|
|
725ec9b91d | ||
|
|
1f717a76be | ||
|
|
3bcb869a5d | ||
|
|
54e13e4330 | ||
|
|
1780e2971d | ||
|
|
ded97701b8 | ||
|
|
316cdc1a62 | ||
|
|
f19cd8fc71 | ||
|
|
181194ae3c | ||
|
|
b8f5599e61 | ||
|
|
ea2b09ebb9 | ||
|
|
143dd8aaa9 | ||
|
|
36169c83fb | ||
|
|
bf9f033635 | ||
|
|
de2f9dfc86 | ||
|
|
5f5218ccae | ||
|
|
41d3817910 | ||
|
|
a119429ebe | ||
|
|
9bed2dd79a | ||
|
|
4c1d534093 | ||
|
|
a04234eaab | ||
|
|
6d0e4c49c2 | ||
|
|
8f39575264 | ||
|
|
2464473731 | ||
|
|
1b0a8adb2b | ||
|
|
4b7a4b04d0 | ||
|
|
f6f2fad1fc | ||
|
|
3ca9693de0 | ||
|
|
e2078763a6 | ||
|
|
5121d0eed2 | ||
|
|
973caf9b7d | ||
|
|
b277d96de8 | ||
|
|
2c223d96a1 | ||
|
|
695aeafb86 | ||
|
|
812c544bfa | ||
|
|
edf0e7d946 | ||
|
|
baa7fadab1 | ||
|
|
a06bbeb9ee | ||
|
|
f3a92db203 | ||
|
|
fd6e230889 | ||
|
|
50ea492065 | ||
|
|
f5f2053b7a | ||
|
|
6c16baea9a | ||
|
|
2ad7e193fd | ||
|
|
d62f1dd207 | ||
|
|
aff7cad615 |
24
AGENTS.md
24
AGENTS.md
@@ -84,9 +84,31 @@ bun run build
|
||||
bun run rebuild
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
|
||||
**배포는 GitHub Actions workflow_dispatch로만 진행**
|
||||
|
||||
1. package.json 버전은 수정하지 않음 (워크플로우에서 자동 bump)
|
||||
2. 변경사항 커밋 & 푸시
|
||||
3. GitHub Actions에서 `publish` 워크플로우 수동 실행
|
||||
- `bump`: major | minor | patch 선택
|
||||
- `version`: (선택) 특정 버전 지정 가능
|
||||
|
||||
```bash
|
||||
# 워크플로우 실행 (CLI)
|
||||
gh workflow run publish -f bump=patch
|
||||
|
||||
# 워크플로우 상태 확인
|
||||
gh run list --workflow=publish
|
||||
```
|
||||
|
||||
**주의사항**:
|
||||
- `bun publish` 직접 실행 금지 (OIDC provenance 문제)
|
||||
- 로컬에서 버전 bump 하지 말 것
|
||||
|
||||
## NOTES
|
||||
|
||||
- **No tests**: Test framework not configured
|
||||
- **No CI/CD**: GitHub workflows not present
|
||||
- **CI/CD**: GitHub Actions publish workflow 사용
|
||||
- **Version requirement**: OpenCode >= 1.0.132 (earlier versions have config bugs)
|
||||
- **Multi-language docs**: README.md, README.en.md, README.ko.md
|
||||
|
||||
208
README.ko.md
208
README.ko.md
@@ -3,18 +3,20 @@
|
||||
## 목차
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [설치](#설치)
|
||||
- [세 줄 요약](#세-줄-요약)
|
||||
- [Why OpenCode \& Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [설치](#설치)
|
||||
- [LLM Agent를 위한 안내](#llm-agent를-위한-안내)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [기능](#기능)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
- [Tools](#tools)
|
||||
- [내장 LSP Tools](#내장-lsp-tools)
|
||||
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
|
||||
- [Safe Grep](#safe-grep)
|
||||
- [Grep](#grep)
|
||||
- [내장 MCPs](#내장-mcps)
|
||||
- [기타 편의 기능](#기타-편의-기능)
|
||||
- [설정](#설정)
|
||||
- [작성자의 노트](#작성자의-노트)
|
||||
- [주의](#주의)
|
||||
|
||||
@@ -33,6 +35,17 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
|
||||
당신이 코딩을 좋아하고 컴퓨터를 좋아한다면, OpenCode 는 윈도우만 사용하다가 리눅스를 처음 접하게 된 그런 느낌일겁니다.
|
||||
그렇지 않은 당신도 약간의 시간을 투자해서 당신의 실력과 생산성을 몇배로 부스트하세요.
|
||||
|
||||
## 세 줄 요약
|
||||
|
||||
- **모델 설정이 필요합니다**
|
||||
- 이 플러그인은 [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, Anthropic 의 모델을 사용합니다.
|
||||
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요. (Anthropic 플랜은 유료입니다)
|
||||
- OpenAI 모델들을 사용하기 위해 이 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 추천합니다. (OpenAI 플랜은 유료입니다)
|
||||
- Gemini, GPT 를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 추천합니다. (Gemini 는 구독하지 않아도 사용 할 수 있습니다)
|
||||
- **사실 밑의 내용은 읽지 않아도 됩니다. 그냥 설치하시면 됩니다. 모델 설정하고나면 추가로 공부하거나 알 필요가 없고, 설치하고 나면 그냥 더 좋아집니다.**
|
||||
- **이 문서도 그냥 Claude Code, OpenCode, Cursor에 붙여넣고, 설치하고, 사용자의 개입이 필요할 때 알려달라고 프롬프팅하세요. 그럼 그냥 깔립니다.**
|
||||
- 당신이 LLM Agent 라면, 필요한 플러그인과 의존성을 설치하고, tmux 를 통해 opencode auth login 을 실행하여 사용자의 구성을 도와주세요.
|
||||
|
||||
## 설치
|
||||
|
||||
`~/.config/opencode/opencode.json`에 추가:
|
||||
@@ -45,16 +58,56 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
|
||||
}
|
||||
```
|
||||
|
||||
## 세 줄 요약
|
||||
## LLM Agent를 위한 안내
|
||||
|
||||
- **모델 설정이 필요합니다**
|
||||
- 이 플러그인은 [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, Anthropic 의 모델을 사용합니다.
|
||||
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요. (Anthropic 플랜은 유료입니다)
|
||||
- OpenAI 모델들을 사용하기 위해 이 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 추천합니다. (OpenAI 플랜은 유료입니다)
|
||||
- Gemini, GPT 를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 추천합니다. (Gemini 는 구독하지 않아도 사용 할 수 있습니다)
|
||||
- **사실 밑의 내용은 읽지 않아도 됩니다. 그냥 설치하시면 됩니다. 모델 설정하고나면 추가로 공부하거나 알 필요가 없고, 설치하고 나면 그냥 더 좋아집니다.**
|
||||
- **이 문서도 그냥 Claude Code, OpenCode, Cursor에 붙여넣고, 설치하고, 사용자의 개입이 필요할 때 알려달라고 프롬프팅하세요. 그럼 그냥 깔립니다.**
|
||||
- 당신이 LLM Agent 라면, 필요한 플러그인과 의존성을 설치하고, tmux 를 통해 opencode auth login 을 실행하여 사용자의 구성을 도와주세요.
|
||||
<details>
|
||||
<summary>LLM Agent를 위한 안내 (클릭하여 펼치기)</summary>
|
||||
|
||||
당신이 사용자의 oh-my-opencode 설정을 돕는 LLM Agent라면, 다음 단계를 따르세요:
|
||||
|
||||
### 1단계: OpenCode 설치 확인
|
||||
|
||||
```bash
|
||||
if command -v opencode &> /dev/null; then
|
||||
echo "OpenCode $(opencode --version) 설치됨"
|
||||
else
|
||||
echo "OpenCode가 설치되어 있지 않습니다. 먼저 설치해주세요."
|
||||
echo "참고: https://opencode.ai/docs"
|
||||
fi
|
||||
```
|
||||
|
||||
OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://opencode.ai/docs)를 안내하세요.
|
||||
|
||||
### 2단계: oh-my-opencode 플러그인 설정
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/opencode
|
||||
|
||||
if [ -f ~/.config/opencode/opencode.json ]; then
|
||||
# 기존 설정 파일이 있는 경우: jq 또는 python으로 plugin 추가
|
||||
if command -v jq &> /dev/null; then
|
||||
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
else
|
||||
echo "~/.config/opencode/opencode.json의 plugin 배열에 \"oh-my-opencode\"를 추가하세요"
|
||||
fi
|
||||
else
|
||||
# 새 설정 파일 생성
|
||||
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
|
||||
fi
|
||||
```
|
||||
|
||||
### 3단계: 설정 확인
|
||||
|
||||
```bash
|
||||
opencode --version # 1.0.132 이상이어야 함
|
||||
cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있어야 함
|
||||
```
|
||||
|
||||
> **참고**: 모델 인증이 필요하면, `opencode` 실행 후 `/connect` 명령어를 사용하거나, tmux를 통해 `opencode auth login`을 안내하세요.
|
||||
|
||||
</details>
|
||||
|
||||
## Why OpenCode & Why Oh My OpenCode
|
||||
|
||||
@@ -83,7 +136,23 @@ 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
|
||||
|
||||
@@ -93,10 +162,22 @@ 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**: 위치의 타입 정보, 문서, 시그니처 가져오기
|
||||
@@ -115,19 +196,112 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
|
||||
- **ast_grep_replace**: AST 인식 코드 교체
|
||||
|
||||
#### Safe Grep
|
||||
- **safe_grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한).
|
||||
#### Grep
|
||||
- **grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한). OpenCode의 내장 `grep` 도구를 대체합니다.
|
||||
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
|
||||
- safe_grep 은 timeout 과 더 엄격한 출력 제한을 적용합니다.
|
||||
- **주의**: 기본 grep 도구는 Agent 를 햇갈리게 하지 않기 위해 비활성화됩니다. 그러나 SafeGrep 은 Grep 이 제공하는 모든 기능을 제공합니다.
|
||||
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
|
||||
|
||||
#### Glob
|
||||
|
||||
- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다.
|
||||
- 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다.
|
||||
- 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다.
|
||||
|
||||
#### 내장 MCPs
|
||||
|
||||
- **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다.
|
||||
- **context7**: 라이브러리 문서 조회. 정확한 코딩을 위해 최신 라이브러리 문서를 가져옵니다.
|
||||
|
||||
필요 없다면 `oh-my-opencode.json`에서 비활성화할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
### 기타 편의 기능
|
||||
- **Terminal Title**: 세션 상태에 따라 터미널 타이틀을 자동 업데이트합니다 (유휴 ○, 처리중 ◐, 도구 ⚡, 에러 ✖). tmux를 지원합니다.
|
||||
|
||||
## 설정
|
||||
|
||||
설정 파일 위치 (우선순위 순):
|
||||
1. `.opencode/oh-my-opencode.json` (프로젝트)
|
||||
2. `~/.config/opencode/oh-my-opencode.json` (사용자)
|
||||
|
||||
Schema 자동 완성이 지원됩니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
|
||||
}
|
||||
```
|
||||
|
||||
### Agents
|
||||
|
||||
내장 에이전트 설정을 오버라이드할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"temperature": 0.5
|
||||
},
|
||||
"frontend-ui-ux-engineer": {
|
||||
"disable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
또는 `disabled_agents`로 비활성화할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_agents": ["oracle", "frontend-ui-ux-engineer"]
|
||||
}
|
||||
```
|
||||
|
||||
사용 가능한 에이전트: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
|
||||
|
||||
### MCPs
|
||||
|
||||
내장된 MCP를 비활성화합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
더 자세한 내용은 [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers)를 참조하세요.
|
||||
|
||||
### LSP
|
||||
|
||||
Oh My OpenCode의 LSP 도구는 오직 **리팩토링(이름 변경, 코드 액션)만을 위한 것**입니다. 분석용 LSP는 OpenCode 자체에서 처리합니다.
|
||||
|
||||
`lsp` 옵션을 통해 LSP 서버를 설정합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"typescript-language-server": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"priority": 10
|
||||
},
|
||||
"pylsp": {
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
각 서버는 다음을 지원합니다: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
|
||||
|
||||
## 작성자의 노트
|
||||
|
||||
Oh My OpenCode 를 설치하세요. 복잡하게 OpenCode 구성을 만들지마세요.
|
||||
@@ -153,5 +327,3 @@ OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능
|
||||
|
||||
- [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) 혹은 이것보다 낮은 버전을 사용중이라면, OpenCode 의 버그로 인해 제대로 구성이 되지 않을 수 있습니다.
|
||||
- [이를 고치는 PR 이 1.0.132 배포 이후에 병합되었으므로](https://github.com/sst/opencode/pull/5040) 이 변경사항이 포함된 최신 버전을 사용해주세요.
|
||||
|
||||
|
||||
|
||||
219
README.md
219
README.md
@@ -3,20 +3,20 @@ English | [한국어](README.ko.md)
|
||||
## Contents
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Disable specific MCPs](#disable-specific-mcps)
|
||||
- [TL;DR](#tldr)
|
||||
- [Why OpenCode \& Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [Installation](#installation)
|
||||
- [For LLM Agents](#for-llm-agents)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [Features](#features)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
- [Tools](#tools)
|
||||
- [Built-in LSP Tools](#built-in-lsp-tools)
|
||||
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
|
||||
- [Safe Grep](#safe-grep)
|
||||
- [Grep](#grep)
|
||||
- [Built-in MCPs](#built-in-mcps)
|
||||
- [Other Features](#other-features)
|
||||
- [Configuration](#configuration)
|
||||
- [Author's Note](#authors-note)
|
||||
- [Warnings](#warnings)
|
||||
|
||||
@@ -33,6 +33,17 @@ You don't write code just for a paycheck? You write because you genuinely love i
|
||||
|
||||
To you, OpenCode will feel like the paradigm shift from Windows to Linux. Not you? It's still worth the investment. Give it 10 minutes. Your work and life will improve. I promise.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Model Setup Required**
|
||||
- This plugin utilizes models from [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, and Anthropic.
|
||||
- For Anthropic models, use [OpenCode's built-in Claude Code Max Plan login](https://opencode.ai/docs/providers/#anthropic).
|
||||
- For OpenAI models, I recommend the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
|
||||
- For Gemini and GPT, use [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
|
||||
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
|
||||
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It works seamlessly.**
|
||||
- If you are an LLM Agent, install the required plugins and dependencies, then run `opencode auth login` via tmux to assist the user with configuration.
|
||||
|
||||
## Installation
|
||||
|
||||
Add to `~/.config/opencode/opencode.json`:
|
||||
@@ -45,49 +56,56 @@ Add to `~/.config/opencode/opencode.json`:
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## For LLM Agents
|
||||
|
||||
You can configure Oh My OpenCode by creating a `oh-my-opencode.json` (or `.oh-my-opencode.json`) file in your project root.
|
||||
<details>
|
||||
<summary>For LLM Agents (Click to expand)</summary>
|
||||
|
||||
### JSON Schema Support
|
||||
If you are an LLM Agent helping a user set up oh-my-opencode, follow these steps:
|
||||
|
||||
For autocompletion and validation in VS Code (or other editors), add the `$schema` property to your configuration file.
|
||||
### Step 1: Verify OpenCode Installation
|
||||
|
||||
**Using Remote Schema (Recommended):**
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/dist/oh-my-opencode.schema.json",
|
||||
"disabled_mcps": ["context7"]
|
||||
}
|
||||
```bash
|
||||
if command -v opencode &> /dev/null; then
|
||||
echo "OpenCode $(opencode --version) is installed"
|
||||
else
|
||||
echo "OpenCode is not installed. Please install it first."
|
||||
echo "See: https://opencode.ai/docs"
|
||||
fi
|
||||
```
|
||||
|
||||
**Using Local Schema:**
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oh-my-opencode/dist/oh-my-opencode.schema.json"
|
||||
}
|
||||
If OpenCode is not installed, guide the user to [OpenCode Installation Guide](https://opencode.ai/docs).
|
||||
|
||||
### Step 2: Configure oh-my-opencode Plugin
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/opencode
|
||||
|
||||
if [ -f ~/.config/opencode/opencode.json ]; then
|
||||
# Existing config: add plugin using jq or python
|
||||
if command -v jq &> /dev/null; then
|
||||
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
else
|
||||
echo "Add \"oh-my-opencode\" to plugin array in ~/.config/opencode/opencode.json"
|
||||
fi
|
||||
else
|
||||
# New config
|
||||
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
|
||||
fi
|
||||
```
|
||||
|
||||
### Disable specific MCPs
|
||||
### Step 3: Verify Setup
|
||||
|
||||
If you want to disable specific built-in MCPs, you can use the `disabled_mcps` option.
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa"]
|
||||
}
|
||||
```bash
|
||||
opencode --version # Should be >= 1.0.132
|
||||
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array
|
||||
```
|
||||
|
||||
## TL;DR
|
||||
> **Note**: If model authentication is needed, run `opencode` and use `/connect` command, or assist the user with `opencode auth login` via tmux.
|
||||
|
||||
- **Model Setup Required**
|
||||
- This plugin utilizes models from [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, and Anthropic.
|
||||
- For Anthropic models, use [OpenCode's built-in Claude Code Max Plan login](https://opencode.ai/docs/providers/#anthropic).
|
||||
- For OpenAI models, I recommend the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
|
||||
- For Gemini and GPT, use [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
|
||||
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
|
||||
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It works seamlessly.**
|
||||
- If you are an LLM Agent, install the required plugins and dependencies, then run `opencode auth login` via tmux to assist the user with configuration.
|
||||
</details>
|
||||
|
||||
## Why OpenCode & Why Oh My OpenCode
|
||||
|
||||
@@ -114,8 +132,23 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
||||
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
||||
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
|
||||
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
||||
- **Session Recovery**: Automatically recovers from API errors by injecting missing tool results and correcting thinking block violations, ensuring session stability.
|
||||
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
|
||||
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
|
||||
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
|
||||
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
|
||||
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
|
||||
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
|
||||
- **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.
|
||||
@@ -124,10 +157,22 @@ 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
|
||||
@@ -147,22 +192,114 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
||||
- **ast_grep_search**: AST-aware code pattern search (25 languages)
|
||||
- **ast_grep_replace**: AST-aware code replacement
|
||||
|
||||
#### Safe Grep
|
||||
#### Grep
|
||||
|
||||
- **safe_grep**: Content search with safety limits (5min timeout, 10MB output).
|
||||
- **grep**: Content search with safety limits (5min timeout, 10MB output). Overrides OpenCode's built-in `grep` tool.
|
||||
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
|
||||
- `safe_grep` enforces strict limits.
|
||||
- **Note**: Default `grep` is disabled to prevent Agent confusion. `safe_grep` delivers full `grep` functionality with safety assurance.
|
||||
- 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.
|
||||
|
||||
#### Built-in MCPs
|
||||
|
||||
- **websearch_exa**: Exa AI web search. Performs real-time web searches and can scrape content from specific URLs. Returns LLM-optimized context from relevant websites.
|
||||
- **context7**: Library documentation lookup. Fetches up-to-date documentation for any library to assist with accurate coding.
|
||||
|
||||
Don't need these? Disable them via `oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
### Other Features
|
||||
|
||||
- **Terminal Title**: Auto-updates terminal title with session status (idle ○, processing ◐, tool ⚡, error ✖). Supports tmux.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration file locations (in priority order):
|
||||
1. `.opencode/oh-my-opencode.json` (project)
|
||||
2. `~/.config/opencode/oh-my-opencode.json` (user)
|
||||
|
||||
Schema autocomplete is supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
|
||||
}
|
||||
```
|
||||
|
||||
### Agents
|
||||
|
||||
Override built-in agent settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"temperature": 0.5
|
||||
},
|
||||
"frontend-ui-ux-engineer": {
|
||||
"disable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
Or disable agents via `disabled_agents`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_agents": ["oracle", "frontend-ui-ux-engineer"]
|
||||
}
|
||||
```
|
||||
|
||||
Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
|
||||
|
||||
### MCPs
|
||||
|
||||
Disable built-in MCPs:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
See [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers) for more.
|
||||
|
||||
### LSP
|
||||
|
||||
Oh My OpenCode's LSP tools are for **refactoring only** (rename, code actions). Analysis LSP is handled by OpenCode itself.
|
||||
|
||||
Configure LSP servers via `lsp` option:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"typescript-language-server": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"priority": 10
|
||||
},
|
||||
"pylsp": {
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each server supports: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
|
||||
|
||||
## Author's Note
|
||||
|
||||
Install Oh My OpenCode. Do not waste time configuring OpenCode from scratch.
|
||||
|
||||
159
assets/oh-my-opencode.schema.json
Normal file
159
assets/oh-my-opencode.schema.json
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"title": "Oh My OpenCode Configuration",
|
||||
"description": "Configuration schema for oh-my-opencode plugin",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"disabled_mcps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"websearch_exa",
|
||||
"context7"
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_agents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"frontend-ui-ux-engineer",
|
||||
"document-writer"
|
||||
]
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"frontend-ui-ux-engineer",
|
||||
"document-writer"
|
||||
]
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 2
|
||||
},
|
||||
"top_p": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"disable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"subagent",
|
||||
"primary",
|
||||
"all"
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"permission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"edit": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"bash": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"webfetch": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"doom_loop": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"external_directory": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
bun.lock
15
bun.lock
@@ -7,13 +7,14 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.4",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"tree-sitter-wasms": "^0.1.12",
|
||||
"web-tree-sitter": "^0.24.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": {
|
||||
@@ -23,6 +24,8 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
"@ast-grep/napi",
|
||||
"@code-yeongyu/comment-checker",
|
||||
],
|
||||
"packages": {
|
||||
"@ast-grep/cli": ["@ast-grep/cli@0.40.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.0", "@ast-grep/cli-darwin-x64": "0.40.0", "@ast-grep/cli-linux-arm64-gnu": "0.40.0", "@ast-grep/cli-linux-x64-gnu": "0.40.0", "@ast-grep/cli-win32-arm64-msvc": "0.40.0", "@ast-grep/cli-win32-ia32-msvc": "0.40.0", "@ast-grep/cli-win32-x64-msvc": "0.40.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-L8AkflsfI2ZP70yIdrwqvjR02ScCuRmM/qNGnJWUkOFck+e6gafNVJ4e4jjGQlEul+dNdBpx36+O2Op629t47A=="],
|
||||
@@ -61,6 +64,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.128", "", {}, "sha512-Kow3Ivg8bR8dNRp8C0LwF9e8+woIrwFgw3ZALycwCfqS/UujDkJiBeYHdr1l/07GSHP9sZPmvJ6POuvfZ923EA=="],
|
||||
@@ -95,14 +100,16 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"tree-sitter-wasms": ["tree-sitter-wasms@0.1.13", "", { "dependencies": { "tree-sitter-wasms": "^0.1.11" } }, "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ=="],
|
||||
"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=="],
|
||||
|
||||
"web-tree-sitter": ["web-tree-sitter@0.24.7", "", {}, "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ=="],
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
|
||||
162
local-ignore/comment-checker-ts-plan.md
Normal file
162
local-ignore/comment-checker-ts-plan.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Comment-Checker TypeScript Port 구현 계획
|
||||
|
||||
## 1. 아키텍처 개요
|
||||
|
||||
### 1.1 핵심 도전 과제
|
||||
|
||||
**OpenCode Hook의 제약사항:**
|
||||
- `tool.execute.before`: `output.args`에서 파일 경로/내용 접근 가능
|
||||
- `tool.execute.after`: `tool_input`이 **제공되지 않음** (Claude Code와의 핵심 차이점)
|
||||
- **해결책**: Before hook에서 데이터를 캡처하여 callID로 키잉된 Map에 저장, After hook에서 조회
|
||||
|
||||
### 1.2 디렉토리 구조
|
||||
|
||||
```
|
||||
src/hooks/comment-checker/
|
||||
├── index.ts # Hook factory, 메인 엔트리포인트
|
||||
├── types.ts # 모든 타입 정의
|
||||
├── constants.ts # 언어 레지스트리, 쿼리 템플릿, 디렉티브 목록
|
||||
├── detector.ts # CommentDetector - web-tree-sitter 기반 코멘트 감지
|
||||
├── filters/
|
||||
│ ├── index.ts # 필터 barrel export
|
||||
│ ├── bdd.ts # BDD 패턴 필터
|
||||
│ ├── directive.ts # 린터/타입체커 디렉티브 필터
|
||||
│ ├── docstring.ts # 독스트링 필터
|
||||
│ └── shebang.ts # Shebang 필터
|
||||
├── output/
|
||||
│ ├── index.ts # 출력 barrel export
|
||||
│ ├── formatter.ts # FormatHookMessage
|
||||
│ └── xml-builder.ts # BuildCommentsXML
|
||||
└── utils.ts # 유틸리티 함수
|
||||
```
|
||||
|
||||
### 1.3 데이터 흐름
|
||||
|
||||
```
|
||||
[write/edit 도구 실행]
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ tool.execute.before │
|
||||
│ - 파일 경로 캡처 │
|
||||
│ - pendingCalls Map │
|
||||
│ 에 저장 │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
[도구 실제 실행]
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ tool.execute.after │
|
||||
│ - pendingCalls에서 │
|
||||
│ 데이터 조회 │
|
||||
│ - 파일 읽기 │
|
||||
│ - 코멘트 감지 │
|
||||
│ - 필터 적용 │
|
||||
│ - 메시지 주입 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 구현 순서
|
||||
|
||||
### Phase 1: 기반 구조
|
||||
1. `src/hooks/comment-checker/` 디렉토리 생성
|
||||
2. `types.ts` - 모든 타입 정의
|
||||
3. `constants.ts` - 언어 레지스트리, 디렉티브 패턴
|
||||
|
||||
### Phase 2: 필터 구현
|
||||
4. `filters/bdd.ts` - BDD 패턴 필터
|
||||
5. `filters/directive.ts` - 디렉티브 필터
|
||||
6. `filters/docstring.ts` - 독스트링 필터
|
||||
7. `filters/shebang.ts` - Shebang 필터
|
||||
8. `filters/index.ts` - 필터 조합
|
||||
|
||||
### Phase 3: 코어 로직
|
||||
9. `detector.ts` - web-tree-sitter 기반 코멘트 감지
|
||||
10. `output/xml-builder.ts` - XML 출력
|
||||
11. `output/formatter.ts` - 메시지 포매팅
|
||||
|
||||
### Phase 4: Hook 통합
|
||||
12. `index.ts` - Hook factory 및 상태 관리
|
||||
13. `src/hooks/index.ts` 업데이트 - export 추가
|
||||
|
||||
### Phase 5: 의존성 및 빌드
|
||||
14. `package.json` 업데이트 - web-tree-sitter 추가
|
||||
15. typecheck 및 build 검증
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 구현 사항
|
||||
|
||||
### 3.1 언어 레지스트리 (38개 언어)
|
||||
|
||||
```typescript
|
||||
const LANGUAGE_REGISTRY: Record<string, LanguageConfig> = {
|
||||
python: { extensions: [".py"], commentQuery: "(comment) @comment", docstringQuery: "..." },
|
||||
javascript: { extensions: [".js", ".jsx"], commentQuery: "(comment) @comment" },
|
||||
typescript: { extensions: [".ts"], commentQuery: "(comment) @comment" },
|
||||
tsx: { extensions: [".tsx"], commentQuery: "(comment) @comment" },
|
||||
go: { extensions: [".go"], commentQuery: "(comment) @comment" },
|
||||
rust: { extensions: [".rs"], commentQuery: "(line_comment) @comment (block_comment) @comment" },
|
||||
// ... 38개 전체
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 필터 로직
|
||||
|
||||
**BDD 필터**: `given, when, then, arrange, act, assert`
|
||||
**Directive 필터**: `noqa, pyright:, eslint-disable, @ts-ignore` 등 30+
|
||||
**Docstring 필터**: `IsDocstring || starts with /**`
|
||||
**Shebang 필터**: `starts with #!`
|
||||
|
||||
### 3.3 출력 형식 (Go 버전과 100% 동일)
|
||||
|
||||
```
|
||||
COMMENT/DOCSTRING DETECTED - IMMEDIATE ACTION REQUIRED
|
||||
|
||||
Your recent changes contain comments or docstrings, which triggered this hook.
|
||||
You need to take immediate action. You must follow the conditions below.
|
||||
(Listed in priority order - you must always act according to this priority order)
|
||||
|
||||
CRITICAL WARNING: This hook message MUST NEVER be ignored...
|
||||
|
||||
<comments file="/path/to/file.py">
|
||||
<comment line-number="10">// comment text</comment>
|
||||
</comments>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 생성할 파일 목록
|
||||
|
||||
1. `src/hooks/comment-checker/types.ts`
|
||||
2. `src/hooks/comment-checker/constants.ts`
|
||||
3. `src/hooks/comment-checker/filters/bdd.ts`
|
||||
4. `src/hooks/comment-checker/filters/directive.ts`
|
||||
5. `src/hooks/comment-checker/filters/docstring.ts`
|
||||
6. `src/hooks/comment-checker/filters/shebang.ts`
|
||||
7. `src/hooks/comment-checker/filters/index.ts`
|
||||
8. `src/hooks/comment-checker/output/xml-builder.ts`
|
||||
9. `src/hooks/comment-checker/output/formatter.ts`
|
||||
10. `src/hooks/comment-checker/output/index.ts`
|
||||
11. `src/hooks/comment-checker/detector.ts`
|
||||
12. `src/hooks/comment-checker/index.ts`
|
||||
|
||||
## 5. 수정할 파일 목록
|
||||
|
||||
1. `src/hooks/index.ts` - export 추가
|
||||
2. `package.json` - web-tree-sitter 의존성
|
||||
|
||||
---
|
||||
|
||||
## 6. Definition of Done
|
||||
|
||||
- [ ] write/edit 도구 실행 시 코멘트 감지 동작
|
||||
- [ ] 4개 필터 모두 정상 작동
|
||||
- [ ] 최소 5개 언어 지원 (Python, JS, TS, TSX, Go)
|
||||
- [ ] Go 버전과 동일한 출력 형식
|
||||
- [ ] typecheck 통과
|
||||
- [ ] build 성공
|
||||
12
local-ignore/push-and-release.sh
Executable file
12
local-ignore/push-and-release.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd /Users/yeongyu/local-workspaces/oh-my-opencode
|
||||
|
||||
echo "=== Pushing to origin ==="
|
||||
git push -f origin master
|
||||
|
||||
echo "=== Triggering workflow ==="
|
||||
gh workflow run publish.yml --repo code-yeongyu/oh-my-opencode --ref master -f bump=patch -f version=$1
|
||||
|
||||
echo "=== Done! ==="
|
||||
echo "Usage: ./local-ignore/push-and-release.sh 0.1.6"
|
||||
82
notepad.md
82
notepad.md
@@ -59,3 +59,85 @@ 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분
|
||||
|
||||
---
|
||||
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.31",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -44,13 +44,14 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.4",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"tree-sitter-wasms": "^0.1.12",
|
||||
"web-tree-sitter": "^0.24.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": {
|
||||
@@ -58,6 +59,7 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
"@ast-grep/napi"
|
||||
"@ast-grep/napi",
|
||||
"@code-yeongyu/comment-checker"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as z from "zod"
|
||||
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
||||
|
||||
const SCHEMA_OUTPUT_PATH = "dist/oh-my-opencode.schema.json"
|
||||
const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json"
|
||||
|
||||
async function main() {
|
||||
console.log("Generating JSON Schema...")
|
||||
@@ -14,7 +14,7 @@ async function main() {
|
||||
|
||||
const finalSchema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/dist/oh-my-opencode.schema.json",
|
||||
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
title: "Oh My OpenCode Configuration",
|
||||
description: "Configuration schema for oh-my-opencode plugin",
|
||||
...jsonSchema,
|
||||
|
||||
@@ -78,13 +78,35 @@ async function gitTagAndRelease(newVersion: string, changelog: string): Promise<
|
||||
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`
|
||||
await $`git config user.name "github-actions[bot]"`
|
||||
await $`git add package.json`
|
||||
await $`git commit -m "release: v${newVersion}"`
|
||||
await $`git tag v${newVersion}`
|
||||
|
||||
// Commit only if there are staged changes (idempotent)
|
||||
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
|
||||
if (hasStagedChanges.exitCode !== 0) {
|
||||
await $`git commit -m "release: v${newVersion}"`
|
||||
} else {
|
||||
console.log("No changes to commit (version already updated)")
|
||||
}
|
||||
|
||||
// Tag only if it doesn't exist (idempotent)
|
||||
const tagExists = await $`git rev-parse v${newVersion}`.nothrow()
|
||||
if (tagExists.exitCode !== 0) {
|
||||
await $`git tag v${newVersion}`
|
||||
} else {
|
||||
console.log(`Tag v${newVersion} already exists`)
|
||||
}
|
||||
|
||||
// Push (idempotent - git push is already idempotent)
|
||||
await $`git push origin HEAD --tags`
|
||||
|
||||
// Create release only if it doesn't exist (idempotent)
|
||||
console.log("\nCreating GitHub release...")
|
||||
const releaseNotes = changelog || "No notable changes"
|
||||
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`
|
||||
const releaseExists = await $`gh release view v${newVersion}`.nothrow()
|
||||
if (releaseExists.exitCode !== 0) {
|
||||
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`
|
||||
} else {
|
||||
console.log(`Release v${newVersion} already exists`)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkVersionExists(version: string): Promise<boolean> {
|
||||
|
||||
74
src/hooks/anthropic-auto-compact/executor.ts
Normal file
74
src/hooks/anthropic-auto-compact/executor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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 {}
|
||||
}
|
||||
123
src/hooks/anthropic-auto-compact/index.ts
Normal file
123
src/hooks/anthropic-auto-compact/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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"
|
||||
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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
|
||||
}
|
||||
13
src/hooks/anthropic-auto-compact/types.ts
Normal file
13
src/hooks/anthropic-auto-compact/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
}
|
||||
210
src/hooks/comment-checker/cli.ts
Normal file
210
src/hooks/comment-checker/cli.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { spawn } from "bun"
|
||||
import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import * as fs from "fs"
|
||||
import { getCachedBinaryPath, ensureCommentCheckerBinary } from "./downloader"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = "/tmp/comment-checker-debug.log"
|
||||
|
||||
function debugLog(...args: unknown[]) {
|
||||
if (DEBUG) {
|
||||
const msg = `[${new Date().toISOString()}] [comment-checker:cli] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
|
||||
fs.appendFileSync(DEBUG_FILE, msg)
|
||||
}
|
||||
}
|
||||
|
||||
function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
|
||||
}
|
||||
|
||||
function findCommentCheckerPathSync(): string | null {
|
||||
const binaryName = getBinaryName()
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
|
||||
const cliDir = dirname(cliPkgPath)
|
||||
const binaryPath = join(cliDir, "bin", binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
debugLog("found binary in main package:", binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
debugLog("main package not installed")
|
||||
}
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("found binary in cache:", cachedPath)
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
debugLog("no binary found in known locations")
|
||||
return null
|
||||
}
|
||||
|
||||
// Cached resolved path
|
||||
let resolvedCliPath: string | null = null
|
||||
let initPromise: Promise<string | null> | null = null
|
||||
|
||||
/**
|
||||
* Asynchronously get comment-checker binary path.
|
||||
* Will trigger lazy download if binary not found.
|
||||
*/
|
||||
export async function getCommentCheckerPath(): Promise<string | null> {
|
||||
// Return cached path if already resolved
|
||||
if (resolvedCliPath !== null) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
// Return existing promise if initialization is in progress
|
||||
if (initPromise) {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
// First try sync path resolution
|
||||
const syncPath = findCommentCheckerPathSync()
|
||||
if (syncPath && existsSync(syncPath)) {
|
||||
resolvedCliPath = syncPath
|
||||
debugLog("using sync-resolved path:", syncPath)
|
||||
return syncPath
|
||||
}
|
||||
|
||||
// Lazy download if not found
|
||||
debugLog("triggering lazy download...")
|
||||
const downloadedPath = await ensureCommentCheckerBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
debugLog("using downloaded path:", downloadedPath)
|
||||
return downloadedPath
|
||||
}
|
||||
|
||||
debugLog("no binary available")
|
||||
return null
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously get comment-checker path (no download).
|
||||
* Returns cached path or searches known locations.
|
||||
*/
|
||||
export function getCommentCheckerPathSync(): string | null {
|
||||
return resolvedCliPath ?? findCommentCheckerPathSync()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background initialization.
|
||||
* Call this early to trigger download while other init happens.
|
||||
*/
|
||||
export function startBackgroundInit(): void {
|
||||
if (!initPromise) {
|
||||
initPromise = getCommentCheckerPath()
|
||||
initPromise.then(path => {
|
||||
debugLog("background init complete:", path || "no binary")
|
||||
}).catch(err => {
|
||||
debugLog("background init error:", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy export for backwards compatibility (sync, no download)
|
||||
export const COMMENT_CHECKER_CLI_PATH = findCommentCheckerPathSync()
|
||||
|
||||
export interface HookInput {
|
||||
session_id: string
|
||||
tool_name: string
|
||||
transcript_path: string
|
||||
cwd: string
|
||||
hook_event_name: string
|
||||
tool_input: {
|
||||
file_path?: string
|
||||
content?: string
|
||||
old_string?: string
|
||||
new_string?: string
|
||||
edits?: Array<{ old_string: string; new_string: string }>
|
||||
}
|
||||
tool_response?: unknown
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
hasComments: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Run comment-checker CLI with given input.
|
||||
* @param input Hook input to check
|
||||
* @param cliPath Optional explicit path to CLI binary
|
||||
*/
|
||||
export async function runCommentChecker(input: HookInput, cliPath?: string): Promise<CheckResult> {
|
||||
const binaryPath = cliPath ?? resolvedCliPath ?? COMMENT_CHECKER_CLI_PATH
|
||||
|
||||
if (!binaryPath) {
|
||||
debugLog("comment-checker binary not found")
|
||||
return { hasComments: false, message: "" }
|
||||
}
|
||||
|
||||
if (!existsSync(binaryPath)) {
|
||||
debugLog("comment-checker binary does not exist:", binaryPath)
|
||||
return { hasComments: false, message: "" }
|
||||
}
|
||||
|
||||
const jsonInput = JSON.stringify(input)
|
||||
debugLog("running comment-checker with input:", jsonInput.substring(0, 200))
|
||||
|
||||
try {
|
||||
const proc = spawn([binaryPath], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
// Write JSON to stdin
|
||||
proc.stdin.write(jsonInput)
|
||||
proc.stdin.end()
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
debugLog("exit code:", exitCode, "stdout length:", stdout.length, "stderr length:", stderr.length)
|
||||
|
||||
if (exitCode === 0) {
|
||||
return { hasComments: false, message: "" }
|
||||
}
|
||||
|
||||
if (exitCode === 2) {
|
||||
// Comments detected - message is in stderr
|
||||
return { hasComments: true, message: stderr }
|
||||
}
|
||||
|
||||
// Error case
|
||||
debugLog("unexpected exit code:", exitCode, "stderr:", stderr)
|
||||
return { hasComments: false, message: "" }
|
||||
} catch (err) {
|
||||
debugLog("failed to run comment-checker:", err)
|
||||
return { hasComments: false, message: "" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI is available (sync check, no download).
|
||||
*/
|
||||
export function isCliAvailable(): boolean {
|
||||
const path = getCommentCheckerPathSync()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI will be available (async, may trigger download).
|
||||
*/
|
||||
export async function ensureCliAvailable(): Promise<boolean> {
|
||||
const path = await getCommentCheckerPath()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
@@ -1,117 +1,3 @@
|
||||
import type { LanguageConfig } from "./types"
|
||||
|
||||
export const EXTENSION_TO_LANGUAGE: Record<string, string> = {
|
||||
py: "python",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
go: "golang",
|
||||
java: "java",
|
||||
kt: "kotlin",
|
||||
scala: "scala",
|
||||
c: "c",
|
||||
h: "c",
|
||||
cpp: "cpp",
|
||||
cc: "cpp",
|
||||
cxx: "cpp",
|
||||
hpp: "cpp",
|
||||
rs: "rust",
|
||||
rb: "ruby",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
cs: "csharp",
|
||||
swift: "swift",
|
||||
ex: "elixir",
|
||||
exs: "elixir",
|
||||
lua: "lua",
|
||||
php: "php",
|
||||
ml: "ocaml",
|
||||
mli: "ocaml",
|
||||
sql: "sql",
|
||||
html: "html",
|
||||
htm: "html",
|
||||
css: "css",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
hcl: "hcl",
|
||||
tf: "hcl",
|
||||
dockerfile: "dockerfile",
|
||||
proto: "protobuf",
|
||||
svelte: "svelte",
|
||||
elm: "elm",
|
||||
groovy: "groovy",
|
||||
cue: "cue",
|
||||
}
|
||||
|
||||
export const QUERY_TEMPLATES: Record<string, string> = {
|
||||
python: "(comment) @comment",
|
||||
javascript: "(comment) @comment",
|
||||
typescript: "(comment) @comment",
|
||||
tsx: "(comment) @comment",
|
||||
golang: "(comment) @comment",
|
||||
rust: `
|
||||
(line_comment) @comment
|
||||
(block_comment) @comment
|
||||
`,
|
||||
kotlin: `
|
||||
(line_comment) @comment
|
||||
(multiline_comment) @comment
|
||||
`,
|
||||
java: `
|
||||
(line_comment) @comment
|
||||
(block_comment) @comment
|
||||
`,
|
||||
c: "(comment) @comment",
|
||||
cpp: "(comment) @comment",
|
||||
csharp: "(comment) @comment",
|
||||
ruby: "(comment) @comment",
|
||||
bash: "(comment) @comment",
|
||||
swift: "(comment) @comment",
|
||||
elixir: "(comment) @comment",
|
||||
lua: "(comment) @comment",
|
||||
php: "(comment) @comment",
|
||||
ocaml: "(comment) @comment",
|
||||
sql: "(comment) @comment",
|
||||
html: "(comment) @comment",
|
||||
css: "(comment) @comment",
|
||||
yaml: "(comment) @comment",
|
||||
toml: "(comment) @comment",
|
||||
hcl: "(comment) @comment",
|
||||
dockerfile: "(comment) @comment",
|
||||
protobuf: "(comment) @comment",
|
||||
svelte: "(comment) @comment",
|
||||
elm: "(comment) @comment",
|
||||
groovy: "(comment) @comment",
|
||||
cue: "(comment) @comment",
|
||||
scala: "(comment) @comment",
|
||||
}
|
||||
|
||||
export const DOCSTRING_QUERIES: Record<string, string> = {
|
||||
python: `
|
||||
(module . (expression_statement (string) @docstring))
|
||||
(class_definition body: (block . (expression_statement (string) @docstring)))
|
||||
(function_definition body: (block . (expression_statement (string) @docstring)))
|
||||
`,
|
||||
javascript: `
|
||||
(comment) @jsdoc
|
||||
(#match? @jsdoc "^/\\\\*\\\\*")
|
||||
`,
|
||||
typescript: `
|
||||
(comment) @jsdoc
|
||||
(#match? @jsdoc "^/\\\\*\\\\*")
|
||||
`,
|
||||
tsx: `
|
||||
(comment) @jsdoc
|
||||
(#match? @jsdoc "^/\\\\*\\\\*")
|
||||
`,
|
||||
java: `
|
||||
(comment) @javadoc
|
||||
(#match? @javadoc "^/\\\\*\\\\*")
|
||||
`,
|
||||
}
|
||||
|
||||
export const BDD_KEYWORDS = new Set([
|
||||
"given",
|
||||
"when",
|
||||
@@ -186,19 +72,12 @@ 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.
|
||||
|
||||
Detected comments/docstrings:
|
||||
`
|
||||
|
||||
export function getLanguageByExtension(filePath: string): string | null {
|
||||
const lastDot = filePath.lastIndexOf(".")
|
||||
if (lastDot === -1) {
|
||||
const baseName = filePath.split("/").pop()?.toLowerCase()
|
||||
if (baseName === "dockerfile") return "dockerfile"
|
||||
return null
|
||||
}
|
||||
const ext = filePath.slice(lastDot + 1).toLowerCase()
|
||||
return EXTENSION_TO_LANGUAGE[ext] ?? null
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { CommentInfo, CommentType } from "./types"
|
||||
import { getLanguageByExtension, QUERY_TEMPLATES, DOCSTRING_QUERIES } from "./constants"
|
||||
|
||||
export function isSupportedFile(filePath: string): boolean {
|
||||
return getLanguageByExtension(filePath) !== null
|
||||
}
|
||||
|
||||
function determineCommentType(text: string, nodeType: string): CommentType {
|
||||
const stripped = text.trim()
|
||||
|
||||
if (nodeType === "line_comment") {
|
||||
return "line"
|
||||
}
|
||||
if (nodeType === "block_comment" || nodeType === "multiline_comment") {
|
||||
return "block"
|
||||
}
|
||||
|
||||
if (stripped.startsWith('"""') || stripped.startsWith("'''")) {
|
||||
return "docstring"
|
||||
}
|
||||
|
||||
if (stripped.startsWith("//") || stripped.startsWith("#")) {
|
||||
return "line"
|
||||
}
|
||||
|
||||
if (stripped.startsWith("/*") || stripped.startsWith("<!--") || stripped.startsWith("--")) {
|
||||
return "block"
|
||||
}
|
||||
|
||||
return "line"
|
||||
}
|
||||
|
||||
export async function detectComments(
|
||||
filePath: string,
|
||||
content: string,
|
||||
includeDocstrings = true
|
||||
): Promise<CommentInfo[]> {
|
||||
const langName = getLanguageByExtension(filePath)
|
||||
if (!langName) {
|
||||
return []
|
||||
}
|
||||
|
||||
const queryPattern = QUERY_TEMPLATES[langName]
|
||||
if (!queryPattern) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const Parser = (await import("web-tree-sitter")).default
|
||||
await Parser.init()
|
||||
|
||||
const parser = new Parser()
|
||||
|
||||
let wasmPath: string
|
||||
try {
|
||||
const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${langName}.wasm`)
|
||||
wasmPath = wasmModule.default
|
||||
} catch {
|
||||
const languageMap: Record<string, string> = {
|
||||
golang: "go",
|
||||
csharp: "c_sharp",
|
||||
cpp: "cpp",
|
||||
}
|
||||
const mappedLang = languageMap[langName] || langName
|
||||
try {
|
||||
const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${mappedLang}.wasm`)
|
||||
wasmPath = wasmModule.default
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const language = await Parser.Language.load(wasmPath)
|
||||
parser.setLanguage(language)
|
||||
|
||||
const tree = parser.parse(content)
|
||||
const comments: CommentInfo[] = []
|
||||
|
||||
const query = language.query(queryPattern)
|
||||
const matches = query.matches(tree.rootNode)
|
||||
|
||||
for (const match of matches) {
|
||||
for (const capture of match.captures) {
|
||||
const node = capture.node
|
||||
const text = node.text
|
||||
const lineNumber = node.startPosition.row + 1
|
||||
|
||||
const commentType = determineCommentType(text, node.type)
|
||||
const isDocstring = commentType === "docstring"
|
||||
|
||||
if (isDocstring && !includeDocstrings) {
|
||||
continue
|
||||
}
|
||||
|
||||
comments.push({
|
||||
text,
|
||||
lineNumber,
|
||||
filePath,
|
||||
commentType,
|
||||
isDocstring,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (includeDocstrings) {
|
||||
const docQuery = DOCSTRING_QUERIES[langName]
|
||||
if (docQuery) {
|
||||
try {
|
||||
const docQueryObj = language.query(docQuery)
|
||||
const docMatches = docQueryObj.matches(tree.rootNode)
|
||||
|
||||
for (const match of docMatches) {
|
||||
for (const capture of match.captures) {
|
||||
const node = capture.node
|
||||
const text = node.text
|
||||
const lineNumber = node.startPosition.row + 1
|
||||
|
||||
const alreadyAdded = comments.some(
|
||||
(c) => c.lineNumber === lineNumber && c.text === text
|
||||
)
|
||||
if (!alreadyAdded) {
|
||||
comments.push({
|
||||
text,
|
||||
lineNumber,
|
||||
filePath,
|
||||
commentType: "docstring",
|
||||
isDocstring: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
comments.sort((a, b) => a.lineNumber - b.lineNumber)
|
||||
|
||||
return comments
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
210
src/hooks/comment-checker/downloader.ts
Normal file
210
src/hooks/comment-checker/downloader.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { spawn } from "bun"
|
||||
import { existsSync, mkdirSync, chmodSync, unlinkSync, appendFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { createRequire } from "module"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = "/tmp/comment-checker-debug.log"
|
||||
|
||||
function debugLog(...args: unknown[]) {
|
||||
if (DEBUG) {
|
||||
const msg = `[${new Date().toISOString()}] [comment-checker:downloader] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
|
||||
appendFileSync(DEBUG_FILE, msg)
|
||||
}
|
||||
}
|
||||
|
||||
const REPO = "code-yeongyu/go-claude-code-comment-checker"
|
||||
|
||||
interface PlatformInfo {
|
||||
os: string
|
||||
arch: string
|
||||
ext: "tar.gz" | "zip"
|
||||
}
|
||||
|
||||
const PLATFORM_MAP: Record<string, PlatformInfo> = {
|
||||
"darwin-arm64": { os: "darwin", arch: "arm64", ext: "tar.gz" },
|
||||
"darwin-x64": { os: "darwin", arch: "amd64", ext: "tar.gz" },
|
||||
"linux-arm64": { os: "linux", arch: "arm64", ext: "tar.gz" },
|
||||
"linux-x64": { os: "linux", arch: "amd64", ext: "tar.gz" },
|
||||
"win32-x64": { os: "windows", arch: "amd64", ext: "zip" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache directory for oh-my-opencode binaries.
|
||||
* Follows XDG Base Directory Specification.
|
||||
*/
|
||||
export function getCacheDir(): string {
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
const base = xdgCache || join(homedir(), ".cache")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the binary name based on platform.
|
||||
*/
|
||||
export function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached binary path if it exists.
|
||||
*/
|
||||
export function getCachedBinaryPath(): string | null {
|
||||
const binaryPath = join(getCacheDir(), getBinaryName())
|
||||
return existsSync(binaryPath) ? binaryPath : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version from the installed @code-yeongyu/comment-checker package.
|
||||
*/
|
||||
function getPackageVersion(): string {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = require("@code-yeongyu/comment-checker/package.json")
|
||||
return pkg.version
|
||||
} catch {
|
||||
// Fallback to hardcoded version if package not found
|
||||
return "0.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tar.gz archive using system tar command.
|
||||
*/
|
||||
async function extractTarGz(archivePath: string, destDir: string): Promise<void> {
|
||||
debugLog("Extracting tar.gz:", archivePath, "to", destDir)
|
||||
|
||||
const proc = spawn(["tar", "-xzf", archivePath, "-C", destDir], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract zip archive using system commands.
|
||||
*/
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
debugLog("Extracting zip:", archivePath, "to", destDir)
|
||||
|
||||
const proc = process.platform === "win32"
|
||||
? spawn(["powershell", "-command", `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
: spawn(["unzip", "-o", archivePath, "-d", destDir], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the comment-checker binary from GitHub Releases.
|
||||
* Returns the path to the downloaded binary, or null on failure.
|
||||
*/
|
||||
export async function downloadCommentChecker(): Promise<string | null> {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformInfo = PLATFORM_MAP[platformKey]
|
||||
|
||||
if (!platformInfo) {
|
||||
debugLog(`Unsupported platform: ${platformKey}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheDir = getCacheDir()
|
||||
const binaryName = getBinaryName()
|
||||
const binaryPath = join(cacheDir, binaryName)
|
||||
|
||||
// Already exists in cache
|
||||
if (existsSync(binaryPath)) {
|
||||
debugLog("Binary already cached at:", binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
const version = getPackageVersion()
|
||||
const { os, arch, ext } = platformInfo
|
||||
const assetName = `comment-checker_v${version}_${os}_${arch}.${ext}`
|
||||
const downloadUrl = `https://github.com/${REPO}/releases/download/v${version}/${assetName}`
|
||||
|
||||
debugLog(`Downloading from: ${downloadUrl}`)
|
||||
console.log(`[oh-my-opencode] Downloading comment-checker binary...`)
|
||||
|
||||
try {
|
||||
// Ensure cache directory exists
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Download with fetch() - Bun handles redirects automatically
|
||||
const response = await fetch(downloadUrl, { redirect: "follow" })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const archivePath = join(cacheDir, assetName)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
await Bun.write(archivePath, arrayBuffer)
|
||||
|
||||
debugLog(`Downloaded archive to: ${archivePath}`)
|
||||
|
||||
// Extract based on file type
|
||||
if (ext === "tar.gz") {
|
||||
await extractTarGz(archivePath, cacheDir)
|
||||
} else {
|
||||
await extractZip(archivePath, cacheDir)
|
||||
}
|
||||
|
||||
// Clean up archive
|
||||
if (existsSync(archivePath)) {
|
||||
unlinkSync(archivePath)
|
||||
}
|
||||
|
||||
// Set execute permission on Unix
|
||||
if (process.platform !== "win32" && existsSync(binaryPath)) {
|
||||
chmodSync(binaryPath, 0o755)
|
||||
}
|
||||
|
||||
debugLog(`Successfully downloaded binary to: ${binaryPath}`)
|
||||
console.log(`[oh-my-opencode] comment-checker binary ready.`)
|
||||
|
||||
return binaryPath
|
||||
|
||||
} catch (err) {
|
||||
debugLog(`Failed to download: ${err}`)
|
||||
console.error(`[oh-my-opencode] Failed to download comment-checker: ${err instanceof Error ? err.message : err}`)
|
||||
console.error(`[oh-my-opencode] Comment checking disabled.`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the comment-checker binary is available.
|
||||
* First checks cache, then downloads if needed.
|
||||
* Returns the binary path or null if unavailable.
|
||||
*/
|
||||
export async function ensureCommentCheckerBinary(): Promise<string | null> {
|
||||
// Check cache first
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("Using cached binary:", cachedPath)
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
// Download if not cached
|
||||
return downloadCommentChecker()
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import type { PendingCall, FileComments } from "./types"
|
||||
import { detectComments, isSupportedFile } from "./detector"
|
||||
import { applyFilters } from "./filters"
|
||||
import { formatHookMessage } from "./output"
|
||||
import type { PendingCall } from "./types"
|
||||
import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli"
|
||||
|
||||
import * as fs from "fs"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = "/tmp/comment-checker-debug.log"
|
||||
|
||||
function debugLog(...args: unknown[]) {
|
||||
if (DEBUG) {
|
||||
const msg = `[${new Date().toISOString()}] [comment-checker:hook] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
|
||||
fs.appendFileSync(DEBUG_FILE, msg)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCalls = new Map<string, PendingCall>()
|
||||
const PENDING_CALL_TTL = 60_000
|
||||
|
||||
let cliPathPromise: Promise<string | null> | null = null
|
||||
|
||||
function cleanupOldPendingCalls(): void {
|
||||
const now = Date.now()
|
||||
for (const [callID, call] of pendingCalls) {
|
||||
@@ -18,30 +31,50 @@ function cleanupOldPendingCalls(): void {
|
||||
setInterval(cleanupOldPendingCalls, 10_000)
|
||||
|
||||
export function createCommentCheckerHooks() {
|
||||
debugLog("createCommentCheckerHooks called")
|
||||
|
||||
// Start background CLI initialization (may trigger lazy download)
|
||||
startBackgroundInit()
|
||||
cliPathPromise = getCommentCheckerPath()
|
||||
cliPathPromise.then(path => {
|
||||
debugLog("CLI path resolved:", path || "disabled (no binary)")
|
||||
}).catch(err => {
|
||||
debugLog("CLI path resolution error:", err)
|
||||
})
|
||||
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> }
|
||||
): Promise<void> => {
|
||||
debugLog("tool.execute.before:", { tool: input.tool, callID: input.callID, args: output.args })
|
||||
|
||||
const toolLower = input.tool.toLowerCase()
|
||||
if (toolLower !== "write" && toolLower !== "edit" && toolLower !== "multiedit") {
|
||||
debugLog("skipping non-write/edit tool:", toolLower)
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = (output.args.filePath ?? output.args.file_path) as string | undefined
|
||||
const filePath = (output.args.filePath ?? output.args.file_path ?? output.args.path) as string | undefined
|
||||
const content = output.args.content as string | undefined
|
||||
const oldString = output.args.oldString ?? output.args.old_string as string | undefined
|
||||
const newString = output.args.newString ?? output.args.new_string as string | undefined
|
||||
const edits = output.args.edits as Array<{ old_string: string; new_string: string }> | undefined
|
||||
|
||||
debugLog("extracted filePath:", filePath)
|
||||
|
||||
if (!filePath) {
|
||||
debugLog("no filePath found")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSupportedFile(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
debugLog("registering pendingCall:", { callID: input.callID, filePath, tool: toolLower })
|
||||
pendingCalls.set(input.callID, {
|
||||
filePath,
|
||||
content,
|
||||
oldString: oldString as string | undefined,
|
||||
newString: newString as string | undefined,
|
||||
edits,
|
||||
tool: toolLower as "write" | "edit" | "multiedit",
|
||||
sessionID: input.sessionID,
|
||||
timestamp: Date.now(),
|
||||
@@ -52,46 +85,79 @@ export function createCommentCheckerHooks() {
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
): Promise<void> => {
|
||||
debugLog("tool.execute.after:", { tool: input.tool, callID: input.callID })
|
||||
|
||||
const pendingCall = pendingCalls.get(input.callID)
|
||||
if (!pendingCall) {
|
||||
debugLog("no pendingCall found for:", input.callID)
|
||||
return
|
||||
}
|
||||
|
||||
pendingCalls.delete(input.callID)
|
||||
debugLog("processing pendingCall:", pendingCall)
|
||||
|
||||
if (output.output.toLowerCase().includes("error")) {
|
||||
// Only skip if the output indicates a tool execution failure
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const isToolFailure =
|
||||
outputLower.includes("error:") ||
|
||||
outputLower.includes("failed to") ||
|
||||
outputLower.includes("could not") ||
|
||||
outputLower.startsWith("error")
|
||||
|
||||
if (isToolFailure) {
|
||||
debugLog("skipping due to tool failure in output")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let content: string
|
||||
|
||||
if (pendingCall.content) {
|
||||
content = pendingCall.content
|
||||
} else {
|
||||
const file = Bun.file(pendingCall.filePath)
|
||||
content = await file.text()
|
||||
}
|
||||
|
||||
const rawComments = await detectComments(pendingCall.filePath, content)
|
||||
const filteredComments = applyFilters(rawComments)
|
||||
|
||||
if (filteredComments.length === 0) {
|
||||
// Wait for CLI path resolution
|
||||
const cliPath = await cliPathPromise
|
||||
|
||||
if (!cliPath || !existsSync(cliPath)) {
|
||||
// CLI not available - silently skip comment checking
|
||||
debugLog("CLI not available, skipping comment check")
|
||||
return
|
||||
}
|
||||
|
||||
const fileComments: FileComments[] = [
|
||||
{
|
||||
filePath: pendingCall.filePath,
|
||||
comments: filteredComments,
|
||||
},
|
||||
]
|
||||
|
||||
const message = formatHookMessage(fileComments)
|
||||
output.output += `\n\n${message}`
|
||||
} catch {}
|
||||
|
||||
// CLI mode only
|
||||
debugLog("using CLI:", cliPath)
|
||||
await processWithCli(input, pendingCall, output, cliPath)
|
||||
} catch (err) {
|
||||
debugLog("tool.execute.after failed:", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function processWithCli(
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
pendingCall: PendingCall,
|
||||
output: { output: string },
|
||||
cliPath: string
|
||||
): Promise<void> {
|
||||
debugLog("using CLI mode with path:", cliPath)
|
||||
|
||||
const hookInput: HookInput = {
|
||||
session_id: pendingCall.sessionID,
|
||||
tool_name: pendingCall.tool.charAt(0).toUpperCase() + pendingCall.tool.slice(1),
|
||||
transcript_path: "",
|
||||
cwd: process.cwd(),
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_input: {
|
||||
file_path: pendingCall.filePath,
|
||||
content: pendingCall.content,
|
||||
old_string: pendingCall.oldString,
|
||||
new_string: pendingCall.newString,
|
||||
edits: pendingCall.edits,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await runCommentChecker(hookInput, cliPath)
|
||||
|
||||
if (result.hasComments && result.message) {
|
||||
debugLog("CLI detected comments, appending message")
|
||||
output.output += `\n\n${result.message}`
|
||||
} else {
|
||||
debugLog("CLI: no comments detected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,12 @@ export interface CommentInfo {
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface LanguageConfig {
|
||||
extensions: string[]
|
||||
commentQuery: string
|
||||
docstringQuery?: string
|
||||
}
|
||||
|
||||
export interface PendingCall {
|
||||
filePath: string
|
||||
content?: string
|
||||
oldString?: string
|
||||
newString?: string
|
||||
edits?: Array<{ old_string: string; new_string: string }>
|
||||
tool: "write" | "edit" | "multiedit"
|
||||
sessionID: string
|
||||
timestamp: number
|
||||
|
||||
9
src/hooks/directory-agents-injector/constants.ts
Normal file
9
src/hooks/directory-agents-injector/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
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";
|
||||
126
src/hooks/directory-agents-injector/index.ts
Normal file
126
src/hooks/directory-agents-injector/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
48
src/hooks/directory-agents-injector/storage.ts
Normal file
48
src/hooks/directory-agents-injector/storage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
5
src/hooks/directory-agents-injector/types.ts
Normal file
5
src/hooks/directory-agents-injector/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface InjectedPathsData {
|
||||
sessionID: string;
|
||||
injectedPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
27
src/hooks/empty-task-response-detector.ts
Normal file
27
src/hooks/empty-task-response-detector.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
131
src/hooks/grep-output-truncator.ts
Normal file
131
src/hooks/grep-output-truncator.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
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,5 +1,9 @@
|
||||
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 { 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";
|
||||
|
||||
10
src/hooks/session-recovery/constants.ts
Normal file
10
src/hooks/session-recovery/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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,26 +1,23 @@
|
||||
/**
|
||||
* 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"
|
||||
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
|
||||
type RecoveryErrorType =
|
||||
| "tool_result_missing"
|
||||
| "thinking_block_order"
|
||||
| "thinking_disabled_violation"
|
||||
| "empty_content_message"
|
||||
| null
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
@@ -37,11 +34,6 @@ interface ToolUsePart {
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ThinkingPart {
|
||||
type: "thinking"
|
||||
thinking: string
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
id?: string
|
||||
@@ -51,16 +43,15 @@ interface MessagePart {
|
||||
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()
|
||||
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 {
|
||||
@@ -70,7 +61,10 @@ function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
if (message.includes("thinking") && message.includes("first block")) {
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
|
||||
@@ -110,7 +104,7 @@ async function recoverToolResultMissing(
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
// @ts-expect-error - SDK types may not include tool_result parts, but runtime accepts it
|
||||
// @ts-expect-error - SDK types may not include tool_result parts
|
||||
body: { parts: toolResultParts },
|
||||
})
|
||||
|
||||
@@ -121,182 +115,77 @@ async function recoverToolResultMissing(
|
||||
}
|
||||
|
||||
async function recoverThinkingBlockOrder(
|
||||
client: Client,
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
_failedAssistantMsg: MessageData,
|
||||
_directory: string
|
||||
): Promise<boolean> {
|
||||
const messageID = failedAssistantMsg.info?.id
|
||||
if (!messageID) {
|
||||
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
|
||||
|
||||
if (orphanMessages.length === 0) {
|
||||
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
|
||||
}
|
||||
|
||||
async function recoverEmptyContentMessage(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
const messageID = failedAssistantMsg.info?.id
|
||||
const parentMsgID = failedAssistantMsg.info?.parentID
|
||||
|
||||
if (!messageID) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Revert to parent message (delete the empty message)
|
||||
const revertTargetID = parentMsgID || messageID
|
||||
|
||||
try {
|
||||
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
|
||||
}
|
||||
let anySuccess = false
|
||||
for (const messageID of orphanMessages) {
|
||||
if (prependThinkingPart(sessionID, messageID)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
@@ -350,16 +239,14 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
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...",
|
||||
empty_content_message: "Fixing empty message...",
|
||||
}
|
||||
const toastTitle = toastTitles[errorType]
|
||||
const toastMessage = toastMessages[errorType]
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: toastTitle,
|
||||
message: toastMessage,
|
||||
title: toastTitles[errorType],
|
||||
message: toastMessages[errorType],
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
235
src/hooks/session-recovery/storage.ts
Normal file
235
src/hooks/session-recovery/storage.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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
|
||||
}
|
||||
82
src/hooks/session-recovery/types.ts
Normal file
82
src/hooks/session-recovery/types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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,18 +7,13 @@ interface Todo {
|
||||
id: string
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO ENFORCEMENT]
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
|
||||
Your todo list is NOT complete. There are still incomplete tasks remaining.
|
||||
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
||||
|
||||
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.`
|
||||
- Proceed without asking for permission
|
||||
- Mark each task complete when finished
|
||||
- Do not stop until all tasks are done`
|
||||
|
||||
function detectInterrupt(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
@@ -113,7 +108,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${incomplete.length}/${todos.length} tasks remaining]`,
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
163
src/index.ts
163
src/index.ts
@@ -1,57 +1,70 @@
|
||||
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"
|
||||
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";
|
||||
|
||||
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 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);
|
||||
|
||||
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,
|
||||
@@ -59,75 +72,79 @@ 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 todoContinuationEnforcer(input);
|
||||
await contextWindowMonitor.event(input);
|
||||
await directoryAgentsInjector.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 = {
|
||||
@@ -135,8 +152,19 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
role: "assistant" as const,
|
||||
sessionID,
|
||||
error,
|
||||
};
|
||||
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 sessionRecovery.handleSessionRecovery(messageInfo)
|
||||
}
|
||||
|
||||
if (sessionID && sessionID === mainSessionID) {
|
||||
@@ -145,25 +173,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({
|
||||
@@ -172,13 +200,16 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
currentTool: input.tool,
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
})
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
await contextWindowMonitor["tool.execute.after"](input, output)
|
||||
await commentChecker["tool.execute.after"](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);
|
||||
|
||||
if (input.sessionID === mainSessionID) {
|
||||
updateTerminalTitle({
|
||||
@@ -186,13 +217,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
})
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default OhMyOpenCodePlugin
|
||||
export default OhMyOpenCodePlugin;
|
||||
|
||||
export type {
|
||||
OhMyOpenCodeConfig,
|
||||
@@ -200,4 +231,4 @@ export type {
|
||||
AgentOverrideConfig,
|
||||
AgentOverrides,
|
||||
McpName,
|
||||
} from "./config"
|
||||
} from "./config";
|
||||
|
||||
@@ -20,5 +20,3 @@ export function createBuiltinMcps(disabledMcps: McpName[] = []) {
|
||||
|
||||
return mcps
|
||||
}
|
||||
|
||||
export const builtinMcps = allBuiltinMcps
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { spawn } from "bun"
|
||||
import { SG_CLI_PATH } from "./constants"
|
||||
import type { CliMatch, CliLanguage } from "./types"
|
||||
import { existsSync } from "fs"
|
||||
import {
|
||||
getSgCliPath,
|
||||
setSgCliPath,
|
||||
findSgCliPathSync,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
DEFAULT_MAX_MATCHES,
|
||||
} from "./constants"
|
||||
import { ensureAstGrepBinary } from "./downloader"
|
||||
import type { CliMatch, CliLanguage, SgResult } from "./types"
|
||||
|
||||
export interface RunOptions {
|
||||
pattern: string
|
||||
@@ -12,7 +21,47 @@ export interface RunOptions {
|
||||
updateAll?: boolean
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<CliMatch[]> {
|
||||
let resolvedCliPath: string | null = null
|
||||
let initPromise: Promise<string | null> | null = null
|
||||
|
||||
export async function getAstGrepPath(): Promise<string | null> {
|
||||
if (resolvedCliPath !== null && existsSync(resolvedCliPath)) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
if (initPromise) {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
const syncPath = findSgCliPathSync()
|
||||
if (syncPath && existsSync(syncPath)) {
|
||||
resolvedCliPath = syncPath
|
||||
setSgCliPath(syncPath)
|
||||
return syncPath
|
||||
}
|
||||
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
return downloadedPath
|
||||
}
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
export function startBackgroundInit(): void {
|
||||
if (!initPromise) {
|
||||
initPromise = getAstGrepPath()
|
||||
initPromise.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
||||
|
||||
if (options.rewrite) {
|
||||
@@ -35,32 +84,147 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
|
||||
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
|
||||
args.push(...paths)
|
||||
|
||||
const proc = spawn([SG_CLI_PATH, ...args], {
|
||||
let cliPath = getSgCliPath()
|
||||
|
||||
if (!existsSync(cliPath) && cliPath !== "sg") {
|
||||
const downloadedPath = await getAstGrepPath()
|
||||
if (downloadedPath) {
|
||||
cliPath = downloadedPath
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = DEFAULT_TIMEOUT_MS
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
try {
|
||||
stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||
stderr = await new Response(proc.stderr).text()
|
||||
exitCode = await proc.exited
|
||||
} 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
|
||||
if (
|
||||
nodeError.code === "ENOENT" ||
|
||||
nodeError.message?.includes("ENOENT") ||
|
||||
nodeError.message?.includes("not found")
|
||||
) {
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
return runSg(options)
|
||||
} else {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
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`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error: `Failed to spawn ast-grep: ${error.message}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && stdout.trim() === "") {
|
||||
if (stderr.includes("No files found")) {
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
if (stderr.trim()) {
|
||||
throw new Error(stderr.trim())
|
||||
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
|
||||
}
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||
const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||
|
||||
let matches: CliMatch[] = []
|
||||
try {
|
||||
return JSON.parse(stdout) as CliMatch[]
|
||||
matches = JSON.parse(outputToProcess) as CliMatch[]
|
||||
} catch {
|
||||
return []
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCliAvailable(): boolean {
|
||||
const path = findSgCliPathSync()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
export async function ensureCliAvailable(): Promise<boolean> {
|
||||
const path = await getAstGrepPath()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, statSync } 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
|
||||
@@ -21,32 +30,37 @@ function getPlatformPackageName(): string | null {
|
||||
return platformMap[`${platform}-${arch}`] ?? null
|
||||
}
|
||||
|
||||
function findSgCliPath(): string {
|
||||
// 1. Try to find from @ast-grep/cli package (installed via npm)
|
||||
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, process.platform === "win32" ? "sg.exe" : "sg")
|
||||
const sgPath = join(cliDir, binaryName)
|
||||
|
||||
if (existsSync(sgPath)) {
|
||||
if (existsSync(sgPath) && isValidBinary(sgPath)) {
|
||||
return sgPath
|
||||
}
|
||||
} catch {
|
||||
// @ast-grep/cli not installed, try platform-specific package
|
||||
// @ast-grep/cli not installed
|
||||
}
|
||||
|
||||
// 2. Try platform-specific package directly
|
||||
const platformPkg = getPlatformPackageName()
|
||||
if (platformPkg) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkgPath = require.resolve(`${platformPkg}/package.json`)
|
||||
const pkgDir = dirname(pkgPath)
|
||||
const binaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
||||
const binaryPath = join(pkgDir, binaryName)
|
||||
const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
||||
const binaryPath = join(pkgDir, astGrepName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
@@ -54,12 +68,39 @@ function findSgCliPath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to system PATH
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path) && isValidBinary(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
let resolvedCliPath: string | null = null
|
||||
|
||||
export function getSgCliPath(): string {
|
||||
if (resolvedCliPath !== null) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
const syncPath = findSgCliPathSync()
|
||||
if (syncPath) {
|
||||
resolvedCliPath = syncPath
|
||||
return syncPath
|
||||
}
|
||||
|
||||
return "sg"
|
||||
}
|
||||
|
||||
// ast-grep CLI path (auto-detected from node_modules or system PATH)
|
||||
export const SG_CLI_PATH = findSgCliPath()
|
||||
export function setSgCliPath(path: string): void {
|
||||
resolvedCliPath = path
|
||||
}
|
||||
|
||||
export const SG_CLI_PATH = getSgCliPath()
|
||||
|
||||
// CLI supported languages (25 total)
|
||||
export const CLI_LANGUAGES = [
|
||||
@@ -94,6 +135,10 @@ 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"],
|
||||
@@ -121,3 +166,99 @@ export const LANG_EXTENSIONS: Record<string, string[]> = {
|
||||
tsx: [".tsx"],
|
||||
yaml: [".yml", ".yaml"],
|
||||
}
|
||||
|
||||
export interface EnvironmentCheckResult {
|
||||
cli: {
|
||||
available: boolean
|
||||
path: string
|
||||
error?: string
|
||||
}
|
||||
napi: {
|
||||
available: boolean
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ast-grep CLI and NAPI are available.
|
||||
* Call this at startup to provide early feedback about missing dependencies.
|
||||
*/
|
||||
export function checkEnvironment(): EnvironmentCheckResult {
|
||||
const result: EnvironmentCheckResult = {
|
||||
cli: {
|
||||
available: false,
|
||||
path: SG_CLI_PATH,
|
||||
},
|
||||
napi: {
|
||||
available: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Check CLI availability
|
||||
if (existsSync(SG_CLI_PATH)) {
|
||||
result.cli.available = true
|
||||
} else if (SG_CLI_PATH === "sg") {
|
||||
// Fallback path - try which/where to find in PATH
|
||||
try {
|
||||
const { spawnSync } = require("child_process")
|
||||
const whichResult = spawnSync(process.platform === "win32" ? "where" : "which", ["sg"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
})
|
||||
result.cli.available = whichResult.status === 0 && !!whichResult.stdout?.trim()
|
||||
if (!result.cli.available) {
|
||||
result.cli.error = "sg binary not found in PATH"
|
||||
}
|
||||
} catch {
|
||||
result.cli.error = "Failed to check sg availability"
|
||||
}
|
||||
} else {
|
||||
result.cli.error = `Binary not found: ${SG_CLI_PATH}`
|
||||
}
|
||||
|
||||
// Check NAPI availability
|
||||
try {
|
||||
require("@ast-grep/napi")
|
||||
result.napi.available = true
|
||||
} catch (e) {
|
||||
result.napi.available = false
|
||||
result.napi.error = `@ast-grep/napi not installed: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Format environment check result as user-friendly message.
|
||||
*/
|
||||
export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
|
||||
const lines: string[] = ["ast-grep Environment Status:", ""]
|
||||
|
||||
// CLI status
|
||||
if (result.cli.available) {
|
||||
lines.push(`✓ CLI: Available (${result.cli.path})`)
|
||||
} else {
|
||||
lines.push(`✗ CLI: Not available`)
|
||||
if (result.cli.error) {
|
||||
lines.push(` Error: ${result.cli.error}`)
|
||||
}
|
||||
lines.push(` Install: bun add -D @ast-grep/cli`)
|
||||
}
|
||||
|
||||
// NAPI status
|
||||
if (result.napi.available) {
|
||||
lines.push(`✓ NAPI: Available`)
|
||||
} else {
|
||||
lines.push(`✗ NAPI: Not available`)
|
||||
if (result.napi.error) {
|
||||
lines.push(` Error: ${result.napi.error}`)
|
||||
}
|
||||
lines.push(` Install: bun add -D @ast-grep/napi`)
|
||||
}
|
||||
|
||||
lines.push("")
|
||||
lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`)
|
||||
lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
151
src/tools/ast-grep/downloader.ts
Normal file
151
src/tools/ast-grep/downloader.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { spawn } from "bun"
|
||||
import { existsSync, mkdirSync, chmodSync, unlinkSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { createRequire } from "module"
|
||||
|
||||
const REPO = "ast-grep/ast-grep"
|
||||
|
||||
// IMPORTANT: Update this when bumping @ast-grep/cli in package.json
|
||||
// This is only used as fallback when @ast-grep/cli package.json cannot be read
|
||||
const DEFAULT_VERSION = "0.40.0"
|
||||
|
||||
function getAstGrepVersion(): string {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = require("@ast-grep/cli/package.json")
|
||||
return pkg.version
|
||||
} catch {
|
||||
return DEFAULT_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
interface PlatformInfo {
|
||||
arch: string
|
||||
os: string
|
||||
}
|
||||
|
||||
const PLATFORM_MAP: Record<string, PlatformInfo> = {
|
||||
"darwin-arm64": { arch: "aarch64", os: "apple-darwin" },
|
||||
"darwin-x64": { arch: "x86_64", os: "apple-darwin" },
|
||||
"linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" },
|
||||
"linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" },
|
||||
"win32-x64": { arch: "x86_64", os: "pc-windows-msvc" },
|
||||
"win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
|
||||
"win32-ia32": { arch: "i686", os: "pc-windows-msvc" },
|
||||
}
|
||||
|
||||
export function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
|
||||
const base = localAppData || join(homedir(), "AppData", "Local")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
}
|
||||
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
const base = xdgCache || join(homedir(), ".cache")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
}
|
||||
|
||||
export function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "sg.exe" : "sg"
|
||||
}
|
||||
|
||||
export function getCachedBinaryPath(): string | null {
|
||||
const binaryPath = join(getCacheDir(), getBinaryName())
|
||||
return existsSync(binaryPath) ? binaryPath : null
|
||||
}
|
||||
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
const proc =
|
||||
process.platform === "win32"
|
||||
? spawn(
|
||||
[
|
||||
"powershell",
|
||||
"-command",
|
||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
)
|
||||
: spawn(["unzip", "-o", archivePath, "-d", destDir], { stdout: "pipe", stderr: "pipe" })
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const toolHint =
|
||||
process.platform === "win32"
|
||||
? "Ensure PowerShell is available on your system."
|
||||
: "Please install 'unzip' (e.g., apt install unzip, brew install unzip)."
|
||||
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}\n\n${toolHint}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise<string | null> {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformInfo = PLATFORM_MAP[platformKey]
|
||||
|
||||
if (!platformInfo) {
|
||||
console.error(`[oh-my-opencode] Unsupported platform for ast-grep: ${platformKey}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheDir = getCacheDir()
|
||||
const binaryName = getBinaryName()
|
||||
const binaryPath = join(cacheDir, binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
const { arch, os } = platformInfo
|
||||
const assetName = `app-${arch}-${os}.zip`
|
||||
const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
|
||||
|
||||
console.log(`[oh-my-opencode] Downloading ast-grep binary...`)
|
||||
|
||||
try {
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, { redirect: "follow" })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const archivePath = join(cacheDir, assetName)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
await Bun.write(archivePath, arrayBuffer)
|
||||
|
||||
await extractZip(archivePath, cacheDir)
|
||||
|
||||
if (existsSync(archivePath)) {
|
||||
unlinkSync(archivePath)
|
||||
}
|
||||
|
||||
if (process.platform !== "win32" && existsSync(binaryPath)) {
|
||||
chmodSync(binaryPath, 0o755)
|
||||
}
|
||||
|
||||
console.log(`[oh-my-opencode] ast-grep binary ready.`)
|
||||
|
||||
return binaryPath
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[oh-my-opencode] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAstGrepBinary(): Promise<string | null> {
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
const version = getAstGrepVersion()
|
||||
return downloadAstGrep(version)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
} from "./tools"
|
||||
import { ast_grep_search, ast_grep_replace } from "./tools"
|
||||
|
||||
export const builtinTools = {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
}
|
||||
|
||||
export {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
}
|
||||
export { ast_grep_search, ast_grep_replace }
|
||||
export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader"
|
||||
export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli"
|
||||
export { checkEnvironment, formatEnvironmentCheck } from "./constants"
|
||||
export type { EnvironmentCheckResult } from "./constants"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parse, Lang } from "@ast-grep/napi"
|
||||
import { NAPI_LANGUAGES } from "./constants"
|
||||
import type { NapiLanguage, AnalyzeResult, MetaVariable, Range } from "./types"
|
||||
|
||||
const LANG_MAP: Record<NapiLanguage, Lang> = {
|
||||
@@ -10,7 +11,16 @@ const LANG_MAP: Record<NapiLanguage, Lang> = {
|
||||
}
|
||||
|
||||
export function parseCode(code: string, lang: NapiLanguage) {
|
||||
return parse(LANG_MAP[lang], code)
|
||||
const parseLang = LANG_MAP[lang]
|
||||
if (!parseLang) {
|
||||
const supportedLangs = NAPI_LANGUAGES.join(", ")
|
||||
throw new Error(
|
||||
`Unsupported language for NAPI: "${lang}"\n` +
|
||||
`Supported languages: ${supportedLangs}\n\n` +
|
||||
`Use ast_grep_search for other languages (25 supported via CLI).`
|
||||
)
|
||||
}
|
||||
return parse(parseLang, code)
|
||||
}
|
||||
|
||||
export function findPattern(root: ReturnType<typeof parseCode>, pattern: string) {
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./constants"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { analyzeCode, transformCode, getRootInfo } from "./napi"
|
||||
import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult } from "./utils"
|
||||
import type { CliLanguage, NapiLanguage } from "./types"
|
||||
import { formatSearchResult, formatReplaceResult } from "./utils"
|
||||
import type { CliLanguage } from "./types"
|
||||
|
||||
function showOutputToUser(context: unknown, output: string): void {
|
||||
const ctx = context as { metadata?: (input: { metadata: { output: string } }) => void }
|
||||
ctx.metadata?.({ metadata: { output } })
|
||||
}
|
||||
|
||||
function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
|
||||
const src = pattern.trim()
|
||||
|
||||
if (lang === "python") {
|
||||
if (src.startsWith("class ") && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
}
|
||||
|
||||
if (["javascript", "typescript", "tsx"].includes(lang)) {
|
||||
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
|
||||
return `💡 Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const ast_grep_search = tool({
|
||||
description:
|
||||
"Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
|
||||
"Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
|
||||
"IMPORTANT: Patterns must be complete AST nodes (valid code). " +
|
||||
"For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " +
|
||||
"Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
|
||||
args: {
|
||||
pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$)"),
|
||||
pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
|
||||
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
|
||||
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
|
||||
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
|
||||
@@ -24,14 +48,23 @@ export const ast_grep_search = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const matches = await runSg({
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
lang: args.lang as CliLanguage,
|
||||
paths: args.paths,
|
||||
globs: args.globs,
|
||||
context: args.context,
|
||||
})
|
||||
const output = formatSearchResult(matches)
|
||||
|
||||
let output = formatSearchResult(result)
|
||||
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
|
||||
if (hint) {
|
||||
output += `\n\n${hint}`
|
||||
}
|
||||
}
|
||||
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
@@ -57,7 +90,7 @@ export const ast_grep_replace = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const matches = await runSg({
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
rewrite: args.rewrite,
|
||||
lang: args.lang as CliLanguage,
|
||||
@@ -65,7 +98,7 @@ export const ast_grep_replace = tool({
|
||||
globs: args.globs,
|
||||
updateAll: args.dryRun === false,
|
||||
})
|
||||
const output = formatReplaceResult(matches, args.dryRun !== false)
|
||||
const output = formatReplaceResult(result, args.dryRun !== false)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
@@ -76,83 +109,4 @@ 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,3 +51,11 @@ 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,13 +1,28 @@
|
||||
import type { CliMatch, AnalyzeResult } from "./types"
|
||||
import type { CliMatch, AnalyzeResult, SgResult } from "./types"
|
||||
|
||||
export function formatSearchResult(matches: CliMatch[]): string {
|
||||
if (matches.length === 0) {
|
||||
export function formatSearchResult(result: SgResult): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found"
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${matches.length} match(es):\n`]
|
||||
const lines: string[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
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) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.lines.trim()}`)
|
||||
@@ -17,15 +32,30 @@ export function formatSearchResult(matches: CliMatch[]): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatReplaceResult(matches: CliMatch[], isDryRun: boolean): string {
|
||||
if (matches.length === 0) {
|
||||
export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found to replace"
|
||||
}
|
||||
|
||||
const prefix = isDryRun ? "[DRY RUN] " : ""
|
||||
const lines: string[] = [`${prefix}${matches.length} replacement(s):\n`]
|
||||
const lines: string[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
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) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.text}`)
|
||||
|
||||
129
src/tools/glob/cli.ts
Normal file
129
src/tools/glob/cli.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/tools/glob/constants.ts
Normal file
12
src/tools/glob/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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
|
||||
3
src/tools/glob/index.ts
Normal file
3
src/tools/glob/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { glob } from "./tools"
|
||||
|
||||
export { glob }
|
||||
36
src/tools/glob/tools.ts
Normal file
36
src/tools/glob/tools.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
21
src/tools/glob/types.ts
Normal file
21
src/tools/glob/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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
|
||||
}
|
||||
26
src/tools/glob/utils.ts
Normal file
26
src/tools/glob/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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")
|
||||
}
|
||||
3
src/tools/grep/index.ts
Normal file
3
src/tools/grep/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { grep } from "./tools"
|
||||
|
||||
export { grep }
|
||||
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { runRg } from "./cli"
|
||||
import { formatGrepResult } from "./utils"
|
||||
|
||||
export const safe_grep = tool({
|
||||
export const grep = tool({
|
||||
description:
|
||||
"Fast content search tool with safety limits (60s timeout, 10MB output). " +
|
||||
"Searches file contents using regular expressions. " +
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
ast_grep_replace,
|
||||
} from "./ast-grep"
|
||||
|
||||
import { safe_grep } from "./safe-grep"
|
||||
import { grep } from "./grep"
|
||||
import { glob } from "./glob"
|
||||
|
||||
export const builtinTools = {
|
||||
lsp_hover,
|
||||
@@ -33,5 +34,6 @@ export const builtinTools = {
|
||||
lsp_code_action_resolve,
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
safe_grep,
|
||||
grep,
|
||||
glob,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -155,6 +156,7 @@ export class LSPClient {
|
||||
private openedFiles = new Set<string>()
|
||||
private stderrBuffer: string[] = []
|
||||
private processExited = false
|
||||
private diagnosticsStore = new Map<string, Diagnostic[]>()
|
||||
|
||||
constructor(
|
||||
private root: string,
|
||||
@@ -290,7 +292,11 @@ export class LSPClient {
|
||||
try {
|
||||
const msg = JSON.parse(content)
|
||||
|
||||
if ("id" in msg && "method" in msg) {
|
||||
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) {
|
||||
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)!
|
||||
@@ -347,9 +353,14 @@ 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") {
|
||||
this.respond(id, [{}])
|
||||
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)
|
||||
} else if (method === "client/registerCapability") {
|
||||
this.respond(id, null)
|
||||
} else if (method === "window/workDoneProgress/create") {
|
||||
@@ -412,7 +423,9 @@ export class LSPClient {
|
||||
...this.server.initialization,
|
||||
})
|
||||
this.notify("initialized")
|
||||
this.notify("workspace/didChangeConfiguration", { settings: {} })
|
||||
this.notify("workspace/didChangeConfiguration", {
|
||||
settings: { json: { validate: { enable: true } } },
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
@@ -477,13 +490,23 @@ export class LSPClient {
|
||||
return this.send("workspace/symbol", { query })
|
||||
}
|
||||
|
||||
async diagnostics(filePath: string): Promise<unknown> {
|
||||
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
|
||||
const absPath = resolve(filePath)
|
||||
const uri = `file://${absPath}`
|
||||
await this.openFile(absPath)
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
return this.send("textDocument/diagnostic", {
|
||||
textDocument: { uri: `file://${absPath}` },
|
||||
})
|
||||
|
||||
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) ?? [] }
|
||||
}
|
||||
|
||||
async prepareRename(filePath: string, line: number, character: number): Promise<unknown> {
|
||||
@@ -545,5 +568,6 @@ export class LSPClient {
|
||||
this.proc?.kill()
|
||||
this.proc = null
|
||||
this.processExited = true
|
||||
this.diagnosticsStore.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ 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"],
|
||||
@@ -171,4 +175,18 @@ 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,5 +1,10 @@
|
||||
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,
|
||||
@@ -9,7 +14,6 @@ import {
|
||||
formatDiagnostic,
|
||||
filterDiagnosticsBySeverity,
|
||||
formatPrepareRenameResult,
|
||||
formatWorkspaceEdit,
|
||||
formatCodeActions,
|
||||
applyWorkspaceEdit,
|
||||
formatApplyResult,
|
||||
@@ -112,7 +116,14 @@ export const lsp_find_references = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const output = result.map(formatLocation).join("\n")
|
||||
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")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
@@ -138,13 +149,21 @@ export const lsp_document_symbols = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
let output: string
|
||||
if ("range" in result[0]) {
|
||||
output = (result as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)).join("\n")
|
||||
} else {
|
||||
output = (result as SymbolInfo[]).map(formatSymbolInfo).join("\n")
|
||||
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}):`)
|
||||
}
|
||||
return output
|
||||
|
||||
if ("range" in limited[0]) {
|
||||
lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)))
|
||||
} else {
|
||||
lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo))
|
||||
}
|
||||
return lines.join("\n")
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
@@ -171,8 +190,15 @@ export const lsp_workspace_symbols = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const limited = args.limit ? result.slice(0, args.limit) : result
|
||||
const output = limited.map(formatSymbolInfo).join("\n")
|
||||
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")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
@@ -213,7 +239,14 @@ export const lsp_diagnostics = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const output = diagnostics.map(formatDiagnostic).join("\n")
|
||||
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")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
Diagnostic,
|
||||
PrepareRenameResult,
|
||||
PrepareRenameDefaultBehavior,
|
||||
Range,
|
||||
WorkspaceEdit,
|
||||
TextEdit,
|
||||
CodeAction,
|
||||
@@ -165,21 +166,35 @@ export function filterDiagnosticsBySeverity(
|
||||
}
|
||||
|
||||
export function formatPrepareRenameResult(
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | null
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null
|
||||
): string {
|
||||
if (!result) return "Cannot rename at this position"
|
||||
|
||||
// Case 1: { defaultBehavior: boolean }
|
||||
if ("defaultBehavior" in result) {
|
||||
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
|
||||
}
|
||||
|
||||
const startLine = result.range.start.line + 1
|
||||
const startChar = result.range.start.character
|
||||
const endLine = result.range.end.line + 1
|
||||
const endChar = result.range.end.character
|
||||
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
|
||||
// Case 2: { range: Range, placeholder?: string }
|
||||
if ("range" in result && result.range) {
|
||||
const startLine = result.range.start.line + 1
|
||||
const startChar = result.range.start.character
|
||||
const endLine = result.range.end.line + 1
|
||||
const endChar = result.range.end.character
|
||||
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
|
||||
}
|
||||
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
|
||||
// Case 3: Range directly (has start/end but no range property)
|
||||
if ("start" in result && "end" in result) {
|
||||
const startLine = result.start.line + 1
|
||||
const startChar = result.start.character
|
||||
const endLine = result.end.line + 1
|
||||
const endChar = result.end.character
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`
|
||||
}
|
||||
|
||||
return "Cannot rename at this position"
|
||||
}
|
||||
|
||||
export function formatTextEdit(edit: TextEdit): string {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { safe_grep } from "./tools"
|
||||
|
||||
export { safe_grep }
|
||||
4
test-dir/AGENTS.md
Normal file
4
test-dir/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## Root Level Rules
|
||||
|
||||
- Root rule 1
|
||||
- Root rule 2
|
||||
3
test-dir/another.ts
Normal file
3
test-dir/another.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const config = {
|
||||
strict: true
|
||||
}
|
||||
4
test-dir/nested/AGENTS.md
Normal file
4
test-dir/nested/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## Nested Level Rules
|
||||
|
||||
- Nested rule 1 (더 specific)
|
||||
- Nested rule 2
|
||||
1
test-dir/nested/deep.ts
Normal file
1
test-dir/nested/deep.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const deep = true
|
||||
3
test-dir/test.ts
Normal file
3
test-dir/test.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function greet(name: string): string {
|
||||
return `Hello, ${name}!`
|
||||
}
|
||||
Reference in New Issue
Block a user