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.
@@ -0,0 +1,154 @@
1
+ import type { SearchOptions, SearchProvider } from "./search-provider";
2
+ import type { Source, SourceScores, UsageMetrics } from "../types";
3
+
4
+ interface SerperOrganicResult {
5
+ title: string;
6
+ link: string;
7
+ snippet: string;
8
+ date?: string;
9
+ }
10
+
11
+ interface SerperResponse {
12
+ organic?: SerperOrganicResult[];
13
+ searchParameters?: {
14
+ q: string;
15
+ };
16
+ }
17
+
18
+ function parseRelativeDate(dateText: string): string | undefined {
19
+ const normalized = dateText.toLowerCase().trim();
20
+ const now = new Date();
21
+
22
+ const dayMatch = normalized.match(/^(\d+)\s+day(?:s)?\s+ago$/);
23
+ if (dayMatch) {
24
+ const days = Number.parseInt(dayMatch[1], 10);
25
+ const date = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
26
+ return date.toISOString().split("T")[0];
27
+ }
28
+
29
+ const monthMatch = normalized.match(/^(\d+)\s+month(?:s)?\s+ago$/);
30
+ if (monthMatch) {
31
+ const months = Number.parseInt(monthMatch[1], 10);
32
+ const date = new Date(now.getFullYear(), now.getMonth() - months, now.getDate());
33
+ return date.toISOString().split("T")[0];
34
+ }
35
+
36
+ const yearMatch = normalized.match(/^(\d+)\s+year(?:s)?\s+ago$/);
37
+ if (yearMatch) {
38
+ const years = Number.parseInt(yearMatch[1], 10);
39
+ const date = new Date(now.getFullYear() - years, now.getMonth(), now.getDate());
40
+ return date.toISOString().split("T")[0];
41
+ }
42
+
43
+ return undefined;
44
+ }
45
+
46
+ function parsePublishedAt(dateText: string): string {
47
+ if (!dateText) return new Date().toISOString().split("T")[0];
48
+
49
+ // ISO date
50
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateText)) {
51
+ return dateText;
52
+ }
53
+
54
+ // Relative date
55
+ const relative = parseRelativeDate(dateText);
56
+ if (relative) return relative;
57
+
58
+ // Month Day, Year (e.g., "May 14, 2026")
59
+ const parsed = new Date(dateText);
60
+ if (!Number.isNaN(parsed.getTime())) {
61
+ return parsed.toISOString().split("T")[0];
62
+ }
63
+
64
+ return new Date().toISOString().split("T")[0];
65
+ }
66
+
67
+ function inferSourceClass(url: string): "primary" | "secondary" {
68
+ try {
69
+ const hostname = new URL(url).hostname.toLowerCase();
70
+ // Treat official code repos, major docs, and government/regulator sites as primary-ish
71
+ if (
72
+ hostname === "github.com" ||
73
+ hostname.endsWith(".github.io") ||
74
+ hostname === "arxiv.org" ||
75
+ hostname.endsWith(".gov")
76
+ ) {
77
+ return "primary";
78
+ }
79
+ } catch {
80
+ // invalid URL, fall through to secondary
81
+ }
82
+ return "secondary";
83
+ }
84
+
85
+ function buildScores(sourceClass: "primary" | "secondary"): SourceScores {
86
+ return {
87
+ relevance: 4,
88
+ authority: sourceClass === "primary" ? 4 : 3,
89
+ freshness: 4,
90
+ diversity: 3,
91
+ extractionValue: sourceClass === "primary" ? 4 : 3,
92
+ };
93
+ }
94
+
95
+ export class SerperSearchProvider implements SearchProvider {
96
+ readonly name = "serper";
97
+ private readonly apiKey: string;
98
+ private readonly metrics?: UsageMetrics;
99
+
100
+ constructor(apiKey: string, metrics?: UsageMetrics) {
101
+ if (!apiKey) {
102
+ throw new Error("SerperSearchProvider requires a non-empty API key");
103
+ }
104
+ this.apiKey = apiKey;
105
+ this.metrics = metrics;
106
+ }
107
+
108
+ async search({ objective, maxResults }: SearchOptions): Promise<Source[]> {
109
+ if (this.metrics) {
110
+ this.metrics.providerCalls += 1;
111
+ this.metrics.apiCalls += 1;
112
+ }
113
+
114
+ const response = await fetch("https://google.serper.dev/search", {
115
+ method: "POST",
116
+ headers: {
117
+ "X-API-KEY": this.apiKey,
118
+ "Content-Type": "application/json",
119
+ },
120
+ body: JSON.stringify({
121
+ q: objective,
122
+ num: Math.min(Math.max(maxResults, 1), 100),
123
+ }),
124
+ });
125
+
126
+ if (!response.ok) {
127
+ const body = await response.text().catch(() => "unknown");
128
+ if (response.status === 429) {
129
+ throw new Error(`Serper API rate limit exceeded (429): ${body}`);
130
+ }
131
+ if (response.status === 401) {
132
+ throw new Error(`Serper API unauthorized (401): check SERPER_API_KEY`);
133
+ }
134
+ throw new Error(`Serper API error: ${response.status} ${body}`);
135
+ }
136
+
137
+ const data = (await response.json()) as SerperResponse;
138
+ const results = data.organic ?? [];
139
+
140
+ return results.slice(0, maxResults).map((result, index) => {
141
+ const sourceClass = inferSourceClass(result.link);
142
+ return {
143
+ id: `SERPER-${String(index + 1).padStart(3, "0")}`,
144
+ url: result.link,
145
+ title: result.title,
146
+ sourceClass,
147
+ publishedAt: parsePublishedAt(result.date ?? ""),
148
+ discoveredBy: "serper-search-provider",
149
+ scores: buildScores(sourceClass),
150
+ claims: [result.snippet],
151
+ };
152
+ });
153
+ }
154
+ }
@@ -0,0 +1,158 @@
1
+ import type { SearchOptions, SearchProvider } from "./search-provider";
2
+ import type { Source, SourceScores, UsageMetrics } from "../types";
3
+
4
+ interface TavilyResult {
5
+ title: string;
6
+ url: string;
7
+ content: string;
8
+ score: number;
9
+ published_date?: string;
10
+ }
11
+
12
+ interface TavilyResponse {
13
+ query?: string;
14
+ answer?: string;
15
+ results?: TavilyResult[];
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(tavilyScore: number, sourceClass: "primary" | "secondary"): SourceScores {
53
+ const scaledRelevance = Math.max(1, Math.min(5, Math.round(tavilyScore * 5)));
54
+ return {
55
+ relevance: scaledRelevance,
56
+ authority: sourceClass === "primary" ? 4 : 3,
57
+ freshness: 4,
58
+ diversity: 3,
59
+ extractionValue: sourceClass === "primary" ? 4 : 3,
60
+ };
61
+ }
62
+
63
+ function mockResults(objective: string): Source[] {
64
+ const now = new Date().toISOString().split("T")[0];
65
+ return [
66
+ {
67
+ id: "TAVILY-001",
68
+ url: "https://example.com/mock/tavily-result-1",
69
+ title: `Tavily mock result for: ${objective}`,
70
+ sourceClass: "primary",
71
+ publishedAt: now,
72
+ discoveredBy: "tavily-search-provider-mock",
73
+ scores: { relevance: 5, authority: 4, freshness: 5, diversity: 3, extractionValue: 4 },
74
+ claims: [
75
+ "Tavily mock search returned a high-relevance primary source.",
76
+ "This is a deterministic fixture for CI and development.",
77
+ ],
78
+ },
79
+ {
80
+ id: "TAVILY-002",
81
+ url: "https://example.com/mock/tavily-result-2",
82
+ title: "Tavily mock secondary perspective",
83
+ sourceClass: "secondary",
84
+ publishedAt: now,
85
+ discoveredBy: "tavily-search-provider-mock",
86
+ scores: { relevance: 3, authority: 3, freshness: 4, diversity: 4, extractionValue: 3 },
87
+ claims: ["Secondary sources broaden coverage in a wide search."],
88
+ },
89
+ ];
90
+ }
91
+
92
+ export class TavilySearchProvider implements SearchProvider {
93
+ readonly name = "tavily";
94
+ private readonly apiKey: string;
95
+ private readonly metrics?: UsageMetrics;
96
+
97
+ constructor(apiKey: string, metrics?: UsageMetrics) {
98
+ this.apiKey = apiKey;
99
+ this.metrics = metrics;
100
+ }
101
+
102
+ async search({ objective, maxResults }: SearchOptions): Promise<Source[]> {
103
+ if (this.metrics) {
104
+ this.metrics.providerCalls += 1;
105
+ this.metrics.apiCalls += 1;
106
+ }
107
+
108
+ if (!this.apiKey) {
109
+ if (process.env.TAVILY_MOCK === "1") {
110
+ return mockResults(objective).slice(0, maxResults);
111
+ }
112
+ throw new Error(
113
+ "TAVILY_API_KEY environment variable is required for the tavily provider (or set TAVILY_MOCK=1 for CI)",
114
+ );
115
+ }
116
+
117
+ const response = await fetch("https://api.tavily.com/search", {
118
+ method: "POST",
119
+ headers: {
120
+ "Content-Type": "application/json",
121
+ },
122
+ body: JSON.stringify({
123
+ api_key: this.apiKey,
124
+ query: objective,
125
+ max_results: Math.min(Math.max(maxResults, 1), 100),
126
+ include_answer: false,
127
+ }),
128
+ });
129
+
130
+ if (!response.ok) {
131
+ const body = await response.text().catch(() => "unknown");
132
+ if (response.status === 429) {
133
+ throw new Error(`Tavily API rate limit exceeded (429): ${body}`);
134
+ }
135
+ if (response.status === 401) {
136
+ throw new Error(`Tavily API unauthorized (401): check TAVILY_API_KEY`);
137
+ }
138
+ throw new Error(`Tavily API error: ${response.status} ${body}`);
139
+ }
140
+
141
+ const data = (await response.json()) as TavilyResponse;
142
+ const results = data.results ?? [];
143
+
144
+ return results.slice(0, maxResults).map((result, index) => {
145
+ const sourceClass = inferSourceClass(result.url);
146
+ return {
147
+ id: `TAVILY-${String(index + 1).padStart(3, "0")}`,
148
+ url: result.url,
149
+ title: result.title,
150
+ sourceClass,
151
+ publishedAt: parsePublishedAt(result.published_date),
152
+ discoveredBy: "tavily-search-provider",
153
+ scores: buildScores(result.score, sourceClass),
154
+ claims: [result.content],
155
+ };
156
+ });
157
+ }
158
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,349 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import { getCachedSources, setCachedSources } from "./cache";
5
+ import { loadCommandSources } from "./command-provider";
6
+ import { loadConfig, resolveProviderCredential } from "./config";
7
+ import {
8
+ calculateActualCost,
9
+ checkBudget,
10
+ checkEstimatedBudget,
11
+ estimateRunCost,
12
+ formatCostReport,
13
+ maxResultsForDepth,
14
+ } from "./costs";
15
+ import { runDistributedWideSearch } from "./distributed/runner";
16
+ import { createSearchProvider } from "./providers";
17
+ import { scoreSource } from "./scorer";
18
+ import { verifyRun } from "./verifier";
19
+ import type {
20
+ BudgetOptions,
21
+ Claim,
22
+ ClaimConfidence,
23
+ ClaimFreshness,
24
+ EnrichedSource,
25
+ ExecutionProfile,
26
+ LoadSourcesOptions,
27
+ ResearchPlan,
28
+ Run,
29
+ RunWideSearchOptions,
30
+ RunWideSearchResult,
31
+ SearchDepth,
32
+ Source,
33
+ UsageMetrics,
34
+ VerificationReport,
35
+ } from "./types";
36
+
37
+ function makeRunId(): string {
38
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
39
+ const suffix = Math.random().toString(36).slice(2, 8);
40
+ return `${stamp}-${suffix}`;
41
+ }
42
+
43
+ function claimConfidence(source: Source): ClaimConfidence {
44
+ const authority = source.scores?.authority ?? 0;
45
+ if (authority >= 4) return "high";
46
+ if (authority >= 2) return "medium";
47
+ return "low";
48
+ }
49
+
50
+ function claimFreshness(source: Source): ClaimFreshness {
51
+ if (!source.publishedAt || source.publishedAt === "unknown") return "unknown";
52
+
53
+ const now = new Date();
54
+ const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
55
+ .toISOString()
56
+ .split("T")[0];
57
+
58
+ return source.publishedAt >= oneYearAgo ? "current" : "stale";
59
+ }
60
+
61
+ const FIXTURE_FILE_MAP: Record<string, string> = {
62
+ fixture: "basic-sources.json",
63
+ "fixture-asset-mgmt": "asset-mgmt-roles.json",
64
+ "fixture-sellside-research": "sellside-research-roles.json",
65
+ "fixture-youtube-niche": "youtube-niche.json",
66
+ "fixture-paul-graham-corpus": "paul-graham-corpus.json",
67
+ "fixture-github-repo-landscape": "github-repo-landscape.json",
68
+ "fixture-market-scan": "market-scan.json",
69
+ };
70
+
71
+ async function loadFixtureSources(profile: ExecutionProfile): Promise<Source[]> {
72
+ const fileName = FIXTURE_FILE_MAP[profile];
73
+ if (fileName === undefined) {
74
+ throw new Error(`unknown fixture profile: ${profile}`);
75
+ }
76
+ const fixtureUrl = new URL(`../fixtures/${fileName}`, import.meta.url);
77
+ const fixture = JSON.parse(await readFile(fixtureUrl, "utf8")) as { sources: Source[] };
78
+ return fixture.sources;
79
+ }
80
+
81
+ async function loadSources({
82
+ profile,
83
+ objective,
84
+ providerCommand,
85
+ providerArgs,
86
+ providerName,
87
+ searchDepth,
88
+ metrics,
89
+ useCache,
90
+ }: LoadSourcesOptions): Promise<Source[]> {
91
+ if (profile.startsWith("fixture")) {
92
+ return loadFixtureSources(profile);
93
+ }
94
+
95
+ if (profile === "local-command") {
96
+ return loadCommandSources({ providerCommand, providerArgs, objective });
97
+ }
98
+
99
+ if (profile === "web-search") {
100
+ const depth = searchDepth ?? "standard";
101
+ const maxResults = maxResultsForDepth(depth);
102
+ const cacheKey = { provider: providerName ?? "mock", objective, depth, maxResults };
103
+
104
+ if (useCache && providerName && providerName !== "mock") {
105
+ const cached = await getCachedSources(cacheKey);
106
+ if (cached) {
107
+ if (metrics) {
108
+ metrics.notes = metrics.notes ? `${metrics.notes}; cache hit` : "cache hit";
109
+ }
110
+ return cached;
111
+ }
112
+ }
113
+
114
+ const config = await loadConfig();
115
+ const credential = resolveProviderCredential(config, providerName ?? "mock");
116
+ const provider = createSearchProvider(providerName ?? "mock", { credential, metrics });
117
+ const sources = await provider.search({
118
+ objective,
119
+ depth,
120
+ maxResults,
121
+ });
122
+
123
+ if (useCache && providerName && providerName !== "mock") {
124
+ await setCachedSources(cacheKey, sources);
125
+ }
126
+
127
+ return sources;
128
+ }
129
+
130
+ throw new Error(`unsupported execution profile: ${profile}`);
131
+ }
132
+
133
+
134
+ function renderSynthesis({
135
+ objective,
136
+ profile,
137
+ sources,
138
+ claims,
139
+ verification,
140
+ }: {
141
+ objective: string;
142
+ profile: ExecutionProfile;
143
+ sources: EnrichedSource[];
144
+ claims: Claim[];
145
+ verification: VerificationReport;
146
+ }): string {
147
+ const accepted = sources.filter((source) => source.decision === "accepted");
148
+ const rejected = sources.filter((source) => source.decision === "rejected");
149
+ const topRows = claims
150
+ .map(
151
+ (claim) =>
152
+ `| ${claim.claim} | ${claim.sourceIds.join(", ")} | ${claim.confidence} |`,
153
+ )
154
+ .join("\n");
155
+
156
+ return `# Wide-Search Synthesis
157
+
158
+ ## Answer
159
+ ${objective}
160
+
161
+ The ${profile} run found ${accepted.length} accepted source(s) and ${rejected.length} rejected source(s). The accepted evidence supports ${claims.length} claim(s).
162
+
163
+ ## Top Findings
164
+ | Finding | Evidence | Confidence |
165
+ | --- | --- | --- |
166
+ ${topRows}
167
+
168
+ ## Source Coverage
169
+ - Accepted sources: ${accepted.length}
170
+ - Rejected sources: ${rejected.length}
171
+ - Verification status: ${verification.status}
172
+
173
+ ## Evidence
174
+ - Source ledger: source-ledger.jsonl
175
+ - Claim ledger: claim-ledger.jsonl
176
+ - Verification: verification-report.json
177
+
178
+ ## Next Human Check
179
+ - Review the accepted sources and rerun with a broader search profile before making production decisions.
180
+ `;
181
+ }
182
+
183
+ function resolveProviderName(profile: ExecutionProfile, providerName?: string): string {
184
+ if (profile === "web-search") {
185
+ return providerName ?? "mock";
186
+ }
187
+ return "mock";
188
+ }
189
+
190
+ export async function runWideSearch({
191
+ objective,
192
+ profile = "fixture",
193
+ providerCommand,
194
+ providerArgs = [],
195
+ providerName,
196
+ searchDepth = "standard",
197
+ workDir = process.cwd(),
198
+ budget = {},
199
+ useCache = false,
200
+ replayRunId,
201
+ distributed,
202
+ }: RunWideSearchOptions = {}): Promise<RunWideSearchResult> {
203
+ let replayedFrom: string | undefined;
204
+
205
+ if (replayRunId) {
206
+ const previousRunDir = join(workDir, ".runs", "wide-search", replayRunId);
207
+ const previousRun = JSON.parse(await readFile(join(previousRunDir, "run.json"), "utf8")) as Run;
208
+ const previousPlan = JSON.parse(
209
+ await readFile(join(previousRunDir, "research-plan.json"), "utf8"),
210
+ ) as ResearchPlan;
211
+ objective = objective ?? previousRun.objective;
212
+ profile = previousRun.executionProfile;
213
+ providerName = providerName ?? resolveProviderName(previousRun.executionProfile);
214
+ searchDepth = previousPlan.searchDepth ?? searchDepth;
215
+ replayedFrom = replayRunId;
216
+ }
217
+
218
+ if (!objective) {
219
+ throw new Error("runWideSearch requires objective");
220
+ }
221
+
222
+ if (distributed?.enabled) {
223
+ return runDistributedWideSearch({
224
+ objective,
225
+ profile,
226
+ providerName,
227
+ searchDepth,
228
+ workDir,
229
+ distributed,
230
+ });
231
+ }
232
+
233
+ const runId = makeRunId();
234
+ const runDir = join(workDir, ".runs", "wide-search", runId);
235
+ await mkdir(runDir, { recursive: true });
236
+
237
+ const effectiveProviderName = resolveProviderName(profile, providerName);
238
+ const estimate = estimateRunCost(effectiveProviderName, searchDepth);
239
+
240
+ const usageMetrics: UsageMetrics = {
241
+ providerCalls: 0,
242
+ apiCalls: 0,
243
+ estimatedCostUsd: estimate.estimatedCostUsd,
244
+ };
245
+
246
+ checkEstimatedBudget(estimate, budget);
247
+
248
+ if (budget.dryRun) {
249
+ usageMetrics.notes = "Dry run: no provider executed.";
250
+ const dryRun: Run = {
251
+ runId,
252
+ objective,
253
+ executionProfile: profile,
254
+ status: "completed",
255
+ createdAt: new Date().toISOString(),
256
+ usageMetrics,
257
+ };
258
+ await writeFile(join(runDir, "run.json"), `${JSON.stringify(dryRun, null, 2)}\n`);
259
+ return {
260
+ runId,
261
+ runDir,
262
+ verification: {
263
+ status: "passed",
264
+ acceptedSources: 0,
265
+ rejectedSources: 0,
266
+ unsupportedClaims: 0,
267
+ staleClaims: 0,
268
+ unknownFreshnessClaims: 0,
269
+ lowConfidenceClaims: 0,
270
+ duplicateClaimGroups: [],
271
+ conflictingClaimPairs: [],
272
+ coverageGaps: [],
273
+ failures: [],
274
+ warnings: ["dry-run: no sources or claims evaluated"],
275
+ },
276
+ };
277
+ }
278
+
279
+ const rawSources = await loadSources({
280
+ profile,
281
+ objective,
282
+ providerCommand,
283
+ providerArgs,
284
+ providerName,
285
+ searchDepth,
286
+ metrics: usageMetrics,
287
+ useCache,
288
+ });
289
+ const sources: EnrichedSource[] = rawSources.map((source) => scoreSource(source));
290
+
291
+ usageMetrics.actualCostUsd = calculateActualCost(effectiveProviderName, usageMetrics);
292
+ checkBudget(effectiveProviderName, usageMetrics, budget);
293
+
294
+ const claims: Claim[] = [];
295
+ for (const source of sources.filter((item) => item.decision === "accepted")) {
296
+ for (const claim of source.claims ?? []) {
297
+ claims.push({
298
+ id: `C${String(claims.length + 1).padStart(3, "0")}`,
299
+ claim,
300
+ sourceIds: [source.id],
301
+ confidence: claimConfidence(source),
302
+ freshness: claimFreshness(source),
303
+ });
304
+ }
305
+ }
306
+
307
+ const run: Run = {
308
+ runId,
309
+ objective,
310
+ executionProfile: profile,
311
+ status: "completed",
312
+ createdAt: new Date().toISOString(),
313
+ usageMetrics,
314
+ replayedFrom,
315
+ cached: useCache && profile === "web-search" && effectiveProviderName !== "mock",
316
+ };
317
+
318
+ const researchPlan: ResearchPlan = {
319
+ objective,
320
+ searchDepth,
321
+ executionProfile: profile,
322
+ queryFamilies: [profile.startsWith("fixture") ? `fixture:${profile}` : "local-command provider"],
323
+ sourceTargets: ["official", "community", "secondary"],
324
+ stopConditions: [profile.startsWith("fixture") ? "fixture source set exhausted" : "provider output exhausted"],
325
+ };
326
+
327
+ await writeFile(join(runDir, "run.json"), `${JSON.stringify(run, null, 2)}\n`);
328
+ await writeFile(join(runDir, "research-plan.json"), `${JSON.stringify(researchPlan, null, 2)}\n`);
329
+ await writeFile(
330
+ join(runDir, "source-ledger.jsonl"),
331
+ `${sources.map((source) => JSON.stringify(source)).join("\n")}\n`,
332
+ );
333
+ await writeFile(
334
+ join(runDir, "claim-ledger.jsonl"),
335
+ `${claims.map((claim) => JSON.stringify(claim)).join("\n")}\n`,
336
+ );
337
+
338
+ const verification = await verifyRun({ runDir, minAcceptedSources: 1 });
339
+ const synthesis = renderSynthesis({ objective, profile, sources, claims, verification });
340
+ await writeFile(join(runDir, "synthesis.md"), synthesis);
341
+
342
+ console.log(formatCostReport(effectiveProviderName, usageMetrics));
343
+
344
+ return {
345
+ runId,
346
+ runDir,
347
+ verification,
348
+ };
349
+ }