@kal-elsam/kairo-runtime 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -161,6 +161,14 @@ kairo install --agents all
161
161
  kairo install --agents cursor,codex --components orchestrator,sdd-core
162
162
  kairo doctor
163
163
  kairo doctor --json
164
+ kairo orchestrator --json
165
+ kairo intelligence status
166
+ kairo intelligence models
167
+ kairo intelligence context --json
168
+ kairo intelligence route --task "explain architecture"
169
+ kairo intelligence ask --prompt "Summarize project risks" --json
170
+ # Cloud (OpenRouter/free) only after explicit consent + confirm:
171
+ # OPENROUTER_API_KEY=... kairo intelligence ask --prompt "..." --cloud-consent --yes
164
172
  kairo update # technical alias; prefer sync
165
173
  kairo detect
166
174
  kairo components
@@ -180,6 +188,21 @@ kairo uninstall
180
188
  kairo install --scope=workspace # opt-in / legacy
181
189
  ```
182
190
 
191
+ ### Intelligence layer (0.2.0)
192
+
193
+ Kairo owns **Harness Engineering** governance: compile relevant project context, route to a backend, and require human confirmation for cloud transmission. It does not store credentials.
194
+
195
+ | Backend | Detection | Invoke |
196
+ |---|---|---|
197
+ | Ollama | `GET $OLLAMA_HOST/api/tags` (default `http://127.0.0.1:11434`) | Local chat |
198
+ | OpenRouter | `OPENROUTER_API_KEY` in env | `openrouter/free` after `--cloud-consent` + `--yes` |
199
+ | Custom HTTP | Profile `customProviders` (`baseUrl`, `modelId`, optional local-only `apiKeyEnv`) | OpenAI-compatible `/chat/completions` |
200
+
201
+ Routing order: user override → Ollama → OpenRouter free (consent) → diagnostics mode.
202
+
203
+ Private paths (`.env`, secrets, keys) are excluded from context packs unless `--include-private`.
204
+ Remote custom providers require explicit cloud consent and cannot receive an `apiKeyEnv` credential in 0.2.0; use a built-in provider or a local custom endpoint for env-backed authentication.
205
+
183
206
  Legacy CLI aliases (backward compatible): `harness`, `agentic-harness`, `sgs-harness`, `harness-sgs`
184
207
 
185
208
  `kairo help` lists commands and JSON support; longer examples live in this README.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Gemini, Copilot, Engram, and Graphify.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kal-elSam/harness#readme",
package/src/cli.js CHANGED
@@ -31,6 +31,7 @@ import { applyPolicyToOptions, loadPolicyFile } from "./global/policy.js";
31
31
  import { resolveHomeDir } from "./global/paths.js";
32
32
  import { runWorkspaceDetect, runWorkspaceDoctor, runWorkspaceInit, runWorkspaceUpdate } from "./workspace-cli.js";
33
33
  import { runOrchestratorDiagnostics, runOrchestratorShell } from "./global/orchestrator.js";
34
+ import { runIntelligenceCli } from "./global/intelligence-cli.js";
34
35
  import {
35
36
  LEGACY_PACKAGE_NAME,
36
37
  PACKAGE_NAME,
@@ -85,6 +86,9 @@ export async function runCli(argv) {
85
86
  json: optionsWithPolicy.json
86
87
  });
87
88
  return;
89
+ case "intelligence":
90
+ await runIntelligenceCli(optionsWithPolicy, packageManifest);
91
+ return;
88
92
  case "setup":
89
93
  await runGlobalSetup(optionsWithPolicy, packageManifest, packageRoot);
90
94
  return;
@@ -324,7 +328,13 @@ export function parseArgs(argv) {
324
328
  simple: false,
325
329
  help: false,
326
330
  version: false,
327
- interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY)
331
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
332
+ intelligenceAction: null,
333
+ intelligenceTask: null,
334
+ intelligencePrompt: null,
335
+ intelligencePaths: [],
336
+ includePrivate: false,
337
+ cloudConsent: false
328
338
  };
329
339
 
330
340
  if (command === "components") {
@@ -339,6 +349,10 @@ export function parseArgs(argv) {
339
349
  parseHistoryAction(args, options);
340
350
  }
341
351
 
352
+ if (command === "intelligence") {
353
+ parseIntelligenceAction(args, options);
354
+ }
355
+
342
356
  for (let index = 0; index < args.length; index += 1) {
343
357
  const arg = args[index];
344
358
 
@@ -400,6 +414,14 @@ export function parseArgs(argv) {
400
414
  else if (arg === "--out") options.outPath = resolve(args[++index]);
401
415
  else if (arg.startsWith("--out=")) options.outPath = resolve(arg.slice("--out=".length));
402
416
  else if (arg === "--simple") options.simple = true;
417
+ else if (arg === "--task") options.intelligenceTask = args[++index];
418
+ else if (arg.startsWith("--task=")) options.intelligenceTask = arg.slice("--task=".length);
419
+ else if (arg === "--prompt") options.intelligencePrompt = args[++index];
420
+ else if (arg.startsWith("--prompt=")) options.intelligencePrompt = arg.slice("--prompt=".length);
421
+ else if (arg === "--paths") options.intelligencePaths = parsePathList(args[++index]);
422
+ else if (arg.startsWith("--paths=")) options.intelligencePaths = parsePathList(arg.slice("--paths=".length));
423
+ else if (arg === "--include-private") options.includePrivate = true;
424
+ else if (arg === "--cloud-consent") options.cloudConsent = true;
403
425
  else if (arg === "--help" || arg === "-h") options.help = true;
404
426
  else if (arg === "--version" || arg === "-v") options.version = true;
405
427
  else throw new Error(`Unknown option "${arg}".`);
@@ -490,6 +512,31 @@ function parseHistoryAction(args, options) {
490
512
  throw new Error(`Unknown history action "${action}". Use last or omit for the full log.`);
491
513
  }
492
514
 
515
+ function parseIntelligenceAction(args, options) {
516
+ const action = args[0];
517
+ if (!action || action.startsWith("-")) {
518
+ options.intelligenceAction = "status";
519
+ return;
520
+ }
521
+
522
+ args.shift();
523
+ const allowed = new Set(["status", "models", "context", "route", "ask"]);
524
+ if (!allowed.has(action)) {
525
+ throw new Error(`Unknown intelligence action "${action}". Use status, models, context, route, or ask.`);
526
+ }
527
+ options.intelligenceAction = action;
528
+ }
529
+
530
+ function parsePathList(value) {
531
+ if (!value) return [];
532
+ return [...new Set(
533
+ value
534
+ .split(",")
535
+ .map((item) => item.trim())
536
+ .filter(Boolean)
537
+ )];
538
+ }
539
+
493
540
  function parsePositiveInt(value, label) {
494
541
  const parsed = Number.parseInt(value, 10);
495
542
  if (!Number.isFinite(parsed) || parsed < 1) {
@@ -512,6 +559,7 @@ function normalizeCommand(command) {
512
559
  if (command === "install" || command === "i") return "install";
513
560
  if (command === "shell") return "shell";
514
561
  if (command === "orchestrator") return "orchestrator";
562
+ if (command === "intelligence" || command === "intel") return "intelligence";
515
563
  if (command === "setup") return "setup";
516
564
  if (command === "status") return "status";
517
565
  if (command === "sync") return "sync";
@@ -573,6 +621,8 @@ Usage:
573
621
  ${cli} --version
574
622
  ${cli} shell Interactive orchestrator shell (TTY)
575
623
  ${cli} orchestrator [--json] Read-only agent capability diagnostics
624
+ ${cli} intelligence [status|models|context|route|ask] [--json]
625
+ ${cli} intelligence ask --prompt "..." [--cloud-consent] [--yes] [--paths a,b]
576
626
  ${cli} setup [--dry-run] [--yes] [--confirm] [--simple] [--no-preflight] [--agents <list|all>] [--components <list>]
577
627
  ${cli} status [--json]
578
628
  ${cli} sync [--dry-run] [--yes] [--confirm] [--json] [--no-preflight]
@@ -608,6 +658,9 @@ Scopes:
608
658
  Commands:
609
659
  shell Interactive Ink orchestrator (TTY). Bare ${cli} opens this in TTY sessions.
610
660
  orchestrator Read-only capability registry diagnostics (--json supported).
661
+ intelligence Harness Engineering layer: backends, context packs, routing, budgets.
662
+ Local-first (Ollama). Cloud (OpenRouter/free) only with --cloud-consent.
663
+ Credentials via env only (OPENROUTER_API_KEY, OLLAMA_HOST). Never stored.
611
664
  setup Managed ecosystem setup. Interactive Ink UI (TTY). Use --simple for Clack prompts.
612
665
  status Control panel: agents, components, drift, backups, next action.
613
666
  sync Converge managed content (repair drift), then show status.
@@ -2,6 +2,11 @@ import { CAPABILITY_STATES } from "./capability-states.js";
2
2
  import { inspectAllCapabilities } from "./capability-registry.js";
3
3
  import { resolveProfile, resolveProfileAgents } from "./profile.js";
4
4
  import { formatCliCommand } from "./brand/cli.js";
5
+ import {
6
+ inspectIntelligenceBackends,
7
+ summarizeIntelligenceBackends,
8
+ resolveRoutingDecision
9
+ } from "./intelligence/index.js";
5
10
 
6
11
  export const PLAN_ACTIONS = {
7
12
  DIAGNOSE: "diagnose",
@@ -16,16 +21,32 @@ export async function buildReadOnlyDiagnostics({
16
21
  workspaceRoot,
17
22
  packageName,
18
23
  packageRoot,
19
- cliVersion
24
+ cliVersion,
25
+ env = process.env,
26
+ fetchImpl = globalThis.fetch
20
27
  }) {
21
28
  const [{ profile, sources }, capabilities] = await Promise.all([
22
29
  resolveProfile({ homeDir, workspaceRoot }),
23
30
  inspectAllCapabilities({ homeDir, workspaceRoot, packageName })
24
31
  ]);
25
32
 
33
+ const intelligence = await inspectIntelligenceBackends({
34
+ env,
35
+ fetchImpl,
36
+ customProviders: profile.customProviders ?? []
37
+ });
38
+
26
39
  const detectedIds = capabilities.filter((entry) => entry.detected).map((entry) => entry.id);
27
40
  const profileAgents = resolveProfileAgents(profile, detectedIds);
28
41
  const diagnostics = summarizeDiagnostics(capabilities);
42
+ const intelligenceSummary = summarizeIntelligenceBackends(intelligence);
43
+ const routingPreview = resolveRoutingDecision({
44
+ backends: intelligence,
45
+ profile,
46
+ // Session consent only — profile.cloudConsent is a preference, never live authorization.
47
+ cloudConsent: false,
48
+ tokenBudget: profile.tokenBudget
49
+ });
29
50
 
30
51
  return {
31
52
  readOnly: true,
@@ -34,7 +55,18 @@ export async function buildReadOnlyDiagnostics({
34
55
  capabilities,
35
56
  profileAgents,
36
57
  diagnostics,
37
- recommendations: buildDiagnosticRecommendations({ capabilities, profile, diagnostics })
58
+ intelligence: {
59
+ backends: intelligence,
60
+ summary: intelligenceSummary,
61
+ routingPreview
62
+ },
63
+ recommendations: buildDiagnosticRecommendations({
64
+ capabilities,
65
+ profile,
66
+ diagnostics,
67
+ intelligenceSummary,
68
+ routingPreview
69
+ })
38
70
  };
39
71
  }
40
72
 
@@ -148,7 +180,13 @@ function summarizeDiagnostics(capabilities) {
148
180
  };
149
181
  }
150
182
 
151
- function buildDiagnosticRecommendations({ capabilities, profile, diagnostics }) {
183
+ function buildDiagnosticRecommendations({
184
+ capabilities,
185
+ profile,
186
+ diagnostics,
187
+ intelligenceSummary,
188
+ routingPreview
189
+ }) {
152
190
  const recommendations = [];
153
191
 
154
192
  if (diagnostics.detected === 0) {
@@ -168,6 +206,20 @@ function buildDiagnosticRecommendations({ capabilities, profile, diagnostics })
168
206
  }
169
207
  }
170
208
 
209
+ if (!intelligenceSummary?.localAvailable && !intelligenceSummary?.cloudAuthenticated) {
210
+ recommendations.push(
211
+ "No intelligence backend available. Start Ollama or set OPENROUTER_API_KEY (env only). Diagnostics mode remains available."
212
+ );
213
+ } else if (!intelligenceSummary?.localAvailable && intelligenceSummary?.cloudAuthenticated) {
214
+ recommendations.push(
215
+ `OpenRouter is authenticated. Cloud invoke still requires session flags: ${formatCliCommand("intelligence ask --cloud-consent --yes")}.`
216
+ );
217
+ }
218
+
219
+ if (routingPreview && !routingPreview.canInvoke) {
220
+ recommendations.push(`Routing: ${routingPreview.reason}`);
221
+ }
222
+
171
223
  return [...new Set(recommendations)];
172
224
  }
173
225
 
@@ -7,8 +7,13 @@ import {
7
7
  ORCHESTRATOR_MENU,
8
8
  ORCHESTRATOR_VIEWS,
9
9
  formatAgentStatusLines,
10
+ formatDiagnosticsLines,
11
+ formatIntelligenceLines,
10
12
  formatPlanLines,
11
- formatProfileLines
13
+ formatProfileLines,
14
+ resolveMenuItem,
15
+ resolveMenuItemView,
16
+ shiftMenuIndex
12
17
  } from "./orchestrator-state.js";
13
18
 
14
19
  const COLORS = {
@@ -95,19 +100,19 @@ export function OrchestratorApp({
95
100
  if (view !== ORCHESTRATOR_VIEWS.HOME) return;
96
101
 
97
102
  if (key.upArrow) {
98
- setMenuIndex((index) => Math.max(0, index - 1));
103
+ setMenuIndex((index) => shiftMenuIndex(index, "up"));
99
104
  return;
100
105
  }
101
106
 
102
107
  if (key.downArrow) {
103
- setMenuIndex((index) => Math.min(ORCHESTRATOR_MENU.length - 1, index + 1));
108
+ setMenuIndex((index) => shiftMenuIndex(index, "down"));
104
109
  return;
105
110
  }
106
111
 
107
112
  if (!key.return) return;
108
113
 
109
- const item = ORCHESTRATOR_MENU[menuIndex];
110
- if (item.action === "setup") {
114
+ const item = resolveMenuItem(menuIndex);
115
+ if (item?.action === "setup") {
111
116
  buildActionPlan({
112
117
  action: PLAN_ACTIONS.SETUP,
113
118
  homeDir,
@@ -123,7 +128,7 @@ export function OrchestratorApp({
123
128
  return;
124
129
  }
125
130
 
126
- setView(item.view);
131
+ setView(resolveMenuItemView(menuIndex));
127
132
  });
128
133
 
129
134
  if (loading) {
@@ -143,7 +148,7 @@ export function OrchestratorApp({
143
148
 
144
149
  return React.createElement(Box, { flexDirection: "column" },
145
150
  React.createElement(Text, { bold: true, color: COLORS.accent }, `${BRAND.displayName} orchestrator`),
146
- React.createElement(Text, { color: COLORS.muted }, "Provider-neutral agent coordinator · no model imposed"),
151
+ React.createElement(Text, { color: COLORS.muted }, "Harness Engineering · local-first · cloud opt-in"),
147
152
  React.createElement(Text, null, ""),
148
153
  renderView({ view, diagnostics, profileJson, plan, menuIndex }),
149
154
  React.createElement(Text, null, ""),
@@ -166,7 +171,18 @@ function renderView({ view, diagnostics, profileJson, plan, menuIndex }) {
166
171
  React.createElement(Text, null, ""),
167
172
  React.createElement(Text, { bold: true }, "Snapshot"),
168
173
  React.createElement(Text, null, `Agents detected: ${diagnostics.diagnostics.detected}/${diagnostics.capabilities.length}`),
169
- React.createElement(Text, null, `Available: ${diagnostics.diagnostics.available}`)
174
+ React.createElement(Text, null, `Available: ${diagnostics.diagnostics.available}`),
175
+ diagnostics.intelligence && React.createElement(
176
+ Text,
177
+ null,
178
+ `Intelligence: local=${diagnostics.intelligence.summary.localAvailable ? "yes" : "no"} cloud=${diagnostics.intelligence.summary.cloudAuthenticated ? "yes" : "no"}`
179
+ )
180
+ );
181
+ case ORCHESTRATOR_VIEWS.DIAGNOSTICS:
182
+ return React.createElement(Box, { flexDirection: "column" },
183
+ React.createElement(Text, { bold: true }, "Diagnostics"),
184
+ formatDiagnosticsLines(diagnostics)
185
+ .map((line) => React.createElement(Text, { key: line }, line))
170
186
  );
171
187
  case ORCHESTRATOR_VIEWS.AGENTS:
172
188
  return React.createElement(Box, { flexDirection: "column" },
@@ -174,6 +190,14 @@ function renderView({ view, diagnostics, profileJson, plan, menuIndex }) {
174
190
  formatAgentStatusLines(diagnostics.capabilities)
175
191
  .map((line) => React.createElement(Text, { key: line }, line))
176
192
  );
193
+ case ORCHESTRATOR_VIEWS.INTELLIGENCE:
194
+ return React.createElement(Box, { flexDirection: "column" },
195
+ React.createElement(Text, { bold: true }, "Intelligence backends"),
196
+ formatIntelligenceLines(diagnostics)
197
+ .map((line) => React.createElement(Text, { key: line }, line)),
198
+ React.createElement(Text, null, ""),
199
+ React.createElement(Text, { dimColor: true }, "CLI: kairo intelligence status|models|context|route|ask")
200
+ );
177
201
  case ORCHESTRATOR_VIEWS.PROFILE:
178
202
  return React.createElement(Box, { flexDirection: "column" },
179
203
  React.createElement(Text, { bold: true }, "Profile"),
@@ -190,10 +214,11 @@ function renderView({ view, diagnostics, profileJson, plan, menuIndex }) {
190
214
  case ORCHESTRATOR_VIEWS.HELP:
191
215
  return React.createElement(Box, { flexDirection: "column" },
192
216
  React.createElement(Text, { bold: true }, "Help"),
193
- React.createElement(Text, null, "Kairo coordinates installed agent CLIs it is not a coding model."),
194
- React.createElement(Text, null, "Use explicit commands for scripts: setup, install, status, doctor, sync."),
217
+ React.createElement(Text, null, "Kairo coordinates installed agent CLIs and governs project intelligence."),
218
+ React.createElement(Text, null, "Local-first: Ollama when available. Cloud (OpenRouter/free) needs consent."),
219
+ React.createElement(Text, null, "Use: intelligence status|models|context|route|ask"),
195
220
  React.createElement(Text, null, "Profiles: ~/.harness/profile.json and .harness/kairo.json (project wins)."),
196
- React.createElement(Text, null, "Credentials are never stored by Kairo.")
221
+ React.createElement(Text, null, "Credentials are never stored by Kairo — use environment variables.")
197
222
  );
198
223
  default: {
199
224
  const _exhaustive = view;
@@ -12,21 +12,65 @@ export function canUseOrchestratorShell({
12
12
 
13
13
  export const ORCHESTRATOR_VIEWS = {
14
14
  HOME: "home",
15
+ DIAGNOSTICS: "diagnostics",
15
16
  AGENTS: "agents",
16
17
  PROFILE: "profile",
17
18
  PLAN: "plan",
18
19
  CONFIRM: "confirm",
19
- HELP: "help"
20
+ HELP: "help",
21
+ INTELLIGENCE: "intelligence"
20
22
  };
21
23
 
22
24
  export const ORCHESTRATOR_MENU = [
23
- { id: "status", label: "Diagnostics", view: ORCHESTRATOR_VIEWS.HOME },
25
+ { id: "status", label: "Diagnostics", view: ORCHESTRATOR_VIEWS.DIAGNOSTICS },
24
26
  { id: "agents", label: "Agents", view: ORCHESTRATOR_VIEWS.AGENTS },
27
+ { id: "intelligence", label: "Intelligence", view: ORCHESTRATOR_VIEWS.INTELLIGENCE },
25
28
  { id: "profile", label: "Profile", view: ORCHESTRATOR_VIEWS.PROFILE },
26
29
  { id: "plan-setup", label: "Plan setup", view: ORCHESTRATOR_VIEWS.PLAN, action: "setup" },
27
30
  { id: "help", label: "Help", view: ORCHESTRATOR_VIEWS.HELP }
28
31
  ];
29
32
 
33
+ export function resolveMenuItem(menuIndex) {
34
+ return ORCHESTRATOR_MENU[menuIndex] ?? null;
35
+ }
36
+
37
+ export function resolveMenuItemView(menuIndex) {
38
+ return resolveMenuItem(menuIndex)?.view ?? ORCHESTRATOR_VIEWS.HOME;
39
+ }
40
+
41
+ export function shiftMenuIndex(currentIndex, direction, menuLength = ORCHESTRATOR_MENU.length) {
42
+ const delta = direction === "up" ? -1 : direction === "down" ? 1 : 0;
43
+ return Math.min(menuLength - 1, Math.max(0, currentIndex + delta));
44
+ }
45
+
46
+ export function formatDiagnosticsLines(diagnostics) {
47
+ const summary = diagnostics?.diagnostics;
48
+ const lines = [
49
+ "Summary",
50
+ `CLI version: ${diagnostics?.cliVersion ?? "unknown"}`,
51
+ `Agents detected: ${summary?.detected ?? 0}/${diagnostics?.capabilities?.length ?? 0}`,
52
+ `Available: ${summary?.available ?? 0}`,
53
+ `Unknown: ${summary?.unknown ?? 0}`,
54
+ `Errors: ${summary?.errors ?? 0}`,
55
+ "",
56
+ "Intelligence availability",
57
+ ...formatIntelligenceLines(diagnostics),
58
+ "",
59
+ "Agent capabilities",
60
+ ...formatAgentStatusLines(diagnostics?.capabilities ?? [])
61
+ ];
62
+
63
+ const recommendations = diagnostics?.recommendations ?? [];
64
+ if (recommendations.length > 0) {
65
+ lines.push("", "Recommendations");
66
+ for (const recommendation of recommendations) {
67
+ lines.push(` • ${recommendation}`);
68
+ }
69
+ }
70
+
71
+ return lines;
72
+ }
73
+
30
74
  export function formatAgentStatusLines(capabilities) {
31
75
  return capabilities.map((entry) => {
32
76
  const auth = entry.authenticated == null ? "n/a" : (entry.authenticated ? "yes" : "no");
@@ -39,7 +83,11 @@ export function formatProfileLines(profileJson) {
39
83
  const lines = [
40
84
  `Coordinator: ${profileJson.coordinator ?? "none"}`,
41
85
  `Default agents: ${formatAgentsLabel(profileJson.defaultAgents)}`,
42
- `Apply mode: ${profileJson.applyMode}`
86
+ `Apply mode: ${profileJson.applyMode}`,
87
+ `Preferred backend: ${profileJson.preferredBackend ?? "auto"}`,
88
+ `Preferred model: ${profileJson.preferredModel ?? "auto"}`,
89
+ `Cloud consent preference: ${profileJson.cloudConsent ? "recorded (session --cloud-consent still required)" : "no"}`,
90
+ `Token budget: ${profileJson.tokenBudget ?? "none"}`
43
91
  ];
44
92
 
45
93
  if (profileJson.sources.global) {
@@ -54,6 +102,29 @@ export function formatProfileLines(profileJson) {
54
102
  return lines;
55
103
  }
56
104
 
105
+ export function formatIntelligenceLines(diagnostics) {
106
+ const intelligence = diagnostics?.intelligence;
107
+ if (!intelligence) {
108
+ return ["Intelligence layer unavailable."];
109
+ }
110
+
111
+ const lines = [
112
+ `Local available: ${intelligence.summary.localAvailable ? "yes" : "no"}`,
113
+ `Cloud authenticated: ${intelligence.summary.cloudAuthenticated ? "yes" : "no"}`,
114
+ `Routing: ${intelligence.routingPreview?.reason ?? "n/a"}`,
115
+ `Can invoke: ${intelligence.routingPreview?.canInvoke ? "yes" : "no"}`,
116
+ ""
117
+ ];
118
+
119
+ for (const backend of intelligence.backends ?? []) {
120
+ lines.push(
121
+ `${backend.label.padEnd(14)} ${backend.state.padEnd(14)} models=${backend.models?.length ?? 0}`
122
+ );
123
+ }
124
+
125
+ return lines;
126
+ }
127
+
57
128
  export function formatPlanLines(plan) {
58
129
  const lines = [`Action: ${plan.action}`, ""];
59
130
  for (const step of plan.steps) {
@@ -0,0 +1,175 @@
1
+ import {
2
+ BACKEND_IDS,
3
+ COST_CLASSES,
4
+ PRIVACY_CLASSES,
5
+ createModelDescriptor
6
+ } from "../types.js";
7
+ import { fetchJson } from "../http.js";
8
+ import { CAPABILITY_STATES } from "../../capability-states.js";
9
+ import { classifyCustomBaseUrl, isValidEnvironmentName } from "../custom-url.js";
10
+
11
+ /**
12
+ * OpenAI-compatible HTTP backend. API keys are read from env by name only —
13
+ * never from profile JSON or disk.
14
+ */
15
+ export function createCustomHttpBackend({
16
+ id = BACKEND_IDS.CUSTOM,
17
+ label = "Custom provider",
18
+ baseUrl,
19
+ modelId,
20
+ apiKeyEnv = null,
21
+ local: _local = false,
22
+ fetchImpl = globalThis.fetch,
23
+ env = process.env
24
+ } = {}) {
25
+ if (!baseUrl) {
26
+ throw new Error("Custom HTTP backend requires baseUrl.");
27
+ }
28
+
29
+ const location = classifyCustomBaseUrl(baseUrl);
30
+ const normalizedBase = location.normalizedBaseUrl;
31
+ if (apiKeyEnv != null && !isValidEnvironmentName(apiKeyEnv)) {
32
+ throw new Error("Custom provider apiKeyEnv must be a valid uppercase environment variable name.");
33
+ }
34
+ if (apiKeyEnv && !location.local) {
35
+ throw new Error(
36
+ "Remote custom providers cannot use apiKeyEnv in 0.2.0; use a built-in provider or a local endpoint."
37
+ );
38
+ }
39
+ const apiKey = apiKeyEnv ? (env[apiKeyEnv] ?? null) : null;
40
+
41
+ return {
42
+ id,
43
+ label,
44
+ local: location.local,
45
+
46
+ async detect() {
47
+ if (apiKeyEnv && !apiKey) {
48
+ return {
49
+ id,
50
+ label,
51
+ state: CAPABILITY_STATES.UNKNOWN,
52
+ detected: false,
53
+ available: false,
54
+ hasApiKey: false,
55
+ error: null,
56
+ recommendation: `Set ${apiKeyEnv} in the environment. Kairo never stores credentials.`
57
+ };
58
+ }
59
+
60
+ return {
61
+ id,
62
+ label,
63
+ state: CAPABILITY_STATES.DETECTED,
64
+ detected: true,
65
+ available: Boolean(modelId),
66
+ hasApiKey: apiKeyEnv ? Boolean(apiKey) : null,
67
+ error: null,
68
+ recommendation: modelId
69
+ ? `Custom provider configured (${modelId}).`
70
+ : "Custom provider baseUrl set; configure modelId in profile."
71
+ };
72
+ },
73
+
74
+ async listModels() {
75
+ if (!modelId) return [];
76
+ return [
77
+ createModelDescriptor({
78
+ provider: id,
79
+ modelId,
80
+ local: location.local,
81
+ costClass: location.local ? COST_CLASSES.LOCAL : COST_CLASSES.UNKNOWN,
82
+ privacyClass: location.local ? PRIVACY_CLASSES.LOCAL : PRIVACY_CLASSES.CLOUD,
83
+ opaque: true
84
+ })
85
+ ];
86
+ },
87
+
88
+ async capabilities() {
89
+ const detection = await this.detect();
90
+ return {
91
+ id,
92
+ local: location.local,
93
+ cloud: !location.local,
94
+ requiresApiKey: Boolean(apiKeyEnv),
95
+ requiresConsent: !location.local,
96
+ streaming: false,
97
+ tools: false,
98
+ state: detection.state,
99
+ baseUrl: normalizedBase,
100
+ modelId
101
+ };
102
+ },
103
+
104
+ async invoke(contextPack, request = {}) {
105
+ if (apiKeyEnv && !apiKey) {
106
+ return invokeError(id, `Missing ${apiKeyEnv}. Kairo never stores credentials.`);
107
+ }
108
+
109
+ const resolvedModel = request.modelId ?? modelId;
110
+ if (!resolvedModel) {
111
+ return invokeError(id, "Custom invoke requires modelId.");
112
+ }
113
+
114
+ const messages = [];
115
+ if (contextPack?.systemPrompt) {
116
+ messages.push({ role: "system", content: contextPack.systemPrompt });
117
+ }
118
+ if (Array.isArray(request.messages) && request.messages.length > 0) {
119
+ messages.push(...request.messages);
120
+ } else if (request.prompt) {
121
+ messages.push({ role: "user", content: request.prompt });
122
+ }
123
+
124
+ const headers = { "Content-Type": "application/json" };
125
+ if (apiKey && location.credentialSafe) headers.Authorization = `Bearer ${apiKey}`;
126
+
127
+ const result = await fetchJson(`${normalizedBase}/chat/completions`, {
128
+ method: "POST",
129
+ headers,
130
+ body: {
131
+ model: resolvedModel,
132
+ messages,
133
+ stream: false
134
+ },
135
+ timeoutMs: request.timeoutMs ?? 120000,
136
+ fetchImpl
137
+ });
138
+
139
+ if (!result.ok) {
140
+ return invokeError(id, result.error ?? "Custom provider chat failed.");
141
+ }
142
+
143
+ const content = result.data?.choices?.[0]?.message?.content ?? "";
144
+ const usage = result.data?.usage ?? {};
145
+
146
+ return {
147
+ ok: true,
148
+ backendId: id,
149
+ model: resolvedModel,
150
+ content,
151
+ usage: {
152
+ inputTokens: usage.prompt_tokens ?? null,
153
+ outputTokens: usage.completion_tokens ?? null,
154
+ cachedTokens: null,
155
+ estimatedCost: null,
156
+ model: resolvedModel,
157
+ backendId: id,
158
+ fallbackUsed: false
159
+ },
160
+ raw: result.data
161
+ };
162
+ }
163
+ };
164
+ }
165
+
166
+ function invokeError(backendId, message) {
167
+ return {
168
+ ok: false,
169
+ backendId,
170
+ model: null,
171
+ content: null,
172
+ error: message,
173
+ usage: null
174
+ };
175
+ }