Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ env/
.env
.env.*

# Config (contains API keys and user settings)
config.yml

# IDE
.vscode/
.idea/
Expand All @@ -32,5 +35,12 @@ dist/
build/
*.egg-info/

# Generated by install.sh
bin/

# Crystallized skills (user-generated)
skills/
!skills/.gitkeep

# Test artifacts
test_doc.md
test_doc.md
9 changes: 8 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,11 @@ The "Aha!" moment. When a task is completed:
1. **Phase 1: Memory Foundation** (SQLite setup + Facilitator) — ✅ Complete
2. **Phase 2: Retrieval Integration** (Integrating semantic search into the system prompt) — ✅ Complete (sqlite-vec wired, numpy cosine fallback)
3. **Phase 3: The Learning Loop** (Implementing Skill Synthesis) — ✅ Complete (wired into MotionAgent.run)
4. **Phase 4: DX & Optimization** (Caveman mode & TUI) — 🟡 In Progress (Caveman bidirectional, TUI with orchestration panel; providers still mock)
4. **Phase 4: DX & Optimization** (Caveman mode & TUI) — ✅ Complete
- Caveman bidirectional compression wired into MotionAgent.run
- Professional TUI with 5 tabs (Chat, Tasks, Skills, Memory, Settings)
- Runtime provider/model switching via Select dropdown
- 4 native Textual themes (One Dark, Solarized Light, Nord, Dracula)
- Ctrl+C cancels requests; Ctrl+Q quits; graceful shutdown
- asyncio.Event-based task orchestration (no polling)
- Graceful KeyboardInterrupt handling in REPL mode
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1

# Command to run the TUI
CMD ["python3", "ui/tui.py"]
CMD ["python3", "main.py"]
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,30 @@ It treats every successful task trajectory as a learning event, crystallizing ex
Get the harness running in under 60 seconds.

```bash
# 1. Run the automated installer
# 1. Run the installer
chmod +x install.sh && ./install.sh

# 2. Refresh your shell
source ~/.config/fish/config.fish # or your shell equivalent
source ~/.config/fish/config.fish # or .zshrc / .bashrc

# 3. Launch the TUI
# 3. Configure your provider
cp config.example.yml config.yml
# Edit config.yml — set your API keys in .env or directly in config.yml

# 4. Launch
motion
```

### CLI Usage

```
motion # Launch TUI (default)
motion --provider ollama-cloud/glm-5.2 # TUI with specific provider/model
motion --chat # Legacy REPL mode
motion --list # List available providers/models
motion --test # Run Caveman compression test
```

*For detailed native installation and GPU configuration, see the [Setup Guide](docs/setup.md).*

---
Expand All @@ -67,9 +81,14 @@ When a complex task is solved, the harness doesn't just forget. It analyzes the

### 🎨 Pro-Grade TUI
A high-performance terminal interface built with `Textual`. Featuring:
- **Multi-Theme Support**: One Dark, Solarized Light, Nord, Dracula.
- **Live Task Monitoring**: Real-time status of parallel agent workers.
- **Workspace Management**: Instant context switching between projects.
- **5-Tab Interface**: Chat, Tasks, Skills, Memory, Settings
- **Provider Dropdown**: Switch models at runtime without restarting
- **4 Native Themes**: One Dark, Solarized Light, Nord, Dracula — cascade through every widget
- **Live Task Monitoring**: Real-time status of parallel agent workers with ⏳⚙️✅❌ indicators
- **Memory Search**: Hybrid semantic + keyword search directly in the TUI
- **Skill Browser**: Browse, search, and inspect crystallized skills
- **Dashboard Integration**: One-click open to the admin dashboard at `https://localhost:7860/`
- **Ctrl+C cancels requests** (doesn't kill the app), **Ctrl+Q quits**

---

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0-beta.1
0.2.0-beta.1
45 changes: 40 additions & 5 deletions config.yaml → config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,47 @@ default_theme: "one_dark"
max_parallel_tasks: null # Set to integer to override CPU-based auto-scaling

# Provider Configuration
# You can define multiple providers and pick one as default.
# Providers with a "models" list share endpoint/api_key/type across models.
# Use provider_id/model_name to pick a specific model (e.g. ollama-cloud/gemma3:12b).
# API keys can be set here or via environment variables:
# ANTHROPIC_API_KEY, OPENAI_API_KEY, PROXY_API_KEY
# ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_API_KEY, PROXY_API_KEY
providers:
default: "claude-3-5"
default: "ollama-cloud"

ollama-cloud:
name: "Ollama Cloud"
endpoint: "https://ollama.com/v1"
api_key: null # Set via OLLAMA_API_KEY env var
provider_type: "cloud"
default_model: "gemma3:12b"
models:
gemma3:12b:
temperature: 0.7
max_tokens: 4096
deepseek-v4-flash:
temperature: 0.7
max_tokens: 4096
qwen3-coder:480b:
temperature: 0.5
max_tokens: 8192
nematron-3-super:
temperature: 0.7
max_tokens: 4096
glm-5.2:
temperature: 0.7
max_tokens: 4096
gemma4:31b:
temperature: 0.7
max_tokens: 4096
qwen3.5:397b:
temperature: 0.7
max_tokens: 4096
glm-5.1:
temperature: 0.7
max_tokens: 4096
minimax-m2.7:
temperature: 0.7
max_tokens: 4096

claude-3-5:
name: "Claude 3.5 Sonnet"
Expand Down Expand Up @@ -43,9 +79,8 @@ providers:
max_tokens: 4096

# Embedding configuration
# Used for semantic search in the Hybrid Memory layer.
# Local providers use Ollama /api/embeddings; cloud-only setups
# fall back to a deterministic hash-based vector.
embedding:
provider: "local-llama" # Which provider to use for embeddings
provider: "local-llama"
dimension: 128
Empty file added core/__init__.py
Empty file.
103 changes: 96 additions & 7 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ class AppConfig:
default_provider: str

class ConfigManager:
def __init__(self, config_path: str = "config.yaml"):
self.config_path = config_path
CONFIG_PATHS = ["config.yml", "config.example.yml"]

def __init__(self, config_path: str = ""):
if config_path:
self.config_path = config_path
else:
# Try config.yml first, fall back to config.example.yml
self.config_path = next((p for p in self.CONFIG_PATHS if os.path.exists(p)), self.CONFIG_PATHS[0])
self.data = self._load_config()

def _load_config(self) -> Dict[str, Any]:
Expand All @@ -35,14 +41,97 @@ def set(self, key: str, value: Any):
yaml.dump(self.data, f)

def get_provider_config(self, provider_id: str) -> Dict[str, Any]:
"""Resolve a provider config, supporting provider/model syntax.

Examples:
'ollama-cloud' -> default model from default_model or options.model
'ollama-cloud/gemma4:31b' -> ollama-cloud with model overridden to gemma4:31b
"""
# Split provider/model if present
if '/' in provider_id:
base_id, model_name = provider_id.split('/', 1)
else:
base_id, model_name = provider_id, None

providers = self.data.get("providers", {})
config = providers.get(provider_id, {})
# Override api_key from environment if available
env_key = f"{provider_id.replace('-', '_').upper()}_API_KEY"
config = providers.get(base_id, {})
if not config:
raise ValueError(f"Unknown provider: {base_id}")

# Resolve api_key from environment
env_key = f"{base_id.replace('-', '_').upper()}_API_KEY"
env_val = os.environ.get(env_key)
if not env_val:
prefix = base_id.split('-')[0].upper()
generic_key = f"{prefix}_API_KEY"
env_val = os.environ.get(generic_key)
if env_val:
config["api_key"] = env_val
config = {**config, "api_key": env_val}

# Resolve model: explicit model_name > default_model > options.model
models = config.get("models", {})
if models:
# Provider uses models list
chosen_model = model_name or config.get("default_model") or next(iter(models))
if chosen_model not in models:
raise ValueError(f"Unknown model '{chosen_model}' for provider '{base_id}'. Available: {', '.join(models.keys())}")
model_opts = models[chosen_model]
config = {
**config,
"name": f"{config.get('name', base_id)} ({chosen_model})",
"options": {"model": chosen_model, **model_opts},
}
elif model_name:
# No models list, but user specified a model override
config = {
**config,
"options": {**config.get("options", {}), "model": model_name},
}

return config

def get_default_provider(self) -> str:
return os.environ.get("MOTION_DEFAULT_PROVIDER") or self.data.get("providers", {}).get("default", "claude-3-5")
return os.environ.get("MOTION_DEFAULT_PROVIDER") or self.data.get("providers", {}).get("default", "ollama-cloud")

def has_api_key(self, provider_id: str) -> bool:
"""Check whether a provider has a usable API key (env var or config)."""
if '/' in provider_id:
base_id = provider_id.split('/', 1)[0]
else:
base_id = provider_id

providers = self.data.get("providers", {})
cfg = providers.get(base_id, {})

# Check config file first
config_key = cfg.get("api_key")
if config_key:
return True

# Check environment variables
env_key = f"{base_id.replace('-', '_').upper()}_API_KEY"
if os.environ.get(env_key):
return True
prefix = base_id.split('-')[0].upper()
generic_key = f"{prefix}_API_KEY"
if os.environ.get(generic_key):
return True

# Local providers don't need a key
if cfg.get("provider_type") == "local":
return True

return False

def list_providers(self) -> list:
"""Return list of (provider_id, name, models, has_key) tuples."""
providers = self.data.get("providers", {})
default = self.get_default_provider()
result = []
for pid, cfg in providers.items():
if pid == "default":
continue
models = list(cfg.get("models", {}).keys()) if "models" in cfg else [cfg.get("options", {}).get("model", "?")]
has_key = self.has_api_key(pid)
result.append((pid, cfg.get("name", pid), models, pid == default.split('/')[0], has_key))
return result
31 changes: 23 additions & 8 deletions core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
from typing import List, Dict, Any, Optional
from dataclasses import dataclass, field
from datetime import datetime
import logging

from core.providers import ModelConfig, ProviderFactory
from main import MotionAgent

logger = logging.getLogger(__name__)

@dataclass
class TaskRequest:
prompt: str
Expand All @@ -24,10 +28,13 @@ class TaskStatus:
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None


class TaskManager:
"""
Orchestrates parallel execution of MotionAgent tasks.
Handles hardware-aware concurrency and per-task model routing.

Uses asyncio.Event per task for efficient notification instead of polling.
"""
def __init__(self, default_model_config: ModelConfig, workspace_path: str):
self.default_config = default_model_config
Expand All @@ -38,42 +45,49 @@ def __init__(self, default_model_config: ModelConfig, workspace_path: str):
self.semaphore = asyncio.Semaphore(self.max_workers)

self.tasks: Dict[str, TaskStatus] = {}
self._events: Dict[str, asyncio.Event] = {}
self.active_count = 0

async def spawn_task(self, request: TaskRequest, model_override: Optional[ModelConfig] = None) -> str:
"""
Queue a new task for execution. Returns the task_id.
Callers can await wait_for_task(task_id) instead of polling.
"""
self.tasks[request.task_id] = TaskStatus(
task_id=request.task_id,
prompt=request.prompt,
status="PENDING"
)
self._events[request.task_id] = asyncio.Event()

# Schedule execution without blocking the main loop
asyncio.create_task(self._execute_task(request, model_override))

return request.task_id

async def wait_for_task(self, task_id: str, timeout: Optional[float] = None) -> TaskStatus:
"""Wait for a task to complete. Returns the final TaskStatus."""
event = self._events.get(task_id)
if not event:
raise ValueError(f"Unknown task: {task_id}")
try:
await asyncio.wait_for(event.wait(), timeout=timeout)
except asyncio.TimeoutError:
pass
return self.tasks[task_id]

async def _execute_task(self, request: TaskRequest, model_override: Optional[ModelConfig] = None):
# Check capacity for warning
if self.semaphore.locked():
print(f"⚠️ Capacity Reached: {self.active_count}/{self.max_workers} workers active. Task {request.task_id} queued.")
logger.info(f"Capacity reached: {self.active_count}/{self.max_workers} workers. Task {request.task_id} queued.")

async with self.semaphore:
self.active_count += 1
self.tasks[request.task_id].status = "RUNNING"
self.tasks[request.task_id].start_time = datetime.now()

try:
# 1. Determine model config (Override -> Request ID -> Default)
config = model_override or self.default_config

# 2. Spawn isolated agent
# We pass the workspace_path to ensure the agent works in the correct root
agent = MotionAgent(config, memory_path=f"memory_{request.task_id}.db")

# 3. Execute
result = await agent.run(request.prompt, target="agent")

self.tasks[request.task_id].result = result
Expand All @@ -84,6 +98,7 @@ async def _execute_task(self, request: TaskRequest, model_override: Optional[Mod
finally:
self.tasks[request.task_id].end_time = datetime.now()
self.active_count -= 1
self._events[request.task_id].set()

def get_status(self) -> Dict[str, Any]:
return {
Expand Down
2 changes: 1 addition & 1 deletion core/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class CloudProvider(BaseProvider):

async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str:
endpoint = self.config.endpoint
api_key = self.config.api_key or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
api_key = self.config.api_key or os.environ.get("OLLAMA_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY", "")

# Detect provider family from endpoint
if "anthropic" in endpoint:
Expand Down
Loading
Loading