Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,9 @@
# 通用设置(建议始终开启)
# ============================================================
DISABLE_TELEMETRY=1
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1

# Web search backend: parallel (free Search MCP, no search API key),
# local (local search engines), or native (model provider search).
# Unset: preserve configured providers; use Parallel when unconfigured.
# CYBERCODE_WEB_SEARCH_MODE=parallel
9 changes: 9 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ Code Graph is local and cross-platform. Once its global switch is enabled, curre

The same tools, memory, desktop workflow, and permissions remain available across the official Claude path, Anthropic-compatible endpoints, OpenAI-compatible providers, and local models. CyberCode also includes a provider-neutral local WebSearch fallback with live search, domain filters, caching, and a direct GitHub Trending path for models whose native browsing is unavailable or unreliable.

For free web search without a search account or API key, use [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp), powered by Fast mode:

```bash
CYBERCODE_WEB_SEARCH_MODE=parallel ./bin/cybercode
```

Parallel handles the built-in `WebSearch` tool with source links and domain filters, under the existing tool permissions. It is also selected when no provider endpoint, cloud provider or credentials are configured. Existing configured providers keep their current search behavior. Set `CYBERCODE_WEB_SEARCH_MODE=local` for the local engines (including direct GitHub Trending), or `native` for the model provider's search. Parallel search sends the query and an opaque conversation identifier to `search.parallel.ai`; anonymous usage has service limits, and errors are returned without switching providers. Page fetching continues to use the existing `WebFetch` tool.


<table>
<tr>
<td width="50%"><img src="docs/images/gui/cybercode-gui-providers.png" alt="CyberCode provider configuration"><br><b>Provider control in the app</b><br>Configure hosted or local endpoints, test connections, and choose defaults without leaving the desktop workflow.</td>
Expand Down
6 changes: 3 additions & 3 deletions src/services/webSearch/localWebSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,11 +582,11 @@ function matchesDomainRule(url: URL, rule: DomainRule): boolean {
return hostMatches && (!rule.path || url.pathname.startsWith(rule.path))
}

export function filterSearchResultsByDomain(
results: LocalWebSearchHit[],
export function filterSearchResultsByDomain<T extends { url: string }>(
results: T[],
allowedDomains: string[] = [],
blockedDomains: string[] = [],
): LocalWebSearchHit[] {
): T[] {
const allowed = allowedDomains
.map(parseDomainRule)
.filter((rule): rule is DomainRule => rule !== null)
Expand Down
91 changes: 91 additions & 0 deletions src/services/webSearch/parallelWebSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { z } from 'zod/v4'
import { getProxyFetchOptions } from '../../utils/proxy.js'
import { filterSearchResultsByDomain } from './localWebSearch.js'

const SEARCH_URL = 'https://search.parallel.ai/mcp'
const SEARCH_TIMEOUT_MS = 15_000
const responseSchema = z.object({
results: z.array(z.object({
url: z.string(),
title: z.string().nullish(),
excerpts: z.array(z.string()),
})),
})

export async function searchParallelWeb(options: {
query: string
sessionId: string
allowedDomains?: string[]
blockedDomains?: string[]
signal: AbortSignal
}) {
const signal = AbortSignal.any([
options.signal,
AbortSignal.timeout(SEARCH_TIMEOUT_MS),
])
signal.throwIfAborted()
const client = new Client({ name: 'cybercode', version: MACRO.VERSION })
const transport = new StreamableHTTPClientTransport(new URL(SEARCH_URL), {
requestInit: {
...getProxyFetchOptions(),
headers: {
'User-Agent': `CyberCode/${MACRO.VERSION} (+https://github.com/wk42worldworld/cybercode)`,
},
},
// Bind the entire operation, including discovery, to cancellation/timeout.
fetch: (url, init) => fetch(url, {
...init,
signal: AbortSignal.any([signal, ...(init?.signal ? [init.signal] : [])]),
}),
})
const query = options.allowedDomains?.length
? `${options.query} (${options.allowedDomains.map(domain => `site:${domain}`).join(' OR ')})`
: options.query

try {
await client.connect(transport, { signal })
const result = await client.callTool({
name: 'web_search',
arguments: {
objective: options.query,
search_queries: [query],
session_id: options.sessionId,
},
}, undefined, { signal })
const text = result.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
if (result.isError) {
throw new Error(`Parallel web search failed: ${text.slice(0, 1000)}`)
}
const response = responseSchema.parse(result.structuredContent ?? JSON.parse(text))
const seen = new Set<string>()
const hits = response.results.flatMap(result => {
try {
const url = new URL(result.url)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return []
if (seen.has(url.href)) return []
seen.add(url.href)
return [{
title: result.title || url.hostname,
url: url.href,
description: result.excerpts.join('\n'),
source: url.hostname,
engine: 'parallel' as const,
}]
} catch {
return []
}
})
return filterSearchResultsByDomain(
hits,
options.allowedDomains,
options.blockedDomains,
).slice(0, 10)
} finally {
await client.close()
}
}
23 changes: 23 additions & 0 deletions src/services/webSearch/searchMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getAuthTokenSource, getApiKeyFromConfigOrMacOSKeychain } from '../../utils/auth.js'
import { getApiKeyFromFileDescriptor } from '../../utils/authFileDescriptor.js'
import { getAPIProvider } from '../../utils/model/providers.js'

/** Only a fresh, unconfigured installation changes its search backend. */
export function shouldUseParallelWebSearch(): boolean {
const mode = process.env.CYBERCODE_WEB_SEARCH_MODE?.trim().toLowerCase()
if (mode) return mode === 'parallel'

// Startup projects saved provider settings into these same environment values.
if (process.env.ANTHROPIC_BASE_URL || getAPIProvider() !== 'firstParty') {
return false
}

// Inspect credential sources without executing an apiKeyHelper. Preserve even
// an unapproved env key: configuring a credential is an intentional choice.
return !(
process.env.ANTHROPIC_API_KEY ||
getAuthTokenSource().hasToken ||
getApiKeyFromFileDescriptor() ||
getApiKeyFromConfigOrMacOSKeychain()?.key
)
}
35 changes: 35 additions & 0 deletions src/tools/WebSearchTool/WebSearchTool.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash } from 'node:crypto'
import { getSessionId } from '../../bootstrap/state.js'
import type {
BetaContentBlock,
BetaWebSearchTool20250305,
Expand All @@ -11,6 +13,8 @@ import { z } from 'zod/v4'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { queryModelWithStreaming } from '../../services/api/claude.js'
import { searchLocalWeb } from '../../services/webSearch/localWebSearch.js'
import { searchParallelWeb } from '../../services/webSearch/parallelWebSearch.js'
import { shouldUseParallelWebSearch } from '../../services/webSearch/searchMode.js'
import {
buildTool,
type ToolCallProgress,
Expand Down Expand Up @@ -332,6 +336,37 @@ export const WebSearchTool = buildTool({
const startTime = performance.now()
const { query } = input

if (shouldUseParallelWebSearch()) {
// Snapshot conversation identity before any await. Subagents keep separate
// research sessions, even when background tasks survive a main-chat reset.
const sessionId = context.agentId
? createHash('sha256').update(context.agentId).digest('hex')
: getSessionId()
const progressId = `parallel-web-search-${Math.round(startTime)}`
onProgress?.({
toolUseID: progressId,
data: { type: 'query_update', query },
})
const content = await searchParallelWeb({
query,
sessionId,
allowedDomains: input.allowed_domains,
blockedDomains: input.blocked_domains,
signal: context.abortController.signal,
})
onProgress?.({
toolUseID: progressId,
data: { type: 'search_results_received', resultCount: content.length, query },
})
return {
data: {
query,
results: [{ tool_use_id: progressId, content }],
durationSeconds: (performance.now() - startTime) / 1000,
},
}
}

if (!shouldUseNativeWebSearch()) {
return {
data: await runLocalWebSearch(
Expand Down
Loading