privateer-agent 0.2.1 → 0.3.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/README.md +135 -443
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +73 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { basename } from "node:path";
|
|
3
|
-
import { tool } from "ai";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import type { ToolContext } from "./context.ts";
|
|
6
|
-
import { resolveInCwd, displayPath, guardScope } from "./context.ts";
|
|
7
|
-
import { mediaTypeForPath } from "../util/images.ts";
|
|
8
|
-
|
|
9
|
-
// Per-file ceiling for the relay's chunked file channel; mirrors the relay
|
|
10
|
-
// client's MAX_ATTACH_BYTES so the tool fails fast with a friendly message
|
|
11
|
-
// instead of letting the transfer be rejected mid-flight.
|
|
12
|
-
const MAX_SEND_BYTES = 10 * 1024 * 1024;
|
|
13
|
-
|
|
14
|
-
export function sendFileToClientTool(ctx: ToolContext) {
|
|
15
|
-
return tool({
|
|
16
|
-
description:
|
|
17
|
-
"Send a file from disk to the connected Privateer app (the phone/web client driving this " +
|
|
18
|
-
"terminal remotely), where the user can preview it and save/share it on their device. Use " +
|
|
19
|
-
"when the user asks to see, download, or receive a file on their phone or browser. Only " +
|
|
20
|
-
"works while remote access is on and the app is connected; max 10 MB per file.",
|
|
21
|
-
inputSchema: z.object({
|
|
22
|
-
path: z.string().describe("Path of the file to send, relative to cwd or absolute."),
|
|
23
|
-
}),
|
|
24
|
-
execute: async ({ path }) => {
|
|
25
|
-
if (!ctx.sendFileToController) {
|
|
26
|
-
return "Cannot send: remote access is not enabled in this session (/remote-access to enable).";
|
|
27
|
-
}
|
|
28
|
-
const abs = resolveInCwd(ctx, path);
|
|
29
|
-
if (!existsSync(abs)) return `File not found: ${displayPath(ctx, abs)}`;
|
|
30
|
-
const stat = statSync(abs);
|
|
31
|
-
if (stat.isDirectory()) return `${displayPath(ctx, abs)} is a directory — send a single file.`;
|
|
32
|
-
if (stat.size === 0) return `${displayPath(ctx, abs)} is empty — nothing to send.`;
|
|
33
|
-
if (stat.size > MAX_SEND_BYTES) {
|
|
34
|
-
return (
|
|
35
|
-
`${displayPath(ctx, abs)} is ${(stat.size / (1024 * 1024)).toFixed(1)} MB; ` +
|
|
36
|
-
`the remote channel caps at ${MAX_SEND_BYTES / (1024 * 1024)} MB per file.`
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
const blocked = await guardScope(ctx, abs, { kind: "read", title: "Send file to connected app" });
|
|
40
|
-
if (blocked) return blocked;
|
|
41
|
-
|
|
42
|
-
const bytes = readFileSync(abs);
|
|
43
|
-
const mediaType = mediaTypeForPath(abs);
|
|
44
|
-
const res = await ctx.sendFileToController({
|
|
45
|
-
name: basename(abs),
|
|
46
|
-
mediaType,
|
|
47
|
-
base64: bytes.toString("base64"),
|
|
48
|
-
size: bytes.length,
|
|
49
|
-
});
|
|
50
|
-
return res.ok
|
|
51
|
-
? `Sent ${basename(abs)} (${bytes.length} bytes, ${mediaType}) to the connected app.`
|
|
52
|
-
: `Couldn't send ${basename(abs)}: ${res.reason ?? "unknown error"}. Is the app currently connected?`;
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
}
|
package/src/tools/skill.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { tool } from "ai";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { ToolContext } from "./context.ts";
|
|
4
|
-
import { loadSkills, type SkillDefinition } from "../skills/loader.ts";
|
|
5
|
-
|
|
6
|
-
// Progressive disclosure for skills: the model sees only the catalog (names +
|
|
7
|
-
// descriptions, embedded in this tool's description — same pattern as `task`
|
|
8
|
-
// advertising sub-agents) and pulls a skill's full instructions into the
|
|
9
|
-
// conversation by calling the tool. Loading a skill also unlocks reads of its
|
|
10
|
-
// bundled files, which live outside cwd for user-scoped skills.
|
|
11
|
-
export function skillTool(ctx: ToolContext, skills?: SkillDefinition[]) {
|
|
12
|
-
const list = skills ?? loadSkills(ctx.cwd).skills;
|
|
13
|
-
return tool({
|
|
14
|
-
description:
|
|
15
|
-
"Load a skill — a package of expert instructions for a specific kind of task. " +
|
|
16
|
-
"When a listed skill matches the user's request, load it BEFORE attempting the " +
|
|
17
|
-
"task and follow its instructions. Available skills:\n" +
|
|
18
|
-
list.map((s) => `- ${s.name}: ${s.description}`).join("\n"),
|
|
19
|
-
inputSchema: z.object({
|
|
20
|
-
skill: z.string().describe("Name of the skill to load."),
|
|
21
|
-
}),
|
|
22
|
-
execute: async ({ skill }) => {
|
|
23
|
-
const def = list.find((s) => s.name === skill);
|
|
24
|
-
if (!def) {
|
|
25
|
-
const names = list.map((s) => s.name).join(", ") || "(none)";
|
|
26
|
-
return `No skill named "${skill}". Available: ${names}.`;
|
|
27
|
-
}
|
|
28
|
-
// Bundled files (scripts/, references/, ...) sit next to SKILL.md — for a
|
|
29
|
-
// user-scoped skill that's outside cwd, so mark the directory in-scope for
|
|
30
|
-
// this session rather than prompting on every read.
|
|
31
|
-
const roots = (ctx.allowedOutsideRoots ??= []);
|
|
32
|
-
if (!roots.includes(def.dir)) roots.push(def.dir);
|
|
33
|
-
const advisory = def.allowedTools?.length
|
|
34
|
-
? `\nDeclared allowed-tools: ${def.allowedTools.join(", ")} (advisory in this version).`
|
|
35
|
-
: "";
|
|
36
|
-
return (
|
|
37
|
-
`Skill "${def.name}" loaded (base directory: ${def.dir}). ` +
|
|
38
|
-
`Bundled files can be read with the read tool using paths under that directory.` +
|
|
39
|
-
advisory +
|
|
40
|
-
`\n\n${def.body}`
|
|
41
|
-
);
|
|
42
|
-
},
|
|
43
|
-
});
|
|
44
|
-
}
|
package/src/tools/task.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { tool } from "ai";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { ToolContext } from "./context.ts";
|
|
4
|
-
import { loadAgents, findAgent } from "../agents/loader.ts";
|
|
5
|
-
|
|
6
|
-
// Delegation tool: hand an open-ended investigation to a sub-agent that runs its own
|
|
7
|
-
// loop and returns just a summary, keeping the parent conversation small. Without a
|
|
8
|
-
// subagent_type it runs the default read-only agent (read/glob/grep); with one it uses
|
|
9
|
-
// that custom agent's tools/model/instructions. Sub-agents cannot spawn further
|
|
10
|
-
// sub-agents (no recursion).
|
|
11
|
-
//
|
|
12
|
-
// When the model emits several `task` calls in one turn the AI SDK runs their
|
|
13
|
-
// execute()s concurrently, so sub-agents fan out in parallel — bounded by the
|
|
14
|
-
// session's `maxSubagents` limiter.
|
|
15
|
-
export function taskTool(ctx: ToolContext) {
|
|
16
|
-
const agents = loadAgents(ctx.cwd);
|
|
17
|
-
const agentList = agents.length
|
|
18
|
-
? ` Available subagent_type values: ${agents.map((a) => `${a.name} (${a.description})`).join("; ")}.`
|
|
19
|
-
: "";
|
|
20
|
-
return tool({
|
|
21
|
-
description:
|
|
22
|
-
"Delegate a search or task to a sub-agent. Give it a complete, self-contained prompt; it " +
|
|
23
|
-
"explores and returns a text summary, keeping the main thread focused. Omit subagent_type " +
|
|
24
|
-
"for the default read-only agent (read/glob/grep, cannot modify files)." +
|
|
25
|
-
agentList,
|
|
26
|
-
inputSchema: z.object({
|
|
27
|
-
description: z.string().describe("Short 3–6 word description of the task."),
|
|
28
|
-
prompt: z.string().describe("The full, self-contained task for the sub-agent."),
|
|
29
|
-
subagent_type: z.string().optional().describe("Name of a custom sub-agent to use."),
|
|
30
|
-
}),
|
|
31
|
-
execute: async ({ description, prompt, subagent_type }, { toolCallId }) => {
|
|
32
|
-
if (!ctx.runSubAgent) return "Sub-agents are not available in this context.";
|
|
33
|
-
let agent;
|
|
34
|
-
if (subagent_type) {
|
|
35
|
-
agent = findAgent(subagent_type, ctx.cwd);
|
|
36
|
-
if (!agent) {
|
|
37
|
-
const names = loadAgents(ctx.cwd).map((a) => a.name).join(", ") || "(none defined)";
|
|
38
|
-
return `No sub-agent named "${subagent_type}". Available: ${names}.`;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
try {
|
|
42
|
-
const { text, toolUses, tokens } = await ctx.runSubAgent({ description, prompt, agent });
|
|
43
|
-
// Report run metrics out-of-band (keyed by this call's id) for the grouped
|
|
44
|
-
// agents view; the model itself only ever sees the text summary.
|
|
45
|
-
ctx.onSubAgentMetrics?.(toolCallId, { toolUses, tokens });
|
|
46
|
-
return text;
|
|
47
|
-
} catch (err) {
|
|
48
|
-
return `Sub-agent failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
}
|
package/src/tools/todo.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { tool } from "ai";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { ToolContext } from "./context.ts";
|
|
4
|
-
|
|
5
|
-
// The planning tool: the model maintains a single flat task list, rewriting the
|
|
6
|
-
// whole list each call. State lives in the session's
|
|
7
|
-
// TodoStore so the TUI can render it live. No filesystem mutation, so it isn't gated.
|
|
8
|
-
export function todoTool(ctx: ToolContext) {
|
|
9
|
-
return tool({
|
|
10
|
-
description:
|
|
11
|
-
"Record and update your task list for multi-step work. Pass the COMPLETE list every " +
|
|
12
|
-
"time (it replaces the previous one). Keep exactly one item 'in_progress'; mark items " +
|
|
13
|
-
"'completed' as you finish them. Use this to plan non-trivial tasks and keep the user oriented.",
|
|
14
|
-
inputSchema: z.object({
|
|
15
|
-
todos: z
|
|
16
|
-
.array(
|
|
17
|
-
z.object({
|
|
18
|
-
content: z.string().describe("Imperative task description, e.g. 'Add auth middleware'."),
|
|
19
|
-
status: z.enum(["pending", "in_progress", "completed"]),
|
|
20
|
-
activeForm: z
|
|
21
|
-
.string()
|
|
22
|
-
.optional()
|
|
23
|
-
.describe("Present-continuous label shown while running, e.g. 'Adding auth middleware'."),
|
|
24
|
-
}),
|
|
25
|
-
)
|
|
26
|
-
.describe("The full task list, in order."),
|
|
27
|
-
}),
|
|
28
|
-
execute: async ({ todos }) => {
|
|
29
|
-
ctx.todos?.set(todos);
|
|
30
|
-
const done = todos.filter((t) => t.status === "completed").length;
|
|
31
|
-
const active = todos.find((t) => t.status === "in_progress");
|
|
32
|
-
const summary = `Updated todo list (${done}/${todos.length} done).`;
|
|
33
|
-
return active ? `${summary} In progress: ${active.content}` : summary;
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
}
|
package/src/tools/todoStore.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// Session-scoped todo list. The `todo` tool replaces it; the TUI subscribes to it to
|
|
2
|
-
// render the live task panel. Kept deliberately tiny — it's just an observable array.
|
|
3
|
-
|
|
4
|
-
export type TodoStatus = "pending" | "in_progress" | "completed";
|
|
5
|
-
|
|
6
|
-
export interface TodoItem {
|
|
7
|
-
content: string;
|
|
8
|
-
status: TodoStatus;
|
|
9
|
-
activeForm?: string; // present-continuous label shown while in_progress
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export class TodoStore {
|
|
13
|
-
private items: TodoItem[] = [];
|
|
14
|
-
private listeners = new Set<(items: TodoItem[]) => void>();
|
|
15
|
-
|
|
16
|
-
get(): TodoItem[] {
|
|
17
|
-
return this.items;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
set(items: TodoItem[]): void {
|
|
21
|
-
this.items = items;
|
|
22
|
-
for (const l of this.listeners) l(items);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
subscribe(fn: (items: TodoItem[]) => void): () => void {
|
|
26
|
-
this.listeners.add(fn);
|
|
27
|
-
return () => {
|
|
28
|
-
this.listeners.delete(fn);
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
}
|
package/src/tools/walk.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { readdirSync } from "node:fs";
|
|
2
|
-
import { join, relative, sep } from "node:path";
|
|
3
|
-
|
|
4
|
-
// Directories we never descend into, so searches stay fast and relevant without
|
|
5
|
-
// needing a full .gitignore parser. (A richer ignore story can come later.)
|
|
6
|
-
const SKIP_DIRS = new Set([
|
|
7
|
-
".git",
|
|
8
|
-
"node_modules",
|
|
9
|
-
"dist",
|
|
10
|
-
"build",
|
|
11
|
-
".next",
|
|
12
|
-
".cache",
|
|
13
|
-
"coverage",
|
|
14
|
-
".venv",
|
|
15
|
-
"__pycache__",
|
|
16
|
-
".turbo",
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
|
-
const MAX_FILES = 20_000;
|
|
20
|
-
|
|
21
|
-
// Recursively list files under `root`, returning paths relative to `root` with
|
|
22
|
-
// forward slashes (so glob patterns behave consistently across platforms).
|
|
23
|
-
export function walkFiles(root: string): string[] {
|
|
24
|
-
const out: string[] = [];
|
|
25
|
-
const stack: string[] = [root];
|
|
26
|
-
while (stack.length && out.length < MAX_FILES) {
|
|
27
|
-
const dir = stack.pop()!;
|
|
28
|
-
let entries;
|
|
29
|
-
try {
|
|
30
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
31
|
-
} catch {
|
|
32
|
-
continue; // unreadable dir — skip
|
|
33
|
-
}
|
|
34
|
-
for (const e of entries) {
|
|
35
|
-
if (e.isDirectory()) {
|
|
36
|
-
if (SKIP_DIRS.has(e.name)) continue;
|
|
37
|
-
stack.push(join(dir, e.name));
|
|
38
|
-
} else if (e.isFile()) {
|
|
39
|
-
out.push(relative(root, join(dir, e.name)).split(sep).join("/"));
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return out;
|
|
44
|
-
}
|
package/src/tools/web.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { tool } from "ai";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { ToolContext } from "./context.ts";
|
|
4
|
-
import { PermissionDeniedError } from "../permissions/gate.ts";
|
|
5
|
-
|
|
6
|
-
const MAX_BYTES = 2_000_000; // cap download size
|
|
7
|
-
const MAX_TEXT = 20_000; // cap returned text
|
|
8
|
-
const TIMEOUT_MS = 20_000;
|
|
9
|
-
|
|
10
|
-
// Fetch a URL's body as text, with a timeout and size cap. Returns null fields on failure.
|
|
11
|
-
async function fetchText(url: string): Promise<{ status: number; contentType: string; body: string }> {
|
|
12
|
-
const ac = new AbortController();
|
|
13
|
-
const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
|
14
|
-
try {
|
|
15
|
-
const res = await fetch(url, {
|
|
16
|
-
signal: ac.signal,
|
|
17
|
-
redirect: "follow",
|
|
18
|
-
headers: { "user-agent": "privateer-agent/0.1.0 (+https://github.com/privateer-agent/privateer-agent)" },
|
|
19
|
-
});
|
|
20
|
-
const body = (await res.text()).slice(0, MAX_BYTES);
|
|
21
|
-
return { status: res.status, contentType: res.headers.get("content-type") ?? "", body };
|
|
22
|
-
} finally {
|
|
23
|
-
clearTimeout(timer);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Crude HTML→text: drop scripts/styles, turn block-closers into newlines, strip tags,
|
|
28
|
-
// decode the common entities. Good enough to feed page content to the model.
|
|
29
|
-
function htmlToText(html: string): string {
|
|
30
|
-
return html
|
|
31
|
-
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
32
|
-
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
33
|
-
.replace(/<\/(p|div|h[1-6]|li|tr|section|article|header|footer)>/gi, "\n")
|
|
34
|
-
.replace(/<br\s*\/?>/gi, "\n")
|
|
35
|
-
.replace(/<[^>]+>/g, " ")
|
|
36
|
-
.replace(/ /gi, " ")
|
|
37
|
-
.replace(/&/gi, "&")
|
|
38
|
-
.replace(/</gi, "<")
|
|
39
|
-
.replace(/>/gi, ">")
|
|
40
|
-
.replace(/"/gi, '"')
|
|
41
|
-
.replace(/'/gi, "'")
|
|
42
|
-
.replace(/[ \t]+/g, " ")
|
|
43
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
44
|
-
.trim();
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function isHttpUrl(u: string): boolean {
|
|
48
|
-
try {
|
|
49
|
-
const parsed = new URL(u);
|
|
50
|
-
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
51
|
-
} catch {
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function webFetchTool(ctx: ToolContext) {
|
|
57
|
-
return tool({
|
|
58
|
-
description:
|
|
59
|
-
"Fetch a URL and return its content as text (HTML is stripped to readable text). Use when " +
|
|
60
|
-
"the user gives a link or you need to read online docs. Network egress, so it may prompt.",
|
|
61
|
-
inputSchema: z.object({
|
|
62
|
-
url: z.string().describe("Absolute http(s) URL to fetch."),
|
|
63
|
-
prompt: z.string().optional().describe("What you're looking for (recorded for context; not a separate model call)."),
|
|
64
|
-
}),
|
|
65
|
-
execute: async ({ url, prompt }) => {
|
|
66
|
-
if (!isHttpUrl(url)) return `Error: not a valid http(s) URL: ${url}`;
|
|
67
|
-
const decision = await ctx.gate.request({
|
|
68
|
-
tool: "web_fetch",
|
|
69
|
-
kind: "fetch",
|
|
70
|
-
title: "Fetch URL",
|
|
71
|
-
detail: url,
|
|
72
|
-
});
|
|
73
|
-
if (decision === "deny") throw new PermissionDeniedError("web_fetch");
|
|
74
|
-
|
|
75
|
-
let result;
|
|
76
|
-
try {
|
|
77
|
-
result = await fetchText(url);
|
|
78
|
-
} catch (err) {
|
|
79
|
-
return `Error fetching ${url}: ${err instanceof Error ? err.message : String(err)}`;
|
|
80
|
-
}
|
|
81
|
-
const text = /html/i.test(result.contentType) ? htmlToText(result.body) : result.body.trim();
|
|
82
|
-
const capped = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) + "\n… (truncated)" : text;
|
|
83
|
-
const header = `[${result.status} · ${result.contentType || "?"}]${prompt ? ` looking for: ${prompt}` : ""}`;
|
|
84
|
-
return `${header}\n\n${capped || "(empty response)"}`;
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// DuckDuckGo's keyless HTML endpoint, scraped for result titles + links. No API key
|
|
90
|
-
// required, which keeps web_search working out of the box.
|
|
91
|
-
// Caveat: it scrapes HTML, so it's best-effort and can break if DDG changes markup.
|
|
92
|
-
function parseDdg(html: string, limit: number): string[] {
|
|
93
|
-
const out: string[] = [];
|
|
94
|
-
const re = /<a[^>]+class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
|
95
|
-
let m: RegExpExecArray | null;
|
|
96
|
-
while ((m = re.exec(html)) && out.length < limit) {
|
|
97
|
-
const href = decodeDdgHref(m[1]);
|
|
98
|
-
const title = htmlToText(m[2]);
|
|
99
|
-
if (title) out.push(`${title}\n ${href}`);
|
|
100
|
-
}
|
|
101
|
-
return out;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// DDG wraps results as //duckduckgo.com/l/?uddg=<encoded real url>.
|
|
105
|
-
function decodeDdgHref(href: string): string {
|
|
106
|
-
try {
|
|
107
|
-
const u = new URL(href, "https://duckduckgo.com");
|
|
108
|
-
const uddg = u.searchParams.get("uddg");
|
|
109
|
-
return uddg ? decodeURIComponent(uddg) : u.toString();
|
|
110
|
-
} catch {
|
|
111
|
-
return href;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function webSearchTool(ctx: ToolContext) {
|
|
116
|
-
return tool({
|
|
117
|
-
description:
|
|
118
|
-
"Search the web and return the top results (title + URL) via DuckDuckGo. Follow up with " +
|
|
119
|
-
"web_fetch to read a result. Network egress, so it may prompt.",
|
|
120
|
-
inputSchema: z.object({
|
|
121
|
-
query: z.string().describe("Search query."),
|
|
122
|
-
}),
|
|
123
|
-
execute: async ({ query }) => {
|
|
124
|
-
const decision = await ctx.gate.request({
|
|
125
|
-
tool: "web_search",
|
|
126
|
-
kind: "fetch",
|
|
127
|
-
title: "Web search",
|
|
128
|
-
detail: query,
|
|
129
|
-
});
|
|
130
|
-
if (decision === "deny") throw new PermissionDeniedError("web_search");
|
|
131
|
-
|
|
132
|
-
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
133
|
-
let result;
|
|
134
|
-
try {
|
|
135
|
-
result = await fetchText(url);
|
|
136
|
-
} catch (err) {
|
|
137
|
-
return `Error searching: ${err instanceof Error ? err.message : String(err)}`;
|
|
138
|
-
}
|
|
139
|
-
const results = parseDdg(result.body, 8);
|
|
140
|
-
return results.length
|
|
141
|
-
? `Results for "${query}":\n\n${results.join("\n\n")}`
|
|
142
|
-
: `No results parsed for "${query}" (DuckDuckGo markup may have changed). Try web_fetch with a direct URL.`;
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
}
|
package/src/tools/worktree.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { tool } from "ai";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { basename, resolve } from "node:path";
|
|
4
|
-
import type { ToolContext } from "./context.ts";
|
|
5
|
-
import { exec } from "./exec.ts";
|
|
6
|
-
import { PermissionDeniedError } from "../permissions/gate.ts";
|
|
7
|
-
|
|
8
|
-
const GIT_TIMEOUT = 60_000;
|
|
9
|
-
|
|
10
|
-
// Strip a worktree/branch name down to a filesystem- and git-safe slug.
|
|
11
|
-
function sanitizeName(name: string): string {
|
|
12
|
-
return name
|
|
13
|
-
.trim()
|
|
14
|
-
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
15
|
-
.replace(/^-+|-+$/g, "")
|
|
16
|
-
.slice(0, 60);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// The sibling path a named worktree lives at: ../<repo>-wt-<name>.
|
|
20
|
-
function worktreePath(cwd: string, slug: string): string {
|
|
21
|
-
return resolve(cwd, "..", `${basename(cwd)}-wt-${slug}`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function git(cwd: string, args: string[]) {
|
|
25
|
-
return exec("git", args, { cwd, timeoutMs: GIT_TIMEOUT });
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface WorktreeEntry {
|
|
29
|
-
path: string;
|
|
30
|
-
branch?: string;
|
|
31
|
-
head?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Parse `git worktree list --porcelain` into structured entries.
|
|
35
|
-
function parseWorktrees(porcelain: string): WorktreeEntry[] {
|
|
36
|
-
const entries: WorktreeEntry[] = [];
|
|
37
|
-
let cur: WorktreeEntry | null = null;
|
|
38
|
-
for (const line of porcelain.split("\n")) {
|
|
39
|
-
if (line.startsWith("worktree ")) {
|
|
40
|
-
if (cur) entries.push(cur);
|
|
41
|
-
cur = { path: line.slice("worktree ".length) };
|
|
42
|
-
} else if (line.startsWith("branch ") && cur) {
|
|
43
|
-
cur.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
44
|
-
} else if (line.startsWith("HEAD ") && cur) {
|
|
45
|
-
cur.head = line.slice("HEAD ".length, "HEAD ".length + 8);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (cur) entries.push(cur);
|
|
49
|
-
return entries;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// The `worktree` tool: lets the agent develop a candidate approach in an isolated
|
|
53
|
-
// git worktree (its own branch + working copy) so a risky or exploratory direction
|
|
54
|
-
// never touches the main tree. Combined with `ask_user`, this is the building block
|
|
55
|
-
// for "try an approach, compare the diff, keep or discard it". Mutating actions
|
|
56
|
-
// (create/remove) run git and are routed through the permission gate.
|
|
57
|
-
export function worktreeTool(ctx: ToolContext) {
|
|
58
|
-
return tool({
|
|
59
|
-
description:
|
|
60
|
-
"Manage git worktrees to develop a candidate approach in isolation (its own branch + " +
|
|
61
|
-
"working copy), so an exploratory or risky direction doesn't touch the main tree. " +
|
|
62
|
-
"Actions: 'create' a new worktree on a fresh branch, 'list' existing worktrees, 'remove' " +
|
|
63
|
-
"one when done. Use this to try an approach the user can later compare via its diff and " +
|
|
64
|
-
"keep or discard. The repository must be a git repo.",
|
|
65
|
-
inputSchema: z.object({
|
|
66
|
-
action: z.enum(["create", "list", "remove"]).describe("What to do."),
|
|
67
|
-
name: z
|
|
68
|
-
.string()
|
|
69
|
-
.optional()
|
|
70
|
-
.describe("Worktree/branch name for create and remove (e.g. 'redis-cache')."),
|
|
71
|
-
base: z
|
|
72
|
-
.string()
|
|
73
|
-
.optional()
|
|
74
|
-
.describe("For create: the ref to branch from (default: current HEAD)."),
|
|
75
|
-
deleteBranch: z
|
|
76
|
-
.boolean()
|
|
77
|
-
.optional()
|
|
78
|
-
.describe("For remove: also delete the worktree's branch (default false)."),
|
|
79
|
-
}),
|
|
80
|
-
execute: async ({ action, name, base, deleteBranch }) => {
|
|
81
|
-
if (action === "list") {
|
|
82
|
-
const { stdout, code } = await git(ctx.cwd, ["worktree", "list", "--porcelain"]);
|
|
83
|
-
if (code !== 0) return "Not a git repository, or `git worktree` failed.";
|
|
84
|
-
const entries = parseWorktrees(stdout.trim());
|
|
85
|
-
if (entries.length <= 1) return "No additional worktrees. Only the main working tree exists.";
|
|
86
|
-
return entries
|
|
87
|
-
.map((e) => `${e.path}${e.branch ? ` [${e.branch}]` : ""}${e.head ? ` @${e.head}` : ""}`)
|
|
88
|
-
.join("\n");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const slug = sanitizeName(name ?? "");
|
|
92
|
-
if (!slug) return "A non-empty `name` is required for this action.";
|
|
93
|
-
const path = worktreePath(ctx.cwd, slug);
|
|
94
|
-
|
|
95
|
-
if (action === "create") {
|
|
96
|
-
const args = ["worktree", "add", "-b", slug, path];
|
|
97
|
-
if (base) args.push(base);
|
|
98
|
-
const cmd = `git ${args.join(" ")}`;
|
|
99
|
-
const decision = await ctx.gate.request({
|
|
100
|
-
tool: "worktree",
|
|
101
|
-
kind: "bash",
|
|
102
|
-
title: "Create git worktree",
|
|
103
|
-
detail: cmd,
|
|
104
|
-
});
|
|
105
|
-
if (decision === "deny") throw new PermissionDeniedError("worktree");
|
|
106
|
-
const { stderr, code } = await git(ctx.cwd, args);
|
|
107
|
-
if (code !== 0) return `git worktree add failed:\n${stderr.trim()}`;
|
|
108
|
-
// Let the agent edit inside the new worktree without the confinement gate
|
|
109
|
-
// re-prompting on every file — the user just approved creating it here.
|
|
110
|
-
if (ctx.allowedOutsideRoots && !ctx.allowedOutsideRoots.includes(path)) {
|
|
111
|
-
ctx.allowedOutsideRoots.push(path);
|
|
112
|
-
}
|
|
113
|
-
return (
|
|
114
|
-
`Created worktree at ${path} on new branch '${slug}'.\n` +
|
|
115
|
-
`Work there with absolute paths under that directory; it's isolated from the main tree. ` +
|
|
116
|
-
`Compare later with: git -C "${path}" diff ${base ?? "HEAD"}`
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// remove
|
|
121
|
-
const cmd = `git worktree remove ${path}${deleteBranch ? ` && git branch -D ${slug}` : ""}`;
|
|
122
|
-
const decision = await ctx.gate.request({
|
|
123
|
-
tool: "worktree",
|
|
124
|
-
kind: "bash",
|
|
125
|
-
title: "Remove git worktree",
|
|
126
|
-
detail: cmd,
|
|
127
|
-
});
|
|
128
|
-
if (decision === "deny") throw new PermissionDeniedError("worktree");
|
|
129
|
-
const rm = await git(ctx.cwd, ["worktree", "remove", path]);
|
|
130
|
-
if (rm.code !== 0) {
|
|
131
|
-
return `git worktree remove failed (commit or discard changes first, or it doesn't exist):\n${rm.stderr.trim()}`;
|
|
132
|
-
}
|
|
133
|
-
if (ctx.allowedOutsideRoots) {
|
|
134
|
-
const i = ctx.allowedOutsideRoots.indexOf(path);
|
|
135
|
-
if (i >= 0) ctx.allowedOutsideRoots.splice(i, 1);
|
|
136
|
-
}
|
|
137
|
-
let msg = `Removed worktree ${path}.`;
|
|
138
|
-
if (deleteBranch) {
|
|
139
|
-
const br = await git(ctx.cwd, ["branch", "-D", slug]);
|
|
140
|
-
msg += br.code === 0 ? ` Deleted branch '${slug}'.` : ` (branch '${slug}' not deleted: ${br.stderr.trim()})`;
|
|
141
|
-
}
|
|
142
|
-
return msg;
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
}
|
package/src/tools/write.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, existsSync } 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 writeTool(ctx: ToolContext) {
|
|
11
|
-
return tool({
|
|
12
|
-
description:
|
|
13
|
-
"Write a file, creating parent directories as needed. Overwrites existing files. " +
|
|
14
|
-
"Prefer the edit tool for small changes to existing files.",
|
|
15
|
-
inputSchema: z.object({
|
|
16
|
-
path: z.string().describe("File path to write."),
|
|
17
|
-
content: z.string().describe("Full file contents."),
|
|
18
|
-
}),
|
|
19
|
-
execute: async ({ path, content }) => {
|
|
20
|
-
const abs = resolveInCwd(ctx, path);
|
|
21
|
-
const exists = existsSync(abs);
|
|
22
|
-
const outside = isOutsideScope(ctx, abs);
|
|
23
|
-
const decision = await ctx.gate.request({
|
|
24
|
-
tool: "write",
|
|
25
|
-
kind: "write",
|
|
26
|
-
title: outside ? "Write outside working directory" : exists ? "Overwrite file" : "Create file",
|
|
27
|
-
detail: `${outside ? abs : displayPath(ctx, abs)} (${content.split("\n").length} lines)`,
|
|
28
|
-
protected: isProtectedPath(abs),
|
|
29
|
-
outside,
|
|
30
|
-
path: abs,
|
|
31
|
-
});
|
|
32
|
-
if (decision === "deny") throw new PermissionDeniedError("write");
|
|
33
|
-
|
|
34
|
-
ctx.recordMutation?.(abs);
|
|
35
|
-
mkdirSync(dirname(abs), { recursive: true });
|
|
36
|
-
writeFileSync(abs, content, "utf8");
|
|
37
|
-
return `${exists ? "Wrote" : "Created"} ${displayPath(ctx, abs)} (${content.length} bytes).`;
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
}
|