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
config/config.py ADDED
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field, model_validator
9
+
10
+
11
+ class ModelConfig(BaseModel):
12
+ name: str = "openai/gpt-oss-120b"
13
+ temperature: float = Field(default=1, ge=0.0, le=2.0)
14
+ context_window: int = 512_000
15
+
16
+
17
+ class ShellEnvironmentPolicy(BaseModel):
18
+ ignore_default_excludes: bool = False
19
+ exclude_patterns: list[str] = Field(
20
+ default_factory=lambda: ["*KEY*", "*TOKEN*", "*SECRET*"]
21
+ )
22
+ set_vars: dict[str, str] = Field(default_factory=dict)
23
+
24
+
25
+ class MCPServerConfig(BaseModel):
26
+ enabled: bool = True
27
+ startup_timeout_sec: float = 10
28
+
29
+ command: str | None = None
30
+ args: list[str] = Field(default_factory=list)
31
+ env: dict[str, str] = Field(default_factory=dict)
32
+ cwd: Path | None = None
33
+ url: str | None = None
34
+
35
+ @model_validator(mode="after")
36
+ def validate_transport(self) -> MCPServerConfig:
37
+ has_command = self.command is not None
38
+ has_url = self.url is not None
39
+
40
+ if not has_command and not has_url:
41
+ raise ValueError(
42
+ "MCP Server must have either 'command' (stdio) or 'url' (http/sse)"
43
+ )
44
+
45
+ if has_command and has_url:
46
+ raise ValueError(
47
+ "MCP Server cannot have both 'command' (stdio) and 'url' (http/sse)"
48
+ )
49
+
50
+ return self
51
+
52
+
53
+ class ApprovalPolicy(str, Enum):
54
+ ON_REQUEST = "on-request"
55
+ ON_FAILURE = "on-failure"
56
+ AUTO = "auto"
57
+ AUTO_EDIT = "auto-editt"
58
+ NEVER = "never"
59
+ YOLO = "yolo"
60
+
61
+
62
+ class HookTrigger(str, Enum):
63
+ BEFORE_AGENT = "before_agent"
64
+ AFTER_AGENT = "after_agent"
65
+ BEFORE_TOOL = "before_tool"
66
+ AFTER_TOOL = "after_tool"
67
+ ON_ERROR = "on_error"
68
+
69
+
70
+ class HookConfig(BaseModel):
71
+ name: str
72
+ trigger: HookTrigger
73
+ command: str | None = None
74
+ script: str | None = None
75
+ timeout_sec: float = 30
76
+ enabled: bool = True
77
+
78
+ @model_validator(mode="after")
79
+ def validate_hook(self) -> HookConfig:
80
+ if not self.command and not self.script:
81
+ raise ValueError("Hook must either have 'command' or 'script'")
82
+ return self
83
+
84
+
85
+ class Config(BaseModel):
86
+ model: ModelConfig = Field(default_factory=ModelConfig)
87
+ cwd: Path = Field(default_factory=Path.cwd)
88
+ shell_environment: ShellEnvironmentPolicy = Field(
89
+ default_factory=ShellEnvironmentPolicy
90
+ )
91
+ hooks_enabled: bool = True
92
+ hooks: list[HookConfig] = Field(default_factory=list)
93
+ approval: ApprovalPolicy = ApprovalPolicy.ON_REQUEST
94
+ max_turns: int = 100
95
+ mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
96
+ api_key_value: str | None = Field(
97
+ default=None,
98
+ alias="api_key",
99
+ exclude=True,
100
+ repr=False,
101
+ )
102
+ base_url_value: str | None = Field(
103
+ default=None,
104
+ alias="base_url",
105
+ exclude=True,
106
+ repr=False,
107
+ )
108
+
109
+ allowed_tools: list[str] | None = Field(
110
+ None,
111
+ description="If set, only these tools will be available to the agent",
112
+ )
113
+ skills_enabled: bool = True
114
+ extra_skill_dirs: list[Path] = Field(default_factory=list)
115
+ allowed_skills: list[str] | None = Field(
116
+ None,
117
+ description="If set, only these skills will be available to the agent",
118
+ )
119
+ always_loaded_skills: list[str] = Field(default_factory=list)
120
+
121
+ developer_instructions: str | None = None
122
+ user_instructions: str | None = None
123
+
124
+ debug: bool = False
125
+
126
+ @property
127
+ def api_key(self) -> str | None:
128
+ return self.api_key_value or os.environ.get("API_KEY")
129
+
130
+ @property
131
+ def base_url(self) -> str | None:
132
+ return self.base_url_value or os.environ.get("BASE_URL")
133
+
134
+ @property
135
+ def model_name(self) -> str:
136
+ return self.model.name
137
+
138
+ @model_name.setter
139
+ def model_name(self, value: str) -> None:
140
+ self.model.name = value
141
+
142
+ @property
143
+ def temperature(self) -> float:
144
+ return self.model.temperature
145
+
146
+ @temperature.setter
147
+ def temperature(self, value: float) -> None:
148
+ self.model.temperature = value
149
+
150
+ def set_credentials(self, api_key: str, base_url: str | None = None) -> None:
151
+ self.api_key_value = api_key
152
+ self.base_url_value = base_url or None
153
+
154
+ def validate(self) -> list[str]:
155
+ errors: list[str] = []
156
+
157
+ if not self.api_key:
158
+ errors.append(
159
+ "No API key found. Run `code-it --configure` or set API_KEY."
160
+ )
161
+
162
+ if not self.cwd.exists():
163
+ errors.append(f"Working directory does not exist: {self.cwd}")
164
+
165
+ return errors
166
+
167
+ def to_dict(self) -> dict[str, Any]:
168
+ data = self.model_dump(mode="json", by_alias=True)
169
+ if self.api_key_value:
170
+ data["api_key"] = self.api_key_value
171
+ if self.base_url_value:
172
+ data["base_url"] = self.base_url_value
173
+ return data
config/loader.py ADDED
@@ -0,0 +1,173 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+ import logging
4
+
5
+ from dotenv import load_dotenv
6
+ from platformdirs import user_config_dir, user_data_dir
7
+
8
+ try:
9
+ import tomllib as tomli
10
+ except ModuleNotFoundError: # pragma: no cover
11
+ import tomli
12
+
13
+ from config.config import Config
14
+ from utils.errors import ConfigError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ APP_NAME = "code-it"
19
+ CONFIG_FILE_NAME = "config.toml"
20
+ AGENT_MD_FILE = "AGENT.MD"
21
+ PROJECT_CONFIG_DIR_NAME = ".ai-agent"
22
+
23
+
24
+ def get_config_dir() -> Path:
25
+ return Path(user_config_dir(APP_NAME))
26
+
27
+
28
+ def get_data_dir() -> Path:
29
+ return Path(user_data_dir(APP_NAME))
30
+
31
+
32
+ def ensure_config_dir() -> Path:
33
+ config_dir = get_config_dir()
34
+ config_dir.mkdir(parents=True, exist_ok=True)
35
+ return config_dir
36
+
37
+
38
+ def get_system_config_path() -> Path:
39
+ return get_config_dir() / CONFIG_FILE_NAME
40
+
41
+
42
+ def _parse_toml(path: Path) -> dict[str, Any]:
43
+ try:
44
+ with open(path, "rb") as file_obj:
45
+ data = tomli.load(file_obj)
46
+ return data if isinstance(data, dict) else {}
47
+ except tomli.TOMLDecodeError as error:
48
+ raise ConfigError(
49
+ f"Invalid TOML in {path}: {error}",
50
+ config_file=str(path),
51
+ ) from error
52
+ except (OSError, IOError) as error:
53
+ raise ConfigError(
54
+ f"Failed to read config file {path}: {error}",
55
+ config_file=str(path),
56
+ ) from error
57
+
58
+
59
+ def _get_project_config(cwd: Path) -> Path | None:
60
+ agent_dir = cwd.resolve() / PROJECT_CONFIG_DIR_NAME
61
+ config_file = agent_dir / CONFIG_FILE_NAME
62
+ if config_file.is_file():
63
+ return config_file
64
+ return None
65
+
66
+
67
+ def _get_agent_md_files(cwd: Path) -> str | None:
68
+ agent_md_file = cwd.resolve() / AGENT_MD_FILE
69
+ if agent_md_file.is_file():
70
+ return agent_md_file.read_text(encoding="utf-8")
71
+ return None
72
+
73
+
74
+ def _merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
75
+ result = base.copy()
76
+ for key, value in override.items():
77
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
78
+ result[key] = _merge_dicts(result[key], value)
79
+ else:
80
+ result[key] = value
81
+ return result
82
+
83
+
84
+ def _format_toml_value(value: Any) -> str:
85
+ if isinstance(value, bool):
86
+ return "true" if value else "false"
87
+ if isinstance(value, (int, float)):
88
+ return str(value)
89
+ if isinstance(value, str):
90
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
91
+ return f'"{escaped}"'
92
+ if isinstance(value, list):
93
+ return "[" + ", ".join(_format_toml_value(item) for item in value) + "]"
94
+ raise TypeError(f"Unsupported TOML value: {type(value)!r}")
95
+
96
+
97
+ def _dumps_toml(data: dict[str, Any]) -> str:
98
+ lines: list[str] = []
99
+
100
+ scalars = {
101
+ key: value
102
+ for key, value in data.items()
103
+ if not isinstance(value, dict) and value is not None
104
+ }
105
+ tables = {
106
+ key: value
107
+ for key, value in data.items()
108
+ if isinstance(value, dict) and value
109
+ }
110
+
111
+ for key, value in scalars.items():
112
+ lines.append(f"{key} = {_format_toml_value(value)}")
113
+
114
+ for table_name, table_values in tables.items():
115
+ if lines:
116
+ lines.append("")
117
+ lines.append(f"[{table_name}]")
118
+ for key, value in table_values.items():
119
+ if isinstance(value, dict):
120
+ raise TypeError("Nested TOML tables beyond one level are not supported")
121
+ if value is None:
122
+ continue
123
+ lines.append(f"{key} = {_format_toml_value(value)}")
124
+
125
+ return "\n".join(lines) + "\n"
126
+
127
+
128
+ def save_system_config(updates: dict[str, Any]) -> Path:
129
+ config_dir = ensure_config_dir()
130
+ config_path = config_dir / CONFIG_FILE_NAME
131
+ current: dict[str, Any] = {}
132
+
133
+ if config_path.is_file():
134
+ current = _parse_toml(config_path)
135
+
136
+ merged = _merge_dicts(current, updates)
137
+ config_path.write_text(_dumps_toml(merged), encoding="utf-8")
138
+ return config_path
139
+
140
+
141
+ def load_config(cwd: Path | None) -> Config:
142
+ cwd = cwd or Path.cwd()
143
+ load_dotenv(cwd / ".env")
144
+
145
+ system_path = get_system_config_path()
146
+ config_dict: dict[str, Any] = {}
147
+
148
+ if system_path.is_file():
149
+ try:
150
+ config_dict = _parse_toml(system_path)
151
+ except ConfigError:
152
+ logger.warning("Skipping invalid system config: %s", system_path)
153
+
154
+ project_path = _get_project_config(cwd)
155
+ if project_path:
156
+ try:
157
+ project_config_dict = _parse_toml(project_path)
158
+ config_dict = _merge_dicts(config_dict, project_config_dict)
159
+ except ConfigError:
160
+ logger.warning("Skipping invalid project config: %s", project_path)
161
+
162
+ if "cwd" not in config_dict:
163
+ config_dict["cwd"] = cwd
164
+
165
+ if "developer_instructions" not in config_dict:
166
+ agent_md_content = _get_agent_md_files(cwd)
167
+ if agent_md_content:
168
+ config_dict["developer_instructions"] = agent_md_content
169
+
170
+ try:
171
+ return Config(**config_dict)
172
+ except Exception as error:
173
+ raise ConfigError(f"Invalid configuration: {error}") from error
context/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Conversation context management."""
context/compaction.py ADDED
@@ -0,0 +1,96 @@
1
+ from typing import Any
2
+ from client.llm_client import LLMClient
3
+ from client.response import StreamEventType, TokenUsage
4
+ from context.manager import ContextManager
5
+ from prompts.system import get_compression_prompt
6
+
7
+
8
+ class ChatCompactor:
9
+ def __init__(self, client: LLMClient):
10
+ self.client = client
11
+ self.last_error: str | None = None
12
+
13
+ def _format_history_for_compaction(self, messages: list[dict[str, Any]]) -> str:
14
+ output = ["Here is the conversation that needs to be continue: \n"]
15
+
16
+ for msg in messages:
17
+ role = msg.get("role", "")
18
+ content = msg.get("content", "")
19
+
20
+ if role == "system":
21
+ continue
22
+
23
+ if role == "tool":
24
+ tool_id = msg.get("tool_call_id", "unknown")
25
+
26
+ truncated = content[:2000] if len(content) > 2000 else content
27
+ if len(content) > 2000:
28
+ truncated += "\n... [tool output truncated]"
29
+
30
+ output.append(f"[Tool Result ({tool_id})]:\n{truncated}")
31
+ elif role == "assistant":
32
+ tool_details = []
33
+ if content:
34
+ truncated = content[:3000] if len(content) > 3000 else content
35
+ if len(content) > 3000:
36
+ truncated += "\n... [response truncated]"
37
+ output.append(f"Assistant:\n{truncated}")
38
+
39
+ if msg.get("tool_calls"):
40
+ for tc in msg["tool_calls"]:
41
+ func = tc.get("function", {})
42
+ name = func.get("name", "unknown")
43
+ args = func.get("arguments", "{}")
44
+
45
+ if len(args) > 500:
46
+ args = args[:500]
47
+ tool_details.append(f" - {name}({args})")
48
+
49
+ output.append("Assistant called tools:\n" + "\n".join(tool_details))
50
+ else:
51
+ truncated = content[:1500] if len(content) > 1500 else content
52
+ if len(content) > 1500:
53
+ truncated += "\n... [message truncated]"
54
+ output.append(f"User:\n{truncated}")
55
+
56
+ return "\n\n---\n\n".join(output)
57
+
58
+ async def compress(
59
+ self, context_manager: ContextManager
60
+ ) -> tuple[str | None, TokenUsage | None]:
61
+ self.last_error = None
62
+ messages = context_manager.get_messages()
63
+
64
+ if len(messages) < 3:
65
+ return None, None
66
+
67
+ compression_messages = [
68
+ {
69
+ "role": "system",
70
+ "content": get_compression_prompt(),
71
+ },
72
+ {
73
+ "role": "user",
74
+ "content": self._format_history_for_compaction(messages),
75
+ },
76
+ ]
77
+
78
+ try:
79
+ summary = ""
80
+ usage = None
81
+ async for event in self.client.chat_completion(
82
+ compression_messages,
83
+ stream=False,
84
+ ):
85
+ if event.type == StreamEventType.MESSAGE_COMPLETE:
86
+ usage = event.usage
87
+ summary += event.text_delta.content
88
+
89
+ if not summary or not usage:
90
+ self.last_error = "Compaction returned no summary."
91
+ return None, None
92
+
93
+ return summary, usage
94
+ except Exception as error:
95
+ self.last_error = str(error) or error.__class__.__name__
96
+ return None, None
@@ -0,0 +1,49 @@
1
+ from collections import deque
2
+ from typing import Any
3
+
4
+
5
+ class LoopDetector:
6
+ def __init__(self):
7
+ self.max_exact_repeats = 10
8
+ self.max_cycle_length = 10
9
+ self._history: deque[str] = deque(maxlen=20)
10
+
11
+ def record_action(self, action_type: str, **details: Any):
12
+ output = [action_type]
13
+
14
+ if action_type == "tool_call":
15
+ output.append(details.get("tool_name", ""))
16
+ args = details.get("args", {})
17
+
18
+ if isinstance(args, dict):
19
+ for k in sorted(args.keys()):
20
+ output.append(f"{k}={str(args[k])}")
21
+ elif action_type == "response":
22
+ output.append(details.get("text", ""))
23
+
24
+ signature = "|".join(output)
25
+ self._history.append(signature)
26
+
27
+ def check_for_loop(self) -> str | None:
28
+ if len(self._history) < 2:
29
+ return None
30
+
31
+ if len(self._history) >= self.max_exact_repeats:
32
+ recent = list(self._history)[-self.max_exact_repeats :]
33
+ if len(set(recent)) == 1:
34
+ return f"Same action repeated {self.max_exact_repeats} tiems"
35
+
36
+ if len(self._history) >= self.max_cycle_length * 2:
37
+ history = list(self._history)
38
+
39
+ for cycle_len in range(
40
+ 2, min(self.max_cycle_length + 1, len(history) // 2 + 1)
41
+ ):
42
+ recent = history[-cycle_len * 2 :]
43
+ if recent[:cycle_len] == recent[cycle_len:]:
44
+ return f"Detected repeating cycle of length {cycle_len}"
45
+
46
+ return None
47
+
48
+ def clear(self) -> None:
49
+ self._history.clear()