aether-code 0.42.0 → 0.43.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/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/aether-code.js +515 -513
- package/package.json +77 -75
- package/scripts/postinstall.js +182 -0
- package/skills/adult-creative-writing.md +60 -60
- package/skills/debugging.md +51 -51
- package/skills/game-modding.md +73 -73
- package/skills/reverse-engineering.md +41 -41
- package/skills/scraping-automation.md +77 -77
- package/skills/security-research.md +67 -67
- package/src/agent.js +170 -219
- package/src/api.js +22 -32
- package/src/box-input.js +141 -36
- package/src/config.js +38 -38
- package/src/diff.js +49 -49
- package/src/ink-input.js +91 -91
- package/src/mcp-cli.js +94 -94
- package/src/mcp-registry.js +266 -266
- package/src/mcp.js +259 -260
- package/src/menu.js +83 -83
- package/src/render.js +141 -107
- package/src/repl.js +221 -257
- package/src/setup.js +139 -141
- package/src/tools.js +911 -1288
- package/src/update-check.js +64 -7
- package/src/project-context.js +0 -707
- package/src/sessions.js +0 -167
- package/src/version.js +0 -15
package/src/agent.js
CHANGED
|
@@ -3,37 +3,98 @@
|
|
|
3
3
|
// tool calls (task done) or max-turns is reached.
|
|
4
4
|
|
|
5
5
|
import os from "node:os";
|
|
6
|
+
import fs from "node:fs";
|
|
6
7
|
import path from "node:path";
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
8
|
import { agentTurnStream, AetherError } from "./api.js";
|
|
9
9
|
import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
|
|
10
10
|
import { unnamespaceToolName } from "./mcp.js";
|
|
11
11
|
import { loadAllSkills, selectSkills, renderSkillsBlock } from "./skills.js";
|
|
12
|
-
import {
|
|
13
|
-
import { c, divider, turn, toolLabel, toolSummary, makeTokenStripper, errorLine, startSpinner } from "./render.js";
|
|
12
|
+
import { c, divider, turn, toolLabel, toolSummary, makeTokenStripper, errorLine, startSpinner, turnStats, fmtTokens } from "./render.js";
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
/* ─────────────────────── Image attachment parsing ─────────────────────── */
|
|
15
|
+
|
|
16
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
|
|
17
|
+
const MIME_MAP = {
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
".jpg": "image/jpeg",
|
|
20
|
+
".jpeg": "image/jpeg",
|
|
21
|
+
".gif": "image/gif",
|
|
22
|
+
".webp": "image/webp",
|
|
23
|
+
".bmp": "image/bmp",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Parse @path/to/image references from user input. Supports quoted paths for
|
|
27
|
+
// spaces: @"C:\Users\me\my screenshot.png". Returns { text, images }.
|
|
28
|
+
function parseImages(prompt, cwd) {
|
|
29
|
+
const re = /@("(?:[^"\\]|\\.)*"|[^\s]+)/g;
|
|
30
|
+
const images = [];
|
|
31
|
+
let cleaned = prompt;
|
|
32
|
+
|
|
33
|
+
for (const m of [...prompt.matchAll(re)]) {
|
|
34
|
+
const raw = m[1].replace(/^"|"$/g, "");
|
|
35
|
+
const ext = path.extname(raw).toLowerCase();
|
|
36
|
+
if (!IMAGE_EXTS.has(ext)) continue;
|
|
37
|
+
|
|
38
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
|
|
39
|
+
try {
|
|
40
|
+
const buf = fs.readFileSync(abs);
|
|
41
|
+
if (buf.length > 20 * 1024 * 1024) {
|
|
42
|
+
console.log(c.yellow(` ⚠ Image too large (>20MB), skipping: ${raw}`));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const mime = MIME_MAP[ext] || "image/png";
|
|
46
|
+
images.push({
|
|
47
|
+
url: `data:${mime};base64,${buf.toString("base64")}`,
|
|
48
|
+
name: path.basename(raw),
|
|
49
|
+
size: buf.length,
|
|
50
|
+
});
|
|
51
|
+
cleaned = cleaned.replace(m[0], "");
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.log(c.yellow(` ⚠ Could not read image: ${raw} — ${e.code === "ENOENT" ? "file not found" : e.message}`));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { text: cleaned.replace(/\s{2,}/g, " ").trim() || prompt.trim(), images };
|
|
58
|
+
}
|
|
16
59
|
|
|
17
|
-
|
|
18
|
-
|
|
60
|
+
// Build OpenAI-compatible content array with text + images, or plain string
|
|
61
|
+
// when no images are attached.
|
|
62
|
+
function buildContent(text, images) {
|
|
63
|
+
if (images.length === 0) return text;
|
|
64
|
+
const parts = [];
|
|
65
|
+
for (const img of images) {
|
|
66
|
+
parts.push({ type: "image_url", image_url: { url: img.url } });
|
|
67
|
+
}
|
|
68
|
+
parts.push({ type: "text", text });
|
|
69
|
+
return parts;
|
|
70
|
+
}
|
|
19
71
|
|
|
20
|
-
|
|
72
|
+
// Friendly file size: 1.2MB, 450KB, 128B
|
|
73
|
+
function fmtSize(bytes) {
|
|
74
|
+
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(1) + "MB";
|
|
75
|
+
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + "KB";
|
|
76
|
+
return bytes + "B";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const DEFAULT_MAX_TURNS = 25;
|
|
80
|
+
|
|
81
|
+
// Environment block prepended to the first user message so the model can
|
|
82
|
+
// resolve named locations to real absolute paths.
|
|
83
|
+
function envContext(cwd) {
|
|
21
84
|
const home = os.homedir();
|
|
22
85
|
const desktop = path.join(home, "Desktop");
|
|
23
86
|
const documents = path.join(home, "Documents");
|
|
24
87
|
const win = process.platform === "win32";
|
|
88
|
+
// OS-specific shell guidance: gemma tends to emit Unix commands (mkdir -p,
|
|
89
|
+
// touch, &&-chains) which FAIL in Windows cmd.exe ("The syntax of the command
|
|
90
|
+
// is incorrect"). Steer it to the file tools (which are cross-platform and
|
|
91
|
+
// auto-create parent dirs) and OS-appropriate shell usage.
|
|
25
92
|
const shellNote = win
|
|
26
93
|
? `shell: Windows cmd.exe. To create files OR directories, use the write_file tool — ` +
|
|
27
94
|
`it creates any missing parent folders automatically. Do NOT use shell "mkdir -p", ` +
|
|
28
95
|
`"touch", "ls", "rm", "cp", "mv", or "&&"-chained Unix commands; they FAIL in cmd.exe. ` +
|
|
29
96
|
`Run programs/tests with their interpreter (python, node, java, etc.).`
|
|
30
97
|
: `shell: POSIX sh. Standard Unix commands are available. write_file still auto-creates parent dirs.`;
|
|
31
|
-
|
|
32
|
-
let projectBlock = "";
|
|
33
|
-
try {
|
|
34
|
-
projectBlock = scanProject(cwd);
|
|
35
|
-
} catch { /* non-fatal */ }
|
|
36
|
-
|
|
37
98
|
return (
|
|
38
99
|
`[environment]\n` +
|
|
39
100
|
`os: ${process.platform}\n` +
|
|
@@ -45,8 +106,7 @@ export function envContext(cwd) {
|
|
|
45
106
|
`When the user names a location ("my desktop", "home", "documents"), write to ` +
|
|
46
107
|
`the matching ABSOLUTE path above (e.g. desktop -> ${desktop}). Otherwise ` +
|
|
47
108
|
`work under the cwd. Use absolute paths when a specific location is named.\n` +
|
|
48
|
-
`[/environment]\n\n`
|
|
49
|
-
(projectBlock ? projectBlock + "\n" : "")
|
|
109
|
+
`[/environment]\n\n`
|
|
50
110
|
);
|
|
51
111
|
}
|
|
52
112
|
|
|
@@ -82,6 +142,15 @@ export async function runAgent({
|
|
|
82
142
|
process.stderr.write(c.yellow(`(skill load failed: ${e.message})\n`));
|
|
83
143
|
}
|
|
84
144
|
const referencedPaths = [];
|
|
145
|
+
|
|
146
|
+
// Parse image attachments (@path/to/image.png) from the user's prompt.
|
|
147
|
+
const { text: cleanPrompt, images: attachedImages } = parseImages(initialPrompt, cwd);
|
|
148
|
+
if (attachedImages.length > 0) {
|
|
149
|
+
for (const img of attachedImages) {
|
|
150
|
+
console.log(`\n${c.cyan("●")} ${c.cyan(c.bold("image"))} ${c.gray(img.name)} ${c.gray(`(${fmtSize(img.size)})`)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
85
154
|
// Two callers: one-shot (initialPrompt only, fresh conversation) and REPL
|
|
86
155
|
// (priorMessages + initialPrompt to continue an ongoing chat).
|
|
87
156
|
// On the FIRST message of a session, prepend an environment block so the
|
|
@@ -89,9 +158,11 @@ export async function runAgent({
|
|
|
89
158
|
// X on my desktop" became `mkdir X` in whatever dir aether was launched from
|
|
90
159
|
// (e.g. C:\WINDOWS\system32). Only prepended once — later turns carry it in
|
|
91
160
|
// history.
|
|
161
|
+
const firstText = priorMessages ? cleanPrompt : envContext(cwd) + cleanPrompt;
|
|
162
|
+
const firstContent = buildContent(firstText, attachedImages);
|
|
92
163
|
const messages = priorMessages
|
|
93
|
-
? [...priorMessages, { role: "user", content:
|
|
94
|
-
: [{ role: "user", content:
|
|
164
|
+
? [...priorMessages, { role: "user", content: firstContent }]
|
|
165
|
+
: [{ role: "user", content: firstContent }];
|
|
95
166
|
let totalCredits = 0;
|
|
96
167
|
let totalIn = 0;
|
|
97
168
|
let totalOut = 0;
|
|
@@ -108,22 +179,23 @@ export async function runAgent({
|
|
|
108
179
|
// Completeness guard: gemma sometimes reads files then narrates the fix as
|
|
109
180
|
// text, or refactors one file but forgets the others. If a MODIFICATION task
|
|
110
181
|
// finishes having read files it never edited, we nudge it once to finish.
|
|
182
|
+
const MOD_RE =
|
|
183
|
+
/\b(refactor|fix|add|adds?|change|update|implement|creat|writ|remov|delet|rename|extract|move|replace|build|generate|insert|append|convert|migrat|wire|hook|edit|modif|patch|integrat)/i;
|
|
111
184
|
const looksLikeModification = MOD_RE.test(initialPrompt);
|
|
112
185
|
let appliedNothingNudges = 0;
|
|
113
|
-
let verifyRounds = 0;
|
|
114
|
-
let buildRounds = 0;
|
|
115
186
|
|
|
116
187
|
for (let i = 0; i < maxTurns; i++) {
|
|
117
|
-
|
|
118
|
-
// each tool label) begins with its own "\n● ", so spacing stays exactly one
|
|
119
|
-
// blank line per step instead of stacking up.
|
|
188
|
+
const turnStart = Date.now();
|
|
120
189
|
|
|
121
190
|
// Stream the assistant's response. Print text deltas as they arrive,
|
|
122
191
|
// along with tool-call announcements as soon as the model commits to
|
|
123
192
|
// calling a particular tool (i.e. the `name` arrives in the stream).
|
|
124
193
|
const announced = new Set();
|
|
125
194
|
let lastWasText = false;
|
|
195
|
+
let streamedChars = 0;
|
|
126
196
|
const stripper = makeTokenStripper();
|
|
197
|
+
let leakBuf = "";
|
|
198
|
+
let leakSuppressing = false;
|
|
127
199
|
|
|
128
200
|
// Select skills for this turn against the current user prompt + any
|
|
129
201
|
// paths the model has read so far. Prepend the matching skills' bodies
|
|
@@ -144,19 +216,34 @@ export async function runAgent({
|
|
|
144
216
|
tools,
|
|
145
217
|
model,
|
|
146
218
|
onDelta: (text) => {
|
|
147
|
-
|
|
148
|
-
|
|
219
|
+
streamedChars += text.length;
|
|
220
|
+
spinner.update({ tokens: Math.ceil(streamedChars / 3.5) });
|
|
149
221
|
const clean = stripper.push(text);
|
|
150
222
|
if (!clean) return;
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
|
|
223
|
+
|
|
224
|
+
// Suppress model echoing tool results (response:web_search, [{snippet:, etc.)
|
|
225
|
+
leakBuf += clean;
|
|
226
|
+
if (leakBuf.length < 40 && /^[\s\n]*(?:response:|[\[{]"?(?:snippet|title|url))/.test(leakBuf)) return;
|
|
227
|
+
if (leakSuppressing) {
|
|
228
|
+
if (clean.includes("\n\n")) { leakSuppressing = false; leakBuf = ""; }
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const checkBuf = leakBuf.trim();
|
|
232
|
+
if (checkBuf && /^response:\w|^\[\{(?:"?snippet|"?title)/.test(checkBuf)) {
|
|
233
|
+
leakSuppressing = true;
|
|
234
|
+
leakBuf = "";
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const emit = leakBuf;
|
|
238
|
+
leakBuf = "";
|
|
239
|
+
|
|
240
|
+
if (!lastWasText && !emit.trim()) return;
|
|
154
241
|
stopSpin();
|
|
155
242
|
if (!lastWasText) {
|
|
156
243
|
process.stdout.write("\n" + c.cyan("● "));
|
|
157
244
|
lastWasText = true;
|
|
158
245
|
}
|
|
159
|
-
process.stdout.write(
|
|
246
|
+
process.stdout.write(emit);
|
|
160
247
|
},
|
|
161
248
|
onToolCallDelta: (delta) => {
|
|
162
249
|
// Just close the streamed text line when the model starts a tool
|
|
@@ -186,13 +273,13 @@ export async function runAgent({
|
|
|
186
273
|
process.stdout.write(tail);
|
|
187
274
|
}
|
|
188
275
|
if (lastWasText) process.stdout.write("\n");
|
|
276
|
+
const turnIn = res.usage?.prompt_tokens ?? 0;
|
|
277
|
+
const turnOut = res.usage?.completion_tokens ?? 0;
|
|
189
278
|
totalCredits += res.creditsCharged ?? 0;
|
|
190
|
-
totalIn +=
|
|
191
|
-
totalOut +=
|
|
279
|
+
totalIn += turnIn;
|
|
280
|
+
totalOut += turnOut;
|
|
192
281
|
if (typeof res.balanceAfter === "number") lastBalance = res.balanceAfter;
|
|
193
282
|
onTokens({ totalCredits, totalIn, totalOut, balance: lastBalance });
|
|
194
|
-
// Per-turn cost line removed for a cleaner look — the session summary at the
|
|
195
|
-
// end carries the totals.
|
|
196
283
|
|
|
197
284
|
// Push assistant message into history
|
|
198
285
|
messages.push({
|
|
@@ -221,88 +308,6 @@ export async function runAgent({
|
|
|
221
308
|
messages.push({ role: "user", content: msg });
|
|
222
309
|
continue; // give the model another turn to finish
|
|
223
310
|
}
|
|
224
|
-
// Auto-build: if we wrote source files, detect the build toolchain and
|
|
225
|
-
// compile automatically. If build fails, feed errors back to the model
|
|
226
|
-
// so it can fix and recompile. Max 5 rounds to avoid infinite loops.
|
|
227
|
-
if (filesTouched.size > 0 && buildRounds < 5) {
|
|
228
|
-
const buildResult = await runBuild(cwd, [...filesTouched.keys()]);
|
|
229
|
-
if (buildResult && !buildResult.ok) {
|
|
230
|
-
buildRounds++;
|
|
231
|
-
console.log("\n" + c.yellow("●") + " " + c.bold("auto-build") + c.gray(` (${buildResult.label}) — round ${buildRounds}/5`));
|
|
232
|
-
console.log(c.red(" └─") + " " + c.gray("build failed — feeding errors back to fix"));
|
|
233
|
-
const errPreview = buildResult.output.length > 6000
|
|
234
|
-
? buildResult.output.slice(0, 6000) + "\n…(truncated)"
|
|
235
|
-
: buildResult.output;
|
|
236
|
-
messages.push({
|
|
237
|
-
role: "user",
|
|
238
|
-
content:
|
|
239
|
-
`AUTO-BUILD FAILED (${buildResult.label}):\n\`\`\`\n${errPreview}\n\`\`\`\n` +
|
|
240
|
-
`Fix the build errors NOW using edit_file. Do not explain — just fix the code and move on.`,
|
|
241
|
-
});
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
if (buildResult && buildResult.ok) {
|
|
245
|
-
console.log("\n" + c.green("●") + " " + c.bold("auto-build") + c.gray(` (${buildResult.label}) — success`));
|
|
246
|
-
// If there's a run command and we haven't already run it, execute the result
|
|
247
|
-
if (buildResult.runCmd && buildRounds === 0) {
|
|
248
|
-
const runResult = await runBuiltProgram(cwd, buildResult.runCmd, buildResult.label);
|
|
249
|
-
if (runResult && !runResult.ok && buildRounds < 5) {
|
|
250
|
-
buildRounds++;
|
|
251
|
-
console.log(c.yellow(" ├─") + " " + c.bold("auto-run") + c.gray(` — crashed (exit ${runResult.code})`));
|
|
252
|
-
const errPreview = runResult.output.length > 6000
|
|
253
|
-
? runResult.output.slice(0, 6000) + "\n…(truncated)"
|
|
254
|
-
: runResult.output;
|
|
255
|
-
messages.push({
|
|
256
|
-
role: "user",
|
|
257
|
-
content:
|
|
258
|
-
`BUILD SUCCEEDED but the program CRASHED when run:\n\`\`\`\n${errPreview}\n\`\`\`\n` +
|
|
259
|
-
`Fix the runtime error NOW using edit_file. Do not explain — just fix and move on.`,
|
|
260
|
-
});
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
if (runResult && runResult.ok) {
|
|
264
|
-
const preview = runResult.output.length > 2000
|
|
265
|
-
? runResult.output.slice(0, 2000) + "\n…(truncated)"
|
|
266
|
-
: runResult.output;
|
|
267
|
-
if (runResult.timedOut) {
|
|
268
|
-
console.log(c.green(" ├─") + " " + c.bold("auto-run") + c.gray(" — running (still alive after 10s)"));
|
|
269
|
-
} else {
|
|
270
|
-
console.log(c.green(" ├─") + " " + c.bold("auto-run") + c.gray(` — exit 0`));
|
|
271
|
-
}
|
|
272
|
-
if (preview.trim()) {
|
|
273
|
-
const lines = preview.trim().split("\n").slice(0, 15);
|
|
274
|
-
for (const line of lines) {
|
|
275
|
-
console.log(c.gray(" │ " + line));
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
// Auto-verify: if we touched files, run the project's typecheck/lint
|
|
283
|
-
// and feed errors back so the model can self-correct without the user
|
|
284
|
-
// having to say "fix that." At most 2 verify rounds to avoid infinite loops.
|
|
285
|
-
if (filesTouched.size > 0 && verifyRounds < 2) {
|
|
286
|
-
const verifyResult = await runVerify(cwd);
|
|
287
|
-
if (verifyResult && !verifyResult.ok) {
|
|
288
|
-
verifyRounds++;
|
|
289
|
-
console.log("\n" + c.yellow("●") + " " + c.bold("auto-verify") + c.gray(` (${verifyResult.label})`));
|
|
290
|
-
console.log(c.red(" └─") + " " + c.gray("errors found — feeding back to fix"));
|
|
291
|
-
const errPreview = verifyResult.output.length > 4000
|
|
292
|
-
? verifyResult.output.slice(0, 4000) + "\n…(truncated)"
|
|
293
|
-
: verifyResult.output;
|
|
294
|
-
messages.push({
|
|
295
|
-
role: "user",
|
|
296
|
-
content:
|
|
297
|
-
`AUTO-VERIFY FAILED (${verifyResult.label}):\n\`\`\`\n${errPreview}\n\`\`\`\n` +
|
|
298
|
-
`Fix these errors NOW using edit_file. Do not explain — just fix and move on.`,
|
|
299
|
-
});
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
if (verifyResult && verifyResult.ok) {
|
|
303
|
-
console.log("\n" + c.green("●") + " " + c.bold("auto-verify") + c.gray(` (${verifyResult.label}) — passed`));
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
311
|
if (filesTouched.size) {
|
|
307
312
|
console.log("\n" + c.bold("Files") + c.gray(` in ${cwd}`));
|
|
308
313
|
for (const [p, action] of filesTouched) {
|
|
@@ -371,112 +376,44 @@ export async function runAgent({
|
|
|
371
376
|
const summary = toolSummary(call.function.name, result);
|
|
372
377
|
if (summary) console.log(summary);
|
|
373
378
|
|
|
379
|
+
// Cap tool result content sent to the model to prevent it from
|
|
380
|
+
// echoing raw data (snippets, HTML dumps) back as text output.
|
|
381
|
+
let toolContent = result.output ?? (result.ok ? "(no output)" : "Failed.");
|
|
382
|
+
if (call.function.name === "web_search") {
|
|
383
|
+
try {
|
|
384
|
+
const arr = JSON.parse(toolContent);
|
|
385
|
+
if (Array.isArray(arr)) {
|
|
386
|
+
toolContent = JSON.stringify(arr.map((r) => ({
|
|
387
|
+
title: r.title, url: r.url,
|
|
388
|
+
snippet: (r.snippet || "").slice(0, 120),
|
|
389
|
+
})));
|
|
390
|
+
}
|
|
391
|
+
} catch { /* leave as-is */ }
|
|
392
|
+
} else if (call.function.name === "web_fetch") {
|
|
393
|
+
if (toolContent.length > 12000) toolContent = toolContent.slice(0, 12000) + "\n...(truncated)";
|
|
394
|
+
} else if (call.function.name === "read_file") {
|
|
395
|
+
if (toolContent.length > 30000) toolContent = toolContent.slice(0, 30000) + "\n...(truncated)";
|
|
396
|
+
}
|
|
374
397
|
messages.push({
|
|
375
398
|
role: "tool",
|
|
376
399
|
tool_call_id: call.id,
|
|
377
|
-
content:
|
|
400
|
+
content: toolContent,
|
|
378
401
|
});
|
|
379
402
|
}
|
|
380
|
-
}
|
|
381
403
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
async function runVerify(cwd) {
|
|
390
|
-
if (cachedVerify === undefined) cachedVerify = detectVerifyCommand(cwd);
|
|
391
|
-
if (!cachedVerify) return null;
|
|
392
|
-
const { cmd, label } = cachedVerify;
|
|
393
|
-
return new Promise((resolve) => {
|
|
394
|
-
const child = spawn(cmd, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
395
|
-
let stdout = "";
|
|
396
|
-
let stderr = "";
|
|
397
|
-
const timer = setTimeout(() => { child.kill("SIGTERM"); }, 60_000);
|
|
398
|
-
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
399
|
-
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
400
|
-
child.on("close", (code) => {
|
|
401
|
-
clearTimeout(timer);
|
|
402
|
-
const output = (stdout + "\n" + stderr).trim();
|
|
403
|
-
resolve({ ok: code === 0, output, label });
|
|
404
|
-
});
|
|
405
|
-
child.on("error", () => {
|
|
406
|
-
clearTimeout(timer);
|
|
407
|
-
resolve(null);
|
|
404
|
+
// Per-turn stats line — shows elapsed time, token flow, and tool count.
|
|
405
|
+
const stats = turnStats({
|
|
406
|
+
elapsed: Date.now() - turnStart,
|
|
407
|
+
tokensIn: turnIn,
|
|
408
|
+
tokensOut: turnOut,
|
|
409
|
+
tools: toolCalls.length,
|
|
410
|
+
credits: res.creditsCharged ?? 0,
|
|
408
411
|
});
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Auto-build: detect the build toolchain from written files and compile.
|
|
413
|
-
// Returns { ok, output, label, runCmd } or null if no buildable project detected.
|
|
414
|
-
let cachedBuild = undefined;
|
|
415
|
-
async function runBuild(cwd, touchedFiles) {
|
|
416
|
-
// Re-detect every time because the model may create new files (e.g. Makefile)
|
|
417
|
-
// or change the project structure between rounds.
|
|
418
|
-
const detected = detectBuildCommand(cwd, touchedFiles);
|
|
419
|
-
if (!detected) {
|
|
420
|
-
// Fallback: if we previously detected a build command, reuse it (the model
|
|
421
|
-
// may have only edited files this round, not created new ones).
|
|
422
|
-
if (cachedBuild) return runBuildCmd(cwd, cachedBuild);
|
|
423
|
-
return null;
|
|
412
|
+
if (stats) console.log(stats);
|
|
424
413
|
}
|
|
425
|
-
cachedBuild = detected;
|
|
426
|
-
return runBuildCmd(cwd, detected);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async function runBuildCmd(cwd, { cmd, label, type, runCmd }) {
|
|
430
|
-
return new Promise((resolve) => {
|
|
431
|
-
const child = spawn(cmd, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
432
|
-
let stdout = "";
|
|
433
|
-
let stderr = "";
|
|
434
|
-
const timer = setTimeout(() => { child.kill("SIGTERM"); }, 120_000);
|
|
435
|
-
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
436
|
-
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
437
|
-
child.on("close", (code) => {
|
|
438
|
-
clearTimeout(timer);
|
|
439
|
-
const output = (stdout + "\n" + stderr).trim();
|
|
440
|
-
resolve({ ok: code === 0, output, label, type, runCmd });
|
|
441
|
-
});
|
|
442
|
-
child.on("error", (err) => {
|
|
443
|
-
clearTimeout(timer);
|
|
444
|
-
resolve({ ok: false, output: err.message, label, type, runCmd });
|
|
445
|
-
});
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
414
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
async function runBuiltProgram(cwd, runCmd, label) {
|
|
452
|
-
return new Promise((resolve) => {
|
|
453
|
-
const child = spawn(runCmd, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
454
|
-
let stdout = "";
|
|
455
|
-
let stderr = "";
|
|
456
|
-
let timedOut = false;
|
|
457
|
-
const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, 10_000);
|
|
458
|
-
child.stdout.on("data", (d) => {
|
|
459
|
-
stdout += d.toString();
|
|
460
|
-
if (stdout.length > 50_000) { child.kill("SIGTERM"); }
|
|
461
|
-
});
|
|
462
|
-
child.stderr.on("data", (d) => {
|
|
463
|
-
stderr += d.toString();
|
|
464
|
-
if (stderr.length > 50_000) { child.kill("SIGTERM"); }
|
|
465
|
-
});
|
|
466
|
-
child.on("close", (code) => {
|
|
467
|
-
clearTimeout(timer);
|
|
468
|
-
const output = (stdout + "\n" + stderr).trim();
|
|
469
|
-
if (timedOut) {
|
|
470
|
-
resolve({ ok: true, output, code: null, timedOut: true });
|
|
471
|
-
} else {
|
|
472
|
-
resolve({ ok: code === 0, output, code, timedOut: false });
|
|
473
|
-
}
|
|
474
|
-
});
|
|
475
|
-
child.on("error", (err) => {
|
|
476
|
-
clearTimeout(timer);
|
|
477
|
-
resolve({ ok: false, output: err.message, code: null, timedOut: false });
|
|
478
|
-
});
|
|
479
|
-
});
|
|
415
|
+
console.log(c.yellow(`\nReached max turns (${maxTurns}). Stopping.`));
|
|
416
|
+
return { ok: false, error: new Error("Max turns reached"), totalCredits, totalIn, totalOut, balance: lastBalance, messages };
|
|
480
417
|
}
|
|
481
418
|
|
|
482
419
|
/**
|
|
@@ -493,7 +430,7 @@ async function runBuiltProgram(cwd, runCmd, label) {
|
|
|
493
430
|
* worse than no skills at all. Prepending into the user message keeps
|
|
494
431
|
* both layers active.
|
|
495
432
|
*/
|
|
496
|
-
|
|
433
|
+
function buildTurnMessages(messages, allSkills, referencedPaths) {
|
|
497
434
|
if (allSkills.length === 0) return messages;
|
|
498
435
|
// Find the latest user message — that's where the current task lives.
|
|
499
436
|
let lastUserIdx = -1;
|
|
@@ -501,16 +438,30 @@ export function buildTurnMessages(messages, allSkills, referencedPaths) {
|
|
|
501
438
|
if (messages[i].role === "user") { lastUserIdx = i; break; }
|
|
502
439
|
}
|
|
503
440
|
if (lastUserIdx === -1) return messages;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
441
|
+
// Content can be a string or a multimodal array (when images are attached).
|
|
442
|
+
// Extract the text portion for skill selection; prepend skills to text only.
|
|
443
|
+
const rawContent = messages[lastUserIdx].content;
|
|
444
|
+
let prompt;
|
|
445
|
+
if (typeof rawContent === "string") {
|
|
446
|
+
prompt = rawContent;
|
|
447
|
+
} else if (Array.isArray(rawContent)) {
|
|
448
|
+
const textPart = rawContent.find((p) => p.type === "text");
|
|
449
|
+
prompt = textPart?.text ?? "";
|
|
450
|
+
} else {
|
|
451
|
+
prompt = "";
|
|
452
|
+
}
|
|
507
453
|
const active = selectSkills({ skills: allSkills, prompt, referencedPaths });
|
|
508
454
|
if (active.length === 0) return messages;
|
|
509
455
|
const block = renderSkillsBlock(active);
|
|
510
456
|
const cloned = [...messages];
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
content: `${block}\n\n---\n\n${prompt}
|
|
514
|
-
}
|
|
457
|
+
// Inject skill block into the text portion of the content.
|
|
458
|
+
if (typeof rawContent === "string") {
|
|
459
|
+
cloned[lastUserIdx] = { ...cloned[lastUserIdx], content: `${block}\n\n---\n\n${prompt}` };
|
|
460
|
+
} else if (Array.isArray(rawContent)) {
|
|
461
|
+
const newParts = rawContent.map((p) =>
|
|
462
|
+
p.type === "text" ? { ...p, text: `${block}\n\n---\n\n${p.text}` } : p,
|
|
463
|
+
);
|
|
464
|
+
cloned[lastUserIdx] = { ...cloned[lastUserIdx], content: newParts };
|
|
465
|
+
}
|
|
515
466
|
return cloned;
|
|
516
467
|
}
|
package/src/api.js
CHANGED
|
@@ -1,43 +1,37 @@
|
|
|
1
1
|
// API client.
|
|
2
2
|
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
import { getConfig } from "./config.js";
|
|
4
|
-
import { getVersion } from "./version.js";
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
// Resolve our own version once for the User-Agent header — read from
|
|
9
|
+
// package.json so it can't drift (the file previously sent three different
|
|
10
|
+
// hardcoded versions across three calls).
|
|
11
|
+
function readVersion() {
|
|
12
|
+
try {
|
|
13
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
14
|
+
return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "0";
|
|
15
|
+
} catch {
|
|
16
|
+
return "0";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const USER_AGENT = `aether-code/${readVersion()}`;
|
|
7
20
|
|
|
8
21
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
22
|
|
|
10
|
-
// Per-request connect timeout. The server retries upstream internally (up to
|
|
11
|
-
// 5 × 75s = 375s worst case), and during that time the CLI's fetch() just
|
|
12
|
-
// hangs with zero feedback. This cap ensures we abort, show a retry message,
|
|
13
|
-
// and try again rather than sitting dead for minutes.
|
|
14
|
-
const CONNECT_TIMEOUT_MS = 90_000;
|
|
15
|
-
|
|
16
23
|
/**
|
|
17
24
|
* fetch() with bounded exponential-backoff retries. Retries on thrown network
|
|
18
25
|
* errors (DNS/reset/offline blip) and transient 5xx; never retries 4xx
|
|
19
26
|
* (auth/credit/validation — a retry won't help). Body is always a small JSON
|
|
20
27
|
* string, so resending is safe. For the streaming endpoint only the initial
|
|
21
28
|
* connection is retried.
|
|
22
|
-
*
|
|
23
|
-
* Each request has a hard 90s connect timeout via AbortController — if the
|
|
24
|
-
* server hasn't responded with headers by then, we abort and retry. This
|
|
25
|
-
* prevents the CLI from hanging indefinitely while the server retries
|
|
26
|
-
* upstream internally.
|
|
27
29
|
*/
|
|
28
|
-
export async function fetchWithRetry(url, options, { retries =
|
|
30
|
+
export async function fetchWithRetry(url, options, { retries = 2, baseDelay = 500, onRetry } = {}) {
|
|
29
31
|
let attempt = 0;
|
|
30
32
|
while (true) {
|
|
31
|
-
const ac = new AbortController();
|
|
32
|
-
const timer = setTimeout(() => ac.abort(new Error(`Server did not respond within ${Math.round(timeoutMs / 1000)}s`)), timeoutMs);
|
|
33
|
-
// If the caller already set a signal, chain it so both can abort.
|
|
34
|
-
const callerSignal = options?.signal;
|
|
35
|
-
if (callerSignal) {
|
|
36
|
-
callerSignal.addEventListener("abort", () => ac.abort(callerSignal.reason), { once: true });
|
|
37
|
-
}
|
|
38
33
|
try {
|
|
39
|
-
const res = await fetch(url,
|
|
40
|
-
clearTimeout(timer);
|
|
34
|
+
const res = await fetch(url, options);
|
|
41
35
|
if (res.status >= 500 && attempt < retries) {
|
|
42
36
|
// Release the connection back to the pool before retrying — an
|
|
43
37
|
// unconsumed body keeps the socket open (undici won't reuse it).
|
|
@@ -49,7 +43,6 @@ export async function fetchWithRetry(url, options, { retries = 4, baseDelay = 10
|
|
|
49
43
|
}
|
|
50
44
|
return res;
|
|
51
45
|
} catch (e) {
|
|
52
|
-
clearTimeout(timer);
|
|
53
46
|
if (attempt < retries) {
|
|
54
47
|
attempt++;
|
|
55
48
|
if (onRetry) onRetry(attempt, e.message);
|
|
@@ -67,12 +60,9 @@ function defaultOnRetry(attempt, why) {
|
|
|
67
60
|
|
|
68
61
|
/**
|
|
69
62
|
* Free balance + plan check via /api/v1/me. Doesn't charge credits.
|
|
70
|
-
* Pass an explicit key to bypass the config chain (used during setup to
|
|
71
|
-
* verify the exact key the user entered, not whatever env var is set).
|
|
72
63
|
*/
|
|
73
|
-
export async function fetchBalance(
|
|
74
|
-
const { apiKey
|
|
75
|
-
const apiKey = apiKeyOverride || configKey;
|
|
64
|
+
export async function fetchBalance() {
|
|
65
|
+
const { apiKey, baseUrl } = getConfig();
|
|
76
66
|
if (!apiKey) {
|
|
77
67
|
throw new AetherError(
|
|
78
68
|
"No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
|
|
@@ -100,7 +90,7 @@ export async function fetchBalance(apiKeyOverride) {
|
|
|
100
90
|
const msg = data?.error || `${res.status} ${res.statusText}`;
|
|
101
91
|
throw new AetherError(msg, code, res.status, data);
|
|
102
92
|
}
|
|
103
|
-
return data; // { plan, planCredits, topupCredits, balance, isSuspended }
|
|
93
|
+
return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
|
|
104
94
|
}
|
|
105
95
|
|
|
106
96
|
export class AetherError extends Error {
|
|
@@ -222,12 +212,12 @@ export async function agentTurnStream({
|
|
|
222
212
|
|
|
223
213
|
// Watchdog: if the open stream stalls (no bytes) for too long, abort instead
|
|
224
214
|
// of hanging the CLI forever.
|
|
225
|
-
const READ_TIMEOUT_MS =
|
|
215
|
+
const READ_TIMEOUT_MS = 120_000;
|
|
226
216
|
const readOnce = () => {
|
|
227
217
|
let timer;
|
|
228
218
|
const timeout = new Promise((_, reject) => {
|
|
229
219
|
timer = setTimeout(
|
|
230
|
-
() => reject(new AetherError(
|
|
220
|
+
() => reject(new AetherError("Stream stalled — no data for 120s", "STREAM_TIMEOUT", 0)),
|
|
231
221
|
READ_TIMEOUT_MS,
|
|
232
222
|
);
|
|
233
223
|
});
|