aether-code 0.5.0 → 0.6.1

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.
@@ -15,9 +15,10 @@ 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
17
  import { generateAndApprovePlan } from "../src/plan.js";
18
+ import { listSessions, loadSession, loadMostRecent, deleteSession, truncatePreview, sessionsDir } from "../src/sessions.js";
18
19
  import { c, errorLine, divider } from "../src/render.js";
19
20
 
20
- const VERSION = "0.5.0";
21
+ const VERSION = "0.6.1";
21
22
 
22
23
  const HELP = `${c.bold("aether")} — uncensored AI coding agent
23
24
 
@@ -31,6 +32,7 @@ ${c.bold("SUBCOMMANDS")}
31
32
  ${c.cyan("logout")} Clear saved API key
32
33
  ${c.cyan("balance")} Show plan + credit balance
33
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
34
36
 
35
37
  ${c.bold("EXAMPLES")}
36
38
  aether # interactive REPL
@@ -42,6 +44,8 @@ ${c.bold("EXAMPLES")}
42
44
 
43
45
  ${c.bold("FLAGS")}
44
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/.
45
49
  --yes Auto-approve all writes and shell commands. Use with care.
46
50
  --cwd <path> Working directory for the agent (default: current dir).
47
51
  --max-turns <n> Maximum turns before stopping (default: 25).
@@ -68,6 +72,18 @@ function parseArgs(argv) {
68
72
  const a = argv[i];
69
73
  if (a === "--yes") { args.flags.yes = true; }
70
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
+ }
71
87
  else if (a === "--unsafe-paths") { args.flags.unsafePaths = true; }
72
88
  else if (a === "--help" || a === "-h") { args.flags.help = true; }
73
89
  else if (a === "--version" || a === "-v") { args.flags.version = true; }
@@ -125,13 +141,30 @@ async function main() {
125
141
  await handleBalance();
126
142
  return;
127
143
  }
144
+ if (sub === "sessions") {
145
+ await handleSessions(args._.slice(1));
146
+ return;
147
+ }
128
148
 
129
149
  const prompt = args._.join(" ").trim();
130
150
 
131
- // No task drop into interactive REPL (Claude-CLI-style)
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.
132
165
  if (!prompt) {
133
166
  if (cwd !== process.cwd()) process.chdir(cwd);
134
- await runRepl({ cwd, autoYes, maxTurns });
167
+ await runRepl({ cwd, autoYes, maxTurns, resumed });
135
168
  return;
136
169
  }
137
170
 
@@ -143,13 +176,13 @@ async function main() {
143
176
  }
144
177
 
145
178
  console.log(divider());
146
- console.log(c.magenta(c.bold("aether-code")) + c.gray(` · cwd ${cwd}${autoYes ? " · auto-yes" : ""}${unsafePaths ? " · unsafe-paths" : ""}${args.flags.plan ? " · plan-mode" : ""}`));
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}` : ""}`));
147
180
  console.log(c.gray(`task: `) + prompt);
148
181
  console.log(divider());
149
182
 
150
183
  // Plan mode: generate plan first, get approval, then execute with the plan
151
184
  // injected into the conversation as context.
152
- let priorMessages = undefined;
185
+ let priorMessages = resumed ? resumed.messages : undefined;
153
186
  if (args.flags.plan) {
154
187
  let currentTask = prompt;
155
188
  // Up to 3 plan/refine cycles before bailing
@@ -186,6 +219,12 @@ async function main() {
186
219
  autoYes,
187
220
  unsafePaths,
188
221
  maxTurns,
222
+ sessionId: resumed?.id, // reuse the prior id when resuming
223
+ sessionCreatedAt: resumed?.createdAt,
224
+ saveSessions: !args.flags.noSave,
225
+ priorTotalCredits: resumed?.totalCredits ?? 0,
226
+ priorTotalIn: resumed?.totalIn ?? 0,
227
+ priorTotalOut: resumed?.totalOut ?? 0,
189
228
  });
190
229
 
191
230
  console.log("\n" + divider());
@@ -198,6 +237,9 @@ async function main() {
198
237
  console.log(c.red(c.bold("✗ Stopped")) + c.gray(` ${result.totalCredits} credits used · ${result.totalIn}→${result.totalOut} tokens`));
199
238
  if (result.error) console.log(errorLine(result.error.message));
200
239
  }
240
+ if (result.sessionId) {
241
+ console.log(c.gray(` session: ${result.sessionId} — resume with `) + c.cyan(`aether --resume ${result.sessionId}`));
242
+ }
201
243
  console.log(divider());
202
244
  }
203
245
 
@@ -257,6 +299,79 @@ async function handleBalance() {
257
299
  }
258
300
  }
259
301
 
302
+ async function handleSessions(rest) {
303
+ const sub = (rest[0] || "list").toLowerCase();
304
+
305
+ if (sub === "list" || sub === "ls") {
306
+ const sessions = listSessions(50);
307
+ if (sessions.length === 0) {
308
+ console.log(c.gray("No sessions saved yet. They appear here after you run aether on any task."));
309
+ console.log(c.gray(`Sessions live in: ${sessionsDir()}`));
310
+ return;
311
+ }
312
+ console.log(c.bold(c.magenta("Recent sessions")) + c.gray(` (${sessions.length})`));
313
+ console.log(c.gray("─".repeat(72)));
314
+ for (const s of sessions) {
315
+ const when = new Date(s.lastUsedAt).toLocaleString("en-US", {
316
+ month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
317
+ });
318
+ const credits = s.totalCredits ? c.gray(` · ${s.totalCredits} cr`) : "";
319
+ console.log(c.cyan(s.id) + c.gray(` · ${when}`) + credits);
320
+ console.log(c.gray(" ") + truncatePreview(s.firstUserMessage, 64));
321
+ console.log(c.dim(` in ${truncatePreview(s.cwd, 64)}`));
322
+ console.log("");
323
+ }
324
+ 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)`));
325
+ return;
326
+ }
327
+
328
+ if (sub === "show") {
329
+ const id = rest[1];
330
+ if (!id) die("sessions show: missing session id");
331
+ const s = loadSession(id);
332
+ if (!s) die(`No session found for '${id}'`);
333
+ console.log(c.bold(c.magenta(`Session ${s.id}`)));
334
+ console.log(c.gray(`Created: ${s.createdAt}`));
335
+ console.log(c.gray(`Last used: ${s.lastUsedAt}`));
336
+ console.log(c.gray(`Cwd: ${s.cwd}`));
337
+ console.log(c.gray(`Credits: ${s.totalCredits ?? 0}, Messages: ${s.messages?.length ?? 0}`));
338
+ console.log(c.gray("─".repeat(72)));
339
+ for (const m of s.messages || []) {
340
+ const label =
341
+ m.role === "user" ? c.cyan("user")
342
+ : m.role === "assistant" ? c.magenta("assistant")
343
+ : m.role === "tool" ? c.green("tool")
344
+ : c.gray(m.role);
345
+ const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
346
+ console.log(`${label}: ${truncatePreview(text, 200)}`);
347
+ if (m.tool_calls?.length) {
348
+ for (const tc of m.tool_calls) {
349
+ console.log(c.gray(` ↪ ${tc.function?.name}(${tc.function?.arguments?.slice(0, 80) ?? ""})`));
350
+ }
351
+ }
352
+ }
353
+ return;
354
+ }
355
+
356
+ if (sub === "delete" || sub === "rm") {
357
+ const id = rest[1];
358
+ if (!id) die("sessions delete: missing session id");
359
+ if (deleteSession(id)) {
360
+ console.log(`${c.green("✓")} Deleted session ${id}`);
361
+ } else {
362
+ die(`No session found for '${id}'`);
363
+ }
364
+ return;
365
+ }
366
+
367
+ if (sub === "dir" || sub === "path") {
368
+ console.log(sessionsDir());
369
+ return;
370
+ }
371
+
372
+ die(`sessions: unknown subcommand '${sub}'. Try: list, show <id>, delete <id>, dir`);
373
+ }
374
+
260
375
  main().catch((err) => {
261
376
  console.error(errorLine(err.message || String(err)));
262
377
  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.5.0",
3
+ "version": "0.6.1",
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 src/plan.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,15 +19,52 @@ 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
26
+ // Prior cumulative totals from a resumed session — used so the saved
27
+ // session reflects lifetime usage, not just this invocation.
28
+ priorTotalCredits = 0,
29
+ priorTotalIn = 0,
30
+ priorTotalOut = 0,
21
31
  }) {
22
32
  const messages = priorMessages
23
33
  ? [...priorMessages, { role: "user", content: initialPrompt }]
24
34
  : [{ role: "user", content: initialPrompt }];
35
+ // Per-invocation counters (used in the result summary).
25
36
  let totalCredits = 0;
26
37
  let totalIn = 0;
27
38
  let totalOut = 0;
28
39
  let lastBalance = null;
29
40
 
41
+ // Session bookkeeping — save after every turn so a crash doesn't lose state.
42
+ const activeSessionId = sessionId ?? (saveSessions ? newSessionId() : null);
43
+ const createdAt = sessionCreatedAt ?? new Date().toISOString();
44
+ // Find the first user message text for the index display
45
+ const firstUser = messages.find((m) => m.role === "user");
46
+ const firstUserMessage =
47
+ firstUser && typeof firstUser.content === "string" ? firstUser.content : initialPrompt;
48
+ const persist = () => {
49
+ if (!activeSessionId || !saveSessions) return;
50
+ try {
51
+ saveSession({
52
+ id: activeSessionId,
53
+ createdAt,
54
+ cwd,
55
+ // Save cumulative lifetime totals so /sessions show reflects all runs.
56
+ totalCredits: priorTotalCredits + totalCredits,
57
+ totalIn: priorTotalIn + totalIn,
58
+ totalOut: priorTotalOut + totalOut,
59
+ firstUserMessage,
60
+ messages,
61
+ });
62
+ } catch (e) {
63
+ // Don't kill the agent on save failure; just warn once.
64
+ console.log(c.dim(`(session save failed: ${e.message})`));
65
+ }
66
+ };
67
+
30
68
  for (let i = 0; i < maxTurns; i++) {
31
69
  process.stdout.write("\n" + turnLine(i + 1) + "\n");
32
70
 
@@ -60,7 +98,17 @@ export async function runAgent({
60
98
  });
61
99
  } catch (err) {
62
100
  if (err instanceof AetherError) {
63
- return { ok: false, error: err, totalCredits, totalIn, totalOut, balance: lastBalance, messages };
101
+ persist();
102
+ return {
103
+ ok: false,
104
+ error: err,
105
+ totalCredits,
106
+ totalIn,
107
+ totalOut,
108
+ balance: lastBalance,
109
+ messages,
110
+ sessionId: activeSessionId,
111
+ };
64
112
  }
65
113
  throw err;
66
114
  }
@@ -88,7 +136,17 @@ export async function runAgent({
88
136
 
89
137
  const toolCalls = res.message.tool_calls ?? [];
90
138
  if (toolCalls.length === 0) {
91
- return { ok: true, totalCredits, totalIn, totalOut, turns: i + 1, balance: lastBalance, messages };
139
+ persist();
140
+ return {
141
+ ok: true,
142
+ totalCredits,
143
+ totalIn,
144
+ totalOut,
145
+ turns: i + 1,
146
+ balance: lastBalance,
147
+ messages,
148
+ sessionId: activeSessionId,
149
+ };
92
150
  }
93
151
 
94
152
  // Execute each tool call. Now we know the full args, render the friendly
@@ -108,8 +166,20 @@ export async function runAgent({
108
166
  content: result.output ?? (result.ok ? "(no output)" : "Failed."),
109
167
  });
110
168
  }
169
+ // Save after each tool round so a kill -9 doesn't lose state
170
+ persist();
111
171
  }
112
172
 
113
173
  console.log(c.yellow(`\n⚠ Reached max turns (${maxTurns}). Stopping.`));
114
- return { ok: false, error: new Error("Max turns reached"), totalCredits, totalIn, totalOut, balance: lastBalance, messages };
174
+ persist();
175
+ return {
176
+ ok: false,
177
+ error: new Error("Max turns reached"),
178
+ totalCredits,
179
+ totalIn,
180
+ totalOut,
181
+ balance: lastBalance,
182
+ messages,
183
+ sessionId: activeSessionId,
184
+ };
115
185
  }
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.5.0";
19
+ const VERSION = "0.6.1";
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: [], // accumulates across turns
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,20 @@ 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,
138
+ // state.session* are cumulative — pass them so saved sessions
139
+ // reflect lifetime usage across both resume and in-REPL turns.
140
+ priorTotalCredits: state.sessionCredits,
141
+ priorTotalIn: state.sessionIn,
142
+ priorTotalOut: state.sessionOut,
128
143
  });
129
144
 
145
+ // Capture the session id from the first turn so we keep appending to it
146
+ if (!state.sessionId && result.sessionId) {
147
+ state.sessionId = result.sessionId;
148
+ if (!state.sessionCreatedAt) state.sessionCreatedAt = new Date().toISOString();
149
+ }
130
150
  state.sessionCredits += result.totalCredits ?? 0;
131
151
  state.sessionIn += result.totalIn ?? 0;
132
152
  state.sessionOut += result.totalOut ?? 0;
@@ -212,6 +232,34 @@ async function handleSlash(line, state) {
212
232
  case "model":
213
233
  console.log(c.gray(`model: ${MODEL_NAME} · 1M context · uncensored`));
214
234
  break;
235
+ case "sessions":
236
+ case "ls": {
237
+ const sessions = listSessions(15);
238
+ if (sessions.length === 0) {
239
+ console.log(c.gray("No saved sessions yet."));
240
+ } else {
241
+ console.log(c.bold(c.magenta("Recent sessions")) + c.gray(` (${sessions.length})`));
242
+ for (const s of sessions) {
243
+ const when = new Date(s.lastUsedAt).toLocaleString("en-US", {
244
+ month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
245
+ });
246
+ const mark = s.id === state.sessionId ? c.green(" ← current") : "";
247
+ console.log(c.cyan(s.id) + c.gray(` · ${when}`) + mark);
248
+ console.log(c.gray(" ") + truncatePreview(s.firstUserMessage, 64));
249
+ }
250
+ console.log(c.gray(`\nResume from shell: `) + c.cyan(`aether --resume <id>`));
251
+ }
252
+ break;
253
+ }
254
+ case "session": {
255
+ if (state.sessionId) {
256
+ console.log(c.gray(`session: `) + c.cyan(state.sessionId));
257
+ console.log(c.gray(`messages: ${state.messages.length}, credits this session: ${state.sessionCredits}`));
258
+ } else {
259
+ console.log(c.gray("No session id yet — send a message to create one."));
260
+ }
261
+ break;
262
+ }
215
263
  default:
216
264
  console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
217
265
  }
@@ -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
+ }