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.
- package/bin/aether-code.js +3 -5
- package/package.json +2 -2
- package/src/agent.js +14 -11
- package/src/api.js +3 -16
- package/src/mcp.js +2 -1
- package/src/project-context.js +279 -0
- package/src/repl.js +161 -35
- package/src/sessions.js +167 -0
- package/src/tools.js +46 -15
- package/src/version.js +15 -0
package/bin/aether-code.js
CHANGED
|
@@ -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 =
|
|
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)}
|
|
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.
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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 =
|
|
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("
|
|
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: [],
|
|
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
|
|
98
|
+
} catch { /* tolerate */ }
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
|
|
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) {
|
|
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")
|
|
152
|
+
if (handled === "exit") {
|
|
153
|
+
autoSave(state);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
119
156
|
printStatusLine(state);
|
|
120
157
|
continue;
|
|
121
158
|
}
|
|
122
159
|
|
|
123
|
-
|
|
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
|
-
"
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 {
|
package/src/sessions.js
ADDED
|
@@ -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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|