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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- 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
|