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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Optional `youcom_search` tool — You.com web search provider alongside exa_search/firecrawl_search, opt-in via `YDC_API_KEY`
- Server-side Composio session proxy — CLI fetches composio MCP session from Render server's env var instead of requiring local `COMPOSIO_API_KEY` in `.env`
- New `POST /api/composio/session` backend endpoint authenticated via bearer token
- `createSessionFromServer()` method on `ComposioSessionManager` — fallback chain: server proxy → local SDK → user prompt
Expand Down
4 changes: 4 additions & 0 deletions apps/supercode-cli/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ GOOGLE_CSE_ID=""
# Firecrawl
FIRECRAWL_API_KEY=""

# You.com — ⚪ Optional, enables the `youcom_search` tool when set
# https://you.com/platform/api-keys
YDC_API_KEY=""

# ─── Voice / Speech-to-Text (STT) ──────────────
# Used by the CLI voice capture and POST /api/voice/transcribe.
# Voice capture uses Smallest.ai (Pulse STT) exclusively.
Expand Down
3 changes: 3 additions & 0 deletions apps/supercode-cli/server/src/agents/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { firecrawlScrapeTool } from "./firecrawl_scrape.ts"
import { firecrawlMapTool } from "./firecrawl_map.ts"
import { exaSearchTool } from "./exa_search.ts"
import { exaFetchTool } from "./exa_fetch.ts"
import { youcomSearchTool } from "./youcom_search.ts"
import { codeExecTool } from "./code_exec.ts"
import { readInstructionsTool } from "./read_instructions.ts"
import { switchToAgentModeTool } from "./switch_to_agent_mode.ts"
Expand Down Expand Up @@ -63,6 +64,7 @@ export const toolMeta: Record<string, ToolMeta> = {
firecrawl_map: { category: "web", requiresPermission: false, description: firecrawlMapTool.description },
exa_search: { category: "web", requiresPermission: false, description: exaSearchTool.description },
exa_fetch: { category: "web", requiresPermission: false, description: exaFetchTool.description },
youcom_search: { category: "web", requiresPermission: false, description: youcomSearchTool.description },
code_exec: { category: "execute", requiresPermission: true, description: codeExecTool.description },
read_instructions: { category: "read", requiresPermission: false, description: readInstructionsTool.description },
switch_to_agent_mode: { category: "agent", requiresPermission: false, description: switchToAgentModeTool.description },
Expand Down Expand Up @@ -90,6 +92,7 @@ export const tools: Record<string, unknown> = {
firecrawl_map: asSdk(firecrawlMapTool),
exa_search: asSdk(exaSearchTool),
exa_fetch: asSdk(exaFetchTool),
youcom_search: asSdk(youcomSearchTool),
code_exec: withPermissionSdk("code_exec", codeExecTool),
read_instructions: asSdk(readInstructionsTool),
switch_to_agent_mode: asSdk(switchToAgentModeTool),
Expand Down
62 changes: 62 additions & 0 deletions apps/supercode-cli/server/src/agents/tools/youcom_search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { z } from "zod"
import { youcomFetch } from "../../lib/youcom"
import { serialize, ok, fail } from "../../cli/ai/tool-result"
import { defineTool } from "../lib/define.ts"

const youcomSearchSchema = z.object({
query: z.string().min(1).describe("Search query"),
maxResults: z.number().int().min(1).max(20).optional().default(5).describe("Maximum number of search results to return (1-20)"),
})

export type YoucomSearchArgs = z.infer<typeof youcomSearchSchema>

function mapYoucomResults(data: any, maxResults: number) {
const results = data?.results
const webResults = Array.isArray(results?.web) ? results.web : []
const newsResults = Array.isArray(results?.news) ? results.news : []
const flat = [...webResults, ...newsResults]
return flat.slice(0, maxResults).map((item: any) => ({
title: String(item.title ?? ""),
snippet: String(item.description ?? item.snippet ?? ""),
link: String(item.url ?? ""),
publishedDate: item.published_date ?? item.publishedDate ?? null,
}))
}

const _def = {
description:
"Search the web using the You.com Search API. " +
"Returns relevant results with titles, snippets, and URLs. " +
"Best for finding current information, news, documentation, and any topic the user asks about. " +
"Returns a structured result: { success: true, data: { query, results: [...], provider } } with title/snippet/link, " +
"or { success: false, error } when search is unavailable. " +
"If success is false, do NOT invent search results — relay the error to the user.",
Comment thread
vercel[bot] marked this conversation as resolved.
inputSchema: youcomSearchSchema,
execute: async (input: YoucomSearchArgs, ctx?: { signal?: AbortSignal }) => {
const { query, maxResults } = youcomSearchSchema.parse(input)
return serialize(async () => {
const resp = await youcomFetch({
apiPath: "/v1/search",
body: { query, count: maxResults },
timeout: 30000,
signal: ctx?.signal,
})

if (resp.ok) {
return ok({
query,
provider: "youcom",
results: mapYoucomResults(resp.data, maxResults),
})
}

return fail(
`Web search failed via You.com: ${resp.error}`,
resp.hint ?? "Set a valid YDC_API_KEY, or use exa_search / firecrawl_search instead.",
)
})
},
}

export const youcomSearchTool = defineTool(_def)
export default youcomSearchTool
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,21 @@ export async function buildToolsForTurn(
// missing/invalid the tools fall back to the authenticated server proxy and
// cross-provider search fallback (Exa ↔ Firecrawl).

// You.com search is strictly opt-in: only expose the tool when YDC_API_KEY
// is set, so existing users see no change.
if (!process.env.YDC_API_KEY) {
delete toolsToUse.youcom_search
}

const preferenceHints: string[] = []

preferenceHints.push(
"For general web search, prefer `exa_search` (Exa). " +
"If Exa fails, it automatically falls back to Firecrawl. " +
"You may also call `firecrawl_search` directly. " +
(process.env.YDC_API_KEY
? "`youcom_search` (You.com) is also available as an additional search provider. "
: "") +
"Use `firecrawl_scrape` when the user asks for deep websearch or webscraping " +
"(extracting full page content, following links, or fetching structured data from a page). " +
"Use `firecrawl_map` to discover URLs on a site. " +
Expand Down
1 change: 1 addition & 0 deletions apps/supercode-cli/server/src/cli/ai/tool-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const EMPTY_SENTINEL_GUARDED = new Set([
"web_search",
"firecrawl_search",
"exa_search",
"youcom_search",
"read_file",
"search_files",
"read_instructions",
Expand Down
2 changes: 2 additions & 0 deletions apps/supercode-cli/server/src/lib/youcom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Re-export shim — implementation in `src/runtime/research/youcom.ts`. */
export * from "../runtime/research/youcom.ts"
37 changes: 37 additions & 0 deletions apps/supercode-cli/server/src/runtime/research/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,28 @@ import { afterEach, beforeEach, expect, test } from "bun:test"
import { exaSearchTool } from "../../agents/tools/exa_search"
import { firecrawlSearchTool } from "../../agents/tools/firecrawl_search"
import { webSearchTool } from "../../agents/tools/web_search"
import { youcomSearchTool } from "../../agents/tools/youcom_search"

const originalFetch = globalThis.fetch
let exaKey: string | undefined
let firecrawlKey: string | undefined
let ydcKey: string | undefined
beforeEach(() => {
exaKey = process.env.EXA_API_KEY
firecrawlKey = process.env.FIRECRAWL_API_KEY
ydcKey = process.env.YDC_API_KEY
process.env.EXA_API_KEY = "test-only-placeholder"
process.env.FIRECRAWL_API_KEY = "test-only-placeholder"
process.env.YDC_API_KEY = "test-only-placeholder"
})
afterEach(() => {
globalThis.fetch = originalFetch
if (exaKey === undefined) delete process.env.EXA_API_KEY
else process.env.EXA_API_KEY = exaKey
if (firecrawlKey === undefined) delete process.env.FIRECRAWL_API_KEY
else process.env.FIRECRAWL_API_KEY = firecrawlKey
if (ydcKey === undefined) delete process.env.YDC_API_KEY
else process.env.YDC_API_KEY = ydcKey
})
const parse = (value: unknown) => JSON.parse(String(value))

Expand Down Expand Up @@ -56,3 +62,34 @@ test("malformed responses fail while valid empty results remain empty", async ()
expect(empty.success).toBe(true)
expect(empty.data.results).toEqual([])
})

test("youcom_search maps results and reports provider", async () => {
let hitUrl: string | undefined
let hitBody: any
globalThis.fetch = (async (url: string | URL | Request, options?: RequestInit) => {
hitUrl = String(url)
hitBody = JSON.parse(String(options?.body))
return Response.json({ results: { web: [{ title: "Docs", url: "https://example.com/docs", description: "Official fixture" }] } })
}) as typeof fetch
const result = parse(await youcomSearchTool.execute({ query: "fixture docs", maxResults: 3 }))
expect(result.success).toBe(true)
expect(result.data.provider).toBe("youcom")
expect(result.data.results[0].link).toBe("https://example.com/docs")
expect(hitUrl).toContain("api.ydc-index.io/v1/search")
expect(hitBody.count).toBe(3)
})

test("youcom_search fails cleanly without YDC_API_KEY", async () => {
delete process.env.YDC_API_KEY
const result = parse(await youcomSearchTool.execute({ query: "fixture" }))
expect(result.success).toBe(false)
expect(result.error).toContain("YDC_API_KEY")
expect(result.hint).toContain("exa_search")
})

test("youcom_search surfaces auth failures with a hint", async () => {
globalThis.fetch = Object.assign(async () => new Response("unauthorized", { status: 401 }), { preconnect: originalFetch.preconnect })
const result = parse(await youcomSearchTool.execute({ query: "fixture" }))
expect(result.success).toBe(false)
expect(result.hint).toContain("YDC_API_KEY")
})
111 changes: 111 additions & 0 deletions apps/supercode-cli/server/src/runtime/research/youcom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { loadEnvOnce } from "../config/load-env"

const YOUCOM_BASE = "https://api.ydc-index.io"

export interface YoucomOptions {
apiPath: string
body: Record<string, unknown>
timeout?: number
/** Optional caller-provided abort signal (e.g. the tool execution signal); combined with the timeout. */
signal?: AbortSignal
}

export interface YoucomResult {
ok: boolean
data?: any
error?: string
hint?: string
status?: number
}

function statusHint(status: number): string | undefined {
if (status === 429) return "Rate limited. Try again later."
if (status === 401 || status === 403) return "Invalid YDC_API_KEY. Check your You.com API key."
return undefined
}

/** Combine an optional external signal with a timeout, following the AbortController pattern used across the repo. */
function withTimeoutSignal(timeout: number, signal?: AbortSignal): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeout)
const onAbort = () => controller.abort()
if (signal) {
if (signal.aborted) controller.abort()
else signal.addEventListener("abort", onAbort)
}
return {
signal: controller.signal,
cleanup: () => {
clearTimeout(timer)
if (signal) signal.removeEventListener("abort", onAbort)
},
}
}

export function youcomFetch({
apiPath,
body,
timeout = 30000,
signal,
}: YoucomOptions): Promise<YoucomResult> {
loadEnvOnce()
const apiKey = process.env.YDC_API_KEY

if (!apiKey) {
return Promise.resolve({
ok: false,
error: "YDC_API_KEY is not set",
hint: "Set YDC_API_KEY to use youcom_search, or use exa_search / firecrawl_search instead.",
})
}

return callYoucomDirect(apiKey, apiPath, body, timeout, signal)
}

async function callYoucomDirect(
apiKey: string,
apiPath: string,
body: Record<string, unknown>,
timeout: number,
signal?: AbortSignal,
): Promise<YoucomResult> {
const timeoutSignal = withTimeoutSignal(timeout, signal)
try {
const res = await fetch(`${YOUCOM_BASE}${apiPath}`, {
method: "POST",
headers: {
"X-API-Key": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: timeoutSignal.signal,
})

const data = await res.json().catch(() => ({}))

if (!res.ok) {
return {
ok: false,
error: `You.com returned HTTP ${res.status}`,
hint: statusHint(res.status),
status: res.status,
}
}

const results = data?.results
const hasWebOrNews = Array.isArray(results?.web) || Array.isArray(results?.news)
if (!data || typeof data !== "object" || (apiPath === "/v1/search" && !hasWebOrNews)) {
return { ok: false, error: "You.com returned a malformed response" }
}
return { ok: true, data }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err: any) {
const isTimeout = err?.name === "TimeoutError" || err?.name === "AbortError"
return {
ok: false,
error: isTimeout ? "Request timed out" : (err.message || String(err)),
hint: isTimeout ? "You.com API may be slow or unreachable. Try exa_search, firecrawl_search, or url_fetch instead." : undefined,
}
} finally {
timeoutSignal.cleanup()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** @deprecated use src/agents/tools/youcom_search.ts */
export { youcomSearchTool } from "src/agents/tools/youcom_search.ts"
export { default } from "src/agents/tools/youcom_search.ts"