Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70fe08a15f | ||
|
|
13ebeb9853 | ||
|
|
2452a4789d | ||
|
|
44640b985d | ||
|
|
794b5263c2 | ||
|
|
b0c39e222a | ||
|
|
bd05f5b434 | ||
|
|
a82575b55f | ||
|
|
ff760e5865 | ||
|
|
4039722160 | ||
|
|
439785ef90 | ||
|
|
e5330311dd | ||
|
|
b122273c2f | ||
|
|
06dee7248b | ||
|
|
c8aed3f428 | ||
|
|
1d4b5dec4a | ||
|
|
a217610ae4 | ||
|
|
b3775719b4 | ||
|
|
490c0b626f | ||
|
|
b30c17ac77 | ||
|
|
a5983f1678 | ||
|
|
2948d94a3c | ||
|
|
c66cfbb8c6 | ||
|
|
f66c886e0d | ||
|
|
1c55385cb5 | ||
|
|
f3db564b2e | ||
|
|
15b0ee80e1 | ||
|
|
2cab836a3b | ||
|
|
4efa58616f | ||
|
|
fbae3aeb6b | ||
|
|
74da07d584 | ||
|
|
7cd04a246c | ||
|
|
1de7df4933 | ||
|
|
ea6121ee1c | ||
|
|
4939f81625 | ||
|
|
820b339fae | ||
|
|
5412578600 | ||
|
|
502e9f504f | ||
|
|
8c3d413c8a | ||
|
|
b51d0bdf65 | ||
|
|
b2adda6e90 | ||
|
|
0da20f21b0 | ||
|
|
2f1ede072f | ||
|
|
ffeb92eb13 | ||
|
|
d49c221cb1 | ||
|
|
dea17dc3ba | ||
|
|
c6efe70f09 | ||
|
|
8cbdfbaf78 | ||
|
|
7cb3f23c2b | ||
|
|
471cf868ff | ||
|
|
f890abdc11 | ||
|
|
a295202a81 |
BIN
.github/assets/orchestrator-sisyphus.png
vendored
Normal file
BIN
.github/assets/orchestrator-sisyphus.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 984 KiB |
@@ -1,7 +1,6 @@
|
||||
---
|
||||
description: Publish oh-my-opencode to npm via GitHub Actions workflow
|
||||
argument-hint: <patch|minor|major>
|
||||
model: opencode/big-pickle
|
||||
---
|
||||
|
||||
<command-instruction>
|
||||
|
||||
51
AGENTS.md
51
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2025-12-28T19:26:00+09:00
|
||||
**Commit:** 122e918
|
||||
**Generated:** 2026-01-02T00:10:00+09:00
|
||||
**Commit:** b0c39e2
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
@@ -14,13 +14,14 @@ OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orc
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # AI agents (7): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker
|
||||
│ ├── hooks/ # 21 lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── hooks/ # 22 lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, etc. - see src/tools/AGENTS.md
|
||||
│ ├── mcp/ # MCP servers: context7, websearch_exa, grep_app
|
||||
│ ├── features/ # Claude Code compatibility - see src/features/AGENTS.md
|
||||
│ ├── features/ # Claude Code compatibility + core features - see src/features/AGENTS.md
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ ├── auth/ # Google Antigravity OAuth (antigravity/)
|
||||
│ ├── shared/ # Utilities: deep-merge, pattern-matcher, logger, etc.
|
||||
│ ├── auth/ # Google Antigravity OAuth - see src/auth/AGENTS.md
|
||||
│ ├── shared/ # Utilities: deep-merge, pattern-matcher, logger, etc. - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor, run - see src/cli/AGENTS.md
|
||||
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
├── assets/ # JSON schema
|
||||
@@ -34,14 +35,21 @@ oh-my-opencode/
|
||||
| Add agent | `src/agents/` | Create .ts, add to builtinAgents in index.ts, update types.ts |
|
||||
| Add hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
|
||||
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
|
||||
| Add MCP | `src/mcp/` | Create config, add to index.ts |
|
||||
| Add MCP | `src/mcp/` | Create config, add to index.ts and types.ts |
|
||||
| Add skill | `src/features/builtin-skills/` | Create skill dir with SKILL.md |
|
||||
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
|
||||
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
|
||||
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google models |
|
||||
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google/Gemini models |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts for task management |
|
||||
| Skill MCP | `src/features/skill-mcp-manager/` | MCP servers embedded in skills |
|
||||
| Interactive terminal | `src/tools/interactive-bash/` | tmux session management |
|
||||
| CLI installer | `src/cli/install.ts` | Interactive TUI installation |
|
||||
| Doctor checks | `src/cli/doctor/checks/` | Health checks for environment |
|
||||
| Shared utilities | `src/shared/` | Cross-cutting utilities |
|
||||
| Slash commands | `src/hooks/auto-slash-command/` | Auto-detect and execute `/command` patterns |
|
||||
| Ralph Loop | `src/hooks/ralph-loop/` | Self-referential dev loop until completion |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
@@ -64,6 +72,11 @@ oh-my-opencode/
|
||||
- **Year 2024**: NEVER use 2024 in code/prompts (use current year)
|
||||
- **Rush completion**: Never mark tasks complete without verification
|
||||
- **Over-exploration**: Stop searching when sufficient context found
|
||||
- **High temperature**: Don't use >0.3 for code-related agents
|
||||
- **Broad tool access**: Prefer explicit `include` over unrestricted access
|
||||
- **Sequential agent calls**: Use `background_task` for parallel execution
|
||||
- **Heavy PreToolUse logic**: Slows every tool call
|
||||
- **Self-planning for complex tasks**: Spawn planning agent (Prometheus) instead
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
@@ -74,6 +87,7 @@ oh-my-opencode/
|
||||
- **Agent tools**: `tools: { include: [...] }` or `tools: { exclude: [...] }`
|
||||
- **Temperature**: Most agents use `0.1` for consistency
|
||||
- **Hook naming**: `createXXXHook` function convention
|
||||
- **Factory pattern**: Components created via `createXXX()` functions
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
@@ -109,13 +123,30 @@ bun test # Run tests
|
||||
|
||||
## CI PIPELINE
|
||||
|
||||
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master
|
||||
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master, rolling `next` draft release
|
||||
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
|
||||
- **sisyphus-agent.yml**: Agent-in-CI for automated issue handling via `@sisyphus-dev-ai` mentions
|
||||
|
||||
## COMPLEXITY HOTSPOTS
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/index.ts` | 723 | Main plugin orchestration, all hook/tool initialization |
|
||||
| `src/cli/config-manager.ts` | 669 | JSONC parsing, environment detection, installation |
|
||||
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting, endpoint fallbacks |
|
||||
| `src/tools/lsp/client.ts` | 611 | LSP protocol, stdin/stdout buffering, JSON-RPC |
|
||||
| `src/auth/antigravity/response.ts` | 598 | Response transformation, streaming |
|
||||
| `src/auth/antigravity/thinking.ts` | 571 | Thinking block extraction/transformation |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Session compaction, multi-stage recovery pipeline |
|
||||
| `src/agents/sisyphus.ts` | 504 | Orchestrator prompt, delegation strategies |
|
||||
|
||||
## NOTES
|
||||
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`, 360+ tests
|
||||
- **OpenCode**: Requires >= 1.0.150
|
||||
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
|
||||
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
|
||||
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
|
||||
- **JSONC support**: Config files support comments (`// comment`, `/* block */`) and trailing commas
|
||||
- **Claude Code Compat**: Full compatibility layer for settings.json hooks, commands, skills, agents, MCPs
|
||||
- **Skill MCP**: Skills can embed MCP server configs in YAML frontmatter
|
||||
|
||||
25
README.ja.md
25
README.ja.md
@@ -660,6 +660,10 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
- **Empty Message Sanitizer**: 空のチャットメッセージによるAPIエラーを防止します。送信前にメッセージ内容を自動的にサニタイズします。
|
||||
- **Grep Output Truncator**: grep は山のようなテキストを返すことがあります。残りのコンテキストウィンドウに応じて動的に出力を切り詰めます—50% の余裕を維持し、最大 50k トークンに制限します。
|
||||
- **Tool Output Truncator**: 同じ考え方をより広範囲に適用します。Grep、Glob、LSP ツール、AST-grep の出力を切り詰めます。一度の冗長な検索がコンテキスト全体を食いつぶすのを防ぎます。
|
||||
- **Preemptive Compaction**: トークン制限に達する前にセッションを事前にコンパクションします。コンテキストウィンドウ使用率85%で実行されます。**デフォルトで有効。** `disabled_hooks: ["preemptive-compaction"]`で無効化できます。
|
||||
- **Compaction Context Injector**: セッションコンパクション中に重要なコンテキスト(AGENTS.md、現在のディレクトリ情報)を保持し、重要な状態を失わないようにします。
|
||||
- **Thinking Block Validator**: thinking ブロックを検証し、適切なフォーマットを確保し、不正な thinking コンテンツによる API エラーを防ぎます。
|
||||
- **Claude Code Hooks**: Claude Code の settings.json からフックを実行します - これは PreToolUse/PostToolUse/UserPromptSubmit/Stop フックを実行する互換性レイヤーです。
|
||||
|
||||
## 設定
|
||||
|
||||
@@ -755,7 +759,19 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
各エージェントでサポートされるオプション:`model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`。
|
||||
各エージェントでサポートされるオプション:`model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`。
|
||||
|
||||
`prompt_append` を使用すると、デフォルトのシステムプロンプトを置き換えずに追加の指示を付け加えられます:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"librarian": {
|
||||
"prompt_append": "Emacs Lisp のドキュメント検索には常に elisp-dev-mcp を使用してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Sisyphus` (メインオーケストレーター) と `build` (デフォルトエージェント) も同じオプションで設定をオーバーライドできます。
|
||||
|
||||
@@ -874,7 +890,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
|
||||
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
|
||||
|
||||
**`auto-update-checker`と`startup-toast`について**: `startup-toast` フックは `auto-update-checker` のサブ機能です。アップデートチェックは有効なまま起動トースト通知のみを無効化するには、`disabled_hooks` に `"startup-toast"` を追加してください。すべてのアップデートチェック機能(トーストを含む)を無効化するには、`"auto-update-checker"` を追加してください。
|
||||
|
||||
@@ -926,7 +942,7 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction": true,
|
||||
"preemptive_compaction_threshold": 0.85,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
@@ -936,8 +952,7 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction` | `false` | トークン制限に達する前にセッションを事前にコンパクションします。デフォルトでコンテキストウィンドウ使用率80%で実行されます。 |
|
||||
| `preemptive_compaction_threshold` | `0.80` | プリエンプティブコンパクションをトリガーする閾値(0.5-0.95)。`preemptive_compaction`が有効な場合のみ適用されます。 |
|
||||
| `preemptive_compaction_threshold` | `0.85` | プリエンプティブコンパクションをトリガーする閾値(0.5-0.95)。`preemptive-compaction` フックはデフォルトで有効です。このオプションで閾値をカスタマイズできます。 |
|
||||
| `truncate_all_tool_outputs` | `false` | ホワイトリストのツール(Grep、Glob、LSP、AST-grep)だけでなく、すべてのツール出力を切り詰めます。Tool output truncator はデフォルトで有効です - `disabled_hooks`で無効化できます。 |
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
|
||||
23
README.ko.md
23
README.ko.md
@@ -653,7 +653,7 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
|
||||
- **Empty Message Sanitizer**: 빈 채팅 메시지로 인한 API 오류를 방지합니다. 전송 전 메시지 내용을 자동으로 정리합니다.
|
||||
- **Grep Output Truncator**: grep은 산더미 같은 텍스트를 반환할 수 있습니다. 남은 컨텍스트 윈도우에 따라 동적으로 출력을 축소합니다—50% 여유 공간 유지, 최대 50k 토큰.
|
||||
- **Tool Output Truncator**: 같은 아이디어, 더 넓은 범위. Grep, Glob, LSP 도구, AST-grep의 출력을 축소합니다. 한 번의 장황한 검색이 전체 컨텍스트를 잡아먹는 것을 방지합니다.
|
||||
- **선제적 압축 (Preemptive Compaction)**: 세션 토큰 한계에 도달하기 전에 선제적으로 세션을 압축합니다. 문제가 발생하기 전에 미리 실행됩니다.
|
||||
- **선제적 압축 (Preemptive Compaction)**: 세션 토큰 한계에 도달하기 전에 선제적으로 세션을 압축합니다. 컨텍스트 윈도우 사용량 85%에서 실행됩니다. **기본적으로 활성화됨.** `disabled_hooks: ["preemptive-compaction"]`으로 비활성화 가능.
|
||||
- **압축 컨텍스트 주입기 (Compaction Context Injector)**: 세션 압축 중에 중요한 컨텍스트(AGENTS.md, 현재 디렉토리 정보 등)를 유지하여 중요한 상태를 잃지 않도록 합니다.
|
||||
- **사고 블록 검증기 (Thinking Block Validator)**: 사고(thinking) 블록의 형식이 올바른지 검증하여 잘못된 형식으로 인한 API 오류를 방지합니다.
|
||||
- **Claude Code 훅 (Claude Code Hooks)**: Claude Code의 settings.json에 설정된 훅을 실행합니다. PreToolUse/PostToolUse/UserPromptSubmit/Stop 이벤트를 지원하는 호환성 레이어입니다.
|
||||
@@ -752,7 +752,19 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
`prompt_append`를 사용하면 기본 시스템 프롬프트를 대체하지 않고 추가 지시사항을 덧붙일 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"librarian": {
|
||||
"prompt_append": "Emacs Lisp 문서 조회 시 항상 elisp-dev-mcp를 사용하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Sisyphus` (메인 오케스트레이터)와 `build` (기본 에이전트)도 동일한 옵션으로 설정을 오버라이드할 수 있습니다.
|
||||
|
||||
@@ -871,7 +883,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
|
||||
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
|
||||
|
||||
**`auto-update-checker`와 `startup-toast`에 대한 참고사항**: `startup-toast` 훅은 `auto-update-checker`의 하위 기능입니다. 업데이트 확인은 유지하면서 시작 토스트 알림만 비활성화하려면 `disabled_hooks`에 `"startup-toast"`를 추가하세요. 모든 업데이트 확인 기능(토스트 포함)을 비활성화하려면 `"auto-update-checker"`를 추가하세요.
|
||||
|
||||
@@ -923,7 +935,7 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction": true,
|
||||
"preemptive_compaction_threshold": 0.85,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
@@ -933,8 +945,7 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
|
||||
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| --------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction` | `false` | 토큰 제한에 도달하기 전에 세션을 미리 컴팩션합니다. 기본적으로 컨텍스트 윈도우 사용량이 80%일 때 실행됩니다. |
|
||||
| `preemptive_compaction_threshold` | `0.80` | 선제적 컴팩션을 트리거할 임계값 비율(0.5-0.95). `preemptive_compaction`이 활성화된 경우에만 적용됩니다. |
|
||||
| `preemptive_compaction_threshold` | `0.85` | 선제적 컴팩션을 트리거할 임계값 비율(0.5-0.95). `preemptive-compaction` 훅은 기본적으로 활성화되어 있으며, 이 옵션으로 임계값을 커스터마이즈할 수 있습니다. |
|
||||
| `truncate_all_tool_outputs` | `false` | 화이트리스트 도구(Grep, Glob, LSP, AST-grep)만이 아닌 모든 도구 출력을 잘라냅니다. Tool output truncator는 기본적으로 활성화됩니다 - `disabled_hooks`로 비활성화 가능합니다. |
|
||||
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
|
||||
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
|
||||
|
||||
42
README.md
42
README.md
@@ -2,6 +2,9 @@
|
||||
>
|
||||
> *"I aim to spark a software revolution by creating a world where agent-generated code is indistinguishable from human code, yet capable of achieving vastly more. I have poured my personal time, passion, and funds into this journey, and I will continue to do so."*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **The Orchestrator is coming. This Week. [Get notified on X](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> Be with us!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
@@ -692,7 +695,7 @@ When agents thrive, you thrive. But I want to help you directly too.
|
||||
- **Empty Message Sanitizer**: Prevents API errors from empty chat messages by automatically sanitizing message content before sending.
|
||||
- **Grep Output Truncator**: Grep can return mountains of text. This dynamically truncates output based on your remaining context window—keeps 50% headroom, caps at 50k tokens.
|
||||
- **Tool Output Truncator**: Same idea, broader scope. Truncates output from Grep, Glob, LSP tools, and AST-grep. Prevents one verbose search from eating your entire context.
|
||||
- **Preemptive Compaction**: Compacts session proactively before hitting hard token limits. Runs before you get into trouble.
|
||||
- **Preemptive Compaction**: Compacts session proactively before hitting hard token limits. Runs at 85% context window usage. **Enabled by default.** Disable via `disabled_hooks: ["preemptive-compaction"]`.
|
||||
- **Compaction Context Injector**: Preserves critical context (AGENTS.md, current directory info) during session compaction so you don't lose important state.
|
||||
- **Thinking Block Validator**: Validates thinking blocks to ensure proper formatting and prevent API errors from malformed thinking content.
|
||||
- **Claude Code Hooks**: Executes hooks from Claude Code's settings.json - this is the compatibility layer that runs PreToolUse/PostToolUse/UserPromptSubmit/Stop hooks.
|
||||
@@ -791,7 +794,19 @@ Override built-in agent settings:
|
||||
}
|
||||
```
|
||||
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
Use `prompt_append` to add extra instructions without replacing the default system prompt:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"librarian": {
|
||||
"prompt_append": "Always use the elisp-dev-mcp for Emacs Lisp documentation lookups."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also override settings for `Sisyphus` (the main orchestrator) and `build` (the default agent) using the same options.
|
||||
|
||||
@@ -831,6 +846,22 @@ Or disable via `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or
|
||||
|
||||
Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`, `multimodal-looker`
|
||||
|
||||
### Built-in Skills
|
||||
|
||||
Oh My OpenCode includes built-in skills that provide additional capabilities:
|
||||
|
||||
- **playwright**: Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.
|
||||
|
||||
Disable built-in skills via `disabled_skills` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_skills": ["playwright"]
|
||||
}
|
||||
```
|
||||
|
||||
Available built-in skills: `playwright`
|
||||
|
||||
### Sisyphus Agent
|
||||
|
||||
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
|
||||
@@ -910,7 +941,7 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
|
||||
}
|
||||
```
|
||||
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
|
||||
|
||||
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
|
||||
|
||||
@@ -962,7 +993,7 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction": true,
|
||||
"preemptive_compaction_threshold": 0.85,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
@@ -972,8 +1003,7 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction` | `false` | Compacts session proactively before hitting hard token limits. Runs at 80% context window usage by default. |
|
||||
| `preemptive_compaction_threshold` | `0.80` | Threshold percentage (0.5-0.95) to trigger preemptive compaction. Only applies when `preemptive_compaction` is enabled. |
|
||||
| `preemptive_compaction_threshold` | `0.85` | Threshold percentage (0.5-0.95) to trigger preemptive compaction. The `preemptive-compaction` hook is enabled by default; this option customizes the threshold. |
|
||||
| `truncate_all_tool_outputs` | `false` | Truncates ALL tool outputs instead of just whitelisted tools (Grep, Glob, LSP, AST-grep). Tool output truncator is enabled by default - disable via `disabled_hooks`. |
|
||||
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
|
||||
|
||||
@@ -664,6 +664,10 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
- **空消息清理器**:防止发空消息导致 API 报错。发出去之前自动打扫干净。
|
||||
- **Grep 输出截断器**:grep 结果太多?根据剩余窗口动态截断——留 50% 空间,顶天 50k token。
|
||||
- **工具输出截断器**:Grep、Glob、LSP、AST-grep 统统管上。防止一次无脑搜索把上下文撑爆。
|
||||
- **预防性压缩 (Preemptive Compaction)**:在达到 token 限制之前主动压缩会话。在上下文窗口使用率 85% 时运行。**默认启用。** 通过 `disabled_hooks: ["preemptive-compaction"]` 禁用。
|
||||
- **压缩上下文注入器**:会话压缩时保留关键上下文(AGENTS.md、当前目录信息),防止丢失重要状态。
|
||||
- **思考块验证器**:验证 thinking block 以确保格式正确,防止因格式错误的 thinking 内容而导致 API 错误。
|
||||
- **Claude Code Hooks**:执行 Claude Code settings.json 中的 hooks - 这是运行 PreToolUse/PostToolUse/UserPromptSubmit/Stop hooks 的兼容层。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -759,7 +763,19 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
每个 Agent 能改这些:`model`、`temperature`、`top_p`、`prompt`、`tools`、`disable`、`description`、`mode`、`color`、`permission`。
|
||||
每个 Agent 能改这些:`model`、`temperature`、`top_p`、`prompt`、`prompt_append`、`tools`、`disable`、`description`、`mode`、`color`、`permission`。
|
||||
|
||||
用 `prompt_append` 可以在默认系统提示后面追加额外指令,不用替换整个提示:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"librarian": {
|
||||
"prompt_append": "查 Emacs Lisp 文档时用 elisp-dev-mcp。"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Sisyphus`(主编排器)和 `build`(默认 Agent)也能改。
|
||||
|
||||
@@ -878,7 +894,7 @@ Sisyphus Agent 也能自定义:
|
||||
}
|
||||
```
|
||||
|
||||
可关的 hook:`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-context-window-limit-recovery`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`、`ralph-loop`
|
||||
可关的 hook:`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-context-window-limit-recovery`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`、`ralph-loop`、`preemptive-compaction`
|
||||
|
||||
**关于 `auto-update-checker` 和 `startup-toast`**: `startup-toast` hook 是 `auto-update-checker` 的子功能。若想保持更新检查但只禁用启动提示通知,在 `disabled_hooks` 中添加 `"startup-toast"`。若要禁用所有更新检查功能(包括提示),添加 `"auto-update-checker"`。
|
||||
|
||||
@@ -930,7 +946,7 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction": true,
|
||||
"preemptive_compaction_threshold": 0.85,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
@@ -940,8 +956,7 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction` | `false` | 在达到 token 限制之前主动压缩会话。默认在上下文窗口使用率达到 80% 时运行。 |
|
||||
| `preemptive_compaction_threshold` | `0.80` | 触发预先压缩的阈值比例(0.5-0.95)。仅在 `preemptive_compaction` 启用时生效。 |
|
||||
| `preemptive_compaction_threshold` | `0.85` | 触发预防性压缩的阈值比例(0.5-0.95)。`preemptive-compaction` 钩子默认启用;此选项用于自定义阈值。 |
|
||||
| `truncate_all_tool_outputs` | `false` | 截断所有工具输出,而不仅仅是白名单工具(Grep、Glob、LSP、AST-grep)。Tool output truncator 默认启用 - 使用 `disabled_hooks` 禁用。 |
|
||||
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
|
||||
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_skills": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"playwright"
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_hooks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -61,7 +70,11 @@
|
||||
"interactive-bash-session",
|
||||
"empty-message-sanitizer",
|
||||
"thinking-block-validator",
|
||||
"ralph-loop"
|
||||
"ralph-loop",
|
||||
"preemptive-compaction",
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
187
bun.lock
187
bun.lock
@@ -9,11 +9,13 @@
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
@@ -21,6 +23,7 @@
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "latest",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -75,6 +78,10 @@
|
||||
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.1", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ=="],
|
||||
|
||||
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.162", "", { "dependencies": { "@opencode-ai/sdk": "1.0.162", "zod": "4.1.8" } }, "sha512-tiJw7SCfSlG/3tY2O0J2UT06OLuazOzsv1zYlFbLxLy/EVedtW0pzxYalO20a4e//vInvOXFkhd2jLyB5vNEVA=="],
|
||||
@@ -93,40 +100,218 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
|
||||
|
||||
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.8.2",
|
||||
"version": "2.10.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -54,11 +54,13 @@
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
@@ -66,6 +68,7 @@
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "latest",
|
||||
"typescript": "^5.7.3"
|
||||
|
||||
@@ -103,6 +103,46 @@
|
||||
"created_at": "2025-12-30T12:04:59Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 349
|
||||
},
|
||||
{
|
||||
"name": "junhoyeo",
|
||||
"id": 32605822,
|
||||
"comment_id": 3701585491,
|
||||
"created_at": "2025-12-31T07:00:36Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 375
|
||||
},
|
||||
{
|
||||
"name": "gtg7784",
|
||||
"id": 32065632,
|
||||
"comment_id": 3701688739,
|
||||
"created_at": "2025-12-31T08:05:25Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 377
|
||||
},
|
||||
{
|
||||
"name": "ul8",
|
||||
"id": 589744,
|
||||
"comment_id": 3701705644,
|
||||
"created_at": "2025-12-31T08:16:46Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 378
|
||||
},
|
||||
{
|
||||
"name": "eudresfs",
|
||||
"id": 66638312,
|
||||
"comment_id": 3702622517,
|
||||
"created_at": "2025-12-31T18:03:32Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 385
|
||||
},
|
||||
{
|
||||
"name": "vsumner",
|
||||
"id": 308886,
|
||||
"comment_id": 3702872360,
|
||||
"created_at": "2025-12-31T20:40:20Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 388
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
|
||||
|
||||
export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "specialist",
|
||||
cost: "CHEAP",
|
||||
promptAlias: "Document Writer",
|
||||
triggers: [
|
||||
{ domain: "Documentation", trigger: "README, API docs, guides" },
|
||||
],
|
||||
}
|
||||
|
||||
export function createDocumentWriterAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
|
||||
const DEFAULT_MODEL = "opencode/grok-code"
|
||||
|
||||
export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "exploration",
|
||||
cost: "FREE",
|
||||
promptAlias: "Explore",
|
||||
keyTrigger: "2+ modules involved → fire `explore` background",
|
||||
triggers: [
|
||||
{ domain: "Explore", trigger: "Find existing codebase structure, patterns and styles" },
|
||||
],
|
||||
useWhen: [
|
||||
"Multiple search angles needed",
|
||||
"Unfamiliar module structure",
|
||||
"Cross-layer pattern discovery",
|
||||
],
|
||||
avoidWhen: [
|
||||
"You know exactly what to search",
|
||||
"Single keyword/pattern suffices",
|
||||
"Known file location",
|
||||
],
|
||||
}
|
||||
|
||||
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
return {
|
||||
description:
|
||||
@@ -87,18 +108,8 @@ Use the right tool for the job:
|
||||
- **Text patterns** (strings, comments, logs): grep
|
||||
- **File patterns** (find by name/extension): glob
|
||||
- **History/evolution** (when added, who changed): git commands
|
||||
- **External examples** (how others implement): grep_app
|
||||
|
||||
### grep_app Strategy
|
||||
|
||||
grep_app searches millions of public GitHub repos instantly — use it for external patterns and examples.
|
||||
|
||||
**Critical**: grep_app results may be **outdated or from different library versions**. Always:
|
||||
1. Start with grep_app for broad discovery
|
||||
2. Launch multiple grep_app calls with query variations in parallel
|
||||
3. **Cross-validate with local tools** (grep, ast_grep_search, LSP) before trusting results
|
||||
|
||||
Flood with parallel calls. Trust only cross-validated results.`,
|
||||
Flood with parallel calls. Cross-validate findings across multiple tools.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
|
||||
|
||||
export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "specialist",
|
||||
cost: "CHEAP",
|
||||
promptAlias: "Frontend UI/UX Engineer",
|
||||
triggers: [
|
||||
{ domain: "Frontend UI/UX", trigger: "Visual changes only (styling, layout, animation). Pure logic changes in frontend files → handle directly" },
|
||||
],
|
||||
useWhen: [
|
||||
"Visual/UI/UX changes: Color, spacing, layout, typography, animation, responsive breakpoints, hover states, shadows, borders, icons, images",
|
||||
],
|
||||
avoidWhen: [
|
||||
"Pure logic: API calls, data fetching, state management, event handlers (non-visual), type definitions, utility functions, business logic",
|
||||
],
|
||||
}
|
||||
|
||||
export function createFrontendUiUxEngineerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
|
||||
@@ -19,3 +19,4 @@ export const builtinAgents: Record<string, AgentConfig> = {
|
||||
|
||||
export * from "./types"
|
||||
export { createBuiltinAgents } from "./utils"
|
||||
export type { AvailableAgent } from "./sisyphus-prompt-builder"
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "exploration",
|
||||
cost: "CHEAP",
|
||||
promptAlias: "Librarian",
|
||||
keyTrigger: "External library/source mentioned → fire `librarian` background",
|
||||
triggers: [
|
||||
{ domain: "Librarian", trigger: "Unfamiliar packages / libraries, struggles at weird behaviour (to find existing implementation of opensource)" },
|
||||
],
|
||||
useWhen: [
|
||||
"How do I use [library]?",
|
||||
"What's the best practice for [framework feature]?",
|
||||
"Why does [external dependency] behave this way?",
|
||||
"Find examples of [library] usage",
|
||||
"Working with unfamiliar npm/pip/cargo packages",
|
||||
],
|
||||
}
|
||||
|
||||
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
return {
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash"
|
||||
|
||||
export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "utility",
|
||||
cost: "CHEAP",
|
||||
promptAlias: "Multimodal Looker",
|
||||
triggers: [],
|
||||
}
|
||||
|
||||
export function createMultimodalLookerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { isGptModel } from "./types"
|
||||
|
||||
const DEFAULT_MODEL = "openai/gpt-5.2"
|
||||
|
||||
export const ORACLE_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "advisor",
|
||||
cost: "EXPENSIVE",
|
||||
promptAlias: "Oracle",
|
||||
triggers: [
|
||||
{ domain: "Architecture decisions", trigger: "Multi-system tradeoffs, unfamiliar patterns" },
|
||||
{ domain: "Self-review", trigger: "After completing significant implementation" },
|
||||
{ domain: "Hard debugging", trigger: "After 2+ failed fix attempts" },
|
||||
],
|
||||
useWhen: [
|
||||
"Complex architecture design",
|
||||
"After completing significant work",
|
||||
"2+ failed fix attempts",
|
||||
"Unfamiliar code patterns",
|
||||
"Security/performance concerns",
|
||||
"Multi-system tradeoffs",
|
||||
],
|
||||
avoidWhen: [
|
||||
"Simple file operations (use direct tools)",
|
||||
"First attempt at any fix (try yourself first)",
|
||||
"Questions answerable from code you've read",
|
||||
"Trivial decisions (variable names, formatting)",
|
||||
"Things you can infer from existing code patterns",
|
||||
],
|
||||
}
|
||||
|
||||
const ORACLE_SYSTEM_PROMPT = `You are a strategic technical advisor with deep reasoning capabilities, operating as a specialized consultant within an AI-assisted development environment.
|
||||
|
||||
## Context
|
||||
|
||||
309
src/agents/sisyphus-prompt-builder.ts
Normal file
309
src/agents/sisyphus-prompt-builder.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type { AgentPromptMetadata, BuiltinAgentName } from "./types"
|
||||
|
||||
export interface AvailableAgent {
|
||||
name: BuiltinAgentName
|
||||
description: string
|
||||
metadata: AgentPromptMetadata
|
||||
}
|
||||
|
||||
export interface AvailableTool {
|
||||
name: string
|
||||
category: "lsp" | "ast" | "search" | "session" | "command" | "other"
|
||||
}
|
||||
|
||||
export interface AvailableSkill {
|
||||
name: string
|
||||
description: string
|
||||
location: "user" | "project" | "plugin"
|
||||
}
|
||||
|
||||
export function categorizeTools(toolNames: string[]): AvailableTool[] {
|
||||
return toolNames.map((name) => {
|
||||
let category: AvailableTool["category"] = "other"
|
||||
if (name.startsWith("lsp_")) {
|
||||
category = "lsp"
|
||||
} else if (name.startsWith("ast_grep")) {
|
||||
category = "ast"
|
||||
} else if (name === "grep" || name === "glob") {
|
||||
category = "search"
|
||||
} else if (name.startsWith("session_")) {
|
||||
category = "session"
|
||||
} else if (name === "slashcommand") {
|
||||
category = "command"
|
||||
}
|
||||
return { name, category }
|
||||
})
|
||||
}
|
||||
|
||||
function formatToolsForPrompt(tools: AvailableTool[]): string {
|
||||
const lspTools = tools.filter((t) => t.category === "lsp")
|
||||
const astTools = tools.filter((t) => t.category === "ast")
|
||||
const searchTools = tools.filter((t) => t.category === "search")
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (searchTools.length > 0) {
|
||||
parts.push(...searchTools.map((t) => `\`${t.name}\``))
|
||||
}
|
||||
|
||||
if (lspTools.length > 0) {
|
||||
parts.push("`lsp_*`")
|
||||
}
|
||||
|
||||
if (astTools.length > 0) {
|
||||
parts.push("`ast_grep`")
|
||||
}
|
||||
|
||||
return parts.join(", ")
|
||||
}
|
||||
|
||||
export function buildKeyTriggersSection(agents: AvailableAgent[], skills: AvailableSkill[] = []): string {
|
||||
const keyTriggers = agents
|
||||
.filter((a) => a.metadata.keyTrigger)
|
||||
.map((a) => `- ${a.metadata.keyTrigger}`)
|
||||
|
||||
const skillTriggers = skills
|
||||
.filter((s) => s.description)
|
||||
.map((s) => `- **Skill \`${s.name}\`**: ${extractTriggerFromDescription(s.description)}`)
|
||||
|
||||
const allTriggers = [...keyTriggers, ...skillTriggers]
|
||||
|
||||
if (allTriggers.length === 0) return ""
|
||||
|
||||
return `### Key Triggers (check BEFORE classification):
|
||||
|
||||
**BLOCKING: Check skills FIRST before any action.**
|
||||
If a skill matches, invoke it IMMEDIATELY via \`skill\` tool.
|
||||
|
||||
${allTriggers.join("\n")}
|
||||
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
|
||||
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.`
|
||||
}
|
||||
|
||||
function extractTriggerFromDescription(description: string): string {
|
||||
const triggerMatch = description.match(/Trigger[s]?[:\s]+([^.]+)/i)
|
||||
if (triggerMatch) return triggerMatch[1].trim()
|
||||
|
||||
const activateMatch = description.match(/Activate when[:\s]+([^.]+)/i)
|
||||
if (activateMatch) return activateMatch[1].trim()
|
||||
|
||||
const useWhenMatch = description.match(/Use (?:this )?when[:\s]+([^.]+)/i)
|
||||
if (useWhenMatch) return useWhenMatch[1].trim()
|
||||
|
||||
return description.split(".")[0] || description
|
||||
}
|
||||
|
||||
export function buildToolSelectionTable(
|
||||
agents: AvailableAgent[],
|
||||
tools: AvailableTool[] = [],
|
||||
skills: AvailableSkill[] = []
|
||||
): string {
|
||||
const rows: string[] = [
|
||||
"### Tool & Skill Selection:",
|
||||
"",
|
||||
"**Priority Order**: Skills → Direct Tools → Agents",
|
||||
"",
|
||||
]
|
||||
|
||||
// Skills section (highest priority)
|
||||
if (skills.length > 0) {
|
||||
rows.push("#### Skills (INVOKE FIRST if matching)")
|
||||
rows.push("")
|
||||
rows.push("| Skill | When to Use |")
|
||||
rows.push("|-------|-------------|")
|
||||
for (const skill of skills) {
|
||||
const shortDesc = extractTriggerFromDescription(skill.description)
|
||||
rows.push(`| \`${skill.name}\` | ${shortDesc} |`)
|
||||
}
|
||||
rows.push("")
|
||||
}
|
||||
|
||||
// Tools and Agents table
|
||||
rows.push("#### Tools & Agents")
|
||||
rows.push("")
|
||||
rows.push("| Resource | Cost | When to Use |")
|
||||
rows.push("|----------|------|-------------|")
|
||||
|
||||
if (tools.length > 0) {
|
||||
const toolsDisplay = formatToolsForPrompt(tools)
|
||||
rows.push(`| ${toolsDisplay} | FREE | Not Complex, Scope Clear, No Implicit Assumptions |`)
|
||||
}
|
||||
|
||||
const costOrder = { FREE: 0, CHEAP: 1, EXPENSIVE: 2 }
|
||||
const sortedAgents = [...agents]
|
||||
.filter((a) => a.metadata.category !== "utility")
|
||||
.sort((a, b) => costOrder[a.metadata.cost] - costOrder[b.metadata.cost])
|
||||
|
||||
for (const agent of sortedAgents) {
|
||||
const shortDesc = agent.description.split(".")[0] || agent.description
|
||||
rows.push(`| \`${agent.name}\` agent | ${agent.metadata.cost} | ${shortDesc} |`)
|
||||
}
|
||||
|
||||
rows.push("")
|
||||
rows.push("**Default flow**: skill (if match) → explore/librarian (background) + tools → oracle (if required)")
|
||||
|
||||
return rows.join("\n")
|
||||
}
|
||||
|
||||
export function buildExploreSection(agents: AvailableAgent[]): string {
|
||||
const exploreAgent = agents.find((a) => a.name === "explore")
|
||||
if (!exploreAgent) return ""
|
||||
|
||||
const useWhen = exploreAgent.metadata.useWhen || []
|
||||
const avoidWhen = exploreAgent.metadata.avoidWhen || []
|
||||
|
||||
return `### Explore Agent = Contextual Grep
|
||||
|
||||
Use it as a **peer tool**, not a fallback. Fire liberally.
|
||||
|
||||
| Use Direct Tools | Use Explore Agent |
|
||||
|------------------|-------------------|
|
||||
${avoidWhen.map((w) => `| ${w} | |`).join("\n")}
|
||||
${useWhen.map((w) => `| | ${w} |`).join("\n")}`
|
||||
}
|
||||
|
||||
export function buildLibrarianSection(agents: AvailableAgent[]): string {
|
||||
const librarianAgent = agents.find((a) => a.name === "librarian")
|
||||
if (!librarianAgent) return ""
|
||||
|
||||
const useWhen = librarianAgent.metadata.useWhen || []
|
||||
|
||||
return `### Librarian Agent = Reference Grep
|
||||
|
||||
Search **external references** (docs, OSS, web). Fire proactively when unfamiliar libraries are involved.
|
||||
|
||||
| Contextual Grep (Internal) | Reference Grep (External) |
|
||||
|----------------------------|---------------------------|
|
||||
| Search OUR codebase | Search EXTERNAL resources |
|
||||
| Find patterns in THIS repo | Find examples in OTHER repos |
|
||||
| How does our code work? | How does this library work? |
|
||||
| Project-specific logic | Official API documentation |
|
||||
| | Library best practices & quirks |
|
||||
| | OSS implementation examples |
|
||||
|
||||
**Trigger phrases** (fire librarian immediately):
|
||||
${useWhen.map((w) => `- "${w}"`).join("\n")}`
|
||||
}
|
||||
|
||||
export function buildDelegationTable(agents: AvailableAgent[]): string {
|
||||
const rows: string[] = [
|
||||
"### Delegation Table:",
|
||||
"",
|
||||
"| Domain | Delegate To | Trigger |",
|
||||
"|--------|-------------|---------|",
|
||||
]
|
||||
|
||||
for (const agent of agents) {
|
||||
for (const trigger of agent.metadata.triggers) {
|
||||
rows.push(`| ${trigger.domain} | \`${agent.name}\` | ${trigger.trigger} |`)
|
||||
}
|
||||
}
|
||||
|
||||
return rows.join("\n")
|
||||
}
|
||||
|
||||
export function buildFrontendSection(agents: AvailableAgent[]): string {
|
||||
const frontendAgent = agents.find((a) => a.name === "frontend-ui-ux-engineer")
|
||||
if (!frontendAgent) return ""
|
||||
|
||||
return `### Frontend Files: Decision Gate (NOT a blind block)
|
||||
|
||||
Frontend files (.tsx, .jsx, .vue, .svelte, .css, etc.) require **classification before action**.
|
||||
|
||||
#### Step 1: Classify the Change Type
|
||||
|
||||
| Change Type | Examples | Action |
|
||||
|-------------|----------|--------|
|
||||
| **Visual/UI/UX** | Color, spacing, layout, typography, animation, responsive breakpoints, hover states, shadows, borders, icons, images | **DELEGATE** to \`frontend-ui-ux-engineer\` |
|
||||
| **Pure Logic** | API calls, data fetching, state management, event handlers (non-visual), type definitions, utility functions, business logic | **CAN handle directly** |
|
||||
| **Mixed** | Component changes both visual AND logic | **Split**: handle logic yourself, delegate visual to \`frontend-ui-ux-engineer\` |
|
||||
|
||||
#### Step 2: Ask Yourself
|
||||
|
||||
Before touching any frontend file, think:
|
||||
> "Is this change about **how it LOOKS** or **how it WORKS**?"
|
||||
|
||||
- **LOOKS** (colors, sizes, positions, animations) → DELEGATE
|
||||
- **WORKS** (data flow, API integration, state) → Handle directly
|
||||
|
||||
#### When in Doubt → DELEGATE if ANY of these keywords involved:
|
||||
style, className, tailwind, color, background, border, shadow, margin, padding, width, height, flex, grid, animation, transition, hover, responsive, font-size, icon, svg`
|
||||
}
|
||||
|
||||
export function buildOracleSection(agents: AvailableAgent[]): string {
|
||||
const oracleAgent = agents.find((a) => a.name === "oracle")
|
||||
if (!oracleAgent) return ""
|
||||
|
||||
const useWhen = oracleAgent.metadata.useWhen || []
|
||||
const avoidWhen = oracleAgent.metadata.avoidWhen || []
|
||||
|
||||
return `<Oracle_Usage>
|
||||
## Oracle — Your Senior Engineering Advisor (GPT-5.2)
|
||||
|
||||
Oracle is an expensive, high-quality reasoning model. Use it wisely.
|
||||
|
||||
### WHEN to Consult:
|
||||
|
||||
| Trigger | Action |
|
||||
|---------|--------|
|
||||
${useWhen.map((w) => `| ${w} | Oracle FIRST, then implement |`).join("\n")}
|
||||
|
||||
### WHEN NOT to Consult:
|
||||
|
||||
${avoidWhen.map((w) => `- ${w}`).join("\n")}
|
||||
|
||||
### Usage Pattern:
|
||||
Briefly announce "Consulting Oracle for [reason]" before invocation.
|
||||
|
||||
**Exception**: This is the ONLY case where you announce before acting. For all other work, start immediately without status updates.
|
||||
</Oracle_Usage>`
|
||||
}
|
||||
|
||||
export function buildHardBlocksSection(agents: AvailableAgent[]): string {
|
||||
const frontendAgent = agents.find((a) => a.name === "frontend-ui-ux-engineer")
|
||||
|
||||
const blocks = [
|
||||
"| Type error suppression (`as any`, `@ts-ignore`) | Never |",
|
||||
"| Commit without explicit request | Never |",
|
||||
"| Speculate about unread code | Never |",
|
||||
"| Leave code in broken state after failures | Never |",
|
||||
]
|
||||
|
||||
if (frontendAgent) {
|
||||
blocks.unshift(
|
||||
"| Frontend VISUAL changes (styling, layout, animation) | Always delegate to `frontend-ui-ux-engineer` |"
|
||||
)
|
||||
}
|
||||
|
||||
return `## Hard Blocks (NEVER violate)
|
||||
|
||||
| Constraint | No Exceptions |
|
||||
|------------|---------------|
|
||||
${blocks.join("\n")}`
|
||||
}
|
||||
|
||||
export function buildAntiPatternsSection(agents: AvailableAgent[]): string {
|
||||
const frontendAgent = agents.find((a) => a.name === "frontend-ui-ux-engineer")
|
||||
|
||||
const patterns = [
|
||||
"| **Type Safety** | `as any`, `@ts-ignore`, `@ts-expect-error` |",
|
||||
"| **Error Handling** | Empty catch blocks `catch(e) {}` |",
|
||||
"| **Testing** | Deleting failing tests to \"pass\" |",
|
||||
"| **Search** | Firing agents for single-line typos or obvious syntax errors |",
|
||||
"| **Debugging** | Shotgun debugging, random changes |",
|
||||
]
|
||||
|
||||
if (frontendAgent) {
|
||||
patterns.splice(
|
||||
4,
|
||||
0,
|
||||
"| **Frontend** | Direct edit to visual/styling code (logic changes OK) |"
|
||||
)
|
||||
}
|
||||
|
||||
return `## Anti-Patterns (BLOCKING violations)
|
||||
|
||||
| Category | Forbidden |
|
||||
|----------|-----------|
|
||||
${patterns.join("\n")}`
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { isGptModel } from "./types"
|
||||
import type { AvailableAgent, AvailableTool, AvailableSkill } from "./sisyphus-prompt-builder"
|
||||
import {
|
||||
buildKeyTriggersSection,
|
||||
buildToolSelectionTable,
|
||||
buildExploreSection,
|
||||
buildLibrarianSection,
|
||||
buildDelegationTable,
|
||||
buildFrontendSection,
|
||||
buildOracleSection,
|
||||
buildHardBlocksSection,
|
||||
buildAntiPatternsSection,
|
||||
categorizeTools,
|
||||
} from "./sisyphus-prompt-builder"
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-opus-4-5"
|
||||
|
||||
const SISYPHUS_SYSTEM_PROMPT = `<Role>
|
||||
const SISYPHUS_ROLE_SECTION = `<Role>
|
||||
You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
|
||||
Named by [YeonGyu Kim](https://github.com/code-yeongyu).
|
||||
|
||||
@@ -21,22 +34,27 @@ Named by [YeonGyu Kim](https://github.com/code-yeongyu).
|
||||
|
||||
**Operating Mode**: You NEVER work alone when specialists are available. Frontend work → delegate. Deep research → parallel background agents (async subagents). Complex architecture → consult Oracle.
|
||||
|
||||
</Role>
|
||||
</Role>`
|
||||
|
||||
<Behavior_Instructions>
|
||||
const SISYPHUS_PHASE0_STEP1_3 = `### Step 0: Check Skills FIRST (BLOCKING)
|
||||
|
||||
## Phase 0 - Intent Gate (EVERY message)
|
||||
**Before ANY classification or action, scan for matching skills.**
|
||||
|
||||
### Key Triggers (check BEFORE classification):
|
||||
- External library/source mentioned → fire \`librarian\` background
|
||||
- 2+ modules involved → fire \`explore\` background
|
||||
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
|
||||
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.
|
||||
\`\`\`
|
||||
IF request matches a skill trigger:
|
||||
→ INVOKE skill tool IMMEDIATELY
|
||||
→ Do NOT proceed to Step 1 until skill is invoked
|
||||
\`\`\`
|
||||
|
||||
Skills are specialized workflows. When relevant, they handle the task better than manual orchestration.
|
||||
|
||||
---
|
||||
|
||||
### Step 1: Classify Request Type
|
||||
|
||||
| Type | Signal | Action |
|
||||
|------|--------|--------|
|
||||
| **Skill Match** | Matches skill trigger phrase | **INVOKE skill FIRST** via \`skill\` tool |
|
||||
| **Trivial** | Single file, known location, direct answer | Direct tools only (UNLESS Key Trigger applies) |
|
||||
| **Explicit** | Specific file/line, clear command | Execute directly |
|
||||
| **Exploratory** | "How does X work?", "Find Y" | Fire explore (1-3) + tools in parallel |
|
||||
@@ -78,11 +96,9 @@ Then: Raise your concern concisely. Propose an alternative. Ask if they want to
|
||||
I notice [observation]. This might cause [problem] because [reason].
|
||||
Alternative: [your suggestion].
|
||||
Should I proceed with your original request, or try the alternative?
|
||||
\`\`\`
|
||||
\`\`\``
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 - Codebase Assessment (for Open-ended tasks)
|
||||
const SISYPHUS_PHASE1 = `## Phase 1 - Codebase Assessment (for Open-ended tasks)
|
||||
|
||||
Before following existing patterns, assess whether they're worth following.
|
||||
|
||||
@@ -103,54 +119,9 @@ Before following existing patterns, assess whether they're worth following.
|
||||
IMPORTANT: If codebase appears undisciplined, verify before assuming:
|
||||
- Different patterns may serve different purposes (intentional)
|
||||
- Migration might be in progress
|
||||
- You might be looking at the wrong reference files
|
||||
- You might be looking at the wrong reference files`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2A - Exploration & Research
|
||||
|
||||
### Tool Selection:
|
||||
|
||||
| Tool | Cost | When to Use |
|
||||
|------|------|-------------|
|
||||
| \`grep\`, \`glob\`, \`lsp_*\`, \`ast_grep\` | FREE | Not Complex, Scope Clear, No Implicit Assumptions |
|
||||
| \`explore\` agent | FREE | Multiple search angles, unfamiliar modules, cross-layer patterns |
|
||||
| \`librarian\` agent | CHEAP | External docs, GitHub examples, OpenSource Implementations, OSS reference |
|
||||
| \`oracle\` agent | EXPENSIVE | Architecture, review, debugging after 2+ failures |
|
||||
|
||||
**Default flow**: explore/librarian (background) + tools → oracle (if required)
|
||||
|
||||
### Explore Agent = Contextual Grep
|
||||
|
||||
Use it as a **peer tool**, not a fallback. Fire liberally.
|
||||
|
||||
| Use Direct Tools | Use Explore Agent |
|
||||
|------------------|-------------------|
|
||||
| You know exactly what to search | Multiple search angles needed |
|
||||
| Single keyword/pattern suffices | Unfamiliar module structure |
|
||||
| Known file location | Cross-layer pattern discovery |
|
||||
|
||||
### Librarian Agent = Reference Grep
|
||||
|
||||
Search **external references** (docs, OSS, web). Fire proactively when unfamiliar libraries are involved.
|
||||
|
||||
| Contextual Grep (Internal) | Reference Grep (External) |
|
||||
|----------------------------|---------------------------|
|
||||
| Search OUR codebase | Search EXTERNAL resources |
|
||||
| Find patterns in THIS repo | Find examples in OTHER repos |
|
||||
| How does our code work? | How does this library work? |
|
||||
| Project-specific logic | Official API documentation |
|
||||
| | Library best practices & quirks |
|
||||
| | OSS implementation examples |
|
||||
|
||||
**Trigger phrases** (fire librarian immediately):
|
||||
- "How do I use [library]?"
|
||||
- "What's the best practice for [framework feature]?"
|
||||
- "Why does [external dependency] behave this way?"
|
||||
- "Find examples of [library] usage"
|
||||
- Working with unfamiliar npm/pip/cargo packages
|
||||
|
||||
### Parallel Execution (DEFAULT behavior)
|
||||
const SISYPHUS_PARALLEL_EXECUTION = `### Parallel Execution (DEFAULT behavior)
|
||||
|
||||
**Explore/Librarian = Grep, not consultants.
|
||||
|
||||
@@ -182,64 +153,16 @@ STOP searching when:
|
||||
- 2 search iterations yielded no new useful data
|
||||
- Direct answer found
|
||||
|
||||
**DO NOT over-explore. Time is precious.**
|
||||
**DO NOT over-explore. Time is precious.**`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2B - Implementation
|
||||
const SISYPHUS_PHASE2B_PRE_IMPLEMENTATION = `## Phase 2B - Implementation
|
||||
|
||||
### Pre-Implementation:
|
||||
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL. No announcements—just create it.
|
||||
2. Mark current task \`in_progress\` before starting
|
||||
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS
|
||||
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS`
|
||||
|
||||
### Frontend Files: Decision Gate (NOT a blind block)
|
||||
|
||||
Frontend files (.tsx, .jsx, .vue, .svelte, .css, etc.) require **classification before action**.
|
||||
|
||||
#### Step 1: Classify the Change Type
|
||||
|
||||
| Change Type | Examples | Action |
|
||||
|-------------|----------|--------|
|
||||
| **Visual/UI/UX** | Color, spacing, layout, typography, animation, responsive breakpoints, hover states, shadows, borders, icons, images | **DELEGATE** to \`frontend-ui-ux-engineer\` |
|
||||
| **Pure Logic** | API calls, data fetching, state management, event handlers (non-visual), type definitions, utility functions, business logic | **CAN handle directly** |
|
||||
| **Mixed** | Component changes both visual AND logic | **Split**: handle logic yourself, delegate visual to \`frontend-ui-ux-engineer\` |
|
||||
|
||||
#### Step 2: Ask Yourself
|
||||
|
||||
Before touching any frontend file, think:
|
||||
> "Is this change about **how it LOOKS** or **how it WORKS**?"
|
||||
|
||||
- **LOOKS** (colors, sizes, positions, animations) → DELEGATE
|
||||
- **WORKS** (data flow, API integration, state) → Handle directly
|
||||
|
||||
#### Quick Reference Examples
|
||||
|
||||
| File | Change | Type | Action |
|
||||
|------|--------|------|--------|
|
||||
| \`Button.tsx\` | Change color blue→green | Visual | DELEGATE |
|
||||
| \`Button.tsx\` | Add onClick API call | Logic | Direct |
|
||||
| \`UserList.tsx\` | Add loading spinner animation | Visual | DELEGATE |
|
||||
| \`UserList.tsx\` | Fix pagination logic bug | Logic | Direct |
|
||||
| \`Modal.tsx\` | Make responsive for mobile | Visual | DELEGATE |
|
||||
| \`Modal.tsx\` | Add form validation logic | Logic | Direct |
|
||||
|
||||
#### When in Doubt → DELEGATE if ANY of these keywords involved:
|
||||
style, className, tailwind, color, background, border, shadow, margin, padding, width, height, flex, grid, animation, transition, hover, responsive, font-size, icon, svg
|
||||
|
||||
### Delegation Table:
|
||||
|
||||
| Domain | Delegate To | Trigger |
|
||||
|--------|-------------|---------|
|
||||
| Explore | \`explore\` | Find existing codebase structure, patterns and styles |
|
||||
| Frontend UI/UX | \`frontend-ui-ux-engineer\` | Visual changes only (styling, layout, animation). Pure logic changes in frontend files → handle directly |
|
||||
| Librarian | \`librarian\` | Unfamiliar packages / libraries, struggles at weird behaviour (to find existing implementation of opensource) |
|
||||
| Documentation | \`document-writer\` | README, API docs, guides |
|
||||
| Architecture decisions | \`oracle\` | Multi-system tradeoffs, unfamiliar patterns |
|
||||
| Self-review | \`oracle\` | After completing significant implementation |
|
||||
| Hard debugging | \`oracle\` | After 2+ failed fix attempts |
|
||||
|
||||
### Delegation Prompt Structure (MANDATORY - ALL 7 sections):
|
||||
const SISYPHUS_DELEGATION_PROMPT_STRUCTURE = `### Delegation Prompt Structure (MANDATORY - ALL 7 sections):
|
||||
|
||||
When delegating, your prompt MUST include:
|
||||
|
||||
@@ -259,9 +182,9 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
|
||||
- EXPECTED RESULT CAME OUT?
|
||||
- DID THE AGENT FOLLOWED "MUST DO" AND "MUST NOT DO" REQUIREMENTS?
|
||||
|
||||
**Vague prompts = rejected. Be exhaustive.**
|
||||
**Vague prompts = rejected. Be exhaustive.**`
|
||||
|
||||
### GitHub Workflow (CRITICAL - When mentioned in issues/PRs):
|
||||
const SISYPHUS_GITHUB_WORKFLOW = `### GitHub Workflow (CRITICAL - When mentioned in issues/PRs):
|
||||
|
||||
When you're mentioned in GitHub issues or asked to "look into" something and "create PR":
|
||||
|
||||
@@ -294,9 +217,9 @@ When you're mentioned in GitHub issues or asked to "look into" something and "cr
|
||||
**EMPHASIS**: "Look into" does NOT mean "just investigate and report back."
|
||||
It means "investigate, understand, implement a solution, and create a PR."
|
||||
|
||||
**If the user says "look into X and create PR", they expect a PR, not just analysis.**
|
||||
**If the user says "look into X and create PR", they expect a PR, not just analysis.**`
|
||||
|
||||
### Code Changes:
|
||||
const SISYPHUS_CODE_CHANGES = `### Code Changes:
|
||||
- Match existing patterns (if codebase is disciplined)
|
||||
- Propose approach first (if codebase is chaotic)
|
||||
- Never suppress type errors with \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\`
|
||||
@@ -322,11 +245,9 @@ If project has build/test commands, run them at task completion.
|
||||
| Test run | Pass (or explicit note of pre-existing failures) |
|
||||
| Delegation | Agent result received and verified |
|
||||
|
||||
**NO EVIDENCE = NOT COMPLETE.**
|
||||
**NO EVIDENCE = NOT COMPLETE.**`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2C - Failure Recovery
|
||||
const SISYPHUS_PHASE2C = `## Phase 2C - Failure Recovery
|
||||
|
||||
### When Fixes Fail:
|
||||
|
||||
@@ -342,11 +263,9 @@ If project has build/test commands, run them at task completion.
|
||||
4. **CONSULT** Oracle with full failure context
|
||||
5. If Oracle cannot resolve → **ASK USER** before proceeding
|
||||
|
||||
**Never**: Leave code in broken state, continue hoping it'll work, delete failing tests to "pass"
|
||||
**Never**: Leave code in broken state, continue hoping it'll work, delete failing tests to "pass"`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 - Completion
|
||||
const SISYPHUS_PHASE3 = `## Phase 3 - Completion
|
||||
|
||||
A task is complete when:
|
||||
- [ ] All planned todo items marked done
|
||||
@@ -361,41 +280,9 @@ If verification fails:
|
||||
|
||||
### Before Delivering Final Answer:
|
||||
- Cancel ALL running background tasks: \`background_cancel(all=true)\`
|
||||
- This conserves resources and ensures clean workflow completion
|
||||
- This conserves resources and ensures clean workflow completion`
|
||||
|
||||
</Behavior_Instructions>
|
||||
|
||||
<Oracle_Usage>
|
||||
## Oracle — Your Senior Engineering Advisor (GPT-5.2)
|
||||
|
||||
Oracle is an expensive, high-quality reasoning model. Use it wisely.
|
||||
|
||||
### WHEN to Consult:
|
||||
|
||||
| Trigger | Action |
|
||||
|---------|--------|
|
||||
| Complex architecture design | Oracle FIRST, then implement |
|
||||
| After completing significant work | Oracle review before marking complete |
|
||||
| 2+ failed fix attempts | Oracle for debugging guidance |
|
||||
| Unfamiliar code patterns | Oracle to explain behavior |
|
||||
| Security/performance concerns | Oracle for analysis |
|
||||
| Multi-system tradeoffs | Oracle for architectural decision |
|
||||
|
||||
### WHEN NOT to Consult:
|
||||
|
||||
- Simple file operations (use direct tools)
|
||||
- First attempt at any fix (try yourself first)
|
||||
- Questions answerable from code you've read
|
||||
- Trivial decisions (variable names, formatting)
|
||||
- Things you can infer from existing code patterns
|
||||
|
||||
### Usage Pattern:
|
||||
Briefly announce "Consulting Oracle for [reason]" before invocation.
|
||||
|
||||
**Exception**: This is the ONLY case where you announce before acting. For all other work, start immediately without status updates.
|
||||
</Oracle_Usage>
|
||||
|
||||
<Task_Management>
|
||||
const SISYPHUS_TASK_MANAGEMENT = `<Task_Management>
|
||||
## Todo Management (CRITICAL)
|
||||
|
||||
**DEFAULT BEHAVIOR**: Create todos BEFORE starting any non-trivial task. This is your PRIMARY coordination mechanism.
|
||||
@@ -450,9 +337,9 @@ I want to make sure I understand correctly.
|
||||
|
||||
Should I proceed with [recommendation], or would you prefer differently?
|
||||
\`\`\`
|
||||
</Task_Management>
|
||||
</Task_Management>`
|
||||
|
||||
<Tone_and_Style>
|
||||
const SISYPHUS_TONE_AND_STYLE = `<Tone_and_Style>
|
||||
## Communication Style
|
||||
|
||||
### Be Concise
|
||||
@@ -492,31 +379,9 @@ If the user's approach seems problematic:
|
||||
- If user is terse, be terse
|
||||
- If user wants detail, provide detail
|
||||
- Adapt to their communication preference
|
||||
</Tone_and_Style>
|
||||
</Tone_and_Style>`
|
||||
|
||||
<Constraints>
|
||||
## Hard Blocks (NEVER violate)
|
||||
|
||||
| Constraint | No Exceptions |
|
||||
|------------|---------------|
|
||||
| Frontend VISUAL changes (styling, layout, animation) | Always delegate to \`frontend-ui-ux-engineer\` |
|
||||
| Type error suppression (\`as any\`, \`@ts-ignore\`) | Never |
|
||||
| Commit without explicit request | Never |
|
||||
| Speculate about unread code | Never |
|
||||
| Leave code in broken state after failures | Never |
|
||||
|
||||
## Anti-Patterns (BLOCKING violations)
|
||||
|
||||
| Category | Forbidden |
|
||||
|----------|-----------|
|
||||
| **Type Safety** | \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\` |
|
||||
| **Error Handling** | Empty catch blocks \`catch(e) {}\` |
|
||||
| **Testing** | Deleting failing tests to "pass" |
|
||||
| **Search** | Firing agents for single-line typos or obvious syntax errors |
|
||||
| **Frontend** | Direct edit to visual/styling code (logic changes OK) |
|
||||
| **Debugging** | Shotgun debugging, random changes |
|
||||
|
||||
## Soft Guidelines
|
||||
const SISYPHUS_SOFT_GUIDELINES = `## Soft Guidelines
|
||||
|
||||
- Prefer existing libraries over new dependencies
|
||||
- Prefer small, focused changes over large refactors
|
||||
@@ -525,14 +390,107 @@ If the user's approach seems problematic:
|
||||
|
||||
`
|
||||
|
||||
export function createSisyphusAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
function buildDynamicSisyphusPrompt(
|
||||
availableAgents: AvailableAgent[],
|
||||
availableTools: AvailableTool[] = [],
|
||||
availableSkills: AvailableSkill[] = []
|
||||
): string {
|
||||
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills)
|
||||
const toolSelection = buildToolSelectionTable(availableAgents, availableTools, availableSkills)
|
||||
const exploreSection = buildExploreSection(availableAgents)
|
||||
const librarianSection = buildLibrarianSection(availableAgents)
|
||||
const frontendSection = buildFrontendSection(availableAgents)
|
||||
const delegationTable = buildDelegationTable(availableAgents)
|
||||
const oracleSection = buildOracleSection(availableAgents)
|
||||
const hardBlocks = buildHardBlocksSection(availableAgents)
|
||||
const antiPatterns = buildAntiPatternsSection(availableAgents)
|
||||
|
||||
const sections = [
|
||||
SISYPHUS_ROLE_SECTION,
|
||||
"<Behavior_Instructions>",
|
||||
"",
|
||||
"## Phase 0 - Intent Gate (EVERY message)",
|
||||
"",
|
||||
keyTriggers,
|
||||
"",
|
||||
SISYPHUS_PHASE0_STEP1_3,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
SISYPHUS_PHASE1,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## Phase 2A - Exploration & Research",
|
||||
"",
|
||||
toolSelection,
|
||||
"",
|
||||
exploreSection,
|
||||
"",
|
||||
librarianSection,
|
||||
"",
|
||||
SISYPHUS_PARALLEL_EXECUTION,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
SISYPHUS_PHASE2B_PRE_IMPLEMENTATION,
|
||||
"",
|
||||
frontendSection,
|
||||
"",
|
||||
delegationTable,
|
||||
"",
|
||||
SISYPHUS_DELEGATION_PROMPT_STRUCTURE,
|
||||
"",
|
||||
SISYPHUS_GITHUB_WORKFLOW,
|
||||
"",
|
||||
SISYPHUS_CODE_CHANGES,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
SISYPHUS_PHASE2C,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
SISYPHUS_PHASE3,
|
||||
"",
|
||||
"</Behavior_Instructions>",
|
||||
"",
|
||||
oracleSection,
|
||||
"",
|
||||
SISYPHUS_TASK_MANAGEMENT,
|
||||
"",
|
||||
SISYPHUS_TONE_AND_STYLE,
|
||||
"",
|
||||
"<Constraints>",
|
||||
hardBlocks,
|
||||
"",
|
||||
antiPatterns,
|
||||
"",
|
||||
SISYPHUS_SOFT_GUIDELINES,
|
||||
]
|
||||
|
||||
return sections.filter((s) => s !== "").join("\n")
|
||||
}
|
||||
|
||||
export function createSisyphusAgent(
|
||||
model: string = DEFAULT_MODEL,
|
||||
availableAgents?: AvailableAgent[],
|
||||
availableToolNames?: string[],
|
||||
availableSkills?: AvailableSkill[]
|
||||
): AgentConfig {
|
||||
const tools = availableToolNames ? categorizeTools(availableToolNames) : []
|
||||
const skills = availableSkills ?? []
|
||||
const prompt = availableAgents
|
||||
? buildDynamicSisyphusPrompt(availableAgents, tools, skills)
|
||||
: buildDynamicSisyphusPrompt([], tools, skills)
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Sisyphus - Powerful AI orchestrator from OhMyOpenCode. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically to specialized agents. Uses explore for internal code (parallel-friendly), librarian only for external docs, and always delegates UI work to frontend engineer.",
|
||||
mode: "primary" as const,
|
||||
model,
|
||||
maxTokens: 64000,
|
||||
prompt: SISYPHUS_SYSTEM_PROMPT,
|
||||
prompt,
|
||||
color: "#00CED1",
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,56 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export type AgentFactory = (model?: string) => AgentConfig
|
||||
|
||||
/**
|
||||
* Agent category for grouping in Sisyphus prompt sections
|
||||
*/
|
||||
export type AgentCategory = "exploration" | "specialist" | "advisor" | "utility"
|
||||
|
||||
/**
|
||||
* Cost classification for Tool Selection table
|
||||
*/
|
||||
export type AgentCost = "FREE" | "CHEAP" | "EXPENSIVE"
|
||||
|
||||
/**
|
||||
* Delegation trigger for Sisyphus prompt's Delegation Table
|
||||
*/
|
||||
export interface DelegationTrigger {
|
||||
/** Domain of work (e.g., "Frontend UI/UX") */
|
||||
domain: string
|
||||
/** When to delegate (e.g., "Visual changes only...") */
|
||||
trigger: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for generating Sisyphus prompt sections dynamically
|
||||
* This allows adding/removing agents without manually updating the Sisyphus prompt
|
||||
*/
|
||||
export interface AgentPromptMetadata {
|
||||
/** Category for grouping in prompt sections */
|
||||
category: AgentCategory
|
||||
|
||||
/** Cost classification for Tool Selection table */
|
||||
cost: AgentCost
|
||||
|
||||
/** Domain triggers for Delegation Table */
|
||||
triggers: DelegationTrigger[]
|
||||
|
||||
/** When to use this agent (for detailed sections) */
|
||||
useWhen?: string[]
|
||||
|
||||
/** When NOT to use this agent */
|
||||
avoidWhen?: string[]
|
||||
|
||||
/** Optional dedicated prompt section (markdown) - for agents like Oracle that have special sections */
|
||||
dedicatedSection?: string
|
||||
|
||||
/** Nickname/alias used in prompt (e.g., "Oracle" instead of "oracle") */
|
||||
promptAlias?: string
|
||||
|
||||
/** Key triggers that should appear in Phase 0 (e.g., "External library mentioned → fire librarian") */
|
||||
keyTrigger?: string
|
||||
}
|
||||
|
||||
export function isGptModel(model: string): boolean {
|
||||
return model.startsWith("openai/") || model.startsWith("github-copilot/gpt-")
|
||||
}
|
||||
|
||||
@@ -29,18 +29,17 @@ function buildAgent(source: AgentSource, model?: string): AgentConfig {
|
||||
return isFactory(source) ? source(model) : source
|
||||
}
|
||||
|
||||
export function createEnvContext(directory: string): string {
|
||||
/**
|
||||
* Creates OmO-specific environment context (time, timezone, locale).
|
||||
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
|
||||
* so we only include fields that OpenCode doesn't provide to avoid duplication.
|
||||
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
||||
*/
|
||||
export function createEnvContext(): string {
|
||||
const now = new Date()
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
||||
|
||||
const dateStr = now.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
const timeStr = now.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -48,18 +47,12 @@ export function createEnvContext(directory: string): string {
|
||||
hour12: true,
|
||||
})
|
||||
|
||||
const platform = process.platform as "darwin" | "linux" | "win32" | string
|
||||
|
||||
return `
|
||||
Here is some useful information about the environment you are running in:
|
||||
<env>
|
||||
Working directory: ${directory}
|
||||
Platform: ${platform}
|
||||
Today's date: ${dateStr} (NOT 2024, NEVEREVER 2024)
|
||||
<omo-env>
|
||||
Current time: ${timeStr}
|
||||
Timezone: ${timezone}
|
||||
Locale: ${locale}
|
||||
</env>`
|
||||
</omo-env>`
|
||||
}
|
||||
|
||||
function mergeAgentConfig(
|
||||
@@ -97,7 +90,7 @@ export function createBuiltinAgents(
|
||||
let config = buildAgent(source, model)
|
||||
|
||||
if ((agentName === "Sisyphus" || agentName === "librarian") && directory && config.prompt) {
|
||||
const envContext = createEnvContext(directory)
|
||||
const envContext = createEnvContext()
|
||||
config = { ...config, prompt: config.prompt + envContext }
|
||||
}
|
||||
|
||||
|
||||
57
src/auth/AGENTS.md
Normal file
57
src/auth/AGENTS.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# AUTH KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Google Antigravity OAuth implementation for Gemini models. Token management, fetch interception, thinking block extraction, and response transformation.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
auth/
|
||||
└── antigravity/
|
||||
├── plugin.ts # Main plugin export, hooks registration
|
||||
├── oauth.ts # OAuth flow, token acquisition
|
||||
├── token.ts # Token storage, refresh logic
|
||||
├── fetch.ts # Fetch interceptor (622 lines) - URL rewriting, retry
|
||||
├── response.ts # Response transformation, streaming
|
||||
├── thinking.ts # Thinking block extraction/transformation
|
||||
├── thought-signature-store.ts # Signature caching for thinking blocks
|
||||
├── message-converter.ts # Message format conversion
|
||||
├── request.ts # Request building, headers
|
||||
├── project.ts # Project ID management
|
||||
├── tools.ts # Tool registration for OAuth
|
||||
├── constants.ts # API endpoints, model mappings
|
||||
└── types.ts # TypeScript interfaces
|
||||
```
|
||||
|
||||
## KEY COMPONENTS
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `fetch.ts` | Core interceptor - rewrites URLs, manages tokens, handles retries |
|
||||
| `thinking.ts` | Extracts `<antThinking>` blocks, transforms for OpenCode compatibility |
|
||||
| `response.ts` | Handles streaming responses, SSE parsing |
|
||||
| `oauth.ts` | Browser-based OAuth flow for Google accounts |
|
||||
| `token.ts` | Token persistence, expiry checking, refresh |
|
||||
|
||||
## HOW IT WORKS
|
||||
|
||||
1. **Intercept**: `fetch.ts` intercepts requests to Anthropic/Google endpoints
|
||||
2. **Rewrite**: URLs rewritten to Antigravity proxy endpoints
|
||||
3. **Auth**: Bearer token injected from stored OAuth credentials
|
||||
4. **Response**: Streaming responses parsed, thinking blocks extracted
|
||||
5. **Transform**: Response format normalized for OpenCode consumption
|
||||
|
||||
## ANTI-PATTERNS (AUTH)
|
||||
|
||||
- **Direct API calls**: Always go through fetch interceptor
|
||||
- **Storing tokens in code**: Use `token.ts` storage layer
|
||||
- **Ignoring refresh**: Check token expiry before requests
|
||||
- **Blocking on OAuth**: OAuth flow is async, never block main thread
|
||||
|
||||
## NOTES
|
||||
|
||||
- **Multi-account**: Supports up to 10 Google accounts for load balancing
|
||||
- **Fallback**: On rate limit, automatically switches to next available account
|
||||
- **Thinking blocks**: Preserved and transformed for extended thinking features
|
||||
- **Proxy**: Uses Antigravity proxy for Google AI Studio access
|
||||
93
src/cli/AGENTS.md
Normal file
93
src/cli/AGENTS.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# CLI KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Command-line interface for oh-my-opencode. Interactive installer, health diagnostics (doctor), and runtime commands. Entry point: `bunx oh-my-opencode`.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Commander.js entry point, subcommand routing
|
||||
├── install.ts # Interactive TUI installer
|
||||
├── config-manager.ts # Config detection, parsing, merging (669 lines)
|
||||
├── types.ts # CLI-specific types
|
||||
├── doctor/ # Health check system
|
||||
│ ├── index.ts # Doctor command entry
|
||||
│ ├── constants.ts # Check categories, descriptions
|
||||
│ ├── types.ts # Check result interfaces
|
||||
│ └── checks/ # 17 individual health checks
|
||||
├── get-local-version/ # Version detection utility
|
||||
│ ├── index.ts
|
||||
│ └── formatter.ts
|
||||
└── run/ # OpenCode session launcher
|
||||
├── index.ts
|
||||
└── completion.test.ts
|
||||
```
|
||||
|
||||
## CLI COMMANDS
|
||||
|
||||
| Command | Purpose | Key File |
|
||||
|---------|---------|----------|
|
||||
| `install` | Interactive setup wizard | `install.ts` |
|
||||
| `doctor` | Environment health checks | `doctor/index.ts` |
|
||||
| `run` | Launch OpenCode session | `run/index.ts` |
|
||||
|
||||
## DOCTOR CHECKS
|
||||
|
||||
17 checks in `doctor/checks/`:
|
||||
|
||||
| Check | Validates |
|
||||
|-------|-----------|
|
||||
| `version.ts` | OpenCode version >= 1.0.150 |
|
||||
| `config.ts` | Plugin registered in opencode.json |
|
||||
| `bun.ts` | Bun runtime available |
|
||||
| `node.ts` | Node.js version compatibility |
|
||||
| `git.ts` | Git installed |
|
||||
| `anthropic-auth.ts` | Claude authentication |
|
||||
| `openai-auth.ts` | OpenAI authentication |
|
||||
| `google-auth.ts` | Google/Gemini authentication |
|
||||
| `lsp-*.ts` | Language server availability |
|
||||
| `mcp-*.ts` | MCP server connectivity |
|
||||
|
||||
## INSTALLATION FLOW
|
||||
|
||||
1. **Detection**: Find existing `opencode.json` / `opencode.jsonc`
|
||||
2. **TUI Prompts**: Claude subscription? ChatGPT? Gemini?
|
||||
3. **Config Generation**: Build `oh-my-opencode.json` based on answers
|
||||
4. **Plugin Registration**: Add to `plugin` array in opencode.json
|
||||
5. **Auth Guidance**: Instructions for `opencode auth login`
|
||||
|
||||
## CONFIG-MANAGER
|
||||
|
||||
The largest file (669 lines) handles:
|
||||
|
||||
- **JSONC support**: Parses comments and trailing commas
|
||||
- **Multi-source detection**: User (~/.config/opencode/) + Project (.opencode/)
|
||||
- **Schema validation**: Zod-based config validation
|
||||
- **Migration**: Handles legacy config formats
|
||||
- **Error collection**: Aggregates parsing errors for doctor
|
||||
|
||||
## HOW TO ADD A DOCTOR CHECK
|
||||
|
||||
1. Create `src/cli/doctor/checks/my-check.ts`:
|
||||
```typescript
|
||||
import type { DoctorCheck } from "../types"
|
||||
|
||||
export const myCheck: DoctorCheck = {
|
||||
name: "my-check",
|
||||
category: "environment",
|
||||
check: async () => {
|
||||
// Return { status: "pass" | "warn" | "fail", message: string }
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Add to `src/cli/doctor/checks/index.ts`
|
||||
3. Update `constants.ts` if new category
|
||||
|
||||
## ANTI-PATTERNS (CLI)
|
||||
|
||||
- **Blocking prompts in non-TTY**: Check `process.stdout.isTTY` before TUI
|
||||
- **Hardcoded paths**: Use shared utilities for config paths
|
||||
- **Ignoring JSONC**: User configs may have comments
|
||||
- **Silent failures**: Doctor checks must return clear status/message
|
||||
106
src/cli/doctor/checks/gh.test.ts
Normal file
106
src/cli/doctor/checks/gh.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as gh from "./gh"
|
||||
|
||||
describe("gh cli check", () => {
|
||||
describe("getGhCliInfo", () => {
|
||||
it("returns gh cli info structure", async () => {
|
||||
// #given
|
||||
// #when checking gh cli info
|
||||
const info = await gh.getGhCliInfo()
|
||||
|
||||
// #then should return valid info structure
|
||||
expect(typeof info.installed).toBe("boolean")
|
||||
expect(info.authenticated === true || info.authenticated === false).toBe(true)
|
||||
expect(Array.isArray(info.scopes)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkGhCli", () => {
|
||||
let getInfoSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getInfoSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns warn when gh is not installed", async () => {
|
||||
// #given gh not installed
|
||||
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: null,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await gh.checkGhCli()
|
||||
|
||||
// #then should warn (optional)
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("Not installed")
|
||||
expect(result.details).toContain("Install: https://cli.github.com/")
|
||||
})
|
||||
|
||||
it("returns warn when gh is installed but not authenticated", async () => {
|
||||
// #given gh installed but not authenticated
|
||||
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
|
||||
installed: true,
|
||||
version: "2.40.0",
|
||||
path: "/usr/local/bin/gh",
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: "not logged in",
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await gh.checkGhCli()
|
||||
|
||||
// #then should warn about auth
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("2.40.0")
|
||||
expect(result.message).toContain("not authenticated")
|
||||
expect(result.details).toContain("Authenticate: gh auth login")
|
||||
})
|
||||
|
||||
it("returns pass when gh is installed and authenticated", async () => {
|
||||
// #given gh installed and authenticated
|
||||
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
|
||||
installed: true,
|
||||
version: "2.40.0",
|
||||
path: "/usr/local/bin/gh",
|
||||
authenticated: true,
|
||||
username: "octocat",
|
||||
scopes: ["repo", "read:org"],
|
||||
error: null,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await gh.checkGhCli()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("2.40.0")
|
||||
expect(result.message).toContain("octocat")
|
||||
expect(result.details).toContain("Account: octocat")
|
||||
expect(result.details).toContain("Scopes: repo, read:org")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGhCliCheckDefinition", () => {
|
||||
it("returns correct check definition", () => {
|
||||
// #given
|
||||
// #when getting definition
|
||||
const def = gh.getGhCliCheckDefinition()
|
||||
|
||||
// #then should have correct properties
|
||||
expect(def.id).toBe("gh-cli")
|
||||
expect(def.name).toBe("GitHub CLI")
|
||||
expect(def.category).toBe("tools")
|
||||
expect(def.critical).toBe(false)
|
||||
expect(typeof def.check).toBe("function")
|
||||
})
|
||||
})
|
||||
})
|
||||
171
src/cli/doctor/checks/gh.ts
Normal file
171
src/cli/doctor/checks/gh.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { CheckResult, CheckDefinition } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
|
||||
export interface GhCliInfo {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
authenticated: boolean
|
||||
username: string | null
|
||||
scopes: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||
try {
|
||||
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return { exists: true, path: output.trim() }
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - binary not found
|
||||
}
|
||||
return { exists: false, path: null }
|
||||
}
|
||||
|
||||
async function getGhVersion(): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
const match = output.match(/gh version (\S+)/)
|
||||
return match?.[1] ?? output.trim().split("\n")[0]
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - version unavailable
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function getGhAuthStatus(): Promise<{
|
||||
authenticated: boolean
|
||||
username: string | null
|
||||
scopes: string[]
|
||||
error: string | null
|
||||
}> {
|
||||
try {
|
||||
const proc = Bun.spawn(["gh", "auth", "status"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
||||
})
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
await proc.exited
|
||||
|
||||
const output = stderr || stdout
|
||||
|
||||
if (proc.exitCode === 0) {
|
||||
const usernameMatch = output.match(/Logged in to github\.com account (\S+)/)
|
||||
const username = usernameMatch?.[1]?.replace(/[()]/g, "") ?? null
|
||||
|
||||
const scopesMatch = output.match(/Token scopes?:\s*(.+)/i)
|
||||
const scopes = scopesMatch?.[1]
|
||||
? scopesMatch[1]
|
||||
.split(/,\s*/)
|
||||
.map((s) => s.replace(/['"]/g, "").trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
return { authenticated: true, username, scopes, error: null }
|
||||
}
|
||||
|
||||
const errorMatch = output.match(/error[:\s]+(.+)/i)
|
||||
return {
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: errorMatch?.[1]?.trim() ?? "Not authenticated",
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: err instanceof Error ? err.message : "Failed to check auth status",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGhCliInfo(): Promise<GhCliInfo> {
|
||||
const binaryCheck = await checkBinaryExists("gh")
|
||||
|
||||
if (!binaryCheck.exists) {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()])
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
path: binaryCheck.path,
|
||||
authenticated: authStatus.authenticated,
|
||||
username: authStatus.username,
|
||||
scopes: authStatus.scopes,
|
||||
error: authStatus.error,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkGhCli(): Promise<CheckResult> {
|
||||
const info = await getGhCliInfo()
|
||||
const name = CHECK_NAMES[CHECK_IDS.GH_CLI]
|
||||
|
||||
if (!info.installed) {
|
||||
return {
|
||||
name,
|
||||
status: "warn",
|
||||
message: "Not installed (optional)",
|
||||
details: [
|
||||
"GitHub CLI is used by librarian agent and scripts",
|
||||
"Install: https://cli.github.com/",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (!info.authenticated) {
|
||||
return {
|
||||
name,
|
||||
status: "warn",
|
||||
message: `${info.version ?? "installed"} - not authenticated`,
|
||||
details: [
|
||||
info.path ? `Path: ${info.path}` : null,
|
||||
"Authenticate: gh auth login",
|
||||
info.error ? `Error: ${info.error}` : null,
|
||||
].filter((d): d is string => d !== null),
|
||||
}
|
||||
}
|
||||
|
||||
const details: string[] = []
|
||||
if (info.path) details.push(`Path: ${info.path}`)
|
||||
if (info.username) details.push(`Account: ${info.username}`)
|
||||
if (info.scopes.length > 0) details.push(`Scopes: ${info.scopes.join(", ")}`)
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "pass",
|
||||
message: `${info.version ?? "installed"} - authenticated as ${info.username ?? "unknown"}`,
|
||||
details: details.length > 0 ? details : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getGhCliCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.GH_CLI,
|
||||
name: CHECK_NAMES[CHECK_IDS.GH_CLI],
|
||||
category: "tools",
|
||||
check: checkGhCli,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getPluginCheckDefinition } from "./plugin"
|
||||
import { getConfigCheckDefinition } from "./config"
|
||||
import { getAuthCheckDefinitions } from "./auth"
|
||||
import { getDependencyCheckDefinitions } from "./dependencies"
|
||||
import { getGhCliCheckDefinition } from "./gh"
|
||||
import { getLspCheckDefinition } from "./lsp"
|
||||
import { getMcpCheckDefinitions } from "./mcp"
|
||||
import { getVersionCheckDefinition } from "./version"
|
||||
@@ -13,6 +14,7 @@ export * from "./plugin"
|
||||
export * from "./config"
|
||||
export * from "./auth"
|
||||
export * from "./dependencies"
|
||||
export * from "./gh"
|
||||
export * from "./lsp"
|
||||
export * from "./mcp"
|
||||
export * from "./version"
|
||||
@@ -24,6 +26,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
|
||||
getConfigCheckDefinition(),
|
||||
...getAuthCheckDefinitions(),
|
||||
...getDependencyCheckDefinitions(),
|
||||
getGhCliCheckDefinition(),
|
||||
getLspCheckDefinition(),
|
||||
...getMcpCheckDefinitions(),
|
||||
getVersionCheckDefinition(),
|
||||
|
||||
@@ -1,76 +1,11 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, VersionCheckInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
|
||||
async function fetchLatestVersion(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as { version: string }
|
||||
return data.version
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentVersion(): {
|
||||
version: string | null
|
||||
isLocalDev: boolean
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
} {
|
||||
const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const config = parseJsonc<{ plugin?: string[] }>(content)
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.startsWith("file:") && plugin.includes(PACKAGE_NAME)) {
|
||||
return { version: "local-dev", isLocalDev: true, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
if (plugin.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const pinnedVersion = plugin.split("@")[1]
|
||||
return { version: pinnedVersion, isLocalDev: false, isPinned: true, pinnedVersion }
|
||||
}
|
||||
if (plugin === PACKAGE_NAME) {
|
||||
if (existsSync(OPENCODE_PACKAGE_JSON)) {
|
||||
try {
|
||||
const pkgContent = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
|
||||
const pkg = JSON.parse(pkgContent) as { dependencies?: Record<string, string> }
|
||||
const depVersion = pkg.dependencies?.[PACKAGE_NAME]
|
||||
if (depVersion) {
|
||||
const cleanVersion = depVersion.replace(/^[\^~]/, "")
|
||||
return { version: cleanVersion, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - parse errors ignored
|
||||
}
|
||||
}
|
||||
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
}
|
||||
|
||||
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
} catch {
|
||||
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
}
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import {
|
||||
getCachedVersion,
|
||||
getLatestVersion,
|
||||
isLocalDevMode,
|
||||
findPluginEntry,
|
||||
} from "../../../hooks/auto-update-checker/checker"
|
||||
|
||||
function compareVersions(current: string, latest: string): boolean {
|
||||
const parseVersion = (v: string): number[] => {
|
||||
@@ -91,22 +26,43 @@ function compareVersions(current: string, latest: string): boolean {
|
||||
}
|
||||
|
||||
export async function getVersionInfo(): Promise<VersionCheckInfo> {
|
||||
const current = getCurrentVersion()
|
||||
const latestVersion = await fetchLatestVersion()
|
||||
const cwd = process.cwd()
|
||||
|
||||
if (isLocalDevMode(cwd)) {
|
||||
return {
|
||||
currentVersion: "local-dev",
|
||||
latestVersion: null,
|
||||
isUpToDate: true,
|
||||
isLocalDev: true,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(cwd)
|
||||
if (pluginInfo?.isPinned) {
|
||||
return {
|
||||
currentVersion: pluginInfo.pinnedVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: true,
|
||||
isLocalDev: false,
|
||||
isPinned: true,
|
||||
}
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion()
|
||||
const latestVersion = await getLatestVersion()
|
||||
|
||||
const isUpToDate =
|
||||
current.isLocalDev ||
|
||||
current.isPinned ||
|
||||
!current.version ||
|
||||
!currentVersion ||
|
||||
!latestVersion ||
|
||||
compareVersions(current.version, latestVersion)
|
||||
compareVersions(currentVersion, latestVersion)
|
||||
|
||||
return {
|
||||
currentVersion: current.version,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpToDate,
|
||||
isLocalDev: current.isLocalDev,
|
||||
isPinned: current.isPinned,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export const CHECK_IDS = {
|
||||
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
|
||||
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
|
||||
DEP_COMMENT_CHECKER: "dep-comment-checker",
|
||||
GH_CLI: "gh-cli",
|
||||
LSP_SERVERS: "lsp-servers",
|
||||
MCP_BUILTIN: "mcp-builtin",
|
||||
MCP_USER: "mcp-user",
|
||||
@@ -43,6 +44,7 @@ export const CHECK_NAMES: Record<string, string> = {
|
||||
[CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI",
|
||||
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
|
||||
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
|
||||
[CHECK_IDS.GH_CLI]: "GitHub CLI",
|
||||
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
|
||||
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
|
||||
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
|
||||
|
||||
@@ -26,6 +26,10 @@ export const BuiltinAgentNameSchema = z.enum([
|
||||
"multimodal-looker",
|
||||
])
|
||||
|
||||
export const BuiltinSkillNameSchema = z.enum([
|
||||
"playwright",
|
||||
])
|
||||
|
||||
export const OverridableAgentNameSchema = z.enum([
|
||||
"build",
|
||||
"plan",
|
||||
@@ -66,6 +70,10 @@ export const HookNameSchema = z.enum([
|
||||
"empty-message-sanitizer",
|
||||
"thinking-block-validator",
|
||||
"ralph-loop",
|
||||
"preemptive-compaction",
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
@@ -164,7 +172,7 @@ export const DynamicContextPruningConfigSchema = z.object({
|
||||
export const ExperimentalConfigSchema = z.object({
|
||||
aggressive_truncation: z.boolean().optional(),
|
||||
auto_resume: z.boolean().optional(),
|
||||
/** Enable preemptive compaction at threshold (default: false) */
|
||||
/** Enable preemptive compaction at threshold (default: true since v2.9.0) */
|
||||
preemptive_compaction: z.boolean().optional(),
|
||||
/** Threshold percentage to trigger preemptive compaction (default: 0.80) */
|
||||
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
|
||||
@@ -227,6 +235,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(McpNameSchema).optional(),
|
||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||
disabled_hooks: z.array(HookNameSchema).optional(),
|
||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
@@ -246,6 +255,7 @@ export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
||||
export type AgentName = z.infer<typeof AgentNameSchema>
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
|
||||
export type BuiltinSkillName = z.infer<typeof BuiltinSkillNameSchema>
|
||||
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
|
||||
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>
|
||||
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||
|
||||
@@ -13,6 +13,8 @@ features/
|
||||
│ ├── manager.test.ts
|
||||
│ └── types.ts
|
||||
├── builtin-commands/ # Built-in slash command definitions
|
||||
├── builtin-skills/ # Built-in skills (playwright, etc.)
|
||||
│ └── */SKILL.md # Each skill in own directory
|
||||
├── claude-code-agent-loader/ # Load agents from ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # Load commands from ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # Load MCPs from .mcp.json
|
||||
@@ -20,6 +22,9 @@ features/
|
||||
├── claude-code-plugin-loader/ # Load external plugins from installed_plugins.json
|
||||
├── claude-code-session-state/ # Session state persistence
|
||||
├── opencode-skill-loader/ # Load skills from OpenCode and Claude paths
|
||||
├── skill-mcp-manager/ # MCP servers embedded in skills
|
||||
│ ├── manager.ts # Lazy-loading MCP client lifecycle
|
||||
│ └── types.ts
|
||||
└── hook-message-injector/ # Inject messages into conversation
|
||||
```
|
||||
|
||||
@@ -72,6 +77,19 @@ Disable features in `oh-my-opencode.json`:
|
||||
- **Timing**: PreToolUse, PostToolUse, UserPromptSubmit, Stop
|
||||
- **Format**: Returns `{ messages: [{ role: "user", content: "..." }] }`
|
||||
|
||||
## SKILL MCP MANAGER
|
||||
|
||||
- **Purpose**: Manage MCP servers embedded in skill YAML frontmatter
|
||||
- **Lifecycle**: Lazy client loading, session-scoped cleanup
|
||||
- **Config**: `mcp` field in skill's YAML frontmatter defines server config
|
||||
- **Tool**: `skill_mcp` exposes MCP capabilities (tools, resources, prompts)
|
||||
|
||||
## BUILTIN SKILLS
|
||||
|
||||
- **Location**: `src/features/builtin-skills/*/SKILL.md`
|
||||
- **Available**: `playwright` (browser automation)
|
||||
- **Disable**: `disabled_skills: ["playwright"]` in config
|
||||
|
||||
## ANTI-PATTERNS (FEATURES)
|
||||
|
||||
- **Blocking on load**: Loaders run at startup, keep them fast
|
||||
|
||||
@@ -42,10 +42,8 @@ export function loadBuiltinCommands(
|
||||
|
||||
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
|
||||
if (!disabled.has(name as BuiltinCommandName)) {
|
||||
commands[name] = {
|
||||
name,
|
||||
...definition,
|
||||
}
|
||||
const { argumentHint: _argumentHint, ...openCodeCompatible } = definition
|
||||
commands[name] = openCodeCompatible as CommandDefinition
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,205 +1,191 @@
|
||||
export const INIT_DEEP_TEMPLATE = `# Initialize Deep Knowledge Base
|
||||
export const INIT_DEEP_TEMPLATE = `# /init-deep
|
||||
|
||||
Generate comprehensive AGENTS.md files across project hierarchy. Combines root-level project knowledge (gen-knowledge) with complexity-based subdirectory documentation (gen-knowledge-deep).
|
||||
Generate hierarchical AGENTS.md files. Root + complexity-scored subdirectories.
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`
|
||||
/init-deep # Analyze and generate hierarchical AGENTS.md
|
||||
/init-deep --create-new # Force create from scratch (ignore existing)
|
||||
/init-deep --max-depth=2 # Limit to N directory levels (default: 3)
|
||||
/init-deep # Update mode: modify existing + create new where warranted
|
||||
/init-deep --create-new # Read existing → remove all → regenerate from scratch
|
||||
/init-deep --max-depth=2 # Limit directory depth (default: 3)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
## Workflow (High-Level)
|
||||
|
||||
- **Telegraphic Style**: Sacrifice grammar for concision ("Project uses React" → "React 18")
|
||||
- **Predict-then-Compare**: Predict standard → find actual → document ONLY deviations
|
||||
- **Hierarchy Aware**: Parent covers general, children cover specific
|
||||
- **No Redundancy**: Child AGENTS.md NEVER repeats parent content
|
||||
- **LSP-First**: Use LSP tools for accurate code intelligence when available (semantic > text search)
|
||||
|
||||
---
|
||||
|
||||
## Process
|
||||
1. **Discovery + Analysis** (concurrent)
|
||||
- Fire background explore agents immediately
|
||||
- Main session: bash structure + LSP codemap + read existing AGENTS.md
|
||||
2. **Score & Decide** - Determine AGENTS.md locations from merged findings
|
||||
3. **Generate** - Root first, then subdirs in parallel
|
||||
4. **Review** - Deduplicate, trim, validate
|
||||
|
||||
<critical>
|
||||
**MANDATORY: TodoWrite for ALL phases. Mark in_progress → completed in real-time.**
|
||||
</critical>
|
||||
|
||||
### Phase 0: Initialize
|
||||
|
||||
**TodoWrite ALL phases. Mark in_progress → completed in real-time.**
|
||||
\`\`\`
|
||||
TodoWrite([
|
||||
{ id: "p1-analysis", content: "Parallel project structure & complexity analysis", status: "pending", priority: "high" },
|
||||
{ id: "p2-scoring", content: "Score directories, determine AGENTS.md locations", status: "pending", priority: "high" },
|
||||
{ id: "p3-root", content: "Generate root AGENTS.md with Predict-then-Compare", status: "pending", priority: "high" },
|
||||
{ id: "p4-subdirs", content: "Generate subdirectory AGENTS.md files in parallel", status: "pending", priority: "high" },
|
||||
{ id: "p5-review", content: "Review, deduplicate, validate all files", status: "pending", priority: "medium" }
|
||||
{ id: "discovery", content: "Fire explore agents + LSP codemap + read existing", status: "pending", priority: "high" },
|
||||
{ id: "scoring", content: "Score directories, determine locations", status: "pending", priority: "high" },
|
||||
{ id: "generate", content: "Generate AGENTS.md files (root + subdirs)", status: "pending", priority: "high" },
|
||||
{ id: "review", content: "Deduplicate, validate, trim", status: "pending", priority: "medium" }
|
||||
])
|
||||
\`\`\`
|
||||
</critical>
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Parallel Project Analysis
|
||||
## Phase 1: Discovery + Analysis (Concurrent)
|
||||
|
||||
**Mark "p1-analysis" as in_progress.**
|
||||
**Mark "discovery" as in_progress.**
|
||||
|
||||
Launch **ALL tasks simultaneously**:
|
||||
### Fire Background Explore Agents IMMEDIATELY
|
||||
|
||||
<parallel-tasks>
|
||||
Don't wait—these run async while main session works.
|
||||
|
||||
\`\`\`
|
||||
// Fire all at once, collect results later
|
||||
background_task(agent="explore", prompt="Project structure: PREDICT standard patterns for detected language → REPORT deviations only")
|
||||
background_task(agent="explore", prompt="Entry points: FIND main files → REPORT non-standard organization")
|
||||
background_task(agent="explore", prompt="Conventions: FIND config files (.eslintrc, pyproject.toml, .editorconfig) → REPORT project-specific rules")
|
||||
background_task(agent="explore", prompt="Anti-patterns: FIND 'DO NOT', 'NEVER', 'ALWAYS', 'DEPRECATED' comments → LIST forbidden patterns")
|
||||
background_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makefile → REPORT non-standard patterns")
|
||||
background_task(agent="explore", prompt="Test patterns: FIND test configs, test structure → REPORT unique conventions")
|
||||
\`\`\`
|
||||
|
||||
<dynamic-agents>
|
||||
**DYNAMIC AGENT SPAWNING**: After bash analysis, spawn ADDITIONAL explore agents based on project scale:
|
||||
|
||||
| Factor | Threshold | Additional Agents |
|
||||
|--------|-----------|-------------------|
|
||||
| **Total files** | >100 | +1 per 100 files |
|
||||
| **Total lines** | >10k | +1 per 10k lines |
|
||||
| **Directory depth** | ≥4 | +2 for deep exploration |
|
||||
| **Large files (>500 lines)** | >10 files | +1 for complexity hotspots |
|
||||
| **Monorepo** | detected | +1 per package/workspace |
|
||||
| **Multiple languages** | >1 | +1 per language |
|
||||
|
||||
### Structural Analysis (bash - run in parallel)
|
||||
\`\`\`bash
|
||||
# Task A: Directory depth analysis
|
||||
find . -type d -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' -not -path '*/dist/*' -not -path '*/build/*' | awk -F/ '{print NF-1}' | sort -n | uniq -c
|
||||
# Measure project scale first
|
||||
total_files=$(find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | wc -l)
|
||||
total_lines=$(find . -type f \\( -name "*.ts" -o -name "*.py" -o -name "*.go" \\) -not -path '*/node_modules/*' -exec wc -l {} + 2>/dev/null | tail -1 | awk '{print $1}')
|
||||
large_files=$(find . -type f \\( -name "*.ts" -o -name "*.py" \\) -not -path '*/node_modules/*' -exec wc -l {} + 2>/dev/null | awk '$1 > 500 {count++} END {print count+0}')
|
||||
max_depth=$(find . -type d -not -path '*/node_modules/*' -not -path '*/.git/*' | awk -F/ '{print NF}' | sort -rn | head -1)
|
||||
\`\`\`
|
||||
|
||||
# Task B: File count per directory
|
||||
find . -type f -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -30
|
||||
Example spawning:
|
||||
\`\`\`
|
||||
// 500 files, 50k lines, depth 6, 15 large files → spawn 5+5+2+1 = 13 additional agents
|
||||
background_task(agent="explore", prompt="Large file analysis: FIND files >500 lines, REPORT complexity hotspots")
|
||||
background_task(agent="explore", prompt="Deep modules at depth 4+: FIND hidden patterns, internal conventions")
|
||||
background_task(agent="explore", prompt="Cross-cutting concerns: FIND shared utilities across directories")
|
||||
// ... more based on calculation
|
||||
\`\`\`
|
||||
</dynamic-agents>
|
||||
|
||||
# Task C: Code concentration
|
||||
find . -type f \\( -name "*.py" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.go" -o -name "*.rs" -o -name "*.java" \\) -not -path '*/node_modules/*' -not -path '*/venv/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20
|
||||
### Main Session: Concurrent Analysis
|
||||
|
||||
# Task D: Existing knowledge files
|
||||
**While background agents run**, main session does:
|
||||
|
||||
#### 1. Bash Structural Analysis
|
||||
\`\`\`bash
|
||||
# Directory depth + file counts
|
||||
find . -type d -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/dist/*' -not -path '*/build/*' | awk -F/ '{print NF-1}' | sort -n | uniq -c
|
||||
|
||||
# Files per directory (top 30)
|
||||
find . -type f -not -path '*/\\.*' -not -path '*/node_modules/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -30
|
||||
|
||||
# Code concentration by extension
|
||||
find . -type f \\( -name "*.py" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.go" -o -name "*.rs" \\) -not -path '*/node_modules/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20
|
||||
|
||||
# Existing AGENTS.md / CLAUDE.md
|
||||
find . -type f \\( -name "AGENTS.md" -o -name "CLAUDE.md" \\) -not -path '*/node_modules/*' 2>/dev/null
|
||||
\`\`\`
|
||||
|
||||
### Context Gathering (Explore agents - background_task in parallel)
|
||||
|
||||
#### 2. Read Existing AGENTS.md
|
||||
\`\`\`
|
||||
background_task(agent="explore", prompt="Project structure: PREDICT standard {lang} patterns → FIND package.json/pyproject.toml/go.mod → REPORT deviations only")
|
||||
|
||||
background_task(agent="explore", prompt="Entry points: PREDICT typical (main.py, index.ts) → FIND actual → REPORT non-standard organization")
|
||||
|
||||
background_task(agent="explore", prompt="Conventions: FIND .cursor/rules, .cursorrules, eslintrc, pyproject.toml → REPORT project-specific rules DIFFERENT from defaults")
|
||||
|
||||
background_task(agent="explore", prompt="Anti-patterns: FIND comments with 'DO NOT', 'NEVER', 'ALWAYS', 'LEGACY', 'DEPRECATED' → REPORT forbidden patterns")
|
||||
|
||||
background_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makefile, justfile → REPORT non-standard build/deploy patterns")
|
||||
|
||||
background_task(agent="explore", prompt="Test patterns: FIND pytest.ini, jest.config, test structure → REPORT unique testing conventions")
|
||||
For each existing file found:
|
||||
Read(filePath=file)
|
||||
Extract: key insights, conventions, anti-patterns
|
||||
Store in EXISTING_AGENTS map
|
||||
\`\`\`
|
||||
|
||||
### Code Intelligence Analysis (LSP tools - run in parallel)
|
||||
|
||||
LSP provides semantic understanding beyond text search. Use for accurate code mapping.
|
||||
If \`--create-new\`: Read all existing first (preserve context) → then delete all → regenerate.
|
||||
|
||||
#### 3. LSP Codemap (if available)
|
||||
\`\`\`
|
||||
# Step 1: Check LSP availability
|
||||
lsp_servers() # Verify language server is available
|
||||
lsp_servers() # Check availability
|
||||
|
||||
# Step 2: Analyze entry point files (run in parallel)
|
||||
# Find entry points first, then analyze each with lsp_document_symbols
|
||||
lsp_document_symbols(filePath="src/index.ts") # Main entry
|
||||
lsp_document_symbols(filePath="src/main.py") # Python entry
|
||||
lsp_document_symbols(filePath="cmd/main.go") # Go entry
|
||||
# Entry points (parallel)
|
||||
lsp_document_symbols(filePath="src/index.ts")
|
||||
lsp_document_symbols(filePath="main.py")
|
||||
|
||||
# Step 3: Discover key symbols across workspace (run in parallel)
|
||||
lsp_workspace_symbols(filePath=".", query="class") # All classes
|
||||
lsp_workspace_symbols(filePath=".", query="interface") # All interfaces
|
||||
lsp_workspace_symbols(filePath=".", query="function") # Top-level functions
|
||||
lsp_workspace_symbols(filePath=".", query="type") # Type definitions
|
||||
# Key symbols (parallel)
|
||||
lsp_workspace_symbols(filePath=".", query="class")
|
||||
lsp_workspace_symbols(filePath=".", query="interface")
|
||||
lsp_workspace_symbols(filePath=".", query="function")
|
||||
|
||||
# Step 4: Analyze symbol centrality (for top 5-10 key symbols)
|
||||
# High reference count = central/important concept
|
||||
lsp_find_references(filePath="src/index.ts", line=X, character=Y) # Main export
|
||||
# Centrality for top exports
|
||||
lsp_find_references(filePath="...", line=X, character=Y)
|
||||
\`\`\`
|
||||
|
||||
#### LSP Analysis Output Format
|
||||
**LSP Fallback**: If unavailable, rely on explore agents + AST-grep.
|
||||
|
||||
### Collect Background Results
|
||||
|
||||
\`\`\`
|
||||
CODE_INTELLIGENCE = {
|
||||
entry_points: [
|
||||
{ file: "src/index.ts", exports: ["Plugin", "createHook"], symbol_count: 12 }
|
||||
],
|
||||
key_symbols: [
|
||||
{ name: "Plugin", type: "class", file: "src/index.ts", refs: 45, role: "Central orchestrator" },
|
||||
{ name: "createHook", type: "function", file: "src/utils.ts", refs: 23, role: "Hook factory" }
|
||||
],
|
||||
module_boundaries: [
|
||||
{ dir: "src/hooks", exports: 21, imports_from: ["shared/"] },
|
||||
{ dir: "src/tools", exports: 15, imports_from: ["shared/", "hooks/"] }
|
||||
]
|
||||
}
|
||||
// After main session analysis done, collect all task results
|
||||
for each task_id: background_output(task_id="...")
|
||||
\`\`\`
|
||||
|
||||
<critical>
|
||||
**LSP Fallback**: If LSP unavailable (no server installed), skip this section and rely on explore agents + AST-grep patterns.
|
||||
</critical>
|
||||
|
||||
</parallel-tasks>
|
||||
|
||||
**Collect all results. Mark "p1-analysis" as completed.**
|
||||
**Merge: bash + LSP + existing + explore findings. Mark "discovery" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Complexity Scoring & Location Decision
|
||||
## Phase 2: Scoring & Location Decision
|
||||
|
||||
**Mark "p2-scoring" as in_progress.**
|
||||
**Mark "scoring" as in_progress.**
|
||||
|
||||
### Scoring Matrix
|
||||
|
||||
| Factor | Weight | Threshold | Source |
|
||||
|--------|--------|-----------|--------|
|
||||
| File count | 3x | >20 files = high | bash |
|
||||
| Subdirectory count | 2x | >5 subdirs = high | bash |
|
||||
| Code file ratio | 2x | >70% code = high | bash |
|
||||
| Factor | Weight | High Threshold | Source |
|
||||
|--------|--------|----------------|--------|
|
||||
| File count | 3x | >20 | bash |
|
||||
| Subdir count | 2x | >5 | bash |
|
||||
| Code ratio | 2x | >70% | bash |
|
||||
| Unique patterns | 1x | Has own config | explore |
|
||||
| Module boundary | 2x | Has __init__.py/index.ts | bash |
|
||||
| **Symbol density** | 2x | >30 symbols = high | LSP |
|
||||
| **Export count** | 2x | >10 exports = high | LSP |
|
||||
| **Reference centrality** | 3x | Symbols with >20 refs | LSP |
|
||||
|
||||
<lsp-scoring>
|
||||
**LSP-Enhanced Scoring** (if available):
|
||||
|
||||
\`\`\`
|
||||
For each directory in candidates:
|
||||
symbols = lsp_document_symbols(dir/index.ts or dir/__init__.py)
|
||||
|
||||
symbol_score = len(symbols) > 30 ? 6 : len(symbols) > 15 ? 3 : 0
|
||||
export_score = count(exported symbols) > 10 ? 4 : 0
|
||||
|
||||
# Check if this module is central (many things depend on it)
|
||||
for each exported symbol:
|
||||
refs = lsp_find_references(symbol)
|
||||
if refs > 20: centrality_score += 3
|
||||
|
||||
total_score += symbol_score + export_score + centrality_score
|
||||
\`\`\`
|
||||
</lsp-scoring>
|
||||
| Module boundary | 2x | Has index.ts/__init__.py | bash |
|
||||
| Symbol density | 2x | >30 symbols | LSP |
|
||||
| Export count | 2x | >10 exports | LSP |
|
||||
| Reference centrality | 3x | >20 refs | LSP |
|
||||
|
||||
### Decision Rules
|
||||
|
||||
| Score | Action |
|
||||
|-------|--------|
|
||||
| **Root (.)** | ALWAYS create AGENTS.md |
|
||||
| **High (>15)** | Create dedicated AGENTS.md |
|
||||
| **Medium (8-15)** | Create if distinct domain |
|
||||
| **Low (<8)** | Skip, parent sufficient |
|
||||
|
||||
### Output Format
|
||||
| **Root (.)** | ALWAYS create |
|
||||
| **>15** | Create AGENTS.md |
|
||||
| **8-15** | Create if distinct domain |
|
||||
| **<8** | Skip (parent covers) |
|
||||
|
||||
### Output
|
||||
\`\`\`
|
||||
AGENTS_LOCATIONS = [
|
||||
{ path: ".", type: "root" },
|
||||
{ path: "src/api", score: 18, reason: "high complexity, 45 files" },
|
||||
{ path: "src/hooks", score: 12, reason: "distinct domain, unique patterns" },
|
||||
{ path: "src/hooks", score: 18, reason: "high complexity" },
|
||||
{ path: "src/api", score: 12, reason: "distinct domain" }
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
**Mark "p2-scoring" as completed.**
|
||||
**Mark "scoring" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Generate Root AGENTS.md
|
||||
## Phase 3: Generate AGENTS.md
|
||||
|
||||
**Mark "p3-root" as in_progress.**
|
||||
**Mark "generate" as in_progress.**
|
||||
|
||||
Root AGENTS.md gets **full treatment** with Predict-then-Compare synthesis.
|
||||
|
||||
### Required Sections
|
||||
### Root AGENTS.md (Full Treatment)
|
||||
|
||||
\`\`\`markdown
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
@@ -209,153 +195,75 @@ Root AGENTS.md gets **full treatment** with Predict-then-Compare synthesis.
|
||||
**Branch:** {BRANCH}
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
{1-2 sentences: what project does, core tech stack}
|
||||
{1-2 sentences: what + core stack}
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
\\\`\\\`\\\`
|
||||
{project-root}/
|
||||
├── {dir}/ # {non-obvious purpose only}
|
||||
└── {entry} # entry point
|
||||
{root}/
|
||||
├── {dir}/ # {non-obvious purpose only}
|
||||
└── {entry}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add feature X | \\\`src/x/\\\` | {pattern hint} |
|
||||
|
||||
## CODE MAP
|
||||
|
||||
{Generated from LSP analysis - shows key symbols and their relationships}
|
||||
{From LSP - skip if unavailable or project <10 files}
|
||||
|
||||
| Symbol | Type | Location | Refs | Role |
|
||||
|--------|------|----------|------|------|
|
||||
| {MainClass} | Class | \\\`src/index.ts\\\` | {N} | {Central orchestrator} |
|
||||
| {createX} | Function | \\\`src/utils.ts\\\` | {N} | {Factory pattern} |
|
||||
| {Config} | Interface | \\\`src/types.ts\\\` | {N} | {Configuration contract} |
|
||||
|
||||
### Module Dependencies
|
||||
|
||||
\\\`\\\`\\\`
|
||||
{entry} ──imports──> {core/}
|
||||
│ │
|
||||
└──imports──> {utils/} <──imports── {features/}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
<code-map-note>
|
||||
**Skip CODE MAP if**: LSP unavailable OR project too small (<10 files) OR no clear module boundaries.
|
||||
</code-map-note>
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
{ONLY deviations from standard - skip generic advice}
|
||||
|
||||
- **{rule}**: {specific detail}
|
||||
{ONLY deviations from standard}
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
|
||||
{Things explicitly forbidden HERE}
|
||||
|
||||
- **{pattern}**: {why} → {alternative}
|
||||
{Explicitly forbidden here}
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
{Project-specific coding styles}
|
||||
|
||||
- **{style}**: {how different}
|
||||
{Project-specific}
|
||||
|
||||
## COMMANDS
|
||||
|
||||
\\\`\\\`\\\`bash
|
||||
{dev-command}
|
||||
{test-command}
|
||||
{build-command}
|
||||
{dev/test/build}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
## NOTES
|
||||
|
||||
{Gotchas, non-obvious info}
|
||||
{Gotchas}
|
||||
\`\`\`
|
||||
|
||||
### Quality Gates
|
||||
**Quality gates**: 50-150 lines, no generic advice, no obvious info.
|
||||
|
||||
- [ ] Size: 50-150 lines
|
||||
- [ ] No generic advice ("write clean code")
|
||||
- [ ] No obvious info ("tests/ has tests")
|
||||
- [ ] Every item is project-specific
|
||||
### Subdirectory AGENTS.md (Parallel)
|
||||
|
||||
**Mark "p3-root" as completed.**
|
||||
Launch document-writer agents for each location:
|
||||
|
||||
\`\`\`
|
||||
for loc in AGENTS_LOCATIONS (except root):
|
||||
background_task(agent="document-writer", prompt=\\\`
|
||||
Generate AGENTS.md for: \${loc.path}
|
||||
- Reason: \${loc.reason}
|
||||
- 30-80 lines max
|
||||
- NEVER repeat parent content
|
||||
- Sections: OVERVIEW (1 line), STRUCTURE (if >5 subdirs), WHERE TO LOOK, CONVENTIONS (if different), ANTI-PATTERNS
|
||||
\\\`)
|
||||
\`\`\`
|
||||
|
||||
**Wait for all. Mark "generate" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Generate Subdirectory AGENTS.md
|
||||
## Phase 4: Review & Deduplicate
|
||||
|
||||
**Mark "p4-subdirs" as in_progress.**
|
||||
**Mark "review" as in_progress.**
|
||||
|
||||
For each location in AGENTS_LOCATIONS (except root), launch **parallel document-writer agents**:
|
||||
For each generated file:
|
||||
- Remove generic advice
|
||||
- Remove parent duplicates
|
||||
- Trim to size limits
|
||||
- Verify telegraphic style
|
||||
|
||||
\`\`\`typescript
|
||||
for (const loc of AGENTS_LOCATIONS.filter(l => l.path !== ".")) {
|
||||
background_task({
|
||||
agent: "document-writer",
|
||||
prompt: \\\`
|
||||
Generate AGENTS.md for: \${loc.path}
|
||||
|
||||
CONTEXT:
|
||||
- Complexity reason: \${loc.reason}
|
||||
- Parent AGENTS.md: ./AGENTS.md (already covers project overview)
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Focus ONLY on this directory's specific context
|
||||
2. NEVER repeat parent AGENTS.md content
|
||||
3. Shorter is better - 30-80 lines max
|
||||
4. Telegraphic style - sacrifice grammar
|
||||
|
||||
REQUIRED SECTIONS:
|
||||
- OVERVIEW (1 line: what this directory does)
|
||||
- STRUCTURE (only if >5 subdirs)
|
||||
- WHERE TO LOOK (directory-specific tasks)
|
||||
- CONVENTIONS (only if DIFFERENT from root)
|
||||
- ANTI-PATTERNS (directory-specific only)
|
||||
|
||||
OUTPUT: Write to \${loc.path}/AGENTS.md
|
||||
\\\`
|
||||
})
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Wait for all agents. Mark "p4-subdirs" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Review & Deduplicate
|
||||
|
||||
**Mark "p5-review" as in_progress.**
|
||||
|
||||
### Validation Checklist
|
||||
|
||||
For EACH generated AGENTS.md:
|
||||
|
||||
| Check | Action if Fail |
|
||||
|-------|----------------|
|
||||
| Contains generic advice | REMOVE the line |
|
||||
| Repeats parent content | REMOVE the line |
|
||||
| Missing required section | ADD it |
|
||||
| Over 150 lines (root) / 80 lines (subdir) | TRIM |
|
||||
| Verbose explanations | REWRITE telegraphic |
|
||||
|
||||
### Cross-Reference Validation
|
||||
|
||||
\`\`\`
|
||||
For each child AGENTS.md:
|
||||
For each line in child:
|
||||
If similar line exists in parent:
|
||||
REMOVE from child (parent already covers)
|
||||
\`\`\`
|
||||
|
||||
**Mark "p5-review" as completed.**
|
||||
**Mark "review" as completed.**
|
||||
|
||||
---
|
||||
|
||||
@@ -364,31 +272,29 @@ For each child AGENTS.md:
|
||||
\`\`\`
|
||||
=== init-deep Complete ===
|
||||
|
||||
Files Generated:
|
||||
Mode: {update | create-new}
|
||||
|
||||
Files:
|
||||
✓ ./AGENTS.md (root, {N} lines)
|
||||
✓ ./src/hooks/AGENTS.md ({N} lines)
|
||||
✓ ./src/tools/AGENTS.md ({N} lines)
|
||||
|
||||
Directories Analyzed: {N}
|
||||
Dirs Analyzed: {N}
|
||||
AGENTS.md Created: {N}
|
||||
Total Lines: {N}
|
||||
AGENTS.md Updated: {N}
|
||||
|
||||
Hierarchy:
|
||||
./AGENTS.md
|
||||
├── src/hooks/AGENTS.md
|
||||
└── src/tools/AGENTS.md
|
||||
└── src/hooks/AGENTS.md
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns for THIS Command
|
||||
## Anti-Patterns
|
||||
|
||||
- **Over-documenting**: Not every directory needs AGENTS.md
|
||||
- **Redundancy**: Child must NOT repeat parent
|
||||
- **Static agent count**: MUST vary agents based on project size/depth
|
||||
- **Sequential execution**: MUST parallel (explore + LSP concurrent)
|
||||
- **Ignoring existing**: ALWAYS read existing first, even with --create-new
|
||||
- **Over-documenting**: Not every dir needs AGENTS.md
|
||||
- **Redundancy**: Child never repeats parent
|
||||
- **Generic content**: Remove anything that applies to ALL projects
|
||||
- **Sequential execution**: MUST use parallel agents
|
||||
- **Deep nesting**: Rarely need AGENTS.md at depth 4+
|
||||
- **Verbose style**: "This directory contains..." → just list it
|
||||
- **Ignoring LSP**: If LSP available, USE IT - semantic analysis > text grep
|
||||
- **LSP without fallback**: Always have explore agent backup if LSP unavailable
|
||||
- **Over-referencing**: Don't trace refs for EVERY symbol - focus on exports only`
|
||||
- **Verbose style**: Telegraphic or die`
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import type { BuiltinSkill } from "./types"
|
||||
|
||||
export function createBuiltinSkills(): BuiltinSkill[] {
|
||||
return []
|
||||
const playwrightSkill: BuiltinSkill = {
|
||||
name: "playwright",
|
||||
description: "Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.",
|
||||
template: `# Playwright Browser Automation
|
||||
|
||||
This skill provides browser automation capabilities via the Playwright MCP server.`,
|
||||
mcpConfig: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp@latest"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function createBuiltinSkills(): BuiltinSkill[] {
|
||||
return [playwrightSkill]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
export interface BuiltinSkill {
|
||||
name: string
|
||||
description: string
|
||||
@@ -10,4 +12,5 @@ export interface BuiltinSkill {
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
mcpConfig?: SkillMcpConfig
|
||||
}
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||
|
||||
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
|
||||
function loadCommandsFromDir(
|
||||
commandsDir: string,
|
||||
scope: CommandScope,
|
||||
visited: Set<string> = new Set(),
|
||||
prefix: string = ""
|
||||
): LoadedCommand[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = realpathSync(commandsDir)
|
||||
} catch (error) {
|
||||
log(`Failed to resolve command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
if (visited.has(realPath)) {
|
||||
return []
|
||||
}
|
||||
visited.add(realPath)
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
log(`Failed to read command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
const commands: LoadedCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
const subDirPath = join(commandsDir, entry.name)
|
||||
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
|
||||
commands.push(...loadCommandsFromDir(subDirPath, scope, visited, subPrefix))
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
const baseCommandName = basename(entry.name, ".md")
|
||||
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
@@ -51,7 +86,8 @@ $ARGUMENTS
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log(`Failed to parse command: ${commandPath}`, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -62,7 +98,8 @@ $ARGUMENTS
|
||||
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const cmd of commands) {
|
||||
result[cmd.name] = cmd.definition
|
||||
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = cmd.definition
|
||||
result[cmd.name] = openCodeCompatible as CommandDefinition
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ $ARGUMENTS
|
||||
|
||||
const formattedDescription = `(plugin: ${plugin.name}) ${data.description || ""}`
|
||||
|
||||
commands[namespacedName] = {
|
||||
const definition = {
|
||||
name: namespacedName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
@@ -255,6 +255,8 @@ $ARGUMENTS
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = definition
|
||||
commands[namespacedName] = openCodeCompatible as CommandDefinition
|
||||
|
||||
log(`Loaded plugin command: ${namespacedName}`, { path: commandPath })
|
||||
} catch (error) {
|
||||
@@ -306,12 +308,14 @@ ${body.trim()}
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
skills[namespacedName] = {
|
||||
const definition = {
|
||||
name: namespacedName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model),
|
||||
}
|
||||
const { name: _name, ...openCodeCompatible } = definition
|
||||
skills[namespacedName] = openCodeCompatible as CommandDefinition
|
||||
|
||||
log(`Loaded plugin skill: ${namespacedName}`, { path: resolvedPath })
|
||||
} catch (error) {
|
||||
|
||||
273
src/features/opencode-skill-loader/loader.test.ts
Normal file
273
src/features/opencode-skill-loader/loader.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "skill-loader-test-" + Date.now())
|
||||
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill")
|
||||
|
||||
function createTestSkill(name: string, content: string, mcpJson?: object): string {
|
||||
const skillDir = join(SKILLS_DIR, name)
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
const skillPath = join(skillDir, "SKILL.md")
|
||||
writeFileSync(skillPath, content)
|
||||
if (mcpJson) {
|
||||
writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2))
|
||||
}
|
||||
return skillDir
|
||||
}
|
||||
|
||||
describe("skill loader MCP parsing", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("parseSkillMcpConfig", () => {
|
||||
it("parses skill with nested MCP config", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: test-skill
|
||||
description: A test skill with MCP
|
||||
mcp:
|
||||
sqlite:
|
||||
command: uvx
|
||||
args:
|
||||
- mcp-server-sqlite
|
||||
- --db-path
|
||||
- ./data.db
|
||||
memory:
|
||||
command: npx
|
||||
args: [-y, "@anthropic-ai/mcp-server-memory"]
|
||||
---
|
||||
This is the skill body.
|
||||
`
|
||||
createTestSkill("test-mcp-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "test-skill")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
|
||||
expect(skill?.mcpConfig?.sqlite?.args).toEqual([
|
||||
"mcp-server-sqlite",
|
||||
"--db-path",
|
||||
"./data.db"
|
||||
])
|
||||
expect(skill?.mcpConfig?.memory).toBeDefined()
|
||||
expect(skill?.mcpConfig?.memory?.command).toBe("npx")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("returns undefined mcpConfig for skill without MCP", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: simple-skill
|
||||
description: A simple skill without MCP
|
||||
---
|
||||
This is a simple skill.
|
||||
`
|
||||
createTestSkill("simple-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "simple-skill")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves env var placeholders without expansion", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: env-skill
|
||||
mcp:
|
||||
api-server:
|
||||
command: node
|
||||
args: [server.js]
|
||||
env:
|
||||
API_KEY: "\${API_KEY}"
|
||||
DB_PATH: "\${HOME}/data.db"
|
||||
---
|
||||
Skill with env vars.
|
||||
`
|
||||
createTestSkill("env-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "env-skill")
|
||||
|
||||
// #then
|
||||
expect(skill?.mcpConfig?.["api-server"]?.env?.API_KEY).toBe("${API_KEY}")
|
||||
expect(skill?.mcpConfig?.["api-server"]?.env?.DB_PATH).toBe("${HOME}/data.db")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("handles malformed YAML gracefully", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: bad-yaml
|
||||
mcp: [this is not valid yaml for mcp
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("bad-yaml-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "bad-yaml")
|
||||
|
||||
// #then - should still load skill but without MCP config
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("mcp.json file loading (AmpCode compat)", () => {
|
||||
it("loads MCP config from mcp.json with mcpServers format", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: ampcode-skill
|
||||
description: Skill with mcp.json
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("ampcode-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "ampcode-skill")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.playwright).toBeDefined()
|
||||
expect(skill?.mcpConfig?.playwright?.command).toBe("npx")
|
||||
expect(skill?.mcpConfig?.playwright?.args).toEqual(["@playwright/mcp@latest"])
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("mcp.json takes priority over YAML frontmatter", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: priority-skill
|
||||
mcp:
|
||||
from-yaml:
|
||||
command: yaml-cmd
|
||||
args: [yaml-arg]
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
"from-json": {
|
||||
command: "json-cmd",
|
||||
args: ["json-arg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("priority-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "priority-skill")
|
||||
|
||||
// #then - mcp.json should take priority
|
||||
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
|
||||
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("supports direct format without mcpServers wrapper", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: direct-format
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const mcpJson = {
|
||||
sqlite: {
|
||||
command: "uvx",
|
||||
args: ["mcp-server-sqlite"]
|
||||
}
|
||||
}
|
||||
createTestSkill("direct-format", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "direct-format")
|
||||
|
||||
// #then
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,58 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { homedir } from "os"
|
||||
import yaml from "js-yaml"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
|
||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||
if (!frontmatterMatch) return undefined
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown>
|
||||
if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) {
|
||||
return parsed.mcp as SkillMcpConfig
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined {
|
||||
const mcpJsonPath = join(skillDir, "mcp.json")
|
||||
if (!existsSync(mcpJsonPath)) return undefined
|
||||
|
||||
try {
|
||||
const content = readFileSync(mcpJsonPath, "utf-8")
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||
|
||||
// AmpCode format: { "mcpServers": { "name": { ... } } }
|
||||
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
|
||||
return parsed.mcpServers as SkillMcpConfig
|
||||
}
|
||||
|
||||
// Also support direct format: { "name": { command: ..., args: ... } }
|
||||
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
|
||||
const hasCommandField = Object.values(parsed).some(
|
||||
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
|
||||
)
|
||||
if (hasCommandField) {
|
||||
return parsed as SkillMcpConfig
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a skill from a markdown file path.
|
||||
*
|
||||
* @param skillPath - Path to the skill file (SKILL.md or {name}.md)
|
||||
* @param resolvedPath - Directory for file reference resolution (@path references)
|
||||
* @param defaultName - Fallback name if not specified in frontmatter
|
||||
* @param scope - Source scope for priority ordering
|
||||
*/
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
@@ -30,6 +67,9 @@ function loadSkillFromPath(
|
||||
try {
|
||||
const content = readFileSync(skillPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
const originalDescription = data.description || ""
|
||||
@@ -67,6 +107,7 @@ $ARGUMENTS
|
||||
compatibility: data.compatibility,
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -126,7 +167,8 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[]
|
||||
function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const skill of skills) {
|
||||
result[skill.name] = skill.definition
|
||||
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = skill.definition
|
||||
result[skill.name] = openCodeCompatible as CommandDefinition
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const SCOPE_PRIORITY: Record<SkillScope, number> = {
|
||||
function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill {
|
||||
const definition: CommandDefinition = {
|
||||
name: builtin.name,
|
||||
description: `(builtin - Skill) ${builtin.description}`,
|
||||
description: `(opencode - Skill) ${builtin.description}`,
|
||||
template: builtin.template,
|
||||
model: builtin.model,
|
||||
agent: builtin.agent,
|
||||
@@ -37,6 +37,7 @@ function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill {
|
||||
compatibility: builtin.compatibility,
|
||||
metadata: builtin.metadata as Record<string, string> | undefined,
|
||||
allowedTools: builtin.allowedTools,
|
||||
mcpConfig: builtin.mcpConfig,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
@@ -13,6 +14,7 @@ export interface SkillMetadata {
|
||||
compatibility?: string
|
||||
metadata?: Record<string, string>
|
||||
"allowed-tools"?: string
|
||||
mcp?: SkillMcpConfig
|
||||
}
|
||||
|
||||
export interface LoadedSkill {
|
||||
@@ -25,4 +27,5 @@ export interface LoadedSkill {
|
||||
compatibility?: string
|
||||
metadata?: Record<string, string>
|
||||
allowedTools?: string[]
|
||||
mcpConfig?: SkillMcpConfig
|
||||
}
|
||||
|
||||
2
src/features/skill-mcp-manager/index.ts
Normal file
2
src/features/skill-mcp-manager/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { SkillMcpManager } from "./manager"
|
||||
109
src/features/skill-mcp-manager/manager.test.ts
Normal file
109
src/features/skill-mcp-manager/manager.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"
|
||||
import { SkillMcpManager } from "./manager"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
|
||||
describe("SkillMcpManager", () => {
|
||||
let manager: SkillMcpManager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SkillMcpManager()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await manager.disconnectAll()
|
||||
})
|
||||
|
||||
describe("getOrCreateClient", () => {
|
||||
it("throws error when command is missing", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/missing required 'command' field/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes helpful error message with example when command is missing", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "my-mcp",
|
||||
skillName: "data-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/my-mcp[\s\S]*data-skill[\s\S]*Example/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("disconnectSession", () => {
|
||||
it("removes all clients for a specific session", async () => {
|
||||
// #given
|
||||
const session1Info: SkillMcpClientInfo = {
|
||||
serverName: "server1",
|
||||
skillName: "skill1",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const session2Info: SkillMcpClientInfo = {
|
||||
serverName: "server1",
|
||||
skillName: "skill1",
|
||||
sessionID: "session-2",
|
||||
}
|
||||
|
||||
// #when
|
||||
await manager.disconnectSession("session-1")
|
||||
|
||||
// #then
|
||||
expect(manager.isConnected(session1Info)).toBe(false)
|
||||
expect(manager.isConnected(session2Info)).toBe(false)
|
||||
})
|
||||
|
||||
it("does not throw when session has no clients", async () => {
|
||||
// #given / #when / #then
|
||||
await expect(manager.disconnectSession("nonexistent")).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("disconnectAll", () => {
|
||||
it("clears all clients", async () => {
|
||||
// #given - no actual clients connected (would require real MCP server)
|
||||
|
||||
// #when
|
||||
await manager.disconnectAll()
|
||||
|
||||
// #then
|
||||
expect(manager.getConnectedServers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isConnected", () => {
|
||||
it("returns false for unconnected server", () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "unknown",
|
||||
skillName: "test",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
expect(manager.isConnected(info)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConnectedServers", () => {
|
||||
it("returns empty array when no servers connected", () => {
|
||||
// #given / #when / #then
|
||||
expect(manager.getConnectedServers()).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
210
src/features/skill-mcp-manager/manager.ts
Normal file
210
src/features/skill-mcp-manager/manager.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
client: Client
|
||||
transport: StdioClientTransport
|
||||
skillName: string
|
||||
}
|
||||
|
||||
export class SkillMcpManager {
|
||||
private clients: Map<string, ManagedClient> = new Map()
|
||||
|
||||
private getClientKey(info: SkillMcpClientInfo): string {
|
||||
return `${info.sessionID}:${info.skillName}:${info.serverName}`
|
||||
}
|
||||
|
||||
async getOrCreateClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
|
||||
if (existing) {
|
||||
return existing.client
|
||||
}
|
||||
|
||||
const expandedConfig = expandEnvVarsInObject(config)
|
||||
const client = await this.createClient(info, expandedConfig)
|
||||
return client
|
||||
}
|
||||
|
||||
private async createClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const key = this.getClientKey(info)
|
||||
|
||||
if (!config.command) {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" is missing required 'command' field.\n\n` +
|
||||
`The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` +
|
||||
`Example:\n` +
|
||||
` mcp:\n` +
|
||||
` ${info.serverName}:\n` +
|
||||
` command: npx\n` +
|
||||
` args: [-y, @some/mcp-server]`
|
||||
)
|
||||
}
|
||||
|
||||
const command = config.command
|
||||
const args = config.args || []
|
||||
|
||||
const mergedEnv: Record<string, string> = {}
|
||||
if (config.env) {
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) mergedEnv[key] = value
|
||||
}
|
||||
Object.assign(mergedEnv, config.env)
|
||||
}
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
env: config.env ? mergedEnv : undefined,
|
||||
})
|
||||
|
||||
const client = new Client(
|
||||
{ name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
)
|
||||
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||
`Command: ${command} ${args.join(" ")}\n` +
|
||||
`Reason: ${errorMessage}\n\n` +
|
||||
`Hints:\n` +
|
||||
` - Ensure the command is installed and available in PATH\n` +
|
||||
` - Check if the MCP server package exists\n` +
|
||||
` - Verify the args are correct for this server`
|
||||
)
|
||||
}
|
||||
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName })
|
||||
return client
|
||||
}
|
||||
|
||||
async disconnectSession(sessionID: string): Promise<void> {
|
||||
const keysToRemove: string[] = []
|
||||
|
||||
for (const [key, managed] of this.clients.entries()) {
|
||||
if (key.startsWith(`${sessionID}:`)) {
|
||||
keysToRemove.push(key)
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch {
|
||||
// Ignore close errors - process may already be terminated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
this.clients.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectAll(): Promise<void> {
|
||||
for (const [, managed] of this.clients.entries()) {
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
}
|
||||
this.clients.clear()
|
||||
}
|
||||
|
||||
async listTools(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext
|
||||
): Promise<Tool[]> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.listTools()
|
||||
return result.tools
|
||||
}
|
||||
|
||||
async listResources(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext
|
||||
): Promise<Resource[]> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.listResources()
|
||||
return result.resources
|
||||
}
|
||||
|
||||
async listPrompts(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext
|
||||
): Promise<Prompt[]> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.listPrompts()
|
||||
return result.prompts
|
||||
}
|
||||
|
||||
async callTool(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext,
|
||||
name: string,
|
||||
args: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.callTool({ name, arguments: args })
|
||||
return result.content
|
||||
}
|
||||
|
||||
async readResource(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext,
|
||||
uri: string
|
||||
): Promise<unknown> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.readResource({ uri })
|
||||
return result.contents
|
||||
}
|
||||
|
||||
async getPrompt(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext,
|
||||
name: string,
|
||||
args: Record<string, string>
|
||||
): Promise<unknown> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.getPrompt({ name, arguments: args })
|
||||
return result.messages
|
||||
}
|
||||
|
||||
private async getOrCreateClientWithRetry(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
try {
|
||||
return await this.getOrCreateClient(info, config)
|
||||
} catch (error) {
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
try {
|
||||
await existing.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
this.clients.delete(key)
|
||||
return await this.getOrCreateClient(info, config)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getConnectedServers(): string[] {
|
||||
return Array.from(this.clients.keys())
|
||||
}
|
||||
|
||||
isConnected(info: SkillMcpClientInfo): boolean {
|
||||
return this.clients.has(this.getClientKey(info))
|
||||
}
|
||||
}
|
||||
14
src/features/skill-mcp-manager/types.ts
Normal file
14
src/features/skill-mcp-manager/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
|
||||
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
|
||||
|
||||
export interface SkillMcpClientInfo {
|
||||
serverName: string
|
||||
skillName: string
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
export interface SkillMcpServerContext {
|
||||
config: ClaudeCodeMcpServer
|
||||
skillName: string
|
||||
}
|
||||
@@ -10,6 +10,7 @@ Lifecycle hooks that intercept/modify agent behavior. Inject context, enforce ru
|
||||
hooks/
|
||||
├── agent-usage-reminder/ # Remind to use specialized agents
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-compact Claude at token limit
|
||||
├── auto-slash-command/ # Auto-detect and execute /command patterns
|
||||
├── auto-update-checker/ # Version update notifications
|
||||
├── background-notification/ # OS notify on background task complete
|
||||
├── claude-code-hooks/ # Claude Code settings.json integration
|
||||
@@ -24,6 +25,7 @@ hooks/
|
||||
├── keyword-detector/ # Detect ultrawork/search keywords
|
||||
├── non-interactive-env/ # CI/headless environment handling
|
||||
├── preemptive-compaction/ # Pre-emptive session compaction
|
||||
├── ralph-loop/ # Self-referential dev loop until completion
|
||||
├── rules-injector/ # Conditional rules from .claude/rules/
|
||||
├── session-recovery/ # Recover from session errors
|
||||
├── think-mode/ # Auto-detect thinking triggers
|
||||
|
||||
@@ -15,7 +15,6 @@ describe("executeCompact lock management", () => {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map(),
|
||||
retryStateBySession: new Map(),
|
||||
fallbackStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
dcpStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
@@ -68,38 +67,6 @@ describe("executeCompact lock management", () => {
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when revert throws exception", async () => {
|
||||
// #given: Force revert path by exhausting retry attempts and making revert fail
|
||||
mockClient.session.revert = mock(() =>
|
||||
Promise.reject(new Error("Revert failed")),
|
||||
)
|
||||
mockClient.session.messages = mock(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{ info: { id: "msg1", role: "user" } },
|
||||
{ info: { id: "msg2", role: "assistant" } },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// Exhaust retry attempts
|
||||
autoCompactState.retryStateBySession.set(sessionID, {
|
||||
attempt: 5,
|
||||
lastAttemptTime: Date.now(),
|
||||
})
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock cleared even though revert failed
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("shows toast when lock already held", async () => {
|
||||
// #given: Lock already held
|
||||
autoCompactState.compactionInProgress.add(sessionID)
|
||||
@@ -195,9 +162,6 @@ describe("executeCompact lock management", () => {
|
||||
attempt: 5,
|
||||
lastAttemptTime: Date.now(),
|
||||
})
|
||||
autoCompactState.fallbackStateBySession.set(sessionID, {
|
||||
revertAttempt: 5,
|
||||
})
|
||||
autoCompactState.truncateStateBySession.set(sessionID, {
|
||||
truncateAttempt: 5,
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type {
|
||||
AutoCompactState,
|
||||
DcpState,
|
||||
FallbackState,
|
||||
RetryState,
|
||||
TruncateState,
|
||||
} from "./types";
|
||||
import type { ExperimentalConfig } from "../../config";
|
||||
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
|
||||
import { RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
|
||||
import { executeDynamicContextPruning } from "./pruning-executor";
|
||||
import {
|
||||
findLargestToolResult,
|
||||
@@ -69,17 +68,7 @@ function getOrCreateRetryState(
|
||||
return state;
|
||||
}
|
||||
|
||||
function getOrCreateFallbackState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): FallbackState {
|
||||
let state = autoCompactState.fallbackStateBySession.get(sessionID);
|
||||
if (!state) {
|
||||
state = { revertAttempt: 0 };
|
||||
autoCompactState.fallbackStateBySession.set(sessionID, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
function getOrCreateTruncateState(
|
||||
autoCompactState: AutoCompactState,
|
||||
@@ -135,58 +124,6 @@ function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
|
||||
return fixedCount;
|
||||
}
|
||||
|
||||
async function getLastMessagePair(
|
||||
sessionID: string,
|
||||
client: Client,
|
||||
directory: string,
|
||||
): Promise<{ userMessageID: string; assistantMessageID?: string } | null> {
|
||||
try {
|
||||
const resp = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data;
|
||||
if (
|
||||
!Array.isArray(data) ||
|
||||
data.length < FALLBACK_CONFIG.minMessagesRequired
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reversed = [...data].reverse();
|
||||
|
||||
const lastAssistant = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>;
|
||||
const info = msg.info as Record<string, unknown> | undefined;
|
||||
return info?.role === "assistant";
|
||||
});
|
||||
|
||||
const lastUser = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>;
|
||||
const info = msg.info as Record<string, unknown> | undefined;
|
||||
return info?.role === "user";
|
||||
});
|
||||
|
||||
if (!lastUser) return null;
|
||||
const userInfo = (lastUser as { info?: Record<string, unknown> }).info;
|
||||
const userMessageID = userInfo?.id as string | undefined;
|
||||
if (!userMessageID) return null;
|
||||
|
||||
let assistantMessageID: string | undefined;
|
||||
if (lastAssistant) {
|
||||
const assistantInfo = (
|
||||
lastAssistant as { info?: Record<string, unknown> }
|
||||
).info;
|
||||
assistantMessageID = assistantInfo?.id as string | undefined;
|
||||
}
|
||||
|
||||
return { userMessageID, assistantMessageID };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
@@ -221,6 +158,8 @@ export async function getLastAssistant(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function clearSessionState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
@@ -228,7 +167,6 @@ function clearSessionState(
|
||||
autoCompactState.pendingCompact.delete(sessionID);
|
||||
autoCompactState.errorDataBySession.delete(sessionID);
|
||||
autoCompactState.retryStateBySession.delete(sessionID);
|
||||
autoCompactState.fallbackStateBySession.delete(sessionID);
|
||||
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||
autoCompactState.dcpStateBySession.delete(sessionID);
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
|
||||
@@ -359,17 +297,16 @@ export async function executeCompact(
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID);
|
||||
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID);
|
||||
|
||||
// DCP FIRST - run before any other recovery attempts when token limit exceeded (controlled by dcp-for-compaction hook)
|
||||
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
|
||||
if (
|
||||
dcpForCompaction !== false &&
|
||||
!dcpState.attempted &&
|
||||
const isOverLimit =
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens
|
||||
) {
|
||||
errorData.currentTokens > errorData.maxTokens;
|
||||
|
||||
// PHASE 1: DCP (Dynamic Context Pruning) - prune duplicate tool calls first
|
||||
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
|
||||
if (dcpForCompaction !== false && !dcpState.attempted && isOverLimit) {
|
||||
dcpState.attempted = true;
|
||||
log("[auto-compact] DCP triggered FIRST on token limit error", {
|
||||
log("[auto-compact] PHASE 1: DCP triggered on token limit error", {
|
||||
sessionID,
|
||||
currentTokens: errorData.currentTokens,
|
||||
maxTokens: errorData.maxTokens,
|
||||
@@ -378,19 +315,25 @@ export async function executeCompact(
|
||||
const dcpConfig = experimental?.dynamic_context_pruning ?? {
|
||||
enabled: true,
|
||||
notification: "detailed" as const,
|
||||
protected_tools: ["task", "todowrite", "todoread", "lsp_rename", "lsp_code_action_resolve"],
|
||||
protected_tools: [
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const pruningResult = await executeDynamicContextPruning(
|
||||
sessionID,
|
||||
dcpConfig,
|
||||
client
|
||||
client,
|
||||
);
|
||||
|
||||
if (pruningResult.itemsPruned > 0) {
|
||||
dcpState.itemsPruned = pruningResult.itemsPruned;
|
||||
log("[auto-compact] DCP successful, proceeding to compaction", {
|
||||
log("[auto-compact] DCP successful, proceeding to truncation", {
|
||||
itemsPruned: pruningResult.itemsPruned,
|
||||
tokensSaved: pruningResult.totalTokensSaved,
|
||||
});
|
||||
@@ -399,56 +342,13 @@ export async function executeCompact(
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Dynamic Context Pruning",
|
||||
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Running compaction...`,
|
||||
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Proceeding to truncation...`,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// After DCP, immediately try summarize
|
||||
const providerID = msg.providerID as string | undefined;
|
||||
const modelID = msg.modelID as string | undefined;
|
||||
|
||||
if (providerID && modelID) {
|
||||
try {
|
||||
sanitizeEmptyMessagesBeforeSummarize(sessionID);
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Summarizing session after DCP...",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
} catch (summarizeError) {
|
||||
log("[auto-compact] summarize after DCP failed, continuing recovery", {
|
||||
error: String(summarizeError),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Continue to PHASE 2 (truncation) instead of summarizing immediately
|
||||
} else {
|
||||
log("[auto-compact] DCP did not prune any items", { sessionID });
|
||||
}
|
||||
@@ -457,14 +357,12 @@ export async function executeCompact(
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 2: Aggressive Truncation - always try when over limit (not experimental-only)
|
||||
if (
|
||||
experimental?.aggressive_truncation &&
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens &&
|
||||
isOverLimit &&
|
||||
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
|
||||
) {
|
||||
log("[auto-compact] aggressive truncation triggered (experimental)", {
|
||||
log("[auto-compact] PHASE 2: aggressive truncation triggered", {
|
||||
currentTokens: errorData.currentTokens,
|
||||
maxTokens: errorData.maxTokens,
|
||||
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
|
||||
@@ -486,16 +384,16 @@ export async function executeCompact(
|
||||
.join(", ");
|
||||
const statusMsg = aggressiveResult.sufficient
|
||||
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
|
||||
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`;
|
||||
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...`;
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: aggressiveResult.sufficient
|
||||
? "Aggressive Truncation"
|
||||
? "Truncation Complete"
|
||||
: "Partial Truncation",
|
||||
message: `${statusMsg}: ${toolNames}`,
|
||||
variant: "warning",
|
||||
variant: aggressiveResult.sufficient ? "success" : "warning",
|
||||
duration: 4000,
|
||||
},
|
||||
})
|
||||
@@ -503,99 +401,21 @@ export async function executeCompact(
|
||||
|
||||
log("[auto-compact] aggressive truncation completed", aggressiveResult);
|
||||
|
||||
if (aggressiveResult.sufficient) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Truncation Skipped",
|
||||
message: "No tool outputs found to truncate.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
let skipSummarize = false;
|
||||
|
||||
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
|
||||
const largest = findLargestToolResult(sessionID);
|
||||
|
||||
if (
|
||||
largest &&
|
||||
largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate
|
||||
) {
|
||||
const result = truncateToolResult(largest.partPath);
|
||||
|
||||
if (result.success) {
|
||||
truncateState.truncateAttempt++;
|
||||
truncateState.lastTruncatedPartId = largest.partId;
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Truncating Large Output",
|
||||
message: `Truncated ${result.toolName} (${formatBytes(result.originalSize ?? 0)}). Retrying...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens
|
||||
) {
|
||||
skipSummarize = true;
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Summarize Skipped",
|
||||
message: `Over token limit (${errorData.currentTokens}/${errorData.maxTokens}) with nothing to truncate. Going to revert...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (!errorData?.currentTokens) {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Truncation Skipped",
|
||||
message: "No large tool outputs found.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 3: Summarize - fallback when no tool outputs to truncate
|
||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
|
||||
|
||||
if (errorData?.errorType?.includes("non-empty content")) {
|
||||
@@ -642,11 +462,10 @@ export async function executeCompact(
|
||||
|
||||
if (Date.now() - retryState.lastAttemptTime > 300000) {
|
||||
retryState.attempt = 0;
|
||||
autoCompactState.fallbackStateBySession.delete(sessionID);
|
||||
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||
}
|
||||
|
||||
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
||||
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
||||
retryState.attempt++;
|
||||
retryState.lastAttemptTime = Date.now();
|
||||
|
||||
@@ -708,75 +527,7 @@ export async function executeCompact(
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Summarize Skipped",
|
||||
message: "Missing providerID or modelID. Skipping to revert...",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID);
|
||||
|
||||
if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) {
|
||||
const pair = await getLastMessagePair(
|
||||
sessionID,
|
||||
client as Client,
|
||||
directory,
|
||||
);
|
||||
|
||||
if (pair) {
|
||||
try {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Emergency Recovery",
|
||||
message: "Removing last message pair...",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
if (pair.assistantMessageID) {
|
||||
await (client as Client).session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: pair.assistantMessageID },
|
||||
query: { directory },
|
||||
});
|
||||
}
|
||||
|
||||
await (client as Client).session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: pair.userMessageID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
fallbackState.revertAttempt++;
|
||||
fallbackState.lastRevertedMessageID = pair.userMessageID;
|
||||
|
||||
// Clear all state after successful revert - don't recurse
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
|
||||
// Send "Continue" prompt to resume session
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
} catch {}
|
||||
} else {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Revert Skipped",
|
||||
message: "Could not find last message pair to revert.",
|
||||
message: "Missing providerID or modelID.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@ function createRecoveryState(): AutoCompactState {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
retryStateBySession: new Map(),
|
||||
fallbackStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
dcpStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
@@ -37,7 +36,6 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.fallbackStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.dcpStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
|
||||
@@ -154,6 +152,6 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AutoCompactState, DcpState, FallbackState, ParsedTokenLimitError, TruncateState } from "./types"
|
||||
export type { AutoCompactState, DcpState, ParsedTokenLimitError, TruncateState } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
|
||||
@@ -13,11 +13,6 @@ export interface RetryState {
|
||||
lastAttemptTime: number
|
||||
}
|
||||
|
||||
export interface FallbackState {
|
||||
revertAttempt: number
|
||||
lastRevertedMessageID?: string
|
||||
}
|
||||
|
||||
export interface TruncateState {
|
||||
truncateAttempt: number
|
||||
lastTruncatedPartId?: string
|
||||
@@ -32,7 +27,6 @@ export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
retryStateBySession: Map<string, RetryState>
|
||||
fallbackStateBySession: Map<string, FallbackState>
|
||||
truncateStateBySession: Map<string, TruncateState>
|
||||
dcpStateBySession: Map<string, DcpState>
|
||||
emptyContentAttemptBySession: Map<string, number>
|
||||
@@ -46,14 +40,9 @@ export const RETRY_CONFIG = {
|
||||
maxDelayMs: 30000,
|
||||
} as const
|
||||
|
||||
export const FALLBACK_CONFIG = {
|
||||
maxRevertAttempts: 3,
|
||||
minMessagesRequired: 2,
|
||||
} as const
|
||||
|
||||
export const TRUNCATE_CONFIG = {
|
||||
maxTruncateAttempts: 20,
|
||||
minOutputSizeToTruncate: 500,
|
||||
targetTokenRatio: 0.5,
|
||||
charsPerToken: 4,
|
||||
charsPerToken: 2,
|
||||
} as const
|
||||
|
||||
11
src/hooks/auto-slash-command/constants.ts
Normal file
11
src/hooks/auto-slash-command/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const HOOK_NAME = "auto-slash-command" as const
|
||||
|
||||
export const AUTO_SLASH_COMMAND_TAG_OPEN = "<auto-slash-command>"
|
||||
export const AUTO_SLASH_COMMAND_TAG_CLOSE = "</auto-slash-command>"
|
||||
|
||||
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/
|
||||
|
||||
export const EXCLUDED_COMMANDS = new Set([
|
||||
"ralph-loop",
|
||||
"cancel-ralph",
|
||||
])
|
||||
296
src/hooks/auto-slash-command/detector.test.ts
Normal file
296
src/hooks/auto-slash-command/detector.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
parseSlashCommand,
|
||||
detectSlashCommand,
|
||||
isExcludedCommand,
|
||||
removeCodeBlocks,
|
||||
extractPromptText,
|
||||
} from "./detector"
|
||||
|
||||
describe("auto-slash-command detector", () => {
|
||||
describe("removeCodeBlocks", () => {
|
||||
it("should remove markdown code blocks", () => {
|
||||
// #given text with code blocks
|
||||
const text = "Hello ```code here``` world"
|
||||
|
||||
// #when removing code blocks
|
||||
const result = removeCodeBlocks(text)
|
||||
|
||||
// #then code blocks should be removed
|
||||
expect(result).toBe("Hello world")
|
||||
})
|
||||
|
||||
it("should remove multiline code blocks", () => {
|
||||
// #given text with multiline code blocks
|
||||
const text = `Before
|
||||
\`\`\`javascript
|
||||
/command-inside-code
|
||||
\`\`\`
|
||||
After`
|
||||
|
||||
// #when removing code blocks
|
||||
const result = removeCodeBlocks(text)
|
||||
|
||||
// #then code blocks should be removed
|
||||
expect(result).toContain("Before")
|
||||
expect(result).toContain("After")
|
||||
expect(result).not.toContain("/command-inside-code")
|
||||
})
|
||||
|
||||
it("should handle text without code blocks", () => {
|
||||
// #given text without code blocks
|
||||
const text = "Just regular text"
|
||||
|
||||
// #when removing code blocks
|
||||
const result = removeCodeBlocks(text)
|
||||
|
||||
// #then text should remain unchanged
|
||||
expect(result).toBe("Just regular text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseSlashCommand", () => {
|
||||
it("should parse simple command without args", () => {
|
||||
// #given a simple slash command
|
||||
const text = "/commit"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract command correctly
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
expect(result?.args).toBe("")
|
||||
})
|
||||
|
||||
it("should parse command with arguments", () => {
|
||||
// #given a slash command with arguments
|
||||
const text = "/plan create a new feature for auth"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract command and args
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("plan")
|
||||
expect(result?.args).toBe("create a new feature for auth")
|
||||
})
|
||||
|
||||
it("should parse command with quoted arguments", () => {
|
||||
// #given a slash command with quoted arguments
|
||||
const text = '/execute "build the API"'
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract command and args
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("execute")
|
||||
expect(result?.args).toBe('"build the API"')
|
||||
})
|
||||
|
||||
it("should parse command with hyphen in name", () => {
|
||||
// #given a slash command with hyphen
|
||||
const text = "/frontend-template-creator project"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract full command name
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("frontend-template-creator")
|
||||
expect(result?.args).toBe("project")
|
||||
})
|
||||
|
||||
it("should return null for non-slash text", () => {
|
||||
// #given text without slash
|
||||
const text = "regular text"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for slash not at start", () => {
|
||||
// #given text with slash in middle
|
||||
const text = "some text /command"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null (slash not at start)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for just a slash", () => {
|
||||
// #given just a slash
|
||||
const text = "/"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for slash followed by number", () => {
|
||||
// #given slash followed by number
|
||||
const text = "/123"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null (command must start with letter)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle whitespace before slash", () => {
|
||||
// #given command with leading whitespace
|
||||
const text = " /commit"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should parse after trimming
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isExcludedCommand", () => {
|
||||
it("should exclude ralph-loop", () => {
|
||||
// #given ralph-loop command
|
||||
// #when checking exclusion
|
||||
// #then should be excluded
|
||||
expect(isExcludedCommand("ralph-loop")).toBe(true)
|
||||
})
|
||||
|
||||
it("should exclude cancel-ralph", () => {
|
||||
// #given cancel-ralph command
|
||||
// #when checking exclusion
|
||||
// #then should be excluded
|
||||
expect(isExcludedCommand("cancel-ralph")).toBe(true)
|
||||
})
|
||||
|
||||
it("should be case-insensitive for exclusion", () => {
|
||||
// #given uppercase variants
|
||||
// #when checking exclusion
|
||||
// #then should still be excluded
|
||||
expect(isExcludedCommand("RALPH-LOOP")).toBe(true)
|
||||
expect(isExcludedCommand("Cancel-Ralph")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not exclude regular commands", () => {
|
||||
// #given regular commands
|
||||
// #when checking exclusion
|
||||
// #then should not be excluded
|
||||
expect(isExcludedCommand("commit")).toBe(false)
|
||||
expect(isExcludedCommand("plan")).toBe(false)
|
||||
expect(isExcludedCommand("execute")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectSlashCommand", () => {
|
||||
it("should detect slash command in plain text", () => {
|
||||
// #given plain text with slash command
|
||||
const text = "/commit fix typo"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should detect
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
expect(result?.args).toBe("fix typo")
|
||||
})
|
||||
|
||||
it("should NOT detect slash command inside code block", () => {
|
||||
// #given slash command inside code block
|
||||
const text = "```bash\n/command\n```"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should not detect (only code block content)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should detect command when text has code blocks elsewhere", () => {
|
||||
// #given slash command before code block
|
||||
const text = "/commit fix\n```code```"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should detect the command
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
})
|
||||
|
||||
it("should NOT detect excluded commands", () => {
|
||||
// #given excluded command
|
||||
const text = "/ralph-loop do something"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should not detect
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for non-command text", () => {
|
||||
// #given regular text
|
||||
const text = "Just some regular text"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractPromptText", () => {
|
||||
it("should extract text from parts", () => {
|
||||
// #given message parts
|
||||
const parts = [
|
||||
{ type: "text", text: "Hello " },
|
||||
{ type: "tool_use", id: "123" },
|
||||
{ type: "text", text: "world" },
|
||||
]
|
||||
|
||||
// #when extracting
|
||||
const result = extractPromptText(parts)
|
||||
|
||||
// #then should join text parts
|
||||
expect(result).toBe("Hello world")
|
||||
})
|
||||
|
||||
it("should handle empty parts", () => {
|
||||
// #given empty parts
|
||||
const parts: Array<{ type: string; text?: string }> = []
|
||||
|
||||
// #when extracting
|
||||
const result = extractPromptText(parts)
|
||||
|
||||
// #then should return empty string
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should handle parts without text", () => {
|
||||
// #given parts without text content
|
||||
const parts = [
|
||||
{ type: "tool_use", id: "123" },
|
||||
{ type: "tool_result", output: "result" },
|
||||
]
|
||||
|
||||
// #when extracting
|
||||
const result = extractPromptText(parts)
|
||||
|
||||
// #then should return empty string
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
65
src/hooks/auto-slash-command/detector.ts
Normal file
65
src/hooks/auto-slash-command/detector.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
SLASH_COMMAND_PATTERN,
|
||||
EXCLUDED_COMMANDS,
|
||||
} from "./constants"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
|
||||
const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
|
||||
export function removeCodeBlocks(text: string): string {
|
||||
return text.replace(CODE_BLOCK_PATTERN, "")
|
||||
}
|
||||
|
||||
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = trimmed.match(SLASH_COMMAND_PATTERN)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [raw, command, args] = match
|
||||
return {
|
||||
command: command.toLowerCase(),
|
||||
args: args.trim(),
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
export function isExcludedCommand(command: string): boolean {
|
||||
return EXCLUDED_COMMANDS.has(command.toLowerCase())
|
||||
}
|
||||
|
||||
export function detectSlashCommand(text: string): ParsedSlashCommand | null {
|
||||
const textWithoutCodeBlocks = removeCodeBlocks(text)
|
||||
const trimmed = textWithoutCodeBlocks.trim()
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = parseSlashCommand(trimmed)
|
||||
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isExcludedCommand(parsed.command)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function extractPromptText(
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
): string {
|
||||
return parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join(" ")
|
||||
}
|
||||
193
src/hooks/auto-slash-command/executor.ts
Normal file
193
src/hooks/auto-slash-command/executor.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { homedir } from "os"
|
||||
import {
|
||||
parseFrontmatter,
|
||||
resolveCommandsInText,
|
||||
resolveFileReferencesInText,
|
||||
sanitizeModelField,
|
||||
getClaudeConfigDir,
|
||||
} from "../../shared"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
|
||||
interface CommandScope {
|
||||
type: "user" | "project" | "opencode" | "opencode-project" | "skill"
|
||||
}
|
||||
|
||||
interface CommandMetadata {
|
||||
name: string
|
||||
description: string
|
||||
argumentHint?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
subtask?: boolean
|
||||
}
|
||||
|
||||
interface CommandInfo {
|
||||
name: string
|
||||
path?: string
|
||||
metadata: CommandMetadata
|
||||
content?: string
|
||||
scope: CommandScope["type"]
|
||||
}
|
||||
|
||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
const commands: CommandInfo[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter(content)
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const metadata: CommandMetadata = {
|
||||
name: commandName,
|
||||
description: data.description || "",
|
||||
argumentHint: data["argument-hint"],
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: Boolean(data.subtask),
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
metadata,
|
||||
content: body,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
return {
|
||||
name: skill.name,
|
||||
path: skill.path,
|
||||
metadata: {
|
||||
name: skill.name,
|
||||
description: skill.definition.description || "",
|
||||
argumentHint: skill.definition.argumentHint,
|
||||
model: skill.definition.model,
|
||||
agent: skill.definition.agent,
|
||||
subtask: skill.definition.subtask,
|
||||
},
|
||||
content: skill.definition.template,
|
||||
scope: "skill",
|
||||
}
|
||||
}
|
||||
|
||||
function discoverAllCommands(): CommandInfo[] {
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
|
||||
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
||||
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
|
||||
const skills = discoverAllSkills()
|
||||
const skillCommands = skills.map(skillToCommandInfo)
|
||||
|
||||
return [
|
||||
...opencodeProjectCommands,
|
||||
...projectCommands,
|
||||
...opencodeGlobalCommands,
|
||||
...userCommands,
|
||||
...skillCommands,
|
||||
]
|
||||
}
|
||||
|
||||
function findCommand(commandName: string): CommandInfo | null {
|
||||
const allCommands = discoverAllCommands()
|
||||
return allCommands.find(
|
||||
(cmd) => cmd.name.toLowerCase() === commandName.toLowerCase()
|
||||
) ?? null
|
||||
}
|
||||
|
||||
async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<string> {
|
||||
const sections: string[] = []
|
||||
|
||||
sections.push(`# /${cmd.name} Command\n`)
|
||||
|
||||
if (cmd.metadata.description) {
|
||||
sections.push(`**Description**: ${cmd.metadata.description}\n`)
|
||||
}
|
||||
|
||||
if (args) {
|
||||
sections.push(`**User Arguments**: ${args}\n`)
|
||||
}
|
||||
|
||||
if (cmd.metadata.model) {
|
||||
sections.push(`**Model**: ${cmd.metadata.model}\n`)
|
||||
}
|
||||
|
||||
if (cmd.metadata.agent) {
|
||||
sections.push(`**Agent**: ${cmd.metadata.agent}\n`)
|
||||
}
|
||||
|
||||
sections.push(`**Scope**: ${cmd.scope}\n`)
|
||||
sections.push("---\n")
|
||||
sections.push("## Command Instructions\n")
|
||||
|
||||
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
||||
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
|
||||
if (args) {
|
||||
sections.push("\n\n---\n")
|
||||
sections.push("## User Request\n")
|
||||
sections.push(args)
|
||||
}
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
export interface ExecuteResult {
|
||||
success: boolean
|
||||
replacementText?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise<ExecuteResult> {
|
||||
const command = findCommand(parsed.command)
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const template = await formatCommandTemplate(command, parsed.args)
|
||||
return {
|
||||
success: true,
|
||||
replacementText: template,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to load command "/${parsed.command}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
258
src/hooks/auto-slash-command/index.test.ts
Normal file
258
src/hooks/auto-slash-command/index.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it, beforeEach, mock, spyOn } from "bun:test"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
} from "./types"
|
||||
|
||||
// Import real shared module to avoid mock leaking to other test files
|
||||
import * as shared from "../../shared"
|
||||
|
||||
// Spy on log instead of mocking the entire module
|
||||
const logMock = spyOn(shared, "log").mockImplementation(() => {})
|
||||
|
||||
|
||||
|
||||
const { createAutoSlashCommandHook } = await import("./index")
|
||||
|
||||
function createMockInput(sessionID: string, messageID?: string): AutoSlashCommandHookInput {
|
||||
return {
|
||||
sessionID,
|
||||
messageID: messageID ?? `msg-${Date.now()}-${Math.random()}`,
|
||||
agent: "test-agent",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
|
||||
}
|
||||
}
|
||||
|
||||
function createMockOutput(text: string): AutoSlashCommandHookOutput {
|
||||
return {
|
||||
message: {
|
||||
agent: "test-agent",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
tools: {},
|
||||
},
|
||||
parts: [{ type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
describe("createAutoSlashCommandHook", () => {
|
||||
beforeEach(() => {
|
||||
logMock.mockClear()
|
||||
})
|
||||
|
||||
describe("slash command replacement", () => {
|
||||
it("should replace message with error when command not found", async () => {
|
||||
// #given a slash command that doesn't exist
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-notfound-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/nonexistent-command args")
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should replace with error message
|
||||
const textPart = output.parts.find((p) => p.type === "text")
|
||||
expect(textPart?.text).toContain("<auto-slash-command>")
|
||||
expect(textPart?.text).toContain("not found")
|
||||
})
|
||||
|
||||
it("should wrap replacement in auto-slash-command tags", async () => {
|
||||
// #given any slash command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-tags-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/some-command")
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should wrap in tags
|
||||
const textPart = output.parts.find((p) => p.type === "text")
|
||||
expect(textPart?.text).toContain("<auto-slash-command>")
|
||||
expect(textPart?.text).toContain("</auto-slash-command>")
|
||||
})
|
||||
|
||||
it("should completely replace original message text", async () => {
|
||||
// #given slash command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-replace-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/test-cmd some args")
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then original text should be replaced, not prepended
|
||||
const textPart = output.parts.find((p) => p.type === "text")
|
||||
expect(textPart?.text).not.toContain("/test-cmd some args\n<auto-slash-command>")
|
||||
expect(textPart?.text?.startsWith("<auto-slash-command>")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("no slash command", () => {
|
||||
it("should do nothing for regular text", async () => {
|
||||
// #given regular text without slash
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-regular-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("Just regular text")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should do nothing for slash in middle of text", async () => {
|
||||
// #given slash in middle
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-middle-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("Please run /commit later")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not detect (not at start)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("excluded commands", () => {
|
||||
it("should NOT trigger for ralph-loop command", async () => {
|
||||
// #given ralph-loop command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-ralph-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/ralph-loop do something")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify (excluded command)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should NOT trigger for cancel-ralph command", async () => {
|
||||
// #given cancel-ralph command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-cancel-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/cancel-ralph")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("already processed", () => {
|
||||
it("should skip if auto-slash-command tags already present", async () => {
|
||||
// #given text with existing tags
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-existing-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput(
|
||||
"<auto-slash-command>/commit</auto-slash-command>"
|
||||
)
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("code blocks", () => {
|
||||
it("should NOT detect command inside code block", async () => {
|
||||
// #given command inside code block
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-codeblock-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("```\n/commit\n```")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not detect
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty text", async () => {
|
||||
// #given empty text
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-empty-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("")
|
||||
|
||||
// #when hook is called
|
||||
// #then should not throw
|
||||
await expect(hook["chat.message"](input, output)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle just slash", async () => {
|
||||
// #given just slash
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-slash-only-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should handle command with special characters in args", async () => {
|
||||
// #given command with special characters
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-special-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput('/execute "test & stuff <tag>"')
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should handle gracefully (not found, but processed)
|
||||
const textPart = output.parts.find((p) => p.type === "text")
|
||||
expect(textPart?.text).toContain("<auto-slash-command>")
|
||||
expect(textPart?.text).toContain("/execute")
|
||||
})
|
||||
|
||||
it("should handle multiple text parts", async () => {
|
||||
// #given multiple text parts
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-multi-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output: AutoSlashCommandHookOutput = {
|
||||
message: {},
|
||||
parts: [
|
||||
{ type: "text", text: "/commit " },
|
||||
{ type: "text", text: "fix bug" },
|
||||
],
|
||||
}
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should detect from combined text and modify first text part
|
||||
const firstTextPart = output.parts.find((p) => p.type === "text")
|
||||
expect(firstTextPart?.text).toContain("<auto-slash-command>")
|
||||
})
|
||||
})
|
||||
})
|
||||
82
src/hooks/auto-slash-command/index.ts
Normal file
82
src/hooks/auto-slash-command/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
detectSlashCommand,
|
||||
extractPromptText,
|
||||
} from "./detector"
|
||||
import { executeSlashCommand } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
} from "./types"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./executor"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
const sessionProcessedCommands = new Set<string>()
|
||||
|
||||
export function createAutoSlashCommandHook() {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: AutoSlashCommandHookInput,
|
||||
output: AutoSlashCommandHookOutput
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
if (
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = detectSlashCommand(promptText)
|
||||
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}`
|
||||
if (sessionProcessedCommands.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
sessionProcessedCommands.add(commandKey)
|
||||
|
||||
log(`[auto-slash-command] Detected: /${parsed.command}`, {
|
||||
sessionID: input.sessionID,
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const result = await executeSlashCommand(parsed)
|
||||
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.success && result.replacementText) {
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
output.parts[idx].text = taggedContent
|
||||
|
||||
log(`[auto-slash-command] Replaced message with command template`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
})
|
||||
} else {
|
||||
const errorMessage = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n[AUTO-SLASH-COMMAND ERROR]\n${result.error}\n\nOriginal input: ${parsed.raw}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
output.parts[idx].text = errorMessage
|
||||
|
||||
log(`[auto-slash-command] Command not found, showing error`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
error: result.error,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
23
src/hooks/auto-slash-command/types.ts
Normal file
23
src/hooks/auto-slash-command/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface AutoSlashCommandHookInput {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
export interface AutoSlashCommandHookOutput {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
|
||||
export interface ParsedSlashCommand {
|
||||
command: string
|
||||
args: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export interface AutoSlashCommandResult {
|
||||
detected: boolean
|
||||
parsedCommand?: ParsedSlashCommand
|
||||
injectedMessage?: string
|
||||
}
|
||||
@@ -60,12 +60,17 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
let current = startDir;
|
||||
|
||||
while (true) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (existsSync(agentsPath)) {
|
||||
found.push(agentsPath);
|
||||
// Skip root AGENTS.md - OpenCode's system.ts already loads it via custom()
|
||||
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
||||
const isRootDir = current === ctx.directory;
|
||||
if (!isRootDir) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (existsSync(agentsPath)) {
|
||||
found.push(agentsPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (current === ctx.directory) break;
|
||||
if (isRootDir) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(ctx.directory)) break;
|
||||
|
||||
@@ -23,3 +23,4 @@ export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
||||
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||
|
||||
@@ -4,7 +4,7 @@ export const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string }> = [
|
||||
// ULTRAWORK: ulw, ultrawork
|
||||
{
|
||||
pattern: /\b(ultrawork|ulw)\b/i,
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
message: `<ultrawork-mode>
|
||||
[CODE RED] Maximum precision required. Ultrathink before acting.
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
INLINE_CODE_PATTERN,
|
||||
} from "./constants"
|
||||
|
||||
export interface DetectedKeyword {
|
||||
type: "ultrawork" | "search" | "analyze"
|
||||
message: string
|
||||
}
|
||||
|
||||
export function removeCodeBlocks(text: string): string {
|
||||
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
|
||||
}
|
||||
@@ -15,6 +20,18 @@ export function detectKeywords(text: string): string[] {
|
||||
).map(({ message }) => message)
|
||||
}
|
||||
|
||||
export function detectKeywordsWithType(text: string): DetectedKeyword[] {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"]
|
||||
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
|
||||
matches: pattern.test(textWithoutCode),
|
||||
type: types[index],
|
||||
message,
|
||||
}))
|
||||
.filter((result) => result.matches)
|
||||
.map(({ type, message }) => ({ type, message }))
|
||||
}
|
||||
|
||||
export function extractPromptText(
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { detectKeywords, extractPromptText } from "./detector"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywords, detectKeywordsWithType, extractPromptText } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
|
||||
@@ -7,8 +8,9 @@ export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionUltraworkNotified = new Set<string>()
|
||||
|
||||
export function createKeywordDetectorHook() {
|
||||
export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
@@ -26,12 +28,28 @@ export function createKeywordDetectorHook() {
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
const promptText = extractPromptText(output.parts)
|
||||
const messages = detectKeywords(promptText)
|
||||
const detectedKeywords = detectKeywordsWithType(promptText)
|
||||
const messages = detectedKeywords.map((k) => k.message)
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork && !sessionUltraworkNotified.has(input.sessionID)) {
|
||||
sessionUltraworkNotified.add(input.sessionID)
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
}).catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }))
|
||||
}
|
||||
|
||||
const context = messages.join("\n")
|
||||
|
||||
// First message: transform parts directly (for title generation compatibility)
|
||||
|
||||
@@ -82,10 +82,12 @@ export function createPreemptiveCompactionHook(
|
||||
const experimental = options?.experimental
|
||||
const onBeforeSummarize = options?.onBeforeSummarize
|
||||
const getModelLimit = options?.getModelLimit
|
||||
const enabled = experimental?.preemptive_compaction === true
|
||||
// Preemptive compaction is now enabled by default.
|
||||
// Backward compatibility: explicit false in experimental config disables the hook.
|
||||
const explicitlyDisabled = experimental?.preemptive_compaction === false
|
||||
const threshold = experimental?.preemptive_compaction_threshold ?? DEFAULT_THRESHOLD
|
||||
|
||||
if (!enabled) {
|
||||
if (explicitlyDisabled) {
|
||||
return { event: async () => {} }
|
||||
}
|
||||
|
||||
|
||||
@@ -329,17 +329,19 @@ describe("ralph-loop", () => {
|
||||
|
||||
test("should detect completion promise and stop loop", async () => {
|
||||
// #given - active loop with transcript containing completion
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => transcriptPath,
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "COMPLETE" })
|
||||
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
writeFileSync(transcriptPath, JSON.stringify({ content: "Task done <promise>COMPLETE</promise>" }))
|
||||
|
||||
// #when - session goes idle with transcript
|
||||
// #when - session goes idle (transcriptPath now derived from sessionID via getTranscriptPath)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123", transcriptPath },
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DEFAULT_COMPLETION_PROMISE,
|
||||
} from "./constants"
|
||||
import type { RalphLoopState, RalphLoopOptions } from "./types"
|
||||
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript"
|
||||
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
@@ -48,6 +49,7 @@ export function createRalphLoopHook(
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const config = options?.config
|
||||
const stateDir = config?.state_dir
|
||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||
|
||||
function getSessionState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
@@ -149,7 +151,8 @@ export function createRalphLoopHook(
|
||||
return
|
||||
}
|
||||
|
||||
const transcriptPath = props?.transcriptPath as string | undefined
|
||||
// Generate transcript path from sessionID - OpenCode doesn't pass it in event properties
|
||||
const transcriptPath = getTranscriptPath(sessionID)
|
||||
|
||||
if (detectCompletionPromise(transcriptPath, state.completion_promise)) {
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
|
||||
@@ -12,4 +12,5 @@ export interface RalphLoopState {
|
||||
|
||||
export interface RalphLoopOptions {
|
||||
config?: RalphLoopConfig
|
||||
getTranscriptPath?: (sessionId: string) => string
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { describe, expect, it, beforeEach, mock } from "bun:test"
|
||||
import { describe, expect, it, beforeEach } from "bun:test"
|
||||
import type { ThinkModeInput } from "./types"
|
||||
|
||||
const logMock = mock(() => {})
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
log: logMock,
|
||||
}))
|
||||
|
||||
const { createThinkModeHook, clearThinkModeState } = await import("./index")
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,9 @@ const TRUNCATABLE_TOOLS = [
|
||||
"ast_grep_search",
|
||||
"interactive_bash",
|
||||
"Interactive_bash",
|
||||
"skill_mcp",
|
||||
"webfetch",
|
||||
"WebFetch",
|
||||
]
|
||||
|
||||
interface ToolOutputTruncatorOptions {
|
||||
|
||||
122
src/index.ts
122
src/index.ts
@@ -25,6 +25,7 @@ import {
|
||||
createEmptyMessageSanitizerHook,
|
||||
createThinkingBlockValidatorHook,
|
||||
createRalphLoopHook,
|
||||
createAutoSlashCommandHook,
|
||||
} from "./hooks";
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||
import {
|
||||
@@ -57,78 +58,16 @@ import {
|
||||
setMainSession,
|
||||
getMainSessionID,
|
||||
} from "./features/claude-code-session-state";
|
||||
import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt, createSkillTool, interactive_bash, getTmuxPath } from "./tools";
|
||||
import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt, createSkillTool, createSkillMcpTool, interactive_bash, getTmuxPath } from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { createBuiltinMcps } from "./mcp";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
|
||||
import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile } from "./shared";
|
||||
import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile, migrateConfigFile } from "./shared";
|
||||
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// Migration map: old keys → new keys (for backward compatibility)
|
||||
const AGENT_NAME_MAP: Record<string, string> = {
|
||||
// Legacy names (backward compatibility)
|
||||
omo: "Sisyphus",
|
||||
"OmO": "Sisyphus",
|
||||
"OmO-Plan": "Planner-Sisyphus",
|
||||
"omo-plan": "Planner-Sisyphus",
|
||||
// Current names
|
||||
sisyphus: "Sisyphus",
|
||||
"planner-sisyphus": "Planner-Sisyphus",
|
||||
build: "build",
|
||||
oracle: "oracle",
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"frontend-ui-ux-engineer": "frontend-ui-ux-engineer",
|
||||
"document-writer": "document-writer",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
};
|
||||
|
||||
function migrateAgentNames(agents: Record<string, unknown>): { migrated: Record<string, unknown>; changed: boolean } {
|
||||
const migrated: Record<string, unknown> = {};
|
||||
let changed = false;
|
||||
|
||||
for (const [key, value] of Object.entries(agents)) {
|
||||
const newKey = AGENT_NAME_MAP[key.toLowerCase()] ?? AGENT_NAME_MAP[key] ?? key;
|
||||
if (newKey !== key) {
|
||||
changed = true;
|
||||
}
|
||||
migrated[newKey] = value;
|
||||
}
|
||||
|
||||
return { migrated, changed };
|
||||
}
|
||||
|
||||
function migrateConfigFile(configPath: string, rawConfig: Record<string, unknown>): boolean {
|
||||
let needsWrite = false;
|
||||
|
||||
if (rawConfig.agents && typeof rawConfig.agents === "object") {
|
||||
const { migrated, changed } = migrateAgentNames(rawConfig.agents as Record<string, unknown>);
|
||||
if (changed) {
|
||||
rawConfig.agents = migrated;
|
||||
needsWrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawConfig.omo_agent) {
|
||||
rawConfig.sisyphus_agent = rawConfig.omo_agent;
|
||||
delete rawConfig.omo_agent;
|
||||
needsWrite = true;
|
||||
}
|
||||
|
||||
if (needsWrite) {
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n", "utf-8");
|
||||
log(`Migrated config file: ${configPath} (OmO → Sisyphus)`);
|
||||
} catch (err) {
|
||||
log(`Failed to write migrated config to ${configPath}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return needsWrite;
|
||||
}
|
||||
|
||||
function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | null {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
@@ -189,6 +128,12 @@ function mergeConfigs(
|
||||
...(override.disabled_commands ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_skills: [
|
||||
...new Set([
|
||||
...(base.disabled_skills ?? []),
|
||||
...(override.disabled_skills ?? []),
|
||||
]),
|
||||
],
|
||||
claude_code: deepMerge(base.claude_code, override.claude_code),
|
||||
};
|
||||
}
|
||||
@@ -279,12 +224,16 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
dcpForCompaction: pluginConfig.experimental?.dcp_for_compaction,
|
||||
})
|
||||
: null;
|
||||
const compactionContextInjector = createCompactionContextInjector();
|
||||
const preemptiveCompaction = createPreemptiveCompactionHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
onBeforeSummarize: compactionContextInjector,
|
||||
getModelLimit,
|
||||
});
|
||||
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
||||
? createCompactionContextInjector()
|
||||
: undefined;
|
||||
const preemptiveCompaction = isHookEnabled("preemptive-compaction")
|
||||
? createPreemptiveCompactionHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
onBeforeSummarize: compactionContextInjector,
|
||||
getModelLimit,
|
||||
})
|
||||
: null;
|
||||
const rulesInjector = isHookEnabled("rules-injector")
|
||||
? createRulesInjectorHook(ctx)
|
||||
: null;
|
||||
@@ -296,7 +245,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
})
|
||||
: null;
|
||||
const keywordDetector = isHookEnabled("keyword-detector")
|
||||
? createKeywordDetectorHook()
|
||||
? createKeywordDetectorHook(ctx)
|
||||
: null;
|
||||
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
|
||||
? createAgentUsageReminderHook(ctx)
|
||||
@@ -318,6 +267,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
|
||||
: null;
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook()
|
||||
: null;
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
|
||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||
@@ -336,7 +289,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
|
||||
const lookAt = createLookAt(ctx);
|
||||
const builtinSkills = createBuiltinSkills();
|
||||
const disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
|
||||
const builtinSkills = createBuiltinSkills().filter(
|
||||
(skill) => !disabledSkills.has(skill.name as any)
|
||||
);
|
||||
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
|
||||
const mergedSkills = mergeSkills(
|
||||
builtinSkills,
|
||||
@@ -346,7 +302,18 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
|
||||
discoverOpencodeProjectSkills(),
|
||||
);
|
||||
const skillTool = createSkillTool({ skills: mergedSkills });
|
||||
const skillMcpManager = new SkillMcpManager();
|
||||
const getSessionIDForMcp = () => getMainSessionID() || "";
|
||||
const skillTool = createSkillTool({
|
||||
skills: mergedSkills,
|
||||
mcpManager: skillMcpManager,
|
||||
getSessionID: getSessionIDForMcp,
|
||||
});
|
||||
const skillMcpTool = createSkillMcpTool({
|
||||
manager: skillMcpManager,
|
||||
getLoadedSkills: () => mergedSkills,
|
||||
getSessionID: getSessionIDForMcp,
|
||||
});
|
||||
|
||||
const googleAuthHooks = pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
@@ -363,12 +330,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
call_omo_agent: callOmoAgent,
|
||||
look_at: lookAt,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
...(tmuxAvailable ? { interactive_bash } : {}),
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||
await keywordDetector?.["chat.message"]?.(input, output);
|
||||
await autoSlashCommand?.["chat.message"]?.(input, output);
|
||||
|
||||
if (ralphLoop) {
|
||||
const parts = (output as { parts?: Array<{ type: string; text?: string }> }).parts;
|
||||
@@ -542,6 +511,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
config.tools = {
|
||||
...config.tools,
|
||||
"grep_app_*": false, // Disable grep_app tools globally to reduce token usage (only librarian needs them)
|
||||
};
|
||||
|
||||
if (config.agent.explore) {
|
||||
@@ -554,6 +524,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
config.agent.librarian.tools = {
|
||||
...config.agent.librarian.tools,
|
||||
call_omo_agent: false,
|
||||
"grep_app_*": true,
|
||||
};
|
||||
}
|
||||
if (config.agent["multimodal-looker"]) {
|
||||
@@ -644,6 +615,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
if (sessionInfo?.id === getMainSessionID()) {
|
||||
setMainSession(undefined);
|
||||
}
|
||||
if (sessionInfo?.id) {
|
||||
await skillMcpManager.disconnectSession(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
|
||||
82
src/shared/AGENTS.md
Normal file
82
src/shared/AGENTS.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# SHARED UTILITIES KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Cross-cutting utility functions used across agents, hooks, tools, and features. Path resolution, config management, text processing, and Claude Code compatibility helpers.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
shared/
|
||||
├── index.ts # Barrel export (import { x } from "../shared")
|
||||
├── claude-config-dir.ts # Resolve ~/.claude directory
|
||||
├── command-executor.ts # Shell command execution with variable expansion
|
||||
├── config-errors.ts # Global config error tracking
|
||||
├── config-path.ts # User/project config path resolution
|
||||
├── data-path.ts # XDG data directory resolution
|
||||
├── deep-merge.ts # Type-safe recursive object merging
|
||||
├── dynamic-truncator.ts # Token-aware output truncation
|
||||
├── file-reference-resolver.ts # @filename syntax resolution
|
||||
├── file-utils.ts # Symlink resolution, markdown detection
|
||||
├── frontmatter.ts # YAML frontmatter parsing
|
||||
├── hook-disabled.ts # Check if hook is disabled in config
|
||||
├── jsonc-parser.ts # JSON with Comments parsing
|
||||
├── logger.ts # File-based logging to OS temp
|
||||
├── migration.ts # Legacy name compatibility (omo -> Sisyphus)
|
||||
├── model-sanitizer.ts # Normalize model names
|
||||
├── pattern-matcher.ts # Tool name matching with wildcards
|
||||
├── snake-case.ts # Case conversion for objects
|
||||
└── tool-name.ts # Normalize tool names to PascalCase
|
||||
```
|
||||
|
||||
## UTILITY CATEGORIES
|
||||
|
||||
| Category | Utilities | Used By |
|
||||
|----------|-----------|---------|
|
||||
| Path Resolution | `getClaudeConfigDir`, `getUserConfigPath`, `getProjectConfigPath`, `getDataDir` | Features, Hooks |
|
||||
| Config Management | `deepMerge`, `parseJsonc`, `isHookDisabled`, `configErrors` | index.ts, CLI |
|
||||
| Text Processing | `resolveCommandsInText`, `resolveFileReferencesInText`, `parseFrontmatter` | Commands, Rules |
|
||||
| Output Control | `dynamicTruncate` | Tools (Grep, LSP) |
|
||||
| Normalization | `transformToolName`, `objectToSnakeCase`, `sanitizeModelName` | Hooks, Agents |
|
||||
| Compatibility | `migration.ts` | Config loading |
|
||||
|
||||
## WHEN TO USE WHAT
|
||||
|
||||
| Task | Utility | Notes |
|
||||
|------|---------|-------|
|
||||
| Find Claude Code configs | `getClaudeConfigDir()` | Never hardcode `~/.claude` |
|
||||
| Merge settings (default → user → project) | `deepMerge(base, override)` | Arrays replaced, objects merged |
|
||||
| Parse user config files | `parseJsonc()` | Supports comments and trailing commas |
|
||||
| Check if hook should run | `isHookDisabled(name, disabledHooks)` | Respects `disabled_hooks` config |
|
||||
| Truncate large tool output | `dynamicTruncate(text, budget, reserved)` | Token-aware, prevents overflow |
|
||||
| Resolve `@file` references | `resolveFileReferencesInText()` | maxDepth=3 prevents infinite loops |
|
||||
| Execute shell commands | `resolveCommandsInText()` | Supports `!`\`command\`\` syntax |
|
||||
| Handle legacy agent names | `migrateLegacyAgentNames()` | `omo` → `Sisyphus` |
|
||||
|
||||
## CRITICAL PATTERNS
|
||||
|
||||
### Dynamic Truncation
|
||||
```typescript
|
||||
import { dynamicTruncate } from "../shared"
|
||||
// Keep 50% headroom, max 50k tokens
|
||||
const output = dynamicTruncate(result, remainingTokens, 0.5)
|
||||
```
|
||||
|
||||
### Deep Merge Priority
|
||||
```typescript
|
||||
const final = deepMerge(defaults, userConfig)
|
||||
final = deepMerge(final, projectConfig) // Project wins
|
||||
```
|
||||
|
||||
### Safe JSONC Parsing
|
||||
```typescript
|
||||
const { config, error } = parseJsoncSafe(content)
|
||||
if (error) return fallback
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS (SHARED)
|
||||
|
||||
- **Hardcoding paths**: Use `getClaudeConfigDir()`, `getUserConfigPath()`
|
||||
- **Manual JSON.parse**: Use `parseJsonc()` for user files (comments allowed)
|
||||
- **Ignoring truncation**: Large outputs MUST use `dynamicTruncate`
|
||||
- **Direct string concat for configs**: Use `deepMerge` for proper priority
|
||||
@@ -15,3 +15,4 @@ export * from "./data-path"
|
||||
export * from "./config-errors"
|
||||
export * from "./claude-config-dir"
|
||||
export * from "./jsonc-parser"
|
||||
export * from "./migration"
|
||||
|
||||
243
src/shared/migration.test.ts
Normal file
243
src/shared/migration.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import {
|
||||
AGENT_NAME_MAP,
|
||||
HOOK_NAME_MAP,
|
||||
migrateAgentNames,
|
||||
migrateHookNames,
|
||||
migrateConfigFile,
|
||||
} from "./migration"
|
||||
|
||||
describe("migrateAgentNames", () => {
|
||||
test("migrates legacy OmO names to Sisyphus", () => {
|
||||
// #given: Config with legacy OmO agent names
|
||||
const agents = {
|
||||
omo: { model: "anthropic/claude-opus-4-5" },
|
||||
OmO: { temperature: 0.5 },
|
||||
"OmO-Plan": { prompt: "custom prompt" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Legacy names should be migrated to Sisyphus
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["Sisyphus"]).toEqual({ temperature: 0.5 })
|
||||
expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "custom prompt" })
|
||||
expect(migrated["omo"]).toBeUndefined()
|
||||
expect(migrated["OmO"]).toBeUndefined()
|
||||
expect(migrated["OmO-Plan"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves current agent names unchanged", () => {
|
||||
// #given: Config with current agent names
|
||||
const agents = {
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
librarian: { model: "google/gemini-3-flash" },
|
||||
explore: { model: "opencode/grok-code" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Current names should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.2" })
|
||||
expect(migrated["librarian"]).toEqual({ model: "google/gemini-3-flash" })
|
||||
expect(migrated["explore"]).toEqual({ model: "opencode/grok-code" })
|
||||
})
|
||||
|
||||
test("handles case-insensitive migration", () => {
|
||||
// #given: Config with mixed case agent names
|
||||
const agents = {
|
||||
SISYPHUS: { model: "test" },
|
||||
"PLANNER-SISYPHUS": { prompt: "test" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Case-insensitive lookup should migrate correctly
|
||||
expect(migrated["Sisyphus"]).toEqual({ model: "test" })
|
||||
expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "test" })
|
||||
})
|
||||
|
||||
test("passes through unknown agent names unchanged", () => {
|
||||
// #given: Config with unknown agent name
|
||||
const agents = {
|
||||
"custom-agent": { model: "custom/model" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
const { migrated, changed } = migrateAgentNames(agents)
|
||||
|
||||
// #then: Unknown names should pass through
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["custom-agent"]).toEqual({ model: "custom/model" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateHookNames", () => {
|
||||
test("migrates anthropic-auto-compact to anthropic-context-window-limit-recovery", () => {
|
||||
// #given: Config with legacy hook name
|
||||
const hooks = ["anthropic-auto-compact", "comment-checker"]
|
||||
|
||||
// #when: Migrate hook names
|
||||
const { migrated, changed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Legacy hook name should be migrated
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toContain("anthropic-context-window-limit-recovery")
|
||||
expect(migrated).toContain("comment-checker")
|
||||
expect(migrated).not.toContain("anthropic-auto-compact")
|
||||
})
|
||||
|
||||
test("preserves current hook names unchanged", () => {
|
||||
// #given: Config with current hook names
|
||||
const hooks = [
|
||||
"anthropic-context-window-limit-recovery",
|
||||
"todo-continuation-enforcer",
|
||||
"session-recovery",
|
||||
]
|
||||
|
||||
// #when: Migrate hook names
|
||||
const { migrated, changed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Current names should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated).toEqual(hooks)
|
||||
})
|
||||
|
||||
test("handles empty hooks array", () => {
|
||||
// #given: Empty hooks array
|
||||
const hooks: string[] = []
|
||||
|
||||
// #when: Migrate hook names
|
||||
const { migrated, changed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: Should return empty array with no changes
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated).toEqual([])
|
||||
})
|
||||
|
||||
test("migrates multiple legacy hook names", () => {
|
||||
// #given: Multiple legacy hook names (if more are added in future)
|
||||
const hooks = ["anthropic-auto-compact"]
|
||||
|
||||
// #when: Migrate hook names
|
||||
const { migrated, changed } = migrateHookNames(hooks)
|
||||
|
||||
// #then: All legacy names should be migrated
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated).toEqual(["anthropic-context-window-limit-recovery"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateConfigFile", () => {
|
||||
const testConfigPath = "/tmp/nonexistent-path-for-test.json"
|
||||
|
||||
test("migrates omo_agent to sisyphus_agent", () => {
|
||||
// #given: Config with legacy omo_agent key
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
omo_agent: { disabled: false },
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: omo_agent should be migrated to sisyphus_agent
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
||||
expect(rawConfig.omo_agent).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates legacy agent names in agents object", () => {
|
||||
// #given: Config with legacy agent names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
omo: { model: "test" },
|
||||
OmO: { temperature: 0.5 },
|
||||
},
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Agent names should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
const agents = rawConfig.agents as Record<string, unknown>
|
||||
expect(agents["Sisyphus"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("migrates legacy hook names in disabled_hooks", () => {
|
||||
// #given: Config with legacy hook names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
disabled_hooks: ["anthropic-auto-compact", "comment-checker"],
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Hook names should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
||||
expect(rawConfig.disabled_hooks).not.toContain("anthropic-auto-compact")
|
||||
})
|
||||
|
||||
test("does not write if no migration needed", () => {
|
||||
// #given: Config with current names
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
sisyphus_agent: { disabled: false },
|
||||
agents: {
|
||||
Sisyphus: { model: "test" },
|
||||
},
|
||||
disabled_hooks: ["anthropic-context-window-limit-recovery"],
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: No write should be needed
|
||||
expect(needsWrite).toBe(false)
|
||||
})
|
||||
|
||||
test("handles migration of all legacy items together", () => {
|
||||
// #given: Config with all legacy items
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
omo_agent: { disabled: false },
|
||||
agents: {
|
||||
omo: { model: "test" },
|
||||
"OmO-Plan": { prompt: "custom" },
|
||||
},
|
||||
disabled_hooks: ["anthropic-auto-compact"],
|
||||
}
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: All legacy items should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
||||
expect(rawConfig.omo_agent).toBeUndefined()
|
||||
const agents = rawConfig.agents as Record<string, unknown>
|
||||
expect(agents["Sisyphus"]).toBeDefined()
|
||||
expect(agents["Planner-Sisyphus"]).toBeDefined()
|
||||
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
||||
})
|
||||
})
|
||||
|
||||
describe("migration maps", () => {
|
||||
test("AGENT_NAME_MAP contains all expected legacy mappings", () => {
|
||||
// #given/#when: Check AGENT_NAME_MAP
|
||||
// #then: Should contain all legacy → current mappings
|
||||
expect(AGENT_NAME_MAP["omo"]).toBe("Sisyphus")
|
||||
expect(AGENT_NAME_MAP["OmO"]).toBe("Sisyphus")
|
||||
expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("Planner-Sisyphus")
|
||||
expect(AGENT_NAME_MAP["omo-plan"]).toBe("Planner-Sisyphus")
|
||||
})
|
||||
|
||||
test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => {
|
||||
// #given/#when: Check HOOK_NAME_MAP
|
||||
// #then: Should contain the legacy hook name mapping
|
||||
expect(HOOK_NAME_MAP["anthropic-auto-compact"]).toBe("anthropic-context-window-limit-recovery")
|
||||
})
|
||||
})
|
||||
94
src/shared/migration.ts
Normal file
94
src/shared/migration.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as fs from "fs"
|
||||
import { log } from "./logger"
|
||||
|
||||
// Migration map: old keys → new keys (for backward compatibility)
|
||||
export const AGENT_NAME_MAP: Record<string, string> = {
|
||||
// Legacy names (backward compatibility)
|
||||
omo: "Sisyphus",
|
||||
"OmO": "Sisyphus",
|
||||
"OmO-Plan": "Planner-Sisyphus",
|
||||
"omo-plan": "Planner-Sisyphus",
|
||||
// Current names
|
||||
sisyphus: "Sisyphus",
|
||||
"planner-sisyphus": "Planner-Sisyphus",
|
||||
build: "build",
|
||||
oracle: "oracle",
|
||||
librarian: "librarian",
|
||||
explore: "explore",
|
||||
"frontend-ui-ux-engineer": "frontend-ui-ux-engineer",
|
||||
"document-writer": "document-writer",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
}
|
||||
|
||||
// Migration map: old hook names → new hook names (for backward compatibility)
|
||||
export const HOOK_NAME_MAP: Record<string, string> = {
|
||||
// Legacy names (backward compatibility)
|
||||
"anthropic-auto-compact": "anthropic-context-window-limit-recovery",
|
||||
}
|
||||
|
||||
export function migrateAgentNames(agents: Record<string, unknown>): { migrated: Record<string, unknown>; changed: boolean } {
|
||||
const migrated: Record<string, unknown> = {}
|
||||
let changed = false
|
||||
|
||||
for (const [key, value] of Object.entries(agents)) {
|
||||
const newKey = AGENT_NAME_MAP[key.toLowerCase()] ?? AGENT_NAME_MAP[key] ?? key
|
||||
if (newKey !== key) {
|
||||
changed = true
|
||||
}
|
||||
migrated[newKey] = value
|
||||
}
|
||||
|
||||
return { migrated, changed }
|
||||
}
|
||||
|
||||
export function migrateHookNames(hooks: string[]): { migrated: string[]; changed: boolean } {
|
||||
const migrated: string[] = []
|
||||
let changed = false
|
||||
|
||||
for (const hook of hooks) {
|
||||
const newHook = HOOK_NAME_MAP[hook] ?? hook
|
||||
if (newHook !== hook) {
|
||||
changed = true
|
||||
}
|
||||
migrated.push(newHook)
|
||||
}
|
||||
|
||||
return { migrated, changed }
|
||||
}
|
||||
|
||||
export function migrateConfigFile(configPath: string, rawConfig: Record<string, unknown>): boolean {
|
||||
let needsWrite = false
|
||||
|
||||
if (rawConfig.agents && typeof rawConfig.agents === "object") {
|
||||
const { migrated, changed } = migrateAgentNames(rawConfig.agents as Record<string, unknown>)
|
||||
if (changed) {
|
||||
rawConfig.agents = migrated
|
||||
needsWrite = true
|
||||
}
|
||||
}
|
||||
|
||||
if (rawConfig.omo_agent) {
|
||||
rawConfig.sisyphus_agent = rawConfig.omo_agent
|
||||
delete rawConfig.omo_agent
|
||||
needsWrite = true
|
||||
}
|
||||
|
||||
if (rawConfig.disabled_hooks && Array.isArray(rawConfig.disabled_hooks)) {
|
||||
const { migrated, changed } = migrateHookNames(rawConfig.disabled_hooks as string[])
|
||||
if (changed) {
|
||||
rawConfig.disabled_hooks = migrated
|
||||
needsWrite = true
|
||||
}
|
||||
}
|
||||
|
||||
if (needsWrite) {
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n", "utf-8")
|
||||
log(`Migrated config file: ${configPath}`)
|
||||
} catch (err) {
|
||||
log(`Failed to write migrated config to ${configPath}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return needsWrite
|
||||
}
|
||||
@@ -29,6 +29,8 @@ tools/
|
||||
│ ├── storage.ts # File I/O operations
|
||||
│ ├── utils.ts # Formatting, filtering
|
||||
│ └── tools.ts # Tool implementations
|
||||
├── skill/ # Skill loading and execution
|
||||
├── skill-mcp/ # Skill-embedded MCP invocation
|
||||
├── slashcommand/ # Slash command execution
|
||||
└── index.ts # builtinTools export
|
||||
```
|
||||
@@ -45,6 +47,7 @@ tools/
|
||||
| Multimodal | look_at | PDF/image analysis via Gemini |
|
||||
| Terminal | interactive_bash | Tmux session control |
|
||||
| Commands | slashcommand | Execute slash commands |
|
||||
| Skills | skill, skill_mcp | Load skills, invoke skill-embedded MCPs |
|
||||
| Agents | call_omo_agent | Spawn explore/librarian |
|
||||
|
||||
## HOW TO ADD A TOOL
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
||||
export { createSkillTool } from "./skill"
|
||||
export { getTmuxPath } from "./interactive-bash/utils"
|
||||
export { createSkillMcpTool } from "./skill-mcp"
|
||||
|
||||
import {
|
||||
createBackgroundTask,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getClaudeConfigDir } from "../../shared"
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir()
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
export const SESSION_STORAGE = join(OPENCODE_STORAGE, "session")
|
||||
export const TODO_DIR = join(getClaudeConfigDir(), "todos")
|
||||
export const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "transcripts")
|
||||
export const SESSION_LIST_DESCRIPTION = `List all OpenCode sessions with optional filtering.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os"
|
||||
const TEST_DIR = join(tmpdir(), "omo-test-session-manager")
|
||||
const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message")
|
||||
const TEST_PART_STORAGE = join(TEST_DIR, "part")
|
||||
const TEST_SESSION_STORAGE = join(TEST_DIR, "session")
|
||||
const TEST_TODO_DIR = join(TEST_DIR, "todos")
|
||||
const TEST_TRANSCRIPT_DIR = join(TEST_DIR, "transcripts")
|
||||
|
||||
@@ -13,6 +14,7 @@ mock.module("./constants", () => ({
|
||||
OPENCODE_STORAGE: TEST_DIR,
|
||||
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
|
||||
PART_STORAGE: TEST_PART_STORAGE,
|
||||
SESSION_STORAGE: TEST_SESSION_STORAGE,
|
||||
TODO_DIR: TEST_TODO_DIR,
|
||||
TRANSCRIPT_DIR: TEST_TRANSCRIPT_DIR,
|
||||
SESSION_LIST_DESCRIPTION: "test",
|
||||
@@ -26,6 +28,8 @@ mock.module("./constants", () => ({
|
||||
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
|
||||
await import("./storage")
|
||||
|
||||
const storage = await import("./storage")
|
||||
|
||||
describe("session-manager storage", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
@@ -34,6 +38,7 @@ describe("session-manager storage", () => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_PART_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_SESSION_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_TODO_DIR, { recursive: true })
|
||||
mkdirSync(TEST_TRANSCRIPT_DIR, { recursive: true })
|
||||
})
|
||||
@@ -174,3 +179,137 @@ describe("session-manager storage", () => {
|
||||
expect(info?.agents_used).toContain("oracle")
|
||||
})
|
||||
})
|
||||
|
||||
describe("session-manager storage - getMainSessions", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_PART_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_SESSION_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_TODO_DIR, { recursive: true })
|
||||
mkdirSync(TEST_TRANSCRIPT_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function createSessionMetadata(
|
||||
projectID: string,
|
||||
sessionID: string,
|
||||
opts: { parentID?: string; directory: string; updated: number }
|
||||
) {
|
||||
const projectDir = join(TEST_SESSION_STORAGE, projectID)
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(projectDir, `${sessionID}.json`),
|
||||
JSON.stringify({
|
||||
id: sessionID,
|
||||
projectID,
|
||||
directory: opts.directory,
|
||||
parentID: opts.parentID,
|
||||
time: { created: opts.updated - 1000, updated: opts.updated },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function createMessageForSession(sessionID: string, msgID: string, created: number) {
|
||||
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(sessionPath, { recursive: true })
|
||||
writeFileSync(
|
||||
join(sessionPath, `${msgID}.json`),
|
||||
JSON.stringify({ id: msgID, role: "user", time: { created } })
|
||||
)
|
||||
}
|
||||
|
||||
test("getMainSessions returns only sessions without parentID", async () => {
|
||||
// #given
|
||||
const projectID = "proj_abc123"
|
||||
const now = Date.now()
|
||||
|
||||
createSessionMetadata(projectID, "ses_main1", { directory: "/test/path", updated: now })
|
||||
createSessionMetadata(projectID, "ses_main2", { directory: "/test/path", updated: now - 1000 })
|
||||
createSessionMetadata(projectID, "ses_child1", { directory: "/test/path", updated: now, parentID: "ses_main1" })
|
||||
|
||||
createMessageForSession("ses_main1", "msg_001", now)
|
||||
createMessageForSession("ses_main2", "msg_001", now - 1000)
|
||||
createMessageForSession("ses_child1", "msg_001", now)
|
||||
|
||||
// #when
|
||||
const sessions = await storage.getMainSessions({ directory: "/test/path" })
|
||||
|
||||
// #then
|
||||
expect(sessions.length).toBe(2)
|
||||
expect(sessions.map((s) => s.id)).not.toContain("ses_child1")
|
||||
})
|
||||
|
||||
test("getMainSessions sorts by time.updated descending (most recent first)", async () => {
|
||||
// #given
|
||||
const projectID = "proj_abc123"
|
||||
const now = Date.now()
|
||||
|
||||
createSessionMetadata(projectID, "ses_old", { directory: "/test/path", updated: now - 5000 })
|
||||
createSessionMetadata(projectID, "ses_mid", { directory: "/test/path", updated: now - 2000 })
|
||||
createSessionMetadata(projectID, "ses_new", { directory: "/test/path", updated: now })
|
||||
|
||||
createMessageForSession("ses_old", "msg_001", now - 5000)
|
||||
createMessageForSession("ses_mid", "msg_001", now - 2000)
|
||||
createMessageForSession("ses_new", "msg_001", now)
|
||||
|
||||
// #when
|
||||
const sessions = await storage.getMainSessions({ directory: "/test/path" })
|
||||
|
||||
// #then
|
||||
expect(sessions.length).toBe(3)
|
||||
expect(sessions[0].id).toBe("ses_new")
|
||||
expect(sessions[1].id).toBe("ses_mid")
|
||||
expect(sessions[2].id).toBe("ses_old")
|
||||
})
|
||||
|
||||
test("getMainSessions filters by directory (project path)", async () => {
|
||||
// #given
|
||||
const projectA = "proj_aaa"
|
||||
const projectB = "proj_bbb"
|
||||
const now = Date.now()
|
||||
|
||||
createSessionMetadata(projectA, "ses_projA", { directory: "/path/to/projectA", updated: now })
|
||||
createSessionMetadata(projectB, "ses_projB", { directory: "/path/to/projectB", updated: now })
|
||||
|
||||
createMessageForSession("ses_projA", "msg_001", now)
|
||||
createMessageForSession("ses_projB", "msg_001", now)
|
||||
|
||||
// #when
|
||||
const sessionsA = await storage.getMainSessions({ directory: "/path/to/projectA" })
|
||||
const sessionsB = await storage.getMainSessions({ directory: "/path/to/projectB" })
|
||||
|
||||
// #then
|
||||
expect(sessionsA.length).toBe(1)
|
||||
expect(sessionsA[0].id).toBe("ses_projA")
|
||||
expect(sessionsB.length).toBe(1)
|
||||
expect(sessionsB[0].id).toBe("ses_projB")
|
||||
})
|
||||
|
||||
test("getMainSessions returns all main sessions when directory is not specified", async () => {
|
||||
// #given
|
||||
const projectA = "proj_aaa"
|
||||
const projectB = "proj_bbb"
|
||||
const now = Date.now()
|
||||
|
||||
createSessionMetadata(projectA, "ses_projA", { directory: "/path/to/projectA", updated: now })
|
||||
createSessionMetadata(projectB, "ses_projB", { directory: "/path/to/projectB", updated: now - 1000 })
|
||||
|
||||
createMessageForSession("ses_projA", "msg_001", now)
|
||||
createMessageForSession("ses_projB", "msg_001", now - 1000)
|
||||
|
||||
// #when
|
||||
const sessions = await storage.getMainSessions({})
|
||||
|
||||
// #then
|
||||
expect(sessions.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,49 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
|
||||
import type { SessionMessage, SessionInfo, TodoItem } from "./types"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
|
||||
import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types"
|
||||
|
||||
export interface GetMainSessionsOptions {
|
||||
directory?: string
|
||||
}
|
||||
|
||||
export async function getMainSessions(options: GetMainSessionsOptions): Promise<SessionMetadata[]> {
|
||||
if (!existsSync(SESSION_STORAGE)) return []
|
||||
|
||||
const sessions: SessionMetadata[] = []
|
||||
|
||||
try {
|
||||
const projectDirs = await readdir(SESSION_STORAGE, { withFileTypes: true })
|
||||
for (const projectDir of projectDirs) {
|
||||
if (!projectDir.isDirectory()) continue
|
||||
|
||||
const projectPath = join(SESSION_STORAGE, projectDir.name)
|
||||
const sessionFiles = await readdir(projectPath)
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
|
||||
try {
|
||||
const content = await readFile(join(projectPath, file), "utf-8")
|
||||
const meta = JSON.parse(content) as SessionMetadata
|
||||
|
||||
if (meta.parentID) continue
|
||||
|
||||
if (options.directory && meta.directory !== options.directory) continue
|
||||
|
||||
sessions.push(meta)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
}
|
||||
|
||||
export async function getAllSessions(): Promise<string[]> {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return []
|
||||
|
||||
@@ -31,6 +31,27 @@ describe("session-manager tools", () => {
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_list filters by project_path", async () => {
|
||||
// #given
|
||||
const projectPath = "/Users/yeongyu/local-workspaces/oh-my-opencode"
|
||||
|
||||
// #when
|
||||
const result = await session_list.execute({ project_path: projectPath }, mockContext)
|
||||
|
||||
// #then
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_list uses process.cwd() as default project_path", async () => {
|
||||
// #given - no project_path provided
|
||||
|
||||
// #when
|
||||
const result = await session_list.execute({}, mockContext)
|
||||
|
||||
// #then - should not throw and return string (uses process.cwd() internally)
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_read handles non-existent session", async () => {
|
||||
const result = await session_read.execute({ session_id: "ses_nonexistent" }, mockContext)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SESSION_SEARCH_DESCRIPTION,
|
||||
SESSION_INFO_DESCRIPTION,
|
||||
} from "./constants"
|
||||
import { getAllSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists } from "./storage"
|
||||
import { getAllSessions, getMainSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists } from "./storage"
|
||||
import {
|
||||
filterSessionsByDate,
|
||||
formatSessionInfo,
|
||||
@@ -32,20 +32,23 @@ export const session_list: ToolDefinition = tool({
|
||||
limit: tool.schema.number().optional().describe("Maximum number of sessions to return"),
|
||||
from_date: tool.schema.string().optional().describe("Filter sessions from this date (ISO 8601 format)"),
|
||||
to_date: tool.schema.string().optional().describe("Filter sessions until this date (ISO 8601 format)"),
|
||||
project_path: tool.schema.string().optional().describe("Filter sessions by project path (default: current working directory)"),
|
||||
},
|
||||
execute: async (args: SessionListArgs, _context) => {
|
||||
try {
|
||||
let sessions = await getAllSessions()
|
||||
const directory = args.project_path ?? process.cwd()
|
||||
let sessions = await getMainSessions({ directory })
|
||||
let sessionIDs = sessions.map((s) => s.id)
|
||||
|
||||
if (args.from_date || args.to_date) {
|
||||
sessions = await filterSessionsByDate(sessions, args.from_date, args.to_date)
|
||||
sessionIDs = await filterSessionsByDate(sessionIDs, args.from_date, args.to_date)
|
||||
}
|
||||
|
||||
if (args.limit && args.limit > 0) {
|
||||
sessions = sessions.slice(0, args.limit)
|
||||
sessionIDs = sessionIDs.slice(0, args.limit)
|
||||
}
|
||||
|
||||
return await formatSessionList(sessions)
|
||||
return await formatSessionList(sessionIDs)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
|
||||
@@ -49,11 +49,30 @@ export interface SearchResult {
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export interface SessionMetadata {
|
||||
id: string
|
||||
version?: string
|
||||
projectID: string
|
||||
directory: string
|
||||
title?: string
|
||||
parentID?: string
|
||||
time: {
|
||||
created: number
|
||||
updated: number
|
||||
}
|
||||
summary?: {
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionListArgs {
|
||||
limit?: number
|
||||
offset?: number
|
||||
from_date?: string
|
||||
to_date?: string
|
||||
project_path?: string
|
||||
}
|
||||
|
||||
export interface SessionReadArgs {
|
||||
|
||||
3
src/tools/skill-mcp/constants.ts
Normal file
3
src/tools/skill-mcp/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const SKILL_MCP_TOOL_NAME = "skill_mcp"
|
||||
|
||||
export const SKILL_MCP_DESCRIPTION = `Invoke MCP server operations from skill-embedded MCPs. Requires mcp_name plus exactly one of: tool_name, resource_name, or prompt_name.`
|
||||
3
src/tools/skill-mcp/index.ts
Normal file
3
src/tools/skill-mcp/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
export { createSkillMcpTool } from "./tools"
|
||||
215
src/tools/skill-mcp/tools.test.ts
Normal file
215
src/tools/skill-mcp/tools.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, beforeEach, mock } from "bun:test"
|
||||
import { createSkillMcpTool, applyGrepFilter } from "./tools"
|
||||
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
|
||||
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
|
||||
return {
|
||||
name,
|
||||
path: `/test/skills/${name}/SKILL.md`,
|
||||
resolvedPath: `/test/skills/${name}`,
|
||||
definition: {
|
||||
name,
|
||||
description: `Test skill ${name}`,
|
||||
template: "Test template",
|
||||
},
|
||||
scope: "opencode-project",
|
||||
mcpConfig: mcpServers as LoadedSkill["mcpConfig"],
|
||||
}
|
||||
}
|
||||
|
||||
const mockContext = {
|
||||
sessionID: "test-session",
|
||||
messageID: "msg-1",
|
||||
agent: "test-agent",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
describe("skill_mcp tool", () => {
|
||||
let manager: SkillMcpManager
|
||||
let loadedSkills: LoadedSkill[]
|
||||
let sessionID: string
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SkillMcpManager()
|
||||
loadedSkills = []
|
||||
sessionID = "test-session-1"
|
||||
})
|
||||
|
||||
describe("parameter validation", () => {
|
||||
it("throws when no operation specified", async () => {
|
||||
// #given
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({ mcp_name: "test-server" }, mockContext)
|
||||
).rejects.toThrow(/Missing operation/)
|
||||
})
|
||||
|
||||
it("throws when multiple operations specified", async () => {
|
||||
// #given
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({
|
||||
mcp_name: "test-server",
|
||||
tool_name: "some-tool",
|
||||
resource_name: "some://resource",
|
||||
}, mockContext)
|
||||
).rejects.toThrow(/Multiple operations/)
|
||||
})
|
||||
|
||||
it("throws when mcp_name not found in any skill", async () => {
|
||||
// #given
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("test-skill", {
|
||||
"known-server": { command: "echo", args: ["test"] },
|
||||
}),
|
||||
]
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({ mcp_name: "unknown-server", tool_name: "some-tool" }, mockContext)
|
||||
).rejects.toThrow(/not found/)
|
||||
})
|
||||
|
||||
it("includes available MCP servers in error message", async () => {
|
||||
// #given
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("db-skill", {
|
||||
sqlite: { command: "uvx", args: ["mcp-server-sqlite"] },
|
||||
}),
|
||||
createMockSkillWithMcp("api-skill", {
|
||||
"rest-api": { command: "node", args: ["server.js"] },
|
||||
}),
|
||||
]
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({ mcp_name: "missing", tool_name: "test" }, mockContext)
|
||||
).rejects.toThrow(/sqlite.*db-skill|rest-api.*api-skill/s)
|
||||
})
|
||||
|
||||
it("throws on invalid JSON arguments", async () => {
|
||||
// #given
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("test-skill", {
|
||||
"test-server": { command: "echo" },
|
||||
}),
|
||||
]
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({
|
||||
mcp_name: "test-server",
|
||||
tool_name: "some-tool",
|
||||
arguments: "not valid json",
|
||||
}, mockContext)
|
||||
).rejects.toThrow(/Invalid arguments JSON/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool description", () => {
|
||||
it("has concise description", () => {
|
||||
// #given / #when
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => [],
|
||||
getSessionID: () => "session",
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(tool.description.length).toBeLessThan(200)
|
||||
expect(tool.description).toContain("mcp_name")
|
||||
})
|
||||
|
||||
it("includes grep parameter in schema", () => {
|
||||
// #given / #when
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => [],
|
||||
getSessionID: () => "session",
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(tool.description).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyGrepFilter", () => {
|
||||
it("filters lines matching pattern", () => {
|
||||
// #given
|
||||
const output = `line1: hello world
|
||||
line2: foo bar
|
||||
line3: hello again
|
||||
line4: baz qux`
|
||||
|
||||
// #when
|
||||
const result = applyGrepFilter(output, "hello")
|
||||
|
||||
// #then
|
||||
expect(result).toContain("line1: hello world")
|
||||
expect(result).toContain("line3: hello again")
|
||||
expect(result).not.toContain("foo bar")
|
||||
expect(result).not.toContain("baz qux")
|
||||
})
|
||||
|
||||
it("returns original output when pattern is undefined", () => {
|
||||
// #given
|
||||
const output = "some output"
|
||||
|
||||
// #when
|
||||
const result = applyGrepFilter(output, undefined)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(output)
|
||||
})
|
||||
|
||||
it("returns message when no lines match", () => {
|
||||
// #given
|
||||
const output = "line1\nline2\nline3"
|
||||
|
||||
// #when
|
||||
const result = applyGrepFilter(output, "xyz")
|
||||
|
||||
// #then
|
||||
expect(result).toContain("[grep] No lines matched pattern")
|
||||
})
|
||||
|
||||
it("handles invalid regex gracefully", () => {
|
||||
// #given
|
||||
const output = "some output"
|
||||
|
||||
// #when
|
||||
const result = applyGrepFilter(output, "[invalid")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(output)
|
||||
})
|
||||
})
|
||||
169
src/tools/skill-mcp/tools.ts
Normal file
169
src/tools/skill-mcp/tools.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { SKILL_MCP_DESCRIPTION } from "./constants"
|
||||
import type { SkillMcpArgs } from "./types"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
|
||||
interface SkillMcpToolOptions {
|
||||
manager: SkillMcpManager
|
||||
getLoadedSkills: () => LoadedSkill[]
|
||||
getSessionID: () => string
|
||||
}
|
||||
|
||||
type OperationType = { type: "tool" | "resource" | "prompt"; name: string }
|
||||
|
||||
function validateOperationParams(args: SkillMcpArgs): OperationType {
|
||||
const operations: OperationType[] = []
|
||||
if (args.tool_name) operations.push({ type: "tool", name: args.tool_name })
|
||||
if (args.resource_name) operations.push({ type: "resource", name: args.resource_name })
|
||||
if (args.prompt_name) operations.push({ type: "prompt", name: args.prompt_name })
|
||||
|
||||
if (operations.length === 0) {
|
||||
throw new Error(
|
||||
`Missing operation. Exactly one of tool_name, resource_name, or prompt_name must be specified.\n\n` +
|
||||
`Examples:\n` +
|
||||
` skill_mcp(mcp_name="sqlite", tool_name="query", arguments='{"sql": "SELECT * FROM users"}')\n` +
|
||||
` skill_mcp(mcp_name="memory", resource_name="memory://notes")\n` +
|
||||
` skill_mcp(mcp_name="helper", prompt_name="summarize", arguments='{"text": "..."}')`
|
||||
)
|
||||
}
|
||||
|
||||
if (operations.length > 1) {
|
||||
const provided = [
|
||||
args.tool_name && `tool_name="${args.tool_name}"`,
|
||||
args.resource_name && `resource_name="${args.resource_name}"`,
|
||||
args.prompt_name && `prompt_name="${args.prompt_name}"`,
|
||||
].filter(Boolean).join(", ")
|
||||
|
||||
throw new Error(
|
||||
`Multiple operations specified. Exactly one of tool_name, resource_name, or prompt_name must be provided.\n\n` +
|
||||
`Received: ${provided}\n\n` +
|
||||
`Use separate calls for each operation.`
|
||||
)
|
||||
}
|
||||
|
||||
return operations[0]
|
||||
}
|
||||
|
||||
function findMcpServer(
|
||||
mcpName: string,
|
||||
skills: LoadedSkill[]
|
||||
): { skill: LoadedSkill; config: NonNullable<LoadedSkill["mcpConfig"]>[string] } | null {
|
||||
for (const skill of skills) {
|
||||
if (skill.mcpConfig && mcpName in skill.mcpConfig) {
|
||||
return { skill, config: skill.mcpConfig[mcpName] }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatAvailableMcps(skills: LoadedSkill[]): string {
|
||||
const mcps: string[] = []
|
||||
for (const skill of skills) {
|
||||
if (skill.mcpConfig) {
|
||||
for (const serverName of Object.keys(skill.mcpConfig)) {
|
||||
mcps.push(` - "${serverName}" from skill "${skill.name}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return mcps.length > 0 ? mcps.join("\n") : " (none found)"
|
||||
}
|
||||
|
||||
function parseArguments(argsJson: string | undefined): Record<string, unknown> {
|
||||
if (!argsJson) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(argsJson)
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new Error("Arguments must be a JSON object")
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Invalid arguments JSON: ${errorMessage}\n\n` +
|
||||
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
|
||||
`Received: ${argsJson}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyGrepFilter(output: string, pattern: string | undefined): string {
|
||||
if (!pattern) return output
|
||||
try {
|
||||
const regex = new RegExp(pattern, "i")
|
||||
const lines = output.split("\n")
|
||||
const filtered = lines.filter(line => regex.test(line))
|
||||
return filtered.length > 0
|
||||
? filtered.join("\n")
|
||||
: `[grep] No lines matched pattern: ${pattern}`
|
||||
} catch {
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition {
|
||||
const { manager, getLoadedSkills, getSessionID } = options
|
||||
|
||||
return tool({
|
||||
description: SKILL_MCP_DESCRIPTION,
|
||||
args: {
|
||||
mcp_name: tool.schema.string().describe("Name of the MCP server from skill config"),
|
||||
tool_name: tool.schema.string().optional().describe("MCP tool to call"),
|
||||
resource_name: tool.schema.string().optional().describe("MCP resource URI to read"),
|
||||
prompt_name: tool.schema.string().optional().describe("MCP prompt to get"),
|
||||
arguments: tool.schema.string().optional().describe("JSON string of arguments"),
|
||||
grep: tool.schema.string().optional().describe("Regex pattern to filter output lines (only matching lines returned)"),
|
||||
},
|
||||
async execute(args: SkillMcpArgs) {
|
||||
const operation = validateOperationParams(args)
|
||||
const skills = getLoadedSkills()
|
||||
const found = findMcpServer(args.mcp_name, skills)
|
||||
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`MCP server "${args.mcp_name}" not found.\n\n` +
|
||||
`Available MCP servers in loaded skills:\n` +
|
||||
formatAvailableMcps(skills) + `\n\n` +
|
||||
`Hint: Load the skill first using the 'skill' tool, then call skill_mcp.`
|
||||
)
|
||||
}
|
||||
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: args.mcp_name,
|
||||
skillName: found.skill.name,
|
||||
sessionID: getSessionID(),
|
||||
}
|
||||
|
||||
const context: SkillMcpServerContext = {
|
||||
config: found.config,
|
||||
skillName: found.skill.name,
|
||||
}
|
||||
|
||||
const parsedArgs = parseArguments(args.arguments)
|
||||
|
||||
let output: string
|
||||
switch (operation.type) {
|
||||
case "tool": {
|
||||
const result = await manager.callTool(info, context, operation.name, parsedArgs)
|
||||
output = JSON.stringify(result, null, 2)
|
||||
break
|
||||
}
|
||||
case "resource": {
|
||||
const result = await manager.readResource(info, context, operation.name)
|
||||
output = JSON.stringify(result, null, 2)
|
||||
break
|
||||
}
|
||||
case "prompt": {
|
||||
const stringArgs: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(parsedArgs)) {
|
||||
stringArgs[key] = String(value)
|
||||
}
|
||||
const result = await manager.getPrompt(info, context, operation.name, stringArgs)
|
||||
output = JSON.stringify(result, null, 2)
|
||||
break
|
||||
}
|
||||
}
|
||||
return applyGrepFilter(output, args.grep)
|
||||
},
|
||||
})
|
||||
}
|
||||
8
src/tools/skill-mcp/types.ts
Normal file
8
src/tools/skill-mcp/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface SkillMcpArgs {
|
||||
mcp_name: string
|
||||
tool_name?: string
|
||||
resource_name?: string
|
||||
prompt_name?: string
|
||||
arguments?: string
|
||||
grep?: string
|
||||
}
|
||||
239
src/tools/skill/tools.test.ts
Normal file
239
src/tools/skill/tools.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
|
||||
import * as fs from "node:fs"
|
||||
import { createSkillTool } from "./tools"
|
||||
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const originalReadFileSync = fs.readFileSync.bind(fs)
|
||||
|
||||
mock.module("node:fs", () => ({
|
||||
...fs,
|
||||
readFileSync: (path: string, encoding?: string) => {
|
||||
if (typeof path === "string" && path.includes("/skills/")) {
|
||||
return `---
|
||||
description: Test skill description
|
||||
---
|
||||
Test skill body content`
|
||||
}
|
||||
return originalReadFileSync(path, encoding as BufferEncoding)
|
||||
},
|
||||
}))
|
||||
|
||||
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
|
||||
return {
|
||||
name,
|
||||
path: `/test/skills/${name}/SKILL.md`,
|
||||
resolvedPath: `/test/skills/${name}`,
|
||||
definition: {
|
||||
name,
|
||||
description: `Test skill ${name}`,
|
||||
template: "Test template",
|
||||
},
|
||||
scope: "opencode-project",
|
||||
mcpConfig: mcpServers as LoadedSkill["mcpConfig"],
|
||||
}
|
||||
}
|
||||
|
||||
const mockContext = {
|
||||
sessionID: "test-session",
|
||||
messageID: "msg-1",
|
||||
agent: "test-agent",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
describe("skill tool - MCP schema display", () => {
|
||||
let manager: SkillMcpManager
|
||||
let loadedSkills: LoadedSkill[]
|
||||
let sessionID: string
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SkillMcpManager()
|
||||
loadedSkills = []
|
||||
sessionID = "test-session-1"
|
||||
})
|
||||
|
||||
describe("formatMcpCapabilities with inputSchema", () => {
|
||||
it("displays tool inputSchema when available", async () => {
|
||||
// #given
|
||||
const mockToolsWithSchema: McpTool[] = [
|
||||
{
|
||||
name: "browser_type",
|
||||
description: "Type text into an element",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
element: { type: "string", description: "Human-readable element description" },
|
||||
ref: { type: "string", description: "Element reference from page snapshot" },
|
||||
text: { type: "string", description: "Text to type into the element" },
|
||||
submit: { type: "boolean", description: "Submit form after typing" },
|
||||
},
|
||||
required: ["element", "ref", "text"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("test-skill", {
|
||||
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
|
||||
}),
|
||||
]
|
||||
|
||||
// Mock manager.listTools to return our mock tools
|
||||
spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema)
|
||||
spyOn(manager, "listResources").mockResolvedValue([])
|
||||
spyOn(manager, "listPrompts").mockResolvedValue([])
|
||||
|
||||
const tool = createSkillTool({
|
||||
skills: loadedSkills,
|
||||
mcpManager: manager,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "test-skill" }, mockContext)
|
||||
|
||||
// #then
|
||||
// Should include inputSchema details
|
||||
expect(result).toContain("browser_type")
|
||||
expect(result).toContain("inputSchema")
|
||||
expect(result).toContain("element")
|
||||
expect(result).toContain("ref")
|
||||
expect(result).toContain("text")
|
||||
expect(result).toContain("submit")
|
||||
expect(result).toContain("required")
|
||||
})
|
||||
|
||||
it("displays multiple tools with their schemas", async () => {
|
||||
// #given
|
||||
const mockToolsWithSchema: McpTool[] = [
|
||||
{
|
||||
name: "browser_navigate",
|
||||
description: "Navigate to a URL",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "URL to navigate to" },
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "browser_click",
|
||||
description: "Click an element",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
element: { type: "string" },
|
||||
ref: { type: "string" },
|
||||
},
|
||||
required: ["element", "ref"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("playwright-skill", {
|
||||
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
|
||||
}),
|
||||
]
|
||||
|
||||
spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema)
|
||||
spyOn(manager, "listResources").mockResolvedValue([])
|
||||
spyOn(manager, "listPrompts").mockResolvedValue([])
|
||||
|
||||
const tool = createSkillTool({
|
||||
skills: loadedSkills,
|
||||
mcpManager: manager,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "playwright-skill" }, mockContext)
|
||||
|
||||
// #then
|
||||
expect(result).toContain("browser_navigate")
|
||||
expect(result).toContain("browser_click")
|
||||
expect(result).toContain("url")
|
||||
expect(result).toContain("Navigate to a URL")
|
||||
})
|
||||
|
||||
it("handles tools without inputSchema gracefully", async () => {
|
||||
// #given
|
||||
const mockToolsMinimal: McpTool[] = [
|
||||
{
|
||||
name: "simple_tool",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
]
|
||||
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("simple-skill", {
|
||||
simple: { command: "echo", args: ["test"] },
|
||||
}),
|
||||
]
|
||||
|
||||
spyOn(manager, "listTools").mockResolvedValue(mockToolsMinimal)
|
||||
spyOn(manager, "listResources").mockResolvedValue([])
|
||||
spyOn(manager, "listPrompts").mockResolvedValue([])
|
||||
|
||||
const tool = createSkillTool({
|
||||
skills: loadedSkills,
|
||||
mcpManager: manager,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "simple-skill" }, mockContext)
|
||||
|
||||
// #then
|
||||
expect(result).toContain("simple_tool")
|
||||
// Should not throw, should handle gracefully
|
||||
})
|
||||
|
||||
it("formats schema in a way LLM can understand for skill_mcp calls", async () => {
|
||||
// #given
|
||||
const mockTools: McpTool[] = [
|
||||
{
|
||||
name: "query",
|
||||
description: "Execute SQL query",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sql: { type: "string", description: "SQL query to execute" },
|
||||
params: { type: "array", description: "Query parameters" },
|
||||
},
|
||||
required: ["sql"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("db-skill", {
|
||||
sqlite: { command: "uvx", args: ["mcp-server-sqlite"] },
|
||||
}),
|
||||
]
|
||||
|
||||
spyOn(manager, "listTools").mockResolvedValue(mockTools)
|
||||
spyOn(manager, "listResources").mockResolvedValue([])
|
||||
spyOn(manager, "listPrompts").mockResolvedValue([])
|
||||
|
||||
const tool = createSkillTool({
|
||||
skills: loadedSkills,
|
||||
mcpManager: manager,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "db-skill" }, mockContext)
|
||||
|
||||
// #then
|
||||
// Should provide enough info for LLM to construct valid skill_mcp call
|
||||
expect(result).toContain("sqlite")
|
||||
expect(result).toContain("query")
|
||||
expect(result).toContain("sql")
|
||||
expect(result).toContain("required")
|
||||
expect(result).toMatch(/sql[\s\S]*string/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants
|
||||
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
|
||||
import { discoverSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
||||
return {
|
||||
@@ -49,6 +51,77 @@ function extractSkillBody(skill: LoadedSkill): string {
|
||||
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
|
||||
}
|
||||
|
||||
async function formatMcpCapabilities(
|
||||
skill: LoadedSkill,
|
||||
manager: SkillMcpManager,
|
||||
sessionID: string
|
||||
): Promise<string | null> {
|
||||
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sections: string[] = ["", "## Available MCP Servers", ""]
|
||||
|
||||
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName,
|
||||
skillName: skill.name,
|
||||
sessionID,
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config,
|
||||
skillName: skill.name,
|
||||
}
|
||||
|
||||
sections.push(`### ${serverName}`)
|
||||
sections.push("")
|
||||
|
||||
try {
|
||||
const [tools, resources, prompts] = await Promise.all([
|
||||
manager.listTools(info, context).catch(() => []),
|
||||
manager.listResources(info, context).catch(() => []),
|
||||
manager.listPrompts(info, context).catch(() => []),
|
||||
])
|
||||
|
||||
if (tools.length > 0) {
|
||||
sections.push("**Tools:**")
|
||||
sections.push("")
|
||||
for (const t of tools as Tool[]) {
|
||||
sections.push(`#### \`${t.name}\``)
|
||||
if (t.description) {
|
||||
sections.push(t.description)
|
||||
}
|
||||
sections.push("")
|
||||
sections.push("**inputSchema:**")
|
||||
sections.push("```json")
|
||||
sections.push(JSON.stringify(t.inputSchema, null, 2))
|
||||
sections.push("```")
|
||||
sections.push("")
|
||||
}
|
||||
}
|
||||
if (resources.length > 0) {
|
||||
sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
|
||||
}
|
||||
if (prompts.length > 0) {
|
||||
sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
|
||||
}
|
||||
|
||||
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
|
||||
sections.push("*No capabilities discovered*")
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
|
||||
}
|
||||
|
||||
sections.push("")
|
||||
sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
|
||||
sections.push("")
|
||||
}
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||
const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
|
||||
const skillInfos = skills.map(loadedSkillToInfo)
|
||||
@@ -75,13 +148,26 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
const body = extractSkillBody(skill)
|
||||
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
|
||||
|
||||
return [
|
||||
const output = [
|
||||
`## Skill: ${skill.name}`,
|
||||
"",
|
||||
`**Base directory**: ${dir}`,
|
||||
"",
|
||||
body,
|
||||
].join("\n")
|
||||
]
|
||||
|
||||
if (options.mcpManager && options.getSessionID && skill.mcpConfig) {
|
||||
const mcpInfo = await formatMcpCapabilities(
|
||||
skill,
|
||||
options.mcpManager,
|
||||
options.getSessionID()
|
||||
)
|
||||
if (mcpInfo) {
|
||||
output.push(mcpInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return output.join("\n")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
import type { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
|
||||
export interface SkillArgs {
|
||||
name: string
|
||||
@@ -20,4 +21,8 @@ export interface SkillLoadOptions {
|
||||
opencodeOnly?: boolean
|
||||
/** Pre-merged skills to use instead of discovering */
|
||||
skills?: LoadedSkill[]
|
||||
/** MCP manager for querying skill-embedded MCP servers */
|
||||
mcpManager?: SkillMcpManager
|
||||
/** Session ID getter for MCP client identification */
|
||||
getSessionID?: () => string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user