kimi-agent-swarm-cli 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/fixtures/asset-mgmt-roles.json +1543 -0
- package/fixtures/basic-sources.json +58 -0
- package/fixtures/github-repo-landscape.json +308 -0
- package/fixtures/golden-answers.ts +56 -0
- package/fixtures/jsonl-provider.ts +41 -0
- package/fixtures/market-scan.json +246 -0
- package/fixtures/paul-graham-corpus.json +272 -0
- package/fixtures/sellside-research-roles.json +1709 -0
- package/fixtures/youtube-niche.json +262 -0
- package/package.json +45 -0
- package/src/benchmark.ts +151 -0
- package/src/cache.ts +86 -0
- package/src/cli.ts +377 -0
- package/src/command-provider.ts +99 -0
- package/src/config.ts +134 -0
- package/src/costs.ts +134 -0
- package/src/distributed/memory-adapter.ts +152 -0
- package/src/distributed/queue-adapter.ts +29 -0
- package/src/distributed/redis-adapter.ts +185 -0
- package/src/distributed/runner.ts +325 -0
- package/src/distributed/task-splitter.ts +78 -0
- package/src/export.ts +70 -0
- package/src/init.ts +138 -0
- package/src/leaderboard.ts +201 -0
- package/src/providers/brave-provider.ts +161 -0
- package/src/providers/github-provider.ts +151 -0
- package/src/providers/index.ts +49 -0
- package/src/providers/mock-search-provider.ts +45 -0
- package/src/providers/search-provider.ts +12 -0
- package/src/providers/serper-provider.ts +154 -0
- package/src/providers/tavily-provider.ts +158 -0
- package/src/runtime.ts +349 -0
- package/src/scorer.ts +103 -0
- package/src/types.ts +246 -0
- package/src/verifier.ts +369 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { BenchmarkResult, LeaderboardEntry } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface ComparisonResult {
|
|
8
|
+
runIds: string[];
|
|
9
|
+
entries: LeaderboardEntry[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getLeaderboardDir(): string {
|
|
13
|
+
return join(homedir(), ".kasw");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getLeaderboardPath(): string {
|
|
17
|
+
return join(getLeaderboardDir(), "leaderboard.jsonl");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function recordEntry(entry: LeaderboardEntry): Promise<void> {
|
|
21
|
+
const dir = getLeaderboardDir();
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
await appendFile(getLeaderboardPath(), `${JSON.stringify(entry)}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getLeaderboard(profile?: string): Promise<LeaderboardEntry[]> {
|
|
27
|
+
try {
|
|
28
|
+
const text = await readFile(getLeaderboardPath(), "utf8");
|
|
29
|
+
const entries = text
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((line) => JSON.parse(line) as LeaderboardEntry);
|
|
33
|
+
|
|
34
|
+
if (profile) {
|
|
35
|
+
return entries.filter((e) => e.profile === profile);
|
|
36
|
+
}
|
|
37
|
+
return entries;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function compareRuns(runIds: string[]): Promise<ComparisonResult> {
|
|
47
|
+
const all = await getLeaderboard();
|
|
48
|
+
const entries = all.filter((e) => runIds.includes(e.runId));
|
|
49
|
+
return { runIds, entries };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function clearLeaderboard(): Promise<void> {
|
|
53
|
+
await writeFile(getLeaderboardPath(), "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function escapeHtml(text: string): string {
|
|
57
|
+
return text
|
|
58
|
+
.replace(/&/g, "&")
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/"/g, """)
|
|
62
|
+
.replace(/'/g, "'");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function trendLine(values: number[], width: number, height: number): string {
|
|
66
|
+
if (values.length < 2) return "";
|
|
67
|
+
const min = Math.min(...values);
|
|
68
|
+
const max = Math.max(...values);
|
|
69
|
+
const range = max - min || 1;
|
|
70
|
+
const stepX = width / (values.length - 1);
|
|
71
|
+
const points = values
|
|
72
|
+
.map((v, i) => {
|
|
73
|
+
const x = i * stepX;
|
|
74
|
+
const y = height - ((v - min) / range) * height;
|
|
75
|
+
return `${x},${y}`;
|
|
76
|
+
})
|
|
77
|
+
.join(" ");
|
|
78
|
+
return `<polyline points="${points}" fill="none" stroke="#2563eb" stroke-width="2" />`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderProfileSection(profile: string, entries: LeaderboardEntry[]): string {
|
|
82
|
+
const sorted = [...entries].sort(
|
|
83
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
84
|
+
);
|
|
85
|
+
const precisionValues = sorted.map((e) => e.scores.precision);
|
|
86
|
+
const recallValues = sorted.map((e) => e.scores.recall);
|
|
87
|
+
const f1Values = sorted.map((e) => e.scores.f1);
|
|
88
|
+
|
|
89
|
+
const bestF1 = sorted.reduce((best, e) => (e.scores.f1 > best.scores.f1 ? e : best), sorted[0]);
|
|
90
|
+
|
|
91
|
+
const rows = sorted
|
|
92
|
+
.map(
|
|
93
|
+
(e) => `
|
|
94
|
+
<tr>
|
|
95
|
+
<td>${escapeHtml(e.timestamp)}</td>
|
|
96
|
+
<td>${escapeHtml(e.runId)}</td>
|
|
97
|
+
<td>${e.scores.precision.toFixed(4)}</td>
|
|
98
|
+
<td>${e.scores.recall.toFixed(4)}</td>
|
|
99
|
+
<td>${e.scores.citationAccuracy.toFixed(4)}</td>
|
|
100
|
+
<td>${e.scores.f1.toFixed(4)}</td>
|
|
101
|
+
<td>${e.scores.passed ? "✅" : "❌"}</td>
|
|
102
|
+
</tr>
|
|
103
|
+
`,
|
|
104
|
+
)
|
|
105
|
+
.join("");
|
|
106
|
+
|
|
107
|
+
return `
|
|
108
|
+
<section>
|
|
109
|
+
<h2>${escapeHtml(profile)}</h2>
|
|
110
|
+
<p>Best F1: ${bestF1.scores.f1.toFixed(4)} (run ${escapeHtml(bestF1.runId)})</p>
|
|
111
|
+
<div class="charts">
|
|
112
|
+
<div class="chart">
|
|
113
|
+
<h3>Precision</h3>
|
|
114
|
+
<svg viewBox="0 0 300 100" preserveAspectRatio="none">${trendLine(precisionValues, 300, 100)}</svg>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="chart">
|
|
117
|
+
<h3>Recall</h3>
|
|
118
|
+
<svg viewBox="0 0 300 100" preserveAspectRatio="none">${trendLine(recallValues, 300, 100)}</svg>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="chart">
|
|
121
|
+
<h3>F1</h3>
|
|
122
|
+
<svg viewBox="0 0 300 100" preserveAspectRatio="none">${trendLine(f1Values, 300, 100)}</svg>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<table>
|
|
126
|
+
<thead>
|
|
127
|
+
<tr>
|
|
128
|
+
<th>Timestamp</th>
|
|
129
|
+
<th>Run ID</th>
|
|
130
|
+
<th>Precision</th>
|
|
131
|
+
<th>Recall</th>
|
|
132
|
+
<th>Citation</th>
|
|
133
|
+
<th>F1</th>
|
|
134
|
+
<th>Passed</th>
|
|
135
|
+
</tr>
|
|
136
|
+
</thead>
|
|
137
|
+
<tbody>${rows}</tbody>
|
|
138
|
+
</table>
|
|
139
|
+
</section>
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function generateHtmlReport(
|
|
144
|
+
entries: LeaderboardEntry[],
|
|
145
|
+
outPath: string,
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
const byProfile = entries.reduce<Record<string, LeaderboardEntry[]>>((acc, entry) => {
|
|
148
|
+
acc[entry.profile] = acc[entry.profile] ?? [];
|
|
149
|
+
acc[entry.profile].push(entry);
|
|
150
|
+
return acc;
|
|
151
|
+
}, {});
|
|
152
|
+
|
|
153
|
+
const sections = Object.entries(byProfile)
|
|
154
|
+
.map(([profile, profileEntries]) => renderProfileSection(profile, profileEntries))
|
|
155
|
+
.join("");
|
|
156
|
+
|
|
157
|
+
const html = `<!DOCTYPE html>
|
|
158
|
+
<html lang="en">
|
|
159
|
+
<head>
|
|
160
|
+
<meta charset="UTF-8" />
|
|
161
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
162
|
+
<title>KASW Benchmark Leaderboard</title>
|
|
163
|
+
<style>
|
|
164
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem; color: #111; }
|
|
165
|
+
h1 { font-size: 1.75rem; margin-bottom: 1rem; }
|
|
166
|
+
section { margin-bottom: 3rem; }
|
|
167
|
+
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
168
|
+
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #ddd; }
|
|
169
|
+
th { font-weight: 600; }
|
|
170
|
+
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }
|
|
171
|
+
.chart { background: #f8fafc; border-radius: 0.5rem; padding: 0.75rem; }
|
|
172
|
+
.chart h3 { margin: 0 0 0.5rem; font-size: 0.875rem; color: #475569; }
|
|
173
|
+
svg { width: 100%; height: 100px; }
|
|
174
|
+
</style>
|
|
175
|
+
</head>
|
|
176
|
+
<body>
|
|
177
|
+
<h1>KASW Benchmark Leaderboard</h1>
|
|
178
|
+
<p>Generated at ${new Date().toISOString()}</p>
|
|
179
|
+
${sections}
|
|
180
|
+
</body>
|
|
181
|
+
</html>`;
|
|
182
|
+
|
|
183
|
+
await writeFile(outPath, html);
|
|
184
|
+
return outPath;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function getGitCommit(): Promise<string | undefined> {
|
|
188
|
+
try {
|
|
189
|
+
const result = await new Promise<string>((resolve, reject) => {
|
|
190
|
+
import("node:child_process").then(({ exec }) => {
|
|
191
|
+
exec("git rev-parse HEAD", (error, stdout) => {
|
|
192
|
+
if (error) reject(error);
|
|
193
|
+
else resolve(stdout.trim());
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
return result;
|
|
198
|
+
} catch {
|
|
199
|
+
return process.env.KASW_GIT_COMMIT;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { SearchOptions, SearchProvider } from "./search-provider";
|
|
2
|
+
import type { Source, SourceScores, UsageMetrics } from "../types";
|
|
3
|
+
|
|
4
|
+
interface BraveResult {
|
|
5
|
+
title: string;
|
|
6
|
+
url: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
age?: string;
|
|
9
|
+
extra_snippets?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface BraveResponse {
|
|
13
|
+
web?: {
|
|
14
|
+
results?: BraveResult[];
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function inferSourceClass(url: string): "primary" | "secondary" {
|
|
19
|
+
try {
|
|
20
|
+
const hostname = new URL(url).hostname.toLowerCase();
|
|
21
|
+
if (
|
|
22
|
+
hostname === "github.com" ||
|
|
23
|
+
hostname.endsWith(".github.io") ||
|
|
24
|
+
hostname === "arxiv.org" ||
|
|
25
|
+
hostname.endsWith(".gov") ||
|
|
26
|
+
hostname.endsWith(".edu") ||
|
|
27
|
+
hostname.endsWith(".ac.uk")
|
|
28
|
+
) {
|
|
29
|
+
return "primary";
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// invalid URL, fall through to secondary
|
|
33
|
+
}
|
|
34
|
+
return "secondary";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parsePublishedAt(dateText?: string): string {
|
|
38
|
+
if (!dateText) return new Date().toISOString().split("T")[0];
|
|
39
|
+
|
|
40
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateText)) {
|
|
41
|
+
return dateText;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parsed = new Date(dateText);
|
|
45
|
+
if (!Number.isNaN(parsed.getTime())) {
|
|
46
|
+
return parsed.toISOString().split("T")[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new Date().toISOString().split("T")[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildScores(sourceClass: "primary" | "secondary", rank: number): SourceScores {
|
|
53
|
+
// Slight relevance decay by rank.
|
|
54
|
+
const relevance = Math.max(2, 5 - Math.floor(rank / 5));
|
|
55
|
+
return {
|
|
56
|
+
relevance,
|
|
57
|
+
authority: sourceClass === "primary" ? 4 : 3,
|
|
58
|
+
freshness: 4,
|
|
59
|
+
diversity: 3,
|
|
60
|
+
extractionValue: sourceClass === "primary" ? 4 : 3,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mockResults(objective: string): Source[] {
|
|
65
|
+
const now = new Date().toISOString().split("T")[0];
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
id: "BRAVE-001",
|
|
69
|
+
url: "https://example.com/mock/brave-result-1",
|
|
70
|
+
title: `Brave mock result for: ${objective}`,
|
|
71
|
+
sourceClass: "primary",
|
|
72
|
+
publishedAt: now,
|
|
73
|
+
discoveredBy: "brave-search-provider-mock",
|
|
74
|
+
scores: { relevance: 5, authority: 4, freshness: 5, diversity: 3, extractionValue: 4 },
|
|
75
|
+
claims: [
|
|
76
|
+
"Brave mock search returned a high-relevance primary source.",
|
|
77
|
+
"This is a deterministic fixture for CI and development.",
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "BRAVE-002",
|
|
82
|
+
url: "https://example.com/mock/brave-result-2",
|
|
83
|
+
title: "Brave mock secondary perspective",
|
|
84
|
+
sourceClass: "secondary",
|
|
85
|
+
publishedAt: now,
|
|
86
|
+
discoveredBy: "brave-search-provider-mock",
|
|
87
|
+
scores: { relevance: 3, authority: 3, freshness: 4, diversity: 4, extractionValue: 3 },
|
|
88
|
+
claims: ["Secondary sources broaden coverage in a wide search."],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class BraveSearchProvider implements SearchProvider {
|
|
94
|
+
readonly name = "brave";
|
|
95
|
+
private readonly apiKey: string;
|
|
96
|
+
private readonly metrics?: UsageMetrics;
|
|
97
|
+
|
|
98
|
+
constructor(apiKey: string, metrics?: UsageMetrics) {
|
|
99
|
+
this.apiKey = apiKey;
|
|
100
|
+
this.metrics = metrics;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async search({ objective, maxResults }: SearchOptions): Promise<Source[]> {
|
|
104
|
+
if (this.metrics) {
|
|
105
|
+
this.metrics.providerCalls += 1;
|
|
106
|
+
this.metrics.apiCalls += 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!this.apiKey) {
|
|
110
|
+
if (process.env.BRAVE_MOCK === "1") {
|
|
111
|
+
return mockResults(objective).slice(0, maxResults);
|
|
112
|
+
}
|
|
113
|
+
throw new Error(
|
|
114
|
+
"BRAVE_API_KEY environment variable is required for the brave provider (or set BRAVE_MOCK=1 for CI)",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
119
|
+
url.searchParams.set("q", objective);
|
|
120
|
+
url.searchParams.set("count", String(Math.min(Math.max(maxResults, 1), 20)));
|
|
121
|
+
|
|
122
|
+
const response = await fetch(url.toString(), {
|
|
123
|
+
method: "GET",
|
|
124
|
+
headers: {
|
|
125
|
+
Accept: "application/json",
|
|
126
|
+
"X-Subscription-Token": this.apiKey,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
const body = await response.text().catch(() => "unknown");
|
|
132
|
+
if (response.status === 429) {
|
|
133
|
+
throw new Error(`Brave API rate limit exceeded (429): ${body}`);
|
|
134
|
+
}
|
|
135
|
+
if (response.status === 401) {
|
|
136
|
+
throw new Error(`Brave API unauthorized (401): check BRAVE_API_KEY`);
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Brave API error: ${response.status} ${body}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const data = (await response.json()) as BraveResponse;
|
|
142
|
+
const results = data.web?.results ?? [];
|
|
143
|
+
|
|
144
|
+
return results.slice(0, maxResults).map((result, index) => {
|
|
145
|
+
const sourceClass = inferSourceClass(result.url);
|
|
146
|
+
const claim = [result.description, ...(result.extra_snippets ?? [])]
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join(" ");
|
|
149
|
+
return {
|
|
150
|
+
id: `BRAVE-${String(index + 1).padStart(3, "0")}`,
|
|
151
|
+
url: result.url,
|
|
152
|
+
title: result.title,
|
|
153
|
+
sourceClass,
|
|
154
|
+
publishedAt: parsePublishedAt(result.age),
|
|
155
|
+
discoveredBy: "brave-search-provider",
|
|
156
|
+
scores: buildScores(sourceClass, index),
|
|
157
|
+
claims: [claim || result.title],
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { SearchOptions, SearchProvider } from "./search-provider";
|
|
2
|
+
import type { Source, SourceScores, UsageMetrics } from "../types";
|
|
3
|
+
|
|
4
|
+
interface GitHubRepoItem {
|
|
5
|
+
full_name: string;
|
|
6
|
+
html_url: string;
|
|
7
|
+
description?: string | null;
|
|
8
|
+
stargazers_count: number;
|
|
9
|
+
updated_at: string;
|
|
10
|
+
language?: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface GitHubResponse {
|
|
14
|
+
items?: GitHubRepoItem[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseUpdatedAt(dateText?: string): string {
|
|
18
|
+
if (!dateText) return new Date().toISOString().split("T")[0];
|
|
19
|
+
const parsed = new Date(dateText);
|
|
20
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
21
|
+
return new Date().toISOString().split("T")[0];
|
|
22
|
+
}
|
|
23
|
+
return parsed.toISOString().split("T")[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function authorityFromStars(stars: number): number {
|
|
27
|
+
if (stars >= 50000) return 5;
|
|
28
|
+
if (stars >= 10000) return 4;
|
|
29
|
+
if (stars >= 1000) return 3;
|
|
30
|
+
if (stars >= 100) return 2;
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildScores(stars: number): SourceScores {
|
|
35
|
+
return {
|
|
36
|
+
relevance: 5,
|
|
37
|
+
authority: authorityFromStars(stars),
|
|
38
|
+
freshness: 4,
|
|
39
|
+
diversity: 3,
|
|
40
|
+
extractionValue: 5,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mockResults(objective: string): Source[] {
|
|
45
|
+
const now = new Date().toISOString().split("T")[0];
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
id: "GITHUB-001",
|
|
49
|
+
url: "https://github.com/mock/example-agent",
|
|
50
|
+
title: `Mock repo for: ${objective}`,
|
|
51
|
+
sourceClass: "primary",
|
|
52
|
+
publishedAt: now,
|
|
53
|
+
discoveredBy: "github-search-provider-mock",
|
|
54
|
+
scores: { relevance: 5, authority: 4, freshness: 5, diversity: 3, extractionValue: 5 },
|
|
55
|
+
claims: [
|
|
56
|
+
"Mock GitHub repository for CI and development.",
|
|
57
|
+
"Language: TypeScript, Stars: 15000.",
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "GITHUB-002",
|
|
62
|
+
url: "https://github.com/mock/secondary-agent",
|
|
63
|
+
title: "Mock secondary agent repo",
|
|
64
|
+
sourceClass: "primary",
|
|
65
|
+
publishedAt: now,
|
|
66
|
+
discoveredBy: "github-search-provider-mock",
|
|
67
|
+
scores: { relevance: 4, authority: 3, freshness: 4, diversity: 4, extractionValue: 4 },
|
|
68
|
+
claims: ["Secondary mock repository with lower star count."],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class GitHubSearchProvider implements SearchProvider {
|
|
74
|
+
readonly name = "github";
|
|
75
|
+
private readonly token: string;
|
|
76
|
+
private readonly metrics?: UsageMetrics;
|
|
77
|
+
|
|
78
|
+
constructor(token: string, metrics?: UsageMetrics) {
|
|
79
|
+
this.token = token;
|
|
80
|
+
this.metrics = metrics;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async search({ objective, maxResults }: SearchOptions): Promise<Source[]> {
|
|
84
|
+
if (this.metrics) {
|
|
85
|
+
this.metrics.providerCalls += 1;
|
|
86
|
+
this.metrics.apiCalls += 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!this.token) {
|
|
90
|
+
if (process.env.GITHUB_MOCK === "1") {
|
|
91
|
+
return mockResults(objective).slice(0, maxResults);
|
|
92
|
+
}
|
|
93
|
+
throw new Error(
|
|
94
|
+
"GITHUB_TOKEN environment variable is required for the github provider (or set GITHUB_MOCK=1 for CI)",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const url = new URL("https://api.github.com/search/repositories");
|
|
99
|
+
url.searchParams.set("q", objective);
|
|
100
|
+
url.searchParams.set("per_page", String(Math.min(Math.max(maxResults, 1), 100)));
|
|
101
|
+
url.searchParams.set("sort", "stars");
|
|
102
|
+
url.searchParams.set("order", "desc");
|
|
103
|
+
|
|
104
|
+
const headers: Record<string, string> = {
|
|
105
|
+
Accept: "application/vnd.github+json",
|
|
106
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
107
|
+
};
|
|
108
|
+
if (this.token) {
|
|
109
|
+
headers.Authorization = `Bearer ${this.token}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const response = await fetch(url.toString(), {
|
|
113
|
+
method: "GET",
|
|
114
|
+
headers,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const body = await response.text().catch(() => "unknown");
|
|
119
|
+
if (response.status === 403 || response.status === 429) {
|
|
120
|
+
throw new Error(`GitHub API rate limit exceeded (${response.status}): ${body}`);
|
|
121
|
+
}
|
|
122
|
+
if (response.status === 401) {
|
|
123
|
+
throw new Error(`GitHub API unauthorized (401): check GITHUB_TOKEN`);
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`GitHub API error: ${response.status} ${body}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const data = (await response.json()) as GitHubResponse;
|
|
129
|
+
const results = data.items ?? [];
|
|
130
|
+
|
|
131
|
+
return results.slice(0, maxResults).map((result, index) => {
|
|
132
|
+
const description = result.description ?? "";
|
|
133
|
+
const language = result.language ?? "unknown";
|
|
134
|
+
const stars = result.stargazers_count ?? 0;
|
|
135
|
+
return {
|
|
136
|
+
id: `GITHUB-${String(index + 1).padStart(3, "0")}`,
|
|
137
|
+
url: result.html_url,
|
|
138
|
+
title: result.full_name,
|
|
139
|
+
sourceClass: "primary",
|
|
140
|
+
publishedAt: parseUpdatedAt(result.updated_at),
|
|
141
|
+
discoveredBy: "github-search-provider",
|
|
142
|
+
scores: buildScores(stars),
|
|
143
|
+
claims: [
|
|
144
|
+
[description, `Language: ${language}`, `Stars: ${stars.toLocaleString()}`]
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.join(" "),
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { BraveSearchProvider } from "./brave-provider";
|
|
2
|
+
import { GitHubSearchProvider } from "./github-provider";
|
|
3
|
+
import { MockSearchProvider } from "./mock-search-provider";
|
|
4
|
+
import { SerperSearchProvider } from "./serper-provider";
|
|
5
|
+
import { TavilySearchProvider } from "./tavily-provider";
|
|
6
|
+
import type { SearchProvider } from "./search-provider";
|
|
7
|
+
import type { UsageMetrics } from "../types";
|
|
8
|
+
|
|
9
|
+
export * from "./search-provider";
|
|
10
|
+
export { BraveSearchProvider } from "./brave-provider";
|
|
11
|
+
export { GitHubSearchProvider } from "./github-provider";
|
|
12
|
+
export { MockSearchProvider } from "./mock-search-provider";
|
|
13
|
+
export { SerperSearchProvider } from "./serper-provider";
|
|
14
|
+
export { TavilySearchProvider } from "./tavily-provider";
|
|
15
|
+
|
|
16
|
+
export interface CreateSearchProviderOptions {
|
|
17
|
+
credential?: string;
|
|
18
|
+
metrics?: UsageMetrics;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createSearchProvider(
|
|
22
|
+
name: string,
|
|
23
|
+
options: CreateSearchProviderOptions = {},
|
|
24
|
+
): SearchProvider {
|
|
25
|
+
const { credential, metrics } = options;
|
|
26
|
+
|
|
27
|
+
switch (name) {
|
|
28
|
+
case "mock":
|
|
29
|
+
return new MockSearchProvider(metrics);
|
|
30
|
+
case "serper": {
|
|
31
|
+
const apiKey = credential ?? process.env.SERPER_API_KEY ?? "";
|
|
32
|
+
return new SerperSearchProvider(apiKey, metrics);
|
|
33
|
+
}
|
|
34
|
+
case "tavily": {
|
|
35
|
+
const apiKey = credential ?? process.env.TAVILY_API_KEY ?? "";
|
|
36
|
+
return new TavilySearchProvider(apiKey, metrics);
|
|
37
|
+
}
|
|
38
|
+
case "brave": {
|
|
39
|
+
const apiKey = credential ?? process.env.BRAVE_API_KEY ?? "";
|
|
40
|
+
return new BraveSearchProvider(apiKey, metrics);
|
|
41
|
+
}
|
|
42
|
+
case "github": {
|
|
43
|
+
const token = credential ?? process.env.GITHUB_TOKEN ?? "";
|
|
44
|
+
return new GitHubSearchProvider(token, metrics);
|
|
45
|
+
}
|
|
46
|
+
default:
|
|
47
|
+
throw new Error(`Unknown search provider: ${name}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SearchOptions, SearchProvider } from "./search-provider";
|
|
2
|
+
import type { Source, UsageMetrics } from "../types";
|
|
3
|
+
|
|
4
|
+
export class MockSearchProvider implements SearchProvider {
|
|
5
|
+
readonly name = "mock";
|
|
6
|
+
private readonly metrics?: UsageMetrics;
|
|
7
|
+
|
|
8
|
+
constructor(metrics?: UsageMetrics) {
|
|
9
|
+
this.metrics = metrics;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async search({ objective, depth }: SearchOptions): Promise<Source[]> {
|
|
13
|
+
if (this.metrics) {
|
|
14
|
+
this.metrics.providerCalls += 1;
|
|
15
|
+
this.metrics.apiCalls += 1;
|
|
16
|
+
}
|
|
17
|
+
const now = new Date().toISOString().split("T")[0];
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
id: "MOCK-001",
|
|
21
|
+
url: "https://example.com/mock/result-1",
|
|
22
|
+
title: `Mock result for: ${objective}`,
|
|
23
|
+
sourceClass: "primary-analysis",
|
|
24
|
+
publishedAt: now,
|
|
25
|
+
discoveredBy: "mock-search-provider",
|
|
26
|
+
scores: { relevance: 5, authority: 4, freshness: 5, diversity: 3, extractionValue: 4 },
|
|
27
|
+
claims: [
|
|
28
|
+
`Mock search was executed with depth '${depth}'.`,
|
|
29
|
+
"This is a placeholder result for development and CI.",
|
|
30
|
+
"Replace with a real provider (serper, tavily, etc.) for live searches.",
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "MOCK-002",
|
|
35
|
+
url: "https://example.com/mock/result-2",
|
|
36
|
+
title: "Mock secondary perspective",
|
|
37
|
+
sourceClass: "secondary",
|
|
38
|
+
publishedAt: now,
|
|
39
|
+
discoveredBy: "mock-search-provider",
|
|
40
|
+
scores: { relevance: 3, authority: 2, freshness: 4, diversity: 4, extractionValue: 2 },
|
|
41
|
+
claims: ["Secondary sources can provide alternative viewpoints in a wide search."],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SearchDepth, Source } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface SearchOptions {
|
|
4
|
+
objective: string;
|
|
5
|
+
depth: SearchDepth;
|
|
6
|
+
maxResults: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SearchProvider {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
search(options: SearchOptions): Promise<Source[]>;
|
|
12
|
+
}
|