aether-code 0.38.0 → 0.40.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-code",
3
- "version": "0.38.0",
3
+ "version": "0.40.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
@@ -4,11 +4,12 @@
4
4
 
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { spawn } from "node:child_process";
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 { scanProject } from "./project-context.js";
12
+ import { scanProject, detectVerifyCommand, detectBuildCommand } from "./project-context.js";
12
13
  import { c, divider, turn, toolLabel, toolSummary, makeTokenStripper, errorLine, startSpinner } from "./render.js";
13
14
 
14
15
  const DEFAULT_MAX_TURNS = 25;
@@ -109,6 +110,8 @@ export async function runAgent({
109
110
  // finishes having read files it never edited, we nudge it once to finish.
110
111
  const looksLikeModification = MOD_RE.test(initialPrompt);
111
112
  let appliedNothingNudges = 0;
113
+ let verifyRounds = 0;
114
+ let buildRounds = 0;
112
115
 
113
116
  for (let i = 0; i < maxTurns; i++) {
114
117
  // No turn header and no leading blank here — each step (assistant text and
@@ -218,6 +221,88 @@ export async function runAgent({
218
221
  messages.push({ role: "user", content: msg });
219
222
  continue; // give the model another turn to finish
220
223
  }
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
+ }
221
306
  if (filesTouched.size) {
222
307
  console.log("\n" + c.bold("Files") + c.gray(` in ${cwd}`));
223
308
  for (const [p, action] of filesTouched) {
@@ -298,6 +383,102 @@ export async function runAgent({
298
383
  return { ok: false, error: new Error("Max turns reached"), totalCredits, totalIn, totalOut, balance: lastBalance, messages };
299
384
  }
300
385
 
386
+ // Auto-verify: run the project's typecheck/lint command silently and return
387
+ // { ok, output, label }. Returns null if no verify command is detected.
388
+ let cachedVerify = undefined;
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);
408
+ });
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;
424
+ }
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
+
449
+ // Run the compiled/interpreted program after a successful build.
450
+ // Returns { ok, output, code, timedOut } or null on spawn error.
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
+ });
480
+ }
481
+
301
482
  /**
302
483
  * Per-turn skill injection. Selects skills against the latest user message
303
484
  * + paths the model has touched, then prepends matching bodies onto the
@@ -263,6 +263,434 @@ export function scanProject(cwd) {
263
263
  return lines.join("\n") + "\n";
264
264
  }
265
265
 
266
+ // ─────────── /init — deep project indexing ───────────
267
+
268
+ const KEY_FILES = [
269
+ "README.md", "CLAUDE.md", ".env.example", "Dockerfile",
270
+ "docker-compose.yml", "docker-compose.yaml",
271
+ ];
272
+
273
+ const ENTRY_PATTERNS = [
274
+ /^(src\/)?index\.[jt]sx?$/,
275
+ /^(src\/)?main\.[jt]sx?$/,
276
+ /^(src\/)?app\.[jt]sx?$/,
277
+ /^(src\/)?server\.[jt]sx?$/,
278
+ /^(bin|cli|cmd)\/[^/]+\.[jt]sx?$/,
279
+ /^(pages|app)\/_app\.[jt]sx?$/,
280
+ /^(pages|app)\/layout\.[jt]sx?$/,
281
+ ];
282
+
283
+ const CONFIG_FILES = [
284
+ "next.config.js", "next.config.mjs", "next.config.ts",
285
+ "vite.config.ts", "vite.config.js", "webpack.config.js",
286
+ "tsconfig.json", "tailwind.config.js", "tailwind.config.ts",
287
+ "eslint.config.js", ".eslintrc.js", ".eslintrc.json",
288
+ "jest.config.js", "jest.config.ts", "vitest.config.ts",
289
+ "prisma/schema.prisma", "drizzle.config.ts",
290
+ ];
291
+
292
+ function collectSourceFiles(dir, base, maxFiles = 500) {
293
+ const results = [];
294
+ function walk(current, rel) {
295
+ if (results.length >= maxFiles) return;
296
+ let entries;
297
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
298
+ for (const e of entries) {
299
+ if (results.length >= maxFiles) return;
300
+ if (e.name.startsWith(".") && e.name !== ".env.example") continue;
301
+ if (SKIP_DIRS.has(e.name)) continue;
302
+ const fullPath = path.join(current, e.name);
303
+ const relPath = rel ? `${rel}/${e.name}` : e.name;
304
+ if (e.isDirectory()) {
305
+ walk(fullPath, relPath);
306
+ } else if (e.isFile()) {
307
+ let size = 0;
308
+ try { size = fs.statSync(fullPath).size; } catch { /* skip */ }
309
+ results.push({ path: relPath, size });
310
+ }
311
+ }
312
+ }
313
+ walk(dir, "");
314
+ return results;
315
+ }
316
+
317
+ function identifyKeyFiles(allFiles, cwd) {
318
+ const key = [];
319
+ for (const f of allFiles) {
320
+ const name = f.path.split("/").pop();
321
+ if (KEY_FILES.includes(name)) { key.push(f.path); continue; }
322
+ if (CONFIG_FILES.includes(f.path)) { key.push(f.path); continue; }
323
+ for (const pat of ENTRY_PATTERNS) {
324
+ if (pat.test(f.path)) { key.push(f.path); break; }
325
+ }
326
+ }
327
+ return key;
328
+ }
329
+
330
+ function summarizeFile(filePath, maxLines = 30) {
331
+ try {
332
+ const stat = fs.statSync(filePath);
333
+ if (!stat.isFile() || stat.size > 200_000) return null;
334
+ const content = fs.readFileSync(filePath, "utf8");
335
+ const lines = content.split("\n");
336
+ const exports = [];
337
+ const imports = [];
338
+ for (const line of lines.slice(0, 100)) {
339
+ if (/^export\s+(default\s+)?(function|class|const|let|async)\s+(\w+)/m.test(line)) {
340
+ const match = line.match(/^export\s+(?:default\s+)?(?:function|class|const|let|async\s+function)\s+(\w+)/);
341
+ if (match) exports.push(match[1]);
342
+ }
343
+ if (/^import\s/.test(line)) {
344
+ const match = line.match(/from\s+["']([^"']+)["']/);
345
+ if (match && !match[1].startsWith(".")) imports.push(match[1]);
346
+ }
347
+ }
348
+ const summary = { lines: lines.length };
349
+ if (exports.length > 0) summary.exports = exports.slice(0, 15);
350
+ if (imports.length > 0) summary.externalImports = [...new Set(imports)].slice(0, 10);
351
+ const firstComment = content.match(/^\/\/\s*(.+)/m);
352
+ if (firstComment) summary.description = firstComment[1].trim().slice(0, 120);
353
+ return summary;
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+
359
+ function detectConventions(allFiles, cwd) {
360
+ const conventions = [];
361
+ const extensions = new Set();
362
+ for (const f of allFiles) {
363
+ const ext = path.extname(f.path);
364
+ if (ext) extensions.add(ext);
365
+ }
366
+ if (extensions.has(".ts") || extensions.has(".tsx")) conventions.push("TypeScript");
367
+ if (extensions.has(".jsx") || extensions.has(".tsx")) conventions.push("JSX/TSX (React-style)");
368
+ const hasTest = allFiles.some((f) =>
369
+ /\.(test|spec)\.[jt]sx?$/.test(f.path) || f.path.startsWith("test/") || f.path.startsWith("__tests__/"),
370
+ );
371
+ if (hasTest) {
372
+ const testDirs = [];
373
+ if (allFiles.some((f) => f.path.startsWith("test/"))) testDirs.push("test/");
374
+ if (allFiles.some((f) => f.path.startsWith("__tests__/"))) testDirs.push("__tests__/");
375
+ const colocated = allFiles.some((f) => /src\/.*\.(test|spec)\.[jt]sx?$/.test(f.path));
376
+ if (colocated) testDirs.push("colocated in src/");
377
+ conventions.push(`Tests: ${testDirs.join(", ") || "detected"}`);
378
+ }
379
+ if (fs.existsSync(path.join(cwd, ".prettierrc")) || fs.existsSync(path.join(cwd, ".prettierrc.json"))) {
380
+ conventions.push("Prettier formatting");
381
+ }
382
+ if (fs.existsSync(path.join(cwd, ".eslintrc.js")) || fs.existsSync(path.join(cwd, "eslint.config.js"))) {
383
+ conventions.push("ESLint");
384
+ }
385
+ return conventions;
386
+ }
387
+
388
+ function buildDirectoryTree(allFiles, maxDepth = 3) {
389
+ const tree = {};
390
+ for (const f of allFiles) {
391
+ const parts = f.path.split("/");
392
+ let node = tree;
393
+ for (let i = 0; i < Math.min(parts.length - 1, maxDepth); i++) {
394
+ if (!node[parts[i]]) node[parts[i]] = {};
395
+ node = node[parts[i]];
396
+ }
397
+ }
398
+ function render(node, indent = "") {
399
+ const lines = [];
400
+ const entries = Object.keys(node).sort();
401
+ for (const key of entries) {
402
+ const children = Object.keys(node[key]);
403
+ lines.push(`${indent}${key}/`);
404
+ if (children.length > 0 && indent.length < maxDepth * 2) {
405
+ lines.push(...render(node[key], indent + " "));
406
+ }
407
+ }
408
+ return lines;
409
+ }
410
+ return render(tree);
411
+ }
412
+
413
+ export function generateProjectIndex(cwd) {
414
+ const lines = [];
415
+ lines.push("# Project Context");
416
+ lines.push(`# Auto-generated by \`aether /init\` — edit freely, this file is yours.`);
417
+ lines.push(`# Loaded on every agent turn to give the model deep project understanding.`);
418
+ lines.push("");
419
+
420
+ const { dirs, files: topFiles } = listTopLevel(cwd);
421
+ const frameworks = detectFrameworks(cwd, topFiles);
422
+ const pkg = extractPackageInfo(cwd);
423
+ const cargo = extractCargoInfo(cwd);
424
+ const goInfo = extractGoInfo(cwd);
425
+ const pyInfo = extractPythonInfo(cwd);
426
+
427
+ lines.push("## Overview");
428
+ if (pkg?.name) lines.push(`- **Package:** ${pkg.name}${pkg.version ? ` v${pkg.version}` : ""}`);
429
+ if (cargo?.name) lines.push(`- **Crate:** ${cargo.name}${cargo.version ? ` v${cargo.version}` : ""}`);
430
+ if (goInfo?.module) lines.push(`- **Module:** ${goInfo.module}${goInfo.goVersion ? ` (go ${goInfo.goVersion})` : ""}`);
431
+ if (pyInfo?.name) lines.push(`- **Python:** ${pyInfo.name || pyInfo.type}`);
432
+ if (frameworks.length > 0) lines.push(`- **Stack:** ${frameworks.join(", ")}`);
433
+ if (pkg?.frameworks?.length) lines.push(`- **Libraries:** ${pkg.frameworks.join(", ")}`);
434
+ if (pkg?.depCounts) lines.push(`- **Dependencies:** ${pkg.depCounts}`);
435
+ lines.push("");
436
+
437
+ const allFiles = collectSourceFiles(cwd);
438
+ const conventions = detectConventions(allFiles, cwd);
439
+ if (conventions.length > 0) {
440
+ lines.push("## Conventions");
441
+ for (const c of conventions) lines.push(`- ${c}`);
442
+ lines.push("");
443
+ }
444
+
445
+ if (pkg?.scripts?.length) {
446
+ lines.push("## Scripts");
447
+ const pkgFull = readJsonSafe(path.join(cwd, "package.json"));
448
+ if (pkgFull?.scripts) {
449
+ for (const [name, cmd] of Object.entries(pkgFull.scripts)) {
450
+ lines.push(`- \`npm run ${name}\` → \`${cmd}\``);
451
+ }
452
+ }
453
+ lines.push("");
454
+ }
455
+
456
+ lines.push("## Structure");
457
+ const tree = buildDirectoryTree(allFiles);
458
+ for (const t of tree.slice(0, 60)) lines.push(t);
459
+ if (tree.length > 60) lines.push(`... (${tree.length - 60} more directories)`);
460
+ lines.push("");
461
+
462
+ const keyFiles = identifyKeyFiles(allFiles, cwd);
463
+ if (keyFiles.length > 0) {
464
+ lines.push("## Key Files");
465
+ for (const kf of keyFiles.slice(0, 25)) {
466
+ const abs = path.join(cwd, kf.replace(/\//g, path.sep));
467
+ const summary = summarizeFile(abs);
468
+ if (summary) {
469
+ const desc = summary.description ? ` — ${summary.description}` : "";
470
+ const exports = summary.exports?.length ? ` (exports: ${summary.exports.join(", ")})` : "";
471
+ lines.push(`- \`${kf}\` [${summary.lines} lines]${desc}${exports}`);
472
+ } else {
473
+ lines.push(`- \`${kf}\``);
474
+ }
475
+ }
476
+ lines.push("");
477
+ }
478
+
479
+ const sourceByDir = {};
480
+ for (const f of allFiles) {
481
+ const dir = f.path.includes("/") ? f.path.split("/").slice(0, -1).join("/") : ".";
482
+ if (!sourceByDir[dir]) sourceByDir[dir] = [];
483
+ sourceByDir[dir].push(f);
484
+ }
485
+ const topDirs = Object.entries(sourceByDir)
486
+ .sort((a, b) => b[1].length - a[1].length)
487
+ .slice(0, 10);
488
+ if (topDirs.length > 0) {
489
+ lines.push("## Module Map");
490
+ for (const [dir, dirFiles] of topDirs) {
491
+ const exts = {};
492
+ for (const f of dirFiles) {
493
+ const ext = path.extname(f.path) || "(no ext)";
494
+ exts[ext] = (exts[ext] || 0) + 1;
495
+ }
496
+ const extStr = Object.entries(exts).map(([e, c]) => `${c}${e}`).join(", ");
497
+ lines.push(`- \`${dir}/\` — ${dirFiles.length} files (${extStr})`);
498
+ }
499
+ lines.push("");
500
+ }
501
+
502
+ lines.push("## Notes");
503
+ lines.push("<!-- Add project-specific instructions, patterns, or gotchas below. -->");
504
+ lines.push("<!-- The agent reads this file on every turn. -->");
505
+ lines.push("");
506
+
507
+ return lines.join("\n");
508
+ }
509
+
510
+ // ─────────── Auto-verify: detect project check commands ───────────
511
+
512
+ export function detectVerifyCommand(cwd) {
513
+ const pkg = readJsonSafe(path.join(cwd, "package.json"));
514
+ if (pkg?.scripts) {
515
+ if (pkg.scripts.typecheck) return { cmd: "npm run typecheck", label: "typecheck" };
516
+ if (pkg.scripts["type-check"]) return { cmd: "npm run type-check", label: "typecheck" };
517
+ }
518
+ if (fs.existsSync(path.join(cwd, "tsconfig.json"))) {
519
+ const hasTsc = pkg?.devDependencies?.typescript || pkg?.dependencies?.typescript;
520
+ if (hasTsc) return { cmd: "npx tsc --noEmit", label: "typecheck" };
521
+ }
522
+ if (pkg?.scripts) {
523
+ if (pkg.scripts.lint) return { cmd: "npm run lint", label: "lint" };
524
+ if (pkg.scripts.check) return { cmd: "npm run check", label: "check" };
525
+ }
526
+ const cargo = readTextSafe(path.join(cwd, "Cargo.toml"), 500);
527
+ if (cargo) return { cmd: "cargo check", label: "cargo check" };
528
+ const goMod = readTextSafe(path.join(cwd, "go.mod"), 500);
529
+ if (goMod) return { cmd: "go vet ./...", label: "go vet" };
530
+ if (fs.existsSync(path.join(cwd, "pyproject.toml"))) {
531
+ if (pkg?.scripts?.mypy || fs.existsSync(path.join(cwd, "mypy.ini"))) {
532
+ return { cmd: "mypy .", label: "mypy" };
533
+ }
534
+ }
535
+ return null;
536
+ }
537
+
538
+ // ─────────── Auto-build: detect compilation / build commands ───────────
539
+
540
+ export function detectBuildCommand(cwd, writtenFiles = []) {
541
+ const exts = new Set(writtenFiles.map((f) => path.extname(f).toLowerCase()));
542
+ const basenames = new Set(writtenFiles.map((f) => path.basename(f).toLowerCase()));
543
+ const win = process.platform === "win32";
544
+
545
+ // Assembly (NASM/FASM/MASM)
546
+ if (exts.has(".asm") || exts.has(".s")) {
547
+ const asmFiles = writtenFiles.filter((f) => /\.(asm|s)$/i.test(f));
548
+ if (asmFiles.length > 0) {
549
+ const outName = win ? "output.exe" : "output";
550
+ const objName = asmFiles[0].replace(/\.(asm|s)$/i, ".o");
551
+ return {
552
+ cmd: `nasm -f ${win ? "win64" : "elf64"} ${asmFiles[0]} -o ${objName} && ${win ? "link" : "ld"} ${objName} -o ${outName}`,
553
+ label: "nasm",
554
+ type: "compiled",
555
+ runCmd: win ? `.\\${outName}` : `./${outName}`,
556
+ };
557
+ }
558
+ }
559
+
560
+ // C/C++
561
+ if (exts.has(".c") || exts.has(".cpp") || exts.has(".cc") || exts.has(".cxx") || exts.has(".h") || exts.has(".hpp")) {
562
+ if (fs.existsSync(path.join(cwd, "CMakeLists.txt"))) {
563
+ return { cmd: "cmake -B build && cmake --build build", label: "cmake", type: "compiled" };
564
+ }
565
+ if (fs.existsSync(path.join(cwd, "Makefile")) || fs.existsSync(path.join(cwd, "makefile"))) {
566
+ return { cmd: "make", label: "make", type: "compiled" };
567
+ }
568
+ const cFiles = writtenFiles.filter((f) => /\.(c|cpp|cc|cxx)$/i.test(f));
569
+ if (cFiles.length > 0) {
570
+ const hasC = cFiles.some((f) => /\.c$/i.test(f) && !/\.(cpp|cc|cxx)$/i.test(f));
571
+ const hasCpp = cFiles.some((f) => /\.(cpp|cc|cxx)$/i.test(f));
572
+ const compiler = hasCpp ? "g++" : "gcc";
573
+ const outName = win ? "output.exe" : "output";
574
+ const flags = [];
575
+ if (hasCpp) flags.push("-std=c++17");
576
+ else if (hasC) flags.push("-std=c11");
577
+ flags.push("-Wall", "-O2");
578
+ // Check if they need threading, math, etc. from the source
579
+ const allSrc = cFiles.map((f) => {
580
+ try { return fs.readFileSync(path.join(cwd, f), "utf8"); } catch { return ""; }
581
+ }).join("\n");
582
+ if (allSrc.includes("pthread") || allSrc.includes("<thread>")) flags.push("-pthread");
583
+ if (allSrc.includes("<math.h>") || allSrc.includes("<cmath>")) flags.push("-lm");
584
+ if (win && (allSrc.includes("winsock") || allSrc.includes("WinSock") || allSrc.includes("ws2_32"))) flags.push("-lws2_32");
585
+ if (win && allSrc.includes("windows.h")) flags.push("-lkernel32", "-luser32");
586
+ return {
587
+ cmd: `${compiler} ${flags.join(" ")} ${cFiles.join(" ")} -o ${outName}`,
588
+ label: compiler,
589
+ type: "compiled",
590
+ runCmd: win ? `.\\${outName}` : `./${outName}`,
591
+ };
592
+ }
593
+ }
594
+
595
+ // Rust
596
+ if (exts.has(".rs") || fs.existsSync(path.join(cwd, "Cargo.toml"))) {
597
+ if (fs.existsSync(path.join(cwd, "Cargo.toml"))) {
598
+ return { cmd: "cargo build --release", label: "cargo", type: "compiled" };
599
+ }
600
+ const rsFiles = writtenFiles.filter((f) => /\.rs$/i.test(f));
601
+ if (rsFiles.length === 1) {
602
+ const outName = win ? "output.exe" : "output";
603
+ return { cmd: `rustc -o ${outName} ${rsFiles[0]}`, label: "rustc", type: "compiled", runCmd: win ? `.\\${outName}` : `./${outName}` };
604
+ }
605
+ }
606
+
607
+ // Go
608
+ if (exts.has(".go") || fs.existsSync(path.join(cwd, "go.mod"))) {
609
+ const outName = win ? "output.exe" : "output";
610
+ return { cmd: `go build -o ${outName} ./...`, label: "go build", type: "compiled", runCmd: win ? `.\\${outName}` : `./${outName}` };
611
+ }
612
+
613
+ // C# / .NET
614
+ if (exts.has(".cs")) {
615
+ const csproj = writtenFiles.find((f) => f.endsWith(".csproj"));
616
+ if (csproj || fs.existsSync(path.join(cwd, "*.csproj"))) {
617
+ return { cmd: "dotnet build", label: "dotnet", type: "compiled" };
618
+ }
619
+ const csFiles = writtenFiles.filter((f) => /\.cs$/i.test(f));
620
+ if (csFiles.length > 0) {
621
+ const outName = win ? "output.exe" : "output";
622
+ return { cmd: `dotnet-script ${csFiles[0]}`, label: "dotnet-script", type: "interpreted", runCmd: `dotnet-script ${csFiles[0]}` };
623
+ }
624
+ }
625
+
626
+ // Java
627
+ if (exts.has(".java")) {
628
+ const javaFiles = writtenFiles.filter((f) => /\.java$/i.test(f));
629
+ if (javaFiles.length > 0) {
630
+ // Find the file with main()
631
+ let mainClass = null;
632
+ for (const jf of javaFiles) {
633
+ try {
634
+ const src = fs.readFileSync(path.join(cwd, jf), "utf8");
635
+ if (src.includes("public static void main")) {
636
+ mainClass = path.basename(jf, ".java");
637
+ break;
638
+ }
639
+ } catch { /* skip */ }
640
+ }
641
+ return {
642
+ cmd: `javac ${javaFiles.join(" ")}`,
643
+ label: "javac",
644
+ type: "compiled",
645
+ runCmd: mainClass ? `java ${mainClass}` : null,
646
+ };
647
+ }
648
+ }
649
+
650
+ // Node/TypeScript — npm build script
651
+ if (fs.existsSync(path.join(cwd, "package.json"))) {
652
+ const pkg = readJsonSafe(path.join(cwd, "package.json"));
653
+ if (pkg?.scripts?.build) {
654
+ const startCmd = pkg.scripts.start ? "npm start" : null;
655
+ return { cmd: "npm install && npm run build", label: "npm build", type: "compiled", runCmd: startCmd };
656
+ }
657
+ if (pkg?.scripts?.start) {
658
+ return { cmd: "npm install", label: "npm install", type: "interpreted", runCmd: "npm start" };
659
+ }
660
+ }
661
+
662
+ // Python
663
+ if (exts.has(".py")) {
664
+ // Check for requirements.txt first
665
+ const hasReqs = fs.existsSync(path.join(cwd, "requirements.txt"));
666
+ const installCmd = hasReqs ? "pip install -r requirements.txt && " : "";
667
+ // Priority: main.py > __main__.py > app.py > single file
668
+ const pyFiles = writtenFiles.filter((f) => /\.py$/i.test(f));
669
+ const mainCandidates = ["main.py", "__main__.py", "app.py", "run.py"];
670
+ let entrypoint = null;
671
+ for (const mc of mainCandidates) {
672
+ const match = pyFiles.find((f) => path.basename(f).toLowerCase() === mc);
673
+ if (match) { entrypoint = match; break; }
674
+ }
675
+ if (!entrypoint && pyFiles.length === 1) entrypoint = pyFiles[0];
676
+ if (entrypoint) {
677
+ return {
678
+ cmd: installCmd ? installCmd.slice(0, -4) : `python ${entrypoint}`,
679
+ label: hasReqs ? "pip install" : "python",
680
+ type: "interpreted",
681
+ runCmd: `python ${entrypoint}`,
682
+ };
683
+ }
684
+ }
685
+
686
+ // Makefile fallback (no source files matched above but a Makefile exists)
687
+ if (fs.existsSync(path.join(cwd, "Makefile")) || fs.existsSync(path.join(cwd, "makefile"))) {
688
+ return { cmd: "make", label: "make", type: "compiled" };
689
+ }
690
+
691
+ return null;
692
+ }
693
+
266
694
  export function scanProjectQuick(cwd) {
267
695
  const { dirs, files } = listTopLevel(cwd);
268
696
  const total = dirs.length + files.length;
package/src/repl.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // - Bottom status line is rendered after each turn
9
9
  // - Ctrl+C: first press cancels in-progress turn, second press exits
10
10
 
11
+ import fs from "node:fs";
11
12
  import path from "node:path";
12
13
  import { runAgent } from "./agent.js";
13
14
  import { fetchBalance, AetherError } from "./api.js";
@@ -20,7 +21,7 @@ import {
20
21
  generateSessionId, saveSession, loadSession, listSessions,
21
22
  findRecentSession, deleteSession, extractSummary, formatAge,
22
23
  } from "./sessions.js";
23
- import { scanProjectQuick } from "./project-context.js";
24
+ import { scanProjectQuick, generateProjectIndex } from "./project-context.js";
24
25
 
25
26
  const VERSION = getVersion();
26
27
  const MODEL_NAME = "Aether Core";
@@ -44,6 +45,7 @@ const SHORTCUTS = `
44
45
  ${c.cyan("/yes")} Toggle auto-approve mode (skip y/N prompts)
45
46
  ${c.cyan("/turns")} ${c.gray("<n>")} Set max turns per prompt (default 25)
46
47
  ${c.cyan("/model")} ${c.gray("[core|ultra]")} Show or switch model (ultra=Opus — premium)
48
+ ${c.cyan("/init")} Deep-scan project → generate .aether/context.md
47
49
  ${c.cyan("/sessions")} List saved sessions for this directory
48
50
  ${c.cyan("/resume")} ${c.gray("[id]")} Resume a saved session (default: most recent)
49
51
  ${c.cyan("/new")} Start a fresh session (save current first)
@@ -286,6 +288,31 @@ async function handleSlash(line, state) {
286
288
  }
287
289
  break;
288
290
  }
291
+ case "init": {
292
+ console.log(c.gray("Scanning project..."));
293
+ try {
294
+ const content = generateProjectIndex(state.cwd);
295
+ const dir = path.join(state.cwd, ".aether");
296
+ fs.mkdirSync(dir, { recursive: true });
297
+ const filePath = path.join(dir, "context.md");
298
+ fs.writeFileSync(filePath, content, "utf8");
299
+ const lineCount = content.split("\n").length;
300
+ console.log(c.green("●") + " " + c.bold(`.aether/context.md`) + c.gray(` generated (${lineCount} lines)`));
301
+ console.log(c.gray(" The agent now reads this on every turn. Edit it freely to add project-specific notes."));
302
+ // Add to .gitignore if not already there
303
+ const gitignorePath = path.join(state.cwd, ".gitignore");
304
+ try {
305
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
306
+ if (!existing.includes(".aether/")) {
307
+ fs.appendFileSync(gitignorePath, `\n# Aether agent context\n.aether/\n`, "utf8");
308
+ console.log(c.gray(" Added .aether/ to .gitignore"));
309
+ }
310
+ } catch { /* non-fatal */ }
311
+ } catch (err) {
312
+ console.log(errorLine(`/init failed: ${err.message}`));
313
+ }
314
+ break;
315
+ }
289
316
  case "sessions": {
290
317
  const sessions = listSessions(state.cwd);
291
318
  if (sessions.length === 0) {