terminal-agent-cli 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.
- terminal_agent/__init__.py +7 -0
- terminal_agent/__main__.py +7 -0
- terminal_agent/agent/__init__.py +9 -0
- terminal_agent/agent/loop.py +338 -0
- terminal_agent/checkpoints/__init__.py +9 -0
- terminal_agent/checkpoints/manager.py +151 -0
- terminal_agent/cli/commands/__init__.py +26 -0
- terminal_agent/cli/commands/checkpoint.py +76 -0
- terminal_agent/cli/commands/clean.py +170 -0
- terminal_agent/cli/commands/config_cmd.py +32 -0
- terminal_agent/cli/commands/diff.py +45 -0
- terminal_agent/cli/commands/doctor.py +136 -0
- terminal_agent/cli/commands/resume.py +109 -0
- terminal_agent/cli/commands/run.py +184 -0
- terminal_agent/cli/commands/setup.py +229 -0
- terminal_agent/cli/commands/status.py +62 -0
- terminal_agent/cli/commands/test.py +38 -0
- terminal_agent/cli/commands/trace.py +60 -0
- terminal_agent/cli/main.py +94 -0
- terminal_agent/cli/theme.py +55 -0
- terminal_agent/cli/ui.py +147 -0
- terminal_agent/config/__init__.py +30 -0
- terminal_agent/config/schema.py +100 -0
- terminal_agent/config/settings.py +85 -0
- terminal_agent/context/__init__.py +11 -0
- terminal_agent/context/engine.py +173 -0
- terminal_agent/context/ranker.py +87 -0
- terminal_agent/git/__init__.py +10 -0
- terminal_agent/git/adapter.py +219 -0
- terminal_agent/planner/__init__.py +10 -0
- terminal_agent/planner/contract.py +58 -0
- terminal_agent/planner/planner.py +30 -0
- terminal_agent/providers/__init__.py +30 -0
- terminal_agent/providers/anthropic_provider.py +141 -0
- terminal_agent/providers/base.py +57 -0
- terminal_agent/providers/detector.py +397 -0
- terminal_agent/providers/factory.py +29 -0
- terminal_agent/providers/gemini_provider.py +130 -0
- terminal_agent/providers/mock_provider.py +80 -0
- terminal_agent/providers/ollama_provider.py +124 -0
- terminal_agent/providers/openai_provider.py +124 -0
- terminal_agent/recovery/__init__.py +10 -0
- terminal_agent/recovery/classifier.py +64 -0
- terminal_agent/recovery/strategies.py +62 -0
- terminal_agent/sandbox/__init__.py +13 -0
- terminal_agent/sandbox/base.py +41 -0
- terminal_agent/sandbox/docker.py +129 -0
- terminal_agent/sandbox/local.py +129 -0
- terminal_agent/security/__init__.py +13 -0
- terminal_agent/security/classifier.py +151 -0
- terminal_agent/security/policy.py +51 -0
- terminal_agent/security/secrets.py +105 -0
- terminal_agent/session/__init__.py +33 -0
- terminal_agent/session/manager.py +195 -0
- terminal_agent/session/models.py +160 -0
- terminal_agent/telemetry/__init__.py +12 -0
- terminal_agent/telemetry/events.py +87 -0
- terminal_agent/telemetry/metrics.py +38 -0
- terminal_agent/tools/__init__.py +33 -0
- terminal_agent/tools/base.py +59 -0
- terminal_agent/tools/checkpoint_tools.py +72 -0
- terminal_agent/tools/command_tools.py +125 -0
- terminal_agent/tools/file_tools.py +340 -0
- terminal_agent/tools/git_tools.py +124 -0
- terminal_agent/tools/registry.py +109 -0
- terminal_agent/verifier/__init__.py +13 -0
- terminal_agent/verifier/assertions.py +115 -0
- terminal_agent/verifier/engine.py +125 -0
- terminal_agent/verifier/runners.py +80 -0
- terminal_agent_cli-0.1.0.dist-info/METADATA +438 -0
- terminal_agent_cli-0.1.0.dist-info/RECORD +74 -0
- terminal_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- terminal_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- terminal_agent_cli-0.1.0.dist-info/licenses/LICENSE +23 -0
terminal_agent/cli/ui.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Rich UI rendering components for the monochrome Terminal Agent aesthetic."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from rich.box import ROUNDED, HEAVY, DOUBLE
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from terminal_agent.cli.theme import (
|
|
11
|
+
PALETTE,
|
|
12
|
+
SYM_ARROW,
|
|
13
|
+
SYM_CHECK,
|
|
14
|
+
SYM_CROSS,
|
|
15
|
+
SYM_WARN,
|
|
16
|
+
console,
|
|
17
|
+
)
|
|
18
|
+
from terminal_agent.session.models import PlanItem, TaskContract, VerificationResult, VerificationStatus
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_header() -> None:
|
|
22
|
+
"""Render main application banner."""
|
|
23
|
+
content = Text()
|
|
24
|
+
content.append("TERMINAL AGENT\n", style="agent.title")
|
|
25
|
+
content.append("Build. Verify. Ship.", style="agent.tagline")
|
|
26
|
+
panel = Panel(content, box=ROUNDED, border_style="agent.border", padding=(0, 2))
|
|
27
|
+
console.print(panel)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_task_card(task: str, project_dir: Path) -> None:
|
|
31
|
+
"""Render current project and task context."""
|
|
32
|
+
t = Table.grid(padding=(0, 2))
|
|
33
|
+
t.add_column(style="agent.muted", width=12)
|
|
34
|
+
t.add_column(style="agent.text")
|
|
35
|
+
|
|
36
|
+
t.add_row("PROJECT", str(project_dir))
|
|
37
|
+
t.add_row("TASK", task)
|
|
38
|
+
console.print(Panel(t, box=ROUNDED, border_style="agent.border"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_contract_card(contract: TaskContract) -> None:
|
|
42
|
+
"""Render negotiated Task Contract."""
|
|
43
|
+
t = Table(box=ROUNDED, border_style="agent.border", show_header=False, expand=True)
|
|
44
|
+
t.add_column("Key", style="agent.muted", width=16)
|
|
45
|
+
t.add_column("Value", style="agent.text")
|
|
46
|
+
|
|
47
|
+
t.add_row("GOAL", contract.goal)
|
|
48
|
+
t.add_row("CONSTRAINTS", "\n".join(f"• {c}" for c in contract.constraints))
|
|
49
|
+
t.add_row("SUCCESS CRITERIA", "\n".join(f"• {sc}" for sc in contract.success_criteria))
|
|
50
|
+
t.add_row("VERIFY COMMANDS", ", ".join(contract.verification_commands))
|
|
51
|
+
|
|
52
|
+
console.print(Panel(t, title="[agent.accent]TASK CONTRACT[/agent.accent]", border_style="agent.border"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_plan(plan: List[PlanItem]) -> None:
|
|
56
|
+
"""Render live plan status."""
|
|
57
|
+
lines = []
|
|
58
|
+
for item in plan:
|
|
59
|
+
if item.status == "COMPLETED":
|
|
60
|
+
symbol = SYM_CHECK
|
|
61
|
+
style = "agent.text"
|
|
62
|
+
elif item.status == "IN_PROGRESS":
|
|
63
|
+
symbol = SYM_ARROW
|
|
64
|
+
style = "agent.accent"
|
|
65
|
+
elif item.status == "FAILED":
|
|
66
|
+
symbol = SYM_CROSS
|
|
67
|
+
style = "agent.error"
|
|
68
|
+
else:
|
|
69
|
+
symbol = "[agent.muted]○[/agent.muted]"
|
|
70
|
+
style = "agent.muted"
|
|
71
|
+
lines.append(f"{symbol} [{style}]{item.description}[/{style}]")
|
|
72
|
+
|
|
73
|
+
console.print(Panel("\n".join(lines), title="[agent.accent]PLAN[/agent.accent]", border_style="agent.border"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_tool_event(tool_name: str, reason: str, status: str = "success", duration_ms: Optional[int] = None) -> None:
|
|
77
|
+
"""Render a step in the action timeline with explainability reason."""
|
|
78
|
+
dur_str = f" [agent.muted]({duration_ms}ms)[/agent.muted]" if duration_ms is not None else ""
|
|
79
|
+
symbol = SYM_CHECK if status == "success" else (SYM_CROSS if status == "error" else SYM_ARROW)
|
|
80
|
+
|
|
81
|
+
t = Table.grid(padding=(0, 1))
|
|
82
|
+
t.add_column(width=4)
|
|
83
|
+
t.add_column()
|
|
84
|
+
t.add_row(symbol, f"[agent.accent]{tool_name}[/agent.accent]{dur_str}")
|
|
85
|
+
if reason:
|
|
86
|
+
t.add_row("", f"[agent.muted]Reason: {reason}[/agent.muted]")
|
|
87
|
+
console.print(t)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render_verification_result(result: VerificationResult) -> None:
|
|
91
|
+
"""Render independent verification card."""
|
|
92
|
+
is_verified = result.status == VerificationStatus.VERIFIED
|
|
93
|
+
border_col = "agent.success" if is_verified else ("agent.warning" if result.status == VerificationStatus.PARTIAL else "agent.error")
|
|
94
|
+
title_text = f"[{border_col}]VERIFICATION: {result.status.value}[/{border_col}]"
|
|
95
|
+
|
|
96
|
+
table = Table(box=ROUNDED, border_style=border_col, show_header=False, expand=True)
|
|
97
|
+
table.add_column("Key", style="agent.muted", width=16)
|
|
98
|
+
table.add_column("Value", style="agent.text")
|
|
99
|
+
|
|
100
|
+
table.add_row("TESTS", f"{result.tests_passed}/{result.tests_run} passed ({result.tests_failed} failed)")
|
|
101
|
+
table.add_row("LINT", "[agent.success]PASS[/agent.success]" if result.lint_passed else "[agent.error]FAIL[/agent.error]")
|
|
102
|
+
table.add_row("FILES CHANGED", f"{result.files_changed_count} files ({', '.join(result.files_changed) if result.files_changed else 'None'})")
|
|
103
|
+
|
|
104
|
+
if result.assertions:
|
|
105
|
+
assertions_str = "\n".join(
|
|
106
|
+
f"{SYM_CHECK if a.passed else SYM_CROSS} {a.name}: {a.message}"
|
|
107
|
+
for a in result.assertions
|
|
108
|
+
)
|
|
109
|
+
table.add_row("ASSERTIONS", assertions_str)
|
|
110
|
+
|
|
111
|
+
console.print(Panel(table, title=title_text, border_style=border_col))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_proof_of_done(pod: Dict[str, Any], duration_seconds: float = 0.0) -> None:
|
|
115
|
+
"""Render final certified Proof of Done."""
|
|
116
|
+
mins = int(duration_seconds // 60)
|
|
117
|
+
secs = int(duration_seconds % 60)
|
|
118
|
+
time_str = f"{mins:02d}m {secs:02d}s"
|
|
119
|
+
|
|
120
|
+
t = Table(box=ROUNDED, border_style="agent.success", show_header=False, expand=True)
|
|
121
|
+
t.add_column("Key", style="agent.muted", width=18)
|
|
122
|
+
t.add_column("Value", style="agent.text")
|
|
123
|
+
|
|
124
|
+
t.add_row("TASK", pod.get("task", ""))
|
|
125
|
+
t.add_row("RESULT", f"[agent.success]{pod.get('result', 'VERIFIED')}[/agent.success]")
|
|
126
|
+
t.add_row("TESTS", f"[agent.success]{pod.get('tests_passed', 'N/A')}[/agent.success]")
|
|
127
|
+
t.add_row("LINT", pod.get("lint", "PASS"))
|
|
128
|
+
t.add_row("FILES CHANGED", f"{pod.get('files_changed_count', 0)} ({', '.join(pod.get('files_changed', []))})")
|
|
129
|
+
t.add_row("UNINTENDED FILES", str(pod.get("unintended_files_count", 0)))
|
|
130
|
+
t.add_row("RETRIES", str(pod.get("retries_count", 0)))
|
|
131
|
+
t.add_row("SUCCESS CRITERIA", pod.get("success_criteria_passed", "N/A"))
|
|
132
|
+
t.add_row("EXECUTION TIME", time_str)
|
|
133
|
+
|
|
134
|
+
console.print()
|
|
135
|
+
console.print(Panel(t, title="[agent.success]PROOF OF DONE[/agent.success]", border_style="agent.success"))
|
|
136
|
+
|
|
137
|
+
summary = pod.get("summary", {})
|
|
138
|
+
if summary:
|
|
139
|
+
sum_table = Table(box=ROUNDED, border_style="agent.border", show_header=False, expand=True)
|
|
140
|
+
sum_table.add_column("Key", style="agent.muted", width=18)
|
|
141
|
+
sum_table.add_column("Value", style="agent.text")
|
|
142
|
+
sum_table.add_row("WHAT CHANGED", summary.get("what_changed", ""))
|
|
143
|
+
sum_table.add_row("WHY IT CHANGED", summary.get("why_it_changed", ""))
|
|
144
|
+
sum_table.add_row("WHAT WAS VERIFIED", summary.get("what_was_verified", ""))
|
|
145
|
+
sum_table.add_row("LIMITATIONS", summary.get("remaining_limitations", "None"))
|
|
146
|
+
console.print(Panel(sum_table, title="[agent.accent]FINAL SUMMARY[/agent.accent]", border_style="agent.border"))
|
|
147
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Configuration package for Terminal Agent."""
|
|
2
|
+
|
|
3
|
+
from terminal_agent.config.schema import (
|
|
4
|
+
TerminalAgentConfig,
|
|
5
|
+
AgentConfig,
|
|
6
|
+
SandboxConfig,
|
|
7
|
+
VerificationConfig,
|
|
8
|
+
SecurityConfig,
|
|
9
|
+
ProviderConfig,
|
|
10
|
+
SandboxMode,
|
|
11
|
+
ProviderType,
|
|
12
|
+
CommandCategory,
|
|
13
|
+
)
|
|
14
|
+
from terminal_agent.config.settings import load_config, save_default_config, find_config_file
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"TerminalAgentConfig",
|
|
18
|
+
"AgentConfig",
|
|
19
|
+
"SandboxConfig",
|
|
20
|
+
"VerificationConfig",
|
|
21
|
+
"SecurityConfig",
|
|
22
|
+
"ProviderConfig",
|
|
23
|
+
"SandboxMode",
|
|
24
|
+
"ProviderType",
|
|
25
|
+
"CommandCategory",
|
|
26
|
+
"load_config",
|
|
27
|
+
"save_default_config",
|
|
28
|
+
"find_config_file",
|
|
29
|
+
]
|
|
30
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Configuration schemas for Terminal Agent."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SandboxMode(str, Enum):
|
|
9
|
+
LOCAL = "local"
|
|
10
|
+
DOCKER = "docker"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProviderType(str, Enum):
|
|
14
|
+
MOCK = "mock"
|
|
15
|
+
OLLAMA = "ollama"
|
|
16
|
+
OPENAI = "openai"
|
|
17
|
+
ANTHROPIC = "anthropic"
|
|
18
|
+
GEMINI = "gemini"
|
|
19
|
+
CUSTOM = "custom"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommandCategory(str, Enum):
|
|
23
|
+
SAFE = "safe"
|
|
24
|
+
WRITE = "write"
|
|
25
|
+
DESTRUCTIVE = "destructive"
|
|
26
|
+
NETWORK = "network"
|
|
27
|
+
PRIVILEGED = "privileged"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AgentConfig(BaseModel):
|
|
31
|
+
max_steps: int = Field(default=40, description="Maximum steps allowed per task execution")
|
|
32
|
+
max_retries: int = Field(default=3, description="Maximum repair retries on verification failure")
|
|
33
|
+
timeout_seconds: int = Field(default=600, description="Overall task execution timeout in seconds")
|
|
34
|
+
token_budget: int = Field(default=128000, description="Maximum token budget for context")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SandboxConfig(BaseModel):
|
|
38
|
+
mode: SandboxMode = Field(default=SandboxMode.LOCAL, description="Sandbox isolation mode (local or docker)")
|
|
39
|
+
image: str = Field(default="python:3.12-slim", description="Docker image if docker mode is enabled")
|
|
40
|
+
timeout_seconds: int = Field(default=60, description="Per-command timeout in seconds")
|
|
41
|
+
network: str = Field(default="disabled", description="Network access policy: 'disabled' or 'enabled'")
|
|
42
|
+
max_memory_mb: int = Field(default=1024, description="Memory limit for docker sandbox in MB")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DiffConstraints(BaseModel):
|
|
46
|
+
max_files_changed: int = Field(default=10, description="Maximum allowed modified files in diff")
|
|
47
|
+
allow_untracked_files: bool = Field(default=True, description="Whether new files are permitted")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VerificationConfig(BaseModel):
|
|
51
|
+
tests: List[str] = Field(default_factory=lambda: ["pytest"], description="Test commands to execute for verification")
|
|
52
|
+
lint: List[str] = Field(default_factory=list, description="Lint/static check commands")
|
|
53
|
+
assertions: List[str] = Field(
|
|
54
|
+
default_factory=lambda: ["no_test_files_modified", "api_contract_preserved"],
|
|
55
|
+
description="Verification contract assertion checks"
|
|
56
|
+
)
|
|
57
|
+
diff: DiffConstraints = Field(default_factory=DiffConstraints, description="Git diff constraints")
|
|
58
|
+
auto_rollback_on_failure: bool = Field(default=False, description="Whether to rollback git changes on failure")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SecurityConfig(BaseModel):
|
|
62
|
+
network: str = Field(default="disabled", description="Global network policy")
|
|
63
|
+
require_confirmation_for: List[CommandCategory] = Field(
|
|
64
|
+
default_factory=lambda: [
|
|
65
|
+
CommandCategory.DESTRUCTIVE,
|
|
66
|
+
CommandCategory.PRIVILEGED,
|
|
67
|
+
CommandCategory.NETWORK
|
|
68
|
+
],
|
|
69
|
+
description="Command categories that require explicit user confirmation"
|
|
70
|
+
)
|
|
71
|
+
blocked_paths: List[str] = Field(
|
|
72
|
+
default_factory=lambda: [
|
|
73
|
+
".env",
|
|
74
|
+
".env.*",
|
|
75
|
+
"**/*.pem",
|
|
76
|
+
"**/*.key",
|
|
77
|
+
"**/.ssh/*",
|
|
78
|
+
"**/.aws/*",
|
|
79
|
+
"**/id_rsa*",
|
|
80
|
+
"**/secrets.*"
|
|
81
|
+
],
|
|
82
|
+
description="File patterns blocked from read/write to protect host secrets"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ProviderConfig(BaseModel):
|
|
87
|
+
name: ProviderType = Field(default=ProviderType.MOCK, description="Model provider name")
|
|
88
|
+
model: str = Field(default="mock-model", description="Model identifier")
|
|
89
|
+
base_url: Optional[str] = Field(default=None, description="Custom API base URL if needed")
|
|
90
|
+
api_key: Optional[str] = Field(default=None, description="API key or retrieved via environment variable")
|
|
91
|
+
temperature: float = Field(default=0.2, description="Sampling temperature")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TerminalAgentConfig(BaseModel):
|
|
95
|
+
agent: AgentConfig = Field(default_factory=AgentConfig)
|
|
96
|
+
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
|
|
97
|
+
verification: VerificationConfig = Field(default_factory=VerificationConfig)
|
|
98
|
+
security: SecurityConfig = Field(default_factory=SecurityConfig)
|
|
99
|
+
provider: ProviderConfig = Field(default_factory=ProviderConfig)
|
|
100
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Settings loader and configuration manager for Terminal Agent."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import yaml
|
|
7
|
+
from terminal_agent.config.schema import TerminalAgentConfig, ProviderType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG_FILENAMES = [
|
|
11
|
+
"terminal-agent.config.yaml",
|
|
12
|
+
"terminal-agent.config.yml",
|
|
13
|
+
".terminal-agent.yaml",
|
|
14
|
+
".terminal-agent.yml",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_config_file(start_dir: Optional[Path] = None) -> Optional[Path]:
|
|
19
|
+
"""Find configuration file by searching current and parent directories."""
|
|
20
|
+
current = start_dir or Path.cwd()
|
|
21
|
+
root = Path(current.anchor)
|
|
22
|
+
|
|
23
|
+
while current != root:
|
|
24
|
+
for filename in DEFAULT_CONFIG_FILENAMES:
|
|
25
|
+
candidate = current / filename
|
|
26
|
+
if candidate.is_file():
|
|
27
|
+
return candidate
|
|
28
|
+
current = current.parent
|
|
29
|
+
|
|
30
|
+
for filename in DEFAULT_CONFIG_FILENAMES:
|
|
31
|
+
candidate = root / filename
|
|
32
|
+
if candidate.is_file():
|
|
33
|
+
return candidate
|
|
34
|
+
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config(config_path: Optional[Path] = None) -> TerminalAgentConfig:
|
|
39
|
+
"""Load configuration from file or defaults, applying environment variables."""
|
|
40
|
+
target_path = config_path or find_config_file()
|
|
41
|
+
|
|
42
|
+
raw_data = {}
|
|
43
|
+
if target_path and target_path.exists():
|
|
44
|
+
with open(target_path, "r", encoding="utf-8") as f:
|
|
45
|
+
loaded = yaml.safe_load(f)
|
|
46
|
+
if isinstance(loaded, dict):
|
|
47
|
+
raw_data = loaded
|
|
48
|
+
|
|
49
|
+
# Environment variable overrides
|
|
50
|
+
if "TERMINAL_AGENT_PROVIDER" in os.environ:
|
|
51
|
+
provider_name = os.environ["TERMINAL_AGENT_PROVIDER"].lower()
|
|
52
|
+
if "provider" not in raw_data:
|
|
53
|
+
raw_data["provider"] = {}
|
|
54
|
+
raw_data["provider"]["name"] = provider_name
|
|
55
|
+
|
|
56
|
+
if "TERMINAL_AGENT_MODEL" in os.environ:
|
|
57
|
+
if "provider" not in raw_data:
|
|
58
|
+
raw_data["provider"] = {}
|
|
59
|
+
raw_data["provider"]["model"] = os.environ["TERMINAL_AGENT_MODEL"]
|
|
60
|
+
|
|
61
|
+
if "OLLAMA_HOST" in os.environ and "provider" in raw_data and raw_data["provider"].get("name") == "ollama":
|
|
62
|
+
raw_data["provider"]["base_url"] = os.environ["OLLAMA_HOST"]
|
|
63
|
+
|
|
64
|
+
if "OPENAI_API_KEY" in os.environ:
|
|
65
|
+
if "provider" in raw_data and raw_data["provider"].get("name") == "openai":
|
|
66
|
+
raw_data["provider"]["api_key"] = os.environ["OPENAI_API_KEY"]
|
|
67
|
+
|
|
68
|
+
if "ANTHROPIC_API_KEY" in os.environ:
|
|
69
|
+
if "provider" in raw_data and raw_data["provider"].get("name") == "anthropic":
|
|
70
|
+
raw_data["provider"]["api_key"] = os.environ["ANTHROPIC_API_KEY"]
|
|
71
|
+
|
|
72
|
+
if "GEMINI_API_KEY" in os.environ:
|
|
73
|
+
if "provider" in raw_data and raw_data["provider"].get("name") == "gemini":
|
|
74
|
+
raw_data["provider"]["api_key"] = os.environ["GEMINI_API_KEY"]
|
|
75
|
+
|
|
76
|
+
return TerminalAgentConfig.model_validate(raw_data)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def save_default_config(destination: Path) -> Path:
|
|
80
|
+
"""Write default configuration YAML template to destination."""
|
|
81
|
+
default_config = TerminalAgentConfig()
|
|
82
|
+
yaml_content = yaml.dump(default_config.model_dump(mode="json"), sort_keys=False, indent=2)
|
|
83
|
+
destination.write_text(yaml_content, encoding="utf-8")
|
|
84
|
+
return destination
|
|
85
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Context exploration package for Terminal Agent."""
|
|
2
|
+
|
|
3
|
+
from terminal_agent.context.ranker import DeterministicRanker, tokenize
|
|
4
|
+
from terminal_agent.context.engine import RepositoryContextEngine
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"DeterministicRanker",
|
|
8
|
+
"tokenize",
|
|
9
|
+
"RepositoryContextEngine",
|
|
10
|
+
]
|
|
11
|
+
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Deterministic context retrieval engine for codebase exploration."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
7
|
+
|
|
8
|
+
from terminal_agent.context.ranker import DeterministicRanker, tokenize
|
|
9
|
+
from terminal_agent.git.adapter import GitAdapter
|
|
10
|
+
from terminal_agent.security.secrets import SecretGuard
|
|
11
|
+
from terminal_agent.session.models import TaskContract
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
IGNORE_PATTERNS = {
|
|
15
|
+
".git", ".terminal_agent", ".venv", "venv", "__pycache__",
|
|
16
|
+
".pytest_cache", ".coverage", "htmlcov", "dist", "build",
|
|
17
|
+
"node_modules", ".idea", ".vscode", "*.pyc", "*.pyo", "*.egg-info"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RepositoryContextEngine:
|
|
22
|
+
"""Explores repository structure and extracts deterministic, token-efficient context."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, root_dir: Optional[Path] = None, secret_guard: Optional[SecretGuard] = None):
|
|
25
|
+
self.root_dir = (root_dir or Path.cwd()).resolve()
|
|
26
|
+
self.secret_guard = secret_guard or SecretGuard(working_dir=self.root_dir)
|
|
27
|
+
self.git_adapter = GitAdapter(self.root_dir)
|
|
28
|
+
|
|
29
|
+
def is_ignored(self, path: Path) -> bool:
|
|
30
|
+
"""Check if path matches standard ignore patterns or security blocklist."""
|
|
31
|
+
if self.secret_guard.is_path_blocked(path):
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
parts = path.parts
|
|
35
|
+
for part in parts:
|
|
36
|
+
if part in IGNORE_PATTERNS:
|
|
37
|
+
return True
|
|
38
|
+
for pat in IGNORE_PATTERNS:
|
|
39
|
+
if fnmatch.fnmatch(part, pat):
|
|
40
|
+
return True
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def list_all_files(self, max_files: int = 500) -> List[Path]:
|
|
44
|
+
"""Discover all valid non-ignored repository files."""
|
|
45
|
+
collected = []
|
|
46
|
+
for root, dirs, files in os.walk(self.root_dir):
|
|
47
|
+
# Prune ignored directories in-place
|
|
48
|
+
dirs[:] = [
|
|
49
|
+
d for d in dirs
|
|
50
|
+
if d not in IGNORE_PATTERNS and not any(fnmatch.fnmatch(d, p) for p in IGNORE_PATTERNS)
|
|
51
|
+
]
|
|
52
|
+
for f in files:
|
|
53
|
+
p = Path(root) / f
|
|
54
|
+
if not self.is_ignored(p):
|
|
55
|
+
collected.append(p)
|
|
56
|
+
if len(collected) >= max_files:
|
|
57
|
+
return collected
|
|
58
|
+
return collected
|
|
59
|
+
|
|
60
|
+
def build_tree_summary(self, max_depth: int = 3, max_entries: int = 60) -> str:
|
|
61
|
+
"""Generate a clean ASCII directory tree summary."""
|
|
62
|
+
lines = [f"{self.root_dir.name}/"]
|
|
63
|
+
count = 0
|
|
64
|
+
|
|
65
|
+
def _traverse(current_dir: Path, prefix: str, depth: int):
|
|
66
|
+
nonlocal count
|
|
67
|
+
if depth > max_depth or count >= max_entries:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
entries = sorted(list(current_dir.iterdir()), key=lambda x: (not x.is_dir(), x.name.lower()))
|
|
72
|
+
except Exception:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
valid_entries = [e for e in entries if not self.is_ignored(e)]
|
|
76
|
+
total = len(valid_entries)
|
|
77
|
+
|
|
78
|
+
for i, entry in enumerate(valid_entries):
|
|
79
|
+
if count >= max_entries:
|
|
80
|
+
lines.append(f"{prefix}└── ... (truncated)")
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
is_last = (i == total - 1)
|
|
84
|
+
connector = "└── " if is_last else "├── "
|
|
85
|
+
sub_prefix = " " if is_last else "│ "
|
|
86
|
+
|
|
87
|
+
if entry.is_dir():
|
|
88
|
+
lines.append(f"{prefix}{connector}{entry.name}/")
|
|
89
|
+
count += 1
|
|
90
|
+
_traverse(entry, prefix + sub_prefix, depth + 1)
|
|
91
|
+
else:
|
|
92
|
+
lines.append(f"{prefix}{connector}{entry.name}")
|
|
93
|
+
count += 1
|
|
94
|
+
|
|
95
|
+
_traverse(self.root_dir, "", 1)
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
def search_relevant_files(self, query: str, top_k: int = 5) -> List[Tuple[str, float, str]]:
|
|
99
|
+
"""
|
|
100
|
+
Rank repository files by relevance to query.
|
|
101
|
+
Returns List of (relative_path, score, reason).
|
|
102
|
+
"""
|
|
103
|
+
query_tokens = tokenize(query)
|
|
104
|
+
if not query_tokens:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
all_files = self.list_all_files()
|
|
108
|
+
scored_files = []
|
|
109
|
+
|
|
110
|
+
for p in all_files:
|
|
111
|
+
try:
|
|
112
|
+
# Only read text files up to 500KB
|
|
113
|
+
if p.stat().st_size > 500_000:
|
|
114
|
+
continue
|
|
115
|
+
content = p.read_text(encoding="utf-8", errors="ignore")
|
|
116
|
+
score, reason = DeterministicRanker.score_file(p, content, query_tokens, self.root_dir)
|
|
117
|
+
if score > 0.5:
|
|
118
|
+
rel_p = str(p.relative_to(self.root_dir)).replace("\\", "/")
|
|
119
|
+
scored_files.append((rel_p, score, reason))
|
|
120
|
+
except Exception:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
scored_files.sort(key=lambda x: x[1], reverse=True)
|
|
124
|
+
return scored_files[:top_k]
|
|
125
|
+
|
|
126
|
+
def read_file_with_lines(self, rel_path: str, max_lines: int = 300) -> str:
|
|
127
|
+
"""Read file content with line numbers."""
|
|
128
|
+
full_path = (self.root_dir / rel_path).resolve()
|
|
129
|
+
if self.secret_guard.is_path_blocked(full_path):
|
|
130
|
+
return f"[ERROR: Access to {rel_path} blocked by security policy]"
|
|
131
|
+
if not full_path.exists():
|
|
132
|
+
return f"[ERROR: File {rel_path} does not exist]"
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
lines = full_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
136
|
+
numbered = [f"{i+1:4d} | {line}" for i, line in enumerate(lines[:max_lines])]
|
|
137
|
+
if len(lines) > max_lines:
|
|
138
|
+
numbered.append(f"... ({len(lines) - max_lines} more lines)")
|
|
139
|
+
return "\n".join(numbered)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
return f"[ERROR reading {rel_path}: {e}]"
|
|
142
|
+
|
|
143
|
+
def build_initial_context(self, task_description: str, contract: Optional[TaskContract] = None) -> str:
|
|
144
|
+
"""Assemble structured, deterministic repository context for the model."""
|
|
145
|
+
tree = self.build_tree_summary()
|
|
146
|
+
relevant = self.search_relevant_files(task_description, top_k=5)
|
|
147
|
+
|
|
148
|
+
context_parts = [
|
|
149
|
+
f"=== REPOSITORY ROOT: {self.root_dir.name} ===",
|
|
150
|
+
"=== DIRECTORY STRUCTURE ===",
|
|
151
|
+
tree,
|
|
152
|
+
"",
|
|
153
|
+
"=== RELEVANT FILES IDENTIFIED ===",
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
if relevant:
|
|
157
|
+
for rel_path, score, reason in relevant:
|
|
158
|
+
context_parts.append(f"- {rel_path} (Score: {score:.1f}, Matches: {reason})")
|
|
159
|
+
else:
|
|
160
|
+
context_parts.append("(No specific file matches identified from initial keywords)")
|
|
161
|
+
|
|
162
|
+
# Include Git status if available
|
|
163
|
+
status = self.git_adapter.get_status()
|
|
164
|
+
if status.is_repo:
|
|
165
|
+
context_parts.extend([
|
|
166
|
+
"",
|
|
167
|
+
f"=== GIT STATUS: Branch '{status.branch}' ===",
|
|
168
|
+
f"Modified: {status.modified_files}",
|
|
169
|
+
f"Untracked: {status.untracked_files}",
|
|
170
|
+
])
|
|
171
|
+
|
|
172
|
+
return "\n".join(context_parts)
|
|
173
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Deterministic relevance ranker for repository files and symbols."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List, Set, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def tokenize(text: str) -> List[str]:
|
|
11
|
+
"""Tokenize text into lowercase alpha-numeric keywords, splitting snake_case and camelCase."""
|
|
12
|
+
# Split camelCase
|
|
13
|
+
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
|
|
14
|
+
# Split snake_case and non-alphanumeric
|
|
15
|
+
tokens = re.findall(r"[a-zA-Z0-9]+", s1.lower())
|
|
16
|
+
# Filter short stopwords
|
|
17
|
+
stopwords = {"the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "with", "by", "of", "is", "it", "this", "that"}
|
|
18
|
+
return [t for t in tokens if len(t) > 1 and t not in stopwords]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeterministicRanker:
|
|
22
|
+
"""Ranks repository files based on task relevance using deterministic symbol and term scoring."""
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def score_file(
|
|
26
|
+
file_path: Path,
|
|
27
|
+
content: str,
|
|
28
|
+
query_tokens: List[str],
|
|
29
|
+
base_dir: Path
|
|
30
|
+
) -> Tuple[float, str]:
|
|
31
|
+
"""
|
|
32
|
+
Compute deterministic relevance score for a file.
|
|
33
|
+
Returns (score, summary_reason).
|
|
34
|
+
"""
|
|
35
|
+
if not query_tokens:
|
|
36
|
+
return 0.0, "No query tokens"
|
|
37
|
+
|
|
38
|
+
rel_path = str(file_path.relative_to(base_dir)).replace("\\", "/")
|
|
39
|
+
path_tokens = tokenize(rel_path)
|
|
40
|
+
content_tokens = tokenize(content[:20000]) # First 20KB for fast scoring
|
|
41
|
+
|
|
42
|
+
query_set = set(query_tokens)
|
|
43
|
+
|
|
44
|
+
# 1. Path Match Score (High weight: 10x)
|
|
45
|
+
path_matches = [t for t in path_tokens if t in query_set]
|
|
46
|
+
path_score = len(path_matches) * 10.0
|
|
47
|
+
|
|
48
|
+
# Exact filename match bonus
|
|
49
|
+
for q in query_tokens:
|
|
50
|
+
if q in file_path.name.lower():
|
|
51
|
+
path_score += 15.0
|
|
52
|
+
|
|
53
|
+
# 2. Symbol Definition Match (defs, classes, functions: 5x)
|
|
54
|
+
symbol_score = 0.0
|
|
55
|
+
symbol_matches = []
|
|
56
|
+
for line in content.splitlines()[:500]:
|
|
57
|
+
trimmed = line.strip()
|
|
58
|
+
if trimmed.startswith(("def ", "class ", "function ", "type ", "interface ", "struct ", "pub fn ")):
|
|
59
|
+
line_tokens = tokenize(trimmed)
|
|
60
|
+
matches = [t for t in line_tokens if t in query_set]
|
|
61
|
+
if matches:
|
|
62
|
+
symbol_score += len(matches) * 5.0
|
|
63
|
+
symbol_matches.extend(matches)
|
|
64
|
+
|
|
65
|
+
# 3. Content Frequency (TF-IDF style term frequency: 1x)
|
|
66
|
+
content_counter = Counter(content_tokens)
|
|
67
|
+
content_score = sum(math.log(1 + content_counter[t]) for t in query_set if t in content_counter)
|
|
68
|
+
|
|
69
|
+
# Penalize test files slightly for implementation queries unless query specifically asks for test
|
|
70
|
+
is_test_query = any("test" in q for q in query_tokens)
|
|
71
|
+
is_test_file = "test" in rel_path.lower()
|
|
72
|
+
if is_test_file and not is_test_query:
|
|
73
|
+
total_score = (path_score + symbol_score + content_score) * 0.6
|
|
74
|
+
else:
|
|
75
|
+
total_score = path_score + symbol_score + content_score
|
|
76
|
+
|
|
77
|
+
reasons = []
|
|
78
|
+
if path_matches:
|
|
79
|
+
reasons.append(f"path matches [{', '.join(set(path_matches))}]")
|
|
80
|
+
if symbol_matches:
|
|
81
|
+
reasons.append(f"symbols [{', '.join(set(symbol_matches))}]")
|
|
82
|
+
if content_score > 0:
|
|
83
|
+
reasons.append(f"term frequency score {content_score:.1f}")
|
|
84
|
+
|
|
85
|
+
reason_str = ", ".join(reasons) if reasons else "low keyword frequency"
|
|
86
|
+
return round(total_score, 2), reason_str
|
|
87
|
+
|