@scira/cli 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/agent/research-agent.js +253 -0
  4. package/dist/agent/skills.js +265 -0
  5. package/dist/agent/tools.js +429 -0
  6. package/dist/agent/tools.test.js +27 -0
  7. package/dist/cli/commands/init.js +370 -0
  8. package/dist/cli/index.js +445 -0
  9. package/dist/cli/shell/shell.js +76 -0
  10. package/dist/cli/shell/tui.js +11 -0
  11. package/dist/config/env-store.js +47 -0
  12. package/dist/config/load-config.js +58 -0
  13. package/dist/export/formatters.js +37 -0
  14. package/dist/providers/llm/gateway.js +64 -0
  15. package/dist/providers/llm/huggingface.js +33 -0
  16. package/dist/providers/llm/models.js +97 -0
  17. package/dist/providers/llm/readiness.js +50 -0
  18. package/dist/providers/llm/registry.js +56 -0
  19. package/dist/storage/jsonl.js +29 -0
  20. package/dist/storage/jsonl.test.js +38 -0
  21. package/dist/storage/run-store.js +134 -0
  22. package/dist/storage/run-store.test.js +65 -0
  23. package/dist/tools/chrome-devtools-mcp.js +61 -0
  24. package/dist/tools/file-tools.js +128 -0
  25. package/dist/tools/mcp-bridge.js +118 -0
  26. package/dist/tools/mcp-oauth.js +276 -0
  27. package/dist/tools/open-url.js +99 -0
  28. package/dist/tools/search-web.js +153 -0
  29. package/dist/types/index.js +91 -0
  30. package/dist/types/schema.test.js +60 -0
  31. package/dist/ui/ink/SciraApp.js +274 -0
  32. package/dist/ui/ink/components/effects.js +44 -0
  33. package/dist/ui/ink/components/home-screen.js +69 -0
  34. package/dist/ui/ink/components/overlays.js +111 -0
  35. package/dist/ui/ink/constants.js +56 -0
  36. package/dist/ui/ink/hooks/use-agent-turn.js +186 -0
  37. package/dist/ui/ink/hooks/use-feed-lines.js +186 -0
  38. package/dist/ui/ink/hooks/use-feed.js +69 -0
  39. package/dist/ui/ink/hooks/use-keyboard.js +315 -0
  40. package/dist/ui/ink/hooks/use-mouse.js +31 -0
  41. package/dist/ui/ink/hooks/use-session.js +103 -0
  42. package/dist/ui/ink/hooks/use-settings.js +155 -0
  43. package/dist/ui/ink/hooks/use-submit.js +366 -0
  44. package/dist/ui/ink/hooks/use-suggestions.js +91 -0
  45. package/dist/ui/ink/lib/file-mentions.js +71 -0
  46. package/dist/ui/ink/lib/markdown.js +245 -0
  47. package/dist/ui/ink/lib/utils.js +224 -0
  48. package/dist/ui/ink/session-manager.js +160 -0
  49. package/dist/ui/ink/types.js +1 -0
  50. package/dist/utils/ids.js +15 -0
  51. package/dist/utils/markdown-joiner.js +249 -0
  52. package/dist/watch/runner.js +65 -0
  53. package/package.json +74 -0
@@ -0,0 +1,64 @@
1
+ import { generateText, gateway } from "ai";
2
+ import { getLanguageModel, requireLlmKeys, defaultModelFor } from "./registry.js";
3
+ async function fetchModelsRaw() {
4
+ const headers = {};
5
+ if (process.env.AI_GATEWAY_API_KEY) {
6
+ headers.Authorization = `Bearer ${process.env.AI_GATEWAY_API_KEY}`;
7
+ }
8
+ const response = await fetch("https://ai-gateway.vercel.sh/v1/models", {
9
+ headers,
10
+ signal: AbortSignal.timeout(15000)
11
+ });
12
+ if (!response.ok) {
13
+ throw new Error(`models endpoint returned ${response.status} ${response.statusText}`);
14
+ }
15
+ const payload = await response.json();
16
+ return payload.data ?? [];
17
+ }
18
+ async function fetchModelsViaSdk() {
19
+ const result = await gateway.getAvailableModels();
20
+ return result.models.map((m) => ({
21
+ id: m.id,
22
+ name: m.name,
23
+ // SDK list exposes modelType but no capability tags
24
+ type: m.modelType
25
+ }));
26
+ }
27
+ export async function listGatewayModels(providerPrefix) {
28
+ let models;
29
+ try {
30
+ models = await fetchModelsRaw();
31
+ }
32
+ catch {
33
+ // Raw endpoint can fail (network/TLS); fall back to the SDK transport that
34
+ // already works for generation. This list lacks capability tags.
35
+ models = await fetchModelsViaSdk();
36
+ }
37
+ return providerPrefix ? models.filter((model) => model.id.startsWith(`${providerPrefix}/`)) : models;
38
+ }
39
+ /**
40
+ * Only text models that can use tools — the rest (image, video, embedding,
41
+ * reranking, or chat models without tool-use) cannot drive the research agent.
42
+ * When capability tags are unavailable (SDK fallback), accept any language model.
43
+ */
44
+ export function isToolUseModel(model) {
45
+ if (model.type !== "language")
46
+ return false;
47
+ return model.tags === undefined ? true : model.tags.includes("tool-use");
48
+ }
49
+ export async function listToolUseModels(providerPrefix) {
50
+ return (await listGatewayModels(providerPrefix)).filter(isToolUseModel);
51
+ }
52
+ export const DEFAULT_MODEL = "deepseek/deepseek-v4-flash";
53
+ export async function chooseConfiguredModel(config) {
54
+ requireLlmKeys(config);
55
+ return config.model || defaultModelFor(config.llmProvider);
56
+ }
57
+ export async function generateWithGateway(config, prompt, system) {
58
+ const result = await generateText({
59
+ model: getLanguageModel(config),
60
+ system,
61
+ prompt
62
+ });
63
+ return result.text;
64
+ }
@@ -0,0 +1,33 @@
1
+ async function fetchModelsRaw() {
2
+ const headers = {};
3
+ if (process.env.HF_API_KEY) {
4
+ headers.Authorization = `Bearer ${process.env.HF_API_KEY}`;
5
+ }
6
+ const response = await fetch("https://router.huggingface.co/v1/models", {
7
+ headers,
8
+ signal: AbortSignal.timeout(15000)
9
+ });
10
+ if (!response.ok) {
11
+ throw new Error(`HuggingFace models endpoint returned ${response.status} ${response.statusText}`);
12
+ }
13
+ const payload = await response.json();
14
+ return payload.data ?? [];
15
+ }
16
+ export async function listHuggingFaceModels() {
17
+ const allModels = await fetchModelsRaw();
18
+ return allModels.filter(isToolUseModel);
19
+ }
20
+ /**
21
+ * Only text models that can use tools — the rest (image, video, embedding,
22
+ * reranking, or chat models without tool-use) cannot drive the research agent.
23
+ */
24
+ export function isToolUseModel(model) {
25
+ // Must support text input
26
+ if (!model.architecture.input_modalities.includes("text"))
27
+ return false;
28
+ // Must support text output
29
+ if (!model.architecture.output_modalities.includes("text"))
30
+ return false;
31
+ // At least one provider must support tools
32
+ return model.providers.some((p) => p.supports_tools === true);
33
+ }
@@ -0,0 +1,97 @@
1
+ import { listToolUseModels } from "./gateway.js";
2
+ import { listHuggingFaceModels } from "./huggingface.js";
3
+ /** Curated fallbacks so the /model picker keeps working when the live list call fails. */
4
+ const STATIC_MODELS = {
5
+ xai: [
6
+ { id: "grok-build-0.1", name: "Grok Build 0.1" },
7
+ { id: "grok-4", name: "Grok 4" },
8
+ { id: "grok-4-fast", name: "Grok 4 Fast" },
9
+ { id: "grok-4-fast-non-reasoning", name: "Grok 4 Fast (Non-Reasoning)" },
10
+ { id: "grok-3", name: "Grok 3" },
11
+ { id: "grok-3-mini", name: "Grok 3 Mini" }
12
+ ],
13
+ "workers-ai": [
14
+ { id: "@cf/moonshotai/kimi-k2.6", name: "Kimi K2.6" },
15
+ { id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", name: "Llama 3.3 70B Instruct (fp8 fast)" },
16
+ { id: "@cf/meta/llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout 17B Instruct" },
17
+ { id: "@cf/qwen/qwen2.5-coder-32b-instruct", name: "Qwen 2.5 Coder 32B Instruct" },
18
+ { id: "@cf/mistralai/mistral-small-3.1-24b-instruct", name: "Mistral Small 3.1 24B Instruct" },
19
+ { id: "@cf/openai/gpt-oss-120b", name: "GPT-OSS 120B" }
20
+ ],
21
+ huggingface: [
22
+ { id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" },
23
+ { id: "meta-llama/Llama-3.1-70B-Instruct", name: "Llama 3.1 70B Instruct" },
24
+ { id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B Instruct" },
25
+ { id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B Instruct v0.3" },
26
+ { id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" }
27
+ ]
28
+ };
29
+ async function listXaiModels() {
30
+ const key = process.env.XAI_API_KEY;
31
+ if (!key)
32
+ return STATIC_MODELS.xai;
33
+ try {
34
+ // OpenAI-compatible models endpoint
35
+ const response = await fetch("https://api.x.ai/v1/models", {
36
+ headers: { Authorization: `Bearer ${key}` },
37
+ signal: AbortSignal.timeout(15000)
38
+ });
39
+ if (!response.ok)
40
+ throw new Error(`xAI models endpoint returned ${response.status}`);
41
+ const payload = await response.json();
42
+ const models = (payload.data ?? []).map((m) => ({ id: m.id }));
43
+ return models.length > 0 ? models : STATIC_MODELS.xai;
44
+ }
45
+ catch {
46
+ return STATIC_MODELS.xai;
47
+ }
48
+ }
49
+ async function listWorkersAiModels() {
50
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
51
+ const token = process.env.CLOUDFLARE_API_TOKEN;
52
+ if (!accountId || !token)
53
+ return STATIC_MODELS["workers-ai"];
54
+ try {
55
+ const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/models/search?task=Text%20Generation&per_page=100`;
56
+ const response = await fetch(url, {
57
+ headers: { Authorization: `Bearer ${token}` },
58
+ signal: AbortSignal.timeout(15000)
59
+ });
60
+ if (!response.ok)
61
+ throw new Error(`Cloudflare models endpoint returned ${response.status}`);
62
+ const payload = await response.json();
63
+ const models = (payload.result ?? [])
64
+ // prefer models that declare function calling; keep all if the flag is absent everywhere
65
+ .map((m) => ({
66
+ id: m.name,
67
+ functionCalling: (m.properties ?? []).some((p) => p.property_id === "function_calling" && p.value === "true")
68
+ }));
69
+ const toolCapable = models.filter((m) => m.functionCalling);
70
+ const chosen = toolCapable.length > 0 ? toolCapable : models;
71
+ return chosen.length > 0 ? chosen.map(({ id }) => ({ id })) : STATIC_MODELS["workers-ai"];
72
+ }
73
+ catch {
74
+ return STATIC_MODELS["workers-ai"];
75
+ }
76
+ }
77
+ async function listHuggingFaceModelsWrapper() {
78
+ const key = process.env.HF_API_KEY;
79
+ if (!key)
80
+ return STATIC_MODELS.huggingface;
81
+ try {
82
+ const models = await listHuggingFaceModels();
83
+ return models.length > 0 ? models.map((m) => ({ id: m.id })) : STATIC_MODELS.huggingface;
84
+ }
85
+ catch {
86
+ return STATIC_MODELS.huggingface;
87
+ }
88
+ }
89
+ /** Model list for the active LLM provider (live where possible, static fallback otherwise). */
90
+ export async function listModels(config) {
91
+ switch (config.llmProvider) {
92
+ case "xai": return listXaiModels();
93
+ case "workers-ai": return listWorkersAiModels();
94
+ case "huggingface": return listHuggingFaceModelsWrapper();
95
+ default: return (await listToolUseModels()).map((m) => ({ id: m.id, name: m.name }));
96
+ }
97
+ }
@@ -0,0 +1,50 @@
1
+ import process from "node:process";
2
+ export const PROVIDER_ENV = {
3
+ parallel: "PARALLEL_API_KEY",
4
+ exa: "EXA_API_KEY",
5
+ firecrawl: "FIRECRAWL_API_KEY"
6
+ };
7
+ export const AI_GATEWAY_ENV = "AI_GATEWAY_API_KEY";
8
+ export function hasEnv(name) {
9
+ const value = process.env[name];
10
+ return typeof value === "string" && value.trim().length > 0;
11
+ }
12
+ export function requireAiGatewayKey() {
13
+ if (!hasEnv(AI_GATEWAY_ENV)) {
14
+ throw new Error(`${AI_GATEWAY_ENV} is required for AI-powered research. Set it in your environment before running plans, searches, or reports.`);
15
+ }
16
+ }
17
+ export function providerEnvVar(provider) {
18
+ return PROVIDER_ENV[provider];
19
+ }
20
+ export function requireSearchProvider(provider) {
21
+ const name = PROVIDER_ENV[provider];
22
+ if (!hasEnv(name)) {
23
+ throw new Error(`${name} is required for the "${provider}" search/scrape provider. Set it in your environment or switch search.provider in config.`);
24
+ }
25
+ }
26
+ const LLM_ENV_CHECKS = [
27
+ { name: AI_GATEWAY_ENV, provider: "gateway", purpose: "Vercel AI Gateway LLM access" },
28
+ { name: "XAI_API_KEY", provider: "xai", purpose: "xAI (Grok) LLM access" },
29
+ { name: "CLOUDFLARE_ACCOUNT_ID", provider: "workers-ai", purpose: "Cloudflare Workers AI account" },
30
+ { name: "CLOUDFLARE_API_TOKEN", provider: "workers-ai", purpose: "Cloudflare Workers AI LLM access" },
31
+ { name: "HF_API_KEY", provider: "huggingface", purpose: "HuggingFace Inference API access" }
32
+ ];
33
+ export function detectEnv(provider, llmProvider = "gateway") {
34
+ const checks = LLM_ENV_CHECKS.map((c) => ({
35
+ name: c.name,
36
+ present: hasEnv(c.name),
37
+ purpose: c.purpose,
38
+ required: c.provider === llmProvider
39
+ }));
40
+ for (const key of Object.keys(PROVIDER_ENV)) {
41
+ const name = PROVIDER_ENV[key];
42
+ checks.push({
43
+ name,
44
+ present: hasEnv(name),
45
+ purpose: `${key} web search + scrape`,
46
+ required: key === provider
47
+ });
48
+ }
49
+ return checks;
50
+ }
@@ -0,0 +1,56 @@
1
+ import { gateway } from "ai";
2
+ import { createXai } from "@ai-sdk/xai";
3
+ import { createWorkersAI } from "workers-ai-provider";
4
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
5
+ import { hasEnv } from "./readiness.js";
6
+ export const LLM_PROVIDERS = ["gateway", "xai", "workers-ai", "huggingface"];
7
+ /** Human-readable names for the provider picker and status messages. */
8
+ export const LLM_PROVIDER_LABELS = {
9
+ gateway: "Vercel AI Gateway",
10
+ xai: "xAI",
11
+ "workers-ai": "Cloudflare Workers AI",
12
+ huggingface: "HuggingFace"
13
+ };
14
+ /** Env vars each LLM provider needs before it can generate. */
15
+ export const LLM_PROVIDER_ENV = {
16
+ gateway: ["AI_GATEWAY_API_KEY"],
17
+ xai: ["XAI_API_KEY"],
18
+ "workers-ai": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
19
+ huggingface: ["HF_API_KEY"]
20
+ };
21
+ export function defaultModelFor(provider) {
22
+ switch (provider) {
23
+ case "xai": return "grok-build-0.1";
24
+ case "workers-ai": return "@cf/moonshotai/kimi-k2.6";
25
+ case "huggingface": return "meta-llama/Llama-3.3-70B-Instruct";
26
+ default: return "deepseek/deepseek-v4-flash";
27
+ }
28
+ }
29
+ /** Throw a setup-oriented error when the active LLM provider's env keys are missing. */
30
+ export function requireLlmKeys(config) {
31
+ const missing = LLM_PROVIDER_ENV[config.llmProvider].filter((name) => !hasEnv(name));
32
+ if (missing.length > 0) {
33
+ throw new Error(`${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} required for the "${config.llmProvider}" LLM provider. Set ${missing.length === 1 ? "it" : "them"} with /key or in your environment.`);
34
+ }
35
+ }
36
+ /** Build the AI SDK language model for the configured provider + model id. */
37
+ export function getLanguageModel(config) {
38
+ requireLlmKeys(config);
39
+ switch (config.llmProvider) {
40
+ case "xai":
41
+ return createXai({ apiKey: process.env.XAI_API_KEY })(config.model);
42
+ case "workers-ai":
43
+ return createWorkersAI({
44
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
45
+ apiKey: process.env.CLOUDFLARE_API_TOKEN
46
+ })(config.model);
47
+ case "huggingface":
48
+ return createOpenAICompatible({
49
+ name: "huggingface",
50
+ baseURL: "https://router.huggingface.co/v1",
51
+ apiKey: process.env.HF_API_KEY
52
+ })(config.model);
53
+ default:
54
+ return gateway(config.model);
55
+ }
56
+ }
@@ -0,0 +1,29 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ export async function appendJsonl(path, value) {
4
+ await mkdir(dirname(path), { recursive: true });
5
+ await writeFile(path, `${JSON.stringify(value)}\n`, { flag: "a" });
6
+ }
7
+ export async function readJsonl(path) {
8
+ try {
9
+ const text = await readFile(path, "utf8");
10
+ const results = [];
11
+ for (const line of text.split("\n")) {
12
+ if (!line.trim())
13
+ continue;
14
+ try {
15
+ results.push(JSON.parse(line));
16
+ }
17
+ catch {
18
+ // skip malformed/truncated lines (e.g. agent wrote unescaped newlines in a value)
19
+ }
20
+ }
21
+ return results;
22
+ }
23
+ catch (error) {
24
+ if (error.code === "ENOENT") {
25
+ return [];
26
+ }
27
+ throw error;
28
+ }
29
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { appendJsonl, readJsonl } from "./jsonl.js";
6
+ describe("readJsonl / appendJsonl", () => {
7
+ let dir;
8
+ let file;
9
+ beforeEach(async () => {
10
+ dir = await mkdtemp(join(tmpdir(), "scira-test-"));
11
+ file = join(dir, "test.jsonl");
12
+ });
13
+ afterEach(async () => {
14
+ await rm(dir, { recursive: true, force: true });
15
+ });
16
+ it("returns [] for a missing file", async () => {
17
+ expect(await readJsonl(join(dir, "missing.jsonl"))).toEqual([]);
18
+ });
19
+ it("round-trips a single object", async () => {
20
+ await appendJsonl(file, { id: "c1", text: "hello" });
21
+ expect(await readJsonl(file)).toEqual([{ id: "c1", text: "hello" }]);
22
+ });
23
+ it("appends multiple objects sequentially", async () => {
24
+ await appendJsonl(file, { n: 1 });
25
+ await appendJsonl(file, { n: 2 });
26
+ await appendJsonl(file, { n: 3 });
27
+ expect(await readJsonl(file)).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
28
+ });
29
+ it("skips malformed lines without throwing", async () => {
30
+ await writeFile(file, '{"ok":true}\nNOT_JSON\n{"ok":false}\n');
31
+ expect(await readJsonl(file)).toEqual([{ ok: true }, { ok: false }]);
32
+ });
33
+ it("creates parent directories that do not exist", async () => {
34
+ const nested = join(dir, "a", "b", "c.jsonl");
35
+ await appendJsonl(nested, { x: 1 });
36
+ expect(await readJsonl(nested)).toEqual([{ x: 1 }]);
37
+ });
38
+ });
@@ -0,0 +1,134 @@
1
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { createRunId } from "../utils/ids.js";
4
+ import { appendJsonl, readJsonl } from "./jsonl.js";
5
+ export function getRunPaths(runPath) {
6
+ return {
7
+ root: runPath,
8
+ goal: join(runPath, "goal.md"),
9
+ plan: join(runPath, "plan.md"),
10
+ research: join(runPath, "RESEARCH.md"),
11
+ scope: join(runPath, "scope.json"),
12
+ progress: join(runPath, "progress.md"),
13
+ sources: join(runPath, "sources.jsonl"),
14
+ claims: join(runPath, "claims.jsonl"),
15
+ notes: join(runPath, "notes.md"),
16
+ report: join(runPath, "report.md"),
17
+ log: join(runPath, "run.log.jsonl"),
18
+ handoff: join(runPath, "handoff.md"),
19
+ artifacts: join(runPath, "artifacts"),
20
+ snapshots: join(runPath, "snapshots")
21
+ };
22
+ }
23
+ export async function createRun(goal, config, projectRoot = process.cwd()) {
24
+ const runId = createRunId(goal);
25
+ const runPath = resolve(projectRoot, config.runDirectory, runId);
26
+ const paths = getRunPaths(runPath);
27
+ await mkdir(paths.artifacts, { recursive: true });
28
+ await mkdir(paths.snapshots, { recursive: true });
29
+ await writeFile(paths.goal, `# Goal\n\n${goal}\n`);
30
+ await writeFile(paths.plan, "# Research Plan\n\nPending plan generation.\n");
31
+ await writeFile(paths.research, researchInstructions());
32
+ await writeFile(paths.scope, `${JSON.stringify({ goal, maxSources: config.maxSources, citationPolicy: config.citationPolicy }, null, 2)}\n`);
33
+ await writeFile(paths.progress, progressText("created", "Generate and approve research plan."));
34
+ await writeFile(paths.sources, "");
35
+ await writeFile(paths.claims, "");
36
+ await writeFile(paths.notes, "# Notes\n\n");
37
+ await writeFile(paths.report, "# Report\n\nDraft not generated yet.\n");
38
+ await writeFile(paths.handoff, handoffText(goal, "created"));
39
+ await logEvent(paths.root, "run.created", { goal });
40
+ return summarizeRun(paths.root);
41
+ }
42
+ export async function summarizeRun(runPath) {
43
+ const paths = getRunPaths(runPath);
44
+ const goal = (await readFile(paths.goal, "utf8").catch(() => "")).replace(/^# Goal\s*/u, "").trim();
45
+ const title = (await readFile(join(runPath, "title.md"), "utf8").catch(() => "")).trim() || undefined;
46
+ const sources = await readJsonl(paths.sources);
47
+ const claims = await readJsonl(paths.claims);
48
+ const report = await readFile(paths.report, "utf8").catch(() => "");
49
+ // last activity = newest mtime among the files that change as a run progresses
50
+ const mtimes = await Promise.all([join(runPath, "convo.json"), paths.report, runPath].map((p) => stat(p).then((s) => s.mtimeMs).catch(() => 0)));
51
+ const updatedAt = Math.max(0, ...mtimes);
52
+ return {
53
+ id: runPath.split(/[\\/]/u).at(-1) ?? runPath,
54
+ path: runPath,
55
+ goal,
56
+ title,
57
+ sourceCount: sources.length,
58
+ claimCount: claims.length,
59
+ weakCount: claims.filter((claim) => claim.status === "weak").length,
60
+ reportDirty: report.length < 200 || report.includes("Draft not generated yet"),
61
+ updatedAt,
62
+ isFull: sources.length > 0 || claims.length > 0
63
+ };
64
+ }
65
+ export async function setRunTitle(runPath, title) {
66
+ await writeFile(join(runPath, "title.md"), title.trim());
67
+ }
68
+ export async function deleteRun(runPath) {
69
+ await rm(runPath, { recursive: true, force: true });
70
+ }
71
+ /** Build a human-readable verification report from the run's claim ledger (scope §16.2). */
72
+ export async function verificationReport(runPath) {
73
+ const claims = await readJsonl(getRunPaths(runPath).claims);
74
+ if (claims.length === 0) {
75
+ return "No claims recorded yet. Run the research agent first.";
76
+ }
77
+ const count = (status) => claims.filter((c) => c.status === status).length;
78
+ const lines = [
79
+ "Verification Report",
80
+ "",
81
+ `Claims: ${claims.length}`,
82
+ `Verified: ${count("verified")}`,
83
+ `Weak: ${count("weak")}`,
84
+ `Contradicted: ${count("contradicted")}`,
85
+ `Needs review: ${count("needs_review")}`,
86
+ `Draft: ${count("draft")}`
87
+ ];
88
+ const flagged = claims.filter((c) => c.status === "weak" || c.status === "contradicted" || c.status === "needs_review");
89
+ for (const claim of flagged) {
90
+ lines.push("", `${claim.id} (${claim.status}): "${claim.text}"`, `Reason: ${claim.reason || "n/a"}`);
91
+ }
92
+ return lines.join("\n");
93
+ }
94
+ export async function listRuns(config, projectRoot = process.cwd()) {
95
+ const runsRoot = resolve(projectRoot, config.runDirectory);
96
+ try {
97
+ const entries = await readdir(runsRoot);
98
+ const dirs = await Promise.all(entries.map(async (entry) => {
99
+ const path = join(runsRoot, entry);
100
+ return (await stat(path)).isDirectory() ? path : undefined;
101
+ }));
102
+ const settled = await Promise.allSettled(dirs.filter((path) => Boolean(path)).map(summarizeRun));
103
+ return settled
104
+ .filter((r) => r.status === "fulfilled")
105
+ .map((r) => r.value)
106
+ .sort((a, b) => b.updatedAt - a.updatedAt);
107
+ }
108
+ catch (error) {
109
+ if (error.code === "ENOENT") {
110
+ return [];
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+ export async function findRun(runId, config, projectRoot = process.cwd()) {
116
+ const runs = await listRuns(config, projectRoot);
117
+ const run = runs.find((candidate) => candidate.id === runId || candidate.id.includes(runId));
118
+ if (!run) {
119
+ throw new Error(`Run not found: ${runId}`);
120
+ }
121
+ return run.path;
122
+ }
123
+ export async function logEvent(runPath, type, data = {}) {
124
+ await appendJsonl(getRunPaths(runPath).log, { type, data, createdAt: new Date().toISOString() });
125
+ }
126
+ export function progressText(status, next) {
127
+ return `# Progress\n\nStatus: ${status}\n\nNext: ${next}\n`;
128
+ }
129
+ export function handoffText(goal, status) {
130
+ return `# Handoff\n\nGoal: ${goal}\n\nStatus: ${status}\n\nNext agent should inspect \`progress.md\`, \`sources.jsonl\`, \`claims.jsonl\`, and \`report.md\`.\n`;
131
+ }
132
+ function researchInstructions() {
133
+ return `# Research Instructions\n\n- Prefer primary sources.\n- Never make uncited claims in final reports.\n- Mark vendor claims as vendor claims.\n- Check dates for pricing, market, company, and product claims.\n- Search for contradictions before finalizing.\n- Do not overstate weak evidence.\n- Put uncertain claims in the risks or open questions section.\n`;
134
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { createRun, summarizeRun, setRunTitle, getRunPaths } from "./run-store.js";
6
+ import { SciraConfigSchema } from "../types/index.js";
7
+ import { appendJsonl } from "./jsonl.js";
8
+ const BASE_CONFIG = SciraConfigSchema.parse({});
9
+ describe("createRun / summarizeRun", () => {
10
+ let tmpRoot;
11
+ beforeEach(async () => {
12
+ tmpRoot = await mkdtemp(join(tmpdir(), "scira-runs-"));
13
+ });
14
+ afterEach(async () => {
15
+ await rm(tmpRoot, { recursive: true, force: true });
16
+ });
17
+ it("creates all expected files and subdirectories", async () => {
18
+ const config = { ...BASE_CONFIG, runDirectory: tmpRoot };
19
+ const state = await createRun("What is the speed of light?", config, "");
20
+ const paths = getRunPaths(state.path);
21
+ const { readFile, stat } = await import("node:fs/promises");
22
+ await expect(readFile(paths.goal, "utf8")).resolves.toContain("speed of light");
23
+ await expect(stat(paths.artifacts)).resolves.toBeDefined();
24
+ await expect(stat(paths.snapshots)).resolves.toBeDefined();
25
+ });
26
+ it("summarizeRun reflects zero sources and claims on a fresh run", async () => {
27
+ const config = { ...BASE_CONFIG, runDirectory: tmpRoot };
28
+ const state = await createRun("Test question", config, "");
29
+ expect(state.sourceCount).toBe(0);
30
+ expect(state.claimCount).toBe(0);
31
+ expect(state.weakCount).toBe(0);
32
+ expect(state.isFull).toBe(false);
33
+ expect(state.reportDirty).toBe(true);
34
+ expect(state.goal).toContain("Test question");
35
+ });
36
+ it("setRunTitle persists the title and summarizeRun reads it back", async () => {
37
+ const config = { ...BASE_CONFIG, runDirectory: tmpRoot };
38
+ const state = await createRun("Title test", config, "");
39
+ await setRunTitle(state.path, "My Custom Title");
40
+ const updated = await summarizeRun(state.path);
41
+ expect(updated.title).toBe("My Custom Title");
42
+ });
43
+ it("isFull becomes true once a source is appended", async () => {
44
+ const config = { ...BASE_CONFIG, runDirectory: tmpRoot };
45
+ const state = await createRun("Full test", config, "");
46
+ const paths = getRunPaths(state.path);
47
+ await appendJsonl(paths.sources, {
48
+ id: "s1", title: "Test", url: "https://test.com",
49
+ kind: "primary", summary: "", createdAt: new Date().toISOString()
50
+ });
51
+ const updated = await summarizeRun(state.path);
52
+ expect(updated.sourceCount).toBe(1);
53
+ expect(updated.isFull).toBe(true);
54
+ });
55
+ it("weakCount reflects weak claims", async () => {
56
+ const config = { ...BASE_CONFIG, runDirectory: tmpRoot };
57
+ const state = await createRun("Weak test", config, "");
58
+ const paths = getRunPaths(state.path);
59
+ await appendJsonl(paths.claims, { id: "c1", text: "x", confidence: "low", status: "weak", sourceIds: [], reason: "", createdAt: new Date().toISOString() });
60
+ await appendJsonl(paths.claims, { id: "c2", text: "y", confidence: "high", status: "verified", sourceIds: [], reason: "", createdAt: new Date().toISOString() });
61
+ const updated = await summarizeRun(state.path);
62
+ expect(updated.claimCount).toBe(2);
63
+ expect(updated.weakCount).toBe(1);
64
+ });
65
+ });
@@ -0,0 +1,61 @@
1
+ import { createMCPClient } from "@ai-sdk/mcp";
2
+ import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
3
+ const NOOP_BRIDGE = {
4
+ tools: {},
5
+ close: async () => { },
6
+ toolNames: []
7
+ };
8
+ /**
9
+ * Spin up the Chrome DevTools MCP server over stdio and return its tools as
10
+ * an AI SDK ToolSet, prefixed to avoid collisions with Scira's built-in tools.
11
+ *
12
+ * If the bridge is disabled in config, or if the MCP server fails to start,
13
+ * this returns a no-op bridge so the agent can still run with built-in tools.
14
+ */
15
+ export async function createChromeDevtoolsMcpBridge(config) {
16
+ const cfg = config.mcp.chromeDevtools;
17
+ if (!cfg.enabled)
18
+ return NOOP_BRIDGE;
19
+ let client;
20
+ try {
21
+ client = await createMCPClient({
22
+ transport: new Experimental_StdioMCPTransport({
23
+ command: cfg.command,
24
+ args: cfg.args,
25
+ stderr: "pipe"
26
+ }),
27
+ clientName: "scira-cli"
28
+ });
29
+ const raw = await client.tools();
30
+ const prefix = cfg.toolPrefix ?? "";
31
+ const prefixed = {};
32
+ const toolNames = [];
33
+ for (const [name, tool] of Object.entries(raw)) {
34
+ const finalName = prefix ? `${prefix}${name}` : name;
35
+ prefixed[finalName] = tool;
36
+ toolNames.push(finalName);
37
+ }
38
+ const owned = client;
39
+ return {
40
+ tools: prefixed,
41
+ close: async () => {
42
+ try {
43
+ await owned.close();
44
+ }
45
+ catch { /* ignore */ }
46
+ },
47
+ toolNames
48
+ };
49
+ }
50
+ catch (error) {
51
+ if (client) {
52
+ try {
53
+ await client.close();
54
+ }
55
+ catch { /* ignore */ }
56
+ }
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ process.stderr.write(`\n[scira] Chrome DevTools MCP unavailable, continuing without it: ${message}\n`);
59
+ return NOOP_BRIDGE;
60
+ }
61
+ }