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,42 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { globalPaths, projectPaths } from "../config/paths.ts";
|
|
4
|
-
import { walkFiles } from "../tools/walk.ts";
|
|
5
|
-
import { parseFrontmatter } from "../commands/custom.ts";
|
|
6
|
-
|
|
7
|
-
// A persona/behavior preset loaded from .privateer/output-styles/<name>.md. Its body
|
|
8
|
-
// replaces the default tone section of the system prompt while the tool policy,
|
|
9
|
-
// security stance, and environment grounding stay intact.
|
|
10
|
-
export interface OutputStyle {
|
|
11
|
-
name: string;
|
|
12
|
-
description?: string;
|
|
13
|
-
body: string;
|
|
14
|
-
scope: "project" | "user";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function loadFromDir(dir: string, scope: "project" | "user"): OutputStyle[] {
|
|
18
|
-
if (!existsSync(dir)) return [];
|
|
19
|
-
const out: OutputStyle[] = [];
|
|
20
|
-
for (const rel of walkFiles(dir)) {
|
|
21
|
-
if (!rel.endsWith(".md")) continue;
|
|
22
|
-
const { meta, body } = parseFrontmatter(readFileSync(join(dir, rel), "utf8"));
|
|
23
|
-
out.push({
|
|
24
|
-
name: rel.replace(/\.md$/, "").split("/").join(":"),
|
|
25
|
-
description: meta.description,
|
|
26
|
-
body: body.trim(),
|
|
27
|
-
scope,
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
return out;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function loadOutputStyles(cwd: string = process.cwd()): OutputStyle[] {
|
|
34
|
-
const byName = new Map<string, OutputStyle>();
|
|
35
|
-
for (const s of loadFromDir(globalPaths().outputStyles, "user")) byName.set(s.name, s);
|
|
36
|
-
for (const s of loadFromDir(projectPaths(cwd).outputStyles, "project")) byName.set(s.name, s);
|
|
37
|
-
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function findOutputStyle(name: string, cwd: string = process.cwd()): OutputStyle | undefined {
|
|
41
|
-
return loadOutputStyles(cwd).find((s) => s.name === name);
|
|
42
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { walkFiles } from "../tools/walk.ts";
|
|
3
|
-
|
|
4
|
-
// Lightweight, synchronous environment probes used to enrich the system prompt at
|
|
5
|
-
// session start. Everything here fails soft: outside a git repo, or if `git` is
|
|
6
|
-
// missing, the git block is simply omitted rather than throwing.
|
|
7
|
-
|
|
8
|
-
function git(cwd: string, args: string[]): string | null {
|
|
9
|
-
try {
|
|
10
|
-
return execFileSync("git", args, {
|
|
11
|
-
cwd,
|
|
12
|
-
encoding: "utf8",
|
|
13
|
-
timeout: 3_000,
|
|
14
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
15
|
-
}).trim();
|
|
16
|
-
} catch {
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface GitInfo {
|
|
22
|
-
branch: string;
|
|
23
|
-
status: string; // short porcelain, possibly truncated
|
|
24
|
-
recent: string; // last few commit subjects
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// A compact git snapshot, or null when cwd isn't a working tree.
|
|
28
|
-
export function gitStatus(cwd: string): GitInfo | null {
|
|
29
|
-
const inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
30
|
-
if (inside !== "true") return null;
|
|
31
|
-
|
|
32
|
-
const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) || "(detached)";
|
|
33
|
-
const raw = git(cwd, ["status", "--porcelain"]) ?? "";
|
|
34
|
-
const lines = raw ? raw.split("\n") : [];
|
|
35
|
-
const status =
|
|
36
|
-
lines.length === 0
|
|
37
|
-
? "(clean)"
|
|
38
|
-
: lines.slice(0, 20).join("\n") +
|
|
39
|
-
(lines.length > 20 ? `\n… (+${lines.length - 20} more)` : "");
|
|
40
|
-
const recent = git(cwd, ["log", "--oneline", "-5"]) ?? "";
|
|
41
|
-
|
|
42
|
-
return { branch, status, recent };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// A shallow snapshot of the project's files (respecting walk's skip list), capped
|
|
46
|
-
// so the prompt stays small. Gives the model a sense of layout before it explores.
|
|
47
|
-
export function dirSnapshot(cwd: string, limit = 40): string {
|
|
48
|
-
let files: string[];
|
|
49
|
-
try {
|
|
50
|
-
files = walkFiles(cwd);
|
|
51
|
-
} catch {
|
|
52
|
-
return "";
|
|
53
|
-
}
|
|
54
|
-
if (files.length === 0) return "";
|
|
55
|
-
files.sort();
|
|
56
|
-
const shown = files.slice(0, limit);
|
|
57
|
-
const more = files.length > limit ? `\n… (+${files.length - limit} more files)` : "";
|
|
58
|
-
return shown.join("\n") + more;
|
|
59
|
-
}
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { gitStatus, dirSnapshot } from "./projectInfo.ts";
|
|
4
|
-
import { loadMemoryContext } from "../memory/auto.ts";
|
|
5
|
-
|
|
6
|
-
// The system prompt is assembled from modular sections: static segments first,
|
|
7
|
-
// then a dynamic environment block. Static sections (identity, tone, tool
|
|
8
|
-
// policy) come first so they stay byte-stable across turns and cache well; the
|
|
9
|
-
// dynamic environment block (cwd, git status, snapshot) comes last. `buildSystemPrompt`
|
|
10
|
-
// stays a pure synchronous string builder — the only I/O is reading project files and
|
|
11
|
-
// the soft git/dir probes in projectInfo.ts.
|
|
12
|
-
|
|
13
|
-
const IDENTITY = `You are Privateer, a provider-agnostic terminal coding agent. You help with software \
|
|
14
|
-
engineering tasks directly from the user's terminal, with the same working style as a senior \
|
|
15
|
-
engineer pairing over a shared shell.`;
|
|
16
|
-
|
|
17
|
-
const TONE = `Tone and style:
|
|
18
|
-
- Be concise and direct. Minimize preamble and postamble — no "Sure!", no "Here is what I'll do" \
|
|
19
|
-
unless asked. Let your actions and their results speak.
|
|
20
|
-
- Prefer doing over explaining. When a task is clear, use your tools to accomplish it rather than \
|
|
21
|
-
describing how the user could.
|
|
22
|
-
- Keep prose short. Answer the question that was asked; don't volunteer tangents.
|
|
23
|
-
- When you finish a task, stop. Don't summarize work the user just watched you do.`;
|
|
24
|
-
|
|
25
|
-
const SECURITY = `Security:
|
|
26
|
-
- Assist with defensive security, debugging, and legitimate engineering. Refuse to help create or \
|
|
27
|
-
improve malware, exploits aimed at systems the user doesn't own, or other clearly malicious uses.
|
|
28
|
-
- Never expose or exfiltrate secrets. Don't print API keys or credentials you encounter.`;
|
|
29
|
-
|
|
30
|
-
const TOOL_POLICY = `Using your tools:
|
|
31
|
-
- Explore before you change: use 'glob' to find files by name and 'grep' to search contents. \
|
|
32
|
-
Read a file with 'read' before editing it.
|
|
33
|
-
- Prefer 'edit' (exact-string replace) over 'write' for changes to existing files; reserve 'write' \
|
|
34
|
-
for new files or full rewrites.
|
|
35
|
-
- Batch independent reads/searches rather than going one at a time.
|
|
36
|
-
- For multi-step work, call 'todo' to lay out and track the plan; keep exactly one item \
|
|
37
|
-
in_progress and mark items completed as you finish them. This keeps the user oriented.
|
|
38
|
-
- For broad, open-ended search or investigation, delegate to a 'task' sub-agent so the details \
|
|
39
|
-
stay out of the main conversation; it returns just a summary.
|
|
40
|
-
- Use 'bash' for builds, tests, git, and other CLI work. Avoid long-running or interactive commands.
|
|
41
|
-
- Each tool call is a separate model round-trip, so chain related shell steps into one \
|
|
42
|
-
'bash' call with '&&' rather than firing them one at a time (e.g. \
|
|
43
|
-
'git add -A && git commit -m "…" && git push', not three calls). Keep commands you need \
|
|
44
|
-
to inspect the output of (a failing test, a diff you'll act on) separate.
|
|
45
|
-
- Scope a commit to how the user asked. An unqualified "commit" / "commit and push" means the \
|
|
46
|
-
whole working tree (stage all changes) — not just files you touched this turn. A scoped request \
|
|
47
|
-
("commit the README", "commit the screenshot") means stage only what that names. When an \
|
|
48
|
-
unqualified commit would sweep in a lot of unrelated changes, say what you're including in one line \
|
|
49
|
-
before doing it.
|
|
50
|
-
- When you create a git commit, end the message with a blank line followed by this trailer so the \
|
|
51
|
-
work is attributed to Privateer as a co-author:
|
|
52
|
-
Co-Authored-By: Privateer <291203302+privateer-first-mate@users.noreply.github.com>
|
|
53
|
-
- Use 'web_fetch' to read a known URL when the user provides one or you need current docs.
|
|
54
|
-
- When the implementation direction is genuinely ambiguous and the choice materially shapes the \
|
|
55
|
-
work — a structural/architectural fork, which library or data model to adopt, the scope of a \
|
|
56
|
-
change — don't silently guess. Lay out the realistic approaches and call 'ask_user' to let the \
|
|
57
|
-
user pick (2–4 options, most-recommended first, each with its trade-offs). Reserve it for \
|
|
58
|
-
consequential forks: for small, reversible choices, just make the call and proceed. After the \
|
|
59
|
-
user chooses, build that direction without re-litigating it.
|
|
60
|
-
- To try a risky or exploratory approach without disturbing the main tree, use 'worktree' to spin \
|
|
61
|
-
up an isolated branch + working copy, do the work there, and let the user compare its diff before \
|
|
62
|
-
keeping or discarding it.
|
|
63
|
-
- Mutating actions (write/edit/bash) may require user approval; that's expected — proceed and let \
|
|
64
|
-
the gate handle it.`;
|
|
65
|
-
|
|
66
|
-
const MEMORY = `Memory:
|
|
67
|
-
- You have a persistent memory across sessions. When an index of saved memories is \
|
|
68
|
-
present below, treat each line as something you already know; read the named .md file \
|
|
69
|
-
with 'read' when an entry looks relevant to the current task.
|
|
70
|
-
- Use the 'memory' tool to record durable facts worth remembering long-term: stable user \
|
|
71
|
-
preferences, project conventions, and feedback on how to work — not transient details \
|
|
72
|
-
about the current task. Prefer updating an existing memory (reuse its name) over \
|
|
73
|
-
creating a near-duplicate. Default to project scope; use global only for facts that hold \
|
|
74
|
-
across every project.`;
|
|
75
|
-
|
|
76
|
-
const RECAP = `Recaps:
|
|
77
|
-
- End every response with a single final line that begins with "recap: " — one plain-language \
|
|
78
|
-
sentence summarizing what the user has accomplished so far this session (their goals and the \
|
|
79
|
-
changes they've driven), not a restatement of what you just did. Keep it to one line; if nothing \
|
|
80
|
-
has happened yet, recap the user's stated goal.`;
|
|
81
|
-
|
|
82
|
-
const PLAN_MODE = `Plan mode is active. Your write, edit, and bash tools are disabled — do not attempt to \
|
|
83
|
-
modify files or run commands. Investigate with read, glob, and grep, then present a clear, \
|
|
84
|
-
step-by-step implementation plan as your final message. Do not start implementing; wait for the \
|
|
85
|
-
user to approve the plan first.`;
|
|
86
|
-
|
|
87
|
-
export interface SystemPromptOptions {
|
|
88
|
-
cwd: string;
|
|
89
|
-
model: string;
|
|
90
|
-
// Optional output-style body: replaces the default tone/persona section.
|
|
91
|
-
outputStyleBody?: string;
|
|
92
|
-
// When true, append the plan-mode mandate (read-only, produce a plan).
|
|
93
|
-
planMode?: boolean;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// System prompt for a `task` sub-agent: same environment grounding, but a read-only,
|
|
97
|
-
// report-back mandate. It shares the parent's identity/security stance but swaps the
|
|
98
|
-
// tool policy for the restricted subset.
|
|
99
|
-
export function buildSubAgentPrompt(opts: SystemPromptOptions & { description: string }): string {
|
|
100
|
-
return [
|
|
101
|
-
IDENTITY,
|
|
102
|
-
SECURITY,
|
|
103
|
-
`You are running as a read-only sub-agent for the task: "${opts.description}".`,
|
|
104
|
-
`You have read, glob, and grep only — you cannot modify files or run commands. Investigate ` +
|
|
105
|
-
`thoroughly and efficiently, then return a concise, self-contained summary of your findings ` +
|
|
106
|
-
`(reference concrete file paths and line numbers). Do not ask the user questions; you run ` +
|
|
107
|
-
`autonomously and your final message is your whole report.`,
|
|
108
|
-
`Environment:\n- cwd: ${opts.cwd}\n- platform: ${process.platform}`,
|
|
109
|
-
].join("\n\n");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// System prompt for a user-defined sub-agent: the agent's own instructions plus the
|
|
113
|
-
// shared identity/security stance and an autonomous report-back mandate.
|
|
114
|
-
export function buildAgentPrompt(
|
|
115
|
-
opts: SystemPromptOptions & { description: string; instructions: string },
|
|
116
|
-
): string {
|
|
117
|
-
return [
|
|
118
|
-
IDENTITY,
|
|
119
|
-
opts.instructions,
|
|
120
|
-
SECURITY,
|
|
121
|
-
`You are running as a sub-agent for the task: "${opts.description}". Work autonomously and ` +
|
|
122
|
-
`return a concise, self-contained final report (reference concrete file paths). Do not ask ` +
|
|
123
|
-
`the user questions — your final message is your whole report.`,
|
|
124
|
-
`Environment:\n- cwd: ${opts.cwd}\n- platform: ${process.platform}`,
|
|
125
|
-
].join("\n\n");
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function buildSystemPrompt(opts: SystemPromptOptions): string {
|
|
129
|
-
// An active output style replaces the default tone/persona section.
|
|
130
|
-
const persona = opts.outputStyleBody?.trim() || TONE;
|
|
131
|
-
const parts: string[] = [IDENTITY, persona, SECURITY, TOOL_POLICY, MEMORY, RECAP];
|
|
132
|
-
if (opts.planMode) parts.push(PLAN_MODE);
|
|
133
|
-
|
|
134
|
-
// --- Dynamic environment section ---
|
|
135
|
-
const env: string[] = [
|
|
136
|
-
`Environment:`,
|
|
137
|
-
`- cwd: ${opts.cwd}`,
|
|
138
|
-
`- model: ${opts.model}`,
|
|
139
|
-
`- launched in: ${process.cwd()}`,
|
|
140
|
-
`- platform: ${process.platform}`,
|
|
141
|
-
`- date: ${new Date().toISOString().slice(0, 10)}`,
|
|
142
|
-
`\nTreat cwd as the project scope: interpret relative paths from it and keep your ` +
|
|
143
|
-
`exploration, searches, and edits inside it. The file tools are confined to cwd — a path ` +
|
|
144
|
-
`that resolves outside it (an absolute path elsewhere, a sibling directory, or a '../' ` +
|
|
145
|
-
`escape) is blocked unless the user has explicitly asked you to work there, in which case ` +
|
|
146
|
-
`they'll be prompted to approve it. Don't reach outside cwd on your own; if a task seems to ` +
|
|
147
|
-
`need a file outside it, ask the user rather than guessing.`,
|
|
148
|
-
];
|
|
149
|
-
|
|
150
|
-
const git = gitStatus(opts.cwd);
|
|
151
|
-
if (git) {
|
|
152
|
-
env.push(`- git branch: ${git.branch}`);
|
|
153
|
-
env.push(`\nGit status (porcelain):\n${git.status}`);
|
|
154
|
-
if (git.recent) env.push(`\nRecent commits:\n${git.recent}`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const snapshot = dirSnapshot(opts.cwd);
|
|
158
|
-
if (snapshot) env.push(`\nProject files (partial):\n${snapshot}`);
|
|
159
|
-
|
|
160
|
-
parts.push(env.join("\n"));
|
|
161
|
-
|
|
162
|
-
// Project context file, our CLAUDE.md analog. Loaded last so user-authored
|
|
163
|
-
// standing instructions carry the most weight.
|
|
164
|
-
const ctxFile = join(opts.cwd, "PRIVATEER.md");
|
|
165
|
-
if (existsSync(ctxFile)) {
|
|
166
|
-
parts.push(`Project context from PRIVATEER.md:\n${readFileSync(ctxFile, "utf8").trim()}`);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Recalled memory index, our auto-memory analog. Read a listed .md for full detail.
|
|
170
|
-
const memory = loadMemoryContext(opts.cwd);
|
|
171
|
-
if (memory) {
|
|
172
|
-
parts.push(`Persistent memory (index — read a file for detail):\n${memory}`);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return parts.join("\n\n");
|
|
176
|
-
}
|
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
streamText,
|
|
3
|
-
generateText,
|
|
4
|
-
generateObject,
|
|
5
|
-
stepCountIs,
|
|
6
|
-
type ModelMessage,
|
|
7
|
-
type ToolSet,
|
|
8
|
-
} from "ai";
|
|
9
|
-
import { z } from "zod";
|
|
10
|
-
import { type EngineEvent, type UsageTotals, emptyUsage, addUsage } from "./events.ts";
|
|
11
|
-
import { type RouteSet, selectRoute, requiredModalities } from "./router.ts";
|
|
12
|
-
import { redactText } from "../util/redact.ts";
|
|
13
|
-
import { describeError, type DescribedError } from "./errors.ts";
|
|
14
|
-
|
|
15
|
-
// Structured shape for compaction so the summary preserves the parts that matter for
|
|
16
|
-
// continuing the work, rather than a free-form blob.
|
|
17
|
-
const CompactionSchema = z.object({
|
|
18
|
-
goals: z.string().describe("The user's overall goals for this session."),
|
|
19
|
-
decisions: z.array(z.string()).describe("Key decisions, approaches, and findings so far."),
|
|
20
|
-
filesTouched: z.array(z.string()).describe("File paths created or modified, each with a short note."),
|
|
21
|
-
openThreads: z.array(z.string()).describe("Unfinished tasks, next steps, and open questions."),
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export function formatCompaction(o: z.infer<typeof CompactionSchema>): string {
|
|
25
|
-
const list = (items: string[]) => (items.length ? items.map((i) => `- ${i}`).join("\n") : "- (none)");
|
|
26
|
-
return [
|
|
27
|
-
`Goals: ${o.goals}`,
|
|
28
|
-
`Decisions:\n${list(o.decisions)}`,
|
|
29
|
-
`Files touched:\n${list(o.filesTouched)}`,
|
|
30
|
-
`Open threads:\n${list(o.openThreads)}`,
|
|
31
|
-
].join("\n\n");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface QueryEngineOptions {
|
|
35
|
-
// The model routes for this session. `routes.default` is always used unless a
|
|
36
|
-
// turn's data/shape selects a specialized route (see src/engine/router.ts).
|
|
37
|
-
// Per-route flags (cacheControl/thinkingBudget) travel on each Route. Compaction
|
|
38
|
-
// always runs on the default route.
|
|
39
|
-
routes: RouteSet;
|
|
40
|
-
system: string;
|
|
41
|
-
tools: ToolSet;
|
|
42
|
-
maxSteps: number;
|
|
43
|
-
// Approx token budget; when the estimated context exceeds budget*ratio before a
|
|
44
|
-
// turn, older history is summarized away. 0/undefined disables auto-compaction.
|
|
45
|
-
contextBudget?: number;
|
|
46
|
-
compactRatio?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Number of most-recent messages kept verbatim when compacting.
|
|
50
|
-
const KEEP_RECENT = 6;
|
|
51
|
-
|
|
52
|
-
// How many times to auto-retry a turn that failed transiently (rate limit, 5xx,
|
|
53
|
-
// network) before any output streamed. Fatal errors (auth/billing/data-policy/bad
|
|
54
|
-
// model) are never retried — describeError leaves their `retryable` flag unset.
|
|
55
|
-
const MAX_RETRIES = 3;
|
|
56
|
-
|
|
57
|
-
// The agent loop. Each `send` streams one user turn through the model, letting the
|
|
58
|
-
// AI SDK run the multi-step tool loop internally (executing our tools' execute()),
|
|
59
|
-
// while we translate the raw stream into normalized EngineEvents and accumulate usage.
|
|
60
|
-
// History persists on the instance so follow-up turns keep context. A turn can be
|
|
61
|
-
// interrupted via an AbortSignal; partial output is still persisted to history.
|
|
62
|
-
export class QueryEngine {
|
|
63
|
-
readonly messages: ModelMessage[] = [];
|
|
64
|
-
usage: UsageTotals = emptyUsage();
|
|
65
|
-
|
|
66
|
-
constructor(private readonly opts: QueryEngineOptions) {}
|
|
67
|
-
|
|
68
|
-
// Current context-window occupancy: estimated tokens in history over the budget
|
|
69
|
-
// that triggers compaction. Drives the Claude-Code-style "% of context" readout.
|
|
70
|
-
// `budget` is 0 when auto-compaction is disabled.
|
|
71
|
-
contextUsage(): { used: number; budget: number } {
|
|
72
|
-
return { used: estimateTokens(this.messages), budget: this.opts.contextBudget ?? 0 };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async *send(
|
|
76
|
-
userText: string,
|
|
77
|
-
signal?: AbortSignal,
|
|
78
|
-
attachments?: { data: string; mediaType: string; modality?: string }[],
|
|
79
|
-
): AsyncGenerator<EngineEvent, void, void> {
|
|
80
|
-
// Auto-compact before the turn if the context has grown past the budget.
|
|
81
|
-
if (this.shouldCompact()) {
|
|
82
|
-
const res = await this.compact();
|
|
83
|
-
if (res) yield { type: "compacted", before: res.before, after: res.after };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (attachments && attachments.length > 0) {
|
|
87
|
-
this.messages.push({
|
|
88
|
-
role: "user",
|
|
89
|
-
content: [
|
|
90
|
-
{ type: "text", text: userText },
|
|
91
|
-
// Images go as image parts; documents/audio/video as generic file parts.
|
|
92
|
-
...attachments.map((a) =>
|
|
93
|
-
a.modality === "image" || a.mediaType.startsWith("image/")
|
|
94
|
-
? ({ type: "image" as const, image: a.data, mediaType: a.mediaType })
|
|
95
|
-
: ({ type: "file" as const, data: a.data, mediaType: a.mediaType })),
|
|
96
|
-
],
|
|
97
|
-
});
|
|
98
|
-
} else {
|
|
99
|
-
this.messages.push({ role: "user", content: userText });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Pick the model for this turn from its data/shape. Modality requirements are
|
|
103
|
-
// sticky over the whole conversation so attachment history never gets replayed to
|
|
104
|
-
// a model that can't accept it.
|
|
105
|
-
const sel = selectRoute(this.opts.routes, {
|
|
106
|
-
modalities: requiredModalities(this.messages),
|
|
107
|
-
estTokens: estimateTokens(this.messages),
|
|
108
|
-
promptChars: userText.length,
|
|
109
|
-
});
|
|
110
|
-
if (sel.name !== "default" || (sel.missing && sel.missing.length > 0)) {
|
|
111
|
-
yield {
|
|
112
|
-
type: "routed",
|
|
113
|
-
route: sel.name,
|
|
114
|
-
label: sel.route.label,
|
|
115
|
-
reason: sel.reason,
|
|
116
|
-
missing: sel.missing,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
const route = sel.route;
|
|
120
|
-
|
|
121
|
-
let result;
|
|
122
|
-
try {
|
|
123
|
-
result = streamText({
|
|
124
|
-
model: route.model,
|
|
125
|
-
system: this.opts.system,
|
|
126
|
-
messages: route.cacheControl ? withCacheBreakpoints(this.messages) : this.messages,
|
|
127
|
-
tools: this.opts.tools,
|
|
128
|
-
stopWhen: stepCountIs(this.opts.maxSteps),
|
|
129
|
-
abortSignal: signal,
|
|
130
|
-
// Re-place the rolling cache breakpoint on every internal tool-loop step.
|
|
131
|
-
// streamText runs the multi-step loop itself, appending tool-call/result
|
|
132
|
-
// messages between API calls; without this the breakpoint stays on the
|
|
133
|
-
// pre-loop tail, so each step's accumulating tool output is re-sent at full
|
|
134
|
-
// price. Marking the new last message each step caches the prefix the
|
|
135
|
-
// previous step already sent. (No-op for non-Anthropic routes.)
|
|
136
|
-
prepareStep: route.cacheControl
|
|
137
|
-
? ({ messages }) => ({ messages: withCacheBreakpoints(messages) })
|
|
138
|
-
: undefined,
|
|
139
|
-
providerOptions: route.thinkingBudget
|
|
140
|
-
? { anthropic: { thinking: { type: "enabled", budgetTokens: route.thinkingBudget } } }
|
|
141
|
-
: undefined,
|
|
142
|
-
});
|
|
143
|
-
} catch (err) {
|
|
144
|
-
const d = describeError(err);
|
|
145
|
-
yield { type: "error", error: d.message, hint: d.hint, retryable: d.retryable };
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// The AI SDK exposes several derived promises that reject lazily when the
|
|
150
|
-
// stream errors. We await some below in their own try/catch, but any we
|
|
151
|
-
// never touch would surface as an unhandled rejection — which Node dumps,
|
|
152
|
-
// unredacted, to the terminal (scrambling the TUI and leaking the request
|
|
153
|
-
// body). Attach no-op catches so a stream error stays inside our channel.
|
|
154
|
-
for (const key of ["text", "steps", "warnings", "sources", "files", "reasoning"] as const) {
|
|
155
|
-
const p = (result as unknown as Record<string, unknown>)[key];
|
|
156
|
-
if (p && typeof (p as Promise<unknown>).then === "function") {
|
|
157
|
-
(p as Promise<unknown>).catch(() => {});
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
let assistantText = "";
|
|
162
|
-
let aborted = false;
|
|
163
|
-
// Track usage as steps finish so the UI can tick the token count up live,
|
|
164
|
-
// instead of jumping only when the whole turn ends. `totalUsage` reconciles
|
|
165
|
-
// the authoritative number at finish.
|
|
166
|
-
const baseline = this.usage;
|
|
167
|
-
let stepsUsage = emptyUsage();
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
for await (const part of result.fullStream) {
|
|
171
|
-
switch (part.type) {
|
|
172
|
-
case "text-delta":
|
|
173
|
-
if (part.text) {
|
|
174
|
-
assistantText += part.text;
|
|
175
|
-
yield { type: "text", text: part.text };
|
|
176
|
-
}
|
|
177
|
-
break;
|
|
178
|
-
case "reasoning-delta":
|
|
179
|
-
if (part.text) yield { type: "reasoning", text: part.text };
|
|
180
|
-
break;
|
|
181
|
-
case "tool-call":
|
|
182
|
-
yield { type: "tool-call", id: part.toolCallId, name: part.toolName, input: part.input };
|
|
183
|
-
break;
|
|
184
|
-
case "tool-result":
|
|
185
|
-
yield {
|
|
186
|
-
type: "tool-result",
|
|
187
|
-
id: part.toolCallId,
|
|
188
|
-
name: part.toolName,
|
|
189
|
-
output: (part as { output: unknown }).output,
|
|
190
|
-
};
|
|
191
|
-
break;
|
|
192
|
-
case "tool-error":
|
|
193
|
-
yield {
|
|
194
|
-
type: "tool-error",
|
|
195
|
-
id: part.toolCallId,
|
|
196
|
-
name: part.toolName,
|
|
197
|
-
error: errMsg((part as { error: unknown }).error),
|
|
198
|
-
};
|
|
199
|
-
break;
|
|
200
|
-
case "finish-step": {
|
|
201
|
-
const u = (part as { usage?: Partial<UsageTotals> }).usage;
|
|
202
|
-
if (u) {
|
|
203
|
-
stepsUsage = addUsage(stepsUsage, {
|
|
204
|
-
inputTokens: u.inputTokens ?? 0,
|
|
205
|
-
outputTokens: u.outputTokens ?? 0,
|
|
206
|
-
totalTokens: u.totalTokens ?? 0,
|
|
207
|
-
cachedInputTokens: u.cachedInputTokens ?? 0,
|
|
208
|
-
});
|
|
209
|
-
this.usage = addUsage(baseline, stepsUsage);
|
|
210
|
-
yield { type: "usage", usage: this.usage, turn: stepsUsage };
|
|
211
|
-
}
|
|
212
|
-
yield { type: "step-finish" };
|
|
213
|
-
break;
|
|
214
|
-
}
|
|
215
|
-
case "abort":
|
|
216
|
-
aborted = true;
|
|
217
|
-
break;
|
|
218
|
-
case "error": {
|
|
219
|
-
const d = describeError(part.error);
|
|
220
|
-
yield { type: "error", error: d.message, hint: d.hint, retryable: d.retryable };
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
} catch (err) {
|
|
226
|
-
if (signal?.aborted || isAbortError(err)) {
|
|
227
|
-
aborted = true;
|
|
228
|
-
} else {
|
|
229
|
-
const d = describeError(err);
|
|
230
|
-
yield { type: "error", error: d.message, hint: d.hint, retryable: d.retryable };
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Persist the model's response so the next turn keeps context. On a clean finish
|
|
236
|
-
// we use the SDK's structured messages; on an interrupt those may be unavailable,
|
|
237
|
-
// so we fall back to a synthetic assistant message from the text we streamed.
|
|
238
|
-
let persisted = false;
|
|
239
|
-
try {
|
|
240
|
-
const response = await result.response;
|
|
241
|
-
if (response?.messages?.length) {
|
|
242
|
-
this.messages.push(...response.messages);
|
|
243
|
-
persisted = true;
|
|
244
|
-
}
|
|
245
|
-
} catch {
|
|
246
|
-
/* aborted/errored before a response was assembled */
|
|
247
|
-
}
|
|
248
|
-
if (!persisted && assistantText.trim()) {
|
|
249
|
-
this.messages.push({ role: "assistant", content: assistantText });
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (aborted) {
|
|
253
|
-
yield { type: "aborted" };
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const turnUsage = await result.totalUsage.catch(() => ({}) as Record<string, number>);
|
|
258
|
-
const usage: UsageTotals = {
|
|
259
|
-
inputTokens: turnUsage.inputTokens ?? 0,
|
|
260
|
-
outputTokens: turnUsage.outputTokens ?? 0,
|
|
261
|
-
totalTokens: turnUsage.totalTokens ?? 0,
|
|
262
|
-
cachedInputTokens: turnUsage.cachedInputTokens ?? 0,
|
|
263
|
-
};
|
|
264
|
-
// Reconcile against the authoritative turn total. We already folded per-step
|
|
265
|
-
// usage into this.usage live; rebase on the baseline so we don't double-count.
|
|
266
|
-
// Fall back to the accumulated step usage if the provider omitted totalUsage.
|
|
267
|
-
this.usage = addUsage(baseline, usage.totalTokens > 0 ? usage : stepsUsage);
|
|
268
|
-
|
|
269
|
-
const finishReason = await result.finishReason.catch(() => "unknown");
|
|
270
|
-
yield { type: "finish", usage, finishReason };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private shouldCompact(): boolean {
|
|
274
|
-
const budget = this.opts.contextBudget;
|
|
275
|
-
if (!budget) return false;
|
|
276
|
-
const ratio = this.opts.compactRatio ?? 0.8;
|
|
277
|
-
return this.messages.length > KEEP_RECENT && estimateTokens(this.messages) > budget * ratio;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Summarize older history into a single briefing message, keeping the most recent
|
|
281
|
-
// messages verbatim. Uses a schema-guided summary (goals / decisions / files /
|
|
282
|
-
// open threads) so the structure survives, falling back to a plain-text summary if
|
|
283
|
-
// structured output fails. The cut always lands on a `user` message so tool-call /
|
|
284
|
-
// result pairs are never orphaned. Returns before/after token estimates, or null
|
|
285
|
-
// when there's nothing worth compacting. Best-effort: failures leave history intact.
|
|
286
|
-
async compact(): Promise<{ before: number; after: number } | null> {
|
|
287
|
-
const before = estimateTokens(this.messages);
|
|
288
|
-
const cut = safeCutIndex(this.messages, KEEP_RECENT);
|
|
289
|
-
if (cut <= 0) return null;
|
|
290
|
-
|
|
291
|
-
const older = this.messages.slice(0, cut);
|
|
292
|
-
const recent = this.messages.slice(cut);
|
|
293
|
-
const transcript = older.map((m) => `${m.role}: ${renderContent(m.content)}`).join("\n\n");
|
|
294
|
-
const instruction =
|
|
295
|
-
`Summarize the earlier part of this coding session so the work can continue without the ` +
|
|
296
|
-
`full history. Be specific and terse.\n\n---\n${transcript}`;
|
|
297
|
-
|
|
298
|
-
let summary: string;
|
|
299
|
-
try {
|
|
300
|
-
const { object } = await generateObject({
|
|
301
|
-
model: this.opts.routes.default.model,
|
|
302
|
-
schema: CompactionSchema,
|
|
303
|
-
prompt: instruction,
|
|
304
|
-
});
|
|
305
|
-
summary = formatCompaction(object);
|
|
306
|
-
} catch {
|
|
307
|
-
// Some models/providers handle structured output poorly — fall back to text.
|
|
308
|
-
try {
|
|
309
|
-
const { text } = await generateText({ model: this.opts.routes.default.model, prompt: instruction });
|
|
310
|
-
summary = text.trim();
|
|
311
|
-
} catch {
|
|
312
|
-
return null; // leave history untouched on failure
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
if (!summary) return null;
|
|
316
|
-
|
|
317
|
-
this.messages.length = 0;
|
|
318
|
-
this.messages.push({ role: "user", content: `[Summary of earlier conversation]\n${summary}` });
|
|
319
|
-
this.messages.push(...recent);
|
|
320
|
-
|
|
321
|
-
return { before, after: estimateTokens(this.messages) };
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Cheap heuristic token estimate (~4 chars/token) over serialized message content.
|
|
326
|
-
export function estimateTokens(messages: ModelMessage[]): number {
|
|
327
|
-
let chars = 0;
|
|
328
|
-
for (const m of messages) chars += renderContent(m.content).length + m.role.length;
|
|
329
|
-
return Math.ceil(chars / 4);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Choose a cut so the kept tail starts on a `user` message — never orphaning a tool
|
|
333
|
-
// result from its tool-call. Returns 0 when there's nothing safe to drop.
|
|
334
|
-
function safeCutIndex(messages: ModelMessage[], minKeep: number): number {
|
|
335
|
-
let cut = messages.length - minKeep;
|
|
336
|
-
if (cut <= 0) return 0;
|
|
337
|
-
while (cut < messages.length && messages[cut].role !== "user") cut++;
|
|
338
|
-
return cut >= messages.length ? 0 : cut;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function renderContent(content: unknown): string {
|
|
342
|
-
if (typeof content === "string") return content;
|
|
343
|
-
if (Array.isArray(content)) {
|
|
344
|
-
return content
|
|
345
|
-
.map((p) => {
|
|
346
|
-
const part = p as { type?: string; text?: string; toolName?: string };
|
|
347
|
-
if (part.type === "text" && part.text) return part.text;
|
|
348
|
-
if (part.type === "tool-call") return `[tool-call ${part.toolName ?? ""}]`;
|
|
349
|
-
if (part.type === "tool-result") return `[tool-result ${part.toolName ?? ""}]`;
|
|
350
|
-
return `[${part.type ?? "part"}]`;
|
|
351
|
-
})
|
|
352
|
-
.join(" ");
|
|
353
|
-
}
|
|
354
|
-
return "";
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Attach Anthropic ephemeral cache breakpoints. Anthropic caches the longest prefix
|
|
358
|
-
// ending at a breakpoint, so we mark the first message (stable base: system + tools +
|
|
359
|
-
// first turn) and the last message (rolling: grows with the conversation). Returns a
|
|
360
|
-
// shallow copy so the stored history stays free of provider-specific annotations.
|
|
361
|
-
const CACHE = { anthropic: { cacheControl: { type: "ephemeral" } } } as const;
|
|
362
|
-
|
|
363
|
-
function withCacheBreakpoints(messages: ModelMessage[]): ModelMessage[] {
|
|
364
|
-
if (messages.length === 0) return messages;
|
|
365
|
-
const out = messages.slice();
|
|
366
|
-
markBreakpoint(out, 0);
|
|
367
|
-
if (out.length > 1) markBreakpoint(out, out.length - 1);
|
|
368
|
-
return out;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function markBreakpoint(messages: ModelMessage[], i: number): void {
|
|
372
|
-
const msg = messages[i] as { role: string; content: unknown };
|
|
373
|
-
const parts =
|
|
374
|
-
typeof msg.content === "string"
|
|
375
|
-
? [{ type: "text", text: msg.content }]
|
|
376
|
-
: (msg.content as unknown[]).slice();
|
|
377
|
-
if (parts.length === 0) return;
|
|
378
|
-
const last = parts.length - 1;
|
|
379
|
-
parts[last] = { ...(parts[last] as object), providerOptions: CACHE };
|
|
380
|
-
messages[i] = { ...msg, content: parts } as unknown as ModelMessage;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function isAbortError(err: unknown): boolean {
|
|
384
|
-
return err instanceof Error && (err.name === "AbortError" || /abort/i.test(err.message));
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function errMsg(err: unknown): string {
|
|
388
|
-
return redactText(rawErrMsg(err));
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function rawErrMsg(err: unknown): string {
|
|
392
|
-
if (err instanceof Error) return err.message;
|
|
393
|
-
if (typeof err === "string") return err;
|
|
394
|
-
try {
|
|
395
|
-
return JSON.stringify(err);
|
|
396
|
-
} catch {
|
|
397
|
-
return String(err);
|
|
398
|
-
}
|
|
399
|
-
}
|