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
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
+ ]