@kal-elsam/kairo-runtime 0.16.0 → 0.18.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 +79 -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 +493 -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 +693 -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 +1298 -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 +1090 -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 +466 -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,466 @@
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
+ export const MIN_QUOTA_PERCENT = 5;
130
+
131
+ // A softer, earlier heads-up threshold — strictly above MIN_QUOTA_PERCENT,
132
+ // so the human sees a warning before a provider actually gets excluded,
133
+ // never after. Display-only: it never affects checkCandidate's own
134
+ // eligibility verdict (see CockpitView's own status-bar consumer).
135
+ export const LOW_QUOTA_WARN_PERCENT = 20;
136
+
137
+ /**
138
+ * The single eligibility policy shared by execution routing, ask routing,
139
+ * and the FIT widget — one candidate is judged the same way everywhere, so
140
+ * a provider that FIT recommends is guaranteed to actually be launchable.
141
+ * Real availability/launchability/quota only; never a capability judgment
142
+ * (that's scoreAvailableModels' job, applied only to survivors of this).
143
+ * @param {string} adapterId - "codex" | "claude" | "opencode-go" | "opencode-zen" | "cursor"
144
+ * @param {{adapters: object[], codexUsage?: object|null, claudeUsage?: object|null, opencodeGoUsage?: object|null, cursorManualQuota?: {manualExhausted: boolean, reason?: string|null}|null}} context -
145
+ * `cursorManualQuota` is the human-reported override (see
146
+ * runtime/usage-store.js's `cursor.json` record, set via
147
+ * `/project cursor exhausted|available`) — Cursor exposes no real,
148
+ * zero-cost local quota read (its CLI has no usage/billing subcommand
149
+ * and a successful `-p` call only reports per-request token counts, not
150
+ * remaining account balance), so unlike Codex/Claude/OpenCode Go this is
151
+ * never auto-detected, only ever what the human last told Kairo.
152
+ * @returns {{ok: boolean, reason: string|null}}
153
+ * @param {{requireLaunchable?: boolean}} [options] - `requireLaunchable: false`
154
+ * is for AI TEAM's recommendation surface only (service.js's snapshot()):
155
+ * it lets opencode-go be named as a real, accessible option — you do
156
+ * have the model via the Go subscription — without claiming Kairo can
157
+ * safely auto-execute through it yet (see opencode.js's checkAvailability
158
+ * for why: no per-event way to prove a run didn't silently bill Zen).
159
+ * It also lets Cursor be named as a real recommendation (e.g. Builder ->
160
+ * Cursor Composer 2.5) even though Kairo never auto-executes through it —
161
+ * the user continues that work manually inside the Cursor IDE; this is
162
+ * permanent for Cursor, not a temporary safety gap like opencode-go's.
163
+ * Real task routing (selectExecutionProvider/selectAskProvider) always
164
+ * uses the default `true` — it must never pick something guaranteed to
165
+ * fail at launch (run-manager.js's own launchable gate would reject it).
166
+ */
167
+ export function checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, opencodeGoUsage, cursorManualQuota }, { requireLaunchable = true } = {}) {
168
+ // Zen carries real PAYG/billing risk (see conversation/service.js's
169
+ // capabilities.openCodeExecution) — never an automatic pick, regardless
170
+ // of what its real catalog/benchmarks might otherwise say.
171
+ if (adapterId === "opencode-zen") return { ok: false, reason: "OpenCode Zen is excluded from automatic routing (PAYG risk)" };
172
+ // Cursor is a deliberate manual-only destination for real task
173
+ // execution — recommendation and execution are different capabilities
174
+ // (see model-intelligence.js's role assignments): Kairo can genuinely
175
+ // recommend a real Cursor model for a role (Builder -> Cursor Composer
176
+ // 2.5, say), the same requireLaunchable:false exception opencode-go
177
+ // already gets below, but real task routing (requireLaunchable: true,
178
+ // the default) always refuses it — the user continues that work
179
+ // manually inside the Cursor IDE, never an automatic Kairo-launched run.
180
+ if (adapterId === "cursor" && requireLaunchable) {
181
+ return { ok: false, reason: "Cursor is manual-only — continue the work in the Cursor IDE, never auto-executed by Kairo" };
182
+ }
183
+
184
+ const adapter = findAdapter(adapterId, adapters);
185
+ if (!adapter) return { ok: false, reason: `${adapterId}: no adapter found` };
186
+ if (!adapter.available) return { ok: false, reason: adapter.reason ?? `${adapterId}: not available` };
187
+ // "launchable" means safe for Kairo to invoke programmatically — not the
188
+ // bar a manual recommendation needs. Cursor gets the same exemption
189
+ // opencode-go already has: real availability is enough to recommend it,
190
+ // even though (unlike opencode-go) it's exempt from launchable for a
191
+ // structurally different reason — it will NEVER be launchable, by design.
192
+ const launchableRequired = requireLaunchable || (adapterId !== "opencode-go" && adapterId !== "cursor");
193
+ if (launchableRequired && !adapter.launchable) return { ok: false, reason: adapter.reason ?? `${adapterId}: not launchable yet` };
194
+
195
+ if (adapterId === "codex") {
196
+ const left = remainingPercent(codexUsage);
197
+ if (left != null && left < MIN_QUOTA_PERCENT) return { ok: false, reason: `Codex quota nearly exhausted (${left}% left)` };
198
+ }
199
+ if (adapterId === "claude") {
200
+ const left = remainingPercent(claudeUsage);
201
+ if (left != null && left < MIN_QUOTA_PERCENT) return { ok: false, reason: `Claude quota nearly exhausted (${left}% left)` };
202
+ }
203
+ if (adapterId === "opencode-go") {
204
+ const windows = opencodeGoUsage?.go?.windows ?? opencodeGoUsage?.windows ?? [];
205
+ // A cap being hit blocks real requests regardless of other windows
206
+ // having headroom — any rate-limited window is real evidence of that,
207
+ // so it's the conservative (fail-closed) reading, not a guess.
208
+ const limited = windows.find((window) => window.status === "rate-limited");
209
+ if (limited) return { ok: false, reason: `OpenCode Go ${limited.name} window is rate-limited` };
210
+ }
211
+ // Cursor: only ever the human's own last word (see this function's own
212
+ // doc) — never fabricated from a guess. Checked here, after the
213
+ // requireLaunchable-gated manual-only return above, so it only ever
214
+ // takes effect on the recommendation path (requireLaunchable: false) —
215
+ // real task routing already refuses Cursor unconditionally regardless
216
+ // of quota.
217
+ if (adapterId === "cursor" && cursorManualQuota?.manualExhausted) {
218
+ return { ok: false, reason: cursorManualQuota.reason ?? "Cursor marked out of credits (manual, via /project cursor exhausted)" };
219
+ }
220
+ return { ok: true, reason: null };
221
+ }
222
+
223
+ /** Picks the provider's default model from its real catalog; null (never a guessed id) if none is marked default. */
224
+ function defaultModelFor(adapterId, catalogs) {
225
+ const catalogKey = adapterId === "opencode-go" ? "opencodeGo" : adapterId === "opencode-zen" ? "opencodeZen" : adapterId;
226
+ const catalog = catalogs?.[catalogKey];
227
+ if (!catalog || catalog.status === "unknown") return null;
228
+ const models = catalog.models ?? [];
229
+ return models.find((model) => model.isDefault)?.id ?? models[0]?.id ?? null;
230
+ }
231
+
232
+ // Short with no reasoning/risk signal doesn't need the account's biggest
233
+ // model — "what does this project do?" or "fix the button color" shouldn't
234
+ // burn the same model as "why is there a race condition in the auth flow?".
235
+ const LIGHT_TASK_MAX_LENGTH = 100;
236
+
237
+ /**
238
+ * How much model capability a task (question OR real execution work)
239
+ * actually needs. Reuses classifyTask()'s real keyword signal rather than
240
+ * a separate heuristic — anything that reads as reasoning-heavy or touches
241
+ * a risk keyword still deserves a capable model even if it's short ("why
242
+ * does auth break?"). Governs which MODEL a provider uses, independent of
243
+ * which PROVIDER gets picked (candidateOrder's job) — a trivial fix still
244
+ * goes to whichever provider the task shape favors, just with its
245
+ * cheapest adequate model instead of automatically reaching for the top.
246
+ * @param {string} taskText
247
+ * @returns {"light"|"standard"|"heavy"}
248
+ */
249
+ export function classifyEffort(taskText) {
250
+ const text = String(taskText ?? "");
251
+ const profile = classifyTask(text);
252
+ // Reasoning or risk always means real complexity, regardless of length —
253
+ // "why does auth break?" is short but not trivial. Multi-file/integration
254
+ // scope counts too, UNLESS it's dominated by repetition: "rename this DTO
255
+ // field across 15 files" spans many files but is mechanical, not complex
256
+ // — the repetition signal is real evidence the multi-file spread doesn't
257
+ // make it harder, just wider.
258
+ if (profile.reasoningScore > 0 || profile.riskScore > 0) return "heavy";
259
+ if (profile.multiFileScore > 0 && profile.multiFileScore > profile.repetitionScore) return "heavy";
260
+ if (text.trim().length <= LIGHT_TASK_MAX_LENGTH) return "light";
261
+ return "standard";
262
+ }
263
+
264
+ // Anthropic's own public model line naming (Haiku < Sonnet < Opus) is a
265
+ // real, documented capability ordering — not a guess — so it's safe to
266
+ // match against real catalog entries by name.
267
+ const CLAUDE_EFFORT_NAME_PATTERNS = { light: "haiku", standard: "sonnet", heavy: "opus" };
268
+
269
+ /**
270
+ * Picks a real model id from the provider's actual catalog for the given
271
+ * effort tier, falling back to the provider's own default when no matching
272
+ * tier exists in that catalog (never a fabricated id).
273
+ * @param {string} adapterId
274
+ * @param {"light"|"standard"|"heavy"} effort
275
+ * @param {object} catalogs
276
+ */
277
+ function pickModelForEffort(adapterId, effort, catalogs) {
278
+ if (adapterId === "claude") {
279
+ const models = catalogs?.claude?.models ?? [];
280
+ const pattern = CLAUDE_EFFORT_NAME_PATTERNS[effort];
281
+ const tiered = pattern ? models.find((model) => model.id.toLowerCase().includes(pattern)) : null;
282
+ if (tiered) return tiered.id;
283
+ }
284
+ // OpenCode Go's real catalog reports a real per-model cost
285
+ // (costInputPerMTok) — no naming convention to trust here, but real
286
+ // price is itself a real, ungamed signal: sort by it and pick the
287
+ // cheapest/median/priciest model for light/standard/heavy. Codex and
288
+ // Cursor's real catalogs carry neither a naming convention nor a cost
289
+ // field (verified: id/displayName/isDefault/hidden only), so picking a
290
+ // tier for them would be inventing data instead of reading it — they
291
+ // keep using the provider's own default.
292
+ if (adapterId === "opencode-go") {
293
+ const withCost = (catalogs?.opencodeGo?.models ?? []).filter((model) => typeof model.costInputPerMTok === "number");
294
+ if (withCost.length > 0) {
295
+ const sorted = [...withCost].sort((a, b) => a.costInputPerMTok - b.costInputPerMTok);
296
+ if (effort === "light") return sorted[0].id;
297
+ if (effort === "heavy") return sorted[sorted.length - 1].id;
298
+ return sorted[Math.floor((sorted.length - 1) / 2)].id;
299
+ }
300
+ }
301
+ return defaultModelFor(adapterId, catalogs);
302
+ }
303
+
304
+ /**
305
+ * Decides an ordered candidate list (most to least preferred) from the
306
+ * classification alone — availability/quota filtering happens next, in
307
+ * `selectExecutionProvider`. Kept separate so the "why this order" reasoning
308
+ * stays inspectable.
309
+ */
310
+ function candidateOrder(profile) {
311
+ if (profile.riskScore > 0 && (profile.reasoningScore > 0 || profile.multiFileScore > 0)) {
312
+ return { needsApproval: true, order: [] };
313
+ }
314
+ if (profile.reasoningScore >= profile.multiFileScore && profile.reasoningScore >= profile.repetitionScore && profile.reasoningScore > 0) {
315
+ return { needsApproval: false, order: ["codex", "claude"] };
316
+ }
317
+ if (profile.multiFileScore > 0) {
318
+ return { needsApproval: false, order: ["claude", "codex"] };
319
+ }
320
+ if (profile.repetitionScore > 0) {
321
+ return { needsApproval: false, order: ["opencode-go", "claude"] };
322
+ }
323
+ // No signal either way — today's existing default behavior, not a guess.
324
+ return { needsApproval: false, order: ["claude", "codex"] };
325
+ }
326
+
327
+ function reasonPhrase(adapterId, profile) {
328
+ if (adapterId === "codex") return profile.reasoning.length ? `reasoning task (${profile.reasoning.join(", ")})` : "default planning provider";
329
+ if (adapterId === "claude") return profile.multiFile.length ? `multi-file/integration task (${profile.multiFile.join(", ")})` : "default implementation provider";
330
+ if (adapterId === "opencode-go") return `repetitive/low-risk task (${profile.repetitive.join(", ")})`;
331
+ return adapterId;
332
+ }
333
+
334
+ /**
335
+ * Orders ask candidates by real remaining quota so repeated questions don't
336
+ * always burn the same account — but only when BOTH sides have a real
337
+ * measured number; with either one unknown there's no honest comparison to
338
+ * make, so it keeps today's existing claude-first default instead of
339
+ * guessing which side "probably" has more room.
340
+ * @param {number|null} codexRemaining
341
+ * @param {number|null} claudeRemaining
342
+ */
343
+ function pickAskOrder(codexRemaining, claudeRemaining) {
344
+ if (codexRemaining == null || claudeRemaining == null) return { order: ["claude", "codex"], usedQuota: false };
345
+ return codexRemaining >= claudeRemaining
346
+ ? { order: ["codex", "claude"], usedQuota: true }
347
+ : { order: ["claude", "codex"], usedQuota: true };
348
+ }
349
+
350
+ /**
351
+ * Routes a read-only question to a provider — deliberately NOT the same
352
+ * risk/reasoning gating as selectExecutionProvider: a question ABOUT a
353
+ * risky topic ("how does auth work here?") is itself completely safe,
354
+ * unlike actually implementing changes to it, so this never returns
355
+ * WAIT_FOR_APPROVAL. Just: is a real, quota-healthy, ask-capable provider
356
+ * available, with its real default model.
357
+ * @param {object} args
358
+ * @param {object[]} args.adapters
359
+ * @param {object|null} [args.codexUsage]
360
+ * @param {object|null} [args.claudeUsage]
361
+ * @param {object} [args.catalogs]
362
+ * @param {string} [args.taskText] - the real question text, used only to size
363
+ * how much model capability it needs (see classifyEffort) — never to
364
+ * change which provider is picked or to gate on risk.
365
+ */
366
+ export function selectAskProvider({ adapters, codexUsage = null, claudeUsage = null, catalogs = {}, taskText = "" }) {
367
+ const effort = classifyEffort(taskText);
368
+ const { order, usedQuota } = pickAskOrder(remainingPercent(codexUsage), remainingPercent(claudeUsage));
369
+ const attempts = [];
370
+ for (const adapterId of order) {
371
+ const check = checkCandidate(adapterId, { adapters, codexUsage, claudeUsage });
372
+ attempts.push({ adapterId, ...check });
373
+ if (check.ok) {
374
+ const quotaNote = usedQuota ? "; more real quota remaining" : "";
375
+ return {
376
+ decision: "ROUTED",
377
+ provider: adapterId,
378
+ model: pickModelForEffort(adapterId, effort, catalogs),
379
+ why: `read-only question (${effort} effort${quotaNote})`,
380
+ rejectedCandidates: attempts.filter((entry) => !entry.ok)
381
+ };
382
+ }
383
+ }
384
+ return {
385
+ decision: "NO_PROVIDER_AVAILABLE",
386
+ provider: null,
387
+ model: null,
388
+ why: `no ask-capable provider available: ${attempts.map((entry) => entry.reason).join("; ")}`,
389
+ rejectedCandidates: attempts
390
+ };
391
+ }
392
+
393
+ /**
394
+ * The actual router: classification -> ordered candidates -> real
395
+ * availability/quota filtering -> a real model id from a real catalog, or
396
+ * an explicit WAIT_FOR_APPROVAL decision when risk is too high to route
397
+ * automatically. Never returns a provider that failed its availability
398
+ * check, and never returns a model id that isn't in that provider's real
399
+ * catalog.
400
+ *
401
+ * @param {object} args
402
+ * @param {string} args.task
403
+ * @param {object[]} args.adapters - inspectExecutionAdapters() result
404
+ * @param {object|null} [args.codexUsage]
405
+ * @param {object|null} [args.claudeUsage]
406
+ * @param {object} [args.catalogs] - { codex, opencodeGo, opencodeZen, cursor, claude } readXModels() results
407
+ */
408
+ export function selectExecutionProvider({
409
+ task, adapters, codexUsage = null, claudeUsage = null, opencodeGoUsage = null, catalogs = {}, skills = []
410
+ }) {
411
+ const profile = classifyTask(task);
412
+ const { needsApproval, order } = candidateOrder(profile);
413
+ const matchedSkills = matchSkills(task, skills);
414
+ const skillNote = matchedSkills.length > 0
415
+ ? ` · matches skill "${matchedSkills[0].name}" (${matchedSkills[0].overlap.join(", ")})`
416
+ : "";
417
+ // Which provider handles the task and how capable a model it needs are
418
+ // separate questions — a trivial fix still goes wherever the task shape
419
+ // favors, just with the cheapest adequate model instead of always
420
+ // reaching for the account's top one (e.g. Claude Opus/Fable for
421
+ // "fix the button color").
422
+ const effort = classifyEffort(task);
423
+
424
+ if (needsApproval) {
425
+ return {
426
+ decision: "WAIT_FOR_APPROVAL",
427
+ provider: null,
428
+ model: null,
429
+ why: `high risk (${profile.risk.join(", ")}) combined with reasoning/multi-file scope — routing automatically would be unsafe; a human should pick the provider.${skillNote}`,
430
+ fallback: null,
431
+ matchedSkills,
432
+ profile
433
+ };
434
+ }
435
+
436
+ const attempts = [];
437
+ for (const adapterId of order) {
438
+ const check = checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, opencodeGoUsage });
439
+ attempts.push({ adapterId, ...check });
440
+ if (check.ok) {
441
+ const model = pickModelForEffort(adapterId, effort, catalogs);
442
+ const remaining = order.slice(order.indexOf(adapterId) + 1);
443
+ return {
444
+ decision: "ROUTED",
445
+ provider: adapterId,
446
+ model,
447
+ why: `${reasonPhrase(adapterId, profile)} (${effort} effort)${skillNote}`,
448
+ fallback: remaining[0] ?? null,
449
+ rejectedCandidates: attempts.filter((entry) => !entry.ok),
450
+ matchedSkills,
451
+ profile
452
+ };
453
+ }
454
+ }
455
+
456
+ return {
457
+ decision: "NO_PROVIDER_AVAILABLE",
458
+ provider: null,
459
+ model: null,
460
+ why: `every candidate provider was unavailable: ${attempts.map((entry) => entry.reason).join("; ")}`,
461
+ fallback: null,
462
+ rejectedCandidates: attempts,
463
+ matchedSkills,
464
+ profile
465
+ };
466
+ }
@@ -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
+ }