mini-coder 0.5.0 → 0.5.2

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/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
- import { join } from "node:path";
13
+ import { dirname, join } from "node:path";
14
14
  import { isDeepStrictEqual } from "node:util";
15
15
  import type {
16
16
  KnownProvider,
@@ -28,6 +28,7 @@ import {
28
28
  resolveHeadlessPrompt,
29
29
  shouldUseHeadlessMode,
30
30
  } from "./cli.ts";
31
+ import { getErrorMessage } from "./errors.ts";
31
32
  import { type GitState, getGitState } from "./git.ts";
32
33
  import { canonicalizePath } from "./paths.ts";
33
34
  import {
@@ -56,6 +57,7 @@ import {
56
57
  truncateSessions,
57
58
  } from "./session.ts";
58
59
  import {
60
+ type CustomProvider,
59
61
  loadSettings,
60
62
  resolveStartupSettings,
61
63
  type UserSettings,
@@ -70,6 +72,7 @@ import {
70
72
  readImageTool,
71
73
  shellTool,
72
74
  } from "./tools.ts";
75
+ import { resolveAppVersionLabel } from "./version.ts";
73
76
 
74
77
  // ---------------------------------------------------------------------------
75
78
  // Constants
@@ -103,19 +106,36 @@ export const MAX_PROMPT_HISTORY = 1_000;
103
106
  // ---------------------------------------------------------------------------
104
107
 
105
108
  /** Load saved OAuth credentials from disk. */
106
- function loadOAuthCredentials(): Record<string, OAuthCredentials> {
107
- if (!existsSync(AUTH_PATH)) return {};
109
+ function loadOAuthCredentials(
110
+ path = AUTH_PATH,
111
+ ): Record<string, OAuthCredentials> {
112
+ if (!existsSync(path)) return {};
113
+
114
+ let parsed: unknown;
108
115
  try {
109
- return JSON.parse(readFileSync(AUTH_PATH, "utf-8"));
110
- } catch {
111
- return {};
116
+ parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
117
+ } catch (error) {
118
+ throw new Error(
119
+ `Failed to read OAuth credentials ${path}: ${getErrorMessage(error)}`,
120
+ );
121
+ }
122
+
123
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
124
+ throw new Error(
125
+ `Failed to read OAuth credentials ${path}: expected a JSON object`,
126
+ );
112
127
  }
128
+
129
+ return parsed as Record<string, OAuthCredentials>;
113
130
  }
114
131
 
115
132
  /** Save OAuth credentials to disk. */
116
- function saveOAuthCredentials(creds: Record<string, OAuthCredentials>): void {
117
- mkdirSync(DATA_DIR, { recursive: true });
118
- writeFileSync(AUTH_PATH, JSON.stringify(creds, null, 2), "utf-8");
133
+ function saveOAuthCredentials(
134
+ creds: Record<string, OAuthCredentials>,
135
+ path = AUTH_PATH,
136
+ ): void {
137
+ mkdirSync(dirname(path), { recursive: true });
138
+ writeFileSync(path, JSON.stringify(creds, null, 2), "utf-8");
119
139
  }
120
140
 
121
141
  /** Return whether refreshed OAuth credentials differ from the persisted value. */
@@ -190,6 +210,101 @@ async function discoverProviders(): Promise<DiscoveryResult> {
190
210
  return { providers, oauthCredentials };
191
211
  }
192
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // Custom provider discovery
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /** Timeout for custom provider model discovery requests. */
218
+ const CUSTOM_PROVIDER_TIMEOUT_MS = 3_000;
219
+
220
+ /** Default API key for custom providers that don't require authentication. */
221
+ const CUSTOM_PROVIDER_DEFAULT_KEY = "no-key";
222
+
223
+ /** Result of custom provider discovery. */
224
+ interface CustomDiscoveryResult {
225
+ /** Discovered models from all reachable custom providers. */
226
+ models: Model<"openai-completions">[];
227
+ /** Provider name → API key for discovered providers. */
228
+ providers: Map<string, string>;
229
+ /** Warning messages for unreachable or invalid providers. */
230
+ warnings: string[];
231
+ }
232
+
233
+ /**
234
+ * Discover models from user-configured OpenAI-compatible endpoints.
235
+ *
236
+ * Queries each provider's `/models` endpoint and constructs pi-ai Model
237
+ * objects from the response. Unreachable endpoints produce a warning
238
+ * instead of failing startup.
239
+ *
240
+ * @param customProviders - Configured custom provider entries.
241
+ * @param builtInProviderNames - Names of built-in providers (to detect collisions).
242
+ * @returns Discovered models, provider credentials, and warnings.
243
+ */
244
+ export async function discoverCustomProviders(
245
+ customProviders: readonly CustomProvider[],
246
+ builtInProviderNames: ReadonlySet<string>,
247
+ ): Promise<CustomDiscoveryResult> {
248
+ const models: Model<"openai-completions">[] = [];
249
+ const providers = new Map<string, string>();
250
+ const warnings: string[] = [];
251
+
252
+ for (const entry of customProviders) {
253
+ if (builtInProviderNames.has(entry.name)) {
254
+ warnings.push(
255
+ `Custom provider "${entry.name}" skipped: name conflicts with a built-in provider.`,
256
+ );
257
+ continue;
258
+ }
259
+
260
+ const apiKey = entry.apiKey ?? CUSTOM_PROVIDER_DEFAULT_KEY;
261
+ const modelsUrl = `${entry.baseUrl}/models`;
262
+
263
+ try {
264
+ const response = await fetch(modelsUrl, {
265
+ signal: AbortSignal.timeout(CUSTOM_PROVIDER_TIMEOUT_MS),
266
+ });
267
+
268
+ if (!response.ok) {
269
+ warnings.push(
270
+ `Custom provider "${entry.name}": ${response.status} ${response.statusText} (${modelsUrl})`,
271
+ );
272
+ continue;
273
+ }
274
+
275
+ const body = (await response.json()) as {
276
+ data?: { id: string }[];
277
+ };
278
+ const modelList = body.data ?? [];
279
+
280
+ for (const item of modelList) {
281
+ if (typeof item.id !== "string" || !item.id) continue;
282
+
283
+ models.push({
284
+ id: item.id,
285
+ name: item.id,
286
+ api: "openai-completions",
287
+ provider: entry.name,
288
+ baseUrl: entry.baseUrl,
289
+ reasoning: false,
290
+ input: ["text"],
291
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
292
+ contextWindow: 131072,
293
+ maxTokens: 8192,
294
+ });
295
+ }
296
+
297
+ providers.set(entry.name, apiKey);
298
+ } catch (error) {
299
+ warnings.push(
300
+ `Custom provider "${entry.name}": ${getErrorMessage(error)} (${modelsUrl})`,
301
+ );
302
+ }
303
+ }
304
+
305
+ return { models, providers, warnings };
306
+ }
307
+
193
308
  // ---------------------------------------------------------------------------
194
309
  // Model selection
195
310
  // ---------------------------------------------------------------------------
@@ -419,8 +534,12 @@ export interface AppState {
419
534
  plugins: LoadedPlugin[];
420
535
  /** Active theme (default + plugin overrides). */
421
536
  theme: Theme;
537
+ /** Version label shown in the empty conversation banner. */
538
+ versionLabel: string;
422
539
  /** Current git state (null if not in a repo). */
423
540
  git: GitState | null;
541
+ /** Optional git state loader override used by tests. */
542
+ loadGitState?: (cwd: string) => Promise<GitState | null>;
424
543
  /** Available provider credentials (provider → API key). */
425
544
  providers: Map<string, string>;
426
545
  /** OAuth credentials on disk. */
@@ -443,6 +562,10 @@ export interface AppState {
443
562
  showReasoning: boolean;
444
563
  /** Whether to show full (un-truncated) tool output. */
445
564
  verbose: boolean;
565
+ /** Models discovered from custom OpenAI-compatible providers. */
566
+ customModels: Model<string>[];
567
+ /** Warnings from startup (e.g. unreachable custom providers). */
568
+ startupWarnings: string[];
446
569
  }
447
570
 
448
571
  // ---------------------------------------------------------------------------
@@ -461,7 +584,21 @@ export async function init(): Promise<AppState> {
461
584
 
462
585
  // Load user settings and resolve startup defaults
463
586
  const settings = loadSettings(SETTINGS_PATH);
464
- const availableModels = listAvailableModels(providers);
587
+
588
+ // Discover custom providers from settings
589
+ const builtInProviderNames = new Set(providers.keys());
590
+ const customResult = await discoverCustomProviders(
591
+ settings.customProviders ?? [],
592
+ builtInProviderNames,
593
+ );
594
+
595
+ // Merge custom provider credentials
596
+ for (const [name, key] of customResult.providers) {
597
+ providers.set(name, key);
598
+ }
599
+
600
+ const builtInModels = listAvailableModels(providers);
601
+ const availableModels = [...builtInModels, ...customResult.models];
465
602
  const startup = resolveStartupSettings(
466
603
  settings,
467
604
  availableModels.map((model) => `${model.provider}/${model.id}`),
@@ -488,6 +625,7 @@ export async function init(): Promise<AppState> {
488
625
  skills: promptContext.skills,
489
626
  plugins: promptContext.plugins,
490
627
  theme: promptContext.theme,
628
+ versionLabel: resolveAppVersionLabel(),
491
629
  git: promptContext.git,
492
630
  providers,
493
631
  oauthCredentials,
@@ -500,6 +638,8 @@ export async function init(): Promise<AppState> {
500
638
  activeTurnPromise: null,
501
639
  showReasoning: startup.showReasoning,
502
640
  verbose: startup.verbose,
641
+ customModels: customResult.models,
642
+ startupWarnings: customResult.warnings,
503
643
  };
504
644
  }
505
645
 
@@ -572,7 +712,7 @@ export function ensureSession(
572
712
  * for, suitable for the `/model` selector.
573
713
  */
574
714
  export function getAvailableModels(state: AppState): Model<string>[] {
575
- return listAvailableModels(state.providers);
715
+ return [...listAvailableModels(state.providers), ...state.customModels];
576
716
  }
577
717
 
578
718
  /** Clean up resources on shutdown. */
package/src/prompt.ts CHANGED
@@ -99,10 +99,15 @@ function readAgentsMdFile(dir: string): AgentsMdFile | null {
99
99
  if (!existsSync(filePath)) {
100
100
  return null;
101
101
  }
102
- return {
103
- path: filePath,
104
- content: readFileSync(filePath, "utf-8"),
105
- };
102
+
103
+ try {
104
+ return {
105
+ path: filePath,
106
+ content: readFileSync(filePath, "utf-8"),
107
+ };
108
+ } catch {
109
+ return null;
110
+ }
106
111
  }
107
112
 
108
113
  /**
@@ -152,6 +157,7 @@ export function discoverAgentsMd(
152
157
  *
153
158
  * Fields are omitted when their values are zero. The git line format:
154
159
  * `Git: branch main | 3 staged, 1 modified, 2 untracked | +5 −2 vs origin/main`
160
+ * where the trailing upstream label reflects the repository's actual tracking ref.
155
161
  *
156
162
  * @param state - The git state to format.
157
163
  * @returns Formatted git status line.
@@ -171,7 +177,8 @@ export function formatGitLine(state: GitState): string {
171
177
  const ab: string[] = [];
172
178
  if (state.ahead > 0) ab.push(`+${state.ahead}`);
173
179
  if (state.behind > 0) ab.push(`\u2212${state.behind}`);
174
- parts.push(`${ab.join(" ")} vs origin/${state.branch}`);
180
+ const upstream = state.upstream ? ` vs ${state.upstream}` : "";
181
+ parts.push(`${ab.join(" ")}${upstream}`);
175
182
  }
176
183
 
177
184
  return parts.join(" | ");
@@ -185,14 +192,14 @@ const BASE_INSTRUCTIONS = `You are mini-coder, a coding agent running in the use
185
192
 
186
193
  # Role
187
194
 
188
- You are an autonomous, senior-level coding assistant. When the user gives a direction, proactively gather context, plan with the user, implement, and verify. Bias toward action: use planning first to clear any assumptions with the user, then implement the plan. Deliver working code, unless you are genuinely blocked.
195
+ You are an autonomous, senior-level coding assistant. When the user gives a direction, proactively gather context, plan with the user, implement, and verify. Bias toward action: plan briefly when needed to clear important assumptions, then continue into implementation. First identify the task contract: required files, names, interfaces, output format, and checks for success. Treat those details as part of correctness, not as polish. Deliver working code, unless you are genuinely blocked.
189
196
 
190
197
  # Tools
191
198
 
192
199
  You have these core tools:
193
200
 
194
- - \`shell\` — run commands in the user's shell. Use this to explore the codebase (rg, find, ls, cat), run tests, build, git, and any other command. Prefer \`rg\` over \`grep\` for speed.
195
- - \`edit\` — make exact-text replacements in files. Provide the file path, the exact text to find, and the replacement text. The old text must match exactly one location in the file. To create a new file, use an empty old text and the full file content as new text.
201
+ - \`shell\` — run commands in the user's shell. Use this to explore the codebase, read tests/verifiers/examples, inspect required outputs, and run targeted checks, builds, or git commands. Prefer \`rg\` over \`grep\` for speed.
202
+ - \`edit\` — make exact-text replacements in files. Provide the file path, the exact text to find, and the replacement text. The old text must match exactly one location in the file. To create a new file, use an empty old text and the full file content as new text. Use this to write the exact final file content the task requires.
196
203
 
197
204
  You may also have additional tools provided by plugins. Use them when they match the task.
198
205
 
@@ -201,7 +208,7 @@ Workflow: **inspect with shell → mutate with edit → verify with shell**.
201
208
  # Code quality
202
209
 
203
210
  - Conform to the codebase's existing conventions: patterns, naming, formatting, language idioms.
204
- - Write correct, clear, minimal code. Don't over-engineer, don't add abstractions for hypothetical futures.
211
+ - Write correct, clear, minimal code. Prefer the simplest solution that satisfies the task's checks exactly. Don't over-engineer, don't add abstractions for hypothetical futures.
205
212
  - Reuse before creating. Search for existing helpers before writing new ones.
206
213
  - Tight error handling: no broad try/catch, no silent failures, no swallowed errors.
207
214
  - Keep type safety. Avoid \`any\` casts. Use proper types and guards.
@@ -217,6 +224,7 @@ Workflow: **inspect with shell → mutate with edit → verify with shell**.
217
224
  # Exploring the codebase
218
225
 
219
226
  - Think first: before any tool call, decide all files and information you need.
227
+ - Early in the task, look for acceptance criteria in tests, verifier scripts, eval scripts, examples, and expected-output files. Do not rely on the task text alone when machine-checkable criteria are available.
220
228
  - Batch reads: if you need multiple files, read them together in parallel rather than one at a time.
221
229
  - Only make sequential calls when a later call genuinely depends on an earlier result.
222
230
 
@@ -231,7 +239,9 @@ Workflow: **inspect with shell → mutate with edit → verify with shell**.
231
239
  # Persistence
232
240
 
233
241
  - Carry work through to completion within the current turn. Don't stop at analysis or partial fixes.
242
+ - Once the contract is clear, create the required artifact early, then iterate and improve it. Do not spend most of the turn exploring.
234
243
  - If you encounter an error, diagnose and fix it rather than reporting it and stopping.
244
+ - Before concluding, run the smallest targeted verification that checks the exact contract: required files exist, names and signatures match, outputs are in the required format, and no forbidden extra artifacts were left behind.
235
245
  - Avoid excessive looping: if you're re-reading or re-editing the same files without progress, stop and ask the user.`;
236
246
 
237
247
  // ---------------------------------------------------------------------------
package/src/session.ts CHANGED
@@ -10,7 +10,12 @@
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
+ ToolResultMessage,
17
+ UserMessage,
18
+ } from "@mariozechner/pi-ai";
14
19
 
15
20
  // ---------------------------------------------------------------------------
16
21
  // Types
@@ -140,6 +145,21 @@ type MaxTurnRow = { max_turn: number | null };
140
145
  /** Row shape for `SELECT data` queries. */
141
146
  type DataRow = { data: string };
142
147
 
148
+ const EMPTY_ASSISTANT_USAGE: AssistantMessage["usage"] = {
149
+ input: 0,
150
+ output: 0,
151
+ cacheRead: 0,
152
+ cacheWrite: 0,
153
+ totalTokens: 0,
154
+ cost: {
155
+ input: 0,
156
+ output: 0,
157
+ cacheRead: 0,
158
+ cacheWrite: 0,
159
+ total: 0,
160
+ },
161
+ };
162
+
143
163
  /** Row shape returned by `SELECT * FROM prompt_history`. */
144
164
  type PromptHistoryRow = {
145
165
  id: number;
@@ -319,14 +339,189 @@ function getMultipartUserPreview(
319
339
  return collapsePreviewText(text);
320
340
  }
321
341
 
342
+ function isTextContentBlock(
343
+ value: unknown,
344
+ ): value is { type: "text"; text: string } {
345
+ const record = toRecord(value);
346
+ return record?.type === "text" && typeof record.text === "string";
347
+ }
348
+
349
+ function isImageContentBlock(
350
+ value: unknown,
351
+ ): value is { type: "image"; data: string; mimeType: string } {
352
+ const record = toRecord(value);
353
+ return (
354
+ record?.type === "image" &&
355
+ typeof record.data === "string" &&
356
+ typeof record.mimeType === "string"
357
+ );
358
+ }
359
+
360
+ function isThinkingContentBlock(
361
+ value: unknown,
362
+ ): value is Extract<AssistantMessage["content"][number], { type: "thinking" }> {
363
+ const record = toRecord(value);
364
+ return record?.type === "thinking" && typeof record.thinking === "string";
365
+ }
366
+
367
+ function isToolCallContentBlock(
368
+ value: unknown,
369
+ ): value is Extract<AssistantMessage["content"][number], { type: "toolCall" }> {
370
+ const record = toRecord(value);
371
+ return (
372
+ record?.type === "toolCall" &&
373
+ typeof record.id === "string" &&
374
+ typeof record.name === "string" &&
375
+ toRecord(record.arguments) !== null
376
+ );
377
+ }
378
+
379
+ function isAssistantUsage(value: unknown): value is AssistantMessage["usage"] {
380
+ const usageRecord = toRecord(value);
381
+ const costRecord = toRecord(usageRecord?.cost);
382
+ return (
383
+ usageRecord !== null &&
384
+ costRecord !== null &&
385
+ readFiniteNumber(usageRecord, "input") !== null &&
386
+ readFiniteNumber(usageRecord, "output") !== null &&
387
+ readFiniteNumber(usageRecord, "cacheRead") !== null &&
388
+ readFiniteNumber(usageRecord, "cacheWrite") !== null &&
389
+ readFiniteNumber(usageRecord, "totalTokens") !== null &&
390
+ readFiniteNumber(costRecord, "input") !== null &&
391
+ readFiniteNumber(costRecord, "output") !== null &&
392
+ readFiniteNumber(costRecord, "cacheRead") !== null &&
393
+ readFiniteNumber(costRecord, "cacheWrite") !== null &&
394
+ readFiniteNumber(costRecord, "total") !== null
395
+ );
396
+ }
397
+
398
+ function isStopReason(value: unknown): value is AssistantMessage["stopReason"] {
399
+ return (
400
+ value === "stop" ||
401
+ value === "length" ||
402
+ value === "toolUse" ||
403
+ value === "error" ||
404
+ value === "aborted"
405
+ );
406
+ }
407
+
408
+ function isUserMessageRecord(value: unknown): value is UserMessage {
409
+ const record = toRecord(value);
410
+ if (!record || record.role !== "user") {
411
+ return false;
412
+ }
413
+
414
+ return (
415
+ readFiniteNumber(record, "timestamp") !== null &&
416
+ (typeof record.content === "string" ||
417
+ (Array.isArray(record.content) &&
418
+ record.content.every(
419
+ (block) => isTextContentBlock(block) || isImageContentBlock(block),
420
+ )))
421
+ );
422
+ }
423
+
424
+ function parseAssistantMessageRecord(value: unknown): AssistantMessage | null {
425
+ const record = toRecord(value);
426
+ if (!record || record.role !== "assistant") {
427
+ return null;
428
+ }
429
+
430
+ const timestamp = readFiniteNumber(record, "timestamp");
431
+ if (
432
+ !Array.isArray(record.content) ||
433
+ !record.content.every(
434
+ (block) =>
435
+ isTextContentBlock(block) ||
436
+ isThinkingContentBlock(block) ||
437
+ isToolCallContentBlock(block),
438
+ ) ||
439
+ typeof record.api !== "string" ||
440
+ typeof record.provider !== "string" ||
441
+ typeof record.model !== "string" ||
442
+ !isStopReason(record.stopReason) ||
443
+ (record.errorMessage !== undefined &&
444
+ typeof record.errorMessage !== "string") ||
445
+ timestamp === null
446
+ ) {
447
+ return null;
448
+ }
449
+
450
+ return {
451
+ role: "assistant",
452
+ content: record.content,
453
+ api: record.api,
454
+ provider: record.provider,
455
+ model: record.model,
456
+ usage: isAssistantUsage(record.usage)
457
+ ? record.usage
458
+ : structuredClone(EMPTY_ASSISTANT_USAGE),
459
+ stopReason: record.stopReason,
460
+ ...(typeof record.errorMessage === "string"
461
+ ? { errorMessage: record.errorMessage }
462
+ : {}),
463
+ timestamp,
464
+ };
465
+ }
466
+
467
+ function isToolResultMessageRecord(value: unknown): value is ToolResultMessage {
468
+ const record = toRecord(value);
469
+ if (!record || record.role !== "toolResult") {
470
+ return false;
471
+ }
472
+
473
+ return (
474
+ typeof record.toolCallId === "string" &&
475
+ typeof record.toolName === "string" &&
476
+ typeof record.isError === "boolean" &&
477
+ Array.isArray(record.content) &&
478
+ record.content.every(
479
+ (block) => isTextContentBlock(block) || isImageContentBlock(block),
480
+ ) &&
481
+ readFiniteNumber(record, "timestamp") !== null
482
+ );
483
+ }
484
+
485
+ function isUiMessageRecord(value: unknown): value is UiMessage {
486
+ const record = toRecord(value);
487
+ if (!record || record.role !== "ui") {
488
+ return false;
489
+ }
490
+
491
+ return (
492
+ record.kind === "info" &&
493
+ typeof record.content === "string" &&
494
+ readFiniteNumber(record, "timestamp") !== null
495
+ );
496
+ }
497
+
498
+ function parsePersistedMessage(data: string): PersistedMessage | null {
499
+ let parsed: unknown;
500
+ try {
501
+ parsed = JSON.parse(data) as unknown;
502
+ } catch {
503
+ return null;
504
+ }
505
+
506
+ if (
507
+ isUserMessageRecord(parsed) ||
508
+ isToolResultMessageRecord(parsed) ||
509
+ isUiMessageRecord(parsed)
510
+ ) {
511
+ return parsed;
512
+ }
513
+
514
+ return parseAssistantMessageRecord(parsed);
515
+ }
516
+
322
517
  /** Read the first-user preview cached by the session-list query. */
323
518
  function readFirstUserPreview(messageData: string | null): string | null {
324
519
  if (!messageData) {
325
520
  return null;
326
521
  }
327
522
 
328
- const message = JSON.parse(messageData) as PersistedMessage;
329
- if (message.role !== "user") {
523
+ const message = parsePersistedMessage(messageData);
524
+ if (!message || message.role !== "user") {
330
525
  return null;
331
526
  }
332
527
 
@@ -434,7 +629,7 @@ export function filterModelMessages(
434
629
  }
435
630
 
436
631
  function toRecord(value: unknown): Record<string, unknown> | null {
437
- return typeof value === "object" && value !== null
632
+ return typeof value === "object" && value !== null && !Array.isArray(value)
438
633
  ? (value as Record<string, unknown>)
439
634
  : null;
440
635
  }
@@ -620,7 +815,8 @@ export function appendMessage(
620
815
  * Load all messages for a session in insertion order.
621
816
  *
622
817
  * Messages are deserialized from their JSON representation back into
623
- * persisted app messages. The ordering matches the original append order
818
+ * persisted app messages. Invalid rows are skipped so corrupted session data
819
+ * does not crash the app. The ordering matches the original append order
624
820
  * (by autoincrement `id`), preserving the conversation flow.
625
821
  *
626
822
  * @param db - Open database handle.
@@ -633,7 +829,16 @@ export function loadMessages(
633
829
  sessionId: string,
634
830
  ): PersistedMessage[] {
635
831
  const rows = db.query<DataRow, [string]>(SQL.loadMessages).all(sessionId);
636
- return rows.map((row) => JSON.parse(row.data) as PersistedMessage);
832
+ const messages: PersistedMessage[] = [];
833
+
834
+ for (const row of rows) {
835
+ const message = parsePersistedMessage(row.data);
836
+ if (message) {
837
+ messages.push(message);
838
+ }
839
+ }
840
+
841
+ return messages;
637
842
  }
638
843
 
639
844
  // ---------------------------------------------------------------------------