Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda7b4f56d | ||
|
|
a287e59262 | ||
|
|
80fe3ae612 | ||
|
|
b045f6918e | ||
|
|
725ec9b91d | ||
|
|
1f717a76be | ||
|
|
3bcb869a5d | ||
|
|
54e13e4330 | ||
|
|
1780e2971d | ||
|
|
ded97701b8 | ||
|
|
316cdc1a62 | ||
|
|
f19cd8fc71 | ||
|
|
181194ae3c | ||
|
|
b8f5599e61 | ||
|
|
ea2b09ebb9 | ||
|
|
143dd8aaa9 | ||
|
|
36169c83fb |
@@ -136,6 +136,11 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
|
||||
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
|
||||
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
|
||||
- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다:
|
||||
- **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입
|
||||
- **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가
|
||||
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
|
||||
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
|
||||
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
||||
|
||||
### Agents
|
||||
|
||||
@@ -132,7 +132,11 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
||||
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
||||
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
|
||||
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
||||
- **Session Recovery**: Automatically recovers from API errors by injecting missing tool results and correcting thinking block violations, ensuring session stability.
|
||||
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
|
||||
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
|
||||
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
|
||||
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
|
||||
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
|
||||
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
|
||||
|
||||
### Agents
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -9,6 +9,7 @@
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -102,6 +103,8 @@
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.25",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -46,6 +46,7 @@
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,14 +12,21 @@
|
||||
* - Recovery: strip thinking/redacted_thinking blocks
|
||||
*
|
||||
* 4. Empty content message (non-empty content required)
|
||||
* - Recovery: delete the empty message via revert
|
||||
* - Recovery: inject text part directly via filesystem
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { xdgData } from "xdg-basedir"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null
|
||||
|
||||
interface MessageInfo {
|
||||
@@ -70,7 +77,10 @@ function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
if (message.includes("thinking") && message.includes("first block")) {
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
|
||||
@@ -212,6 +222,140 @@ async function recoverThinkingDisabledViolation(
|
||||
}
|
||||
|
||||
const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
|
||||
const META_TYPES = new Set(["step-start", "step-finish"])
|
||||
|
||||
interface StoredMessageMeta {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: string
|
||||
parentID?: string
|
||||
}
|
||||
|
||||
interface StoredPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
function generatePartId(): string {
|
||||
const timestamp = Date.now().toString(16)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `prt_${timestamp}${random}`
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string {
|
||||
const projectHash = readdirSync(MESSAGE_STORAGE).find((dir) => {
|
||||
const sessionDir = join(MESSAGE_STORAGE, dir)
|
||||
try {
|
||||
return readdirSync(sessionDir).some((f) => f.includes(sessionID.replace("ses_", "")))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (projectHash) {
|
||||
return join(MESSAGE_STORAGE, projectHash, sessionID)
|
||||
}
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) {
|
||||
return sessionPath
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function readMessagesFromStorage(sessionID: string): StoredMessageMeta[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir || !existsSync(messageDir)) return []
|
||||
|
||||
const messages: StoredMessageMeta[] = []
|
||||
for (const file of readdirSync(messageDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
messages.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return messages.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
function readPartsFromStorage(messageID: string): StoredPart[] {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return []
|
||||
|
||||
const parts: StoredPart[] = []
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(partDir, file), "utf-8")
|
||||
parts.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function injectTextPartToStorage(sessionID: string, messageID: string, text: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
const partId = generatePartId()
|
||||
const part: StoredPart = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function findEmptyContentMessageFromStorage(sessionID: string): string | null {
|
||||
const messages = readMessagesFromStorage(sessionID)
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
|
||||
const parts = readPartsFromStorage(msg.id)
|
||||
const hasContent = parts.some((p) => {
|
||||
if (THINKING_TYPES.has(p.type)) return false
|
||||
if (META_TYPES.has(p.type)) return false
|
||||
if (p.type === "text" && p.text?.trim()) return true
|
||||
if (p.type === "tool_use") return true
|
||||
if (p.type === "tool_result") return true
|
||||
return false
|
||||
})
|
||||
|
||||
if (!hasContent && parts.length > 0) {
|
||||
return msg.id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function hasNonEmptyOutput(msg: MessageData): boolean {
|
||||
const parts = msg.parts
|
||||
@@ -243,65 +387,15 @@ function findEmptyContentMessage(msgs: MessageData[]): MessageData | null {
|
||||
}
|
||||
|
||||
async function recoverEmptyContentMessage(
|
||||
client: Client,
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
_directory: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
const emptyMessageID = findEmptyContentMessageFromStorage(sessionID) || failedAssistantMsg.info?.id
|
||||
if (!emptyMessageID) return false
|
||||
|
||||
if (!msgs || msgs.length === 0) return false
|
||||
|
||||
const emptyMsg = findEmptyContentMessage(msgs) || failedAssistantMsg
|
||||
const messageID = emptyMsg.info?.id
|
||||
if (!messageID) return false
|
||||
|
||||
const existingParts = emptyMsg.parts || []
|
||||
const hasOnlyThinkingOrMeta = existingParts.length > 0 && existingParts.every(
|
||||
(p) => THINKING_TYPES.has(p.type) || p.type === "step-start" || p.type === "step-finish"
|
||||
)
|
||||
|
||||
if (hasOnlyThinkingOrMeta) {
|
||||
const strippedParts: MessagePart[] = [{ type: "text", text: "(interrupted)" }]
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.message?.update?.({
|
||||
path: { id: messageID },
|
||||
body: { parts: strippedParts },
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// message.update not available
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.session.patch?.({
|
||||
path: { id: sessionID },
|
||||
body: { messageID, parts: strippedParts },
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// session.patch not available
|
||||
}
|
||||
}
|
||||
|
||||
const revertTargetID = emptyMsg.info?.parentID || messageID
|
||||
await client.session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: revertTargetID },
|
||||
query: { directory },
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return injectTextPartToStorage(sessionID, emptyMessageID, "(interrupted)")
|
||||
}
|
||||
|
||||
async function fallbackRevertStrategy(
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@@ -136,7 +136,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
sessionID,
|
||||
error,
|
||||
}
|
||||
await sessionRecovery.handleSessionRecovery(messageInfo)
|
||||
const recovered = await sessionRecovery.handleSessionRecovery(messageInfo)
|
||||
|
||||
if (recovered && sessionID && sessionID === mainSessionID) {
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: ctx.directory },
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionID && sessionID === mainSessionID) {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { spawn } from "bun"
|
||||
import { SG_CLI_PATH } from "./constants"
|
||||
import type { CliMatch, CliLanguage } from "./types"
|
||||
import { existsSync } from "fs"
|
||||
import {
|
||||
getSgCliPath,
|
||||
setSgCliPath,
|
||||
findSgCliPathSync,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
DEFAULT_MAX_MATCHES,
|
||||
} from "./constants"
|
||||
import { ensureAstGrepBinary } from "./downloader"
|
||||
import type { CliMatch, CliLanguage, SgResult } from "./types"
|
||||
|
||||
export interface RunOptions {
|
||||
pattern: string
|
||||
@@ -12,7 +21,47 @@ export interface RunOptions {
|
||||
updateAll?: boolean
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<CliMatch[]> {
|
||||
let resolvedCliPath: string | null = null
|
||||
let initPromise: Promise<string | null> | null = null
|
||||
|
||||
export async function getAstGrepPath(): Promise<string | null> {
|
||||
if (resolvedCliPath !== null && existsSync(resolvedCliPath)) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
if (initPromise) {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
const syncPath = findSgCliPathSync()
|
||||
if (syncPath && existsSync(syncPath)) {
|
||||
resolvedCliPath = syncPath
|
||||
setSgCliPath(syncPath)
|
||||
return syncPath
|
||||
}
|
||||
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
return downloadedPath
|
||||
}
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
export function startBackgroundInit(): void {
|
||||
if (!initPromise) {
|
||||
initPromise = getAstGrepPath()
|
||||
initPromise.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
||||
|
||||
if (options.rewrite) {
|
||||
@@ -35,32 +84,147 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
|
||||
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
|
||||
args.push(...paths)
|
||||
|
||||
const proc = spawn([SG_CLI_PATH, ...args], {
|
||||
let cliPath = getSgCliPath()
|
||||
|
||||
if (!existsSync(cliPath) && cliPath !== "sg") {
|
||||
const downloadedPath = await getAstGrepPath()
|
||||
if (downloadedPath) {
|
||||
cliPath = downloadedPath
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = DEFAULT_TIMEOUT_MS
|
||||
|
||||
const proc = spawn([cliPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error(`Search timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
proc.exited.then(() => clearTimeout(id))
|
||||
})
|
||||
|
||||
let stdout: string
|
||||
let stderr: string
|
||||
let exitCode: number
|
||||
|
||||
try {
|
||||
stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||
stderr = await new Response(proc.stderr).text()
|
||||
exitCode = await proc.exited
|
||||
} catch (e) {
|
||||
const error = e as Error
|
||||
if (error.message?.includes("timeout")) {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "timeout",
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
const nodeError = e as NodeJS.ErrnoException
|
||||
if (
|
||||
nodeError.code === "ENOENT" ||
|
||||
nodeError.message?.includes("ENOENT") ||
|
||||
nodeError.message?.includes("not found")
|
||||
) {
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
return runSg(options)
|
||||
} else {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error:
|
||||
`ast-grep CLI binary not found.\n\n` +
|
||||
`Auto-download failed. Manual install options:\n` +
|
||||
` bun add -D @ast-grep/cli\n` +
|
||||
` cargo install ast-grep --locked\n` +
|
||||
` brew install ast-grep`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error: `Failed to spawn ast-grep: ${error.message}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && stdout.trim() === "") {
|
||||
if (stderr.includes("No files found")) {
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
if (stderr.trim()) {
|
||||
throw new Error(stderr.trim())
|
||||
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
|
||||
}
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||
const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||
|
||||
let matches: CliMatch[] = []
|
||||
try {
|
||||
return JSON.parse(stdout) as CliMatch[]
|
||||
matches = JSON.parse(outputToProcess) as CliMatch[]
|
||||
} catch {
|
||||
return []
|
||||
if (outputTruncated) {
|
||||
try {
|
||||
const lastValidIndex = outputToProcess.lastIndexOf("}")
|
||||
if (lastValidIndex > 0) {
|
||||
const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
|
||||
if (bracketIndex > 0) {
|
||||
const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
|
||||
matches = JSON.parse(truncatedJson) as CliMatch[]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "max_output_bytes",
|
||||
error: "Output too large and could not be parsed",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
|
||||
const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
|
||||
|
||||
return {
|
||||
matches: finalMatches,
|
||||
totalMatches,
|
||||
truncated: outputTruncated || matchesTruncated,
|
||||
truncatedReason: outputTruncated ? "max_output_bytes" : matchesTruncated ? "max_matches" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCliAvailable(): boolean {
|
||||
const path = findSgCliPathSync()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
export async function ensureCliAvailable(): Promise<boolean> {
|
||||
const path = await getAstGrepPath()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { getCachedBinaryPath } from "./downloader"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function isValidBinary(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).size > 10000
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatformPackageName(): string | null {
|
||||
const platform = process.platform as Platform
|
||||
const arch = process.arch
|
||||
@@ -21,32 +30,37 @@ function getPlatformPackageName(): string | null {
|
||||
return platformMap[`${platform}-${arch}`] ?? null
|
||||
}
|
||||
|
||||
function findSgCliPath(): string {
|
||||
// 1. Try to find from @ast-grep/cli package (installed via npm)
|
||||
export function findSgCliPathSync(): string | null {
|
||||
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath && isValidBinary(cachedPath)) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@ast-grep/cli/package.json")
|
||||
const cliDir = dirname(cliPkgPath)
|
||||
const sgPath = join(cliDir, process.platform === "win32" ? "sg.exe" : "sg")
|
||||
const sgPath = join(cliDir, binaryName)
|
||||
|
||||
if (existsSync(sgPath)) {
|
||||
if (existsSync(sgPath) && isValidBinary(sgPath)) {
|
||||
return sgPath
|
||||
}
|
||||
} catch {
|
||||
// @ast-grep/cli not installed, try platform-specific package
|
||||
// @ast-grep/cli not installed
|
||||
}
|
||||
|
||||
// 2. Try platform-specific package directly
|
||||
const platformPkg = getPlatformPackageName()
|
||||
if (platformPkg) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkgPath = require.resolve(`${platformPkg}/package.json`)
|
||||
const pkgDir = dirname(pkgPath)
|
||||
const binaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
||||
const binaryPath = join(pkgDir, binaryName)
|
||||
const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
||||
const binaryPath = join(pkgDir, astGrepName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
@@ -54,12 +68,39 @@ function findSgCliPath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to system PATH
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path) && isValidBinary(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
let resolvedCliPath: string | null = null
|
||||
|
||||
export function getSgCliPath(): string {
|
||||
if (resolvedCliPath !== null) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
const syncPath = findSgCliPathSync()
|
||||
if (syncPath) {
|
||||
resolvedCliPath = syncPath
|
||||
return syncPath
|
||||
}
|
||||
|
||||
return "sg"
|
||||
}
|
||||
|
||||
// ast-grep CLI path (auto-detected from node_modules or system PATH)
|
||||
export const SG_CLI_PATH = findSgCliPath()
|
||||
export function setSgCliPath(path: string): void {
|
||||
resolvedCliPath = path
|
||||
}
|
||||
|
||||
export const SG_CLI_PATH = getSgCliPath()
|
||||
|
||||
// CLI supported languages (25 total)
|
||||
export const CLI_LANGUAGES = [
|
||||
@@ -94,6 +135,10 @@ export const CLI_LANGUAGES = [
|
||||
export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const
|
||||
|
||||
// Language to file extensions mapping
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
export const DEFAULT_MAX_MATCHES = 500
|
||||
|
||||
export const LANG_EXTENSIONS: Record<string, string[]> = {
|
||||
bash: [".bash", ".sh", ".zsh", ".bats"],
|
||||
c: [".c", ".h"],
|
||||
@@ -121,3 +166,99 @@ export const LANG_EXTENSIONS: Record<string, string[]> = {
|
||||
tsx: [".tsx"],
|
||||
yaml: [".yml", ".yaml"],
|
||||
}
|
||||
|
||||
export interface EnvironmentCheckResult {
|
||||
cli: {
|
||||
available: boolean
|
||||
path: string
|
||||
error?: string
|
||||
}
|
||||
napi: {
|
||||
available: boolean
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ast-grep CLI and NAPI are available.
|
||||
* Call this at startup to provide early feedback about missing dependencies.
|
||||
*/
|
||||
export function checkEnvironment(): EnvironmentCheckResult {
|
||||
const result: EnvironmentCheckResult = {
|
||||
cli: {
|
||||
available: false,
|
||||
path: SG_CLI_PATH,
|
||||
},
|
||||
napi: {
|
||||
available: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Check CLI availability
|
||||
if (existsSync(SG_CLI_PATH)) {
|
||||
result.cli.available = true
|
||||
} else if (SG_CLI_PATH === "sg") {
|
||||
// Fallback path - try which/where to find in PATH
|
||||
try {
|
||||
const { spawnSync } = require("child_process")
|
||||
const whichResult = spawnSync(process.platform === "win32" ? "where" : "which", ["sg"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
})
|
||||
result.cli.available = whichResult.status === 0 && !!whichResult.stdout?.trim()
|
||||
if (!result.cli.available) {
|
||||
result.cli.error = "sg binary not found in PATH"
|
||||
}
|
||||
} catch {
|
||||
result.cli.error = "Failed to check sg availability"
|
||||
}
|
||||
} else {
|
||||
result.cli.error = `Binary not found: ${SG_CLI_PATH}`
|
||||
}
|
||||
|
||||
// Check NAPI availability
|
||||
try {
|
||||
require("@ast-grep/napi")
|
||||
result.napi.available = true
|
||||
} catch (e) {
|
||||
result.napi.available = false
|
||||
result.napi.error = `@ast-grep/napi not installed: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Format environment check result as user-friendly message.
|
||||
*/
|
||||
export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
|
||||
const lines: string[] = ["ast-grep Environment Status:", ""]
|
||||
|
||||
// CLI status
|
||||
if (result.cli.available) {
|
||||
lines.push(`✓ CLI: Available (${result.cli.path})`)
|
||||
} else {
|
||||
lines.push(`✗ CLI: Not available`)
|
||||
if (result.cli.error) {
|
||||
lines.push(` Error: ${result.cli.error}`)
|
||||
}
|
||||
lines.push(` Install: bun add -D @ast-grep/cli`)
|
||||
}
|
||||
|
||||
// NAPI status
|
||||
if (result.napi.available) {
|
||||
lines.push(`✓ NAPI: Available`)
|
||||
} else {
|
||||
lines.push(`✗ NAPI: Not available`)
|
||||
if (result.napi.error) {
|
||||
lines.push(` Error: ${result.napi.error}`)
|
||||
}
|
||||
lines.push(` Install: bun add -D @ast-grep/napi`)
|
||||
}
|
||||
|
||||
lines.push("")
|
||||
lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`)
|
||||
lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
151
src/tools/ast-grep/downloader.ts
Normal file
151
src/tools/ast-grep/downloader.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { spawn } from "bun"
|
||||
import { existsSync, mkdirSync, chmodSync, unlinkSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { createRequire } from "module"
|
||||
|
||||
const REPO = "ast-grep/ast-grep"
|
||||
|
||||
// IMPORTANT: Update this when bumping @ast-grep/cli in package.json
|
||||
// This is only used as fallback when @ast-grep/cli package.json cannot be read
|
||||
const DEFAULT_VERSION = "0.40.0"
|
||||
|
||||
function getAstGrepVersion(): string {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = require("@ast-grep/cli/package.json")
|
||||
return pkg.version
|
||||
} catch {
|
||||
return DEFAULT_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
interface PlatformInfo {
|
||||
arch: string
|
||||
os: string
|
||||
}
|
||||
|
||||
const PLATFORM_MAP: Record<string, PlatformInfo> = {
|
||||
"darwin-arm64": { arch: "aarch64", os: "apple-darwin" },
|
||||
"darwin-x64": { arch: "x86_64", os: "apple-darwin" },
|
||||
"linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" },
|
||||
"linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" },
|
||||
"win32-x64": { arch: "x86_64", os: "pc-windows-msvc" },
|
||||
"win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
|
||||
"win32-ia32": { arch: "i686", os: "pc-windows-msvc" },
|
||||
}
|
||||
|
||||
export function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
|
||||
const base = localAppData || join(homedir(), "AppData", "Local")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
}
|
||||
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
const base = xdgCache || join(homedir(), ".cache")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
}
|
||||
|
||||
export function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "sg.exe" : "sg"
|
||||
}
|
||||
|
||||
export function getCachedBinaryPath(): string | null {
|
||||
const binaryPath = join(getCacheDir(), getBinaryName())
|
||||
return existsSync(binaryPath) ? binaryPath : null
|
||||
}
|
||||
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
const proc =
|
||||
process.platform === "win32"
|
||||
? spawn(
|
||||
[
|
||||
"powershell",
|
||||
"-command",
|
||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
)
|
||||
: spawn(["unzip", "-o", archivePath, "-d", destDir], { stdout: "pipe", stderr: "pipe" })
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const toolHint =
|
||||
process.platform === "win32"
|
||||
? "Ensure PowerShell is available on your system."
|
||||
: "Please install 'unzip' (e.g., apt install unzip, brew install unzip)."
|
||||
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}\n\n${toolHint}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise<string | null> {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformInfo = PLATFORM_MAP[platformKey]
|
||||
|
||||
if (!platformInfo) {
|
||||
console.error(`[oh-my-opencode] Unsupported platform for ast-grep: ${platformKey}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheDir = getCacheDir()
|
||||
const binaryName = getBinaryName()
|
||||
const binaryPath = join(cacheDir, binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
const { arch, os } = platformInfo
|
||||
const assetName = `app-${arch}-${os}.zip`
|
||||
const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
|
||||
|
||||
console.log(`[oh-my-opencode] Downloading ast-grep binary...`)
|
||||
|
||||
try {
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, { redirect: "follow" })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const archivePath = join(cacheDir, assetName)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
await Bun.write(archivePath, arrayBuffer)
|
||||
|
||||
await extractZip(archivePath, cacheDir)
|
||||
|
||||
if (existsSync(archivePath)) {
|
||||
unlinkSync(archivePath)
|
||||
}
|
||||
|
||||
if (process.platform !== "win32" && existsSync(binaryPath)) {
|
||||
chmodSync(binaryPath, 0o755)
|
||||
}
|
||||
|
||||
console.log(`[oh-my-opencode] ast-grep binary ready.`)
|
||||
|
||||
return binaryPath
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[oh-my-opencode] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAstGrepBinary(): Promise<string | null> {
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
const version = getAstGrepVersion()
|
||||
return downloadAstGrep(version)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
} from "./tools"
|
||||
import { ast_grep_search, ast_grep_replace } from "./tools"
|
||||
|
||||
export const builtinTools = {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
}
|
||||
|
||||
export {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
}
|
||||
export { ast_grep_search, ast_grep_replace }
|
||||
export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader"
|
||||
export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli"
|
||||
export { checkEnvironment, formatEnvironmentCheck } from "./constants"
|
||||
export type { EnvironmentCheckResult } from "./constants"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parse, Lang } from "@ast-grep/napi"
|
||||
import { NAPI_LANGUAGES } from "./constants"
|
||||
import type { NapiLanguage, AnalyzeResult, MetaVariable, Range } from "./types"
|
||||
|
||||
const LANG_MAP: Record<NapiLanguage, Lang> = {
|
||||
@@ -10,7 +11,16 @@ const LANG_MAP: Record<NapiLanguage, Lang> = {
|
||||
}
|
||||
|
||||
export function parseCode(code: string, lang: NapiLanguage) {
|
||||
return parse(LANG_MAP[lang], code)
|
||||
const parseLang = LANG_MAP[lang]
|
||||
if (!parseLang) {
|
||||
const supportedLangs = NAPI_LANGUAGES.join(", ")
|
||||
throw new Error(
|
||||
`Unsupported language for NAPI: "${lang}"\n` +
|
||||
`Supported languages: ${supportedLangs}\n\n` +
|
||||
`Use ast_grep_search for other languages (25 supported via CLI).`
|
||||
)
|
||||
}
|
||||
return parse(parseLang, code)
|
||||
}
|
||||
|
||||
export function findPattern(root: ReturnType<typeof parseCode>, pattern: string) {
|
||||
|
||||
@@ -10,44 +10,27 @@ function showOutputToUser(context: unknown, output: string): void {
|
||||
ctx.metadata?.({ metadata: { output } })
|
||||
}
|
||||
|
||||
/**
|
||||
* JS/TS languages that require complete function declaration patterns
|
||||
*/
|
||||
const JS_TS_LANGUAGES = ["javascript", "typescript", "tsx"] as const
|
||||
|
||||
/**
|
||||
* Validates AST pattern for common incomplete patterns that will fail silently.
|
||||
* Only validates JS/TS languages where function declarations require body.
|
||||
*
|
||||
* @throws Error with helpful message if pattern is incomplete
|
||||
*/
|
||||
function validatePatternForCli(pattern: string, lang: CliLanguage): void {
|
||||
if (!JS_TS_LANGUAGES.includes(lang as (typeof JS_TS_LANGUAGES)[number])) {
|
||||
return
|
||||
}
|
||||
|
||||
function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
|
||||
const src = pattern.trim()
|
||||
|
||||
// Detect incomplete function declarations:
|
||||
// - "function $NAME" (no params/body)
|
||||
// - "export function $NAME" (no params/body)
|
||||
// - "export async function $NAME" (no params/body)
|
||||
// - "export default function $NAME" (no params/body)
|
||||
// Pattern: ends with $METAVAR (uppercase, underscore, digits) without ( or {
|
||||
const incompleteFunctionDecl =
|
||||
/^(export\s+)?(default\s+)?(async\s+)?function\s+\$[A-Z_][A-Z0-9_]*\s*$/i.test(src)
|
||||
|
||||
if (incompleteFunctionDecl) {
|
||||
throw new Error(
|
||||
`Incomplete AST pattern for ${lang}: "${pattern}"\n\n` +
|
||||
`ast-grep requires complete AST nodes. Function declarations must include parameters and body.\n\n` +
|
||||
`Examples of correct patterns:\n` +
|
||||
` - "export async function $NAME($$$) { $$$ }" (matches export async functions)\n` +
|
||||
` - "function $NAME($$$) { $$$ }" (matches all function declarations)\n` +
|
||||
` - "async function $NAME($$$) { $$$ }" (matches async functions)\n\n` +
|
||||
`Your pattern "${pattern}" is missing the parameter list and body.`
|
||||
)
|
||||
if (lang === "python") {
|
||||
if (src.startsWith("class ") && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
}
|
||||
|
||||
if (["javascript", "typescript", "tsx"].includes(lang)) {
|
||||
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
|
||||
return `💡 Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const ast_grep_search = tool({
|
||||
@@ -66,16 +49,23 @@ export const ast_grep_search = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
validatePatternForCli(args.pattern, args.lang as CliLanguage)
|
||||
|
||||
const matches = await runSg({
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
lang: args.lang as CliLanguage,
|
||||
paths: args.paths,
|
||||
globs: args.globs,
|
||||
context: args.context,
|
||||
})
|
||||
const output = formatSearchResult(matches)
|
||||
|
||||
let output = formatSearchResult(result)
|
||||
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
|
||||
if (hint) {
|
||||
output += `\n\n${hint}`
|
||||
}
|
||||
}
|
||||
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
@@ -101,7 +91,7 @@ export const ast_grep_replace = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const matches = await runSg({
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
rewrite: args.rewrite,
|
||||
lang: args.lang as CliLanguage,
|
||||
@@ -109,7 +99,7 @@ export const ast_grep_replace = tool({
|
||||
globs: args.globs,
|
||||
updateAll: args.dryRun === false,
|
||||
})
|
||||
const output = formatReplaceResult(matches, args.dryRun !== false)
|
||||
const output = formatReplaceResult(result, args.dryRun !== false)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
|
||||
@@ -51,3 +51,11 @@ export interface TransformResult {
|
||||
transformed: string
|
||||
editCount: number
|
||||
}
|
||||
|
||||
export interface SgResult {
|
||||
matches: CliMatch[]
|
||||
totalMatches: number
|
||||
truncated: boolean
|
||||
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import type { CliMatch, AnalyzeResult } from "./types"
|
||||
import type { CliMatch, AnalyzeResult, SgResult } from "./types"
|
||||
|
||||
export function formatSearchResult(matches: CliMatch[]): string {
|
||||
if (matches.length === 0) {
|
||||
export function formatSearchResult(result: SgResult): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found"
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${matches.length} match(es):\n`]
|
||||
const lines: string[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`⚠️ Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.lines.trim()}`)
|
||||
@@ -17,15 +32,30 @@ export function formatSearchResult(matches: CliMatch[]): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatReplaceResult(matches: CliMatch[], isDryRun: boolean): string {
|
||||
if (matches.length === 0) {
|
||||
export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found to replace"
|
||||
}
|
||||
|
||||
const prefix = isDryRun ? "[DRY RUN] " : ""
|
||||
const lines: string[] = [`${prefix}${matches.length} replacement(s):\n`]
|
||||
const lines: string[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`⚠️ Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`${prefix}${result.matches.length} replacement(s):\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.text}`)
|
||||
|
||||
@@ -36,6 +36,10 @@ export const SEVERITY_MAP: Record<number, string> = {
|
||||
4: "hint",
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_REFERENCES = 200
|
||||
export const DEFAULT_MAX_SYMBOLS = 200
|
||||
export const DEFAULT_MAX_DIAGNOSTICS = 200
|
||||
|
||||
export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
|
||||
typescript: {
|
||||
command: ["typescript-language-server", "--stdio"],
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { getAllServers } from "./config"
|
||||
import {
|
||||
DEFAULT_MAX_REFERENCES,
|
||||
DEFAULT_MAX_SYMBOLS,
|
||||
DEFAULT_MAX_DIAGNOSTICS,
|
||||
} from "./constants"
|
||||
import {
|
||||
withLspClient,
|
||||
formatHoverResult,
|
||||
@@ -112,7 +117,14 @@ export const lsp_find_references = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const output = result.map(formatLocation).join("\n")
|
||||
const total = result.length
|
||||
const truncated = total > DEFAULT_MAX_REFERENCES
|
||||
const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result
|
||||
const lines = limited.map(formatLocation)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
@@ -138,13 +150,21 @@ export const lsp_document_symbols = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
let output: string
|
||||
if ("range" in result[0]) {
|
||||
output = (result as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)).join("\n")
|
||||
} else {
|
||||
output = (result as SymbolInfo[]).map(formatSymbolInfo).join("\n")
|
||||
const total = result.length
|
||||
const truncated = total > DEFAULT_MAX_SYMBOLS
|
||||
const limited = truncated ? result.slice(0, DEFAULT_MAX_SYMBOLS) : result
|
||||
|
||||
const lines: string[] = []
|
||||
if (truncated) {
|
||||
lines.push(`Found ${total} symbols (showing first ${DEFAULT_MAX_SYMBOLS}):`)
|
||||
}
|
||||
return output
|
||||
|
||||
if ("range" in limited[0]) {
|
||||
lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)))
|
||||
} else {
|
||||
lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo))
|
||||
}
|
||||
return lines.join("\n")
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
@@ -171,8 +191,15 @@ export const lsp_workspace_symbols = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const limited = args.limit ? result.slice(0, args.limit) : result
|
||||
const output = limited.map(formatSymbolInfo).join("\n")
|
||||
const total = result.length
|
||||
const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS)
|
||||
const truncated = total > limit
|
||||
const limited = result.slice(0, limit)
|
||||
const lines = limited.map(formatSymbolInfo)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} symbols (showing first ${limit}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
@@ -213,7 +240,14 @@ export const lsp_diagnostics = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const output = diagnostics.map(formatDiagnostic).join("\n")
|
||||
const total = diagnostics.length
|
||||
const truncated = total > DEFAULT_MAX_DIAGNOSTICS
|
||||
const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics
|
||||
const lines = limited.map(formatDiagnostic)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
Diagnostic,
|
||||
PrepareRenameResult,
|
||||
PrepareRenameDefaultBehavior,
|
||||
Range,
|
||||
WorkspaceEdit,
|
||||
TextEdit,
|
||||
CodeAction,
|
||||
@@ -165,21 +166,35 @@ export function filterDiagnosticsBySeverity(
|
||||
}
|
||||
|
||||
export function formatPrepareRenameResult(
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | null
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null
|
||||
): string {
|
||||
if (!result) return "Cannot rename at this position"
|
||||
|
||||
// Case 1: { defaultBehavior: boolean }
|
||||
if ("defaultBehavior" in result) {
|
||||
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
|
||||
}
|
||||
|
||||
const startLine = result.range.start.line + 1
|
||||
const startChar = result.range.start.character
|
||||
const endLine = result.range.end.line + 1
|
||||
const endChar = result.range.end.character
|
||||
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
|
||||
// Case 2: { range: Range, placeholder?: string }
|
||||
if ("range" in result && result.range) {
|
||||
const startLine = result.range.start.line + 1
|
||||
const startChar = result.range.start.character
|
||||
const endLine = result.range.end.line + 1
|
||||
const endChar = result.range.end.character
|
||||
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
|
||||
}
|
||||
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
|
||||
// Case 3: Range directly (has start/end but no range property)
|
||||
if ("start" in result && "end" in result) {
|
||||
const startLine = result.start.line + 1
|
||||
const startChar = result.start.character
|
||||
const endLine = result.end.line + 1
|
||||
const endChar = result.end.character
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`
|
||||
}
|
||||
|
||||
return "Cannot rename at this position"
|
||||
}
|
||||
|
||||
export function formatTextEdit(edit: TextEdit): string {
|
||||
|
||||
Reference in New Issue
Block a user