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.
Files changed (74) hide show
  1. terminal_agent/__init__.py +7 -0
  2. terminal_agent/__main__.py +7 -0
  3. terminal_agent/agent/__init__.py +9 -0
  4. terminal_agent/agent/loop.py +338 -0
  5. terminal_agent/checkpoints/__init__.py +9 -0
  6. terminal_agent/checkpoints/manager.py +151 -0
  7. terminal_agent/cli/commands/__init__.py +26 -0
  8. terminal_agent/cli/commands/checkpoint.py +76 -0
  9. terminal_agent/cli/commands/clean.py +170 -0
  10. terminal_agent/cli/commands/config_cmd.py +32 -0
  11. terminal_agent/cli/commands/diff.py +45 -0
  12. terminal_agent/cli/commands/doctor.py +136 -0
  13. terminal_agent/cli/commands/resume.py +109 -0
  14. terminal_agent/cli/commands/run.py +184 -0
  15. terminal_agent/cli/commands/setup.py +229 -0
  16. terminal_agent/cli/commands/status.py +62 -0
  17. terminal_agent/cli/commands/test.py +38 -0
  18. terminal_agent/cli/commands/trace.py +60 -0
  19. terminal_agent/cli/main.py +94 -0
  20. terminal_agent/cli/theme.py +55 -0
  21. terminal_agent/cli/ui.py +147 -0
  22. terminal_agent/config/__init__.py +30 -0
  23. terminal_agent/config/schema.py +100 -0
  24. terminal_agent/config/settings.py +85 -0
  25. terminal_agent/context/__init__.py +11 -0
  26. terminal_agent/context/engine.py +173 -0
  27. terminal_agent/context/ranker.py +87 -0
  28. terminal_agent/git/__init__.py +10 -0
  29. terminal_agent/git/adapter.py +219 -0
  30. terminal_agent/planner/__init__.py +10 -0
  31. terminal_agent/planner/contract.py +58 -0
  32. terminal_agent/planner/planner.py +30 -0
  33. terminal_agent/providers/__init__.py +30 -0
  34. terminal_agent/providers/anthropic_provider.py +141 -0
  35. terminal_agent/providers/base.py +57 -0
  36. terminal_agent/providers/detector.py +397 -0
  37. terminal_agent/providers/factory.py +29 -0
  38. terminal_agent/providers/gemini_provider.py +130 -0
  39. terminal_agent/providers/mock_provider.py +80 -0
  40. terminal_agent/providers/ollama_provider.py +124 -0
  41. terminal_agent/providers/openai_provider.py +124 -0
  42. terminal_agent/recovery/__init__.py +10 -0
  43. terminal_agent/recovery/classifier.py +64 -0
  44. terminal_agent/recovery/strategies.py +62 -0
  45. terminal_agent/sandbox/__init__.py +13 -0
  46. terminal_agent/sandbox/base.py +41 -0
  47. terminal_agent/sandbox/docker.py +129 -0
  48. terminal_agent/sandbox/local.py +129 -0
  49. terminal_agent/security/__init__.py +13 -0
  50. terminal_agent/security/classifier.py +151 -0
  51. terminal_agent/security/policy.py +51 -0
  52. terminal_agent/security/secrets.py +105 -0
  53. terminal_agent/session/__init__.py +33 -0
  54. terminal_agent/session/manager.py +195 -0
  55. terminal_agent/session/models.py +160 -0
  56. terminal_agent/telemetry/__init__.py +12 -0
  57. terminal_agent/telemetry/events.py +87 -0
  58. terminal_agent/telemetry/metrics.py +38 -0
  59. terminal_agent/tools/__init__.py +33 -0
  60. terminal_agent/tools/base.py +59 -0
  61. terminal_agent/tools/checkpoint_tools.py +72 -0
  62. terminal_agent/tools/command_tools.py +125 -0
  63. terminal_agent/tools/file_tools.py +340 -0
  64. terminal_agent/tools/git_tools.py +124 -0
  65. terminal_agent/tools/registry.py +109 -0
  66. terminal_agent/verifier/__init__.py +13 -0
  67. terminal_agent/verifier/assertions.py +115 -0
  68. terminal_agent/verifier/engine.py +125 -0
  69. terminal_agent/verifier/runners.py +80 -0
  70. terminal_agent_cli-0.1.0.dist-info/METADATA +438 -0
  71. terminal_agent_cli-0.1.0.dist-info/RECORD +74 -0
  72. terminal_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  73. terminal_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  74. terminal_agent_cli-0.1.0.dist-info/licenses/LICENSE +23 -0
@@ -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
+
@@ -0,0 +1,10 @@
1
+ """Git adapter package for Terminal Agent."""
2
+
3
+ from terminal_agent.git.adapter import GitAdapter, GitStatusResult, GitDiffStat
4
+
5
+ __all__ = [
6
+ "GitAdapter",
7
+ "GitStatusResult",
8
+ "GitDiffStat",
9
+ ]
10
+