aether-code 0.36.8 → 0.38.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/src/repl.js CHANGED
@@ -15,8 +15,14 @@ import { runSetup } from "./setup.js";
15
15
  import { c, errorLine, boxLines, sideBySide } from "./render.js";
16
16
  import { checkForUpdate } from "./update-check.js";
17
17
  import { promptBox, EXIT_SIGNAL } from "./box-input.js";
18
+ import { getVersion } from "./version.js";
19
+ import {
20
+ generateSessionId, saveSession, loadSession, listSessions,
21
+ findRecentSession, deleteSession, extractSummary, formatAge,
22
+ } from "./sessions.js";
23
+ import { scanProjectQuick } from "./project-context.js";
18
24
 
19
- const VERSION = "0.32.0";
25
+ const VERSION = getVersion();
20
26
  const MODEL_NAME = "Aether Core";
21
27
 
22
28
  // /model aliases → { id, name }. id=null = server default (gemma / Aether Core).
@@ -38,9 +44,12 @@ const SHORTCUTS = `
38
44
  ${c.cyan("/yes")} Toggle auto-approve mode (skip y/N prompts)
39
45
  ${c.cyan("/turns")} ${c.gray("<n>")} Set max turns per prompt (default 25)
40
46
  ${c.cyan("/model")} ${c.gray("[core|ultra]")} Show or switch model (ultra=Opus — premium)
47
+ ${c.cyan("/sessions")} List saved sessions for this directory
48
+ ${c.cyan("/resume")} ${c.gray("[id]")} Resume a saved session (default: most recent)
49
+ ${c.cyan("/new")} Start a fresh session (save current first)
41
50
 
42
51
  ${c.gray("Anything else is sent to the agent as your next message.")}
43
- ${c.gray("Conversation history is kept across messages until you /clear.")}
52
+ ${c.gray("Sessions auto-save — close the terminal and come back later.")}
44
53
  `;
45
54
 
46
55
  export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafePaths: initialUnsafePaths, maxTurns: initialMaxTurns, model: initialModel = null, mcpManager = null }) {
@@ -52,19 +61,17 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
52
61
  maxTurns: initialMaxTurns ?? 25,
53
62
  model: initialModel ?? null,
54
63
  modelName: initialAlias?.name ?? MODEL_NAME,
55
- messages: [], // accumulates across turns
64
+ messages: [],
56
65
  balance: null,
57
66
  sessionCredits: 0,
58
67
  sessionIn: 0,
59
68
  sessionOut: 0,
69
+ sessionId: generateSessionId(initialCwd),
70
+ turnCount: 0,
60
71
  };
61
72
 
62
- // Kick off the npm update check concurrently with the balance fetch so it
63
- // adds no startup latency; the nudge (if any) prints just under the banner.
64
73
  const updatePromise = checkForUpdate().catch(() => null);
65
74
 
66
- // Free balance check up front. If no key configured, walk through first-time
67
- // setup flow (open browser → paste key → verify → save).
68
75
  let needsSetup = false;
69
76
  try {
70
77
  const me = await fetchBalance();
@@ -74,7 +81,6 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
74
81
  if (err instanceof AetherError && (err.code === "NO_API_KEY" || err.status === 401)) {
75
82
  needsSetup = true;
76
83
  } else {
77
- // Transient network or other — surface but don't block
78
84
  console.log(c.gray(`(could not fetch balance: ${err.message})`));
79
85
  }
80
86
  }
@@ -85,42 +91,73 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
85
91
  console.log(c.gray("Aborting — no valid API key."));
86
92
  process.exit(1);
87
93
  }
88
- // Re-fetch balance now that the key is saved
89
94
  try {
90
95
  const me = await fetchBalance();
91
96
  state.balance = me.balance;
92
97
  state.plan = me.plan;
93
- } catch { /* tolerate — banner just won't show balance */ }
98
+ } catch { /* tolerate */ }
94
99
  }
95
100
 
96
- printBanner(state);
101
+ // Check for a resumable session before printing the banner
102
+ let resumed = false;
103
+ try {
104
+ const recent = findRecentSession(state.cwd);
105
+ if (recent && recent.messageCount >= 2) {
106
+ const full = loadSession(recent.id);
107
+ if (full && full.messages?.length >= 2) {
108
+ console.log(
109
+ c.gray(`\n Found session from ${formatAge(recent.updatedAt)} — `) +
110
+ c.cyan(extractSummary(full.messages) || "(no summary)"),
111
+ );
112
+ console.log(c.gray(" Resuming automatically. Use /new to start fresh.\n"));
113
+ state.sessionId = full.id;
114
+ state.messages = full.messages;
115
+ state.sessionCredits = full.credits ?? 0;
116
+ state.sessionIn = full.tokensIn ?? 0;
117
+ state.sessionOut = full.tokensOut ?? 0;
118
+ state.turnCount = full.turnCount ?? 0;
119
+ if (full.model !== undefined) state.model = full.model;
120
+ if (full.modelName) state.modelName = full.modelName;
121
+ resumed = true;
122
+ }
123
+ }
124
+ } catch { /* non-fatal */ }
125
+
126
+ // Project quick-scan for banner
127
+ let projectLine = "";
128
+ try {
129
+ projectLine = scanProjectQuick(state.cwd);
130
+ } catch { /* non-fatal */ }
131
+
132
+ printBanner(state, projectLine, resumed);
97
133
  const updateNudge = await updatePromise;
98
134
  if (updateNudge) console.log(updateNudge + "\n");
99
135
 
100
- // Input: a bordered raw-mode box (box-input.js) — cmd.exe-safe and identical
101
- // everywhere. (Ink's live redraw ghosts in the legacy Windows console, so we
102
- // don't use it.) Each prompt is independent; the tool y/N prompts and the
103
- // ask_user menu run BETWEEN prompts in raw mode too, so nothing overlaps.
104
136
  const inputHistory = [];
105
137
 
106
138
  while (true) {
107
139
  const raw = await promptBox({ history: inputHistory });
108
- if (raw === EXIT_SIGNAL) { console.log(c.gray("bye.")); return; }
140
+ if (raw === EXIT_SIGNAL) {
141
+ autoSave(state);
142
+ console.log(c.gray("bye."));
143
+ return;
144
+ }
109
145
  const line = (raw ?? "").trim();
110
146
  if (!line) continue;
111
147
 
112
- // promptBox already echoes "› <line>" on submit, so no echo needed here.
113
148
  inputHistory.push(line);
114
149
 
115
- // Slash command?
116
150
  if (line.startsWith("/") || line === "?") {
117
151
  const handled = await handleSlash(line, state);
118
- if (handled === "exit") return;
152
+ if (handled === "exit") {
153
+ autoSave(state);
154
+ return;
155
+ }
119
156
  printStatusLine(state);
120
157
  continue;
121
158
  }
122
159
 
123
- // Otherwise — send as a message to the agent
160
+ state.turnCount++;
124
161
  const result = await runAgent({
125
162
  initialPrompt: line,
126
163
  priorMessages: state.messages.length > 0 ? state.messages : undefined,
@@ -140,13 +177,31 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
140
177
 
141
178
  if (!result.ok && result.error) {
142
179
  console.log("\n" + errorLine(result.error.message || String(result.error)));
143
- // Don't kill the session on a single error — surface it and continue.
144
180
  }
145
181
 
182
+ autoSave(state);
146
183
  printStatusLine(state);
147
184
  }
148
185
  }
149
186
 
187
+ function autoSave(state) {
188
+ if (state.messages.length < 2) return;
189
+ try {
190
+ saveSession({
191
+ id: state.sessionId,
192
+ cwd: state.cwd,
193
+ model: state.model,
194
+ modelName: state.modelName,
195
+ messages: state.messages,
196
+ credits: state.sessionCredits,
197
+ tokensIn: state.sessionIn,
198
+ tokensOut: state.sessionOut,
199
+ turnCount: state.turnCount,
200
+ summary: extractSummary(state.messages),
201
+ });
202
+ } catch { /* non-fatal — don't kill the session over a save failure */ }
203
+ }
204
+
150
205
  /* ───────── slash commands ───────── */
151
206
 
152
207
  async function handleSlash(line, state) {
@@ -231,6 +286,75 @@ async function handleSlash(line, state) {
231
286
  }
232
287
  break;
233
288
  }
289
+ case "sessions": {
290
+ const sessions = listSessions(state.cwd);
291
+ if (sessions.length === 0) {
292
+ console.log(c.gray("No saved sessions for this directory."));
293
+ } else {
294
+ console.log(c.bold("Saved sessions") + c.gray(` (${state.cwd})`));
295
+ for (const s of sessions.slice(0, 10)) {
296
+ const active = s.id === state.sessionId ? c.cyan(" ← current") : "";
297
+ const summary = s.summary || "(no summary)";
298
+ const truncated = summary.length > 60 ? summary.slice(0, 57) + "..." : summary;
299
+ console.log(
300
+ ` ${c.gray(formatAge(s.updatedAt).padEnd(8))} ` +
301
+ `${c.gray(s.id.slice(0, 16))} ` +
302
+ `${truncated}` +
303
+ `${c.gray(` · ${s.turnCount} turns`)}` +
304
+ active,
305
+ );
306
+ }
307
+ if (sessions.length > 10) {
308
+ console.log(c.gray(` ... and ${sessions.length - 10} more`));
309
+ }
310
+ console.log(c.gray("\nUse /resume <id-prefix> to load one."));
311
+ }
312
+ break;
313
+ }
314
+ case "resume": {
315
+ const target = (arg || "").trim();
316
+ let session = null;
317
+ if (target) {
318
+ const sessions = listSessions(state.cwd);
319
+ const match = sessions.find((s) => s.id.startsWith(target));
320
+ if (match) session = loadSession(match.id);
321
+ if (!session) {
322
+ console.log(errorLine(`No session matching "${target}" found.`));
323
+ break;
324
+ }
325
+ } else {
326
+ const recent = findRecentSession(state.cwd);
327
+ if (recent) session = loadSession(recent.id);
328
+ if (!session) {
329
+ console.log(errorLine("No recent session to resume. Use /sessions to list."));
330
+ break;
331
+ }
332
+ }
333
+ autoSave(state);
334
+ state.sessionId = session.id;
335
+ state.messages = session.messages ?? [];
336
+ state.sessionCredits = session.credits ?? 0;
337
+ state.sessionIn = session.tokensIn ?? 0;
338
+ state.sessionOut = session.tokensOut ?? 0;
339
+ state.turnCount = session.turnCount ?? 0;
340
+ if (session.model !== undefined) state.model = session.model;
341
+ if (session.modelName) state.modelName = session.modelName;
342
+ const summary = extractSummary(session.messages) || "(no summary)";
343
+ console.log(c.gray(`Resumed: `) + c.cyan(summary));
344
+ console.log(c.gray(` ${state.messages.length} messages · ${state.turnCount} turns · ${formatAge(session.updatedAt)}`));
345
+ break;
346
+ }
347
+ case "new": {
348
+ autoSave(state);
349
+ state.sessionId = generateSessionId(state.cwd);
350
+ state.messages = [];
351
+ state.sessionCredits = 0;
352
+ state.sessionIn = 0;
353
+ state.sessionOut = 0;
354
+ state.turnCount = 0;
355
+ console.log(c.gray("New session started. Previous session saved."));
356
+ break;
357
+ }
234
358
  default:
235
359
  console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
236
360
  }
@@ -245,23 +369,27 @@ const shortenPath = (p, max) => (p.length <= max ? p : "..." + p.slice(p.length
245
369
  // Short, hand-curated highlights shown in the "What's new" box. Update with
246
370
  // notable releases — keep it to ~3 lines so it balances the banner height.
247
371
  const WHATS_NEW = [
248
- "Files now save to your Desktop",
372
+ "Session persistence — close & resume",
373
+ "Project auto-understanding on startup",
249
374
  "Live thinking spinner + loop guard",
250
- "Asks you when a task is unclear",
251
375
  ];
252
376
 
253
- function printBanner(state) {
377
+ function printBanner(state, projectLine = "", resumed = false) {
254
378
  const mode = state.autoYes ? (state.unsafePaths ? "skip-permissions" : "auto-yes") : "review mode";
255
379
  const credits = state.balance != null ? `${state.balance.toLocaleString()} credits` : "";
256
- const left = boxLines(
257
- [
258
- c.bold(c.magenta("aether")) + c.gray(" · ") + c.bold(state.modelName) + c.gray(" v" + VERSION),
259
- c.gray("uncensored AI coding agent"),
260
- c.gray((credits ? credits + " · " : "") + mode),
261
- c.gray(shortenPath(state.cwd, 52)),
262
- ],
263
- { color: c.magenta, padX: 2 },
264
- );
380
+ const leftLines = [
381
+ c.bold(c.magenta("aether")) + c.gray(" · ") + c.bold(state.modelName) + c.gray(" v" + VERSION),
382
+ c.gray("uncensored AI coding agent"),
383
+ c.gray((credits ? credits + " · " : "") + mode),
384
+ c.gray(shortenPath(state.cwd, 52)),
385
+ ];
386
+ if (projectLine) {
387
+ leftLines.push(c.gray("project: " + (projectLine.length > 48 ? projectLine.slice(0, 45) + "..." : projectLine)));
388
+ }
389
+ if (resumed) {
390
+ leftLines.push(c.cyan("↻ resumed session") + c.gray(` · ${state.turnCount} turns · ${state.messages.length} msgs`));
391
+ }
392
+ const left = boxLines(leftLines, { color: c.magenta, padX: 2 });
265
393
  const right = boxLines(
266
394
  [c.bold(c.cyan("What's new")), ...WHATS_NEW.map((s) => c.gray("• " + s))],
267
395
  { color: c.cyan, padX: 2 },
@@ -269,8 +397,6 @@ function printBanner(state) {
269
397
 
270
398
  console.log("");
271
399
  const cols = process.stdout.columns || 80;
272
- // Only place the two boxes side by side when there's room; otherwise the
273
- // banner alone (narrow terminals would wrap and tear the second box).
274
400
  if (cols >= visLen(left[0]) + visLen(right[0]) + 4) {
275
401
  console.log(sideBySide(left, right, 3));
276
402
  } else {
@@ -0,0 +1,167 @@
1
+ // Session persistence. Saves conversation state to ~/.aether/sessions/ so
2
+ // users can close the terminal, come back tomorrow, and resume exactly
3
+ // where they left off. Auto-saves after every agent turn.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import os from "node:os";
8
+ import crypto from "node:crypto";
9
+
10
+ const SESSIONS_DIR = path.join(os.homedir(), ".aether", "sessions");
11
+ const MAX_SESSIONS = 50;
12
+ const STALE_DAYS = 14;
13
+
14
+ function ensureDir() {
15
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
16
+ }
17
+
18
+ function cwdHash(cwd) {
19
+ return crypto.createHash("sha256").update(cwd.toLowerCase()).digest("hex").slice(0, 12);
20
+ }
21
+
22
+ function sessionPath(id) {
23
+ return path.join(SESSIONS_DIR, `${id}.json`);
24
+ }
25
+
26
+ export function generateSessionId(cwd) {
27
+ const hash = cwdHash(cwd);
28
+ const ts = Date.now().toString(36);
29
+ return `${hash}-${ts}`;
30
+ }
31
+
32
+ export function saveSession(session) {
33
+ ensureDir();
34
+ const data = {
35
+ id: session.id,
36
+ cwd: session.cwd,
37
+ model: session.model ?? null,
38
+ modelName: session.modelName ?? "Aether Core",
39
+ messages: session.messages ?? [],
40
+ credits: session.credits ?? 0,
41
+ tokensIn: session.tokensIn ?? 0,
42
+ tokensOut: session.tokensOut ?? 0,
43
+ createdAt: session.createdAt ?? new Date().toISOString(),
44
+ updatedAt: new Date().toISOString(),
45
+ summary: session.summary ?? "",
46
+ turnCount: session.turnCount ?? 0,
47
+ };
48
+ const filePath = sessionPath(data.id);
49
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
50
+ try { fs.chmodSync(filePath, 0o600); } catch { /* windows */ }
51
+ pruneOldSessions();
52
+ return data;
53
+ }
54
+
55
+ export function loadSession(id) {
56
+ const filePath = sessionPath(id);
57
+ try {
58
+ const raw = fs.readFileSync(filePath, "utf8");
59
+ return JSON.parse(raw);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export function deleteSession(id) {
66
+ try {
67
+ fs.unlinkSync(sessionPath(id));
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ export function listSessions(filterCwd) {
75
+ ensureDir();
76
+ let files;
77
+ try {
78
+ files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
79
+ } catch {
80
+ return [];
81
+ }
82
+ const sessions = [];
83
+ for (const f of files) {
84
+ try {
85
+ const raw = fs.readFileSync(path.join(SESSIONS_DIR, f), "utf8");
86
+ const data = JSON.parse(raw);
87
+ if (filterCwd && data.cwd?.toLowerCase() !== filterCwd.toLowerCase()) continue;
88
+ sessions.push({
89
+ id: data.id,
90
+ cwd: data.cwd,
91
+ model: data.model,
92
+ modelName: data.modelName,
93
+ summary: data.summary,
94
+ turnCount: data.turnCount ?? 0,
95
+ messageCount: data.messages?.length ?? 0,
96
+ credits: data.credits ?? 0,
97
+ createdAt: data.createdAt,
98
+ updatedAt: data.updatedAt,
99
+ });
100
+ } catch {
101
+ continue;
102
+ }
103
+ }
104
+ sessions.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
105
+ return sessions;
106
+ }
107
+
108
+ export function findRecentSession(cwd, maxAgeMs = 24 * 60 * 60 * 1000) {
109
+ const sessions = listSessions(cwd);
110
+ if (sessions.length === 0) return null;
111
+ const newest = sessions[0];
112
+ if (!newest.updatedAt) return null;
113
+ const age = Date.now() - new Date(newest.updatedAt).getTime();
114
+ if (age > maxAgeMs) return null;
115
+ if (newest.messageCount < 2) return null;
116
+ return newest;
117
+ }
118
+
119
+ function pruneOldSessions() {
120
+ let files;
121
+ try {
122
+ files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
123
+ } catch {
124
+ return;
125
+ }
126
+ if (files.length <= MAX_SESSIONS) return;
127
+ const entries = [];
128
+ for (const f of files) {
129
+ try {
130
+ const stat = fs.statSync(path.join(SESSIONS_DIR, f));
131
+ entries.push({ file: f, mtime: stat.mtimeMs });
132
+ } catch {
133
+ continue;
134
+ }
135
+ }
136
+ entries.sort((a, b) => b.mtime - a.mtime);
137
+ const cutoff = Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000;
138
+ for (const e of entries.slice(MAX_SESSIONS)) {
139
+ if (e.mtime < cutoff) {
140
+ try { fs.unlinkSync(path.join(SESSIONS_DIR, e.file)); } catch { /* ignore */ }
141
+ }
142
+ }
143
+ }
144
+
145
+ export function extractSummary(messages) {
146
+ if (!messages || messages.length === 0) return "";
147
+ const firstUser = messages.find((m) => m.role === "user");
148
+ if (!firstUser || typeof firstUser.content !== "string") return "";
149
+ const text = firstUser.content
150
+ .replace(/\[environment\][\s\S]*?\[\/environment\]\s*/g, "")
151
+ .replace(/\[project\][\s\S]*?\[\/project\]\s*/g, "")
152
+ .replace(/=== LOADED SKILLS[\s\S]*?---\s*/g, "")
153
+ .trim();
154
+ return text.length > 120 ? text.slice(0, 117) + "..." : text;
155
+ }
156
+
157
+ export function formatAge(isoDate) {
158
+ if (!isoDate) return "unknown";
159
+ const ms = Date.now() - new Date(isoDate).getTime();
160
+ const mins = Math.floor(ms / 60_000);
161
+ if (mins < 1) return "just now";
162
+ if (mins < 60) return `${mins}m ago`;
163
+ const hours = Math.floor(mins / 60);
164
+ if (hours < 24) return `${hours}h ago`;
165
+ const days = Math.floor(hours / 24);
166
+ return `${days}d ago`;
167
+ }