@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,456 @@
1
+ // The Complete Candidate Catalog: every real model Kairo's four provider
2
+ // catalogs (Codex, Claude, Cursor, OpenCode Go) actually report, mapped
3
+ // into one uniform ModelCandidateIdentity shape — INCLUDING models with
4
+ // zero real Artificial Analysis evidence (UNSCORED). This module also
5
+ // builds the two real consumer pools joined against it (see
6
+ // buildRecommendationPool/buildAutomaticExecutionPool's own docs near
7
+ // the bottom of this file) — deliberately SEPARATE from buildAiTeam/
8
+ // buildEfficientTeam's own capability-gated role ranking
9
+ // (model-intelligence.js), which this module never reimplements: a
10
+ // model with no real benchmark can still be offered for MANUAL selection
11
+ // here, but it must never receive an invented score — see
12
+ // role-profiles.js's RoleProfile.capabilities for what "scored" actually
13
+ // requires per role; this module never computes that, only whether AA
14
+ // matched the candidate at all.
15
+ //
16
+ // Per the real catalog audit this session ran against crm's live
17
+ // providers (codex 5, claude 9 documented, opencode-go 27, cursor 223):
18
+ // none of the four sources report an explicit usage multiplier anywhere
19
+ // — `usageMultiplier` is deliberately NOT a field here yet; adding it
20
+ // would mean inventing a number no provider actually gives us. Re-audit
21
+ // before adding it, never assume it stays true forever.
22
+ //
23
+ // lineageKey/generation are populated only for the recognized, conservative
24
+ // families in LINEAGE_PARSERS (see its own doc) — every other candidate
25
+ // gets null/null, never a guess. lifecycle is computed from THAT: a
26
+ // candidate is "superseded" only when a real, strictly newer generation
27
+ // under the exact same lineageKey is ALSO present in this same catalog
28
+ // (i.e. genuinely accessible, not hypothetical); everything else —
29
+ // unrecognized lineage, or the newest (or only) generation within a
30
+ // recognized one — is "current" or "unknown", never excluded from
31
+ // anything downstream just because its lineage couldn't be determined.
32
+
33
+ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
34
+
35
+ /**
36
+ * @typedef {object} ModelCandidateIdentity
37
+ * @property {string} candidateKey - `${adapterId}::${modelId}`, this catalog's stable identity key.
38
+ * @property {string} modelId - the exact provider-reported id — what scoring/execution actually use, never the cleaned name.
39
+ * @property {string} modelName - human-readable clean name, with real effort/context/privacy variant tokens stripped (see stripDisplayVariant). Never invented — always derived from the provider's own real displayName.
40
+ * @property {string} rawDisplayName - the provider's own displayName, completely unmodified — the real evidence modelName was derived from. Whatever stripDisplayVariant peeled off (effort/context/privacy tokens) to produce modelName is still visible here, never a separate field: /models --evidence's own "technical detail" is just this string.
41
+ * @property {string} adapterId - "codex" | "claude" | "cursor" | "opencode-go".
42
+ * @property {"automatic"|"manual"} accessMode - whether Kairo can actually launch this candidate itself right now, or whether it's a real, recommendable option the human runs manually (Cursor always; OpenCode Go until its own empirical automatic-execution proof lands — see this module's own doc).
43
+ * @property {"scored"|"partial"|"unscored"} evidenceStatus - "scored": AA matched this exact model AND reports at least one of intelligenceIndex/codingIndex. "partial": AA matched it but both composite indices are null (real match, thin evidence). "unscored": no confident AA match at all. Never role-specific — see this module's own doc for why.
44
+ * @property {string|null} lineageKey - real, recognized model family/lineage (see LINEAGE_PARSERS) — null when the modelId doesn't match any recognized, conservative pattern. Never guessed.
45
+ * @property {number|null} generation - a real, comparable version number within that lineage — null whenever lineageKey is null.
46
+ * @property {"current"|"superseded"|"unknown"} lifecycle - "superseded" only when a real, strictly newer generation under the SAME lineageKey is also present in this catalog; "current" when it's the newest (or only) generation in a recognized lineage; "unknown" whenever lineageKey is null. An unknown lineage NEVER excludes a candidate from anything downstream.
47
+ * @property {{inputPerMTok: number, outputPerMTok: number}|null} resourceCost - real, provider-reported cost, when the provider actually reports one (OpenCode Go today) — never estimated or carried over from a different model.
48
+ */
49
+
50
+ // Real variant tokens observed across the four live provider catalogs
51
+ // this session audited (Cursor's 223-model catalog especially — the only
52
+ // source that embeds these directly into displayName text, with no
53
+ // separate field). Deliberately separate from model-intelligence.js's
54
+ // own CONCENTRATION_SUFFIX_TOKENS (modelId-level, kebab-case) — this
55
+ // operates on human display TEXT (space-separated, mixed case, multi-
56
+ // word phrases like "Extra High") and per explicit instruction never
57
+ // modifies that other table. Longest phrases first so "extra high"
58
+ // matches before a lone trailing "high" would. Real product-tier names
59
+ // that happen to look like effort words in isolation — Mini, Nano,
60
+ // Flash, Pro, Sol, Terra, Luna, Astra, Code — are deliberately absent:
61
+ // they're part of a model's real identity (verified against real, very
62
+ // differently-priced OpenCode Go siblings, e.g. GLM-5.3 vs GLM-5.3-Flash
63
+ // at 1.4/4.4 vs 0.15/0.5 — genuinely different models, never a variant of
64
+ // each other), never stripped.
65
+ const VARIANT_PHRASES = ["extra high", "minimal", "medium", "thinking", "high", "low", "max", "none", "fast", "1m"];
66
+ const PARENTHETICAL_VARIANT = /\s*\(([^()]*)\)\s*$/;
67
+
68
+ /**
69
+ * Splits a provider's real displayName into a clean modelName and the
70
+ * real variant text stripped from its end — never touches the front of
71
+ * the name (a variant token is only ever a TRAILING modifier in every
72
+ * real example this session's audit found; a word matching the variant
73
+ * vocabulary elsewhere in the name is left alone). Idempotent: a name
74
+ * with no trailing variant tokens returns unchanged with `variant: null`.
75
+ * @param {string} rawDisplayName
76
+ * @returns {{modelName: string, variant: string|null}}
77
+ */
78
+ export function stripDisplayVariant(rawDisplayName) {
79
+ const trimmed = String(rawDisplayName ?? "").trim();
80
+ if (!trimmed) return { modelName: trimmed, variant: null };
81
+
82
+ let working = trimmed;
83
+ const trailingParts = [];
84
+ const parenMatch = working.match(PARENTHETICAL_VARIANT);
85
+ if (parenMatch) {
86
+ working = working.slice(0, parenMatch.index).trim();
87
+ }
88
+
89
+ let tokens = working.split(/\s+/).filter(Boolean);
90
+ const peeled = [];
91
+ let peeling = true;
92
+ while (peeling && tokens.length) {
93
+ peeling = false;
94
+ for (const phrase of VARIANT_PHRASES) {
95
+ const phraseWords = phrase.split(" ");
96
+ if (phraseWords.length > tokens.length) continue;
97
+ const candidate = tokens.slice(tokens.length - phraseWords.length).join(" ").toLowerCase();
98
+ if (candidate === phrase) {
99
+ peeled.unshift(tokens.slice(tokens.length - phraseWords.length).join(" "));
100
+ tokens = tokens.slice(0, tokens.length - phraseWords.length);
101
+ peeling = true;
102
+ break;
103
+ }
104
+ }
105
+ }
106
+
107
+ const modelName = tokens.join(" ") || trimmed;
108
+ if (peeled.length) trailingParts.push(peeled.join(" "));
109
+ if (parenMatch) trailingParts.push(`(${parenMatch[1]})`);
110
+ const variant = trailingParts.length ? trailingParts.join(" ") : null;
111
+ return { modelName, variant };
112
+ }
113
+
114
+ // Real, conservative lineage parsers — one per recognized, unambiguous
115
+ // naming scheme. Each entry's `match` is deliberately STRICT (anchored
116
+ // start and end, no wildcard trailing segments): a modelId that doesn't
117
+ // match EXACTLY falls through to the next parser, and if none match, the
118
+ // candidate gets lineageKey/generation null — "unknown", never a guess.
119
+ // This strictness is itself the safety boundary: Cursor's own re-exposed
120
+ // ids append effort-variant suffixes this session's catalog audit found
121
+ // (e.g. "gpt-5.6-sol-high-fast", "claude-opus-5-thinking-high") — those
122
+ // intentionally DON'T match here and stay unknown, rather than trying to
123
+ // also parse Cursor's much larger, effort-suffixed id space in this same
124
+ // pass (a separate, later increment, not this one).
125
+ //
126
+ // claude-{opus|sonnet|fable|haiku}-{version}: Claude's own catalog
127
+ // (claude-models.js, "documented" — hand-maintained, regular naming).
128
+ // Version "4-8" parses as generation 4.8, "5" as 5.0 — real, verified
129
+ // against this session's live audit: opus-4-6 < opus-4-7 < opus-4-8 <
130
+ // opus-5; sonnet-4-6 < sonnet-5; fable-5 < fable-5-1.
131
+ //
132
+ // glm-{version}[-tier]: OpenCode Go's GLM models. A tier suffix (e.g.
133
+ // "-flash") produces a DIFFERENT lineageKey ("glm-flash") from the bare
134
+ // line ("glm") — verified against real, very differently-priced siblings
135
+ // (glm-5.3 at 1.4/4.4 vs glm-5.3-flash at 0.15/0.5 real cost): genuinely
136
+ // different products, never comparable generations of each other.
137
+ //
138
+ // gpt-{version}[-name]: Codex's own catalog. Same tier-suffix-changes-
139
+ // lineage rule as GLM — "gpt-6-astra" (lineage "gpt-astra") is never
140
+ // compared against "gpt-5.6-sol" (lineage "gpt-sol") or bare "gpt-5.5"
141
+ // (lineage "gpt") — verified against this session's audit finding no
142
+ // real evidence any of Codex's five current models share a persona name
143
+ // at two different generations yet.
144
+ //
145
+ // A real bug this same session's live-catalog verification caught before
146
+ // shipping: GLM/GPT's own trailing-tier group also matched Cursor's
147
+ // EFFORT suffixes (e.g. "gpt-5.4-low", "glm-5.2-high") — treating
148
+ // "-low"/"-high" as if they were real product names invented fake
149
+ // lineages ("gpt-low", "glm-high") that then wrongly compared DIFFERENT
150
+ // base generations sharing the same effort word (gpt-5.1-low vs
151
+ // gpt-5.2-low vs gpt-5.4-low) as if they were the same real product line,
152
+ // marking real, unrelated older generations "superseded" for the wrong
153
+ // reason. EFFORT_SUFFIX_WORDS excludes every real effort/mode token this
154
+ // session's audit found (mirrors model-intelligence.js's own
155
+ // CONCENTRATION_SUFFIX_TOKENS vocabulary, kept separate per that file's
156
+ // own "never modify" instruction) — a trailing word matching this set is
157
+ // treated as NOT a real tier name, falling through to unrecognized
158
+ // (null) rather than inventing a lineage split.
159
+ const EFFORT_SUFFIX_WORDS = new Set(["low", "medium", "high", "xhigh", "max", "none", "fast", "thinking"]);
160
+ const LINEAGE_PARSERS = [
161
+ { pattern: /^claude-(opus|sonnet|fable|haiku)-(\d+(?:-\d+)?)$/, resolve: (m) => ({ lineageKey: `claude-${m[1]}`, generation: parseVersionToken(m[2]) }) },
162
+ // A real, separate id ordering this session's own live integration
163
+ // verification caught: Claude's documented catalog uses
164
+ // "claude-{tier}-{version}" (claude-sonnet-5), but Cursor's own
165
+ // real re-exposure of the SAME Sonnet 4/4.5/4.6 generations uses
166
+ // "claude-{version}-{tier}" instead (claude-4-sonnet,
167
+ // claude-4.6-sonnet — verified against Cursor's real catalog: no
168
+ // "claude-sonnet-4" id exists there at all for this specific
169
+ // generation, only this reordered form). Resolves to the exact same
170
+ // lineageKey ("claude-sonnet") as the other pattern, so a real
171
+ // "claude-4-sonnet" and a real "claude-sonnet-5" correctly compare
172
+ // as the same lineage's generations 4 and 5 — this is what actually
173
+ // closes the original reported bug (an old Sonnet 4 still appearing
174
+ // as an option once Sonnet 5 is real and accessible).
175
+ { pattern: /^claude-(\d+(?:\.\d+)?)-(opus|sonnet|fable|haiku)$/, resolve: (m) => ({ lineageKey: `claude-${m[2]}`, generation: parseFloat(m[1]) }) },
176
+ { pattern: /^glm-(\d+(?:\.\d+)?)(-[a-z0-9]+)?$/, resolve: (m) => resolveTieredVersion("glm", m[1], m[2]) },
177
+ { pattern: /^gpt-(\d+(?:\.\d+)?)(-[a-z]+)?$/, resolve: (m) => resolveTieredVersion("gpt", m[1], m[2]) }
178
+ ];
179
+
180
+ /** Shared by the glm/gpt parsers: a real tier suffix changes lineageKey; an effort-word suffix (Cursor's own re-exposed variants) is NOT a real tier — returns null (unrecognized) instead of inventing a fake lineage split. */
181
+ function resolveTieredVersion(base, versionToken, suffix) {
182
+ const tier = suffix ? suffix.slice(1).toLowerCase() : null;
183
+ if (tier && EFFORT_SUFFIX_WORDS.has(tier)) return null;
184
+ return { lineageKey: tier ? `${base}-${tier}` : base, generation: parseFloat(versionToken) };
185
+ }
186
+
187
+ /** Turns Claude's real hyphenated minor-version token ("4-8") into a comparable number (4.8); a bare token ("5") becomes 5. */
188
+ function parseVersionToken(token) {
189
+ return parseFloat(token.replace("-", "."));
190
+ }
191
+
192
+ // A real gap this session's own live-catalog verification found AFTER
193
+ // EFFORT_SUFFIX_WORDS shipped: rejecting an effort suffix at the TOP
194
+ // level (e.g. "claude-opus-5-thinking-high" simply failing to match the
195
+ // claude pattern's anchored end) avoided the false lineage, but produced
196
+ // a false UNKNOWN instead — a real, genuinely-superseded Cursor variant
197
+ // of an old Claude generation (e.g. "claude-sonnet-4-6-thinking-high")
198
+ // silently escaped supersession entirely, which would let it keep
199
+ // competing as if lineage-unknown. A variant token never changes what
200
+ // generation a model actually is — thinking/high/low/medium/xhigh/max/
201
+ // none/fast/1m and the "no-zdr" (from "(NO ZDR)") privacy marker are
202
+ // real, comprobados (verified against this session's own live catalog)
203
+ // suffixes that get stripped to compute a lineageSubjectId BEFORE
204
+ // matching against LINEAGE_PARSERS — modelId itself is never touched
205
+ // (scoring/execution/candidateKey all still use the real, exact,
206
+ // unmodified modelId; lineageSubjectId exists ONLY inside lineage
207
+ // resolution). Deliberately the SAME real vocabulary as
208
+ // EFFORT_SUFFIX_WORDS (kebab-case tokens here, not display-text words) —
209
+ // resolveTieredVersion's own EFFORT_SUFFIX_WORDS check stays as a second,
210
+ // defensive layer in case a future suffix token isn't in this set yet.
211
+ const LINEAGE_SUFFIX_TOKENS = new Set(["thinking", "high", "low", "medium", "xhigh", "max", "none", "fast", "1m"]);
212
+
213
+ /**
214
+ * Strips real, verified trailing variant tokens (see LINEAGE_SUFFIX_TOKENS's
215
+ * own doc) from a modelId to compute the id lineage resolution should
216
+ * actually match against — never the modelId used for scoring/execution
217
+ * itself. "no-zdr" (two kebab tokens) is peeled as one unit, matching
218
+ * displayName's own "(NO ZDR)" marker. Idempotent: an id with no
219
+ * recognized trailing tokens returns unchanged.
220
+ * @param {string} modelId
221
+ * @returns {string}
222
+ */
223
+ export function stripLineageSuffixes(modelId) {
224
+ let tokens = String(modelId ?? "").split("-").filter(Boolean);
225
+ let peeling = true;
226
+ while (peeling && tokens.length) {
227
+ peeling = false;
228
+ if (tokens.length >= 2 && tokens[tokens.length - 2] === "no" && tokens[tokens.length - 1] === "zdr") {
229
+ tokens = tokens.slice(0, -2);
230
+ peeling = true;
231
+ continue;
232
+ }
233
+ if (LINEAGE_SUFFIX_TOKENS.has(tokens[tokens.length - 1])) {
234
+ tokens = tokens.slice(0, -1);
235
+ peeling = true;
236
+ }
237
+ }
238
+ return tokens.join("-");
239
+ }
240
+
241
+ /**
242
+ * Resolves a real modelId against the recognized, conservative lineage
243
+ * parsers above — null (never a guess) when nothing matches, even after
244
+ * stripping real, verified variant suffixes via stripLineageSuffixes.
245
+ * @param {string} modelId
246
+ * @returns {{lineageKey: string, generation: number}|null}
247
+ */
248
+ export function resolveLineage(modelId) {
249
+ const subjectId = stripLineageSuffixes(modelId);
250
+ for (const { pattern, resolve } of LINEAGE_PARSERS) {
251
+ const match = subjectId.match(pattern);
252
+ if (match) return resolve(match);
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /**
258
+ * Computes each candidate's real "current"/"superseded"/"unknown"
259
+ * lifecycle from lineageKey/generation already resolved onto it —
260
+ * "superseded" only when a real, strictly newer generation under the
261
+ * SAME lineageKey is ALSO present in `catalog` (i.e. genuinely
262
+ * accessible right now, not merely a known future release). Returns a
263
+ * NEW array (candidates are copied, never mutated in place).
264
+ * @param {Array<ModelCandidateIdentity>} catalog
265
+ * @returns {Array<ModelCandidateIdentity>}
266
+ */
267
+ function applyLifecycle(catalog) {
268
+ const maxGenerationByLineage = new Map();
269
+ for (const candidate of catalog) {
270
+ if (candidate.lineageKey == null) continue;
271
+ const current = maxGenerationByLineage.get(candidate.lineageKey);
272
+ if (current == null || candidate.generation > current) maxGenerationByLineage.set(candidate.lineageKey, candidate.generation);
273
+ }
274
+ return catalog.map((candidate) => {
275
+ if (candidate.lineageKey == null) return { ...candidate, lifecycle: "unknown" };
276
+ const max = maxGenerationByLineage.get(candidate.lineageKey);
277
+ return { ...candidate, lifecycle: candidate.generation < max ? "superseded" : "current" };
278
+ });
279
+ }
280
+
281
+ // Whether Kairo can actually launch a candidate itself right now, per
282
+ // adapter — real, current state (execution-adapters/index.js's own
283
+ // `launchable` flags, intelligence/execution-router.js's checkCandidate),
284
+ // not a guess. OpenCode Go stays "manual" until the real empirical
285
+ // automatic-execution proof this session's plan calls for
286
+ // (`opencode run -m opencode-go/<model>`, verifying real provider
287
+ // attribution and Go-only consumption) actually runs and passes — see
288
+ // this module's own doc. Cursor is permanently "manual" by design (see
289
+ // execution-router.js's own checkCandidate: real task execution always
290
+ // refuses it, recommendation never does).
291
+ const ACCESS_MODE_BY_ADAPTER = { codex: "automatic", claude: "automatic", cursor: "manual", "opencode-go": "manual" };
292
+
293
+ function resolveAccessMode(adapterId, modelId) {
294
+ if (adapterId === "cursor" && modelId === "auto") return "manual";
295
+ return ACCESS_MODE_BY_ADAPTER[adapterId] ?? "manual";
296
+ }
297
+
298
+ function resolveEvidenceStatus(matched) {
299
+ if (!matched) return "unscored";
300
+ return matched.intelligenceIndex == null && matched.codingIndex == null ? "partial" : "scored";
301
+ }
302
+
303
+ function resolveResourceCost(rawModel) {
304
+ const input = rawModel?.costInputPerMTok;
305
+ const output = rawModel?.costOutputPerMTok;
306
+ return typeof input === "number" && typeof output === "number" ? { inputPerMTok: input, outputPerMTok: output } : null;
307
+ }
308
+
309
+ /**
310
+ * Builds one ModelCandidateIdentity for a single real provider model —
311
+ * the Cursor "auto" router gets an honest, deliberately opaque identity
312
+ * (never an assumed inner model): modelName "Cursor Auto", no variant,
313
+ * always unscored/manual/unknown-lifecycle.
314
+ * @param {string} adapterId
315
+ * @param {{id: string, displayName?: string}} rawModel
316
+ * @param {Array<object>} aaModels
317
+ * @param {(modelId: string, aaModels: Array<object>) => object|null} matcher
318
+ * @returns {ModelCandidateIdentity}
319
+ */
320
+ function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
321
+ const modelId = rawModel.id;
322
+ const rawDisplayName = rawModel.displayName ?? modelId;
323
+
324
+ if (adapterId === "cursor" && modelId === "auto") {
325
+ return {
326
+ candidateKey: `${adapterId}::${modelId}`, modelId, modelName: "Cursor Auto", rawDisplayName,
327
+ adapterId, accessMode: "manual", evidenceStatus: "unscored",
328
+ lineageKey: null, generation: null, lifecycle: "unknown", resourceCost: null
329
+ };
330
+ }
331
+
332
+ const { modelName } = stripDisplayVariant(rawDisplayName);
333
+ const matched = matcher(modelId, aaModels);
334
+ const lineage = resolveLineage(modelId);
335
+ return {
336
+ candidateKey: `${adapterId}::${modelId}`, modelId, modelName, rawDisplayName,
337
+ adapterId, accessMode: resolveAccessMode(adapterId, modelId), evidenceStatus: resolveEvidenceStatus(matched),
338
+ // lifecycle is resolved in a second pass (applyLifecycle, called from
339
+ // buildCompleteCandidateCatalog) once the WHOLE catalog is known —
340
+ // "superseded" is a statement about this candidate relative to its
341
+ // real siblings, not something a single candidate can determine
342
+ // alone. "unknown" here is only a placeholder for lineage == null;
343
+ // applyLifecycle overwrites it for every recognized lineage.
344
+ lineageKey: lineage?.lineageKey ?? null, generation: lineage?.generation ?? null, lifecycle: "unknown",
345
+ resourceCost: resolveResourceCost(rawModel)
346
+ };
347
+ }
348
+
349
+ /**
350
+ * The Complete Candidate Catalog: every real model across every real
351
+ * provider catalog given, mapped to ModelCandidateIdentity — unfiltered,
352
+ * UNSCORED candidates included. Never the ranking surface (see this
353
+ * module's own doc) — just the real, complete inventory.
354
+ * @param {Array<{adapterId: string, models: Array<{id: string, displayName?: string}>}>} providerCatalogs -
355
+ * same shape model-intelligence.js's scoreAvailableModels takes, e.g.
356
+ * `[{ adapterId: "codex", models: readCodexModels().models }, ...]`.
357
+ * @param {Array<object>} aaModels - readArtificialAnalysisModels().models
358
+ * @param {{matchArtificialAnalysisScore?: (modelId: string, aaModels: Array<object>) => object|null}} [deps] -
359
+ * injectable for tests; defaults to model-intelligence.js's real export.
360
+ * @returns {Array<ModelCandidateIdentity>}
361
+ */
362
+ export function buildCompleteCandidateCatalog(providerCatalogs, aaModels, deps = {}) {
363
+ const matcher = deps.matchArtificialAnalysisScore ?? matchArtificialAnalysisScore;
364
+ const catalog = [];
365
+ for (const { adapterId, models } of providerCatalogs) {
366
+ for (const rawModel of models ?? []) {
367
+ catalog.push(buildCandidateIdentity(adapterId, rawModel, aaModels, matcher));
368
+ }
369
+ }
370
+ return applyLifecycle(catalog);
371
+ }
372
+
373
+ /**
374
+ * @typedef {object} RecommendationPoolCandidate
375
+ * One real scoreAvailableModels() entry (adapterId, modelId,
376
+ * intelligenceIndex, codingIndex, priceInputPerMTok, etc. — completely
377
+ * untouched, never recomputed), spread together with: candidateKey,
378
+ * modelName, accessMode, evidenceStatus, lineageKey, generation,
379
+ * lifecycle, resourceCost from the joined ModelCandidateIdentity.
380
+ * `resourceCost` and `priceInputPerMTok` are DELIBERATELY separate
381
+ * fields — OpenCode Go's own real per-model cost is never blended into
382
+ * or mistaken for AA's price figure, even when both exist for the same
383
+ * candidate.
384
+ */
385
+
386
+ /**
387
+ * The Recommendation Pool: every real, scored candidate (from
388
+ * scoreAvailableModels — the same real AA-matched pool buildAiTeam/
389
+ * buildEfficientTeam already consume) that is NOT superseded, enriched
390
+ * with its real ModelCandidateIdentity fields via a candidateKey join.
391
+ * Deliberately does NOT recompute capability/role fit itself — that
392
+ * stays model-intelligence.js's own job; a caller feeds this pool's
393
+ * output straight into buildAiTeam/buildEfficientTeam/
394
+ * bestModelPerRoleGlobal/etc. as their own `models` argument, in place
395
+ * of raw scoreAvailableModels() output, so QUALITY/EFFICIENT TEAM
396
+ * naturally stop considering a real, superseded old generation without
397
+ * either team's own ranking logic needing to know why.
398
+ *
399
+ * "current" and "unknown" lifecycle candidates are BOTH kept — an
400
+ * unrecognized lineage never excludes a real candidate from being
401
+ * recommended, only a PROVEN newer same-lineage successor does. Manual-
402
+ * only real candidates (Cursor, OpenCode Go today) are kept too — this
403
+ * pool answers "what can Kairo honestly recommend", not "what can Kairo
404
+ * launch by itself" (see buildAutomaticExecutionPool for that).
405
+ * @param {Array<object>} scoredAll - scoreAvailableModels() output, every candidate provider regardless of eligibility.
406
+ * @param {Array<ModelCandidateIdentity>} completeCatalog - buildCompleteCandidateCatalog() output, same provider catalogs.
407
+ * @returns {Array<RecommendationPoolCandidate>}
408
+ */
409
+ export function buildRecommendationPool(scoredAll, completeCatalog) {
410
+ const identityByKey = new Map(completeCatalog.map((identity) => [identity.candidateKey, identity]));
411
+ const pool = [];
412
+ for (const scored of scoredAll) {
413
+ const candidateKey = `${scored.adapterId}::${scored.modelId}`;
414
+ const identity = identityByKey.get(candidateKey);
415
+ // No real catalog identity for this exact scored candidate is only
416
+ // possible if the caller passed mismatched provider catalogs to
417
+ // scoreAvailableModels vs buildCompleteCandidateCatalog — defensive,
418
+ // never excludes: an un-joined candidate is treated as lineage-
419
+ // unknown, exactly like any other real unrecognized lineage.
420
+ if (identity?.lifecycle === "superseded") continue;
421
+ pool.push({
422
+ ...scored,
423
+ candidateKey,
424
+ modelName: identity?.modelName ?? scored.displayName ?? scored.modelId,
425
+ accessMode: identity?.accessMode ?? "manual",
426
+ evidenceStatus: identity?.evidenceStatus ?? "scored",
427
+ lineageKey: identity?.lineageKey ?? null,
428
+ generation: identity?.generation ?? null,
429
+ lifecycle: identity?.lifecycle ?? "unknown",
430
+ resourceCost: identity?.resourceCost ?? null
431
+ });
432
+ }
433
+ return pool;
434
+ }
435
+
436
+ /**
437
+ * The Automatic Execution Pool: the subset of the Recommendation Pool
438
+ * Kairo can actually launch itself, right now — real routing's own
439
+ * candidate source, never QUALITY/EFFICIENT TEAM's. Requires BOTH a real
440
+ * accessMode of "automatic" (Cursor/OpenCode Go are permanently or
441
+ * currently manual — see ModelCandidateIdentity's own doc) AND real,
442
+ * current eligibility (adapter availability, quota, launchability — the
443
+ * exact same `eligibility` object checkCandidate/execution-router.js
444
+ * already compute, reused here rather than reimplemented). Never
445
+ * mutates or filters the Recommendation Pool itself — a manual-only real
446
+ * recommendation (Cursor, say) stays fully visible there; a caller that
447
+ * wants to actually RUN a task must separately produce a real
448
+ * "Continue in Cursor"-style handoff for it, never a silent fallback to
449
+ * a different, automatically-launchable model the human didn't ask for.
450
+ * @param {Array<RecommendationPoolCandidate>} recommendationPool
451
+ * @param {Record<string, {ok: boolean, reason?: string}>} eligibility - checkCandidate() results per adapterId.
452
+ * @returns {Array<RecommendationPoolCandidate>}
453
+ */
454
+ export function buildAutomaticExecutionPool(recommendationPool, eligibility) {
455
+ return recommendationPool.filter((candidate) => candidate.accessMode === "automatic" && eligibility[candidate.adapterId]?.ok === true);
456
+ }
@@ -0,0 +1,145 @@
1
+ // Feeds real evidence into a CapabilityRegistry (model-capability-registry.js).
2
+ // Two sources so far: Artificial Analysis (already integrated elsewhere in
3
+ // Kairo) and Hugging Face's per-benchmark leaderboard API, scoped to
4
+ // OpenCode Go's open-weight models only — verified live that HF's
5
+ // leaderboards have ZERO coverage of Codex/Claude (proprietary, not hosted
6
+ // on the HF Hub) and real coverage of Go's model family (GLM/Kimi/DeepSeek/
7
+ // Qwen/MiniMax, all HF-hosted). Adding another source (Epoch AI, OpenRouter
8
+ // metadata) means adding another `ingest*` function here, never changing
9
+ // the registry itself.
10
+
11
+ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
12
+
13
+ // One evidence entry per real, non-null field AA reports — never a
14
+ // fabricated zero for a metric AA doesn't have for that model. The
15
+ // per-benchmark fields (gpqa, hle, ...) are real scores the free API
16
+ // already returns alongside the composite indices — verified live, an
17
+ // earlier assumption that free tier lacked them was wrong.
18
+ const AA_METRICS = [
19
+ "intelligenceIndex", "codingIndex", "mathIndex", "priceInputPerMTok", "outputTokensPerSecond",
20
+ "gpqa", "hle", "sciCode", "mmluPro", "liveCodeBench", "ifBench", "terminalBenchHard", "terminalBenchV2", "tau2", "tauBanking"
21
+ ];
22
+
23
+ // The REAL scale each AA metric is reported on — declared here, not
24
+ // guessed downstream by capability-scoring.js's metric-name-based
25
+ // fallback. AA's own per-benchmark fields (gpqa/hle/sciCode/mmluPro/
26
+ // liveCodeBench/ifBench/terminalBenchHard/terminalBenchV2/tau2/
27
+ // tauBanking) are real 0-1 fractions. intelligenceIndex/codingIndex/
28
+ // mathIndex are AA's own 0-100 composite indices. price/speed aren't
29
+ // percentage-like values at all — no real "unit vs hundred" question
30
+ // applies, left undeclared (undefined here means addEvidence gets no
31
+ // scale for them, same as before this fix).
32
+ const AA_METRIC_SCALE = {
33
+ gpqa: "unit", hle: "unit", sciCode: "unit", mmluPro: "unit", liveCodeBench: "unit", ifBench: "unit",
34
+ terminalBenchHard: "unit", terminalBenchV2: "unit", tau2: "unit", tauBanking: "unit",
35
+ intelligenceIndex: "hundred", codingIndex: "hundred", mathIndex: "hundred"
36
+ };
37
+
38
+ /**
39
+ * Registers every real, matched (provider catalog ↔ Artificial Analysis)
40
+ * model as an identity, and records one evidence entry per real metric it
41
+ * reports. AA's free tier never independently verifies a result the way a
42
+ * leaderboard's per-submission `verified` flag would, so every entry here
43
+ * is honestly `verified: false` — that distinction matters once a source
44
+ * that does verify (e.g. Hugging Face leaderboards) is added.
45
+ * @param {ReturnType<import("./capability-registry.js").createCapabilityRegistry>} registry
46
+ * @param {Array<{adapterId: string, models: Array<object|string>}>} providerCatalogs
47
+ * @param {Array<object>} aaModels
48
+ * @param {{fetchedAt?: string}} [options]
49
+ */
50
+ export function ingestArtificialAnalysisEvidence(registry, providerCatalogs, aaModels, { fetchedAt = new Date().toISOString() } = {}) {
51
+ for (const { adapterId, models } of providerCatalogs) {
52
+ for (const entry of models ?? []) {
53
+ const model = typeof entry === "string" ? { id: entry, displayName: entry } : entry;
54
+ const match = matchArtificialAnalysisScore(model.id, aaModels);
55
+ if (!match) continue;
56
+ const id = registry.registerIdentity(adapterId, model.id, model.displayName ?? null);
57
+ for (const metric of AA_METRICS) {
58
+ const value = match[metric];
59
+ if (value == null) continue;
60
+ registry.addEvidence(id, {
61
+ metric, value, source: "artificial-analysis-free", scale: AA_METRIC_SCALE[metric] ?? null,
62
+ benchmarkVersion: null, modelConfig: null, date: fetchedAt, verified: false
63
+ });
64
+ }
65
+ }
66
+ }
67
+ return registry;
68
+ }
69
+
70
+ function normalizeHfId(id) {
71
+ return String(id ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
72
+ }
73
+
74
+ function sortedTokens(normalizedId) {
75
+ return normalizedId.split("-").filter(Boolean).sort().join("-");
76
+ }
77
+
78
+ /**
79
+ * Finds every real leaderboard entry for a model id — plural, never just
80
+ * the first hit, because the same model can legitimately appear more than
81
+ * once in one dataset with different real configs/harnesses (e.g. "with
82
+ * tools" vs. not) and those aren't the same measurement. HF's `modelId` is
83
+ * "org/model" (e.g. "zai-org/GLM-5.3"); only the model part is compared,
84
+ * since Kairo's own catalog ids never carry an org prefix.
85
+ * @param {string} modelId
86
+ * @param {Array<{modelId: string}>} hfEntries
87
+ */
88
+ export function matchHuggingFaceEntries(modelId, hfEntries) {
89
+ const normalized = normalizeHfId(modelId);
90
+ if (!normalized) return [];
91
+ const withModelPart = hfEntries.map((entry) => {
92
+ const raw = String(entry.modelId ?? "");
93
+ const modelPart = raw.includes("/") ? raw.slice(raw.lastIndexOf("/") + 1) : raw;
94
+ return { entry, normalized: normalizeHfId(modelPart) };
95
+ });
96
+ const exact = withModelPart.filter((c) => c.normalized === normalized);
97
+ if (exact.length) return exact.map((c) => c.entry);
98
+ const tokens = sortedTokens(normalized);
99
+ return withModelPart.filter((c) => sortedTokens(c.normalized) === tokens).map((c) => c.entry);
100
+ }
101
+
102
+ /**
103
+ * Registers real leaderboard evidence for one benchmark (one HF dataset =
104
+ * one benchmark — there's no cross-benchmark aggregate endpoint). Every
105
+ * real match becomes its own evidence entry (never averaged, never
106
+ * deduplicated to "the first one") so different real configs/harnesses for
107
+ * the same model stay distinguishable, exactly like the registry's own
108
+ * contract requires. `verified` is real per-entry data from HF (an
109
+ * independent-verification flag on that specific submission), unlike AA's
110
+ * free tier which never verifies anything.
111
+ * @param {ReturnType<import("./model-capability-registry.js").createCapabilityRegistry>} registry
112
+ * @param {Array<{adapterId: string, models: Array<object|string>}>} providerCatalogs
113
+ * @param {Array<{modelId: string, value: number|null, verified: boolean, notes?: string|null}>} hfEntries
114
+ * @param {{metric: string, scale?: "unit"|"hundred", benchmarkVersion?: string|null, fetchedAt?: string}} options -
115
+ * `scale` is the REAL scale this exact HF dataset reports on — the
116
+ * caller's own responsibility to know, since a different source can
117
+ * report the SAME metric name in a genuinely different real scale (see
118
+ * the real bug this parameter fixes: HLE via Hugging Face reports 0-100,
119
+ * verified live — DeepSeek-V4.1-Flash's real value is 63.9, not 0.639 —
120
+ * while Artificial Analysis's own "hle" field is a real 0-1 fraction;
121
+ * both used to share capability-scoring.js's single metric-name-based
122
+ * "unit" assumption, silently treating HF's 63.9 as if it were already
123
+ * a fraction). Omit only when genuinely unknown — capability-scoring.js
124
+ * falls back to its own metric-name guess, which is exactly the
125
+ * behavior that produced the bug.
126
+ */
127
+ export function ingestHuggingFaceLeaderboardEvidence(registry, providerCatalogs, hfEntries, { metric, scale = null, benchmarkVersion = null, fetchedAt = new Date().toISOString() }) {
128
+ for (const { adapterId, models } of providerCatalogs) {
129
+ for (const entry of models ?? []) {
130
+ const model = typeof entry === "string" ? { id: entry, displayName: entry } : entry;
131
+ const matches = matchHuggingFaceEntries(model.id, hfEntries);
132
+ if (!matches.length) continue;
133
+ const id = registry.registerIdentity(adapterId, model.id, model.displayName ?? null);
134
+ for (const match of matches) {
135
+ if (match.value == null) continue;
136
+ registry.addEvidence(id, {
137
+ metric, value: match.value, source: "huggingface-leaderboard", scale,
138
+ benchmarkVersion, modelConfig: match.notes ?? match.modelId ?? null,
139
+ date: fetchedAt, verified: match.verified === true
140
+ });
141
+ }
142
+ }
143
+ }
144
+ return registry;
145
+ }