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,52 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, extname } from "node:path";
|
|
4
|
+
import type { Attachment, Modality } from "./images.ts";
|
|
5
|
+
|
|
6
|
+
// One stored attachment: its bytes persisted to a stable scratch file, keyed by the
|
|
7
|
+
// "#n" reference the user sees in the prompt.
|
|
8
|
+
export interface StoredAttachment {
|
|
9
|
+
n: number;
|
|
10
|
+
path: string; // absolute scratch-file path holding the decoded bytes
|
|
11
|
+
mediaType: string;
|
|
12
|
+
modality: Modality;
|
|
13
|
+
origin: string; // the token the user referenced (for display/debugging)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Keeps the decoded bytes of every attachment seen this session in a private scratch
|
|
17
|
+
// dir, keyed by its "[Kind #n]" reference, so the `save_attachment` tool can write one
|
|
18
|
+
// back out to a real path on demand.
|
|
19
|
+
//
|
|
20
|
+
// This exists to close the macOS file-promise drop gap: the bytes we capture at
|
|
21
|
+
// paste-time (in resolveAttachments) are the only durable copy — the terminal's
|
|
22
|
+
// …/T/drop-XXXXXX/ file is a volatile stub that may already be gone or truncated by the
|
|
23
|
+
// time the agent goes looking. We copy what we captured into a place we control, once,
|
|
24
|
+
// instead of trusting that path twice.
|
|
25
|
+
export class AttachmentStore {
|
|
26
|
+
private dir: string | null = null;
|
|
27
|
+
private readonly byN = new Map<number, StoredAttachment>();
|
|
28
|
+
|
|
29
|
+
private ensureDir(): string {
|
|
30
|
+
// mkdtemp gives a 0700 dir; created lazily so a session that never attaches
|
|
31
|
+
// anything leaves no temp files behind.
|
|
32
|
+
if (!this.dir) this.dir = mkdtempSync(join(tmpdir(), "privateer-att-"));
|
|
33
|
+
return this.dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Persist an attachment's bytes to the scratch dir, keyed by n. Idempotent: the same
|
|
37
|
+
// n (a file referenced twice in a session) is written once and reused.
|
|
38
|
+
register(att: Attachment): StoredAttachment | undefined {
|
|
39
|
+
if (att.n == null) return undefined;
|
|
40
|
+
const existing = this.byN.get(att.n);
|
|
41
|
+
if (existing) return existing;
|
|
42
|
+
const file = join(this.ensureDir(), `att-${att.n}${extname(att.path)}`);
|
|
43
|
+
writeFileSync(file, Buffer.from(att.data, "base64"));
|
|
44
|
+
const stored: StoredAttachment = {
|
|
45
|
+
n: att.n,
|
|
46
|
+
path: file,
|
|
47
|
+
mediaType: att.mediaType,
|
|
48
|
+
modality: att.modality,
|
|
49
|
+
origin: att.path,
|
|
50
|
+
};
|
|
51
|
+
this.byN.set(att.n, stored);
|
|
52
|
+
return stored;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get(n: number): StoredAttachment | undefined {
|
|
56
|
+
return this.byN.get(n);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The reference numbers currently held, ascending — for a helpful error when the
|
|
60
|
+
// model asks for one that isn't there.
|
|
61
|
+
refs(): number[] {
|
|
62
|
+
return [...this.byN.keys()].sort((a, b) => a - b);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Remove the scratch dir (e.g. on session exit). Safe to call when nothing was stored.
|
|
66
|
+
cleanup(): void {
|
|
67
|
+
if (!this.dir) return;
|
|
68
|
+
rmSync(this.dir, { recursive: true, force: true });
|
|
69
|
+
this.dir = null;
|
|
70
|
+
this.byN.clear();
|
|
71
|
+
}
|
|
72
|
+
}
|