mita-code 0.1.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.
- mita/__init__.py +5 -0
- mita/__main__.py +5 -0
- mita/agent/__init__.py +1 -0
- mita/agent/context.py +43 -0
- mita/agent/conversation.py +101 -0
- mita/agent/loop.py +594 -0
- mita/agent/system_prompt.py +75 -0
- mita/cli.py +940 -0
- mita/config/__init__.py +6 -0
- mita/config/defaults.py +41 -0
- mita/config/loader.py +53 -0
- mita/config/schema.py +131 -0
- mita/hooks/__init__.py +1 -0
- mita/hooks/manager.py +94 -0
- mita/hooks/runner.py +145 -0
- mita/index/__init__.py +1 -0
- mita/index/embeddings.py +49 -0
- mita/index/manager.py +170 -0
- mita/index/parser.py +331 -0
- mita/index/retriever.py +53 -0
- mita/index/store.py +143 -0
- mita/llm/__init__.py +1 -0
- mita/llm/client.py +86 -0
- mita/llm/instructor.py +80 -0
- mita/llm/streaming.py +58 -0
- mita/memory/__init__.py +6 -0
- mita/memory/discovery.py +61 -0
- mita/memory/loader.py +76 -0
- mita/memory/manager.py +117 -0
- mita/models/__init__.py +13 -0
- mita/models/hardware.py +289 -0
- mita/models/manager.py +268 -0
- mita/models/ollama_client.py +104 -0
- mita/models/recommender.py +88 -0
- mita/models/registry.py +167 -0
- mita/models/server.py +262 -0
- mita/plugins/__init__.py +1 -0
- mita/plugins/client.py +152 -0
- mita/plugins/manager.py +210 -0
- mita/py.typed +0 -0
- mita/skills/__init__.py +1 -0
- mita/skills/executor.py +84 -0
- mita/skills/loader.py +117 -0
- mita/skills/manager.py +129 -0
- mita/tools/__init__.py +1 -0
- mita/tools/builtins/__init__.py +28 -0
- mita/tools/builtins/file_edit.py +71 -0
- mita/tools/builtins/file_read.py +74 -0
- mita/tools/builtins/file_write.py +42 -0
- mita/tools/builtins/git.py +112 -0
- mita/tools/builtins/glob_tool.py +67 -0
- mita/tools/builtins/grep_tool.py +93 -0
- mita/tools/builtins/shell.py +83 -0
- mita/tools/executor.py +80 -0
- mita/tools/registry.py +69 -0
- mita/tools/safety.py +91 -0
- mita/tools/schema.py +87 -0
- mita/ui/__init__.py +1 -0
- mita/ui/display.py +139 -0
- mita/ui/repl.py +88 -0
- mita/ui/spinner.py +48 -0
- mita/ui/theme.py +23 -0
- mita_code-0.1.0.dist-info/METADATA +227 -0
- mita_code-0.1.0.dist-info/RECORD +67 -0
- mita_code-0.1.0.dist-info/WHEEL +4 -0
- mita_code-0.1.0.dist-info/entry_points.txt +3 -0
- mita_code-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Filter and rank models by hardware capability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from mita.models.hardware import HardwareInfo
|
|
8
|
+
from mita.models.registry import ModelCard, get_coding_models
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ModelRecommendation(BaseModel):
|
|
12
|
+
"""A model recommendation with a fit score and notes."""
|
|
13
|
+
|
|
14
|
+
model: ModelCard
|
|
15
|
+
fit_score: float # 0.0–1.0
|
|
16
|
+
notes: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def recommend_models(hw: HardwareInfo) -> list[ModelRecommendation]:
|
|
20
|
+
"""Recommend coding models that fit the given hardware.
|
|
21
|
+
|
|
22
|
+
Returns a list sorted by fit_score descending (best fit first).
|
|
23
|
+
Only includes models that can actually run on the hardware.
|
|
24
|
+
"""
|
|
25
|
+
available_vram = hw.available_vram_gb
|
|
26
|
+
recommendations: list[ModelRecommendation] = []
|
|
27
|
+
|
|
28
|
+
for model in get_coding_models():
|
|
29
|
+
if model.min_vram_gb <= 0:
|
|
30
|
+
continue
|
|
31
|
+
if model.min_vram_gb > available_vram:
|
|
32
|
+
continue
|
|
33
|
+
if model.min_ram_gb > hw.ram_gb:
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
fit_score, notes = _score_model(model, hw, available_vram)
|
|
37
|
+
recommendations.append(ModelRecommendation(model=model, fit_score=fit_score, notes=notes))
|
|
38
|
+
|
|
39
|
+
recommendations.sort(key=lambda r: r.fit_score, reverse=True)
|
|
40
|
+
return recommendations
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _score_model(model: ModelCard, hw: HardwareInfo, available_vram: float) -> tuple[float, str]:
|
|
44
|
+
"""Score a model's fit for the hardware. Returns (score, notes)."""
|
|
45
|
+
notes_parts: list[str] = []
|
|
46
|
+
|
|
47
|
+
# Base score: how much headroom do we have?
|
|
48
|
+
# Ratio of available VRAM to required VRAM
|
|
49
|
+
headroom_ratio = available_vram / model.min_vram_gb
|
|
50
|
+
if headroom_ratio >= 2.0:
|
|
51
|
+
vram_score = 1.0
|
|
52
|
+
notes_parts.append(f"Plenty of headroom ({available_vram:.0f}GB available)")
|
|
53
|
+
elif headroom_ratio >= 1.5:
|
|
54
|
+
vram_score = 0.9
|
|
55
|
+
notes_parts.append(f"Comfortable fit ({available_vram:.0f}GB available)")
|
|
56
|
+
elif headroom_ratio >= 1.2:
|
|
57
|
+
vram_score = 0.7
|
|
58
|
+
notes_parts.append(f"Fits with some room ({available_vram:.0f}GB available)")
|
|
59
|
+
else:
|
|
60
|
+
vram_score = 0.5
|
|
61
|
+
notes_parts.append(f"Tight fit — uses ~{model.min_vram_gb:.0f}GB of {available_vram:.0f}GB")
|
|
62
|
+
|
|
63
|
+
# Bonus for tool call support (important for agentic use)
|
|
64
|
+
tool_bonus = 0.1 if model.tool_call_support else 0.0
|
|
65
|
+
|
|
66
|
+
# Bonus for being a recommended model
|
|
67
|
+
recommended_bonus = 0.05 if "recommended" in model.tags else 0.0
|
|
68
|
+
|
|
69
|
+
# Penalty for Apple Silicon running very large models (thermal throttling)
|
|
70
|
+
thermal_penalty = 0.0
|
|
71
|
+
if hw.apple_silicon and model.min_vram_gb > hw.ram_gb * 0.5:
|
|
72
|
+
thermal_penalty = 0.1
|
|
73
|
+
notes_parts.append("May cause thermal throttling on Apple Silicon")
|
|
74
|
+
|
|
75
|
+
# Context window bonus (larger is better for agentic coding)
|
|
76
|
+
ctx_bonus = min(0.05, model.context_window / 1_000_000)
|
|
77
|
+
|
|
78
|
+
score = min(1.0, vram_score + tool_bonus + recommended_bonus + ctx_bonus - thermal_penalty)
|
|
79
|
+
|
|
80
|
+
if model.tool_call_support:
|
|
81
|
+
notes_parts.append("Supports tool calls")
|
|
82
|
+
else:
|
|
83
|
+
notes_parts.append("No tool call support — will use JSON fallback")
|
|
84
|
+
|
|
85
|
+
if hw.unified_memory:
|
|
86
|
+
notes_parts.append("Unified memory — shared with system")
|
|
87
|
+
|
|
88
|
+
return round(score, 2), ". ".join(notes_parts)
|
mita/models/registry.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Curated registry of coding-focused models for Ollama."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModelCard(BaseModel):
|
|
9
|
+
"""Metadata for a coding-focused model in the curated registry."""
|
|
10
|
+
|
|
11
|
+
name: str # Ollama model tag, e.g. "qwen2.5-coder:7b"
|
|
12
|
+
family: str # "qwen", "codellama", "deepseek", etc.
|
|
13
|
+
param_count: str # "7B", "14B", "32B"
|
|
14
|
+
min_ram_gb: float
|
|
15
|
+
min_vram_gb: float
|
|
16
|
+
context_window: int
|
|
17
|
+
quantization: str = "Q4_K_M"
|
|
18
|
+
tool_call_support: bool = True
|
|
19
|
+
description: str = ""
|
|
20
|
+
tags: list[str] = Field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Curated list of coding models that work well with Ollama
|
|
24
|
+
CODING_MODELS: list[ModelCard] = [
|
|
25
|
+
# ── Small (< 4GB) ────────────────────────────────────────────
|
|
26
|
+
ModelCard(
|
|
27
|
+
name="qwen2.5-coder:1.5b",
|
|
28
|
+
family="qwen",
|
|
29
|
+
param_count="1.5B",
|
|
30
|
+
min_ram_gb=2,
|
|
31
|
+
min_vram_gb=2,
|
|
32
|
+
context_window=32768,
|
|
33
|
+
description="Tiny but capable code model. Good for quick completions.",
|
|
34
|
+
tags=["code", "fast", "small"],
|
|
35
|
+
tool_call_support=False,
|
|
36
|
+
),
|
|
37
|
+
ModelCard(
|
|
38
|
+
name="qwen2.5-coder:3b",
|
|
39
|
+
family="qwen",
|
|
40
|
+
param_count="3B",
|
|
41
|
+
min_ram_gb=3,
|
|
42
|
+
min_vram_gb=3,
|
|
43
|
+
context_window=32768,
|
|
44
|
+
description="Small code model. Good balance of speed and quality.",
|
|
45
|
+
tags=["code", "fast"],
|
|
46
|
+
tool_call_support=False,
|
|
47
|
+
),
|
|
48
|
+
ModelCard(
|
|
49
|
+
name="deepseek-coder-v2:lite",
|
|
50
|
+
family="deepseek",
|
|
51
|
+
param_count="2.4B",
|
|
52
|
+
min_ram_gb=3,
|
|
53
|
+
min_vram_gb=2,
|
|
54
|
+
context_window=16384,
|
|
55
|
+
description="Lightweight DeepSeek coder variant.",
|
|
56
|
+
tags=["code", "fast", "small"],
|
|
57
|
+
tool_call_support=False,
|
|
58
|
+
),
|
|
59
|
+
# ── Medium (4–8GB) ───────────────────────────────────────────
|
|
60
|
+
ModelCard(
|
|
61
|
+
name="qwen2.5-coder:7b",
|
|
62
|
+
family="qwen",
|
|
63
|
+
param_count="7B",
|
|
64
|
+
min_ram_gb=6,
|
|
65
|
+
min_vram_gb=5,
|
|
66
|
+
context_window=32768,
|
|
67
|
+
description="Strong code model. Best default for 8GB+ machines.",
|
|
68
|
+
tags=["code", "instruct", "recommended"],
|
|
69
|
+
),
|
|
70
|
+
ModelCard(
|
|
71
|
+
name="codellama:7b",
|
|
72
|
+
family="codellama",
|
|
73
|
+
param_count="7B",
|
|
74
|
+
min_ram_gb=6,
|
|
75
|
+
min_vram_gb=5,
|
|
76
|
+
context_window=16384,
|
|
77
|
+
description="Meta's Code Llama. Solid general-purpose code model.",
|
|
78
|
+
tags=["code", "instruct"],
|
|
79
|
+
),
|
|
80
|
+
ModelCard(
|
|
81
|
+
name="deepseek-coder-v2:16b",
|
|
82
|
+
family="deepseek",
|
|
83
|
+
param_count="16B",
|
|
84
|
+
min_ram_gb=10,
|
|
85
|
+
min_vram_gb=10,
|
|
86
|
+
context_window=65536,
|
|
87
|
+
description="DeepSeek Coder V2. Strong MoE architecture.",
|
|
88
|
+
tags=["code", "instruct"],
|
|
89
|
+
),
|
|
90
|
+
# ── Large (8–16GB) ───────────────────────────────────────────
|
|
91
|
+
ModelCard(
|
|
92
|
+
name="qwen2.5-coder:14b",
|
|
93
|
+
family="qwen",
|
|
94
|
+
param_count="14B",
|
|
95
|
+
min_ram_gb=10,
|
|
96
|
+
min_vram_gb=10,
|
|
97
|
+
context_window=32768,
|
|
98
|
+
description="Excellent code model. Strong tool call support.",
|
|
99
|
+
tags=["code", "instruct", "recommended"],
|
|
100
|
+
),
|
|
101
|
+
ModelCard(
|
|
102
|
+
name="codellama:13b",
|
|
103
|
+
family="codellama",
|
|
104
|
+
param_count="13B",
|
|
105
|
+
min_ram_gb=10,
|
|
106
|
+
min_vram_gb=10,
|
|
107
|
+
context_window=16384,
|
|
108
|
+
description="Larger Code Llama with better reasoning.",
|
|
109
|
+
tags=["code", "instruct"],
|
|
110
|
+
),
|
|
111
|
+
# ── XL (16–32GB) ─────────────────────────────────────────────
|
|
112
|
+
ModelCard(
|
|
113
|
+
name="qwen2.5-coder:32b",
|
|
114
|
+
family="qwen",
|
|
115
|
+
param_count="32B",
|
|
116
|
+
min_ram_gb=20,
|
|
117
|
+
min_vram_gb=20,
|
|
118
|
+
context_window=32768,
|
|
119
|
+
description="Top-tier local code model. Excellent tool call reliability.",
|
|
120
|
+
tags=["code", "instruct", "recommended"],
|
|
121
|
+
),
|
|
122
|
+
ModelCard(
|
|
123
|
+
name="codellama:34b",
|
|
124
|
+
family="codellama",
|
|
125
|
+
param_count="34B",
|
|
126
|
+
min_ram_gb=22,
|
|
127
|
+
min_vram_gb=22,
|
|
128
|
+
context_window=16384,
|
|
129
|
+
description="Largest Code Llama. Near-cloud quality.",
|
|
130
|
+
tags=["code", "instruct"],
|
|
131
|
+
),
|
|
132
|
+
# ── Embedding models ─────────────────────────────────────────
|
|
133
|
+
ModelCard(
|
|
134
|
+
name="nomic-embed-text",
|
|
135
|
+
family="nomic",
|
|
136
|
+
param_count="137M",
|
|
137
|
+
min_ram_gb=1,
|
|
138
|
+
min_vram_gb=1,
|
|
139
|
+
context_window=8192,
|
|
140
|
+
description="Default embedding model for codebase indexing.",
|
|
141
|
+
tags=["embedding"],
|
|
142
|
+
tool_call_support=False,
|
|
143
|
+
),
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_registry() -> list[ModelCard]:
|
|
148
|
+
"""Return the full curated model registry."""
|
|
149
|
+
return list(CODING_MODELS)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def find_model(name: str) -> ModelCard | None:
|
|
153
|
+
"""Find a model in the registry by name (exact match)."""
|
|
154
|
+
for model in CODING_MODELS:
|
|
155
|
+
if model.name == name:
|
|
156
|
+
return model
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_coding_models() -> list[ModelCard]:
|
|
161
|
+
"""Return only coding models (exclude embedding models)."""
|
|
162
|
+
return [m for m in CODING_MODELS if "embedding" not in m.tags]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_embedding_models() -> list[ModelCard]:
|
|
166
|
+
"""Return only embedding models."""
|
|
167
|
+
return [m for m in CODING_MODELS if "embedding" in m.tags]
|
mita/models/server.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Ollama server lifecycle management — start, stop, health check.
|
|
2
|
+
|
|
3
|
+
Runs Ollama as a detached daemon tracked via a PID file so it persists
|
|
4
|
+
across short-lived CLI commands like ``mita models list``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from mita.models.ollama_client import OllamaClient
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_pid_dir() -> Path:
|
|
22
|
+
"""Return the directory for the PID file (lazy to avoid module-level Path.home())."""
|
|
23
|
+
return Path.home() / ".config" / "mita"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_pid_file() -> Path:
|
|
27
|
+
"""Return the PID file path (lazy to avoid module-level Path.home())."""
|
|
28
|
+
return _get_pid_dir() / "ollama.pid"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_ollama_binary() -> str | None:
|
|
32
|
+
"""Locate the ollama binary on the system."""
|
|
33
|
+
return shutil.which("ollama")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_server_running(host: str = "http://localhost:11434", timeout: int = 5) -> bool:
|
|
37
|
+
"""Check if an Ollama server is reachable."""
|
|
38
|
+
client = OllamaClient(host=host, timeout=timeout)
|
|
39
|
+
return client.is_running()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_pid() -> int | None:
|
|
43
|
+
"""Read the PID from the PID file, return None if missing or stale."""
|
|
44
|
+
try:
|
|
45
|
+
pid = int(_get_pid_file().read_text().strip())
|
|
46
|
+
# Check if process is alive
|
|
47
|
+
os.kill(pid, 0)
|
|
48
|
+
return pid
|
|
49
|
+
except (FileNotFoundError, ValueError, ProcessLookupError, PermissionError, OSError):
|
|
50
|
+
_remove_pid_file()
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_pid(pid: int) -> None:
|
|
55
|
+
"""Write a PID to the PID file."""
|
|
56
|
+
_get_pid_dir().mkdir(parents=True, exist_ok=True)
|
|
57
|
+
_get_pid_file().write_text(str(pid))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _remove_pid_file() -> None:
|
|
61
|
+
"""Remove the PID file if it exists."""
|
|
62
|
+
try:
|
|
63
|
+
_get_pid_file().unlink()
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def start_server(
|
|
69
|
+
host: str = "http://localhost:11434",
|
|
70
|
+
timeout: int = 30,
|
|
71
|
+
console: Console | None = None,
|
|
72
|
+
) -> bool:
|
|
73
|
+
"""Start the Ollama server as a detached daemon.
|
|
74
|
+
|
|
75
|
+
The server persists after Mita exits. Its PID is tracked in
|
|
76
|
+
``~/.config/mita/ollama.pid`` so ``stop_server`` can find it later.
|
|
77
|
+
|
|
78
|
+
Returns True if the server is running (either already was or we started it).
|
|
79
|
+
"""
|
|
80
|
+
if is_server_running(host, timeout=5):
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
binary = find_ollama_binary()
|
|
84
|
+
if binary is None:
|
|
85
|
+
if console:
|
|
86
|
+
console.print(
|
|
87
|
+
"[red]Ollama binary not found.[/red]\n"
|
|
88
|
+
"Install Ollama from [bold]https://ollama.com[/bold]"
|
|
89
|
+
)
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
if console:
|
|
93
|
+
console.print("[dim]Starting Ollama server...[/dim]")
|
|
94
|
+
|
|
95
|
+
env = os.environ.copy()
|
|
96
|
+
env_host = host.replace("http://", "").replace("https://", "")
|
|
97
|
+
env["OLLAMA_HOST"] = env_host
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
proc = subprocess.Popen(
|
|
101
|
+
[binary, "serve"],
|
|
102
|
+
stdout=subprocess.DEVNULL,
|
|
103
|
+
stderr=subprocess.DEVNULL,
|
|
104
|
+
env=env,
|
|
105
|
+
start_new_session=True,
|
|
106
|
+
)
|
|
107
|
+
except OSError as e:
|
|
108
|
+
if console:
|
|
109
|
+
console.print(f"[red]Failed to start Ollama: {e}[/red]")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
_write_pid(proc.pid)
|
|
113
|
+
|
|
114
|
+
# Wait for the server to become healthy
|
|
115
|
+
deadline = time.monotonic() + timeout
|
|
116
|
+
while time.monotonic() < deadline:
|
|
117
|
+
if proc.poll() is not None:
|
|
118
|
+
if console:
|
|
119
|
+
console.print("[red]Ollama server exited unexpectedly.[/red]")
|
|
120
|
+
_remove_pid_file()
|
|
121
|
+
return False
|
|
122
|
+
if is_server_running(host, timeout=2):
|
|
123
|
+
if console:
|
|
124
|
+
console.print("[green]Ollama server started.[/green]")
|
|
125
|
+
return True
|
|
126
|
+
time.sleep(0.5)
|
|
127
|
+
|
|
128
|
+
if console:
|
|
129
|
+
console.print("[red]Ollama server did not become ready in time.[/red]")
|
|
130
|
+
stop_server()
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def stop_server(console: Console | None = None) -> bool:
|
|
135
|
+
"""Stop the Ollama server if it was started by Mita (tracked via PID file).
|
|
136
|
+
|
|
137
|
+
Returns True if we stopped it, False if there was nothing to stop.
|
|
138
|
+
"""
|
|
139
|
+
pid = _read_pid()
|
|
140
|
+
if pid is None:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
os.kill(pid, signal.SIGTERM)
|
|
145
|
+
# Wait for graceful shutdown
|
|
146
|
+
deadline = time.monotonic() + 10
|
|
147
|
+
while time.monotonic() < deadline:
|
|
148
|
+
try:
|
|
149
|
+
os.kill(pid, 0)
|
|
150
|
+
time.sleep(0.25)
|
|
151
|
+
except ProcessLookupError:
|
|
152
|
+
break
|
|
153
|
+
else:
|
|
154
|
+
# Still alive after 10s — force kill
|
|
155
|
+
try:
|
|
156
|
+
os.kill(pid, signal.SIGKILL)
|
|
157
|
+
except ProcessLookupError:
|
|
158
|
+
pass
|
|
159
|
+
except ProcessLookupError:
|
|
160
|
+
pass # Already dead
|
|
161
|
+
|
|
162
|
+
_remove_pid_file()
|
|
163
|
+
|
|
164
|
+
if console:
|
|
165
|
+
console.print("[dim]Ollama server stopped.[/dim]")
|
|
166
|
+
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def is_managed() -> bool:
|
|
171
|
+
"""Return True if a Mita-started Ollama server is tracked and alive."""
|
|
172
|
+
return _read_pid() is not None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def ensure_server(
|
|
176
|
+
host: str = "http://localhost:11434",
|
|
177
|
+
auto_manage: bool = True,
|
|
178
|
+
console: Console | None = None,
|
|
179
|
+
) -> bool:
|
|
180
|
+
"""Ensure Ollama is available — start it if auto_manage is enabled."""
|
|
181
|
+
if is_server_running(host, timeout=5):
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
if not auto_manage:
|
|
185
|
+
if console:
|
|
186
|
+
console.print(
|
|
187
|
+
"[red]Cannot connect to Ollama.[/red]\n"
|
|
188
|
+
"Start it manually with [bold]ollama serve[/bold] "
|
|
189
|
+
"or set [bold]ollama.auto_manage = true[/bold] in your config."
|
|
190
|
+
)
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
return start_server(host=host, console=console)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def ensure_model(
|
|
197
|
+
model_name: str,
|
|
198
|
+
host: str = "http://localhost:11434",
|
|
199
|
+
timeout: int = 120,
|
|
200
|
+
console: Console | None = None,
|
|
201
|
+
) -> bool:
|
|
202
|
+
"""Ensure a model is installed, pulling it automatically if missing."""
|
|
203
|
+
client = OllamaClient(host=host, timeout=timeout)
|
|
204
|
+
|
|
205
|
+
# Check if model is already installed
|
|
206
|
+
try:
|
|
207
|
+
installed = client.list_models()
|
|
208
|
+
for m in installed:
|
|
209
|
+
# Match both exact name and name without tag
|
|
210
|
+
if m.name == model_name or m.name == f"{model_name}:latest":
|
|
211
|
+
return True
|
|
212
|
+
# Also match if user specified without tag
|
|
213
|
+
if m.name.split(":")[0] == model_name.split(":")[0] and (
|
|
214
|
+
":" not in model_name or m.name == model_name
|
|
215
|
+
):
|
|
216
|
+
return True
|
|
217
|
+
except (ConnectionError, OSError):
|
|
218
|
+
if console:
|
|
219
|
+
console.print("[red]Cannot connect to Ollama to check models.[/red]")
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
# Model not found — pull it
|
|
223
|
+
if console:
|
|
224
|
+
console.print(f"[yellow]Model '{model_name}' is not installed.[/yellow]")
|
|
225
|
+
console.print(f"[dim]Pulling {model_name}...[/dim]")
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
from rich.progress import (
|
|
229
|
+
BarColumn,
|
|
230
|
+
DownloadColumn,
|
|
231
|
+
Progress,
|
|
232
|
+
TextColumn,
|
|
233
|
+
TransferSpeedColumn,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if console:
|
|
237
|
+
with Progress(
|
|
238
|
+
TextColumn("[progress.description]{task.description}"),
|
|
239
|
+
BarColumn(),
|
|
240
|
+
DownloadColumn(),
|
|
241
|
+
TransferSpeedColumn(),
|
|
242
|
+
console=console,
|
|
243
|
+
) as progress:
|
|
244
|
+
task = progress.add_task("Downloading", total=None)
|
|
245
|
+
for update in client.pull(model_name, stream=True):
|
|
246
|
+
status = update.get("status", "")
|
|
247
|
+
completed = update.get("completed", 0)
|
|
248
|
+
total = update.get("total", 0)
|
|
249
|
+
if total > 0:
|
|
250
|
+
progress.update(task, completed=completed, total=total, description=status)
|
|
251
|
+
else:
|
|
252
|
+
progress.update(task, description=status)
|
|
253
|
+
progress.update(task, description="Complete")
|
|
254
|
+
console.print(f"[green]Model '{model_name}' pulled successfully.[/green]")
|
|
255
|
+
else:
|
|
256
|
+
for _update in client.pull(model_name, stream=False):
|
|
257
|
+
pass
|
|
258
|
+
return True
|
|
259
|
+
except Exception as e:
|
|
260
|
+
if console:
|
|
261
|
+
console.print(f"[red]Failed to pull '{model_name}': {e}[/red]")
|
|
262
|
+
return False
|
mita/plugins/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP plugin system — discover and call tools from MCP servers."""
|
mita/plugins/client.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""MCP client wrapper for a single plugin server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from contextlib import AsyncExitStack
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mcp import ClientSession, StdioServerParameters
|
|
11
|
+
from mcp.client.stdio import stdio_client
|
|
12
|
+
|
|
13
|
+
from mita.config.schema import PluginDefinition
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MCPPluginClient:
|
|
17
|
+
"""Manages connection to a single MCP server."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, plugin: PluginDefinition) -> None:
|
|
20
|
+
self._plugin = plugin
|
|
21
|
+
self._session: ClientSession | None = None
|
|
22
|
+
self._exit_stack: AsyncExitStack | None = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def name(self) -> str:
|
|
26
|
+
return self._plugin.name
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def transport(self) -> str:
|
|
30
|
+
return self._plugin.transport
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def connected(self) -> bool:
|
|
34
|
+
return self._session is not None
|
|
35
|
+
|
|
36
|
+
async def connect(self, timeout: float = 30.0) -> None:
|
|
37
|
+
"""Connect to the MCP server and initialize the session."""
|
|
38
|
+
if self._session is not None:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
self._exit_stack = AsyncExitStack()
|
|
42
|
+
|
|
43
|
+
if self._plugin.transport == "stdio":
|
|
44
|
+
await self._connect_stdio(timeout)
|
|
45
|
+
elif self._plugin.transport == "sse":
|
|
46
|
+
await self._connect_sse(timeout)
|
|
47
|
+
else:
|
|
48
|
+
raise ValueError(f"Unsupported transport: {self._plugin.transport}")
|
|
49
|
+
|
|
50
|
+
async def _connect_stdio(self, timeout: float) -> None:
|
|
51
|
+
"""Connect via stdio transport (subprocess)."""
|
|
52
|
+
assert self._exit_stack is not None
|
|
53
|
+
|
|
54
|
+
if not self._plugin.command:
|
|
55
|
+
raise ValueError(f"Plugin '{self.name}' requires a command for stdio transport")
|
|
56
|
+
|
|
57
|
+
# Build environment: inherit current env, overlay plugin-specific vars
|
|
58
|
+
env: dict[str, str] = {**os.environ, **self._plugin.env}
|
|
59
|
+
|
|
60
|
+
server_params = StdioServerParameters(
|
|
61
|
+
command=self._plugin.command,
|
|
62
|
+
args=self._plugin.args,
|
|
63
|
+
env=env,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
transport = await self._exit_stack.enter_async_context(stdio_client(server_params))
|
|
67
|
+
read_stream, write_stream = transport
|
|
68
|
+
|
|
69
|
+
session = await self._exit_stack.enter_async_context(
|
|
70
|
+
ClientSession(read_stream, write_stream)
|
|
71
|
+
)
|
|
72
|
+
await asyncio.wait_for(session.initialize(), timeout=timeout)
|
|
73
|
+
self._session = session
|
|
74
|
+
|
|
75
|
+
async def _connect_sse(self, timeout: float) -> None:
|
|
76
|
+
"""Connect via SSE transport (HTTP)."""
|
|
77
|
+
assert self._exit_stack is not None
|
|
78
|
+
|
|
79
|
+
if not self._plugin.url:
|
|
80
|
+
raise ValueError(f"Plugin '{self.name}' requires a url for SSE transport")
|
|
81
|
+
|
|
82
|
+
from mcp.client.sse import sse_client
|
|
83
|
+
|
|
84
|
+
transport = await self._exit_stack.enter_async_context(sse_client(self._plugin.url))
|
|
85
|
+
read_stream, write_stream = transport
|
|
86
|
+
|
|
87
|
+
session = await self._exit_stack.enter_async_context(
|
|
88
|
+
ClientSession(read_stream, write_stream)
|
|
89
|
+
)
|
|
90
|
+
await asyncio.wait_for(session.initialize(), timeout=timeout)
|
|
91
|
+
self._session = session
|
|
92
|
+
|
|
93
|
+
async def disconnect(self) -> None:
|
|
94
|
+
"""Disconnect from the MCP server and clean up resources."""
|
|
95
|
+
if self._exit_stack is not None:
|
|
96
|
+
await self._exit_stack.aclose()
|
|
97
|
+
self._exit_stack = None
|
|
98
|
+
self._session = None
|
|
99
|
+
|
|
100
|
+
async def list_tools(self) -> list[dict[str, Any]]:
|
|
101
|
+
"""List tools provided by this MCP server.
|
|
102
|
+
|
|
103
|
+
Returns a list of dicts with name, description, and inputSchema.
|
|
104
|
+
"""
|
|
105
|
+
if self._session is None:
|
|
106
|
+
raise RuntimeError(f"Plugin '{self.name}' is not connected")
|
|
107
|
+
|
|
108
|
+
response = await self._session.list_tools()
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
"name": tool.name,
|
|
112
|
+
"description": tool.description or "",
|
|
113
|
+
"inputSchema": tool.inputSchema,
|
|
114
|
+
}
|
|
115
|
+
for tool in response.tools
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
async def call_tool(
|
|
119
|
+
self, tool_name: str, arguments: dict[str, Any], timeout: float = 120.0
|
|
120
|
+
) -> str:
|
|
121
|
+
"""Call a tool on this MCP server.
|
|
122
|
+
|
|
123
|
+
Returns the text content from the tool result.
|
|
124
|
+
"""
|
|
125
|
+
if self._session is None:
|
|
126
|
+
raise RuntimeError(f"Plugin '{self.name}' is not connected")
|
|
127
|
+
|
|
128
|
+
result = await asyncio.wait_for(
|
|
129
|
+
self._session.call_tool(tool_name, arguments),
|
|
130
|
+
timeout=timeout,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Extract text content from result
|
|
134
|
+
parts: list[str] = []
|
|
135
|
+
for item in result.content:
|
|
136
|
+
if hasattr(item, "text"):
|
|
137
|
+
parts.append(item.text)
|
|
138
|
+
elif hasattr(item, "data"):
|
|
139
|
+
parts.append(f"[binary data: {getattr(item, 'mimeType', 'unknown')}]")
|
|
140
|
+
else:
|
|
141
|
+
parts.append(str(item))
|
|
142
|
+
return "\n".join(parts)
|
|
143
|
+
|
|
144
|
+
async def ping(self, timeout: float = 10.0) -> bool:
|
|
145
|
+
"""Ping the MCP server to check connectivity."""
|
|
146
|
+
if self._session is None:
|
|
147
|
+
return False
|
|
148
|
+
try:
|
|
149
|
+
await asyncio.wait_for(self._session.send_ping(), timeout=timeout)
|
|
150
|
+
return True
|
|
151
|
+
except (TimeoutError, ConnectionError, OSError):
|
|
152
|
+
return False
|