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
krnl_agent/server.py ADDED
@@ -0,0 +1,302 @@
1
+ """FastAPI server: WebSocket (streaming + approvals) and a REST fallback.
2
+
3
+ Cloud-ready: optional bearer-token auth (`--token` / $KRNL_AGENT_TOKEN), bind to
4
+ any host, dynamic port, cancel/undo, auto-approve, and cross-restart persistence.
5
+
6
+ WebSocket protocol (JSON both directions)
7
+ -----------------------------------------
8
+ Client -> server:
9
+ {"type":"init","workspace_path":"...","provider":"krnl"?,"model":"..."?,
10
+ "api_key":"..."?,"base_url":"..."?,"auto_approve":bool?,"persist":bool?,"token":"..."?}
11
+ {"type":"run","task":"..."}
12
+ {"type":"approval","id":"...","approved":bool,"feedback":"..."?}
13
+ {"type":"cancel"} # stop the running task
14
+ {"type":"undo"} # revert the last turn's file changes
15
+ {"type":"ping"}
16
+
17
+ Server -> client: see krnl_agent.events.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import os
23
+ import secrets
24
+ from typing import Optional
25
+
26
+ from fastapi import FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect
27
+ from pydantic import BaseModel
28
+
29
+ from . import events as ev
30
+ from .config import Config, load_config
31
+ from .events import AgentIO, ApprovalDecision
32
+ from .loop import AgentSession
33
+
34
+ app = FastAPI(title="Krnl Agent", version="1.4.0")
35
+ app.state.token = None # set by serve(); None disables auth (local dev/tests)
36
+
37
+
38
+ def _auth_ok(provided: Optional[str]) -> bool:
39
+ expected = app.state.token
40
+ return expected is None or (provided is not None and secrets.compare_digest(provided, expected))
41
+
42
+
43
+ # --------------------------------------------------------------------------- #
44
+ # WebSocket IO
45
+ # --------------------------------------------------------------------------- #
46
+ class WebSocketIO(AgentIO):
47
+ def __init__(self, ws: WebSocket, loop: asyncio.AbstractEventLoop):
48
+ self.ws = ws
49
+ self.loop = loop
50
+ self.pending: dict[str, asyncio.Future] = {}
51
+
52
+ def emit_sync(self, event: dict) -> None:
53
+ asyncio.run_coroutine_threadsafe(self._safe_send(event), self.loop)
54
+
55
+ async def _safe_send(self, event: dict) -> None:
56
+ try:
57
+ await self.ws.send_json(event)
58
+ except Exception:
59
+ pass
60
+
61
+ async def emit(self, event: dict) -> None:
62
+ await self._safe_send(event)
63
+
64
+ async def request_approval(self, request: dict) -> ApprovalDecision:
65
+ fut: asyncio.Future = self.loop.create_future()
66
+ self.pending[request["id"]] = fut
67
+ await self.ws.send_json(request)
68
+ return await fut
69
+
70
+ def resolve_approval(self, data: dict) -> None:
71
+ fut = self.pending.pop(data.get("id"), None)
72
+ if fut and not fut.done():
73
+ fut.set_result(
74
+ ApprovalDecision(
75
+ bool(data.get("approved", False)),
76
+ data.get("feedback"),
77
+ always=bool(data.get("always", False)),
78
+ )
79
+ )
80
+
81
+
82
+ def _config_for(data: dict) -> Config:
83
+ return load_config(
84
+ provider=data.get("provider"),
85
+ model=data.get("model"),
86
+ api_key=data.get("api_key"),
87
+ base_url=data.get("base_url"),
88
+ )
89
+
90
+
91
+ @app.websocket("/ws")
92
+ async def ws_endpoint(ws: WebSocket):
93
+ await ws.accept()
94
+ loop = asyncio.get_running_loop()
95
+ io = WebSocketIO(ws, loop)
96
+ session: Optional[AgentSession] = None
97
+ running: Optional[asyncio.Task] = None
98
+
99
+ try:
100
+ while True:
101
+ data = await ws.receive_json()
102
+ kind = data.get("type")
103
+
104
+ if kind == "init":
105
+ if not _auth_ok(data.get("token")):
106
+ await ws.send_json(ev.error("Unauthorized: bad or missing token."))
107
+ await ws.close()
108
+ return
109
+ cfg = _config_for(data)
110
+ if data.get("auto_approve"):
111
+ cfg.agent.auto_approve_writes = True
112
+ cfg.agent.auto_approve_commands = True
113
+ effort = data.get("reasoning_effort")
114
+ if effort:
115
+ cfg.agent.reasoning_effort = effort
116
+ cfg.agent.thinking = effort == "high"
117
+ session = AgentSession(
118
+ data["workspace_path"],
119
+ cfg,
120
+ io,
121
+ persist=bool(data.get("persist")),
122
+ plan_mode=bool(data.get("plan_mode")),
123
+ session_id=data.get("session_id"),
124
+ dangerous=bool(data.get("dangerous")),
125
+ )
126
+ await ws.send_json(
127
+ ev.status(f"ready · {cfg.provider.name} · {cfg.provider.model}")
128
+ )
129
+ if cfg.agent.auto_onboard and not data.get("no_onboard"):
130
+ from . import scaffold
131
+ from .skills import load_skills
132
+
133
+ if scaffold.needs_scaffold(data["workspace_path"]):
134
+ created = scaffold.scaffold(data["workspace_path"])
135
+ if created:
136
+ session.skills = load_skills(data["workspace_path"])
137
+ await ws.send_json(ev.info(
138
+ f"Onboarded this project — created {len(created)} files "
139
+ "under .krnl/ (memory, project doc, a skill)."))
140
+
141
+ elif kind == "onboard":
142
+ if session:
143
+ from . import scaffold
144
+ from .skills import load_skills
145
+
146
+ created = scaffold.scaffold(session.workspace)
147
+ session.skills = load_skills(session.workspace)
148
+ await ws.send_json(ev.info(
149
+ "Onboarded: " + (", ".join(created) if created else "already set up")))
150
+
151
+ elif kind == "run":
152
+ if session is None:
153
+ await ws.send_json(ev.error("Send an 'init' message first."))
154
+ continue
155
+ if running and not running.done():
156
+ await ws.send_json(ev.error("A task is already running."))
157
+ continue
158
+ running = asyncio.create_task(session.run(data["task"], data.get("images")))
159
+
160
+ elif kind == "approval":
161
+ io.resolve_approval(data)
162
+
163
+ elif kind == "cancel":
164
+ if session:
165
+ session.cancel()
166
+
167
+ elif kind == "undo":
168
+ if session:
169
+ reverted = session.undo()
170
+ await ws.send_json(
171
+ ev.info(
172
+ f"reverted {len(reverted)} file(s): {', '.join(reverted)}"
173
+ if reverted
174
+ else "nothing to undo"
175
+ )
176
+ )
177
+
178
+ elif kind == "ping":
179
+ await ws.send_json({"type": "pong"})
180
+
181
+ else:
182
+ await ws.send_json(ev.error(f"Unknown message type: {kind}"))
183
+
184
+ except WebSocketDisconnect:
185
+ if running and not running.done():
186
+ if session:
187
+ session.cancel()
188
+ running.cancel()
189
+
190
+
191
+ # --------------------------------------------------------------------------- #
192
+ # REST fallback (one-shot, non-interactive)
193
+ # --------------------------------------------------------------------------- #
194
+ class BufferIO(AgentIO):
195
+ def __init__(self, auto_approve: bool):
196
+ self.events: list[dict] = []
197
+ self.auto_approve = auto_approve
198
+
199
+ def emit_sync(self, event: dict) -> None:
200
+ self.events.append(event)
201
+
202
+ async def emit(self, event: dict) -> None:
203
+ self.events.append(event)
204
+
205
+ async def request_approval(self, request: dict) -> ApprovalDecision:
206
+ self.events.append(request)
207
+ return ApprovalDecision(approved=self.auto_approve, feedback=None)
208
+
209
+
210
+ class RunRequest(BaseModel):
211
+ task: str
212
+ workspace_path: str
213
+ provider: Optional[str] = None
214
+ model: Optional[str] = None
215
+ api_key: Optional[str] = None
216
+ base_url: Optional[str] = None
217
+ auto_approve: bool = False
218
+
219
+
220
+ @app.post("/agent/run")
221
+ async def agent_run(req: RunRequest, authorization: Optional[str] = Header(default=None)):
222
+ token = authorization.replace("Bearer ", "") if authorization else None
223
+ if not _auth_ok(token):
224
+ raise HTTPException(status_code=401, detail="Unauthorized")
225
+ cfg = load_config(
226
+ provider=req.provider, model=req.model, api_key=req.api_key, base_url=req.base_url
227
+ )
228
+ if req.auto_approve:
229
+ cfg.agent.auto_approve_writes = True
230
+ cfg.agent.auto_approve_commands = True
231
+ io = BufferIO(auto_approve=req.auto_approve)
232
+ session = AgentSession(req.workspace_path, cfg, io)
233
+ await session.run(req.task)
234
+ final = next(
235
+ (e["text"] for e in reversed(io.events) if e["type"] == "assistant_message"), ""
236
+ )
237
+ return {
238
+ "status": "ok",
239
+ "provider": cfg.provider.name,
240
+ "model": cfg.provider.model,
241
+ "final": final,
242
+ "session_tokens": session.session_tokens,
243
+ "events": io.events,
244
+ }
245
+
246
+
247
+ @app.get("/health")
248
+ async def health():
249
+ return {"status": "ok"}
250
+
251
+
252
+ @app.get("/providers")
253
+ async def providers():
254
+ cfg = load_config()
255
+ return {
256
+ "active": cfg.provider.name,
257
+ "providers": {
258
+ name: {"type": p.type, "model": p.model, "base_url": p.base_url}
259
+ for name, p in cfg.all_providers.items()
260
+ },
261
+ }
262
+
263
+
264
+ # --------------------------------------------------------------------------- #
265
+ # Server entry
266
+ # --------------------------------------------------------------------------- #
267
+ def _free_port(host: str) -> int:
268
+ import socket
269
+
270
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
271
+ s.bind((host if host != "0.0.0.0" else "", 0))
272
+ port = s.getsockname()[1]
273
+ s.close()
274
+ return port
275
+
276
+
277
+ def serve(
278
+ host: str = "127.0.0.1",
279
+ port: int = 8000,
280
+ reload: bool = False,
281
+ token: Optional[str] = None,
282
+ auth: bool = True,
283
+ ) -> None:
284
+ import uvicorn
285
+
286
+ if port == 0:
287
+ port = _free_port(host)
288
+
289
+ # Resolve the auth token: explicit > env > generated (unless auth disabled).
290
+ resolved = token or os.getenv("KRNL_AGENT_TOKEN")
291
+ if auth and not resolved:
292
+ resolved = secrets.token_urlsafe(24)
293
+ app.state.token = resolved if auth else None
294
+
295
+ # Announce port (and token) on stdout so a parent process can discover them.
296
+ print(f"KRNL_AGENT_LISTENING http://{host}:{port}", flush=True)
297
+ if app.state.token:
298
+ print(f"KRNL_AGENT_TOKEN {app.state.token}", flush=True)
299
+
300
+ uvicorn.run(
301
+ "krnl_agent.server:app" if reload else app, host=host, port=port, reload=reload
302
+ )
krnl_agent/sessions.py ADDED
@@ -0,0 +1,258 @@
1
+ """Named, resumable sessions with a dashboard + transcript search.
2
+
3
+ Each session is a JSON file under ``~/.krnl-agent/sessions/<id>.json`` with
4
+ metadata (workspace, created/updated timestamps, title) and the full message
5
+ list. Supports listing (dashboard), resuming by id or "latest for this
6
+ workspace", and full-text search across past conversations.
7
+
8
+ Phase 3 additions:
9
+ - Structured per-session memory with types: fact, decision, error, solution
10
+ - Memory retrieval with staleness checking against the code knowledge graph
11
+ - Per-session only (no cross-session persistence).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ import uuid
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Optional
21
+
22
+ from .settings import SETTINGS_DIR
23
+
24
+ if TYPE_CHECKING:
25
+ from .graph import GraphManager
26
+
27
+ SESSIONS_DIR = SETTINGS_DIR / "sessions"
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Phase 3: Per-Session Memory
32
+ # --------------------------------------------------------------------------- #
33
+ @dataclass
34
+ class MemoryEntry:
35
+ """A single memory entry in a session."""
36
+ type: str # fact, decision, error, solution
37
+ content: str
38
+ created_at: float
39
+ related_files: list[str] = field(default_factory=list)
40
+ stale: bool = False
41
+
42
+
43
+ @dataclass
44
+ class SessionMemory:
45
+ """Per-session structured memory."""
46
+ entries: list[MemoryEntry] = field(default_factory=list)
47
+
48
+ def add(self, memory_type: str, content: str, related_files: Optional[list[str]] = None) -> None:
49
+ """Add a memory entry."""
50
+ if memory_type not in ("fact", "decision", "error", "solution"):
51
+ raise ValueError(f"Invalid memory type: {memory_type}")
52
+ entry = MemoryEntry(
53
+ type=memory_type,
54
+ content=content,
55
+ created_at=time.time(),
56
+ related_files=related_files or [],
57
+ )
58
+ self.entries.append(entry)
59
+
60
+ def retrieve(
61
+ self,
62
+ context_files: Optional[list[str]] = None,
63
+ graph_manager: Optional["GraphManager"] = None,
64
+ limit: int = 10,
65
+ ) -> list[MemoryEntry]:
66
+ """Retrieve memory entries, filtering by context and staleness.
67
+
68
+ Args:
69
+ context_files: Files currently in context (for relevance matching)
70
+ graph_manager: GraphManager for staleness checking
71
+ limit: Max number of entries to return
72
+
73
+ Returns:
74
+ List of non-stale, relevant memory entries ordered by recency.
75
+ """
76
+ # Filter stale entries using graph
77
+ if graph_manager:
78
+ self._check_staleness(graph_manager)
79
+
80
+ # Filter by context relevance
81
+ relevant = []
82
+ for entry in self.entries:
83
+ if entry.stale:
84
+ continue
85
+ if context_files and entry.related_files:
86
+ # Check if any related file is in current context
87
+ if not any(f in context_files for f in entry.related_files):
88
+ continue
89
+ relevant.append(entry)
90
+
91
+ # Sort by recency and limit
92
+ relevant.sort(key=lambda e: e.created_at, reverse=True)
93
+ return relevant[:limit]
94
+
95
+ def _check_staleness(self, graph_manager: "GraphManager") -> None:
96
+ """Mark entries stale if their referenced files/functions changed.
97
+
98
+ Precedence rule: if a memory entry references a file/function that the
99
+ Phase 1 graph shows has since changed or been deleted, mark that memory
100
+ entry stale and exclude it from retrieval.
101
+ """
102
+ for entry in self.entries:
103
+ if not entry.related_files:
104
+ continue
105
+
106
+ # Check if any related file has changed or been deleted
107
+ for file_path in entry.related_files:
108
+ # Try both relative and absolute path
109
+ nodes = graph_manager.graph.get_nodes_by_file(file_path)
110
+ if not nodes:
111
+ # Try resolving as relative to workspace
112
+ from pathlib import Path
113
+ abs_path = str(Path(graph_manager.workspace) / file_path)
114
+ nodes = graph_manager.graph.get_nodes_by_file(abs_path)
115
+
116
+ if not nodes:
117
+ # File deleted from graph
118
+ entry.stale = True
119
+ break
120
+
121
+ # Check if nodes still exist (they might have been invalidated)
122
+ # In a real implementation, we'd track node versions/timestamps
123
+ # For v1, we assume if the file exists in the graph, it's not stale
124
+ # This is a simplification per the spec's "explicitly deferred" scope
125
+
126
+
127
+
128
+ def new_id() -> str:
129
+ return uuid.uuid4().hex[:12]
130
+
131
+
132
+ def _file(session_id: str) -> Path:
133
+ return SESSIONS_DIR / f"{session_id}.json"
134
+
135
+
136
+ def _title(messages: list[dict]) -> str:
137
+ for m in messages:
138
+ if m.get("role") == "user":
139
+ c = m.get("content")
140
+ text = c if isinstance(c, str) else (
141
+ next((b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text"), "")
142
+ if isinstance(c, list) else ""
143
+ )
144
+ text = text.strip().replace("\n", " ")
145
+ if text:
146
+ return text[:60]
147
+ return "(empty session)"
148
+
149
+
150
+ def save(session_id: str, workspace: str, messages: list[dict], memory: Optional[SessionMemory] = None) -> None:
151
+ try:
152
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
153
+ f = _file(session_id)
154
+ created = time.time()
155
+ if f.exists():
156
+ try:
157
+ created = json.loads(f.read_text(encoding="utf-8")).get("created", created)
158
+ except Exception:
159
+ pass
160
+
161
+ # Serialize memory entries
162
+ memory_data = None
163
+ if memory:
164
+ memory_data = [
165
+ {
166
+ "type": e.type,
167
+ "content": e.content,
168
+ "created_at": e.created_at,
169
+ "related_files": e.related_files,
170
+ "stale": e.stale,
171
+ }
172
+ for e in memory.entries
173
+ ]
174
+
175
+ f.write_text(json.dumps({
176
+ "id": session_id,
177
+ "workspace": str(Path(workspace).resolve()),
178
+ "created": created,
179
+ "updated": time.time(),
180
+ "title": _title(messages),
181
+ "messages": messages,
182
+ "memory": memory_data,
183
+ }), encoding="utf-8")
184
+ except Exception:
185
+ pass
186
+
187
+
188
+ def load(session_id: str) -> tuple[list[dict], Optional[SessionMemory]]:
189
+ """Load messages and memory from a session."""
190
+ try:
191
+ data = json.loads(_file(session_id).read_text(encoding="utf-8"))
192
+ messages = data.get("messages", [])
193
+
194
+ # Deserialize memory entries
195
+ memory_data = data.get("memory")
196
+ memory = None
197
+ if memory_data:
198
+ memory = SessionMemory()
199
+ for entry_data in memory_data:
200
+ entry = MemoryEntry(
201
+ type=entry_data["type"],
202
+ content=entry_data["content"],
203
+ created_at=entry_data["created_at"],
204
+ related_files=entry_data.get("related_files", []),
205
+ stale=entry_data.get("stale", False),
206
+ )
207
+ memory.entries.append(entry)
208
+
209
+ return messages, memory
210
+ except Exception:
211
+ return [], None
212
+
213
+
214
+ def _meta(path: Path) -> dict | None:
215
+ try:
216
+ d = json.loads(path.read_text(encoding="utf-8"))
217
+ return {k: d.get(k) for k in ("id", "workspace", "created", "updated", "title")}
218
+ except Exception:
219
+ return None
220
+
221
+
222
+ def list_sessions(workspace: str | None = None) -> list[dict]:
223
+ if not SESSIONS_DIR.is_dir():
224
+ return []
225
+ ws = str(Path(workspace).resolve()) if workspace else None
226
+ out = []
227
+ for p in SESSIONS_DIR.glob("*.json"):
228
+ m = _meta(p)
229
+ if m and (ws is None or m.get("workspace") == ws):
230
+ out.append(m)
231
+ return sorted(out, key=lambda m: m.get("updated") or 0, reverse=True)
232
+
233
+
234
+ def latest_id(workspace: str) -> str | None:
235
+ rows = list_sessions(workspace)
236
+ return rows[0]["id"] if rows else None
237
+
238
+
239
+ def search(query: str, workspace: str | None = None) -> list[dict]:
240
+ q = query.lower()
241
+ hits = []
242
+ for meta in list_sessions(workspace):
243
+ messages, _ = load(meta["id"])
244
+ for m in messages:
245
+ c = m.get("content")
246
+ if isinstance(c, str) and q in c.lower():
247
+ idx = c.lower().find(q)
248
+ hits.append({"id": meta["id"], "title": meta["title"],
249
+ "snippet": c[max(0, idx - 30): idx + 50].replace("\n", " ")})
250
+ break
251
+ return hits
252
+
253
+
254
+ def delete(session_id: str) -> None:
255
+ try:
256
+ _file(session_id).unlink()
257
+ except Exception:
258
+ pass
krnl_agent/settings.py ADDED
@@ -0,0 +1,59 @@
1
+ """Persistent user settings for the CLI (Claude-CLI-style).
2
+
3
+ Stored at ``~/.krnl-agent/config.json`` so the CLI remembers your provider,
4
+ model, and API keys between sessions. Keys live here in plaintext (user-only
5
+ file permissions on POSIX); for shared machines prefer environment variables.
6
+
7
+ Shape:
8
+ {
9
+ "provider": "openai",
10
+ "providers": {
11
+ "openai": {"api_key": "sk-...", "model": "gpt-4o-mini", "base_url": null}
12
+ }
13
+ }
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+
21
+ SETTINGS_DIR = Path.home() / ".krnl-agent"
22
+ SETTINGS_FILE = SETTINGS_DIR / "config.json"
23
+
24
+
25
+ def load() -> dict:
26
+ try:
27
+ return json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
28
+ except Exception:
29
+ return {}
30
+
31
+
32
+ def save(data: dict) -> None:
33
+ SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
34
+ SETTINGS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
35
+ try:
36
+ os.chmod(SETTINGS_FILE, 0o600) # best-effort; no-op on some platforms
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ def set_active_provider(name: str) -> None:
42
+ d = load()
43
+ d["provider"] = name
44
+ save(d)
45
+
46
+
47
+ def set_provider_field(provider: str, field: str, value) -> None:
48
+ d = load()
49
+ provs = d.setdefault("providers", {})
50
+ p = provs.setdefault(provider, {})
51
+ if value in (None, ""):
52
+ p.pop(field, None)
53
+ else:
54
+ p[field] = value
55
+ save(d)
56
+
57
+
58
+ def provider_overrides(provider: str) -> dict:
59
+ return (load().get("providers") or {}).get(provider, {})
krnl_agent/skills.py ADDED
@@ -0,0 +1,73 @@
1
+ """Skills — packaged, reusable capabilities loaded on demand (Claude-Code-style).
2
+
3
+ A skill is a folder with a `SKILL.md` file:
4
+ .krnl/skills/<name>/SKILL.md (project)
5
+ ~/.krnl-agent/skills/<name>/SKILL.md (global)
6
+
7
+ `SKILL.md` may start with YAML-ish frontmatter:
8
+ ---
9
+ description: One line shown to the model so it knows when to use this skill.
10
+ ---
11
+ <the full instructions / playbook>
12
+
13
+ Progressive disclosure: only the name + description go into the system prompt; the
14
+ model calls `use_skill(name)` to pull the full body when it actually needs it.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ from .settings import SETTINGS_DIR
21
+
22
+
23
+ def _dirs(workspace: str) -> list[Path]:
24
+ from . import plugins
25
+
26
+ return [SETTINGS_DIR / "skills", *plugins.plugin_skill_dirs(),
27
+ Path(workspace) / ".krnl" / "skills"]
28
+
29
+
30
+ def _parse(text: str) -> tuple[str, str]:
31
+ """Return (description, body)."""
32
+ description = ""
33
+ body = text
34
+ if text.startswith("---"):
35
+ end = text.find("---", 3)
36
+ if end != -1:
37
+ front = text[3:end]
38
+ body = text[end + 3:].lstrip("\n")
39
+ for line in front.splitlines():
40
+ if line.lower().strip().startswith("description:"):
41
+ description = line.split(":", 1)[1].strip()
42
+ if not description:
43
+ for line in body.splitlines():
44
+ s = line.strip().lstrip("#").strip()
45
+ if s:
46
+ description = s
47
+ break
48
+ return description, body
49
+
50
+
51
+ def load_skills(workspace: str) -> dict[str, dict]:
52
+ skills: dict[str, dict] = {}
53
+ for d in _dirs(workspace):
54
+ if not d.is_dir():
55
+ continue
56
+ for sub in sorted(d.iterdir()):
57
+ skill_md = sub / "SKILL.md"
58
+ if skill_md.is_file():
59
+ try:
60
+ desc, body = _parse(skill_md.read_text(encoding="utf-8"))
61
+ except Exception:
62
+ continue
63
+ skills[sub.name] = {"description": desc, "body": body, "path": str(sub)}
64
+ return skills
65
+
66
+
67
+ def skills_summary(skills: dict[str, dict]) -> str:
68
+ if not skills:
69
+ return ""
70
+ lines = ["You have these SKILLS available — call `use_skill(name)` to load one when relevant:"]
71
+ for name, s in skills.items():
72
+ lines.append(f"- {name}: {s['description']}")
73
+ return "\n".join(lines)