aether-code 0.43.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/bin/aether-code.js +6 -4
- package/package.json +4 -2
- 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 +168 -267
- 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 +288 -254
- package/src/repl.js +408 -444
- package/src/setup.js +6 -8
- package/src/tools.js +16 -393
- package/src/update-check.js +64 -7
- package/src/project-context.js +0 -712
- package/src/sessions.js +0 -167
- package/src/version.js +0 -15
package/src/agent.js
CHANGED
|
@@ -3,66 +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 ─────────────────────── */
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
};
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|
|
59
|
+
|
|
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;
|
|
44
70
|
}
|
|
45
71
|
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
48
80
|
|
|
49
|
-
|
|
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) {
|
|
50
84
|
const home = os.homedir();
|
|
51
85
|
const desktop = path.join(home, "Desktop");
|
|
52
86
|
const documents = path.join(home, "Documents");
|
|
53
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.
|
|
54
92
|
const shellNote = win
|
|
55
93
|
? `shell: Windows cmd.exe. To create files OR directories, use the write_file tool — ` +
|
|
56
94
|
`it creates any missing parent folders automatically. Do NOT use shell "mkdir -p", ` +
|
|
57
95
|
`"touch", "ls", "rm", "cp", "mv", or "&&"-chained Unix commands; they FAIL in cmd.exe. ` +
|
|
58
96
|
`Run programs/tests with their interpreter (python, node, java, etc.).`
|
|
59
97
|
: `shell: POSIX sh. Standard Unix commands are available. write_file still auto-creates parent dirs.`;
|
|
60
|
-
|
|
61
|
-
let projectBlock = "";
|
|
62
|
-
try {
|
|
63
|
-
projectBlock = scanProject(cwd);
|
|
64
|
-
} catch { /* non-fatal */ }
|
|
65
|
-
|
|
66
98
|
return (
|
|
67
99
|
`[environment]\n` +
|
|
68
100
|
`os: ${process.platform}\n` +
|
|
@@ -74,8 +106,7 @@ export function envContext(cwd) {
|
|
|
74
106
|
`When the user names a location ("my desktop", "home", "documents"), write to ` +
|
|
75
107
|
`the matching ABSOLUTE path above (e.g. desktop -> ${desktop}). Otherwise ` +
|
|
76
108
|
`work under the cwd. Use absolute paths when a specific location is named.\n` +
|
|
77
|
-
`[/environment]\n\n`
|
|
78
|
-
(projectBlock ? projectBlock + "\n" : "")
|
|
109
|
+
`[/environment]\n\n`
|
|
79
110
|
);
|
|
80
111
|
}
|
|
81
112
|
|
|
@@ -111,6 +142,15 @@ export async function runAgent({
|
|
|
111
142
|
process.stderr.write(c.yellow(`(skill load failed: ${e.message})\n`));
|
|
112
143
|
}
|
|
113
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
|
+
|
|
114
154
|
// Two callers: one-shot (initialPrompt only, fresh conversation) and REPL
|
|
115
155
|
// (priorMessages + initialPrompt to continue an ongoing chat).
|
|
116
156
|
// On the FIRST message of a session, prepend an environment block so the
|
|
@@ -118,9 +158,11 @@ export async function runAgent({
|
|
|
118
158
|
// X on my desktop" became `mkdir X` in whatever dir aether was launched from
|
|
119
159
|
// (e.g. C:\WINDOWS\system32). Only prepended once — later turns carry it in
|
|
120
160
|
// history.
|
|
161
|
+
const firstText = priorMessages ? cleanPrompt : envContext(cwd) + cleanPrompt;
|
|
162
|
+
const firstContent = buildContent(firstText, attachedImages);
|
|
121
163
|
const messages = priorMessages
|
|
122
|
-
? [...priorMessages, { role: "user", content:
|
|
123
|
-
: [{ role: "user", content:
|
|
164
|
+
? [...priorMessages, { role: "user", content: firstContent }]
|
|
165
|
+
: [{ role: "user", content: firstContent }];
|
|
124
166
|
let totalCredits = 0;
|
|
125
167
|
let totalIn = 0;
|
|
126
168
|
let totalOut = 0;
|
|
@@ -137,29 +179,30 @@ export async function runAgent({
|
|
|
137
179
|
// Completeness guard: gemma sometimes reads files then narrates the fix as
|
|
138
180
|
// text, or refactors one file but forgets the others. If a MODIFICATION task
|
|
139
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;
|
|
140
184
|
const looksLikeModification = MOD_RE.test(initialPrompt);
|
|
141
185
|
let appliedNothingNudges = 0;
|
|
142
|
-
let verifyRounds = 0;
|
|
143
|
-
let buildRounds = 0;
|
|
144
186
|
|
|
145
187
|
for (let i = 0; i < maxTurns; i++) {
|
|
146
|
-
|
|
147
|
-
// each tool label) begins with its own "\n● ", so spacing stays exactly one
|
|
148
|
-
// blank line per step instead of stacking up.
|
|
188
|
+
const turnStart = Date.now();
|
|
149
189
|
|
|
150
190
|
// Stream the assistant's response. Print text deltas as they arrive,
|
|
151
191
|
// along with tool-call announcements as soon as the model commits to
|
|
152
192
|
// calling a particular tool (i.e. the `name` arrives in the stream).
|
|
153
193
|
const announced = new Set();
|
|
154
194
|
let lastWasText = false;
|
|
195
|
+
let streamedChars = 0;
|
|
155
196
|
const stripper = makeTokenStripper();
|
|
197
|
+
let leakBuf = "";
|
|
198
|
+
let leakSuppressing = false;
|
|
156
199
|
|
|
157
200
|
// Select skills for this turn against the current user prompt + any
|
|
158
201
|
// paths the model has read so far. Prepend the matching skills' bodies
|
|
159
202
|
// to the last user message of a shallow-cloned messages array — we
|
|
160
203
|
// don't want skill text accumulating in the persisted history, only
|
|
161
204
|
// being available to the model for the turn where it's relevant.
|
|
162
|
-
const turnMessages =
|
|
205
|
+
const turnMessages = buildTurnMessages(messages, allSkills, referencedPaths);
|
|
163
206
|
|
|
164
207
|
let res;
|
|
165
208
|
// "Thinking" spinner: shown from request-send until the first token or tool
|
|
@@ -173,19 +216,34 @@ export async function runAgent({
|
|
|
173
216
|
tools,
|
|
174
217
|
model,
|
|
175
218
|
onDelta: (text) => {
|
|
176
|
-
|
|
177
|
-
|
|
219
|
+
streamedChars += text.length;
|
|
220
|
+
spinner.update({ tokens: Math.ceil(streamedChars / 3.5) });
|
|
178
221
|
const clean = stripper.push(text);
|
|
179
222
|
if (!clean) return;
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
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;
|
|
183
241
|
stopSpin();
|
|
184
242
|
if (!lastWasText) {
|
|
185
243
|
process.stdout.write("\n" + c.cyan("● "));
|
|
186
244
|
lastWasText = true;
|
|
187
245
|
}
|
|
188
|
-
process.stdout.write(
|
|
246
|
+
process.stdout.write(emit);
|
|
189
247
|
},
|
|
190
248
|
onToolCallDelta: (delta) => {
|
|
191
249
|
// Just close the streamed text line when the model starts a tool
|
|
@@ -215,13 +273,13 @@ export async function runAgent({
|
|
|
215
273
|
process.stdout.write(tail);
|
|
216
274
|
}
|
|
217
275
|
if (lastWasText) process.stdout.write("\n");
|
|
276
|
+
const turnIn = res.usage?.prompt_tokens ?? 0;
|
|
277
|
+
const turnOut = res.usage?.completion_tokens ?? 0;
|
|
218
278
|
totalCredits += res.creditsCharged ?? 0;
|
|
219
|
-
totalIn +=
|
|
220
|
-
totalOut +=
|
|
279
|
+
totalIn += turnIn;
|
|
280
|
+
totalOut += turnOut;
|
|
221
281
|
if (typeof res.balanceAfter === "number") lastBalance = res.balanceAfter;
|
|
222
282
|
onTokens({ totalCredits, totalIn, totalOut, balance: lastBalance });
|
|
223
|
-
// Per-turn cost line removed for a cleaner look — the session summary at the
|
|
224
|
-
// end carries the totals.
|
|
225
283
|
|
|
226
284
|
// Push assistant message into history
|
|
227
285
|
messages.push({
|
|
@@ -250,88 +308,6 @@ export async function runAgent({
|
|
|
250
308
|
messages.push({ role: "user", content: msg });
|
|
251
309
|
continue; // give the model another turn to finish
|
|
252
310
|
}
|
|
253
|
-
// Auto-build: if we wrote source files, detect the build toolchain and
|
|
254
|
-
// compile automatically. If build fails, feed errors back to the model
|
|
255
|
-
// so it can fix and recompile. Max 5 rounds to avoid infinite loops.
|
|
256
|
-
if (filesTouched.size > 0 && buildRounds < 5) {
|
|
257
|
-
const buildResult = await runBuild(cwd, [...filesTouched.keys()]);
|
|
258
|
-
if (buildResult && !buildResult.ok) {
|
|
259
|
-
buildRounds++;
|
|
260
|
-
console.log("\n" + c.yellow("●") + " " + c.bold("auto-build") + c.gray(` (${buildResult.label}) — round ${buildRounds}/5`));
|
|
261
|
-
console.log(c.red(" └─") + " " + c.gray("build failed — feeding errors back to fix"));
|
|
262
|
-
const errPreview = buildResult.output.length > 6000
|
|
263
|
-
? buildResult.output.slice(0, 6000) + "\n…(truncated)"
|
|
264
|
-
: buildResult.output;
|
|
265
|
-
messages.push({
|
|
266
|
-
role: "user",
|
|
267
|
-
content:
|
|
268
|
-
`AUTO-BUILD FAILED (${buildResult.label}):\n\`\`\`\n${errPreview}\n\`\`\`\n` +
|
|
269
|
-
`Fix the build errors NOW using edit_file. Do not explain — just fix the code and move on.`,
|
|
270
|
-
});
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
if (buildResult && buildResult.ok) {
|
|
274
|
-
console.log("\n" + c.green("●") + " " + c.bold("auto-build") + c.gray(` (${buildResult.label}) — success`));
|
|
275
|
-
// If there's a run command and we haven't already run it, execute the result
|
|
276
|
-
if (buildResult.runCmd && buildRounds === 0) {
|
|
277
|
-
const runResult = await runBuiltProgram(cwd, buildResult.runCmd, buildResult.label);
|
|
278
|
-
if (runResult && !runResult.ok && buildRounds < 5) {
|
|
279
|
-
buildRounds++;
|
|
280
|
-
console.log(c.yellow(" ├─") + " " + c.bold("auto-run") + c.gray(` — crashed (exit ${runResult.code})`));
|
|
281
|
-
const errPreview = runResult.output.length > 6000
|
|
282
|
-
? runResult.output.slice(0, 6000) + "\n…(truncated)"
|
|
283
|
-
: runResult.output;
|
|
284
|
-
messages.push({
|
|
285
|
-
role: "user",
|
|
286
|
-
content:
|
|
287
|
-
`BUILD SUCCEEDED but the program CRASHED when run:\n\`\`\`\n${errPreview}\n\`\`\`\n` +
|
|
288
|
-
`Fix the runtime error NOW using edit_file. Do not explain — just fix and move on.`,
|
|
289
|
-
});
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
if (runResult && runResult.ok) {
|
|
293
|
-
const preview = runResult.output.length > 2000
|
|
294
|
-
? runResult.output.slice(0, 2000) + "\n…(truncated)"
|
|
295
|
-
: runResult.output;
|
|
296
|
-
if (runResult.timedOut) {
|
|
297
|
-
console.log(c.green(" ├─") + " " + c.bold("auto-run") + c.gray(" — running (still alive after 10s)"));
|
|
298
|
-
} else {
|
|
299
|
-
console.log(c.green(" ├─") + " " + c.bold("auto-run") + c.gray(` — exit 0`));
|
|
300
|
-
}
|
|
301
|
-
if (preview.trim()) {
|
|
302
|
-
const lines = preview.trim().split("\n").slice(0, 15);
|
|
303
|
-
for (const line of lines) {
|
|
304
|
-
console.log(c.gray(" │ " + line));
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
// Auto-verify: if we touched files, run the project's typecheck/lint
|
|
312
|
-
// and feed errors back so the model can self-correct without the user
|
|
313
|
-
// having to say "fix that." At most 2 verify rounds to avoid infinite loops.
|
|
314
|
-
if (filesTouched.size > 0 && verifyRounds < 2) {
|
|
315
|
-
const verifyResult = await runVerify(cwd);
|
|
316
|
-
if (verifyResult && !verifyResult.ok) {
|
|
317
|
-
verifyRounds++;
|
|
318
|
-
console.log("\n" + c.yellow("●") + " " + c.bold("auto-verify") + c.gray(` (${verifyResult.label})`));
|
|
319
|
-
console.log(c.red(" └─") + " " + c.gray("errors found — feeding back to fix"));
|
|
320
|
-
const errPreview = verifyResult.output.length > 4000
|
|
321
|
-
? verifyResult.output.slice(0, 4000) + "\n…(truncated)"
|
|
322
|
-
: verifyResult.output;
|
|
323
|
-
messages.push({
|
|
324
|
-
role: "user",
|
|
325
|
-
content:
|
|
326
|
-
`AUTO-VERIFY FAILED (${verifyResult.label}):\n\`\`\`\n${errPreview}\n\`\`\`\n` +
|
|
327
|
-
`Fix these errors NOW using edit_file. Do not explain — just fix and move on.`,
|
|
328
|
-
});
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
if (verifyResult && verifyResult.ok) {
|
|
332
|
-
console.log("\n" + c.green("●") + " " + c.bold("auto-verify") + c.gray(` (${verifyResult.label}) — passed`));
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
311
|
if (filesTouched.size) {
|
|
336
312
|
console.log("\n" + c.bold("Files") + c.gray(` in ${cwd}`));
|
|
337
313
|
for (const [p, action] of filesTouched) {
|
|
@@ -400,133 +376,44 @@ export async function runAgent({
|
|
|
400
376
|
const summary = toolSummary(call.function.name, result);
|
|
401
377
|
if (summary) console.log(summary);
|
|
402
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
|
+
}
|
|
403
397
|
messages.push({
|
|
404
398
|
role: "tool",
|
|
405
399
|
tool_call_id: call.id,
|
|
406
|
-
content:
|
|
400
|
+
content: toolContent,
|
|
407
401
|
});
|
|
408
402
|
}
|
|
409
|
-
}
|
|
410
403
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
// 5 rounds of "fixing" code that's actually fine. These are the shell's
|
|
419
|
-
// command-not-found messages (Windows cmd.exe + POSIX sh); real compiler
|
|
420
|
-
// diagnostics look like `main.cpp:5:10: error: ...` and never match these.
|
|
421
|
-
// Matches ONLY the shell's "I can't find this executable" messages — Windows
|
|
422
|
-
// cmd.exe ("'g++' is not recognized…") and POSIX sh ("g++: command not found",
|
|
423
|
-
// "sh: 1: g++: not found"). Real compiler diagnostics (`main.cpp:5: error:`,
|
|
424
|
-
// `fatal error: foo.h: No such file or directory`) never match these, so a
|
|
425
|
-
// genuine build error is still reported and fixed.
|
|
426
|
-
const TOOLCHAIN_MISSING_RE =
|
|
427
|
-
/is not recognized as an internal or external command|: command not found|: not found\b/i;
|
|
428
|
-
|
|
429
|
-
// Auto-verify: run the project's typecheck/lint command silently and return
|
|
430
|
-
// { ok, output, label }. Returns null if no verify command is detected OR the
|
|
431
|
-
// toolchain isn't installed (skip, don't report a false failure).
|
|
432
|
-
let cachedVerify = undefined;
|
|
433
|
-
async function runVerify(cwd) {
|
|
434
|
-
if (cachedVerify === undefined) cachedVerify = detectVerifyCommand(cwd);
|
|
435
|
-
if (!cachedVerify) return null;
|
|
436
|
-
const { cmd, label } = cachedVerify;
|
|
437
|
-
return new Promise((resolve) => {
|
|
438
|
-
const child = spawn(cmd, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
439
|
-
let stdout = "";
|
|
440
|
-
let stderr = "";
|
|
441
|
-
const timer = setTimeout(() => { child.kill("SIGTERM"); }, 60_000);
|
|
442
|
-
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
443
|
-
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
444
|
-
child.on("close", (code) => {
|
|
445
|
-
clearTimeout(timer);
|
|
446
|
-
const output = (stdout + "\n" + stderr).trim();
|
|
447
|
-
// Toolchain not installed → skip silently rather than nag the model.
|
|
448
|
-
if (code !== 0 && TOOLCHAIN_MISSING_RE.test(output)) { resolve(null); return; }
|
|
449
|
-
resolve({ ok: code === 0, output, label });
|
|
450
|
-
});
|
|
451
|
-
child.on("error", () => {
|
|
452
|
-
clearTimeout(timer);
|
|
453
|
-
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,
|
|
454
411
|
});
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Auto-build: detect the build toolchain from written files and compile.
|
|
459
|
-
// Returns { ok, output, label, runCmd } or null if no buildable project detected.
|
|
460
|
-
let cachedBuild = undefined;
|
|
461
|
-
async function runBuild(cwd, touchedFiles) {
|
|
462
|
-
// Re-detect every time because the model may create new files (e.g. Makefile)
|
|
463
|
-
// or change the project structure between rounds.
|
|
464
|
-
const detected = detectBuildCommand(cwd, touchedFiles);
|
|
465
|
-
if (!detected) {
|
|
466
|
-
// Fallback: if we previously detected a build command, reuse it (the model
|
|
467
|
-
// may have only edited files this round, not created new ones).
|
|
468
|
-
if (cachedBuild) return runBuildCmd(cwd, cachedBuild);
|
|
469
|
-
return null;
|
|
412
|
+
if (stats) console.log(stats);
|
|
470
413
|
}
|
|
471
|
-
cachedBuild = detected;
|
|
472
|
-
return runBuildCmd(cwd, detected);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
async function runBuildCmd(cwd, { cmd, label, type, runCmd }) {
|
|
476
|
-
return new Promise((resolve) => {
|
|
477
|
-
const child = spawn(cmd, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
478
|
-
let stdout = "";
|
|
479
|
-
let stderr = "";
|
|
480
|
-
const timer = setTimeout(() => { child.kill("SIGTERM"); }, 120_000);
|
|
481
|
-
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
482
|
-
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
483
|
-
child.on("close", (code) => {
|
|
484
|
-
clearTimeout(timer);
|
|
485
|
-
const output = (stdout + "\n" + stderr).trim();
|
|
486
|
-
// Compiler/toolchain not installed → skip auto-build silently instead of
|
|
487
|
-
// reporting a build failure and derailing the run.
|
|
488
|
-
if (code !== 0 && TOOLCHAIN_MISSING_RE.test(output)) { resolve(null); return; }
|
|
489
|
-
resolve({ ok: code === 0, output, label, type, runCmd });
|
|
490
|
-
});
|
|
491
|
-
child.on("error", () => {
|
|
492
|
-
clearTimeout(timer);
|
|
493
|
-
// ENOENT (command not found) → toolchain missing → skip.
|
|
494
|
-
resolve(null);
|
|
495
|
-
});
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
414
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
async function runBuiltProgram(cwd, runCmd, label) {
|
|
502
|
-
return new Promise((resolve) => {
|
|
503
|
-
const child = spawn(runCmd, [], { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
504
|
-
let stdout = "";
|
|
505
|
-
let stderr = "";
|
|
506
|
-
let timedOut = false;
|
|
507
|
-
const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, 10_000);
|
|
508
|
-
child.stdout.on("data", (d) => {
|
|
509
|
-
stdout += d.toString();
|
|
510
|
-
if (stdout.length > 50_000) { child.kill("SIGTERM"); }
|
|
511
|
-
});
|
|
512
|
-
child.stderr.on("data", (d) => {
|
|
513
|
-
stderr += d.toString();
|
|
514
|
-
if (stderr.length > 50_000) { child.kill("SIGTERM"); }
|
|
515
|
-
});
|
|
516
|
-
child.on("close", (code) => {
|
|
517
|
-
clearTimeout(timer);
|
|
518
|
-
const output = (stdout + "\n" + stderr).trim();
|
|
519
|
-
if (timedOut) {
|
|
520
|
-
resolve({ ok: true, output, code: null, timedOut: true });
|
|
521
|
-
} else {
|
|
522
|
-
resolve({ ok: code === 0, output, code, timedOut: false });
|
|
523
|
-
}
|
|
524
|
-
});
|
|
525
|
-
child.on("error", (err) => {
|
|
526
|
-
clearTimeout(timer);
|
|
527
|
-
resolve({ ok: false, output: err.message, code: null, timedOut: false });
|
|
528
|
-
});
|
|
529
|
-
});
|
|
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 };
|
|
530
417
|
}
|
|
531
418
|
|
|
532
419
|
/**
|
|
@@ -543,7 +430,7 @@ async function runBuiltProgram(cwd, runCmd, label) {
|
|
|
543
430
|
* worse than no skills at all. Prepending into the user message keeps
|
|
544
431
|
* both layers active.
|
|
545
432
|
*/
|
|
546
|
-
|
|
433
|
+
function buildTurnMessages(messages, allSkills, referencedPaths) {
|
|
547
434
|
if (allSkills.length === 0) return messages;
|
|
548
435
|
// Find the latest user message — that's where the current task lives.
|
|
549
436
|
let lastUserIdx = -1;
|
|
@@ -551,16 +438,30 @@ export function buildTurnMessages(messages, allSkills, referencedPaths) {
|
|
|
551
438
|
if (messages[i].role === "user") { lastUserIdx = i; break; }
|
|
552
439
|
}
|
|
553
440
|
if (lastUserIdx === -1) return messages;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
+
}
|
|
557
453
|
const active = selectSkills({ skills: allSkills, prompt, referencedPaths });
|
|
558
454
|
if (active.length === 0) return messages;
|
|
559
455
|
const block = renderSkillsBlock(active);
|
|
560
456
|
const cloned = [...messages];
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
content: `${block}\n\n---\n\n${prompt}
|
|
564
|
-
}
|
|
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
|
+
}
|
|
565
466
|
return cloned;
|
|
566
467
|
}
|