Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.
Merged
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
110 changes: 73 additions & 37 deletions src/sources/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,46 @@ type SearxResult = {
publishedDate?: string;
};

function buildQueries(query: string) {
const cleaned = query.trim();
if (!cleaned) return [];
if (cleaned.includes("site:")) return [cleaned];

const repoLike = cleaned.match(/\b([a-z0-9_.-]+\/[a-z0-9_.-]+)\b/i)?.[1];

return [
cleaned,
...(repoLike ? [`site:github.com ${repoLike}`] : []),
...(repoLike ? [] : [`site:github.com ${cleaned}`]),
`site:stackoverflow.com ${cleaned}`,
`${cleaned} official docs`,
];
}

function normalizeResults(list: SearxResult[], depth: Depth): RawResult[] {
const limit = depth === "quick" ? 5 : 10;
const deduped = new Map<string, SearxResult>();

for (const item of list) {
const key = item.url ?? `${item.title ?? "untitled"}:${item.content ?? ""}`;
if (!deduped.has(key)) deduped.set(key, item);
}

return Array.from(deduped.values())
.slice(0, limit)
.map((item, i, arr) => ({
id: `web-${i}`,
type: "web",
title: item.title ?? item.url ?? "Untitled",
snippet: (item.content ?? item.url ?? "").slice(0, 700),
url: item.url,
relevance: Math.max(0.1, 1 - i / Math.max(1, arr.length)),
timestamp: item.publishedDate
? Date.parse(item.publishedDate) || undefined
: undefined,
} satisfies RawResult));
}

export async function searchWeb(
query: string,
baseUrl: string | undefined,
Expand All @@ -23,49 +63,45 @@ export async function searchWeb(
}

try {
const url = new URL("search", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
url.searchParams.set("q", query);
url.searchParams.set("format", "json");
const res = await fetch(url, {
headers: {
Accept: "application/json",
},
});
const collected: SearxResult[] = [];

if (!res.ok) {
return failure(
source,
"request_failed",
res.status === 403
? "SearXNG search failed with status 403. Enable JSON output on the instance."
: `SearXNG search failed with status ${res.status}.`,
);
}
const body = (await res.json()) as { results?: SearxResult[] };
if (!Array.isArray(body.results)) {
return failure(
source,
"invalid_response",
"SearXNG search returned an invalid payload.",
);
for (const candidate of buildQueries(query)) {
const url = new URL("search", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
url.searchParams.set("q", candidate);
url.searchParams.set("format", "json");
const res = await fetch(url, {
headers: {
Accept: "application/json",
},
});

if (!res.ok) {
return failure(
source,
"request_failed",
res.status === 403
? "SearXNG search failed with status 403. Enable JSON output on the instance."
: `SearXNG search failed with status ${res.status}.`,
);
}
const body = (await res.json()) as { results?: SearxResult[] };
if (!Array.isArray(body.results)) {
return failure(
source,
"invalid_response",
"SearXNG search returned an invalid payload.",
);
}

collected.push(...body.results);
if (collected.length > 0) break;
}

const limit = depth === "quick" ? 5 : 10;
const list = body.results.slice(0, limit);
const results = normalizeResults(collected, depth);

return {
source,
results: list.map((item, i) => ({
id: `web-${i}`,
type: source,
title: item.title ?? item.url ?? "Untitled",
snippet: (item.content ?? item.url ?? "").slice(0, 700),
url: item.url,
relevance: Math.max(0.1, 1 - i / Math.max(1, list.length)),
timestamp: item.publishedDate
? Date.parse(item.publishedDate) || undefined
: undefined,
} satisfies RawResult)),
results,
};
} catch (error) {
return failure(
Expand Down
113 changes: 113 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,4 +398,117 @@ describe("opensearch tool", () => {
});
expect(body.sources[0]?.snippet).toContain("self-hosted SearXNG");
});

it("retries web search with developer-focused fallback queries", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ results: [] }),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
results: [
{
title: "GitHub issue result",
url: "https://github.com/example/repo/issues/1",
content: "Relevant developer-facing result.",
},
],
}),
} as Response);
globalThis.fetch = fetchMock;

const hooks = await createHooks();
await hooks.config?.({
opensearch: {
sources: {
session: false,
web: { enabled: true, url: "https://search.example/" },
code: false,
},
depth: "quick",
synth: false,
},
} as never);

const tool = hooks.tool?.opensearch;
if (!tool) throw new Error("opensearch tool missing");

const output = await tool.execute(
{ query: "opencode plugin hooks", sources: ["web"] },
createToolContext("/tmp/opensearch").context,
);
const body = JSON.parse(output) as {
status: string;
sources: Array<{ title: string; url?: string }>;
meta: { sources_yielded: number };
};

expect(body.status).toBe("raw");
expect(body.meta.sources_yielded).toBe(1);
expect(body.sources[0]).toMatchObject({
title: "GitHub issue result",
url: "https://github.com/example/repo/issues/1",
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(decodeURIComponent(String(fetchMock.mock.calls[1]?.[0]))).toContain(
"site:github.com+opencode+plugin+hooks",
);
});

it("prefers owner-repo GitHub disambiguation for repo-like queries", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ results: [] }),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
results: [
{
title: "OpenSearch repository",
url: "https://github.com/kagan-sh/opensearch",
content: "The exact repository match.",
},
],
}),
} as Response);
globalThis.fetch = fetchMock;

const hooks = await createHooks();
await hooks.config?.({
opensearch: {
sources: {
session: false,
web: { enabled: true, url: "https://search.example/" },
code: false,
},
depth: "quick",
synth: false,
},
} as never);

const tool = hooks.tool?.opensearch;
if (!tool) throw new Error("opensearch tool missing");

const output = await tool.execute(
{ query: "kagan-sh/opensearch", sources: ["web"] },
createToolContext("/tmp/opensearch").context,
);
const body = JSON.parse(output) as {
status: string;
sources: Array<{ url?: string }>;
};

expect(body.status).toBe("raw");
expect(body.sources[0]?.url).toBe("https://github.com/kagan-sh/opensearch");
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(decodeURIComponent(String(fetchMock.mock.calls[1]?.[0]))).toContain(
"site:github.com+kagan-sh/opensearch",
);
});
});
Loading