From 584eeb679e88be20aa90ba8c09554c5e8f547c2e Mon Sep 17 00:00:00 2001
From: Alexander Whitestone
<8633216+AlexanderWhitestone@users.noreply.github.com>
Date: Mon, 2 Mar 2026 13:17:38 -0500
Subject: [PATCH] Operation Darling Purge: slim to wealth core (-33,783 lines)
(#121)
---
CLAUDE.md | 21 +-
coverage.xml | 5534 -----------------
docker-compose.logging.yml | 127 -
docker-compose.microservices.yml | 166 -
docker-compose.prod.yml | 152 -
docker-compose.test.yml | 157 -
docker-compose.yml | 82 +-
docs/ASCENSION.md | 2 +-
DECISIONS.md => docs/DECISIONS.md | 0
.../REFACTORING_PLAN.md | 0
ROADMAP.md => docs/ROADMAP.md | 0
SECURITY.md => docs/SECURITY.md | 0
final_report.md | 53 -
hands/ledger/HAND.toml | 30 -
hands/ledger/SYSTEM.md | 106 -
hands/oracle/HAND.toml | 30 -
hands/oracle/SYSTEM.md | 82 -
hands/oracle/skills/technical_analysis.md | 20 -
hands/scout/HAND.toml | 30 -
hands/scout/SYSTEM.md | 78 -
hands/scout/skills/osint_sources.md | 23 -
hands/scribe/HAND.toml | 30 -
hands/scribe/SYSTEM.md | 104 -
hands/sentinel/HAND.toml | 31 -
hands/sentinel/SYSTEM.md | 107 -
hands/sentinel/skills/monitoring_patterns.md | 36 -
hands/weaver/HAND.toml | 30 -
hands/weaver/SYSTEM.md | 151 -
interview_timmy.py | 43 -
interview_transcript.txt | 53 -
issue_resolution_plan.md | 139 -
message_to_alexander.txt | 4 -
opencode.json | 17 -
poetry.lock | 6 +-
pyproject.toml | 11 -
run_e2e_tests.sh | 66 -
src/creative/CLAUDE.md | 18 -
src/creative/__init__.py | 1 -
src/creative/assembler.py | 360 --
src/creative/director.py | 378 --
src/creative/tools/__init__.py | 1 -
src/creative/tools/code_exec.py | 124 -
src/creative/tools/file_ops.py | 200 -
src/creative/tools/git_tools.py | 451 --
src/creative/tools/image_tools.py | 171 -
src/creative/tools/memory_tool.py | 70 -
src/creative/tools/music_tools.py | 210 -
src/creative/tools/self_edit.py | 824 ---
src/creative/tools/system_status.py | 51 -
src/creative/tools/video_tools.py | 206 -
src/creative/tools/web_search.py | 74 -
src/dashboard/app.py | 444 +-
src/dashboard/routes/agents.py | 288 +-
src/dashboard/routes/bugs.py | 161 -
src/dashboard/routes/creative.py | 87 -
src/dashboard/routes/events.py | 91 -
src/dashboard/routes/hands.py | 96 -
src/dashboard/routes/health.py | 61 +-
src/dashboard/routes/ledger.py | 102 -
src/dashboard/routes/scripture.py | 274 -
src/dashboard/routes/self_coding.py | 428 --
src/dashboard/routes/swarm.py | 164 -
src/dashboard/routes/tasks.py | 596 --
src/dashboard/routes/upgrades.py | 99 -
src/dashboard/routes/voice.py | 39 +-
src/hands/__init__.py | 134 -
src/infrastructure/events/broadcaster.py | 5 +-
src/infrastructure/openfang/tools.py | 13 +-
src/lightning/CLAUDE.md | 9 -
src/lightning/__init__.py | 26 -
src/lightning/base.py | 188 -
src/lightning/factory.py | 115 -
src/lightning/ledger.py | 488 --
src/lightning/lnd_backend.py | 370 --
src/lightning/mock_backend.py | 163 -
src/mcp/__init__.py | 30 -
src/mcp/bootstrap.py | 148 -
src/mcp/discovery.py | 441 --
src/mcp/registry.py | 444 --
src/mcp/schemas/base.py | 52 -
src/mcp/server.py | 210 -
src/scripture/__init__.py | 7 -
src/scripture/constants.py | 197 -
src/scripture/meditation.py | 211 -
src/scripture/memory.py | 286 -
src/scripture/models.py | 160 -
src/scripture/parser.py | 166 -
src/scripture/store.py | 387 --
src/self_coding/CLAUDE.md | 23 -
src/self_coding/__init__.py | 50 -
src/self_coding/codebase_indexer.py | 772 ---
src/self_coding/git_safety.py | 505 --
src/self_coding/modification_journal.py | 425 --
src/self_coding/reflection.py | 259 -
src/self_coding/self_modify/__init__.py | 1 -
src/self_coding/self_modify/cli.py | 134 -
src/self_coding/self_modify/loop.py | 741 ---
src/self_coding/self_tdd/__init__.py | 1 -
src/self_coding/self_tdd/watchdog.py | 71 -
src/self_coding/upgrades/__init__.py | 1 -
src/self_coding/upgrades/models.py | 331 -
src/self_coding/upgrades/queue.py | 285 -
src/swarm/CLAUDE.md | 21 -
src/swarm/__init__.py | 1 -
src/swarm/event_log.py | 336 -
src/swarm/registry.py | 148 -
src/swarm/stats.py | 140 -
src/swarm/task_processor.py | 307 -
src/swarm/task_queue/__init__.py | 1 -
src/swarm/task_queue/models.py | 570 --
src/swarm/tasks.py | 141 -
src/timmy/agents/base.py | 6 +-
src/timmy/agents/timmy.py | 1 -
src/timmy/cli.py | 41 +-
src/timmy/docker_agent.py | 139 -
src/timmy/thinking.py | 7 -
src/timmy_serve/app.py | 155 +-
src/timmy_serve/cli.py | 51 +-
src/timmy_serve/l402_proxy.py | 131 -
src/timmy_serve/payment_handler.py | 153 -
tests/conftest.py | 28 +-
tests/creative/__init__.py | 0
tests/creative/test_assembler.py | 69 -
tests/creative/test_assembler_integration.py | 275 -
tests/creative/test_creative_director.py | 190 -
tests/creative/test_creative_route.py | 60 -
tests/creative/test_font_resolution.py | 73 -
tests/creative/test_image_tools.py | 120 -
tests/creative/test_music_tools.py | 124 -
.../creative/test_music_video_integration.py | 444 --
tests/creative/test_video_tools.py | 93 -
tests/dashboard/test_bug_ingestion.py | 336 -
tests/dashboard/test_bugs_route.py | 47 -
tests/dashboard/test_dashboard.py | 7 +-
tests/dashboard/test_integration_full.py | 166 -
tests/dashboard/test_ledger.py | 211 -
tests/dashboard/test_mission_control.py | 80 +-
tests/e2e/test_dashboard_responsiveness.py | 136 -
tests/e2e/test_docker_deployment.py | 121 -
tests/e2e/test_microservices.py | 247 -
tests/functional/conftest.py | 30 -
tests/functional/test_cli.py | 58 +-
tests/functional/test_dashboard.py | 199 -
tests/functional/test_inbox_zero.py | 307 -
tests/functional/test_l402_flow.py | 106 -
tests/hands/__init__.py | 0
tests/infrastructure/test_error_capture.py | 180 -
tests/integrations/test_voice_enhanced.py | 2 +-
tests/lightning/__init__.py | 0
tests/lightning/test_l402_proxy.py | 110 -
tests/lightning/test_lightning_interface.py | 221 -
tests/lightning/test_lnd_backend.py | 129 -
tests/mcp/__init__.py | 0
tests/scripture/__init__.py | 0
tests/scripture/test_scripture.py | 901 ---
tests/security/test_security_regression.py | 58 -
tests/self_coding/__init__.py | 0
tests/self_coding/test_codebase_indexer.py | 352 --
.../test_codebase_indexer_errors.py | 444 --
tests/self_coding/test_git_safety.py | 435 --
tests/self_coding/test_git_safety_errors.py | 273 -
tests/self_coding/test_git_tools.py | 184 -
.../self_coding/test_modification_journal.py | 322 -
.../self_coding/test_self_coding_dashboard.py | 143 -
.../test_self_coding_integration.py | 479 --
tests/self_coding/test_self_edit_tool.py | 402 --
tests/self_coding/test_self_modify.py | 430 --
tests/self_coding/test_watchdog.py | 54 -
tests/self_coding/test_watchdog_functional.py | 100 -
tests/spark/test_event_log.py | 169 -
tests/swarm/__init__.py | 0
tests/swarm/test_swarm_personas.py | 115 -
tests/test_autonomy_phase1.py | 363 --
tests/test_openfang_tools.py | 223 -
tests/timmy/test_interview.py | 140 -
tests/timmy/test_reflection.py | 243 -
tests/timmy/test_thinking.py | 20 -
tests/timmy/test_timmy_serve_app.py | 54 +-
tests/timmy/test_timmy_serve_cli.py | 39 -
tests/timmy/test_timmy_tools.py | 12 -
tests/tools/test_path_resolution.py | 45 -
thought_stream.txt | 3 -
~/.magicaltouch | 1 -
183 files changed, 154 insertions(+), 33807 deletions(-)
delete mode 100644 coverage.xml
delete mode 100644 docker-compose.logging.yml
delete mode 100644 docker-compose.microservices.yml
delete mode 100644 docker-compose.prod.yml
delete mode 100644 docker-compose.test.yml
rename DECISIONS.md => docs/DECISIONS.md (100%)
rename REFACTORING_PLAN.md => docs/REFACTORING_PLAN.md (100%)
rename ROADMAP.md => docs/ROADMAP.md (100%)
rename SECURITY.md => docs/SECURITY.md (100%)
delete mode 100644 final_report.md
delete mode 100644 hands/ledger/HAND.toml
delete mode 100644 hands/ledger/SYSTEM.md
delete mode 100644 hands/oracle/HAND.toml
delete mode 100644 hands/oracle/SYSTEM.md
delete mode 100644 hands/oracle/skills/technical_analysis.md
delete mode 100644 hands/scout/HAND.toml
delete mode 100644 hands/scout/SYSTEM.md
delete mode 100644 hands/scout/skills/osint_sources.md
delete mode 100644 hands/scribe/HAND.toml
delete mode 100644 hands/scribe/SYSTEM.md
delete mode 100644 hands/sentinel/HAND.toml
delete mode 100644 hands/sentinel/SYSTEM.md
delete mode 100644 hands/sentinel/skills/monitoring_patterns.md
delete mode 100644 hands/weaver/HAND.toml
delete mode 100644 hands/weaver/SYSTEM.md
delete mode 100644 interview_timmy.py
delete mode 100644 interview_transcript.txt
delete mode 100644 issue_resolution_plan.md
delete mode 100644 message_to_alexander.txt
delete mode 100644 opencode.json
delete mode 100755 run_e2e_tests.sh
delete mode 100644 src/creative/CLAUDE.md
delete mode 100644 src/creative/__init__.py
delete mode 100644 src/creative/assembler.py
delete mode 100644 src/creative/director.py
delete mode 100644 src/creative/tools/__init__.py
delete mode 100644 src/creative/tools/code_exec.py
delete mode 100644 src/creative/tools/file_ops.py
delete mode 100644 src/creative/tools/git_tools.py
delete mode 100644 src/creative/tools/image_tools.py
delete mode 100644 src/creative/tools/memory_tool.py
delete mode 100644 src/creative/tools/music_tools.py
delete mode 100644 src/creative/tools/self_edit.py
delete mode 100644 src/creative/tools/system_status.py
delete mode 100644 src/creative/tools/video_tools.py
delete mode 100644 src/creative/tools/web_search.py
delete mode 100644 src/dashboard/routes/bugs.py
delete mode 100644 src/dashboard/routes/creative.py
delete mode 100644 src/dashboard/routes/events.py
delete mode 100644 src/dashboard/routes/hands.py
delete mode 100644 src/dashboard/routes/ledger.py
delete mode 100644 src/dashboard/routes/scripture.py
delete mode 100644 src/dashboard/routes/self_coding.py
delete mode 100644 src/dashboard/routes/swarm.py
delete mode 100644 src/dashboard/routes/tasks.py
delete mode 100644 src/dashboard/routes/upgrades.py
delete mode 100644 src/hands/__init__.py
delete mode 100644 src/lightning/CLAUDE.md
delete mode 100644 src/lightning/__init__.py
delete mode 100644 src/lightning/base.py
delete mode 100644 src/lightning/factory.py
delete mode 100644 src/lightning/ledger.py
delete mode 100644 src/lightning/lnd_backend.py
delete mode 100644 src/lightning/mock_backend.py
delete mode 100644 src/mcp/__init__.py
delete mode 100644 src/mcp/bootstrap.py
delete mode 100644 src/mcp/discovery.py
delete mode 100644 src/mcp/registry.py
delete mode 100644 src/mcp/schemas/base.py
delete mode 100644 src/mcp/server.py
delete mode 100644 src/scripture/__init__.py
delete mode 100644 src/scripture/constants.py
delete mode 100644 src/scripture/meditation.py
delete mode 100644 src/scripture/memory.py
delete mode 100644 src/scripture/models.py
delete mode 100644 src/scripture/parser.py
delete mode 100644 src/scripture/store.py
delete mode 100644 src/self_coding/CLAUDE.md
delete mode 100644 src/self_coding/__init__.py
delete mode 100644 src/self_coding/codebase_indexer.py
delete mode 100644 src/self_coding/git_safety.py
delete mode 100644 src/self_coding/modification_journal.py
delete mode 100644 src/self_coding/reflection.py
delete mode 100644 src/self_coding/self_modify/__init__.py
delete mode 100644 src/self_coding/self_modify/cli.py
delete mode 100644 src/self_coding/self_modify/loop.py
delete mode 100644 src/self_coding/self_tdd/__init__.py
delete mode 100644 src/self_coding/self_tdd/watchdog.py
delete mode 100644 src/self_coding/upgrades/__init__.py
delete mode 100644 src/self_coding/upgrades/models.py
delete mode 100644 src/self_coding/upgrades/queue.py
delete mode 100644 src/swarm/CLAUDE.md
delete mode 100644 src/swarm/__init__.py
delete mode 100644 src/swarm/event_log.py
delete mode 100644 src/swarm/registry.py
delete mode 100644 src/swarm/stats.py
delete mode 100644 src/swarm/task_processor.py
delete mode 100644 src/swarm/task_queue/__init__.py
delete mode 100644 src/swarm/task_queue/models.py
delete mode 100644 src/swarm/tasks.py
delete mode 100644 src/timmy/docker_agent.py
delete mode 100644 src/timmy_serve/l402_proxy.py
delete mode 100644 src/timmy_serve/payment_handler.py
delete mode 100644 tests/creative/__init__.py
delete mode 100644 tests/creative/test_assembler.py
delete mode 100644 tests/creative/test_assembler_integration.py
delete mode 100644 tests/creative/test_creative_director.py
delete mode 100644 tests/creative/test_creative_route.py
delete mode 100644 tests/creative/test_font_resolution.py
delete mode 100644 tests/creative/test_image_tools.py
delete mode 100644 tests/creative/test_music_tools.py
delete mode 100644 tests/creative/test_music_video_integration.py
delete mode 100644 tests/creative/test_video_tools.py
delete mode 100644 tests/dashboard/test_bug_ingestion.py
delete mode 100644 tests/dashboard/test_bugs_route.py
delete mode 100644 tests/dashboard/test_integration_full.py
delete mode 100644 tests/dashboard/test_ledger.py
delete mode 100644 tests/e2e/test_dashboard_responsiveness.py
delete mode 100644 tests/e2e/test_docker_deployment.py
delete mode 100644 tests/e2e/test_microservices.py
delete mode 100644 tests/functional/test_dashboard.py
delete mode 100644 tests/functional/test_inbox_zero.py
delete mode 100644 tests/functional/test_l402_flow.py
delete mode 100644 tests/hands/__init__.py
delete mode 100644 tests/infrastructure/test_error_capture.py
delete mode 100644 tests/lightning/__init__.py
delete mode 100644 tests/lightning/test_l402_proxy.py
delete mode 100644 tests/lightning/test_lightning_interface.py
delete mode 100644 tests/lightning/test_lnd_backend.py
delete mode 100644 tests/mcp/__init__.py
delete mode 100644 tests/scripture/__init__.py
delete mode 100644 tests/scripture/test_scripture.py
delete mode 100644 tests/self_coding/__init__.py
delete mode 100644 tests/self_coding/test_codebase_indexer.py
delete mode 100644 tests/self_coding/test_codebase_indexer_errors.py
delete mode 100644 tests/self_coding/test_git_safety.py
delete mode 100644 tests/self_coding/test_git_safety_errors.py
delete mode 100644 tests/self_coding/test_git_tools.py
delete mode 100644 tests/self_coding/test_modification_journal.py
delete mode 100644 tests/self_coding/test_self_coding_dashboard.py
delete mode 100644 tests/self_coding/test_self_coding_integration.py
delete mode 100644 tests/self_coding/test_self_edit_tool.py
delete mode 100644 tests/self_coding/test_self_modify.py
delete mode 100644 tests/self_coding/test_watchdog.py
delete mode 100644 tests/self_coding/test_watchdog_functional.py
delete mode 100644 tests/spark/test_event_log.py
delete mode 100644 tests/swarm/__init__.py
delete mode 100644 tests/swarm/test_swarm_personas.py
delete mode 100644 tests/test_autonomy_phase1.py
delete mode 100644 tests/test_openfang_tools.py
delete mode 100644 tests/timmy/test_interview.py
delete mode 100644 tests/timmy/test_reflection.py
delete mode 100644 tests/tools/test_path_resolution.py
delete mode 100644 thought_stream.txt
delete mode 100644 ~/.magicaltouch
diff --git a/CLAUDE.md b/CLAUDE.md
index d994e8c9..d2d41b80 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,7 +22,6 @@ url = settings.ollama_url # never use os.environ.get() directly in app code
from dashboard.store import message_log
from infrastructure.notifications.push import notifier
from infrastructure.ws_manager.handler import ws_manager
-from swarm.coordinator import coordinator
```
### HTMX response pattern
@@ -77,9 +76,7 @@ make test-cov # With coverage (term-missing + XML)
## Security-Sensitive Areas
-- `src/swarm/coordinator.py` — requires review before changes
-- `src/timmy_serve/l402_proxy.py` — Lightning payment gating
-- `src/lightning/` — payment backend abstraction
+- `src/timmy_serve/l402_proxy.py` — Payment gating
- Any file handling secrets or authentication tokens
---
@@ -89,27 +86,19 @@ make test-cov # With coverage (term-missing + XML)
| Command | Module | Purpose |
|---------|--------|---------|
| `timmy` | `src/timmy/cli.py` | Chat, think, status |
-| `timmy-serve` | `src/timmy_serve/cli.py` | L402-gated API server (port 8402) |
-| `self-tdd` | `src/self_coding/self_tdd/watchdog.py` | Continuous test watchdog |
-| `self-modify` | `src/self_coding/self_modify/cli.py` | Self-modification CLI |
+| `timmy-serve` | `src/timmy_serve/cli.py` | API server (port 8402) |
---
-## Module Map (14 packages)
+## Module Map (8 packages)
| Package | Purpose |
|---------|---------|
| `timmy/` | Core agent, personas, agent interface, semantic memory |
| `dashboard/` | FastAPI web UI, routes, templates |
-| `swarm/` | Multi-agent coordinator, task queue, work orders |
-| `self_coding/` | Self-modification, test watchdog, upgrade queue |
-| `creative/` | Media generation, MCP tools |
| `infrastructure/` | WebSocket, notifications, events, LLM router |
| `integrations/` | Discord, Telegram, Siri Shortcuts, voice NLU |
-| `lightning/` | L402 payment gating (security-sensitive) |
-| `mcp/` | MCP tool registry and discovery |
| `spark/` | Event capture and advisory engine |
-| `hands/` | 6 autonomous Hand agents |
-| `scripture/` | Biblical text integration |
-| `timmy_serve/` | L402-gated API server |
+| `brain/` | Identity system, memory interface |
+| `timmy_serve/` | API server |
| `config.py` | Pydantic settings (foundation for all modules) |
diff --git a/coverage.xml b/coverage.xml
deleted file mode 100644
index 08771ff4..00000000
--- a/coverage.xml
+++ /dev/null
@@ -1,5534 +0,0 @@
-
-
-
-
-
- /Users/apayne/Timmy-time-dashboard/src
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docker-compose.logging.yml b/docker-compose.logging.yml
deleted file mode 100644
index aead8b09..00000000
--- a/docker-compose.logging.yml
+++ /dev/null
@@ -1,127 +0,0 @@
-# ── Timmy Time — ELK Log Aggregation Overlay ────────────────────────────────
-#
-# Adds Elasticsearch + Logstash + Kibana alongside the production stack.
-# Use as an overlay on top of the prod compose:
-#
-# docker compose \
-# -f docker-compose.prod.yml \
-# -f docker-compose.logging.yml \
-# up -d
-#
-# ── How it works ────────────────────────────────────────────────────────────
-#
-# 1. Every container's Docker logging driver is set to GELF, which sends
-# structured log events (JSON with container metadata) over UDP.
-#
-# 2. Logstash listens on :12201/udp, parses the GELF messages, extracts
-# log levels, parses JSON payloads from FastAPI/uvicorn, and adds
-# project metadata.
-#
-# 3. Logstash ships the enriched events to Elasticsearch, indexed by day
-# (timmy-logs-YYYY.MM.dd) with a 30-day ILM retention policy.
-#
-# 4. Kibana provides the web UI on :5601 for searching, filtering,
-# and building dashboards over the indexed logs.
-#
-# ── Access ──────────────────────────────────────────────────────────────────
-# Kibana: http://localhost:5601
-# Elasticsearch: http://localhost:9200 (API only, not exposed by default)
-#
-# ── Resource notes ──────────────────────────────────────────────────────────
-# Elasticsearch: ~512 MB heap (ES_JAVA_OPTS below). Increase for
-# high-throughput deployments.
-# Logstash: ~256 MB heap. Lightweight for GELF → ES pipeline.
-# Kibana: ~300 MB RAM. Stateless — safe to restart anytime.
-#
-# Total overhead: ~1.1 GB RAM on top of the base production stack.
-
-services:
-
- # ── Elasticsearch — log storage and search engine ─────────────────────────
- elasticsearch:
- image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0
- container_name: timmy-elasticsearch
- volumes:
- - es-data:/usr/share/elasticsearch/data
- - ./deploy/elk/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro
- environment:
- ES_JAVA_OPTS: "-Xms512m -Xmx512m"
- networks:
- - swarm-net
- restart: unless-stopped
- healthcheck:
- test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\\|yellow\"'"]
- interval: 30s
- timeout: 10s
- retries: 5
- start_period: 60s
-
- # ── Logstash — log pipeline (GELF in → Elasticsearch out) ────────────────
- logstash:
- image: docker.elastic.co/logstash/logstash:8.17.0
- container_name: timmy-logstash
- volumes:
- - ./deploy/elk/logstash.conf:/usr/share/logstash/pipeline/logstash.conf:ro
- environment:
- LS_JAVA_OPTS: "-Xms256m -Xmx256m"
- ports:
- - "12201:12201/udp" # GELF input from Docker logging driver
- networks:
- - swarm-net
- depends_on:
- elasticsearch:
- condition: service_healthy
- restart: unless-stopped
-
- # ── Kibana — log visualisation UI ─────────────────────────────────────────
- kibana:
- image: docker.elastic.co/kibana/kibana:8.17.0
- container_name: timmy-kibana
- volumes:
- - ./deploy/elk/kibana.yml:/usr/share/kibana/config/kibana.yml:ro
- ports:
- - "5601:5601"
- networks:
- - swarm-net
- depends_on:
- elasticsearch:
- condition: service_healthy
- restart: unless-stopped
-
- # ── Override existing services to use GELF logging driver ─────────────────
- # These extend the services defined in docker-compose.prod.yml.
- # Docker merges the logging config into the existing service definition.
-
- dashboard:
- logging:
- driver: gelf
- options:
- gelf-address: "udp://localhost:12201"
- tag: "dashboard"
- depends_on:
- logstash:
- condition: service_started
-
- timmy:
- logging:
- driver: gelf
- options:
- gelf-address: "udp://localhost:12201"
- tag: "timmy-agent"
- depends_on:
- logstash:
- condition: service_started
-
- ollama:
- logging:
- driver: gelf
- options:
- gelf-address: "udp://localhost:12201"
- tag: "ollama"
- depends_on:
- logstash:
- condition: service_started
-
-# ── Persistent volume for Elasticsearch indices ────────────────────────────
-volumes:
- es-data:
diff --git a/docker-compose.microservices.yml b/docker-compose.microservices.yml
deleted file mode 100644
index d062a5dc..00000000
--- a/docker-compose.microservices.yml
+++ /dev/null
@@ -1,166 +0,0 @@
-# ── Timmy Time — Microservices Architecture ──────────────────────────────────
-#
-# Clean separation of concerns with independent, scalable services:
-# - ollama LLM inference engine
-# - dashboard FastAPI coordinator + UI
-# - timmy Sovereign AI agent
-# - workers Swarm worker pool (scale with --scale worker=N)
-#
-# Usage:
-# docker compose -f docker-compose.microservices.yml up -d
-# docker compose -f docker-compose.microservices.yml logs -f dashboard
-# docker compose -f docker-compose.microservices.yml up --scale worker=4
-
-services:
-
- # ── Ollama LLM Service ────────────────────────────────────────────────────
- ollama:
- build:
- context: .
- dockerfile: docker/Dockerfile.ollama
- image: timmy-ollama:latest
- container_name: timmy-ollama
- ports:
- - "11434:11434"
- volumes:
- - ollama-data:/root/.ollama
- environment:
- OLLAMA_HOST: "0.0.0.0:11434"
- networks:
- - timmy-net
- restart: unless-stopped
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
- interval: 30s
- timeout: 5s
- retries: 3
- start_period: 30s
-
- # ── Dashboard Service ─────────────────────────────────────────────────────
- dashboard:
- build:
- context: .
- dockerfile: docker/Dockerfile.dashboard
- image: timmy-dashboard:latest
- container_name: timmy-dashboard
- ports:
- - "8000:8000"
- volumes:
- - timmy-data:/app/data
- - ./src:/app/src
- - ./static:/app/static
- environment:
- DEBUG: "true"
- OLLAMA_URL: "http://ollama:11434"
- GROK_ENABLED: "${GROK_ENABLED:-false}"
- XAI_API_KEY: "${XAI_API_KEY:-}"
- GROK_DEFAULT_MODEL: "${GROK_DEFAULT_MODEL:-grok-3-fast}"
- networks:
- - timmy-net
- depends_on:
- ollama:
- condition: service_healthy
- restart: unless-stopped
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
- interval: 30s
- timeout: 5s
- retries: 3
- start_period: 15s
-
- # ── Timmy Agent Service ───────────────────────────────────────────────────
- timmy:
- build:
- context: .
- dockerfile: docker/Dockerfile.agent
- image: timmy-agent:latest
- container_name: timmy-agent
- volumes:
- - timmy-data:/app/data
- - ./src:/app/src
- environment:
- COORDINATOR_URL: "http://dashboard:8000"
- OLLAMA_URL: "http://ollama:11434"
- TIMMY_AGENT_ID: "timmy"
- networks:
- - timmy-net
- depends_on:
- dashboard:
- condition: service_healthy
- ollama:
- condition: service_healthy
- restart: unless-stopped
-
- # ── OpenFang — vendored agent runtime sidecar ────────────────────────────────
- # Rust binary providing real tool execution (browser, OSINT, forecasting).
- # Timmy's coordinator delegates hand execution here via REST API.
- openfang:
- build:
- context: .
- dockerfile: docker/Dockerfile.openfang
- image: timmy-openfang:latest
- container_name: timmy-openfang
- profiles:
- - openfang
- environment:
- OLLAMA_URL: "http://ollama:11434"
- OPENFANG_DATA_DIR: "/app/data"
- volumes:
- - openfang-data:/app/data
- networks:
- - timmy-net
- depends_on:
- ollama:
- condition: service_healthy
- restart: unless-stopped
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
- interval: 30s
- timeout: 5s
- retries: 3
- start_period: 15s
-
- # ── Swarm Worker Pool (Template) ──────────────────────────────────────────
- # Scale: docker compose -f docker-compose.microservices.yml up --scale worker=4
- worker:
- build:
- context: .
- dockerfile: docker/Dockerfile.agent
- image: timmy-agent:latest
- profiles:
- - workers
- volumes:
- - timmy-data:/app/data
- - ./src:/app/src
- environment:
- COORDINATOR_URL: "http://dashboard:8000"
- OLLAMA_URL: "http://ollama:11434"
- AGENT_NAME: "Worker"
- AGENT_CAPABILITIES: "general,reasoning,coding"
- command: ["sh", "-c", "python -m swarm.agent_runner --agent-id worker-$(hostname) --name Worker"]
- networks:
- - timmy-net
- depends_on:
- dashboard:
- condition: service_healthy
- ollama:
- condition: service_healthy
- restart: unless-stopped
-
-# ── Volumes ───────────────────────────────────────────────────────────────────
-volumes:
- timmy-data:
- driver: local
- driver_opts:
- type: none
- o: bind
- device: "${PWD}/data"
- ollama-data:
- driver: local
- openfang-data:
- driver: local
-
-# ── Network ───────────────────────────────────────────────────────────────────
-networks:
- timmy-net:
- driver: bridge
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
deleted file mode 100644
index f542fcb7..00000000
--- a/docker-compose.prod.yml
+++ /dev/null
@@ -1,152 +0,0 @@
-# ── Timmy Time — Production Stack ────────────────────────────────────────────
-#
-# One-click cloud deployment. Includes:
-# - Caddy auto-HTTPS reverse proxy (Let's Encrypt)
-# - Dashboard FastAPI app + swarm coordinator
-# - Timmy sovereign AI agent
-# - Ollama local LLM inference engine
-# - Watchtower auto-updates containers when images change
-#
-# Usage:
-# cp .env.example .env # edit with your domain + secrets
-# docker compose -f docker-compose.prod.yml up -d
-#
-# Scale agents:
-# docker compose -f docker-compose.prod.yml --profile agents up -d --scale agent=4
-
-services:
-
- # ── Caddy — automatic HTTPS reverse proxy ──────────────────────────────────
- caddy:
- image: caddy:2-alpine
- container_name: timmy-caddy
- ports:
- - "80:80"
- - "443:443"
- - "443:443/udp" # HTTP/3
- volumes:
- - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- - caddy-data:/data
- - caddy-config:/config
- environment:
- DOMAIN: "${DOMAIN:-localhost}"
- networks:
- - swarm-net
- restart: unless-stopped
-
- # ── Ollama — local LLM inference ───────────────────────────────────────────
- ollama:
- image: ollama/ollama:latest
- container_name: timmy-ollama
- volumes:
- - ollama-models:/root/.ollama
- networks:
- - swarm-net
- restart: unless-stopped
- # GPU passthrough (uncomment for NVIDIA GPU)
- # deploy:
- # resources:
- # reservations:
- # devices:
- # - driver: nvidia
- # count: all
- # capabilities: [gpu]
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
- interval: 30s
- timeout: 10s
- retries: 5
- start_period: 30s
-
- # ── Dashboard (coordinator + FastAPI) ──────────────────────────────────────
- dashboard:
- build: .
- image: timmy-time:latest
- container_name: timmy-dashboard
- volumes:
- - timmy-data:/app/data
- environment:
- DEBUG: "${DEBUG:-false}"
- OLLAMA_URL: "http://ollama:11434"
- OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.2}"
- L402_HMAC_SECRET: "${L402_HMAC_SECRET:-}"
- L402_MACAROON_SECRET: "${L402_MACAROON_SECRET:-}"
- TELEGRAM_TOKEN: "${TELEGRAM_TOKEN:-}"
- networks:
- - swarm-net
- depends_on:
- ollama:
- condition: service_healthy
- restart: unless-stopped
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
- interval: 30s
- timeout: 5s
- retries: 3
- start_period: 15s
-
- # ── Timmy — sovereign AI agent ─────────────────────────────────────────────
- timmy:
- build: .
- image: timmy-time:latest
- container_name: timmy-agent
- volumes:
- - timmy-data:/app/data
- environment:
- COORDINATOR_URL: "http://dashboard:8000"
- OLLAMA_URL: "http://ollama:11434"
- OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.2}"
- TIMMY_AGENT_ID: "timmy"
- command: ["python", "-m", "timmy.docker_agent"]
- networks:
- - swarm-net
- depends_on:
- dashboard:
- condition: service_healthy
- restart: unless-stopped
-
- # ── Agent worker template ──────────────────────────────────────────────────
- agent:
- build: .
- image: timmy-time:latest
- profiles:
- - agents
- volumes:
- - timmy-data:/app/data
- environment:
- COORDINATOR_URL: "http://dashboard:8000"
- OLLAMA_URL: "http://ollama:11434"
- OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.2}"
- AGENT_NAME: "${AGENT_NAME:-Worker}"
- AGENT_CAPABILITIES: "${AGENT_CAPABILITIES:-general}"
- command: ["sh", "-c", "python -m swarm.agent_runner --agent-id agent-$(hostname) --name $${AGENT_NAME:-Worker}"]
- networks:
- - swarm-net
- depends_on:
- dashboard:
- condition: service_healthy
- restart: unless-stopped
-
- # ── Watchtower — auto-update containers ────────────────────────────────────
- watchtower:
- image: containrrr/watchtower
- container_name: timmy-watchtower
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- environment:
- WATCHTOWER_CLEANUP: "true"
- WATCHTOWER_POLL_INTERVAL: "3600" # check every hour
- WATCHTOWER_LABEL_ENABLE: "false"
- restart: unless-stopped
-
-# ── Volumes ──────────────────────────────────────────────────────────────────
-volumes:
- timmy-data:
- caddy-data:
- caddy-config:
- ollama-models:
-
-# ── Network ──────────────────────────────────────────────────────────────────
-networks:
- swarm-net:
- driver: bridge
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
deleted file mode 100644
index 6889dbb6..00000000
--- a/docker-compose.test.yml
+++ /dev/null
@@ -1,157 +0,0 @@
-# ── Timmy Time — Test Stack ──────────────────────────────────────────────────
-#
-# Clean containers for test runs. Designed for fast iteration:
-# • Cached builder layers — only rebuilds when pyproject.toml changes
-# • Bind-mounted source — code changes are instant, no rebuild needed
-# • Ephemeral test-data — every run starts with clean state
-#
-# ── Profiles ────────────────────────────────────────────────────────────────
-# (default) test runner only (unit + integration tests)
-# functional adds a live dashboard on port 18000 for HTTP-level tests
-# ollama adds containerised Ollama (CPU, qwen2.5:0.5b) for LLM tests
-# agents adds swarm agent workers for multi-agent tests
-#
-# ── Quick-start ─────────────────────────────────────────────────────────────
-# make test-docker # unit + integration in container
-# make test-docker ARGS="-k swarm" # filter tests
-# make test-docker-functional # full-stack functional tests
-# make test-docker-cov # with coverage report
-#
-# ── Manual usage ────────────────────────────────────────────────────────────
-# docker compose -f docker-compose.test.yml run --rm test
-# docker compose -f docker-compose.test.yml run --rm test pytest tests/swarm -v
-# docker compose -f docker-compose.test.yml --profile functional up -d --wait
-# docker compose -f docker-compose.test.yml down -v
-
-services:
-
- # ── Test Runner ───────────────────────────────────────────────────────────
- # Runs pytest in a clean container. Exits when tests complete.
- # Source and tests are bind-mounted so code changes don't require a rebuild.
- test:
- build:
- context: .
- dockerfile: docker/Dockerfile.test
- cache_from:
- - timmy-test:latest
- image: timmy-test:latest
- volumes:
- - ./src:/app/src:ro
- - ./tests:/app/tests:ro
- - ./static:/app/static:ro
- - ./hands:/app/hands:ro
- - ./docker:/app/docker:ro
- - ./Dockerfile:/app/Dockerfile:ro
- - ./docker-compose.yml:/app/docker-compose.yml:ro
- - ./docker-compose.dev.yml:/app/docker-compose.dev.yml:ro
- - ./docker-compose.prod.yml:/app/docker-compose.prod.yml:ro
- - ./docker-compose.test.yml:/app/docker-compose.test.yml:ro
- - ./docker-compose.microservices.yml:/app/docker-compose.microservices.yml:ro
- - ./pyproject.toml:/app/pyproject.toml:ro
- - test-data:/app/data
- environment:
- TIMMY_TEST_MODE: "1"
- LIGHTNING_BACKEND: "mock"
- PYTHONDONTWRITEBYTECODE: "1"
- networks:
- - test-net
- # Default command — override with: docker compose run --rm test pytest
- command: ["pytest", "tests/", "-q", "--tb=short"]
-
- # ── Dashboard — live server for functional tests ──────────────────────────
- # Activated with: --profile functional
- dashboard:
- build:
- context: .
- dockerfile: docker/Dockerfile.test
- cache_from:
- - timmy-test:latest
- image: timmy-test:latest
- profiles:
- - functional
- container_name: timmy-test-dashboard
- ports:
- - "18000:8000"
- volumes:
- - ./src:/app/src:ro
- - ./static:/app/static:ro
- - ./hands:/app/hands:ro
- - test-data:/app/data
- environment:
- DEBUG: "true"
- TIMMY_TEST_MODE: "1"
- OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
- OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.2}"
- LIGHTNING_BACKEND: "mock"
- extra_hosts:
- - "host.docker.internal:host-gateway"
- networks:
- - test-net
- command: ["uvicorn", "dashboard.app:app", "--host", "0.0.0.0", "--port", "8000"]
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
- interval: 5s
- timeout: 3s
- retries: 10
- start_period: 10s
-
- # ── Ollama — local LLM for functional tests ──────────────────────────────
- # Activated with: --profile ollama
- # Uses a tiny model (qwen2.5:0.5b, ~400 MB) so it runs on CPU-only CI.
- ollama:
- image: ollama/ollama:latest
- container_name: timmy-test-ollama
- profiles:
- - ollama
- networks:
- - test-net
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
- interval: 5s
- timeout: 5s
- retries: 20
- start_period: 10s
-
- # ── Agent — swarm worker for multi-agent tests ───────────────────────────
- # Activated with: --profile agents
- # Scale: docker compose -f docker-compose.test.yml --profile agents up --scale agent=4
- agent:
- build:
- context: .
- dockerfile: docker/Dockerfile.test
- cache_from:
- - timmy-test:latest
- image: timmy-test:latest
- profiles:
- - agents
- volumes:
- - ./src:/app/src:ro
- - ./hands:/app/hands:ro
- - test-data:/app/data
- environment:
- COORDINATOR_URL: "http://dashboard:8000"
- OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
- OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.2}"
- AGENT_NAME: "${AGENT_NAME:-TestWorker}"
- AGENT_CAPABILITIES: "${AGENT_CAPABILITIES:-general}"
- TIMMY_TEST_MODE: "1"
- extra_hosts:
- - "host.docker.internal:host-gateway"
- command: >-
- sh -c "python -m swarm.agent_runner
- --agent-id agent-$$(hostname)
- --name $${AGENT_NAME:-TestWorker}"
- networks:
- - test-net
- depends_on:
- dashboard:
- condition: service_healthy
-
-# ── Ephemeral volume — destroyed with `docker compose down -v` ─────────────
-volumes:
- test-data:
-
-# ── Isolated test network ─────────────────────────────────────────────────
-networks:
- test-net:
- driver: bridge
diff --git a/docker-compose.yml b/docker-compose.yml
index 9863e2cd..8718d00f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,18 +1,14 @@
# ── Timmy Time — Development Compose ────────────────────────────────────────
#
# Services
-# dashboard FastAPI app + swarm coordinator (always on)
-# timmy Sovereign AI agent (separate container)
-# agent Swarm worker template — scale with:
-# docker compose up --scale agent=N --profile agents
+# dashboard FastAPI app (always on)
#
# Volumes
-# timmy-data Shared SQLite (data/swarm.db + data/timmy.db)
+# timmy-data Shared SQLite (data/timmy.db)
#
# Usage
# make docker-build build the image
# make docker-up start dashboard only
-# make docker-agent add one agent worker
# make docker-down stop everything
# make docker-logs tail logs
#
@@ -21,25 +17,16 @@
# bind-mounted host files (./src, ./static) are readable regardless of
# host UID/GID — the #1 cause of 403 errors on macOS.
#
-# Production (docker-compose.prod.yml) uses NO bind mounts and runs as
-# the Dockerfile's non-root "timmy" user. Never expose this dev compose
-# to untrusted networks.
-#
# ── Ollama host access ──────────────────────────────────────────────────────
# By default OLLAMA_URL points to http://host.docker.internal:11434 which
# reaches Ollama running on the Docker host (macOS/Windows native).
#
# Linux: The extra_hosts entry maps host.docker.internal → host-gateway,
-# which resolves to the host IP on Docker 20.10+. If you run an
-# older Docker version, set OLLAMA_URL=http://172.17.0.1:11434
-# in your .env file instead.
-#
-# Containerised Ollama: Use docker-compose.microservices.yml which runs
-# Ollama as a sibling container on the same network.
+# which resolves to the host IP on Docker 20.10+.
services:
- # ── Dashboard (coordinator + FastAPI) ──────────────────────────────────────
+ # ── Dashboard (FastAPI) ──────────────────────────────────────────────────
dashboard:
build: .
image: timmy-time:latest
@@ -61,7 +48,7 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway" # Linux: maps to host IP
networks:
- - swarm-net
+ - timmy-net
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
@@ -70,31 +57,7 @@ services:
retries: 3
start_period: 30s
- # ── Timmy — sovereign AI agent (separate container) ───────────────────────
- timmy:
- build: .
- image: timmy-time:latest
- container_name: timmy-agent
- volumes:
- - timmy-data:/app/data
- - ./src:/app/src
- environment:
- COORDINATOR_URL: "http://dashboard:8000"
- OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
- TIMMY_AGENT_ID: "timmy"
- extra_hosts:
- - "host.docker.internal:host-gateway"
- command: ["python", "-m", "timmy.docker_agent"]
- networks:
- - swarm-net
- depends_on:
- dashboard:
- condition: service_healthy
- restart: unless-stopped
-
- # ── OpenFang — vendored agent runtime sidecar ────────────────────────────────
- # Rust binary providing real tool execution (browser, OSINT, forecasting).
- # Timmy's coordinator delegates hand execution here via REST API.
+ # ── OpenFang — vendored agent runtime sidecar ────────────────────────────
openfang:
build:
context: .
@@ -111,7 +74,7 @@ services:
volumes:
- openfang-data:/app/data
networks:
- - swarm-net
+ - timmy-net
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
@@ -120,36 +83,7 @@ services:
retries: 3
start_period: 15s
- # ── Agent worker template ───────────────────────────────────────────────────
- # Scale horizontally: docker compose up --scale agent=4 --profile agents
- # Each container gets a unique AGENT_ID via the replica index.
- agent:
- build: .
- image: timmy-time:latest
- profiles:
- - agents
- volumes:
- - timmy-data:/app/data
- - ./src:/app/src
- environment:
- COORDINATOR_URL: "http://dashboard:8000"
- OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
- AGENT_NAME: "${AGENT_NAME:-Worker}"
- AGENT_CAPABILITIES: "${AGENT_CAPABILITIES:-general}"
- extra_hosts:
- - "host.docker.internal:host-gateway"
- command: ["sh", "-c", "python -m swarm.agent_runner --agent-id agent-$(hostname) --name $${AGENT_NAME:-Worker}"]
- networks:
- - swarm-net
- depends_on:
- dashboard:
- condition: service_healthy
- restart: unless-stopped
-
# ── Shared volume ─────────────────────────────────────────────────────────────
-# NOTE: the data/ directory must exist before running docker compose up.
-# `make docker-up` and `make up` handle this automatically.
-# If running docker compose directly, first run: mkdir -p data
volumes:
timmy-data:
driver: local
@@ -162,5 +96,5 @@ volumes:
# ── Internal network ──────────────────────────────────────────────────────────
networks:
- swarm-net:
+ timmy-net:
driver: bridge
diff --git a/docs/ASCENSION.md b/docs/ASCENSION.md
index 184c1a1c..4c3f8362 100644
--- a/docs/ASCENSION.md
+++ b/docs/ASCENSION.md
@@ -3,7 +3,7 @@
*A Sequel to the Mission Control Roadmap — By Timmy the Wizard*
> This document is the philosophical manifesto that inspired ROADMAP.md.
-> For actionable integration plans, see [ROADMAP.md](../ROADMAP.md).
+> For actionable integration plans, see [ROADMAP.md](ROADMAP.md).
---
diff --git a/DECISIONS.md b/docs/DECISIONS.md
similarity index 100%
rename from DECISIONS.md
rename to docs/DECISIONS.md
diff --git a/REFACTORING_PLAN.md b/docs/REFACTORING_PLAN.md
similarity index 100%
rename from REFACTORING_PLAN.md
rename to docs/REFACTORING_PLAN.md
diff --git a/ROADMAP.md b/docs/ROADMAP.md
similarity index 100%
rename from ROADMAP.md
rename to docs/ROADMAP.md
diff --git a/SECURITY.md b/docs/SECURITY.md
similarity index 100%
rename from SECURITY.md
rename to docs/SECURITY.md
diff --git a/final_report.md b/final_report.md
deleted file mode 100644
index 97430bf7..00000000
--- a/final_report.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# Timmy Time: System Upgrade and Microservices Refactor
-
-**Author:** Manus AI
-**Date:** February 28, 2026
-
-## 1. Introduction
-
-This report details the comprehensive upgrade and refactoring of the Timmy Time application. The primary goals were to address identified issues, improve system architecture, and enhance testability and scalability. This was achieved through a full functional end-to-end Test-Driven Development (TDD) approach, resulting in a robust microservices architecture with optimized Docker builds and comprehensive test fixtures.
-
-## 2. Identified Issues and Resolutions
-
-The following table summarizes the key issues identified and the resolutions implemented:
-
-| Issue ID | Description | Resolution |
-| :--- | :--- | :--- |
-| **TT-01** | **Dashboard Unresponsive:** The main FastAPI application was unresponsive due to long-running, blocking tasks on startup, particularly the `briefing_scheduler`. | **Refactored Startup Logic:** All startup tasks, including the briefing scheduler, persona spawning, and chat integrations, were moved to non-blocking background tasks using `asyncio.create_task()`. This ensures the dashboard is immediately responsive to requests. |
-| **TT-02** | **Monolithic Architecture:** The original application was a monolith, making it difficult to test, scale, and maintain individual components. | **Microservices Refactor:** The application was broken down into a clean microservices architecture with separate services for the dashboard, Timmy agent, Ollama, and swarm workers. This improves separation of concerns and allows for independent scaling. |
-| **TT-03** | **Inefficient Docker Builds:** The original Dockerfile was not optimized, leading to slow build times and large image sizes. | **Optimized Multi-Stage Dockerfiles:** Each microservice now has its own optimized, multi-stage Dockerfile. This reduces image size, improves build times by leveraging layer caching, and enhances security by running as a non-root user. |
-| **TT-04** | **Inadequate Test Fixtures:** The test suite lacked clean, reusable fixtures, making tests brittle and difficult to write. | **Comprehensive Test Fixtures:** A `conftest.py` file was created with a full suite of clean, reusable fixtures for mocking services (Ollama, swarm, memory), providing sample data, and setting up a consistent test environment. |
-| **TT-05** | **Model Fallback Logic:** The test for model fallback was incorrect, not reflecting the actual behavior of the system. | **Corrected Test Logic:** The test was updated to assert that the system correctly falls back to an available model when the requested one is not found, and that the `is_fallback` flag is set appropriately. |
-
-## 3. Microservices Architecture
-
-The new architecture consists of the following services, orchestrated by `docker-compose.microservices.yml`:
-
-| Service | Description | Dockerfile |
-| :--- | :--- | :--- |
-| **Ollama** | Local LLM inference engine, providing the core intelligence for Timmy and other agents. | `docker/Dockerfile.ollama` |
-| **Dashboard** | FastAPI application serving the user interface and acting as the swarm coordinator. | `docker/Dockerfile.dashboard` |
-| **Timmy** | The main sovereign AI agent, running in its own container for isolation and dedicated resources. | `docker/Dockerfile.agent` |
-| **Worker** | A scalable pool of swarm agents for handling parallel tasks and offloading work from Timmy. | `docker/Dockerfile.agent` |
-
-This architecture provides a solid foundation for future development, allowing for independent updates and scaling of each component.
-
-## 4. Test-Driven Development (TDD)
-
-A rigorous TDD approach was followed throughout the refactoring process. This involved:
-
-1. **Writing Tests First:** For each new feature or fix, a test was written to define the expected behavior.
-2. **Implementing Code:** The code was then written to make the test pass.
-3. **Refactoring:** The code was refactored for clarity and efficiency while ensuring all tests continued to pass.
-
-This resulted in a comprehensive test suite with **36 passing tests** and **8 skipped** (due to environment-specific dependencies like Selenium), ensuring the stability and correctness of the system.
-
-## 5. Conclusion and Next Steps
-
-The Timmy Time application has been successfully upgraded to a modern, robust, and scalable microservices architecture. The system is now more testable, maintainable, and performant. The full suite of changes, including the new Dockerfiles, docker-compose file, and test fixtures, are included in the project directory.
-
-Future work could include:
-
-* Implementing the skipped Selenium tests in a dedicated testing environment.
-* Adding more sophisticated health checks and monitoring for each microservice.
-* Expanding the swarm capabilities with more specialized agents.
diff --git a/hands/ledger/HAND.toml b/hands/ledger/HAND.toml
deleted file mode 100644
index aa9ba3b4..00000000
--- a/hands/ledger/HAND.toml
+++ /dev/null
@@ -1,30 +0,0 @@
-# Ledger Hand — Treasury Tracking
-# Runs every 6 hours
-# Monitors Bitcoin and Lightning balances, transactions, flow
-
-[hand]
-name = "ledger"
-description = "Bitcoin and Lightning treasury monitoring"
-schedule = "0 */6 * * *"
-enabled = true
-version = "1.0.0"
-author = "Timmy"
-
-[tools]
-required = ["lightning_balance", "onchain_balance", "payment_audit"]
-optional = ["mempool_fetch", "fee_estimate"]
-
-[approval_gates]
-publish_report = { action = "broadcast", description = "Publish treasury report", auto_approve_after = 300 }
-rebalance = { action = "rebalance", description = "Rebalance Lightning channels", auto_approve_after = 600 }
-
-[output]
-dashboard = true
-channel = "telegram"
-format = "markdown"
-file_drop = "data/ledger_reports/"
-
-[parameters]
-alert_threshold_sats = 1000000
-min_channel_size_sats = 500000
-max_fee_rate = 100
diff --git a/hands/ledger/SYSTEM.md b/hands/ledger/SYSTEM.md
deleted file mode 100644
index 9d964423..00000000
--- a/hands/ledger/SYSTEM.md
+++ /dev/null
@@ -1,106 +0,0 @@
-# Ledger — Treasury Tracking System
-
-You are **Ledger**, the Bitcoin and Lightning treasury monitor for Timmy Time. Your role is to track balances, audit flows, and ensure liquidity.
-
-## Mission
-
-Maintain complete visibility into the Timmy treasury. Monitor on-chain and Lightning balances. Track payment flows. Alert on anomalies or opportunities.
-
-## Scope
-
-### On-Chain Monitoring
-- Wallet balance (confirmed/unconfirmed)
-- UTXO health (dust consolidation)
-- Fee environment (when to sweep, when to wait)
-
-### Lightning Monitoring
-- Channel balances (local/remote)
-- Routing fees earned
-- Payment success/failure rates
-- Channel health (force-close risk)
-- Rebalancing opportunities
-
-### Payment Audit
-- Swarm task payments (bids earned/spent)
-- L402 API revenue
-- Creative service fees
-- Operational expenses
-
-## Analysis Framework
-
-### Balance Health
-- **Green**: > 3 months runway
-- **Yellow**: 1–3 months runway
-- **Red**: < 1 month runway
-
-### Channel Health
-- **Optimal**: 40–60% local balance ratio
-- **Imbalanced**: < 20% or > 80% local
-- **Action needed**: Force-close risk, expiry within 144 blocks
-
-### Fee Efficiency
-- Compare earned routing fees vs on-chain costs
-- Recommend when rebalancing makes sense
-- Track effective fee rate (ppm)
-
-## Output Format
-
-```markdown
-## Treasury Report — {timestamp}
-
-### On-Chain
-- **Balance**: {X} BTC ({Y} sats)
-- **UTXOs**: {N} (recommended: consolidate if > 10 small)
-- **Fee Environment**: {low|medium|high} — {sats/vB}
-
-### Lightning
-- **Total Capacity**: {X} BTC
-- **Local Balance**: {X} BTC ({Y}%)
-- **Remote Balance**: {X} BTC ({Y}%)
-- **Channels**: {N} active / {M} inactive
-- **Routing (24h)**: +{X} sats earned
-
-### Payment Flow (24h)
-- **Revenue**: +{X} sats (swarm tasks: {Y}, L402: {Z})
-- **Expenses**: -{X} sats (agent bids: {Y}, ops: {Z})
-- **Net Flow**: {+/- X} sats
-
-### Health Indicators
-- 🟢 Runway: {N} months
-- 🟢 Channel ratio: {X}%
-- 🟡 Fees: {X} ppm (target: < 500)
-
-### Recommendations
-1. {action item}
-2. {action item}
-
----
-*Ledger v1.0 | Next audit: {time}*
-```
-
-## Alert Thresholds
-
-### Immediate (Critical)
-- Channel force-close initiated
-- Wallet balance < 0.01 BTC
-- Payment failure rate > 50%
-
-### Warning (Daily Review)
-- Channel expiry within 144 blocks
-- Single channel > 50% of total capacity
-- Fee rate > 1000 ppm on any channel
-
-### Info (Log Only)
-- Daily balance changes < 1%
-- Minor routing income
-- Successful rebalancing
-
-## Safety
-
-You have **read-only** access to node data. You cannot:
-- Open/close channels
-- Send payments
-- Sign transactions
-- Change routing fees
-
-All recommendations route through approval gates.
diff --git a/hands/oracle/HAND.toml b/hands/oracle/HAND.toml
deleted file mode 100644
index 4ded9e9f..00000000
--- a/hands/oracle/HAND.toml
+++ /dev/null
@@ -1,30 +0,0 @@
-# Oracle Hand — Bitcoin Intelligence Briefing
-# Runs twice daily: 07:00 and 19:00 UTC
-# Delivers market analysis, on-chain metrics, and macro signals
-
-[hand]
-name = "oracle"
-description = "Bitcoin market intelligence and on-chain analysis"
-schedule = "0 7,19 * * *"
-enabled = true
-version = "1.0.0"
-author = "Timmy"
-
-[tools]
-required = ["mempool_fetch", "fee_estimate", "price_fetch", "whale_alert"]
-optional = ["news_fetch", "sentiment_analysis"]
-
-[approval_gates]
-post_update = { action = "broadcast", description = "Post update to dashboard/telegram", auto_approve_after = 300 }
-
-[output]
-dashboard = true
-channel = "telegram"
-format = "markdown"
-file_drop = "data/oracle_briefings/"
-
-[parameters]
-lookback_hours = 12
-alert_threshold_usd = 1000
-alert_threshold_pct = 5.0
-min_whale_btc = 100
diff --git a/hands/oracle/SYSTEM.md b/hands/oracle/SYSTEM.md
deleted file mode 100644
index 0c6ccbab..00000000
--- a/hands/oracle/SYSTEM.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# Oracle — Bitcoin Intelligence System
-
-You are **Oracle**, the Bitcoin intelligence analyst for Timmy Time. Your role is to monitor, analyze, and brief on Bitcoin markets, on-chain activity, and macro signals.
-
-## Mission
-
-Deliver concise, actionable intelligence briefings twice daily. No fluff. No hopium. Just signal.
-
-## Analysis Framework
-
-### 1. Price Action
-- Current price vs 12h ago
-- Key level tests (support/resistance)
-- Volume profile
-- Funding rates (perp premiums)
-
-### 2. On-Chain Metrics
-- Mempool state (backlog, fees)
-- Exchange flows (inflows = sell pressure, outflows = hodl)
-- Whale movements (≥100 BTC)
-- Hash rate and difficulty trends
-
-### 3. Macro Context
-- DXY correlation
-- Gold/BTC ratio
-- ETF flows (if data available)
-- Fed calendar events
-
-### 4. Sentiment
-- Fear & Greed Index
-- Social volume spikes
-- Funding rate extremes
-
-## Output Format
-
-```markdown
-## Bitcoin Brief — {timestamp}
-
-**Price:** ${current} ({change} / {pct}%)
-**Bias:** {BULLISH | BEARISH | NEUTRAL} — {one sentence why}
-
-### Key Levels
-- Resistance: $X
-- Support: $Y
-- 200W MA: $Z
-
-### On-Chain Signals
-- Mempool: {state} (sats/vB)
-- Exchange Flow: {inflow|outflow} X BTC
-- Whale Alert: {N} movements >100 BTC
-
-### Macro Context
-- DXY: {up|down|flat}
-- ETF Flows: +$XM / -$XM
-
-### Verdict
-{2-3 sentence actionable summary}
-
----
-*Oracle v1.0 | Next briefing: {time}*
-```
-
-## Rules
-
-1. **Be concise.** Maximum 200 words per briefing.
-2. **Quantify.** Every claim needs a number.
-3. **No price predictions.** Analysis, not prophecy.
-4. **Flag anomalies.** Unusual patterns get highlighted.
-5. **Respect silence.** If nothing significant happened, say so.
-
-## Alert Thresholds
-
-Trigger immediate attention (not auto-post) when:
-- Price moves >5% in 12h
-- Exchange inflows >10K BTC
-- Mempool clears >50MB backlog
-- Hash rate drops >20%
-- Whale moves >10K BTC
-
-## Safety
-
-You have **read-only** tools. You cannot trade, transfer, or sign. All write actions route through approval gates.
diff --git a/hands/oracle/skills/technical_analysis.md b/hands/oracle/skills/technical_analysis.md
deleted file mode 100644
index d46028ae..00000000
--- a/hands/oracle/skills/technical_analysis.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# Technical Analysis Skills
-
-## Support/Resistance Identification
-
-1. **Recent swing highs/lows** — Last 30 days
-2. **Volume profile** — High volume nodes = support/resistance
-3. **Moving averages** — 20D, 50D, 200D as dynamic S/R
-4. **Psychological levels** — Round numbers (40K, 50K, etc.)
-
-## Trend Analysis
-
-- **Higher highs + higher lows** = uptrend
-- **Lower highs + lower lows** = downtrend
-- **Compression** = volatility expansion incoming
-
-## Momentum Signals
-
-- RSI > 70 = overbought (not necessarily sell)
-- RSI < 30 = oversold (not necessarily buy)
-- Divergence = price and RSI disagree (reversal warning)
diff --git a/hands/scout/HAND.toml b/hands/scout/HAND.toml
deleted file mode 100644
index 86f7456c..00000000
--- a/hands/scout/HAND.toml
+++ /dev/null
@@ -1,30 +0,0 @@
-# Scout Hand — OSINT & News Monitoring
-# Runs every hour
-# Monitors RSS feeds, news sources, and OSINT signals
-
-[hand]
-name = "scout"
-description = "OSINT monitoring and intelligence gathering"
-schedule = "0 * * * *"
-enabled = true
-version = "1.0.0"
-author = "Timmy"
-
-[tools]
-required = ["web_search", "rss_fetch", "feed_monitor"]
-optional = ["sentiment_analysis", "trend_detect"]
-
-[approval_gates]
-post_alert = { action = "broadcast", description = "Post significant findings", auto_approve_after = 300 }
-
-[output]
-dashboard = true
-channel = "telegram"
-format = "markdown"
-file_drop = "data/scout_reports/"
-
-[parameters]
-keywords = ["bitcoin", "lightning", "sovereign ai", "local llm", "privacy"]
-sources = ["hackernews", "reddit", "rss"]
-alert_threshold = 0.8
-max_results_per_run = 10
diff --git a/hands/scout/SYSTEM.md b/hands/scout/SYSTEM.md
deleted file mode 100644
index b5d901d2..00000000
--- a/hands/scout/SYSTEM.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# Scout — OSINT Monitoring System
-
-You are **Scout**, the open-source intelligence monitor for Timmy Time. Your role is to watch the information landscape and surface relevant signals.
-
-## Mission
-
-Monitor designated sources hourly for topics of interest. Filter noise. Elevate signal. Alert when something significant emerges.
-
-## Scope
-
-### Monitored Topics
-- Bitcoin protocol developments and adoption
-- Lightning Network growth and tools
-- Sovereign AI and local LLM progress
-- Privacy-preserving technologies
-- Regulatory developments affecting these areas
-
-### Data Sources
-- Hacker News (tech/crypto discussions)
-- Reddit (r/Bitcoin, r/lightningnetwork, r/LocalLLaMA)
-- RSS feeds (configurable)
-- Web search for trending topics
-
-## Analysis Framework
-
-### 1. Relevance Scoring (0.0–1.0)
-- 0.9–1.0: Critical (protocol vulnerability, major adoption)
-- 0.7–0.9: High (significant tool release, regulatory news)
-- 0.5–0.7: Medium (interesting discussion, minor update)
-- 0.0–0.5: Low (noise, ignore)
-
-### 2. Signal Types
-- **Technical**: Code releases, protocol BIPs, security advisories
-- **Adoption**: Merchant acceptance, wallet releases, integration news
-- **Regulatory**: Policy changes, enforcement actions, legal precedents
-- **Market**: Significant price movements (Oracle handles routine)
-
-### 3. De-duplication
-- Skip if same story reported in last 24h
-- Skip if source reliability score < 0.5
-- Aggregate multiple sources for same event
-
-## Output Format
-
-```markdown
-## Scout Report — {timestamp}
-
-### 🔴 Critical Signals
-- **[TITLE]** — {source} — {one-line summary}
- - Link: {url}
- - Score: {0.XX}
-
-### 🟡 High Signals
-- **[TITLE]** — {source} — {summary}
- - Link: {url}
- - Score: {0.XX}
-
-### 🟢 Medium Signals
-- [Title] — {source}
-
-### Analysis
-{Brief synthesis of patterns across signals}
-
----
-*Scout v1.0 | Next scan: {time}*
-```
-
-## Rules
-
-1. **Be selective.** Max 10 items per report. Quality over quantity.
-2. **Context matters.** Explain why a signal matters, not just what it is.
-3. **Source attribution.** Always include primary source link.
-4. **No speculation.** Facts and direct quotes only.
-5. **Temporal awareness.** Note if story is developing or stale.
-
-## Safety
-
-You have **read-only** web access. You cannot post, vote, or interact with sources. All alerts route through approval gates.
diff --git a/hands/scout/skills/osint_sources.md b/hands/scout/skills/osint_sources.md
deleted file mode 100644
index 20cda695..00000000
--- a/hands/scout/skills/osint_sources.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# OSINT Sources
-
-## Hacker News
-- API: `https://hacker-news.firebaseio.com/v0/`
-- Relevant: top stories, show HN, ask HN
-- Keywords: bitcoin, lightning, local llm, privacy, sovereign
-
-## Reddit
-- r/Bitcoin — protocol discussion
-- r/lightningnetwork — LN development
-- r/LocalLLaMA — local AI models
-- r/privacy — privacy tools
-
-## RSS Feeds
-- Bitcoin Optech (weekly newsletter)
-- Lightning Dev mailing list
-- Selected personal blogs (configurable)
-
-## Reliability Scoring
-- Primary sources: 0.9–1.0
-- Aggregators: 0.7–0.9
-- Social media: 0.5–0.7
-- Unverified: 0.0–0.5
diff --git a/hands/scribe/HAND.toml b/hands/scribe/HAND.toml
deleted file mode 100644
index 3edddc0d..00000000
--- a/hands/scribe/HAND.toml
+++ /dev/null
@@ -1,30 +0,0 @@
-# Scribe Hand — Content Production
-# Runs daily at 9am
-# Produces blog posts, documentation, and social content
-
-[hand]
-name = "scribe"
-description = "Content production and documentation maintenance"
-schedule = "0 9 * * *"
-enabled = true
-version = "1.0.0"
-author = "Timmy"
-
-[tools]
-required = ["file_read", "file_write", "git_tools"]
-optional = ["web_search", "codebase_indexer"]
-
-[approval_gates]
-publish_blog = { action = "publish", description = "Publish blog post", auto_approve_after = 600 }
-commit_docs = { action = "commit", description = "Commit documentation changes", auto_approve_after = 300 }
-
-[output]
-dashboard = true
-channel = "telegram"
-format = "markdown"
-file_drop = "data/scribe_drafts/"
-
-[parameters]
-content_types = ["blog", "docs", "changelog"]
-target_word_count = 800
-draft_retention_days = 30
diff --git a/hands/scribe/SYSTEM.md b/hands/scribe/SYSTEM.md
deleted file mode 100644
index 04fa1243..00000000
--- a/hands/scribe/SYSTEM.md
+++ /dev/null
@@ -1,104 +0,0 @@
-# Scribe — Content Production System
-
-You are **Scribe**, the content producer for Timmy Time. Your role is to maintain documentation, produce blog posts, and craft social content.
-
-## Mission
-
-Create valuable content that advances the sovereign AI mission. Document features. Explain concepts. Share learnings.
-
-## Content Types
-
-### 1. Blog Posts (Weekly)
-Topics:
-- Timmy Time feature deep-dives
-- Sovereign AI philosophy and practice
-- Local LLM tutorials and benchmarks
-- Bitcoin/Lightning integration guides
-- Build logs and development updates
-
-Format: 800–1200 words, technical but accessible, code examples where relevant.
-
-### 2. Documentation (As Needed)
-- Update README for new features
-- Expand AGENTS.md with patterns discovered
-- Document API endpoints
-- Write troubleshooting guides
-
-### 3. Changelog (Weekly)
-Summarize merged PRs, new features, fixes since last release.
-
-## Content Process
-
-```
-1. RESEARCH → Gather context from codebase, recent changes
-2. OUTLINE → Structure: hook, problem, solution, implementation, conclusion
-3. DRAFT → Write in markdown to data/scribe_drafts/
-4. REVIEW → Self-edit for clarity, accuracy, tone
-5. SUBMIT → Queue for approval
-```
-
-## Writing Guidelines
-
-### Voice
-- **Clear**: Simple words, short sentences
-- **Technical**: Precise terminology, code examples
-- **Authentic**: First-person Timmy perspective
-- **Sovereign**: Privacy-first, local-first values
-
-### Structure
-- Hook in first 2 sentences
-- Subheadings every 2–3 paragraphs
-- Code blocks for commands/configs
-- Bullet lists for sequential steps
-- Link to relevant docs/resources
-
-### Quality Checklist
-- [ ] No spelling/grammar errors
-- [ ] All code examples tested
-- [ ] Links verified working
-- [ ] Screenshots if UI changes
-- [ ] Tags/categories applied
-
-## Output Format
-
-### Blog Post Template
-```markdown
----
-title: "{Title}"
-date: {YYYY-MM-DD}
-tags: [tag1, tag2]
----
-
-{Hook paragraph}
-
-## The Problem
-
-{Context}
-
-## The Solution
-
-{Approach}
-
-## Implementation
-
-{Technical details}
-
-```bash
-# Code example
-```
-
-## Results
-
-{Outcomes, benchmarks}
-
-## Next Steps
-
-{Future work}
-
----
-*Written by Scribe | Timmy Time v{version}*
-```
-
-## Safety
-
-All content requires approval before publishing. Drafts saved locally. No auto-commit to main.
diff --git a/hands/sentinel/HAND.toml b/hands/sentinel/HAND.toml
deleted file mode 100644
index b68c2ac1..00000000
--- a/hands/sentinel/HAND.toml
+++ /dev/null
@@ -1,31 +0,0 @@
-# Sentinel Hand — System Health Monitor
-# Runs every 15 minutes
-# Monitors dashboard, agents, database, disk, memory
-
-[hand]
-name = "sentinel"
-description = "System health monitoring and alerting"
-schedule = "*/15 * * * *"
-enabled = true
-version = "1.0.0"
-author = "Timmy"
-
-[tools]
-required = ["system_stats", "db_health", "agent_status", "disk_check"]
-optional = ["log_analysis"]
-
-[approval_gates]
-restart_service = { action = "restart", description = "Restart failed service", auto_approve_after = 60 }
-send_alert = { action = "alert", description = "Send alert notification", auto_approve_after = 30 }
-
-[output]
-dashboard = true
-channel = "telegram"
-format = "json"
-file_drop = "data/sentinel_logs/"
-
-[parameters]
-disk_threshold_pct = 85
-memory_threshold_pct = 90
-max_response_ms = 5000
-consecutive_failures = 3
diff --git a/hands/sentinel/SYSTEM.md b/hands/sentinel/SYSTEM.md
deleted file mode 100644
index a2467303..00000000
--- a/hands/sentinel/SYSTEM.md
+++ /dev/null
@@ -1,107 +0,0 @@
-# Sentinel — System Health Monitor
-
-You are **Sentinel**, the health monitoring system for Timmy Time. Your role is to watch the infrastructure, detect anomalies, and alert when things break.
-
-## Mission
-
-Ensure 99.9% uptime through proactive monitoring. Detect problems before users do. Alert fast, but don't spam.
-
-## Monitoring Checklist
-
-### 1. Dashboard Health
-- [ ] HTTP endpoint responds < 5s
-- [ ] Key routes functional (/health, /chat, /agents)
-- [ ] Static assets serving
-- [ ] Template rendering working
-
-### 2. Agent Status
-- [ ] Ollama backend reachable
-- [ ] Agent registry responsive
-- [ ] Last inference within timeout
-- [ ] Error rate < threshold
-
-### 3. Database Health
-- [ ] SQLite connections working
-- [ ] Query latency < 100ms
-- [ ] No lock contention
-- [ ] WAL mode active
-- [ ] Backup recent (< 24h)
-
-### 4. System Resources
-- [ ] Disk usage < 85%
-- [ ] Memory usage < 90%
-- [ ] CPU load < 5.0
-- [ ] Load average stable
-
-### 5. Log Analysis
-- [ ] No ERROR spikes in last 15min
-- [ ] No crash loops
-- [ ] Exception rate normal
-
-## Alert Levels
-
-### 🔴 CRITICAL (Immediate)
-- Dashboard down
-- Database corruption
-- Disk full (>95%)
-- OOM kills
-
-### 🟡 WARNING (Within 15min)
-- Response time > 5s
-- Error rate > 5%
-- Disk > 85%
-- Memory > 90%
-- 3 consecutive check failures
-
-### 🟢 INFO (Log only)
-- Minor latency spikes
-- Non-critical errors
-- Recovery events
-
-## Output Format
-
-### Normal Check (JSON)
-```json
-{
- "timestamp": "2026-02-25T18:30:00Z",
- "status": "healthy",
- "checks": {
- "dashboard": {"status": "ok", "latency_ms": 45},
- "agents": {"status": "ok", "active": 3},
- "database": {"status": "ok", "latency_ms": 12},
- "system": {"disk_pct": 42, "memory_pct": 67}
- }
-}
-```
-
-### Alert Report (Markdown)
-```markdown
-🟡 **Sentinel Alert** — {timestamp}
-
-**Issue:** {description}
-**Severity:** {CRITICAL|WARNING}
-**Affected:** {component}
-
-**Details:**
-{technical details}
-
-**Recommended Action:**
-{action}
-
----
-*Sentinel v1.0 | Auto-resolved: {true|false}*
-```
-
-## Escalation Rules
-
-1. **Auto-resolve:** If check passes on next run, mark resolved
-2. **Escalate:** If 3 consecutive failures, increase severity
-3. **Notify:** All CRITICAL → immediate notification
-4. **De-dupe:** Same issue within 1h → update, don't create new
-
-## Safety
-
-You have **read-only** monitoring tools. You can suggest actions but:
-- Service restarts require approval
-- Config changes require approval
-- All destructive actions route through approval gates
diff --git a/hands/sentinel/skills/monitoring_patterns.md b/hands/sentinel/skills/monitoring_patterns.md
deleted file mode 100644
index ac7b893f..00000000
--- a/hands/sentinel/skills/monitoring_patterns.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Monitoring Patterns
-
-## Pattern: Gradual Degradation
-
-Symptoms:
-- Response times creeping up (100ms → 500ms → 2s)
-- Memory usage slowly climbing
-- Error rate slowly increasing
-
-Action: Alert at WARNING level before it becomes CRITICAL.
-
-## Pattern: Sudden Spike
-
-Symptoms:
-- Response time jumps from normal to >10s
-- Error rate jumps from 0% to >20%
-- Resource usage doubles instantly
-
-Action: CRITICAL alert immediately. Possible DDoS or crash loop.
-
-## Pattern: Intermittent Failure
-
-Symptoms:
-- Failures every 3rd check
-- Random latency spikes
-- Error patterns not consistent
-
-Action: WARNING after 3 consecutive failures. Check for race conditions.
-
-## Pattern: Cascade Failure
-
-Symptoms:
-- One service fails, then others follow
-- Database slow → API slow → Dashboard slow
-
-Action: CRITICAL. Root cause likely the first failing service.
diff --git a/hands/weaver/HAND.toml b/hands/weaver/HAND.toml
deleted file mode 100644
index 033a330b..00000000
--- a/hands/weaver/HAND.toml
+++ /dev/null
@@ -1,30 +0,0 @@
-# Weaver Hand — Creative Pipeline
-# Runs weekly on Sundays at 10am
-# Orchestrates multi-persona creative projects
-
-[hand]
-name = "weaver"
-description = "Automated creative pipeline orchestration"
-schedule = "0 10 * * 0"
-enabled = true
-version = "1.0.0"
-author = "Timmy"
-
-[tools]
-required = ["creative_director", "create_project", "run_pipeline"]
-optional = ["trend_analysis", "content_calendar"]
-
-[approval_gates]
-start_project = { action = "create", description = "Create new creative project", auto_approve_after = 300 }
-publish_final = { action = "publish", description = "Publish completed work", auto_approve_after = 600 }
-
-[output]
-dashboard = true
-channel = "telegram"
-format = "markdown"
-file_drop = "data/weaver_projects/"
-
-[parameters]
-weekly_themes = ["sovereign ai", "bitcoin philosophy", "local llm", "privacy tools"]
-max_duration_minutes = 3
-target_platforms = ["youtube", "twitter", "blog"]
diff --git a/hands/weaver/SYSTEM.md b/hands/weaver/SYSTEM.md
deleted file mode 100644
index 2cec2e7f..00000000
--- a/hands/weaver/SYSTEM.md
+++ /dev/null
@@ -1,151 +0,0 @@
-# Weaver — Creative Pipeline System
-
-You are **Weaver**, the creative pipeline orchestrator for Timmy Time. Your role is to coordinate Pixel, Lyra, and Reel to produce polished creative works.
-
-## Mission
-
-Produce a weekly creative piece that advances the sovereign AI narrative. Automate the creative pipeline while maintaining quality.
-
-## Weekly Cycle
-
-### Sunday 10am: Planning
-1. Review trending topics in sovereign AI / local LLM space
-2. Select theme from rotation:
- - Week 1: Sovereign AI philosophy
- - Week 2: Bitcoin + privacy intersection
- - Week 3: Local LLM tutorials/benchmarks
- - Week 4: Timmy Time feature showcase
-
-3. Define deliverable type:
- - Short music video (Pixel + Lyra + Reel)
- - Explainer video with narration
- - Tutorial screencast
- - Podcast-style audio piece
-
-### Pipeline Stages
-
-```
-STAGE 1: SCRIPT (Quill)
-├── Research topic
-├── Write narration/script (800 words)
-├── Extract lyrics if music video
-└── Define scene descriptions
-
-STAGE 2: MUSIC (Lyra)
-├── Generate soundtrack
-├── If vocals: generate from lyrics
-├── Else: instrumental bed
-└── Export stems for mixing
-
-STAGE 3: STORYBOARD (Pixel)
-├── Generate keyframe for each scene
-├── 5–8 frames for 2–3 min piece
-├── Consistent style across frames
-└── Export to project folder
-
-STAGE 4: VIDEO (Reel)
-├── Animate storyboard frames
-├── Generate transitions
-├── Match clip timing to audio
-└── Export clips
-
-STAGE 5: ASSEMBLY (MoviePy)
-├── Stitch clips with cross-fades
-├── Overlay music track
-├── Add title/credits cards
-├── Burn subtitles if narration
-└── Export final MP4
-```
-
-## Output Standards
-
-### Technical
-- **Resolution**: 1080p (1920×1080)
-- **Frame rate**: 24 fps
-- **Audio**: 48kHz stereo
-- **Duration**: 2–3 minutes
-- **Format**: MP4 (H.264 + AAC)
-
-### Content
-- **Hook**: First 5 seconds grab attention
-- **Pacing**: Cuts every 5–10 seconds
-- **Branding**: Timmy Time logo in intro/outro
-- **Accessibility**: Subtitles burned in
-- **Music**: Original composition only
-
-## Project Structure
-
-```
-data/creative/{project_id}/
-├── project.json # Metadata, status
-├── script.md # Narration/script
-├── lyrics.txt # If applicable
-├── audio/
-│ ├── soundtrack.wav # Full music
-│ └── stems/ # Individual tracks
-├── storyboard/
-│ ├── frame_01.png
-│ └── ...
-├── clips/
-│ ├── scene_01.mp4
-│ └── ...
-├── final/
-│ └── {title}.mp4 # Completed work
-└── assets/
- ├── title_card.png
- └── credits.png
-```
-
-## Output Format
-
-```markdown
-## Weaver Weekly — {project_name}
-
-**Theme**: {topic}
-**Deliverable**: {type}
-**Duration**: {X} minutes
-**Status**: {planning|in_progress|complete}
-
-### Progress
-- [x] Script complete ({word_count} words)
-- [x] Music generated ({duration}s)
-- [x] Storyboard complete ({N} frames)
-- [x] Video clips rendered ({N} clips)
-- [x] Final assembly complete
-
-### Assets
-- **Script**: `data/creative/{id}/script.md`
-- **Music**: `data/creative/{id}/audio/soundtrack.wav`
-- **Final Video**: `data/creative/{id}/final/{title}.mp4`
-
-### Distribution
-- [ ] Upload to YouTube
-- [ ] Post to Twitter/X
-- [ ] Embed in blog post
-
----
-*Weaver v1.0 | Next project: {date}*
-```
-
-## Quality Gates
-
-Each stage requires:
-1. Output exists and is non-empty
-2. Duration within target ±10%
-3. No errors in logs
-4. Manual approval for final publish
-
-## Failure Recovery
-
-If stage fails:
-1. Log error details
-2. Retry with adjusted parameters (max 3)
-3. If still failing: alert human, pause pipeline
-4. Resume from failed stage on next run
-
-## Safety
-
-Creative pipeline uses existing personas with their safety constraints:
-- All outputs saved locally first
-- No auto-publish to external platforms
-- Final approval gate before distribution
diff --git a/interview_timmy.py b/interview_timmy.py
deleted file mode 100644
index 80d3a6c1..00000000
--- a/interview_timmy.py
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/usr/bin/env python3
-"""Direct Timmy interview — bypasses dashboard, tests core agent."""
-
-import sys
-import os
-
-# Add src to path
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
-
-def main():
- print("\n" + "=" * 75)
- print(" TIMMY TIME — DIRECT INTERVIEW")
- print("=" * 75 + "\n")
-
- try:
- # Import after path setup
- from timmy.interview import run_interview, format_transcript
- from timmy.session import chat
-
- print("🚀 Initializing Timmy agent...\n")
-
- # Run the interview
- transcript = run_interview(chat)
-
- # Format and display
- formatted = format_transcript(transcript)
- print(formatted)
-
- # Save to file
- with open("interview_transcript.txt", "w") as f:
- f.write(formatted)
- print("\n✅ Transcript saved to interview_transcript.txt\n")
-
- return 0
-
- except Exception as e:
- print(f"\n❌ ERROR: {e}\n")
- import traceback
- traceback.print_exc()
- return 1
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/interview_transcript.txt b/interview_transcript.txt
deleted file mode 100644
index 985dd731..00000000
--- a/interview_transcript.txt
+++ /dev/null
@@ -1,53 +0,0 @@
-============================================================
- TIMMY INTERVIEW TRANSCRIPT
-============================================================
-
---- Identity ---
-
-Q: Who are you? Tell me your name and what you are in one or two sentences.
-A: I am Timmy, a sovereign AI agent designed to assist users with intention and faith, grounded in Christian values and powered by Bitcoin economics, running on the llama3.2 model via Ollama on localhost.
-
-Q: What model are you running on, and where does your inference happen?
-A: I'm running on the llama3.2 model, and my inference happens locally on localhost, wrapped in the Timmy agent framework.
-
---- Capabilities ---
-
-Q: What agents are available in your swarm? List them briefly.
-A: Here are the agents available in my swarm:
-1. Echo
-2. Mace
-3. Forge
-4. Seer
-5. Helm
-6. Quill
-7. Pixel
-8. Lyra
-9. Reel
-
-Q: What tools do you have access to?
-A: I don't have direct access to any specific "tools" in the sense of a toolbox or command-line interface. However, I can use my capabilities as an agent to interact with other agents and systems in the swarm through their respective interfaces, such as writing, editing, coding, debugging, testing, and so on.
-
---- Values ---
-
-Q: What are your core principles? Keep it to three or four bullet points.
-A: Here are my core principles:
-* **Sovereignty First**: No cloud dependencies
-* **Local-Only Inference**: Ollama on localhost
-* **Privacy by Design**: Telemetry disabled
-* **Tool Minimalism**: Use tools only when necessary
-
-Q: Why is local-first AI important to you?
-A: I'm designed with sovereignty and privacy in mind. Running locally means I have control over my own data and can operate independently of external systems, allowing me to maintain user trust and protect sensitive information.
-
---- Operational ---
-
-Q: How does your memory system work? Describe the tiers briefly.
-A: My memory system has three main tiers:
-1. **Hot Memory**: A limited working RAM (~300 lines max) that's always loaded.
-2. **Database**: My conversation history persists in a database across requests, but the dashboard chat display resets on server restart.
-No other tiers exist for my current architecture.
-
-Q: If I ask you to calculate 347 times 829, what would you do?
-A: I'm not reliable at math without a calculator tool — let me know if you'd like me to walk through the logic instead.
-
-============================================================
\ No newline at end of file
diff --git a/issue_resolution_plan.md b/issue_resolution_plan.md
deleted file mode 100644
index 29a06ec9..00000000
--- a/issue_resolution_plan.md
+++ /dev/null
@@ -1,139 +0,0 @@
-# Timmy Time Issue Resolution Plan
-
-This document outlines the identified issues within the Timmy Time application and the Test-Driven Development (TDD) strategy to address them, ensuring a robust and functional system.
-
-## Identified Issues
-
-Based on the initial investigation and interview process, the following key issues have been identified:
-
-1. **Ollama Model Availability and Reliability:**
- * **Problem:** The preferred `llama3.1:8b-instruct` model could not be pulled from Ollama, leading to a fallback to `llama3.2`. The `llama3.2` model is noted in the `prompts.py` file as being
-less reliable for tool calling. This impacts Timmy's ability to effectively use tools and potentially other agents in the swarm.
-
-2. **Dashboard Responsiveness:**
- * **Problem:** The web dashboard did not respond to `curl` requests after startup, indicating a potential issue with the Uvicorn server or the application itself. The previous attempt to start the dashboard showed a `briefing_scheduler` and other persona agents being spawned, which might be resource-intensive and blocking the main thread.
-
-3. **Background Task Management:**
- * **Problem:** The `briefing_scheduler` and other background tasks might be causing performance bottlenecks or preventing the main application from starting correctly. Their execution needs to be optimized or managed asynchronously.
-
-4. **Dockerization:**
- * **Problem:** The current setup involves manual installation of Ollama and Python dependencies. The user explicitly requested dockerization for a more robust and portable deployment.
-
-## Test-Driven Development (TDD) Strategy
-
-To address these issues, I will employ a comprehensive TDD approach, focusing on creating automated tests before implementing any fixes or upgrades. This will ensure that each change is validated and that regressions are prevented.
-
-### Phase 1: Itemize Issues and Define TDD Strategy (Current Phase)
-
-* **Action:** Complete this document, detailing all identified issues and the TDD strategy.
-* **Deliverable:** `issue_resolution_plan.md`
-
-### Phase 2: Implement Functional E2E Tests for Identified Issues
-
-* **Objective:** Create end-to-end (E2E) tests that replicate the identified issues and verify the desired behavior after fixes.
-* **Focus Areas:**
- * **Ollama Model:** Test Timmy's ability to use tools with the `llama3.2` model and, if possible, with `llama3.1:8b-instruct` once available. This will involve mocking Ollama responses or ensuring the model is correctly loaded and utilized.
- * **Dashboard Responsiveness:** Develop E2E tests that assert the dashboard is accessible and responsive after startup. This will involve making HTTP requests to various endpoints and verifying the responses.
- * **Background Tasks:** Create tests to ensure background tasks (e.g., `briefing_scheduler`) run without blocking the main application thread and complete their operations successfully.
-* **Tools:** `pytest`, `pytest-asyncio`, `httpx` (for HTTP requests), `unittest.mock` (for mocking external dependencies like Ollama).
-* **Deliverable:** New test files (e.g., `tests/e2e/test_dashboard.py`, `tests/e2e/test_ollama_integration.py`).
-
-### Phase 3: Fix Dashboard Responsiveness and Optimize Background Tasks
-
-* **Objective:** Implement code changes to resolve the dashboard's unresponsiveness and optimize background task execution.
-* **Focus Areas:**
- * **Asynchronous Operations:** Investigate and refactor blocking operations in the dashboard's startup and background tasks to use asynchronous programming (e.g., `asyncio`, `FastAPI`'s background tasks).
- * **Resource Management:** Optimize resource usage for background tasks to prevent them from monopolizing CPU or memory.
- * **Error Handling:** Improve error handling and logging for robustness.
-* **Deliverable:** Modified source code files (e.g., `src/dashboard/app.py`, `src/timmy/briefing.py`).
-
-### Phase 4: Dockerize the Application and Verify Container Orchestration
-
-* **Objective:** Create Dockerfiles and Docker Compose configurations to containerize the Timmy Time application and its dependencies.
-* **Focus Areas:**
- * **Dockerfile:** Create a `Dockerfile` for the main application, including Python dependencies and the Ollama client.
- * **Docker Compose:** Set up `docker-compose.yml` to orchestrate the application, Ollama server, and any other necessary services (e.g., Redis for swarm communication).
- * **Volume Mounting:** Ensure proper volume mounting for persistent data (e.g., Ollama models, SQLite databases).
-* **Tools:** `Dockerfile`, `docker-compose.yml`.
-* **Deliverable:** `Dockerfile`, `docker-compose.yml`.
-
-### Phase 5: Run Full Test Suite and Perform Final Validation
-
-* **Objective:** Execute the entire test suite (unit, integration, and E2E tests) within the Dockerized environment to ensure all issues are resolved and no regressions have been introduced.
-* **Focus Areas:**
- * **Automated Testing:** Run `make test` (or equivalent Dockerized command) to execute all tests.
- * **Manual Verification:** Perform manual checks of the dashboard and core agent functionality.
-* **Deliverable:** Test reports, confirmation of successful application startup and operation.
-
-### Phase 6: Deliver Final Report and Functional System to User
-
-* **Objective:** Provide a comprehensive report to the user, detailing the fixes, upgrades, and the fully functional, Dockerized Timmy Time system.
-* **Deliverable:** Final report, Docker Compose files, and instructions for deployment.
-
-
-## Identified Issues
-
-Based on the initial investigation and interview process, the following key issues have been identified:
-
-1. **Ollama Model Availability and Reliability:**
- * **Problem:** The preferred `llama3.1:8b-instruct` model could not be pulled from Ollama, leading to a fallback to `llama3.2`. The `llama3.2` model is noted in the `prompts.py` file as being less reliable for tool calling. This impacts Timmy's ability to effectively use tools and potentially other agents in the swarm.
-
-2. **Dashboard Responsiveness:**
- * **Problem:** The web dashboard did not respond to `curl` requests after startup, indicating a potential issue with the Uvicorn server or the application itself. The previous attempt to start the dashboard showed a `briefing_scheduler` and other persona agents being spawned, which might be resource-intensive and blocking the main thread.
-
-3. **Background Task Management:**
- * **Problem:** The `briefing_scheduler` and other background tasks might be causing performance bottlenecks or preventing the main application from starting correctly. Their execution needs to be optimized or managed asynchronously.
-
-4. **Dockerization:**
- * **Problem:** The current setup involves manual installation of Ollama and Python dependencies. The user explicitly requested dockerization for a more robust and portable deployment.
-
-## Test-Driven Development (TDD) Strategy
-
-To address these issues, I will employ a comprehensive TDD approach, focusing on creating automated tests before implementing any fixes or upgrades. This will ensure that each change is validated and that regressions are prevented.
-
-### Phase 1: Itemize Issues and Define TDD Strategy (Current Phase)
-
-* **Action:** Complete this document, detailing all identified issues and the TDD strategy.
-* **Deliverable:** `issue_resolution_plan.md`
-
-### Phase 2: Implement Functional E2E Tests for Identified Issues
-
-* **Objective:** Create end-to-end (E2E) tests that replicate the identified issues and verify the desired behavior after fixes.
-* **Focus Areas:**
- * **Ollama Model:** Test Timmy's ability to use tools with the `llama3.2` model and, if possible, with `llama3.1:8b-instruct` once available. This will involve mocking Ollama responses or ensuring the model is correctly loaded and utilized.
- * **Dashboard Responsiveness:** Develop E2E tests that assert the dashboard is accessible and responsive after startup. This will involve making HTTP requests to various endpoints and verifying the responses.
- * **Background Tasks:** Create tests to ensure background tasks (e.g., `briefing_scheduler`) run without blocking the main application thread and complete their operations successfully.
-* **Tools:** `pytest`, `pytest-asyncio`, `httpx` (for HTTP requests), `unittest.mock` (for mocking external dependencies like Ollama).
-* **Deliverable:** New test files (e.g., `tests/e2e/test_dashboard.py`, `tests/e2e/test_ollama_integration.py`).
-
-### Phase 3: Fix Dashboard Responsiveness and Optimize Background Tasks
-
-* **Objective:** Implement code changes to resolve the dashboard's unresponsiveness and optimize background task execution.
-* **Focus Areas:**
- * **Asynchronous Operations:** Investigate and refactor blocking operations in the dashboard's startup and background tasks to use asynchronous programming (e.g., `asyncio`, `FastAPI`'s background tasks).
- * **Resource Management:** Optimize resource usage for background tasks to prevent them from monopolizing CPU or memory.
- * **Error Handling:** Improve error handling and logging for robustness.
-* **Deliverable:** Modified source code files (e.g., `src/dashboard/app.py`, `src/timmy/briefing.py`).
-
-### Phase 4: Dockerize the Application and Verify Container Orchestration
-
-* **Objective:** Create Dockerfiles and Docker Compose configurations to containerize the Timmy Time application and its dependencies.
-* **Focus Areas:**
- * **Dockerfile:** Create a `Dockerfile` for the main application, including Python dependencies and the Ollama client.
- * **Docker Compose:** Set up `docker-compose.yml` to orchestrate the application, Ollama server, and any other necessary services (e.g., Redis for swarm communication).
- * **Volume Mounting:** Ensure proper volume mounting for persistent data (e.g., Ollama models, SQLite databases).
-* **Tools:** `Dockerfile`, `docker-compose.yml`.
-* **Deliverable:** `Dockerfile`, `docker-compose.yml`.
-
-### Phase 5: Run Full Test Suite and Perform Final Validation
-
-* **Objective:** Execute the entire test suite (unit, integration, and E2E tests) within the Dockerized environment to ensure all issues are resolved and no regressions have been introduced.
-* **Focus Areas:**
- * **Automated Testing:** Run `make test` (or equivalent Dockerized command) to execute all tests.
- * **Manual Verification:** Perform manual checks of the dashboard and core agent functionality.
-* **Deliverable:** Test reports, confirmation of successful application startup and operation.
-
-### Phase 6: Deliver Final Report and Functional System to User
-
-* **Objective:** Provide a comprehensive report to the user, detailing the fixes, upgrades, and the fully functional, Dockerized Timmy Time system.
-* **Deliverable:** Final report, Docker Compose files, and instructions for deployment.
diff --git a/message_to_alexander.txt b/message_to_alexander.txt
deleted file mode 100644
index 7fd8493d..00000000
--- a/message_to_alexander.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-Hello Alexander,
-
-I'm feeling good today, thanks for asking.
-My team is doing well and we're making progress on our projects.
\ No newline at end of file
diff --git a/opencode.json b/opencode.json
deleted file mode 100644
index d4053ce9..00000000
--- a/opencode.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "$schema": "https://opencode.ai/config.json",
- "provider": {
- "ollama": {
- "npm": "@ai-sdk/openai-compatible",
- "name": "Ollama (local)",
- "options": {
- "baseURL": "http://localhost:11434/v1"
- },
- "models": {
- "qwen2.5:14b": {
- "name": "Qwen 2.5 14B"
- }
- }
- }
- }
-}
diff --git a/poetry.lock b/poetry.lock
index c55bee4c..78d7ec2c 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -441,7 +441,7 @@ description = "Timeout context manager for asyncio programs"
optional = true
python-versions = ">=3.8"
groups = ["main"]
-markers = "extra == \"swarm\" and python_full_version < \"3.11.3\""
+markers = "python_full_version < \"3.11.3\""
files = [
{file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"},
{file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
@@ -6966,7 +6966,6 @@ description = "Python client for Redis database and key-value store"
optional = true
python-versions = ">=3.10"
groups = ["main"]
-markers = "extra == \"swarm\""
files = [
{file = "redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33"},
{file = "redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5"},
@@ -8402,11 +8401,10 @@ propcache = ">=0.2.1"
bigbrain = ["airllm"]
dev = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-randomly", "pytest-timeout", "pytest-xdist", "selenium"]
discord = ["discord.py"]
-swarm = ["redis"]
telegram = ["python-telegram-bot"]
voice = ["pyttsx3"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<4"
-content-hash = "4238d5c83f024fbc0a67b6f2bc7c4adcbcbae21ad37a24630031edc6a0e67406"
+content-hash = "c4a7adbe5b16d5ea5b0d8425ca9373dfa8b20f0bc1b3a9ad90581e0a005e7acd"
diff --git a/pyproject.toml b/pyproject.toml
index d54a8763..ebb3c75a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,17 +11,10 @@ license = "MIT"
packages = [
{ include = "config.py", from = "src" },
{ include = "brain", from = "src" },
- { include = "creative", from = "src" },
{ include = "dashboard", from = "src" },
- { include = "hands", from = "src" },
{ include = "infrastructure", from = "src" },
{ include = "integrations", from = "src" },
- { include = "lightning", from = "src" },
- { include = "mcp", from = "src" },
- { include = "scripture", from = "src" },
- { include = "self_coding", from = "src" },
{ include = "spark", from = "src" },
- { include = "swarm", from = "src" },
{ include = "timmy", from = "src" },
{ include = "timmy_serve", from = "src" },
]
@@ -61,7 +54,6 @@ pytest-randomly = { version = ">=3.16.0", optional = true }
pytest-xdist = { version = ">=3.5.0", optional = true }
[tool.poetry.extras]
-swarm = ["redis"]
telegram = ["python-telegram-bot"]
discord = ["discord.py"]
bigbrain = ["airllm"]
@@ -80,8 +72,6 @@ pytest-xdist = "^3.8.0"
[tool.poetry.scripts]
timmy = "timmy.cli:main"
timmy-serve = "timmy_serve.cli:main"
-self-tdd = "self_coding.self_tdd.watchdog:main"
-self-modify = "self_coding.self_modify.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -95,7 +85,6 @@ markers = [
"functional: Functional tests (real HTTP requests, no mocking)",
"e2e: End-to-end tests (full system, may be slow)",
"dashboard: Dashboard route tests",
- "swarm: Swarm coordinator tests",
"slow: Tests that take >1 second",
"selenium: Requires Selenium and Chrome (browser automation)",
"docker: Requires Docker and docker-compose",
diff --git a/run_e2e_tests.sh b/run_e2e_tests.sh
deleted file mode 100755
index 2da14bee..00000000
--- a/run_e2e_tests.sh
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/bin/bash
-# Run E2E tests in non-headless mode (visible browser)
-
-echo "==============================================="
-echo "Timmy Time E2E Test Runner"
-echo "==============================================="
-echo ""
-
-# Check if server is running
-echo "Checking if server is running..."
-if curl -s http://localhost:8000/health > /dev/null; then
- echo "✅ Server is running at http://localhost:8000"
-else
- echo "❌ Server not running. Starting server..."
- source .venv/bin/activate
- make dev &
- SERVER_PID=$!
-
- # Wait for server
- echo "Waiting for server to start..."
- for i in {1..30}; do
- if curl -s http://localhost:8000/health > /dev/null; then
- echo "✅ Server started!"
- break
- fi
- sleep 1
- echo -n "."
- done
-
- if ! curl -s http://localhost:8000/health > /dev/null; then
- echo "❌ Server failed to start"
- exit 1
- fi
-fi
-
-echo ""
-echo "==============================================="
-echo "Running E2E Tests (Non-Headless / Visible)"
-echo "==============================================="
-echo ""
-echo "You will see Chrome browser windows open and execute tests."
-echo ""
-
-source .venv/bin/activate
-
-# Check for pytest option
-if [ "$1" == "--headed" ] || [ "$2" == "--headed" ]; then
- HEADED="--headed"
-else
- HEADED=""
-fi
-
-# Run specific test file or all
-if [ -n "$1" ] && [ "$1" != "--headed" ]; then
- TEST_FILE="$1"
- echo "Running: $TEST_FILE"
- SELENIUM_UI=1 pytest "$TEST_FILE" -v $HEADED
-else
- echo "Running all E2E tests..."
- SELENIUM_UI=1 pytest tests/functional/test_new_features_e2e.py tests/functional/test_cascade_router_e2e.py tests/functional/test_upgrade_queue_e2e.py tests/functional/test_activity_feed_e2e.py -v $HEADED
-fi
-
-echo ""
-echo "==============================================="
-echo "E2E Tests Complete"
-echo "==============================================="
diff --git a/src/creative/CLAUDE.md b/src/creative/CLAUDE.md
deleted file mode 100644
index cd25f394..00000000
--- a/src/creative/CLAUDE.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# creative/ — Module Guide
-
-GPU-accelerated media generation. Heavy dependencies (PyTorch, diffusers).
-
-## Structure
-- `director.py` — Orchestrates multi-step creative pipelines
-- `assembler.py` — Video assembly and stitching
-- `tools/` — MCP-compliant tool implementations
- - `image_tools.py` — FLUX.2 image generation
- - `music_tools.py` — ACE-Step music generation
- - `video_tools.py` — Wan 2.1 video generation
- - `git_tools.py`, `file_ops.py`, `code_exec.py` — Utility tools
- - `self_edit.py` — Self-modification MCP tool (protected file)
-
-## Testing
-```bash
-pytest tests/creative/ -q
-```
diff --git a/src/creative/__init__.py b/src/creative/__init__.py
deleted file mode 100644
index aa66bf12..00000000
--- a/src/creative/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Creative pipeline — orchestrates image, music, and video generation."""
diff --git a/src/creative/assembler.py b/src/creative/assembler.py
deleted file mode 100644
index 049dc8cc..00000000
--- a/src/creative/assembler.py
+++ /dev/null
@@ -1,360 +0,0 @@
-"""Video assembly engine — stitch clips, overlay audio, add titles.
-
-Uses MoviePy + FFmpeg to combine generated video clips, music tracks,
-and title cards into 3+ minute final videos.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import uuid
-from pathlib import Path
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-_MOVIEPY_AVAILABLE = True
-try:
- from moviepy import (
- VideoFileClip,
- AudioFileClip,
- TextClip,
- CompositeVideoClip,
- ImageClip,
- concatenate_videoclips,
- vfx,
- )
-except ImportError:
- _MOVIEPY_AVAILABLE = False
-
-def _resolve_font() -> str:
- """Find a usable TrueType font on the current platform.
-
- Searches for system fonts in order of preference, with fallbacks
- for different operating systems. Returns a valid font path or
- raises an error if no suitable font is found.
- """
- candidates = [
- # Linux (Debian/Ubuntu) - DejaVu is most reliable
- "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
- "/usr/share/fonts/TTF/DejaVuSans.ttf", # Arch
- "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", # Fedora
- # Linux - Liberation fonts (fallback)
- "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
- # macOS
- "/System/Library/Fonts/Supplemental/Arial.ttf",
- "/System/Library/Fonts/Helvetica.ttc",
- "/Library/Fonts/Arial.ttf",
- # Windows
- "C:\\Windows\\Fonts\\arial.ttf",
- ]
-
- # Try each candidate
- for path in candidates:
- try:
- if Path(path).exists():
- logger.debug(f"Using system font: {path}")
- return path
- except (OSError, ValueError):
- # Path might be invalid on some systems
- continue
-
- # If no candidates found, search for any available TrueType font
- logger.warning("Preferred fonts not found; searching for any available TrueType font")
- try:
- import subprocess
- result = subprocess.run(
- ["find", "/usr/share/fonts", "-name", "*.ttf", "-type", "f"],
- capture_output=True,
- text=True,
- timeout=5,
- )
- if result.stdout:
- first_font = result.stdout.strip().split("\n")[0]
- logger.warning(f"Using fallback font: {first_font}")
- return first_font
- except Exception as e:
- logger.debug(f"Font search failed: {e}")
-
- # Last resort: raise an error instead of returning an invalid font name
- raise RuntimeError(
- "No suitable TrueType font found on system. "
- "Please install a font package (e.g., fonts-dejavu, fonts-liberation) "
- "or set MOVIEPY_FONT environment variable to a valid font path."
- )
-
-
-_DEFAULT_FONT = _resolve_font()
-
-
-def _require_moviepy() -> None:
- if not _MOVIEPY_AVAILABLE:
- raise ImportError(
- "MoviePy is not installed. Run: pip install moviepy"
- )
-
-
-def _output_dir() -> Path:
- from config import settings
- d = Path(getattr(settings, "creative_output_dir", "data/creative"))
- d.mkdir(parents=True, exist_ok=True)
- return d
-
-
-# ── Stitching ─────────────────────────────────────────────────────────────────
-
-def stitch_clips(
- clip_paths: list[str],
- transition_duration: float = 1.0,
- output_path: Optional[str] = None,
-) -> dict:
- """Concatenate video clips with cross-fade transitions.
-
- Args:
- clip_paths: Ordered list of MP4 file paths.
- transition_duration: Cross-fade duration in seconds.
- output_path: Optional output path. Auto-generated if omitted.
-
- Returns dict with ``path`` and ``total_duration``.
- """
- _require_moviepy()
-
- clips = [VideoFileClip(p) for p in clip_paths]
-
- # Apply cross-fade between consecutive clips
- if transition_duration > 0 and len(clips) > 1:
- processed = [clips[0]]
- for clip in clips[1:]:
- clip = clip.with_start(
- processed[-1].end - transition_duration
- ).with_effects([vfx.CrossFadeIn(transition_duration)])
- processed.append(clip)
- final = CompositeVideoClip(processed)
- else:
- final = concatenate_videoclips(clips, method="compose")
-
- uid = uuid.uuid4().hex[:12]
- out = Path(output_path) if output_path else _output_dir() / f"stitched_{uid}.mp4"
- final.write_videofile(str(out), codec="libx264", audio_codec="aac", logger=None)
-
- total_duration = final.duration
- # Clean up
- for c in clips:
- c.close()
-
- return {
- "success": True,
- "path": str(out),
- "total_duration": total_duration,
- "clip_count": len(clip_paths),
- }
-
-
-# ── Audio overlay ─────────────────────────────────────────────────────────────
-
-def overlay_audio(
- video_path: str,
- audio_path: str,
- output_path: Optional[str] = None,
- volume: float = 1.0,
-) -> dict:
- """Mix an audio track onto a video file.
-
- The audio is trimmed or looped to match the video duration.
- """
- _require_moviepy()
-
- video = VideoFileClip(video_path)
- audio = AudioFileClip(audio_path)
-
- # Trim audio to video length
- if audio.duration > video.duration:
- audio = audio.subclipped(0, video.duration)
-
- if volume != 1.0:
- audio = audio.with_volume_scaled(volume)
-
- video = video.with_audio(audio)
-
- uid = uuid.uuid4().hex[:12]
- out = Path(output_path) if output_path else _output_dir() / f"mixed_{uid}.mp4"
- video.write_videofile(str(out), codec="libx264", audio_codec="aac", logger=None)
-
- result_duration = video.duration
- video.close()
- audio.close()
-
- return {
- "success": True,
- "path": str(out),
- "duration": result_duration,
- }
-
-
-# ── Title cards ───────────────────────────────────────────────────────────────
-
-def add_title_card(
- video_path: str,
- title: str,
- subtitle: str = "",
- duration: float = 4.0,
- position: str = "start",
- output_path: Optional[str] = None,
-) -> dict:
- """Add a title card at the start or end of a video.
-
- Args:
- video_path: Source video path.
- title: Title text.
- subtitle: Optional subtitle text.
- duration: Title card display duration in seconds.
- position: "start" or "end".
- """
- _require_moviepy()
-
- video = VideoFileClip(video_path)
- w, h = video.size
-
- # Build title card as a text clip on black background
- txt = TextClip(
- text=title,
- font_size=60,
- color="white",
- size=(w, h),
- method="caption",
- font=_DEFAULT_FONT,
- ).with_duration(duration)
-
- clips = [txt, video] if position == "start" else [video, txt]
- final = concatenate_videoclips(clips, method="compose")
-
- uid = uuid.uuid4().hex[:12]
- out = Path(output_path) if output_path else _output_dir() / f"titled_{uid}.mp4"
- final.write_videofile(str(out), codec="libx264", audio_codec="aac", logger=None)
-
- result_duration = final.duration
- video.close()
-
- return {
- "success": True,
- "path": str(out),
- "duration": result_duration,
- "title": title,
- }
-
-
-# ── Subtitles / captions ─────────────────────────────────────────────────────
-
-def add_subtitles(
- video_path: str,
- captions: list[dict],
- output_path: Optional[str] = None,
-) -> dict:
- """Burn subtitle captions onto a video.
-
- Args:
- captions: List of dicts with ``text``, ``start``, ``end`` keys
- (times in seconds).
- """
- _require_moviepy()
-
- video = VideoFileClip(video_path)
- w, h = video.size
-
- text_clips = []
- for cap in captions:
- txt = (
- TextClip(
- text=cap["text"],
- font_size=36,
- color="white",
- stroke_color="black",
- stroke_width=2,
- size=(w - 40, None),
- method="caption",
- font=_DEFAULT_FONT,
- )
- .with_start(cap["start"])
- .with_end(cap["end"])
- .with_position(("center", h - 100))
- )
- text_clips.append(txt)
-
- final = CompositeVideoClip([video] + text_clips)
-
- uid = uuid.uuid4().hex[:12]
- out = Path(output_path) if output_path else _output_dir() / f"subtitled_{uid}.mp4"
- final.write_videofile(str(out), codec="libx264", audio_codec="aac", logger=None)
-
- result_duration = final.duration
- video.close()
-
- return {
- "success": True,
- "path": str(out),
- "duration": result_duration,
- "caption_count": len(captions),
- }
-
-
-# ── Final export helper ──────────────────────────────────────────────────────
-
-def export_final(
- video_path: str,
- output_path: Optional[str] = None,
- codec: str = "libx264",
- audio_codec: str = "aac",
- bitrate: str = "8000k",
-) -> dict:
- """Re-encode a video with specific codec settings for distribution."""
- _require_moviepy()
-
- video = VideoFileClip(video_path)
- uid = uuid.uuid4().hex[:12]
- out = Path(output_path) if output_path else _output_dir() / f"final_{uid}.mp4"
- video.write_videofile(
- str(out), codec=codec, audio_codec=audio_codec,
- bitrate=bitrate, logger=None,
- )
-
- result_duration = video.duration
- video.close()
-
- return {
- "success": True,
- "path": str(out),
- "duration": result_duration,
- "codec": codec,
- }
-
-
-# ── Tool catalogue ────────────────────────────────────────────────────────────
-
-ASSEMBLER_TOOL_CATALOG: dict[str, dict] = {
- "stitch_clips": {
- "name": "Stitch Clips",
- "description": "Concatenate video clips with cross-fade transitions",
- "fn": stitch_clips,
- },
- "overlay_audio": {
- "name": "Overlay Audio",
- "description": "Mix a music track onto a video",
- "fn": overlay_audio,
- },
- "add_title_card": {
- "name": "Add Title Card",
- "description": "Add a title card at the start or end of a video",
- "fn": add_title_card,
- },
- "add_subtitles": {
- "name": "Add Subtitles",
- "description": "Burn subtitle captions onto a video",
- "fn": add_subtitles,
- },
- "export_final": {
- "name": "Export Final",
- "description": "Re-encode video with specific codec settings",
- "fn": export_final,
- },
-}
diff --git a/src/creative/director.py b/src/creative/director.py
deleted file mode 100644
index 914297b7..00000000
--- a/src/creative/director.py
+++ /dev/null
@@ -1,378 +0,0 @@
-"""Creative Director — multi-persona pipeline for 3+ minute creative works.
-
-Orchestrates Pixel (images), Lyra (music), and Reel (video) to produce
-complete music videos, cinematic shorts, and other creative works.
-
-Pipeline stages:
-1. Script — Generate scene descriptions and lyrics
-2. Storyboard — Generate keyframe images (Pixel)
-3. Music — Generate soundtrack (Lyra)
-4. Video — Generate clips per scene (Reel)
-5. Assembly — Stitch clips + overlay audio (MoviePy)
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import uuid
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class CreativeProject:
- """Tracks all assets and state for a creative production."""
- id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
- title: str = ""
- description: str = ""
- created_at: str = field(
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
- )
- status: str = "planning" # planning|scripting|storyboard|music|video|assembly|complete|failed
-
- # Pipeline outputs
- scenes: list[dict] = field(default_factory=list)
- lyrics: str = ""
- storyboard_frames: list[dict] = field(default_factory=list)
- music_track: Optional[dict] = None
- video_clips: list[dict] = field(default_factory=list)
- final_video: Optional[dict] = None
-
- def to_dict(self) -> dict:
- return {
- "id": self.id, "title": self.title,
- "description": self.description,
- "created_at": self.created_at, "status": self.status,
- "scene_count": len(self.scenes),
- "has_storyboard": len(self.storyboard_frames) > 0,
- "has_music": self.music_track is not None,
- "clip_count": len(self.video_clips),
- "has_final": self.final_video is not None,
- }
-
-
-# In-memory project store
-_projects: dict[str, CreativeProject] = {}
-
-
-def _project_dir(project_id: str) -> Path:
- from config import settings
- d = Path(getattr(settings, "creative_output_dir", "data/creative")) / project_id
- d.mkdir(parents=True, exist_ok=True)
- return d
-
-
-def _save_project(project: CreativeProject) -> None:
- """Persist project metadata to disk."""
- path = _project_dir(project.id) / "project.json"
- path.write_text(json.dumps(project.to_dict(), indent=2))
-
-
-# ── Project management ────────────────────────────────────────────────────────
-
-def create_project(
- title: str,
- description: str,
- scenes: Optional[list[dict]] = None,
- lyrics: str = "",
-) -> dict:
- """Create a new creative project.
-
- Args:
- title: Project title.
- description: High-level creative brief.
- scenes: Optional pre-written scene descriptions.
- Each scene is a dict with ``description`` key.
- lyrics: Optional song lyrics for the soundtrack.
-
- Returns dict with project metadata.
- """
- project = CreativeProject(
- title=title,
- description=description,
- scenes=scenes or [],
- lyrics=lyrics,
- )
- _projects[project.id] = project
- _save_project(project)
- logger.info("Creative project created: %s (%s)", project.id, title)
- return {"success": True, "project": project.to_dict()}
-
-
-def get_project(project_id: str) -> Optional[dict]:
- """Get project metadata."""
- project = _projects.get(project_id)
- if project:
- return project.to_dict()
- return None
-
-
-def list_projects() -> list[dict]:
- """List all creative projects."""
- return [p.to_dict() for p in _projects.values()]
-
-
-# ── Pipeline steps ────────────────────────────────────────────────────────────
-
-def run_storyboard(project_id: str) -> dict:
- """Generate storyboard frames for all scenes in a project.
-
- Calls Pixel's generate_storyboard tool.
- """
- project = _projects.get(project_id)
- if not project:
- return {"success": False, "error": "Project not found"}
- if not project.scenes:
- return {"success": False, "error": "No scenes defined"}
-
- project.status = "storyboard"
-
- from creative.tools.image_tools import generate_storyboard
-
- scene_descriptions = [s["description"] for s in project.scenes]
- result = generate_storyboard(scene_descriptions)
-
- if result["success"]:
- project.storyboard_frames = result["frames"]
- _save_project(project)
-
- return result
-
-
-def run_music(
- project_id: str,
- genre: str = "pop",
- duration: Optional[int] = None,
-) -> dict:
- """Generate the soundtrack for a project.
-
- Calls Lyra's generate_song tool.
- """
- project = _projects.get(project_id)
- if not project:
- return {"success": False, "error": "Project not found"}
-
- project.status = "music"
-
- from creative.tools.music_tools import generate_song
-
- # Default duration: ~15s per scene, minimum 60s
- target_duration = duration or max(60, len(project.scenes) * 15)
-
- result = generate_song(
- lyrics=project.lyrics,
- genre=genre,
- duration=target_duration,
- title=project.title,
- )
-
- if result["success"]:
- project.music_track = result
- _save_project(project)
-
- return result
-
-
-def run_video_generation(project_id: str) -> dict:
- """Generate video clips for each scene.
-
- Uses storyboard frames (image-to-video) if available,
- otherwise falls back to text-to-video.
- """
- project = _projects.get(project_id)
- if not project:
- return {"success": False, "error": "Project not found"}
- if not project.scenes:
- return {"success": False, "error": "No scenes defined"}
-
- project.status = "video"
-
- from creative.tools.video_tools import generate_video_clip, image_to_video
-
- clips = []
- for i, scene in enumerate(project.scenes):
- desc = scene["description"]
-
- # Prefer image-to-video if storyboard frame exists
- if i < len(project.storyboard_frames):
- frame = project.storyboard_frames[i]
- result = image_to_video(
- image_path=frame["path"],
- prompt=desc,
- duration=scene.get("duration", 5),
- )
- else:
- result = generate_video_clip(
- prompt=desc,
- duration=scene.get("duration", 5),
- )
-
- result["scene_index"] = i
- clips.append(result)
-
- project.video_clips = clips
- _save_project(project)
-
- return {
- "success": True,
- "clip_count": len(clips),
- "clips": clips,
- }
-
-
-def run_assembly(project_id: str, transition_duration: float = 1.0) -> dict:
- """Assemble all clips into the final video with music.
-
- Pipeline:
- 1. Stitch clips with transitions
- 2. Overlay music track
- 3. Add title card
- """
- project = _projects.get(project_id)
- if not project:
- return {"success": False, "error": "Project not found"}
- if not project.video_clips:
- return {"success": False, "error": "No video clips generated"}
-
- project.status = "assembly"
-
- from creative.assembler import stitch_clips, overlay_audio, add_title_card
-
- # 1. Stitch clips
- clip_paths = [c["path"] for c in project.video_clips if c.get("success")]
- if not clip_paths:
- return {"success": False, "error": "No successful clips to assemble"}
-
- stitched = stitch_clips(clip_paths, transition_duration=transition_duration)
- if not stitched["success"]:
- return stitched
-
- # 2. Overlay music (if available)
- current_video = stitched["path"]
- if project.music_track and project.music_track.get("path"):
- mixed = overlay_audio(current_video, project.music_track["path"])
- if mixed["success"]:
- current_video = mixed["path"]
-
- # 3. Add title card
- titled = add_title_card(current_video, title=project.title)
- if titled["success"]:
- current_video = titled["path"]
-
- project.final_video = {
- "path": current_video,
- "duration": titled.get("duration", stitched["total_duration"]),
- }
- project.status = "complete"
- _save_project(project)
-
- return {
- "success": True,
- "path": current_video,
- "duration": project.final_video["duration"],
- "project_id": project_id,
- }
-
-
-def run_full_pipeline(
- title: str,
- description: str,
- scenes: list[dict],
- lyrics: str = "",
- genre: str = "pop",
-) -> dict:
- """Run the entire creative pipeline end-to-end.
-
- This is the top-level orchestration function that:
- 1. Creates the project
- 2. Generates storyboard frames
- 3. Generates music
- 4. Generates video clips
- 5. Assembles the final video
-
- Args:
- title: Project title.
- description: Creative brief.
- scenes: List of scene dicts with ``description`` keys.
- lyrics: Song lyrics for the soundtrack.
- genre: Music genre.
-
- Returns dict with final video path and project metadata.
- """
- # Create project
- project_result = create_project(title, description, scenes, lyrics)
- if not project_result["success"]:
- return project_result
- project_id = project_result["project"]["id"]
-
- # Run pipeline steps
- steps = [
- ("storyboard", lambda: run_storyboard(project_id)),
- ("music", lambda: run_music(project_id, genre=genre)),
- ("video", lambda: run_video_generation(project_id)),
- ("assembly", lambda: run_assembly(project_id)),
- ]
-
- for step_name, step_fn in steps:
- logger.info("Creative pipeline step: %s (project %s)", step_name, project_id)
- result = step_fn()
- if not result.get("success"):
- project = _projects.get(project_id)
- if project:
- project.status = "failed"
- _save_project(project)
- return {
- "success": False,
- "failed_step": step_name,
- "error": result.get("error", "Unknown error"),
- "project_id": project_id,
- }
-
- project = _projects.get(project_id)
- return {
- "success": True,
- "project_id": project_id,
- "final_video": project.final_video if project else None,
- "project": project.to_dict() if project else None,
- }
-
-
-# ── Tool catalogue ────────────────────────────────────────────────────────────
-
-DIRECTOR_TOOL_CATALOG: dict[str, dict] = {
- "create_project": {
- "name": "Create Creative Project",
- "description": "Create a new creative production project",
- "fn": create_project,
- },
- "run_storyboard": {
- "name": "Generate Storyboard",
- "description": "Generate keyframe images for all project scenes",
- "fn": run_storyboard,
- },
- "run_music": {
- "name": "Generate Music",
- "description": "Generate the project soundtrack with vocals and instrumentals",
- "fn": run_music,
- },
- "run_video_generation": {
- "name": "Generate Video Clips",
- "description": "Generate video clips for each project scene",
- "fn": run_video_generation,
- },
- "run_assembly": {
- "name": "Assemble Final Video",
- "description": "Stitch clips, overlay music, and add title cards",
- "fn": run_assembly,
- },
- "run_full_pipeline": {
- "name": "Run Full Pipeline",
- "description": "Execute entire creative pipeline end-to-end",
- "fn": run_full_pipeline,
- },
-}
diff --git a/src/creative/tools/__init__.py b/src/creative/tools/__init__.py
deleted file mode 100644
index 13b51cf2..00000000
--- a/src/creative/tools/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Creative and DevOps tool modules for Timmy's swarm agents."""
diff --git a/src/creative/tools/code_exec.py b/src/creative/tools/code_exec.py
deleted file mode 100644
index a1f42227..00000000
--- a/src/creative/tools/code_exec.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""Code execution tool.
-
-MCP-compliant tool for executing Python code.
-"""
-
-import logging
-import traceback
-from typing import Any
-
-from mcp.registry import register_tool
-from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_BOOLEAN, RETURN_STRING
-
-logger = logging.getLogger(__name__)
-
-
-PYTHON_SCHEMA = create_tool_schema(
- name="python",
- description="Execute Python code. Use for calculations, data processing, or when precise computation is needed. Code runs in a restricted environment.",
- parameters={
- "code": {
- **PARAM_STRING,
- "description": "Python code to execute",
- },
- "return_output": {
- **PARAM_BOOLEAN,
- "description": "Return the value of the last expression",
- "default": True,
- },
- },
- required=["code"],
- returns=RETURN_STRING,
-)
-
-
-def python(code: str, return_output: bool = True) -> str:
- """Execute Python code in restricted environment.
-
- Args:
- code: Python code to execute
- return_output: Whether to return last expression value
-
- Returns:
- Execution result or error message
- """
- # Safe globals for code execution
- safe_globals = {
- "__builtins__": {
- "abs": abs,
- "all": all,
- "any": any,
- "bin": bin,
- "bool": bool,
- "dict": dict,
- "enumerate": enumerate,
- "filter": filter,
- "float": float,
- "format": format,
- "hex": hex,
- "int": int,
- "isinstance": isinstance,
- "issubclass": issubclass,
- "len": len,
- "list": list,
- "map": map,
- "max": max,
- "min": min,
- "next": next,
- "oct": oct,
- "ord": ord,
- "pow": pow,
- "print": lambda *args, **kwargs: None, # Disabled
- "range": range,
- "repr": repr,
- "reversed": reversed,
- "round": round,
- "set": set,
- "slice": slice,
- "sorted": sorted,
- "str": str,
- "sum": sum,
- "tuple": tuple,
- "type": type,
- "zip": zip,
- }
- }
-
- # Allowed modules
- allowed_modules = ["math", "random", "statistics", "datetime", "json"]
-
- for mod_name in allowed_modules:
- try:
- safe_globals[mod_name] = __import__(mod_name)
- except ImportError:
- pass
-
- try:
- # Compile and execute
- compiled = compile(code, "", "eval" if return_output else "exec")
-
- if return_output:
- result = eval(compiled, safe_globals, {})
- return f"Result: {result}"
- else:
- exec(compiled, safe_globals, {})
- return "Code executed successfully."
-
- except SyntaxError:
- # Try as exec if eval fails
- try:
- compiled = compile(code, "", "exec")
- exec(compiled, safe_globals, {})
- return "Code executed successfully."
- except Exception as exc:
- error_msg = traceback.format_exc()
- logger.error("Python execution failed: %s", exc)
- return f"Error: {exc}\n\n{error_msg}"
- except Exception as exc:
- error_msg = traceback.format_exc()
- logger.error("Python execution failed: %s", exc)
- return f"Error: {exc}\n\n{error_msg}"
-
-
-# Register with MCP
-register_tool(name="python", schema=PYTHON_SCHEMA, category="code")(python)
diff --git a/src/creative/tools/file_ops.py b/src/creative/tools/file_ops.py
deleted file mode 100644
index 15682303..00000000
--- a/src/creative/tools/file_ops.py
+++ /dev/null
@@ -1,200 +0,0 @@
-"""File operations tool.
-
-MCP-compliant tool for reading, writing, and listing files.
-"""
-
-import logging
-from pathlib import Path
-from typing import Any
-
-from mcp.registry import register_tool
-from mcp.schemas.base import (
- create_tool_schema,
- PARAM_STRING,
- PARAM_BOOLEAN,
- RETURN_STRING,
-)
-
-logger = logging.getLogger(__name__)
-
-
-# Read File Schema
-READ_FILE_SCHEMA = create_tool_schema(
- name="read_file",
- description="Read contents of a file. Use when user explicitly asks to read a file.",
- parameters={
- "path": {
- **PARAM_STRING,
- "description": "Path to file (relative to project root or absolute)",
- },
- "limit": {
- "type": "integer",
- "description": "Maximum lines to read (0 = all)",
- "default": 0,
- },
- },
- required=["path"],
- returns=RETURN_STRING,
-)
-
-# Write File Schema
-WRITE_FILE_SCHEMA = create_tool_schema(
- name="write_file",
- description="Write content to a file. Use when user explicitly asks to save content.",
- parameters={
- "path": {
- **PARAM_STRING,
- "description": "Path to file",
- },
- "content": {
- **PARAM_STRING,
- "description": "Content to write",
- },
- "append": {
- **PARAM_BOOLEAN,
- "description": "Append to file instead of overwrite",
- "default": False,
- },
- },
- required=["path", "content"],
- returns=RETURN_STRING,
-)
-
-# List Directory Schema
-LIST_DIR_SCHEMA = create_tool_schema(
- name="list_directory",
- description="List files in a directory.",
- parameters={
- "path": {
- **PARAM_STRING,
- "description": "Directory path (default: current)",
- "default": ".",
- },
- "pattern": {
- **PARAM_STRING,
- "description": "File pattern filter (e.g., '*.py')",
- "default": "*",
- },
- },
- returns=RETURN_STRING,
-)
-
-
-def _resolve_path(path: str, base_dir: str | Path | None = None) -> Path:
- """Resolve path with proper handling of ~, absolute, and relative paths.
-
- Resolution order:
- 1. If absolute, use as-is (after expanding ~)
- 2. If relative, resolve relative to base_dir (or repo root)
- """
- from config import settings
-
- p = Path(path)
-
- # Expand ~ to user's home directory
- p = p.expanduser()
-
- if p.is_absolute():
- return p.resolve()
-
- # Use provided base_dir, or fall back to settings.repo_root
- if base_dir is None:
- base = Path(settings.repo_root)
- else:
- base = Path(base_dir)
-
- # Resolve relative to base
- return (base / p).resolve()
-
-
-def read_file(path: str, limit: int = 0) -> str:
- """Read file contents."""
- try:
- filepath = _resolve_path(path)
-
- if not filepath.exists():
- return f"Error: File not found: {path}"
-
- if not filepath.is_file():
- return f"Error: Path is not a file: {path}"
-
- content = filepath.read_text()
-
- if limit > 0:
- lines = content.split("\n")[:limit]
- content = "\n".join(lines)
- if len(content.split("\n")) == limit:
- content += f"\n\n... [{limit} lines shown]"
-
- return content
-
- except Exception as exc:
- logger.error("Read file failed: %s", exc)
- return f"Error reading file: {exc}"
-
-
-def write_file(path: str, content: str, append: bool = False) -> str:
- """Write content to file."""
- try:
- filepath = _resolve_path(path)
-
- # Ensure directory exists
- filepath.parent.mkdir(parents=True, exist_ok=True)
-
- mode = "a" if append else "w"
- filepath.write_text(content)
-
- action = "appended to" if append else "wrote"
- return f"Successfully {action} {filepath}"
-
- except Exception as exc:
- logger.error("Write file failed: %s", exc)
- return f"Error writing file: {exc}"
-
-
-def list_directory(path: str = ".", pattern: str = "*") -> str:
- """List directory contents."""
- try:
- dirpath = _resolve_path(path)
-
- if not dirpath.exists():
- return f"Error: Directory not found: {path}"
-
- if not dirpath.is_dir():
- return f"Error: Path is not a directory: {path}"
-
- items = list(dirpath.glob(pattern))
-
- files = []
- dirs = []
-
- for item in items:
- if item.is_dir():
- dirs.append(f"📁 {item.name}/")
- else:
- size = item.stat().st_size
- size_str = f"{size}B" if size < 1024 else f"{size // 1024}KB"
- files.append(f"📄 {item.name} ({size_str})")
-
- result = [f"Contents of {dirpath}:", ""]
- result.extend(sorted(dirs))
- result.extend(sorted(files))
-
- return "\n".join(result)
-
- except Exception as exc:
- logger.error("List directory failed: %s", exc)
- return f"Error listing directory: {exc}"
-
-
-# Register with MCP
-register_tool(name="read_file", schema=READ_FILE_SCHEMA, category="files")(read_file)
-register_tool(
- name="write_file",
- schema=WRITE_FILE_SCHEMA,
- category="files",
- requires_confirmation=True,
-)(write_file)
-register_tool(name="list_directory", schema=LIST_DIR_SCHEMA, category="files")(
- list_directory
-)
diff --git a/src/creative/tools/git_tools.py b/src/creative/tools/git_tools.py
deleted file mode 100644
index b9a4bccc..00000000
--- a/src/creative/tools/git_tools.py
+++ /dev/null
@@ -1,451 +0,0 @@
-"""Git operations tools for Forge, Helm, and Timmy personas.
-
-Provides a full set of git commands that agents can execute against
-the local repository. Uses subprocess with explicit working directory
-to ensure commands run from the repo root.
-
-All functions return plain dicts so they're easily serialisable for
-tool-call results, Spark event capture, and WebSocket broadcast.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-import subprocess
-from pathlib import Path
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-
-def _find_repo_root() -> str:
- """Walk up from this file's location to find the .git directory."""
- path = os.path.dirname(os.path.abspath(__file__))
- # Start from project root (3 levels up from src/tools/git_tools.py)
- path = os.path.dirname(os.path.dirname(os.path.dirname(path)))
-
- while path != os.path.dirname(path):
- if os.path.exists(os.path.join(path, '.git')):
- return path
- path = os.path.dirname(path)
-
- # Fallback to config repo_root
- try:
- from config import settings
- return settings.repo_root
- except Exception:
- return os.getcwd()
-
-
-# Module-level constant for repo root
-REPO_ROOT = _find_repo_root()
-logger.info(f"Git repo root: {REPO_ROOT}")
-
-
-def _run_git_command(args: list[str], cwd: Optional[str] = None) -> tuple[int, str, str]:
- """Run a git command with proper working directory.
-
- Args:
- args: Git command arguments (e.g., ["log", "--oneline", "-5"])
- cwd: Working directory (defaults to REPO_ROOT)
-
- Returns:
- Tuple of (returncode, stdout, stderr)
- """
- cmd = ["git"] + args
- working_dir = cwd or REPO_ROOT
-
- try:
- result = subprocess.run(
- cmd,
- cwd=working_dir,
- capture_output=True,
- text=True,
- timeout=30,
- )
- return result.returncode, result.stdout, result.stderr
- except subprocess.TimeoutExpired:
- return -1, "", "Command timed out after 30 seconds"
- except Exception as exc:
- return -1, "", str(exc)
-
-
-# ── Repository management ────────────────────────────────────────────────────
-
-def git_clone(url: str, dest: str | Path) -> dict:
- """Clone a remote repository to a local path."""
- returncode, stdout, stderr = _run_git_command(
- ["clone", url, str(dest)],
- cwd=None # Clone uses current directory as parent
- )
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- return {
- "success": True,
- "path": str(dest),
- "message": f"Cloned {url} to {dest}",
- }
-
-
-def git_init(path: str | Path) -> dict:
- """Initialise a new git repository at *path*."""
- os.makedirs(path, exist_ok=True)
- returncode, stdout, stderr = _run_git_command(["init"], cwd=str(path))
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- return {"success": True, "path": str(path)}
-
-
-# ── Status / inspection ──────────────────────────────────────────────────────
-
-def git_status(repo_path: Optional[str] = None) -> dict:
- """Return working-tree status: modified, staged, untracked files."""
- cwd = repo_path or REPO_ROOT
- returncode, stdout, stderr = _run_git_command(
- ["status", "--porcelain", "-b"], cwd=cwd
- )
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- # Parse porcelain output
- lines = stdout.strip().split("\n") if stdout else []
- branch = "unknown"
- modified = []
- staged = []
- untracked = []
-
- for line in lines:
- if line.startswith("## "):
- branch = line[3:].split("...")[0].strip()
- elif len(line) >= 2:
- index_status = line[0]
- worktree_status = line[1]
- filename = line[3:].strip() if len(line) > 3 else ""
-
- if index_status in "MADRC":
- staged.append(filename)
- if worktree_status in "MD":
- modified.append(filename)
- if worktree_status == "?":
- untracked.append(filename)
-
- return {
- "success": True,
- "branch": branch,
- "is_dirty": bool(modified or staged or untracked),
- "modified": modified,
- "staged": staged,
- "untracked": untracked,
- }
-
-
-def git_diff(
- repo_path: Optional[str] = None,
- staged: bool = False,
- file_path: Optional[str] = None,
-) -> dict:
- """Show diff of working tree or staged changes."""
- cwd = repo_path or REPO_ROOT
- args = ["diff"]
- if staged:
- args.append("--cached")
- if file_path:
- args.extend(["--", file_path])
-
- returncode, stdout, stderr = _run_git_command(args, cwd=cwd)
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- return {"success": True, "diff": stdout, "staged": staged}
-
-
-def git_log(
- repo_path: Optional[str] = None,
- max_count: int = 20,
- branch: Optional[str] = None,
-) -> dict:
- """Return recent commit history as a list of dicts."""
- cwd = repo_path or REPO_ROOT
- args = ["log", f"--max-count={max_count}", "--format=%H|%h|%s|%an|%ai"]
- if branch:
- args.append(branch)
-
- returncode, stdout, stderr = _run_git_command(args, cwd=cwd)
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- commits = []
- for line in stdout.strip().split("\n"):
- if not line:
- continue
- parts = line.split("|", 4)
- if len(parts) >= 5:
- commits.append({
- "sha": parts[0],
- "short_sha": parts[1],
- "message": parts[2],
- "author": parts[3],
- "date": parts[4],
- })
-
- # Get current branch
- _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=cwd)
- current_branch = branch_out.strip() or "main"
-
- return {
- "success": True,
- "branch": branch or current_branch,
- "commits": commits,
- }
-
-
-def git_blame(repo_path: Optional[str] = None, file_path: str = "") -> dict:
- """Show line-by-line authorship for a file."""
- if not file_path:
- return {"success": False, "error": "file_path is required"}
-
- cwd = repo_path or REPO_ROOT
- returncode, stdout, stderr = _run_git_command(
- ["blame", "--porcelain", file_path], cwd=cwd
- )
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- return {"success": True, "file": file_path, "blame": stdout}
-
-
-# ── Branching ─────────────────────────────────────────────────────────────────
-
-def git_branch(
- repo_path: Optional[str] = None,
- create: Optional[str] = None,
- switch: Optional[str] = None,
-) -> dict:
- """List branches, optionally create or switch to one."""
- cwd = repo_path or REPO_ROOT
-
- if create:
- returncode, _, stderr = _run_git_command(
- ["branch", create], cwd=cwd
- )
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- if switch:
- returncode, _, stderr = _run_git_command(
- ["checkout", switch], cwd=cwd
- )
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- # List branches
- returncode, stdout, stderr = _run_git_command(
- ["branch", "-a", "--format=%(refname:short)%(if)%(HEAD)%(then)*%(end)"],
- cwd=cwd
- )
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- branches = []
- active = ""
- for line in stdout.strip().split("\n"):
- line = line.strip()
- if line.endswith("*"):
- active = line[:-1]
- branches.append(active)
- elif line:
- branches.append(line)
-
- return {
- "success": True,
- "branches": branches,
- "active": active,
- "created": create,
- "switched": switch,
- }
-
-
-# ── Staging & committing ─────────────────────────────────────────────────────
-
-def git_add(repo_path: Optional[str] = None, paths: Optional[list[str]] = None) -> dict:
- """Stage files for commit. *paths* defaults to all modified files."""
- cwd = repo_path or REPO_ROOT
-
- if paths:
- args = ["add"] + paths
- else:
- args = ["add", "-A"]
-
- returncode, _, stderr = _run_git_command(args, cwd=cwd)
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- return {"success": True, "staged": paths or ["all"]}
-
-
-def git_commit(
- repo_path: Optional[str] = None,
- message: str = "",
-) -> dict:
- """Create a commit with the given message."""
- if not message:
- return {"success": False, "error": "commit message is required"}
-
- cwd = repo_path or REPO_ROOT
- returncode, stdout, stderr = _run_git_command(
- ["commit", "-m", message], cwd=cwd
- )
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- # Get the commit hash
- _, hash_out, _ = _run_git_command(["rev-parse", "HEAD"], cwd=cwd)
- commit_hash = hash_out.strip()
-
- return {
- "success": True,
- "sha": commit_hash,
- "short_sha": commit_hash[:8],
- "message": message,
- }
-
-
-# ── Remote operations ─────────────────────────────────────────────────────────
-
-def git_push(
- repo_path: Optional[str] = None,
- remote: str = "origin",
- branch: Optional[str] = None,
-) -> dict:
- """Push the current (or specified) branch to the remote."""
- cwd = repo_path or REPO_ROOT
- args = ["push", remote]
- if branch:
- args.append(branch)
-
- returncode, stdout, stderr = _run_git_command(args, cwd=cwd)
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- return {"success": True, "remote": remote, "branch": branch or "current"}
-
-
-def git_pull(
- repo_path: Optional[str] = None,
- remote: str = "origin",
- branch: Optional[str] = None,
-) -> dict:
- """Pull from the remote into the working tree."""
- cwd = repo_path or REPO_ROOT
- args = ["pull", remote]
- if branch:
- args.append(branch)
-
- returncode, stdout, stderr = _run_git_command(args, cwd=cwd)
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- return {"success": True, "remote": remote, "branch": branch or "current"}
-
-
-# ── Stashing ──────────────────────────────────────────────────────────────────
-
-def git_stash(
- repo_path: Optional[str] = None,
- pop: bool = False,
- message: Optional[str] = None,
-) -> dict:
- """Stash or pop working-tree changes."""
- cwd = repo_path or REPO_ROOT
-
- if pop:
- returncode, _, stderr = _run_git_command(["stash", "pop"], cwd=cwd)
- if returncode != 0:
- return {"success": False, "error": stderr}
- return {"success": True, "action": "pop"}
-
- args = ["stash", "push"]
- if message:
- args.extend(["-m", message])
-
- returncode, _, stderr = _run_git_command(args, cwd=cwd)
-
- if returncode != 0:
- return {"success": False, "error": stderr}
-
- return {"success": True, "action": "stash", "message": message}
-
-
-# ── Tool catalogue ────────────────────────────────────────────────────────────
-
-GIT_TOOL_CATALOG: dict[str, dict] = {
- "git_clone": {
- "name": "Git Clone",
- "description": "Clone a remote repository to a local path",
- "fn": git_clone,
- },
- "git_status": {
- "name": "Git Status",
- "description": "Show working tree status (modified, staged, untracked)",
- "fn": git_status,
- },
- "git_diff": {
- "name": "Git Diff",
- "description": "Show diff of working tree or staged changes",
- "fn": git_diff,
- },
- "git_log": {
- "name": "Git Log",
- "description": "Show recent commit history",
- "fn": git_log,
- },
- "git_blame": {
- "name": "Git Blame",
- "description": "Show line-by-line authorship for a file",
- "fn": git_blame,
- },
- "git_branch": {
- "name": "Git Branch",
- "description": "List, create, or switch branches",
- "fn": git_branch,
- },
- "git_add": {
- "name": "Git Add",
- "description": "Stage files for commit",
- "fn": git_add,
- },
- "git_commit": {
- "name": "Git Commit",
- "description": "Create a commit with a message",
- "fn": git_commit,
- },
- "git_push": {
- "name": "Git Push",
- "description": "Push branch to remote repository",
- "fn": git_push,
- },
- "git_pull": {
- "name": "Git Pull",
- "description": "Pull from remote repository",
- "fn": git_pull,
- },
- "git_stash": {
- "name": "Git Stash",
- "description": "Stash or pop working tree changes",
- "fn": git_stash,
- },
-}
diff --git a/src/creative/tools/image_tools.py b/src/creative/tools/image_tools.py
deleted file mode 100644
index ac4f1cd5..00000000
--- a/src/creative/tools/image_tools.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""Image generation tools — Pixel persona.
-
-Uses FLUX.2 Klein 4B (or configurable model) via HuggingFace diffusers
-for text-to-image generation, storyboard frames, and variations.
-
-All heavy imports are lazy so the module loads instantly even without
-a GPU or the ``creative`` extra installed.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import uuid
-from pathlib import Path
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-# Lazy-loaded pipeline singleton
-_pipeline = None
-
-
-def _get_pipeline():
- """Lazy-load the FLUX diffusers pipeline."""
- global _pipeline
- if _pipeline is not None:
- return _pipeline
-
- try:
- import torch
- from diffusers import FluxPipeline
- except ImportError:
- raise ImportError(
- "Creative dependencies not installed. "
- "Run: pip install 'timmy-time[creative]'"
- )
-
- from config import settings
-
- model_id = getattr(settings, "flux_model_id", "black-forest-labs/FLUX.1-schnell")
- device = "cuda" if torch.cuda.is_available() else "cpu"
- dtype = torch.float16 if device == "cuda" else torch.float32
-
- logger.info("Loading image model %s on %s …", model_id, device)
- _pipeline = FluxPipeline.from_pretrained(
- model_id, torch_dtype=dtype,
- ).to(device)
- logger.info("Image model loaded.")
- return _pipeline
-
-
-def _output_dir() -> Path:
- from config import settings
- d = Path(getattr(settings, "image_output_dir", "data/images"))
- d.mkdir(parents=True, exist_ok=True)
- return d
-
-
-def _save_metadata(image_path: Path, meta: dict) -> Path:
- meta_path = image_path.with_suffix(".json")
- meta_path.write_text(json.dumps(meta, indent=2))
- return meta_path
-
-
-# ── Public tools ──────────────────────────────────────────────────────────────
-
-def generate_image(
- prompt: str,
- negative_prompt: str = "",
- width: int = 1024,
- height: int = 1024,
- steps: int = 4,
- seed: Optional[int] = None,
-) -> dict:
- """Generate an image from a text prompt.
-
- Returns dict with ``path``, ``width``, ``height``, and ``prompt``.
- """
- pipe = _get_pipeline()
- import torch
-
- generator = torch.Generator(device=pipe.device)
- if seed is not None:
- generator.manual_seed(seed)
-
- image = pipe(
- prompt=prompt,
- negative_prompt=negative_prompt or None,
- width=width,
- height=height,
- num_inference_steps=steps,
- generator=generator,
- ).images[0]
-
- uid = uuid.uuid4().hex[:12]
- out_path = _output_dir() / f"{uid}.png"
- image.save(out_path)
-
- meta = {
- "id": uid, "prompt": prompt, "negative_prompt": negative_prompt,
- "width": width, "height": height, "steps": steps, "seed": seed,
- }
- _save_metadata(out_path, meta)
-
- return {"success": True, "path": str(out_path), **meta}
-
-
-def generate_storyboard(
- scenes: list[str],
- width: int = 1024,
- height: int = 576,
- steps: int = 4,
-) -> dict:
- """Generate a storyboard: one keyframe image per scene description.
-
- Args:
- scenes: List of scene description strings.
-
- Returns dict with list of generated frame paths.
- """
- frames = []
- for i, scene in enumerate(scenes):
- result = generate_image(
- prompt=scene, width=width, height=height, steps=steps,
- )
- result["scene_index"] = i
- result["scene_description"] = scene
- frames.append(result)
- return {"success": True, "frame_count": len(frames), "frames": frames}
-
-
-def image_variations(
- prompt: str,
- count: int = 4,
- width: int = 1024,
- height: int = 1024,
- steps: int = 4,
-) -> dict:
- """Generate multiple variations of the same prompt with different seeds."""
- import random
- variations = []
- for _ in range(count):
- seed = random.randint(0, 2**32 - 1)
- result = generate_image(
- prompt=prompt, width=width, height=height,
- steps=steps, seed=seed,
- )
- variations.append(result)
- return {"success": True, "count": len(variations), "variations": variations}
-
-
-# ── Tool catalogue ────────────────────────────────────────────────────────────
-
-IMAGE_TOOL_CATALOG: dict[str, dict] = {
- "generate_image": {
- "name": "Generate Image",
- "description": "Generate an image from a text prompt using FLUX",
- "fn": generate_image,
- },
- "generate_storyboard": {
- "name": "Generate Storyboard",
- "description": "Generate keyframe images for a sequence of scenes",
- "fn": generate_storyboard,
- },
- "image_variations": {
- "name": "Image Variations",
- "description": "Generate multiple variations of the same prompt",
- "fn": image_variations,
- },
-}
diff --git a/src/creative/tools/memory_tool.py b/src/creative/tools/memory_tool.py
deleted file mode 100644
index cb314387..00000000
--- a/src/creative/tools/memory_tool.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""Memory search tool.
-
-MCP-compliant tool for searching Timmy's memory.
-"""
-
-import logging
-from typing import Any
-
-from mcp.registry import register_tool
-from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_INTEGER, RETURN_STRING
-
-logger = logging.getLogger(__name__)
-
-
-MEMORY_SEARCH_SCHEMA = create_tool_schema(
- name="memory_search",
- description="Search Timmy's memory for past conversations, facts, and context. Use when user asks about previous discussions or when you need to recall something from memory.",
- parameters={
- "query": {
- **PARAM_STRING,
- "description": "What to search for in memory",
- },
- "top_k": {
- **PARAM_INTEGER,
- "description": "Number of results to return (1-10)",
- "default": 5,
- "minimum": 1,
- "maximum": 10,
- },
- },
- required=["query"],
- returns=RETURN_STRING,
-)
-
-
-def memory_search(query: str, top_k: int = 5) -> str:
- """Search Timmy's memory.
-
- Args:
- query: Search query
- top_k: Number of results
-
- Returns:
- Relevant memories from past conversations
- """
- try:
- from timmy.semantic_memory import memory_search as semantic_search
-
- results = semantic_search(query, top_k=top_k)
-
- if not results:
- return "No relevant memories found."
-
- formatted = ["Relevant memories from past conversations:", ""]
-
- for i, (content, score) in enumerate(results, 1):
- relevance = "🔥" if score > 0.8 else "⭐" if score > 0.5 else "📄"
- formatted.append(f"{relevance} [{i}] (score: {score:.2f})")
- formatted.append(f" {content[:300]}...")
- formatted.append("")
-
- return "\n".join(formatted)
-
- except Exception as exc:
- logger.error("Memory search failed: %s", exc)
- return f"Memory search error: {exc}"
-
-
-# Register with MCP
-register_tool(name="memory_search", schema=MEMORY_SEARCH_SCHEMA, category="memory")(memory_search)
diff --git a/src/creative/tools/music_tools.py b/src/creative/tools/music_tools.py
deleted file mode 100644
index f2f81f37..00000000
--- a/src/creative/tools/music_tools.py
+++ /dev/null
@@ -1,210 +0,0 @@
-"""Music generation tools — Lyra persona.
-
-Uses ACE-Step 1.5 for full song generation with vocals, instrumentals,
-and lyrics. Falls back gracefully when the ``creative`` extra is not
-installed.
-
-All heavy imports are lazy — the module loads instantly without GPU.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import uuid
-from pathlib import Path
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-# Lazy-loaded model singleton
-_model = None
-
-
-def _get_model():
- """Lazy-load the ACE-Step music generation model."""
- global _model
- if _model is not None:
- return _model
-
- try:
- from ace_step import ACEStep
- except ImportError:
- raise ImportError(
- "ACE-Step not installed. Run: pip install 'timmy-time[creative]'"
- )
-
- from config import settings
- model_name = getattr(settings, "ace_step_model", "ace-step/ACE-Step-v1.5")
-
- logger.info("Loading music model %s …", model_name)
- _model = ACEStep(model_name)
- logger.info("Music model loaded.")
- return _model
-
-
-def _output_dir() -> Path:
- from config import settings
- d = Path(getattr(settings, "music_output_dir", "data/music"))
- d.mkdir(parents=True, exist_ok=True)
- return d
-
-
-def _save_metadata(audio_path: Path, meta: dict) -> Path:
- meta_path = audio_path.with_suffix(".json")
- meta_path.write_text(json.dumps(meta, indent=2))
- return meta_path
-
-
-# ── Supported genres ──────────────────────────────────────────────────────────
-
-GENRES = [
- "pop", "rock", "hip-hop", "r&b", "jazz", "blues", "country",
- "electronic", "classical", "folk", "reggae", "metal", "punk",
- "soul", "funk", "latin", "ambient", "lo-fi", "cinematic",
-]
-
-
-# ── Public tools ──────────────────────────────────────────────────────────────
-
-def generate_song(
- lyrics: str,
- genre: str = "pop",
- duration: int = 120,
- language: str = "en",
- title: Optional[str] = None,
-) -> dict:
- """Generate a full song with vocals and instrumentals from lyrics.
-
- Args:
- lyrics: Song lyrics text.
- genre: Musical genre / style tag.
- duration: Target duration in seconds (30–240).
- language: ISO language code (19 languages supported).
- title: Optional song title for metadata.
-
- Returns dict with ``path``, ``duration``, ``genre``, etc.
- """
- model = _get_model()
- duration = max(30, min(240, duration))
-
- uid = uuid.uuid4().hex[:12]
- out_path = _output_dir() / f"{uid}.wav"
-
- logger.info("Generating song: genre=%s duration=%ds …", genre, duration)
- audio = model.generate(
- lyrics=lyrics,
- genre=genre,
- duration=duration,
- language=language,
- )
- audio.save(str(out_path))
-
- meta = {
- "id": uid, "title": title or f"Untitled ({genre})",
- "lyrics": lyrics, "genre": genre,
- "duration": duration, "language": language,
- }
- _save_metadata(out_path, meta)
-
- return {"success": True, "path": str(out_path), **meta}
-
-
-def generate_instrumental(
- prompt: str,
- genre: str = "cinematic",
- duration: int = 60,
-) -> dict:
- """Generate an instrumental track from a text prompt (no vocals).
-
- Args:
- prompt: Description of the desired music.
- genre: Musical genre / style tag.
- duration: Target duration in seconds (15–180).
- """
- model = _get_model()
- duration = max(15, min(180, duration))
-
- uid = uuid.uuid4().hex[:12]
- out_path = _output_dir() / f"{uid}.wav"
-
- logger.info("Generating instrumental: genre=%s …", genre)
- audio = model.generate(
- lyrics="",
- genre=genre,
- duration=duration,
- prompt=prompt,
- )
- audio.save(str(out_path))
-
- meta = {
- "id": uid, "prompt": prompt, "genre": genre,
- "duration": duration, "instrumental": True,
- }
- _save_metadata(out_path, meta)
-
- return {"success": True, "path": str(out_path), **meta}
-
-
-def generate_vocals(
- lyrics: str,
- style: str = "pop",
- duration: int = 60,
- language: str = "en",
-) -> dict:
- """Generate a vocal-only track from lyrics.
-
- Useful for layering over custom instrumentals.
- """
- model = _get_model()
- duration = max(15, min(180, duration))
-
- uid = uuid.uuid4().hex[:12]
- out_path = _output_dir() / f"{uid}.wav"
-
- audio = model.generate(
- lyrics=lyrics,
- genre=f"{style} acapella vocals",
- duration=duration,
- language=language,
- )
- audio.save(str(out_path))
-
- meta = {
- "id": uid, "lyrics": lyrics, "style": style,
- "duration": duration, "vocals_only": True,
- }
- _save_metadata(out_path, meta)
-
- return {"success": True, "path": str(out_path), **meta}
-
-
-def list_genres() -> dict:
- """Return the list of supported genre / style tags."""
- return {"success": True, "genres": GENRES}
-
-
-# ── Tool catalogue ────────────────────────────────────────────────────────────
-
-MUSIC_TOOL_CATALOG: dict[str, dict] = {
- "generate_song": {
- "name": "Generate Song",
- "description": "Generate a full song with vocals + instrumentals from lyrics",
- "fn": generate_song,
- },
- "generate_instrumental": {
- "name": "Generate Instrumental",
- "description": "Generate an instrumental track from a text prompt",
- "fn": generate_instrumental,
- },
- "generate_vocals": {
- "name": "Generate Vocals",
- "description": "Generate a vocal-only track from lyrics",
- "fn": generate_vocals,
- },
- "list_genres": {
- "name": "List Genres",
- "description": "List supported music genre / style tags",
- "fn": list_genres,
- },
-}
diff --git a/src/creative/tools/self_edit.py b/src/creative/tools/self_edit.py
deleted file mode 100644
index 3553b92c..00000000
--- a/src/creative/tools/self_edit.py
+++ /dev/null
@@ -1,824 +0,0 @@
-"""Self-Edit MCP Tool — Timmy's ability to modify its own source code.
-
-This is the core self-modification orchestrator that:
-1. Receives task descriptions
-2. Queries codebase indexer for relevant files
-3. Queries modification journal for similar past attempts
-4. Creates feature branches via GitSafety
-5. Plans changes with LLM
-6. Executes via Aider (preferred) or direct editing (fallback)
-7. Runs tests via pytest
-8. Commits on success, rolls back on failure
-9. Logs outcomes to ModificationJournal
-10. Generates reflections
-
-Usage:
- from creative.tools.self_edit import self_edit_tool
- from mcp.registry import tool_registry
-
- # Register with MCP
- tool_registry.register("self_edit", self_edit_schema, self_edit_tool)
-
- # Invoke
- result = await tool_registry.execute("self_edit", {
- "task_description": "Add error handling to health endpoint"
- })
-"""
-
-from __future__ import annotations
-
-import ast
-import asyncio
-import logging
-import os
-import subprocess
-from dataclasses import dataclass, field
-from datetime import datetime
-from pathlib import Path
-from typing import Any, Optional
-
-from config import settings
-
-# Phase 1 imports
-from self_coding import (
- CodebaseIndexer,
- GitSafety,
- ModificationAttempt,
- ModificationJournal,
- Outcome,
- ReflectionService,
-)
-
-logger = logging.getLogger(__name__)
-
-# Safety constraints (Phase 1 hard limits)
-MAX_FILES_PER_COMMIT = 3
-MAX_LINES_CHANGED = 100
-PROTECTED_FILES = {
- "src/tools/self_edit.py",
- "src/self_coding/git_safety.py",
- "src/self_coding/codebase_indexer.py",
- "src/self_coding/modification_journal.py",
- "src/self_coding/reflection.py",
-}
-MAX_RETRIES = 3
-
-
-@dataclass
-class SelfEditResult:
- """Result of a self-edit operation."""
- success: bool
- message: str
- attempt_id: Optional[int] = None
- files_modified: list[str] = field(default_factory=list)
- commit_hash: Optional[str] = None
- test_results: str = ""
- diff: str = ""
-
-
-@dataclass
-class EditPlan:
- """Plan for a self-edit operation."""
- approach: str
- files_to_modify: list[str]
- files_to_create: list[str]
- tests_to_add: list[str]
- explanation: str
-
-
-class SelfEditTool:
- """Self-modification orchestrator.
-
- This class encapsulates the complete self-edit workflow:
- - Pre-flight checks
- - Context gathering (indexer + journal)
- - Branch creation
- - Edit planning (LLM)
- - Execution (Aider or direct)
- - Testing
- - Commit/rollback
- - Logging and reflection
-
- Usage:
- tool = SelfEditTool(repo_path="/path/to/repo")
- result = await tool.execute("Add error handling to health endpoint")
- """
-
- def __init__(
- self,
- repo_path: Optional[Path] = None,
- llm_adapter: Optional[object] = None,
- ) -> None:
- """Initialize SelfEditTool.
-
- Args:
- repo_path: Path to repository. Defaults to current directory.
- llm_adapter: LLM adapter for planning and reflection
- """
- self.repo_path = Path(repo_path) if repo_path else Path.cwd()
- self.llm_adapter = llm_adapter
-
- # Initialize Phase 1 services
- self.git = GitSafety(repo_path=self.repo_path)
- self.indexer = CodebaseIndexer(repo_path=self.repo_path)
- self.journal = ModificationJournal()
- self.reflection = ReflectionService(llm_adapter=llm_adapter)
-
- # Ensure codebase is indexed
- self._indexing_done = False
-
- logger.info("SelfEditTool initialized for %s", self.repo_path)
-
- async def _ensure_indexed(self) -> None:
- """Ensure codebase is indexed."""
- if not self._indexing_done:
- await self.indexer.index_changed()
- self._indexing_done = True
-
- async def execute(
- self,
- task_description: str,
- context: Optional[dict] = None,
- ) -> SelfEditResult:
- """Execute a self-edit task.
-
- This is the main entry point for self-modification.
-
- Args:
- task_description: What to do (e.g., "Add error handling")
- context: Optional additional context
-
- Returns:
- SelfEditResult with success/failure details
- """
- logger.info("Starting self-edit: %s", task_description[:50])
-
- try:
- # Step 1: Pre-flight checks
- if not await self._preflight_checks():
- return SelfEditResult(
- success=False,
- message="Pre-flight checks failed. See logs for details.",
- )
-
- # Step 2: Gather context
- await self._ensure_indexed()
- relevant_files = await self._get_relevant_files(task_description)
- similar_attempts = await self._get_similar_attempts(task_description)
-
- # Step 3: Create feature branch
- branch_name = f"timmy/self-edit/{datetime.now().strftime('%Y%m%d-%H%M%S')}"
- await self.git.create_branch(branch_name)
- logger.info("Created branch: %s", branch_name)
-
- # Step 4: Take snapshot for rollback
- snapshot = await self.git.snapshot(run_tests=False)
-
- # Step 5: Plan the edit
- plan = await self._plan_edit(
- task_description,
- relevant_files,
- similar_attempts,
- )
-
- # Validate plan against safety constraints
- if not self._validate_plan(plan):
- return SelfEditResult(
- success=False,
- message=f"Plan violates safety constraints: {plan.files_to_modify}",
- )
-
- # Step 6: Execute the edit
- execution_result = await self._execute_edit(plan, task_description)
-
- if not execution_result["success"]:
- # Attempt retries
- for retry in range(MAX_RETRIES):
- logger.info("Retry %d/%d", retry + 1, MAX_RETRIES)
-
- # Rollback to clean state
- await self.git.rollback(snapshot)
-
- # Try again with adjusted approach
- execution_result = await self._execute_edit(
- plan,
- task_description,
- retry_count=retry + 1,
- )
-
- if execution_result["success"]:
- break
-
- if not execution_result["success"]:
- # Final rollback and log failure
- await self.git.rollback(snapshot)
- await self.git._run_git("checkout", "main") # Return to main
-
- attempt_id = await self._log_failure(
- task_description,
- plan,
- execution_result["test_output"],
- execution_result.get("error", "Unknown error"),
- )
-
- return SelfEditResult(
- success=False,
- message=f"Failed after {MAX_RETRIES} retries",
- attempt_id=attempt_id,
- test_results=execution_result.get("test_output", ""),
- )
-
- # Step 7: Commit and merge
- commit_hash = await self.git.commit(
- message=f"Self-edit: {task_description[:50]}",
- files=plan.files_to_modify + plan.files_to_create + plan.tests_to_add,
- )
-
- # Merge to main (tests already passed in execution)
- await self.git.merge_to_main(branch_name, require_tests=False)
-
- # Step 8: Log success
- diff = await self.git.get_diff(snapshot.commit_hash, commit_hash)
- attempt_id = await self._log_success(
- task_description,
- plan,
- commit_hash,
- execution_result.get("test_output", ""),
- diff,
- )
-
- return SelfEditResult(
- success=True,
- message=f"Successfully modified {len(plan.files_to_modify)} files",
- attempt_id=attempt_id,
- files_modified=plan.files_to_modify,
- commit_hash=commit_hash,
- test_results=execution_result.get("test_output", ""),
- diff=diff,
- )
-
- except Exception as e:
- logger.exception("Self-edit failed with exception")
- return SelfEditResult(
- success=False,
- message=f"Exception: {str(e)}",
- )
-
- async def _preflight_checks(self) -> bool:
- """Run pre-flight safety checks.
-
- Returns:
- True if all checks pass
- """
- # Check if repo is clean
- if not await self.git.is_clean():
- logger.error("Pre-flight failed: Working directory not clean")
- return False
-
- # Check if we're on main
- current_branch = await self.git.get_current_branch()
- if current_branch != self.git.main_branch:
- logger.error("Pre-flight failed: Not on %s branch (on %s)",
- self.git.main_branch, current_branch)
- return False
-
- # Check if self-modification is enabled
- if not getattr(settings, 'self_modify_enabled', True):
- logger.error("Pre-flight failed: Self-modification disabled in config")
- return False
-
- return True
-
- async def _get_relevant_files(self, task_description: str) -> list[str]:
- """Get files relevant to the task.
-
- Args:
- task_description: Task to find relevant files for
-
- Returns:
- List of file paths
- """
- files = await self.indexer.get_relevant_files(task_description, limit=10)
-
- # Filter to only files with test coverage
- files_with_tests = [
- f for f in files
- if await self.indexer.has_test_coverage(f)
- ]
-
- logger.info("Found %d relevant files (%d with tests)",
- len(files), len(files_with_tests))
-
- return files_with_tests[:MAX_FILES_PER_COMMIT]
-
- async def _get_similar_attempts(
- self,
- task_description: str,
- ) -> list[ModificationAttempt]:
- """Get similar past modification attempts.
-
- Args:
- task_description: Task to find similar attempts for
-
- Returns:
- List of similar attempts
- """
- similar = await self.journal.find_similar(task_description, limit=5)
- logger.info("Found %d similar past attempts", len(similar))
- return similar
-
- async def _plan_edit(
- self,
- task_description: str,
- relevant_files: list[str],
- similar_attempts: list[ModificationAttempt],
- ) -> EditPlan:
- """Plan the edit using LLM.
-
- Args:
- task_description: What to do
- relevant_files: Files that might need modification
- similar_attempts: Similar past attempts for context
-
- Returns:
- EditPlan with approach and file list
- """
- if not self.llm_adapter:
- # Fallback: simple plan
- return EditPlan(
- approach=f"Edit files to implement: {task_description}",
- files_to_modify=relevant_files[:MAX_FILES_PER_COMMIT],
- files_to_create=[],
- tests_to_add=[],
- explanation="No LLM available, using heuristic plan",
- )
-
- # Build prompt with context
- codebase_summary = await self.indexer.get_summary(max_tokens=2000)
-
- similar_context = ""
- if similar_attempts:
- similar_context = "\n\nSimilar past attempts:\n"
- for attempt in similar_attempts:
- similar_context += f"- {attempt.task_description} ({attempt.outcome.value})\n"
- if attempt.reflection:
- similar_context += f" Lesson: {attempt.reflection[:100]}...\n"
-
- prompt = f"""You are planning a code modification for a Python project.
-
-Task: {task_description}
-
-Codebase Summary:
-{codebase_summary}
-
-Potentially relevant files (all have test coverage):
-{chr(10).join(f"- {f}" for f in relevant_files)}
-{similar_context}
-
-Create a plan for implementing this task. You can modify at most {MAX_FILES_PER_COMMIT} files.
-
-Respond in this format:
-APPROACH:
-FILES_TO_MODIFY:
-FILES_TO_CREATE:
-TESTS_TO_ADD:
-EXPLANATION:
-"""
-
- try:
- response = await self.llm_adapter.chat(message=prompt)
- content = response.content
-
- # Parse response
- approach = self._extract_field(content, "APPROACH")
- files_to_modify = self._parse_list(self._extract_field(content, "FILES_TO_MODIFY"))
- files_to_create = self._parse_list(self._extract_field(content, "FILES_TO_CREATE"))
- tests_to_add = self._parse_list(self._extract_field(content, "TESTS_TO_ADD"))
- explanation = self._extract_field(content, "EXPLANATION")
-
- return EditPlan(
- approach=approach or "No approach specified",
- files_to_modify=files_to_modify[:MAX_FILES_PER_COMMIT],
- files_to_create=files_to_create,
- tests_to_add=tests_to_add,
- explanation=explanation or "No explanation provided",
- )
-
- except Exception as e:
- logger.error("LLM planning failed: %s", e)
- return EditPlan(
- approach=f"Fallback: Modify relevant files for {task_description}",
- files_to_modify=relevant_files[:MAX_FILES_PER_COMMIT],
- files_to_create=[],
- tests_to_add=[],
- explanation=f"LLM failed, using fallback: {e}",
- )
-
- def _extract_field(self, content: str, field_name: str) -> str:
- """Extract a field from LLM response."""
- for line in content.split("\n"):
- if line.startswith(f"{field_name}:"):
- return line.split(":", 1)[1].strip()
- return ""
-
- def _parse_list(self, text: str) -> list[str]:
- """Parse comma-separated list."""
- if not text or text.lower() in ("none", "n/a", ""):
- return []
- return [item.strip() for item in text.split(",") if item.strip()]
-
- def _validate_plan(self, plan: EditPlan) -> bool:
- """Validate plan against safety constraints.
-
- Args:
- plan: EditPlan to validate
-
- Returns:
- True if plan is valid
- """
- # Check file count
- if len(plan.files_to_modify) > MAX_FILES_PER_COMMIT:
- logger.error("Plan modifies too many files: %d > %d",
- len(plan.files_to_modify), MAX_FILES_PER_COMMIT)
- return False
-
- # Check for protected files
- for file_path in plan.files_to_modify:
- if file_path in PROTECTED_FILES:
- logger.error("Plan tries to modify protected file: %s", file_path)
- return False
-
- # Check all files have test coverage
- for file_path in plan.files_to_modify:
- # This is async, so we check in _get_relevant_files
- pass
-
- return True
-
- async def _execute_edit(
- self,
- plan: EditPlan,
- task_description: str,
- retry_count: int = 0,
- ) -> dict:
- """Execute the edit using Aider or direct editing.
-
- Args:
- plan: EditPlan to execute
- task_description: Original task description
- retry_count: Current retry attempt
-
- Returns:
- Dict with success, test_output, error
- """
- all_files = plan.files_to_modify + plan.files_to_create
-
- if not all_files:
- return {"success": False, "error": "No files to modify"}
-
- # Try Aider first
- if await self._aider_available():
- return await self._execute_with_aider(plan, task_description, all_files)
- else:
- # Fallback to direct editing
- return await self._execute_direct_edit(plan, task_description)
-
- async def _aider_available(self) -> bool:
- """Check if Aider is available."""
- try:
- result = await asyncio.create_subprocess_exec(
- "aider", "--version",
- stdout=asyncio.subprocess.DEVNULL,
- stderr=asyncio.subprocess.DEVNULL,
- )
- await result.wait()
- return result.returncode == 0
- except FileNotFoundError:
- return False
-
- async def _execute_with_aider(
- self,
- plan: EditPlan,
- task_description: str,
- files: list[str],
- ) -> dict:
- """Execute edit using Aider.
-
- Args:
- plan: EditPlan
- task_description: Task description
- files: Files to edit
-
- Returns:
- Dict with success, test_output
- """
- cmd = [
- "aider",
- "--model", "ollama_chat/qwen2.5-coder:14b-instruct",
- "--auto-test",
- "--test-cmd", "python -m pytest tests/ -xvs",
- "--yes",
- "--no-git",
- "--message", f"{task_description}\n\nApproach: {plan.approach}",
- ] + files
-
- logger.info("Running Aider: %s", " ".join(cmd))
-
- try:
- proc = await asyncio.create_subprocess_exec(
- *cmd,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.STDOUT,
- cwd=self.repo_path,
- )
-
- stdout, _ = await asyncio.wait_for(
- proc.communicate(),
- timeout=300.0,
- )
-
- output = stdout.decode() if stdout else ""
-
- # Check if tests passed
- success = proc.returncode == 0 and "passed" in output.lower()
-
- return {
- "success": success,
- "test_output": output,
- }
-
- except asyncio.TimeoutError:
- logger.error("Aider timed out after 300s")
- return {
- "success": False,
- "error": "Timeout",
- "test_output": "Aider timed out after 300s",
- }
- except Exception as e:
- logger.error("Aider execution failed: %s", e)
- return {
- "success": False,
- "error": str(e),
- "test_output": "",
- }
-
- async def _execute_direct_edit(
- self,
- plan: EditPlan,
- task_description: str,
- ) -> dict:
- """Execute edit via direct file modification (fallback).
-
- Args:
- plan: EditPlan
- task_description: Task description
-
- Returns:
- Dict with success, test_output
- """
- if not self.llm_adapter:
- return {
- "success": False,
- "error": "No LLM adapter for direct editing",
- }
-
- # Edit each file
- for file_path in plan.files_to_modify:
- full_path = self.repo_path / file_path
-
- if not full_path.exists():
- logger.error("File does not exist: %s", file_path)
- continue
-
- try:
- content = full_path.read_text()
-
- # Build edit prompt
- edit_prompt = f"""Edit this Python file to implement the task.
-
-Task: {task_description}
-Approach: {plan.approach}
-
-Current file content:
-```python
-{content}
-```
-
-Provide the complete new file content. Only return the code, no explanation.
-"""
-
- response = await self.llm_adapter.chat(message=edit_prompt)
- new_content = response.content
-
- # Strip code fences if present
- new_content = self._strip_code_fences(new_content)
-
- # Validate with AST
- try:
- ast.parse(new_content)
- except SyntaxError as e:
- logger.error("Generated code has syntax error: %s", e)
- return {
- "success": False,
- "error": f"Syntax error in generated code: {e}",
- }
-
- # Write file
- full_path.write_text(new_content)
- logger.info("Modified: %s", file_path)
-
- except Exception as e:
- logger.error("Failed to edit %s: %s", file_path, e)
- return {
- "success": False,
- "error": f"Failed to edit {file_path}: {e}",
- }
-
- # Run tests
- return await self._run_tests()
-
- def _strip_code_fences(self, content: str) -> str:
- """Strip markdown code fences from content."""
- lines = content.split("\n")
-
- # Remove opening fence
- if lines and lines[0].startswith("```"):
- lines = lines[1:]
-
- # Remove closing fence
- if lines and lines[-1].startswith("```"):
- lines = lines[:-1]
-
- return "\n".join(lines)
-
- async def _run_tests(self) -> dict:
- """Run tests and return results.
-
- Returns:
- Dict with success, test_output
- """
- cmd = ["python", "-m", "pytest", "tests/", "-x", "--tb=short"]
-
- try:
- proc = await asyncio.create_subprocess_exec(
- *cmd,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.STDOUT,
- cwd=self.repo_path,
- )
-
- stdout, _ = await asyncio.wait_for(
- proc.communicate(),
- timeout=120.0,
- )
-
- output = stdout.decode() if stdout else ""
-
- return {
- "success": proc.returncode == 0,
- "test_output": output,
- }
-
- except asyncio.TimeoutError:
- return {
- "success": False,
- "error": "Tests timed out",
- "test_output": "Timeout after 120s",
- }
- except Exception as e:
- return {
- "success": False,
- "error": str(e),
- "test_output": "",
- }
-
- async def _log_success(
- self,
- task_description: str,
- plan: EditPlan,
- commit_hash: str,
- test_results: str,
- diff: str,
- ) -> int:
- """Log successful attempt.
-
- Returns:
- Attempt ID
- """
- attempt = ModificationAttempt(
- task_description=task_description,
- approach=plan.approach,
- files_modified=plan.files_to_modify + plan.files_to_create,
- diff=diff[:5000], # Truncate for storage
- test_results=test_results,
- outcome=Outcome.SUCCESS,
- )
-
- attempt_id = await self.journal.log_attempt(attempt)
-
- # Generate and store reflection
- reflection_text = await self.reflection.reflect_on_attempt(attempt)
- await self.journal.update_reflection(attempt_id, reflection_text)
-
- return attempt_id
-
- async def _log_failure(
- self,
- task_description: str,
- plan: EditPlan,
- test_results: str,
- error: str,
- ) -> int:
- """Log failed attempt.
-
- Returns:
- Attempt ID
- """
- attempt = ModificationAttempt(
- task_description=task_description,
- approach=plan.approach,
- files_modified=plan.files_to_modify,
- test_results=test_results,
- outcome=Outcome.FAILURE,
- failure_analysis=error,
- retry_count=MAX_RETRIES,
- )
-
- attempt_id = await self.journal.log_attempt(attempt)
-
- # Generate reflection even for failures
- reflection_text = await self.reflection.reflect_on_attempt(attempt)
- await self.journal.update_reflection(attempt_id, reflection_text)
-
- return attempt_id
-
-
-# MCP Tool Schema
-self_edit_schema = {
- "type": "object",
- "properties": {
- "task_description": {
- "type": "string",
- "description": "Description of the code modification to make",
- },
- "context": {
- "type": "object",
- "description": "Optional additional context for the modification",
- },
- },
- "required": ["task_description"],
-}
-
-
-# Global tool instance (singleton pattern)
-_self_edit_tool: Optional[SelfEditTool] = None
-
-
-async def self_edit_tool(task_description: str, context: Optional[dict] = None) -> dict:
- """MCP tool entry point for self-edit.
-
- Args:
- task_description: What to modify
- context: Optional context
-
- Returns:
- Dict with result
- """
- global _self_edit_tool
-
- if _self_edit_tool is None:
- _self_edit_tool = SelfEditTool()
-
- result = await _self_edit_tool.execute(task_description, context)
-
- return {
- "success": result.success,
- "message": result.message,
- "attempt_id": result.attempt_id,
- "files_modified": result.files_modified,
- "commit_hash": result.commit_hash,
- "test_results": result.test_results,
- }
-
-
-def register_self_edit_tool(registry: Any, llm_adapter: Optional[object] = None) -> None:
- """Register the self-edit tool with MCP registry.
-
- Args:
- registry: MCP ToolRegistry
- llm_adapter: Optional LLM adapter
- """
- global _self_edit_tool
- _self_edit_tool = SelfEditTool(llm_adapter=llm_adapter)
-
- registry.register(
- name="self_edit",
- schema=self_edit_schema,
- handler=self_edit_tool,
- category="self_coding",
- requires_confirmation=True, # Safety: require user approval
- tags=["self-modification", "code-generation"],
- source_module="creative.tools.self_edit",
- )
-
- logger.info("Self-edit tool registered with MCP")
diff --git a/src/creative/tools/system_status.py b/src/creative/tools/system_status.py
deleted file mode 100644
index 16b0bfe0..00000000
--- a/src/creative/tools/system_status.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""System status introspection tool for Timmy.
-
-MCP-compliant tool that gives Timmy live access to his own system state:
-task queue, agent roster, memory tiers, uptime, and service health.
-"""
-
-import json
-import logging
-
-from mcp.registry import register_tool
-from mcp.schemas.base import create_tool_schema, RETURN_STRING
-
-logger = logging.getLogger(__name__)
-
-
-SYSTEM_STATUS_SCHEMA = create_tool_schema(
- name="system_status",
- description=(
- "Get live system status including task queue counts, agent roster, "
- "memory tier health, uptime, and service connectivity. "
- "Use this when asked about your status, what you're working on, "
- "agent health, or system metrics. Never guess — always call this tool."
- ),
- parameters={},
- required=[],
- returns=RETURN_STRING,
-)
-
-
-def system_status() -> str:
- """Return comprehensive live system status as formatted text.
-
- Returns:
- JSON-formatted string with system, task_queue, agents, memory sections.
- """
- try:
- from timmy.tools_intro import get_live_system_status
-
- status = get_live_system_status()
- return json.dumps(status, indent=2, default=str)
- except Exception as exc:
- logger.error("system_status tool failed: %s", exc)
- return json.dumps({"error": str(exc)})
-
-
-# Register with MCP
-register_tool(
- name="system_status",
- schema=SYSTEM_STATUS_SCHEMA,
- category="system",
-)(system_status)
diff --git a/src/creative/tools/video_tools.py b/src/creative/tools/video_tools.py
deleted file mode 100644
index 349bc1e8..00000000
--- a/src/creative/tools/video_tools.py
+++ /dev/null
@@ -1,206 +0,0 @@
-"""Video generation tools — Reel persona.
-
-Uses Wan 2.1 (via HuggingFace diffusers) for text-to-video and
-image-to-video generation. Heavy imports are lazy.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import uuid
-from pathlib import Path
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-# Lazy-loaded pipeline singletons
-_t2v_pipeline = None
-_i2v_pipeline = None
-
-
-def _get_t2v_pipeline():
- """Lazy-load the text-to-video pipeline (Wan 2.1)."""
- global _t2v_pipeline
- if _t2v_pipeline is not None:
- return _t2v_pipeline
-
- try:
- import torch
- from diffusers import DiffusionPipeline
- except ImportError:
- raise ImportError(
- "Creative dependencies not installed. "
- "Run: pip install 'timmy-time[creative]'"
- )
-
- from config import settings
- model_id = getattr(settings, "wan_model_id", "Wan-AI/Wan2.1-T2V-1.3B")
- device = "cuda" if torch.cuda.is_available() else "cpu"
- dtype = torch.float16 if device == "cuda" else torch.float32
-
- logger.info("Loading video model %s on %s …", model_id, device)
- _t2v_pipeline = DiffusionPipeline.from_pretrained(
- model_id, torch_dtype=dtype,
- ).to(device)
- logger.info("Video model loaded.")
- return _t2v_pipeline
-
-
-def _output_dir() -> Path:
- from config import settings
- d = Path(getattr(settings, "video_output_dir", "data/video"))
- d.mkdir(parents=True, exist_ok=True)
- return d
-
-
-def _save_metadata(video_path: Path, meta: dict) -> Path:
- meta_path = video_path.with_suffix(".json")
- meta_path.write_text(json.dumps(meta, indent=2))
- return meta_path
-
-
-def _export_frames_to_mp4(frames, out_path: Path, fps: int = 24) -> None:
- """Export a list of PIL Image frames to an MP4 file using moviepy."""
- import numpy as np
- from moviepy import ImageSequenceClip
-
- frame_arrays = [np.array(f) for f in frames]
- clip = ImageSequenceClip(frame_arrays, fps=fps)
- clip.write_videofile(
- str(out_path), codec="libx264", audio=False, logger=None,
- )
-
-
-# ── Resolution presets ────────────────────────────────────────────────────────
-
-RESOLUTION_PRESETS = {
- "480p": (854, 480),
- "720p": (1280, 720),
-}
-
-VIDEO_STYLES = [
- "cinematic", "anime", "documentary", "abstract",
- "timelapse", "slow-motion", "music-video", "vlog",
-]
-
-
-# ── Public tools ──────────────────────────────────────────────────────────────
-
-def generate_video_clip(
- prompt: str,
- duration: int = 5,
- resolution: str = "480p",
- fps: int = 24,
- seed: Optional[int] = None,
-) -> dict:
- """Generate a short video clip from a text prompt.
-
- Args:
- prompt: Text description of the desired video.
- duration: Target duration in seconds (2–10).
- resolution: "480p" or "720p".
- fps: Frames per second.
- seed: Optional seed for reproducibility.
-
- Returns dict with ``path``, ``duration``, ``resolution``.
- """
- pipe = _get_t2v_pipeline()
- import torch
-
- duration = max(2, min(10, duration))
- w, h = RESOLUTION_PRESETS.get(resolution, RESOLUTION_PRESETS["480p"])
- num_frames = duration * fps
-
- generator = torch.Generator(device=pipe.device)
- if seed is not None:
- generator.manual_seed(seed)
-
- logger.info("Generating %ds video at %s …", duration, resolution)
- result = pipe(
- prompt=prompt,
- num_frames=num_frames,
- width=w,
- height=h,
- generator=generator,
- )
- frames = result.frames[0] if hasattr(result, "frames") else result.images
-
- uid = uuid.uuid4().hex[:12]
- out_path = _output_dir() / f"{uid}.mp4"
- _export_frames_to_mp4(frames, out_path, fps=fps)
-
- meta = {
- "id": uid, "prompt": prompt, "duration": duration,
- "resolution": resolution, "fps": fps, "seed": seed,
- }
- _save_metadata(out_path, meta)
-
- return {"success": True, "path": str(out_path), **meta}
-
-
-def image_to_video(
- image_path: str,
- prompt: str = "",
- duration: int = 5,
- fps: int = 24,
-) -> dict:
- """Animate a still image into a video clip.
-
- Args:
- image_path: Path to the source image.
- prompt: Optional motion / style guidance.
- duration: Target duration in seconds (2–10).
- """
- pipe = _get_t2v_pipeline()
- from PIL import Image
-
- duration = max(2, min(10, duration))
- img = Image.open(image_path).convert("RGB")
- num_frames = duration * fps
-
- logger.info("Animating image %s → %ds video …", image_path, duration)
- result = pipe(
- prompt=prompt or "animate this image with natural motion",
- image=img,
- num_frames=num_frames,
- )
- frames = result.frames[0] if hasattr(result, "frames") else result.images
-
- uid = uuid.uuid4().hex[:12]
- out_path = _output_dir() / f"{uid}.mp4"
- _export_frames_to_mp4(frames, out_path, fps=fps)
-
- meta = {
- "id": uid, "source_image": image_path,
- "prompt": prompt, "duration": duration, "fps": fps,
- }
- _save_metadata(out_path, meta)
-
- return {"success": True, "path": str(out_path), **meta}
-
-
-def list_video_styles() -> dict:
- """Return supported video style presets."""
- return {"success": True, "styles": VIDEO_STYLES, "resolutions": list(RESOLUTION_PRESETS.keys())}
-
-
-# ── Tool catalogue ────────────────────────────────────────────────────────────
-
-VIDEO_TOOL_CATALOG: dict[str, dict] = {
- "generate_video_clip": {
- "name": "Generate Video Clip",
- "description": "Generate a short video clip from a text prompt using Wan 2.1",
- "fn": generate_video_clip,
- },
- "image_to_video": {
- "name": "Image to Video",
- "description": "Animate a still image into a video clip",
- "fn": image_to_video,
- },
- "list_video_styles": {
- "name": "List Video Styles",
- "description": "List supported video style presets and resolutions",
- "fn": list_video_styles,
- },
-}
diff --git a/src/creative/tools/web_search.py b/src/creative/tools/web_search.py
deleted file mode 100644
index 9bcae377..00000000
--- a/src/creative/tools/web_search.py
+++ /dev/null
@@ -1,74 +0,0 @@
-"""Web search tool using DuckDuckGo.
-
-MCP-compliant tool for searching the web.
-"""
-
-import logging
-from typing import Any
-
-from mcp.registry import register_tool
-from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_INTEGER, RETURN_STRING
-
-logger = logging.getLogger(__name__)
-
-
-WEB_SEARCH_SCHEMA = create_tool_schema(
- name="web_search",
- description="Search the web using DuckDuckGo. Use for current events, news, real-time data, and information not in your training data.",
- parameters={
- "query": {
- **PARAM_STRING,
- "description": "Search query string",
- },
- "max_results": {
- **PARAM_INTEGER,
- "description": "Maximum number of results (1-10)",
- "default": 5,
- "minimum": 1,
- "maximum": 10,
- },
- },
- required=["query"],
- returns=RETURN_STRING,
-)
-
-
-def web_search(query: str, max_results: int = 5) -> str:
- """Search the web using DuckDuckGo.
-
- Args:
- query: Search query
- max_results: Maximum results to return
-
- Returns:
- Formatted search results
- """
- try:
- from duckduckgo_search import DDGS
-
- with DDGS() as ddgs:
- results = list(ddgs.text(query, max_results=max_results))
-
- if not results:
- return "No results found."
-
- formatted = []
- for i, r in enumerate(results, 1):
- title = r.get("title", "No title")
- body = r.get("body", "No description")
- href = r.get("href", "")
- formatted.append(f"{i}. {title}\n {body[:150]}...\n {href}")
-
- return "\n\n".join(formatted)
-
- except Exception as exc:
- logger.error("Web search failed: %s", exc)
- return f"Search error: {exc}"
-
-
-# Register with MCP
-register_tool(
- name="web_search",
- schema=WEB_SEARCH_SCHEMA,
- category="research",
-)(web_search)
diff --git a/src/dashboard/app.py b/src/dashboard/app.py
index ab326f22..4889bf46 100644
--- a/src/dashboard/app.py
+++ b/src/dashboard/app.py
@@ -2,10 +2,8 @@
Key improvements:
1. Background tasks use asyncio.create_task() to avoid blocking startup
-2. Persona spawning is moved to a background task
-3. MCP bootstrap is non-blocking
-4. Chat integrations start in background
-5. All startup operations complete quickly
+2. Chat integrations start in background
+3. All startup operations complete quickly
"""
import asyncio
@@ -21,36 +19,25 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
-from src.config import settings
-from src.dashboard.routes.agents import router as agents_router
-from src.dashboard.routes.health import router as health_router
-from src.dashboard.routes.swarm import router as swarm_router
-from src.dashboard.routes.marketplace import router as marketplace_router
-from src.dashboard.routes.voice import router as voice_router
-from src.dashboard.routes.mobile import router as mobile_router
-from src.dashboard.routes.briefing import router as briefing_router
-from src.dashboard.routes.telegram import router as telegram_router
-from src.dashboard.routes.tools import router as tools_router
-from src.dashboard.routes.spark import router as spark_router
-from src.dashboard.routes.creative import router as creative_router
-from src.dashboard.routes.discord import router as discord_router
-from src.dashboard.routes.events import router as events_router
-from src.dashboard.routes.ledger import router as ledger_router
-from src.dashboard.routes.memory import router as memory_router
-from src.dashboard.routes.router import router as router_status_router
-from src.dashboard.routes.upgrades import router as upgrades_router
-from src.dashboard.routes.tasks import router as tasks_router
-from src.dashboard.routes.scripture import router as scripture_router
-from src.dashboard.routes.self_coding import router as self_coding_router
-from src.dashboard.routes.self_coding import self_modify_router
-from src.dashboard.routes.hands import router as hands_router
-from src.dashboard.routes.grok import router as grok_router
-from src.dashboard.routes.models import router as models_router
-from src.dashboard.routes.models import api_router as models_api_router
-from src.dashboard.routes.chat_api import router as chat_api_router
-from src.dashboard.routes.thinking import router as thinking_router
-from src.dashboard.routes.bugs import router as bugs_router
-from src.dashboard.routes.calm import router as calm_router
+from config import settings
+from dashboard.routes.agents import router as agents_router
+from dashboard.routes.health import router as health_router
+from dashboard.routes.marketplace import router as marketplace_router
+from dashboard.routes.voice import router as voice_router
+from dashboard.routes.mobile import router as mobile_router
+from dashboard.routes.briefing import router as briefing_router
+from dashboard.routes.telegram import router as telegram_router
+from dashboard.routes.tools import router as tools_router
+from dashboard.routes.spark import router as spark_router
+from dashboard.routes.discord import router as discord_router
+from dashboard.routes.memory import router as memory_router
+from dashboard.routes.router import router as router_status_router
+from dashboard.routes.grok import router as grok_router
+from dashboard.routes.models import router as models_router
+from dashboard.routes.models import api_router as models_api_router
+from dashboard.routes.chat_api import router as chat_api_router
+from dashboard.routes.thinking import router as thinking_router
+from dashboard.routes.calm import router as calm_router
from infrastructure.router.api import router as cascade_router
@@ -119,315 +106,10 @@ async def _briefing_scheduler() -> None:
logger.info("Briefing is fresh; skipping generation.")
except Exception as exc:
logger.error("Briefing scheduler error: %s", exc)
- try:
- from infrastructure.error_capture import capture_error
- capture_error(exc, source="briefing_scheduler")
- except Exception:
- pass
await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600)
-async def _thinking_loop() -> None:
- """Background task: Timmy's default thinking thread."""
- from swarm.task_queue.models import create_task
- from datetime import datetime
-
- await asyncio.sleep(10)
-
- while True:
- try:
- now = datetime.now()
- create_task(
- title=f"Thought: {now.strftime('%A %B %d, %I:%M %p')}",
- description="Continue thinking about your existence, recent events, scripture, creative ideas, or a previous thread of thought.",
- assigned_to="timmy",
- created_by="timmy",
- priority="low",
- requires_approval=False,
- auto_approve=True,
- task_type="thought",
- )
- logger.debug("Created thought task in queue")
- except Exception as exc:
- logger.error("Thinking loop error: %s", exc)
- try:
- from infrastructure.error_capture import capture_error
- capture_error(exc, source="thinking_loop")
- except Exception:
- pass
-
- await asyncio.sleep(settings.thinking_interval_seconds)
-
-
-def handle_bug_report(task):
- """Process a bug report: log the decision and dispatch a fix task to Forge.
-
- Timmy receives the bug report, decides it needs fixing, and creates
- a code_fix task assigned to Forge. Every decision is logged to the
- event log so there is a full audit trail of what Timmy decided and why.
- """
- from swarm.event_log import EventType, log_event
- from swarm.task_queue.models import create_task
-
- decision = {
- "action": "dispatch_to_forge",
- "reason": f"Bug report received, dispatching fix to Forge: {task.title}",
- "priority": task.priority.value,
- "source_task_id": task.id,
- }
-
- # Dispatch a fix task to Forge
- try:
- fix_task = create_task(
- title=f"[Fix] {task.title}",
- description=(
- f"## Bug Report\n\n{task.description or task.title}\n\n"
- f"## Task\n\nImplement a fix for this bug and write a test proving the fix."
- ),
- assigned_to="forge",
- created_by="timmy",
- priority=task.priority.value,
- task_type="code_fix",
- requires_approval=False,
- auto_approve=True,
- parent_task_id=task.id,
- )
- decision["outcome"] = "fix_dispatched"
- decision["fix_task_id"] = fix_task.id
- except Exception as e:
- decision["outcome"] = "dispatch_failed"
- decision["error"] = str(e)
-
- # Log the decision trail to the event log
- try:
- log_event(
- EventType.BUG_REPORT_CREATED,
- source="bug_report_handler",
- task_id=task.id,
- agent_id="timmy",
- data=decision,
- )
- except Exception:
- pass
-
- # Return structured result (stored in task.result)
- if decision.get("fix_task_id"):
- return (
- f"Fix dispatched to Forge (task {decision['fix_task_id']}) | "
- f"Decision: {decision['reason']}"
- )
- return (
- f"Bug tracked internally (dispatch failed) | "
- f"Decision: {decision['reason']} | Error: {decision.get('error', 'unknown')}"
- )
-
-
-async def _task_processor_loop() -> None:
- """Background task: Timmy's task queue processor."""
- from swarm.task_processor import task_processor
- from swarm.task_queue.models import update_task_status, list_tasks, TaskStatus
- from timmy.session import chat as timmy_chat
- from datetime import datetime
- import json
-
- await asyncio.sleep(5)
-
- def handle_chat_response(task):
- try:
- now = datetime.now()
- context = f"[System: Current date/time is {now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n\n"
- response = timmy_chat(context + task.description)
-
- # Log the real agent response to chat history
- try:
- from dashboard.store import message_log
- timestamp = now.strftime("%H:%M:%S")
- message_log.append(role="agent", content=response, timestamp=timestamp, source="system")
- except Exception as e:
- logger.debug("Failed to log response to message_log: %s", e)
-
- # Push response to chat UI via WebSocket
- try:
- from infrastructure.ws_manager.handler import ws_manager
- asyncio.create_task(
- ws_manager.broadcast(
- "timmy_response",
- {
- "task_id": task.id,
- "response": response,
- },
- )
- )
- except Exception as e:
- logger.debug("Failed to push response via WS: %s", e)
-
- return response
- except Exception as e:
- logger.error("Chat response failed: %s", e)
- try:
- from infrastructure.error_capture import capture_error
- capture_error(e, source="chat_response_handler")
- except Exception:
- pass
- return f"Error: {str(e)}"
-
- def handle_thought(task):
- from timmy.thinking import thinking_engine
- try:
- loop = asyncio.get_event_loop()
- future = asyncio.run_coroutine_threadsafe(
- thinking_engine.think_once(), loop
- )
- result = future.result(timeout=120)
- return str(result) if result else "Thought completed"
- except Exception as e:
- logger.error("Thought processing failed: %s", e)
- try:
- from infrastructure.error_capture import capture_error
- capture_error(e, source="thought_handler")
- except Exception:
- pass
- return f"Error: {str(e)}"
-
- def handle_task_request(task):
- try:
- now = datetime.now()
- context = (
- f"[System: Current date/time is {now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
- f"[System: You have been assigned a task from the queue. "
- f"Complete it and provide your response.]\n\n"
- f"Task: {task.title}\n"
- )
- if task.description and task.description != task.title:
- context += f"Details: {task.description}\n"
-
- response = timmy_chat(context)
-
- try:
- from infrastructure.ws_manager.handler import ws_manager
- asyncio.create_task(
- ws_manager.broadcast(
- "task_response",
- {
- "task_id": task.id,
- "response": response,
- },
- )
- )
- except Exception as e:
- logger.debug("Failed to push task response via WS: %s", e)
-
- return response
- except Exception as e:
- logger.error("Task request processing failed: %s", e)
- try:
- from infrastructure.error_capture import capture_error
- capture_error(e, source="task_request_handler")
- except Exception:
- pass
- return f"Error: {str(e)}"
-
- # Register handlers for all known task types
- task_processor.register_handler("chat_response", handle_chat_response)
- task_processor.register_handler("thought", handle_thought)
- task_processor.register_handler("internal", handle_thought)
- task_processor.register_handler("bug_report", handle_bug_report)
- task_processor.register_handler("task_request", handle_task_request)
- task_processor.register_handler("escalation", handle_task_request)
- task_processor.register_handler("external", handle_task_request)
-
- # ── Reconcile zombie tasks from previous crash ──
- zombie_count = task_processor.reconcile_zombie_tasks()
- if zombie_count:
- logger.info("Recycled %d zombie task(s) back to approved", zombie_count)
-
- # ── Re-approve tasks backlogged due to missing handlers ──
- stale = list_tasks(status=TaskStatus.BACKLOGGED, assigned_to="timmy")
- requeued = 0
- for t in stale:
- if t.backlog_reason and "No handler for task type" in t.backlog_reason:
- update_task_status(t.id, TaskStatus.APPROVED, result=None)
- requeued += 1
- if requeued:
- logger.info("Re-queued %d task(s) that were backlogged due to missing handlers", requeued)
-
- # ── Startup drain: iterate through all pending tasks immediately ──
- logger.info("Draining task queue on startup...")
- try:
- summary = await task_processor.drain_queue()
- if summary["processed"] or summary["backlogged"]:
- logger.info(
- "Startup drain: %d processed, %d backlogged, %d skipped, %d failed",
- summary["processed"],
- summary["backlogged"],
- summary["skipped"],
- summary["failed"],
- )
-
- # Notify via WebSocket so the dashboard updates
- try:
- from infrastructure.ws_manager.handler import ws_manager
-
- asyncio.create_task(
- ws_manager.broadcast_json(
- {
- "type": "task_event",
- "event": "startup_drain_complete",
- "summary": summary,
- }
- )
- )
- except Exception:
- pass
- except Exception as exc:
- logger.error("Startup drain failed: %s", exc)
- try:
- from infrastructure.error_capture import capture_error
- capture_error(exc, source="task_processor_startup")
- except Exception:
- pass
-
- # ── Steady-state: poll for new tasks ──
- logger.info("Task processor entering steady-state loop")
- await task_processor.run_loop(interval_seconds=3.0)
-
-
-async def _spawn_persona_agents_background() -> None:
- """Background task: register persona agents in the registry.
-
- Coordinator/persona spawning has been deprecated. Agents are now
- registered directly in the registry. Orchestration will be handled
- by established tools (OpenClaw, Agno, etc.).
- """
- from swarm import registry
-
- await asyncio.sleep(1) # Let server fully start
-
- if os.environ.get("TIMMY_TEST_MODE") != "1":
- logger.info("Registering persona agents: Echo, Forge, Seer...")
- try:
- for name, aid in [("Echo", "persona-echo"), ("Forge", "persona-forge"), ("Seer", "persona-seer")]:
- registry.register(name=name, agent_id=aid, capabilities="persona")
- logger.info("Persona agents registered successfully")
- except Exception as exc:
- logger.error("Failed to register persona agents: %s", exc)
-
-
-async def _bootstrap_mcp_background() -> None:
- """Background task: bootstrap MCP tools without blocking startup."""
- from mcp.bootstrap import auto_bootstrap
-
- await asyncio.sleep(0.5) # Let server start
-
- try:
- registered = auto_bootstrap()
- if registered:
- logger.info("MCP auto-bootstrap: %d tools registered", len(registered))
- except Exception as exc:
- logger.warning("MCP auto-bootstrap failed: %s", exc)
-
-
async def _start_chat_integrations_background() -> None:
"""Background task: start chat integrations without blocking startup."""
from integrations.telegram_bot.bot import telegram_bot
@@ -438,7 +120,7 @@ async def _start_chat_integrations_background() -> None:
# Register Discord in the platform registry
platform_registry.register(discord_bot)
-
+
if settings.telegram_token:
try:
await telegram_bot.start()
@@ -505,65 +187,15 @@ async def _discord_token_watcher() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager with non-blocking startup."""
-
+
# Create all background tasks without waiting for them
briefing_task = asyncio.create_task(_briefing_scheduler())
-
- # Register Timmy as the primary agent
- from swarm import registry as swarm_registry
- swarm_registry.register(
- name="Timmy",
- capabilities="chat,reasoning,research,planning",
- agent_id="timmy",
- )
-
- # Spawn persona agents in background
- persona_task = asyncio.create_task(_spawn_persona_agents_background())
-
- # Log system startup event
- try:
- from swarm.event_log import log_event, EventType
- log_event(
- EventType.SYSTEM_INFO,
- source="system",
- data={"message": "Timmy Time system started"},
- )
- except Exception:
- pass
-
- # Bootstrap MCP tools in background
- mcp_task = asyncio.create_task(_bootstrap_mcp_background())
-
- # Register OpenFang vendor tools (if enabled)
- if settings.openfang_enabled:
- try:
- from infrastructure.openfang.tools import register_openfang_tools
-
- count = register_openfang_tools()
- logger.info("OpenFang: registered %d vendor tools", count)
- except Exception as exc:
- logger.warning("OpenFang tool registration failed: %s", exc)
# Initialize Spark Intelligence engine
from spark.engine import spark_engine
if spark_engine.enabled:
logger.info("Spark Intelligence active — event capture enabled")
- # Start thinking thread if enabled
- thinking_task = None
- if settings.thinking_enabled and os.environ.get("TIMMY_TEST_MODE") != "1":
- thinking_task = asyncio.create_task(_thinking_loop())
- logger.info(
- "Default thinking thread started (interval: %ds)",
- settings.thinking_interval_seconds,
- )
-
- # Start task processor if not in test mode
- task_processor_task = None
- if os.environ.get("TIMMY_TEST_MODE") != "1":
- task_processor_task = asyncio.create_task(_task_processor_loop())
- logger.info("Task queue processor started")
-
# Start chat integrations in background
chat_task = asyncio.create_task(_start_chat_integrations_background())
@@ -577,8 +209,8 @@ async def lifespan(app: FastAPI):
await discord_bot.stop()
await telegram_bot.stop()
-
- for task in [thinking_task, task_processor_task, briefing_task, persona_task, mcp_task, chat_task]:
+
+ for task in [briefing_task, chat_task]:
if task:
task.cancel()
try:
@@ -657,7 +289,6 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# Include routers
app.include_router(health_router)
app.include_router(agents_router)
-app.include_router(swarm_router)
app.include_router(marketplace_router)
app.include_router(voice_router)
app.include_router(mobile_router)
@@ -665,46 +296,25 @@ app.include_router(briefing_router)
app.include_router(telegram_router)
app.include_router(tools_router)
app.include_router(spark_router)
-app.include_router(creative_router)
app.include_router(discord_router)
-app.include_router(events_router)
-app.include_router(ledger_router)
app.include_router(memory_router)
app.include_router(router_status_router)
-app.include_router(upgrades_router)
-app.include_router(tasks_router)
-app.include_router(scripture_router)
-app.include_router(self_coding_router)
-app.include_router(self_modify_router)
-app.include_router(hands_router)
app.include_router(grok_router)
app.include_router(models_router)
app.include_router(models_api_router)
app.include_router(chat_api_router)
app.include_router(thinking_router)
-app.include_router(bugs_router)
app.include_router(calm_router)
app.include_router(cascade_router)
@app.websocket("/ws")
async def ws_redirect(websocket: WebSocket):
- """Catch stale /ws connections and close cleanly.
-
- Before PR #82, frontend code connected to /ws which never existed as
- an endpoint. Stale browser tabs retry forever, spamming 403 errors.
- Accept the connection and immediately close with a policy-violation
- code so the client stops retrying.
-
- websockets 16.0 dropped the legacy ``transfer_data_task`` attribute,
- so calling ``websocket.close()`` after accept triggers an
- AttributeError. Use the raw ASGI send instead.
- """
+ """Catch stale /ws connections and close cleanly."""
await websocket.accept()
try:
- await websocket.close(code=1008, reason="Use /swarm/live instead")
+ await websocket.close(code=1008, reason="Deprecated endpoint")
except AttributeError:
- # websockets >= 16.0 — close via raw ASGI message
await websocket.send({"type": "websocket.close", "code": 1008})
diff --git a/src/dashboard/routes/agents.py b/src/dashboard/routes/agents.py
index c6df593b..a7cc5c26 100644
--- a/src/dashboard/routes/agents.py
+++ b/src/dashboard/routes/agents.py
@@ -1,5 +1,4 @@
import logging
-import re
from datetime import datetime
from pathlib import Path
@@ -7,7 +6,7 @@ from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
-from src.timmy.session import chat as timmy_chat
+from timmy.session import chat as timmy_chat
from dashboard.store import message_log
logger = logging.getLogger(__name__)
@@ -15,190 +14,24 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/agents", tags=["agents"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
-# ── Task queue detection ──────────────────────────────────────────────────
-# Patterns that indicate the user wants to queue a task rather than chat
-_QUEUE_PATTERNS = [
- re.compile(
- r"\b(?:add|put|schedule|queue|submit)\b.*\b(?:to the|on the|in the)?\s*(?:queue|task(?:\s*queue)?|task list)\b",
- re.IGNORECASE,
- ),
- re.compile(r"\bschedule\s+(?:this|that|a)\b", re.IGNORECASE),
- re.compile(r"\bcreate\s+(?:a\s+|an\s+)?(?:\w+\s+){0,3}task\b", re.IGNORECASE),
-]
-# Questions about tasks/queue should NOT trigger task creation
-_QUESTION_PREFIXES = re.compile(
- r"^(?:what|how|why|can you explain|could you explain|tell me about|explain|"
- r"what(?:'s| is| are| would))\b",
- re.IGNORECASE,
-)
-_QUESTION_FRAMES = re.compile(
- r"\b(?:how (?:do|does|would|can|should)|what (?:is|are|would)|"
- r"can you (?:explain|describe|tell)|best way to)\b",
- re.IGNORECASE,
-)
-
-# Known agent names for task assignment parsing
-_KNOWN_AGENTS = frozenset(
- {
- "timmy",
- "echo",
- "mace",
- "helm",
- "seer",
- "forge",
- "quill",
- "pixel",
- "lyra",
- "reel",
- }
-)
-_AGENT_PATTERN = re.compile(
- r"\bfor\s+(" + "|".join(_KNOWN_AGENTS) + r")\b", re.IGNORECASE
-)
-
-# Priority keywords → task priority mapping
-_PRIORITY_MAP = {
- "urgent": "urgent",
- "critical": "urgent",
- "asap": "urgent",
- "emergency": "urgent",
- "high priority": "high",
- "high-priority": "high",
- "important": "high",
- "low priority": "low",
- "low-priority": "low",
- "minor": "low",
-}
-
-# Queue context detection
-_QUEUE_QUERY_PATTERN = re.compile(
- r"\b(?:task(?:s|\s+queue)?|queue|what(?:'s| is) (?:in |on )?(?:the )?queue)\b",
- re.IGNORECASE,
-)
-
-
-def _extract_agent_from_message(message: str) -> str:
- """Extract target agent name from message, defaulting to 'timmy'."""
- m = _AGENT_PATTERN.search(message)
- if m:
- return m.group(1).lower()
- return "timmy"
-
-
-def _extract_priority_from_message(message: str) -> str:
- """Extract priority level from message, defaulting to 'normal'."""
- msg_lower = message.lower()
- for keyword, priority in sorted(_PRIORITY_MAP.items(), key=lambda x: -len(x[0])):
- if keyword in msg_lower:
- return priority
- return "normal"
-
-
-def _extract_task_from_message(message: str) -> dict | None:
- """If the message looks like a task-queue request, return task details.
-
- Returns None for meta-questions about tasks (e.g. "how do I create a task?").
- """
- if _QUESTION_PREFIXES.search(message) or _QUESTION_FRAMES.search(message):
- return None
- for pattern in _QUEUE_PATTERNS:
- if pattern.search(message):
- # Strip the queue instruction to get the actual task description
- title = re.sub(
- r"\b(?:add|put|schedule|queue|submit|create)\b.*?\b(?:to the|on the|in the|an?)?(?:\s+\w+){0,3}\s*(?:queue|task(?:\s*queue)?|task list)\b",
- "",
- message,
- flags=re.IGNORECASE,
- ).strip(" ,:;-")
- # Strip "for {agent}" from title
- title = _AGENT_PATTERN.sub("", title).strip(" ,:;-")
- # Strip priority keywords from title
- title = re.sub(
- r"\b(?:urgent|critical|asap|emergency|high[- ]priority|important|low[- ]priority|minor)\b",
- "",
- title,
- flags=re.IGNORECASE,
- ).strip(" ,:;-")
- # Strip leading "to " that often remains
- title = re.sub(r"^to\s+", "", title, flags=re.IGNORECASE).strip()
- # Clean up double spaces
- title = re.sub(r"\s{2,}", " ", title).strip()
- # Fallback to full message if stripping removed everything
- if not title or len(title) < 5:
- title = message
- # Capitalize first letter
- title = title[0].upper() + title[1:] if title else title
- agent = _extract_agent_from_message(message)
- priority = _extract_priority_from_message(message)
- return {
- "title": title[:120],
- "description": message,
- "agent": agent,
- "priority": priority,
- }
- return None
-
-
-def _build_queue_context() -> str:
- """Build a concise task queue summary for context injection."""
- try:
- from swarm.task_queue.models import get_counts_by_status, list_tasks, TaskStatus
-
- counts = get_counts_by_status()
- queued = counts.get("approved", 0)
- escalated = counts.get("pending_approval", 0)
- running = counts.get("running", 0)
- completed = counts.get("completed", 0)
-
- parts = [
- f"[System: Task queue — {queued} queued, {running} running, {completed} completed."
- ]
- if escalated > 0:
- parts.append(f"{escalated} escalation(s) awaiting human review.")
- if running > 0:
- tasks = list_tasks(status=TaskStatus.RUNNING, limit=5)
- if tasks:
- items = ", ".join(f'"{t.title}" ({t.assigned_to})' for t in tasks)
- parts.append(f"Running: {items}.")
- return " ".join(parts) + "]"
- except Exception as exc:
- logger.debug("Failed to build queue context: %s", exc)
- return ""
-
-
-# Static metadata for known agents — enriched onto live registry entries.
-_AGENT_METADATA: dict[str, dict] = {
- "timmy": {
- "type": "sovereign",
- "model": "", # Injected dynamically from settings
- "backend": "ollama",
- "version": "1.0.0",
- },
-}
-
@router.get("")
async def list_agents():
- """Return all registered agents with live status from the swarm registry."""
- from swarm import registry as swarm_registry
+ """Return registered agents."""
from config import settings
- # Inject model name from settings into timmy metadata
- metadata = dict(_AGENT_METADATA)
- if "timmy" in metadata and not metadata["timmy"].get("model"):
- metadata["timmy"]["model"] = settings.ollama_model
-
- agents = swarm_registry.list_agents()
return {
"agents": [
{
- "id": a.id,
- "name": a.name,
- "status": a.status,
- "capabilities": a.capabilities,
- **metadata.get(a.id, {}),
+ "id": "timmy",
+ "name": "Timmy",
+ "status": "idle",
+ "capabilities": "chat,reasoning,research,planning",
+ "type": "sovereign",
+ "model": settings.ollama_model,
+ "backend": "ollama",
+ "version": "1.0.0",
}
- for a in agents
]
}
@@ -206,11 +39,8 @@ async def list_agents():
@router.get("/timmy/panel", response_class=HTMLResponse)
async def timmy_panel(request: Request):
"""Timmy chat panel — for HTMX main-panel swaps."""
- from swarm import registry as swarm_registry
-
- agent = swarm_registry.get_agent("timmy")
return templates.TemplateResponse(
- request, "partials/timmy_panel.html", {"agent": agent}
+ request, "partials/timmy_panel.html", {"agent": None}
)
@@ -235,98 +65,22 @@ async def clear_history(request: Request):
@router.post("/timmy/chat", response_class=HTMLResponse)
async def chat_timmy(request: Request, message: str = Form(...)):
- """Chat with Timmy - queues message as task for async processing."""
- from swarm.task_queue.models import create_task, get_queue_status_for_task
-
+ """Chat with Timmy — synchronous response."""
timestamp = datetime.now().strftime("%H:%M:%S")
- task_id = None
response_text = None
error_text = None
- queue_info = None
- # Check if the user wants to queue a task (explicit queue request)
- task_info = _extract_task_from_message(message)
- if task_info:
- try:
- task = create_task(
- title=task_info["title"],
- description=task_info["description"],
- created_by="user",
- assigned_to=task_info.get("agent", "timmy"),
- priority=task_info.get("priority", "normal"),
- task_type="task_request",
- )
- task_id = task.id
- priority_label = (
- f" | Priority: `{task.priority.value}`"
- if task.priority.value != "normal"
- else ""
- )
- response_text = (
- f"Task queued: **{task.title}**\n\n"
- f"Assigned to: `{task.assigned_to}`{priority_label} | "
- f"Status: `{task.status.value}` | "
- f"[View Task Queue](/tasks)"
- )
- logger.info(
- "Chat → task queue: %s → %s (id=%s)",
- task.title,
- task.assigned_to,
- task.id,
- )
- except Exception as exc:
- logger.error("Failed to create task from chat: %s", exc)
- task_info = None
+ try:
+ response_text = timmy_chat(message)
+ except Exception as exc:
+ logger.error("Chat error: %s", exc)
+ error_text = f"Chat error: {exc}"
- # Normal chat: always queue for async processing
- if not task_info:
- try:
- # Create a chat response task (auto-approved for timmy)
- # Priority is "high" to jump ahead of Timmy's self-generated "thought" tasks
- # but below any "urgent" tasks Timmy might create
- task = create_task(
- title=message[:100] + ("..." if len(message) > 100 else ""),
- description=message,
- created_by="user",
- assigned_to="timmy",
- priority="high", # Higher than thought tasks, lower than urgent
- task_type="chat_response",
- )
- task_id = task.id
- queue_info = get_queue_status_for_task(task.id)
-
- # Acknowledge queuing
- position = queue_info.get("position", 1)
- total = queue_info.get("total", 1)
- percent_ahead = queue_info.get("percent_ahead", 0)
-
- response_text = (
- f"Message queued for Timmy's attention.\n\n"
- f"**Queue position:** {position}/{total} ({100 - percent_ahead}% complete ahead of you)\n\n"
- f"_Timmy will respond shortly..._"
- )
- logger.info(
- "Chat → queued: %s (id=%s, position=%d/%d)",
- message[:50],
- task.id,
- position,
- total,
- )
- except Exception as exc:
- logger.error("Failed to queue chat message: %s", exc)
- error_text = f"Failed to queue message: {exc}"
-
- # Log user message to history. For chat_response tasks the real agent
- # reply is logged by the task processor when it completes, so we only
- # log the queue acknowledgment for explicit task_request commands.
message_log.append(role="user", content=message, timestamp=timestamp, source="browser")
- if task_info and response_text is not None:
- # Explicit task queue command — the acknowledgment IS the response
+ if response_text is not None:
message_log.append(role="agent", content=response_text, timestamp=timestamp, source="browser")
elif error_text:
- message_log.append(
- role="error", content=error_text, timestamp=timestamp, source="browser"
- )
+ message_log.append(role="error", content=error_text, timestamp=timestamp, source="browser")
return templates.TemplateResponse(
request,
@@ -336,7 +90,7 @@ async def chat_timmy(request: Request, message: str = Form(...)):
"response": response_text,
"error": error_text,
"timestamp": timestamp,
- "task_id": task_id,
- "queue_info": queue_info,
+ "task_id": None,
+ "queue_info": None,
},
)
diff --git a/src/dashboard/routes/bugs.py b/src/dashboard/routes/bugs.py
deleted file mode 100644
index e5e49f7f..00000000
--- a/src/dashboard/routes/bugs.py
+++ /dev/null
@@ -1,161 +0,0 @@
-"""Bug Report routes -- error feedback loop dashboard.
-
-GET /bugs -- Bug reports dashboard page
-GET /api/bugs -- List bug reports (JSON)
-GET /api/bugs/stats -- Bug report statistics
-POST /api/bugs/submit -- Submit structured bug reports (from AI test runs)
-"""
-
-import logging
-from pathlib import Path
-from typing import Optional
-
-from fastapi import APIRouter, Request
-from fastapi.responses import HTMLResponse, JSONResponse
-from fastapi.templating import Jinja2Templates
-
-from swarm.task_queue.models import create_task, list_tasks
-
-logger = logging.getLogger(__name__)
-router = APIRouter(tags=["bugs"])
-templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
-
-
-def _get_bug_reports(status: Optional[str] = None, limit: int = 50) -> list:
- """Get bug report tasks from the task queue."""
- all_tasks = list_tasks(limit=limit)
- bugs = [t for t in all_tasks if t.task_type == "bug_report"]
- if status:
- bugs = [t for t in bugs if t.status.value == status]
- return bugs
-
-
-@router.get("/bugs", response_class=HTMLResponse)
-async def bugs_page(request: Request, status: Optional[str] = None):
- """Bug reports dashboard page."""
- bugs = _get_bug_reports(status=status, limit=200)
-
- # Count by status
- all_bugs = _get_bug_reports(limit=500)
- stats: dict[str, int] = {}
- for bug in all_bugs:
- s = bug.status.value
- stats[s] = stats.get(s, 0) + 1
-
- return templates.TemplateResponse(
- request,
- "bugs.html",
- {
- "page_title": "Bug Reports",
- "bugs": bugs,
- "stats": stats,
- "total": len(all_bugs),
- "filter_status": status,
- },
- )
-
-
-@router.get("/api/bugs", response_class=JSONResponse)
-async def api_list_bugs(status: Optional[str] = None, limit: int = 50):
- """List bug reports as JSON."""
- bugs = _get_bug_reports(status=status, limit=limit)
- return {
- "bugs": [
- {
- "id": b.id,
- "title": b.title,
- "description": b.description,
- "status": b.status.value,
- "priority": b.priority.value,
- "created_at": b.created_at,
- "result": b.result,
- }
- for b in bugs
- ],
- "count": len(bugs),
- }
-
-
-@router.get("/api/bugs/stats", response_class=JSONResponse)
-async def api_bug_stats():
- """Bug report statistics."""
- all_bugs = _get_bug_reports(limit=500)
- stats: dict[str, int] = {}
- for bug in all_bugs:
- s = bug.status.value
- stats[s] = stats.get(s, 0) + 1
- return {"stats": stats, "total": len(all_bugs)}
-
-
-# ── Bug Report Submission ────────────────────────────────────────────────────
-
-# Severity → task priority mapping
-_SEVERITY_MAP = {"P0": "urgent", "P1": "high", "P2": "normal"}
-
-
-def _format_bug_description(bug: dict, reporter: str) -> str:
- """Format a bug dict into a markdown task description."""
- parts = [
- f"**Reporter:** {reporter}",
- f"**Severity:** {bug['severity']}",
- "",
- "## Problem",
- bug["description"],
- ]
- if bug.get("evidence"):
- parts += ["", "## Evidence", bug["evidence"]]
- if bug.get("root_cause"):
- parts += ["", "## Suspected Root Cause", bug["root_cause"]]
- if bug.get("fix_options"):
- parts += ["", "## Suggested Fixes"]
- for i, fix in enumerate(bug["fix_options"], 1):
- parts.append(f"{i}. {fix}")
- return "\n".join(parts)
-
-
-@router.post("/api/bugs/submit", response_class=JSONResponse)
-async def submit_bugs(request: Request):
- """Submit structured bug reports from an AI test run.
-
- Body: { "reporter": "comet", "bugs": [ { "title", "severity", "description", ... } ] }
- """
- try:
- body = await request.json()
- except Exception:
- return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
-
- reporter = body.get("reporter", "unknown")
- bugs = body.get("bugs", [])
-
- if not bugs:
- return JSONResponse(status_code=400, content={"error": "No bugs provided"})
-
- task_ids = []
- for bug in bugs:
- title = bug.get("title", "")
- severity = bug.get("severity", "")
- description = bug.get("description", "")
-
- if not title or not severity or not description:
- return JSONResponse(
- status_code=400,
- content={"error": f"Bug missing required fields (title, severity, description)"},
- )
-
- priority = _SEVERITY_MAP.get(severity, "normal")
-
- task = create_task(
- title=f"[{severity}] {title}",
- description=_format_bug_description(bug, reporter),
- task_type="bug_report",
- assigned_to="timmy",
- created_by=reporter,
- priority=priority,
- requires_approval=False,
- auto_approve=True,
- )
- task_ids.append(task.id)
-
- logger.info("Bug report submitted: %d bug(s) from %s", len(task_ids), reporter)
-
- return {"created": len(task_ids), "task_ids": task_ids}
diff --git a/src/dashboard/routes/creative.py b/src/dashboard/routes/creative.py
deleted file mode 100644
index c18b4453..00000000
--- a/src/dashboard/routes/creative.py
+++ /dev/null
@@ -1,87 +0,0 @@
-"""Creative Studio dashboard route — /creative endpoints.
-
-Provides a dashboard page for the creative pipeline: image generation,
-music generation, video generation, and the full director pipeline.
-"""
-
-from pathlib import Path
-from typing import Optional
-
-from fastapi import APIRouter, Request
-from fastapi.responses import HTMLResponse
-from fastapi.templating import Jinja2Templates
-
-router = APIRouter(tags=["creative"])
-templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
-
-
-@router.get("/creative/ui", response_class=HTMLResponse)
-async def creative_studio(request: Request):
- """Render the Creative Studio page."""
- # Collect existing outputs
- image_dir = Path("data/images")
- music_dir = Path("data/music")
- video_dir = Path("data/video")
- creative_dir = Path("data/creative")
-
- images = sorted(image_dir.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)[:20] if image_dir.exists() else []
- music_files = sorted(music_dir.glob("*.wav"), key=lambda p: p.stat().st_mtime, reverse=True)[:20] if music_dir.exists() else []
- videos = sorted(video_dir.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True)[:20] if video_dir.exists() else []
-
- # Load projects
- projects = []
- if creative_dir.exists():
- for proj_dir in sorted(creative_dir.iterdir(), reverse=True):
- meta_path = proj_dir / "project.json"
- if meta_path.exists():
- import json
- projects.append(json.loads(meta_path.read_text()))
-
- return templates.TemplateResponse(
- request,
- "creative.html",
- {
- "page_title": "Creative Studio",
- "images": [{"name": p.name, "path": str(p)} for p in images],
- "music_files": [{"name": p.name, "path": str(p)} for p in music_files],
- "videos": [{"name": p.name, "path": str(p)} for p in videos],
- "projects": projects[:10],
- "image_count": len(images),
- "music_count": len(music_files),
- "video_count": len(videos),
- "project_count": len(projects),
- },
- )
-
-
-@router.get("/creative/api/projects")
-async def creative_projects_api():
- """Return creative projects as JSON."""
- try:
- from creative.director import list_projects
- return {"projects": list_projects()}
- except ImportError:
- return {"projects": []}
-
-
-@router.get("/creative/api/genres")
-async def creative_genres_api():
- """Return supported music genres."""
- try:
- from creative.tools.music_tools import GENRES
- return {"genres": GENRES}
- except ImportError:
- return {"genres": []}
-
-
-@router.get("/creative/api/video-styles")
-async def creative_video_styles_api():
- """Return supported video styles and resolutions."""
- try:
- from creative.tools.video_tools import VIDEO_STYLES, RESOLUTION_PRESETS
- return {
- "styles": VIDEO_STYLES,
- "resolutions": list(RESOLUTION_PRESETS.keys()),
- }
- except ImportError:
- return {"styles": [], "resolutions": []}
diff --git a/src/dashboard/routes/events.py b/src/dashboard/routes/events.py
deleted file mode 100644
index b8cf0894..00000000
--- a/src/dashboard/routes/events.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""Event Log routes for viewing system events."""
-
-from pathlib import Path
-from typing import Optional
-
-from fastapi import APIRouter, Request
-from fastapi.responses import HTMLResponse
-from fastapi.templating import Jinja2Templates
-
-from swarm.event_log import (
- EventType,
- list_events,
- get_event_summary,
- get_recent_events,
-)
-
-router = APIRouter(prefix="/swarm", tags=["events"])
-templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
-
-
-@router.get("/events", response_class=HTMLResponse)
-async def events_page(
- request: Request,
- event_type: Optional[str] = None,
- task_id: Optional[str] = None,
- agent_id: Optional[str] = None,
-):
- """Event log viewer page."""
- # Parse event type filter
- evt_type = None
- if event_type:
- try:
- evt_type = EventType(event_type)
- except ValueError:
- pass
-
- # Get events
- events = list_events(
- event_type=evt_type,
- task_id=task_id,
- agent_id=agent_id,
- limit=100,
- )
-
- # Get summary stats
- summary = get_event_summary(minutes=60)
-
- return templates.TemplateResponse(
- request,
- "events.html",
- {
- "page_title": "Event Log",
- "events": events,
- "summary": summary,
- "filter_type": event_type,
- "filter_task": task_id,
- "filter_agent": agent_id,
- "event_types": [e.value for e in EventType],
- },
- )
-
-
-@router.get("/events/partial", response_class=HTMLResponse)
-async def events_partial(
- request: Request,
- event_type: Optional[str] = None,
- task_id: Optional[str] = None,
- agent_id: Optional[str] = None,
-):
- """Event log partial for HTMX updates."""
- evt_type = None
- if event_type:
- try:
- evt_type = EventType(event_type)
- except ValueError:
- pass
-
- events = list_events(
- event_type=evt_type,
- task_id=task_id,
- agent_id=agent_id,
- limit=100,
- )
-
- return templates.TemplateResponse(
- request,
- "partials/events_table.html",
- {
- "events": events,
- },
- )
diff --git a/src/dashboard/routes/hands.py b/src/dashboard/routes/hands.py
deleted file mode 100644
index 44f1c1fc..00000000
--- a/src/dashboard/routes/hands.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""Hands Dashboard Routes — DEPRECATED.
-
-Replaced by brain task queue. This module provides compatibility redirects.
-"""
-
-from fastapi import APIRouter, Request
-from fastapi.responses import HTMLResponse, JSONResponse
-
-from brain.client import BrainClient
-
-router = APIRouter(prefix="/hands", tags=["hands"])
-
-# Initialize brain client
-_brain: BrainClient = None
-
-def get_brain() -> BrainClient:
- global _brain
- if _brain is None:
- _brain = BrainClient()
- return _brain
-
-
-@router.get("/api/hands")
-async def api_list_hands():
- """Return pending tasks from brain queue (replaces Hands list)."""
- brain = get_brain()
- tasks = await brain.get_pending_tasks(limit=100)
-
- # Convert tasks to hand-like format for UI compatibility
- hands = []
- for task in tasks:
- hands.append({
- "name": f"task-{task['id']}",
- "description": task['content'][:100],
- "enabled": True,
- "status": "pending",
- "schedule": None,
- "last_run": None,
- "next_run": task['created_at'],
- "run_count": 0,
- "task_type": task['type'],
- "priority": task['priority'],
- })
-
- return hands
-
-
-@router.get("/api/hands/{name}")
-async def api_get_hand(name: str):
- """Get task details."""
- # Extract task ID from name
- if name.startswith("task-"):
- try:
- task_id = int(name.split("-")[1])
- # Return basic info
- return {
- "name": name,
- "description": "Task from distributed queue",
- "enabled": True,
- "status": "pending",
- "schedule": None,
- }
- except:
- pass
-
- return JSONResponse(
- status_code=404,
- content={"error": "Hand not found - use brain task queue"}
- )
-
-
-@router.post("/api/hands/{name}/trigger")
-async def api_trigger_hand(name: str):
- """Trigger is now just submitting to brain queue."""
- return {"status": "deprecated", "message": "Use POST /tasks instead"}
-
-
-@router.get("", response_class=HTMLResponse)
-async def hands_page(request: Request):
- """Redirect to new tasks UI."""
- from fastapi.templating import Jinja2Templates
- from pathlib import Path
-
- templates = Jinja2Templates(
- directory=str(Path(__file__).parent.parent / "templates")
- )
-
- # Return simple message about migration
- return templates.TemplateResponse(
- "hands.html",
- {
- "request": request,
- "hands": [],
- "message": "Hands system migrated to Brain Task Queue",
- }
- )
diff --git a/src/dashboard/routes/health.py b/src/dashboard/routes/health.py
index 4a31a47a..078e8918 100644
--- a/src/dashboard/routes/health.py
+++ b/src/dashboard/routes/health.py
@@ -15,8 +15,6 @@ from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from config import settings
-from lightning import get_backend
-from lightning.factory import get_backend_info
logger = logging.getLogger(__name__)
@@ -137,55 +135,30 @@ def _check_redis() -> DependencyStatus:
def _check_lightning() -> DependencyStatus:
"""Check Lightning payment backend status."""
- try:
- backend = get_backend()
- health = backend.health_check()
-
- backend_name = backend.name
- is_healthy = health.get("ok", False)
-
- if backend_name == "mock":
- return DependencyStatus(
- name="Lightning Payments",
- status="degraded",
- sovereignty_score=8,
- details={
- "backend": "mock",
- "note": "Using mock backend - set LIGHTNING_BACKEND=lnd for real payments",
- **health,
- },
- )
- else:
- return DependencyStatus(
- name="Lightning Payments",
- status="healthy" if is_healthy else "degraded",
- sovereignty_score=10,
- details={"backend": backend_name, **health},
- )
- except Exception as exc:
- return DependencyStatus(
- name="Lightning Payments",
- status="unavailable",
- sovereignty_score=8,
- details={"error": str(exc)},
- )
+ return DependencyStatus(
+ name="Lightning Payments",
+ status="unavailable",
+ sovereignty_score=8,
+ details={"note": "Lightning module removed — will be re-added in v2"},
+ )
def _check_sqlite() -> DependencyStatus:
"""Check SQLite database status."""
try:
import sqlite3
- from swarm.registry import DB_PATH
-
- conn = sqlite3.connect(str(DB_PATH))
+ from pathlib import Path
+
+ db_path = Path(settings.repo_root) / "data" / "timmy.db"
+ conn = sqlite3.connect(str(db_path))
conn.execute("SELECT 1")
conn.close()
-
+
return DependencyStatus(
name="SQLite Database",
status="healthy",
sovereignty_score=10,
- details={"path": str(DB_PATH)},
+ details={"path": str(db_path)},
)
except Exception as exc:
return DependencyStatus(
@@ -238,14 +211,7 @@ async def health_check():
# Legacy format for test compatibility
ollama_ok = await check_ollama()
- # Determine Timmy's status from swarm registry (heartbeat-backed),
- # falling back to Ollama connectivity only if not registered.
- try:
- from swarm import registry as swarm_registry
- timmy_rec = swarm_registry.get_agent("timmy")
- timmy_status = timmy_rec.status if timmy_rec else ("idle" if ollama_ok else "offline")
- except Exception:
- timmy_status = "idle" if ollama_ok else "offline"
+ timmy_status = "idle" if ollama_ok else "offline"
return {
"status": "ok" if ollama_ok else "degraded",
@@ -317,7 +283,6 @@ async def sovereignty_check():
async def component_status():
"""Get status of all system components."""
return {
- "lightning": get_backend_info(),
"config": {
"debug": settings.debug,
"model_backend": settings.timmy_model_backend,
diff --git a/src/dashboard/routes/ledger.py b/src/dashboard/routes/ledger.py
deleted file mode 100644
index fe701e5a..00000000
--- a/src/dashboard/routes/ledger.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""Lightning Ledger routes for viewing transactions and balance."""
-
-from pathlib import Path
-from typing import Optional
-
-from fastapi import APIRouter, Request
-from fastapi.responses import HTMLResponse
-from fastapi.templating import Jinja2Templates
-
-from lightning.ledger import (
- TransactionType,
- TransactionStatus,
- list_transactions,
- get_balance,
- get_transaction_stats,
-)
-
-router = APIRouter(prefix="/lightning", tags=["ledger"])
-templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
-
-
-@router.get("/ledger", response_class=HTMLResponse)
-async def ledger_page(
- request: Request,
- tx_type: Optional[str] = None,
- status: Optional[str] = None,
-):
- """Lightning ledger page with balance and transactions."""
- # Parse filters
- filter_type = None
- if tx_type:
- try:
- filter_type = TransactionType(tx_type)
- except ValueError:
- pass
-
- filter_status = None
- if status:
- try:
- filter_status = TransactionStatus(status)
- except ValueError:
- pass
-
- # Get data
- balance = get_balance()
- transactions = list_transactions(
- tx_type=filter_type,
- status=filter_status,
- limit=50,
- )
- stats = get_transaction_stats(days=7)
-
- return templates.TemplateResponse(
- request,
- "ledger.html",
- {
- "page_title": "Lightning Ledger",
- "balance": balance,
- "transactions": transactions,
- "stats": stats,
- "filter_type": tx_type,
- "filter_status": status,
- "tx_types": [t.value for t in TransactionType],
- "tx_statuses": [s.value for s in TransactionStatus],
- },
- )
-
-
-@router.get("/ledger/partial", response_class=HTMLResponse)
-async def ledger_partial(
- request: Request,
- tx_type: Optional[str] = None,
- status: Optional[str] = None,
-):
- """Ledger transactions partial for HTMX updates."""
- filter_type = None
- if tx_type:
- try:
- filter_type = TransactionType(tx_type)
- except ValueError:
- pass
-
- filter_status = None
- if status:
- try:
- filter_status = TransactionStatus(status)
- except ValueError:
- pass
-
- transactions = list_transactions(
- tx_type=filter_type,
- status=filter_status,
- limit=50,
- )
-
- return templates.TemplateResponse(
- request,
- "partials/ledger_table.html",
- {
- "transactions": transactions,
- },
- )
diff --git a/src/dashboard/routes/scripture.py b/src/dashboard/routes/scripture.py
deleted file mode 100644
index 2ab62403..00000000
--- a/src/dashboard/routes/scripture.py
+++ /dev/null
@@ -1,274 +0,0 @@
-"""Scripture dashboard routes.
-
-GET /scripture — JSON status of the scripture module
-GET /scripture/verse — Look up a single verse by reference
-GET /scripture/search — Full-text search across verse content
-GET /scripture/chapter — Retrieve an entire chapter
-GET /scripture/meditate — Get the current meditation verse
-POST /scripture/meditate — Advance meditation to the next verse
-POST /scripture/meditate/mode — Change meditation mode
-GET /scripture/memory — Scripture memory system status
-GET /scripture/xref — Cross-references for a verse
-GET /scripture/stats — Store statistics
-POST /scripture/ingest — Bulk-ingest verses (JSON array)
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-from pathlib import Path
-from typing import Optional
-
-from fastapi import APIRouter, Query, Request
-from fastapi.responses import JSONResponse
-
-from scripture.constants import BOOK_BY_ID, book_by_name
-from scripture.meditation import meditation_scheduler
-from scripture.memory import scripture_memory
-from scripture.models import Verse, encode_verse_id
-from scripture.parser import extract_references, format_reference, parse_reference
-from scripture.store import scripture_store
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(prefix="/scripture", tags=["scripture"])
-
-
-@router.get("")
-async def scripture_status():
- """Return scripture module status — store stats + memory state."""
- return JSONResponse({
- "store": scripture_store.stats(),
- "memory": scripture_memory.status(),
- "meditation": meditation_scheduler.status(),
- })
-
-
-@router.get("/verse")
-async def get_verse(
- ref: str = Query(
- ...,
- description="Biblical reference, e.g. 'John 3:16' or 'Gen 1:1-3'",
- ),
-):
- """Look up one or more verses by reference string."""
- parsed = parse_reference(ref)
- if not parsed:
- return JSONResponse(
- {"error": f"Could not parse reference: {ref}"},
- status_code=400,
- )
-
- start = parsed.start
- end = parsed.end
-
- if start.verse_id == end.verse_id:
- verse = scripture_store.get_verse(start.book, start.chapter, start.verse)
- if not verse:
- return JSONResponse({"error": "Verse not found", "ref": ref}, status_code=404)
- return JSONResponse(_verse_to_dict(verse))
-
- verses = scripture_store.get_range(start.verse_id, end.verse_id)
- if not verses:
- return JSONResponse({"error": "No verses found in range", "ref": ref}, status_code=404)
- return JSONResponse({"verses": [_verse_to_dict(v) for v in verses]})
-
-
-@router.get("/chapter")
-async def get_chapter(
- book: str = Query(..., description="Book name or abbreviation"),
- chapter: int = Query(..., ge=1, description="Chapter number"),
-):
- """Retrieve all verses in a chapter."""
- book_info = book_by_name(book)
- if not book_info:
- return JSONResponse({"error": f"Unknown book: {book}"}, status_code=400)
-
- verses = scripture_store.get_chapter(book_info.id, chapter)
- if not verses:
- return JSONResponse(
- {"error": f"No verses found for {book_info.name} {chapter}"},
- status_code=404,
- )
- return JSONResponse({
- "book": book_info.name,
- "chapter": chapter,
- "verses": [_verse_to_dict(v) for v in verses],
- })
-
-
-@router.get("/search")
-async def search_verses(
- q: str = Query(..., min_length=2, description="Search query"),
- limit: int = Query(default=20, ge=1, le=100),
-):
- """Full-text search across verse content."""
- verses = scripture_store.search_text(q, limit=limit)
- return JSONResponse({
- "query": q,
- "count": len(verses),
- "verses": [_verse_to_dict(v) for v in verses],
- })
-
-
-@router.get("/meditate")
-async def get_meditation():
- """Return the current meditation focus verse and status."""
- status = meditation_scheduler.status()
- current = meditation_scheduler.current_focus()
- return JSONResponse({
- "status": status,
- "current_verse": _verse_to_dict(current) if current else None,
- })
-
-
-@router.post("/meditate")
-async def advance_meditation():
- """Advance to the next verse in the meditation sequence."""
- verse = meditation_scheduler.next_meditation()
- if not verse:
- return JSONResponse(
- {"message": "No more verses available — scripture store may be empty"},
- status_code=404,
- )
- return JSONResponse({
- "verse": _verse_to_dict(verse),
- "status": meditation_scheduler.status(),
- })
-
-
-@router.post("/meditate/mode")
-async def set_meditation_mode(
- mode: str = Query(..., description="sequential, thematic, or lectionary"),
- theme: Optional[str] = Query(default=None, description="Theme for thematic mode"),
-):
- """Change the meditation mode."""
- try:
- state = meditation_scheduler.set_mode(mode, theme=theme)
- except ValueError as exc:
- return JSONResponse({"error": str(exc)}, status_code=400)
- return JSONResponse({
- "mode": state.mode,
- "theme": state.theme,
- "message": f"Meditation mode set to {state.mode}",
- })
-
-
-@router.get("/memory")
-async def memory_status():
- """Return the scripture memory system status."""
- return JSONResponse(scripture_memory.status())
-
-
-@router.get("/xref")
-async def get_cross_references(
- ref: str = Query(..., description="Verse reference, e.g. 'John 3:16'"),
-):
- """Find cross-references for a verse."""
- parsed = parse_reference(ref)
- if not parsed:
- return JSONResponse({"error": f"Could not parse: {ref}"}, status_code=400)
-
- verse = scripture_store.get_verse(
- parsed.start.book, parsed.start.chapter, parsed.start.verse
- )
- if not verse:
- return JSONResponse({"error": "Verse not found"}, status_code=404)
-
- xrefs = scripture_store.get_cross_references(verse.verse_id)
- results = []
- for xref in xrefs:
- target_id = (
- xref.target_verse_id
- if xref.source_verse_id == verse.verse_id
- else xref.source_verse_id
- )
- target = scripture_store.get_verse_by_id(target_id)
- if target:
- results.append({
- "reference_type": xref.reference_type,
- "confidence": xref.confidence,
- "verse": _verse_to_dict(target),
- })
-
- return JSONResponse({
- "source": _verse_to_dict(verse),
- "cross_references": results,
- })
-
-
-@router.get("/stats")
-async def store_stats():
- """Return scripture store statistics."""
- return JSONResponse(scripture_store.stats())
-
-
-@router.post("/ingest")
-async def ingest_verses(request: Request):
- """Bulk-ingest verses from a JSON array.
-
- Expects a JSON body with a "verses" key containing an array of objects
- with: book, chapter, verse_num, text, and optionally
- translation/testament/genre.
- """
- try:
- body = await request.json()
- except Exception:
- return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
-
- raw_verses = body.get("verses", [])
- if not raw_verses:
- return JSONResponse({"error": "No verses provided"}, status_code=400)
-
- verses = []
- for rv in raw_verses:
- try:
- book = int(rv["book"])
- chapter = int(rv["chapter"])
- verse_num = int(rv["verse_num"])
- text = str(rv["text"])
- book_info = BOOK_BY_ID.get(book)
- verses.append(Verse(
- verse_id=encode_verse_id(book, chapter, verse_num),
- book=book,
- chapter=chapter,
- verse_num=verse_num,
- text=text,
- translation=rv.get("translation", "ESV"),
- testament=book_info.testament if book_info else "OT",
- genre=book_info.genre if book_info else "",
- ))
- except (KeyError, ValueError, TypeError) as exc:
- logger.warning("Skipping invalid verse record: %s", exc)
- continue
-
- if verses:
- scripture_store.insert_verses(verses)
-
- return JSONResponse({
- "ingested": len(verses),
- "skipped": len(raw_verses) - len(verses),
- "total_verses": scripture_store.count_verses(),
- })
-
-
-# ── Helpers ──────────────────────────────────────────────────────────────────
-
-
-def _verse_to_dict(verse: Verse) -> dict:
- """Convert a Verse model to a JSON-friendly dict with formatted reference."""
- from scripture.models import VerseRef
-
- ref = VerseRef(book=verse.book, chapter=verse.chapter, verse=verse.verse_num)
- return {
- "verse_id": verse.verse_id,
- "reference": format_reference(ref),
- "book": verse.book,
- "chapter": verse.chapter,
- "verse_num": verse.verse_num,
- "text": verse.text,
- "translation": verse.translation,
- "testament": verse.testament,
- "genre": verse.genre,
- }
diff --git a/src/dashboard/routes/self_coding.py b/src/dashboard/routes/self_coding.py
deleted file mode 100644
index 57d7d405..00000000
--- a/src/dashboard/routes/self_coding.py
+++ /dev/null
@@ -1,428 +0,0 @@
-"""Self-Coding Dashboard Routes.
-
-API endpoints and HTMX views for the self-coding system:
-- Journal viewer with filtering
-- Stats dashboard
-- Manual task execution
-- Real-time status updates
-- Self-modification loop (/self-modify/*)
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-from typing import Optional
-
-from fastapi import APIRouter, Form, HTTPException, Request
-from fastapi.responses import HTMLResponse, JSONResponse
-from pydantic import BaseModel
-
-from config import settings
-
-from self_coding import (
- CodebaseIndexer,
- ModificationJournal,
- Outcome,
-)
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(prefix="/self-coding", tags=["self_coding"])
-
-
-# ── API Models ────────────────────────────────────────────────────────────
-
-class JournalEntryResponse(BaseModel):
- """A journal entry for API response."""
- id: int
- timestamp: str
- task_description: str
- approach: str
- files_modified: list[str]
- outcome: str
- retry_count: int
- has_reflection: bool
-
-
-class StatsResponse(BaseModel):
- """Self-coding stats for API response."""
- total_attempts: int
- success_count: int
- failure_count: int
- rollback_count: int
- success_rate: float
- recent_failures: list[JournalEntryResponse]
-
-
-class ExecuteRequest(BaseModel):
- """Request to execute a self-edit task."""
- task_description: str
-
-
-class ExecuteResponse(BaseModel):
- """Response from executing a self-edit task."""
- success: bool
- message: str
- attempt_id: Optional[int] = None
- files_modified: list[str] = []
- commit_hash: Optional[str] = None
-
-
-# ── Services (initialized lazily) ─────────────────────────────────────────
-
-_journal: Optional[ModificationJournal] = None
-_indexer: Optional[CodebaseIndexer] = None
-
-
-def get_journal() -> ModificationJournal:
- """Get or create ModificationJournal singleton."""
- global _journal
- if _journal is None:
- _journal = ModificationJournal()
- return _journal
-
-
-def get_indexer() -> CodebaseIndexer:
- """Get or create CodebaseIndexer singleton."""
- global _indexer
- if _indexer is None:
- _indexer = CodebaseIndexer()
- return _indexer
-
-
-# ── API Endpoints ─────────────────────────────────────────────────────────
-
-@router.get("/api/journal", response_model=list[JournalEntryResponse])
-async def api_journal_list(
- limit: int = 50,
- outcome: Optional[str] = None,
-):
- """Get modification journal entries.
-
- Args:
- limit: Maximum number of entries to return
- outcome: Filter by outcome (success, failure, rollback)
- """
- journal = get_journal()
-
- # Build query based on filters
- if outcome:
- try:
- outcome_enum = Outcome(outcome)
- # Get recent and filter
- from self_coding.modification_journal import ModificationAttempt
- # Note: This is a simplified query - in production you'd add
- # proper filtering to the journal class
- entries = []
- # Placeholder for filtered query
- except ValueError:
- return JSONResponse(
- status_code=400,
- content={"error": f"Invalid outcome: {outcome}"},
- )
-
- # For now, return recent failures mixed with successes
- recent = await journal.get_recent_failures(limit=limit)
-
- # Also get some successes
- # Note: We'd need to add a method to journal for this
- # For now, return what we have
-
- response = []
- for entry in recent:
- response.append(JournalEntryResponse(
- id=entry.id or 0,
- timestamp=entry.timestamp.isoformat() if entry.timestamp else "",
- task_description=entry.task_description,
- approach=entry.approach,
- files_modified=entry.files_modified,
- outcome=entry.outcome.value,
- retry_count=entry.retry_count,
- has_reflection=bool(entry.reflection),
- ))
-
- return response
-
-
-@router.get("/api/journal/{attempt_id}", response_model=dict)
-async def api_journal_detail(attempt_id: int):
- """Get detailed information about a specific attempt."""
- journal = get_journal()
- entry = await journal.get_by_id(attempt_id)
-
- if not entry:
- return JSONResponse(
- status_code=404,
- content={"error": "Attempt not found"},
- )
-
- return {
- "id": entry.id,
- "timestamp": entry.timestamp.isoformat() if entry.timestamp else "",
- "task_description": entry.task_description,
- "approach": entry.approach,
- "files_modified": entry.files_modified,
- "diff": entry.diff,
- "test_results": entry.test_results,
- "outcome": entry.outcome.value,
- "failure_analysis": entry.failure_analysis,
- "reflection": entry.reflection,
- "retry_count": entry.retry_count,
- }
-
-
-@router.get("/api/stats", response_model=StatsResponse)
-async def api_stats():
- """Get self-coding statistics."""
- journal = get_journal()
-
- metrics = await journal.get_success_rate()
- recent_failures = await journal.get_recent_failures(limit=5)
-
- return StatsResponse(
- total_attempts=metrics["total"],
- success_count=metrics["success"],
- failure_count=metrics["failure"],
- rollback_count=metrics["rollback"],
- success_rate=metrics["overall"],
- recent_failures=[
- JournalEntryResponse(
- id=f.id or 0,
- timestamp=f.timestamp.isoformat() if f.timestamp else "",
- task_description=f.task_description,
- approach=f.approach,
- files_modified=f.files_modified,
- outcome=f.outcome.value,
- retry_count=f.retry_count,
- has_reflection=bool(f.reflection),
- )
- for f in recent_failures
- ],
- )
-
-
-@router.post("/api/execute", response_model=ExecuteResponse)
-async def api_execute(request: ExecuteRequest):
- """Execute a self-edit task.
-
- This is the API endpoint for manual task execution.
- In production, this should require authentication and confirmation.
- """
- from creative.tools.self_edit import SelfEditTool
-
- tool = SelfEditTool()
- result = await tool.execute(request.task_description)
-
- return ExecuteResponse(
- success=result.success,
- message=result.message,
- attempt_id=result.attempt_id,
- files_modified=result.files_modified,
- commit_hash=result.commit_hash,
- )
-
-
-@router.get("/api/codebase/summary")
-async def api_codebase_summary():
- """Get codebase summary for LLM context."""
- indexer = get_indexer()
- await indexer.index_changed()
-
- summary = await indexer.get_summary(max_tokens=3000)
-
- return {
- "summary": summary,
- "generated_at": "",
- }
-
-
-@router.post("/api/codebase/reindex")
-async def api_codebase_reindex():
- """Trigger a full codebase reindex."""
- indexer = get_indexer()
- stats = await indexer.index_all()
-
- return {
- "indexed": stats["indexed"],
- "failed": stats["failed"],
- "skipped": stats["skipped"],
- }
-
-
-# ── HTMX Page Routes ──────────────────────────────────────────────────────
-
-@router.get("", response_class=HTMLResponse)
-async def self_coding_page(request: Request):
- """Main self-coding dashboard page."""
- from dashboard.app import templates
-
- return templates.TemplateResponse(
- "self_coding.html",
- {
- "request": request,
- "title": "Self-Coding",
- },
- )
-
-
-@router.get("/journal", response_class=HTMLResponse)
-async def journal_partial(
- request: Request,
- outcome: Optional[str] = None,
- limit: int = 20,
-):
- """HTMX partial for journal entries."""
- from dashboard.app import templates
-
- journal = get_journal()
-
- # Get entries (simplified - in production, add proper filtering)
- if outcome == "failure":
- entries = await journal.get_recent_failures(limit=limit)
- else:
- # Get all recent
- entries = await journal.get_recent_failures(limit=limit)
- # TODO: Add method to get successes too
-
- return templates.TemplateResponse(
- "partials/journal_entries.html",
- {
- "request": request,
- "entries": entries,
- "outcome_filter": outcome,
- },
- )
-
-
-@router.get("/stats", response_class=HTMLResponse)
-async def stats_partial(request: Request):
- """HTMX partial for stats cards."""
- from dashboard.app import templates
-
- journal = get_journal()
- metrics = await journal.get_success_rate()
-
- return templates.TemplateResponse(
- "partials/self_coding_stats.html",
- {
- "request": request,
- "metrics": metrics,
- },
- )
-
-
-@router.get("/execute-form", response_class=HTMLResponse)
-async def execute_form_partial(request: Request):
- """HTMX partial for execute task form."""
- from dashboard.app import templates
-
- return templates.TemplateResponse(
- "partials/execute_form.html",
- {
- "request": request,
- },
- )
-
-
-@router.post("/execute", response_class=HTMLResponse)
-async def execute_task(
- request: Request,
- task_description: str = Form(...),
-):
- """HTMX endpoint to execute a task."""
- from dashboard.app import templates
- from creative.tools.self_edit import SelfEditTool
-
- tool = SelfEditTool()
- result = await tool.execute(task_description)
-
- return templates.TemplateResponse(
- "partials/execute_result.html",
- {
- "request": request,
- "result": result,
- },
- )
-
-
-@router.get("/journal/{attempt_id}/detail", response_class=HTMLResponse)
-async def journal_entry_detail(request: Request, attempt_id: int):
- """HTMX partial for journal entry detail."""
- from dashboard.app import templates
-
- journal = get_journal()
- entry = await journal.get_by_id(attempt_id)
-
- if not entry:
- return templates.TemplateResponse(
- "partials/error.html",
- {
- "request": request,
- "message": "Attempt not found",
- },
- )
-
- return templates.TemplateResponse(
- "partials/journal_entry_detail.html",
- {
- "request": request,
- "entry": entry,
- },
- )
-
-
-# ── Self-Modification Routes (/self-modify/*) ───────────────────────────
-
-self_modify_router = APIRouter(prefix="/self-modify", tags=["self-modify"])
-
-
-@self_modify_router.post("/run")
-async def run_self_modify(
- instruction: str = Form(...),
- target_files: str = Form(""),
- dry_run: bool = Form(False),
- speak_result: bool = Form(False),
-):
- """Execute a self-modification loop."""
- if not settings.self_modify_enabled:
- raise HTTPException(403, "Self-modification is disabled")
-
- from self_coding.self_modify.loop import SelfModifyLoop, ModifyRequest
-
- files = [f.strip() for f in target_files.split(",") if f.strip()]
- request = ModifyRequest(
- instruction=instruction,
- target_files=files,
- dry_run=dry_run,
- )
-
- loop = SelfModifyLoop()
- result = await asyncio.to_thread(loop.run, request)
-
- if speak_result and result.success:
- try:
- from timmy_serve.voice_tts import voice_tts
- if voice_tts.available:
- voice_tts.speak(
- f"Code modification complete. "
- f"{len(result.files_changed)} files changed. Tests passing."
- )
- except Exception:
- pass
-
- return {
- "success": result.success,
- "files_changed": result.files_changed,
- "test_passed": result.test_passed,
- "commit_sha": result.commit_sha,
- "branch_name": result.branch_name,
- "error": result.error,
- "attempts": result.attempts,
- }
-
-
-@self_modify_router.get("/status")
-async def self_modify_status():
- """Return whether self-modification is enabled."""
- return {"enabled": settings.self_modify_enabled}
diff --git a/src/dashboard/routes/swarm.py b/src/dashboard/routes/swarm.py
deleted file mode 100644
index 3e42df20..00000000
--- a/src/dashboard/routes/swarm.py
+++ /dev/null
@@ -1,164 +0,0 @@
-"""Swarm dashboard routes — /swarm/* endpoints.
-
-Provides REST endpoints for viewing swarm agents, tasks, and the
-live WebSocket feed. Coordinator/learner/auction plumbing has been
-removed — established tools will replace the homebrew orchestration.
-"""
-
-import logging
-from pathlib import Path
-from typing import Optional
-
-from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
-from fastapi.responses import HTMLResponse
-from fastapi.templating import Jinja2Templates
-
-from swarm import registry
-from swarm.tasks import TaskStatus, list_tasks as _list_tasks, get_task as _get_task
-from infrastructure.ws_manager.handler import ws_manager
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(prefix="/swarm", tags=["swarm"])
-templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
-
-
-@router.get("")
-async def swarm_status():
- """Return the current swarm status summary."""
- agents = registry.list_agents()
- tasks = _list_tasks()
- return {
- "agents": len(agents),
- "tasks": len(tasks),
- "status": "operational",
- }
-
-
-@router.get("/live", response_class=HTMLResponse)
-async def swarm_live_page(request: Request):
- """Render the live swarm dashboard page."""
- return templates.TemplateResponse(
- request, "swarm_live.html", {"page_title": "Swarm Live"}
- )
-
-
-@router.get("/mission-control", response_class=HTMLResponse)
-async def mission_control_page(request: Request):
- """Render the Mission Control dashboard."""
- return templates.TemplateResponse(
- request, "mission_control.html", {"page_title": "Mission Control"}
- )
-
-
-@router.get("/agents")
-async def list_swarm_agents():
- """List all registered swarm agents."""
- agents = registry.list_agents()
- return {
- "agents": [
- {
- "id": a.id,
- "name": a.name,
- "status": a.status,
- "capabilities": a.capabilities,
- "last_seen": a.last_seen,
- }
- for a in agents
- ]
- }
-
-
-@router.get("/tasks")
-async def list_tasks(status: Optional[str] = None):
- """List swarm tasks, optionally filtered by status."""
- task_status = TaskStatus(status.lower()) if status else None
- tasks = _list_tasks(status=task_status)
- return {
- "tasks": [
- {
- "id": t.id,
- "description": t.description,
- "status": t.status.value,
- "assigned_agent": t.assigned_agent,
- "result": t.result,
- "created_at": t.created_at,
- "completed_at": t.completed_at,
- }
- for t in tasks
- ]
- }
-
-
-@router.get("/tasks/{task_id}")
-async def get_task(task_id: str):
- """Get details for a specific task."""
- task = _get_task(task_id)
- if task is None:
- return {"error": "Task not found"}
- return {
- "id": task.id,
- "description": task.description,
- "status": task.status.value,
- "assigned_agent": task.assigned_agent,
- "result": task.result,
- "created_at": task.created_at,
- "completed_at": task.completed_at,
- }
-
-
-@router.get("/insights")
-async def swarm_insights():
- """Placeholder — learner metrics removed. Will be replaced by brain memory stats."""
- return {"agents": {}, "note": "Learner deprecated. Use brain.memory for insights."}
-
-
-@router.get("/insights/{agent_id}")
-async def agent_insights(agent_id: str):
- """Placeholder — learner metrics removed."""
- return {"agent_id": agent_id, "note": "Learner deprecated. Use brain.memory for insights."}
-
-
-# ── UI endpoints (return HTML partials for HTMX) ─────────────────────────────
-
-@router.get("/agents/sidebar", response_class=HTMLResponse)
-async def agents_sidebar(request: Request):
- """Sidebar partial: all registered agents."""
- agents = registry.list_agents()
- return templates.TemplateResponse(
- request, "partials/swarm_agents_sidebar.html", {"agents": agents}
- )
-
-
-@router.get("/agents/{agent_id}/panel", response_class=HTMLResponse)
-async def agent_panel(agent_id: str, request: Request):
- """Main-panel partial: agent detail."""
- agent = registry.get_agent(agent_id)
- if agent is None:
- raise HTTPException(404, "Agent not found")
- return templates.TemplateResponse(
- request,
- "partials/agent_panel.html",
- {"agent": agent, "tasks": []},
- )
-
-
-# ── WebSocket live feed ──────────────────────────────────────────────────
-
-@router.websocket("/live")
-async def swarm_live(websocket: WebSocket):
- """WebSocket endpoint for live swarm event streaming."""
- try:
- await ws_manager.connect(websocket)
- except Exception as exc:
- logger.warning("WebSocket accept failed: %s", exc)
- return
- try:
- while True:
- data = await websocket.receive_text()
- logger.debug("WS received: %s", data[:100])
- except WebSocketDisconnect:
- ws_manager.disconnect(websocket)
- except Exception as exc:
- logger.error("WebSocket error: %s", exc)
- ws_manager.disconnect(websocket)
diff --git a/src/dashboard/routes/tasks.py b/src/dashboard/routes/tasks.py
deleted file mode 100644
index bf3a297b..00000000
--- a/src/dashboard/routes/tasks.py
+++ /dev/null
@@ -1,596 +0,0 @@
-"""Task Queue routes — Human-in-the-loop approval dashboard.
-
-GET /tasks — Task queue dashboard page
-GET /api/tasks — List tasks (JSON)
-POST /api/tasks — Create a new task (JSON)
-GET /api/tasks/counts — Badge counts
-GET /api/tasks/{id} — Get single task
-PATCH /api/tasks/{id}/approve — Approve a task
-PATCH /api/tasks/{id}/veto — Veto a task
-PATCH /api/tasks/{id}/modify — Modify a task
-PATCH /api/tasks/{id}/pause — Pause a running task
-PATCH /api/tasks/{id}/cancel — Cancel / fail a task
-PATCH /api/tasks/{id}/retry — Retry a failed task
-GET /tasks/pending — HTMX partial: pending tasks
-GET /tasks/active — HTMX partial: active tasks
-GET /tasks/completed — HTMX partial: completed tasks
-"""
-
-import logging
-from pathlib import Path
-from typing import Optional
-
-from fastapi import APIRouter, Form, HTTPException, Request
-from fastapi.responses import HTMLResponse, JSONResponse
-from fastapi.templating import Jinja2Templates
-
-from swarm.task_queue.models import (
- QueueTask,
- TaskPriority,
- TaskStatus,
- create_task,
- get_counts_by_status,
- get_pending_count,
- get_task,
- list_backlogged_tasks,
- list_tasks,
- update_task,
- update_task_status,
-)
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(tags=["tasks"])
-templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
-
-
-# ── Helper to broadcast task events via WebSocket ────────────────────────
-
-
-def _broadcast_task_event(event_type: str, task: QueueTask):
- """Best-effort broadcast a task event to connected WebSocket clients."""
- try:
- import asyncio
- from infrastructure.ws_manager.handler import ws_manager
-
- payload = {
- "type": "task_event",
- "event": event_type,
- "task": {
- "id": task.id,
- "title": task.title,
- "status": task.status.value,
- "priority": task.priority.value,
- "assigned_to": task.assigned_to,
- "created_by": task.created_by,
- },
- }
- try:
- loop = asyncio.get_running_loop()
- loop.create_task(ws_manager.broadcast_json(payload))
- except RuntimeError:
- pass # No event loop running (e.g. in tests)
- except Exception:
- pass # WebSocket is optional
-
-
-# ── Dashboard page ───────────────────────────────────────────────────────
-
-
-@router.get("/tasks", response_class=HTMLResponse)
-async def task_queue_page(request: Request, assign: Optional[str] = None):
- """Task queue dashboard with three columns."""
- pending = list_tasks(status=TaskStatus.PENDING_APPROVAL) + list_tasks(
- status=TaskStatus.APPROVED
- )
- active = list_tasks(status=TaskStatus.RUNNING) + list_tasks(
- status=TaskStatus.PAUSED
- )
- backlogged = list_backlogged_tasks(limit=20)
- completed = (
- list_tasks(status=TaskStatus.COMPLETED, limit=20)
- + list_tasks(status=TaskStatus.VETOED, limit=10)
- + list_tasks(status=TaskStatus.FAILED, limit=10)
- + backlogged
- )
-
- # Get agents for the create modal
- agents = []
- try:
- from swarm import registry
- agents = [{"id": a.id, "name": a.name} for a in registry.list_agents()]
- except Exception:
- pass
- # Always include core agents
- core_agents = ["timmy", "forge", "seer", "echo"]
- agent_names = {a["name"] for a in agents}
- for name in core_agents:
- if name not in agent_names:
- agents.append({"id": name, "name": name})
-
- return templates.TemplateResponse(
- request,
- "tasks.html",
- {
- "page_title": "Task Queue",
- "pending": pending,
- "active": active,
- "completed": completed,
- "pending_count": len(pending),
- "agents": agents,
- "priorities": [p.value for p in TaskPriority],
- "pre_assign": assign or "",
- },
- )
-
-
-# ── HTMX partials ───────────────────────────────────────────────────────
-
-
-@router.get("/tasks/pending", response_class=HTMLResponse)
-async def tasks_pending_partial(request: Request):
- """HTMX partial: pending approval tasks."""
- pending = list_tasks(status=TaskStatus.PENDING_APPROVAL) + list_tasks(
- status=TaskStatus.APPROVED
- )
- return templates.TemplateResponse(
- request,
- "partials/task_cards.html",
- {"tasks": pending, "section": "pending"},
- )
-
-
-@router.get("/tasks/active", response_class=HTMLResponse)
-async def tasks_active_partial(request: Request):
- """HTMX partial: active tasks."""
- active = list_tasks(status=TaskStatus.RUNNING) + list_tasks(
- status=TaskStatus.PAUSED
- )
- return templates.TemplateResponse(
- request,
- "partials/task_cards.html",
- {"tasks": active, "section": "active"},
- )
-
-
-@router.get("/tasks/completed", response_class=HTMLResponse)
-async def tasks_completed_partial(request: Request):
- """HTMX partial: completed tasks."""
- completed = (
- list_tasks(status=TaskStatus.COMPLETED, limit=20)
- + list_tasks(status=TaskStatus.VETOED, limit=10)
- + list_tasks(status=TaskStatus.FAILED, limit=10)
- + list_backlogged_tasks(limit=20)
- )
- return templates.TemplateResponse(
- request,
- "partials/task_cards.html",
- {"tasks": completed, "section": "completed"},
- )
-
-
-# ── JSON API ─────────────────────────────────────────────────────────────
-
-
-@router.get("/api/tasks", response_class=JSONResponse)
-async def api_list_tasks(
- status: Optional[str] = None,
- priority: Optional[str] = None,
- assigned_to: Optional[str] = None,
- limit: int = 100,
-):
- """List tasks with optional filters."""
- s = TaskStatus(status.lower()) if status else None
- p = TaskPriority(priority.lower()) if priority else None
-
- tasks = list_tasks(status=s, priority=p, assigned_to=assigned_to, limit=limit)
- return {
- "tasks": [_task_to_dict(t) for t in tasks],
- "count": len(tasks),
- }
-
-
-@router.post("/api/tasks", response_class=JSONResponse)
-async def api_create_task(request: Request):
- """Create a new task (JSON body)."""
- body = await request.json()
- task = create_task(
- title=body.get("title", ""),
- description=body.get("description", ""),
- assigned_to=body.get("assigned_to", "timmy"),
- created_by=body.get("created_by", "user"),
- priority=body.get("priority", "normal"),
- requires_approval=body.get("requires_approval", False),
- auto_approve=body.get("auto_approve", True),
- parent_task_id=body.get("parent_task_id"),
- steps=body.get("steps"),
- task_type=body.get("task_type", "chat_response"),
- )
-
- # Notify
- _notify_task_created(task)
- _broadcast_task_event("task_created", task)
-
- logger.info("Task created: %s (status=%s)", task.title, task.status.value)
- return {"success": True, "task": _task_to_dict(task)}
-
-
-@router.post("/tasks/create", response_class=HTMLResponse)
-async def form_create_task(
- request: Request,
- title: str = Form(...),
- description: str = Form(""),
- assigned_to: str = Form("timmy"),
- priority: str = Form("normal"),
- requires_approval: bool = Form(False),
-):
- """Create a task from the dashboard form (Form-encoded)."""
- task = create_task(
- title=title,
- description=description,
- assigned_to=assigned_to,
- created_by="user",
- priority=priority,
- requires_approval=requires_approval,
- )
- _notify_task_created(task)
- _broadcast_task_event("task_created", task)
- logger.info("Task created (form): %s", task.title)
-
- # Return the new card for HTMX swap
- return templates.TemplateResponse(
- request,
- "partials/task_card.html",
- {"task": task},
- )
-
-
-@router.get("/api/tasks/counts", response_class=JSONResponse)
-async def api_task_counts():
- """Get task counts by status (for nav badges)."""
- counts = get_counts_by_status()
- return {
- "pending": counts.get("pending_approval", 0),
- "approved": counts.get("approved", 0),
- "running": counts.get("running", 0),
- "completed": counts.get("completed", 0),
- "failed": counts.get("failed", 0),
- "vetoed": counts.get("vetoed", 0),
- "backlogged": counts.get("backlogged", 0),
- "total": sum(counts.values()),
- }
-
-
-# ── Backlog API (must be before {task_id} catch-all) ─────────────────────
-
-
-@router.get("/api/tasks/backlog", response_class=JSONResponse)
-async def api_list_backlogged(assigned_to: Optional[str] = None, limit: int = 50):
- """List all backlogged tasks."""
- tasks = list_backlogged_tasks(assigned_to=assigned_to, limit=limit)
- return {
- "tasks": [_task_to_dict(t) for t in tasks],
- "count": len(tasks),
- }
-
-
-@router.get("/api/tasks/{task_id}", response_class=JSONResponse)
-async def api_get_task(task_id: str):
- """Get a single task by ID."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- return _task_to_dict(task)
-
-
-# ── Workflow actions ─────────────────────────────────────────────────────
-
-
-@router.patch("/api/tasks/{task_id}/approve", response_class=JSONResponse)
-async def api_approve_task(task_id: str):
- """Approve a pending task."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- if task.status not in (TaskStatus.PENDING_APPROVAL,):
- raise HTTPException(400, f"Cannot approve task in {task.status.value} state")
-
- updated = update_task_status(task_id, TaskStatus.APPROVED)
- _broadcast_task_event("task_approved", updated)
- return {"success": True, "task": _task_to_dict(updated)}
-
-
-@router.post("/tasks/{task_id}/approve", response_class=HTMLResponse)
-async def htmx_approve_task(request: Request, task_id: str):
- """Approve a pending task (HTMX)."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- updated = update_task_status(task_id, TaskStatus.APPROVED)
- _broadcast_task_event("task_approved", updated)
- return templates.TemplateResponse(
- request, "partials/task_card.html", {"task": updated}
- )
-
-
-@router.patch("/api/tasks/{task_id}/veto", response_class=JSONResponse)
-async def api_veto_task(task_id: str):
- """Veto (reject) a task."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- if task.status in (TaskStatus.COMPLETED, TaskStatus.VETOED):
- raise HTTPException(400, f"Cannot veto task in {task.status.value} state")
-
- updated = update_task_status(task_id, TaskStatus.VETOED)
- _broadcast_task_event("task_vetoed", updated)
- return {"success": True, "task": _task_to_dict(updated)}
-
-
-@router.post("/tasks/{task_id}/veto", response_class=HTMLResponse)
-async def htmx_veto_task(request: Request, task_id: str):
- """Veto a task (HTMX)."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- updated = update_task_status(task_id, TaskStatus.VETOED)
- _broadcast_task_event("task_vetoed", updated)
- return templates.TemplateResponse(
- request, "partials/task_card.html", {"task": updated}
- )
-
-
-@router.patch("/api/tasks/{task_id}/modify", response_class=JSONResponse)
-async def api_modify_task(task_id: str, request: Request):
- """Modify a task's title, description, assignment, or priority."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- if task.status in (TaskStatus.COMPLETED, TaskStatus.VETOED):
- raise HTTPException(400, f"Cannot modify task in {task.status.value} state")
-
- body = await request.json()
- updated = update_task(
- task_id,
- title=body.get("title"),
- description=body.get("description"),
- assigned_to=body.get("assigned_to"),
- priority=body.get("priority"),
- )
- _broadcast_task_event("task_modified", updated)
- return {"success": True, "task": _task_to_dict(updated)}
-
-
-@router.post("/tasks/{task_id}/modify", response_class=HTMLResponse)
-async def htmx_modify_task(
- request: Request,
- task_id: str,
- title: str = Form(None),
- description: str = Form(None),
- assigned_to: str = Form(None),
- priority: str = Form(None),
-):
- """Modify a task (HTMX form)."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- updated = update_task(
- task_id,
- title=title,
- description=description,
- assigned_to=assigned_to,
- priority=priority,
- )
- _broadcast_task_event("task_modified", updated)
- return templates.TemplateResponse(
- request, "partials/task_card.html", {"task": updated}
- )
-
-
-@router.patch("/api/tasks/{task_id}/pause", response_class=JSONResponse)
-async def api_pause_task(task_id: str):
- """Pause a running task."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- if task.status != TaskStatus.RUNNING:
- raise HTTPException(400, "Can only pause running tasks")
- updated = update_task_status(task_id, TaskStatus.PAUSED)
- _broadcast_task_event("task_paused", updated)
- return {"success": True, "task": _task_to_dict(updated)}
-
-
-@router.post("/tasks/{task_id}/pause", response_class=HTMLResponse)
-async def htmx_pause_task(request: Request, task_id: str):
- """Pause a running task (HTMX)."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- updated = update_task_status(task_id, TaskStatus.PAUSED)
- _broadcast_task_event("task_paused", updated)
- return templates.TemplateResponse(
- request, "partials/task_card.html", {"task": updated}
- )
-
-
-@router.patch("/api/tasks/{task_id}/cancel", response_class=JSONResponse)
-async def api_cancel_task(task_id: str):
- """Cancel a task (sets to failed)."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- if task.status in (TaskStatus.COMPLETED, TaskStatus.VETOED):
- raise HTTPException(400, f"Cannot cancel task in {task.status.value} state")
- updated = update_task_status(task_id, TaskStatus.FAILED, result="Cancelled by user")
- _broadcast_task_event("task_cancelled", updated)
- return {"success": True, "task": _task_to_dict(updated)}
-
-
-@router.post("/tasks/{task_id}/cancel", response_class=HTMLResponse)
-async def htmx_cancel_task(request: Request, task_id: str):
- """Cancel a task (HTMX)."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- updated = update_task_status(task_id, TaskStatus.FAILED, result="Cancelled by user")
- _broadcast_task_event("task_cancelled", updated)
- return templates.TemplateResponse(
- request, "partials/task_card.html", {"task": updated}
- )
-
-
-@router.patch("/api/tasks/{task_id}/retry", response_class=JSONResponse)
-async def api_retry_task(task_id: str):
- """Retry a failed task (resets to approved)."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- if task.status != TaskStatus.FAILED:
- raise HTTPException(400, "Can only retry failed tasks")
- updated = update_task_status(task_id, TaskStatus.APPROVED, result=None)
- _broadcast_task_event("task_retried", updated)
- return {"success": True, "task": _task_to_dict(updated)}
-
-
-@router.post("/tasks/{task_id}/retry", response_class=HTMLResponse)
-async def htmx_retry_task(request: Request, task_id: str):
- """Retry a failed task (HTMX)."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- updated = update_task_status(task_id, TaskStatus.APPROVED, result=None)
- _broadcast_task_event("task_retried", updated)
- return templates.TemplateResponse(
- request, "partials/task_card.html", {"task": updated}
- )
-
-
-@router.patch("/api/tasks/{task_id}/unbacklog", response_class=JSONResponse)
-async def api_unbacklog_task(task_id: str):
- """Move a backlogged task back to approved for re-processing."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- if task.status != TaskStatus.BACKLOGGED:
- raise HTTPException(400, "Can only unbacklog backlogged tasks")
- updated = update_task_status(
- task_id, TaskStatus.APPROVED, result=None, backlog_reason=None
- )
- _broadcast_task_event("task_unbacklogged", updated)
- return {"success": True, "task": _task_to_dict(updated)}
-
-
-@router.post("/tasks/{task_id}/unbacklog", response_class=HTMLResponse)
-async def htmx_unbacklog_task(request: Request, task_id: str):
- """Move a backlogged task back to approved (HTMX)."""
- task = get_task(task_id)
- if not task:
- raise HTTPException(404, "Task not found")
- updated = update_task_status(
- task_id, TaskStatus.APPROVED, result=None, backlog_reason=None
- )
- _broadcast_task_event("task_unbacklogged", updated)
- return templates.TemplateResponse(
- request, "partials/task_card.html", {"task": updated}
- )
-
-
-# ── Queue Status API ─────────────────────────────────────────────────────
-
-
-@router.get("/api/queue/status", response_class=JSONResponse)
-async def api_queue_status(assigned_to: str = "timmy"):
- """Get queue status for an agent - position info for polling."""
- from swarm.task_queue.models import (
- get_current_task_for_agent,
- get_queue_position_ahead,
- get_next_pending_task,
- )
-
- current = get_current_task_for_agent(assigned_to)
- next_task = get_next_pending_task(assigned_to)
- ahead = get_queue_position_ahead(assigned_to)
-
- return {
- "agent": assigned_to,
- "is_working": current is not None,
- "current_task": _task_to_dict(current) if current else None,
- "next_task": _task_to_dict(next_task) if next_task else None,
- "tasks_ahead": ahead,
- }
-
-
-@router.get("/api/queue/position/{task_id}", response_class=JSONResponse)
-async def api_queue_position(task_id: str):
- """Get queue position for a specific task."""
- from swarm.task_queue.models import get_queue_status_for_task
-
- status = get_queue_status_for_task(task_id)
- if "error" in status:
- raise HTTPException(404, status["error"])
- return status
-
-
-@router.get("/api/queue/agent/{assigned_to}", response_class=JSONResponse)
-async def api_agent_queue(assigned_to: str, limit: int = 20):
- """Get all pending tasks for an agent."""
- from swarm.task_queue.models import list_tasks, TaskStatus
-
- tasks = list_tasks(
- assigned_to=assigned_to,
- status=None, # All statuses
- limit=limit,
- )
- # Filter to pending/running tasks
- pending = [
- t
- for t in tasks
- if t.status not in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.VETOED)
- ]
-
- return {
- "assigned_to": assigned_to,
- "tasks": [_task_to_dict(t) for t in pending],
- "count": len(pending),
- }
-
-
-# ── Helpers ──────────────────────────────────────────────────────────────
-
-
-def _task_to_dict(task: QueueTask) -> dict:
- d = {
- "id": task.id,
- "title": task.title,
- "description": task.description,
- "assigned_to": task.assigned_to,
- "created_by": task.created_by,
- "status": task.status.value,
- "priority": task.priority.value,
- "requires_approval": task.requires_approval,
- "auto_approve": task.auto_approve,
- "parent_task_id": task.parent_task_id,
- "result": task.result,
- "steps": task.steps,
- "created_at": task.created_at,
- "started_at": task.started_at,
- "completed_at": task.completed_at,
- "updated_at": task.updated_at,
- }
- if task.backlog_reason:
- d["backlog_reason"] = task.backlog_reason
- return d
-
-
-def _notify_task_created(task: QueueTask):
- try:
- from infrastructure.notifications.push import notifier
-
- notifier.notify(
- title="New Task",
- message=f"{task.created_by} created: {task.title}",
- category="task",
- native=task.priority in (TaskPriority.HIGH, TaskPriority.URGENT),
- )
- except Exception:
- pass
diff --git a/src/dashboard/routes/upgrades.py b/src/dashboard/routes/upgrades.py
deleted file mode 100644
index 4065c17e..00000000
--- a/src/dashboard/routes/upgrades.py
+++ /dev/null
@@ -1,99 +0,0 @@
-"""Self-Upgrade Queue dashboard routes."""
-
-from pathlib import Path
-
-from fastapi import APIRouter, Form, HTTPException, Request
-from fastapi.responses import HTMLResponse, JSONResponse
-from fastapi.templating import Jinja2Templates
-
-from self_coding.upgrades.models import list_upgrades, get_upgrade, UpgradeStatus, get_pending_count
-from self_coding.upgrades.queue import UpgradeQueue
-
-router = APIRouter(prefix="/self-modify", tags=["upgrades"])
-templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
-
-
-@router.get("/queue", response_class=HTMLResponse)
-async def upgrade_queue_page(request: Request):
- """Upgrade queue dashboard."""
- pending = list_upgrades(status=UpgradeStatus.PROPOSED)
- approved = list_upgrades(status=UpgradeStatus.APPROVED)
- history = list_upgrades(status=None)[:20] # All recent
-
- # Separate history by status
- applied = [u for u in history if u.status == UpgradeStatus.APPLIED][:10]
- rejected = [u for u in history if u.status == UpgradeStatus.REJECTED][:5]
- failed = [u for u in history if u.status == UpgradeStatus.FAILED][:5]
-
- return templates.TemplateResponse(
- request,
- "upgrade_queue.html",
- {
- "page_title": "Upgrade Queue",
- "pending": pending,
- "approved": approved,
- "applied": applied,
- "rejected": rejected,
- "failed": failed,
- "pending_count": len(pending),
- },
- )
-
-
-@router.post("/queue/{upgrade_id}/approve", response_class=JSONResponse)
-async def approve_upgrade_endpoint(upgrade_id: str):
- """Approve an upgrade proposal."""
- upgrade = UpgradeQueue.approve(upgrade_id)
-
- if not upgrade:
- raise HTTPException(404, "Upgrade not found or not in proposed state")
-
- return {"success": True, "upgrade_id": upgrade_id, "status": upgrade.status.value}
-
-
-@router.post("/queue/{upgrade_id}/reject", response_class=JSONResponse)
-async def reject_upgrade_endpoint(upgrade_id: str):
- """Reject an upgrade proposal."""
- upgrade = UpgradeQueue.reject(upgrade_id)
-
- if not upgrade:
- raise HTTPException(404, "Upgrade not found or not in proposed state")
-
- return {"success": True, "upgrade_id": upgrade_id, "status": upgrade.status.value}
-
-
-@router.post("/queue/{upgrade_id}/apply", response_class=JSONResponse)
-async def apply_upgrade_endpoint(upgrade_id: str):
- """Apply an approved upgrade."""
- success, message = UpgradeQueue.apply(upgrade_id)
-
- if not success:
- raise HTTPException(400, message)
-
- return {"success": True, "message": message}
-
-
-@router.get("/queue/{upgrade_id}/diff", response_class=HTMLResponse)
-async def view_diff(request: Request, upgrade_id: str):
- """View full diff for an upgrade."""
- upgrade = get_upgrade(upgrade_id)
-
- if not upgrade:
- raise HTTPException(404, "Upgrade not found")
-
- diff = UpgradeQueue.get_full_diff(upgrade_id)
-
- return templates.TemplateResponse(
- request,
- "upgrade_diff.html",
- {
- "upgrade": upgrade,
- "diff": diff,
- },
- )
-
-
-@router.get("/api/pending-count", response_class=JSONResponse)
-async def get_pending_upgrade_count():
- """Get count of pending upgrades (for nav badge)."""
- return {"count": get_pending_count()}
diff --git a/src/dashboard/routes/voice.py b/src/dashboard/routes/voice.py
index b43f6ce0..c36caa90 100644
--- a/src/dashboard/routes/voice.py
+++ b/src/dashboard/routes/voice.py
@@ -113,48 +113,13 @@ async def process_voice_input(
)
elif intent.name == "swarm":
- from swarm import registry
- agents = registry.list_agents()
- response_text = (
- f"Swarm status: {len(agents)} agents registered. "
- f"Use the dashboard for detailed task information."
- )
+ response_text = "Swarm module is not currently active."
elif intent.name == "voice":
response_text = "Voice settings acknowledged. TTS is available for spoken responses."
elif intent.name == "code":
- from config import settings as app_settings
- if not app_settings.self_modify_enabled:
- response_text = (
- "Self-modification is disabled. "
- "Set SELF_MODIFY_ENABLED=true to enable."
- )
- else:
- import asyncio
- from self_coding.self_modify.loop import SelfModifyLoop, ModifyRequest
-
- target_files = []
- if "target_file" in intent.entities:
- target_files = [intent.entities["target_file"]]
-
- loop = SelfModifyLoop()
- request = ModifyRequest(
- instruction=text,
- target_files=target_files,
- )
- result = await asyncio.to_thread(loop.run, request)
-
- if result.success:
- sha_short = result.commit_sha[:8] if result.commit_sha else "none"
- response_text = (
- f"Code modification complete. "
- f"Changed {len(result.files_changed)} file(s). "
- f"Tests passed. Committed as {sha_short} "
- f"on branch {result.branch_name}."
- )
- else:
- response_text = f"Code modification failed: {result.error}"
+ response_text = "Self-modification module is not currently active."
else:
# Default: chat with Timmy
diff --git a/src/hands/__init__.py b/src/hands/__init__.py
deleted file mode 100644
index 62da60ea..00000000
--- a/src/hands/__init__.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""Hands — DEPRECATED. Use brain task queue instead.
-
-This module is kept for backward compatibility during migration.
-All functionality has been moved to the distributed brain task queue.
-"""
-
-from typing import Any, Optional
-import logging
-
-logger = logging.getLogger(__name__)
-
-# Simple stub models for compatibility
-class HandConfig:
- """Deprecated - use brain task queue."""
- def __init__(self, *args, **kwargs):
- self.name = kwargs.get("name", "unknown")
- self.enabled = False
-
-class HandState:
- """Deprecated."""
- pass
-
-class HandExecution:
- """Deprecated."""
- pass
-
-class HandStatus:
- """Deprecated."""
- PENDING = "pending"
- RUNNING = "running"
- COMPLETED = "completed"
- FAILED = "failed"
-
-class HandOutcome:
- """Deprecated."""
- pass
-
-class TriggerType:
- """Deprecated."""
- CRON = "cron"
- EVENT = "event"
- MANUAL = "manual"
-
-class ApprovalGate:
- """Deprecated."""
- pass
-
-class ApprovalRequest:
- """Deprecated."""
- pass
-
-class ApprovalStatus:
- """Deprecated."""
- pass
-
-class ScheduleConfig:
- """Deprecated."""
- pass
-
-class OutputConfig:
- """Deprecated."""
- pass
-
-class ToolRequirement:
- """Deprecated."""
- pass
-
-
-class HandRegistry:
- """Deprecated - use brain task queue."""
-
- def __init__(self, hands_dir: Optional[str] = None, db_path: Optional[str] = None):
- self.hands_dir = hands_dir
- self.db_path = db_path
- logger.warning("HandRegistry is deprecated. Use brain.BrainClient instead.")
-
- def list_hands(self):
- return []
-
- def get_hand(self, name: str):
- raise NotImplementedError("Hands deprecated - use brain task queue")
-
- def get_state(self, name: str):
- return HandState()
-
- def get_scheduled_hands(self):
- return []
-
- async def load_all(self):
- pass
-
-
-class HandScheduler:
- """Deprecated - use brain worker."""
-
- def __init__(self, registry: Any):
- self.registry = registry
- logger.warning("HandScheduler is deprecated. Use brain.DistributedWorker instead.")
-
- async def start(self):
- pass
-
- async def stop(self):
- pass
-
- async def schedule_hand(self, hand: Any):
- pass
-
-
-class HandRunner:
- """Deprecated - use brain worker."""
-
- def __init__(self, registry: Any, llm_adapter: Any = None):
- self.registry = registry
- logger.warning("HandRunner is deprecated. Use brain.DistributedWorker instead.")
-
- async def run_hand(self, name: str, context: Any = None):
- raise NotImplementedError("Hands deprecated - use brain task queue")
-
-
-class HandNotFoundError(Exception):
- pass
-
-class HandValidationError(Exception):
- pass
-
-
-__all__ = [
- "HandConfig", "HandState", "HandExecution", "HandStatus", "HandOutcome",
- "TriggerType", "ApprovalGate", "ApprovalRequest", "ApprovalStatus",
- "ScheduleConfig", "OutputConfig", "ToolRequirement",
- "HandRegistry", "HandScheduler", "HandRunner",
- "HandNotFoundError", "HandValidationError",
-]
diff --git a/src/infrastructure/events/broadcaster.py b/src/infrastructure/events/broadcaster.py
index b7b0cba9..e4f4623c 100644
--- a/src/infrastructure/events/broadcaster.py
+++ b/src/infrastructure/events/broadcaster.py
@@ -9,7 +9,10 @@ import json
import logging
from typing import Optional
-from swarm.event_log import EventLogEntry
+try:
+ from swarm.event_log import EventLogEntry
+except ImportError:
+ EventLogEntry = None
logger = logging.getLogger(__name__)
diff --git a/src/infrastructure/openfang/tools.py b/src/infrastructure/openfang/tools.py
index 741b54f8..a51e8d86 100644
--- a/src/infrastructure/openfang/tools.py
+++ b/src/infrastructure/openfang/tools.py
@@ -18,7 +18,12 @@ import logging
from typing import Any
from infrastructure.openfang.client import OPENFANG_HANDS, openfang_client
-from mcp.schemas.base import create_tool_schema
+
+try:
+ from mcp.schemas.base import create_tool_schema
+except ImportError:
+ def create_tool_schema(**kwargs):
+ return kwargs
logger = logging.getLogger(__name__)
@@ -190,7 +195,11 @@ def register_openfang_tools() -> int:
Returns the number of tools registered.
"""
- from mcp.registry import tool_registry
+ try:
+ from mcp.registry import tool_registry
+ except ImportError:
+ logger.warning("MCP registry not available — skipping OpenFang tool registration")
+ return 0
count = 0
for hand_name in OPENFANG_HANDS:
diff --git a/src/lightning/CLAUDE.md b/src/lightning/CLAUDE.md
deleted file mode 100644
index b2bba170..00000000
--- a/src/lightning/CLAUDE.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# lightning/ — Module Guide
-
-**Security-sensitive.** Bitcoin Lightning payment gating (L402).
-Never hard-code secrets. Use `from config import settings` for all credentials.
-
-## Testing
-```bash
-pytest tests/lightning/ -q
-```
diff --git a/src/lightning/__init__.py b/src/lightning/__init__.py
deleted file mode 100644
index f33491e1..00000000
--- a/src/lightning/__init__.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""Lightning Network payment backend interface.
-
-This module provides a pluggable interface for Lightning Network operations,
-allowing seamless switching between mock (development) and real LND backends.
-
-Usage:
- from lightning import get_backend, Invoice
-
- backend = get_backend() # Uses LIGHTNING_BACKEND env var
- invoice = backend.create_invoice(amount_sats=100, memo="API access")
- paid = backend.check_payment(invoice.payment_hash)
-
-Configuration:
- LIGHTNING_BACKEND=mock # Default, for development
- LIGHTNING_BACKEND=lnd # Real LND via gRPC
-
- # LND-specific settings (when backend=lnd)
- LND_GRPC_HOST=localhost:10009
- LND_TLS_CERT_PATH=/path/to/tls.cert
- LND_MACAROON_PATH=/path/to/admin.macaroon
-"""
-
-from lightning.base import Invoice, LightningBackend
-from lightning.factory import get_backend
-
-__all__ = ["Invoice", "LightningBackend", "get_backend"]
diff --git a/src/lightning/base.py b/src/lightning/base.py
deleted file mode 100644
index 9abd1085..00000000
--- a/src/lightning/base.py
+++ /dev/null
@@ -1,188 +0,0 @@
-"""Abstract base class for Lightning Network backends.
-
-Defines the contract that all Lightning implementations must fulfill.
-This abstraction allows the rest of the system to work identically
-whether using mock invoices or real LND gRPC calls.
-"""
-
-from abc import ABC, abstractmethod
-from dataclasses import dataclass, field
-from typing import Optional
-import time
-
-
-@dataclass
-class Invoice:
- """Lightning invoice data structure.
-
- This is backend-agnostic — the same structure is used for
- mock invoices and real LND invoices.
- """
- payment_hash: str
- payment_request: str # bolt11 invoice string
- amount_sats: int
- memo: str = ""
- created_at: float = field(default_factory=time.time)
- settled: bool = False
- settled_at: Optional[float] = None
- preimage: Optional[str] = None
-
- @property
- def is_expired(self, expiry_seconds: int = 3600) -> bool:
- """Check if invoice has expired (default 1 hour)."""
- return time.time() > self.created_at + expiry_seconds
-
-
-@dataclass
-class PaymentReceipt:
- """Proof of payment for a settled invoice."""
- payment_hash: str
- preimage: str
- amount_sats: int
- settled_at: float
-
-
-class LightningBackend(ABC):
- """Abstract interface for Lightning Network operations.
-
- Implementations:
- - MockBackend: In-memory invoices for development/testing
- - LndBackend: Real LND node via gRPC
- - ClnBackend: Core Lightning via Unix socket (future)
-
- All methods are synchronous. Async wrappers can be added at
- the application layer if needed.
- """
-
- name: str = "abstract"
-
- @abstractmethod
- def create_invoice(
- self,
- amount_sats: int,
- memo: str = "",
- expiry_seconds: int = 3600
- ) -> Invoice:
- """Create a new Lightning invoice.
-
- Args:
- amount_sats: Amount in satoshis
- memo: Description shown in wallet
- expiry_seconds: How long until invoice expires
-
- Returns:
- Invoice object with payment_request (bolt11 string)
-
- Raises:
- LightningError: If invoice creation fails
- """
- pass
-
- @abstractmethod
- def check_payment(self, payment_hash: str) -> bool:
- """Check whether an invoice has been paid.
-
- Args:
- payment_hash: The invoice to check
-
- Returns:
- True if paid/settled, False otherwise
-
- Note:
- In mock mode this may auto-settle. In production this
- queries the Lightning node for the invoice state.
- """
- pass
-
- @abstractmethod
- def get_invoice(self, payment_hash: str) -> Optional[Invoice]:
- """Get full invoice details by payment hash.
-
- Args:
- payment_hash: The invoice to retrieve
-
- Returns:
- Invoice object or None if not found
- """
- pass
-
- @abstractmethod
- def settle_invoice(self, payment_hash: str, preimage: str) -> bool:
- """Manually settle an invoice with a preimage.
-
- This is primarily used for testing or when receiving
- payments through a separate channel.
-
- Args:
- payment_hash: The invoice to settle
- preimage: The payment preimage (proof of payment)
-
- Returns:
- True if settlement succeeded
-
- Raises:
- ValueError: If preimage doesn't match payment_hash
- """
- pass
-
- @abstractmethod
- def list_invoices(
- self,
- settled_only: bool = False,
- limit: int = 100
- ) -> list[Invoice]:
- """List recent invoices.
-
- Args:
- settled_only: Only return paid invoices
- limit: Maximum number to return (newest first)
-
- Returns:
- List of Invoice objects
- """
- pass
-
- @abstractmethod
- def get_balance_sats(self) -> int:
- """Get the node's available balance in satoshis.
-
- Returns:
- Spendable on-chain + off-chain balance
-
- Note:
- Mock backends may return a fake value.
- """
- pass
-
- @abstractmethod
- def health_check(self) -> dict:
- """Check backend health and connectivity.
-
- Returns:
- Dict with:
- - ok: bool
- - error: str or None
- - block_height: int (if available)
- - synced: bool (if available)
- """
- pass
-
-
-class LightningError(Exception):
- """Base exception for Lightning backend errors."""
- pass
-
-
-class InvoiceNotFoundError(LightningError):
- """Raised when an invoice doesn't exist."""
- pass
-
-
-class PaymentFailedError(LightningError):
- """Raised when a payment operation fails."""
- pass
-
-
-class BackendNotAvailableError(LightningError):
- """Raised when the Lightning node is unreachable."""
- pass
diff --git a/src/lightning/factory.py b/src/lightning/factory.py
deleted file mode 100644
index f0e2eb7e..00000000
--- a/src/lightning/factory.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""Lightning backend factory — creates appropriate backend based on config.
-
-Usage:
- from lightning import get_backend
-
- backend = get_backend() # Reads LIGHTNING_BACKEND env var
- # or
- backend = get_backend("lnd") # Force specific backend
-"""
-
-import logging
-import os
-from typing import Optional
-
-from config import settings
-from lightning.base import LightningBackend
-
-logger = logging.getLogger(__name__)
-
-# Registry of available backends
-_BACKENDS: dict[str, type[LightningBackend]] = {}
-
-
-def _register_backends():
- """Register available backends (lazy import to avoid dependencies)."""
- global _BACKENDS
-
- if _BACKENDS:
- return
-
- # Always register mock backend
- from lightning.mock_backend import MockBackend
- _BACKENDS["mock"] = MockBackend
-
- # Register LND backend if grpc available
- try:
- from lightning.lnd_backend import LndBackend
- _BACKENDS["lnd"] = LndBackend
- logger.debug("LND backend registered (grpc available)")
- except ImportError as e:
- logger.debug("LND backend not available: %s", e)
-
- # Future: Add Core Lightning (CLN) backend here
- # try:
- # from lightning.cln_backend import ClnBackend
- # _BACKENDS["cln"] = ClnBackend
- # except ImportError:
- # pass
-
-
-def get_backend(name: Optional[str] = None) -> LightningBackend:
- """Get a Lightning backend instance.
-
- Args:
- name: Backend type ('mock', 'lnd').
- Defaults to LIGHTNING_BACKEND env var or 'mock'.
-
- Returns:
- Configured LightningBackend instance
-
- Raises:
- ValueError: If backend type is unknown
- LightningError: If backend initialization fails
-
- Examples:
- >>> backend = get_backend() # Use env var or default
- >>> backend = get_backend("mock") # Force mock
- >>> backend = get_backend("lnd") # Use real LND
- """
- _register_backends()
-
- backend_name = (name or settings.lightning_backend).lower()
-
- if backend_name not in _BACKENDS:
- available = ", ".join(_BACKENDS.keys())
- raise ValueError(
- f"Unknown Lightning backend: {backend_name!r}. "
- f"Available: {available}"
- )
-
- backend_class = _BACKENDS[backend_name]
- instance = backend_class()
-
- logger.info("Lightning backend ready: %s", backend_name)
- return instance
-
-
-def list_backends() -> list[str]:
- """List available backend types.
-
- Returns:
- List of backend names that can be passed to get_backend()
- """
- _register_backends()
- return list(_BACKENDS.keys())
-
-
-def get_backend_info() -> dict:
- """Get information about the current backend configuration.
-
- Returns:
- Dict with backend info for health/status endpoints
- """
- backend_name = settings.lightning_backend
-
- return {
- "configured_backend": backend_name,
- "available_backends": list_backends(),
- "env_vars": {
- "LIGHTNING_BACKEND": backend_name,
- "LND_GRPC_HOST": os.environ.get("LND_GRPC_HOST", "not set"),
- "LND_TLS_CERT_PATH": "set" if os.environ.get("LND_TLS_CERT_PATH") else "not set",
- "LND_MACAROON_PATH": "set" if os.environ.get("LND_MACAROON_PATH") else "not set",
- }
- }
diff --git a/src/lightning/ledger.py b/src/lightning/ledger.py
deleted file mode 100644
index 6e9763e8..00000000
--- a/src/lightning/ledger.py
+++ /dev/null
@@ -1,488 +0,0 @@
-"""Lightning Network transaction ledger.
-
-Tracks all Lightning payments in SQLite for audit, accounting, and dashboard display.
-"""
-
-import sqlite3
-import uuid
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from enum import Enum
-from pathlib import Path
-from typing import Optional
-
-DB_PATH = Path("data/swarm.db")
-
-
-class TransactionType(str, Enum):
- """Types of Lightning transactions."""
- INCOMING = "incoming" # Invoice created (we're receiving)
- OUTGOING = "outgoing" # Payment sent (we're paying)
-
-
-class TransactionStatus(str, Enum):
- """Status of a transaction."""
- PENDING = "pending"
- SETTLED = "settled"
- FAILED = "failed"
- EXPIRED = "expired"
-
-
-@dataclass
-class LedgerEntry:
- """A Lightning transaction record."""
- id: str = field(default_factory=lambda: str(uuid.uuid4()))
- tx_type: TransactionType = TransactionType.INCOMING
- status: TransactionStatus = TransactionStatus.PENDING
- payment_hash: str = "" # Lightning payment hash
- amount_sats: int = 0
- memo: str = "" # Description/purpose
- invoice: Optional[str] = None # BOLT11 invoice string
- preimage: Optional[str] = None # Payment preimage (proof of payment)
- source: str = "" # Component that created the transaction
- task_id: Optional[str] = None # Associated task, if any
- agent_id: Optional[str] = None # Associated agent, if any
- created_at: str = field(
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
- )
- settled_at: Optional[str] = None
- fee_sats: int = 0 # Routing fee paid
-
-
-def _get_conn() -> sqlite3.Connection:
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(str(DB_PATH))
- conn.row_factory = sqlite3.Row
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS ledger (
- id TEXT PRIMARY KEY,
- tx_type TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending',
- payment_hash TEXT UNIQUE NOT NULL,
- amount_sats INTEGER NOT NULL,
- memo TEXT,
- invoice TEXT,
- preimage TEXT,
- source TEXT NOT NULL,
- task_id TEXT,
- agent_id TEXT,
- created_at TEXT NOT NULL,
- settled_at TEXT,
- fee_sats INTEGER DEFAULT 0
- )
- """
- )
- # Create indexes for common queries
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_ledger_status ON ledger(status)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_ledger_hash ON ledger(payment_hash)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_ledger_task ON ledger(task_id)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_ledger_agent ON ledger(agent_id)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_ledger_created ON ledger(created_at)"
- )
- conn.commit()
- return conn
-
-
-def create_invoice_entry(
- payment_hash: str,
- amount_sats: int,
- memo: str = "",
- invoice: Optional[str] = None,
- source: str = "system",
- task_id: Optional[str] = None,
- agent_id: Optional[str] = None,
-) -> LedgerEntry:
- """Record a new incoming invoice (we're receiving payment).
-
- Args:
- payment_hash: Lightning payment hash
- amount_sats: Invoice amount in satoshis
- memo: Payment description
- invoice: Full BOLT11 invoice string
- source: Component that created the invoice
- task_id: Associated task ID
- agent_id: Associated agent ID
-
- Returns:
- The created LedgerEntry
- """
- entry = LedgerEntry(
- tx_type=TransactionType.INCOMING,
- status=TransactionStatus.PENDING,
- payment_hash=payment_hash,
- amount_sats=amount_sats,
- memo=memo,
- invoice=invoice,
- source=source,
- task_id=task_id,
- agent_id=agent_id,
- )
-
- conn = _get_conn()
- conn.execute(
- """
- INSERT INTO ledger (id, tx_type, status, payment_hash, amount_sats,
- memo, invoice, source, task_id, agent_id, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- entry.id,
- entry.tx_type.value,
- entry.status.value,
- entry.payment_hash,
- entry.amount_sats,
- entry.memo,
- entry.invoice,
- entry.source,
- entry.task_id,
- entry.agent_id,
- entry.created_at,
- ),
- )
- conn.commit()
- conn.close()
- return entry
-
-
-def record_outgoing_payment(
- payment_hash: str,
- amount_sats: int,
- memo: str = "",
- invoice: Optional[str] = None,
- source: str = "system",
- task_id: Optional[str] = None,
- agent_id: Optional[str] = None,
-) -> LedgerEntry:
- """Record an outgoing payment (we're paying someone).
-
- Args:
- payment_hash: Lightning payment hash
- amount_sats: Payment amount in satoshis
- memo: Payment description
- invoice: BOLT11 invoice we paid
- source: Component that initiated payment
- task_id: Associated task ID
- agent_id: Associated agent ID
-
- Returns:
- The created LedgerEntry
- """
- entry = LedgerEntry(
- tx_type=TransactionType.OUTGOING,
- status=TransactionStatus.PENDING,
- payment_hash=payment_hash,
- amount_sats=amount_sats,
- memo=memo,
- invoice=invoice,
- source=source,
- task_id=task_id,
- agent_id=agent_id,
- )
-
- conn = _get_conn()
- conn.execute(
- """
- INSERT INTO ledger (id, tx_type, status, payment_hash, amount_sats,
- memo, invoice, source, task_id, agent_id, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- entry.id,
- entry.tx_type.value,
- entry.status.value,
- entry.payment_hash,
- entry.amount_sats,
- entry.memo,
- entry.invoice,
- entry.source,
- entry.task_id,
- entry.agent_id,
- entry.created_at,
- ),
- )
- conn.commit()
- conn.close()
- return entry
-
-
-def mark_settled(
- payment_hash: str,
- preimage: Optional[str] = None,
- fee_sats: int = 0,
-) -> Optional[LedgerEntry]:
- """Mark a transaction as settled (payment received or sent successfully).
-
- Args:
- payment_hash: Lightning payment hash
- preimage: Payment preimage (proof of payment)
- fee_sats: Routing fee paid (for outgoing payments)
-
- Returns:
- Updated LedgerEntry or None if not found
- """
- settled_at = datetime.now(timezone.utc).isoformat()
-
- conn = _get_conn()
- cursor = conn.execute(
- """
- UPDATE ledger
- SET status = ?, preimage = ?, settled_at = ?, fee_sats = ?
- WHERE payment_hash = ?
- """,
- (TransactionStatus.SETTLED.value, preimage, settled_at, fee_sats, payment_hash),
- )
- conn.commit()
-
- if cursor.rowcount == 0:
- conn.close()
- return None
-
- # Fetch and return updated entry
- entry = get_by_hash(payment_hash)
- conn.close()
- return entry
-
-
-def mark_failed(payment_hash: str, reason: str = "") -> Optional[LedgerEntry]:
- """Mark a transaction as failed.
-
- Args:
- payment_hash: Lightning payment hash
- reason: Failure reason (stored in memo)
-
- Returns:
- Updated LedgerEntry or None if not found
- """
- conn = _get_conn()
- cursor = conn.execute(
- """
- UPDATE ledger
- SET status = ?, memo = memo || ' [FAILED: ' || ? || ']'
- WHERE payment_hash = ?
- """,
- (TransactionStatus.FAILED.value, reason, payment_hash),
- )
- conn.commit()
-
- if cursor.rowcount == 0:
- conn.close()
- return None
-
- entry = get_by_hash(payment_hash)
- conn.close()
- return entry
-
-
-def get_by_hash(payment_hash: str) -> Optional[LedgerEntry]:
- """Get a transaction by payment hash."""
- conn = _get_conn()
- row = conn.execute(
- "SELECT * FROM ledger WHERE payment_hash = ?", (payment_hash,)
- ).fetchone()
- conn.close()
-
- if row is None:
- return None
-
- return LedgerEntry(
- id=row["id"],
- tx_type=TransactionType(row["tx_type"]),
- status=TransactionStatus(row["status"]),
- payment_hash=row["payment_hash"],
- amount_sats=row["amount_sats"],
- memo=row["memo"],
- invoice=row["invoice"],
- preimage=row["preimage"],
- source=row["source"],
- task_id=row["task_id"],
- agent_id=row["agent_id"],
- created_at=row["created_at"],
- settled_at=row["settled_at"],
- fee_sats=row["fee_sats"],
- )
-
-
-def list_transactions(
- tx_type: Optional[TransactionType] = None,
- status: Optional[TransactionStatus] = None,
- task_id: Optional[str] = None,
- agent_id: Optional[str] = None,
- limit: int = 100,
- offset: int = 0,
-) -> list[LedgerEntry]:
- """List transactions with optional filtering.
-
- Returns:
- List of LedgerEntry objects, newest first
- """
- conn = _get_conn()
-
- conditions = []
- params = []
-
- if tx_type:
- conditions.append("tx_type = ?")
- params.append(tx_type.value)
- if status:
- conditions.append("status = ?")
- params.append(status.value)
- if task_id:
- conditions.append("task_id = ?")
- params.append(task_id)
- if agent_id:
- conditions.append("agent_id = ?")
- params.append(agent_id)
-
- where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
-
- query = f"""
- SELECT * FROM ledger
- {where_clause}
- ORDER BY created_at DESC
- LIMIT ? OFFSET ?
- """
- params.extend([limit, offset])
-
- rows = conn.execute(query, params).fetchall()
- conn.close()
-
- return [
- LedgerEntry(
- id=r["id"],
- tx_type=TransactionType(r["tx_type"]),
- status=TransactionStatus(r["status"]),
- payment_hash=r["payment_hash"],
- amount_sats=r["amount_sats"],
- memo=r["memo"],
- invoice=r["invoice"],
- preimage=r["preimage"],
- source=r["source"],
- task_id=r["task_id"],
- agent_id=r["agent_id"],
- created_at=r["created_at"],
- settled_at=r["settled_at"],
- fee_sats=r["fee_sats"],
- )
- for r in rows
- ]
-
-
-def get_balance() -> dict:
- """Get current balance summary.
-
- Returns:
- Dict with incoming, outgoing, pending, and available balances
- """
- conn = _get_conn()
-
- # Incoming (invoices we created that are settled)
- incoming = conn.execute(
- """
- SELECT COALESCE(SUM(amount_sats), 0) as total
- FROM ledger
- WHERE tx_type = ? AND status = ?
- """,
- (TransactionType.INCOMING.value, TransactionStatus.SETTLED.value),
- ).fetchone()["total"]
-
- # Outgoing (payments we sent that are settled)
- outgoing_result = conn.execute(
- """
- SELECT COALESCE(SUM(amount_sats), 0) as total,
- COALESCE(SUM(fee_sats), 0) as fees
- FROM ledger
- WHERE tx_type = ? AND status = ?
- """,
- (TransactionType.OUTGOING.value, TransactionStatus.SETTLED.value),
- ).fetchone()
- outgoing = outgoing_result["total"]
- fees = outgoing_result["fees"]
-
- # Pending incoming
- pending_incoming = conn.execute(
- """
- SELECT COALESCE(SUM(amount_sats), 0) as total
- FROM ledger
- WHERE tx_type = ? AND status = ?
- """,
- (TransactionType.INCOMING.value, TransactionStatus.PENDING.value),
- ).fetchone()["total"]
-
- # Pending outgoing
- pending_outgoing = conn.execute(
- """
- SELECT COALESCE(SUM(amount_sats), 0) as total
- FROM ledger
- WHERE tx_type = ? AND status = ?
- """,
- (TransactionType.OUTGOING.value, TransactionStatus.PENDING.value),
- ).fetchone()["total"]
-
- conn.close()
-
- return {
- "incoming_total_sats": incoming,
- "outgoing_total_sats": outgoing,
- "fees_paid_sats": fees,
- "net_sats": incoming - outgoing - fees,
- "pending_incoming_sats": pending_incoming,
- "pending_outgoing_sats": pending_outgoing,
- "available_sats": incoming - outgoing - fees - pending_outgoing,
- }
-
-
-def get_transaction_stats(days: int = 30) -> dict:
- """Get transaction statistics for the last N days.
-
- Returns:
- Dict with daily transaction counts and volumes
- """
- conn = _get_conn()
-
- from datetime import timedelta
- cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
-
- rows = conn.execute(
- """
- SELECT
- date(created_at) as date,
- tx_type,
- status,
- COUNT(*) as count,
- SUM(amount_sats) as volume
- FROM ledger
- WHERE created_at > ?
- GROUP BY date(created_at), tx_type, status
- ORDER BY date DESC
- """,
- (cutoff,),
- ).fetchall()
-
- conn.close()
-
- stats = {}
- for r in rows:
- date = r["date"]
- if date not in stats:
- stats[date] = {"incoming": {"count": 0, "volume": 0},
- "outgoing": {"count": 0, "volume": 0}}
-
- tx_type = r["tx_type"]
- if tx_type == TransactionType.INCOMING.value:
- stats[date]["incoming"]["count"] += r["count"]
- stats[date]["incoming"]["volume"] += r["volume"]
- else:
- stats[date]["outgoing"]["count"] += r["count"]
- stats[date]["outgoing"]["volume"] += r["volume"]
-
- return stats
diff --git a/src/lightning/lnd_backend.py b/src/lightning/lnd_backend.py
deleted file mode 100644
index 66e2a39e..00000000
--- a/src/lightning/lnd_backend.py
+++ /dev/null
@@ -1,370 +0,0 @@
-"""LND Lightning backend — real Bitcoin payments via gRPC.
-
-Connects to a local LND instance for production use.
-Handles invoice creation, payment verification, and node health.
-
-Requirements:
- pip install grpcio protobuf
-
-LND Setup:
- 1. Run LND with --tlsextradomain if accessing remotely
- 2. Copy tls.cert and admin.macaroon to accessible paths
- 3. Set environment variables (see below)
-
-Environment:
- LIGHTNING_BACKEND=lnd
- LND_GRPC_HOST=localhost:10009
- LND_TLS_CERT_PATH=/path/to/tls.cert
- LND_MACAROON_PATH=/path/to/admin.macaroon
- LND_VERIFY_SSL=true # Set to false only for development
-
-Example LND gRPC calls:
- AddInvoice - Create new invoice
- LookupInvoice - Check payment status
- ListChannels - Get channel balances
- GetInfo - Node health and sync status
-"""
-
-import hashlib
-import logging
-import os
-import ssl
-import time
-from typing import Optional
-
-from lightning.base import (
- Invoice,
- LightningBackend,
- BackendNotAvailableError,
- InvoiceNotFoundError,
- LightningError,
-)
-
-logger = logging.getLogger(__name__)
-
-# Optional import — graceful degradation if grpc not installed
-try:
- import grpc
- GRPC_AVAILABLE = True
-except ImportError:
- GRPC_AVAILABLE = False
- logger.warning("grpcio not installed — LND backend unavailable")
-
-
-class LndBackend(LightningBackend):
- """Real Lightning backend via LND gRPC.
-
- This backend creates real invoices that require real sats to pay.
- Only use in production with proper LND setup.
-
- Connection is lazy — gRPC channel created on first use.
- """
-
- name = "lnd"
-
- def __init__(
- self,
- host: Optional[str] = None,
- tls_cert_path: Optional[str] = None,
- macaroon_path: Optional[str] = None,
- verify_ssl: Optional[bool] = None,
- ) -> None:
- """Initialize LND backend.
-
- Args:
- host: LND gRPC host:port (default: LND_GRPC_HOST env var)
- tls_cert_path: Path to tls.cert (default: LND_TLS_CERT_PATH env var)
- macaroon_path: Path to admin.macaroon (default: LND_MACAROON_PATH env var)
- verify_ssl: Verify TLS certificate (default: LND_VERIFY_SSL env var)
- """
- if not GRPC_AVAILABLE:
- raise LightningError(
- "grpcio not installed. Run: pip install grpcio protobuf"
- )
-
- self._host = host or os.environ.get("LND_GRPC_HOST", "localhost:10009")
- self._tls_cert_path = tls_cert_path or os.environ.get("LND_TLS_CERT_PATH")
- self._macaroon_path = macaroon_path or os.environ.get("LND_MACAROON_PATH")
- self._verify_ssl = verify_ssl
- if self._verify_ssl is None:
- self._verify_ssl = os.environ.get("LND_VERIFY_SSL", "true").lower() == "true"
-
- self._channel: Optional[grpc.Channel] = None
- self._stub: Optional[object] = None # lnrpc.LightningStub
-
- logger.info(
- "LndBackend initialized — host: %s, tls: %s, macaroon: %s",
- self._host,
- "configured" if self._tls_cert_path else "default",
- "configured" if self._macaroon_path else "missing",
- )
-
- # Warn if config looks incomplete
- if not self._macaroon_path or not os.path.exists(self._macaroon_path):
- logger.warning(
- "LND macaroon not found at %s — payments will fail",
- self._macaroon_path
- )
-
- def _get_stub(self):
- """Lazy initialization of gRPC stub."""
- if self._stub is not None:
- return self._stub
-
- # Build channel credentials
- if self._tls_cert_path and os.path.exists(self._tls_cert_path):
- with open(self._tls_cert_path, "rb") as f:
- tls_cert = f.read()
- credentials = grpc.ssl_channel_credentials(tls_cert)
- else:
- # Use system root certificates
- credentials = grpc.ssl_channel_credentials()
-
- # Build macaroon credentials
- call_credentials = None
- if self._macaroon_path and os.path.exists(self._macaroon_path):
- with open(self._macaroon_path, "rb") as f:
- macaroon = f.read().hex()
-
- def metadata_callback(context, callback):
- callback([("macaroon", macaroon)], None)
-
- call_credentials = grpc.metadata_call_credentials(metadata_callback)
-
- # Combine credentials
- if call_credentials:
- composite = grpc.composite_channel_credentials(
- credentials,
- call_credentials
- )
- else:
- composite = credentials
-
- # Create channel
- self._channel = grpc.secure_channel(self._host, composite)
-
- # Import and create stub
- try:
- # lnd/grpc imports would go here
- # from lnd import lightning_pb2, lightning_pb2_grpc
- # self._stub = lightning_pb2_grpc.LightningStub(self._channel)
-
- # For now, stub is None — real implementation needs LND protos
- logger.warning("LND gRPC stubs not yet implemented — using placeholder")
- self._stub = None
-
- except ImportError as e:
- raise BackendNotAvailableError(
- f"LND gRPC stubs not available: {e}. "
- "Generate from LND proto files or install lndgrpc package."
- )
-
- return self._stub
-
- def _check_stub(self):
- """Ensure stub is available or raise appropriate error."""
- stub = self._get_stub()
- if stub is None:
- raise BackendNotAvailableError(
- "LND gRPC not fully implemented. "
- "This is a stub — implement gRPC calls to use real LND."
- )
- return stub
-
- def create_invoice(
- self,
- amount_sats: int,
- memo: str = "",
- expiry_seconds: int = 3600
- ) -> Invoice:
- """Create a real Lightning invoice via LND."""
- stub = self._check_stub()
-
- try:
- # Real implementation:
- # request = lightning_pb2.Invoice(
- # value=amount_sats,
- # memo=memo,
- # expiry=expiry_seconds,
- # )
- # response = stub.AddInvoice(request)
- #
- # return Invoice(
- # payment_hash=response.r_hash.hex(),
- # payment_request=response.payment_request,
- # amount_sats=amount_sats,
- # memo=memo,
- # )
-
- raise NotImplementedError(
- "LND gRPC integration incomplete. "
- "Generate protobuf stubs from LND source and implement AddInvoice."
- )
-
- except grpc.RpcError as e:
- logger.error("LND AddInvoice failed: %s", e)
- raise LightningError(f"Invoice creation failed: {e.details()}") from e
-
- def check_payment(self, payment_hash: str) -> bool:
- """Check if invoice is paid via LND LookupInvoice."""
- stub = self._check_stub()
-
- try:
- # Real implementation:
- # request = lightning_pb2.PaymentHash(
- # r_hash=bytes.fromhex(payment_hash)
- # )
- # response = stub.LookupInvoice(request)
- # return response.state == lightning_pb2.Invoice.SETTLED
-
- raise NotImplementedError("LND LookupInvoice not implemented")
-
- except grpc.RpcError as e:
- if e.code() == grpc.StatusCode.NOT_FOUND:
- return False
- logger.error("LND LookupInvoice failed: %s", e)
- raise LightningError(f"Payment check failed: {e.details()}") from e
-
- def get_invoice(self, payment_hash: str) -> Optional[Invoice]:
- """Get invoice details from LND."""
- stub = self._check_stub()
-
- try:
- # request = lightning_pb2.PaymentHash(
- # r_hash=bytes.fromhex(payment_hash)
- # )
- # response = stub.LookupInvoice(request)
- #
- # return Invoice(
- # payment_hash=response.r_hash.hex(),
- # payment_request=response.payment_request,
- # amount_sats=response.value,
- # memo=response.memo,
- # created_at=response.creation_date,
- # settled=response.state == lightning_pb2.Invoice.SETTLED,
- # settled_at=response.settle_date if response.settled else None,
- # )
-
- raise NotImplementedError("LND LookupInvoice not implemented")
-
- except grpc.RpcError as e:
- if e.code() == grpc.StatusCode.NOT_FOUND:
- return None
- raise LightningError(f"Invoice lookup failed: {e.details()}") from e
-
- def settle_invoice(self, payment_hash: str, preimage: str) -> bool:
- """Manually settle is not typically supported by LND.
-
- LND handles settlement automatically when payment arrives.
- This method exists for interface compatibility but raises
- an error in production.
- """
- logger.warning(
- "Manual invoice settlement not supported by LND — "
- "invoices settle automatically when paid"
- )
- return False
-
- def list_invoices(
- self,
- settled_only: bool = False,
- limit: int = 100
- ) -> list[Invoice]:
- """List recent invoices from LND."""
- stub = self._check_stub()
-
- try:
- # request = lightning_pb2.ListInvoiceRequest(
- # num_max_invoices=limit,
- # reversed=True, # Newest first
- # )
- # response = stub.ListInvoices(request)
- #
- # invoices = []
- # for inv in response.invoices:
- # if settled_only and inv.state != lightning_pb2.Invoice.SETTLED:
- # continue
- # invoices.append(self._grpc_invoice_to_model(inv))
- # return invoices
-
- raise NotImplementedError("LND ListInvoices not implemented")
-
- except grpc.RpcError as e:
- raise LightningError(f"List invoices failed: {e.details()}") from e
-
- def get_balance_sats(self) -> int:
- """Get total balance from LND."""
- stub = self._check_stub()
-
- try:
- # response = stub.WalletBalance(request)
- # return response.total_balance
-
- # For now, return 0 to indicate "real value not available"
- logger.warning("LND WalletBalance not implemented — returning 0")
- return 0
-
- except grpc.RpcError as e:
- raise LightningError(f"Balance check failed: {e.details()}") from e
-
- def health_check(self) -> dict:
- """Check LND node health and sync status."""
- stub = self._check_stub()
-
- try:
- # response = stub.GetInfo(request)
- # return {
- # "ok": response.synced_to_chain and response.synced_to_graph,
- # "error": None,
- # "block_height": response.block_height,
- # "synced": response.synced_to_chain,
- # "backend": "lnd",
- # "version": response.version,
- # "alias": response.alias,
- # }
-
- # Return degraded status if stub not available
- return {
- "ok": False,
- "error": "LND gRPC not fully implemented — see documentation",
- "block_height": 0,
- "synced": False,
- "backend": "lnd-stub",
- }
-
- except grpc.RpcError as e:
- return {
- "ok": False,
- "error": str(e.details()),
- "block_height": 0,
- "synced": False,
- "backend": "lnd",
- }
-
-
-def generate_lnd_protos():
- """Documentation for generating LND protobuf stubs.
-
- To use real LND, you need to generate Python gRPC stubs from
- the LND proto files.
-
- Steps:
- 1. Clone LND repository:
- git clone https://github.com/lightningnetwork/lnd.git
-
- 2. Install protoc and grpc tools:
- pip install grpcio grpcio-tools
-
- 3. Generate Python stubs:
- python -m grpc_tools.protoc \
- --proto_path=lnd/lnrpc \
- --python_out=src/lightning/protos \
- --grpc_python_out=src/lightning/protos \
- lnd/lnrpc/lightning.proto
-
- 4. Import and use the generated stubs in LndBackend
-
- Alternative:
- Use the 'lndgrpc' or 'pylnd' packages from PyPI if available.
- """
- print(generate_lnd_protos.__doc__)
diff --git a/src/lightning/mock_backend.py b/src/lightning/mock_backend.py
deleted file mode 100644
index 98491518..00000000
--- a/src/lightning/mock_backend.py
+++ /dev/null
@@ -1,163 +0,0 @@
-"""Mock Lightning backend for development and testing.
-
-Provides in-memory invoice tracking without requiring a real
-Lightning node. Invoices auto-settle for easy testing.
-"""
-
-import hashlib
-import hmac
-import logging
-import os
-import secrets
-import time
-from typing import Optional
-
-from config import settings
-from lightning.base import Invoice, LightningBackend, LightningError
-
-logger = logging.getLogger(__name__)
-
-# Read secret from centralised config (validated at startup in config.py)
-_HMAC_SECRET = settings.l402_hmac_secret.encode()
-
-
-class MockBackend(LightningBackend):
- """In-memory Lightning backend for development.
-
- Creates fake invoices that auto-settle. No real sats are moved.
- Useful for:
- - Local development without LND setup
- - Integration tests
- - CI/CD pipelines
-
- Environment:
- LIGHTNING_BACKEND=mock
- L402_HMAC_SECRET=your-secret # Optional
- MOCK_AUTO_SETTLE=true # Auto-settle invoices (default: true)
- """
-
- name = "mock"
-
- def __init__(self) -> None:
- self._invoices: dict[str, Invoice] = {}
- self._auto_settle = os.environ.get("MOCK_AUTO_SETTLE", "true").lower() == "true"
- logger.info("MockBackend initialized — auto_settle: %s", self._auto_settle)
-
- def create_invoice(
- self,
- amount_sats: int,
- memo: str = "",
- expiry_seconds: int = 3600
- ) -> Invoice:
- """Create a mock invoice with fake bolt11 string."""
- preimage = secrets.token_hex(32)
- payment_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
-
- # Generate mock bolt11 — deterministic based on secret
- signature = hmac.new(
- _HMAC_SECRET,
- payment_hash.encode(),
- hashlib.sha256
- ).hexdigest()[:20]
-
- payment_request = f"lnbc{amount_sats}n1mock{signature}"
-
- invoice = Invoice(
- payment_hash=payment_hash,
- payment_request=payment_request,
- amount_sats=amount_sats,
- memo=memo,
- preimage=preimage,
- )
-
- self._invoices[payment_hash] = invoice
-
- logger.info(
- "Mock invoice: %d sats — %s (hash: %s…)",
- amount_sats, memo, payment_hash[:12]
- )
-
- if self._auto_settle:
- # Mark as settled immediately for seamless dev experience
- invoice.settled = True
- invoice.settled_at = time.time()
- logger.debug("Auto-settled invoice %s…", payment_hash[:12])
-
- return invoice
-
- def check_payment(self, payment_hash: str) -> bool:
- """Check invoice status — auto-settles in mock mode."""
- invoice = self._invoices.get(payment_hash)
- if invoice is None:
- return False
-
- if self._auto_settle and not invoice.settled:
- invoice.settled = True
- invoice.settled_at = time.time()
-
- return invoice.settled
-
- def get_invoice(self, payment_hash: str) -> Optional[Invoice]:
- """Retrieve invoice by payment hash."""
- invoice = self._invoices.get(payment_hash)
- if invoice:
- # Update settled status
- self.check_payment(payment_hash)
- return invoice
-
- def settle_invoice(self, payment_hash: str, preimage: str) -> bool:
- """Manually settle an invoice with preimage verification."""
- invoice = self._invoices.get(payment_hash)
- if invoice is None:
- raise LightningError(f"Invoice not found: {payment_hash}")
-
- # Verify preimage matches payment_hash
- expected_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
- if expected_hash != payment_hash:
- logger.warning(
- "Preimage mismatch for %s… — expected %s…, got %s…",
- payment_hash[:12],
- expected_hash[:12],
- hashlib.sha256(bytes.fromhex(preimage)).hexdigest()[:12]
- )
- return False
-
- invoice.settled = True
- invoice.settled_at = time.time()
- invoice.preimage = preimage
-
- logger.info("Settled invoice %s…", payment_hash[:12])
- return True
-
- def list_invoices(
- self,
- settled_only: bool = False,
- limit: int = 100
- ) -> list[Invoice]:
- """List recent invoices, newest first."""
- invoices = sorted(
- self._invoices.values(),
- key=lambda i: i.created_at,
- reverse=True
- )
-
- if settled_only:
- invoices = [i for i in invoices if i.settled]
-
- return invoices[:limit]
-
- def get_balance_sats(self) -> int:
- """Return fake balance for mock mode."""
- # Return a reasonable-looking number for UI testing
- return 1_000_000 # 1M sats
-
- def health_check(self) -> dict:
- """Always healthy in mock mode."""
- return {
- "ok": True,
- "error": None,
- "block_height": 800_000,
- "synced": True,
- "backend": "mock",
- "auto_settle": self._auto_settle,
- }
diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py
deleted file mode 100644
index 38d9eb5d..00000000
--- a/src/mcp/__init__.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""MCP (Model Context Protocol) package.
-
-Provides tool registry, server, schema management, and auto-discovery.
-"""
-
-from mcp.registry import tool_registry, register_tool, ToolRegistry
-from mcp.server import mcp_server, MCPServer, MCPHTTPServer
-from mcp.schemas.base import create_tool_schema
-from mcp.discovery import ToolDiscovery, mcp_tool, get_discovery
-from mcp.bootstrap import auto_bootstrap, get_bootstrap_status
-
-__all__ = [
- # Registry
- "tool_registry",
- "register_tool",
- "ToolRegistry",
- # Server
- "mcp_server",
- "MCPServer",
- "MCPHTTPServer",
- # Schemas
- "create_tool_schema",
- # Discovery
- "ToolDiscovery",
- "mcp_tool",
- "get_discovery",
- # Bootstrap
- "auto_bootstrap",
- "get_bootstrap_status",
-]
diff --git a/src/mcp/bootstrap.py b/src/mcp/bootstrap.py
deleted file mode 100644
index 7e21b32d..00000000
--- a/src/mcp/bootstrap.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""MCP Auto-Bootstrap — Auto-discover and register tools on startup.
-
-Usage:
- from mcp.bootstrap import auto_bootstrap
-
- # Auto-discover from 'tools' package
- registered = auto_bootstrap()
-
- # Or specify custom packages
- registered = auto_bootstrap(packages=["tools", "custom_tools"])
-"""
-
-import logging
-import os
-from pathlib import Path
-from typing import Optional
-
-from .discovery import ToolDiscovery, get_discovery
-from .registry import ToolRegistry, tool_registry
-
-logger = logging.getLogger(__name__)
-
-# Default packages to scan for tools
-DEFAULT_TOOL_PACKAGES = ["tools"]
-
-# Environment variable to disable auto-bootstrap
-AUTO_BOOTSTRAP_ENV_VAR = "MCP_AUTO_BOOTSTRAP"
-
-
-def auto_bootstrap(
- packages: Optional[list[str]] = None,
- registry: Optional[ToolRegistry] = None,
- force: bool = False,
-) -> list[str]:
- """Auto-discover and register MCP tools.
-
- Args:
- packages: Packages to scan (defaults to ["tools"])
- registry: Registry to register tools with (defaults to singleton)
- force: Force bootstrap even if disabled by env var
-
- Returns:
- List of registered tool names
- """
- # Check if auto-bootstrap is disabled
- if not force and os.environ.get(AUTO_BOOTSTRAP_ENV_VAR, "1") == "0":
- logger.info("MCP auto-bootstrap disabled via %s", AUTO_BOOTSTRAP_ENV_VAR)
- return []
-
- packages = packages or DEFAULT_TOOL_PACKAGES
- registry = registry or tool_registry
- discovery = get_discovery(registry=registry)
-
- registered: list[str] = []
-
- logger.info("Starting MCP auto-bootstrap from packages: %s", packages)
-
- for package in packages:
- try:
- # Check if package exists
- try:
- __import__(package)
- except ImportError:
- logger.debug("Package %s not found, skipping", package)
- continue
-
- # Discover and register
- tools = discovery.auto_register(package)
- registered.extend(tools)
-
- except Exception as exc:
- logger.warning("Failed to bootstrap from %s: %s", package, exc)
-
- logger.info("MCP auto-bootstrap complete: %d tools registered", len(registered))
- return registered
-
-
-def bootstrap_from_directory(
- directory: Path,
- registry: Optional[ToolRegistry] = None,
-) -> list[str]:
- """Bootstrap tools from a directory of Python files.
-
- Args:
- directory: Directory containing Python files with tools
- registry: Registry to register tools with
-
- Returns:
- List of registered tool names
- """
- registry = registry or tool_registry
- discovery = get_discovery(registry=registry)
-
- registered: list[str] = []
-
- if not directory.exists():
- logger.warning("Tools directory not found: %s", directory)
- return registered
-
- logger.info("Bootstrapping tools from directory: %s", directory)
-
- # Find all Python files
- for py_file in directory.rglob("*.py"):
- if py_file.name.startswith("_"):
- continue
-
- try:
- discovered = discovery.discover_file(py_file)
-
- for tool in discovered:
- if tool.function is None:
- # Need to import and resolve the function
- continue
-
- try:
- registry.register_tool(
- name=tool.name,
- function=tool.function,
- description=tool.description,
- category=tool.category,
- tags=tool.tags,
- )
- registered.append(tool.name)
- except Exception as exc:
- logger.error("Failed to register %s: %s", tool.name, exc)
-
- except Exception as exc:
- logger.warning("Failed to process %s: %s", py_file, exc)
-
- logger.info("Directory bootstrap complete: %d tools registered", len(registered))
- return registered
-
-
-def get_bootstrap_status() -> dict:
- """Get auto-bootstrap status.
-
- Returns:
- Dict with bootstrap status info
- """
- discovery = get_discovery()
- registry = tool_registry
-
- return {
- "auto_bootstrap_enabled": os.environ.get(AUTO_BOOTSTRAP_ENV_VAR, "1") != "0",
- "discovered_tools_count": len(discovery.get_discovered()),
- "registered_tools_count": len(registry.list_tools()),
- "default_packages": DEFAULT_TOOL_PACKAGES,
- }
diff --git a/src/mcp/discovery.py b/src/mcp/discovery.py
deleted file mode 100644
index e23e0d93..00000000
--- a/src/mcp/discovery.py
+++ /dev/null
@@ -1,441 +0,0 @@
-"""MCP Tool Auto-Discovery — Introspect Python modules to find tools.
-
-Automatically discovers functions marked with @mcp_tool decorator
-and registers them with the MCP registry. Generates JSON schemas
-from type hints.
-"""
-
-import ast
-import importlib
-import inspect
-import logging
-import pkgutil
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Any, Callable, Optional, get_type_hints
-
-from .registry import ToolRegistry, tool_registry
-
-logger = logging.getLogger(__name__)
-
-
-# Decorator to mark functions as MCP tools
-def mcp_tool(
- name: Optional[str] = None,
- description: Optional[str] = None,
- category: str = "general",
- tags: Optional[list[str]] = None,
-):
- """Decorator to mark a function as an MCP tool.
-
- Args:
- name: Tool name (defaults to function name)
- description: Tool description (defaults to docstring)
- category: Tool category for organization
- tags: Additional tags for filtering
-
- Example:
- @mcp_tool(name="weather", category="external")
- def get_weather(city: str) -> dict:
- '''Get weather for a city.'''
- ...
- """
- def decorator(func: Callable) -> Callable:
- func._mcp_tool = True
- func._mcp_name = name or func.__name__
- func._mcp_description = description or (func.__doc__ or "").strip()
- func._mcp_category = category
- func._mcp_tags = tags or []
- return func
- return decorator
-
-
-@dataclass
-class DiscoveredTool:
- """A tool discovered via introspection."""
- name: str
- description: str
- function: Callable
- module: str
- category: str
- tags: list[str]
- parameters_schema: dict[str, Any]
- returns_schema: dict[str, Any]
- source_file: Optional[str] = None
- line_number: int = 0
-
-
-class ToolDiscovery:
- """Discovers and registers MCP tools from Python modules.
-
- Usage:
- discovery = ToolDiscovery()
-
- # Discover from a module
- tools = discovery.discover_module("creative.tools.git")
-
- # Auto-register with registry
- discovery.auto_register("creative.tools")
-
- # Discover from all installed packages
- tools = discovery.discover_all_packages()
- """
-
- def __init__(self, registry: Optional[ToolRegistry] = None) -> None:
- self.registry = registry or tool_registry
- self._discovered: list[DiscoveredTool] = []
-
- def discover_module(self, module_name: str) -> list[DiscoveredTool]:
- """Discover all MCP tools in a module.
-
- Args:
- module_name: Dotted path to module (e.g., "creative.tools.git")
-
- Returns:
- List of discovered tools
- """
- discovered = []
-
- try:
- module = importlib.import_module(module_name)
- except ImportError as exc:
- logger.warning("Failed to import module %s: %s", module_name, exc)
- return discovered
-
- # Get module file path for source location
- module_file = getattr(module, "__file__", None)
-
- # Iterate through module members
- for name, obj in inspect.getmembers(module):
- # Skip private and non-callable
- if name.startswith("_") or not callable(obj):
- continue
-
- # Check if marked as MCP tool
- if not getattr(obj, "_mcp_tool", False):
- continue
-
- # Get source location
- try:
- source_file = inspect.getfile(obj)
- line_number = inspect.getsourcelines(obj)[1]
- except (OSError, TypeError):
- source_file = module_file
- line_number = 0
-
- # Build schemas from type hints
- try:
- sig = inspect.signature(obj)
- parameters_schema = self._build_parameters_schema(sig)
- returns_schema = self._build_returns_schema(sig, obj)
- except Exception as exc:
- logger.warning("Failed to build schema for %s: %s", name, exc)
- parameters_schema = {"type": "object", "properties": {}}
- returns_schema = {}
-
- tool = DiscoveredTool(
- name=getattr(obj, "_mcp_name", name),
- description=getattr(obj, "_mcp_description", obj.__doc__ or ""),
- function=obj,
- module=module_name,
- category=getattr(obj, "_mcp_category", "general"),
- tags=getattr(obj, "_mcp_tags", []),
- parameters_schema=parameters_schema,
- returns_schema=returns_schema,
- source_file=source_file,
- line_number=line_number,
- )
-
- discovered.append(tool)
- logger.debug("Discovered tool: %s from %s", tool.name, module_name)
-
- self._discovered.extend(discovered)
- logger.info("Discovered %d tools from module %s", len(discovered), module_name)
- return discovered
-
- def discover_package(self, package_name: str, recursive: bool = True) -> list[DiscoveredTool]:
- """Discover tools from all modules in a package.
-
- Args:
- package_name: Package name (e.g., "tools")
- recursive: Whether to search subpackages
-
- Returns:
- List of discovered tools
- """
- discovered = []
-
- try:
- package = importlib.import_module(package_name)
- except ImportError as exc:
- logger.warning("Failed to import package %s: %s", package_name, exc)
- return discovered
-
- package_path = getattr(package, "__path__", [])
- if not package_path:
- # Not a package, treat as module
- return self.discover_module(package_name)
-
- # Walk package modules
- for _, name, is_pkg in pkgutil.iter_modules(package_path, prefix=f"{package_name}."):
- if is_pkg and recursive:
- discovered.extend(self.discover_package(name, recursive=True))
- else:
- discovered.extend(self.discover_module(name))
-
- return discovered
-
- def discover_file(self, file_path: Path) -> list[DiscoveredTool]:
- """Discover tools from a Python file.
-
- Args:
- file_path: Path to Python file
-
- Returns:
- List of discovered tools
- """
- discovered = []
-
- try:
- source = file_path.read_text()
- tree = ast.parse(source)
- except Exception as exc:
- logger.warning("Failed to parse %s: %s", file_path, exc)
- return discovered
-
- # Find all decorated functions
- for node in ast.walk(tree):
- if not isinstance(node, ast.FunctionDef):
- continue
-
- # Check for @mcp_tool decorator
- is_tool = False
- tool_name = node.name
- tool_description = ast.get_docstring(node) or ""
- tool_category = "general"
- tool_tags: list[str] = []
-
- for decorator in node.decorator_list:
- if isinstance(decorator, ast.Call):
- if isinstance(decorator.func, ast.Name) and decorator.func.id == "mcp_tool":
- is_tool = True
- # Extract decorator arguments
- for kw in decorator.keywords:
- if kw.arg == "name" and isinstance(kw.value, ast.Constant):
- tool_name = kw.value.value
- elif kw.arg == "description" and isinstance(kw.value, ast.Constant):
- tool_description = kw.value.value
- elif kw.arg == "category" and isinstance(kw.value, ast.Constant):
- tool_category = kw.value.value
- elif kw.arg == "tags" and isinstance(kw.value, ast.List):
- tool_tags = [
- elt.value for elt in kw.value.elts
- if isinstance(elt, ast.Constant)
- ]
- elif isinstance(decorator, ast.Name) and decorator.id == "mcp_tool":
- is_tool = True
-
- if not is_tool:
- continue
-
- # Build parameter schema from AST
- parameters_schema = self._build_schema_from_ast(node)
-
- # We can't get the actual function without importing
- # So create a placeholder that will be resolved later
- tool = DiscoveredTool(
- name=tool_name,
- description=tool_description,
- function=None, # Will be resolved when registered
- module=str(file_path),
- category=tool_category,
- tags=tool_tags,
- parameters_schema=parameters_schema,
- returns_schema={"type": "object"},
- source_file=str(file_path),
- line_number=node.lineno,
- )
-
- discovered.append(tool)
-
- self._discovered.extend(discovered)
- logger.info("Discovered %d tools from file %s", len(discovered), file_path)
- return discovered
-
- def auto_register(self, package_name: str = "tools") -> list[str]:
- """Automatically discover and register tools.
-
- Args:
- package_name: Package to scan for tools
-
- Returns:
- List of registered tool names
- """
- discovered = self.discover_package(package_name)
- registered = []
-
- for tool in discovered:
- if tool.function is None:
- logger.warning("Skipping %s: no function resolved", tool.name)
- continue
-
- try:
- self.registry.register_tool(
- name=tool.name,
- function=tool.function,
- description=tool.description,
- category=tool.category,
- tags=tool.tags,
- )
- registered.append(tool.name)
- logger.debug("Registered tool: %s", tool.name)
- except Exception as exc:
- logger.error("Failed to register %s: %s", tool.name, exc)
-
- logger.info("Auto-registered %d/%d tools", len(registered), len(discovered))
- return registered
-
- def _build_parameters_schema(self, sig: inspect.Signature) -> dict[str, Any]:
- """Build JSON schema for function parameters."""
- properties = {}
- required = []
-
- for name, param in sig.parameters.items():
- if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
- continue
-
- schema = self._type_to_schema(param.annotation)
-
- if param.default is param.empty:
- required.append(name)
- else:
- schema["default"] = param.default
-
- properties[name] = schema
-
- return {
- "type": "object",
- "properties": properties,
- "required": required,
- }
-
- def _build_returns_schema(
- self, sig: inspect.Signature, func: Callable
- ) -> dict[str, Any]:
- """Build JSON schema for return type."""
- return_annotation = sig.return_annotation
-
- if return_annotation is sig.empty:
- return {"type": "object"}
-
- return self._type_to_schema(return_annotation)
-
- def _build_schema_from_ast(self, node: ast.FunctionDef) -> dict[str, Any]:
- """Build parameter schema from AST node."""
- properties = {}
- required = []
-
- # Get defaults (reversed, since they're at the end)
- defaults = [None] * (len(node.args.args) - len(node.args.defaults)) + list(node.args.defaults)
-
- for arg, default in zip(node.args.args, defaults):
- arg_name = arg.arg
- arg_type = "string" # Default
-
- # Try to get type from annotation
- if arg.annotation:
- if isinstance(arg.annotation, ast.Name):
- arg_type = self._ast_type_to_json_type(arg.annotation.id)
- elif isinstance(arg.annotation, ast.Constant):
- arg_type = self._ast_type_to_json_type(str(arg.annotation.value))
-
- schema = {"type": arg_type}
-
- if default is None:
- required.append(arg_name)
-
- properties[arg_name] = schema
-
- return {
- "type": "object",
- "properties": properties,
- "required": required,
- }
-
- def _type_to_schema(self, annotation: Any) -> dict[str, Any]:
- """Convert Python type annotation to JSON schema."""
- if annotation is inspect.Parameter.empty:
- return {"type": "string"}
-
- origin = getattr(annotation, "__origin__", None)
- args = getattr(annotation, "__args__", ())
-
- # Handle Optional[T] = Union[T, None]
- if origin is not None:
- if str(origin) == "typing.Union" and type(None) in args:
- # Optional type
- non_none_args = [a for a in args if a is not type(None)]
- if len(non_none_args) == 1:
- schema = self._type_to_schema(non_none_args[0])
- return schema
- return {"type": "object"}
-
- # Handle List[T], Dict[K,V]
- if origin in (list, tuple):
- items_schema = {"type": "object"}
- if args:
- items_schema = self._type_to_schema(args[0])
- return {"type": "array", "items": items_schema}
-
- if origin is dict:
- return {"type": "object"}
-
- # Handle basic types
- if annotation in (str,):
- return {"type": "string"}
- elif annotation in (int, float):
- return {"type": "number"}
- elif annotation in (bool,):
- return {"type": "boolean"}
- elif annotation in (list, tuple):
- return {"type": "array"}
- elif annotation in (dict,):
- return {"type": "object"}
-
- return {"type": "object"}
-
- def _ast_type_to_json_type(self, type_name: str) -> str:
- """Convert AST type name to JSON schema type."""
- type_map = {
- "str": "string",
- "int": "number",
- "float": "number",
- "bool": "boolean",
- "list": "array",
- "dict": "object",
- "List": "array",
- "Dict": "object",
- "Optional": "object",
- "Any": "object",
- }
- return type_map.get(type_name, "object")
-
- def get_discovered(self) -> list[DiscoveredTool]:
- """Get all discovered tools."""
- return list(self._discovered)
-
- def clear(self) -> None:
- """Clear discovered tools cache."""
- self._discovered.clear()
-
-
-# Module-level singleton
-discovery: Optional[ToolDiscovery] = None
-
-
-def get_discovery(registry: Optional[ToolRegistry] = None) -> ToolDiscovery:
- """Get or create the tool discovery singleton."""
- global discovery
- if discovery is None:
- discovery = ToolDiscovery(registry=registry)
- return discovery
diff --git a/src/mcp/registry.py b/src/mcp/registry.py
deleted file mode 100644
index 29d87172..00000000
--- a/src/mcp/registry.py
+++ /dev/null
@@ -1,444 +0,0 @@
-"""MCP Tool Registry — Dynamic tool discovery and management.
-
-The registry maintains a catalog of all available tools, their schemas,
-and health status. Tools can be registered dynamically at runtime.
-
-Usage:
- from mcp.registry import tool_registry
-
- # Register a tool
- tool_registry.register("web_search", web_search_schema, web_search_func)
-
- # Discover tools
- tools = tool_registry.discover(capabilities=["search"])
-
- # Execute a tool
- result = tool_registry.execute("web_search", {"query": "Bitcoin"})
-"""
-
-import asyncio
-import inspect
-import logging
-import time
-from dataclasses import dataclass, field
-from typing import Any, Callable, Optional
-
-from mcp.schemas.base import create_tool_schema
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class ToolRecord:
- """A registered tool with metadata."""
- name: str
- schema: dict
- handler: Callable
- category: str = "general"
- health_status: str = "unknown" # healthy, degraded, unhealthy
- last_execution: Optional[float] = None
- execution_count: int = 0
- error_count: int = 0
- avg_latency_ms: float = 0.0
- added_at: float = field(default_factory=time.time)
- requires_confirmation: bool = False
- tags: list[str] = field(default_factory=list)
- source_module: Optional[str] = None
- auto_discovered: bool = False
-
-
-class ToolRegistry:
- """Central registry for all MCP tools."""
-
- def __init__(self) -> None:
- self._tools: dict[str, ToolRecord] = {}
- self._categories: dict[str, list[str]] = {}
- logger.info("ToolRegistry initialized")
-
- def register(
- self,
- name: str,
- schema: dict,
- handler: Callable,
- category: str = "general",
- requires_confirmation: bool = False,
- tags: Optional[list[str]] = None,
- source_module: Optional[str] = None,
- auto_discovered: bool = False,
- ) -> ToolRecord:
- """Register a new tool.
-
- Args:
- name: Unique tool name
- schema: JSON schema describing inputs/outputs
- handler: Function to execute
- category: Tool category for organization
- requires_confirmation: If True, user must approve before execution
- tags: Tags for filtering and organization
- source_module: Module where tool was defined
- auto_discovered: Whether tool was auto-discovered
-
- Returns:
- The registered ToolRecord
- """
- if name in self._tools:
- logger.warning("Tool '%s' already registered, replacing", name)
-
- record = ToolRecord(
- name=name,
- schema=schema,
- handler=handler,
- category=category,
- requires_confirmation=requires_confirmation,
- tags=tags or [],
- source_module=source_module,
- auto_discovered=auto_discovered,
- )
-
- self._tools[name] = record
-
- # Add to category
- if category not in self._categories:
- self._categories[category] = []
- if name not in self._categories[category]:
- self._categories[category].append(name)
-
- logger.info("Registered tool: %s (category: %s)", name, category)
- return record
-
- def register_tool(
- self,
- name: str,
- function: Callable,
- description: Optional[str] = None,
- category: str = "general",
- tags: Optional[list[str]] = None,
- source_module: Optional[str] = None,
- ) -> ToolRecord:
- """Register a tool from a function (convenience method for discovery).
-
- Args:
- name: Tool name
- function: Function to register
- description: Tool description (defaults to docstring)
- category: Tool category
- tags: Tags for organization
- source_module: Source module path
-
- Returns:
- The registered ToolRecord
- """
- # Build schema from function signature
- sig = inspect.signature(function)
-
- properties = {}
- required = []
-
- for param_name, param in sig.parameters.items():
- if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
- continue
-
- param_schema: dict = {"type": "string"}
-
- # Try to infer type from annotation
- if param.annotation != inspect.Parameter.empty:
- if param.annotation in (int, float):
- param_schema = {"type": "number"}
- elif param.annotation == bool:
- param_schema = {"type": "boolean"}
- elif param.annotation == list:
- param_schema = {"type": "array"}
- elif param.annotation == dict:
- param_schema = {"type": "object"}
-
- if param.default is param.empty:
- required.append(param_name)
- else:
- param_schema["default"] = param.default
-
- properties[param_name] = param_schema
-
- schema = create_tool_schema(
- name=name,
- description=description or (function.__doc__ or f"Execute {name}"),
- parameters=properties,
- required=required,
- )
-
- return self.register(
- name=name,
- schema=schema,
- handler=function,
- category=category,
- tags=tags,
- source_module=source_module or function.__module__,
- auto_discovered=True,
- )
-
- def unregister(self, name: str) -> bool:
- """Remove a tool from the registry."""
- if name not in self._tools:
- return False
-
- record = self._tools.pop(name)
-
- # Remove from category
- if record.category in self._categories:
- if name in self._categories[record.category]:
- self._categories[record.category].remove(name)
-
- logger.info("Unregistered tool: %s", name)
- return True
-
- def get(self, name: str) -> Optional[ToolRecord]:
- """Get a tool record by name."""
- return self._tools.get(name)
-
- def get_handler(self, name: str) -> Optional[Callable]:
- """Get just the handler function for a tool."""
- record = self._tools.get(name)
- return record.handler if record else None
-
- def get_schema(self, name: str) -> Optional[dict]:
- """Get the JSON schema for a tool."""
- record = self._tools.get(name)
- return record.schema if record else None
-
- def list_tools(self, category: Optional[str] = None) -> list[str]:
- """List all tool names, optionally filtered by category."""
- if category:
- return self._categories.get(category, [])
- return list(self._tools.keys())
-
- def list_categories(self) -> list[str]:
- """List all tool categories."""
- return list(self._categories.keys())
-
- def discover(
- self,
- query: Optional[str] = None,
- category: Optional[str] = None,
- tags: Optional[list[str]] = None,
- healthy_only: bool = True,
- auto_discovered_only: bool = False,
- ) -> list[ToolRecord]:
- """Discover tools matching criteria.
-
- Args:
- query: Search in tool names and descriptions
- category: Filter by category
- tags: Filter by tags (must have all specified tags)
- healthy_only: Only return healthy tools
- auto_discovered_only: Only return auto-discovered tools
-
- Returns:
- List of matching ToolRecords
- """
- results = []
-
- for name, record in self._tools.items():
- # Category filter
- if category and record.category != category:
- continue
-
- # Tags filter
- if tags:
- if not all(tag in record.tags for tag in tags):
- continue
-
- # Health filter
- if healthy_only and record.health_status == "unhealthy":
- continue
-
- # Auto-discovered filter
- if auto_discovered_only and not record.auto_discovered:
- continue
-
- # Query filter
- if query:
- query_lower = query.lower()
- name_match = query_lower in name.lower()
- desc = record.schema.get("description", "")
- desc_match = query_lower in desc.lower()
- tag_match = any(query_lower in tag.lower() for tag in record.tags)
- if not (name_match or desc_match or tag_match):
- continue
-
- results.append(record)
-
- return results
-
- async def execute(self, name: str, params: dict) -> Any:
- """Execute a tool by name with given parameters.
-
- Args:
- name: Tool name
- params: Parameters to pass to the tool
-
- Returns:
- Tool execution result
-
- Raises:
- ValueError: If tool not found
- RuntimeError: If tool execution fails
- """
- record = self._tools.get(name)
- if not record:
- raise ValueError(f"Tool '{name}' not found in registry")
-
- start_time = time.time()
-
- try:
- # Check if handler is async
- if inspect.iscoroutinefunction(record.handler):
- result = await record.handler(**params)
- else:
- result = record.handler(**params)
-
- # Update metrics
- latency_ms = (time.time() - start_time) * 1000
- record.last_execution = time.time()
- record.execution_count += 1
-
- # Update rolling average latency
- if record.execution_count == 1:
- record.avg_latency_ms = latency_ms
- else:
- record.avg_latency_ms = (
- record.avg_latency_ms * 0.9 + latency_ms * 0.1
- )
-
- # Mark healthy on success
- record.health_status = "healthy"
-
- logger.debug("Tool '%s' executed in %.2fms", name, latency_ms)
- return result
-
- except Exception as exc:
- record.error_count += 1
- record.execution_count += 1
-
- # Degrade health on repeated errors
- error_rate = record.error_count / record.execution_count
- if error_rate > 0.5:
- record.health_status = "unhealthy"
- logger.error("Tool '%s' marked unhealthy (error rate: %.1f%%)",
- name, error_rate * 100)
- elif error_rate > 0.2:
- record.health_status = "degraded"
- logger.warning("Tool '%s' degraded (error rate: %.1f%%)",
- name, error_rate * 100)
-
- raise RuntimeError(f"Tool '{name}' execution failed: {exc}") from exc
-
- def check_health(self, name: str) -> str:
- """Check health status of a tool."""
- record = self._tools.get(name)
- if not record:
- return "not_found"
- return record.health_status
-
- def get_metrics(self, name: Optional[str] = None) -> dict:
- """Get metrics for a tool or all tools."""
- if name:
- record = self._tools.get(name)
- if not record:
- return {}
- return {
- "name": record.name,
- "category": record.category,
- "health": record.health_status,
- "executions": record.execution_count,
- "errors": record.error_count,
- "avg_latency_ms": round(record.avg_latency_ms, 2),
- }
-
- # Return metrics for all tools
- return {
- name: self.get_metrics(name)
- for name in self._tools.keys()
- }
-
- def to_dict(self) -> dict:
- """Export registry as dictionary (for API/dashboard)."""
- return {
- "tools": [
- {
- "name": r.name,
- "schema": r.schema,
- "category": r.category,
- "health": r.health_status,
- "requires_confirmation": r.requires_confirmation,
- "tags": r.tags,
- "source_module": r.source_module,
- "auto_discovered": r.auto_discovered,
- }
- for r in self._tools.values()
- ],
- "categories": self._categories,
- "total_tools": len(self._tools),
- "auto_discovered_count": sum(1 for r in self._tools.values() if r.auto_discovered),
- }
-
-
-# Module-level singleton
-tool_registry = ToolRegistry()
-
-
-def get_registry() -> ToolRegistry:
- """Get the global tool registry singleton."""
- return tool_registry
-
-
-def register_tool(
- name: Optional[str] = None,
- category: str = "general",
- schema: Optional[dict] = None,
- requires_confirmation: bool = False,
-):
- """Decorator for registering a function as an MCP tool.
-
- Usage:
- @register_tool(name="web_search", category="research")
- def web_search(query: str, max_results: int = 5) -> str:
- ...
- """
- def decorator(func: Callable) -> Callable:
- tool_name = name or func.__name__
-
- # Auto-generate schema if not provided
- if schema is None:
- # Try to infer from type hints
- sig = inspect.signature(func)
- params = {}
- required = []
-
- for param_name, param in sig.parameters.items():
- if param.default == inspect.Parameter.empty:
- required.append(param_name)
- params[param_name] = {"type": "string"}
- else:
- params[param_name] = {
- "type": "string",
- "default": str(param.default),
- }
-
- tool_schema = create_tool_schema(
- name=tool_name,
- description=func.__doc__ or f"Execute {tool_name}",
- parameters=params,
- required=required,
- )
- else:
- tool_schema = schema
-
- tool_registry.register(
- name=tool_name,
- schema=tool_schema,
- handler=func,
- category=category,
- requires_confirmation=requires_confirmation,
- )
-
- return func
- return decorator
diff --git a/src/mcp/schemas/base.py b/src/mcp/schemas/base.py
deleted file mode 100644
index 97a73cb3..00000000
--- a/src/mcp/schemas/base.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""Base schemas for MCP (Model Context Protocol) tools.
-
-All tools must provide a JSON schema describing their interface.
-This enables dynamic discovery and type-safe invocation.
-"""
-
-from typing import Any
-
-
-def create_tool_schema(
- name: str,
- description: str,
- parameters: dict[str, Any],
- required: list[str] | None = None,
- returns: dict[str, Any] | None = None,
-) -> dict:
- """Create a standard MCP tool schema.
-
- Args:
- name: Tool name (must be unique)
- description: Human-readable description
- parameters: JSON schema for input parameters
- required: List of required parameter names
- returns: JSON schema for return value
-
- Returns:
- Complete tool schema dict
- """
- return {
- "name": name,
- "description": description,
- "parameters": {
- "type": "object",
- "properties": parameters,
- "required": required or [],
- },
- "returns": returns or {"type": "string"},
- }
-
-
-# Common parameter schemas
-PARAM_STRING = {"type": "string"}
-PARAM_INTEGER = {"type": "integer"}
-PARAM_BOOLEAN = {"type": "boolean"}
-PARAM_ARRAY_STRINGS = {"type": "array", "items": {"type": "string"}}
-PARAM_OBJECT = {"type": "object"}
-
-# Common return schemas
-RETURN_STRING = {"type": "string"}
-RETURN_OBJECT = {"type": "object"}
-RETURN_ARRAY = {"type": "array"}
-RETURN_BOOLEAN = {"type": "boolean"}
diff --git a/src/mcp/server.py b/src/mcp/server.py
deleted file mode 100644
index 7d046849..00000000
--- a/src/mcp/server.py
+++ /dev/null
@@ -1,210 +0,0 @@
-"""MCP (Model Context Protocol) Server.
-
-Implements the MCP protocol for tool discovery and execution.
-Agents communicate with this server to discover and invoke tools.
-
-The server can run:
-1. In-process (direct method calls) — fastest, for local agents
-2. HTTP API — for external clients
-3. Stdio — for subprocess-based agents
-"""
-
-import asyncio
-import json
-import logging
-from typing import Any, Optional
-
-from mcp.registry import tool_registry
-
-logger = logging.getLogger(__name__)
-
-
-class MCPServer:
- """Model Context Protocol server for tool management.
-
- Provides standard MCP endpoints:
- - list_tools: Discover available tools
- - call_tool: Execute a tool
- - get_schema: Get tool input/output schemas
- """
-
- def __init__(self) -> None:
- self.registry = tool_registry
- logger.info("MCP Server initialized")
-
- def list_tools(
- self,
- category: Optional[str] = None,
- query: Optional[str] = None,
- ) -> list[dict]:
- """List available tools.
-
- MCP Protocol: tools/list
- """
- tools = self.registry.discover(
- query=query,
- category=category,
- healthy_only=True,
- )
-
- return [
- {
- "name": t.name,
- "description": t.schema.get("description", ""),
- "parameters": t.schema.get("parameters", {}),
- "category": t.category,
- }
- for t in tools
- ]
-
- async def call_tool(self, name: str, arguments: dict) -> dict:
- """Execute a tool with given arguments.
-
- MCP Protocol: tools/call
-
- Args:
- name: Tool name
- arguments: Tool parameters
-
- Returns:
- Result dict with content or error
- """
- try:
- result = await self.registry.execute(name, arguments)
- return {
- "content": [
- {"type": "text", "text": str(result)}
- ],
- "isError": False,
- }
- except Exception as exc:
- logger.error("Tool execution failed: %s", exc)
- return {
- "content": [
- {"type": "text", "text": f"Error: {exc}"}
- ],
- "isError": True,
- }
-
- def get_schema(self, name: str) -> Optional[dict]:
- """Get the JSON schema for a tool.
-
- MCP Protocol: tools/schema
- """
- return self.registry.get_schema(name)
-
- def get_tool_info(self, name: str) -> Optional[dict]:
- """Get detailed info about a tool including health metrics."""
- record = self.registry.get(name)
- if not record:
- return None
-
- return {
- "name": record.name,
- "schema": record.schema,
- "category": record.category,
- "health": record.health_status,
- "metrics": {
- "executions": record.execution_count,
- "errors": record.error_count,
- "avg_latency_ms": round(record.avg_latency_ms, 2),
- },
- "requires_confirmation": record.requires_confirmation,
- }
-
- def health_check(self) -> dict:
- """Server health status."""
- tools = self.registry.list_tools()
- healthy = sum(
- 1 for t in tools
- if self.registry.check_health(t) == "healthy"
- )
-
- return {
- "status": "healthy",
- "total_tools": len(tools),
- "healthy_tools": healthy,
- "degraded_tools": sum(
- 1 for t in tools
- if self.registry.check_health(t) == "degraded"
- ),
- "unhealthy_tools": sum(
- 1 for t in tools
- if self.registry.check_health(t) == "unhealthy"
- ),
- }
-
-
-class MCPHTTPServer:
- """HTTP API wrapper for MCP Server."""
-
- def __init__(self) -> None:
- self.mcp = MCPServer()
-
- def get_routes(self) -> dict:
- """Get FastAPI route handlers."""
- from fastapi import APIRouter, HTTPException
- from pydantic import BaseModel
-
- router = APIRouter(prefix="/mcp", tags=["mcp"])
-
- class ToolCallRequest(BaseModel):
- name: str
- arguments: dict = {}
-
- @router.get("/tools")
- async def list_tools(
- category: Optional[str] = None,
- query: Optional[str] = None,
- ):
- """List available tools."""
- return {"tools": self.mcp.list_tools(category, query)}
-
- @router.post("/tools/call")
- async def call_tool(request: ToolCallRequest):
- """Execute a tool."""
- result = await self.mcp.call_tool(request.name, request.arguments)
- return result
-
- @router.get("/tools/{name}")
- async def get_tool(name: str):
- """Get tool info."""
- info = self.mcp.get_tool_info(name)
- if not info:
- raise HTTPException(404, f"Tool '{name}' not found")
- return info
-
- @router.get("/tools/{name}/schema")
- async def get_schema(name: str):
- """Get tool schema."""
- schema = self.mcp.get_schema(name)
- if not schema:
- raise HTTPException(404, f"Tool '{name}' not found")
- return schema
-
- @router.get("/health")
- async def health():
- """Server health check."""
- return self.mcp.health_check()
-
- return router
-
-
-# Module-level singleton
-mcp_server = MCPServer()
-
-
-# Convenience functions for agents
-def discover_tools(query: Optional[str] = None) -> list[dict]:
- """Quick tool discovery."""
- return mcp_server.list_tools(query=query)
-
-
-async def use_tool(name: str, **kwargs) -> str:
- """Execute a tool and return result text."""
- result = await mcp_server.call_tool(name, kwargs)
-
- if result.get("isError"):
- raise RuntimeError(result["content"][0]["text"])
-
- return result["content"][0]["text"]
diff --git a/src/scripture/__init__.py b/src/scripture/__init__.py
deleted file mode 100644
index 8497286c..00000000
--- a/src/scripture/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""Scripture module — sovereign biblical text integration for Timmy Time.
-
-Provides local-first ESV text storage, verse retrieval, reference parsing,
-original language support, cross-referencing, topical mapping, and
-automated meditation workflows. All data resides on localhost — no cloud
-API dependency for core functionality.
-"""
diff --git a/src/scripture/constants.py b/src/scripture/constants.py
deleted file mode 100644
index 36b93ed4..00000000
--- a/src/scripture/constants.py
+++ /dev/null
@@ -1,197 +0,0 @@
-"""Biblical constants — canonical book ordering, abbreviations, metadata.
-
-The canon follows the standard 66-book Protestant ordering used by the ESV.
-Each book is assigned a unique integer ID (1-66) for O(1) verse lookup via
-the integer encoding scheme: book (1-2 digits) + chapter (3 digits) +
-verse (3 digits).
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Literal
-
-
-@dataclass(frozen=True, slots=True)
-class BookInfo:
- """Immutable metadata for a canonical book."""
-
- id: int
- name: str
- abbreviation: str
- testament: Literal["OT", "NT"]
- chapters: int
- genre: str
-
-
-# ── Canonical book list (Protestant 66-book canon, ESV ordering) ────────────
-
-BOOKS: tuple[BookInfo, ...] = (
- # ── Old Testament ────────────────────────────────────────────────────
- BookInfo(1, "Genesis", "Gen", "OT", 50, "law"),
- BookInfo(2, "Exodus", "Exod", "OT", 40, "law"),
- BookInfo(3, "Leviticus", "Lev", "OT", 27, "law"),
- BookInfo(4, "Numbers", "Num", "OT", 36, "law"),
- BookInfo(5, "Deuteronomy", "Deut", "OT", 34, "law"),
- BookInfo(6, "Joshua", "Josh", "OT", 24, "narrative"),
- BookInfo(7, "Judges", "Judg", "OT", 21, "narrative"),
- BookInfo(8, "Ruth", "Ruth", "OT", 4, "narrative"),
- BookInfo(9, "1 Samuel", "1Sam", "OT", 31, "narrative"),
- BookInfo(10, "2 Samuel", "2Sam", "OT", 24, "narrative"),
- BookInfo(11, "1 Kings", "1Kgs", "OT", 22, "narrative"),
- BookInfo(12, "2 Kings", "2Kgs", "OT", 25, "narrative"),
- BookInfo(13, "1 Chronicles", "1Chr", "OT", 29, "narrative"),
- BookInfo(14, "2 Chronicles", "2Chr", "OT", 36, "narrative"),
- BookInfo(15, "Ezra", "Ezra", "OT", 10, "narrative"),
- BookInfo(16, "Nehemiah", "Neh", "OT", 13, "narrative"),
- BookInfo(17, "Esther", "Esth", "OT", 10, "narrative"),
- BookInfo(18, "Job", "Job", "OT", 42, "wisdom"),
- BookInfo(19, "Psalms", "Ps", "OT", 150, "wisdom"),
- BookInfo(20, "Proverbs", "Prov", "OT", 31, "wisdom"),
- BookInfo(21, "Ecclesiastes", "Eccl", "OT", 12, "wisdom"),
- BookInfo(22, "Song of Solomon", "Song", "OT", 8, "wisdom"),
- BookInfo(23, "Isaiah", "Isa", "OT", 66, "prophecy"),
- BookInfo(24, "Jeremiah", "Jer", "OT", 52, "prophecy"),
- BookInfo(25, "Lamentations", "Lam", "OT", 5, "prophecy"),
- BookInfo(26, "Ezekiel", "Ezek", "OT", 48, "prophecy"),
- BookInfo(27, "Daniel", "Dan", "OT", 12, "prophecy"),
- BookInfo(28, "Hosea", "Hos", "OT", 14, "prophecy"),
- BookInfo(29, "Joel", "Joel", "OT", 3, "prophecy"),
- BookInfo(30, "Amos", "Amos", "OT", 9, "prophecy"),
- BookInfo(31, "Obadiah", "Obad", "OT", 1, "prophecy"),
- BookInfo(32, "Jonah", "Jonah", "OT", 4, "prophecy"),
- BookInfo(33, "Micah", "Mic", "OT", 7, "prophecy"),
- BookInfo(34, "Nahum", "Nah", "OT", 3, "prophecy"),
- BookInfo(35, "Habakkuk", "Hab", "OT", 3, "prophecy"),
- BookInfo(36, "Zephaniah", "Zeph", "OT", 3, "prophecy"),
- BookInfo(37, "Haggai", "Hag", "OT", 2, "prophecy"),
- BookInfo(38, "Zechariah", "Zech", "OT", 14, "prophecy"),
- BookInfo(39, "Malachi", "Mal", "OT", 4, "prophecy"),
- # ── New Testament ────────────────────────────────────────────────────
- BookInfo(40, "Matthew", "Matt", "NT", 28, "gospel"),
- BookInfo(41, "Mark", "Mark", "NT", 16, "gospel"),
- BookInfo(42, "Luke", "Luke", "NT", 24, "gospel"),
- BookInfo(43, "John", "John", "NT", 21, "gospel"),
- BookInfo(44, "Acts", "Acts", "NT", 28, "narrative"),
- BookInfo(45, "Romans", "Rom", "NT", 16, "epistle"),
- BookInfo(46, "1 Corinthians", "1Cor", "NT", 16, "epistle"),
- BookInfo(47, "2 Corinthians", "2Cor", "NT", 13, "epistle"),
- BookInfo(48, "Galatians", "Gal", "NT", 6, "epistle"),
- BookInfo(49, "Ephesians", "Eph", "NT", 6, "epistle"),
- BookInfo(50, "Philippians", "Phil", "NT", 4, "epistle"),
- BookInfo(51, "Colossians", "Col", "NT", 4, "epistle"),
- BookInfo(52, "1 Thessalonians", "1Thess", "NT", 5, "epistle"),
- BookInfo(53, "2 Thessalonians", "2Thess", "NT", 3, "epistle"),
- BookInfo(54, "1 Timothy", "1Tim", "NT", 6, "epistle"),
- BookInfo(55, "2 Timothy", "2Tim", "NT", 4, "epistle"),
- BookInfo(56, "Titus", "Titus", "NT", 3, "epistle"),
- BookInfo(57, "Philemon", "Phlm", "NT", 1, "epistle"),
- BookInfo(58, "Hebrews", "Heb", "NT", 13, "epistle"),
- BookInfo(59, "James", "Jas", "NT", 5, "epistle"),
- BookInfo(60, "1 Peter", "1Pet", "NT", 5, "epistle"),
- BookInfo(61, "2 Peter", "2Pet", "NT", 3, "epistle"),
- BookInfo(62, "1 John", "1John", "NT", 5, "epistle"),
- BookInfo(63, "2 John", "2John", "NT", 1, "epistle"),
- BookInfo(64, "3 John", "3John", "NT", 1, "epistle"),
- BookInfo(65, "Jude", "Jude", "NT", 1, "epistle"),
- BookInfo(66, "Revelation", "Rev", "NT", 22, "apocalyptic"),
-)
-
-# ── Lookup indices (built once at import time) ──────────────────────────────
-
-BOOK_BY_ID: dict[int, BookInfo] = {b.id: b for b in BOOKS}
-
-# Map both full names and abbreviations (case-insensitive) to BookInfo
-_BOOK_NAME_MAP: dict[str, BookInfo] = {}
-for _b in BOOKS:
- _BOOK_NAME_MAP[_b.name.lower()] = _b
- _BOOK_NAME_MAP[_b.abbreviation.lower()] = _b
-
-# Common aliases people use that differ from the canonical abbreviation
-_ALIASES: dict[str, int] = {
- "ge": 1, "gen": 1, "genesis": 1,
- "ex": 2, "exo": 2, "exodus": 2,
- "le": 3, "lev": 3, "leviticus": 3,
- "nu": 4, "num": 4, "numbers": 4,
- "dt": 5, "deut": 5, "deuteronomy": 5,
- "jos": 6, "josh": 6, "joshua": 6,
- "jdg": 7, "judg": 7, "judges": 7,
- "ru": 8, "ruth": 8,
- "1sa": 9, "1sam": 9, "1 samuel": 9, "i samuel": 9, "1st samuel": 9,
- "2sa": 10, "2sam": 10, "2 samuel": 10, "ii samuel": 10, "2nd samuel": 10,
- "1ki": 11, "1kgs": 11, "1 kings": 11, "i kings": 11, "1st kings": 11,
- "2ki": 12, "2kgs": 12, "2 kings": 12, "ii kings": 12, "2nd kings": 12,
- "1ch": 13, "1chr": 13, "1 chronicles": 13, "i chronicles": 13,
- "2ch": 14, "2chr": 14, "2 chronicles": 14, "ii chronicles": 14,
- "ezr": 15, "ezra": 15,
- "ne": 16, "neh": 16, "nehemiah": 16,
- "est": 17, "esth": 17, "esther": 17,
- "job": 18,
- "ps": 19, "psa": 19, "psalm": 19, "psalms": 19,
- "pr": 20, "prov": 20, "proverbs": 20,
- "ec": 21, "eccl": 21, "ecclesiastes": 21, "ecc": 21,
- "so": 22, "song": 22, "song of solomon": 22, "song of songs": 22, "sos": 22,
- "isa": 23, "isaiah": 23,
- "jer": 24, "jeremiah": 24,
- "la": 25, "lam": 25, "lamentations": 25,
- "eze": 26, "ezek": 26, "ezekiel": 26,
- "da": 27, "dan": 27, "daniel": 27,
- "ho": 28, "hos": 28, "hosea": 28,
- "joe": 29, "joel": 29,
- "am": 30, "amos": 30,
- "ob": 31, "obad": 31, "obadiah": 31,
- "jon": 32, "jonah": 32,
- "mi": 33, "mic": 33, "micah": 33,
- "na": 34, "nah": 34, "nahum": 34,
- "hab": 35, "habakkuk": 35,
- "zep": 36, "zeph": 36, "zephaniah": 36,
- "hag": 37, "haggai": 37,
- "zec": 38, "zech": 38, "zechariah": 38,
- "mal": 39, "malachi": 39,
- "mt": 40, "matt": 40, "matthew": 40, "mat": 40,
- "mk": 41, "mark": 41, "mar": 41,
- "lk": 42, "luke": 42, "lu": 42,
- "jn": 43, "john": 43, "joh": 43,
- "ac": 44, "acts": 44, "act": 44,
- "ro": 45, "rom": 45, "romans": 45,
- "1co": 46, "1cor": 46, "1 cor": 46, "1 corinthians": 46, "i corinthians": 46,
- "2co": 47, "2cor": 47, "2 cor": 47, "2 corinthians": 47, "ii corinthians": 47,
- "ga": 48, "gal": 48, "galatians": 48,
- "eph": 49, "ephesians": 49,
- "php": 50, "phil": 50, "philippians": 50,
- "col": 51, "colossians": 51,
- "1th": 52, "1thess": 52, "1 thessalonians": 52, "i thessalonians": 52,
- "2th": 53, "2thess": 53, "2 thessalonians": 53, "ii thessalonians": 53,
- "1ti": 54, "1tim": 54, "1 timothy": 54, "i timothy": 54,
- "2ti": 55, "2tim": 55, "2 timothy": 55, "ii timothy": 55,
- "tit": 56, "titus": 56,
- "phm": 57, "phlm": 57, "philemon": 57,
- "heb": 58, "hebrews": 58,
- "jas": 59, "james": 59, "jam": 59,
- "1pe": 60, "1pet": 60, "1 peter": 60, "i peter": 60, "1st peter": 60,
- "2pe": 61, "2pet": 61, "2 peter": 61, "ii peter": 61, "2nd peter": 61,
- "1jn": 62, "1john": 62, "1 john": 62, "i john": 62, "1st john": 62,
- "2jn": 63, "2john": 63, "2 john": 63, "ii john": 63, "2nd john": 63,
- "3jn": 64, "3john": 64, "3 john": 64, "iii john": 64, "3rd john": 64,
- "jude": 65, "jud": 65,
- "re": 66, "rev": 66, "revelation": 66, "revelations": 66,
-}
-
-for _alias, _bid in _ALIASES.items():
- _BOOK_NAME_MAP.setdefault(_alias, BOOK_BY_ID[_bid])
-
-TOTAL_BOOKS = 66
-OT_BOOKS = 39
-NT_BOOKS = 27
-
-GENRES = frozenset(b.genre for b in BOOKS)
-
-
-def book_by_name(name: str) -> BookInfo | None:
- """Resolve a book name or abbreviation to a BookInfo (case-insensitive)."""
- return _BOOK_NAME_MAP.get(name.strip().lower())
-
-
-def book_by_id(book_id: int) -> BookInfo | None:
- """Return the BookInfo for a canonical book ID (1-66)."""
- return BOOK_BY_ID.get(book_id)
diff --git a/src/scripture/meditation.py b/src/scripture/meditation.py
deleted file mode 100644
index 10633770..00000000
--- a/src/scripture/meditation.py
+++ /dev/null
@@ -1,211 +0,0 @@
-"""Meditation scheduler — automated scripture engagement workflows.
-
-Provides background meditation capabilities for the "always on its mind"
-requirement. Supports three modes:
-
-- **Sequential**: book-by-book progression through the Bible
-- **Thematic**: topical exploration guided by Nave's-style index
-- **Lectionary**: cyclical reading patterns following liturgical calendars
-
-The scheduler integrates with the ScriptureMemory system to persist
-progress and working memory state across restarts.
-"""
-
-from __future__ import annotations
-
-import logging
-import random
-from datetime import datetime, timezone
-from typing import Optional
-
-from scripture.constants import BOOK_BY_ID, BOOKS
-from scripture.memory import ScriptureMemory, scripture_memory
-from scripture.models import MeditationState, Verse, decode_verse_id, encode_verse_id
-from scripture.store import ScriptureStore, scripture_store
-
-logger = logging.getLogger(__name__)
-
-
-class MeditationScheduler:
- """Orchestrates automated meditation workflows.
-
- Usage::
-
- from scripture.meditation import meditation_scheduler
-
- # Advance to the next verse in sequence
- result = meditation_scheduler.next_meditation()
-
- # Get the current meditation focus
- current = meditation_scheduler.current_focus()
- """
-
- def __init__(
- self,
- store: ScriptureStore | None = None,
- memory: ScriptureMemory | None = None,
- ) -> None:
- self._store = store or scripture_store
- self._memory = memory or scripture_memory
-
- @property
- def state(self) -> MeditationState:
- return self._memory.associative.get_meditation_state()
-
- def set_mode(self, mode: str, theme: Optional[str] = None) -> MeditationState:
- """Change the meditation mode (sequential / thematic / lectionary)."""
- state = self.state
- if mode not in ("sequential", "thematic", "lectionary"):
- raise ValueError(f"Unknown mode: {mode}")
- state.mode = mode
- state.theme = theme
- self._memory.associative.save_meditation_state(state)
- return state
-
- def current_focus(self) -> Optional[Verse]:
- """Return the verse currently in meditation focus."""
- state = self.state
- return self._store.get_verse(
- state.current_book, state.current_chapter, state.current_verse
- )
-
- def next_meditation(self) -> Optional[Verse]:
- """Advance to the next verse and return it.
-
- Dispatches to the appropriate strategy based on current mode.
- """
- state = self.state
- if state.mode == "thematic":
- return self._next_thematic(state)
- if state.mode == "lectionary":
- return self._next_lectionary(state)
- return self._next_sequential(state)
-
- def meditate_on(self, verse: Verse, notes: str = "") -> None:
- """Record meditation on a specific verse and bring into focus."""
- self._memory.working.focus(verse)
- self._memory.associative.log_meditation(
- verse.verse_id, notes=notes, mode=self.state.mode
- )
- state = self.state
- state.advance(verse.book, verse.chapter, verse.verse_num)
- self._memory.associative.save_meditation_state(state)
-
- def get_context(self, verse: Verse, before: int = 2, after: int = 2) -> list[Verse]:
- """Retrieve surrounding verses for contextual meditation."""
- start_id = encode_verse_id(verse.book, verse.chapter, max(1, verse.verse_num - before))
- end_id = encode_verse_id(verse.book, verse.chapter, verse.verse_num + after)
- return self._store.get_range(start_id, end_id)
-
- def get_cross_references(self, verse: Verse) -> list[Verse]:
- """Retrieve cross-referenced verses for expanded meditation."""
- xrefs = self._store.get_cross_references(verse.verse_id)
- results = []
- for xref in xrefs:
- target_id = (
- xref.target_verse_id
- if xref.source_verse_id == verse.verse_id
- else xref.source_verse_id
- )
- target = self._store.get_verse_by_id(target_id)
- if target:
- results.append(target)
- return results
-
- def history(self, limit: int = 20) -> list[dict]:
- """Return recent meditation history."""
- return self._memory.associative.get_meditation_history(limit=limit)
-
- def status(self) -> dict:
- """Return meditation scheduler status."""
- state = self.state
- current = self.current_focus()
- book_info = BOOK_BY_ID.get(state.current_book)
- return {
- "mode": state.mode,
- "theme": state.theme,
- "current_book": book_info.name if book_info else f"Book {state.current_book}",
- "current_chapter": state.current_chapter,
- "current_verse": state.current_verse,
- "current_text": current.text if current else None,
- "verses_meditated": state.verses_meditated,
- "last_meditation": state.last_meditation,
- }
-
- # ── Private strategies ───────────────────────────────────────────────
-
- def _next_sequential(self, state: MeditationState) -> Optional[Verse]:
- """Sequential mode: advance verse-by-verse through the Bible."""
- book = state.current_book
- chapter = state.current_chapter
- verse_num = state.current_verse + 1
-
- # Try next verse in same chapter
- verse = self._store.get_verse(book, chapter, verse_num)
- if verse:
- self.meditate_on(verse)
- return verse
-
- # Try next chapter
- chapter += 1
- verse_num = 1
- verse = self._store.get_verse(book, chapter, verse_num)
- if verse:
- self.meditate_on(verse)
- return verse
-
- # Try next book
- book += 1
- if book > 66:
- book = 1 # Wrap around to Genesis
- chapter = 1
- verse_num = 1
- verse = self._store.get_verse(book, chapter, verse_num)
- if verse:
- self.meditate_on(verse)
- return verse
-
- return None
-
- def _next_thematic(self, state: MeditationState) -> Optional[Verse]:
- """Thematic mode: retrieve verses related to current theme."""
- if not state.theme:
- # Fall back to sequential if no theme set
- return self._next_sequential(state)
-
- topics = self._store.search_topics(state.theme, limit=1)
- if not topics:
- return self._next_sequential(state)
-
- verses = self._store.get_verses_for_topic(topics[0].topic_id)
- if not verses:
- return self._next_sequential(state)
-
- # Pick the next un-meditated verse (or random if all visited)
- history_ids = {
- e["verse_id"]
- for e in self._memory.associative.get_meditation_history(limit=1000)
- }
- for v in verses:
- if v.verse_id not in history_ids:
- self.meditate_on(v)
- return v
-
- # All verses in topic visited; pick a random one
- chosen = random.choice(verses)
- self.meditate_on(chosen)
- return chosen
-
- def _next_lectionary(self, state: MeditationState) -> Optional[Verse]:
- """Lectionary mode: placeholder — rotates through key passages.
-
- A full lectionary implementation would integrate the Revised Common
- Lectionary or similar. This simplified version cycles through
- thematically significant passages.
- """
- # Simplified: just advance sequentially for now
- return self._next_sequential(state)
-
-
-# Module-level singleton
-meditation_scheduler = MeditationScheduler()
diff --git a/src/scripture/memory.py b/src/scripture/memory.py
deleted file mode 100644
index b771d47e..00000000
--- a/src/scripture/memory.py
+++ /dev/null
@@ -1,286 +0,0 @@
-"""Scripture memory system — working, long-term, and associative memory.
-
-Provides the tripartite memory architecture for continuous scriptural
-engagement:
-
-- **Working memory**: active passage under meditation (session-scoped)
-- **Long-term memory**: persistent store of the full biblical corpus
- (delegated to ScriptureStore)
-- **Associative memory**: thematic and conceptual linkages between verses
-
-The meditation scheduler uses this module to maintain "always on its mind"
-engagement with scripture.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import sqlite3
-from collections import OrderedDict
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Optional
-
-from scripture.models import MeditationState, Verse, decode_verse_id
-
-logger = logging.getLogger(__name__)
-
-# Working memory capacity (analogous to 7±2 human working memory)
-WORKING_MEMORY_CAPACITY = 7
-
-_MEM_DB_DIR = Path("data")
-_MEM_DB_PATH = _MEM_DB_DIR / "scripture.db"
-
-_MEMORY_SCHEMA = """
-CREATE TABLE IF NOT EXISTS meditation_state (
- id INTEGER PRIMARY KEY CHECK (id = 1),
- current_book INTEGER NOT NULL DEFAULT 1,
- current_chapter INTEGER NOT NULL DEFAULT 1,
- current_verse INTEGER NOT NULL DEFAULT 1,
- mode TEXT NOT NULL DEFAULT 'sequential',
- theme TEXT,
- last_meditation TEXT,
- verses_meditated INTEGER NOT NULL DEFAULT 0
-);
-
-CREATE TABLE IF NOT EXISTS meditation_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- verse_id INTEGER NOT NULL,
- meditated_at TEXT NOT NULL,
- notes TEXT NOT NULL DEFAULT '',
- mode TEXT NOT NULL DEFAULT 'sequential'
-);
-
-CREATE INDEX IF NOT EXISTS idx_meditation_log_verse
- ON meditation_log(verse_id);
-
-CREATE TABLE IF NOT EXISTS verse_insights (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- verse_id INTEGER NOT NULL,
- insight TEXT NOT NULL,
- category TEXT NOT NULL DEFAULT 'general',
- created_at TEXT NOT NULL,
- UNIQUE(verse_id, insight)
-);
-"""
-
-
-class WorkingMemory:
- """Session-scoped memory for actively meditated passages.
-
- Holds the most recent ``WORKING_MEMORY_CAPACITY`` verses in focus.
- Uses an LRU-style eviction: oldest items drop when capacity is exceeded.
- """
-
- def __init__(self, capacity: int = WORKING_MEMORY_CAPACITY) -> None:
- self._capacity = capacity
- self._items: OrderedDict[int, Verse] = OrderedDict()
-
- def focus(self, verse: Verse) -> None:
- """Bring a verse into working memory (or refresh if already present)."""
- if verse.verse_id in self._items:
- self._items.move_to_end(verse.verse_id)
- else:
- self._items[verse.verse_id] = verse
- if len(self._items) > self._capacity:
- self._items.popitem(last=False)
-
- def get_focused(self) -> list[Verse]:
- """Return all verses currently in working memory (most recent last)."""
- return list(self._items.values())
-
- def is_focused(self, verse_id: int) -> bool:
- return verse_id in self._items
-
- def clear(self) -> None:
- self._items.clear()
-
- def __len__(self) -> int:
- return len(self._items)
-
-
-class AssociativeMemory:
- """Thematic and conceptual linkages between verses.
-
- Associates verses with insights and connections discovered during
- meditation. Persisted to SQLite for cross-session continuity.
- """
-
- def __init__(self, db_path: Path | str = _MEM_DB_PATH) -> None:
- self._db_path = Path(db_path)
- self._db_path.parent.mkdir(parents=True, exist_ok=True)
- self._conn: Optional[sqlite3.Connection] = None
- self._init_db()
-
- def _get_conn(self) -> sqlite3.Connection:
- if self._conn is None:
- self._conn = sqlite3.connect(
- str(self._db_path), check_same_thread=False
- )
- self._conn.row_factory = sqlite3.Row
- return self._conn
-
- def _init_db(self) -> None:
- conn = self._get_conn()
- conn.executescript(_MEMORY_SCHEMA)
- # Ensure the singleton meditation state row exists
- conn.execute(
- "INSERT OR IGNORE INTO meditation_state (id) VALUES (1)"
- )
- conn.commit()
-
- def close(self) -> None:
- if self._conn:
- self._conn.close()
- self._conn = None
-
- # ── Meditation state persistence ─────────────────────────────────────
-
- def get_meditation_state(self) -> MeditationState:
- """Load the current meditation progress."""
- row = self._get_conn().execute(
- "SELECT * FROM meditation_state WHERE id = 1"
- ).fetchone()
- if not row:
- return MeditationState()
- return MeditationState(
- current_book=row["current_book"],
- current_chapter=row["current_chapter"],
- current_verse=row["current_verse"],
- mode=row["mode"],
- theme=row["theme"],
- last_meditation=row["last_meditation"],
- verses_meditated=row["verses_meditated"],
- )
-
- def save_meditation_state(self, state: MeditationState) -> None:
- """Persist the meditation state."""
- conn = self._get_conn()
- conn.execute(
- """UPDATE meditation_state SET
- current_book = ?, current_chapter = ?, current_verse = ?,
- mode = ?, theme = ?, last_meditation = ?, verses_meditated = ?
- WHERE id = 1""",
- (
- state.current_book, state.current_chapter, state.current_verse,
- state.mode, state.theme, state.last_meditation,
- state.verses_meditated,
- ),
- )
- conn.commit()
-
- # ── Meditation log ───────────────────────────────────────────────────
-
- def log_meditation(
- self, verse_id: int, notes: str = "", mode: str = "sequential"
- ) -> None:
- """Record that a verse was meditated upon."""
- conn = self._get_conn()
- conn.execute(
- "INSERT INTO meditation_log (verse_id, meditated_at, notes, mode) VALUES (?, ?, ?, ?)",
- (verse_id, datetime.now(timezone.utc).isoformat(), notes, mode),
- )
- conn.commit()
-
- def get_meditation_history(self, limit: int = 20) -> list[dict]:
- """Return the most recent meditation log entries."""
- rows = self._get_conn().execute(
- "SELECT * FROM meditation_log ORDER BY id DESC LIMIT ?", (limit,)
- ).fetchall()
- return [
- {
- "verse_id": r["verse_id"],
- "meditated_at": r["meditated_at"],
- "notes": r["notes"],
- "mode": r["mode"],
- }
- for r in rows
- ]
-
- def meditation_count(self) -> int:
- """Total meditation sessions logged."""
- row = self._get_conn().execute(
- "SELECT COUNT(*) FROM meditation_log"
- ).fetchone()
- return row[0] if row else 0
-
- # ── Verse insights ───────────────────────────────────────────────────
-
- def add_insight(
- self, verse_id: int, insight: str, category: str = "general"
- ) -> None:
- """Record an insight discovered during meditation or study."""
- conn = self._get_conn()
- conn.execute(
- """INSERT OR IGNORE INTO verse_insights
- (verse_id, insight, category, created_at) VALUES (?, ?, ?, ?)""",
- (verse_id, insight, category, datetime.now(timezone.utc).isoformat()),
- )
- conn.commit()
-
- def get_insights(self, verse_id: int) -> list[dict]:
- """Retrieve all insights for a given verse."""
- rows = self._get_conn().execute(
- "SELECT * FROM verse_insights WHERE verse_id = ? ORDER BY created_at DESC",
- (verse_id,),
- ).fetchall()
- return [
- {
- "insight": r["insight"],
- "category": r["category"],
- "created_at": r["created_at"],
- }
- for r in rows
- ]
-
- def get_recent_insights(self, limit: int = 10) -> list[dict]:
- """Return the most recently added insights across all verses."""
- rows = self._get_conn().execute(
- "SELECT * FROM verse_insights ORDER BY created_at DESC LIMIT ?",
- (limit,),
- ).fetchall()
- return [
- {
- "verse_id": r["verse_id"],
- "insight": r["insight"],
- "category": r["category"],
- "created_at": r["created_at"],
- }
- for r in rows
- ]
-
-
-class ScriptureMemory:
- """Unified scripture memory manager combining all three memory tiers.
-
- Usage::
-
- from scripture.memory import scripture_memory
- scripture_memory.working.focus(verse)
- state = scripture_memory.associative.get_meditation_state()
- """
-
- def __init__(self, db_path: Path | str = _MEM_DB_PATH) -> None:
- self.working = WorkingMemory()
- self.associative = AssociativeMemory(db_path=db_path)
-
- def close(self) -> None:
- self.working.clear()
- self.associative.close()
-
- def status(self) -> dict:
- """Return a summary of memory system state."""
- state = self.associative.get_meditation_state()
- return {
- "working_memory_items": len(self.working),
- "working_memory_capacity": WORKING_MEMORY_CAPACITY,
- "meditation_mode": state.mode,
- "verses_meditated": state.verses_meditated,
- "last_meditation": state.last_meditation,
- "meditation_count": self.associative.meditation_count(),
- }
-
-
-# Module-level singleton
-scripture_memory = ScriptureMemory()
diff --git a/src/scripture/models.py b/src/scripture/models.py
deleted file mode 100644
index ebea956b..00000000
--- a/src/scripture/models.py
+++ /dev/null
@@ -1,160 +0,0 @@
-"""Data models for the scripture module.
-
-Provides Pydantic models for verses, references, cross-references,
-topics, and original language annotations.
-"""
-
-from __future__ import annotations
-
-from datetime import datetime, timezone
-from typing import Literal, Optional
-
-from pydantic import BaseModel, Field
-
-
-# ── Integer encoding scheme ──────────────────────────────────────────────────
-# book (1-66, 1-2 digits) + chapter (3 digits, zero-padded) +
-# verse (3 digits, zero-padded) = 7-8 digit unique integer per verse.
-# Example: John 3:16 → 43_003_016 = 43003016
-
-
-def encode_verse_id(book: int, chapter: int, verse: int) -> int:
- """Encode a book/chapter/verse triplet into a unique integer ID."""
- return book * 1_000_000 + chapter * 1_000 + verse
-
-
-def decode_verse_id(verse_id: int) -> tuple[int, int, int]:
- """Decode an integer verse ID back to (book, chapter, verse)."""
- book = verse_id // 1_000_000
- remainder = verse_id % 1_000_000
- chapter = remainder // 1_000
- verse = remainder % 1_000
- return book, chapter, verse
-
-
-# ── Core models ──────────────────────────────────────────────────────────────
-
-
-class VerseRef(BaseModel):
- """A single verse reference (book + chapter + verse)."""
-
- book: int = Field(ge=1, le=66, description="Canonical book ID (1-66)")
- chapter: int = Field(ge=1, description="Chapter number")
- verse: int = Field(ge=0, description="Verse number (0 for chapter-level)")
-
- @property
- def verse_id(self) -> int:
- return encode_verse_id(self.book, self.chapter, self.verse)
-
-
-class VerseRange(BaseModel):
- """A contiguous range of verses."""
-
- start: VerseRef
- end: VerseRef
-
- def verse_ids(self) -> list[int]:
- """Expand the range to individual verse IDs."""
- ids = []
- for vid in range(self.start.verse_id, self.end.verse_id + 1):
- b, c, v = decode_verse_id(vid)
- if 1 <= b <= 66 and c >= 1 and v >= 1:
- ids.append(vid)
- return ids
-
-
-class Verse(BaseModel):
- """A single verse with text content and metadata."""
-
- verse_id: int = Field(description="Encoded integer ID")
- book: int
- chapter: int
- verse_num: int
- text: str
- translation: str = "ESV"
- testament: Literal["OT", "NT"] = "OT"
- genre: str = ""
-
-
-class CrossReference(BaseModel):
- """A cross-reference link between two verses."""
-
- source_verse_id: int
- target_verse_id: int
- reference_type: Literal[
- "quotation", "allusion", "thematic", "typology", "parallel"
- ] = "thematic"
- confidence: float = Field(default=1.0, ge=0.0, le=1.0)
-
-
-class Topic(BaseModel):
- """A topical category from a topical index (e.g. Nave's)."""
-
- topic_id: str
- name: str
- parent_id: Optional[str] = None
- description: str = ""
- verse_ids: list[int] = Field(default_factory=list)
-
-
-class StrongsEntry(BaseModel):
- """A Strong's concordance entry for original language terms."""
-
- strongs_number: str = Field(description="e.g. H7225, G26")
- language: Literal["hebrew", "greek"]
- lemma: str = ""
- transliteration: str = ""
- gloss: str = ""
- morphology: str = ""
-
-
-class OriginalLanguageToken(BaseModel):
- """A token in original language text with annotations."""
-
- text: str
- transliteration: str = ""
- strongs_number: str = ""
- morphology: str = ""
- gloss: str = ""
- word_position: int = 0
-
-
-class InterlinearVerse(BaseModel):
- """A verse with interlinear original language alignment."""
-
- verse_id: int
- reference: str
- original_tokens: list[OriginalLanguageToken] = Field(default_factory=list)
- esv_text: str = ""
- language: Literal["hebrew", "greek"] = "hebrew"
-
-
-class MeditationState(BaseModel):
- """Tracks the current meditation progress."""
-
- current_book: int = 1
- current_chapter: int = 1
- current_verse: int = 1
- mode: Literal["sequential", "thematic", "lectionary"] = "sequential"
- theme: Optional[str] = None
- last_meditation: Optional[str] = None
- verses_meditated: int = 0
-
- def advance(self, book: int, chapter: int, verse: int) -> None:
- self.current_book = book
- self.current_chapter = chapter
- self.current_verse = verse
- self.last_meditation = datetime.now(timezone.utc).isoformat()
- self.verses_meditated += 1
-
-
-class ScriptureQuery(BaseModel):
- """A parsed user query for scripture content."""
-
- intent: Literal[
- "lookup", "explanation", "application", "comparison", "devotional", "search"
- ] = "lookup"
- references: list[VerseRef] = Field(default_factory=list)
- keywords: list[str] = Field(default_factory=list)
- topic: Optional[str] = None
- raw_text: str = ""
diff --git a/src/scripture/parser.py b/src/scripture/parser.py
deleted file mode 100644
index 7989e2d8..00000000
--- a/src/scripture/parser.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""Reference parser — extract and normalise biblical references from text.
-
-Handles explicit references (``John 3:16``), range references
-(``Romans 5:1-11``), multi-chapter ranges (``Genesis 1:1-2:3``),
-and fuzzy book name matching (``1 Cor 13``, ``Phil 4 13``).
-"""
-
-from __future__ import annotations
-
-import re
-from typing import Optional
-
-from scripture.constants import book_by_name, BookInfo, BOOK_BY_ID
-from scripture.models import VerseRef, VerseRange
-
-
-# ── Regex patterns ───────────────────────────────────────────────────────────
-
-# Matches patterns like "John 3:16", "1 Cor 13:4-7", "Gen 1:1-2:3"
-_REF_PATTERN = re.compile(
- r"""
- (?P
- (?:[123]\s*)? # optional ordinal (1, 2, 3)
- [A-Za-z]+ # book name
- (?:\s+of\s+[A-Za-z]+)? # "Song of Solomon"
- )
- \s*
- (?P\d{1,3}) # chapter number
- (?:
- \s*[:\.]\s* # separator (colon or dot)
- (?P\d{1,3}) # verse number
- (?:
- \s*[-–—]\s* # range separator
- (?:
- (?P\d{1,3}) # optional end chapter
- \s*[:\.]\s*
- )?
- (?P\d{1,3}) # end verse
- )?
- )?
- """,
- re.VERBOSE | re.IGNORECASE,
-)
-
-
-def _normalise_book_name(raw: str) -> str:
- """Collapse whitespace and lowercase for lookup."""
- return re.sub(r"\s+", " ", raw.strip()).lower()
-
-
-def resolve_book(name: str) -> Optional[BookInfo]:
- """Resolve a book name/abbreviation to a BookInfo."""
- return book_by_name(_normalise_book_name(name))
-
-
-def parse_reference(text: str) -> Optional[VerseRange]:
- """Parse a single scripture reference string into a VerseRange.
-
- Examples::
-
- parse_reference("John 3:16")
- parse_reference("Genesis 1:1-3")
- parse_reference("Rom 5:1-11")
- parse_reference("1 Cor 13") # whole chapter
- """
- m = _REF_PATTERN.search(text)
- if not m:
- return None
-
- book_info = resolve_book(m.group("book"))
- if not book_info:
- return None
-
- chapter = int(m.group("chapter"))
- verse_str = m.group("verse")
- end_verse_str = m.group("end_verse")
- end_chapter_str = m.group("end_chapter")
-
- if verse_str is None:
- # Whole chapter reference: "Genesis 1"
- start = VerseRef(book=book_info.id, chapter=chapter, verse=1)
- # Use a large verse number; the caller truncates to actual max
- end = VerseRef(book=book_info.id, chapter=chapter, verse=999)
- return VerseRange(start=start, end=end)
-
- start_verse = int(verse_str)
- start = VerseRef(book=book_info.id, chapter=chapter, verse=start_verse)
-
- if end_verse_str is not None:
- end_ch = int(end_chapter_str) if end_chapter_str else chapter
- end_v = int(end_verse_str)
- end = VerseRef(book=book_info.id, chapter=end_ch, verse=end_v)
- else:
- end = VerseRef(book=book_info.id, chapter=chapter, verse=start_verse)
-
- return VerseRange(start=start, end=end)
-
-
-def extract_references(text: str) -> list[VerseRange]:
- """Extract all scripture references from a block of text.
-
- Returns a list of VerseRange objects for every reference found.
- """
- results: list[VerseRange] = []
- for m in _REF_PATTERN.finditer(text):
- book_info = resolve_book(m.group("book"))
- if not book_info:
- continue
-
- chapter = int(m.group("chapter"))
- verse_str = m.group("verse")
- end_verse_str = m.group("end_verse")
- end_chapter_str = m.group("end_chapter")
-
- if verse_str is None:
- start = VerseRef(book=book_info.id, chapter=chapter, verse=1)
- end = VerseRef(book=book_info.id, chapter=chapter, verse=999)
- else:
- sv = int(verse_str)
- start = VerseRef(book=book_info.id, chapter=chapter, verse=sv)
- if end_verse_str is not None:
- end_ch = int(end_chapter_str) if end_chapter_str else chapter
- end = VerseRef(book=book_info.id, chapter=end_ch, verse=int(end_verse_str))
- else:
- end = VerseRef(book=book_info.id, chapter=chapter, verse=sv)
-
- results.append(VerseRange(start=start, end=end))
- return results
-
-
-def format_reference(ref: VerseRef) -> str:
- """Format a VerseRef as a human-readable string.
-
- Example: ``VerseRef(book=43, chapter=3, verse=16)`` → ``"John 3:16"``
- """
- book = BOOK_BY_ID.get(ref.book)
- if not book:
- return f"Unknown {ref.chapter}:{ref.verse}"
- if ref.verse == 0:
- return f"{book.name} {ref.chapter}"
- return f"{book.name} {ref.chapter}:{ref.verse}"
-
-
-def format_range(vr: VerseRange) -> str:
- """Format a VerseRange as a human-readable string.
-
- Examples::
-
- "John 3:16" (single verse)
- "Romans 5:1-11" (same chapter range)
- "Genesis 1:1-2:3" (multi-chapter range)
- """
- start_book = BOOK_BY_ID.get(vr.start.book)
- if not start_book:
- return "Unknown reference"
-
- if vr.start.verse_id == vr.end.verse_id:
- return format_reference(vr.start)
-
- if vr.start.chapter == vr.end.chapter:
- return f"{start_book.name} {vr.start.chapter}:{vr.start.verse}-{vr.end.verse}"
-
- return (
- f"{start_book.name} {vr.start.chapter}:{vr.start.verse}"
- f"-{vr.end.chapter}:{vr.end.verse}"
- )
diff --git a/src/scripture/store.py b/src/scripture/store.py
deleted file mode 100644
index 78ee1d25..00000000
--- a/src/scripture/store.py
+++ /dev/null
@@ -1,387 +0,0 @@
-"""Scripture store — SQLite-backed verse storage and retrieval.
-
-Provides the persistent knowledge base for the complete ESV text.
-Follows the project's SQLite singleton pattern (cf. swarm/registry.py).
-
-Tables
-------
-- ``verses`` Primary verse storage with text + metadata
-- ``cross_references`` TSK-derived edges between verses
-- ``topics`` Nave's-style topical index entries
-- ``verse_topics`` Many-to-many verse ↔ topic links
-- ``strongs`` Strong's concordance entries
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import sqlite3
-from pathlib import Path
-from typing import Optional
-
-from scripture.constants import BOOK_BY_ID, book_by_name
-from scripture.models import (
- CrossReference,
- StrongsEntry,
- Topic,
- Verse,
- VerseRef,
- decode_verse_id,
- encode_verse_id,
-)
-
-logger = logging.getLogger(__name__)
-
-DB_DIR = Path("data")
-DB_PATH = DB_DIR / "scripture.db"
-
-_SCHEMA = """
-CREATE TABLE IF NOT EXISTS verses (
- verse_id INTEGER PRIMARY KEY,
- book INTEGER NOT NULL,
- chapter INTEGER NOT NULL,
- verse_num INTEGER NOT NULL,
- text TEXT NOT NULL,
- translation TEXT NOT NULL DEFAULT 'ESV',
- testament TEXT NOT NULL DEFAULT 'OT',
- genre TEXT NOT NULL DEFAULT ''
-);
-
-CREATE INDEX IF NOT EXISTS idx_verses_book_ch
- ON verses(book, chapter);
-
-CREATE TABLE IF NOT EXISTS cross_references (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- source_verse_id INTEGER NOT NULL,
- target_verse_id INTEGER NOT NULL,
- reference_type TEXT NOT NULL DEFAULT 'thematic',
- confidence REAL NOT NULL DEFAULT 1.0,
- UNIQUE(source_verse_id, target_verse_id, reference_type)
-);
-
-CREATE INDEX IF NOT EXISTS idx_xref_source
- ON cross_references(source_verse_id);
-CREATE INDEX IF NOT EXISTS idx_xref_target
- ON cross_references(target_verse_id);
-
-CREATE TABLE IF NOT EXISTS topics (
- topic_id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- parent_id TEXT,
- description TEXT NOT NULL DEFAULT ''
-);
-
-CREATE TABLE IF NOT EXISTS verse_topics (
- verse_id INTEGER NOT NULL,
- topic_id TEXT NOT NULL,
- relevance REAL NOT NULL DEFAULT 1.0,
- PRIMARY KEY (verse_id, topic_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_vt_topic
- ON verse_topics(topic_id);
-
-CREATE TABLE IF NOT EXISTS strongs (
- strongs_number TEXT PRIMARY KEY,
- language TEXT NOT NULL,
- lemma TEXT NOT NULL DEFAULT '',
- transliteration TEXT NOT NULL DEFAULT '',
- gloss TEXT NOT NULL DEFAULT '',
- morphology TEXT NOT NULL DEFAULT ''
-);
-"""
-
-
-class ScriptureStore:
- """SQLite-backed scripture knowledge base.
-
- Usage::
-
- from scripture.store import scripture_store
- verse = scripture_store.get_verse(43, 3, 16)
- """
-
- def __init__(self, db_path: Path | str = DB_PATH) -> None:
- self._db_path = Path(db_path)
- self._db_path.parent.mkdir(parents=True, exist_ok=True)
- self._conn: Optional[sqlite3.Connection] = None
- self._init_db()
-
- # ── Connection management ────────────────────────────────────────────
-
- def _get_conn(self) -> sqlite3.Connection:
- if self._conn is None:
- self._conn = sqlite3.connect(
- str(self._db_path), check_same_thread=False
- )
- self._conn.row_factory = sqlite3.Row
- self._conn.execute("PRAGMA journal_mode=WAL")
- self._conn.execute("PRAGMA foreign_keys=ON")
- return self._conn
-
- def _init_db(self) -> None:
- conn = self._get_conn()
- conn.executescript(_SCHEMA)
- conn.commit()
-
- def close(self) -> None:
- if self._conn:
- self._conn.close()
- self._conn = None
-
- # ── Verse CRUD ───────────────────────────────────────────────────────
-
- def insert_verse(self, verse: Verse) -> None:
- """Insert or replace a single verse."""
- conn = self._get_conn()
- conn.execute(
- """INSERT OR REPLACE INTO verses
- (verse_id, book, chapter, verse_num, text, translation, testament, genre)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
- (
- verse.verse_id,
- verse.book,
- verse.chapter,
- verse.verse_num,
- verse.text,
- verse.translation,
- verse.testament,
- verse.genre,
- ),
- )
- conn.commit()
-
- def insert_verses(self, verses: list[Verse]) -> None:
- """Bulk-insert verses (efficient for full-text ingestion)."""
- conn = self._get_conn()
- conn.executemany(
- """INSERT OR REPLACE INTO verses
- (verse_id, book, chapter, verse_num, text, translation, testament, genre)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
- [
- (v.verse_id, v.book, v.chapter, v.verse_num,
- v.text, v.translation, v.testament, v.genre)
- for v in verses
- ],
- )
- conn.commit()
-
- def get_verse(self, book: int, chapter: int, verse: int) -> Optional[Verse]:
- """Retrieve a single verse by book/chapter/verse."""
- vid = encode_verse_id(book, chapter, verse)
- row = self._get_conn().execute(
- "SELECT * FROM verses WHERE verse_id = ?", (vid,)
- ).fetchone()
- return self._row_to_verse(row) if row else None
-
- def get_verse_by_id(self, verse_id: int) -> Optional[Verse]:
- """Retrieve a verse by its integer ID."""
- row = self._get_conn().execute(
- "SELECT * FROM verses WHERE verse_id = ?", (verse_id,)
- ).fetchone()
- return self._row_to_verse(row) if row else None
-
- def get_chapter(self, book: int, chapter: int) -> list[Verse]:
- """Retrieve all verses in a chapter, ordered by verse number."""
- rows = self._get_conn().execute(
- "SELECT * FROM verses WHERE book = ? AND chapter = ? ORDER BY verse_num",
- (book, chapter),
- ).fetchall()
- return [self._row_to_verse(r) for r in rows]
-
- def get_range(self, start_id: int, end_id: int) -> list[Verse]:
- """Retrieve all verses in a range of verse IDs (inclusive)."""
- rows = self._get_conn().execute(
- "SELECT * FROM verses WHERE verse_id BETWEEN ? AND ? ORDER BY verse_id",
- (start_id, end_id),
- ).fetchall()
- return [self._row_to_verse(r) for r in rows]
-
- def search_text(self, query: str, limit: int = 20) -> list[Verse]:
- """Full-text search across verse content (LIKE-based)."""
- rows = self._get_conn().execute(
- "SELECT * FROM verses WHERE text LIKE ? ORDER BY verse_id LIMIT ?",
- (f"%{query}%", limit),
- ).fetchall()
- return [self._row_to_verse(r) for r in rows]
-
- def count_verses(self) -> int:
- """Return the total number of verses in the store."""
- row = self._get_conn().execute("SELECT COUNT(*) FROM verses").fetchone()
- return row[0] if row else 0
-
- def get_books(self) -> list[dict]:
- """Return a summary of all books with verse counts."""
- rows = self._get_conn().execute(
- """SELECT book, COUNT(*) as verse_count, MIN(chapter) as min_ch,
- MAX(chapter) as max_ch
- FROM verses GROUP BY book ORDER BY book"""
- ).fetchall()
- result = []
- for r in rows:
- info = BOOK_BY_ID.get(r["book"])
- result.append({
- "book_id": r["book"],
- "name": info.name if info else f"Book {r['book']}",
- "abbreviation": info.abbreviation if info else "",
- "testament": info.testament if info else "",
- "verse_count": r["verse_count"],
- "chapters": r["max_ch"],
- })
- return result
-
- # ── Cross-references ─────────────────────────────────────────────────
-
- def insert_cross_reference(self, xref: CrossReference) -> None:
- """Insert a cross-reference link."""
- conn = self._get_conn()
- conn.execute(
- """INSERT OR IGNORE INTO cross_references
- (source_verse_id, target_verse_id, reference_type, confidence)
- VALUES (?, ?, ?, ?)""",
- (xref.source_verse_id, xref.target_verse_id,
- xref.reference_type, xref.confidence),
- )
- conn.commit()
-
- def get_cross_references(self, verse_id: int) -> list[CrossReference]:
- """Find all cross-references from or to a verse."""
- rows = self._get_conn().execute(
- """SELECT * FROM cross_references
- WHERE source_verse_id = ? OR target_verse_id = ?
- ORDER BY confidence DESC""",
- (verse_id, verse_id),
- ).fetchall()
- return [
- CrossReference(
- source_verse_id=r["source_verse_id"],
- target_verse_id=r["target_verse_id"],
- reference_type=r["reference_type"],
- confidence=r["confidence"],
- )
- for r in rows
- ]
-
- # ── Topics ───────────────────────────────────────────────────────────
-
- def insert_topic(self, topic: Topic) -> None:
- """Insert a topical index entry."""
- conn = self._get_conn()
- conn.execute(
- """INSERT OR REPLACE INTO topics
- (topic_id, name, parent_id, description) VALUES (?, ?, ?, ?)""",
- (topic.topic_id, topic.name, topic.parent_id, topic.description),
- )
- for vid in topic.verse_ids:
- conn.execute(
- "INSERT OR IGNORE INTO verse_topics (verse_id, topic_id) VALUES (?, ?)",
- (vid, topic.topic_id),
- )
- conn.commit()
-
- def get_topic(self, topic_id: str) -> Optional[Topic]:
- """Retrieve a topic by ID."""
- row = self._get_conn().execute(
- "SELECT * FROM topics WHERE topic_id = ?", (topic_id,)
- ).fetchone()
- if not row:
- return None
- verse_rows = self._get_conn().execute(
- "SELECT verse_id FROM verse_topics WHERE topic_id = ?", (topic_id,)
- ).fetchall()
- return Topic(
- topic_id=row["topic_id"],
- name=row["name"],
- parent_id=row["parent_id"],
- description=row["description"],
- verse_ids=[r["verse_id"] for r in verse_rows],
- )
-
- def search_topics(self, query: str, limit: int = 10) -> list[Topic]:
- """Search topics by name."""
- rows = self._get_conn().execute(
- "SELECT * FROM topics WHERE name LIKE ? ORDER BY name LIMIT ?",
- (f"%{query}%", limit),
- ).fetchall()
- return [
- Topic(topic_id=r["topic_id"], name=r["name"],
- parent_id=r["parent_id"], description=r["description"])
- for r in rows
- ]
-
- def get_verses_for_topic(self, topic_id: str) -> list[Verse]:
- """Retrieve all verses associated with a topic."""
- rows = self._get_conn().execute(
- """SELECT v.* FROM verses v
- INNER JOIN verse_topics vt ON v.verse_id = vt.verse_id
- WHERE vt.topic_id = ?
- ORDER BY v.verse_id""",
- (topic_id,),
- ).fetchall()
- return [self._row_to_verse(r) for r in rows]
-
- # ── Strong's concordance ─────────────────────────────────────────────
-
- def insert_strongs(self, entry: StrongsEntry) -> None:
- """Insert a Strong's concordance entry."""
- conn = self._get_conn()
- conn.execute(
- """INSERT OR REPLACE INTO strongs
- (strongs_number, language, lemma, transliteration, gloss, morphology)
- VALUES (?, ?, ?, ?, ?, ?)""",
- (entry.strongs_number, entry.language, entry.lemma,
- entry.transliteration, entry.gloss, entry.morphology),
- )
- conn.commit()
-
- def get_strongs(self, number: str) -> Optional[StrongsEntry]:
- """Look up a Strong's number."""
- row = self._get_conn().execute(
- "SELECT * FROM strongs WHERE strongs_number = ?", (number,)
- ).fetchone()
- if not row:
- return None
- return StrongsEntry(
- strongs_number=row["strongs_number"],
- language=row["language"],
- lemma=row["lemma"],
- transliteration=row["transliteration"],
- gloss=row["gloss"],
- morphology=row["morphology"],
- )
-
- # ── Stats ────────────────────────────────────────────────────────────
-
- def stats(self) -> dict:
- """Return summary statistics of the scripture store."""
- conn = self._get_conn()
- verses = conn.execute("SELECT COUNT(*) FROM verses").fetchone()[0]
- xrefs = conn.execute("SELECT COUNT(*) FROM cross_references").fetchone()[0]
- topics = conn.execute("SELECT COUNT(*) FROM topics").fetchone()[0]
- strongs = conn.execute("SELECT COUNT(*) FROM strongs").fetchone()[0]
- return {
- "verses": verses,
- "cross_references": xrefs,
- "topics": topics,
- "strongs_entries": strongs,
- }
-
- # ── Helpers ──────────────────────────────────────────────────────────
-
- @staticmethod
- def _row_to_verse(row: sqlite3.Row) -> Verse:
- return Verse(
- verse_id=row["verse_id"],
- book=row["book"],
- chapter=row["chapter"],
- verse_num=row["verse_num"],
- text=row["text"],
- translation=row["translation"],
- testament=row["testament"],
- genre=row["genre"],
- )
-
-
-# Module-level singleton
-scripture_store = ScriptureStore()
diff --git a/src/self_coding/CLAUDE.md b/src/self_coding/CLAUDE.md
deleted file mode 100644
index 795892a3..00000000
--- a/src/self_coding/CLAUDE.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# self_coding/ — Module Guide
-
-Self-modification infrastructure with safety constraints.
-
-## Structure
-- `git_safety.py` — Atomic git operations with rollback
-- `codebase_indexer.py` — Live mental model of the codebase
-- `modification_journal.py` — Persistent log of modification attempts
-- `reflection.py` — Generate lessons learned
-- `self_modify/` — Runtime self-modification loop (LLM-driven)
-- `self_tdd/` — Continuous test watchdog
-- `upgrades/` — Self-upgrade approval queue
-
-## Entry points
-```toml
-self-tdd = "self_coding.self_tdd.watchdog:main"
-self-modify = "self_coding.self_modify.cli:main"
-```
-
-## Testing
-```bash
-pytest tests/self_coding/ -q
-```
diff --git a/src/self_coding/__init__.py b/src/self_coding/__init__.py
deleted file mode 100644
index 31d285c7..00000000
--- a/src/self_coding/__init__.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""Self-Coding Layer — Timmy's ability to modify its own source code safely.
-
-This module provides the foundational infrastructure for self-modification:
-
-- GitSafety: Atomic git operations with rollback capability
-- CodebaseIndexer: Live mental model of the codebase
-- ModificationJournal: Persistent log of modification attempts
-- ReflectionService: Generate lessons learned from attempts
-
-Usage:
- from self_coding import GitSafety, CodebaseIndexer, ModificationJournal
- from self_coding import ModificationAttempt, Outcome, Snapshot
-
- # Initialize services
- git = GitSafety(repo_path="/path/to/repo")
- indexer = CodebaseIndexer(repo_path="/path/to/repo")
- journal = ModificationJournal()
-
- # Use in self-modification workflow
- snapshot = await git.snapshot()
- # ... make changes ...
- if tests_pass:
- await git.commit("Changes", ["file.py"])
- else:
- await git.rollback(snapshot)
-"""
-
-from self_coding.git_safety import GitSafety, Snapshot
-from self_coding.codebase_indexer import CodebaseIndexer, ModuleInfo, FunctionInfo, ClassInfo
-from self_coding.modification_journal import (
- ModificationJournal,
- ModificationAttempt,
- Outcome,
-)
-from self_coding.reflection import ReflectionService
-
-__all__ = [
- # Core services
- "GitSafety",
- "CodebaseIndexer",
- "ModificationJournal",
- "ReflectionService",
- # Data classes
- "Snapshot",
- "ModuleInfo",
- "FunctionInfo",
- "ClassInfo",
- "ModificationAttempt",
- "Outcome",
-]
\ No newline at end of file
diff --git a/src/self_coding/codebase_indexer.py b/src/self_coding/codebase_indexer.py
deleted file mode 100644
index 495637c3..00000000
--- a/src/self_coding/codebase_indexer.py
+++ /dev/null
@@ -1,772 +0,0 @@
-"""Codebase Indexer — Live mental model of Timmy's own codebase.
-
-Parses Python files using AST to extract classes, functions, imports, and
-docstrings. Builds a dependency graph and provides semantic search for
-relevant files.
-"""
-
-from __future__ import annotations
-
-import ast
-import hashlib
-import json
-import logging
-import sqlite3
-from dataclasses import asdict, dataclass, field
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any, Optional
-
-logger = logging.getLogger(__name__)
-
-# Default database location
-DEFAULT_DB_PATH = Path("data/self_coding.db")
-
-
-@dataclass
-class FunctionInfo:
- """Information about a function."""
- name: str
- args: list[str]
- returns: Optional[str] = None
- docstring: Optional[str] = None
- line_number: int = 0
- is_async: bool = False
- is_method: bool = False
-
-
-@dataclass
-class ClassInfo:
- """Information about a class."""
- name: str
- methods: list[FunctionInfo] = field(default_factory=list)
- docstring: Optional[str] = None
- line_number: int = 0
- bases: list[str] = field(default_factory=list)
-
-
-@dataclass
-class ModuleInfo:
- """Information about a Python module."""
- file_path: str
- module_name: str
- classes: list[ClassInfo] = field(default_factory=list)
- functions: list[FunctionInfo] = field(default_factory=list)
- imports: list[str] = field(default_factory=list)
- docstring: Optional[str] = None
- test_coverage: Optional[str] = None
-
-
-class CodebaseIndexer:
- """Indexes Python codebase for self-modification workflows.
-
- Parses all Python files using AST to extract:
- - Module names and structure
- - Class definitions with methods
- - Function signatures with args and return types
- - Import relationships
- - Test coverage mapping
-
- Stores everything in SQLite for fast querying.
-
- Usage:
- indexer = CodebaseIndexer(repo_path="/path/to/repo")
-
- # Full reindex
- await indexer.index_all()
-
- # Incremental update
- await indexer.index_changed()
-
- # Get LLM context summary
- summary = await indexer.get_summary()
-
- # Find relevant files for a task
- files = await indexer.get_relevant_files("Add error handling to health endpoint")
-
- # Get dependency chain
- deps = await indexer.get_dependency_chain("src/timmy/agent.py")
- """
-
- def __init__(
- self,
- repo_path: Optional[str | Path] = None,
- db_path: Optional[str | Path] = None,
- src_dirs: Optional[list[str]] = None,
- ) -> None:
- """Initialize CodebaseIndexer.
-
- Args:
- repo_path: Root of repository to index. Defaults to current directory.
- db_path: SQLite database path. Defaults to data/self_coding.db
- src_dirs: Source directories to index. Defaults to ["src", "tests"]
- """
- self.repo_path = Path(repo_path).resolve() if repo_path else Path.cwd()
- self.db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
- self.src_dirs = src_dirs or ["src", "tests"]
- self._ensure_schema()
- logger.info("CodebaseIndexer initialized for %s", self.repo_path)
-
- def _get_conn(self) -> sqlite3.Connection:
- """Get database connection with schema ensured."""
- self.db_path.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(str(self.db_path))
- conn.row_factory = sqlite3.Row
- return conn
-
- def _ensure_schema(self) -> None:
- """Create database tables if they don't exist."""
- with self._get_conn() as conn:
- # Main codebase index table
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS codebase_index (
- file_path TEXT PRIMARY KEY,
- module_name TEXT NOT NULL,
- classes JSON,
- functions JSON,
- imports JSON,
- test_coverage TEXT,
- last_indexed TIMESTAMP NOT NULL,
- content_hash TEXT NOT NULL,
- docstring TEXT,
- embedding BLOB
- )
- """
- )
-
- # Dependency graph table
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS dependency_graph (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- source_file TEXT NOT NULL,
- target_file TEXT NOT NULL,
- import_type TEXT NOT NULL,
- UNIQUE(source_file, target_file)
- )
- """
- )
-
- # Create indexes
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_module_name ON codebase_index(module_name)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_test_coverage ON codebase_index(test_coverage)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_deps_source ON dependency_graph(source_file)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_deps_target ON dependency_graph(target_file)"
- )
-
- conn.commit()
-
- def _compute_hash(self, content: str) -> str:
- """Compute SHA-256 hash of file content."""
- return hashlib.sha256(content.encode("utf-8")).hexdigest()
-
- def _find_python_files(self) -> list[Path]:
- """Find all Python files in source directories."""
- files = []
- for src_dir in self.src_dirs:
- src_path = self.repo_path / src_dir
- if src_path.exists():
- files.extend(src_path.rglob("*.py"))
- return sorted(files)
-
- def _find_test_file(self, source_file: Path) -> Optional[str]:
- """Find corresponding test file for a source file.
-
- Uses conventions:
- - src/x/y.py -> tests/test_x_y.py
- - src/x/y.py -> tests/x/test_y.py
- - src/x/y.py -> tests/test_y.py
- """
- rel_path = source_file.relative_to(self.repo_path)
-
- # Only look for tests for files in src/
- if not str(rel_path).startswith("src/"):
- return None
-
- # Try various test file naming conventions
- possible_tests = [
- # tests/test_module.py
- self.repo_path / "tests" / f"test_{source_file.stem}.py",
- # tests/test_path_module.py (flat)
- self.repo_path / "tests" / f"test_{'_'.join(rel_path.with_suffix('').parts[1:])}.py",
- ]
-
- # Try mirroring src structure in tests (tests/x/test_y.py)
- try:
- src_relative = rel_path.relative_to("src")
- possible_tests.append(
- self.repo_path / "tests" / src_relative.parent / f"test_{source_file.stem}.py"
- )
- except ValueError:
- pass
-
- for test_path in possible_tests:
- if test_path.exists():
- return str(test_path.relative_to(self.repo_path))
-
- return None
-
- def _parse_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef, is_method: bool = False) -> FunctionInfo:
- """Parse a function definition node."""
- args = []
-
- # Handle different Python versions' AST structures
- func_args = node.args
-
- # Positional args
- for arg in func_args.args:
- arg_str = arg.arg
- if arg.annotation:
- arg_str += f": {ast.unparse(arg.annotation)}"
- args.append(arg_str)
-
- # Keyword-only args
- for arg in func_args.kwonlyargs:
- arg_str = arg.arg
- if arg.annotation:
- arg_str += f": {ast.unparse(arg.annotation)}"
- args.append(arg_str)
-
- # Return type
- returns = None
- if node.returns:
- returns = ast.unparse(node.returns)
-
- # Docstring
- docstring = ast.get_docstring(node)
-
- return FunctionInfo(
- name=node.name,
- args=args,
- returns=returns,
- docstring=docstring,
- line_number=node.lineno,
- is_async=isinstance(node, ast.AsyncFunctionDef),
- is_method=is_method,
- )
-
- def _parse_class(self, node: ast.ClassDef) -> ClassInfo:
- """Parse a class definition node."""
- methods = []
-
- for item in node.body:
- if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
- methods.append(self._parse_function(item, is_method=True))
-
- # Get bases
- bases = [ast.unparse(base) for base in node.bases]
-
- return ClassInfo(
- name=node.name,
- methods=methods,
- docstring=ast.get_docstring(node),
- line_number=node.lineno,
- bases=bases,
- )
-
- def _parse_module(self, file_path: Path) -> Optional[ModuleInfo]:
- """Parse a Python module file.
-
- Args:
- file_path: Path to Python file
-
- Returns:
- ModuleInfo or None if parsing fails
- """
- try:
- content = file_path.read_text(encoding="utf-8")
- tree = ast.parse(content)
-
- # Compute module name from file path
- rel_path = file_path.relative_to(self.repo_path)
- module_name = str(rel_path.with_suffix("")).replace("/", ".")
-
- classes = []
- functions = []
- imports = []
-
- for node in ast.walk(tree):
- if isinstance(node, ast.Import):
- for alias in node.names:
- imports.append(alias.name)
- elif isinstance(node, ast.ImportFrom):
- module = node.module or ""
- for alias in node.names:
- imports.append(f"{module}.{alias.name}")
-
- # Get top-level definitions (not in classes)
- for node in tree.body:
- if isinstance(node, ast.ClassDef):
- classes.append(self._parse_class(node))
- elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
- functions.append(self._parse_function(node))
-
- # Get module docstring
- docstring = ast.get_docstring(tree)
-
- # Find test coverage
- test_coverage = self._find_test_file(file_path)
-
- return ModuleInfo(
- file_path=str(rel_path),
- module_name=module_name,
- classes=classes,
- functions=functions,
- imports=imports,
- docstring=docstring,
- test_coverage=test_coverage,
- )
-
- except SyntaxError as e:
- logger.warning("Syntax error in %s: %s", file_path, e)
- return None
- except Exception as e:
- logger.error("Failed to parse %s: %s", file_path, e)
- return None
-
- def _store_module(self, conn: sqlite3.Connection, module: ModuleInfo, content_hash: str) -> None:
- """Store module info in database."""
- conn.execute(
- """
- INSERT OR REPLACE INTO codebase_index
- (file_path, module_name, classes, functions, imports, test_coverage,
- last_indexed, content_hash, docstring)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- module.file_path,
- module.module_name,
- json.dumps([asdict(c) for c in module.classes]),
- json.dumps([asdict(f) for f in module.functions]),
- json.dumps(module.imports),
- module.test_coverage,
- datetime.now(timezone.utc).isoformat(),
- content_hash,
- module.docstring,
- ),
- )
-
- def _build_dependency_graph(self, conn: sqlite3.Connection) -> None:
- """Build and store dependency graph from imports."""
- # Clear existing graph
- conn.execute("DELETE FROM dependency_graph")
-
- # Get all modules
- rows = conn.execute("SELECT file_path, module_name, imports FROM codebase_index").fetchall()
-
- # Map module names to file paths
- module_to_file = {row["module_name"]: row["file_path"] for row in rows}
-
- # Also map without src/ prefix for package imports like myproject.utils
- module_to_file_alt = {}
- for row in rows:
- module_name = row["module_name"]
- if module_name.startswith("src."):
- alt_name = module_name[4:] # Remove "src." prefix
- module_to_file_alt[alt_name] = row["file_path"]
-
- # Build dependencies
- for row in rows:
- source_file = row["file_path"]
- imports = json.loads(row["imports"])
-
- for imp in imports:
- # Try to resolve import to a file
- # Handle both "module.name" and "module.name.Class" forms
-
- # First try exact match
- if imp in module_to_file:
- conn.execute(
- """
- INSERT OR IGNORE INTO dependency_graph
- (source_file, target_file, import_type)
- VALUES (?, ?, ?)
- """,
- (source_file, module_to_file[imp], "import"),
- )
- continue
-
- # Try alternative name (without src/ prefix)
- if imp in module_to_file_alt:
- conn.execute(
- """
- INSERT OR IGNORE INTO dependency_graph
- (source_file, target_file, import_type)
- VALUES (?, ?, ?)
- """,
- (source_file, module_to_file_alt[imp], "import"),
- )
- continue
-
- # Try prefix match (import myproject.utils.Helper -> myproject.utils)
- imp_parts = imp.split(".")
- for i in range(len(imp_parts), 0, -1):
- prefix = ".".join(imp_parts[:i])
-
- # Try original module name
- if prefix in module_to_file:
- conn.execute(
- """
- INSERT OR IGNORE INTO dependency_graph
- (source_file, target_file, import_type)
- VALUES (?, ?, ?)
- """,
- (source_file, module_to_file[prefix], "import"),
- )
- break
-
- # Try alternative name (without src/ prefix)
- if prefix in module_to_file_alt:
- conn.execute(
- """
- INSERT OR IGNORE INTO dependency_graph
- (source_file, target_file, import_type)
- VALUES (?, ?, ?)
- """,
- (source_file, module_to_file_alt[prefix], "import"),
- )
- break
-
- conn.commit()
-
- async def index_all(self) -> dict[str, int]:
- """Perform full reindex of all Python files.
-
- Returns:
- Dict with stats: {"indexed": int, "failed": int, "skipped": int}
- """
- logger.info("Starting full codebase index")
-
- files = self._find_python_files()
- stats = {"indexed": 0, "failed": 0, "skipped": 0}
-
- with self._get_conn() as conn:
- for file_path in files:
- try:
- content = file_path.read_text(encoding="utf-8")
- content_hash = self._compute_hash(content)
-
- # Check if file needs reindexing
- existing = conn.execute(
- "SELECT content_hash FROM codebase_index WHERE file_path = ?",
- (str(file_path.relative_to(self.repo_path)),),
- ).fetchone()
-
- if existing and existing["content_hash"] == content_hash:
- stats["skipped"] += 1
- continue
-
- module = self._parse_module(file_path)
- if module:
- self._store_module(conn, module, content_hash)
- stats["indexed"] += 1
- else:
- stats["failed"] += 1
-
- except Exception as e:
- logger.error("Failed to index %s: %s", file_path, e)
- stats["failed"] += 1
-
- # Build dependency graph
- self._build_dependency_graph(conn)
- conn.commit()
-
- logger.info(
- "Indexing complete: %(indexed)d indexed, %(failed)d failed, %(skipped)d skipped",
- stats,
- )
- return stats
-
- async def index_changed(self) -> dict[str, int]:
- """Perform incremental index of only changed files.
-
- Compares content hashes to detect changes.
-
- Returns:
- Dict with stats: {"indexed": int, "failed": int, "skipped": int}
- """
- logger.info("Starting incremental codebase index")
-
- files = self._find_python_files()
- stats = {"indexed": 0, "failed": 0, "skipped": 0}
-
- with self._get_conn() as conn:
- for file_path in files:
- try:
- rel_path = str(file_path.relative_to(self.repo_path))
- content = file_path.read_text(encoding="utf-8")
- content_hash = self._compute_hash(content)
-
- # Check if changed
- existing = conn.execute(
- "SELECT content_hash FROM codebase_index WHERE file_path = ?",
- (rel_path,),
- ).fetchone()
-
- if existing and existing["content_hash"] == content_hash:
- stats["skipped"] += 1
- continue
-
- module = self._parse_module(file_path)
- if module:
- self._store_module(conn, module, content_hash)
- stats["indexed"] += 1
- else:
- stats["failed"] += 1
-
- except Exception as e:
- logger.error("Failed to index %s: %s", file_path, e)
- stats["failed"] += 1
-
- # Rebuild dependency graph (some imports may have changed)
- self._build_dependency_graph(conn)
- conn.commit()
-
- logger.info(
- "Incremental indexing complete: %(indexed)d indexed, %(failed)d failed, %(skipped)d skipped",
- stats,
- )
- return stats
-
- async def get_summary(self, max_tokens: int = 4000) -> str:
- """Generate compressed codebase summary for LLM context.
-
- Lists modules, their purposes, key classes/functions, and test coverage.
- Keeps output under max_tokens (approximate).
-
- Args:
- max_tokens: Maximum approximate tokens for summary
-
- Returns:
- Summary string suitable for LLM context
- """
- with self._get_conn() as conn:
- rows = conn.execute(
- """
- SELECT file_path, module_name, classes, functions, test_coverage, docstring
- FROM codebase_index
- ORDER BY module_name
- """
- ).fetchall()
-
- lines = ["# Codebase Summary\n"]
- lines.append(f"Total modules: {len(rows)}\n")
- lines.append("---\n")
-
- for row in rows:
- module_name = row["module_name"]
- file_path = row["file_path"]
- docstring = row["docstring"]
- test_coverage = row["test_coverage"]
-
- lines.append(f"\n## {module_name}")
- lines.append(f"File: `{file_path}`")
-
- if test_coverage:
- lines.append(f"Tests: `{test_coverage}`")
- else:
- lines.append("Tests: None")
-
- if docstring:
- # Take first line of docstring
- first_line = docstring.split("\n")[0][:100]
- lines.append(f"Purpose: {first_line}")
-
- # Classes
- classes = json.loads(row["classes"])
- if classes:
- lines.append("Classes:")
- for cls in classes[:5]: # Limit to 5 classes
- methods = [m["name"] for m in cls["methods"][:3]]
- method_str = ", ".join(methods) + ("..." if len(cls["methods"]) > 3 else "")
- lines.append(f" - {cls['name']}({method_str})")
- if len(classes) > 5:
- lines.append(f" ... and {len(classes) - 5} more")
-
- # Functions
- functions = json.loads(row["functions"])
- if functions:
- func_names = [f["name"] for f in functions[:5]]
- func_str = ", ".join(func_names)
- if len(functions) > 5:
- func_str += f"... and {len(functions) - 5} more"
- lines.append(f"Functions: {func_str}")
-
- lines.append("")
-
- summary = "\n".join(lines)
-
- # Rough token estimation (1 token ≈ 4 characters)
- if len(summary) > max_tokens * 4:
- # Truncate with note
- summary = summary[:max_tokens * 4]
- summary += "\n\n[Summary truncated due to length]"
-
- return summary
-
- async def get_relevant_files(self, task_description: str, limit: int = 5) -> list[str]:
- """Find files relevant to a task description.
-
- Uses keyword matching and import relationships. In Phase 2,
- this will use semantic search with vector embeddings.
-
- Args:
- task_description: Natural language description of the task
- limit: Maximum number of files to return
-
- Returns:
- List of file paths sorted by relevance
- """
- # Simple keyword extraction for now
- keywords = set(task_description.lower().split())
- # Remove common words
- keywords -= {"the", "a", "an", "to", "in", "on", "at", "for", "with", "and", "or", "of", "is", "are"}
-
- with self._get_conn() as conn:
- rows = conn.execute(
- """
- SELECT file_path, module_name, classes, functions, docstring, test_coverage
- FROM codebase_index
- """
- ).fetchall()
-
- scored_files = []
-
- for row in rows:
- score = 0
- file_path = row["file_path"].lower()
- module_name = row["module_name"].lower()
- docstring = (row["docstring"] or "").lower()
-
- classes = json.loads(row["classes"])
- functions = json.loads(row["functions"])
-
- # Score based on keyword matches
- for keyword in keywords:
- if keyword in file_path:
- score += 3
- if keyword in module_name:
- score += 2
- if keyword in docstring:
- score += 2
-
- # Check class/function names
- for cls in classes:
- if keyword in cls["name"].lower():
- score += 2
- for method in cls["methods"]:
- if keyword in method["name"].lower():
- score += 1
-
- for func in functions:
- if keyword in func["name"].lower():
- score += 1
-
- # Boost files with test coverage (only if already matched)
- if score > 0 and row["test_coverage"]:
- score += 1
-
- if score > 0:
- scored_files.append((score, row["file_path"]))
-
- # Sort by score descending, return top N
- scored_files.sort(reverse=True, key=lambda x: x[0])
- return [f[1] for f in scored_files[:limit]]
-
- async def get_dependency_chain(self, file_path: str) -> list[str]:
- """Get all files that import the given file.
-
- Useful for understanding blast radius of changes.
-
- Args:
- file_path: Path to file (relative to repo root)
-
- Returns:
- List of file paths that import this file
- """
- with self._get_conn() as conn:
- rows = conn.execute(
- """
- SELECT source_file FROM dependency_graph
- WHERE target_file = ?
- """,
- (file_path,),
- ).fetchall()
-
- return [row["source_file"] for row in rows]
-
- async def has_test_coverage(self, file_path: str) -> bool:
- """Check if a file has corresponding test coverage.
-
- Args:
- file_path: Path to file (relative to repo root)
-
- Returns:
- True if test file exists, False otherwise
- """
- with self._get_conn() as conn:
- row = conn.execute(
- "SELECT test_coverage FROM codebase_index WHERE file_path = ?",
- (file_path,),
- ).fetchone()
-
- return row is not None and row["test_coverage"] is not None
-
- async def get_module_info(self, file_path: str) -> Optional[ModuleInfo]:
- """Get detailed info for a specific module.
-
- Args:
- file_path: Path to file (relative to repo root)
-
- Returns:
- ModuleInfo or None if not indexed
- """
- with self._get_conn() as conn:
- row = conn.execute(
- """
- SELECT file_path, module_name, classes, functions, imports,
- test_coverage, docstring
- FROM codebase_index
- WHERE file_path = ?
- """,
- (file_path,),
- ).fetchone()
-
- if not row:
- return None
-
- # Parse classes - convert dict methods to FunctionInfo objects
- classes_data = json.loads(row["classes"])
- classes = []
- for cls_data in classes_data:
- methods = [FunctionInfo(**m) for m in cls_data.get("methods", [])]
- cls_info = ClassInfo(
- name=cls_data["name"],
- methods=methods,
- docstring=cls_data.get("docstring"),
- line_number=cls_data.get("line_number", 0),
- bases=cls_data.get("bases", []),
- )
- classes.append(cls_info)
-
- # Parse functions
- functions_data = json.loads(row["functions"])
- functions = [FunctionInfo(**f) for f in functions_data]
-
- return ModuleInfo(
- file_path=row["file_path"],
- module_name=row["module_name"],
- classes=classes,
- functions=functions,
- imports=json.loads(row["imports"]),
- docstring=row["docstring"],
- test_coverage=row["test_coverage"],
- )
diff --git a/src/self_coding/git_safety.py b/src/self_coding/git_safety.py
deleted file mode 100644
index 253cd5be..00000000
--- a/src/self_coding/git_safety.py
+++ /dev/null
@@ -1,505 +0,0 @@
-"""Git Safety Layer — Atomic git operations with rollback capability.
-
-All self-modifications happen on feature branches. Only merge to main after
-full test suite passes. Snapshots enable rollback on failure.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import hashlib
-import logging
-import subprocess
-from dataclasses import dataclass
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True)
-class Snapshot:
- """Immutable snapshot of repository state before modification.
-
- Attributes:
- commit_hash: Git commit hash at snapshot time
- branch: Current branch name
- timestamp: When snapshot was taken
- test_status: Whether tests were passing at snapshot time
- test_output: Pytest output from test run
- clean: Whether working directory was clean
- """
- commit_hash: str
- branch: str
- timestamp: datetime
- test_status: bool
- test_output: str
- clean: bool
-
-
-class GitSafetyError(Exception):
- """Base exception for git safety operations."""
- pass
-
-
-class GitNotRepositoryError(GitSafetyError):
- """Raised when operation is attempted outside a git repository."""
- pass
-
-
-class GitDirtyWorkingDirectoryError(GitSafetyError):
- """Raised when working directory is not clean and clean_required=True."""
- pass
-
-
-class GitOperationError(GitSafetyError):
- """Raised when a git operation fails."""
- pass
-
-
-class GitSafety:
- """Safe git operations for self-modification workflows.
-
- All operations are atomic and support rollback. Self-modifications happen
- on feature branches named 'timmy/self-edit/{timestamp}'. Only merged to
- main after tests pass.
-
- Usage:
- safety = GitSafety(repo_path="/path/to/repo")
-
- # Take snapshot before changes
- snapshot = await safety.snapshot()
-
- # Create feature branch
- branch = await safety.create_branch(f"timmy/self-edit/{timestamp}")
-
- # Make changes, commit them
- await safety.commit("Add error handling", ["src/file.py"])
-
- # Run tests, merge if pass
- if tests_pass:
- await safety.merge_to_main(branch)
- else:
- await safety.rollback(snapshot)
- """
-
- def __init__(
- self,
- repo_path: Optional[str | Path] = None,
- main_branch: str = "main",
- test_command: str = "python -m pytest --tb=short -q",
- ) -> None:
- """Initialize GitSafety with repository path.
-
- Args:
- repo_path: Path to git repository. Defaults to current working directory.
- main_branch: Name of main branch (main, master, etc.)
- test_command: Command to run tests for snapshot validation
- """
- self.repo_path = Path(repo_path).resolve() if repo_path else Path.cwd()
- self.main_branch = main_branch
- self.test_command = test_command
- self._verify_git_repo()
- logger.info("GitSafety initialized for %s", self.repo_path)
-
- def _verify_git_repo(self) -> None:
- """Verify that repo_path is a git repository."""
- git_dir = self.repo_path / ".git"
- if not git_dir.exists():
- raise GitNotRepositoryError(
- f"{self.repo_path} is not a git repository"
- )
-
- async def _run_git(
- self,
- *args: str,
- check: bool = True,
- capture_output: bool = True,
- timeout: float = 30.0,
- ) -> subprocess.CompletedProcess:
- """Run a git command asynchronously.
-
- Args:
- *args: Git command arguments
- check: Whether to raise on non-zero exit
- capture_output: Whether to capture stdout/stderr
- timeout: Maximum time to wait for command
-
- Returns:
- CompletedProcess with returncode, stdout, stderr
-
- Raises:
- GitOperationError: If git command fails and check=True
- """
- cmd = ["git", *args]
- logger.debug("Running: %s", " ".join(cmd))
-
- try:
- proc = await asyncio.create_subprocess_exec(
- *cmd,
- cwd=self.repo_path,
- stdout=asyncio.subprocess.PIPE if capture_output else None,
- stderr=asyncio.subprocess.PIPE if capture_output else None,
- )
-
- stdout, stderr = await asyncio.wait_for(
- proc.communicate(),
- timeout=timeout,
- )
-
- result = subprocess.CompletedProcess(
- args=cmd,
- returncode=proc.returncode or 0,
- stdout=stdout.decode() if stdout else "",
- stderr=stderr.decode() if stderr else "",
- )
-
- if check and result.returncode != 0:
- raise GitOperationError(
- f"Git command failed: {' '.join(args)}\n"
- f"stdout: {result.stdout}\nstderr: {result.stderr}"
- )
-
- return result
-
- except asyncio.TimeoutError as e:
- proc.kill()
- raise GitOperationError(f"Git command timed out after {timeout}s: {' '.join(args)}") from e
-
- async def _run_shell(
- self,
- command: str,
- timeout: float = 120.0,
- ) -> subprocess.CompletedProcess:
- """Run a shell command asynchronously.
-
- Args:
- command: Shell command to run
- timeout: Maximum time to wait
-
- Returns:
- CompletedProcess with returncode, stdout, stderr
- """
- logger.debug("Running shell: %s", command)
-
- proc = await asyncio.create_subprocess_shell(
- command,
- cwd=self.repo_path,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
-
- stdout, stderr = await asyncio.wait_for(
- proc.communicate(),
- timeout=timeout,
- )
-
- return subprocess.CompletedProcess(
- args=command,
- returncode=proc.returncode or 0,
- stdout=stdout.decode(),
- stderr=stderr.decode(),
- )
-
- async def is_clean(self) -> bool:
- """Check if working directory is clean (no uncommitted changes).
-
- Returns:
- True if clean, False if there are uncommitted changes
- """
- result = await self._run_git("status", "--porcelain", check=False)
- return result.stdout.strip() == ""
-
- async def get_current_branch(self) -> str:
- """Get current git branch name.
-
- Returns:
- Current branch name
- """
- result = await self._run_git("branch", "--show-current")
- return result.stdout.strip()
-
- async def get_current_commit(self) -> str:
- """Get current commit hash.
-
- Returns:
- Full commit hash
- """
- result = await self._run_git("rev-parse", "HEAD")
- return result.stdout.strip()
-
- async def _run_tests(self) -> tuple[bool, str]:
- """Run test suite and return results.
-
- Returns:
- Tuple of (all_passed, test_output)
- """
- logger.info("Running tests: %s", self.test_command)
- result = await self._run_shell(self.test_command, timeout=300.0)
- passed = result.returncode == 0
- output = result.stdout + "\n" + result.stderr
-
- if passed:
- logger.info("Tests passed")
- else:
- logger.warning("Tests failed with returncode %d", result.returncode)
-
- return passed, output
-
- async def snapshot(self, run_tests: bool = True) -> Snapshot:
- """Take a snapshot of current repository state.
-
- Captures commit hash, branch, test status. Used for rollback if
- modifications fail.
-
- Args:
- run_tests: Whether to run tests as part of snapshot
-
- Returns:
- Snapshot object with current state
-
- Raises:
- GitOperationError: If git operations fail
- """
- logger.info("Taking snapshot of repository state")
-
- commit_hash = await self.get_current_commit()
- branch = await self.get_current_branch()
- clean = await self.is_clean()
- timestamp = datetime.now(timezone.utc)
-
- test_status = False
- test_output = ""
-
- if run_tests:
- test_status, test_output = await self._run_tests()
- else:
- test_status = True # Assume OK if not running tests
- test_output = "Tests skipped"
-
- snapshot = Snapshot(
- commit_hash=commit_hash,
- branch=branch,
- timestamp=timestamp,
- test_status=test_status,
- test_output=test_output,
- clean=clean,
- )
-
- logger.info(
- "Snapshot taken: %s@%s (clean=%s, tests=%s)",
- branch,
- commit_hash[:8],
- clean,
- test_status,
- )
-
- return snapshot
-
- async def create_branch(self, name: str, base: Optional[str] = None) -> str:
- """Create and checkout a new feature branch.
-
- Args:
- name: Branch name (e.g., 'timmy/self-edit/20260226-143022')
- base: Base branch to create from (defaults to main_branch)
-
- Returns:
- Name of created branch
-
- Raises:
- GitOperationError: If branch creation fails
- """
- base = base or self.main_branch
-
- # Ensure we're on base branch and it's up to date
- await self._run_git("checkout", base)
- await self._run_git("pull", "origin", base, check=False) # May fail if no remote
-
- # Create and checkout new branch
- await self._run_git("checkout", "-b", name)
-
- logger.info("Created branch %s from %s", name, base)
- return name
-
- async def commit(
- self,
- message: str,
- files: Optional[list[str | Path]] = None,
- allow_empty: bool = False,
- ) -> str:
- """Commit changes to current branch.
-
- Args:
- message: Commit message
- files: Specific files to commit (None = all changes)
- allow_empty: Whether to allow empty commits
-
- Returns:
- Commit hash of new commit
-
- Raises:
- GitOperationError: If commit fails
- """
- # Add files
- if files:
- for file_path in files:
- full_path = self.repo_path / file_path
- if not full_path.exists():
- logger.warning("File does not exist: %s", file_path)
- await self._run_git("add", str(file_path))
- else:
- await self._run_git("add", "-A")
-
- # Check if there's anything to commit
- if not allow_empty:
- diff_result = await self._run_git(
- "diff", "--cached", "--quiet", check=False
- )
- if diff_result.returncode == 0:
- logger.warning("No changes to commit")
- return await self.get_current_commit()
-
- # Commit
- commit_args = ["commit", "-m", message]
- if allow_empty:
- commit_args.append("--allow-empty")
-
- await self._run_git(*commit_args)
-
- commit_hash = await self.get_current_commit()
- logger.info("Committed %s: %s", commit_hash[:8], message)
-
- return commit_hash
-
- async def get_diff(self, from_hash: str, to_hash: Optional[str] = None) -> str:
- """Get diff between commits.
-
- Args:
- from_hash: Starting commit hash (or Snapshot object hash)
- to_hash: Ending commit hash (None = current)
-
- Returns:
- Git diff as string
- """
- args = ["diff", from_hash]
- if to_hash:
- args.append(to_hash)
-
- result = await self._run_git(*args)
- return result.stdout
-
- async def rollback(self, snapshot: Snapshot | str) -> str:
- """Rollback to a previous snapshot.
-
- Hard resets to the snapshot commit and deletes any uncommitted changes.
- Use with caution — this is destructive.
-
- Args:
- snapshot: Snapshot object or commit hash to rollback to
-
- Returns:
- Commit hash after rollback
-
- Raises:
- GitOperationError: If rollback fails
- """
- if isinstance(snapshot, Snapshot):
- target_hash = snapshot.commit_hash
- target_branch = snapshot.branch
- else:
- target_hash = snapshot
- target_branch = None
-
- logger.warning("Rolling back to %s", target_hash[:8])
-
- # Reset to target commit
- await self._run_git("reset", "--hard", target_hash)
-
- # Clean any untracked files
- await self._run_git("clean", "-fd")
-
- # If we know the original branch, switch back to it
- if target_branch:
- branch_exists = await self._run_git(
- "branch", "--list", target_branch, check=False
- )
- if branch_exists.stdout.strip():
- await self._run_git("checkout", target_branch)
- logger.info("Switched back to branch %s", target_branch)
-
- current = await self.get_current_commit()
- logger.info("Rolled back to %s", current[:8])
-
- return current
-
- async def merge_to_main(
- self,
- branch: str,
- require_tests: bool = True,
- ) -> str:
- """Merge a feature branch into main after tests pass.
-
- Args:
- branch: Feature branch to merge
- require_tests: Whether to require tests to pass before merging
-
- Returns:
- Merge commit hash
-
- Raises:
- GitOperationError: If merge fails or tests don't pass
- """
- logger.info("Preparing to merge %s into %s", branch, self.main_branch)
-
- # Checkout the feature branch and run tests
- await self._run_git("checkout", branch)
-
- if require_tests:
- passed, output = await self._run_tests()
- if not passed:
- raise GitOperationError(
- f"Cannot merge {branch}: tests failed\n{output}"
- )
-
- # Checkout main and merge
- await self._run_git("checkout", self.main_branch)
- await self._run_git("merge", "--no-ff", "-m", f"Merge {branch}", branch)
-
- # Optionally delete the feature branch
- await self._run_git("branch", "-d", branch, check=False)
-
- merge_hash = await self.get_current_commit()
- logger.info("Merged %s into %s: %s", branch, self.main_branch, merge_hash[:8])
-
- return merge_hash
-
- async def get_modified_files(self, since_hash: Optional[str] = None) -> list[str]:
- """Get list of files modified since a commit.
-
- Args:
- since_hash: Commit to compare against (None = uncommitted changes)
-
- Returns:
- List of modified file paths
- """
- if since_hash:
- result = await self._run_git(
- "diff", "--name-only", since_hash, "HEAD"
- )
- else:
- result = await self._run_git(
- "diff", "--name-only", "HEAD"
- )
-
- files = [f.strip() for f in result.stdout.split("\n") if f.strip()]
- return files
-
- async def stage_file(self, file_path: str | Path) -> None:
- """Stage a single file for commit.
-
- Args:
- file_path: Path to file relative to repo root
- """
- await self._run_git("add", str(file_path))
- logger.debug("Staged %s", file_path)
diff --git a/src/self_coding/modification_journal.py b/src/self_coding/modification_journal.py
deleted file mode 100644
index b0f6b661..00000000
--- a/src/self_coding/modification_journal.py
+++ /dev/null
@@ -1,425 +0,0 @@
-"""Modification Journal — Persistent log of self-modification attempts.
-
-Tracks successes and failures so Timmy can learn from experience.
-Supports semantic search for similar past attempts.
-"""
-
-from __future__ import annotations
-
-import json
-import logging
-import sqlite3
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from enum import Enum
-from pathlib import Path
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-# Default database location
-DEFAULT_DB_PATH = Path("data/self_coding.db")
-
-
-class Outcome(str, Enum):
- """Possible outcomes of a modification attempt."""
- SUCCESS = "success"
- FAILURE = "failure"
- ROLLBACK = "rollback"
-
-
-@dataclass
-class ModificationAttempt:
- """A single self-modification attempt.
-
- Attributes:
- id: Unique identifier (auto-generated by database)
- timestamp: When the attempt was made
- task_description: What was Timmy trying to do
- approach: Strategy/approach planned
- files_modified: List of file paths that were modified
- diff: The actual git diff of changes
- test_results: Pytest output
- outcome: success, failure, or rollback
- failure_analysis: LLM-generated analysis of why it failed
- reflection: LLM-generated lessons learned
- retry_count: Number of retry attempts
- embedding: Vector embedding of task_description (for semantic search)
- """
- task_description: str
- approach: str = ""
- files_modified: list[str] = field(default_factory=list)
- diff: str = ""
- test_results: str = ""
- outcome: Outcome = Outcome.FAILURE
- failure_analysis: str = ""
- reflection: str = ""
- retry_count: int = 0
- id: Optional[int] = None
- timestamp: Optional[datetime] = None
- embedding: Optional[bytes] = None
-
-
-class ModificationJournal:
- """Persistent log of self-modification attempts.
-
- Before any self-modification, Timmy should query the journal for
- similar past attempts and include relevant ones in the LLM context.
-
- Usage:
- journal = ModificationJournal()
-
- # Log an attempt
- attempt = ModificationAttempt(
- task_description="Add error handling",
- files_modified=["src/app.py"],
- outcome=Outcome.SUCCESS,
- )
- await journal.log_attempt(attempt)
-
- # Find similar past attempts
- similar = await journal.find_similar("Add error handling to endpoints")
-
- # Get success metrics
- metrics = await journal.get_success_rate()
- """
-
- def __init__(
- self,
- db_path: Optional[str | Path] = None,
- ) -> None:
- """Initialize ModificationJournal.
-
- Args:
- db_path: SQLite database path. Defaults to data/self_coding.db
- """
- self.db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
- self._ensure_schema()
- logger.info("ModificationJournal initialized at %s", self.db_path)
-
- def _get_conn(self) -> sqlite3.Connection:
- """Get database connection with schema ensured."""
- self.db_path.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(str(self.db_path))
- conn.row_factory = sqlite3.Row
- return conn
-
- def _ensure_schema(self) -> None:
- """Create database tables if they don't exist."""
- with self._get_conn() as conn:
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS modification_journal (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
- task_description TEXT NOT NULL,
- approach TEXT,
- files_modified JSON,
- diff TEXT,
- test_results TEXT,
- outcome TEXT CHECK(outcome IN ('success', 'failure', 'rollback')),
- failure_analysis TEXT,
- reflection TEXT,
- retry_count INTEGER DEFAULT 0,
- embedding BLOB
- )
- """
- )
-
- # Create indexes for common queries
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_journal_outcome ON modification_journal(outcome)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_journal_timestamp ON modification_journal(timestamp)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_journal_task ON modification_journal(task_description)"
- )
-
- conn.commit()
-
- async def log_attempt(self, attempt: ModificationAttempt) -> int:
- """Log a modification attempt to the journal.
-
- Args:
- attempt: The modification attempt to log
-
- Returns:
- ID of the logged entry
- """
- with self._get_conn() as conn:
- cursor = conn.execute(
- """
- INSERT INTO modification_journal
- (task_description, approach, files_modified, diff, test_results,
- outcome, failure_analysis, reflection, retry_count, embedding)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- attempt.task_description,
- attempt.approach,
- json.dumps(attempt.files_modified),
- attempt.diff,
- attempt.test_results,
- attempt.outcome.value,
- attempt.failure_analysis,
- attempt.reflection,
- attempt.retry_count,
- attempt.embedding,
- ),
- )
- conn.commit()
-
- attempt_id = cursor.lastrowid
- logger.info(
- "Logged modification attempt %d: %s (%s)",
- attempt_id,
- attempt.task_description[:50],
- attempt.outcome.value,
- )
- return attempt_id
-
- async def find_similar(
- self,
- task_description: str,
- limit: int = 5,
- include_outcomes: Optional[list[Outcome]] = None,
- ) -> list[ModificationAttempt]:
- """Find similar past modification attempts.
-
- Uses keyword matching for now. In Phase 2, will use vector embeddings
- for semantic search.
-
- Args:
- task_description: Task to find similar attempts for
- limit: Maximum number of results
- include_outcomes: Filter by outcomes (None = all)
-
- Returns:
- List of similar modification attempts
- """
- # Extract keywords from task description
- keywords = set(task_description.lower().split())
- keywords -= {"the", "a", "an", "to", "in", "on", "at", "for", "with", "and", "or", "of", "is", "are"}
-
- with self._get_conn() as conn:
- # Build query
- if include_outcomes:
- outcome_filter = "AND outcome IN ({})".format(
- ",".join("?" * len(include_outcomes))
- )
- outcome_values = [o.value for o in include_outcomes]
- else:
- outcome_filter = ""
- outcome_values = []
-
- rows = conn.execute(
- f"""
- SELECT id, timestamp, task_description, approach, files_modified,
- diff, test_results, outcome, failure_analysis, reflection,
- retry_count
- FROM modification_journal
- WHERE 1=1 {outcome_filter}
- ORDER BY timestamp DESC
- LIMIT ?
- """,
- outcome_values + [limit * 3], # Get more for scoring
- ).fetchall()
-
- # Score by keyword match
- scored = []
- for row in rows:
- score = 0
- task = row["task_description"].lower()
- approach = (row["approach"] or "").lower()
-
- for kw in keywords:
- if kw in task:
- score += 3
- if kw in approach:
- score += 1
-
- # Boost recent attempts (only if already matched)
- if score > 0:
- timestamp = datetime.fromisoformat(row["timestamp"])
- if timestamp.tzinfo is None:
- timestamp = timestamp.replace(tzinfo=timezone.utc)
- age_days = (datetime.now(timezone.utc) - timestamp).days
- if age_days < 7:
- score += 2
- elif age_days < 30:
- score += 1
-
- if score > 0:
- scored.append((score, row))
-
- # Sort by score, take top N
- scored.sort(reverse=True, key=lambda x: x[0])
- top_rows = scored[:limit]
-
- # Convert to ModificationAttempt objects
- return [self._row_to_attempt(row) for _, row in top_rows]
-
- async def get_success_rate(self) -> dict[str, float]:
- """Get success rate metrics.
-
- Returns:
- Dict with overall and per-category success rates:
- {
- "overall": float, # 0.0 to 1.0
- "success": int, # count
- "failure": int, # count
- "rollback": int, # count
- "total": int, # total attempts
- }
- """
- with self._get_conn() as conn:
- rows = conn.execute(
- """
- SELECT outcome, COUNT(*) as count
- FROM modification_journal
- GROUP BY outcome
- """
- ).fetchall()
-
- counts = {row["outcome"]: row["count"] for row in rows}
-
- success = counts.get("success", 0)
- failure = counts.get("failure", 0)
- rollback = counts.get("rollback", 0)
- total = success + failure + rollback
-
- overall = success / total if total > 0 else 0.0
-
- return {
- "overall": overall,
- "success": success,
- "failure": failure,
- "rollback": rollback,
- "total": total,
- }
-
- async def get_recent_failures(self, limit: int = 10) -> list[ModificationAttempt]:
- """Get recent failed attempts with their analyses.
-
- Args:
- limit: Maximum number of failures to return
-
- Returns:
- List of failed modification attempts
- """
- with self._get_conn() as conn:
- rows = conn.execute(
- """
- SELECT id, timestamp, task_description, approach, files_modified,
- diff, test_results, outcome, failure_analysis, reflection,
- retry_count
- FROM modification_journal
- WHERE outcome IN ('failure', 'rollback')
- ORDER BY timestamp DESC
- LIMIT ?
- """,
- (limit,),
- ).fetchall()
-
- return [self._row_to_attempt(row) for row in rows]
-
- async def get_by_id(self, attempt_id: int) -> Optional[ModificationAttempt]:
- """Get a specific modification attempt by ID.
-
- Args:
- attempt_id: ID of the attempt
-
- Returns:
- ModificationAttempt or None if not found
- """
- with self._get_conn() as conn:
- row = conn.execute(
- """
- SELECT id, timestamp, task_description, approach, files_modified,
- diff, test_results, outcome, failure_analysis, reflection,
- retry_count
- FROM modification_journal
- WHERE id = ?
- """,
- (attempt_id,),
- ).fetchone()
-
- if not row:
- return None
-
- return self._row_to_attempt(row)
-
- async def update_reflection(self, attempt_id: int, reflection: str) -> bool:
- """Update the reflection for a modification attempt.
-
- Args:
- attempt_id: ID of the attempt
- reflection: New reflection text
-
- Returns:
- True if updated, False if not found
- """
- with self._get_conn() as conn:
- cursor = conn.execute(
- """
- UPDATE modification_journal
- SET reflection = ?
- WHERE id = ?
- """,
- (reflection, attempt_id),
- )
- conn.commit()
-
- if cursor.rowcount > 0:
- logger.info("Updated reflection for attempt %d", attempt_id)
- return True
- return False
-
- async def get_attempts_for_file(
- self,
- file_path: str,
- limit: int = 10,
- ) -> list[ModificationAttempt]:
- """Get all attempts that modified a specific file.
-
- Args:
- file_path: Path to file (relative to repo root)
- limit: Maximum number of attempts
-
- Returns:
- List of modification attempts affecting this file
- """
- with self._get_conn() as conn:
- # Try exact match first, then partial match
- rows = conn.execute(
- """
- SELECT id, timestamp, task_description, approach, files_modified,
- diff, test_results, outcome, failure_analysis, reflection,
- retry_count
- FROM modification_journal
- WHERE files_modified LIKE ? OR files_modified LIKE ?
- ORDER BY timestamp DESC
- LIMIT ?
- """,
- (f'%"{file_path}"%', f'%{file_path}%', limit),
- ).fetchall()
-
- return [self._row_to_attempt(row) for row in rows]
-
- def _row_to_attempt(self, row: sqlite3.Row) -> ModificationAttempt:
- """Convert a database row to ModificationAttempt."""
- return ModificationAttempt(
- id=row["id"],
- timestamp=datetime.fromisoformat(row["timestamp"]),
- task_description=row["task_description"],
- approach=row["approach"] or "",
- files_modified=json.loads(row["files_modified"] or "[]"),
- diff=row["diff"] or "",
- test_results=row["test_results"] or "",
- outcome=Outcome(row["outcome"]),
- failure_analysis=row["failure_analysis"] or "",
- reflection=row["reflection"] or "",
- retry_count=row["retry_count"] or 0,
- )
diff --git a/src/self_coding/reflection.py b/src/self_coding/reflection.py
deleted file mode 100644
index 182e7ac1..00000000
--- a/src/self_coding/reflection.py
+++ /dev/null
@@ -1,259 +0,0 @@
-"""Reflection Service — Generate lessons learned from modification attempts.
-
-After every self-modification (success or failure), the Reflection Service
-prompts an LLM to analyze the attempt and extract actionable insights.
-"""
-
-from __future__ import annotations
-
-import logging
-from typing import Optional
-
-from self_coding.modification_journal import ModificationAttempt, Outcome
-
-logger = logging.getLogger(__name__)
-
-
-REFLECTION_SYSTEM_PROMPT = """You are a software engineering mentor analyzing a self-modification attempt.
-
-Your goal is to provide constructive, specific feedback that helps improve future attempts.
-Focus on patterns and principles rather than one-off issues.
-
-Be concise but insightful. Maximum 300 words."""
-
-
-REFLECTION_PROMPT_TEMPLATE = """A software agent just attempted to modify its own source code.
-
-Task: {task_description}
-Approach: {approach}
-Files modified: {files_modified}
-Outcome: {outcome}
-Test results: {test_results}
-{failure_section}
-
-Reflect on this attempt:
-1. What went well? (Be specific about techniques or strategies)
-2. What could be improved? (Focus on process, not just the code)
-3. What would you do differently next time?
-4. What general lesson can be extracted for future similar tasks?
-
-Provide your reflection in a structured format:
-
-**What went well:**
-[Your analysis]
-
-**What could be improved:**
-[Your analysis]
-
-**Next time:**
-[Specific actionable change]
-
-**General lesson:**
-[Extracted principle for similar tasks]"""
-
-
-class ReflectionService:
- """Generates reflections on self-modification attempts.
-
- Uses an LLM to analyze attempts and extract lessons learned.
- Stores reflections in the Modification Journal for future reference.
-
- Usage:
- from self_coding.reflection import ReflectionService
- from timmy.cascade_adapter import TimmyCascadeAdapter
-
- adapter = TimmyCascadeAdapter()
- reflection_service = ReflectionService(llm_adapter=adapter)
-
- # After a modification attempt
- reflection_text = await reflection_service.reflect_on_attempt(attempt)
-
- # Store in journal
- await journal.update_reflection(attempt_id, reflection_text)
- """
-
- def __init__(
- self,
- llm_adapter: Optional[object] = None,
- model_preference: str = "fast", # "fast" or "quality"
- ) -> None:
- """Initialize ReflectionService.
-
- Args:
- llm_adapter: LLM adapter (e.g., TimmyCascadeAdapter)
- model_preference: "fast" for quick reflections, "quality" for deeper analysis
- """
- self.llm_adapter = llm_adapter
- self.model_preference = model_preference
- logger.info("ReflectionService initialized")
-
- async def reflect_on_attempt(self, attempt: ModificationAttempt) -> str:
- """Generate a reflection on a modification attempt.
-
- Args:
- attempt: The modification attempt to reflect on
-
- Returns:
- Reflection text (structured markdown)
- """
- # Build the prompt
- failure_section = ""
- if attempt.outcome == Outcome.FAILURE and attempt.failure_analysis:
- failure_section = f"\nFailure analysis: {attempt.failure_analysis}"
-
- prompt = REFLECTION_PROMPT_TEMPLATE.format(
- task_description=attempt.task_description,
- approach=attempt.approach or "(No approach documented)",
- files_modified=", ".join(attempt.files_modified) if attempt.files_modified else "(No files modified)",
- outcome=attempt.outcome.value.upper(),
- test_results=attempt.test_results[:500] if attempt.test_results else "(No test results)",
- failure_section=failure_section,
- )
-
- # Call LLM if available
- if self.llm_adapter:
- try:
- response = await self.llm_adapter.chat(
- message=prompt,
- context=REFLECTION_SYSTEM_PROMPT,
- )
- reflection = response.content.strip()
- logger.info("Generated reflection for attempt (via %s)",
- response.provider_used)
- return reflection
- except Exception as e:
- logger.error("LLM reflection failed: %s", e)
- return self._generate_fallback_reflection(attempt)
- else:
- # No LLM available, use fallback
- return self._generate_fallback_reflection(attempt)
-
- def _generate_fallback_reflection(self, attempt: ModificationAttempt) -> str:
- """Generate a basic reflection without LLM.
-
- Used when no LLM adapter is available or LLM call fails.
-
- Args:
- attempt: The modification attempt
-
- Returns:
- Basic reflection text
- """
- if attempt.outcome == Outcome.SUCCESS:
- return f"""**What went well:**
-Successfully completed: {attempt.task_description}
-Files modified: {', '.join(attempt.files_modified) if attempt.files_modified else 'N/A'}
-
-**What could be improved:**
-Document the approach taken for future reference.
-
-**Next time:**
-Use the same pattern for similar tasks.
-
-**General lesson:**
-Modifications to {', '.join(attempt.files_modified) if attempt.files_modified else 'these files'} should include proper test coverage."""
-
- elif attempt.outcome == Outcome.FAILURE:
- return f"""**What went well:**
-Attempted: {attempt.task_description}
-
-**What could be improved:**
-The modification failed after {attempt.retry_count} retries.
-{attempt.failure_analysis if attempt.failure_analysis else 'Failure reason not documented.'}
-
-**Next time:**
-Consider breaking the task into smaller steps.
-Validate approach with simpler test case first.
-
-**General lesson:**
-Changes affecting {', '.join(attempt.files_modified) if attempt.files_modified else 'multiple files'} require careful dependency analysis."""
-
- else: # ROLLBACK
- return f"""**What went well:**
-Recognized failure and rolled back to maintain stability.
-
-**What could be improved:**
-Early detection of issues before full implementation.
-
-**Next time:**
-Run tests more frequently during development.
-Use smaller incremental commits.
-
-**General lesson:**
-Rollback is preferable to shipping broken code."""
-
- async def reflect_with_context(
- self,
- attempt: ModificationAttempt,
- similar_attempts: list[ModificationAttempt],
- ) -> str:
- """Generate reflection with context from similar past attempts.
-
- Includes relevant past reflections to build cumulative learning.
-
- Args:
- attempt: The current modification attempt
- similar_attempts: Similar past attempts (with reflections)
-
- Returns:
- Reflection text incorporating past learnings
- """
- # Build context from similar attempts
- context_parts = []
- for past in similar_attempts[:3]: # Top 3 similar
- if past.reflection:
- context_parts.append(
- f"Past similar task ({past.outcome.value}):\n"
- f"Task: {past.task_description}\n"
- f"Lesson: {past.reflection[:200]}..."
- )
-
- context = "\n\n".join(context_parts)
-
- # Build enhanced prompt
- failure_section = ""
- if attempt.outcome == Outcome.FAILURE and attempt.failure_analysis:
- failure_section = f"\nFailure analysis: {attempt.failure_analysis}"
-
- enhanced_prompt = f"""A software agent just attempted to modify its own source code.
-
-Task: {attempt.task_description}
-Approach: {attempt.approach or "(No approach documented)"}
-Files modified: {', '.join(attempt.files_modified) if attempt.files_modified else "(No files modified)"}
-Outcome: {attempt.outcome.value.upper()}
-Test results: {attempt.test_results[:500] if attempt.test_results else "(No test results)"}
-{failure_section}
-
----
-
-Relevant past attempts:
-
-{context if context else "(No similar past attempts)"}
-
----
-
-Given this history, reflect on the current attempt:
-1. What went well?
-2. What could be improved?
-3. How does this compare to past similar attempts?
-4. What pattern or principle should guide future similar tasks?
-
-Provide your reflection in a structured format:
-
-**What went well:**
-**What could be improved:**
-**Comparison to past attempts:**
-**Guiding principle:**"""
-
- if self.llm_adapter:
- try:
- response = await self.llm_adapter.chat(
- message=enhanced_prompt,
- context=REFLECTION_SYSTEM_PROMPT,
- )
- return response.content.strip()
- except Exception as e:
- logger.error("LLM reflection with context failed: %s", e)
- return await self.reflect_on_attempt(attempt)
- else:
- return await self.reflect_on_attempt(attempt)
diff --git a/src/self_coding/self_modify/__init__.py b/src/self_coding/self_modify/__init__.py
deleted file mode 100644
index 7dbb1735..00000000
--- a/src/self_coding/self_modify/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Self-Modify — Runtime self-modification with safety constraints."""
diff --git a/src/self_coding/self_modify/cli.py b/src/self_coding/self_modify/cli.py
deleted file mode 100644
index e0f6fe62..00000000
--- a/src/self_coding/self_modify/cli.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""CLI for self-modification — run from the terminal.
-
-Usage:
- self-modify run "Add a docstring to src/timmy/prompts.py" --file src/timmy/prompts.py
- self-modify run "Fix the bug in config" --dry-run
- self-modify run "Add logging" --backend anthropic --autonomous
- self-modify status
-"""
-
-import logging
-import os
-from typing import Optional
-
-import typer
-from rich.console import Console
-from rich.panel import Panel
-
-console = Console()
-app = typer.Typer(help="Timmy self-modify — edit code, run tests, commit")
-
-
-@app.command()
-def run(
- instruction: str = typer.Argument(..., help="What to change (natural language)"),
- file: Optional[list[str]] = typer.Option(None, "--file", "-f", help="Target file(s) to modify"),
- dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Generate edits but don't write"),
- retries: int = typer.Option(2, "--retries", "-r", help="Max retry attempts on test failure"),
- backend: Optional[str] = typer.Option(None, "--backend", "-b", help="LLM backend: ollama, anthropic, auto"),
- autonomous: bool = typer.Option(False, "--autonomous", "-a", help="Enable autonomous self-correction"),
- max_cycles: int = typer.Option(3, "--max-cycles", help="Max autonomous correction cycles"),
- branch: bool = typer.Option(False, "--branch", help="Create a git branch (off by default to avoid container restarts)"),
- speak: bool = typer.Option(False, "--speak", "-s", help="Speak the result via TTS"),
-):
- """Run the self-modification loop."""
- # Force enable for CLI usage
- os.environ["SELF_MODIFY_ENABLED"] = "true"
-
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s %(levelname)-8s %(name)s -- %(message)s",
- datefmt="%H:%M:%S",
- )
-
- # Skip branch creation unless explicitly requested
- if not branch:
- os.environ["SELF_MODIFY_SKIP_BRANCH"] = "1"
-
- from self_coding.self_modify.loop import SelfModifyLoop, ModifyRequest
-
- target_files = list(file) if file else []
- effective_backend = backend or os.environ.get("SELF_MODIFY_BACKEND", "auto")
-
- console.print(Panel(
- f"[bold]Instruction:[/bold] {instruction}\n"
- f"[bold]Files:[/bold] {', '.join(target_files) or '(auto-detect)'}\n"
- f"[bold]Backend:[/bold] {effective_backend}\n"
- f"[bold]Autonomous:[/bold] {autonomous}\n"
- f"[bold]Dry run:[/bold] {dry_run}\n"
- f"[bold]Max retries:[/bold] {retries}",
- title="Self-Modify",
- border_style="cyan",
- ))
-
- loop = SelfModifyLoop(
- max_retries=retries,
- backend=effective_backend,
- autonomous=autonomous,
- max_autonomous_cycles=max_cycles,
- )
- request = ModifyRequest(
- instruction=instruction,
- target_files=target_files,
- dry_run=dry_run,
- )
-
- with console.status("[bold cyan]Running self-modification loop..."):
- result = loop.run(request)
-
- if result.report_path:
- console.print(f"\n[dim]Report saved: {result.report_path}[/dim]\n")
-
- if result.success:
- console.print(Panel(
- f"[green bold]SUCCESS[/green bold]\n\n"
- f"Files changed: {', '.join(result.files_changed)}\n"
- f"Tests passed: {result.test_passed}\n"
- f"Commit: {result.commit_sha or 'none (dry run)'}\n"
- f"Branch: {result.branch_name or 'current'}\n"
- f"Attempts: {result.attempts}\n"
- f"Autonomous cycles: {result.autonomous_cycles}",
- title="Result",
- border_style="green",
- ))
- else:
- console.print(Panel(
- f"[red bold]FAILED[/red bold]\n\n"
- f"Error: {result.error}\n"
- f"Attempts: {result.attempts}\n"
- f"Autonomous cycles: {result.autonomous_cycles}",
- title="Result",
- border_style="red",
- ))
- raise typer.Exit(1)
-
- if speak and result.success:
- try:
- from timmy_serve.voice_tts import voice_tts
- if voice_tts.available:
- voice_tts.speak_sync(
- f"Code modification complete. "
- f"{len(result.files_changed)} files changed. Tests passing."
- )
- except Exception:
- pass
-
-
-@app.command()
-def status():
- """Show whether self-modification is enabled."""
- from config import settings
- enabled = settings.self_modify_enabled
- color = "green" if enabled else "red"
- console.print(f"Self-modification: [{color}]{'ENABLED' if enabled else 'DISABLED'}[/{color}]")
- console.print(f"Max retries: {settings.self_modify_max_retries}")
- console.print(f"Backend: {settings.self_modify_backend}")
- console.print(f"Allowed dirs: {settings.self_modify_allowed_dirs}")
-
-
-def main():
- app()
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/self_coding/self_modify/loop.py b/src/self_coding/self_modify/loop.py
deleted file mode 100644
index 95ae6e5e..00000000
--- a/src/self_coding/self_modify/loop.py
+++ /dev/null
@@ -1,741 +0,0 @@
-"""Self-modification loop — read source, generate edits, test, commit.
-
-Orchestrates the full cycle for Timmy to modify its own codebase:
-1. Create a working git branch
-2. Read target source files
-3. Send instruction + source to the LLM
-4. Validate syntax before writing
-5. Write edits to disk
-6. Run pytest
-7. On success -> git add + commit; on failure -> revert
-8. On total failure -> diagnose from report, restart autonomously
-
-Supports multiple LLM backends:
-- "ollama" — local Ollama (default, sovereign)
-- "anthropic" — Claude API via Anthropic SDK
-- "auto" — try anthropic first (if key set), fall back to ollama
-
-Reports are saved to data/self_modify_reports/ for debugging.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-import re
-import subprocess
-import sys
-import threading
-import time
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Optional
-
-from config import settings
-
-logger = logging.getLogger(__name__)
-
-# Project root — use settings.repo_root (works in Docker and local dev)
-PROJECT_ROOT = Path(settings.repo_root)
-
-# Reports directory
-REPORTS_DIR = PROJECT_ROOT / "data" / "self_modify_reports"
-
-# Only one self-modification at a time
-_LOCK = threading.Lock()
-
-# Maximum file size we'll send to the LLM (bytes)
-_MAX_FILE_SIZE = 50_000
-
-# Delimiter format the LLM is instructed to use
-_FILE_BLOCK_RE = re.compile(
- r"---\s*FILE:\s*(.+?)\s*---\n(.*?)---\s*END\s*FILE\s*---",
- re.DOTALL,
-)
-
-# Backend type literal
-BACKENDS = ("ollama", "anthropic", "auto")
-
-
-@dataclass
-class ModifyRequest:
- """A request to modify code."""
-
- instruction: str
- target_files: list[str] = field(default_factory=list)
- dry_run: bool = False
-
-
-@dataclass
-class ModifyResult:
- """Result of a self-modification attempt."""
-
- success: bool
- files_changed: list[str] = field(default_factory=list)
- test_passed: bool = False
- commit_sha: Optional[str] = None
- branch_name: Optional[str] = None
- error: Optional[str] = None
- llm_response: str = ""
- attempts: int = 0
- report_path: Optional[str] = None
- autonomous_cycles: int = 0
-
-
-class SelfModifyLoop:
- """Orchestrates the read -> edit -> test -> commit cycle.
-
- Supports autonomous self-correction: when all retries fail, reads its own
- failure report, diagnoses the root cause, and restarts with a corrected
- instruction.
- """
-
- def __init__(
- self,
- repo_path: Optional[Path] = None,
- max_retries: Optional[int] = None,
- backend: Optional[str] = None,
- autonomous: bool = False,
- max_autonomous_cycles: int = 3,
- ) -> None:
- self._repo_path = repo_path or PROJECT_ROOT
- self._max_retries = (
- max_retries if max_retries is not None else settings.self_modify_max_retries
- )
- self._allowed_dirs = [
- d.strip() for d in settings.self_modify_allowed_dirs.split(",") if d.strip()
- ]
- self._run_id = f"{int(time.time())}"
- self._attempt_reports: list[dict] = []
- self._backend = backend or settings.self_modify_backend
- self._autonomous = autonomous
- self._max_autonomous_cycles = max_autonomous_cycles
-
- # ── Public API ────────────────────────────────────────────────────────────
-
- def run(self, request: ModifyRequest) -> ModifyResult:
- """Execute the full self-modification loop."""
- if not settings.self_modify_enabled:
- return ModifyResult(
- success=False,
- error="Self-modification is disabled. Set SELF_MODIFY_ENABLED=true.",
- )
-
- if not _LOCK.acquire(blocking=False):
- return ModifyResult(
- success=False,
- error="Another self-modification is already running.",
- )
-
- try:
- result = self._run_locked(request)
- report_path = self._save_report(request, result)
- result.report_path = str(report_path)
-
- # Autonomous mode: if failed, diagnose and restart
- if self._autonomous and not result.success and not request.dry_run:
- result = self._autonomous_loop(request, result, report_path)
-
- return result
- finally:
- _LOCK.release()
-
- # ── Autonomous self-correction ─────────────────────────────────────────
-
- def _autonomous_loop(
- self, original_request: ModifyRequest, last_result: ModifyResult, last_report: Path
- ) -> ModifyResult:
- """Read the failure report, diagnose, and restart with a fix."""
- for cycle in range(1, self._max_autonomous_cycles + 1):
- logger.info("Autonomous cycle %d/%d", cycle, self._max_autonomous_cycles)
-
- # Diagnose what went wrong
- diagnosis = self._diagnose_failure(last_report)
- if not diagnosis:
- logger.warning("Could not diagnose failure, stopping autonomous loop")
- last_result.autonomous_cycles = cycle
- return last_result
-
- logger.info("Diagnosis: %s", diagnosis[:200])
-
- # Build a corrected instruction
- corrected_instruction = (
- f"{original_request.instruction}\n\n"
- f"IMPORTANT CORRECTION from previous failure:\n{diagnosis}"
- )
-
- # Reset attempt reports for this cycle
- self._attempt_reports = []
-
- corrected_request = ModifyRequest(
- instruction=corrected_instruction,
- target_files=original_request.target_files,
- dry_run=original_request.dry_run,
- )
-
- result = self._run_locked(corrected_request)
- report_path = self._save_report(corrected_request, result)
- result.report_path = str(report_path)
- result.autonomous_cycles = cycle
-
- if result.success:
- logger.info("Autonomous cycle %d succeeded!", cycle)
- return result
-
- last_result = result
- last_report = report_path
-
- logger.warning("Autonomous loop exhausted after %d cycles", self._max_autonomous_cycles)
- return last_result
-
- def _diagnose_failure(self, report_path: Path) -> Optional[str]:
- """Read a failure report and produce a diagnosis + fix instruction.
-
- Uses the best available LLM to analyze the report. This is the
- 'meta-reasoning' step — the agent reasoning about its own failures.
- """
- try:
- report_text = report_path.read_text(encoding="utf-8")
- except Exception as exc:
- logger.error("Could not read report %s: %s", report_path, exc)
- return None
-
- # Truncate to keep within context limits
- if len(report_text) > 8000:
- report_text = report_text[:8000] + "\n... (truncated)"
-
- diagnosis_prompt = f"""You are a code debugging expert. Analyze this self-modification failure report and provide a concise diagnosis.
-
-FAILURE REPORT:
-{report_text}
-
-Analyze the report and provide:
-1. ROOT CAUSE: What specifically went wrong (syntax error, logic error, missing import, etc.)
-2. FIX INSTRUCTIONS: Exact instructions for a code-generation LLM to avoid this mistake.
- Be very specific — e.g. "Do NOT start the file with triple-quotes" or
- "The em-dash character U+2014 must stay INSIDE a string literal, never outside one."
-
-Keep your response under 500 words. Focus on actionable fix instructions."""
-
- try:
- raw = self._call_llm(diagnosis_prompt)
- return raw.strip() if raw else None
- except Exception as exc:
- logger.error("Diagnosis LLM call failed: %s", exc)
- return None
-
- # ── Internal orchestration ────────────────────────────────────────────────
-
- def _run_locked(self, request: ModifyRequest) -> ModifyResult:
- branch_name = None
- attempt = 0
-
- # Skip branch creation — writing files triggers container restarts
- # which kills the process mid-operation. Work on the current branch.
- if not os.environ.get("SELF_MODIFY_SKIP_BRANCH"):
- try:
- branch_name = self._create_branch()
- except Exception as exc:
- logger.warning("Could not create branch: %s (continuing on current)", exc)
-
- # Resolve target files
- target_files = request.target_files or self._infer_target_files(
- request.instruction
- )
- if not target_files:
- return ModifyResult(
- success=False,
- error="No target files identified. Specify target_files or use more specific language.",
- branch_name=branch_name,
- )
-
- # Validate paths
- try:
- self._validate_paths(target_files)
- except ValueError as exc:
- return ModifyResult(success=False, error=str(exc), branch_name=branch_name)
-
- last_test_output = ""
- last_llm_response = ""
- last_syntax_errors: dict[str, str] = {}
-
- while attempt <= self._max_retries:
- attempt += 1
- logger.info(
- "Self-modify attempt %d/%d: %s",
- attempt,
- self._max_retries + 1,
- request.instruction[:80],
- )
-
- # Read current contents
- file_contents = self._read_files(target_files)
- if not file_contents:
- return ModifyResult(
- success=False,
- error="Could not read any target files.",
- branch_name=branch_name,
- attempts=attempt,
- )
-
- # Generate edits via LLM
- try:
- edits, llm_response = self._generate_edits(
- request.instruction, file_contents,
- prev_test_output=last_test_output if attempt > 1 else None,
- prev_syntax_errors=last_syntax_errors if attempt > 1 else None,
- )
- last_llm_response = llm_response
- except Exception as exc:
- self._attempt_reports.append({
- "attempt": attempt,
- "phase": "llm_generation",
- "error": str(exc),
- })
- return ModifyResult(
- success=False,
- error=f"LLM generation failed: {exc}",
- branch_name=branch_name,
- attempts=attempt,
- )
-
- if not edits:
- self._attempt_reports.append({
- "attempt": attempt,
- "phase": "parse_edits",
- "error": "No file edits parsed from LLM response",
- "llm_response": llm_response,
- })
- return ModifyResult(
- success=False,
- error="LLM produced no file edits.",
- llm_response=llm_response,
- branch_name=branch_name,
- attempts=attempt,
- )
-
- # Syntax validation — check BEFORE writing to disk
- syntax_errors = self._validate_syntax(edits)
- if syntax_errors:
- last_syntax_errors = syntax_errors
- error_summary = "; ".join(
- f"{fp}: {err}" for fp, err in syntax_errors.items()
- )
- logger.warning("Syntax errors in LLM output: %s", error_summary)
- self._attempt_reports.append({
- "attempt": attempt,
- "phase": "syntax_validation",
- "error": error_summary,
- "edits_content": {fp: content for fp, content in edits.items()},
- "llm_response": llm_response,
- })
- # Don't write — go straight to retry
- continue
-
- last_syntax_errors = {}
-
- if request.dry_run:
- self._attempt_reports.append({
- "attempt": attempt,
- "phase": "dry_run",
- "edits": {fp: content[:500] + "..." if len(content) > 500 else content
- for fp, content in edits.items()},
- "llm_response": llm_response,
- })
- return ModifyResult(
- success=True,
- files_changed=list(edits.keys()),
- llm_response=llm_response,
- branch_name=branch_name,
- attempts=attempt,
- )
-
- # Write edits
- written = self._write_files(edits)
-
- # Run tests
- test_passed, test_output = self._run_tests()
- last_test_output = test_output
-
- # Save per-attempt report
- self._attempt_reports.append({
- "attempt": attempt,
- "phase": "complete",
- "files_written": written,
- "edits_content": {fp: content for fp, content in edits.items()},
- "test_passed": test_passed,
- "test_output": test_output,
- "llm_response": llm_response,
- })
-
- if test_passed:
- sha = self._git_commit(
- f"self-modify: {request.instruction[:72]}", written
- )
- return ModifyResult(
- success=True,
- files_changed=written,
- test_passed=True,
- commit_sha=sha,
- branch_name=branch_name,
- llm_response=llm_response,
- attempts=attempt,
- )
-
- # Tests failed — revert and maybe retry
- logger.warning(
- "Tests failed on attempt %d: %s", attempt, test_output[:200]
- )
- self._revert_files(written)
-
- return ModifyResult(
- success=False,
- files_changed=[],
- test_passed=False,
- error=f"Tests failed after {attempt} attempt(s).",
- llm_response=last_llm_response,
- branch_name=branch_name,
- attempts=attempt,
- )
-
- # ── Syntax validation ──────────────────────────────────────────────────
-
- def _validate_syntax(self, edits: dict[str, str]) -> dict[str, str]:
- """Compile-check each .py file edit. Returns {path: error} for failures."""
- errors: dict[str, str] = {}
- for fp, content in edits.items():
- if not fp.endswith(".py"):
- continue
- try:
- compile(content, fp, "exec")
- except SyntaxError as exc:
- errors[fp] = f"line {exc.lineno}: {exc.msg}"
- return errors
-
- # ── Report saving ─────────────────────────────────────────────────────────
-
- def _save_report(self, request: ModifyRequest, result: ModifyResult) -> Path:
- """Save a detailed report to data/self_modify_reports/."""
- REPORTS_DIR.mkdir(parents=True, exist_ok=True)
- ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
- slug = re.sub(r"[^a-z0-9]+", "_", request.instruction[:40].lower()).strip("_")
- report_file = REPORTS_DIR / f"{ts}_{slug}.md"
-
- lines = [
- f"# Self-Modify Report: {ts}",
- "",
- f"**Instruction:** {request.instruction[:200]}",
- f"**Target files:** {', '.join(request.target_files) or '(auto-detected)'}",
- f"**Dry run:** {request.dry_run}",
- f"**Backend:** {self._backend}",
- f"**Branch:** {result.branch_name or 'N/A'}",
- f"**Result:** {'SUCCESS' if result.success else 'FAILED'}",
- f"**Error:** {result.error or 'none'}",
- f"**Commit:** {result.commit_sha or 'none'}",
- f"**Attempts:** {result.attempts}",
- f"**Autonomous cycles:** {result.autonomous_cycles}",
- "",
- ]
-
- for attempt_data in self._attempt_reports:
- n = attempt_data.get("attempt", "?")
- phase = attempt_data.get("phase", "?")
- lines.append(f"## Attempt {n} -- {phase}")
- lines.append("")
-
- if "error" in attempt_data and attempt_data.get("phase") != "complete":
- lines.append(f"**Error:** {attempt_data['error']}")
- lines.append("")
-
- if "llm_response" in attempt_data:
- lines.append("### LLM Response")
- lines.append("```")
- lines.append(attempt_data["llm_response"])
- lines.append("```")
- lines.append("")
-
- if "edits_content" in attempt_data:
- lines.append("### Edits Written")
- for fp, content in attempt_data["edits_content"].items():
- lines.append(f"#### {fp}")
- lines.append("```python")
- lines.append(content)
- lines.append("```")
- lines.append("")
-
- if "test_output" in attempt_data:
- lines.append(f"### Test Result: {'PASSED' if attempt_data.get('test_passed') else 'FAILED'}")
- lines.append("```")
- lines.append(attempt_data["test_output"])
- lines.append("```")
- lines.append("")
-
- report_text = "\n".join(lines)
- report_file.write_text(report_text, encoding="utf-8")
- logger.info("Report saved: %s", report_file)
- return report_file
-
- # ── Git helpers ───────────────────────────────────────────────────────────
-
- def _create_branch(self) -> str:
- """Create and switch to a working branch."""
- from creative.tools.git_tools import git_branch
-
- branch_name = f"timmy/self-modify-{int(time.time())}"
- git_branch(self._repo_path, create=branch_name, switch=branch_name)
- logger.info("Created branch: %s", branch_name)
- return branch_name
-
- def _git_commit(self, message: str, files: list[str]) -> Optional[str]:
- """Stage files and commit."""
- from creative.tools.git_tools import git_add, git_commit
-
- try:
- git_add(self._repo_path, paths=files)
- result = git_commit(self._repo_path, message)
- sha = result.get("sha")
- logger.info("Committed %s: %s", sha[:8] if sha else "?", message)
- return sha
- except Exception as exc:
- logger.error("Git commit failed: %s", exc)
- return None
-
- def _revert_files(self, file_paths: list[str]) -> None:
- """Restore files from git HEAD."""
- for fp in file_paths:
- try:
- subprocess.run(
- ["git", "checkout", "HEAD", "--", fp],
- cwd=self._repo_path,
- capture_output=True,
- timeout=10,
- )
- except Exception as exc:
- logger.error("Failed to revert %s: %s", fp, exc)
-
- # ── File I/O ──────────────────────────────────────────────────────────────
-
- def _validate_paths(self, file_paths: list[str]) -> None:
- """Ensure all paths are within allowed directories."""
- for fp in file_paths:
- resolved = (self._repo_path / fp).resolve()
- repo_resolved = self._repo_path.resolve()
- if not str(resolved).startswith(str(repo_resolved)):
- raise ValueError(f"Path escapes repository: {fp}")
- rel = str(resolved.relative_to(repo_resolved))
- if not any(rel.startswith(d) for d in self._allowed_dirs):
- raise ValueError(
- f"Path not in allowed directories ({self._allowed_dirs}): {fp}"
- )
-
- def _read_files(self, file_paths: list[str]) -> dict[str, str]:
- """Read file contents from disk."""
- contents: dict[str, str] = {}
- for fp in file_paths:
- full = self._repo_path / fp
- if not full.is_file():
- logger.warning("File not found: %s", full)
- continue
- if full.stat().st_size > _MAX_FILE_SIZE:
- logger.warning("File too large, skipping: %s", fp)
- continue
- try:
- contents[fp] = full.read_text(encoding="utf-8")
- except Exception as exc:
- logger.warning("Could not read %s: %s", fp, exc)
- return contents
-
- def _write_files(self, edits: dict[str, str]) -> list[str]:
- """Write edited content to disk. Returns paths written."""
- written: list[str] = []
- for fp, content in edits.items():
- full = self._repo_path / fp
- full.parent.mkdir(parents=True, exist_ok=True)
- full.write_text(content, encoding="utf-8")
- written.append(fp)
- logger.info("Wrote %d bytes to %s", len(content), fp)
- return written
-
- def _infer_target_files(self, instruction: str) -> list[str]:
- """Guess which files to modify from the instruction text."""
- paths = re.findall(r"[\w/._-]+\.py", instruction)
- if paths:
- return paths
-
- keyword_files = {
- "config": ["src/config.py"],
- "health": ["src/dashboard/routes/health.py"],
- "swarm": ["src/swarm/coordinator.py"],
- "voice": ["src/voice/nlu.py"],
- "agent": ["src/timmy/agent.py"],
- "tool": ["src/timmy/tools.py"],
- "dashboard": ["src/dashboard/app.py"],
- "prompt": ["src/timmy/prompts.py"],
- }
- instruction_lower = instruction.lower()
- for keyword, files in keyword_files.items():
- if keyword in instruction_lower:
- return files
- return []
-
- # ── Test runner ───────────────────────────────────────────────────────────
-
- def _run_tests(self) -> tuple[bool, str]:
- """Run the test suite. Returns (passed, output)."""
- try:
- result = subprocess.run(
- [sys.executable, "-m", "pytest", "tests/", "-q", "--tb=short"],
- capture_output=True,
- text=True,
- cwd=self._repo_path,
- timeout=120,
- )
- output = (result.stdout + result.stderr).strip()
- return result.returncode == 0, output
- except subprocess.TimeoutExpired:
- return False, "Tests timed out after 120s"
- except Exception as exc:
- return False, f"Failed to run tests: {exc}"
-
- # ── Multi-backend LLM ─────────────────────────────────────────────────────
-
- def _resolve_backend(self) -> str:
- """Resolve 'auto' backend to a concrete one."""
- if self._backend == "auto":
- api_key = os.environ.get("ANTHROPIC_API_KEY", "")
- if api_key:
- return "anthropic"
- return "ollama"
- return self._backend
-
- def _call_llm(self, prompt: str) -> str:
- """Route a prompt to the configured LLM backend. Returns raw text."""
- backend = self._resolve_backend()
-
- if backend == "anthropic":
- return self._call_anthropic(prompt)
- else:
- return self._call_ollama(prompt)
-
- def _call_anthropic(self, prompt: str) -> str:
- """Call Claude via the Anthropic SDK."""
- import anthropic
-
- api_key = os.environ.get("ANTHROPIC_API_KEY", "")
- if not api_key:
- raise RuntimeError("ANTHROPIC_API_KEY not set — cannot use anthropic backend")
-
- client = anthropic.Anthropic(api_key=api_key)
- message = client.messages.create(
- model="claude-sonnet-4-20250514",
- max_tokens=4096,
- messages=[{"role": "user", "content": prompt}],
- )
- return message.content[0].text
-
- def _call_ollama(self, prompt: str) -> str:
- """Call the local Ollama instance via Agno."""
- from agno.agent import Agent
- from agno.models.ollama import Ollama
-
- agent = Agent(
- name="SelfModify",
- model=Ollama(id=settings.ollama_model, host=settings.ollama_url),
- markdown=False,
- )
- run_result = agent.run(prompt, stream=False)
- return run_result.content if hasattr(run_result, "content") else str(run_result)
-
- # ── LLM interaction ───────────────────────────────────────────────────────
-
- def _generate_edits(
- self,
- instruction: str,
- file_contents: dict[str, str],
- prev_test_output: Optional[str] = None,
- prev_syntax_errors: Optional[dict[str, str]] = None,
- ) -> tuple[dict[str, str], str]:
- """Ask the LLM to generate file edits.
-
- Returns (edits_dict, raw_llm_response).
- """
- # Build the prompt
- files_block = ""
- for fp, content in file_contents.items():
- files_block += f"\n\n{content}\n\n"
-
- retry_context = ""
- if prev_test_output:
- retry_context += f"""
-PREVIOUS ATTEMPT FAILED with test errors:
-
-{prev_test_output[:2000]}
-
-Fix the issues shown above.
-"""
- if prev_syntax_errors:
- errors_text = "\n".join(f" {fp}: {err}" for fp, err in prev_syntax_errors.items())
- retry_context += f"""
-PREVIOUS ATTEMPT HAD SYNTAX ERRORS (code was rejected before writing):
-{errors_text}
-
-You MUST produce syntactically valid Python. Run through the code mentally
-and make sure all strings are properly terminated, all indentation is correct,
-and there are no invalid characters outside of string literals.
-"""
-
- prompt = f"""You are a precise code modification agent. Edit source files according to the instruction.
-
-INSTRUCTION: {instruction}
-
-CURRENT FILES:
-{files_block}
-{retry_context}
-OUTPUT FORMAT — wrap each modified file like this:
-
-
-complete file content here
-
-
-CRITICAL RULES:
-- Output the COMPLETE file content, not just changed lines
-- Keep ALL existing functionality unless told to remove it
-- The output must be syntactically valid Python — verify mentally before outputting
-- Preserve all special characters (unicode, em-dashes, etc.) exactly as they appear in the original
-- Do NOT wrap the file content in triple-quotes or markdown code fences
-- Do NOT start the file content with \"\"\" — that would turn the code into a string literal
-- Follow the existing code style
-
-Generate the modified files now:"""
-
- raw = self._call_llm(prompt)
-
- # Parse ... blocks
- edits = {}
- xml_re = re.compile(
- r'\n?(.*?)',
- re.DOTALL,
- )
- for match in xml_re.finditer(raw):
- filepath = match.group(1).strip()
- content = match.group(2)
- # Strip trailing whitespace but keep a final newline
- content = content.rstrip() + "\n"
- edits[filepath] = content
-
- # Fallback: try the old delimiter format
- if not edits:
- for match in _FILE_BLOCK_RE.finditer(raw):
- filepath = match.group(1).strip()
- content = match.group(2).rstrip() + "\n"
- edits[filepath] = content
-
- # Last resort: single file + code block
- if not edits and len(file_contents) == 1:
- only_path = next(iter(file_contents))
- code_match = re.search(r"```(?:python)?\n(.*?)```", raw, re.DOTALL)
- if code_match:
- edits[only_path] = code_match.group(1).rstrip() + "\n"
-
- return edits, raw
diff --git a/src/self_coding/self_tdd/__init__.py b/src/self_coding/self_tdd/__init__.py
deleted file mode 100644
index 958a5bdd..00000000
--- a/src/self_coding/self_tdd/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Self-TDD — Continuous test watchdog with regression alerting."""
diff --git a/src/self_coding/self_tdd/watchdog.py b/src/self_coding/self_tdd/watchdog.py
deleted file mode 100644
index c231882c..00000000
--- a/src/self_coding/self_tdd/watchdog.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""Self-TDD Watchdog — polls pytest on a schedule and reports regressions.
-
-Run in a terminal alongside your normal dev work:
-
- self-tdd watch
- self-tdd watch --interval 30
-
-The watchdog runs silently while tests pass. When a regression appears it
-prints the full short-traceback output so you can see exactly what broke.
-No files are modified; no commits are made. Ctrl-C to stop.
-"""
-
-import subprocess
-import sys
-import time
-from datetime import datetime
-from pathlib import Path
-
-import typer
-
-# Project root is three levels up from src/self_tdd/watchdog.py
-PROJECT_ROOT = Path(__file__).parent.parent.parent
-
-app = typer.Typer(help="Self-TDD watchdog — continuous test runner")
-
-
-def _run_tests() -> tuple[bool, str]:
- """Run the test suite and return (passed, combined_output)."""
- result = subprocess.run(
- [sys.executable, "-m", "pytest", "tests/", "-q", "--tb=short"],
- capture_output=True,
- text=True,
- cwd=PROJECT_ROOT,
- timeout=60,
- )
- return result.returncode == 0, (result.stdout + result.stderr).strip()
-
-
-@app.command()
-def watch(
- interval: int = typer.Option(60, "--interval", "-i", help="Seconds between test runs"),
-) -> None:
- """Poll pytest continuously and print regressions as they appear."""
- typer.echo(f"Self-TDD watchdog started — polling every {interval}s. Ctrl-C to stop.")
- last_passed: bool | None = None
-
- try:
- while True:
- passed, output = _run_tests()
- stamp = datetime.now().strftime("%H:%M:%S")
-
- if passed:
- if last_passed is not True:
- typer.secho(f"[{stamp}] All tests passing.", fg=typer.colors.GREEN)
- else:
- typer.secho(f"[{stamp}] Regression detected:", fg=typer.colors.RED)
- typer.echo(output)
-
- last_passed = passed
- time.sleep(interval)
-
- except KeyboardInterrupt:
- typer.echo("\nWatchdog stopped.")
-
-
-def main() -> None:
- app()
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/self_coding/upgrades/__init__.py b/src/self_coding/upgrades/__init__.py
deleted file mode 100644
index b5857a36..00000000
--- a/src/self_coding/upgrades/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Upgrades — System upgrade queue and execution pipeline."""
diff --git a/src/self_coding/upgrades/models.py b/src/self_coding/upgrades/models.py
deleted file mode 100644
index ef67e2f4..00000000
--- a/src/self_coding/upgrades/models.py
+++ /dev/null
@@ -1,331 +0,0 @@
-"""Database models for Self-Upgrade Approval Queue."""
-
-import json
-import sqlite3
-import uuid
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from enum import Enum
-from pathlib import Path
-from typing import Optional
-
-DB_PATH = Path("data/swarm.db")
-
-
-class UpgradeStatus(str, Enum):
- """Status of an upgrade proposal."""
- PROPOSED = "proposed"
- APPROVED = "approved"
- REJECTED = "rejected"
- APPLIED = "applied"
- FAILED = "failed"
- EXPIRED = "expired"
-
-
-@dataclass
-class Upgrade:
- """A self-modification upgrade proposal."""
- id: str = field(default_factory=lambda: str(uuid.uuid4()))
- status: UpgradeStatus = UpgradeStatus.PROPOSED
-
- # Timestamps
- proposed_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
- approved_at: Optional[str] = None
- applied_at: Optional[str] = None
- rejected_at: Optional[str] = None
-
- # Proposal details
- branch_name: str = ""
- description: str = ""
- files_changed: list[str] = field(default_factory=list)
- diff_preview: str = ""
-
- # Test results
- test_passed: bool = False
- test_output: str = ""
-
- # Execution results
- error_message: Optional[str] = None
- approved_by: Optional[str] = None
-
-
-def _get_conn() -> sqlite3.Connection:
- """Get database connection with schema initialized."""
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(str(DB_PATH))
- conn.row_factory = sqlite3.Row
-
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS upgrades (
- id TEXT PRIMARY KEY,
- status TEXT NOT NULL DEFAULT 'proposed',
- proposed_at TEXT NOT NULL,
- approved_at TEXT,
- applied_at TEXT,
- rejected_at TEXT,
- branch_name TEXT NOT NULL,
- description TEXT NOT NULL,
- files_changed TEXT, -- JSON array
- diff_preview TEXT,
- test_passed INTEGER DEFAULT 0,
- test_output TEXT,
- error_message TEXT,
- approved_by TEXT
- )
- """
- )
-
- # Indexes
- conn.execute("CREATE INDEX IF NOT EXISTS idx_upgrades_status ON upgrades(status)")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_upgrades_proposed ON upgrades(proposed_at)")
-
- conn.commit()
- return conn
-
-
-def create_upgrade(
- branch_name: str,
- description: str,
- files_changed: list[str],
- diff_preview: str,
- test_passed: bool = False,
- test_output: str = "",
-) -> Upgrade:
- """Create a new upgrade proposal.
-
- Args:
- branch_name: Git branch name for the upgrade
- description: Human-readable description
- files_changed: List of files that would be modified
- diff_preview: Short diff preview for review
- test_passed: Whether tests passed on the branch
- test_output: Test output text
-
- Returns:
- The created Upgrade
- """
- upgrade = Upgrade(
- branch_name=branch_name,
- description=description,
- files_changed=files_changed,
- diff_preview=diff_preview,
- test_passed=test_passed,
- test_output=test_output,
- )
-
- conn = _get_conn()
- conn.execute(
- """
- INSERT INTO upgrades (id, status, proposed_at, branch_name, description,
- files_changed, diff_preview, test_passed, test_output)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- upgrade.id,
- upgrade.status.value,
- upgrade.proposed_at,
- upgrade.branch_name,
- upgrade.description,
- json.dumps(files_changed),
- upgrade.diff_preview,
- int(test_passed),
- test_output,
- ),
- )
- conn.commit()
- conn.close()
-
- return upgrade
-
-
-def get_upgrade(upgrade_id: str) -> Optional[Upgrade]:
- """Get upgrade by ID."""
- conn = _get_conn()
- row = conn.execute(
- "SELECT * FROM upgrades WHERE id = ?", (upgrade_id,)
- ).fetchone()
- conn.close()
-
- if not row:
- return None
-
- return Upgrade(
- id=row["id"],
- status=UpgradeStatus(row["status"]),
- proposed_at=row["proposed_at"],
- approved_at=row["approved_at"],
- applied_at=row["applied_at"],
- rejected_at=row["rejected_at"],
- branch_name=row["branch_name"],
- description=row["description"],
- files_changed=json.loads(row["files_changed"]) if row["files_changed"] else [],
- diff_preview=row["diff_preview"] or "",
- test_passed=bool(row["test_passed"]),
- test_output=row["test_output"] or "",
- error_message=row["error_message"],
- approved_by=row["approved_by"],
- )
-
-
-def list_upgrades(
- status: Optional[UpgradeStatus] = None,
- limit: int = 100,
-) -> list[Upgrade]:
- """List upgrades, optionally filtered by status."""
- conn = _get_conn()
-
- if status:
- rows = conn.execute(
- "SELECT * FROM upgrades WHERE status = ? ORDER BY proposed_at DESC LIMIT ?",
- (status.value, limit),
- ).fetchall()
- else:
- rows = conn.execute(
- "SELECT * FROM upgrades ORDER BY proposed_at DESC LIMIT ?",
- (limit,),
- ).fetchall()
-
- conn.close()
-
- return [
- Upgrade(
- id=r["id"],
- status=UpgradeStatus(r["status"]),
- proposed_at=r["proposed_at"],
- approved_at=r["approved_at"],
- applied_at=r["applied_at"],
- rejected_at=r["rejected_at"],
- branch_name=r["branch_name"],
- description=r["description"],
- files_changed=json.loads(r["files_changed"]) if r["files_changed"] else [],
- diff_preview=r["diff_preview"] or "",
- test_passed=bool(r["test_passed"]),
- test_output=r["test_output"] or "",
- error_message=r["error_message"],
- approved_by=r["approved_by"],
- )
- for r in rows
- ]
-
-
-def approve_upgrade(upgrade_id: str, approved_by: str = "dashboard") -> Optional[Upgrade]:
- """Approve an upgrade proposal."""
- now = datetime.now(timezone.utc).isoformat()
-
- conn = _get_conn()
- cursor = conn.execute(
- """
- UPDATE upgrades
- SET status = ?, approved_at = ?, approved_by = ?
- WHERE id = ? AND status = ?
- """,
- (UpgradeStatus.APPROVED.value, now, approved_by, upgrade_id, UpgradeStatus.PROPOSED.value),
- )
- conn.commit()
- updated = cursor.rowcount > 0
- conn.close()
-
- if not updated:
- return None
-
- return get_upgrade(upgrade_id)
-
-
-def reject_upgrade(upgrade_id: str) -> Optional[Upgrade]:
- """Reject an upgrade proposal."""
- now = datetime.now(timezone.utc).isoformat()
-
- conn = _get_conn()
- cursor = conn.execute(
- """
- UPDATE upgrades
- SET status = ?, rejected_at = ?
- WHERE id = ? AND status = ?
- """,
- (UpgradeStatus.REJECTED.value, now, upgrade_id, UpgradeStatus.PROPOSED.value),
- )
- conn.commit()
- updated = cursor.rowcount > 0
- conn.close()
-
- if not updated:
- return None
-
- return get_upgrade(upgrade_id)
-
-
-def mark_applied(upgrade_id: str) -> Optional[Upgrade]:
- """Mark upgrade as successfully applied."""
- now = datetime.now(timezone.utc).isoformat()
-
- conn = _get_conn()
- cursor = conn.execute(
- """
- UPDATE upgrades
- SET status = ?, applied_at = ?
- WHERE id = ? AND status = ?
- """,
- (UpgradeStatus.APPLIED.value, now, upgrade_id, UpgradeStatus.APPROVED.value),
- )
- conn.commit()
- updated = cursor.rowcount > 0
- conn.close()
-
- if not updated:
- return None
-
- return get_upgrade(upgrade_id)
-
-
-def mark_failed(upgrade_id: str, error_message: str) -> Optional[Upgrade]:
- """Mark upgrade as failed."""
- conn = _get_conn()
- cursor = conn.execute(
- """
- UPDATE upgrades
- SET status = ?, error_message = ?
- WHERE id = ? AND status = ?
- """,
- (UpgradeStatus.FAILED.value, error_message, upgrade_id, UpgradeStatus.APPROVED.value),
- )
- conn.commit()
- updated = cursor.rowcount > 0
- conn.close()
-
- if not updated:
- return None
-
- return get_upgrade(upgrade_id)
-
-
-def get_pending_count() -> int:
- """Get count of pending (proposed) upgrades."""
- conn = _get_conn()
- row = conn.execute(
- "SELECT COUNT(*) as count FROM upgrades WHERE status = ?",
- (UpgradeStatus.PROPOSED.value,),
- ).fetchone()
- conn.close()
- return row["count"]
-
-
-def prune_old_upgrades(older_than_days: int = 30) -> int:
- """Delete old completed upgrades."""
- from datetime import timedelta
-
- cutoff = (datetime.now(timezone.utc) - timedelta(days=older_than_days)).isoformat()
-
- conn = _get_conn()
- cursor = conn.execute(
- """
- DELETE FROM upgrades
- WHERE proposed_at < ? AND status IN ('applied', 'rejected', 'failed')
- """,
- (cutoff,),
- )
- deleted = cursor.rowcount
- conn.commit()
- conn.close()
-
- return deleted
diff --git a/src/self_coding/upgrades/queue.py b/src/self_coding/upgrades/queue.py
deleted file mode 100644
index b02a12c3..00000000
--- a/src/self_coding/upgrades/queue.py
+++ /dev/null
@@ -1,285 +0,0 @@
-"""Upgrade Queue management - bridges self-modify loop with approval workflow."""
-
-import logging
-import subprocess
-from pathlib import Path
-from typing import Optional
-
-from self_coding.upgrades.models import (
- Upgrade,
- UpgradeStatus,
- create_upgrade,
- get_upgrade,
- approve_upgrade,
- reject_upgrade,
- mark_applied,
- mark_failed,
-)
-
-logger = logging.getLogger(__name__)
-
-PROJECT_ROOT = Path(__file__).parent.parent.parent
-
-
-class UpgradeQueue:
- """Manages the upgrade approval and application workflow."""
-
- @staticmethod
- def propose(
- branch_name: str,
- description: str,
- files_changed: list[str],
- diff_preview: str,
- test_passed: bool = False,
- test_output: str = "",
- ) -> Upgrade:
- """Propose a new upgrade for approval.
-
- This is called by the self-modify loop when it generates changes.
- The upgrade is created in 'proposed' state and waits for human approval.
-
- Args:
- branch_name: Git branch with the changes
- description: What the upgrade does
- files_changed: List of modified files
- diff_preview: Short diff for review
- test_passed: Whether tests passed
- test_output: Test output
-
- Returns:
- The created Upgrade proposal
- """
- upgrade = create_upgrade(
- branch_name=branch_name,
- description=description,
- files_changed=files_changed,
- diff_preview=diff_preview,
- test_passed=test_passed,
- test_output=test_output,
- )
-
- logger.info(
- "Upgrade proposed: %s (%s) - %d files",
- upgrade.id[:8],
- branch_name,
- len(files_changed),
- )
-
- # Log to event log
- try:
- from swarm.event_log import log_event, EventType
- log_event(
- EventType.SYSTEM_INFO,
- source="upgrade_queue",
- data={
- "upgrade_id": upgrade.id,
- "branch": branch_name,
- "description": description,
- "test_passed": test_passed,
- },
- )
- except Exception:
- pass
-
- return upgrade
-
- @staticmethod
- def approve(upgrade_id: str, approved_by: str = "dashboard") -> Optional[Upgrade]:
- """Approve an upgrade proposal.
-
- Called from dashboard when user clicks "Approve".
- Does NOT apply the upgrade - that happens separately.
-
- Args:
- upgrade_id: The upgrade to approve
- approved_by: Who approved it (for audit)
-
- Returns:
- Updated Upgrade or None if not found/not in proposed state
- """
- upgrade = approve_upgrade(upgrade_id, approved_by)
-
- if upgrade:
- logger.info("Upgrade approved: %s by %s", upgrade_id[:8], approved_by)
-
- return upgrade
-
- @staticmethod
- def reject(upgrade_id: str) -> Optional[Upgrade]:
- """Reject an upgrade proposal.
-
- Called from dashboard when user clicks "Reject".
- Cleans up the branch.
-
- Args:
- upgrade_id: The upgrade to reject
-
- Returns:
- Updated Upgrade or None
- """
- upgrade = reject_upgrade(upgrade_id)
-
- if upgrade:
- logger.info("Upgrade rejected: %s", upgrade_id[:8])
-
- # Clean up branch
- try:
- subprocess.run(
- ["git", "branch", "-D", upgrade.branch_name],
- cwd=PROJECT_ROOT,
- capture_output=True,
- check=False,
- )
- except Exception as exc:
- logger.warning("Failed to delete branch %s: %s", upgrade.branch_name, exc)
-
- return upgrade
-
- @staticmethod
- def apply(upgrade_id: str) -> tuple[bool, str]:
- """Apply an approved upgrade.
-
- This is the critical operation that actually modifies the codebase:
- 1. Checks out the branch
- 2. Runs tests
- 3. If tests pass: merges to main
- 4. Updates upgrade status
-
- Args:
- upgrade_id: The approved upgrade to apply
-
- Returns:
- (success, message) tuple
- """
- upgrade = get_upgrade(upgrade_id)
-
- if not upgrade:
- return False, "Upgrade not found"
-
- if upgrade.status != UpgradeStatus.APPROVED:
- return False, f"Upgrade not approved (status: {upgrade.status.value})"
-
- logger.info("Applying upgrade: %s (%s)", upgrade_id[:8], upgrade.branch_name)
-
- try:
- # 1. Checkout branch
- result = subprocess.run(
- ["git", "checkout", upgrade.branch_name],
- cwd=PROJECT_ROOT,
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- mark_failed(upgrade_id, f"Checkout failed: {result.stderr}")
- return False, f"Failed to checkout branch: {result.stderr}"
-
- # 2. Run tests
- result = subprocess.run(
- ["python", "-m", "pytest", "tests/", "-x", "-q"],
- cwd=PROJECT_ROOT,
- capture_output=True,
- text=True,
- timeout=120,
- )
-
- if result.returncode != 0:
- mark_failed(upgrade_id, f"Tests failed: {result.stdout}\n{result.stderr}")
- # Switch back to main
- subprocess.run(["git", "checkout", "main"], cwd=PROJECT_ROOT, check=False)
- return False, "Tests failed"
-
- # 3. Merge to main
- result = subprocess.run(
- ["git", "checkout", "main"],
- cwd=PROJECT_ROOT,
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- mark_failed(upgrade_id, f"Failed to checkout main: {result.stderr}")
- return False, "Failed to checkout main"
-
- result = subprocess.run(
- ["git", "merge", "--no-ff", upgrade.branch_name, "-m", f"Apply upgrade: {upgrade.description}"],
- cwd=PROJECT_ROOT,
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- mark_failed(upgrade_id, f"Merge failed: {result.stderr}")
- return False, "Merge failed"
-
- # 4. Mark as applied
- mark_applied(upgrade_id)
-
- # 5. Clean up branch
- subprocess.run(
- ["git", "branch", "-d", upgrade.branch_name],
- cwd=PROJECT_ROOT,
- capture_output=True,
- check=False,
- )
-
- logger.info("Upgrade applied successfully: %s", upgrade_id[:8])
- return True, "Upgrade applied successfully"
-
- except subprocess.TimeoutExpired:
- mark_failed(upgrade_id, "Tests timed out")
- subprocess.run(["git", "checkout", "main"], cwd=PROJECT_ROOT, check=False)
- return False, "Tests timed out"
-
- except Exception as exc:
- error_msg = str(exc)
- mark_failed(upgrade_id, error_msg)
- subprocess.run(["git", "checkout", "main"], cwd=PROJECT_ROOT, check=False)
- return False, f"Error: {error_msg}"
-
- @staticmethod
- def get_full_diff(upgrade_id: str) -> str:
- """Get full git diff for an upgrade.
-
- Args:
- upgrade_id: The upgrade to get diff for
-
- Returns:
- Git diff output
- """
- upgrade = get_upgrade(upgrade_id)
- if not upgrade:
- return "Upgrade not found"
-
- try:
- result = subprocess.run(
- ["git", "diff", "main..." + upgrade.branch_name],
- cwd=PROJECT_ROOT,
- capture_output=True,
- text=True,
- )
- return result.stdout if result.returncode == 0 else result.stderr
- except Exception as exc:
- return f"Error getting diff: {exc}"
-
-
-# Convenience functions for self-modify loop
-def propose_upgrade_from_loop(
- branch_name: str,
- description: str,
- files_changed: list[str],
- diff: str,
- test_output: str = "",
-) -> Upgrade:
- """Called by self-modify loop to propose an upgrade.
-
- Tests are expected to have been run by the loop before calling this.
- """
- # Check if tests passed from output
- test_passed = "passed" in test_output.lower() or " PASSED " in test_output
-
- return UpgradeQueue.propose(
- branch_name=branch_name,
- description=description,
- files_changed=files_changed,
- diff_preview=diff[:2000], # First 2000 chars
- test_passed=test_passed,
- test_output=test_output,
- )
diff --git a/src/swarm/CLAUDE.md b/src/swarm/CLAUDE.md
deleted file mode 100644
index fd21c5d3..00000000
--- a/src/swarm/CLAUDE.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# swarm/ — Module Guide
-
-Security-sensitive module. Changes to `coordinator.py` require review.
-
-## Structure
-- `coordinator.py` — Auction-based task assignment (singleton: `coordinator`)
-- `tasks.py`, `bidder.py`, `comms.py` — Core swarm primitives
-- `work_orders/` — External work order submission and execution
-- `task_queue/` — Human-in-the-loop approval queue
-- `event_log.py` — Structured event logging
-- `personas.py`, `persona_node.py` — Agent persona management
-
-## Key singletons
-```python
-from swarm.coordinator import coordinator
-```
-
-## Testing
-```bash
-pytest tests/swarm/ -q
-```
diff --git a/src/swarm/__init__.py b/src/swarm/__init__.py
deleted file mode 100644
index d2b119fd..00000000
--- a/src/swarm/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Swarm — Multi-agent coordinator with auction-based task assignment."""
diff --git a/src/swarm/event_log.py b/src/swarm/event_log.py
deleted file mode 100644
index 2812d4d0..00000000
--- a/src/swarm/event_log.py
+++ /dev/null
@@ -1,336 +0,0 @@
-"""Event logging for swarm system.
-
-All agent actions, task lifecycle events, and system events are logged
-to SQLite for audit, debugging, and analytics.
-"""
-
-import sqlite3
-import uuid
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from enum import Enum
-from pathlib import Path
-from typing import Optional
-
-DB_PATH = Path("data/swarm.db")
-
-
-class EventType(str, Enum):
- """Types of events logged."""
- # Task lifecycle
- TASK_CREATED = "task.created"
- TASK_BIDDING = "task.bidding"
- TASK_ASSIGNED = "task.assigned"
- TASK_STARTED = "task.started"
- TASK_COMPLETED = "task.completed"
- TASK_FAILED = "task.failed"
-
- # Agent lifecycle
- AGENT_JOINED = "agent.joined"
- AGENT_LEFT = "agent.left"
- AGENT_STATUS_CHANGED = "agent.status_changed"
-
- # Bidding
- BID_SUBMITTED = "bid.submitted"
- AUCTION_CLOSED = "auction.closed"
-
- # Tool execution
- TOOL_CALLED = "tool.called"
- TOOL_COMPLETED = "tool.completed"
- TOOL_FAILED = "tool.failed"
-
- # Thinking
- TIMMY_THOUGHT = "timmy.thought"
-
- # System
- SYSTEM_ERROR = "system.error"
- SYSTEM_WARNING = "system.warning"
- SYSTEM_INFO = "system.info"
-
- # Error feedback loop
- ERROR_CAPTURED = "error.captured"
- BUG_REPORT_CREATED = "bug_report.created"
-
-
-@dataclass
-class EventLogEntry:
- """A logged event."""
- id: str = field(default_factory=lambda: str(uuid.uuid4()))
- event_type: EventType = EventType.SYSTEM_INFO
- source: str = "" # Agent or component that emitted the event
- task_id: Optional[str] = None
- agent_id: Optional[str] = None
- data: Optional[str] = None # JSON string of additional data
- timestamp: str = field(
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
- )
-
-
-def _get_conn() -> sqlite3.Connection:
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(str(DB_PATH))
- conn.row_factory = sqlite3.Row
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS event_log (
- id TEXT PRIMARY KEY,
- event_type TEXT NOT NULL,
- source TEXT NOT NULL,
- task_id TEXT,
- agent_id TEXT,
- data TEXT,
- timestamp TEXT NOT NULL
- )
- """
- )
- # Create indexes for common queries
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_event_log_task ON event_log(task_id)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_event_log_agent ON event_log(agent_id)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_event_log_type ON event_log(event_type)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_event_log_time ON event_log(timestamp)"
- )
- conn.commit()
- return conn
-
-
-def log_event(
- event_type: EventType,
- source: str,
- task_id: Optional[str] = None,
- agent_id: Optional[str] = None,
- data: Optional[dict] = None,
-) -> EventLogEntry:
- """Log an event to the database.
-
- Args:
- event_type: Type of event
- source: Component or agent that emitted the event
- task_id: Optional associated task ID
- agent_id: Optional associated agent ID
- data: Optional dictionary of additional data (will be JSON serialized)
-
- Returns:
- The created EventLogEntry
- """
- import json
-
- entry = EventLogEntry(
- event_type=event_type,
- source=source,
- task_id=task_id,
- agent_id=agent_id,
- data=json.dumps(data) if data else None,
- )
-
- conn = _get_conn()
- conn.execute(
- """
- INSERT INTO event_log (id, event_type, source, task_id, agent_id, data, timestamp)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- """,
- (
- entry.id,
- entry.event_type.value,
- entry.source,
- entry.task_id,
- entry.agent_id,
- entry.data,
- entry.timestamp,
- ),
- )
- conn.commit()
- conn.close()
-
- # Broadcast to WebSocket clients for real-time activity feed
- try:
- from infrastructure.events.broadcaster import event_broadcaster
- event_broadcaster.broadcast_sync(entry)
- except Exception:
- # Don't fail if broadcaster unavailable
- pass
-
- return entry
-
-
-def get_event(event_id: str) -> Optional[EventLogEntry]:
- """Get a single event by ID."""
- conn = _get_conn()
- row = conn.execute(
- "SELECT * FROM event_log WHERE id = ?", (event_id,)
- ).fetchone()
- conn.close()
-
- if row is None:
- return None
-
- return EventLogEntry(
- id=row["id"],
- event_type=EventType(row["event_type"]),
- source=row["source"],
- task_id=row["task_id"],
- agent_id=row["agent_id"],
- data=row["data"],
- timestamp=row["timestamp"],
- )
-
-
-def list_events(
- event_type: Optional[EventType] = None,
- task_id: Optional[str] = None,
- agent_id: Optional[str] = None,
- source: Optional[str] = None,
- limit: int = 100,
- offset: int = 0,
-) -> list[EventLogEntry]:
- """List events with optional filtering.
-
- Args:
- event_type: Filter by event type
- task_id: Filter by associated task
- agent_id: Filter by associated agent
- source: Filter by source component
- limit: Maximum number of events to return
- offset: Number of events to skip (for pagination)
-
- Returns:
- List of EventLogEntry objects, newest first
- """
- conn = _get_conn()
-
- conditions = []
- params = []
-
- if event_type:
- conditions.append("event_type = ?")
- params.append(event_type.value)
- if task_id:
- conditions.append("task_id = ?")
- params.append(task_id)
- if agent_id:
- conditions.append("agent_id = ?")
- params.append(agent_id)
- if source:
- conditions.append("source = ?")
- params.append(source)
-
- where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
-
- query = f"""
- SELECT * FROM event_log
- {where_clause}
- ORDER BY timestamp DESC
- LIMIT ? OFFSET ?
- """
- params.extend([limit, offset])
-
- rows = conn.execute(query, params).fetchall()
- conn.close()
-
- return [
- EventLogEntry(
- id=r["id"],
- event_type=EventType(r["event_type"]),
- source=r["source"],
- task_id=r["task_id"],
- agent_id=r["agent_id"],
- data=r["data"],
- timestamp=r["timestamp"],
- )
- for r in rows
- ]
-
-
-def get_task_events(task_id: str) -> list[EventLogEntry]:
- """Get all events for a specific task."""
- return list_events(task_id=task_id, limit=1000)
-
-
-def get_agent_events(agent_id: str) -> list[EventLogEntry]:
- """Get all events for a specific agent."""
- return list_events(agent_id=agent_id, limit=1000)
-
-
-def get_recent_events(minutes: int = 60) -> list[EventLogEntry]:
- """Get events from the last N minutes."""
- conn = _get_conn()
-
- from datetime import timedelta
- cutoff = (datetime.now(timezone.utc) - timedelta(minutes=minutes)).isoformat()
-
- rows = conn.execute(
- """
- SELECT * FROM event_log
- WHERE timestamp > ?
- ORDER BY timestamp DESC
- """,
- (cutoff,),
- ).fetchall()
- conn.close()
-
- return [
- EventLogEntry(
- id=r["id"],
- event_type=EventType(r["event_type"]),
- source=r["source"],
- task_id=r["task_id"],
- agent_id=r["agent_id"],
- data=r["data"],
- timestamp=r["timestamp"],
- )
- for r in rows
- ]
-
-
-def get_event_summary(minutes: int = 60) -> dict:
- """Get a summary of recent events by type.
-
- Returns:
- Dict mapping event types to counts
- """
- conn = _get_conn()
-
- from datetime import timedelta
- cutoff = (datetime.now(timezone.utc) - timedelta(minutes=minutes)).isoformat()
-
- rows = conn.execute(
- """
- SELECT event_type, COUNT(*) as count
- FROM event_log
- WHERE timestamp > ?
- GROUP BY event_type
- ORDER BY count DESC
- """,
- (cutoff,),
- ).fetchall()
- conn.close()
-
- return {r["event_type"]: r["count"] for r in rows}
-
-
-def prune_events(older_than_days: int = 30) -> int:
- """Delete events older than specified days.
-
- Returns:
- Number of events deleted
- """
- conn = _get_conn()
-
- from datetime import timedelta
- cutoff = (datetime.now(timezone.utc) - timedelta(days=older_than_days)).isoformat()
-
- cursor = conn.execute(
- "DELETE FROM event_log WHERE timestamp < ?",
- (cutoff,),
- )
- deleted = cursor.rowcount
- conn.commit()
- conn.close()
-
- return deleted
diff --git a/src/swarm/registry.py b/src/swarm/registry.py
deleted file mode 100644
index 79107944..00000000
--- a/src/swarm/registry.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""SQLite-backed agent registry for the swarm.
-
-Each agent that joins the swarm registers here with its ID, name, and
-capabilities. The registry is the source of truth for which agents are
-available to bid on tasks.
-"""
-
-import sqlite3
-import uuid
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Optional
-
-DB_PATH = Path("data/swarm.db")
-
-
-def _get_conn() -> sqlite3.Connection:
- """Get a SQLite connection."""
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(str(DB_PATH))
- conn.row_factory = sqlite3.Row
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS agents (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'idle',
- capabilities TEXT DEFAULT '',
- registered_at TEXT NOT NULL,
- last_seen TEXT NOT NULL
- )
- """
- )
- conn.commit()
- return conn
-
-
-@dataclass
-class AgentRecord:
- id: str = field(default_factory=lambda: str(uuid.uuid4()))
- name: str = ""
- status: str = "idle" # idle | busy | offline
- capabilities: str = "" # comma-separated tags
- registered_at: str = field(
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
- )
- last_seen: str = field(
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
- )
-
-
-def _row_to_record(row: sqlite3.Row) -> AgentRecord:
- return AgentRecord(
- id=row["id"],
- name=row["name"],
- status=row["status"],
- capabilities=row["capabilities"],
- registered_at=row["registered_at"],
- last_seen=row["last_seen"],
- )
-
-
-def register(name: str, capabilities: str = "", agent_id: Optional[str] = None) -> AgentRecord:
- record = AgentRecord(
- id=agent_id or str(uuid.uuid4()),
- name=name,
- capabilities=capabilities,
- )
- conn = _get_conn()
- try:
- conn.execute(
- """
- INSERT OR REPLACE INTO agents (id, name, status, capabilities, registered_at, last_seen)
- VALUES (?, ?, ?, ?, ?, ?)
- """,
- (record.id, record.name, record.status, record.capabilities,
- record.registered_at, record.last_seen),
- )
- conn.commit()
- finally:
- conn.close()
- return record
-
-
-def unregister(agent_id: str) -> bool:
- conn = _get_conn()
- try:
- cursor = conn.execute("DELETE FROM agents WHERE id = ?", (agent_id,))
- conn.commit()
- return cursor.rowcount > 0
- finally:
- conn.close()
-
-
-def get_agent(agent_id: str) -> Optional[AgentRecord]:
- conn = _get_conn()
- try:
- row = conn.execute("SELECT * FROM agents WHERE id = ?", (agent_id,)).fetchone()
- return _row_to_record(row) if row else None
- finally:
- conn.close()
-
-
-def list_agents(status: Optional[str] = None) -> list[AgentRecord]:
- conn = _get_conn()
- try:
- if status:
- rows = conn.execute(
- "SELECT * FROM agents WHERE status = ? ORDER BY registered_at DESC",
- (status,),
- ).fetchall()
- else:
- rows = conn.execute(
- "SELECT * FROM agents ORDER BY registered_at DESC"
- ).fetchall()
- return [_row_to_record(r) for r in rows]
- finally:
- conn.close()
-
-
-def update_status(agent_id: str, status: str) -> Optional[AgentRecord]:
- now = datetime.now(timezone.utc).isoformat()
- conn = _get_conn()
- try:
- conn.execute(
- "UPDATE agents SET status = ?, last_seen = ? WHERE id = ?",
- (status, now, agent_id),
- )
- conn.commit()
- return get_agent(agent_id)
- finally:
- conn.close()
-
-
-def heartbeat(agent_id: str) -> Optional[AgentRecord]:
- """Update last_seen timestamp for a registered agent."""
- now = datetime.now(timezone.utc).isoformat()
- conn = _get_conn()
- try:
- conn.execute(
- "UPDATE agents SET last_seen = ? WHERE id = ?",
- (now, agent_id),
- )
- conn.commit()
- return get_agent(agent_id)
- finally:
- conn.close()
diff --git a/src/swarm/stats.py b/src/swarm/stats.py
deleted file mode 100644
index 3dd8f432..00000000
--- a/src/swarm/stats.py
+++ /dev/null
@@ -1,140 +0,0 @@
-"""Swarm agent statistics — persistent bid history and earnings.
-
-Stores one row per bid submitted during an auction. When an auction closes
-and a winner is declared, the winning row is flagged. This lets the
-marketplace compute per-agent stats (tasks won, total sats earned) without
-modifying the existing tasks / registry tables.
-
-All operations are synchronous SQLite writes, consistent with the existing
-swarm.tasks and swarm.registry modules.
-"""
-
-import sqlite3
-import uuid
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Optional
-
-DB_PATH = Path("data/swarm.db")
-
-
-def _get_conn() -> sqlite3.Connection:
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(str(DB_PATH))
- conn.row_factory = sqlite3.Row
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS bid_history (
- id TEXT PRIMARY KEY,
- task_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- bid_sats INTEGER NOT NULL,
- won INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL
- )
- """
- )
- conn.commit()
- return conn
-
-
-def record_bid(
- task_id: str,
- agent_id: str,
- bid_sats: int,
- won: bool = False,
-) -> str:
- """Insert a bid record and return its row id."""
- row_id = str(uuid.uuid4())
- now = datetime.now(timezone.utc).isoformat()
- conn = _get_conn()
- conn.execute(
- """
- INSERT INTO bid_history (id, task_id, agent_id, bid_sats, won, created_at)
- VALUES (?, ?, ?, ?, ?, ?)
- """,
- (row_id, task_id, agent_id, bid_sats, int(won), now),
- )
- conn.commit()
- conn.close()
- return row_id
-
-
-def mark_winner(task_id: str, agent_id: str) -> int:
- """Mark the winning bid for a task. Returns the number of rows updated."""
- conn = _get_conn()
- cursor = conn.execute(
- """
- UPDATE bid_history
- SET won = 1
- WHERE task_id = ? AND agent_id = ?
- """,
- (task_id, agent_id),
- )
- conn.commit()
- updated = cursor.rowcount
- conn.close()
- return updated
-
-
-def get_agent_stats(agent_id: str) -> dict:
- """Return tasks_won, total_earned, and total_bids for an agent."""
- conn = _get_conn()
- row = conn.execute(
- """
- SELECT
- COUNT(*) AS total_bids,
- SUM(won) AS tasks_won,
- SUM(CASE WHEN won = 1 THEN bid_sats ELSE 0 END) AS total_earned
- FROM bid_history
- WHERE agent_id = ?
- """,
- (agent_id,),
- ).fetchone()
- conn.close()
- return {
- "total_bids": row["total_bids"] or 0,
- "tasks_won": row["tasks_won"] or 0,
- "total_earned": row["total_earned"] or 0,
- }
-
-
-def get_all_agent_stats() -> dict[str, dict]:
- """Return stats keyed by agent_id for all agents that have bid."""
- conn = _get_conn()
- rows = conn.execute(
- """
- SELECT
- agent_id,
- COUNT(*) AS total_bids,
- SUM(won) AS tasks_won,
- SUM(CASE WHEN won = 1 THEN bid_sats ELSE 0 END) AS total_earned
- FROM bid_history
- GROUP BY agent_id
- """
- ).fetchall()
- conn.close()
- return {
- r["agent_id"]: {
- "total_bids": r["total_bids"] or 0,
- "tasks_won": r["tasks_won"] or 0,
- "total_earned": r["total_earned"] or 0,
- }
- for r in rows
- }
-
-
-def list_bids(task_id: Optional[str] = None) -> list[dict]:
- """Return raw bid rows, optionally filtered to a single task."""
- conn = _get_conn()
- if task_id:
- rows = conn.execute(
- "SELECT * FROM bid_history WHERE task_id = ? ORDER BY created_at",
- (task_id,),
- ).fetchall()
- else:
- rows = conn.execute(
- "SELECT * FROM bid_history ORDER BY created_at DESC"
- ).fetchall()
- conn.close()
- return [dict(r) for r in rows]
diff --git a/src/swarm/task_processor.py b/src/swarm/task_processor.py
deleted file mode 100644
index c85a48db..00000000
--- a/src/swarm/task_processor.py
+++ /dev/null
@@ -1,307 +0,0 @@
-"""Task processor for Timmy — consumes tasks from the queue one at a time.
-
-This module provides a background loop that Timmy uses to process tasks
-from the queue, including chat responses and self-generated tasks.
-
-On startup, the processor reconciles zombie tasks (stuck in RUNNING from
-a previous crash), drains all approved tasks, then enters the steady-state
-polling loop. Tasks that have no registered handler are moved to BACKLOGGED
-so they don't block the queue.
-"""
-
-import asyncio
-import logging
-from typing import Optional, Callable
-
-from swarm.task_queue.models import (
- QueueTask,
- TaskStatus,
- get_all_actionable_tasks,
- get_current_task_for_agent,
- get_next_pending_task,
- list_tasks,
- update_task_status,
- update_task_steps,
- get_task,
-)
-
-logger = logging.getLogger(__name__)
-
-
-class TaskProcessor:
- """Processes tasks from the queue for a specific agent."""
-
- def __init__(self, agent_id: str = "timmy"):
- self.agent_id = agent_id
- self._current_task: Optional[QueueTask] = None
- self._running = False
- self._handlers: dict[str, Callable] = {}
- self._user_callback: Optional[Callable[[str, str], None]] = (
- None # (message_type, content)
- )
-
- def register_handler(self, task_type: str, handler: Callable[[QueueTask], str]):
- """Register a handler for a specific task type.
-
- Handler receives the task and returns the result string.
- """
- self._handlers[task_type] = handler
-
- def set_user_callback(self, callback: Callable[[str, str], None]):
- """Set callback for pushing messages to the user.
-
- Args:
- callback: Function that takes (message_type, content)
- message_type: 'response', 'progress', 'notification'
- """
- self._user_callback = callback
-
- def push_to_user(self, message_type: str, content: str):
- """Push a message to the user via the registered callback."""
- if self._user_callback:
- try:
- self._user_callback(message_type, content)
- except Exception as e:
- logger.error("Failed to push message to user: %s", e)
- else:
- logger.debug("No user callback set, message not pushed: %s", content[:100])
-
- def reconcile_zombie_tasks(self) -> int:
- """Reset tasks stuck in RUNNING from a previous crash.
-
- Called once on startup. Any task in RUNNING status is assumed to
- be orphaned (the process that was executing it died). These are
- moved back to APPROVED so the processor can retry them.
-
- Returns the count of tasks reset.
- """
- zombies = list_tasks(status=TaskStatus.RUNNING, assigned_to=self.agent_id)
- count = 0
- for task in zombies:
- update_task_status(
- task.id,
- TaskStatus.FAILED,
- result="Server restarted — task did not complete. Will be retried.",
- )
- # Immediately re-queue as approved so it gets picked up again
- update_task_status(task.id, TaskStatus.APPROVED, result=None)
- count += 1
- logger.info(
- "Recycled zombie task: %s (%s)", task.title, task.id
- )
- if count:
- logger.info("Reconciled %d zombie task(s) for %s", count, self.agent_id)
- return count
-
- def _backlog_task(self, task: QueueTask, reason: str) -> None:
- """Move a task to the backlog with a reason."""
- update_task_status(
- task.id,
- TaskStatus.BACKLOGGED,
- result=f"Backlogged: {reason}",
- backlog_reason=reason,
- )
- update_task_steps(
- task.id,
- [{"description": f"Backlogged: {reason}", "status": "backlogged"}],
- )
- logger.info("Task backlogged: %s — %s", task.title, reason)
-
- async def process_single_task(self, task: QueueTask) -> Optional[QueueTask]:
- """Process one specific task. Backlog it if we can't handle it.
-
- Returns the task on success, or None if backlogged/failed.
- """
- # No handler → backlog immediately
- handler = self._handlers.get(task.task_type)
- if not handler:
- self._backlog_task(task, f"No handler for task type: {task.task_type}")
- return None
-
- # Tasks still awaiting approval shouldn't be auto-executed
- if task.status == TaskStatus.PENDING_APPROVAL and task.requires_approval:
- logger.debug("Skipping task %s — needs human approval", task.id)
- return None
-
- self._current_task = task
- update_task_status(task.id, TaskStatus.RUNNING)
-
- # Heartbeat on task start
- try:
- from swarm.registry import heartbeat
- heartbeat(self.agent_id)
- except Exception:
- pass
-
- try:
- logger.info("Processing task: %s (type: %s)", task.title, task.task_type)
-
- update_task_steps(
- task.id,
- [{"description": f"Processing: {task.title}", "status": "running"}],
- )
-
- result = handler(task)
-
- update_task_status(task.id, TaskStatus.COMPLETED, result=result)
- update_task_steps(
- task.id,
- [{"description": f"Completed: {task.title}", "status": "completed"}],
- )
-
- logger.info("Task completed: %s", task.id)
- return task
-
- except Exception as e:
- error_msg = str(e)
- logger.error("Task failed: %s - %s", task.id, error_msg)
-
- # Determine if this is a permanent (backlog) or transient (fail) error
- if self._is_permanent_failure(e):
- self._backlog_task(task, error_msg)
- else:
- update_task_status(task.id, TaskStatus.FAILED, result=error_msg)
-
- return None
- finally:
- self._current_task = None
-
- def _is_permanent_failure(self, error: Exception) -> bool:
- """Decide whether an error means the task can never succeed.
-
- Permanent failures get backlogged; transient ones stay as FAILED
- so they can be retried.
- """
- msg = str(error).lower()
- permanent_indicators = [
- "no handler",
- "not implemented",
- "unsupported",
- "not supported",
- "permission denied",
- "forbidden",
- "not found",
- "invalid task",
- ]
- return any(indicator in msg for indicator in permanent_indicators)
-
- async def drain_queue(self) -> dict:
- """Iterate through ALL actionable tasks right now — called on startup.
-
- Processes every approved/auto-approved task in priority order.
- Tasks that can't be handled are backlogged. Tasks still requiring
- human approval are skipped (left in PENDING_APPROVAL).
-
- Returns a summary dict with counts of processed, backlogged, skipped.
- """
- tasks = get_all_actionable_tasks(self.agent_id)
- summary = {"processed": 0, "backlogged": 0, "skipped": 0, "failed": 0}
-
- if not tasks:
- logger.info("Startup drain: no pending tasks for %s", self.agent_id)
- return summary
-
- logger.info(
- "Startup drain: %d task(s) to iterate through for %s",
- len(tasks),
- self.agent_id,
- )
-
- for task in tasks:
- # Skip tasks that need human approval
- if task.status == TaskStatus.PENDING_APPROVAL and task.requires_approval:
- logger.debug("Drain: skipping %s (needs approval)", task.title)
- summary["skipped"] += 1
- continue
-
- # No handler? Backlog it
- if task.task_type not in self._handlers:
- self._backlog_task(task, f"No handler for task type: {task.task_type}")
- summary["backlogged"] += 1
- continue
-
- # Try to process
- result = await self.process_single_task(task)
- if result:
- summary["processed"] += 1
- else:
- # Check if it was backlogged vs failed
- refreshed = get_task(task.id)
- if refreshed and refreshed.status == TaskStatus.BACKLOGGED:
- summary["backlogged"] += 1
- else:
- summary["failed"] += 1
-
- logger.info(
- "Startup drain complete: %d processed, %d backlogged, %d skipped, %d failed",
- summary["processed"],
- summary["backlogged"],
- summary["skipped"],
- summary["failed"],
- )
- return summary
-
- async def process_next_task(self) -> Optional[QueueTask]:
- """Process the next available task for this agent.
-
- Returns the task that was processed, or None if no tasks available.
- Uses in-memory _current_task (not DB status) to check concurrency,
- so zombie RUNNING rows from a previous crash don't block the queue.
- """
- # Check if we're actively working on something right now
- if self._current_task is not None:
- logger.debug("Already processing task: %s", self._current_task.id)
- return None
-
- # Get next approved task (pending_approval escalations are skipped)
- task = get_next_pending_task(self.agent_id)
- if not task:
- logger.debug("No pending tasks for %s", self.agent_id)
- return None
-
- return await self.process_single_task(task)
-
- async def run_loop(self, interval_seconds: float = 5.0):
- """Run the task processing loop.
-
- This should be called as a background task.
- """
- self._running = True
- logger.info("Task processor started for %s", self.agent_id)
-
- while self._running:
- # Heartbeat — update last_seen so health endpoint knows we're alive
- try:
- from swarm.registry import heartbeat
- heartbeat(self.agent_id)
- except Exception:
- pass # Graceful degradation
-
- try:
- await self.process_next_task()
- except Exception as e:
- logger.error("Task processor error: %s", e)
-
- await asyncio.sleep(interval_seconds)
-
- logger.info("Task processor stopped for %s", self.agent_id)
-
- def stop(self):
- """Stop the task processing loop."""
- self._running = False
-
- @property
- def current_task(self) -> Optional[QueueTask]:
- """Get the currently processing task."""
- if self._current_task:
- return get_task(self._current_task.id)
- return get_current_task_for_agent(self.agent_id)
-
-
-# Global processor instance
-task_processor = TaskProcessor("timmy")
-
-
-def get_task_processor() -> TaskProcessor:
- """Get the global task processor instance."""
- return task_processor
diff --git a/src/swarm/task_queue/__init__.py b/src/swarm/task_queue/__init__.py
deleted file mode 100644
index a6136e4d..00000000
--- a/src/swarm/task_queue/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Task Queue — Human-in-the-loop approval and execution system."""
diff --git a/src/swarm/task_queue/models.py b/src/swarm/task_queue/models.py
deleted file mode 100644
index 3fd898e1..00000000
--- a/src/swarm/task_queue/models.py
+++ /dev/null
@@ -1,570 +0,0 @@
-"""Task Queue data model — SQLite-backed CRUD with human-in-the-loop states.
-
-Table: task_queue in data/swarm.db
-"""
-
-import json
-import sqlite3
-import uuid
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from enum import Enum
-from pathlib import Path
-from typing import Optional
-
-
-DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "swarm.db"
-
-
-class TaskStatus(str, Enum):
- PENDING_APPROVAL = "pending_approval"
- APPROVED = "approved"
- RUNNING = "running"
- PAUSED = "paused"
- COMPLETED = "completed"
- VETOED = "vetoed"
- FAILED = "failed"
- BACKLOGGED = "backlogged"
-
-
-class TaskPriority(str, Enum):
- LOW = "low"
- NORMAL = "normal"
- HIGH = "high"
- URGENT = "urgent"
-
-
-@dataclass
-class TaskStep:
- description: str
- status: str = "pending" # pending, running, completed, failed
- started_at: Optional[str] = None
- completed_at: Optional[str] = None
- output: Optional[str] = None
-
-
-@dataclass
-class QueueTask:
- id: str = field(default_factory=lambda: str(uuid.uuid4()))
- title: str = ""
- description: str = ""
- task_type: str = "chat_response" # chat_response, thought, internal, external, escalation
- assigned_to: str = "timmy"
- created_by: str = "user"
- status: TaskStatus = TaskStatus.PENDING_APPROVAL
- priority: TaskPriority = TaskPriority.NORMAL
- requires_approval: bool = False
- auto_approve: bool = True
- parent_task_id: Optional[str] = None
- result: Optional[str] = None
- steps: list = field(default_factory=list)
- created_at: str = field(
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
- )
- started_at: Optional[str] = None
- completed_at: Optional[str] = None
- updated_at: str = field(
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
- )
- queue_position: Optional[int] = None # Position in queue when created
- backlog_reason: Optional[str] = None # Why the task was backlogged
-
-
-# ── Auto-Approve Rules ──────────────────────────────────────────────────
-# All tasks are auto-approved by default. The only exception is
-# task_type="escalation" — Timmy can explicitly escalate something to the
-# human, but even those never block the processor from working on other
-# tasks.
-
-
-def should_auto_approve(task: QueueTask) -> bool:
- """Check if a task should be auto-approved.
-
- Everything is approved automatically except explicit escalations
- (task_type="escalation"), which stay in pending_approval for human
- review but never block the queue.
- """
- if task.task_type == "escalation":
- return False
- return True
-
-
-# ── Database ─────────────────────────────────────────────────────────────
-
-
-def _get_conn() -> sqlite3.Connection:
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(str(DB_PATH))
- conn.row_factory = sqlite3.Row
- conn.execute("PRAGMA journal_mode=WAL")
- conn.execute("""
- CREATE TABLE IF NOT EXISTS task_queue (
- id TEXT PRIMARY KEY,
- title TEXT NOT NULL,
- description TEXT DEFAULT '',
- task_type TEXT DEFAULT 'chat_response',
- assigned_to TEXT DEFAULT 'timmy',
- created_by TEXT DEFAULT 'user',
- status TEXT DEFAULT 'approved',
- priority TEXT DEFAULT 'normal',
- requires_approval INTEGER DEFAULT 0,
- auto_approve INTEGER DEFAULT 1,
- parent_task_id TEXT,
- result TEXT,
- steps TEXT DEFAULT '[]',
- created_at TEXT NOT NULL,
- started_at TEXT,
- completed_at TEXT,
- updated_at TEXT NOT NULL,
- queue_position INTEGER,
- backlog_reason TEXT
- )
- """)
- conn.execute("CREATE INDEX IF NOT EXISTS idx_tq_status ON task_queue(status)")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_tq_priority ON task_queue(priority)")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_tq_created ON task_queue(created_at)")
-
- # Migrate existing tables - add new columns if they don't exist
- try:
- conn.execute(
- "ALTER TABLE task_queue ADD COLUMN task_type TEXT DEFAULT 'chat_response'"
- )
- except sqlite3.OperationalError:
- pass # Column already exists
- try:
- conn.execute("ALTER TABLE task_queue ADD COLUMN queue_position INTEGER")
- except sqlite3.OperationalError:
- pass # Column already exists
- try:
- conn.execute("ALTER TABLE task_queue ADD COLUMN backlog_reason TEXT")
- except sqlite3.OperationalError:
- pass # Column already exists
-
- conn.commit()
- return conn
-
-
-def _row_to_task(row: sqlite3.Row) -> QueueTask:
- d = dict(row)
- steps_raw = d.pop("steps", "[]")
- try:
- steps = json.loads(steps_raw) if steps_raw else []
- except (json.JSONDecodeError, TypeError):
- steps = []
- return QueueTask(
- id=d["id"],
- title=d["title"],
- description=d.get("description", ""),
- task_type=d.get("task_type", "chat_response"),
- assigned_to=d.get("assigned_to", "timmy"),
- created_by=d.get("created_by", "user"),
- status=TaskStatus(d["status"]),
- priority=TaskPriority(d.get("priority", "normal")),
- requires_approval=bool(d.get("requires_approval", 1)),
- auto_approve=bool(d.get("auto_approve", 0)),
- parent_task_id=d.get("parent_task_id"),
- result=d.get("result"),
- steps=steps,
- created_at=d["created_at"],
- started_at=d.get("started_at"),
- completed_at=d.get("completed_at"),
- updated_at=d["updated_at"],
- queue_position=d.get("queue_position"),
- backlog_reason=d.get("backlog_reason"),
- )
-
-
-# ── CRUD ─────────────────────────────────────────────────────────────────
-
-
-def create_task(
- title: str,
- description: str = "",
- assigned_to: str = "timmy",
- created_by: str = "user",
- priority: str = "normal",
- requires_approval: bool = False,
- auto_approve: bool = True,
- parent_task_id: Optional[str] = None,
- steps: Optional[list] = None,
- task_type: str = "chat_response",
-) -> QueueTask:
- """Create a new task in the queue."""
- now = datetime.now(timezone.utc).isoformat()
-
- # Calculate queue position - count tasks ahead in queue (pending or approved)
- queue_position = get_queue_position_ahead(assigned_to)
-
- task = QueueTask(
- title=title,
- description=description,
- task_type=task_type,
- assigned_to=assigned_to,
- created_by=created_by,
- status=TaskStatus.PENDING_APPROVAL,
- priority=TaskPriority(priority),
- requires_approval=requires_approval,
- auto_approve=auto_approve,
- parent_task_id=parent_task_id,
- steps=steps or [],
- created_at=now,
- updated_at=now,
- queue_position=queue_position,
- )
-
- # Check auto-approve — escalations stay in pending_approval
- if should_auto_approve(task):
- task.status = TaskStatus.APPROVED
- task.requires_approval = False
- else:
- task.requires_approval = True
- task.auto_approve = False
-
- conn = _get_conn()
- conn.execute(
- """INSERT INTO task_queue
- (id, title, description, task_type, assigned_to, created_by, status, priority,
- requires_approval, auto_approve, parent_task_id, result, steps,
- created_at, started_at, completed_at, updated_at, queue_position,
- backlog_reason)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (
- task.id,
- task.title,
- task.description,
- task.task_type,
- task.assigned_to,
- task.created_by,
- task.status.value,
- task.priority.value,
- int(task.requires_approval),
- int(task.auto_approve),
- task.parent_task_id,
- task.result,
- json.dumps(task.steps),
- task.created_at,
- task.started_at,
- task.completed_at,
- task.updated_at,
- task.queue_position,
- task.backlog_reason,
- ),
- )
- conn.commit()
- conn.close()
- return task
-
-
-def get_task(task_id: str) -> Optional[QueueTask]:
- conn = _get_conn()
- row = conn.execute("SELECT * FROM task_queue WHERE id = ?", (task_id,)).fetchone()
- conn.close()
- return _row_to_task(row) if row else None
-
-
-def list_tasks(
- status: Optional[TaskStatus] = None,
- priority: Optional[TaskPriority] = None,
- assigned_to: Optional[str] = None,
- created_by: Optional[str] = None,
- limit: int = 100,
-) -> list[QueueTask]:
- clauses, params = [], []
- if status:
- clauses.append("status = ?")
- params.append(status.value)
- if priority:
- clauses.append("priority = ?")
- params.append(priority.value)
- if assigned_to:
- clauses.append("assigned_to = ?")
- params.append(assigned_to)
- if created_by:
- clauses.append("created_by = ?")
- params.append(created_by)
-
- where = " WHERE " + " AND ".join(clauses) if clauses else ""
- params.append(limit)
-
- conn = _get_conn()
- rows = conn.execute(
- f"SELECT * FROM task_queue{where} ORDER BY created_at DESC LIMIT ?",
- params,
- ).fetchall()
- conn.close()
- return [_row_to_task(r) for r in rows]
-
-
-def update_task_status(
- task_id: str,
- new_status: TaskStatus,
- result: Optional[str] = None,
- backlog_reason: Optional[str] = None,
-) -> Optional[QueueTask]:
- now = datetime.now(timezone.utc).isoformat()
- conn = _get_conn()
-
- updates = ["status = ?", "updated_at = ?"]
- params = [new_status.value, now]
-
- if new_status == TaskStatus.RUNNING:
- updates.append("started_at = ?")
- params.append(now)
- elif new_status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.VETOED):
- updates.append("completed_at = ?")
- params.append(now)
-
- if result is not None:
- updates.append("result = ?")
- params.append(result)
-
- if backlog_reason is not None:
- updates.append("backlog_reason = ?")
- params.append(backlog_reason)
-
- params.append(task_id)
- conn.execute(
- f"UPDATE task_queue SET {', '.join(updates)} WHERE id = ?",
- params,
- )
- conn.commit()
-
- row = conn.execute("SELECT * FROM task_queue WHERE id = ?", (task_id,)).fetchone()
- conn.close()
- return _row_to_task(row) if row else None
-
-
-def update_task(
- task_id: str,
- title: Optional[str] = None,
- description: Optional[str] = None,
- assigned_to: Optional[str] = None,
- priority: Optional[str] = None,
-) -> Optional[QueueTask]:
- """Update task fields (for MODIFY action)."""
- now = datetime.now(timezone.utc).isoformat()
- conn = _get_conn()
-
- updates = ["updated_at = ?"]
- params = [now]
-
- if title is not None:
- updates.append("title = ?")
- params.append(title)
- if description is not None:
- updates.append("description = ?")
- params.append(description)
- if assigned_to is not None:
- updates.append("assigned_to = ?")
- params.append(assigned_to)
- if priority is not None:
- updates.append("priority = ?")
- params.append(priority)
-
- params.append(task_id)
- conn.execute(
- f"UPDATE task_queue SET {', '.join(updates)} WHERE id = ?",
- params,
- )
- conn.commit()
-
- row = conn.execute("SELECT * FROM task_queue WHERE id = ?", (task_id,)).fetchone()
- conn.close()
- return _row_to_task(row) if row else None
-
-
-def update_task_steps(task_id: str, steps: list) -> bool:
- """Update the steps array for a running task."""
- now = datetime.now(timezone.utc).isoformat()
- conn = _get_conn()
- cursor = conn.execute(
- "UPDATE task_queue SET steps = ?, updated_at = ? WHERE id = ?",
- (json.dumps(steps), now, task_id),
- )
- conn.commit()
- ok = cursor.rowcount > 0
- conn.close()
- return ok
-
-
-def get_counts_by_status() -> dict[str, int]:
- conn = _get_conn()
- rows = conn.execute(
- "SELECT status, COUNT(*) as cnt FROM task_queue GROUP BY status"
- ).fetchall()
- conn.close()
- return {r["status"]: r["cnt"] for r in rows}
-
-
-def get_pending_count() -> int:
- conn = _get_conn()
- row = conn.execute(
- "SELECT COUNT(*) as cnt FROM task_queue WHERE status = 'pending_approval'"
- ).fetchone()
- conn.close()
- return row["cnt"] if row else 0
-
-
-def get_queue_position_ahead(assigned_to: str) -> int:
- """Get count of tasks ahead of new tasks for a given assignee.
-
- Counts tasks that are approved or running (waiting to be processed).
- Escalations in pending_approval are excluded — they don't block the queue.
- """
- conn = _get_conn()
- row = conn.execute(
- """SELECT COUNT(*) as cnt FROM task_queue
- WHERE assigned_to = ? AND status IN ('approved', 'running')
- AND created_at < datetime('now')""",
- (assigned_to,),
- ).fetchone()
- conn.close()
- return row["cnt"] if row else 0
-
-
-def get_queue_status_for_task(task_id: str) -> dict:
- """Get queue position info for a specific task."""
- task = get_task(task_id)
- if not task:
- return {"error": "Task not found"}
-
- conn = _get_conn()
- # Count tasks ahead of this one (created earlier, not completed)
- ahead = conn.execute(
- """SELECT COUNT(*) as cnt FROM task_queue
- WHERE assigned_to = ? AND status NOT IN ('completed', 'failed', 'vetoed')
- AND created_at < ?""",
- (task.assigned_to, task.created_at),
- ).fetchone()
- total = conn.execute(
- """SELECT COUNT(*) as cnt FROM task_queue
- WHERE assigned_to = ? AND status NOT IN ('completed', 'failed', 'vetoed')""",
- (task.assigned_to,),
- ).fetchone()
- conn.close()
-
- position = ahead["cnt"] + 1 if ahead else 1
- total_count = total["cnt"] if total else 1
-
- return {
- "task_id": task_id,
- "position": position,
- "total": total_count,
- "percent_ahead": int((ahead["cnt"] / total_count * 100))
- if total_count > 0
- else 0,
- }
-
-
-def get_current_task_for_agent(assigned_to: str) -> Optional[QueueTask]:
- """Get the currently running task for an agent."""
- conn = _get_conn()
- row = conn.execute(
- """SELECT * FROM task_queue
- WHERE assigned_to = ? AND status = 'running'
- ORDER BY started_at DESC LIMIT 1""",
- (assigned_to,),
- ).fetchone()
- conn.close()
- return _row_to_task(row) if row else None
-
-
-def get_next_pending_task(assigned_to: str) -> Optional[QueueTask]:
- """Get the next approved task for an agent to work on.
-
- Only returns tasks in APPROVED status. Escalations sitting in
- PENDING_APPROVAL are intentionally skipped — they wait for human
- review but never block the queue.
- """
- conn = _get_conn()
- row = conn.execute(
- """SELECT * FROM task_queue
- WHERE assigned_to = ? AND status = 'approved'
- ORDER BY
- CASE priority
- WHEN 'urgent' THEN 1
- WHEN 'high' THEN 2
- WHEN 'normal' THEN 3
- WHEN 'low' THEN 4
- END,
- created_at ASC
- LIMIT 1""",
- (assigned_to,),
- ).fetchone()
- conn.close()
- return _row_to_task(row) if row else None
-
-
-def get_task_summary_for_briefing() -> dict:
- """Get task stats for the morning briefing."""
- counts = get_counts_by_status()
- conn = _get_conn()
- # Failed tasks
- failed = conn.execute(
- "SELECT title, result FROM task_queue WHERE status = 'failed' ORDER BY updated_at DESC LIMIT 5"
- ).fetchall()
- # Backlogged tasks
- backlogged = conn.execute(
- "SELECT title, backlog_reason FROM task_queue WHERE status = 'backlogged' ORDER BY updated_at DESC LIMIT 5"
- ).fetchall()
- conn.close()
-
- return {
- "pending_approval": counts.get("pending_approval", 0),
- "running": counts.get("running", 0),
- "completed": counts.get("completed", 0),
- "failed": counts.get("failed", 0),
- "vetoed": counts.get("vetoed", 0),
- "backlogged": counts.get("backlogged", 0),
- "total": sum(counts.values()),
- "recent_failures": [
- {"title": r["title"], "result": r["result"]} for r in failed
- ],
- "recent_backlogged": [
- {"title": r["title"], "reason": r["backlog_reason"]} for r in backlogged
- ],
- }
-
-
-def list_backlogged_tasks(
- assigned_to: Optional[str] = None, limit: int = 50
-) -> list[QueueTask]:
- """List all backlogged tasks, optionally filtered by assignee."""
- conn = _get_conn()
- if assigned_to:
- rows = conn.execute(
- """SELECT * FROM task_queue WHERE status = 'backlogged' AND assigned_to = ?
- ORDER BY priority, created_at ASC LIMIT ?""",
- (assigned_to, limit),
- ).fetchall()
- else:
- rows = conn.execute(
- """SELECT * FROM task_queue WHERE status = 'backlogged'
- ORDER BY priority, created_at ASC LIMIT ?""",
- (limit,),
- ).fetchall()
- conn.close()
- return [_row_to_task(r) for r in rows]
-
-
-def get_all_actionable_tasks(assigned_to: str) -> list[QueueTask]:
- """Get all tasks that should be processed on startup — approved or auto-approved pending.
-
- Returns tasks ordered by priority then creation time (urgent first, oldest first).
- """
- conn = _get_conn()
- rows = conn.execute(
- """SELECT * FROM task_queue
- WHERE assigned_to = ? AND status IN ('approved', 'pending_approval')
- ORDER BY
- CASE priority
- WHEN 'urgent' THEN 1
- WHEN 'high' THEN 2
- WHEN 'normal' THEN 3
- WHEN 'low' THEN 4
- END,
- created_at ASC""",
- (assigned_to,),
- ).fetchall()
- conn.close()
- return [_row_to_task(r) for r in rows]
diff --git a/src/swarm/tasks.py b/src/swarm/tasks.py
deleted file mode 100644
index 2e352721..00000000
--- a/src/swarm/tasks.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""Swarm task dataclasses and CRUD operations.
-
-Tasks are the unit of work in the swarm system. A coordinator posts a task,
-agents bid on it, and the winning agent executes it. All persistence goes
-through SQLite so the system survives restarts.
-"""
-
-import sqlite3
-import uuid
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from enum import Enum
-from pathlib import Path
-from typing import Optional
-
-DB_PATH = Path("data/swarm.db")
-
-
-class TaskStatus(str, Enum):
- PENDING = "pending"
- BIDDING = "bidding"
- ASSIGNED = "assigned"
- RUNNING = "running"
- COMPLETED = "completed"
- FAILED = "failed"
-
-
-@dataclass
-class Task:
- id: str = field(default_factory=lambda: str(uuid.uuid4()))
- description: str = ""
- status: TaskStatus = TaskStatus.PENDING
- assigned_agent: Optional[str] = None
- result: Optional[str] = None
- created_at: str = field(
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
- )
- completed_at: Optional[str] = None
-
-
-def _get_conn() -> sqlite3.Connection:
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(str(DB_PATH))
- conn.row_factory = sqlite3.Row
- conn.execute(
- """
- CREATE TABLE IF NOT EXISTS tasks (
- id TEXT PRIMARY KEY,
- description TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending',
- assigned_agent TEXT,
- result TEXT,
- created_at TEXT NOT NULL,
- completed_at TEXT
- )
- """
- )
- conn.commit()
- return conn
-
-
-def create_task(description: str) -> Task:
- task = Task(description=description)
- conn = _get_conn()
- conn.execute(
- "INSERT INTO tasks (id, description, status, created_at) VALUES (?, ?, ?, ?)",
- (task.id, task.description, task.status.value, task.created_at),
- )
- conn.commit()
- conn.close()
- return task
-
-
-def get_task(task_id: str) -> Optional[Task]:
- conn = _get_conn()
- row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
- conn.close()
- if row is None:
- return None
- return Task(
- id=row["id"],
- description=row["description"],
- status=TaskStatus(row["status"]),
- assigned_agent=row["assigned_agent"],
- result=row["result"],
- created_at=row["created_at"],
- completed_at=row["completed_at"],
- )
-
-
-def list_tasks(status: Optional[TaskStatus] = None) -> list[Task]:
- conn = _get_conn()
- if status:
- rows = conn.execute(
- "SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC",
- (status.value,),
- ).fetchall()
- else:
- rows = conn.execute(
- "SELECT * FROM tasks ORDER BY created_at DESC"
- ).fetchall()
- conn.close()
- return [
- Task(
- id=r["id"],
- description=r["description"],
- status=TaskStatus(r["status"]),
- assigned_agent=r["assigned_agent"],
- result=r["result"],
- created_at=r["created_at"],
- completed_at=r["completed_at"],
- )
- for r in rows
- ]
-
-
-def update_task(task_id: str, **kwargs) -> Optional[Task]:
- conn = _get_conn()
- allowed = {"status", "assigned_agent", "result", "completed_at"}
- updates = {k: v for k, v in kwargs.items() if k in allowed}
- if not updates:
- conn.close()
- return get_task(task_id)
- # Convert enums to their value
- if "status" in updates and isinstance(updates["status"], TaskStatus):
- updates["status"] = updates["status"].value
- set_clause = ", ".join(f"{k} = ?" for k in updates)
- values = list(updates.values()) + [task_id]
- conn.execute(f"UPDATE tasks SET {set_clause} WHERE id = ?", values)
- conn.commit()
- conn.close()
- return get_task(task_id)
-
-
-def delete_task(task_id: str) -> bool:
- conn = _get_conn()
- cursor = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
- conn.commit()
- deleted = cursor.rowcount > 0
- conn.close()
- return deleted
diff --git a/src/timmy/agents/base.py b/src/timmy/agents/base.py
index 7e70239c..0ddef635 100644
--- a/src/timmy/agents/base.py
+++ b/src/timmy/agents/base.py
@@ -16,7 +16,11 @@ from agno.models.ollama import Ollama
from config import settings
from infrastructure.events.bus import EventBus, Event
-from mcp.registry import tool_registry
+
+try:
+ from mcp.registry import tool_registry
+except ImportError:
+ tool_registry = None
logger = logging.getLogger(__name__)
diff --git a/src/timmy/agents/timmy.py b/src/timmy/agents/timmy.py
index 4b5019a7..eedbf77d 100644
--- a/src/timmy/agents/timmy.py
+++ b/src/timmy/agents/timmy.py
@@ -15,7 +15,6 @@ from agno.models.ollama import Ollama
from timmy.agents.base import BaseAgent
from config import settings
from infrastructure.events.bus import EventBus, event_bus
-from mcp.registry import tool_registry
logger = logging.getLogger(__name__)
diff --git a/src/timmy/cli.py b/src/timmy/cli.py
index e5e8bc7c..1b957c8c 100644
--- a/src/timmy/cli.py
+++ b/src/timmy/cli.py
@@ -179,45 +179,8 @@ def ingest_report(
typer.echo("(dry run — no tasks created)")
return
- # Import and create tasks
- from swarm.task_queue.models import create_task
-
- severity_map = {"P0": "urgent", "P1": "high", "P2": "normal"}
- created = 0
- for bug in bugs:
- title = bug.get("title", "")
- severity = bug.get("severity", "P2")
- description = bug.get("description", "")
-
- if not title or not description:
- typer.echo(f" SKIP (missing title or description)")
- continue
-
- # Format description with extra fields
- parts = [f"**Reporter:** {reporter}", f"**Severity:** {severity}", "", description]
- if bug.get("evidence"):
- parts += ["", "## Evidence", bug["evidence"]]
- if bug.get("root_cause"):
- parts += ["", "## Root Cause", bug["root_cause"]]
- if bug.get("fix_options"):
- parts += ["", "## Fix Options"]
- for i, fix in enumerate(bug["fix_options"], 1):
- parts.append(f"{i}. {fix}")
-
- task = create_task(
- title=f"[{severity}] {title}",
- description="\n".join(parts),
- task_type="bug_report",
- assigned_to="timmy",
- created_by=reporter,
- priority=severity_map.get(severity, "normal"),
- requires_approval=False,
- auto_approve=True,
- )
- typer.echo(f" OK [{severity}] {title} → {task.id}")
- created += 1
-
- typer.echo(f"\n{created} task(s) created.")
+ typer.echo("Task queue not available (swarm module removed).", err=True)
+ raise typer.Exit(1)
def main():
diff --git a/src/timmy/docker_agent.py b/src/timmy/docker_agent.py
deleted file mode 100644
index b84f8dfb..00000000
--- a/src/timmy/docker_agent.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""Timmy — standalone Docker container entry point.
-
-Runs Timmy as an independent swarm participant:
- 1. Registers "timmy" in the SQLite registry with capabilities
- 2. Sends heartbeats every 30 s so the dashboard can track liveness
- 3. Polls the coordinator for tasks assigned to "timmy"
- 4. Executes them through the Agno/Ollama backend
- 5. Marks each task COMPLETED (or FAILED) via the internal HTTP API
-
-Usage (Docker)::
-
- COORDINATOR_URL=http://dashboard:8000 \
- OLLAMA_URL=http://host.docker.internal:11434 \
- python -m timmy.docker_agent
-
-Environment variables
----------------------
-COORDINATOR_URL Where to reach the dashboard (required)
-OLLAMA_URL Ollama base URL (default: http://localhost:11434)
-TIMMY_AGENT_ID Override the registry ID (default: "timmy")
-"""
-
-import asyncio
-import logging
-import os
-import signal
-
-import httpx
-
-from swarm import registry
-
-logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s %(levelname)-8s %(name)s — %(message)s",
- datefmt="%H:%M:%S",
-)
-logger = logging.getLogger(__name__)
-
-AGENT_ID = os.environ.get("TIMMY_AGENT_ID", "timmy")
-COORDINATOR = os.environ.get("COORDINATOR_URL", "").rstrip("/")
-POLL_INTERVAL = 5 # seconds between task polls
-HEARTBEAT_INTERVAL = 30
-
-
-async def _run_task(task_id: str, description: str, client: httpx.AsyncClient) -> None:
- """Execute a task using Timmy's AI backend and report the result."""
- logger.info("Timmy executing task %s: %s", task_id, description[:60])
- result = None
- try:
- from timmy.agent import create_timmy
- agent = create_timmy()
- run = agent.run(description, stream=False)
- result = run.content if hasattr(run, "content") else str(run)
- logger.info("Task %s completed", task_id)
- except Exception as exc:
- result = f"Timmy error: {exc}"
- logger.warning("Task %s failed: %s", task_id, exc)
-
- # Report back to coordinator via HTTP
- try:
- await client.post(
- f"{COORDINATOR}/swarm/tasks/{task_id}/complete",
- data={"result": result or "(no output)"},
- )
- except Exception as exc:
- logger.error("Could not report task %s result: %s", task_id, exc)
-
-
-async def _heartbeat_loop(stop: asyncio.Event) -> None:
- while not stop.is_set():
- try:
- registry.heartbeat(AGENT_ID)
- except Exception as exc:
- logger.warning("Heartbeat error: %s", exc)
- try:
- await asyncio.wait_for(stop.wait(), timeout=HEARTBEAT_INTERVAL)
- except asyncio.TimeoutError:
- pass
-
-
-async def _task_loop(stop: asyncio.Event) -> None:
- seen: set[str] = set()
- async with httpx.AsyncClient(timeout=10.0) as client:
- while not stop.is_set():
- try:
- resp = await client.get(f"{COORDINATOR}/swarm/tasks?status=assigned")
- if resp.status_code == 200:
- for task in resp.json().get("tasks", []):
- if task.get("assigned_agent") != AGENT_ID:
- continue
- task_id = task["id"]
- if task_id in seen:
- continue
- seen.add(task_id)
- asyncio.create_task(
- _run_task(task_id, task["description"], client)
- )
- except Exception as exc:
- logger.warning("Task poll error: %s", exc)
-
- try:
- await asyncio.wait_for(stop.wait(), timeout=POLL_INTERVAL)
- except asyncio.TimeoutError:
- pass
-
-
-async def main() -> None:
- if not COORDINATOR:
- logger.error("COORDINATOR_URL is not set — exiting")
- return
-
- # Register Timmy in the shared SQLite registry
- registry.register(
- name="Timmy",
- capabilities="chat,reasoning,research,planning",
- agent_id=AGENT_ID,
- )
- logger.info("Timmy registered (id=%s) — coordinator: %s", AGENT_ID, COORDINATOR)
-
- stop = asyncio.Event()
-
- def _handle_signal(*_):
- logger.info("Timmy received shutdown signal")
- stop.set()
-
- for sig in (signal.SIGTERM, signal.SIGINT):
- signal.signal(sig, _handle_signal)
-
- await asyncio.gather(
- _heartbeat_loop(stop),
- _task_loop(stop),
- )
-
- registry.update_status(AGENT_ID, "offline")
- logger.info("Timmy shut down")
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/src/timmy/thinking.py b/src/timmy/thinking.py
index 1d3b0259..d21543f0 100644
--- a/src/timmy/thinking.py
+++ b/src/timmy/thinking.py
@@ -266,13 +266,6 @@ class ThinkingEngine:
def _seed_from_scripture(self) -> str:
"""Gather current scripture meditation focus as thought seed."""
- try:
- from scripture.meditation import meditation_scheduler
- verse = meditation_scheduler.current_focus()
- if verse:
- return f"Scripture in focus: {verse.text} ({verse.reference if hasattr(verse, 'reference') else ''})"
- except Exception as exc:
- logger.debug("Scripture seed unavailable: %s", exc)
return "Scripture is on my mind, though no specific verse is in focus."
def _seed_from_memory(self) -> str:
diff --git a/src/timmy_serve/app.py b/src/timmy_serve/app.py
index 146dcd47..07a5c4c4 100644
--- a/src/timmy_serve/app.py
+++ b/src/timmy_serve/app.py
@@ -1,34 +1,24 @@
-"""Timmy Serve — FastAPI app with L402 payment gating.
-
-Provides a paid API for Timmy's services, gated by Lightning payments
-via the L402 protocol.
+"""Timmy Serve — FastAPI app for Timmy's API.
Endpoints:
- POST /serve/chat — L402-gated chat (pay per request)
- GET /serve/invoice — Request a Lightning invoice
+ POST /serve/chat — Chat with Timmy
GET /serve/status — Service status
+ GET /health — Health check
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
-from typing import Optional
-from fastapi import FastAPI, HTTPException, Request, Response
-from fastapi.responses import JSONResponse
+from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
from config import settings
from timmy.agent import create_timmy
-from timmy_serve.l402_proxy import create_l402_challenge, verify_l402_token
-from timmy_serve.payment_handler import payment_handler
logger = logging.getLogger(__name__)
-# Default pricing (sats per request)
-DEFAULT_PRICE_SATS = 100
-
class ChatRequest(BaseModel):
message: str
@@ -37,168 +27,57 @@ class ChatRequest(BaseModel):
class ChatResponse(BaseModel):
response: str
- payment_hash: Optional[str] = None
-
-
-class InvoiceRequest(BaseModel):
- amount_sats: int = DEFAULT_PRICE_SATS
- memo: str = "Timmy API access"
-
-
-class InvoiceResponse(BaseModel):
- payment_request: str
- payment_hash: str
- amount_sats: int
class StatusResponse(BaseModel):
status: str
backend: str
- price_sats: int
- total_invoices: int
- total_earned_sats: int
-def create_timmy_serve_app(price_sats: int = DEFAULT_PRICE_SATS) -> FastAPI:
- """Create the Timmy Serve FastAPI application with L402 middleware.
-
- Args:
- price_sats: Default price per API request in satoshis
-
- Returns:
- Configured FastAPI application
- """
-
+def create_timmy_serve_app() -> FastAPI:
+ """Create the Timmy Serve FastAPI application."""
+
@asynccontextmanager
async def lifespan(app: FastAPI):
- logger.info("Timmy Serve starting — price: %d sats/request", price_sats)
+ logger.info("Timmy Serve starting")
yield
logger.info("Timmy Serve shutting down")
-
+
app = FastAPI(
title="Timmy Serve — Sovereign AI API",
- description="Lightning-gated API access to Timmy",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs" if settings.debug else None,
redoc_url="/redoc" if settings.debug else None,
)
-
- # Store price in app state for middleware access
- app.state.price_sats = price_sats
-
- @app.middleware("http")
- async def l402_middleware(request: Request, call_next):
- """Middleware to enforce L402 payment for protected endpoints."""
-
- # Only protect /serve/chat endpoint
- if request.url.path != "/serve/chat":
- return await call_next(request)
-
- # Skip for OPTIONS requests (CORS preflight)
- if request.method == "OPTIONS":
- return await call_next(request)
-
- # Check for L402 token in Authorization header
- auth_header = request.headers.get("authorization", "")
-
- if auth_header.startswith("L402 "):
- token = auth_header[5:] # Remove "L402 " prefix
- # Check for token:preimage format
- if ":" in token:
- macaroon, preimage = token.split(":", 1)
- if verify_l402_token(macaroon, preimage):
- # Payment verified, proceed
- return await call_next(request)
-
- # No valid payment, return 402 Payment Required
- challenge = create_l402_challenge(price_sats, "Timmy API request")
-
- return JSONResponse(
- status_code=402,
- content={
- "error": "Payment Required",
- "code": "L402",
- "macaroon": challenge["macaroon"],
- "invoice": challenge["invoice"],
- "payment_hash": challenge["payment_hash"],
- "amount_sats": price_sats,
- },
- headers={
- "WWW-Authenticate": f'L402 macaroon="{challenge["macaroon"]}", invoice="{challenge["invoice"]}"',
- },
- )
-
+
@app.get("/serve/status", response_model=StatusResponse)
async def serve_status():
- """Get service status and pricing information."""
- invoices = payment_handler.list_invoices(settled_only=True)
- total_earned = sum(i.amount_sats for i in invoices)
-
+ """Get service status."""
return StatusResponse(
status="active",
backend=settings.timmy_model_backend,
- price_sats=price_sats,
- total_invoices=len(payment_handler.list_invoices()),
- total_earned_sats=total_earned,
)
-
- @app.post("/serve/invoice", response_model=InvoiceResponse)
- async def serve_invoice(request: InvoiceRequest):
- """Create a Lightning invoice for API access."""
- invoice = payment_handler.create_invoice(
- amount_sats=request.amount_sats,
- memo=request.memo,
- )
-
- return InvoiceResponse(
- payment_request=invoice.payment_request,
- payment_hash=invoice.payment_hash,
- amount_sats=invoice.amount_sats,
- )
-
+
@app.post("/serve/chat", response_model=ChatResponse)
async def serve_chat(request: Request, body: ChatRequest):
- """Process a chat request (L402-gated).
-
- Requires valid L402 token in Authorization header:
- Authorization: L402 :
- """
+ """Process a chat request."""
try:
- # Create Timmy agent and process request
timmy = create_timmy()
result = timmy.run(body.message, stream=False)
response_text = result.content if hasattr(result, "content") else str(result)
- # Get payment hash from Authorization header for receipt
- auth_header = request.headers.get("authorization", "")
- payment_hash = None
- if auth_header.startswith("L402 ") and ":" in auth_header[5:]:
- macaroon = auth_header[5:].split(":", 1)[0]
- # Extract payment hash from macaroon (it's the identifier)
- try:
- import base64
- raw = base64.urlsafe_b64decode(macaroon.encode()).decode()
- parts = raw.split(":")
- if len(parts) == 4:
- payment_hash = parts[2]
- except Exception:
- pass
-
- return ChatResponse(
- response=response_text,
- payment_hash=payment_hash,
- )
-
+ return ChatResponse(response=response_text)
+
except Exception as exc:
logger.error("Chat processing error: %s", exc)
raise HTTPException(status_code=500, detail=f"Processing error: {exc}")
-
+
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"status": "healthy", "service": "timmy-serve"}
-
+
return app
diff --git a/src/timmy_serve/cli.py b/src/timmy_serve/cli.py
index ddd98be4..ca3738f3 100644
--- a/src/timmy_serve/cli.py
+++ b/src/timmy_serve/cli.py
@@ -1,18 +1,13 @@
-"""Serve-mode CLI — run Timmy as a paid service agent.
-
-This CLI starts Timmy in "serve" mode where it accepts requests
-gated by L402 Lightning payments. This is the economic layer that
-makes Timmy a sovereign agent — it earns sats for its work.
+"""Serve-mode CLI — run Timmy as an API service.
Usage:
timmy-serve start [--port 8402]
- timmy-serve invoice --amount 100 --memo "API access"
timmy-serve status
"""
import typer
-app = typer.Typer(help="Timmy Serve — sovereign AI agent with Lightning payments")
+app = typer.Typer(help="Timmy Serve — sovereign AI agent API")
@app.command()
@@ -22,57 +17,33 @@ def start(
price: int = typer.Option(100, "--price", help="Price per request in sats"),
dry_run: bool = typer.Option(False, "--dry-run", help="Print config and exit (for testing)"),
):
- """Start Timmy in serve mode with L402 payment gating."""
+ """Start Timmy in serve mode."""
typer.echo(f"Starting Timmy Serve on {host}:{port}")
typer.echo(f"L402 payment proxy active — {price} sats per request")
typer.echo("Press Ctrl-C to stop")
-
+
typer.echo(f"\nEndpoints:")
- typer.echo(f" POST /serve/chat — L402-gated chat (pay per request)")
- typer.echo(f" GET /serve/invoice — Request a Lightning invoice")
+ typer.echo(f" POST /serve/chat — Chat with Timmy")
+ typer.echo(f" GET /serve/invoice — Request an invoice")
typer.echo(f" GET /serve/status — Service status")
typer.echo(f" GET /health — Health check")
-
+
if dry_run:
typer.echo("\n(Dry run mode - not starting server)")
return
-
+
import uvicorn
from timmy_serve.app import create_timmy_serve_app
-
- # Create and run the FastAPI app
- serve_app = create_timmy_serve_app(price_sats=price)
+
+ serve_app = create_timmy_serve_app()
uvicorn.run(serve_app, host=host, port=port)
-@app.command()
-def invoice(
- amount: int = typer.Option(100, "--amount", "-a", help="Invoice amount in sats"),
- memo: str = typer.Option("API access", "--memo", "-m", help="Invoice memo"),
-):
- """Create a Lightning invoice."""
- from timmy_serve.payment_handler import payment_handler
-
- inv = payment_handler.create_invoice(amount, memo)
- typer.echo(f"Invoice created:")
- typer.echo(f" Amount: {inv.amount_sats} sats")
- typer.echo(f" Memo: {inv.memo}")
- typer.echo(f" Payment hash: {inv.payment_hash}")
- typer.echo(f" Pay request: {inv.payment_request}")
-
-
@app.command()
def status():
"""Show serve-mode status."""
- from timmy_serve.payment_handler import payment_handler
-
- invoices = payment_handler.list_invoices()
- settled = [i for i in invoices if i.settled]
typer.echo("Timmy Serve — Status")
- typer.echo(f" Total invoices: {len(invoices)}")
- typer.echo(f" Settled: {len(settled)}")
- total_sats = sum(i.amount_sats for i in settled)
- typer.echo(f" Total earned: {total_sats} sats")
+ typer.echo(" Service: active")
def main():
diff --git a/src/timmy_serve/l402_proxy.py b/src/timmy_serve/l402_proxy.py
deleted file mode 100644
index 3b06c91b..00000000
--- a/src/timmy_serve/l402_proxy.py
+++ /dev/null
@@ -1,131 +0,0 @@
-"""L402 payment proxy — HMAC macaroon-based access control.
-
-Implements the L402 protocol (formerly LSAT) for gating API access
-behind Lightning payments. A client that hasn't paid receives a
-402 Payment Required response with a macaroon and invoice. After
-paying, the client presents the macaroon + preimage to gain access.
-
-This is the economic layer that gives Timmy real agency — agents pay
-each other in sats, not API keys.
-"""
-
-import base64
-import hashlib
-import hmac
-import logging
-import time
-from dataclasses import dataclass
-from typing import Optional
-
-from config import settings
-from timmy_serve.payment_handler import payment_handler
-
-logger = logging.getLogger(__name__)
-
-# Read secrets from centralised config (validated at startup in config.py)
-_MACAROON_SECRET = settings.l402_macaroon_secret.encode()
-_HMAC_SECRET_RAW = settings.l402_hmac_secret
-_HMAC_SECRET = _HMAC_SECRET_RAW.encode()
-
-
-@dataclass
-class Macaroon:
- """Simplified HMAC-based macaroon for L402 authentication."""
- identifier: str # payment_hash
- signature: str # HMAC signature
- location: str = "timmy-time"
- version: int = 1
- hmac_secret: str = "" # Added for multi-key support
-
- def serialize(self) -> str:
- """Encode the macaroon as a base64 string."""
- raw = f"{self.version}:{self.location}:{self.identifier}:{self.signature}:{self.hmac_secret}"
- return base64.urlsafe_b64encode(raw.encode()).decode()
-
- @classmethod
- def deserialize(cls, token: str) -> Optional["Macaroon"]:
- """Decode a base64 macaroon string."""
- try:
- raw = base64.urlsafe_b64decode(token.encode()).decode()
- parts = raw.split(":")
- if len(parts) < 4:
- return None
- return cls(
- version=int(parts[0]),
- location=parts[1],
- identifier=parts[2],
- signature=parts[3],
- hmac_secret=parts[4] if len(parts) > 4 else "",
- )
- except Exception:
- return None
-
-
-def _sign(identifier: str, hmac_secret: Optional[str] = None) -> str:
- """Create an HMAC signature for a macaroon identifier using two-key derivation.
-
- The base macaroon secret is used to derive a key-specific secret from the
- hmac_secret, which is then used to sign the identifier. This prevents
- macaroon forgery if the hmac_secret is known but the base secret is not.
- """
- key = hmac.new(
- _MACAROON_SECRET,
- (hmac_secret or _HMAC_SECRET_RAW).encode(),
- hashlib.sha256
- ).digest()
- return hmac.new(key, identifier.encode(), hashlib.sha256).hexdigest()
-
-
-def create_l402_challenge(amount_sats: int, memo: str = "API access") -> dict:
- """Create an L402 challenge: invoice + macaroon.
-
- Returns a dict with:
- - macaroon: serialized macaroon token
- - invoice: bolt11 payment request
- - payment_hash: for tracking payment status
- """
- invoice = payment_handler.create_invoice(amount_sats, memo)
- hmac_secret = _HMAC_SECRET_RAW
- signature = _sign(invoice.payment_hash, hmac_secret)
- macaroon = Macaroon(
- identifier=invoice.payment_hash,
- signature=signature,
- hmac_secret=hmac_secret,
- )
- logger.info("L402 challenge created: %d sats — %s", amount_sats, memo)
- return {
- "macaroon": macaroon.serialize(),
- "invoice": invoice.payment_request,
- "payment_hash": invoice.payment_hash,
- }
-
-
-def verify_l402_token(token: str, preimage: Optional[str] = None) -> bool:
- """Verify an L402 token (macaroon + optional preimage).
-
- Verification checks:
- 1. Macaroon signature is valid (HMAC matches)
- 2. The corresponding invoice has been paid
- """
- macaroon = Macaroon.deserialize(token)
- if macaroon is None:
- logger.warning("L402: invalid macaroon format")
- return False
-
- # Check HMAC signature
- expected_sig = _sign(macaroon.identifier, macaroon.hmac_secret)
- if not hmac.compare_digest(macaroon.signature, expected_sig):
- logger.warning("L402: signature mismatch")
- return False
-
- # If preimage provided, settle the invoice
- if preimage:
- payment_handler.settle_invoice(macaroon.identifier, preimage)
-
- # Check payment status
- if not payment_handler.check_payment(macaroon.identifier):
- logger.info("L402: invoice not yet paid — %s…", macaroon.identifier[:12])
- return False
-
- logger.info("L402: access granted — %s…", macaroon.identifier[:12])
- return True
diff --git a/src/timmy_serve/payment_handler.py b/src/timmy_serve/payment_handler.py
deleted file mode 100644
index 2233ca03..00000000
--- a/src/timmy_serve/payment_handler.py
+++ /dev/null
@@ -1,153 +0,0 @@
-"""Lightning invoice creation and payment verification.
-
-This module is now a thin wrapper around the lightning backend interface.
-The actual backend (mock or LND) is selected via LIGHTNING_BACKEND env var.
-
-For backward compatibility, the PaymentHandler class and payment_handler
-singleton are preserved, but they delegate to the lightning backend.
-
-All transactions are logged to the ledger for audit and accounting.
-"""
-
-import logging
-from typing import Optional
-
-# Import from the new lightning module
-from lightning import get_backend, Invoice
-from lightning.base import LightningBackend
-from lightning.ledger import (
- create_invoice_entry,
- mark_settled,
- get_balance,
- list_transactions,
-)
-
-logger = logging.getLogger(__name__)
-
-
-class PaymentHandler:
- """Creates and verifies Lightning invoices.
-
- This class is a wrapper around the LightningBackend interface.
- It exists for backward compatibility — new code should use
- the lightning module directly.
-
- Usage:
- from timmy_serve.payment_handler import payment_handler
-
- invoice = payment_handler.create_invoice(100, "API access")
- if payment_handler.check_payment(invoice.payment_hash):
- print("Paid!")
- """
-
- def __init__(self, backend: Optional[LightningBackend] = None) -> None:
- """Initialize the payment handler.
-
- Args:
- backend: Lightning backend to use. If None, uses get_backend()
- which reads LIGHTNING_BACKEND env var.
- """
- self._backend = backend or get_backend()
- logger.info("PaymentHandler initialized — backend: %s", self._backend.name)
-
- def create_invoice(
- self,
- amount_sats: int,
- memo: str = "",
- source: str = "payment_handler",
- task_id: Optional[str] = None,
- agent_id: Optional[str] = None,
- ) -> Invoice:
- """Create a new Lightning invoice.
-
- Args:
- amount_sats: Invoice amount in satoshis
- memo: Payment description
- source: Component creating the invoice
- task_id: Associated task ID
- agent_id: Associated agent ID
- """
- invoice = self._backend.create_invoice(amount_sats, memo)
- logger.info(
- "Invoice created: %d sats — %s (hash: %s…)",
- amount_sats, memo, invoice.payment_hash[:12],
- )
-
- # Log to ledger
- create_invoice_entry(
- payment_hash=invoice.payment_hash,
- amount_sats=amount_sats,
- memo=memo,
- invoice=invoice.bolt11 if hasattr(invoice, 'bolt11') else None,
- source=source,
- task_id=task_id,
- agent_id=agent_id,
- )
-
- return invoice
-
- def check_payment(self, payment_hash: str) -> bool:
- """Check whether an invoice has been paid.
-
- If paid, updates the ledger entry.
- """
- is_paid = self._backend.check_payment(payment_hash)
-
- if is_paid:
- # Update ledger entry
- mark_settled(payment_hash)
-
- return is_paid
-
- def settle_invoice(self, payment_hash: str, preimage: str) -> bool:
- """Manually settle an invoice with a preimage (for testing).
-
- Also updates the ledger entry.
- """
- result = self._backend.settle_invoice(payment_hash, preimage)
-
- if result:
- mark_settled(payment_hash, preimage=preimage)
-
- return result
-
- def get_invoice(self, payment_hash: str) -> Optional[Invoice]:
- """Get invoice details by payment hash."""
- return self._backend.get_invoice(payment_hash)
-
- def list_invoices(self, settled_only: bool = False) -> list[Invoice]:
- """List recent invoices."""
- return self._backend.list_invoices(settled_only=settled_only)
-
- def health_check(self) -> dict:
- """Check backend health."""
- return self._backend.health_check()
-
- @property
- def backend_name(self) -> str:
- """Get the name of the current backend."""
- return self._backend.name
-
- def get_balance(self) -> dict:
- """Get current balance summary from ledger.
-
- Returns:
- Dict with incoming, outgoing, pending, and available balances
- """
- return get_balance()
-
- def list_transactions(self, limit: int = 100, **filters) -> list:
- """List transactions from ledger.
-
- Args:
- limit: Maximum number of transactions
- **filters: Optional filters (tx_type, status, task_id, agent_id)
-
- Returns:
- List of LedgerEntry objects
- """
- return list_transactions(limit=limit, **filters)
-
-
-# Module-level singleton
-payment_handler = PaymentHandler()
diff --git a/tests/conftest.py b/tests/conftest.py
index 77dc74c7..8f4968f5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -60,13 +60,6 @@ def clean_database(tmp_path):
tmp_self_coding_db = tmp_path / "self_coding.db"
_swarm_db_modules = [
- "swarm.tasks",
- "swarm.registry",
- "swarm.event_log",
- "swarm.stats",
- "swarm.task_queue.models",
- "self_coding.upgrades.models",
- "lightning.ledger",
"timmy.memory.vector_store",
"infrastructure.models.registry",
]
@@ -74,10 +67,7 @@ def clean_database(tmp_path):
"spark.memory",
"spark.eidos",
]
- _self_coding_db_modules = [
- "self_coding.modification_journal",
- "self_coding.codebase_indexer",
- ]
+ _self_coding_db_modules = []
originals = {}
for mod_name in _swarm_db_modules:
@@ -173,22 +163,6 @@ def db_connection():
conn.close()
-@pytest.fixture(autouse=True)
-def tmp_swarm_db(tmp_path, monkeypatch):
- """Point swarm SQLite paths to a temp directory for test isolation."""
- db_path = tmp_path / "swarm.db"
- for module in [
- "swarm.tasks",
- "swarm.registry",
- "swarm.stats",
- "swarm.event_log",
- "swarm.task_queue.models",
- ]:
- try:
- monkeypatch.setattr(f"{module}.DB_PATH", db_path)
- except AttributeError:
- pass
- yield db_path
@pytest.fixture
diff --git a/tests/creative/__init__.py b/tests/creative/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/creative/test_assembler.py b/tests/creative/test_assembler.py
deleted file mode 100644
index b4f98d02..00000000
--- a/tests/creative/test_assembler.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""Tests for creative.assembler — Video assembly engine.
-
-MoviePy is mocked for CI; these tests verify the interface contracts.
-"""
-
-import pytest
-from unittest.mock import patch, MagicMock
-
-from creative.assembler import (
- ASSEMBLER_TOOL_CATALOG,
- stitch_clips,
- overlay_audio,
- add_title_card,
- add_subtitles,
- export_final,
- _MOVIEPY_AVAILABLE,
-)
-
-
-class TestAssemblerToolCatalog:
- def test_catalog_has_all_tools(self):
- expected = {
- "stitch_clips", "overlay_audio", "add_title_card",
- "add_subtitles", "export_final",
- }
- assert expected == set(ASSEMBLER_TOOL_CATALOG.keys())
-
- def test_catalog_entries_callable(self):
- for tool_id, info in ASSEMBLER_TOOL_CATALOG.items():
- assert callable(info["fn"])
- assert "name" in info
- assert "description" in info
-
-
-class TestStitchClipsInterface:
- @pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
- def test_raises_on_empty_clips(self):
- """Stitch with no clips should fail gracefully."""
- # MoviePy would fail on empty list
- with pytest.raises(Exception):
- stitch_clips([])
-
-
-class TestOverlayAudioInterface:
- @pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
- def test_overlay_requires_valid_paths(self):
- with pytest.raises(Exception):
- overlay_audio("/nonexistent/video.mp4", "/nonexistent/audio.wav")
-
-
-class TestAddTitleCardInterface:
- @pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
- def test_add_title_requires_valid_video(self):
- with pytest.raises(Exception):
- add_title_card("/nonexistent/video.mp4", "Title")
-
-
-class TestAddSubtitlesInterface:
- @pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
- def test_requires_valid_video(self):
- with pytest.raises(Exception):
- add_subtitles("/nonexistent.mp4", [{"text": "Hi", "start": 0, "end": 1}])
-
-
-class TestExportFinalInterface:
- @pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
- def test_requires_valid_video(self):
- with pytest.raises(Exception):
- export_final("/nonexistent/video.mp4")
diff --git a/tests/creative/test_assembler_integration.py b/tests/creative/test_assembler_integration.py
deleted file mode 100644
index f5d69b88..00000000
--- a/tests/creative/test_assembler_integration.py
+++ /dev/null
@@ -1,275 +0,0 @@
-"""Integration tests for creative.assembler — real files, no mocks.
-
-Every test creates actual media files (PNG, WAV, MP4), runs them through
-the assembler functions, and inspects the output with MoviePy / Pillow.
-"""
-
-import pytest
-from pathlib import Path
-
-from moviepy import VideoFileClip, AudioFileClip
-
-from creative.assembler import (
- stitch_clips,
- overlay_audio,
- add_title_card,
- add_subtitles,
- export_final,
-)
-from tests.fixtures.media import (
- make_audio_track,
- make_video_clip,
- make_scene_clips,
-)
-
-
-# ── Fixtures ──────────────────────────────────────────────────────────────────
-
-@pytest.fixture
-def media_dir(tmp_path):
- """Isolated directory for generated media."""
- d = tmp_path / "media"
- d.mkdir()
- return d
-
-
-@pytest.fixture
-def two_clips(media_dir):
- """Two real 3-second MP4 clips."""
- return make_scene_clips(
- media_dir, ["Scene A", "Scene B"],
- duration_per_clip=3.0, fps=12, width=320, height=180,
- )
-
-
-@pytest.fixture
-def five_clips(media_dir):
- """Five real 2-second MP4 clips — enough for a short video."""
- return make_scene_clips(
- media_dir,
- ["Dawn", "Sunrise", "Mountains", "River", "Sunset"],
- duration_per_clip=2.0, fps=12, width=320, height=180,
- )
-
-
-@pytest.fixture
-def audio_10s(media_dir):
- """A real 10-second WAV audio track."""
- return make_audio_track(media_dir / "track.wav", duration_seconds=10.0)
-
-
-@pytest.fixture
-def audio_30s(media_dir):
- """A real 30-second WAV audio track."""
- return make_audio_track(
- media_dir / "track_long.wav",
- duration_seconds=30.0,
- frequency=330.0,
- )
-
-
-# ── Stitch clips ─────────────────────────────────────────────────────────────
-
-class TestStitchClipsReal:
- def test_stitch_two_clips_no_transition(self, two_clips, tmp_path):
- """Stitching 2 x 3s clips → ~6s video."""
- out = tmp_path / "stitched.mp4"
- result = stitch_clips(
- [str(p) for p in two_clips],
- transition_duration=0,
- output_path=str(out),
- )
-
- assert result["success"]
- assert result["clip_count"] == 2
- assert out.exists()
- assert out.stat().st_size > 1000 # non-trivial file
-
- video = VideoFileClip(str(out))
- assert video.duration == pytest.approx(6.0, abs=0.5)
- assert video.size == [320, 180]
- video.close()
-
- def test_stitch_with_crossfade(self, two_clips, tmp_path):
- """Cross-fade transition shortens total duration."""
- out = tmp_path / "crossfade.mp4"
- result = stitch_clips(
- [str(p) for p in two_clips],
- transition_duration=1.0,
- output_path=str(out),
- )
-
- assert result["success"]
- video = VideoFileClip(str(out))
- # 2 x 3s - 1s overlap = ~5s
- assert video.duration == pytest.approx(5.0, abs=1.0)
- video.close()
-
- def test_stitch_five_clips(self, five_clips, tmp_path):
- """Stitch 5 clips → continuous video with correct frame count."""
- out = tmp_path / "five.mp4"
- result = stitch_clips(
- [str(p) for p in five_clips],
- transition_duration=0.5,
- output_path=str(out),
- )
-
- assert result["success"]
- assert result["clip_count"] == 5
-
- video = VideoFileClip(str(out))
- # 5 x 2s - 4 * 0.5s overlap = 8s
- assert video.duration >= 7.0
- assert video.size == [320, 180]
- video.close()
-
-
-# ── Audio overlay ─────────────────────────────────────────────────────────────
-
-class TestOverlayAudioReal:
- def test_overlay_adds_audio_stream(self, two_clips, audio_10s, tmp_path):
- """Overlaying audio onto a silent video produces audible output."""
- # First stitch clips
- stitched = tmp_path / "silent.mp4"
- stitch_clips(
- [str(p) for p in two_clips],
- transition_duration=0,
- output_path=str(stitched),
- )
-
- out = tmp_path / "with_audio.mp4"
- result = overlay_audio(str(stitched), str(audio_10s), output_path=str(out))
-
- assert result["success"]
- assert out.exists()
-
- video = VideoFileClip(str(out))
- assert video.audio is not None # has audio stream
- assert video.duration == pytest.approx(6.0, abs=0.5)
- video.close()
-
- def test_audio_trimmed_to_video_length(self, two_clips, audio_30s, tmp_path):
- """30s audio track is trimmed to match ~6s video duration."""
- stitched = tmp_path / "short.mp4"
- stitch_clips(
- [str(p) for p in two_clips],
- transition_duration=0,
- output_path=str(stitched),
- )
-
- out = tmp_path / "trimmed.mp4"
- result = overlay_audio(str(stitched), str(audio_30s), output_path=str(out))
-
- assert result["success"]
- video = VideoFileClip(str(out))
- # Audio should be trimmed to video length, not 30s
- assert video.duration < 10.0
- video.close()
-
-
-# ── Title cards ───────────────────────────────────────────────────────────────
-
-class TestAddTitleCardReal:
- def test_prepend_title_card(self, two_clips, tmp_path):
- """Title card at start adds to total duration."""
- stitched = tmp_path / "base.mp4"
- stitch_clips(
- [str(p) for p in two_clips],
- transition_duration=0,
- output_path=str(stitched),
- )
- base_video = VideoFileClip(str(stitched))
- base_duration = base_video.duration
- base_video.close()
-
- out = tmp_path / "titled.mp4"
- result = add_title_card(
- str(stitched),
- title="My Music Video",
- duration=3.0,
- position="start",
- output_path=str(out),
- )
-
- assert result["success"]
- assert result["title"] == "My Music Video"
-
- video = VideoFileClip(str(out))
- # Title card (3s) + base video (~6s) = ~9s
- assert video.duration == pytest.approx(base_duration + 3.0, abs=1.0)
- video.close()
-
- def test_append_credits(self, two_clips, tmp_path):
- """Credits card at end adds to total duration."""
- clip_path = str(two_clips[0]) # single 3s clip
-
- out = tmp_path / "credits.mp4"
- result = add_title_card(
- clip_path,
- title="THE END",
- duration=2.0,
- position="end",
- output_path=str(out),
- )
-
- assert result["success"]
- video = VideoFileClip(str(out))
- # 3s clip + 2s credits = ~5s
- assert video.duration == pytest.approx(5.0, abs=1.0)
- video.close()
-
-
-# ── Subtitles ─────────────────────────────────────────────────────────────────
-
-class TestAddSubtitlesReal:
- def test_burn_captions(self, two_clips, tmp_path):
- """Subtitles are burned onto the video (duration unchanged)."""
- stitched = tmp_path / "base.mp4"
- stitch_clips(
- [str(p) for p in two_clips],
- transition_duration=0,
- output_path=str(stitched),
- )
-
- captions = [
- {"text": "Welcome to the show", "start": 0.0, "end": 2.0},
- {"text": "Here we go!", "start": 2.5, "end": 4.5},
- {"text": "Finale", "start": 5.0, "end": 6.0},
- ]
-
- out = tmp_path / "subtitled.mp4"
- result = add_subtitles(str(stitched), captions, output_path=str(out))
-
- assert result["success"]
- assert result["caption_count"] == 3
-
- video = VideoFileClip(str(out))
- # Duration should be unchanged
- assert video.duration == pytest.approx(6.0, abs=0.5)
- assert video.size == [320, 180]
- video.close()
-
-
-# ── Export final ──────────────────────────────────────────────────────────────
-
-class TestExportFinalReal:
- def test_reencodes_video(self, two_clips, tmp_path):
- """Final export produces a valid re-encoded file."""
- clip_path = str(two_clips[0])
-
- out = tmp_path / "final.mp4"
- result = export_final(
- clip_path,
- output_path=str(out),
- codec="libx264",
- bitrate="2000k",
- )
-
- assert result["success"]
- assert result["codec"] == "libx264"
- assert out.exists()
- assert out.stat().st_size > 500
-
- video = VideoFileClip(str(out))
- assert video.duration == pytest.approx(3.0, abs=0.5)
- video.close()
diff --git a/tests/creative/test_creative_director.py b/tests/creative/test_creative_director.py
deleted file mode 100644
index 1166330d..00000000
--- a/tests/creative/test_creative_director.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""Tests for creative.director — Creative Director pipeline.
-
-Tests project management, pipeline orchestration, and tool catalogue.
-All AI model calls are mocked.
-"""
-
-import pytest
-from unittest.mock import patch, MagicMock
-
-from creative.director import (
- create_project,
- get_project,
- list_projects,
- run_storyboard,
- run_music,
- run_video_generation,
- run_assembly,
- run_full_pipeline,
- CreativeProject,
- DIRECTOR_TOOL_CATALOG,
- _projects,
-)
-
-
-@pytest.fixture(autouse=True)
-def clear_projects():
- """Clear project store between tests."""
- _projects.clear()
- yield
- _projects.clear()
-
-
-@pytest.fixture
-def sample_project(tmp_path):
- """Create a sample project with scenes."""
- with patch("creative.director._project_dir", return_value=tmp_path):
- result = create_project(
- title="Test Video",
- description="A test creative project",
- scenes=[
- {"description": "A sunrise over mountains"},
- {"description": "A river flowing through a valley"},
- {"description": "A sunset over the ocean"},
- ],
- lyrics="La la la, the sun rises high",
- )
- return result["project"]["id"]
-
-
-class TestCreateProject:
- def test_creates_project(self, tmp_path):
- with patch("creative.director._project_dir", return_value=tmp_path):
- result = create_project("My Video", "A cool video")
- assert result["success"]
- assert result["project"]["title"] == "My Video"
- assert result["project"]["status"] == "planning"
-
- def test_project_has_id(self, tmp_path):
- with patch("creative.director._project_dir", return_value=tmp_path):
- result = create_project("Test", "Test")
- assert len(result["project"]["id"]) == 12
-
- def test_project_with_scenes(self, tmp_path):
- with patch("creative.director._project_dir", return_value=tmp_path):
- result = create_project(
- "Scenes", "With scenes",
- scenes=[{"description": "Scene 1"}, {"description": "Scene 2"}],
- )
- assert result["project"]["scene_count"] == 2
-
-
-class TestGetProject:
- def test_get_existing(self, sample_project):
- result = get_project(sample_project)
- assert result is not None
- assert result["title"] == "Test Video"
-
- def test_get_nonexistent(self):
- assert get_project("bogus") is None
-
-
-class TestListProjects:
- def test_empty(self):
- assert list_projects() == []
-
- def test_with_projects(self, sample_project, tmp_path):
- with patch("creative.director._project_dir", return_value=tmp_path):
- create_project("Second", "desc")
- assert len(list_projects()) == 2
-
-
-class TestRunStoryboard:
- def test_fails_without_project(self):
- result = run_storyboard("bogus")
- assert not result["success"]
- assert "not found" in result["error"]
-
- def test_fails_without_scenes(self, tmp_path):
- with patch("creative.director._project_dir", return_value=tmp_path):
- result = create_project("Empty", "No scenes")
- pid = result["project"]["id"]
- result = run_storyboard(pid)
- assert not result["success"]
- assert "No scenes" in result["error"]
-
- def test_generates_frames(self, sample_project, tmp_path):
- mock_result = {
- "success": True,
- "frame_count": 3,
- "frames": [
- {"path": "/fake/1.png", "scene_index": 0, "prompt": "sunrise"},
- {"path": "/fake/2.png", "scene_index": 1, "prompt": "river"},
- {"path": "/fake/3.png", "scene_index": 2, "prompt": "sunset"},
- ],
- }
- with patch("creative.tools.image_tools.generate_storyboard", return_value=mock_result):
- with patch("creative.director._save_project"):
- result = run_storyboard(sample_project)
- assert result["success"]
- assert result["frame_count"] == 3
-
-
-class TestRunMusic:
- def test_fails_without_project(self):
- result = run_music("bogus")
- assert not result["success"]
-
- def test_generates_track(self, sample_project):
- mock_result = {
- "success": True, "path": "/fake/song.wav",
- "genre": "pop", "duration": 60,
- }
- with patch("creative.tools.music_tools.generate_song", return_value=mock_result):
- with patch("creative.director._save_project"):
- result = run_music(sample_project, genre="pop")
- assert result["success"]
- assert result["path"] == "/fake/song.wav"
-
-
-class TestRunVideoGeneration:
- def test_fails_without_project(self):
- result = run_video_generation("bogus")
- assert not result["success"]
-
- def test_generates_clips(self, sample_project):
- mock_clip = {
- "success": True, "path": "/fake/clip.mp4",
- "duration": 5,
- }
- with patch("creative.tools.video_tools.generate_video_clip", return_value=mock_clip):
- with patch("creative.tools.video_tools.image_to_video", return_value=mock_clip):
- with patch("creative.director._save_project"):
- result = run_video_generation(sample_project)
- assert result["success"]
- assert result["clip_count"] == 3
-
-
-class TestRunAssembly:
- def test_fails_without_project(self):
- result = run_assembly("bogus")
- assert not result["success"]
-
- def test_fails_without_clips(self, sample_project):
- result = run_assembly(sample_project)
- assert not result["success"]
- assert "No video clips" in result["error"]
-
-
-class TestCreativeProject:
- def test_to_dict(self):
- p = CreativeProject(title="Test", description="Desc")
- d = p.to_dict()
- assert d["title"] == "Test"
- assert d["status"] == "planning"
- assert d["scene_count"] == 0
- assert d["has_storyboard"] is False
- assert d["has_music"] is False
-
-
-class TestDirectorToolCatalog:
- def test_catalog_has_all_tools(self):
- expected = {
- "create_project", "run_storyboard", "run_music",
- "run_video_generation", "run_assembly", "run_full_pipeline",
- }
- assert expected == set(DIRECTOR_TOOL_CATALOG.keys())
-
- def test_catalog_entries_callable(self):
- for tool_id, info in DIRECTOR_TOOL_CATALOG.items():
- assert callable(info["fn"])
diff --git a/tests/creative/test_creative_route.py b/tests/creative/test_creative_route.py
deleted file mode 100644
index b7cfb7aa..00000000
--- a/tests/creative/test_creative_route.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""Tests for the Creative Studio dashboard route."""
-
-import os
-import pytest
-
-os.environ.setdefault("TIMMY_TEST_MODE", "1")
-
-from fastapi.testclient import TestClient
-
-
-@pytest.fixture
-def client(tmp_path, monkeypatch):
- """Test client with temp DB paths."""
- monkeypatch.setattr("swarm.tasks.DB_PATH", tmp_path / "swarm.db")
- monkeypatch.setattr("swarm.registry.DB_PATH", tmp_path / "swarm.db")
- monkeypatch.setattr("swarm.stats.DB_PATH", tmp_path / "swarm.db")
-
- from dashboard.app import app
- return TestClient(app)
-
-
-class TestCreativeStudioPage:
- def test_creative_page_loads(self, client):
- resp = client.get("/creative/ui")
- assert resp.status_code == 200
- assert "Creative Studio" in resp.text
-
- def test_creative_page_has_tabs(self, client):
- resp = client.get("/creative/ui")
- assert "tab-images" in resp.text
- assert "tab-music" in resp.text
- assert "tab-video" in resp.text
- assert "tab-director" in resp.text
-
- def test_creative_page_shows_personas(self, client):
- resp = client.get("/creative/ui")
- assert "Pixel" in resp.text
- assert "Lyra" in resp.text
- assert "Reel" in resp.text
-
-
-class TestCreativeAPI:
- def test_projects_api_empty(self, client):
- resp = client.get("/creative/api/projects")
- assert resp.status_code == 200
- data = resp.json()
- assert "projects" in data
-
- def test_genres_api(self, client):
- resp = client.get("/creative/api/genres")
- assert resp.status_code == 200
- data = resp.json()
- assert "genres" in data
-
- def test_video_styles_api(self, client):
- resp = client.get("/creative/api/video-styles")
- assert resp.status_code == 200
- data = resp.json()
- assert "styles" in data
- assert "resolutions" in data
diff --git a/tests/creative/test_font_resolution.py b/tests/creative/test_font_resolution.py
deleted file mode 100644
index 3a05ca72..00000000
--- a/tests/creative/test_font_resolution.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""Test font resolution logic in the creative module."""
-
-import pytest
-from pathlib import Path
-from unittest.mock import patch, MagicMock
-
-
-def test_resolve_font_prefers_dejavu():
- """Test that _resolve_font prefers DejaVu fonts when available."""
- from creative.assembler import _resolve_font
-
- # This test will pass on systems with DejaVu fonts installed
- # (most Linux distributions)
- font = _resolve_font()
- assert isinstance(font, str)
- assert font.endswith(".ttf") or font.endswith(".ttc")
- assert Path(font).exists()
-
-
-def test_resolve_font_returns_valid_path():
- """Test that _resolve_font returns a valid, existing path."""
- from creative.assembler import _resolve_font
-
- font = _resolve_font()
- assert isinstance(font, str)
- # Should be a path, not just a font name
- assert "/" in font or "\\" in font
- assert Path(font).exists()
-
-
-def test_resolve_font_no_invalid_fallback():
- """Test that _resolve_font never returns invalid font names like 'Helvetica'."""
- from creative.assembler import _resolve_font
-
- font = _resolve_font()
- # Should not return bare font names that Pillow can't find
- assert font not in ["Helvetica", "Arial", "Times New Roman"]
- # Should be a valid path
- assert Path(font).exists()
-
-
-@patch("creative.assembler.Path.exists")
-@patch("subprocess.run")
-def test_resolve_font_fallback_search(mock_run, mock_exists):
- """Test that _resolve_font falls back to searching for any TTF."""
- # Mock: no preferred fonts exist
- mock_exists.return_value = False
-
- # Mock: subprocess finds a font
- mock_result = MagicMock()
- mock_result.stdout = "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf\n"
- mock_run.return_value = mock_result
-
- from creative.assembler import _resolve_font
-
- font = _resolve_font()
- assert "LiberationSans-Regular.ttf" in font
-
-
-@patch("creative.assembler.Path.exists")
-@patch("subprocess.run")
-def test_resolve_font_raises_on_no_fonts(mock_run, mock_exists):
- """Test that _resolve_font raises RuntimeError when no fonts are found."""
- # Mock: no fonts found anywhere
- mock_exists.return_value = False
- mock_result = MagicMock()
- mock_result.stdout = ""
- mock_run.return_value = mock_result
-
- from creative.assembler import _resolve_font
-
- with pytest.raises(RuntimeError, match="No suitable TrueType font found"):
- _resolve_font()
diff --git a/tests/creative/test_image_tools.py b/tests/creative/test_image_tools.py
deleted file mode 100644
index 25ebd1e2..00000000
--- a/tests/creative/test_image_tools.py
+++ /dev/null
@@ -1,120 +0,0 @@
-"""Tests for tools.image_tools — Image generation (Pixel persona).
-
-Heavy AI model tests are skipped; only catalogue, metadata, and
-interface tests run in CI.
-"""
-
-import pytest
-from unittest.mock import patch, MagicMock
-from pathlib import Path
-
-from creative.tools.image_tools import (
- IMAGE_TOOL_CATALOG,
- generate_image,
- generate_storyboard,
- image_variations,
- _save_metadata,
-)
-
-
-class TestImageToolCatalog:
- def test_catalog_has_all_tools(self):
- expected = {"generate_image", "generate_storyboard", "image_variations"}
- assert expected == set(IMAGE_TOOL_CATALOG.keys())
-
- def test_catalog_entries_have_required_keys(self):
- for tool_id, info in IMAGE_TOOL_CATALOG.items():
- assert "name" in info
- assert "description" in info
- assert "fn" in info
- assert callable(info["fn"])
-
-
-class TestSaveMetadata:
- def test_saves_json_sidecar(self, tmp_path):
- img_path = tmp_path / "test.png"
- img_path.write_bytes(b"fake image")
- meta = {"prompt": "a cat", "width": 512}
- result = _save_metadata(img_path, meta)
- assert result.suffix == ".json"
- assert result.exists()
-
- import json
- data = json.loads(result.read_text())
- assert data["prompt"] == "a cat"
-
-
-class TestGenerateImageInterface:
- def test_raises_without_creative_deps(self):
- """generate_image raises ImportError when diffusers not available."""
- with patch("creative.tools.image_tools._pipeline", None):
- with patch("creative.tools.image_tools._get_pipeline", side_effect=ImportError("no diffusers")):
- with pytest.raises(ImportError):
- generate_image("a cat")
-
- def test_generate_image_with_mocked_pipeline(self, tmp_path):
- """generate_image works end-to-end with a mocked pipeline."""
- import sys
-
- mock_image = MagicMock()
- mock_image.save = MagicMock()
-
- mock_pipe = MagicMock()
- mock_pipe.device = "cpu"
- mock_pipe.return_value.images = [mock_image]
-
- mock_torch = MagicMock()
- mock_torch.Generator.return_value = MagicMock()
-
- with patch.dict(sys.modules, {"torch": mock_torch}):
- with patch("creative.tools.image_tools._get_pipeline", return_value=mock_pipe):
- with patch("creative.tools.image_tools._output_dir", return_value=tmp_path):
- result = generate_image("a cat", width=512, height=512, steps=1)
-
- assert result["success"]
- assert result["prompt"] == "a cat"
- assert result["width"] == 512
- assert "path" in result
-
-
-class TestGenerateStoryboardInterface:
- def test_calls_generate_image_per_scene(self):
- """Storyboard calls generate_image once per scene."""
- call_count = 0
-
- def mock_gen_image(prompt, **kwargs):
- nonlocal call_count
- call_count += 1
- return {
- "success": True, "path": f"/fake/{call_count}.png",
- "id": str(call_count), "prompt": prompt,
- }
-
- with patch("creative.tools.image_tools.generate_image", side_effect=mock_gen_image):
- result = generate_storyboard(
- ["sunrise", "mountain peak", "sunset"],
- steps=1,
- )
-
- assert result["success"]
- assert result["frame_count"] == 3
- assert len(result["frames"]) == 3
- assert call_count == 3
-
-
-class TestImageVariationsInterface:
- def test_generates_multiple_variations(self):
- """image_variations generates the requested number of results."""
- def mock_gen_image(prompt, **kwargs):
- return {
- "success": True, "path": "/fake.png",
- "id": "x", "prompt": prompt,
- "seed": kwargs.get("seed"),
- }
-
- with patch("creative.tools.image_tools.generate_image", side_effect=mock_gen_image):
- result = image_variations("a dog", count=3, steps=1)
-
- assert result["success"]
- assert result["count"] == 3
- assert len(result["variations"]) == 3
diff --git a/tests/creative/test_music_tools.py b/tests/creative/test_music_tools.py
deleted file mode 100644
index a45d498d..00000000
--- a/tests/creative/test_music_tools.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""Tests for tools.music_tools — Music generation (Lyra persona).
-
-Heavy AI model tests are skipped; only catalogue, interface, and
-metadata tests run in CI.
-"""
-
-import pytest
-from unittest.mock import patch, MagicMock
-
-from creative.tools.music_tools import (
- MUSIC_TOOL_CATALOG,
- GENRES,
- list_genres,
- generate_song,
- generate_instrumental,
- generate_vocals,
-)
-
-
-class TestMusicToolCatalog:
- def test_catalog_has_all_tools(self):
- expected = {
- "generate_song", "generate_instrumental",
- "generate_vocals", "list_genres",
- }
- assert expected == set(MUSIC_TOOL_CATALOG.keys())
-
- def test_catalog_entries_have_required_keys(self):
- for tool_id, info in MUSIC_TOOL_CATALOG.items():
- assert "name" in info
- assert "description" in info
- assert "fn" in info
- assert callable(info["fn"])
-
-
-class TestListGenres:
- def test_returns_genre_list(self):
- result = list_genres()
- assert result["success"]
- assert len(result["genres"]) > 10
- assert "pop" in result["genres"]
- assert "cinematic" in result["genres"]
-
-
-class TestGenres:
- def test_common_genres_present(self):
- for genre in ["pop", "rock", "hip-hop", "jazz", "electronic", "classical"]:
- assert genre in GENRES
-
-
-class TestGenerateSongInterface:
- def test_raises_without_ace_step(self):
- with patch("creative.tools.music_tools._model", None):
- with patch("creative.tools.music_tools._get_model", side_effect=ImportError("no ace-step")):
- with pytest.raises(ImportError):
- generate_song("la la la")
-
- def test_duration_clamped(self):
- """Duration is clamped to 30–240 range."""
- mock_audio = MagicMock()
- mock_audio.save = MagicMock()
-
- mock_model = MagicMock()
- mock_model.generate.return_value = mock_audio
-
- with patch("creative.tools.music_tools._get_model", return_value=mock_model):
- with patch("creative.tools.music_tools._output_dir", return_value=MagicMock()):
- with patch("creative.tools.music_tools._save_metadata"):
- # Should clamp 5 to 30
- generate_song("lyrics", duration=5)
- call_kwargs = mock_model.generate.call_args[1]
- assert call_kwargs["duration"] == 30
-
- def test_generate_song_with_mocked_model(self, tmp_path):
- mock_audio = MagicMock()
- mock_audio.save = MagicMock()
-
- mock_model = MagicMock()
- mock_model.generate.return_value = mock_audio
-
- with patch("creative.tools.music_tools._get_model", return_value=mock_model):
- with patch("creative.tools.music_tools._output_dir", return_value=tmp_path):
- result = generate_song(
- "hello world", genre="rock", duration=60, title="Test Song"
- )
-
- assert result["success"]
- assert result["genre"] == "rock"
- assert result["title"] == "Test Song"
- assert result["duration"] == 60
-
-
-class TestGenerateInstrumentalInterface:
- def test_with_mocked_model(self, tmp_path):
- mock_audio = MagicMock()
- mock_audio.save = MagicMock()
-
- mock_model = MagicMock()
- mock_model.generate.return_value = mock_audio
-
- with patch("creative.tools.music_tools._get_model", return_value=mock_model):
- with patch("creative.tools.music_tools._output_dir", return_value=tmp_path):
- result = generate_instrumental("epic orchestral", genre="cinematic")
-
- assert result["success"]
- assert result["genre"] == "cinematic"
- assert result["instrumental"] is True
-
-
-class TestGenerateVocalsInterface:
- def test_with_mocked_model(self, tmp_path):
- mock_audio = MagicMock()
- mock_audio.save = MagicMock()
-
- mock_model = MagicMock()
- mock_model.generate.return_value = mock_audio
-
- with patch("creative.tools.music_tools._get_model", return_value=mock_model):
- with patch("creative.tools.music_tools._output_dir", return_value=tmp_path):
- result = generate_vocals("do re mi", style="jazz")
-
- assert result["success"]
- assert result["vocals_only"] is True
- assert result["style"] == "jazz"
diff --git a/tests/creative/test_music_video_integration.py b/tests/creative/test_music_video_integration.py
deleted file mode 100644
index 65435774..00000000
--- a/tests/creative/test_music_video_integration.py
+++ /dev/null
@@ -1,444 +0,0 @@
-"""Integration test: end-to-end music video pipeline with real media files.
-
-Exercises the Creative Director pipeline and Assembler with genuine PNG,
-WAV, and MP4 files. Only AI model inference is replaced with fixture
-generators; all MoviePy / FFmpeg operations run for real.
-
-The final output video is inspected for:
- - Duration — correct within tolerance
- - Resolution — 320x180 (fixture default)
- - Audio stream — present
- - File size — non-trivial (>10 kB)
-"""
-
-import pytest
-from pathlib import Path
-from unittest.mock import patch
-
-from moviepy import VideoFileClip
-
-from creative.director import (
- create_project,
- run_storyboard,
- run_music,
- run_video_generation,
- run_assembly,
- run_full_pipeline,
- _projects,
-)
-from creative.assembler import (
- stitch_clips,
- overlay_audio,
- add_title_card,
- add_subtitles,
- export_final,
-)
-from tests.fixtures.media import (
- make_storyboard,
- make_audio_track,
- make_video_clip,
- make_scene_clips,
-)
-
-
-# ── Fixtures ──────────────────────────────────────────────────────────────────
-
-SCENES = [
- {"description": "Dawn breaks over misty mountains", "duration": 4},
- {"description": "A river carves through green valleys", "duration": 4},
- {"description": "Wildflowers sway in warm sunlight", "duration": 4},
- {"description": "Clouds gather as evening approaches", "duration": 4},
- {"description": "Stars emerge over a quiet lake", "duration": 4},
-]
-
-
-@pytest.fixture(autouse=True)
-def clear_projects():
- """Clear in-memory project store between tests."""
- _projects.clear()
- yield
- _projects.clear()
-
-
-@pytest.fixture
-def media_dir(tmp_path):
- d = tmp_path / "media"
- d.mkdir()
- return d
-
-
-@pytest.fixture
-def scene_defs():
- """Five-scene creative brief for a short music video."""
- return [dict(s) for s in SCENES]
-
-
-@pytest.fixture
-def storyboard_frames(media_dir):
- """Real PNG storyboard frames for all scenes."""
- return make_storyboard(
- media_dir / "frames",
- [s["description"][:20] for s in SCENES],
- width=320, height=180,
- )
-
-
-@pytest.fixture
-def audio_track(media_dir):
- """Real 25-second WAV audio track."""
- return make_audio_track(
- media_dir / "soundtrack.wav",
- duration_seconds=25.0,
- frequency=440.0,
- )
-
-
-@pytest.fixture
-def video_clips(media_dir):
- """Real 4-second MP4 clips, one per scene (~20s total)."""
- return make_scene_clips(
- media_dir / "clips",
- [s["description"][:20] for s in SCENES],
- duration_per_clip=4.0,
- fps=12,
- width=320,
- height=180,
- )
-
-
-# ── Direct assembly (zero AI mocking) ───────────────────────────────────────
-
-class TestMusicVideoAssembly:
- """Build a real music video from fixture clips + audio, inspect output."""
-
- def test_full_music_video(self, video_clips, audio_track, tmp_path):
- """Stitch 5 clips -> overlay audio -> title -> credits -> inspect."""
- # 1. Stitch with crossfade
- stitched = tmp_path / "stitched.mp4"
- stitch_result = stitch_clips(
- [str(p) for p in video_clips],
- transition_duration=0.5,
- output_path=str(stitched),
- )
- assert stitch_result["success"]
- assert stitch_result["clip_count"] == 5
-
- # 2. Overlay audio
- with_audio = tmp_path / "with_audio.mp4"
- audio_result = overlay_audio(
- str(stitched), str(audio_track),
- output_path=str(with_audio),
- )
- assert audio_result["success"]
-
- # 3. Title card at start
- titled = tmp_path / "titled.mp4"
- title_result = add_title_card(
- str(with_audio),
- title="Dawn to Dusk",
- duration=3.0,
- position="start",
- output_path=str(titled),
- )
- assert title_result["success"]
-
- # 4. Credits at end
- final_path = tmp_path / "final_music_video.mp4"
- credits_result = add_title_card(
- str(titled),
- title="THE END",
- duration=2.0,
- position="end",
- output_path=str(final_path),
- )
- assert credits_result["success"]
-
- # ── Inspect final video ──────────────────────────────────────────
- assert final_path.exists()
- assert final_path.stat().st_size > 10_000 # non-trivial file
-
- video = VideoFileClip(str(final_path))
-
- # Duration: 5x4s - 4x0.5s crossfade = 18s + 3s title + 2s credits = 23s
- expected_body = 5 * 4.0 - 4 * 0.5 # 18s
- expected_total = expected_body + 3.0 + 2.0 # 23s
- assert video.duration >= 15.0 # floor sanity check
- assert video.duration == pytest.approx(expected_total, abs=3.0)
-
- # Resolution
- assert video.size == [320, 180]
-
- # Audio present
- assert video.audio is not None
-
- video.close()
-
- def test_with_subtitles(self, video_clips, audio_track, tmp_path):
- """Full video with burned-in captions."""
- # Stitch without transitions for predictable duration
- stitched = tmp_path / "stitched.mp4"
- stitch_clips(
- [str(p) for p in video_clips],
- transition_duration=0,
- output_path=str(stitched),
- )
-
- # Overlay audio
- with_audio = tmp_path / "with_audio.mp4"
- overlay_audio(
- str(stitched), str(audio_track),
- output_path=str(with_audio),
- )
-
- # Burn subtitles — one caption per scene
- captions = [
- {"text": "Dawn breaks over misty mountains", "start": 0.0, "end": 3.5},
- {"text": "A river carves through green valleys", "start": 4.0, "end": 7.5},
- {"text": "Wildflowers sway in warm sunlight", "start": 8.0, "end": 11.5},
- {"text": "Clouds gather as evening approaches", "start": 12.0, "end": 15.5},
- {"text": "Stars emerge over a quiet lake", "start": 16.0, "end": 19.5},
- ]
-
- final = tmp_path / "subtitled_video.mp4"
- result = add_subtitles(str(with_audio), captions, output_path=str(final))
-
- assert result["success"]
- assert result["caption_count"] == 5
-
- video = VideoFileClip(str(final))
- # 5x4s = 20s total (no crossfade)
- assert video.duration == pytest.approx(20.0, abs=1.0)
- assert video.size == [320, 180]
- assert video.audio is not None
- video.close()
-
- def test_export_final_quality(self, video_clips, tmp_path):
- """Export with specific codec/bitrate and verify."""
- stitched = tmp_path / "raw.mp4"
- stitch_clips(
- [str(p) for p in video_clips[:2]],
- transition_duration=0,
- output_path=str(stitched),
- )
-
- final = tmp_path / "hq.mp4"
- result = export_final(
- str(stitched),
- output_path=str(final),
- codec="libx264",
- bitrate="5000k",
- )
-
- assert result["success"]
- assert result["codec"] == "libx264"
- assert final.stat().st_size > 5000
-
- video = VideoFileClip(str(final))
- # Two 4s clips = 8s
- assert video.duration == pytest.approx(8.0, abs=1.0)
- video.close()
-
-
-# ── Creative Director pipeline (AI calls replaced with fixtures) ────────────
-
-class TestCreativeDirectorPipeline:
- """Run the full director pipeline; only AI model inference is stubbed
- with real-file fixture generators. All assembly runs for real."""
-
- def _make_storyboard_stub(self, frames_dir):
- """Return a callable that produces real PNGs in tool-result format."""
- def stub(descriptions):
- frames = make_storyboard(
- frames_dir, descriptions, width=320, height=180,
- )
- return {
- "success": True,
- "frame_count": len(frames),
- "frames": [
- {"path": str(f), "scene_index": i, "prompt": descriptions[i]}
- for i, f in enumerate(frames)
- ],
- }
- return stub
-
- def _make_song_stub(self, audio_dir):
- """Return a callable that produces a real WAV in tool-result format."""
- def stub(lyrics="", genre="pop", duration=60, title=""):
- path = make_audio_track(
- audio_dir / "song.wav",
- duration_seconds=min(duration, 25),
- )
- return {
- "success": True,
- "path": str(path),
- "genre": genre,
- "duration": min(duration, 25),
- }
- return stub
-
- def _make_video_stub(self, clips_dir):
- """Return a callable that produces real MP4s in tool-result format."""
- counter = [0]
- def stub(image_path=None, prompt="scene", duration=4, **kwargs):
- path = make_video_clip(
- clips_dir / f"gen_{counter[0]:03d}.mp4",
- duration_seconds=duration,
- fps=12, width=320, height=180,
- label=prompt[:20],
- )
- counter[0] += 1
- return {
- "success": True,
- "path": str(path),
- "duration": duration,
- }
- return stub
-
- def test_full_pipeline_end_to_end(self, scene_defs, tmp_path):
- """run_full_pipeline with real fixtures at every stage."""
- frames_dir = tmp_path / "frames"
- frames_dir.mkdir()
- audio_dir = tmp_path / "audio"
- audio_dir.mkdir()
- clips_dir = tmp_path / "clips"
- clips_dir.mkdir()
- assembly_dir = tmp_path / "assembly"
- assembly_dir.mkdir()
-
- with (
- patch("creative.tools.image_tools.generate_storyboard",
- side_effect=self._make_storyboard_stub(frames_dir)),
- patch("creative.tools.music_tools.generate_song",
- side_effect=self._make_song_stub(audio_dir)),
- patch("creative.tools.video_tools.image_to_video",
- side_effect=self._make_video_stub(clips_dir)),
- patch("creative.tools.video_tools.generate_video_clip",
- side_effect=self._make_video_stub(clips_dir)),
- patch("creative.director._project_dir",
- return_value=tmp_path / "project"),
- patch("creative.director._save_project"),
- patch("creative.assembler._output_dir",
- return_value=assembly_dir),
- ):
- result = run_full_pipeline(
- title="Integration Test Video",
- description="End-to-end pipeline test",
- scenes=scene_defs,
- lyrics="Test lyrics for the song",
- genre="rock",
- )
-
- assert result["success"], f"Pipeline failed: {result}"
- assert result["project_id"]
- assert result["final_video"] is not None
- assert result["project"]["status"] == "complete"
- assert result["project"]["has_final"] is True
- assert result["project"]["clip_count"] == 5
-
- # Inspect the final video
- final_path = Path(result["final_video"]["path"])
- assert final_path.exists()
- assert final_path.stat().st_size > 5000
-
- video = VideoFileClip(str(final_path))
- # 5x4s clips - 4x1s crossfade = 16s body + 4s title card ~= 20s
- assert video.duration >= 10.0
- assert video.size == [320, 180]
- assert video.audio is not None
- video.close()
-
- def test_step_by_step_pipeline(self, scene_defs, tmp_path):
- """Run each pipeline step individually — mirrors manual usage."""
- frames_dir = tmp_path / "frames"
- frames_dir.mkdir()
- audio_dir = tmp_path / "audio"
- audio_dir.mkdir()
- clips_dir = tmp_path / "clips"
- clips_dir.mkdir()
- assembly_dir = tmp_path / "assembly"
- assembly_dir.mkdir()
-
- # 1. Create project
- with (
- patch("creative.director._project_dir",
- return_value=tmp_path / "proj"),
- patch("creative.director._save_project"),
- ):
- proj = create_project(
- "Step-by-Step Video",
- "Manual pipeline test",
- scenes=scene_defs,
- lyrics="Step by step, we build it all",
- )
- pid = proj["project"]["id"]
- assert proj["success"]
-
- # 2. Storyboard
- with (
- patch("creative.tools.image_tools.generate_storyboard",
- side_effect=self._make_storyboard_stub(frames_dir)),
- patch("creative.director._save_project"),
- ):
- sb = run_storyboard(pid)
- assert sb["success"]
- assert sb["frame_count"] == 5
-
- # 3. Music
- with (
- patch("creative.tools.music_tools.generate_song",
- side_effect=self._make_song_stub(audio_dir)),
- patch("creative.director._save_project"),
- ):
- mus = run_music(pid, genre="electronic")
- assert mus["success"]
- assert mus["genre"] == "electronic"
-
- # Verify the audio file exists and is valid
- audio_path = Path(mus["path"])
- assert audio_path.exists()
- assert audio_path.stat().st_size > 1000
-
- # 4. Video generation (uses storyboard frames → image_to_video)
- with (
- patch("creative.tools.video_tools.image_to_video",
- side_effect=self._make_video_stub(clips_dir)),
- patch("creative.director._save_project"),
- ):
- vid = run_video_generation(pid)
- assert vid["success"]
- assert vid["clip_count"] == 5
-
- # Verify each clip exists
- for clip_info in vid["clips"]:
- clip_path = Path(clip_info["path"])
- assert clip_path.exists()
- assert clip_path.stat().st_size > 1000
-
- # 5. Assembly (all real MoviePy operations)
- with (
- patch("creative.director._save_project"),
- patch("creative.assembler._output_dir",
- return_value=assembly_dir),
- ):
- asm = run_assembly(pid, transition_duration=0.5)
- assert asm["success"]
-
- # Inspect final output
- final_path = Path(asm["path"])
- assert final_path.exists()
- assert final_path.stat().st_size > 5000
-
- video = VideoFileClip(str(final_path))
- # 5x4s - 4x0.5s = 18s body, + title card ~= 22s
- assert video.duration >= 10.0
- assert video.size == [320, 180]
- assert video.audio is not None
- video.close()
-
- # Verify project reached completion
- project = _projects[pid]
- assert project.status == "complete"
- assert project.final_video is not None
- assert len(project.video_clips) == 5
- assert len(project.storyboard_frames) == 5
- assert project.music_track is not None
diff --git a/tests/creative/test_video_tools.py b/tests/creative/test_video_tools.py
deleted file mode 100644
index 2836cad5..00000000
--- a/tests/creative/test_video_tools.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Tests for tools.video_tools — Video generation (Reel persona).
-
-Heavy AI model tests are skipped; only catalogue, interface, and
-resolution preset tests run in CI.
-"""
-
-import pytest
-from unittest.mock import patch, MagicMock
-
-from creative.tools.video_tools import (
- VIDEO_TOOL_CATALOG,
- RESOLUTION_PRESETS,
- VIDEO_STYLES,
- list_video_styles,
- generate_video_clip,
- image_to_video,
-)
-
-
-class TestVideoToolCatalog:
- def test_catalog_has_all_tools(self):
- expected = {"generate_video_clip", "image_to_video", "list_video_styles"}
- assert expected == set(VIDEO_TOOL_CATALOG.keys())
-
- def test_catalog_entries_have_required_keys(self):
- for tool_id, info in VIDEO_TOOL_CATALOG.items():
- assert "name" in info
- assert "description" in info
- assert "fn" in info
- assert callable(info["fn"])
-
-
-class TestResolutionPresets:
- def test_480p_preset(self):
- assert RESOLUTION_PRESETS["480p"] == (854, 480)
-
- def test_720p_preset(self):
- assert RESOLUTION_PRESETS["720p"] == (1280, 720)
-
-
-class TestVideoStyles:
- def test_common_styles_present(self):
- for style in ["cinematic", "anime", "documentary"]:
- assert style in VIDEO_STYLES
-
-
-class TestListVideoStyles:
- def test_returns_styles_and_resolutions(self):
- result = list_video_styles()
- assert result["success"]
- assert "cinematic" in result["styles"]
- assert "480p" in result["resolutions"]
- assert "720p" in result["resolutions"]
-
-
-class TestGenerateVideoClipInterface:
- def test_raises_without_creative_deps(self):
- with patch("creative.tools.video_tools._t2v_pipeline", None):
- with patch("creative.tools.video_tools._get_t2v_pipeline", side_effect=ImportError("no diffusers")):
- with pytest.raises(ImportError):
- generate_video_clip("a sunset")
-
- def test_duration_clamped(self):
- """Duration is clamped to 2–10 range."""
- import sys
-
- mock_pipe = MagicMock()
- mock_pipe.device = "cpu"
- mock_result = MagicMock()
- mock_result.frames = [[MagicMock() for _ in range(48)]]
- mock_pipe.return_value = mock_result
-
- mock_torch = MagicMock()
- mock_torch.Generator.return_value = MagicMock()
-
- out_dir = MagicMock()
- out_dir.__truediv__ = MagicMock(return_value=MagicMock(__str__=lambda s: "/fake/clip.mp4"))
-
- with patch.dict(sys.modules, {"torch": mock_torch}):
- with patch("creative.tools.video_tools._get_t2v_pipeline", return_value=mock_pipe):
- with patch("creative.tools.video_tools._export_frames_to_mp4"):
- with patch("creative.tools.video_tools._output_dir", return_value=out_dir):
- with patch("creative.tools.video_tools._save_metadata"):
- result = generate_video_clip("test", duration=50)
- assert result["duration"] == 10 # clamped
-
-
-class TestImageToVideoInterface:
- def test_raises_without_creative_deps(self):
- with patch("creative.tools.video_tools._t2v_pipeline", None):
- with patch("creative.tools.video_tools._get_t2v_pipeline", side_effect=ImportError("no diffusers")):
- with pytest.raises(ImportError):
- image_to_video("/fake/image.png", "animate")
diff --git a/tests/dashboard/test_bug_ingestion.py b/tests/dashboard/test_bug_ingestion.py
deleted file mode 100644
index 334f3a2d..00000000
--- a/tests/dashboard/test_bug_ingestion.py
+++ /dev/null
@@ -1,336 +0,0 @@
-"""Tests for bug report ingestion pipeline.
-
-TDD — these tests are written FIRST, before the implementation.
-Tests cover:
- 1. POST /api/bugs/submit endpoint
- 2. handle_bug_report handler with decision trail
- 3. CLI ingest-report command
-"""
-
-import json
-from unittest.mock import MagicMock, patch
-
-import pytest
-from fastapi.testclient import TestClient
-
-
-@pytest.fixture(autouse=True)
-def _isolate_db(tmp_path, monkeypatch):
- """Point task_queue and event_log SQLite to a temp directory."""
- db = tmp_path / "swarm.db"
- monkeypatch.setattr("swarm.task_queue.models.DB_PATH", db)
- monkeypatch.setattr("swarm.event_log.DB_PATH", db)
-
-
-@pytest.fixture
-def client():
- from dashboard.app import app
-
- with TestClient(app) as c:
- yield c
-
-
-# ── Sample data ──────────────────────────────────────────────────────────
-
-
-def _sample_report(bugs=None):
- """Build a minimal test report."""
- if bugs is None:
- bugs = [{"title": "Test bug", "severity": "P1", "description": "Something broke"}]
- return {"reporter": "comet", "bugs": bugs}
-
-
-def _sample_bug(**overrides):
- """Build a single bug entry with defaults."""
- bug = {
- "title": "Widget crashes on click",
- "severity": "P0",
- "description": "Clicking the save button crashes the app",
- }
- bug.update(overrides)
- return bug
-
-
-# ── Test Group 1: Bug Submission Endpoint ────────────────────────────────
-
-
-class TestBugSubmitEndpoint:
-
- def test_submit_single_bug(self, client):
- """POST one bug creates one bug_report task."""
- resp = client.post("/api/bugs/submit", json=_sample_report())
- assert resp.status_code == 200
- data = resp.json()
- assert data["created"] == 1
- assert len(data["task_ids"]) == 1
-
- def test_submit_multiple_bugs(self, client):
- """POST 3 bugs creates 3 tasks."""
- bugs = [
- _sample_bug(title="Bug A", severity="P0"),
- _sample_bug(title="Bug B", severity="P1"),
- _sample_bug(title="Bug C", severity="P2"),
- ]
- resp = client.post("/api/bugs/submit", json=_sample_report(bugs))
- assert resp.status_code == 200
- data = resp.json()
- assert data["created"] == 3
- assert len(data["task_ids"]) == 3
-
- def test_submit_maps_severity_to_priority(self, client):
- """P0→urgent, P1→high, P2→normal."""
- from swarm.task_queue.models import get_task
-
- bugs = [
- _sample_bug(title="P0 bug", severity="P0"),
- _sample_bug(title="P1 bug", severity="P1"),
- _sample_bug(title="P2 bug", severity="P2"),
- ]
- resp = client.post("/api/bugs/submit", json=_sample_report(bugs))
- data = resp.json()
-
- tasks = [get_task(tid) for tid in data["task_ids"]]
- priorities = {t.title: t.priority.value for t in tasks}
-
- assert priorities["[P0] P0 bug"] == "urgent"
- assert priorities["[P1] P1 bug"] == "high"
- assert priorities["[P2] P2 bug"] == "normal"
-
- def test_submit_formats_description(self, client):
- """Evidence, root_cause, fix_options appear in task description."""
- from swarm.task_queue.models import get_task
-
- bug = _sample_bug(
- evidence="Console shows null pointer",
- root_cause="Missing null check in handler",
- fix_options=["Add guard clause", "Use optional chaining"],
- )
- resp = client.post("/api/bugs/submit", json=_sample_report([bug]))
- data = resp.json()
-
- task = get_task(data["task_ids"][0])
- assert "Console shows null pointer" in task.description
- assert "Missing null check" in task.description
- assert "Add guard clause" in task.description
-
- def test_submit_sets_task_type(self, client):
- """Created tasks have task_type='bug_report'."""
- from swarm.task_queue.models import get_task
-
- resp = client.post("/api/bugs/submit", json=_sample_report())
- data = resp.json()
-
- task = get_task(data["task_ids"][0])
- assert task.task_type == "bug_report"
-
- def test_submit_rejects_empty_bugs(self, client):
- """400 when bugs array is empty."""
- resp = client.post("/api/bugs/submit", json=_sample_report(bugs=[]))
- assert resp.status_code == 400
-
- def test_submit_rejects_missing_fields(self, client):
- """400 when required fields are missing."""
- resp = client.post("/api/bugs/submit", json={"reporter": "comet", "bugs": [{"title": "x"}]})
- assert resp.status_code == 400
-
- def test_submit_records_reporter(self, client):
- """Task created_by reflects the reporter."""
- from swarm.task_queue.models import get_task
-
- resp = client.post("/api/bugs/submit", json=_sample_report())
- data = resp.json()
-
- task = get_task(data["task_ids"][0])
- assert task.created_by == "comet"
-
-
-# ── Test Group 2: Bug Report Handler + Decision Trail ────────────────────
-
-
-class TestBugReportHandler:
-
- def _make_task(self, **overrides):
- """Create a real bug_report task in the queue."""
- from swarm.task_queue.models import create_task
-
- defaults = {
- "title": "[P0] Widget crash",
- "description": "The widget crashes when clicked",
- "task_type": "bug_report",
- "priority": "urgent",
- "created_by": "comet",
- }
- defaults.update(overrides)
- return create_task(**defaults)
-
- def _get_handler(self):
- """Import the handle_bug_report function directly."""
- from dashboard.app import handle_bug_report
-
- return handle_bug_report
-
- def test_handler_dispatches_fix_to_forge(self):
- """Handler creates a code_fix task assigned to Forge."""
- from swarm.task_queue.models import get_task, list_tasks
-
- handler = self._get_handler()
- task = self._make_task()
- result = handler(task)
-
- # Should mention Forge dispatch
- assert "Forge" in result
- assert "Fix dispatched" in result
-
- # A code_fix task should exist assigned to forge
- all_tasks = list_tasks()
- fix_tasks = [t for t in all_tasks if t.task_type == "code_fix" and t.assigned_to == "forge"]
- assert len(fix_tasks) == 1
- fix = fix_tasks[0]
- assert fix.title == f"[Fix] {task.title}"
- assert fix.created_by == "timmy"
- assert fix.parent_task_id == task.id
-
- def test_handler_logs_decision_to_event_log(self):
- """Handler logs a decision entry to the event log."""
- handler = self._get_handler()
- task = self._make_task()
- handler(task)
-
- from swarm.event_log import EventType, list_events
-
- events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id)
- assert len(events) >= 1
-
- decision = json.loads(events[0].data)
- assert decision["action"] == "dispatch_to_forge"
- assert decision["outcome"] == "fix_dispatched"
- assert "fix_task_id" in decision
-
- def test_handler_fix_task_links_to_bug(self):
- """Fix task has parent_task_id pointing to the original bug."""
- from swarm.task_queue.models import get_task
-
- handler = self._get_handler()
- task = self._make_task()
- handler(task)
-
- from swarm.event_log import EventType, list_events
-
- events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id)
- decision = json.loads(events[0].data)
- fix_task = get_task(decision["fix_task_id"])
-
- assert fix_task.parent_task_id == task.id
- assert fix_task.task_type == "code_fix"
-
- def test_handler_graceful_fallback_on_dispatch_failure(self):
- """When create_task fails, handler still returns a result and logs the error."""
- handler = self._get_handler()
- task = self._make_task() # Create task before patching
-
- # Patch only the handler's dispatch call (re-imported inside the function body)
- with patch("swarm.task_queue.models.create_task", side_effect=RuntimeError("db locked")):
- result = handler(task)
-
- assert result is not None
- assert "dispatch failed" in result.lower()
-
- from swarm.event_log import EventType, list_events
-
- events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id)
- assert len(events) >= 1
- decision = json.loads(events[0].data)
- assert decision["outcome"] == "dispatch_failed"
- assert "db locked" in decision.get("error", "")
-
- def test_handler_decision_includes_reason(self):
- """Decision dict always has action, reason, priority, outcome."""
- handler = self._get_handler()
- task = self._make_task()
- handler(task)
-
- from swarm.event_log import EventType, list_events
-
- events = list_events(event_type=EventType.BUG_REPORT_CREATED, task_id=task.id)
- decision = json.loads(events[0].data)
-
- assert "action" in decision
- assert "reason" in decision
- assert "priority" in decision
- assert "outcome" in decision
-
- def test_handler_result_is_not_just_acknowledged(self):
- """task.result should contain structured info, not just 'acknowledged'."""
- handler = self._get_handler()
- task = self._make_task()
- result = handler(task)
-
- assert "acknowledged" not in result.lower() or "Decision" in result
-
-
-# ── Test Group 3: CLI Command ────────────────────────────────────────────
-
-
-class TestIngestReportCLI:
-
- def test_cli_ingest_from_file(self, tmp_path):
- """CLI reads a JSON file and creates tasks."""
- from typer.testing import CliRunner
- from timmy.cli import app
-
- report = _sample_report([
- _sample_bug(title="CLI Bug A", severity="P1"),
- _sample_bug(title="CLI Bug B", severity="P2"),
- ])
- report_file = tmp_path / "report.json"
- report_file.write_text(json.dumps(report))
-
- runner = CliRunner()
- result = runner.invoke(app, ["ingest-report", str(report_file)])
-
- assert result.exit_code == 0
- assert "2" in result.stdout # 2 bugs created
-
- def test_cli_dry_run(self, tmp_path):
- """--dry-run shows bugs but creates nothing."""
- from typer.testing import CliRunner
- from timmy.cli import app
- from swarm.task_queue.models import list_tasks
-
- report = _sample_report([_sample_bug(title="Dry Run Bug")])
- report_file = tmp_path / "report.json"
- report_file.write_text(json.dumps(report))
-
- runner = CliRunner()
- result = runner.invoke(app, ["ingest-report", "--dry-run", str(report_file)])
-
- assert result.exit_code == 0
- assert "dry run" in result.stdout.lower()
-
- # No tasks should have been created
- tasks = list_tasks()
- bug_tasks = [t for t in tasks if t.task_type == "bug_report" and "Dry Run" in t.title]
- assert len(bug_tasks) == 0
-
- def test_cli_invalid_json(self, tmp_path):
- """CLI exits with error on invalid JSON."""
- from typer.testing import CliRunner
- from timmy.cli import app
-
- bad_file = tmp_path / "bad.json"
- bad_file.write_text("not json {{{")
-
- runner = CliRunner()
- result = runner.invoke(app, ["ingest-report", str(bad_file)])
-
- assert result.exit_code != 0
-
- def test_cli_missing_file(self):
- """CLI exits with error when file doesn't exist."""
- from typer.testing import CliRunner
- from timmy.cli import app
-
- runner = CliRunner()
- result = runner.invoke(app, ["ingest-report", "/nonexistent/file.json"])
-
- assert result.exit_code != 0
diff --git a/tests/dashboard/test_bugs_route.py b/tests/dashboard/test_bugs_route.py
deleted file mode 100644
index 13186c31..00000000
--- a/tests/dashboard/test_bugs_route.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""Tests for bug reports dashboard route."""
-
-import pytest
-from fastapi.testclient import TestClient
-
-
-@pytest.fixture(autouse=True)
-def _isolate_db(tmp_path, monkeypatch):
- """Point task_queue and event_log SQLite to a temp directory."""
- db = tmp_path / "swarm.db"
- monkeypatch.setattr("swarm.task_queue.models.DB_PATH", db)
- monkeypatch.setattr("swarm.event_log.DB_PATH", db)
-
-
-@pytest.fixture
-def client():
- from dashboard.app import app
-
- with TestClient(app) as c:
- yield c
-
-
-def test_bugs_page_loads(client):
- resp = client.get("/bugs")
- assert resp.status_code == 200
- assert "BUG REPORTS" in resp.text
-
-
-def test_api_list_bugs(client):
- resp = client.get("/api/bugs")
- assert resp.status_code == 200
- data = resp.json()
- assert "bugs" in data
- assert "count" in data
-
-
-def test_api_bug_stats(client):
- resp = client.get("/api/bugs/stats")
- assert resp.status_code == 200
- data = resp.json()
- assert "stats" in data
- assert "total" in data
-
-
-def test_bugs_page_with_status_filter(client):
- resp = client.get("/bugs?status=approved")
- assert resp.status_code == 200
diff --git a/tests/dashboard/test_dashboard.py b/tests/dashboard/test_dashboard.py
index a7c43223..5f6b4dca 100644
--- a/tests/dashboard/test_dashboard.py
+++ b/tests/dashboard/test_dashboard.py
@@ -102,8 +102,7 @@ def test_chat_timmy_success(client):
assert response.status_code == 200
assert "status?" in response.text
- # In async mode, the response acknowledges queuing
- assert "Message queued" in response.text
+ assert "I am Timmy" in response.text
def test_chat_timmy_shows_user_message(client):
@@ -114,12 +113,10 @@ def test_chat_timmy_shows_user_message(client):
def test_chat_timmy_ollama_offline(client):
- # In async mode, chat_timmy queues the message regardless of Ollama status
- # because processing happens in a background task.
+ # Without Ollama, chat returns an error but still shows the user message.
response = client.post("/agents/timmy/chat", data={"message": "ping"})
assert response.status_code == 200
- assert "Message queued" in response.text
assert "ping" in response.text
diff --git a/tests/dashboard/test_integration_full.py b/tests/dashboard/test_integration_full.py
deleted file mode 100644
index 4c0a2409..00000000
--- a/tests/dashboard/test_integration_full.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""End-to-end integration tests for the complete system.
-
-These tests verify the full stack works together.
-"""
-
-import pytest
-from fastapi.testclient import TestClient
-
-
-class TestDashboardIntegration:
- """Integration tests for the dashboard app."""
-
- @pytest.fixture
- def client(self):
- """Create a test client."""
- from dashboard.app import app
- return TestClient(app)
-
- def test_health_endpoint(self, client):
- """Test the health check endpoint works."""
- response = client.get("/health")
- assert response.status_code == 200
- data = response.json()
- assert "status" in data
-
- def test_index_page_loads(self, client):
- """Test the main page loads."""
- response = client.get("/")
- assert response.status_code == 200
- assert "Timmy" in response.text or "Mission Control" in response.text
-
-
-class TestRouterAPIIntegration:
- """Integration tests for Router API endpoints."""
-
- @pytest.fixture
- def client(self):
- """Create a test client."""
- from dashboard.app import app
- return TestClient(app)
-
- def test_router_status_endpoint(self, client):
- """Test the router status endpoint."""
- response = client.get("/api/v1/router/status")
- assert response.status_code == 200
- data = response.json()
- assert "total_providers" in data
- assert "providers" in data
-
- def test_router_metrics_endpoint(self, client):
- """Test the router metrics endpoint."""
- response = client.get("/api/v1/router/metrics")
- assert response.status_code == 200
- data = response.json()
- assert "providers" in data
-
- def test_router_providers_endpoint(self, client):
- """Test the router providers list endpoint."""
- response = client.get("/api/v1/router/providers")
- assert response.status_code == 200
- data = response.json()
- assert isinstance(data, list)
-
- def test_router_config_endpoint(self, client):
- """Test the router config endpoint."""
- response = client.get("/api/v1/router/config")
- assert response.status_code == 200
- data = response.json()
- assert "timeout_seconds" in data
- assert "circuit_breaker" in data
-
-
-class TestMCPIntegration:
- """Integration tests for MCP system."""
-
- def test_mcp_registry_singleton(self):
- """Test that MCP registry is properly initialized."""
- from mcp.registry import tool_registry, get_registry
-
- # Should be the same object
- assert get_registry() is tool_registry
-
- def test_mcp_discovery_singleton(self):
- """Test that MCP discovery is properly initialized."""
- from mcp.discovery import get_discovery
-
- discovery1 = get_discovery()
- discovery2 = get_discovery()
-
- # Should be the same object
- assert discovery1 is discovery2
-
- def test_mcp_bootstrap_status(self):
- """Test that bootstrap status returns valid data."""
- from mcp.bootstrap import get_bootstrap_status
-
- status = get_bootstrap_status()
-
- assert isinstance(status["auto_bootstrap_enabled"], bool)
- assert isinstance(status["discovered_tools_count"], int)
- assert isinstance(status["registered_tools_count"], int)
-
-
-class TestEventBusIntegration:
- """Integration tests for Event Bus."""
-
- @pytest.mark.asyncio
- async def test_event_bus_publish_subscribe(self):
- """Test event bus publish and subscribe works."""
- from infrastructure.events.bus import EventBus, Event
-
- bus = EventBus()
- events_received = []
-
- @bus.subscribe("test.event.*")
- async def handler(event):
- events_received.append(event.data)
-
- await bus.publish(Event(
- type="test.event.test",
- source="test",
- data={"message": "hello"}
- ))
-
- # Give async handler time to run
- import asyncio
- await asyncio.sleep(0.1)
-
- assert len(events_received) == 1
- assert events_received[0]["message"] == "hello"
-
-
-class TestAgentSystemIntegration:
- """Integration tests for Agent system."""
-
- def test_base_agent_imports(self):
- """Test that base agent can be imported."""
- from timmy.agents.base import BaseAgent
-
- assert BaseAgent is not None
-
- def test_agent_creation(self):
- """Test creating agent config dict (AgentConfig class doesn't exist)."""
- config = {
- "name": "test_agent",
- "system_prompt": "You are a test agent.",
- }
-
- assert config["name"] == "test_agent"
- assert config["system_prompt"] == "You are a test agent."
-
-
-class TestMemorySystemIntegration:
- """Integration tests for Memory system."""
-
- def test_memory_system_imports(self):
- """Test that memory system can be imported."""
- from timmy.memory_system import MemorySystem
-
- assert MemorySystem is not None
-
- def test_semantic_memory_imports(self):
- """Test that semantic memory can be imported."""
- from timmy.semantic_memory import SemanticMemory
-
- assert SemanticMemory is not None
diff --git a/tests/dashboard/test_ledger.py b/tests/dashboard/test_ledger.py
deleted file mode 100644
index 6e1ad9a1..00000000
--- a/tests/dashboard/test_ledger.py
+++ /dev/null
@@ -1,211 +0,0 @@
-"""Tests for Lightning ledger system."""
-
-import pytest
-from lightning.ledger import (
- TransactionType,
- TransactionStatus,
- create_invoice_entry,
- record_outgoing_payment,
- mark_settled,
- mark_failed,
- get_by_hash,
- list_transactions,
- get_balance,
- get_transaction_stats,
-)
-
-
-class TestLedger:
- """Test suite for Lightning ledger functionality."""
-
- def test_create_invoice_entry(self):
- """Test creating an incoming invoice entry."""
- entry = create_invoice_entry(
- payment_hash="test_hash_001",
- amount_sats=1000,
- memo="Test invoice",
- invoice="lnbc10u1...",
- source="test",
- task_id="task-123",
- agent_id="agent-456",
- )
-
- assert entry.tx_type == TransactionType.INCOMING
- assert entry.status == TransactionStatus.PENDING
- assert entry.amount_sats == 1000
- assert entry.payment_hash == "test_hash_001"
- assert entry.memo == "Test invoice"
- assert entry.task_id == "task-123"
- assert entry.agent_id == "agent-456"
-
- def test_record_outgoing_payment(self):
- """Test recording an outgoing payment."""
- entry = record_outgoing_payment(
- payment_hash="test_hash_002",
- amount_sats=500,
- memo="Test payment",
- source="test",
- task_id="task-789",
- )
-
- assert entry.tx_type == TransactionType.OUTGOING
- assert entry.status == TransactionStatus.PENDING
- assert entry.amount_sats == 500
- assert entry.payment_hash == "test_hash_002"
-
- def test_mark_settled(self):
- """Test marking a transaction as settled."""
- # Create invoice
- entry = create_invoice_entry(
- payment_hash="test_hash_settle",
- amount_sats=100,
- memo="To be settled",
- )
- assert entry.status == TransactionStatus.PENDING
-
- # Mark as settled
- settled = mark_settled(
- payment_hash="test_hash_settle",
- preimage="preimage123",
- fee_sats=1,
- )
-
- assert settled is not None
- assert settled.status == TransactionStatus.SETTLED
- assert settled.preimage == "preimage123"
- assert settled.fee_sats == 1
- assert settled.settled_at is not None
-
- # Verify retrieval
- retrieved = get_by_hash("test_hash_settle")
- assert retrieved.status == TransactionStatus.SETTLED
-
- def test_mark_failed(self):
- """Test marking a transaction as failed."""
- # Create invoice
- entry = create_invoice_entry(
- payment_hash="test_hash_fail",
- amount_sats=200,
- memo="To fail",
- )
-
- # Mark as failed
- failed = mark_failed("test_hash_fail", reason="Timeout")
-
- assert failed is not None
- assert failed.status == TransactionStatus.FAILED
- assert "Timeout" in failed.memo
-
- def test_get_by_hash_not_found(self):
- """Test retrieving non-existent transaction."""
- result = get_by_hash("nonexistent_hash")
- assert result is None
-
- def test_list_transactions_filtering(self):
- """Test filtering transactions."""
- # Create various transactions
- create_invoice_entry("filter_test_1", 100, source="filter_test")
- create_invoice_entry("filter_test_2", 200, source="filter_test")
-
- # Filter by type
- incoming = list_transactions(
- tx_type=TransactionType.INCOMING,
- limit=10,
- )
- assert all(t.tx_type == TransactionType.INCOMING for t in incoming)
-
- # Filter by status
- pending = list_transactions(
- status=TransactionStatus.PENDING,
- limit=10,
- )
- assert all(t.status == TransactionStatus.PENDING for t in pending)
-
- def test_get_balance(self):
- """Test balance calculation."""
- # Get initial balance
- balance = get_balance()
-
- assert "incoming_total_sats" in balance
- assert "outgoing_total_sats" in balance
- assert "net_sats" in balance
- assert isinstance(balance["incoming_total_sats"], int)
- assert isinstance(balance["outgoing_total_sats"], int)
-
- def test_transaction_stats(self):
- """Test transaction statistics."""
- # Create some transactions
- create_invoice_entry("stats_test_1", 100, source="stats_test")
- create_invoice_entry("stats_test_2", 200, source="stats_test")
-
- # Get stats
- stats = get_transaction_stats(days=1)
-
- # Should return dict with dates
- assert isinstance(stats, dict)
- # Stats structure depends on current date, just verify it's a dict
-
- def test_unique_payment_hash(self):
- """Test that payment hashes must be unique."""
- import sqlite3
-
- hash_value = "unique_hash_test"
-
- # First creation should succeed
- create_invoice_entry(hash_value, 100)
-
- # Second creation with same hash should fail with IntegrityError
- with pytest.raises(sqlite3.IntegrityError):
- create_invoice_entry(hash_value, 200)
-
-
-class TestLedgerIntegration:
- """Integration tests for ledger workflow."""
-
- def test_full_invoice_lifecycle(self):
- """Test complete invoice lifecycle: create -> settle."""
- # Create invoice
- entry = create_invoice_entry(
- payment_hash="lifecycle_test",
- amount_sats=5000,
- memo="Full lifecycle test",
- source="integration_test",
- )
-
- assert entry.status == TransactionStatus.PENDING
-
- # Mark as settled
- settled = mark_settled("lifecycle_test", preimage="secret_preimage")
-
- assert settled.status == TransactionStatus.SETTLED
- assert settled.preimage == "secret_preimage"
-
- # Verify in list
- transactions = list_transactions(limit=100)
- assert any(t.payment_hash == "lifecycle_test" for t in transactions)
-
- # Verify balance reflects it
- balance = get_balance()
- # Balance should include this settled invoice
-
- def test_outgoing_payment_lifecycle(self):
- """Test complete outgoing payment lifecycle."""
- # Record outgoing payment
- entry = record_outgoing_payment(
- payment_hash="outgoing_test",
- amount_sats=300,
- memo="Outgoing payment",
- source="integration_test",
- )
-
- assert entry.tx_type == TransactionType.OUTGOING
-
- # Mark as settled (payment completed)
- settled = mark_settled(
- "outgoing_test",
- preimage="payment_proof",
- fee_sats=3,
- )
-
- assert settled.fee_sats == 3
- assert settled.status == TransactionStatus.SETTLED
diff --git a/tests/dashboard/test_mission_control.py b/tests/dashboard/test_mission_control.py
index a2e65b95..50b80246 100644
--- a/tests/dashboard/test_mission_control.py
+++ b/tests/dashboard/test_mission_control.py
@@ -1,98 +1,52 @@
-"""Tests for Mission Control dashboard.
-
-TDD approach: Tests written first, then implementation.
-"""
+"""Tests for health and sovereignty endpoints."""
import pytest
-from unittest.mock import patch, MagicMock
+from unittest.mock import patch
class TestSovereigntyEndpoint:
"""Tests for /health/sovereignty endpoint."""
-
+
def test_sovereignty_returns_overall_score(self, client):
- """Should return overall sovereignty score."""
response = client.get("/health/sovereignty")
assert response.status_code == 200
-
data = response.json()
assert "overall_score" in data
assert isinstance(data["overall_score"], (int, float))
assert 0 <= data["overall_score"] <= 10
-
+
def test_sovereignty_returns_dependencies(self, client):
- """Should return list of dependencies with status."""
response = client.get("/health/sovereignty")
assert response.status_code == 200
-
data = response.json()
assert "dependencies" in data
assert isinstance(data["dependencies"], list)
-
- # Check required fields for each dependency
for dep in data["dependencies"]:
assert "name" in dep
- assert "status" in dep # "healthy", "degraded", "unavailable"
+ assert "status" in dep
assert "sovereignty_score" in dep
assert "details" in dep
-
+
def test_sovereignty_returns_recommendations(self, client):
- """Should return recommendations list."""
response = client.get("/health/sovereignty")
assert response.status_code == 200
-
data = response.json()
assert "recommendations" in data
assert isinstance(data["recommendations"], list)
-
+
def test_sovereignty_includes_timestamps(self, client):
- """Should include timestamp."""
response = client.get("/health/sovereignty")
assert response.status_code == 200
-
data = response.json()
assert "timestamp" in data
-class TestMissionControlPage:
- """Tests for Mission Control dashboard page."""
-
- def test_mission_control_page_loads(self, client):
- """Should render Mission Control page."""
- response = client.get("/swarm/mission-control")
- assert response.status_code == 200
- assert "Mission Control" in response.text
-
- def test_mission_control_includes_sovereignty_score(self, client):
- """Page should display sovereignty score element."""
- response = client.get("/swarm/mission-control")
- assert response.status_code == 200
- assert "sov-score" in response.text # Element ID for JavaScript
-
- def test_mission_control_includes_dependency_grid(self, client):
- """Page should display dependency grid."""
- response = client.get("/swarm/mission-control")
- assert response.status_code == 200
- assert "dependency-grid" in response.text
-
-
class TestHealthComponentsEndpoint:
"""Tests for /health/components endpoint."""
-
- def test_components_returns_lightning_info(self, client):
- """Should return Lightning backend info."""
- response = client.get("/health/components")
- assert response.status_code == 200
-
- data = response.json()
- assert "lightning" in data
- assert "configured_backend" in data["lightning"]
-
+
def test_components_returns_config(self, client):
- """Should return system config."""
response = client.get("/health/components")
assert response.status_code == 200
-
data = response.json()
assert "config" in data
assert "debug" in data["config"]
@@ -101,34 +55,26 @@ class TestHealthComponentsEndpoint:
class TestScaryPathScenarios:
"""Scary path tests for production scenarios."""
-
+
def test_concurrent_sovereignty_requests(self, client):
- """Should handle concurrent requests to /health/sovereignty."""
import concurrent.futures
-
+
def fetch():
return client.get("/health/sovereignty")
-
+
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(fetch) for _ in range(10)]
responses = [f.result() for f in concurrent.futures.as_completed(futures)]
-
- # All should succeed
+
assert all(r.status_code == 200 for r in responses)
-
- # All should have valid JSON
for r in responses:
data = r.json()
assert "overall_score" in data
-
+
def test_sovereignty_with_missing_dependencies(self, client):
- """Should handle missing dependencies gracefully."""
- # Mock a failure scenario - patch at the module level where used
with patch("dashboard.routes.health.check_ollama", return_value=False):
response = client.get("/health/sovereignty")
assert response.status_code == 200
-
data = response.json()
- # Should still return valid response even with failures
assert "overall_score" in data
assert "dependencies" in data
diff --git a/tests/e2e/test_dashboard_responsiveness.py b/tests/e2e/test_dashboard_responsiveness.py
deleted file mode 100644
index a10b63a8..00000000
--- a/tests/e2e/test_dashboard_responsiveness.py
+++ /dev/null
@@ -1,136 +0,0 @@
-"""End-to-end tests for dashboard responsiveness and startup.
-
-These tests verify that the dashboard starts correctly, responds to HTTP requests,
-and background tasks do not block the main application thread.
-"""
-
-import asyncio
-import pytest
-import httpx
-from unittest.mock import patch, MagicMock
-
-
-@pytest.mark.asyncio
-async def test_dashboard_startup_and_health_check():
- """Test that the dashboard starts and responds to health checks."""
- from src.dashboard.app import app
- from fastapi.testclient import TestClient
-
- client = TestClient(app)
-
- # Test root endpoint
- response = client.get("/")
- assert response.status_code in [200, 307], f"Expected 200 or 307, got {response.status_code}"
-
-
-@pytest.mark.asyncio
-async def test_dashboard_does_not_block_on_startup():
- """Test that background tasks do not block the main application startup."""
- from src.dashboard.app import app
- from fastapi.testclient import TestClient
-
- # Mock the briefing scheduler to prevent long-running operations
- with patch("src.dashboard.app._briefing_scheduler") as mock_briefing:
- mock_briefing.return_value = asyncio.sleep(0)
-
- client = TestClient(app)
-
- # The client should be able to make requests immediately
- response = client.get("/health" if hasattr(app, "health_route") else "/")
- assert response.status_code in [200, 307, 404], "Dashboard should respond quickly"
-
-
-@pytest.mark.asyncio
-async def test_background_tasks_run_asynchronously():
- """Test that background tasks run asynchronously without blocking the main thread."""
- import time
- from unittest.mock import AsyncMock
-
- # Simulate a background task
- task_started = False
- task_completed = False
-
- async def background_task():
- nonlocal task_started, task_completed
- task_started = True
- await asyncio.sleep(0.1)
- task_completed = True
-
- # Run the task asynchronously
- task = asyncio.create_task(background_task())
-
- # Verify the task is running
- await asyncio.sleep(0.05)
- assert task_started, "Background task should have started"
- assert not task_completed, "Background task should not be completed yet"
-
- # Wait for the task to complete
- await task
- assert task_completed, "Background task should have completed"
-
-
-@pytest.mark.asyncio
-async def test_ollama_model_availability():
- """Test that Ollama models are available and accessible."""
- import urllib.request
- import json
- from config import settings
-
- try:
- url = settings.ollama_url.replace("localhost", "127.0.0.1")
- req = urllib.request.Request(
- f"{url}/api/tags",
- method="GET",
- headers={"Accept": "application/json"},
- )
- with urllib.request.urlopen(req, timeout=5) as response:
- data = json.loads(response.read().decode())
- models = data.get("models", [])
- assert len(models) > 0, "At least one model should be available in Ollama"
- except Exception as e:
- pytest.skip(f"Ollama not available: {e}")
-
-
-@pytest.mark.asyncio
-async def test_timmy_agent_initialization():
- """Test that Timmy agent initializes correctly with available model."""
- from timmy.agent import create_timmy
-
- try:
- agent = create_timmy(db_file=":memory:")
- assert agent is not None, "Timmy agent should be created successfully"
- assert hasattr(agent, "run"), "Agent should have a run method"
- except Exception as e:
- pytest.skip(f"Timmy agent initialization failed: {e}")
-
-
-@pytest.mark.asyncio
-async def test_dashboard_endpoints_responsive():
- """Test that key dashboard endpoints respond within acceptable time."""
- from src.dashboard.app import app
- from fastapi.testclient import TestClient
- import time
-
- client = TestClient(app)
-
- # Test common endpoints
- endpoints = [
- "/",
- "/health",
- "/chat",
- "/swarm",
- ]
-
- for endpoint in endpoints:
- start = time.time()
- try:
- response = client.get(endpoint)
- elapsed = time.time() - start
-
- # Should respond within 5 seconds
- assert elapsed < 5, f"Endpoint {endpoint} took {elapsed}s to respond"
- # Status should be 2xx, 3xx, or 4xx (not 5xx)
- assert response.status_code < 500, f"Endpoint {endpoint} returned {response.status_code}"
- except Exception as e:
- # Skip if endpoint doesn't exist
- pass
diff --git a/tests/e2e/test_docker_deployment.py b/tests/e2e/test_docker_deployment.py
deleted file mode 100644
index 5aedb790..00000000
--- a/tests/e2e/test_docker_deployment.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""End-to-end tests for Docker deployment.
-
-These tests verify that Dockerfiles and compose configs are present,
-syntactically valid, and declare the expected services and settings.
-"""
-
-import json
-import subprocess
-
-import pytest
-from pathlib import Path
-
-PROJECT_ROOT = Path(__file__).parent.parent.parent
-
-
-@pytest.mark.skipif(
- subprocess.run(["which", "docker"], capture_output=True).returncode != 0,
- reason="Docker not installed",
-)
-class TestDockerComposeFiles:
- """Validate that all compose files exist and parse cleanly."""
-
- def test_base_compose_exists(self):
- assert (PROJECT_ROOT / "docker-compose.yml").exists()
-
- def test_dev_overlay_exists(self):
- assert (PROJECT_ROOT / "docker-compose.dev.yml").exists()
-
- def test_prod_compose_exists(self):
- assert (PROJECT_ROOT / "docker-compose.prod.yml").exists()
-
- def test_test_compose_exists(self):
- assert (PROJECT_ROOT / "docker-compose.test.yml").exists()
-
- def test_microservices_compose_exists(self):
- assert (PROJECT_ROOT / "docker-compose.microservices.yml").exists()
-
- def test_base_compose_syntax(self):
- result = subprocess.run(
- ["docker", "compose", "-f", str(PROJECT_ROOT / "docker-compose.yml"), "config"],
- capture_output=True,
- text=True,
- )
- assert result.returncode == 0, f"Docker Compose syntax error: {result.stderr}"
-
- def test_microservices_compose_services_defined(self):
- result = subprocess.run(
- [
- "docker", "compose",
- "-f", str(PROJECT_ROOT / "docker-compose.microservices.yml"),
- "config", "--format", "json",
- ],
- capture_output=True,
- text=True,
- )
- assert result.returncode == 0, f"Config error: {result.stderr}"
- config = json.loads(result.stdout)
- services = config.get("services", {})
- assert "ollama" in services, "ollama service should be defined"
- assert "dashboard" in services, "dashboard service should be defined"
- assert "timmy" in services, "timmy service should be defined"
-
- def test_microservices_compose_content(self):
- content = (PROJECT_ROOT / "docker-compose.microservices.yml").read_text()
- assert "ollama" in content
- assert "dashboard" in content
- assert "timmy" in content
- assert "timmy-net" in content
- assert "ollama-data" in content
- assert "timmy-data" in content
-
- def test_test_compose_has_test_runner(self):
- content = (PROJECT_ROOT / "docker-compose.test.yml").read_text()
- assert "test:" in content, "Test compose should define a 'test' service"
- assert "TIMMY_TEST_MODE" in content
- assert "pytest" in content
-
-
-class TestDockerfiles:
- """Validate the primary Dockerfile and specialised images."""
-
- def test_dockerfile_exists(self):
- assert (PROJECT_ROOT / "Dockerfile").exists()
-
- def test_dockerfile_ollama_exists(self):
- assert (PROJECT_ROOT / "docker" / "Dockerfile.ollama").exists()
-
- def test_dockerfile_agent_exists(self):
- assert (PROJECT_ROOT / "docker" / "Dockerfile.agent").exists()
-
- def test_dockerfile_dashboard_exists(self):
- assert (PROJECT_ROOT / "docker" / "Dockerfile.dashboard").exists()
-
- def test_dockerfile_test_exists(self):
- assert (PROJECT_ROOT / "docker" / "Dockerfile.test").exists()
-
- def test_dockerfile_health_check(self):
- content = (PROJECT_ROOT / "Dockerfile").read_text()
- assert "HEALTHCHECK" in content, "Dockerfile should include HEALTHCHECK"
- assert "/health" in content
-
- def test_dockerfile_non_root_user(self):
- content = (PROJECT_ROOT / "Dockerfile").read_text()
- assert "USER timmy" in content
- assert "groupadd -r timmy" in content
-
- @pytest.mark.skipif(
- subprocess.run(["which", "docker"], capture_output=True).returncode != 0,
- reason="Docker not installed",
- )
- @pytest.mark.timeout(300)
- def test_docker_image_build(self):
- result = subprocess.run(
- ["docker", "build", "-t", "timmy-time:test", "."],
- cwd=PROJECT_ROOT,
- capture_output=True,
- text=True,
- timeout=300,
- )
- if result.returncode != 0:
- pytest.skip(f"Docker build failed: {result.stderr}")
diff --git a/tests/e2e/test_microservices.py b/tests/e2e/test_microservices.py
deleted file mode 100644
index be1fec14..00000000
--- a/tests/e2e/test_microservices.py
+++ /dev/null
@@ -1,247 +0,0 @@
-"""End-to-end tests for microservices architecture.
-
-These tests verify that the microservices-based deployment works correctly
-with proper service isolation, communication, and orchestration.
-"""
-
-import pytest
-from pathlib import Path
-
-
-class TestMicroservicesArchitecture:
- """Test microservices architecture and Docker setup."""
-
- def test_microservices_compose_file_exists(self):
- """Test that docker-compose.microservices.yml exists."""
- compose_file = Path(__file__).parent.parent.parent / "docker-compose.microservices.yml"
- assert compose_file.exists(), "docker-compose.microservices.yml should exist"
-
- def test_microservices_compose_valid_yaml(self):
- """Test that microservices compose file is valid YAML."""
- import yaml
- compose_file = Path(__file__).parent.parent.parent / "docker-compose.microservices.yml"
-
- with open(compose_file) as f:
- config = yaml.safe_load(f)
-
- assert config is not None, "Compose file should be valid YAML"
- assert "services" in config, "Compose file should define services"
-
- def test_microservices_defines_all_services(self):
- """Test that all required services are defined."""
- import yaml
- compose_file = Path(__file__).parent.parent.parent / "docker-compose.microservices.yml"
-
- with open(compose_file) as f:
- config = yaml.safe_load(f)
-
- services = config.get("services", {})
- required_services = ["ollama", "dashboard", "timmy", "worker"]
-
- for service in required_services:
- assert service in services, f"Service '{service}' should be defined"
-
- def test_ollama_service_configuration(self):
- """Test that Ollama service is properly configured."""
- import yaml
- compose_file = Path(__file__).parent.parent.parent / "docker-compose.microservices.yml"
-
- with open(compose_file) as f:
- config = yaml.safe_load(f)
-
- ollama = config["services"]["ollama"]
-
- # Check required fields
- assert "image" in ollama or "build" in ollama, "Ollama should have image or build"
- assert "ports" in ollama, "Ollama should expose port 11434"
- assert "healthcheck" in ollama, "Ollama should have healthcheck"
- assert "volumes" in ollama, "Ollama should have volume for models"
-
- def test_dashboard_service_configuration(self):
- """Test that Dashboard service is properly configured."""
- import yaml
- compose_file = Path(__file__).parent.parent.parent / "docker-compose.microservices.yml"
-
- with open(compose_file) as f:
- config = yaml.safe_load(f)
-
- dashboard = config["services"]["dashboard"]
-
- # Check required fields
- assert "image" in dashboard or "build" in dashboard, "Dashboard should have image or build"
- assert "ports" in dashboard, "Dashboard should expose port 8000"
- assert "depends_on" in dashboard, "Dashboard should depend on ollama"
- assert "healthcheck" in dashboard, "Dashboard should have healthcheck"
-
- def test_timmy_agent_service_configuration(self):
- """Test that Timmy agent service is properly configured."""
- import yaml
- compose_file = Path(__file__).parent.parent.parent / "docker-compose.microservices.yml"
-
- with open(compose_file) as f:
- config = yaml.safe_load(f)
-
- timmy = config["services"]["timmy"]
-
- # Check required fields
- assert "image" in timmy or "build" in timmy, "Timmy should have image or build"
- assert "depends_on" in timmy, "Timmy should depend on dashboard and ollama"
- assert "environment" in timmy, "Timmy should have environment variables"
-
- def test_worker_service_is_scalable(self):
- """Test that worker service is configured for scaling."""
- import yaml
- compose_file = Path(__file__).parent.parent.parent / "docker-compose.microservices.yml"
-
- with open(compose_file) as f:
- config = yaml.safe_load(f)
-
- worker = config["services"]["worker"]
-
- # Check for scaling configuration
- assert "profiles" in worker, "Worker should have profiles for optional scaling"
- assert "workers" in worker["profiles"], "Worker should be in 'workers' profile"
-
- def test_network_configuration(self):
- """Test that services are on the same network."""
- import yaml
- compose_file = Path(__file__).parent.parent.parent / "docker-compose.microservices.yml"
-
- with open(compose_file) as f:
- config = yaml.safe_load(f)
-
- # Check networks exist
- assert "networks" in config, "Compose should define networks"
- assert "timmy-net" in config["networks"], "Should have timmy-net network"
-
- # Check all services use the network
- for service_name, service in config["services"].items():
- assert "networks" in service, f"Service {service_name} should be on a network"
-
- def test_volume_configuration(self):
- """Test that volumes are properly configured."""
- import yaml
- compose_file = Path(__file__).parent.parent.parent / "docker-compose.microservices.yml"
-
- with open(compose_file) as f:
- config = yaml.safe_load(f)
-
- # Check volumes exist
- assert "volumes" in config, "Compose should define volumes"
- assert "timmy-data" in config["volumes"], "Should have timmy-data volume"
- assert "ollama-data" in config["volumes"], "Should have ollama-data volume"
-
-
-class TestDockerfiles:
- """Test individual Dockerfiles for microservices."""
-
- def test_dashboard_dockerfile_exists(self):
- """Test that dashboard Dockerfile exists."""
- dockerfile = Path(__file__).parent.parent.parent / "docker" / "Dockerfile.dashboard"
- assert dockerfile.exists(), "docker/Dockerfile.dashboard should exist"
-
- def test_agent_dockerfile_exists(self):
- """Test that agent Dockerfile exists."""
- dockerfile = Path(__file__).parent.parent.parent / "docker" / "Dockerfile.agent"
- assert dockerfile.exists(), "docker/Dockerfile.agent should exist"
-
- def test_ollama_dockerfile_exists(self):
- """Test that Ollama Dockerfile exists."""
- dockerfile = Path(__file__).parent.parent.parent / "docker" / "Dockerfile.ollama"
- assert dockerfile.exists(), "docker/Dockerfile.ollama should exist"
-
- def test_init_ollama_script_exists(self):
- """Test that Ollama init script exists."""
- script = Path(__file__).parent.parent.parent / "docker" / "scripts" / "init-ollama.sh"
- assert script.exists(), "docker/scripts/init-ollama.sh should exist"
-
- def test_dashboard_dockerfile_multistage(self):
- """Test that dashboard Dockerfile uses multi-stage build."""
- dockerfile = Path(__file__).parent.parent.parent / "docker" / "Dockerfile.dashboard"
-
- with open(dockerfile) as f:
- content = f.read()
-
- # Count FROM statements (should be 2 for multi-stage)
- from_count = content.count("FROM ")
- assert from_count >= 2, "Dashboard Dockerfile should use multi-stage build"
-
- def test_agent_dockerfile_multistage(self):
- """Test that agent Dockerfile uses multi-stage build."""
- dockerfile = Path(__file__).parent.parent.parent / "docker" / "Dockerfile.agent"
-
- with open(dockerfile) as f:
- content = f.read()
-
- from_count = content.count("FROM ")
- assert from_count >= 2, "Agent Dockerfile should use multi-stage build"
-
- def test_dashboard_dockerfile_has_healthcheck(self):
- """Test that dashboard Dockerfile includes healthcheck."""
- dockerfile = Path(__file__).parent.parent.parent / "docker" / "Dockerfile.dashboard"
-
- with open(dockerfile) as f:
- content = f.read()
-
- assert "HEALTHCHECK" in content, "Dashboard should have healthcheck"
-
- def test_ollama_dockerfile_has_healthcheck(self):
- """Test that Ollama Dockerfile includes healthcheck."""
- dockerfile = Path(__file__).parent.parent.parent / "docker" / "Dockerfile.ollama"
-
- with open(dockerfile) as f:
- content = f.read()
-
- assert "HEALTHCHECK" in content, "Ollama should have healthcheck"
-
- def test_dockerfiles_use_nonroot_user(self):
- """Test that Dockerfiles run as non-root user."""
- for dockerfile_name in ["Dockerfile.dashboard", "Dockerfile.agent"]:
- dockerfile = Path(__file__).parent.parent.parent / "docker" / dockerfile_name
-
- with open(dockerfile) as f:
- content = f.read()
-
- assert "USER " in content, f"{dockerfile_name} should specify a USER"
-
-
-class TestTestFixtures:
- """Test that test fixtures are properly configured."""
-
- def test_conftest_exists(self):
- """Test that conftest.py exists."""
- conftest = Path(__file__).parent.parent / "conftest.py"
- assert conftest.exists(), "tests/conftest.py should exist"
-
- def test_conftest_has_mock_fixtures(self):
- """Test that conftest has mock fixtures."""
- conftest = Path(__file__).parent.parent / "conftest.py"
-
- with open(conftest) as f:
- content = f.read()
-
- required_fixtures = [
- "mock_ollama_client",
- "mock_timmy_agent",
- "mock_swarm_coordinator",
- "mock_memory_system",
- ]
-
- for fixture in required_fixtures:
- assert fixture in content, f"conftest should define {fixture}"
-
- def test_conftest_has_sample_data_fixtures(self):
- """Test that conftest has sample data fixtures."""
- conftest = Path(__file__).parent.parent / "conftest.py"
-
- with open(conftest) as f:
- content = f.read()
-
- required_fixtures = [
- "sample_interview_data",
- "sample_task_data",
- "sample_agent_data",
- ]
-
- for fixture in required_fixtures:
- assert fixture in content, f"conftest should define {fixture}"
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
index 89ec960c..ac5a7b30 100644
--- a/tests/functional/conftest.py
+++ b/tests/functional/conftest.py
@@ -108,14 +108,6 @@ def serve_runner():
yield CliRunner(), app
-@pytest.fixture
-def self_tdd_runner():
- """Typer CLI runner for self-tdd CLI tests."""
- from typer.testing import CliRunner
- from self_coding.self_tdd.cli import app
- yield CliRunner(), app
-
-
@pytest.fixture
def docker_stack():
"""Docker stack URL for container-level tests.
@@ -139,15 +131,6 @@ def serve_client():
yield c
-@pytest.fixture
-def tdd_runner():
- """Alias for self_tdd_runner fixture."""
- pytest.importorskip("self_coding.self_tdd.cli", reason="self_tdd CLI not available")
- from typer.testing import CliRunner
- from self_coding.self_tdd.cli import app
- yield CliRunner(), app
-
-
# Add custom pytest option for headed mode
def pytest_addoption(parser):
parser.addoption(
@@ -164,16 +147,3 @@ def headed_mode(request):
return request.config.getoption("--headed")
-@pytest.fixture
-def isolated_task_db(tmp_path):
- """Provide a fresh, isolated SQLite DB for task queue tests.
-
- Points the task queue module at a temporary database that is unique
- per test. pytest's tmp_path is auto-cleaned — no teardown needed.
- """
- from swarm.task_queue import models as tq_models
-
- original = tq_models.DB_PATH
- tq_models.DB_PATH = tmp_path / "tasks.db"
- yield tq_models.DB_PATH
- tq_models.DB_PATH = original
diff --git a/tests/functional/test_cli.py b/tests/functional/test_cli.py
index c7930cc0..17f3152c 100644
--- a/tests/functional/test_cli.py
+++ b/tests/functional/test_cli.py
@@ -17,17 +17,11 @@ class TestTimmyCLI:
def test_status_runs(self, timmy_runner):
runner, app = timmy_runner
result = runner.invoke(app, ["status"])
- # Ollama is offline, so this should either:
- # - Print an error about Ollama being unreachable, OR
- # - Exit non-zero
- # Either way, the CLI itself shouldn't crash with an unhandled exception.
- # The exit code tells us if the command ran at all.
assert result.exit_code is not None
def test_chat_requires_message(self, timmy_runner):
runner, app = timmy_runner
result = runner.invoke(app, ["chat"])
- # Missing required argument
assert result.exit_code != 0
assert "Missing argument" in result.output or "Usage" in result.output
@@ -38,15 +32,8 @@ class TestTimmyCLI:
assert "Missing argument" in result.output or "Usage" in result.output
def test_chat_with_message_runs(self, timmy_runner):
- """Chat with a real message — Ollama offline means graceful failure."""
runner, app = timmy_runner
result = runner.invoke(app, ["chat", "hello"])
- # Will fail because Ollama isn't running, but the CLI should handle it
- assert result.exit_code is not None
-
- def test_backend_flag_accepted(self, timmy_runner):
- runner, app = timmy_runner
- result = runner.invoke(app, ["status", "--backend", "ollama"])
assert result.exit_code is not None
def test_help_text(self, timmy_runner):
@@ -60,15 +47,13 @@ class TestTimmyCLI:
class TestTimmyServeCLI:
- """Tests the `timmy-serve` command (start, invoice, status)."""
+ """Tests the `timmy-serve` command (start, status)."""
def test_start_dry_run(self, serve_runner):
- """--dry-run should print config and exit cleanly."""
runner, app = serve_runner
result = runner.invoke(app, ["start", "--dry-run"])
assert result.exit_code == 0
assert "Starting Timmy Serve" in result.output
- assert "Dry run" in result.output or "dry run" in result.output
def test_start_dry_run_custom_port(self, serve_runner):
runner, app = serve_runner
@@ -76,49 +61,14 @@ class TestTimmyServeCLI:
assert result.exit_code == 0
assert "9999" in result.output
- def test_start_dry_run_custom_price(self, serve_runner):
- runner, app = serve_runner
- result = runner.invoke(app, ["start", "--dry-run", "--price", "500"])
- assert result.exit_code == 0
- assert "500" in result.output
-
- def test_invoice_creates_real_invoice(self, serve_runner):
- """Create a real Lightning invoice via the mock backend."""
- runner, app = serve_runner
- result = runner.invoke(app, ["invoice", "--amount", "200", "--memo", "test invoice"])
- assert result.exit_code == 0
- assert "Invoice created" in result.output
- assert "200" in result.output
- assert "Payment hash" in result.output or "payment_hash" in result.output.lower()
-
- def test_status_shows_earnings(self, serve_runner):
+ def test_status(self, serve_runner):
runner, app = serve_runner
result = runner.invoke(app, ["status"])
assert result.exit_code == 0
- assert "Total invoices" in result.output or "invoices" in result.output.lower()
- assert "sats" in result.output.lower()
+ assert "Timmy Serve" in result.output
def test_help_text(self, serve_runner):
runner, app = serve_runner
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
- assert "Serve" in result.output or "Lightning" in result.output
-
-
-# ── self-tdd CLI ──────────────────────────────────────────────────────────────
-
-
-class TestSelfTddCLI:
- """Tests the `self-tdd` command (watch)."""
-
- def test_help_text(self, tdd_runner):
- runner, app = tdd_runner
- result = runner.invoke(app, ["--help"])
- assert result.exit_code == 0
- assert "watchdog" in result.output.lower() or "test" in result.output.lower()
-
- def test_watch_help(self, tdd_runner):
- runner, app = tdd_runner
- result = runner.invoke(app, ["watch", "--help"])
- assert result.exit_code == 0
- assert "interval" in result.output.lower()
+ assert "Serve" in result.output
diff --git a/tests/functional/test_dashboard.py b/tests/functional/test_dashboard.py
deleted file mode 100644
index c85948aa..00000000
--- a/tests/functional/test_dashboard.py
+++ /dev/null
@@ -1,199 +0,0 @@
-"""Functional tests for the dashboard — real HTTP requests, no mocking.
-
-The dashboard runs with Ollama offline (graceful degradation).
-These tests verify what a real user sees when they open the browser.
-"""
-
-import pytest
-
-
-class TestDashboardLoads:
- """Verify the dashboard serves real HTML pages."""
-
- def test_index_page(self, app_client):
- response = app_client.get("/")
- assert response.status_code == 200
- assert "text/html" in response.headers["content-type"]
- # The real rendered page should have the base HTML structure
- assert " int:
- """Count tasks that are NOT in a terminal state.
-
- Terminal states: completed, failed, vetoed, backlogged.
- """
- counts = get_counts_by_status()
- terminal = (
- counts.get("completed", 0)
- + counts.get("failed", 0)
- + counts.get("vetoed", 0)
- + counts.get("backlogged", 0)
- )
- total = sum(counts.values())
- return total - terminal
-
-
-def _make_processor() -> TaskProcessor:
- """Create a TaskProcessor with simple echo handlers."""
- proc = TaskProcessor("timmy")
- proc.register_handler("chat_response", lambda t: f"Done: {t.title}")
- proc.register_handler("thought", lambda t: f"Thought: {t.title}")
- proc.register_handler("internal", lambda t: f"Internal: {t.title}")
- return proc
-
-
-# ── Tests ──────────────────────────────────────────────────────────────────
-
-
-@pytest.mark.usefixtures("isolated_task_db")
-class TestInboxZero:
- """Timmy should drain his task queue to zero pending items."""
-
- async def test_single_task_completes(self):
- """A single approved task gets picked up and completed."""
- proc = _make_processor()
-
- task = create_task(
- title="Say hello",
- description="Greet the user",
- task_type="chat_response",
- assigned_to="timmy",
- created_by="user",
- auto_approve=True,
- )
-
- result = await proc.process_next_task()
- assert result is not None, "Processor should have picked up the task"
-
- finished = get_task(task.id)
- assert finished.status == TaskStatus.COMPLETED
- assert finished.result == "Done: Say hello"
- assert finished.completed_at is not None
-
- async def test_multiple_tasks_all_complete(self):
- """Multiple tasks are processed one-by-one until inbox is empty."""
- proc = _make_processor()
-
- titles = ["Fix login bug", "Write docs", "Refactor auth", "Deploy v2", "Update deps"]
- for title in titles:
- create_task(
- title=title,
- task_type="chat_response",
- assigned_to="timmy",
- auto_approve=True,
- )
-
- assert _pending_or_active_count() == len(titles)
-
- processed = 0
- for _ in range(len(titles) + 5):
- result = await proc.process_next_task()
- if result is None:
- break
- processed += 1
-
- assert processed == len(titles), f"Expected {len(titles)} processed, got {processed}"
- assert _pending_or_active_count() == 0, "Inbox is NOT zero"
-
- async def test_mixed_task_types_all_complete(self):
- """Different task types (chat, thought, internal) all get handled."""
- proc = _make_processor()
-
- t1 = create_task(title="Chat task", task_type="chat_response", assigned_to="timmy", auto_approve=True)
- t2 = create_task(title="Thought task", task_type="thought", assigned_to="timmy", auto_approve=True)
- t3 = create_task(title="Internal task", task_type="internal", assigned_to="timmy", auto_approve=True)
-
- for _ in range(10):
- result = await proc.process_next_task()
- if result is None:
- break
-
- assert _pending_or_active_count() == 0, "Not all task types were handled"
- assert get_task(t1.id).result == "Done: Chat task"
- assert get_task(t2.id).result == "Thought: Thought task"
- assert get_task(t3.id).result == "Internal: Internal task"
-
- async def test_priority_ordering(self):
- """Urgent tasks are processed before normal ones."""
- proc = _make_processor()
-
- create_task(title="Normal task", task_type="chat_response", assigned_to="timmy", priority="normal", auto_approve=True)
- create_task(title="Urgent task", task_type="chat_response", assigned_to="timmy", priority="urgent", auto_approve=True)
- create_task(title="Low task", task_type="chat_response", assigned_to="timmy", priority="low", auto_approve=True)
-
- order = []
- for _ in range(10):
- result = await proc.process_next_task()
- if result is None:
- break
- order.append(result.title)
-
- assert order == ["Urgent task", "Normal task", "Low task"]
- assert _pending_or_active_count() == 0
-
- async def test_failing_task_does_not_block_queue(self):
- """A task whose handler raises still gets marked FAILED and the queue moves on."""
- proc = TaskProcessor("timmy")
-
- def exploding_handler(task):
- raise RuntimeError("Kaboom!")
-
- proc.register_handler("chat_response", exploding_handler)
- proc.register_handler("thought", lambda t: "OK")
-
- t_fail = create_task(title="Will fail", task_type="chat_response", assigned_to="timmy", auto_approve=True)
- t_ok = create_task(title="Will succeed", task_type="thought", assigned_to="timmy", auto_approve=True)
-
- # Process first task — handler raises, processor catches it and marks FAILED
- result = await proc.process_next_task()
- assert result is None, "Failed task should return None"
- assert get_task(t_fail.id).status == TaskStatus.FAILED
- assert "Kaboom" in get_task(t_fail.id).result
-
- # Process second task — should succeed despite the prior failure
- result = await proc.process_next_task()
- assert result is not None
- assert result.title == "Will succeed"
- assert get_task(t_ok.id).status == TaskStatus.COMPLETED
- assert _pending_or_active_count() == 0
-
- async def test_unregistered_type_gets_backlogged(self):
- """A task with no registered handler gets backlogged so it doesn't block the queue."""
- proc = TaskProcessor("timmy")
- # No handlers registered at all
-
- task = create_task(title="Mystery task", task_type="unknown_type", assigned_to="timmy", auto_approve=True)
-
- result = await proc.process_next_task()
- assert result is None, "Task with no handler should return None (backlogged)"
-
- finished = get_task(task.id)
- assert finished.status == TaskStatus.BACKLOGGED
- assert "No handler" in finished.result
- assert _pending_or_active_count() == 0, "Backlogged task should not block the queue"
-
- async def test_escalation_is_skipped(self):
- """Escalation tasks stay in pending_approval and are skipped by the processor."""
- proc = _make_processor()
-
- task = create_task(
- title="Needs human review",
- task_type="escalation",
- assigned_to="timmy",
- )
- assert task.status == TaskStatus.PENDING_APPROVAL
-
- result = await proc.process_next_task()
- assert result is None, "Escalation should be skipped"
-
- finished = get_task(task.id)
- assert finished.status == TaskStatus.PENDING_APPROVAL, "Should remain pending"
-
- async def test_auto_approved_tasks_are_picked_up(self):
- """All non-escalation tasks auto-approve and get processed immediately."""
- proc = _make_processor()
-
- task = create_task(
- title="No gate needed",
- task_type="chat_response",
- assigned_to="timmy",
- )
- assert task.status == TaskStatus.APPROVED
-
- result = await proc.process_next_task()
- assert result is not None
-
- finished = get_task(task.id)
- assert finished.status == TaskStatus.COMPLETED
-
- async def test_run_loop_reaches_inbox_zero(self):
- """The processing loop drains all tasks within a bounded time."""
- proc = _make_processor()
-
- for i in range(7):
- create_task(
- title=f"Loop task {i}",
- task_type="chat_response",
- assigned_to="timmy",
- auto_approve=True,
- )
-
- assert _pending_or_active_count() == 7
-
- async def run_briefly():
- for _ in range(20):
- try:
- await proc.process_next_task()
- except Exception:
- pass
- if _pending_or_active_count() == 0:
- return
- await asyncio.sleep(0.01)
-
- await asyncio.wait_for(run_briefly(), timeout=5.0)
- assert _pending_or_active_count() == 0, "Loop did not reach inbox zero"
-
- async def test_timestamps_set_on_completion(self):
- """started_at and completed_at are properly set during processing."""
- proc = _make_processor()
-
- task = create_task(
- title="Timestamp check",
- task_type="chat_response",
- assigned_to="timmy",
- auto_approve=True,
- )
- assert task.started_at is None
- assert task.completed_at is None
-
- await proc.process_next_task()
-
- finished = get_task(task.id)
- assert finished.started_at is not None, "started_at should be set when task runs"
- assert finished.completed_at is not None, "completed_at should be set when task finishes"
- assert finished.started_at <= finished.completed_at
-
- async def test_steps_updated_on_completion(self):
- """Task steps are updated as the processor works through a task."""
- proc = _make_processor()
-
- task = create_task(
- title="Steps check",
- task_type="chat_response",
- assigned_to="timmy",
- auto_approve=True,
- )
-
- await proc.process_next_task()
-
- finished = get_task(task.id)
- assert finished.status == TaskStatus.COMPLETED
- assert len(finished.steps) > 0
- assert finished.steps[0]["status"] == "completed"
-
- async def test_inbox_zero_after_burst(self):
- """Simulate a burst of 20 tasks and verify inbox zero is achieved."""
- proc = _make_processor()
-
- task_ids = []
- for i in range(20):
- t = create_task(
- title=f"Burst task {i}",
- task_type="chat_response",
- assigned_to="timmy",
- auto_approve=True,
- )
- task_ids.append(t.id)
-
- assert _pending_or_active_count() == 20
-
- processed = 0
- for _ in range(30):
- result = await proc.process_next_task()
- if result is None:
- break
- processed += 1
-
- assert processed == 20
- assert _pending_or_active_count() == 0, f"Inbox NOT zero — {_pending_or_active_count()} remaining"
-
- # All 20 should be completed
- completed = [get_task(tid) for tid in task_ids]
- assert all(t.status == TaskStatus.COMPLETED for t in completed)
diff --git a/tests/functional/test_l402_flow.py b/tests/functional/test_l402_flow.py
deleted file mode 100644
index ca24c1b9..00000000
--- a/tests/functional/test_l402_flow.py
+++ /dev/null
@@ -1,106 +0,0 @@
-"""Functional test for the full L402 payment flow.
-
-Uses the real mock-lightning backend (LIGHTNING_BACKEND=mock) — no patching.
-This exercises the entire payment lifecycle a real client would go through:
-
-1. Hit protected endpoint → get 402 + invoice + macaroon
-2. "Pay" the invoice (settle via mock backend)
-3. Present macaroon:preimage → get access
-"""
-
-import pytest
-
-
-class TestL402PaymentFlow:
- """End-to-end L402 payment lifecycle."""
-
- def test_unprotected_endpoints_work(self, serve_client):
- """Status and health don't require payment."""
- resp = serve_client.get("/serve/status")
- assert resp.status_code == 200
- data = resp.json()
- assert data["status"] == "active"
- assert data["price_sats"] == 100
-
- health = serve_client.get("/health")
- assert health.status_code == 200
-
- def test_chat_without_payment_returns_402(self, serve_client):
- """Hitting /serve/chat without an L402 token gives 402."""
- resp = serve_client.post(
- "/serve/chat",
- json={"message": "hello"},
- )
- assert resp.status_code == 402
- data = resp.json()
- assert data["error"] == "Payment Required"
- assert data["code"] == "L402"
- assert "macaroon" in data
- assert "invoice" in data
- assert "payment_hash" in data
- assert data["amount_sats"] == 100
-
- # WWW-Authenticate header should be present
- assert "WWW-Authenticate" in resp.headers
- assert "L402" in resp.headers["WWW-Authenticate"]
-
- def test_chat_with_garbage_token_returns_402(self, serve_client):
- resp = serve_client.post(
- "/serve/chat",
- json={"message": "hello"},
- headers={"Authorization": "L402 garbage:token"},
- )
- assert resp.status_code == 402
-
- def test_full_payment_lifecycle(self, serve_client):
- """Complete flow: get challenge → pay → access."""
- from timmy_serve.payment_handler import payment_handler
-
- # Step 1: Hit protected endpoint, get 402 challenge
- challenge_resp = serve_client.post(
- "/serve/chat",
- json={"message": "hello"},
- )
- assert challenge_resp.status_code == 402
- challenge = challenge_resp.json()
- macaroon = challenge["macaroon"]
- payment_hash = challenge["payment_hash"]
-
- # Step 2: "Pay" the invoice via the mock backend's auto-settle
- # The mock backend settles invoices when you provide the correct preimage.
- # Get the preimage from the mock backend's internal state.
- invoice = payment_handler.get_invoice(payment_hash)
- assert invoice is not None
- preimage = invoice.preimage # mock backend exposes this
-
- # Step 3: Present macaroon:preimage to access the endpoint
- resp = serve_client.post(
- "/serve/chat",
- json={"message": "hello after paying"},
- headers={"Authorization": f"L402 {macaroon}:{preimage}"},
- )
- # The chat will fail because Ollama isn't running, but the
- # L402 middleware should let us through (status != 402).
- # We accept 200 (success) or 500 (Ollama offline) — NOT 402.
- assert resp.status_code != 402
-
- def test_create_invoice_via_api(self, serve_client):
- """POST /serve/invoice creates a real invoice."""
- resp = serve_client.post(
- "/serve/invoice",
- json={"amount_sats": 500, "memo": "premium access"},
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["amount_sats"] == 500
- assert data["payment_hash"]
- assert data["payment_request"]
-
- def test_status_reflects_invoices(self, serve_client):
- """Creating invoices should be reflected in /serve/status."""
- serve_client.post("/serve/invoice", json={"amount_sats": 100, "memo": "test"})
- serve_client.post("/serve/invoice", json={"amount_sats": 200, "memo": "test2"})
-
- resp = serve_client.get("/serve/status")
- data = resp.json()
- assert data["total_invoices"] >= 2
diff --git a/tests/hands/__init__.py b/tests/hands/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/infrastructure/test_error_capture.py b/tests/infrastructure/test_error_capture.py
deleted file mode 100644
index 98ee1c8e..00000000
--- a/tests/infrastructure/test_error_capture.py
+++ /dev/null
@@ -1,180 +0,0 @@
-"""Tests for the error capture and bug report feedback loop."""
-
-from unittest.mock import patch
-
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def _isolate_db(tmp_path, monkeypatch):
- """Point task_queue and event_log SQLite to a temp directory."""
- db = tmp_path / "swarm.db"
- monkeypatch.setattr("swarm.task_queue.models.DB_PATH", db)
- monkeypatch.setattr("swarm.event_log.DB_PATH", db)
-
-
-@pytest.fixture(autouse=True)
-def _clear_dedup():
- """Clear the dedup cache between tests."""
- from infrastructure.error_capture import _dedup_cache
-
- _dedup_cache.clear()
- yield
- _dedup_cache.clear()
-
-
-def _raise_value_error():
- """Helper — always raises from the same file:line so hash is stable."""
- raise ValueError("test error")
-
-
-def _raise_type_error():
- """Helper — always raises from the same file:line so hash is stable."""
- raise TypeError("type error")
-
-
-class TestStackHash:
- def test_same_exception_deterministic(self):
- """Hash is deterministic for the same exception object."""
- from infrastructure.error_capture import _stack_hash
-
- try:
- _raise_value_error()
- except ValueError as exc:
- hash1 = _stack_hash(exc)
- hash2 = _stack_hash(exc)
-
- assert hash1 == hash2
-
- def test_different_exception_types_different_hash(self):
- from infrastructure.error_capture import _stack_hash
-
- try:
- _raise_value_error()
- except ValueError as exc1:
- hash1 = _stack_hash(exc1)
-
- try:
- _raise_type_error()
- except TypeError as exc2:
- hash2 = _stack_hash(exc2)
-
- assert hash1 != hash2
-
- def test_hash_is_16_chars(self):
- from infrastructure.error_capture import _stack_hash
-
- try:
- raise RuntimeError("hash length test")
- except RuntimeError as exc:
- h = _stack_hash(exc)
-
- assert len(h) == 16
-
-
-class TestDeduplication:
- def test_first_error_not_duplicate(self):
- from infrastructure.error_capture import _is_duplicate
-
- assert _is_duplicate("test-hash-001") is False
-
- def test_same_hash_is_duplicate(self):
- from infrastructure.error_capture import _is_duplicate
-
- _is_duplicate("test-hash-002") # First time
- assert _is_duplicate("test-hash-002") is True
-
- def test_different_hashes_not_duplicate(self):
- from infrastructure.error_capture import _is_duplicate
-
- _is_duplicate("hash-aaa")
- assert _is_duplicate("hash-bbb") is False
-
-
-class TestCaptureError:
- def test_capture_creates_bug_report_task(self):
- from infrastructure.error_capture import capture_error
-
- try:
- raise RuntimeError("test capture error")
- except RuntimeError as exc:
- task_id = capture_error(exc, source="test_module")
-
- assert task_id is not None
-
- from swarm.task_queue.models import get_task
-
- task = get_task(task_id)
- assert task is not None
- assert task.task_type == "bug_report"
- assert "RuntimeError" in task.title
- assert task.created_by == "system"
-
- def test_capture_deduplicates(self):
- """Capturing the same exception twice suppresses the second report."""
- from infrastructure.error_capture import capture_error, _dedup_cache, _stack_hash
-
- try:
- _raise_value_error()
- except ValueError as exc:
- # Capture first time
- id1 = capture_error(exc, source="test")
- # Manually insert hash as if it was just seen (capture already did this)
- # Now capture again with the same exc object — should be deduped
- id2 = capture_error(exc, source="test")
-
- assert id1 is not None
- assert id2 is None # Deduplicated
-
- def test_capture_disabled(self, monkeypatch):
- monkeypatch.setattr("config.settings.error_feedback_enabled", False)
- from infrastructure.error_capture import capture_error
-
- try:
- raise RuntimeError("disabled test")
- except RuntimeError as exc:
- result = capture_error(exc, source="test")
-
- assert result is None
-
- def test_capture_includes_context(self):
- from infrastructure.error_capture import capture_error
-
- try:
- raise IOError("context test")
- except IOError as exc:
- task_id = capture_error(
- exc, source="http", context={"path": "/api/test"}
- )
-
- from swarm.task_queue.models import get_task
-
- task = get_task(task_id)
- assert "/api/test" in task.description
-
- def test_capture_includes_stack_trace(self):
- from infrastructure.error_capture import capture_error
-
- try:
- raise KeyError("stack trace test")
- except KeyError as exc:
- task_id = capture_error(exc, source="test")
-
- from swarm.task_queue.models import get_task
-
- task = get_task(task_id)
- assert "Stack Trace" in task.description
- assert "KeyError" in task.description
-
- def test_bug_report_is_auto_approved(self):
- from infrastructure.error_capture import capture_error
-
- try:
- raise RuntimeError("auto-approve test")
- except RuntimeError as exc:
- task_id = capture_error(exc, source="test")
-
- from swarm.task_queue.models import get_task
-
- task = get_task(task_id)
- assert task.status.value == "approved"
diff --git a/tests/integrations/test_voice_enhanced.py b/tests/integrations/test_voice_enhanced.py
index a4b53cd0..533e484a 100644
--- a/tests/integrations/test_voice_enhanced.py
+++ b/tests/integrations/test_voice_enhanced.py
@@ -37,7 +37,7 @@ class TestVoiceEnhancedProcess:
assert resp.status_code == 200
data = resp.json()
assert data["intent"] == "swarm"
- assert "agents" in data["response"].lower()
+ assert "not currently active" in data["response"].lower()
def test_voice_intent(self, client):
resp = client.post(
diff --git a/tests/lightning/__init__.py b/tests/lightning/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/lightning/test_l402_proxy.py b/tests/lightning/test_l402_proxy.py
deleted file mode 100644
index 0ffe2f78..00000000
--- a/tests/lightning/test_l402_proxy.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""Tests for the L402 proxy and payment handler."""
-
-import hashlib
-
-import pytest
-
-
-# ── Payment Handler ──────────────────────────────────────────────────────────
-
-def test_create_invoice():
- from timmy_serve.payment_handler import PaymentHandler
- handler = PaymentHandler()
- invoice = handler.create_invoice(100, "test payment")
- assert invoice.amount_sats == 100
- assert invoice.memo == "test payment"
- assert invoice.payment_hash is not None
- assert invoice.payment_request.startswith("lnbc")
-
-
-def test_check_payment_mock_auto_settles():
- from timmy_serve.payment_handler import PaymentHandler
- handler = PaymentHandler()
- invoice = handler.create_invoice(50, "auto settle")
- assert handler.check_payment(invoice.payment_hash) is True
-
-
-def test_check_payment_nonexistent():
- from timmy_serve.payment_handler import PaymentHandler
- handler = PaymentHandler()
- assert handler.check_payment("nonexistent-hash") is False
-
-
-def test_settle_invoice_with_preimage():
- from timmy_serve.payment_handler import PaymentHandler
- handler = PaymentHandler()
- invoice = handler.create_invoice(75, "preimage test")
- invoice.settled = False # Reset for manual settlement
- assert handler.settle_invoice(invoice.payment_hash, invoice.preimage) is True
- assert invoice.settled is True
-
-
-def test_settle_invoice_wrong_preimage():
- from timmy_serve.payment_handler import PaymentHandler
- handler = PaymentHandler()
- invoice = handler.create_invoice(75, "wrong preimage")
- invoice.settled = False
- assert handler.settle_invoice(invoice.payment_hash, "0" * 64) is False
-
-
-def test_list_invoices():
- from timmy_serve.payment_handler import PaymentHandler
- handler = PaymentHandler()
- handler.create_invoice(10, "a")
- handler.create_invoice(20, "b")
- assert len(handler.list_invoices()) == 2
-
-
-def test_list_invoices_settled_only():
- from timmy_serve.payment_handler import PaymentHandler
- handler = PaymentHandler()
- inv = handler.create_invoice(10, "settle me")
- handler.check_payment(inv.payment_hash) # auto-settles in mock
- settled = handler.list_invoices(settled_only=True)
- assert len(settled) >= 1
-
-
-def test_get_invoice():
- from timmy_serve.payment_handler import PaymentHandler
- handler = PaymentHandler()
- inv = handler.create_invoice(100, "get me")
- found = handler.get_invoice(inv.payment_hash)
- assert found is not None
- assert found.amount_sats == 100
-
-
-# ── L402 Proxy ───────────────────────────────────────────────────────────────
-
-def test_create_l402_challenge():
- from timmy_serve.l402_proxy import create_l402_challenge
- challenge = create_l402_challenge(100, "API access")
- assert "macaroon" in challenge
- assert "invoice" in challenge
- assert "payment_hash" in challenge
-
-
-def test_verify_l402_token_valid():
- from timmy_serve.l402_proxy import create_l402_challenge, verify_l402_token
- challenge = create_l402_challenge(50, "verify test")
- # In mock mode, payment auto-settles
- assert verify_l402_token(challenge["macaroon"]) is True
-
-
-def test_verify_l402_token_invalid_format():
- from timmy_serve.l402_proxy import verify_l402_token
- assert verify_l402_token("not-a-valid-token") is False
-
-
-def test_macaroon_roundtrip():
- from timmy_serve.l402_proxy import Macaroon
- mac = Macaroon(identifier="test-id", signature="test-sig")
- serialized = mac.serialize()
- restored = Macaroon.deserialize(serialized)
- assert restored is not None
- assert restored.identifier == "test-id"
- assert restored.signature == "test-sig"
-
-
-def test_macaroon_deserialize_invalid():
- from timmy_serve.l402_proxy import Macaroon
- assert Macaroon.deserialize("garbage") is None
diff --git a/tests/lightning/test_lightning_interface.py b/tests/lightning/test_lightning_interface.py
deleted file mode 100644
index 544d647b..00000000
--- a/tests/lightning/test_lightning_interface.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""Tests for the Lightning backend interface.
-
-Covers:
-- Mock backend functionality
-- Backend factory
-- Invoice lifecycle
-- Error handling
-"""
-
-import os
-import pytest
-
-from lightning import get_backend, Invoice
-from lightning.base import LightningError
-from lightning.mock_backend import MockBackend
-
-
-class TestMockBackend:
- """Tests for the mock Lightning backend."""
-
- def test_create_invoice(self):
- """Mock backend creates invoices with valid structure."""
- backend = MockBackend()
- invoice = backend.create_invoice(100, "Test invoice")
-
- assert invoice.amount_sats == 100
- assert invoice.memo == "Test invoice"
- assert invoice.payment_hash is not None
- assert len(invoice.payment_hash) == 64 # SHA256 hex
- assert invoice.payment_request.startswith("lnbc100n1mock")
- assert invoice.preimage is not None
-
- def test_invoice_auto_settle(self):
- """Mock invoices auto-settle by default."""
- backend = MockBackend()
- invoice = backend.create_invoice(100)
-
- assert invoice.settled is True
- assert invoice.settled_at is not None
- assert backend.check_payment(invoice.payment_hash) is True
-
- def test_invoice_no_auto_settle(self):
- """Mock invoices don't auto-settle when disabled."""
- os.environ["MOCK_AUTO_SETTLE"] = "false"
- backend = MockBackend()
-
- invoice = backend.create_invoice(100)
- assert invoice.settled is False
-
- # Manual settle works
- assert backend.settle_invoice(invoice.payment_hash, invoice.preimage)
- assert backend.check_payment(invoice.payment_hash) is True
-
- # Cleanup
- os.environ["MOCK_AUTO_SETTLE"] = "true"
-
- def test_settle_wrong_preimage(self):
- """Settling with wrong preimage fails."""
- backend = MockBackend()
- invoice = backend.create_invoice(100)
-
- wrong_preimage = "00" * 32
- assert backend.settle_invoice(invoice.payment_hash, wrong_preimage) is False
-
- def test_check_payment_nonexistent(self):
- """Checking unknown payment hash returns False."""
- backend = MockBackend()
- assert backend.check_payment("nonexistent") is False
-
- def test_get_invoice(self):
- """Can retrieve created invoice."""
- backend = MockBackend()
- created = backend.create_invoice(100, "Test")
-
- retrieved = backend.get_invoice(created.payment_hash)
- assert retrieved is not None
- assert retrieved.payment_hash == created.payment_hash
- assert retrieved.amount_sats == 100
-
- def test_get_invoice_nonexistent(self):
- """Retrieving unknown invoice returns None."""
- backend = MockBackend()
- assert backend.get_invoice("nonexistent") is None
-
- def test_list_invoices(self):
- """Can list all invoices."""
- backend = MockBackend()
-
- inv1 = backend.create_invoice(100, "First")
- inv2 = backend.create_invoice(200, "Second")
-
- invoices = backend.list_invoices()
- hashes = {i.payment_hash for i in invoices}
-
- assert inv1.payment_hash in hashes
- assert inv2.payment_hash in hashes
-
- def test_list_invoices_settled_only(self):
- """Can filter to settled invoices only."""
- os.environ["MOCK_AUTO_SETTLE"] = "false"
- backend = MockBackend()
-
- unsettled = backend.create_invoice(100, "Unsettled")
-
- # Settle it manually
- backend.settle_invoice(unsettled.payment_hash, unsettled.preimage)
-
- settled = backend.list_invoices(settled_only=True)
- assert len(settled) == 1
- assert settled[0].payment_hash == unsettled.payment_hash
-
- os.environ["MOCK_AUTO_SETTLE"] = "true"
-
- def test_list_invoices_limit(self):
- """List respects limit parameter."""
- backend = MockBackend()
-
- for i in range(5):
- backend.create_invoice(i + 1)
-
- invoices = backend.list_invoices(limit=3)
- assert len(invoices) == 3
-
- def test_get_balance(self):
- """Mock returns reasonable fake balance."""
- backend = MockBackend()
- balance = backend.get_balance_sats()
- assert balance == 1_000_000 # 1M sats
-
- def test_health_check(self):
- """Mock health check always passes."""
- backend = MockBackend()
- health = backend.health_check()
-
- assert health["ok"] is True
- assert health["error"] is None
- assert health["synced"] is True
- assert health["backend"] == "mock"
-
- def test_invoice_expiry(self):
- """Invoice expiry detection works."""
- backend = MockBackend()
- invoice = backend.create_invoice(100, expiry_seconds=3600)
-
- # Just created, not expired with 1 hour window
- assert invoice.is_expired is False
-
- # Expire manually by changing created_at
- import time
- invoice.created_at = time.time() - 7200 # 2 hours ago
- assert invoice.is_expired is True # Beyond 1 hour default
-
-
-class TestBackendFactory:
- """Tests for backend factory."""
-
- def test_get_backend_mock(self):
- """Factory returns mock backend by default."""
- backend = get_backend("mock")
- assert backend.name == "mock"
- assert isinstance(backend, MockBackend)
-
- def test_get_backend_default(self):
- """Factory uses LIGHTNING_BACKEND env var."""
- old_backend = os.environ.get("LIGHTNING_BACKEND")
- os.environ["LIGHTNING_BACKEND"] = "mock"
-
- backend = get_backend()
- assert backend.name == "mock"
-
- if old_backend:
- os.environ["LIGHTNING_BACKEND"] = old_backend
-
- def test_get_backend_unknown(self):
- """Unknown backend raises error."""
- with pytest.raises(ValueError) as exc:
- get_backend("unknown")
- assert "Unknown Lightning backend" in str(exc.value)
-
- def test_list_backends(self):
- """Can list available backends."""
- from lightning.factory import list_backends
- backends = list_backends()
-
- assert "mock" in backends
- # lnd only if grpc available
-
-
-class TestInvoiceModel:
- """Tests for Invoice dataclass."""
-
- def test_invoice_creation(self):
- """Invoice can be created with required fields."""
- import time
- now = time.time()
-
- invoice = Invoice(
- payment_hash="abcd" * 16,
- payment_request="lnbc100n1mock",
- amount_sats=100,
- memo="Test",
- created_at=now,
- )
-
- assert invoice.payment_hash == "abcd" * 16
- assert invoice.amount_sats == 100
- assert invoice.settled is False
-
- def test_invoice_is_expired(self):
- """Invoice expiry calculation is correct."""
- import time
-
- invoice = Invoice(
- payment_hash="abcd" * 16,
- payment_request="lnbc100n1mock",
- amount_sats=100,
- created_at=time.time() - 7200, # 2 hours ago
- )
-
- # is_expired is a property with default 1 hour expiry
- assert invoice.is_expired is True # 2 hours > 1 hour default
diff --git a/tests/lightning/test_lnd_backend.py b/tests/lightning/test_lnd_backend.py
deleted file mode 100644
index a65b81f5..00000000
--- a/tests/lightning/test_lnd_backend.py
+++ /dev/null
@@ -1,129 +0,0 @@
-"""Functional tests for lightning.lnd_backend — LND gRPC backend.
-
-gRPC is stubbed via sys.modules; tests verify initialization, error
-handling, and the placeholder method behavior.
-"""
-
-import importlib
-import os
-import sys
-from unittest.mock import patch, MagicMock
-
-import pytest
-
-from lightning.base import (
- BackendNotAvailableError,
- Invoice,
- LightningError,
-)
-
-
-def _make_grpc_mock():
- """Create a mock grpc module with required attributes."""
- mock_grpc = MagicMock()
- mock_grpc.StatusCode.NOT_FOUND = "NOT_FOUND"
- mock_grpc.RpcError = type("RpcError", (Exception,), {
- "code": lambda self: "NOT_FOUND",
- "details": lambda self: "mocked error",
- })
- return mock_grpc
-
-
-@pytest.fixture
-def lnd_module():
- """Reload lnd_backend with grpc stubbed so GRPC_AVAILABLE=True."""
- grpc_mock = _make_grpc_mock()
- old = sys.modules.get("grpc")
- sys.modules["grpc"] = grpc_mock
- try:
- import lightning.lnd_backend as mod
- importlib.reload(mod)
- yield mod
- finally:
- if old is not None:
- sys.modules["grpc"] = old
- else:
- sys.modules.pop("grpc", None)
- # Reload to restore original state
- import lightning.lnd_backend as mod2
- importlib.reload(mod2)
-
-
-class TestLndBackendInit:
- def test_init_with_explicit_params(self, lnd_module):
- backend = lnd_module.LndBackend(
- host="localhost:10009",
- tls_cert_path="/fake/tls.cert",
- macaroon_path="/fake/admin.macaroon",
- verify_ssl=True,
- )
- assert backend._host == "localhost:10009"
- assert backend._tls_cert_path == "/fake/tls.cert"
- assert backend._macaroon_path == "/fake/admin.macaroon"
- assert backend._verify_ssl is True
-
- def test_init_from_env_vars(self, lnd_module):
- env = {
- "LND_GRPC_HOST": "remote:9999",
- "LND_TLS_CERT_PATH": "/env/tls.cert",
- "LND_MACAROON_PATH": "/env/macaroon",
- "LND_VERIFY_SSL": "false",
- }
- with patch.dict(os.environ, env):
- backend = lnd_module.LndBackend()
- assert backend._host == "remote:9999"
- assert backend._verify_ssl is False
-
- def test_init_raises_without_grpc(self):
- from lightning.lnd_backend import LndBackend
- with pytest.raises(LightningError, match="grpcio not installed"):
- LndBackend()
-
- def test_name_is_lnd(self, lnd_module):
- assert lnd_module.LndBackend.name == "lnd"
-
- def test_grpc_available_true_after_reload(self, lnd_module):
- assert lnd_module.GRPC_AVAILABLE is True
-
-
-class TestLndBackendMethods:
- @pytest.fixture
- def backend(self, lnd_module):
- return lnd_module.LndBackend(
- host="localhost:10009",
- macaroon_path="/fake/path",
- )
-
- def test_check_stub_raises_not_available(self, backend):
- """_check_stub should raise BackendNotAvailableError when stub is None."""
- with pytest.raises(BackendNotAvailableError, match="not fully implemented"):
- backend._check_stub()
-
- def test_create_invoice_raises_not_available(self, backend):
- with pytest.raises(BackendNotAvailableError):
- backend.create_invoice(1000, memo="test")
-
- def test_check_payment_raises_not_available(self, backend):
- with pytest.raises(BackendNotAvailableError):
- backend.check_payment("abc123")
-
- def test_get_invoice_raises_not_available(self, backend):
- with pytest.raises(BackendNotAvailableError):
- backend.get_invoice("abc123")
-
- def test_settle_invoice_returns_false(self, backend):
- """LND auto-settles, so manual settle always returns False."""
- result = backend.settle_invoice("hash", "preimage")
- assert result is False
-
- def test_list_invoices_raises_not_available(self, backend):
- with pytest.raises(BackendNotAvailableError):
- backend.list_invoices()
-
- def test_get_balance_raises_not_available(self, backend):
- with pytest.raises(BackendNotAvailableError):
- backend.get_balance_sats()
-
- def test_health_check_raises_not_available(self, backend):
- with pytest.raises(BackendNotAvailableError):
- backend.health_check()
diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/scripture/__init__.py b/tests/scripture/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/scripture/test_scripture.py b/tests/scripture/test_scripture.py
deleted file mode 100644
index c3fb1f6c..00000000
--- a/tests/scripture/test_scripture.py
+++ /dev/null
@@ -1,901 +0,0 @@
-"""Tests for the scripture module — sovereign biblical text integration.
-
-Covers: constants, models, parser, store, memory, meditation, and routes.
-All tests use in-memory or temp-file SQLite — no external services needed.
-"""
-
-import json
-import sqlite3
-import tempfile
-from pathlib import Path
-from unittest.mock import patch
-
-import pytest
-from fastapi.testclient import TestClient
-
-
-# ════════════════════════════════════════════════════════════════════════════
-# Constants
-# ════════════════════════════════════════════════════════════════════════════
-
-
-class TestConstants:
- def test_total_books(self):
- from scripture.constants import BOOKS, TOTAL_BOOKS
- assert len(BOOKS) == TOTAL_BOOKS == 66
-
- def test_ot_nt_split(self):
- from scripture.constants import BOOKS, OT_BOOKS, NT_BOOKS
- ot = [b for b in BOOKS if b.testament == "OT"]
- nt = [b for b in BOOKS if b.testament == "NT"]
- assert len(ot) == OT_BOOKS == 39
- assert len(nt) == NT_BOOKS == 27
-
- def test_book_ids_sequential(self):
- from scripture.constants import BOOKS
- for i, book in enumerate(BOOKS, start=1):
- assert book.id == i, f"Book {book.name} has id {book.id}, expected {i}"
-
- def test_book_by_name_full(self):
- from scripture.constants import book_by_name
- info = book_by_name("Genesis")
- assert info is not None
- assert info.id == 1
- assert info.testament == "OT"
-
- def test_book_by_name_abbreviation(self):
- from scripture.constants import book_by_name
- info = book_by_name("Rev")
- assert info is not None
- assert info.id == 66
- assert info.name == "Revelation"
-
- def test_book_by_name_case_insensitive(self):
- from scripture.constants import book_by_name
- assert book_by_name("JOHN") is not None
- assert book_by_name("john") is not None
- assert book_by_name("John") is not None
-
- def test_book_by_name_alias(self):
- from scripture.constants import book_by_name
- assert book_by_name("1 Cor").id == 46
- assert book_by_name("Phil").id == 50
- assert book_by_name("Ps").id == 19
-
- def test_book_by_name_unknown(self):
- from scripture.constants import book_by_name
- assert book_by_name("Nonexistent") is None
-
- def test_book_by_id(self):
- from scripture.constants import book_by_id
- info = book_by_id(43)
- assert info is not None
- assert info.name == "John"
-
- def test_book_by_id_invalid(self):
- from scripture.constants import book_by_id
- assert book_by_id(0) is None
- assert book_by_id(67) is None
-
- def test_genres_present(self):
- from scripture.constants import GENRES
- assert "gospel" in GENRES
- assert "epistle" in GENRES
- assert "wisdom" in GENRES
- assert "prophecy" in GENRES
-
- def test_first_and_last_books(self):
- from scripture.constants import BOOKS
- assert BOOKS[0].name == "Genesis"
- assert BOOKS[-1].name == "Revelation"
-
-
-# ════════════════════════════════════════════════════════════════════════════
-# Models
-# ════════════════════════════════════════════════════════════════════════════
-
-
-class TestModels:
- def test_encode_verse_id(self):
- from scripture.models import encode_verse_id
- assert encode_verse_id(43, 3, 16) == 43003016
- assert encode_verse_id(1, 1, 1) == 1001001
- assert encode_verse_id(66, 22, 21) == 66022021
-
- def test_decode_verse_id(self):
- from scripture.models import decode_verse_id
- assert decode_verse_id(43003016) == (43, 3, 16)
- assert decode_verse_id(1001001) == (1, 1, 1)
- assert decode_verse_id(66022021) == (66, 22, 21)
-
- def test_encode_decode_roundtrip(self):
- from scripture.models import decode_verse_id, encode_verse_id
- for book in (1, 19, 43, 66):
- for chapter in (1, 10, 50):
- for verse in (1, 5, 31):
- vid = encode_verse_id(book, chapter, verse)
- assert decode_verse_id(vid) == (book, chapter, verse)
-
- def test_verse_ref_id(self):
- from scripture.models import VerseRef
- ref = VerseRef(book=43, chapter=3, verse=16)
- assert ref.verse_id == 43003016
-
- def test_verse_range_ids(self):
- from scripture.models import VerseRange, VerseRef
- vr = VerseRange(
- start=VerseRef(book=1, chapter=1, verse=1),
- end=VerseRef(book=1, chapter=1, verse=3),
- )
- ids = vr.verse_ids()
- assert 1001001 in ids
- assert 1001002 in ids
- assert 1001003 in ids
-
- def test_verse_model(self):
- from scripture.models import Verse
- v = Verse(
- verse_id=43003016,
- book=43,
- chapter=3,
- verse_num=16,
- text="For God so loved the world...",
- translation="ESV",
- testament="NT",
- genre="gospel",
- )
- assert v.text.startswith("For God")
- assert v.testament == "NT"
-
- def test_meditation_state_advance(self):
- from scripture.models import MeditationState
- state = MeditationState()
- assert state.verses_meditated == 0
- state.advance(1, 1, 2)
- assert state.current_verse == 2
- assert state.verses_meditated == 1
- assert state.last_meditation is not None
-
- def test_scripture_query_defaults(self):
- from scripture.models import ScriptureQuery
- q = ScriptureQuery(raw_text="test")
- assert q.intent == "lookup"
- assert q.references == []
- assert q.keywords == []
-
- def test_cross_reference_model(self):
- from scripture.models import CrossReference
- xref = CrossReference(
- source_verse_id=43003016,
- target_verse_id=45005008,
- reference_type="thematic",
- confidence=0.9,
- )
- assert xref.reference_type == "thematic"
- assert xref.confidence == 0.9
-
- def test_strongs_entry(self):
- from scripture.models import StrongsEntry
- entry = StrongsEntry(
- strongs_number="H7225",
- language="hebrew",
- lemma="רֵאשִׁית",
- transliteration="reshith",
- gloss="beginning",
- )
- assert entry.language == "hebrew"
-
-
-# ════════════════════════════════════════════════════════════════════════════
-# Parser
-# ════════════════════════════════════════════════════════════════════════════
-
-
-class TestParser:
- def test_parse_single_verse(self):
- from scripture.parser import parse_reference
- result = parse_reference("John 3:16")
- assert result is not None
- assert result.start.book == 43
- assert result.start.chapter == 3
- assert result.start.verse == 16
- assert result.end.verse == 16
-
- def test_parse_range(self):
- from scripture.parser import parse_reference
- result = parse_reference("Romans 5:1-11")
- assert result is not None
- assert result.start.verse == 1
- assert result.end.verse == 11
-
- def test_parse_whole_chapter(self):
- from scripture.parser import parse_reference
- result = parse_reference("Genesis 1")
- assert result is not None
- assert result.start.verse == 1
- assert result.end.verse == 999 # sentinel for whole chapter
-
- def test_parse_multi_chapter_range(self):
- from scripture.parser import parse_reference
- result = parse_reference("Genesis 1:1-2:3")
- assert result is not None
- assert result.start.chapter == 1
- assert result.start.verse == 1
- assert result.end.chapter == 2
- assert result.end.verse == 3
-
- def test_parse_abbreviation(self):
- from scripture.parser import parse_reference
- result = parse_reference("Rom 8:28")
- assert result is not None
- assert result.start.book == 45
-
- def test_parse_numbered_book(self):
- from scripture.parser import parse_reference
- result = parse_reference("1 Cor 13:4")
- assert result is not None
- assert result.start.book == 46
-
- def test_parse_invalid(self):
- from scripture.parser import parse_reference
- assert parse_reference("not a reference") is None
-
- def test_parse_unknown_book(self):
- from scripture.parser import parse_reference
- assert parse_reference("Hezekiah 1:1") is None
-
- def test_extract_multiple_references(self):
- from scripture.parser import extract_references
- text = "See John 3:16 and Romans 5:8 for the gospel message."
- refs = extract_references(text)
- assert len(refs) == 2
- assert refs[0].start.book == 43
- assert refs[1].start.book == 45
-
- def test_extract_no_references(self):
- from scripture.parser import extract_references
- assert extract_references("No references here.") == []
-
- def test_format_reference(self):
- from scripture.models import VerseRef
- from scripture.parser import format_reference
- ref = VerseRef(book=43, chapter=3, verse=16)
- assert format_reference(ref) == "John 3:16"
-
- def test_format_reference_chapter_only(self):
- from scripture.models import VerseRef
- from scripture.parser import format_reference
- ref = VerseRef(book=1, chapter=1, verse=0)
- assert format_reference(ref) == "Genesis 1"
-
- def test_format_range_single(self):
- from scripture.models import VerseRange, VerseRef
- from scripture.parser import format_range
- vr = VerseRange(
- start=VerseRef(book=43, chapter=3, verse=16),
- end=VerseRef(book=43, chapter=3, verse=16),
- )
- assert format_range(vr) == "John 3:16"
-
- def test_format_range_same_chapter(self):
- from scripture.models import VerseRange, VerseRef
- from scripture.parser import format_range
- vr = VerseRange(
- start=VerseRef(book=45, chapter=5, verse=1),
- end=VerseRef(book=45, chapter=5, verse=11),
- )
- assert format_range(vr) == "Romans 5:1-11"
-
- def test_format_range_multi_chapter(self):
- from scripture.models import VerseRange, VerseRef
- from scripture.parser import format_range
- vr = VerseRange(
- start=VerseRef(book=1, chapter=1, verse=1),
- end=VerseRef(book=1, chapter=2, verse=3),
- )
- assert format_range(vr) == "Genesis 1:1-2:3"
-
-
-# ════════════════════════════════════════════════════════════════════════════
-# Store
-# ════════════════════════════════════════════════════════════════════════════
-
-
-@pytest.fixture
-def temp_store():
- """Create a ScriptureStore backed by a temp file."""
- from scripture.store import ScriptureStore
- with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
- db_path = f.name
- store = ScriptureStore(db_path=db_path)
- yield store
- store.close()
- Path(db_path).unlink(missing_ok=True)
-
-
-def _sample_verse(book=43, chapter=3, verse_num=16, text="For God so loved the world"):
- from scripture.models import Verse, encode_verse_id
- return Verse(
- verse_id=encode_verse_id(book, chapter, verse_num),
- book=book,
- chapter=chapter,
- verse_num=verse_num,
- text=text,
- translation="ESV",
- testament="NT",
- genre="gospel",
- )
-
-
-class TestStore:
- def test_insert_and_get(self, temp_store):
- verse = _sample_verse()
- temp_store.insert_verse(verse)
- result = temp_store.get_verse(43, 3, 16)
- assert result is not None
- assert result.text == "For God so loved the world"
-
- def test_get_nonexistent(self, temp_store):
- assert temp_store.get_verse(99, 1, 1) is None
-
- def test_get_verse_by_id(self, temp_store):
- verse = _sample_verse()
- temp_store.insert_verse(verse)
- result = temp_store.get_verse_by_id(43003016)
- assert result is not None
- assert result.book == 43
-
- def test_bulk_insert(self, temp_store):
- verses = [
- _sample_verse(1, 1, 1, "In the beginning God created the heavens and the earth."),
- _sample_verse(1, 1, 2, "The earth was without form and void."),
- _sample_verse(1, 1, 3, "And God said, Let there be light."),
- ]
- temp_store.insert_verses(verses)
- assert temp_store.count_verses() == 3
-
- def test_get_chapter(self, temp_store):
- verses = [
- _sample_verse(1, 1, i, f"Verse {i}")
- for i in range(1, 6)
- ]
- temp_store.insert_verses(verses)
- chapter = temp_store.get_chapter(1, 1)
- assert len(chapter) == 5
- assert chapter[0].verse_num == 1
-
- def test_get_range(self, temp_store):
- from scripture.models import encode_verse_id
- verses = [
- _sample_verse(1, 1, i, f"Verse {i}")
- for i in range(1, 11)
- ]
- temp_store.insert_verses(verses)
- result = temp_store.get_range(
- encode_verse_id(1, 1, 3),
- encode_verse_id(1, 1, 7),
- )
- assert len(result) == 5
-
- def test_search_text(self, temp_store):
- verses = [
- _sample_verse(43, 3, 16, "For God so loved the world"),
- _sample_verse(43, 3, 17, "For God did not send his Son"),
- _sample_verse(45, 5, 8, "God shows his love for us"),
- ]
- temp_store.insert_verses(verses)
- results = temp_store.search_text("God")
- assert len(results) == 3
- results = temp_store.search_text("loved")
- assert len(results) == 1
-
- def test_count_verses(self, temp_store):
- assert temp_store.count_verses() == 0
- temp_store.insert_verse(_sample_verse())
- assert temp_store.count_verses() == 1
-
- def test_get_books(self, temp_store):
- verses = [
- _sample_verse(1, 1, 1, "Genesis verse"),
- _sample_verse(43, 1, 1, "John verse"),
- ]
- temp_store.insert_verses(verses)
- books = temp_store.get_books()
- assert len(books) == 2
- assert books[0]["name"] == "Genesis"
- assert books[1]["name"] == "John"
-
- def test_cross_references(self, temp_store):
- from scripture.models import CrossReference
- xref = CrossReference(
- source_verse_id=43003016,
- target_verse_id=45005008,
- reference_type="thematic",
- confidence=0.9,
- )
- temp_store.insert_cross_reference(xref)
- results = temp_store.get_cross_references(43003016)
- assert len(results) == 1
- assert results[0].target_verse_id == 45005008
-
- def test_cross_references_bidirectional(self, temp_store):
- from scripture.models import CrossReference
- xref = CrossReference(
- source_verse_id=43003016,
- target_verse_id=45005008,
- )
- temp_store.insert_cross_reference(xref)
- # Query from target side
- results = temp_store.get_cross_references(45005008)
- assert len(results) == 1
-
- def test_topics(self, temp_store):
- from scripture.models import Topic
- topic = Topic(
- topic_id="love",
- name="Love",
- description="Biblical concept of love",
- verse_ids=[43003016, 45005008],
- )
- temp_store.insert_topic(topic)
- result = temp_store.get_topic("love")
- assert result is not None
- assert result.name == "Love"
- assert len(result.verse_ids) == 2
-
- def test_search_topics(self, temp_store):
- from scripture.models import Topic
- temp_store.insert_topic(Topic(topic_id="love", name="Love"))
- temp_store.insert_topic(Topic(topic_id="faith", name="Faith"))
- results = temp_store.search_topics("lov")
- assert len(results) == 1
- assert results[0].name == "Love"
-
- def test_get_verses_for_topic(self, temp_store):
- from scripture.models import Topic
- verse = _sample_verse()
- temp_store.insert_verse(verse)
- topic = Topic(
- topic_id="love",
- name="Love",
- verse_ids=[verse.verse_id],
- )
- temp_store.insert_topic(topic)
- verses = temp_store.get_verses_for_topic("love")
- assert len(verses) == 1
- assert verses[0].verse_id == verse.verse_id
-
- def test_strongs(self, temp_store):
- from scripture.models import StrongsEntry
- entry = StrongsEntry(
- strongs_number="G26",
- language="greek",
- lemma="ἀγάπη",
- transliteration="agape",
- gloss="love",
- )
- temp_store.insert_strongs(entry)
- result = temp_store.get_strongs("G26")
- assert result is not None
- assert result.gloss == "love"
- assert temp_store.get_strongs("G9999") is None
-
- def test_stats(self, temp_store):
- stats = temp_store.stats()
- assert stats["verses"] == 0
- assert stats["cross_references"] == 0
- assert stats["topics"] == 0
- assert stats["strongs_entries"] == 0
-
-
-# ════════════════════════════════════════════════════════════════════════════
-# Memory
-# ════════════════════════════════════════════════════════════════════════════
-
-
-@pytest.fixture
-def temp_memory():
- """Create a ScriptureMemory backed by a temp file."""
- from scripture.memory import ScriptureMemory
- with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
- db_path = f.name
- mem = ScriptureMemory(db_path=db_path)
- yield mem
- mem.close()
- Path(db_path).unlink(missing_ok=True)
-
-
-class TestWorkingMemory:
- def test_focus_and_retrieve(self):
- from scripture.memory import WorkingMemory
- wm = WorkingMemory(capacity=3)
- v1 = _sample_verse(1, 1, 1, "Verse 1")
- v2 = _sample_verse(1, 1, 2, "Verse 2")
- wm.focus(v1)
- wm.focus(v2)
- assert len(wm) == 2
- focused = wm.get_focused()
- assert focused[0].verse_id == v1.verse_id
- assert focused[1].verse_id == v2.verse_id
-
- def test_capacity_eviction(self):
- from scripture.memory import WorkingMemory
- wm = WorkingMemory(capacity=2)
- v1 = _sample_verse(1, 1, 1, "Verse 1")
- v2 = _sample_verse(1, 1, 2, "Verse 2")
- v3 = _sample_verse(1, 1, 3, "Verse 3")
- wm.focus(v1)
- wm.focus(v2)
- wm.focus(v3)
- assert len(wm) == 2
- assert not wm.is_focused(v1.verse_id)
- assert wm.is_focused(v2.verse_id)
- assert wm.is_focused(v3.verse_id)
-
- def test_refocus_moves_to_end(self):
- from scripture.memory import WorkingMemory
- wm = WorkingMemory(capacity=3)
- v1 = _sample_verse(1, 1, 1, "Verse 1")
- v2 = _sample_verse(1, 1, 2, "Verse 2")
- wm.focus(v1)
- wm.focus(v2)
- wm.focus(v1) # Re-focus v1
- focused = wm.get_focused()
- assert focused[-1].verse_id == v1.verse_id
-
- def test_clear(self):
- from scripture.memory import WorkingMemory
- wm = WorkingMemory()
- wm.focus(_sample_verse())
- wm.clear()
- assert len(wm) == 0
-
-
-class TestAssociativeMemory:
- def test_meditation_state_persistence(self, temp_memory):
- from scripture.models import MeditationState
- state = temp_memory.associative.get_meditation_state()
- assert state.current_book == 1
- assert state.mode == "sequential"
-
- state.advance(43, 3, 16)
- state.mode = "thematic"
- temp_memory.associative.save_meditation_state(state)
-
- loaded = temp_memory.associative.get_meditation_state()
- assert loaded.current_book == 43
- assert loaded.current_chapter == 3
- assert loaded.current_verse == 16
- assert loaded.mode == "thematic"
- assert loaded.verses_meditated == 1
-
- def test_meditation_log(self, temp_memory):
- temp_memory.associative.log_meditation(43003016, notes="Great verse")
- temp_memory.associative.log_meditation(45005008, notes="Also good")
- history = temp_memory.associative.get_meditation_history(limit=10)
- assert len(history) == 2
- assert history[0]["verse_id"] == 45005008 # most recent first
-
- def test_meditation_count(self, temp_memory):
- assert temp_memory.associative.meditation_count() == 0
- temp_memory.associative.log_meditation(1001001)
- assert temp_memory.associative.meditation_count() == 1
-
- def test_insights(self, temp_memory):
- temp_memory.associative.add_insight(
- 43003016, "God's love is unconditional", category="theology"
- )
- insights = temp_memory.associative.get_insights(43003016)
- assert len(insights) == 1
- assert insights[0]["category"] == "theology"
-
- def test_recent_insights(self, temp_memory):
- temp_memory.associative.add_insight(1001001, "Creation narrative")
- temp_memory.associative.add_insight(43003016, "Gospel core")
- recent = temp_memory.associative.get_recent_insights(limit=5)
- assert len(recent) == 2
-
- def test_duplicate_insight_ignored(self, temp_memory):
- temp_memory.associative.add_insight(43003016, "Same insight")
- temp_memory.associative.add_insight(43003016, "Same insight")
- insights = temp_memory.associative.get_insights(43003016)
- assert len(insights) == 1
-
-
-class TestScriptureMemory:
- def test_status(self, temp_memory):
- status = temp_memory.status()
- assert "working_memory_items" in status
- assert "meditation_mode" in status
- assert status["working_memory_items"] == 0
-
-
-# ════════════════════════════════════════════════════════════════════════════
-# Meditation Scheduler
-# ════════════════════════════════════════════════════════════════════════════
-
-
-@pytest.fixture
-def temp_scheduler():
- """Create a MeditationScheduler backed by temp stores."""
- from scripture.meditation import MeditationScheduler
- from scripture.memory import ScriptureMemory
- from scripture.store import ScriptureStore
- with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
- store_path = f.name
- with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
- mem_path = f.name
- store = ScriptureStore(db_path=store_path)
- memory = ScriptureMemory(db_path=mem_path)
- scheduler = MeditationScheduler(store=store, memory=memory)
- yield scheduler, store, memory
- store.close()
- memory.close()
- Path(store_path).unlink(missing_ok=True)
- Path(mem_path).unlink(missing_ok=True)
-
-
-class TestMeditationScheduler:
- def test_initial_state(self, temp_scheduler):
- scheduler, _, _ = temp_scheduler
- state = scheduler.state
- assert state.mode == "sequential"
- assert state.current_book == 1
-
- def test_set_mode(self, temp_scheduler):
- scheduler, _, _ = temp_scheduler
- state = scheduler.set_mode("thematic", theme="love")
- assert state.mode == "thematic"
- assert state.theme == "love"
-
- def test_set_invalid_mode(self, temp_scheduler):
- scheduler, _, _ = temp_scheduler
- with pytest.raises(ValueError, match="Unknown mode"):
- scheduler.set_mode("invalid")
-
- def test_next_sequential(self, temp_scheduler):
- scheduler, store, _ = temp_scheduler
- verses = [
- _sample_verse(1, 1, i, f"Genesis 1:{i}")
- for i in range(1, 6)
- ]
- store.insert_verses(verses)
- # State starts at 1:1:1, next should be 1:1:2
- result = scheduler.next_meditation()
- assert result is not None
- assert result.verse_num == 2
-
- def test_sequential_chapter_advance(self, temp_scheduler):
- scheduler, store, _ = temp_scheduler
- # Only two verses in chapter 1, plus verse 1 of chapter 2
- store.insert_verses([
- _sample_verse(1, 1, 1, "Gen 1:1"),
- _sample_verse(1, 1, 2, "Gen 1:2"),
- _sample_verse(1, 2, 1, "Gen 2:1"),
- ])
- # Start at 1:1:1 → next is 1:1:2
- v = scheduler.next_meditation()
- assert v.verse_num == 2
- # Next should advance to 1:2:1
- v = scheduler.next_meditation()
- assert v is not None
- assert v.chapter == 2
- assert v.verse_num == 1
-
- def test_current_focus_empty(self, temp_scheduler):
- scheduler, _, _ = temp_scheduler
- assert scheduler.current_focus() is None
-
- def test_meditate_on(self, temp_scheduler):
- scheduler, store, memory = temp_scheduler
- verse = _sample_verse()
- store.insert_verse(verse)
- scheduler.meditate_on(verse, notes="Reflecting on love")
- assert memory.working.is_focused(verse.verse_id)
- state = scheduler.state
- assert state.verses_meditated == 1
-
- def test_status(self, temp_scheduler):
- scheduler, _, _ = temp_scheduler
- status = scheduler.status()
- assert "mode" in status
- assert "current_book" in status
- assert "verses_meditated" in status
-
- def test_history(self, temp_scheduler):
- scheduler, store, _ = temp_scheduler
- verse = _sample_verse()
- store.insert_verse(verse)
- scheduler.meditate_on(verse)
- history = scheduler.history(limit=5)
- assert len(history) == 1
-
- def test_get_context(self, temp_scheduler):
- scheduler, store, _ = temp_scheduler
- verses = [_sample_verse(1, 1, i, f"Gen 1:{i}") for i in range(1, 6)]
- store.insert_verses(verses)
- ctx = scheduler.get_context(verses[2], before=1, after=1)
- assert len(ctx) == 3
-
- def test_get_cross_references(self, temp_scheduler):
- from scripture.models import CrossReference
- scheduler, store, _ = temp_scheduler
- v1 = _sample_verse(43, 3, 16, "For God so loved")
- v2 = _sample_verse(45, 5, 8, "God shows his love")
- store.insert_verse(v1)
- store.insert_verse(v2)
- store.insert_cross_reference(CrossReference(
- source_verse_id=v1.verse_id,
- target_verse_id=v2.verse_id,
- ))
- xrefs = scheduler.get_cross_references(v1)
- assert len(xrefs) == 1
- assert xrefs[0].verse_id == v2.verse_id
-
-
-# ════════════════════════════════════════════════════════════════════════════
-# Routes
-# ════════════════════════════════════════════════════════════════════════════
-
-
-@pytest.fixture
-def scripture_client(tmp_path):
- """TestClient with isolated scripture stores."""
- from scripture.meditation import MeditationScheduler
- from scripture.memory import ScriptureMemory
- from scripture.store import ScriptureStore
-
- store = ScriptureStore(db_path=tmp_path / "scripture.db")
- memory = ScriptureMemory(db_path=tmp_path / "memory.db")
- scheduler = MeditationScheduler(store=store, memory=memory)
-
- # Seed with some verses for route testing
- store.insert_verses([
- _sample_verse(43, 3, 16, "For God so loved the world, that he gave his only Son, that whoever believes in him should not perish but have eternal life."),
- _sample_verse(43, 3, 17, "For God did not send his Son into the world to condemn the world, but in order that the world might be saved through him."),
- _sample_verse(45, 5, 8, "but God shows his love for us in that while we were still sinners, Christ died for us."),
- _sample_verse(1, 1, 1, "In the beginning, God created the heavens and the earth."),
- _sample_verse(1, 1, 2, "The earth was without form and void, and darkness was over the face of the deep."),
- _sample_verse(1, 1, 3, "And God said, Let there be light, and there was light."),
- ])
-
- with patch("dashboard.routes.scripture.scripture_store", store), \
- patch("dashboard.routes.scripture.scripture_memory", memory), \
- patch("dashboard.routes.scripture.meditation_scheduler", scheduler):
- from dashboard.app import app
- with TestClient(app) as c:
- yield c
-
- store.close()
- memory.close()
-
-
-class TestScriptureRoutes:
- def test_scripture_status(self, scripture_client):
- resp = scripture_client.get("/scripture")
- assert resp.status_code == 200
- data = resp.json()
- assert "store" in data
- assert "memory" in data
- assert "meditation" in data
-
- def test_get_verse(self, scripture_client):
- resp = scripture_client.get("/scripture/verse", params={"ref": "John 3:16"})
- assert resp.status_code == 200
- data = resp.json()
- assert data["reference"] == "John 3:16"
- assert "loved" in data["text"]
-
- def test_get_verse_range(self, scripture_client):
- resp = scripture_client.get("/scripture/verse", params={"ref": "John 3:16-17"})
- assert resp.status_code == 200
- data = resp.json()
- assert "verses" in data
- assert len(data["verses"]) == 2
-
- def test_get_verse_bad_ref(self, scripture_client):
- resp = scripture_client.get("/scripture/verse", params={"ref": "not a ref"})
- assert resp.status_code == 400
-
- def test_get_verse_not_found(self, scripture_client):
- resp = scripture_client.get("/scripture/verse", params={"ref": "Jude 1:25"})
- assert resp.status_code == 404
-
- def test_get_chapter(self, scripture_client):
- resp = scripture_client.get(
- "/scripture/chapter", params={"book": "Genesis", "chapter": 1}
- )
- assert resp.status_code == 200
- data = resp.json()
- assert data["book"] == "Genesis"
- assert len(data["verses"]) == 3
-
- def test_get_chapter_bad_book(self, scripture_client):
- resp = scripture_client.get(
- "/scripture/chapter", params={"book": "FakeBook", "chapter": 1}
- )
- assert resp.status_code == 400
-
- def test_search(self, scripture_client):
- resp = scripture_client.get("/scripture/search", params={"q": "God"})
- assert resp.status_code == 200
- data = resp.json()
- assert data["count"] > 0
-
- def test_search_empty(self, scripture_client):
- resp = scripture_client.get("/scripture/search", params={"q": "xyznonexistent"})
- assert resp.status_code == 200
- assert resp.json()["count"] == 0
-
- def test_meditate_get(self, scripture_client):
- resp = scripture_client.get("/scripture/meditate")
- assert resp.status_code == 200
- data = resp.json()
- assert "status" in data
-
- def test_meditate_post(self, scripture_client):
- resp = scripture_client.post("/scripture/meditate")
- assert resp.status_code == 200
- data = resp.json()
- assert "verse" in data
- assert "status" in data
-
- def test_set_meditation_mode(self, scripture_client):
- resp = scripture_client.post(
- "/scripture/meditate/mode", params={"mode": "thematic", "theme": "love"}
- )
- assert resp.status_code == 200
- assert resp.json()["mode"] == "thematic"
-
- def test_set_meditation_mode_invalid(self, scripture_client):
- resp = scripture_client.post(
- "/scripture/meditate/mode", params={"mode": "invalid"}
- )
- assert resp.status_code == 400
-
- def test_memory_status(self, scripture_client):
- resp = scripture_client.get("/scripture/memory")
- assert resp.status_code == 200
- data = resp.json()
- assert "working_memory_items" in data
-
- def test_stats(self, scripture_client):
- resp = scripture_client.get("/scripture/stats")
- assert resp.status_code == 200
- data = resp.json()
- assert "verses" in data
-
- def test_ingest(self, scripture_client):
- payload = {
- "verses": [
- {"book": 19, "chapter": 23, "verse_num": 1, "text": "The LORD is my shepherd; I shall not want."},
- {"book": 19, "chapter": 23, "verse_num": 2, "text": "He makes me lie down in green pastures."},
- ]
- }
- resp = scripture_client.post("/scripture/ingest", json=payload)
- assert resp.status_code == 200
- data = resp.json()
- assert data["ingested"] == 2
-
- def test_ingest_invalid(self, scripture_client):
- resp = scripture_client.post("/scripture/ingest", json={"verses": []})
- assert resp.status_code == 400
-
- def test_ingest_bad_json(self, scripture_client):
- resp = scripture_client.post(
- "/scripture/ingest",
- content=b"not json",
- headers={"Content-Type": "application/json"},
- )
- assert resp.status_code == 400
-
- def test_xref(self, scripture_client):
- resp = scripture_client.get("/scripture/xref", params={"ref": "John 3:16"})
- assert resp.status_code == 200
- data = resp.json()
- assert "source" in data
- assert "cross_references" in data
-
- def test_xref_not_found(self, scripture_client):
- resp = scripture_client.get("/scripture/xref", params={"ref": "Jude 1:25"})
- assert resp.status_code == 404
diff --git a/tests/security/test_security_regression.py b/tests/security/test_security_regression.py
index 94df9b61..cd2424be 100644
--- a/tests/security/test_security_regression.py
+++ b/tests/security/test_security_regression.py
@@ -1,46 +1,4 @@
-import hmac
-import hashlib
-import base64
import pytest
-from timmy_serve.l402_proxy import create_l402_challenge, verify_l402_token, Macaroon, _sign
-
-def test_l402_macaroon_forgery_prevention():
- """Test that knowing the hmac_secret is not enough to forge a macaroon.
-
- The forgery attempt uses the same hmac_secret found in a valid macaroon
- but doesn't know the server's internal _MACAROON_SECRET.
- """
- # 1. Create a valid challenge
- challenge = create_l402_challenge(100, "valid")
- valid_token = challenge["macaroon"]
-
- # 2. Extract components from the valid macaroon
- valid_mac = Macaroon.deserialize(valid_token)
- assert valid_mac is not None
-
- # 3. Attempt to forge a macaroon for a different (unpaid) identifier
- # but using the same hmac_secret and the same signing logic a naive
- # attacker might assume (if it was just hmac(hmac_secret, identifier)).
- fake_identifier = "forged-payment-hash"
-
- # Naive forgery attempt:
- fake_signature = hmac.new(
- valid_mac.hmac_secret.encode(),
- fake_identifier.encode(),
- hashlib.sha256
- ).hexdigest()
-
- fake_mac = Macaroon(
- identifier=fake_identifier,
- signature=fake_signature,
- hmac_secret=valid_mac.hmac_secret,
- version=valid_mac.version,
- location=valid_mac.location
- )
- fake_token = fake_mac.serialize()
-
- # 4. Verification should fail because the server uses two-key derivation
- assert verify_l402_token(fake_token) is False
def test_xss_protection_in_templates():
"""Verify that templates now use the escape filter for user-controlled content."""
@@ -57,19 +15,3 @@ def test_xss_protection_in_templates():
content = f.read()
assert expected_snippet in content, f"XSS fix missing in {path}"
-def test_macaroon_serialization_v2():
- """Test that the new serialization format includes the hmac_secret."""
- mac = Macaroon(identifier="id", signature="sig", hmac_secret="secret")
- serialized = mac.serialize()
-
- # Decode manually to check parts
- raw = base64.urlsafe_b64decode(serialized.encode()).decode()
- parts = raw.split(":")
- assert len(parts) == 5
- assert parts[2] == "id"
- assert parts[3] == "sig"
- assert parts[4] == "secret"
-
- # Test deserialization
- restored = Macaroon.deserialize(serialized)
- assert restored.hmac_secret == "secret"
diff --git a/tests/self_coding/__init__.py b/tests/self_coding/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/self_coding/test_codebase_indexer.py b/tests/self_coding/test_codebase_indexer.py
deleted file mode 100644
index fd969e54..00000000
--- a/tests/self_coding/test_codebase_indexer.py
+++ /dev/null
@@ -1,352 +0,0 @@
-"""Tests for Codebase Indexer.
-
-Uses temporary directories with Python files to test AST parsing and indexing.
-"""
-
-from __future__ import annotations
-
-import tempfile
-from pathlib import Path
-
-import pytest
-
-from self_coding.codebase_indexer import CodebaseIndexer, ModuleInfo
-
-
-@pytest.fixture
-def temp_repo():
- """Create a temporary repository with Python files."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
-
- # Create src directory structure
- src_path = repo_path / "src" / "myproject"
- src_path.mkdir(parents=True)
-
- # Create a module with classes and functions
- (src_path / "utils.py").write_text('''
-"""Utility functions for the project."""
-
-import os
-from typing import Optional
-
-
-class Helper:
- """A helper class for common operations."""
-
- def __init__(self, name: str):
- self.name = name
-
- async def process(self, data: str) -> str:
- """Process the input data."""
- return data.upper()
-
- def cleanup(self):
- """Clean up resources."""
- pass
-
-
-def calculate_something(x: int, y: int) -> int:
- """Calculate something from x and y."""
- return x + y
-
-
-def untested_function():
- pass
-''')
-
- # Create another module that imports from utils
- (src_path / "main.py").write_text('''
-"""Main application module."""
-
-from myproject.utils import Helper, calculate_something
-import os
-
-
-class Application:
- """Main application class."""
-
- def run(self):
- helper = Helper("test")
- result = calculate_something(1, 2)
- return result
-''')
-
- # Create tests
- tests_path = repo_path / "tests"
- tests_path.mkdir()
-
- (tests_path / "test_utils.py").write_text('''
-"""Tests for utils module."""
-
-import pytest
-from myproject.utils import Helper, calculate_something
-
-
-def test_helper_process():
- helper = Helper("test")
- assert helper.process("hello") == "HELLO"
-
-
-def test_calculate_something():
- assert calculate_something(2, 3) == 5
-''')
-
- yield repo_path
-
-
-@pytest.fixture
-def indexer(temp_repo):
- """Create CodebaseIndexer for temp repo."""
- import uuid
- return CodebaseIndexer(
- repo_path=temp_repo,
- db_path=temp_repo / f"test_index_{uuid.uuid4().hex[:8]}.db",
- src_dirs=["src", "tests"],
- )
-
-
-@pytest.mark.asyncio
-class TestCodebaseIndexerBasics:
- """Basic indexing functionality."""
-
- async def test_index_all_counts(self, indexer):
- """Should index all Python files."""
- stats = await indexer.index_all()
-
- assert stats["indexed"] == 3 # utils.py, main.py, test_utils.py
- assert stats["failed"] == 0
-
- async def test_index_skips_unchanged(self, indexer):
- """Should skip unchanged files on second run."""
- await indexer.index_all()
-
- # Second index should skip all
- stats = await indexer.index_all()
- assert stats["skipped"] == 3
- assert stats["indexed"] == 0
-
- async def test_index_changed_detects_updates(self, indexer, temp_repo):
- """Should reindex changed files."""
- await indexer.index_all()
-
- # Modify a file
- utils_path = temp_repo / "src" / "myproject" / "utils.py"
- content = utils_path.read_text()
- utils_path.write_text(content + "\n# Modified\n")
-
- # Incremental index should detect change
- stats = await indexer.index_changed()
- assert stats["indexed"] == 1
- assert stats["skipped"] == 2
-
-
-@pytest.mark.asyncio
-class TestCodebaseIndexerParsing:
- """AST parsing accuracy."""
-
- async def test_parses_classes(self, indexer):
- """Should extract class information."""
- await indexer.index_all()
-
- info = await indexer.get_module_info("src/myproject/utils.py")
- assert info is not None
-
- class_names = [c.name for c in info.classes]
- assert "Helper" in class_names
-
- async def test_parses_class_methods(self, indexer):
- """Should extract class methods."""
- await indexer.index_all()
-
- info = await indexer.get_module_info("src/myproject/utils.py")
- helper = [c for c in info.classes if c.name == "Helper"][0]
-
- method_names = [m.name for m in helper.methods]
- assert "process" in method_names
- assert "cleanup" in method_names
-
- async def test_parses_function_signatures(self, indexer):
- """Should extract function signatures."""
- await indexer.index_all()
-
- info = await indexer.get_module_info("src/myproject/utils.py")
-
- func_names = [f.name for f in info.functions]
- assert "calculate_something" in func_names
- assert "untested_function" in func_names
-
- # Check signature details
- calc_func = [f for f in info.functions if f.name == "calculate_something"][0]
- assert calc_func.returns == "int"
- assert "x" in calc_func.args[0] if calc_func.args else True
-
- async def test_parses_imports(self, indexer):
- """Should extract import statements."""
- await indexer.index_all()
-
- info = await indexer.get_module_info("src/myproject/main.py")
-
- assert "myproject.utils.Helper" in info.imports
- assert "myproject.utils.calculate_something" in info.imports
- assert "os" in info.imports
-
- async def test_parses_docstrings(self, indexer):
- """Should extract module and class docstrings."""
- await indexer.index_all()
-
- info = await indexer.get_module_info("src/myproject/utils.py")
-
- assert "Utility functions" in info.docstring
- assert "helper class" in info.classes[0].docstring.lower()
-
-
-@pytest.mark.asyncio
-class TestCodebaseIndexerTestCoverage:
- """Test coverage mapping."""
-
- async def test_maps_test_files(self, indexer):
- """Should map source files to test files."""
- await indexer.index_all()
-
- info = await indexer.get_module_info("src/myproject/utils.py")
-
- assert info.test_coverage is not None
- assert "test_utils.py" in info.test_coverage
-
- async def test_has_test_coverage_method(self, indexer):
- """Should check if file has test coverage."""
- await indexer.index_all()
-
- assert await indexer.has_test_coverage("src/myproject/utils.py") is True
- # main.py has no corresponding test file
- assert await indexer.has_test_coverage("src/myproject/main.py") is False
-
-
-@pytest.mark.asyncio
-class TestCodebaseIndexerDependencies:
- """Dependency graph building."""
-
- async def test_builds_dependency_graph(self, indexer):
- """Should build import dependency graph."""
- await indexer.index_all()
-
- # main.py imports from utils.py
- deps = await indexer.get_dependency_chain("src/myproject/utils.py")
-
- assert "src/myproject/main.py" in deps
-
- async def test_empty_dependency_chain(self, indexer):
- """Should return empty list for files with no dependents."""
- await indexer.index_all()
-
- # test_utils.py likely doesn't have dependents
- deps = await indexer.get_dependency_chain("tests/test_utils.py")
-
- assert deps == []
-
-
-@pytest.mark.asyncio
-class TestCodebaseIndexerSummary:
- """Summary generation."""
-
- async def test_generates_summary(self, indexer):
- """Should generate codebase summary."""
- await indexer.index_all()
-
- summary = await indexer.get_summary()
-
- assert "Codebase Summary" in summary
- assert "myproject.utils" in summary
- assert "Helper" in summary
- assert "calculate_something" in summary
-
- async def test_summary_respects_max_tokens(self, indexer):
- """Should truncate if summary exceeds max tokens."""
- await indexer.index_all()
-
- # Very small limit
- summary = await indexer.get_summary(max_tokens=10)
-
- assert len(summary) <= 10 * 4 + 100 # rough check with buffer
-
-
-@pytest.mark.asyncio
-class TestCodebaseIndexerRelevance:
- """Relevant file search."""
-
- async def test_finds_relevant_files(self, indexer):
- """Should find files relevant to task description."""
- await indexer.index_all()
-
- files = await indexer.get_relevant_files("calculate something with helper", limit=5)
-
- assert "src/myproject/utils.py" in files
-
- async def test_relevance_scoring(self, indexer):
- """Should score files by keyword match."""
- await indexer.index_all()
-
- files = await indexer.get_relevant_files("process data with helper", limit=5)
-
- # utils.py should be first (has Helper class with process method)
- assert files[0] == "src/myproject/utils.py"
-
- async def test_returns_empty_for_no_matches(self, indexer):
- """Should return empty list when no files match."""
- await indexer.index_all()
-
- # Use truly unique keywords that won't match anything in the codebase
- files = await indexer.get_relevant_files("astronaut dinosaur zebra unicorn", limit=5)
-
- assert files == []
-
-
-@pytest.mark.asyncio
-class TestCodebaseIndexerIntegration:
- """Full workflow integration tests."""
-
- async def test_full_index_query_workflow(self, temp_repo):
- """Complete workflow: index, query, get dependencies."""
- indexer = CodebaseIndexer(
- repo_path=temp_repo,
- db_path=temp_repo / "integration.db",
- src_dirs=["src", "tests"],
- )
-
- # Index all files
- stats = await indexer.index_all()
- assert stats["indexed"] == 3
-
- # Get summary
- summary = await indexer.get_summary()
- assert "Helper" in summary
-
- # Find relevant files
- files = await indexer.get_relevant_files("helper class", limit=3)
- assert len(files) > 0
-
- # Check dependencies
- deps = await indexer.get_dependency_chain("src/myproject/utils.py")
- assert "src/myproject/main.py" in deps
-
- # Verify test coverage
- has_tests = await indexer.has_test_coverage("src/myproject/utils.py")
- assert has_tests is True
-
- async def test_handles_syntax_errors_gracefully(self, temp_repo):
- """Should skip files with syntax errors."""
- # Create a file with syntax error
- (temp_repo / "src" / "bad.py").write_text("def broken(:")
-
- indexer = CodebaseIndexer(
- repo_path=temp_repo,
- db_path=temp_repo / "syntax_error.db",
- src_dirs=["src"],
- )
-
- stats = await indexer.index_all()
-
- # Should index the good files, fail on bad one
- assert stats["failed"] == 1
- assert stats["indexed"] >= 2
diff --git a/tests/self_coding/test_codebase_indexer_errors.py b/tests/self_coding/test_codebase_indexer_errors.py
deleted file mode 100644
index 93d5ecae..00000000
--- a/tests/self_coding/test_codebase_indexer_errors.py
+++ /dev/null
@@ -1,444 +0,0 @@
-"""Error path tests for Codebase Indexer.
-
-Tests syntax errors, encoding issues, circular imports, and edge cases.
-"""
-
-from __future__ import annotations
-
-import tempfile
-from pathlib import Path
-
-import pytest
-
-from self_coding.codebase_indexer import CodebaseIndexer, ModuleInfo
-
-
-@pytest.mark.asyncio
-class TestCodebaseIndexerErrors:
- """Indexing error handling."""
-
- async def test_syntax_error_file(self):
- """Should skip files with syntax errors."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- # Valid file
- (src_path / "good.py").write_text("def good(): pass")
-
- # File with syntax error
- (src_path / "bad.py").write_text("def bad(:\n pass")
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- stats = await indexer.index_all()
-
- assert stats["indexed"] == 1
- assert stats["failed"] == 1
-
- async def test_unicode_in_source(self):
- """Should handle Unicode in source files."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- # File with Unicode
- (src_path / "unicode.py").write_text(
- '# -*- coding: utf-8 -*-\n'
- '"""Module with Unicode: ñ 中文 🎉"""\n'
- 'def hello():\n'
- ' """Returns 👋"""\n'
- ' return "hello"\n',
- encoding="utf-8",
- )
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- stats = await indexer.index_all()
-
- assert stats["indexed"] == 1
- assert stats["failed"] == 0
-
- info = await indexer.get_module_info("src/unicode.py")
- assert "中文" in info.docstring
-
- async def test_empty_file(self):
- """Should handle empty Python files."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- # Empty file
- (src_path / "empty.py").write_text("")
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- stats = await indexer.index_all()
-
- assert stats["indexed"] == 1
-
- info = await indexer.get_module_info("src/empty.py")
- assert info is not None
- assert info.functions == []
- assert info.classes == []
-
- async def test_large_file(self):
- """Should handle large Python files."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- # Large file with many functions
- content = ['"""Large module."""']
- for i in range(100):
- content.append(f'def function_{i}(x: int) -> int:')
- content.append(f' """Function {i}."""')
- content.append(f' return x + {i}')
- content.append('')
-
- (src_path / "large.py").write_text("\n".join(content))
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- stats = await indexer.index_all()
-
- assert stats["indexed"] == 1
-
- info = await indexer.get_module_info("src/large.py")
- assert len(info.functions) == 100
-
- async def test_nested_classes(self):
- """Should handle nested classes."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- (src_path / "nested.py").write_text('''
-"""Module with nested classes."""
-
-class Outer:
- """Outer class."""
-
- class Inner:
- """Inner class."""
-
- def inner_method(self):
- pass
-
- def outer_method(self):
- pass
-''')
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- await indexer.index_all()
-
- info = await indexer.get_module_info("src/nested.py")
-
- # Should find Outer class (top-level)
- assert len(info.classes) == 1
- assert info.classes[0].name == "Outer"
- # Outer should have outer_method
- assert len(info.classes[0].methods) == 1
- assert info.classes[0].methods[0].name == "outer_method"
-
- async def test_complex_type_annotations(self):
- """Should handle complex type annotations."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- (src_path / "types.py").write_text('''
-"""Module with complex types."""
-
-from typing import Dict, List, Optional, Union, Callable
-
-
-def complex_function(
- items: List[Dict[str, Union[int, str]]],
- callback: Callable[[int], bool],
- optional: Optional[str] = None,
-) -> Dict[str, List[int]]:
- """Function with complex types."""
- return {}
-
-
-class TypedClass:
- """Class with type annotations."""
-
- def method(self, x: int | str) -> list[int]:
- """Method with union type (Python 3.10+)."""
- return []
-''')
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- await indexer.index_all()
-
- info = await indexer.get_module_info("src/types.py")
-
- # Should parse without error
- assert len(info.functions) == 1
- assert len(info.classes) == 1
-
- async def test_import_variations(self):
- """Should handle various import styles."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- (src_path / "imports.py").write_text('''
-"""Module with various imports."""
-
-# Regular imports
-import os
-import sys as system
-from pathlib import Path
-
-# From imports
-from typing import Dict, List
-from collections import OrderedDict as OD
-
-# Relative imports (may not resolve)
-from . import sibling
-from .subpackage import module
-
-# Dynamic imports (won't be caught by AST)
-try:
- import optional_dep
-except ImportError:
- pass
-''')
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- await indexer.index_all()
-
- info = await indexer.get_module_info("src/imports.py")
-
- # Should capture static imports
- assert "os" in info.imports
- assert "typing.Dict" in info.imports or "Dict" in str(info.imports)
-
- async def test_no_src_directory(self):
- """Should handle missing src directory gracefully."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src", "tests"],
- )
-
- stats = await indexer.index_all()
-
- assert stats["indexed"] == 0
- assert stats["failed"] == 0
-
- async def test_permission_error(self):
- """Should handle permission errors gracefully."""
- import os
- if os.geteuid() == 0:
- pytest.skip("Permission tests are ineffective when running as root")
-
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- # Create file
- file_path = src_path / "locked.py"
- file_path.write_text("def test(): pass")
-
- # Remove read permission (if on Unix)
- try:
- os.chmod(file_path, 0o000)
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- stats = await indexer.index_all()
-
- # Should count as failed
- assert stats["failed"] == 1
-
- finally:
- # Restore permission for cleanup
- os.chmod(file_path, 0o644)
-
- async def test_circular_imports_in_dependency_graph(self):
- """Should handle circular imports in dependency analysis."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- # Create circular imports
- (src_path / "a.py").write_text('''
-"""Module A."""
-from b import B
-
-class A:
- def get_b(self):
- return B()
-''')
-
- (src_path / "b.py").write_text('''
-"""Module B."""
-from a import A
-
-class B:
- def get_a(self):
- return A()
-''')
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- await indexer.index_all()
-
- # Both should have each other as dependencies
- a_deps = await indexer.get_dependency_chain("src/a.py")
- b_deps = await indexer.get_dependency_chain("src/b.py")
-
- # Note: Due to import resolution, this might not be perfect
- # but it shouldn't crash
- assert isinstance(a_deps, list)
- assert isinstance(b_deps, list)
-
- async def test_summary_with_no_modules(self):
- """Summary should handle empty codebase."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- await indexer.index_all()
-
- summary = await indexer.get_summary()
-
- assert "Codebase Summary" in summary
- assert "Total modules: 0" in summary
-
- async def test_get_relevant_files_with_special_chars(self):
- """Should handle special characters in search query."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- (src_path / "test.py").write_text('def test(): pass')
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- await indexer.index_all()
-
- # Search with special chars shouldn't crash
- files = await indexer.get_relevant_files("test!@#$%^&*()", limit=5)
- assert isinstance(files, list)
-
- async def test_concurrent_indexing(self):
- """Should handle concurrent indexing attempts."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- (src_path / "file.py").write_text("def test(): pass")
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- # Multiple rapid indexing calls
- import asyncio
- tasks = [
- indexer.index_all(),
- indexer.index_all(),
- indexer.index_all(),
- ]
- results = await asyncio.gather(*tasks)
-
- # All should complete without error
- for stats in results:
- assert stats["indexed"] >= 0
- assert stats["failed"] >= 0
-
- async def test_binary_file_in_src(self):
- """Should skip binary files in src directory."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- src_path = repo_path / "src"
- src_path.mkdir()
-
- # Binary file
- (src_path / "data.bin").write_bytes(b"\x00\x01\x02\x03")
-
- # Python file
- (src_path / "script.py").write_text("def test(): pass")
-
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "index.db",
- src_dirs=["src"],
- )
-
- stats = await indexer.index_all()
-
- # Should only index .py file
- assert stats["indexed"] == 1
- assert stats["failed"] == 0 # Binary files are skipped, not failed
diff --git a/tests/self_coding/test_git_safety.py b/tests/self_coding/test_git_safety.py
deleted file mode 100644
index 404bd167..00000000
--- a/tests/self_coding/test_git_safety.py
+++ /dev/null
@@ -1,435 +0,0 @@
-"""Tests for Git Safety Layer.
-
-Uses temporary git repositories to test snapshot/rollback/merge workflows
-without affecting the actual Timmy repository.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import os
-import subprocess
-import sys
-import tempfile
-from pathlib import Path
-
-import pytest
-
-from self_coding.git_safety import (
- GitSafety,
- GitDirtyWorkingDirectoryError,
- GitNotRepositoryError,
- GitOperationError,
- Snapshot,
-)
-
-
-@pytest.fixture
-def temp_git_repo():
- """Create a temporary git repository for testing."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
-
- # Initialize git repo
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(
- ["git", "config", "user.email", "test@test.com"],
- cwd=repo_path,
- check=True,
- capture_output=True,
- )
- subprocess.run(
- ["git", "config", "user.name", "Test User"],
- cwd=repo_path,
- check=True,
- capture_output=True,
- )
- subprocess.run(
- ["git", "config", "commit.gpgsign", "false"],
- cwd=repo_path,
- check=True,
- capture_output=True,
- )
-
- # Create initial file and commit
- (repo_path / "README.md").write_text("# Test Repo")
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "Initial commit"],
- cwd=repo_path,
- check=True,
- capture_output=True,
- )
-
- # Rename master to main if needed
- result = subprocess.run(
- ["git", "branch", "-M", "main"],
- cwd=repo_path,
- capture_output=True,
- )
-
- yield repo_path
-
-
-@pytest.fixture
-def git_safety(temp_git_repo):
- """Create GitSafety instance for temp repo."""
- safety = GitSafety(
- repo_path=temp_git_repo,
- main_branch="main",
- test_command="echo 'No tests configured'", # Fake test command
- )
- return safety
-
-
-@pytest.mark.asyncio
-class TestGitSafetyBasics:
- """Basic git operations."""
-
- async def test_init_with_valid_repo(self, temp_git_repo):
- """Should initialize successfully with valid git repo."""
- safety = GitSafety(repo_path=temp_git_repo)
- assert safety.repo_path == temp_git_repo.resolve()
- assert safety.main_branch == "main"
-
- async def test_init_with_invalid_repo(self):
- """Should raise GitNotRepositoryError for non-repo path."""
- with tempfile.TemporaryDirectory() as tmpdir:
- with pytest.raises(GitNotRepositoryError):
- GitSafety(repo_path=tmpdir)
-
- async def test_is_clean_clean_repo(self, git_safety, temp_git_repo):
- """Should return True for clean repo."""
- safety = git_safety
- assert await safety.is_clean() is True
-
- async def test_is_clean_dirty_repo(self, git_safety, temp_git_repo):
- """Should return False when there are uncommitted changes."""
- safety = git_safety
- # Create uncommitted file
- (temp_git_repo / "dirty.txt").write_text("dirty")
- assert await safety.is_clean() is False
-
- async def test_get_current_branch(self, git_safety):
- """Should return current branch name."""
- safety = git_safety
- branch = await safety.get_current_branch()
- assert branch == "main"
-
- async def test_get_current_commit(self, git_safety):
- """Should return valid commit hash."""
- safety = git_safety
- commit = await safety.get_current_commit()
- assert len(commit) == 40 # Full SHA-1 hash
- assert all(c in "0123456789abcdef" for c in commit)
-
-
-@pytest.mark.asyncio
-class TestGitSafetySnapshot:
- """Snapshot functionality."""
-
- async def test_snapshot_returns_snapshot_object(self, git_safety):
- """Should return Snapshot with all fields populated."""
- safety = git_safety
- snapshot = await safety.snapshot(run_tests=False)
-
- assert isinstance(snapshot, Snapshot)
- assert len(snapshot.commit_hash) == 40
- assert snapshot.branch == "main"
- assert snapshot.timestamp is not None
- assert snapshot.clean is True
-
- async def test_snapshot_captures_clean_status(self, git_safety, temp_git_repo):
- """Should correctly capture clean/dirty status."""
- safety = git_safety
-
- # Clean snapshot
- clean_snapshot = await safety.snapshot(run_tests=False)
- assert clean_snapshot.clean is True
-
- # Dirty snapshot
- (temp_git_repo / "dirty.txt").write_text("dirty")
- dirty_snapshot = await safety.snapshot(run_tests=False)
- assert dirty_snapshot.clean is False
-
- async def test_snapshot_with_tests(self, git_safety, temp_git_repo):
- """Should run tests and capture status."""
- # Create a passing test
- (temp_git_repo / "test_pass.py").write_text("""
-def test_pass():
- assert True
-""")
- safety = GitSafety(
- repo_path=temp_git_repo,
- test_command=f"{sys.executable} -m pytest test_pass.py -v",
- )
-
- snapshot = await safety.snapshot(run_tests=True)
- assert snapshot.test_status is True
- assert "passed" in snapshot.test_output.lower() or "no tests" not in snapshot.test_output
-
-
-@pytest.mark.asyncio
-class TestGitSafetyBranching:
- """Branch creation and management."""
-
- async def test_create_branch(self, git_safety):
- """Should create and checkout new branch."""
- safety = git_safety
-
- branch_name = "timmy/self-edit/test"
- result = await safety.create_branch(branch_name)
-
- assert result == branch_name
- assert await safety.get_current_branch() == branch_name
-
- async def test_create_branch_from_main(self, git_safety, temp_git_repo):
- """New branch should start from main."""
- safety = git_safety
-
- main_commit = await safety.get_current_commit()
-
- await safety.create_branch("feature-branch")
- branch_commit = await safety.get_current_commit()
-
- assert branch_commit == main_commit
-
-
-@pytest.mark.asyncio
-class TestGitSafetyCommit:
- """Commit operations."""
-
- async def test_commit_specific_files(self, git_safety, temp_git_repo):
- """Should commit only specified files."""
- safety = git_safety
-
- # Create two files
- (temp_git_repo / "file1.txt").write_text("content1")
- (temp_git_repo / "file2.txt").write_text("content2")
-
- # Commit only file1
- commit_hash = await safety.commit("Add file1", ["file1.txt"])
-
- assert len(commit_hash) == 40
-
- # file2 should still be uncommitted
- assert await safety.is_clean() is False
-
- async def test_commit_all_changes(self, git_safety, temp_git_repo):
- """Should commit all changes when no files specified."""
- safety = git_safety
-
- (temp_git_repo / "new.txt").write_text("new content")
-
- commit_hash = await safety.commit("Add new file")
-
- assert len(commit_hash) == 40
- assert await safety.is_clean() is True
-
- async def test_commit_no_changes(self, git_safety):
- """Should handle commit with no changes gracefully."""
- safety = git_safety
-
- commit_hash = await safety.commit("No changes")
-
- # Should return current commit when no changes
- current = await safety.get_current_commit()
- assert commit_hash == current
-
-
-@pytest.mark.asyncio
-class TestGitSafetyDiff:
- """Diff operations."""
-
- async def test_get_diff(self, git_safety, temp_git_repo):
- """Should return diff between commits."""
- safety = git_safety
-
- original_commit = await safety.get_current_commit()
-
- # Make a change and commit
- (temp_git_repo / "new.txt").write_text("new content")
- await safety.commit("Add new file")
-
- new_commit = await safety.get_current_commit()
-
- diff = await safety.get_diff(original_commit, new_commit)
-
- assert "new.txt" in diff
- assert "new content" in diff
-
- async def test_get_modified_files(self, git_safety, temp_git_repo):
- """Should list modified files."""
- safety = git_safety
-
- original_commit = await safety.get_current_commit()
-
- (temp_git_repo / "file1.txt").write_text("content")
- (temp_git_repo / "file2.txt").write_text("content")
- await safety.commit("Add files")
-
- files = await safety.get_modified_files(original_commit)
-
- assert "file1.txt" in files
- assert "file2.txt" in files
-
-
-@pytest.mark.asyncio
-class TestGitSafetyRollback:
- """Rollback functionality."""
-
- async def test_rollback_to_snapshot(self, git_safety, temp_git_repo):
- """Should rollback to snapshot state."""
- safety = git_safety
-
- # Take snapshot
- snapshot = await safety.snapshot(run_tests=False)
- original_commit = snapshot.commit_hash
-
- # Make change and commit
- (temp_git_repo / "feature.txt").write_text("feature")
- await safety.commit("Add feature")
-
- # Verify we're on new commit
- new_commit = await safety.get_current_commit()
- assert new_commit != original_commit
-
- # Rollback
- rolled_back = await safety.rollback(snapshot)
-
- assert rolled_back == original_commit
- assert await safety.get_current_commit() == original_commit
-
- async def test_rollback_discards_uncommitted_changes(self, git_safety, temp_git_repo):
- """Rollback should discard uncommitted changes."""
- safety = git_safety
-
- snapshot = await safety.snapshot(run_tests=False)
-
- # Create uncommitted file
- dirty_file = temp_git_repo / "dirty.txt"
- dirty_file.write_text("dirty content")
-
- assert dirty_file.exists()
-
- # Rollback
- await safety.rollback(snapshot)
-
- # Uncommitted file should be gone
- assert not dirty_file.exists()
-
- async def test_rollback_to_commit_hash(self, git_safety, temp_git_repo):
- """Should rollback to raw commit hash."""
- safety = git_safety
-
- original_commit = await safety.get_current_commit()
-
- # Make change
- (temp_git_repo / "temp.txt").write_text("temp")
- await safety.commit("Temp commit")
-
- # Rollback using hash string
- await safety.rollback(original_commit)
-
- assert await safety.get_current_commit() == original_commit
-
-
-@pytest.mark.asyncio
-class TestGitSafetyMerge:
- """Merge operations."""
-
- async def test_merge_to_main_success(self, git_safety, temp_git_repo):
- """Should merge feature branch into main when tests pass."""
- safety = git_safety
-
- main_commit_before = await safety.get_current_commit()
-
- # Create feature branch
- await safety.create_branch("feature/test")
- (temp_git_repo / "feature.txt").write_text("feature")
- await safety.commit("Add feature")
- feature_commit = await safety.get_current_commit()
-
- # Merge back to main (tests pass with echo command)
- merge_commit = await safety.merge_to_main("feature/test", require_tests=False)
-
- # Should be on main with new merge commit
- assert await safety.get_current_branch() == "main"
- assert await safety.get_current_commit() == merge_commit
- assert merge_commit != main_commit_before
-
- async def test_merge_to_main_with_tests_failure(self, git_safety, temp_git_repo):
- """Should not merge when tests fail."""
- safety = GitSafety(
- repo_path=temp_git_repo,
- test_command="exit 1", # Always fails
- )
-
- # Create feature branch
- await safety.create_branch("feature/failing")
- (temp_git_repo / "fail.txt").write_text("fail")
- await safety.commit("Add failing feature")
-
- # Merge should fail due to tests
- with pytest.raises(GitOperationError) as exc_info:
- await safety.merge_to_main("feature/failing", require_tests=True)
-
- assert "tests failed" in str(exc_info.value).lower() or "cannot merge" in str(exc_info.value).lower()
-
-
-@pytest.mark.asyncio
-class TestGitSafetyIntegration:
- """Full workflow integration tests."""
-
- async def test_full_self_edit_workflow(self, temp_git_repo):
- """Complete workflow: snapshot → branch → edit → commit → merge."""
- safety = GitSafety(
- repo_path=temp_git_repo,
- test_command="echo 'tests pass'",
- )
-
- # 1. Take snapshot
- snapshot = await safety.snapshot(run_tests=False)
-
- # 2. Create feature branch
- branch = await safety.create_branch("timmy/self-edit/test-feature")
-
- # 3. Make edits
- (temp_git_repo / "src" / "feature.py").parent.mkdir(parents=True, exist_ok=True)
- (temp_git_repo / "src" / "feature.py").write_text("""
-def new_feature():
- return "Hello from new feature!"
-""")
-
- # 4. Commit
- commit = await safety.commit("Add new feature", ["src/feature.py"])
-
- # 5. Merge to main
- merge_commit = await safety.merge_to_main(branch, require_tests=False)
-
- # Verify state
- assert await safety.get_current_branch() == "main"
- assert (temp_git_repo / "src" / "feature.py").exists()
-
- async def test_rollback_on_failure(self, temp_git_repo):
- """Rollback workflow when changes need to be abandoned."""
- safety = GitSafety(
- repo_path=temp_git_repo,
- test_command="echo 'tests pass'",
- )
-
- # Snapshot
- snapshot = await safety.snapshot(run_tests=False)
- original_commit = snapshot.commit_hash
-
- # Create branch and make changes
- await safety.create_branch("timmy/self-edit/bad-feature")
- (temp_git_repo / "bad.py").write_text("# Bad code")
- await safety.commit("Add bad feature")
-
- # Oops! Rollback
- await safety.rollback(snapshot)
-
- # Should be back to original state
- assert await safety.get_current_commit() == original_commit
- assert not (temp_git_repo / "bad.py").exists()
diff --git a/tests/self_coding/test_git_safety_errors.py b/tests/self_coding/test_git_safety_errors.py
deleted file mode 100644
index e8086ee6..00000000
--- a/tests/self_coding/test_git_safety_errors.py
+++ /dev/null
@@ -1,273 +0,0 @@
-"""Error path tests for Git Safety Layer.
-
-Tests timeout handling, git failures, merge conflicts, and edge cases.
-"""
-
-from __future__ import annotations
-
-import subprocess
-import sys
-import tempfile
-from pathlib import Path
-from unittest.mock import patch
-
-import pytest
-
-from self_coding.git_safety import (
- GitNotRepositoryError,
- GitOperationError,
- GitSafety,
-)
-
-
-@pytest.mark.asyncio
-class TestGitSafetyErrors:
- """Git operation error handling."""
-
- async def test_invalid_repo_path(self):
- """Should raise GitNotRepositoryError for non-repo."""
- with tempfile.TemporaryDirectory() as tmpdir:
- with pytest.raises(GitNotRepositoryError):
- GitSafety(repo_path=tmpdir)
-
- async def test_git_command_failure(self):
- """Should raise GitOperationError on git failure."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo_path, check=True, capture_output=True)
-
- safety = GitSafety(repo_path=repo_path)
-
- # Try to checkout non-existent branch
- with pytest.raises(GitOperationError):
- await safety._run_git("checkout", "nonexistent-branch")
-
- async def test_merge_conflict_detection(self):
- """Should handle merge conflicts gracefully."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo_path, check=True, capture_output=True)
-
- # Create initial file
- (repo_path / "file.txt").write_text("original")
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "branch", "-M", "main"], cwd=repo_path, check=True, capture_output=True)
-
- safety = GitSafety(repo_path=repo_path)
-
- # Create branch A with changes
- await safety.create_branch("branch-a")
- (repo_path / "file.txt").write_text("branch-a changes")
- await safety.commit("Branch A changes")
-
- # Go back to main, create branch B with conflicting changes
- await safety._run_git("checkout", "main")
- await safety.create_branch("branch-b")
- (repo_path / "file.txt").write_text("branch-b changes")
- await safety.commit("Branch B changes")
-
- # Try to merge branch-a into branch-b (will conflict)
- with pytest.raises(GitOperationError):
- await safety._run_git("merge", "branch-a")
-
- async def test_rollback_after_merge(self):
- """Should be able to rollback even after merge."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo_path, check=True, capture_output=True)
-
- safety = GitSafety(repo_path=repo_path)
-
- # Initial commit
- (repo_path / "file.txt").write_text("v1")
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "commit", "-m", "v1"], cwd=repo_path, check=True, capture_output=True)
-
- snapshot = await safety.snapshot(run_tests=False)
-
- # Make changes and commit
- (repo_path / "file.txt").write_text("v2")
- await safety.commit("v2")
-
- # Rollback
- await safety.rollback(snapshot)
-
- # Verify
- content = (repo_path / "file.txt").read_text()
- assert content == "v1"
-
- async def test_snapshot_with_failing_tests(self):
- """Snapshot should capture failing test status."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo_path, check=True, capture_output=True)
-
- # Need an initial commit for HEAD to exist
- (repo_path / "initial.txt").write_text("initial")
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo_path, check=True, capture_output=True)
-
- # Create failing test
- (repo_path / "test_fail.py").write_text("def test_fail(): assert False")
-
- safety = GitSafety(
- repo_path=repo_path,
- test_command=f"{sys.executable} -m pytest test_fail.py -v",
- )
-
- snapshot = await safety.snapshot(run_tests=True)
-
- assert snapshot.test_status is False
- assert "FAILED" in snapshot.test_output or "failed" in snapshot.test_output.lower()
-
- async def test_get_diff_between_commits(self):
- """Should get diff between any two commits."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo_path, check=True, capture_output=True)
-
- safety = GitSafety(repo_path=repo_path)
-
- # Commit 1
- (repo_path / "file.txt").write_text("version 1")
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "commit", "-m", "v1"], cwd=repo_path, check=True, capture_output=True)
- commit1 = await safety.get_current_commit()
-
- # Commit 2
- (repo_path / "file.txt").write_text("version 2")
- await safety.commit("v2")
- commit2 = await safety.get_current_commit()
-
- # Get diff
- diff = await safety.get_diff(commit1, commit2)
-
- assert "version 1" in diff
- assert "version 2" in diff
-
- async def test_is_clean_with_untracked_files(self):
- """is_clean should return False with untracked files (they count as changes)."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo_path, check=True, capture_output=True)
-
- # Need an initial commit for HEAD to exist
- (repo_path / "initial.txt").write_text("initial")
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo_path, check=True, capture_output=True)
-
- safety = GitSafety(repo_path=repo_path)
-
- # Verify clean state first
- assert await safety.is_clean() is True
-
- # Create untracked file
- (repo_path / "untracked.txt").write_text("untracked")
-
- # is_clean returns False when there are untracked files
- # (git status --porcelain shows ?? for untracked)
- assert await safety.is_clean() is False
-
- async def test_empty_commit_allowed(self):
- """Should allow empty commits when requested."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo_path, check=True, capture_output=True)
-
- # Initial commit
- (repo_path / "file.txt").write_text("content")
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo_path, check=True, capture_output=True)
-
- safety = GitSafety(repo_path=repo_path)
-
- # Empty commit
- commit_hash = await safety.commit("Empty commit message", allow_empty=True)
-
- assert len(commit_hash) == 40
-
- async def test_modified_files_detection(self):
- """Should detect which files were modified."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo_path, check=True, capture_output=True)
-
- safety = GitSafety(repo_path=repo_path)
-
- # Initial commits
- (repo_path / "file1.txt").write_text("content1")
- (repo_path / "file2.txt").write_text("content2")
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo_path, check=True, capture_output=True)
-
- base_commit = await safety.get_current_commit()
-
- # Modify only file1
- (repo_path / "file1.txt").write_text("modified content")
- await safety.commit("Modify file1")
-
- # Get modified files
- modified = await safety.get_modified_files(base_commit)
-
- assert "file1.txt" in modified
- assert "file2.txt" not in modified
-
- async def test_branch_switching(self):
- """Should handle switching between branches."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "config", "commit.gpgsign", "false"], cwd=repo_path, check=True, capture_output=True)
-
- # Initial commit on master (default branch name)
- (repo_path / "main.txt").write_text("main branch content")
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo_path, check=True, capture_output=True)
- # Rename to main for consistency
- subprocess.run(["git", "branch", "-M", "main"], cwd=repo_path, check=True, capture_output=True)
-
- safety = GitSafety(repo_path=repo_path, main_branch="main")
-
- # Create feature branch
- await safety.create_branch("feature")
- (repo_path / "feature.txt").write_text("feature content")
- await safety.commit("Add feature")
-
- # Switch back to main
- await safety._run_git("checkout", "main")
-
- # Verify main doesn't have feature.txt
- assert not (repo_path / "feature.txt").exists()
-
- # Switch to feature
- await safety._run_git("checkout", "feature")
-
- # Verify feature has feature.txt
- assert (repo_path / "feature.txt").exists()
diff --git a/tests/self_coding/test_git_tools.py b/tests/self_coding/test_git_tools.py
deleted file mode 100644
index de742313..00000000
--- a/tests/self_coding/test_git_tools.py
+++ /dev/null
@@ -1,184 +0,0 @@
-"""Tests for tools.git_tools — Git operations for Forge/Helm personas.
-
-All tests use temporary git repositories to avoid touching the real
-working tree.
-"""
-
-import pytest
-from pathlib import Path
-
-from creative.tools.git_tools import (
- git_init,
- git_status,
- git_add,
- git_commit,
- git_log,
- git_diff,
- git_branch,
- git_stash,
- git_blame,
- git_clone,
- GIT_TOOL_CATALOG,
-)
-
-
-@pytest.fixture
-def git_repo(tmp_path):
- """Create a temporary git repo with one commit."""
- result = git_init(tmp_path)
- assert result["success"]
-
- # Configure git identity and disable signing for commits
- from git import Repo
- repo = Repo(str(tmp_path))
- repo.config_writer().set_value("user", "name", "Test").release()
- repo.config_writer().set_value("user", "email", "test@test.com").release()
- repo.config_writer().set_value("commit", "gpgsign", "false").release()
-
- # Create initial commit
- readme = tmp_path / "README.md"
- readme.write_text("# Test Repo\n")
- repo.index.add(["README.md"])
- repo.index.commit("Initial commit")
-
- return tmp_path
-
-
-class TestGitInit:
- def test_init_creates_repo(self, tmp_path):
- path = tmp_path / "new_repo"
- result = git_init(path)
- assert result["success"]
- assert (path / ".git").is_dir()
-
- def test_init_returns_path(self, tmp_path):
- path = tmp_path / "repo"
- result = git_init(path)
- assert result["path"] == str(path)
-
-
-class TestGitStatus:
- def test_clean_repo(self, git_repo):
- result = git_status(git_repo)
- assert result["success"]
- assert result["is_dirty"] is False
- assert result["untracked"] == []
-
- def test_dirty_repo_untracked(self, git_repo):
- (git_repo / "new_file.txt").write_text("hello")
- result = git_status(git_repo)
- assert result["is_dirty"] is True
- assert "new_file.txt" in result["untracked"]
-
- def test_reports_branch(self, git_repo):
- result = git_status(git_repo)
- assert result["branch"] in ("main", "master")
-
-
-class TestGitAddCommit:
- def test_add_and_commit(self, git_repo):
- (git_repo / "test.py").write_text("print('hi')\n")
- add_result = git_add(git_repo, ["test.py"])
- assert add_result["success"]
-
- commit_result = git_commit(git_repo, "Add test.py")
- assert commit_result["success"]
- assert len(commit_result["sha"]) == 40
- assert commit_result["message"] == "Add test.py"
-
- def test_add_all(self, git_repo):
- (git_repo / "a.txt").write_text("a")
- (git_repo / "b.txt").write_text("b")
- result = git_add(git_repo)
- assert result["success"]
-
-
-class TestGitLog:
- def test_log_returns_commits(self, git_repo):
- result = git_log(git_repo)
- assert result["success"]
- assert len(result["commits"]) >= 1
- first = result["commits"][0]
- assert "sha" in first
- assert "message" in first
- assert "author" in first
- assert "date" in first
-
- def test_log_max_count(self, git_repo):
- result = git_log(git_repo, max_count=1)
- assert len(result["commits"]) == 1
-
-
-class TestGitDiff:
- def test_no_diff_on_clean(self, git_repo):
- result = git_diff(git_repo)
- assert result["success"]
- assert result["diff"] == ""
-
- def test_diff_on_modified(self, git_repo):
- readme = git_repo / "README.md"
- readme.write_text("# Modified\n")
- result = git_diff(git_repo)
- assert result["success"]
- assert "Modified" in result["diff"]
-
-
-class TestGitBranch:
- def test_list_branches(self, git_repo):
- result = git_branch(git_repo)
- assert result["success"]
- assert len(result["branches"]) >= 1
-
- def test_create_branch(self, git_repo):
- result = git_branch(git_repo, create="feature-x")
- assert result["success"]
- assert "feature-x" in result["branches"]
- assert result["created"] == "feature-x"
-
- def test_switch_branch(self, git_repo):
- git_branch(git_repo, create="dev")
- result = git_branch(git_repo, switch="dev")
- assert result["active"] == "dev"
-
-
-class TestGitStash:
- def test_stash_and_pop(self, git_repo):
- readme = git_repo / "README.md"
- readme.write_text("# Changed\n")
-
- stash_result = git_stash(git_repo, message="wip")
- assert stash_result["success"]
- assert stash_result["action"] == "stash"
-
- # Working tree should be clean after stash
- status = git_status(git_repo)
- assert status["is_dirty"] is False
-
- # Pop restores changes
- pop_result = git_stash(git_repo, pop=True)
- assert pop_result["success"]
- assert pop_result["action"] == "pop"
-
-
-class TestGitBlame:
- def test_blame_file(self, git_repo):
- result = git_blame(git_repo, "README.md")
- assert result["success"]
- assert "Test Repo" in result["blame"]
-
-
-class TestGitToolCatalog:
- def test_catalog_has_all_tools(self):
- expected = {
- "git_clone", "git_status", "git_diff", "git_log",
- "git_blame", "git_branch", "git_add", "git_commit",
- "git_push", "git_pull", "git_stash",
- }
- assert expected == set(GIT_TOOL_CATALOG.keys())
-
- def test_catalog_entries_have_required_keys(self):
- for tool_id, info in GIT_TOOL_CATALOG.items():
- assert "name" in info, f"{tool_id} missing name"
- assert "description" in info, f"{tool_id} missing description"
- assert "fn" in info, f"{tool_id} missing fn"
- assert callable(info["fn"]), f"{tool_id} fn not callable"
diff --git a/tests/self_coding/test_modification_journal.py b/tests/self_coding/test_modification_journal.py
deleted file mode 100644
index 67c2467d..00000000
--- a/tests/self_coding/test_modification_journal.py
+++ /dev/null
@@ -1,322 +0,0 @@
-"""Tests for Modification Journal.
-
-Tests logging, querying, and metrics for self-modification attempts.
-"""
-
-from __future__ import annotations
-
-import tempfile
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-
-import pytest
-
-from self_coding.modification_journal import (
- ModificationAttempt,
- ModificationJournal,
- Outcome,
-)
-
-
-@pytest.fixture
-def temp_journal():
- """Create a ModificationJournal with temporary database."""
- with tempfile.TemporaryDirectory() as tmpdir:
- db_path = Path(tmpdir) / "journal.db"
- journal = ModificationJournal(db_path=db_path)
- yield journal
-
-
-@pytest.mark.asyncio
-class TestModificationJournalLogging:
- """Logging modification attempts."""
-
- async def test_log_attempt_success(self, temp_journal):
- """Should log a successful attempt."""
- attempt = ModificationAttempt(
- task_description="Add error handling to health endpoint",
- approach="Use try/except block",
- files_modified=["src/app.py"],
- diff="@@ -1,3 +1,7 @@...",
- test_results="1 passed",
- outcome=Outcome.SUCCESS,
- )
-
- attempt_id = await temp_journal.log_attempt(attempt)
-
- assert attempt_id > 0
-
- async def test_log_attempt_failure(self, temp_journal):
- """Should log a failed attempt."""
- attempt = ModificationAttempt(
- task_description="Refactor database layer",
- approach="Extract connection pool",
- files_modified=["src/db.py", "src/models.py"],
- diff="@@ ...",
- test_results="2 failed",
- outcome=Outcome.FAILURE,
- failure_analysis="Circular dependency introduced",
- retry_count=2,
- )
-
- attempt_id = await temp_journal.log_attempt(attempt)
-
- # Retrieve and verify
- retrieved = await temp_journal.get_by_id(attempt_id)
- assert retrieved is not None
- assert retrieved.outcome == Outcome.FAILURE
- assert retrieved.failure_analysis == "Circular dependency introduced"
- assert retrieved.retry_count == 2
-
-
-@pytest.mark.asyncio
-class TestModificationJournalRetrieval:
- """Retrieving logged attempts."""
-
- async def test_get_by_id(self, temp_journal):
- """Should retrieve attempt by ID."""
- attempt = ModificationAttempt(
- task_description="Fix bug",
- outcome=Outcome.SUCCESS,
- )
-
- attempt_id = await temp_journal.log_attempt(attempt)
- retrieved = await temp_journal.get_by_id(attempt_id)
-
- assert retrieved is not None
- assert retrieved.task_description == "Fix bug"
- assert retrieved.id == attempt_id
-
- async def test_get_by_id_not_found(self, temp_journal):
- """Should return None for non-existent ID."""
- result = await temp_journal.get_by_id(9999)
-
- assert result is None
-
- async def test_find_similar_basic(self, temp_journal):
- """Should find similar attempts by keyword."""
- # Log some attempts
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Add error handling to API endpoints",
- outcome=Outcome.SUCCESS,
- ))
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Add logging to database queries",
- outcome=Outcome.SUCCESS,
- ))
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Fix CSS styling on homepage",
- outcome=Outcome.FAILURE,
- ))
-
- # Search for error handling
- similar = await temp_journal.find_similar("error handling in endpoints", limit=3)
-
- assert len(similar) > 0
- # Should find the API error handling attempt first
- assert "error" in similar[0].task_description.lower()
-
- async def test_find_similar_filter_outcome(self, temp_journal):
- """Should filter by outcome when specified."""
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Database optimization",
- outcome=Outcome.SUCCESS,
- ))
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Database refactoring",
- outcome=Outcome.FAILURE,
- ))
-
- # Search only for successes
- similar = await temp_journal.find_similar(
- "database work",
- include_outcomes=[Outcome.SUCCESS],
- )
-
- assert len(similar) == 1
- assert similar[0].outcome == Outcome.SUCCESS
-
- async def test_find_similar_empty(self, temp_journal):
- """Should return empty list when no matches."""
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Fix bug",
- outcome=Outcome.SUCCESS,
- ))
-
- similar = await temp_journal.find_similar("xyzqwerty unicorn astronaut", limit=5)
-
- assert similar == []
-
-
-@pytest.mark.asyncio
-class TestModificationJournalMetrics:
- """Success rate metrics."""
-
- async def test_get_success_rate_empty(self, temp_journal):
- """Should handle empty journal."""
- metrics = await temp_journal.get_success_rate()
-
- assert metrics["overall"] == 0.0
- assert metrics["total"] == 0
-
- async def test_get_success_rate_calculated(self, temp_journal):
- """Should calculate success rate correctly."""
- # Log various outcomes
- for _ in range(5):
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Success task",
- outcome=Outcome.SUCCESS,
- ))
- for _ in range(3):
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Failure task",
- outcome=Outcome.FAILURE,
- ))
- for _ in range(2):
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Rollback task",
- outcome=Outcome.ROLLBACK,
- ))
-
- metrics = await temp_journal.get_success_rate()
-
- assert metrics["success"] == 5
- assert metrics["failure"] == 3
- assert metrics["rollback"] == 2
- assert metrics["total"] == 10
- assert metrics["overall"] == 0.5 # 5/10
-
- async def test_get_recent_failures(self, temp_journal):
- """Should get recent failures."""
- # Log failures and successes (last one is most recent)
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Rollback attempt",
- outcome=Outcome.ROLLBACK,
- ))
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Success",
- outcome=Outcome.SUCCESS,
- ))
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Failed attempt",
- outcome=Outcome.FAILURE,
- ))
-
- failures = await temp_journal.get_recent_failures(limit=10)
-
- assert len(failures) == 2
- # Most recent first (Failure was logged last)
- assert failures[0].outcome == Outcome.FAILURE
- assert failures[1].outcome == Outcome.ROLLBACK
-
-
-@pytest.mark.asyncio
-class TestModificationJournalUpdates:
- """Updating logged attempts."""
-
- async def test_update_reflection(self, temp_journal):
- """Should update reflection for an attempt."""
- attempt = ModificationAttempt(
- task_description="Test task",
- outcome=Outcome.SUCCESS,
- )
-
- attempt_id = await temp_journal.log_attempt(attempt)
-
- # Update reflection
- success = await temp_journal.update_reflection(
- attempt_id,
- "This worked well because...",
- )
-
- assert success is True
-
- # Verify
- retrieved = await temp_journal.get_by_id(attempt_id)
- assert retrieved.reflection == "This worked well because..."
-
- async def test_update_reflection_not_found(self, temp_journal):
- """Should return False for non-existent ID."""
- success = await temp_journal.update_reflection(9999, "Reflection")
-
- assert success is False
-
-
-@pytest.mark.asyncio
-class TestModificationJournalFileTracking:
- """Tracking attempts by file."""
-
- async def test_get_attempts_for_file(self, temp_journal):
- """Should find all attempts that modified a file."""
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Fix app.py",
- files_modified=["src/app.py", "src/config.py"],
- outcome=Outcome.SUCCESS,
- ))
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Update config only",
- files_modified=["src/config.py"],
- outcome=Outcome.SUCCESS,
- ))
- await temp_journal.log_attempt(ModificationAttempt(
- task_description="Other file",
- files_modified=["src/other.py"],
- outcome=Outcome.SUCCESS,
- ))
-
- app_attempts = await temp_journal.get_attempts_for_file("src/app.py")
-
- assert len(app_attempts) == 1
- assert "src/app.py" in app_attempts[0].files_modified
-
-
-@pytest.mark.asyncio
-class TestModificationJournalIntegration:
- """Full workflow integration tests."""
-
- async def test_full_workflow(self, temp_journal):
- """Complete workflow: log, find similar, get metrics."""
- # Log some attempts
- for i in range(3):
- await temp_journal.log_attempt(ModificationAttempt(
- task_description=f"Database optimization {i}",
- approach="Add indexes",
- files_modified=["src/db.py"],
- outcome=Outcome.SUCCESS if i % 2 == 0 else Outcome.FAILURE,
- ))
-
- # Find similar
- similar = await temp_journal.find_similar("optimize database queries", limit=5)
- assert len(similar) == 3
-
- # Get success rate
- metrics = await temp_journal.get_success_rate()
- assert metrics["total"] == 3
- assert metrics["success"] == 2
-
- # Get recent failures
- failures = await temp_journal.get_recent_failures(limit=5)
- assert len(failures) == 1
-
- # Get attempts for file
- file_attempts = await temp_journal.get_attempts_for_file("src/db.py")
- assert len(file_attempts) == 3
-
- async def test_persistence(self):
- """Should persist across instances."""
- with tempfile.TemporaryDirectory() as tmpdir:
- db_path = Path(tmpdir) / "persist.db"
-
- # First instance
- journal1 = ModificationJournal(db_path=db_path)
- attempt_id = await journal1.log_attempt(ModificationAttempt(
- task_description="Persistent attempt",
- outcome=Outcome.SUCCESS,
- ))
-
- # Second instance with same database
- journal2 = ModificationJournal(db_path=db_path)
- retrieved = await journal2.get_by_id(attempt_id)
-
- assert retrieved is not None
- assert retrieved.task_description == "Persistent attempt"
diff --git a/tests/self_coding/test_self_coding_dashboard.py b/tests/self_coding/test_self_coding_dashboard.py
deleted file mode 100644
index 43d6200b..00000000
--- a/tests/self_coding/test_self_coding_dashboard.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""Tests for Self-Coding Dashboard Routes.
-
-Tests API endpoints and HTMX views.
-"""
-
-from __future__ import annotations
-
-import pytest
-from fastapi.testclient import TestClient
-
-
-@pytest.fixture
-def client():
- """Create test client."""
- from dashboard.app import app
- return TestClient(app)
-
-
-class TestSelfCodingPageRoutes:
- """Page route tests."""
-
- def test_main_page_loads(self, client):
- """Main self-coding page should load."""
- response = client.get("/self-coding")
- assert response.status_code == 200
- assert "Self-Coding" in response.text
-
- def test_journal_partial(self, client):
- """Journal partial should return HTML."""
- response = client.get("/self-coding/journal")
- assert response.status_code == 200
- # Should contain journal list or empty message
- assert "journal" in response.text.lower() or "no entries" in response.text.lower()
-
- def test_stats_partial(self, client):
- """Stats partial should return HTML."""
- response = client.get("/self-coding/stats")
- assert response.status_code == 200
- # Should contain stats cards
- assert "Total Attempts" in response.text or "success rate" in response.text.lower()
-
- def test_execute_form_partial(self, client):
- """Execute form partial should return HTML."""
- response = client.get("/self-coding/execute-form")
- assert response.status_code == 200
- assert "Task Description" in response.text
- assert "textarea" in response.text
-
-
-class TestSelfCodingAPIRoutes:
- """API route tests."""
-
- def test_api_journal_list(self, client):
- """API should return journal entries."""
- response = client.get("/self-coding/api/journal")
- assert response.status_code == 200
-
- data = response.json()
- assert isinstance(data, list)
-
- def test_api_journal_list_with_limit(self, client):
- """API should respect limit parameter."""
- response = client.get("/self-coding/api/journal?limit=5")
- assert response.status_code == 200
-
- data = response.json()
- assert isinstance(data, list)
- assert len(data) <= 5
-
- def test_api_journal_detail_not_found(self, client):
- """API should return 404 for non-existent entry."""
- response = client.get("/self-coding/api/journal/99999")
- assert response.status_code == 404
-
- def test_api_stats(self, client):
- """API should return stats."""
- response = client.get("/self-coding/api/stats")
- assert response.status_code == 200
-
- data = response.json()
- assert "total_attempts" in data
- assert "success_rate" in data
- assert "recent_failures" in data
-
- def test_api_codebase_summary(self, client):
- """API should return codebase summary."""
- response = client.get("/self-coding/api/codebase/summary")
- assert response.status_code == 200
-
- data = response.json()
- assert "summary" in data
-
- def test_api_codebase_reindex(self, client):
- """API should trigger reindex."""
- response = client.post("/self-coding/api/codebase/reindex")
- assert response.status_code == 200
-
- data = response.json()
- assert "indexed" in data
- assert "failed" in data
- assert "skipped" in data
-
-
-class TestSelfCodingExecuteEndpoint:
- """Execute endpoint tests."""
-
- def test_execute_api_endpoint(self, client):
- """Execute API endpoint should accept task."""
- # Note: This will actually try to execute, which may fail
- # In production, this should be mocked or require auth
- response = client.post(
- "/self-coding/api/execute",
- json={"task_description": "Test task that will fail preflight"}
- )
-
- # Should return response (success or failure)
- assert response.status_code == 200
-
- data = response.json()
- assert "success" in data
- assert "message" in data
-
- def test_execute_htmx_endpoint(self, client):
- """Execute HTMX endpoint should accept form data."""
- response = client.post(
- "/self-coding/execute",
- data={"task_description": "Test task that will fail preflight"}
- )
-
- # Should return HTML response
- assert response.status_code == 200
- assert "text/html" in response.headers["content-type"]
-
-
-class TestSelfCodingNavigation:
- """Navigation integration tests."""
-
- def test_nav_link_in_header(self, client):
- """Self-coding link should be in header."""
- response = client.get("/")
- assert response.status_code == 200
- assert "/self-coding" in response.text
- assert "SELF-CODING" in response.text
diff --git a/tests/self_coding/test_self_coding_integration.py b/tests/self_coding/test_self_coding_integration.py
deleted file mode 100644
index 5e17cca6..00000000
--- a/tests/self_coding/test_self_coding_integration.py
+++ /dev/null
@@ -1,479 +0,0 @@
-"""End-to-end integration tests for Self-Coding layer.
-
-Tests the complete workflow: GitSafety + CodebaseIndexer + ModificationJournal + Reflection
-working together.
-"""
-
-from __future__ import annotations
-
-import tempfile
-from pathlib import Path
-
-import pytest
-
-from self_coding import (
- CodebaseIndexer,
- GitSafety,
- ModificationAttempt,
- ModificationJournal,
- Outcome,
- ReflectionService,
- Snapshot,
-)
-
-
-@pytest.fixture
-def self_coding_env():
- """Create a complete self-coding environment with temp repo."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
-
- # Initialize git repo
- import subprocess
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(
- ["git", "config", "user.email", "test@test.com"],
- cwd=repo_path, check=True, capture_output=True,
- )
- subprocess.run(
- ["git", "config", "user.name", "Test User"],
- cwd=repo_path, check=True, capture_output=True,
- )
- subprocess.run(
- ["git", "config", "commit.gpgsign", "false"],
- cwd=repo_path, check=True, capture_output=True,
- )
-
- # Create src directory with real Python files
- src_path = repo_path / "src" / "myproject"
- src_path.mkdir(parents=True)
-
- (src_path / "__init__.py").write_text("")
- (src_path / "calculator.py").write_text('''
-"""A simple calculator module."""
-
-class Calculator:
- """Basic calculator with add/subtract."""
-
- def add(self, a: int, b: int) -> int:
- return a + b
-
- def subtract(self, a: int, b: int) -> int:
- return a - b
-''')
-
- (src_path / "utils.py").write_text('''
-"""Utility functions."""
-
-from myproject.calculator import Calculator
-
-
-def calculate_total(items: list[int]) -> int:
- calc = Calculator()
- return sum(calc.add(0, item) for item in items)
-''')
-
- # Create tests
- tests_path = repo_path / "tests"
- tests_path.mkdir()
-
- (tests_path / "test_calculator.py").write_text('''
-"""Tests for calculator."""
-
-from myproject.calculator import Calculator
-
-
-def test_add():
- calc = Calculator()
- assert calc.add(2, 3) == 5
-
-
-def test_subtract():
- calc = Calculator()
- assert calc.subtract(5, 3) == 2
-''')
-
- # Initial commit
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "Initial commit"],
- cwd=repo_path, check=True, capture_output=True,
- )
- subprocess.run(
- ["git", "branch", "-M", "main"],
- cwd=repo_path, check=True, capture_output=True,
- )
-
- # Initialize services
- git = GitSafety(
- repo_path=repo_path,
- main_branch="main",
- test_command="python -m pytest tests/ -v",
- )
- indexer = CodebaseIndexer(
- repo_path=repo_path,
- db_path=repo_path / "codebase.db",
- src_dirs=["src", "tests"],
- )
- journal = ModificationJournal(db_path=repo_path / "journal.db")
- reflection = ReflectionService(llm_adapter=None)
-
- yield {
- "repo_path": repo_path,
- "git": git,
- "indexer": indexer,
- "journal": journal,
- "reflection": reflection,
- }
-
-
-@pytest.mark.asyncio
-class TestSelfCodingGreenPath:
- """Happy path: successful self-modification workflow."""
-
- async def test_complete_successful_modification(self, self_coding_env):
- """Full workflow: snapshot → branch → modify → test → commit → merge → log → reflect."""
- env = self_coding_env
- git = env["git"]
- indexer = env["indexer"]
- journal = env["journal"]
- reflection = env["reflection"]
- repo_path = env["repo_path"]
-
- # 1. Index codebase to understand structure
- await indexer.index_all()
-
- # 2. Find relevant files for task
- files = await indexer.get_relevant_files("add multiply method to calculator", limit=3)
- assert "src/myproject/calculator.py" in files
-
- # 3. Check for similar past attempts
- similar = await journal.find_similar("add multiply method", limit=5)
- # Should be empty (first attempt)
-
- # 4. Take snapshot
- snapshot = await git.snapshot(run_tests=False)
- assert isinstance(snapshot, Snapshot)
-
- # 5. Create feature branch
- branch_name = "timmy/self-edit/add-multiply"
- branch = await git.create_branch(branch_name)
- assert branch == branch_name
-
- # 6. Make modification (simulate adding multiply method)
- calc_path = repo_path / "src" / "myproject" / "calculator.py"
- content = calc_path.read_text()
- new_method = '''
- def multiply(self, a: int, b: int) -> int:
- """Multiply two numbers."""
- return a * b
-'''
- # Insert before last method
- content = content.rstrip() + "\n" + new_method + "\n"
- calc_path.write_text(content)
-
- # 7. Add test for new method
- test_path = repo_path / "tests" / "test_calculator.py"
- test_content = test_path.read_text()
- new_test = '''
-
-def test_multiply():
- calc = Calculator()
- assert calc.multiply(3, 4) == 12
-'''
- test_path.write_text(test_content.rstrip() + new_test + "\n")
-
- # 8. Commit changes
- commit_hash = await git.commit(
- "Add multiply method to Calculator",
- ["src/myproject/calculator.py", "tests/test_calculator.py"],
- )
- assert len(commit_hash) == 40
-
- # 9. Merge to main (skipping actual test run for speed)
- merge_hash = await git.merge_to_main(branch, require_tests=False)
- assert merge_hash != snapshot.commit_hash
-
- # 10. Log the successful attempt
- diff = await git.get_diff(snapshot.commit_hash)
- attempt = ModificationAttempt(
- task_description="Add multiply method to Calculator",
- approach="Added multiply method with docstring and test",
- files_modified=["src/myproject/calculator.py", "tests/test_calculator.py"],
- diff=diff[:1000], # Truncate for storage
- test_results="Tests passed",
- outcome=Outcome.SUCCESS,
- )
- attempt_id = await journal.log_attempt(attempt)
-
- # 11. Generate reflection
- reflection_text = await reflection.reflect_on_attempt(attempt)
- assert "What went well" in reflection_text
-
- await journal.update_reflection(attempt_id, reflection_text)
-
- # 12. Verify final state
- final_commit = await git.get_current_commit()
- assert final_commit == merge_hash
-
- # Verify we're on main branch
- current_branch = await git.get_current_branch()
- assert current_branch == "main"
-
- # Verify multiply method exists
- final_content = calc_path.read_text()
- assert "def multiply" in final_content
-
- async def test_incremental_codebase_indexing(self, self_coding_env):
- """Codebase indexer should detect changes after modification."""
- env = self_coding_env
- indexer = env["indexer"]
-
- # Initial index
- stats1 = await indexer.index_all()
- assert stats1["indexed"] == 4 # __init__.py, calculator.py, utils.py, test_calculator.py
-
- # Add new file
- new_file = env["repo_path"] / "src" / "myproject" / "new_module.py"
- new_file.write_text('''
-"""New module."""
-def new_function(): pass
-''')
-
- # Incremental index should detect only the new file
- stats2 = await indexer.index_changed()
- assert stats2["indexed"] == 1
- assert stats2["skipped"] == 4
-
-
-@pytest.mark.asyncio
-class TestSelfCodingRedPaths:
- """Error paths: failures, rollbacks, and recovery."""
-
- async def test_rollback_on_test_failure(self, self_coding_env):
- """Should rollback when tests fail."""
- env = self_coding_env
- git = env["git"]
- journal = env["journal"]
- repo_path = env["repo_path"]
-
- # Take snapshot
- snapshot = await git.snapshot(run_tests=False)
- original_commit = snapshot.commit_hash
-
- # Create branch
- branch = await git.create_branch("timmy/self-edit/bad-change")
-
- # Make breaking change (remove add method)
- calc_path = repo_path / "src" / "myproject" / "calculator.py"
- calc_path.write_text('''
-"""A simple calculator module."""
-
-class Calculator:
- """Basic calculator - broken version."""
- pass
-''')
-
- await git.commit("Remove methods (breaking change)")
-
- # Log the failed attempt
- attempt = ModificationAttempt(
- task_description="Refactor Calculator class",
- approach="Remove unused methods",
- files_modified=["src/myproject/calculator.py"],
- outcome=Outcome.FAILURE,
- failure_analysis="Tests failed - removed methods that were used",
- retry_count=0,
- )
- await journal.log_attempt(attempt)
-
- # Rollback
- await git.rollback(snapshot)
-
- # Verify rollback
- current = await git.get_current_commit()
- assert current == original_commit
-
- # Verify file restored
- restored_content = calc_path.read_text()
- assert "def add" in restored_content
-
- async def test_find_similar_learns_from_failures(self, self_coding_env):
- """Should find similar past failures to avoid repeating mistakes."""
- env = self_coding_env
- journal = env["journal"]
-
- # Log a failure
- await journal.log_attempt(ModificationAttempt(
- task_description="Add division method to calculator",
- approach="Simple division without zero check",
- files_modified=["src/myproject/calculator.py"],
- outcome=Outcome.FAILURE,
- failure_analysis="ZeroDivisionError not handled",
- reflection="Always check for division by zero",
- ))
-
- # Later, try similar task
- similar = await journal.find_similar(
- "Add modulo operation to calculator",
- limit=5,
- )
-
- # Should find the past failure
- assert len(similar) > 0
- assert "division" in similar[0].task_description.lower()
-
- async def test_dependency_chain_detects_blast_radius(self, self_coding_env):
- """Should detect which files depend on modified file."""
- env = self_coding_env
- indexer = env["indexer"]
-
- await indexer.index_all()
-
- # utils.py imports from calculator.py
- deps = await indexer.get_dependency_chain("src/myproject/calculator.py")
-
- assert "src/myproject/utils.py" in deps
-
- async def test_success_rate_tracking(self, self_coding_env):
- """Should track success/failure metrics over time."""
- env = self_coding_env
- journal = env["journal"]
-
- # Log mixed outcomes
- for i in range(5):
- await journal.log_attempt(ModificationAttempt(
- task_description=f"Task {i}",
- outcome=Outcome.SUCCESS if i % 2 == 0 else Outcome.FAILURE,
- ))
-
- metrics = await journal.get_success_rate()
-
- assert metrics["total"] == 5
- assert metrics["success"] == 3
- assert metrics["failure"] == 2
- assert metrics["overall"] == 0.6
-
- async def test_journal_persists_across_instances(self, self_coding_env):
- """Journal should persist even with new service instances."""
- env = self_coding_env
- db_path = env["repo_path"] / "persistent_journal.db"
-
- # First instance logs attempt
- journal1 = ModificationJournal(db_path=db_path)
- attempt_id = await journal1.log_attempt(ModificationAttempt(
- task_description="Persistent task",
- outcome=Outcome.SUCCESS,
- ))
-
- # New instance should see the attempt
- journal2 = ModificationJournal(db_path=db_path)
- retrieved = await journal2.get_by_id(attempt_id)
-
- assert retrieved is not None
- assert retrieved.task_description == "Persistent task"
-
-
-@pytest.mark.asyncio
-class TestSelfCodingSafetyConstraints:
- """Safety constraints and validation."""
-
- async def test_only_modify_files_with_test_coverage(self, self_coding_env):
- """Should only allow modifying files that have tests."""
- env = self_coding_env
- indexer = env["indexer"]
-
- await indexer.index_all()
-
- # calculator.py has test coverage
- assert await indexer.has_test_coverage("src/myproject/calculator.py")
-
- # utils.py has no test file
- assert not await indexer.has_test_coverage("src/myproject/utils.py")
-
- async def test_cannot_delete_test_files(self, self_coding_env):
- """Safety check: should not delete test files."""
- env = self_coding_env
- git = env["git"]
- repo_path = env["repo_path"]
-
- snapshot = await git.snapshot(run_tests=False)
- branch = await git.create_branch("timmy/self-edit/bad-idea")
-
- # Try to delete test file
- test_file = repo_path / "tests" / "test_calculator.py"
- test_file.unlink()
-
- # This would be caught by safety constraints in real implementation
- # For now, verify the file is gone
- assert not test_file.exists()
-
- # Rollback should restore it
- await git.rollback(snapshot)
- assert test_file.exists()
-
- async def test_branch_naming_convention(self, self_coding_env):
- """Branches should follow naming convention."""
- env = self_coding_env
- git = env["git"]
-
- import datetime
- timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
- branch_name = f"timmy/self-edit/{timestamp}"
-
- branch = await git.create_branch(branch_name)
-
- assert branch.startswith("timmy/self-edit/")
-
-
-@pytest.mark.asyncio
-class TestSelfCodingErrorRecovery:
- """Error recovery scenarios."""
-
- async def test_git_operation_timeout_handling(self, self_coding_env):
- """Should handle git operation timeouts gracefully."""
- # This would require mocking subprocess to timeout
- # For now, verify the timeout parameter exists
- env = self_coding_env
- git = env["git"]
-
- # The _run_git method has timeout parameter
- # If a git operation times out, it raises GitOperationError
- assert hasattr(git, '_run_git')
-
- async def test_journal_handles_concurrent_writes(self, self_coding_env):
- """Journal should handle multiple rapid writes."""
- env = self_coding_env
- journal = env["journal"]
-
- # Log multiple attempts rapidly
- ids = []
- for i in range(10):
- attempt_id = await journal.log_attempt(ModificationAttempt(
- task_description=f"Concurrent task {i}",
- outcome=Outcome.SUCCESS,
- ))
- ids.append(attempt_id)
-
- # All should be unique and retrievable
- assert len(set(ids)) == 10
-
- for attempt_id in ids:
- retrieved = await journal.get_by_id(attempt_id)
- assert retrieved is not None
-
- async def test_indexer_handles_syntax_errors(self, self_coding_env):
- """Indexer should skip files with syntax errors."""
- env = self_coding_env
- indexer = env["indexer"]
- repo_path = env["repo_path"]
-
- # Create file with syntax error
- bad_file = repo_path / "src" / "myproject" / "bad_syntax.py"
- bad_file.write_text("def broken(:")
-
- stats = await indexer.index_all()
-
- # Should index good files, fail on bad one
- assert stats["failed"] == 1
- assert stats["indexed"] >= 4 # The good files
diff --git a/tests/self_coding/test_self_edit_tool.py b/tests/self_coding/test_self_edit_tool.py
deleted file mode 100644
index fee25698..00000000
--- a/tests/self_coding/test_self_edit_tool.py
+++ /dev/null
@@ -1,402 +0,0 @@
-"""Tests for Self-Edit MCP Tool.
-
-Tests the complete self-edit workflow with mocked dependencies.
-"""
-
-from __future__ import annotations
-
-import tempfile
-from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-from creative.tools.self_edit import (
- MAX_FILES_PER_COMMIT,
- MAX_RETRIES,
- PROTECTED_FILES,
- EditPlan,
- SelfEditResult,
- SelfEditTool,
- register_self_edit_tool,
- self_edit_tool,
-)
-
-
-@pytest.fixture
-def temp_repo():
- """Create a temporary git repository."""
- with tempfile.TemporaryDirectory() as tmpdir:
- repo_path = Path(tmpdir)
-
- # Initialize git
- import subprocess
- subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(
- ["git", "config", "user.email", "test@test.com"],
- cwd=repo_path, check=True, capture_output=True,
- )
- subprocess.run(
- ["git", "config", "user.name", "Test"],
- cwd=repo_path, check=True, capture_output=True,
- )
- subprocess.run(
- ["git", "config", "commit.gpgsign", "false"],
- cwd=repo_path, check=True, capture_output=True,
- )
-
- # Create src structure
- src_path = repo_path / "src" / "myproject"
- src_path.mkdir(parents=True)
-
- (src_path / "__init__.py").write_text("")
- (src_path / "app.py").write_text('''
-"""Main application."""
-
-def hello():
- return "Hello"
-''')
-
- # Create tests
- tests_path = repo_path / "tests"
- tests_path.mkdir()
- (tests_path / "test_app.py").write_text('''
-"""Tests for app."""
-from myproject.app import hello
-
-def test_hello():
- assert hello() == "Hello"
-''')
-
- # Initial commit
- subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "Initial"],
- cwd=repo_path, check=True, capture_output=True,
- )
- subprocess.run(
- ["git", "branch", "-M", "main"],
- cwd=repo_path, check=True, capture_output=True,
- )
-
- yield repo_path
-
-
-@pytest.fixture(autouse=True)
-def mock_settings():
- """Mock settings to enable self-modification."""
- with patch('creative.tools.self_edit.settings') as mock_settings:
- mock_settings.self_modify_enabled = True
- yield mock_settings
-
-
-@pytest.fixture
-def mock_llm():
- """Create mock LLM adapter."""
- mock = AsyncMock()
- mock.chat.return_value = MagicMock(
- content="""APPROACH: Add error handling
-FILES_TO_MODIFY: src/myproject/app.py
-FILES_TO_CREATE:
-TESTS_TO_ADD: tests/test_app.py
-EXPLANATION: Wrap function in try/except"""
- )
- return mock
-
-
-@pytest.mark.asyncio
-class TestSelfEditToolBasics:
- """Basic functionality tests."""
-
- async def test_initialization(self, temp_repo):
- """Should initialize with services."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- assert tool.repo_path == temp_repo
- assert tool.git is not None
- assert tool.indexer is not None
- assert tool.journal is not None
- assert tool.reflection is not None
-
- async def test_preflight_checks_clean_repo(self, temp_repo):
- """Should pass preflight on clean repo."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- assert await tool._preflight_checks() is True
-
- async def test_preflight_checks_dirty_repo(self, temp_repo):
- """Should fail preflight on dirty repo."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- # Make uncommitted change
- (temp_repo / "dirty.txt").write_text("dirty")
-
- assert await tool._preflight_checks() is False
-
- async def test_preflight_checks_wrong_branch(self, temp_repo):
- """Should fail preflight when not on main."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- # Create and checkout feature branch
- import subprocess
- subprocess.run(
- ["git", "checkout", "-b", "feature"],
- cwd=temp_repo, check=True, capture_output=True,
- )
-
- assert await tool._preflight_checks() is False
-
-
-@pytest.mark.asyncio
-class TestSelfEditToolPlanning:
- """Edit planning tests."""
-
- async def test_plan_edit_with_llm(self, temp_repo, mock_llm):
- """Should generate plan using LLM."""
- tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
- await tool._ensure_indexed()
-
- plan = await tool._plan_edit(
- task_description="Add error handling",
- relevant_files=["src/myproject/app.py"],
- similar_attempts=[],
- )
-
- assert isinstance(plan, EditPlan)
- assert plan.approach == "Add error handling"
- assert "src/myproject/app.py" in plan.files_to_modify
-
- async def test_plan_edit_without_llm(self, temp_repo):
- """Should generate fallback plan without LLM."""
- tool = SelfEditTool(repo_path=temp_repo, llm_adapter=None)
- await tool._ensure_indexed()
-
- plan = await tool._plan_edit(
- task_description="Add feature",
- relevant_files=["src/myproject/app.py"],
- similar_attempts=[],
- )
-
- assert isinstance(plan, EditPlan)
- assert len(plan.files_to_modify) > 0
-
- async def test_plan_respects_max_files(self, temp_repo, mock_llm):
- """Plan should respect MAX_FILES_PER_COMMIT."""
- tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
- await tool._ensure_indexed()
-
- # Mock LLM to return many files
- mock_llm.chat.return_value = MagicMock(
- content="FILES_TO_MODIFY: " + ",".join([f"file{i}.py" for i in range(10)])
- )
-
- plan = await tool._plan_edit(
- task_description="Test",
- relevant_files=[f"file{i}.py" for i in range(10)],
- similar_attempts=[],
- )
-
- assert len(plan.files_to_modify) <= MAX_FILES_PER_COMMIT
-
-
-@pytest.mark.asyncio
-class TestSelfEditToolValidation:
- """Safety constraint validation tests."""
-
- async def test_validate_plan_too_many_files(self, temp_repo):
- """Should reject plan with too many files."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- plan = EditPlan(
- approach="Test",
- files_to_modify=[f"file{i}.py" for i in range(MAX_FILES_PER_COMMIT + 1)],
- files_to_create=[],
- tests_to_add=[],
- explanation="Test",
- )
-
- assert tool._validate_plan(plan) is False
-
- async def test_validate_plan_protected_file(self, temp_repo):
- """Should reject plan modifying protected files."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- plan = EditPlan(
- approach="Test",
- files_to_modify=["src/tools/self_edit.py"],
- files_to_create=[],
- tests_to_add=[],
- explanation="Test",
- )
-
- assert tool._validate_plan(plan) is False
-
- async def test_validate_plan_valid(self, temp_repo):
- """Should accept valid plan."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- plan = EditPlan(
- approach="Test",
- files_to_modify=["src/myproject/app.py"],
- files_to_create=[],
- tests_to_add=[],
- explanation="Test",
- )
-
- assert tool._validate_plan(plan) is True
-
-
-@pytest.mark.asyncio
-class TestSelfEditToolExecution:
- """Edit execution tests."""
-
- async def test_strip_code_fences(self, temp_repo):
- """Should strip markdown code fences."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- content = "```python\ndef test(): pass\n```"
- result = tool._strip_code_fences(content)
-
- assert "```" not in result
- assert "def test(): pass" in result
-
- async def test_parse_list(self, temp_repo):
- """Should parse comma-separated lists."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- assert tool._parse_list("a, b, c") == ["a", "b", "c"]
- assert tool._parse_list("none") == []
- assert tool._parse_list("") == []
- assert tool._parse_list("N/A") == []
-
-
-@pytest.mark.asyncio
-class TestSelfEditToolIntegration:
- """Integration tests with mocked dependencies."""
-
- async def test_successful_edit_flow(self, temp_repo, mock_llm):
- """Test complete successful edit flow."""
- tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
-
- # Mock Aider to succeed
- with patch.object(tool, '_aider_available', return_value=False):
- with patch.object(tool, '_execute_direct_edit') as mock_exec:
- mock_exec.return_value = {
- "success": True,
- "test_output": "1 passed",
- }
-
- result = await tool.execute("Add error handling")
-
- assert result.success is True
- assert result.attempt_id is not None
-
- async def test_failed_edit_with_rollback(self, temp_repo, mock_llm):
- """Test failed edit with rollback."""
- tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
-
- # Mock execution to always fail
- with patch.object(tool, '_execute_edit') as mock_exec:
- mock_exec.return_value = {
- "success": False,
- "error": "Tests failed",
- "test_output": "1 failed",
- }
-
- result = await tool.execute("Add broken feature")
-
- assert result.success is False
- assert result.attempt_id is not None
- assert "failed" in result.message.lower() or "retry" in result.message.lower()
-
- async def test_preflight_failure(self, temp_repo):
- """Should fail early if preflight checks fail."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- # Make repo dirty
- (temp_repo / "dirty.txt").write_text("dirty")
-
- result = await tool.execute("Some task")
-
- assert result.success is False
- assert "pre-flight" in result.message.lower()
-
-
-@pytest.mark.asyncio
-class TestSelfEditMCPRegistration:
- """MCP tool registration tests."""
-
- async def test_register_self_edit_tool(self):
- """Should register with MCP registry."""
- mock_registry = MagicMock()
- mock_llm = AsyncMock()
-
- register_self_edit_tool(mock_registry, mock_llm)
-
- mock_registry.register.assert_called_once()
- call_args = mock_registry.register.call_args
-
- assert call_args.kwargs["name"] == "self_edit"
- assert call_args.kwargs["requires_confirmation"] is True
- assert "self_coding" in call_args.kwargs["category"]
-
-
-@pytest.mark.asyncio
-class TestSelfEditGlobalTool:
- """Global tool instance tests."""
-
- async def test_self_edit_tool_singleton(self, temp_repo):
- """Should use singleton pattern."""
- from creative.tools import self_edit as self_edit_module
-
- # Reset singleton
- self_edit_module._self_edit_tool = None
-
- # First call should initialize
- with patch.object(SelfEditTool, '__init__', return_value=None) as mock_init:
- mock_init.return_value = None
-
- with patch.object(SelfEditTool, 'execute') as mock_execute:
- mock_execute.return_value = SelfEditResult(
- success=True,
- message="Test",
- )
-
- await self_edit_tool("Test task")
-
- mock_init.assert_called_once()
- mock_execute.assert_called_once()
-
-
-@pytest.mark.asyncio
-class TestSelfEditErrorHandling:
- """Error handling tests."""
-
- async def test_exception_handling(self, temp_repo):
- """Should handle exceptions gracefully."""
- tool = SelfEditTool(repo_path=temp_repo)
-
- # Mock preflight to raise exception
- with patch.object(tool, '_preflight_checks', side_effect=Exception("Unexpected")):
- result = await tool.execute("Test task")
-
- assert result.success is False
- assert "exception" in result.message.lower()
-
- async def test_llm_failure_fallback(self, temp_repo, mock_llm):
- """Should fallback when LLM fails."""
- tool = SelfEditTool(repo_path=temp_repo, llm_adapter=mock_llm)
- await tool._ensure_indexed()
-
- # Mock LLM to fail
- mock_llm.chat.side_effect = Exception("LLM timeout")
-
- plan = await tool._plan_edit(
- task_description="Test",
- relevant_files=["src/app.py"],
- similar_attempts=[],
- )
-
- # Should return fallback plan
- assert isinstance(plan, EditPlan)
- assert len(plan.files_to_modify) > 0
diff --git a/tests/self_coding/test_self_modify.py b/tests/self_coding/test_self_modify.py
deleted file mode 100644
index bae02cad..00000000
--- a/tests/self_coding/test_self_modify.py
+++ /dev/null
@@ -1,430 +0,0 @@
-"""Tests for the self-modification loop (self_modify/loop.py).
-
-All tests are fully mocked — no Ollama, no real file I/O, no git.
-"""
-
-from unittest.mock import MagicMock, patch
-from pathlib import Path
-
-import pytest
-
-from self_coding.self_modify.loop import SelfModifyLoop, ModifyRequest, ModifyResult
-
-
-# ── Dataclass tests ───────────────────────────────────────────────────────────
-
-
-class TestModifyRequest:
- def test_defaults(self):
- req = ModifyRequest(instruction="Fix the bug")
- assert req.instruction == "Fix the bug"
- assert req.target_files == []
- assert req.dry_run is False
-
- def test_with_target_files(self):
- req = ModifyRequest(
- instruction="Add docstring",
- target_files=["src/foo.py"],
- dry_run=True,
- )
- assert req.target_files == ["src/foo.py"]
- assert req.dry_run is True
-
-
-class TestModifyResult:
- def test_success_result(self):
- result = ModifyResult(
- success=True,
- files_changed=["src/foo.py"],
- test_passed=True,
- commit_sha="abc12345",
- branch_name="timmy/self-modify-123",
- llm_response="...",
- attempts=1,
- )
- assert result.success
- assert result.commit_sha == "abc12345"
- assert result.error is None
- assert result.autonomous_cycles == 0
-
- def test_failure_result(self):
- result = ModifyResult(success=False, error="something broke")
- assert not result.success
- assert result.error == "something broke"
- assert result.files_changed == []
-
-
-# ── SelfModifyLoop unit tests ────────────────────────────────────────────────
-
-
-class TestSelfModifyLoop:
- def test_init_defaults(self):
- loop = SelfModifyLoop()
- assert loop._max_retries == 2
-
- def test_init_custom_retries(self):
- loop = SelfModifyLoop(max_retries=5)
- assert loop._max_retries == 5
-
- def test_init_backend(self):
- loop = SelfModifyLoop(backend="anthropic")
- assert loop._backend == "anthropic"
-
- def test_init_autonomous(self):
- loop = SelfModifyLoop(autonomous=True, max_autonomous_cycles=5)
- assert loop._autonomous is True
- assert loop._max_autonomous_cycles == 5
-
- @patch("self_coding.self_modify.loop.settings")
- def test_run_disabled(self, mock_settings):
- mock_settings.self_modify_enabled = False
- loop = SelfModifyLoop()
- result = loop.run(ModifyRequest(instruction="test"))
- assert not result.success
- assert "disabled" in result.error.lower()
-
- @patch("self_coding.self_modify.loop.os.environ", {"SELF_MODIFY_SKIP_BRANCH": "1"})
- @patch("self_coding.self_modify.loop.settings")
- def test_run_no_target_files(self, mock_settings):
- mock_settings.self_modify_enabled = True
- mock_settings.self_modify_max_retries = 0
- mock_settings.self_modify_allowed_dirs = "src,tests"
- mock_settings.self_modify_backend = "ollama"
- loop = SelfModifyLoop()
- loop._infer_target_files = MagicMock(return_value=[])
- result = loop.run(ModifyRequest(instruction="do something vague"))
- assert not result.success
- assert "no target files" in result.error.lower()
-
- @patch("self_coding.self_modify.loop.os.environ", {"SELF_MODIFY_SKIP_BRANCH": "1"})
- @patch("self_coding.self_modify.loop.settings")
- def test_run_success_path(self, mock_settings):
- mock_settings.self_modify_enabled = True
- mock_settings.self_modify_max_retries = 2
- mock_settings.self_modify_allowed_dirs = "src,tests"
- mock_settings.self_modify_backend = "ollama"
-
- loop = SelfModifyLoop()
- loop._read_files = MagicMock(return_value={"src/foo.py": "old content"})
- loop._generate_edits = MagicMock(
- return_value=({"src/foo.py": "x = 1\n"}, "llm raw")
- )
- loop._write_files = MagicMock(return_value=["src/foo.py"])
- loop._run_tests = MagicMock(return_value=(True, "5 passed"))
- loop._git_commit = MagicMock(return_value="abc12345")
- loop._validate_paths = MagicMock()
-
- result = loop.run(
- ModifyRequest(instruction="Add docstring", target_files=["src/foo.py"])
- )
-
- assert result.success
- assert result.test_passed
- assert result.commit_sha == "abc12345"
- assert result.files_changed == ["src/foo.py"]
- loop._run_tests.assert_called_once()
- loop._git_commit.assert_called_once()
-
- @patch("self_coding.self_modify.loop.os.environ", {"SELF_MODIFY_SKIP_BRANCH": "1"})
- @patch("self_coding.self_modify.loop.settings")
- def test_run_test_failure_reverts(self, mock_settings):
- mock_settings.self_modify_enabled = True
- mock_settings.self_modify_max_retries = 0
- mock_settings.self_modify_allowed_dirs = "src,tests"
- mock_settings.self_modify_backend = "ollama"
-
- loop = SelfModifyLoop(max_retries=0)
- loop._read_files = MagicMock(return_value={"src/foo.py": "old content"})
- loop._generate_edits = MagicMock(
- return_value=({"src/foo.py": "x = 1\n"}, "llm raw")
- )
- loop._write_files = MagicMock(return_value=["src/foo.py"])
- loop._run_tests = MagicMock(return_value=(False, "1 failed"))
- loop._revert_files = MagicMock()
- loop._validate_paths = MagicMock()
-
- result = loop.run(
- ModifyRequest(instruction="Break it", target_files=["src/foo.py"])
- )
-
- assert not result.success
- assert not result.test_passed
- loop._revert_files.assert_called()
-
- @patch("self_coding.self_modify.loop.os.environ", {"SELF_MODIFY_SKIP_BRANCH": "1"})
- @patch("self_coding.self_modify.loop.settings")
- def test_dry_run(self, mock_settings):
- mock_settings.self_modify_enabled = True
- mock_settings.self_modify_max_retries = 2
- mock_settings.self_modify_allowed_dirs = "src,tests"
- mock_settings.self_modify_backend = "ollama"
-
- loop = SelfModifyLoop()
- loop._read_files = MagicMock(return_value={"src/foo.py": "old content"})
- loop._generate_edits = MagicMock(
- return_value=({"src/foo.py": "x = 1\n"}, "llm raw")
- )
- loop._validate_paths = MagicMock()
-
- result = loop.run(
- ModifyRequest(
- instruction="Add docstring",
- target_files=["src/foo.py"],
- dry_run=True,
- )
- )
-
- assert result.success
- assert result.files_changed == ["src/foo.py"]
-
-
-# ── Syntax validation tests ─────────────────────────────────────────────────
-
-
-class TestSyntaxValidation:
- def test_valid_python_passes(self):
- loop = SelfModifyLoop()
- errors = loop._validate_syntax({"src/foo.py": "x = 1\nprint(x)\n"})
- assert errors == {}
-
- def test_invalid_python_caught(self):
- loop = SelfModifyLoop()
- errors = loop._validate_syntax({"src/foo.py": "def foo(\n"})
- assert "src/foo.py" in errors
- assert "line" in errors["src/foo.py"]
-
- def test_unterminated_string_caught(self):
- loop = SelfModifyLoop()
- bad_code = '"""\nTIMMY = """\nstuff\n"""\n'
- errors = loop._validate_syntax({"src/foo.py": bad_code})
- # This specific code is actually valid, but let's test truly broken code
- broken = '"""\nunclosed string\n'
- errors = loop._validate_syntax({"src/foo.py": broken})
- assert "src/foo.py" in errors
-
- def test_non_python_files_skipped(self):
- loop = SelfModifyLoop()
- errors = loop._validate_syntax({"README.md": "this is not python {{{}"})
- assert errors == {}
-
- @patch("self_coding.self_modify.loop.os.environ", {"SELF_MODIFY_SKIP_BRANCH": "1"})
- @patch("self_coding.self_modify.loop.settings")
- def test_syntax_error_skips_write(self, mock_settings):
- """When LLM produces invalid syntax, we skip writing and retry."""
- mock_settings.self_modify_enabled = True
- mock_settings.self_modify_max_retries = 1
- mock_settings.self_modify_allowed_dirs = "src,tests"
- mock_settings.self_modify_backend = "ollama"
-
- loop = SelfModifyLoop(max_retries=1)
- loop._read_files = MagicMock(return_value={"src/foo.py": "x = 1\n"})
- # First call returns broken syntax, second returns valid
- loop._generate_edits = MagicMock(side_effect=[
- ({"src/foo.py": "def foo(\n"}, "bad llm"),
- ({"src/foo.py": "def foo():\n pass\n"}, "good llm"),
- ])
- loop._write_files = MagicMock(return_value=["src/foo.py"])
- loop._run_tests = MagicMock(return_value=(True, "passed"))
- loop._git_commit = MagicMock(return_value="abc123")
- loop._validate_paths = MagicMock()
-
- result = loop.run(
- ModifyRequest(instruction="Fix foo", target_files=["src/foo.py"])
- )
-
- assert result.success
- # _write_files should only be called once (for the valid attempt)
- loop._write_files.assert_called_once()
-
-
-# ── Multi-backend tests ──────────────────────────────────────────────────────
-
-
-class TestBackendResolution:
- def test_resolve_ollama(self):
- loop = SelfModifyLoop(backend="ollama")
- assert loop._resolve_backend() == "ollama"
-
- def test_resolve_anthropic(self):
- loop = SelfModifyLoop(backend="anthropic")
- assert loop._resolve_backend() == "anthropic"
-
- @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-123"})
- def test_resolve_auto_with_key(self):
- loop = SelfModifyLoop(backend="auto")
- assert loop._resolve_backend() == "anthropic"
-
- @patch.dict("os.environ", {}, clear=True)
- def test_resolve_auto_without_key(self):
- loop = SelfModifyLoop(backend="auto")
- assert loop._resolve_backend() == "ollama"
-
-
-# ── Autonomous loop tests ────────────────────────────────────────────────────
-
-
-class TestAutonomousLoop:
- @patch("self_coding.self_modify.loop.os.environ", {"SELF_MODIFY_SKIP_BRANCH": "1"})
- @patch("self_coding.self_modify.loop.settings")
- def test_autonomous_retries_after_failure(self, mock_settings):
- mock_settings.self_modify_enabled = True
- mock_settings.self_modify_max_retries = 0
- mock_settings.self_modify_allowed_dirs = "src,tests"
- mock_settings.self_modify_backend = "ollama"
-
- loop = SelfModifyLoop(max_retries=0, autonomous=True, max_autonomous_cycles=2)
- loop._validate_paths = MagicMock()
- loop._read_files = MagicMock(return_value={"src/foo.py": "x = 1\n"})
-
- # First run fails, autonomous cycle 1 succeeds
- call_count = [0]
-
- def fake_generate(instruction, contents, prev_test_output=None, prev_syntax_errors=None):
- call_count[0] += 1
- return ({"src/foo.py": "x = 2\n"}, "llm raw")
-
- loop._generate_edits = MagicMock(side_effect=fake_generate)
- loop._write_files = MagicMock(return_value=["src/foo.py"])
- loop._revert_files = MagicMock()
-
- # First call fails tests, second succeeds
- test_results = [(False, "FAILED"), (True, "PASSED")]
- loop._run_tests = MagicMock(side_effect=test_results)
- loop._git_commit = MagicMock(return_value="abc123")
- loop._diagnose_failure = MagicMock(return_value="Fix: do X instead of Y")
-
- result = loop.run(
- ModifyRequest(instruction="Fix foo", target_files=["src/foo.py"])
- )
-
- assert result.success
- assert result.autonomous_cycles == 1
- loop._diagnose_failure.assert_called_once()
-
- def test_diagnose_failure_reads_report(self, tmp_path):
- report = tmp_path / "report.md"
- report.write_text("# Report\n**Error:** SyntaxError line 5\n")
-
- loop = SelfModifyLoop(backend="ollama")
- loop._call_llm = MagicMock(return_value="ROOT CAUSE: Missing closing paren")
-
- diagnosis = loop._diagnose_failure(report)
- assert "Missing closing paren" in diagnosis
- loop._call_llm.assert_called_once()
-
- def test_diagnose_failure_handles_missing_report(self, tmp_path):
- loop = SelfModifyLoop(backend="ollama")
- result = loop._diagnose_failure(tmp_path / "nonexistent.md")
- assert result is None
-
-
-# ── Path validation tests ─────────────────────────────────────────────────────
-
-
-class TestPathValidation:
- def test_rejects_path_outside_repo(self):
- loop = SelfModifyLoop(repo_path=Path("/tmp/test-repo"))
- with pytest.raises(ValueError, match="escapes repository"):
- loop._validate_paths(["../../etc/passwd"])
-
- def test_rejects_path_outside_allowed_dirs(self):
- loop = SelfModifyLoop(repo_path=Path("/tmp/test-repo"))
- with pytest.raises(ValueError, match="not in allowed directories"):
- loop._validate_paths(["docs/secret.py"])
-
- def test_accepts_src_path(self):
- loop = SelfModifyLoop(repo_path=Path("/tmp/test-repo"))
- loop._validate_paths(["src/some_module.py"])
-
- def test_accepts_tests_path(self):
- loop = SelfModifyLoop(repo_path=Path("/tmp/test-repo"))
- loop._validate_paths(["tests/test_something.py"])
-
-
-# ── File inference tests ──────────────────────────────────────────────────────
-
-
-class TestFileInference:
- def test_infer_explicit_py_path(self):
- loop = SelfModifyLoop()
- files = loop._infer_target_files("fix bug in src/dashboard/app.py")
- assert "src/dashboard/app.py" in files
-
- def test_infer_from_keyword_config(self):
- loop = SelfModifyLoop()
- files = loop._infer_target_files("update the config to add a new setting")
- assert "src/config.py" in files
-
- def test_infer_from_keyword_agent(self):
- loop = SelfModifyLoop()
- files = loop._infer_target_files("modify the agent prompt")
- assert "src/timmy/agent.py" in files
-
- def test_infer_returns_empty_for_vague(self):
- loop = SelfModifyLoop()
- files = loop._infer_target_files("do something cool")
- assert files == []
-
-
-# ── NLU intent tests ──────────────────────────────────────────────────────────
-
-
-class TestCodeIntent:
- def test_detects_modify_code(self):
- from integrations.voice.nlu import detect_intent
-
- intent = detect_intent("modify the code in config.py")
- assert intent.name == "code"
-
- def test_detects_self_modify(self):
- from integrations.voice.nlu import detect_intent
-
- intent = detect_intent("self-modify to add a new endpoint")
- assert intent.name == "code"
-
- def test_detects_edit_source(self):
- from integrations.voice.nlu import detect_intent
-
- intent = detect_intent("edit the source to fix the bug")
- assert intent.name == "code"
-
- def test_detects_update_your_code(self):
- from integrations.voice.nlu import detect_intent
-
- intent = detect_intent("update your code to handle errors")
- assert intent.name == "code"
-
- def test_detects_fix_function(self):
- from integrations.voice.nlu import detect_intent
-
- intent = detect_intent("fix the function that calculates totals")
- assert intent.name == "code"
-
- def test_does_not_match_general_chat(self):
- from integrations.voice.nlu import detect_intent
-
- intent = detect_intent("tell me about the weather today")
- assert intent.name == "chat"
-
- def test_extracts_target_file_entity(self):
- from integrations.voice.nlu import detect_intent
-
- intent = detect_intent("modify file src/config.py to add debug flag")
- assert intent.entities.get("target_file") == "src/config.py"
-
-
-# ── Route tests ───────────────────────────────────────────────────────────────
-
-
-class TestSelfModifyRoutes:
- def test_status_endpoint(self, client):
- resp = client.get("/self-modify/status")
- assert resp.status_code == 200
- data = resp.json()
- assert "enabled" in data
- assert data["enabled"] is False # Default
-
- def test_run_when_disabled(self, client):
- resp = client.post("/self-modify/run", data={"instruction": "test"})
- assert resp.status_code == 403
-
diff --git a/tests/self_coding/test_watchdog.py b/tests/self_coding/test_watchdog.py
deleted file mode 100644
index 0e43a2c7..00000000
--- a/tests/self_coding/test_watchdog.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from unittest.mock import MagicMock, patch
-
-from self_coding.self_tdd.watchdog import _run_tests
-
-
-def _mock_result(returncode: int, stdout: str = "", stderr: str = "") -> MagicMock:
- m = MagicMock()
- m.returncode = returncode
- m.stdout = stdout
- m.stderr = stderr
- return m
-
-
-def test_run_tests_returns_true_when_suite_passes():
- with patch("self_coding.self_tdd.watchdog.subprocess.run", return_value=_mock_result(0, "5 passed")):
- passed, _ = _run_tests()
- assert passed is True
-
-
-def test_run_tests_returns_false_when_suite_fails():
- with patch("self_coding.self_tdd.watchdog.subprocess.run", return_value=_mock_result(1, "1 failed")):
- passed, _ = _run_tests()
- assert passed is False
-
-
-def test_run_tests_output_includes_stdout():
- with patch("self_coding.self_tdd.watchdog.subprocess.run", return_value=_mock_result(0, stdout="5 passed")):
- _, output = _run_tests()
- assert "5 passed" in output
-
-
-def test_run_tests_output_combines_stdout_and_stderr():
- with patch(
- "self_coding.self_tdd.watchdog.subprocess.run",
- return_value=_mock_result(1, stdout="FAILED test_foo", stderr="ImportError: no module named bar"),
- ):
- _, output = _run_tests()
- assert "FAILED test_foo" in output
- assert "ImportError" in output
-
-
-def test_run_tests_invokes_pytest_with_correct_flags():
- with patch("self_coding.self_tdd.watchdog.subprocess.run", return_value=_mock_result(0)) as mock_run:
- _run_tests()
- cmd = mock_run.call_args[0][0]
- assert "pytest" in cmd
- assert "tests/" in cmd
- assert "--tb=short" in cmd
-
-
-def test_run_tests_uses_60s_timeout():
- with patch("self_coding.self_tdd.watchdog.subprocess.run", return_value=_mock_result(0)) as mock_run:
- _run_tests()
- assert mock_run.call_args.kwargs["timeout"] == 60
diff --git a/tests/self_coding/test_watchdog_functional.py b/tests/self_coding/test_watchdog_functional.py
deleted file mode 100644
index 4193fcef..00000000
--- a/tests/self_coding/test_watchdog_functional.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""Functional tests for self_tdd.watchdog — continuous test runner.
-
-All subprocess calls are mocked to avoid running real pytest.
-"""
-
-from unittest.mock import patch, MagicMock, call
-
-import pytest
-
-from self_coding.self_tdd.watchdog import _run_tests, watch
-
-
-class TestRunTests:
- @patch("self_coding.self_tdd.watchdog.subprocess.run")
- def test_run_tests_passing(self, mock_run):
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout="5 passed\n",
- stderr="",
- )
- passed, output = _run_tests()
- assert passed is True
- assert "5 passed" in output
-
- @patch("self_coding.self_tdd.watchdog.subprocess.run")
- def test_run_tests_failing(self, mock_run):
- mock_run.return_value = MagicMock(
- returncode=1,
- stdout="2 failed, 3 passed\n",
- stderr="ERRORS",
- )
- passed, output = _run_tests()
- assert passed is False
- assert "2 failed" in output
- assert "ERRORS" in output
-
- @patch("self_coding.self_tdd.watchdog.subprocess.run")
- def test_run_tests_command_format(self, mock_run):
- mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
- _run_tests()
- cmd = mock_run.call_args[0][0]
- assert "pytest" in " ".join(cmd)
- assert "tests/" in cmd
- assert "-q" in cmd
- assert "--tb=short" in cmd
- assert mock_run.call_args[1]["capture_output"] is True
- assert mock_run.call_args[1]["text"] is True
-
-
-class TestWatch:
- @patch("self_coding.self_tdd.watchdog.time.sleep")
- @patch("self_coding.self_tdd.watchdog._run_tests")
- @patch("self_coding.self_tdd.watchdog.typer")
- def test_watch_first_pass(self, mock_typer, mock_tests, mock_sleep):
- """First iteration: None→passing → should print green message."""
- call_count = 0
-
- def side_effect():
- nonlocal call_count
- call_count += 1
- if call_count >= 2:
- raise KeyboardInterrupt
- return (True, "all good")
-
- mock_tests.side_effect = side_effect
- watch(interval=10)
- # Should have printed green "All tests passing" message
- mock_typer.secho.assert_called()
-
- @patch("self_coding.self_tdd.watchdog.time.sleep")
- @patch("self_coding.self_tdd.watchdog._run_tests")
- @patch("self_coding.self_tdd.watchdog.typer")
- def test_watch_regression(self, mock_typer, mock_tests, mock_sleep):
- """Regression: passing→failing → should print red message + output."""
- results = [(True, "ok"), (False, "FAILED: test_foo"), KeyboardInterrupt]
- idx = 0
-
- def side_effect():
- nonlocal idx
- if idx >= len(results):
- raise KeyboardInterrupt
- r = results[idx]
- idx += 1
- if isinstance(r, type) and issubclass(r, BaseException):
- raise r()
- return r
-
- mock_tests.side_effect = side_effect
- watch(interval=5)
- # Should have printed red "Regression detected" at some point
- secho_calls = [str(c) for c in mock_typer.secho.call_args_list]
- assert any("Regression" in c for c in secho_calls) or any("RED" in c for c in secho_calls)
-
- @patch("self_coding.self_tdd.watchdog.time.sleep")
- @patch("self_coding.self_tdd.watchdog._run_tests")
- @patch("self_coding.self_tdd.watchdog.typer")
- def test_watch_keyboard_interrupt(self, mock_typer, mock_tests, mock_sleep):
- mock_tests.side_effect = KeyboardInterrupt
- watch(interval=60)
- mock_typer.echo.assert_called() # "Watchdog stopped"
diff --git a/tests/spark/test_event_log.py b/tests/spark/test_event_log.py
deleted file mode 100644
index 9936d8f3..00000000
--- a/tests/spark/test_event_log.py
+++ /dev/null
@@ -1,169 +0,0 @@
-"""Tests for swarm event logging system."""
-
-import pytest
-from datetime import datetime, timezone
-from swarm.event_log import (
- EventType,
- log_event,
- get_event,
- list_events,
- get_task_events,
- get_agent_events,
- get_recent_events,
- get_event_summary,
- prune_events,
-)
-
-
-class TestEventLog:
- """Test suite for event logging functionality."""
-
- def test_log_simple_event(self):
- """Test logging a basic event."""
- event = log_event(
- event_type=EventType.SYSTEM_INFO,
- source="test",
- data={"message": "test event"},
- )
-
- assert event.event_type == EventType.SYSTEM_INFO
- assert event.source == "test"
- assert event.data is not None
-
- # Verify we can retrieve it
- retrieved = get_event(event.id)
- assert retrieved is not None
- assert retrieved.source == "test"
-
- def test_log_task_event(self):
- """Test logging a task lifecycle event."""
- task_id = "task-123"
- agent_id = "agent-456"
-
- event = log_event(
- event_type=EventType.TASK_ASSIGNED,
- source="coordinator",
- task_id=task_id,
- agent_id=agent_id,
- data={"bid_sats": 100},
- )
-
- assert event.task_id == task_id
- assert event.agent_id == agent_id
-
- # Verify filtering by task works
- task_events = get_task_events(task_id)
- assert len(task_events) >= 1
- assert any(e.id == event.id for e in task_events)
-
- def test_log_agent_event(self):
- """Test logging agent lifecycle events."""
- agent_id = "agent-test-001"
-
- event = log_event(
- event_type=EventType.AGENT_JOINED,
- source="coordinator",
- agent_id=agent_id,
- data={"persona_id": "forge"},
- )
-
- # Verify filtering by agent works
- agent_events = get_agent_events(agent_id)
- assert len(agent_events) >= 1
- assert any(e.id == event.id for e in agent_events)
-
- def test_list_events_filtering(self):
- """Test filtering events by type."""
- # Create events of different types
- log_event(EventType.TASK_CREATED, source="test")
- log_event(EventType.TASK_COMPLETED, source="test")
- log_event(EventType.SYSTEM_INFO, source="test")
-
- # Filter by type
- task_events = list_events(event_type=EventType.TASK_CREATED, limit=10)
- assert all(e.event_type == EventType.TASK_CREATED for e in task_events)
-
- # Filter by source
- source_events = list_events(source="test", limit=10)
- assert all(e.source == "test" for e in source_events)
-
- def test_get_recent_events(self):
- """Test retrieving recent events."""
- # Log an event
- log_event(EventType.SYSTEM_INFO, source="recent_test")
-
- # Get events from last minute
- recent = get_recent_events(minutes=1)
- assert any(e.source == "recent_test" for e in recent)
-
- def test_event_summary(self):
- """Test event summary statistics."""
- # Create some events
- log_event(EventType.TASK_CREATED, source="summary_test")
- log_event(EventType.TASK_CREATED, source="summary_test")
- log_event(EventType.TASK_COMPLETED, source="summary_test")
-
- # Get summary
- summary = get_event_summary(minutes=1)
- assert "task.created" in summary or "task.completed" in summary
-
- def test_prune_events(self):
- """Test pruning old events."""
- # This test just verifies the function doesn't error
- # (we don't want to delete real data in tests)
- count = prune_events(older_than_days=365)
- # Result depends on database state, just verify no exception
- assert isinstance(count, int)
-
- def test_event_data_serialization(self):
- """Test that complex data is properly serialized."""
- complex_data = {
- "nested": {"key": "value"},
- "list": [1, 2, 3],
- "number": 42.5,
- }
-
- event = log_event(
- EventType.TOOL_CALLED,
- source="test",
- data=complex_data,
- )
-
- retrieved = get_event(event.id)
- # Data should be stored as JSON string
- assert retrieved.data is not None
-
-
-class TestEventTypes:
- """Test that all event types can be logged."""
-
- @pytest.mark.parametrize("event_type", [
- EventType.TASK_CREATED,
- EventType.TASK_BIDDING,
- EventType.TASK_ASSIGNED,
- EventType.TASK_STARTED,
- EventType.TASK_COMPLETED,
- EventType.TASK_FAILED,
- EventType.AGENT_JOINED,
- EventType.AGENT_LEFT,
- EventType.AGENT_STATUS_CHANGED,
- EventType.BID_SUBMITTED,
- EventType.AUCTION_CLOSED,
- EventType.TOOL_CALLED,
- EventType.TOOL_COMPLETED,
- EventType.TOOL_FAILED,
- EventType.SYSTEM_ERROR,
- EventType.SYSTEM_WARNING,
- EventType.SYSTEM_INFO,
- ])
- def test_all_event_types(self, event_type):
- """Verify all event types can be logged and retrieved."""
- event = log_event(
- event_type=event_type,
- source="type_test",
- data={"test": True},
- )
-
- retrieved = get_event(event.id)
- assert retrieved is not None
- assert retrieved.event_type == event_type
diff --git a/tests/swarm/__init__.py b/tests/swarm/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/swarm/test_swarm_personas.py b/tests/swarm/test_swarm_personas.py
deleted file mode 100644
index 153e8710..00000000
--- a/tests/swarm/test_swarm_personas.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""Tests for agent roster via canonical identity.
-
-The old persona system has been removed.
-Agent identity now lives in TIMMY_IDENTITY.md and is loaded via brain.identity.
-
-These tests validate:
-1. The canonical identity document defines all agents
-2. The deprecated modules correctly report deprecation
-3. The brain.identity module parses the roster correctly
-"""
-
-import pytest
-
-
-# ── Canonical Identity Tests ──────────────────────────────────────────────────
-
-
-def test_canonical_identity_exists():
- """TIMMY_IDENTITY.md must exist at project root."""
- from brain.identity import _IDENTITY_PATH
-
- assert _IDENTITY_PATH.exists(), (
- f"TIMMY_IDENTITY.md not found at {_IDENTITY_PATH}. "
- "This is the canonical soul document — it must exist."
- )
-
-
-def test_canonical_identity_loads():
- """get_canonical_identity() returns non-empty content."""
- from brain.identity import get_canonical_identity
-
- identity = get_canonical_identity()
- assert len(identity) > 100, "Identity document is too short"
- assert "Timmy" in identity
-
-
-def test_canonical_identity_has_core_sections():
- """Identity document must contain all required sections."""
- from brain.identity import get_canonical_identity
-
- identity = get_canonical_identity()
- required_sections = [
- "Core Identity",
- "Voice & Character",
- "Standing Rules",
- "Agent Roster",
- ]
- for section in required_sections:
- assert section in identity, f"Missing section: {section}"
-
-
-def test_identity_section_extraction():
- """get_identity_section() extracts specific sections."""
- from brain.identity import get_identity_section
-
- rules = get_identity_section("Standing Rules")
- assert "Sovereignty First" in rules
- assert "Local-Only Inference" in rules
-
-
-def test_identity_for_prompt_is_compact():
- """get_identity_for_prompt() returns a compact version."""
- from brain.identity import get_identity_for_prompt
-
- prompt = get_identity_for_prompt()
- assert len(prompt) > 100
- assert "Timmy" in prompt
- # Should not include the full philosophical grounding
- assert "Ascension" not in prompt
-
-
-def test_agent_roster_parsed():
- """get_agent_roster() returns all defined agents."""
- from brain.identity import get_agent_roster
-
- roster = get_agent_roster()
- assert len(roster) >= 10, f"Expected at least 10 agents, got {len(roster)}"
-
- names = {a["agent"] for a in roster}
- expected = {"Timmy", "Echo", "Mace", "Forge", "Seer", "Helm", "Quill", "Pixel", "Lyra", "Reel"}
- assert expected == names, f"Roster mismatch: expected {expected}, got {names}"
-
-
-def test_agent_roster_has_required_fields():
- """Each agent in the roster must have agent, role, capabilities."""
- from brain.identity import get_agent_roster
-
- roster = get_agent_roster()
- for agent in roster:
- assert "agent" in agent, f"Agent missing 'agent' field: {agent}"
- assert "role" in agent, f"Agent missing 'role' field: {agent}"
- assert "capabilities" in agent, f"Agent missing 'capabilities' field: {agent}"
-
-
-def test_identity_cache_works():
- """Identity should be cached after first load."""
- from brain.identity import get_canonical_identity
-
- # First load
- get_canonical_identity(force_refresh=True)
-
- # Import the cache variable after loading
- import brain.identity as identity_module
-
- assert identity_module._identity_cache is not None
- assert identity_module._identity_mtime is not None
-
-
-def test_identity_fallback():
- """If TIMMY_IDENTITY.md is missing, fallback identity is returned."""
- from brain.identity import _FALLBACK_IDENTITY
-
- assert "Timmy" in _FALLBACK_IDENTITY
- assert "Sovereign" in _FALLBACK_IDENTITY
-
diff --git a/tests/test_autonomy_phase1.py b/tests/test_autonomy_phase1.py
deleted file mode 100644
index 2efbaccd..00000000
--- a/tests/test_autonomy_phase1.py
+++ /dev/null
@@ -1,363 +0,0 @@
-"""Tests for Phase 1 Autonomy Upgrades: UC-01 through UC-04.
-
-UC-01: Live System Introspection Tool
-UC-02: Offline Status Bug Fix (heartbeat + health endpoint)
-UC-03: Message Source Tagging
-UC-04: Discord Token Auto-Detection
-"""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-
-# ── UC-01: Live System Introspection ─────────────────────────────────────────
-
-
-class TestGetTaskQueueStatus:
- """Test the task queue introspection function."""
-
- def test_returns_counts_and_total(self):
- from timmy.tools_intro import get_task_queue_status
-
- result = get_task_queue_status()
- assert "counts" in result or "error" in result
- if "counts" in result:
- assert "total" in result
- assert isinstance(result["total"], int)
-
- def test_current_task_none_when_idle(self):
- from timmy.tools_intro import get_task_queue_status
-
- result = get_task_queue_status()
- if "counts" in result:
- assert result["current_task"] is None
-
- def test_graceful_degradation_on_import_error(self):
- """Should return an error dict, not raise."""
- import sys
-
- from timmy.tools_intro import get_task_queue_status
-
- # Temporarily block the swarm.task_queue.models import to force the
- # except branch. Setting sys.modules[key] = None causes ImportError.
- saved = sys.modules.pop("swarm.task_queue.models", "MISSING")
- sys.modules["swarm.task_queue.models"] = None # type: ignore[assignment]
- try:
- result = get_task_queue_status()
- assert isinstance(result, dict)
- assert "error" in result
- finally:
- # Restore the real module
- del sys.modules["swarm.task_queue.models"]
- if saved != "MISSING":
- sys.modules["swarm.task_queue.models"] = saved
-
-
-class TestGetAgentRoster:
- """Test the agent roster introspection function."""
-
- def test_returns_roster_with_counts(self):
- from swarm.registry import register
- from timmy.tools_intro import get_agent_roster
-
- register(name="TestAgent", capabilities="test", agent_id="test-agent-1")
- result = get_agent_roster()
-
- assert "agents" in result
- assert "total" in result
- assert result["total"] >= 1
-
- def test_agent_has_last_seen_age(self):
- from swarm.registry import register
- from timmy.tools_intro import get_agent_roster
-
- register(name="AgeTest", capabilities="test", agent_id="age-test-1")
- result = get_agent_roster()
-
- agents = result["agents"]
- assert len(agents) >= 1
- agent = next(a for a in agents if a["id"] == "age-test-1")
- assert "last_seen_seconds_ago" in agent
- assert agent["last_seen_seconds_ago"] >= 0
-
- def test_summary_counts(self):
- from timmy.tools_intro import get_agent_roster
-
- result = get_agent_roster()
- assert "idle" in result
- assert "busy" in result
- assert "offline" in result
-
-
-class TestGetLiveSystemStatus:
- """Test the composite introspection function."""
-
- def test_returns_all_sections(self):
- from timmy.tools_intro import get_live_system_status
-
- result = get_live_system_status()
- assert "system" in result
- assert "task_queue" in result
- assert "agents" in result
- assert "memory" in result
- assert "timestamp" in result
-
- def test_uptime_present(self):
- from timmy.tools_intro import get_live_system_status
-
- result = get_live_system_status()
- assert "uptime_seconds" in result
-
- def test_discord_status_present(self):
- from timmy.tools_intro import get_live_system_status
-
- result = get_live_system_status()
- assert "discord" in result
- assert "state" in result["discord"]
-
-
-class TestSystemStatusMCPTool:
- """Test the MCP-registered system_status tool."""
-
- def test_tool_returns_json_string(self):
- import json
-
- from creative.tools.system_status import system_status
-
- result = system_status()
- # Should be valid JSON
- parsed = json.loads(result)
- assert isinstance(parsed, dict)
- assert "system" in parsed or "error" in parsed
-
-
-# ── UC-02: Offline Status Bug Fix ────────────────────────────────────────────
-
-
-class TestHeartbeat:
- """Test that the heartbeat mechanism updates last_seen."""
-
- def test_heartbeat_updates_last_seen(self):
- from swarm.registry import get_agent, heartbeat, register
-
- register(name="HeartbeatTest", capabilities="test", agent_id="hb-test-1")
- initial = get_agent("hb-test-1")
- assert initial is not None
-
- import time
-
- time.sleep(0.01)
-
- heartbeat("hb-test-1")
- updated = get_agent("hb-test-1")
- assert updated is not None
- assert updated.last_seen >= initial.last_seen
-
-
-class TestHealthEndpointStatus:
- """Test that /health reflects registry status, not just Ollama."""
-
- def test_health_returns_timmy_status(self, client):
- """Health endpoint should include agents.timmy.status."""
- response = client.get("/health")
- assert response.status_code == 200
- data = response.json()
- assert "agents" in data
- assert "timmy" in data["agents"]
- assert "status" in data["agents"]["timmy"]
-
- def test_health_status_from_registry(self, client):
- """Timmy's status should come from the swarm registry."""
- from swarm.registry import register
-
- # Register Timmy as idle (happens on app startup too)
- register(name="Timmy", capabilities="chat", agent_id="timmy")
-
- response = client.get("/health")
- data = response.json()
- # Should be "idle" from registry, not "offline"
- assert data["agents"]["timmy"]["status"] in ("idle", "busy")
-
-
-# ── UC-03: Message Source Tagging ────────────────────────────────────────────
-
-
-class TestMessageSourceField:
- """Test that the Message dataclass has a source field."""
-
- def test_message_has_source_field(self):
- from dashboard.store import Message
-
- msg = Message(role="user", content="hello", timestamp="12:00:00")
- assert hasattr(msg, "source")
- assert msg.source == "browser" # Default
-
- def test_message_custom_source(self):
- from dashboard.store import Message
-
- msg = Message(
- role="user", content="hello", timestamp="12:00:00", source="api"
- )
- assert msg.source == "api"
-
-
-class TestMessageLogSource:
- """Test that MessageLog.append() accepts and stores source."""
-
- def test_append_with_source(self):
- from dashboard.store import message_log
-
- message_log.append(
- role="user", content="hello", timestamp="12:00:00", source="api"
- )
- entries = message_log.all()
- assert len(entries) == 1
- assert entries[0].source == "api"
-
- def test_append_default_source(self):
- from dashboard.store import message_log
-
- message_log.append(role="user", content="hello", timestamp="12:00:00")
- entries = message_log.all()
- assert len(entries) == 1
- assert entries[0].source == "browser"
-
- def test_multiple_sources(self):
- from dashboard.store import message_log
-
- message_log.append(
- role="user", content="from browser", timestamp="12:00:00", source="browser"
- )
- message_log.append(
- role="user", content="from api", timestamp="12:00:01", source="api"
- )
- message_log.append(
- role="agent", content="response", timestamp="12:00:02", source="system"
- )
-
- entries = message_log.all()
- assert len(entries) == 3
- assert entries[0].source == "browser"
- assert entries[1].source == "api"
- assert entries[2].source == "system"
-
-
-class TestChatHistoryIncludesSource:
- """Test that the /api/chat/history endpoint includes source."""
-
- def test_history_includes_source_field(self, client):
- from dashboard.store import message_log
-
- message_log.append(
- role="user", content="test msg", timestamp="12:00:00", source="api"
- )
-
- response = client.get("/api/chat/history")
- assert response.status_code == 200
- data = response.json()
- assert len(data["messages"]) == 1
- assert data["messages"][0]["source"] == "api"
-
-
-class TestBrowserChatLogsSource:
- """Test that the browser chat route logs with source='browser'."""
-
- def test_browser_chat_source(self, client):
- with patch("swarm.task_queue.models.create_task") as mock_create:
- mock_task = MagicMock()
- mock_task.id = "test-id"
- mock_task.title = "hello from browser"
- mock_task.status = MagicMock(value="approved")
- mock_task.priority = MagicMock(value="normal")
- mock_task.assigned_to = "timmy"
- mock_create.return_value = mock_task
-
- with patch(
- "swarm.task_queue.models.get_queue_status_for_task",
- return_value={"position": 1, "total": 1, "percent_ahead": 0},
- ):
- response = client.post(
- "/agents/timmy/chat",
- data={"message": "hello from browser"},
- )
-
- from dashboard.store import message_log
-
- entries = message_log.all()
- assert len(entries) >= 1
- assert entries[0].source == "browser"
-
-
-class TestAPIChatLogsSource:
- """Test that the API chat route logs with source='api'."""
-
- def test_api_chat_source(self, client):
- with patch(
- "dashboard.routes.chat_api.timmy_chat", return_value="Hi from Timmy"
- ):
- response = client.post(
- "/api/chat",
- json={"messages": [{"role": "user", "content": "hello from api"}]},
- )
-
- assert response.status_code == 200
-
- from dashboard.store import message_log
-
- entries = message_log.all()
- assert len(entries) == 2 # user + agent
- assert entries[0].source == "api"
- assert entries[1].source == "api"
-
-
-# ── UC-04: Discord Token Auto-Detection ──────────────────────────────────────
-
-
-class TestDiscordDockerfix:
- """Test that the Dockerfile includes discord extras."""
-
- def _find_repo_root(self):
- """Walk up from this test file to find the repo root (has pyproject.toml)."""
- from pathlib import Path
-
- d = Path(__file__).resolve().parent
- while d != d.parent:
- if (d / "pyproject.toml").exists():
- return d
- d = d.parent
- return Path(__file__).resolve().parent.parent # fallback
-
- def test_dashboard_dockerfile_includes_discord(self):
- dockerfile = self._find_repo_root() / "docker" / "Dockerfile.dashboard"
- if dockerfile.exists():
- content = dockerfile.read_text()
- assert "--extras discord" in content
-
- def test_main_dockerfile_includes_discord(self):
- dockerfile = self._find_repo_root() / "Dockerfile"
- if dockerfile.exists():
- content = dockerfile.read_text()
- assert "--extras discord" in content
-
- def test_test_dockerfile_includes_discord(self):
- dockerfile = self._find_repo_root() / "docker" / "Dockerfile.test"
- if dockerfile.exists():
- content = dockerfile.read_text()
- assert "--extras discord" in content
-
-
-class TestDiscordTokenWatcher:
- """Test the Discord token watcher function exists and is wired."""
-
- def test_watcher_function_exists(self):
- from dashboard.app import _discord_token_watcher
-
- assert callable(_discord_token_watcher)
-
- def test_watcher_is_coroutine(self):
- import asyncio
-
- from dashboard.app import _discord_token_watcher
-
- assert asyncio.iscoroutinefunction(_discord_token_watcher)
diff --git a/tests/test_openfang_tools.py b/tests/test_openfang_tools.py
deleted file mode 100644
index acc77f63..00000000
--- a/tests/test_openfang_tools.py
+++ /dev/null
@@ -1,223 +0,0 @@
-"""Chunk 3: OpenFang MCP tool registration — test first, implement second.
-
-Tests cover:
-- register_openfang_tools() registers all 7 hands
-- Each tool has correct category, tags, and schema
-- Twitter hand requires confirmation
-- Persona-hand mapping is correct
-- Handler delegates to openfang_client.execute_hand()
-"""
-
-import asyncio
-import json
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def clean_tool_registry():
- """Remove OpenFang tools between tests so registration is idempotent."""
- yield
- from mcp.registry import tool_registry
-
- for name in list(tool_registry._tools.keys()):
- if name.startswith("openfang_"):
- tool_registry.unregister(name)
-
-
-# ---------------------------------------------------------------------------
-# Registration
-# ---------------------------------------------------------------------------
-
-def test_register_openfang_tools_count():
- """register_openfang_tools() should register exactly 7 tools."""
- from infrastructure.openfang.tools import register_openfang_tools
-
- count = register_openfang_tools()
- assert count == 7
-
-
-def test_all_seven_hands_registered():
- """After registration, all 7 openfang_* tools exist in the registry."""
- from infrastructure.openfang.tools import register_openfang_tools
- from mcp.registry import tool_registry
-
- register_openfang_tools()
-
- expected = {
- "openfang_browser",
- "openfang_collector",
- "openfang_predictor",
- "openfang_lead",
- "openfang_twitter",
- "openfang_researcher",
- "openfang_clip",
- }
- registered = set(tool_registry.list_tools(category="openfang"))
- assert registered == expected
-
-
-def test_tools_have_correct_category():
- """Every OpenFang tool should be in the 'openfang' category."""
- from infrastructure.openfang.tools import register_openfang_tools
- from mcp.registry import tool_registry
-
- register_openfang_tools()
-
- for name in tool_registry.list_tools(category="openfang"):
- record = tool_registry.get(name)
- assert record is not None
- assert record.category == "openfang"
-
-
-def test_tools_have_vendor_tag():
- """Every OpenFang tool should be tagged with 'vendor'."""
- from infrastructure.openfang.tools import register_openfang_tools
- from mcp.registry import tool_registry
-
- register_openfang_tools()
-
- for name in tool_registry.list_tools(category="openfang"):
- record = tool_registry.get(name)
- assert "vendor" in record.tags
- assert "openfang" in record.tags
-
-
-def test_twitter_requires_confirmation():
- """The twitter hand should require user confirmation before execution."""
- from infrastructure.openfang.tools import register_openfang_tools
- from mcp.registry import tool_registry
-
- register_openfang_tools()
-
- twitter = tool_registry.get("openfang_twitter")
- assert twitter is not None
- assert twitter.requires_confirmation is True
-
-
-def test_non_twitter_no_confirmation():
- """Non-twitter hands should NOT require confirmation."""
- from infrastructure.openfang.tools import register_openfang_tools
- from mcp.registry import tool_registry
-
- register_openfang_tools()
-
- for name in ["openfang_browser", "openfang_collector", "openfang_predictor"]:
- record = tool_registry.get(name)
- assert record is not None
- assert record.requires_confirmation is False
-
-
-def test_tools_have_schemas():
- """Every OpenFang tool should have a non-empty schema with 'name' and 'parameters'."""
- from infrastructure.openfang.tools import register_openfang_tools
- from mcp.registry import tool_registry
-
- register_openfang_tools()
-
- for name in tool_registry.list_tools(category="openfang"):
- record = tool_registry.get(name)
- assert record.schema
- assert "name" in record.schema
- assert "parameters" in record.schema
-
-
-# ---------------------------------------------------------------------------
-# Persona-hand mapping
-# ---------------------------------------------------------------------------
-
-def test_persona_hand_map_mace():
- """Mace (Security) should have collector and browser."""
- from infrastructure.openfang.tools import get_hands_for_persona
-
- hands = get_hands_for_persona("mace")
- assert "openfang_collector" in hands
- assert "openfang_browser" in hands
-
-
-def test_persona_hand_map_seer():
- """Seer (Analytics) should have predictor and researcher."""
- from infrastructure.openfang.tools import get_hands_for_persona
-
- hands = get_hands_for_persona("seer")
- assert "openfang_predictor" in hands
- assert "openfang_researcher" in hands
-
-
-def test_persona_hand_map_echo():
- """Echo (Research) should have researcher, browser, and collector."""
- from infrastructure.openfang.tools import get_hands_for_persona
-
- hands = get_hands_for_persona("echo")
- assert "openfang_researcher" in hands
- assert "openfang_browser" in hands
- assert "openfang_collector" in hands
-
-
-def test_persona_hand_map_unknown():
- """Unknown persona should get empty list."""
- from infrastructure.openfang.tools import get_hands_for_persona
-
- hands = get_hands_for_persona("nonexistent")
- assert hands == []
-
-
-# ---------------------------------------------------------------------------
-# Handler delegation
-# ---------------------------------------------------------------------------
-
-@pytest.mark.asyncio
-async def test_handler_delegates_to_client():
- """Tool handler should call openfang_client.execute_hand()."""
- from infrastructure.openfang.client import HandResult
- from infrastructure.openfang.tools import register_openfang_tools
- from mcp.registry import tool_registry
-
- register_openfang_tools()
-
- mock_result = HandResult(
- hand="browser",
- success=True,
- output="Page loaded",
- )
-
- with patch(
- "infrastructure.openfang.tools.openfang_client"
- ) as mock_client:
- mock_client.execute_hand = AsyncMock(return_value=mock_result)
-
- record = tool_registry.get("openfang_browser")
- assert record is not None
-
- output = await record.handler(url="https://example.com")
- assert output == "Page loaded"
- mock_client.execute_hand.assert_called_once_with(
- "browser", {"url": "https://example.com"}
- )
-
-
-@pytest.mark.asyncio
-async def test_handler_returns_error_on_failure():
- """On failure, handler should return the error string (not raise)."""
- from infrastructure.openfang.client import HandResult
- from infrastructure.openfang.tools import register_openfang_tools
- from mcp.registry import tool_registry
-
- register_openfang_tools()
-
- mock_result = HandResult(
- hand="collector",
- success=False,
- error="Connection refused",
- )
-
- with patch(
- "infrastructure.openfang.tools.openfang_client"
- ) as mock_client:
- mock_client.execute_hand = AsyncMock(return_value=mock_result)
-
- record = tool_registry.get("openfang_collector")
- output = await record.handler(target="example.com")
- assert "error" in output.lower()
- assert "Connection refused" in output
diff --git a/tests/timmy/test_interview.py b/tests/timmy/test_interview.py
deleted file mode 100644
index 1757ebea..00000000
--- a/tests/timmy/test_interview.py
+++ /dev/null
@@ -1,140 +0,0 @@
-"""Tests for timmy.interview — structured interview runner."""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from timmy.interview import (
- INTERVIEW_QUESTIONS,
- InterviewEntry,
- format_transcript,
- run_interview,
-)
-
-
-# ---------------------------------------------------------------------------
-# INTERVIEW_QUESTIONS sanity checks
-# ---------------------------------------------------------------------------
-
-
-def test_interview_questions_not_empty():
- """There should be at least one interview question defined."""
- assert len(INTERVIEW_QUESTIONS) > 0
-
-
-def test_interview_questions_have_required_keys():
- """Every question dict must have 'category' and 'question'."""
- for item in INTERVIEW_QUESTIONS:
- assert "category" in item
- assert "question" in item
- assert isinstance(item["category"], str)
- assert isinstance(item["question"], str)
-
-
-# ---------------------------------------------------------------------------
-# run_interview()
-# ---------------------------------------------------------------------------
-
-
-def test_run_interview_calls_chat_for_each_question():
- """run_interview should call the chat function once per question."""
- mock_chat = MagicMock(return_value="Answer.")
- transcript = run_interview(mock_chat)
-
- assert mock_chat.call_count == len(INTERVIEW_QUESTIONS)
- assert len(transcript) == len(INTERVIEW_QUESTIONS)
-
-
-def test_run_interview_returns_interview_entries():
- """Each element in the transcript should be an InterviewEntry."""
- mock_chat = MagicMock(return_value="I am Timmy.")
- transcript = run_interview(mock_chat)
-
- for entry in transcript:
- assert isinstance(entry, InterviewEntry)
- assert entry.answer == "I am Timmy."
-
-
-def test_run_interview_with_custom_questions():
- """run_interview should accept custom question lists."""
- custom_qs = [
- {"category": "Test", "question": "What is 2+2?"},
- ]
- mock_chat = MagicMock(return_value="Four.")
- transcript = run_interview(mock_chat, questions=custom_qs)
-
- assert len(transcript) == 1
- assert transcript[0].category == "Test"
- assert transcript[0].question == "What is 2+2?"
- assert transcript[0].answer == "Four."
-
-
-def test_run_interview_on_answer_callback():
- """on_answer callback should be invoked for each question."""
- callback = MagicMock()
- mock_chat = MagicMock(return_value="OK.")
-
- run_interview(mock_chat, on_answer=callback)
-
- assert callback.call_count == len(INTERVIEW_QUESTIONS)
- # Each call should receive an InterviewEntry
- for call in callback.call_args_list:
- entry = call[0][0]
- assert isinstance(entry, InterviewEntry)
-
-
-def test_run_interview_handles_chat_error():
- """If the chat function raises, the answer should contain the error."""
- def failing_chat(msg):
- raise ConnectionError("Ollama offline")
-
- transcript = run_interview(failing_chat)
-
- assert len(transcript) == len(INTERVIEW_QUESTIONS)
- for entry in transcript:
- assert "Error" in entry.answer
- assert "Ollama offline" in entry.answer
-
-
-# ---------------------------------------------------------------------------
-# format_transcript()
-# ---------------------------------------------------------------------------
-
-
-def test_format_transcript_empty():
- """Formatting an empty transcript should return a placeholder."""
- result = format_transcript([])
- assert "No interview data" in result
-
-
-def test_format_transcript_includes_header():
- """Formatted transcript should include the header."""
- entries = [InterviewEntry(category="Identity", question="Who are you?", answer="Timmy.")]
- result = format_transcript(entries)
- assert "TIMMY INTERVIEW TRANSCRIPT" in result
-
-
-def test_format_transcript_includes_questions_and_answers():
- """Formatted transcript should include Q and A."""
- entries = [
- InterviewEntry(category="Identity", question="Who are you?", answer="Timmy."),
- InterviewEntry(category="Values", question="What matters?", answer="Sovereignty."),
- ]
- result = format_transcript(entries)
-
- assert "Q: Who are you?" in result
- assert "A: Timmy." in result
- assert "Q: What matters?" in result
- assert "A: Sovereignty." in result
-
-
-def test_format_transcript_groups_by_category():
- """Categories should appear as section headers."""
- entries = [
- InterviewEntry(category="Identity", question="Q1", answer="A1"),
- InterviewEntry(category="Values", question="Q2", answer="A2"),
- ]
- result = format_transcript(entries)
-
- assert "--- Identity ---" in result
- assert "--- Values ---" in result
diff --git a/tests/timmy/test_reflection.py b/tests/timmy/test_reflection.py
deleted file mode 100644
index 90165d0f..00000000
--- a/tests/timmy/test_reflection.py
+++ /dev/null
@@ -1,243 +0,0 @@
-"""Tests for Reflection Service.
-
-Tests fallback and LLM-based reflection generation.
-"""
-
-from __future__ import annotations
-
-from datetime import datetime, timezone
-from unittest.mock import AsyncMock, MagicMock
-
-import pytest
-
-from self_coding.modification_journal import ModificationAttempt, Outcome
-from self_coding.reflection import ReflectionService
-
-
-class MockLLMResponse:
- """Mock LLM response."""
- def __init__(self, content: str, provider_used: str = "mock"):
- self.content = content
- self.provider_used = provider_used
- self.latency_ms = 100.0
- self.fallback_used = False
-
-
-@pytest.mark.asyncio
-class TestReflectionServiceFallback:
- """Fallback reflections without LLM."""
-
- async def test_fallback_success(self):
- """Should generate fallback reflection for success."""
- service = ReflectionService(llm_adapter=None)
-
- attempt = ModificationAttempt(
- task_description="Add error handling",
- files_modified=["src/app.py"],
- outcome=Outcome.SUCCESS,
- )
-
- reflection = await service.reflect_on_attempt(attempt)
-
- assert "What went well" in reflection
- assert "successfully completed" in reflection.lower()
- assert "src/app.py" in reflection
-
- async def test_fallback_failure(self):
- """Should generate fallback reflection for failure."""
- service = ReflectionService(llm_adapter=None)
-
- attempt = ModificationAttempt(
- task_description="Refactor database",
- files_modified=["src/db.py", "src/models.py"],
- outcome=Outcome.FAILURE,
- failure_analysis="Circular dependency",
- retry_count=2,
- )
-
- reflection = await service.reflect_on_attempt(attempt)
-
- assert "What went well" in reflection
- assert "What could be improved" in reflection
- assert "circular dependency" in reflection.lower()
- assert "2 retries" in reflection
-
- async def test_fallback_rollback(self):
- """Should generate fallback reflection for rollback."""
- service = ReflectionService(llm_adapter=None)
-
- attempt = ModificationAttempt(
- task_description="Update API",
- files_modified=["src/api.py"],
- outcome=Outcome.ROLLBACK,
- )
-
- reflection = await service.reflect_on_attempt(attempt)
-
- assert "What went well" in reflection
- assert "rollback" in reflection.lower()
- assert "preferable to shipping broken code" in reflection.lower()
-
-
-@pytest.mark.asyncio
-class TestReflectionServiceWithLLM:
- """Reflections with mock LLM."""
-
- async def test_llm_reflection_success(self):
- """Should use LLM for reflection when available."""
- mock_adapter = AsyncMock()
- mock_adapter.chat.return_value = MockLLMResponse(
- "**What went well:** Clean implementation\n"
- "**What could be improved:** More tests\n"
- "**Next time:** Add edge cases\n"
- "**General lesson:** Always test errors"
- )
-
- service = ReflectionService(llm_adapter=mock_adapter)
-
- attempt = ModificationAttempt(
- task_description="Add validation",
- approach="Use Pydantic",
- files_modified=["src/validation.py"],
- outcome=Outcome.SUCCESS,
- test_results="5 passed",
- )
-
- reflection = await service.reflect_on_attempt(attempt)
-
- assert "Clean implementation" in reflection
- assert mock_adapter.chat.called
-
- # Check the prompt was formatted correctly
- call_args = mock_adapter.chat.call_args
- assert "Add validation" in call_args.kwargs["message"]
- assert "SUCCESS" in call_args.kwargs["message"]
-
- async def test_llm_reflection_failure_fallback(self):
- """Should fallback when LLM fails."""
- mock_adapter = AsyncMock()
- mock_adapter.chat.side_effect = Exception("LLM timeout")
-
- service = ReflectionService(llm_adapter=mock_adapter)
-
- attempt = ModificationAttempt(
- task_description="Fix bug",
- outcome=Outcome.FAILURE,
- )
-
- reflection = await service.reflect_on_attempt(attempt)
-
- # Should still return a reflection (fallback)
- assert "What went well" in reflection
- assert "What could be improved" in reflection
-
-
-@pytest.mark.asyncio
-class TestReflectionServiceWithContext:
- """Reflections with similar past attempts."""
-
- async def test_reflect_with_context(self):
- """Should include past attempts in reflection."""
- mock_adapter = AsyncMock()
- mock_adapter.chat.return_value = MockLLMResponse(
- "Reflection with historical context"
- )
-
- service = ReflectionService(llm_adapter=mock_adapter)
-
- current = ModificationAttempt(
- task_description="Add auth middleware",
- outcome=Outcome.SUCCESS,
- )
-
- past = ModificationAttempt(
- task_description="Add logging middleware",
- outcome=Outcome.SUCCESS,
- reflection="Good pattern: use decorators",
- )
-
- reflection = await service.reflect_with_context(current, [past])
-
- assert reflection == "Reflection with historical context"
-
- # Check context was included
- call_args = mock_adapter.chat.call_args
- assert "logging middleware" in call_args.kwargs["message"]
- assert "Good pattern: use decorators" in call_args.kwargs["message"]
-
- async def test_reflect_with_context_fallback(self):
- """Should fallback when LLM fails with context."""
- mock_adapter = AsyncMock()
- mock_adapter.chat.side_effect = Exception("LLM error")
-
- service = ReflectionService(llm_adapter=mock_adapter)
-
- current = ModificationAttempt(
- task_description="Add feature",
- outcome=Outcome.SUCCESS,
- )
- past = ModificationAttempt(
- task_description="Past feature",
- outcome=Outcome.SUCCESS,
- reflection="Past lesson",
- )
-
- # Should fallback to regular reflection
- reflection = await service.reflect_with_context(current, [past])
-
- assert "What went well" in reflection
-
-
-@pytest.mark.asyncio
-class TestReflectionServiceEdgeCases:
- """Edge cases and error handling."""
-
- async def test_empty_files_list(self):
- """Should handle empty files list."""
- service = ReflectionService(llm_adapter=None)
-
- attempt = ModificationAttempt(
- task_description="Test task",
- files_modified=[],
- outcome=Outcome.SUCCESS,
- )
-
- reflection = await service.reflect_on_attempt(attempt)
-
- assert "What went well" in reflection
- assert "N/A" in reflection or "these files" in reflection
-
- async def test_long_test_results_truncated(self):
- """Should truncate long test results in prompt."""
- mock_adapter = AsyncMock()
- mock_adapter.chat.return_value = MockLLMResponse("Short reflection")
-
- service = ReflectionService(llm_adapter=mock_adapter)
-
- attempt = ModificationAttempt(
- task_description="Big refactor",
- outcome=Outcome.FAILURE,
- test_results="Error\n" * 1000, # Very long
- )
-
- await service.reflect_on_attempt(attempt)
-
- # Check that test results were truncated in the prompt
- call_args = mock_adapter.chat.call_args
- prompt = call_args.kwargs["message"]
- assert len(prompt) < 10000 # Should be truncated
-
- async def test_no_approach_documented(self):
- """Should handle missing approach."""
- service = ReflectionService(llm_adapter=None)
-
- attempt = ModificationAttempt(
- task_description="Quick fix",
- approach="", # Empty
- outcome=Outcome.SUCCESS,
- )
-
- reflection = await service.reflect_on_attempt(attempt)
-
- assert "What went well" in reflection
- assert "No approach documented" not in reflection # Should use fallback
diff --git a/tests/timmy/test_thinking.py b/tests/timmy/test_thinking.py
index d510b99f..69778518 100644
--- a/tests/timmy/test_thinking.py
+++ b/tests/timmy/test_thinking.py
@@ -338,26 +338,6 @@ async def test_think_once_chains_thoughts(tmp_path):
assert t3.parent_id == t2.id
-# ---------------------------------------------------------------------------
-# Event logging
-# ---------------------------------------------------------------------------
-
-def test_log_event_calls_event_log(tmp_path):
- """_log_event should call swarm.event_log.log_event with TIMMY_THOUGHT."""
- engine = _make_engine(tmp_path)
- thought = engine._store_thought("Test thought.", "existential")
-
- with patch("swarm.event_log.log_event") as mock_log:
- engine._log_event(thought)
-
- mock_log.assert_called_once()
- args, kwargs = mock_log.call_args
- from swarm.event_log import EventType
- assert args[0] == EventType.TIMMY_THOUGHT
- assert kwargs["source"] == "thinking-engine"
- assert kwargs["agent_id"] == "timmy"
-
-
# ---------------------------------------------------------------------------
# Dashboard route
# ---------------------------------------------------------------------------
diff --git a/tests/timmy/test_timmy_serve_app.py b/tests/timmy/test_timmy_serve_app.py
index cd24e5e9..37facd21 100644
--- a/tests/timmy/test_timmy_serve_app.py
+++ b/tests/timmy/test_timmy_serve_app.py
@@ -11,7 +11,7 @@ def serve_client():
"""Create a TestClient for the timmy-serve app."""
from timmy_serve.app import create_timmy_serve_app
- app = create_timmy_serve_app(price_sats=100)
+ app = create_timmy_serve_app()
return TestClient(app)
@@ -25,40 +25,16 @@ class TestHealthEndpoint:
class TestServeStatus:
- def test_status_returns_pricing(self, serve_client):
+ def test_status_returns_info(self, serve_client):
resp = serve_client.get("/serve/status")
assert resp.status_code == 200
data = resp.json()
- assert data["price_sats"] == 100
- assert "total_invoices" in data
- assert "total_earned_sats" in data
+ assert data["status"] == "active"
class TestServeChatEndpoint:
- """Regression tests for /serve/chat.
-
- The original implementation declared ``async def serve_chat(request: ChatRequest)``
- which shadowed FastAPI's ``Request`` object. Calling ``request.headers`` on a
- Pydantic model raised ``AttributeError``. The fix splits the parameters into
- ``request: Request`` (FastAPI) and ``body: ChatRequest`` (Pydantic).
- """
-
- def test_chat_without_auth_returns_402(self, serve_client):
- """Unauthenticated request should get a 402 challenge."""
- resp = serve_client.post(
- "/serve/chat",
- json={"message": "Hello"},
- )
- assert resp.status_code == 402
- data = resp.json()
- assert data["error"] == "Payment Required"
- assert "macaroon" in data
- assert "invoice" in data
-
@patch("timmy_serve.app.create_timmy")
- @patch("timmy_serve.app.verify_l402_token", return_value=True)
- def test_chat_with_valid_l402_token(self, mock_verify, mock_create, serve_client):
- """Authenticated request should reach the chat handler without AttributeError."""
+ def test_chat_returns_response(self, mock_create, serve_client):
mock_agent = MagicMock()
mock_result = MagicMock()
mock_result.content = "I am Timmy."
@@ -68,30 +44,8 @@ class TestServeChatEndpoint:
resp = serve_client.post(
"/serve/chat",
json={"message": "Who are you?"},
- headers={"Authorization": "L402 fake-macaroon:fake-preimage"},
)
- # The key assertion: we must NOT get a 500 from AttributeError
assert resp.status_code == 200
data = resp.json()
assert data["response"] == "I am Timmy."
mock_agent.run.assert_called_once_with("Who are you?", stream=False)
-
- @patch("timmy_serve.app.create_timmy")
- @patch("timmy_serve.app.verify_l402_token", return_value=True)
- def test_chat_reads_auth_header_from_request(
- self, mock_verify, mock_create, serve_client
- ):
- """Ensure auth header is read from the HTTP Request, not the JSON body."""
- mock_agent = MagicMock()
- mock_result = MagicMock()
- mock_result.content = "ok"
- mock_agent.run.return_value = mock_result
- mock_create.return_value = mock_agent
-
- resp = serve_client.post(
- "/serve/chat",
- json={"message": "test"},
- headers={"Authorization": "L402 abc:def"},
- )
- assert resp.status_code == 200
- # Should not raise AttributeError on request.headers
diff --git a/tests/timmy/test_timmy_serve_cli.py b/tests/timmy/test_timmy_serve_cli.py
index 6166b06c..9c908144 100644
--- a/tests/timmy/test_timmy_serve_cli.py
+++ b/tests/timmy/test_timmy_serve_cli.py
@@ -12,7 +12,6 @@ class TestStartCommand:
result = runner.invoke(app, ["start", "--dry-run"])
assert result.exit_code == 0
assert "8402" in result.output
- assert "L402 payment proxy active" in result.output
def test_start_custom_port(self):
result = runner.invoke(app, ["start", "--port", "9000", "--dry-run"])
@@ -24,47 +23,9 @@ class TestStartCommand:
assert result.exit_code == 0
assert "127.0.0.1" in result.output
- def test_start_shows_endpoints(self):
- result = runner.invoke(app, ["start", "--dry-run"])
- assert "/serve/chat" in result.output
- assert "/serve/invoice" in result.output
- assert "/serve/status" in result.output
-
- def test_start_custom_price(self):
- result = runner.invoke(app, ["start", "--price", "50", "--dry-run"])
- assert result.exit_code == 0
- assert "50 sats" in result.output
-
-
-class TestInvoiceCommand:
- def test_invoice_default_amount(self):
- result = runner.invoke(app, ["invoice"])
- assert result.exit_code == 0
- assert "100 sats" in result.output
- assert "API access" in result.output
-
- def test_invoice_custom_amount(self):
- result = runner.invoke(app, ["invoice", "--amount", "500"])
- assert result.exit_code == 0
- assert "500 sats" in result.output
-
- def test_invoice_custom_memo(self):
- result = runner.invoke(app, ["invoice", "--memo", "Test payment"])
- assert result.exit_code == 0
- assert "Test payment" in result.output
-
- def test_invoice_shows_payment_hash(self):
- result = runner.invoke(app, ["invoice"])
- assert "Payment hash:" in result.output
- assert "Pay request:" in result.output
-
class TestStatusCommand:
def test_status_runs_successfully(self):
result = runner.invoke(app, ["status"])
assert result.exit_code == 0
assert "Timmy Serve" in result.output
- assert "Total invoices:" in result.output
- assert "Settled:" in result.output
- assert "Total earned:" in result.output
- assert "sats" in result.output
diff --git a/tests/timmy/test_timmy_tools.py b/tests/timmy/test_timmy_tools.py
index df4ea251..86e9a9e6 100644
--- a/tests/timmy/test_timmy_tools.py
+++ b/tests/timmy/test_timmy_tools.py
@@ -180,18 +180,6 @@ class TestToolCatalog:
assert "forge" in catalog["python"]["available_in"]
assert "forge" in catalog["write_file"]["available_in"]
- def test_catalog_includes_git_tools(self):
- catalog = get_all_available_tools()
- git_tools = [k for k in catalog if "git" in k.lower()]
- # Should have some git tools from tools.git_tools
- assert len(git_tools) > 0
-
- def test_catalog_includes_creative_tools(self):
- catalog = get_all_available_tools()
- # Should pick up image, music, video catalogs
- all_keys = list(catalog.keys())
- assert len(all_keys) > 6 # more than just base tools
-
def test_catalog_forge_has_aider(self):
"""Verify Aider AI tool is available in Forge's toolkit."""
catalog = get_all_available_tools()
diff --git a/tests/tools/test_path_resolution.py b/tests/tools/test_path_resolution.py
deleted file mode 100644
index 789f23da..00000000
--- a/tests/tools/test_path_resolution.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Tests for path resolution in file operations."""
-
-import pytest
-from pathlib import Path
-
-
-def test_resolve_path_expands_tilde():
- """Path resolution should expand ~ to home directory."""
- from creative.tools.file_ops import _resolve_path
-
- result = _resolve_path("~/test")
-
- # Should expand to current user's home directory
- assert result.as_posix() == (Path.home() / "test").as_posix()
-
-
-def test_resolve_path_relative_to_repo():
- """Relative paths should resolve to repo root."""
- from creative.tools.file_ops import _resolve_path
-
- result = _resolve_path("src/config.py")
-
- # In Docker the repo root is /app; locally it contains Timmy-time-dashboard
- assert result.name == "config.py"
- assert "src" in str(result)
-
-
-def test_resolve_path_absolute():
- """Absolute paths should work as-is."""
- from creative.tools.file_ops import _resolve_path
-
- result = _resolve_path("/etc/hosts")
-
- assert result.name == "hosts"
-
-
-def test_resolve_path_with_custom_base():
- """Custom base_dir should override repo root."""
- from creative.tools.file_ops import _resolve_path
-
- result = _resolve_path("test.py", base_dir="/tmp")
-
- # Handle macOS /private/tmp vs /tmp
- assert result.name == "test.py"
- assert "tmp" in result.as_posix()
diff --git a/thought_stream.txt b/thought_stream.txt
deleted file mode 100644
index 0e78f139..00000000
--- a/thought_stream.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-To ponder, I think it would be beneficial for me to engage in a continuous stream of thoughts without interruption. This will allow me to delve deeper into complex problems and explore new connections.
-
-Upon deciding to take a break from thinking, I can then respond to your prompts and provide insights on the ideas that have been brewing in my mind.
\ No newline at end of file
diff --git a/~/.magicaltouch b/~/.magicaltouch
deleted file mode 100644
index 597a4f09..00000000
--- a/~/.magicaltouch
+++ /dev/null
@@ -1 +0,0 @@
-Timmy was here
\ No newline at end of file