mini-coder 0.5.13 → 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.
Files changed (67) hide show
  1. package/README.md +25 -108
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/package.json +17 -22
  5. package/src/agent.ts +242 -915
  6. package/src/args.ts +289 -0
  7. package/src/headless.ts +43 -385
  8. package/src/index.ts +29 -836
  9. package/src/oauth.ts +117 -0
  10. package/src/prompt.ts +227 -276
  11. package/src/session.ts +57 -961
  12. package/src/shared.ts +117 -38
  13. package/src/tool-bash.ts +110 -0
  14. package/src/tool-edit.ts +133 -0
  15. package/src/tool-task.ts +114 -0
  16. package/src/tui-components.ts +150 -0
  17. package/src/tui-conversation.ts +262 -0
  18. package/src/tui-editor.ts +29 -0
  19. package/src/tui-overlay.ts +403 -0
  20. package/src/tui.ts +236 -0
  21. package/src/types.ts +160 -0
  22. package/tsconfig.json +17 -0
  23. package/BENCHMARK.md +0 -107
  24. package/LICENSE +0 -9
  25. package/PROGRESS.md +0 -4
  26. package/assets/icon-1-minimal.svg +0 -31
  27. package/assets/icon-2-dark-terminal.svg +0 -48
  28. package/assets/icon-3-gradient-modern.svg +0 -45
  29. package/assets/icon-4-filled-bold.svg +0 -54
  30. package/assets/icon-5-community-badge.svg +0 -63
  31. package/assets/mc-claude-smart.png +0 -0
  32. package/assets/mc-gpt-smart.png +0 -0
  33. package/assets/preview-0-5-0.png +0 -0
  34. package/assets/preview.gif +0 -0
  35. package/benchmark-baseline.sh +0 -15
  36. package/benchmark-loop.sh +0 -19
  37. package/skills-lock.json +0 -15
  38. package/src/cli.ts +0 -134
  39. package/src/errors.ts +0 -15
  40. package/src/git.ts +0 -247
  41. package/src/input.ts +0 -168
  42. package/src/mcp.ts +0 -609
  43. package/src/paths.ts +0 -37
  44. package/src/session-message.ts +0 -393
  45. package/src/settings.ts +0 -449
  46. package/src/skills.ts +0 -271
  47. package/src/submit.ts +0 -371
  48. package/src/text.ts +0 -71
  49. package/src/theme.ts +0 -330
  50. package/src/tool-common.ts +0 -93
  51. package/src/tool-grep.ts +0 -606
  52. package/src/tool-read.ts +0 -313
  53. package/src/tool-shell.ts +0 -1001
  54. package/src/tools.ts +0 -854
  55. package/src/ui/agent.ts +0 -317
  56. package/src/ui/commands.test.ts +0 -913
  57. package/src/ui/commands.ts +0 -834
  58. package/src/ui/conversation.test.ts +0 -585
  59. package/src/ui/conversation.ts +0 -1836
  60. package/src/ui/help.ts +0 -158
  61. package/src/ui/input.test.ts +0 -64
  62. package/src/ui/input.ts +0 -138
  63. package/src/ui/overlay.ts +0 -59
  64. package/src/ui/runtime.ts +0 -69
  65. package/src/ui/status.ts +0 -220
  66. package/src/ui.ts +0 -1190
  67. package/src/version.ts +0 -48
package/src/session.ts CHANGED
@@ -1,990 +1,86 @@
1
- /**
2
- * Session persistence layer.
3
- *
4
- * Stores sessions and their message histories in a single SQLite database
5
- * via `bun:sqlite`. Messages are stored as JSON-serialized pi-ai {@link Message}
6
- * objects grouped by turn number. Cumulative token/cost stats are computed
7
- * from the message history rather than stored separately.
8
- *
9
- * @module
10
- */
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Message } from "@mariozechner/pi-ai";
4
+ import { Value } from "typebox/value";
5
+ import { SESSIONS_DIR } from "./shared";
6
+ import { type Session, SessionSchema } from "./types";
11
7
 
12
- import { Database } from "bun:sqlite";
13
- import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
14
- import {
15
- getAssistantUsage,
16
- isUiMessage,
17
- type PersistedMessage,
18
- parsePersistedMessage,
19
- readFirstUserPreview,
20
- type UiInfoFormat,
21
- type UiInfoMessage,
22
- type UiMessage,
23
- type UiTodoMessage,
24
- } from "./session-message.ts";
25
- import type { TodoItem } from "./tools.ts";
8
+ // TODO: sessions are json files in SESSIONS_DIR inside of DATA_DIR, use a 10 length `secureRandomString()` for the ids.
26
9
 
27
- export type {
28
- PersistedMessage,
29
- UiInfoFormat,
30
- UiInfoMessage,
31
- UiMessage,
32
- UiTodoMessage,
33
- };
34
- export { getAssistantUsage };
35
-
36
- // ---------------------------------------------------------------------------
37
- // Types
38
- // ---------------------------------------------------------------------------
39
-
40
- /**
41
- * A persisted session record.
42
- *
43
- * Represents a single conversation scoped to a working directory.
44
- * The `model` and `effort` fields reflect the values at session creation —
45
- * the user may switch models mid-session via `/model`, but the session
46
- * record is not updated (individual assistant messages carry their own model).
47
- */
48
- export interface Session {
49
- /** Unique session identifier (UUID). */
50
- id: string;
51
- /** Working directory the session is scoped to. */
52
- cwd: string;
53
- /** Provider/model string at creation time, e.g. `"anthropic/claude-sonnet-4-20250514"`. */
54
- model: string | null;
55
- /** Thinking effort level at creation time. */
56
- effort: string | null;
57
- /** ID of the session this was forked from, or `null` if original. */
58
- forkedFrom: string | null;
59
- /** Unix timestamp in milliseconds when the session was created. */
60
- createdAt: number;
61
- /** Unix timestamp in milliseconds, updated on each new message. */
62
- updatedAt: number;
63
- }
64
-
65
- /** Session row used by the `/session` picker. */
66
- export interface SessionListEntry extends Session {
67
- /** First conversational user message collapsed into a single-line preview, or `null` when none exists. */
68
- firstUserPreview: string | null;
69
- }
70
-
71
- /**
72
- * Cumulative input/output token and cost statistics for a session.
73
- *
74
- * Computed by summing `usage` fields from all {@link AssistantMessage}s
75
- * in the session's history. Not stored — derived on load and maintained
76
- * in-memory during the session. These feed the status bar's cumulative
77
- * `in`, `out`, and `$cost` values; current context usage is estimated
78
- * separately from the current model-visible history.
79
- */
80
- export interface SessionStats {
81
- /** Total input tokens across all assistant messages. */
82
- totalInput: number;
83
- /** Total output tokens across all assistant messages. */
84
- totalOutput: number;
85
- /** Total cost in dollars across all assistant messages. */
86
- totalCost: number;
87
- }
88
-
89
- /** A raw submitted prompt stored for global input-history search. */
90
- interface PromptHistoryEntry {
91
- /** Monotonic row id. */
92
- id: number;
93
- /** Exact raw prompt text as submitted by the user. */
94
- text: string;
95
- /** Working directory where the prompt was submitted. */
96
- cwd: string;
97
- /** Originating session id when available. */
98
- sessionId: string | null;
99
- /** Unix timestamp in milliseconds when the prompt was submitted. */
100
- createdAt: number;
101
- }
102
-
103
- /** Options for appending a raw prompt-history entry. */
104
- interface AppendPromptHistoryOpts {
105
- /** Exact raw prompt text as submitted by the user. */
106
- text: string;
107
- /** Working directory where the prompt was submitted. */
108
- cwd: string;
109
- /** Originating session id when available. */
110
- sessionId?: string;
111
- }
112
-
113
- /** Options for creating a new session. */
114
- interface CreateSessionOpts {
115
- /** Working directory to scope the session to. */
116
- cwd: string;
117
- /** Provider/model identifier, e.g. `"anthropic/claude-sonnet-4-20250514"`. */
118
- model?: string;
119
- /** Thinking effort level, e.g. `"medium"`. */
120
- effort?: string;
121
- }
122
-
123
- // ---------------------------------------------------------------------------
124
- // Internal row types (map directly to SQLite column names)
125
- // ---------------------------------------------------------------------------
126
-
127
- /** Row shape returned by `SELECT * FROM sessions`. */
128
- type SessionRow = {
129
- id: string;
130
- cwd: string;
131
- model: string | null;
132
- effort: string | null;
133
- forked_from: string | null;
134
- created_at: number;
135
- updated_at: number;
136
- };
137
-
138
- /** Row shape returned by the `/session` picker query. */
139
- type SessionListRow = SessionRow & {
140
- first_user_message_data: string | null;
141
- };
142
-
143
- /** Row shape for `SELECT MAX(turn)` queries. */
144
- type MaxTurnRow = { max_turn: number | null };
145
-
146
- /** Row shape for `SELECT data` queries. */
147
- type DataRow = { data: string };
148
-
149
- const SQLITE_BUSY_TIMEOUT_MS = 1_000;
150
-
151
- /** Row shape returned by `SELECT * FROM prompt_history`. */
152
- type PromptHistoryRow = {
153
- id: number;
154
- text: string;
155
- cwd: string;
156
- session_id: string | null;
157
- created_at: number;
158
- };
159
-
160
- // ---------------------------------------------------------------------------
161
- // SQL
162
- // ---------------------------------------------------------------------------
163
-
164
- const SQL = {
165
- listSessions: `
166
- SELECT
167
- sessions.*,
168
- (
169
- SELECT data
170
- FROM messages
171
- WHERE session_id = sessions.id AND turn IS NOT NULL
172
- ORDER BY id
173
- LIMIT 1
174
- ) AS first_user_message_data
175
- FROM sessions
176
- WHERE cwd = ?
177
- ORDER BY updated_at DESC, rowid DESC
178
- `,
179
- maxTurn: "SELECT MAX(turn) as max_turn FROM messages WHERE session_id = ?",
180
- loadMessages: "SELECT data FROM messages WHERE session_id = ? ORDER BY id",
181
- listPromptHistory:
182
- "SELECT * FROM prompt_history ORDER BY created_at DESC, id DESC LIMIT ?",
183
- } as const;
184
-
185
- const SCHEMA = `
186
- CREATE TABLE IF NOT EXISTS sessions (
187
- id TEXT PRIMARY KEY,
188
- cwd TEXT NOT NULL,
189
- model TEXT,
190
- effort TEXT,
191
- forked_from TEXT,
192
- created_at INTEGER NOT NULL,
193
- updated_at INTEGER NOT NULL
194
- );
195
-
196
- CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
197
-
198
- CREATE TABLE IF NOT EXISTS messages (
199
- id INTEGER PRIMARY KEY AUTOINCREMENT,
200
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
201
- turn INTEGER,
202
- data TEXT NOT NULL,
203
- created_at INTEGER NOT NULL
204
- );
205
-
206
- CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, turn);
207
-
208
- CREATE TABLE IF NOT EXISTS prompt_history (
209
- id INTEGER PRIMARY KEY AUTOINCREMENT,
210
- text TEXT NOT NULL,
211
- cwd TEXT NOT NULL,
212
- session_id TEXT,
213
- created_at INTEGER NOT NULL
214
- );
215
-
216
- CREATE INDEX IF NOT EXISTS idx_prompt_history_created_at ON prompt_history(created_at, id);
217
- `;
218
-
219
- // ---------------------------------------------------------------------------
220
- // Database
221
- // ---------------------------------------------------------------------------
222
-
223
- /**
224
- * Open (or create) the SQLite database and ensure the schema exists.
225
- *
226
- * Enables WAL journal mode for concurrent read performance and foreign
227
- * keys for cascade deletes. Pass `":memory:"` for an in-memory database
228
- * (useful in tests).
229
- *
230
- * @param path - File path for the database, or `":memory:"` for in-memory.
231
- * @returns An open {@link Database} handle. The caller is responsible for
232
- * closing it when done.
233
- *
234
- * @example
235
- * ```ts
236
- * const db = openDatabase("~/.config/mini-coder/mini-coder.db");
237
- * // ... use db ...
238
- * db.close();
239
- * ```
240
- */
241
- export function openDatabase(path: string): Database {
242
- const db = new Database(path);
243
- db.run("PRAGMA journal_mode = WAL");
244
- db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
245
- db.run("PRAGMA foreign_keys = ON");
246
- db.exec(SCHEMA);
247
- return db;
10
+ export async function ensureSessionsDir(): Promise<void> {
11
+ await mkdir(SESSIONS_DIR, { recursive: true });
248
12
  }
249
13
 
250
- // ---------------------------------------------------------------------------
251
- // Session CRUD
252
- // ---------------------------------------------------------------------------
253
-
254
- function generateId(): string {
255
- return crypto.randomUUID();
256
- }
14
+ // readSession: writes the session
15
+ export async function getSession(id: string): Promise<Session | undefined> {
16
+ const file = Bun.file(join(SESSIONS_DIR, `${id}.json`));
257
17
 
258
- /**
259
- * Create a new session record.
260
- *
261
- * @param db - Open database handle.
262
- * @param opts - Session options (cwd is required; model and effort are optional).
263
- * @returns The newly created {@link Session}.
264
- */
265
- export function createSession(db: Database, opts: CreateSessionOpts): Session {
266
- const id = generateId();
267
- const now = Date.now();
268
- db.run(
269
- "INSERT INTO sessions (id, cwd, model, effort, forked_from, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, ?, ?)",
270
- [id, opts.cwd, opts.model ?? null, opts.effort ?? null, now, now],
271
- );
272
- return {
273
- id,
274
- cwd: opts.cwd,
275
- model: opts.model ?? null,
276
- effort: opts.effort ?? null,
277
- forkedFrom: null,
278
- createdAt: now,
279
- updatedAt: now,
280
- };
281
- }
282
-
283
- /**
284
- * Retrieve a session by its ID.
285
- *
286
- * @param db - Open database handle.
287
- * @param id - The session UUID.
288
- * @returns The {@link Session}, or `null` if not found.
289
- */
290
- export function getSession(db: Database, id: string): Session | null {
291
- const row = db
292
- .query<SessionRow, [string]>("SELECT * FROM sessions WHERE id = ?")
293
- .get(id);
294
- if (!row) return null;
295
- return {
296
- id: row.id,
297
- cwd: row.cwd,
298
- model: row.model,
299
- effort: row.effort,
300
- forkedFrom: row.forked_from,
301
- createdAt: row.created_at,
302
- updatedAt: row.updated_at,
303
- };
304
- }
305
-
306
- /**
307
- * List sessions for a working directory, most recently updated first.
308
- *
309
- * @param db - Open database handle.
310
- * @param cwd - Working directory to filter by.
311
- * @returns Session rows ordered by `updatedAt` descending, enriched with the first-user preview.
312
- */
313
- export function listSessions(db: Database, cwd: string): SessionListEntry[] {
314
- const rows = db.query<SessionListRow, [string]>(SQL.listSessions).all(cwd);
315
- return rows.map((row) => ({
316
- id: row.id,
317
- cwd: row.cwd,
318
- model: row.model,
319
- effort: row.effort,
320
- forkedFrom: row.forked_from,
321
- createdAt: row.created_at,
322
- updatedAt: row.updated_at,
323
- firstUserPreview: readFirstUserPreview(row.first_user_message_data),
324
- }));
325
- }
18
+ if (!(await file.exists())) {
19
+ return;
20
+ }
326
21
 
327
- /**
328
- * Delete a session and all its messages (via foreign key cascade).
329
- *
330
- * @param db - Open database handle.
331
- * @param id - The session UUID to delete.
332
- */
333
- export function deleteSession(db: Database, id: string): void {
334
- db.run("DELETE FROM sessions WHERE id = ?", [id]);
335
- }
22
+ const sessionJson = await file.text();
23
+ const parsed = JSON.parse(sessionJson) as unknown;
24
+ const valid = Value.Check(SessionSchema, parsed);
336
25
 
337
- /**
338
- * Keep only the most recent sessions for a CWD, deleting the rest.
339
- *
340
- * Sessions are ordered by `updated_at DESC`; those beyond `keep` are
341
- * deleted (cascade removes their messages too). No-op if the count is
342
- * already within the limit.
343
- *
344
- * @param db - Open database handle.
345
- * @param cwd - Working directory to scope the truncation to.
346
- * @param keep - Maximum number of sessions to retain.
347
- */
348
- export function truncateSessions(
349
- db: Database,
350
- cwd: string,
351
- keep: number,
352
- ): void {
353
- db.run(
354
- `DELETE FROM sessions WHERE id IN (
355
- SELECT id FROM sessions WHERE cwd = ?
356
- ORDER BY updated_at DESC, rowid DESC
357
- LIMIT -1 OFFSET ?
358
- )`,
359
- [cwd, keep],
360
- );
26
+ if (valid) return parsed;
27
+ return;
361
28
  }
362
29
 
363
- // ---------------------------------------------------------------------------
364
- // Messages
365
- // ---------------------------------------------------------------------------
366
-
367
- /**
368
- * Create a persisted UI info message.
369
- *
370
- * @param content - Display text shown in the conversation log.
371
- * @param format - Optional rich-text format hint for the content.
372
- * @returns A new {@link UiInfoMessage}.
373
- */
374
- export function createUiMessage(
375
- content: string,
376
- format?: UiInfoFormat,
377
- ): UiInfoMessage {
378
- return {
379
- role: "ui",
380
- kind: "info",
381
- content,
382
- ...(format ? { format } : {}),
383
- timestamp: Date.now(),
384
- };
30
+ function latestMessageTimestamp(session: Session): number {
31
+ return Math.max(0, ...session.messages.map((message) => message.timestamp));
385
32
  }
386
33
 
387
- /**
388
- * Create a persisted UI todo snapshot message.
389
- *
390
- * @param todos - Todo snapshot rendered in the conversation log.
391
- * @returns A new {@link UiTodoMessage}.
392
- */
393
- export function createUiTodoMessage(todos: readonly TodoItem[]): UiTodoMessage {
394
- return {
395
- role: "ui",
396
- kind: "todo",
397
- todos: todos.map((todo) => ({ ...todo })),
398
- timestamp: Date.now(),
399
- };
400
- }
34
+ export async function listSessionsForCwd(): Promise<Session[]> {
35
+ const sessions: Session[] = [];
36
+ const sessionFiles = new Bun.Glob("*.json");
37
+ const cwd = process.cwd();
401
38
 
402
- /**
403
- * Filter persisted session history down to model-visible pi-ai messages.
404
- *
405
- * @param messages - Persisted session history.
406
- * @returns Only the pi-ai {@link Message} entries.
407
- */
408
- export function filterModelMessages(
409
- messages: readonly PersistedMessage[],
410
- ): Message[] {
411
- return messages.filter(
412
- (message): message is Message => !isUiMessage(message),
413
- );
414
- }
415
-
416
- function runInImmediateTransaction<T>(db: Database, callback: () => T): T {
417
- if (db.inTransaction) {
418
- return callback();
419
- }
420
-
421
- db.run("BEGIN IMMEDIATE");
422
39
  try {
423
- const result = callback();
424
- db.run("COMMIT");
425
- return result;
426
- } catch (error) {
427
- if (db.inTransaction) {
40
+ for await (const entry of sessionFiles.scan({
41
+ cwd: SESSIONS_DIR,
42
+ dot: true,
43
+ })) {
428
44
  try {
429
- db.run("ROLLBACK");
430
- } catch (rollbackError) {
431
- throw new AggregateError(
432
- [error, rollbackError],
433
- "Failed to roll back SQLite transaction",
434
- );
45
+ const sessionJson = await Bun.file(join(SESSIONS_DIR, entry)).text();
46
+ const parsed = JSON.parse(sessionJson) as unknown;
47
+
48
+ if (Value.Check(SessionSchema, parsed) && cwd === parsed.cwd) {
49
+ sessions.push(parsed);
50
+ }
51
+ } catch {
52
+ // Ignore invalid session files.
435
53
  }
436
54
  }
437
- throw error;
438
- }
439
- }
440
-
441
- /**
442
- * Append a UI-only message to a session's history.
443
- *
444
- * UI messages are persisted with `turn = NULL` so they remain visible in
445
- * history without participating in conversational turn numbering or `/undo`.
446
- *
447
- * @param db - Open database handle.
448
- * @param sessionId - The session to append to.
449
- * @param message - The UI-only message to persist.
450
- * @param turn - Ignored for UI messages.
451
- * @returns `null`, since UI messages do not belong to conversational turns.
452
- */
453
- export function appendMessage(
454
- db: Database,
455
- sessionId: string,
456
- message: UiMessage,
457
- turn?: number,
458
- ): null;
459
-
460
- /**
461
- * Append a conversational message to a session's history.
462
- *
463
- * Turn numbering rules:
464
- * - When `turn` is **omitted**, a new turn is started with `MAX(turn) + 1`
465
- * (or `1` for the first message). This is used for user messages.
466
- * - When `turn` is **provided**, the message joins that existing turn.
467
- * This is used for assistant and tool-result messages that belong to
468
- * the same agent loop as the initiating user message.
469
- *
470
- * Also updates the session's `updatedAt` timestamp.
471
- *
472
- * @param db - Open database handle.
473
- * @param sessionId - The session to append to.
474
- * @param message - A model-visible pi-ai message.
475
- * @param turn - Explicit turn number to join. Omit to start a new turn.
476
- * @returns The conversational turn number the message was stored with.
477
- */
478
- export function appendMessage(
479
- db: Database,
480
- sessionId: string,
481
- message: Message,
482
- turn?: number,
483
- ): number;
484
-
485
- /**
486
- * Append a persisted message to a session's history.
487
- *
488
- * UI messages always store `turn = NULL`. Conversational messages either start
489
- * a new turn or join an existing one, depending on `turn`.
490
- *
491
- * @param db - Open database handle.
492
- * @param sessionId - The session to append to.
493
- * @param message - Persisted message to store.
494
- * @param turn - Explicit conversational turn to join.
495
- * @returns The assigned conversational turn number, or `null` for UI messages.
496
- */
497
- export function appendMessage(
498
- db: Database,
499
- sessionId: string,
500
- message: PersistedMessage,
501
- turn?: number,
502
- ): number | null;
503
-
504
- /**
505
- * Append a persisted message to a session's history.
506
- *
507
- * UI messages always store `turn = NULL`. Conversational messages either start
508
- * a new turn or join an existing one, depending on `turn`.
509
- *
510
- * @param db - Open database handle.
511
- * @param sessionId - The session to append to.
512
- * @param message - Persisted message to store.
513
- * @param turn - Explicit conversational turn to join.
514
- * @returns The assigned conversational turn number, or `null` for UI messages.
515
- */
516
- export function appendMessage(
517
- db: Database,
518
- sessionId: string,
519
- message: PersistedMessage,
520
- turn?: number,
521
- ): number | null {
522
- return runInImmediateTransaction(db, () => {
523
- const now = Date.now();
524
-
525
- let effectiveTurn: number | null;
526
- if (isUiMessage(message)) {
527
- effectiveTurn = null;
528
- } else if (turn !== undefined) {
529
- effectiveTurn = turn;
530
- } else {
531
- const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
532
- effectiveTurn = (row?.max_turn ?? 0) + 1;
533
- }
534
-
535
- db.run(
536
- "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
537
- [sessionId, effectiveTurn, JSON.stringify(message), now],
538
- );
539
- db.run("UPDATE sessions SET updated_at = ? WHERE id = ?", [now, sessionId]);
540
-
541
- return effectiveTurn;
542
- });
543
- }
544
-
545
- /**
546
- * Load all messages for a session in insertion order.
547
- *
548
- * Messages are deserialized from their JSON representation back into
549
- * persisted app messages. Invalid rows are skipped so corrupted session data
550
- * does not crash the app. The ordering matches the original append order
551
- * (by autoincrement `id`), preserving the conversation flow.
552
- *
553
- * @param db - Open database handle.
554
- * @param sessionId - The session to load messages for.
555
- * @returns An array of {@link PersistedMessage} objects, empty if the session
556
- * has no messages or does not exist.
557
- */
558
- export function loadMessages(
559
- db: Database,
560
- sessionId: string,
561
- ): PersistedMessage[] {
562
- const rows = db.query<DataRow, [string]>(SQL.loadMessages).all(sessionId);
563
- const messages: PersistedMessage[] = [];
564
-
565
- for (const row of rows) {
566
- const message = parsePersistedMessage(row.data);
567
- if (message) {
568
- messages.push(message);
569
- }
570
- }
571
-
572
- return messages;
573
- }
574
-
575
- // ---------------------------------------------------------------------------
576
- // Prompt history
577
- // ---------------------------------------------------------------------------
578
-
579
- /**
580
- * Append a raw submitted prompt to the global prompt-history table.
581
- *
582
- * This history is separate from conversational turn state: it is global,
583
- * append-only, and not affected by `/undo`.
584
- *
585
- * @param db - Open database handle.
586
- * @param opts - Prompt-history fields to persist.
587
- * @returns The stored {@link PromptHistoryEntry}.
588
- */
589
- export function appendPromptHistory(
590
- db: Database,
591
- opts: AppendPromptHistoryOpts,
592
- ): PromptHistoryEntry {
593
- const now = Date.now();
594
- const result = db.run(
595
- "INSERT INTO prompt_history (text, cwd, session_id, created_at) VALUES (?, ?, ?, ?)",
596
- [opts.text, opts.cwd, opts.sessionId ?? null, now],
597
- );
598
-
599
- return {
600
- id: Number(result.lastInsertRowid),
601
- text: opts.text,
602
- cwd: opts.cwd,
603
- sessionId: opts.sessionId ?? null,
604
- createdAt: now,
605
- };
606
- }
607
-
608
- /**
609
- * List raw submitted prompts newest first.
610
- *
611
- * @param db - Open database handle.
612
- * @param limit - Maximum number of entries to return.
613
- * @returns Prompt-history entries ordered newest first.
614
- */
615
- export function listPromptHistory(
616
- db: Database,
617
- limit = Number.MAX_SAFE_INTEGER,
618
- ): PromptHistoryEntry[] {
619
- const rows = db
620
- .query<PromptHistoryRow, [number]>(SQL.listPromptHistory)
621
- .all(limit);
622
- return rows.map((row) => ({
623
- id: row.id,
624
- text: row.text,
625
- cwd: row.cwd,
626
- sessionId: row.session_id,
627
- createdAt: row.created_at,
628
- }));
629
- }
630
-
631
- /**
632
- * Keep only the newest prompt-history rows.
633
- *
634
- * @param db - Open database handle.
635
- * @param keep - Maximum number of prompt-history rows to retain.
636
- */
637
- export function truncatePromptHistory(db: Database, keep: number): void {
638
- db.run(
639
- `DELETE FROM prompt_history WHERE id IN (
640
- SELECT id FROM prompt_history
641
- ORDER BY created_at DESC, id DESC
642
- LIMIT -1 OFFSET ?
643
- )`,
644
- [keep],
645
- );
646
- }
647
-
648
- // ---------------------------------------------------------------------------
649
- // Undo
650
- // ---------------------------------------------------------------------------
651
-
652
- /**
653
- * Remove the last turn from a session's history.
654
- *
655
- * Deletes **all** messages with the highest turn number — the user message
656
- * and every assistant/tool-result message that followed in the same agent
657
- * loop. This is a context-only operation; filesystem changes are not reverted.
658
- *
659
- * @param db - Open database handle.
660
- * @param sessionId - The session to undo in.
661
- * @returns `true` if a turn was removed, `false` if the session had no messages.
662
- */
663
- export function undoLastTurn(db: Database, sessionId: string): boolean {
664
- const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
665
- if (!row?.max_turn) return false;
666
-
667
- db.run("DELETE FROM messages WHERE session_id = ? AND turn = ?", [
668
- sessionId,
669
- row.max_turn,
670
- ]);
671
- return true;
672
- }
673
-
674
- // ---------------------------------------------------------------------------
675
- // Fork
676
- // ---------------------------------------------------------------------------
677
-
678
- /**
679
- * Fork a session into a new independent copy.
680
- *
681
- * Creates a new session with the same `cwd`, `model`, and `effort` as the
682
- * source, then copies all messages preserving their turn numbers. The new
683
- * session's `forkedFrom` field points back to the source. The original
684
- * session is not modified.
685
- *
686
- * @param db - Open database handle.
687
- * @param sourceId - The session to fork from.
688
- * @returns The newly created {@link Session}.
689
- * @throws If the source session does not exist.
690
- */
691
- export function forkSession(db: Database, sourceId: string): Session {
692
- const source = getSession(db, sourceId);
693
- if (!source) throw new Error(`Session not found: ${sourceId}`);
694
-
695
- const id = generateId();
696
- const now = Date.now();
697
-
698
- db.run(
699
- "INSERT INTO sessions (id, cwd, model, effort, forked_from, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
700
- [id, source.cwd, source.model, source.effort, sourceId, now, now],
701
- );
702
-
703
- db.run(
704
- "INSERT INTO messages (session_id, turn, data, created_at) SELECT ?, turn, data, created_at FROM messages WHERE session_id = ? ORDER BY id",
705
- [id, sourceId],
706
- );
707
-
708
- return {
709
- id,
710
- cwd: source.cwd,
711
- model: source.model,
712
- effort: source.effort,
713
- forkedFrom: sourceId,
714
- createdAt: now,
715
- updatedAt: now,
716
- };
717
- }
718
-
719
- // ---------------------------------------------------------------------------
720
- // Stats
721
- // ---------------------------------------------------------------------------
722
-
723
- /**
724
- * Add one persisted message's assistant usage to cumulative session stats.
725
- *
726
- * Non-assistant messages and assistant messages without valid `usage` are
727
- * ignored and return the original totals unchanged.
728
- *
729
- * @param stats - Running cumulative session totals.
730
- * @param message - Persisted message to fold into the totals.
731
- * @returns Updated cumulative session stats.
732
- */
733
- export function addMessageToStats(
734
- stats: SessionStats,
735
- message: PersistedMessage,
736
- ): SessionStats {
737
- const usage = getAssistantUsage(message);
738
- if (!usage) {
739
- return stats;
55
+ } catch {
56
+ return [];
740
57
  }
741
58
 
742
- return {
743
- totalInput: stats.totalInput + usage.input,
744
- totalOutput: stats.totalOutput + usage.output,
745
- totalCost: stats.totalCost + usage.cost.total,
746
- };
747
- }
748
-
749
- /** Create a zeroed cumulative session-stats object. */
750
- export function createEmptySessionStats(): SessionStats {
751
- return {
752
- totalInput: 0,
753
- totalOutput: 0,
754
- totalCost: 0,
755
- };
756
- }
757
-
758
- /**
759
- * Compute cumulative token and cost statistics from a message history.
760
- *
761
- * @param messages - Full persisted session history.
762
- * @returns Aggregated cumulative session stats.
763
- */
764
- export function computeStats(
765
- messages: readonly PersistedMessage[],
766
- ): SessionStats {
767
- let stats = createEmptySessionStats();
768
-
769
- for (const message of messages) {
770
- stats = addMessageToStats(stats, message);
771
- }
772
-
773
- return stats;
774
- }
775
-
776
- // ---------------------------------------------------------------------------
777
- // Context estimation
778
- // ---------------------------------------------------------------------------
779
-
780
- /** Conservative fixed estimate for an image block's token footprint. */
781
- const ESTIMATED_IMAGE_TOKENS = 1_200;
782
-
783
- /** Calculate context tokens from assistant usage, falling back when `totalTokens` is zero. */
784
- function calculateUsageTokens(usage: AssistantMessage["usage"]): number {
785
- return (
786
- usage.totalTokens ||
787
- usage.input + usage.output + usage.cacheRead + usage.cacheWrite
59
+ return sessions.sort(
60
+ (a, b) => latestMessageTimestamp(b) - latestMessageTimestamp(a),
788
61
  );
789
62
  }
790
63
 
791
- /** Estimate token usage from a character count using a conservative chars/4 heuristic. */
792
- function estimateCharacterTokens(charCount: number): number {
793
- return Math.ceil(charCount / 4);
64
+ export async function saveSession(s: Session) {
65
+ await ensureSessionsDir();
66
+ const file = Bun.file(join(SESSIONS_DIR, `${s.id}.json`));
67
+ await Bun.write(file, JSON.stringify(s));
794
68
  }
795
69
 
796
- type UserMultipartContent = Exclude<
797
- Extract<Message, { role: "user" }>["content"],
798
- string
799
- >;
800
- type TextOrImageContentBlock =
801
- | UserMultipartContent[number]
802
- | Extract<Message, { role: "toolResult" }>["content"][number];
803
-
804
- function estimateTextOrImageContentTokens(
805
- content: readonly TextOrImageContentBlock[],
806
- ): number {
807
- let chars = 0;
808
- let imageTokens = 0;
809
-
810
- for (const block of content) {
811
- if (block.type === "text") {
812
- chars += block.text.length;
813
- continue;
814
- }
815
- if (block.type === "image") {
816
- imageTokens += ESTIMATED_IMAGE_TOKENS;
817
- }
818
- }
819
-
820
- return estimateCharacterTokens(chars) + imageTokens;
821
- }
822
-
823
- function estimateUserMessageTokens(
824
- message: Extract<Message, { role: "user" }>,
825
- ): number {
826
- if (typeof message.content === "string") {
827
- return estimateCharacterTokens(message.content.length);
70
+ // updateSession: finds and appends to the existing setting file
71
+ export async function updateSession(id: string, messages: Message[]) {
72
+ const existing = await getSession(id);
73
+ if (existing) {
74
+ existing.messages = messages;
75
+ await saveSession(existing);
76
+ return;
828
77
  }
829
- return estimateTextOrImageContentTokens(message.content);
830
- }
831
78
 
832
- function estimateAssistantBlockCharacters(
833
- block: Extract<Message, { role: "assistant" }>["content"][number],
834
- ): number {
835
- if (block.type === "text") {
836
- return block.text.length;
837
- }
838
- if (block.type === "thinking") {
839
- return block.thinking.length;
840
- }
841
- return block.name.length + JSON.stringify(block.arguments).length;
842
- }
843
-
844
- function estimateAssistantMessageTokens(
845
- message: Extract<Message, { role: "assistant" }>,
846
- ): number {
847
- const chars = message.content.reduce((total, block) => {
848
- return total + estimateAssistantBlockCharacters(block);
849
- }, 0);
850
- return estimateCharacterTokens(chars);
851
- }
852
-
853
- function estimateToolResultMessageTokens(
854
- message: Extract<Message, { role: "toolResult" }>,
855
- ): number {
856
- return estimateTextOrImageContentTokens(message.content);
857
- }
858
-
859
- /** Estimate token usage for a model-visible message. */
860
- function estimateMessageTokens(message: Message): number {
861
- switch (message.role) {
862
- case "user":
863
- return estimateUserMessageTokens(message);
864
- case "assistant":
865
- return estimateAssistantMessageTokens(message);
866
- case "toolResult":
867
- return estimateToolResultMessageTokens(message);
868
- }
869
- }
870
-
871
- /**
872
- * Fold one persisted message into the running context estimate for the next request.
873
- *
874
- * Assistant messages with valid usage anchor the full model-visible context for
875
- * that point in the transcript, so they replace the running estimate. All other
876
- * model-visible messages are added incrementally using the same conservative
877
- * estimation logic used before the first valid assistant usage appears.
878
- *
879
- * @param contextTokens - Running estimate before this message.
880
- * @param message - Persisted message to fold into the estimate.
881
- * @returns Updated context-token estimate.
882
- */
883
- export function addMessageToContextTokens(
884
- contextTokens: number,
885
- message: PersistedMessage,
886
- ): number {
887
- if (message.role === "ui") {
888
- return contextTokens;
889
- }
890
-
891
- const usage = getAssistantUsage(message);
892
- if (
893
- message.role === "assistant" &&
894
- usage &&
895
- message.stopReason !== "aborted" &&
896
- message.stopReason !== "error"
897
- ) {
898
- return calculateUsageTokens(usage);
899
- }
900
-
901
- return contextTokens + estimateMessageTokens(message);
902
- }
903
-
904
- /**
905
- * Estimate the current model-visible context size for the next request.
906
- *
907
- * Recomputed on session-load boundaries and maintained incrementally during an
908
- * active turn so render-time status-bar updates do not need to rescan the full
909
- * message history.
910
- *
911
- * @param messages - Full persisted session history.
912
- * @returns Estimated context tokens visible to the next model request.
913
- */
914
- export function computeContextTokens(
915
- messages: readonly PersistedMessage[],
916
- ): number {
917
- let contextTokens = 0;
918
-
919
- for (const message of messages) {
920
- contextTokens = addMessageToContextTokens(contextTokens, message);
921
- }
922
-
923
- return contextTokens;
924
- }
925
-
926
- interface MutableConversationState {
927
- messages: PersistedMessage[];
928
- stats: SessionStats;
929
- contextTokens: number;
930
- }
931
-
932
- /**
933
- * Derive the in-memory conversation snapshot for a persisted message history.
934
- *
935
- * @param messages - Persisted messages to expose in memory.
936
- * @returns Message history plus derived stats and context-token estimate.
937
- */
938
- export function createConversationSnapshot(messages: PersistedMessage[] = []): {
939
- messages: PersistedMessage[];
940
- stats: SessionStats;
941
- contextTokens: number;
942
- } {
943
- return {
79
+ const s = {
80
+ id,
81
+ cwd: process.cwd(),
944
82
  messages,
945
- stats: computeStats(messages),
946
- contextTokens: computeContextTokens(messages),
947
83
  };
948
- }
949
-
950
- /**
951
- * Replace the current in-memory conversation state from a message history.
952
- *
953
- * @param state - Mutable conversation state.
954
- * @param messages - Replacement persisted message history.
955
- */
956
- export function replaceConversationState<T extends MutableConversationState>(
957
- state: T,
958
- messages: PersistedMessage[],
959
- ): void {
960
- const snapshot = createConversationSnapshot(messages);
961
- state.messages = snapshot.messages;
962
- state.stats = snapshot.stats;
963
- state.contextTokens = snapshot.contextTokens;
964
- }
965
-
966
- /**
967
- * Clear the current in-memory conversation state.
968
- *
969
- * @param state - Mutable conversation state.
970
- */
971
- export function clearConversationState<T extends MutableConversationState>(
972
- state: T,
973
- ): void {
974
- replaceConversationState(state, []);
975
- }
976
84
 
977
- /**
978
- * Append one persisted message to the in-memory conversation state.
979
- *
980
- * @param state - Mutable conversation state.
981
- * @param message - Message to append.
982
- */
983
- export function appendConversationMessage<T extends MutableConversationState>(
984
- state: T,
985
- message: PersistedMessage,
986
- ): void {
987
- state.messages.push(message);
988
- state.stats = addMessageToStats(state.stats, message);
989
- state.contextTokens = addMessageToContextTokens(state.contextTokens, message);
85
+ await saveSession(s);
990
86
  }