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,15 @@
1
+ """
2
+ Legacy facade — re-exports the brain's memory module at its old path.
3
+
4
+ Implementation lives in `nexus.memory`. This package will keep
5
+ existing forever for backwards compatibility with old imports.
6
+
7
+ # Legacy
8
+ from polymath.memory.store import Chunk, MemoryStore
9
+
10
+ # Preferred (post-decoupling)
11
+ from nexus import Chunk, MemoryStore
12
+ """
13
+ from nexus.memory.store import Chunk, MemoryStore, resolve_memory_root
14
+
15
+ __all__ = ["Chunk", "MemoryStore", "resolve_memory_root"]
@@ -0,0 +1,6 @@
1
+ """Legacy facade — see nexus.memory.chunker."""
2
+ from nexus.memory.chunker import * # noqa: F401,F403
3
+ from nexus.memory.chunker import ( # noqa: F401
4
+ CHARS_PER_TOKEN,
5
+ chunk_markdown,
6
+ )
@@ -0,0 +1,179 @@
1
+ """
2
+ Embedding adapters: Gemini text-embedding-004 (primary), OpenAI text-embedding-3-small (fallback).
3
+
4
+ Reuses key resolution from polymath.config / polymath.detector. Same provider
5
+ ordering as the chat adapters — Gemini's free tier first, OpenAI as paid fallback.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from dataclasses import dataclass
11
+ from typing import Sequence
12
+
13
+ from polymath.adapters.base import AuthExpiredError
14
+ from polymath.config import get_api_key
15
+ from polymath.detector import _read_codex_cli_key, _read_gemini_cli_key
16
+
17
+
18
+ GEMINI_MODEL = "text-embedding-004"
19
+ GEMINI_DIM = 768
20
+ OPENAI_MODEL = "text-embedding-3-small"
21
+ OPENAI_DIM = 1536
22
+
23
+ PROVIDER_DIMS = {
24
+ ("gemini", "text-embedding-004"): 768,
25
+ ("openai", "text-embedding-3-small"): 1536,
26
+ ("openai", "text-embedding-3-large"): 3072,
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class EmbedResult:
32
+ provider: str
33
+ model: str
34
+ dim: int
35
+ vectors: list[list[float]]
36
+
37
+
38
+ class EmbedderUnavailable(RuntimeError):
39
+ pass
40
+
41
+
42
+ class Embedder:
43
+ """Tries Gemini first, then OpenAI. Caller picks an instance and stays on it
44
+ for the lifetime of a memory write — switching mid-batch would mix dims."""
45
+
46
+ def __init__(self, cfg: dict, prefer: str = "gemini") -> None:
47
+ self.cfg = cfg
48
+ self.prefer = prefer
49
+ self._gemini_client = None
50
+ self._openai_client = None
51
+ self._gemini_types = None
52
+ self._active: tuple[str, str, int] | None = None
53
+
54
+ # ── Public API ────────────────────────────────────────────────────────────
55
+
56
+ async def embed(self, texts: Sequence[str]) -> EmbedResult:
57
+ if not texts:
58
+ return EmbedResult(provider="", model="", dim=0, vectors=[])
59
+
60
+ order = ["gemini", "openai"] if self.prefer == "gemini" else ["openai", "gemini"]
61
+ failures: list[str] = []
62
+ for provider in order:
63
+ try:
64
+ if provider == "gemini":
65
+ return await self._embed_gemini(list(texts))
66
+ return await self._embed_openai(list(texts))
67
+ except (EmbedderUnavailable, AuthExpiredError, Exception) as e:
68
+ failures.append(f"{provider}: {e}")
69
+ guidance = (
70
+ "no embedding provider available.\n " + "\n ".join(failures) +
71
+ "\nfix: set GOOGLE_API_KEY (free at https://aistudio.google.com/apikey) "
72
+ "or OPENAI_API_KEY (codex OAuth tokens are chat-only and cannot embed)."
73
+ )
74
+ raise EmbedderUnavailable(guidance)
75
+
76
+ def active_model(self) -> tuple[str, str, int] | None:
77
+ return self._active
78
+
79
+ # ── Gemini ────────────────────────────────────────────────────────────────
80
+
81
+ def _ensure_gemini(self) -> None:
82
+ if self._gemini_client is not None:
83
+ return
84
+ try:
85
+ from google import genai
86
+ from google.genai import types as genai_types
87
+ except ImportError as e:
88
+ raise EmbedderUnavailable(f"google-genai not installed: {e}")
89
+
90
+ key = get_api_key("gemini", self.cfg) or _read_gemini_cli_key() or ""
91
+ if not key:
92
+ raise EmbedderUnavailable("no gemini api key")
93
+
94
+ if key.startswith("ya29."):
95
+ self._gemini_client = genai.Client(
96
+ http_options={"headers": {"Authorization": f"Bearer {key}"}}
97
+ )
98
+ else:
99
+ self._gemini_client = genai.Client(api_key=key)
100
+ self._gemini_types = genai_types
101
+
102
+ async def _embed_gemini(self, texts: list[str]) -> EmbedResult:
103
+ self._ensure_gemini()
104
+ client = self._gemini_client
105
+ all_vectors: list[list[float]] = []
106
+ for batch in _batches(texts, 100):
107
+ try:
108
+ resp = await client.aio.models.embed_content(
109
+ model=GEMINI_MODEL,
110
+ contents=batch,
111
+ )
112
+ except Exception as e:
113
+ msg = str(e).lower()
114
+ if "401" in msg or "unauthenticated" in msg or "invalid_grant" in msg:
115
+ raise AuthExpiredError("gemini", "gemini auth login") from e
116
+ raise
117
+ for embedding in resp.embeddings:
118
+ values = list(getattr(embedding, "values", []) or [])
119
+ all_vectors.append(values)
120
+ self._active = ("gemini", GEMINI_MODEL, GEMINI_DIM)
121
+ return EmbedResult(provider="gemini", model=GEMINI_MODEL, dim=GEMINI_DIM, vectors=all_vectors)
122
+
123
+ # ── OpenAI ────────────────────────────────────────────────────────────────
124
+
125
+ def _ensure_openai(self) -> None:
126
+ if self._openai_client is not None:
127
+ return
128
+ try:
129
+ from openai import AsyncOpenAI
130
+ except ImportError as e:
131
+ raise EmbedderUnavailable(f"openai not installed: {e}")
132
+
133
+ key = get_api_key("openai", self.cfg) or _read_codex_cli_key() or ""
134
+ if not key:
135
+ raise EmbedderUnavailable("no openai api key")
136
+ if key.startswith("chatgpt_oauth:"):
137
+ token = key[len("chatgpt_oauth:"):]
138
+ self._openai_client = AsyncOpenAI(
139
+ api_key="unused",
140
+ default_headers={"Authorization": f"Bearer {token}"},
141
+ )
142
+ else:
143
+ self._openai_client = AsyncOpenAI(api_key=key)
144
+
145
+ async def _embed_openai(self, texts: list[str]) -> EmbedResult:
146
+ self._ensure_openai()
147
+ client = self._openai_client
148
+ all_vectors: list[list[float]] = []
149
+ for batch in _batches(texts, 100):
150
+ try:
151
+ resp = await client.embeddings.create(
152
+ model=OPENAI_MODEL,
153
+ input=batch,
154
+ )
155
+ except Exception as e:
156
+ msg = str(e).lower()
157
+ if "401" in msg or "authentication" in msg:
158
+ raise AuthExpiredError("openai", "codex login") from e
159
+ raise
160
+ for item in resp.data:
161
+ all_vectors.append(list(item.embedding))
162
+ self._active = ("openai", OPENAI_MODEL, OPENAI_DIM)
163
+ return EmbedResult(provider="openai", model=OPENAI_MODEL, dim=OPENAI_DIM, vectors=all_vectors)
164
+
165
+
166
+ def _batches(items: list, size: int):
167
+ for i in range(0, len(items), size):
168
+ yield items[i:i + size]
169
+
170
+
171
+ def cosine(a: list[float], b: list[float]) -> float:
172
+ if not a or not b or len(a) != len(b):
173
+ return 0.0
174
+ dot = sum(x * y for x, y in zip(a, b))
175
+ na = sum(x * x for x in a) ** 0.5
176
+ nb = sum(y * y for y in b) ** 0.5
177
+ if na == 0 or nb == 0:
178
+ return 0.0
179
+ return dot / (na * nb)
@@ -0,0 +1,2 @@
1
+ """Legacy facade — see nexus.memory.migrate."""
2
+ from nexus.memory.migrate import * # noqa: F401,F403
@@ -0,0 +1,2 @@
1
+ """Legacy facade — see nexus.memory.retriever."""
2
+ from nexus.memory.retriever import * # noqa: F401,F403
@@ -0,0 +1,9 @@
1
+ """Legacy facade — see nexus.memory.store for the implementation."""
2
+ from nexus.memory.store import * # noqa: F401,F403
3
+ from nexus.memory.store import ( # noqa: F401
4
+ CHUNK_SCHEMA_VERSION,
5
+ CHUNK_TYPES,
6
+ Chunk,
7
+ MemoryStore,
8
+ resolve_memory_root,
9
+ )
@@ -0,0 +1,2 @@
1
+ """Legacy facade — see nexus.memory.sync."""
2
+ from nexus.memory.sync import * # noqa: F401,F403
@@ -0,0 +1,9 @@
1
+ """Legacy facade — see nexus.memory.writer."""
2
+ from nexus.memory.writer import * # noqa: F401,F403
3
+ from nexus.memory.writer import ( # noqa: F401
4
+ HarvestResult,
5
+ WriteBackResult,
6
+ harvest_session,
7
+ preview,
8
+ write_back,
9
+ )
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from polymath.adapters import ClaudeAdapter, GeminiAdapter, OllamaAdapter, OpenAIAdapter
6
+ from polymath.adapters.base import BaseAdapter
7
+ from polymath.compressor import build_context
8
+ from polymath.config import TaskType, get_api_key
9
+ from polymath.context_manager import build_context_injection
10
+ from polymath.detector import _read_claude_cli_key, _read_codex_cli_key, _read_gemini_cli_key
11
+ from polymath.router import select_primary, select_reviewer, select_summarizer
12
+ from polymath.workspace import get_workspace_context
13
+
14
+
15
+ CONTEXT_HEADROOM_TOKENS = 2048
16
+
17
+
18
+ def adapter_for(provider: str, cfg: dict) -> BaseAdapter | None:
19
+ key = ""
20
+ try:
21
+ if provider in ("claude", "gemini", "openai"):
22
+ key = get_api_key(provider, cfg) or ""
23
+ if not key:
24
+ readers = {
25
+ "claude": _read_claude_cli_key,
26
+ "gemini": _read_gemini_cli_key,
27
+ "openai": _read_codex_cli_key,
28
+ }
29
+ key = readers[provider]() or ""
30
+ if provider == "claude":
31
+ return ClaudeAdapter(api_key=key)
32
+ if provider == "gemini":
33
+ return GeminiAdapter(api_key=key)
34
+ if provider == "openai":
35
+ return OpenAIAdapter(api_key=key)
36
+ if provider == "ollama":
37
+ return OllamaAdapter(base_url=cfg.get("ollama_url", "http://localhost:11434"))
38
+ if provider == "lmstudio":
39
+ url = cfg.get("lmstudio_url", "http://localhost:1234").rstrip("/") + "/v1"
40
+ return OpenAIAdapter(api_key="", base_url=url)
41
+ except Exception:
42
+ return None
43
+ return None
44
+
45
+
46
+ async def estimate_prompt_tokens(
47
+ session_id: str,
48
+ user_input: str,
49
+ active_project: str,
50
+ task_type: TaskType,
51
+ adapter: BaseAdapter,
52
+ summarizer_adapter: BaseAdapter | None,
53
+ summarizer_model_id: str,
54
+ is_ask: bool,
55
+ model_id: str = "",
56
+ ) -> int:
57
+ context = await build_context(
58
+ session_id,
59
+ summarizer=summarizer_adapter,
60
+ summarizer_model=summarizer_model_id,
61
+ )
62
+ prompt_parts = [f"{m.role}: {m.content}" for m in context]
63
+ prompt_parts.append(f"user: {user_input}")
64
+ project_ctx = build_context_injection(active_project, task_type) if active_project else ""
65
+ if is_ask:
66
+ system = "Answer concisely and directly."
67
+ if project_ctx:
68
+ system += f"\n\n{project_ctx}"
69
+ else:
70
+ system = (
71
+ "You are an autonomous agent capable of using tools to interact with your environment. "
72
+ "When a task requires information you don't have, use a tool to get it. "
73
+ "Always reason out loud before taking an action. "
74
+ "If you have all the information needed, provide a final answer. "
75
+ "Be precise and thorough."
76
+ )
77
+ if project_ctx:
78
+ system += f"\n\n{project_ctx}"
79
+ system += f"\n\n{get_workspace_context()}"
80
+ return await adapter.count_tokens_async(
81
+ system + "\n\n" + "\n".join(prompt_parts), model_id,
82
+ )
83
+
84
+
85
+ @dataclass
86
+ class ModelResolution:
87
+ primary_info: object | None
88
+ primary_adapter: BaseAdapter | None
89
+ reviewer_info: object | None
90
+ reviewer_adapter: BaseAdapter | None
91
+ summarizer_info: object | None
92
+ summarizer_adapter: BaseAdapter | None
93
+ estimated_prompt_tokens: int = 0
94
+
95
+
96
+ async def resolve_models(
97
+ registry,
98
+ cfg: dict,
99
+ session_id: str,
100
+ user_input: str,
101
+ active_project: str,
102
+ task_type: TaskType,
103
+ profile: str,
104
+ force_provider: str = "",
105
+ cheap: bool = False,
106
+ fast: bool = False,
107
+ is_ask: bool = False,
108
+ ) -> ModelResolution:
109
+ summarizer_info = select_summarizer(registry)
110
+ summarizer_adapter = adapter_for(summarizer_info.provider, cfg) if summarizer_info else None
111
+
112
+ excluded: set[str] = set()
113
+ primary_info = None
114
+ primary_adapter = None
115
+ estimated_prompt_tokens = 0
116
+ while True:
117
+ candidates = [m for m in registry if m.id not in excluded]
118
+ primary_info = select_primary(
119
+ candidates,
120
+ task_type,
121
+ profile,
122
+ force_provider=force_provider,
123
+ force_cheap=cheap,
124
+ force_fast=fast,
125
+ )
126
+ if not primary_info:
127
+ break
128
+ primary_adapter = adapter_for(primary_info.provider, cfg)
129
+ if not primary_adapter:
130
+ excluded.add(primary_info.id)
131
+ continue
132
+ estimated_prompt_tokens = await estimate_prompt_tokens(
133
+ model_id=primary_info.id,
134
+ session_id=session_id,
135
+ user_input=user_input,
136
+ active_project=active_project,
137
+ task_type=task_type,
138
+ adapter=primary_adapter,
139
+ summarizer_adapter=summarizer_adapter,
140
+ summarizer_model_id=summarizer_info.id if summarizer_info else "",
141
+ is_ask=is_ask,
142
+ )
143
+ if primary_info.context_window - estimated_prompt_tokens > CONTEXT_HEADROOM_TOKENS:
144
+ break
145
+ excluded.add(primary_info.id)
146
+ reviewer_info = select_reviewer(registry, primary_info, profile) if primary_info else None
147
+ reviewer_adapter = adapter_for(reviewer_info.provider, cfg) if reviewer_info else None
148
+ return ModelResolution(
149
+ primary_info=primary_info,
150
+ primary_adapter=primary_adapter,
151
+ reviewer_info=reviewer_info,
152
+ reviewer_adapter=reviewer_adapter,
153
+ summarizer_info=summarizer_info,
154
+ summarizer_adapter=summarizer_adapter,
155
+ estimated_prompt_tokens=estimated_prompt_tokens,
156
+ )
157
+
158
+
159
+ def try_fallback_adapter(registry, failed_model, profile: str, cfg: dict, tried_model_ids: set[str] | None = None):
160
+ tried = set(tried_model_ids or set())
161
+ tried.add(failed_model.id)
162
+ available = [m for m in registry if m.available and m.id not in tried]
163
+ while available:
164
+ candidate = select_primary(available, TaskType.GENERAL, profile)
165
+ if not candidate or candidate.id in tried:
166
+ break
167
+ tried.add(candidate.id)
168
+ adapter = adapter_for(candidate.provider, cfg)
169
+ if adapter:
170
+ return candidate, adapter
171
+ available = [m for m in available if m.id not in tried]
172
+ return None
@@ -0,0 +1,68 @@
1
+ from polymath.orchestrator.attempt_ledger import AttemptLedger
2
+ from polymath.orchestrator.ensemble import (
3
+ EnsembleResult,
4
+ EnsembleWorker,
5
+ WorkerResult,
6
+ run_ensemble,
7
+ select_ensemble_workers,
8
+ )
9
+ from polymath.orchestrator.fanout import (
10
+ FanoutResult,
11
+ Subtask,
12
+ SubtaskResult,
13
+ run_fanout,
14
+ select_splitter_and_joiner,
15
+ )
16
+ from polymath.orchestrator.speculative_review import SpeculativeReviewer
17
+ from polymath.orchestrator.output_policy import FailureDecision, OutputPolicy
18
+ from polymath.orchestrator.race import (
19
+ AllCandidatesFailed,
20
+ RaceCandidate,
21
+ RaceOutcome,
22
+ StreamCandidate,
23
+ StreamOutcome,
24
+ gather_all,
25
+ race_first_success,
26
+ race_streams,
27
+ with_failover,
28
+ )
29
+ from polymath.orchestrator.run_controller import (
30
+ ExecutionStrategy,
31
+ RunController,
32
+ RunDecision,
33
+ RunRequest,
34
+ )
35
+ from polymath.orchestrator.state_responder import StateResponder
36
+ from polymath.orchestrator.worker_pool import WorkerPool
37
+
38
+ __all__ = [
39
+ "AllCandidatesFailed",
40
+ "AttemptLedger",
41
+ "EnsembleResult",
42
+ "EnsembleWorker",
43
+ "ExecutionStrategy",
44
+ "FailureDecision",
45
+ "FanoutResult",
46
+ "Subtask",
47
+ "SubtaskResult",
48
+ "OutputPolicy",
49
+ "RaceCandidate",
50
+ "RaceOutcome",
51
+ "StreamCandidate",
52
+ "StreamOutcome",
53
+ "RunController",
54
+ "RunDecision",
55
+ "RunRequest",
56
+ "SpeculativeReviewer",
57
+ "StateResponder",
58
+ "WorkerPool",
59
+ "WorkerResult",
60
+ "gather_all",
61
+ "race_first_success",
62
+ "race_streams",
63
+ "run_ensemble",
64
+ "run_fanout",
65
+ "select_ensemble_workers",
66
+ "select_splitter_and_joiner",
67
+ "with_failover",
68
+ ]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class AttemptFailure:
8
+ model_id: str
9
+ reason: str
10
+ retryable: bool = True
11
+
12
+
13
+ @dataclass
14
+ class AttemptLedger:
15
+ attempted_by_phase: dict[str, set[str]] = field(default_factory=dict)
16
+ failures: dict[str, list[AttemptFailure]] = field(default_factory=dict)
17
+
18
+ def note_attempt(self, phase: str, model_id: str) -> None:
19
+ self.attempted_by_phase.setdefault(phase, set()).add(model_id)
20
+
21
+ def note_failure(self, phase: str, model_id: str, reason: str, *, retryable: bool = True) -> None:
22
+ self.failures.setdefault(phase, []).append(
23
+ AttemptFailure(model_id=model_id, reason=reason, retryable=retryable)
24
+ )
25
+ self.note_attempt(phase, model_id)
26
+
27
+ def attempted(self, phase: str) -> set[str]:
28
+ return set(self.attempted_by_phase.get(phase, set()))
29
+
30
+ def has_attempted(self, phase: str, model_id: str) -> bool:
31
+ return model_id in self.attempted_by_phase.get(phase, set())
32
+
33
+ def failures_for_phase(self, phase: str) -> list[AttemptFailure]:
34
+ return list(self.failures.get(phase, []))