kritya 0.8.2-beta
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/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- package/package.json +122 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { exec, execFile } from "node:child_process";
|
|
2
|
+
import { scrubbedShellEnv } from "../config/config.js";
|
|
3
|
+
import { gitDiffStat } from "../git/git.js";
|
|
4
|
+
import { backgroundManager } from "../shell/background.js";
|
|
5
|
+
import { buildSandboxedCommand, requiresSandbox, sandboxUnavailableReason, shouldSandbox, } from "../shell/sandbox.js";
|
|
6
|
+
import { commandTouchesSensitivePath, truncateTail } from "./common.js";
|
|
7
|
+
import { redactSecrets } from "./secretScan.js";
|
|
8
|
+
const DEFAULT_TIMEOUT_S = 120;
|
|
9
|
+
const MAX_TIMEOUT_S = 600;
|
|
10
|
+
/** git subcommands that rewrite the working tree, index, or history. */
|
|
11
|
+
const GIT_MUTATING_RE = /\bgit\s+(commit|merge|rebase|pull|checkout|reset|clean|stash|cherry-pick|revert|apply|am|rm|mv|restore|add)\b/i;
|
|
12
|
+
/** The markers `run` appends when a command didn't complete successfully. */
|
|
13
|
+
const FAILURE_MARKER = /^\[(?:exit code: |command timed out |command cancelled |command refused: )/m;
|
|
14
|
+
export const shellTool = {
|
|
15
|
+
name: "shell",
|
|
16
|
+
description: "Run a shell command from the workspace root (sh on Linux/macOS, cmd on Windows). " +
|
|
17
|
+
"Returns stdout, stderr, and the exit code. Use `cd subdir && cmd` to run in a subdirectory. " +
|
|
18
|
+
"For long-running processes (dev servers, watchers) pass background:true — the command " +
|
|
19
|
+
"returns an id immediately; use bg_output to read its output and bg_kill to stop it. " +
|
|
20
|
+
"Foreground commands time out after timeout_seconds (default 120, max 600).",
|
|
21
|
+
parameters: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
command: { type: "string", description: "The command to run" },
|
|
25
|
+
timeout_seconds: {
|
|
26
|
+
type: "number",
|
|
27
|
+
description: "Foreground timeout in seconds (default 120, max 600)",
|
|
28
|
+
},
|
|
29
|
+
background: {
|
|
30
|
+
type: "boolean",
|
|
31
|
+
description: "Run detached for servers/watchers; returns an id for bg_output/bg_kill",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ["command"],
|
|
35
|
+
},
|
|
36
|
+
requiresPermission: true,
|
|
37
|
+
// Self-managed: timeout_seconds (max 600) is enforced by exec below, and a
|
|
38
|
+
// background command returns immediately. A second, shorter cap from the
|
|
39
|
+
// agent loop would cut off commands the user explicitly asked to run longer.
|
|
40
|
+
timeoutMs: 0,
|
|
41
|
+
summarize: (args) => `Run${args.background ? " in background" : ""}: ${args.command}`,
|
|
42
|
+
// A command that printed nothing needs no preview to say so — but every
|
|
43
|
+
// other command's output is the answer, so keep it (null = show the preview).
|
|
44
|
+
resultSummary: (output) => (output.trim() === "(no output)" ? "no output" : null),
|
|
45
|
+
// A nonzero exit resolves rather than throwing, so the model still sees the
|
|
46
|
+
// output — but the trailing marker this tool wrote is what the UI needs to
|
|
47
|
+
// show a failure rather than a green check.
|
|
48
|
+
failed: (output) => FAILURE_MARKER.test(output),
|
|
49
|
+
async preview(args, ctx) {
|
|
50
|
+
const command = String(args.command ?? "");
|
|
51
|
+
if (!GIT_MUTATING_RE.test(command))
|
|
52
|
+
return null;
|
|
53
|
+
return gitDiffStat(ctx.workspace) || null;
|
|
54
|
+
},
|
|
55
|
+
execute(args, ctx, signal) {
|
|
56
|
+
const command = String(args.command);
|
|
57
|
+
const sensitivePath = commandTouchesSensitivePath(command);
|
|
58
|
+
if (sensitivePath) {
|
|
59
|
+
throw new Error(`Command references "${sensitivePath}", which looks like a secret file, and is blocked from tool access`);
|
|
60
|
+
}
|
|
61
|
+
if (args.background) {
|
|
62
|
+
const { id } = backgroundManager.start(command, ctx.workspace, ctx.sandboxMode);
|
|
63
|
+
// Echoing the command back can leak a credential embedded in it (e.g. a
|
|
64
|
+
// curl Authorization header), so redact it here just like bg_output does.
|
|
65
|
+
const { redacted } = redactSecrets(command);
|
|
66
|
+
return Promise.resolve(`Started background process ${id}: ${redacted}\nUse bg_output {"id":"${id}"} to read its output and bg_kill {"id":"${id}"} to stop it.`);
|
|
67
|
+
}
|
|
68
|
+
const timeoutS = Math.min(Math.max(Number(args.timeout_seconds) || DEFAULT_TIMEOUT_S, 1), MAX_TIMEOUT_S);
|
|
69
|
+
const runOpts = {
|
|
70
|
+
cwd: ctx.workspace,
|
|
71
|
+
env: scrubbedShellEnv(),
|
|
72
|
+
timeout: timeoutS * 1000,
|
|
73
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
signal,
|
|
76
|
+
};
|
|
77
|
+
const finish = (resolve, error, stdout, stderr, note) => {
|
|
78
|
+
const parts = [];
|
|
79
|
+
if (note)
|
|
80
|
+
parts.push(note);
|
|
81
|
+
if (stdout)
|
|
82
|
+
parts.push(stdout.trimEnd());
|
|
83
|
+
if (stderr)
|
|
84
|
+
parts.push(`[stderr]\n${stderr.trimEnd()}`);
|
|
85
|
+
if (error) {
|
|
86
|
+
if (signal?.aborted) {
|
|
87
|
+
parts.push("[command cancelled by user]");
|
|
88
|
+
}
|
|
89
|
+
else if (error.killed) {
|
|
90
|
+
parts.push(`[command timed out after ${timeoutS}s — for servers/watchers use background:true]`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
parts.push(`[exit code: ${error.code ?? "unknown"}]`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const joined = parts.join("\n") || "(no output)";
|
|
97
|
+
const { redacted, matches } = redactSecrets(joined);
|
|
98
|
+
const withNote = matches.length > 0
|
|
99
|
+
? `[${matches.length} secret(s) redacted from output: ${matches.map((m) => m.kind).join(", ")}]\n${redacted}`
|
|
100
|
+
: redacted;
|
|
101
|
+
resolve(truncateTail(withNote));
|
|
102
|
+
};
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
if (shouldSandbox(ctx.sandboxMode, command)) {
|
|
105
|
+
const wrapped = buildSandboxedCommand(command, ctx.workspace);
|
|
106
|
+
if (wrapped) {
|
|
107
|
+
const opts = wrapped.env
|
|
108
|
+
? { ...runOpts, env: { ...runOpts.env, ...wrapped.env } }
|
|
109
|
+
: runOpts;
|
|
110
|
+
execFile(wrapped.cmd, wrapped.args, opts, (error, stdout, stderr) => {
|
|
111
|
+
wrapped.cleanup?.();
|
|
112
|
+
finish(resolve, error, stdout, stderr);
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Sandboxing was requested but no sandbox binary is available here.
|
|
117
|
+
// In "strict" mode that's a hard requirement, not a best-effort one —
|
|
118
|
+
// refuse to run rather than silently falling back to an unconfined
|
|
119
|
+
// command. Other modes fall back to a plain run, flagged with a note.
|
|
120
|
+
if (requiresSandbox(ctx.sandboxMode)) {
|
|
121
|
+
resolve(`[command refused: sandboxExec is "strict" but sandboxing is unavailable here ` +
|
|
122
|
+
`(${sandboxUnavailableReason()}) — set sandboxExec to "auto"/"always" to allow ` +
|
|
123
|
+
`an unsandboxed fallback, or install the required sandbox binary]`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
exec(command, runOpts, (error, stdout, stderr) => finish(resolve, error, stdout, stderr, `[sandbox unavailable (${sandboxUnavailableReason()}) — ran without sandbox]`));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// exec (not execFile) on purpose: this tool exists to run arbitrary
|
|
130
|
+
// shell commands, and every invocation is gated by a user permission prompt.
|
|
131
|
+
exec(command, runOpts, (error, stdout, stderr) => finish(resolve, error, stdout, stderr));
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { defaultExtraSkillRoots, parseSkillFrontmatter, scanSkills, skillsDir, } from "../agent/skills.js";
|
|
4
|
+
import { truncateResult } from "./common.js";
|
|
5
|
+
const BUNDLE_DIRS = ["scripts", "references", "assets"];
|
|
6
|
+
function listBundledFiles(skillDir) {
|
|
7
|
+
const sections = [];
|
|
8
|
+
for (const sub of BUNDLE_DIRS) {
|
|
9
|
+
let entries;
|
|
10
|
+
try {
|
|
11
|
+
entries = fs.readdirSync(path.join(skillDir, sub), { withFileTypes: true });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (!entries.length)
|
|
17
|
+
continue;
|
|
18
|
+
const names = entries.map((e) => ` ${e.name}${e.isDirectory() ? "/" : ""}`).join("\n");
|
|
19
|
+
sections.push(`${sub}/\n${names}`);
|
|
20
|
+
}
|
|
21
|
+
return sections.join("\n\n");
|
|
22
|
+
}
|
|
23
|
+
export const loadSkillTool = {
|
|
24
|
+
name: "load_skill",
|
|
25
|
+
description: "Load the full instructions for an available skill by name. Call this when a task matches a skill listed in the system prompt.",
|
|
26
|
+
parameters: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
name: { type: "string", description: "The skill's name, as listed in the system prompt" },
|
|
30
|
+
},
|
|
31
|
+
required: ["name"],
|
|
32
|
+
},
|
|
33
|
+
requiresPermission: false,
|
|
34
|
+
summarize: (args) => `Load skill "${args.name}"`,
|
|
35
|
+
async execute(args, ctx) {
|
|
36
|
+
const name = String(args.name ?? "");
|
|
37
|
+
// Re-scan on every call (no session-start caching) so a skill added
|
|
38
|
+
// mid-session is usable immediately -- cheap since these are a handful
|
|
39
|
+
// of small file reads.
|
|
40
|
+
// Workspace-controlled skill roots (.kritya/skills, workspace plugin
|
|
41
|
+
// skills) are the same trust-gated surface as KRITYA.md -- a full skill
|
|
42
|
+
// body is arbitrary instructions the model then follows, so an untrusted
|
|
43
|
+
// workspace's own skills/plugins are excluded from the scan (user-global
|
|
44
|
+
// ones under ~/.kritya still apply). `trustWorkspace` undefined (tool
|
|
45
|
+
// contexts that don't go through the trust flow) is treated as trusted,
|
|
46
|
+
// matching the rest of the codebase's convention.
|
|
47
|
+
const trustWorkspace = ctx.trustWorkspace !== false;
|
|
48
|
+
const skills = scanSkills([
|
|
49
|
+
...(trustWorkspace ? [skillsDir(ctx.workspace)] : []),
|
|
50
|
+
...defaultExtraSkillRoots(ctx.workspace, trustWorkspace),
|
|
51
|
+
]);
|
|
52
|
+
const skill = skills.find((s) => s.name === name);
|
|
53
|
+
if (!skill) {
|
|
54
|
+
const available = skills.map((s) => s.name).join(", ") || "(none)";
|
|
55
|
+
// Throwing (rather than returning an "Error: ..." string) matches this
|
|
56
|
+
// codebase's convention (see resolveSafe): the agent loop's catch path
|
|
57
|
+
// marks the call failed and formats the message for the model.
|
|
58
|
+
throw new Error(`skill "${name}" not found. Available: ${available}`);
|
|
59
|
+
}
|
|
60
|
+
// skill.dir came from scanSkills' own directory listing, never from the
|
|
61
|
+
// model-supplied `name` -- a hallucinated/malicious name can only fail
|
|
62
|
+
// the lookup above, never build a path.
|
|
63
|
+
const raw = fs.readFileSync(path.join(skill.dir, "SKILL.md"), "utf8");
|
|
64
|
+
const parsed = parseSkillFrontmatter(raw);
|
|
65
|
+
const body = parsed?.body ?? "";
|
|
66
|
+
const bundled = listBundledFiles(skill.dir);
|
|
67
|
+
return truncateResult(bundled ? `${body}\n\n# Bundled files\n${bundled}` : body);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const MAX_AGENTS = 6;
|
|
2
|
+
/**
|
|
3
|
+
* Dispatches one or more focused, read-only subtasks to fresh agents that each
|
|
4
|
+
* get their own context window and return only a summary. Useful for wide
|
|
5
|
+
* searches and codebase questions that would otherwise flood the main
|
|
6
|
+
* conversation with tool output. Subagents cannot write, edit, or run shell
|
|
7
|
+
* commands. Multiple tasks run concurrently.
|
|
8
|
+
*/
|
|
9
|
+
export const spawnAgentTool = {
|
|
10
|
+
name: "spawn_agent",
|
|
11
|
+
description: "Run one or more read-only subagents on focused investigations (e.g. 'find everywhere X is " +
|
|
12
|
+
"used and summarize how it works'), each in its own fresh context. They can read, list, glob, " +
|
|
13
|
+
"and grep, but cannot modify files or run commands. Pass multiple tasks to investigate several " +
|
|
14
|
+
"independent things at once — they run concurrently. Returns each subagent's findings. Prefer " +
|
|
15
|
+
"this for broad searches so the main context stays lean.",
|
|
16
|
+
parameters: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
tasks: {
|
|
20
|
+
type: "array",
|
|
21
|
+
items: { type: "string" },
|
|
22
|
+
minItems: 1,
|
|
23
|
+
maxItems: MAX_AGENTS,
|
|
24
|
+
description: "Self-contained instructions for each subagent, one per investigation. Each must include " +
|
|
25
|
+
"all context it needs; subagents do not see the current conversation.",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ["tasks"],
|
|
29
|
+
},
|
|
30
|
+
requiresPermission: false,
|
|
31
|
+
// Self-managed: each subagent has its own wall-clock cap and the batch runs
|
|
32
|
+
// several in sequence, so the total legitimately exceeds any per-tool limit.
|
|
33
|
+
timeoutMs: 0,
|
|
34
|
+
async execute(args, ctx, signal) {
|
|
35
|
+
const tasks = Array.isArray(args.tasks)
|
|
36
|
+
? args.tasks.map((t) => String(t).trim()).filter(Boolean)
|
|
37
|
+
: [];
|
|
38
|
+
if (!tasks.length)
|
|
39
|
+
return "Error: tasks is required and must be a non-empty array of strings.";
|
|
40
|
+
if (tasks.length > MAX_AGENTS)
|
|
41
|
+
return `Error: at most ${MAX_AGENTS} tasks per call.`;
|
|
42
|
+
if (!ctx.spawnAgents)
|
|
43
|
+
return "Error: subagents are not available in this session.";
|
|
44
|
+
const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: false })), signal);
|
|
45
|
+
if (results.length === 1)
|
|
46
|
+
return results[0].summary;
|
|
47
|
+
return results
|
|
48
|
+
.map((r, i) => `--- Subagent ${i + 1}: ${r.task.slice(0, 60)} ---\n${r.summary}`)
|
|
49
|
+
.join("\n\n");
|
|
50
|
+
},
|
|
51
|
+
summarize: (args) => {
|
|
52
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : [];
|
|
53
|
+
if (tasks.length <= 1)
|
|
54
|
+
return `Subagent: ${String(tasks[0] ?? "").slice(0, 60)}`;
|
|
55
|
+
return `${tasks.length} subagents in parallel`;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const VALID_STATUS = new Set(["pending", "in_progress", "done"]);
|
|
2
|
+
export const updateTasksTool = {
|
|
3
|
+
name: "update_tasks",
|
|
4
|
+
description: "Maintain a visible checklist for multi-step work. Call with the FULL task list " +
|
|
5
|
+
"(it replaces the previous list) whenever you plan steps or a step's status changes. " +
|
|
6
|
+
"Use it at the start of any request that needs more than 2 distinct steps.",
|
|
7
|
+
parameters: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
tasks: {
|
|
11
|
+
type: "array",
|
|
12
|
+
description: "The complete, ordered task list",
|
|
13
|
+
items: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
text: { type: "string", description: "Short description of the step" },
|
|
17
|
+
status: { type: "string", enum: ["pending", "in_progress", "done"] },
|
|
18
|
+
},
|
|
19
|
+
required: ["text", "status"],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["tasks"],
|
|
24
|
+
},
|
|
25
|
+
requiresPermission: false,
|
|
26
|
+
summarize: (args) => {
|
|
27
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : [];
|
|
28
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
29
|
+
return `Update tasks (${done}/${tasks.length} done)`;
|
|
30
|
+
},
|
|
31
|
+
async execute(args, ctx) {
|
|
32
|
+
if (!Array.isArray(args.tasks))
|
|
33
|
+
throw new Error("tasks must be an array");
|
|
34
|
+
const tasks = args.tasks.map((t) => ({
|
|
35
|
+
text: String(t.text ?? ""),
|
|
36
|
+
status: VALID_STATUS.has(String(t.status))
|
|
37
|
+
? String(t.status)
|
|
38
|
+
: "pending",
|
|
39
|
+
}));
|
|
40
|
+
ctx.onTasksUpdate?.(tasks);
|
|
41
|
+
return `Task list updated (${tasks.length} tasks).`;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { truncateResult } from "./common.js";
|
|
2
|
+
const SEARCH_TIMEOUT_MS = 30_000;
|
|
3
|
+
/**
|
|
4
|
+
* Raw Tavily search call, returning the parsed structured response. Shared by
|
|
5
|
+
* the web_search tool (which formats it for display) and deep_research (which
|
|
6
|
+
* needs the result URLs to fetch full pages). Throws on any error so callers
|
|
7
|
+
* can decide how to surface it.
|
|
8
|
+
*/
|
|
9
|
+
export async function tavilyRaw(query, maxResults = 5, opts = {}) {
|
|
10
|
+
const apiKey = process.env.TAVILY_API_KEY;
|
|
11
|
+
if (!apiKey) {
|
|
12
|
+
throw new Error("TAVILY_API_KEY is not set. Add it to your .env file to enable web search.");
|
|
13
|
+
}
|
|
14
|
+
const body = {
|
|
15
|
+
query,
|
|
16
|
+
max_results: Math.min(Math.max(maxResults, 1), 10),
|
|
17
|
+
include_answer: true,
|
|
18
|
+
};
|
|
19
|
+
if (opts.topic)
|
|
20
|
+
body.topic = opts.topic;
|
|
21
|
+
// `days` is a news-topic filter; setting it implies topic "news" so the
|
|
22
|
+
// recency window is actually applied (and publish dates come back).
|
|
23
|
+
if (opts.days !== undefined && Number.isFinite(opts.days)) {
|
|
24
|
+
body.topic = "news";
|
|
25
|
+
body.days = Math.max(1, Math.floor(opts.days));
|
|
26
|
+
}
|
|
27
|
+
// Bounded explicitly: a fetch with no signal waits on the socket forever, and
|
|
28
|
+
// this is also called outside the agent loop (the /web-search command), where
|
|
29
|
+
// the loop's per-tool deadline isn't there to catch it.
|
|
30
|
+
const res = await fetch("https://api.tavily.com/search", {
|
|
31
|
+
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
Authorization: `Bearer ${apiKey}`,
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify(body),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const errBody = await res.text().catch(() => "");
|
|
41
|
+
throw new Error(`Tavily search failed (HTTP ${res.status}) ${errBody.slice(0, 300)}`);
|
|
42
|
+
}
|
|
43
|
+
return (await res.json());
|
|
44
|
+
}
|
|
45
|
+
export async function tavilySearch(query, maxResults = 5, opts = {}) {
|
|
46
|
+
let data;
|
|
47
|
+
try {
|
|
48
|
+
data = await tavilyRaw(query, maxResults, opts);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
52
|
+
}
|
|
53
|
+
const parts = [];
|
|
54
|
+
if (data.answer)
|
|
55
|
+
parts.push(`Answer: ${data.answer}`);
|
|
56
|
+
for (const [i, r] of (data.results ?? []).entries()) {
|
|
57
|
+
const date = r.published_date ? ` (published ${r.published_date})` : "";
|
|
58
|
+
parts.push(`${i + 1}. ${r.title ?? "(untitled)"}${date}\n ${r.url ?? ""}\n ${(r.content ?? "").slice(0, 400)}`);
|
|
59
|
+
}
|
|
60
|
+
return truncateResult(parts.join("\n\n") || "(no results)");
|
|
61
|
+
}
|
|
62
|
+
/** Distinguishes "omitted" (use default) from an explicit value like 0. */
|
|
63
|
+
export function parseMaxResults(raw, defaultValue = 5) {
|
|
64
|
+
return raw === undefined || raw === null ? defaultValue : Number(raw);
|
|
65
|
+
}
|
|
66
|
+
export const webSearchTool = {
|
|
67
|
+
name: "web_search",
|
|
68
|
+
description: "Search the web (via Tavily) for current information: documentation, error messages, " +
|
|
69
|
+
"library versions, news. Returns an answer summary plus result snippets with URLs. " +
|
|
70
|
+
"For time-sensitive queries ('latest', 'this week', recent news), set recency_days to the " +
|
|
71
|
+
"window so results are restricted to that period and labeled with publish dates.",
|
|
72
|
+
parameters: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
query: { type: "string", description: "The search query" },
|
|
76
|
+
max_results: { type: "number", description: "Number of results, 1-10 (default 5)" },
|
|
77
|
+
recency_days: {
|
|
78
|
+
type: "number",
|
|
79
|
+
description: "Restrict results to the last N days and return publish dates (news sources). " +
|
|
80
|
+
"Set this for recent/time-sensitive queries; omit for timeless ones.",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
required: ["query"],
|
|
84
|
+
},
|
|
85
|
+
// Requires a prompt (rather than running silently) so the query text is
|
|
86
|
+
// visible before it's sent out — under prompt injection, a search query is
|
|
87
|
+
// an easy channel to smuggle file contents to an external service.
|
|
88
|
+
requiresPermission: true,
|
|
89
|
+
external: true,
|
|
90
|
+
summarize: (args) => `Web search: ${args.query}`,
|
|
91
|
+
async execute(args) {
|
|
92
|
+
const days = args.recency_days === undefined || args.recency_days === null
|
|
93
|
+
? undefined
|
|
94
|
+
: Number(args.recency_days);
|
|
95
|
+
return tavilySearch(String(args.query), parseMaxResults(args.max_results), { days });
|
|
96
|
+
},
|
|
97
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeFileAtomic } from "../atomicWrite.js";
|
|
4
|
+
import { resolveSafe } from "./common.js";
|
|
5
|
+
import { diffLines } from "./diff.js";
|
|
6
|
+
import { formatSecretWarning, scanForSecrets } from "./secretScan.js";
|
|
7
|
+
export const writeFileTool = {
|
|
8
|
+
name: "write_file",
|
|
9
|
+
description: "Create or overwrite a file in the workspace with the given content. " +
|
|
10
|
+
"Parent directories are created automatically.",
|
|
11
|
+
parameters: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
path: { type: "string", description: "File path relative to the workspace root" },
|
|
15
|
+
content: { type: "string", description: "Full content to write" },
|
|
16
|
+
},
|
|
17
|
+
required: ["path", "content"],
|
|
18
|
+
},
|
|
19
|
+
requiresPermission: true,
|
|
20
|
+
// The summary line already names the file and its size; "Wrote <path>" under
|
|
21
|
+
// it is the same sentence twice.
|
|
22
|
+
resultSummary: () => "",
|
|
23
|
+
summarize: (args) => `Write ${args.path} (${Buffer.byteLength(String(args.content ?? ""), "utf8")} bytes)`,
|
|
24
|
+
async preview(args, ctx) {
|
|
25
|
+
const abs = resolveSafe(ctx.workspace, String(args.path));
|
|
26
|
+
let existing;
|
|
27
|
+
try {
|
|
28
|
+
existing = await fs.readFile(abs, "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
existing = "";
|
|
32
|
+
}
|
|
33
|
+
return diffLines(existing, String(args.content ?? ""));
|
|
34
|
+
},
|
|
35
|
+
async execute(args, ctx) {
|
|
36
|
+
const abs = resolveSafe(ctx.workspace, String(args.path));
|
|
37
|
+
const content = String(args.content);
|
|
38
|
+
const secrets = scanForSecrets(content);
|
|
39
|
+
if (secrets.length > 0) {
|
|
40
|
+
throw new Error(formatSecretWarning(secrets, String(args.path)));
|
|
41
|
+
}
|
|
42
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
43
|
+
ctx.undo?.snapshot(abs, String(args.path));
|
|
44
|
+
await writeFileAtomic(abs, content);
|
|
45
|
+
return `Wrote ${args.path}`;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const MAX_AGENTS = 4;
|
|
2
|
+
/**
|
|
3
|
+
* Dispatches one or more subagents that can write files, edit files, and run
|
|
4
|
+
* shell commands — each isolated on its own git branch/worktree so nothing
|
|
5
|
+
* touches the user's real working tree until they review and merge it.
|
|
6
|
+
* Always requires permission: this is the one tool that lets the model make
|
|
7
|
+
* unattended file/shell changes, so the user approves the task list up front.
|
|
8
|
+
*/
|
|
9
|
+
export const spawnWriteAgentTool = {
|
|
10
|
+
name: "spawn_write_agent",
|
|
11
|
+
description: "Run one or more subagents that can write/edit files and run shell commands, for independent " +
|
|
12
|
+
"chunks of work that can proceed in parallel (e.g. 'implement the API client' and 'write its " +
|
|
13
|
+
"tests' at the same time). Each subagent works in an isolated git worktree on its own branch — " +
|
|
14
|
+
"it never touches your real working tree. Requires the workspace to be a git repository. " +
|
|
15
|
+
"Returns a summary of each subagent's changes and the branch name to review/merge " +
|
|
16
|
+
"(git diff <base>...<branch>, git merge <branch>); branches with no changes are cleaned up " +
|
|
17
|
+
"automatically. Destructive shell commands (rm -rf, force push, etc.) are still blocked inside " +
|
|
18
|
+
"these subagents since there's no one to confirm them.",
|
|
19
|
+
parameters: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
tasks: {
|
|
23
|
+
type: "array",
|
|
24
|
+
items: { type: "string" },
|
|
25
|
+
minItems: 1,
|
|
26
|
+
maxItems: MAX_AGENTS,
|
|
27
|
+
description: "Self-contained instructions for each subagent, one per independent piece of work. Each " +
|
|
28
|
+
"must include all context it needs — subagents do not see the current conversation.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
required: ["tasks"],
|
|
32
|
+
},
|
|
33
|
+
requiresPermission: true,
|
|
34
|
+
// Self-managed, same as spawn_agent: per-subagent timeout plus batching.
|
|
35
|
+
timeoutMs: 0,
|
|
36
|
+
summarize: (args) => {
|
|
37
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : [];
|
|
38
|
+
if (tasks.length <= 1)
|
|
39
|
+
return `Write subagent: ${String(tasks[0] ?? "").slice(0, 60)}`;
|
|
40
|
+
return `${tasks.length} write subagents in parallel`;
|
|
41
|
+
},
|
|
42
|
+
async preview(args) {
|
|
43
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : [];
|
|
44
|
+
return (`About to run ${tasks.length} write-capable subagent(s), each isolated on its own git branch:\n` +
|
|
45
|
+
tasks.map((t, i) => ` ${i + 1}. ${String(t).slice(0, 100)}`).join("\n") +
|
|
46
|
+
`\n\nNone of this touches your working tree directly — review each branch's diff before merging.`);
|
|
47
|
+
},
|
|
48
|
+
async execute(args, ctx, signal) {
|
|
49
|
+
const tasks = Array.isArray(args.tasks)
|
|
50
|
+
? args.tasks.map((t) => String(t).trim()).filter(Boolean)
|
|
51
|
+
: [];
|
|
52
|
+
if (!tasks.length)
|
|
53
|
+
return "Error: tasks is required and must be a non-empty array of strings.";
|
|
54
|
+
if (tasks.length > MAX_AGENTS)
|
|
55
|
+
return `Error: at most ${MAX_AGENTS} tasks per call.`;
|
|
56
|
+
if (!ctx.spawnAgents)
|
|
57
|
+
return "Error: subagents are not available in this session.";
|
|
58
|
+
const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: true })), signal);
|
|
59
|
+
return results
|
|
60
|
+
.map((r, i) => {
|
|
61
|
+
const header = `--- Write subagent ${i + 1}: ${r.task.slice(0, 60)} ---`;
|
|
62
|
+
if (r.error)
|
|
63
|
+
return `${header}\n${r.summary}\n[error: ${r.error}]`;
|
|
64
|
+
const branchNote = r.branch
|
|
65
|
+
? `\n\nChanges committed to branch "${r.branch}". Review with ` +
|
|
66
|
+
`\`git diff main...${r.branch}\` (or your base branch), merge with \`git merge ${r.branch}\`.`
|
|
67
|
+
: "\n\n(no file changes were made)";
|
|
68
|
+
return `${header}\n${r.summary}${branchNote}`;
|
|
69
|
+
})
|
|
70
|
+
.join("\n\n");
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "../config/config.js";
|
|
4
|
+
import { hardenWindowsDir } from "../config/winAcl.js";
|
|
5
|
+
import { debugLog } from "../config/debug.js";
|
|
6
|
+
/**
|
|
7
|
+
* Tracks which workspaces have already seen the one-time "kritya is an AI
|
|
8
|
+
* agent" notice (see AiDisclosurePrompt.tsx), keyed by resolved workspace
|
|
9
|
+
* path — same shape and persistence pattern as trust.ts's trusted.json, so a
|
|
10
|
+
* dismissal in one workspace doesn't suppress the notice in every other one.
|
|
11
|
+
*
|
|
12
|
+
* Values are the ISO timestamp the notice was acknowledged, not a plain
|
|
13
|
+
* boolean: if this file is ever pointed to as evidence the user was told
|
|
14
|
+
* ("Art. 50(1) disclosure happened"), a bare true/false has no record of
|
|
15
|
+
* *when* — the timestamp is what makes it worth citing.
|
|
16
|
+
*/
|
|
17
|
+
const DISCLOSURE_FILE = path.join(CONFIG_DIR, "ai-disclosure.json");
|
|
18
|
+
function loadStore(storeFile) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(fs.readFileSync(storeFile, "utf8"));
|
|
21
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
// A missing file means "not shown anywhere yet" (normal); a malformed one
|
|
25
|
+
// means every workspace re-shows the notice, worth being able to see.
|
|
26
|
+
debugLog(`loadStore(${storeFile})`, err);
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Whether the AI disclosure notice has already been shown and dismissed for this workspace. */
|
|
31
|
+
export function isAiDisclosureShown(workspace, storeFile = DISCLOSURE_FILE) {
|
|
32
|
+
return typeof loadStore(storeFile)[path.resolve(workspace)] === "string";
|
|
33
|
+
}
|
|
34
|
+
/** When the AI disclosure notice was shown and dismissed for this workspace, if it was. */
|
|
35
|
+
export function aiDisclosureShownAt(workspace, storeFile = DISCLOSURE_FILE) {
|
|
36
|
+
return loadStore(storeFile)[path.resolve(workspace)];
|
|
37
|
+
}
|
|
38
|
+
/** Record that the AI disclosure notice has been shown and dismissed for this workspace, now. */
|
|
39
|
+
export function markAiDisclosureShown(workspace, storeFile = DISCLOSURE_FILE) {
|
|
40
|
+
const store = loadStore(storeFile);
|
|
41
|
+
store[path.resolve(workspace)] = new Date().toISOString();
|
|
42
|
+
fs.mkdirSync(path.dirname(storeFile), { recursive: true, mode: 0o700 });
|
|
43
|
+
hardenWindowsDir(path.dirname(storeFile));
|
|
44
|
+
fs.writeFileSync(storeFile, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
45
|
+
}
|