mini-coder 0.5.10 → 0.5.12

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,14 +10,29 @@
10
10
  */
11
11
 
12
12
  import { Database } from "bun:sqlite";
13
- import type {
14
- AssistantMessage,
15
- Message,
16
- ToolResultMessage,
17
- UserMessage,
18
- } from "@mariozechner/pi-ai";
13
+ import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
14
+ import {
15
+ getAssistantUsage,
16
+ isUiMessage,
17
+ type PersistedMessage,
18
+ parsePersistedMessage,
19
+ readFirstUserPreview,
20
+ type UiInfoFormat,
21
+ type UiInfoMessage,
22
+ type UiMessage,
23
+ type UiTodoMessage,
24
+ } from "./session-message.ts";
19
25
  import type { TodoItem } from "./tools.ts";
20
26
 
27
+ export type {
28
+ PersistedMessage,
29
+ UiInfoFormat,
30
+ UiInfoMessage,
31
+ UiMessage,
32
+ UiTodoMessage,
33
+ };
34
+ export { getAssistantUsage };
35
+
21
36
  // ---------------------------------------------------------------------------
22
37
  // Types
23
38
  // ---------------------------------------------------------------------------
@@ -95,41 +110,6 @@ interface AppendPromptHistoryOpts {
95
110
  sessionId?: string;
96
111
  }
97
112
 
98
- /** Rich-text format hints supported by persisted UI info messages. */
99
- export type UiInfoFormat = "markdown";
100
-
101
- /** A persisted UI-only info message shown in the conversation log. */
102
- export interface UiInfoMessage {
103
- /** Identifies this as an internal UI message. */
104
- role: "ui";
105
- /** UI message category for rendering and future behavior. */
106
- kind: "info";
107
- /** Display text shown in the conversation log. */
108
- content: string;
109
- /** Optional rich-text format hint for the content. */
110
- format?: UiInfoFormat;
111
- /** Unix timestamp in milliseconds. */
112
- timestamp: number;
113
- }
114
-
115
- /** A persisted UI-only todo snapshot shown in the conversation log. */
116
- export interface UiTodoMessage {
117
- /** Identifies this as an internal UI message. */
118
- role: "ui";
119
- /** UI message category for rendering and future behavior. */
120
- kind: "todo";
121
- /** Todo snapshot rendered in the conversation pane. */
122
- todos: TodoItem[];
123
- /** Unix timestamp in milliseconds. */
124
- timestamp: number;
125
- }
126
-
127
- /** A persisted UI-only message shown in the conversation log. */
128
- export type UiMessage = UiInfoMessage | UiTodoMessage;
129
-
130
- /** Any message persisted in session history. */
131
- export type PersistedMessage = Message | UiMessage;
132
-
133
113
  /** Options for creating a new session. */
134
114
  interface CreateSessionOpts {
135
115
  /** Working directory to scope the session to. */
@@ -166,21 +146,6 @@ type MaxTurnRow = { max_turn: number | null };
166
146
  /** Row shape for `SELECT data` queries. */
167
147
  type DataRow = { data: string };
168
148
 
169
- const EMPTY_ASSISTANT_USAGE: AssistantMessage["usage"] = {
170
- input: 0,
171
- output: 0,
172
- cacheRead: 0,
173
- cacheWrite: 0,
174
- totalTokens: 0,
175
- cost: {
176
- input: 0,
177
- output: 0,
178
- cacheRead: 0,
179
- cacheWrite: 0,
180
- total: 0,
181
- },
182
- };
183
-
184
149
  const SQLITE_BUSY_TIMEOUT_MS = 1_000;
185
150
 
186
151
  /** Row shape returned by `SELECT * FROM prompt_history`. */
@@ -338,240 +303,6 @@ export function getSession(db: Database, id: string): Session | null {
338
303
  };
339
304
  }
340
305
 
341
- /** Collapse preview text into a single readable line. */
342
- function collapsePreviewText(text: string): string | null {
343
- const collapsed = text.replace(/\s+/g, " ").trim();
344
- return collapsed.length > 0 ? collapsed : null;
345
- }
346
-
347
- function getMultipartUserPreview(
348
- content: Extract<Message, { role: "user" }>["content"],
349
- ): string | null {
350
- if (typeof content === "string") {
351
- return collapsePreviewText(content);
352
- }
353
-
354
- const text = content
355
- .filter(
356
- (block): block is Extract<(typeof content)[number], { type: "text" }> => {
357
- return block.type === "text";
358
- },
359
- )
360
- .map((block) => block.text)
361
- .join(" ");
362
-
363
- return collapsePreviewText(text);
364
- }
365
-
366
- function isTextContentBlock(
367
- value: unknown,
368
- ): value is { type: "text"; text: string } {
369
- const record = toRecord(value);
370
- return record?.type === "text" && typeof record.text === "string";
371
- }
372
-
373
- function isImageContentBlock(
374
- value: unknown,
375
- ): value is { type: "image"; data: string; mimeType: string } {
376
- const record = toRecord(value);
377
- return (
378
- record?.type === "image" &&
379
- typeof record.data === "string" &&
380
- typeof record.mimeType === "string"
381
- );
382
- }
383
-
384
- function isThinkingContentBlock(
385
- value: unknown,
386
- ): value is Extract<AssistantMessage["content"][number], { type: "thinking" }> {
387
- const record = toRecord(value);
388
- return record?.type === "thinking" && typeof record.thinking === "string";
389
- }
390
-
391
- function isToolCallContentBlock(
392
- value: unknown,
393
- ): value is Extract<AssistantMessage["content"][number], { type: "toolCall" }> {
394
- const record = toRecord(value);
395
- return (
396
- record?.type === "toolCall" &&
397
- typeof record.id === "string" &&
398
- typeof record.name === "string" &&
399
- toRecord(record.arguments) !== null
400
- );
401
- }
402
-
403
- function isAssistantUsage(value: unknown): value is AssistantMessage["usage"] {
404
- const usageRecord = toRecord(value);
405
- const costRecord = toRecord(usageRecord?.cost);
406
- return (
407
- usageRecord !== null &&
408
- costRecord !== null &&
409
- readFiniteNumber(usageRecord, "input") !== null &&
410
- readFiniteNumber(usageRecord, "output") !== null &&
411
- readFiniteNumber(usageRecord, "cacheRead") !== null &&
412
- readFiniteNumber(usageRecord, "cacheWrite") !== null &&
413
- readFiniteNumber(usageRecord, "totalTokens") !== null &&
414
- readFiniteNumber(costRecord, "input") !== null &&
415
- readFiniteNumber(costRecord, "output") !== null &&
416
- readFiniteNumber(costRecord, "cacheRead") !== null &&
417
- readFiniteNumber(costRecord, "cacheWrite") !== null &&
418
- readFiniteNumber(costRecord, "total") !== null
419
- );
420
- }
421
-
422
- function isStopReason(value: unknown): value is AssistantMessage["stopReason"] {
423
- return (
424
- value === "stop" ||
425
- value === "length" ||
426
- value === "toolUse" ||
427
- value === "error" ||
428
- value === "aborted"
429
- );
430
- }
431
-
432
- function isUserMessageRecord(value: unknown): value is UserMessage {
433
- const record = toRecord(value);
434
- if (!record || record.role !== "user") {
435
- return false;
436
- }
437
-
438
- return (
439
- readFiniteNumber(record, "timestamp") !== null &&
440
- (typeof record.content === "string" ||
441
- (Array.isArray(record.content) &&
442
- record.content.every(
443
- (block) => isTextContentBlock(block) || isImageContentBlock(block),
444
- )))
445
- );
446
- }
447
-
448
- function parseAssistantMessageRecord(value: unknown): AssistantMessage | null {
449
- const record = toRecord(value);
450
- if (!record || record.role !== "assistant") {
451
- return null;
452
- }
453
-
454
- const timestamp = readFiniteNumber(record, "timestamp");
455
- if (
456
- !Array.isArray(record.content) ||
457
- !record.content.every(
458
- (block) =>
459
- isTextContentBlock(block) ||
460
- isThinkingContentBlock(block) ||
461
- isToolCallContentBlock(block),
462
- ) ||
463
- typeof record.api !== "string" ||
464
- typeof record.provider !== "string" ||
465
- typeof record.model !== "string" ||
466
- !isStopReason(record.stopReason) ||
467
- (record.errorMessage !== undefined &&
468
- typeof record.errorMessage !== "string") ||
469
- timestamp === null
470
- ) {
471
- return null;
472
- }
473
-
474
- return {
475
- role: "assistant",
476
- content: record.content,
477
- api: record.api,
478
- provider: record.provider,
479
- model: record.model,
480
- usage: isAssistantUsage(record.usage)
481
- ? record.usage
482
- : structuredClone(EMPTY_ASSISTANT_USAGE),
483
- stopReason: record.stopReason,
484
- ...(typeof record.errorMessage === "string"
485
- ? { errorMessage: record.errorMessage }
486
- : {}),
487
- timestamp,
488
- };
489
- }
490
-
491
- function isToolResultMessageRecord(value: unknown): value is ToolResultMessage {
492
- const record = toRecord(value);
493
- if (!record || record.role !== "toolResult") {
494
- return false;
495
- }
496
-
497
- return (
498
- typeof record.toolCallId === "string" &&
499
- typeof record.toolName === "string" &&
500
- typeof record.isError === "boolean" &&
501
- Array.isArray(record.content) &&
502
- record.content.every(
503
- (block) => isTextContentBlock(block) || isImageContentBlock(block),
504
- ) &&
505
- readFiniteNumber(record, "timestamp") !== null
506
- );
507
- }
508
-
509
- function isUiMessageRecord(value: unknown): value is UiMessage {
510
- const record = toRecord(value);
511
- if (!record || record.role !== "ui") {
512
- return false;
513
- }
514
-
515
- const timestamp = readFiniteNumber(record, "timestamp");
516
- if (timestamp === null) {
517
- return false;
518
- }
519
-
520
- if (record.kind === "info") {
521
- return (
522
- typeof record.content === "string" &&
523
- (record.format === undefined || record.format === "markdown")
524
- );
525
- }
526
-
527
- return (
528
- record.kind === "todo" &&
529
- Array.isArray(record.todos) &&
530
- record.todos.every(
531
- (todo) =>
532
- typeof todo === "object" &&
533
- todo !== null &&
534
- typeof (todo as { content?: unknown }).content === "string" &&
535
- ((todo as { status?: unknown }).status === "pending" ||
536
- (todo as { status?: unknown }).status === "in_progress" ||
537
- (todo as { status?: unknown }).status === "completed"),
538
- )
539
- );
540
- }
541
-
542
- function parsePersistedMessage(data: string): PersistedMessage | null {
543
- let parsed: unknown;
544
- try {
545
- parsed = JSON.parse(data) as unknown;
546
- } catch {
547
- return null;
548
- }
549
-
550
- if (
551
- isUserMessageRecord(parsed) ||
552
- isToolResultMessageRecord(parsed) ||
553
- isUiMessageRecord(parsed)
554
- ) {
555
- return parsed;
556
- }
557
-
558
- return parseAssistantMessageRecord(parsed);
559
- }
560
-
561
- /** Read the first-user preview cached by the session-list query. */
562
- function readFirstUserPreview(messageData: string | null): string | null {
563
- if (!messageData) {
564
- return null;
565
- }
566
-
567
- const message = parsePersistedMessage(messageData);
568
- if (!message || message.role !== "user") {
569
- return null;
570
- }
571
-
572
- return getMultipartUserPreview(message.content);
573
- }
574
-
575
306
  /**
576
307
  * List sessions for a working directory, most recently updated first.
577
308
  *
@@ -668,16 +399,6 @@ export function createUiTodoMessage(todos: readonly TodoItem[]): UiTodoMessage {
668
399
  };
669
400
  }
670
401
 
671
- /**
672
- * Check whether a persisted message is a UI-only message.
673
- *
674
- * @param message - Message to inspect.
675
- * @returns `true` when the message is a {@link UiMessage}.
676
- */
677
- function isUiMessage(message: PersistedMessage): message is UiMessage {
678
- return message.role === "ui";
679
- }
680
-
681
402
  /**
682
403
  * Filter persisted session history down to model-visible pi-ai messages.
683
404
  *
@@ -692,87 +413,6 @@ export function filterModelMessages(
692
413
  );
693
414
  }
694
415
 
695
- function toRecord(value: unknown): Record<string, unknown> | null {
696
- return typeof value === "object" && value !== null && !Array.isArray(value)
697
- ? (value as Record<string, unknown>)
698
- : null;
699
- }
700
-
701
- function readFiniteNumber(
702
- record: Record<string, unknown>,
703
- key: string,
704
- ): number | null {
705
- const value = record[key];
706
- return typeof value === "number" && Number.isFinite(value) ? value : null;
707
- }
708
-
709
- /**
710
- * Return an assistant message's usage when the persisted shape is valid.
711
- *
712
- * Session rows are treated as untrusted at runtime because older builds or
713
- * external tooling may have stored assistant messages without a `usage`
714
- * payload. Invalid or missing usage is ignored instead of crashing session
715
- * loading or stats calculations.
716
- *
717
- * @param message - Message to inspect.
718
- * @returns The assistant usage payload, or `null` when it is missing/invalid.
719
- */
720
- export function getAssistantUsage(
721
- message: PersistedMessage | Message,
722
- ): AssistantMessage["usage"] | null {
723
- if (message.role !== "assistant") {
724
- return null;
725
- }
726
-
727
- const messageRecord = toRecord(message);
728
- const usageRecord = toRecord(messageRecord?.usage);
729
- const costRecord = toRecord(usageRecord?.cost);
730
- if (!usageRecord || !costRecord) {
731
- return null;
732
- }
733
-
734
- const input = readFiniteNumber(usageRecord, "input");
735
- const output = readFiniteNumber(usageRecord, "output");
736
- const cacheRead = readFiniteNumber(usageRecord, "cacheRead");
737
- const cacheWrite = readFiniteNumber(usageRecord, "cacheWrite");
738
- const totalTokens = readFiniteNumber(usageRecord, "totalTokens");
739
- const costInput = readFiniteNumber(costRecord, "input");
740
- const costOutput = readFiniteNumber(costRecord, "output");
741
- const costCacheRead = readFiniteNumber(costRecord, "cacheRead");
742
- const costCacheWrite = readFiniteNumber(costRecord, "cacheWrite");
743
- const costTotal = readFiniteNumber(costRecord, "total");
744
-
745
- if (
746
- input === null ||
747
- output === null ||
748
- cacheRead === null ||
749
- cacheWrite === null ||
750
- totalTokens === null ||
751
- costInput === null ||
752
- costOutput === null ||
753
- costCacheRead === null ||
754
- costCacheWrite === null ||
755
- costTotal === null
756
- ) {
757
- return null;
758
- }
759
-
760
- return {
761
- input,
762
- output,
763
- cacheRead,
764
- cacheWrite,
765
- totalTokens,
766
- cost: {
767
- input: costInput,
768
- output: costOutput,
769
- cacheRead: costCacheRead,
770
- cacheWrite: costCacheWrite,
771
- total: costTotal,
772
- },
773
- };
774
- }
775
-
776
416
  function runInImmediateTransaction<T>(db: Database, callback: () => T): T {
777
417
  if (db.inTransaction) {
778
418
  return callback();
@@ -1080,17 +720,6 @@ export function forkSession(db: Database, sourceId: string): Session {
1080
720
  // Stats
1081
721
  // ---------------------------------------------------------------------------
1082
722
 
1083
- /**
1084
- * Compute cumulative token and cost statistics from a message history.
1085
- *
1086
- * Iterates over the messages, summing `usage` fields from assistant messages
1087
- * only (user and tool-result messages do not carry usage data). This is
1088
- * designed to be called once on session load, with the result maintained
1089
- * in-memory via a running accumulator during the session.
1090
- *
1091
- * @param messages - The full persisted message history for a session.
1092
- * @returns Aggregated {@link SessionStats}.
1093
- */
1094
723
  /**
1095
724
  * Add one persisted message's assistant usage to cumulative session stats.
1096
725
  *
@@ -1117,14 +746,25 @@ export function addMessageToStats(
1117
746
  };
1118
747
  }
1119
748
 
1120
- export function computeStats(
1121
- messages: readonly PersistedMessage[],
1122
- ): SessionStats {
1123
- let stats: SessionStats = {
749
+ /** Create a zeroed cumulative session-stats object. */
750
+ export function createEmptySessionStats(): SessionStats {
751
+ return {
1124
752
  totalInput: 0,
1125
753
  totalOutput: 0,
1126
754
  totalCost: 0,
1127
755
  };
756
+ }
757
+
758
+ /**
759
+ * Compute cumulative token and cost statistics from a message history.
760
+ *
761
+ * @param messages - Full persisted session history.
762
+ * @returns Aggregated cumulative session stats.
763
+ */
764
+ export function computeStats(
765
+ messages: readonly PersistedMessage[],
766
+ ): SessionStats {
767
+ let stats = createEmptySessionStats();
1128
768
 
1129
769
  for (const message of messages) {
1130
770
  stats = addMessageToStats(stats, message);
@@ -1282,3 +922,69 @@ export function computeContextTokens(
1282
922
 
1283
923
  return contextTokens;
1284
924
  }
925
+
926
+ interface MutableConversationState {
927
+ messages: PersistedMessage[];
928
+ stats: SessionStats;
929
+ contextTokens: number;
930
+ }
931
+
932
+ /**
933
+ * Derive the in-memory conversation snapshot for a persisted message history.
934
+ *
935
+ * @param messages - Persisted messages to expose in memory.
936
+ * @returns Message history plus derived stats and context-token estimate.
937
+ */
938
+ export function createConversationSnapshot(messages: PersistedMessage[] = []): {
939
+ messages: PersistedMessage[];
940
+ stats: SessionStats;
941
+ contextTokens: number;
942
+ } {
943
+ return {
944
+ messages,
945
+ stats: computeStats(messages),
946
+ contextTokens: computeContextTokens(messages),
947
+ };
948
+ }
949
+
950
+ /**
951
+ * Replace the current in-memory conversation state from a message history.
952
+ *
953
+ * @param state - Mutable conversation state.
954
+ * @param messages - Replacement persisted message history.
955
+ */
956
+ export function replaceConversationState<T extends MutableConversationState>(
957
+ state: T,
958
+ messages: PersistedMessage[],
959
+ ): void {
960
+ const snapshot = createConversationSnapshot(messages);
961
+ state.messages = snapshot.messages;
962
+ state.stats = snapshot.stats;
963
+ state.contextTokens = snapshot.contextTokens;
964
+ }
965
+
966
+ /**
967
+ * Clear the current in-memory conversation state.
968
+ *
969
+ * @param state - Mutable conversation state.
970
+ */
971
+ export function clearConversationState<T extends MutableConversationState>(
972
+ state: T,
973
+ ): void {
974
+ replaceConversationState(state, []);
975
+ }
976
+
977
+ /**
978
+ * Append one persisted message to the in-memory conversation state.
979
+ *
980
+ * @param state - Mutable conversation state.
981
+ * @param message - Message to append.
982
+ */
983
+ export function appendConversationMessage<T extends MutableConversationState>(
984
+ state: T,
985
+ message: PersistedMessage,
986
+ ): void {
987
+ state.messages.push(message);
988
+ state.stats = addMessageToStats(state.stats, message);
989
+ state.contextTokens = addMessageToContextTokens(state.contextTokens, message);
990
+ }
package/src/settings.ts CHANGED
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { dirname } from "node:path";
12
12
  import type { ThinkingLevel } from "@mariozechner/pi-ai";
13
13
  import { getErrorMessage } from "./errors.ts";
14
+ import { readBoolean, readString, toRecord } from "./shared.ts";
14
15
 
15
16
  /** A user-configured OpenAI-compatible provider endpoint. */
16
17
  export interface CustomProvider {
@@ -164,24 +165,27 @@ export function resolveStartupSettings(
164
165
  * @returns Sanitized settings.
165
166
  */
166
167
  function sanitizeSettings(value: unknown): UserSettings {
167
- if (value == null || typeof value !== "object" || Array.isArray(value)) {
168
+ const candidate = toRecord(value);
169
+ if (!candidate) {
168
170
  return {};
169
171
  }
170
172
 
171
- const candidate = value as Record<string, unknown>;
172
173
  const settings: UserSettings = {};
174
+ const defaultModel = readString(candidate, "defaultModel");
175
+ const showReasoning = readBoolean(candidate, "showReasoning");
176
+ const verbose = readBoolean(candidate, "verbose");
173
177
 
174
- if (typeof candidate.defaultModel === "string") {
175
- settings.defaultModel = candidate.defaultModel;
178
+ if (defaultModel !== null) {
179
+ settings.defaultModel = defaultModel;
176
180
  }
177
181
  if (isThinkingLevel(candidate.defaultEffort)) {
178
182
  settings.defaultEffort = candidate.defaultEffort;
179
183
  }
180
- if (typeof candidate.showReasoning === "boolean") {
181
- settings.showReasoning = candidate.showReasoning;
184
+ if (showReasoning !== null) {
185
+ settings.showReasoning = showReasoning;
182
186
  }
183
- if (typeof candidate.verbose === "boolean") {
184
- settings.verbose = candidate.verbose;
187
+ if (verbose !== null) {
188
+ settings.verbose = verbose;
185
189
  }
186
190
 
187
191
  const customProviders = sanitizeCustomProviders(candidate.customProviders);
@@ -194,22 +198,22 @@ function sanitizeSettings(value: unknown): UserSettings {
194
198
 
195
199
  /** Try to parse a single custom provider entry, returning null on failure. */
196
200
  function parseCustomProvider(item: unknown): CustomProvider | null {
197
- if (item == null || typeof item !== "object" || Array.isArray(item)) {
201
+ const candidate = toRecord(item);
202
+ if (!candidate) {
198
203
  return null;
199
204
  }
200
205
 
201
- const candidate = item as Record<string, unknown>;
202
- const name = typeof candidate.name === "string" ? candidate.name.trim() : "";
203
- const baseUrl =
204
- typeof candidate.baseUrl === "string" ? candidate.baseUrl.trim() : "";
206
+ const name = readString(candidate, "name")?.trim() ?? "";
207
+ const baseUrl = readString(candidate, "baseUrl")?.trim() ?? "";
205
208
 
206
209
  if (!name || !baseUrl) {
207
210
  return null;
208
211
  }
209
212
 
210
213
  const entry: CustomProvider = { name, baseUrl };
211
- if (typeof candidate.apiKey === "string") {
212
- entry.apiKey = candidate.apiKey;
214
+ const apiKey = readString(candidate, "apiKey");
215
+ if (apiKey !== null) {
216
+ entry.apiKey = apiKey;
213
217
  }
214
218
  return entry;
215
219
  }
package/src/shared.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared runtime record/primitive readers used across the app.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /** Convert an unknown value into a plain record, rejecting arrays and null. */
8
+ export function toRecord(value: unknown): Record<string, unknown> | null {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value)
10
+ ? (value as Record<string, unknown>)
11
+ : null;
12
+ }
13
+
14
+ /** Read a string field from a record, returning null for missing or invalid values. */
15
+ export function readString(
16
+ record: Record<string, unknown>,
17
+ key: string,
18
+ ): string | null {
19
+ const value = record[key];
20
+ return typeof value === "string" ? value : null;
21
+ }
22
+
23
+ /** Read a boolean field from a record, returning null for missing or invalid values. */
24
+ export function readBoolean(
25
+ record: Record<string, unknown>,
26
+ key: string,
27
+ ): boolean | null {
28
+ const value = record[key];
29
+ return typeof value === "boolean" ? value : null;
30
+ }
31
+
32
+ /** Read a finite numeric field from a record, returning null for missing or invalid values. */
33
+ export function readFiniteNumber(
34
+ record: Record<string, unknown>,
35
+ key: string,
36
+ ): number | null {
37
+ const value = record[key];
38
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
39
+ }