mini-coder 0.5.12 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.ts CHANGED
@@ -10,12 +10,17 @@
10
10
  */
11
11
 
12
12
  import { Database } from "bun:sqlite";
13
- import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
13
+ import type {
14
+ AssistantMessage,
15
+ Message,
16
+ UserMessage,
17
+ } from "@mariozechner/pi-ai";
14
18
  import {
15
19
  getAssistantUsage,
16
20
  isUiMessage,
17
21
  type PersistedMessage,
18
22
  parsePersistedMessage,
23
+ readAssistantUsage,
19
24
  readFirstUserPreview,
20
25
  type UiInfoFormat,
21
26
  type UiInfoMessage,
@@ -86,6 +91,28 @@ export interface SessionStats {
86
91
  totalCost: number;
87
92
  }
88
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
+
89
116
  /** A raw submitted prompt stored for global input-history search. */
90
117
  interface PromptHistoryEntry {
91
118
  /** Monotonic row id. */
@@ -143,6 +170,28 @@ type SessionListRow = SessionRow & {
143
170
  /** Row shape for `SELECT MAX(turn)` queries. */
144
171
  type MaxTurnRow = { max_turn: number | null };
145
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
+
146
195
  /** Row shape for `SELECT data` queries. */
147
196
  type DataRow = { data: string };
148
197
 
@@ -178,6 +227,15 @@ const SQL = {
178
227
  `,
179
228
  maxTurn: "SELECT MAX(turn) as max_turn FROM messages WHERE session_id = ?",
180
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",
181
239
  listPromptHistory:
182
240
  "SELECT * FROM prompt_history ORDER BY created_at DESC, id DESC LIMIT ?",
183
241
  } as const;
@@ -205,6 +263,17 @@ CREATE TABLE IF NOT EXISTS messages (
205
263
 
206
264
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, turn);
207
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
+
208
277
  CREATE TABLE IF NOT EXISTS prompt_history (
209
278
  id INTEGER PRIMARY KEY AUTOINCREMENT,
210
279
  text TEXT NOT NULL,
@@ -238,12 +307,26 @@ CREATE INDEX IF NOT EXISTS idx_prompt_history_created_at ON prompt_history(creat
238
307
  * db.close();
239
308
  * ```
240
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
+ }
320
+ }
321
+ }
322
+
241
323
  export function openDatabase(path: string): Database {
242
324
  const db = new Database(path);
243
325
  db.run("PRAGMA journal_mode = WAL");
244
326
  db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
245
327
  db.run("PRAGMA foreign_keys = ON");
246
328
  db.exec(SCHEMA);
329
+ ensureSessionCompactionUsageColumn(db);
247
330
  return db;
248
331
  }
249
332
 
@@ -438,6 +521,175 @@ function runInImmediateTransaction<T>(db: Database, callback: () => T): T {
438
521
  }
439
522
  }
440
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
+ } catch {
539
+ return null;
540
+ }
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
+
441
693
  /**
442
694
  * Append a UI-only message to a session's history.
443
695
  *
@@ -661,14 +913,30 @@ export function truncatePromptHistory(db: Database, keep: number): void {
661
913
  * @returns `true` if a turn was removed, `false` if the session had no messages.
662
914
  */
663
915
  export function undoLastTurn(db: Database, sessionId: string): boolean {
664
- const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
665
- if (!row?.max_turn) return false;
916
+ return runInImmediateTransaction(db, () => {
917
+ const row = db.query<MaxTurnRow, [string]>(SQL.maxTurn).get(sessionId);
918
+ if (!row?.max_turn) {
919
+ return false;
920
+ }
666
921
 
667
- db.run("DELETE FROM messages WHERE session_id = ? AND turn = ?", [
668
- sessionId,
669
- row.max_turn,
670
- ]);
671
- return true;
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
+ });
672
940
  }
673
941
 
674
942
  // ---------------------------------------------------------------------------
@@ -679,8 +947,9 @@ export function undoLastTurn(db: Database, sessionId: string): boolean {
679
947
  * Fork a session into a new independent copy.
680
948
  *
681
949
  * Creates a new session with the same `cwd`, `model`, and `effort` as the
682
- * source, then copies all messages preserving their turn numbers. The new
683
- * session's `forkedFrom` field points back to the source. The original
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
684
953
  * session is not modified.
685
954
  *
686
955
  * @param db - Open database handle.
@@ -692,34 +961,83 @@ export function forkSession(db: Database, sourceId: string): Session {
692
961
  const source = getSession(db, sourceId);
693
962
  if (!source) throw new Error(`Session not found: ${sourceId}`);
694
963
 
695
- const id = generateId();
696
- const now = Date.now();
964
+ return runInImmediateTransaction(db, () => {
965
+ const id = generateId();
966
+ const now = Date.now();
697
967
 
698
- db.run(
699
- "INSERT INTO sessions (id, cwd, model, effort, forked_from, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
700
- [id, source.cwd, source.model, source.effort, sourceId, now, now],
701
- );
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
+ );
702
972
 
703
- db.run(
704
- "INSERT INTO messages (session_id, turn, data, created_at) SELECT ?, turn, data, created_at FROM messages WHERE session_id = ? ORDER BY id",
705
- [id, sourceId],
706
- );
973
+ const sourceMessageIds = listMessageIds(db, sourceId);
707
974
 
708
- return {
709
- id,
710
- cwd: source.cwd,
711
- model: source.model,
712
- effort: source.effort,
713
- forkedFrom: sourceId,
714
- createdAt: now,
715
- updatedAt: now,
716
- };
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
+ });
717
1024
  }
718
1025
 
719
1026
  // ---------------------------------------------------------------------------
720
1027
  // Stats
721
1028
  // ---------------------------------------------------------------------------
722
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
+
723
1041
  /**
724
1042
  * Add one persisted message's assistant usage to cumulative session stats.
725
1043
  *
@@ -735,15 +1053,7 @@ export function addMessageToStats(
735
1053
  message: PersistedMessage,
736
1054
  ): SessionStats {
737
1055
  const usage = getAssistantUsage(message);
738
- if (!usage) {
739
- return stats;
740
- }
741
-
742
- return {
743
- totalInput: stats.totalInput + usage.input,
744
- totalOutput: stats.totalOutput + usage.output,
745
- totalCost: stats.totalCost + usage.cost.total,
746
- };
1056
+ return usage ? addUsageToStats(stats, usage) : stats;
747
1057
  }
748
1058
 
749
1059
  /** Create a zeroed cumulative session-stats object. */
@@ -773,6 +1083,32 @@ export function computeStats(
773
1083
  return stats;
774
1084
  }
775
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
+
776
1112
  // ---------------------------------------------------------------------------
777
1113
  // Context estimation
778
1114
  // ---------------------------------------------------------------------------
@@ -868,21 +1204,29 @@ function estimateMessageTokens(message: Message): number {
868
1204
  }
869
1205
  }
870
1206
 
1207
+ interface ContextTokenOptions {
1208
+ /** Ignore assistant usage anchors and estimate every message directly. */
1209
+ ignoreAssistantUsage?: boolean;
1210
+ }
1211
+
871
1212
  /**
872
1213
  * Fold one persisted message into the running context estimate for the next request.
873
1214
  *
874
1215
  * Assistant messages with valid usage anchor the full model-visible context for
875
- * that point in the transcript, so they replace the running estimate. All other
876
- * model-visible messages are added incrementally using the same conservative
877
- * estimation logic used before the first valid assistant usage appears.
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.
878
1220
  *
879
1221
  * @param contextTokens - Running estimate before this message.
880
1222
  * @param message - Persisted message to fold into the estimate.
1223
+ * @param options - Optional estimation behavior overrides.
881
1224
  * @returns Updated context-token estimate.
882
1225
  */
883
1226
  export function addMessageToContextTokens(
884
1227
  contextTokens: number,
885
1228
  message: PersistedMessage,
1229
+ options?: ContextTokenOptions,
886
1230
  ): number {
887
1231
  if (message.role === "ui") {
888
1232
  return contextTokens;
@@ -890,6 +1234,7 @@ export function addMessageToContextTokens(
890
1234
 
891
1235
  const usage = getAssistantUsage(message);
892
1236
  if (
1237
+ !options?.ignoreAssistantUsage &&
893
1238
  message.role === "assistant" &&
894
1239
  usage &&
895
1240
  message.stopReason !== "aborted" &&
@@ -909,15 +1254,17 @@ export function addMessageToContextTokens(
909
1254
  * message history.
910
1255
  *
911
1256
  * @param messages - Full persisted session history.
1257
+ * @param options - Optional estimation behavior overrides.
912
1258
  * @returns Estimated context tokens visible to the next model request.
913
1259
  */
914
1260
  export function computeContextTokens(
915
1261
  messages: readonly PersistedMessage[],
1262
+ options?: ContextTokenOptions,
916
1263
  ): number {
917
1264
  let contextTokens = 0;
918
1265
 
919
1266
  for (const message of messages) {
920
- contextTokens = addMessageToContextTokens(contextTokens, message);
1267
+ contextTokens = addMessageToContextTokens(contextTokens, message, options);
921
1268
  }
922
1269
 
923
1270
  return contextTokens;