feat: 工具层及 mcp 大重构 (#252)

* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

* fix: 修复对穷鬼模式的 auto dream 和 session memory 越过

* feat: 穷鬼模式去除 session-summary

* feat: 创建 builtin-tools 包,搬运所有工具实现

将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/,
内部导入路径已更新为 src/ alias 模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/

- src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/
- 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock

- tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射
- 新增 packages/builtin-tools/src 至 include

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀

所有包名及 import 路径统一添加 @claude-code-best/ 前缀:
- builtin-tools → @claude-code-best/builtin-tools
- mcp-client → @claude-code-best/mcp-client
- agent-tools → @claude-code-best/agent-tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 node 环境没有 bun 的问题

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-13 09:52:05 +08:00
committed by GitHub
parent bbb8b613a9
commit 2fb1c9dcd8
559 changed files with 9346 additions and 1837 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import { extractTag } from 'src/utils/messages.js'
import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js'
import { MessageResponse } from 'src/components/MessageResponse.js'
import { Text } from '@anthropic/ink'
import { FilePathLink } from 'src/components/FilePathLink.js'
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from 'src/utils/file.js'
import { formatFileSize } from 'src/utils/format.js'
import { getPlansDirectory } from 'src/utils/plans.js'
import { getTaskOutputDir } from 'src/utils/task/diskOutput.js'
import type { Input, Output } from './FileReadTool.js'
/**
* Check if a file path is an agent output file and extract the task ID.
* Agent output files follow the pattern: {projectTempDir}/tasks/{taskId}.output
*/
function getAgentOutputTaskId(filePath: string): string | null {
const prefix = `${getTaskOutputDir()}/`
const suffix = '.output'
if (filePath.startsWith(prefix) && filePath.endsWith(suffix)) {
const taskId = filePath.slice(prefix.length, -suffix.length)
// Validate it looks like a task ID (alphanumeric, reasonable length)
if (
taskId.length > 0 &&
taskId.length <= 20 &&
/^[a-zA-Z0-9_-]+$/.test(taskId)
) {
return taskId
}
}
return null
}
export function renderToolUseMessage(
{ file_path, offset, limit, pages }: Partial<Input>,
{ verbose }: { verbose: boolean },
): React.ReactNode {
if (!file_path) {
return null
}
// For agent output files, return empty string so no parentheses are shown
// The task ID is displayed separately by AssistantToolUseMessage
if (getAgentOutputTaskId(file_path)) {
return ''
}
const displayPath = verbose ? file_path : getDisplayPath(file_path)
if (pages) {
return (
<>
<FilePathLink filePath={file_path}>{displayPath}</FilePathLink>
{` · pages ${pages}`}
</>
)
}
if (verbose && (offset || limit)) {
const startLine = offset ?? 1
const lineRange = limit
? `lines ${startLine}-${startLine + limit - 1}`
: `from line ${startLine}`
return (
<>
<FilePathLink filePath={file_path}>{displayPath}</FilePathLink>
{` · ${lineRange}`}
</>
)
}
return <FilePathLink filePath={file_path}>{displayPath}</FilePathLink>
}
export function renderToolUseTag({
file_path,
}: Partial<Input>): React.ReactNode {
const agentTaskId = file_path ? getAgentOutputTaskId(file_path) : null
// Show agent task ID for Read tool when reading agent output
if (!agentTaskId) {
return null
}
return <Text dimColor> {agentTaskId}</Text>
}
export function renderToolResultMessage(output: Output): React.ReactNode {
// TODO: Render recursively
switch (output.type) {
case 'image': {
const { originalSize } = output.file
const formattedSize = formatFileSize(originalSize)
return (
<MessageResponse height={1}>
<Text>Read image ({formattedSize})</Text>
</MessageResponse>
)
}
case 'notebook': {
const { cells } = output.file
if (!cells || cells.length < 1) {
return <Text color="error">No cells found in notebook</Text>
}
return (
<MessageResponse height={1}>
<Text>
Read <Text bold>{cells.length}</Text> cells
</Text>
</MessageResponse>
)
}
case 'pdf': {
const { originalSize } = output.file
const formattedSize = formatFileSize(originalSize)
return (
<MessageResponse height={1}>
<Text>Read PDF ({formattedSize})</Text>
</MessageResponse>
)
}
case 'parts': {
return (
<MessageResponse height={1}>
<Text>
Read <Text bold>{output.file.count}</Text>{' '}
{output.file.count === 1 ? 'page' : 'pages'} (
{formatFileSize(output.file.originalSize)})
</Text>
</MessageResponse>
)
}
case 'text': {
const { numLines } = output.file
return (
<MessageResponse height={1}>
<Text>
Read <Text bold>{numLines}</Text>{' '}
{numLines === 1 ? 'line' : 'lines'}
</Text>
</MessageResponse>
)
}
case 'file_unchanged': {
return (
<MessageResponse height={1}>
<Text dimColor>Unchanged since last read</Text>
</MessageResponse>
)
}
}
}
export function renderToolUseErrorMessage(
result: ToolResultBlockParam['content'],
{ verbose }: { verbose: boolean },
): React.ReactNode {
if (!verbose && typeof result === 'string') {
// FileReadTool throws from call() so errors lack <tool_use_error> wrapping —
// check the raw string directly for the cwd note marker.
if (result.includes(FILE_NOT_FOUND_CWD_NOTE)) {
return (
<MessageResponse>
<Text color="error">File not found</Text>
</MessageResponse>
)
}
if (extractTag(result, 'tool_use_error')) {
return (
<MessageResponse>
<Text color="error">Error reading file</Text>
</MessageResponse>
)
}
}
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />
}
export function userFacingName(input: Partial<Input> | undefined): string {
if (input?.file_path?.startsWith(getPlansDirectory())) {
return 'Reading Plan'
}
if (input?.file_path && getAgentOutputTaskId(input.file_path)) {
return 'Read agent output'
}
return 'Read'
}
export function getToolUseSummary(
input: Partial<Input> | undefined,
): string | null {
if (!input?.file_path) {
return null
}
// For agent output files, just show the task ID
const agentTaskId = getAgentOutputTaskId(input.file_path)
if (agentTaskId) {
return agentTaskId
}
return getDisplayPath(input.file_path)
}

View File

@@ -0,0 +1,94 @@
import type { Buffer } from 'buffer'
import { isInBundledMode } from 'src/utils/bundledMode.js'
export type SharpInstance = {
metadata(): Promise<{ width: number; height: number; format: string }>
resize(
width: number,
height: number,
options?: { fit?: string; withoutEnlargement?: boolean },
): SharpInstance
jpeg(options?: { quality?: number }): SharpInstance
png(options?: {
compressionLevel?: number
palette?: boolean
colors?: number
}): SharpInstance
webp(options?: { quality?: number }): SharpInstance
toBuffer(): Promise<Buffer>
}
export type SharpFunction = (input: Buffer) => SharpInstance
type SharpCreatorOptions = {
create: {
width: number
height: number
channels: 3 | 4
background: { r: number; g: number; b: number }
}
}
type SharpCreator = (options: SharpCreatorOptions) => SharpInstance
let imageProcessorModule: { default: SharpFunction } | null = null
let imageCreatorModule: { default: SharpCreator } | null = null
export async function getImageProcessor(): Promise<SharpFunction> {
if (imageProcessorModule) {
return imageProcessorModule.default
}
if (isInBundledMode()) {
// Try to load the native image processor first
try {
// Use the native image processor module
const imageProcessor = await import('image-processor-napi')
const sharpFn = (imageProcessor.sharp ?? imageProcessor.default) as SharpFunction
imageProcessorModule = { default: sharpFn }
return sharpFn
} catch {
// Fall back to sharp if native module is not available
// biome-ignore lint/suspicious/noConsole: intentional warning
console.warn(
'Native image processor not available, falling back to sharp',
)
}
}
// Use sharp for non-bundled builds or as fallback.
// Single structural cast: our SharpFunction is a subset of sharp's actual type surface.
const imported = (await import(
'sharp'
)) as unknown as MaybeDefault<SharpFunction>
const sharp = unwrapDefault(imported)
imageProcessorModule = { default: sharp }
return sharp
}
/**
* Get image creator for generating new images from scratch.
* Note: image-processor-napi doesn't support image creation,
* so this always uses sharp directly.
*/
export async function getImageCreator(): Promise<SharpCreator> {
if (imageCreatorModule) {
return imageCreatorModule.default
}
const imported = (await import(
'sharp'
)) as unknown as MaybeDefault<SharpCreator>
const sharp = unwrapDefault(imported)
imageCreatorModule = { default: sharp }
return sharp
}
// Dynamic import shape varies by module interop mode — ESM yields { default: fn }, CJS yields fn directly.
type MaybeDefault<T> = T | { default: T }
function unwrapDefault<T extends (...args: never[]) => unknown>(
mod: MaybeDefault<T>,
): T {
return typeof mod === 'function' ? mod : mod.default
}

View File

@@ -0,0 +1,92 @@
/**
* Read tool output limits. Two caps apply to text reads:
*
* | limit | default | checks | cost | on overflow |
* |---------------|---------|---------------------------|---------------|-----------------|
* | maxSizeBytes | 256 KB | TOTAL FILE SIZE (not out) | 1 stat | throws pre-read |
* | maxTokens | 25000 | actual output tokens | API roundtrip | throws post-read|
*
* Known mismatch: maxSizeBytes gates on total file size, not the slice.
* Tested truncating instead of throwing for explicit-limit reads that
* exceed the byte cap (#21841, Mar 2026). Reverted: tool error rate
* dropped but mean tokens rose — the throw path yields a ~100-byte error
* tool-result while truncation yields ~25K tokens of content at the cap.
*/
import memoize from 'lodash-es/memoize.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { MAX_OUTPUT_SIZE } from 'src/utils/file.js'
export const DEFAULT_MAX_OUTPUT_TOKENS = 25000
/**
* Env var override for max output tokens. Returns undefined when unset/invalid
* so the caller can fall through to the next precedence tier.
*/
function getEnvMaxTokens(): number | undefined {
const override = process.env.CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS
if (override) {
const parsed = parseInt(override, 10)
if (!isNaN(parsed) && parsed > 0) {
return parsed
}
}
return undefined
}
export type FileReadingLimits = {
maxTokens: number
maxSizeBytes: number
includeMaxSizeInPrompt?: boolean
targetedRangeNudge?: boolean
}
/**
* Default limits for Read tool when the ToolUseContext doesn't supply an
* override. Memoized so the GrowthBook value is fixed at first call — avoids
* the cap changing mid-session as the flag refreshes in the background.
*
* Precedence for maxTokens: env var > GrowthBook > DEFAULT_MAX_OUTPUT_TOKENS.
* (Env var is a user-set override, should beat experiment infrastructure.)
*
* Defensive: each field is individually validated; invalid values fall
* through to the hardcoded defaults (no route to cap=0).
*/
export const getDefaultFileReadingLimits = memoize((): FileReadingLimits => {
const override =
getFeatureValue_CACHED_MAY_BE_STALE<Partial<FileReadingLimits> | null>(
'tengu_amber_wren',
{},
)
const maxSizeBytes =
typeof override?.maxSizeBytes === 'number' &&
Number.isFinite(override.maxSizeBytes) &&
override.maxSizeBytes > 0
? override.maxSizeBytes
: MAX_OUTPUT_SIZE
const envMaxTokens = getEnvMaxTokens()
const maxTokens =
envMaxTokens ??
(typeof override?.maxTokens === 'number' &&
Number.isFinite(override.maxTokens) &&
override.maxTokens > 0
? override.maxTokens
: DEFAULT_MAX_OUTPUT_TOKENS)
const includeMaxSizeInPrompt =
typeof override?.includeMaxSizeInPrompt === 'boolean'
? override.includeMaxSizeInPrompt
: undefined
const targetedRangeNudge =
typeof override?.targetedRangeNudge === 'boolean'
? override.targetedRangeNudge
: undefined
return {
maxSizeBytes,
maxTokens,
includeMaxSizeInPrompt,
targetedRangeNudge,
}
})

View File

@@ -0,0 +1,49 @@
import { isPDFSupported } from 'src/utils/pdfUtils.js'
import { BASH_TOOL_NAME } from '../BashTool/toolName.js'
// Use a string constant for tool names to avoid circular dependencies
export const FILE_READ_TOOL_NAME = 'Read'
export const FILE_UNCHANGED_STUB =
'File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading.'
export const MAX_LINES_TO_READ = 2000
export const DESCRIPTION = 'Read a file from the local filesystem.'
export const LINE_FORMAT_INSTRUCTION =
'- Results are returned using cat -n format, with line numbers starting at 1'
export const OFFSET_INSTRUCTION_DEFAULT =
"- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters"
export const OFFSET_INSTRUCTION_TARGETED =
'- When you already know which part of the file you need, only read that part. This can be important for larger files.'
/**
* Renders the Read tool prompt template. The caller (FileReadTool) supplies
* the runtime-computed parts.
*/
export function renderPromptTemplate(
lineFormat: string,
maxSizeInstruction: string,
offsetInstruction: string,
): string {
return `Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${MAX_LINES_TO_READ} lines starting from the beginning of the file${maxSizeInstruction}
${offsetInstruction}
${lineFormat}
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.${
isPDFSupported()
? '\n- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request.'
: ''
}
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${BASH_TOOL_NAME} tool.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.`
}

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type getFeatureValue_CACHED_MAY_BE_STALE = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type MAX_OUTPUT_SIZE = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type extractTag = any;