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/project.py ADDED
@@ -0,0 +1,273 @@
1
+ """
2
+ Project auto-detection.
3
+
4
+ Scans the workspace for known project files and detects:
5
+ - Programming languages
6
+ - Frameworks and libraries
7
+ - Build systems / package managers
8
+ - Test frameworks
9
+ - Code style tools (linters, formatters)
10
+ - Git repository info
11
+
12
+ The detected info is injected into the agent's system prompt so the LLM
13
+ understands the project context from the start.
14
+ """
15
+
16
+ import logging
17
+ import os
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ── Detection rules ──────────────────────────────────────────────────────────
26
+
27
+ # File patterns → language
28
+ LANGUAGE_DETECTORS: dict[str, list[str]] = {
29
+ "Python": ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "poetry.lock"],
30
+ "JavaScript": ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", ".nvmrc"],
31
+ "TypeScript": ["tsconfig.json", "tsconfig.*.json"],
32
+ "Go": ["go.mod", "go.sum"],
33
+ "Rust": ["Cargo.toml", "Cargo.lock"],
34
+ "Java": ["pom.xml", "build.gradle", "build.gradle.kts", "gradlew", ".java-version"],
35
+ "Kotlin": ["build.gradle.kts", "settings.gradle.kts"],
36
+ "Ruby": ["Gemfile", "Rakefile", ".ruby-version"],
37
+ "PHP": ["composer.json", "composer.lock"],
38
+ "C/C++": ["CMakeLists.txt", "Makefile", "configure.ac"],
39
+ "C#": ["*.csproj", "*.sln", "global.json"],
40
+ "Swift": ["Package.swift"],
41
+ "Zig": ["build.zig"],
42
+ "Elixir": ["mix.exs"],
43
+ "Clojure": ["deps.edn", "project.clj"],
44
+ "Haskell": ["stack.yaml", "*.cabal"],
45
+ "Scala": ["build.sbt"],
46
+ "Dart": ["pubspec.yaml"],
47
+ "Lua": ["*.rockspec"],
48
+ }
49
+
50
+
51
+ # File patterns → build system
52
+ BUILD_DETECTORS: dict[str, list[str]] = {
53
+ "pip": ["setup.py", "setup.cfg", "requirements.txt"],
54
+ "poetry": ["poetry.lock", "pyproject.toml"],
55
+ "npm": ["package-lock.json"],
56
+ "yarn": ["yarn.lock"],
57
+ "pnpm": ["pnpm-lock.yaml"],
58
+ "cargo": ["Cargo.toml", "Cargo.lock"],
59
+ "go mod": ["go.mod"],
60
+ "gradle": ["build.gradle", "build.gradle.kts"],
61
+ "maven": ["pom.xml"],
62
+ "cmake": ["CMakeLists.txt"],
63
+ "mix": ["mix.exs"],
64
+ "stack": ["stack.yaml"],
65
+ "cabal": ["*.cabal"],
66
+ }
67
+
68
+ # File patterns → test framework
69
+ TEST_DETECTORS: dict[str, list[str]] = {
70
+ "pytest": ["pytest.ini", "pyproject.toml", "conftest.py"],
71
+ "unittest": ["test_*.py", "*_test.py"],
72
+ "jest": ["jest.config.js", "jest.config.ts"],
73
+ "vitest": ["vitest.config.js", "vitest.config.ts"],
74
+ "mocha": [".mocharc.js", ".mocharc.json"],
75
+ "go test": ["*_test.go"],
76
+ "JUnit": ["*Test.java", "*Tests.java"],
77
+ "RSpec": ["spec/"],
78
+ "PHPUnit": ["phpunit.xml"],
79
+ "cargo test": [],
80
+ "Catch2": ["catch.hpp", "catch2.hpp"],
81
+ }
82
+
83
+
84
+ # ── Project info ─────────────────────────────────────────────────────────────
85
+
86
+ @dataclass
87
+ class ProjectInfo:
88
+ """Detected project information."""
89
+ languages: list[str] = field(default_factory=list)
90
+ frameworks: list[str] = field(default_factory=list)
91
+ build_systems: list[str] = field(default_factory=list)
92
+ test_frameworks: list[str] = field(default_factory=list)
93
+ is_git_repo: bool = False
94
+ git_branch: str = ""
95
+ git_remote: str = ""
96
+ has_docker: bool = False
97
+ has_docker_compose: bool = False
98
+ has_ci_cd: bool = False
99
+ ci_system: str = ""
100
+ file_count: int = 0
101
+ directory_count: int = 0
102
+
103
+ def to_prompt(self) -> str:
104
+ """Format as a system prompt section."""
105
+ lines = ["## Project Detection"]
106
+
107
+ if self.languages:
108
+ lines.append(f"- **Languages:** {', '.join(self.languages)}")
109
+ if self.frameworks:
110
+ lines.append(f"- **Frameworks:** {', '.join(self.frameworks)}")
111
+ if self.build_systems:
112
+ lines.append(f"- **Build:** {', '.join(self.build_systems)}")
113
+ if self.test_frameworks:
114
+ lines.append(f"- **Testing:** {', '.join(self.test_frameworks)}")
115
+
116
+ if self.is_git_repo:
117
+ lines.append(f"- **Git:** branch=`{self.git_branch}`")
118
+ if self.git_remote:
119
+ lines.append(f" remote=`{self.git_remote}`")
120
+
121
+ if self.has_docker:
122
+ lines.append("- **Docker:** Dockerfile detected")
123
+ if self.has_docker_compose:
124
+ lines.append("- **Docker Compose:** docker-compose.yml detected")
125
+
126
+ lines.append(f"- **Size:** ~{self.file_count} files in {self.directory_count} directories")
127
+
128
+ return "\n".join(lines)
129
+
130
+ def to_dict(self) -> dict[str, Any]:
131
+ return {
132
+ "languages": self.languages,
133
+ "frameworks": self.frameworks,
134
+ "build_systems": self.build_systems,
135
+ "test_frameworks": self.test_frameworks,
136
+ "is_git_repo": self.is_git_repo,
137
+ "git_branch": self.git_branch,
138
+ "git_remote": self.git_remote,
139
+ "has_docker": self.has_docker,
140
+ "has_docker_compose": self.has_docker_compose,
141
+ "file_count": self.file_count,
142
+ "directory_count": self.directory_count,
143
+ }
144
+
145
+
146
+ # ── Detector ─────────────────────────────────────────────────────────────────
147
+
148
+ class ProjectDetector:
149
+ """Scans a directory and detects project characteristics."""
150
+
151
+ def __init__(self, project_dir: str | Path | None = None):
152
+ self.root = Path(project_dir) if project_dir else Path.cwd()
153
+
154
+ def detect(self) -> ProjectInfo:
155
+ """Run all detectors and return a ProjectInfo."""
156
+ info = ProjectInfo()
157
+
158
+ # Scan files in root
159
+ root_files = set()
160
+ all_files: list[str] = []
161
+
162
+ if self.root.exists():
163
+ for entry in self.root.iterdir():
164
+ if entry.is_file() and not entry.name.startswith("."):
165
+ root_files.add(entry.name)
166
+
167
+ # Walk for deeper files (1-2 levels)
168
+ for root, dirs, files in os.walk(self.root):
169
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ("node_modules", "__pycache__", ".git", "venv", ".venv")]
170
+ if root.count(os.sep) - str(self.root).count(os.sep) > 2:
171
+ dirs[:] = []
172
+ continue
173
+ for f in files:
174
+ all_files.append(os.path.relpath(os.path.join(root, f), self.root))
175
+
176
+ info.file_count = len(all_files)
177
+ info.directory_count = len(set(os.path.dirname(f) for f in all_files))
178
+
179
+ # Unified detection pass — run all detectors in one loop
180
+ _DETECTOR_TARGETS: list[tuple[dict[str, list[str]], list[str]]] = [
181
+ (LANGUAGE_DETECTORS, info.languages),
182
+ (BUILD_DETECTORS, info.build_systems),
183
+ (TEST_DETECTORS, info.test_frameworks),
184
+ ]
185
+ for detector_map, target_list in _DETECTOR_TARGETS:
186
+ for name, indicators in detector_map.items():
187
+ if any(self._match_indicator(ind, root_files, all_files)
188
+ for ind in indicators):
189
+ if name not in target_list:
190
+ target_list.append(name)
191
+
192
+ # Git detection
193
+ git_dir = self.root / ".git"
194
+ if git_dir.exists():
195
+ info.is_git_repo = True
196
+ info.git_branch = self._get_git_branch()
197
+ info.git_remote = self._get_git_remote()
198
+
199
+ # Docker detection
200
+ if (self.root / "Dockerfile").exists():
201
+ info.has_docker = True
202
+ compose_files = list(self.root.glob("docker-compose*.yml")) + list(self.root.glob("docker-compose*.yaml"))
203
+ if compose_files:
204
+ info.has_docker_compose = True
205
+
206
+ # CI/CD detection
207
+ ci_indicators = {
208
+ ".github/workflows": "GitHub Actions",
209
+ ".gitlab-ci.yml": "GitLab CI",
210
+ "Jenkinsfile": "Jenkins",
211
+ ".circleci": "CircleCI",
212
+ ".travis.yml": "Travis CI",
213
+ "azure-pipelines.yml": "Azure Pipelines",
214
+ "buildkite": "Buildkite",
215
+ }
216
+ for indicator, name in ci_indicators.items():
217
+ if (self.root / indicator).exists() or any(indicator in f for f in all_files):
218
+ info.has_ci_cd = True
219
+ info.ci_system = name
220
+ break
221
+
222
+ logger.info(
223
+ "Project detected: langs=%s, frameworks=%s, build=%s, tests=%s",
224
+ info.languages, info.frameworks, info.build_systems, info.test_frameworks,
225
+ )
226
+ return info
227
+
228
+ def _match_indicator(self, pattern: str, root_files: set, all_files: list[str]) -> bool:
229
+ """Check if a file indicator exists."""
230
+ if "*" in pattern:
231
+ # Glob pattern
232
+ import fnmatch
233
+ for f in all_files:
234
+ if fnmatch.fnmatch(os.path.basename(f), pattern):
235
+ return True
236
+ return False
237
+ if pattern in root_files:
238
+ return True
239
+ # Check deeper files
240
+ for f in all_files:
241
+ if os.path.basename(f) == pattern:
242
+ return True
243
+ if f.endswith("/" + pattern):
244
+ return True
245
+ return False
246
+
247
+ def _get_git_branch(self) -> str:
248
+ """Get current git branch."""
249
+ import subprocess
250
+ try:
251
+ result = subprocess.run(
252
+ ["git", "branch", "--show-current"],
253
+ capture_output=True, text=True,
254
+ encoding="utf-8", errors="replace",
255
+ cwd=str(self.root), timeout=5,
256
+ )
257
+ return result.stdout.strip()
258
+ except Exception:
259
+ return ""
260
+
261
+ def _get_git_remote(self) -> str:
262
+ """Get git remote URL."""
263
+ import subprocess
264
+ try:
265
+ result = subprocess.run(
266
+ ["git", "remote", "get-url", "origin"],
267
+ capture_output=True, text=True,
268
+ encoding="utf-8", errors="replace",
269
+ cwd=str(self.root), timeout=5,
270
+ )
271
+ return result.stdout.strip()
272
+ except Exception:
273
+ return ""
@@ -0,0 +1,423 @@
1
+ """
2
+ Prompt template engine with variable substitution and context injection.
3
+
4
+ Templates use a simple syntax:
5
+ - {{ variable }} — substituted from context
6
+ - {{% if condition %}}...{{% endif %}} — conditional blocks
7
+ - {{% for item in list %}}...{{% endfor %}} — loop blocks
8
+ - {{ project_structure }} — built-in function to inject project tree
9
+ - {{ git_status }} — built-in function to inject git status
10
+ - {{ recent_files }} — built-in function to inject recently modified files
11
+ - {{ memory_context }} — built-in function to inject relevant memories
12
+
13
+ Templates are loaded from files in prompts/ directory.
14
+ """
15
+
16
+ import logging
17
+ import os
18
+ import re
19
+ import subprocess
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ── Template context ─────────────────────────────────────────────────────────
28
+
29
+ class TemplateContext:
30
+ """
31
+ Holds all variables and context functions available during template rendering.
32
+ """
33
+
34
+ def __init__(self, variables: dict[str, Any] | None = None):
35
+ self.variables: dict[str, Any] = variables or {}
36
+ self._fn_cache: dict[str, str] = {}
37
+
38
+ def get(self, key: str, default: Any = "") -> Any:
39
+ """Get a variable value."""
40
+ # Check variables first
41
+ if key in self.variables:
42
+ return self.variables[key]
43
+
44
+ # Check built-in functions (whitelist — only known safe functions)
45
+ _ALLOWED_BUILTINS = frozenset({
46
+ "date", "time", "datetime", "now",
47
+ "workspace", "model", "cwd", "os",
48
+ "python_version", "project_structure",
49
+ "git_status", "git_branch", "recent_files",
50
+ })
51
+ if key in _ALLOWED_BUILTINS:
52
+ fn = getattr(self, f"_fn_{key}", None)
53
+ else:
54
+ fn = None
55
+ if fn:
56
+ if key not in self._fn_cache:
57
+ try:
58
+ self._fn_cache[key] = fn()
59
+ except Exception as e:
60
+ logger.warning("Template function %s failed: %s", key, e)
61
+ self._fn_cache[key] = f"[Error: {e}]"
62
+ return self._fn_cache[key]
63
+
64
+ return default
65
+
66
+ def set(self, key: str, value: Any) -> None:
67
+ """Set a variable."""
68
+ self.variables[key] = value
69
+
70
+ # ── Built-in context functions ────────────────────────────────────────
71
+
72
+ def _fn_now(self) -> str:
73
+ """Current date/time."""
74
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
75
+
76
+ def _fn_date(self) -> str:
77
+ """Current date."""
78
+ return datetime.now().strftime("%Y-%m-%d")
79
+
80
+ def _fn_workspace(self) -> str:
81
+ """Workspace directory path."""
82
+ return str(Path.cwd())
83
+
84
+ def _fn_os(self) -> str:
85
+ """Operating system info."""
86
+ import platform
87
+ return f"{platform.system()} {platform.release()}"
88
+
89
+ def _fn_python_version(self) -> str:
90
+ """Python version."""
91
+ import platform
92
+ return platform.python_version()
93
+
94
+ def _fn_project_structure(self) -> str:
95
+ """Generate a tree view of the project."""
96
+ workspace = Path(self.variables.get("workspace_dir", Path.cwd()))
97
+ lines = []
98
+ try:
99
+ for root, dirs, files in os.walk(workspace):
100
+ # Skip hidden and common ignore dirs
101
+ dirs[:] = [
102
+ d for d in sorted(dirs)
103
+ if not d.startswith(".")
104
+ and d not in (
105
+ "node_modules", "__pycache__", ".git",
106
+ "venv", ".venv", "dist", "build",
107
+ "target", ".next", "coverage",
108
+ )
109
+ ]
110
+ level = root.replace(str(workspace), "").count(os.sep)
111
+ indent = " " * level
112
+ if level <= 3: # limit depth
113
+ if level > 0:
114
+ lines.append(f"{indent}{os.path.basename(root)}/")
115
+ for f in sorted(files)[:30]: # limit files per dir
116
+ lines.append(f"{indent} {f}")
117
+ return "\n".join(lines[:200]) # total limit
118
+ except Exception as e:
119
+ return f"[Error reading project structure: {e}]"
120
+
121
+ def _fn_git_status(self) -> str:
122
+ """Get git status summary."""
123
+ workspace = self.variables.get("workspace_dir", Path.cwd())
124
+ try:
125
+ result = subprocess.run(
126
+ ["git", "status", "--short"],
127
+ capture_output=True, text=True,
128
+ encoding="utf-8", errors="replace",
129
+ cwd=str(workspace), timeout=10,
130
+ )
131
+ if result.returncode == 0:
132
+ output = result.stdout.strip()
133
+ return output if output else "(clean working tree)"
134
+ return "(not a git repository or git not available)"
135
+ except Exception:
136
+ return "(git not available)"
137
+
138
+ def _fn_git_branch(self) -> str:
139
+ """Get current git branch."""
140
+ workspace = self.variables.get("workspace_dir", Path.cwd())
141
+ try:
142
+ result = subprocess.run(
143
+ ["git", "branch", "--show-current"],
144
+ capture_output=True, text=True,
145
+ encoding="utf-8", errors="replace",
146
+ cwd=str(workspace), timeout=10,
147
+ )
148
+ return result.stdout.strip() or "(no branch)"
149
+ except Exception:
150
+ return "(git not available)"
151
+
152
+ def _fn_recent_files(self) -> str:
153
+ """List recently modified files."""
154
+ workspace = Path(self.variables.get("workspace_dir", Path.cwd()))
155
+ files = []
156
+ try:
157
+ for root, dirs, filenames in os.walk(workspace):
158
+ dirs[:] = [
159
+ d for d in dirs
160
+ if not d.startswith(".")
161
+ and d not in ("node_modules", "__pycache__", ".git")
162
+ ]
163
+ for f in filenames:
164
+ fp = os.path.join(root, f)
165
+ try:
166
+ mtime = os.path.getmtime(fp)
167
+ files.append((mtime, fp))
168
+ except OSError:
169
+ pass
170
+ files.sort(reverse=True)
171
+ recent = files[:20]
172
+ lines = []
173
+ for mtime, fp in recent:
174
+ rel = os.path.relpath(fp, str(workspace))
175
+ dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
176
+ lines.append(f" {dt} {rel}")
177
+ return "\n".join(lines) if lines else "(no files found)"
178
+ except Exception as e:
179
+ return f"[Error: {e}]"
180
+
181
+
182
+ # ── Template parser / renderer ───────────────────────────────────────────────
183
+
184
+ class PromptTemplate:
185
+ """
186
+ A prompt template with variable substitution and conditionals.
187
+ """
188
+
189
+ def __init__(self, source: str, name: str = "inline"):
190
+ self.name = name
191
+ self.source = source
192
+
193
+ def render(self, context: TemplateContext | None = None, **kwargs) -> str:
194
+ """
195
+ Render the template with the given context.
196
+
197
+ Args:
198
+ context: TemplateContext with variables
199
+ **kwargs: Additional variables to add to context
200
+ """
201
+ if context is None:
202
+ context = TemplateContext()
203
+ for k, v in kwargs.items():
204
+ context.set(k, v)
205
+
206
+ result = self._render(self.source, context)
207
+ return result
208
+
209
+ def _render(self, source: str, context: TemplateContext) -> str:
210
+ """Recursive template renderer."""
211
+ # First, handle for loops
212
+ source = self._expand_for(source, context)
213
+ # Then, handle conditionals
214
+ source = self._expand_if(source, context)
215
+ # Finally, handle variable substitution
216
+ source = self._expand_vars(source, context)
217
+ return source
218
+
219
+ def _expand_vars(self, source: str, context: TemplateContext) -> str:
220
+ """Replace {{ variable }} placeholders."""
221
+ def replacer(match):
222
+ expr = match.group(1).strip()
223
+ # Handle {{ var }} or {{ var | default }}
224
+ if "|" in expr:
225
+ var, default = expr.split("|", 1)
226
+ value = context.get(var.strip(), default.strip())
227
+ else:
228
+ value = context.get(expr, "")
229
+ return str(value) if value is not None else ""
230
+
231
+ return re.sub(r"\{\{\s*(.+?)\s*\}\}", replacer, source)
232
+
233
+ def _expand_if(self, source: str, context: TemplateContext) -> str:
234
+ """Handle {{% if condition %}}...{{% endif %}} blocks.
235
+
236
+ Uses a stack-based parser so nested conditionals are handled
237
+ correctly. The old regex with a non-greedy ``.*?`` would match
238
+ the *inner* ``{% endif %}`` first, leaving outer tags orphaned.
239
+ """
240
+ import re as _re
241
+
242
+ if_tag = _re.compile(r'\{\%\s*if\s+(.+?)\s*\%\}')
243
+ endif_tag = _re.compile(r'\{\%\s*endif\s*\%\}')
244
+ any_tag = _re.compile(r'\{\%\s*(?:if\s+.+?|endif)\s*\%\}')
245
+
246
+ def _eval_condition(cond: str) -> bool:
247
+ negate = cond.startswith("not ")
248
+ if negate:
249
+ cond = cond[4:]
250
+ value = context.get(cond.strip(), "")
251
+ result = bool(value)
252
+ return not result if negate else result
253
+
254
+ # Find all tag positions
255
+ tags: list[tuple[int, int, str, str]] = [] # (start, end, kind, condition)
256
+ for m in any_tag.finditer(source):
257
+ raw = m.group(0)
258
+ if_m = if_tag.fullmatch(raw)
259
+ if if_m:
260
+ tags.append((m.start(), m.end(), "if", if_m.group(1).strip()))
261
+ else:
262
+ tags.append((m.start(), m.end(), "endif", ""))
263
+
264
+ if not tags:
265
+ return source
266
+
267
+ # Stack-match if/endif pairs
268
+ pairs: list[tuple[int, int, str]] = [] # (if_idx, endif_idx, condition)
269
+ stack: list[int] = []
270
+ for i, (_s, _e, kind, cond) in enumerate(tags):
271
+ if kind == "if":
272
+ stack.append(i)
273
+ elif kind == "endif":
274
+ if stack:
275
+ if_idx = stack.pop()
276
+ pairs.append((if_idx, i, tags[if_idx][3]))
277
+
278
+ # Process from rightmost (innermost) to leftmost (outermost)
279
+ # so replaced text offsets don't cascade.
280
+ pairs.sort(key=lambda p: tags[p[0]][0], reverse=True)
281
+
282
+ for if_idx, endif_idx, condition in pairs:
283
+ if_start = tags[if_idx][0]
284
+ if_end = tags[if_idx][1]
285
+ endif_start = tags[endif_idx][0]
286
+ endif_end = tags[endif_idx][1]
287
+
288
+ body = source[if_end:endif_start]
289
+ replacement = body if _eval_condition(condition) else ""
290
+ source = source[:if_start] + replacement + source[endif_end:]
291
+
292
+ return source
293
+
294
+ def _expand_for(self, source: str, context: TemplateContext) -> str:
295
+ """Handle {{% for item in list %}}...{{% endfor %}} blocks."""
296
+ def replacer(match):
297
+ var_name = match.group(1).strip()
298
+ list_name = match.group(2).strip()
299
+ body = match.group(3)
300
+
301
+ items = context.get(list_name, [])
302
+ if isinstance(items, str):
303
+ items = [items]
304
+ if not isinstance(items, (list, tuple)):
305
+ items = [str(items)]
306
+
307
+ result = []
308
+ for item in items:
309
+ # Create a sub-context with the loop variable
310
+ item_context = TemplateContext({**context.variables})
311
+ item_context.set(var_name, item)
312
+ result.append(body.replace(
313
+ f"{{{{ {var_name} }}}}", str(item)
314
+ ))
315
+ return "\n".join(result)
316
+
317
+ return re.sub(
318
+ r"\{\%\s*for\s+(\w+)\s+in\s+(\w+)\s*\%\}(.*?)\{\%\s*endfor\s*\%\}",
319
+ replacer,
320
+ source,
321
+ flags=re.DOTALL,
322
+ )
323
+
324
+
325
+ # ── Template manager ─────────────────────────────────────────────────────────
326
+
327
+ class TemplateManager:
328
+ """
329
+ Manages prompt templates: loading from files, rendering, and caching.
330
+ """
331
+
332
+ def __init__(self, prompts_dir: str | Path | None = None):
333
+ if prompts_dir is None:
334
+ prompts_dir = Path(__file__).parent / "prompts"
335
+ self.prompts_dir = Path(prompts_dir)
336
+ self._templates: dict[str, PromptTemplate] = {}
337
+ self._load_templates()
338
+
339
+ def _load_templates(self) -> None:
340
+ """Load all template files from the prompts directory."""
341
+ if not self.prompts_dir.exists():
342
+ return
343
+
344
+ for ext in ("*.md", "*.txt", "*.tmpl"):
345
+ for file_path in self.prompts_dir.glob(ext):
346
+ try:
347
+ with open(file_path, "r", encoding="utf-8") as f:
348
+ source = f.read()
349
+ name = file_path.stem
350
+ self._templates[name] = PromptTemplate(source, name=name)
351
+ logger.debug("Loaded template: %s", name)
352
+ except Exception as e:
353
+ logger.warning("Failed to load template %s: %s", file_path, e)
354
+
355
+ logger.debug("Loaded %d templates", len(self._templates))
356
+
357
+ def get(self, name: str) -> PromptTemplate | None:
358
+ """Get a template by name."""
359
+ return self._templates.get(name)
360
+
361
+ def render(self, name: str, **kwargs) -> str | None:
362
+ """Render a named template."""
363
+ template = self._templates.get(name)
364
+ if template is None:
365
+ return None
366
+ return template.render(**kwargs)
367
+
368
+ def list_templates(self) -> list[str]:
369
+ return list(self._templates.keys())
370
+
371
+ def register(self, name: str, source: str) -> PromptTemplate:
372
+ """Register an inline template."""
373
+ template = PromptTemplate(source, name=name)
374
+ self._templates[name] = template
375
+ return template
376
+
377
+
378
+ # ── Build system prompt from template ────────────────────────────────────────
379
+
380
+ def build_system_prompt(
381
+ skill_prompt: str,
382
+ context: TemplateContext | None = None,
383
+ template_manager: TemplateManager | None = None,
384
+ ) -> str:
385
+ """
386
+ Build a complete system prompt by combining:
387
+ 1. The skill's system prompt template
388
+ 2. Injected context (workspace, git, project structure)
389
+ 3. Memory recall
390
+ """
391
+ if context is None:
392
+ context = TemplateContext()
393
+
394
+ template = PromptTemplate(skill_prompt)
395
+
396
+ prompt = template.render(context)
397
+
398
+ # Add environment context
399
+ prompt += f"""
400
+
401
+ ## Environment Context
402
+ - Workspace: {context.get('workspace', 'unknown')}
403
+ - Date: {context.get('date', 'unknown')}
404
+ - OS: {context.get('os', 'unknown')}
405
+ - Git branch: {context.get('git_branch', 'unknown')}
406
+ """
407
+
408
+ # Add project structure if available
409
+ structure = context.get("project_structure", "")
410
+ if structure:
411
+ prompt += f"\n## Project Structure\n```\n{structure}\n```\n"
412
+
413
+ # Add git status if there are changes
414
+ git_status = context.get("git_status", "")
415
+ if git_status and git_status != "(clean working tree)":
416
+ prompt += f"\n## Git Status\n```\n{git_status}\n```\n"
417
+
418
+ # Add memory context
419
+ memory_ctx = context.get("memory_context", "")
420
+ if memory_ctx:
421
+ prompt += memory_ctx
422
+
423
+ return prompt