aether-code 0.36.8 → 0.37.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.
@@ -27,8 +27,9 @@ import {
27
27
  } from "../src/mcp-registry.js";
28
28
  import readline from "node:readline";
29
29
  import { c, errorLine, divider, setTerminalTitle } from "../src/render.js";
30
+ import { getVersion } from "../src/version.js";
30
31
 
31
- const VERSION = "0.32.0";
32
+ const VERSION = getVersion();
32
33
 
33
34
  /**
34
35
  * Try to start MCP servers from ~/.aether/mcp.json. Returns a started
@@ -315,13 +316,10 @@ async function handleBalance() {
315
316
  const me = await fetchBalance();
316
317
  console.log(c.bold(c.magenta("Aether")));
317
318
  console.log(c.gray("─".repeat(50)));
318
- console.log(`Plan ${c.cyan(me.plan)}${me.role !== "USER" ? c.gray(` · ${me.role}`) : ""}`);
319
+ console.log(`Plan ${c.cyan(me.plan)}`);
319
320
  console.log(`Balance ${c.bold(me.balance.toLocaleString())} credits`);
320
321
  console.log(` plan ${me.planCredits.toLocaleString()}`);
321
322
  console.log(` topup ${me.topupCredits.toLocaleString()}`);
322
- if (me.rate) {
323
- console.log(`Rate ${me.rate.used}/${me.rate.limit} this hour${me.rate.resetIn ? ` · resets in ${me.rate.resetIn}s` : ""}`);
324
- }
325
323
  if (me.isSuspended) console.log(c.red("\n⚠ Account is suspended."));
326
324
  } catch (err) {
327
325
  if (err instanceof AetherError && err.code === "NO_API_KEY") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-code",
3
- "version": "0.36.8",
3
+ "version": "0.37.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": {
@@ -23,7 +23,7 @@
23
23
  "node": ">=18"
24
24
  },
25
25
  "scripts": {
26
- "lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/mcp-registry.js src/skills.js src/update-check.js src/ink-input.js",
26
+ "lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/mcp-registry.js src/skills.js src/update-check.js src/ink-input.js src/version.js src/sessions.js src/project-context.js",
27
27
  "test": "node --test \"test/**/*.test.js\"",
28
28
  "prepublishOnly": "npm run lint && npm test"
29
29
  },
package/src/agent.js CHANGED
@@ -8,27 +8,31 @@ import { agentTurnStream, AetherError } from "./api.js";
8
8
  import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
9
9
  import { unnamespaceToolName } from "./mcp.js";
10
10
  import { loadAllSkills, selectSkills, renderSkillsBlock } from "./skills.js";
11
+ import { scanProject } from "./project-context.js";
11
12
  import { c, divider, turn, toolLabel, toolSummary, makeTokenStripper, errorLine, startSpinner } from "./render.js";
12
13
 
13
14
  const DEFAULT_MAX_TURNS = 25;
14
15
 
15
- // Environment block prepended to the first user message so the model can
16
- // resolve named locations to real absolute paths.
17
- function envContext(cwd) {
16
+ export const MOD_RE =
17
+ /\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;
18
+
19
+ export function envContext(cwd) {
18
20
  const home = os.homedir();
19
21
  const desktop = path.join(home, "Desktop");
20
22
  const documents = path.join(home, "Documents");
21
23
  const win = process.platform === "win32";
22
- // OS-specific shell guidance: gemma tends to emit Unix commands (mkdir -p,
23
- // touch, &&-chains) which FAIL in Windows cmd.exe ("The syntax of the command
24
- // is incorrect"). Steer it to the file tools (which are cross-platform and
25
- // auto-create parent dirs) and OS-appropriate shell usage.
26
24
  const shellNote = win
27
25
  ? `shell: Windows cmd.exe. To create files OR directories, use the write_file tool — ` +
28
26
  `it creates any missing parent folders automatically. Do NOT use shell "mkdir -p", ` +
29
27
  `"touch", "ls", "rm", "cp", "mv", or "&&"-chained Unix commands; they FAIL in cmd.exe. ` +
30
28
  `Run programs/tests with their interpreter (python, node, java, etc.).`
31
29
  : `shell: POSIX sh. Standard Unix commands are available. write_file still auto-creates parent dirs.`;
30
+
31
+ let projectBlock = "";
32
+ try {
33
+ projectBlock = scanProject(cwd);
34
+ } catch { /* non-fatal */ }
35
+
32
36
  return (
33
37
  `[environment]\n` +
34
38
  `os: ${process.platform}\n` +
@@ -40,7 +44,8 @@ function envContext(cwd) {
40
44
  `When the user names a location ("my desktop", "home", "documents"), write to ` +
41
45
  `the matching ABSOLUTE path above (e.g. desktop -> ${desktop}). Otherwise ` +
42
46
  `work under the cwd. Use absolute paths when a specific location is named.\n` +
43
- `[/environment]\n\n`
47
+ `[/environment]\n\n` +
48
+ (projectBlock ? projectBlock + "\n" : "")
44
49
  );
45
50
  }
46
51
 
@@ -102,8 +107,6 @@ export async function runAgent({
102
107
  // Completeness guard: gemma sometimes reads files then narrates the fix as
103
108
  // text, or refactors one file but forgets the others. If a MODIFICATION task
104
109
  // finishes having read files it never edited, we nudge it once to finish.
105
- const MOD_RE =
106
- /\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;
107
110
  const looksLikeModification = MOD_RE.test(initialPrompt);
108
111
  let appliedNothingNudges = 0;
109
112
 
@@ -309,7 +312,7 @@ export async function runAgent({
309
312
  * worse than no skills at all. Prepending into the user message keeps
310
313
  * both layers active.
311
314
  */
312
- function buildTurnMessages(messages, allSkills, referencedPaths) {
315
+ export function buildTurnMessages(messages, allSkills, referencedPaths) {
313
316
  if (allSkills.length === 0) return messages;
314
317
  // Find the latest user message — that's where the current task lives.
315
318
  let lastUserIdx = -1;
package/src/api.js CHANGED
@@ -1,22 +1,9 @@
1
1
  // API client.
2
2
 
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
3
  import { getConfig } from "./config.js";
4
+ import { getVersion } from "./version.js";
7
5
 
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()}`;
6
+ const USER_AGENT = `aether-code/${getVersion()}`;
20
7
 
21
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
9
 
@@ -93,7 +80,7 @@ export async function fetchBalance(apiKeyOverride) {
93
80
  const msg = data?.error || `${res.status} ${res.statusText}`;
94
81
  throw new AetherError(msg, code, res.status, data);
95
82
  }
96
- return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
83
+ return data; // { plan, planCredits, topupCredits, balance, isSuspended }
97
84
  }
98
85
 
99
86
  export class AetherError extends Error {
package/src/mcp.js CHANGED
@@ -31,6 +31,7 @@ import os from "node:os";
31
31
  import path from "node:path";
32
32
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
33
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
34
+ import { getVersion } from "./version.js";
34
35
 
35
36
  const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".aether", "mcp.json");
36
37
  const NAMESPACE_SEP = "__";
@@ -163,7 +164,7 @@ export class MCPManager {
163
164
  env: { ...process.env, ...(cfg.env ?? {}) },
164
165
  });
165
166
  const client = new Client(
166
- { name: "aether-code", version: "0.9.0" },
167
+ { name: "aether-code", version: getVersion() },
167
168
  { capabilities: {} },
168
169
  );
169
170
  // Bound the init handshake so a hung server doesn't stall startup.
@@ -0,0 +1,279 @@
1
+ // Project context scanner. Reads the cwd on startup to give the model
2
+ // immediate understanding of what project it's working in — tech stack,
3
+ // file structure, available scripts, dependencies, and any project-specific
4
+ // instructions. This is the single biggest quality-of-life improvement for
5
+ // a weaker model: instead of guessing, it KNOWS.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const SKIP_DIRS = new Set([
11
+ "node_modules", ".git", ".next", "dist", "build", "__pycache__",
12
+ ".venv", "venv", "env", ".env", ".idea", ".vscode", "coverage",
13
+ ".cache", ".turbo", "target", "out", ".output", ".nuxt", ".svelte-kit",
14
+ "vendor", "bower_components", ".parcel-cache", ".webpack",
15
+ ]);
16
+
17
+ const FRAMEWORK_SIGNATURES = {
18
+ "next.config.js": "Next.js",
19
+ "next.config.mjs": "Next.js",
20
+ "next.config.ts": "Next.js",
21
+ "nuxt.config.ts": "Nuxt",
22
+ "svelte.config.js": "SvelteKit",
23
+ "astro.config.mjs": "Astro",
24
+ "remix.config.js": "Remix",
25
+ "angular.json": "Angular",
26
+ "vue.config.js": "Vue CLI",
27
+ "vite.config.ts": "Vite",
28
+ "vite.config.js": "Vite",
29
+ "webpack.config.js": "Webpack",
30
+ "tsconfig.json": "TypeScript",
31
+ "Cargo.toml": "Rust (Cargo)",
32
+ "go.mod": "Go",
33
+ "pyproject.toml": "Python",
34
+ "setup.py": "Python (setuptools)",
35
+ "requirements.txt": "Python",
36
+ "Gemfile": "Ruby (Bundler)",
37
+ "pom.xml": "Java (Maven)",
38
+ "build.gradle": "Java/Kotlin (Gradle)",
39
+ "CMakeLists.txt": "C/C++ (CMake)",
40
+ "Makefile": "Make",
41
+ "Dockerfile": "Docker",
42
+ "docker-compose.yml": "Docker Compose",
43
+ "docker-compose.yaml": "Docker Compose",
44
+ ".env": "dotenv config",
45
+ "prisma/schema.prisma": "Prisma ORM",
46
+ "drizzle.config.ts": "Drizzle ORM",
47
+ };
48
+
49
+ const DEP_FRAMEWORKS = {
50
+ next: "Next.js",
51
+ react: "React",
52
+ vue: "Vue",
53
+ svelte: "Svelte",
54
+ angular: "Angular",
55
+ express: "Express",
56
+ fastify: "Fastify",
57
+ koa: "Koa",
58
+ hono: "Hono",
59
+ "socket.io": "Socket.IO",
60
+ prisma: "Prisma",
61
+ drizzle: "Drizzle",
62
+ mongoose: "Mongoose",
63
+ sequelize: "Sequelize",
64
+ tailwindcss: "Tailwind CSS",
65
+ "styled-components": "styled-components",
66
+ electron: "Electron",
67
+ jest: "Jest",
68
+ vitest: "Vitest",
69
+ mocha: "Mocha",
70
+ playwright: "Playwright",
71
+ cypress: "Cypress",
72
+ };
73
+
74
+ function readJsonSafe(filePath) {
75
+ try {
76
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ function readTextSafe(filePath, maxBytes = 2000) {
83
+ try {
84
+ const stat = fs.statSync(filePath);
85
+ if (!stat.isFile() || stat.size > 500_000) return null;
86
+ const buf = Buffer.alloc(Math.min(stat.size, maxBytes));
87
+ const fd = fs.openSync(filePath, "r");
88
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
89
+ fs.closeSync(fd);
90
+ return buf.toString("utf8", 0, bytesRead);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function listTopLevel(cwd) {
97
+ let entries;
98
+ try {
99
+ entries = fs.readdirSync(cwd, { withFileTypes: true });
100
+ } catch {
101
+ return { dirs: [], files: [] };
102
+ }
103
+ const dirs = [];
104
+ const files = [];
105
+ for (const e of entries) {
106
+ if (e.name.startsWith(".") && e.name !== ".env" && e.name !== ".gitignore") continue;
107
+ if (SKIP_DIRS.has(e.name)) continue;
108
+ if (e.isDirectory()) {
109
+ dirs.push(e.name + "/");
110
+ } else if (e.isFile()) {
111
+ files.push(e.name);
112
+ }
113
+ }
114
+ dirs.sort();
115
+ files.sort();
116
+ return { dirs, files };
117
+ }
118
+
119
+ function listSecondLevel(cwd, dirName) {
120
+ const full = path.join(cwd, dirName);
121
+ let entries;
122
+ try {
123
+ entries = fs.readdirSync(full, { withFileTypes: true });
124
+ } catch {
125
+ return [];
126
+ }
127
+ const items = [];
128
+ for (const e of entries) {
129
+ if (e.name.startsWith(".")) continue;
130
+ if (SKIP_DIRS.has(e.name)) continue;
131
+ items.push(e.isDirectory() ? e.name + "/" : e.name);
132
+ }
133
+ items.sort();
134
+ return items.slice(0, 30);
135
+ }
136
+
137
+ function detectFrameworks(cwd, topFiles) {
138
+ const found = new Set();
139
+ for (const file of topFiles) {
140
+ if (FRAMEWORK_SIGNATURES[file]) found.add(FRAMEWORK_SIGNATURES[file]);
141
+ }
142
+ if (fs.existsSync(path.join(cwd, "prisma", "schema.prisma"))) {
143
+ found.add("Prisma ORM");
144
+ }
145
+ return [...found];
146
+ }
147
+
148
+ function extractPackageInfo(cwd) {
149
+ const pkg = readJsonSafe(path.join(cwd, "package.json"));
150
+ if (!pkg) return null;
151
+ const info = { name: pkg.name, version: pkg.version };
152
+ if (pkg.scripts && Object.keys(pkg.scripts).length > 0) {
153
+ info.scripts = Object.keys(pkg.scripts);
154
+ }
155
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
156
+ const detectedLibs = [];
157
+ for (const [dep, label] of Object.entries(DEP_FRAMEWORKS)) {
158
+ if (allDeps[dep]) detectedLibs.push(label);
159
+ }
160
+ if (detectedLibs.length > 0) info.frameworks = detectedLibs;
161
+ const depCount = Object.keys(pkg.dependencies || {}).length;
162
+ const devCount = Object.keys(pkg.devDependencies || {}).length;
163
+ if (depCount || devCount) {
164
+ info.depCounts = `${depCount} deps, ${devCount} devDeps`;
165
+ }
166
+ return info;
167
+ }
168
+
169
+ function extractCargoInfo(cwd) {
170
+ const raw = readTextSafe(path.join(cwd, "Cargo.toml"), 1500);
171
+ if (!raw) return null;
172
+ const name = raw.match(/^name\s*=\s*"(.+?)"/m)?.[1];
173
+ const version = raw.match(/^version\s*=\s*"(.+?)"/m)?.[1];
174
+ return { name, version, type: "Rust" };
175
+ }
176
+
177
+ function extractGoInfo(cwd) {
178
+ const raw = readTextSafe(path.join(cwd, "go.mod"), 500);
179
+ if (!raw) return null;
180
+ const mod = raw.match(/^module\s+(\S+)/m)?.[1];
181
+ const goVer = raw.match(/^go\s+(\S+)/m)?.[1];
182
+ return { module: mod, goVersion: goVer, type: "Go" };
183
+ }
184
+
185
+ function extractPythonInfo(cwd) {
186
+ const pyproject = readTextSafe(path.join(cwd, "pyproject.toml"), 1500);
187
+ if (pyproject) {
188
+ const name = pyproject.match(/^name\s*=\s*"(.+?)"/m)?.[1];
189
+ return { name, type: "Python" };
190
+ }
191
+ if (fs.existsSync(path.join(cwd, "requirements.txt"))) {
192
+ return { type: "Python (requirements.txt)" };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ export function scanProject(cwd) {
198
+ const { dirs, files } = listTopLevel(cwd);
199
+ if (dirs.length === 0 && files.length === 0) {
200
+ return "[project]\nEmpty directory — no files detected.\n[/project]\n";
201
+ }
202
+
203
+ const lines = ["[project]"];
204
+
205
+ const frameworks = detectFrameworks(cwd, files);
206
+ if (frameworks.length > 0) {
207
+ lines.push(`stack: ${frameworks.join(", ")}`);
208
+ }
209
+
210
+ const pkg = extractPackageInfo(cwd);
211
+ const cargo = extractCargoInfo(cwd);
212
+ const goInfo = extractGoInfo(cwd);
213
+ const pyInfo = extractPythonInfo(cwd);
214
+
215
+ if (pkg) {
216
+ if (pkg.name) lines.push(`package: ${pkg.name}${pkg.version ? `@${pkg.version}` : ""}`);
217
+ if (pkg.frameworks) lines.push(`libraries: ${pkg.frameworks.join(", ")}`);
218
+ if (pkg.depCounts) lines.push(`dependencies: ${pkg.depCounts}`);
219
+ if (pkg.scripts) lines.push(`scripts: ${pkg.scripts.join(", ")}`);
220
+ }
221
+ if (cargo) {
222
+ lines.push(`crate: ${cargo.name || "unknown"}${cargo.version ? `@${cargo.version}` : ""}`);
223
+ }
224
+ if (goInfo) {
225
+ lines.push(`module: ${goInfo.module || "unknown"}${goInfo.goVersion ? ` (go ${goInfo.goVersion})` : ""}`);
226
+ }
227
+ if (pyInfo) {
228
+ lines.push(`python project: ${pyInfo.name || pyInfo.type}`);
229
+ }
230
+
231
+ lines.push("");
232
+ lines.push("structure:");
233
+ for (const d of dirs) {
234
+ lines.push(` ${d}`);
235
+ const children = listSecondLevel(cwd, d.replace(/\/$/, ""));
236
+ for (const child of children.slice(0, 15)) {
237
+ lines.push(` ${child}`);
238
+ }
239
+ if (children.length > 15) {
240
+ lines.push(` ... (${children.length - 15} more)`);
241
+ }
242
+ }
243
+ for (const f of files) {
244
+ lines.push(` ${f}`);
245
+ }
246
+
247
+ const contextFile = path.join(cwd, ".aether", "context.md");
248
+ const context = readTextSafe(contextFile, 3000);
249
+ if (context) {
250
+ lines.push("");
251
+ lines.push("project-instructions:");
252
+ lines.push(context.trim());
253
+ }
254
+
255
+ const readme = readTextSafe(path.join(cwd, "README.md"), 1500);
256
+ if (readme) {
257
+ lines.push("");
258
+ lines.push("readme-excerpt:");
259
+ lines.push(readme.trim());
260
+ }
261
+
262
+ lines.push("[/project]");
263
+ return lines.join("\n") + "\n";
264
+ }
265
+
266
+ export function scanProjectQuick(cwd) {
267
+ const { dirs, files } = listTopLevel(cwd);
268
+ const total = dirs.length + files.length;
269
+ if (total === 0) return "empty directory";
270
+ const frameworks = detectFrameworks(cwd, files);
271
+ const pkg = extractPackageInfo(cwd);
272
+ const parts = [];
273
+ if (pkg?.name) parts.push(pkg.name);
274
+ if (frameworks.length > 0) parts.push(frameworks.join(", "));
275
+ else if (pkg?.frameworks?.length) parts.push(pkg.frameworks[0]);
276
+ parts.push(`${total} items`);
277
+ if (pkg?.scripts) parts.push(`${pkg.scripts.length} scripts`);
278
+ return parts.join(" · ");
279
+ }
package/src/repl.js CHANGED
@@ -15,8 +15,14 @@ import { runSetup } from "./setup.js";
15
15
  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
+ import { getVersion } from "./version.js";
19
+ import {
20
+ generateSessionId, saveSession, loadSession, listSessions,
21
+ findRecentSession, deleteSession, extractSummary, formatAge,
22
+ } from "./sessions.js";
23
+ import { scanProjectQuick } from "./project-context.js";
18
24
 
19
- const VERSION = "0.32.0";
25
+ const VERSION = getVersion();
20
26
  const MODEL_NAME = "Aether Core";
21
27
 
22
28
  // /model aliases → { id, name }. id=null = server default (gemma / Aether Core).
@@ -38,9 +44,12 @@ const SHORTCUTS = `
38
44
  ${c.cyan("/yes")} Toggle auto-approve mode (skip y/N prompts)
39
45
  ${c.cyan("/turns")} ${c.gray("<n>")} Set max turns per prompt (default 25)
40
46
  ${c.cyan("/model")} ${c.gray("[core|ultra]")} Show or switch model (ultra=Opus — premium)
47
+ ${c.cyan("/sessions")} List saved sessions for this directory
48
+ ${c.cyan("/resume")} ${c.gray("[id]")} Resume a saved session (default: most recent)
49
+ ${c.cyan("/new")} Start a fresh session (save current first)
41
50
 
42
51
  ${c.gray("Anything else is sent to the agent as your next message.")}
43
- ${c.gray("Conversation history is kept across messages until you /clear.")}
52
+ ${c.gray("Sessions auto-save — close the terminal and come back later.")}
44
53
  `;
45
54
 
46
55
  export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafePaths: initialUnsafePaths, maxTurns: initialMaxTurns, model: initialModel = null, mcpManager = null }) {
@@ -52,19 +61,17 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
52
61
  maxTurns: initialMaxTurns ?? 25,
53
62
  model: initialModel ?? null,
54
63
  modelName: initialAlias?.name ?? MODEL_NAME,
55
- messages: [], // accumulates across turns
64
+ messages: [],
56
65
  balance: null,
57
66
  sessionCredits: 0,
58
67
  sessionIn: 0,
59
68
  sessionOut: 0,
69
+ sessionId: generateSessionId(initialCwd),
70
+ turnCount: 0,
60
71
  };
61
72
 
62
- // Kick off the npm update check concurrently with the balance fetch so it
63
- // adds no startup latency; the nudge (if any) prints just under the banner.
64
73
  const updatePromise = checkForUpdate().catch(() => null);
65
74
 
66
- // Free balance check up front. If no key configured, walk through first-time
67
- // setup flow (open browser → paste key → verify → save).
68
75
  let needsSetup = false;
69
76
  try {
70
77
  const me = await fetchBalance();
@@ -74,7 +81,6 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
74
81
  if (err instanceof AetherError && (err.code === "NO_API_KEY" || err.status === 401)) {
75
82
  needsSetup = true;
76
83
  } else {
77
- // Transient network or other — surface but don't block
78
84
  console.log(c.gray(`(could not fetch balance: ${err.message})`));
79
85
  }
80
86
  }
@@ -85,42 +91,73 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
85
91
  console.log(c.gray("Aborting — no valid API key."));
86
92
  process.exit(1);
87
93
  }
88
- // Re-fetch balance now that the key is saved
89
94
  try {
90
95
  const me = await fetchBalance();
91
96
  state.balance = me.balance;
92
97
  state.plan = me.plan;
93
- } catch { /* tolerate — banner just won't show balance */ }
98
+ } catch { /* tolerate */ }
94
99
  }
95
100
 
96
- printBanner(state);
101
+ // Check for a resumable session before printing the banner
102
+ let resumed = false;
103
+ try {
104
+ const recent = findRecentSession(state.cwd);
105
+ if (recent && recent.messageCount >= 2) {
106
+ const full = loadSession(recent.id);
107
+ if (full && full.messages?.length >= 2) {
108
+ console.log(
109
+ c.gray(`\n Found session from ${formatAge(recent.updatedAt)} — `) +
110
+ c.cyan(extractSummary(full.messages) || "(no summary)"),
111
+ );
112
+ console.log(c.gray(" Resuming automatically. Use /new to start fresh.\n"));
113
+ state.sessionId = full.id;
114
+ state.messages = full.messages;
115
+ state.sessionCredits = full.credits ?? 0;
116
+ state.sessionIn = full.tokensIn ?? 0;
117
+ state.sessionOut = full.tokensOut ?? 0;
118
+ state.turnCount = full.turnCount ?? 0;
119
+ if (full.model !== undefined) state.model = full.model;
120
+ if (full.modelName) state.modelName = full.modelName;
121
+ resumed = true;
122
+ }
123
+ }
124
+ } catch { /* non-fatal */ }
125
+
126
+ // Project quick-scan for banner
127
+ let projectLine = "";
128
+ try {
129
+ projectLine = scanProjectQuick(state.cwd);
130
+ } catch { /* non-fatal */ }
131
+
132
+ printBanner(state, projectLine, resumed);
97
133
  const updateNudge = await updatePromise;
98
134
  if (updateNudge) console.log(updateNudge + "\n");
99
135
 
100
- // Input: a bordered raw-mode box (box-input.js) — cmd.exe-safe and identical
101
- // everywhere. (Ink's live redraw ghosts in the legacy Windows console, so we
102
- // don't use it.) Each prompt is independent; the tool y/N prompts and the
103
- // ask_user menu run BETWEEN prompts in raw mode too, so nothing overlaps.
104
136
  const inputHistory = [];
105
137
 
106
138
  while (true) {
107
139
  const raw = await promptBox({ history: inputHistory });
108
- if (raw === EXIT_SIGNAL) { console.log(c.gray("bye.")); return; }
140
+ if (raw === EXIT_SIGNAL) {
141
+ autoSave(state);
142
+ console.log(c.gray("bye."));
143
+ return;
144
+ }
109
145
  const line = (raw ?? "").trim();
110
146
  if (!line) continue;
111
147
 
112
- // promptBox already echoes "› <line>" on submit, so no echo needed here.
113
148
  inputHistory.push(line);
114
149
 
115
- // Slash command?
116
150
  if (line.startsWith("/") || line === "?") {
117
151
  const handled = await handleSlash(line, state);
118
- if (handled === "exit") return;
152
+ if (handled === "exit") {
153
+ autoSave(state);
154
+ return;
155
+ }
119
156
  printStatusLine(state);
120
157
  continue;
121
158
  }
122
159
 
123
- // Otherwise — send as a message to the agent
160
+ state.turnCount++;
124
161
  const result = await runAgent({
125
162
  initialPrompt: line,
126
163
  priorMessages: state.messages.length > 0 ? state.messages : undefined,
@@ -140,13 +177,31 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
140
177
 
141
178
  if (!result.ok && result.error) {
142
179
  console.log("\n" + errorLine(result.error.message || String(result.error)));
143
- // Don't kill the session on a single error — surface it and continue.
144
180
  }
145
181
 
182
+ autoSave(state);
146
183
  printStatusLine(state);
147
184
  }
148
185
  }
149
186
 
187
+ function autoSave(state) {
188
+ if (state.messages.length < 2) return;
189
+ try {
190
+ saveSession({
191
+ id: state.sessionId,
192
+ cwd: state.cwd,
193
+ model: state.model,
194
+ modelName: state.modelName,
195
+ messages: state.messages,
196
+ credits: state.sessionCredits,
197
+ tokensIn: state.sessionIn,
198
+ tokensOut: state.sessionOut,
199
+ turnCount: state.turnCount,
200
+ summary: extractSummary(state.messages),
201
+ });
202
+ } catch { /* non-fatal — don't kill the session over a save failure */ }
203
+ }
204
+
150
205
  /* ───────── slash commands ───────── */
151
206
 
152
207
  async function handleSlash(line, state) {
@@ -231,6 +286,75 @@ async function handleSlash(line, state) {
231
286
  }
232
287
  break;
233
288
  }
289
+ case "sessions": {
290
+ const sessions = listSessions(state.cwd);
291
+ if (sessions.length === 0) {
292
+ console.log(c.gray("No saved sessions for this directory."));
293
+ } else {
294
+ console.log(c.bold("Saved sessions") + c.gray(` (${state.cwd})`));
295
+ for (const s of sessions.slice(0, 10)) {
296
+ const active = s.id === state.sessionId ? c.cyan(" ← current") : "";
297
+ const summary = s.summary || "(no summary)";
298
+ const truncated = summary.length > 60 ? summary.slice(0, 57) + "..." : summary;
299
+ console.log(
300
+ ` ${c.gray(formatAge(s.updatedAt).padEnd(8))} ` +
301
+ `${c.gray(s.id.slice(0, 16))} ` +
302
+ `${truncated}` +
303
+ `${c.gray(` · ${s.turnCount} turns`)}` +
304
+ active,
305
+ );
306
+ }
307
+ if (sessions.length > 10) {
308
+ console.log(c.gray(` ... and ${sessions.length - 10} more`));
309
+ }
310
+ console.log(c.gray("\nUse /resume <id-prefix> to load one."));
311
+ }
312
+ break;
313
+ }
314
+ case "resume": {
315
+ const target = (arg || "").trim();
316
+ let session = null;
317
+ if (target) {
318
+ const sessions = listSessions(state.cwd);
319
+ const match = sessions.find((s) => s.id.startsWith(target));
320
+ if (match) session = loadSession(match.id);
321
+ if (!session) {
322
+ console.log(errorLine(`No session matching "${target}" found.`));
323
+ break;
324
+ }
325
+ } else {
326
+ const recent = findRecentSession(state.cwd);
327
+ if (recent) session = loadSession(recent.id);
328
+ if (!session) {
329
+ console.log(errorLine("No recent session to resume. Use /sessions to list."));
330
+ break;
331
+ }
332
+ }
333
+ autoSave(state);
334
+ state.sessionId = session.id;
335
+ state.messages = session.messages ?? [];
336
+ state.sessionCredits = session.credits ?? 0;
337
+ state.sessionIn = session.tokensIn ?? 0;
338
+ state.sessionOut = session.tokensOut ?? 0;
339
+ state.turnCount = session.turnCount ?? 0;
340
+ if (session.model !== undefined) state.model = session.model;
341
+ if (session.modelName) state.modelName = session.modelName;
342
+ const summary = extractSummary(session.messages) || "(no summary)";
343
+ console.log(c.gray(`Resumed: `) + c.cyan(summary));
344
+ console.log(c.gray(` ${state.messages.length} messages · ${state.turnCount} turns · ${formatAge(session.updatedAt)}`));
345
+ break;
346
+ }
347
+ case "new": {
348
+ autoSave(state);
349
+ state.sessionId = generateSessionId(state.cwd);
350
+ state.messages = [];
351
+ state.sessionCredits = 0;
352
+ state.sessionIn = 0;
353
+ state.sessionOut = 0;
354
+ state.turnCount = 0;
355
+ console.log(c.gray("New session started. Previous session saved."));
356
+ break;
357
+ }
234
358
  default:
235
359
  console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
236
360
  }
@@ -245,23 +369,27 @@ const shortenPath = (p, max) => (p.length <= max ? p : "..." + p.slice(p.length
245
369
  // Short, hand-curated highlights shown in the "What's new" box. Update with
246
370
  // notable releases — keep it to ~3 lines so it balances the banner height.
247
371
  const WHATS_NEW = [
248
- "Files now save to your Desktop",
372
+ "Session persistence — close & resume",
373
+ "Project auto-understanding on startup",
249
374
  "Live thinking spinner + loop guard",
250
- "Asks you when a task is unclear",
251
375
  ];
252
376
 
253
- function printBanner(state) {
377
+ function printBanner(state, projectLine = "", resumed = false) {
254
378
  const mode = state.autoYes ? (state.unsafePaths ? "skip-permissions" : "auto-yes") : "review mode";
255
379
  const credits = state.balance != null ? `${state.balance.toLocaleString()} credits` : "";
256
- const left = boxLines(
257
- [
258
- c.bold(c.magenta("aether")) + c.gray(" · ") + c.bold(state.modelName) + c.gray(" v" + VERSION),
259
- c.gray("uncensored AI coding agent"),
260
- c.gray((credits ? credits + " · " : "") + mode),
261
- c.gray(shortenPath(state.cwd, 52)),
262
- ],
263
- { color: c.magenta, padX: 2 },
264
- );
380
+ const leftLines = [
381
+ c.bold(c.magenta("aether")) + c.gray(" · ") + c.bold(state.modelName) + c.gray(" v" + VERSION),
382
+ c.gray("uncensored AI coding agent"),
383
+ c.gray((credits ? credits + " · " : "") + mode),
384
+ c.gray(shortenPath(state.cwd, 52)),
385
+ ];
386
+ if (projectLine) {
387
+ leftLines.push(c.gray("project: " + (projectLine.length > 48 ? projectLine.slice(0, 45) + "..." : projectLine)));
388
+ }
389
+ if (resumed) {
390
+ leftLines.push(c.cyan("↻ resumed session") + c.gray(` · ${state.turnCount} turns · ${state.messages.length} msgs`));
391
+ }
392
+ const left = boxLines(leftLines, { color: c.magenta, padX: 2 });
265
393
  const right = boxLines(
266
394
  [c.bold(c.cyan("What's new")), ...WHATS_NEW.map((s) => c.gray("• " + s))],
267
395
  { color: c.cyan, padX: 2 },
@@ -269,8 +397,6 @@ function printBanner(state) {
269
397
 
270
398
  console.log("");
271
399
  const cols = process.stdout.columns || 80;
272
- // Only place the two boxes side by side when there's room; otherwise the
273
- // banner alone (narrow terminals would wrap and tear the second box).
274
400
  if (cols >= visLen(left[0]) + visLen(right[0]) + 4) {
275
401
  console.log(sideBySide(left, right, 3));
276
402
  } else {
@@ -0,0 +1,167 @@
1
+ // Session persistence. Saves conversation state to ~/.aether/sessions/ so
2
+ // users can close the terminal, come back tomorrow, and resume exactly
3
+ // where they left off. Auto-saves after every agent turn.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import os from "node:os";
8
+ import crypto from "node:crypto";
9
+
10
+ const SESSIONS_DIR = path.join(os.homedir(), ".aether", "sessions");
11
+ const MAX_SESSIONS = 50;
12
+ const STALE_DAYS = 14;
13
+
14
+ function ensureDir() {
15
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
16
+ }
17
+
18
+ function cwdHash(cwd) {
19
+ return crypto.createHash("sha256").update(cwd.toLowerCase()).digest("hex").slice(0, 12);
20
+ }
21
+
22
+ function sessionPath(id) {
23
+ return path.join(SESSIONS_DIR, `${id}.json`);
24
+ }
25
+
26
+ export function generateSessionId(cwd) {
27
+ const hash = cwdHash(cwd);
28
+ const ts = Date.now().toString(36);
29
+ return `${hash}-${ts}`;
30
+ }
31
+
32
+ export function saveSession(session) {
33
+ ensureDir();
34
+ const data = {
35
+ id: session.id,
36
+ cwd: session.cwd,
37
+ model: session.model ?? null,
38
+ modelName: session.modelName ?? "Aether Core",
39
+ messages: session.messages ?? [],
40
+ credits: session.credits ?? 0,
41
+ tokensIn: session.tokensIn ?? 0,
42
+ tokensOut: session.tokensOut ?? 0,
43
+ createdAt: session.createdAt ?? new Date().toISOString(),
44
+ updatedAt: new Date().toISOString(),
45
+ summary: session.summary ?? "",
46
+ turnCount: session.turnCount ?? 0,
47
+ };
48
+ const filePath = sessionPath(data.id);
49
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
50
+ try { fs.chmodSync(filePath, 0o600); } catch { /* windows */ }
51
+ pruneOldSessions();
52
+ return data;
53
+ }
54
+
55
+ export function loadSession(id) {
56
+ const filePath = sessionPath(id);
57
+ try {
58
+ const raw = fs.readFileSync(filePath, "utf8");
59
+ return JSON.parse(raw);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export function deleteSession(id) {
66
+ try {
67
+ fs.unlinkSync(sessionPath(id));
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ export function listSessions(filterCwd) {
75
+ ensureDir();
76
+ let files;
77
+ try {
78
+ files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
79
+ } catch {
80
+ return [];
81
+ }
82
+ const sessions = [];
83
+ for (const f of files) {
84
+ try {
85
+ const raw = fs.readFileSync(path.join(SESSIONS_DIR, f), "utf8");
86
+ const data = JSON.parse(raw);
87
+ if (filterCwd && data.cwd?.toLowerCase() !== filterCwd.toLowerCase()) continue;
88
+ sessions.push({
89
+ id: data.id,
90
+ cwd: data.cwd,
91
+ model: data.model,
92
+ modelName: data.modelName,
93
+ summary: data.summary,
94
+ turnCount: data.turnCount ?? 0,
95
+ messageCount: data.messages?.length ?? 0,
96
+ credits: data.credits ?? 0,
97
+ createdAt: data.createdAt,
98
+ updatedAt: data.updatedAt,
99
+ });
100
+ } catch {
101
+ continue;
102
+ }
103
+ }
104
+ sessions.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
105
+ return sessions;
106
+ }
107
+
108
+ export function findRecentSession(cwd, maxAgeMs = 24 * 60 * 60 * 1000) {
109
+ const sessions = listSessions(cwd);
110
+ if (sessions.length === 0) return null;
111
+ const newest = sessions[0];
112
+ if (!newest.updatedAt) return null;
113
+ const age = Date.now() - new Date(newest.updatedAt).getTime();
114
+ if (age > maxAgeMs) return null;
115
+ if (newest.messageCount < 2) return null;
116
+ return newest;
117
+ }
118
+
119
+ function pruneOldSessions() {
120
+ let files;
121
+ try {
122
+ files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
123
+ } catch {
124
+ return;
125
+ }
126
+ if (files.length <= MAX_SESSIONS) return;
127
+ const entries = [];
128
+ for (const f of files) {
129
+ try {
130
+ const stat = fs.statSync(path.join(SESSIONS_DIR, f));
131
+ entries.push({ file: f, mtime: stat.mtimeMs });
132
+ } catch {
133
+ continue;
134
+ }
135
+ }
136
+ entries.sort((a, b) => b.mtime - a.mtime);
137
+ const cutoff = Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000;
138
+ for (const e of entries.slice(MAX_SESSIONS)) {
139
+ if (e.mtime < cutoff) {
140
+ try { fs.unlinkSync(path.join(SESSIONS_DIR, e.file)); } catch { /* ignore */ }
141
+ }
142
+ }
143
+ }
144
+
145
+ export function extractSummary(messages) {
146
+ if (!messages || messages.length === 0) return "";
147
+ const firstUser = messages.find((m) => m.role === "user");
148
+ if (!firstUser || typeof firstUser.content !== "string") return "";
149
+ const text = firstUser.content
150
+ .replace(/\[environment\][\s\S]*?\[\/environment\]\s*/g, "")
151
+ .replace(/\[project\][\s\S]*?\[\/project\]\s*/g, "")
152
+ .replace(/=== LOADED SKILLS[\s\S]*?---\s*/g, "")
153
+ .trim();
154
+ return text.length > 120 ? text.slice(0, 117) + "..." : text;
155
+ }
156
+
157
+ export function formatAge(isoDate) {
158
+ if (!isoDate) return "unknown";
159
+ const ms = Date.now() - new Date(isoDate).getTime();
160
+ const mins = Math.floor(ms / 60_000);
161
+ if (mins < 1) return "just now";
162
+ if (mins < 60) return `${mins}m ago`;
163
+ const hours = Math.floor(mins / 60);
164
+ if (hours < 24) return `${hours}h ago`;
165
+ const days = Math.floor(hours / 24);
166
+ return `${days}d ago`;
167
+ }
package/src/tools.js CHANGED
@@ -16,6 +16,7 @@ import { c } from "./render.js";
16
16
  import { promptChoice } from "./menu.js";
17
17
  import { unifiedDiff, summarizeWrite } from "./diff.js";
18
18
  import { getConfig } from "./config.js";
19
+ import { getVersion } from "./version.js";
19
20
 
20
21
  /* ─────────────────────── Tool definitions (sent to model) ─────────────────────── */
21
22
 
@@ -646,26 +647,39 @@ async function webSearch(args, opts) {
646
647
  return { ok: false, output: "Web search requires AETHER_API_KEY. Set it and try again." };
647
648
  }
648
649
  const max = Number.isInteger(args.max_results) ? Math.min(10, Math.max(1, args.max_results)) : 5;
650
+ const maxRetries = 2;
651
+ const baseDelay = 500;
649
652
  let res;
650
- try {
651
- res = await fetch(`${baseUrl}/api/v1/web-search`, {
652
- method: "POST",
653
- headers: {
654
- "Content-Type": "application/json",
655
- Authorization: `Bearer ${apiKey}`,
656
- "User-Agent": "aether-code/web-search",
657
- },
658
- body: JSON.stringify({ query: args.query, max_results: max }),
659
- });
660
- } catch (e) {
661
- return { ok: false, output: `web_search network error: ${e.message}` };
653
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
654
+ try {
655
+ res = await fetch(`${baseUrl}/api/v1/web-search`, {
656
+ method: "POST",
657
+ headers: {
658
+ "Content-Type": "application/json",
659
+ Authorization: `Bearer ${apiKey}`,
660
+ "User-Agent": `aether-code/${getVersion()}`,
661
+ },
662
+ body: JSON.stringify({ query: args.query, max_results: max }),
663
+ });
664
+ if (res.status >= 500 && attempt < maxRetries) {
665
+ res.body?.cancel().catch(() => {});
666
+ await new Promise((r) => setTimeout(r, baseDelay * 2 ** attempt));
667
+ continue;
668
+ }
669
+ break;
670
+ } catch (e) {
671
+ if (attempt < maxRetries) {
672
+ await new Promise((r) => setTimeout(r, baseDelay * 2 ** attempt));
673
+ continue;
674
+ }
675
+ return { ok: false, output: `web_search network error: ${e.message}` };
676
+ }
662
677
  }
663
678
  let data = null;
664
679
  try { data = await res.json(); } catch { /* non-JSON */ }
665
680
  if (!res.ok) {
666
681
  return { ok: false, output: data?.error || `web_search HTTP ${res.status}` };
667
682
  }
668
- // Hand the model just the array — that's all it needs to decide which URL to fetch.
669
683
  return { ok: true, output: JSON.stringify(data.results ?? [], null, 2) };
670
684
  }
671
685
 
@@ -709,7 +723,7 @@ async function webFetch(args, opts) {
709
723
  headers: {
710
724
  // Looking like a normal browser dodges many anti-bot pages.
711
725
  "User-Agent":
712
- "Mozilla/5.0 (compatible; aether-code/0.7) Gecko/20100101 Firefox/130.0",
726
+ `Mozilla/5.0 (compatible; aether-code/${getVersion()}) Gecko/20100101 Firefox/130.0`,
713
727
  Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
714
728
  "Accept-Language": "en-US,en;q=0.9",
715
729
  },
@@ -782,11 +796,28 @@ export function htmlToText(html) {
782
796
  return s.trim();
783
797
  }
784
798
 
799
+ const CATASTROPHIC_CMD_RE = new RegExp(
800
+ [
801
+ String.raw`rm\s+-(r|f|rf|fr)\w*\s+[/~](?!\S*node_modules)(?!\S*dist)(?!\S*\.next)(?!\S*build)`,
802
+ String.raw`del\s+/[sfq]+\s+[cC]:\\`,
803
+ String.raw`format\s+[a-zA-Z]:`,
804
+ String.raw`mkfs\.`,
805
+ String.raw`dd\s+.*of=/dev/[sh]d`,
806
+ String.raw`:\(\)\s*\{\s*:\|:`,
807
+ String.raw`>\s*/dev/[sh]d`,
808
+ String.raw`rd\s+/[sq]+\s+[cC]:\\`,
809
+ ].join("|"),
810
+ );
811
+
785
812
  async function runShell(args, opts) {
786
813
  if (typeof args.command !== "string") return { ok: false, output: "command is required" };
787
814
  const cwd = args.cwd ? resolveSafe(args.cwd, opts) : opts.cwd;
788
815
  console.log(c.yellow("$ ") + c.bold(args.command) + (args.cwd ? c.dim(` (cwd: ${args.cwd})`) : ""));
789
- const approved = await confirm(c.yellow("Run this command?"), opts.autoYes);
816
+ const forceReview = CATASTROPHIC_CMD_RE.test(args.command);
817
+ if (forceReview) {
818
+ console.log(c.red(c.bold(" ⚠ DESTRUCTIVE COMMAND DETECTED")) + c.gray(" — requires manual confirmation"));
819
+ }
820
+ const approved = await confirm(c.yellow("Run this command?"), forceReview ? false : opts.autoYes);
790
821
  if (!approved) return { ok: false, output: "User declined the command." };
791
822
 
792
823
  return new Promise((resolve) => {
package/src/version.js ADDED
@@ -0,0 +1,15 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ let cached;
6
+ export function getVersion() {
7
+ if (cached) return cached;
8
+ try {
9
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
10
+ cached = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "0";
11
+ } catch {
12
+ cached = "0";
13
+ }
14
+ return cached;
15
+ }