@scira/cli 0.1.2 → 0.1.4
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 +56 -10
- package/dist/agent/background-tasks.js +173 -0
- package/dist/agent/research-agent.js +95 -38
- package/dist/agent/todos.js +140 -0
- package/dist/agent/tools.js +146 -143
- package/dist/agent/tools.test.js +33 -0
- package/dist/agent/workspace.js +85 -0
- package/dist/cli/commands/init.js +53 -39
- package/dist/cli/index.js +30 -14
- package/dist/config/env-guide.js +151 -0
- package/dist/config/env-guide.test.js +18 -0
- package/dist/config/env-store.js +53 -0
- package/dist/config/env-store.test.js +60 -0
- package/dist/tools/agent-tools.js +621 -0
- package/dist/tools/background-tasks.js +261 -0
- package/dist/tools/bash-policy.test.js +38 -0
- package/dist/tools/file-tools.js +6 -1
- package/dist/tools/search-web.js +24 -6
- package/dist/tools/search-web.test.js +24 -0
- package/dist/tools/todos.js +140 -0
- package/dist/tools/workspace.js +91 -0
- package/dist/tools/workspace.test.js +75 -0
- package/dist/tools/x-search.js +142 -0
- package/dist/types/index.js +1 -0
- package/dist/types/schema.test.js +1 -0
- package/dist/ui/ink/SciraApp.js +74 -21
- package/dist/ui/ink/components/overlays.js +15 -9
- package/dist/ui/ink/constants.js +13 -4
- package/dist/ui/ink/hooks/use-agent-turn.js +26 -7
- package/dist/ui/ink/hooks/use-feed-lines.js +33 -6
- package/dist/ui/ink/hooks/use-keyboard.js +16 -1
- package/dist/ui/ink/hooks/use-session.js +15 -14
- package/dist/ui/ink/hooks/use-settings.js +30 -8
- package/dist/ui/ink/hooks/use-submit.js +14 -3
- package/dist/ui/ink/hooks/use-theme.js +1 -1
- package/dist/ui/ink/lib/tool-result.js +73 -5
- package/dist/ui/ink/lib/tool-result.test.js +3 -3
- package/dist/ui/ink/lib/utils.js +104 -5
- package/dist/ui/ink/lib/utils.test.js +18 -1
- package/dist/ui/ink/theme-context.js +29 -26
- package/dist/ui/ink/theme.js +36 -9
- package/dist/ui/ink/theme.test.js +32 -5
- package/package.json +6 -2
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { exec, execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
|
+
import { tool } from "ai";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { multiSearchWeb } from "./search-web.js";
|
|
8
|
+
import { createXSearchTool } from "./x-search.js";
|
|
9
|
+
import { openUrl, writeSnapshot } from "./open-url.js";
|
|
10
|
+
import { appendJsonl, readJsonl } from "../storage/jsonl.js";
|
|
11
|
+
import { logEvent } from "../storage/run-store.js";
|
|
12
|
+
import { diffLines } from "diff";
|
|
13
|
+
import { SKILL_NAMES, getSkill } from "../agent/skills.js";
|
|
14
|
+
import { createFileTools } from "./file-tools.js";
|
|
15
|
+
import { createTodoTool } from "./todos.js";
|
|
16
|
+
import { resolveToolPath, resolveInsideWorkspace, harnessBasename } from "./workspace.js";
|
|
17
|
+
export { resolveInsideRun } from "./workspace.js";
|
|
18
|
+
const execAsync = promisify(exec);
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
const MAX_OUTPUT = 8000;
|
|
21
|
+
function truncate(text, max = MAX_OUTPUT) {
|
|
22
|
+
if (text.length <= max)
|
|
23
|
+
return text;
|
|
24
|
+
return `${text.slice(0, max)}\n…[truncated ${text.length - max} chars]`;
|
|
25
|
+
}
|
|
26
|
+
/** Derive a stable, filesystem-safe snapshot filename slug from a URL. */
|
|
27
|
+
function snapshotSlug(url) {
|
|
28
|
+
return url.replace(/^https?:\/\//u, "").replace(/[^a-zA-Z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80) || "page";
|
|
29
|
+
}
|
|
30
|
+
export const PLAN_MODE_MSG = "Plan mode is active. Exit plan mode (/plan) before making changes.";
|
|
31
|
+
const FIND_MUTATING_FLAGS = /\s-(?:delete|exec|execdir|ok|okdir|fprintf|fls|fprint)\b/u;
|
|
32
|
+
/** Reject parent refs, absolute paths, and home expansion in plan-mode bash. */
|
|
33
|
+
function bashPathsStayInCwd(command) {
|
|
34
|
+
if (/\.\./u.test(command))
|
|
35
|
+
return false;
|
|
36
|
+
if (/\.\/\//u.test(command))
|
|
37
|
+
return false;
|
|
38
|
+
if (/(?:^|\s)\//u.test(command))
|
|
39
|
+
return false;
|
|
40
|
+
if (/~[/\\]/u.test(command) || /\$HOME/u.test(command))
|
|
41
|
+
return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const BASH_PRIVILEGED_FLAGS = /--(?:extcmd|ext-diff|pre)\b/u;
|
|
45
|
+
/** Read-only / self-gated tools that must stay available while plan mode is active. */
|
|
46
|
+
const PLAN_MODE_UNRESTRICTED = new Set([
|
|
47
|
+
"webSearch",
|
|
48
|
+
"xSearch",
|
|
49
|
+
"readUrl",
|
|
50
|
+
"readFile",
|
|
51
|
+
"readWorkspaceFile",
|
|
52
|
+
"listWorkspaceDir",
|
|
53
|
+
"grepWorkspace",
|
|
54
|
+
"listSkills",
|
|
55
|
+
"readSkill",
|
|
56
|
+
"bash",
|
|
57
|
+
"runWorkspaceCommand",
|
|
58
|
+
"todo"
|
|
59
|
+
]);
|
|
60
|
+
/** Block MCP and mutating tools while plan mode is active. */
|
|
61
|
+
export function wrapToolsForPlanMode(tools, getPlanMode) {
|
|
62
|
+
if (!getPlanMode)
|
|
63
|
+
return tools;
|
|
64
|
+
const wrapped = {};
|
|
65
|
+
for (const [name, entry] of Object.entries(tools)) {
|
|
66
|
+
if (!entry || typeof entry !== "object" || !("execute" in entry) || typeof entry.execute !== "function") {
|
|
67
|
+
wrapped[name] = entry;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (PLAN_MODE_UNRESTRICTED.has(name)) {
|
|
71
|
+
wrapped[name] = entry;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const original = entry.execute;
|
|
75
|
+
wrapped[name] = {
|
|
76
|
+
...entry,
|
|
77
|
+
execute: async (input, options) => {
|
|
78
|
+
if (getPlanMode())
|
|
79
|
+
return PLAN_MODE_MSG;
|
|
80
|
+
return original(input, options);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return wrapped;
|
|
85
|
+
}
|
|
86
|
+
/** Bash commands allowed during plan mode (exploration only). */
|
|
87
|
+
export function isReadOnlyBashCommand(command) {
|
|
88
|
+
const c = command.trim();
|
|
89
|
+
if (!c)
|
|
90
|
+
return false;
|
|
91
|
+
if (/[\r\n]/u.test(c))
|
|
92
|
+
return false;
|
|
93
|
+
if (/[;&|`$<>]|\$\(/u.test(c))
|
|
94
|
+
return false;
|
|
95
|
+
if (BASH_PRIVILEGED_FLAGS.test(c))
|
|
96
|
+
return false;
|
|
97
|
+
if (!bashPathsStayInCwd(c))
|
|
98
|
+
return false;
|
|
99
|
+
const parts = c.split(/\s+/u);
|
|
100
|
+
const bin = parts[0]?.replace(/^\.\//u, "") ?? "";
|
|
101
|
+
if (bin === "find")
|
|
102
|
+
return !FIND_MUTATING_FLAGS.test(c);
|
|
103
|
+
const readOnlyBins = new Set(["ls", "cat", "head", "tail", "wc", "grep", "rg", "pwd", "file", "stat", "tree", "which"]);
|
|
104
|
+
if (readOnlyBins.has(bin))
|
|
105
|
+
return true;
|
|
106
|
+
if (bin === "git") {
|
|
107
|
+
if (/\s-(?:c|config)\b/u.test(c))
|
|
108
|
+
return false;
|
|
109
|
+
const sub = parts[1] ?? "";
|
|
110
|
+
return ["status", "log", "diff", "show", "rev-parse", "describe", "shortlog"].includes(sub);
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
function planModeActive(getPlanMode) {
|
|
115
|
+
return getPlanMode?.() ?? false;
|
|
116
|
+
}
|
|
117
|
+
async function listWorkspaceRecursive(root, limit) {
|
|
118
|
+
const results = [];
|
|
119
|
+
const queue = [root];
|
|
120
|
+
while (queue.length > 0 && results.length < limit) {
|
|
121
|
+
const dir = queue.shift();
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
if (results.length >= limit)
|
|
131
|
+
break;
|
|
132
|
+
const full = join(dir, entry.name);
|
|
133
|
+
results.push(full);
|
|
134
|
+
if (entry.isDirectory())
|
|
135
|
+
queue.push(full);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
/** Plan mode: workspace mutations blocked; harness writes limited to plan.md. */
|
|
141
|
+
function planModeBlocksWrite(getPlanMode, scope, harnessName) {
|
|
142
|
+
if (!planModeActive(getPlanMode))
|
|
143
|
+
return null;
|
|
144
|
+
if (scope === "workspace")
|
|
145
|
+
return PLAN_MODE_MSG;
|
|
146
|
+
if (harnessName === "plan.md")
|
|
147
|
+
return null;
|
|
148
|
+
return PLAN_MODE_MSG;
|
|
149
|
+
}
|
|
150
|
+
function planModeBlocksEdit(getPlanMode, scope, harnessName) {
|
|
151
|
+
if (!planModeActive(getPlanMode))
|
|
152
|
+
return null;
|
|
153
|
+
if (scope === "workspace")
|
|
154
|
+
return PLAN_MODE_MSG;
|
|
155
|
+
if (harnessName === "plan.md")
|
|
156
|
+
return null;
|
|
157
|
+
return PLAN_MODE_MSG;
|
|
158
|
+
}
|
|
159
|
+
export function createResearchTools(runPath, config, onApprovalRequired, workspacePath, getPlanMode) {
|
|
160
|
+
/** Gate a tool behind user approval unless approvalMode is "auto". */
|
|
161
|
+
async function gate(toolName, description) {
|
|
162
|
+
if (config.approvalMode === "auto" || !onApprovalRequired)
|
|
163
|
+
return true;
|
|
164
|
+
return onApprovalRequired(toolName, description);
|
|
165
|
+
}
|
|
166
|
+
const claimsPath = join(runPath, "claims.jsonl");
|
|
167
|
+
const filePathHint = workspacePath
|
|
168
|
+
? "Project source paths (src/foo.ts, package.json, …) are relative to the project root. Harness files (plan.md, notes.md, report.md, sources.jsonl) live in the run directory — use those bare names."
|
|
169
|
+
: "Paths are relative to the run directory.";
|
|
170
|
+
const runBash = tool({
|
|
171
|
+
description: "Run a shell command inside the run harness directory (.scira/runs/…). Use for grepping notes.md, listing run artifacts, etc. Returns combined stdout/stderr.",
|
|
172
|
+
inputSchema: z.object({
|
|
173
|
+
command: z.string().describe("The shell command to execute."),
|
|
174
|
+
timeoutMs: z.number().int().positive().max(120000).optional().describe("Optional timeout in ms (default 60000).")
|
|
175
|
+
}),
|
|
176
|
+
execute: async ({ command, timeoutMs }) => {
|
|
177
|
+
if (planModeActive(getPlanMode) && !isReadOnlyBashCommand(command))
|
|
178
|
+
return PLAN_MODE_MSG;
|
|
179
|
+
if (!await gate("bash", command))
|
|
180
|
+
return "Command rejected by user.";
|
|
181
|
+
const escapesRunDir = /(?:^|[;&|`\n])\s*cd\s+[^.]/u.test(command)
|
|
182
|
+
|| /\.\.\/\.\.\//u.test(command)
|
|
183
|
+
|| /~[/\\]/u.test(command)
|
|
184
|
+
|| /\$HOME/u.test(command);
|
|
185
|
+
if (escapesRunDir) {
|
|
186
|
+
return "Command rejected: navigating outside the run directory is not allowed.";
|
|
187
|
+
}
|
|
188
|
+
await logEvent(runPath, "tool.bash", { command });
|
|
189
|
+
try {
|
|
190
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
191
|
+
cwd: runPath,
|
|
192
|
+
timeout: timeoutMs ?? 60000,
|
|
193
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
194
|
+
shell: "/bin/bash"
|
|
195
|
+
});
|
|
196
|
+
const out = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
197
|
+
return truncate(out || "(no output)");
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const err = error;
|
|
201
|
+
const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim();
|
|
202
|
+
return truncate(`Command failed:\n${out}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
...(workspacePath ? { runBash } : { bash: runBash }),
|
|
208
|
+
writeFile: tool({
|
|
209
|
+
description: `Create or overwrite a file. ${filePathHint}`,
|
|
210
|
+
inputSchema: z.object({
|
|
211
|
+
path: z.string().describe("File path."),
|
|
212
|
+
content: z.string().describe("Full file content to write.")
|
|
213
|
+
}),
|
|
214
|
+
execute: async ({ path, content }) => {
|
|
215
|
+
const resolved = resolveToolPath(runPath, workspacePath, path);
|
|
216
|
+
const harnessName = resolved.scope === "run" ? harnessBasename(resolved.displayPath) : null;
|
|
217
|
+
if (harnessName === "background-tasks.json") {
|
|
218
|
+
return "background-tasks.json is managed by bash background tasks. Do not write it directly.";
|
|
219
|
+
}
|
|
220
|
+
const blocked = planModeBlocksWrite(getPlanMode, resolved.scope, harnessName);
|
|
221
|
+
if (blocked)
|
|
222
|
+
return blocked;
|
|
223
|
+
const needsApproval = resolved.scope === "workspace"
|
|
224
|
+
|| harnessName === "plan.md"
|
|
225
|
+
|| harnessName === "report.md";
|
|
226
|
+
if (needsApproval) {
|
|
227
|
+
let description;
|
|
228
|
+
if (harnessName === "report.md" && resolved.scope === "run") {
|
|
229
|
+
const existing = await readFile(resolved.abs, "utf8").catch(() => "");
|
|
230
|
+
const parts = diffLines(existing, content);
|
|
231
|
+
const added = parts.filter((p) => p.added).reduce((n, p) => n + (p.count ?? 0), 0);
|
|
232
|
+
const removed = parts.filter((p) => p.removed).reduce((n, p) => n + (p.count ?? 0), 0);
|
|
233
|
+
const diffPreview = parts
|
|
234
|
+
.filter((p) => p.added || p.removed)
|
|
235
|
+
.flatMap((p) => p.value.split("\n").filter(Boolean).map((l) => `${p.added ? "+" : "-"} ${l}`))
|
|
236
|
+
.slice(0, 20)
|
|
237
|
+
.join("\n");
|
|
238
|
+
description = `report.md (+${added} / -${removed} lines)\n\n${diffPreview}`;
|
|
239
|
+
}
|
|
240
|
+
else if (resolved.scope === "workspace") {
|
|
241
|
+
const preview = content.length > 800 ? `${content.slice(0, 800)}\n…[${content.length} total chars]` : content;
|
|
242
|
+
description = `Write to ${resolved.displayPath}:\n\n${preview}`;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
description = `${path}\n\n${content.length > 600 ? `${content.slice(0, 600)}\n…` : content}`;
|
|
246
|
+
}
|
|
247
|
+
if (!await gate("writeFile", description))
|
|
248
|
+
return `Write to ${resolved.displayPath} rejected by user.`;
|
|
249
|
+
}
|
|
250
|
+
await mkdir(dirname(resolved.abs), { recursive: true });
|
|
251
|
+
await writeFile(resolved.abs, content);
|
|
252
|
+
const eventType = harnessName === "report.md" ? "report.updated" : harnessName === "plan.md" ? "plan.updated" : "file.written";
|
|
253
|
+
await logEvent(runPath, eventType, { path: resolved.displayPath, scope: resolved.scope, chars: content.length });
|
|
254
|
+
const where = resolved.scope === "workspace" ? "workspace" : "run";
|
|
255
|
+
return `Wrote ${content.length} chars to ${resolved.displayPath} (${where})`;
|
|
256
|
+
}
|
|
257
|
+
}),
|
|
258
|
+
editFile: tool({
|
|
259
|
+
description: `Replace an exact string in an existing file. ${filePathHint} The oldString must match exactly and be unique.`,
|
|
260
|
+
inputSchema: z.object({
|
|
261
|
+
path: z.string().describe("File path."),
|
|
262
|
+
oldString: z.string().describe("Exact text to replace."),
|
|
263
|
+
newString: z.string().describe("Replacement text.")
|
|
264
|
+
}),
|
|
265
|
+
execute: async ({ path, oldString, newString }) => {
|
|
266
|
+
const resolved = resolveToolPath(runPath, workspacePath, path);
|
|
267
|
+
const harnessName = resolved.scope === "run" ? harnessBasename(resolved.displayPath) : null;
|
|
268
|
+
const blocked = planModeBlocksEdit(getPlanMode, resolved.scope, harnessName);
|
|
269
|
+
if (blocked)
|
|
270
|
+
return blocked;
|
|
271
|
+
const current = await readFile(resolved.abs, "utf8");
|
|
272
|
+
const occurrences = current.split(oldString).length - 1;
|
|
273
|
+
if (occurrences === 0) {
|
|
274
|
+
return `No match for the given oldString in ${resolved.displayPath}. No changes made.`;
|
|
275
|
+
}
|
|
276
|
+
if (occurrences > 1) {
|
|
277
|
+
return `oldString matched ${occurrences} times in ${resolved.displayPath}; provide more context to make it unique. No changes made.`;
|
|
278
|
+
}
|
|
279
|
+
if (resolved.scope === "workspace") {
|
|
280
|
+
const diff = diffLines(current, current.replace(oldString, newString));
|
|
281
|
+
const preview = diff
|
|
282
|
+
.filter((p) => p.added || p.removed)
|
|
283
|
+
.flatMap((p) => p.value.split("\n").filter(Boolean).map((l) => `${p.added ? "+" : "-"} ${l}`))
|
|
284
|
+
.slice(0, 15)
|
|
285
|
+
.join("\n");
|
|
286
|
+
if (!await gate("editFile", `Edit ${resolved.displayPath}:\n\n${preview}`)) {
|
|
287
|
+
return `Edit to ${resolved.displayPath} rejected by user.`;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
await writeFile(resolved.abs, current.replace(oldString, newString));
|
|
291
|
+
await logEvent(runPath, "file.edited", { path: resolved.displayPath, scope: resolved.scope });
|
|
292
|
+
return `Edited ${resolved.displayPath}`;
|
|
293
|
+
}
|
|
294
|
+
}),
|
|
295
|
+
createClaim: tool({
|
|
296
|
+
description: "Record a structured research claim into claims.jsonl. Call after reading a source to track a significant finding.",
|
|
297
|
+
inputSchema: z.object({
|
|
298
|
+
id: z.string().describe("Unique claim ID, e.g. claim_001."),
|
|
299
|
+
text: z.string().describe("The claim statement."),
|
|
300
|
+
confidence: z.enum(["low", "medium", "high"]).describe("Confidence level."),
|
|
301
|
+
sourceIds: z.array(z.string()).describe("Source IDs supporting this claim."),
|
|
302
|
+
reason: z.string().describe("Why these sources support the claim.")
|
|
303
|
+
}),
|
|
304
|
+
execute: async ({ id, text, confidence, sourceIds, reason }) => {
|
|
305
|
+
if (planModeActive(getPlanMode))
|
|
306
|
+
return PLAN_MODE_MSG;
|
|
307
|
+
const claim = { id, text, confidence, status: "draft", sourceIds, reason, createdAt: new Date().toISOString() };
|
|
308
|
+
await appendJsonl(claimsPath, claim);
|
|
309
|
+
await logEvent(runPath, "claim.created", { id, confidence, sourceIds });
|
|
310
|
+
return `Claim ${id} recorded.`;
|
|
311
|
+
}
|
|
312
|
+
}),
|
|
313
|
+
verifyClaim: tool({
|
|
314
|
+
description: "Update a claim's verification status after checking its evidence. Call after confirming or questioning a claim.",
|
|
315
|
+
inputSchema: z.object({
|
|
316
|
+
id: z.string().describe("Claim ID to update."),
|
|
317
|
+
status: z.enum(["verified", "weak", "contradicted", "needs_review"]).describe("New verification status."),
|
|
318
|
+
reason: z.string().describe("Explanation of the verification result.")
|
|
319
|
+
}),
|
|
320
|
+
execute: async ({ id, status, reason }) => {
|
|
321
|
+
if (planModeActive(getPlanMode))
|
|
322
|
+
return PLAN_MODE_MSG;
|
|
323
|
+
const claims = await readJsonl(claimsPath);
|
|
324
|
+
const idx = claims.findIndex((c) => c.id === id);
|
|
325
|
+
if (idx === -1) {
|
|
326
|
+
return `Claim "${id}" not found. Present IDs: ${claims.map((c) => c.id).join(", ") || "none"}`;
|
|
327
|
+
}
|
|
328
|
+
claims[idx] = { ...claims[idx], status, reason };
|
|
329
|
+
await mkdir(dirname(claimsPath), { recursive: true });
|
|
330
|
+
await writeFile(claimsPath, claims.map((c) => JSON.stringify(c)).join("\n") + "\n");
|
|
331
|
+
await logEvent(runPath, "claim.verified", { id, status });
|
|
332
|
+
return `Claim ${id} → ${status}`;
|
|
333
|
+
}
|
|
334
|
+
}),
|
|
335
|
+
readFile: tool({
|
|
336
|
+
description: `Read a file. ${filePathHint}`,
|
|
337
|
+
inputSchema: z.object({
|
|
338
|
+
path: z.string().describe("File path.")
|
|
339
|
+
}),
|
|
340
|
+
execute: async ({ path }) => {
|
|
341
|
+
const resolved = resolveToolPath(runPath, workspacePath, path);
|
|
342
|
+
return truncate(await readFile(resolved.abs, "utf8"));
|
|
343
|
+
}
|
|
344
|
+
}),
|
|
345
|
+
webSearch: tool({
|
|
346
|
+
description: `Search the web with multiple parallel queries. Always use 3-5 queries per call to cover the topic from different angles.
|
|
347
|
+
- Include date context in queries: "${new Date().getFullYear()}", "latest", "recent".
|
|
348
|
+
- Use topic:"news" for breaking events, quality:"best" only when depth is essential.
|
|
349
|
+
- Never invent sources — only cite URLs returned by this tool.`,
|
|
350
|
+
inputSchema: z.object({
|
|
351
|
+
queries: z.array(z.string()).min(3).max(10).describe("3-5 search queries covering different angles of the topic."),
|
|
352
|
+
maxResults: z.array(z.number().int().min(1).max(20)).max(10).optional().describe("Max results per query (default 10)."),
|
|
353
|
+
topics: z.array(z.enum(["general", "news"])).optional().describe("Topic type per query. Default: general."),
|
|
354
|
+
quality: z.array(z.enum(["default", "best"])).optional().describe("Search quality per query. Use best sparingly."),
|
|
355
|
+
startDates: z.array(z.string().nullable().optional()).optional().describe("ISO date filter per query (YYYY-MM-DD). Omit for no filter.")
|
|
356
|
+
}),
|
|
357
|
+
execute: async ({ queries, maxResults, topics, quality, startDates }) => {
|
|
358
|
+
const perQuery = queries.map((_, i) => ({
|
|
359
|
+
maxResults: maxResults?.[i] ?? 10,
|
|
360
|
+
topic: (topics?.[i] ?? "general"),
|
|
361
|
+
quality: (quality?.[i] ?? "default"),
|
|
362
|
+
startDate: startDates?.[i] ?? null
|
|
363
|
+
}));
|
|
364
|
+
const searches = await multiSearchWeb(queries, perQuery, config);
|
|
365
|
+
const resultCount = searches.reduce((n, s) => n + s.results.length, 0);
|
|
366
|
+
const errors = searches.map((s) => s.error).filter((e) => Boolean(e));
|
|
367
|
+
await logEvent(runPath, "tool.search", { queries, resultCount, errors: errors.length > 0 ? errors : undefined });
|
|
368
|
+
if (resultCount === 0 && errors.length > 0) {
|
|
369
|
+
throw new Error(`Web search returned no results: ${errors.join(" | ")}`);
|
|
370
|
+
}
|
|
371
|
+
return JSON.stringify(searches.map((s) => ({
|
|
372
|
+
query: s.query,
|
|
373
|
+
...(s.error ? { error: s.error } : {}),
|
|
374
|
+
results: s.results.map((r) => ({ title: r.title, url: r.url, snippet: r.snippet, publishedDate: r.publishedDate }))
|
|
375
|
+
})), null, 2);
|
|
376
|
+
}
|
|
377
|
+
}),
|
|
378
|
+
readUrl: tool({
|
|
379
|
+
description: "Fetch and extract the readable content of a web page so you can read and cite it.",
|
|
380
|
+
inputSchema: z.object({
|
|
381
|
+
url: z.string().describe("The URL to open and extract.")
|
|
382
|
+
}),
|
|
383
|
+
execute: async ({ url }) => {
|
|
384
|
+
const page = await openUrl(url, config);
|
|
385
|
+
const snapshotPath = await writeSnapshot(join(runPath, "snapshots"), snapshotSlug(url), page);
|
|
386
|
+
const snapshotRel = relative(runPath, snapshotPath);
|
|
387
|
+
await logEvent(runPath, "tool.open_url", { url, title: page.title, snapshot: snapshotRel });
|
|
388
|
+
return truncate(`# ${page.title}\n(snapshot saved to ${snapshotRel} — record this as snapshotPath in sources.jsonl)\n\n${page.text}`);
|
|
389
|
+
}
|
|
390
|
+
}),
|
|
391
|
+
listSkills: tool({
|
|
392
|
+
description: "List the names and one-line summaries of all built-in research skills.",
|
|
393
|
+
inputSchema: z.object({}),
|
|
394
|
+
execute: async () => {
|
|
395
|
+
return SKILL_NAMES.map((n) => {
|
|
396
|
+
const s = getSkill(n);
|
|
397
|
+
return `${n}: ${s?.summary ?? ""}`;
|
|
398
|
+
}).join("\n");
|
|
399
|
+
}
|
|
400
|
+
}),
|
|
401
|
+
readSkill: tool({
|
|
402
|
+
description: "Read the full content of a built-in research skill by name. " +
|
|
403
|
+
"The available skill names are listed in your instructions.",
|
|
404
|
+
inputSchema: z.object({
|
|
405
|
+
name: z.string().describe("Skill name exactly as listed in the instructions.")
|
|
406
|
+
}),
|
|
407
|
+
execute: async ({ name }) => {
|
|
408
|
+
const skill = getSkill(name);
|
|
409
|
+
if (!skill) {
|
|
410
|
+
return `Unknown skill "${name}". Available: ${SKILL_NAMES.join(", ")}`;
|
|
411
|
+
}
|
|
412
|
+
return skill.content;
|
|
413
|
+
}
|
|
414
|
+
}),
|
|
415
|
+
todo: createTodoTool(runPath),
|
|
416
|
+
...(process.env.XAI_API_KEY ? { xSearch: createXSearchTool(runPath) } : {}),
|
|
417
|
+
...(config.files ? createFileTools(runPath, config, onApprovalRequired, getPlanMode) : {})
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Lightweight toolset for quick one-shot answers: web search + page reading,
|
|
422
|
+
* plus a single escalation tool the model can call (with user approval) to
|
|
423
|
+
* switch into the full research harness.
|
|
424
|
+
*/
|
|
425
|
+
export function createOneShotTools(runPath, config, onApprovalRequired, onEscalate, workspacePath, backgroundTasks, getPlanMode) {
|
|
426
|
+
const all = createResearchTools(runPath, config, onApprovalRequired, workspacePath, getPlanMode);
|
|
427
|
+
const coding = workspacePath
|
|
428
|
+
? createCodingTools(workspacePath, config, onApprovalRequired, backgroundTasks, runPath, getPlanMode)
|
|
429
|
+
: {};
|
|
430
|
+
async function gate(toolName, description) {
|
|
431
|
+
if (config.approvalMode === "auto" || !onApprovalRequired)
|
|
432
|
+
return true;
|
|
433
|
+
return onApprovalRequired(toolName, description);
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
webSearch: all.webSearch,
|
|
437
|
+
...(all.xSearch ? { xSearch: all.xSearch } : {}),
|
|
438
|
+
readUrl: all.readUrl,
|
|
439
|
+
readFile: all.readFile,
|
|
440
|
+
writeFile: all.writeFile,
|
|
441
|
+
editFile: all.editFile,
|
|
442
|
+
todo: all.todo,
|
|
443
|
+
...coding,
|
|
444
|
+
requestFullResearch: tool({
|
|
445
|
+
description: "Escalate from quick one-shot mode to the FULL research harness (skills, plan.md, claims, verification, sources.jsonl, report.md). " +
|
|
446
|
+
"You MUST call this if the user's goal involves research, deep dives, analysis, comparisons, history, or any topic that would benefit from structured multi-source research. " +
|
|
447
|
+
"For simple factual questions (e.g. 'what is the capital of France?') do NOT call this — just answer directly.",
|
|
448
|
+
inputSchema: z.object({
|
|
449
|
+
reason: z.string().describe("One sentence on why full research is warranted.")
|
|
450
|
+
}),
|
|
451
|
+
execute: async ({ reason }) => {
|
|
452
|
+
const approved = await gate("requestFullResearch", `Escalate to the full research harness?\n\nReason: ${reason}`);
|
|
453
|
+
if (!approved) {
|
|
454
|
+
return "User declined escalation. Answer the question concisely now using your available read/search tools — do not ask to escalate again.";
|
|
455
|
+
}
|
|
456
|
+
onEscalate?.();
|
|
457
|
+
return "Approved. Stop now and do not call more tools — the full research harness will take over and complete the work.";
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Workspace-aware tools for coding agent capabilities.
|
|
464
|
+
* Unlike research tools, these operate on the full workspace, not just the run directory.
|
|
465
|
+
*/
|
|
466
|
+
export function createCodingTools(workspacePath, config, onApprovalRequired, backgroundTasks, runPath, getPlanMode) {
|
|
467
|
+
async function gate(toolName, description) {
|
|
468
|
+
if (config.approvalMode === "auto" || !onApprovalRequired)
|
|
469
|
+
return true;
|
|
470
|
+
return onApprovalRequired(toolName, description);
|
|
471
|
+
}
|
|
472
|
+
function resolveWorkspacePath(candidate) {
|
|
473
|
+
return resolveInsideWorkspace(workspacePath, candidate);
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
listWorkspaceDir: tool({
|
|
477
|
+
description: "List files and directories in the project workspace.",
|
|
478
|
+
inputSchema: z.object({
|
|
479
|
+
path: z.string().describe("Directory path (absolute or relative to workspace root)."),
|
|
480
|
+
recursive: z.boolean().optional().describe("Recursively list subdirectories (default false).")
|
|
481
|
+
}),
|
|
482
|
+
execute: async ({ path, recursive }) => {
|
|
483
|
+
const abs = resolveWorkspacePath(path);
|
|
484
|
+
try {
|
|
485
|
+
if (recursive) {
|
|
486
|
+
const lines = await listWorkspaceRecursive(abs, 200);
|
|
487
|
+
return truncate(lines.join("\n") || "(empty)");
|
|
488
|
+
}
|
|
489
|
+
const { stdout } = await execFileAsync("ls", ["-lah", abs], { maxBuffer: 5 * 1024 * 1024 });
|
|
490
|
+
return truncate(stdout.trim());
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
const err = error;
|
|
494
|
+
return `Failed to list ${path}: ${err.stderr ?? err.message}`;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}),
|
|
498
|
+
grepWorkspace: tool({
|
|
499
|
+
description: "Search for a pattern across workspace files using grep. Essential for finding code references.",
|
|
500
|
+
inputSchema: z.object({
|
|
501
|
+
pattern: z.string().describe("Search pattern (regex supported)."),
|
|
502
|
+
path: z.string().optional().describe("Directory to search (default: workspace root)."),
|
|
503
|
+
filePattern: z.string().optional().describe("File pattern to include (e.g., '*.ts', '*.{js,jsx}').")
|
|
504
|
+
}),
|
|
505
|
+
execute: async ({ pattern, path, filePattern }) => {
|
|
506
|
+
const searchPath = path ? resolveWorkspacePath(path) : workspacePath;
|
|
507
|
+
const args = ["-rn"];
|
|
508
|
+
if (filePattern)
|
|
509
|
+
args.push(`--include=${filePattern}`);
|
|
510
|
+
args.push("-E", pattern, "--", searchPath);
|
|
511
|
+
try {
|
|
512
|
+
const { stdout } = await execFileAsync("grep", args, { maxBuffer: 5 * 1024 * 1024 });
|
|
513
|
+
const lines = stdout.trim().split("\n").filter(Boolean).slice(0, 100);
|
|
514
|
+
const result = lines.join("\n") || `No matches found for pattern: ${pattern}`;
|
|
515
|
+
return truncate(result);
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
const err = error;
|
|
519
|
+
if (err.code === "1")
|
|
520
|
+
return `No matches found for pattern: ${pattern}`;
|
|
521
|
+
const result = err.stdout?.trim() || `No matches found for pattern: ${pattern}`;
|
|
522
|
+
return truncate(result);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}),
|
|
526
|
+
bash: tool({
|
|
527
|
+
description: "Run shell commands in the workspace. " +
|
|
528
|
+
"Actions: run (foreground, default), background (start a long-running process like a dev server), list (show background tasks), output (read task logs), kill (stop a background task). " +
|
|
529
|
+
"Use background for servers and watchers; the task id and output remain available across turns.",
|
|
530
|
+
inputSchema: z.object({
|
|
531
|
+
action: z
|
|
532
|
+
.enum(["run", "background", "list", "output", "kill"])
|
|
533
|
+
.optional()
|
|
534
|
+
.describe("run=foreground (default), background=spawn detached, list/output/kill manage background tasks."),
|
|
535
|
+
command: z.string().optional().describe("Shell command for run or background."),
|
|
536
|
+
taskId: z.string().optional().describe("Task id for output or kill."),
|
|
537
|
+
cwd: z.string().optional().describe("Working directory (default: workspace root)."),
|
|
538
|
+
timeoutMs: z.number().int().positive().max(300000).optional().describe("Timeout for foreground run (default 60000)."),
|
|
539
|
+
tailLines: z.number().int().positive().max(200).optional().describe("Lines of output for action=output (default 50).")
|
|
540
|
+
}),
|
|
541
|
+
execute: async ({ action, command, taskId, cwd, timeoutMs, tailLines }) => {
|
|
542
|
+
const act = action ?? "run";
|
|
543
|
+
const workDir = cwd ? resolveWorkspacePath(cwd) : workspacePath;
|
|
544
|
+
if (act === "list") {
|
|
545
|
+
if (!backgroundTasks)
|
|
546
|
+
return "No background task manager available.";
|
|
547
|
+
const tasks = await backgroundTasks.list();
|
|
548
|
+
if (tasks.length === 0)
|
|
549
|
+
return "No background tasks.";
|
|
550
|
+
return tasks
|
|
551
|
+
.map((t) => `${t.id} [${t.status}] pid=${t.pid} ${t.command}`)
|
|
552
|
+
.join("\n");
|
|
553
|
+
}
|
|
554
|
+
if (act === "output") {
|
|
555
|
+
if (!backgroundTasks)
|
|
556
|
+
return "No background task manager available.";
|
|
557
|
+
if (!taskId)
|
|
558
|
+
return "output requires taskId.";
|
|
559
|
+
return truncate(await backgroundTasks.getOutput(taskId, tailLines ?? 50));
|
|
560
|
+
}
|
|
561
|
+
if (act === "kill") {
|
|
562
|
+
if (!backgroundTasks)
|
|
563
|
+
return "No background task manager available.";
|
|
564
|
+
if (!taskId)
|
|
565
|
+
return "kill requires taskId.";
|
|
566
|
+
const task = await backgroundTasks.getTask(taskId);
|
|
567
|
+
if (!task)
|
|
568
|
+
return `Task "${taskId}" not found.`;
|
|
569
|
+
const killPreview = [
|
|
570
|
+
"Kill background task?",
|
|
571
|
+
"",
|
|
572
|
+
`ID: ${task.id}`,
|
|
573
|
+
`PID: ${task.pid}`,
|
|
574
|
+
`CWD: ${task.cwd}`,
|
|
575
|
+
`Command: ${task.command}`
|
|
576
|
+
].join("\n");
|
|
577
|
+
if (!await gate("bash", killPreview))
|
|
578
|
+
return "Kill rejected by user.";
|
|
579
|
+
return await backgroundTasks.kill(taskId);
|
|
580
|
+
}
|
|
581
|
+
if (!command)
|
|
582
|
+
return `${act} requires command.`;
|
|
583
|
+
if (planModeActive(getPlanMode) && act === "background")
|
|
584
|
+
return PLAN_MODE_MSG;
|
|
585
|
+
if (planModeActive(getPlanMode) && act === "run" && !isReadOnlyBashCommand(command))
|
|
586
|
+
return PLAN_MODE_MSG;
|
|
587
|
+
if (act === "background") {
|
|
588
|
+
if (!backgroundTasks)
|
|
589
|
+
return "Background tasks not available in this session.";
|
|
590
|
+
if (!await gate("bash", `Start background in ${relative(workspacePath, workDir) || "."}:\n\n${command}`)) {
|
|
591
|
+
return "Command rejected by user.";
|
|
592
|
+
}
|
|
593
|
+
const task = await backgroundTasks.spawn(command, workDir);
|
|
594
|
+
if (runPath)
|
|
595
|
+
await logEvent(runPath, "tool.bash.background", { taskId: task.id, command });
|
|
596
|
+
return `Started background task ${task.id} (pid ${task.pid}): ${command}\nUse bash action=output taskId=${task.id} to read output.`;
|
|
597
|
+
}
|
|
598
|
+
if (!await gate("bash", `Run in ${relative(workspacePath, workDir) || "."}:\n\n${command}`)) {
|
|
599
|
+
return "Command rejected by user.";
|
|
600
|
+
}
|
|
601
|
+
if (runPath)
|
|
602
|
+
await logEvent(runPath, "tool.bash", { command, cwd: workDir });
|
|
603
|
+
try {
|
|
604
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
605
|
+
cwd: workDir,
|
|
606
|
+
timeout: timeoutMs ?? 60000,
|
|
607
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
608
|
+
shell: "/bin/bash"
|
|
609
|
+
});
|
|
610
|
+
const out = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
611
|
+
return truncate(out || "(no output)");
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
const err = error;
|
|
615
|
+
const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim();
|
|
616
|
+
return truncate(`Command failed:\n${out}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
};
|
|
621
|
+
}
|