diff --git a/CHANGELOG.md b/CHANGELOG.md index f574f85..342d6c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/supercode-cli/server/.env.example b/apps/supercode-cli/server/.env.example index 2050ec0..2cf7280 100644 --- a/apps/supercode-cli/server/.env.example +++ b/apps/supercode-cli/server/.env.example @@ -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. diff --git a/apps/supercode-cli/server/src/agents/tools/registry.ts b/apps/supercode-cli/server/src/agents/tools/registry.ts index 2f98e27..975a7d5 100644 --- a/apps/supercode-cli/server/src/agents/tools/registry.ts +++ b/apps/supercode-cli/server/src/agents/tools/registry.ts @@ -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" @@ -63,6 +64,7 @@ export const toolMeta: Record = { 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 }, @@ -90,6 +92,7 @@ export const tools: Record = { 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), diff --git a/apps/supercode-cli/server/src/agents/tools/youcom_search.ts b/apps/supercode-cli/server/src/agents/tools/youcom_search.ts new file mode 100644 index 0000000..b1b8d55 --- /dev/null +++ b/apps/supercode-cli/server/src/agents/tools/youcom_search.ts @@ -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 + +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.", + 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 diff --git a/apps/supercode-cli/server/src/cli/ai/chat/lib/tools-for-turn.ts b/apps/supercode-cli/server/src/cli/ai/chat/lib/tools-for-turn.ts index 2096357..2ebd12e 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/lib/tools-for-turn.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/lib/tools-for-turn.ts @@ -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. " + diff --git a/apps/supercode-cli/server/src/cli/ai/tool-result.ts b/apps/supercode-cli/server/src/cli/ai/tool-result.ts index 9215c09..eccb757 100644 --- a/apps/supercode-cli/server/src/cli/ai/tool-result.ts +++ b/apps/supercode-cli/server/src/cli/ai/tool-result.ts @@ -9,6 +9,7 @@ const EMPTY_SENTINEL_GUARDED = new Set([ "web_search", "firecrawl_search", "exa_search", + "youcom_search", "read_file", "search_files", "read_instructions", diff --git a/apps/supercode-cli/server/src/lib/youcom.ts b/apps/supercode-cli/server/src/lib/youcom.ts new file mode 100644 index 0000000..f4a8410 --- /dev/null +++ b/apps/supercode-cli/server/src/lib/youcom.ts @@ -0,0 +1,2 @@ +/** Re-export shim — implementation in `src/runtime/research/youcom.ts`. */ +export * from "../runtime/research/youcom.ts" diff --git a/apps/supercode-cli/server/src/runtime/research/search.test.ts b/apps/supercode-cli/server/src/runtime/research/search.test.ts index c419e2b..eba36f2 100644 --- a/apps/supercode-cli/server/src/runtime/research/search.test.ts +++ b/apps/supercode-cli/server/src/runtime/research/search.test.ts @@ -2,15 +2,19 @@ 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 @@ -18,6 +22,8 @@ afterEach(() => { 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)) @@ -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") +}) diff --git a/apps/supercode-cli/server/src/runtime/research/youcom.ts b/apps/supercode-cli/server/src/runtime/research/youcom.ts new file mode 100644 index 0000000..ea24983 --- /dev/null +++ b/apps/supercode-cli/server/src/runtime/research/youcom.ts @@ -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 + 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 { + 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, + timeout: number, + signal?: AbortSignal, +): Promise { + 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 } + } 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() + } +} diff --git a/apps/supercode-cli/server/src/tools/definitions/youcom-search.ts b/apps/supercode-cli/server/src/tools/definitions/youcom-search.ts new file mode 100644 index 0000000..881e852 --- /dev/null +++ b/apps/supercode-cli/server/src/tools/definitions/youcom-search.ts @@ -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"