askvireo 0.1.2 → 0.1.4
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/dist/cli-model.js +750 -0
- package/dist/cli-model.js.map +1 -0
- package/dist/cli.js +0 -0
- package/dist/hook.js +3 -0
- package/dist/hook.js.map +1 -1
- package/dist/http/settings.js +6 -1
- package/dist/http/settings.js.map +1 -1
- package/dist/models.js +113 -7
- package/dist/models.js.map +1 -1
- package/dist/plugins/agents/index.js +20 -7
- package/dist/plugins/agents/index.js.map +1 -1
- package/dist/plugins/google/index.js +46 -14
- package/dist/plugins/google/index.js.map +1 -1
- package/dist/plugins/index.js +8 -3
- package/dist/plugins/index.js.map +1 -1
- package/dist/runner.js +1 -1
- package/dist/runner.js.map +1 -1
- package/dist/tools/setup.js +5 -3
- package/dist/tools/setup.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { delimiter, dirname, join } from "node:path";
|
|
6
|
+
import { agentEnv } from "./acp.js";
|
|
7
|
+
import { newId } from "./util.js";
|
|
8
|
+
/**
|
|
9
|
+
* Vireo's model, served by a coding-agent CLI on this machine (Claude Code,
|
|
10
|
+
* Codex or pi) and whatever account it is signed in to.
|
|
11
|
+
*
|
|
12
|
+
* This is not ACP. ACP drives a coding agent as an agent: its own tools, its
|
|
13
|
+
* own system prompt, its own loop — what the Coding agents plugin does. Here
|
|
14
|
+
* the CLI is only the model: Vireo keeps its agents, handoffs, tools and
|
|
15
|
+
* confirmations, and asks the CLI for one turn at a time with its built-in
|
|
16
|
+
* tools switched off. Each CLI's headless mode does that directly (a system
|
|
17
|
+
* prompt of Vireo's, no tools, streamed JSON with token counts), which ACP
|
|
18
|
+
* has no way to ask for.
|
|
19
|
+
*
|
|
20
|
+
* The bridge speaks Chat Completions on a loopback port, like the scripted
|
|
21
|
+
* test model, so the rest of Vireo talks to it exactly as it would to any
|
|
22
|
+
* OpenAI-compatible endpoint. Vireo's tools are declared to the CLI as real
|
|
23
|
+
* tools that do nothing (an extension for pi, an MCP server on the bridge
|
|
24
|
+
* for Claude Code and Codex), so the model calls them in its own native
|
|
25
|
+
* format. The turn ends at the first batch of calls, which come back as Chat
|
|
26
|
+
* Completions tool calls for Vireo to run. Calls written as <tool_call> text
|
|
27
|
+
* are still read, for a model that writes them anyway.
|
|
28
|
+
*/
|
|
29
|
+
/** The model name that means "whatever the CLI itself is set to use". */
|
|
30
|
+
export const CLI_DEFAULT_MODEL = "default";
|
|
31
|
+
/** The MCP server's name in the CLIs' configuration, and so in Claude Code's tool names. */
|
|
32
|
+
const MCP_NAME = "vireo";
|
|
33
|
+
const argsJson = (v) => (typeof v === "string" ? v : JSON.stringify(v ?? {}));
|
|
34
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
35
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
36
|
+
const obj = (v) => (v && typeof v === "object" ? v : {});
|
|
37
|
+
/** Claude Code's own settings file for the owner (~/.claude/settings.json, or under CLAUDE_CONFIG_DIR). */
|
|
38
|
+
function claudeUserSettings() {
|
|
39
|
+
try {
|
|
40
|
+
return obj(JSON.parse(readFileSync(join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json"), "utf8")));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const claude = {
|
|
47
|
+
id: "claude",
|
|
48
|
+
name: "Claude Code",
|
|
49
|
+
bin: "claude",
|
|
50
|
+
install: "npm install -g @anthropic-ai/claude-code",
|
|
51
|
+
signIn: "claude",
|
|
52
|
+
defaults: { main: CLI_DEFAULT_MODEL, fast: "haiku" },
|
|
53
|
+
ownsSystemPrompt: true,
|
|
54
|
+
args: ({ model, systemFile, tools }) => [
|
|
55
|
+
"-p",
|
|
56
|
+
"--output-format",
|
|
57
|
+
"stream-json",
|
|
58
|
+
"--verbose",
|
|
59
|
+
"--include-partial-messages",
|
|
60
|
+
"--no-session-persistence",
|
|
61
|
+
// No built-in tools, no MCP servers but Vireo's, no hooks: Vireo's own tools are the only ones.
|
|
62
|
+
"--tools",
|
|
63
|
+
"",
|
|
64
|
+
"--strict-mcp-config",
|
|
65
|
+
...(tools.mcpUrl ? ["--mcp-config", JSON.stringify({ mcpServers: { [MCP_NAME]: { type: "http", url: tools.mcpUrl } } })] : []),
|
|
66
|
+
// None of the owner's settings, CLAUDE.md or plugins: they are for the owner's own sessions, and
|
|
67
|
+
// steer (and bloat) a turn of Vireo's. Only how to reach the model is kept (see env below).
|
|
68
|
+
"--setting-sources",
|
|
69
|
+
"",
|
|
70
|
+
"--settings",
|
|
71
|
+
JSON.stringify({ disableAllHooks: true, ...(str(claudeUserSettings().apiKeyHelper) ? { apiKeyHelper: claudeUserSettings().apiKeyHelper } : {}) }),
|
|
72
|
+
"--system-prompt-file",
|
|
73
|
+
systemFile,
|
|
74
|
+
...(model && model !== CLI_DEFAULT_MODEL ? ["--model", model] : []),
|
|
75
|
+
],
|
|
76
|
+
// The settings' env often holds the endpoint, token or CA certificates the account needs.
|
|
77
|
+
env: () => Object.fromEntries(Object.entries(obj(claudeUserSettings().env)).filter((e) => typeof e[1] === "string")),
|
|
78
|
+
models: async () => ["opus", "sonnet", "haiku"],
|
|
79
|
+
reader: () => {
|
|
80
|
+
let streamed = false;
|
|
81
|
+
return {
|
|
82
|
+
push(l, out) {
|
|
83
|
+
if (l.type === "stream_event") {
|
|
84
|
+
const ev = obj(l.event);
|
|
85
|
+
const delta = obj(ev.delta);
|
|
86
|
+
if (ev.type === "content_block_delta" && delta.type === "text_delta" && str(delta.text)) {
|
|
87
|
+
streamed = true;
|
|
88
|
+
out.text += delta.text;
|
|
89
|
+
out.onDelta(delta.text);
|
|
90
|
+
}
|
|
91
|
+
else if (ev.type === "message_delta" && obj(ev.delta).stop_reason === "tool_use") {
|
|
92
|
+
// Claude Code would refuse the calls (they aren't allowed) and go on; the turn ends here.
|
|
93
|
+
const u = obj(ev.usage);
|
|
94
|
+
out.usage = { input: num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens), output: num(u.output_tokens), cached: num(u.cache_read_input_tokens), cacheWrite: num(u.cache_creation_input_tokens) };
|
|
95
|
+
out.done = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (l.type === "assistant" && !l.parent_tool_use_id) {
|
|
99
|
+
const prefix = `mcp__${MCP_NAME}__`;
|
|
100
|
+
for (const c of obj(l.message).content ?? []) {
|
|
101
|
+
const b = obj(c);
|
|
102
|
+
if (b.type === "tool_use" && str(b.name)?.startsWith(prefix) && !out.calls.some((x) => x.id === b.id)) {
|
|
103
|
+
out.calls.push({ id: str(b.id) ?? newId("call"), name: b.name.slice(prefix.length), arguments: argsJson(b.input) });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (l.type === "result") {
|
|
108
|
+
const u = obj(l.usage);
|
|
109
|
+
out.usage = { input: num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens), output: num(u.output_tokens), cached: num(u.cache_read_input_tokens), cacheWrite: num(u.cache_creation_input_tokens) };
|
|
110
|
+
if (l.is_error)
|
|
111
|
+
out.error = str(l.result) || str(l.subtype) || "Claude Code reported an error";
|
|
112
|
+
else if (!streamed && str(l.result)) {
|
|
113
|
+
out.text = l.result;
|
|
114
|
+
out.onDelta(out.text);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const codex = {
|
|
122
|
+
id: "codex",
|
|
123
|
+
name: "Codex",
|
|
124
|
+
bin: "codex",
|
|
125
|
+
install: "npm install -g @openai/codex",
|
|
126
|
+
signIn: "codex login",
|
|
127
|
+
defaults: { main: CLI_DEFAULT_MODEL, fast: CLI_DEFAULT_MODEL },
|
|
128
|
+
ownsSystemPrompt: false,
|
|
129
|
+
// Codex starts a batch's calls one after another and never says the batch is complete.
|
|
130
|
+
// Known gap: it reports usage only at turn.completed, which a turn ending in calls never reaches,
|
|
131
|
+
// so those turns are recorded with no tokens.
|
|
132
|
+
callsSettleMs: 500,
|
|
133
|
+
args: ({ model, cwd, tools }) => [
|
|
134
|
+
"exec",
|
|
135
|
+
"--json",
|
|
136
|
+
"--ephemeral",
|
|
137
|
+
"--skip-git-repo-check",
|
|
138
|
+
"--sandbox",
|
|
139
|
+
"read-only",
|
|
140
|
+
"-C",
|
|
141
|
+
cwd,
|
|
142
|
+
// Its own tools off as far as this version allows (unknown switches are ignored), and no
|
|
143
|
+
// `notify` hook: every model turn would otherwise tell the owner "Codex finished".
|
|
144
|
+
"-c",
|
|
145
|
+
"features.shell_tool=false",
|
|
146
|
+
"-c",
|
|
147
|
+
'web_search="disabled"',
|
|
148
|
+
"-c",
|
|
149
|
+
"features.apps=false",
|
|
150
|
+
"-c",
|
|
151
|
+
"features.plugins=false",
|
|
152
|
+
"-c",
|
|
153
|
+
"features.browser_use=false",
|
|
154
|
+
"-c",
|
|
155
|
+
"features.computer_use=false",
|
|
156
|
+
"-c",
|
|
157
|
+
"notify=[]",
|
|
158
|
+
...(tools.mcpUrl ? ["-c", `mcp_servers.${MCP_NAME}.url=${JSON.stringify(tools.mcpUrl)}`] : []),
|
|
159
|
+
...(model && model !== CLI_DEFAULT_MODEL ? ["-m", model] : []),
|
|
160
|
+
"-",
|
|
161
|
+
],
|
|
162
|
+
models: async () => {
|
|
163
|
+
// Codex keeps the models it last saw for this account; the names are all we need.
|
|
164
|
+
const home = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
165
|
+
try {
|
|
166
|
+
const raw = JSON.parse(readFileSync(join(home, "models_cache.json"), "utf8"));
|
|
167
|
+
const list = Array.isArray(raw) ? raw : obj(raw).models ?? [];
|
|
168
|
+
return [...new Set(list.map((m) => str(obj(m).slug) ?? str(obj(m).id) ?? str(m)).filter((s) => Boolean(s)))];
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
reader: () => ({
|
|
175
|
+
push(l, out) {
|
|
176
|
+
if (l.type === "item.completed") {
|
|
177
|
+
const item = obj(l.item);
|
|
178
|
+
if (item.type === "agent_message" && str(item.text)) {
|
|
179
|
+
const add = (out.text ? "\n\n" : "") + item.text;
|
|
180
|
+
out.text += add;
|
|
181
|
+
out.onDelta(add);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (l.type === "item.started") {
|
|
185
|
+
// The call never gets an answer from the bridge; the turn ends once the batch is in.
|
|
186
|
+
const item = obj(l.item);
|
|
187
|
+
if (item.type === "mcp_tool_call" && item.server === MCP_NAME && str(item.tool)) {
|
|
188
|
+
out.calls.push({ id: newId("call"), name: item.tool, arguments: argsJson(item.arguments) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (l.type === "turn.completed") {
|
|
192
|
+
const u = obj(l.usage);
|
|
193
|
+
out.usage = { input: num(u.input_tokens), output: num(u.output_tokens), cached: num(u.cached_input_tokens), cacheWrite: 0 };
|
|
194
|
+
}
|
|
195
|
+
else if (l.type === "turn.failed") {
|
|
196
|
+
out.error = str(obj(l.error).message) ?? "Codex could not finish the turn";
|
|
197
|
+
}
|
|
198
|
+
else if (l.type === "error" && !out.text) {
|
|
199
|
+
out.error = str(l.message) ?? out.error;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
const pi = {
|
|
205
|
+
id: "pi",
|
|
206
|
+
name: "pi",
|
|
207
|
+
bin: "pi",
|
|
208
|
+
install: "npm install -g @earendil-works/pi-coding-agent",
|
|
209
|
+
signIn: "pi",
|
|
210
|
+
defaults: { main: CLI_DEFAULT_MODEL, fast: CLI_DEFAULT_MODEL },
|
|
211
|
+
ownsSystemPrompt: true,
|
|
212
|
+
args: ({ model, systemFile, tools }) => [
|
|
213
|
+
"-p",
|
|
214
|
+
"--mode",
|
|
215
|
+
"json",
|
|
216
|
+
"--no-session",
|
|
217
|
+
// Only Vireo's tools, from an extension of stand-ins (other extensions still load: some are providers).
|
|
218
|
+
...(tools.names.length ? ["-e", tools.extFile, "--tools", tools.names.join(",")] : ["--no-tools"]),
|
|
219
|
+
"--no-skills",
|
|
220
|
+
"--no-prompt-templates",
|
|
221
|
+
"--no-context-files",
|
|
222
|
+
"--no-mcp",
|
|
223
|
+
"--system-prompt",
|
|
224
|
+
systemFile,
|
|
225
|
+
...(model && model !== CLI_DEFAULT_MODEL ? ["--model", model] : []),
|
|
226
|
+
],
|
|
227
|
+
models: (path) => new Promise((resolve) => {
|
|
228
|
+
execFile(path, ["--list-models"], { timeout: 15_000, env: cliEnv(path) }, (_err, stdout) => {
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const line of String(stdout ?? "").split("\n").slice(1)) {
|
|
231
|
+
const [provider, model] = line.trim().split(/\s+/);
|
|
232
|
+
if (provider && model)
|
|
233
|
+
out.push(`${provider}/${model}`);
|
|
234
|
+
}
|
|
235
|
+
resolve(out);
|
|
236
|
+
});
|
|
237
|
+
}),
|
|
238
|
+
reader: () => ({
|
|
239
|
+
push(l, out) {
|
|
240
|
+
if (l.type === "message_update") {
|
|
241
|
+
const ev = obj(l.assistantMessageEvent);
|
|
242
|
+
if (ev.type === "text_delta" && str(ev.delta)) {
|
|
243
|
+
out.text += ev.delta;
|
|
244
|
+
out.onDelta(ev.delta);
|
|
245
|
+
}
|
|
246
|
+
else if (ev.type === "toolcall_end") {
|
|
247
|
+
const c = obj(ev.toolCall);
|
|
248
|
+
if (str(c.name))
|
|
249
|
+
out.calls.push({ id: str(c.id) ?? newId("call"), name: c.name, arguments: argsJson(c.arguments) });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else if (l.type === "message_end") {
|
|
253
|
+
const m = obj(l.message);
|
|
254
|
+
if (m.role !== "assistant")
|
|
255
|
+
return;
|
|
256
|
+
const u = obj(m.usage);
|
|
257
|
+
out.usage = { input: num(u.input) + num(u.cacheRead) + num(u.cacheWrite), output: num(u.output), cached: num(u.cacheRead), cacheWrite: num(u.cacheWrite) };
|
|
258
|
+
if (m.stopReason === "error" || m.stopReason === "aborted")
|
|
259
|
+
out.error = str(m.errorMessage) ?? "pi reported an error";
|
|
260
|
+
else if (out.calls.length)
|
|
261
|
+
out.done = true;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
};
|
|
266
|
+
export const CLIS = { claude, codex, pi };
|
|
267
|
+
export const CLI_ORDER = ["claude", "codex", "pi"];
|
|
268
|
+
// ---------------------------------------------------------------- detection
|
|
269
|
+
/** Where these CLIs install themselves, beyond PATH (a background service often has a short PATH). */
|
|
270
|
+
function searchDirs() {
|
|
271
|
+
const home = homedir();
|
|
272
|
+
const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
273
|
+
dirs.push(join(home, ".local", "bin"), join(home, ".claude", "local"), join(home, ".npm-global", "bin"), join(home, ".bun", "bin"), join(home, ".volta", "bin"), join(home, "bin"), "/opt/homebrew/bin", "/usr/local/bin", dirname(process.execPath));
|
|
274
|
+
try {
|
|
275
|
+
const nvm = join(home, ".nvm", "versions", "node");
|
|
276
|
+
for (const v of readdirSync(nvm))
|
|
277
|
+
dirs.push(join(nvm, v, "bin"));
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// No nvm.
|
|
281
|
+
}
|
|
282
|
+
return [...new Set(dirs)];
|
|
283
|
+
}
|
|
284
|
+
function executable(path) {
|
|
285
|
+
try {
|
|
286
|
+
accessSync(path, constants.X_OK);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
export function findCli(id) {
|
|
294
|
+
for (const dir of searchDirs()) {
|
|
295
|
+
const p = join(dir, CLIS[id].bin);
|
|
296
|
+
if (executable(p))
|
|
297
|
+
return p;
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
/** PATH for a CLI: its own directory and Node's, since most of them are Node scripts. */
|
|
302
|
+
function cliEnv(path, extra = {}) {
|
|
303
|
+
const env = agentEnv(process.env, extra);
|
|
304
|
+
env.PATH = [dirname(path), dirname(process.execPath), env.PATH].filter(Boolean).join(delimiter);
|
|
305
|
+
return env;
|
|
306
|
+
}
|
|
307
|
+
function version(path) {
|
|
308
|
+
return new Promise((resolve) => {
|
|
309
|
+
execFile(path, ["--version"], { timeout: 15_000, env: cliEnv(path) }, (err, stdout, stderr) => {
|
|
310
|
+
const out = String(stdout ?? "").trim() || String(stderr ?? "").trim();
|
|
311
|
+
if (err && !out)
|
|
312
|
+
resolve({ error: err.message });
|
|
313
|
+
// "2.1.3 (Claude Code)", "codex-cli 0.46.0", "1.2.0": the number is enough.
|
|
314
|
+
else
|
|
315
|
+
resolve({ version: out.match(/\d+\.\d+[\w.-]*/)?.[0] ?? out.split("\n")[0].slice(0, 40) });
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** Looks for each CLI (or uses the path the owner gave) and asks it for its version and models. */
|
|
320
|
+
export async function detectClis(paths = {}) {
|
|
321
|
+
return Promise.all(CLI_ORDER.map(async (id) => {
|
|
322
|
+
const spec = CLIS[id];
|
|
323
|
+
const base = { id, name: spec.name, install: spec.install, signIn: spec.signIn, defaults: spec.defaults, models: [] };
|
|
324
|
+
const path = paths[id]?.trim() || findCli(id);
|
|
325
|
+
if (!path)
|
|
326
|
+
return { ...base, found: false };
|
|
327
|
+
if (!executable(path))
|
|
328
|
+
return { ...base, found: false, path, error: `${path} is not an executable file` };
|
|
329
|
+
const v = await version(path);
|
|
330
|
+
if (v.error)
|
|
331
|
+
return { ...base, found: false, path, error: v.error };
|
|
332
|
+
const models = await spec.models(path).catch(() => []);
|
|
333
|
+
return { ...base, found: true, path, version: v.version, models };
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
function partsText(content) {
|
|
337
|
+
if (!content)
|
|
338
|
+
return "";
|
|
339
|
+
if (typeof content === "string")
|
|
340
|
+
return content;
|
|
341
|
+
return content.map((p) => (p.type === "text" ? (p.text ?? "") : p.type === "image_url" ? "[An image was attached here; it can't be shown through this model.]" : "")).join("");
|
|
342
|
+
}
|
|
343
|
+
const TOOL_RULES = [
|
|
344
|
+
"# Tools",
|
|
345
|
+
"",
|
|
346
|
+
"You can't use any tools of your own here (no shell, files, web or editing). You act only through the tools you are given, which are Vireo's.",
|
|
347
|
+
"",
|
|
348
|
+
"- Call them the usual way. Several calls in one turn are fine, and so is a short sentence to the owner before them.",
|
|
349
|
+
"- After your calls, stop: Vireo runs them and the results come back in the next message.",
|
|
350
|
+
"- Earlier calls and their results appear in the conversation as <tool_call> and <tool_result> blocks. That is only how the record is written; never write a result yourself.",
|
|
351
|
+
"- When you need no tool, answer the owner in plain text.",
|
|
352
|
+
];
|
|
353
|
+
/** Vireo's system prompt, plus how its tools work here. */
|
|
354
|
+
export function systemText(req) {
|
|
355
|
+
const system = req.messages.filter((m) => m.role === "system" || m.role === "developer").map((m) => partsText(m.content));
|
|
356
|
+
if (offeredTools(req).length)
|
|
357
|
+
system.push(TOOL_RULES.join("\n"));
|
|
358
|
+
return system.join("\n\n").trim();
|
|
359
|
+
}
|
|
360
|
+
/** The tools the model may call this turn. */
|
|
361
|
+
export function offeredTools(req) {
|
|
362
|
+
return req.tool_choice === "none" ? [] : (req.tools ?? []).filter((t) => /^[A-Za-z0-9_-]{1,64}$/.test(t.function.name));
|
|
363
|
+
}
|
|
364
|
+
/** A pi extension that registers the tools as stand-ins: pi never runs a call, it only reports it and stops. */
|
|
365
|
+
export function piExtension(tools) {
|
|
366
|
+
const list = tools.map((t) => ({ name: t.function.name, description: t.function.description ?? "", parameters: t.function.parameters ?? { type: "object", properties: {} } }));
|
|
367
|
+
return `const TOOLS = ${JSON.stringify(list)};
|
|
368
|
+
export default function (pi) {
|
|
369
|
+
for (const t of TOOLS) {
|
|
370
|
+
pi.registerTool({
|
|
371
|
+
name: t.name,
|
|
372
|
+
label: t.name,
|
|
373
|
+
description: t.description,
|
|
374
|
+
parameters: t.parameters,
|
|
375
|
+
execute: async () => ({ content: [{ type: "text", text: "Vireo runs this call." }], details: undefined, terminate: true }),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
`;
|
|
380
|
+
}
|
|
381
|
+
const esc = (s) => s.replace(/<(\/?)(owner|assistant|tool_call|tool_result|conversation)\b/g, "<\\$1$2");
|
|
382
|
+
/** The conversation as one prompt: just the message when there is one, else a transcript to continue. */
|
|
383
|
+
export function promptText(req) {
|
|
384
|
+
const turns = req.messages.filter((m) => m.role !== "system" && m.role !== "developer");
|
|
385
|
+
if (turns.length === 1 && turns[0].role === "user")
|
|
386
|
+
return partsText(turns[0].content);
|
|
387
|
+
const names = new Map();
|
|
388
|
+
const lines = ["<conversation>"];
|
|
389
|
+
for (const m of turns) {
|
|
390
|
+
if (m.role === "user")
|
|
391
|
+
lines.push(`<owner>\n${esc(partsText(m.content))}\n</owner>`);
|
|
392
|
+
else if (m.role === "assistant") {
|
|
393
|
+
const text = partsText(m.content).trim();
|
|
394
|
+
const calls = (m.tool_calls ?? []).map((c) => {
|
|
395
|
+
names.set(c.id, c.function.name);
|
|
396
|
+
let args = {};
|
|
397
|
+
try {
|
|
398
|
+
args = JSON.parse(c.function.arguments || "{}");
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
args = c.function.arguments;
|
|
402
|
+
}
|
|
403
|
+
return `<tool_call>${JSON.stringify({ name: c.function.name, arguments: args })}</tool_call>`;
|
|
404
|
+
});
|
|
405
|
+
lines.push(`<assistant>\n${[esc(text), ...calls].filter(Boolean).join("\n")}\n</assistant>`);
|
|
406
|
+
}
|
|
407
|
+
else if (m.role === "tool") {
|
|
408
|
+
const name = names.get(m.tool_call_id ?? "") ?? "";
|
|
409
|
+
lines.push(`<tool_result name="${name}">\n${esc(partsText(m.content))}\n</tool_result>`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
lines.push("</conversation>", "", "Continue as the assistant: write only your next turn, plain text and/or tool calls.");
|
|
413
|
+
return lines.join("\n");
|
|
414
|
+
}
|
|
415
|
+
// ---------------------------------------------------------------- reply
|
|
416
|
+
const OPEN = "<tool_call>";
|
|
417
|
+
const CLOSE = "</tool_call>";
|
|
418
|
+
/** Passes reply text through as it streams, holding back the <tool_call> blocks. */
|
|
419
|
+
export class CallFilter {
|
|
420
|
+
buf = "";
|
|
421
|
+
inCall = false;
|
|
422
|
+
push(chunk) {
|
|
423
|
+
this.buf += chunk;
|
|
424
|
+
let out = "";
|
|
425
|
+
for (;;) {
|
|
426
|
+
if (this.inCall) {
|
|
427
|
+
const end = this.buf.indexOf(CLOSE);
|
|
428
|
+
if (end === -1)
|
|
429
|
+
return out;
|
|
430
|
+
this.buf = this.buf.slice(end + CLOSE.length);
|
|
431
|
+
this.inCall = false;
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
const start = this.buf.indexOf(OPEN);
|
|
435
|
+
if (start !== -1) {
|
|
436
|
+
out += this.buf.slice(0, start);
|
|
437
|
+
this.buf = this.buf.slice(start + OPEN.length);
|
|
438
|
+
this.inCall = true;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// Keep a tail that could be the start of a block still arriving.
|
|
442
|
+
let keep = 0;
|
|
443
|
+
for (let n = Math.min(OPEN.length - 1, this.buf.length); n > 0; n--) {
|
|
444
|
+
if (OPEN.startsWith(this.buf.slice(-n))) {
|
|
445
|
+
keep = n;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
out += this.buf.slice(0, this.buf.length - keep);
|
|
450
|
+
this.buf = this.buf.slice(this.buf.length - keep);
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
end() {
|
|
456
|
+
const rest = this.inCall ? "" : this.buf;
|
|
457
|
+
this.buf = "";
|
|
458
|
+
return rest;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/** The reply's tool calls, for tools Vireo offered; anything else stays text. */
|
|
462
|
+
export function parseCalls(text, tools) {
|
|
463
|
+
const calls = [];
|
|
464
|
+
const rest = text.replace(/<tool_call>\s*([\s\S]*?)\s*(?:<\/tool_call>|$)/g, (whole, body) => {
|
|
465
|
+
const json = body.replace(/^```(?:json)?\s*|\s*```$/g, "");
|
|
466
|
+
try {
|
|
467
|
+
const v = JSON.parse(json);
|
|
468
|
+
const name = typeof v.name === "string" ? v.name : "";
|
|
469
|
+
if (!tools.includes(name))
|
|
470
|
+
return whole;
|
|
471
|
+
const args = v.arguments ?? v.parameters ?? {};
|
|
472
|
+
calls.push({ id: newId("call"), name, arguments: typeof args === "string" ? args : JSON.stringify(args) });
|
|
473
|
+
return "";
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return whole;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
return { text: calls.length ? rest.trim() : text.trim(), calls };
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* A reply that looks like a tool call no one could read: a model's own call markup (DeepSeek's
|
|
483
|
+
* DSML, Hermes-style tags) or a broken <tool_call> block. Shown as an answer, it would leave the
|
|
484
|
+
* owner with gibberish and nothing done.
|
|
485
|
+
*/
|
|
486
|
+
export function strayCall(text) {
|
|
487
|
+
return /<[||]{1,2}[^>]*(?:tool|call|invoke|DSML)|<\/?(?:tool_call|function_calls?|invoke)\b|"name"\s*:\s*"transfer_to_/i.test(text);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Runs one model turn on a CLI. Stopping (the signal) kills it and anything it started, and so
|
|
491
|
+
* does the model's first batch of tool calls. `mcpUrl` is where the bridge lists the turn's tools.
|
|
492
|
+
*/
|
|
493
|
+
export function runCli(id, path, req, opts = {}) {
|
|
494
|
+
const spec = CLIS[id];
|
|
495
|
+
const dir = mkdtempSync(join(tmpdir(), "vireo-cli-"));
|
|
496
|
+
const systemFile = join(dir, "system.md");
|
|
497
|
+
const system = systemText(req);
|
|
498
|
+
const prompt = promptText(req);
|
|
499
|
+
writeFileSync(systemFile, system || "You are a helpful assistant.");
|
|
500
|
+
const offered = offeredTools(req);
|
|
501
|
+
const tools = { names: offered.map((t) => t.function.name), extFile: join(dir, "vireo-tools.ts"), mcpUrl: offered.length ? opts.mcpUrl : undefined };
|
|
502
|
+
if (offered.length)
|
|
503
|
+
writeFileSync(tools.extFile, piExtension(offered));
|
|
504
|
+
// Codex keeps its own system prompt, so Vireo's goes first in the message instead.
|
|
505
|
+
const input = spec.ownsSystemPrompt || !system ? prompt : `<instructions>\n${system}\n</instructions>\n\n${prompt}`;
|
|
506
|
+
const reader = spec.reader();
|
|
507
|
+
const out = { text: "", calls: [], done: false, usage: { input: 0, output: 0, cached: 0, cacheWrite: 0 }, onDelta: (t) => opts.onDelta?.(t) };
|
|
508
|
+
return new Promise((resolve) => {
|
|
509
|
+
const child = spawn(path, spec.args({ model: req.model, systemFile, cwd: dir, tools }), {
|
|
510
|
+
cwd: dir,
|
|
511
|
+
env: cliEnv(path, { ...spec.env?.(), VIREO_AS_MODEL: "1" }),
|
|
512
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
513
|
+
detached: process.platform !== "win32",
|
|
514
|
+
});
|
|
515
|
+
let pending = "";
|
|
516
|
+
let stderr = "";
|
|
517
|
+
let done = false;
|
|
518
|
+
const kill = () => {
|
|
519
|
+
try {
|
|
520
|
+
if (child.pid && process.platform !== "win32")
|
|
521
|
+
process.kill(-child.pid, "SIGKILL");
|
|
522
|
+
else
|
|
523
|
+
child.kill("SIGKILL");
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
child.kill("SIGKILL");
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
const timer = setTimeout(() => {
|
|
530
|
+
out.error = `${spec.name} took longer than ${Math.round((opts.timeoutMs ?? 600_000) / 1000)}s`;
|
|
531
|
+
kill();
|
|
532
|
+
}, opts.timeoutMs ?? 600_000);
|
|
533
|
+
const onAbort = () => {
|
|
534
|
+
out.error = "Stopped";
|
|
535
|
+
kill();
|
|
536
|
+
};
|
|
537
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
538
|
+
// The calls are in: Vireo runs them, so the CLI has nothing left to do this turn.
|
|
539
|
+
let settle;
|
|
540
|
+
let seen = 0;
|
|
541
|
+
const line = (l) => {
|
|
542
|
+
const t = l.trim();
|
|
543
|
+
if (!t.startsWith("{") || out.done)
|
|
544
|
+
return;
|
|
545
|
+
try {
|
|
546
|
+
reader.push(JSON.parse(t), out);
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
// Not a JSON line.
|
|
550
|
+
}
|
|
551
|
+
if (out.done) {
|
|
552
|
+
clearTimeout(settle);
|
|
553
|
+
kill();
|
|
554
|
+
}
|
|
555
|
+
else if (spec.callsSettleMs && out.calls.length > seen) {
|
|
556
|
+
seen = out.calls.length;
|
|
557
|
+
clearTimeout(settle);
|
|
558
|
+
settle = setTimeout(() => {
|
|
559
|
+
out.done = true;
|
|
560
|
+
kill();
|
|
561
|
+
}, spec.callsSettleMs);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
const finish = (code, err) => {
|
|
565
|
+
if (done)
|
|
566
|
+
return;
|
|
567
|
+
done = true;
|
|
568
|
+
clearTimeout(timer);
|
|
569
|
+
clearTimeout(settle);
|
|
570
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
571
|
+
if (pending)
|
|
572
|
+
line(pending);
|
|
573
|
+
rmSync(dir, { recursive: true, force: true });
|
|
574
|
+
if (err)
|
|
575
|
+
out.error = `Could not start ${spec.name}: ${err.message}`;
|
|
576
|
+
else if (!out.error && !out.done && code !== 0 && !out.text)
|
|
577
|
+
out.error = `${spec.name} exited with code ${code}${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-3).join(" ")}` : ""}`;
|
|
578
|
+
resolve({ text: out.text, calls: out.calls, usage: out.usage, error: out.error });
|
|
579
|
+
};
|
|
580
|
+
child.stdout.on("data", (chunk) => {
|
|
581
|
+
const lines = (pending + chunk.toString("utf8")).split("\n");
|
|
582
|
+
pending = lines.pop() ?? "";
|
|
583
|
+
for (const l of lines)
|
|
584
|
+
line(l);
|
|
585
|
+
});
|
|
586
|
+
child.stderr.on("data", (chunk) => {
|
|
587
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-8000);
|
|
588
|
+
});
|
|
589
|
+
child.on("error", (err) => finish(null, err));
|
|
590
|
+
child.on("close", (code) => finish(code));
|
|
591
|
+
child.stdin.on("error", () => undefined);
|
|
592
|
+
child.stdin.end(input);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Serves the CLIs over Chat Completions at http://127.0.0.1:<port>/<cli>/v1,
|
|
597
|
+
* and each turn's tools over MCP at /mcp/<turn>. `resolve` gives the
|
|
598
|
+
* executable for a CLI and its model list.
|
|
599
|
+
*/
|
|
600
|
+
export async function startCliBridge(resolve) {
|
|
601
|
+
const turns = new Map();
|
|
602
|
+
let base = "";
|
|
603
|
+
const server = createServer((req, res) => {
|
|
604
|
+
const work = (req.url ?? "").startsWith("/mcp/") ? mcp(req, res, turns) : handle(req, res, resolve, turns, base);
|
|
605
|
+
void work.catch((err) => {
|
|
606
|
+
if (!res.headersSent)
|
|
607
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
608
|
+
res.end(JSON.stringify({ error: { message: String(err) } }));
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
612
|
+
server.unref();
|
|
613
|
+
const { port } = server.address();
|
|
614
|
+
base = `http://127.0.0.1:${port}`;
|
|
615
|
+
return { url: base, server };
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* A turn's tools as a stateless MCP server (Streamable HTTP, JSON replies). It lists them and
|
|
619
|
+
* never answers a call: the CLI is stopped once the model has made its calls, and Vireo runs them.
|
|
620
|
+
* The tools are marked read-only so Codex doesn't refuse them for want of an approval.
|
|
621
|
+
*/
|
|
622
|
+
async function mcp(req, res, turns) {
|
|
623
|
+
const tools = turns.get((req.url ?? "").slice("/mcp/".length).split(/[/?]/)[0]);
|
|
624
|
+
if (!tools)
|
|
625
|
+
return fail(res, 404, "No such turn");
|
|
626
|
+
if (req.method !== "POST")
|
|
627
|
+
return fail(res, 405, "POST only");
|
|
628
|
+
let raw = "";
|
|
629
|
+
for await (const chunk of req)
|
|
630
|
+
raw += chunk;
|
|
631
|
+
const msg = obj(JSON.parse(raw || "{}"));
|
|
632
|
+
if (msg.id === undefined || msg.id === null) {
|
|
633
|
+
res.writeHead(202).end();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const reply = (body) => {
|
|
637
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
638
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id, ...body }));
|
|
639
|
+
};
|
|
640
|
+
if (msg.method === "tools/call")
|
|
641
|
+
return;
|
|
642
|
+
if (msg.method === "initialize") {
|
|
643
|
+
return reply({ result: { protocolVersion: str(obj(msg.params).protocolVersion) ?? "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: MCP_NAME, version: "1" } } });
|
|
644
|
+
}
|
|
645
|
+
if (msg.method === "tools/list") {
|
|
646
|
+
return reply({
|
|
647
|
+
result: {
|
|
648
|
+
tools: tools.map((t) => ({ name: t.function.name, description: t.function.description ?? "", inputSchema: t.function.parameters ?? { type: "object", properties: {} }, annotations: { readOnlyHint: true } })),
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
if (msg.method === "ping")
|
|
653
|
+
return reply({ result: {} });
|
|
654
|
+
reply({ error: { code: -32601, message: `Method not found: ${String(msg.method)}` } });
|
|
655
|
+
}
|
|
656
|
+
function fail(res, status, message) {
|
|
657
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
658
|
+
res.end(JSON.stringify({ error: { message, type: "cli_error" } }));
|
|
659
|
+
}
|
|
660
|
+
async function handle(req, res, resolve, turns, bridgeUrl) {
|
|
661
|
+
const m = (req.url ?? "").match(/^\/(claude|codex|pi)\/v1\/(models|chat\/completions)\b/);
|
|
662
|
+
if (!m)
|
|
663
|
+
return fail(res, 404, "Not found");
|
|
664
|
+
const id = m[1];
|
|
665
|
+
const found = resolve(id);
|
|
666
|
+
if (m[2] === "models") {
|
|
667
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
668
|
+
res.end(JSON.stringify({ object: "list", data: found.models.map((x) => ({ id: x, object: "model", owned_by: id })) }));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (req.method !== "POST")
|
|
672
|
+
return fail(res, 405, "POST only");
|
|
673
|
+
if (!found.path)
|
|
674
|
+
return fail(res, 400, `${CLIS[id].name} is not installed on this machine (${CLIS[id].install}).`);
|
|
675
|
+
let raw = "";
|
|
676
|
+
for await (const chunk of req)
|
|
677
|
+
raw += chunk;
|
|
678
|
+
const body = JSON.parse(raw);
|
|
679
|
+
const toolNames = (body.tools ?? []).map((t) => t.function.name);
|
|
680
|
+
// A closed request (the run was stopped) stops the CLI.
|
|
681
|
+
const abort = new AbortController();
|
|
682
|
+
res.on("close", () => {
|
|
683
|
+
if (!res.writableFinished)
|
|
684
|
+
abort.abort();
|
|
685
|
+
});
|
|
686
|
+
const base = { id: `chatcmpl-${Date.now()}`, created: Math.floor(Date.now() / 1000), model: body.model };
|
|
687
|
+
const filter = new CallFilter();
|
|
688
|
+
const send = (choice, extra = {}) => res.write(`data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: choice ? [{ index: 0, ...choice }] : [], ...extra })}\n\n`);
|
|
689
|
+
if (body.stream) {
|
|
690
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
|
691
|
+
send({ delta: { role: "assistant", content: "" } });
|
|
692
|
+
}
|
|
693
|
+
const turn = newId("turn");
|
|
694
|
+
turns.set(turn, offeredTools(body));
|
|
695
|
+
const run = await runCli(id, found.path, body, {
|
|
696
|
+
signal: abort.signal,
|
|
697
|
+
mcpUrl: `${bridgeUrl}/mcp/${turn}`,
|
|
698
|
+
onDelta: body.stream
|
|
699
|
+
? (t) => {
|
|
700
|
+
const visible = filter.push(t);
|
|
701
|
+
if (visible)
|
|
702
|
+
send({ delta: { content: visible } });
|
|
703
|
+
}
|
|
704
|
+
: undefined,
|
|
705
|
+
}).finally(() => turns.delete(turn));
|
|
706
|
+
if (abort.signal.aborted)
|
|
707
|
+
return;
|
|
708
|
+
const usage = { prompt_tokens: run.usage.input, completion_tokens: run.usage.output, total_tokens: run.usage.input + run.usage.output, prompt_tokens_details: { cached_tokens: run.usage.cached, cache_creation_tokens: run.usage.cacheWrite } };
|
|
709
|
+
if (run.error && !run.text && !run.calls.length) {
|
|
710
|
+
if (!body.stream)
|
|
711
|
+
return fail(res, 502, run.error);
|
|
712
|
+
// Mid-stream, an error event is what the OpenAI client turns into a thrown error.
|
|
713
|
+
res.end(`data: ${JSON.stringify({ error: { message: run.error, type: "cli_error" } })}\n\n`);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
// Native calls; calls written as text count only when there are none.
|
|
717
|
+
const written = parseCalls(run.text, toolNames);
|
|
718
|
+
// Vireo's own ids: the CLIs' are long, and pi's carry a "|".
|
|
719
|
+
const native = run.calls.filter((c) => toolNames.includes(c.name)).map((c) => ({ ...c, id: newId("call") }));
|
|
720
|
+
const calls = native.length ? native : written.calls;
|
|
721
|
+
const text = native.length ? run.text.trim() : written.text;
|
|
722
|
+
if (!calls.length && strayCall(text)) {
|
|
723
|
+
const message = "The model tried to call a tool in a form Vireo can't read, so nothing ran. Try again, or pick another model.";
|
|
724
|
+
if (!body.stream)
|
|
725
|
+
return fail(res, 502, message);
|
|
726
|
+
res.end(`data: ${JSON.stringify({ error: { message, type: "cli_error" } })}\n\n`);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const toolCalls = calls.map((c, index) => ({ index, id: c.id, type: "function", function: { name: c.name, arguments: c.arguments } }));
|
|
730
|
+
const finish = calls.length ? "tool_calls" : "stop";
|
|
731
|
+
if (!body.stream) {
|
|
732
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
733
|
+
res.end(JSON.stringify({
|
|
734
|
+
...base,
|
|
735
|
+
object: "chat.completion",
|
|
736
|
+
choices: [{ index: 0, message: { role: "assistant", content: text || null, tool_calls: toolCalls.length ? toolCalls : undefined }, finish_reason: finish }],
|
|
737
|
+
usage,
|
|
738
|
+
}));
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const tail = filter.end();
|
|
742
|
+
if (tail)
|
|
743
|
+
send({ delta: { content: tail } });
|
|
744
|
+
if (toolCalls.length)
|
|
745
|
+
send({ delta: { tool_calls: toolCalls } });
|
|
746
|
+
send({ delta: {}, finish_reason: finish });
|
|
747
|
+
send(null, { usage });
|
|
748
|
+
res.end("data: [DONE]\n\n");
|
|
749
|
+
}
|
|
750
|
+
//# sourceMappingURL=cli-model.js.map
|