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/config.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path.home() / ".polymath"
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
13
|
+
DB_FILE = CONFIG_DIR / "context.db"
|
|
14
|
+
SESSIONS_DIR = CONFIG_DIR / "sessions"
|
|
15
|
+
PROJECTS_DIR = CONFIG_DIR / "projects"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskType(Enum):
|
|
19
|
+
CODE = "code"
|
|
20
|
+
ANALYSIS = "analysis"
|
|
21
|
+
CREATIVE = "creative"
|
|
22
|
+
MATH = "math"
|
|
23
|
+
RESEARCH = "research"
|
|
24
|
+
GENERAL = "general"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TaskComplexity(Enum):
|
|
28
|
+
SIMPLE = "simple" # direct answer, skip plan/clarify
|
|
29
|
+
MEDIUM = "medium" # one clarify round
|
|
30
|
+
COMPLEX = "complex" # full pipeline
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CostTier(Enum):
|
|
34
|
+
FREE = 0
|
|
35
|
+
CHEAP = 1
|
|
36
|
+
PREMIUM = 2
|
|
37
|
+
EXPENSIVE = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ModelInfo:
|
|
42
|
+
id: str
|
|
43
|
+
provider: str
|
|
44
|
+
display_name: str
|
|
45
|
+
cost_tier: CostTier
|
|
46
|
+
context_window: int
|
|
47
|
+
speed: int # 1–5
|
|
48
|
+
quality: int # 1–5
|
|
49
|
+
capabilities: list[str]
|
|
50
|
+
input_cost_per_1k: float
|
|
51
|
+
output_cost_per_1k: float
|
|
52
|
+
available: bool = False
|
|
53
|
+
#: Whether the model accepts a sampling temperature. Claude removed
|
|
54
|
+
#: temperature/top_p/top_k with the 4.7 generation and rejects them with
|
|
55
|
+
#: a 400, so this is per-model and not per-provider.
|
|
56
|
+
supports_temperature: bool = True
|
|
57
|
+
|
|
58
|
+
def supports(self, task_type: TaskType) -> bool:
|
|
59
|
+
return task_type.value in self.capabilities or "general" in self.capabilities
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Full model registry — availability set at runtime by detector
|
|
63
|
+
MODEL_REGISTRY: list[ModelInfo] = [
|
|
64
|
+
# ── Claude ────────────────────────────────────────────────────────────────
|
|
65
|
+
# IDs are complete as written — no date suffix. These models reject
|
|
66
|
+
# temperature, hence supports_temperature=False; see ClaudeAdapter.
|
|
67
|
+
ModelInfo("claude-opus-5", "claude", "Claude Opus 5",
|
|
68
|
+
CostTier.EXPENSIVE, 1_000_000, 3, 5,
|
|
69
|
+
["code", "analysis", "creative", "math", "research", "general"],
|
|
70
|
+
0.005, 0.025, supports_temperature=False),
|
|
71
|
+
ModelInfo("claude-sonnet-5", "claude", "Claude Sonnet 5",
|
|
72
|
+
CostTier.PREMIUM, 1_000_000, 4, 4,
|
|
73
|
+
["code", "analysis", "creative", "math", "research", "general"],
|
|
74
|
+
0.002, 0.010, supports_temperature=False),
|
|
75
|
+
ModelInfo("claude-haiku-4-5", "claude", "Claude Haiku 4.5",
|
|
76
|
+
CostTier.CHEAP, 200_000, 5, 3,
|
|
77
|
+
["code", "analysis", "general"],
|
|
78
|
+
0.001, 0.005),
|
|
79
|
+
|
|
80
|
+
# ── Gemini ────────────────────────────────────────────────────────────────
|
|
81
|
+
ModelInfo("gemini-2.0-flash", "gemini", "Gemini 2.0 Flash",
|
|
82
|
+
CostTier.FREE, 1_000_000, 5, 3,
|
|
83
|
+
["code", "analysis", "general"],
|
|
84
|
+
0.0, 0.0),
|
|
85
|
+
ModelInfo("gemini-1.5-pro", "gemini", "Gemini 1.5 Pro",
|
|
86
|
+
CostTier.PREMIUM, 2_000_000, 3, 4,
|
|
87
|
+
["code", "analysis", "creative", "math", "research", "general"],
|
|
88
|
+
0.00125, 0.005),
|
|
89
|
+
ModelInfo("gemini-1.5-flash", "gemini", "Gemini 1.5 Flash",
|
|
90
|
+
CostTier.CHEAP, 1_000_000, 5, 3,
|
|
91
|
+
["code", "analysis", "general"],
|
|
92
|
+
0.000075, 0.0003),
|
|
93
|
+
|
|
94
|
+
# ── OpenAI ────────────────────────────────────────────────────────────────
|
|
95
|
+
ModelInfo("gpt-4o", "openai", "GPT-4o",
|
|
96
|
+
CostTier.PREMIUM, 128_000, 4, 5,
|
|
97
|
+
["code", "analysis", "creative", "math", "vision", "general"],
|
|
98
|
+
0.0025, 0.01),
|
|
99
|
+
ModelInfo("gpt-4o-mini", "openai", "GPT-4o Mini",
|
|
100
|
+
CostTier.CHEAP, 128_000, 5, 3,
|
|
101
|
+
["code", "analysis", "general"],
|
|
102
|
+
0.00015, 0.0006),
|
|
103
|
+
ModelInfo("o1-mini", "openai", "o1 Mini",
|
|
104
|
+
CostTier.CHEAP, 128_000, 3, 4,
|
|
105
|
+
["code", "math", "analysis"],
|
|
106
|
+
0.003, 0.012),
|
|
107
|
+
|
|
108
|
+
# ── Ollama (local) ────────────────────────────────────────────────────────
|
|
109
|
+
ModelInfo("llama3.2:3b", "ollama", "Llama 3.2 3B",
|
|
110
|
+
CostTier.FREE, 128_000, 4, 2,
|
|
111
|
+
["general", "code"],
|
|
112
|
+
0.0, 0.0),
|
|
113
|
+
ModelInfo("mistral:latest", "ollama", "Mistral 7B",
|
|
114
|
+
CostTier.FREE, 32_000, 4, 3,
|
|
115
|
+
["code", "analysis", "general"],
|
|
116
|
+
0.0, 0.0),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def get_model(model_id: str) -> ModelInfo | None:
|
|
120
|
+
"""Registry row for a model id, or None if we have never heard of it."""
|
|
121
|
+
for model in MODEL_REGISTRY:
|
|
122
|
+
if model.id == model_id:
|
|
123
|
+
return model
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Priority sort keys ────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
PRIORITY_PROFILES: dict[str, Callable[[ModelInfo], tuple]] = {
|
|
130
|
+
"quality-first": lambda m: (-m.quality, -m.context_window, -m.speed, m.cost_tier.value),
|
|
131
|
+
"cost-first": lambda m: (m.cost_tier.value, -m.quality, -m.speed),
|
|
132
|
+
"speed-first": lambda m: (-m.speed, m.cost_tier.value, -m.quality),
|
|
133
|
+
"balanced": lambda m: (-(m.quality + m.speed) / 2, m.cost_tier.value),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── Persistent user config ────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
DEFAULT_CONFIG = {
|
|
140
|
+
"priority_profile": "quality-first",
|
|
141
|
+
"review_rounds": 2,
|
|
142
|
+
"context_window_messages": 8,
|
|
143
|
+
"api_keys": {}, # provider → key
|
|
144
|
+
"ollama_url": "http://localhost:11434",
|
|
145
|
+
"lmstudio_url": "http://localhost:1234",
|
|
146
|
+
# Plan-first is Polymath's headline UX: every non-`--ask` session pauses
|
|
147
|
+
# after the plan step for explicit approval. Boris-pattern: a good plan
|
|
148
|
+
# is the difference between a great session and a wasted one.
|
|
149
|
+
"plan_mode": "always", # always | complexity | off
|
|
150
|
+
"memory": {
|
|
151
|
+
"embedding_provider": "gemini", # gemini | openai
|
|
152
|
+
"embedding_model": "text-embedding-004",
|
|
153
|
+
"retrieval_k": 12,
|
|
154
|
+
"dedup_threshold": 0.92,
|
|
155
|
+
# Writeback OFF by default. Use /learn-from for deliberate corrections
|
|
156
|
+
# instead — see slash_commands._run_learn_from.
|
|
157
|
+
"auto_writeback": False,
|
|
158
|
+
"writeback_min_chars": 200,
|
|
159
|
+
"stale_candidate_days": 30,
|
|
160
|
+
# Head start the primary model gets before the failover model is
|
|
161
|
+
# also fired. See orchestrator/race.py.
|
|
162
|
+
"race_delay_seconds": 5.0,
|
|
163
|
+
# Run the reviewer concurrently against partial output. Off until
|
|
164
|
+
# it has proved itself. See orchestrator/speculative_review.py.
|
|
165
|
+
"speculative_review": False,
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def merge_defaults(defaults: dict, saved: dict) -> dict:
|
|
171
|
+
"""Overlay saved settings on the defaults, nested blocks included.
|
|
172
|
+
|
|
173
|
+
A one-level merge replaced a whole nested block whenever the saved file
|
|
174
|
+
had one, so saving a single "memory" setting silently dropped every
|
|
175
|
+
other memory default. Values the user has set win; everything else falls
|
|
176
|
+
through to the default.
|
|
177
|
+
"""
|
|
178
|
+
merged = dict(defaults)
|
|
179
|
+
for key, value in saved.items():
|
|
180
|
+
current = merged.get(key)
|
|
181
|
+
if isinstance(current, dict) and isinstance(value, dict):
|
|
182
|
+
merged[key] = merge_defaults(current, value)
|
|
183
|
+
else:
|
|
184
|
+
merged[key] = value
|
|
185
|
+
return merged
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
#: The config file holds every provider's API key, so it is ours alone to
|
|
189
|
+
#: read. Both modes are enforced on each load, which also repairs a file
|
|
190
|
+
#: written by an older version under the default umask (0644).
|
|
191
|
+
CONFIG_DIR_MODE = 0o700
|
|
192
|
+
CONFIG_FILE_MODE = 0o600
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def atomic_write_text(path: Path, text: str, mode: int | None = None) -> None:
|
|
196
|
+
"""Replace a file's contents in one step.
|
|
197
|
+
|
|
198
|
+
A plain write truncates first, so an interruption leaves a partial
|
|
199
|
+
file. That matters most for files we do not own: polymath rewrites the
|
|
200
|
+
Gemini CLI's oauth_creds.json when refreshing a token, and a truncated
|
|
201
|
+
write there destroys the user's login to a different tool.
|
|
202
|
+
|
|
203
|
+
An existing file keeps its own permissions unless `mode` says otherwise.
|
|
204
|
+
"""
|
|
205
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
if mode is None and path.exists():
|
|
207
|
+
try:
|
|
208
|
+
mode = path.stat().st_mode & 0o777
|
|
209
|
+
except OSError:
|
|
210
|
+
mode = None
|
|
211
|
+
|
|
212
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
|
|
213
|
+
tmp = Path(tmp_name)
|
|
214
|
+
try:
|
|
215
|
+
with os.fdopen(fd, "w") as f:
|
|
216
|
+
f.write(text)
|
|
217
|
+
if mode is not None:
|
|
218
|
+
_secure(tmp, mode)
|
|
219
|
+
os.replace(tmp, path)
|
|
220
|
+
except Exception:
|
|
221
|
+
tmp.unlink(missing_ok=True)
|
|
222
|
+
raise
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _secure(path: Path, mode: int) -> None:
|
|
226
|
+
"""Best-effort chmod. A filesystem that cannot represent the mode (a
|
|
227
|
+
mounted share, say) must not stop polymath from starting."""
|
|
228
|
+
try:
|
|
229
|
+
if path.exists() and (path.stat().st_mode & 0o777) != mode:
|
|
230
|
+
path.chmod(mode)
|
|
231
|
+
except OSError:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def load_config() -> dict:
|
|
236
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
_secure(CONFIG_DIR, CONFIG_DIR_MODE)
|
|
238
|
+
_secure(CONFIG_FILE, CONFIG_FILE_MODE)
|
|
239
|
+
if CONFIG_FILE.exists():
|
|
240
|
+
with open(CONFIG_FILE) as f:
|
|
241
|
+
saved = json.load(f)
|
|
242
|
+
return merge_defaults(DEFAULT_CONFIG, saved)
|
|
243
|
+
return dict(DEFAULT_CONFIG)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def save_config(cfg: dict) -> None:
|
|
247
|
+
"""Write the config atomically and readable only by its owner.
|
|
248
|
+
|
|
249
|
+
Atomic because a crash partway through a plain write leaves a truncated
|
|
250
|
+
file, and the next start would then fail to parse its own config and
|
|
251
|
+
silently fall back to defaults, losing the user's API keys.
|
|
252
|
+
"""
|
|
253
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
_secure(CONFIG_DIR, CONFIG_DIR_MODE)
|
|
255
|
+
|
|
256
|
+
# Session state borrows this dictionary — the ensemble cost warning
|
|
257
|
+
# records that it has fired as cfg["_ensemble_warned"]. The underscore
|
|
258
|
+
# marks it as not-for-disk, and this is what enforces that: persisting
|
|
259
|
+
# it would mean the warning never appears again in any future session.
|
|
260
|
+
cfg = {key: value for key, value in cfg.items() if not key.startswith("_")}
|
|
261
|
+
|
|
262
|
+
atomic_write_text(
|
|
263
|
+
CONFIG_FILE, json.dumps(cfg, indent=2), mode=CONFIG_FILE_MODE,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def memory_setting(cfg: dict, key: str):
|
|
268
|
+
"""Read one cfg["memory"] setting.
|
|
269
|
+
|
|
270
|
+
Falls back to DEFAULT_CONFIG rather than to a literal written at the call
|
|
271
|
+
site, so each default has exactly one home and cannot drift. Tolerates a
|
|
272
|
+
partial cfg, which tests and older config files both produce.
|
|
273
|
+
"""
|
|
274
|
+
block = cfg.get("memory") if isinstance(cfg, dict) else None
|
|
275
|
+
if isinstance(block, dict) and key in block:
|
|
276
|
+
return block[key]
|
|
277
|
+
return DEFAULT_CONFIG["memory"][key]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def get_api_key(provider: str, cfg: dict) -> str | None:
|
|
281
|
+
# 1. user config file
|
|
282
|
+
key = cfg.get("api_keys", {}).get(provider)
|
|
283
|
+
if key:
|
|
284
|
+
return key
|
|
285
|
+
# 2. environment variable
|
|
286
|
+
env_map = {
|
|
287
|
+
"claude": "ANTHROPIC_API_KEY",
|
|
288
|
+
"gemini": "GOOGLE_API_KEY",
|
|
289
|
+
"openai": "OPENAI_API_KEY",
|
|
290
|
+
"mistral": "MISTRAL_API_KEY",
|
|
291
|
+
"groq": "GROQ_API_KEY",
|
|
292
|
+
}
|
|
293
|
+
return os.environ.get(env_map.get(provider, ""))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Legacy facade — historical import location for the brain index.
|
|
3
|
+
|
|
4
|
+
The actual implementation now lives in `nexus.index`. This file
|
|
5
|
+
re-exports the public API so any existing callers
|
|
6
|
+
(`from polymath.context_manager import X`) keep working unchanged.
|
|
7
|
+
|
|
8
|
+
New code should import from `nexus` directly:
|
|
9
|
+
|
|
10
|
+
from nexus import append_context, read_context, TaskType
|
|
11
|
+
|
|
12
|
+
This file will continue to exist for backwards compatibility — old brains
|
|
13
|
+
on disk, old session exports, and external scripts may reference it for
|
|
14
|
+
years to come. Long-term contract: never delete a facade.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from nexus.config import BRAIN_PROJECTS_DIR as PROJECTS_DIR
|
|
19
|
+
from nexus.discovery import current_owner_slug, find_brain_root
|
|
20
|
+
from nexus.index import (
|
|
21
|
+
ALWAYS_INJECT,
|
|
22
|
+
CONTEXT_DESCRIPTIONS,
|
|
23
|
+
CONTEXT_FILES,
|
|
24
|
+
TASK_CONTEXT_MAP,
|
|
25
|
+
_resolve_context_dir,
|
|
26
|
+
append_context,
|
|
27
|
+
artifacts_dir,
|
|
28
|
+
brain_grep,
|
|
29
|
+
brain_usage,
|
|
30
|
+
build_context_injection,
|
|
31
|
+
context_dir,
|
|
32
|
+
create_project,
|
|
33
|
+
create_session_dirs,
|
|
34
|
+
export_session_to_project,
|
|
35
|
+
get_project_meta,
|
|
36
|
+
init_brain_root,
|
|
37
|
+
list_context_files,
|
|
38
|
+
list_projects,
|
|
39
|
+
project_dir,
|
|
40
|
+
read_context,
|
|
41
|
+
save_artifact,
|
|
42
|
+
session_dir,
|
|
43
|
+
sessions_dir,
|
|
44
|
+
write_context,
|
|
45
|
+
)
|
|
46
|
+
from nexus.types import TaskType
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"ALWAYS_INJECT",
|
|
50
|
+
"CONTEXT_DESCRIPTIONS",
|
|
51
|
+
"CONTEXT_FILES",
|
|
52
|
+
"PROJECTS_DIR",
|
|
53
|
+
"TASK_CONTEXT_MAP",
|
|
54
|
+
"TaskType",
|
|
55
|
+
"append_context",
|
|
56
|
+
"artifacts_dir",
|
|
57
|
+
"brain_grep",
|
|
58
|
+
"brain_usage",
|
|
59
|
+
"build_context_injection",
|
|
60
|
+
"context_dir",
|
|
61
|
+
"create_project",
|
|
62
|
+
"create_session_dirs",
|
|
63
|
+
"current_owner_slug",
|
|
64
|
+
"export_session_to_project",
|
|
65
|
+
"find_brain_root",
|
|
66
|
+
"get_project_meta",
|
|
67
|
+
"init_brain_root",
|
|
68
|
+
"list_context_files",
|
|
69
|
+
"list_projects",
|
|
70
|
+
"project_dir",
|
|
71
|
+
"read_context",
|
|
72
|
+
"save_artifact",
|
|
73
|
+
"session_dir",
|
|
74
|
+
"sessions_dir",
|
|
75
|
+
"write_context",
|
|
76
|
+
]
|