Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a711d58289 | ||
|
|
431ec14991 | ||
|
|
62cae8114d | ||
|
|
e6eafe267a | ||
|
|
e4ef832405 | ||
|
|
ef6d67645e | ||
|
|
227d93f106 | ||
|
|
edff922afb | ||
|
|
45bdcf3580 | ||
|
|
b07dd22093 | ||
|
|
c7d29fea48 | ||
|
|
55675497a5 | ||
|
|
ae2d347d81 |
12
README.ko.md
12
README.ko.md
@@ -142,7 +142,17 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
|
||||
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
|
||||
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
||||
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 디렉토리별 컨텍스트를 에이전트에게 제공합니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
|
||||
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # 프로젝트 전체 컨텍스트
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src 전용 컨텍스트
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # 컴포넌트 전용 컨텍스트
|
||||
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
|
||||
```
|
||||
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
|
||||
|
||||
### Agents
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -138,7 +138,17 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
||||
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
|
||||
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
|
||||
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
|
||||
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, providing directory-level context to the agent. Inspired by Claude Code's CLAUDE.md feature.
|
||||
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, collecting **all** `AGENTS.md` files along the path hierarchy. This enables nested, directory-specific instructions:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # Project-wide context
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src-specific context
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # Component-specific context
|
||||
│ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files
|
||||
```
|
||||
When reading `Button.tsx`, the hook injects contexts in order: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. Each directory's context is injected only once per session. Inspired by Claude Code's CLAUDE.md feature.
|
||||
|
||||
### Agents
|
||||
- **oracle** (`openai/gpt-5.1`): The architect. Expert in code reviews and strategy. Uses GPT-5.1 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
|
||||
|
||||
82
notepad.md
82
notepad.md
@@ -59,3 +59,85 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-08 18:56] - Task 1: Remove unused import formatWorkspaceEdit from LSP tools
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - simple import cleanup task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Removed only `formatWorkspaceEdit` from import list at line 17
|
||||
- Kept all other imports intact (formatCodeActions, applyWorkspaceEdit, formatApplyResult remain)
|
||||
- Verified the function exists in utils.ts:212 but is truly unused in tools.ts
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified for remaining tasks
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, bundled 200 modules
|
||||
- Ran: `rg "formatWorkspaceEdit" src/tools/lsp/tools.ts` → no matches (confirmed removal)
|
||||
|
||||
### LEARNINGS
|
||||
- Convention: This project uses `bun run typecheck` (tsc --noEmit) and `bun run build` for verification
|
||||
- The `formatWorkspaceEdit` function still exists in utils.ts - it's exported but just not used in tools.ts
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-08 19:00] - Task 2: Remove unused ThinkingPart interface and fallbackRevertStrategy function
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - both items were genuinely unused (no callers found)
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Removed `ThinkingPart` interface (lines 37-40) - defined but never referenced
|
||||
- Removed `fallbackRevertStrategy` function (lines 189-244) - defined but never called
|
||||
- Added comment explaining removal reason as per task requirements
|
||||
- Kept `ThinkingPartType`, `prependThinkingPart`, `stripThinkingParts` - these are different items and ARE used
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, bundled 200 modules
|
||||
- Ran: `rg "ThinkingPart" src/hooks/session-recovery/` → only related types/functions found, interface removed
|
||||
- Ran: `rg "fallbackRevertStrategy" src/hooks/session-recovery/` → only comment found, function removed
|
||||
- Ran: `rg "createSessionRecoveryHook" src/hooks/` → exports intact
|
||||
|
||||
### LEARNINGS
|
||||
- `ThinkingPart` interface vs `ThinkingPartType` type vs `prependThinkingPart` function - different entities, verify before removing
|
||||
- `fallbackRevertStrategy` was likely a planned feature that never got integrated into the recovery flow
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-08 19:04] - Task 3: Remove unused builtinMcps export from MCP module
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - `builtinMcps` export was genuinely unused (no external importers)
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Removed `export const builtinMcps = allBuiltinMcps` from line 24
|
||||
- Kept `allBuiltinMcps` const - used internally by `createBuiltinMcps` function
|
||||
- Kept `createBuiltinMcps` function - actively used in src/index.ts:89
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, bundled 200 modules
|
||||
- Ran: `rg "builtinMcps" src/mcp/index.ts` → no matches (export removed)
|
||||
- Ran: `rg "createBuiltinMcps" src/mcp/index.ts` → function still exists
|
||||
|
||||
### LEARNINGS
|
||||
- `createBuiltinMcps` function vs `builtinMcps` export - function is used, direct export is not
|
||||
- Internal const `allBuiltinMcps` should be kept since it's referenced by the function
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.1.28",
|
||||
"version": "0.1.30",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
74
src/hooks/anthropic-auto-compact/executor.ts
Normal file
74
src/hooks/anthropic-auto-compact/executor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { AutoCompactState } from "./types"
|
||||
|
||||
type Client = {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown>
|
||||
summarize: (opts: {
|
||||
path: { id: string }
|
||||
body: { providerID: string; modelID: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLastAssistant(
|
||||
sessionID: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const resp = await (client as Client).session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data
|
||||
if (!Array.isArray(data)) return null
|
||||
|
||||
const reversed = [...data].reverse()
|
||||
const last = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>
|
||||
const info = msg.info as Record<string, unknown> | undefined
|
||||
return info?.role === "assistant"
|
||||
})
|
||||
if (!last) return null
|
||||
return (last as { info?: Record<string, unknown> }).info ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
autoCompactState: AutoCompactState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const providerID = msg.providerID as string | undefined
|
||||
const modelID = msg.modelID as string | undefined
|
||||
|
||||
if (providerID && modelID) {
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).tui.submitPrompt({ query: { directory } })
|
||||
} catch {}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
autoCompactState.errorDataBySession.delete(sessionID)
|
||||
} catch {}
|
||||
}
|
||||
123
src/hooks/anthropic-auto-compact/index.ts
Normal file
123
src/hooks/anthropic-auto-compact/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
|
||||
function createAutoCompactState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
||||
const autoCompactState = createAutoCompactState()
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const parsed = parseAnthropicTokenLimitError(props?.error)
|
||||
if (parsed) {
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
const parsed = parseAnthropicTokenLimitError(info.error)
|
||||
if (parsed) {
|
||||
parsed.providerID = info.providerID as string | undefined
|
||||
parsed.modelID = info.modelID as string | undefined
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||
if (errorData?.providerID && errorData?.modelID) {
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
{ providerID: errorData.providerID, modelID: errorData.modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
if (!lastAssistant) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAssistant.summary === true) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (!lastAssistant.modelID || !lastAssistant.providerID) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(sessionID, lastAssistant, autoCompactState, ctx.client, ctx.directory)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { ParsedTokenLimitError } from "./types"
|
||||
|
||||
interface AnthropicErrorData {
|
||||
type: "error"
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
const TOKEN_LIMIT_PATTERNS = [
|
||||
/(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i,
|
||||
/prompt.*?(\d+).*?tokens.*?exceeds.*?(\d+)/i,
|
||||
/(\d+).*?tokens.*?limit.*?(\d+)/i,
|
||||
/context.*?length.*?(\d+).*?maximum.*?(\d+)/i,
|
||||
/max.*?context.*?(\d+).*?but.*?(\d+)/i,
|
||||
]
|
||||
|
||||
const TOKEN_LIMIT_KEYWORDS = [
|
||||
"prompt is too long",
|
||||
"is too long",
|
||||
"context_length_exceeded",
|
||||
"max_tokens",
|
||||
"token limit",
|
||||
"context length",
|
||||
"too many tokens",
|
||||
]
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
for (const pattern of TOKEN_LIMIT_PATTERNS) {
|
||||
const match = message.match(pattern)
|
||||
if (match) {
|
||||
const num1 = parseInt(match[1], 10)
|
||||
const num2 = parseInt(match[2], 10)
|
||||
return num1 > num2 ? { current: num1, max: num2 } : { current: num2, max: num1 }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||
if (typeof err === "string") {
|
||||
if (isTokenLimitError(err)) {
|
||||
const tokens = extractTokensFromMessage(err)
|
||||
return {
|
||||
currentTokens: tokens?.current ?? 0,
|
||||
maxTokens: tokens?.max ?? 0,
|
||||
errorType: "token_limit_exceeded_string",
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!err || typeof err !== "object") return null
|
||||
|
||||
const errObj = err as Record<string, unknown>
|
||||
|
||||
const dataObj = errObj.data as Record<string, unknown> | undefined
|
||||
const responseBody = dataObj?.responseBody
|
||||
const errorMessage = errObj.message as string | undefined
|
||||
const errorData = errObj.error as Record<string, unknown> | undefined
|
||||
const nestedError = errorData?.error as Record<string, unknown> | undefined
|
||||
|
||||
const textSources: string[] = []
|
||||
|
||||
if (typeof responseBody === "string") textSources.push(responseBody)
|
||||
if (typeof errorMessage === "string") textSources.push(errorMessage)
|
||||
if (typeof errorData?.message === "string") textSources.push(errorData.message as string)
|
||||
if (typeof errObj.body === "string") textSources.push(errObj.body as string)
|
||||
if (typeof errObj.details === "string") textSources.push(errObj.details as string)
|
||||
if (typeof errObj.reason === "string") textSources.push(errObj.reason as string)
|
||||
if (typeof errObj.description === "string") textSources.push(errObj.description as string)
|
||||
if (typeof nestedError?.message === "string") textSources.push(nestedError.message as string)
|
||||
if (typeof dataObj?.message === "string") textSources.push(dataObj.message as string)
|
||||
if (typeof dataObj?.error === "string") textSources.push(dataObj.error as string)
|
||||
|
||||
if (textSources.length === 0) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(errObj)
|
||||
if (isTokenLimitError(jsonStr)) {
|
||||
textSources.push(jsonStr)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const combinedText = textSources.join(" ")
|
||||
if (!isTokenLimitError(combinedText)) return null
|
||||
|
||||
if (typeof responseBody === "string") {
|
||||
try {
|
||||
const jsonPatterns = [
|
||||
/data:\s*(\{[\s\S]*?\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
|
||||
/(\{[\s\S]*?"error"[\s\S]*?\})/,
|
||||
]
|
||||
|
||||
for (const pattern of jsonPatterns) {
|
||||
const dataMatch = responseBody.match(pattern)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
const jsonData: AnthropicErrorData = JSON.parse(dataMatch[1])
|
||||
const message = jsonData.error?.message || ""
|
||||
const tokens = extractTokensFromMessage(message)
|
||||
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
requestId: jsonData.request_id,
|
||||
errorType: jsonData.error?.type || "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const bedrockJson = JSON.parse(responseBody)
|
||||
if (typeof bedrockJson.message === "string" && isTokenLimitError(bedrockJson.message)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "bedrock_input_too_long",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const text of textSources) {
|
||||
const tokens = extractTokensFromMessage(text)
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
errorType: "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTokenLimitError(combinedText)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "token_limit_exceeded_unknown",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
13
src/hooks/anthropic-auto-compact/types.ts
Normal file
13
src/hooks/anthropic-auto-compact/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface ParsedTokenLimitError {
|
||||
currentTokens: number
|
||||
maxTokens: number
|
||||
requestId?: string
|
||||
errorType: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
}
|
||||
@@ -15,36 +15,13 @@ function debugLog(...args: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function getPlatformPackageName(): string | null {
|
||||
const platform = process.platform as Platform
|
||||
const arch = process.arch
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
"darwin-arm64": "@code-yeongyu/comment-checker-darwin-arm64",
|
||||
"darwin-x64": "@code-yeongyu/comment-checker-darwin-x64",
|
||||
"linux-arm64": "@code-yeongyu/comment-checker-linux-arm64",
|
||||
"linux-x64": "@code-yeongyu/comment-checker-linux-x64",
|
||||
"win32-x64": "@code-yeongyu/comment-checker-windows-x64",
|
||||
}
|
||||
|
||||
return platformMap[`${platform}-${arch}`] ?? null
|
||||
}
|
||||
|
||||
function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously find comment-checker binary path.
|
||||
* Checks installed packages, homebrew, cache, and system PATH.
|
||||
* Does NOT trigger download.
|
||||
*/
|
||||
function findCommentCheckerPathSync(): string | null {
|
||||
const binaryName = getBinaryName()
|
||||
|
||||
// 1. Try to find from @code-yeongyu/comment-checker package
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
|
||||
@@ -59,46 +36,12 @@ function findCommentCheckerPathSync(): string | null {
|
||||
debugLog("main package not installed")
|
||||
}
|
||||
|
||||
// 2. Try platform-specific package directly (legacy, for backwards compatibility)
|
||||
const platformPkg = getPlatformPackageName()
|
||||
if (platformPkg) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkgPath = require.resolve(`${platformPkg}/package.json`)
|
||||
const pkgDir = dirname(pkgPath)
|
||||
const binaryPath = join(pkgDir, "bin", binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
debugLog("found binary in platform package:", binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
debugLog("platform package not installed:", platformPkg)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try homebrew installation (macOS)
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = [
|
||||
"/opt/homebrew/bin/comment-checker",
|
||||
"/usr/local/bin/comment-checker",
|
||||
]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path)) {
|
||||
debugLog("found binary via homebrew:", path)
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try cached binary (lazy download location)
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("found binary in cache:", cachedPath)
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
// 5. Try system PATH (as fallback)
|
||||
debugLog("no binary found in known locations")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ PRIORITY-BASED ACTION GUIDELINES:
|
||||
\t-> Make the code itself clearer so it can be understood without comments/docstrings.
|
||||
\t-> For verbose docstrings: refactor code to be self-documenting instead of adding lengthy explanations.
|
||||
|
||||
CODE SMELL WARNING: Using comments as visual separators (e.g., "// =========", "# ---", "// *** Section ***")
|
||||
is a code smell. If you need separators, your file is too long or poorly organized.
|
||||
Refactor into smaller modules or use proper code organization instead of comment-based section dividers.
|
||||
|
||||
MANDATORY REQUIREMENT: You must acknowledge this hook message and take one of the above actions.
|
||||
Review in the above priority order and take the corresponding action EVERY TIME this appears.
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ export { createSessionNotification } from "./session-notification";
|
||||
export { createSessionRecoveryHook } from "./session-recovery";
|
||||
export { createCommentCheckerHooks } from "./comment-checker";
|
||||
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
|
||||
export { createPulseMonitorHook } from "./pulse-monitor";
|
||||
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
|
||||
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
||||
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export function createPulseMonitorHook(ctx: PluginInput) {
|
||||
const STANDARD_TIMEOUT = 5 * 60 * 1000 // 5 minutes
|
||||
const THINKING_TIMEOUT = 5 * 60 * 1000 // 5 minutes
|
||||
const CHECK_INTERVAL = 5 * 1000 // 5 seconds
|
||||
|
||||
let lastHeartbeat = Date.now()
|
||||
let isMonitoring = false
|
||||
let currentSessionID: string | null = null
|
||||
let monitorTimer: ReturnType<typeof setInterval> | null = null
|
||||
let isThinking = false
|
||||
|
||||
const startMonitoring = (sessionID: string) => {
|
||||
if (currentSessionID !== sessionID) {
|
||||
currentSessionID = sessionID
|
||||
// Reset thinking state when switching sessions or starting new
|
||||
isThinking = false
|
||||
}
|
||||
|
||||
lastHeartbeat = Date.now()
|
||||
|
||||
if (!isMonitoring) {
|
||||
isMonitoring = true
|
||||
if (monitorTimer) clearInterval(monitorTimer)
|
||||
|
||||
monitorTimer = setInterval(async () => {
|
||||
if (!isMonitoring || !currentSessionID) return
|
||||
|
||||
const timeSinceLastHeartbeat = Date.now() - lastHeartbeat
|
||||
const currentTimeout = isThinking ? THINKING_TIMEOUT : STANDARD_TIMEOUT
|
||||
|
||||
if (timeSinceLastHeartbeat > currentTimeout) {
|
||||
await recoverStalledSession(currentSessionID, timeSinceLastHeartbeat, isThinking)
|
||||
}
|
||||
}, CHECK_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
const stopMonitoring = () => {
|
||||
isMonitoring = false
|
||||
if (monitorTimer) {
|
||||
clearInterval(monitorTimer)
|
||||
monitorTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const updateHeartbeat = (isThinkingUpdate?: boolean) => {
|
||||
if (isMonitoring) {
|
||||
lastHeartbeat = Date.now()
|
||||
if (isThinkingUpdate !== undefined) {
|
||||
isThinking = isThinkingUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recoverStalledSession = async (sessionID: string, stalledDuration: number, wasThinking: boolean) => {
|
||||
stopMonitoring()
|
||||
|
||||
try {
|
||||
const durationSec = Math.round(stalledDuration/1000)
|
||||
const typeStr = wasThinking ? "Thinking" : "Standard"
|
||||
|
||||
// 1. Notify User
|
||||
await ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "Pulse Monitor: Cardiac Arrest",
|
||||
message: `Session stalled (${typeStr}) for ${durationSec}s. Defibrillating...`,
|
||||
variant: "error",
|
||||
duration: 5000
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
// 2. Abort current generation (Defibrillation shock)
|
||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
|
||||
// 3. Wait a bit for state to settle
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// 4. Prompt "continue" to kickstart (CPR)
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "The connection was unstable and stalled. Please continue from where you left off." }] },
|
||||
query: { directory: ctx.directory }
|
||||
})
|
||||
|
||||
// Resume monitoring
|
||||
startMonitoring(sessionID)
|
||||
|
||||
} catch (err) {
|
||||
console.error("[PulseMonitor] Recovery failed:", err)
|
||||
// If recovery fails, we stop monitoring to avoid loops
|
||||
stopMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: async (input: { event: any }) => {
|
||||
const { event } = input
|
||||
const props = event.properties as Record<string, any> | undefined
|
||||
|
||||
// Monitor both session updates and part updates to capture token flow
|
||||
if (event.type === "session.updated" || event.type === "message.part.updated") {
|
||||
// Try to get sessionID from various common locations
|
||||
const sessionID = props?.info?.id || props?.sessionID
|
||||
|
||||
if (sessionID) {
|
||||
if (!isMonitoring) startMonitoring(sessionID)
|
||||
|
||||
// Check for thinking indicators in the payload
|
||||
let thinkingUpdate: boolean | undefined = undefined
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = props?.part
|
||||
if (part) {
|
||||
const THINKING_TYPES = ["thinking", "redacted_thinking", "reasoning"]
|
||||
if (THINKING_TYPES.includes(part.type)) {
|
||||
thinkingUpdate = true
|
||||
} else if (part.type === "text" || part.type === "tool_use") {
|
||||
thinkingUpdate = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateHeartbeat(thinkingUpdate)
|
||||
}
|
||||
} else if (event.type === "session.idle" || event.type === "session.error" || event.type === "session.stopped") {
|
||||
stopMonitoring()
|
||||
}
|
||||
},
|
||||
"tool.execute.before": async () => {
|
||||
// Pause monitoring while tool runs locally (tools can take time)
|
||||
stopMonitoring()
|
||||
},
|
||||
"tool.execute.after": async (_input: { sessionID: string }) => {
|
||||
// Don't forcefully restart monitoring here to avoid false positives
|
||||
// Monitoring will naturally resume when next session/message event arrives
|
||||
// This prevents stalled detection on legitimately idle sessions
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,6 @@ interface ToolUsePart {
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ThinkingPart {
|
||||
type: "thinking"
|
||||
thinking: string
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
id?: string
|
||||
@@ -186,62 +181,10 @@ async function recoverEmptyContentMessage(
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
async function fallbackRevertStrategy(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
const parentMsgID = failedAssistantMsg.info?.parentID
|
||||
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
if (!msgs || msgs.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let targetUserMsg: MessageData | null = null
|
||||
if (parentMsgID) {
|
||||
targetUserMsg = msgs.find((m) => m.info?.id === parentMsgID) ?? null
|
||||
}
|
||||
if (!targetUserMsg) {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].info?.role === "user") {
|
||||
targetUserMsg = msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUserMsg?.parts?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: targetUserMsg.info?.id ?? "" },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const textParts = targetUserMsg.parts
|
||||
.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => ({ type: "text" as const, text: p.text ?? "" }))
|
||||
|
||||
if (textParts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: textParts },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
// NOTE: fallbackRevertStrategy was removed (2025-12-08)
|
||||
// Reason: Function was defined but never called - no error recovery paths used it.
|
||||
// All error types have dedicated recovery functions (recoverToolResultMissing,
|
||||
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
|
||||
|
||||
export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
const processingErrors = new Set<string>()
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
createSessionRecoveryHook,
|
||||
createCommentCheckerHooks,
|
||||
createGrepOutputTruncatorHook,
|
||||
createPulseMonitorHook,
|
||||
createDirectoryAgentsInjectorHook,
|
||||
createEmptyTaskResponseDetectorHook,
|
||||
} from "./hooks";
|
||||
@@ -54,7 +53,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
|
||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
|
||||
const sessionRecovery = createSessionRecoveryHook(ctx);
|
||||
const pulseMonitor = createPulseMonitorHook(ctx);
|
||||
const commentChecker = createCommentCheckerHooks();
|
||||
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
|
||||
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
|
||||
@@ -93,7 +91,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
event: async (input) => {
|
||||
await todoContinuationEnforcer(input);
|
||||
await contextWindowMonitor.event(input);
|
||||
await pulseMonitor.event(input);
|
||||
await directoryAgentsInjector.event(input);
|
||||
|
||||
const { event } = input;
|
||||
@@ -194,7 +191,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
},
|
||||
|
||||
"tool.execute.before": async (input, output) => {
|
||||
await pulseMonitor["tool.execute.before"]();
|
||||
await commentChecker["tool.execute.before"](input, output);
|
||||
|
||||
if (input.sessionID === mainSessionID) {
|
||||
@@ -209,7 +205,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
await pulseMonitor["tool.execute.after"](input);
|
||||
await grepOutputTruncator["tool.execute.after"](input, output);
|
||||
await contextWindowMonitor["tool.execute.after"](input, output);
|
||||
await commentChecker["tool.execute.after"](input, output);
|
||||
|
||||
@@ -20,5 +20,3 @@ export function createBuiltinMcps(disabledMcps: McpName[] = []) {
|
||||
|
||||
return mcps
|
||||
}
|
||||
|
||||
export const builtinMcps = allBuiltinMcps
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./constants"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { analyzeCode, transformCode, getRootInfo } from "./napi"
|
||||
import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult } from "./utils"
|
||||
import type { CliLanguage, NapiLanguage } from "./types"
|
||||
import { formatSearchResult, formatReplaceResult } from "./utils"
|
||||
import type { CliLanguage } from "./types"
|
||||
|
||||
function showOutputToUser(context: unknown, output: string): void {
|
||||
const ctx = context as { metadata?: (input: { metadata: { output: string } }) => void }
|
||||
@@ -110,83 +109,4 @@ export const ast_grep_replace = tool({
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_languages = tool({
|
||||
description:
|
||||
"List all supported languages for ast-grep tools with their file extensions. " +
|
||||
"Use this to determine valid language options.",
|
||||
args: {},
|
||||
execute: async (_args, context) => {
|
||||
const lines: string[] = [`Supported Languages (${CLI_LANGUAGES.length}):`]
|
||||
for (const lang of CLI_LANGUAGES) {
|
||||
const exts = LANG_EXTENSIONS[lang]?.join(", ") || ""
|
||||
lines.push(` ${lang}: ${exts}`)
|
||||
}
|
||||
lines.push("")
|
||||
lines.push(`NAPI (in-memory) languages: ${NAPI_LANGUAGES.join(", ")}`)
|
||||
const output = lines.join("\n")
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_analyze = tool({
|
||||
description:
|
||||
"Parse code and extract AST structure with pattern matching (in-memory). " +
|
||||
"Extracts meta-variable bindings. Only for: html, javascript, tsx, css, typescript. " +
|
||||
"Use for detailed code analysis without file I/O.",
|
||||
args: {
|
||||
code: tool.schema.string().describe("Source code to analyze"),
|
||||
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language (html, javascript, tsx, css, typescript)"),
|
||||
pattern: tool.schema.string().optional().describe("Pattern to find (omit for root structure)"),
|
||||
extractMetaVars: tool.schema.boolean().optional().describe("Extract meta-variable bindings (default: true)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
if (!args.pattern) {
|
||||
const info = getRootInfo(args.code, args.lang as NapiLanguage)
|
||||
const output = `Root kind: ${info.kind}\nChildren: ${info.childCount}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
|
||||
const results = analyzeCode(args.code, args.lang as NapiLanguage, args.pattern, args.extractMetaVars !== false)
|
||||
const output = formatAnalyzeResult(results, args.extractMetaVars !== false)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_transform = tool({
|
||||
description:
|
||||
"Transform code in-memory using AST-aware rewriting. " +
|
||||
"Only for: html, javascript, tsx, css, typescript. " +
|
||||
"Returns transformed code without writing to filesystem.",
|
||||
args: {
|
||||
code: tool.schema.string().describe("Source code to transform"),
|
||||
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language"),
|
||||
pattern: tool.schema.string().describe("Pattern to match"),
|
||||
rewrite: tool.schema.string().describe("Replacement (can use $VAR from pattern)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const { transformed, editCount } = transformCode(
|
||||
args.code,
|
||||
args.lang as NapiLanguage,
|
||||
args.pattern,
|
||||
args.rewrite
|
||||
)
|
||||
const output = formatTransformResult(args.code, transformed, editCount)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
formatDiagnostic,
|
||||
filterDiagnosticsBySeverity,
|
||||
formatPrepareRenameResult,
|
||||
formatWorkspaceEdit,
|
||||
formatCodeActions,
|
||||
applyWorkspaceEdit,
|
||||
formatApplyResult,
|
||||
|
||||
Reference in New Issue
Block a user