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/README.md +2 -0
- package/bun.lock +39 -40
- package/package.json +10 -11
- package/src/agent.ts +49 -1
- package/src/errors.ts +15 -0
- package/src/git.ts +50 -17
- package/src/index.ts +151 -11
- package/src/prompt.ts +19 -9
- package/src/session.ts +211 -6
- package/src/settings.ts +72 -3
- package/src/skills.ts +11 -7
- package/src/submit.ts +4 -6
- package/src/tools.ts +3 -2
- package/src/ui/agent.ts +1 -4
- package/src/ui/commands.test.ts +3 -0
- package/src/ui/commands.ts +1 -4
- package/src/ui/conversation.test.ts +165 -1
- package/src/ui/conversation.ts +268 -36
- package/src/ui/help.test.ts +18 -0
- package/src/ui/help.ts +6 -0
- package/src/ui/input.test.ts +5 -1
- package/src/ui/input.ts +7 -1
- package/src/ui/status.test.ts +4 -0
- package/src/ui.ts +96 -11
- package/src/version.ts +48 -0
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(
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
} catch {
|
|
111
|
-
|
|
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(
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 {
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|