jeo-code 0.8.22 → 0.8.23
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/CHANGELOG.md +21 -0
- package/README.ja.md +14 -2
- package/README.ko.md +14 -2
- package/README.md +14 -2
- package/README.zh.md +14 -2
- package/package.json +1 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/browser-tool.ts +32 -3
- package/src/agent/engine.ts +24 -4
- package/src/agent/loop.ts +3 -2
- package/src/agent/prompt-router.ts +83 -4
- package/src/agent/skill-lessons.ts +315 -0
- package/src/agent/subagents.ts +7 -6
- package/src/agent/task-tool.ts +210 -68
- package/src/agent/vision-verify.ts +75 -0
- package/src/ai/model-catalog.ts +16 -0
- package/src/ai/model-manager.ts +11 -2
- package/src/ai/types.ts +4 -2
- package/src/cli/runner.ts +13 -1
- package/src/commands/launch.ts +118 -23
- package/src/commands/ralplan.ts +40 -3
- package/src/commands/routine.ts +249 -0
- package/src/commands/skills.ts +57 -0
- package/src/util/provider-error.ts +15 -0
- package/src/util/retry.ts +15 -4
- package/src/util/routine-template.ts +206 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill self-compounding: the durable link the bundled/user skill docs were
|
|
3
|
+
* missing. `recordFailedAttempt` (memory.ts) already writes stalled turns into
|
|
4
|
+
* OKF concept memory, but nothing ever fed that signal BACK into the skill
|
|
5
|
+
* files themselves — a skill that repeatedly leads an agent into the same dead
|
|
6
|
+
* end never learns to warn about it. This module closes that loop:
|
|
7
|
+
*
|
|
8
|
+
* - `matchLessonToSkill` deterministically (no LLM) maps free text to the
|
|
9
|
+
* bundled skill it most plausibly concerns, conservatively preferring
|
|
10
|
+
* `undefined` over a wrong guess.
|
|
11
|
+
* - `appendSkillLesson`/`recordSkillLesson` append a lesson bullet into a
|
|
12
|
+
* `## Known Failure Modes` or `## Anti-Patterns (do NOT do)` section of the
|
|
13
|
+
* project-level `.jeo/skills/<name>.md` file (seeded from the bundled
|
|
14
|
+
* skill on first write), idempotent on exact-title re-append.
|
|
15
|
+
* - `evalSkillLessons` is the self-eval half: ONE batched LLM call judges
|
|
16
|
+
* whether each recorded lesson is still "covered" by the skill's current
|
|
17
|
+
* guidance or has gone "stale" (guidance drifted, entry looks obsolete),
|
|
18
|
+
* so a human/agent can prune or fold lessons back into the skill body.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs/promises";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { SKILLS, getSkill, bundledSkillFileContent } from "../skills/catalog";
|
|
23
|
+
import { callLlm } from "./loop";
|
|
24
|
+
import { resolveVerifierModel } from "./prompt-router";
|
|
25
|
+
import { readGlobalConfig } from "./state";
|
|
26
|
+
import { tryExtractJsonObject } from "./json";
|
|
27
|
+
|
|
28
|
+
export interface SkillLesson {
|
|
29
|
+
skill: string;
|
|
30
|
+
kind: "failure-mode" | "anti-pattern";
|
|
31
|
+
title: string;
|
|
32
|
+
detail: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SECTION_HEADER: Record<SkillLesson["kind"], string> = {
|
|
36
|
+
"failure-mode": "## Known Failure Modes",
|
|
37
|
+
"anti-pattern": "## Anti-Patterns (do NOT do)",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Hand-picked keyword sets per bundled skill, used ONLY for the deterministic
|
|
41
|
+
* free-text -> skill matcher below. Kept as one small record (not inline in
|
|
42
|
+
* the function) so the mapping is easy to audit/extend as skills are added. */
|
|
43
|
+
const SKILL_KEYWORDS: Record<string, string[]> = {
|
|
44
|
+
"deep-interview": ["ambiguity", "interview", "clarify", "clarification", "requirements", "socratic", "vague"],
|
|
45
|
+
"deep-dive": ["root cause", "root-cause", "diagnose", "diagnosis", "trace", "hypothesis", "causal"],
|
|
46
|
+
"ralplan": ["critic", "consensus", "[okay]", "[iterate]", "plan draft", "plan critique", "iterate verdict"],
|
|
47
|
+
"team": ["parallel_group", "worktree", "team executor", "plan step", "coordinated agents", "shared task list"],
|
|
48
|
+
"ultragoal": ["verification", "acceptance criteria", "goal verifier", "goal state", "verdict: met", "not_met"],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deterministic (no LLM) free-text -> bundled-skill match. Scores each SKILLS
|
|
53
|
+
* entry by counting keyword/substring hits (the skill's own `name` plus its
|
|
54
|
+
* hand-picked related terms from {@link SKILL_KEYWORDS}) against the lowercased
|
|
55
|
+
* input text, and returns the highest-scoring skill name — or `undefined` when
|
|
56
|
+
* nothing scores at all, since a wrong guess is worse than no guess here.
|
|
57
|
+
*/
|
|
58
|
+
export function matchLessonToSkill(text: string): string | undefined {
|
|
59
|
+
const haystack = text.toLowerCase();
|
|
60
|
+
if (!haystack.trim()) return undefined;
|
|
61
|
+
|
|
62
|
+
let bestName: string | undefined;
|
|
63
|
+
let bestScore = 0;
|
|
64
|
+
for (const skill of SKILLS) {
|
|
65
|
+
const terms = [skill.name.toLowerCase(), ...(SKILL_KEYWORDS[skill.name] ?? [])];
|
|
66
|
+
let score = 0;
|
|
67
|
+
for (const term of terms) {
|
|
68
|
+
if (term && haystack.includes(term)) score++;
|
|
69
|
+
}
|
|
70
|
+
if (score > bestScore) {
|
|
71
|
+
bestScore = score;
|
|
72
|
+
bestName = skill.name;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return bestScore > 0 ? bestName : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Project-level skill file path — `<cwd>/.jeo/skills/<name>.md`, the same
|
|
79
|
+
* highest-precedence directory `skillDirs()` (catalog.ts) resolves last, so a
|
|
80
|
+
* lesson written here transparently overrides/extends the bundled version by
|
|
81
|
+
* name the next time skills are loaded. */
|
|
82
|
+
export function skillFilePath(cwd: string, skillName: string): string {
|
|
83
|
+
return path.join(cwd, ".jeo", "skills", `${skillName}.md`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Locate the target H2 section within `content`'s lines. Returns the line
|
|
87
|
+
* index range `[headerLine, bulletsEnd)` where `bulletsEnd` is the index to
|
|
88
|
+
* insert a new bullet at (immediately before the next `## ` header, or
|
|
89
|
+
* end-of-file, trimming any trailing blank lines inside the section so the
|
|
90
|
+
* new bullet lands directly after the last existing one). `null` when the
|
|
91
|
+
* section header isn't present yet. */
|
|
92
|
+
function findSection(lines: string[], header: string): { headerLine: number; insertAt: number } | null {
|
|
93
|
+
const headerLine = lines.findIndex(l => l.trim() === header);
|
|
94
|
+
if (headerLine < 0) return null;
|
|
95
|
+
let end = lines.length;
|
|
96
|
+
for (let i = headerLine + 1; i < lines.length; i++) {
|
|
97
|
+
if (lines[i]!.startsWith("## ")) { end = i; break; }
|
|
98
|
+
}
|
|
99
|
+
let insertAt = end;
|
|
100
|
+
while (insertAt > headerLine + 1 && lines[insertAt - 1]!.trim() === "") insertAt--;
|
|
101
|
+
return { headerLine, insertAt };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Does a `- **title**: ...` (or bare `- title...`) bullet with this exact
|
|
105
|
+
* title (case-insensitive) already exist between `[start, end)`? */
|
|
106
|
+
function sectionHasTitle(lines: string[], start: number, end: number, title: string): boolean {
|
|
107
|
+
const needle = title.trim().toLowerCase();
|
|
108
|
+
for (let i = start; i < end; i++) {
|
|
109
|
+
const m = lines[i]!.match(/^-\s+\*\*(.+?)\*\*\s*:/);
|
|
110
|
+
if (m && m[1]!.trim().toLowerCase() === needle) return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Core compounding write: append `lesson` as a markdown bullet into the
|
|
117
|
+
* project-level skill file's failure-mode/anti-pattern section, seeding the
|
|
118
|
+
* file from the bundled skill's own on-disk text on first write so existing
|
|
119
|
+
* guidance is never lost. Idempotent on exact-title re-append (case-
|
|
120
|
+
* insensitive) — returns `{ appended: false, reason: "duplicate" }` rather
|
|
121
|
+
* than writing a second copy. Best-effort: any I/O error is caught and
|
|
122
|
+
* reported, never thrown (mirrors `recordFailedAttempt`'s fail-open contract).
|
|
123
|
+
*/
|
|
124
|
+
export async function appendSkillLesson(cwd: string, lesson: SkillLesson): Promise<{ appended: boolean; reason?: string }> {
|
|
125
|
+
const title = lesson.title.trim();
|
|
126
|
+
if (!title) return { appended: false, reason: "empty title" };
|
|
127
|
+
const detail = lesson.detail.trim();
|
|
128
|
+
const header = SECTION_HEADER[lesson.kind];
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const filePath = skillFilePath(cwd, lesson.skill);
|
|
132
|
+
let content: string;
|
|
133
|
+
try {
|
|
134
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
135
|
+
} catch {
|
|
136
|
+
const bundled = getSkill(lesson.skill);
|
|
137
|
+
content = bundled ? bundledSkillFileContent(bundled) : `# ${lesson.skill}\n\n`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
141
|
+
let section = findSection(lines, header);
|
|
142
|
+
if (!section) {
|
|
143
|
+
// Append the section header at the end of the file (blank-line separated).
|
|
144
|
+
if (lines.length > 0 && lines[lines.length - 1]!.trim() !== "") lines.push("");
|
|
145
|
+
if (!(lines.length > 0 && lines[lines.length - 1] === "")) lines.push("");
|
|
146
|
+
lines.push(header, "");
|
|
147
|
+
section = { headerLine: lines.length - 2, insertAt: lines.length };
|
|
148
|
+
} else if (sectionHasTitle(lines, section.headerLine + 1, section.insertAt, title)) {
|
|
149
|
+
return { appended: false, reason: "duplicate" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const bullet = `- **${title}**: ${detail}`;
|
|
153
|
+
lines.splice(section.insertAt, 0, bullet);
|
|
154
|
+
|
|
155
|
+
const finalContent = lines.join("\n").replace(/\n{3,}$/, "\n\n");
|
|
156
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
157
|
+
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
158
|
+
await fs.writeFile(tmpPath, finalContent, "utf-8");
|
|
159
|
+
await fs.rename(tmpPath, filePath);
|
|
160
|
+
return { appended: true };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { appended: false, reason: err instanceof Error ? err.message : String(err) };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Convenience wrapper for the automatic (stall-guard) path: matches `freeText`
|
|
168
|
+
* to a bundled skill via {@link matchLessonToSkill} and, only on a match,
|
|
169
|
+
* writes the lesson via {@link appendSkillLesson}. Unrelated text (no skill
|
|
170
|
+
* match) correctly writes nothing.
|
|
171
|
+
*/
|
|
172
|
+
export async function recordSkillLesson(
|
|
173
|
+
cwd: string,
|
|
174
|
+
freeText: string,
|
|
175
|
+
lesson: Omit<SkillLesson, "skill">,
|
|
176
|
+
): Promise<{ appended: boolean; skill?: string; reason?: string }> {
|
|
177
|
+
const skill = matchLessonToSkill(freeText);
|
|
178
|
+
if (!skill) return { appended: false, reason: "no matching skill" };
|
|
179
|
+
const result = await appendSkillLesson(cwd, { ...lesson, skill });
|
|
180
|
+
return { ...result, skill };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface LessonEvalResult {
|
|
184
|
+
skill: string;
|
|
185
|
+
total: number;
|
|
186
|
+
covered: number;
|
|
187
|
+
stale: Array<{ title: string; reason: string }>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface ParsedBullet {
|
|
191
|
+
title: string;
|
|
192
|
+
detail: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Extract every bullet from `[start, end)`, splitting the `- **title**:
|
|
196
|
+
* detail` format {@link appendSkillLesson} produces — a plain `- detail`
|
|
197
|
+
* bullet (no bold title) tolerantly uses the whole bullet text as both. */
|
|
198
|
+
function extractBullets(lines: string[], start: number, end: number): ParsedBullet[] {
|
|
199
|
+
const out: ParsedBullet[] = [];
|
|
200
|
+
for (let i = start; i < end; i++) {
|
|
201
|
+
const line = lines[i]!;
|
|
202
|
+
if (!line.startsWith("- ")) continue;
|
|
203
|
+
const m = line.match(/^-\s+\*\*(.+?)\*\*\s*:\s*(.*)$/);
|
|
204
|
+
if (m) {
|
|
205
|
+
out.push({ title: m[1]!.trim(), detail: m[2]!.trim() });
|
|
206
|
+
} else {
|
|
207
|
+
const text = line.slice(2).trim();
|
|
208
|
+
if (text) out.push({ title: text, detail: text });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The skill body with BOTH lesson sections (header + their bullets) removed —
|
|
215
|
+
* i.e. just the skill's core guidance, which is what the eval judge should
|
|
216
|
+
* compare each lesson against. */
|
|
217
|
+
function coreBodyWithoutLessons(lines: string[]): string {
|
|
218
|
+
const headers = new Set(Object.values(SECTION_HEADER));
|
|
219
|
+
const kept: string[] = [];
|
|
220
|
+
let skipping = false;
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
if (headers.has(line.trim())) { skipping = true; continue; }
|
|
223
|
+
if (skipping && line.startsWith("## ")) skipping = false;
|
|
224
|
+
if (!skipping) kept.push(line);
|
|
225
|
+
}
|
|
226
|
+
return kept.join("\n").trim();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Self-eval half of the compounding loop: reads the project-level skill file
|
|
231
|
+
* and, for every recorded failure-mode/anti-pattern bullet, asks an
|
|
232
|
+
* independent LLM call whether the skill's CURRENT core guidance still
|
|
233
|
+
* addresses it ("covered") or has drifted ("stale"). Zero bullets short-
|
|
234
|
+
* circuits to a zero-cost result (no LLM call). `null` when the project-level
|
|
235
|
+
* file doesn't exist at all (no lessons ever recorded for this skill).
|
|
236
|
+
* Parse failure fails conservatively: every entry is treated as "covered"
|
|
237
|
+
* rather than risk falsely flagging a lesson as obsolete.
|
|
238
|
+
*/
|
|
239
|
+
export async function evalSkillLessons(
|
|
240
|
+
cwd: string,
|
|
241
|
+
skillName: string,
|
|
242
|
+
opts: { model?: string } = {},
|
|
243
|
+
): Promise<LessonEvalResult | null> {
|
|
244
|
+
const filePath = skillFilePath(cwd, skillName);
|
|
245
|
+
let content: string;
|
|
246
|
+
try {
|
|
247
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
253
|
+
const failureSection = findSection(lines, SECTION_HEADER["failure-mode"]);
|
|
254
|
+
const antiSection = findSection(lines, SECTION_HEADER["anti-pattern"]);
|
|
255
|
+
const bullets: ParsedBullet[] = [
|
|
256
|
+
...(failureSection ? extractBullets(lines, failureSection.headerLine + 1, failureSection.insertAt) : []),
|
|
257
|
+
...(antiSection ? extractBullets(lines, antiSection.headerLine + 1, antiSection.insertAt) : []),
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
if (bullets.length === 0) {
|
|
261
|
+
return { skill: skillName, total: 0, covered: 0, stale: [] };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const model = opts.model ?? resolveVerifierModel(await readGlobalConfig());
|
|
265
|
+
const coreBody = coreBodyWithoutLessons(lines);
|
|
266
|
+
const lessonList = bullets.map((b, i) => `${i + 1}. "${b.title}": ${b.detail}`).join("\n");
|
|
267
|
+
|
|
268
|
+
const systemPrompt = `You are an independent Skill Auditor. You are shown a skill's CURRENT core guidance text and a list of lessons (failure modes / anti-patterns) previously recorded against earlier versions of this skill.
|
|
269
|
+
|
|
270
|
+
For EACH lesson, judge:
|
|
271
|
+
- "covered": the skill's current guidance still clearly addresses this lesson (a reader following the current guidance would avoid/handle it).
|
|
272
|
+
- "stale": the guidance has drifted and no longer clearly addresses this lesson, OR the lesson itself looks obsolete/no-longer-applicable.
|
|
273
|
+
|
|
274
|
+
Respond with ONLY a JSON object: {"results": [{"title": string, "verdict": "covered" | "stale", "reason": string}]}. Include EVERY lesson by its exact title. No markdown, no code fences, no other text.`;
|
|
275
|
+
|
|
276
|
+
const userMessage = `Skill's current core guidance:\n\n${coreBody}\n\nRecorded lessons:\n\n${lessonList}\n\nJudge each lesson.`;
|
|
277
|
+
|
|
278
|
+
const verdictByTitle = new Map<string, { verdict: "covered" | "stale"; reason: string }>();
|
|
279
|
+
try {
|
|
280
|
+
const response = await callLlm(
|
|
281
|
+
[
|
|
282
|
+
{ role: "system", content: systemPrompt },
|
|
283
|
+
{ role: "user", content: userMessage },
|
|
284
|
+
],
|
|
285
|
+
{ model, jsonMode: true, maxTokens: 1500, reasoningEffort: "none" },
|
|
286
|
+
);
|
|
287
|
+
const parsed = tryExtractJsonObject<{ results?: unknown }>(response);
|
|
288
|
+
if (parsed && Array.isArray(parsed.results)) {
|
|
289
|
+
for (const entry of parsed.results) {
|
|
290
|
+
if (
|
|
291
|
+
entry && typeof entry === "object" &&
|
|
292
|
+
"title" in entry && typeof entry.title === "string" &&
|
|
293
|
+
"verdict" in entry && (entry.verdict === "covered" || entry.verdict === "stale")
|
|
294
|
+
) {
|
|
295
|
+
const reason = "reason" in entry && typeof entry.reason === "string" ? entry.reason : "";
|
|
296
|
+
verdictByTitle.set(entry.title.trim().toLowerCase(), { verdict: entry.verdict, reason });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
// fail conservatively below: an empty verdictByTitle treats every bullet as "covered"
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let covered = 0;
|
|
305
|
+
const stale: Array<{ title: string; reason: string }> = [];
|
|
306
|
+
for (const b of bullets) {
|
|
307
|
+
const v = verdictByTitle.get(b.title.trim().toLowerCase());
|
|
308
|
+
if (v && v.verdict === "stale") {
|
|
309
|
+
stale.push({ title: b.title, reason: v.reason || "flagged stale by skill auditor" });
|
|
310
|
+
} else {
|
|
311
|
+
covered++;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { skill: skillName, total: bullets.length, covered, stale };
|
|
315
|
+
}
|
package/src/agent/subagents.ts
CHANGED
|
@@ -16,7 +16,7 @@ import architectPrompt from "../prompts/agents/architect.md" with { type: "text"
|
|
|
16
16
|
import criticPrompt from "../prompts/agents/critic.md" with { type: "text" };
|
|
17
17
|
import executorPrompt from "../prompts/agents/executor.md" with { type: "text" };
|
|
18
18
|
import plannerPrompt from "../prompts/agents/planner.md" with { type: "text" };
|
|
19
|
-
import type
|
|
19
|
+
import { strongestCredentialed, cheapestCredentialed, type PromptTier } from "./prompt-router";
|
|
20
20
|
|
|
21
21
|
const PROMPT_ROUTE_TIERS = ["trivial", "standard", "high", "complex"] as const satisfies readonly PromptTier[];
|
|
22
22
|
|
|
@@ -176,9 +176,6 @@ export function resolveSubagentModel(
|
|
|
176
176
|
const entry = config.subagents?.[normalized];
|
|
177
177
|
if (entry?.model) return entry.model;
|
|
178
178
|
|
|
179
|
-
// Exception: load strongestCredentialed dynamically using require to prevent eager loading and circular dependency issues that break test mocking.
|
|
180
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
181
|
-
const { strongestCredentialed } = require("./prompt-router");
|
|
182
179
|
if (normalized === "executor") {
|
|
183
180
|
return config.roles?.xhigh || config.roles?.slow || strongestCredentialed(config) || config.defaultModel;
|
|
184
181
|
}
|
|
@@ -189,11 +186,15 @@ export function resolveSubagentModel(
|
|
|
189
186
|
return config.roles?.high || strongestCredentialed(config) || config.defaultModel;
|
|
190
187
|
}
|
|
191
188
|
if (normalized === "critic") {
|
|
189
|
+
// Grader role (plan reviewer): prefer an explicit mid/cheap pin, else the
|
|
190
|
+
// live-cheapest credentialed model — a GENERAL search (not 2 hardcoded ids),
|
|
191
|
+
// so critic tracks catalog drift the same way every other role's fallback
|
|
192
|
+
// does instead of silently collapsing to defaultModel when neither o3-mini
|
|
193
|
+
// nor gemini-2.5-flash happens to be credentialed.
|
|
192
194
|
return (
|
|
193
195
|
config.roles?.medium ||
|
|
194
196
|
config.roles?.smol ||
|
|
195
|
-
|
|
196
|
-
strongestCredentialed(config, (m: CatalogModel) => m.canonical === "gemini-2.5-flash") ||
|
|
197
|
+
cheapestCredentialed(config) ||
|
|
197
198
|
config.defaultModel
|
|
198
199
|
);
|
|
199
200
|
}
|