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

View File

@@ -0,0 +1,173 @@
/**
* API-based search adapter — delegates to Anthropic's server-side
* web_search_20250305 tool via a secondary API call.
*/
import type {
BetaContentBlock,
BetaWebSearchTool20250305,
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { queryModelWithStreaming } from 'src/services/api/claude.js'
import { createUserMessage } from 'src/utils/messages.js'
import { getMainLoopModel, getSmallFastModel } from 'src/utils/model/model.js'
import { jsonParse } from 'src/utils/slowOperations.js'
import { asSystemPrompt } from 'src/utils/systemPromptType.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
function makeToolSchema(input: { allowedDomains?: string[]; blockedDomains?: string[] }): BetaWebSearchTool20250305 {
return {
type: 'web_search_20250305',
name: 'web_search',
allowed_domains: input.allowedDomains,
blocked_domains: input.blockedDomains,
max_uses: 8,
}
}
export class ApiSearchAdapter implements WebSearchAdapter {
async search(
query: string,
options: SearchOptions,
): Promise<SearchResult[]> {
const { signal, onProgress, allowedDomains, blockedDomains } = options
const userMessage = createUserMessage({
content: 'Perform a web search for the query: ' + query,
})
const toolSchema = makeToolSchema({ allowedDomains, blockedDomains })
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE('tengu_plum_vx3', false)
const queryStream = queryModelWithStreaming({
messages: [userMessage],
systemPrompt: asSystemPrompt([
'You are an assistant for performing a web search tool use',
]),
thinkingConfig: useHaiku
? { type: 'disabled' as const }
: { type: 'enabled' as const, budgetTokens: 10000 },
tools: [],
signal: signal ?? new AbortController().signal,
options: {
getToolPermissionContext: async () => ({
mode: 'default' as const,
additionalWorkingDirectories: new Map(),
alwaysAllowRules: {},
alwaysDenyRules: {},
alwaysAskRules: {},
isBypassPermissionsModeAvailable: false,
}),
model: useHaiku ? getSmallFastModel() : getMainLoopModel(),
toolChoice: useHaiku ? { type: 'tool' as const, name: 'web_search' } : undefined,
isNonInteractiveSession: false,
hasAppendSystemPrompt: false,
extraToolSchemas: [toolSchema],
querySource: 'web_search_tool' as const,
agents: [],
mcpTools: [],
agentId: undefined,
effortValue: undefined,
},
})
const allContentBlocks: BetaContentBlock[] = []
let currentToolUseId: string | null = null
let currentToolUseJson = ''
const toolUseQueries = new Map<string, string>()
let progressCounter = 0
for await (const event of queryStream) {
if (event.type === 'assistant') {
const msg = event as { message: { content: BetaContentBlock[] } }
allContentBlocks.push(...msg.message.content)
continue
}
if (event.type === 'stream_event') {
const streamEvt = event as {
event?: {
type: string
content_block?: { type: string; id?: string; tool_use_id?: string; content?: unknown; [key: string]: unknown }
delta?: { type: string; partial_json?: string; [key: string]: unknown }
[key: string]: unknown
}
}
if (streamEvt.event?.type === 'content_block_start') {
const contentBlock = streamEvt.event.content_block
if (contentBlock && contentBlock.type === 'server_tool_use') {
currentToolUseId = contentBlock.id as string
currentToolUseJson = ''
continue
}
}
if (currentToolUseId && streamEvt.event?.type === 'content_block_delta') {
const delta = streamEvt.event.delta
if (delta?.type === 'input_json_delta' && delta.partial_json) {
currentToolUseJson += delta.partial_json
try {
const queryMatch = currentToolUseJson.match(
/"query"\s*:\s*"((?:[^"\\]|\\.)*)"/,
)
if (queryMatch && queryMatch[1]) {
const parsedQuery = jsonParse('"' + queryMatch[1] + '"')
if (
!toolUseQueries.has(currentToolUseId) ||
toolUseQueries.get(currentToolUseId) !== parsedQuery
) {
toolUseQueries.set(currentToolUseId, parsedQuery)
progressCounter++
onProgress?.({
type: 'query_update',
query: parsedQuery,
})
}
}
} catch {
// Ignore parsing errors for partial JSON
}
}
}
if (streamEvt.event?.type === 'content_block_start') {
const contentBlock = streamEvt.event.content_block
if (contentBlock && contentBlock.type === 'web_search_tool_result') {
const toolUseId = contentBlock.tool_use_id as string
const actualQuery = toolUseQueries.get(toolUseId) || query
const content = contentBlock.content
progressCounter++
onProgress?.({
type: 'search_results_received',
resultCount: Array.isArray(content) ? content.length : 0,
query: actualQuery,
})
}
}
}
}
// Extract SearchResult[] from content blocks
return extractSearchResults(allContentBlocks)
}
}
function extractSearchResults(
blocks: BetaContentBlock[],
): SearchResult[] {
const results: SearchResult[] = []
for (const block of blocks) {
if (block.type === 'web_search_tool_result' && Array.isArray(block.content)) {
for (const r of block.content as Array<{ title: string; url: string; page_age?: string; type?: string }>) {
results.push({
title: r.title,
url: r.url,
})
}
}
}
return results
}

View File

@@ -0,0 +1,204 @@
/**
* Bing-based search adapter — fetches Bing search pages and extracts
* search results using regex pattern matching on raw HTML.
*/
import axios from 'axios'
import he from 'he'
import { AbortError } from 'src/utils/errors.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const FETCH_TIMEOUT_MS = 30_000
/**
* Browser-like headers to avoid Bing's anti-bot JS-rendered response.
* These mimic Microsoft Edge on macOS to get full HTML search results.
*/
const BROWSER_HEADERS = {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
'Sec-Ch-Ua': '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"macOS"',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1',
} as const
export class BingSearchAdapter implements WebSearchAdapter {
async search(
query: string,
options: SearchOptions,
): Promise<SearchResult[]> {
const { signal, onProgress, allowedDomains, blockedDomains } = options
if (signal?.aborted) {
throw new AbortError()
}
onProgress?.({ type: 'query_update', query })
const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}&setmkt=en-US`
const abortController = new AbortController()
if (signal) {
signal.addEventListener('abort', () => abortController.abort(), { once: true })
}
let html: string
try {
const response = await axios.get(url, {
signal: abortController.signal,
timeout: FETCH_TIMEOUT_MS,
responseType: 'text',
headers: BROWSER_HEADERS,
})
html = response.data
} catch (e) {
if (axios.isCancel(e) || abortController.signal.aborted) {
throw new AbortError()
}
throw e
}
if (abortController.signal.aborted) {
throw new AbortError()
}
const rawResults = extractBingResults(html)
// Client-side domain filtering
const results = rawResults.filter((r) => {
if (!r.url) return false
try {
const hostname = new URL(r.url).hostname
if (allowedDomains?.length && !allowedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) {
return false
}
if (blockedDomains?.length && blockedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) {
return false
}
} catch {
return false
}
return true
})
onProgress?.({
type: 'search_results_received',
resultCount: results.length,
query,
})
return results
}
}
/**
* Extract organic search results from Bing HTML.
* Bing results live in <li class="b_algo"> blocks within <ol id="b_results">.
*/
export function extractBingResults(html: string): SearchResult[] {
const results: SearchResult[] = []
const algoBlockRegex = /<li\s+class="b_algo"[^>]*>([\s\S]*?)<\/li>/gi
let blockMatch: RegExpExecArray | null
while ((blockMatch = algoBlockRegex.exec(html)) !== null) {
const block = blockMatch[1]
// Extract the primary link from <h2><a href="...">...</a></h2>
const h2LinkRegex = /<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i
const linkMatch = h2LinkRegex.exec(block)
if (!linkMatch) continue
const rawUrl = decodeHtmlEntities(linkMatch[1])
const titleHtml = linkMatch[2]
// Resolve Bing redirect URLs (bing.com/ck/a?...&u=a1aHR0cHM6Ly9...)
// or skip Bing-internal / relative links
const url = resolveBingUrl(rawUrl)
if (!url) continue
const title = decodeHtmlEntities(
titleHtml.replace(/<[^>]+>/g, '').trim(),
)
// Extract snippet: try b_lineclamp → b_caption <p> → b_caption fallback
const snippet = extractSnippet(block)
results.push({ title, url, snippet })
}
return results
}
function extractSnippet(block: string): string | undefined {
// 1. Try <p class="b_lineclamp...">
const lineclampRegex = /<p[^>]*class="b_lineclamp[^"]*"[^>]*>([\s\S]*?)<\/p>/i
let match = lineclampRegex.exec(block)
if (match) {
return decodeHtmlEntities(match[1].replace(/<[^>]+>/g, '').trim())
}
// 2. Try <p> inside b_caption
const captionPRegex = /<div[^>]*class="b_caption[^"]*"[^>]*>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/i
match = captionPRegex.exec(block)
if (match) {
return decodeHtmlEntities(match[1].replace(/<[^>]+>/g, '').trim())
}
// 3. Fallback: any text inside b_caption <div>
const fallbackRegex = /<div[^>]*class="b_caption[^"]*"[^>]*>([\s\S]*?)<\/div>/i
const fallbackMatch = fallbackRegex.exec(block)
if (fallbackMatch) {
const text = fallbackMatch[1].replace(/<[^>]+>/g, '').trim()
if (text) return decodeHtmlEntities(text)
}
return undefined
}
export const decodeHtmlEntities = he.decode
/**
* Resolve a Bing redirect URL to the actual target URL.
* Bing uses URLs like: https://www.bing.com/ck/a?...&u=a1aHR0cHM6Ly9leGFtcGxlLmNvbQ...
* The `u` query parameter is a base64-encoded URL prefixed with a1 (https) or a0 (http).
* Returns `undefined` for Bing-internal or relative links that should be skipped.
*/
export function resolveBingUrl(rawUrl: string): string | undefined {
// Skip relative / anchor links
if (rawUrl.startsWith('/') || rawUrl.startsWith('#')) return undefined
// Try to extract the `u` parameter from Bing redirect URLs
const uMatch = rawUrl.match(/[?&]u=([a-zA-Z0-9+/_=-]+)/)
if (uMatch) {
const encoded = uMatch[1]
if (encoded.length >= 3) {
const prefix = encoded.slice(0, 2)
const b64 = encoded.slice(2)
try {
// Base64url decode (pad as needed)
const padded = b64.replace(/-/g, '+').replace(/_/g, '/')
const decoded = Buffer.from(padded, 'base64').toString('utf-8')
if (decoded.startsWith('http')) return decoded
} catch {
// Fall through — not a valid base64 redirect
}
}
}
// Direct external URL (not a Bing-internal page)
if (!rawUrl.includes('bing.com')) return rawUrl
return undefined
}

View File

@@ -0,0 +1,169 @@
/**
* Brave-based search adapter — fetches Brave's LLM context API and maps the
* grounding payload into SearchResult objects.
*/
import axios from 'axios'
import { AbortError } from 'src/utils/errors.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const FETCH_TIMEOUT_MS = 30_000
const BRAVE_LLM_CONTEXT_URL = 'https://api.search.brave.com/res/v1/llm/context'
const BRAVE_API_KEY_ENV_VARS = ['BRAVE_SEARCH_API_KEY', 'BRAVE_API_KEY'] as const
interface BraveGroundingResult {
title?: string
url?: string
snippets?: string[]
}
interface BraveSearchResponse {
grounding?: {
generic?: BraveGroundingResult[]
map?: BraveGroundingResult[]
poi?: BraveGroundingResult | null
}
}
export class BraveSearchAdapter implements WebSearchAdapter {
async search(
query: string,
options: SearchOptions,
): Promise<SearchResult[]> {
const { signal, onProgress, allowedDomains, blockedDomains } = options
if (signal?.aborted) {
throw new AbortError()
}
onProgress?.({ type: 'query_update', query })
const abortController = new AbortController()
if (signal) {
signal.addEventListener('abort', () => abortController.abort(), {
once: true,
})
}
let payload: BraveSearchResponse
try {
const response = await axios.get<BraveSearchResponse>(
BRAVE_LLM_CONTEXT_URL,
{
signal: abortController.signal,
timeout: FETCH_TIMEOUT_MS,
responseType: 'json',
headers: {
Accept: 'application/json',
'X-Subscription-Token': getBraveApiKey(),
},
params: { q: query },
},
)
payload = response.data
} catch (e) {
if (axios.isCancel(e) || abortController.signal.aborted) {
throw new AbortError()
}
throw e
}
if (abortController.signal.aborted) {
throw new AbortError()
}
const rawResults = extractBraveResults(payload)
const results = rawResults.filter(r => {
try {
const hostname = new URL(r.url).hostname
if (
allowedDomains?.length &&
!allowedDomains.some(
d => hostname === d || hostname.endsWith('.' + d),
)
) {
return false
}
if (
blockedDomains?.length &&
blockedDomains.some(d => hostname === d || hostname.endsWith('.' + d))
) {
return false
}
} catch {
return false
}
return true
})
onProgress?.({
type: 'search_results_received',
resultCount: results.length,
query,
})
return results
}
}
export function extractBraveResults(
payload: BraveSearchResponse,
): SearchResult[] {
const grounding = payload.grounding
if (!grounding) {
return []
}
const entries = [
...(Array.isArray(grounding.generic) ? grounding.generic : []),
...(grounding.poi ? [grounding.poi] : []),
...(Array.isArray(grounding.map) ? grounding.map : []),
]
const seenUrls = new Set<string>()
const results: SearchResult[] = []
for (const entry of entries) {
if (!entry?.url || !entry.title || seenUrls.has(entry.url)) {
continue
}
seenUrls.add(entry.url)
results.push({
title: entry.title,
url: entry.url,
snippet: normalizeSnippet(entry.snippets),
})
}
return results
}
function normalizeSnippet(snippets: string[] | undefined): string | undefined {
if (!Array.isArray(snippets)) {
return undefined
}
const normalized = snippets
.map(snippet => snippet.trim())
.filter(snippet => snippet.length > 0)
if (normalized.length === 0) {
return undefined
}
return normalized.join(' ')
}
function getBraveApiKey(): string {
for (const envVar of BRAVE_API_KEY_ENV_VARS) {
const value = process.env[envVar]?.trim()
if (value) {
return value
}
}
throw new Error(
'BraveSearchAdapter requires BRAVE_SEARCH_API_KEY or BRAVE_API_KEY',
)
}

View File

@@ -0,0 +1,47 @@
/**
* Search adapter factory — selects the appropriate backend by checking
* whether the API base URL points to Anthropic's official endpoint.
*/
import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js'
import { ApiSearchAdapter } from './apiAdapter.js'
import { BingSearchAdapter } from './bingAdapter.js'
import { BraveSearchAdapter } from './braveAdapter.js'
import type { WebSearchAdapter } from './types.js'
export type {
SearchResult,
SearchOptions,
SearchProgress,
WebSearchAdapter,
} from './types.js'
let cachedAdapter: WebSearchAdapter | null = null
let cachedAdapterKey: 'api' | 'bing' | 'brave' | null = null
export function createAdapter(): WebSearchAdapter {
const envAdapter = process.env.WEB_SEARCH_ADAPTER
const adapterKey =
envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave'
? envAdapter
: isFirstPartyAnthropicBaseUrl()
? 'api'
: 'bing'
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
if (adapterKey === 'api') {
cachedAdapter = new ApiSearchAdapter()
cachedAdapterKey = 'api'
return cachedAdapter
}
if (adapterKey === 'brave') {
cachedAdapter = new BraveSearchAdapter()
cachedAdapterKey = 'brave'
return cachedAdapter
}
cachedAdapter = new BingSearchAdapter()
cachedAdapterKey = 'bing'
return cachedAdapter
}

View File

@@ -0,0 +1,22 @@
export interface SearchResult {
title: string
url: string
snippet?: string
}
export interface SearchOptions {
allowedDomains?: string[]
blockedDomains?: string[]
signal?: AbortSignal
onProgress?: (progress: SearchProgress) => void
}
export interface SearchProgress {
type: 'query_update' | 'search_results_received'
query?: string
resultCount?: number
}
export interface WebSearchAdapter {
search(query: string, options: SearchOptions): Promise<SearchResult[]>
}