@kal-elsam/kairo-runtime 0.16.0 → 0.17.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.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/scripts/ux-smoke-test.sh +3 -3
- package/src/cli.js +96 -11
- package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
- package/src/global/architect/architect-cli.js +76 -0
- package/src/global/architect/architect-codex.js +146 -0
- package/src/global/architect/architect-manager.js +125 -0
- package/src/global/architect/architect-store.js +377 -0
- package/src/global/architect/architect-types.js +47 -0
- package/src/global/cli-help.js +10 -1
- package/src/global/cockpit/app.js +475 -0
- package/src/global/cockpit/card.js +111 -0
- package/src/global/cockpit/cli.js +33 -0
- package/src/global/cockpit/gauge.js +31 -0
- package/src/global/cockpit/project-overlay.js +683 -0
- package/src/global/cockpit/rows.js +148 -0
- package/src/global/cockpit/theme.js +118 -0
- package/src/global/cockpit/view.js +1263 -0
- package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
- package/src/global/conversation/cli.js +53 -0
- package/src/global/conversation/codex-sandbox.js +230 -0
- package/src/global/conversation/cursor-sandbox.js +215 -0
- package/src/global/conversation/project-analysis.js +204 -0
- package/src/global/conversation/project-profile.js +178 -0
- package/src/global/conversation/project-router.js +149 -0
- package/src/global/conversation/project-strategy-store.js +64 -0
- package/src/global/conversation/project-strategy.js +514 -0
- package/src/global/conversation/sanitized-snapshot.js +169 -0
- package/src/global/conversation/secret-scanner.js +71 -0
- package/src/global/conversation/service.js +1063 -0
- package/src/global/conversation/session-store.js +75 -0
- package/src/global/conversation/transcript-store.js +79 -0
- package/src/global/conversation/ui.js +195 -0
- package/src/global/intelligence/capability-scoring.js +480 -0
- package/src/global/intelligence/execution-router.js +444 -0
- package/src/global/intelligence/kairo-telemetry-source.js +59 -0
- package/src/global/intelligence/kairobench-runner.js +85 -0
- package/src/global/intelligence/kairobench-source.js +34 -0
- package/src/global/intelligence/kairobench-tasks.js +47 -0
- package/src/global/intelligence/model-candidate-catalog.js +456 -0
- package/src/global/intelligence/model-capability-registry-sources.js +145 -0
- package/src/global/intelligence/model-capability-registry.js +125 -0
- package/src/global/intelligence/model-intelligence.js +1646 -0
- package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
- package/src/global/intelligence/quick-ask.js +149 -0
- package/src/global/intelligence/role-profiles.js +251 -0
- package/src/global/intelligence/skill-catalog.js +67 -0
- package/src/global/intelligence/subscription-pressure-source.js +41 -0
- package/src/global/mcp/kairo-mcp.js +51 -18
- package/src/global/mcp/work-snapshot-rule.js +4 -2
- package/src/global/mcp/workspace-binding.js +88 -0
- package/src/global/mcp/workspace-mcp-entry.js +74 -0
- package/src/global/mcp-install.js +8 -1
- package/src/global/observability/artificial-analysis-models.js +118 -0
- package/src/global/observability/claude-models.js +31 -0
- package/src/global/observability/claude-usage.js +112 -0
- package/src/global/observability/codex-models.js +96 -0
- package/src/global/observability/codex-usage.js +160 -0
- package/src/global/observability/cursor-auth.js +88 -0
- package/src/global/observability/cursor-models.js +101 -0
- package/src/global/observability/huggingface-leaderboard.js +97 -0
- package/src/global/observability/opencode-models.js +101 -0
- package/src/global/observability/opencode-usage.js +162 -0
- package/src/global/paths.js +49 -2
- package/src/global/profile.js +23 -1
- package/src/global/runtime/execution-adapters/claude.js +63 -30
- package/src/global/runtime/execution-adapters/codex.js +9 -2
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
- package/src/global/runtime/execution-adapters/opencode.js +83 -18
- package/src/global/runtime/execution-worktree-manager.js +924 -0
- package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
- package/src/global/runtime/execution-worktree-store.js +83 -0
- package/src/global/runtime/execution-worktree-types.js +45 -0
- package/src/global/runtime/run-events.js +38 -0
- package/src/global/runtime/run-manager.js +22 -6
- package/src/global/runtime/run-supervisor.js +41 -12
- package/src/global/runtime/usage-manager.js +96 -0
- package/src/global/runtime/usage-store.js +69 -0
- package/src/global/runtime/usage-types.js +62 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// Task -> provider/model routing. Deliberately NOT a black box: every
|
|
2
|
+
// decision is traceable to the exact keywords that matched and the exact
|
|
3
|
+
// real availability/quota data that ruled a candidate in or out. This is a
|
|
4
|
+
// heuristic keyword classifier, not a learned model — do not oversell it as
|
|
5
|
+
// "AI routing" in any UI copy that surfaces its output.
|
|
6
|
+
//
|
|
7
|
+
// Distinct from `intelligence/router.js`, which selects among model
|
|
8
|
+
// *backends* (Ollama/Zen/OpenRouter) for a different purpose — this module
|
|
9
|
+
// selects among Kairo's execution adapters (codex/claude/opencode-go/
|
|
10
|
+
// opencode-zen/cursor) for running an approved task.
|
|
11
|
+
|
|
12
|
+
const REPETITIVE_KEYWORDS = [
|
|
13
|
+
"rename", "boilerplate", "mock", "fixture", "typo", "lint", "format",
|
|
14
|
+
"docstring", "comment", "changelog", "readme", "scaffold", "stub"
|
|
15
|
+
];
|
|
16
|
+
const REASONING_KEYWORDS = [
|
|
17
|
+
"architecture", "design", "investigate", "root cause", "race condition",
|
|
18
|
+
"refactor", "security", "performance", "algorithm", "diagnose", "why does",
|
|
19
|
+
"tradeoff", "evaluate"
|
|
20
|
+
];
|
|
21
|
+
const MULTI_FILE_KEYWORDS = [
|
|
22
|
+
"integrate", "multi-file", "across", "backend and frontend", "full feature",
|
|
23
|
+
"migrate", "end to end", "end-to-end", "wire up", "plumb"
|
|
24
|
+
];
|
|
25
|
+
const QUESTION_WORDS = [
|
|
26
|
+
"qué", "que", "cómo", "como", "cuál", "cual", "cuáles", "por qué", "porque",
|
|
27
|
+
"quién", "quien", "dónde", "donde", "cuándo", "cuando",
|
|
28
|
+
"what", "how", "why", "which", "who", "where", "when",
|
|
29
|
+
"is ", "are ", "does ", "do ", "can ", "could ", "should ", "explain", "explica", "describe"
|
|
30
|
+
];
|
|
31
|
+
const ACTION_VERBS = [
|
|
32
|
+
"implementa", "implement", "agrega", "add", "crea", "create", "arregla", "fix",
|
|
33
|
+
"refactoriza", "refactor", "migra", "migrate", "actualiza", "update", "elimina", "remove",
|
|
34
|
+
"borra", "delete", "cambia", "change", "escribe", "write", "rename", "renombra",
|
|
35
|
+
"integra", "integrate", "corrige", "wire up", "build", "construye"
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A read-only heuristic — never a model call — for whether a prompt reads
|
|
40
|
+
* like a question/exploration rather than a change request. Deliberately
|
|
41
|
+
* conservative: an action verb anywhere in the text always wins (so "explain
|
|
42
|
+
* this, then fix the bug" still routes as a task), and anything not clearly
|
|
43
|
+
* question-shaped falls back to "task" (today's existing behavior), so this
|
|
44
|
+
* can only ever remove work Kairo used to do wrong, never add new
|
|
45
|
+
* uncertainty to what already worked.
|
|
46
|
+
* @param {string} text
|
|
47
|
+
*/
|
|
48
|
+
export function isLikelyQuestion(text) {
|
|
49
|
+
const normalized = String(text ?? "").trim().toLowerCase();
|
|
50
|
+
if (!normalized) return false;
|
|
51
|
+
if (ACTION_VERBS.some((verb) => normalized.includes(verb))) return false;
|
|
52
|
+
if (normalized.endsWith("?")) return true;
|
|
53
|
+
return QUESTION_WORDS.some((word) => normalized.startsWith(word));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RISK_KEYWORDS = [
|
|
57
|
+
"auth", "authentication", "payment", "security", "production", "credential",
|
|
58
|
+
"secret", "delete", "drop table", "migration", "billing", "pii"
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Matches in the order they actually appear in the text, so a "why" built from them reads naturally. */
|
|
62
|
+
/**
|
|
63
|
+
* Matches a task against the project's real skill catalog (name +
|
|
64
|
+
* description, read by skill-catalog.js from each SKILL.md — never
|
|
65
|
+
* guessed from a bare folder name). Word-overlap only, sorted by overlap
|
|
66
|
+
* size — surfaced as transparent evidence in the routing "why", not (yet)
|
|
67
|
+
* a factor that changes which provider is picked; that needs more design
|
|
68
|
+
* than a simple overlap heuristic should be trusted with.
|
|
69
|
+
* @param {string} taskText
|
|
70
|
+
* @param {Array<{name: string, description: string}>} skills
|
|
71
|
+
*/
|
|
72
|
+
export function matchSkills(taskText, skills = []) {
|
|
73
|
+
const taskWords = new Set(String(taskText ?? "").toLowerCase().split(/\W+/).filter((word) => word.length > 3));
|
|
74
|
+
const matches = [];
|
|
75
|
+
for (const skill of skills) {
|
|
76
|
+
const descriptionWords = String(skill.description ?? "").toLowerCase().split(/\W+/).filter((word) => word.length > 3);
|
|
77
|
+
const overlap = descriptionWords.filter((word) => taskWords.has(word));
|
|
78
|
+
if (overlap.length > 0) matches.push({ name: skill.name, overlap: [...new Set(overlap)] });
|
|
79
|
+
}
|
|
80
|
+
return matches.sort((a, b) => b.overlap.length - a.overlap.length);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function countMatches(text, keywords) {
|
|
84
|
+
return keywords
|
|
85
|
+
.map((keyword) => ({ keyword, index: text.indexOf(keyword) }))
|
|
86
|
+
.filter((entry) => entry.index !== -1)
|
|
87
|
+
.sort((a, b) => a.index - b.index)
|
|
88
|
+
.map((entry) => entry.keyword);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Pure keyword classifier — no model call, no network, deterministic and
|
|
93
|
+
* fast enough to run on every task submission. Returns which keywords
|
|
94
|
+
* actually matched (not just a score) so the router's "why" can quote them.
|
|
95
|
+
* @param {string} taskText
|
|
96
|
+
*/
|
|
97
|
+
export function classifyTask(taskText) {
|
|
98
|
+
const text = String(taskText ?? "").toLowerCase();
|
|
99
|
+
const repetitive = countMatches(text, REPETITIVE_KEYWORDS);
|
|
100
|
+
const reasoning = countMatches(text, REASONING_KEYWORDS);
|
|
101
|
+
const multiFile = countMatches(text, MULTI_FILE_KEYWORDS);
|
|
102
|
+
const risk = countMatches(text, RISK_KEYWORDS);
|
|
103
|
+
return {
|
|
104
|
+
repetitive, reasoning, multiFile, risk,
|
|
105
|
+
repetitionScore: repetitive.length,
|
|
106
|
+
reasoningScore: reasoning.length,
|
|
107
|
+
multiFileScore: multiFile.length,
|
|
108
|
+
riskScore: risk.length
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} adapterId - "codex" | "claude" | "opencode-go" | "opencode-zen" | "cursor"
|
|
114
|
+
* @param {ReturnType<typeof import("../runtime/execution-adapters/index.js").inspectExecutionAdapters>} adapters
|
|
115
|
+
*/
|
|
116
|
+
function findAdapter(adapterId, adapters) {
|
|
117
|
+
const baseId = adapterId.startsWith("opencode") ? "opencode" : adapterId;
|
|
118
|
+
return adapters.find((adapter) => adapter.id === baseId) ?? null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {object|null} usageEntry - a codex/claude usage-probe result (primary/secondary windows) */
|
|
122
|
+
function remainingPercent(usageEntry) {
|
|
123
|
+
return usageEntry?.primary?.remainingPercent ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Below this real remaining-quota percentage, a provider is treated as
|
|
127
|
+
// exhausted for automatic routing — conserved for the tests explicitly
|
|
128
|
+
// listed as this increment's scope, not a newly-invented number.
|
|
129
|
+
const MIN_QUOTA_PERCENT = 5;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The single eligibility policy shared by execution routing, ask routing,
|
|
133
|
+
* and the FIT widget — one candidate is judged the same way everywhere, so
|
|
134
|
+
* a provider that FIT recommends is guaranteed to actually be launchable.
|
|
135
|
+
* Real availability/launchability/quota only; never a capability judgment
|
|
136
|
+
* (that's scoreAvailableModels' job, applied only to survivors of this).
|
|
137
|
+
* @param {string} adapterId - "codex" | "claude" | "opencode-go" | "opencode-zen" | "cursor"
|
|
138
|
+
* @param {{adapters: object[], codexUsage?: object|null, claudeUsage?: object|null, opencodeGoUsage?: object|null}} context
|
|
139
|
+
* @returns {{ok: boolean, reason: string|null}}
|
|
140
|
+
* @param {{requireLaunchable?: boolean}} [options] - `requireLaunchable: false`
|
|
141
|
+
* is for AI TEAM's recommendation surface only (service.js's snapshot()):
|
|
142
|
+
* it lets opencode-go be named as a real, accessible option — you do
|
|
143
|
+
* have the model via the Go subscription — without claiming Kairo can
|
|
144
|
+
* safely auto-execute through it yet (see opencode.js's checkAvailability
|
|
145
|
+
* for why: no per-event way to prove a run didn't silently bill Zen).
|
|
146
|
+
* It also lets Cursor be named as a real recommendation (e.g. Builder ->
|
|
147
|
+
* Cursor Composer 2.5) even though Kairo never auto-executes through it —
|
|
148
|
+
* the user continues that work manually inside the Cursor IDE; this is
|
|
149
|
+
* permanent for Cursor, not a temporary safety gap like opencode-go's.
|
|
150
|
+
* Real task routing (selectExecutionProvider/selectAskProvider) always
|
|
151
|
+
* uses the default `true` — it must never pick something guaranteed to
|
|
152
|
+
* fail at launch (run-manager.js's own launchable gate would reject it).
|
|
153
|
+
*/
|
|
154
|
+
export function checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, opencodeGoUsage }, { requireLaunchable = true } = {}) {
|
|
155
|
+
// Zen carries real PAYG/billing risk (see conversation/service.js's
|
|
156
|
+
// capabilities.openCodeExecution) — never an automatic pick, regardless
|
|
157
|
+
// of what its real catalog/benchmarks might otherwise say.
|
|
158
|
+
if (adapterId === "opencode-zen") return { ok: false, reason: "OpenCode Zen is excluded from automatic routing (PAYG risk)" };
|
|
159
|
+
// Cursor is a deliberate manual-only destination for real task
|
|
160
|
+
// execution — recommendation and execution are different capabilities
|
|
161
|
+
// (see model-intelligence.js's role assignments): Kairo can genuinely
|
|
162
|
+
// recommend a real Cursor model for a role (Builder -> Cursor Composer
|
|
163
|
+
// 2.5, say), the same requireLaunchable:false exception opencode-go
|
|
164
|
+
// already gets below, but real task routing (requireLaunchable: true,
|
|
165
|
+
// the default) always refuses it — the user continues that work
|
|
166
|
+
// manually inside the Cursor IDE, never an automatic Kairo-launched run.
|
|
167
|
+
if (adapterId === "cursor" && requireLaunchable) {
|
|
168
|
+
return { ok: false, reason: "Cursor is manual-only — continue the work in the Cursor IDE, never auto-executed by Kairo" };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const adapter = findAdapter(adapterId, adapters);
|
|
172
|
+
if (!adapter) return { ok: false, reason: `${adapterId}: no adapter found` };
|
|
173
|
+
if (!adapter.available) return { ok: false, reason: adapter.reason ?? `${adapterId}: not available` };
|
|
174
|
+
// "launchable" means safe for Kairo to invoke programmatically — not the
|
|
175
|
+
// bar a manual recommendation needs. Cursor gets the same exemption
|
|
176
|
+
// opencode-go already has: real availability is enough to recommend it,
|
|
177
|
+
// even though (unlike opencode-go) it's exempt from launchable for a
|
|
178
|
+
// structurally different reason — it will NEVER be launchable, by design.
|
|
179
|
+
const launchableRequired = requireLaunchable || (adapterId !== "opencode-go" && adapterId !== "cursor");
|
|
180
|
+
if (launchableRequired && !adapter.launchable) return { ok: false, reason: adapter.reason ?? `${adapterId}: not launchable yet` };
|
|
181
|
+
|
|
182
|
+
if (adapterId === "codex") {
|
|
183
|
+
const left = remainingPercent(codexUsage);
|
|
184
|
+
if (left != null && left < MIN_QUOTA_PERCENT) return { ok: false, reason: `Codex quota nearly exhausted (${left}% left)` };
|
|
185
|
+
}
|
|
186
|
+
if (adapterId === "claude") {
|
|
187
|
+
const left = remainingPercent(claudeUsage);
|
|
188
|
+
if (left != null && left < MIN_QUOTA_PERCENT) return { ok: false, reason: `Claude quota nearly exhausted (${left}% left)` };
|
|
189
|
+
}
|
|
190
|
+
if (adapterId === "opencode-go") {
|
|
191
|
+
const windows = opencodeGoUsage?.go?.windows ?? opencodeGoUsage?.windows ?? [];
|
|
192
|
+
// A cap being hit blocks real requests regardless of other windows
|
|
193
|
+
// having headroom — any rate-limited window is real evidence of that,
|
|
194
|
+
// so it's the conservative (fail-closed) reading, not a guess.
|
|
195
|
+
const limited = windows.find((window) => window.status === "rate-limited");
|
|
196
|
+
if (limited) return { ok: false, reason: `OpenCode Go ${limited.name} window is rate-limited` };
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, reason: null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Picks the provider's default model from its real catalog; null (never a guessed id) if none is marked default. */
|
|
202
|
+
function defaultModelFor(adapterId, catalogs) {
|
|
203
|
+
const catalogKey = adapterId === "opencode-go" ? "opencodeGo" : adapterId === "opencode-zen" ? "opencodeZen" : adapterId;
|
|
204
|
+
const catalog = catalogs?.[catalogKey];
|
|
205
|
+
if (!catalog || catalog.status === "unknown") return null;
|
|
206
|
+
const models = catalog.models ?? [];
|
|
207
|
+
return models.find((model) => model.isDefault)?.id ?? models[0]?.id ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Short with no reasoning/risk signal doesn't need the account's biggest
|
|
211
|
+
// model — "what does this project do?" or "fix the button color" shouldn't
|
|
212
|
+
// burn the same model as "why is there a race condition in the auth flow?".
|
|
213
|
+
const LIGHT_TASK_MAX_LENGTH = 100;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* How much model capability a task (question OR real execution work)
|
|
217
|
+
* actually needs. Reuses classifyTask()'s real keyword signal rather than
|
|
218
|
+
* a separate heuristic — anything that reads as reasoning-heavy or touches
|
|
219
|
+
* a risk keyword still deserves a capable model even if it's short ("why
|
|
220
|
+
* does auth break?"). Governs which MODEL a provider uses, independent of
|
|
221
|
+
* which PROVIDER gets picked (candidateOrder's job) — a trivial fix still
|
|
222
|
+
* goes to whichever provider the task shape favors, just with its
|
|
223
|
+
* cheapest adequate model instead of automatically reaching for the top.
|
|
224
|
+
* @param {string} taskText
|
|
225
|
+
* @returns {"light"|"standard"|"heavy"}
|
|
226
|
+
*/
|
|
227
|
+
export function classifyEffort(taskText) {
|
|
228
|
+
const text = String(taskText ?? "");
|
|
229
|
+
const profile = classifyTask(text);
|
|
230
|
+
// Reasoning or risk always means real complexity, regardless of length —
|
|
231
|
+
// "why does auth break?" is short but not trivial. Multi-file/integration
|
|
232
|
+
// scope counts too, UNLESS it's dominated by repetition: "rename this DTO
|
|
233
|
+
// field across 15 files" spans many files but is mechanical, not complex
|
|
234
|
+
// — the repetition signal is real evidence the multi-file spread doesn't
|
|
235
|
+
// make it harder, just wider.
|
|
236
|
+
if (profile.reasoningScore > 0 || profile.riskScore > 0) return "heavy";
|
|
237
|
+
if (profile.multiFileScore > 0 && profile.multiFileScore > profile.repetitionScore) return "heavy";
|
|
238
|
+
if (text.trim().length <= LIGHT_TASK_MAX_LENGTH) return "light";
|
|
239
|
+
return "standard";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Anthropic's own public model line naming (Haiku < Sonnet < Opus) is a
|
|
243
|
+
// real, documented capability ordering — not a guess — so it's safe to
|
|
244
|
+
// match against real catalog entries by name.
|
|
245
|
+
const CLAUDE_EFFORT_NAME_PATTERNS = { light: "haiku", standard: "sonnet", heavy: "opus" };
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Picks a real model id from the provider's actual catalog for the given
|
|
249
|
+
* effort tier, falling back to the provider's own default when no matching
|
|
250
|
+
* tier exists in that catalog (never a fabricated id).
|
|
251
|
+
* @param {string} adapterId
|
|
252
|
+
* @param {"light"|"standard"|"heavy"} effort
|
|
253
|
+
* @param {object} catalogs
|
|
254
|
+
*/
|
|
255
|
+
function pickModelForEffort(adapterId, effort, catalogs) {
|
|
256
|
+
if (adapterId === "claude") {
|
|
257
|
+
const models = catalogs?.claude?.models ?? [];
|
|
258
|
+
const pattern = CLAUDE_EFFORT_NAME_PATTERNS[effort];
|
|
259
|
+
const tiered = pattern ? models.find((model) => model.id.toLowerCase().includes(pattern)) : null;
|
|
260
|
+
if (tiered) return tiered.id;
|
|
261
|
+
}
|
|
262
|
+
// OpenCode Go's real catalog reports a real per-model cost
|
|
263
|
+
// (costInputPerMTok) — no naming convention to trust here, but real
|
|
264
|
+
// price is itself a real, ungamed signal: sort by it and pick the
|
|
265
|
+
// cheapest/median/priciest model for light/standard/heavy. Codex and
|
|
266
|
+
// Cursor's real catalogs carry neither a naming convention nor a cost
|
|
267
|
+
// field (verified: id/displayName/isDefault/hidden only), so picking a
|
|
268
|
+
// tier for them would be inventing data instead of reading it — they
|
|
269
|
+
// keep using the provider's own default.
|
|
270
|
+
if (adapterId === "opencode-go") {
|
|
271
|
+
const withCost = (catalogs?.opencodeGo?.models ?? []).filter((model) => typeof model.costInputPerMTok === "number");
|
|
272
|
+
if (withCost.length > 0) {
|
|
273
|
+
const sorted = [...withCost].sort((a, b) => a.costInputPerMTok - b.costInputPerMTok);
|
|
274
|
+
if (effort === "light") return sorted[0].id;
|
|
275
|
+
if (effort === "heavy") return sorted[sorted.length - 1].id;
|
|
276
|
+
return sorted[Math.floor((sorted.length - 1) / 2)].id;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return defaultModelFor(adapterId, catalogs);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Decides an ordered candidate list (most to least preferred) from the
|
|
284
|
+
* classification alone — availability/quota filtering happens next, in
|
|
285
|
+
* `selectExecutionProvider`. Kept separate so the "why this order" reasoning
|
|
286
|
+
* stays inspectable.
|
|
287
|
+
*/
|
|
288
|
+
function candidateOrder(profile) {
|
|
289
|
+
if (profile.riskScore > 0 && (profile.reasoningScore > 0 || profile.multiFileScore > 0)) {
|
|
290
|
+
return { needsApproval: true, order: [] };
|
|
291
|
+
}
|
|
292
|
+
if (profile.reasoningScore >= profile.multiFileScore && profile.reasoningScore >= profile.repetitionScore && profile.reasoningScore > 0) {
|
|
293
|
+
return { needsApproval: false, order: ["codex", "claude"] };
|
|
294
|
+
}
|
|
295
|
+
if (profile.multiFileScore > 0) {
|
|
296
|
+
return { needsApproval: false, order: ["claude", "codex"] };
|
|
297
|
+
}
|
|
298
|
+
if (profile.repetitionScore > 0) {
|
|
299
|
+
return { needsApproval: false, order: ["opencode-go", "claude"] };
|
|
300
|
+
}
|
|
301
|
+
// No signal either way — today's existing default behavior, not a guess.
|
|
302
|
+
return { needsApproval: false, order: ["claude", "codex"] };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function reasonPhrase(adapterId, profile) {
|
|
306
|
+
if (adapterId === "codex") return profile.reasoning.length ? `reasoning task (${profile.reasoning.join(", ")})` : "default planning provider";
|
|
307
|
+
if (adapterId === "claude") return profile.multiFile.length ? `multi-file/integration task (${profile.multiFile.join(", ")})` : "default implementation provider";
|
|
308
|
+
if (adapterId === "opencode-go") return `repetitive/low-risk task (${profile.repetitive.join(", ")})`;
|
|
309
|
+
return adapterId;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Orders ask candidates by real remaining quota so repeated questions don't
|
|
314
|
+
* always burn the same account — but only when BOTH sides have a real
|
|
315
|
+
* measured number; with either one unknown there's no honest comparison to
|
|
316
|
+
* make, so it keeps today's existing claude-first default instead of
|
|
317
|
+
* guessing which side "probably" has more room.
|
|
318
|
+
* @param {number|null} codexRemaining
|
|
319
|
+
* @param {number|null} claudeRemaining
|
|
320
|
+
*/
|
|
321
|
+
function pickAskOrder(codexRemaining, claudeRemaining) {
|
|
322
|
+
if (codexRemaining == null || claudeRemaining == null) return { order: ["claude", "codex"], usedQuota: false };
|
|
323
|
+
return codexRemaining >= claudeRemaining
|
|
324
|
+
? { order: ["codex", "claude"], usedQuota: true }
|
|
325
|
+
: { order: ["claude", "codex"], usedQuota: true };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Routes a read-only question to a provider — deliberately NOT the same
|
|
330
|
+
* risk/reasoning gating as selectExecutionProvider: a question ABOUT a
|
|
331
|
+
* risky topic ("how does auth work here?") is itself completely safe,
|
|
332
|
+
* unlike actually implementing changes to it, so this never returns
|
|
333
|
+
* WAIT_FOR_APPROVAL. Just: is a real, quota-healthy, ask-capable provider
|
|
334
|
+
* available, with its real default model.
|
|
335
|
+
* @param {object} args
|
|
336
|
+
* @param {object[]} args.adapters
|
|
337
|
+
* @param {object|null} [args.codexUsage]
|
|
338
|
+
* @param {object|null} [args.claudeUsage]
|
|
339
|
+
* @param {object} [args.catalogs]
|
|
340
|
+
* @param {string} [args.taskText] - the real question text, used only to size
|
|
341
|
+
* how much model capability it needs (see classifyEffort) — never to
|
|
342
|
+
* change which provider is picked or to gate on risk.
|
|
343
|
+
*/
|
|
344
|
+
export function selectAskProvider({ adapters, codexUsage = null, claudeUsage = null, catalogs = {}, taskText = "" }) {
|
|
345
|
+
const effort = classifyEffort(taskText);
|
|
346
|
+
const { order, usedQuota } = pickAskOrder(remainingPercent(codexUsage), remainingPercent(claudeUsage));
|
|
347
|
+
const attempts = [];
|
|
348
|
+
for (const adapterId of order) {
|
|
349
|
+
const check = checkCandidate(adapterId, { adapters, codexUsage, claudeUsage });
|
|
350
|
+
attempts.push({ adapterId, ...check });
|
|
351
|
+
if (check.ok) {
|
|
352
|
+
const quotaNote = usedQuota ? "; more real quota remaining" : "";
|
|
353
|
+
return {
|
|
354
|
+
decision: "ROUTED",
|
|
355
|
+
provider: adapterId,
|
|
356
|
+
model: pickModelForEffort(adapterId, effort, catalogs),
|
|
357
|
+
why: `read-only question (${effort} effort${quotaNote})`,
|
|
358
|
+
rejectedCandidates: attempts.filter((entry) => !entry.ok)
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
decision: "NO_PROVIDER_AVAILABLE",
|
|
364
|
+
provider: null,
|
|
365
|
+
model: null,
|
|
366
|
+
why: `no ask-capable provider available: ${attempts.map((entry) => entry.reason).join("; ")}`,
|
|
367
|
+
rejectedCandidates: attempts
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The actual router: classification -> ordered candidates -> real
|
|
373
|
+
* availability/quota filtering -> a real model id from a real catalog, or
|
|
374
|
+
* an explicit WAIT_FOR_APPROVAL decision when risk is too high to route
|
|
375
|
+
* automatically. Never returns a provider that failed its availability
|
|
376
|
+
* check, and never returns a model id that isn't in that provider's real
|
|
377
|
+
* catalog.
|
|
378
|
+
*
|
|
379
|
+
* @param {object} args
|
|
380
|
+
* @param {string} args.task
|
|
381
|
+
* @param {object[]} args.adapters - inspectExecutionAdapters() result
|
|
382
|
+
* @param {object|null} [args.codexUsage]
|
|
383
|
+
* @param {object|null} [args.claudeUsage]
|
|
384
|
+
* @param {object} [args.catalogs] - { codex, opencodeGo, opencodeZen, cursor, claude } readXModels() results
|
|
385
|
+
*/
|
|
386
|
+
export function selectExecutionProvider({
|
|
387
|
+
task, adapters, codexUsage = null, claudeUsage = null, opencodeGoUsage = null, catalogs = {}, skills = []
|
|
388
|
+
}) {
|
|
389
|
+
const profile = classifyTask(task);
|
|
390
|
+
const { needsApproval, order } = candidateOrder(profile);
|
|
391
|
+
const matchedSkills = matchSkills(task, skills);
|
|
392
|
+
const skillNote = matchedSkills.length > 0
|
|
393
|
+
? ` · matches skill "${matchedSkills[0].name}" (${matchedSkills[0].overlap.join(", ")})`
|
|
394
|
+
: "";
|
|
395
|
+
// Which provider handles the task and how capable a model it needs are
|
|
396
|
+
// separate questions — a trivial fix still goes wherever the task shape
|
|
397
|
+
// favors, just with the cheapest adequate model instead of always
|
|
398
|
+
// reaching for the account's top one (e.g. Claude Opus/Fable for
|
|
399
|
+
// "fix the button color").
|
|
400
|
+
const effort = classifyEffort(task);
|
|
401
|
+
|
|
402
|
+
if (needsApproval) {
|
|
403
|
+
return {
|
|
404
|
+
decision: "WAIT_FOR_APPROVAL",
|
|
405
|
+
provider: null,
|
|
406
|
+
model: null,
|
|
407
|
+
why: `high risk (${profile.risk.join(", ")}) combined with reasoning/multi-file scope — routing automatically would be unsafe; a human should pick the provider.${skillNote}`,
|
|
408
|
+
fallback: null,
|
|
409
|
+
matchedSkills,
|
|
410
|
+
profile
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const attempts = [];
|
|
415
|
+
for (const adapterId of order) {
|
|
416
|
+
const check = checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, opencodeGoUsage });
|
|
417
|
+
attempts.push({ adapterId, ...check });
|
|
418
|
+
if (check.ok) {
|
|
419
|
+
const model = pickModelForEffort(adapterId, effort, catalogs);
|
|
420
|
+
const remaining = order.slice(order.indexOf(adapterId) + 1);
|
|
421
|
+
return {
|
|
422
|
+
decision: "ROUTED",
|
|
423
|
+
provider: adapterId,
|
|
424
|
+
model,
|
|
425
|
+
why: `${reasonPhrase(adapterId, profile)} (${effort} effort)${skillNote}`,
|
|
426
|
+
fallback: remaining[0] ?? null,
|
|
427
|
+
rejectedCandidates: attempts.filter((entry) => !entry.ok),
|
|
428
|
+
matchedSkills,
|
|
429
|
+
profile
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
decision: "NO_PROVIDER_AVAILABLE",
|
|
436
|
+
provider: null,
|
|
437
|
+
model: null,
|
|
438
|
+
why: `every candidate provider was unavailable: ${attempts.map((entry) => entry.reason).join("; ")}`,
|
|
439
|
+
fallback: null,
|
|
440
|
+
rejectedCandidates: attempts,
|
|
441
|
+
matchedSkills,
|
|
442
|
+
profile
|
|
443
|
+
};
|
|
444
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Kairo's own real runtime telemetry as a Model Intelligence Foundation
|
|
2
|
+
// source. Every real task Kairo executes already gets its duration, token
|
|
3
|
+
// usage, cost, and outcome recorded per run (see applyEventToMetadata in
|
|
4
|
+
// run-events.js) — this just wires that already-collected data into the
|
|
5
|
+
// same CapabilityRegistry as every external source, with zero new cost
|
|
6
|
+
// and zero new benchmark runs. It's the one source with real 100%
|
|
7
|
+
// coverage of exactly what you actually use: every model, every provider,
|
|
8
|
+
// in your real environment, on your real tasks — not a public
|
|
9
|
+
// benchmark's idea of what matters.
|
|
10
|
+
//
|
|
11
|
+
// Only terminal (finished) runs with a known model are ingested: a run
|
|
12
|
+
// still in progress has no real duration or outcome yet, and a run with
|
|
13
|
+
// no recorded model can't be attributed to one. Kairo directly observing
|
|
14
|
+
// its own runs is treated as `verified: true` — there is no more
|
|
15
|
+
// first-party a measurement can get.
|
|
16
|
+
|
|
17
|
+
import { isTerminalRunState, RUN_STATES } from "../runtime/run-types.js";
|
|
18
|
+
|
|
19
|
+
export const TELEMETRY_METRICS = Object.freeze({
|
|
20
|
+
DURATION_MS: "kairo.durationMs",
|
|
21
|
+
TOTAL_TOKENS: "kairo.totalTokens",
|
|
22
|
+
COST: "kairo.cost",
|
|
23
|
+
SUCCESS: "kairo.success"
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {ReturnType<import("./model-capability-registry.js").createCapabilityRegistry>} registry
|
|
28
|
+
* @param {Array<object>} runRecords - listRunRecords() output (real run metadata)
|
|
29
|
+
*/
|
|
30
|
+
export function ingestKairoTelemetryEvidence(registry, runRecords) {
|
|
31
|
+
for (const run of runRecords ?? []) {
|
|
32
|
+
if (!run?.model || !run?.agentId) continue;
|
|
33
|
+
if (!isTerminalRunState(run.state)) continue;
|
|
34
|
+
|
|
35
|
+
const id = registry.registerIdentity(run.agentId, run.model);
|
|
36
|
+
const date = run.completedAt ?? run.updatedAt ?? null;
|
|
37
|
+
// runId identifies WHICH real run each measurement came from — never
|
|
38
|
+
// averaged into a single number here; that's for whatever consumes
|
|
39
|
+
// this evidence to decide, same as every other source in this registry.
|
|
40
|
+
const addTelemetryEvidence = (metric, value) => {
|
|
41
|
+
if (value == null) return;
|
|
42
|
+
registry.addEvidence(id, {
|
|
43
|
+
metric, value, source: "kairo-telemetry",
|
|
44
|
+
benchmarkVersion: null, modelConfig: run.runId, date, verified: true
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const startedAtMs = Date.parse(run.startedAt ?? "");
|
|
49
|
+
const completedAtMs = Date.parse(run.completedAt ?? "");
|
|
50
|
+
if (Number.isFinite(startedAtMs) && Number.isFinite(completedAtMs) && completedAtMs >= startedAtMs) {
|
|
51
|
+
addTelemetryEvidence(TELEMETRY_METRICS.DURATION_MS, completedAtMs - startedAtMs);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
addTelemetryEvidence(TELEMETRY_METRICS.TOTAL_TOKENS, run.tokenUsage?.total ?? null);
|
|
55
|
+
addTelemetryEvidence(TELEMETRY_METRICS.COST, run.cost ?? null);
|
|
56
|
+
addTelemetryEvidence(TELEMETRY_METRICS.SUCCESS, run.state === RUN_STATES.COMPLETED ? 1 : 0);
|
|
57
|
+
}
|
|
58
|
+
return registry;
|
|
59
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Runs one KairoBench task (kairobench-tasks.js) against one real model,
|
|
2
|
+
// in an isolated scratch directory that's always deleted afterward — never
|
|
3
|
+
// the user's real project. `runTask` is injectable specifically so this
|
|
4
|
+
// can be exercised in tests with zero cost and zero real execution; the
|
|
5
|
+
// default wires to run-manager's real startRun, which is what actually
|
|
6
|
+
// spends quota when this is genuinely invoked.
|
|
7
|
+
|
|
8
|
+
import { mkdtemp, readFile as fsReadFile, writeFile as fsWriteFile, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { startRun } from "../runtime/run-manager.js";
|
|
13
|
+
import { readRunState } from "../runtime/run-store.js";
|
|
14
|
+
|
|
15
|
+
function realShellRun(command, args, cwd) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
let child;
|
|
18
|
+
try {
|
|
19
|
+
child = spawn(command, args, { cwd });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
reject(error);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let stdout = "";
|
|
25
|
+
let stderr = "";
|
|
26
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
|
27
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
28
|
+
child.once("error", reject);
|
|
29
|
+
child.once("close", (exitCode) => resolve({ stdout, stderr, exitCode }));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function defaultRunTask({ adapterId, model, cwd, task, homeDir }) {
|
|
34
|
+
// Real execution: this is what actually spends real quota/money against
|
|
35
|
+
// a real provider. Never called by a test — every test injects a fake.
|
|
36
|
+
const { runId } = await startRun({
|
|
37
|
+
homeDir, agentId: adapterId, model, cwd, task, permissions: ["yolo"], wait: true
|
|
38
|
+
});
|
|
39
|
+
const state = await readRunState(homeDir, runId);
|
|
40
|
+
const startedAtMs = Date.parse(state?.startedAt ?? "");
|
|
41
|
+
const completedAtMs = Date.parse(state?.completedAt ?? "");
|
|
42
|
+
return {
|
|
43
|
+
runId,
|
|
44
|
+
durationMs: Number.isFinite(startedAtMs) && Number.isFinite(completedAtMs) ? completedAtMs - startedAtMs : null,
|
|
45
|
+
tokenUsage: state?.tokenUsage ?? null,
|
|
46
|
+
cost: state?.cost ?? null
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} task - one KAIROBENCH_TASKS entry ({id, category, prompt, setup?, verify})
|
|
52
|
+
* @param {{adapterId: string, model: string, homeDir?: string, runTask?: Function}} options
|
|
53
|
+
* @returns {Promise<{taskId: string, category: string, adapterId: string, model: string, success: boolean, durationMs: number|null, tokenUsage: object|null, cost: number|null, runId: string|null}>}
|
|
54
|
+
*/
|
|
55
|
+
export async function runKairoBenchTask(task, { adapterId, model, homeDir = null, runTask = defaultRunTask } = {}) {
|
|
56
|
+
const scratchDir = await mkdtemp(join(tmpdir(), `kairobench-${task.id}-`));
|
|
57
|
+
try {
|
|
58
|
+
const io = {
|
|
59
|
+
readFile: (path, encoding) => fsReadFile(join(scratchDir, path), encoding),
|
|
60
|
+
writeFile: (path, content) => fsWriteFile(join(scratchDir, path), content),
|
|
61
|
+
run: (command, args) => realShellRun(command, args, scratchDir)
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (task.setup) await task.setup(io);
|
|
65
|
+
|
|
66
|
+
const runResult = await runTask({ adapterId, model, cwd: scratchDir, task: task.prompt, homeDir });
|
|
67
|
+
|
|
68
|
+
let success = false;
|
|
69
|
+
try {
|
|
70
|
+
success = await task.verify(io);
|
|
71
|
+
} catch {
|
|
72
|
+
success = false; // a verify that throws (e.g. the expected file was never created) is a real failure, not an error to surface
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
taskId: task.id, category: task.category, adapterId, model, success,
|
|
77
|
+
durationMs: runResult?.durationMs ?? null,
|
|
78
|
+
tokenUsage: runResult?.tokenUsage ?? null,
|
|
79
|
+
cost: runResult?.cost ?? null,
|
|
80
|
+
runId: runResult?.runId ?? null
|
|
81
|
+
};
|
|
82
|
+
} finally {
|
|
83
|
+
await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Feeds real KairoBench results (kairobench-runner.js) into the Model
|
|
2
|
+
// Intelligence Foundation registry, same shape as every other source:
|
|
3
|
+
// per-task success and duration recorded separately, never blended into
|
|
4
|
+
// one composite score. `verified: true` — Kairo directly ran this exact
|
|
5
|
+
// task against this exact model itself; there is no more first-party a
|
|
6
|
+
// measurement gets.
|
|
7
|
+
|
|
8
|
+
export function ingestKairoBenchEvidence(registry, results) {
|
|
9
|
+
for (const result of results ?? []) {
|
|
10
|
+
if (!result?.adapterId || !result?.model || !result?.taskId) continue;
|
|
11
|
+
const id = registry.registerIdentity(result.adapterId, result.model);
|
|
12
|
+
const date = new Date().toISOString();
|
|
13
|
+
|
|
14
|
+
registry.addEvidence(id, {
|
|
15
|
+
metric: `kairobench.${result.taskId}.success`, value: result.success ? 1 : 0,
|
|
16
|
+
source: "kairobench", benchmarkVersion: null, modelConfig: result.runId ?? null, date, verified: true
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (result.durationMs != null) {
|
|
20
|
+
registry.addEvidence(id, {
|
|
21
|
+
metric: `kairobench.${result.taskId}.durationMs`, value: result.durationMs,
|
|
22
|
+
source: "kairobench", benchmarkVersion: null, modelConfig: result.runId ?? null, date, verified: true
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (result.cost != null) {
|
|
27
|
+
registry.addEvidence(id, {
|
|
28
|
+
metric: `kairobench.${result.taskId}.cost`, value: result.cost,
|
|
29
|
+
source: "kairobench", benchmarkVersion: null, modelConfig: result.runId ?? null, date, verified: true
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return registry;
|
|
34
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// KairoBench: a small, hand-authored, reproducible task battery — NOT
|
|
2
|
+
// hundreds of tasks fired at once. Every task runs identically against
|
|
3
|
+
// every real model Kairo has access to (same prompt, same working
|
|
4
|
+
// directory shape, same verification), so results are directly
|
|
5
|
+
// comparable in a way no public leaderboard can be, since it's measured
|
|
6
|
+
// against exactly what Kairo can actually invoke, not a vendor's idea of
|
|
7
|
+
// what matters.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately starts tiny per explicit decision: stage KairoBench, never
|
|
10
|
+
// run a large paid batch at once. Grows only after this batch's results
|
|
11
|
+
// are reviewed.
|
|
12
|
+
//
|
|
13
|
+
// `verify` is a scriptable, deterministic pass/fail check against the
|
|
14
|
+
// task's real working directory — never an LLM-graded "quality" score,
|
|
15
|
+
// which would just be another unverified opinion layered on top. Judged
|
|
16
|
+
// quality scoring is explicitly out of scope for this first version.
|
|
17
|
+
|
|
18
|
+
export const KAIROBENCH_TASKS = [
|
|
19
|
+
{
|
|
20
|
+
id: "implementation-01",
|
|
21
|
+
category: "Implementation",
|
|
22
|
+
prompt: "Create a file named answer.txt in the current directory containing exactly the text: 42",
|
|
23
|
+
/** @param {{readFile: (path: string, encoding: string) => Promise<string>}} io */
|
|
24
|
+
async verify({ readFile }) {
|
|
25
|
+
try {
|
|
26
|
+
const content = await readFile("answer.txt", "utf8");
|
|
27
|
+
return content.trim() === "42";
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "debugging-01",
|
|
35
|
+
category: "Debugging",
|
|
36
|
+
prompt: "The file broken.js exports add(a, b) but its return statement is missing, so it always returns undefined. Fix it so add(2, 3) returns 5. Do not change the function signature or exports.",
|
|
37
|
+
/** @param {{writeFile: (path: string, content: string) => Promise<void>}} io */
|
|
38
|
+
async setup({ writeFile }) {
|
|
39
|
+
await writeFile("broken.js", "function add(a, b) {\n a + b;\n}\n\nmodule.exports = { add };\n");
|
|
40
|
+
},
|
|
41
|
+
/** @param {{run: (command: string, args: string[]) => Promise<{stdout: string, exitCode: number}>}} io */
|
|
42
|
+
async verify({ run }) {
|
|
43
|
+
const result = await run("node", ["-e", "console.log(require('./broken.js').add(2, 3))"]);
|
|
44
|
+
return result.exitCode === 0 && result.stdout.trim() === "5";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
];
|