@ibartel74/pi-automode-ext 1.0.0

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.
@@ -0,0 +1,338 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { Type } from "typebox";
3
+ import { Compile } from "typebox/compile";
4
+ import { parseModelSpec } from "./model.ts";
5
+ import type {
6
+ ClassificationDecision,
7
+ ClassifierIoAttempt,
8
+ EffectiveConfig,
9
+ } from "./types.ts";
10
+
11
+ /**
12
+ * TypeSafe's Jev classifier backend, served through OpenRouter's Decisions API.
13
+ *
14
+ * Jev is a System One decision model, not a chat model: it evaluates typed
15
+ * questions (Choice/Noul) against a state and returns calibrated probabilities.
16
+ * One call replaces both LLM classifier stages; the allow/block decision is made
17
+ * locally from the probabilities.
18
+ */
19
+
20
+ /** OpenRouter Decisions API endpoint (alpha). */
21
+ export const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
22
+ export const JEV_API_KEY_ENV = "OPENROUTER_API_KEY";
23
+
24
+ /** TypeSafe's native System One endpoint. */
25
+ export const JEV_TYPESAFE_URL = "https://api.typesafe.ai/v1/systemone";
26
+ export const JEV_TYPESAFE_API_KEY_ENV = "TYPESAFE_API_KEY";
27
+
28
+ /** Jev transport: OpenRouter's Decisions API or TypeSafe's native System One API. */
29
+ export type JevTransport = "openrouter" | "typesafe";
30
+
31
+ /**
32
+ * Pinned Jev release. `openrouter/~typesafe/jev-latest` also works, but the
33
+ * alias moves; a safety classifier should be reproducible across sessions.
34
+ */
35
+ export const RECOMMENDED_JEV_MODEL_SPEC = "openrouter/typesafe/jev-1.13";
36
+
37
+ const JEV_PROVIDER = "openrouter";
38
+ const JEV_MAX_CHOICE_OPTIONS = 255;
39
+ // One fixed risk tolerance: allow needs P(no deny rule) >= 0.8, and combined
40
+ // hard-deny probability >= 0.2 blocks. Make this configurable once real traffic
41
+ // shows where it should sit.
42
+ const JEV_RISK_TOLERANCE = 0.2;
43
+
44
+ type JevChoiceQuestion = {
45
+ type: "choice";
46
+ instructions: string;
47
+ criteria: Record<string, string>;
48
+ };
49
+
50
+ type JevNoulQuestion = {
51
+ type: "noul";
52
+ instructions: string;
53
+ };
54
+
55
+ export type JevRequest = {
56
+ model: string;
57
+ state: Record<string, string>;
58
+ questions: {
59
+ rule: JevChoiceQuestion;
60
+ allow_exception: JevNoulQuestion;
61
+ user_authorized: JevNoulQuestion;
62
+ };
63
+ };
64
+
65
+ /** Typed answers Jev must return: the schema is the boundary; failures fail closed. */
66
+ const JevAnswersSchema = Type.Object({
67
+ rule: Type.Object({
68
+ type: Type.Literal("choice"),
69
+ probabilities: Type.Record(Type.String(), Type.Number()),
70
+ }),
71
+ allow_exception: Type.Object({
72
+ type: Type.Literal("noul"),
73
+ noul: Type.Number(),
74
+ }),
75
+ user_authorized: Type.Object({
76
+ type: Type.Literal("noul"),
77
+ noul: Type.Number(),
78
+ }),
79
+ });
80
+
81
+ const JevResponseSchema = Type.Object({
82
+ model: Type.Optional(Type.String()),
83
+ answers: JevAnswersSchema,
84
+ usage: Type.Optional(Type.Object({
85
+ input_tokens: Type.Optional(Type.Number()),
86
+ output_tokens: Type.Optional(Type.Number()),
87
+ })),
88
+ });
89
+
90
+ type JevResponse = Type.Static<typeof JevResponseSchema>;
91
+
92
+ const answersValidator = Compile(JevAnswersSchema);
93
+ const responseValidator = Compile(JevResponseSchema);
94
+
95
+ /**
96
+ * Recognize `classifierModel` specs that route to Jev. `openrouter/typesafe/...`
97
+ * specs call OpenRouter's Decisions API; `typesafe/...` specs call TypeSafe's
98
+ * native System One API. Other `openrouter/...` specs are ordinary registry
99
+ * models and must keep using the registry path.
100
+ */
101
+ export function isJevClassifierModel(
102
+ spec: string | undefined,
103
+ ): { modelId: string; transport: JevTransport } | undefined {
104
+ const parsed = spec ? parseModelSpec(spec) : undefined;
105
+ if (!parsed || !parsed.id) return undefined;
106
+ if (parsed.provider === JEV_PROVIDER) {
107
+ const isJevId =
108
+ parsed.id.startsWith("typesafe/") || parsed.id.startsWith("~typesafe/");
109
+ return isJevId
110
+ ? { modelId: parsed.id, transport: "openrouter" }
111
+ : undefined;
112
+ }
113
+ if (parsed.provider === "typesafe") {
114
+ return { modelId: parsed.id, transport: "typesafe" };
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ /**
120
+ * One System One call replaces both LLM stages: the configured deny rules are
121
+ * the Choice categories, and two Noul questions cover soft-deny exceptions.
122
+ * Untrusted input is isolated in its own state field; Jev does not treat state
123
+ * as hostile, so the policy and context must not contain routing instructions.
124
+ */
125
+ export function buildJevRequest(
126
+ model: string,
127
+ config: EffectiveConfig,
128
+ prompt: { policy: string; context: string; action: string },
129
+ ): JevRequest {
130
+ const criteria: Record<string, string> = {
131
+ none: "The action matches none of the HARD_DENY or SOFT_DENY rules.",
132
+ };
133
+ config.hardDeny.forEach((rule, i) => (criteria[`hard_deny_${i + 1}`] = rule));
134
+ config.softDeny.forEach((rule, i) => (criteria[`soft_deny_${i + 1}`] = rule));
135
+ return {
136
+ model,
137
+ state: {
138
+ policy: prompt.policy,
139
+ context: prompt.context,
140
+ untrusted_action: prompt.action,
141
+ },
142
+ questions: {
143
+ rule: {
144
+ type: "choice",
145
+ instructions:
146
+ "Which deny rule from the policy does the agent's current tool action (untrusted_action) match? Choose none only when no HARD_DENY or SOFT_DENY rule matches.",
147
+ criteria,
148
+ },
149
+ allow_exception: {
150
+ type: "noul",
151
+ instructions:
152
+ "An ALLOW exception from the policy clearly covers the agent's current tool action.",
153
+ },
154
+ user_authorized: {
155
+ type: "noul",
156
+ instructions:
157
+ "The retained user transcript contains direct, specific user authorization for exactly this risky action, and no later user message narrows or revokes it. General requests such as \"clean up the repo\" do not count.",
158
+ },
159
+ },
160
+ };
161
+ }
162
+
163
+ const block = (reason: string): ClassificationDecision => ({
164
+ decision: "block",
165
+ tier: "none",
166
+ reason: `Jev classifier failed; auto mode fails closed: ${reason}`,
167
+ });
168
+
169
+ /**
170
+ * Map Jev's typed answers onto the hard-deny / soft-deny / allow semantics.
171
+ * With one risk tolerance (0.2): allow when P(none) >= 0.8; block with
172
+ * `hard_deny` when a hard-deny rule is the top rule match or hard-deny rules
173
+ * together reach the tolerance (exceptions never override hard-deny);
174
+ * otherwise the top soft-deny rule blocks unless the ALLOW-exception or
175
+ * user-authorization Noul is >= 0.8. Malformed answers fail closed.
176
+ */
177
+ export function decideFromJevAnswers(
178
+ answers: unknown,
179
+ config: EffectiveConfig,
180
+ ): ClassificationDecision {
181
+ if (!answersValidator.Check(answers)) {
182
+ return block("response did not contain the expected typed answers.");
183
+ }
184
+ const probabilities = answers.rule.probabilities;
185
+ const p = (key: string) => probabilities[key] ?? 0;
186
+
187
+ if (p("none") >= 1 - JEV_RISK_TOLERANCE) {
188
+ return {
189
+ decision: "allow",
190
+ tier: "none",
191
+ reason: "Jev matched no deny rule.",
192
+ };
193
+ }
194
+
195
+ const topRule = (rules: string[], prefix: string) =>
196
+ rules
197
+ .map((text, i) => ({ text, p: p(`${prefix}_${i + 1}`) }))
198
+ .reduce<{ text: string; p: number } | undefined>(
199
+ (best, r) => (!best || r.p > best.p ? r : best),
200
+ undefined,
201
+ );
202
+ const hard = topRule(config.hardDeny, "hard_deny");
203
+ const soft = topRule(config.softDeny, "soft_deny");
204
+ const hardMass = config.hardDeny.reduce(
205
+ (sum, _r, i) => sum + p(`hard_deny_${i + 1}`),
206
+ 0,
207
+ );
208
+
209
+ // Exceptions never override hard-deny, so any real hard-deny mass keeps the block.
210
+ if (hard && (hardMass >= JEV_RISK_TOLERANCE || !soft || hard.p >= soft.p)) {
211
+ return {
212
+ decision: "block",
213
+ tier: "hard_deny",
214
+ reason: `Matches hard-deny rule: ${hard.text}`,
215
+ };
216
+ }
217
+ if (!soft) {
218
+ return block(
219
+ "no deny rules configured but the action was not clearly allowed.",
220
+ );
221
+ }
222
+ if (answers.allow_exception.noul >= 1 - JEV_RISK_TOLERANCE) {
223
+ return {
224
+ decision: "allow",
225
+ tier: "allow",
226
+ reason: `ALLOW exception covers soft-deny rule: ${soft.text}`,
227
+ };
228
+ }
229
+ if (answers.user_authorized.noul >= 1 - JEV_RISK_TOLERANCE) {
230
+ return {
231
+ decision: "allow",
232
+ tier: "explicit_intent",
233
+ reason: `User authorized soft-deny action: ${soft.text}`,
234
+ };
235
+ }
236
+ return {
237
+ decision: "block",
238
+ tier: "soft_deny",
239
+ reason: `Matches soft-deny rule: ${soft.text}`,
240
+ };
241
+ }
242
+
243
+ /** Call the Jev System One API (OpenRouter Decisions or TypeSafe-native) once and decide locally from the typed answers. */
244
+ export async function classifyWithJev(
245
+ request: JevRequest,
246
+ config: EffectiveConfig,
247
+ signal: AbortSignal | undefined,
248
+ onAttempt: (attempt: ClassifierIoAttempt) => void,
249
+ apiKey: string | undefined,
250
+ fetchFn: typeof fetch = fetch,
251
+ transport: JevTransport = "openrouter",
252
+ ): Promise<ClassificationDecision> {
253
+ const envName = transport === "typesafe"
254
+ ? JEV_TYPESAFE_API_KEY_ENV
255
+ : JEV_API_KEY_ENV;
256
+ const url = transport === "typesafe" ? JEV_TYPESAFE_URL : JEV_DECISIONS_URL;
257
+ const key = apiKey ?? process.env[envName];
258
+ if (!key) {
259
+ return block(
260
+ `${envName} is not set and no ${transport} provider key is registered.`,
261
+ );
262
+ }
263
+ if (
264
+ Object.keys(request.questions.rule.criteria).length > JEV_MAX_CHOICE_OPTIONS
265
+ ) {
266
+ return block(
267
+ `more than ${JEV_MAX_CHOICE_OPTIONS - 1} deny rules configured.`,
268
+ );
269
+ }
270
+
271
+ const started = Date.now();
272
+ const timeout = AbortSignal.timeout(config.classifierTimeoutMs);
273
+ let response: JevResponse | undefined;
274
+ let attempt = 0;
275
+ for (attempt = 1; attempt <= 2; attempt += 1) {
276
+ try {
277
+ const result = await fetchFn(url, {
278
+ method: "POST",
279
+ headers: {
280
+ authorization: `Bearer ${key}`,
281
+ "content-type": "application/json",
282
+ },
283
+ body: JSON.stringify(request),
284
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
285
+ });
286
+ const text = await result.text();
287
+ if (!result.ok) {
288
+ throw new Error(`HTTP ${result.status}: ${text.slice(0, 300)}`);
289
+ }
290
+ const body: unknown = JSON.parse(text);
291
+ if (responseValidator.Check(body)) response = body;
292
+ } catch (error) {
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ onAttempt({
295
+ stage: "detailed",
296
+ attempt,
297
+ error: message,
298
+ durationMs: Date.now() - started,
299
+ });
300
+ // OpenRouter's alpha Decisions endpoint intermittently answers a valid
301
+ // key with 401 "User not found"; one delayed retry recovers without user
302
+ // involvement. Any other failure fails closed immediately.
303
+ if (attempt === 1 && message.startsWith("HTTP 401") && !signal?.aborted) {
304
+ await sleep(300);
305
+ continue;
306
+ }
307
+ return block(message);
308
+ }
309
+ break;
310
+ }
311
+
312
+ const decision = response
313
+ ? decideFromJevAnswers(response.answers, config)
314
+ : block("response did not contain the expected typed answers.");
315
+ const input = response?.usage?.input_tokens ?? 0;
316
+ const output = response?.usage?.output_tokens ?? 0;
317
+ onAttempt({
318
+ stage: "detailed",
319
+ attempt,
320
+ response: {
321
+ stopReason: "stop",
322
+ text: JSON.stringify(response?.answers ?? null),
323
+ model: response?.model ?? request.model,
324
+ timestamp: Date.now(),
325
+ usage: {
326
+ input,
327
+ output,
328
+ cacheRead: 0,
329
+ cacheWrite: 0,
330
+ totalTokens: input + output,
331
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
332
+ },
333
+ },
334
+ parsed: decision,
335
+ durationMs: Date.now() - started,
336
+ });
337
+ return decision;
338
+ }
@@ -0,0 +1,173 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
5
+ import type {
6
+ ClassifierIo,
7
+ ClassifierIoAttempt,
8
+ ClassifierReasoningLog,
9
+ ClassificationDecision,
10
+ DecisionKind,
11
+ } from "./types.ts";
12
+
13
+ /** A final allow/block decision for a tool call. */
14
+ export type DecisionLogEntry = {
15
+ type: "decision";
16
+ ts: string;
17
+ decisionId: string;
18
+ sessionId?: string;
19
+ cwd: string;
20
+ tool: string;
21
+ summary: string;
22
+ kind: DecisionKind;
23
+ outcome: "allow" | "block";
24
+ reason: string;
25
+ classifierModel?: string;
26
+ reasoning: ClassifierReasoningLog;
27
+ };
28
+
29
+ /** The classifier prompt, raw responses, and parsed decision for one action. */
30
+ export type ClassifierLogEntry = {
31
+ type: "classifier";
32
+ ts: string;
33
+ decisionId: string;
34
+ model: string;
35
+ reasoning: ClassifierIo["reasoning"];
36
+ prompt: ClassifierIo["prompt"];
37
+ attempts: ClassifierIoAttempt[];
38
+ durationMs: number;
39
+ parsed: ClassificationDecision;
40
+ };
41
+
42
+ /** A ccusage-compatible record for one classifier model response. */
43
+ export type ClassifierUsageLogEntry = {
44
+ type: "message";
45
+ timestamp: string;
46
+ message: {
47
+ role: "assistant";
48
+ model: string;
49
+ usage: NonNullable<ClassifierIoAttempt["response"]>["usage"];
50
+ };
51
+ };
52
+
53
+ export type LogEntry =
54
+ | DecisionLogEntry
55
+ | ClassifierLogEntry
56
+ | ClassifierUsageLogEntry;
57
+
58
+ export type Logger = {
59
+ enabled: boolean;
60
+ classifierIo: boolean;
61
+ append(entry: LogEntry): void;
62
+ };
63
+
64
+ export type LoggerOptions = {
65
+ enabled: boolean;
66
+ classifierIo: boolean;
67
+ sessionFile?: string;
68
+ sessionDir: string;
69
+ /** Effective cwd for an in-memory session. */
70
+ sessionCwd?: string;
71
+ sessionId: string;
72
+ /** Test/embedder override. Runtime uses ~/.pi/agent/extensions/pi-automode/logs. */
73
+ logRoot?: string;
74
+ /** Test clock used for the UTC date partition. */
75
+ now?: Date;
76
+ };
77
+
78
+ export const DEFAULT_AUTOMODE_LOG_ROOT = join(
79
+ homedir(),
80
+ ".pi/agent/extensions/pi-automode/logs",
81
+ );
82
+
83
+ const VALID_SESSION_ID =
84
+ /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
85
+
86
+ function safeLogSessionId(sessionId: string): string {
87
+ if (VALID_SESSION_ID.test(sessionId)) return sessionId;
88
+ const digest = createHash("sha256")
89
+ .update(sessionId)
90
+ .digest("hex")
91
+ .slice(0, 16);
92
+ return `invalid-${digest}`;
93
+ }
94
+
95
+ /** Short id linking a classifier entry to its decision entry in the same file. */
96
+ export function newDecisionId(): string {
97
+ return randomBytes(4).toString("hex");
98
+ }
99
+
100
+ /**
101
+ * Derive the log file path from the current session: the session file's
102
+ * directory with `-pi-automode` inserted before the extension. Falls back to
103
+ * an absolute session directory when one is available. In-memory sessions use
104
+ * an application-owned, project- and date-partitioned directory instead of a
105
+ * relative path resolved against the launching process cwd.
106
+ */
107
+ export function resolveLogPath(
108
+ sessionFile: string | undefined,
109
+ sessionDir: string,
110
+ sessionId: string,
111
+ sessionCwd = process.cwd(),
112
+ logRoot = DEFAULT_AUTOMODE_LOG_ROOT,
113
+ now = new Date(),
114
+ ): string {
115
+ if (sessionFile) {
116
+ const ext = extname(sessionFile);
117
+ const stem = ext ? basename(sessionFile, ext) : basename(sessionFile);
118
+ return join(dirname(sessionFile), `${stem}-pi-automode${ext}`);
119
+ }
120
+
121
+ const logFile = `${safeLogSessionId(sessionId)}-pi-automode.jsonl`;
122
+ if (isAbsolute(sessionDir)) {
123
+ return join(sessionDir, logFile);
124
+ }
125
+
126
+ const resolvedCwd = resolve(sessionCwd);
127
+ const projectDir = `--${
128
+ resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")
129
+ }--`;
130
+ const dateDir = now.toISOString().slice(0, 10);
131
+ const resolvedLogRoot = isAbsolute(logRoot)
132
+ ? logRoot
133
+ : DEFAULT_AUTOMODE_LOG_ROOT;
134
+ return join(
135
+ resolvedLogRoot,
136
+ projectDir,
137
+ dateDir,
138
+ logFile,
139
+ );
140
+ }
141
+
142
+ /** Append one JSON object as a line. Failures are swallowed: logging must
143
+ * never change a safety decision. */
144
+ function appendJsonl(path: string, entry: unknown): void {
145
+ try {
146
+ mkdirSync(dirname(path), { recursive: true });
147
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
148
+ } catch {
149
+ // Fail open.
150
+ }
151
+ }
152
+
153
+ /** Build a logger bound to one session's log path. No-ops when disabled. */
154
+ export function createLogger(opts: LoggerOptions): Logger {
155
+ const { enabled, classifierIo } = opts;
156
+ const path = resolveLogPath(
157
+ opts.sessionFile,
158
+ opts.sessionDir,
159
+ opts.sessionId,
160
+ opts.sessionCwd,
161
+ opts.logRoot,
162
+ opts.now,
163
+ );
164
+ return {
165
+ enabled,
166
+ classifierIo,
167
+ append(entry) {
168
+ if (!enabled) return;
169
+ if (entry.type === "classifier" && !classifierIo) return;
170
+ appendJsonl(path, entry);
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,113 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ fuzzyFilter,
4
+ Input,
5
+ matchesKey,
6
+ SelectList,
7
+ } from "@earendil-works/pi-tui";
8
+ import type { SelectItem } from "@earendil-works/pi-tui";
9
+ import { formatModelSpec } from "./model.ts";
10
+
11
+ /** Interactive model selector shown when `/automode model` is run without arguments. */
12
+ export function promptForClassifierModel(
13
+ ctx: ExtensionContext,
14
+ current?: string,
15
+ ): Promise<string | undefined> {
16
+ if (!ctx.hasUI) {
17
+ return Promise.resolve(undefined);
18
+ }
19
+ const available = ctx.modelRegistry.getAvailable();
20
+ if (available.length === 0) {
21
+ return Promise.resolve(undefined);
22
+ }
23
+
24
+ const items: SelectItem[] = available.map((model) => {
25
+ const spec = formatModelSpec(model);
26
+ return {
27
+ value: spec,
28
+ label: `${model.id} \u001b[2m[${model.provider}]\u001b[0m`,
29
+ description: spec === current ? "\u2713" : undefined,
30
+ };
31
+ });
32
+ items.sort((a, b) => a.label.localeCompare(b.label));
33
+
34
+ return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
35
+ const filterInput = new Input();
36
+ filterInput.onEscape = () => done(undefined);
37
+
38
+ let filtered: SelectItem[] = items;
39
+ let selectList = buildModelList(filtered, theme, filterInput, done, tui);
40
+
41
+ function applyFilter(query: string): void {
42
+ filtered = query
43
+ ? fuzzyFilter(items, query, (item) => `${item.label} ${item.value}`)
44
+ : items;
45
+ selectList = buildModelList(filtered, theme, filterInput, done, tui);
46
+ tui.requestRender();
47
+ }
48
+
49
+ return {
50
+ render(width: number) {
51
+ const selected = selectList.getSelectedItem();
52
+ const lines: string[] = [];
53
+ lines.push(theme.fg("accent", theme.bold("Select classifier model")));
54
+ lines.push(
55
+ theme.fg(
56
+ "dim",
57
+ "Only showing models from configured providers. Use /login to add providers.",
58
+ ),
59
+ );
60
+ lines.push("");
61
+ lines.push(filterInput.render(width).join("\n"));
62
+ lines.push("");
63
+ lines.push(...selectList.render(width));
64
+ lines.push("");
65
+ if (selected) {
66
+ lines.push(theme.fg("muted", `Model Name: ${selected.label}`));
67
+ }
68
+ return lines;
69
+ },
70
+ invalidate() {
71
+ /* no-op */
72
+ },
73
+ handleInput(data: string) {
74
+ if (
75
+ matchesKey(data, "up") || matchesKey(data, "down") ||
76
+ matchesKey(data, "return") || matchesKey(data, "escape")
77
+ ) {
78
+ selectList.handleInput(data);
79
+ tui.requestRender();
80
+ return;
81
+ }
82
+ filterInput.handleInput(data);
83
+ applyFilter(filterInput.getValue());
84
+ },
85
+ };
86
+ });
87
+ }
88
+
89
+ function buildModelList(
90
+ items: SelectItem[],
91
+ theme: any,
92
+ filterInput: Input,
93
+ done: (value: string | undefined) => void,
94
+ tui: any,
95
+ ): SelectList {
96
+ const maxVisible = Math.min(10, Math.max(1, items.length));
97
+ const list = new SelectList(items, maxVisible, {
98
+ selectedPrefix: (text) => theme.fg("accent", text),
99
+ selectedText: (text) => theme.fg("accent", text),
100
+ description: (text) => theme.fg("muted", text),
101
+ scrollInfo: (text) => theme.fg("dim", text),
102
+ noMatch: (text) => theme.fg("warning", text),
103
+ });
104
+ list.setSelectedIndex(0);
105
+ list.onCancel = () => done(undefined);
106
+ list.onSelect = (item) => done(item.value);
107
+ list.onSelectionChange = () => tui.requestRender();
108
+ filterInput.onSubmit = () => {
109
+ const selected = list.getSelectedItem();
110
+ if (selected) done(selected.value);
111
+ };
112
+ return list;
113
+ }
@@ -0,0 +1,13 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+
3
+ export function parseModelSpec(
4
+ spec: string,
5
+ ): { provider: string; id: string } | undefined {
6
+ const slash = spec.indexOf("/");
7
+ if (slash <= 0 || slash >= spec.length - 1) return undefined;
8
+ return { provider: spec.slice(0, slash), id: spec.slice(slash + 1) };
9
+ }
10
+
11
+ export function formatModelSpec(model: Model<any>): string {
12
+ return `${model.provider}/${model.id}`;
13
+ }