mini-coder 0.5.14 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +26 -109
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/nono-mini-coder.json +42 -0
  5. package/package.json +17 -22
  6. package/src/agent.ts +243 -1403
  7. package/src/args.ts +289 -0
  8. package/src/headless.ts +41 -359
  9. package/src/index.ts +29 -1016
  10. package/src/oauth.ts +117 -0
  11. package/src/prompt.ts +219 -284
  12. package/src/session.ts +55 -1306
  13. package/src/shared.ts +117 -38
  14. package/src/tool-bash.ts +110 -0
  15. package/src/tool-edit.ts +133 -0
  16. package/src/tool-read.ts +80 -293
  17. package/src/tui-components.ts +150 -0
  18. package/src/tui-conversation.ts +271 -0
  19. package/src/tui-editor.ts +29 -0
  20. package/src/tui-overlay.ts +403 -0
  21. package/src/tui.ts +228 -0
  22. package/src/types.ts +164 -0
  23. package/tsconfig.json +17 -0
  24. package/BENCHMARK.md +0 -107
  25. package/LICENSE +0 -9
  26. package/PROGRESS.md +0 -5
  27. package/assets/icon-1-minimal.svg +0 -31
  28. package/assets/icon-2-dark-terminal.svg +0 -48
  29. package/assets/icon-3-gradient-modern.svg +0 -45
  30. package/assets/icon-4-filled-bold.svg +0 -54
  31. package/assets/icon-5-community-badge.svg +0 -63
  32. package/assets/mc-claude-smart.png +0 -0
  33. package/assets/mc-gpt-smart.png +0 -0
  34. package/assets/preview-0-5-0.png +0 -0
  35. package/assets/preview.gif +0 -0
  36. package/benchmark-baseline.sh +0 -15
  37. package/benchmark-loop.sh +0 -19
  38. package/skills-lock.json +0 -15
  39. package/src/assistant-output.ts +0 -73
  40. package/src/cli.ts +0 -134
  41. package/src/delegation.ts +0 -238
  42. package/src/errors.ts +0 -15
  43. package/src/git.ts +0 -247
  44. package/src/input.ts +0 -168
  45. package/src/mcp.ts +0 -609
  46. package/src/paths.ts +0 -37
  47. package/src/session-message.ts +0 -385
  48. package/src/settings.ts +0 -449
  49. package/src/skills.ts +0 -271
  50. package/src/submit.ts +0 -376
  51. package/src/text.ts +0 -71
  52. package/src/theme.ts +0 -330
  53. package/src/tool-common.ts +0 -93
  54. package/src/tool-delegate.ts +0 -125
  55. package/src/tool-grep.ts +0 -606
  56. package/src/tool-shell.ts +0 -1051
  57. package/src/tools.ts +0 -1179
  58. package/src/ui/agent.ts +0 -320
  59. package/src/ui/commands.test.ts +0 -957
  60. package/src/ui/commands.ts +0 -848
  61. package/src/ui/conversation.test.ts +0 -585
  62. package/src/ui/conversation.ts +0 -1836
  63. package/src/ui/help.ts +0 -158
  64. package/src/ui/input.test.ts +0 -64
  65. package/src/ui/input.ts +0 -138
  66. package/src/ui/overlay.ts +0 -59
  67. package/src/ui/runtime.ts +0 -69
  68. package/src/ui/status.ts +0 -220
  69. package/src/ui.ts +0 -1190
  70. package/src/version.ts +0 -48
package/src/session.ts CHANGED
@@ -1,1337 +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 {
14
- AssistantMessage,
15
- Message,
16
- UserMessage,
17
- } from "@mariozechner/pi-ai";
18
- import {
19
- getAssistantUsage,
20
- isUiMessage,
21
- type PersistedMessage,
22
- parsePersistedMessage,
23
- readAssistantUsage,
24
- readFirstUserPreview,
25
- type UiInfoFormat,
26
- type UiInfoMessage,
27
- type UiMessage,
28
- type UiTodoMessage,
29
- } from "./session-message.ts";
30
- 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.
31
9
 
32
- export type {
33
- PersistedMessage,
34
- UiInfoFormat,
35
- UiInfoMessage,
36
- UiMessage,
37
- UiTodoMessage,
38
- };
39
- export { getAssistantUsage };
40
-
41
- // ---------------------------------------------------------------------------
42
- // Types
43
- // ---------------------------------------------------------------------------
44
-
45
- /**
46
- * A persisted session record.
47
- *
48
- * Represents a single conversation scoped to a working directory.
49
- * The `model` and `effort` fields reflect the values at session creation —
50
- * the user may switch models mid-session via `/model`, but the session
51
- * record is not updated (individual assistant messages carry their own model).
52
- */
53
- export interface Session {
54
- /** Unique session identifier (UUID). */
55
- id: string;
56
- /** Working directory the session is scoped to. */
57
- cwd: string;
58
- /** Provider/model string at creation time, e.g. `"anthropic/claude-sonnet-4-20250514"`. */
59
- model: string | null;
60
- /** Thinking effort level at creation time. */
61
- effort: string | null;
62
- /** ID of the session this was forked from, or `null` if original. */
63
- forkedFrom: string | null;
64
- /** Unix timestamp in milliseconds when the session was created. */
65
- createdAt: number;
66
- /** Unix timestamp in milliseconds, updated on each new message. */
67
- updatedAt: number;
68
- }
69
-
70
- /** Session row used by the `/session` picker. */
71
- export interface SessionListEntry extends Session {
72
- /** First conversational user message collapsed into a single-line preview, or `null` when none exists. */
73
- firstUserPreview: string | null;
74
- }
75
-
76
- /**
77
- * Cumulative input/output token and cost statistics for a session.
78
- *
79
- * Computed by summing `usage` fields from all {@link AssistantMessage}s
80
- * in the session's history. Not stored — derived on load and maintained
81
- * in-memory during the session. These feed the status bar's cumulative
82
- * `in`, `out`, and `$cost` values; current context usage is estimated
83
- * separately from the current model-visible history.
84
- */
85
- export interface SessionStats {
86
- /** Total input tokens across all assistant messages. */
87
- totalInput: number;
88
- /** Total output tokens across all assistant messages. */
89
- totalOutput: number;
90
- /** Total cost in dollars across all assistant messages. */
91
- totalCost: number;
92
- }
93
-
94
- /** One persisted synthetic summary that replaces an older compacted context prefix. */
95
- export interface SessionCompaction {
96
- /** Monotonic compaction row id. */
97
- id: number;
98
- /** Session that owns this compaction. */
99
- sessionId: string;
100
- /** Message-row id of the last raw message covered by the summary. */
101
- messageEndId: number;
102
- /** Synthetic user message injected into future model context. */
103
- summaryMessage: UserMessage;
104
- /** Unix timestamp in milliseconds when the compaction was created. */
105
- createdAt: number;
106
- }
107
-
108
- /** One uncompacted model-visible session message paired with its SQLite row id. */
109
- export interface SessionModelMessageRow {
110
- /** Monotonic message row id. */
111
- id: number;
112
- /** Persisted model-visible message. */
113
- message: Message;
114
- }
115
-
116
- /** A raw submitted prompt stored for global input-history search. */
117
- interface PromptHistoryEntry {
118
- /** Monotonic row id. */
119
- id: number;
120
- /** Exact raw prompt text as submitted by the user. */
121
- text: string;
122
- /** Working directory where the prompt was submitted. */
123
- cwd: string;
124
- /** Originating session id when available. */
125
- sessionId: string | null;
126
- /** Unix timestamp in milliseconds when the prompt was submitted. */
127
- createdAt: number;
128
- }
129
-
130
- /** Options for appending a raw prompt-history entry. */
131
- interface AppendPromptHistoryOpts {
132
- /** Exact raw prompt text as submitted by the user. */
133
- text: string;
134
- /** Working directory where the prompt was submitted. */
135
- cwd: string;
136
- /** Originating session id when available. */
137
- sessionId?: string;
10
+ export async function ensureSessionsDir(): Promise<void> {
11
+ await mkdir(SESSIONS_DIR, { recursive: true });
138
12
  }
139
13
 
140
- /** Options for creating a new session. */
141
- interface CreateSessionOpts {
142
- /** Working directory to scope the session to. */
143
- cwd: string;
144
- /** Provider/model identifier, e.g. `"anthropic/claude-sonnet-4-20250514"`. */
145
- model?: string;
146
- /** Thinking effort level, e.g. `"medium"`. */
147
- effort?: string;
148
- }
149
-
150
- // ---------------------------------------------------------------------------
151
- // Internal row types (map directly to SQLite column names)
152
- // ---------------------------------------------------------------------------
153
-
154
- /** Row shape returned by `SELECT * FROM sessions`. */
155
- type SessionRow = {
156
- id: string;
157
- cwd: string;
158
- model: string | null;
159
- effort: string | null;
160
- forked_from: string | null;
161
- created_at: number;
162
- updated_at: number;
163
- };
164
-
165
- /** Row shape returned by the `/session` picker query. */
166
- type SessionListRow = SessionRow & {
167
- first_user_message_data: string | null;
168
- };
169
-
170
- /** Row shape for `SELECT MAX(turn)` queries. */
171
- type MaxTurnRow = { max_turn: number | null };
172
-
173
- /** Row shape for `SELECT id, data` message queries. */
174
- type StoredMessageRow = {
175
- id: number;
176
- data: string;
177
- };
178
-
179
- /** Row shape returned by `SELECT * FROM session_compactions`. */
180
- type SessionCompactionRow = {
181
- id: number;
182
- session_id: string;
183
- message_end_id: number;
184
- summary_data: string;
185
- usage_data: string | null;
186
- created_at: number;
187
- };
188
-
189
- /** Row shape for `SELECT message_end_id` compaction queries. */
190
- type CompactionEndRow = { message_end_id: number };
191
-
192
- /** Row shape returned by `SELECT id` message queries. */
193
- type MessageIdRow = { id: number };
194
-
195
- /** Row shape for `SELECT data` queries. */
196
- type DataRow = { data: string };
197
-
198
- const SQLITE_BUSY_TIMEOUT_MS = 1_000;
199
-
200
- /** Row shape returned by `SELECT * FROM prompt_history`. */
201
- type PromptHistoryRow = {
202
- id: number;
203
- text: string;
204
- cwd: string;
205
- session_id: string | null;
206
- created_at: number;
207
- };
208
-
209
- // ---------------------------------------------------------------------------
210
- // SQL
211
- // ---------------------------------------------------------------------------
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`));
212
17
 
213
- const SQL = {
214
- listSessions: `
215
- SELECT
216
- sessions.*,
217
- (
218
- SELECT data
219
- FROM messages
220
- WHERE session_id = sessions.id AND turn IS NOT NULL
221
- ORDER BY id
222
- LIMIT 1
223
- ) AS first_user_message_data
224
- FROM sessions
225
- WHERE cwd = ?
226
- ORDER BY updated_at DESC, rowid DESC
227
- `,
228
- maxTurn: "SELECT MAX(turn) as max_turn FROM messages WHERE session_id = ?",
229
- loadMessages: "SELECT data FROM messages WHERE session_id = ? ORDER BY id",
230
- loadStoredMessagesAfterId:
231
- "SELECT id, data FROM messages WHERE session_id = ? AND id > ? ORDER BY id",
232
- listCompactions:
233
- "SELECT * FROM session_compactions WHERE session_id = ? ORDER BY message_end_id, id",
234
- latestCompactionEnd:
235
- "SELECT message_end_id FROM session_compactions WHERE session_id = ? ORDER BY message_end_id DESC, id DESC LIMIT 1",
236
- firstMessageIdForTurn:
237
- "SELECT id FROM messages WHERE session_id = ? AND turn = ? ORDER BY id LIMIT 1",
238
- listMessageIds: "SELECT id FROM messages WHERE session_id = ? ORDER BY id",
239
- listPromptHistory:
240
- "SELECT * FROM prompt_history ORDER BY created_at DESC, id DESC LIMIT ?",
241
- } as const;
242
-
243
- const SCHEMA = `
244
- CREATE TABLE IF NOT EXISTS sessions (
245
- id TEXT PRIMARY KEY,
246
- cwd TEXT NOT NULL,
247
- model TEXT,
248
- effort TEXT,
249
- forked_from TEXT,
250
- created_at INTEGER NOT NULL,
251
- updated_at INTEGER NOT NULL
252
- );
253
-
254
- CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
255
-
256
- CREATE TABLE IF NOT EXISTS messages (
257
- id INTEGER PRIMARY KEY AUTOINCREMENT,
258
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
259
- turn INTEGER,
260
- data TEXT NOT NULL,
261
- created_at INTEGER NOT NULL
262
- );
263
-
264
- CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, turn);
265
-
266
- CREATE TABLE IF NOT EXISTS session_compactions (
267
- id INTEGER PRIMARY KEY AUTOINCREMENT,
268
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
269
- message_end_id INTEGER NOT NULL,
270
- summary_data TEXT NOT NULL,
271
- usage_data TEXT,
272
- created_at INTEGER NOT NULL
273
- );
274
-
275
- CREATE INDEX IF NOT EXISTS idx_session_compactions_session ON session_compactions(session_id, message_end_id);
276
-
277
- CREATE TABLE IF NOT EXISTS prompt_history (
278
- id INTEGER PRIMARY KEY AUTOINCREMENT,
279
- text TEXT NOT NULL,
280
- cwd TEXT NOT NULL,
281
- session_id TEXT,
282
- created_at INTEGER NOT NULL
283
- );
284
-
285
- CREATE INDEX IF NOT EXISTS idx_prompt_history_created_at ON prompt_history(created_at, id);
286
- `;
287
-
288
- // ---------------------------------------------------------------------------
289
- // Database
290
- // ---------------------------------------------------------------------------
291
-
292
- /**
293
- * Open (or create) the SQLite database and ensure the schema exists.
294
- *
295
- * Enables WAL journal mode for concurrent read performance and foreign
296
- * keys for cascade deletes. Pass `":memory:"` for an in-memory database
297
- * (useful in tests).
298
- *
299
- * @param path - File path for the database, or `":memory:"` for in-memory.
300
- * @returns An open {@link Database} handle. The caller is responsible for
301
- * closing it when done.
302
- *
303
- * @example
304
- * ```ts
305
- * const db = openDatabase("~/.config/mini-coder/mini-coder.db");
306
- * // ... use db ...
307
- * db.close();
308
- * ```
309
- */
310
- function ensureSessionCompactionUsageColumn(db: Database): void {
311
- try {
312
- db.run("ALTER TABLE session_compactions ADD COLUMN usage_data TEXT");
313
- } catch (error) {
314
- if (
315
- !(error instanceof Error) ||
316
- !error.message.includes("duplicate column name: usage_data")
317
- ) {
318
- throw error;
319
- }
18
+ if (!(await file.exists())) {
19
+ return;
320
20
  }
321
- }
322
21
 
323
- export function openDatabase(path: string): Database {
324
- const db = new Database(path);
325
- db.run("PRAGMA journal_mode = WAL");
326
- db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
327
- db.run("PRAGMA foreign_keys = ON");
328
- db.exec(SCHEMA);
329
- ensureSessionCompactionUsageColumn(db);
330
- return db;
331
- }
22
+ const sessionJson = await file.text();
23
+ const parsed = JSON.parse(sessionJson) as unknown;
24
+ const valid = Value.Check(SessionSchema, parsed);
332
25
 
333
- // ---------------------------------------------------------------------------
334
- // Session CRUD
335
- // ---------------------------------------------------------------------------
336
-
337
- function generateId(): string {
338
- return crypto.randomUUID();
26
+ if (valid) return parsed;
27
+ return;
339
28
  }
340
29
 
341
- /**
342
- * Create a new session record.
343
- *
344
- * @param db - Open database handle.
345
- * @param opts - Session options (cwd is required; model and effort are optional).
346
- * @returns The newly created {@link Session}.
347
- */
348
- export function createSession(db: Database, opts: CreateSessionOpts): Session {
349
- const id = generateId();
350
- const now = Date.now();
351
- db.run(
352
- "INSERT INTO sessions (id, cwd, model, effort, forked_from, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, ?, ?)",
353
- [id, opts.cwd, opts.model ?? null, opts.effort ?? null, now, now],
354
- );
355
- return {
356
- id,
357
- cwd: opts.cwd,
358
- model: opts.model ?? null,
359
- effort: opts.effort ?? null,
360
- forkedFrom: null,
361
- createdAt: now,
362
- updatedAt: now,
363
- };
364
- }
365
-
366
- /**
367
- * Retrieve a session by its ID.
368
- *
369
- * @param db - Open database handle.
370
- * @param id - The session UUID.
371
- * @returns The {@link Session}, or `null` if not found.
372
- */
373
- export function getSession(db: Database, id: string): Session | null {
374
- const row = db
375
- .query<SessionRow, [string]>("SELECT * FROM sessions WHERE id = ?")
376
- .get(id);
377
- if (!row) return null;
378
- return {
379
- id: row.id,
380
- cwd: row.cwd,
381
- model: row.model,
382
- effort: row.effort,
383
- forkedFrom: row.forked_from,
384
- createdAt: row.created_at,
385
- updatedAt: row.updated_at,
386
- };
30
+ function latestMessageTimestamp(session: Session): number {
31
+ return Math.max(0, ...session.messages.map((message) => message.timestamp));
387
32
  }
388
33
 
389
- /**
390
- * List sessions for a working directory, most recently updated first.
391
- *
392
- * @param db - Open database handle.
393
- * @param cwd - Working directory to filter by.
394
- * @returns Session rows ordered by `updatedAt` descending, enriched with the first-user preview.
395
- */
396
- export function listSessions(db: Database, cwd: string): SessionListEntry[] {
397
- const rows = db.query<SessionListRow, [string]>(SQL.listSessions).all(cwd);
398
- return rows.map((row) => ({
399
- id: row.id,
400
- cwd: row.cwd,
401
- model: row.model,
402
- effort: row.effort,
403
- forkedFrom: row.forked_from,
404
- createdAt: row.created_at,
405
- updatedAt: row.updated_at,
406
- firstUserPreview: readFirstUserPreview(row.first_user_message_data),
407
- }));
408
- }
409
-
410
- /**
411
- * Delete a session and all its messages (via foreign key cascade).
412
- *
413
- * @param db - Open database handle.
414
- * @param id - The session UUID to delete.
415
- */
416
- export function deleteSession(db: Database, id: string): void {
417
- db.run("DELETE FROM sessions WHERE id = ?", [id]);
418
- }
419
-
420
- /**
421
- * Keep only the most recent sessions for a CWD, deleting the rest.
422
- *
423
- * Sessions are ordered by `updated_at DESC`; those beyond `keep` are
424
- * deleted (cascade removes their messages too). No-op if the count is
425
- * already within the limit.
426
- *
427
- * @param db - Open database handle.
428
- * @param cwd - Working directory to scope the truncation to.
429
- * @param keep - Maximum number of sessions to retain.
430
- */
431
- export function truncateSessions(
432
- db: Database,
433
- cwd: string,
434
- keep: number,
435
- ): void {
436
- db.run(
437
- `DELETE FROM sessions WHERE id IN (
438
- SELECT id FROM sessions WHERE cwd = ?
439
- ORDER BY updated_at DESC, rowid DESC
440
- LIMIT -1 OFFSET ?
441
- )`,
442
- [cwd, keep],
443
- );
444
- }
34
+ export async function listSessionsForCwd(): Promise<Session[]> {
35
+ const sessions: Session[] = [];
36
+ const sessionFiles = new Bun.Glob("*.json");
37
+ const cwd = process.cwd();
445
38
 
446
- // ---------------------------------------------------------------------------
447
- // Messages
448
- // ---------------------------------------------------------------------------
449
-
450
- /**
451
- * Create a persisted UI info message.
452
- *
453
- * @param content - Display text shown in the conversation log.
454
- * @param format - Optional rich-text format hint for the content.
455
- * @returns A new {@link UiInfoMessage}.
456
- */
457
- export function createUiMessage(
458
- content: string,
459
- format?: UiInfoFormat,
460
- ): UiInfoMessage {
461
- return {
462
- role: "ui",
463
- kind: "info",
464
- content,
465
- ...(format ? { format } : {}),
466
- timestamp: Date.now(),
467
- };
468
- }
469
-
470
- /**
471
- * Create a persisted UI todo snapshot message.
472
- *
473
- * @param todos - Todo snapshot rendered in the conversation log.
474
- * @returns A new {@link UiTodoMessage}.
475
- */
476
- export function createUiTodoMessage(todos: readonly TodoItem[]): UiTodoMessage {
477
- return {
478
- role: "ui",
479
- kind: "todo",
480
- todos: todos.map((todo) => ({ ...todo })),
481
- timestamp: Date.now(),
482
- };
483
- }
484
-
485
- /**
486
- * Filter persisted session history down to model-visible pi-ai messages.
487
- *
488
- * @param messages - Persisted session history.
489
- * @returns Only the pi-ai {@link Message} entries.
490
- */
491
- export function filterModelMessages(
492
- messages: readonly PersistedMessage[],
493
- ): Message[] {
494
- return messages.filter(
495
- (message): message is Message => !isUiMessage(message),
496
- );
497
- }
498
-
499
- function runInImmediateTransaction<T>(db: Database, callback: () => T): T {
500
- if (db.inTransaction) {
501
- return callback();
502
- }
503
-
504
- db.run("BEGIN IMMEDIATE");
505
39
  try {
506
- const result = callback();
507
- db.run("COMMIT");
508
- return result;
509
- } catch (error) {
510
- if (db.inTransaction) {
40
+ for await (const entry of sessionFiles.scan({
41
+ cwd: SESSIONS_DIR,
42
+ dot: true,
43
+ })) {
511
44
  try {
512
- db.run("ROLLBACK");
513
- } catch (rollbackError) {
514
- throw new AggregateError(
515
- [error, rollbackError],
516
- "Failed to roll back SQLite transaction",
517
- );
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.
518
53
  }
519
54
  }
520
- throw error;
521
- }
522
- }
523
-
524
- function parseCompactionSummaryMessage(data: string): UserMessage | null {
525
- const message = parsePersistedMessage(data);
526
- return message?.role === "user" ? message : null;
527
- }
528
-
529
- function parseCompactionUsage(
530
- data: string | null,
531
- ): AssistantMessage["usage"] | null {
532
- if (!data) {
533
- return null;
534
- }
535
-
536
- try {
537
- return readAssistantUsage(JSON.parse(data) as unknown);
538
55
  } catch {
539
- return null;
56
+ return [];
540
57
  }
541
- }
542
-
543
- function getLatestCompactionEndId(db: Database, sessionId: string): number {
544
- const row = db
545
- .query<CompactionEndRow, [string]>(SQL.latestCompactionEnd)
546
- .get(sessionId);
547
- return row?.message_end_id ?? 0;
548
- }
549
-
550
- function listMessageIds(db: Database, sessionId: string): number[] {
551
- return db
552
- .query<MessageIdRow, [string]>(SQL.listMessageIds)
553
- .all(sessionId)
554
- .map((row) => row.id);
555
- }
556
-
557
- /**
558
- * List persisted compaction summaries for a session in replacement order.
559
- *
560
- * @param db - Open database handle.
561
- * @param sessionId - Session whose compactions should be listed.
562
- * @returns Parsed compaction summaries ordered by covered message range.
563
- */
564
- export function listSessionCompactions(
565
- db: Database,
566
- sessionId: string,
567
- ): SessionCompaction[] {
568
- const rows = db
569
- .query<SessionCompactionRow, [string]>(SQL.listCompactions)
570
- .all(sessionId);
571
- const compactions: SessionCompaction[] = [];
572
-
573
- for (const row of rows) {
574
- const summaryMessage = parseCompactionSummaryMessage(row.summary_data);
575
- if (!summaryMessage) {
576
- continue;
577
- }
578
-
579
- compactions.push({
580
- id: row.id,
581
- sessionId: row.session_id,
582
- messageEndId: row.message_end_id,
583
- summaryMessage,
584
- createdAt: row.created_at,
585
- });
586
- }
587
-
588
- return compactions;
589
- }
590
-
591
- /**
592
- * Persist one synthetic compaction summary for future model-context rebuilds.
593
- *
594
- * @param db - Open database handle.
595
- * @param sessionId - Session that owns the compaction.
596
- * @param messageEndId - Last raw message-row id covered by the summary.
597
- * @param summaryMessage - Synthetic user message that replaces that prefix in context.
598
- * @param summaryUsage - Optional usage for the hidden summarization model call.
599
- * @returns The stored {@link SessionCompaction}.
600
- */
601
- export function appendSessionCompaction(
602
- db: Database,
603
- sessionId: string,
604
- messageEndId: number,
605
- summaryMessage: UserMessage,
606
- summaryUsage?: AssistantMessage["usage"],
607
- ): SessionCompaction {
608
- return runInImmediateTransaction(db, () => {
609
- const now = Date.now();
610
- const result = db.run(
611
- "INSERT INTO session_compactions (session_id, message_end_id, summary_data, usage_data, created_at) VALUES (?, ?, ?, ?, ?)",
612
- [
613
- sessionId,
614
- messageEndId,
615
- JSON.stringify(summaryMessage),
616
- summaryUsage ? JSON.stringify(summaryUsage) : null,
617
- now,
618
- ],
619
- );
620
-
621
- return {
622
- id: Number(result.lastInsertRowid),
623
- sessionId,
624
- messageEndId,
625
- summaryMessage,
626
- createdAt: now,
627
- };
628
- });
629
- }
630
-
631
- /**
632
- * Load raw model-visible messages that have not yet been compacted.
633
- *
634
- * @param db - Open database handle.
635
- * @param sessionId - Session whose uncompacted message tail should be loaded.
636
- * @returns Model-visible message rows after the latest compaction boundary.
637
- */
638
- export function loadUncompactedModelMessages(
639
- db: Database,
640
- sessionId: string,
641
- ): SessionModelMessageRow[] {
642
- const latestCompactionEndId = getLatestCompactionEndId(db, sessionId);
643
- const rows = db
644
- .query<StoredMessageRow, [string, number]>(SQL.loadStoredMessagesAfterId)
645
- .all(sessionId, latestCompactionEndId);
646
- const messages: SessionModelMessageRow[] = [];
647
-
648
- for (const row of rows) {
649
- const message = parsePersistedMessage(row.data);
650
- if (!message || isUiMessage(message)) {
651
- continue;
652
- }
653
-
654
- messages.push({ id: row.id, message });
655
- }
656
-
657
- return messages;
658
- }
659
-
660
- /**
661
- * Rebuild the model-visible session context using persisted compaction summaries.
662
- *
663
- * @param db - Open database handle.
664
- * @param sessionId - Session whose compacted model context should be loaded.
665
- * @returns Synthetic compaction summaries followed by the uncompacted message tail.
666
- */
667
- export function loadCompactedModelMessages(
668
- db: Database,
669
- sessionId: string,
670
- ): Message[] {
671
- return [
672
- ...listSessionCompactions(db, sessionId).map(
673
- (compaction) => compaction.summaryMessage,
674
- ),
675
- ...loadUncompactedModelMessages(db, sessionId).map((row) => row.message),
676
- ];
677
- }
678
-
679
- /**
680
- * Compute the next-request context-token estimate for a persisted session.
681
- *
682
- * @param db - Open database handle.
683
- * @param sessionId - Session whose compacted model context should be estimated.
684
- * @returns Estimated context tokens for the next request.
685
- */
686
- export function computeSessionContextTokens(
687
- db: Database,
688
- sessionId: string,
689
- ): number {
690
- return computeContextTokens(loadCompactedModelMessages(db, sessionId));
691
- }
692
-
693
- /**
694
- * Append a UI-only message to a session's history.
695
- *
696
- * UI messages are persisted with `turn = NULL` so they remain visible in
697
- * history without participating in conversational turn numbering or `/undo`.
698
- *
699
- * @param db - Open database handle.
700
- * @param sessionId - The session to append to.
701
- * @param message - The UI-only message to persist.
702
- * @param turn - Ignored for UI messages.
703
- * @returns `null`, since UI messages do not belong to conversational turns.
704
- */
705
- export function appendMessage(
706
- db: Database,
707
- sessionId: string,
708
- message: UiMessage,
709
- turn?: number,
710
- ): null;
711
-
712
- /**
713
- * Append a conversational message to a session's history.
714
- *
715
- * Turn numbering rules:
716
- * - When `turn` is **omitted**, a new turn is started with `MAX(turn) + 1`
717
- * (or `1` for the first message). This is used for user messages.
718
- * - When `turn` is **provided**, the message joins that existing turn.
719
- * This is used for assistant and tool-result messages that belong to
720
- * the same agent loop as the initiating user message.
721
- *
722
- * Also updates the session's `updatedAt` timestamp.
723
- *
724
- * @param db - Open database handle.
725
- * @param sessionId - The session to append to.
726
- * @param message - A model-visible pi-ai message.
727
- * @param turn - Explicit turn number to join. Omit to start a new turn.
728
- * @returns The conversational turn number the message was stored with.
729
- */
730
- export function appendMessage(
731
- db: Database,
732
- sessionId: string,
733
- message: Message,
734
- turn?: number,
735
- ): number;
736
-
737
- /**
738
- * Append a persisted message to a session's history.
739
- *
740
- * UI messages always store `turn = NULL`. Conversational messages either start
741
- * a new turn or join an existing one, depending on `turn`.
742
- *
743
- * @param db - Open database handle.
744
- * @param sessionId - The session to append to.
745
- * @param message - Persisted message to store.
746
- * @param turn - Explicit conversational turn to join.
747
- * @returns The assigned conversational turn number, or `null` for UI messages.
748
- */
749
- export function appendMessage(
750
- db: Database,
751
- sessionId: string,
752
- message: PersistedMessage,
753
- turn?: number,
754
- ): number | null;
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
- return runInImmediateTransaction(db, () => {
775
- const now = Date.now();
776
-
777
- let effectiveTurn: number | null;
778
- if (isUiMessage(message)) {
779
- effectiveTurn = null;
780
- } else if (turn !== undefined) {
781
- effectiveTurn = turn;
782
- } else {
783
- const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
784
- effectiveTurn = (row?.max_turn ?? 0) + 1;
785
- }
786
-
787
- db.run(
788
- "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
789
- [sessionId, effectiveTurn, JSON.stringify(message), now],
790
- );
791
- db.run("UPDATE sessions SET updated_at = ? WHERE id = ?", [now, sessionId]);
792
58
 
793
- return effectiveTurn;
794
- });
795
- }
796
-
797
- /**
798
- * Load all messages for a session in insertion order.
799
- *
800
- * Messages are deserialized from their JSON representation back into
801
- * persisted app messages. Invalid rows are skipped so corrupted session data
802
- * does not crash the app. The ordering matches the original append order
803
- * (by autoincrement `id`), preserving the conversation flow.
804
- *
805
- * @param db - Open database handle.
806
- * @param sessionId - The session to load messages for.
807
- * @returns An array of {@link PersistedMessage} objects, empty if the session
808
- * has no messages or does not exist.
809
- */
810
- export function loadMessages(
811
- db: Database,
812
- sessionId: string,
813
- ): PersistedMessage[] {
814
- const rows = db.query<DataRow, [string]>(SQL.loadMessages).all(sessionId);
815
- const messages: PersistedMessage[] = [];
816
-
817
- for (const row of rows) {
818
- const message = parsePersistedMessage(row.data);
819
- if (message) {
820
- messages.push(message);
821
- }
822
- }
823
-
824
- return messages;
825
- }
826
-
827
- // ---------------------------------------------------------------------------
828
- // Prompt history
829
- // ---------------------------------------------------------------------------
830
-
831
- /**
832
- * Append a raw submitted prompt to the global prompt-history table.
833
- *
834
- * This history is separate from conversational turn state: it is global,
835
- * append-only, and not affected by `/undo`.
836
- *
837
- * @param db - Open database handle.
838
- * @param opts - Prompt-history fields to persist.
839
- * @returns The stored {@link PromptHistoryEntry}.
840
- */
841
- export function appendPromptHistory(
842
- db: Database,
843
- opts: AppendPromptHistoryOpts,
844
- ): PromptHistoryEntry {
845
- const now = Date.now();
846
- const result = db.run(
847
- "INSERT INTO prompt_history (text, cwd, session_id, created_at) VALUES (?, ?, ?, ?)",
848
- [opts.text, opts.cwd, opts.sessionId ?? null, now],
849
- );
850
-
851
- return {
852
- id: Number(result.lastInsertRowid),
853
- text: opts.text,
854
- cwd: opts.cwd,
855
- sessionId: opts.sessionId ?? null,
856
- createdAt: now,
857
- };
858
- }
859
-
860
- /**
861
- * List raw submitted prompts newest first.
862
- *
863
- * @param db - Open database handle.
864
- * @param limit - Maximum number of entries to return.
865
- * @returns Prompt-history entries ordered newest first.
866
- */
867
- export function listPromptHistory(
868
- db: Database,
869
- limit = Number.MAX_SAFE_INTEGER,
870
- ): PromptHistoryEntry[] {
871
- const rows = db
872
- .query<PromptHistoryRow, [number]>(SQL.listPromptHistory)
873
- .all(limit);
874
- return rows.map((row) => ({
875
- id: row.id,
876
- text: row.text,
877
- cwd: row.cwd,
878
- sessionId: row.session_id,
879
- createdAt: row.created_at,
880
- }));
881
- }
882
-
883
- /**
884
- * Keep only the newest prompt-history rows.
885
- *
886
- * @param db - Open database handle.
887
- * @param keep - Maximum number of prompt-history rows to retain.
888
- */
889
- export function truncatePromptHistory(db: Database, keep: number): void {
890
- db.run(
891
- `DELETE FROM prompt_history WHERE id IN (
892
- SELECT id FROM prompt_history
893
- ORDER BY created_at DESC, id DESC
894
- LIMIT -1 OFFSET ?
895
- )`,
896
- [keep],
897
- );
898
- }
899
-
900
- // ---------------------------------------------------------------------------
901
- // Undo
902
- // ---------------------------------------------------------------------------
903
-
904
- /**
905
- * Remove the last turn from a session's history.
906
- *
907
- * Deletes **all** messages with the highest turn number — the user message
908
- * and every assistant/tool-result message that followed in the same agent
909
- * loop. This is a context-only operation; filesystem changes are not reverted.
910
- *
911
- * @param db - Open database handle.
912
- * @param sessionId - The session to undo in.
913
- * @returns `true` if a turn was removed, `false` if the session had no messages.
914
- */
915
- export function undoLastTurn(db: Database, sessionId: string): boolean {
916
- return runInImmediateTransaction(db, () => {
917
- const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
918
- if (!row?.max_turn) {
919
- return false;
920
- }
921
-
922
- const firstMessageRow = db
923
- .query<MessageIdRow, [string, number]>(SQL.firstMessageIdForTurn)
924
- .get(sessionId, row.max_turn);
925
-
926
- db.run("DELETE FROM messages WHERE session_id = ? AND turn = ?", [
927
- sessionId,
928
- row.max_turn,
929
- ]);
930
-
931
- if (firstMessageRow) {
932
- db.run(
933
- "DELETE FROM session_compactions WHERE session_id = ? AND message_end_id >= ?",
934
- [sessionId, firstMessageRow.id],
935
- );
936
- }
937
-
938
- return true;
939
- });
940
- }
941
-
942
- // ---------------------------------------------------------------------------
943
- // Fork
944
- // ---------------------------------------------------------------------------
945
-
946
- /**
947
- * Fork a session into a new independent copy.
948
- *
949
- * Creates a new session with the same `cwd`, `model`, and `effort` as the
950
- * source, then copies all messages preserving their turn numbers plus any
951
- * persisted compaction summaries with remapped message-row boundaries. The
952
- * new 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
- return runInImmediateTransaction(db, () => {
965
- const id = generateId();
966
- const now = Date.now();
967
-
968
- db.run(
969
- "INSERT INTO sessions (id, cwd, model, effort, forked_from, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
970
- [id, source.cwd, source.model, source.effort, sourceId, now, now],
971
- );
972
-
973
- const sourceMessageIds = listMessageIds(db, sourceId);
974
-
975
- db.run(
976
- "INSERT INTO messages (session_id, turn, data, created_at) SELECT ?, turn, data, created_at FROM messages WHERE session_id = ? ORDER BY id",
977
- [id, sourceId],
978
- );
979
-
980
- const forkedMessageIds = listMessageIds(db, id);
981
- const forkedMessageIdBySourceId = new Map<number, number>();
982
- for (let index = 0; index < sourceMessageIds.length; index += 1) {
983
- const sourceMessageId = sourceMessageIds[index];
984
- const forkedMessageId = forkedMessageIds[index];
985
- if (sourceMessageId === undefined || forkedMessageId === undefined) {
986
- continue;
987
- }
988
- forkedMessageIdBySourceId.set(sourceMessageId, forkedMessageId);
989
- }
990
-
991
- const sourceCompactions = db
992
- .query<SessionCompactionRow, [string]>(SQL.listCompactions)
993
- .all(sourceId);
994
- for (const compaction of sourceCompactions) {
995
- const forkedMessageEndId = forkedMessageIdBySourceId.get(
996
- compaction.message_end_id,
997
- );
998
- if (forkedMessageEndId === undefined) {
999
- continue;
1000
- }
1001
-
1002
- db.run(
1003
- "INSERT INTO session_compactions (session_id, message_end_id, summary_data, usage_data, created_at) VALUES (?, ?, ?, ?, ?)",
1004
- [
1005
- id,
1006
- forkedMessageEndId,
1007
- compaction.summary_data,
1008
- compaction.usage_data,
1009
- compaction.created_at,
1010
- ],
1011
- );
1012
- }
1013
-
1014
- return {
1015
- id,
1016
- cwd: source.cwd,
1017
- model: source.model,
1018
- effort: source.effort,
1019
- forkedFrom: sourceId,
1020
- createdAt: now,
1021
- updatedAt: now,
1022
- };
1023
- });
1024
- }
1025
-
1026
- // ---------------------------------------------------------------------------
1027
- // Stats
1028
- // ---------------------------------------------------------------------------
1029
-
1030
- function addUsageToStats(
1031
- stats: SessionStats,
1032
- usage: AssistantMessage["usage"],
1033
- ): SessionStats {
1034
- return {
1035
- totalInput: stats.totalInput + usage.input,
1036
- totalOutput: stats.totalOutput + usage.output,
1037
- totalCost: stats.totalCost + usage.cost.total,
1038
- };
1039
- }
1040
-
1041
- /**
1042
- * Add one persisted message's assistant usage to cumulative session stats.
1043
- *
1044
- * Non-assistant messages and assistant messages without valid `usage` are
1045
- * ignored and return the original totals unchanged.
1046
- *
1047
- * @param stats - Running cumulative session totals.
1048
- * @param message - Persisted message to fold into the totals.
1049
- * @returns Updated cumulative session stats.
1050
- */
1051
- export function addMessageToStats(
1052
- stats: SessionStats,
1053
- message: PersistedMessage,
1054
- ): SessionStats {
1055
- const usage = getAssistantUsage(message);
1056
- return usage ? addUsageToStats(stats, usage) : stats;
1057
- }
1058
-
1059
- /** Create a zeroed cumulative session-stats object. */
1060
- export function createEmptySessionStats(): SessionStats {
1061
- return {
1062
- totalInput: 0,
1063
- totalOutput: 0,
1064
- totalCost: 0,
1065
- };
1066
- }
1067
-
1068
- /**
1069
- * Compute cumulative token and cost statistics from a message history.
1070
- *
1071
- * @param messages - Full persisted session history.
1072
- * @returns Aggregated cumulative session stats.
1073
- */
1074
- export function computeStats(
1075
- messages: readonly PersistedMessage[],
1076
- ): SessionStats {
1077
- let stats = createEmptySessionStats();
1078
-
1079
- for (const message of messages) {
1080
- stats = addMessageToStats(stats, message);
1081
- }
1082
-
1083
- return stats;
1084
- }
1085
-
1086
- /**
1087
- * Compute cumulative session stats including persisted compaction-summary calls.
1088
- *
1089
- * @param db - Open database handle.
1090
- * @param sessionId - Session whose cumulative stats should be loaded.
1091
- * @returns Aggregated cumulative session stats for visible turns plus compactions.
1092
- */
1093
- export function computeSessionStats(
1094
- db: Database,
1095
- sessionId: string,
1096
- ): SessionStats {
1097
- let stats = computeStats(loadMessages(db, sessionId));
1098
-
1099
- const compactionRows = db
1100
- .query<SessionCompactionRow, [string]>(SQL.listCompactions)
1101
- .all(sessionId);
1102
- for (const row of compactionRows) {
1103
- const usage = parseCompactionUsage(row.usage_data);
1104
- if (usage) {
1105
- stats = addUsageToStats(stats, usage);
1106
- }
1107
- }
1108
-
1109
- return stats;
1110
- }
1111
-
1112
- // ---------------------------------------------------------------------------
1113
- // Context estimation
1114
- // ---------------------------------------------------------------------------
1115
-
1116
- /** Conservative fixed estimate for an image block's token footprint. */
1117
- const ESTIMATED_IMAGE_TOKENS = 1_200;
1118
-
1119
- /** Calculate context tokens from assistant usage, falling back when `totalTokens` is zero. */
1120
- function calculateUsageTokens(usage: AssistantMessage["usage"]): number {
1121
- return (
1122
- usage.totalTokens ||
1123
- usage.input + usage.output + usage.cacheRead + usage.cacheWrite
59
+ return sessions.sort(
60
+ (a, b) => latestMessageTimestamp(b) - latestMessageTimestamp(a),
1124
61
  );
1125
62
  }
1126
63
 
1127
- /** Estimate token usage from a character count using a conservative chars/4 heuristic. */
1128
- function estimateCharacterTokens(charCount: number): number {
1129
- 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));
1130
68
  }
1131
69
 
1132
- type UserMultipartContent = Exclude<
1133
- Extract<Message, { role: "user" }>["content"],
1134
- string
1135
- >;
1136
- type TextOrImageContentBlock =
1137
- | UserMultipartContent[number]
1138
- | Extract<Message, { role: "toolResult" }>["content"][number];
1139
-
1140
- function estimateTextOrImageContentTokens(
1141
- content: readonly TextOrImageContentBlock[],
1142
- ): number {
1143
- let chars = 0;
1144
- let imageTokens = 0;
1145
-
1146
- for (const block of content) {
1147
- if (block.type === "text") {
1148
- chars += block.text.length;
1149
- continue;
1150
- }
1151
- if (block.type === "image") {
1152
- imageTokens += ESTIMATED_IMAGE_TOKENS;
1153
- }
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;
1154
77
  }
1155
78
 
1156
- return estimateCharacterTokens(chars) + imageTokens;
1157
- }
1158
-
1159
- function estimateUserMessageTokens(
1160
- message: Extract<Message, { role: "user" }>,
1161
- ): number {
1162
- if (typeof message.content === "string") {
1163
- return estimateCharacterTokens(message.content.length);
1164
- }
1165
- return estimateTextOrImageContentTokens(message.content);
1166
- }
1167
-
1168
- function estimateAssistantBlockCharacters(
1169
- block: Extract<Message, { role: "assistant" }>["content"][number],
1170
- ): number {
1171
- if (block.type === "text") {
1172
- return block.text.length;
1173
- }
1174
- if (block.type === "thinking") {
1175
- return block.thinking.length;
1176
- }
1177
- return block.name.length + JSON.stringify(block.arguments).length;
1178
- }
1179
-
1180
- function estimateAssistantMessageTokens(
1181
- message: Extract<Message, { role: "assistant" }>,
1182
- ): number {
1183
- const chars = message.content.reduce((total, block) => {
1184
- return total + estimateAssistantBlockCharacters(block);
1185
- }, 0);
1186
- return estimateCharacterTokens(chars);
1187
- }
1188
-
1189
- function estimateToolResultMessageTokens(
1190
- message: Extract<Message, { role: "toolResult" }>,
1191
- ): number {
1192
- return estimateTextOrImageContentTokens(message.content);
1193
- }
1194
-
1195
- /** Estimate token usage for a model-visible message. */
1196
- function estimateMessageTokens(message: Message): number {
1197
- switch (message.role) {
1198
- case "user":
1199
- return estimateUserMessageTokens(message);
1200
- case "assistant":
1201
- return estimateAssistantMessageTokens(message);
1202
- case "toolResult":
1203
- return estimateToolResultMessageTokens(message);
1204
- }
1205
- }
1206
-
1207
- interface ContextTokenOptions {
1208
- /** Ignore assistant usage anchors and estimate every message directly. */
1209
- ignoreAssistantUsage?: boolean;
1210
- }
1211
-
1212
- /**
1213
- * Fold one persisted message into the running context estimate for the next request.
1214
- *
1215
- * Assistant messages with valid usage anchor the full model-visible context for
1216
- * that point in the transcript, so they replace the running estimate unless the
1217
- * caller explicitly disables those anchors. All other model-visible messages are
1218
- * added incrementally using the same conservative estimation logic used before
1219
- * the first valid assistant usage appears.
1220
- *
1221
- * @param contextTokens - Running estimate before this message.
1222
- * @param message - Persisted message to fold into the estimate.
1223
- * @param options - Optional estimation behavior overrides.
1224
- * @returns Updated context-token estimate.
1225
- */
1226
- export function addMessageToContextTokens(
1227
- contextTokens: number,
1228
- message: PersistedMessage,
1229
- options?: ContextTokenOptions,
1230
- ): number {
1231
- if (message.role === "ui") {
1232
- return contextTokens;
1233
- }
1234
-
1235
- const usage = getAssistantUsage(message);
1236
- if (
1237
- !options?.ignoreAssistantUsage &&
1238
- message.role === "assistant" &&
1239
- usage &&
1240
- message.stopReason !== "aborted" &&
1241
- message.stopReason !== "error"
1242
- ) {
1243
- return calculateUsageTokens(usage);
1244
- }
1245
-
1246
- return contextTokens + estimateMessageTokens(message);
1247
- }
1248
-
1249
- /**
1250
- * Estimate the current model-visible context size for the next request.
1251
- *
1252
- * Recomputed on session-load boundaries and maintained incrementally during an
1253
- * active turn so render-time status-bar updates do not need to rescan the full
1254
- * message history.
1255
- *
1256
- * @param messages - Full persisted session history.
1257
- * @param options - Optional estimation behavior overrides.
1258
- * @returns Estimated context tokens visible to the next model request.
1259
- */
1260
- export function computeContextTokens(
1261
- messages: readonly PersistedMessage[],
1262
- options?: ContextTokenOptions,
1263
- ): number {
1264
- let contextTokens = 0;
1265
-
1266
- for (const message of messages) {
1267
- contextTokens = addMessageToContextTokens(contextTokens, message, options);
1268
- }
1269
-
1270
- return contextTokens;
1271
- }
1272
-
1273
- interface MutableConversationState {
1274
- messages: PersistedMessage[];
1275
- stats: SessionStats;
1276
- contextTokens: number;
1277
- }
1278
-
1279
- /**
1280
- * Derive the in-memory conversation snapshot for a persisted message history.
1281
- *
1282
- * @param messages - Persisted messages to expose in memory.
1283
- * @returns Message history plus derived stats and context-token estimate.
1284
- */
1285
- export function createConversationSnapshot(messages: PersistedMessage[] = []): {
1286
- messages: PersistedMessage[];
1287
- stats: SessionStats;
1288
- contextTokens: number;
1289
- } {
1290
- return {
79
+ const s = {
80
+ id,
81
+ cwd: process.cwd(),
1291
82
  messages,
1292
- stats: computeStats(messages),
1293
- contextTokens: computeContextTokens(messages),
1294
83
  };
1295
- }
1296
-
1297
- /**
1298
- * Replace the current in-memory conversation state from a message history.
1299
- *
1300
- * @param state - Mutable conversation state.
1301
- * @param messages - Replacement persisted message history.
1302
- */
1303
- export function replaceConversationState<T extends MutableConversationState>(
1304
- state: T,
1305
- messages: PersistedMessage[],
1306
- ): void {
1307
- const snapshot = createConversationSnapshot(messages);
1308
- state.messages = snapshot.messages;
1309
- state.stats = snapshot.stats;
1310
- state.contextTokens = snapshot.contextTokens;
1311
- }
1312
-
1313
- /**
1314
- * Clear the current in-memory conversation state.
1315
- *
1316
- * @param state - Mutable conversation state.
1317
- */
1318
- export function clearConversationState<T extends MutableConversationState>(
1319
- state: T,
1320
- ): void {
1321
- replaceConversationState(state, []);
1322
- }
1323
84
 
1324
- /**
1325
- * Append one persisted message to the in-memory conversation state.
1326
- *
1327
- * @param state - Mutable conversation state.
1328
- * @param message - Message to append.
1329
- */
1330
- export function appendConversationMessage<T extends MutableConversationState>(
1331
- state: T,
1332
- message: PersistedMessage,
1333
- ): void {
1334
- state.messages.push(message);
1335
- state.stats = addMessageToStats(state.stats, message);
1336
- state.contextTokens = addMessageToContextTokens(state.contextTokens, message);
85
+ await saveSession(s);
1337
86
  }