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,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as _json_module
|
|
4
|
+
from typing import Any, AsyncIterator
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from polymath.adapters.base import (
|
|
9
|
+
BaseAdapter, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_MAX_TOKENS,
|
|
10
|
+
Message, ToolCall,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OllamaAdapter(BaseAdapter):
|
|
15
|
+
provider = "ollama"
|
|
16
|
+
|
|
17
|
+
def __init__(self, base_url: str = "http://localhost:11434") -> None:
|
|
18
|
+
self._base = base_url.rstrip("/")
|
|
19
|
+
|
|
20
|
+
def _to_messages(self, messages: list[Message], system: str) -> list[dict]:
|
|
21
|
+
result = []
|
|
22
|
+
if system:
|
|
23
|
+
result.append({"role": "system", "content": system})
|
|
24
|
+
for m in messages:
|
|
25
|
+
if m.role == "system":
|
|
26
|
+
continue
|
|
27
|
+
result.append({"role": m.role, "content": m.content})
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
def _convert_tools(self, tools: list[Any] | None) -> list[dict] | None:
|
|
31
|
+
if not tools:
|
|
32
|
+
return None
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
"type": "function",
|
|
36
|
+
"function": {
|
|
37
|
+
"name": t.name,
|
|
38
|
+
"description": t.description,
|
|
39
|
+
"parameters": t.parameters,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
for t in tools
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
async def complete(
|
|
46
|
+
self,
|
|
47
|
+
messages: list[Message],
|
|
48
|
+
model_id: str,
|
|
49
|
+
system: str = "",
|
|
50
|
+
tools: list[Any] | None = None,
|
|
51
|
+
temperature: float = 0.7,
|
|
52
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
53
|
+
) -> str | Message:
|
|
54
|
+
body: dict[str, Any] = {
|
|
55
|
+
"model": model_id,
|
|
56
|
+
"messages": self._to_messages(messages, system),
|
|
57
|
+
"stream": False,
|
|
58
|
+
"options": {"temperature": temperature, "num_predict": max_tokens},
|
|
59
|
+
}
|
|
60
|
+
ollama_tools = self._convert_tools(tools)
|
|
61
|
+
if ollama_tools:
|
|
62
|
+
body["tools"] = ollama_tools
|
|
63
|
+
|
|
64
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
65
|
+
resp = await client.post(f"{self._base}/api/chat", json=body)
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
data = resp.json()
|
|
68
|
+
|
|
69
|
+
msg = data.get("message", {})
|
|
70
|
+
raw_tool_calls = msg.get("tool_calls", [])
|
|
71
|
+
if raw_tool_calls:
|
|
72
|
+
tc_list = []
|
|
73
|
+
for tc in raw_tool_calls:
|
|
74
|
+
fn = tc.get("function", {})
|
|
75
|
+
tc_id = tc.get("id", fn.get("name", ""))
|
|
76
|
+
args = fn.get("arguments", {})
|
|
77
|
+
if isinstance(args, str):
|
|
78
|
+
try:
|
|
79
|
+
args = _json_module.loads(args)
|
|
80
|
+
except Exception:
|
|
81
|
+
args = {}
|
|
82
|
+
tc_list.append(ToolCall(id=tc_id, name=fn.get("name", ""), arguments=args))
|
|
83
|
+
return Message(
|
|
84
|
+
role="assistant",
|
|
85
|
+
content=msg.get("content", ""),
|
|
86
|
+
tool_calls=tc_list,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return msg.get("content", "")
|
|
90
|
+
|
|
91
|
+
async def stream(
|
|
92
|
+
self,
|
|
93
|
+
messages: list[Message],
|
|
94
|
+
model_id: str,
|
|
95
|
+
system: str = "",
|
|
96
|
+
tools: list[Any] | None = None,
|
|
97
|
+
temperature: float = 0.7,
|
|
98
|
+
max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
|
|
99
|
+
) -> AsyncIterator[str]:
|
|
100
|
+
body: dict[str, Any] = {
|
|
101
|
+
"model": model_id,
|
|
102
|
+
"messages": self._to_messages(messages, system),
|
|
103
|
+
"stream": True,
|
|
104
|
+
"options": {"temperature": temperature, "num_predict": max_tokens},
|
|
105
|
+
}
|
|
106
|
+
ollama_tools = self._convert_tools(tools)
|
|
107
|
+
if ollama_tools:
|
|
108
|
+
body["tools"] = ollama_tools
|
|
109
|
+
|
|
110
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
111
|
+
async with client.stream("POST", f"{self._base}/api/chat", json=body) as resp:
|
|
112
|
+
async for line in resp.aiter_lines():
|
|
113
|
+
if line.strip():
|
|
114
|
+
chunk = _json_module.loads(line)
|
|
115
|
+
content = chunk.get("message", {}).get("content", "")
|
|
116
|
+
if content:
|
|
117
|
+
yield content
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, AsyncIterator
|
|
5
|
+
|
|
6
|
+
from polymath.adapters.base import (
|
|
7
|
+
AuthExpiredError, BaseAdapter, DEFAULT_MAX_TOKENS,
|
|
8
|
+
DEFAULT_STREAM_MAX_TOKENS, Message, ToolCall,
|
|
9
|
+
is_auth_failure, resolve_exception_types, truncation_notice,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _auth_types() -> tuple[type, ...]:
|
|
14
|
+
return resolve_exception_types(
|
|
15
|
+
"openai", "AuthenticationError", "PermissionDeniedError",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenAIAdapter(BaseAdapter):
|
|
20
|
+
provider = "openai"
|
|
21
|
+
|
|
22
|
+
def __init__(self, api_key: str, base_url: str = "") -> None:
|
|
23
|
+
from openai import AsyncOpenAI
|
|
24
|
+
client_kwargs: dict[str, Any] = {}
|
|
25
|
+
if base_url:
|
|
26
|
+
client_kwargs["base_url"] = base_url
|
|
27
|
+
if api_key.startswith("chatgpt_oauth:"):
|
|
28
|
+
# Codex CLI ChatGPT OAuth — use as bearer token
|
|
29
|
+
token = api_key[len("chatgpt_oauth:"):]
|
|
30
|
+
self._client = AsyncOpenAI(
|
|
31
|
+
api_key="unused",
|
|
32
|
+
default_headers={"Authorization": f"Bearer {token}"},
|
|
33
|
+
**client_kwargs,
|
|
34
|
+
)
|
|
35
|
+
else:
|
|
36
|
+
self._client = AsyncOpenAI(api_key=api_key or "lm-studio", **client_kwargs)
|
|
37
|
+
self._last_input_tokens: int = 0
|
|
38
|
+
self._last_output_tokens: int = 0
|
|
39
|
+
|
|
40
|
+
def _convert_tools(self, tools: list[Any] | None) -> list[dict] | None:
|
|
41
|
+
if not tools:
|
|
42
|
+
return None
|
|
43
|
+
oai_tools = []
|
|
44
|
+
for t in tools:
|
|
45
|
+
oai_tools.append({
|
|
46
|
+
"type": "function",
|
|
47
|
+
"function": t.to_json_schema()
|
|
48
|
+
})
|
|
49
|
+
return oai_tools
|
|
50
|
+
|
|
51
|
+
def _to_oai(self, messages: list[Message], system: str) -> list[dict]:
|
|
52
|
+
result = []
|
|
53
|
+
if system:
|
|
54
|
+
result.append({"role": "system", "content": system})
|
|
55
|
+
for m in messages:
|
|
56
|
+
if m.role == "system":
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
msg = {"role": m.role, "content": m.content or ""}
|
|
60
|
+
|
|
61
|
+
if m.tool_calls:
|
|
62
|
+
msg["tool_calls"] = [
|
|
63
|
+
{
|
|
64
|
+
"id": tc.id,
|
|
65
|
+
"type": "function",
|
|
66
|
+
"function": {
|
|
67
|
+
"name": tc.name,
|
|
68
|
+
"arguments": json.dumps(tc.arguments)
|
|
69
|
+
}
|
|
70
|
+
} for tc in m.tool_calls
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
if m.role == "tool":
|
|
74
|
+
msg["tool_call_id"] = m.tool_call_id
|
|
75
|
+
|
|
76
|
+
result.append(msg)
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
async def complete(
|
|
80
|
+
self,
|
|
81
|
+
messages: list[Message],
|
|
82
|
+
model_id: str,
|
|
83
|
+
system: str = "",
|
|
84
|
+
tools: list[Any] | None = None,
|
|
85
|
+
temperature: float = 0.7,
|
|
86
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
87
|
+
) -> str | Message:
|
|
88
|
+
oai_tools = self._convert_tools(tools)
|
|
89
|
+
kwargs = dict(
|
|
90
|
+
model=model_id,
|
|
91
|
+
messages=self._to_oai(messages, system),
|
|
92
|
+
temperature=temperature,
|
|
93
|
+
max_tokens=max_tokens,
|
|
94
|
+
)
|
|
95
|
+
if oai_tools:
|
|
96
|
+
kwargs["tools"] = oai_tools
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
resp = await self._client.chat.completions.create(**kwargs)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
if is_auth_failure(e, _auth_types()):
|
|
102
|
+
raise AuthExpiredError("openai", "codex login") from e
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
if hasattr(resp, "usage") and resp.usage:
|
|
106
|
+
self._last_input_tokens = getattr(resp.usage, "prompt_tokens", 0)
|
|
107
|
+
self._last_output_tokens = getattr(resp.usage, "completion_tokens", 0)
|
|
108
|
+
|
|
109
|
+
choice = resp.choices[0]
|
|
110
|
+
oai_msg = choice.message
|
|
111
|
+
truncated = getattr(choice, "finish_reason", None) == "length"
|
|
112
|
+
|
|
113
|
+
if oai_msg.tool_calls:
|
|
114
|
+
tool_calls = [
|
|
115
|
+
ToolCall(
|
|
116
|
+
id=tc.id,
|
|
117
|
+
name=tc.function.name,
|
|
118
|
+
arguments=json.loads(tc.function.arguments)
|
|
119
|
+
) for tc in oai_msg.tool_calls
|
|
120
|
+
]
|
|
121
|
+
content = oai_msg.content or ""
|
|
122
|
+
if truncated:
|
|
123
|
+
content += truncation_notice(max_tokens)
|
|
124
|
+
return Message(
|
|
125
|
+
role="assistant",
|
|
126
|
+
content=content,
|
|
127
|
+
tool_calls=tool_calls
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if truncated:
|
|
131
|
+
return (oai_msg.content or "") + truncation_notice(max_tokens)
|
|
132
|
+
return oai_msg.content
|
|
133
|
+
|
|
134
|
+
async def stream(
|
|
135
|
+
self,
|
|
136
|
+
messages: list[Message],
|
|
137
|
+
model_id: str,
|
|
138
|
+
system: str = "",
|
|
139
|
+
tools: list[Any] | None = None,
|
|
140
|
+
temperature: float = 0.7,
|
|
141
|
+
max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
|
|
142
|
+
) -> AsyncIterator[str | ToolCall]:
|
|
143
|
+
# Simplified stream for text only for now
|
|
144
|
+
stream = await self._client.chat.completions.create(
|
|
145
|
+
model=model_id,
|
|
146
|
+
messages=self._to_oai(messages, system),
|
|
147
|
+
temperature=temperature,
|
|
148
|
+
max_tokens=max_tokens,
|
|
149
|
+
stream=True,
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
async for chunk in stream:
|
|
153
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
154
|
+
yield chunk.choices[0].delta.content
|
|
155
|
+
except Exception as e:
|
|
156
|
+
if is_auth_failure(e, _auth_types()):
|
|
157
|
+
raise AuthExpiredError("openai", "codex login") from e
|
|
158
|
+
raise
|
|
159
|
+
|
|
160
|
+
def count_tokens(self, text: str) -> int:
|
|
161
|
+
"""tiktoken is OpenAI's own tokenizer, so it belongs here and only
|
|
162
|
+
here. cl100k_base covers the GPT-4 family; a model it does not know
|
|
163
|
+
falls back to the character estimate."""
|
|
164
|
+
try:
|
|
165
|
+
import tiktoken
|
|
166
|
+
return len(tiktoken.get_encoding("cl100k_base").encode(text))
|
|
167
|
+
except Exception:
|
|
168
|
+
return len(text) // 4
|
polymath/bootstrap.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency bootstrap — runs before any other import in main.py.
|
|
3
|
+
|
|
4
|
+
Checks the dependencies polymath needs and reports what is missing. It does
|
|
5
|
+
NOT install anything unless the user opts in with POLYMATH_AUTO_INSTALL=1,
|
|
6
|
+
because a published CLI must never mutate the environment it is run from:
|
|
7
|
+
`polymath --help` would otherwise pip-install into whatever interpreter is
|
|
8
|
+
active, which may be a shared or read-only one.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
#: Distribution name to suggest in the "run this to fix it" hint.
|
|
18
|
+
DIST_EXTRA = "polymath-agent[orchestrator]"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def auto_install_enabled() -> bool:
|
|
22
|
+
"""Auto-install is opt-in, via POLYMATH_AUTO_INSTALL=1."""
|
|
23
|
+
return os.environ.get("POLYMATH_AUTO_INSTALL", "").strip().lower() in {
|
|
24
|
+
"1", "true", "yes", "on",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# (import_name, pip_package, min_version_check)
|
|
29
|
+
REQUIRED: list[tuple[str, str]] = [
|
|
30
|
+
("anthropic", "anthropic>=0.40.0"),
|
|
31
|
+
("openai", "openai>=1.50.0"),
|
|
32
|
+
("rich", "rich>=13.0.0"),
|
|
33
|
+
("prompt_toolkit", "prompt_toolkit>=3.0.0"),
|
|
34
|
+
("tiktoken", "tiktoken>=0.7.0"),
|
|
35
|
+
("httpx", "httpx>=0.27.0"),
|
|
36
|
+
("sqlite_vec", "sqlite-vec>=0.1.6"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
OPTIONAL: list[tuple[str, str, str]] = [
|
|
40
|
+
# (import_name, pip_package, human_label)
|
|
41
|
+
("google.genai", "google-genai>=1.0.0", "Gemini"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Old package → new package (auto-upgrade)
|
|
45
|
+
DEPRECATED: list[tuple[str, str, str]] = [
|
|
46
|
+
# (old_import, old_pip_pkg, new_pip_pkg)
|
|
47
|
+
("google.generativeai", "google-generativeai", "google-genai>=1.0.0"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _pip(*args: str) -> bool:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
[sys.executable, "-m", "pip", "install", "-q", *args],
|
|
54
|
+
capture_output=True, text=True,
|
|
55
|
+
)
|
|
56
|
+
return result.returncode == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _importable(module: str) -> bool:
|
|
60
|
+
import warnings
|
|
61
|
+
try:
|
|
62
|
+
with warnings.catch_warnings():
|
|
63
|
+
warnings.simplefilter("ignore")
|
|
64
|
+
importlib.import_module(module)
|
|
65
|
+
return True
|
|
66
|
+
except ImportError:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run() -> None:
|
|
71
|
+
"""
|
|
72
|
+
Check deps and report. Only prints if something is missing.
|
|
73
|
+
Safe to call every startup — returns immediately if everything is fine.
|
|
74
|
+
|
|
75
|
+
With POLYMATH_AUTO_INSTALL=1 it also repairs what it finds, which is the
|
|
76
|
+
old self-healing behaviour and is meant for local development only.
|
|
77
|
+
"""
|
|
78
|
+
auto = auto_install_enabled()
|
|
79
|
+
missing: list[str] = []
|
|
80
|
+
fixes: list[str] = []
|
|
81
|
+
failures: list[str] = []
|
|
82
|
+
|
|
83
|
+
# 1. Detect deprecated packages
|
|
84
|
+
for old_import, old_pkg, new_pkg in DEPRECATED:
|
|
85
|
+
if _importable(old_import):
|
|
86
|
+
if not auto:
|
|
87
|
+
missing.append(new_pkg)
|
|
88
|
+
continue
|
|
89
|
+
ok = _pip("--upgrade", new_pkg)
|
|
90
|
+
if ok:
|
|
91
|
+
fixes.append(f"upgraded {old_pkg} → {new_pkg.split('>=')[0]}")
|
|
92
|
+
else:
|
|
93
|
+
failures.append(f"could not upgrade {old_pkg} → {new_pkg}")
|
|
94
|
+
|
|
95
|
+
# 2. Check required packages
|
|
96
|
+
for module, package in REQUIRED:
|
|
97
|
+
if _importable(module):
|
|
98
|
+
continue
|
|
99
|
+
if not auto:
|
|
100
|
+
missing.append(package)
|
|
101
|
+
continue
|
|
102
|
+
ok = _pip(package)
|
|
103
|
+
if ok:
|
|
104
|
+
fixes.append(f"installed {package.split('>=')[0]}")
|
|
105
|
+
else:
|
|
106
|
+
failures.append(f"could not install {package}")
|
|
107
|
+
|
|
108
|
+
# 3. Check optional packages — only warn, don't force-install
|
|
109
|
+
missing_optional: list[tuple[str, str]] = []
|
|
110
|
+
for module, package, label in OPTIONAL:
|
|
111
|
+
if not _importable(module):
|
|
112
|
+
missing_optional.append((label, package))
|
|
113
|
+
|
|
114
|
+
# 4. Print summary (only if anything happened)
|
|
115
|
+
if not missing and not missing_optional and not fixes and not failures:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
from rich.console import Console
|
|
120
|
+
console = Console()
|
|
121
|
+
|
|
122
|
+
for fix in fixes:
|
|
123
|
+
console.print(f"[green]✓ {fix}[/green]")
|
|
124
|
+
for fail in failures:
|
|
125
|
+
console.print(f"[red]✗ {fail}[/red]")
|
|
126
|
+
if missing:
|
|
127
|
+
# escape() matters: DIST_EXTRA contains "[orchestrator]", which
|
|
128
|
+
# rich would otherwise parse as a style tag and drop.
|
|
129
|
+
from rich.markup import escape
|
|
130
|
+
console.print(
|
|
131
|
+
f"[yellow]missing: {escape(', '.join(sorted(missing)))}[/yellow]\n"
|
|
132
|
+
f"[dim]run: pip install \"{escape(DIST_EXTRA)}\"[/dim]"
|
|
133
|
+
)
|
|
134
|
+
if missing_optional:
|
|
135
|
+
names = ", ".join(label for label, _ in missing_optional)
|
|
136
|
+
pkgs = " ".join(pkg for _, pkg in missing_optional)
|
|
137
|
+
console.print(
|
|
138
|
+
f"[dim]optional: {names} not installed "
|
|
139
|
+
f"(run: pip install {pkgs})[/dim]"
|
|
140
|
+
)
|
|
141
|
+
if fixes:
|
|
142
|
+
console.print()
|
|
143
|
+
except Exception:
|
|
144
|
+
# rich itself might not be installed yet
|
|
145
|
+
for fix in fixes:
|
|
146
|
+
print(f"✓ {fix}")
|
|
147
|
+
for fail in failures:
|
|
148
|
+
print(f"✗ {fail}")
|
|
149
|
+
if missing:
|
|
150
|
+
print(f"missing: {', '.join(sorted(missing))}")
|
|
151
|
+
print(f'run: pip install "{DIST_EXTRA}"')
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_models_available() -> tuple[bool, str]:
|
|
155
|
+
"""
|
|
156
|
+
Returns (ok, warning_message).
|
|
157
|
+
Called after detect() — if zero models available, returns actionable message.
|
|
158
|
+
"""
|
|
159
|
+
return True, ""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from prompt_toolkit.completion import WordCompleter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
BUILTIN_COMMANDS = [
|
|
7
|
+
"/help",
|
|
8
|
+
"/exit",
|
|
9
|
+
"/models",
|
|
10
|
+
"/sessions",
|
|
11
|
+
"/session",
|
|
12
|
+
"/new",
|
|
13
|
+
"/project",
|
|
14
|
+
"/project list",
|
|
15
|
+
"/project new",
|
|
16
|
+
"/project use",
|
|
17
|
+
"/project init",
|
|
18
|
+
"/context",
|
|
19
|
+
"/context show",
|
|
20
|
+
"/context add",
|
|
21
|
+
"/context edit",
|
|
22
|
+
"/commands",
|
|
23
|
+
"/subagents",
|
|
24
|
+
"/parallel",
|
|
25
|
+
"/accounts",
|
|
26
|
+
"/state",
|
|
27
|
+
"/queue",
|
|
28
|
+
"/memory",
|
|
29
|
+
"/memory show",
|
|
30
|
+
"/memory search",
|
|
31
|
+
"/memory rebuild",
|
|
32
|
+
"/memory confirm",
|
|
33
|
+
"/memory diff",
|
|
34
|
+
"/memory test",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_repl_completer(custom_commands: list[str]) -> WordCompleter:
|
|
39
|
+
words = BUILTIN_COMMANDS + [f"/{name}" for name in sorted(custom_commands)]
|
|
40
|
+
words += ["@claude", "@gemini", "@openai", "@ollama", "@all", "--ask", "--cheap", "--fast", "--verify", "--simplify", "--ensemble", "--fanout"]
|
|
41
|
+
return WordCompleter(words, ignore_case=True, sentence=True)
|