Files
fusion/packages/cli/src/commands/research.ts
Fusion 789e0d96e4 feat(FN-4223): complete Step 4 — disambiguate research copy across surfaces
Fusion-Task-Id: FN-4223
Fusion-Task-Lineage: bc114ff2-ee0f-4e96-b1d2-6ba3112a48e4
2026-05-13 06:29:36 -07:00

301 lines
10 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import {
RESEARCH_EXPORT_FORMATS,
RESEARCH_RUN_STATUSES,
ResearchRunStatus,
TaskStore,
resolveResearchSettings,
type ResearchExportFormat,
type ResearchRun,
} from "@fusion/core";
import { ResearchOrchestrator, ResearchProviderRegistry, ResearchStepRunner } from "@fusion/engine";
import { resolveProject } from "../project-context.js";
interface ResearchCommandOptions {
projectName?: string;
json?: boolean;
}
interface ResearchCreateOptions extends ResearchCommandOptions {
query: string;
waitForCompletion?: boolean;
maxWaitMs?: number;
}
interface ResearchListOptions extends ResearchCommandOptions {
status?: string;
limit?: number;
}
interface ResearchExportOptions extends ResearchCommandOptions {
runId: string;
format?: string;
output?: string;
}
async function getStore(projectName?: string): Promise<TaskStore> {
const project = projectName ? await resolveProject(projectName) : undefined;
const store = new TaskStore(project?.projectPath ?? process.cwd());
await store.init();
return store;
}
function hasProviderCredentials(settings: Awaited<ReturnType<TaskStore["getSettings"]>>, providerId: string | undefined): boolean {
if (!providerId || providerId === "builtin") return true;
if (providerId === "searxng") return Boolean(settings.researchGlobalSearxngUrl);
if (providerId === "brave") return Boolean(settings.researchGlobalBraveApiKey);
if (providerId === "google") return Boolean(settings.researchGlobalGoogleSearchApiKey && settings.researchGlobalGoogleSearchCx);
if (providerId === "tavily") return Boolean(settings.researchGlobalTavilyApiKey);
return false;
}
async function getResearchRuntime(store: TaskStore) {
const settings = await store.getSettings();
const resolved = resolveResearchSettings(settings);
if (!resolved.enabled) {
throw new Error("feature-disabled: Research is disabled in settings.");
}
const configuredProvider = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
if (configuredProvider !== "builtin" && !hasProviderCredentials(settings, configuredProvider)) {
throw new Error(`missing-credentials: ${configuredProvider} credentials are missing. Configure Authentication and Research defaults in settings.`);
}
const registry = new ResearchProviderRegistry(settings, process.cwd());
const availableProviderTypes = registry.getAvailableProviders();
if (availableProviderTypes.length === 0) {
throw new Error("provider-unavailable: Research providers are not configured. Add provider credentials in settings.");
}
const stepRunner = new ResearchStepRunner({
providers: availableProviderTypes
.map((type) => registry.getProvider(type))
.filter((provider): provider is NonNullable<typeof provider> => Boolean(provider)),
});
const orchestrator = new ResearchOrchestrator({
store: store.getResearchStore(),
stepRunner,
maxConcurrentRuns: resolved.limits.maxConcurrentRuns,
});
return { orchestrator, settings, resolved, availableProviderTypes };
}
function printRun(run: ResearchRun): void {
console.log(`Run: ${run.id}`);
console.log(`Status: ${run.status}`);
console.log(`Query: ${run.query}`);
console.log(`Created: ${run.createdAt}`);
console.log(`Updated: ${run.updatedAt}`);
if (run.startedAt) console.log(`Started: ${run.startedAt}`);
if (run.completedAt) console.log(`Completed: ${run.completedAt}`);
if (run.cancelledAt) console.log(`Cancelled: ${run.cancelledAt}`);
if (run.results?.summary) console.log(`Summary: ${run.results.summary}`);
if (run.error) console.log(`Error: ${run.error}`);
}
function jsonOut(payload: unknown): void {
console.log(JSON.stringify(payload, null, 2));
}
function handleError(error: unknown): never {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: ${message}`);
process.exit(1);
}
export async function runResearchCreate(options: ResearchCreateOptions): Promise<void> {
try {
const store = await getStore(options.projectName);
const { orchestrator, settings, resolved, availableProviderTypes } = await getResearchRuntime(store);
const runId = orchestrator.createRun({
providers: availableProviderTypes
.filter((type) => type !== "llm-synthesis")
.map((type) => ({ type, config: { maxResults: resolved.limits.maxSourcesPerRun, timeoutMs: resolved.limits.requestTimeoutMs } })),
maxSources: resolved.limits.maxSourcesPerRun,
maxSynthesisRounds: Math.max(1, settings.researchMaxSynthesisRounds ?? settings.researchGlobalMaxSynthesisRounds ?? 2),
phaseTimeoutMs: resolved.limits.maxDurationMs,
stepTimeoutMs: resolved.limits.requestTimeoutMs,
});
const runPromise = orchestrator.startRun(runId, options.query);
if (!options.waitForCompletion) {
const run = store.getResearchStore().getRun(runId);
if (options.json) {
jsonOut(run);
} else {
console.log(`Created cited-research run ${runId}.`);
if (run) printRun(run);
}
return;
}
const maxWaitMs = Math.max(1_000, Math.min(options.maxWaitMs ?? 90_000, resolved.limits.maxDurationMs));
const completed = await Promise.race([
runPromise,
new Promise<ResearchRun>((resolveRun) => setTimeout(() => {
const latest = store.getResearchStore().getRun(runId);
resolveRun(latest ?? ({
id: runId,
query: options.query,
status: "running",
sources: [],
events: [],
tags: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as ResearchRun));
}, maxWaitMs)),
]);
if (options.json) {
jsonOut(completed);
} else {
printRun(completed);
}
} catch (error) {
handleError(error);
}
}
export async function runResearchList(options: ResearchListOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
if (options.status && !RESEARCH_RUN_STATUSES.includes(options.status as ResearchRunStatus)) {
throw new Error(`Invalid status: ${options.status}`);
}
const runs = store.getResearchStore().listRuns({
status: options.status as ResearchRunStatus | undefined,
limit: options.limit ? Math.max(1, options.limit) : 20,
});
if (options.json) {
jsonOut({ runs });
return;
}
if (!runs.length) {
console.log("No cited-research runs found.");
return;
}
for (const run of runs) {
console.log(`${run.id} [${run.status}] ${run.query}`);
}
} catch (error) {
handleError(error);
}
}
export async function runResearchShow(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Cited-research run not found: ${runId}`);
if (options.json) {
jsonOut(run);
return;
}
printRun(run);
} catch (error) {
handleError(error);
}
}
function renderMarkdown(run: ResearchRun): string {
const citations = run.results?.citations?.length
? `\n## Citations\n${run.results.citations.map((citation) => `- ${citation}`).join("\n")}`
: "";
return `# ${run.topic || run.query}\n\n## Summary\n${run.results?.summary ?? ""}${citations}\n`;
}
export async function runResearchExport(options: ResearchExportOptions): Promise<void> {
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(options.runId);
if (!run) throw new Error(`Cited-research run not found: ${options.runId}`);
const format = (options.format ?? "markdown") as ResearchExportFormat;
if (!RESEARCH_EXPORT_FORMATS.includes(format)) {
throw new Error(`Unsupported export format: ${format}`);
}
const content = format === "json" ? JSON.stringify(run, null, 2) : renderMarkdown(run);
const ext = format === "json" ? "json" : "md";
const outputPath = options.output
? resolve(options.output)
: join(process.cwd(), `research-${run.id.toLowerCase()}.${ext}`);
await writeFile(outputPath, content, "utf8");
store.getResearchStore().createExport(run.id, format, content);
if (options.json) {
jsonOut({ runId: run.id, format, outputPath, bytes: Buffer.byteLength(content, "utf8") });
return;
}
console.log(`Exported ${run.id} (${format}) to ${outputPath}`);
} catch (error) {
handleError(error);
}
}
export async function runResearchCancel(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Cited-research run not found: ${runId}`);
if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) {
throw new Error(`invalid-transition: Run ${runId} cannot be cancelled from status ${run.status}.`);
}
const { orchestrator } = await getResearchRuntime(store);
const cancelled = orchestrator.cancelRun(runId);
if (options.json) {
jsonOut({ cancelled, run });
return;
}
console.log(cancelled ? `Cancellation requested for ${runId}.` : `Run ${runId} is not active.`);
printRun(run);
} catch (error) {
handleError(error);
}
}
export async function runResearchRetry(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
const existing = store.getResearchStore().getRun(runId);
if (!existing) throw new Error(`Cited-research run not found: ${runId}`);
if (existing.status === "retry_exhausted" || existing.lifecycle?.errorCode === "RETRY_EXHAUSTED") {
throw new Error(`retry-exhausted: Run ${runId} has exhausted retry attempts.`);
}
if (existing.lifecycle?.retryable === false) {
throw new Error(`non-retryable-provider-error: Run ${runId} is marked non-retryable.`);
}
const { orchestrator } = await getResearchRuntime(store);
const newRunId = orchestrator.retryRun(runId);
const run = store.getResearchStore().getRun(newRunId);
if (options.json) {
jsonOut({ retryOf: runId, run });
return;
}
console.log(`Created retry run ${newRunId} from ${runId}.`);
if (run) printRun(run);
} catch (error) {
handleError(error);
}
}