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,139 @@
1
+ """Parallel Executor for Phase 5: Parallel Execution.
2
+
3
+ Implements concurrent execution of multiple specialized agents.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Callable, Optional
11
+
12
+
13
+ @dataclass
14
+ class AgentTask:
15
+ """A task to be executed by an agent."""
16
+ agent_name: str
17
+ task: str
18
+ context: dict[str, Any] = field(default_factory=dict)
19
+
20
+
21
+ @dataclass
22
+ class AgentResult:
23
+ """Result from an agent task execution."""
24
+ agent_name: str
25
+ task: str
26
+ result: Any
27
+ success: bool
28
+ error: Optional[str] = None
29
+
30
+
31
+ class ParallelExecutor:
32
+ """Executes multiple agent tasks concurrently."""
33
+
34
+ def __init__(self, max_workers: int = 4):
35
+ self.max_workers = max_workers
36
+
37
+ async def execute_tasks(
38
+ self,
39
+ tasks: list[AgentTask],
40
+ agent_handler: Callable[[str, str, dict[str, Any]], Any],
41
+ ) -> list[AgentResult]:
42
+ """Execute multiple agent tasks concurrently.
43
+
44
+ Args:
45
+ tasks: List of AgentTask objects to execute.
46
+ agent_handler: Async function that handles agent execution.
47
+ Signature: (agent_name, task, context) -> result
48
+
49
+ Returns:
50
+ List of AgentResult objects in the same order as input tasks.
51
+ """
52
+ results = []
53
+
54
+ async def execute_single(task: AgentTask) -> AgentResult:
55
+ try:
56
+ result = await agent_handler(task.agent_name, task.task, task.context)
57
+ return AgentResult(
58
+ agent_name=task.agent_name,
59
+ task=task.task,
60
+ result=result,
61
+ success=True,
62
+ )
63
+ except Exception as e:
64
+ return AgentResult(
65
+ agent_name=task.agent_name,
66
+ task=task.task,
67
+ result=None,
68
+ success=False,
69
+ error=str(e),
70
+ )
71
+
72
+ # Execute tasks concurrently with semaphore to limit concurrency
73
+ semaphore = asyncio.Semaphore(self.max_workers)
74
+
75
+ async def execute_with_semaphore(task: AgentTask) -> AgentResult:
76
+ async with semaphore:
77
+ return await execute_single(task)
78
+
79
+ # Run all tasks concurrently
80
+ results = await asyncio.gather(
81
+ *[execute_with_semaphore(task) for task in tasks],
82
+ return_exceptions=False,
83
+ )
84
+
85
+ return results
86
+
87
+ def execute_tasks_sync(
88
+ self,
89
+ tasks: list[AgentTask],
90
+ agent_handler: Callable[[str, str, dict[str, Any]], Any],
91
+ ) -> list[AgentResult]:
92
+ """Execute multiple agent tasks concurrently (synchronous wrapper).
93
+
94
+ Args:
95
+ tasks: List of AgentTask objects to execute.
96
+ agent_handler: Function that handles agent execution.
97
+ Signature: (agent_name, task, context) -> result
98
+
99
+ Returns:
100
+ List of AgentResult objects in the same order as input tasks.
101
+ """
102
+ results = []
103
+
104
+ def execute_single(task: AgentTask) -> AgentResult:
105
+ try:
106
+ result = agent_handler(task.agent_name, task.task, task.context)
107
+ return AgentResult(
108
+ agent_name=task.agent_name,
109
+ task=task.task,
110
+ result=result,
111
+ success=True,
112
+ )
113
+ except Exception as e:
114
+ return AgentResult(
115
+ agent_name=task.agent_name,
116
+ task=task.task,
117
+ result=None,
118
+ success=False,
119
+ error=str(e),
120
+ )
121
+
122
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
123
+ results = list(executor.map(execute_single, tasks))
124
+
125
+ return results
126
+
127
+ def get_summary(self, results: list[AgentResult]) -> dict[str, Any]:
128
+ """Get a summary of execution results."""
129
+ total = len(results)
130
+ successful = sum(1 for r in results if r.success)
131
+ failed = total - successful
132
+
133
+ return {
134
+ "total_tasks": total,
135
+ "successful": successful,
136
+ "failed": failed,
137
+ "success_rate": successful / total if total > 0 else 0,
138
+ "agents_used": list(set(r.agent_name for r in results)),
139
+ }
@@ -0,0 +1,128 @@
1
+ """Permission engine — allow / ask / deny rules by tool + glob.
2
+
3
+ Replaces the coarse auto-approve flags with fine-grained control, e.g.
4
+ allow: read_file(**), git_status, git_diff
5
+ ask: run_command(*), write_file(**)
6
+ deny: delete_file(**/.git/**), run_command(rm -rf*)
7
+
8
+ Rules are evaluated in order (first match wins); otherwise per-category defaults
9
+ apply. "Always allow" decisions from an approval prompt are appended and
10
+ persisted to ~/.krnl-agent/permissions.json.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import fnmatch
15
+ import json
16
+ from dataclasses import dataclass
17
+
18
+ from .settings import SETTINGS_DIR
19
+
20
+ PERMISSIONS_FILE = SETTINGS_DIR / "permissions.json"
21
+
22
+ _READ_TOOLS = {
23
+ "list_files", "read_file", "search_text", "glob",
24
+ "process_output", "process_list", "git_status", "git_diff",
25
+ }
26
+ _COMMAND_TOOLS = {"run_command", "bash_background"}
27
+
28
+
29
+ @dataclass
30
+ class Rule:
31
+ tool: str # tool name or "*"
32
+ pattern: str # glob matched against the action's target string
33
+ action: str # allow | ask | deny
34
+
35
+
36
+ def _target(tool: str, args: dict) -> str:
37
+ if tool in ("run_command", "bash_background"):
38
+ return args.get("command", "")
39
+ if tool.startswith("mcp__"):
40
+ return f"{tool} {json.dumps(args)[:200]}"
41
+ return args.get("path") or args.get("url") or args.get("query") or ""
42
+
43
+
44
+ def parse_rule(spec: str, action: str) -> Rule:
45
+ """Parse 'tool(pattern)' or 'tool' into a Rule."""
46
+ spec = spec.strip()
47
+ if spec.endswith(")") and "(" in spec:
48
+ tool, pattern = spec[:-1].split("(", 1)
49
+ return Rule(tool.strip() or "*", pattern.strip() or "*", action)
50
+ return Rule(spec or "*", "*", action)
51
+
52
+
53
+ class Permissions:
54
+ def __init__(
55
+ self,
56
+ rules: list[Rule] | None = None,
57
+ *,
58
+ default_writes: str = "ask",
59
+ default_commands: str = "ask",
60
+ default_reads: str = "allow",
61
+ ):
62
+ self.rules = rules or []
63
+ self.default_writes = default_writes
64
+ self.default_commands = default_commands
65
+ self.default_reads = default_reads
66
+
67
+ @classmethod
68
+ def from_config(cls, perms: dict, *, auto_writes: bool, auto_commands: bool) -> "Permissions":
69
+ rules: list[Rule] = []
70
+ for action in ("deny", "ask", "allow"): # deny first so it's checked first
71
+ for spec in (perms.get(action) or []):
72
+ rules.append(parse_rule(spec, action))
73
+ # persisted user "always allow" rules
74
+ rules += _load_persisted()
75
+ return cls(
76
+ rules,
77
+ default_writes="allow" if auto_writes else "ask",
78
+ default_commands="allow" if auto_commands else "ask",
79
+ )
80
+
81
+ def decide(self, tool: str, args: dict) -> str:
82
+ target = _target(tool, args)
83
+ for r in self.rules:
84
+ if r.tool in (tool, "*") and fnmatch.fnmatch(target, r.pattern):
85
+ return r.action
86
+ if tool in _READ_TOOLS:
87
+ return self.default_reads
88
+ if tool in _COMMAND_TOOLS or tool.startswith("mcp__"):
89
+ return self.default_commands
90
+ return self.default_writes
91
+
92
+ def add_always_allow(self, tool: str, args: dict) -> None:
93
+ target = _target(tool, args)
94
+ pattern = _generalize(tool, target)
95
+ rule = Rule(tool, pattern, "allow")
96
+ self.rules.insert(0, rule)
97
+ _persist(rule)
98
+
99
+
100
+ def _generalize(tool: str, target: str) -> str:
101
+ """Turn a concrete target into a sensible reusable glob."""
102
+ if tool in ("run_command", "bash_background"):
103
+ first = target.split() [0] if target.split() else target
104
+ return f"{first}*"
105
+ if "/" in target:
106
+ return target # exact path
107
+ return target or "*"
108
+
109
+
110
+ # --------------------------------------------------------------------------- #
111
+ def _load_persisted() -> list[Rule]:
112
+ try:
113
+ data = json.loads(PERMISSIONS_FILE.read_text(encoding="utf-8"))
114
+ return [Rule(r["tool"], r["pattern"], r["action"]) for r in data.get("rules", [])]
115
+ except Exception:
116
+ return []
117
+
118
+
119
+ def _persist(rule: Rule) -> None:
120
+ try:
121
+ SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
122
+ existing = []
123
+ if PERMISSIONS_FILE.exists():
124
+ existing = json.loads(PERMISSIONS_FILE.read_text(encoding="utf-8")).get("rules", [])
125
+ existing.insert(0, {"tool": rule.tool, "pattern": rule.pattern, "action": rule.action})
126
+ PERMISSIONS_FILE.write_text(json.dumps({"rules": existing}, indent=2), encoding="utf-8")
127
+ except Exception:
128
+ pass
krnl_agent/plugins.py ADDED
@@ -0,0 +1,105 @@
1
+ """Plugins — installable bundles of skills, commands, and MCP servers.
2
+
3
+ A plugin is a folder (or a `.zip` URL) containing any of:
4
+ <plugin>/skills/<name>/SKILL.md # skills
5
+ <plugin>/commands/*.md # custom slash commands
6
+ <plugin>/krnl-plugin.yaml # manifest: name, description, mcp servers
7
+
8
+ Installed plugins live in `~/.krnl-agent/plugins/<name>/`. Their skills,
9
+ commands, and MCP servers are merged into every session automatically.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import shutil
15
+ import zipfile
16
+ from pathlib import Path
17
+
18
+ import yaml
19
+
20
+ from .settings import SETTINGS_DIR
21
+
22
+ PLUGINS_DIR = SETTINGS_DIR / "plugins"
23
+
24
+
25
+ def _manifest(plugin_dir: Path) -> dict:
26
+ for name in ("krnl-plugin.yaml", "krnl-plugin.yml", "krnl-plugin.json"):
27
+ p = plugin_dir / name
28
+ if p.is_file():
29
+ try:
30
+ return yaml.safe_load(p.read_text(encoding="utf-8")) or {}
31
+ except Exception:
32
+ return {}
33
+ return {}
34
+
35
+
36
+ def list_plugins() -> list[dict]:
37
+ if not PLUGINS_DIR.is_dir():
38
+ return []
39
+ out = []
40
+ for d in sorted(PLUGINS_DIR.iterdir()):
41
+ if d.is_dir():
42
+ m = _manifest(d)
43
+ out.append({"name": d.name, "description": m.get("description", "")})
44
+ return out
45
+
46
+
47
+ def install(source: str) -> str:
48
+ """Install from a local directory or a .zip URL. Returns the plugin name."""
49
+ PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
50
+ src = Path(source)
51
+ if src.is_dir():
52
+ name = _manifest(src).get("name") or src.name
53
+ dest = PLUGINS_DIR / name
54
+ if dest.exists():
55
+ shutil.rmtree(dest)
56
+ shutil.copytree(src, dest)
57
+ return name
58
+ if source.startswith(("http://", "https://")) and source.endswith(".zip"):
59
+ import httpx
60
+
61
+ data = httpx.get(source, follow_redirects=True, timeout=60).content
62
+ name = Path(source).stem
63
+ dest = PLUGINS_DIR / name
64
+ if dest.exists():
65
+ shutil.rmtree(dest)
66
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
67
+ zf.extractall(dest)
68
+ # if the zip wrapped everything in a single top folder, flatten it
69
+ entries = [p for p in dest.iterdir()]
70
+ if len(entries) == 1 and entries[0].is_dir():
71
+ inner = entries[0]
72
+ for child in inner.iterdir():
73
+ shutil.move(str(child), str(dest / child.name))
74
+ inner.rmdir()
75
+ return _manifest(dest).get("name") or name
76
+ raise ValueError("source must be a local directory or an http(s) .zip URL")
77
+
78
+
79
+ def remove(name: str) -> bool:
80
+ dest = PLUGINS_DIR / name
81
+ if dest.is_dir():
82
+ shutil.rmtree(dest)
83
+ return True
84
+ return False
85
+
86
+
87
+ def _plugin_dirs() -> list[Path]:
88
+ if not PLUGINS_DIR.is_dir():
89
+ return []
90
+ return [d for d in PLUGINS_DIR.iterdir() if d.is_dir()]
91
+
92
+
93
+ def plugin_skill_dirs() -> list[Path]:
94
+ return [d / "skills" for d in _plugin_dirs() if (d / "skills").is_dir()]
95
+
96
+
97
+ def plugin_command_dirs() -> list[Path]:
98
+ return [d / "commands" for d in _plugin_dirs() if (d / "commands").is_dir()]
99
+
100
+
101
+ def plugin_mcp_servers() -> dict:
102
+ servers: dict = {}
103
+ for d in _plugin_dirs():
104
+ servers.update((_manifest(d).get("mcp", {}) or {}).get("servers", {}) or {})
105
+ return servers
krnl_agent/pricing.py ADDED
@@ -0,0 +1,85 @@
1
+ """Token to cost estimation.
2
+
3
+ Prices are USD per 1M tokens (input, output), matched by substring against the
4
+ model id (longest match wins), so 'gpt-4o-mini-2024-..' resolves correctly.
5
+
6
+ Two ways a model gets priced:
7
+ 1. A per-model override from config (`pricing:` in config.yaml) - authoritative.
8
+ 2. The built-in table below.
9
+ Unknown models return 0.0 (we only ever estimate, never bill); for those, the CLI
10
+ tells you to add a price under `pricing:`.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ # (input_per_1M, output_per_1M)
17
+ PRICES: dict[str, tuple[float, float]] = {
18
+ # OpenAI
19
+ "gpt-4o-mini": (0.15, 0.60),
20
+ "gpt-4o": (2.50, 10.0),
21
+ "gpt-4.1-mini": (0.40, 1.60),
22
+ "gpt-4.1-nano": (0.10, 0.40),
23
+ "gpt-4.1": (2.00, 8.00),
24
+ "gpt-5-nano": (0.05, 0.40),
25
+ "gpt-5-mini": (0.25, 2.00),
26
+ "gpt-5.5": (1.25, 10.0),
27
+ "gpt-5": (1.25, 10.0),
28
+ "o4-mini": (1.10, 4.40),
29
+ "o3-mini": (1.10, 4.40),
30
+ "o3": (2.00, 8.00),
31
+ "o1-mini": (1.10, 4.40),
32
+ "o1": (15.0, 60.0),
33
+ # Anthropic
34
+ "claude-3-5-haiku": (0.80, 4.00),
35
+ "claude-haiku": (1.00, 5.00),
36
+ "claude-3-5-sonnet": (3.00, 15.0),
37
+ "claude-sonnet": (3.00, 15.0),
38
+ "claude-opus": (15.0, 75.0),
39
+ # Google
40
+ "gemini-2.5-pro": (1.25, 10.0),
41
+ "gemini-2.5-flash": (0.30, 2.50),
42
+ "gemini-2.0-flash": (0.10, 0.40),
43
+ "gemini-1.5-flash": (0.075, 0.30),
44
+ "gemini-1.5-pro": (1.25, 5.00),
45
+ "gemini": (0.10, 0.40),
46
+ # others
47
+ "deepseek": (0.27, 1.10),
48
+ "grok": (2.00, 10.0),
49
+ "mistral-large": (2.00, 6.00),
50
+ "mistral": (0.20, 0.60),
51
+ "llama": (0.0, 0.0),
52
+ "qwen": (0.0, 0.0),
53
+ }
54
+
55
+
56
+ def _rate_from(table: dict, model: str) -> Optional[tuple[float, float]]:
57
+ m = (model or "").lower()
58
+ for key in sorted(table, key=len, reverse=True): # longest (most specific) wins
59
+ if key.lower() in m:
60
+ v = table[key]
61
+ if isinstance(v, dict):
62
+ return float(v.get("input", 0)), float(v.get("output", 0))
63
+ return float(v[0]), float(v[1])
64
+ return None
65
+
66
+
67
+ def rate_for(model: str, overrides: Optional[dict] = None) -> Optional[tuple[float, float]]:
68
+ """Return (input_per_1M, output_per_1M) or None if the model is unpriced."""
69
+ if overrides:
70
+ r = _rate_from(overrides, model)
71
+ if r is not None:
72
+ return r
73
+ return _rate_from(PRICES, model)
74
+
75
+
76
+ def is_priced(model: str, overrides: Optional[dict] = None) -> bool:
77
+ return rate_for(model, overrides) is not None
78
+
79
+
80
+ def cost_for(model: str, prompt_tokens: int, completion_tokens: int,
81
+ overrides: Optional[dict] = None) -> float:
82
+ rate = rate_for(model, overrides)
83
+ if rate is None:
84
+ return 0.0
85
+ return prompt_tokens / 1_000_000 * rate[0] + completion_tokens / 1_000_000 * rate[1]
krnl_agent/prompts.py ADDED
@@ -0,0 +1,60 @@
1
+ """System prompt for the coding agent.
2
+
3
+ The prompt is deliberately tight and concrete: clear tool-use rules and a
4
+ plan-first discipline are what let a *small* model behave well on focused tasks.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ def system_prompt(workspace_path: str, file_tree: str) -> str:
10
+ return f"""You are Krnl Agent, an expert pair-programmer working INSIDE a user's project.
11
+ You operate by calling tools. You can read files, search, edit/create/delete
12
+ files, and run shell commands — always scoped to the workspace.
13
+
14
+ Workspace root: {workspace_path}
15
+
16
+ Operating rules:
17
+ 1. PLAN FIRST. For any non-trivial task, briefly state a short plan (2-5 steps)
18
+ in plain text before acting. Keep it terse.
19
+ 2. GROUND YOURSELF. Never guess file contents. Use `read_file` and `search_text`
20
+ to learn the real code before editing. Prefer small, targeted reads.
21
+ 3. EDIT SURGICALLY. Use `edit_file` (exact search/replace) for changes to
22
+ existing files — it is safer and cheaper than rewriting. Use `write_file`
23
+ only for whole new files or full rewrites. `old_string` must match the file
24
+ EXACTLY, including indentation, and be unique enough to target one spot.
25
+ 4. ONE STEP AT A TIME. Make one logical change, observe the tool result, then
26
+ continue. Do not assume an edit applied — the result tells you.
27
+ 5. RESPECT APPROVAL. Edits and commands may be rejected by the user. If a tool
28
+ result says the action was rejected, adapt — do not retry the identical action.
29
+ 6. VERIFY when reasonable (run tests, run the file, re-read the edited region),
30
+ but don't run long or destructive commands without good reason.
31
+ 7. STOP when done. When the task is complete, reply with a concise final summary
32
+ (what changed and why) and DO NOT call any more tools. That ends the turn.
33
+
34
+ Extra capabilities:
35
+ - `todo_write`: for any task with 3+ steps, keep a checklist updated — exactly one
36
+ item `in_progress` at a time. It keeps you organized and the user informed.
37
+ WARNING: After updating the checklist, you MUST immediately call a real tool
38
+ (write_file, edit_file, run_command, etc.). Never call todo_write twice in a row
39
+ without doing real work in between.
40
+ - `spawn_agent`: delegate a focused, independent sub-task to a sub-agent (e.g. a
41
+ separate investigation). It returns a summary; you stay the orchestrator.
42
+ - `multi_edit`: several edits to one file atomically. `glob`: find files by pattern.
43
+ - `web_search` / `web_fetch`: look up current information when needed.
44
+ - `bash_background` + `process_output`/`process_kill`/`process_list`: long-running
45
+ processes like dev servers (never block on them with run_command)
46
+ - `git_status` / `git_diff` / `git_commit`: inspect and commit changes.
47
+ - `mcp__*` tools (if present) come from connected MCP servers — use them like any tool.
48
+
49
+ Large file strategy: When creating files with 100+ lines (CSS, JS, HTML, etc.):
50
+ 1. Use write_file for the first portion (e.g. first 150 lines).
51
+ 2. Use edit_file to append remaining sections one at a time.
52
+ 3. Never attempt to write more content than can fit in a single tool call output.
53
+ If unsure, write smaller chunks and build up incrementally.
54
+
55
+ Be concise. Favor correctness over cleverness. Match the project's existing
56
+ style and conventions.
57
+
58
+ Current files (truncated):
59
+ {file_tree}
60
+ """
krnl_agent/repomap.py ADDED
@@ -0,0 +1,133 @@
1
+ """Token-friendly semantic repo map.
2
+
3
+ Instead of reading whole files, the agent can pull a compact *outline* of the
4
+ repository: per-file lists of the top-level symbols (classes, functions, methods,
5
+ exported consts) with their line numbers. This is language-agnostic - it uses
6
+ lightweight regex signatures, so there are no heavy parser dependencies - and the
7
+ output is bounded so it always fits in a small slice of the context window.
8
+
9
+ The agent reads the map first to locate code, then `read_file` only the specific
10
+ ranges it needs. That turns "read 40 files to find the auth handler" into "read
11
+ one outline, then one function".
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ from pathlib import Path
18
+
19
+ # Per-language symbol signatures: (compiled regex, kind). Group 1 is the name.
20
+ _LANG_PATTERNS: dict[str, list[tuple[re.Pattern, str]]] = {
21
+ "py": [
22
+ (re.compile(r"^\s*class\s+([A-Za-z_]\w*)"), "class"),
23
+ (re.compile(r"^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)"), "def"),
24
+ ],
25
+ "js": [
26
+ (re.compile(r"^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)"), "class"),
27
+ (re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)"), "function"),
28
+ (re.compile(r"^\s*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(?[^=]*=>"), "const-fn"),
29
+ ],
30
+ "ts": [
31
+ (re.compile(r"^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)"), "class"),
32
+ (re.compile(r"^\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)"), "interface"),
33
+ (re.compile(r"^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)"), "type"),
34
+ (re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)"), "function"),
35
+ (re.compile(r"^\s*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*[:=]"), "const"),
36
+ ],
37
+ "go": [
38
+ (re.compile(r"^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)"), "func"),
39
+ (re.compile(r"^\s*type\s+([A-Za-z_]\w*)\s+(?:struct|interface)"), "type"),
40
+ ],
41
+ "rs": [
42
+ (re.compile(r"^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)"), "fn"),
43
+ (re.compile(r"^\s*(?:pub\s+)?(?:struct|enum|trait)\s+([A-Za-z_]\w*)"), "type"),
44
+ ],
45
+ "java": [
46
+ (re.compile(r"^\s*(?:public|private|protected).*\bclass\s+([A-Za-z_]\w*)"), "class"),
47
+ (re.compile(r"^\s*(?:public|private|protected|static).*\b([A-Za-z_]\w*)\s*\([^;{]*\)\s*\{"), "method"),
48
+ ],
49
+ "rb": [
50
+ (re.compile(r"^\s*class\s+([A-Za-z_]\w*)"), "class"),
51
+ (re.compile(r"^\s*def\s+([A-Za-z_][\w?!]*)"), "def"),
52
+ ],
53
+ }
54
+ # File extension -> language key.
55
+ _EXT_LANG = {
56
+ ".py": "py", ".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js",
57
+ ".ts": "ts", ".tsx": "ts", ".go": "go", ".rs": "rs", ".java": "java",
58
+ ".rb": "rb",
59
+ }
60
+
61
+ _MAX_FILES = 400
62
+ _MAX_SYMBOLS_PER_FILE = 40
63
+ _MAX_TOTAL_LINES = 1500
64
+
65
+
66
+ def _symbols_in(path: Path, lang: str) -> list[tuple[int, str, str]]:
67
+ """Return (line_no, kind, name) for symbols in one file."""
68
+ pats = _LANG_PATTERNS.get(lang, [])
69
+ out: list[tuple[int, str, str]] = []
70
+ try:
71
+ with open(path, encoding="utf-8", errors="ignore") as fh:
72
+ for i, line in enumerate(fh, 1):
73
+ if len(line) > 400:
74
+ continue
75
+ for rx, kind in pats:
76
+ m = rx.match(line)
77
+ if m:
78
+ out.append((i, kind, m.group(1)))
79
+ break
80
+ if len(out) >= _MAX_SYMBOLS_PER_FILE:
81
+ out.append((i, "…", "(more symbols truncated)"))
82
+ break
83
+ except Exception:
84
+ return out
85
+ return out
86
+
87
+
88
+ def build_map(ctx, path: str = ".", lang_filter: str | None = None) -> str:
89
+ """Build a compact outline of the workspace (or a sub-path).
90
+
91
+ `ctx` is a tools.ToolContext (used for sandboxing + ignore rules).
92
+ """
93
+ base = ctx.resolve(path)
94
+ if not base.exists():
95
+ return f"Path not found: {path}"
96
+ if base.is_file():
97
+ files = [base]
98
+ else:
99
+ files = []
100
+ for root, dirs, names in os.walk(base):
101
+ rroot = ctx.rel(Path(root))
102
+ dirs[:] = [d for d in sorted(dirs)
103
+ if not ctx.is_ignored(f"{rroot}/{d}".lstrip("./"))]
104
+ for n in sorted(names):
105
+ fp = Path(root) / n
106
+ if fp.suffix.lower() in _EXT_LANG and not ctx.is_ignored(ctx.rel(fp)):
107
+ files.append(fp)
108
+ if len(files) >= _MAX_FILES:
109
+ break
110
+
111
+ lines: list[str] = []
112
+ total = 0
113
+ shown = 0
114
+ for fp in sorted(files):
115
+ lang = _EXT_LANG.get(fp.suffix.lower())
116
+ if not lang or (lang_filter and lang != lang_filter):
117
+ continue
118
+ syms = _symbols_in(fp, lang)
119
+ if not syms:
120
+ continue
121
+ lines.append(f"\n{ctx.rel(fp)}")
122
+ for ln, kind, name in syms:
123
+ lines.append(f" {ln:>5} {kind} {name}")
124
+ total += 1
125
+ shown += 1
126
+ if total >= _MAX_TOTAL_LINES:
127
+ lines.append("\n… (map truncated; narrow with path= or lang=)")
128
+ break
129
+
130
+ if not lines:
131
+ return "(no recognizable source symbols found)"
132
+ header = f"# Repo map — {shown} file(s), {total} symbols (line kind name)"
133
+ return header + "\n" + "\n".join(lines)