mini-coder 0.4.0 → 0.5.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 (51) hide show
  1. package/README.md +87 -48
  2. package/assets/icon-1-minimal.svg +31 -0
  3. package/assets/icon-2-dark-terminal.svg +48 -0
  4. package/assets/icon-3-gradient-modern.svg +45 -0
  5. package/assets/icon-4-filled-bold.svg +54 -0
  6. package/assets/icon-5-community-badge.svg +63 -0
  7. package/assets/preview-0-5-0.png +0 -0
  8. package/assets/preview.gif +0 -0
  9. package/bin/mc.ts +14 -0
  10. package/bun.lock +438 -0
  11. package/package.json +12 -29
  12. package/src/agent.ts +592 -0
  13. package/src/cli.ts +124 -0
  14. package/src/git.ts +164 -0
  15. package/src/headless.ts +140 -0
  16. package/src/index.ts +645 -0
  17. package/src/input.ts +155 -0
  18. package/src/paths.ts +37 -0
  19. package/src/plugins.ts +183 -0
  20. package/src/prompt.ts +294 -0
  21. package/src/session.ts +838 -0
  22. package/src/settings.ts +184 -0
  23. package/src/skills.ts +258 -0
  24. package/src/submit.ts +323 -0
  25. package/src/theme.ts +147 -0
  26. package/src/tools.ts +636 -0
  27. package/src/ui/agent.test.ts +49 -0
  28. package/src/ui/agent.ts +210 -0
  29. package/src/ui/commands.test.ts +610 -0
  30. package/src/ui/commands.ts +638 -0
  31. package/src/ui/conversation.test.ts +892 -0
  32. package/src/ui/conversation.ts +926 -0
  33. package/src/ui/help.test.ts +26 -0
  34. package/src/ui/help.ts +119 -0
  35. package/src/ui/input.test.ts +74 -0
  36. package/src/ui/input.ts +138 -0
  37. package/src/ui/overlay.test.ts +42 -0
  38. package/src/ui/overlay.ts +59 -0
  39. package/src/ui/status.test.ts +450 -0
  40. package/src/ui/status.ts +357 -0
  41. package/src/ui.ts +615 -0
  42. package/.claude/settings.local.json +0 -54
  43. package/.prettierignore +0 -7
  44. package/dist/mc-edit.js +0 -275
  45. package/dist/mc.js +0 -7267
  46. package/docs/KNOWN_ISSUES.md +0 -13
  47. package/docs/design-decisions.md +0 -31
  48. package/docs/mini-coder.1.md +0 -227
  49. package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
  50. package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
  51. package/lefthook.yml +0 -4
package/src/session.ts ADDED
@@ -0,0 +1,838 @@
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
+ */
11
+
12
+ import { Database } from "bun:sqlite";
13
+ import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * A persisted session record.
21
+ *
22
+ * Represents a single conversation scoped to a working directory.
23
+ * The `model` and `effort` fields reflect the values at session creation —
24
+ * the user may switch models mid-session via `/model`, but the session
25
+ * record is not updated (individual assistant messages carry their own model).
26
+ */
27
+ export interface Session {
28
+ /** Unique session identifier (UUID). */
29
+ id: string;
30
+ /** Working directory the session is scoped to. */
31
+ cwd: string;
32
+ /** Provider/model string at creation time, e.g. `"anthropic/claude-sonnet-4-20250514"`. */
33
+ model: string | null;
34
+ /** Thinking effort level at creation time. */
35
+ effort: string | null;
36
+ /** ID of the session this was forked from, or `null` if original. */
37
+ forkedFrom: string | null;
38
+ /** Unix timestamp in milliseconds when the session was created. */
39
+ createdAt: number;
40
+ /** Unix timestamp in milliseconds, updated on each new message. */
41
+ updatedAt: number;
42
+ }
43
+
44
+ /** Session row used by the `/session` picker. */
45
+ export interface SessionListEntry extends Session {
46
+ /** First conversational user message collapsed into a single-line preview, or `null` when none exists. */
47
+ firstUserPreview: string | null;
48
+ }
49
+
50
+ /**
51
+ * Cumulative input/output token and cost statistics for a session.
52
+ *
53
+ * Computed by summing `usage` fields from all {@link AssistantMessage}s
54
+ * in the session's history. Not stored — derived on load and maintained
55
+ * in-memory during the session. These feed the status bar's cumulative
56
+ * `in`, `out`, and `$cost` values; current context usage is estimated
57
+ * separately from the current model-visible history.
58
+ */
59
+ export interface SessionStats {
60
+ /** Total input tokens across all assistant messages. */
61
+ totalInput: number;
62
+ /** Total output tokens across all assistant messages. */
63
+ totalOutput: number;
64
+ /** Total cost in dollars across all assistant messages. */
65
+ totalCost: number;
66
+ }
67
+
68
+ /** A raw submitted prompt stored for global input-history search. */
69
+ interface PromptHistoryEntry {
70
+ /** Monotonic row id. */
71
+ id: number;
72
+ /** Exact raw prompt text as submitted by the user. */
73
+ text: string;
74
+ /** Working directory where the prompt was submitted. */
75
+ cwd: string;
76
+ /** Originating session id when available. */
77
+ sessionId: string | null;
78
+ /** Unix timestamp in milliseconds when the prompt was submitted. */
79
+ createdAt: number;
80
+ }
81
+
82
+ /** Options for appending a raw prompt-history entry. */
83
+ interface AppendPromptHistoryOpts {
84
+ /** Exact raw prompt text as submitted by the user. */
85
+ text: string;
86
+ /** Working directory where the prompt was submitted. */
87
+ cwd: string;
88
+ /** Originating session id when available. */
89
+ sessionId?: string;
90
+ }
91
+
92
+ /** A persisted UI-only message shown in the conversation log. */
93
+ export interface UiMessage {
94
+ /** Identifies this as an internal UI message. */
95
+ role: "ui";
96
+ /** UI message category for rendering and future behavior. */
97
+ kind: "info";
98
+ /** Display text shown in the conversation log. */
99
+ content: string;
100
+ /** Unix timestamp in milliseconds. */
101
+ timestamp: number;
102
+ }
103
+
104
+ /** Any message persisted in session history. */
105
+ type PersistedMessage = Message | UiMessage;
106
+
107
+ /** Options for creating a new session. */
108
+ interface CreateSessionOpts {
109
+ /** Working directory to scope the session to. */
110
+ cwd: string;
111
+ /** Provider/model identifier, e.g. `"anthropic/claude-sonnet-4-20250514"`. */
112
+ model?: string;
113
+ /** Thinking effort level, e.g. `"medium"`. */
114
+ effort?: string;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Internal row types (map directly to SQLite column names)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** Row shape returned by `SELECT * FROM sessions`. */
122
+ type SessionRow = {
123
+ id: string;
124
+ cwd: string;
125
+ model: string | null;
126
+ effort: string | null;
127
+ forked_from: string | null;
128
+ created_at: number;
129
+ updated_at: number;
130
+ };
131
+
132
+ /** Row shape returned by the `/session` picker query. */
133
+ type SessionListRow = SessionRow & {
134
+ first_user_message_data: string | null;
135
+ };
136
+
137
+ /** Row shape for `SELECT MAX(turn)` queries. */
138
+ type MaxTurnRow = { max_turn: number | null };
139
+
140
+ /** Row shape for `SELECT data` queries. */
141
+ type DataRow = { data: string };
142
+
143
+ /** Row shape returned by `SELECT * FROM prompt_history`. */
144
+ type PromptHistoryRow = {
145
+ id: number;
146
+ text: string;
147
+ cwd: string;
148
+ session_id: string | null;
149
+ created_at: number;
150
+ };
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // SQL
154
+ // ---------------------------------------------------------------------------
155
+
156
+ const SQL = {
157
+ listSessions: `
158
+ SELECT
159
+ sessions.*,
160
+ (
161
+ SELECT data
162
+ FROM messages
163
+ WHERE session_id = sessions.id AND turn IS NOT NULL
164
+ ORDER BY id
165
+ LIMIT 1
166
+ ) AS first_user_message_data
167
+ FROM sessions
168
+ WHERE cwd = ?
169
+ ORDER BY updated_at DESC, rowid DESC
170
+ `,
171
+ maxTurn: "SELECT MAX(turn) as max_turn FROM messages WHERE session_id = ?",
172
+ loadMessages: "SELECT data FROM messages WHERE session_id = ? ORDER BY id",
173
+ listPromptHistory:
174
+ "SELECT * FROM prompt_history ORDER BY created_at DESC, id DESC LIMIT ?",
175
+ } as const;
176
+
177
+ const SCHEMA = `
178
+ CREATE TABLE IF NOT EXISTS sessions (
179
+ id TEXT PRIMARY KEY,
180
+ cwd TEXT NOT NULL,
181
+ model TEXT,
182
+ effort TEXT,
183
+ forked_from TEXT,
184
+ created_at INTEGER NOT NULL,
185
+ updated_at INTEGER NOT NULL
186
+ );
187
+
188
+ CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
189
+
190
+ CREATE TABLE IF NOT EXISTS messages (
191
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
192
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
193
+ turn INTEGER,
194
+ data TEXT NOT NULL,
195
+ created_at INTEGER NOT NULL
196
+ );
197
+
198
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, turn);
199
+
200
+ CREATE TABLE IF NOT EXISTS prompt_history (
201
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
202
+ text TEXT NOT NULL,
203
+ cwd TEXT NOT NULL,
204
+ session_id TEXT,
205
+ created_at INTEGER NOT NULL
206
+ );
207
+
208
+ CREATE INDEX IF NOT EXISTS idx_prompt_history_created_at ON prompt_history(created_at, id);
209
+ `;
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Database
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * Open (or create) the SQLite database and ensure the schema exists.
217
+ *
218
+ * Enables WAL journal mode for concurrent read performance and foreign
219
+ * keys for cascade deletes. Pass `":memory:"` for an in-memory database
220
+ * (useful in tests).
221
+ *
222
+ * @param path - File path for the database, or `":memory:"` for in-memory.
223
+ * @returns An open {@link Database} handle. The caller is responsible for
224
+ * closing it when done.
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * const db = openDatabase("~/.config/mini-coder/mini-coder.db");
229
+ * // ... use db ...
230
+ * db.close();
231
+ * ```
232
+ */
233
+ export function openDatabase(path: string): Database {
234
+ const db = new Database(path);
235
+ db.run("PRAGMA journal_mode = WAL");
236
+ db.run("PRAGMA foreign_keys = ON");
237
+ db.exec(SCHEMA);
238
+ return db;
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Session CRUD
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function generateId(): string {
246
+ return crypto.randomUUID();
247
+ }
248
+
249
+ /**
250
+ * Create a new session record.
251
+ *
252
+ * @param db - Open database handle.
253
+ * @param opts - Session options (cwd is required; model and effort are optional).
254
+ * @returns The newly created {@link Session}.
255
+ */
256
+ export function createSession(db: Database, opts: CreateSessionOpts): Session {
257
+ const id = generateId();
258
+ const now = Date.now();
259
+ db.run(
260
+ "INSERT INTO sessions (id, cwd, model, effort, forked_from, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, ?, ?)",
261
+ [id, opts.cwd, opts.model ?? null, opts.effort ?? null, now, now],
262
+ );
263
+ return {
264
+ id,
265
+ cwd: opts.cwd,
266
+ model: opts.model ?? null,
267
+ effort: opts.effort ?? null,
268
+ forkedFrom: null,
269
+ createdAt: now,
270
+ updatedAt: now,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Retrieve a session by its ID.
276
+ *
277
+ * @param db - Open database handle.
278
+ * @param id - The session UUID.
279
+ * @returns The {@link Session}, or `null` if not found.
280
+ */
281
+ export function getSession(db: Database, id: string): Session | null {
282
+ const row = db
283
+ .query<SessionRow, [string]>("SELECT * FROM sessions WHERE id = ?")
284
+ .get(id);
285
+ if (!row) return null;
286
+ return {
287
+ id: row.id,
288
+ cwd: row.cwd,
289
+ model: row.model,
290
+ effort: row.effort,
291
+ forkedFrom: row.forked_from,
292
+ createdAt: row.created_at,
293
+ updatedAt: row.updated_at,
294
+ };
295
+ }
296
+
297
+ /** Collapse preview text into a single readable line. */
298
+ function collapsePreviewText(text: string): string | null {
299
+ const collapsed = text.replace(/\s+/g, " ").trim();
300
+ return collapsed.length > 0 ? collapsed : null;
301
+ }
302
+
303
+ function getMultipartUserPreview(
304
+ content: Extract<Message, { role: "user" }>["content"],
305
+ ): string | null {
306
+ if (typeof content === "string") {
307
+ return collapsePreviewText(content);
308
+ }
309
+
310
+ const text = content
311
+ .filter(
312
+ (block): block is Extract<(typeof content)[number], { type: "text" }> => {
313
+ return block.type === "text";
314
+ },
315
+ )
316
+ .map((block) => block.text)
317
+ .join(" ");
318
+
319
+ return collapsePreviewText(text);
320
+ }
321
+
322
+ /** Read the first-user preview cached by the session-list query. */
323
+ function readFirstUserPreview(messageData: string | null): string | null {
324
+ if (!messageData) {
325
+ return null;
326
+ }
327
+
328
+ const message = JSON.parse(messageData) as PersistedMessage;
329
+ if (message.role !== "user") {
330
+ return null;
331
+ }
332
+
333
+ return getMultipartUserPreview(message.content);
334
+ }
335
+
336
+ /**
337
+ * List sessions for a working directory, most recently updated first.
338
+ *
339
+ * @param db - Open database handle.
340
+ * @param cwd - Working directory to filter by.
341
+ * @returns Session rows ordered by `updatedAt` descending, enriched with the first-user preview.
342
+ */
343
+ export function listSessions(db: Database, cwd: string): SessionListEntry[] {
344
+ const rows = db.query<SessionListRow, [string]>(SQL.listSessions).all(cwd);
345
+ return rows.map((row) => ({
346
+ id: row.id,
347
+ cwd: row.cwd,
348
+ model: row.model,
349
+ effort: row.effort,
350
+ forkedFrom: row.forked_from,
351
+ createdAt: row.created_at,
352
+ updatedAt: row.updated_at,
353
+ firstUserPreview: readFirstUserPreview(row.first_user_message_data),
354
+ }));
355
+ }
356
+
357
+ /**
358
+ * Delete a session and all its messages (via foreign key cascade).
359
+ *
360
+ * @param db - Open database handle.
361
+ * @param id - The session UUID to delete.
362
+ */
363
+ export function deleteSession(db: Database, id: string): void {
364
+ db.run("DELETE FROM sessions WHERE id = ?", [id]);
365
+ }
366
+
367
+ /**
368
+ * Keep only the most recent sessions for a CWD, deleting the rest.
369
+ *
370
+ * Sessions are ordered by `updated_at DESC`; those beyond `keep` are
371
+ * deleted (cascade removes their messages too). No-op if the count is
372
+ * already within the limit.
373
+ *
374
+ * @param db - Open database handle.
375
+ * @param cwd - Working directory to scope the truncation to.
376
+ * @param keep - Maximum number of sessions to retain.
377
+ */
378
+ export function truncateSessions(
379
+ db: Database,
380
+ cwd: string,
381
+ keep: number,
382
+ ): void {
383
+ db.run(
384
+ `DELETE FROM sessions WHERE id IN (
385
+ SELECT id FROM sessions WHERE cwd = ?
386
+ ORDER BY updated_at DESC, rowid DESC
387
+ LIMIT -1 OFFSET ?
388
+ )`,
389
+ [cwd, keep],
390
+ );
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Messages
395
+ // ---------------------------------------------------------------------------
396
+
397
+ /**
398
+ * Create a persisted UI message.
399
+ *
400
+ * @param content - Display text shown in the conversation log.
401
+ * @returns A new {@link UiMessage}.
402
+ */
403
+ export function createUiMessage(content: string): UiMessage {
404
+ return {
405
+ role: "ui",
406
+ kind: "info",
407
+ content,
408
+ timestamp: Date.now(),
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Check whether a persisted message is a UI-only message.
414
+ *
415
+ * @param message - Message to inspect.
416
+ * @returns `true` when the message is a {@link UiMessage}.
417
+ */
418
+ function isUiMessage(message: PersistedMessage): message is UiMessage {
419
+ return message.role === "ui";
420
+ }
421
+
422
+ /**
423
+ * Filter persisted session history down to model-visible pi-ai messages.
424
+ *
425
+ * @param messages - Persisted session history.
426
+ * @returns Only the pi-ai {@link Message} entries.
427
+ */
428
+ export function filterModelMessages(
429
+ messages: readonly PersistedMessage[],
430
+ ): Message[] {
431
+ return messages.filter(
432
+ (message): message is Message => !isUiMessage(message),
433
+ );
434
+ }
435
+
436
+ function toRecord(value: unknown): Record<string, unknown> | null {
437
+ return typeof value === "object" && value !== null
438
+ ? (value as Record<string, unknown>)
439
+ : null;
440
+ }
441
+
442
+ function readFiniteNumber(
443
+ record: Record<string, unknown>,
444
+ key: string,
445
+ ): number | null {
446
+ const value = record[key];
447
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
448
+ }
449
+
450
+ /**
451
+ * Return an assistant message's usage when the persisted shape is valid.
452
+ *
453
+ * Session rows are treated as untrusted at runtime because older builds or
454
+ * external tooling may have stored assistant messages without a `usage`
455
+ * payload. Invalid or missing usage is ignored instead of crashing session
456
+ * loading or stats calculations.
457
+ *
458
+ * @param message - Message to inspect.
459
+ * @returns The assistant usage payload, or `null` when it is missing/invalid.
460
+ */
461
+ export function getAssistantUsage(
462
+ message: PersistedMessage | Message,
463
+ ): AssistantMessage["usage"] | null {
464
+ if (message.role !== "assistant") {
465
+ return null;
466
+ }
467
+
468
+ const messageRecord = toRecord(message);
469
+ const usageRecord = toRecord(messageRecord?.usage);
470
+ const costRecord = toRecord(usageRecord?.cost);
471
+ if (!usageRecord || !costRecord) {
472
+ return null;
473
+ }
474
+
475
+ const input = readFiniteNumber(usageRecord, "input");
476
+ const output = readFiniteNumber(usageRecord, "output");
477
+ const cacheRead = readFiniteNumber(usageRecord, "cacheRead");
478
+ const cacheWrite = readFiniteNumber(usageRecord, "cacheWrite");
479
+ const totalTokens = readFiniteNumber(usageRecord, "totalTokens");
480
+ const costInput = readFiniteNumber(costRecord, "input");
481
+ const costOutput = readFiniteNumber(costRecord, "output");
482
+ const costCacheRead = readFiniteNumber(costRecord, "cacheRead");
483
+ const costCacheWrite = readFiniteNumber(costRecord, "cacheWrite");
484
+ const costTotal = readFiniteNumber(costRecord, "total");
485
+
486
+ if (
487
+ input === null ||
488
+ output === null ||
489
+ cacheRead === null ||
490
+ cacheWrite === null ||
491
+ totalTokens === null ||
492
+ costInput === null ||
493
+ costOutput === null ||
494
+ costCacheRead === null ||
495
+ costCacheWrite === null ||
496
+ costTotal === null
497
+ ) {
498
+ return null;
499
+ }
500
+
501
+ return {
502
+ input,
503
+ output,
504
+ cacheRead,
505
+ cacheWrite,
506
+ totalTokens,
507
+ cost: {
508
+ input: costInput,
509
+ output: costOutput,
510
+ cacheRead: costCacheRead,
511
+ cacheWrite: costCacheWrite,
512
+ total: costTotal,
513
+ },
514
+ };
515
+ }
516
+
517
+ /**
518
+ * Append a UI-only message to a session's history.
519
+ *
520
+ * UI messages are persisted with `turn = NULL` so they remain visible in
521
+ * history without participating in conversational turn numbering or `/undo`.
522
+ *
523
+ * @param db - Open database handle.
524
+ * @param sessionId - The session to append to.
525
+ * @param message - The UI-only message to persist.
526
+ * @param turn - Ignored for UI messages.
527
+ * @returns `null`, since UI messages do not belong to conversational turns.
528
+ */
529
+ export function appendMessage(
530
+ db: Database,
531
+ sessionId: string,
532
+ message: UiMessage,
533
+ turn?: number,
534
+ ): null;
535
+
536
+ /**
537
+ * Append a conversational message to a session's history.
538
+ *
539
+ * Turn numbering rules:
540
+ * - When `turn` is **omitted**, a new turn is started with `MAX(turn) + 1`
541
+ * (or `1` for the first message). This is used for user messages.
542
+ * - When `turn` is **provided**, the message joins that existing turn.
543
+ * This is used for assistant and tool-result messages that belong to
544
+ * the same agent loop as the initiating user message.
545
+ *
546
+ * Also updates the session's `updatedAt` timestamp.
547
+ *
548
+ * @param db - Open database handle.
549
+ * @param sessionId - The session to append to.
550
+ * @param message - A model-visible pi-ai message.
551
+ * @param turn - Explicit turn number to join. Omit to start a new turn.
552
+ * @returns The conversational turn number the message was stored with.
553
+ */
554
+ export function appendMessage(
555
+ db: Database,
556
+ sessionId: string,
557
+ message: Message,
558
+ turn?: number,
559
+ ): number;
560
+
561
+ /**
562
+ * Append a persisted message to a session's history.
563
+ *
564
+ * UI messages always store `turn = NULL`. Conversational messages either start
565
+ * a new turn or join an existing one, depending on `turn`.
566
+ *
567
+ * @param db - Open database handle.
568
+ * @param sessionId - The session to append to.
569
+ * @param message - Persisted message to store.
570
+ * @param turn - Explicit conversational turn to join.
571
+ * @returns The assigned conversational turn number, or `null` for UI messages.
572
+ */
573
+ export function appendMessage(
574
+ db: Database,
575
+ sessionId: string,
576
+ message: PersistedMessage,
577
+ turn?: number,
578
+ ): number | null;
579
+
580
+ /**
581
+ * Append a persisted message to a session's history.
582
+ *
583
+ * UI messages always store `turn = NULL`. Conversational messages either start
584
+ * a new turn or join an existing one, depending on `turn`.
585
+ *
586
+ * @param db - Open database handle.
587
+ * @param sessionId - The session to append to.
588
+ * @param message - Persisted message to store.
589
+ * @param turn - Explicit conversational turn to join.
590
+ * @returns The assigned conversational turn number, or `null` for UI messages.
591
+ */
592
+ export function appendMessage(
593
+ db: Database,
594
+ sessionId: string,
595
+ message: PersistedMessage,
596
+ turn?: number,
597
+ ): number | null {
598
+ const now = Date.now();
599
+
600
+ let effectiveTurn: number | null;
601
+ if (isUiMessage(message)) {
602
+ effectiveTurn = null;
603
+ } else if (turn !== undefined) {
604
+ effectiveTurn = turn;
605
+ } else {
606
+ const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
607
+ effectiveTurn = (row?.max_turn ?? 0) + 1;
608
+ }
609
+
610
+ db.run(
611
+ "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
612
+ [sessionId, effectiveTurn, JSON.stringify(message), now],
613
+ );
614
+ db.run("UPDATE sessions SET updated_at = ? WHERE id = ?", [now, sessionId]);
615
+
616
+ return effectiveTurn;
617
+ }
618
+
619
+ /**
620
+ * Load all messages for a session in insertion order.
621
+ *
622
+ * Messages are deserialized from their JSON representation back into
623
+ * persisted app messages. The ordering matches the original append order
624
+ * (by autoincrement `id`), preserving the conversation flow.
625
+ *
626
+ * @param db - Open database handle.
627
+ * @param sessionId - The session to load messages for.
628
+ * @returns An array of {@link PersistedMessage} objects, empty if the session
629
+ * has no messages or does not exist.
630
+ */
631
+ export function loadMessages(
632
+ db: Database,
633
+ sessionId: string,
634
+ ): PersistedMessage[] {
635
+ const rows = db.query<DataRow, [string]>(SQL.loadMessages).all(sessionId);
636
+ return rows.map((row) => JSON.parse(row.data) as PersistedMessage);
637
+ }
638
+
639
+ // ---------------------------------------------------------------------------
640
+ // Prompt history
641
+ // ---------------------------------------------------------------------------
642
+
643
+ /**
644
+ * Append a raw submitted prompt to the global prompt-history table.
645
+ *
646
+ * This history is separate from conversational turn state: it is global,
647
+ * append-only, and not affected by `/undo`.
648
+ *
649
+ * @param db - Open database handle.
650
+ * @param opts - Prompt-history fields to persist.
651
+ * @returns The stored {@link PromptHistoryEntry}.
652
+ */
653
+ export function appendPromptHistory(
654
+ db: Database,
655
+ opts: AppendPromptHistoryOpts,
656
+ ): PromptHistoryEntry {
657
+ const now = Date.now();
658
+ const result = db.run(
659
+ "INSERT INTO prompt_history (text, cwd, session_id, created_at) VALUES (?, ?, ?, ?)",
660
+ [opts.text, opts.cwd, opts.sessionId ?? null, now],
661
+ );
662
+
663
+ return {
664
+ id: Number(result.lastInsertRowid),
665
+ text: opts.text,
666
+ cwd: opts.cwd,
667
+ sessionId: opts.sessionId ?? null,
668
+ createdAt: now,
669
+ };
670
+ }
671
+
672
+ /**
673
+ * List raw submitted prompts newest first.
674
+ *
675
+ * @param db - Open database handle.
676
+ * @param limit - Maximum number of entries to return.
677
+ * @returns Prompt-history entries ordered newest first.
678
+ */
679
+ export function listPromptHistory(
680
+ db: Database,
681
+ limit = Number.MAX_SAFE_INTEGER,
682
+ ): PromptHistoryEntry[] {
683
+ const rows = db
684
+ .query<PromptHistoryRow, [number]>(SQL.listPromptHistory)
685
+ .all(limit);
686
+ return rows.map((row) => ({
687
+ id: row.id,
688
+ text: row.text,
689
+ cwd: row.cwd,
690
+ sessionId: row.session_id,
691
+ createdAt: row.created_at,
692
+ }));
693
+ }
694
+
695
+ /**
696
+ * Keep only the newest prompt-history rows.
697
+ *
698
+ * @param db - Open database handle.
699
+ * @param keep - Maximum number of prompt-history rows to retain.
700
+ */
701
+ export function truncatePromptHistory(db: Database, keep: number): void {
702
+ db.run(
703
+ `DELETE FROM prompt_history WHERE id IN (
704
+ SELECT id FROM prompt_history
705
+ ORDER BY created_at DESC, id DESC
706
+ LIMIT -1 OFFSET ?
707
+ )`,
708
+ [keep],
709
+ );
710
+ }
711
+
712
+ // ---------------------------------------------------------------------------
713
+ // Undo
714
+ // ---------------------------------------------------------------------------
715
+
716
+ /**
717
+ * Remove the last turn from a session's history.
718
+ *
719
+ * Deletes **all** messages with the highest turn number — the user message
720
+ * and every assistant/tool-result message that followed in the same agent
721
+ * loop. This is a context-only operation; filesystem changes are not reverted.
722
+ *
723
+ * @param db - Open database handle.
724
+ * @param sessionId - The session to undo in.
725
+ * @returns `true` if a turn was removed, `false` if the session had no messages.
726
+ */
727
+ export function undoLastTurn(db: Database, sessionId: string): boolean {
728
+ const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
729
+ if (!row?.max_turn) return false;
730
+
731
+ db.run("DELETE FROM messages WHERE session_id = ? AND turn = ?", [
732
+ sessionId,
733
+ row.max_turn,
734
+ ]);
735
+ return true;
736
+ }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // Fork
740
+ // ---------------------------------------------------------------------------
741
+
742
+ /**
743
+ * Fork a session into a new independent copy.
744
+ *
745
+ * Creates a new session with the same `cwd`, `model`, and `effort` as the
746
+ * source, then copies all messages preserving their turn numbers. The new
747
+ * session's `forkedFrom` field points back to the source. The original
748
+ * session is not modified.
749
+ *
750
+ * @param db - Open database handle.
751
+ * @param sourceId - The session to fork from.
752
+ * @returns The newly created {@link Session}.
753
+ * @throws If the source session does not exist.
754
+ */
755
+ export function forkSession(db: Database, sourceId: string): Session {
756
+ const source = getSession(db, sourceId);
757
+ if (!source) throw new Error(`Session not found: ${sourceId}`);
758
+
759
+ const id = generateId();
760
+ const now = Date.now();
761
+
762
+ db.run(
763
+ "INSERT INTO sessions (id, cwd, model, effort, forked_from, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
764
+ [id, source.cwd, source.model, source.effort, sourceId, now, now],
765
+ );
766
+
767
+ db.run(
768
+ "INSERT INTO messages (session_id, turn, data, created_at) SELECT ?, turn, data, created_at FROM messages WHERE session_id = ? ORDER BY id",
769
+ [id, sourceId],
770
+ );
771
+
772
+ return {
773
+ id,
774
+ cwd: source.cwd,
775
+ model: source.model,
776
+ effort: source.effort,
777
+ forkedFrom: sourceId,
778
+ createdAt: now,
779
+ updatedAt: now,
780
+ };
781
+ }
782
+
783
+ // ---------------------------------------------------------------------------
784
+ // Stats
785
+ // ---------------------------------------------------------------------------
786
+
787
+ /**
788
+ * Compute cumulative token and cost statistics from a message history.
789
+ *
790
+ * Iterates over the messages, summing `usage` fields from assistant messages
791
+ * only (user and tool-result messages do not carry usage data). This is
792
+ * designed to be called once on session load, with the result maintained
793
+ * in-memory via a running accumulator during the session.
794
+ *
795
+ * @param messages - The full persisted message history for a session.
796
+ * @returns Aggregated {@link SessionStats}.
797
+ */
798
+ /**
799
+ * Add one persisted message's assistant usage to cumulative session stats.
800
+ *
801
+ * Non-assistant messages and assistant messages without valid `usage` are
802
+ * ignored and return the original totals unchanged.
803
+ *
804
+ * @param stats - Running cumulative session totals.
805
+ * @param message - Persisted message to fold into the totals.
806
+ * @returns Updated cumulative session stats.
807
+ */
808
+ export function addMessageToStats(
809
+ stats: SessionStats,
810
+ message: PersistedMessage,
811
+ ): SessionStats {
812
+ const usage = getAssistantUsage(message);
813
+ if (!usage) {
814
+ return stats;
815
+ }
816
+
817
+ return {
818
+ totalInput: stats.totalInput + usage.input,
819
+ totalOutput: stats.totalOutput + usage.output,
820
+ totalCost: stats.totalCost + usage.cost.total,
821
+ };
822
+ }
823
+
824
+ export function computeStats(
825
+ messages: readonly PersistedMessage[],
826
+ ): SessionStats {
827
+ let stats: SessionStats = {
828
+ totalInput: 0,
829
+ totalOutput: 0,
830
+ totalCost: 0,
831
+ };
832
+
833
+ for (const message of messages) {
834
+ stats = addMessageToStats(stats, message);
835
+ }
836
+
837
+ return stats;
838
+ }