@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/scripts/cockpit-smoke.mjs +1 -1
  4. package/scripts/ux-smoke-test.sh +3 -3
  5. package/src/cli.js +96 -11
  6. package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
  7. package/src/global/architect/architect-cli.js +76 -0
  8. package/src/global/architect/architect-codex.js +146 -0
  9. package/src/global/architect/architect-manager.js +125 -0
  10. package/src/global/architect/architect-store.js +377 -0
  11. package/src/global/architect/architect-types.js +47 -0
  12. package/src/global/cli-help.js +10 -1
  13. package/src/global/cockpit/app.js +475 -0
  14. package/src/global/cockpit/card.js +111 -0
  15. package/src/global/cockpit/cli.js +33 -0
  16. package/src/global/cockpit/gauge.js +31 -0
  17. package/src/global/cockpit/project-overlay.js +683 -0
  18. package/src/global/cockpit/rows.js +148 -0
  19. package/src/global/cockpit/theme.js +118 -0
  20. package/src/global/cockpit/view.js +1263 -0
  21. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  22. package/src/global/conversation/cli.js +53 -0
  23. package/src/global/conversation/codex-sandbox.js +230 -0
  24. package/src/global/conversation/cursor-sandbox.js +215 -0
  25. package/src/global/conversation/project-analysis.js +204 -0
  26. package/src/global/conversation/project-profile.js +178 -0
  27. package/src/global/conversation/project-router.js +149 -0
  28. package/src/global/conversation/project-strategy-store.js +64 -0
  29. package/src/global/conversation/project-strategy.js +514 -0
  30. package/src/global/conversation/sanitized-snapshot.js +169 -0
  31. package/src/global/conversation/secret-scanner.js +71 -0
  32. package/src/global/conversation/service.js +1063 -0
  33. package/src/global/conversation/session-store.js +75 -0
  34. package/src/global/conversation/transcript-store.js +79 -0
  35. package/src/global/conversation/ui.js +195 -0
  36. package/src/global/intelligence/capability-scoring.js +480 -0
  37. package/src/global/intelligence/execution-router.js +444 -0
  38. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  39. package/src/global/intelligence/kairobench-runner.js +85 -0
  40. package/src/global/intelligence/kairobench-source.js +34 -0
  41. package/src/global/intelligence/kairobench-tasks.js +47 -0
  42. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  43. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  44. package/src/global/intelligence/model-capability-registry.js +125 -0
  45. package/src/global/intelligence/model-intelligence.js +1646 -0
  46. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  47. package/src/global/intelligence/quick-ask.js +149 -0
  48. package/src/global/intelligence/role-profiles.js +251 -0
  49. package/src/global/intelligence/skill-catalog.js +67 -0
  50. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  51. package/src/global/mcp/kairo-mcp.js +51 -18
  52. package/src/global/mcp/work-snapshot-rule.js +4 -2
  53. package/src/global/mcp/workspace-binding.js +88 -0
  54. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  55. package/src/global/mcp-install.js +8 -1
  56. package/src/global/observability/artificial-analysis-models.js +118 -0
  57. package/src/global/observability/claude-models.js +31 -0
  58. package/src/global/observability/claude-usage.js +112 -0
  59. package/src/global/observability/codex-models.js +96 -0
  60. package/src/global/observability/codex-usage.js +160 -0
  61. package/src/global/observability/cursor-auth.js +88 -0
  62. package/src/global/observability/cursor-models.js +101 -0
  63. package/src/global/observability/huggingface-leaderboard.js +97 -0
  64. package/src/global/observability/opencode-models.js +101 -0
  65. package/src/global/observability/opencode-usage.js +162 -0
  66. package/src/global/paths.js +49 -2
  67. package/src/global/profile.js +23 -1
  68. package/src/global/runtime/execution-adapters/claude.js +63 -30
  69. package/src/global/runtime/execution-adapters/codex.js +9 -2
  70. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  71. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  72. package/src/global/runtime/execution-worktree-manager.js +924 -0
  73. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  74. package/src/global/runtime/execution-worktree-store.js +83 -0
  75. package/src/global/runtime/execution-worktree-types.js +45 -0
  76. package/src/global/runtime/run-events.js +38 -0
  77. package/src/global/runtime/run-manager.js +22 -6
  78. package/src/global/runtime/run-supervisor.js +41 -12
  79. package/src/global/runtime/usage-manager.js +96 -0
  80. package/src/global/runtime/usage-store.js +69 -0
  81. 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
+ ];