aether-code 0.4.0 → 0.6.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 +152 -4
- package/package.json +2 -2
- package/src/agent.js +66 -3
- package/src/api.js +2 -0
- package/src/plan.js +133 -0
- package/src/repl.js +49 -6
- package/src/sessions.js +145 -0
package/bin/aether-code.js
CHANGED
|
@@ -14,9 +14,11 @@ import { runRepl } from "../src/repl.js";
|
|
|
14
14
|
import { runSetup } from "../src/setup.js";
|
|
15
15
|
import { fetchBalance, AetherError } from "../src/api.js";
|
|
16
16
|
import { writeConfigFile, getConfig, CONFIG_PATH } from "../src/config.js";
|
|
17
|
+
import { generateAndApprovePlan } from "../src/plan.js";
|
|
18
|
+
import { listSessions, loadSession, loadMostRecent, deleteSession, truncatePreview, sessionsDir } from "../src/sessions.js";
|
|
17
19
|
import { c, errorLine, divider } from "../src/render.js";
|
|
18
20
|
|
|
19
|
-
const VERSION = "0.
|
|
21
|
+
const VERSION = "0.6.0";
|
|
20
22
|
|
|
21
23
|
const HELP = `${c.bold("aether")} — uncensored AI coding agent
|
|
22
24
|
|
|
@@ -30,6 +32,7 @@ ${c.bold("SUBCOMMANDS")}
|
|
|
30
32
|
${c.cyan("logout")} Clear saved API key
|
|
31
33
|
${c.cyan("balance")} Show plan + credit balance
|
|
32
34
|
${c.cyan("config")} show|set|set-base|path Manage config file
|
|
35
|
+
${c.cyan("sessions")} [list|show|delete] [id] List, show, or delete saved sessions
|
|
33
36
|
|
|
34
37
|
${c.bold("EXAMPLES")}
|
|
35
38
|
aether # interactive REPL
|
|
@@ -40,6 +43,9 @@ ${c.bold("EXAMPLES")}
|
|
|
40
43
|
aether --cwd ./my-project "fix the failing tests"
|
|
41
44
|
|
|
42
45
|
${c.bold("FLAGS")}
|
|
46
|
+
--plan Show a numbered plan first; approve / refine / cancel before any tools fire.
|
|
47
|
+
--resume [id] Continue a prior conversation. No id = most recent.
|
|
48
|
+
--no-save Don't persist this session to ~/.aether/sessions/.
|
|
43
49
|
--yes Auto-approve all writes and shell commands. Use with care.
|
|
44
50
|
--cwd <path> Working directory for the agent (default: current dir).
|
|
45
51
|
--max-turns <n> Maximum turns before stopping (default: 25).
|
|
@@ -65,6 +71,19 @@ function parseArgs(argv) {
|
|
|
65
71
|
for (let i = 0; i < argv.length; i++) {
|
|
66
72
|
const a = argv[i];
|
|
67
73
|
if (a === "--yes") { args.flags.yes = true; }
|
|
74
|
+
else if (a === "--plan") { args.flags.plan = true; }
|
|
75
|
+
else if (a === "--no-save") { args.flags.noSave = true; }
|
|
76
|
+
else if (a === "--resume") {
|
|
77
|
+
// --resume optionally takes a session id. If next arg starts with "--"
|
|
78
|
+
// or doesn't exist, default to "latest".
|
|
79
|
+
const nxt = argv[i + 1];
|
|
80
|
+
if (!nxt || nxt.startsWith("--")) {
|
|
81
|
+
args.flags.resume = "latest";
|
|
82
|
+
} else {
|
|
83
|
+
args.flags.resume = nxt;
|
|
84
|
+
i++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
68
87
|
else if (a === "--unsafe-paths") { args.flags.unsafePaths = true; }
|
|
69
88
|
else if (a === "--help" || a === "-h") { args.flags.help = true; }
|
|
70
89
|
else if (a === "--version" || a === "-v") { args.flags.version = true; }
|
|
@@ -122,13 +141,30 @@ async function main() {
|
|
|
122
141
|
await handleBalance();
|
|
123
142
|
return;
|
|
124
143
|
}
|
|
144
|
+
if (sub === "sessions") {
|
|
145
|
+
await handleSessions(args._.slice(1));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
125
148
|
|
|
126
149
|
const prompt = args._.join(" ").trim();
|
|
127
150
|
|
|
128
|
-
//
|
|
151
|
+
// Resume from a prior session if --resume was passed
|
|
152
|
+
let resumed = null;
|
|
153
|
+
if (args.flags.resume) {
|
|
154
|
+
resumed =
|
|
155
|
+
args.flags.resume === "latest"
|
|
156
|
+
? loadMostRecent()
|
|
157
|
+
: loadSession(args.flags.resume);
|
|
158
|
+
if (!resumed) {
|
|
159
|
+
die(`No session found for '${args.flags.resume}'. Run 'aether sessions' to see what's available.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// No task AND not resuming → drop into interactive REPL.
|
|
164
|
+
// No task BUT resuming → drop into REPL with the prior history loaded.
|
|
129
165
|
if (!prompt) {
|
|
130
166
|
if (cwd !== process.cwd()) process.chdir(cwd);
|
|
131
|
-
await runRepl({ cwd, autoYes, maxTurns });
|
|
167
|
+
await runRepl({ cwd, autoYes, maxTurns, resumed });
|
|
132
168
|
return;
|
|
133
169
|
}
|
|
134
170
|
|
|
@@ -140,16 +176,52 @@ async function main() {
|
|
|
140
176
|
}
|
|
141
177
|
|
|
142
178
|
console.log(divider());
|
|
143
|
-
console.log(c.magenta(c.bold("aether-code")) + c.gray(` · cwd ${cwd}${autoYes ? " · auto-yes" : ""}${unsafePaths ? " · unsafe-paths" : ""}`));
|
|
179
|
+
console.log(c.magenta(c.bold("aether-code")) + c.gray(` · cwd ${cwd}${autoYes ? " · auto-yes" : ""}${unsafePaths ? " · unsafe-paths" : ""}${args.flags.plan ? " · plan-mode" : ""}${resumed ? ` · resumed ${resumed.id}` : ""}`));
|
|
144
180
|
console.log(c.gray(`task: `) + prompt);
|
|
145
181
|
console.log(divider());
|
|
146
182
|
|
|
183
|
+
// Plan mode: generate plan first, get approval, then execute with the plan
|
|
184
|
+
// injected into the conversation as context.
|
|
185
|
+
let priorMessages = resumed ? resumed.messages : undefined;
|
|
186
|
+
if (args.flags.plan) {
|
|
187
|
+
let currentTask = prompt;
|
|
188
|
+
// Up to 3 plan/refine cycles before bailing
|
|
189
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
190
|
+
const planResult = await generateAndApprovePlan({ initialPrompt: currentTask, cwd });
|
|
191
|
+
if (planResult.cancelled) {
|
|
192
|
+
console.log(c.gray("\nCancelled. No tools were run."));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (planResult.approved) {
|
|
196
|
+
// Inject the plan as prior context so the agent remembers it as it executes
|
|
197
|
+
priorMessages = [
|
|
198
|
+
{ role: "user", content: currentTask },
|
|
199
|
+
{ role: "assistant", content: `Plan:\n${planResult.planText}` },
|
|
200
|
+
];
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
if (planResult.refinement) {
|
|
204
|
+
console.log(c.gray(`Refining: ${planResult.refinement}`));
|
|
205
|
+
currentTask = `${currentTask}\n\nRefinement: ${planResult.refinement}`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!priorMessages) {
|
|
209
|
+
console.log(c.yellow("\nGave up after 3 refinement cycles. Try a clearer task."));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
console.log("");
|
|
213
|
+
}
|
|
214
|
+
|
|
147
215
|
const result = await runAgent({
|
|
148
216
|
initialPrompt: prompt,
|
|
217
|
+
priorMessages,
|
|
149
218
|
cwd,
|
|
150
219
|
autoYes,
|
|
151
220
|
unsafePaths,
|
|
152
221
|
maxTurns,
|
|
222
|
+
sessionId: resumed?.id, // reuse the prior id when resuming
|
|
223
|
+
sessionCreatedAt: resumed?.createdAt,
|
|
224
|
+
saveSessions: !args.flags.noSave,
|
|
153
225
|
});
|
|
154
226
|
|
|
155
227
|
console.log("\n" + divider());
|
|
@@ -162,6 +234,9 @@ async function main() {
|
|
|
162
234
|
console.log(c.red(c.bold("✗ Stopped")) + c.gray(` ${result.totalCredits} credits used · ${result.totalIn}→${result.totalOut} tokens`));
|
|
163
235
|
if (result.error) console.log(errorLine(result.error.message));
|
|
164
236
|
}
|
|
237
|
+
if (result.sessionId) {
|
|
238
|
+
console.log(c.gray(` session: ${result.sessionId} — resume with `) + c.cyan(`aether --resume ${result.sessionId}`));
|
|
239
|
+
}
|
|
165
240
|
console.log(divider());
|
|
166
241
|
}
|
|
167
242
|
|
|
@@ -221,6 +296,79 @@ async function handleBalance() {
|
|
|
221
296
|
}
|
|
222
297
|
}
|
|
223
298
|
|
|
299
|
+
async function handleSessions(rest) {
|
|
300
|
+
const sub = (rest[0] || "list").toLowerCase();
|
|
301
|
+
|
|
302
|
+
if (sub === "list" || sub === "ls") {
|
|
303
|
+
const sessions = listSessions(50);
|
|
304
|
+
if (sessions.length === 0) {
|
|
305
|
+
console.log(c.gray("No sessions saved yet. They appear here after you run aether on any task."));
|
|
306
|
+
console.log(c.gray(`Sessions live in: ${sessionsDir()}`));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
console.log(c.bold(c.magenta("Recent sessions")) + c.gray(` (${sessions.length})`));
|
|
310
|
+
console.log(c.gray("─".repeat(72)));
|
|
311
|
+
for (const s of sessions) {
|
|
312
|
+
const when = new Date(s.lastUsedAt).toLocaleString("en-US", {
|
|
313
|
+
month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
|
|
314
|
+
});
|
|
315
|
+
const credits = s.totalCredits ? c.gray(` · ${s.totalCredits} cr`) : "";
|
|
316
|
+
console.log(c.cyan(s.id) + c.gray(` · ${when}`) + credits);
|
|
317
|
+
console.log(c.gray(" ") + truncatePreview(s.firstUserMessage, 64));
|
|
318
|
+
console.log(c.dim(` in ${truncatePreview(s.cwd, 64)}`));
|
|
319
|
+
console.log("");
|
|
320
|
+
}
|
|
321
|
+
console.log(c.gray(`Resume with `) + c.cyan(`aether --resume <id>`) + c.gray(` (or just `) + c.cyan(`aether --resume`) + c.gray(` for the most recent)`));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (sub === "show") {
|
|
326
|
+
const id = rest[1];
|
|
327
|
+
if (!id) die("sessions show: missing session id");
|
|
328
|
+
const s = loadSession(id);
|
|
329
|
+
if (!s) die(`No session found for '${id}'`);
|
|
330
|
+
console.log(c.bold(c.magenta(`Session ${s.id}`)));
|
|
331
|
+
console.log(c.gray(`Created: ${s.createdAt}`));
|
|
332
|
+
console.log(c.gray(`Last used: ${s.lastUsedAt}`));
|
|
333
|
+
console.log(c.gray(`Cwd: ${s.cwd}`));
|
|
334
|
+
console.log(c.gray(`Credits: ${s.totalCredits ?? 0}, Messages: ${s.messages?.length ?? 0}`));
|
|
335
|
+
console.log(c.gray("─".repeat(72)));
|
|
336
|
+
for (const m of s.messages || []) {
|
|
337
|
+
const label =
|
|
338
|
+
m.role === "user" ? c.cyan("user")
|
|
339
|
+
: m.role === "assistant" ? c.magenta("assistant")
|
|
340
|
+
: m.role === "tool" ? c.green("tool")
|
|
341
|
+
: c.gray(m.role);
|
|
342
|
+
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
|
343
|
+
console.log(`${label}: ${truncatePreview(text, 200)}`);
|
|
344
|
+
if (m.tool_calls?.length) {
|
|
345
|
+
for (const tc of m.tool_calls) {
|
|
346
|
+
console.log(c.gray(` ↪ ${tc.function?.name}(${tc.function?.arguments?.slice(0, 80) ?? ""})`));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (sub === "delete" || sub === "rm") {
|
|
354
|
+
const id = rest[1];
|
|
355
|
+
if (!id) die("sessions delete: missing session id");
|
|
356
|
+
if (deleteSession(id)) {
|
|
357
|
+
console.log(`${c.green("✓")} Deleted session ${id}`);
|
|
358
|
+
} else {
|
|
359
|
+
die(`No session found for '${id}'`);
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (sub === "dir" || sub === "path") {
|
|
365
|
+
console.log(sessionsDir());
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
die(`sessions: unknown subcommand '${sub}'. Try: list, show <id>, delete <id>, dir`);
|
|
370
|
+
}
|
|
371
|
+
|
|
224
372
|
main().catch((err) => {
|
|
225
373
|
console.error(errorLine(err.message || String(err)));
|
|
226
374
|
if (process.env.DEBUG) console.error(err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aether-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Uncensored AI coding agent for your terminal. Type `aether` to launch the interactive REPL — like Claude Code, with no refusal layer.",
|
|
5
5
|
"homepage": "https://trynoguard.com",
|
|
6
6
|
"repository": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"node": ">=18"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"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"
|
|
25
|
+
"lint": "node --check bin/aether-code.js && node --check src/agent.js && node --check src/api.js && node --check src/config.js && node --check src/render.js && node --check src/tools.js && node --check src/diff.js && node --check src/repl.js && node --check src/plan.js && node --check src/sessions.js"
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
|
28
28
|
"aether",
|
package/src/agent.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { agentTurnStream, AetherError } from "./api.js";
|
|
6
6
|
import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
|
|
7
|
+
import { saveSession, newSessionId } from "./sessions.js";
|
|
7
8
|
import {
|
|
8
9
|
c, turnLine, costLine, toolAnnounce, toolResultLine, errorLine, assistantPrefix,
|
|
9
10
|
} from "./render.js";
|
|
@@ -18,6 +19,10 @@ export async function runAgent({
|
|
|
18
19
|
unsafePaths = false,
|
|
19
20
|
maxTurns = DEFAULT_MAX_TURNS,
|
|
20
21
|
onTokens = () => {},
|
|
22
|
+
// Session persistence options
|
|
23
|
+
sessionId, // existing id (resume) or null (new)
|
|
24
|
+
saveSessions = true, // disable with --no-save
|
|
25
|
+
sessionCreatedAt, // ISO from prior session, or now
|
|
21
26
|
}) {
|
|
22
27
|
const messages = priorMessages
|
|
23
28
|
? [...priorMessages, { role: "user", content: initialPrompt }]
|
|
@@ -27,6 +32,32 @@ export async function runAgent({
|
|
|
27
32
|
let totalOut = 0;
|
|
28
33
|
let lastBalance = null;
|
|
29
34
|
|
|
35
|
+
// Session bookkeeping — save after every turn so a crash doesn't lose state.
|
|
36
|
+
const activeSessionId = sessionId ?? (saveSessions ? newSessionId() : null);
|
|
37
|
+
const createdAt = sessionCreatedAt ?? new Date().toISOString();
|
|
38
|
+
// Find the first user message text for the index display
|
|
39
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
40
|
+
const firstUserMessage =
|
|
41
|
+
firstUser && typeof firstUser.content === "string" ? firstUser.content : initialPrompt;
|
|
42
|
+
const persist = () => {
|
|
43
|
+
if (!activeSessionId || !saveSessions) return;
|
|
44
|
+
try {
|
|
45
|
+
saveSession({
|
|
46
|
+
id: activeSessionId,
|
|
47
|
+
createdAt,
|
|
48
|
+
cwd,
|
|
49
|
+
totalCredits,
|
|
50
|
+
totalIn,
|
|
51
|
+
totalOut,
|
|
52
|
+
firstUserMessage,
|
|
53
|
+
messages,
|
|
54
|
+
});
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// Don't kill the agent on save failure; just warn once.
|
|
57
|
+
console.log(c.dim(`(session save failed: ${e.message})`));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
30
61
|
for (let i = 0; i < maxTurns; i++) {
|
|
31
62
|
process.stdout.write("\n" + turnLine(i + 1) + "\n");
|
|
32
63
|
|
|
@@ -60,7 +91,17 @@ export async function runAgent({
|
|
|
60
91
|
});
|
|
61
92
|
} catch (err) {
|
|
62
93
|
if (err instanceof AetherError) {
|
|
63
|
-
|
|
94
|
+
persist();
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: err,
|
|
98
|
+
totalCredits,
|
|
99
|
+
totalIn,
|
|
100
|
+
totalOut,
|
|
101
|
+
balance: lastBalance,
|
|
102
|
+
messages,
|
|
103
|
+
sessionId: activeSessionId,
|
|
104
|
+
};
|
|
64
105
|
}
|
|
65
106
|
throw err;
|
|
66
107
|
}
|
|
@@ -88,7 +129,17 @@ export async function runAgent({
|
|
|
88
129
|
|
|
89
130
|
const toolCalls = res.message.tool_calls ?? [];
|
|
90
131
|
if (toolCalls.length === 0) {
|
|
91
|
-
|
|
132
|
+
persist();
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
totalCredits,
|
|
136
|
+
totalIn,
|
|
137
|
+
totalOut,
|
|
138
|
+
turns: i + 1,
|
|
139
|
+
balance: lastBalance,
|
|
140
|
+
messages,
|
|
141
|
+
sessionId: activeSessionId,
|
|
142
|
+
};
|
|
92
143
|
}
|
|
93
144
|
|
|
94
145
|
// Execute each tool call. Now we know the full args, render the friendly
|
|
@@ -108,8 +159,20 @@ export async function runAgent({
|
|
|
108
159
|
content: result.output ?? (result.ok ? "(no output)" : "Failed."),
|
|
109
160
|
});
|
|
110
161
|
}
|
|
162
|
+
// Save after each tool round so a kill -9 doesn't lose state
|
|
163
|
+
persist();
|
|
111
164
|
}
|
|
112
165
|
|
|
113
166
|
console.log(c.yellow(`\n⚠ Reached max turns (${maxTurns}). Stopping.`));
|
|
114
|
-
|
|
167
|
+
persist();
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: new Error("Max turns reached"),
|
|
171
|
+
totalCredits,
|
|
172
|
+
totalIn,
|
|
173
|
+
totalOut,
|
|
174
|
+
balance: lastBalance,
|
|
175
|
+
messages,
|
|
176
|
+
sessionId: activeSessionId,
|
|
177
|
+
};
|
|
115
178
|
}
|
package/src/api.js
CHANGED
|
@@ -66,6 +66,7 @@ export async function agentTurnStream({
|
|
|
66
66
|
tools,
|
|
67
67
|
maxTokens,
|
|
68
68
|
temperature,
|
|
69
|
+
toolChoice,
|
|
69
70
|
onDelta,
|
|
70
71
|
onToolCallDelta,
|
|
71
72
|
onFinish,
|
|
@@ -94,6 +95,7 @@ export async function agentTurnStream({
|
|
|
94
95
|
tools,
|
|
95
96
|
max_tokens: maxTokens,
|
|
96
97
|
temperature,
|
|
98
|
+
...(toolChoice ? { tool_choice: toolChoice } : {}),
|
|
97
99
|
}),
|
|
98
100
|
});
|
|
99
101
|
} catch (e) {
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Plan mode — get a structured multi-step plan from the model BEFORE any
|
|
2
|
+
// tools fire, show it to the user, let them approve or refine.
|
|
3
|
+
//
|
|
4
|
+
// Flow:
|
|
5
|
+
// 1. Send a preflight request to /api/v1/agent with tool_choice: "none"
|
|
6
|
+
// and a planning system prompt. Model returns a numbered plan as text.
|
|
7
|
+
// 2. Render the plan with borders. Prompt user: approve / refine / cancel.
|
|
8
|
+
// 3. If approved, the plan text is appended as a tool-style "plan" entry
|
|
9
|
+
// in the message history (so the model remembers it as it executes).
|
|
10
|
+
// 4. Normal agent loop continues with tools enabled.
|
|
11
|
+
|
|
12
|
+
import readline from "node:readline";
|
|
13
|
+
import { agentTurnStream, AetherError } from "./api.js";
|
|
14
|
+
import { TOOL_DEFINITIONS } from "./tools.js";
|
|
15
|
+
import { c, errorLine } from "./render.js";
|
|
16
|
+
|
|
17
|
+
const PLAN_SYSTEM = `You are in PLAN MODE. The user wants to see what you'll do BEFORE you do it.
|
|
18
|
+
|
|
19
|
+
Output a NUMBERED PLAN of the concrete steps you'll take. Each step should be:
|
|
20
|
+
- One short line
|
|
21
|
+
- Reference a specific tool you'll call (read_file, write_file, edit_file, run_shell, list_dir, search_files)
|
|
22
|
+
- Mention the file path or command involved
|
|
23
|
+
|
|
24
|
+
DO NOT call any tools. DO NOT include code blocks. Just the numbered plan.
|
|
25
|
+
|
|
26
|
+
End with one line: "Approve this plan? (y / n / refine)"
|
|
27
|
+
|
|
28
|
+
Example output:
|
|
29
|
+
|
|
30
|
+
1. read_file: src/auth.ts — inspect the existing handler
|
|
31
|
+
2. edit_file: src/auth.ts — add input validation before the password check
|
|
32
|
+
3. write_file: src/auth.test.ts — add a test for invalid input
|
|
33
|
+
4. run_shell: npm test — verify the test passes
|
|
34
|
+
|
|
35
|
+
Approve this plan? (y / n / refine)`;
|
|
36
|
+
|
|
37
|
+
function ask(question) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
40
|
+
rl.question(question, (answer) => {
|
|
41
|
+
rl.close();
|
|
42
|
+
resolve(answer.trim());
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderPlanBox(plan) {
|
|
48
|
+
const w = Math.min(process.stdout.columns || 80, 90);
|
|
49
|
+
const top = c.gray("╭" + "─".repeat(w - 2) + "╮");
|
|
50
|
+
const bot = c.gray("╰" + "─".repeat(w - 2) + "╯");
|
|
51
|
+
const blank = c.gray("│") + " ".repeat(w - 2) + c.gray("│");
|
|
52
|
+
const lines = [top, blank, c.gray("│ ") + c.bold(c.cyan("📋 Plan")) + " ".repeat(w - 11) + c.gray("│"), blank];
|
|
53
|
+
for (const line of plan.split("\n")) {
|
|
54
|
+
// Wrap long lines
|
|
55
|
+
const wrapWidth = w - 4;
|
|
56
|
+
let rest = line;
|
|
57
|
+
while (rest.length > wrapWidth) {
|
|
58
|
+
const slice = rest.slice(0, wrapWidth);
|
|
59
|
+
lines.push(c.gray("│ ") + slice + " ".repeat(w - 3 - slice.length) + c.gray("│"));
|
|
60
|
+
rest = rest.slice(wrapWidth);
|
|
61
|
+
}
|
|
62
|
+
lines.push(c.gray("│ ") + rest + " ".repeat(Math.max(0, w - 3 - rest.length)) + c.gray("│"));
|
|
63
|
+
}
|
|
64
|
+
lines.push(blank, bot);
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate a plan, show it, get approval. Returns:
|
|
70
|
+
* { approved: true, planText: "..." } — user said yes
|
|
71
|
+
* { approved: false, cancelled: true } — user said no
|
|
72
|
+
* { approved: false, refinement: "..." } — user wants to refine
|
|
73
|
+
*/
|
|
74
|
+
export async function generateAndApprovePlan({ initialPrompt, cwd, priorMessages }) {
|
|
75
|
+
process.stdout.write(c.dim("Generating plan...\n"));
|
|
76
|
+
|
|
77
|
+
const messages = priorMessages
|
|
78
|
+
? [...priorMessages, { role: "user", content: initialPrompt }]
|
|
79
|
+
: [
|
|
80
|
+
{ role: "system", content: PLAN_SYSTEM },
|
|
81
|
+
{ role: "user", content: initialPrompt },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
let planText = "";
|
|
85
|
+
let creditsCharged = 0;
|
|
86
|
+
let inputTokens = 0;
|
|
87
|
+
let outputTokens = 0;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const res = await agentTurnStream({
|
|
91
|
+
messages,
|
|
92
|
+
tools: TOOL_DEFINITIONS,
|
|
93
|
+
// Override server heuristic — we explicitly want NO tools here.
|
|
94
|
+
toolChoice: "none",
|
|
95
|
+
maxTokens: 1200,
|
|
96
|
+
});
|
|
97
|
+
planText = (res.message.content || "").trim();
|
|
98
|
+
creditsCharged = res.creditsCharged ?? 0;
|
|
99
|
+
inputTokens = res.usage?.prompt_tokens ?? 0;
|
|
100
|
+
outputTokens = res.usage?.completion_tokens ?? 0;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof AetherError) {
|
|
103
|
+
console.log(errorLine(err.message));
|
|
104
|
+
return { approved: false, cancelled: true };
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!planText) {
|
|
110
|
+
console.log(errorLine("Got empty plan from model. Try again or run without --plan."));
|
|
111
|
+
return { approved: false, cancelled: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Render the plan in a box
|
|
115
|
+
console.log("");
|
|
116
|
+
console.log(renderPlanBox(planText));
|
|
117
|
+
console.log(c.dim(` ${creditsCharged} cr · ${inputTokens}→${outputTokens} tokens (planning)\n`));
|
|
118
|
+
|
|
119
|
+
// Prompt for approval
|
|
120
|
+
const answer = await ask(
|
|
121
|
+
c.yellow(" ? ") + c.bold("Run this plan?") + c.gray(" [y / n / refine]: "),
|
|
122
|
+
);
|
|
123
|
+
const lower = answer.toLowerCase();
|
|
124
|
+
|
|
125
|
+
if (lower === "y" || lower === "yes") {
|
|
126
|
+
return { approved: true, planText, creditsCharged, inputTokens, outputTokens };
|
|
127
|
+
}
|
|
128
|
+
if (lower === "n" || lower === "no" || lower === "" || lower === "exit") {
|
|
129
|
+
return { approved: false, cancelled: true };
|
|
130
|
+
}
|
|
131
|
+
// Anything else is treated as refinement text
|
|
132
|
+
return { approved: false, refinement: answer || lower };
|
|
133
|
+
}
|
package/src/repl.js
CHANGED
|
@@ -13,9 +13,10 @@ import path from "node:path";
|
|
|
13
13
|
import { runAgent } from "./agent.js";
|
|
14
14
|
import { fetchBalance, AetherError } from "./api.js";
|
|
15
15
|
import { runSetup } from "./setup.js";
|
|
16
|
+
import { listSessions, truncatePreview } from "./sessions.js";
|
|
16
17
|
import { c, errorLine, banner, statusLine } from "./render.js";
|
|
17
18
|
|
|
18
|
-
const VERSION = "0.
|
|
19
|
+
const VERSION = "0.6.0";
|
|
19
20
|
const MODEL_NAME = "Aether Core";
|
|
20
21
|
|
|
21
22
|
const SHORTCUTS = `
|
|
@@ -27,22 +28,29 @@ const SHORTCUTS = `
|
|
|
27
28
|
${c.cyan("/yes")} Toggle auto-approve mode (skip y/N prompts)
|
|
28
29
|
${c.cyan("/turns")} ${c.gray("<n>")} Set max turns per prompt (default 25)
|
|
29
30
|
${c.cyan("/model")} Show current model
|
|
31
|
+
${c.cyan("/sessions")} List recent saved sessions
|
|
32
|
+
${c.cyan("/session")} Show id + path of the current session
|
|
30
33
|
|
|
31
34
|
${c.gray("Anything else is sent to the agent as your next message.")}
|
|
32
35
|
${c.gray("Conversation history is kept across messages until you /clear.")}
|
|
33
36
|
`;
|
|
34
37
|
|
|
35
|
-
export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, maxTurns: initialMaxTurns }) {
|
|
38
|
+
export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, maxTurns: initialMaxTurns, resumed }) {
|
|
36
39
|
const state = {
|
|
37
40
|
cwd: initialCwd,
|
|
38
41
|
autoYes: !!initialAutoYes,
|
|
39
42
|
maxTurns: initialMaxTurns ?? 25,
|
|
40
|
-
messages: [],
|
|
43
|
+
messages: resumed?.messages ?? [],
|
|
41
44
|
balance: null,
|
|
42
|
-
sessionCredits: 0,
|
|
43
|
-
sessionIn: 0,
|
|
44
|
-
sessionOut: 0,
|
|
45
|
+
sessionCredits: resumed?.totalCredits ?? 0,
|
|
46
|
+
sessionIn: resumed?.totalIn ?? 0,
|
|
47
|
+
sessionOut: resumed?.totalOut ?? 0,
|
|
48
|
+
sessionId: resumed?.id,
|
|
49
|
+
sessionCreatedAt: resumed?.createdAt,
|
|
45
50
|
};
|
|
51
|
+
if (resumed) {
|
|
52
|
+
console.log(c.gray(`\nResumed session ${c.cyan(resumed.id)} · ${state.messages.length} messages from ${new Date(resumed.lastUsedAt).toLocaleString()}\n`));
|
|
53
|
+
}
|
|
46
54
|
|
|
47
55
|
// Free balance check up front. If no key configured, walk through first-time
|
|
48
56
|
// setup flow (open browser → paste key → verify → save).
|
|
@@ -125,8 +133,15 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, maxTur
|
|
|
125
133
|
cwd: state.cwd,
|
|
126
134
|
autoYes: state.autoYes,
|
|
127
135
|
maxTurns: state.maxTurns,
|
|
136
|
+
sessionId: state.sessionId,
|
|
137
|
+
sessionCreatedAt: state.sessionCreatedAt,
|
|
128
138
|
});
|
|
129
139
|
|
|
140
|
+
// Capture the session id from the first turn so we keep appending to it
|
|
141
|
+
if (!state.sessionId && result.sessionId) {
|
|
142
|
+
state.sessionId = result.sessionId;
|
|
143
|
+
if (!state.sessionCreatedAt) state.sessionCreatedAt = new Date().toISOString();
|
|
144
|
+
}
|
|
130
145
|
state.sessionCredits += result.totalCredits ?? 0;
|
|
131
146
|
state.sessionIn += result.totalIn ?? 0;
|
|
132
147
|
state.sessionOut += result.totalOut ?? 0;
|
|
@@ -212,6 +227,34 @@ async function handleSlash(line, state) {
|
|
|
212
227
|
case "model":
|
|
213
228
|
console.log(c.gray(`model: ${MODEL_NAME} · 1M context · uncensored`));
|
|
214
229
|
break;
|
|
230
|
+
case "sessions":
|
|
231
|
+
case "ls": {
|
|
232
|
+
const sessions = listSessions(15);
|
|
233
|
+
if (sessions.length === 0) {
|
|
234
|
+
console.log(c.gray("No saved sessions yet."));
|
|
235
|
+
} else {
|
|
236
|
+
console.log(c.bold(c.magenta("Recent sessions")) + c.gray(` (${sessions.length})`));
|
|
237
|
+
for (const s of sessions) {
|
|
238
|
+
const when = new Date(s.lastUsedAt).toLocaleString("en-US", {
|
|
239
|
+
month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
|
|
240
|
+
});
|
|
241
|
+
const mark = s.id === state.sessionId ? c.green(" ← current") : "";
|
|
242
|
+
console.log(c.cyan(s.id) + c.gray(` · ${when}`) + mark);
|
|
243
|
+
console.log(c.gray(" ") + truncatePreview(s.firstUserMessage, 64));
|
|
244
|
+
}
|
|
245
|
+
console.log(c.gray(`\nResume from shell: `) + c.cyan(`aether --resume <id>`));
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case "session": {
|
|
250
|
+
if (state.sessionId) {
|
|
251
|
+
console.log(c.gray(`session: `) + c.cyan(state.sessionId));
|
|
252
|
+
console.log(c.gray(`messages: ${state.messages.length}, credits this session: ${state.sessionCredits}`));
|
|
253
|
+
} else {
|
|
254
|
+
console.log(c.gray("No session id yet — send a message to create one."));
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
215
258
|
default:
|
|
216
259
|
console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
|
|
217
260
|
}
|
package/src/sessions.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Session persistence — save conversation history to disk so users can
|
|
2
|
+
// resume across invocations.
|
|
3
|
+
//
|
|
4
|
+
// Storage: `~/.aether/sessions/<id>.json`, one file per session.
|
|
5
|
+
// Index: `~/.aether/sessions/index.json` — a sorted list of recent sessions
|
|
6
|
+
// with metadata for `aether sessions` and `aether --resume`.
|
|
7
|
+
//
|
|
8
|
+
// Garbage collection: keep at most MAX_SESSIONS (50). Older files removed
|
|
9
|
+
// on each save. The index reflects only live files.
|
|
10
|
+
//
|
|
11
|
+
// File mode 0600 — same convention as ~/.aetherrc since sessions can contain
|
|
12
|
+
// pasted code, internal paths, etc.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
|
|
18
|
+
const SESSIONS_DIR = path.join(os.homedir(), ".aether", "sessions");
|
|
19
|
+
const INDEX_FILE = path.join(SESSIONS_DIR, "index.json");
|
|
20
|
+
const MAX_SESSIONS = 50;
|
|
21
|
+
|
|
22
|
+
export function sessionsDir() {
|
|
23
|
+
return SESSIONS_DIR;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureDir() {
|
|
27
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Generate a fresh session id — local date+time, sortable. */
|
|
31
|
+
export function newSessionId() {
|
|
32
|
+
const now = new Date();
|
|
33
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
34
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Persist a session. Called incrementally as the agent runs.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} session — { id, createdAt, lastUsedAt, cwd, model, totalCredits,
|
|
41
|
+
* totalIn, totalOut, firstUserMessage, messages }
|
|
42
|
+
*/
|
|
43
|
+
export function saveSession(session) {
|
|
44
|
+
ensureDir();
|
|
45
|
+
const file = path.join(SESSIONS_DIR, `${session.id}.json`);
|
|
46
|
+
const payload = {
|
|
47
|
+
...session,
|
|
48
|
+
lastUsedAt: new Date().toISOString(),
|
|
49
|
+
};
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
51
|
+
try { fs.chmodSync(file, 0o600); } catch { /* windows */ }
|
|
52
|
+
rebuildIndex();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Load a session by id. Returns null if not found. */
|
|
56
|
+
export function loadSession(id) {
|
|
57
|
+
const file = path.join(SESSIONS_DIR, `${id}.json`);
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Load the most recent session (by lastUsedAt). Null if none. */
|
|
66
|
+
export function loadMostRecent() {
|
|
67
|
+
const list = listSessions(1);
|
|
68
|
+
if (list.length === 0) return null;
|
|
69
|
+
return loadSession(list[0].id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Delete a session by id. Returns true if it existed. */
|
|
73
|
+
export function deleteSession(id) {
|
|
74
|
+
const file = path.join(SESSIONS_DIR, `${id}.json`);
|
|
75
|
+
try {
|
|
76
|
+
fs.unlinkSync(file);
|
|
77
|
+
rebuildIndex();
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Return up to `limit` sessions sorted by lastUsedAt descending.
|
|
86
|
+
* Each entry is the LIGHTWEIGHT summary, not the full conversation.
|
|
87
|
+
*/
|
|
88
|
+
export function listSessions(limit = 20) {
|
|
89
|
+
ensureDir();
|
|
90
|
+
try {
|
|
91
|
+
const idx = JSON.parse(fs.readFileSync(INDEX_FILE, "utf8"));
|
|
92
|
+
return Array.isArray(idx) ? idx.slice(0, limit) : [];
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Rebuild the index by reading every session file's metadata. */
|
|
99
|
+
function rebuildIndex() {
|
|
100
|
+
ensureDir();
|
|
101
|
+
let files;
|
|
102
|
+
try {
|
|
103
|
+
files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json") && f !== "index.json");
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const entries = [];
|
|
108
|
+
for (const f of files) {
|
|
109
|
+
try {
|
|
110
|
+
const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), "utf8"));
|
|
111
|
+
entries.push({
|
|
112
|
+
id: s.id,
|
|
113
|
+
createdAt: s.createdAt,
|
|
114
|
+
lastUsedAt: s.lastUsedAt ?? s.createdAt,
|
|
115
|
+
cwd: s.cwd,
|
|
116
|
+
firstUserMessage: s.firstUserMessage ?? "(no message)",
|
|
117
|
+
totalCredits: s.totalCredits ?? 0,
|
|
118
|
+
messageCount: Array.isArray(s.messages) ? s.messages.length : 0,
|
|
119
|
+
});
|
|
120
|
+
} catch {
|
|
121
|
+
// Corrupt file — skip
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Sort by last used, descending
|
|
125
|
+
entries.sort((a, b) => (b.lastUsedAt || "").localeCompare(a.lastUsedAt || ""));
|
|
126
|
+
|
|
127
|
+
// Garbage-collect oldest sessions beyond MAX_SESSIONS
|
|
128
|
+
if (entries.length > MAX_SESSIONS) {
|
|
129
|
+
const toDelete = entries.slice(MAX_SESSIONS);
|
|
130
|
+
for (const old of toDelete) {
|
|
131
|
+
try { fs.unlinkSync(path.join(SESSIONS_DIR, `${old.id}.json`)); } catch { /* tolerate */ }
|
|
132
|
+
}
|
|
133
|
+
entries.length = MAX_SESSIONS;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fs.writeFileSync(INDEX_FILE, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
137
|
+
try { fs.chmodSync(INDEX_FILE, 0o600); } catch { /* windows */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Truncate a long user message for display in the listing. */
|
|
141
|
+
export function truncatePreview(text, max = 60) {
|
|
142
|
+
if (!text) return "";
|
|
143
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
144
|
+
return oneLine.length > max ? oneLine.slice(0, max - 1) + "…" : oneLine;
|
|
145
|
+
}
|