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/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/render.js +33 -0
- package/src/repl.js +161 -35
- package/src/sessions.js +167 -0
- package/src/tools.js +392 -15
- package/src/version.js +15 -0
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
|
+
}
|