oakode 0.0.0-stage → 0.5.1
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/CHANGELOG.md +93 -0
- package/LICENSE +9 -0
- package/README.md +245 -2
- package/SECURITY.md +13 -0
- package/dist/account.js +34 -0
- package/dist/agent.js +584 -0
- package/dist/agents/approval.js +116 -0
- package/dist/agents/budget.js +293 -0
- package/dist/agents/child.js +103 -0
- package/dist/agents/host.js +289 -0
- package/dist/agents/orchestrator.js +286 -0
- package/dist/agents/plan.js +143 -0
- package/dist/agents/types.js +20 -0
- package/dist/agents/verify.js +94 -0
- package/dist/app.js +619 -0
- package/dist/auth.js +456 -0
- package/dist/brief.js +97 -0
- package/dist/commands.js +588 -0
- package/dist/compact.js +231 -0
- package/dist/config.js +87 -0
- package/dist/diff.js +98 -0
- package/dist/doctor.js +106 -0
- package/dist/errors.js +15 -0
- package/dist/fileio.js +688 -0
- package/dist/input.js +244 -0
- package/dist/jobs.js +262 -0
- package/dist/main.js +560 -0
- package/dist/mask.js +63 -0
- package/dist/mcp.js +721 -0
- package/dist/models.js +106 -0
- package/dist/modes.js +114 -0
- package/dist/oicki.js +475 -0
- package/dist/output.js +101 -0
- package/dist/permissions.js +389 -0
- package/dist/permissions_ui.js +134 -0
- package/dist/print.js +76 -0
- package/dist/proc.js +63 -0
- package/dist/runners.js +264 -0
- package/dist/serial.js +9 -0
- package/dist/session.js +452 -0
- package/dist/shell.js +40 -0
- package/dist/skills.js +335 -0
- package/dist/syntax.js +80 -0
- package/dist/tools.js +480 -0
- package/dist/tui/editor.js +279 -0
- package/dist/tui/highlight.js +78 -0
- package/dist/tui/markdown.js +254 -0
- package/dist/tui/screen.js +146 -0
- package/dist/tui/tui.js +505 -0
- package/dist/tui/view.js +337 -0
- package/dist/ui.js +105 -0
- package/dist/update.js +64 -0
- package/package.json +57 -4
package/dist/main.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { realpathSync, rmSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { Agent } from "./agent.js";
|
|
6
|
+
import { App } from "./app.js";
|
|
7
|
+
import { Auth, clientIdSetting } from "./auth.js";
|
|
8
|
+
import { billingLines } from "./account.js";
|
|
9
|
+
import { CreditRate } from "./agents/budget.js";
|
|
10
|
+
import { SubagentHost } from "./agents/host.js";
|
|
11
|
+
import { subagentSettings } from "./agents/types.js";
|
|
12
|
+
import { doctor } from "./doctor.js";
|
|
13
|
+
import { refreshUpdateCache, updateCheckEnabled, updateNotice } from "./update.js";
|
|
14
|
+
import { VERSION, baseUrl, configProblem, home, loadConfig, readJson, saveConfig, skillExcludes, writeJson } from "./config.js";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { explain } from "./errors.js";
|
|
17
|
+
import { allowedModel, compatibilityNotice, curatedModels, defaultModel, describeModel, experimentalHint, experimentalModels, ignoredDefaultNotice, matchModels, notSupported, selectableModels } from "./models.js";
|
|
18
|
+
import { makePrintEmitter, readAll } from "./print.js";
|
|
19
|
+
import { OickiClient, OickiError } from "./oicki.js";
|
|
20
|
+
import { Session } from "./session.js";
|
|
21
|
+
import { killAllChildren } from "./tools.js";
|
|
22
|
+
import { removeOutput, sweepOldOutput } from "./jobs.js";
|
|
23
|
+
import { Tui } from "./tui/tui.js";
|
|
24
|
+
import { ProjectTrust } from "./permissions.js";
|
|
25
|
+
import { Mcp } from "./mcp.js";
|
|
26
|
+
import { Skills } from "./skills.js";
|
|
27
|
+
import { ago, bold, dim, gray, green, red, yellow } from "./ui.js";
|
|
28
|
+
const HELP = `oakode ${VERSION} - coding agent powered by OICKI
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
oakode [options] [prompt] interactive session (or one-shot with -p)
|
|
32
|
+
oakode login sign in with your OICKI account in the browser
|
|
33
|
+
--no-browser print the sign-in link instead of opening it (remote machines)
|
|
34
|
+
--expensive also allow expensive models
|
|
35
|
+
--api-key use an OICKI API key instead (hidden input, verified with OICKI)
|
|
36
|
+
oakode logout remove the credentials stored on this computer
|
|
37
|
+
oakode status account, credits and which credentials are in use
|
|
38
|
+
oakode doctor check the installation, credentials and connection
|
|
39
|
+
oakode models [all|text] supported models, or search them (the whole catalog with experimentalModels)
|
|
40
|
+
oakode sessions your previous sessions in this directory
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
-p, --print run the prompt non-interactively and exit
|
|
44
|
+
-m, --model ID a supported model (default: auto; others need "experimentalModels": true in config.json)
|
|
45
|
+
-c, --continue resume the latest session in this directory
|
|
46
|
+
-r, --resume ID resume a session by id (see: oakode sessions)
|
|
47
|
+
-y, --yes NO APPROVAL PROMPTS: writes, edits and shell commands run unattended
|
|
48
|
+
-h, --help -v, --version
|
|
49
|
+
|
|
50
|
+
Inside a session type / for commands (/help lists them). Enter sends; Ctrl+J, Alt+Enter or Shift+Enter (kitty-style
|
|
51
|
+
terminals) insert a newline. Pasted text is never sent by itself. Typing while the agent works queues a message.
|
|
52
|
+
Esc or Ctrl+C interrupts a running turn. Every write, edit and shell command asks first. Approval is not a sandbox.
|
|
53
|
+
Environment: OICKI_API_KEY, OAKODE_HOME, OAKODE_BASE_URL, OAKODE_CLIENT_ID, OAKODE_REDIRECT_PORT, OAKODE_MAX_OUTPUT_TOKENS,
|
|
54
|
+
OAKODE_REASONING_EFFORT, OAKODE_NO_UPDATE_CHECK. Docs: README.md · problems: oakode doctor`;
|
|
55
|
+
// a mistyped flag gets one line and a pointer to --help, not a stack trace
|
|
56
|
+
const { values: opt, positionals } = (() => {
|
|
57
|
+
try {
|
|
58
|
+
return parseArgs({
|
|
59
|
+
allowPositionals: true,
|
|
60
|
+
options: {
|
|
61
|
+
print: { type: "boolean", short: "p" },
|
|
62
|
+
model: { type: "string", short: "m" },
|
|
63
|
+
continue: { type: "boolean", short: "c" },
|
|
64
|
+
resume: { type: "string", short: "r" },
|
|
65
|
+
yes: { type: "boolean", short: "y" },
|
|
66
|
+
help: { type: "boolean", short: "h" },
|
|
67
|
+
version: { type: "boolean", short: "v" },
|
|
68
|
+
"client-id": { type: "string" },
|
|
69
|
+
expensive: { type: "boolean" },
|
|
70
|
+
"api-key": { type: "boolean" },
|
|
71
|
+
"no-browser": { type: "boolean" },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
console.error(`oakode: ${e.message}\nSee \`oakode --help\`.`);
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
if (!opt.help && !opt.version && configProblem())
|
|
81
|
+
console.error(yellow(configProblem())); // config.json is edited by hand
|
|
82
|
+
const auth = new Auth();
|
|
83
|
+
sweepOldOutput(); // output folders a crashed OAKODE left behind (a day old)
|
|
84
|
+
auth.onSwitch = (m) => process.stderr.write(`oakode: ${m}\n`); // the interactive app shows it in the conversation
|
|
85
|
+
const client = new OickiClient(baseUrl(), auth);
|
|
86
|
+
const cwd = realpathSync.native(process.cwd()); // native: one spelling of a Windows drive letter, so sessions are found again
|
|
87
|
+
const interactive = !opt.print && process.stdin.isTTY === true;
|
|
88
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
89
|
+
let tui;
|
|
90
|
+
let printAbort;
|
|
91
|
+
let printTurn;
|
|
92
|
+
let app;
|
|
93
|
+
let shuttingDown = false;
|
|
94
|
+
let activeSession;
|
|
95
|
+
/**
|
|
96
|
+
* The one teardown order for every exit path. The terminal comes LAST because restoring it can block when the
|
|
97
|
+
* terminal has stopped reading; everything that matters (commands, session data, locks) is settled before that.
|
|
98
|
+
*/
|
|
99
|
+
let cleaned = false;
|
|
100
|
+
function cleanup() {
|
|
101
|
+
if (cleaned)
|
|
102
|
+
return;
|
|
103
|
+
cleaned = true;
|
|
104
|
+
try {
|
|
105
|
+
killAllChildren("SIGKILL");
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
try {
|
|
109
|
+
removeOutput();
|
|
110
|
+
}
|
|
111
|
+
catch { }
|
|
112
|
+
try {
|
|
113
|
+
const dump = activeSession && !activeSession.flush() ? activeSession.emergencyDump() : undefined;
|
|
114
|
+
if (dump)
|
|
115
|
+
console.error(`Unsaved session entries were written to ${dump}`);
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
try {
|
|
119
|
+
activeSession?.release();
|
|
120
|
+
}
|
|
121
|
+
catch { }
|
|
122
|
+
tui?.stop();
|
|
123
|
+
}
|
|
124
|
+
/** SIGTERM/SIGHUP/SIGINT: stop the turn (so every tool call gets a recorded result), then tear down. */
|
|
125
|
+
async function shutdown(code) {
|
|
126
|
+
if (shuttingDown)
|
|
127
|
+
return;
|
|
128
|
+
shuttingDown = true;
|
|
129
|
+
if (app?.currentTurn)
|
|
130
|
+
app.session.recordTurnEnd("terminated", `OAKODE received a stop signal while a turn was running`);
|
|
131
|
+
app?.abort?.abort();
|
|
132
|
+
printAbort?.abort();
|
|
133
|
+
tui?.cancelMenu();
|
|
134
|
+
await Promise.race([app?.currentTurn ?? printTurn ?? Promise.resolve(), sleep(3000)]);
|
|
135
|
+
cleanup();
|
|
136
|
+
process.exit(code);
|
|
137
|
+
}
|
|
138
|
+
process.on("SIGTERM", () => void shutdown(143));
|
|
139
|
+
process.on("SIGHUP", () => void shutdown(129));
|
|
140
|
+
process.on("SIGINT", () => void shutdown(130)); // raw-mode Ctrl+C is a key, not a signal; this is an external SIGINT
|
|
141
|
+
process.on("exit", cleanup);
|
|
142
|
+
process.on("uncaughtException", (e) => {
|
|
143
|
+
console.error(red(`Fatal: ${e.stack ?? e}`));
|
|
144
|
+
cleanup();
|
|
145
|
+
process.exit(1);
|
|
146
|
+
});
|
|
147
|
+
const howToSignIn = () => clientIdSetting()
|
|
148
|
+
? "Run `oakode login` to sign in with your OICKI account (or set OICKI_API_KEY)."
|
|
149
|
+
: "Create an API key at https://oicki.vercel.app/dashboard/developer, then run `oakode login --api-key` (or set OICKI_API_KEY).";
|
|
150
|
+
/** One keypress answer; Enter means yes. */
|
|
151
|
+
async function askYesNo(prompt) {
|
|
152
|
+
process.stdout.write(prompt);
|
|
153
|
+
const stdin = process.stdin;
|
|
154
|
+
if (stdin.isTTY)
|
|
155
|
+
stdin.setRawMode(true);
|
|
156
|
+
stdin.resume();
|
|
157
|
+
const ch = await new Promise((resolve) => stdin.once("data", (d) => resolve(d.toString()[0] ?? "")));
|
|
158
|
+
if (stdin.isTTY)
|
|
159
|
+
stdin.setRawMode(false);
|
|
160
|
+
stdin.pause();
|
|
161
|
+
process.stdout.write("\n");
|
|
162
|
+
if (ch === "\u0003")
|
|
163
|
+
process.exit(130);
|
|
164
|
+
return !/^[nN]/.test(ch);
|
|
165
|
+
}
|
|
166
|
+
async function readSecret(prompt) {
|
|
167
|
+
process.stderr.write(prompt);
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
let buf = "";
|
|
170
|
+
const stdin = process.stdin;
|
|
171
|
+
if (stdin.isTTY)
|
|
172
|
+
stdin.setRawMode(true);
|
|
173
|
+
stdin.resume();
|
|
174
|
+
const onData = (d) => {
|
|
175
|
+
for (const ch of d.toString()) {
|
|
176
|
+
if (ch === "\r" || ch === "\n" || ch === "\u0004") {
|
|
177
|
+
if (stdin.isTTY)
|
|
178
|
+
stdin.setRawMode(false);
|
|
179
|
+
stdin.off("data", onData);
|
|
180
|
+
stdin.pause();
|
|
181
|
+
process.stderr.write("\n");
|
|
182
|
+
return resolve(buf.trim());
|
|
183
|
+
}
|
|
184
|
+
if (ch === "\u0003")
|
|
185
|
+
process.exit(130);
|
|
186
|
+
if (ch === "\u007f")
|
|
187
|
+
buf = buf.slice(0, -1);
|
|
188
|
+
else
|
|
189
|
+
buf += ch;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
stdin.on("data", onData);
|
|
193
|
+
stdin.once("end", () => resolve(buf.trim())); // input closed (piped, </dev/null): whatever arrived, or nothing
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/** Non-billable: GET /v1/me identifies the API key (and proves OICKI accepts it). */
|
|
197
|
+
async function checkKey(key) {
|
|
198
|
+
try {
|
|
199
|
+
const r = await fetch(baseUrl() + "/v1/me", { headers: { Authorization: `Bearer ${key}`, "User-Agent": `oakode/${VERSION}` }, signal: AbortSignal.timeout(10_000) });
|
|
200
|
+
if (r.ok)
|
|
201
|
+
return { state: "ok", label: String((await r.json()).label ?? "") };
|
|
202
|
+
if (r.status === 401)
|
|
203
|
+
return { state: "rejected" };
|
|
204
|
+
return { state: "unreachable", detail: `HTTP ${r.status}` };
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
return { state: "unreachable", detail: e.message };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Ask for a key (hidden), verify it, store it (0600). Returns false if the user entered nothing. */
|
|
211
|
+
async function promptForKey() {
|
|
212
|
+
const key = await readSecret("OICKI API key (input hidden, Enter to cancel): ");
|
|
213
|
+
if (!key)
|
|
214
|
+
return false;
|
|
215
|
+
if (!key.startsWith("oicki_sk_"))
|
|
216
|
+
throw new Error("That does not look like an OICKI API key (they start with oicki_sk_).");
|
|
217
|
+
const r = await checkKey(key);
|
|
218
|
+
if (r.state === "rejected")
|
|
219
|
+
throw new Error("OICKI rejected that key (401). Check that you copied all of it and that it has not been revoked. Nothing was saved.");
|
|
220
|
+
auth.setApiKey(key);
|
|
221
|
+
console.log(green(r.state === "ok" ? `Key saved and verified${r.label ? ` (label "${r.label}")` : ""}.` : `Key saved, but it could not be verified (${r.detail}). If requests fail, check your connection.`));
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
async function cmdLogin() {
|
|
225
|
+
if (opt["api-key"]) {
|
|
226
|
+
if (!(await promptForKey())) {
|
|
227
|
+
console.log("Cancelled; nothing saved.");
|
|
228
|
+
process.exitCode = 1;
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!opt["client-id"] && !clientIdSetting()) {
|
|
233
|
+
console.log(`Browser sign-in is not available in this build of OAKODE yet. Use an API key instead:\n ${bold("oakode login --api-key")}`);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const ctl = new AbortController();
|
|
238
|
+
const onInt = () => ctl.abort();
|
|
239
|
+
process.once("SIGINT", onInt);
|
|
240
|
+
try {
|
|
241
|
+
const { scopes } = await auth.login({ clientId: opt["client-id"], expensive: opt.expensive, noBrowser: opt["no-browser"], log: (s) => console.log(s), signal: ctl.signal });
|
|
242
|
+
if (opt["client-id"])
|
|
243
|
+
saveConfig({ clientId: opt["client-id"] });
|
|
244
|
+
console.log(green("\n✓ Signed in to OICKI."));
|
|
245
|
+
if (!scopes.includes("billing:read"))
|
|
246
|
+
console.log(yellow("Credits will not be shown: the sign-in did not include \"see your plan and credits\"."));
|
|
247
|
+
if (opt.expensive && !scopes.includes("models:expensive"))
|
|
248
|
+
console.log(yellow("Expensive models were not allowed in the browser; they stay unavailable."));
|
|
249
|
+
const b = await client.billing(AbortSignal.timeout(10_000)).catch(() => undefined);
|
|
250
|
+
for (const l of billingLines(b, "oauth"))
|
|
251
|
+
console.log(l);
|
|
252
|
+
console.log(dim(`Credentials saved in ${home()}/auth.json (readable only by you). Start with: oakode`));
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
process.off("SIGINT", onInt);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function cmdLogout() {
|
|
259
|
+
const kind = auth.source();
|
|
260
|
+
const revoked = await auth.logout();
|
|
261
|
+
rmSync(join(home(), "models-cache.json"), { force: true });
|
|
262
|
+
if (kind === "none")
|
|
263
|
+
return console.log("You were not signed in on this computer.");
|
|
264
|
+
console.log("Signed out: the credentials stored on this computer were deleted.");
|
|
265
|
+
if (kind === "oauth")
|
|
266
|
+
console.log(revoked ? dim("OICKI ended this sign-in (other computers stay signed in).") : yellow("OICKI could not be reached to end the sign-in; it expires on its own within 30 days, or remove OAKODE under Connected apps in your OICKI account."));
|
|
267
|
+
if (kind === "env")
|
|
268
|
+
console.log(yellow("OICKI_API_KEY is still set in your environment and will keep being used."));
|
|
269
|
+
}
|
|
270
|
+
async function cmdStatus() {
|
|
271
|
+
const src = auth.source();
|
|
272
|
+
console.log(`${bold("OAKODE")} ${VERSION} · server ${baseUrl()} · data ${home()}`);
|
|
273
|
+
if (src === "none") {
|
|
274
|
+
console.log(red("Not signed in."), howToSignIn());
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (src === "oauth") {
|
|
279
|
+
console.log(`Credentials: signed in with OICKI (scopes: ${auth.scopes().join(" ") || "?"}).`);
|
|
280
|
+
try {
|
|
281
|
+
await client.whoami(AbortSignal.timeout(10_000)); // proves the sign-in works, whatever was granted
|
|
282
|
+
console.log(green("OICKI accepts the sign-in."));
|
|
283
|
+
const b = await client.billing(AbortSignal.timeout(10_000));
|
|
284
|
+
for (const l of billingLines(b, "oauth"))
|
|
285
|
+
console.log(l);
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
console.log(red(explain(e)));
|
|
289
|
+
process.exitCode = 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
console.log(`Credentials: API key ${src === "env" ? "from $OICKI_API_KEY" : `stored in ${home()}/auth.json`}.`);
|
|
294
|
+
let key;
|
|
295
|
+
try {
|
|
296
|
+
key = await auth.token();
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
console.log(red(explain(e)));
|
|
300
|
+
process.exitCode = 1;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const r = await checkKey(key);
|
|
304
|
+
if (r.state === "ok") {
|
|
305
|
+
const u = (await fetch(baseUrl() + "/v1/usage", { headers: { Authorization: `Bearer ${await auth.token()}` } }).then((x) => x.json()).catch(() => undefined));
|
|
306
|
+
console.log(green(`OICKI accepts it${r.label ? ` (label "${r.label}")` : ""}.`) + (u?.requestCount !== undefined ? ` Usage on this key: ${u.requestCount} requests, ${u.inputTokens} in / ${u.outputTokens} out tokens.` : ""));
|
|
307
|
+
}
|
|
308
|
+
else if (r.state === "rejected") {
|
|
309
|
+
console.log(red("OICKI rejected this key (401)."), `Replace it with ${bold("oakode login --api-key")}.`);
|
|
310
|
+
process.exitCode = 1;
|
|
311
|
+
}
|
|
312
|
+
else
|
|
313
|
+
console.log(yellow(`Could not reach OICKI to verify (${r.detail}).`));
|
|
314
|
+
}
|
|
315
|
+
const def = defaultModel();
|
|
316
|
+
console.log(`Default model: ${def.id}${def.ignored ? yellow(` (saved "${def.ignored}" is not supported, so ignored)`) : ""} · sessions in this directory: ${Session.list(cwd).length}`);
|
|
317
|
+
}
|
|
318
|
+
function cmdSessions() {
|
|
319
|
+
const list = Session.list(cwd, 20);
|
|
320
|
+
if (!list.length)
|
|
321
|
+
return console.log(`No sessions for ${cwd}. Start one with ${bold("oakode")}.`);
|
|
322
|
+
console.log(bold(`Sessions in ${cwd}`));
|
|
323
|
+
for (const x of list)
|
|
324
|
+
console.log(` ${x.id} ${ago(x.mtime).padEnd(9)} ${(x.messages === undefined ? "large" : x.messages + " msgs").padEnd(9)} ${x.preview}`);
|
|
325
|
+
console.log(dim(`Resume the latest: oakode -c · a specific one: oakode -r <id>`));
|
|
326
|
+
}
|
|
327
|
+
async function main() {
|
|
328
|
+
if (opt.help)
|
|
329
|
+
return console.log(HELP);
|
|
330
|
+
if (opt.version)
|
|
331
|
+
return console.log(VERSION);
|
|
332
|
+
const [cmd, ...rest] = positionals;
|
|
333
|
+
if (cmd === "login")
|
|
334
|
+
return cmdLogin();
|
|
335
|
+
if (cmd === "logout")
|
|
336
|
+
return cmdLogout();
|
|
337
|
+
if (cmd === "status")
|
|
338
|
+
return cmdStatus();
|
|
339
|
+
if (cmd === "doctor")
|
|
340
|
+
return void (process.exitCode = (await doctor(auth, client, cwd)) ? 0 : 1);
|
|
341
|
+
if (interactive && !cmd && updateCheckEnabled()) {
|
|
342
|
+
const n = updateNotice();
|
|
343
|
+
if (n)
|
|
344
|
+
console.error(yellow(n));
|
|
345
|
+
void refreshUpdateCache();
|
|
346
|
+
}
|
|
347
|
+
if (cmd === "sessions")
|
|
348
|
+
return cmdSessions();
|
|
349
|
+
if (cmd === "models") {
|
|
350
|
+
const catalog = await client.listModels();
|
|
351
|
+
const q = rest.join(" ");
|
|
352
|
+
const pool = (all) => (q === "all" ? all : q ? matchModels(all, q) : curatedModels(all));
|
|
353
|
+
const list = pool(selectableModels(catalog));
|
|
354
|
+
list.forEach((m) => console.log(describeModel(m)));
|
|
355
|
+
const hidden = experimentalModels() ? 0 : (q ? pool(catalog).length : catalog.length) - list.length;
|
|
356
|
+
if (!q)
|
|
357
|
+
console.error(dim(experimentalModels() ? `Supported models. Experimental models are on: oakode models all, or oakode models <text>.` : `Supported models. ${hidden} more in OICKI's catalog are experimental. ${experimentalHint()}`));
|
|
358
|
+
else
|
|
359
|
+
console.error(dim(`${list.length} model(s)${hidden ? `; ${hidden} more ${q === "all" ? "" : "matching "}are experimental. ${experimentalHint()}` : ""}`));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (auth.kind() === "none") {
|
|
363
|
+
const where = "Create an API key at https://oicki.vercel.app/dashboard/developer (leave \"Allow expensive models\" off).";
|
|
364
|
+
if (!interactive) {
|
|
365
|
+
console.error(`${red("Not signed in.")} ${howToSignIn()}`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
console.log(`${bold("Welcome to OAKODE.")} Sign in with your OICKI account to start.\n`);
|
|
369
|
+
if (clientIdSetting() && (await askYesNo("Open the browser to sign in? [Y/n] (n: use an API key instead) "))) {
|
|
370
|
+
await cmdLogin();
|
|
371
|
+
if (auth.kind() === "none")
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
console.log(`${where}\nPaste the key below; it is checked with OICKI and stored in ${home()}/auth.json (readable only by you).\n`);
|
|
376
|
+
if (!(await promptForKey())) {
|
|
377
|
+
console.error(dim("No key entered. Run `oakode` again when you have one."));
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Raw mode first: keys typed while we talk to the network are captured, not left to the kernel's line editing.
|
|
383
|
+
if (interactive) {
|
|
384
|
+
tui = new Tui(process.stdin, process.stdout);
|
|
385
|
+
tui.onAbort = () => void shutdown(130); // Ctrl+C / Esc while starting
|
|
386
|
+
tui.setBusy(true); // Enter queues messages typed during startup
|
|
387
|
+
tui.footer = () => gray("starting…");
|
|
388
|
+
tui.start();
|
|
389
|
+
}
|
|
390
|
+
const fail = (e) => {
|
|
391
|
+
tui?.stop();
|
|
392
|
+
console.error(explain(e));
|
|
393
|
+
process.exit(1);
|
|
394
|
+
};
|
|
395
|
+
let models = [];
|
|
396
|
+
const cat = loadCatalog();
|
|
397
|
+
try {
|
|
398
|
+
models = await cat.models;
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
fail(e);
|
|
402
|
+
}
|
|
403
|
+
cat.fresh?.then((fresh) => void models.splice(0, models.length, ...fresh), () => { }); // the cache was used: update in place
|
|
404
|
+
if (opt.model && !allowedModel(opt.model)) {
|
|
405
|
+
tui?.stop();
|
|
406
|
+
console.error(red(notSupported(opt.model)));
|
|
407
|
+
process.exit(2);
|
|
408
|
+
}
|
|
409
|
+
const saved = defaultModel();
|
|
410
|
+
const wanted = opt.model || saved.id;
|
|
411
|
+
let session;
|
|
412
|
+
try {
|
|
413
|
+
if (opt.resume || opt.continue) {
|
|
414
|
+
const sessions = Session.list(cwd); // only sessions recorded for this exact directory
|
|
415
|
+
// -c continues the latest session that has a conversation (files from older versions may hold none)
|
|
416
|
+
const matches = opt.resume ? sessions.filter((s) => s.id.startsWith(opt.resume)) : sessions.filter((s) => s.messages !== 0).slice(0, 1);
|
|
417
|
+
if (matches.length !== 1) {
|
|
418
|
+
tui?.stop();
|
|
419
|
+
console.error(red(matches.length ? `"${opt.resume}" matches ${matches.length} sessions; use a longer id.` : opt.resume ? `No session "${opt.resume}" for this directory. \`oakode sessions\` lists them.` : "No previous session in this directory. `oakode sessions` lists them."));
|
|
420
|
+
process.exit(1);
|
|
421
|
+
}
|
|
422
|
+
session = Session.open(matches[0].file, wanted, cwd);
|
|
423
|
+
if (opt.model)
|
|
424
|
+
session.setModel(opt.model);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
session = Session.create(cwd, wanted);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
return fail(e);
|
|
432
|
+
}
|
|
433
|
+
activeSession = session;
|
|
434
|
+
if (!models.some((m) => m.id === session.model))
|
|
435
|
+
console.error(yellow(`Model "${session.model}" is not in your model list; the server may reject it. See /models.`));
|
|
436
|
+
const resumed = !!(opt.resume || opt.continue);
|
|
437
|
+
const modelNotice = resumed && !allowedModel(session.model) ? compatibilityNotice(session.model) : !resumed && !opt.model && saved.ignored ? ignoredDefaultNotice(saved.ignored) : undefined;
|
|
438
|
+
// ---- print mode (-p, or piped stdin): plain text, no terminal control
|
|
439
|
+
if (!tui) {
|
|
440
|
+
const args = (cmd ? [cmd, ...rest] : []).join(" ").trim();
|
|
441
|
+
const piped = process.stdin.isTTY ? "" : (await readAll()).trim();
|
|
442
|
+
// with an instruction AND piped input, the model must see where the instruction ends and the data begins
|
|
443
|
+
const prompt = args && piped ? `${args}\n\n<stdin>\n${piped}\n</stdin>` : args || piped;
|
|
444
|
+
if (!prompt) {
|
|
445
|
+
console.error(red("No prompt given."));
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
if (opt.yes)
|
|
449
|
+
console.error(yellow("--yes: approval prompts are OFF. Writes, edits and shell commands run without asking."));
|
|
450
|
+
if (modelNotice)
|
|
451
|
+
console.error(yellow(modelNotice));
|
|
452
|
+
const out = makePrintEmitter();
|
|
453
|
+
const ctl = new AbortController();
|
|
454
|
+
printAbort = ctl;
|
|
455
|
+
const trust = new ProjectTrust(cwd);
|
|
456
|
+
const mcp = new Mcp({ cwd, trust });
|
|
457
|
+
await mcp.start();
|
|
458
|
+
const skills = new Skills({ cwd, extraDir: process.env.OAKODE_SKILLS_DIR, exclude: skillExcludes(), projectTrusted: (s, fp) => trust.skillStatus(s.name, fp) });
|
|
459
|
+
trust.endLegacyMigration();
|
|
460
|
+
// -p never delegates, except for evaluations (eval/run.ts): this variable, --yes and subagents.enabled all together
|
|
461
|
+
let parentCredits = 0;
|
|
462
|
+
const settings = subagentSettings();
|
|
463
|
+
const evalDelegation = process.env.OAKODE_EVAL_UNATTENDED_DELEGATION === "1" && !!opt.yes && settings.enabled;
|
|
464
|
+
if (evalDelegation)
|
|
465
|
+
console.error(yellow("Evaluation mode: subagent plans without validation errors are approved unattended, within the subagent budget."));
|
|
466
|
+
const subagents = evalDelegation
|
|
467
|
+
? new SubagentHost({
|
|
468
|
+
settings, client, session: () => session, models: () => models, rate: CreditRate.stored(),
|
|
469
|
+
authorize: async () => null, // read-only tools only; print mode has no permission modes
|
|
470
|
+
confirm: async () => !!opt.yes,
|
|
471
|
+
autoApprove: () => !!opt.yes,
|
|
472
|
+
ui: { chooseFrom: async () => -1, ask: async () => null, print: () => { }, query: () => "" },
|
|
473
|
+
note: (t) => console.error(dim(t)),
|
|
474
|
+
repaint: () => { },
|
|
475
|
+
parentTurnCredits: () => parentCredits,
|
|
476
|
+
unattended: true,
|
|
477
|
+
})
|
|
478
|
+
: undefined;
|
|
479
|
+
subagents?.beginTurn();
|
|
480
|
+
const emit = (e) => {
|
|
481
|
+
if (e.type === "usage" && e.credits !== undefined)
|
|
482
|
+
parentCredits += e.credits;
|
|
483
|
+
out.emit(e);
|
|
484
|
+
};
|
|
485
|
+
// -p asks nothing: without --yes every action that needs approval is declined, and the run says so in its exit code
|
|
486
|
+
let declined = 0;
|
|
487
|
+
const agent = new Agent({ client, session, model: () => models.find((m) => m.id === session.model), lookup: (id) => models.find((m) => m.id === id), emit, confirm: async () => (opt.yes ? true : (declined++, false)), extraTools: () => [skills.tool(), ...mcp.tools(), subagents?.tool].filter((t) => !!t), systemExtra: () => skills.promptSection() });
|
|
488
|
+
let failed = false;
|
|
489
|
+
const resumeHint = dim(`The session is saved; continue it with: oakode -p -r ${session.id} "…"`);
|
|
490
|
+
const run = agent.run(prompt, ctl.signal).then(() => void (ctl.signal.aborted && console.error(yellow("\n⏹ Interrupted."))), (e) => ((failed = true), console.error("\n" + explain(e)), console.error(resumeHint)));
|
|
491
|
+
printTurn = run;
|
|
492
|
+
await run;
|
|
493
|
+
out.end();
|
|
494
|
+
await mcp.close();
|
|
495
|
+
// Scripts and CI read the exit status: 0 only for a turn that completed with nothing declined.
|
|
496
|
+
const reason = session.lastTurn?.reason;
|
|
497
|
+
const interrupted = ctl.signal.aborted || reason === "interrupted" || reason === "terminated";
|
|
498
|
+
if (!failed && !interrupted && reason && reason !== "complete")
|
|
499
|
+
console.error(yellow(`The turn did not complete (${reason}${session.lastTurn?.detail ? `: ${session.lastTurn.detail}` : ""}).`), "\n" + resumeHint);
|
|
500
|
+
if (declined)
|
|
501
|
+
console.error(yellow(`${declined} action${declined > 1 ? "s" : ""} needed approval and ${declined > 1 ? "were" : "was"} declined: -p cannot ask, so changes and commands need --yes.`));
|
|
502
|
+
process.exitCode = interrupted ? 130 : failed || (reason && reason !== "complete") || declined ? 1 : 0;
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// ---- interactive
|
|
506
|
+
const t = tui;
|
|
507
|
+
app = new App({ tui: t, client, auth, cwd, models, session, yes: !!opt.yes });
|
|
508
|
+
const early = t.takeQueued(); // messages submitted while starting are processed in order, as separate messages
|
|
509
|
+
let prefill = t.leftover();
|
|
510
|
+
t.setBusy(false);
|
|
511
|
+
app.banner(VERSION, resumed);
|
|
512
|
+
if (cwd === "/" || cwd === realpathSync(homedir()))
|
|
513
|
+
app.view.note(`Working directory is ${cwd}: the file tools can reach everything in it. cd into a project first.`, "warn");
|
|
514
|
+
if (opt.yes)
|
|
515
|
+
app.view.note("--yes: approval prompts are OFF. Writes, edits and shell commands run without asking.", "warn");
|
|
516
|
+
if (resumed)
|
|
517
|
+
app.resumeNotice();
|
|
518
|
+
else if (modelNotice)
|
|
519
|
+
app.view.note(modelNotice, "warn"); // resumeNotice covers a resumed session's model
|
|
520
|
+
await app.run(positionals, early, prefill);
|
|
521
|
+
await app.close();
|
|
522
|
+
cleanup();
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* The model catalog takes seconds to fetch, so start from a cached copy (under a day old, same server and kind of
|
|
526
|
+
* credential) and refresh it in the background. Without a usable cache, wait for the network; if that fails, an
|
|
527
|
+
* older cache still beats not starting.
|
|
528
|
+
*/
|
|
529
|
+
function loadCatalog() {
|
|
530
|
+
const file = join(home(), "models-cache.json");
|
|
531
|
+
const key = `${client.baseUrl} ${auth.kind()}`;
|
|
532
|
+
const cached = readJson(file);
|
|
533
|
+
const valid = cached?.key === key && Array.isArray(cached.data) && cached.data.length > 0 ? cached : undefined;
|
|
534
|
+
const fetched = client.listModels().then((data) => {
|
|
535
|
+
try {
|
|
536
|
+
writeJson(file, { at: Date.now(), key, data });
|
|
537
|
+
}
|
|
538
|
+
catch { }
|
|
539
|
+
return data;
|
|
540
|
+
});
|
|
541
|
+
fetched.catch(() => { }); // a failed background refresh is not an error: the cached copy is in use
|
|
542
|
+
if (valid && Date.now() - valid.at < 24 * 3600_000)
|
|
543
|
+
return { models: Promise.resolve(valid.data.slice()), fresh: fetched };
|
|
544
|
+
const models = fetched.catch((e) => {
|
|
545
|
+
// credentials problems must surface; only an unreachable or failing server falls back to the old copy
|
|
546
|
+
if (!valid || (e instanceof OickiError && (e.status === 401 || e.status === 403)))
|
|
547
|
+
throw e;
|
|
548
|
+
console.error(yellow(`Could not refresh the model list (${e.message}); using the copy from ${ago(new Date(valid.at))}.`));
|
|
549
|
+
return valid.data.slice();
|
|
550
|
+
});
|
|
551
|
+
return { models };
|
|
552
|
+
}
|
|
553
|
+
main().then(() => {
|
|
554
|
+
if (!shuttingDown)
|
|
555
|
+
process.exit(process.exitCode ?? 0); // a signal handler that is already shutting down owns the exit code
|
|
556
|
+
}, (e) => {
|
|
557
|
+
console.error(explain(e));
|
|
558
|
+
cleanup();
|
|
559
|
+
process.exit(1);
|
|
560
|
+
});
|
package/dist/mask.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const MASK_KEEP = 30;
|
|
2
|
+
export const MASK_STEP = 20;
|
|
3
|
+
const BIG = 600;
|
|
4
|
+
const boundary = (len, keep, step) => (len - keep < step ? 0 : Math.floor((len - keep) / step) * step);
|
|
5
|
+
export const maskBoundary = (len) => boundary(len, MASK_KEEP, MASK_STEP);
|
|
6
|
+
/**
|
|
7
|
+
* The model's own write/edit arguments are the biggest thing in a long coding session, and once applied the content is
|
|
8
|
+
* on disk. They are shrunk much sooner than everything else (still in steps, to keep the cached prefix stable).
|
|
9
|
+
*/
|
|
10
|
+
export const FILE_ARGS_KEEP = 8;
|
|
11
|
+
export const FILE_ARGS_STEP = 8;
|
|
12
|
+
export const fileArgsBoundary = (len) => boundary(len, FILE_ARGS_KEEP, FILE_ARGS_STEP);
|
|
13
|
+
const FILE_TOOLS = new Set(["write", "edit"]);
|
|
14
|
+
const bigFileCall = (c) => FILE_TOOLS.has(c.function.name) && c.function.arguments.length > BIG;
|
|
15
|
+
const omitted = (n) => `[omitted ${n} chars to save context]`;
|
|
16
|
+
function shrink(v) {
|
|
17
|
+
if (typeof v === "string")
|
|
18
|
+
return v.length > BIG ? omitted(v.length) : v;
|
|
19
|
+
if (Array.isArray(v))
|
|
20
|
+
return v.map(shrink);
|
|
21
|
+
if (v && typeof v === "object")
|
|
22
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, shrink(x)]));
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
function shrinkCall(c) {
|
|
26
|
+
if (c.function.arguments.length <= BIG)
|
|
27
|
+
return c;
|
|
28
|
+
try {
|
|
29
|
+
return { ...c, function: { ...c.function, arguments: JSON.stringify(shrink(JSON.parse(c.function.arguments))) } };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return c;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function maskFileArgs(m) {
|
|
36
|
+
if (m.role !== "assistant" || !m.tool_calls?.some(bigFileCall))
|
|
37
|
+
return m;
|
|
38
|
+
return { ...m, tool_calls: m.tool_calls.map((c) => (bigFileCall(c) ? shrinkCall(c) : c)) };
|
|
39
|
+
}
|
|
40
|
+
export function maskMessage(m) {
|
|
41
|
+
if (m.role === "tool") {
|
|
42
|
+
if (m.content.length <= BIG)
|
|
43
|
+
return m;
|
|
44
|
+
const first = m.content.trimStart().split("\n")[0].slice(0, 120);
|
|
45
|
+
return { ...m, content: `[older tool result omitted to save context (${m.content.length} chars); it began: ${first}. Run the tool again if you need it.]` };
|
|
46
|
+
}
|
|
47
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
48
|
+
return {
|
|
49
|
+
...m,
|
|
50
|
+
tool_calls: m.tool_calls.map(shrinkCall),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (m.role === "user" && m.images?.length) {
|
|
54
|
+
const { images, ...rest } = m;
|
|
55
|
+
return { ...rest, content: `${m.content}\n[${images.length} image${images.length > 1 ? "s" : ""} no longer in context: ${images.map((i) => i.path).join(", ")}; read the file again to see ${images.length > 1 ? "them" : "it"}]` };
|
|
56
|
+
}
|
|
57
|
+
return m;
|
|
58
|
+
}
|
|
59
|
+
export function maskTail(tail) {
|
|
60
|
+
const b = maskBoundary(tail.length);
|
|
61
|
+
const f = fileArgsBoundary(tail.length);
|
|
62
|
+
return b || f ? tail.map((m, i) => (i < b ? maskMessage(m) : i < f ? maskFileArgs(m) : m)) : tail;
|
|
63
|
+
}
|