privateer-agent 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.
- package/LICENSE +21 -0
- package/README.md +474 -0
- package/bin/privateer.mjs +11 -0
- package/package.json +74 -0
- package/src/agents/loader.ts +49 -0
- package/src/auth/privateer.ts +393 -0
- package/src/commands/custom.ts +75 -0
- package/src/commands/registry.ts +499 -0
- package/src/components/AgentGroupView.tsx +104 -0
- package/src/components/App.tsx +1376 -0
- package/src/components/ApprovalPrompt.tsx +38 -0
- package/src/components/Banner.tsx +58 -0
- package/src/components/Markdown.tsx +183 -0
- package/src/components/ModeHint.tsx +40 -0
- package/src/components/ModelPicker.tsx +269 -0
- package/src/components/Onboarding.tsx +203 -0
- package/src/components/PlanConfirm.tsx +37 -0
- package/src/components/PrivateerLogin.tsx +109 -0
- package/src/components/PromptInput.tsx +602 -0
- package/src/components/RewindPicker.tsx +69 -0
- package/src/components/Root.tsx +95 -0
- package/src/components/SessionPicker.tsx +64 -0
- package/src/components/StatusBar.tsx +121 -0
- package/src/components/TodoPanel.tsx +36 -0
- package/src/components/ToolCallView.tsx +109 -0
- package/src/components/Transcript.tsx +203 -0
- package/src/components/figures.ts +13 -0
- package/src/components/promptModel.ts +73 -0
- package/src/components/spinnerVerbs.ts +46 -0
- package/src/components/theme.ts +55 -0
- package/src/components/types.ts +34 -0
- package/src/components/useTeeShield.ts +104 -0
- package/src/components/useTerminalWidth.ts +24 -0
- package/src/components/useZdrShield.ts +126 -0
- package/src/config/load.ts +115 -0
- package/src/config/paths.ts +61 -0
- package/src/config/schema.ts +94 -0
- package/src/context/outputStyles.ts +42 -0
- package/src/context/projectInfo.ts +59 -0
- package/src/context/systemPrompt.ts +167 -0
- package/src/engine/QueryEngine.ts +399 -0
- package/src/engine/errors.ts +197 -0
- package/src/engine/events.ts +74 -0
- package/src/engine/router.ts +165 -0
- package/src/hooks/engine.ts +155 -0
- package/src/main.tsx +167 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/oauth.ts +245 -0
- package/src/memory/auto.ts +146 -0
- package/src/memory/checkpoints.ts +227 -0
- package/src/memory/store.ts +127 -0
- package/src/permissions/danger.ts +56 -0
- package/src/permissions/gate.ts +38 -0
- package/src/permissions/mode.ts +39 -0
- package/src/permissions/protected.ts +29 -0
- package/src/permissions/uiGate.ts +73 -0
- package/src/providers/attestation.ts +149 -0
- package/src/providers/capabilities.ts +104 -0
- package/src/providers/catalog.ts +66 -0
- package/src/providers/models.ts +183 -0
- package/src/providers/registry.ts +71 -0
- package/src/providers/resolve.ts +78 -0
- package/src/remote/relayClient.ts +283 -0
- package/src/session.ts +264 -0
- package/src/tools/bash.ts +98 -0
- package/src/tools/context.ts +114 -0
- package/src/tools/edit.ts +67 -0
- package/src/tools/exec.ts +60 -0
- package/src/tools/glob.ts +39 -0
- package/src/tools/grep.ts +86 -0
- package/src/tools/index.ts +69 -0
- package/src/tools/memory.ts +53 -0
- package/src/tools/processRegistry.ts +77 -0
- package/src/tools/read.ts +42 -0
- package/src/tools/saveAttachment.ts +53 -0
- package/src/tools/task.ts +52 -0
- package/src/tools/todo.ts +36 -0
- package/src/tools/todoStore.ts +31 -0
- package/src/tools/walk.ts +44 -0
- package/src/tools/web.ts +145 -0
- package/src/tools/write.ts +40 -0
- package/src/util/attachmentStore.ts +72 -0
- package/src/util/images.ts +343 -0
- package/src/util/limit.ts +32 -0
- package/src/util/redact.ts +44 -0
- package/src/version.ts +13 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { resolve, isAbsolute, relative, sep } from "node:path";
|
|
2
|
+
import type { PermissionGate, PermissionKind } from "../permissions/gate.ts";
|
|
3
|
+
import type { TodoStore } from "./todoStore.ts";
|
|
4
|
+
import type { AgentDefinition } from "../agents/loader.ts";
|
|
5
|
+
import type { ProcessRegistry } from "./processRegistry.ts";
|
|
6
|
+
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
7
|
+
|
|
8
|
+
// A finished sub-agent's result: its final text answer plus run metrics (how many
|
|
9
|
+
// tools it called and how many tokens it spent), so the UI can show a per-agent
|
|
10
|
+
// summary in the grouped "N agents finished" view.
|
|
11
|
+
export interface SubAgentResult {
|
|
12
|
+
text: string;
|
|
13
|
+
toolUses: number;
|
|
14
|
+
tokens: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Runs a child agent and resolves to its final text answer + metrics. With no `agent`
|
|
18
|
+
// it runs the default read-only sub-agent; with one it uses that agent's
|
|
19
|
+
// tools/model/instructions. Supplied by the session (which has the model + config);
|
|
20
|
+
// absent in bare tool contexts.
|
|
21
|
+
export type SubAgentRunner = (input: {
|
|
22
|
+
description: string;
|
|
23
|
+
prompt: string;
|
|
24
|
+
agent?: AgentDefinition;
|
|
25
|
+
}) => Promise<SubAgentResult>;
|
|
26
|
+
|
|
27
|
+
// Shared state handed to every tool's execute().
|
|
28
|
+
export interface ToolContext {
|
|
29
|
+
cwd: string;
|
|
30
|
+
gate: PermissionGate;
|
|
31
|
+
// When true (the default), the tools confine file access to `cwd`: a path that
|
|
32
|
+
// resolves outside it (an absolute path elsewhere, or a `../` escape) is only
|
|
33
|
+
// touched after the user explicitly approves it via the gate. Set false to let the
|
|
34
|
+
// agent roam (e.g. the user launched with --no-confine / set confineToCwd:false).
|
|
35
|
+
confineToCwd?: boolean;
|
|
36
|
+
// Out-of-cwd directories the user has approved this session ("always" on an outside
|
|
37
|
+
// prompt). A shared array, also held by the gate, so an approved sibling directory
|
|
38
|
+
// isn't re-prompted on every file inside it. Paths under any of these count as
|
|
39
|
+
// in-scope.
|
|
40
|
+
allowedOutsideRoots?: string[];
|
|
41
|
+
todos?: TodoStore; // session todo list, for the `todo` tool + TUI panel
|
|
42
|
+
runSubAgent?: SubAgentRunner; // spawns a `task` sub-agent
|
|
43
|
+
// Reports a finished `task` sub-agent's run metrics, keyed by the originating
|
|
44
|
+
// tool-call id, so the TUI can annotate the grouped agents view with each agent's
|
|
45
|
+
// tool-use and token counts. Best-effort; absent outside the interactive session.
|
|
46
|
+
onSubAgentMetrics?: (toolCallId: string, m: { toolUses: number; tokens: number }) => void;
|
|
47
|
+
// Called by write/edit just before they mutate a file, so the checkpoint store
|
|
48
|
+
// can capture its pre-modification state for /rewind.
|
|
49
|
+
recordMutation?: (abs: string) => void;
|
|
50
|
+
// Background-shell registry, for bash run_in_background + bash_output/kill_shell.
|
|
51
|
+
processes?: ProcessRegistry;
|
|
52
|
+
// Session attachment store (decoded bytes of pasted/dropped files, by "#n"), for the
|
|
53
|
+
// save_attachment tool to write one back to disk.
|
|
54
|
+
attachments?: AttachmentStore;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolve a possibly-relative path against the session cwd. The cwd is a *soft*
|
|
58
|
+
// anchor: relative paths are interpreted from it, but absolute paths and `../`
|
|
59
|
+
// escapes are allowed through — the model is nudged (via the system prompt) to
|
|
60
|
+
// stay inside cwd rather than being walled in here.
|
|
61
|
+
export function resolveInCwd(ctx: ToolContext, p: string): string {
|
|
62
|
+
return isAbsolute(p) ? p : resolve(ctx.cwd, p);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Display a path relative to cwd when possible (nicer for tool output / UI).
|
|
66
|
+
export function displayPath(ctx: ToolContext, abs: string): string {
|
|
67
|
+
const rel = relative(ctx.cwd, abs);
|
|
68
|
+
return rel === "" ? "." : rel;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Is `abs` the directory `root` itself, or contained within it? Uses the resolved
|
|
72
|
+
// relative path so `..` escapes are caught regardless of how the path was written.
|
|
73
|
+
export function isInsideDir(root: string, abs: string): boolean {
|
|
74
|
+
if (abs === root) return true;
|
|
75
|
+
const rel = relative(root, abs);
|
|
76
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Does this path fall outside the agent's working-directory scope? True only when
|
|
80
|
+
// confinement is on and the path is neither inside cwd nor inside a directory the
|
|
81
|
+
// user already approved this session.
|
|
82
|
+
export function isOutsideScope(ctx: ToolContext, abs: string): boolean {
|
|
83
|
+
if (ctx.confineToCwd === false) return false;
|
|
84
|
+
if (isInsideDir(ctx.cwd, abs)) return false;
|
|
85
|
+
return !(ctx.allowedOutsideRoots ?? []).some((root) => isInsideDir(root, abs));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Gate access to a path that may sit outside cwd. Returns null when the path is in
|
|
89
|
+
// scope or the user approves the out-of-scope access; returns an error string (for the
|
|
90
|
+
// tool to hand back to the model) when confinement blocks it. In-scope paths never
|
|
91
|
+
// prompt, so ordinary work inside cwd is untouched.
|
|
92
|
+
export async function guardScope(
|
|
93
|
+
ctx: ToolContext,
|
|
94
|
+
abs: string,
|
|
95
|
+
opts: { kind: PermissionKind; title: string },
|
|
96
|
+
): Promise<string | null> {
|
|
97
|
+
if (!isOutsideScope(ctx, abs)) return null;
|
|
98
|
+
const decision = await ctx.gate.request({
|
|
99
|
+
tool: opts.kind,
|
|
100
|
+
kind: opts.kind,
|
|
101
|
+
title: opts.title,
|
|
102
|
+
detail: abs,
|
|
103
|
+
path: abs,
|
|
104
|
+
outside: true,
|
|
105
|
+
});
|
|
106
|
+
if (decision === "deny") {
|
|
107
|
+
return (
|
|
108
|
+
`Error: ${abs} is outside the working directory (${ctx.cwd}). ` +
|
|
109
|
+
`By default I stay within the working directory; access here was declined. ` +
|
|
110
|
+
`Ask me explicitly to work in this location to allow it.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { tool } from "ai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { ToolContext } from "./context.ts";
|
|
5
|
+
import { resolveInCwd, displayPath, isOutsideScope } from "./context.ts";
|
|
6
|
+
import { PermissionDeniedError } from "../permissions/gate.ts";
|
|
7
|
+
import { isProtectedPath } from "../permissions/protected.ts";
|
|
8
|
+
|
|
9
|
+
// Count occurrences of a substring (non-overlapping).
|
|
10
|
+
function countOccurrences(haystack: string, needle: string): number {
|
|
11
|
+
if (needle === "") return 0;
|
|
12
|
+
let count = 0;
|
|
13
|
+
let i = haystack.indexOf(needle);
|
|
14
|
+
while (i !== -1) {
|
|
15
|
+
count++;
|
|
16
|
+
i = haystack.indexOf(needle, i + needle.length);
|
|
17
|
+
}
|
|
18
|
+
return count;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function editTool(ctx: ToolContext) {
|
|
22
|
+
return tool({
|
|
23
|
+
description:
|
|
24
|
+
"Replace an exact string in a file. old_string must match uniquely unless replace_all is set. " +
|
|
25
|
+
"Include enough surrounding context to make the match unique.",
|
|
26
|
+
inputSchema: z.object({
|
|
27
|
+
path: z.string().describe("File to edit."),
|
|
28
|
+
old_string: z.string().describe("Exact text to replace."),
|
|
29
|
+
new_string: z.string().describe("Replacement text."),
|
|
30
|
+
replace_all: z.boolean().optional().describe("Replace every occurrence."),
|
|
31
|
+
}),
|
|
32
|
+
execute: async ({ path, old_string, new_string, replace_all }) => {
|
|
33
|
+
const abs = resolveInCwd(ctx, path);
|
|
34
|
+
if (!existsSync(abs)) return `Error: file not found: ${displayPath(ctx, abs)}`;
|
|
35
|
+
if (old_string === new_string) return `Error: old_string and new_string are identical.`;
|
|
36
|
+
|
|
37
|
+
const original = readFileSync(abs, "utf8");
|
|
38
|
+
const occ = countOccurrences(original, old_string);
|
|
39
|
+
if (occ === 0) return `Error: old_string not found in ${displayPath(ctx, abs)}.`;
|
|
40
|
+
if (occ > 1 && !replace_all) {
|
|
41
|
+
return `Error: old_string matches ${occ} places in ${displayPath(ctx, abs)}. Add context or set replace_all.`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const updated = replace_all
|
|
45
|
+
? original.split(old_string).join(new_string)
|
|
46
|
+
: original.replace(old_string, new_string);
|
|
47
|
+
|
|
48
|
+
const removed = old_string.split("\n").length;
|
|
49
|
+
const added = new_string.split("\n").length;
|
|
50
|
+
const outside = isOutsideScope(ctx, abs);
|
|
51
|
+
const decision = await ctx.gate.request({
|
|
52
|
+
tool: "edit",
|
|
53
|
+
kind: "edit",
|
|
54
|
+
title: outside ? "Edit outside working directory" : "Edit file",
|
|
55
|
+
detail: `${outside ? abs : displayPath(ctx, abs)} (${replace_all ? occ + "×, " : ""}-${removed} +${added})`,
|
|
56
|
+
protected: isProtectedPath(abs),
|
|
57
|
+
outside,
|
|
58
|
+
path: abs,
|
|
59
|
+
});
|
|
60
|
+
if (decision === "deny") throw new PermissionDeniedError("edit");
|
|
61
|
+
|
|
62
|
+
ctx.recordMutation?.(abs);
|
|
63
|
+
writeFileSync(abs, updated, "utf8");
|
|
64
|
+
return `Edited ${displayPath(ctx, abs)} (${replace_all ? occ + " replacements" : "1 replacement"}).`;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface ExecResult {
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
code: number | null;
|
|
7
|
+
timedOut: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_OUTPUT = 30_000; // chars per stream, to keep tool results bounded
|
|
11
|
+
|
|
12
|
+
// Run a command and capture output with a timeout. When `shell` is true the
|
|
13
|
+
// command string is interpreted by the shell (used by the bash tool); otherwise
|
|
14
|
+
// args are passed directly (used for ripgrep).
|
|
15
|
+
export function exec(
|
|
16
|
+
cmd: string,
|
|
17
|
+
args: string[],
|
|
18
|
+
opts: { cwd: string; timeoutMs: number; shell?: boolean; input?: string },
|
|
19
|
+
): Promise<ExecResult> {
|
|
20
|
+
return new Promise((resolveP) => {
|
|
21
|
+
const child = spawn(opts.shell ? cmd : cmd, opts.shell ? [] : args, {
|
|
22
|
+
cwd: opts.cwd,
|
|
23
|
+
shell: opts.shell ?? false,
|
|
24
|
+
env: process.env,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Feed the optional payload on stdin (used by hooks), then close it.
|
|
28
|
+
if (opts.input !== undefined) {
|
|
29
|
+
child.stdin?.on("error", () => {});
|
|
30
|
+
child.stdin?.write(opts.input);
|
|
31
|
+
child.stdin?.end();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let stdout = "";
|
|
35
|
+
let stderr = "";
|
|
36
|
+
let timedOut = false;
|
|
37
|
+
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
timedOut = true;
|
|
40
|
+
child.kill("SIGKILL");
|
|
41
|
+
}, opts.timeoutMs);
|
|
42
|
+
|
|
43
|
+
child.stdout?.on("data", (d) => {
|
|
44
|
+
if (stdout.length < MAX_OUTPUT) stdout += d.toString();
|
|
45
|
+
});
|
|
46
|
+
child.stderr?.on("data", (d) => {
|
|
47
|
+
if (stderr.length < MAX_OUTPUT) stderr += d.toString();
|
|
48
|
+
});
|
|
49
|
+
child.on("error", (err) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
resolveP({ stdout, stderr: stderr + String(err), code: null, timedOut });
|
|
52
|
+
});
|
|
53
|
+
child.on("close", (code) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
const cap = (s: string) =>
|
|
56
|
+
s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + "\n… (output truncated)" : s;
|
|
57
|
+
resolveP({ stdout: cap(stdout), stderr: cap(stderr), code, timedOut });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import picomatch from "picomatch";
|
|
3
|
+
import { tool } from "ai";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { ToolContext } from "./context.ts";
|
|
6
|
+
import { resolveInCwd, guardScope } from "./context.ts";
|
|
7
|
+
import { walkFiles } from "./walk.ts";
|
|
8
|
+
|
|
9
|
+
// Pure-Node glob: walk the tree and match relative paths with picomatch. No
|
|
10
|
+
// external binary dependency, so it works anywhere Privateer is installed.
|
|
11
|
+
export function globTool(ctx: ToolContext) {
|
|
12
|
+
return tool({
|
|
13
|
+
description:
|
|
14
|
+
"Find files matching a glob pattern (e.g. '**/*.ts', 'src/**/test_*.py'). " +
|
|
15
|
+
"Skips node_modules/.git/build dirs. Returns matching file paths. Read-only.",
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
pattern: z.string().describe("Glob pattern to match file paths (relative to the search dir)."),
|
|
18
|
+
path: z.string().optional().describe("Directory to search in (default: working directory)."),
|
|
19
|
+
}),
|
|
20
|
+
execute: async ({ pattern, path }) => {
|
|
21
|
+
const root = path ? resolveInCwd(ctx, path) : ctx.cwd;
|
|
22
|
+
const blocked = await guardScope(ctx, root, { kind: "read", title: "Search outside working directory" });
|
|
23
|
+
if (blocked) return blocked;
|
|
24
|
+
try {
|
|
25
|
+
if (!statSync(root).isDirectory()) return `Error: ${path} is not a directory.`;
|
|
26
|
+
} catch {
|
|
27
|
+
return `Error: directory not found: ${path}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isMatch = picomatch(pattern, { dot: true });
|
|
31
|
+
const files = walkFiles(root).filter((f) => isMatch(f));
|
|
32
|
+
if (files.length === 0) return "No files matched.";
|
|
33
|
+
files.sort();
|
|
34
|
+
const shown = files.slice(0, 200);
|
|
35
|
+
const more = files.length > shown.length ? `\n… (${files.length - shown.length} more)` : "";
|
|
36
|
+
return shown.join("\n") + more;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import picomatch from "picomatch";
|
|
4
|
+
import { tool } from "ai";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { ToolContext } from "./context.ts";
|
|
7
|
+
import { resolveInCwd, guardScope } from "./context.ts";
|
|
8
|
+
import { walkFiles } from "./walk.ts";
|
|
9
|
+
|
|
10
|
+
const MAX_MATCHES = 200;
|
|
11
|
+
|
|
12
|
+
// Treat a file as binary (and skip it) if an early chunk contains a NUL byte.
|
|
13
|
+
function looksBinary(buf: string): boolean {
|
|
14
|
+
const head = buf.slice(0, 1024);
|
|
15
|
+
for (let i = 0; i < head.length; i++) {
|
|
16
|
+
if (head.charCodeAt(i) === 0) return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pure-Node content search. Walks files, optionally filtered by a glob, and reports
|
|
22
|
+
// regex matches as "file:line:text". No external binary dependency.
|
|
23
|
+
export function grepTool(ctx: ToolContext) {
|
|
24
|
+
return tool({
|
|
25
|
+
description:
|
|
26
|
+
"Search file contents with a regular expression. Returns matching lines as " +
|
|
27
|
+
"file:line:text. Optionally restrict to files matching a glob. Read-only.",
|
|
28
|
+
inputSchema: z.object({
|
|
29
|
+
pattern: z.string().describe("Regex pattern to search for (JavaScript regex syntax)."),
|
|
30
|
+
path: z.string().optional().describe("File or directory to search (default: working directory)."),
|
|
31
|
+
glob: z.string().optional().describe("Only search files matching this glob, e.g. '**/*.ts'."),
|
|
32
|
+
ignore_case: z.boolean().optional().describe("Case-insensitive search."),
|
|
33
|
+
}),
|
|
34
|
+
execute: async ({ pattern, path, glob, ignore_case }) => {
|
|
35
|
+
let re: RegExp;
|
|
36
|
+
try {
|
|
37
|
+
re = new RegExp(pattern, ignore_case ? "i" : "");
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return `Error: invalid regex: ${(err as Error).message}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const root = path ? resolveInCwd(ctx, path) : ctx.cwd;
|
|
43
|
+
const blocked = await guardScope(ctx, root, { kind: "read", title: "Search outside working directory" });
|
|
44
|
+
if (blocked) return blocked;
|
|
45
|
+
let files: string[];
|
|
46
|
+
let baseDir: string;
|
|
47
|
+
try {
|
|
48
|
+
if (statSync(root).isFile()) {
|
|
49
|
+
baseDir = ctx.cwd;
|
|
50
|
+
files = [root];
|
|
51
|
+
} else {
|
|
52
|
+
baseDir = root;
|
|
53
|
+
const rel = walkFiles(root);
|
|
54
|
+
const isMatch = glob ? picomatch(glob, { dot: true }) : () => true;
|
|
55
|
+
files = rel.filter((f) => isMatch(f)).map((f) => join(root, f));
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
return `Error: path not found: ${path}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const results: string[] = [];
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
if (results.length >= MAX_MATCHES) break;
|
|
64
|
+
let content: string;
|
|
65
|
+
try {
|
|
66
|
+
content = readFileSync(file, "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (looksBinary(content)) continue;
|
|
71
|
+
const rel = file.startsWith(baseDir) ? file.slice(baseDir.length).replace(/^[/\\]/, "") : file;
|
|
72
|
+
const lines = content.split("\n");
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
if (re.test(lines[i])) {
|
|
75
|
+
results.push(`${rel}:${i + 1}:${lines[i].slice(0, 300)}`);
|
|
76
|
+
if (results.length >= MAX_MATCHES) break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (results.length === 0) return "No matches.";
|
|
82
|
+
const capped = results.length >= MAX_MATCHES ? "\n… (more matches truncated)" : "";
|
|
83
|
+
return results.join("\n") + capped;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ToolSet } from "ai";
|
|
2
|
+
import type { ToolContext } from "./context.ts";
|
|
3
|
+
import { readTool } from "./read.ts";
|
|
4
|
+
import { writeTool } from "./write.ts";
|
|
5
|
+
import { editTool } from "./edit.ts";
|
|
6
|
+
import { globTool } from "./glob.ts";
|
|
7
|
+
import { grepTool } from "./grep.ts";
|
|
8
|
+
import { bashTool, bashOutputTool, killShellTool } from "./bash.ts";
|
|
9
|
+
import { todoTool } from "./todo.ts";
|
|
10
|
+
import { taskTool } from "./task.ts";
|
|
11
|
+
import { webFetchTool, webSearchTool } from "./web.ts";
|
|
12
|
+
import { saveAttachmentTool } from "./saveAttachment.ts";
|
|
13
|
+
import { memoryTool } from "./memory.ts";
|
|
14
|
+
|
|
15
|
+
export type { ToolContext } from "./context.ts";
|
|
16
|
+
|
|
17
|
+
// Build the full toolset bound to a session context (cwd + permission gate + todo store).
|
|
18
|
+
export function createTools(ctx: ToolContext): ToolSet {
|
|
19
|
+
return {
|
|
20
|
+
read: readTool(ctx),
|
|
21
|
+
write: writeTool(ctx),
|
|
22
|
+
edit: editTool(ctx),
|
|
23
|
+
glob: globTool(ctx),
|
|
24
|
+
grep: grepTool(ctx),
|
|
25
|
+
bash: bashTool(ctx),
|
|
26
|
+
bash_output: bashOutputTool(ctx),
|
|
27
|
+
kill_shell: killShellTool(ctx),
|
|
28
|
+
todo: todoTool(ctx),
|
|
29
|
+
task: taskTool(ctx),
|
|
30
|
+
web_fetch: webFetchTool(ctx),
|
|
31
|
+
web_search: webSearchTool(ctx),
|
|
32
|
+
save_attachment: saveAttachmentTool(ctx),
|
|
33
|
+
memory: memoryTool(ctx),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The read-only subset given to `task` sub-agents: search + inspect, no mutation, no
|
|
38
|
+
// recursion (no `task`/`todo`). Safe to run with an auto-approve gate.
|
|
39
|
+
export function createReadOnlyTools(ctx: ToolContext): ToolSet {
|
|
40
|
+
return {
|
|
41
|
+
read: readTool(ctx),
|
|
42
|
+
glob: globTool(ctx),
|
|
43
|
+
grep: grepTool(ctx),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Factories a sub-agent may be granted. Deliberately excludes `task` and `todo` so
|
|
48
|
+
// sub-agents can't recurse or mutate the parent's todo list.
|
|
49
|
+
const AGENT_TOOL_FACTORIES: Record<string, (ctx: ToolContext) => ToolSet[string]> = {
|
|
50
|
+
read: readTool,
|
|
51
|
+
write: writeTool,
|
|
52
|
+
edit: editTool,
|
|
53
|
+
glob: globTool,
|
|
54
|
+
grep: grepTool,
|
|
55
|
+
bash: bashTool,
|
|
56
|
+
web_fetch: webFetchTool,
|
|
57
|
+
web_search: webSearchTool,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Build a sub-agent's toolset from a list of tool names; unknown names are ignored.
|
|
61
|
+
// Falls back to the read-only set when the list is empty or yields nothing.
|
|
62
|
+
export function createToolSubset(ctx: ToolContext, names?: string[]): ToolSet {
|
|
63
|
+
const set: ToolSet = {};
|
|
64
|
+
for (const n of names ?? []) {
|
|
65
|
+
const factory = AGENT_TOOL_FACTORIES[n];
|
|
66
|
+
if (factory) set[n] = factory(ctx);
|
|
67
|
+
}
|
|
68
|
+
return Object.keys(set).length > 0 ? set : createReadOnlyTools(ctx);
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { ToolContext } from "./context.ts";
|
|
4
|
+
import { saveMemory, deleteMemory } from "../memory/auto.ts";
|
|
5
|
+
|
|
6
|
+
// Records durable facts across runs (auto-memory). Unlike write/edit this is not gated:
|
|
7
|
+
// memory lives in the agent's own store, and the returned string surfaces in the
|
|
8
|
+
// transcript as a visible notice of what was saved.
|
|
9
|
+
export function memoryTool(ctx: ToolContext) {
|
|
10
|
+
return tool({
|
|
11
|
+
description:
|
|
12
|
+
"Record or remove a durable memory that persists across sessions. Use it for facts " +
|
|
13
|
+
"worth remembering long-term — user preferences, stable project conventions, and " +
|
|
14
|
+
"feedback on how to work — not transient details. Memories are recalled into your " +
|
|
15
|
+
"context automatically via the memory index. Prefer updating an existing memory " +
|
|
16
|
+
"(same name) over creating a near-duplicate.",
|
|
17
|
+
inputSchema: z.object({
|
|
18
|
+
action: z.enum(["write", "delete"]).describe("Write/update a memory, or delete one."),
|
|
19
|
+
name: z
|
|
20
|
+
.string()
|
|
21
|
+
.describe("Short kebab-case identifier, e.g. 'user-prefers-tabs'. Reuse to update."),
|
|
22
|
+
description: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("One-line summary (required for write). Shown in the recalled index."),
|
|
26
|
+
type: z
|
|
27
|
+
.enum(["user", "feedback", "project", "reference"])
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("user=about the user, feedback=how to work, project=ongoing work, reference=pointer."),
|
|
30
|
+
scope: z
|
|
31
|
+
.enum(["project", "global"])
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("project (default) recalls only here; global recalls in every project."),
|
|
34
|
+
content: z
|
|
35
|
+
.string()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("The fact to remember (required for write). Link others with [[name]]."),
|
|
38
|
+
}),
|
|
39
|
+
execute: async ({ action, name, description, type, scope, content }) => {
|
|
40
|
+
if (action === "delete") {
|
|
41
|
+
const removed = deleteMemory(ctx.cwd, name);
|
|
42
|
+
return removed
|
|
43
|
+
? `Deleted memory "${removed.name}" (${removed.scope}).`
|
|
44
|
+
: `No memory named "${name}" to delete.`;
|
|
45
|
+
}
|
|
46
|
+
if (!description || !content) {
|
|
47
|
+
return 'To write a memory, provide both "description" and "content".';
|
|
48
|
+
}
|
|
49
|
+
const rec = saveMemory(ctx.cwd, { name, description, type, scope, body: content });
|
|
50
|
+
return `Saved memory "${rec.name}" (${rec.scope}, ${rec.type}).`;
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface BgProcess {
|
|
4
|
+
id: string;
|
|
5
|
+
command: string;
|
|
6
|
+
status: "running" | "exited";
|
|
7
|
+
code: number | null;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_OUTPUT = 200_000; // cap retained output per process
|
|
12
|
+
|
|
13
|
+
// Tracks background shells started by the bash tool (run_in_background). Output
|
|
14
|
+
// accumulates as it streams; `read` returns only what's new since the last read so
|
|
15
|
+
// the model can poll a long-running command.
|
|
16
|
+
export class ProcessRegistry {
|
|
17
|
+
private procs = new Map<string, { meta: BgProcess; child: ChildProcess; output: string; offset: number }>();
|
|
18
|
+
private seq = 0;
|
|
19
|
+
|
|
20
|
+
spawn(command: string, cwd: string): string {
|
|
21
|
+
const id = `bash_${++this.seq}`;
|
|
22
|
+
const child = spawn(command, [], { cwd, shell: true, env: process.env });
|
|
23
|
+
const entry: { meta: BgProcess; child: ChildProcess; output: string; offset: number } = {
|
|
24
|
+
meta: { id, command, status: "running", code: null, startedAt: Date.now() },
|
|
25
|
+
child,
|
|
26
|
+
output: "",
|
|
27
|
+
offset: 0,
|
|
28
|
+
};
|
|
29
|
+
const append = (d: Buffer) => {
|
|
30
|
+
if (entry.output.length < MAX_OUTPUT) entry.output += d.toString();
|
|
31
|
+
};
|
|
32
|
+
child.stdout?.on("data", append);
|
|
33
|
+
child.stderr?.on("data", append);
|
|
34
|
+
child.on("error", (e) => {
|
|
35
|
+
entry.output += `\n[error] ${e.message}`;
|
|
36
|
+
entry.meta.status = "exited";
|
|
37
|
+
});
|
|
38
|
+
child.on("close", (code) => {
|
|
39
|
+
entry.meta.status = "exited";
|
|
40
|
+
entry.meta.code = code;
|
|
41
|
+
});
|
|
42
|
+
this.procs.set(id, entry);
|
|
43
|
+
return id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// New output since the last read, plus current status.
|
|
47
|
+
read(id: string): { output: string; status: BgProcess["status"]; code: number | null } | null {
|
|
48
|
+
const e = this.procs.get(id);
|
|
49
|
+
if (!e) return null;
|
|
50
|
+
const fresh = e.output.slice(e.offset);
|
|
51
|
+
e.offset = e.output.length;
|
|
52
|
+
return { output: fresh, status: e.meta.status, code: e.meta.code };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
kill(id: string): boolean {
|
|
56
|
+
const e = this.procs.get(id);
|
|
57
|
+
if (!e) return false;
|
|
58
|
+
e.child.kill("SIGTERM");
|
|
59
|
+
e.meta.status = "exited";
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
list(): BgProcess[] {
|
|
64
|
+
return [...this.procs.values()].map((e) => e.meta);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Best-effort teardown of all background shells (called on app exit).
|
|
68
|
+
killAll(): void {
|
|
69
|
+
for (const e of this.procs.values()) {
|
|
70
|
+
try {
|
|
71
|
+
e.child.kill("SIGKILL");
|
|
72
|
+
} catch {
|
|
73
|
+
/* already gone */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { tool } from "ai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { ToolContext } from "./context.ts";
|
|
5
|
+
import { resolveInCwd, displayPath, guardScope } from "./context.ts";
|
|
6
|
+
|
|
7
|
+
const MAX_LINES = 2000;
|
|
8
|
+
const MAX_LINE_LEN = 2000;
|
|
9
|
+
|
|
10
|
+
export function readTool(ctx: ToolContext) {
|
|
11
|
+
return tool({
|
|
12
|
+
description:
|
|
13
|
+
"Read a file from the working directory. Returns the contents with line numbers. " +
|
|
14
|
+
"Use offset/limit for large files. Read-only.",
|
|
15
|
+
inputSchema: z.object({
|
|
16
|
+
path: z.string().describe("File path, absolute or relative to the working directory."),
|
|
17
|
+
offset: z.number().int().min(1).optional().describe("1-based line to start from."),
|
|
18
|
+
limit: z.number().int().positive().optional().describe("Max lines to read."),
|
|
19
|
+
}),
|
|
20
|
+
execute: async ({ path, offset, limit }) => {
|
|
21
|
+
const abs = resolveInCwd(ctx, path);
|
|
22
|
+
const blocked = await guardScope(ctx, abs, { kind: "read", title: "Read outside working directory" });
|
|
23
|
+
if (blocked) return blocked;
|
|
24
|
+
if (!existsSync(abs)) return `Error: file not found: ${displayPath(ctx, abs)}`;
|
|
25
|
+
if (statSync(abs).isDirectory()) return `Error: ${displayPath(ctx, abs)} is a directory.`;
|
|
26
|
+
|
|
27
|
+
const start = (offset ?? 1) - 1;
|
|
28
|
+
const max = limit ?? MAX_LINES;
|
|
29
|
+
const lines = readFileSync(abs, "utf8").split("\n");
|
|
30
|
+
const slice = lines.slice(start, start + max);
|
|
31
|
+
const body = slice
|
|
32
|
+
.map((line, i) => {
|
|
33
|
+
const n = start + i + 1;
|
|
34
|
+
const text = line.length > MAX_LINE_LEN ? line.slice(0, MAX_LINE_LEN) + "…" : line;
|
|
35
|
+
return `${String(n).padStart(6)}\t${text}`;
|
|
36
|
+
})
|
|
37
|
+
.join("\n");
|
|
38
|
+
const more = start + max < lines.length ? `\n… (${lines.length - (start + max)} more lines)` : "";
|
|
39
|
+
return body + more;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { tool } from "ai";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { ToolContext } from "./context.ts";
|
|
6
|
+
import { resolveInCwd, displayPath, isOutsideScope } from "./context.ts";
|
|
7
|
+
import { PermissionDeniedError } from "../permissions/gate.ts";
|
|
8
|
+
import { isProtectedPath } from "../permissions/protected.ts";
|
|
9
|
+
|
|
10
|
+
export function saveAttachmentTool(ctx: ToolContext) {
|
|
11
|
+
return tool({
|
|
12
|
+
description:
|
|
13
|
+
"Save a user-attached file (image, PDF, audio, or video — shown in the prompt as a " +
|
|
14
|
+
'"[Image #n]" / "[PDF #n]" / etc. chip) to a path on disk. Use this to persist a pasted ' +
|
|
15
|
+
"or dragged-in attachment instead of trying to copy it from a temp or drop path, which is " +
|
|
16
|
+
"unreliable and may be an empty placeholder. `ref` is the number n from the chip.",
|
|
17
|
+
inputSchema: z.object({
|
|
18
|
+
ref: z
|
|
19
|
+
.number()
|
|
20
|
+
.int()
|
|
21
|
+
.describe("The attachment reference number n, taken from its [Kind #n] chip."),
|
|
22
|
+
path: z.string().describe("Destination file path to write the attachment to."),
|
|
23
|
+
}),
|
|
24
|
+
execute: async ({ ref, path }) => {
|
|
25
|
+
const store = ctx.attachments;
|
|
26
|
+
const entry = store?.get(ref);
|
|
27
|
+
if (!entry) {
|
|
28
|
+
const avail = store?.refs() ?? [];
|
|
29
|
+
return avail.length
|
|
30
|
+
? `No attachment #${ref}. Available this session: ${avail.map((n) => `#${n}`).join(", ")}.`
|
|
31
|
+
: `No attachment #${ref}: nothing has been attached this session.`;
|
|
32
|
+
}
|
|
33
|
+
const abs = resolveInCwd(ctx, path);
|
|
34
|
+
const outside = isOutsideScope(ctx, abs);
|
|
35
|
+
const decision = await ctx.gate.request({
|
|
36
|
+
tool: "save_attachment",
|
|
37
|
+
kind: "write",
|
|
38
|
+
title: outside ? "Save attachment outside working directory" : "Save attachment",
|
|
39
|
+
detail: `[#${ref}] → ${outside ? abs : displayPath(ctx, abs)} (${entry.mediaType})`,
|
|
40
|
+
protected: isProtectedPath(abs),
|
|
41
|
+
outside,
|
|
42
|
+
path: abs,
|
|
43
|
+
});
|
|
44
|
+
if (decision === "deny") throw new PermissionDeniedError("save_attachment");
|
|
45
|
+
|
|
46
|
+
ctx.recordMutation?.(abs);
|
|
47
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
48
|
+
const bytes = readFileSync(entry.path);
|
|
49
|
+
writeFileSync(abs, bytes);
|
|
50
|
+
return `Saved attachment #${ref} to ${displayPath(ctx, abs)} (${bytes.length} bytes).`;
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|