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.
- krnl_agent/__init__.py +9 -0
- krnl_agent/__main__.py +7 -0
- krnl_agent/agent_registry.py +95 -0
- krnl_agent/agent_selector.py +69 -0
- krnl_agent/audit_log.py +155 -0
- krnl_agent/background.py +94 -0
- krnl_agent/checkpoints.py +67 -0
- krnl_agent/ci.py +73 -0
- krnl_agent/cli.py +1458 -0
- krnl_agent/commands.py +42 -0
- krnl_agent/config.py +425 -0
- krnl_agent/context.py +352 -0
- krnl_agent/depaudit.py +63 -0
- krnl_agent/deploy.py +245 -0
- krnl_agent/doctor.py +106 -0
- krnl_agent/events.py +141 -0
- krnl_agent/gitignore.py +47 -0
- krnl_agent/graph.py +928 -0
- krnl_agent/guardrails.py +70 -0
- krnl_agent/headless.py +60 -0
- krnl_agent/history.py +49 -0
- krnl_agent/hooks.py +72 -0
- krnl_agent/ingest.py +129 -0
- krnl_agent/llm.py +456 -0
- krnl_agent/loop.py +779 -0
- krnl_agent/mcp_client.py +128 -0
- krnl_agent/memory.py +61 -0
- krnl_agent/modelrouter.py +151 -0
- krnl_agent/monitor.py +112 -0
- krnl_agent/notify.py +119 -0
- krnl_agent/parallel_executor.py +139 -0
- krnl_agent/permissions.py +128 -0
- krnl_agent/plugins.py +105 -0
- krnl_agent/pricing.py +85 -0
- krnl_agent/prompts.py +60 -0
- krnl_agent/repomap.py +133 -0
- krnl_agent/sandbox.py +69 -0
- krnl_agent/scaffold.py +167 -0
- krnl_agent/schedules.py +137 -0
- krnl_agent/secrets.py +100 -0
- krnl_agent/selfheal.py +87 -0
- krnl_agent/server.py +302 -0
- krnl_agent/sessions.py +258 -0
- krnl_agent/settings.py +59 -0
- krnl_agent/skills.py +73 -0
- krnl_agent/teams.py +38 -0
- krnl_agent/tool_schemas.py +431 -0
- krnl_agent/tools.py +694 -0
- krnl_agent/webtools.py +139 -0
- krnl_code-1.0.4.dist-info/METADATA +214 -0
- krnl_code-1.0.4.dist-info/RECORD +56 -0
- krnl_code-1.0.4.dist-info/WHEEL +5 -0
- krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
- krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
- krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
- krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/guardrails.py
ADDED
|
@@ -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
|