krnl-code 1.0.4__py3-none-any.whl

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 (56) hide show
  1. krnl_agent/__init__.py +9 -0
  2. krnl_agent/__main__.py +7 -0
  3. krnl_agent/agent_registry.py +95 -0
  4. krnl_agent/agent_selector.py +69 -0
  5. krnl_agent/audit_log.py +155 -0
  6. krnl_agent/background.py +94 -0
  7. krnl_agent/checkpoints.py +67 -0
  8. krnl_agent/ci.py +73 -0
  9. krnl_agent/cli.py +1458 -0
  10. krnl_agent/commands.py +42 -0
  11. krnl_agent/config.py +425 -0
  12. krnl_agent/context.py +352 -0
  13. krnl_agent/depaudit.py +63 -0
  14. krnl_agent/deploy.py +245 -0
  15. krnl_agent/doctor.py +106 -0
  16. krnl_agent/events.py +141 -0
  17. krnl_agent/gitignore.py +47 -0
  18. krnl_agent/graph.py +928 -0
  19. krnl_agent/guardrails.py +70 -0
  20. krnl_agent/headless.py +60 -0
  21. krnl_agent/history.py +49 -0
  22. krnl_agent/hooks.py +72 -0
  23. krnl_agent/ingest.py +129 -0
  24. krnl_agent/llm.py +456 -0
  25. krnl_agent/loop.py +779 -0
  26. krnl_agent/mcp_client.py +128 -0
  27. krnl_agent/memory.py +61 -0
  28. krnl_agent/modelrouter.py +151 -0
  29. krnl_agent/monitor.py +112 -0
  30. krnl_agent/notify.py +119 -0
  31. krnl_agent/parallel_executor.py +139 -0
  32. krnl_agent/permissions.py +128 -0
  33. krnl_agent/plugins.py +105 -0
  34. krnl_agent/pricing.py +85 -0
  35. krnl_agent/prompts.py +60 -0
  36. krnl_agent/repomap.py +133 -0
  37. krnl_agent/sandbox.py +69 -0
  38. krnl_agent/scaffold.py +167 -0
  39. krnl_agent/schedules.py +137 -0
  40. krnl_agent/secrets.py +100 -0
  41. krnl_agent/selfheal.py +87 -0
  42. krnl_agent/server.py +302 -0
  43. krnl_agent/sessions.py +258 -0
  44. krnl_agent/settings.py +59 -0
  45. krnl_agent/skills.py +73 -0
  46. krnl_agent/teams.py +38 -0
  47. krnl_agent/tool_schemas.py +431 -0
  48. krnl_agent/tools.py +694 -0
  49. krnl_agent/webtools.py +139 -0
  50. krnl_code-1.0.4.dist-info/METADATA +214 -0
  51. krnl_code-1.0.4.dist-info/RECORD +56 -0
  52. krnl_code-1.0.4.dist-info/WHEEL +5 -0
  53. krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
  54. krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
  55. krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
  56. krnl_code-1.0.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,128 @@
1
+ """MCP (Model Context Protocol) client integration.
2
+
3
+ Connects to external MCP servers (stdio or SSE/HTTP) and exposes their tools to
4
+ the agent as OpenAI-style function schemas named ``mcp__<server>__<tool>``. This
5
+ is the universal, model-independent extensibility layer: any MCP server's tools
6
+ become available to whatever model you're running.
7
+
8
+ Entirely optional and guarded: if the `mcp` package isn't installed or no servers
9
+ are configured, this is a no-op and the core agent is unaffected.
10
+
11
+ Config (config.yaml):
12
+ mcp:
13
+ servers:
14
+ filesystem:
15
+ command: npx
16
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
17
+ env: {}
18
+ my_http:
19
+ url: https://example.com/mcp # SSE endpoint
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from contextlib import AsyncExitStack
24
+ from typing import Any, Optional
25
+
26
+
27
+ def mcp_available() -> bool:
28
+ try:
29
+ import mcp # noqa: F401
30
+
31
+ return True
32
+ except Exception:
33
+ return False
34
+
35
+
36
+ def _tool_name(server: str, tool: str) -> str:
37
+ return f"mcp__{server}__{tool}"
38
+
39
+
40
+ class MCPManager:
41
+ def __init__(self, servers: dict):
42
+ self.servers = servers or {}
43
+ self._stack: Optional[AsyncExitStack] = None
44
+ self._sessions: dict[str, Any] = {}
45
+ self._schemas: list[dict] = []
46
+ self._route: dict[str, tuple[str, str]] = {} # full name -> (server, tool)
47
+ self.errors: list[str] = []
48
+ self.started = False
49
+
50
+ def schemas(self) -> list[dict]:
51
+ return self._schemas
52
+
53
+ async def start(self) -> None:
54
+ if self.started or not self.servers or not mcp_available():
55
+ self.started = True
56
+ return
57
+ from mcp import ClientSession, StdioServerParameters
58
+ from mcp.client.stdio import stdio_client
59
+
60
+ self._stack = AsyncExitStack()
61
+ for name, conf in self.servers.items():
62
+ try:
63
+ if conf.get("url"):
64
+ from mcp.client.sse import sse_client
65
+
66
+ read, write = await self._stack.enter_async_context(
67
+ sse_client(conf["url"])
68
+ )
69
+ else:
70
+ params = StdioServerParameters(
71
+ command=conf["command"],
72
+ args=conf.get("args", []),
73
+ env=conf.get("env") or None,
74
+ )
75
+ read, write = await self._stack.enter_async_context(stdio_client(params))
76
+ session = await self._stack.enter_async_context(ClientSession(read, write))
77
+ await session.initialize()
78
+ self._sessions[name] = session
79
+ listed = await session.list_tools()
80
+ for t in listed.tools:
81
+ full = _tool_name(name, t.name)
82
+ self._route[full] = (name, t.name)
83
+ self._schemas.append(
84
+ {
85
+ "type": "function",
86
+ "function": {
87
+ "name": full,
88
+ "description": (t.description or "")[:1024],
89
+ "parameters": t.inputSchema or {"type": "object", "properties": {}},
90
+ },
91
+ }
92
+ )
93
+ except Exception as e: # noqa: BLE001
94
+ self.errors.append(f"{name}: {e}")
95
+ self.started = True
96
+
97
+ async def call(self, full_name: str, args: dict) -> str:
98
+ route = self._route.get(full_name)
99
+ if not route:
100
+ return f"unknown MCP tool: {full_name}"
101
+ server, tool = route
102
+ session = self._sessions.get(server)
103
+ if not session:
104
+ return f"MCP server not connected: {server}"
105
+ try:
106
+ result = await session.call_tool(tool, args or {})
107
+ except Exception as e: # noqa: BLE001
108
+ return f"MCP tool error: {e}"
109
+ # result.content is a list of content blocks
110
+ parts: list[str] = []
111
+ for block in getattr(result, "content", []) or []:
112
+ text = getattr(block, "text", None)
113
+ parts.append(text if text is not None else str(block))
114
+ return "\n".join(parts) if parts else "(no content)"
115
+
116
+ async def stop(self) -> None:
117
+ if self._stack:
118
+ try:
119
+ await self._stack.aclose()
120
+ except Exception:
121
+ pass
122
+ self._stack = None
123
+ self._sessions.clear()
124
+ self.started = False
125
+
126
+
127
+ def is_mcp_tool(name: str) -> bool:
128
+ return name.startswith("mcp__")
krnl_agent/memory.py ADDED
@@ -0,0 +1,61 @@
1
+ """Project & user memory — persistent instructions auto-loaded into context.
2
+
3
+ Looks for memory files in the workspace (and a global one) and injects them into
4
+ the system prompt, so project conventions and standing instructions are always in
5
+ context (like Claude Code's CLAUDE.md / AGENTS.md).
6
+
7
+ Precedence (all concatenated): user-global → project.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from .settings import SETTINGS_DIR
14
+
15
+ PROJECT_FILES = ["AGENTS.md", "KRNL.md", "CLAUDE.md", ".krnl/AGENTS.md"]
16
+ USER_FILE = SETTINGS_DIR / "AGENTS.md"
17
+ _MAX = 12000
18
+
19
+
20
+ def load_memory(workspace: str) -> str:
21
+ parts: list[str] = []
22
+ if USER_FILE.is_file():
23
+ try:
24
+ parts.append("# User memory (global)\n" + USER_FILE.read_text(encoding="utf-8")[:_MAX])
25
+ except Exception:
26
+ pass
27
+ root = Path(workspace)
28
+ for name in PROJECT_FILES:
29
+ p = root / name
30
+ if p.is_file():
31
+ try:
32
+ parts.append(f"# Project memory ({name})\n" + p.read_text(encoding="utf-8")[:_MAX])
33
+ except Exception:
34
+ pass
35
+ break # first project memory file wins
36
+ return "\n\n".join(parts)
37
+
38
+
39
+ TEMPLATE = """# Project memory for the Krnl Agent
40
+
41
+ This file is automatically loaded into the agent's context. Put standing
42
+ instructions and project conventions here.
43
+
44
+ ## Commands
45
+ - Build:
46
+ - Test:
47
+ - Lint:
48
+
49
+ ## Conventions
50
+ - (e.g. "use 4-space indent", "prefer async", "never touch generated/")
51
+
52
+ ## Notes
53
+ - (anything the agent should always know about this project)
54
+ """
55
+
56
+
57
+ def write_template(workspace: str) -> Path:
58
+ path = Path(workspace) / "AGENTS.md"
59
+ if not path.exists():
60
+ path.write_text(TEMPLATE, encoding="utf-8")
61
+ return path
@@ -0,0 +1,151 @@
1
+ """Multi-model routing — one model per job, across providers, cost-aware.
2
+
3
+ You can assign a different model (from any configured provider) to each *phase* of
4
+ the agent's work:
5
+
6
+ models: # named roles; each may be a different provider
7
+ planner: { provider: anthropic, model: claude-opus-4-8 }
8
+ executor: { provider: openai, model: gpt-5.5 }
9
+ cheap: { provider: groq, model: llama-3.3-70b-versatile }
10
+ verifier: { provider: gemini, model: gemini-2.5-flash }
11
+ routing:
12
+ strategy: auto # auto = cheap-where-safe + escalate on trouble
13
+ plan: planner # model used in plan mode
14
+ execute: executor # model for normal execution
15
+ subagent: cheap # model for delegated sub-agents
16
+ verify: cheap # model for the post-edit verifier
17
+ escalate: planner # stronger model to jump to when quality slips
18
+ escalate_after: 2 # consecutive all-failed steps before escalating
19
+
20
+ The goal is to spend the least money that still gets the job done: cheap models for
21
+ mechanical / parallel work, your main model for execution, a strong reasoning model
22
+ for planning — and an automatic **escalation** to a stronger model when the cheaper
23
+ one keeps failing. If nothing is configured, behaviour is identical to before
24
+ (single active model, with `router.cheap` / `subagent_model` still honored).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import replace
29
+
30
+ from . import pricing
31
+ from .config import AgentConfig, Config, ProviderConfig
32
+ from .llm import build_client
33
+
34
+ # Phase -> ordered fallback role names if the phase isn't explicitly routed.
35
+ _PHASE_DEFAULTS = {
36
+ "plan": ["planner", "plan", "heavy", "executor", "execute"],
37
+ "execute": ["executor", "execute"],
38
+ "subagent": ["subagent", "cheap", "executor", "execute"],
39
+ "verify": ["verifier", "verify", "cheap", "executor", "execute"],
40
+ "escalate": ["escalate", "heavy", "planner", "executor", "execute"],
41
+ }
42
+
43
+
44
+ class ModelRouter:
45
+ def __init__(self, config: Config):
46
+ self.config = config
47
+ self.routing: dict = dict(config.routing or {})
48
+ self.strategy = str(self.routing.get("strategy", "auto")).lower()
49
+ self.escalate_after = int(self.routing.get("escalate_after", 2))
50
+ self.roles: dict[str, dict] = self._build_roles()
51
+ self._clients: dict[str, object] = {}
52
+
53
+ # --- role resolution -------------------------------------------------- #
54
+ def _build_roles(self) -> dict[str, dict]:
55
+ roles: dict[str, dict] = {}
56
+ for name, entry in (self.config.models or {}).items():
57
+ if isinstance(entry, str): # shorthand: "role: model-id"
58
+ entry = {"model": entry}
59
+ roles[name] = dict(entry or {})
60
+ # Implicit roles for back-compat / zero-config.
61
+ roles.setdefault("execute", {"provider": self.config.provider.name,
62
+ "model": self.config.provider.model})
63
+ roles.setdefault("executor", roles["execute"])
64
+ cheap = (self.config.router or {}).get("cheap") or self.config.subagent_model
65
+ if cheap and "cheap" not in roles:
66
+ roles["cheap"] = {"model": cheap}
67
+ heavy = (self.config.router or {}).get("heavy")
68
+ if heavy and "heavy" not in roles:
69
+ roles["heavy"] = {"model": heavy}
70
+ return roles
71
+
72
+ def role_for_phase(self, phase: str) -> str:
73
+ explicit = self.routing.get(phase)
74
+ if explicit and explicit in self.roles:
75
+ return explicit
76
+ for cand in _PHASE_DEFAULTS.get(phase, ["execute"]):
77
+ if cand in self.roles:
78
+ return cand
79
+ return "execute"
80
+
81
+ def _provider_for_role(self, role: str) -> ProviderConfig:
82
+ entry = self.roles.get(role, {})
83
+ base_name = entry.get("provider") or self.config.provider.name
84
+ # Prefer the live active provider (it may carry a runtime-injected key).
85
+ if base_name == self.config.provider.name:
86
+ base = self.config.provider
87
+ else:
88
+ base = self.config.all_providers.get(base_name, self.config.provider)
89
+ over: dict = {}
90
+ if entry.get("model"):
91
+ over["model"] = entry["model"]
92
+ if "temperature" in entry:
93
+ over["temperature"] = float(entry["temperature"])
94
+ if "max_tokens" in entry:
95
+ over["max_tokens"] = int(entry["max_tokens"])
96
+ return replace(base, **over) if over else base
97
+
98
+ def _agent_for_role(self, role: str) -> AgentConfig:
99
+ entry = self.roles.get(role, {})
100
+ if "fallback_models" in entry:
101
+ return replace(self.config.agent, fallback_models=list(entry["fallback_models"]))
102
+ return self.config.agent
103
+
104
+ # --- public API ------------------------------------------------------- #
105
+ def provider_for_phase(self, phase: str) -> ProviderConfig:
106
+ return self._provider_for_role(self.role_for_phase(phase))
107
+
108
+ def model_for_phase(self, phase: str) -> str:
109
+ return self.provider_for_phase(phase).model
110
+
111
+ def client_for_phase(self, phase: str):
112
+ role = self.role_for_phase(phase)
113
+ if role not in self._clients:
114
+ self._clients[role] = build_client(self._provider_for_role(role),
115
+ self._agent_for_role(role))
116
+ return self._clients[role]
117
+
118
+ def is_default_phase(self, phase: str) -> bool:
119
+ """True if `phase` resolves to the active provider's model (no divergence).
120
+ Lets callers reuse an injected/active client instead of building a new one."""
121
+ p = self.provider_for_phase(phase)
122
+ return p.name == self.config.provider.name and p.model == self.config.provider.model
123
+
124
+ def has_distinct_escalation(self) -> bool:
125
+ """True if the escalate phase resolves to a different model than execute."""
126
+ return self.model_for_phase("escalate") != self.model_for_phase("execute")
127
+
128
+ @staticmethod
129
+ def _blended_rate(model: str, overrides: dict | None = None) -> float | None:
130
+ r = pricing.rate_for(model, overrides)
131
+ if r is None:
132
+ return None
133
+ # Weight output heavier (it dominates agent cost): input + 3*output.
134
+ return r[0] + 3 * r[1]
135
+
136
+ def summary(self) -> list[dict]:
137
+ """One row per phase: which role/provider/model and its price (visibility)."""
138
+ rows = []
139
+ for phase in ("plan", "execute", "subagent", "verify", "escalate"):
140
+ role = self.role_for_phase(phase)
141
+ p = self._provider_for_role(role)
142
+ rate = pricing.rate_for(p.model, self.config.pricing)
143
+ rows.append({
144
+ "phase": phase,
145
+ "role": role,
146
+ "provider": p.name,
147
+ "model": p.model,
148
+ "in_per_1m": rate[0] if rate else None,
149
+ "out_per_1m": rate[1] if rate else None,
150
+ })
151
+ return rows
krnl_agent/monitor.py ADDED
@@ -0,0 +1,112 @@
1
+ """Runtime monitoring & observability wiring.
2
+
3
+ The missing pillar in the field: once an app is deployed, give it eyes. This module
4
+ helps the agent (1) instrument the app with error tracking + telemetry, (2) make sure
5
+ a health endpoint exists, (3) register an uptime check, and (4) report current status
6
+ back into the terminal. Everything is credential-by-env-var and network calls are
7
+ best-effort (skipped cleanly when a token isn't set).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ # Health-endpoint snippets the agent can drop into a project that lacks one.
17
+ HEALTH_SNIPPETS = {
18
+ "fastapi": (
19
+ '@app.get("/health")\n'
20
+ 'def health():\n'
21
+ ' return {"status": "ok"}\n'),
22
+ "flask": (
23
+ '@app.get("/health")\n'
24
+ 'def health():\n'
25
+ ' return {"status": "ok"}, 200\n'),
26
+ "express": (
27
+ "app.get('/health', (req, res) => res.json({ status: 'ok' }));\n"),
28
+ "next": (
29
+ "// app/health/route.ts\n"
30
+ "export function GET() { return Response.json({ status: 'ok' }); }\n"),
31
+ }
32
+
33
+
34
+ def instrument_env() -> dict[str, str]:
35
+ """Env vars to inject into the deployed service for monitoring, based on what the
36
+ user has configured locally. Only includes a var if its source is present."""
37
+ out: dict[str, str] = {}
38
+ if os.getenv("SENTRY_DSN"):
39
+ out["SENTRY_DSN"] = os.environ["SENTRY_DSN"]
40
+ if os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"):
41
+ out["OTEL_EXPORTER_OTLP_ENDPOINT"] = os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"]
42
+ if os.getenv("OTEL_EXPORTER_OTLP_HEADERS"):
43
+ out["OTEL_EXPORTER_OTLP_HEADERS"] = os.environ["OTEL_EXPORTER_OTLP_HEADERS"]
44
+ return out
45
+
46
+
47
+ def configured_providers() -> dict[str, bool]:
48
+ return {
49
+ "sentry": bool(os.getenv("SENTRY_DSN") or os.getenv("SENTRY_AUTH_TOKEN")),
50
+ "opentelemetry": bool(os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")),
51
+ "datadog": bool(os.getenv("DD_API_KEY")),
52
+ "uptimerobot": bool(os.getenv("UPTIMEROBOT_API_KEY")),
53
+ "betterstack": bool(os.getenv("BETTERSTACK_TOKEN")),
54
+ }
55
+
56
+
57
+ def register_uptime_command(url: str) -> tuple[bool, str]:
58
+ """Build a headless command to register an uptime monitor for `url`."""
59
+ if os.getenv("UPTIMEROBOT_API_KEY"):
60
+ return True, (
61
+ 'curl -s -X POST https://api.uptimerobot.com/v3/monitors '
62
+ '-H "Authorization: Bearer $UPTIMEROBOT_API_KEY" '
63
+ '-H "Content-Type: application/json" '
64
+ f'-d \'{{"type":"http","url":"{url}","interval":300,"friendlyName":"krnl"}}\'')
65
+ if os.getenv("BETTERSTACK_TOKEN"):
66
+ return True, (
67
+ 'curl -s -X POST https://uptime.betterstack.com/api/v2/monitors '
68
+ '-H "Authorization: Bearer $BETTERSTACK_TOKEN" '
69
+ '-H "Content-Type: application/json" '
70
+ f'-d \'{{"monitor_type":"status","url":"{url}"}}\'')
71
+ return False, ("No uptime provider configured. Set UPTIMEROBOT_API_KEY or "
72
+ "BETTERSTACK_TOKEN to auto-register a check.")
73
+
74
+
75
+ def _sentry_unresolved() -> tuple[int, list[str]] | None:
76
+ """Best-effort: count unresolved Sentry issues. Returns None if not configured
77
+ or the call fails (never raises)."""
78
+ token = os.getenv("SENTRY_AUTH_TOKEN")
79
+ org = os.getenv("SENTRY_ORG")
80
+ project = os.getenv("SENTRY_PROJECT")
81
+ if not (token and org and project):
82
+ return None
83
+ url = (f"https://sentry.io/api/0/projects/{org}/{project}/issues/"
84
+ "?query=is:unresolved&statsPeriod=24h")
85
+ try:
86
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
87
+ with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
88
+ data = json.loads(r.read().decode("utf-8"))
89
+ titles = [i.get("title", "?") for i in data[:5]]
90
+ return len(data), titles
91
+ except (urllib.error.URLError, ValueError, TimeoutError, Exception): # noqa: BLE001
92
+ return None
93
+
94
+
95
+ def status() -> str:
96
+ """Human-readable monitoring status for the `monitor` command."""
97
+ prov = configured_providers()
98
+ lines = ["Monitoring status", ""]
99
+ for name, on in prov.items():
100
+ lines.append(f" {'✔' if on else '○'} {name}: {'configured' if on else 'not set'}")
101
+ sentry = _sentry_unresolved()
102
+ if sentry is not None:
103
+ n, titles = sentry
104
+ lines += ["", f"Sentry: {n} unresolved issue(s) in the last 24h:"]
105
+ lines += [f" - {t}" for t in titles] or [" (none)"]
106
+ elif prov["sentry"]:
107
+ lines += ["", "Sentry: set SENTRY_AUTH_TOKEN + SENTRY_ORG + SENTRY_PROJECT to "
108
+ "pull live issues."]
109
+ if not any(prov.values()):
110
+ lines += ["", "No monitoring configured yet. `krnl-agent ship` wires Sentry/OTel "
111
+ "+ an uptime check automatically when their tokens are present."]
112
+ return "\n".join(lines)
krnl_agent/notify.py ADDED
@@ -0,0 +1,119 @@
1
+ """Messaging connectors — push agent results to Slack / Discord / Telegram.
2
+
3
+ Config (config.yaml):
4
+ notifications:
5
+ on: [done, error] # which events to notify on
6
+ slack_webhook: https://hooks.slack.com/services/...
7
+ discord_webhook: https://discord.com/api/webhooks/...
8
+ telegram_token: 123:ABC
9
+ telegram_chat_id: "123456"
10
+
11
+ Used by the loop (on task completion) and by scheduled agents.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+
18
+ def send_slack(webhook: str, text: str) -> bool:
19
+ import httpx
20
+
21
+ try:
22
+ r = httpx.post(webhook, json={"text": text}, timeout=15)
23
+ return r.status_code < 300
24
+ except Exception:
25
+ return False
26
+
27
+
28
+ def send_discord(webhook: str, text: str) -> bool:
29
+ import httpx
30
+
31
+ try:
32
+ r = httpx.post(webhook, json={"content": text[:1900]}, timeout=15)
33
+ return r.status_code < 300
34
+ except Exception:
35
+ return False
36
+
37
+
38
+ def send_telegram(token: str, chat_id: str, text: str) -> bool:
39
+ import httpx
40
+
41
+ try:
42
+ r = httpx.post(
43
+ f"https://api.telegram.org/bot{token}/sendMessage",
44
+ json={"chat_id": chat_id, "text": text[:4000]},
45
+ timeout=15,
46
+ )
47
+ return r.status_code < 300
48
+ except Exception:
49
+ return False
50
+
51
+
52
+ def send_google_chat(webhook: str, text: str) -> bool:
53
+ import httpx
54
+
55
+ try:
56
+ r = httpx.post(webhook, json={"text": text[:4000]}, timeout=15)
57
+ return r.status_code < 300
58
+ except Exception:
59
+ return False
60
+
61
+
62
+ def send_whatsapp(sid: str, token: str, frm: str, to: str, text: str) -> bool:
63
+ """Send a WhatsApp message via Twilio."""
64
+ import httpx
65
+
66
+ try:
67
+ r = httpx.post(
68
+ f"https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json",
69
+ data={"From": f"whatsapp:{frm}", "To": f"whatsapp:{to}", "Body": text[:1500]},
70
+ auth=(sid, token),
71
+ timeout=15,
72
+ )
73
+ return r.status_code < 300
74
+ except Exception:
75
+ return False
76
+
77
+
78
+ def send_linear(api_key: str, team_id: str, text: str) -> bool:
79
+ """Create a Linear issue with the result."""
80
+ import httpx
81
+
82
+ mutation = (
83
+ "mutation($t:String!,$d:String!,$team:String!){"
84
+ "issueCreate(input:{teamId:$team,title:$t,description:$d}){success}}"
85
+ )
86
+ try:
87
+ r = httpx.post(
88
+ "https://api.linear.app/graphql",
89
+ json={"query": mutation, "variables": {
90
+ "t": text.splitlines()[0][:80] or "Krnl Agent", "d": text[:4000], "team": team_id}},
91
+ headers={"Authorization": api_key, "Content-Type": "application/json"},
92
+ timeout=15,
93
+ )
94
+ return r.status_code < 300
95
+ except Exception:
96
+ return False
97
+
98
+
99
+ def dispatch(config: Optional[dict], text: str) -> list[str]:
100
+ """Send `text` to every configured channel. Returns the channels notified."""
101
+ config = config or {}
102
+ sent = []
103
+ if config.get("slack_webhook") and send_slack(config["slack_webhook"], text):
104
+ sent.append("slack")
105
+ if config.get("discord_webhook") and send_discord(config["discord_webhook"], text):
106
+ sent.append("discord")
107
+ if config.get("telegram_token") and config.get("telegram_chat_id"):
108
+ if send_telegram(config["telegram_token"], str(config["telegram_chat_id"]), text):
109
+ sent.append("telegram")
110
+ if config.get("google_chat_webhook") and send_google_chat(config["google_chat_webhook"], text):
111
+ sent.append("google_chat")
112
+ if all(config.get(k) for k in ("twilio_sid", "twilio_token", "whatsapp_from", "whatsapp_to")):
113
+ if send_whatsapp(config["twilio_sid"], config["twilio_token"],
114
+ str(config["whatsapp_from"]), str(config["whatsapp_to"]), text):
115
+ sent.append("whatsapp")
116
+ if config.get("linear_api_key") and config.get("linear_team_id"):
117
+ if send_linear(config["linear_api_key"], config["linear_team_id"], text):
118
+ sent.append("linear")
119
+ return sent