polymath-agent 0.4.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.
- polymath/__init__.py +2 -0
- polymath/adapters/__init__.py +7 -0
- polymath/adapters/base.py +175 -0
- polymath/adapters/claude.py +280 -0
- polymath/adapters/gemini.py +186 -0
- polymath/adapters/ollama.py +117 -0
- polymath/adapters/openai_adapter.py +168 -0
- polymath/bootstrap.py +159 -0
- polymath/command_registry.py +41 -0
- polymath/command_service.py +572 -0
- polymath/compressor.py +90 -0
- polymath/config.py +293 -0
- polymath/context_manager.py +76 -0
- polymath/context_store.py +336 -0
- polymath/detector.py +442 -0
- polymath/domain.py +78 -0
- polymath/execution_service.py +325 -0
- polymath/main.py +1293 -0
- polymath/memory/__init__.py +15 -0
- polymath/memory/chunker.py +6 -0
- polymath/memory/embedder.py +179 -0
- polymath/memory/migrate.py +2 -0
- polymath/memory/retriever.py +2 -0
- polymath/memory/store.py +9 -0
- polymath/memory/sync.py +2 -0
- polymath/memory/writer.py +9 -0
- polymath/model_policy.py +172 -0
- polymath/orchestrator/__init__.py +68 -0
- polymath/orchestrator/attempt_ledger.py +34 -0
- polymath/orchestrator/ensemble.py +229 -0
- polymath/orchestrator/fanout.py +322 -0
- polymath/orchestrator/output_policy.py +61 -0
- polymath/orchestrator/race.py +311 -0
- polymath/orchestrator/run_controller.py +91 -0
- polymath/orchestrator/speculative_review.py +120 -0
- polymath/orchestrator/state_responder.py +184 -0
- polymath/orchestrator/worker_pool.py +37 -0
- polymath/permissions.py +82 -0
- polymath/pipeline.py +700 -0
- polymath/project_config.py +229 -0
- polymath/project_runtime.py +109 -0
- polymath/router.py +127 -0
- polymath/setup_wizard.py +106 -0
- polymath/slash_commands.py +566 -0
- polymath/subagents.py +486 -0
- polymath/tools.py +333 -0
- polymath/ui_state.py +84 -0
- polymath/workspace.py +66 -0
- polymath_agent-0.4.0.dist-info/METADATA +693 -0
- polymath_agent-0.4.0.dist-info/RECORD +54 -0
- polymath_agent-0.4.0.dist-info/WHEEL +5 -0
- polymath_agent-0.4.0.dist-info/entry_points.txt +2 -0
- polymath_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
- polymath_agent-0.4.0.dist-info/top_level.txt +1 -0
polymath/tools.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool (function) support for polymath models.
|
|
3
|
+
Provides a registry of tools that models can call to interact with the environment.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from polymath.config import CONFIG_DIR
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── Scope ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
#
|
|
20
|
+
# Reading is not free. The model can put whatever it reads into its output,
|
|
21
|
+
# and that output goes to the next model, the transcript, and the brain. So a
|
|
22
|
+
# read outside the project is an escalation and has to be asked about, and the
|
|
23
|
+
# directory holding the API keys is refused however the call arrives.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
#: Markers that identify a real project root, in preference order.
|
|
27
|
+
_PROJECT_MARKERS = (".polymath", ".git")
|
|
28
|
+
|
|
29
|
+
#: Paths that are refused wherever they sit, whatever the project root is.
|
|
30
|
+
#: Position cannot launder these: a project root that happens to contain
|
|
31
|
+
#: your ssh keys does not make reading them ordinary. Relative to home
|
|
32
|
+
#: unless absolute.
|
|
33
|
+
_DENIED_HOME_PATHS = (
|
|
34
|
+
".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud",
|
|
35
|
+
".kube", ".docker/config.json", ".netrc", ".npmrc", ".pypirc",
|
|
36
|
+
".git-credentials", ".bash_history", ".zsh_history",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
#: File names that carry secrets wherever they appear, including inside
|
|
40
|
+
#: the project root.
|
|
41
|
+
_DENIED_NAMES = (".env", ".netrc", ".git-credentials")
|
|
42
|
+
_DENIED_PREFIXES = ("id_rsa", "id_ed25519", "id_ecdsa", "id_dsa")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def workspace_root() -> Path | None:
|
|
46
|
+
"""The project the user is working in, or None if there isn't one.
|
|
47
|
+
|
|
48
|
+
Anchored to a real project marker rather than to the current directory.
|
|
49
|
+
Polymath is a REPL you launch from anywhere, and launching it from home
|
|
50
|
+
used to make the whole home directory the project, so ssh keys and .env
|
|
51
|
+
files counted as ordinary in-project reads.
|
|
52
|
+
|
|
53
|
+
None means no free-read zone: every read is asked about. That is the
|
|
54
|
+
right default for "I started this in a directory that is not a project".
|
|
55
|
+
"""
|
|
56
|
+
start = Path.cwd().resolve()
|
|
57
|
+
for directory in [start, *start.parents]:
|
|
58
|
+
if directory == Path.home():
|
|
59
|
+
# Home is never a project root, even with a dotfiles repo in it.
|
|
60
|
+
break
|
|
61
|
+
for marker in _PROJECT_MARKERS:
|
|
62
|
+
if (directory / marker).exists():
|
|
63
|
+
return directory
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_denied(path: str) -> bool:
|
|
68
|
+
"""True for paths nothing the model does should reach, wherever they are."""
|
|
69
|
+
try:
|
|
70
|
+
target = resolve_path(path)
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
return True
|
|
73
|
+
if target.name in _DENIED_NAMES:
|
|
74
|
+
return True
|
|
75
|
+
if any(target.name.startswith(prefix) for prefix in _DENIED_PREFIXES):
|
|
76
|
+
return True
|
|
77
|
+
home = Path.home().resolve()
|
|
78
|
+
for relative in _DENIED_HOME_PATHS:
|
|
79
|
+
candidate = (home / relative)
|
|
80
|
+
try:
|
|
81
|
+
if target == candidate or target.is_relative_to(candidate):
|
|
82
|
+
return True
|
|
83
|
+
except (OSError, ValueError):
|
|
84
|
+
continue
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_path(path: str) -> Path:
|
|
89
|
+
"""Absolute, symlink-resolved. Resolving matters: a link inside the
|
|
90
|
+
workspace can point anywhere, and a string prefix test would not notice."""
|
|
91
|
+
return Path(path).expanduser().resolve()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_inside_workspace(path: str) -> bool:
|
|
95
|
+
"""True only when there is a project root and the path resolves inside it."""
|
|
96
|
+
root = workspace_root()
|
|
97
|
+
if root is None:
|
|
98
|
+
return False
|
|
99
|
+
try:
|
|
100
|
+
return resolve_path(path).is_relative_to(root)
|
|
101
|
+
except (OSError, ValueError):
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_protected(path: str) -> bool:
|
|
106
|
+
"""True for paths nothing the model does has any business touching.
|
|
107
|
+
|
|
108
|
+
~/.polymath holds the API keys for every provider. Reading it once puts
|
|
109
|
+
them in the transcript, which is then sent to the next model.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
if resolve_path(path).is_relative_to(CONFIG_DIR.resolve()):
|
|
113
|
+
return True
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
return False
|
|
116
|
+
return is_denied(path)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
#: Arguments naming a filesystem path, per tool.
|
|
120
|
+
_PATH_ARGUMENT = {
|
|
121
|
+
"read_file": "file_path",
|
|
122
|
+
"write_file": "file_path",
|
|
123
|
+
"list_directory": "path",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def permission_scope(tool_name: str, arguments: Dict[str, Any] | None) -> str:
|
|
128
|
+
"""Why this particular call needs asking about, or "" if it does not.
|
|
129
|
+
|
|
130
|
+
This is per-call, not per-tool: reading inside the project is ordinary,
|
|
131
|
+
reading your ssh key is not, and both are `read_file`.
|
|
132
|
+
"""
|
|
133
|
+
argument = _PATH_ARGUMENT.get(tool_name)
|
|
134
|
+
if not argument:
|
|
135
|
+
return ""
|
|
136
|
+
path = (arguments or {}).get(argument)
|
|
137
|
+
if not isinstance(path, str) or not path:
|
|
138
|
+
return ""
|
|
139
|
+
if is_protected(path):
|
|
140
|
+
return "a credentials path, which the model is never given"
|
|
141
|
+
if not is_inside_workspace(path):
|
|
142
|
+
if workspace_root() is None:
|
|
143
|
+
return "outside any project — polymath was not started in one"
|
|
144
|
+
return "outside the current project"
|
|
145
|
+
return ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class Tool:
|
|
150
|
+
name: str
|
|
151
|
+
description: str
|
|
152
|
+
parameters: Dict[str, Any] # JSON schema for arguments
|
|
153
|
+
callable: Callable[..., Any]
|
|
154
|
+
requires_permission: bool = False
|
|
155
|
+
|
|
156
|
+
def needs_permission(self, arguments: Dict[str, Any] | None = None) -> bool:
|
|
157
|
+
"""Whether this call must be approved. Tools that always need consent
|
|
158
|
+
say so; the rest need it only when the call leaves the project."""
|
|
159
|
+
if self.requires_permission:
|
|
160
|
+
return True
|
|
161
|
+
return bool(permission_scope(self.name, arguments))
|
|
162
|
+
|
|
163
|
+
def to_json_schema(self) -> Dict[str, Any]:
|
|
164
|
+
"""Convert to standard JSON schema format."""
|
|
165
|
+
return {
|
|
166
|
+
"name": self.name,
|
|
167
|
+
"description": self.description,
|
|
168
|
+
"parameters": self.parameters,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ToolRegistry:
|
|
173
|
+
def __init__(self):
|
|
174
|
+
self._tools: Dict[str, Tool] = {}
|
|
175
|
+
|
|
176
|
+
def register(self, tool: Tool):
|
|
177
|
+
self._tools[tool.name] = tool
|
|
178
|
+
|
|
179
|
+
def get(self, name: str) -> Optional[Tool]:
|
|
180
|
+
return self._tools.get(name)
|
|
181
|
+
|
|
182
|
+
def list_tools(self) -> List[Tool]:
|
|
183
|
+
return list(self._tools.values())
|
|
184
|
+
|
|
185
|
+
async def execute(self, name: str, args: Dict[str, Any]) -> str:
|
|
186
|
+
"""Execute tool and return result as string."""
|
|
187
|
+
tool = self.get(name)
|
|
188
|
+
if not tool:
|
|
189
|
+
return f"Error: Tool '{name}' not found."
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
# Handle sync call for now; can be expanded for async if needed
|
|
193
|
+
result = tool.callable(**args)
|
|
194
|
+
if result is None:
|
|
195
|
+
return "Success"
|
|
196
|
+
if isinstance(result, (dict, list)):
|
|
197
|
+
return json.dumps(result, indent=2)
|
|
198
|
+
return str(result)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
return f"Error executing '{name}': {str(e)}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Built-in Tools ────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def list_directory(path: str = ".") -> List[str]:
|
|
206
|
+
"""Lists files and directories in a given path."""
|
|
207
|
+
if is_protected(path):
|
|
208
|
+
return [f"Error: refused — {path} is inside polymath's config directory."]
|
|
209
|
+
return os.listdir(path)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def read_file(file_path: str) -> str:
|
|
213
|
+
"""Reads the content of a file."""
|
|
214
|
+
if is_protected(file_path):
|
|
215
|
+
return (
|
|
216
|
+
f"Error: refused — {file_path} is inside polymath's config "
|
|
217
|
+
"directory, which holds your API keys."
|
|
218
|
+
)
|
|
219
|
+
with open(file_path, "r") as f:
|
|
220
|
+
return f.read()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def write_file(file_path: str, content: str) -> str:
|
|
224
|
+
"""Writes content to a file (restricted to current workspace).
|
|
225
|
+
|
|
226
|
+
The path is resolved before the test, so a symlink inside the project
|
|
227
|
+
that points elsewhere is refused. The previous version compared the
|
|
228
|
+
textual absolute path, which normalises ".." but follows no links, so
|
|
229
|
+
`project/link.txt -> /etc/passwd` passed the check.
|
|
230
|
+
"""
|
|
231
|
+
if is_protected(file_path):
|
|
232
|
+
return (
|
|
233
|
+
f"Error: refused — {file_path} is inside polymath's config "
|
|
234
|
+
"directory, which holds your API keys."
|
|
235
|
+
)
|
|
236
|
+
root = workspace_root()
|
|
237
|
+
target = resolve_path(file_path)
|
|
238
|
+
if root is None:
|
|
239
|
+
return (
|
|
240
|
+
"Error: write_file needs a project. polymath was started outside "
|
|
241
|
+
"any .polymath folder or git repository, so there is nowhere it "
|
|
242
|
+
"is safe to write."
|
|
243
|
+
)
|
|
244
|
+
if target != root and not target.is_relative_to(root):
|
|
245
|
+
return (
|
|
246
|
+
f"Error: write_file is restricted to current workspace ({root}). "
|
|
247
|
+
f"Refused: {target}"
|
|
248
|
+
)
|
|
249
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
target.write_text(content)
|
|
251
|
+
return f"File written to {file_path}"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def run_shell_command(command: str) -> str:
|
|
255
|
+
"""Runs a shell command and returns stdout + stderr."""
|
|
256
|
+
import shlex
|
|
257
|
+
try:
|
|
258
|
+
args = shlex.split(command)
|
|
259
|
+
except ValueError as e:
|
|
260
|
+
return f"Error: Invalid command syntax: {e}"
|
|
261
|
+
try:
|
|
262
|
+
result = subprocess.run(
|
|
263
|
+
args,
|
|
264
|
+
shell=False,
|
|
265
|
+
capture_output=True,
|
|
266
|
+
text=True,
|
|
267
|
+
timeout=30
|
|
268
|
+
)
|
|
269
|
+
output = result.stdout
|
|
270
|
+
if result.stderr:
|
|
271
|
+
output += f"\nErrors:\n{result.stderr}"
|
|
272
|
+
return output
|
|
273
|
+
except Exception as e:
|
|
274
|
+
return str(e)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ── Global Registry ───────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
registry = ToolRegistry()
|
|
280
|
+
|
|
281
|
+
registry.register(Tool(
|
|
282
|
+
name="list_directory",
|
|
283
|
+
description="List files and directories in a given path.",
|
|
284
|
+
parameters={
|
|
285
|
+
"type": "object",
|
|
286
|
+
"properties": {
|
|
287
|
+
"path": {"type": "string", "description": "The directory path (default: '.')"}
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
callable=list_directory
|
|
291
|
+
))
|
|
292
|
+
|
|
293
|
+
registry.register(Tool(
|
|
294
|
+
name="read_file",
|
|
295
|
+
description="Read the contents of a file.",
|
|
296
|
+
parameters={
|
|
297
|
+
"type": "object",
|
|
298
|
+
"properties": {
|
|
299
|
+
"file_path": {"type": "string", "description": "The path to the file to read."}
|
|
300
|
+
},
|
|
301
|
+
"required": ["file_path"]
|
|
302
|
+
},
|
|
303
|
+
callable=read_file
|
|
304
|
+
))
|
|
305
|
+
|
|
306
|
+
registry.register(Tool(
|
|
307
|
+
name="write_file",
|
|
308
|
+
description="Write content to a file (creates parent directories if needed).",
|
|
309
|
+
parameters={
|
|
310
|
+
"type": "object",
|
|
311
|
+
"properties": {
|
|
312
|
+
"file_path": {"type": "string", "description": "The path to write the file to."},
|
|
313
|
+
"content": {"type": "string", "description": "The content to write."}
|
|
314
|
+
},
|
|
315
|
+
"required": ["file_path", "content"]
|
|
316
|
+
},
|
|
317
|
+
callable=write_file,
|
|
318
|
+
requires_permission=True
|
|
319
|
+
))
|
|
320
|
+
|
|
321
|
+
registry.register(Tool(
|
|
322
|
+
name="run_shell_command",
|
|
323
|
+
description="Execute a shell command in the local environment.",
|
|
324
|
+
parameters={
|
|
325
|
+
"type": "object",
|
|
326
|
+
"properties": {
|
|
327
|
+
"command": {"type": "string", "description": "The command to run."}
|
|
328
|
+
},
|
|
329
|
+
"required": ["command"]
|
|
330
|
+
},
|
|
331
|
+
callable=run_shell_command,
|
|
332
|
+
requires_permission=True
|
|
333
|
+
))
|
polymath/ui_state.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from polymath.domain import Project
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def prompt_toolbar(
|
|
13
|
+
project: Project,
|
|
14
|
+
queued_inputs: deque[str | None],
|
|
15
|
+
active: bool,
|
|
16
|
+
session_id: str,
|
|
17
|
+
) -> str:
|
|
18
|
+
queue_preview = [q for q in list(queued_inputs)[-2:] if q]
|
|
19
|
+
queue_text = " | ".join(queue_preview)[:120] if queue_preview else "empty"
|
|
20
|
+
repo_name = project.repo_root.name if project.repo_root else "no-repo"
|
|
21
|
+
branch = project.repo_branch or "-"
|
|
22
|
+
dirty = "dirty" if project.repo_dirty else "clean"
|
|
23
|
+
mode = "busy" if active else "idle"
|
|
24
|
+
source = "shared" if project.shared else ("local" if project.name else "none")
|
|
25
|
+
return (
|
|
26
|
+
f" session {session_id} | project {project.name or '-'} ({source}) | "
|
|
27
|
+
f"git {repo_name}:{branch}:{dirty} | queue {len(queued_inputs)} [{queue_text}] | {mode} "
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_queue_table(queued_inputs: list[str]) -> Table:
|
|
32
|
+
table = Table(title="Queued Commands", show_header=True, header_style="bold cyan")
|
|
33
|
+
table.add_column("#", style="dim", width=4)
|
|
34
|
+
table.add_column("Command", style="white")
|
|
35
|
+
for idx, item in enumerate(queued_inputs, 1):
|
|
36
|
+
table.add_row(str(idx), item)
|
|
37
|
+
return table
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RunStatus:
|
|
42
|
+
phase: str
|
|
43
|
+
model: str
|
|
44
|
+
note: str = ""
|
|
45
|
+
reviewer: str = ""
|
|
46
|
+
queue_count: int = 0
|
|
47
|
+
context_line: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_run_status(status: RunStatus) -> Panel:
|
|
51
|
+
lines = [
|
|
52
|
+
f"[bold cyan]≡ Running Agent[/bold cyan]",
|
|
53
|
+
f"[dim]phase:[/dim] {status.phase} [dim]model:[/dim] {status.model} [dim]queue:[/dim] {status.queue_count}",
|
|
54
|
+
]
|
|
55
|
+
if status.reviewer:
|
|
56
|
+
lines.append(f"[dim]reviewer:[/dim] {status.reviewer}")
|
|
57
|
+
if status.context_line:
|
|
58
|
+
lines.append(f"[dim]{status.context_line}[/dim]")
|
|
59
|
+
if status.note:
|
|
60
|
+
lines.append(status.note)
|
|
61
|
+
return Panel("\n".join(lines), border_style="cyan")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class CompletionSummary:
|
|
66
|
+
session_id: str
|
|
67
|
+
primary_model: str
|
|
68
|
+
reviewer_model: str = ""
|
|
69
|
+
token_line: str = ""
|
|
70
|
+
queue_count: int = 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_completion_summary(summary: CompletionSummary) -> Panel:
|
|
74
|
+
lines = [
|
|
75
|
+
"[bold green]✓ Agent Complete[/bold green]",
|
|
76
|
+
f"[dim]session:[/dim] {summary.session_id}",
|
|
77
|
+
f"[dim]primary:[/dim] {summary.primary_model}",
|
|
78
|
+
]
|
|
79
|
+
if summary.reviewer_model:
|
|
80
|
+
lines.append(f"[dim]reviewer:[/dim] {summary.reviewer_model}")
|
|
81
|
+
if summary.token_line:
|
|
82
|
+
lines.append(f"[dim]{summary.token_line}[/dim]")
|
|
83
|
+
lines.append(f"[dim]queue remaining:[/dim] {summary.queue_count}")
|
|
84
|
+
return Panel("\n".join(lines), border_style="green")
|
polymath/workspace.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workspace awareness for polymath.
|
|
3
|
+
Scans the current directory to provide context to the models.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def scan_workspace(root_dir: str = ".") -> Dict[str, Any]:
|
|
13
|
+
"""Scans the workspace for structure and key files."""
|
|
14
|
+
root = Path(root_dir).resolve()
|
|
15
|
+
|
|
16
|
+
# 1. Directory Structure (shallow)
|
|
17
|
+
structure = []
|
|
18
|
+
try:
|
|
19
|
+
for item in root.iterdir():
|
|
20
|
+
if item.name.startswith(".") or item.name == "__pycache__":
|
|
21
|
+
continue
|
|
22
|
+
if item.is_dir():
|
|
23
|
+
structure.append(f" [DIR] {item.name}/")
|
|
24
|
+
else:
|
|
25
|
+
structure.append(f" [FILE] {item.name}")
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# 2. Key Files
|
|
30
|
+
key_files = ["README.md", "pyproject.toml", "package.json", "requirements.txt", "setup.py"]
|
|
31
|
+
file_contents = {}
|
|
32
|
+
for kf in key_files:
|
|
33
|
+
p = root / kf
|
|
34
|
+
if p.exists() and p.is_file():
|
|
35
|
+
try:
|
|
36
|
+
# Read first 2KB of each key file
|
|
37
|
+
with open(p, "r") as f:
|
|
38
|
+
file_contents[kf] = f.read(2048)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"root": str(root),
|
|
44
|
+
"structure": structure,
|
|
45
|
+
"key_files": file_contents
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_workspace_context(root_dir: str = ".") -> str:
|
|
50
|
+
"""Formats workspace scan into a string for system prompts."""
|
|
51
|
+
data = scan_workspace(root_dir)
|
|
52
|
+
|
|
53
|
+
lines = [
|
|
54
|
+
f"CURRENT WORKSPACE: {data['root']}",
|
|
55
|
+
"DIRECTORY STRUCTURE:",
|
|
56
|
+
]
|
|
57
|
+
lines.extend(data["structure"][:30]) # Limit to 30 items
|
|
58
|
+
|
|
59
|
+
if data["key_files"]:
|
|
60
|
+
lines.append("\nKEY FILE CONTENTS:")
|
|
61
|
+
for name, content in data["key_files"].items():
|
|
62
|
+
lines.append(f"--- {name} ---")
|
|
63
|
+
lines.append(content)
|
|
64
|
+
lines.append("-" * (len(name) + 8))
|
|
65
|
+
|
|
66
|
+
return "\n".join(lines)
|