aether-code 0.32.2 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,7 @@ import {
28
28
  import readline from "node:readline";
29
29
  import { c, errorLine, divider, setTerminalTitle } from "../src/render.js";
30
30
 
31
- const VERSION = "0.32.0";
31
+ const VERSION = "0.33.0";
32
32
 
33
33
  /**
34
34
  * Try to start MCP servers from ~/.aether/mcp.json. Returns a started
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-code",
3
- "version": "0.32.2",
3
+ "version": "0.33.0",
4
4
  "description": "Uncensored AI coding agent for your terminal — Claude Code alternative with MCP support. Reads code, writes files, runs commands. Drives IDA Pro, Roblox Studio, Wireshark, Blender, and any MCP server. No refusal layer.",
5
5
  "homepage": "https://trynoguard.com",
6
6
  "repository": {
package/src/agent.js CHANGED
@@ -3,12 +3,78 @@
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
8
  import { agentTurnStream, AetherError } from "./api.js";
8
9
  import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
9
10
  import { unnamespaceToolName } from "./mcp.js";
10
11
  import { loadAllSkills, selectSkills, renderSkillsBlock } from "./skills.js";
11
- 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";
13
+
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
+ }
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;
70
+ }
71
+
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
+ }
12
78
 
13
79
  const DEFAULT_MAX_TURNS = 25;
14
80
 
@@ -76,6 +142,15 @@ export async function runAgent({
76
142
  process.stderr.write(c.yellow(`(skill load failed: ${e.message})\n`));
77
143
  }
78
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
+
79
154
  // Two callers: one-shot (initialPrompt only, fresh conversation) and REPL
80
155
  // (priorMessages + initialPrompt to continue an ongoing chat).
81
156
  // On the FIRST message of a session, prepend an environment block so the
@@ -83,9 +158,11 @@ export async function runAgent({
83
158
  // X on my desktop" became `mkdir X` in whatever dir aether was launched from
84
159
  // (e.g. C:\WINDOWS\system32). Only prepended once — later turns carry it in
85
160
  // history.
161
+ const firstText = priorMessages ? cleanPrompt : envContext(cwd) + cleanPrompt;
162
+ const firstContent = buildContent(firstText, attachedImages);
86
163
  const messages = priorMessages
87
- ? [...priorMessages, { role: "user", content: initialPrompt }]
88
- : [{ role: "user", content: envContext(cwd) + initialPrompt }];
164
+ ? [...priorMessages, { role: "user", content: firstContent }]
165
+ : [{ role: "user", content: firstContent }];
89
166
  let totalCredits = 0;
90
167
  let totalIn = 0;
91
168
  let totalOut = 0;
@@ -108,15 +185,14 @@ export async function runAgent({
108
185
  let appliedNothingNudges = 0;
109
186
 
110
187
  for (let i = 0; i < maxTurns; i++) {
111
- // No turn header and no leading blank here — each step (assistant text and
112
- // each tool label) begins with its own "\n● ", so spacing stays exactly one
113
- // blank line per step instead of stacking up.
188
+ const turnStart = Date.now();
114
189
 
115
190
  // Stream the assistant's response. Print text deltas as they arrive,
116
191
  // along with tool-call announcements as soon as the model commits to
117
192
  // calling a particular tool (i.e. the `name` arrives in the stream).
118
193
  const announced = new Set();
119
194
  let lastWasText = false;
195
+ let streamedChars = 0;
120
196
  const stripper = makeTokenStripper();
121
197
 
122
198
  // Select skills for this turn against the current user prompt + any
@@ -138,6 +214,8 @@ export async function runAgent({
138
214
  tools,
139
215
  model,
140
216
  onDelta: (text) => {
217
+ streamedChars += text.length;
218
+ spinner.update({ tokens: Math.ceil(streamedChars / 3.5) });
141
219
  // Buffered strip of leaked model channel/control tokens (which can
142
220
  // be split across stream chunks) before display.
143
221
  const clean = stripper.push(text);
@@ -180,13 +258,13 @@ export async function runAgent({
180
258
  process.stdout.write(tail);
181
259
  }
182
260
  if (lastWasText) process.stdout.write("\n");
261
+ const turnIn = res.usage?.prompt_tokens ?? 0;
262
+ const turnOut = res.usage?.completion_tokens ?? 0;
183
263
  totalCredits += res.creditsCharged ?? 0;
184
- totalIn += res.usage?.prompt_tokens ?? 0;
185
- totalOut += res.usage?.completion_tokens ?? 0;
264
+ totalIn += turnIn;
265
+ totalOut += turnOut;
186
266
  if (typeof res.balanceAfter === "number") lastBalance = res.balanceAfter;
187
267
  onTokens({ totalCredits, totalIn, totalOut, balance: lastBalance });
188
- // Per-turn cost line removed for a cleaner look — the session summary at the
189
- // end carries the totals.
190
268
 
191
269
  // Push assistant message into history
192
270
  messages.push({
@@ -289,6 +367,16 @@ export async function runAgent({
289
367
  content: result.output ?? (result.ok ? "(no output)" : "Failed."),
290
368
  });
291
369
  }
370
+
371
+ // Per-turn stats line — shows elapsed time, token flow, and tool count.
372
+ const stats = turnStats({
373
+ elapsed: Date.now() - turnStart,
374
+ tokensIn: turnIn,
375
+ tokensOut: turnOut,
376
+ tools: toolCalls.length,
377
+ credits: res.creditsCharged ?? 0,
378
+ });
379
+ if (stats) console.log(stats);
292
380
  }
293
381
 
294
382
  console.log(c.yellow(`\nReached max turns (${maxTurns}). Stopping.`));
@@ -317,16 +405,30 @@ function buildTurnMessages(messages, allSkills, referencedPaths) {
317
405
  if (messages[i].role === "user") { lastUserIdx = i; break; }
318
406
  }
319
407
  if (lastUserIdx === -1) return messages;
320
- const prompt = typeof messages[lastUserIdx].content === "string"
321
- ? messages[lastUserIdx].content
322
- : "";
408
+ // Content can be a string or a multimodal array (when images are attached).
409
+ // Extract the text portion for skill selection; prepend skills to text only.
410
+ const rawContent = messages[lastUserIdx].content;
411
+ let prompt;
412
+ if (typeof rawContent === "string") {
413
+ prompt = rawContent;
414
+ } else if (Array.isArray(rawContent)) {
415
+ const textPart = rawContent.find((p) => p.type === "text");
416
+ prompt = textPart?.text ?? "";
417
+ } else {
418
+ prompt = "";
419
+ }
323
420
  const active = selectSkills({ skills: allSkills, prompt, referencedPaths });
324
421
  if (active.length === 0) return messages;
325
422
  const block = renderSkillsBlock(active);
326
423
  const cloned = [...messages];
327
- cloned[lastUserIdx] = {
328
- ...cloned[lastUserIdx],
329
- content: `${block}\n\n---\n\n${prompt}`,
330
- };
424
+ // Inject skill block into the text portion of the content.
425
+ if (typeof rawContent === "string") {
426
+ cloned[lastUserIdx] = { ...cloned[lastUserIdx], content: `${block}\n\n---\n\n${prompt}` };
427
+ } else if (Array.isArray(rawContent)) {
428
+ const newParts = rawContent.map((p) =>
429
+ p.type === "text" ? { ...p, text: `${block}\n\n---\n\n${p.text}` } : p,
430
+ );
431
+ cloned[lastUserIdx] = { ...cloned[lastUserIdx], content: newParts };
432
+ }
331
433
  return cloned;
332
434
  }
package/src/box-input.js CHANGED
@@ -64,7 +64,7 @@ export function promptBox({ history = [], placeholder = "type a message… /hel
64
64
  const top = c.magenta("╭" + "─".repeat(W + 4) + "╮");
65
65
  const mid = c.magenta("│") + " " + c.magenta("›") + " " + content + " " + c.magenta("│");
66
66
  const bot = c.magenta("╰" + "─".repeat(W + 4) + "╯");
67
- const hint = c.dim(" Enter send · ↑↓ history · /help · /exit");
67
+ const hint = c.dim(" Enter send · ↑↓ history · @file for images · /help · /exit");
68
68
 
69
69
  process.stdout.write(top + "\n" + mid + "\n" + bot + "\n" + hint + "\n");
70
70
  drawn = 4;
package/src/render.js CHANGED
@@ -98,33 +98,69 @@ export function setTerminalTitle(title) {
98
98
  if (process.stdout.isTTY) process.stdout.write(`\x1b]0;${title}\x07`);
99
99
  }
100
100
 
101
- // Live "working…" spinner. Rewrites a single line in place (\r) with a rotating
102
- // half-circle (Geometric Shapes — same block as ●, so cmd.exe-safe) + elapsed
103
- // seconds, so the user sees the agent is alive while the model thinks or a slow
104
- // tool runs. stop() clears the line and restores the cursor. No-op in non-TTY.
101
+ // Live "working…" spinner with optional live metrics (token count, status).
102
+ // Rewrites a single line in place (\r) with a rotating half-circle + elapsed
103
+ // time (mm:ss when > 60s) + any metrics fed via update(). stop() clears the
104
+ // line and restores the cursor. No-op in non-TTY.
105
105
  export function startSpinner(label) {
106
- if (!process.stdout.isTTY) return { stop: () => {} };
106
+ if (!process.stdout.isTTY) return { stop: () => {}, update: () => {} };
107
107
  const frames = ["◐", "◓", "◑", "◒"];
108
108
  const t0 = Date.now();
109
109
  let i = 0;
110
+ let tokenCount = 0;
111
+ let statusText = "";
110
112
  process.stdout.write("\x1b[?25l"); // hide cursor
111
113
  const render = () => {
112
- const s = Math.floor((Date.now() - t0) / 1000);
113
- const tail = s > 0 ? c.gray(` ${s}s`) : "";
114
- process.stdout.write(`\r\x1b[K${c.cyan(frames[i % frames.length])} ${c.gray(label + "…")}${tail}`);
114
+ const ms = Date.now() - t0;
115
+ const secs = Math.floor(ms / 1000);
116
+ const timeFmt = secs >= 60
117
+ ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, "0")}s`
118
+ : `${secs}s`;
119
+ const parts = [];
120
+ if (secs > 0) parts.push(timeFmt);
121
+ if (tokenCount > 0) parts.push(`↓ ${fmtTokens(tokenCount)} tokens`);
122
+ if (statusText) parts.push(statusText);
123
+ const metrics = parts.length > 0 ? c.gray(` (${parts.join(" · ")})`) : "";
124
+ process.stdout.write(`\r\x1b[K${c.cyan(frames[i % frames.length])} ${c.gray(label + "…")}${metrics}`);
115
125
  i++;
116
126
  };
117
127
  render();
118
128
  const timer = setInterval(render, 120);
119
129
  if (typeof timer.unref === "function") timer.unref(); // don't keep the process alive
120
130
  return {
121
- stop: () => {
131
+ update(data) {
132
+ if (data.tokens != null) tokenCount = data.tokens;
133
+ if (data.status != null) statusText = data.status;
134
+ },
135
+ stop() {
122
136
  clearInterval(timer);
123
137
  process.stdout.write("\r\x1b[K\x1b[?25h"); // clear line + show cursor
124
138
  },
125
139
  };
126
140
  }
127
141
 
142
+ // Human-friendly token count (14.2k, 1.3M, etc.)
143
+ export function fmtTokens(n) {
144
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
145
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
146
+ return String(n);
147
+ }
148
+
149
+ // Compact one-line turn summary printed after each agent turn completes.
150
+ export function turnStats({ elapsed, tokensIn, tokensOut, tools, credits }) {
151
+ const parts = [];
152
+ if (elapsed > 0) {
153
+ const s = Math.floor(elapsed / 1000);
154
+ parts.push(s >= 60 ? `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s` : `${s}s`);
155
+ }
156
+ if (tokensIn > 0 || tokensOut > 0) {
157
+ parts.push(`${fmtTokens(tokensIn)}→${fmtTokens(tokensOut)} tokens`);
158
+ }
159
+ if (tools > 0) parts.push(`${tools} tool call${tools === 1 ? "" : "s"}`);
160
+ if (credits > 0) parts.push(`${credits} cr`);
161
+ return parts.length > 0 ? c.dim(` ── ${parts.join(" · ")}`) : "";
162
+ }
163
+
128
164
  // Terse one-line result summary instead of dumping raw JSON / file contents.
129
165
  // Tools whose handlers already render rich output (diffs, the shell stream, the
130
166
  // plan) just get a check — the detail was already printed.
package/src/repl.js CHANGED
@@ -16,7 +16,7 @@ import { c, errorLine, boxLines, sideBySide } from "./render.js";
16
16
  import { checkForUpdate } from "./update-check.js";
17
17
  import { promptBox, EXIT_SIGNAL } from "./box-input.js";
18
18
 
19
- const VERSION = "0.32.0";
19
+ const VERSION = "0.33.0";
20
20
  const MODEL_NAME = "Aether Core";
21
21
 
22
22
  // /model aliases → { id, name }. id=null = server default (gemma / Aether Core).
@@ -245,9 +245,9 @@ const shortenPath = (p, max) => (p.length <= max ? p : "..." + p.slice(p.length
245
245
  // Short, hand-curated highlights shown in the "What's new" box. Update with
246
246
  // notable releases — keep it to ~3 lines so it balances the banner height.
247
247
  const WHATS_NEW = [
248
- "Files now save to your Desktop",
249
- "Live thinking spinner + loop guard",
250
- "Asks you when a task is unclear",
248
+ "Image support — @file.png in your prompt",
249
+ "Live token counter + turn stats",
250
+ "Enhanced spinner with mm:ss timing",
251
251
  ];
252
252
 
253
253
  function printBanner(state) {