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