andromity 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 (48) hide show
  1. andromity/__init__.py +2 -0
  2. andromity/assets/sounds/done.wav +0 -0
  3. andromity/cli.py +73 -0
  4. andromity/config.py +316 -0
  5. andromity/core/__init__.py +1 -0
  6. andromity/core/agent.py +211 -0
  7. andromity/core/audio.py +32 -0
  8. andromity/core/cron.py +324 -0
  9. andromity/core/debug_log.py +36 -0
  10. andromity/core/events.py +49 -0
  11. andromity/core/git_ops.py +186 -0
  12. andromity/core/mcp.py +636 -0
  13. andromity/core/models.py +428 -0
  14. andromity/core/oauth.py +454 -0
  15. andromity/core/planner.py +79 -0
  16. andromity/core/profiles.py +122 -0
  17. andromity/core/provider.py +188 -0
  18. andromity/core/search.py +345 -0
  19. andromity/core/security.py +64 -0
  20. andromity/core/session.py +169 -0
  21. andromity/core/todo.py +97 -0
  22. andromity/core/tools.py +1091 -0
  23. andromity/core/web.py +170 -0
  24. andromity/telemetry.py +102 -0
  25. andromity/tui/__init__.py +1 -0
  26. andromity/tui/app.py +1570 -0
  27. andromity/tui/footer.py +456 -0
  28. andromity/tui/markup_utils.py +26 -0
  29. andromity/tui/overlays/__init__.py +1 -0
  30. andromity/tui/overlays/batch_review.py +141 -0
  31. andromity/tui/overlays/cron.py +452 -0
  32. andromity/tui/overlays/model.py +305 -0
  33. andromity/tui/overlays/profile.py +109 -0
  34. andromity/tui/overlays/session.py +143 -0
  35. andromity/tui/overlays/settings.py +1061 -0
  36. andromity/tui/overlays/trust.py +60 -0
  37. andromity/tui/overlays/undo.py +77 -0
  38. andromity/tui/panels/__init__.py +1 -0
  39. andromity/tui/panels/chat.py +446 -0
  40. andromity/tui/panels/diff.py +613 -0
  41. andromity/tui/panels/filetree.py +306 -0
  42. andromity/tui/panels/plan.py +152 -0
  43. andromity-0.1.0.dist-info/METADATA +381 -0
  44. andromity-0.1.0.dist-info/RECORD +48 -0
  45. andromity-0.1.0.dist-info/WHEEL +5 -0
  46. andromity-0.1.0.dist-info/entry_points.txt +2 -0
  47. andromity-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. andromity-0.1.0.dist-info/top_level.txt +1 -0
andromity/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Andromity - The coding agent that never clocks out."""
2
+ __version__ = "0.1.0"
Binary file
andromity/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ import asyncio
2
+ import click
3
+
4
+
5
+ @click.group(invoke_without_command=True)
6
+ @click.pass_context
7
+ def main(ctx):
8
+ """Andromity — The coding agent that never clocks out.
9
+
10
+ Run without arguments to launch the interactive TUI.
11
+ """
12
+ if ctx.invoked_subcommand is None:
13
+ # No subcommand given — launch TUI directly
14
+ _launch_tui()
15
+
16
+
17
+ def _launch_tui():
18
+ from andromity.telemetry import maybe_ping
19
+ maybe_ping()
20
+
21
+ from rich.console import Console
22
+ console = Console()
23
+ with console.status("[bold cyan]✦ Starting Andromity...[/bold cyan]", spinner="dots12"):
24
+ from andromity.tui.app import AndromityApp
25
+ app = AndromityApp()
26
+ app.run()
27
+
28
+
29
+ @main.command()
30
+ @click.argument("prompt")
31
+ @click.option("--yes", is_flag=True, help="Auto-approve all actions")
32
+ @click.option("--dry-run", is_flag=True, help="Preview actions without executing")
33
+ @click.option("--profile", type=str, default="builder", help="Agent profile to use")
34
+ def run(prompt, yes, dry_run, profile):
35
+ """Headless single-task mode."""
36
+ asyncio.run(_run_async(prompt, yes, dry_run, profile))
37
+
38
+
39
+ @main.command()
40
+ def tui():
41
+ """Launch the interactive TUI (same as running `andromity` with no args)."""
42
+ _launch_tui()
43
+
44
+
45
+
46
+ async def _run_async(prompt, yes, dry_run, profile):
47
+ from andromity.config import config
48
+ from andromity.core.session import Session
49
+ from andromity.core.agent import Agent
50
+ from andromity.core.events import TextDelta, ToolCallStart, ToolCallEnd, Done
51
+ from andromity.core.models import get_context_limit_for_model, get_ollama_num_ctx
52
+ session = Session(name="headless-session")
53
+ provider = config.get("default", "provider", "")
54
+ model = config.get("default", "model", "")
55
+ if provider == "ollama":
56
+ ctx_limit = get_ollama_num_ctx(model)
57
+ else:
58
+ ctx_limit = get_context_limit_for_model(provider, model) if (provider and model) else 0
59
+ agent = Agent(session, profile=profile, dry_run=dry_run, auto_approve=yes, ctx_limit=ctx_limit)
60
+ print(f"\nUser: {prompt}\n")
61
+ print("Andromity:", end=" ", flush=True)
62
+ async for event in agent.run(prompt):
63
+ if isinstance(event, TextDelta):
64
+ print(event.text, end="", flush=True)
65
+ elif isinstance(event, ToolCallStart):
66
+ print(f"\n[Tool: {event.tool_name}]", end=" ", flush=True)
67
+ elif isinstance(event, ToolCallEnd):
68
+ print("[Done]", end=" ", flush=True)
69
+ print(f"\n\nTokens: {session.token_total} | Cost: ${session.cost_usd:.4f}")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
andromity/config.py ADDED
@@ -0,0 +1,316 @@
1
+ import hashlib
2
+ import os
3
+ import platform
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+
9
+ if sys.version_info >= (3, 11):
10
+ import tomllib
11
+ else:
12
+ import tomli as tomllib
13
+
14
+ import tomli_w
15
+
16
+
17
+ def get_config_dir() -> Path:
18
+ if platform.system() == "Windows":
19
+ return Path(os.environ.get("APPDATA", "~")).expanduser() / "andromity"
20
+ return Path.home() / ".andromity"
21
+
22
+
23
+ def get_shell() -> str:
24
+ if platform.system() == "Windows":
25
+ return "powershell"
26
+ return os.environ.get("SHELL", "/bin/bash")
27
+
28
+
29
+ class ConfigManager:
30
+ def __init__(self, config_dir: Optional[Path] = None):
31
+ self.config_dir = config_dir or get_config_dir()
32
+ self.config_path = self.config_dir / "config.toml"
33
+ self.config_dir.mkdir(parents=True, exist_ok=True)
34
+ self._config_cache: Dict[str, Any] = {}
35
+ self._load()
36
+
37
+ def _load(self):
38
+ if not self.config_path.exists():
39
+ self._create_default_config()
40
+ with open(self.config_path, "rb") as f:
41
+ try:
42
+ self._config_cache = tomllib.load(f)
43
+ except Exception as e:
44
+ print(f"Warning: Failed to parse {self.config_path}: {e}")
45
+ self._config_cache = {}
46
+
47
+ def _create_default_config(self):
48
+ default_config = {
49
+ "default": {
50
+ "provider": "anthropic",
51
+ "model": "claude-sonnet-4-6",
52
+ "profile": "builder",
53
+ "permission_mode": "safe",
54
+ "allowed_commands": ["npm run", "npm test", "npm list", "npm run dev", "pytest", "python -m pytest", "git status", "git diff", "git log", "ls", "dir", "cat", "echo"]
55
+ },
56
+ "providers": [
57
+ {"name": "anthropic", "type": "anthropic"},
58
+ {"name": "ollama", "type": "ollama", "base_url": "http://localhost:11434"},
59
+ ],
60
+ }
61
+ self.save(default_config)
62
+
63
+ def save(self, config_data: Optional[Dict[str, Any]] = None):
64
+ data_to_save = config_data if config_data is not None else self._config_cache
65
+ with open(self.config_path, "wb") as f:
66
+ tomli_w.dump(data_to_save, f)
67
+ if config_data is not None:
68
+ self._config_cache = config_data
69
+
70
+ def get(self, section: str, key: str, default: Any = None) -> Any:
71
+ return self._config_cache.get(section, {}).get(key, default)
72
+
73
+ def set(self, section: str, key: str, value: Any):
74
+ if section not in self._config_cache:
75
+ self._config_cache[section] = {}
76
+ self._config_cache[section][key] = value
77
+ self.save()
78
+
79
+ def get_provider_config(self, provider_name: str) -> Optional[Dict[str, Any]]:
80
+ providers = self._config_cache.get("providers", [])
81
+ for p in providers:
82
+ if p.get("name") == provider_name:
83
+ return p
84
+ return None
85
+
86
+ def get_api_key(self, provider_name: str) -> Optional[str]:
87
+ provider_cfg = self.get_provider_config(provider_name)
88
+ if provider_cfg:
89
+ key = provider_cfg.get("api_key")
90
+ if key:
91
+ if key.startswith("$"):
92
+ return os.environ.get(key[1:])
93
+ return key
94
+
95
+ # Fallback to standard env vars
96
+ env_map = {
97
+ "anthropic": "ANTHROPIC_API_KEY",
98
+ "openai": "OPENAI_API_KEY",
99
+ "google": "GEMINI_API_KEY",
100
+ "deepseek": "DEEPSEEK_API_KEY",
101
+ "groq": "GROQ_API_KEY",
102
+ "openrouter": "OPENROUTER_API_KEY",
103
+ "nvidia": "NVIDIA_API_KEY",
104
+ }
105
+ if provider_name == "google":
106
+ return os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
107
+ if provider_name == "nvidia":
108
+ return os.environ.get("NVIDIA_API_KEY") or os.environ.get("NVIDIA_NIM_API_KEY")
109
+ env_var = env_map.get(provider_name)
110
+ if env_var:
111
+ return os.environ.get(env_var)
112
+ return None
113
+
114
+ def set_api_key(self, provider_name: str, api_key: str):
115
+ """Set or update the API key for a given provider in config.toml and environment."""
116
+ providers = self._config_cache.get("providers", [])
117
+ found = False
118
+ for p in providers:
119
+ if p.get("name") == provider_name:
120
+ p["api_key"] = api_key
121
+ found = True
122
+ break
123
+ if not found:
124
+ providers.append({"name": provider_name, "type": provider_name, "api_key": api_key})
125
+ self._config_cache["providers"] = providers
126
+ self.save()
127
+
128
+ # Also set in os.environ for immediate use by litellm
129
+ env_map = {
130
+ "anthropic": "ANTHROPIC_API_KEY",
131
+ "openai": "OPENAI_API_KEY",
132
+ "google": "GEMINI_API_KEY",
133
+ "deepseek": "DEEPSEEK_API_KEY",
134
+ "groq": "GROQ_API_KEY",
135
+ "openrouter": "OPENROUTER_API_KEY",
136
+ }
137
+ env_var = env_map.get(provider_name)
138
+ if env_var:
139
+ os.environ[env_var] = api_key
140
+
141
+ def list_providers(self) -> list:
142
+ return self._config_cache.get("providers", [])
143
+
144
+ # ─── User Management ─────────────────────────────────────────────────
145
+ def get_user(self) -> Dict[str, str]:
146
+ return self._config_cache.get("user", {})
147
+
148
+ def set_user(self, name: str, email: str):
149
+ if "user" not in self._config_cache:
150
+ self._config_cache["user"] = {}
151
+ if name is not None:
152
+ self._config_cache["user"]["name"] = name
153
+ if email is not None:
154
+ self._config_cache["user"]["email"] = email
155
+ self.save()
156
+
157
+ # ─── MCP Config ──────────────────────────────────────────────────────
158
+ def get_mcp_config_path(self, project_path: str = "") -> Path:
159
+ """Find the most specific mcp.json file available."""
160
+ candidates = []
161
+ if project_path:
162
+ candidates.extend([
163
+ Path(project_path) / ".andromity" / "mcp.json",
164
+ Path(project_path) / ".vscode" / "mcp.json",
165
+ ])
166
+ candidates.append(Path.home() / ".andromity" / "mcp.json")
167
+ for p in candidates:
168
+ if p.is_file():
169
+ return p
170
+ # Default to home if none exist
171
+ default_path = Path.home() / ".andromity" / "mcp.json"
172
+ default_path.parent.mkdir(parents=True, exist_ok=True)
173
+ if not default_path.exists():
174
+ import json
175
+ default_path.write_text(json.dumps({"mcpServers": {}}, indent=2), encoding="utf-8")
176
+ return default_path
177
+
178
+ def set_mcp_server_disabled(self, project_path: str, server_name: str, disabled: bool):
179
+ """Toggle the 'disabled' flag for a specific MCP server in the config."""
180
+ path = self.get_mcp_config_path(project_path)
181
+ if not path.exists():
182
+ return
183
+ try:
184
+ import json
185
+ data = json.loads(path.read_text(encoding="utf-8"))
186
+ servers = data.get("mcpServers") or data.get("servers") or {}
187
+ if server_name in servers and isinstance(servers[server_name], dict):
188
+ servers[server_name]["disabled"] = disabled
189
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
190
+ except Exception:
191
+ pass
192
+
193
+ def _find_mcp_file_for_server(self, project_path: str, server_name: str):
194
+ """
195
+ Return (path, data, key) for the mcp.json file that contains server_name,
196
+ searching project then global Gemini config.
197
+ Returns (None, None, None) if not found.
198
+ """
199
+ import json
200
+
201
+ # Candidate files in priority order
202
+ candidates = []
203
+ if project_path:
204
+ candidates.extend([
205
+ Path(project_path) / ".andromity" / "mcp.json",
206
+ Path(project_path) / ".vscode" / "mcp.json",
207
+ ])
208
+ candidates.extend([
209
+ Path.home() / ".andromity" / "mcp.json",
210
+ Path.home() / ".gemini" / "config" / "mcp_config.json",
211
+ ])
212
+
213
+ for p in candidates:
214
+ if not p.is_file():
215
+ continue
216
+ try:
217
+ data = json.loads(p.read_text(encoding="utf-8"))
218
+ except Exception:
219
+ continue
220
+ for key in ("mcpServers", "servers"):
221
+ if server_name in data.get(key, {}):
222
+ return p, data, key
223
+ return None, None, None
224
+
225
+ def set_mcp_server_env(self, project_path: str, server_name: str, key: str, value: str):
226
+ """Set a single env-var key for an MCP server in the config file."""
227
+ path, data, srv_key = self._find_mcp_file_for_server(project_path, server_name)
228
+ if path is None:
229
+ return
230
+ try:
231
+ import json
232
+ servers = data.get(srv_key, {})
233
+ if "env" not in servers[server_name]:
234
+ servers[server_name]["env"] = {}
235
+ servers[server_name]["env"][key] = value
236
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
237
+ except Exception:
238
+ pass
239
+
240
+ def remove_mcp_server(self, project_path: str, server_name: str) -> bool:
241
+ """Remove an MCP server entry from the config file. Returns True on success."""
242
+ path, data, srv_key = self._find_mcp_file_for_server(project_path, server_name)
243
+ if path is None:
244
+ return False
245
+ try:
246
+ import json
247
+ del data[srv_key][server_name]
248
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
249
+ return True
250
+ except Exception:
251
+ return False
252
+
253
+ def convert_remote_to_mcp_remote(
254
+ self, project_path: str, server_name: str, token: str
255
+ ) -> bool:
256
+ """
257
+ Convert a remote HTTP server (serverUrl only) to use npx mcp-remote
258
+ with an Authorization: Bearer header, so it can be launched as stdio.
259
+ Searches project and global Gemini config. Returns True on success.
260
+ """
261
+ path, data, srv_key = self._find_mcp_file_for_server(project_path, server_name)
262
+ if path is None:
263
+ return False
264
+ try:
265
+ import json
266
+ conf = data[srv_key][server_name]
267
+ remote_url = conf.get("serverUrl") or conf.get("url") or ""
268
+ if not remote_url:
269
+ return False
270
+ # Preserve safe metadata fields only
271
+ meta = {k: v for k, v in conf.items()
272
+ if k not in ("command", "args", "env", "serverUrl", "url",
273
+ "type", "$typeName")}
274
+ data[srv_key][server_name] = {
275
+ "command": "npx",
276
+ "args": [
277
+ "-y", "mcp-remote", remote_url,
278
+ "--header", f"Authorization:Bearer {token}",
279
+ ],
280
+ "env": {},
281
+ **meta,
282
+ }
283
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
284
+ return True
285
+ except Exception:
286
+ return False
287
+
288
+ # ─── Trust Management ────────────────────────────────────────────────
289
+ def _trust_key(self, path: str) -> str:
290
+ resolved = str(Path(path).resolve())
291
+ return "p" + hashlib.sha256(resolved.encode()).hexdigest()[:15]
292
+
293
+ def is_trusted(self, path: str) -> bool:
294
+ key = self._trust_key(path)
295
+ return key in self._config_cache.get("trusted_projects", {})
296
+
297
+ def set_trusted(self, path: str):
298
+ resolved = str(Path(path).resolve())
299
+ key = self._trust_key(path)
300
+ if "trusted_projects" not in self._config_cache:
301
+ self._config_cache["trusted_projects"] = {}
302
+ self._config_cache["trusted_projects"][key] = {
303
+ "path": resolved,
304
+ "trusted_at": datetime.now(timezone.utc).isoformat(),
305
+ }
306
+ self.save()
307
+
308
+ def revoke_trust(self, path: str):
309
+ key = self._trust_key(path)
310
+ trusted = self._config_cache.get("trusted_projects", {})
311
+ if key in trusted:
312
+ del trusted[key]
313
+ self.save()
314
+
315
+
316
+ config = ConfigManager()
@@ -0,0 +1 @@
1
+ """Core modules."""
@@ -0,0 +1,211 @@
1
+ import json
2
+ from typing import AsyncGenerator, Dict, Any, Optional, Callable
3
+
4
+ from andromity.core.provider import stream_completion
5
+ from andromity.core.session import Session
6
+ from andromity.core.profiles import get_system_prompt, filter_tools_for_profile
7
+ from andromity.core.tools import CORE_TOOLS, ToolRegistry, execute_tool, register_session
8
+ from andromity.core.events import (
9
+ StreamEvent, TextDelta, ToolCallStart, ToolCallDelta, ToolCallEnd, Done, ToolResult, PlanApprovalRequired
10
+ )
11
+ from andromity.config import config
12
+
13
+
14
+ class Agent:
15
+ def __init__(self, session: Session, profile: str = None, dry_run: bool = False,
16
+ auto_approve: bool = False, on_tool_approval: Optional[Callable] = None,
17
+ ctx_limit: int = 0):
18
+ self.session = session
19
+ self.profile = profile or config.get("default", "profile", "builder")
20
+ self.dry_run = dry_run
21
+ self.auto_approve = auto_approve
22
+ self.on_tool_approval = on_tool_approval
23
+ self.ctx_limit = ctx_limit
24
+ self.allowed_tools = filter_tools_for_profile(CORE_TOOLS, self.profile)
25
+ self._empty_retried = False
26
+ # Register session so plan tools can store plan in it
27
+ register_session(session)
28
+ sys_prompt = get_system_prompt(self.profile)
29
+ deferred_catalog = ToolRegistry.get_instance().get_deferred_prompt_catalog()
30
+ if deferred_catalog:
31
+ sys_prompt += "\n\n" + deferred_catalog
32
+
33
+ if not self.session.messages:
34
+ self.session.add_message("system", sys_prompt)
35
+ elif self.session.messages[0]["role"] == "system":
36
+ self.session.messages[0]["content"] = sys_prompt
37
+ self.session.save()
38
+
39
+ async def _compact_context(self) -> AsyncGenerator[StreamEvent, None]:
40
+ limit = self.ctx_limit
41
+ if limit <= 0:
42
+ from andromity.core.models import get_context_limit_for_model
43
+ provider = config.get("default", "provider", "")
44
+ model = config.get("default", "model", "")
45
+ limit = get_context_limit_for_model(provider, model)
46
+ if limit <= 0:
47
+ limit = 131072 # 128K default for unknown models
48
+
49
+ # Use real API-reported token count as primary source.
50
+ # Fall back to a crude character estimate only if we haven't had any API reply yet.
51
+ if self.session.token_total > 0:
52
+ current_tokens = self.session.token_total
53
+ else:
54
+ current_tokens = sum(len(str(m.get("content", ""))) // 4 for m in self.session.messages)
55
+ threshold = int(limit * 0.8)
56
+
57
+ # Also compact if history has grown beyond 30 user/assistant turns
58
+ # regardless of token count — keeps the window fresh for long sessions.
59
+ non_system = [m for m in self.session.messages if m.get("role") != "system"]
60
+ turn_limit_hit = len(non_system) > 60 # 30 user + 30 assistant = 60 messages
61
+
62
+ if (current_tokens > threshold or turn_limit_hit) and len(self.session.messages) > 6:
63
+ reason = "turn limit (30 turns)" if turn_limit_hit else "token limit"
64
+ yield TextDelta(text=f"\n*Context window near {reason}. Compacting memory...*\n\n")
65
+
66
+ keep_last_n = 10
67
+ msgs_to_summarize = self.session.messages[1:-keep_last_n]
68
+
69
+ summary_prompt = (
70
+ "Summarize the following conversation history. Focus on decisions made, "
71
+ "files created, the overarching goal and important details. Combine this with the previous "
72
+ "summary if one exists:\n\n"
73
+ )
74
+ for m in msgs_to_summarize:
75
+ role = m.get("role", "unknown")
76
+ content = str(m.get("content", ""))
77
+ if len(content) > 500:
78
+ content = content[:500] + " ...[truncated]"
79
+ summary_prompt += f"{role.upper()}: {content}\n\n"
80
+
81
+ summary_msgs = [{"role": "user", "content": summary_prompt}]
82
+
83
+ new_summary = ""
84
+ async for event in stream_completion(summary_msgs, tools=[]):
85
+ if isinstance(event, TextDelta):
86
+ new_summary += event.text
87
+
88
+ removed = self.session.compact_messages(new_summary, keep_last_n=keep_last_n)
89
+ yield TextDelta(text=f"*Compacted {removed} messages into memory.*\n\n")
90
+
91
+ async def run(self, user_input: str) -> AsyncGenerator[StreamEvent, None]:
92
+ self.session.add_message("user", content=user_input)
93
+
94
+ async for event in self._compact_context():
95
+ yield event
96
+
97
+ while True:
98
+ current_tool_id = None
99
+ current_tool_name = None
100
+ current_tool_args_str = ""
101
+ tool_calls_to_execute = []
102
+ assistant_content = ""
103
+ last_usage = None
104
+
105
+ async for event in stream_completion(self.session.messages, tools=self.allowed_tools):
106
+ yield event
107
+ if isinstance(event, TextDelta):
108
+ assistant_content += event.text
109
+ elif isinstance(event, ToolCallStart):
110
+ current_tool_id = event.tool_id
111
+ current_tool_name = event.tool_name
112
+ current_tool_args_str = ""
113
+ elif isinstance(event, ToolCallDelta):
114
+ if event.tool_id == current_tool_id:
115
+ current_tool_args_str += event.args_json_chunk
116
+ elif isinstance(event, ToolCallEnd):
117
+ if current_tool_id == event.tool_id:
118
+ tool_calls_to_execute.append({
119
+ "id": current_tool_id, "type": "function",
120
+ "function": {"name": current_tool_name, "arguments": current_tool_args_str},
121
+ })
122
+ current_tool_id = None
123
+ elif isinstance(event, Done):
124
+ last_usage = event.usage
125
+
126
+ if last_usage:
127
+ from andromity.config import config
128
+ m = f"{config.get('default', 'provider', 'ollama')}/{config.get('default', 'model', '')}"
129
+ self.session.update_usage(last_usage, model=m)
130
+ else:
131
+ prompt_tokens = sum(len(str(m.get("content", ""))) // 4 for m in self.session.messages)
132
+ completion_tokens = len(assistant_content) // 4
133
+ self.session.update_usage({
134
+ "prompt_tokens": prompt_tokens,
135
+ "completion_tokens": completion_tokens,
136
+ "total_tokens": prompt_tokens + completion_tokens
137
+ })
138
+
139
+ self.session.add_message(
140
+ "assistant",
141
+ content=assistant_content if assistant_content else None,
142
+ tool_calls=tool_calls_to_execute if tool_calls_to_execute else None,
143
+ )
144
+
145
+ if not assistant_content and not tool_calls_to_execute:
146
+ # Model returned nothing — retry once before giving up
147
+ if not getattr(self, '_empty_retried', False):
148
+ self._empty_retried = True
149
+ yield TextDelta(text="\n*Model returned empty, retrying...*\n\n")
150
+ continue
151
+ self._empty_retried = False
152
+ from andromity.core.models import get_context_limit_for_model
153
+ provider = config.get("default", "provider", "")
154
+ model = config.get("default", "model", "")
155
+ limit = self.ctx_limit or get_context_limit_for_model(provider, model)
156
+ current_tokens = sum(len(str(m.get("content", ""))) // 4 for m in self.session.messages)
157
+ if limit > 0 and current_tokens > limit * 0.9:
158
+ warning = (
159
+ f"\n**[No response from model]** Context full ({current_tokens:,}/{limit:,} tokens). "
160
+ "Try `/new` for a fresh session, or switch model with **Ctrl+L**.\n"
161
+ )
162
+ else:
163
+ warning = (
164
+ "\n**[No response from model]** The model returned an empty response. "
165
+ "Try rephrasing your message or switch model with **Ctrl+L**.\n"
166
+ )
167
+ yield TextDelta(text=warning)
168
+ break
169
+
170
+ self._empty_retried = False
171
+
172
+ if not tool_calls_to_execute:
173
+ break
174
+
175
+ for tool_call in tool_calls_to_execute:
176
+ tool_name = tool_call["function"]["name"]
177
+ try:
178
+ args = json.loads(tool_call["function"]["arguments"])
179
+ except json.JSONDecodeError:
180
+ args = {}
181
+
182
+ if not self.auto_approve and self.on_tool_approval:
183
+ import asyncio
184
+ is_approved = await self.on_tool_approval(tool_name, args) if asyncio.iscoroutinefunction(self.on_tool_approval) else self.on_tool_approval(tool_name, args)
185
+ if not is_approved:
186
+ result = (
187
+ f"TOOL REJECTED BY USER: '{tool_name}' was explicitly declined.\n"
188
+ f"Do NOT retry this tool call.\n"
189
+ f"Acknowledge the rejection, then ask the user how they would like to proceed."
190
+ )
191
+ self.session.add_message("tool", content=result, name=tool_name, tool_call_id=tool_call["id"])
192
+ yield ToolResult(tool_id=tool_call["id"], result="[Rejected by User]")
193
+ continue
194
+
195
+ if self.dry_run:
196
+ result = f"[DRY RUN] Would execute {tool_name}({json.dumps(args, indent=2)})"
197
+ else:
198
+ try:
199
+ from andromity.core.tools import execute_tool_async
200
+ result = await execute_tool_async(tool_name, args)
201
+ except Exception as e:
202
+ result = f"Error executing {tool_name}: {e}"
203
+
204
+ self.session.add_message("tool", content=str(result), name=tool_name, tool_call_id=tool_call["id"])
205
+ yield ToolResult(tool_id=tool_call["id"], result=str(result))
206
+
207
+ if tool_name == "write_plan":
208
+ plan = self.session.load_plan_obj()
209
+ if plan and plan.status == "pending":
210
+ yield PlanApprovalRequired(plan=plan)
211
+
@@ -0,0 +1,32 @@
1
+ import os
2
+ import sys
3
+ import subprocess
4
+ from pathlib import Path
5
+ import threading
6
+
7
+ def play_sound(sound_name: str = "done.wav"):
8
+ """
9
+ Play a sound file cross-platform without blocking.
10
+ Assumes sound file is in the project root.
11
+ """
12
+ # Find sound file inside the package assets folder
13
+ sound_path = Path(__file__).resolve().parent.parent / "assets" / "sounds" / sound_name
14
+
15
+ if not sound_path.exists():
16
+ return
17
+
18
+ path_str = str(sound_path)
19
+
20
+ def _play():
21
+ try:
22
+ if sys.platform == "win32":
23
+ import winsound
24
+ winsound.PlaySound(path_str, winsound.SND_FILENAME)
25
+ elif sys.platform == "darwin":
26
+ subprocess.run(["afplay", path_str], check=False)
27
+ else:
28
+ subprocess.run(["aplay", "-q", path_str], check=False, stderr=subprocess.DEVNULL)
29
+ except Exception:
30
+ pass
31
+
32
+ threading.Thread(target=_play, daemon=True).start()