codenaut 0.1.0__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 (63) hide show
  1. agent/__init__.py +1 -0
  2. agent/agent.py +236 -0
  3. agent/events.py +145 -0
  4. agent/persistence.py +123 -0
  5. agent/session.py +99 -0
  6. client/__init__.py +1 -0
  7. client/llm_client.py +266 -0
  8. client/response.py +88 -0
  9. codenaut-0.1.0.dist-info/METADATA +199 -0
  10. codenaut-0.1.0.dist-info/RECORD +63 -0
  11. codenaut-0.1.0.dist-info/WHEEL +5 -0
  12. codenaut-0.1.0.dist-info/entry_points.txt +3 -0
  13. codenaut-0.1.0.dist-info/licenses/LICENSE +21 -0
  14. codenaut-0.1.0.dist-info/top_level.txt +12 -0
  15. config/__init__.py +1 -0
  16. config/config.py +173 -0
  17. config/loader.py +173 -0
  18. context/__init__.py +1 -0
  19. context/compaction.py +96 -0
  20. context/loop_detector.py +49 -0
  21. context/manager.py +257 -0
  22. hooks/__init__.py +1 -0
  23. hooks/hook_system.py +140 -0
  24. main.py +422 -0
  25. prompts/__init__.py +1 -0
  26. prompts/system.py +380 -0
  27. safety/__init__.py +1 -0
  28. safety/approval.py +157 -0
  29. skills/__init__.py +9 -0
  30. skills/discovery.py +97 -0
  31. skills/models.py +14 -0
  32. skills/registry.py +56 -0
  33. tools/__init__.py +1 -0
  34. tools/base.py +200 -0
  35. tools/builtin/__init__.py +67 -0
  36. tools/builtin/apply_patch.py +388 -0
  37. tools/builtin/edit_file.py +211 -0
  38. tools/builtin/glob.py +80 -0
  39. tools/builtin/grep.py +117 -0
  40. tools/builtin/list_dir.py +60 -0
  41. tools/builtin/memory.py +115 -0
  42. tools/builtin/read_file.py +133 -0
  43. tools/builtin/shell.py +160 -0
  44. tools/builtin/tasks.py +336 -0
  45. tools/builtin/web_fetch.py +55 -0
  46. tools/builtin/web_search.py +60 -0
  47. tools/builtin/write_file.py +111 -0
  48. tools/discovery.py +70 -0
  49. tools/mcp/__init__.py +1 -0
  50. tools/mcp/client.py +127 -0
  51. tools/mcp/mcp_manager.py +83 -0
  52. tools/mcp/mcp_tool.py +51 -0
  53. tools/registry.py +156 -0
  54. tools/subagents.py +148 -0
  55. ui/__init__.py +12 -0
  56. ui/completer.py +160 -0
  57. ui/prompt.py +65 -0
  58. ui/tui.py +669 -0
  59. utils/__init__.py +1 -0
  60. utils/errors.py +49 -0
  61. utils/file_tags.py +83 -0
  62. utils/paths.py +40 -0
  63. utils/text.py +79 -0
context/manager.py ADDED
@@ -0,0 +1,257 @@
1
+ from datetime import datetime
2
+ import json
3
+ from typing import Any
4
+ from client.response import TokenUsage
5
+ from config.config import Config
6
+ from prompts.system import get_system_prompt
7
+ from skills.models import SkillDefinition
8
+ from dataclasses import dataclass, field
9
+
10
+ from tools.base import Tool
11
+ from utils.text import count_tokens
12
+
13
+
14
+ @dataclass
15
+ class MessageItem:
16
+ role: str
17
+ content: str
18
+ tool_call_id: str | None = None
19
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
20
+ token_count: int | None = None
21
+ pruned_at: datetime | None = None
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ result: dict[str, Any] = {"role": self.role}
25
+
26
+ if self.tool_call_id:
27
+ result["tool_call_id"] = self.tool_call_id
28
+
29
+ if self.tool_calls:
30
+ result["tool_calls"] = self.tool_calls
31
+
32
+ # `content` is required by the API for tool messages and for assistant
33
+ # messages that carry no tool_calls. A quiet shell command returns "",
34
+ # and dropping the field here produces a message the API rejects, which
35
+ # then poisons every subsequent request in the session.
36
+ requires_content = self.role == "tool" or (
37
+ self.role == "assistant" and not self.tool_calls
38
+ )
39
+ if self.content or requires_content:
40
+ result["content"] = self.content
41
+
42
+ return result
43
+
44
+
45
+ class ContextManager:
46
+ PRUNE_PROTECT_TOKENS = 40_000
47
+ PRUNE_MINIMUM_TOKENS = 20_000
48
+
49
+ def __init__(
50
+ self,
51
+ config: Config,
52
+ user_memory: str | None,
53
+ tools: list[Tool] | None,
54
+ skills: list[SkillDefinition] | None,
55
+ ) -> None:
56
+ self.config = config
57
+ self._user_memory = user_memory
58
+ self._tools = tools
59
+ self._skills = skills or []
60
+ self._model_name = self.config.model_name
61
+ self._messages: list[MessageItem] = []
62
+ self._latest_usage = TokenUsage()
63
+ self.total_usage = TokenUsage()
64
+
65
+ @property
66
+ def message_count(self) -> int:
67
+ return len(self._messages)
68
+
69
+ def add_user_message(self, content: str) -> None:
70
+ item = MessageItem(
71
+ role="user",
72
+ content=content,
73
+ token_count=count_tokens(
74
+ content,
75
+ self._model_name,
76
+ ),
77
+ )
78
+
79
+ self._messages.append(item)
80
+
81
+ def add_assistant_message(
82
+ self,
83
+ content: str,
84
+ tool_calls: list[dict[str, any]] | None = None,
85
+ ) -> None:
86
+ item = MessageItem(
87
+ role="assistant",
88
+ content=content or "",
89
+ token_count=count_tokens(
90
+ content or "",
91
+ self._model_name,
92
+ ),
93
+ tool_calls=tool_calls or [],
94
+ )
95
+
96
+ self._messages.append(item)
97
+
98
+ def add_tool_result(self, tool_call_id: str, content: str) -> None:
99
+ item = MessageItem(
100
+ role="tool",
101
+ content=content,
102
+ tool_call_id=tool_call_id,
103
+ token_count=count_tokens(content, self._model_name),
104
+ )
105
+
106
+ self._messages.append(item)
107
+
108
+ def get_messages(self) -> list[dict[str, Any]]:
109
+ messages = []
110
+
111
+ system_prompt = self._build_system_prompt()
112
+ if system_prompt:
113
+ messages.append(
114
+ {
115
+ "role": "system",
116
+ "content": system_prompt,
117
+ }
118
+ )
119
+
120
+ for item in self._messages:
121
+ messages.append(item.to_dict())
122
+
123
+ return messages
124
+
125
+ def _build_system_prompt(self) -> str:
126
+ active_skills = self._get_active_skills()
127
+ return get_system_prompt(
128
+ self.config,
129
+ self._user_memory,
130
+ self._tools,
131
+ available_skills=self._skills,
132
+ active_skills=active_skills,
133
+ )
134
+
135
+ def _get_active_skills(self) -> list[SkillDefinition]:
136
+ if not self._skills or not self.config.skills_enabled:
137
+ return []
138
+
139
+ allowed = {name.casefold() for name in self.config.allowed_skills or []}
140
+ always_loaded = {name.casefold() for name in self.config.always_loaded_skills}
141
+ query_parts = [
142
+ message.content
143
+ for message in self._messages
144
+ if message.role == "user" and message.content
145
+ ]
146
+ normalized_query = " ".join(" ".join(query_parts).casefold().split())
147
+ active_skills: list[SkillDefinition] = []
148
+
149
+ for skill in self._skills:
150
+ skill_name = skill.name.casefold()
151
+ if allowed and skill_name not in allowed:
152
+ continue
153
+
154
+ trigger_match = any(
155
+ trigger.casefold() in normalized_query for trigger in skill.triggers
156
+ )
157
+ name_match = skill_name in normalized_query if normalized_query else False
158
+
159
+ if (
160
+ skill.always_include
161
+ or skill_name in always_loaded
162
+ or trigger_match
163
+ or name_match
164
+ ):
165
+ active_skills.append(skill)
166
+
167
+ return active_skills
168
+
169
+ def needs_compression(self) -> bool:
170
+ context_limit = self.config.model.context_window
171
+ current_tokens = self.estimate_context_tokens()
172
+
173
+ return current_tokens > (context_limit * 0.8)
174
+
175
+ def estimate_context_tokens(self) -> int:
176
+ total_tokens = 0
177
+
178
+ system_prompt = self._build_system_prompt()
179
+ if system_prompt:
180
+ total_tokens += count_tokens(system_prompt, self._model_name)
181
+
182
+ for item in self._messages:
183
+ total_tokens += self._estimate_message_tokens(item)
184
+
185
+ return total_tokens
186
+
187
+ def _estimate_message_tokens(self, item: MessageItem) -> int:
188
+ payload = item.to_dict()
189
+ serialized = json.dumps(payload, ensure_ascii=False)
190
+ return count_tokens(serialized, self._model_name)
191
+
192
+ def set_latest_usage(self, usage: TokenUsage):
193
+ self._latest_usage = usage
194
+
195
+ def add_usage(self, usage: TokenUsage):
196
+ self.total_usage += usage
197
+
198
+ def replace_with_summary(self, summary: str) -> None:
199
+ continuation_content = f"""# Context Restoration (Previous Session Compacted)
200
+
201
+ The previous conversation was compacted due to context length limits. Below is a detailed summary of the work done so far.
202
+
203
+ Actions listed under "COMPLETED ACTIONS" are already done. Do not repeat them.
204
+
205
+ ---
206
+
207
+ {summary}
208
+
209
+ ---
210
+
211
+ Resume work from where we left off. Focus only on the remaining tasks and treat this summary as authoritative context."""
212
+
213
+ summary_item = MessageItem(
214
+ role="user",
215
+ content=continuation_content,
216
+ token_count=count_tokens(continuation_content, self._model_name),
217
+ )
218
+ # Commit only after the replacement message is fully built.
219
+ self._messages = [summary_item]
220
+
221
+ def prune_tool_outputs(self) -> int:
222
+ user_message_count = sum(1 for msg in self._messages if msg.role == "user")
223
+
224
+ if user_message_count < 2:
225
+ return 0
226
+
227
+ total_tokens = 0
228
+ pruned_tokens = 0
229
+ to_prune: list[MessageItem] = []
230
+
231
+ for msg in reversed(self._messages):
232
+ if msg.role == "tool" and msg.tool_call_id:
233
+ if msg.pruned_at:
234
+ break
235
+
236
+ tokens = msg.token_count or count_tokens(msg.content, self._model_name)
237
+ total_tokens += tokens
238
+
239
+ if total_tokens > self.PRUNE_PROTECT_TOKENS:
240
+ pruned_tokens += tokens
241
+ to_prune.append(msg)
242
+
243
+ if pruned_tokens < self.PRUNE_MINIMUM_TOKENS:
244
+ return 0
245
+
246
+ pruned_count = 0
247
+
248
+ for msg in to_prune:
249
+ msg.content = "[Old tool result content cleared]"
250
+ msg.token_count = count_tokens(msg.content, self._model_name)
251
+ msg.pruned_at = datetime.now()
252
+ pruned_count += 1
253
+
254
+ return pruned_count
255
+
256
+ def clear(self) -> None:
257
+ self._messages = []
hooks/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Lifecycle hook support."""
hooks/hook_system.py ADDED
@@ -0,0 +1,140 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import signal
5
+ import sys
6
+ import tempfile
7
+ from typing import Any
8
+ from config.config import Config, HookConfig, HookTrigger
9
+ from tools.base import ToolResult
10
+
11
+
12
+ class HookSystem:
13
+ def __init__(self, config: Config):
14
+ self.config = config
15
+ self.hooks: list[HookConfig] = []
16
+ if self.config.hooks_enabled:
17
+ self.hooks = [hook for hook in self.config.hooks if hook.enabled]
18
+
19
+ async def _run_hook(self, hook: HookConfig, env: dict[str, str]) -> None:
20
+ try:
21
+ if hook.command:
22
+ await self._run_command(hook.command, hook.timeout_sec, env)
23
+ else:
24
+ with tempfile.NamedTemporaryFile(
25
+ mode="w", suffix=".sh", delete=False
26
+ ) as f:
27
+ f.write("#!/bin/bash\n")
28
+ f.write(hook.script)
29
+ script_path = f.name
30
+ try:
31
+ os.chmod(script_path, 0o755)
32
+ await self._run_command(script_path, hook.timeout_sec, env)
33
+ finally:
34
+ os.unlink(script_path)
35
+ except Exception as e:
36
+ print(e)
37
+
38
+ async def _run_command(
39
+ self,
40
+ command: str,
41
+ timeout: float,
42
+ env: dict[str, str],
43
+ ) -> None:
44
+ process = await asyncio.create_subprocess_shell(
45
+ command,
46
+ stdout=asyncio.subprocess.PIPE,
47
+ stderr=asyncio.subprocess.PIPE,
48
+ cwd=self.config.cwd,
49
+ env=env,
50
+ start_new_session=True,
51
+ )
52
+
53
+ try:
54
+ await asyncio.wait_for(process.communicate(), timeout=timeout)
55
+ except asyncio.TimeoutError:
56
+ if sys.platform != "win32":
57
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
58
+ else:
59
+ process.kill()
60
+ await process.wait()
61
+
62
+ def _build_env(
63
+ self,
64
+ trigger: HookTrigger,
65
+ tool_name: str | None = None,
66
+ user_message: str | None = None,
67
+ error: Exception | None = None,
68
+ ) -> dict[str, str]:
69
+ env = os.environ.copy()
70
+ env["AI_AGENT_TRIGGER"] = trigger.value
71
+ env["AI_AGENT_CWD"] = str(self.config.cwd)
72
+
73
+ if tool_name:
74
+ env["AI_AGENT_TOOL_NAME"] = tool_name
75
+
76
+ if user_message:
77
+ env["AI_AGENT_USER_MESSAGE"] = user_message
78
+
79
+ if error:
80
+ env["AI_AGENT_ERROR"] = str(error)
81
+
82
+ return env
83
+
84
+ async def trigger_before_agent(self, user_message: str) -> None:
85
+ env = self._build_env(
86
+ HookTrigger.BEFORE_AGENT,
87
+ user_message=user_message,
88
+ )
89
+
90
+ for hook in self.hooks:
91
+ if hook.trigger == HookTrigger.BEFORE_AGENT:
92
+ await self._run_hook(hook, env)
93
+
94
+ async def trigger_after_agent(
95
+ self,
96
+ user_message: str,
97
+ agent_response: str,
98
+ ) -> None:
99
+ env = self._build_env(
100
+ HookTrigger.AFTER_AGENT,
101
+ user_message=user_message,
102
+ )
103
+ env["AI_AGENT_RESPONSE"] = agent_response
104
+
105
+ for hook in self.hooks:
106
+ if hook.trigger == HookTrigger.AFTER_AGENT:
107
+ await self._run_hook(hook, env)
108
+
109
+ async def trigger_before_tool(
110
+ self,
111
+ tool_name: str,
112
+ tool_params: dict[str, Any],
113
+ ) -> None:
114
+ env = self._build_env(HookTrigger.BEFORE_TOOL, tool_name=tool_name)
115
+ env["AI_AGENT_TOOL_PARAMS"] = json.dumps(tool_params)
116
+
117
+ for hook in self.hooks:
118
+ if hook.trigger == HookTrigger.BEFORE_TOOL:
119
+ await self._run_hook(hook, env)
120
+
121
+ async def trigger_after_tool(
122
+ self,
123
+ tool_name: str,
124
+ tool_params: dict[str, Any],
125
+ tool_result: ToolResult,
126
+ ) -> None:
127
+ env = self._build_env(HookTrigger.AFTER_TOOL, tool_name=tool_name)
128
+ env["AI_AGENT_TOOL_PARAMS"] = json.dumps(tool_params)
129
+ env["AI_AGENT_TOOL_RESULT"] = tool_result.to_model_output()
130
+
131
+ for hook in self.hooks:
132
+ if hook.trigger == HookTrigger.AFTER_TOOL:
133
+ await self._run_hook(hook, env)
134
+
135
+ async def trigger_on_error(self, error: Exception) -> None:
136
+ env = self._build_env(HookTrigger.ON_ERROR, error=error)
137
+
138
+ for hook in self.hooks:
139
+ if hook.trigger == HookTrigger.ON_ERROR:
140
+ await self._run_hook(hook, env)