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/detector.py ADDED
@@ -0,0 +1,442 @@
1
+ """
2
+ Auto-detect all available models on the device.
3
+ Checks: CLI tools, Ollama API, LM Studio API, env vars, polymath config.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import shutil
8
+ import subprocess
9
+ import time
10
+ from typing import TYPE_CHECKING
11
+
12
+ import httpx
13
+
14
+ from polymath.config import MODEL_REGISTRY, ModelInfo, get_api_key, load_config, atomic_write_text
15
+
16
+ if TYPE_CHECKING:
17
+ pass
18
+
19
+
20
+ _registry_cache: list[ModelInfo] | None = None
21
+ _cache_ts: float = 0
22
+ CACHE_TTL = 300 # seconds
23
+
24
+
25
+ def invalidate_cache() -> None:
26
+ """Force next detect() call to re-probe all providers."""
27
+ global _registry_cache, _cache_ts
28
+ _registry_cache = None
29
+ _cache_ts = 0
30
+
31
+
32
+ def detect(force: bool = False) -> list[ModelInfo]:
33
+ """Return MODEL_REGISTRY with `available` flags set. Results cached for 5 minutes."""
34
+ global _registry_cache, _cache_ts
35
+ if not force and _registry_cache and (time.time() - _cache_ts) < CACHE_TTL:
36
+ return _registry_cache
37
+ _registry_cache = _run_detect()
38
+ _cache_ts = time.time()
39
+ return _registry_cache
40
+
41
+
42
+ def _run_detect() -> list[ModelInfo]:
43
+ """Internal: actually probe all providers and return registry."""
44
+ cfg = load_config()
45
+ registry = [_clone(m) for m in MODEL_REGISTRY]
46
+
47
+ _detect_claude(registry, cfg)
48
+ _detect_gemini(registry, cfg)
49
+ _detect_openai(registry, cfg)
50
+ _detect_ollama(registry, cfg)
51
+ _detect_lmstudio(registry, cfg)
52
+
53
+ return registry
54
+
55
+
56
+ def available_models(registry: list[ModelInfo] | None = None) -> list[ModelInfo]:
57
+ r = registry or detect()
58
+ return [m for m in r if m.available]
59
+
60
+
61
+ # ── Provider detectors ────────────────────────────────────────────────────────
62
+
63
+ def _detect_claude(registry: list[ModelInfo], cfg: dict) -> None:
64
+ key = get_api_key("claude", cfg)
65
+ cli = shutil.which("claude")
66
+ if not key and not cli:
67
+ return
68
+ # If CLI exists but no explicit key, attempt to read from claude's own config
69
+ if not key and cli:
70
+ key = _read_claude_cli_key()
71
+ if key:
72
+ cfg.setdefault("api_keys", {})["claude"] = key
73
+ for m in registry:
74
+ if m.provider == "claude":
75
+ m.available = True
76
+
77
+
78
+ def _detect_gemini(registry: list[ModelInfo], cfg: dict) -> None:
79
+ key = get_api_key("gemini", cfg)
80
+ cli = shutil.which("gemini")
81
+ if not key and not cli:
82
+ return
83
+ if not key and cli:
84
+ key = _read_gemini_cli_key()
85
+ if key:
86
+ cfg.setdefault("api_keys", {})["gemini"] = key
87
+ # Mark whether this is an OAuth token (from gemini CLI) vs API key
88
+ # OAuth tokens start with "ya29." — flag it so the adapter knows
89
+ cfg.setdefault("gemini_auth_type", "oauth" if key.startswith("ya29.") else "api_key")
90
+ for m in registry:
91
+ if m.provider == "gemini":
92
+ m.available = True
93
+
94
+
95
+ def _detect_openai(registry: list[ModelInfo], cfg: dict) -> None:
96
+ key = get_api_key("openai", cfg)
97
+ cli = shutil.which("codex")
98
+ if not key and not cli:
99
+ return
100
+ if not key and cli:
101
+ key = _read_codex_cli_key()
102
+ if key:
103
+ cfg.setdefault("api_keys", {})["openai"] = key
104
+ for m in registry:
105
+ if m.provider == "openai":
106
+ m.available = True
107
+
108
+
109
+ def _detect_ollama(registry: list[ModelInfo], cfg: dict) -> None:
110
+ base = cfg.get("ollama_url", "http://localhost:11434")
111
+ try:
112
+ resp = httpx.get(f"{base}/api/tags", timeout=3)
113
+ if resp.status_code != 200:
114
+ return
115
+ remote_models = {m["name"] for m in resp.json().get("models", [])}
116
+ except Exception:
117
+ # Ollama not running — still mark as available if CLI exists
118
+ if not shutil.which("ollama"):
119
+ return
120
+ remote_models = set()
121
+
122
+ # Mark known registry entries
123
+ for m in registry:
124
+ if m.provider == "ollama" and m.id in remote_models:
125
+ m.available = True
126
+
127
+ # Add any extra Ollama models not in registry
128
+ known = {m.id for m in registry if m.provider == "ollama"}
129
+ for name in remote_models - known:
130
+ registry.append(ModelInfo(
131
+ id=name, provider="ollama", display_name=name,
132
+ cost_tier=__import__("polymath.config", fromlist=["CostTier"]).CostTier.FREE,
133
+ context_window=32_000, speed=3, quality=2,
134
+ capabilities=["general", "code"],
135
+ input_cost_per_1k=0.0, output_cost_per_1k=0.0,
136
+ available=True,
137
+ ))
138
+
139
+
140
+ def _detect_lmstudio(registry: list[ModelInfo], cfg: dict) -> None:
141
+ base = cfg.get("lmstudio_url", "http://localhost:1234")
142
+ try:
143
+ resp = httpx.get(f"{base}/v1/models", timeout=3)
144
+ if resp.status_code != 200:
145
+ return
146
+ for item in resp.json().get("data", []):
147
+ model_id = item["id"]
148
+ known = {m.id for m in registry}
149
+ if model_id not in known:
150
+ from polymath.config import CostTier
151
+ registry.append(ModelInfo(
152
+ id=model_id, provider="lmstudio", display_name=model_id,
153
+ cost_tier=CostTier.FREE,
154
+ context_window=32_000, speed=3, quality=3,
155
+ capabilities=["general", "code"],
156
+ input_cost_per_1k=0.0, output_cost_per_1k=0.0,
157
+ available=True,
158
+ ))
159
+ else:
160
+ for m in registry:
161
+ if m.id == model_id:
162
+ m.available = True
163
+ except Exception:
164
+ pass
165
+
166
+
167
+ # ── Key readers ───────────────────────────────────────────────────────────────
168
+
169
+ def _read_claude_cli_key() -> str | None:
170
+ """
171
+ Claude Code stores its key in the macOS Keychain, not a plain file.
172
+ Read it via the `security` CLI, which is the only reliable cross-version method.
173
+ Falls back to scanning known JSON locations for older/alternate installs.
174
+ """
175
+ import json, subprocess
176
+ from pathlib import Path
177
+
178
+ # 1. macOS Keychain — Claude Code stores credentials here
179
+ # "Claude Code-credentials" holds a JSON blob; extract apiKey if present
180
+ for service in ("Claude API Key", "Claude Code-credentials", "Claude Safe Storage"):
181
+ try:
182
+ result = subprocess.run(
183
+ ["security", "find-generic-password", "-s", service, "-w"],
184
+ capture_output=True, text=True, timeout=5,
185
+ )
186
+ if result.returncode == 0 and result.stdout.strip():
187
+ raw = result.stdout.strip()
188
+ # Try to parse as JSON (Claude Code-credentials format)
189
+ try:
190
+ import json as _json
191
+ data = _json.loads(raw)
192
+ # "claudeAiOauth" holds a nested dict with accessToken
193
+ oauth = data.get("claudeAiOauth")
194
+ if isinstance(oauth, dict) and oauth.get("accessToken"):
195
+ return oauth["accessToken"]
196
+ if isinstance(oauth, str) and "accessToken" in oauth:
197
+ # Sometimes stored as a JSON string inside JSON
198
+ inner = _json.loads(oauth)
199
+ if inner.get("accessToken"):
200
+ return inner["accessToken"]
201
+ for field in ("apiKey", "api_key", "ANTHROPIC_API_KEY", "claudeApiKey"):
202
+ if data.get(field):
203
+ return data[field]
204
+ except Exception:
205
+ # Raw string — check it looks like an Anthropic key/token
206
+ if raw.startswith(("sk-ant-", "claude-")):
207
+ return raw
208
+ except Exception:
209
+ pass
210
+
211
+ # 2. JSON file fallbacks — Linux / Windows / older Claude CLI versions
212
+ candidates = [
213
+ Path.home() / ".claude" / "config.json",
214
+ Path.home() / ".config" / "claude" / "config.json",
215
+ ]
216
+ # Windows: %APPDATA%\Claude\config.json
217
+ import os as _os
218
+ _appdata = _os.environ.get("APPDATA")
219
+ if _appdata:
220
+ candidates.append(Path(_appdata) / "Claude" / "config.json")
221
+ # Also check ANTHROPIC_API_KEY env var here as a final fallback
222
+ _env_key = _os.environ.get("ANTHROPIC_API_KEY")
223
+ if _env_key:
224
+ return _env_key
225
+ for p in candidates:
226
+ if p.exists():
227
+ try:
228
+ data = json.loads(p.read_text())
229
+ for field in ("apiKey", "api_key", "ANTHROPIC_API_KEY"):
230
+ if data.get(field):
231
+ return data[field]
232
+ except Exception:
233
+ pass
234
+ return None
235
+
236
+
237
+ def _read_gemini_cli_key() -> str | None:
238
+ """
239
+ Gemini CLI uses OAuth, not a raw API key.
240
+ Reads ~/.gemini/oauth_creds.json. If the token is expired (expiry_date < now),
241
+ attempts a silent refresh via the refresh_token before returning.
242
+ Also checks standard JSON config locations for API-key-based setups.
243
+ """
244
+ import json
245
+ import time as _time
246
+ from pathlib import Path
247
+
248
+ # 1. OAuth access token (google-gemini CLI)
249
+ oauth_path = Path.home() / ".gemini" / "oauth_creds.json"
250
+ if oauth_path.exists():
251
+ try:
252
+ data = json.loads(oauth_path.read_text())
253
+ token = data.get("access_token")
254
+ expiry_ms = data.get("expiry_date", 0)
255
+ refresh_token = data.get("refresh_token")
256
+
257
+ # expiry_date is in milliseconds
258
+ is_expired = expiry_ms and (_time.time() * 1000) > expiry_ms
259
+
260
+ if is_expired and refresh_token:
261
+ token = _refresh_gemini_token(oauth_path, data, refresh_token)
262
+ elif token:
263
+ return token
264
+ except Exception:
265
+ pass
266
+
267
+ # 2. JSON config API key (older / API-key based setups)
268
+ candidates = [
269
+ Path.home() / ".gemini" / "config.json",
270
+ Path.home() / ".gemini" / "settings.json",
271
+ Path.home() / ".config" / "gemini" / "config.json",
272
+ Path.home() / ".config" / "google" / "gemini.json",
273
+ ]
274
+ for p in candidates:
275
+ if p.exists():
276
+ try:
277
+ data = json.loads(p.read_text())
278
+ for field_name in ("apiKey", "api_key", "GOOGLE_API_KEY", "key"):
279
+ if data.get(field_name):
280
+ return data[field_name]
281
+ except Exception:
282
+ pass
283
+ return None
284
+
285
+
286
+ def _refresh_gemini_token(oauth_path, data: dict, refresh_token: str) -> str | None:
287
+ """
288
+ Attempt to refresh an expired Gemini OAuth access token using the refresh_token.
289
+ Updates oauth_creds.json in-place. Returns new access_token or None on failure.
290
+ """
291
+ import json
292
+ import time as _time
293
+ try:
294
+ # Google OAuth2 token endpoint — client_id/secret are public Gemini CLI values
295
+ resp = httpx.post(
296
+ "https://oauth2.googleapis.com/token",
297
+ data={
298
+ "grant_type": "refresh_token",
299
+ "refresh_token": refresh_token,
300
+ # Gemini CLI's public OAuth client credentials
301
+ "client_id": "681254290090-ob3mcu4n8j2rdjvo8c32pnfenrh3hjqm.apps.googleusercontent.com",
302
+ "client_secret": "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",
303
+ },
304
+ timeout=10,
305
+ )
306
+ if resp.status_code == 200:
307
+ new_data = resp.json()
308
+ new_token = new_data.get("access_token")
309
+ if new_token:
310
+ # Merge new fields back and persist
311
+ data.update(new_data)
312
+ import time as _time2
313
+ if "expires_in" in new_data and "expiry_date" not in new_data:
314
+ data["expiry_date"] = int((_time2.time() + new_data["expires_in"]) * 1000)
315
+ # Another tool's credential file. A truncated write here
316
+ # logs the user out of the Gemini CLI, not just polymath.
317
+ atomic_write_text(oauth_path, json.dumps(data, indent=2))
318
+ return new_token
319
+ except Exception:
320
+ pass
321
+ return None
322
+
323
+
324
+ # ── Gemini account management ─────────────────────────────────────────────────
325
+
326
+ def list_gemini_accounts() -> dict:
327
+ """
328
+ Return {"active": email_str, "old": [email, ...]} from google_accounts.json.
329
+ Returns {"active": None, "old": []} if not found.
330
+ """
331
+ import json
332
+ from pathlib import Path
333
+ accounts_path = Path.home() / ".gemini" / "google_accounts.json"
334
+ if not accounts_path.exists():
335
+ return {"active": None, "old": []}
336
+ try:
337
+ data = json.loads(accounts_path.read_text())
338
+ active = data.get("active")
339
+ old = data.get("old", [])
340
+ if isinstance(old, str):
341
+ old = [old] if old else []
342
+ return {"active": active, "old": list(old)}
343
+ except Exception:
344
+ return {"active": None, "old": []}
345
+
346
+
347
+ def switch_gemini_account(email: str) -> bool:
348
+ """
349
+ Switch active Gemini account by updating google_accounts.json and clearing
350
+ oauth_creds.json (forcing re-login on next use). Returns True on success.
351
+ Note: actually switching accounts requires `gemini auth login` for the new account.
352
+ This just updates the active pointer and wipes cached tokens.
353
+ """
354
+ import json
355
+ from pathlib import Path
356
+ accounts_path = Path.home() / ".gemini" / "google_accounts.json"
357
+ if not accounts_path.exists():
358
+ return False
359
+ try:
360
+ data = json.loads(accounts_path.read_text())
361
+ current_active = data.get("active")
362
+ old = data.get("old", [])
363
+ if isinstance(old, str):
364
+ old = [old] if old else []
365
+ old = list(old)
366
+
367
+ if email == current_active:
368
+ return True # already active
369
+
370
+ all_known = ([current_active] if current_active else []) + old
371
+ if email not in all_known:
372
+ return False # unknown account
373
+
374
+ # Rotate: current active → old, requested → active
375
+ new_old = [a for a in all_known if a != email]
376
+ data["active"] = email
377
+ data["old"] = new_old
378
+ atomic_write_text(accounts_path, json.dumps(data, indent=2))
379
+
380
+ # Wipe token cache so next detect() re-reads/re-logs
381
+ oauth_path = Path.home() / ".gemini" / "oauth_creds.json"
382
+ if oauth_path.exists():
383
+ oauth_path.unlink()
384
+ invalidate_cache()
385
+ return True
386
+ except Exception:
387
+ return False
388
+
389
+
390
+ def _read_codex_cli_key() -> str | None:
391
+ """
392
+ Codex CLI stores the OpenAI key in ~/.codex/auth.json under OPENAI_API_KEY.
393
+ """
394
+ import json
395
+ from pathlib import Path
396
+
397
+ # 1. Codex auth.json (primary location)
398
+ auth = Path.home() / ".codex" / "auth.json"
399
+ if auth.exists():
400
+ try:
401
+ data = json.loads(auth.read_text())
402
+ # Direct API key (api_key auth mode)
403
+ key = data.get("OPENAI_API_KEY")
404
+ if key:
405
+ return key
406
+ # ChatGPT OAuth mode — access_token works as bearer for OpenAI API
407
+ if data.get("auth_mode") == "chatgpt":
408
+ token = (data.get("tokens") or {}).get("access_token")
409
+ if token:
410
+ return f"chatgpt_oauth:{token}" # flagged for adapter
411
+ except Exception:
412
+ pass
413
+
414
+ # 2. Fallback JSON locations
415
+ candidates = [
416
+ Path.home() / ".codex" / "config.json",
417
+ Path.home() / ".config" / "codex" / "config.json",
418
+ Path.home() / ".openai" / "config.json",
419
+ ]
420
+ for p in candidates:
421
+ if p.exists():
422
+ try:
423
+ data = json.loads(p.read_text())
424
+ for field in ("apiKey", "api_key", "OPENAI_API_KEY", "key"):
425
+ if data.get(field):
426
+ return data[field]
427
+ except Exception:
428
+ pass
429
+ return None
430
+
431
+
432
+ # ── Helpers ───────────────────────────────────────────────────────────────────
433
+
434
+ def _clone(m: ModelInfo) -> ModelInfo:
435
+ return ModelInfo(
436
+ id=m.id, provider=m.provider, display_name=m.display_name,
437
+ cost_tier=m.cost_tier, context_window=m.context_window,
438
+ speed=m.speed, quality=m.quality, capabilities=list(m.capabilities),
439
+ input_cost_per_1k=m.input_cost_per_1k,
440
+ output_cost_per_1k=m.output_cost_per_1k,
441
+ available=False,
442
+ )
polymath/domain.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Callable
6
+
7
+ from polymath.config import ModelInfo, TaskType
8
+ from polymath.project_config import LocalProject
9
+
10
+
11
+ @dataclass
12
+ class Project:
13
+ name: str = ""
14
+ source: str = "local" # local | repo | none
15
+ shared_root: Path | None = None
16
+ local_project: LocalProject | None = None
17
+ repo_root: Path | None = None
18
+ repo_branch: str = ""
19
+ repo_dirty: bool = False
20
+
21
+ @property
22
+ def shared(self) -> bool:
23
+ return self.source == "repo" and self.shared_root is not None
24
+
25
+
26
+ @dataclass
27
+ class SessionState:
28
+ session_id: str
29
+ title: str = ""
30
+ active_project: str = ""
31
+ queued_inputs: list[str] = field(default_factory=list)
32
+ last_output: str = ""
33
+
34
+
35
+ @dataclass
36
+ class ModelSelection:
37
+ primary: ModelInfo | None = None
38
+ reviewer: ModelInfo | None = None
39
+ summarizer: ModelInfo | None = None
40
+ estimated_prompt_tokens: int = 0
41
+ fallback_reason: str = ""
42
+
43
+
44
+ @dataclass
45
+ class PermissionRequest:
46
+ """One request to run something that needs the user's consent.
47
+
48
+ `tool_name` and `arguments` are the tool call itself, and they are what
49
+ the permission layer decides on. `action` is a rendering of the same
50
+ call for the user to read — never parse it back, that is the bug this
51
+ dataclass exists to prevent.
52
+ """
53
+ action: str
54
+ tool_name: str = ""
55
+ shell_command: str = ""
56
+ arguments: dict = field(default_factory=dict)
57
+ #: Non-empty when this particular call reaches beyond the tool's ordinary
58
+ #: scope, e.g. a read outside the project. An allowlist entry for the tool
59
+ #: does not cover it — the user is asked every time.
60
+ escalation_reason: str = ""
61
+ requires_prompt: bool = True
62
+
63
+
64
+ @dataclass
65
+ class CommandInvocation:
66
+ raw: str
67
+ name: str
68
+ args: str = ""
69
+ kind: str = "user" # user | builtin | custom
70
+
71
+
72
+ @dataclass
73
+ class RunContext:
74
+ session: SessionState
75
+ project: Project
76
+ task_type: TaskType
77
+ model_selection: ModelSelection
78
+ permission_handler: Callable[[PermissionRequest], bool]