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,70 @@
1
+ """Guardrails for autonomous deploy / self-heal.
2
+
3
+ Two deterministic safety layers used by the deploy + ship + heal flows:
4
+
5
+ * spend gate — block anything that may create billable cloud infra unless the user
6
+ opted in (`deploy.allow_billable`), defaulting to free-tier-only. Irreversible /
7
+ money-spending steps stay behind an explicit approval.
8
+ * secret redaction — mask any known credential value (from the env vars our deploy
9
+ targets use) before it could be echoed to the user, logged, or audited.
10
+
11
+ Graduated trust: a health-gated rollback (reverting to a known-good state) is always
12
+ safe to automate; anything that *creates* state or *spends money* is gated.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+
19
+ from . import deploy
20
+
21
+ # Env vars that hold deploy/monitoring credentials — their values get redacted.
22
+ _CRED_ENVS = [
23
+ "VERCEL_TOKEN", "NETLIFY_AUTH_TOKEN", "CLOUDFLARE_API_TOKEN", "RENDER_API_KEY",
24
+ "RAILWAY_TOKEN", "RAILWAY_API_TOKEN", "FLY_API_TOKEN", "NEON_API_KEY",
25
+ "SUPABASE_ACCESS_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY",
26
+ "AWS_SESSION_TOKEN", "AZURE_CLIENT_SECRET", "DOCKER_TOKEN", "DOCKER_PASSWORD",
27
+ "SENTRY_AUTH_TOKEN", "SENTRY_DSN", "DD_API_KEY", "DD_APP_KEY",
28
+ "UPTIMEROBOT_API_KEY", "GITHUB_TOKEN", "GH_TOKEN",
29
+ ]
30
+
31
+
32
+ def spend_gate(deploy_cfg: dict, target_name: str) -> tuple[str, str]:
33
+ """Return (decision, reason). decision is 'allow' | 'ask' | 'deny'.
34
+
35
+ Free-tier targets → allow. Billable targets → 'ask' (approval) unless the user
36
+ set deploy.allow_billable: true, and 'deny' if deploy.free_tier_only is set.
37
+ """
38
+ dep = deploy_cfg or {}
39
+ t = deploy.target(target_name)
40
+ if t is None:
41
+ return "deny", f"unknown deploy target '{target_name}'"
42
+ if not t.billable and t.free_tier:
43
+ return "allow", "free-tier target"
44
+ # Billable / no-free-tier target.
45
+ if dep.get("free_tier_only", True) and not dep.get("allow_billable", False):
46
+ return "deny", (f"{target_name} may create billable infrastructure; blocked by "
47
+ "deploy.free_tier_only (set deploy.allow_billable: true to permit)")
48
+ if dep.get("allow_billable", False):
49
+ return "allow", "billable allowed by deploy.allow_billable"
50
+ return "ask", f"{target_name} may incur cost — confirm before provisioning"
51
+
52
+
53
+ def cost_ceiling(deploy_cfg: dict) -> float | None:
54
+ v = (deploy_cfg or {}).get("cost_ceiling_usd")
55
+ return float(v) if v is not None else None
56
+
57
+
58
+ def redact(text: str) -> str:
59
+ """Mask known credential values and common secret token shapes in `text`."""
60
+ if not text:
61
+ return text
62
+ out = text
63
+ for env in _CRED_ENVS:
64
+ val = os.getenv(env)
65
+ if val and len(val) >= 6:
66
+ out = out.replace(val, f"$***{env}***")
67
+ # Generic token shapes (in case a value isn't from a known env var).
68
+ out = re.sub(r"\b(pypi-[A-Za-z0-9_\-]{8,}|gh[pousr]_[A-Za-z0-9]{20,}|"
69
+ r"sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})\b", "***redacted***", out)
70
+ return out
krnl_agent/headless.py ADDED
@@ -0,0 +1,60 @@
1
+ """Headless execution — run the agent non-interactively (CI, scripts, schedules).
2
+
3
+ Auto-approves all actions and optionally streams every event as JSON lines for
4
+ command chaining (`krnl-agent run "..." --json`).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+
10
+ from .config import load_config
11
+ from .events import AgentIO, ApprovalDecision
12
+ from .loop import AgentSession
13
+
14
+
15
+ class HeadlessIO(AgentIO):
16
+ def __init__(self, json_stream: bool = False):
17
+ self.events: list[dict] = []
18
+ self.final = ""
19
+ self.json_stream = json_stream
20
+
21
+ def _out(self, event: dict) -> None:
22
+ if self.json_stream:
23
+ print(json.dumps(event), flush=True)
24
+
25
+ def emit_sync(self, event: dict) -> None:
26
+ self.events.append(event)
27
+ self._out(event)
28
+
29
+ async def emit(self, event: dict) -> None:
30
+ self.events.append(event)
31
+ if event.get("type") == "assistant_message":
32
+ self.final = event["text"]
33
+ self._out(event)
34
+
35
+ async def request_approval(self, request: dict) -> ApprovalDecision:
36
+ self._out(request)
37
+ return ApprovalDecision(approved=True)
38
+
39
+
40
+ async def run_headless(
41
+ task: str,
42
+ workspace: str,
43
+ *,
44
+ provider: str | None = None,
45
+ model: str | None = None,
46
+ api_key: str | None = None,
47
+ json_stream: bool = False,
48
+ team: str | None = None,
49
+ ) -> dict:
50
+ cfg = load_config(provider=provider, model=model, api_key=api_key)
51
+ cfg.agent.auto_approve_writes = True
52
+ cfg.agent.auto_approve_commands = True
53
+ io = HeadlessIO(json_stream)
54
+ session = AgentSession(workspace, cfg, io, team=team)
55
+ await session.run(task)
56
+ return {
57
+ "final": io.final,
58
+ "tokens": session.session_tokens,
59
+ "cost": round(session.session_cost, 4),
60
+ }
krnl_agent/history.py ADDED
@@ -0,0 +1,49 @@
1
+ """Persist conversation history per workspace so context survives restarts.
2
+
3
+ Stored under ``~/.krnl-agent/sessions/<hash>.json``. The session saves after
4
+ every turn and can reload on startup, so closing the CLI or reloading the VS Code
5
+ window no longer wipes the conversation.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from .settings import SETTINGS_DIR
14
+
15
+ SESSIONS_DIR = SETTINGS_DIR / "sessions"
16
+
17
+
18
+ def _session_file(workspace: str) -> Path:
19
+ key = hashlib.sha1(str(Path(workspace).resolve()).encode("utf-8")).hexdigest()[:16]
20
+ return SESSIONS_DIR / f"{key}.json"
21
+
22
+
23
+ def load_history(workspace: str) -> list[dict]:
24
+ f = _session_file(workspace)
25
+ if not f.is_file():
26
+ return []
27
+ try:
28
+ data = json.loads(f.read_text(encoding="utf-8"))
29
+ return data.get("messages", []) if isinstance(data, dict) else []
30
+ except Exception:
31
+ return []
32
+
33
+
34
+ def save_history(workspace: str, messages: list[dict]) -> None:
35
+ try:
36
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
37
+ _session_file(workspace).write_text(
38
+ json.dumps({"workspace": str(Path(workspace).resolve()), "messages": messages}),
39
+ encoding="utf-8",
40
+ )
41
+ except Exception:
42
+ pass
43
+
44
+
45
+ def clear_history(workspace: str) -> None:
46
+ try:
47
+ _session_file(workspace).unlink()
48
+ except Exception:
49
+ pass
krnl_agent/hooks.py ADDED
@@ -0,0 +1,72 @@
1
+ """User-defined hooks — run shell commands on agent lifecycle events.
2
+
3
+ Config (config.yaml):
4
+ hooks:
5
+ PreToolUse:
6
+ - matcher: "write_file|edit_file|multi_edit" # regex on tool name (or "*")
7
+ command: "ruff check ." # non-zero exit BLOCKS the tool
8
+ PostToolUse:
9
+ - matcher: "*"
10
+ command: "echo done"
11
+ Stop:
12
+ - command: "notify-send 'agent finished'"
13
+ UserPromptSubmit:
14
+ - command: "echo prompt received"
15
+
16
+ The event payload is passed to the command as JSON on stdin. For PreToolUse a
17
+ non-zero exit code blocks the tool and the command's output is fed back to the
18
+ model as the reason.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ import subprocess
25
+ from typing import Optional
26
+
27
+ EVENTS = ("PreToolUse", "PostToolUse", "Stop", "UserPromptSubmit")
28
+
29
+
30
+ class HookRunner:
31
+ def __init__(self, config: Optional[dict], workspace: str, timeout: int = 30):
32
+ self.config = config or {}
33
+ self.workspace = workspace
34
+ self.timeout = timeout
35
+
36
+ @property
37
+ def active(self) -> bool:
38
+ return bool(self.config)
39
+
40
+ def _matching(self, event: str, tool: Optional[str]) -> list[str]:
41
+ cmds = []
42
+ for hook in self.config.get(event, []) or []:
43
+ matcher = hook.get("matcher", "*")
44
+ if tool is None or matcher == "*" or re.search(matcher, tool):
45
+ if hook.get("command"):
46
+ cmds.append(hook["command"])
47
+ return cmds
48
+
49
+ def run(self, event: str, payload: dict) -> tuple[bool, str]:
50
+ """Returns (blocked, message). `blocked` is only ever True for PreToolUse."""
51
+ tool = payload.get("tool")
52
+ outputs: list[str] = []
53
+ for command in self._matching(event, tool):
54
+ try:
55
+ res = subprocess.run(
56
+ command,
57
+ cwd=self.workspace,
58
+ shell=True,
59
+ input=json.dumps(payload),
60
+ capture_output=True,
61
+ text=True,
62
+ timeout=self.timeout,
63
+ )
64
+ except Exception as e: # noqa: BLE001
65
+ outputs.append(f"hook error: {e}")
66
+ continue
67
+ out = (res.stdout + res.stderr).strip()
68
+ if out:
69
+ outputs.append(out)
70
+ if event == "PreToolUse" and res.returncode != 0:
71
+ return True, out or f"blocked by hook (exit {res.returncode})"
72
+ return False, "\n".join(outputs)
krnl_agent/ingest.py ADDED
@@ -0,0 +1,129 @@
1
+ """Ingest files, folders, and images the user references or drags into the chat.
2
+
3
+ If a message contains a path (quoted, bare, or the whole line - e.g. a drag-and-
4
+ dropped file/folder, absolute or relative) that exists on disk, its contents are
5
+ inlined into the prompt: text files are read (any reasonable length), folders are
6
+ read recursively (bounded), and images become multimodal blocks for vision models.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import mimetypes
12
+ import re
13
+ from pathlib import Path
14
+
15
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
16
+ _MAX_FILE = 400_000 # chars per ingested file
17
+ _MAX_TOTAL = 1_200_000 # total chars across an ingest
18
+ _MAX_FOLDER_FILES = 60
19
+
20
+
21
+ def _is_binary(p: Path) -> bool:
22
+ try:
23
+ with open(p, "rb") as f:
24
+ return b"\x00" in f.read(4096)
25
+ except Exception:
26
+ return True
27
+
28
+
29
+ def _read_text(p: Path) -> str:
30
+ try:
31
+ data = p.read_text(encoding="utf-8", errors="replace")
32
+ except Exception as e: # noqa: BLE001
33
+ return f"(could not read: {e})"
34
+ if len(data) > _MAX_FILE:
35
+ head = _MAX_FILE * 3 // 4
36
+ data = data[:head] + f"\n…[truncated, {len(data)} chars total]…\n" + data[-(_MAX_FILE - head):]
37
+ return data
38
+
39
+
40
+ def _image_url(p: Path) -> str | None:
41
+ try:
42
+ mime = mimetypes.guess_type(str(p))[0] or "image/png"
43
+ b64 = base64.b64encode(p.read_bytes()).decode("ascii")
44
+ return f"data:{mime};base64,{b64}"
45
+ except Exception:
46
+ return None
47
+
48
+
49
+ def _candidates(text: str) -> list[str]:
50
+ cands: list[str] = []
51
+ for m in re.finditer(r'"([^"]+)"|\'([^\']+)\'', text): # quoted (drag-drop w/ spaces)
52
+ cands.append(m.group(1) or m.group(2))
53
+ whole = text.strip().strip('"').strip("'")
54
+ if whole:
55
+ cands.append(whole) # whole line = one dropped path
56
+ for tok in re.split(r"\s+", text): # path-like tokens
57
+ tok = tok.strip().strip("\"'").rstrip(".,;:)").lstrip("@")
58
+ looks_path = "/" in tok or "\\" in tok or re.match(r"^[A-Za-z]:", tok)
59
+ has_ext = re.search(r"\.[A-Za-z0-9]{1,6}$", tok)
60
+ if len(tok) > 2 and (looks_path or has_ext):
61
+ cands.append(tok)
62
+ out, seen = [], set()
63
+ for c in cands:
64
+ if c and c not in seen:
65
+ seen.add(c)
66
+ out.append(c)
67
+ return out
68
+
69
+
70
+ def _resolve(cand: str, ctx) -> Path | None:
71
+ try:
72
+ p = Path(cand)
73
+ if p.exists():
74
+ return p
75
+ rp = ctx.root / cand
76
+ if rp.exists():
77
+ return rp
78
+ except Exception:
79
+ return None
80
+ return None
81
+
82
+
83
+ def gather(task: str, ctx) -> tuple[str, list[str]]:
84
+ """Return (augmented_task, image_data_urls). Inlines referenced files/folders;
85
+ collects images as data URLs for vision models."""
86
+ images: list[str] = []
87
+ blocks: list[str] = []
88
+ total = 0
89
+ handled: set[str] = set()
90
+ for cand in _candidates(task):
91
+ p = _resolve(cand, ctx)
92
+ if p is None:
93
+ continue
94
+ key = str(p.resolve())
95
+ if key in handled:
96
+ continue
97
+ handled.add(key)
98
+ try:
99
+ if p.is_dir():
100
+ files = [fp for fp in sorted(p.rglob("*"))
101
+ if fp.is_file() and not _is_binary(fp)][:_MAX_FOLDER_FILES]
102
+ blocks.append(f"--- folder: {p} ({len(files)} files) ---")
103
+ for fp in files:
104
+ if total > _MAX_TOTAL:
105
+ break
106
+ c = _read_text(fp)
107
+ try:
108
+ rel = fp.relative_to(p)
109
+ except Exception:
110
+ rel = fp.name
111
+ blocks.append(f"### {rel}\n{c}")
112
+ total += len(c)
113
+ elif p.suffix.lower() in IMAGE_EXTS:
114
+ url = _image_url(p)
115
+ if url:
116
+ images.append(url)
117
+ blocks.append(f"[image attached: {p.name}]")
118
+ elif p.is_file() and not _is_binary(p):
119
+ c = _read_text(p)
120
+ blocks.append(f"--- file: {p} ---\n{c}")
121
+ total += len(c)
122
+ except Exception:
123
+ continue
124
+ if total > _MAX_TOTAL:
125
+ break
126
+
127
+ if blocks:
128
+ task = task + "\n\n# Provided context (files / folders / images you referenced)\n" + "\n\n".join(blocks)
129
+ return task, images