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/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)
|