ata-coder 2.4.2__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 (118) hide show
  1. ata_coder/__init__.py +1 -0
  2. ata_coder/agent.py +874 -0
  3. ata_coder/agent_compact.py +190 -0
  4. ata_coder/agent_controller.py +218 -0
  5. ata_coder/agent_extension.py +69 -0
  6. ata_coder/agent_routing.py +105 -0
  7. ata_coder/agent_subsystems.py +72 -0
  8. ata_coder/agent_tools.py +318 -0
  9. ata_coder/agent_undo.py +63 -0
  10. ata_coder/anthropic_client.py +465 -0
  11. ata_coder/change_tracker.py +368 -0
  12. ata_coder/clawd_integration.py +574 -0
  13. ata_coder/commands/__init__.py +128 -0
  14. ata_coder/commands/_core.py +184 -0
  15. ata_coder/commands/_safety.py +95 -0
  16. ata_coder/commands/_settings.py +241 -0
  17. ata_coder/commands/_workflow.py +451 -0
  18. ata_coder/commands.py +974 -0
  19. ata_coder/config.py +257 -0
  20. ata_coder/core/__init__.py +35 -0
  21. ata_coder/core/events.py +73 -0
  22. ata_coder/core/queue.py +85 -0
  23. ata_coder/core/state.py +17 -0
  24. ata_coder/event_queue.py +5 -0
  25. ata_coder/extension.py +654 -0
  26. ata_coder/extensions/__init__.py +1 -0
  27. ata_coder/extensions/hello_skill.py +47 -0
  28. ata_coder/fool_proof.py +295 -0
  29. ata_coder/git_workflow.py +371 -0
  30. ata_coder/gui.py +511 -0
  31. ata_coder/llm_client.py +543 -0
  32. ata_coder/main.py +814 -0
  33. ata_coder/mcp_client.py +1095 -0
  34. ata_coder/memory.py +539 -0
  35. ata_coder/model_registry.py +134 -0
  36. ata_coder/model_router.py +105 -0
  37. ata_coder/permissions.py +274 -0
  38. ata_coder/privilege.py +464 -0
  39. ata_coder/project.py +273 -0
  40. ata_coder/prompt_template.py +423 -0
  41. ata_coder/prompts/auto-mode.md +7 -0
  42. ata_coder/prompts/coding-rules.md +40 -0
  43. ata_coder/prompts/execution-guardrails.md +14 -0
  44. ata_coder/prompts/memory-system.md +24 -0
  45. ata_coder/prompts/output-style.md +23 -0
  46. ata_coder/prompts/safety.md +17 -0
  47. ata_coder/prompts/slash-commands.md +24 -0
  48. ata_coder/prompts/sub-agents.md +38 -0
  49. ata_coder/prompts/system-reminders.md +17 -0
  50. ata_coder/prompts/system.md +105 -0
  51. ata_coder/prompts/tool-policy.md +46 -0
  52. ata_coder/repl_theme.py +99 -0
  53. ata_coder/repl_tracker.py +89 -0
  54. ata_coder/repl_ui.py +1214 -0
  55. ata_coder/safety_guard.py +434 -0
  56. ata_coder/self_correct.py +346 -0
  57. ata_coder/server.py +882 -0
  58. ata_coder/server_session.py +159 -0
  59. ata_coder/server_shell.py +129 -0
  60. ata_coder/session.py +431 -0
  61. ata_coder/settings.py +439 -0
  62. ata_coder/setup_wizard.py +136 -0
  63. ata_coder/skill_extension.py +92 -0
  64. ata_coder/skills/architect/SKILL.md +42 -0
  65. ata_coder/skills/code-reviewer/SKILL.md +37 -0
  66. ata_coder/skills/codecraft/SKILL.md +452 -0
  67. ata_coder/skills/debugger/SKILL.md +45 -0
  68. ata_coder/skills/doc-writer/SKILL.md +36 -0
  69. ata_coder/skills/general-coder/SKILL.md +76 -0
  70. ata_coder/skills/math-calculator/README.md +40 -0
  71. ata_coder/skills/math-calculator/SKILL.md +59 -0
  72. ata_coder/skills/math-calculator/handler.py +103 -0
  73. ata_coder/skills/math-calculator/prompts/system.md +8 -0
  74. ata_coder/skills/math-calculator/requirements.txt +2 -0
  75. ata_coder/skills/math-calculator/resources/constants.json +8 -0
  76. ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
  77. ata_coder/skills/security-auditor/SKILL.md +40 -0
  78. ata_coder/skills/test-writer/SKILL.md +36 -0
  79. ata_coder/skills/weather-skill/README.md +45 -0
  80. ata_coder/skills/weather-skill/handler.py +76 -0
  81. ata_coder/skills/weather-skill/manifest.json +48 -0
  82. ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
  83. ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
  84. ata_coder/skills/weather-skill/requirements.txt +1 -0
  85. ata_coder/skills/weather-skill/resources/city_list.json +17 -0
  86. ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
  87. ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
  88. ata_coder/skills/weather-skill/weather_utils.py +50 -0
  89. ata_coder/skills.py +1014 -0
  90. ata_coder/sub_agent.py +273 -0
  91. ata_coder/sub_agent_manager.py +203 -0
  92. ata_coder/system_prompt_builder.py +146 -0
  93. ata_coder/task_planner.py +391 -0
  94. ata_coder/terminal.py +318 -0
  95. ata_coder/test_runner.py +219 -0
  96. ata_coder/thread_supervisor.py +195 -0
  97. ata_coder/tool_defs.py +335 -0
  98. ata_coder/tools/__init__.py +11 -0
  99. ata_coder/tools/definitions.py +335 -0
  100. ata_coder/tools/executor.py +1036 -0
  101. ata_coder/tools/result.py +26 -0
  102. ata_coder/tools/subagent.py +332 -0
  103. ata_coder/tools/web.py +361 -0
  104. ata_coder/tools.py +1576 -0
  105. ata_coder/types.py +92 -0
  106. ata_coder/utils.py +113 -0
  107. ata_coder/web/css/style.css +180 -0
  108. ata_coder/web/index.html +84 -0
  109. ata_coder/web/js/app.js +489 -0
  110. ata_coder/web/package-lock.json +25 -0
  111. ata_coder/web/package.json +10 -0
  112. ata_coder/web/tsconfig.json +13 -0
  113. ata_coder-2.4.2.dist-info/METADATA +799 -0
  114. ata_coder-2.4.2.dist-info/RECORD +118 -0
  115. ata_coder-2.4.2.dist-info/WHEEL +5 -0
  116. ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
  117. ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
  118. ata_coder-2.4.2.dist-info/top_level.txt +1 -0
ata_coder/session.py ADDED
@@ -0,0 +1,431 @@
1
+ """
2
+ Session persistence — save, list, resume, and export conversations.
3
+
4
+ Sessions are stored as JSONL files in .ata_coder/sessions/.
5
+ Each line is a JSON object representing one message in the conversation.
6
+
7
+ Session metadata (timestamps, summary, skill used) is stored in a
8
+ sessions.json index file.
9
+
10
+ Usage:
11
+ from .session import SessionManager
12
+ sm = SessionManager()
13
+ sm.save("my-session", agent.state.messages)
14
+ sm.list_sessions()
15
+ messages = sm.load("my-session")
16
+ sm.delete("my-session")
17
+ sm.export_markdown("my-session", "output.md")
18
+ """
19
+
20
+ import json
21
+ import logging
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # ── Data model ───────────────────────────────────────────────────────────────
31
+
32
+ @dataclass
33
+ class SessionMeta:
34
+ """Metadata about a saved session."""
35
+ id: str
36
+ created: str = ""
37
+ updated: str = ""
38
+ message_count: int = 0
39
+ tool_call_count: int = 0
40
+ summary: str = "" # first user message, truncated
41
+ skill: str = ""
42
+ model: str = ""
43
+ workspace: str = ""
44
+ tags: list[str] = field(default_factory=list)
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ return {
48
+ "id": self.id,
49
+ "created": self.created,
50
+ "updated": self.updated,
51
+ "message_count": self.message_count,
52
+ "tool_call_count": self.tool_call_count,
53
+ "summary": self.summary,
54
+ "skill": self.skill,
55
+ "model": self.model,
56
+ "workspace": self.workspace,
57
+ "tags": self.tags,
58
+ }
59
+
60
+ @classmethod
61
+ def from_dict(cls, d: dict[str, Any]) -> "SessionMeta":
62
+ return cls(
63
+ id=d.get("id", ""),
64
+ created=d.get("created", ""),
65
+ updated=d.get("updated", ""),
66
+ message_count=d.get("message_count", 0),
67
+ tool_call_count=d.get("tool_call_count", 0),
68
+ summary=d.get("summary", ""),
69
+ skill=d.get("skill", ""),
70
+ model=d.get("model", ""),
71
+ workspace=d.get("workspace", ""),
72
+ tags=d.get("tags", []),
73
+ )
74
+
75
+
76
+ # ── Session manager ──────────────────────────────────────────────────────────
77
+
78
+ class SessionManager:
79
+ """
80
+ Manages conversation sessions: save, load, list, delete, export.
81
+
82
+ Session storage:
83
+ - .ata_coder/sessions/<id>.jsonl — conversation messages
84
+ - .ata_coder/sessions.json — index of all sessions
85
+ """
86
+
87
+ def __init__(self, project_dir: str | Path | None = None):
88
+ if project_dir is None:
89
+ try:
90
+ from .settings import get_settings
91
+ project_dir = get_settings().data_dir
92
+ except Exception:
93
+ project_dir = Path.home() / ".ata_coder"
94
+ self._base_dir = Path(project_dir) / "sessions"
95
+ self._base_dir.mkdir(parents=True, exist_ok=True)
96
+ self._index_path = self._base_dir.parent / "sessions.json"
97
+ self._index: dict[str, SessionMeta] = {}
98
+ self._load_index()
99
+
100
+ # ── Index management ─────────────────────────────────────────────────
101
+
102
+ def _load_index(self) -> None:
103
+ """Load session index from disk."""
104
+ if self._index_path.exists():
105
+ try:
106
+ with open(self._index_path, "r", encoding="utf-8") as f:
107
+ data = json.load(f)
108
+ for item in data.get("sessions", []):
109
+ meta = SessionMeta.from_dict(item)
110
+ self._index[meta.id] = meta
111
+ logger.debug("Loaded %d sessions from index", len(self._index))
112
+ except Exception as e:
113
+ logger.warning("Failed to load sessions index: %s", e)
114
+
115
+ def _save_index(self) -> None:
116
+ """Save session index to disk atomically (write-then-rename)."""
117
+ tmp = self._index_path.with_suffix(".tmp")
118
+ try:
119
+ from .utils import sanitize_surrogates
120
+ data = sanitize_surrogates({
121
+ "sessions": [m.to_dict() for m in self._index.values()],
122
+ "updated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
123
+ })
124
+ # Belt-and-suspenders: json.dumps → encode→decode strips any
125
+ # lone surrogates that json.dumps(ensure_ascii=False) may emit.
126
+ raw = json.dumps(data, indent=2, ensure_ascii=False)
127
+ safe = raw.encode("utf-8", errors="replace").decode("utf-8")
128
+ with open(tmp, "w", encoding="utf-8") as f:
129
+ f.write(safe)
130
+ tmp.replace(self._index_path)
131
+ except Exception as e:
132
+ logger.warning("Failed to save sessions index: %s", e)
133
+
134
+ # ── CRUD ─────────────────────────────────────────────────────────────
135
+
136
+ def save(
137
+ self,
138
+ session_id: str,
139
+ messages: list[dict[str, Any]],
140
+ summary: str = "",
141
+ skill: str = "",
142
+ model: str = "",
143
+ workspace: str = "",
144
+ tool_call_count: int = 0,
145
+ ) -> SessionMeta:
146
+ """
147
+ Save a session's messages to disk.
148
+
149
+ Args:
150
+ session_id: Unique session identifier
151
+ messages: List of OpenAI-format message dicts
152
+ summary: Short description (first user message, truncated)
153
+ skill: Active skill name
154
+ model: Model used
155
+ workspace: Workspace directory
156
+ tool_call_count: Number of tool calls made
157
+ """
158
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
159
+
160
+ # Save messages as JSONL
161
+ session_file = self._base_dir / f"{session_id}.jsonl"
162
+ try:
163
+ from .utils import sanitize_surrogates
164
+ with open(session_file, "w", encoding="utf-8") as f:
165
+ for msg in messages:
166
+ safe_msg = sanitize_surrogates(msg)
167
+ # Round-trip through ASCII-safe JSON to guarantee no lone
168
+ # surrogates reach f.write() — ensure_ascii=True escapes
169
+ # all non-ASCII, then we decode back to unicode cleanly.
170
+ line = json.dumps(safe_msg, ensure_ascii=False)
171
+ # Belt-and-suspenders: encode→decode strips any remaining
172
+ # lone surrogates that json.dumps may have emitted.
173
+ line = line.encode("utf-8", errors="replace").decode("utf-8")
174
+ f.write(line + "\n")
175
+ except Exception as e:
176
+ logger.error("Failed to save session %s: %s", session_id, e)
177
+ raise
178
+
179
+ # Update index
180
+ existing = self._index.get(session_id)
181
+ meta = SessionMeta(
182
+ id=session_id,
183
+ created=existing.created if existing else now,
184
+ updated=now,
185
+ message_count=len(messages),
186
+ tool_call_count=tool_call_count,
187
+ summary=summary[:200] if summary else "",
188
+ skill=skill,
189
+ model=model,
190
+ workspace=workspace,
191
+ tags=existing.tags if existing else [],
192
+ )
193
+ self._index[session_id] = meta
194
+ self._save_index()
195
+
196
+ logger.info("Saved session %s: %d messages", session_id, len(messages))
197
+ return meta
198
+
199
+ def resolve_session_id(self, session_id: str) -> str | None:
200
+ """
201
+ Resolve a session ID, supporting partial hash matching.
202
+
203
+ - Exact match → return as-is.
204
+ - 8-char hex hash → find the session whose ID ends with that hash.
205
+ - Multiple matches → return the most recently updated.
206
+ Returns None if no match found.
207
+ """
208
+ if session_id in self._index:
209
+ return session_id
210
+ # Try hash-part match (user typed any of the three 8-char hashes)
211
+ if len(session_id) >= 4 and all(c in "0123456789abcdef" for c in session_id):
212
+ candidates = [
213
+ (meta.updated, sid)
214
+ for sid, meta in self._index.items()
215
+ if session_id in sid # matches any part of the 3-part ID
216
+ ]
217
+ if candidates:
218
+ candidates.sort(reverse=True)
219
+ best = candidates[0][1]
220
+ logger.info("Resolved session %s → %s", session_id, best)
221
+ return best
222
+ return None
223
+
224
+ def load(self, session_id: str) -> list[dict[str, Any]] | None:
225
+ """
226
+ Load a session's messages from disk.
227
+ Supports partial hash matching via resolve_session_id().
228
+ Returns list of message dicts or None if not found.
229
+ """
230
+ sid = self.resolve_session_id(session_id)
231
+ if sid is None:
232
+ logger.warning("Session not found: %s", session_id)
233
+ return None
234
+ session_file = self._base_dir / f"{sid}.jsonl"
235
+ if not session_file.exists():
236
+ logger.warning("Session file not found: %s", sid)
237
+ return None
238
+
239
+ # Safety: refuse to load files > 100 MB (prevent memory exhaustion)
240
+ try:
241
+ st = session_file.stat()
242
+ if st.st_size > 100_000_000:
243
+ logger.error("Session file too large: %s (%d bytes)", session_id, st.st_size)
244
+ return None
245
+ except OSError:
246
+ pass
247
+
248
+ try:
249
+ messages = []
250
+ with open(session_file, "r", encoding="utf-8") as f:
251
+ for line in f:
252
+ line = line.strip()
253
+ if line:
254
+ messages.append(json.loads(line))
255
+ logger.info("Loaded session %s: %d messages", session_id, len(messages))
256
+ return messages
257
+ except Exception as e:
258
+ logger.error("Failed to load session %s: %s", session_id, e)
259
+ return None
260
+
261
+ def delete(self, session_id: str) -> bool:
262
+ """Delete a session (messages + index entry)."""
263
+ session_file = self._base_dir / f"{session_id}.jsonl"
264
+ deleted = False
265
+ try:
266
+ session_file.unlink(missing_ok=True)
267
+ deleted = True
268
+ except Exception as e:
269
+ logger.error("Failed to delete session file %s: %s", session_id, e)
270
+ # Still clean up index — don't leak stale entries
271
+
272
+ if session_id in self._index:
273
+ del self._index[session_id]
274
+ self._save_index()
275
+ deleted = True
276
+
277
+ return deleted
278
+
279
+ def get_meta(self, session_id: str) -> SessionMeta | None:
280
+ """Get session metadata."""
281
+ return self._index.get(session_id)
282
+
283
+ def list_sessions(self, limit: int = 20,
284
+ workspace: str | None = None) -> list[SessionMeta]:
285
+ """List sessions, newest first. Optionally filter by workspace."""
286
+ sessions = self._index.values()
287
+ if workspace:
288
+ ws_normalized = str(Path(workspace).resolve())
289
+ sessions = [
290
+ s for s in sessions
291
+ if s.workspace and str(Path(s.workspace).resolve()) == ws_normalized
292
+ ]
293
+ sorted_sessions = sorted(sessions, key=lambda m: m.updated, reverse=True)
294
+ return sorted_sessions[:limit]
295
+
296
+ def search_sessions(self, query: str,
297
+ workspace: str | None = None) -> list[SessionMeta]:
298
+ """Search sessions by summary text. Optionally filter by workspace."""
299
+ q = query.lower()
300
+ results = []
301
+ for meta in self._index.values():
302
+ if q in meta.summary.lower() or q in meta.id.lower():
303
+ results.append(meta)
304
+ if workspace:
305
+ ws_normalized = str(Path(workspace).resolve())
306
+ results = [
307
+ r for r in results
308
+ if r.workspace and str(Path(r.workspace).resolve()) == ws_normalized
309
+ ]
310
+ return sorted(results, key=lambda m: m.updated, reverse=True)
311
+
312
+ def get_recent(self, count: int = 5,
313
+ workspace: str | None = None) -> list[SessionMeta]:
314
+ """Get the most recent sessions, optionally filtered to workspace."""
315
+ return self.list_sessions(limit=count, workspace=workspace)
316
+
317
+ def tag_session(self, session_id: str, tag: str) -> bool:
318
+ """Add a tag to a session."""
319
+ meta = self._index.get(session_id)
320
+ if not meta:
321
+ return False
322
+ if tag not in meta.tags:
323
+ meta.tags.append(tag)
324
+ self._save_index()
325
+ return True
326
+
327
+ # ── Export ───────────────────────────────────────────────────────────
328
+
329
+ def export_markdown(self, session_id: str, output_path: str | None = None) -> str | None:
330
+ """
331
+ Export a session as a Markdown file.
332
+ Returns the markdown content, or saves to output_path if provided.
333
+ """
334
+ messages = self.load(session_id)
335
+ if not messages:
336
+ return None
337
+
338
+ meta = self.get_meta(session_id)
339
+ lines = [
340
+ f"# Session: {session_id}",
341
+ "",
342
+ f"- **Created:** {meta.created if meta else 'unknown'}",
343
+ f"- **Model:** {meta.model if meta else 'unknown'}",
344
+ f"- **Skill:** {meta.skill if meta else 'none'}",
345
+ f"- **Messages:** {len(messages)}",
346
+ "",
347
+ "---",
348
+ "",
349
+ ]
350
+
351
+ for msg in messages:
352
+ role = msg.get("role", "unknown")
353
+ content = msg.get("content", "")
354
+
355
+ if role == "system":
356
+ lines.append(f"<details>\n<summary>System Prompt</summary>\n\n```\n{content[:500]}\n```\n</details>\n")
357
+ elif role == "user":
358
+ lines.append(f"### User\n\n{content}\n")
359
+ elif role == "assistant":
360
+ if content:
361
+ lines.append(f"### Assistant\n\n{content}\n")
362
+ tool_calls = msg.get("tool_calls", [])
363
+ if tool_calls:
364
+ for tc in tool_calls:
365
+ fn = tc.get("function", {})
366
+ lines.append(f"**Tool:** `{fn.get('name', '?')}`\n")
367
+ lines.append(f"```json\n{fn.get('arguments', '')}\n```\n")
368
+ elif role == "tool":
369
+ lines.append(f"**Tool Result:**\n```\n{content[:500]}\n```\n")
370
+
371
+ lines.append("")
372
+
373
+ markdown = "\n".join(lines)
374
+
375
+ if output_path:
376
+ with open(output_path, "w", encoding="utf-8") as f:
377
+ f.write(markdown)
378
+ logger.info("Exported session to %s", output_path)
379
+
380
+ return markdown
381
+
382
+ def export_json(self, session_id: str, output_path: str | None = None) -> str | None:
383
+ """Export a session as a single JSON file."""
384
+ messages = self.load(session_id)
385
+ if not messages:
386
+ return None
387
+
388
+ meta = self.get_meta(session_id)
389
+ from .utils import sanitize_surrogates
390
+ data = sanitize_surrogates({
391
+ "session_id": session_id,
392
+ "metadata": meta.to_dict() if meta else {},
393
+ "messages": messages,
394
+ })
395
+ json_str = json.dumps(data, indent=2, ensure_ascii=False)
396
+
397
+ if output_path:
398
+ with open(output_path, "w", encoding="utf-8") as f:
399
+ f.write(json_str)
400
+
401
+ return json_str
402
+
403
+
404
+ # ── Auto-save helper ─────────────────────────────────────────────────────────
405
+
406
+ def generate_session_id(task: str, skill: str = "", workspace: str = "") -> str:
407
+ """
408
+ Generate a 3-part hash-based session ID.
409
+
410
+ Format: ``xxxxxxxx-xxxxxxxx-xxxxxxxx`` (8-8-8 hex)
411
+
412
+ - Part 1: SHA256(workspace path) → first 8 chars — groups by project
413
+ - Part 2: SHA256(ISO timestamp) → first 8 chars — unique per session
414
+ - Part 3: SHA256(task text) → first 8 chars — identifies the task
415
+
416
+ The ``workspace`` parameter hashes the current working directory,
417
+ so ``//resume`` can find all sessions for a given project folder.
418
+ """
419
+ import hashlib
420
+ # Sanitize inputs — lone surrogates crash .encode()
421
+ from .utils import sanitize_surrogates
422
+ ws_safe = sanitize_surrogates(workspace or "")
423
+ task_safe = sanitize_surrogates(task or "conversation")
424
+ # Part 1: workspace hash (project-scoped)
425
+ ws_hash = hashlib.sha256(ws_safe.encode()).hexdigest()[:8]
426
+ # Part 2: timestamp hash (unique per session)
427
+ now_iso = datetime.now(timezone.utc).isoformat()
428
+ time_hash = hashlib.sha256(now_iso.encode()).hexdigest()[:8]
429
+ # Part 3: task title hash (identifies the conversation topic)
430
+ task_hash = hashlib.sha256(task_safe.encode()).hexdigest()[:8]
431
+ return f"{ws_hash}-{time_hash}-{task_hash}"