pi-subagents-lite 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +222 -36
- package/package.json +3 -1
- package/src/agent-discovery.ts +36 -45
- package/src/agent-manager.ts +101 -87
- package/src/agent-runner.ts +40 -49
- package/src/agent-types.ts +15 -37
- package/src/config-io.ts +40 -0
- package/src/context.ts +80 -1
- package/src/index.ts +105 -1117
- package/src/menus.ts +866 -0
- package/src/model-precedence.ts +46 -36
- package/src/model-selector.ts +19 -19
- package/src/output-file.ts +123 -33
- package/src/prompts.ts +2 -2
- package/src/result-viewer.ts +166 -37
- package/src/skill-loader.ts +1 -1
- package/src/stop-agent-tool.ts +76 -0
- package/src/tool-execution.ts +361 -0
- package/src/types.ts +16 -1
- package/src/ui/agent-widget.ts +98 -91
- package/src/usage.ts +12 -4
- package/src/utils.ts +53 -4
package/src/ui/agent-widget.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
|
|
3
3
|
*
|
|
4
|
-
* Ported from upstream pi-subagents.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - Import paths use relative imports within our extension
|
|
8
|
-
* - addUsage/getLifetimeTotal/getSessionContextPercent imported from ../usage.js
|
|
4
|
+
* Ported from upstream pi-subagents.
|
|
5
|
+
* Import paths use relative imports within our extension.
|
|
6
|
+
* addUsage/getLifetimeTotal/getSessionContextPercent imported from ../usage.js.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
12
10
|
import type { AgentManager } from "../agent-manager.js";
|
|
13
11
|
import { getConfig } from "../agent-types.js";
|
|
14
|
-
import type {
|
|
15
|
-
import {
|
|
12
|
+
import type { AgentRecord, SubagentType } from "../types.js";
|
|
13
|
+
import {
|
|
14
|
+
formatTokens,
|
|
15
|
+
getLifetimeTotal,
|
|
16
|
+
getSessionContextPercent,
|
|
17
|
+
type LifetimeUsage,
|
|
18
|
+
type SessionLike,
|
|
19
|
+
} from "../usage.js";
|
|
16
20
|
|
|
17
21
|
// ---- Constants ----
|
|
18
22
|
|
|
@@ -20,10 +24,10 @@ import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type Se
|
|
|
20
24
|
const MAX_WIDGET_LINES = 12;
|
|
21
25
|
|
|
22
26
|
/** Braille spinner frames for animated running indicator. */
|
|
23
|
-
|
|
27
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
24
28
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
29
|
+
/** Non-success statuses — used for linger behavior and icon rendering. */
|
|
30
|
+
const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
|
|
27
31
|
|
|
28
32
|
/** Tree-drawing connectors used in the widget header/continuation lines. */
|
|
29
33
|
const BRANCH = "├─";
|
|
@@ -58,7 +62,7 @@ const TOOL_DISPLAY: Record<string, string> = {
|
|
|
58
62
|
|
|
59
63
|
// ---- Types ----
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
type Theme = {
|
|
62
66
|
fg(color: string, text: string): string;
|
|
63
67
|
bold(text: string): string;
|
|
64
68
|
};
|
|
@@ -102,13 +106,6 @@ export interface AgentActivity {
|
|
|
102
106
|
|
|
103
107
|
// ---- Formatting helpers ----
|
|
104
108
|
|
|
105
|
-
/** Format a token count compactly: "33.8k", "1.2M". */
|
|
106
|
-
export function formatTokens(count: number): string {
|
|
107
|
-
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
108
|
-
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
109
|
-
return `${count}`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
109
|
/**
|
|
113
110
|
* Token count with optional context-fill % and compaction-count annotations.
|
|
114
111
|
* Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
|
|
@@ -119,7 +116,7 @@ export function formatTokens(count: number): string {
|
|
|
119
116
|
* "12.3k(↻ 2)" — compactions only (e.g. right after compact)
|
|
120
117
|
* "12.3k(45%·↻ 2)" — both
|
|
121
118
|
*/
|
|
122
|
-
|
|
119
|
+
function formatSessionTokens(
|
|
123
120
|
tokens: number,
|
|
124
121
|
percent: number | null,
|
|
125
122
|
theme: Theme,
|
|
@@ -143,35 +140,56 @@ export function formatSessionTokens(
|
|
|
143
140
|
}
|
|
144
141
|
|
|
145
142
|
/** Format turn count with optional max limit: "5≤30⟳" or "5⟳". */
|
|
146
|
-
|
|
143
|
+
function formatTurns(turnCount: number, maxTurns?: number | null): string {
|
|
147
144
|
return maxTurns != null ? `${turnCount}≤${maxTurns}⟳ ` : `${turnCount}⟳ `;
|
|
148
145
|
}
|
|
149
146
|
|
|
150
|
-
/** Format milliseconds as human-readable duration. */
|
|
147
|
+
/** Format milliseconds as a compact human-readable duration: "1h 1m 1s", "5m 37s", "10s", "<1s". */
|
|
151
148
|
export function formatMs(ms: number): string {
|
|
152
|
-
|
|
153
|
-
}
|
|
149
|
+
if (!Number.isFinite(ms) || ms < 1000) return "<1s";
|
|
154
150
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
151
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
152
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
153
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
154
|
+
const seconds = totalSeconds % 60;
|
|
155
|
+
|
|
156
|
+
const parts: string[] = [];
|
|
157
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
158
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
159
|
+
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
|
|
160
|
+
|
|
161
|
+
return parts.join(" ");
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
/**
|
|
161
|
-
* Build
|
|
162
|
-
*
|
|
163
|
-
* because our AgentInvocation doesn't have those fields.
|
|
165
|
+
* Build common stats parts: toolUses · turns · tokens with context %.
|
|
166
|
+
* Shared by AgentWidget and index.ts for consistent stats display.
|
|
164
167
|
*/
|
|
165
|
-
export function
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
168
|
+
export function buildStatsParts(
|
|
169
|
+
args: {
|
|
170
|
+
toolUses: number;
|
|
171
|
+
turnCount?: number;
|
|
172
|
+
maxTurns?: number;
|
|
173
|
+
tokens: number;
|
|
174
|
+
contextPercent: number | null;
|
|
175
|
+
compactions: number;
|
|
176
|
+
},
|
|
177
|
+
theme: Theme,
|
|
178
|
+
): string[] {
|
|
179
|
+
const parts: string[] = [];
|
|
180
|
+
if (args.toolUses > 0) parts.push(`${args.toolUses}🛠 `);
|
|
181
|
+
if (args.turnCount != null) parts.push(formatTurns(args.turnCount, args.maxTurns));
|
|
182
|
+
if (args.tokens > 0) {
|
|
183
|
+
parts.push(formatSessionTokens(
|
|
184
|
+
args.tokens, args.contextPercent, theme, args.compactions,
|
|
185
|
+
));
|
|
186
|
+
}
|
|
187
|
+
return parts;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Get display name for any agent type (built-in or custom). */
|
|
191
|
+
export function getDisplayName(type: SubagentType): string {
|
|
192
|
+
return getConfig(type).displayName;
|
|
175
193
|
}
|
|
176
194
|
|
|
177
195
|
/**
|
|
@@ -194,7 +212,7 @@ function truncateLine(text: string, len = 60): string {
|
|
|
194
212
|
}
|
|
195
213
|
|
|
196
214
|
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
197
|
-
|
|
215
|
+
function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
|
|
198
216
|
if (activeTools.size > 0) {
|
|
199
217
|
const groups = new Map<string, number>();
|
|
200
218
|
for (const toolName of activeTools.values()) {
|
|
@@ -227,7 +245,7 @@ export class AgentWidget {
|
|
|
227
245
|
private uiCtx: UICtx | undefined;
|
|
228
246
|
private widgetFrame = 0;
|
|
229
247
|
private widgetInterval: ReturnType<typeof setInterval> | undefined;
|
|
230
|
-
/**
|
|
248
|
+
/** Finished agents: agent ID → turns since finished. */
|
|
231
249
|
private finishedTurnAge = new Map<string, number>();
|
|
232
250
|
|
|
233
251
|
|
|
@@ -304,8 +322,11 @@ export class AgentWidget {
|
|
|
304
322
|
}
|
|
305
323
|
|
|
306
324
|
/** Build the icon and status suffix for a finished agent. */
|
|
307
|
-
private finishedIconAndStatus(
|
|
308
|
-
|
|
325
|
+
private finishedIconAndStatus(
|
|
326
|
+
status: string,
|
|
327
|
+
error: string | undefined,
|
|
328
|
+
theme: Theme,
|
|
329
|
+
): { icon: string; statusText: string } {
|
|
309
330
|
switch (status) {
|
|
310
331
|
case "completed":
|
|
311
332
|
return { icon: theme.fg("success", "✓"), statusText: "" };
|
|
@@ -335,31 +356,18 @@ export class AgentWidget {
|
|
|
335
356
|
const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
|
|
336
357
|
const { icon, statusText } = this.finishedIconAndStatus(a.status, a.error, theme);
|
|
337
358
|
|
|
338
|
-
const parts: string[] = [];
|
|
339
359
|
const activity = this.agentActivity.get(a.id);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// Token usage with context % — read from record if activity was cleaned up
|
|
352
|
-
const tokens = getLifetimeTotal(activity?.lifetimeUsage ?? a.lifetimeUsage);
|
|
353
|
-
if (tokens > 0) {
|
|
354
|
-
const contextPct = getSessionContextPercent(activity?.session ?? a.session);
|
|
355
|
-
const tokenText = formatSessionTokens(tokens, contextPct, theme, a.compactionCount);
|
|
356
|
-
parts.push(tokenText);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
parts.push(duration);
|
|
360
|
-
|
|
361
|
-
// Wrap stats in dim, re-applying after any ANSI reset from formatSessionTokens.
|
|
362
|
-
const statsLine = parts.join("·");
|
|
360
|
+
const statsParts = buildStatsParts({
|
|
361
|
+
toolUses: a.toolUses,
|
|
362
|
+
turnCount: activity?.turnCount ?? a.turnCount,
|
|
363
|
+
maxTurns: activity?.maxTurns ?? a.maxTurns,
|
|
364
|
+
tokens: getLifetimeTotal(activity?.lifetimeUsage ?? a.lifetimeUsage),
|
|
365
|
+
contextPercent: getSessionContextPercent(activity?.session ?? a.session),
|
|
366
|
+
compactions: a.compactionCount,
|
|
367
|
+
}, theme);
|
|
368
|
+
statsParts.push(duration);
|
|
369
|
+
|
|
370
|
+
const statsLine = statsParts.join("·");
|
|
363
371
|
return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
|
|
364
372
|
}
|
|
365
373
|
|
|
@@ -369,20 +377,15 @@ export class AgentWidget {
|
|
|
369
377
|
activity: AgentActivity | undefined,
|
|
370
378
|
theme: Theme,
|
|
371
379
|
): string {
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const parts: string[] = [];
|
|
382
|
-
if (toolUses > 0) parts.push(`${toolUses}🛠 `);
|
|
383
|
-
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
|
|
384
|
-
if (tokenText) parts.push(tokenText);
|
|
385
|
-
parts.push(elapsed);
|
|
380
|
+
const parts = buildStatsParts({
|
|
381
|
+
toolUses: activity?.toolUses ?? agent.toolUses,
|
|
382
|
+
turnCount: activity?.turnCount,
|
|
383
|
+
maxTurns: activity?.maxTurns,
|
|
384
|
+
tokens: getLifetimeTotal(activity?.lifetimeUsage),
|
|
385
|
+
contextPercent: getSessionContextPercent(activity?.session),
|
|
386
|
+
compactions: agent.compactionCount,
|
|
387
|
+
}, theme);
|
|
388
|
+
parts.push(formatMs(Date.now() - agent.startedAt));
|
|
386
389
|
return parts.join("·");
|
|
387
390
|
}
|
|
388
391
|
|
|
@@ -396,7 +399,7 @@ export class AgentWidget {
|
|
|
396
399
|
const blocks: RenderBlock[] = [];
|
|
397
400
|
for (const a of finished) {
|
|
398
401
|
blocks.push({
|
|
399
|
-
header: truncate(theme.fg("dim", BRANCH)
|
|
402
|
+
header: truncate(`${theme.fg("dim", BRANCH)} ${this.renderFinishedLine(a, theme)}`),
|
|
400
403
|
continuations: a.outputFile
|
|
401
404
|
? [truncate(theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
|
|
402
405
|
: [],
|
|
@@ -420,8 +423,9 @@ export class AgentWidget {
|
|
|
420
423
|
const statsLine = this.buildStatsLine(a, bg, theme);
|
|
421
424
|
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : THINKING_TEXT;
|
|
422
425
|
|
|
426
|
+
const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.description} ${statsLine}`;
|
|
423
427
|
blocks.push({
|
|
424
|
-
header: truncate(
|
|
428
|
+
header: truncate(headerLine),
|
|
425
429
|
continuations: [
|
|
426
430
|
...(a.outputFile
|
|
427
431
|
? [truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
|
|
@@ -441,10 +445,8 @@ export class AgentWidget {
|
|
|
441
445
|
): RenderBlock | undefined {
|
|
442
446
|
if (queued.length === 0) return undefined;
|
|
443
447
|
const truncate = (line: string) => truncateToWidth(line, w);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
continuations: [],
|
|
447
|
-
};
|
|
448
|
+
const header = `${theme.fg("dim", BRANCH)} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`;
|
|
449
|
+
return { header: truncate(header), continuations: [] };
|
|
448
450
|
}
|
|
449
451
|
|
|
450
452
|
/**
|
|
@@ -471,7 +473,7 @@ export class AgentWidget {
|
|
|
471
473
|
const headingIcon = hasActive ? "●" : "○";
|
|
472
474
|
const frame = SPINNER[this.widgetFrame % SPINNER.length];
|
|
473
475
|
|
|
474
|
-
//
|
|
476
|
+
// Build blocks with placeholder connectors (BRANCH for headers, VLINE for continuations)
|
|
475
477
|
// Separate arrays so overflow logic can apply priority: running > queued > finished.
|
|
476
478
|
const finishedBlocks = this.buildFinishedBlocks(finished, theme, w);
|
|
477
479
|
const runningBlocks = this.buildRunningBlocks(running, theme, w, frame);
|
|
@@ -489,7 +491,8 @@ export class AgentWidget {
|
|
|
489
491
|
const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
|
|
490
492
|
const totalBody = blocks.reduce((sum, b) => sum + 1 + b.continuations.length, 0);
|
|
491
493
|
|
|
492
|
-
const
|
|
494
|
+
const heading = `${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, "Agents")}`;
|
|
495
|
+
const lines: string[] = [truncate(heading)];
|
|
493
496
|
|
|
494
497
|
if (totalBody <= maxBody) {
|
|
495
498
|
// Everything fits — render all blocks with correct connectors.
|
|
@@ -570,7 +573,8 @@ export class AgentWidget {
|
|
|
570
573
|
const parts: string[] = [];
|
|
571
574
|
if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`);
|
|
572
575
|
if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
|
|
573
|
-
|
|
576
|
+
const summary = `+${hiddenRunning + hiddenFinished} more (${parts.join(", ")})`;
|
|
577
|
+
return `${theme.fg("dim", CORNER)} ${theme.fg("dim", summary)}`;
|
|
574
578
|
})()
|
|
575
579
|
: undefined;
|
|
576
580
|
|
|
@@ -588,7 +592,10 @@ export class AgentWidget {
|
|
|
588
592
|
this.uiCtx?.setStatus(STATUS_KEY, undefined);
|
|
589
593
|
this.lastStatusText = undefined;
|
|
590
594
|
}
|
|
591
|
-
if (this.widgetInterval) {
|
|
595
|
+
if (this.widgetInterval) {
|
|
596
|
+
clearInterval(this.widgetInterval);
|
|
597
|
+
this.widgetInterval = undefined;
|
|
598
|
+
}
|
|
592
599
|
// Clean up stale entries
|
|
593
600
|
const allAgents = this.manager.listAgents();
|
|
594
601
|
for (const [id] of this.finishedTurnAge) {
|
package/src/usage.ts
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* the cumulative cached prefix re-read on that one call — summing across
|
|
8
8
|
* turns counts the prefix N times. See issue #38.
|
|
9
9
|
*/
|
|
10
|
-
export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
|
|
10
|
+
export type LifetimeUsage = { input: number; output: number; cacheWrite: number; cost: number };
|
|
11
11
|
|
|
12
|
-
/** Sum of lifetime usage components, or 0 if undefined. */
|
|
12
|
+
/** Sum of lifetime usage components (including cost), or 0 if undefined. */
|
|
13
13
|
export function getLifetimeTotal(u?: LifetimeUsage): number {
|
|
14
|
-
return u ? u.input + u.output + u.cacheWrite : 0;
|
|
14
|
+
return u ? u.input + u.output + u.cacheWrite + u.cost : 0;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** Add a usage delta into a target accumulator (mutates target). */
|
|
@@ -19,15 +19,23 @@ export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
|
|
|
19
19
|
into.input += delta.input;
|
|
20
20
|
into.output += delta.output;
|
|
21
21
|
into.cacheWrite += delta.cacheWrite;
|
|
22
|
+
into.cost += delta.cost;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/** Minimal shape we read from upstream `getSessionStats()`. */
|
|
25
|
-
|
|
26
|
+
type SessionStatsLike = {
|
|
26
27
|
tokens: { input: number; output: number; cacheWrite: number };
|
|
27
28
|
contextUsage?: { percent: number | null };
|
|
28
29
|
};
|
|
29
30
|
export type SessionLike = { getSessionStats(): SessionStatsLike };
|
|
30
31
|
|
|
32
|
+
/** Format a token count compactly: "12.3k", "1.2M", or raw number. */
|
|
33
|
+
export function formatTokens(count: number): string {
|
|
34
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
35
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
36
|
+
return `${count}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
/**
|
|
32
40
|
* Context-window utilization (0–100), or null when unavailable
|
|
33
41
|
* (no model contextWindow, or post-compaction before the next response).
|
package/src/utils.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* utils.ts — Security helpers: safe file access, name validation.
|
|
2
|
+
* utils.ts — Security helpers: safe file access, name validation + general utilities.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Security helpers extracted from upstream memory.ts — pure implementations copied verbatim.
|
|
5
|
+
* General utilities (parseModelKey, findModelInRegistry) moved here so both agent-runner
|
|
6
|
+
* and tool-execution can use them without circular dependencies.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { lstatSync, readFileSync } from "node:fs";
|
|
10
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
11
|
+
import type { ThinkingLevel } from "./types.js";
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Returns true if a name contains characters not allowed in agent/skill names.
|
|
11
15
|
* Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
|
|
12
16
|
*/
|
|
13
17
|
export function isUnsafeName(name: string): boolean {
|
|
14
|
-
|
|
15
|
-
return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
|
18
|
+
return !name || name.length > 128 || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -38,3 +41,49 @@ export function safeReadFile(filePath: string): string | undefined {
|
|
|
38
41
|
return undefined;
|
|
39
42
|
}
|
|
40
43
|
}
|
|
44
|
+
|
|
45
|
+
/** All valid thinking levels. */
|
|
46
|
+
export const VALID_THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
47
|
+
"off", "minimal", "low", "medium", "high", "xhigh",
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate and narrow a raw string value to ThinkingLevel.
|
|
52
|
+
* Returns undefined if the value is not a valid thinking level.
|
|
53
|
+
*/
|
|
54
|
+
export function parseThinkingLevel(raw: string | undefined): ThinkingLevel | undefined {
|
|
55
|
+
if (raw === undefined) return undefined;
|
|
56
|
+
return VALID_THINKING_LEVELS.includes(raw as ThinkingLevel) ? (raw as ThinkingLevel) : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Safely extract a human-readable error message from an unknown exception.
|
|
61
|
+
*/
|
|
62
|
+
export function errorMessage(err: unknown): string {
|
|
63
|
+
return err instanceof Error ? err.message : String(err);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a "provider/model-id" string into { provider, modelId }.
|
|
68
|
+
* Returns null if the format is invalid (no slash or empty provider).
|
|
69
|
+
*/
|
|
70
|
+
export function parseModelKey(modelStr: string): { provider: string; modelId: string } | null {
|
|
71
|
+
const slashIdx = modelStr.indexOf("/");
|
|
72
|
+
if (slashIdx <= 0) return null;
|
|
73
|
+
return { provider: modelStr.slice(0, slashIdx), modelId: modelStr.slice(slashIdx + 1) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Find a model in the registry by "provider/model-id" string.
|
|
78
|
+
* Returns the found model, or the fallback if the string is unparseable or not in registry.
|
|
79
|
+
*/
|
|
80
|
+
export function findModelInRegistry(
|
|
81
|
+
modelStr: string | undefined,
|
|
82
|
+
registry: { find(provider: string, modelId: string): Model<any> | undefined },
|
|
83
|
+
fallback: Model<any> | undefined,
|
|
84
|
+
): Model<any> | undefined {
|
|
85
|
+
if (!modelStr) return fallback;
|
|
86
|
+
const parsed = parseModelKey(modelStr);
|
|
87
|
+
if (!parsed) return fallback;
|
|
88
|
+
return registry.find(parsed.provider, parsed.modelId) ?? fallback;
|
|
89
|
+
}
|