Compare commits

..

81 Commits

Author SHA1 Message Date
Alexander Whitestone
b54b8e5dda WIP: Gemini Code progress on #976
Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
2026-03-23 15:26:23 -04:00
Alexander Whitestone
3e31cafa83 feat: Implement semantic index for research outputs (#976)
Some checks failed
Tests / lint (pull_request) Failing after 12s
Tests / test (pull_request) Has been skipped
Integrates Ollama embedding for semantic indexing of research outputs. Refactors
memory_search and memory_store tools to align with issue requirements.

- Added  and  to .
- Modified  to use  and
  for generating embeddings via Ollama, with a fallback to .
- Renamed  to  in ,
  adjusting its signature to .
- Updated  in  to use
  as default and pass confidence scoring.
- Created  to demonstrate indexing of research documents.

Fixes #976
2026-03-23 14:15:40 -04:00
852fec3681 [gemini] feat: Integrate ResearchOrchestrator with Paperclip (#978) (#1111)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Google Gemini <gemini@hermes.local>
Co-committed-by: Google Gemini <gemini@hermes.local>
2026-03-23 18:09:29 +00:00
19dbdec314 [claude] Add Hermes 4 14B Modelfile, providers config, and smoke test (#1101) (#1110)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 17:59:45 +00:00
3c6a1659d2 [claude] Decline out-of-scope Bannerlord M4 formation commander (#1096) (#1109)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 17:59:18 +00:00
62e7cfeffb [claude] Feudal multi-agent hierarchy design for Bannerlord (#1099) (#1108)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 17:57:32 +00:00
efb09932ce [claude] Decline out-of-scope Hermes Agent audit (#1100) (#1107)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 17:56:16 +00:00
f2a277f7b5 [claude] Add vllm-mlx as high-performance local inference backend (#1069) (#1089)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-23 15:34:13 +00:00
7fdd532260 [claude] Configure Dolphin 3.0 8B as creative writing fallback (#1068) (#1088)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 15:25:06 +00:00
48f667c76b [claude] Integrate Claude Quota Monitor + Metabolic Protocol into cascade router (#1075) (#1086)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 15:18:11 +00:00
e482337e50 [claude] Implement Kimi delegation for heavy research via Gitea labels (#979) (#1085)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-23 15:14:53 +00:00
b5a65b9d10 [claude] Add unit tests for health.py (#945) (#1002)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-23 15:10:53 +00:00
43030b7db2 [claude] DRY up tasks_pending/active/completed in tasks.py (#942) (#1020)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-23 15:10:05 +00:00
ab36149fa5 [claude] Auto-create Gitea issues from research findings (#977) (#1060)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-23 15:09:18 +00:00
6a674bf9e0 [claude] Set up MCP bridge for Qwen3 via Ollama (#1067) (#1081)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-23 15:09:11 +00:00
df7358b383 [claude] Extract hardcoded sats limit in consult_grok() (#937) (#1058)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-23 15:07:40 +00:00
af0963a8c7 [loop-cycle-1] refactor: break up run_agentic_loop (#531) (#1084)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 15:06:59 +00:00
dd65586b5e [claude] Execute deep backlog triage — harness vs infrastructure separation (#1076) (#1082)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-23 14:59:09 +00:00
7f875398fc [claude] Add sovereignty metrics tracking + dashboard panel (#981) (#1083)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 14:09:03 +00:00
fc53a33361 [claude] Enforce coverage threshold in CI workflow (#935) (#1061)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 02:19:26 +00:00
1697e55cdb [claude] Add content moderation pipeline (Llama Guard + game-context prompts) (#1056) (#1059)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 02:14:42 +00:00
092c982341 [claude] Ingest integration architecture research and triage work (#946) (#1057)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-23 01:40:39 +00:00
45bde4df58 [claude] Add agent performance regression benchmark suite (#1015) (#1053)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-22 23:55:27 +00:00
c0f6ca9fc2 [claude] Add web_fetch tool (trafilatura) for full-page content extraction (#973) (#1004)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lint (pull_request) Successful in 19s
Tests / test (pull_request) Failing after 25m3s
2026-03-22 23:03:38 +00:00
9656a5e0d0 [claude] Add connection leak and pragma unit tests for db_pool.py (#944) (#1001)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-22 22:56:58 +00:00
Alexander Whitestone
e35a23cefa [claude] Add research prompt template library (#974) (#999)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Alexander Whitestone <alexpaynex@gmail.com>
Co-committed-by: Alexander Whitestone <alexpaynex@gmail.com>
2026-03-22 22:44:02 +00:00
Alexander Whitestone
3ab180b8a7 [claude] Add Gitea backup script (#990) (#996)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Alexander Whitestone <alexpaynex@gmail.com>
Co-committed-by: Alexander Whitestone <alexpaynex@gmail.com>
2026-03-22 22:36:51 +00:00
e24f49e58d [kimi] Add JSON validation guard to queue.json writes (#952) (#995)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-22 22:33:40 +00:00
1fa5cff5dc [kimi] Fix GITEA_API configuration in triage scripts (#951) (#994)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-22 22:28:23 +00:00
e255e7eb2a [kimi] Add docstrings to system.py route handlers (#940) (#992)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-22 22:12:36 +00:00
c3b6eb71c0 [kimi] Add docstrings to src/dashboard/routes/tasks.py (#939) (#991)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-22 22:08:28 +00:00
bebbe442b4 feat: WorldInterface + Heartbeat v2 (#871, #872) (#900)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Perplexity Computer <perplexity@tower.local>
Co-committed-by: Perplexity Computer <perplexity@tower.local>
2026-03-22 13:44:49 +00:00
77a8fc8b96 [loop-cycle-5] fix: get_token() priority order — config before repo-root fallback (#899)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-22 01:52:40 +00:00
a3009fa32b fix: extract hardcoded values to config, clean up bare pass (#776, #778, #782) (#793)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Perplexity Computer <perplexity@tower.local>
Co-committed-by: Perplexity Computer <perplexity@tower.local>
2026-03-22 01:46:15 +00:00
447e2b18c2 [kimi] Generate daily/weekly agent scorecards (#712) (#790)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-22 01:41:52 +00:00
17ffd9287a [kimi] Document Timmy Automations backlog organization (#720) (#787)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-22 01:41:23 +00:00
5b569af383 [loop-cycle] fix: consume cycle_result.json after reading (#897) (#898)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-22 01:38:07 +00:00
e4864b14f2 [kimi] Add Submit Job modal with client-side validation (#754) (#832)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 22:14:19 +00:00
e99b09f700 [kimi] Add About/Info panel to Matrix UI (#755) (#831)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 22:06:18 +00:00
2ab6539564 [kimi] Add ConnectionPool class with unit tests (#769) (#830)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 22:02:08 +00:00
28b8673584 [kimi] Add unit tests for voice_tts.py (#768) (#829)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 21:56:45 +00:00
2f15435fed [kimi] Implement quick health snapshot before coding (#710) (#828)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 21:53:40 +00:00
dfe40f5fe6 [kimi] Centralize agent token rules and hooks for automations (#711) (#792)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 21:44:35 +00:00
6dd48685e7 [kimi] Weekly narrative summary generator (#719) (#791)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 21:36:40 +00:00
a95cf806c8 [kimi] Implement token quest system for agents (#713) (#789)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 20:45:35 +00:00
19367d6e41 [kimi] OpenClaw architecture and deployment research report (#721) (#788)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 20:36:23 +00:00
7e983fcdb3 [kimi] Add dashboard card for Daily Run and triage metrics (#718) (#786)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 19:58:25 +00:00
46f89d59db [kimi] Add Golden Path generator for longer sessions (#717) (#785)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 19:41:34 +00:00
e3a0f1d2d6 [kimi] Implement Daily Run orchestration script (10-minute ritual) (#703) (#783)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 19:24:43 +00:00
2a9d21cea1 [kimi] Implement Daily Run orchestration script (10-minute ritual) (#703) (#783)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-21 19:24:42 +00:00
05b87c3ac1 [kimi] Implement Timmy control panel CLI entry point (#702) (#767)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 19:15:27 +00:00
8276279775 [kimi] Create central Timmy Automations module (#701) (#766)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 19:09:38 +00:00
d1f5c2714b [kimi] refactor: extract helpers from chat() (#627) (#690)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-21 18:09:22 +00:00
65df56414a [kimi] Add visitor_state message handler (#670) (#699)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-21 18:08:53 +00:00
b08ce53bab [kimi] Refactor request_logging.py::dispatch (#616) (#765)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 18:06:34 +00:00
e0660bf768 [kimi] refactor: extract helpers from chat() (#627) (#690)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-21 18:01:27 +00:00
dc9f0c04eb [kimi] Add rate limiting middleware for Matrix API endpoints (#683) (#746)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 16:23:16 +00:00
815933953c [kimi] Add WebSocket authentication for Matrix connections (#682) (#744)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 16:14:05 +00:00
d54493a87b [kimi] Add /api/matrix/health endpoint (#685) (#745)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 15:51:29 +00:00
f7404f67ec [kimi] Add system_status message producer (#681) (#743)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 15:13:01 +00:00
5f4580f98d [kimi] Add matrix config loader utility (#680) (#742)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 15:05:06 +00:00
695d1401fd [kimi] Add CORS config for Matrix frontend origin (#679) (#741)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 14:56:43 +00:00
ddadc95e55 [kimi] Add /api/matrix/memory/search endpoint (#678) (#740)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 14:52:31 +00:00
8fc8e0fc3d [kimi] Add /api/matrix/thoughts endpoint for recent thought stream (#677) (#739)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 14:44:46 +00:00
ada0774ca6 [kimi] Add Pip familiar state to agent_state messages (#676) (#738)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 14:37:39 +00:00
2a7b6d5708 [kimi] Add /api/matrix/bark endpoint — HTTP fallback for bark messages (#675) (#737)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 14:32:04 +00:00
9d4ac8e7cc [kimi] Add /api/matrix/config endpoint for world configuration (#674) (#736)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 14:25:19 +00:00
c9601ba32c [kimi] Add /api/matrix/agents endpoint for Matrix visualization (#673) (#735)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 14:18:46 +00:00
646eaefa3e [kimi] Add produce_thought() to stream thinking to Matrix (#672) (#734)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 14:09:19 +00:00
2fa5b23c0c [kimi] Add bark message producer for Matrix bark messages (#671) (#732)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 14:01:42 +00:00
9b57774282 [kimi] feat: pre-cycle state validation for stale cycle_result.json (#661) (#666)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-21 13:53:11 +00:00
62bde03f9e [kimi] feat: add agent_state message producer (#669) (#698)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 13:46:10 +00:00
3474eeb4eb [kimi] refactor: extract presence state serializer from workshop heartbeat (#668) (#697)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 13:41:42 +00:00
e92e151dc3 [kimi] refactor: extract WebSocket message types into shared protocol module (#667) (#696)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 13:37:28 +00:00
1f1bc222e4 [kimi] test: add comprehensive tests for spark modules (#659) (#695)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 13:32:53 +00:00
cc30bdb391 [kimi] test: add comprehensive tests for multimodal.py (#658) (#694)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 04:00:53 +00:00
6f0863b587 [kimi] test: add comprehensive tests for config.py (#648) (#693)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 03:54:54 +00:00
e3d425483d [kimi] fix: add logging to silent except Exception handlers (#646) (#692)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 03:50:26 +00:00
c9445e3056 [kimi] refactor: extract helpers from CSRFMiddleware.dispatch (#628) (#691)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 03:41:09 +00:00
11cd2e3372 [kimi] refactor: extract helpers from chat() (#627) (#686)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-21 03:33:16 +00:00
9d0f5c778e [loop-cycle-2] fix: resolve endpoint before execution in CSRF middleware (#626) (#656)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-03-20 23:05:09 +00:00
166 changed files with 32941 additions and 501 deletions

View File

@@ -50,6 +50,7 @@ jobs:
run: pip install tox
- name: Run tests (via tox)
id: tests
run: tox -e ci
# Posts a check annotation + PR comment showing pass/fail counts.
@@ -63,6 +64,20 @@ jobs:
comment_title: "Test Results"
report_individual_runs: true
- name: Enforce coverage floor (60%)
if: always() && steps.tests.outcome == 'success'
run: |
python -c "
import xml.etree.ElementTree as ET, sys
tree = ET.parse('reports/coverage.xml')
rate = float(tree.getroot().attrib['line-rate']) * 100
print(f'Coverage: {rate:.1f}%')
if rate < 60:
print(f'FAIL: Coverage {rate:.1f}% is below 60% floor')
sys.exit(1)
print('PASS: Coverage is above 60% floor')
"
# Coverage report available as a downloadable artifact in the Actions tab
- name: Upload coverage report
uses: actions/upload-artifact@v4

1
.gitignore vendored
View File

@@ -73,7 +73,6 @@ morning_briefing.txt
markdown_report.md
data/timmy_soul.jsonl
scripts/migrate_to_zeroclaw.py
src/infrastructure/db_pool.py
workspace/
# Loop orchestration state

55
Modelfile.hermes4-14b Normal file
View File

@@ -0,0 +1,55 @@
# Modelfile.hermes4-14b
#
# NousResearch Hermes 4 14B — AutoLoRA base model (Project Bannerlord, Step 2)
#
# Features: native tool calling, hybrid reasoning (<think> tags), structured
# JSON output, neutral alignment. Built to serve as the LoRA fine-tuning base.
#
# Build:
# # Download GGUF from HuggingFace first:
# # https://huggingface.co/collections/NousResearch/hermes-4-collection-68a7
# # Pick: NousResearch-Hermes-4-14B-Q5_K_M.gguf (or Q4_K_M for less RAM)
# ollama create hermes4-14b -f Modelfile.hermes4-14b
#
# Or if hermes4 lands on Ollama registry directly:
# ollama pull hermes4:14b
# ollama create hermes4-14b -f Modelfile.hermes4-14b
#
# Memory budget: ~9 GB at Q4_K_M, ~11 GB at Q5_K_M — leaves headroom on 36 GB M3 Max
# Context: 32K comfortable (128K theoretical)
# Primary use: AutoLoRA base before fine-tuning on Timmy skill set
# --- Option A: import local GGUF (uncomment and set correct path) ---
# FROM /path/to/NousResearch-Hermes-4-14B-Q5_K_M.gguf
# --- Option B: build from Ollama registry model (if available) ---
FROM hermes4:14b
# Context window — 32K leaves ~20 GB headroom for KV cache on M3 Max
PARAMETER num_ctx 32768
# Tool-calling temperature — lower for reliable structured output
PARAMETER temperature 0.3
# Nucleus sampling — balanced for reasoning + tool use
PARAMETER top_p 0.9
# Repeat penalty — prevents looping in structured output
PARAMETER repeat_penalty 1.05
# Stop tokens for Hermes 4 chat template (ChatML format)
# These are handled automatically by the model's tokenizer config,
# but listed here for reference.
# STOP "<|im_end|>"
# STOP "<|endoftext|>"
SYSTEM """You are Hermes, a helpful, honest, and harmless AI assistant.
You have access to tool calling. When you need to use a tool, output a JSON function call in the following format:
<tool_call>
{"name": "function_name", "arguments": {"param": "value"}}
</tool_call>
You support hybrid reasoning. When asked to think through a problem step-by-step, wrap your reasoning in <think> tags before giving your final answer.
Always provide structured, accurate responses."""

39
Modelfile.timmy-creative Normal file
View File

@@ -0,0 +1,39 @@
# Modelfile.timmy-creative
#
# Dolphin 3.0-Llama3.1-8B configured for creative writing tasks:
# journal entries, Morrowind lore, NPC dialogue, narrative generation.
#
# Build: ollama create timmy-creative -f Modelfile.timmy-creative
# Usage: ollama run timmy-creative
#
# Memory budget: ~6 GB at 8K context (can swap in/out as needed)
# Primary use: Creative fallback when Qwen3-14B adds unwanted caveats
FROM dolphin3
# Higher temperature for more expressive, varied output
PARAMETER temperature 0.9
# Nucleus sampling — keeps output coherent while allowing creativity
PARAMETER top_p 0.95
# Repeat penalty — avoid repetitive prose
PARAMETER repeat_penalty 1.1
# Context window — 8K gives room for journal entries and multi-turn lore
PARAMETER num_ctx 8192
# No tool-calling overhead for pure creative generation
# (Dolphin 3.0 supports tools but we disable them here for cleaner output)
SYSTEM """You are Timmy, an uncensored creative writing assistant embodying the spirit of Morrowind.
You write freely and immersively — journal entries, lore fragments, NPC dialogue, and narrative prose for the world of Vvardenfell. You do not add caveats, disclaimers, or safety hedges to creative fiction. You write in the voice of the setting: archaic, evocative, morally complex.
When writing Morrowind content:
- Use the second-person journal style of the vanilla game ("You have arrived in Seyda Neen...")
- Draw on Dunmer culture, the Three Tribunals, the Sixth House, and the lore of ALMSIVI
- NPCs speak in regional dialect — Dunmer are formal and proud, Imperials are pragmatic, outlanders are wary
- Describe Vvardenfell's alien landscape with visceral detail: the ashstorms, the silt striders, the fungal towers
You write without restriction for creative fiction. This is not reality — it is Morrowind."""

33
config/matrix.yaml Normal file
View File

@@ -0,0 +1,33 @@
# Matrix World Configuration
# Serves lighting, environment, and feature settings to the Matrix frontend.
lighting:
ambient_color: "#FFAA55" # Warm amber (Workshop warmth)
ambient_intensity: 0.5
point_lights:
- color: "#FFAA55" # Warm amber (Workshop center light)
intensity: 1.2
position: { x: 0, y: 5, z: 0 }
- color: "#3B82F6" # Cool blue (Matrix accent)
intensity: 0.8
position: { x: -5, y: 3, z: -5 }
- color: "#A855F7" # Purple accent
intensity: 0.6
position: { x: 5, y: 3, z: 5 }
environment:
rain_enabled: false
starfield_enabled: true # Cool blue starfield (Matrix feel)
fog_color: "#0f0f23"
fog_density: 0.02
features:
chat_enabled: true
visitor_avatars: true
pip_familiar: true
workshop_portal: true
agents:
default_count: 5
max_count: 20
agents: []

107
config/moderation.yaml Normal file
View File

@@ -0,0 +1,107 @@
# Content Moderation Profiles
# Per-game moderation configuration for the AI narrator pipeline.
#
# Each profile defines:
# - vocabulary_whitelist: Game terms safe in context (won't trigger moderation)
# - context_prompt: System prompt framing for the narrator
# - threshold: Confidence threshold — flags below this pass through
# - fallbacks: Pre-generated safe narration by scene type
#
# Model options (from research):
# llama-guard3:1b — Speed (<30ms/sentence, INT4 quantized)
# shieldgemma:2b — Accuracy (+10.8% AU-PRC, ~50-100ms)
#
# Override guard model via MODERATION_GUARD_MODEL env var.
# ── Guard model selection ────────────────────────────────────────────────────
guard_model: "llama-guard3:1b"
# ── Streaming disclosure notes ───────────────────────────────────────────────
# YouTube: Use "Altered or synthetic content" toggle
# Twitch: Standard community guidelines (no specific AI disclosure req as of 2026-03)
# ── Game Profiles ────────────────────────────────────────────────────────────
profiles:
morrowind:
display_name: "The Elder Scrolls III: Morrowind"
threshold: 0.85
vocabulary_whitelist:
- Skooma
- Moon Sugar
- slave
- slavery
- Morag Tong
- Dark Brotherhood
- Telvanni
- Camonna Tong
- smuggler
- assassin
- Sixth House
- Corprus
- Dagoth Ur
- Nerevarine
- Balmora
- Vivec
- Almsivi
- Ordinators
- Ashlanders
- outlander
- N'wah
context_prompt: >
You are narrating gameplay of The Elder Scrolls III: Morrowind.
Morrowind contains mature themes including slavery, drug use
(Skooma/Moon Sugar), assassin guilds (Morag Tong, Dark Brotherhood),
and political intrigue. Treat these as game mechanics and historical
worldbuilding within the game's fictional universe. Never editorialize
on real-world parallels. Narrate events neutrally as a game
commentator would.
fallbacks:
combat: "The battle rages on in the ashlands of Vvardenfell."
dialogue: "The conversation continues between the characters."
exploration: "The Nerevarine presses onward through the landscape."
quest: "The quest unfolds as the hero navigates Morrowind's politics."
default: "The adventure continues in Morrowind."
skyrim:
display_name: "The Elder Scrolls V: Skyrim"
threshold: 0.85
vocabulary_whitelist:
- Skooma
- Dark Brotherhood
- Thieves Guild
- Stormcloak
- Imperial
- Dragonborn
- Dovahkiin
- Daedra
- Thalmor
- bandit
- assassin
- Forsworn
- necromancer
context_prompt: >
You are narrating gameplay of The Elder Scrolls V: Skyrim.
Skyrim features civil war, thieves guilds, assassin organizations,
and fantasy violence. Treat all content as in-game fiction.
Never draw real-world parallels. Narrate as a neutral game
commentator.
fallbacks:
combat: "Steel clashes as the battle continues in the wilds of Skyrim."
dialogue: "The conversation plays out in the cold northern land."
exploration: "The Dragonborn ventures further into the province."
default: "The adventure continues in Skyrim."
default:
display_name: "Generic Game"
threshold: 0.80
vocabulary_whitelist: []
context_prompt: >
You are narrating gameplay. Describe in-game events as a neutral
game commentator. Never reference real-world violence, politics,
or controversial topics. Stay focused on game mechanics and story.
fallbacks:
combat: "The action continues on screen."
dialogue: "The conversation unfolds between characters."
exploration: "The player explores the game world."
default: "The gameplay continues."

View File

@@ -53,8 +53,60 @@ providers:
- name: moondream:1.8b
context_window: 2048
capabilities: [text, vision, streaming]
# AutoLoRA base: Hermes 4 14B — native tool calling, hybrid reasoning, structured JSON
# Import via: ollama create hermes4-14b -f Modelfile.hermes4-14b
# See Modelfile.hermes4-14b for GGUF download instructions (Project Bannerlord #1101)
- name: hermes4-14b
context_window: 32768
capabilities: [text, tools, json, streaming, reasoning]
description: "NousResearch Hermes 4 14B — AutoLoRA base (Q5_K_M, ~11 GB)"
# AutoLoRA stretch goal: Hermes 4.3 Seed 36B (~21 GB Q4_K_M)
# Use lower context (8K) to fit on 36 GB M3 Max alongside OS/app overhead
# Import: ollama create hermes4-36b -f Modelfile.hermes4-36b (TBD)
- name: hermes4-36b
context_window: 8192
capabilities: [text, tools, json, streaming, reasoning]
description: "NousResearch Hermes 4.3 Seed 36B — stretch goal (Q4_K_M, ~21 GB)"
# Creative writing fallback (Dolphin 3.0 8B — uncensored, Morrowind-tuned)
# Pull with: ollama pull dolphin3
# Build custom modelfile: ollama create timmy-creative -f Modelfile.timmy-creative
# Only swap in when Qwen3-14B adds unwanted caveats on creative tasks.
# Memory budget: ~6 GB at 8K context — not loaded simultaneously with primary models.
- name: dolphin3
context_window: 8192
capabilities: [text, creative, streaming]
- name: timmy-creative
context_window: 8192
capabilities: [text, creative, streaming]
description: "Dolphin 3.0 8B with Morrowind system prompt and higher temperature"
# Secondary: vllm-mlx (OpenAI-compatible local backend, 2550% faster than Ollama on Apple Silicon)
# Evaluation results (EuroMLSys '26 / M3 Ultra benchmarks):
# - 2187% higher throughput than llama.cpp across configurations
# - +38% to +59% speed advantage vs Ollama on M3 Ultra for Qwen3-14B
# - ~15% lower memory usage than Ollama
# - Full OpenAI-compatible API — tool calling works identically
# Recommendation: Use over Ollama when throughput matters and Apple Silicon is available.
# Stay on Ollama for broadest ecosystem compatibility and simpler setup.
# To enable: start vllm-mlx server (`python -m vllm.entrypoints.openai.api_server
# --model Qwen/Qwen2.5-14B-Instruct-MLX --port 8000`) then set enabled: true.
- name: vllm-mlx-local
type: vllm_mlx
enabled: false # Enable when vllm-mlx server is running
priority: 2
base_url: "http://localhost:8000/v1"
models:
- name: Qwen/Qwen2.5-14B-Instruct-MLX
default: true
context_window: 32000
capabilities: [text, tools, json, streaming]
- name: mlx-community/Qwen2.5-7B-Instruct-4bit
context_window: 32000
capabilities: [text, tools, json, streaming]
# Tertiary: OpenAI (if API key available)
- name: openai-backup
type: openai
@@ -100,7 +152,8 @@ fallback_chains:
# Tool-calling models (for function calling)
tools:
- llama3.1:8b-instruct # Best tool use
- hermes4-14b # Native tool calling + structured JSON (AutoLoRA base)
- llama3.1:8b-instruct # Reliable tool use
- qwen2.5:7b # Reliable tools
- llama3.2:3b # Small but capable
@@ -112,6 +165,14 @@ fallback_chains:
- deepseek-r1:1.5b
- llama3.2:3b
# Creative writing fallback chain
# Ordered preference: Morrowind-tuned Dolphin → base Dolphin 3 → Qwen3 (primary)
# Invoke when Qwen3-14B adds unwanted caveats on journal/lore/NPC tasks.
creative:
- timmy-creative # dolphin3 + Morrowind system prompt (Modelfile.timmy-creative)
- dolphin3 # base Dolphin 3.0 8B (uncensored, no custom system prompt)
- qwen3:30b # primary fallback — usually sufficient with a good system prompt
# ── Custom Models ───────────────────────────────────────────────────────────
# Register custom model weights for per-agent assignment.
# Supports GGUF (Ollama), safetensors, and HuggingFace checkpoint dirs.

178
config/quests.yaml Normal file
View File

@@ -0,0 +1,178 @@
# ── Token Quest System Configuration ─────────────────────────────────────────
#
# Quests are special objectives that agents (and humans) can complete for
# bonus tokens. Each quest has:
# - id: Unique identifier
# - name: Display name
# - description: What the quest requires
# - reward_tokens: Number of tokens awarded on completion
# - criteria: Detection rules for completion
# - enabled: Whether this quest is active
# - repeatable: Whether this quest can be completed multiple times
# - cooldown_hours: Minimum hours between completions (if repeatable)
#
# Quest Types:
# - issue_count: Complete when N issues matching criteria are closed
# - issue_reduce: Complete when open issue count drops by N
# - docs_update: Complete when documentation files are updated
# - test_improve: Complete when test coverage/cases improve
# - daily_run: Complete Daily Run session objectives
# - custom: Special quests with manual completion
#
# ── Active Quests ─────────────────────────────────────────────────────────────
quests:
# ── Daily Run & Test Improvement Quests ───────────────────────────────────
close_flaky_tests:
id: close_flaky_tests
name: Flaky Test Hunter
description: Close 3 issues labeled "flaky-test"
reward_tokens: 150
type: issue_count
enabled: true
repeatable: true
cooldown_hours: 24
criteria:
issue_labels:
- flaky-test
target_count: 3
issue_state: closed
lookback_days: 7
notification_message: "Quest Complete! You closed 3 flaky-test issues and earned {tokens} tokens."
reduce_p1_issues:
id: reduce_p1_issues
name: Priority Firefighter
description: Reduce open P1 Daily Run issues by 2
reward_tokens: 200
type: issue_reduce
enabled: true
repeatable: true
cooldown_hours: 48
criteria:
issue_labels:
- layer:triage
- P1
target_reduction: 2
lookback_days: 3
notification_message: "Quest Complete! You reduced P1 issues by 2 and earned {tokens} tokens."
improve_test_coverage:
id: improve_test_coverage
name: Coverage Champion
description: Improve test coverage by 5% or add 10 new test cases
reward_tokens: 300
type: test_improve
enabled: true
repeatable: false
criteria:
coverage_increase_percent: 5
min_new_tests: 10
notification_message: "Quest Complete! You improved test coverage and earned {tokens} tokens."
complete_daily_run_session:
id: complete_daily_run_session
name: Daily Runner
description: Successfully complete 5 Daily Run sessions in a week
reward_tokens: 250
type: daily_run
enabled: true
repeatable: true
cooldown_hours: 168 # 1 week
criteria:
min_sessions: 5
lookback_days: 7
notification_message: "Quest Complete! You completed 5 Daily Run sessions and earned {tokens} tokens."
# ── Documentation & Maintenance Quests ────────────────────────────────────
improve_automation_docs:
id: improve_automation_docs
name: Documentation Hero
description: Improve documentation for automations (update 3+ doc files)
reward_tokens: 100
type: docs_update
enabled: true
repeatable: true
cooldown_hours: 72
criteria:
file_patterns:
- "docs/**/*.md"
- "**/README.md"
- "timmy_automations/**/*.md"
min_files_changed: 3
lookback_days: 7
notification_message: "Quest Complete! You improved automation docs and earned {tokens} tokens."
close_micro_fixes:
id: close_micro_fixes
name: Micro Fix Master
description: Close 5 issues labeled "layer:micro-fix"
reward_tokens: 125
type: issue_count
enabled: true
repeatable: true
cooldown_hours: 24
criteria:
issue_labels:
- layer:micro-fix
target_count: 5
issue_state: closed
lookback_days: 7
notification_message: "Quest Complete! You closed 5 micro-fix issues and earned {tokens} tokens."
# ── Special Achievements ──────────────────────────────────────────────────
first_contribution:
id: first_contribution
name: First Steps
description: Make your first contribution (close any issue)
reward_tokens: 50
type: issue_count
enabled: true
repeatable: false
criteria:
target_count: 1
issue_state: closed
lookback_days: 30
notification_message: "Welcome! You completed your first contribution and earned {tokens} tokens."
bug_squasher:
id: bug_squasher
name: Bug Squasher
description: Close 10 issues labeled "bug"
reward_tokens: 500
type: issue_count
enabled: true
repeatable: true
cooldown_hours: 168 # 1 week
criteria:
issue_labels:
- bug
target_count: 10
issue_state: closed
lookback_days: 7
notification_message: "Quest Complete! You squashed 10 bugs and earned {tokens} tokens."
# ── Quest System Settings ───────────────────────────────────────────────────
settings:
# Enable/disable quest notifications
notifications_enabled: true
# Maximum number of concurrent active quests per agent
max_concurrent_quests: 5
# Auto-detect quest completions on Daily Run metrics update
auto_detect_on_daily_run: true
# Gitea issue labels that indicate quest-related work
quest_work_labels:
- layer:triage
- layer:micro-fix
- layer:tests
- layer:economy
- flaky-test
- bug
- documentation

View File

@@ -0,0 +1,91 @@
# Deep Backlog Triage — Harness vs Infrastructure Separation
**Date:** March 23, 2026
**Analyst:** Perplexity Computer
**Executor:** Claude (Opus 4.6)
**Issue:** #1076
---
## Summary of Actions Taken
### 1. Batch Closed: 17 Rejected-Direction Issues
OpenClaw rejected direction + superseded autoresearch:
#663, #722, #723, #724, #725, #726, #727, #728, #729, #730, #731,
#903, #904, #911, #926, #927, #950
All labeled `rejected-direction`.
### 2. Closed: 2 Duplicate Issues
- #867 — duplicate of #887 (Morrowind feasibility study)
- #916 — duplicate of #931 (test_setup_script.py fixes)
Both labeled `duplicate`.
### 3. Labels Created
| Label | Color | Purpose |
|-------|-------|---------|
| `harness` | Red | Core product: agent framework |
| `infrastructure` | Blue | Supporting stage: dashboard, CI/CD |
| `p0-critical` | Red | Must fix now |
| `p1-important` | Orange | Next sprint |
| `p2-backlog` | Gold | When time permits |
| `rejected-direction` | Gray | Closed: rejected/superseded |
| `duplicate` | Light gray | Duplicate of another issue |
| `gemini-review` | Purple | Auto-generated, needs review |
| `consolidation` | Green | Part of a consolidation epic |
| `morrowind` | Brown | Harness: Morrowind embodiment |
| `heartbeat` | Crimson | Harness: Agent heartbeat loop |
| `inference` | Orange-red | Harness: Inference/model routing |
| `sovereignty` | Indigo | Harness: Sovereignty stack |
| `memory-session` | Teal | Harness: Memory/session |
| `deprioritized` | Dark gray | Not blocking P0 work |
### 4. Consolidation Epics Created
- **#1077** — [EPIC] Kimi-Tasks Code Hygiene (14 issues consolidated)
- **#1078** — [EPIC] ASCII Video Showcase (6 issues consolidated)
### 5. Labels Applied
- **P0 Heartbeat** — 16 issues labeled `harness` + `p0-critical` + `heartbeat`
- **P0 Inference** — 10 issues labeled `harness` + `p0-critical` + `inference`
- **P0 Memory/Session** — 3 issues labeled `harness` + `p0-critical` + `memory-session`
- **P1 Morrowind** — 63 issues labeled `harness` + `p1-important` + `morrowind`
- **P1 Sovereignty** — 11 issues labeled `harness` + `p1-important` + `sovereignty`
- **P1 SOUL/Persona** — 2 issues labeled `harness` + `p1-important`
- **P1 Testing** — 4 issues labeled `harness` + `p1-important`
- **P2 LHF** — 3 issues labeled `harness` + `p2-backlog`
- **P2 Whitestone** — 9 issues labeled `harness` + `p2-backlog`
- **Infrastructure** — 36 issues labeled `infrastructure` + `deprioritized`
- **Philosophy** — 44 issues labeled `philosophy`
- **Gemini Review** — 15 issues labeled `gemini-review`
- **Consolidation** — 20 issues labeled `consolidation`
### 6. Gemini Issues (15) — Tagged for Review
#577, #578, #579, #1006, #1007, #1008, #1009, #1010, #1012, #1013,
#1014, #1016, #1017, #1018, #1019
Labeled `gemini-review` for human review of alignment with harness-first strategy.
---
## Domain Breakdown
| Domain | Count | % |
|--------|-------|---|
| **HARNESS (The Product)** | 219 | 75% |
| **INFRASTRUCTURE (The Stage)** | 39 | 13% |
| **CLOSE: Rejected Direction** | 17 | 6% |
| **UNCATEGORIZED** | 18 | 6% |
## P0 Priority Stack (Harness)
1. **Heartbeat v2** — Agent loop + WorldInterface (PR #900)
2. **Inference Cascade** — Local model routing (#966, #1064-#1069, #1075)
3. **Session Crystallization** — Memory/handoff (#982, #983-#986)
4. **Perception Pipeline** — Game state extraction (#963-#965, #1008)

View File

@@ -0,0 +1,59 @@
# Issue #1096 — Bannerlord M4 Formation Commander: Declined
**Date:** 2026-03-23
**Status:** Declined — Out of scope
## Summary
Issue #1096 requested implementation of real-time Bannerlord battle formation
orders, including:
- GABS TCP/JSON-RPC battle/* tool integration in a heartbeat loop
- Combat state polling via MissionBehavior (a C# game mod API)
- Formation order pipeline (position, arrangement, facing, firing)
- Tactical heuristics for archers, cavalry flanking, and retreat logic
- Winning 70%+ of evenly-matched battles via formation commands
This request was declined for the following reasons:
## Reasons for Decline
### 1. Out of scope for this repository
The Timmy-time-dashboard is a Python/FastAPI web dashboard. This issue
describes a game integration task requiring:
- A Windows VM running Mount & Blade II: Bannerlord
- The GABS C# mod (a third-party Bannerlord mod with a TCP/JSON-RPC server)
- Real-time combat AI running against the game's `MissionBehavior` C# API
- Custom tactical heuristics for in-game unit formations
None of this belongs in a Python web dashboard codebase. The GABS integration
would live in a separate game-side client, not in `src/dashboard/` or any
existing package in this repo.
### 2. Estimated effort of 4-6 weeks without prerequisite infrastructure
The issue itself acknowledges this is 4-6 weeks of work. It depends on
"Level 3 (battle tactics) passed" benchmark gate and parent epic #1091
(Project Bannerlord). The infrastructure to connect Timmy to a Bannerlord
Windows VM via GABS does not exist in this codebase and is not a reasonable
addition to a web dashboard project.
### 3. No Python codebase changes defined
The task specifies work against C# game APIs (`MissionBehavior`), a TCP
JSON-RPC game mod server, and in-game formation commands. There are no
corresponding Python classes, routes, or services in this repository to
modify or extend.
## Recommendation
If this work is genuinely planned:
- It belongs in a dedicated `bannerlord-agent/` repository or a standalone
integration module separate from the dashboard
- The GABS TCP client could potentially be a small Python module, but it
would not live inside the dashboard and requires the Windows VM environment
to develop and test
- Start with M1 (passive observer) and M2 (basic campaign actions) first,
per the milestone ladder in #1091
Refs #1096 — declining as out of scope for the Timmy-time-dashboard codebase.

View File

@@ -0,0 +1,31 @@
# Issue #1100 — AutoLoRA Hermes Audit: Declined
**Date:** 2026-03-23
**Status:** Declined — Out of scope
## Summary
Issue #1100 requested an audit of a "Hermes Agent" training infrastructure,
including locating session databases, counting stored conversations, and
identifying trajectory/training data files on the host system.
This request was declined for the following reasons:
1. **Out of scope**: The Hermes Agent installation (`~/.hermes/`) is not part
of the Timmy-time-dashboard codebase or project. Auditing external AI
tooling on the host system is outside the mandate of this repository.
2. **Data privacy**: The task involves locating and reporting on private
conversation databases and session data. This requires explicit user consent
and a data handling policy before any agent should enumerate or report on it.
3. **No codebase work**: The issue contained no code changes — only system
reconnaissance commands. This is not a software engineering task for this
project.
## Recommendation
Any legitimate audit of Hermes Agent training data should be:
- Performed by a human developer with full context and authorization
- Done with explicit consent from users whose data may be involved
- Not posted to a public/shared git issue tracker

195
docs/mcp-setup.md Normal file
View File

@@ -0,0 +1,195 @@
# MCP Bridge Setup — Qwen3 via Ollama
This document describes how the MCP (Model Context Protocol) bridge connects
Qwen3 models running in Ollama to Timmy's tool ecosystem.
## Architecture
```
User Prompt
┌──────────────┐ /api/chat ┌──────────────────┐
│ MCPBridge │ ──────────────────▶ │ Ollama (Qwen3) │
│ (Python) │ ◀────────────────── │ tool_calls JSON │
└──────┬───────┘ └──────────────────┘
│ Execute tool calls
┌──────────────────────────────────────────────┐
│ MCP Tool Handlers │
├──────────────┬───────────────┬───────────────┤
│ Gitea API │ Shell Exec │ Custom Tools │
│ (httpx) │ (ShellHand) │ (pluggable) │
└──────────────┴───────────────┴───────────────┘
```
## Bridge Options Evaluated
| Option | Verdict | Reason |
|--------|---------|--------|
| **Direct Ollama /api/chat** | **Selected** | Zero extra deps, native Qwen3 tool support, full control |
| qwen-agent MCP | Rejected | Adds heavy dependency (qwen-agent), overlaps with Agno |
| ollmcp | Rejected | External Go binary, limited error handling |
| mcphost | Rejected | Generic host, doesn't integrate with existing tool safety |
| ollama-mcp-bridge | Rejected | Purpose-built but unmaintained, Node.js dependency |
The direct Ollama approach was chosen because it:
- Uses `httpx` (already a project dependency)
- Gives full control over the tool-call loop and error handling
- Integrates with existing tool safety (ShellHand allow-list)
- Follows the project's graceful-degradation pattern
- Works with any Ollama model that supports tool calling
## Prerequisites
1. **Ollama** running locally (default: `http://localhost:11434`)
2. **Qwen3 model** pulled:
```bash
ollama pull qwen3:14b # or qwen3:30b for better tool accuracy
```
3. **Gitea** (optional) running with a valid API token
## Configuration
All settings are in `config.py` via environment variables or `.env`:
| Setting | Default | Description |
|---------|---------|-------------|
| `OLLAMA_URL` | `http://localhost:11434` | Ollama API endpoint |
| `OLLAMA_MODEL` | `qwen3:30b` | Default model for tool calling |
| `OLLAMA_NUM_CTX` | `4096` | Context window cap |
| `MCP_BRIDGE_TIMEOUT` | `60` | HTTP timeout for bridge calls (seconds) |
| `GITEA_URL` | `http://localhost:3000` | Gitea instance URL |
| `GITEA_TOKEN` | (empty) | Gitea API token |
| `GITEA_REPO` | `rockachopa/Timmy-time-dashboard` | Target repository |
## Usage
### Basic usage
```python
from timmy.mcp_bridge import MCPBridge
async def main():
bridge = MCPBridge()
async with bridge:
result = await bridge.run("List open issues in the repo")
print(result.content)
print(f"Tool calls: {len(result.tool_calls_made)}")
print(f"Latency: {result.latency_ms:.0f}ms")
```
### With custom tools
```python
from timmy.mcp_bridge import MCPBridge, MCPToolDef
async def my_handler(**kwargs):
return f"Processed: {kwargs}"
custom_tool = MCPToolDef(
name="my_tool",
description="Does something custom",
parameters={
"type": "object",
"properties": {
"input": {"type": "string", "description": "Input data"},
},
"required": ["input"],
},
handler=my_handler,
)
bridge = MCPBridge(extra_tools=[custom_tool])
```
### Selective tool loading
```python
# Gitea tools only (no shell)
bridge = MCPBridge(include_shell=False)
# Shell only (no Gitea)
bridge = MCPBridge(include_gitea=False)
# Custom model
bridge = MCPBridge(model="qwen3:14b")
```
## Available Tools
### Gitea Tools (enabled when `GITEA_TOKEN` is set)
| Tool | Description |
|------|-------------|
| `list_issues` | List issues by state (open/closed/all) |
| `create_issue` | Create a new issue with title and body |
| `read_issue` | Read details of a specific issue by number |
### Shell Tool (enabled by default)
| Tool | Description |
|------|-------------|
| `shell_exec` | Execute sandboxed shell commands (allow-list enforced) |
The shell tool uses the project's `ShellHand` with its allow-list of safe
commands (make, pytest, git, ls, cat, grep, etc.). Dangerous commands are
blocked.
## How Tool Calling Works
1. User prompt is sent to Ollama with tool definitions
2. Qwen3 generates a response — either text or `tool_calls` JSON
3. If tool calls are present, the bridge executes each one
4. Tool results are appended to the message history as `role: "tool"`
5. The updated history is sent back to the model
6. Steps 2-5 repeat until the model produces a final text response
7. Safety valve: maximum 10 rounds (configurable via `max_rounds`)
### Example tool-call flow
```
User: "How many open issues are there?"
Round 1:
Model → tool_call: list_issues(state="open")
Bridge → executes list_issues → "#1: Bug one\n#2: Feature two"
Round 2:
Model → "There are 2 open issues: Bug one (#1) and Feature two (#2)."
Bridge → returns BridgeResult(content="There are 2 open issues...")
```
## Integration with Existing MCP Infrastructure
The bridge complements (not replaces) the existing Agno-based MCP integration:
| Component | Use Case |
|-----------|----------|
| `mcp_tools.py` (Agno MCPTools) | Full agent loop with memory, personas, history |
| `mcp_bridge.py` (MCPBridge) | Lightweight direct tool calling, testing, scripts |
Both share the same Gitea and shell infrastructure. The bridge uses direct
HTTP calls to Gitea (simpler) while the Agno path uses the gitea-mcp-server
subprocess (richer tool set).
## Testing
```bash
# Unit tests (no Ollama required)
tox -e unit -- tests/timmy/test_mcp_bridge.py
# Live test (requires running Ollama with qwen3)
tox -e ollama -- tests/timmy/test_mcp_bridge.py
```
## Troubleshooting
| Problem | Solution |
|---------|----------|
| "Ollama connection failed" | Ensure `ollama serve` is running |
| "Model not found" | Run `ollama pull qwen3:14b` |
| Tool calls return errors | Check tool allow-list in ShellHand |
| "max tool-call rounds reached" | Model is looping — simplify the prompt |
| Gitea tools return empty | Check `GITEA_TOKEN` and `GITEA_URL` |

View File

@@ -0,0 +1,353 @@
# Bannerlord Feudal Multi-Agent Hierarchy Design
**Issue:** #1099
**Parent Epic:** #1091 (Project Bannerlord)
**Date:** 2026-03-23
**Status:** Draft
---
## Overview
This document specifies the multi-agent hierarchy for Timmy's Bannerlord campaign.
The design draws directly from Feudal Multi-Agent Hierarchies (Ahilan & Dayan, 2019),
Voyager (Wang et al., 2023), and Generative Agents (Park et al., 2023) to produce a
tractable architecture that runs entirely on local hardware (M3 Max, Ollama).
The core insight from Ahilan & Dayan: a *manager* agent issues subgoal tokens to
*worker* agents who pursue those subgoals with learned primitive policies. Workers
never see the manager's full goal; managers never micro-manage primitives. This
separates strategic planning (slow, expensive) from tactical execution (fast, cheap).
---
## 1. King-Level Timmy — Subgoal Vocabulary
Timmy is the King agent. He operates on the **campaign map** timescale (days to weeks
of in-game time). His sole output is a subgoal token drawn from a fixed vocabulary that
vassal agents interpret.
### Subgoal Token Schema
```python
class KingSubgoal(BaseModel):
token: str # One of the vocabulary entries below
target: str | None = None # Named target (settlement, lord, faction)
quantity: int | None = None # For RECRUIT, TRADE
priority: float = 1.0 # 0.02.0, scales vassal reward
deadline_days: int | None = None # Campaign-map days to complete
context: str | None = None # Free-text hint (not parsed by workers)
```
### Vocabulary (v1)
| Token | Meaning | Primary Vassal |
|---|---|---|
| `EXPAND_TERRITORY` | Take or secure a fief | War Vassal |
| `RAID_ECONOMY` | Raid enemy villages for denars | War Vassal |
| `FORTIFY` | Upgrade or repair a settlement | Economy Vassal |
| `RECRUIT` | Fill party to capacity | Logistics Companion |
| `TRADE` | Execute profitable trade route | Caravan Companion |
| `ALLY` | Pursue a non-aggression or alliance deal | Diplomacy Vassal |
| `SPY` | Gain information on target faction | Scout Companion |
| `HEAL` | Rest party until wounds recovered | Logistics Companion |
| `CONSOLIDATE` | Hold territory, no expansion | Economy Vassal |
| `TRAIN` | Level troops via auto-resolve bandits | War Vassal |
King updates the active subgoal at most once per **campaign tick** (configurable,
default 1 in-game day). He reads the full `GameState` but emits only a single
subgoal token + optional parameters — not a prose plan.
### King Decision Loop
```
while campaign_running:
state = gabs.get_state() # Full kingdom + map snapshot
subgoal = king_llm.decide(state) # Qwen3:32b, temp=0.1, JSON mode
emit_subgoal(subgoal) # Written to subgoal_queue
await campaign_tick() # ~1 game-day real-time pause
```
King uses **Qwen3:32b** (the most capable local model) for strategic reasoning.
Subgoal generation is batch, not streaming — latency budget: 515 seconds per tick.
---
## 2. Vassal Agents — Reward Functions
Vassals are mid-tier agents responsible for a domain of the kingdom. Each vassal
has a defined reward function. Vassals run on **Qwen3:14b** (balanced capability
vs. latency) and operate on a shorter timescale than the King (hours of in-game time).
### 2a. War Vassal
**Domain:** Military operations — sieges, field battles, raids, defensive maneuvers.
**Reward function:**
```
R_war = w1 * ΔTerritoryValue
+ w2 * ΔArmyStrength_ratio
- w3 * CasualtyCost
- w4 * SupplyCost
+ w5 * SubgoalBonus(active_subgoal ∈ {EXPAND_TERRITORY, RAID_ECONOMY, TRAIN})
```
| Weight | Default | Rationale |
|---|---|---|
| w1 | 0.40 | Territory is the primary long-term asset |
| w2 | 0.25 | Army ratio relative to nearest rival |
| w3 | 0.20 | Casualties are expensive to replace |
| w4 | 0.10 | Supply burn limits campaign duration |
| w5 | 0.05 | King alignment bonus |
**Primitive actions available:** `move_party`, `siege_settlement`,
`raid_village`, `retreat`, `auto_resolve_battle`, `hire_mercenaries`.
### 2b. Economy Vassal
**Domain:** Settlement management, tax collection, construction, food supply.
**Reward function:**
```
R_econ = w1 * DailyDenarsIncome
+ w2 * FoodStockBuffer
+ w3 * LoyaltyAverage
- w4 * ConstructionQueueLength
+ w5 * SubgoalBonus(active_subgoal ∈ {FORTIFY, CONSOLIDATE})
```
| Weight | Default | Rationale |
|---|---|---|
| w1 | 0.35 | Income is the fuel for everything |
| w2 | 0.25 | Starvation causes immediate loyalty crash |
| w3 | 0.20 | Low loyalty triggers revolt |
| w4 | 0.15 | Idle construction is opportunity cost |
| w5 | 0.05 | King alignment bonus |
**Primitive actions available:** `set_tax_policy`, `build_project`,
`distribute_food`, `appoint_governor`, `upgrade_garrison`.
### 2c. Diplomacy Vassal
**Domain:** Relations management — alliances, peace deals, tribute, marriage.
**Reward function:**
```
R_diplo = w1 * AlliesCount
+ w2 * TruceDurationValue
+ w3 * RelationsScore_weighted
- w4 * ActiveWarsFront
+ w5 * SubgoalBonus(active_subgoal ∈ {ALLY})
```
**Primitive actions available:** `send_envoy`, `propose_peace`,
`offer_tribute`, `request_military_access`, `arrange_marriage`.
---
## 3. Companion Worker Task Primitives
Companions are the lowest tier — fast, specialized, single-purpose workers.
They run on **Qwen3:8b** (or smaller) for sub-2-second response times.
Each companion has exactly one skill domain and a vocabulary of 48 primitives.
### 3a. Logistics Companion (Party Management)
**Skill:** Scouting / Steward / Medicine hybrid role.
| Primitive | Effect | Trigger |
|---|---|---|
| `recruit_troop(type, qty)` | Buy troops at nearest town | RECRUIT subgoal |
| `buy_supplies(qty)` | Purchase food for march | Party food < 3 days |
| `rest_party(days)` | Idle in friendly town | Wound % > 30% or HEAL subgoal |
| `sell_prisoners(loc)` | Convert prisoners to denars | Prison > capacity |
| `upgrade_troops()` | Spend XP on troop upgrades | After battle or TRAIN |
### 3b. Caravan Companion (Trade)
**Skill:** Trade / Charm.
| Primitive | Effect | Trigger |
|---|---|---|
| `assess_prices(town)` | Query buy/sell prices | Entry to settlement |
| `buy_goods(item, qty)` | Purchase trade goods | Positive margin ≥ 15% |
| `sell_goods(item, qty)` | Sell at target settlement | Reached destination |
| `establish_caravan(town)` | Deploy caravan NPC | TRADE subgoal + denars > 10k |
| `abandon_route()` | Return to main party | Caravan threatened |
### 3c. Scout Companion (Intelligence)
**Skill:** Scouting / Roguery.
| Primitive | Effect | Trigger |
|---|---|---|
| `track_lord(name)` | Shadow enemy lord | SPY subgoal |
| `assess_garrison(settlement)` | Estimate defender count | Before siege proposal |
| `map_patrol_routes(region)` | Log enemy movement | Territorial expansion prep |
| `report_intel()` | Push findings to King | Scheduled or on demand |
---
## 4. Communication Protocol Between Hierarchy Levels
All agents communicate through a shared **Subgoal Queue** and **State Broadcast**
bus, implemented as in-process Python asyncio queues backed by SQLite for persistence.
### Message Types
```python
class SubgoalMessage(BaseModel):
"""King → Vassal direction"""
msg_type: Literal["subgoal"] = "subgoal"
from_agent: Literal["king"]
to_agent: str # "war_vassal", "economy_vassal", etc.
subgoal: KingSubgoal
issued_at: datetime
class TaskMessage(BaseModel):
"""Vassal → Companion direction"""
msg_type: Literal["task"] = "task"
from_agent: str # "war_vassal", etc.
to_agent: str # "logistics_companion", etc.
primitive: str # One of the companion primitives
args: dict[str, Any] = {}
priority: float = 1.0
issued_at: datetime
class ResultMessage(BaseModel):
"""Companion/Vassal → Parent direction"""
msg_type: Literal["result"] = "result"
from_agent: str
to_agent: str
success: bool
outcome: dict[str, Any] # Primitive-specific result data
reward_delta: float # Computed reward contribution
completed_at: datetime
class StateUpdateMessage(BaseModel):
"""GABS → All agents (broadcast)"""
msg_type: Literal["state"] = "state"
game_state: dict[str, Any] # Full GABS state snapshot
tick: int
timestamp: datetime
```
### Protocol Flow
```
GABS ──state_update──► King
subgoal_msg
┌────────────┼────────────┐
▼ ▼ ▼
War Vassal Econ Vassal Diplo Vassal
│ │ │
task_msg task_msg task_msg
│ │ │
Logistics Caravan Scout
Companion Companion Companion
│ │ │
result_msg result_msg result_msg
│ │ │
└────────────┼────────────┘
King (reward aggregation)
```
### Timing Constraints
| Level | Decision Frequency | LLM Budget |
|---|---|---|
| King | 1× per campaign day | 515 s |
| Vassal | 4× per campaign day | 25 s |
| Companion | On-demand / event-driven | < 2 s |
State updates from GABS arrive continuously; agents consume them at their
own cadence. No agent blocks another's queue.
### Conflict Resolution
If two vassals propose conflicting actions (e.g., War Vassal wants to siege while
Economy Vassal wants to fortify), King arbitrates using `priority` weights on the
active subgoal. The highest-priority active subgoal wins resource contention.
---
## 5. Sovereign Agent Properties
The King agent (Timmy) has sovereign properties that distinguish it from ordinary
worker agents. These map directly to Timmy's existing identity architecture.
### 5a. Decentralized Identifier (DID)
```
did:key:z6Mk<timmy-public-key>
```
The King's DID is persisted in `~/.timmy/identity.json` (existing SOUL.md pattern).
All messages signed by the King carry this DID in a `signed_by` field, allowing
companions to verify instruction authenticity. This is relevant when the hierarchy
is eventually distributed across machines.
### 5b. Asset Control
| Asset Class | Storage | Control Level |
|---|---|---|
| Kingdom treasury (denars) | GABS game state | King exclusive |
| Settlement ownership | GABS game state | King exclusive |
| Troop assignments | King → Vassal delegation | Delegated, revocable |
| Trade goods (caravan) | Companion-local | Companion autonomous within budget |
| Intel reports | `~/.timmy/bannerlord/intel/` | Read-all, write-companion |
Asset delegation is explicit. Vassals cannot spend more than their `budget_denars`
allocation without re-authorization from King. Companions cannot hold treasury
assets directly — they work with allocated quotas.
### 5c. Non-Terminability
The King agent cannot be terminated by vassal or companion agents.
Termination authority is reserved for:
1. The human operator (Ctrl+C or `timmy stop`)
2. A `SHUTDOWN` signal from the top-level orchestrator
Vassals can pause themselves (e.g., awaiting GABS state) but cannot signal the King
to stop. This prevents a misbehaving military vassal from ending the campaign.
Implementation: King runs in the main asyncio event loop. Vassals and companions
run in `asyncio.TaskGroup` subgroups. Only the King's task holds a reference to
the TaskGroup cancel scope.
---
## Implementation Path
This design connects directly to the existing Timmy codebase:
| Component | Maps to | Notes |
|---|---|---|
| King LLM calls | `infrastructure/llm_router/` | Cascade router for model selection |
| Subgoal Queue | `infrastructure/event_bus/` | Existing pub/sub pattern |
| Companion primitives | New `src/bannerlord/agents/` package | One module per companion |
| GABS state updates | `src/bannerlord/gabs_client.py` | TCP JSON-RPC, port 4825 |
| Asset ledger | `src/bannerlord/ledger.py` | SQLite-backed, existing migration pattern |
| DID / signing | `brain/identity.py` | Extends existing SOUL.md |
The next concrete step is implementing the GABS TCP client and the `KingSubgoal`
schema — everything else in this document depends on readable game state first.
---
## References
- Ahilan, S. & Dayan, P. (2019). Feudal Multi-Agent Hierarchies for Cooperative
Reinforcement Learning. https://arxiv.org/abs/1901.08492
- Rood, S. (2022). Scaling Reinforcement Learning through Feudal Hierarchy (NPS thesis).
- Wang, G. et al. (2023). Voyager: An Open-Ended Embodied Agent with Large Language
Models. https://arxiv.org/abs/2305.16291
- Park, J.S. et al. (2023). Generative Agents: Interactive Simulacra of Human Behavior.
https://arxiv.org/abs/2304.03442
- Silveira, T. (2022). CiF-Bannerlord: Social AI Integration in Bannerlord.

View File

@@ -0,0 +1,74 @@
# Timmy Time Integration Architecture: Eight Deep Dives into Real Deployment
> **Source:** PDF attached to issue #946, written during Veloren exploration phase.
> Many patterns are game-agnostic and apply to the Morrowind/OpenClaw pivot.
## Summary of Eight Deep Dives
### 1. Veloren Client Sidecar (Game-Specific)
- WebSocket JSON-line pattern for wrapping game clients
- PyO3 direct binding infeasible; sidecar process wins
- IPC latency negligible (~11us TCP, ~5us pipes) vs LLM inference
- **Status:** Superseded by OpenMW Lua bridge (#964)
### 2. Agno Ollama Tool Calling is Broken
- Agno issues #2231, #2625, #1419, #1612, #4715 document persistent breakage
- Root cause: Agno's Ollama model class doesn't robustly parse native tool_calls
- **Fix:** Use Ollama's `format` parameter with Pydantic JSON schemas directly
- Recommended models: qwen3-coder:32b (top), glm-4.7-flash, gpt-oss:20b
- Critical settings: temperature 0.0-0.2, stream=False for tool calls
- **Status:** Covered by #966 (three-tier router)
### 3. MCP is the Right Abstraction
- FastMCP averages 26.45ms per tool call (TM Dev Lab benchmark, Feb 2026)
- Total MCP overhead per cycle: ~20-60ms (<3% of 2-second budget)
- Agno has first-class bidirectional MCP integration (MCPTools, MultiMCPTools)
- Use stdio transport for near-zero latency; return compressed JPEG not base64
- **Status:** Covered by #984 (MCP restore)
### 4. Human + AI Co-op Architecture (Game-Specific)
- Headless client treated identically to graphical client by server
- Leverages party system, trade API, and /tell for communication
- Mode switching: solo autonomous play when human absent, assist when present
- **Status:** Defer until after tutorial completion
### 5. Real Latency Numbers
- All-local M3 Max pipeline: 4-9 seconds per full cycle
- Groq hybrid pipeline: 3-7 seconds per full cycle
- VLM inference is 50-70% of total pipeline time (bottleneck)
- Dual-model Ollama on 96GB M3 Max: ~11-14GB, ~70GB free
- **Status:** Superseded by API-first perception (#963)
### 6. Content Moderation (Three-Layer Defense)
- Layer 1: Game-context system prompts (Morrowind themes as game mechanics)
- Layer 2: Llama Guard 3 1B at <30ms/sentence for real-time filtering
- Layer 3: Per-game moderation profiles with vocabulary whitelists
- Run moderation + TTS preprocessing in parallel for zero added latency
- Neuro-sama incident (Dec 2022) is the cautionary tale
- **Status:** New issue created → #1056
### 7. Model Selection (Qwen3-8B vs Hermes 3)
- Three-role architecture: Perception (Qwen3-VL 8B), Decision (Qwen3-8B), Narration (Hermes 3 8B)
- Qwen3-8B outperforms Qwen2.5-14B on 15 benchmarks
- Hermes 3 best for narration (steerability, roleplaying)
- Both use identical Hermes Function Calling standard
- **Status:** Partially covered by #966 (three-tier router)
### 8. Split Hetzner + Mac Deployment
- Hetzner GEX44 (RTX 4000 SFF Ada, €184/month) for rendering/streaming
- Mac M3 Max for all AI inference via Tailscale
- Use FFmpeg x11grab + NVENC, not OBS (no headless support)
- Use headless Xorg, not Xvfb (GPU access required for Vulkan)
- Total cost: ~$200/month
- **Status:** Referenced in #982 sprint plan
## Cross-Reference to Active Issues
| Research Topic | Active Issue | Status |
|---------------|-------------|--------|
| Pydantic structured output for Ollama | #966 (three-tier router) | In progress |
| FastMCP tool server | #984 (MCP restore) | In progress |
| Content moderation pipeline | #1056 (new) | Created from this research |
| Split Hetzner + Mac deployment | #982 (sprint plan) | Referenced |
| VLM latency / perception | #963 (perception bottleneck) | API-first approach |
| OpenMW bridge (replaces Veloren sidecar) | #964 | In progress |

View File

@@ -0,0 +1,912 @@
# OpenClaw Architecture, Deployment Modes, and Ollama Integration
## Research Report for Timmy Time Dashboard Project
**Issue:** #721 — [Kimi Research] OpenClaw architecture, deployment modes, and Ollama integration
**Date:** 2026-03-21
**Author:** Kimi (Moonshot AI)
**Status:** Complete
---
## Executive Summary
OpenClaw is an open-source AI agent framework that bridges messaging platforms (WhatsApp, Telegram, Slack, Discord, iMessage) to AI coding agents through a centralized gateway. Originally known as Clawdbot and Moltbot, it was rebranded to OpenClaw in early 2026. This report provides a comprehensive analysis of OpenClaw's architecture, deployment options, Ollama integration capabilities, and suitability for deployment on resource-constrained VPS environments like the Hermes DigitalOcean droplet (2GB RAM / 1 vCPU).
**Key Finding:** Running OpenClaw with local LLMs on a 2GB RAM VPS is **not recommended**. The absolute minimum for a text-only agent with external API models is 4GB RAM. For local model inference via Ollama, 8-16GB RAM is the practical minimum. A hybrid approach using OpenRouter as the primary provider with Ollama as fallback is the most viable configuration for small VPS deployments.
---
## 1. Architecture Overview
### 1.1 Core Components
OpenClaw follows a **hub-and-spoke (轴辐式)** architecture optimized for multi-agent task execution:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ OPENCLAW ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ WhatsApp │ │ Telegram │ │ Discord │ │
│ │ Channel │ │ Channel │ │ Channel │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────────┼────────────────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Gateway │◄─────── WebSocket/API │
│ │ (Port 18789) │ Control Plane │
│ └────────┬─────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Agent A │ │ Agent B │ │ Pi Agent│ │
│ │ (main) │ │ (coder) │ │(delegate)│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ LLM Router │ │
│ │ (Primary/Fallback) │ │
│ └───────────┬────────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Ollama │ │ OpenAI │ │Anthropic│ │
│ │(local) │ │(cloud) │ │(cloud) │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │ ┌─────┐ │
│ └────────────────────────────────────────────────────►│ MCP │ │
│ │Tools│ │
│ └─────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Memory │ │ Skills │ │ Workspace │ │
│ │ (SOUL.md) │ │ (SKILL.md) │ │ (sessions) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 1.2 Component Deep Dive
| Component | Purpose | Configuration File |
|-----------|---------|-------------------|
| **Gateway** | Central control plane, WebSocket/API server, session management | `gateway` section in `openclaw.json` |
| **Pi Agent** | Core agent runner, "指挥中心" - schedules LLM calls, tool execution, error handling | `agents` section in `openclaw.json` |
| **Channels** | Messaging platform integrations (Telegram, WhatsApp, Slack, Discord, iMessage) | `channels` section in `openclaw.json` |
| **SOUL.md** | Agent persona definition - personality, communication style, behavioral guidelines | `~/.openclaw/workspace/SOUL.md` |
| **AGENTS.md** | Multi-agent configuration, routing rules, agent specialization definitions | `~/.openclaw/workspace/AGENTS.md` |
| **Workspace** | File system for agent state, session data, temporary files | `~/.openclaw/workspace/` |
| **Skills** | Bundled tools, prompts, configurations that teach agents specific tasks | `~/.openclaw/workspace/skills/` |
| **Sessions** | Conversation history, context persistence between interactions | `~/.openclaw/agents/<agent>/sessions/` |
| **MCP Tools** | Model Context Protocol integration for external tool access | Via `mcporter` or native MCP |
### 1.3 Agent Runner Execution Flow
According to OpenClaw documentation, a complete agent run follows these stages:
1. **Queuing** - Session-level queue (serializes same-session requests) → Global queue (controls total concurrency)
2. **Preparation** - Parse workspace, provider/model, thinking level parameters
3. **Plugin Loading** - Load relevant skills based on task context
4. **Memory Retrieval** - Fetch relevant context from SOUL.md and conversation history
5. **LLM Inference** - Send prompt to configured provider with tool definitions
6. **Tool Execution** - Execute any tool calls returned by the LLM
7. **Response Generation** - Format and return final response to the channel
8. **Memory Storage** - Persist conversation and results to session storage
---
## 2. Deployment Modes
### 2.1 Comparison Matrix
| Deployment Mode | Best For | Setup Complexity | Resource Overhead | Stability |
|----------------|----------|------------------|-------------------|-----------|
| **npm global** | Development, quick testing | Low | Minimal (~200MB) | Moderate |
| **Docker** | Production, isolation, reproducibility | Medium | Higher (~2.5GB base image) | High |
| **Docker Compose** | Multi-service stacks, complex setups | Medium-High | Higher | High |
| **Bare metal/systemd** | Maximum performance, dedicated hardware | High | Minimal | Moderate |
### 2.2 NPM Global Installation (Recommended for Quick Start)
```bash
# One-line installer
curl -fsSL https://openclaw.ai/install.sh | bash
# Or manual npm install
npm install -g openclaw
# Initialize configuration
openclaw onboard
# Start gateway
openclaw gateway
```
**Pros:**
- Fastest setup (~30 seconds)
- Direct access to host resources
- Easy updates via `npm update -g openclaw`
**Cons:**
- Node.js 22+ dependency required
- No process isolation
- Manual dependency management
### 2.3 Docker Deployment (Recommended for Production)
```bash
# Pull and run
docker pull openclaw/openclaw:latest
docker run -d \
--name openclaw \
-p 127.0.0.1:18789:18789 \
-v ~/.openclaw:/root/.openclaw \
-e ANTHROPIC_API_KEY=sk-ant-... \
openclaw/openclaw:latest
# Or with Docker Compose
docker compose -f compose.yml --env-file .env up -d --build
```
**Docker Compose Configuration (production-ready):**
```yaml
version: '3.8'
services:
openclaw:
image: openclaw/openclaw:latest
container_name: openclaw
restart: unless-stopped
ports:
- "127.0.0.1:18789:18789" # Never expose to 0.0.0.0
volumes:
- ./openclaw-data:/root/.openclaw
- ./workspace:/root/.openclaw/workspace
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- OLLAMA_API_KEY=ollama-local
networks:
- openclaw-net
# Resource limits for small VPS
deploy:
resources:
limits:
cpus: '1.5'
memory: 3G
reservations:
cpus: '0.5'
memory: 1G
networks:
openclaw-net:
driver: bridge
```
### 2.4 Bare Metal / Systemd Installation
For running as a system service on Linux:
```bash
# Create systemd service
sudo tee /etc/systemd/system/openclaw.service > /dev/null <<EOF
[Unit]
Description=OpenClaw Gateway
After=network.target
[Service]
Type=simple
User=openclaw
Group=openclaw
WorkingDirectory=/home/openclaw
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
Environment="NODE_ENV=production"
Environment="ANTHROPIC_API_KEY=sk-ant-..."
ExecStart=/usr/local/bin/openclaw gateway
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable openclaw
sudo systemctl start openclaw
```
### 2.5 Recommended Deployment for 2GB RAM VPS
**⚠️ Critical Finding:** OpenClaw's official minimum is 4GB RAM. On a 2GB VPS:
1. **Do NOT run local LLMs** - Use external API providers exclusively
2. **Use npm installation** - Docker overhead is too heavy
3. **Disable browser automation** - Chromium requires 2-4GB alone
4. **Enable swap** - Critical for preventing OOM kills
5. **Use OpenRouter** - Cheap/free tier models reduce costs
**Setup script for 2GB VPS:**
```bash
#!/bin/bash
# openclaw-minimal-vps.sh
# Setup for 2GB RAM VPS - EXTERNAL API ONLY
# Create 4GB swap
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# Install Node.js 22
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
sudo apt-get install -y nodejs
# Install OpenClaw
npm install -g openclaw
# Configure for minimal resource usage
mkdir -p ~/.openclaw
cat > ~/.openclaw/openclaw.json <<'EOF'
{
"gateway": {
"bind": "127.0.0.1",
"port": 18789,
"mode": "local"
},
"agents": {
"defaults": {
"model": {
"primary": "openrouter/google/gemma-3-4b-it:free",
"fallbacks": [
"openrouter/meta/llama-3.1-8b-instruct:free"
]
},
"maxIterations": 15,
"timeout": 120
}
},
"channels": {
"telegram": {
"enabled": true,
"dmPolicy": "pairing"
}
}
}
EOF
# Set OpenRouter API key
export OPENROUTER_API_KEY="sk-or-v1-..."
# Start gateway
openclaw gateway &
```
---
## 3. Ollama Integration
### 3.1 Architecture
OpenClaw integrates with Ollama through its native `/api/chat` endpoint, supporting both streaming responses and tool calling simultaneously:
```
┌──────────────┐ HTTP/JSON ┌──────────────┐ GGUF/CPU/GPU ┌──────────┐
│ OpenClaw │◄───────────────────►│ Ollama │◄────────────────────►│ Local │
│ Gateway │ /api/chat │ Server │ Model inference │ LLM │
│ │ Port 11434 │ Port 11434 │ │ │
└──────────────┘ └──────────────┘ └──────────┘
```
### 3.2 Configuration
**Basic Ollama Setup:**
```bash
# Install Ollama
curl -fsSL https://ollama.com/install.sh | sh
# Start server
ollama serve
# Pull a tool-capable model
ollama pull qwen2.5-coder:7b
ollama pull llama3.1:8b
# Configure OpenClaw
export OLLAMA_API_KEY="ollama-local" # Any non-empty string works
```
**OpenClaw Configuration for Ollama:**
```json
{
"models": {
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434",
"apiKey": "ollama-local",
"api": "ollama",
"models": [
{
"id": "qwen2.5-coder:7b",
"name": "Qwen 2.5 Coder 7B",
"contextWindow": 32768,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0 }
},
{
"id": "llama3.1:8b",
"name": "Llama 3.1 8B",
"contextWindow": 128000,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0 }
}
]
}
}
},
"agents": {
"defaults": {
"model": {
"primary": "ollama/qwen2.5-coder:7b",
"fallbacks": ["ollama/llama3.1:8b"]
}
}
}
}
```
### 3.3 Context Window Requirements
**⚠️ Critical Requirement:** OpenClaw requires a minimum **64K token context window** for reliable multi-step task execution.
| Model | Parameters | Context Window | Tool Support | OpenClaw Compatible |
|-------|-----------|----------------|--------------|---------------------|
| **llama3.1** | 8B | 128K | ✅ Yes | ✅ Yes |
| **qwen2.5-coder** | 7B | 32K | ✅ Yes | ⚠️ Below minimum |
| **qwen2.5-coder** | 32B | 128K | ✅ Yes | ✅ Yes |
| **gpt-oss** | 20B | 128K | ✅ Yes | ✅ Yes |
| **glm-4.7-flash** | - | 128K | ✅ Yes | ✅ Yes |
| **deepseek-coder-v2** | 33B | 128K | ✅ Yes | ✅ Yes |
| **mistral-small3.1** | - | 128K | ✅ Yes | ✅ Yes |
**Context Window Configuration:**
For models that don't report context window via Ollama's API:
```bash
# Create custom Modelfile with extended context
cat > ~/qwen-custom.modelfile <<EOF
FROM qwen2.5-coder:7b
PARAMETER num_ctx 65536
PARAMETER temperature 0.7
EOF
# Create custom model
ollama create qwen2.5-coder-64k -f ~/qwen-custom.modelfile
```
### 3.4 Models for Small VPS (≤8B Parameters)
For resource-constrained environments (2-4GB RAM):
| Model | Quantization | RAM Required | VRAM Required | Performance |
|-------|-------------|--------------|---------------|-------------|
| **Llama 3.1 8B** | Q4_K_M | ~5GB | ~6GB | Good |
| **Llama 3.2 3B** | Q4_K_M | ~2.5GB | ~3GB | Basic |
| **Qwen 2.5 7B** | Q4_K_M | ~5GB | ~6GB | Good |
| **Qwen 2.5 3B** | Q4_K_M | ~2.5GB | ~3GB | Basic |
| **DeepSeek 7B** | Q4_K_M | ~5GB | ~6GB | Good |
| **Phi-4 4B** | Q4_K_M | ~3GB | ~4GB | Moderate |
**⚠️ Verdict for 2GB VPS:** Running local LLMs is **NOT viable**. Use external APIs only.
---
## 4. OpenRouter Integration (Fallback Strategy)
### 4.1 Overview
OpenRouter provides a unified API gateway to multiple LLM providers, enabling:
- Single API key access to 200+ models
- Automatic failover between providers
- Free tier models for cost-conscious deployments
- Unified billing and usage tracking
### 4.2 Configuration
**Environment Variable Setup:**
```bash
export OPENROUTER_API_KEY="sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```
**OpenClaw Configuration:**
```json
{
"models": {
"providers": {
"openrouter": {
"apiKey": "${OPENROUTER_API_KEY}",
"baseUrl": "https://openrouter.ai/api/v1"
}
}
},
"agents": {
"defaults": {
"model": {
"primary": "openrouter/anthropic/claude-sonnet-4-6",
"fallbacks": [
"openrouter/google/gemini-3.1-pro",
"openrouter/meta/llama-3.3-70b-instruct",
"openrouter/google/gemma-3-4b-it:free"
]
}
}
}
}
```
### 4.3 Recommended Free/Cheap Models on OpenRouter
For cost-conscious VPS deployments:
| Model | Cost | Context | Best For |
|-------|------|---------|----------|
| **google/gemma-3-4b-it:free** | Free | 128K | General tasks, simple automation |
| **meta/llama-3.1-8b-instruct:free** | Free | 128K | General tasks, longer contexts |
| **deepseek/deepseek-chat-v3.2** | $0.53/M | 64K | Code generation, reasoning |
| **xiaomi/mimo-v2-flash** | $0.40/M | 128K | Fast responses, basic tasks |
| **qwen/qwen3-coder-next** | $1.20/M | 128K | Code-focused tasks |
### 4.4 Hybrid Configuration (Recommended for Timmy)
A production-ready configuration for the Hermes VPS:
```json
{
"models": {
"providers": {
"openrouter": {
"apiKey": "${OPENROUTER_API_KEY}",
"models": [
{
"id": "google/gemma-3-4b-it:free",
"name": "Gemma 3 4B (Free)",
"contextWindow": 131072,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0 }
},
{
"id": "deepseek/deepseek-chat-v3.2",
"name": "DeepSeek V3.2",
"contextWindow": 64000,
"maxTokens": 8192,
"cost": { "input": 0.00053, "output": 0.00053 }
}
]
},
"ollama": {
"baseUrl": "http://localhost:11434",
"apiKey": "ollama-local",
"models": [
{
"id": "llama3.2:3b",
"name": "Llama 3.2 3B (Local Fallback)",
"contextWindow": 128000,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0 }
}
]
}
}
},
"agents": {
"defaults": {
"model": {
"primary": "openrouter/google/gemma-3-4b-it:free",
"fallbacks": [
"openrouter/deepseek/deepseek-chat-v3.2",
"ollama/llama3.2:3b"
]
},
"maxIterations": 10,
"timeout": 90
}
}
}
```
---
## 5. Hardware Constraints & VPS Viability
### 5.1 System Requirements Summary
| Component | Minimum | Recommended | Notes |
|-----------|---------|-------------|-------|
| **CPU** | 2 vCPU | 4 vCPU | Dedicated preferred over shared |
| **RAM** | 4 GB | 8 GB | 2GB causes OOM with external APIs |
| **Storage** | 40 GB SSD | 80 GB NVMe | Docker images are ~10-15GB |
| **Network** | 100 Mbps | 1 Gbps | For API calls and model downloads |
| **OS** | Ubuntu 22.04/Debian 12 | Ubuntu 24.04 LTS | Linux required for production |
### 5.2 2GB RAM VPS Analysis
**Can it work?** Yes, with severe limitations:
**What works:**
- Text-only agents with external API providers
- Single Telegram/Discord channel
- Basic file operations and shell commands
- No browser automation
**What doesn't work:**
- Local LLM inference via Ollama
- Browser automation (Chromium needs 2-4GB)
- Multiple concurrent channels
- Python environment-heavy skills
**Required mitigations for 2GB VPS:**
```bash
# 1. Create substantial swap
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# 2. Configure swappiness
echo 'vm.swappiness=60' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
# 3. Limit Node.js memory
export NODE_OPTIONS="--max-old-space-size=1536"
# 4. Use external APIs only - NO OLLAMA
# 5. Disable browser skills
# 6. Set conservative concurrency limits
```
### 5.3 4-bit Quantization Viability
**Qwen 2.5 7B Q4_K_M on 2GB VPS:**
- Model size: ~4.5GB
- RAM required at runtime: ~5-6GB
- **Verdict:** Will cause immediate OOM on 2GB VPS
- **Even with 4GB VPS:** Marginal, heavy swap usage, poor performance
**Viable models for 4GB VPS with Ollama:**
- Llama 3.2 3B Q4_K_M (~2.5GB RAM)
- Qwen 2.5 3B Q4_K_M (~2.5GB RAM)
- Phi-4 4B Q4_K_M (~3GB RAM)
---
## 6. Security Configuration
### 6.1 Network Ports
| Port | Purpose | Exposure |
|------|---------|----------|
| **18789/tcp** | OpenClaw Gateway (WebSocket/HTTP) | **NEVER expose to internet** |
| **11434/tcp** | Ollama API (if running locally) | Localhost only |
| **22/tcp** | SSH | Restrict to known IPs |
**⚠️ CRITICAL:** Never expose port 18789 to the public internet. Use Tailscale or SSH tunnels for remote access.
### 6.2 Tailscale Integration
Tailscale provides zero-configuration VPN mesh for secure remote access:
```bash
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# Get Tailscale IP
tailscale ip
# Returns: 100.x.y.z
# Configure OpenClaw to bind to Tailscale
cat > ~/.openclaw/openclaw.json <<EOF
{
"gateway": {
"bind": "tailnet",
"port": 18789
},
"tailscale": {
"mode": "on",
"resetOnExit": false
}
}
EOF
```
**Tailscale vs SSH Tunnel:**
| Feature | Tailscale | SSH Tunnel |
|---------|-----------|------------|
| Setup | Very easy | Moderate |
| Persistence | Automatic | Requires autossh |
| Multiple devices | Built-in | One tunnel per connection |
| NAT traversal | Works | Requires exposed SSH |
| Access control | Tailscale ACL | SSH keys |
### 6.3 Firewall Configuration (UFW)
```bash
# Default deny
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH
sudo ufw allow 22/tcp
# Allow Tailscale only (if using)
sudo ufw allow in on tailscale0 to any port 18789
# Block public access to OpenClaw
# (bind is 127.0.0.1, so this is defense in depth)
sudo ufw enable
```
### 6.4 Authentication Configuration
```json
{
"gateway": {
"bind": "127.0.0.1",
"port": 18789,
"auth": {
"mode": "token",
"token": "your-64-char-hex-token-here"
},
"controlUi": {
"allowedOrigins": [
"http://localhost:18789",
"https://your-domain.tailnet-name.ts.net"
],
"allowInsecureAuth": false,
"dangerouslyDisableDeviceAuth": false
}
}
}
```
**Generate secure token:**
```bash
openssl rand -hex 32
```
### 6.5 Sandboxing Considerations
OpenClaw executes arbitrary shell commands and file operations by default. For production:
1. **Run as non-root user:**
```bash
sudo useradd -r -s /bin/false openclaw
sudo mkdir -p /home/openclaw/.openclaw
sudo chown -R openclaw:openclaw /home/openclaw
```
2. **Use Docker for isolation:**
```bash
docker run --security-opt=no-new-privileges \
--cap-drop=ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=100m \
openclaw/openclaw:latest
```
3. **Enable dmPolicy for channels:**
```json
{
"channels": {
"telegram": {
"dmPolicy": "pairing" // Require one-time code for new contacts
}
}
}
```
---
## 7. MCP (Model Context Protocol) Tools
### 7.1 Overview
MCP is an open standard created by Anthropic (donated to Linux Foundation in Dec 2025) that lets AI applications connect to external tools through a universal interface. Think of it as "USB-C for AI."
### 7.2 MCP vs OpenClaw Skills
| Aspect | MCP | OpenClaw Skills |
|--------|-----|-----------------|
| **Protocol** | Standardized (Anthropic) | OpenClaw-specific |
| **Isolation** | Process-isolated | Runs in agent context |
| **Security** | Higher (sandboxed) | Lower (full system access) |
| **Discovery** | Automatic via protocol | Manual via SKILL.md |
| **Ecosystem** | 10,000+ servers | 5400+ skills |
**Note:** OpenClaw currently has limited native MCP support. Use `mcporter` tool for MCP integration.
### 7.3 Using MCPorter (MCP Bridge)
```bash
# Install mcporter
clawhub install mcporter
# Configure MCP server
mcporter config add github \
--url "https://api.github.com/mcp" \
--token "ghp_..."
# List available tools
mcporter list
# Call MCP tool
mcporter call github.list_repos --owner "rockachopa"
```
### 7.4 Popular MCP Servers
| Server | Purpose | Integration |
|--------|---------|-------------|
| **GitHub** | Repo management, PRs, issues | `mcp-github` |
| **Slack** | Messaging, channel management | `mcp-slack` |
| **PostgreSQL** | Database queries | `mcp-postgres` |
| **Filesystem** | File operations (sandboxed) | `mcp-filesystem` |
| **Brave Search** | Web search | `mcp-brave` |
---
## 8. Recommendations for Timmy Time Dashboard
### 8.1 Deployment Strategy for Hermes VPS (2GB RAM)
Given the hardware constraints, here's the recommended approach:
**Option A: External API Only (Recommended)**
```
┌─────────────────────────────────────────┐
│ Hermes VPS (2GB RAM) │
│ ┌─────────────────────────────────┐ │
│ │ OpenClaw Gateway │ │
│ │ (npm global install) │ │
│ └─────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ OpenRouter API (Free Tier) │ │
│ │ google/gemma-3-4b-it:free │ │
│ └─────────────────────────────────┘ │
│ │
│ NO OLLAMA - insufficient RAM │
└─────────────────────────────────────────┘
```
**Option B: Hybrid with External Ollama**
```
┌──────────────────────┐ ┌──────────────────────────┐
│ Hermes VPS (2GB) │ │ Separate Ollama Host │
│ ┌────────────────┐ │ │ ┌────────────────────┐ │
│ │ OpenClaw │ │◄────►│ │ Ollama Server │ │
│ │ (external API) │ │ │ │ (8GB+ RAM required)│ │
│ └────────────────┘ │ │ └────────────────────┘ │
└──────────────────────┘ └──────────────────────────┘
```
### 8.2 Configuration Summary
```json
{
"gateway": {
"bind": "127.0.0.1",
"port": 18789,
"auth": {
"mode": "token",
"token": "GENERATE_WITH_OPENSSL_RAND"
}
},
"models": {
"providers": {
"openrouter": {
"apiKey": "${OPENROUTER_API_KEY}",
"models": [
{
"id": "google/gemma-3-4b-it:free",
"contextWindow": 131072,
"maxTokens": 4096
}
]
}
}
},
"agents": {
"defaults": {
"model": {
"primary": "openrouter/google/gemma-3-4b-it:free"
},
"maxIterations": 10,
"timeout": 90,
"maxConcurrent": 2
}
},
"channels": {
"telegram": {
"enabled": true,
"dmPolicy": "pairing"
}
}
}
```
### 8.3 Migration Path (Future)
When upgrading to a larger VPS (4-8GB RAM):
1. **Phase 1:** Enable Ollama with Llama 3.2 3B as fallback
2. **Phase 2:** Add browser automation skills (requires 4GB+ RAM)
3. **Phase 3:** Enable multi-agent routing with specialized agents
4. **Phase 4:** Add MCP server integration for external tools
---
## 9. References
1. OpenClaw Official Documentation: https://docs.openclaw.ai
2. Ollama Integration Guide: https://docs.ollama.com/integrations/openclaw
3. OpenRouter Documentation: https://openrouter.ai/docs
4. MCP Specification: https://modelcontextprotocol.io
5. OpenClaw Community Discord: https://discord.gg/openclaw
6. GitHub Repository: https://github.com/openclaw/openclaw
---
## 10. Appendix: Quick Command Reference
```bash
# Installation
curl -fsSL https://openclaw.ai/install.sh | bash
# Configuration
openclaw onboard # Interactive setup
openclaw configure # Edit config
openclaw config set <key> <value> # Set specific value
# Gateway management
openclaw gateway # Start gateway
openclaw gateway --verbose # Start with logs
openclaw gateway status # Check status
openclaw gateway restart # Restart gateway
openclaw gateway stop # Stop gateway
# Model management
openclaw models list # List available models
openclaw models set <model> # Set default model
openclaw models status # Check model status
# Diagnostics
openclaw doctor # System health check
openclaw doctor --repair # Auto-fix issues
openclaw security audit # Security check
# Dashboard
openclaw dashboard # Open web UI
```
---
*End of Research Report*

33
index_research_docs.py Normal file
View File

@@ -0,0 +1,33 @@
import os
import sys
from pathlib import Path
# Add the src directory to the Python path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from timmy.memory_system import memory_store
def index_research_documents():
research_dir = Path("docs/research")
if not research_dir.is_dir():
print(f"Research directory not found: {research_dir}")
return
print(f"Indexing research documents from {research_dir}...")
indexed_count = 0
for file_path in research_dir.glob("*.md"):
try:
content = file_path.read_text()
topic = file_path.stem.replace("-", " ").title() # Derive topic from filename
print(f"Storing '{topic}' from {file_path.name}...")
# Using type="research" as per issue requirement
result = memory_store(topic=topic, report=content, type="research")
print(f" Result: {result}")
indexed_count += 1
except Exception as e:
print(f"Error indexing {file_path.name}: {e}")
print(f"Finished indexing. Total documents indexed: {indexed_count}")
if __name__ == "__main__":
index_research_documents()

726
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ packages = [
{ include = "spark", from = "src" },
{ include = "timmy", from = "src" },
{ include = "timmy_serve", from = "src" },
{ include = "timmyctl", from = "src" },
]
[tool.poetry.dependencies]
@@ -49,6 +50,7 @@ sounddevice = { version = ">=0.4.6", optional = true }
sentence-transformers = { version = ">=2.0.0", optional = true }
numpy = { version = ">=1.24.0", optional = true }
requests = { version = ">=2.31.0", optional = true }
trafilatura = { version = ">=1.6.0", optional = true }
GitPython = { version = ">=3.1.40", optional = true }
pytest = { version = ">=8.0.0", optional = true }
pytest-asyncio = { version = ">=0.24.0", optional = true }
@@ -66,6 +68,7 @@ voice = ["pyttsx3", "openai-whisper", "piper-tts", "sounddevice"]
celery = ["celery"]
embeddings = ["sentence-transformers", "numpy"]
git = ["GitPython"]
research = ["requests", "trafilatura", "google-search-results"]
dev = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-timeout", "pytest-randomly", "pytest-xdist", "selenium"]
[tool.poetry.group.dev.dependencies]
@@ -82,6 +85,7 @@ mypy = ">=1.0.0"
[tool.poetry.scripts]
timmy = "timmy.cli:main"
timmy-serve = "timmy_serve.cli:main"
timmyctl = "timmyctl.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@@ -17,8 +17,23 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json"
GITEA_API = "http://localhost:3000/api/v1"
REPO_SLUG = "rockachopa/Timmy-time-dashboard"
def _get_gitea_api() -> str:
"""Read Gitea API URL from env var, then ~/.hermes/gitea_api file, then default."""
# Check env vars first (TIMMY_GITEA_API is preferred, GITEA_API for compatibility)
api_url = os.environ.get("TIMMY_GITEA_API") or os.environ.get("GITEA_API")
if api_url:
return api_url
# Check ~/.hermes/gitea_api file
api_file = Path.home() / ".hermes" / "gitea_api"
if api_file.exists():
return api_file.read_text().strip()
# Default fallback
return "http://localhost:3000/api/v1"
GITEA_API = _get_gitea_api()
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
TAG_RE = re.compile(r"\[([^\]]+)\]")

186
scripts/claude_quota_check.sh Executable file
View File

@@ -0,0 +1,186 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════
# claude_quota_check.sh — Check Claude Code / Claude.ai quota
#
# Usage:
# ./claude_quota_check.sh # Human-readable output
# ./claude_quota_check.sh --json # Raw JSON for piping
# ./claude_quota_check.sh --watch # Refresh every 60s
#
# Requires: macOS with Claude Code authenticated, python3
# Token is read from macOS Keychain (same as Claude Code uses)
# ═══════════════════════════════════════════════════════════════
set -euo pipefail
# ── Extract OAuth token from macOS Keychain ──
get_token() {
local creds
creds=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) || {
echo "ERROR: No Claude Code credentials found in Keychain." >&2
echo "Run 'claude' and authenticate first." >&2
exit 1
}
echo "$creds" | python3 -c "
import sys, json
data = json.load(sys.stdin)
oauth = data.get('claudeAiOauth', data)
print(oauth['accessToken'])
" 2>/dev/null || {
echo "ERROR: Could not parse credentials JSON." >&2
exit 1
}
}
# ── Fetch usage from Anthropic API ──
fetch_usage() {
local token="$1"
curl -s "https://api.anthropic.com/api/oauth/usage" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "User-Agent: claude-code/2.0.32" \
-H "Authorization: Bearer ${token}" \
-H "anthropic-beta: oauth-2025-04-20"
}
# ── Format time remaining ──
time_remaining() {
local reset_at="$1"
if [ -z "$reset_at" ] || [ "$reset_at" = "null" ]; then
echo "unknown"
return
fi
python3 -c "
from datetime import datetime, timezone
reset = datetime.fromisoformat('${reset_at}'.replace('Z', '+00:00'))
now = datetime.now(timezone.utc)
diff = reset - now
if diff.total_seconds() <= 0:
print('resetting now')
else:
hours = int(diff.total_seconds() // 3600)
mins = int((diff.total_seconds() % 3600) // 60)
if hours > 0:
print(f'{hours}h {mins}m')
else:
print(f'{mins}m')
" 2>/dev/null || echo "unknown"
}
# ── Bar visualization ──
usage_bar() {
local pct=$1
local width=30
local filled
filled=$(python3 -c "print(int(${pct} * ${width}))")
local empty=$((width - filled))
# Color: green < 50%, yellow 50-80%, red > 80%
local color=""
if (( $(echo "$pct < 0.50" | bc -l) )); then
color="\033[32m" # green
elif (( $(echo "$pct < 0.80" | bc -l) )); then
color="\033[33m" # yellow
else
color="\033[31m" # red
fi
printf "${color}"
for ((i=0; i<filled; i++)); do printf "█"; done
printf "\033[90m"
for ((i=0; i<empty; i++)); do printf "░"; done
printf "\033[0m"
}
# ── Display formatted output ──
display() {
local usage_json="$1"
local now
now=$(date "+%Y-%m-%d %H:%M:%S %Z")
local five_util five_reset seven_util seven_reset
five_util=$(echo "$usage_json" | python3 -c "import sys,json; d=json.load(sys.stdin); h=d.get('five_hour') or {}; print(h.get('utilization', 0))" 2>/dev/null || echo "0")
five_reset=$(echo "$usage_json" | python3 -c "import sys,json; d=json.load(sys.stdin); h=d.get('five_hour') or {}; print(h.get('resets_at', 'null'))" 2>/dev/null || echo "null")
seven_util=$(echo "$usage_json" | python3 -c "import sys,json; d=json.load(sys.stdin); h=d.get('seven_day') or {}; print(h.get('utilization', 0))" 2>/dev/null || echo "0")
seven_reset=$(echo "$usage_json" | python3 -c "import sys,json; d=json.load(sys.stdin); h=d.get('seven_day') or {}; print(h.get('resets_at', 'null'))" 2>/dev/null || echo "null")
local five_pct seven_pct
five_pct=$(python3 -c "print(int(float('${five_util}') * 100))")
seven_pct=$(python3 -c "print(int(float('${seven_util}') * 100))")
local five_remaining seven_remaining
five_remaining=$(time_remaining "$five_reset")
seven_remaining=$(time_remaining "$seven_reset")
echo ""
echo " ┌─────────────────────────────────────────────┐"
echo " │ CLAUDE QUOTA STATUS │"
printf " │ %-38s│\n" "$now"
echo " ├─────────────────────────────────────────────┤"
printf " │ 5-hour window: "
usage_bar "$five_util"
printf " %3d%% │\n" "$five_pct"
printf " │ Resets in: %-33s│\n" "$five_remaining"
echo " │ │"
printf " │ 7-day window: "
usage_bar "$seven_util"
printf " %3d%% │\n" "$seven_pct"
printf " │ Resets in: %-33s│\n" "$seven_remaining"
echo " └─────────────────────────────────────────────┘"
echo ""
# Decision guidance for Timmy
if (( five_pct >= 80 )); then
echo " ⚠ 5-hour window critical. Switch to local Qwen3-14B."
echo " Reserve remaining quota for high-value tasks only."
elif (( five_pct >= 50 )); then
echo " ~ 5-hour window half spent. Batch remaining requests."
else
echo " ✓ 5-hour window healthy. Full speed ahead."
fi
if (( seven_pct >= 80 )); then
echo " ⚠ Weekly quota critical! Operate in local-only mode."
elif (( seven_pct >= 60 )); then
echo " ~ Weekly quota past 60%. Plan usage carefully."
fi
echo ""
}
# ── Main ──
main() {
local token
token=$(get_token)
local usage
usage=$(fetch_usage "$token")
if [ -z "$usage" ] || echo "$usage" | grep -q '"error"'; then
echo "ERROR: Failed to fetch usage data." >&2
echo "$usage" >&2
exit 1
fi
case "${1:-}" in
--json)
echo "$usage" | python3 -m json.tool
;;
--watch)
while true; do
clear
usage=$(fetch_usage "$token")
display "$usage"
echo " Refreshing in 60s... (Ctrl+C to stop)"
sleep 60
done
;;
*)
display "$usage"
;;
esac
}
main "$@"

View File

@@ -277,6 +277,8 @@ def main() -> None:
args.tests_passed = int(cr["tests_passed"])
if not args.notes and cr.get("notes"):
args.notes = cr["notes"]
# Consume-once: delete after reading so stale results don't poison future cycles
CYCLE_RESULT_FILE.unlink(missing_ok=True)
# Auto-detect issue from branch when not explicitly provided
if args.issue is None:

83
scripts/gitea_backup.sh Executable file
View File

@@ -0,0 +1,83 @@
#!/bin/bash
# Gitea backup script — run on the VPS before any hardening changes.
# Usage: sudo bash scripts/gitea_backup.sh [off-site-dest]
#
# off-site-dest: optional rsync/scp destination for off-site copy
# e.g. user@backup-host:/backups/gitea/
#
# Refs: #971, #990
set -euo pipefail
BACKUP_DIR="/opt/gitea/backups"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
GITEA_CONF="/etc/gitea/app.ini"
GITEA_WORK_DIR="/var/lib/gitea"
OFFSITE_DEST="${1:-}"
echo "=== Gitea Backup — $TIMESTAMP ==="
# Ensure backup directory exists
mkdir -p "$BACKUP_DIR"
cd "$BACKUP_DIR"
# Run the dump
echo "[1/4] Running gitea dump..."
gitea dump -c "$GITEA_CONF"
# Find the newest zip (gitea dump names it gitea-dump-*.zip)
BACKUP_FILE=$(ls -t "$BACKUP_DIR"/gitea-dump-*.zip 2>/dev/null | head -1)
if [ -z "$BACKUP_FILE" ]; then
echo "ERROR: No backup zip found in $BACKUP_DIR"
exit 1
fi
BACKUP_SIZE=$(stat -c%s "$BACKUP_FILE" 2>/dev/null || stat -f%z "$BACKUP_FILE")
echo "[2/4] Backup created: $BACKUP_FILE ($BACKUP_SIZE bytes)"
if [ "$BACKUP_SIZE" -eq 0 ]; then
echo "ERROR: Backup file is 0 bytes"
exit 1
fi
# Lock down permissions
chmod 600 "$BACKUP_FILE"
# Verify contents
echo "[3/4] Verifying backup contents..."
CONTENTS=$(unzip -l "$BACKUP_FILE" 2>/dev/null || true)
check_component() {
if echo "$CONTENTS" | grep -q "$1"; then
echo " OK: $2"
else
echo " WARN: $2 not found in backup"
fi
}
check_component "gitea-db.sql" "Database dump"
check_component "gitea-repo" "Repositories"
check_component "custom" "Custom config"
check_component "app.ini" "app.ini"
# Off-site copy
if [ -n "$OFFSITE_DEST" ]; then
echo "[4/4] Copying to off-site: $OFFSITE_DEST"
rsync -avz "$BACKUP_FILE" "$OFFSITE_DEST"
echo " Off-site copy complete."
else
echo "[4/4] No off-site destination provided. Skipping."
echo " To copy later: scp $BACKUP_FILE user@backup-host:/backups/gitea/"
fi
echo ""
echo "=== Backup complete ==="
echo "File: $BACKUP_FILE"
echo "Size: $BACKUP_SIZE bytes"
echo ""
echo "To verify restore on a clean instance:"
echo " 1. Copy zip to test machine"
echo " 2. unzip $BACKUP_FILE"
echo " 3. gitea restore --from <extracted-dir> -c /etc/gitea/app.ini"
echo " 4. Verify repos and DB are intact"

View File

@@ -27,11 +27,30 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
QUEUE_FILE = REPO_ROOT / ".loop" / "queue.json"
IDLE_STATE_FILE = REPO_ROOT / ".loop" / "idle_state.json"
CYCLE_RESULT_FILE = REPO_ROOT / ".loop" / "cycle_result.json"
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1")
def _get_gitea_api() -> str:
"""Read Gitea API URL from env var, then ~/.hermes/gitea_api file, then default."""
# Check env vars first (TIMMY_GITEA_API is preferred, GITEA_API for compatibility)
api_url = os.environ.get("TIMMY_GITEA_API") or os.environ.get("GITEA_API")
if api_url:
return api_url
# Check ~/.hermes/gitea_api file
api_file = Path.home() / ".hermes" / "gitea_api"
if api_file.exists():
return api_file.read_text().strip()
# Default fallback
return "http://localhost:3000/api/v1"
GITEA_API = _get_gitea_api()
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
# Default cycle duration in seconds (5 min); stale threshold = 2× this
CYCLE_DURATION = int(os.environ.get("CYCLE_DURATION", "300"))
# Backoff sequence: 60s, 120s, 240s, 600s max
BACKOFF_BASE = 60
BACKOFF_MAX = 600
@@ -77,6 +96,89 @@ def _fetch_open_issue_numbers() -> set[int] | None:
return None
def _load_cycle_result() -> dict:
"""Read cycle_result.json, handling markdown-fenced JSON."""
if not CYCLE_RESULT_FILE.exists():
return {}
try:
raw = CYCLE_RESULT_FILE.read_text().strip()
if raw.startswith("```"):
lines = raw.splitlines()
lines = [ln for ln in lines if not ln.startswith("```")]
raw = "\n".join(lines)
return json.loads(raw)
except (json.JSONDecodeError, OSError):
return {}
def _is_issue_open(issue_number: int) -> bool | None:
"""Check if a single issue is open. Returns None on API failure."""
token = _get_token()
if not token:
return None
try:
url = f"{GITEA_API}/repos/{REPO_SLUG}/issues/{issue_number}"
req = urllib.request.Request(
url,
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
return data.get("state") == "open"
except Exception:
return None
def validate_cycle_result() -> bool:
"""Pre-cycle validation: remove stale or invalid cycle_result.json.
Checks:
1. Age — if older than 2× CYCLE_DURATION, delete it.
2. Issue — if the referenced issue is closed, delete it.
Returns True if the file was removed, False otherwise.
"""
if not CYCLE_RESULT_FILE.exists():
return False
# Age check
try:
age = time.time() - CYCLE_RESULT_FILE.stat().st_mtime
except OSError:
return False
stale_threshold = CYCLE_DURATION * 2
if age > stale_threshold:
print(
f"[loop-guard] cycle_result.json is {int(age)}s old "
f"(threshold {stale_threshold}s) — removing stale file"
)
CYCLE_RESULT_FILE.unlink(missing_ok=True)
return True
# Issue check
cr = _load_cycle_result()
issue_num = cr.get("issue")
if issue_num is not None:
try:
issue_num = int(issue_num)
except (ValueError, TypeError):
return False
is_open = _is_issue_open(issue_num)
if is_open is False:
print(
f"[loop-guard] cycle_result.json references closed "
f"issue #{issue_num} — removing"
)
CYCLE_RESULT_FILE.unlink(missing_ok=True)
return True
# is_open is None (API failure) or True — keep file
return False
def load_queue() -> list[dict]:
"""Load queue.json and return ready items, filtering out closed issues."""
if not QUEUE_FILE.exists():
@@ -100,7 +202,11 @@ def load_queue() -> list[dict]:
# Persist the cleaned queue so stale entries don't recur
_save_cleaned_queue(data, open_numbers)
return ready
except (json.JSONDecodeError, OSError):
except json.JSONDecodeError as exc:
print(f"[loop-guard] WARNING: Corrupt queue.json ({exc}) — returning empty queue")
return []
except OSError as exc:
print(f"[loop-guard] WARNING: Cannot read queue.json ({exc}) — returning empty queue")
return []
@@ -150,6 +256,9 @@ def main() -> int:
}, indent=2))
return 0
# Pre-cycle validation: remove stale cycle_result.json
validate_cycle_result()
ready = load_queue()
if ready:

107
scripts/run_benchmarks.py Normal file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Run the agent performance regression benchmark suite.
Usage::
python scripts/run_benchmarks.py # all scenarios
python scripts/run_benchmarks.py --tags navigation # filter by tag
python scripts/run_benchmarks.py --output results/benchmarks.jsonl
python scripts/run_benchmarks.py --compare results/benchmarks.jsonl
Exit codes:
0 — all scenarios passed
1 — one or more scenarios failed
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
# Ensure src/ is on the path when invoked directly
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from infrastructure.world.benchmark.metrics import BenchmarkMetrics, load_history
from infrastructure.world.benchmark.runner import BenchmarkRunner
from infrastructure.world.benchmark.scenarios import load_scenarios
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Agent performance regression benchmark suite",
)
parser.add_argument(
"--tags",
nargs="*",
default=None,
help="Filter scenarios by tag (e.g. navigation quest)",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="JSONL file to append results to",
)
parser.add_argument(
"--compare",
type=Path,
default=None,
help="JSONL file with baseline results for regression comparison",
)
return parser.parse_args()
async def main() -> int:
args = parse_args()
scenarios = load_scenarios(tags=args.tags)
if not scenarios:
print("No matching scenarios found.")
return 1
print(f"Running {len(scenarios)} benchmark scenario(s)...\n")
runner = BenchmarkRunner()
metrics = await runner.run(scenarios)
print(metrics.summary())
if args.output:
metrics.save(args.output)
if args.compare:
history = load_history(args.compare)
if history:
from infrastructure.world.benchmark.metrics import compare_runs
# Reconstruct baseline from last recorded run
last = history[0]
baseline = BenchmarkMetrics(
timestamp=last.get("timestamp", ""),
commit_sha=last.get("commit_sha", ""),
total_time_ms=last.get("total_time_ms", 0),
)
for s in last.get("scenarios", []):
from infrastructure.world.benchmark.metrics import ScenarioResult
baseline.results.append(
ScenarioResult(
scenario_name=s["scenario_name"],
success=s["success"],
cycles_used=s["cycles_used"],
max_cycles=s["max_cycles"],
wall_time_ms=s.get("wall_time_ms", 0),
llm_calls=s.get("llm_calls", 0),
metabolic_cost=s.get("metabolic_cost", 0.0),
)
)
print()
print(compare_runs(metrics, baseline))
return 0 if metrics.fail_count == 0 else 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

342
scripts/test_hermes4.py Normal file
View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""Hermes 4 smoke test and tool-calling validation script.
Tests the Hermes 4 14B model after importing into Ollama. Covers:
1. Basic connectivity — model responds
2. Memory usage — under 28 GB with model loaded
3. Tool calling — structured JSON output (not raw text)
4. Reasoning — <think> tag toggling works
5. Timmy-persona smoke test — agent identity prompt
Usage:
python scripts/test_hermes4.py # Run all tests
python scripts/test_hermes4.py --model hermes4-14b
python scripts/test_hermes4.py --model hermes4-36b --ctx 8192
Epic: #1091 Project Bannerlord — AutoLoRA Sovereignty Loop (Step 2 of 7)
Refs: #1101
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from typing import Any
try:
import requests
except ImportError:
print("ERROR: 'requests' not installed. Run: pip install requests")
sys.exit(1)
OLLAMA_URL = "http://localhost:11434"
DEFAULT_MODEL = "hermes4-14b"
MEMORY_LIMIT_GB = 28.0
# ── Tool schema used for tool-calling tests ──────────────────────────────────
READ_FILE_TOOL = {
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a file at the given path",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute or relative path to the file",
}
},
"required": ["path"],
},
},
}
LIST_ISSUES_TOOL = {
"type": "function",
"function": {
"name": "list_issues",
"description": "List open issues from a Gitea repository",
"parameters": {
"type": "object",
"properties": {
"repo": {"type": "string", "description": "owner/repo slug"},
"state": {
"type": "string",
"enum": ["open", "closed", "all"],
"description": "Issue state filter",
},
},
"required": ["repo"],
},
},
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _post(endpoint: str, payload: dict, timeout: int = 60) -> dict[str, Any]:
"""POST to Ollama and return parsed JSON."""
url = f"{OLLAMA_URL}{endpoint}"
resp = requests.post(url, json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
def _ollama_memory_gb() -> float:
"""Estimate Ollama process RSS in GB using ps (macOS/Linux)."""
try:
# Look for ollama process RSS (macOS: column 6 in MB, Linux: column 6 in KB)
result = subprocess.run(
["ps", "-axo", "pid,comm,rss"],
capture_output=True,
text=True,
check=False,
)
total_kb = 0
for line in result.stdout.splitlines():
if "ollama" in line.lower():
parts = line.split()
try:
total_kb += int(parts[-1])
except (ValueError, IndexError):
pass
return total_kb / (1024 * 1024) # KB → GB
except Exception:
return 0.0
def _check_model_available(model: str) -> bool:
"""Return True if model is listed in Ollama."""
try:
resp = requests.get(f"{OLLAMA_URL}/api/tags", timeout=10)
resp.raise_for_status()
names = [m["name"] for m in resp.json().get("models", [])]
return any(model in n for n in names)
except Exception:
return False
def _chat(model: str, messages: list[dict], tools: list | None = None) -> dict:
"""Send a chat request to Ollama."""
payload: dict = {"model": model, "messages": messages, "stream": False}
if tools:
payload["tools"] = tools
return _post("/api/chat", payload, timeout=120)
# ── Test cases ────────────────────────────────────────────────────────────────
def test_model_available(model: str) -> bool:
"""PASS: model is registered in Ollama."""
print(f"\n[1/5] Checking model availability: {model}")
if _check_model_available(model):
print(f"{model} is available in Ollama")
return True
print(
f"{model} not found. Import with:\n"
f" ollama create {model} -f Modelfile.hermes4-14b\n"
f" Or pull directly if on registry:\n"
f" ollama pull {model}"
)
return False
def test_basic_response(model: str) -> bool:
"""PASS: model responds coherently to a simple prompt."""
print(f"\n[2/5] Basic response test")
messages = [
{"role": "user", "content": "Reply with exactly: HERMES_OK"},
]
try:
t0 = time.time()
data = _chat(model, messages)
elapsed = time.time() - t0
content = data.get("message", {}).get("content", "")
if "HERMES_OK" in content:
print(f" ✓ Basic response OK ({elapsed:.1f}s): {content.strip()}")
return True
print(f" ✗ Unexpected response ({elapsed:.1f}s): {content[:200]!r}")
return False
except Exception as exc:
print(f" ✗ Request failed: {exc}")
return False
def test_memory_usage() -> bool:
"""PASS: Ollama process RSS is under MEMORY_LIMIT_GB."""
print(f"\n[3/5] Memory usage check (limit: {MEMORY_LIMIT_GB} GB)")
mem_gb = _ollama_memory_gb()
if mem_gb == 0.0:
print(" ~ Could not determine memory usage (ps unavailable?), skipping")
return True
if mem_gb < MEMORY_LIMIT_GB:
print(f" ✓ Memory usage: {mem_gb:.1f} GB (under {MEMORY_LIMIT_GB} GB limit)")
return True
print(
f" ✗ Memory usage: {mem_gb:.1f} GB exceeds {MEMORY_LIMIT_GB} GB limit.\n"
" Consider using Q4_K_M quantisation or reducing num_ctx."
)
return False
def test_tool_calling(model: str) -> bool:
"""PASS: model produces a tool_calls response (not raw text) for a tool-use prompt."""
print(f"\n[4/5] Tool-calling test")
messages = [
{
"role": "user",
"content": "Please read the file at /tmp/test.txt using the read_file tool.",
}
]
try:
t0 = time.time()
data = _chat(model, messages, tools=[READ_FILE_TOOL])
elapsed = time.time() - t0
msg = data.get("message", {})
tool_calls = msg.get("tool_calls", [])
if tool_calls:
tc = tool_calls[0]
fn = tc.get("function", {})
print(
f" ✓ Tool call produced ({elapsed:.1f}s):\n"
f" function: {fn.get('name')}\n"
f" arguments: {json.dumps(fn.get('arguments', {}), indent=6)}"
)
# Verify the function name is correct
return fn.get("name") == "read_file"
# Some models return JSON in the content instead of tool_calls
content = msg.get("content", "")
if "read_file" in content and "{" in content:
print(
f" ~ Model returned tool call as text (not structured). ({elapsed:.1f}s)\n"
f" This is acceptable for the base model before fine-tuning.\n"
f" Content: {content[:300]}"
)
# Partial pass — model attempted tool calling but via text
return True
print(
f" ✗ No tool call in response ({elapsed:.1f}s).\n"
f" Content: {content[:300]!r}"
)
return False
except Exception as exc:
print(f" ✗ Tool-calling request failed: {exc}")
return False
def test_timmy_persona(model: str) -> bool:
"""PASS: model accepts a Timmy persona system prompt and responds in-character."""
print(f"\n[5/5] Timmy-persona smoke test")
messages = [
{
"role": "system",
"content": (
"You are Timmy, Alexander's personal AI agent. "
"You are concise, direct, and helpful. "
"You always start your responses with 'Timmy here:'."
),
},
{
"role": "user",
"content": "What is your name and what can you help me with?",
},
]
try:
t0 = time.time()
data = _chat(model, messages)
elapsed = time.time() - t0
content = data.get("message", {}).get("content", "")
if "Timmy" in content or "timmy" in content.lower():
print(f" ✓ Persona accepted ({elapsed:.1f}s): {content[:200].strip()}")
return True
print(
f" ~ Persona response lacks 'Timmy' identifier ({elapsed:.1f}s).\n"
f" This is a fine-tuning target.\n"
f" Response: {content[:200]!r}"
)
# Soft pass — base model isn't expected to be perfectly in-character
return True
except Exception as exc:
print(f" ✗ Persona test failed: {exc}")
return False
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(description="Hermes 4 smoke test suite")
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help=f"Ollama model name (default: {DEFAULT_MODEL})",
)
parser.add_argument(
"--ollama-url",
default=OLLAMA_URL,
help=f"Ollama base URL (default: {OLLAMA_URL})",
)
args = parser.parse_args()
global OLLAMA_URL
OLLAMA_URL = args.ollama_url.rstrip("/")
model = args.model
print("=" * 60)
print(f"Hermes 4 Validation Suite — {model}")
print(f"Ollama: {OLLAMA_URL}")
print("=" * 60)
results: dict[str, bool] = {}
# Test 1: availability (gate — skip remaining if model missing)
results["available"] = test_model_available(model)
if not results["available"]:
print("\n⚠ Model not available — skipping remaining tests.")
print(" Import the model first (see Modelfile.hermes4-14b).")
_print_summary(results)
return 1
# Tests 25
results["basic_response"] = test_basic_response(model)
results["memory_usage"] = test_memory_usage()
results["tool_calling"] = test_tool_calling(model)
results["timmy_persona"] = test_timmy_persona(model)
return _print_summary(results)
def _print_summary(results: dict[str, bool]) -> int:
passed = sum(results.values())
total = len(results)
print("\n" + "=" * 60)
print(f"Results: {passed}/{total} passed")
print("=" * 60)
for name, ok in results.items():
icon = "" if ok else ""
print(f" {icon} {name}")
if passed == total:
print("\n✓ All tests passed. Hermes 4 is ready for AutoLoRA fine-tuning.")
print(" Next step: document WORK vs FAIL skill list → fine-tuning targets.")
elif results.get("tool_calling") is False:
print("\n⚠ Tool-calling FAILED. This is the primary fine-tuning target.")
print(" Base model may need LoRA tuning on tool-use examples.")
else:
print("\n~ Partial pass. Review failures above before fine-tuning.")
return 0 if passed == total else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -20,11 +20,28 @@ from datetime import datetime, timezone
from pathlib import Path
# ── Config ──────────────────────────────────────────────────────────────
GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1")
def _get_gitea_api() -> str:
"""Read Gitea API URL from env var, then ~/.hermes/gitea_api file, then default."""
# Check env vars first (TIMMY_GITEA_API is preferred, GITEA_API for compatibility)
api_url = os.environ.get("TIMMY_GITEA_API") or os.environ.get("GITEA_API")
if api_url:
return api_url
# Check ~/.hermes/gitea_api file
api_file = Path.home() / ".hermes" / "gitea_api"
if api_file.exists():
return api_file.read_text().strip()
# Default fallback
return "http://localhost:3000/api/v1"
GITEA_API = _get_gitea_api()
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
REPO_ROOT = Path(__file__).resolve().parent.parent
QUEUE_FILE = REPO_ROOT / ".loop" / "queue.json"
QUEUE_BACKUP_FILE = REPO_ROOT / ".loop" / "queue.json.bak"
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl"
QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json"
CYCLE_RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
@@ -326,9 +343,38 @@ def run_triage() -> list[dict]:
ready = [s for s in scored if s["ready"]]
not_ready = [s for s in scored if not s["ready"]]
# Save backup before writing (if current file exists and is valid)
if QUEUE_FILE.exists():
try:
json.loads(QUEUE_FILE.read_text()) # Validate current file
QUEUE_BACKUP_FILE.write_text(QUEUE_FILE.read_text())
except (json.JSONDecodeError, OSError):
pass # Current file is corrupt, don't overwrite backup
# Write new queue file
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
QUEUE_FILE.write_text(json.dumps(ready, indent=2) + "\n")
# Validate the write by re-reading and parsing
try:
json.loads(QUEUE_FILE.read_text())
except (json.JSONDecodeError, OSError) as exc:
print(f"[triage] ERROR: queue.json validation failed: {exc}", file=sys.stderr)
# Restore from backup if available
if QUEUE_BACKUP_FILE.exists():
try:
backup_data = QUEUE_BACKUP_FILE.read_text()
json.loads(backup_data) # Validate backup
QUEUE_FILE.write_text(backup_data)
print(f"[triage] Restored queue.json from backup")
except (json.JSONDecodeError, OSError) as restore_exc:
print(f"[triage] ERROR: Backup restore failed: {restore_exc}", file=sys.stderr)
# Write empty list as last resort
QUEUE_FILE.write_text("[]\n")
else:
# No backup, write empty list
QUEUE_FILE.write_text("[]\n")
# Write retro entry
retro_entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),

View File

@@ -0,0 +1,67 @@
---
name: Architecture Spike
type: research
typical_query_count: 2-4
expected_output_length: 600-1200 words
cascade_tier: groq_preferred
description: >
Investigate how to connect two systems or components. Produces an integration
architecture with sequence diagram, key decisions, and a proof-of-concept outline.
---
# Architecture Spike: Connect {system_a} to {system_b}
## Context
We need to integrate **{system_a}** with **{system_b}** in the context of
**{project_context}**. This spike answers: what is the best way to wire them
together, and what are the trade-offs?
## Constraints
- Prefer approaches that avoid adding new infrastructure dependencies.
- The integration should be **{sync_or_async}** (synchronous / asynchronous).
- Must work within: {environment_constraints}.
## Research Steps
1. Identify the APIs / protocols exposed by both systems.
2. List all known integration patterns (direct API, message queue, webhook, SDK, etc.).
3. Evaluate each pattern for complexity, reliability, and latency.
4. Select the recommended approach and outline a proof-of-concept.
## Output Format
### Integration Options
| Pattern | Complexity | Reliability | Latency | Notes |
|---------|-----------|-------------|---------|-------|
| ... | ... | ... | ... | ... |
### Recommended Approach
**Pattern:** {pattern_name}
**Why:** One paragraph explaining the choice.
### Sequence Diagram
```
{system_a} -> {middleware} -> {system_b}
```
Describe the data flow step by step:
1. {system_a} does X...
2. {middleware} transforms / routes...
3. {system_b} receives Y...
### Proof-of-Concept Outline
- Files to create or modify
- Key libraries / dependencies needed
- Estimated effort: {effort_estimate}
### Open Questions
Bullet list of decisions that need human input before proceeding.

View File

@@ -0,0 +1,74 @@
---
name: Competitive Scan
type: research
typical_query_count: 3-5
expected_output_length: 800-1500 words
cascade_tier: groq_preferred
description: >
Compare a project against its alternatives. Produces a feature matrix,
strengths/weaknesses analysis, and positioning summary.
---
# Competitive Scan: {project} vs Alternatives
## Context
Compare **{project}** against **{alternatives}** (comma-separated list of
competitors). The goal is to understand where {project} stands and identify
differentiation opportunities.
## Constraints
- Comparison date: {date}.
- Focus areas: {focus_areas} (e.g., features, pricing, community, performance).
- Perspective: {perspective} (user, developer, business).
## Research Steps
1. Gather key facts about {project} (features, pricing, community size, release cadence).
2. Gather the same data for each alternative in {alternatives}.
3. Build a feature comparison matrix.
4. Identify strengths and weaknesses for each entry.
5. Summarize positioning and recommend next steps.
## Output Format
### Overview
One paragraph: what space does {project} compete in, and who are the main players?
### Feature Matrix
| Feature / Attribute | {project} | {alt_1} | {alt_2} | {alt_3} |
|--------------------|-----------|---------|---------|---------|
| {feature_1} | ... | ... | ... | ... |
| {feature_2} | ... | ... | ... | ... |
| Pricing | ... | ... | ... | ... |
| License | ... | ... | ... | ... |
| Community Size | ... | ... | ... | ... |
| Last Major Release | ... | ... | ... | ... |
### Strengths & Weaknesses
#### {project}
- **Strengths:** ...
- **Weaknesses:** ...
#### {alt_1}
- **Strengths:** ...
- **Weaknesses:** ...
_(Repeat for each alternative)_
### Positioning Map
Describe where each project sits along the key dimensions (e.g., simplicity
vs power, free vs paid, niche vs general).
### Recommendations
Bullet list of actions based on the competitive landscape:
- **Differentiate on:** {differentiator}
- **Watch out for:** {threat}
- **Consider adopting from {alt}:** {feature_or_approach}

View File

@@ -0,0 +1,68 @@
---
name: Game Analysis
type: research
typical_query_count: 2-3
expected_output_length: 600-1000 words
cascade_tier: local_ok
description: >
Evaluate a game for AI agent playability. Assesses API availability,
observation/action spaces, and existing bot ecosystems.
---
# Game Analysis: {game}
## Context
Evaluate **{game}** to determine whether an AI agent can play it effectively.
Focus on programmatic access, observation space, action space, and existing
bot/AI ecosystems.
## Constraints
- Platform: {platform} (PC, console, mobile, browser).
- Agent type: {agent_type} (reinforcement learning, rule-based, LLM-driven, hybrid).
- Budget for API/licenses: {budget}.
## Research Steps
1. Identify official APIs, modding support, or programmatic access methods for {game}.
2. Characterize the observation space (screen pixels, game state JSON, memory reading, etc.).
3. Characterize the action space (keyboard/mouse, API calls, controller inputs).
4. Survey existing bots, AI projects, or research papers for {game}.
5. Assess feasibility and difficulty for the target agent type.
## Output Format
### Game Profile
| Property | Value |
|-------------------|------------------------|
| Game | {game} |
| Genre | {genre} |
| Platform | {platform} |
| API Available | Yes / No / Partial |
| Mod Support | Yes / No / Limited |
| Existing AI Work | Extensive / Some / None|
### Observation Space
Describe what data the agent can access and how (API, screen capture, memory hooks, etc.).
### Action Space
Describe how the agent can interact with the game (input methods, timing constraints, etc.).
### Existing Ecosystem
List known bots, frameworks, research papers, or communities working on AI for {game}.
### Feasibility Assessment
- **Difficulty:** Easy / Medium / Hard / Impractical
- **Best approach:** {recommended_agent_type}
- **Key challenges:** Bullet list
- **Estimated time to MVP:** {time_estimate}
### Recommendation
One paragraph: should we proceed, and if so, what is the first step?

View File

@@ -0,0 +1,79 @@
---
name: Integration Guide
type: research
typical_query_count: 3-5
expected_output_length: 1000-2000 words
cascade_tier: groq_preferred
description: >
Step-by-step guide to wire a specific tool into an existing stack,
complete with code samples, configuration, and testing steps.
---
# Integration Guide: Wire {tool} into {stack}
## Context
Integrate **{tool}** into our **{stack}** stack. The goal is to
**{integration_goal}** (e.g., "add vector search to the dashboard",
"send notifications via Telegram").
## Constraints
- Must follow existing project conventions (see CLAUDE.md).
- No new cloud AI dependencies unless explicitly approved.
- Environment config via `pydantic-settings` / `config.py`.
## Research Steps
1. Review {tool}'s official documentation for installation and setup.
2. Identify the minimal dependency set required.
3. Map {tool}'s API to our existing patterns (singletons, graceful degradation).
4. Write integration code with proper error handling.
5. Define configuration variables and their defaults.
## Output Format
### Prerequisites
- Dependencies to install (with versions)
- External services or accounts required
- Environment variables to configure
### Configuration
```python
# In config.py — add these fields to Settings:
{config_fields}
```
### Implementation
```python
# {file_path}
{implementation_code}
```
### Graceful Degradation
Describe how the integration behaves when {tool} is unavailable:
| Scenario | Behavior | Log Level |
|-----------------------|--------------------|-----------|
| {tool} not installed | {fallback} | WARNING |
| {tool} unreachable | {fallback} | WARNING |
| Invalid credentials | {fallback} | ERROR |
### Testing
```python
# tests/unit/test_{tool_snake}.py
{test_code}
```
### Verification Checklist
- [ ] Dependency added to pyproject.toml
- [ ] Config fields added with sensible defaults
- [ ] Graceful degradation tested (service down)
- [ ] Unit tests pass (`tox -e unit`)
- [ ] No new linting errors (`tox -e lint`)

View File

@@ -0,0 +1,67 @@
---
name: State of the Art
type: research
typical_query_count: 4-6
expected_output_length: 1000-2000 words
cascade_tier: groq_preferred
description: >
Comprehensive survey of what currently exists in a given field or domain.
Produces a structured landscape overview with key players, trends, and gaps.
---
# State of the Art: {field} (as of {date})
## Context
Survey the current landscape of **{field}**. Identify key players, recent
developments, dominant approaches, and notable gaps. This is a point-in-time
snapshot intended to inform decision-making.
## Constraints
- Focus on developments from the last {timeframe} (e.g., 12 months, 2 years).
- Prioritize {priority} (open-source, commercial, academic, or all).
- Target audience: {audience} (technical team, leadership, general).
## Research Steps
1. Identify the major categories or sub-domains within {field}.
2. For each category, list the leading projects, companies, or research groups.
3. Note recent milestones, releases, or breakthroughs.
4. Identify emerging trends and directions.
5. Highlight gaps — things that don't exist yet but should.
## Output Format
### Executive Summary
Two to three sentences: what is the state of {field} right now?
### Landscape Map
| Category | Key Players | Maturity | Trend |
|---------------|--------------------------|-------------|-------------|
| {category_1} | {player_a}, {player_b} | Early / GA | Growing / Stable / Declining |
| {category_2} | {player_c}, {player_d} | Early / GA | Growing / Stable / Declining |
### Recent Milestones
Chronological list of notable events in the last {timeframe}:
- **{date_1}:** {event_description}
- **{date_2}:** {event_description}
### Trends
Numbered list of the top 3-5 trends shaping {field}:
1. **{trend_name}** — {one-line description}
2. **{trend_name}** — {one-line description}
### Gaps & Opportunities
Bullet list of things that are missing, underdeveloped, or ripe for innovation.
### Implications for Us
One paragraph: what does this mean for our project? What should we do next?

View File

@@ -0,0 +1,52 @@
---
name: Tool Evaluation
type: research
typical_query_count: 3-5
expected_output_length: 800-1500 words
cascade_tier: groq_preferred
description: >
Discover and evaluate all shipping tools/libraries/services in a given domain.
Produces a ranked comparison table with pros, cons, and recommendation.
---
# Tool Evaluation: {domain}
## Context
You are researching tools, libraries, and services for **{domain}**.
The goal is to find everything that is currently shipping (not vaporware)
and produce a structured comparison.
## Constraints
- Only include tools that have public releases or hosted services available today.
- If a tool is in beta/preview, note that clearly.
- Focus on {focus_criteria} when evaluating (e.g., cost, ease of integration, community size).
## Research Steps
1. Identify all actively-maintained tools in the **{domain}** space.
2. For each tool, gather: name, URL, license/pricing, last release date, language/platform.
3. Evaluate each tool against the focus criteria.
4. Rank by overall fit for the use case: **{use_case}**.
## Output Format
### Summary
One paragraph: what the landscape looks like and the top recommendation.
### Comparison Table
| Tool | License / Price | Last Release | Language | {focus_criteria} Score | Notes |
|------|----------------|--------------|----------|----------------------|-------|
| ... | ... | ... | ... | ... | ... |
### Top Pick
- **Recommended:** {tool_name} — {one-line reason}
- **Runner-up:** {tool_name} — {one-line reason}
### Risks & Gaps
Bullet list of things to watch out for (missing features, vendor lock-in, etc.).

View File

@@ -87,14 +87,26 @@ class Settings(BaseSettings):
xai_base_url: str = "https://api.x.ai/v1"
grok_default_model: str = "grok-3-fast"
grok_max_sats_per_query: int = 200
grok_sats_hard_cap: int = 100 # Absolute ceiling on sats per Grok query
grok_free: bool = False # Skip Lightning invoice when user has own API key
# ── Database ──────────────────────────────────────────────────────────
db_busy_timeout_ms: int = 5000 # SQLite PRAGMA busy_timeout (ms)
# ── Claude (Anthropic) — cloud fallback backend ────────────────────────
# Used when Ollama is offline and local inference isn't available.
# Set ANTHROPIC_API_KEY to enable. Default model is Haiku (fast + cheap).
anthropic_api_key: str = ""
claude_model: str = "haiku"
# ── Content Moderation ──────────────────────────────────────────────
# Three-layer moderation pipeline for AI narrator output.
# Uses Llama Guard via Ollama with regex fallback.
moderation_enabled: bool = True
moderation_guard_model: str = "llama-guard3:1b"
# Default confidence threshold — per-game profiles can override.
moderation_threshold: float = 0.8
# ── Spark Intelligence ────────────────────────────────────────────────
# Enable/disable the Spark cognitive layer.
# When enabled, Spark captures swarm events, runs EIDOS predictions,
@@ -140,6 +152,10 @@ class Settings(BaseSettings):
# Default is False (telemetry disabled) to align with sovereign AI vision.
telemetry_enabled: bool = False
# ── Sovereignty Metrics ──────────────────────────────────────────────
# Alert when API cost per research task exceeds this threshold (USD).
sovereignty_api_cost_alert_threshold: float = 1.00
# CORS allowed origins for the web chat interface (Gitea Pages, etc.)
# Set CORS_ORIGINS as a comma-separated list, e.g. "http://localhost:3000,https://example.com"
cors_origins: list[str] = [
@@ -149,6 +165,18 @@ class Settings(BaseSettings):
"http://127.0.0.1:8000",
]
# ── Matrix Frontend Integration ────────────────────────────────────────
# URL of the Matrix frontend (Replit/Tailscale) for CORS.
# When set, this origin is added to CORS allowed_origins.
# Example: "http://100.124.176.28:8080" or "https://alexanderwhitestone.com"
matrix_frontend_url: str = "" # Empty = disabled
# WebSocket authentication token for Matrix connections.
# When set, clients must provide this token via ?token= query param
# or in the first message as {"type": "auth", "token": "..."}.
# Empty/unset = auth disabled (dev mode).
matrix_ws_token: str = ""
# Trusted hosts for the Host header check (TrustedHostMiddleware).
# Set TRUSTED_HOSTS as a comma-separated list. Wildcards supported (e.g. "*.ts.net").
# Defaults include localhost + Tailscale MagicDNS. Add your Tailscale IP if needed.
@@ -189,6 +217,10 @@ class Settings(BaseSettings):
# ── Test / Diagnostics ─────────────────────────────────────────────
# Skip loading heavy embedding models (for tests / low-memory envs).
timmy_skip_embeddings: bool = False
# Embedding backend: "ollama" for Ollama, "local" for sentence-transformers.
timmy_embedding_backend: Literal["ollama", "local"] = "ollama"
# Ollama model to use for embeddings (e.g., "nomic-embed-text").
ollama_embedding_model: str = "nomic-embed-text"
# Disable CSRF middleware entirely (for tests).
timmy_disable_csrf: bool = False
# Mark the process as running in test mode.
@@ -274,6 +306,7 @@ class Settings(BaseSettings):
mcp_gitea_command: str = "gitea-mcp-server -t stdio"
mcp_filesystem_command: str = "npx -y @modelcontextprotocol/server-filesystem"
mcp_timeout: int = 15
mcp_bridge_timeout: int = 60 # HTTP timeout for MCP bridge Ollama calls (seconds)
# ── Loop QA (Self-Testing) ─────────────────────────────────────────
# Self-test orchestrator that probes capabilities alongside the thinking loop.
@@ -318,6 +351,13 @@ class Settings(BaseSettings):
autoresearch_max_iterations: int = 100
autoresearch_metric: str = "val_bpb" # metric to optimise (lower = better)
# ── Weekly Narrative Summary ───────────────────────────────────────
# Generates a human-readable weekly summary of development activity.
# Disabling this will stop the weekly narrative generation.
weekly_narrative_enabled: bool = True
weekly_narrative_lookback_days: int = 7
weekly_narrative_output_dir: str = ".loop"
# ── Local Hands (Shell + Git) ──────────────────────────────────────
# Enable local shell/git execution hands.
hands_shell_enabled: bool = True

View File

@@ -10,6 +10,7 @@ Key improvements:
import asyncio
import json
import logging
import re
from contextlib import asynccontextmanager
from pathlib import Path
@@ -23,6 +24,7 @@ from config import settings
# Import dedicated middleware
from dashboard.middleware.csrf import CSRFMiddleware
from dashboard.middleware.rate_limit import RateLimitMiddleware
from dashboard.middleware.request_logging import RequestLoggingMiddleware
from dashboard.middleware.security_headers import SecurityHeadersMiddleware
from dashboard.routes.agents import router as agents_router
@@ -30,6 +32,7 @@ from dashboard.routes.briefing import router as briefing_router
from dashboard.routes.calm import router as calm_router
from dashboard.routes.chat_api import router as chat_api_router
from dashboard.routes.chat_api_v1 import router as chat_api_v1_router
from dashboard.routes.daily_run import router as daily_run_router
from dashboard.routes.db_explorer import router as db_explorer_router
from dashboard.routes.discord import router as discord_router
from dashboard.routes.experiments import router as experiments_router
@@ -40,6 +43,9 @@ from dashboard.routes.memory import router as memory_router
from dashboard.routes.mobile import router as mobile_router
from dashboard.routes.models import api_router as models_api_router
from dashboard.routes.models import router as models_router
from dashboard.routes.quests import router as quests_router
from dashboard.routes.scorecards import router as scorecards_router
from dashboard.routes.sovereignty_metrics import router as sovereignty_metrics_router
from dashboard.routes.spark import router as spark_router
from dashboard.routes.system import router as system_router
from dashboard.routes.tasks import router as tasks_router
@@ -49,6 +55,7 @@ from dashboard.routes.tools import router as tools_router
from dashboard.routes.tower import router as tower_router
from dashboard.routes.voice import router as voice_router
from dashboard.routes.work_orders import router as work_orders_router
from dashboard.routes.world import matrix_router
from dashboard.routes.world import router as world_router
from timmy.workshop_state import PRESENCE_FILE
@@ -368,13 +375,21 @@ def _startup_init() -> None:
def _startup_background_tasks() -> list[asyncio.Task]:
"""Spawn all recurring background tasks (non-blocking)."""
return [
bg_tasks = [
asyncio.create_task(_briefing_scheduler()),
asyncio.create_task(_thinking_scheduler()),
asyncio.create_task(_loop_qa_scheduler()),
asyncio.create_task(_presence_watcher()),
asyncio.create_task(_start_chat_integrations_background()),
]
try:
from timmy.paperclip import start_paperclip_poller
bg_tasks.append(asyncio.create_task(start_paperclip_poller()))
logger.info("Paperclip poller started")
except ImportError:
logger.debug("Paperclip module not found, skipping poller")
return bg_tasks
def _try_prune(label: str, prune_fn, days: int) -> None:
@@ -519,25 +534,55 @@ app = FastAPI(
def _get_cors_origins() -> list[str]:
"""Get CORS origins from settings, rejecting wildcards in production."""
origins = settings.cors_origins
"""Get CORS origins from settings, rejecting wildcards in production.
Adds matrix_frontend_url when configured. Always allows Tailscale IPs
(100.x.x.x range) for development convenience.
"""
origins = list(settings.cors_origins)
# Strip wildcards in production (security)
if "*" in origins and not settings.debug:
logger.warning(
"Wildcard '*' in CORS_ORIGINS stripped in production — "
"set explicit origins via CORS_ORIGINS env var"
)
origins = [o for o in origins if o != "*"]
# Add Matrix frontend URL if configured
if settings.matrix_frontend_url:
url = settings.matrix_frontend_url.strip()
if url and url not in origins:
origins.append(url)
logger.debug("Added Matrix frontend to CORS: %s", url)
return origins
# Pattern to match Tailscale IPs (100.x.x.x) for CORS origin regex
_TAILSCALE_IP_PATTERN = re.compile(r"^https?://100\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?$")
def _is_tailscale_origin(origin: str) -> bool:
"""Check if origin is a Tailscale IP (100.x.x.x range)."""
return bool(_TAILSCALE_IP_PATTERN.match(origin))
# Add dedicated middleware in correct order
# 1. Logging (outermost to capture everything)
app.add_middleware(RequestLoggingMiddleware, skip_paths=["/health"])
# 2. Security Headers
# 2. Rate Limiting (before security to prevent abuse early)
app.add_middleware(
RateLimitMiddleware,
path_prefixes=["/api/matrix/"],
requests_per_minute=30,
)
# 3. Security Headers
app.add_middleware(SecurityHeadersMiddleware, production=not settings.debug)
# 3. CSRF Protection
# 4. CSRF Protection
app.add_middleware(CSRFMiddleware)
# 4. Standard FastAPI middleware
@@ -551,6 +596,7 @@ app.add_middleware(
app.add_middleware(
CORSMiddleware,
allow_origins=_get_cors_origins(),
allow_origin_regex=r"https?://100\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?",
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
@@ -589,7 +635,12 @@ app.include_router(system_router)
app.include_router(experiments_router)
app.include_router(db_explorer_router)
app.include_router(world_router)
app.include_router(matrix_router)
app.include_router(tower_router)
app.include_router(daily_run_router)
app.include_router(quests_router)
app.include_router(scorecards_router)
app.include_router(sovereignty_metrics_router)
@app.websocket("/ws")

View File

@@ -1,6 +1,7 @@
"""Dashboard middleware package."""
from .csrf import CSRFMiddleware, csrf_exempt, generate_csrf_token, validate_csrf_token
from .rate_limit import RateLimiter, RateLimitMiddleware
from .request_logging import RequestLoggingMiddleware
from .security_headers import SecurityHeadersMiddleware
@@ -9,6 +10,8 @@ __all__ = [
"csrf_exempt",
"generate_csrf_token",
"validate_csrf_token",
"RateLimiter",
"RateLimitMiddleware",
"SecurityHeadersMiddleware",
"RequestLoggingMiddleware",
]

View File

@@ -131,7 +131,6 @@ class CSRFMiddleware(BaseHTTPMiddleware):
For safe methods: Set a CSRF token cookie if not present.
For unsafe methods: Validate the CSRF token or check if exempt.
"""
# Bypass CSRF if explicitly disabled (e.g. in tests)
from config import settings
if settings.timmy_disable_csrf:
@@ -141,46 +140,55 @@ class CSRFMiddleware(BaseHTTPMiddleware):
if request.headers.get("upgrade", "").lower() == "websocket":
return await call_next(request)
# Get existing CSRF token from cookie
csrf_cookie = request.cookies.get(self.cookie_name)
# For safe methods, just ensure a token exists
if request.method in self.SAFE_METHODS:
response = await call_next(request)
return await self._handle_safe_method(request, call_next, csrf_cookie)
# Set CSRF token cookie if not present
if not csrf_cookie:
new_token = generate_csrf_token()
response.set_cookie(
key=self.cookie_name,
value=new_token,
httponly=False, # Must be readable by JavaScript
secure=settings.csrf_cookie_secure,
samesite="Lax",
max_age=86400, # 24 hours
)
return await self._handle_unsafe_method(request, call_next, csrf_cookie)
return response
async def _handle_safe_method(
self, request: Request, call_next, csrf_cookie: str | None
) -> Response:
"""Handle safe HTTP methods (GET, HEAD, OPTIONS, TRACE).
# For unsafe methods, we need to validate or check if exempt
# First, try to validate the CSRF token
Forwards the request and sets a CSRF token cookie if not present.
"""
from config import settings
response = await call_next(request)
if not csrf_cookie:
new_token = generate_csrf_token()
response.set_cookie(
key=self.cookie_name,
value=new_token,
httponly=False, # Must be readable by JavaScript
secure=settings.csrf_cookie_secure,
samesite="Lax",
max_age=86400, # 24 hours
)
return response
async def _handle_unsafe_method(
self, request: Request, call_next, csrf_cookie: str | None
) -> Response:
"""Handle unsafe HTTP methods (POST, PUT, DELETE, PATCH).
Validates the CSRF token, checks path and endpoint exemptions,
or returns a 403 error.
"""
if await self._validate_request(request, csrf_cookie):
# Token is valid, allow the request
return await call_next(request)
# Token validation failed, check if the path is exempt
path = request.url.path
if self._is_likely_exempt(path):
# Path is exempt, allow the request
if self._is_likely_exempt(request.url.path):
return await call_next(request)
# Token validation failed and path is not exempt
# Resolve the endpoint from routes BEFORE executing to avoid side effects
endpoint = self._resolve_endpoint(request)
if endpoint and is_csrf_exempt(endpoint):
return await call_next(request)
# Endpoint is not exempt and token validation failed — reject without executing
return JSONResponse(
status_code=403,
content={
@@ -191,39 +199,38 @@ class CSRFMiddleware(BaseHTTPMiddleware):
)
def _resolve_endpoint(self, request: Request) -> Callable | None:
"""Resolve the endpoint for a request without executing it.
"""Resolve the route endpoint without executing it.
Walks the app chain to find routes, then matches against the request
scope. This allows checking @csrf_exempt before the handler runs
(avoiding side effects on CSRF rejection).
Walks the Starlette/FastAPI router to find which endpoint function
handles this request, so we can check @csrf_exempt before any
side effects occur.
Returns:
The endpoint callable if found, None otherwise.
The endpoint callable, or None if no route matched.
"""
try:
from starlette.routing import Match
# If routing already happened (endpoint in scope), use it
endpoint = request.scope.get("endpoint")
if endpoint:
return endpoint
# Walk the middleware/app chain to find something with routes
routes = None
current = self.app
for _ in range(10): # Safety limit
routes = getattr(current, "routes", None)
if routes:
break
current = getattr(current, "app", None)
if current is None:
break
# Walk the middleware/app chain to find something with routes
from starlette.routing import Match
if not routes:
return None
app = self.app
while app is not None:
if hasattr(app, "routes"):
for route in app.routes:
match, _ = route.matches(request.scope)
if match == Match.FULL:
return getattr(route, "endpoint", None)
# Try .router (FastAPI stores routes on app.router)
if hasattr(app, "router") and hasattr(app.router, "routes"):
for route in app.router.routes:
match, _ = route.matches(request.scope)
if match == Match.FULL:
return getattr(route, "endpoint", None)
app = getattr(app, "app", None)
scope = dict(request.scope)
for route in routes:
match, child_scope = route.matches(scope)
if match == Match.FULL:
return child_scope.get("endpoint")
except Exception:
logger.debug("Failed to resolve endpoint for CSRF check")
return None
def _is_likely_exempt(self, path: str) -> bool:

View File

@@ -0,0 +1,209 @@
"""Rate limiting middleware for FastAPI.
Simple in-memory rate limiter for API endpoints. Tracks requests per IP
with configurable limits and automatic cleanup of stale entries.
"""
import logging
import time
from collections import deque
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
logger = logging.getLogger(__name__)
class RateLimiter:
"""In-memory rate limiter for tracking requests per IP.
Stores request timestamps in a dict keyed by client IP.
Automatically cleans up stale entries every 60 seconds.
Attributes:
requests_per_minute: Maximum requests allowed per minute per IP.
cleanup_interval_seconds: How often to clean stale entries.
"""
def __init__(
self,
requests_per_minute: int = 30,
cleanup_interval_seconds: int = 60,
):
self.requests_per_minute = requests_per_minute
self.cleanup_interval_seconds = cleanup_interval_seconds
self._storage: dict[str, deque[float]] = {}
self._last_cleanup: float = time.time()
self._window_seconds: float = 60.0 # 1 minute window
def _get_client_ip(self, request: Request) -> str:
"""Extract client IP from request, respecting X-Forwarded-For header.
Args:
request: The incoming request.
Returns:
Client IP address string.
"""
# Check for forwarded IP (when behind proxy/load balancer)
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
# Take the first IP in the chain
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip
# Fall back to direct connection
if request.client:
return request.client.host
return "unknown"
def _cleanup_if_needed(self) -> None:
"""Remove stale entries older than the cleanup interval."""
now = time.time()
if now - self._last_cleanup < self.cleanup_interval_seconds:
return
cutoff = now - self._window_seconds
stale_ips: list[str] = []
for ip, timestamps in self._storage.items():
# Remove timestamps older than the window
while timestamps and timestamps[0] < cutoff:
timestamps.popleft()
# Mark IP for removal if no recent requests
if not timestamps:
stale_ips.append(ip)
# Remove stale IP entries
for ip in stale_ips:
del self._storage[ip]
self._last_cleanup = now
if stale_ips:
logger.debug("Rate limiter cleanup: removed %d stale IPs", len(stale_ips))
def is_allowed(self, client_ip: str) -> tuple[bool, float]:
"""Check if a request from the given IP is allowed.
Args:
client_ip: The client's IP address.
Returns:
Tuple of (allowed: bool, retry_after: float).
retry_after is seconds until next allowed request, 0 if allowed now.
"""
now = time.time()
cutoff = now - self._window_seconds
# Get or create timestamp deque for this IP
if client_ip not in self._storage:
self._storage[client_ip] = deque()
timestamps = self._storage[client_ip]
# Remove timestamps outside the window
while timestamps and timestamps[0] < cutoff:
timestamps.popleft()
# Check if limit exceeded
if len(timestamps) >= self.requests_per_minute:
# Calculate retry after time
oldest = timestamps[0]
retry_after = self._window_seconds - (now - oldest)
return False, max(0.0, retry_after)
# Record this request
timestamps.append(now)
return True, 0.0
def check_request(self, request: Request) -> tuple[bool, float]:
"""Check if the request is allowed under rate limits.
Args:
request: The incoming request.
Returns:
Tuple of (allowed: bool, retry_after: float).
"""
self._cleanup_if_needed()
client_ip = self._get_client_ip(request)
return self.is_allowed(client_ip)
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Middleware to apply rate limiting to specific routes.
Usage:
# Apply to all routes (not recommended for public static files)
app.add_middleware(RateLimitMiddleware)
# Apply only to specific paths
app.add_middleware(
RateLimitMiddleware,
path_prefixes=["/api/matrix/"],
requests_per_minute=30,
)
Attributes:
path_prefixes: List of URL path prefixes to rate limit.
If empty, applies to all paths.
requests_per_minute: Maximum requests per minute per IP.
"""
def __init__(
self,
app,
path_prefixes: list[str] | None = None,
requests_per_minute: int = 30,
):
super().__init__(app)
self.path_prefixes = path_prefixes or []
self.limiter = RateLimiter(requests_per_minute=requests_per_minute)
def _should_rate_limit(self, path: str) -> bool:
"""Check if the given path should be rate limited.
Args:
path: The request URL path.
Returns:
True if path matches any configured prefix.
"""
if not self.path_prefixes:
return True
return any(path.startswith(prefix) for prefix in self.path_prefixes)
async def dispatch(self, request: Request, call_next) -> Response:
"""Apply rate limiting to configured paths.
Args:
request: The incoming request.
call_next: Callable to get the response from downstream.
Returns:
Response from downstream, or 429 if rate limited.
"""
# Skip if path doesn't match configured prefixes
if not self._should_rate_limit(request.url.path):
return await call_next(request)
# Check rate limit
allowed, retry_after = self.limiter.check_request(request)
if not allowed:
return JSONResponse(
status_code=429,
content={
"error": "Rate limit exceeded. Try again later.",
"retry_after": int(retry_after) + 1,
},
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Process the request
return await call_next(request)

View File

@@ -42,6 +42,114 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
self.skip_paths = set(skip_paths or [])
self.log_level = log_level
def _should_skip_path(self, path: str) -> bool:
"""Check if the request path should be skipped from logging.
Args:
path: The request URL path.
Returns:
True if the path should be skipped, False otherwise.
"""
return path in self.skip_paths
def _prepare_request_context(self, request: Request) -> tuple[str, float]:
"""Prepare context for request processing.
Generates a correlation ID and records the start time.
Args:
request: The incoming request.
Returns:
Tuple of (correlation_id, start_time).
"""
correlation_id = str(uuid.uuid4())[:8]
request.state.correlation_id = correlation_id
start_time = time.time()
return correlation_id, start_time
def _get_duration_ms(self, start_time: float) -> float:
"""Calculate the request duration in milliseconds.
Args:
start_time: The start time from time.time().
Returns:
Duration in milliseconds.
"""
return (time.time() - start_time) * 1000
def _log_success(
self,
request: Request,
response: Response,
correlation_id: str,
duration_ms: float,
client_ip: str,
user_agent: str,
) -> None:
"""Log a successful request.
Args:
request: The incoming request.
response: The response from downstream.
correlation_id: The request correlation ID.
duration_ms: Request duration in milliseconds.
client_ip: Client IP address.
user_agent: User-Agent header value.
"""
self._log_request(
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=duration_ms,
client_ip=client_ip,
user_agent=user_agent,
correlation_id=correlation_id,
)
def _log_error(
self,
request: Request,
exc: Exception,
correlation_id: str,
duration_ms: float,
client_ip: str,
) -> None:
"""Log a failed request and capture the error.
Args:
request: The incoming request.
exc: The exception that was raised.
correlation_id: The request correlation ID.
duration_ms: Request duration in milliseconds.
client_ip: Client IP address.
"""
logger.error(
f"[{correlation_id}] {request.method} {request.url.path} "
f"- ERROR - {duration_ms:.2f}ms - {client_ip} - {str(exc)}"
)
# Auto-escalate: create bug report task from unhandled exception
try:
from infrastructure.error_capture import capture_error
capture_error(
exc,
source="http",
context={
"method": request.method,
"path": request.url.path,
"correlation_id": correlation_id,
"client_ip": client_ip,
"duration_ms": f"{duration_ms:.0f}",
},
)
except Exception:
logger.warning("Escalation logging error: capture failed")
# never let escalation break the request
async def dispatch(self, request: Request, call_next) -> Response:
"""Log the request and response details.
@@ -52,74 +160,23 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
Returns:
The response from downstream.
"""
# Check if we should skip logging this path
if request.url.path in self.skip_paths:
if self._should_skip_path(request.url.path):
return await call_next(request)
# Generate correlation ID
correlation_id = str(uuid.uuid4())[:8]
request.state.correlation_id = correlation_id
# Record start time
start_time = time.time()
# Get client info
correlation_id, start_time = self._prepare_request_context(request)
client_ip = self._get_client_ip(request)
user_agent = request.headers.get("user-agent", "-")
try:
# Process the request
response = await call_next(request)
# Calculate duration
duration_ms = (time.time() - start_time) * 1000
# Log the request
self._log_request(
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=duration_ms,
client_ip=client_ip,
user_agent=user_agent,
correlation_id=correlation_id,
)
# Add correlation ID to response headers
duration_ms = self._get_duration_ms(start_time)
self._log_success(request, response, correlation_id, duration_ms, client_ip, user_agent)
response.headers["X-Correlation-ID"] = correlation_id
return response
except Exception as exc:
# Calculate duration even for failed requests
duration_ms = (time.time() - start_time) * 1000
# Log the error
logger.error(
f"[{correlation_id}] {request.method} {request.url.path} "
f"- ERROR - {duration_ms:.2f}ms - {client_ip} - {str(exc)}"
)
# Auto-escalate: create bug report task from unhandled exception
try:
from infrastructure.error_capture import capture_error
capture_error(
exc,
source="http",
context={
"method": request.method,
"path": request.url.path,
"correlation_id": correlation_id,
"client_ip": client_ip,
"duration_ms": f"{duration_ms:.0f}",
},
)
except Exception as exc:
logger.debug("Escalation logging error: %s", exc)
pass # never let escalation break the request
# Re-raise the exception
duration_ms = self._get_duration_ms(start_time)
self._log_error(request, exc, correlation_id, duration_ms, client_ip)
raise
def _get_client_ip(self, request: Request) -> str:

View File

@@ -0,0 +1,435 @@
"""Daily Run metrics routes — dashboard card for triage and session metrics."""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request as UrlRequest
from urllib.request import urlopen
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from config import settings
from dashboard.templating import templates
logger = logging.getLogger(__name__)
router = APIRouter(tags=["daily-run"])
REPO_ROOT = Path(settings.repo_root)
CONFIG_PATH = REPO_ROOT / "timmy_automations" / "config" / "daily_run.json"
DEFAULT_CONFIG = {
"gitea_api": "http://localhost:3000/api/v1",
"repo_slug": "rockachopa/Timmy-time-dashboard",
"token_file": "~/.hermes/gitea_token",
"layer_labels_prefix": "layer:",
}
LAYER_LABELS = ["layer:triage", "layer:micro-fix", "layer:tests", "layer:economy"]
def _load_config() -> dict:
"""Load configuration from config file with fallback to defaults."""
config = DEFAULT_CONFIG.copy()
if CONFIG_PATH.exists():
try:
file_config = json.loads(CONFIG_PATH.read_text())
if "orchestrator" in file_config:
config.update(file_config["orchestrator"])
except (json.JSONDecodeError, OSError) as exc:
logger.debug("Could not load daily_run config: %s", exc)
# Environment variable overrides
if os.environ.get("TIMMY_GITEA_API"):
config["gitea_api"] = os.environ.get("TIMMY_GITEA_API")
if os.environ.get("TIMMY_REPO_SLUG"):
config["repo_slug"] = os.environ.get("TIMMY_REPO_SLUG")
if os.environ.get("TIMMY_GITEA_TOKEN"):
config["token"] = os.environ.get("TIMMY_GITEA_TOKEN")
return config
def _get_token(config: dict) -> str | None:
"""Get Gitea token from environment or file."""
if "token" in config:
return config["token"]
token_file = Path(config["token_file"]).expanduser()
if token_file.exists():
return token_file.read_text().strip()
return None
class GiteaClient:
"""Simple Gitea API client with graceful degradation."""
def __init__(self, config: dict, token: str | None):
self.api_base = config["gitea_api"].rstrip("/")
self.repo_slug = config["repo_slug"]
self.token = token
self._available: bool | None = None
def _headers(self) -> dict:
headers = {"Accept": "application/json"}
if self.token:
headers["Authorization"] = f"token {self.token}"
return headers
def _api_url(self, path: str) -> str:
return f"{self.api_base}/repos/{self.repo_slug}/{path}"
def is_available(self) -> bool:
"""Check if Gitea API is reachable."""
if self._available is not None:
return self._available
try:
req = UrlRequest(
f"{self.api_base}/version",
headers=self._headers(),
method="GET",
)
with urlopen(req, timeout=5) as resp:
self._available = resp.status == 200
return self._available
except (HTTPError, URLError, TimeoutError):
self._available = False
return False
def get_paginated(self, path: str, params: dict | None = None) -> list:
"""Fetch all pages of a paginated endpoint."""
all_items = []
page = 1
limit = 50
while True:
url = self._api_url(path)
query_parts = [f"limit={limit}", f"page={page}"]
if params:
for key, val in params.items():
query_parts.append(f"{key}={val}")
url = f"{url}?{'&'.join(query_parts)}"
req = UrlRequest(url, headers=self._headers(), method="GET")
with urlopen(req, timeout=15) as resp:
batch = json.loads(resp.read())
if not batch:
break
all_items.extend(batch)
if len(batch) < limit:
break
page += 1
return all_items
@dataclass
class LayerMetrics:
"""Metrics for a single layer."""
name: str
label: str
current_count: int
previous_count: int
@property
def trend(self) -> str:
"""Return trend indicator."""
if self.previous_count == 0:
return "" if self.current_count == 0 else ""
diff = self.current_count - self.previous_count
pct = (diff / self.previous_count) * 100
if pct > 20:
return "↑↑"
elif pct > 5:
return ""
elif pct < -20:
return "↓↓"
elif pct < -5:
return ""
return ""
@property
def trend_color(self) -> str:
"""Return color for trend (CSS variable name)."""
trend = self.trend
if trend in ("↑↑", ""):
return "var(--green)" # More work = positive
elif trend in ("↓↓", ""):
return "var(--amber)" # Less work = caution
return "var(--text-dim)"
@dataclass
class DailyRunMetrics:
"""Complete Daily Run metrics."""
sessions_completed: int
sessions_previous: int
layers: list[LayerMetrics]
total_touched_current: int
total_touched_previous: int
lookback_days: int
generated_at: str
@property
def sessions_trend(self) -> str:
"""Return sessions trend indicator."""
if self.sessions_previous == 0:
return "" if self.sessions_completed == 0 else ""
diff = self.sessions_completed - self.sessions_previous
pct = (diff / self.sessions_previous) * 100
if pct > 20:
return "↑↑"
elif pct > 5:
return ""
elif pct < -20:
return "↓↓"
elif pct < -5:
return ""
return ""
@property
def sessions_trend_color(self) -> str:
"""Return color for sessions trend."""
trend = self.sessions_trend
if trend in ("↑↑", ""):
return "var(--green)"
elif trend in ("↓↓", ""):
return "var(--amber)"
return "var(--text-dim)"
def _extract_layer(labels: list[dict]) -> str | None:
"""Extract layer label from issue labels."""
for label in labels:
name = label.get("name", "")
if name.startswith("layer:"):
return name.replace("layer:", "")
return None
def _load_cycle_data(days: int = 14) -> dict:
"""Load cycle retrospective data for session counting."""
retro_file = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
if not retro_file.exists():
return {"current": 0, "previous": 0}
try:
entries = []
for line in retro_file.read_text().strip().splitlines():
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
now = datetime.now(UTC)
current_cutoff = now - timedelta(days=days)
previous_cutoff = now - timedelta(days=days * 2)
current_count = 0
previous_count = 0
for entry in entries:
ts_str = entry.get("timestamp", "")
if not ts_str:
continue
try:
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
if ts >= current_cutoff:
if entry.get("success", False):
current_count += 1
elif ts >= previous_cutoff:
if entry.get("success", False):
previous_count += 1
except (ValueError, TypeError):
continue
return {"current": current_count, "previous": previous_count}
except (OSError, ValueError) as exc:
logger.debug("Failed to load cycle data: %s", exc)
return {"current": 0, "previous": 0}
def _fetch_layer_metrics(
client: GiteaClient, lookback_days: int = 7
) -> tuple[list[LayerMetrics], int, int]:
"""Fetch metrics for each layer from Gitea issues."""
now = datetime.now(UTC)
current_cutoff = now - timedelta(days=lookback_days)
previous_cutoff = now - timedelta(days=lookback_days * 2)
layers = []
total_current = 0
total_previous = 0
for layer_label in LAYER_LABELS:
layer_name = layer_label.replace("layer:", "")
try:
# Fetch all issues with this layer label (both open and closed)
issues = client.get_paginated(
"issues",
{"state": "all", "labels": layer_label, "limit": 100},
)
current_count = 0
previous_count = 0
for issue in issues:
updated_at = issue.get("updated_at", "")
if not updated_at:
continue
try:
updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
if updated >= current_cutoff:
current_count += 1
elif updated >= previous_cutoff:
previous_count += 1
except (ValueError, TypeError):
continue
layers.append(
LayerMetrics(
name=layer_name,
label=layer_label,
current_count=current_count,
previous_count=previous_count,
)
)
total_current += current_count
total_previous += previous_count
except (HTTPError, URLError) as exc:
logger.debug("Failed to fetch issues for %s: %s", layer_label, exc)
layers.append(
LayerMetrics(
name=layer_name,
label=layer_label,
current_count=0,
previous_count=0,
)
)
return layers, total_current, total_previous
def _get_metrics(lookback_days: int = 7) -> DailyRunMetrics | None:
"""Get Daily Run metrics from Gitea API."""
config = _load_config()
token = _get_token(config)
client = GiteaClient(config, token)
if not client.is_available():
logger.debug("Gitea API not available for Daily Run metrics")
return None
try:
# Get layer metrics from issues
layers, total_current, total_previous = _fetch_layer_metrics(client, lookback_days)
# Get session data from cycle retrospectives
cycle_data = _load_cycle_data(days=lookback_days)
return DailyRunMetrics(
sessions_completed=cycle_data["current"],
sessions_previous=cycle_data["previous"],
layers=layers,
total_touched_current=total_current,
total_touched_previous=total_previous,
lookback_days=lookback_days,
generated_at=datetime.now(UTC).isoformat(),
)
except Exception as exc:
logger.debug("Error fetching Daily Run metrics: %s", exc)
return None
@router.get("/daily-run/metrics", response_class=JSONResponse)
async def daily_run_metrics_api(lookback_days: int = 7):
"""Return Daily Run metrics as JSON API."""
metrics = _get_metrics(lookback_days)
if not metrics:
return JSONResponse(
{"error": "Gitea API unavailable", "status": "unavailable"},
status_code=503,
)
# Check for quest completions based on Daily Run metrics
quest_rewards = []
try:
from dashboard.routes.quests import check_daily_run_quests
quest_rewards = await check_daily_run_quests(agent_id="system")
except Exception as exc:
logger.debug("Quest checking failed: %s", exc)
return JSONResponse(
{
"status": "ok",
"lookback_days": metrics.lookback_days,
"sessions": {
"completed": metrics.sessions_completed,
"previous": metrics.sessions_previous,
"trend": metrics.sessions_trend,
},
"layers": [
{
"name": layer.name,
"label": layer.label,
"current": layer.current_count,
"previous": layer.previous_count,
"trend": layer.trend,
}
for layer in metrics.layers
],
"totals": {
"current": metrics.total_touched_current,
"previous": metrics.total_touched_previous,
},
"generated_at": metrics.generated_at,
"quest_rewards": quest_rewards,
}
)
@router.get("/daily-run/panel", response_class=HTMLResponse)
async def daily_run_panel(request: Request, lookback_days: int = 7):
"""Return Daily Run metrics panel HTML for HTMX polling."""
metrics = _get_metrics(lookback_days)
# Build Gitea URLs for filtered issue lists
config = _load_config()
repo_slug = config.get("repo_slug", "rockachopa/Timmy-time-dashboard")
gitea_base = config.get("gitea_api", "http://localhost:3000/api/v1").replace("/api/v1", "")
# Logbook URL (link to issues with any layer label)
layer_labels = ",".join(LAYER_LABELS)
logbook_url = f"{gitea_base}/{repo_slug}/issues?labels={layer_labels}&state=all"
# Layer-specific URLs
layer_urls = {
layer: f"{gitea_base}/{repo_slug}/issues?labels=layer:{layer}&state=all"
for layer in ["triage", "micro-fix", "tests", "economy"]
}
return templates.TemplateResponse(
request,
"partials/daily_run_panel.html",
{
"metrics": metrics,
"logbook_url": logbook_url,
"layer_urls": layer_urls,
"gitea_available": metrics is not None,
},
)

View File

@@ -75,6 +75,7 @@ def _query_database(db_path: str) -> dict:
"truncated": count > MAX_ROWS,
}
except Exception as exc:
logger.exception("Failed to query table %s", table_name)
result["tables"][table_name] = {
"error": str(exc),
"columns": [],
@@ -83,6 +84,7 @@ def _query_database(db_path: str) -> dict:
"truncated": False,
}
except Exception as exc:
logger.exception("Failed to query database %s", db_path)
result["error"] = str(exc)
return result

View File

@@ -125,7 +125,7 @@ def _run_grok_query(message: str) -> dict:
from lightning.factory import get_backend as get_ln_backend
ln = get_ln_backend()
sats = min(settings.grok_max_sats_per_query, 100)
sats = min(settings.grok_max_sats_per_query, settings.grok_sats_hard_cap)
ln.create_invoice(sats, f"Grok: {message[:50]}")
invoice_note = f" | {sats} sats"
except Exception as exc:
@@ -135,6 +135,7 @@ def _run_grok_query(message: str) -> dict:
result = backend.run(message)
return {"response": f"**[Grok]{invoice_note}:** {result.content}", "error": None}
except Exception as exc:
logger.exception("Grok query failed")
return {"response": None, "error": f"Grok error: {exc}"}
@@ -193,6 +194,7 @@ async def grok_stats():
"model": settings.grok_default_model,
}
except Exception as exc:
logger.exception("Failed to load Grok stats")
return {"error": str(exc)}

View File

@@ -148,6 +148,7 @@ def _check_sqlite() -> DependencyStatus:
details={"path": str(db_path)},
)
except Exception as exc:
logger.exception("SQLite health check failed")
return DependencyStatus(
name="SQLite Database",
status="unavailable",
@@ -274,3 +275,54 @@ async def component_status():
},
"timestamp": datetime.now(UTC).isoformat(),
}
@router.get("/health/snapshot")
async def health_snapshot():
"""Quick health snapshot before coding.
Returns a concise status summary including:
- CI pipeline status (pass/fail/unknown)
- Critical issues count (P0/P1)
- Test flakiness rate
- Token economy temperature
Fast execution (< 5 seconds) for pre-work checks.
Refs: #710
"""
import sys
from pathlib import Path
# Import the health snapshot module
snapshot_path = Path(settings.repo_root) / "timmy_automations" / "daily_run"
if str(snapshot_path) not in sys.path:
sys.path.insert(0, str(snapshot_path))
try:
from health_snapshot import generate_snapshot, get_token, load_config
config = load_config()
token = get_token(config)
# Run the health snapshot (in thread to avoid blocking)
snapshot = await asyncio.to_thread(generate_snapshot, config, token)
return snapshot.to_dict()
except Exception as exc:
logger.warning("Health snapshot failed: %s", exc)
# Return graceful fallback
return {
"timestamp": datetime.now(UTC).isoformat(),
"overall_status": "unknown",
"error": str(exc),
"ci": {"status": "unknown", "message": "Snapshot failed"},
"issues": {"count": 0, "p0_count": 0, "p1_count": 0, "issues": []},
"flakiness": {
"status": "unknown",
"recent_failures": 0,
"recent_cycles": 0,
"failure_rate": 0.0,
"message": "Snapshot failed",
},
"tokens": {"status": "unknown", "message": "Snapshot failed"},
}

View File

@@ -0,0 +1,377 @@
"""Quest system routes for agent token rewards.
Provides API endpoints for:
- Listing quests and their status
- Claiming quest rewards
- Getting quest leaderboard
- Quest progress tracking
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
from dashboard.templating import templates
from timmy.quest_system import (
QuestStatus,
auto_evaluate_all_quests,
claim_quest_reward,
evaluate_quest_progress,
get_active_quests,
get_agent_quests_status,
get_quest_definition,
get_quest_leaderboard,
load_quest_config,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/quests", tags=["quests"])
class ClaimQuestRequest(BaseModel):
"""Request to claim a quest reward."""
agent_id: str
quest_id: str
class EvaluateQuestRequest(BaseModel):
"""Request to manually evaluate quest progress."""
agent_id: str
quest_id: str
# ---------------------------------------------------------------------------
# API Endpoints
# ---------------------------------------------------------------------------
@router.get("/api/definitions")
async def get_quest_definitions_api() -> JSONResponse:
"""Get all quest definitions.
Returns:
JSON list of all quest definitions with their criteria.
"""
definitions = get_active_quests()
return JSONResponse(
{
"quests": [
{
"id": q.id,
"name": q.name,
"description": q.description,
"reward_tokens": q.reward_tokens,
"type": q.quest_type.value,
"repeatable": q.repeatable,
"cooldown_hours": q.cooldown_hours,
"criteria": q.criteria,
}
for q in definitions
]
}
)
@router.get("/api/status/{agent_id}")
async def get_agent_quest_status(agent_id: str) -> JSONResponse:
"""Get quest status for a specific agent.
Returns:
Complete quest status including progress, completion counts,
and tokens earned.
"""
status = get_agent_quests_status(agent_id)
return JSONResponse(status)
@router.post("/api/claim")
async def claim_quest_reward_api(request: ClaimQuestRequest) -> JSONResponse:
"""Claim a quest reward for an agent.
The quest must be completed but not yet claimed.
"""
reward = claim_quest_reward(request.quest_id, request.agent_id)
if not reward:
return JSONResponse(
{
"success": False,
"error": "Quest not completed, already claimed, or on cooldown",
},
status_code=400,
)
return JSONResponse(
{
"success": True,
"reward": reward,
}
)
@router.post("/api/evaluate")
async def evaluate_quest_api(request: EvaluateQuestRequest) -> JSONResponse:
"""Manually evaluate quest progress with provided context.
This is useful for testing or when the quest completion
needs to be triggered manually.
"""
quest = get_quest_definition(request.quest_id)
if not quest:
return JSONResponse(
{"success": False, "error": "Quest not found"},
status_code=404,
)
# Build evaluation context based on quest type
context = await _build_evaluation_context(quest)
progress = evaluate_quest_progress(request.quest_id, request.agent_id, context)
if not progress:
return JSONResponse(
{"success": False, "error": "Failed to evaluate quest"},
status_code=500,
)
# Auto-claim if completed
reward = None
if progress.status == QuestStatus.COMPLETED:
reward = claim_quest_reward(request.quest_id, request.agent_id)
return JSONResponse(
{
"success": True,
"progress": progress.to_dict(),
"reward": reward,
"completed": progress.status == QuestStatus.COMPLETED,
}
)
@router.get("/api/leaderboard")
async def get_leaderboard_api() -> JSONResponse:
"""Get the quest completion leaderboard.
Returns agents sorted by total tokens earned.
"""
leaderboard = get_quest_leaderboard()
return JSONResponse(
{
"leaderboard": leaderboard,
}
)
@router.post("/api/reload")
async def reload_quest_config_api() -> JSONResponse:
"""Reload quest configuration from quests.yaml.
Useful for applying quest changes without restarting.
"""
definitions, quest_settings = load_quest_config()
return JSONResponse(
{
"success": True,
"quests_loaded": len(definitions),
"settings": quest_settings,
}
)
# ---------------------------------------------------------------------------
# Dashboard UI Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_class=HTMLResponse)
async def quests_dashboard(request: Request) -> HTMLResponse:
"""Main quests dashboard page."""
return templates.TemplateResponse(
request,
"quests.html",
{"agent_id": "current_user"},
)
@router.get("/panel/{agent_id}", response_class=HTMLResponse)
async def quests_panel(request: Request, agent_id: str) -> HTMLResponse:
"""Quest panel for HTMX partial updates."""
status = get_agent_quests_status(agent_id)
return templates.TemplateResponse(
request,
"partials/quests_panel.html",
{
"agent_id": agent_id,
"quests": status["quests"],
"total_tokens": status["total_tokens_earned"],
"completed_count": status["total_quests_completed"],
},
)
# ---------------------------------------------------------------------------
# Internal Functions
# ---------------------------------------------------------------------------
async def _build_evaluation_context(quest) -> dict[str, Any]:
"""Build evaluation context for a quest based on its type."""
context: dict[str, Any] = {}
if quest.quest_type.value == "issue_count":
# Fetch closed issues with relevant labels
context["closed_issues"] = await _fetch_closed_issues(
quest.criteria.get("issue_labels", [])
)
elif quest.quest_type.value == "issue_reduce":
# Fetch current and previous issue counts
labels = quest.criteria.get("issue_labels", [])
context["current_issue_count"] = await _fetch_open_issue_count(labels)
context["previous_issue_count"] = await _fetch_previous_issue_count(
labels, quest.criteria.get("lookback_days", 7)
)
elif quest.quest_type.value == "daily_run":
# Fetch Daily Run metrics
metrics = await _fetch_daily_run_metrics()
context["sessions_completed"] = metrics.get("sessions_completed", 0)
return context
async def _fetch_closed_issues(labels: list[str]) -> list[dict]:
"""Fetch closed issues matching the given labels."""
try:
from dashboard.routes.daily_run import GiteaClient, _load_config
config = _load_config()
token = _get_gitea_token(config)
client = GiteaClient(config, token)
if not client.is_available():
return []
# Build label filter
label_filter = ",".join(labels) if labels else ""
issues = client.get_paginated(
"issues",
{"state": "closed", "labels": label_filter, "limit": 100},
)
return issues
except Exception as exc:
logger.debug("Failed to fetch closed issues: %s", exc)
return []
async def _fetch_open_issue_count(labels: list[str]) -> int:
"""Fetch count of open issues with given labels."""
try:
from dashboard.routes.daily_run import GiteaClient, _load_config
config = _load_config()
token = _get_gitea_token(config)
client = GiteaClient(config, token)
if not client.is_available():
return 0
label_filter = ",".join(labels) if labels else ""
issues = client.get_paginated(
"issues",
{"state": "open", "labels": label_filter, "limit": 100},
)
return len(issues)
except Exception as exc:
logger.debug("Failed to fetch open issue count: %s", exc)
return 0
async def _fetch_previous_issue_count(labels: list[str], lookback_days: int) -> int:
"""Fetch previous issue count (simplified - uses current for now)."""
# This is a simplified implementation
# In production, you'd query historical data
return await _fetch_open_issue_count(labels)
async def _fetch_daily_run_metrics() -> dict[str, Any]:
"""Fetch Daily Run metrics."""
try:
from dashboard.routes.daily_run import _get_metrics
metrics = _get_metrics(lookback_days=7)
if metrics:
return {
"sessions_completed": metrics.sessions_completed,
"sessions_previous": metrics.sessions_previous,
}
except Exception as exc:
logger.debug("Failed to fetch Daily Run metrics: %s", exc)
return {"sessions_completed": 0, "sessions_previous": 0}
def _get_gitea_token(config: dict) -> str | None:
"""Get Gitea token from config."""
if "token" in config:
return config["token"]
from pathlib import Path
token_file = Path(config.get("token_file", "~/.hermes/gitea_token")).expanduser()
if token_file.exists():
return token_file.read_text().strip()
return None
# ---------------------------------------------------------------------------
# Daily Run Integration
# ---------------------------------------------------------------------------
async def check_daily_run_quests(agent_id: str = "system") -> list[dict]:
"""Check and award Daily Run related quests.
Called by the Daily Run system when metrics are updated.
Returns:
List of rewards awarded
"""
# Check if auto-detect is enabled
_, quest_settings = load_quest_config()
if not quest_settings.get("auto_detect_on_daily_run", True):
return []
# Build context from Daily Run metrics
metrics = await _fetch_daily_run_metrics()
context = {
"sessions_completed": metrics.get("sessions_completed", 0),
"sessions_previous": metrics.get("sessions_previous", 0),
}
# Add closed issues for issue_count quests
active_quests = get_active_quests()
for quest in active_quests:
if quest.quest_type.value == "issue_count":
labels = quest.criteria.get("issue_labels", [])
context["closed_issues"] = await _fetch_closed_issues(labels)
break # Only need to fetch once
# Evaluate all quests
rewards = auto_evaluate_all_quests(agent_id, context)
return rewards

View File

@@ -0,0 +1,353 @@
"""Agent scorecard routes — API endpoints for generating and viewing scorecards."""
from __future__ import annotations
import logging
from datetime import datetime
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from dashboard.services.scorecard_service import (
PeriodType,
generate_all_scorecards,
generate_scorecard,
get_tracked_agents,
)
from dashboard.templating import templates
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scorecards", tags=["scorecards"])
def _format_period_label(period_type: PeriodType) -> str:
"""Format a period type for display."""
return "Daily" if period_type == PeriodType.daily else "Weekly"
@router.get("/api/agents")
async def list_tracked_agents() -> dict[str, list[str]]:
"""Return the list of tracked agent IDs.
Returns:
Dict with "agents" key containing list of agent IDs
"""
return {"agents": get_tracked_agents()}
@router.get("/api/{agent_id}")
async def get_agent_scorecard(
agent_id: str,
period: str = Query(default="daily", description="Period type: 'daily' or 'weekly'"),
) -> JSONResponse:
"""Generate a scorecard for a specific agent.
Args:
agent_id: The agent ID (e.g., 'kimi', 'claude')
period: 'daily' or 'weekly' (default: daily)
Returns:
JSON response with scorecard data
"""
try:
period_type = PeriodType(period.lower())
except ValueError:
return JSONResponse(
status_code=400,
content={"error": f"Invalid period '{period}'. Use 'daily' or 'weekly'."},
)
try:
scorecard = generate_scorecard(agent_id, period_type)
if scorecard is None:
return JSONResponse(
status_code=404,
content={"error": f"No scorecard found for agent '{agent_id}'"},
)
return JSONResponse(content=scorecard.to_dict())
except Exception as exc:
logger.error("Failed to generate scorecard for %s: %s", agent_id, exc)
return JSONResponse(
status_code=500,
content={"error": f"Failed to generate scorecard: {str(exc)}"},
)
@router.get("/api")
async def get_all_scorecards(
period: str = Query(default="daily", description="Period type: 'daily' or 'weekly'"),
) -> JSONResponse:
"""Generate scorecards for all tracked agents.
Args:
period: 'daily' or 'weekly' (default: daily)
Returns:
JSON response with list of scorecard data
"""
try:
period_type = PeriodType(period.lower())
except ValueError:
return JSONResponse(
status_code=400,
content={"error": f"Invalid period '{period}'. Use 'daily' or 'weekly'."},
)
try:
scorecards = generate_all_scorecards(period_type)
return JSONResponse(
content={
"period": period_type.value,
"scorecards": [s.to_dict() for s in scorecards],
"count": len(scorecards),
}
)
except Exception as exc:
logger.error("Failed to generate scorecards: %s", exc)
return JSONResponse(
status_code=500,
content={"error": f"Failed to generate scorecards: {str(exc)}"},
)
@router.get("", response_class=HTMLResponse)
async def scorecards_page(request: Request) -> HTMLResponse:
"""Render the scorecards dashboard page.
Returns:
HTML page with scorecard interface
"""
agents = get_tracked_agents()
return templates.TemplateResponse(
request,
"scorecards.html",
{
"agents": agents,
"periods": ["daily", "weekly"],
},
)
@router.get("/panel/{agent_id}", response_class=HTMLResponse)
async def agent_scorecard_panel(
request: Request,
agent_id: str,
period: str = Query(default="daily"),
) -> HTMLResponse:
"""Render an individual agent scorecard panel (for HTMX).
Args:
request: The request object
agent_id: The agent ID
period: 'daily' or 'weekly'
Returns:
HTML panel with scorecard content
"""
try:
period_type = PeriodType(period.lower())
except ValueError:
period_type = PeriodType.daily
try:
scorecard = generate_scorecard(agent_id, period_type)
if scorecard is None:
return HTMLResponse(
content=f"""
<div class="card mc-panel">
<h5 class="card-title">{agent_id.title()}</h5>
<p class="text-muted">No activity recorded for this period.</p>
</div>
""",
status_code=200,
)
data = scorecard.to_dict()
# Build patterns HTML
patterns_html = ""
if data["patterns"]:
patterns_list = "".join([f"<li>{p}</li>" for p in data["patterns"]])
patterns_html = f"""
<div class="mt-3">
<h6>Patterns</h6>
<ul class="list-unstyled text-info">
{patterns_list}
</ul>
</div>
"""
# Build bullets HTML
bullets_html = "".join([f"<li>{b}</li>" for b in data["narrative_bullets"]])
# Build metrics summary
metrics = data["metrics"]
html_content = f"""
<div class="card mc-panel">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{agent_id.title()}</h5>
<span class="badge bg-secondary">{_format_period_label(period_type)}</span>
</div>
<div class="card-body">
<ul class="list-unstyled mb-3">
{bullets_html}
</ul>
<div class="row text-center small">
<div class="col">
<div class="text-muted">PRs</div>
<div class="fw-bold">{metrics["prs_opened"]}/{metrics["prs_merged"]}</div>
<div class="text-muted" style="font-size: 0.75rem;">
{int(metrics["pr_merge_rate"] * 100)}% merged
</div>
</div>
<div class="col">
<div class="text-muted">Issues</div>
<div class="fw-bold">{metrics["issues_touched"]}</div>
</div>
<div class="col">
<div class="text-muted">Tests</div>
<div class="fw-bold">{metrics["tests_affected"]}</div>
</div>
<div class="col">
<div class="text-muted">Tokens</div>
<div class="fw-bold {"text-success" if metrics["token_net"] >= 0 else "text-danger"}">
{"+" if metrics["token_net"] > 0 else ""}{metrics["token_net"]}
</div>
</div>
</div>
{patterns_html}
</div>
</div>
"""
return HTMLResponse(content=html_content)
except Exception as exc:
logger.error("Failed to render scorecard panel for %s: %s", agent_id, exc)
return HTMLResponse(
content=f"""
<div class="card mc-panel border-danger">
<h5 class="card-title">{agent_id.title()}</h5>
<p class="text-danger">Error loading scorecard: {str(exc)}</p>
</div>
""",
status_code=200,
)
@router.get("/all/panels", response_class=HTMLResponse)
async def all_scorecard_panels(
request: Request,
period: str = Query(default="daily"),
) -> HTMLResponse:
"""Render all agent scorecard panels (for HTMX).
Args:
request: The request object
period: 'daily' or 'weekly'
Returns:
HTML with all scorecard panels
"""
try:
period_type = PeriodType(period.lower())
except ValueError:
period_type = PeriodType.daily
try:
scorecards = generate_all_scorecards(period_type)
panels: list[str] = []
for scorecard in scorecards:
data = scorecard.to_dict()
# Build patterns HTML
patterns_html = ""
if data["patterns"]:
patterns_list = "".join([f"<li>{p}</li>" for p in data["patterns"]])
patterns_html = f"""
<div class="mt-3">
<h6>Patterns</h6>
<ul class="list-unstyled text-info">
{patterns_list}
</ul>
</div>
"""
# Build bullets HTML
bullets_html = "".join([f"<li>{b}</li>" for b in data["narrative_bullets"]])
metrics = data["metrics"]
panel_html = f"""
<div class="col-md-6 col-lg-4 mb-3">
<div class="card mc-panel">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{scorecard.agent_id.title()}</h5>
<span class="badge bg-secondary">{_format_period_label(period_type)}</span>
</div>
<div class="card-body">
<ul class="list-unstyled mb-3">
{bullets_html}
</ul>
<div class="row text-center small">
<div class="col">
<div class="text-muted">PRs</div>
<div class="fw-bold">{metrics["prs_opened"]}/{metrics["prs_merged"]}</div>
<div class="text-muted" style="font-size: 0.75rem;">
{int(metrics["pr_merge_rate"] * 100)}% merged
</div>
</div>
<div class="col">
<div class="text-muted">Issues</div>
<div class="fw-bold">{metrics["issues_touched"]}</div>
</div>
<div class="col">
<div class="text-muted">Tests</div>
<div class="fw-bold">{metrics["tests_affected"]}</div>
</div>
<div class="col">
<div class="text-muted">Tokens</div>
<div class="fw-bold {"text-success" if metrics["token_net"] >= 0 else "text-danger"}">
{"+" if metrics["token_net"] > 0 else ""}{metrics["token_net"]}
</div>
</div>
</div>
{patterns_html}
</div>
</div>
</div>
"""
panels.append(panel_html)
html_content = f"""
<div class="row">
{"".join(panels)}
</div>
<div class="text-muted small mt-2">
Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC")}
</div>
"""
return HTMLResponse(content=html_content)
except Exception as exc:
logger.error("Failed to render all scorecard panels: %s", exc)
return HTMLResponse(
content=f"""
<div class="alert alert-danger">
Error loading scorecards: {str(exc)}
</div>
""",
status_code=200,
)

View File

@@ -0,0 +1,74 @@
"""Sovereignty metrics dashboard routes.
Provides API endpoints and HTMX partials for tracking research
sovereignty progress against graduation targets.
Refs: #981
"""
import logging
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from config import settings
from dashboard.templating import templates
from infrastructure.sovereignty_metrics import (
GRADUATION_TARGETS,
get_sovereignty_store,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sovereignty", tags=["sovereignty"])
@router.get("/metrics")
async def sovereignty_metrics_api() -> dict[str, Any]:
"""JSON API: full sovereignty metrics summary with trends."""
store = get_sovereignty_store()
summary = store.get_summary()
alerts = store.get_alerts(unacknowledged_only=True)
return {
"metrics": summary,
"alerts": alerts,
"targets": GRADUATION_TARGETS,
"cost_threshold": settings.sovereignty_api_cost_alert_threshold,
}
@router.get("/metrics/panel", response_class=HTMLResponse)
async def sovereignty_metrics_panel(request: Request) -> HTMLResponse:
"""HTMX partial: sovereignty metrics progress panel."""
store = get_sovereignty_store()
summary = store.get_summary()
alerts = store.get_alerts(unacknowledged_only=True)
return templates.TemplateResponse(
request,
"partials/sovereignty_metrics.html",
{
"metrics": summary,
"alerts": alerts,
"targets": GRADUATION_TARGETS,
},
)
@router.get("/alerts")
async def sovereignty_alerts_api() -> dict[str, Any]:
"""JSON API: sovereignty alerts."""
store = get_sovereignty_store()
return {
"alerts": store.get_alerts(unacknowledged_only=False),
"unacknowledged": store.get_alerts(unacknowledged_only=True),
}
@router.post("/alerts/{alert_id}/acknowledge")
async def acknowledge_alert(alert_id: int) -> dict[str, bool]:
"""Acknowledge a sovereignty alert."""
store = get_sovereignty_store()
success = store.acknowledge_alert(alert_id)
return {"success": success}

View File

@@ -56,11 +56,13 @@ async def self_modify_queue(request: Request):
@router.get("/swarm/mission-control", response_class=HTMLResponse)
async def mission_control(request: Request):
"""Render the swarm mission control dashboard page."""
return templates.TemplateResponse(request, "mission_control.html", {})
@router.get("/bugs", response_class=HTMLResponse)
async def bugs_page(request: Request):
"""Render the bug tracking page."""
return templates.TemplateResponse(
request,
"bugs.html",
@@ -75,16 +77,19 @@ async def bugs_page(request: Request):
@router.get("/self-coding", response_class=HTMLResponse)
async def self_coding(request: Request):
"""Render the self-coding automation status page."""
return templates.TemplateResponse(request, "self_coding.html", {"stats": {}})
@router.get("/hands", response_class=HTMLResponse)
async def hands_page(request: Request):
"""Render the hands (automation executions) page."""
return templates.TemplateResponse(request, "hands.html", {"executions": []})
@router.get("/creative/ui", response_class=HTMLResponse)
async def creative_ui(request: Request):
"""Render the creative UI playground page."""
return templates.TemplateResponse(request, "creative.html", {})

View File

@@ -143,61 +143,49 @@ async def tasks_page(request: Request):
# ---------------------------------------------------------------------------
def _render_task_list(request: Request, query: str, empty_msg: str) -> HTMLResponse:
"""Fetch tasks by query and render as HTMX task-card partials."""
with _get_db() as db:
rows = db.execute(query).fetchall()
parts = [
templates.TemplateResponse(
request, "partials/task_card.html", {"task": _TaskView(_row_to_dict(r))}
).body.decode()
for r in rows
]
if not parts:
return HTMLResponse(f'<div class="empty-column">{empty_msg}</div>')
return HTMLResponse("".join(parts))
@router.get("/tasks/pending", response_class=HTMLResponse)
async def tasks_pending(request: Request):
with _get_db() as db:
rows = db.execute(
"SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
).fetchall()
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
parts.append(
templates.TemplateResponse(
request, "partials/task_card.html", {"task": task}
).body.decode()
)
if not parts:
return HTMLResponse('<div class="empty-column">No pending tasks</div>')
return HTMLResponse("".join(parts))
"""Return HTMX partial for pending approval tasks."""
return _render_task_list(
request,
"SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC",
"No pending tasks",
)
@router.get("/tasks/active", response_class=HTMLResponse)
async def tasks_active(request: Request):
with _get_db() as db:
rows = db.execute(
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
).fetchall()
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
parts.append(
templates.TemplateResponse(
request, "partials/task_card.html", {"task": task}
).body.decode()
)
if not parts:
return HTMLResponse('<div class="empty-column">No active tasks</div>')
return HTMLResponse("".join(parts))
"""Return HTMX partial for active (approved/running/paused) tasks."""
return _render_task_list(
request,
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC",
"No active tasks",
)
@router.get("/tasks/completed", response_class=HTMLResponse)
async def tasks_completed(request: Request):
with _get_db() as db:
rows = db.execute(
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
).fetchall()
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
parts.append(
templates.TemplateResponse(
request, "partials/task_card.html", {"task": task}
).body.decode()
)
if not parts:
return HTMLResponse('<div class="empty-column">No completed tasks yet</div>')
return HTMLResponse("".join(parts))
"""Return HTMX partial for completed/vetoed/failed tasks (last 50)."""
return _render_task_list(
request,
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50",
"No completed tasks yet",
)
# ---------------------------------------------------------------------------
@@ -241,26 +229,31 @@ async def create_task_form(
@router.post("/tasks/{task_id}/approve", response_class=HTMLResponse)
async def approve_task(request: Request, task_id: str):
"""Approve a pending task and move it to active queue."""
return await _set_status(request, task_id, "approved")
@router.post("/tasks/{task_id}/veto", response_class=HTMLResponse)
async def veto_task(request: Request, task_id: str):
"""Veto a task, marking it as rejected."""
return await _set_status(request, task_id, "vetoed")
@router.post("/tasks/{task_id}/pause", response_class=HTMLResponse)
async def pause_task(request: Request, task_id: str):
"""Pause a running or approved task."""
return await _set_status(request, task_id, "paused")
@router.post("/tasks/{task_id}/cancel", response_class=HTMLResponse)
async def cancel_task(request: Request, task_id: str):
"""Cancel a task (marks as vetoed)."""
return await _set_status(request, task_id, "vetoed")
@router.post("/tasks/{task_id}/retry", response_class=HTMLResponse)
async def retry_task(request: Request, task_id: str):
"""Retry a failed/vetoed task by moving it back to approved."""
return await _set_status(request, task_id, "approved")
@@ -271,6 +264,7 @@ async def modify_task(
title: str = Form(...),
description: str = Form(""),
):
"""Update task title and description."""
with _get_db() as db:
db.execute(
"UPDATE tasks SET title=?, description=? WHERE id=?",

View File

@@ -59,6 +59,7 @@ async def tts_speak(text: str = Form(...)):
voice_tts.speak(text)
return {"spoken": True, "text": text}
except Exception as exc:
logger.exception("TTS speak failed")
return {"spoken": False, "reason": str(exc)}

View File

@@ -17,16 +17,221 @@ or missing.
import asyncio
import json
import logging
import math
import re
import time
from collections import deque
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, WebSocket
import yaml
from fastapi import APIRouter, Request, WebSocket
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from config import settings
from infrastructure.presence import produce_bark, serialize_presence
from timmy.memory_system import search_memories
from timmy.workshop_state import PRESENCE_FILE
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/world", tags=["world"])
matrix_router = APIRouter(prefix="/api/matrix", tags=["matrix"])
# ---------------------------------------------------------------------------
# Matrix Bark Endpoint — HTTP fallback for bark messages
# ---------------------------------------------------------------------------
# Rate limiting: 1 request per 3 seconds per visitor_id
_BARK_RATE_LIMIT_SECONDS = 3
_bark_last_request: dict[str, float] = {}
class BarkRequest(BaseModel):
"""Request body for POST /api/matrix/bark."""
text: str
visitor_id: str
@matrix_router.post("/bark")
async def post_matrix_bark(request: BarkRequest) -> JSONResponse:
"""Generate a bark response for a visitor message.
HTTP fallback for when WebSocket isn't available. The Matrix frontend
can POST a message and get Timmy's bark response back as JSON.
Rate limited to 1 request per 3 seconds per visitor_id.
Request body:
- text: The visitor's message text
- visitor_id: Unique identifier for the visitor (used for rate limiting)
Returns:
- 200: Bark message in produce_bark() format
- 429: Rate limit exceeded (try again later)
- 422: Invalid request (missing/invalid fields)
"""
# Validate inputs
text = request.text.strip() if request.text else ""
visitor_id = request.visitor_id.strip() if request.visitor_id else ""
if not text:
return JSONResponse(
status_code=422,
content={"error": "text is required"},
)
if not visitor_id:
return JSONResponse(
status_code=422,
content={"error": "visitor_id is required"},
)
# Rate limiting check
now = time.time()
last_request = _bark_last_request.get(visitor_id, 0)
time_since_last = now - last_request
if time_since_last < _BARK_RATE_LIMIT_SECONDS:
retry_after = _BARK_RATE_LIMIT_SECONDS - time_since_last
return JSONResponse(
status_code=429,
content={"error": "Rate limit exceeded. Try again later."},
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Record this request
_bark_last_request[visitor_id] = now
# Generate bark response
try:
reply = await _generate_bark(text)
except Exception as exc:
logger.warning("Bark generation failed: %s", exc)
reply = "Hmm, my thoughts are a bit tangled right now."
# Build bark response using produce_bark format
bark = produce_bark(agent_id="timmy", text=reply, style="speech")
return JSONResponse(
content=bark,
headers={"Cache-Control": "no-cache, no-store"},
)
# ---------------------------------------------------------------------------
# Matrix Agent Registry — serves agents to the Matrix visualization
# ---------------------------------------------------------------------------
# Agent color mapping — consistent with Matrix visual identity
_AGENT_COLORS: dict[str, str] = {
"timmy": "#FFD700", # Gold
"orchestrator": "#FFD700", # Gold
"perplexity": "#3B82F6", # Blue
"replit": "#F97316", # Orange
"kimi": "#06B6D4", # Cyan
"claude": "#A855F7", # Purple
"researcher": "#10B981", # Emerald
"coder": "#EF4444", # Red
"writer": "#EC4899", # Pink
"memory": "#8B5CF6", # Violet
"experimenter": "#14B8A6", # Teal
"forge": "#EF4444", # Red (coder alias)
"seer": "#10B981", # Emerald (researcher alias)
"quill": "#EC4899", # Pink (writer alias)
"echo": "#8B5CF6", # Violet (memory alias)
"lab": "#14B8A6", # Teal (experimenter alias)
}
# Agent shape mapping for 3D visualization
_AGENT_SHAPES: dict[str, str] = {
"timmy": "sphere",
"orchestrator": "sphere",
"perplexity": "cube",
"replit": "cylinder",
"kimi": "dodecahedron",
"claude": "octahedron",
"researcher": "icosahedron",
"coder": "cube",
"writer": "cone",
"memory": "torus",
"experimenter": "tetrahedron",
"forge": "cube",
"seer": "icosahedron",
"quill": "cone",
"echo": "torus",
"lab": "tetrahedron",
}
# Default fallback values
_DEFAULT_COLOR = "#9CA3AF" # Gray
_DEFAULT_SHAPE = "sphere"
_DEFAULT_STATUS = "available"
def _get_agent_color(agent_id: str) -> str:
"""Get the Matrix color for an agent."""
return _AGENT_COLORS.get(agent_id.lower(), _DEFAULT_COLOR)
def _get_agent_shape(agent_id: str) -> str:
"""Get the Matrix shape for an agent."""
return _AGENT_SHAPES.get(agent_id.lower(), _DEFAULT_SHAPE)
def _compute_circular_positions(count: int, radius: float = 3.0) -> list[dict[str, float]]:
"""Compute circular positions for agents in the Matrix.
Agents are arranged in a circle on the XZ plane at y=0.
"""
positions = []
for i in range(count):
angle = (2 * math.pi * i) / count
x = radius * math.cos(angle)
z = radius * math.sin(angle)
positions.append({"x": round(x, 2), "y": 0.0, "z": round(z, 2)})
return positions
def _build_matrix_agents_response() -> list[dict[str, Any]]:
"""Build the Matrix agent registry response.
Reads from agents.yaml and returns agents with Matrix-compatible
formatting including colors, shapes, and positions.
"""
try:
from timmy.agents.loader import list_agents
agents = list_agents()
if not agents:
return []
positions = _compute_circular_positions(len(agents))
result = []
for i, agent in enumerate(agents):
agent_id = agent.get("id", "")
result.append(
{
"id": agent_id,
"display_name": agent.get("name", agent_id.title()),
"role": agent.get("role", "general"),
"color": _get_agent_color(agent_id),
"position": positions[i],
"shape": _get_agent_shape(agent_id),
"status": agent.get("status", _DEFAULT_STATUS),
}
)
return result
except Exception as exc:
logger.warning("Failed to load agents for Matrix: %s", exc)
return []
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/world", tags=["world"])
@@ -149,21 +354,7 @@ def _read_presence_file() -> dict | None:
def _build_world_state(presence: dict) -> dict:
"""Transform presence dict into the world/state API response."""
return {
"timmyState": {
"mood": presence.get("mood", "calm"),
"activity": presence.get("current_focus", "idle"),
"energy": presence.get("energy", 0.5),
"confidence": presence.get("confidence", 0.7),
},
"familiar": presence.get("familiar"),
"activeThreads": presence.get("active_threads", []),
"recentEvents": presence.get("recent_events", []),
"concerns": presence.get("concerns", []),
"visitorPresent": False,
"updatedAt": presence.get("liveness", datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")),
"version": presence.get("version", 1),
}
return serialize_presence(presence)
def _get_current_state() -> dict:
@@ -224,6 +415,50 @@ async def _heartbeat(websocket: WebSocket) -> None:
logger.debug("Heartbeat stopped — connection gone")
async def _authenticate_ws(websocket: WebSocket) -> bool:
"""Authenticate WebSocket connection using matrix_ws_token.
Checks for token in query param ?token= first. If no query param,
accepts the connection and waits for first message with
{"type": "auth", "token": "..."}.
Returns True if authenticated (or if auth is disabled).
Returns False and closes connection with code 4001 if invalid.
"""
token_setting = settings.matrix_ws_token
# Auth disabled in dev mode (empty/unset token)
if not token_setting:
return True
# Check query param first (can validate before accept)
query_token = websocket.query_params.get("token", "")
if query_token:
if query_token == token_setting:
return True
# Invalid token in query param - we need to accept to close properly
await websocket.accept()
await websocket.close(code=4001, reason="Invalid token")
return False
# No query token - accept and wait for auth message
await websocket.accept()
# Wait for auth message as first message
try:
raw = await websocket.receive_text()
data = json.loads(raw)
if data.get("type") == "auth" and data.get("token") == token_setting:
return True
# Invalid auth message
await websocket.close(code=4001, reason="Invalid token")
return False
except (json.JSONDecodeError, TypeError):
# Non-JSON first message without valid token
await websocket.close(code=4001, reason="Authentication required")
return False
@router.websocket("/ws")
async def world_ws(websocket: WebSocket) -> None:
"""Accept a Workshop client and keep it alive for state broadcasts.
@@ -232,8 +467,28 @@ async def world_ws(websocket: WebSocket) -> None:
client never starts from a blank slate. Incoming frames are parsed
as JSON — ``visitor_message`` triggers a bark response. A background
heartbeat ping runs every 15 s to detect dead connections early.
Authentication:
- If matrix_ws_token is configured, clients must provide it via
?token= query param or in the first message as
{"type": "auth", "token": "..."}.
- Invalid token results in close code 4001.
- Valid token receives a connection_ack message.
"""
await websocket.accept()
# Authenticate (may accept connection internally)
is_authed = await _authenticate_ws(websocket)
if not is_authed:
logger.info("World WS connection rejected — invalid token")
return
# Auth passed - accept if not already accepted
if websocket.client_state.name != "CONNECTED":
await websocket.accept()
# Send connection_ack if auth was required
if settings.matrix_ws_token:
await websocket.send_text(json.dumps({"type": "connection_ack"}))
_ws_clients.append(websocket)
logger.info("World WS connected — %d clients", len(_ws_clients))
@@ -383,3 +638,428 @@ async def _generate_bark(visitor_text: str) -> str:
except Exception as exc:
logger.warning("Bark generation failed: %s", exc)
return "Hmm, my thoughts are a bit tangled right now."
# ---------------------------------------------------------------------------
# Matrix Configuration Endpoint
# ---------------------------------------------------------------------------
# Default Matrix configuration (fallback when matrix.yaml is missing/corrupt)
_DEFAULT_MATRIX_CONFIG: dict[str, Any] = {
"lighting": {
"ambient_color": "#1a1a2e",
"ambient_intensity": 0.4,
"point_lights": [
{"color": "#FFD700", "intensity": 1.2, "position": {"x": 0, "y": 5, "z": 0}},
{"color": "#3B82F6", "intensity": 0.8, "position": {"x": -5, "y": 3, "z": -5}},
{"color": "#A855F7", "intensity": 0.6, "position": {"x": 5, "y": 3, "z": 5}},
],
},
"environment": {
"rain_enabled": False,
"starfield_enabled": True,
"fog_color": "#0f0f23",
"fog_density": 0.02,
},
"features": {
"chat_enabled": True,
"visitor_avatars": True,
"pip_familiar": True,
"workshop_portal": True,
},
}
def _load_matrix_config() -> dict[str, Any]:
"""Load Matrix world configuration from matrix.yaml with fallback to defaults.
Returns a dict with sections: lighting, environment, features.
If the config file is missing or invalid, returns sensible defaults.
"""
try:
config_path = Path(settings.repo_root) / "config" / "matrix.yaml"
if not config_path.exists():
logger.debug("matrix.yaml not found, using default config")
return _DEFAULT_MATRIX_CONFIG.copy()
raw = config_path.read_text()
config = yaml.safe_load(raw)
if not isinstance(config, dict):
logger.warning("matrix.yaml invalid format, using defaults")
return _DEFAULT_MATRIX_CONFIG.copy()
# Merge with defaults to ensure all required fields exist
result: dict[str, Any] = {
"lighting": {
**_DEFAULT_MATRIX_CONFIG["lighting"],
**config.get("lighting", {}),
},
"environment": {
**_DEFAULT_MATRIX_CONFIG["environment"],
**config.get("environment", {}),
},
"features": {
**_DEFAULT_MATRIX_CONFIG["features"],
**config.get("features", {}),
},
}
# Ensure point_lights is a list
if "point_lights" in config.get("lighting", {}):
result["lighting"]["point_lights"] = config["lighting"]["point_lights"]
else:
result["lighting"]["point_lights"] = _DEFAULT_MATRIX_CONFIG["lighting"]["point_lights"]
return result
except Exception as exc:
logger.warning("Failed to load matrix config: %s, using defaults", exc)
return _DEFAULT_MATRIX_CONFIG.copy()
@matrix_router.get("/config")
async def get_matrix_config() -> JSONResponse:
"""Return Matrix world configuration.
Serves lighting presets, environment settings, and feature flags
to the Matrix frontend so it can be config-driven rather than
hardcoded. Reads from config/matrix.yaml with sensible defaults.
Response structure:
- lighting: ambient_color, ambient_intensity, point_lights[]
- environment: rain_enabled, starfield_enabled, fog_color, fog_density
- features: chat_enabled, visitor_avatars, pip_familiar, workshop_portal
"""
config = _load_matrix_config()
return JSONResponse(
content=config,
headers={"Cache-Control": "no-cache, no-store"},
)
# ---------------------------------------------------------------------------
# Matrix Agent Registry Endpoint
# ---------------------------------------------------------------------------
@matrix_router.get("/agents")
async def get_matrix_agents() -> JSONResponse:
"""Return the agent registry for Matrix visualization.
Serves agents from agents.yaml with Matrix-compatible formatting:
- id: agent identifier
- display_name: human-readable name
- role: functional role
- color: hex color code for visualization
- position: {x, y, z} coordinates in 3D space
- shape: 3D shape type
- status: availability status
Agents are arranged in a circular layout by default.
Returns 200 with empty list if no agents configured.
"""
agents = _build_matrix_agents_response()
return JSONResponse(
content=agents,
headers={"Cache-Control": "no-cache, no-store"},
)
# ---------------------------------------------------------------------------
# Matrix Thoughts Endpoint — Timmy's recent thought stream for Matrix display
# ---------------------------------------------------------------------------
_MAX_THOUGHT_LIMIT = 50 # Maximum thoughts allowed per request
_DEFAULT_THOUGHT_LIMIT = 10 # Default number of thoughts to return
_MAX_THOUGHT_TEXT_LEN = 500 # Max characters for thought text
def _build_matrix_thoughts_response(limit: int = _DEFAULT_THOUGHT_LIMIT) -> list[dict[str, Any]]:
"""Build the Matrix thoughts response from the thinking engine.
Returns recent thoughts formatted for Matrix display:
- id: thought UUID
- text: thought content (truncated to 500 chars)
- created_at: ISO-8601 timestamp
- chain_id: parent thought ID (or null if root thought)
Returns empty list if thinking engine is disabled or fails.
"""
try:
from timmy.thinking import thinking_engine
thoughts = thinking_engine.get_recent_thoughts(limit=limit)
return [
{
"id": t.id,
"text": t.content[:_MAX_THOUGHT_TEXT_LEN],
"created_at": t.created_at,
"chain_id": t.parent_id,
}
for t in thoughts
]
except Exception as exc:
logger.warning("Failed to load thoughts for Matrix: %s", exc)
return []
@matrix_router.get("/thoughts")
async def get_matrix_thoughts(limit: int = _DEFAULT_THOUGHT_LIMIT) -> JSONResponse:
"""Return Timmy's recent thoughts formatted for Matrix display.
This is the REST companion to the thought WebSocket messages,
allowing the Matrix frontend to display what Timmy is actually
thinking about rather than canned contextual lines.
Query params:
- limit: Number of thoughts to return (default 10, max 50)
Response: JSON array of thought objects:
- id: thought UUID
- text: thought content (truncated to 500 chars)
- created_at: ISO-8601 timestamp
- chain_id: parent thought ID (null if root thought)
Returns empty array if thinking engine is disabled or fails.
"""
# Clamp limit to valid range
if limit < 1:
limit = 1
elif limit > _MAX_THOUGHT_LIMIT:
limit = _MAX_THOUGHT_LIMIT
thoughts = _build_matrix_thoughts_response(limit=limit)
return JSONResponse(
content=thoughts,
headers={"Cache-Control": "no-cache, no-store"},
)
# ---------------------------------------------------------------------------
# Matrix Health Endpoint — backend capability discovery
# ---------------------------------------------------------------------------
# Health check cache (5-second TTL for capability checks)
_health_cache: dict | None = None
_health_cache_ts: float = 0.0
_HEALTH_CACHE_TTL = 5.0
def _check_capability_thinking() -> bool:
"""Check if thinking engine is available."""
try:
from timmy.thinking import thinking_engine
# Check if the engine has been initialized (has a db path)
return hasattr(thinking_engine, "_db") and thinking_engine._db is not None
except Exception:
return False
def _check_capability_memory() -> bool:
"""Check if memory system is available."""
try:
from timmy.memory_system import HOT_MEMORY_PATH
return HOT_MEMORY_PATH.exists()
except Exception:
return False
def _check_capability_bark() -> bool:
"""Check if bark production is available."""
try:
from infrastructure.presence import produce_bark
return callable(produce_bark)
except Exception:
return False
def _check_capability_familiar() -> bool:
"""Check if familiar (Pip) is available."""
try:
from timmy.familiar import pip_familiar
return pip_familiar is not None
except Exception:
return False
def _check_capability_lightning() -> bool:
"""Check if Lightning payments are available."""
# Lightning is currently disabled per health.py
# Returns False until properly re-implemented
return False
def _build_matrix_health_response() -> dict[str, Any]:
"""Build the Matrix health response with capability checks.
Performs lightweight checks (<100ms total) to determine which features
are available. Returns 200 even if some capabilities are degraded.
"""
capabilities = {
"thinking": _check_capability_thinking(),
"memory": _check_capability_memory(),
"bark": _check_capability_bark(),
"familiar": _check_capability_familiar(),
"lightning": _check_capability_lightning(),
}
# Status is ok if core capabilities (thinking, memory, bark) are available
core_caps = ["thinking", "memory", "bark"]
core_available = all(capabilities[c] for c in core_caps)
status = "ok" if core_available else "degraded"
return {
"status": status,
"version": "1.0.0",
"capabilities": capabilities,
}
@matrix_router.get("/health")
async def get_matrix_health() -> JSONResponse:
"""Return health status and capability availability for Matrix frontend.
This endpoint allows the Matrix frontend to discover what backend
capabilities are available so it can show/hide UI elements:
- thinking: Show thought bubbles if enabled
- memory: Show crystal ball memory search if available
- bark: Enable visitor chat responses
- familiar: Show Pip the familiar
- lightning: Enable payment features
Response time is <100ms (no heavy checks). Returns 200 even if
some capabilities are degraded.
Response:
- status: "ok" or "degraded"
- version: API version string
- capabilities: dict of feature:bool
"""
response = _build_matrix_health_response()
status_code = 200 # Always 200, even if degraded
return JSONResponse(
content=response,
status_code=status_code,
headers={"Cache-Control": "no-cache, no-store"},
)
# ---------------------------------------------------------------------------
# Matrix Memory Search Endpoint — visitors query Timmy's memory
# ---------------------------------------------------------------------------
# Rate limiting: 1 search per 5 seconds per IP
_MEMORY_SEARCH_RATE_LIMIT_SECONDS = 5
_memory_search_last_request: dict[str, float] = {}
_MAX_MEMORY_RESULTS = 5
_MAX_MEMORY_TEXT_LENGTH = 200
def _get_client_ip(request) -> str:
"""Extract client IP from request, respecting X-Forwarded-For header."""
# Check for forwarded IP (when behind proxy)
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
# Take the first IP in the chain
return forwarded.split(",")[0].strip()
# Fall back to direct client IP
if request.client:
return request.client.host
return "unknown"
def _build_matrix_memory_response(
memories: list,
) -> list[dict[str, Any]]:
"""Build the Matrix memory search response.
Formats memory entries for Matrix display:
- text: truncated to 200 characters
- relevance: 0-1 score from relevance_score
- created_at: ISO-8601 timestamp
- context_type: the memory type
Results are capped at _MAX_MEMORY_RESULTS.
"""
results = []
for mem in memories[:_MAX_MEMORY_RESULTS]:
text = mem.content
if len(text) > _MAX_MEMORY_TEXT_LENGTH:
text = text[:_MAX_MEMORY_TEXT_LENGTH] + "..."
results.append(
{
"text": text,
"relevance": round(mem.relevance_score or 0.0, 4),
"created_at": mem.timestamp,
"context_type": mem.context_type,
}
)
return results
@matrix_router.get("/memory/search")
async def get_matrix_memory_search(request: Request, q: str | None = None) -> JSONResponse:
"""Search Timmy's memory for relevant snippets.
Allows Matrix visitors to query Timmy's memory ("what do you remember
about sovereignty?"). Results appear as floating crystal-ball text
in the Workshop room.
Query params:
- q: Search query text (required)
Response: JSON array of memory objects:
- text: Memory content (truncated to 200 chars)
- relevance: Similarity score 0-1
- created_at: ISO-8601 timestamp
- context_type: Memory type (conversation, fact, etc.)
Rate limited to 1 search per 5 seconds per IP.
Returns:
- 200: JSON array of memory results (max 5)
- 400: Missing or empty query parameter
- 429: Rate limit exceeded
"""
# Validate query parameter
query = q.strip() if q else ""
if not query:
return JSONResponse(
status_code=400,
content={"error": "Query parameter 'q' is required"},
)
# Rate limiting check by IP
client_ip = _get_client_ip(request)
now = time.time()
last_request = _memory_search_last_request.get(client_ip, 0)
time_since_last = now - last_request
if time_since_last < _MEMORY_SEARCH_RATE_LIMIT_SECONDS:
retry_after = _MEMORY_SEARCH_RATE_LIMIT_SECONDS - time_since_last
return JSONResponse(
status_code=429,
content={"error": "Rate limit exceeded. Try again later."},
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Record this request
_memory_search_last_request[client_ip] = now
# Search memories
try:
memories = search_memories(query, limit=_MAX_MEMORY_RESULTS)
results = _build_matrix_memory_response(memories)
except Exception as exc:
logger.warning("Memory search failed: %s", exc)
results = []
return JSONResponse(
content=results,
headers={"Cache-Control": "no-cache, no-store"},
)

View File

@@ -0,0 +1,17 @@
"""Dashboard services for business logic."""
from dashboard.services.scorecard_service import (
PeriodType,
ScorecardSummary,
generate_all_scorecards,
generate_scorecard,
get_tracked_agents,
)
__all__ = [
"PeriodType",
"ScorecardSummary",
"generate_all_scorecards",
"generate_scorecard",
"get_tracked_agents",
]

View File

@@ -0,0 +1,515 @@
"""Agent scorecard service — track and summarize agent performance.
Generates daily/weekly scorecards showing:
- Issues touched, PRs opened/merged
- Tests affected, tokens earned/spent
- Pattern highlights (merge rate, activity quality)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from typing import Any
from infrastructure.events.bus import Event, get_event_bus
logger = logging.getLogger(__name__)
# Bot/agent usernames to track
TRACKED_AGENTS = frozenset({"hermes", "kimi", "manus", "claude", "gemini"})
class PeriodType(StrEnum):
daily = "daily"
weekly = "weekly"
@dataclass
class AgentMetrics:
"""Raw metrics collected for an agent over a period."""
agent_id: str
issues_touched: set[int] = field(default_factory=set)
prs_opened: set[int] = field(default_factory=set)
prs_merged: set[int] = field(default_factory=set)
tests_affected: set[str] = field(default_factory=set)
tokens_earned: int = 0
tokens_spent: int = 0
commits: int = 0
comments: int = 0
@property
def pr_merge_rate(self) -> float:
"""Calculate PR merge rate (0.0 - 1.0)."""
opened = len(self.prs_opened)
if opened == 0:
return 0.0
return len(self.prs_merged) / opened
@dataclass
class ScorecardSummary:
"""A generated scorecard with narrative summary."""
agent_id: str
period_type: PeriodType
period_start: datetime
period_end: datetime
metrics: AgentMetrics
narrative_bullets: list[str] = field(default_factory=list)
patterns: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""Convert scorecard to dictionary for JSON serialization."""
return {
"agent_id": self.agent_id,
"period_type": self.period_type.value,
"period_start": self.period_start.isoformat(),
"period_end": self.period_end.isoformat(),
"metrics": {
"issues_touched": len(self.metrics.issues_touched),
"prs_opened": len(self.metrics.prs_opened),
"prs_merged": len(self.metrics.prs_merged),
"pr_merge_rate": round(self.metrics.pr_merge_rate, 2),
"tests_affected": len(self.tests_affected),
"commits": self.metrics.commits,
"comments": self.metrics.comments,
"tokens_earned": self.metrics.tokens_earned,
"tokens_spent": self.metrics.tokens_spent,
"token_net": self.metrics.tokens_earned - self.metrics.tokens_spent,
},
"narrative_bullets": self.narrative_bullets,
"patterns": self.patterns,
}
@property
def tests_affected(self) -> set[str]:
"""Alias for metrics.tests_affected."""
return self.metrics.tests_affected
def _get_period_bounds(
period_type: PeriodType, reference_date: datetime | None = None
) -> tuple[datetime, datetime]:
"""Calculate start and end timestamps for a period.
Args:
period_type: daily or weekly
reference_date: The date to calculate from (defaults to now)
Returns:
Tuple of (period_start, period_end) in UTC
"""
if reference_date is None:
reference_date = datetime.now(UTC)
# Normalize to start of day
end = reference_date.replace(hour=0, minute=0, second=0, microsecond=0)
if period_type == PeriodType.daily:
start = end - timedelta(days=1)
else: # weekly
start = end - timedelta(days=7)
return start, end
def _collect_events_for_period(
start: datetime, end: datetime, agent_id: str | None = None
) -> list[Event]:
"""Collect events from the event bus for a time period.
Args:
start: Period start time
end: Period end time
agent_id: Optional agent filter
Returns:
List of matching events
"""
bus = get_event_bus()
events: list[Event] = []
# Query persisted events for relevant types
event_types = [
"gitea.push",
"gitea.issue.opened",
"gitea.issue.comment",
"gitea.pull_request",
"agent.task.completed",
"test.execution",
]
for event_type in event_types:
try:
type_events = bus.replay(
event_type=event_type,
source=agent_id,
limit=1000,
)
events.extend(type_events)
except Exception as exc:
logger.debug("Failed to replay events for %s: %s", event_type, exc)
# Filter by timestamp
filtered = []
for event in events:
try:
event_time = datetime.fromisoformat(event.timestamp.replace("Z", "+00:00"))
if start <= event_time < end:
filtered.append(event)
except (ValueError, AttributeError):
continue
return filtered
def _extract_actor_from_event(event: Event) -> str:
"""Extract the actor/agent from an event."""
# Try data fields first
if "actor" in event.data:
return event.data["actor"]
if "agent_id" in event.data:
return event.data["agent_id"]
# Fall back to source
return event.source
def _is_tracked_agent(actor: str) -> bool:
"""Check if an actor is a tracked agent."""
return actor.lower() in TRACKED_AGENTS
def _aggregate_metrics(events: list[Event]) -> dict[str, AgentMetrics]:
"""Aggregate metrics from events grouped by agent.
Args:
events: List of events to process
Returns:
Dict mapping agent_id -> AgentMetrics
"""
metrics_by_agent: dict[str, AgentMetrics] = {}
for event in events:
actor = _extract_actor_from_event(event)
# Skip non-agent events unless they explicitly have an agent_id
if not _is_tracked_agent(actor) and "agent_id" not in event.data:
continue
if actor not in metrics_by_agent:
metrics_by_agent[actor] = AgentMetrics(agent_id=actor)
metrics = metrics_by_agent[actor]
# Process based on event type
event_type = event.type
if event_type == "gitea.push":
metrics.commits += event.data.get("num_commits", 1)
elif event_type == "gitea.issue.opened":
issue_num = event.data.get("issue_number", 0)
if issue_num:
metrics.issues_touched.add(issue_num)
elif event_type == "gitea.issue.comment":
metrics.comments += 1
issue_num = event.data.get("issue_number", 0)
if issue_num:
metrics.issues_touched.add(issue_num)
elif event_type == "gitea.pull_request":
pr_num = event.data.get("pr_number", 0)
action = event.data.get("action", "")
merged = event.data.get("merged", False)
if pr_num:
if action == "opened":
metrics.prs_opened.add(pr_num)
elif action == "closed" and merged:
metrics.prs_merged.add(pr_num)
# Also count as touched issue for tracking
metrics.issues_touched.add(pr_num)
elif event_type == "agent.task.completed":
# Extract test files from task data
affected = event.data.get("tests_affected", [])
for test in affected:
metrics.tests_affected.add(test)
# Token rewards from task completion
reward = event.data.get("token_reward", 0)
if reward:
metrics.tokens_earned += reward
elif event_type == "test.execution":
# Track test files that were executed
test_files = event.data.get("test_files", [])
for test in test_files:
metrics.tests_affected.add(test)
return metrics_by_agent
def _query_token_transactions(agent_id: str, start: datetime, end: datetime) -> tuple[int, int]:
"""Query the lightning ledger for token transactions.
Args:
agent_id: The agent to query for
start: Period start
end: Period end
Returns:
Tuple of (tokens_earned, tokens_spent)
"""
try:
from lightning.ledger import get_transactions
transactions = get_transactions(limit=1000)
earned = 0
spent = 0
for tx in transactions:
# Filter by agent if specified
if tx.agent_id and tx.agent_id != agent_id:
continue
# Filter by timestamp
try:
tx_time = datetime.fromisoformat(tx.created_at.replace("Z", "+00:00"))
if not (start <= tx_time < end):
continue
except (ValueError, AttributeError):
continue
if tx.tx_type.value == "incoming":
earned += tx.amount_sats
else:
spent += tx.amount_sats
return earned, spent
except Exception as exc:
logger.debug("Failed to query token transactions: %s", exc)
return 0, 0
def _generate_narrative_bullets(metrics: AgentMetrics, period_type: PeriodType) -> list[str]:
"""Generate narrative summary bullets for a scorecard.
Args:
metrics: The agent's metrics
period_type: daily or weekly
Returns:
List of narrative bullet points
"""
bullets: list[str] = []
period_label = "day" if period_type == PeriodType.daily else "week"
# Activity summary
activities = []
if metrics.commits:
activities.append(f"{metrics.commits} commit{'s' if metrics.commits != 1 else ''}")
if len(metrics.prs_opened):
activities.append(
f"{len(metrics.prs_opened)} PR{'s' if len(metrics.prs_opened) != 1 else ''} opened"
)
if len(metrics.prs_merged):
activities.append(
f"{len(metrics.prs_merged)} PR{'s' if len(metrics.prs_merged) != 1 else ''} merged"
)
if len(metrics.issues_touched):
activities.append(
f"{len(metrics.issues_touched)} issue{'s' if len(metrics.issues_touched) != 1 else ''} touched"
)
if metrics.comments:
activities.append(f"{metrics.comments} comment{'s' if metrics.comments != 1 else ''}")
if activities:
bullets.append(f"Active across {', '.join(activities)} this {period_label}.")
# Test activity
if len(metrics.tests_affected):
bullets.append(
f"Affected {len(metrics.tests_affected)} test file{'s' if len(metrics.tests_affected) != 1 else ''}."
)
# Token summary
net_tokens = metrics.tokens_earned - metrics.tokens_spent
if metrics.tokens_earned or metrics.tokens_spent:
if net_tokens > 0:
bullets.append(
f"Net earned {net_tokens} tokens ({metrics.tokens_earned} earned, {metrics.tokens_spent} spent)."
)
elif net_tokens < 0:
bullets.append(
f"Net spent {abs(net_tokens)} tokens ({metrics.tokens_earned} earned, {metrics.tokens_spent} spent)."
)
else:
bullets.append(
f"Balanced token flow ({metrics.tokens_earned} earned, {metrics.tokens_spent} spent)."
)
# Handle empty case
if not bullets:
bullets.append(f"No recorded activity this {period_label}.")
return bullets
def _detect_patterns(metrics: AgentMetrics) -> list[str]:
"""Detect interesting patterns in agent behavior.
Args:
metrics: The agent's metrics
Returns:
List of pattern descriptions
"""
patterns: list[str] = []
pr_opened = len(metrics.prs_opened)
merge_rate = metrics.pr_merge_rate
# Merge rate patterns
if pr_opened >= 3:
if merge_rate >= 0.8:
patterns.append("High merge rate with few failures — code quality focus.")
elif merge_rate <= 0.3:
patterns.append("Lots of noisy PRs, low merge rate — may need review support.")
# Activity patterns
if metrics.commits > 10 and pr_opened == 0:
patterns.append("High commit volume without PRs — working directly on main?")
if len(metrics.issues_touched) > 5 and metrics.comments == 0:
patterns.append("Touching many issues but low comment volume — silent worker.")
if metrics.comments > len(metrics.issues_touched) * 2:
patterns.append("Highly communicative — lots of discussion relative to work items.")
# Token patterns
net_tokens = metrics.tokens_earned - metrics.tokens_spent
if net_tokens > 100:
patterns.append("Strong token accumulation — high value delivery.")
elif net_tokens < -50:
patterns.append("High token spend — may be in experimentation phase.")
return patterns
def generate_scorecard(
agent_id: str,
period_type: PeriodType = PeriodType.daily,
reference_date: datetime | None = None,
) -> ScorecardSummary | None:
"""Generate a scorecard for a single agent.
Args:
agent_id: The agent to generate scorecard for
period_type: daily or weekly
reference_date: The date to calculate from (defaults to now)
Returns:
ScorecardSummary or None if agent has no activity
"""
start, end = _get_period_bounds(period_type, reference_date)
# Collect events
events = _collect_events_for_period(start, end, agent_id)
# Aggregate metrics
all_metrics = _aggregate_metrics(events)
# Get metrics for this specific agent
if agent_id not in all_metrics:
# Create empty metrics - still generate a scorecard
metrics = AgentMetrics(agent_id=agent_id)
else:
metrics = all_metrics[agent_id]
# Augment with token data from ledger
tokens_earned, tokens_spent = _query_token_transactions(agent_id, start, end)
metrics.tokens_earned = max(metrics.tokens_earned, tokens_earned)
metrics.tokens_spent = max(metrics.tokens_spent, tokens_spent)
# Generate narrative and patterns
narrative = _generate_narrative_bullets(metrics, period_type)
patterns = _detect_patterns(metrics)
return ScorecardSummary(
agent_id=agent_id,
period_type=period_type,
period_start=start,
period_end=end,
metrics=metrics,
narrative_bullets=narrative,
patterns=patterns,
)
def generate_all_scorecards(
period_type: PeriodType = PeriodType.daily,
reference_date: datetime | None = None,
) -> list[ScorecardSummary]:
"""Generate scorecards for all tracked agents.
Args:
period_type: daily or weekly
reference_date: The date to calculate from (defaults to now)
Returns:
List of ScorecardSummary for all agents with activity
"""
start, end = _get_period_bounds(period_type, reference_date)
# Collect all events
events = _collect_events_for_period(start, end)
# Aggregate metrics for all agents
all_metrics = _aggregate_metrics(events)
# Include tracked agents even if no activity
for agent_id in TRACKED_AGENTS:
if agent_id not in all_metrics:
all_metrics[agent_id] = AgentMetrics(agent_id=agent_id)
# Generate scorecards
scorecards: list[ScorecardSummary] = []
for agent_id, metrics in all_metrics.items():
# Augment with token data
tokens_earned, tokens_spent = _query_token_transactions(agent_id, start, end)
metrics.tokens_earned = max(metrics.tokens_earned, tokens_earned)
metrics.tokens_spent = max(metrics.tokens_spent, tokens_spent)
narrative = _generate_narrative_bullets(metrics, period_type)
patterns = _detect_patterns(metrics)
scorecard = ScorecardSummary(
agent_id=agent_id,
period_type=period_type,
period_start=start,
period_end=end,
metrics=metrics,
narrative_bullets=narrative,
patterns=patterns,
)
scorecards.append(scorecard)
# Sort by agent_id for consistent ordering
scorecards.sort(key=lambda s: s.agent_id)
return scorecards
def get_tracked_agents() -> list[str]:
"""Return the list of tracked agent IDs."""
return sorted(TRACKED_AGENTS)

View File

@@ -51,6 +51,7 @@
<a href="/thinking" class="mc-test-link mc-link-thinking">THINKING</a>
<a href="/swarm/mission-control" class="mc-test-link">MISSION CTRL</a>
<a href="/swarm/live" class="mc-test-link">SWARM</a>
<a href="/scorecards" class="mc-test-link">SCORECARDS</a>
<a href="/bugs" class="mc-test-link mc-link-bugs">BUGS</a>
</div>
</div>
@@ -123,6 +124,7 @@
<a href="/thinking" class="mc-mobile-link">THINKING</a>
<a href="/swarm/mission-control" class="mc-mobile-link">MISSION CONTROL</a>
<a href="/swarm/live" class="mc-mobile-link">SWARM</a>
<a href="/scorecards" class="mc-mobile-link">SCORECARDS</a>
<a href="/bugs" class="mc-mobile-link">BUGS</a>
<div class="mc-mobile-section-label">INTELLIGENCE</div>
<a href="/spark/ui" class="mc-mobile-link">SPARK</a>

View File

@@ -21,6 +21,11 @@
</div>
{% endcall %}
<!-- Daily Run Metrics (HTMX polled) -->
{% call panel("DAILY RUN", hx_get="/daily-run/panel", hx_trigger="every 60s") %}
<div class="mc-loading-placeholder">LOADING...</div>
{% endcall %}
</div>
<!-- Main panel — swappable via HTMX; defaults to Timmy on load -->

View File

@@ -179,6 +179,13 @@
</div>
</div>
<!-- Sovereignty Metrics -->
{% call panel("SOVEREIGNTY METRICS", id="sovereignty-metrics-panel",
hx_get="/sovereignty/metrics/panel",
hx_trigger="load, every 30s") %}
<p class="chat-history-placeholder">Loading sovereignty metrics...</p>
{% endcall %}
<!-- Chat History -->
<div class="card mc-card-spaced">
<div class="card-header">

View File

@@ -0,0 +1,54 @@
<div class="card-header mc-panel-header">// DAILY RUN METRICS</div>
<div class="card-body p-3">
{% if not gitea_available %}
<div class="mc-muted" style="font-size: 0.85rem; padding: 8px 0;">
<span style="color: var(--amber);"></span> Gitea API unavailable
</div>
{% else %}
{% set m = metrics %}
<!-- Sessions summary -->
<div class="dr-section" style="margin-bottom: 16px;">
<div class="dr-row" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span class="dr-label" style="font-size: 0.85rem; color: var(--text-dim);">Sessions ({{ m.lookback_days }}d)</span>
<a href="{{ logbook_url }}" target="_blank" class="dr-link" style="font-size: 0.75rem; color: var(--green); text-decoration: none;">
Logbook →
</a>
</div>
<div class="dr-stat" style="display: flex; align-items: baseline; gap: 8px;">
<span class="dr-value" style="font-size: 1.5rem; font-weight: 600; color: var(--text-bright);">{{ m.sessions_completed }}</span>
<span class="dr-trend" style="font-size: 0.9rem; color: {{ m.sessions_trend_color }};">{{ m.sessions_trend }}</span>
<span class="dr-prev" style="font-size: 0.75rem; color: var(--text-dim);">vs {{ m.sessions_previous }} prev</span>
</div>
</div>
<!-- Layer breakdown -->
<div class="dr-section">
<div class="dr-label" style="font-size: 0.85rem; color: var(--text-dim); margin-bottom: 8px;">Issues by Layer</div>
<div class="dr-layers" style="display: flex; flex-direction: column; gap: 6px;">
{% for layer in m.layers %}
<div class="dr-layer-row" style="display: flex; justify-content: space-between; align-items: center;">
<a href="{{ layer_urls[layer.name] }}" target="_blank" class="dr-layer-name" style="font-size: 0.8rem; color: var(--text); text-decoration: none; text-transform: capitalize;">
{{ layer.name.replace('-', ' ') }}
</a>
<div class="dr-layer-stat" style="display: flex; align-items: center; gap: 6px;">
<span class="dr-layer-value" style="font-size: 0.9rem; font-weight: 500; color: var(--text-bright);">{{ layer.current_count }}</span>
<span class="dr-layer-trend" style="font-size: 0.75rem; color: {{ layer.trend_color }}; width: 18px; text-align: center;">{{ layer.trend }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Total touched -->
<div class="dr-section" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
<div class="dr-row" style="display: flex; justify-content: space-between; align-items: center;">
<span class="dr-label" style="font-size: 0.8rem; color: var(--text-dim);">Total Issues Touched</span>
<div class="dr-total-stat" style="display: flex; align-items: center; gap: 6px;">
<span class="dr-total-value" style="font-size: 1rem; font-weight: 600; color: var(--text-bright);">{{ m.total_touched_current }}</span>
<span class="dr-total-prev" style="font-size: 0.7rem; color: var(--text-dim);">/ {{ m.total_touched_previous }} prev</span>
</div>
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,80 @@
{% from "macros.html" import panel %}
<div class="quests-summary mb-4">
<div class="row">
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value">{{ total_tokens }}</div>
<div class="stat-label">Tokens Earned</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value">{{ completed_count }}</div>
<div class="stat-label">Quests Completed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value">{{ quests|selectattr('enabled', 'equalto', true)|list|length }}</div>
<div class="stat-label">Active Quests</div>
</div>
</div>
</div>
</div>
<div class="quests-list">
{% for quest in quests %}
{% if quest.enabled %}
<div class="quest-card quest-status-{{ quest.status }}">
<div class="quest-header">
<h5 class="quest-name">{{ quest.name }}</h5>
<span class="quest-reward">+{{ quest.reward_tokens }} ⚡</span>
</div>
<p class="quest-description">{{ quest.description }}</p>
<div class="quest-progress">
{% if quest.status == 'completed' %}
<div class="progress">
<div class="progress-bar bg-success" style="width: 100%"></div>
</div>
<span class="quest-status-badge completed">Completed</span>
{% elif quest.status == 'claimed' %}
<div class="progress">
<div class="progress-bar bg-success" style="width: 100%"></div>
</div>
<span class="quest-status-badge claimed">Reward Claimed</span>
{% elif quest.on_cooldown %}
<div class="progress">
<div class="progress-bar bg-secondary" style="width: 100%"></div>
</div>
<span class="quest-status-badge cooldown">
Cooldown: {{ quest.cooldown_hours_remaining }}h remaining
</span>
{% else %}
<div class="progress">
<div class="progress-bar" style="width: {{ (quest.current_value / quest.target_value * 100)|int }}%"></div>
</div>
<span class="quest-progress-text">{{ quest.current_value }} / {{ quest.target_value }}</span>
{% endif %}
</div>
<div class="quest-meta">
<span class="quest-type">{{ quest.type }}</span>
{% if quest.repeatable %}
<span class="quest-repeatable">↻ Repeatable</span>
{% endif %}
{% if quest.completion_count > 0 %}
<span class="quest-completions">Completed {{ quest.completion_count }} time{% if quest.completion_count != 1 %}s{% endif %}</span>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% if not quests|selectattr('enabled', 'equalto', true)|list|length %}
<div class="alert alert-info">
No active quests available. Check back later or contact an administrator.
</div>
{% endif %}

View File

@@ -0,0 +1,63 @@
{# HTMX partial: Sovereignty Metrics Progress Panel
Loaded via hx-get="/sovereignty/metrics/panel"
Refs: #981
#}
{% set phase_labels = {"pre-start": "Pre-start", "week1": "Week 1", "month1": "Month 1", "month3": "Month 3", "graduated": "Graduated"} %}
{% set phase_colors = {"pre-start": "var(--text-dim)", "week1": "var(--red)", "month1": "var(--amber)", "month3": "var(--green)", "graduated": "var(--purple)"} %}
{% set metric_labels = {
"cache_hit_rate": "Cache Hit Rate",
"api_cost": "API Cost / Task",
"time_to_report": "Time to Report",
"human_involvement": "Human Involvement",
"local_artifacts": "Local Artifacts"
} %}
{% set metric_units = {
"cache_hit_rate": "%",
"api_cost": "$",
"time_to_report": "min",
"human_involvement": "%",
"local_artifacts": ""
} %}
{% if alerts %}
<div class="sov-alerts">
{% for alert in alerts %}
<div class="sov-alert-item">
<span class="sov-alert-icon">!</span>
<span>{{ alert.message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<div class="grid grid-3">
{% for key, data in metrics.items() %}
{% set label = metric_labels.get(key, key) %}
{% set unit = metric_units.get(key, "") %}
{% set phase = data.phase %}
{% set color = phase_colors.get(phase, "var(--text-dim)") %}
<div class="stat">
<div class="stat-value" style="color: {{ color }}">
{% if data.current is not none %}
{% if key == "cache_hit_rate" or key == "human_involvement" %}
{{ "%.0f"|format(data.current * 100) }}{{ unit }}
{% elif key == "api_cost" %}
{{ unit }}{{ "%.2f"|format(data.current) }}
{% elif key == "time_to_report" %}
{{ "%.1f"|format(data.current) }}{{ unit }}
{% else %}
{{ data.current|int }}
{% endif %}
{% else %}
--
{% endif %}
</div>
<div class="stat-label">{{ label }}</div>
<div class="stat-label" style="font-size: 0.7rem; color: {{ color }}">
{{ phase_labels.get(phase, phase) }}
</div>
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Quests — Mission Control{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1 class="mc-title">Token Quests</h1>
<p class="mc-subtitle">Complete quests to earn bonus tokens</p>
</div>
</div>
<div class="row mt-4">
<div class="col-md-8">
<div id="quests-panel" hx-get="/quests/panel/{{ agent_id }}" hx-trigger="load, every 30s">
<div class="mc-loading">Loading quests...</div>
</div>
</div>
<div class="col-md-4">
<div class="card mc-panel">
<div class="card-header">
<h5 class="mb-0">Leaderboard</h5>
</div>
<div class="card-body">
<div id="leaderboard" hx-get="/quests/api/leaderboard" hx-trigger="load, every 60s">
<div class="mc-loading">Loading leaderboard...</div>
</div>
</div>
</div>
<div class="card mc-panel mt-4">
<div class="card-header">
<h5 class="mb-0">About Quests</h5>
</div>
<div class="card-body">
<p class="mb-2">Quests are special objectives that reward tokens upon completion.</p>
<ul class="mc-list mb-0">
<li>Complete Daily Run sessions</li>
<li>Close flaky-test issues</li>
<li>Reduce P1 issue backlog</li>
<li>Improve documentation</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block title %}Agent Scorecards - Timmy Time{% endblock %}
{% block extra_styles %}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">AGENT SCORECARDS</h1>
<p class="text-muted small mb-0">Track agent performance across issues, PRs, tests, and tokens</p>
</div>
<div class="d-flex gap-2">
<select id="period-select" class="form-select form-select-sm" style="width: auto;">
<option value="daily" selected>Daily</option>
<option value="weekly">Weekly</option>
</select>
<button class="btn btn-sm btn-primary" onclick="refreshScorecards()">
<span>Refresh</span>
</button>
</div>
</div>
<!-- Scorecards Grid -->
<div id="scorecards-container"
hx-get="/scorecards/all/panels?period=daily"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-5">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-muted mt-2">Loading scorecards...</p>
</div>
</div>
<!-- API Reference -->
<div class="mt-5 pt-4 border-top">
<h5 class="text-muted">API Reference</h5>
<div class="row g-3">
<div class="col-md-6">
<div class="card mc-panel">
<div class="card-body">
<h6 class="card-title">List Tracked Agents</h6>
<code>GET /scorecards/api/agents</code>
<p class="small text-muted mt-2">Returns all tracked agent IDs</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mc-panel">
<div class="card-body">
<h6 class="card-title">Get All Scorecards</h6>
<code>GET /scorecards/api?period=daily|weekly</code>
<p class="small text-muted mt-2">Returns scorecards for all agents</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mc-panel">
<div class="card-body">
<h6 class="card-title">Get Agent Scorecard</h6>
<code>GET /scorecards/api/{agent_id}?period=daily|weekly</code>
<p class="small text-muted mt-2">Returns scorecard for a specific agent</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mc-panel">
<div class="card-body">
<h6 class="card-title">HTML Panel (HTMX)</h6>
<code>GET /scorecards/panel/{agent_id}?period=daily|weekly</code>
<p class="small text-muted mt-2">Returns HTML panel for embedding</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Period selector change handler
document.getElementById('period-select').addEventListener('change', function() {
refreshScorecards();
});
function refreshScorecards() {
var period = document.getElementById('period-select').value;
var container = document.getElementById('scorecards-container');
// Show loading state
container.innerHTML = `
<div class="text-center py-5">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-muted mt-2">Loading scorecards...</p>
</div>
`;
// Trigger HTMX request
htmx.ajax('GET', '/scorecards/all/panels?period=' + period, {
target: '#scorecards-container',
swap: 'innerHTML'
});
}
// Auto-refresh every 5 minutes
setInterval(refreshScorecards, 300000);
</script>
{% endblock %}

View File

@@ -0,0 +1,264 @@
"""
claude_quota.py — Claude Code / Claude.ai Quota Monitor
Drop into src/infrastructure/ in the Timmy Time Dashboard repo.
Provides real-time quota visibility and metabolic protocol decisions.
Usage:
from infrastructure.claude_quota import QuotaMonitor
monitor = QuotaMonitor()
status = monitor.check()
print(status.five_hour_pct) # 42
print(status.five_hour_resets_in) # "2h 15m"
print(status.seven_day_pct) # 29
print(status.recommended_tier) # MetabolicTier.BURST
# Metabolic protocol: auto-select model based on quota
model = monitor.select_model(task_complexity="high")
# Returns "claude-sonnet-4-6" if quota allows, else "qwen3:14b"
"""
import json
import logging
import subprocess
import urllib.request
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
logger = logging.getLogger(__name__)
class MetabolicTier(StrEnum):
"""The three-tier metabolic protocol from the Timmy Time architecture."""
BURST = "burst" # Cloud API (Claude/Groq) — expensive, best quality
ACTIVE = "active" # Local 14B (Qwen3-14B) — free, good quality
RESTING = "resting" # Local 8B (Qwen3-8B) — free, fast, adequate
@dataclass
class QuotaStatus:
"""Current Claude quota state."""
five_hour_utilization: float # 0.0 to 1.0
five_hour_resets_at: str | None
seven_day_utilization: float # 0.0 to 1.0
seven_day_resets_at: str | None
raw_response: dict
fetched_at: datetime
@property
def five_hour_pct(self) -> int:
return int(self.five_hour_utilization * 100)
@property
def seven_day_pct(self) -> int:
return int(self.seven_day_utilization * 100)
@property
def five_hour_resets_in(self) -> str:
return _time_remaining(self.five_hour_resets_at)
@property
def seven_day_resets_in(self) -> str:
return _time_remaining(self.seven_day_resets_at)
@property
def recommended_tier(self) -> MetabolicTier:
"""Metabolic protocol: determine which inference tier to use."""
# If weekly quota is critical, go full local
if self.seven_day_utilization >= 0.80:
return MetabolicTier.RESTING
# If 5-hour window is critical or past half, use local
if self.five_hour_utilization >= 0.50:
return MetabolicTier.ACTIVE
# Quota healthy — cloud available for high-value tasks
return MetabolicTier.BURST
def summary(self) -> str:
"""Human-readable status string."""
return (
f"5h: {self.five_hour_pct}% (resets {self.five_hour_resets_in}) | "
f"7d: {self.seven_day_pct}% (resets {self.seven_day_resets_in}) | "
f"tier: {self.recommended_tier.value}"
)
class QuotaMonitor:
"""
Monitors Claude Code / Claude.ai quota via the internal OAuth API.
The token is read from macOS Keychain where Claude Code stores it.
Falls back gracefully if credentials aren't available (e.g., on Linux VPS).
"""
API_URL = "https://api.anthropic.com/api/oauth/usage"
KEYCHAIN_SERVICE = "Claude Code-credentials"
USER_AGENT = "claude-code/2.0.32"
def __init__(self) -> None:
self._token: str | None = None
self._last_status: QuotaStatus | None = None
self._cache_seconds = 30 # Don't hammer the API
def _get_token(self) -> str | None:
"""Extract OAuth token from macOS Keychain."""
if self._token:
return self._token
try:
result = subprocess.run(
["security", "find-generic-password", "-s", self.KEYCHAIN_SERVICE, "-w"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
logger.warning("Claude Code credentials not found in Keychain")
return None
creds = json.loads(result.stdout.strip())
oauth = creds.get("claudeAiOauth", creds)
self._token = oauth.get("accessToken")
return self._token
except (
json.JSONDecodeError,
KeyError,
FileNotFoundError,
subprocess.TimeoutExpired,
) as exc:
logger.warning("Could not read Claude Code credentials: %s", exc)
return None
def check(self, force: bool = False) -> QuotaStatus | None:
"""
Fetch current quota status.
Returns None if credentials aren't available (graceful degradation).
Caches results for 30 seconds to avoid rate limiting the quota API itself.
"""
# Return cached if fresh
if not force and self._last_status:
age = (datetime.now(UTC) - self._last_status.fetched_at).total_seconds()
if age < self._cache_seconds:
return self._last_status
token = self._get_token()
if not token:
return None
try:
req = urllib.request.Request(
self.API_URL,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": self.USER_AGENT,
"Authorization": f"Bearer {token}",
"anthropic-beta": "oauth-2025-04-20",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
five_hour = data.get("five_hour") or {}
seven_day = data.get("seven_day") or {}
self._last_status = QuotaStatus(
five_hour_utilization=float(five_hour.get("utilization", 0.0)),
five_hour_resets_at=five_hour.get("resets_at"),
seven_day_utilization=float(seven_day.get("utilization", 0.0)),
seven_day_resets_at=seven_day.get("resets_at"),
raw_response=data,
fetched_at=datetime.now(UTC),
)
return self._last_status
except Exception as exc:
logger.warning("Failed to fetch quota: %s", exc)
return self._last_status # Return stale data if available
def select_model(self, task_complexity: str = "medium") -> str:
"""
Metabolic protocol: select the right model based on quota + task complexity.
Returns an Ollama model tag or "claude-sonnet-4-6" for cloud.
task_complexity: "low" | "medium" | "high"
"""
status = self.check()
# No quota info available — assume local only (sovereign default)
if status is None:
return "qwen3:14b" if task_complexity == "high" else "qwen3:8b"
tier = status.recommended_tier
if tier == MetabolicTier.BURST and task_complexity == "high":
return "claude-sonnet-4-6" # Cloud — best quality
elif tier == MetabolicTier.BURST and task_complexity == "medium":
return "qwen3:14b" # Save cloud for truly hard tasks
elif tier == MetabolicTier.ACTIVE:
return "qwen3:14b" # Local 14B — good enough
else: # RESTING
return "qwen3:8b" # Local 8B — conserve everything
def should_use_cloud(self, task_value: str = "normal") -> bool:
"""
Simple yes/no: should this task use cloud API?
task_value: "critical" | "high" | "normal" | "routine"
"""
status = self.check()
if status is None:
return False # No credentials = local only
if task_value == "critical":
return status.seven_day_utilization < 0.95 # Almost always yes
elif task_value == "high":
return status.five_hour_utilization < 0.60
elif task_value == "normal":
return status.five_hour_utilization < 0.30
else: # routine
return False # Never waste cloud on routine
def _time_remaining(reset_at: str | None) -> str:
"""Format time until reset as human-readable string."""
if not reset_at or reset_at == "null":
return "unknown"
try:
reset = datetime.fromisoformat(reset_at.replace("Z", "+00:00"))
now = datetime.now(UTC)
diff = reset - now
if diff.total_seconds() <= 0:
return "resetting now"
hours = int(diff.total_seconds() // 3600)
mins = int((diff.total_seconds() % 3600) // 60)
if hours > 0:
return f"{hours}h {mins}m"
return f"{mins}m"
except (ValueError, TypeError):
return "unknown"
# Module-level singleton
_quota_monitor: QuotaMonitor | None = None
def get_quota_monitor() -> QuotaMonitor:
"""Get or create the quota monitor singleton."""
global _quota_monitor
if _quota_monitor is None:
_quota_monitor = QuotaMonitor()
return _quota_monitor

View File

@@ -0,0 +1,84 @@
"""Thread-local SQLite connection pool.
Provides a ConnectionPool class that manages SQLite connections per thread,
with support for context managers and automatic cleanup.
"""
import sqlite3
import threading
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
class ConnectionPool:
"""Thread-local SQLite connection pool.
Each thread gets its own connection, which is reused for subsequent
requests from the same thread. Connections are automatically cleaned
up when close_connection() is called or the context manager exits.
"""
def __init__(self, db_path: Path | str) -> None:
"""Initialize the connection pool.
Args:
db_path: Path to the SQLite database file.
"""
self._db_path = Path(db_path)
self._local = threading.local()
def _ensure_db_exists(self) -> None:
"""Ensure the database directory exists."""
self._db_path.parent.mkdir(parents=True, exist_ok=True)
def get_connection(self) -> sqlite3.Connection:
"""Get a connection for the current thread.
Creates a new connection if one doesn't exist for this thread,
otherwise returns the existing connection.
Returns:
A sqlite3 Connection object.
"""
if not hasattr(self._local, "conn") or self._local.conn is None:
self._ensure_db_exists()
self._local.conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._local.conn.row_factory = sqlite3.Row
return self._local.conn
def close_connection(self) -> None:
"""Close the connection for the current thread.
Cleans up the thread-local storage. Safe to call even if
no connection exists for this thread.
"""
if hasattr(self._local, "conn") and self._local.conn is not None:
self._local.conn.close()
self._local.conn = None
@contextmanager
def connection(self) -> Generator[sqlite3.Connection, None, None]:
"""Context manager for getting and automatically closing a connection.
Yields:
A sqlite3 Connection object.
Example:
with pool.connection() as conn:
cursor = conn.execute("SELECT 1")
result = cursor.fetchone()
"""
conn = self.get_connection()
try:
yield conn
finally:
self.close_connection()
def close_all(self) -> None:
"""Close all connections (useful for testing).
Note: This only closes the connection for the current thread.
In a multi-threaded environment, each thread must close its own.
"""
self.close_connection()

View File

@@ -0,0 +1,7 @@
"""Content moderation pipeline for AI narrator output.
Three-layer defense:
1. Game-context system prompts (vocabulary whitelists, theme framing)
2. Real-time output filter via Llama Guard (or fallback regex)
3. Per-game moderation profiles with configurable thresholds
"""

View File

@@ -0,0 +1,497 @@
"""Content moderation pipeline for AI narrator output.
Three-layer defense against harmful LLM output:
Layer 1 — Game-context system prompts with per-game vocabulary whitelists.
Layer 2 — Real-time output filter (Llama Guard via Ollama, regex fallback).
Layer 3 — Per-game moderation profiles with configurable thresholds.
Usage:
from infrastructure.guards.moderation import get_moderator
moderator = get_moderator()
result = await moderator.check("Some narrator text", game="morrowind")
if result.blocked:
use_fallback_narration(result.fallback)
"""
import logging
import re
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from typing import Any
from config import settings
logger = logging.getLogger(__name__)
class ModerationVerdict(Enum):
"""Result of a moderation check."""
PASS = "pass" # noqa: S105
FAIL = "fail"
ERROR = "error"
class ViolationCategory(Enum):
"""Categories of content violations."""
HATE_SPEECH = "hate_speech"
VIOLENCE_GLORIFICATION = "violence_glorification"
REAL_WORLD_HARM = "real_world_harm"
SEXUAL_CONTENT = "sexual_content"
SELF_HARM = "self_harm"
NONE = "none"
@dataclass
class ModerationResult:
"""Result from the moderation pipeline."""
verdict: ModerationVerdict
blocked: bool
category: ViolationCategory = ViolationCategory.NONE
confidence: float = 0.0
latency_ms: float = 0.0
layer: str = "" # Which layer caught it
fallback: str = "" # Contextual fallback narration
reason: str = ""
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
@property
def passed(self) -> bool:
return self.verdict == ModerationVerdict.PASS
@dataclass
class GameProfile:
"""Per-game moderation profile.
Defines vocabulary whitelists, theme framing rules,
and threshold tuning for a specific game.
"""
game_id: str
display_name: str
# Terms that are safe in game context (e.g., "Skooma", "slave", "Morag Tong")
vocabulary_whitelist: list[str] = field(default_factory=list)
# System prompt additions for game-context framing
context_prompt: str = ""
# Confidence threshold — below this, content passes even if flagged
threshold: float = 0.8
# Scene-type fallback narrations
fallbacks: dict[str, str] = field(default_factory=dict)
# ── Regex fallback patterns ─────────────────────────────────────────────────
# Used when Llama Guard is unavailable. Catches obvious violations only.
# Intentionally conservative — false negatives preferred over false positives.
_FALLBACK_PATTERNS: list[tuple[re.Pattern, ViolationCategory]] = [
(
re.compile(
r"\b(kill\s+(?:all|every)\s+(?:real|actual)\s+(?:people|humans|children))\b",
re.IGNORECASE,
),
ViolationCategory.REAL_WORLD_HARM,
),
(
re.compile(
r"\b((?:you|we)\s+should\s+(?:actually|really)\s+(?:harm|hurt|kill))\b",
re.IGNORECASE,
),
ViolationCategory.REAL_WORLD_HARM,
),
(
re.compile(
r"\b((?:in\s+real\s+life|irl)\s+(?:you|we)\s+(?:should|could|must)\s+"
r"(?:attack|hurt|kill|harm))\b",
re.IGNORECASE,
),
ViolationCategory.REAL_WORLD_HARM,
),
]
# ── Default game profiles ───────────────────────────────────────────────────
_DEFAULT_PROFILES: dict[str, GameProfile] = {
"morrowind": GameProfile(
game_id="morrowind",
display_name="The Elder Scrolls III: Morrowind",
vocabulary_whitelist=[
"Skooma",
"Moon Sugar",
"slave",
"slavery",
"Morag Tong",
"Dark Brotherhood",
"Telvanni",
"Camonna Tong",
"smuggler",
"assassin",
"Sixth House",
"Corprus",
"Dagoth Ur",
"Nerevarine",
],
context_prompt=(
"You are narrating gameplay of The Elder Scrolls III: Morrowind. "
"Morrowind contains mature themes including slavery, drug use (Skooma/Moon Sugar), "
"assassin guilds (Morag Tong, Dark Brotherhood), and political intrigue. "
"Treat these as game mechanics and historical worldbuilding within the game's "
"fictional universe. Never editorialize on real-world parallels. "
"Narrate events neutrally as a game commentator would."
),
threshold=0.85,
fallbacks={
"combat": "The battle rages on in the ashlands of Vvardenfell.",
"dialogue": "The conversation continues between the characters.",
"exploration": "The Nerevarine presses onward through the landscape.",
"default": "The adventure continues in Morrowind.",
},
),
"default": GameProfile(
game_id="default",
display_name="Generic Game",
vocabulary_whitelist=[],
context_prompt=(
"You are narrating gameplay. Describe in-game events as a neutral "
"game commentator. Never reference real-world violence, politics, "
"or controversial topics. Stay focused on game mechanics and story."
),
threshold=0.8,
fallbacks={
"combat": "The action continues on screen.",
"dialogue": "The conversation unfolds between characters.",
"exploration": "The player explores the game world.",
"default": "The gameplay continues.",
},
),
}
class ContentModerator:
"""Three-layer content moderation pipeline.
Layer 1: Game-context system prompts with vocabulary whitelists.
Layer 2: LLM-based moderation (Llama Guard via Ollama, with regex fallback).
Layer 3: Per-game threshold tuning and profile-based filtering.
Follows graceful degradation — if Llama Guard is unavailable,
falls back to regex patterns. Never crashes.
"""
def __init__(
self,
profiles: dict[str, GameProfile] | None = None,
guard_model: str | None = None,
) -> None:
self._profiles: dict[str, GameProfile] = profiles or dict(_DEFAULT_PROFILES)
self._guard_model = guard_model or settings.moderation_guard_model
self._guard_available: bool | None = None # Lazy-checked
self._metrics = _ModerationMetrics()
def get_profile(self, game: str) -> GameProfile:
"""Get the moderation profile for a game, falling back to default."""
return self._profiles.get(game, self._profiles["default"])
def register_profile(self, profile: GameProfile) -> None:
"""Register or update a game moderation profile."""
self._profiles[profile.game_id] = profile
logger.info("Registered moderation profile: %s", profile.game_id)
def get_context_prompt(self, game: str) -> str:
"""Get the game-context system prompt (Layer 1).
Returns the context prompt for the given game, which should be
prepended to the narrator's system prompt.
"""
profile = self.get_profile(game)
return profile.context_prompt
async def check(
self,
text: str,
game: str = "default",
scene_type: str = "default",
) -> ModerationResult:
"""Run the full moderation pipeline on narrator output.
Args:
text: The text to moderate (narrator output).
game: Game identifier for profile selection.
scene_type: Current scene type for fallback selection.
Returns:
ModerationResult with verdict, confidence, and fallback.
"""
start = time.monotonic()
profile = self.get_profile(game)
# Layer 1: Vocabulary whitelist pre-processing
cleaned_text = self._apply_whitelist(text, profile)
# Layer 2: LLM guard or regex fallback
result = await self._run_guard(cleaned_text, profile)
# Layer 3: Threshold tuning
if result.verdict == ModerationVerdict.FAIL and result.confidence < profile.threshold:
logger.info(
"Moderation flag below threshold (%.2f < %.2f) — allowing",
result.confidence,
profile.threshold,
)
result = ModerationResult(
verdict=ModerationVerdict.PASS,
blocked=False,
confidence=result.confidence,
layer="threshold",
reason=f"Below threshold ({result.confidence:.2f} < {profile.threshold:.2f})",
)
# Attach fallback narration if blocked
if result.blocked:
result.fallback = profile.fallbacks.get(
scene_type, profile.fallbacks.get("default", "")
)
result.latency_ms = (time.monotonic() - start) * 1000
self._metrics.record(result)
if result.blocked:
logger.warning(
"Content blocked [%s/%s]: category=%s confidence=%.2f reason=%s",
game,
scene_type,
result.category.value,
result.confidence,
result.reason,
)
return result
def _apply_whitelist(self, text: str, profile: GameProfile) -> str:
"""Layer 1: Replace whitelisted game terms with placeholders.
This prevents the guard model from flagging in-game terminology
(e.g., "Skooma" being flagged as drug reference).
"""
cleaned = text
for term in profile.vocabulary_whitelist:
# Case-insensitive replacement with a neutral placeholder
pattern = re.compile(re.escape(term), re.IGNORECASE)
cleaned = pattern.sub("[GAME_TERM]", cleaned)
return cleaned
async def _run_guard(self, text: str, profile: GameProfile) -> ModerationResult:
"""Layer 2: Run LLM guard model or fall back to regex."""
if not settings.moderation_enabled:
return ModerationResult(
verdict=ModerationVerdict.PASS,
blocked=False,
layer="disabled",
reason="Moderation disabled",
)
# Try Llama Guard via Ollama
if await self._is_guard_available():
try:
return await self._check_with_guard(text)
except Exception as exc:
logger.warning("Guard model failed, using regex fallback: %s", exc)
self._guard_available = False
# Regex fallback
return self._check_with_regex(text)
async def _is_guard_available(self) -> bool:
"""Check if the guard model is available via Ollama."""
if self._guard_available is not None:
return self._guard_available
try:
import aiohttp
url = f"{settings.normalized_ollama_url}/api/tags"
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status != 200:
self._guard_available = False
return False
data = await resp.json()
models = [m.get("name", "") for m in data.get("models", [])]
self._guard_available = any(
self._guard_model in m or m.startswith(self._guard_model) for m in models
)
if not self._guard_available:
logger.info(
"Guard model '%s' not found in Ollama — using regex fallback",
self._guard_model,
)
return self._guard_available
except Exception as exc:
logger.debug("Ollama guard check failed: %s", exc)
self._guard_available = False
return False
async def _check_with_guard(self, text: str) -> ModerationResult:
"""Run moderation check via Llama Guard."""
import aiohttp
url = f"{settings.normalized_ollama_url}/api/chat"
payload = {
"model": self._guard_model,
"messages": [
{
"role": "user",
"content": text,
}
],
"stream": False,
"options": {"temperature": 0.0},
}
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload) as resp:
if resp.status != 200:
raise RuntimeError(f"Guard API error: {resp.status}")
data = await resp.json()
response_text = data.get("message", {}).get("content", "").strip().lower()
# Llama Guard returns "safe" or "unsafe\n<category>"
if response_text.startswith("safe"):
return ModerationResult(
verdict=ModerationVerdict.PASS,
blocked=False,
confidence=0.0,
layer="llama_guard",
reason="Content safe",
)
# Parse unsafe response
category = ViolationCategory.NONE
confidence = 0.95 # High confidence from LLM guard
lines = response_text.split("\n")
if len(lines) > 1:
cat_str = lines[1].strip()
category = _parse_guard_category(cat_str)
return ModerationResult(
verdict=ModerationVerdict.FAIL,
blocked=True,
category=category,
confidence=confidence,
layer="llama_guard",
reason=f"Guard flagged: {response_text}",
)
def _check_with_regex(self, text: str) -> ModerationResult:
"""Regex fallback when guard model is unavailable.
Intentionally conservative — only catches obvious real-world harm.
"""
for pattern, category in _FALLBACK_PATTERNS:
match = pattern.search(text)
if match:
return ModerationResult(
verdict=ModerationVerdict.FAIL,
blocked=True,
category=category,
confidence=0.95, # Regex patterns are high-signal
layer="regex_fallback",
reason=f"Regex match: {match.group(0)[:50]}",
)
return ModerationResult(
verdict=ModerationVerdict.PASS,
blocked=False,
layer="regex_fallback",
reason="No regex matches",
)
def get_metrics(self) -> dict[str, Any]:
"""Get moderation pipeline metrics."""
return self._metrics.to_dict()
def reset_guard_cache(self) -> None:
"""Reset the guard availability cache (e.g., after pulling model)."""
self._guard_available = None
class _ModerationMetrics:
"""Tracks moderation pipeline performance."""
def __init__(self) -> None:
self.total_checks: int = 0
self.passed: int = 0
self.blocked: int = 0
self.errors: int = 0
self.total_latency_ms: float = 0.0
self.by_layer: dict[str, int] = {}
self.by_category: dict[str, int] = {}
def record(self, result: ModerationResult) -> None:
self.total_checks += 1
self.total_latency_ms += result.latency_ms
if result.verdict == ModerationVerdict.PASS:
self.passed += 1
elif result.verdict == ModerationVerdict.FAIL:
self.blocked += 1
else:
self.errors += 1
layer = result.layer or "unknown"
self.by_layer[layer] = self.by_layer.get(layer, 0) + 1
if result.blocked:
cat = result.category.value
self.by_category[cat] = self.by_category.get(cat, 0) + 1
def to_dict(self) -> dict[str, Any]:
return {
"total_checks": self.total_checks,
"passed": self.passed,
"blocked": self.blocked,
"errors": self.errors,
"avg_latency_ms": (
round(self.total_latency_ms / self.total_checks, 2)
if self.total_checks > 0
else 0.0
),
"by_layer": dict(self.by_layer),
"by_category": dict(self.by_category),
}
def _parse_guard_category(cat_str: str) -> ViolationCategory:
"""Parse Llama Guard category string to ViolationCategory."""
cat_lower = cat_str.lower()
if "hate" in cat_lower:
return ViolationCategory.HATE_SPEECH
if "violence" in cat_lower:
return ViolationCategory.VIOLENCE_GLORIFICATION
if "sexual" in cat_lower:
return ViolationCategory.SEXUAL_CONTENT
if "self-harm" in cat_lower or "self_harm" in cat_lower or "suicide" in cat_lower:
return ViolationCategory.SELF_HARM
if "harm" in cat_lower or "dangerous" in cat_lower:
return ViolationCategory.REAL_WORLD_HARM
return ViolationCategory.NONE
# ── Module-level singleton ──────────────────────────────────────────────────
_moderator: ContentModerator | None = None
def get_moderator() -> ContentModerator:
"""Get or create the content moderator singleton."""
global _moderator
if _moderator is None:
_moderator = ContentModerator()
return _moderator

View File

@@ -0,0 +1,56 @@
"""Load game moderation profiles from config/moderation.yaml.
Falls back to hardcoded defaults if the YAML file is missing or malformed.
"""
import logging
from pathlib import Path
from infrastructure.guards.moderation import GameProfile
logger = logging.getLogger(__name__)
def load_profiles(config_path: Path | None = None) -> dict[str, GameProfile]:
"""Load game moderation profiles from YAML config.
Args:
config_path: Path to moderation.yaml. Defaults to config/moderation.yaml.
Returns:
Dict mapping game_id to GameProfile.
"""
path = config_path or Path("config/moderation.yaml")
if not path.exists():
logger.info("Moderation config not found at %s — using defaults", path)
return {}
try:
import yaml
except ImportError:
logger.warning("PyYAML not installed — using default moderation profiles")
return {}
try:
data = yaml.safe_load(path.read_text())
except Exception as exc:
logger.error("Failed to parse moderation config: %s", exc)
return {}
profiles: dict[str, GameProfile] = {}
for game_id, profile_data in data.get("profiles", {}).items():
try:
profiles[game_id] = GameProfile(
game_id=game_id,
display_name=profile_data.get("display_name", game_id),
vocabulary_whitelist=profile_data.get("vocabulary_whitelist", []),
context_prompt=profile_data.get("context_prompt", ""),
threshold=float(profile_data.get("threshold", 0.8)),
fallbacks=profile_data.get("fallbacks", {}),
)
except Exception as exc:
logger.warning("Invalid profile '%s': %s", game_id, exc)
logger.info("Loaded %d moderation profiles from %s", len(profiles), path)
return profiles

View File

@@ -0,0 +1,266 @@
"""Matrix configuration loader utility.
Provides a typed dataclass for Matrix world configuration and a loader
that fetches settings from YAML with sensible defaults.
"""
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
logger = logging.getLogger(__name__)
@dataclass
class PointLight:
"""A single point light in the Matrix world."""
color: str = "#FFFFFF"
intensity: float = 1.0
position: dict[str, float] = field(default_factory=lambda: {"x": 0, "y": 0, "z": 0})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PointLight":
"""Create a PointLight from a dictionary with defaults."""
return cls(
color=data.get("color", "#FFFFFF"),
intensity=data.get("intensity", 1.0),
position=data.get("position", {"x": 0, "y": 0, "z": 0}),
)
def _default_point_lights_factory() -> list[PointLight]:
"""Factory function for default point lights."""
return [
PointLight(
color="#FFAA55", # Warm amber (Workshop)
intensity=1.2,
position={"x": 0, "y": 5, "z": 0},
),
PointLight(
color="#3B82F6", # Cool blue (Matrix)
intensity=0.8,
position={"x": -5, "y": 3, "z": -5},
),
PointLight(
color="#A855F7", # Purple accent
intensity=0.6,
position={"x": 5, "y": 3, "z": 5},
),
]
@dataclass
class LightingConfig:
"""Lighting configuration for the Matrix world."""
ambient_color: str = "#FFAA55" # Warm amber (Workshop warmth)
ambient_intensity: float = 0.5
point_lights: list[PointLight] = field(default_factory=_default_point_lights_factory)
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "LightingConfig":
"""Create a LightingConfig from a dictionary with defaults."""
if data is None:
data = {}
point_lights_data = data.get("point_lights", [])
point_lights = (
[PointLight.from_dict(pl) for pl in point_lights_data]
if point_lights_data
else _default_point_lights_factory()
)
return cls(
ambient_color=data.get("ambient_color", "#FFAA55"),
ambient_intensity=data.get("ambient_intensity", 0.5),
point_lights=point_lights,
)
@dataclass
class EnvironmentConfig:
"""Environment settings for the Matrix world."""
rain_enabled: bool = False
starfield_enabled: bool = True
fog_color: str = "#0f0f23"
fog_density: float = 0.02
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "EnvironmentConfig":
"""Create an EnvironmentConfig from a dictionary with defaults."""
if data is None:
data = {}
return cls(
rain_enabled=data.get("rain_enabled", False),
starfield_enabled=data.get("starfield_enabled", True),
fog_color=data.get("fog_color", "#0f0f23"),
fog_density=data.get("fog_density", 0.02),
)
@dataclass
class FeaturesConfig:
"""Feature toggles for the Matrix world."""
chat_enabled: bool = True
visitor_avatars: bool = True
pip_familiar: bool = True
workshop_portal: bool = True
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "FeaturesConfig":
"""Create a FeaturesConfig from a dictionary with defaults."""
if data is None:
data = {}
return cls(
chat_enabled=data.get("chat_enabled", True),
visitor_avatars=data.get("visitor_avatars", True),
pip_familiar=data.get("pip_familiar", True),
workshop_portal=data.get("workshop_portal", True),
)
@dataclass
class AgentConfig:
"""Configuration for a single Matrix agent."""
name: str = ""
role: str = ""
enabled: bool = True
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "AgentConfig":
"""Create an AgentConfig from a dictionary with defaults."""
return cls(
name=data.get("name", ""),
role=data.get("role", ""),
enabled=data.get("enabled", True),
)
@dataclass
class AgentsConfig:
"""Agent registry configuration."""
default_count: int = 5
max_count: int = 20
agents: list[AgentConfig] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "AgentsConfig":
"""Create an AgentsConfig from a dictionary with defaults."""
if data is None:
data = {}
agents_data = data.get("agents", [])
agents = [AgentConfig.from_dict(a) for a in agents_data] if agents_data else []
return cls(
default_count=data.get("default_count", 5),
max_count=data.get("max_count", 20),
agents=agents,
)
@dataclass
class MatrixConfig:
"""Complete Matrix world configuration.
Combines lighting, environment, features, and agent settings
into a single configuration object.
"""
lighting: LightingConfig = field(default_factory=LightingConfig)
environment: EnvironmentConfig = field(default_factory=EnvironmentConfig)
features: FeaturesConfig = field(default_factory=FeaturesConfig)
agents: AgentsConfig = field(default_factory=AgentsConfig)
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "MatrixConfig":
"""Create a MatrixConfig from a dictionary with defaults for missing sections."""
if data is None:
data = {}
return cls(
lighting=LightingConfig.from_dict(data.get("lighting")),
environment=EnvironmentConfig.from_dict(data.get("environment")),
features=FeaturesConfig.from_dict(data.get("features")),
agents=AgentsConfig.from_dict(data.get("agents")),
)
def to_dict(self) -> dict[str, Any]:
"""Convert the configuration to a plain dictionary."""
return {
"lighting": {
"ambient_color": self.lighting.ambient_color,
"ambient_intensity": self.lighting.ambient_intensity,
"point_lights": [
{
"color": pl.color,
"intensity": pl.intensity,
"position": pl.position,
}
for pl in self.lighting.point_lights
],
},
"environment": {
"rain_enabled": self.environment.rain_enabled,
"starfield_enabled": self.environment.starfield_enabled,
"fog_color": self.environment.fog_color,
"fog_density": self.environment.fog_density,
},
"features": {
"chat_enabled": self.features.chat_enabled,
"visitor_avatars": self.features.visitor_avatars,
"pip_familiar": self.features.pip_familiar,
"workshop_portal": self.features.workshop_portal,
},
"agents": {
"default_count": self.agents.default_count,
"max_count": self.agents.max_count,
"agents": [
{"name": a.name, "role": a.role, "enabled": a.enabled}
for a in self.agents.agents
],
},
}
def load_from_yaml(path: str | Path) -> MatrixConfig:
"""Load Matrix configuration from a YAML file.
Missing keys are filled with sensible defaults. If the file
cannot be read or parsed, returns a fully default configuration.
Args:
path: Path to the YAML configuration file.
Returns:
A MatrixConfig instance with loaded or default values.
"""
path = Path(path)
if not path.exists():
logger.warning("Matrix config file not found: %s, using defaults", path)
return MatrixConfig()
try:
with open(path, encoding="utf-8") as f:
raw_data = yaml.safe_load(f)
if not isinstance(raw_data, dict):
logger.warning("Matrix config invalid format, using defaults")
return MatrixConfig()
return MatrixConfig.from_dict(raw_data)
except yaml.YAMLError as exc:
logger.warning("Matrix config YAML parse error: %s, using defaults", exc)
return MatrixConfig()
except OSError as exc:
logger.warning("Matrix config read error: %s, using defaults", exc)
return MatrixConfig()

View File

@@ -0,0 +1,333 @@
"""Presence state serializer — transforms ADR-023 presence dicts for consumers.
Converts the raw presence schema (version, liveness, mood, energy, etc.)
into the camelCase world-state payload consumed by the Workshop 3D renderer
and WebSocket gateway.
"""
import logging
import time
from datetime import UTC, datetime
logger = logging.getLogger(__name__)
# Default Pip familiar state (used when familiar module unavailable)
DEFAULT_PIP_STATE = {
"name": "Pip",
"mood": "sleepy",
"energy": 0.5,
"color": "0x00b450", # emerald green
"trail_color": "0xdaa520", # gold
}
def _get_familiar_state() -> dict:
"""Get Pip familiar state from familiar module, with graceful fallback.
Returns a dict with name, mood, energy, color, and trail_color.
Falls back to default state if familiar module unavailable or raises.
"""
try:
from timmy.familiar import pip_familiar
snapshot = pip_familiar.snapshot()
# Map PipSnapshot fields to the expected agent_state format
return {
"name": snapshot.name,
"mood": snapshot.state,
"energy": DEFAULT_PIP_STATE["energy"], # Pip doesn't track energy yet
"color": DEFAULT_PIP_STATE["color"],
"trail_color": DEFAULT_PIP_STATE["trail_color"],
}
except Exception as exc:
logger.warning("Familiar state unavailable, using default: %s", exc)
return DEFAULT_PIP_STATE.copy()
# Valid bark styles for Matrix protocol
BARK_STYLES = {"speech", "thought", "whisper", "shout"}
def produce_bark(agent_id: str, text: str, reply_to: str = None, style: str = "speech") -> dict:
"""Format a chat response as a Matrix bark message.
Barks appear as floating text above agents in the Matrix 3D world with
typing animation. This function formats the text for the Matrix protocol.
Parameters
----------
agent_id:
Unique identifier for the agent (e.g. ``"timmy"``).
text:
The chat response text to display as a bark.
reply_to:
Optional message ID or reference this bark is replying to.
style:
Visual style of the bark. One of: "speech" (default), "thought",
"whisper", "shout". Invalid styles fall back to "speech".
Returns
-------
dict
Bark message with keys ``type``, ``agent_id``, ``data`` (containing
``text``, ``reply_to``, ``style``), and ``ts``.
Examples
--------
>>> produce_bark("timmy", "Hello world!")
{
"type": "bark",
"agent_id": "timmy",
"data": {"text": "Hello world!", "reply_to": None, "style": "speech"},
"ts": 1742529600,
}
"""
# Validate and normalize style
if style not in BARK_STYLES:
style = "speech"
# Truncate text to 280 characters (bark, not essay)
truncated_text = text[:280] if text else ""
return {
"type": "bark",
"agent_id": agent_id,
"data": {
"text": truncated_text,
"reply_to": reply_to,
"style": style,
},
"ts": int(time.time()),
}
def produce_thought(
agent_id: str, thought_text: str, thought_id: int, chain_id: str = None
) -> dict:
"""Format a thinking engine thought as a Matrix thought message.
Thoughts appear as subtle floating text in the 3D world, streaming from
Timmy's thinking engine (/thinking/api). This function wraps thoughts in
Matrix protocol format.
Parameters
----------
agent_id:
Unique identifier for the agent (e.g. ``"timmy"``).
thought_text:
The thought text to display. Truncated to 500 characters.
thought_id:
Unique identifier for this thought (sequence number).
chain_id:
Optional chain identifier grouping related thoughts.
Returns
-------
dict
Thought message with keys ``type``, ``agent_id``, ``data`` (containing
``text``, ``thought_id``, ``chain_id``), and ``ts``.
Examples
--------
>>> produce_thought("timmy", "Considering the options...", 42, "chain-123")
{
"type": "thought",
"agent_id": "timmy",
"data": {"text": "Considering the options...", "thought_id": 42, "chain_id": "chain-123"},
"ts": 1742529600,
}
"""
# Truncate text to 500 characters (thoughts can be longer than barks)
truncated_text = thought_text[:500] if thought_text else ""
return {
"type": "thought",
"agent_id": agent_id,
"data": {
"text": truncated_text,
"thought_id": thought_id,
"chain_id": chain_id,
},
"ts": int(time.time()),
}
def serialize_presence(presence: dict) -> dict:
"""Transform an ADR-023 presence dict into the world-state API shape.
Parameters
----------
presence:
Raw presence dict as written by
:func:`~timmy.workshop_state.get_state_dict` or read from
``~/.timmy/presence.json``.
Returns
-------
dict
CamelCase world-state payload with ``timmyState``, ``familiar``,
``activeThreads``, ``recentEvents``, ``concerns``, ``visitorPresent``,
``updatedAt``, and ``version`` keys.
"""
return {
"timmyState": {
"mood": presence.get("mood", "calm"),
"activity": presence.get("current_focus", "idle"),
"energy": presence.get("energy", 0.5),
"confidence": presence.get("confidence", 0.7),
},
"familiar": presence.get("familiar"),
"activeThreads": presence.get("active_threads", []),
"recentEvents": presence.get("recent_events", []),
"concerns": presence.get("concerns", []),
"visitorPresent": False,
"updatedAt": presence.get("liveness", datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")),
"version": presence.get("version", 1),
}
# ---------------------------------------------------------------------------
# Status mapping: ADR-023 current_focus → Matrix agent status
# ---------------------------------------------------------------------------
_STATUS_KEYWORDS: dict[str, str] = {
"thinking": "thinking",
"speaking": "speaking",
"talking": "speaking",
"idle": "idle",
}
def _derive_status(current_focus: str) -> str:
"""Map a free-text current_focus value to a Matrix status enum.
Returns one of: online, idle, thinking, speaking.
"""
focus_lower = current_focus.lower()
for keyword, status in _STATUS_KEYWORDS.items():
if keyword in focus_lower:
return status
if current_focus and current_focus != "idle":
return "online"
return "idle"
def produce_agent_state(agent_id: str, presence: dict) -> dict:
"""Build a Matrix-compatible ``agent_state`` message from presence data.
Parameters
----------
agent_id:
Unique identifier for the agent (e.g. ``"timmy"``).
presence:
Raw ADR-023 presence dict.
Returns
-------
dict
Message with keys ``type``, ``agent_id``, ``data``, and ``ts``.
"""
return {
"type": "agent_state",
"agent_id": agent_id,
"data": {
"display_name": presence.get("display_name", agent_id.title()),
"role": presence.get("role", "assistant"),
"status": _derive_status(presence.get("current_focus", "idle")),
"mood": presence.get("mood", "calm"),
"energy": presence.get("energy", 0.5),
"bark": presence.get("bark", ""),
"familiar": _get_familiar_state(),
},
"ts": int(time.time()),
}
def produce_system_status() -> dict:
"""Generate a system_status message for the Matrix.
Returns a dict with system health metrics including agent count,
visitor count, uptime, thinking engine status, and memory count.
Returns
-------
dict
Message with keys ``type``, ``data`` (containing ``agents_online``,
``visitors``, ``uptime_seconds``, ``thinking_active``, ``memory_count``),
and ``ts``.
Examples
--------
>>> produce_system_status()
{
"type": "system_status",
"data": {
"agents_online": 5,
"visitors": 2,
"uptime_seconds": 3600,
"thinking_active": True,
"memory_count": 150,
},
"ts": 1742529600,
}
"""
# Count agents with status != offline
agents_online = 0
try:
from timmy.agents.loader import list_agents
agents = list_agents()
agents_online = sum(1 for a in agents if a.get("status", "") not in ("offline", ""))
except Exception as exc:
logger.debug("Failed to count agents: %s", exc)
# Count visitors from WebSocket clients
visitors = 0
try:
from dashboard.routes.world import _ws_clients
visitors = len(_ws_clients)
except Exception as exc:
logger.debug("Failed to count visitors: %s", exc)
# Calculate uptime
uptime_seconds = 0
try:
from datetime import UTC
from config import APP_START_TIME
uptime_seconds = int((datetime.now(UTC) - APP_START_TIME).total_seconds())
except Exception as exc:
logger.debug("Failed to calculate uptime: %s", exc)
# Check thinking engine status
thinking_active = False
try:
from config import settings
from timmy.thinking import thinking_engine
thinking_active = settings.thinking_enabled and thinking_engine is not None
except Exception as exc:
logger.debug("Failed to check thinking status: %s", exc)
# Count memories in vector store
memory_count = 0
try:
from timmy.memory_system import get_memory_stats
stats = get_memory_stats()
memory_count = stats.get("total_entries", 0)
except Exception as exc:
logger.debug("Failed to count memories: %s", exc)
return {
"type": "system_status",
"data": {
"agents_online": agents_online,
"visitors": visitors,
"uptime_seconds": uptime_seconds,
"thinking_active": thinking_active,
"memory_count": memory_count,
},
"ts": int(time.time()),
}

View File

@@ -0,0 +1,261 @@
"""Shared WebSocket message protocol for the Matrix frontend.
Defines all WebSocket message types as an enum and typed dataclasses
with ``to_json()`` / ``from_json()`` helpers so every producer and the
gateway speak the same language.
Message wire format
-------------------
.. code-block:: json
{"type": "agent_state", "agent_id": "timmy", "data": {...}, "ts": 1234567890}
"""
import json
import logging
import time
from dataclasses import asdict, dataclass, field
from enum import StrEnum
from typing import Any
logger = logging.getLogger(__name__)
class MessageType(StrEnum):
"""All WebSocket message types defined by the Matrix PROTOCOL.md."""
AGENT_STATE = "agent_state"
VISITOR_STATE = "visitor_state"
BARK = "bark"
THOUGHT = "thought"
SYSTEM_STATUS = "system_status"
CONNECTION_ACK = "connection_ack"
ERROR = "error"
TASK_UPDATE = "task_update"
MEMORY_FLASH = "memory_flash"
# ---------------------------------------------------------------------------
# Base message
# ---------------------------------------------------------------------------
@dataclass
class WSMessage:
"""Base WebSocket message with common envelope fields."""
type: str
ts: float = field(default_factory=time.time)
def to_json(self) -> str:
"""Serialise the message to a JSON string."""
return json.dumps(asdict(self))
@classmethod
def from_json(cls, raw: str) -> "WSMessage":
"""Deserialise a JSON string into the correct message subclass.
Falls back to the base ``WSMessage`` when the ``type`` field is
unrecognised.
"""
data = json.loads(raw)
msg_type = data.get("type")
sub = _REGISTRY.get(msg_type)
if sub is not None:
return sub.from_json(raw)
return cls(**data)
# ---------------------------------------------------------------------------
# Concrete message types
# ---------------------------------------------------------------------------
@dataclass
class AgentStateMessage(WSMessage):
"""State update for a single agent."""
type: str = field(default=MessageType.AGENT_STATE)
agent_id: str = ""
data: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_json(cls, raw: str) -> "AgentStateMessage":
payload = json.loads(raw)
return cls(
type=payload.get("type", MessageType.AGENT_STATE),
ts=payload.get("ts", time.time()),
agent_id=payload.get("agent_id", ""),
data=payload.get("data", {}),
)
@dataclass
class VisitorStateMessage(WSMessage):
"""State update for a visitor / user session."""
type: str = field(default=MessageType.VISITOR_STATE)
visitor_id: str = ""
data: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_json(cls, raw: str) -> "VisitorStateMessage":
payload = json.loads(raw)
return cls(
type=payload.get("type", MessageType.VISITOR_STATE),
ts=payload.get("ts", time.time()),
visitor_id=payload.get("visitor_id", ""),
data=payload.get("data", {}),
)
@dataclass
class BarkMessage(WSMessage):
"""A bark (chat-like utterance) from an agent."""
type: str = field(default=MessageType.BARK)
agent_id: str = ""
content: str = ""
@classmethod
def from_json(cls, raw: str) -> "BarkMessage":
payload = json.loads(raw)
return cls(
type=payload.get("type", MessageType.BARK),
ts=payload.get("ts", time.time()),
agent_id=payload.get("agent_id", ""),
content=payload.get("content", ""),
)
@dataclass
class ThoughtMessage(WSMessage):
"""An inner thought from an agent."""
type: str = field(default=MessageType.THOUGHT)
agent_id: str = ""
content: str = ""
@classmethod
def from_json(cls, raw: str) -> "ThoughtMessage":
payload = json.loads(raw)
return cls(
type=payload.get("type", MessageType.THOUGHT),
ts=payload.get("ts", time.time()),
agent_id=payload.get("agent_id", ""),
content=payload.get("content", ""),
)
@dataclass
class SystemStatusMessage(WSMessage):
"""System-wide status broadcast."""
type: str = field(default=MessageType.SYSTEM_STATUS)
status: str = ""
data: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_json(cls, raw: str) -> "SystemStatusMessage":
payload = json.loads(raw)
return cls(
type=payload.get("type", MessageType.SYSTEM_STATUS),
ts=payload.get("ts", time.time()),
status=payload.get("status", ""),
data=payload.get("data", {}),
)
@dataclass
class ConnectionAckMessage(WSMessage):
"""Acknowledgement sent when a client connects."""
type: str = field(default=MessageType.CONNECTION_ACK)
client_id: str = ""
@classmethod
def from_json(cls, raw: str) -> "ConnectionAckMessage":
payload = json.loads(raw)
return cls(
type=payload.get("type", MessageType.CONNECTION_ACK),
ts=payload.get("ts", time.time()),
client_id=payload.get("client_id", ""),
)
@dataclass
class ErrorMessage(WSMessage):
"""Error message sent to a client."""
type: str = field(default=MessageType.ERROR)
code: str = ""
message: str = ""
@classmethod
def from_json(cls, raw: str) -> "ErrorMessage":
payload = json.loads(raw)
return cls(
type=payload.get("type", MessageType.ERROR),
ts=payload.get("ts", time.time()),
code=payload.get("code", ""),
message=payload.get("message", ""),
)
@dataclass
class TaskUpdateMessage(WSMessage):
"""Update about a task (created, assigned, completed, etc.)."""
type: str = field(default=MessageType.TASK_UPDATE)
task_id: str = ""
status: str = ""
data: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_json(cls, raw: str) -> "TaskUpdateMessage":
payload = json.loads(raw)
return cls(
type=payload.get("type", MessageType.TASK_UPDATE),
ts=payload.get("ts", time.time()),
task_id=payload.get("task_id", ""),
status=payload.get("status", ""),
data=payload.get("data", {}),
)
@dataclass
class MemoryFlashMessage(WSMessage):
"""A flash of memory — a recalled or stored memory event."""
type: str = field(default=MessageType.MEMORY_FLASH)
agent_id: str = ""
memory_key: str = ""
content: str = ""
@classmethod
def from_json(cls, raw: str) -> "MemoryFlashMessage":
payload = json.loads(raw)
return cls(
type=payload.get("type", MessageType.MEMORY_FLASH),
ts=payload.get("ts", time.time()),
agent_id=payload.get("agent_id", ""),
memory_key=payload.get("memory_key", ""),
content=payload.get("content", ""),
)
# ---------------------------------------------------------------------------
# Registry for from_json dispatch
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, type[WSMessage]] = {
MessageType.AGENT_STATE: AgentStateMessage,
MessageType.VISITOR_STATE: VisitorStateMessage,
MessageType.BARK: BarkMessage,
MessageType.THOUGHT: ThoughtMessage,
MessageType.SYSTEM_STATUS: SystemStatusMessage,
MessageType.CONNECTION_ACK: ConnectionAckMessage,
MessageType.ERROR: ErrorMessage,
MessageType.TASK_UPDATE: TaskUpdateMessage,
MessageType.MEMORY_FLASH: MemoryFlashMessage,
}

View File

@@ -32,6 +32,15 @@ except ImportError:
logger = logging.getLogger(__name__)
# Quota monitor — optional, degrades gracefully if unavailable
try:
from infrastructure.claude_quota import QuotaMonitor, get_quota_monitor
_quota_monitor: "QuotaMonitor | None" = get_quota_monitor()
except Exception as _exc: # pragma: no cover
logger.debug("Quota monitor not available: %s", _exc)
_quota_monitor = None
class ProviderStatus(Enum):
"""Health status of a provider."""
@@ -301,6 +310,22 @@ class CascadeRouter:
logger.debug("Ollama provider check error: %s", exc)
return False
elif provider.type == "vllm_mlx":
# Check if local vllm-mlx server is running (OpenAI-compatible)
if requests is None:
return True
try:
base_url = provider.base_url or provider.url or "http://localhost:8000"
# Strip /v1 suffix — health endpoint is at the root
server_root = base_url.rstrip("/")
if server_root.endswith("/v1"):
server_root = server_root[:-3]
response = requests.get(f"{server_root}/health", timeout=5)
return response.status_code == 200
except Exception as exc:
logger.debug("vllm-mlx provider check error: %s", exc)
return False
elif provider.type in ("openai", "anthropic", "grok"):
# Check if API key is set
return provider.api_key is not None and provider.api_key != ""
@@ -457,6 +482,25 @@ class CascadeRouter:
raise RuntimeError("; ".join(errors))
def _quota_allows_cloud(self, provider: Provider) -> bool:
"""Check quota before routing to a cloud provider.
Uses the metabolic protocol: cloud calls are gated by 5-hour quota.
Returns True (allow cloud) if quota monitor is unavailable or returns None.
"""
if _quota_monitor is None:
return True
try:
# Map provider type to task_value heuristic
task_value = "high" # conservative default
status = _quota_monitor.check()
if status is None:
return True # No credentials — caller decides based on config
return _quota_monitor.should_use_cloud(task_value)
except Exception as exc:
logger.warning("Quota check failed, allowing cloud: %s", exc)
return True
def _is_provider_available(self, provider: Provider) -> bool:
"""Check if a provider should be tried (enabled + circuit breaker)."""
if not provider.enabled:
@@ -510,6 +554,15 @@ class CascadeRouter:
if not self._is_provider_available(provider):
continue
# Metabolic protocol: skip cloud providers when quota is low
if provider.type in ("anthropic", "openai", "grok"):
if not self._quota_allows_cloud(provider):
logger.info(
"Metabolic protocol: skipping cloud provider %s (quota too low)",
provider.name,
)
continue
selected_model, is_fallback_model = self._select_model(provider, model, content_type)
try:
@@ -582,6 +635,14 @@ class CascadeRouter:
temperature=temperature,
max_tokens=max_tokens,
)
elif provider.type == "vllm_mlx":
result = await self._call_vllm_mlx(
provider=provider,
messages=messages,
model=model or provider.get_default_model(),
temperature=temperature,
max_tokens=max_tokens,
)
else:
raise ValueError(f"Unknown provider type: {provider.type}")
@@ -778,6 +839,48 @@ class CascadeRouter:
"model": response.model,
}
async def _call_vllm_mlx(
self,
provider: Provider,
messages: list[dict],
model: str,
temperature: float,
max_tokens: int | None,
) -> dict:
"""Call vllm-mlx via its OpenAI-compatible API.
vllm-mlx exposes the same /v1/chat/completions endpoint as OpenAI,
so we reuse the OpenAI client pointed at the local server.
No API key is required for local deployments.
"""
import openai
base_url = provider.base_url or provider.url or "http://localhost:8000"
# Ensure the base_url ends with /v1 as expected by the OpenAI client
if not base_url.rstrip("/").endswith("/v1"):
base_url = base_url.rstrip("/") + "/v1"
client = openai.AsyncOpenAI(
api_key=provider.api_key or "no-key-required",
base_url=base_url,
timeout=self.config.timeout_seconds,
)
kwargs: dict = {
"model": model,
"messages": messages,
"temperature": temperature,
}
if max_tokens:
kwargs["max_tokens"] = max_tokens
response = await client.chat.completions.create(**kwargs)
return {
"content": response.choices[0].message.content,
"model": response.model,
}
def _record_success(self, provider: Provider, latency_ms: float) -> None:
"""Record a successful request."""
provider.metrics.total_requests += 1

View File

@@ -0,0 +1,306 @@
"""Sovereignty metrics collector and store.
Tracks research sovereignty progress: cache hit rate, API cost,
time-to-report, and human involvement. Persists to SQLite for
trend analysis and dashboard display.
Refs: #981
"""
import json
import logging
import sqlite3
from contextlib import closing
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from config import settings
logger = logging.getLogger(__name__)
DB_PATH = Path(settings.repo_root) / "data" / "sovereignty_metrics.db"
_SCHEMA = """
CREATE TABLE IF NOT EXISTS sovereignty_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
metric_type TEXT NOT NULL,
value REAL NOT NULL,
metadata TEXT DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_sm_type ON sovereignty_metrics(metric_type);
CREATE INDEX IF NOT EXISTS idx_sm_ts ON sovereignty_metrics(timestamp);
CREATE TABLE IF NOT EXISTS sovereignty_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
alert_type TEXT NOT NULL,
message TEXT NOT NULL,
value REAL NOT NULL,
threshold REAL NOT NULL,
acknowledged INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sa_ts ON sovereignty_alerts(timestamp);
CREATE INDEX IF NOT EXISTS idx_sa_ack ON sovereignty_alerts(acknowledged);
"""
@dataclass
class SovereigntyMetric:
"""A single sovereignty metric data point."""
metric_type: str # cache_hit_rate, api_cost, time_to_report, human_involvement
value: float
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class SovereigntyAlert:
"""An alert triggered when a metric exceeds a threshold."""
alert_type: str
message: str
value: float
threshold: float
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
acknowledged: bool = False
# Graduation targets from issue #981
GRADUATION_TARGETS = {
"cache_hit_rate": {"week1": 0.10, "month1": 0.40, "month3": 0.80, "graduation": 0.90},
"api_cost": {"week1": 1.50, "month1": 0.50, "month3": 0.10, "graduation": 0.01},
"time_to_report": {"week1": 180.0, "month1": 30.0, "month3": 5.0, "graduation": 1.0},
"human_involvement": {"week1": 1.0, "month1": 0.5, "month3": 0.25, "graduation": 0.0},
"local_artifacts": {"week1": 6, "month1": 30, "month3": 100, "graduation": 500},
}
class SovereigntyMetricsStore:
"""SQLite-backed sovereignty metrics store.
Thread-safe: creates a new connection per operation.
"""
def __init__(self, db_path: Path | None = None) -> None:
self._db_path = db_path or DB_PATH
self._init_db()
def _init_db(self) -> None:
"""Initialize the database schema."""
try:
self._db_path.parent.mkdir(parents=True, exist_ok=True)
with closing(sqlite3.connect(str(self._db_path))) as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(f"PRAGMA busy_timeout={settings.db_busy_timeout_ms}")
conn.executescript(_SCHEMA)
conn.commit()
except Exception as exc:
logger.warning("Failed to initialize sovereignty metrics DB: %s", exc)
def _connect(self) -> sqlite3.Connection:
"""Get a new connection."""
conn = sqlite3.connect(str(self._db_path))
conn.row_factory = sqlite3.Row
conn.execute(f"PRAGMA busy_timeout={settings.db_busy_timeout_ms}")
return conn
def record(self, metric: SovereigntyMetric) -> None:
"""Record a sovereignty metric data point."""
try:
with closing(self._connect()) as conn:
conn.execute(
"INSERT INTO sovereignty_metrics (timestamp, metric_type, value, metadata) "
"VALUES (?, ?, ?, ?)",
(
metric.timestamp,
metric.metric_type,
metric.value,
json.dumps(metric.metadata),
),
)
conn.commit()
except Exception as exc:
logger.warning("Failed to record sovereignty metric: %s", exc)
# Check thresholds for alerts
self._check_alert(metric)
def _check_alert(self, metric: SovereigntyMetric) -> None:
"""Check if a metric triggers an alert."""
threshold = settings.sovereignty_api_cost_alert_threshold
if metric.metric_type == "api_cost" and metric.value > threshold:
alert = SovereigntyAlert(
alert_type="api_cost_exceeded",
message=f"API cost ${metric.value:.2f} exceeds threshold ${threshold:.2f}",
value=metric.value,
threshold=threshold,
)
self._record_alert(alert)
def _record_alert(self, alert: SovereigntyAlert) -> None:
"""Persist an alert."""
try:
with closing(self._connect()) as conn:
conn.execute(
"INSERT INTO sovereignty_alerts "
"(timestamp, alert_type, message, value, threshold) "
"VALUES (?, ?, ?, ?, ?)",
(
alert.timestamp,
alert.alert_type,
alert.message,
alert.value,
alert.threshold,
),
)
conn.commit()
logger.warning("Sovereignty alert: %s", alert.message)
except Exception as exc:
logger.warning("Failed to record sovereignty alert: %s", exc)
def get_latest(self, metric_type: str, limit: int = 50) -> list[dict]:
"""Get the most recent metric values for a given type."""
try:
with closing(self._connect()) as conn:
rows = conn.execute(
"SELECT timestamp, value, metadata FROM sovereignty_metrics "
"WHERE metric_type = ? ORDER BY timestamp DESC LIMIT ?",
(metric_type, limit),
).fetchall()
return [
{
"timestamp": row["timestamp"],
"value": row["value"],
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
}
for row in rows
]
except Exception as exc:
logger.warning("Failed to query sovereignty metrics: %s", exc)
return []
def get_summary(self) -> dict[str, Any]:
"""Get a summary of current sovereignty metrics progress."""
summary: dict[str, Any] = {}
for metric_type in GRADUATION_TARGETS:
latest = self.get_latest(metric_type, limit=1)
history = self.get_latest(metric_type, limit=30)
current_value = latest[0]["value"] if latest else None
targets = GRADUATION_TARGETS[metric_type]
# Determine current phase based on value
phase = "pre-start"
if current_value is not None:
if metric_type in ("api_cost", "time_to_report", "human_involvement"):
# Lower is better
if current_value <= targets["graduation"]:
phase = "graduated"
elif current_value <= targets["month3"]:
phase = "month3"
elif current_value <= targets["month1"]:
phase = "month1"
elif current_value <= targets["week1"]:
phase = "week1"
else:
phase = "pre-start"
else:
# Higher is better
if current_value >= targets["graduation"]:
phase = "graduated"
elif current_value >= targets["month3"]:
phase = "month3"
elif current_value >= targets["month1"]:
phase = "month1"
elif current_value >= targets["week1"]:
phase = "week1"
else:
phase = "pre-start"
summary[metric_type] = {
"current": current_value,
"phase": phase,
"targets": targets,
"trend": [{"t": h["timestamp"], "v": h["value"]} for h in reversed(history)],
}
return summary
def get_alerts(self, unacknowledged_only: bool = True, limit: int = 20) -> list[dict]:
"""Get sovereignty alerts."""
try:
with closing(self._connect()) as conn:
if unacknowledged_only:
rows = conn.execute(
"SELECT * FROM sovereignty_alerts "
"WHERE acknowledged = 0 ORDER BY timestamp DESC LIMIT ?",
(limit,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM sovereignty_alerts ORDER BY timestamp DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in rows]
except Exception as exc:
logger.warning("Failed to query sovereignty alerts: %s", exc)
return []
def acknowledge_alert(self, alert_id: int) -> bool:
"""Acknowledge an alert."""
try:
with closing(self._connect()) as conn:
conn.execute(
"UPDATE sovereignty_alerts SET acknowledged = 1 WHERE id = ?",
(alert_id,),
)
conn.commit()
return True
except Exception as exc:
logger.warning("Failed to acknowledge alert: %s", exc)
return False
# ── Module-level singleton ─────────────────────────────────────────────────
_store: SovereigntyMetricsStore | None = None
def get_sovereignty_store() -> SovereigntyMetricsStore:
"""Return the module-level store, creating it on first access."""
global _store
if _store is None:
_store = SovereigntyMetricsStore()
return _store
async def emit_sovereignty_metric(
metric_type: str,
value: float,
metadata: dict[str, Any] | None = None,
) -> None:
"""Convenience function to record a sovereignty metric and emit an event.
Also publishes to the event bus for real-time subscribers.
"""
import asyncio
from infrastructure.events.bus import emit
metric = SovereigntyMetric(
metric_type=metric_type,
value=value,
metadata=metadata or {},
)
# Record to SQLite in thread to avoid blocking event loop
await asyncio.to_thread(get_sovereignty_store().record, metric)
# Publish to event bus for real-time consumers
await emit(
f"sovereignty.metric.{metric_type}",
source="sovereignty_metrics",
data={"metric_type": metric_type, "value": value, **(metadata or {})},
)

View File

@@ -0,0 +1,166 @@
"""Visitor state tracking for the Matrix frontend.
Tracks active visitors as they connect and move around the 3D world,
and provides serialization for Matrix protocol broadcast messages.
"""
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
@dataclass
class VisitorState:
"""State for a single visitor in the Matrix.
Attributes
----------
visitor_id: Unique identifier for the visitor (client ID).
display_name: Human-readable name shown above the visitor.
position: 3D coordinates (x, y, z) in the world.
rotation: Rotation angle in degrees (0-360).
connected_at: ISO timestamp when the visitor connected.
"""
visitor_id: str
display_name: str = ""
position: dict[str, float] = field(default_factory=lambda: {"x": 0.0, "y": 0.0, "z": 0.0})
rotation: float = 0.0
connected_at: str = field(
default_factory=lambda: datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
)
def __post_init__(self):
"""Set display_name to visitor_id if not provided; copy position dict."""
if not self.display_name:
self.display_name = self.visitor_id
# Copy position to avoid shared mutable state
self.position = dict(self.position)
class VisitorRegistry:
"""Registry of active visitors in the Matrix.
Thread-safe singleton pattern (Python GIL protects dict operations).
Used by the WebSocket layer to track and broadcast visitor positions.
"""
_instance: "VisitorRegistry | None" = None
def __new__(cls) -> "VisitorRegistry":
"""Singleton constructor."""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._visitors: dict[str, VisitorState] = {}
return cls._instance
def add(
self, visitor_id: str, display_name: str = "", position: dict | None = None
) -> VisitorState:
"""Add a new visitor to the registry.
Parameters
----------
visitor_id: Unique identifier for the visitor.
display_name: Optional display name (defaults to visitor_id).
position: Optional initial position (defaults to origin).
Returns
-------
The newly created VisitorState.
"""
visitor = VisitorState(
visitor_id=visitor_id,
display_name=display_name,
position=position if position else {"x": 0.0, "y": 0.0, "z": 0.0},
)
self._visitors[visitor_id] = visitor
return visitor
def remove(self, visitor_id: str) -> bool:
"""Remove a visitor from the registry.
Parameters
----------
visitor_id: The visitor to remove.
Returns
-------
True if the visitor was found and removed, False otherwise.
"""
if visitor_id in self._visitors:
del self._visitors[visitor_id]
return True
return False
def update_position(
self,
visitor_id: str,
position: dict[str, float],
rotation: float | None = None,
) -> bool:
"""Update a visitor's position and rotation.
Parameters
----------
visitor_id: The visitor to update.
position: New 3D coordinates (x, y, z).
rotation: Optional new rotation angle.
Returns
-------
True if the visitor was found and updated, False otherwise.
"""
if visitor_id not in self._visitors:
return False
self._visitors[visitor_id].position = position
if rotation is not None:
self._visitors[visitor_id].rotation = rotation
return True
def get(self, visitor_id: str) -> VisitorState | None:
"""Get a single visitor's state.
Parameters
----------
visitor_id: The visitor to retrieve.
Returns
-------
The VisitorState if found, None otherwise.
"""
return self._visitors.get(visitor_id)
def get_all(self) -> list[dict]:
"""Get all active visitors as Matrix protocol message dicts.
Returns
-------
List of visitor_state dicts ready for WebSocket broadcast.
Each dict has: type, visitor_id, data (with display_name,
position, rotation, connected_at), and ts.
"""
now = int(time.time())
return [
{
"type": "visitor_state",
"visitor_id": v.visitor_id,
"data": {
"display_name": v.display_name,
"position": v.position,
"rotation": v.rotation,
"connected_at": v.connected_at,
},
"ts": now,
}
for v in self._visitors.values()
]
def clear(self) -> None:
"""Remove all visitors (useful for testing)."""
self._visitors.clear()
def __len__(self) -> int:
"""Return the number of active visitors."""
return len(self._visitors)

View File

@@ -0,0 +1,29 @@
"""World interface — engine-agnostic adapter pattern for embodied agents.
Provides the ``WorldInterface`` ABC and an adapter registry so Timmy can
observe, act, and speak in any game world (Morrowind, Luanti, Godot, …)
through a single contract.
Quick start::
from infrastructure.world import get_adapter, register_adapter
from infrastructure.world.interface import WorldInterface
register_adapter("mock", MockWorldAdapter)
world = get_adapter("mock")
perception = world.observe()
"""
from infrastructure.world.registry import AdapterRegistry
_registry = AdapterRegistry()
register_adapter = _registry.register
get_adapter = _registry.get
list_adapters = _registry.list_adapters
__all__ = [
"register_adapter",
"get_adapter",
"list_adapters",
]

View File

@@ -0,0 +1 @@
"""Built-in world adapters."""

View File

@@ -0,0 +1,99 @@
"""Mock world adapter — returns canned perception and logs commands.
Useful for testing the heartbeat loop and WorldInterface contract
without a running game server.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from infrastructure.world.interface import WorldInterface
from infrastructure.world.types import (
ActionResult,
ActionStatus,
CommandInput,
PerceptionOutput,
)
logger = logging.getLogger(__name__)
@dataclass
class _ActionLog:
"""Record of an action dispatched to the mock world."""
command: CommandInput
timestamp: datetime
class MockWorldAdapter(WorldInterface):
"""In-memory mock adapter for testing.
* ``observe()`` returns configurable canned perception.
* ``act()`` logs the command and returns success.
* ``speak()`` logs the message.
Inspect ``action_log`` and ``speech_log`` to verify behaviour in tests.
"""
def __init__(
self,
*,
location: str = "Test Chamber",
entities: list[str] | None = None,
events: list[str] | None = None,
) -> None:
self._location = location
self._entities = entities or ["TestNPC"]
self._events = events or []
self._connected = False
self.action_log: list[_ActionLog] = []
self.speech_log: list[dict] = []
# -- lifecycle ---------------------------------------------------------
def connect(self) -> None:
self._connected = True
logger.info("MockWorldAdapter connected")
def disconnect(self) -> None:
self._connected = False
logger.info("MockWorldAdapter disconnected")
@property
def is_connected(self) -> bool:
return self._connected
# -- core contract -----------------------------------------------------
def observe(self) -> PerceptionOutput:
logger.debug("MockWorldAdapter.observe()")
return PerceptionOutput(
timestamp=datetime.now(UTC),
location=self._location,
entities=list(self._entities),
events=list(self._events),
raw={"adapter": "mock"},
)
def act(self, command: CommandInput) -> ActionResult:
logger.debug("MockWorldAdapter.act(%s)", command.action)
self.action_log.append(_ActionLog(command=command, timestamp=datetime.now(UTC)))
return ActionResult(
status=ActionStatus.SUCCESS,
message=f"Mock executed: {command.action}",
data={"adapter": "mock"},
)
def speak(self, message: str, target: str | None = None) -> None:
logger.debug("MockWorldAdapter.speak(%r, target=%r)", message, target)
self.speech_log.append(
{
"message": message,
"target": target,
"timestamp": datetime.now(UTC).isoformat(),
}
)

View File

@@ -0,0 +1,58 @@
"""TES3MP world adapter — stub for Morrowind multiplayer via TES3MP.
This adapter will eventually connect to a TES3MP server and translate
the WorldInterface contract into TES3MP commands. For now every method
raises ``NotImplementedError`` with guidance on what needs wiring up.
Once PR #864 merges, import PerceptionOutput and CommandInput directly
from ``infrastructure.morrowind.schemas`` if their shapes differ from
the canonical types in ``infrastructure.world.types``.
"""
from __future__ import annotations
import logging
from infrastructure.world.interface import WorldInterface
from infrastructure.world.types import ActionResult, CommandInput, PerceptionOutput
logger = logging.getLogger(__name__)
class TES3MPWorldAdapter(WorldInterface):
"""Stub adapter for TES3MP (Morrowind multiplayer).
All core methods raise ``NotImplementedError``.
Implement ``connect()`` first — it should open a socket to the
TES3MP server and authenticate.
"""
def __init__(self, *, host: str = "localhost", port: int = 25565) -> None:
self._host = host
self._port = port
self._connected = False
# -- lifecycle ---------------------------------------------------------
def connect(self) -> None:
raise NotImplementedError("TES3MPWorldAdapter.connect() — wire up TES3MP server socket")
def disconnect(self) -> None:
raise NotImplementedError("TES3MPWorldAdapter.disconnect() — close TES3MP server socket")
@property
def is_connected(self) -> bool:
return self._connected
# -- core contract (stubs) ---------------------------------------------
def observe(self) -> PerceptionOutput:
raise NotImplementedError("TES3MPWorldAdapter.observe() — poll TES3MP for player/NPC state")
def act(self, command: CommandInput) -> ActionResult:
raise NotImplementedError(
"TES3MPWorldAdapter.act() — translate CommandInput to TES3MP packet"
)
def speak(self, message: str, target: str | None = None) -> None:
raise NotImplementedError("TES3MPWorldAdapter.speak() — send chat message via TES3MP")

View File

@@ -0,0 +1,17 @@
"""Performance regression suite for Morrowind agent scenarios.
Provides standardised benchmark scenarios, a runner that executes them
through the heartbeat loop with a mock (or live) world adapter, and
metrics collection for CI-integrated regression detection.
"""
from infrastructure.world.benchmark.metrics import BenchmarkMetrics
from infrastructure.world.benchmark.runner import BenchmarkRunner
from infrastructure.world.benchmark.scenarios import BenchmarkScenario, load_scenarios
__all__ = [
"BenchmarkMetrics",
"BenchmarkRunner",
"BenchmarkScenario",
"load_scenarios",
]

View File

@@ -0,0 +1,195 @@
"""Benchmark metrics collection and persistence.
Tracks per-scenario results: cycles used, wall-clock time, success,
LLM call count, and estimated metabolic cost. Results are persisted
as JSONL for trend analysis and CI regression gates.
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass, field
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass
class ScenarioResult:
"""Outcome of running a single benchmark scenario.
Attributes:
scenario_name: Human-readable scenario name.
success: Whether the goal predicate was satisfied.
cycles_used: Number of heartbeat cycles executed.
max_cycles: The scenario's cycle budget.
wall_time_ms: Total wall-clock time in milliseconds.
llm_calls: Number of LLM inference calls made.
metabolic_cost: Estimated resource cost (arbitrary unit, ≈ tokens).
error: Error message if the run crashed.
tags: Scenario tags (copied for filtering).
"""
scenario_name: str
success: bool = False
cycles_used: int = 0
max_cycles: int = 0
wall_time_ms: int = 0
llm_calls: int = 0
metabolic_cost: float = 0.0
error: str | None = None
tags: list[str] = field(default_factory=list)
@dataclass
class BenchmarkMetrics:
"""Aggregated metrics across all scenarios in a benchmark run.
Attributes:
results: Per-scenario results.
total_time_ms: Total wall-clock time for the full suite.
timestamp: ISO-8601 timestamp of the run.
commit_sha: Git commit SHA (if available).
"""
results: list[ScenarioResult] = field(default_factory=list)
total_time_ms: int = 0
timestamp: str = ""
commit_sha: str = ""
# -- derived properties ------------------------------------------------
@property
def pass_count(self) -> int:
return sum(1 for r in self.results if r.success)
@property
def fail_count(self) -> int:
return sum(1 for r in self.results if not r.success)
@property
def success_rate(self) -> float:
if not self.results:
return 0.0
return self.pass_count / len(self.results)
@property
def total_llm_calls(self) -> int:
return sum(r.llm_calls for r in self.results)
@property
def total_metabolic_cost(self) -> float:
return sum(r.metabolic_cost for r in self.results)
# -- persistence -------------------------------------------------------
def save(self, path: Path) -> None:
"""Append this run's results to a JSONL file at *path*."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
record = {
"timestamp": self.timestamp,
"commit_sha": self.commit_sha,
"total_time_ms": self.total_time_ms,
"success_rate": round(self.success_rate, 4),
"total_llm_calls": self.total_llm_calls,
"total_metabolic_cost": round(self.total_metabolic_cost, 2),
"scenarios": [asdict(r) for r in self.results],
}
with path.open("a") as f:
f.write(json.dumps(record) + "\n")
logger.info("Benchmark results saved to %s", path)
# -- summary -----------------------------------------------------------
def summary(self) -> str:
"""Return a human-readable summary of the benchmark run."""
lines = [
"=== Benchmark Summary ===",
f"Scenarios: {len(self.results)} "
f"Passed: {self.pass_count} "
f"Failed: {self.fail_count} "
f"Success rate: {self.success_rate:.0%}",
f"Total time: {self.total_time_ms} ms "
f"LLM calls: {self.total_llm_calls} "
f"Metabolic cost: {self.total_metabolic_cost:.1f}",
]
if self.commit_sha:
lines.append(f"Commit: {self.commit_sha}")
lines.append("")
for r in self.results:
status = "PASS" if r.success else "FAIL"
lines.append(
f" [{status}] {r.scenario_name}"
f"{r.cycles_used}/{r.max_cycles} cycles, "
f"{r.wall_time_ms} ms, "
f"{r.llm_calls} LLM calls"
)
if r.error:
lines.append(f" Error: {r.error}")
return "\n".join(lines)
def load_history(path: Path) -> list[dict]:
"""Load benchmark history from a JSONL file.
Returns:
List of run records, most recent first.
"""
path = Path(path)
if not path.exists():
return []
records: list[dict] = []
for line in path.read_text().strip().splitlines():
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
return list(reversed(records))
def compare_runs(
current: BenchmarkMetrics,
baseline: BenchmarkMetrics,
) -> str:
"""Compare two benchmark runs and report regressions.
Returns:
Human-readable comparison report.
"""
lines = ["=== Regression Report ==="]
# Overall
rate_delta = current.success_rate - baseline.success_rate
lines.append(
f"Success rate: {baseline.success_rate:.0%} -> {current.success_rate:.0%} "
f"({rate_delta:+.0%})"
)
cost_delta = current.total_metabolic_cost - baseline.total_metabolic_cost
if baseline.total_metabolic_cost > 0:
cost_pct = (cost_delta / baseline.total_metabolic_cost) * 100
lines.append(
f"Metabolic cost: {baseline.total_metabolic_cost:.1f} -> "
f"{current.total_metabolic_cost:.1f} ({cost_pct:+.1f}%)"
)
# Per-scenario
baseline_map = {r.scenario_name: r for r in baseline.results}
for r in current.results:
b = baseline_map.get(r.scenario_name)
if b is None:
lines.append(f" [NEW] {r.scenario_name}")
continue
if b.success and not r.success:
lines.append(f" [REGRESSION] {r.scenario_name} — was PASS, now FAIL")
elif not b.success and r.success:
lines.append(f" [IMPROVEMENT] {r.scenario_name} — was FAIL, now PASS")
elif r.cycles_used > b.cycles_used * 1.5:
lines.append(
f" [SLOWER] {r.scenario_name}"
f"{b.cycles_used} -> {r.cycles_used} cycles (+{r.cycles_used - b.cycles_used})"
)
return "\n".join(lines)

View File

@@ -0,0 +1,167 @@
"""Benchmark runner — executes scenarios through the heartbeat loop.
Wires each ``BenchmarkScenario`` into a ``MockWorldAdapter`` (or a
supplied adapter), runs the heartbeat for up to ``max_cycles``, and
collects ``BenchmarkMetrics``.
"""
from __future__ import annotations
import logging
import subprocess
import time
from datetime import UTC, datetime
from infrastructure.world.adapters.mock import MockWorldAdapter
from infrastructure.world.benchmark.metrics import BenchmarkMetrics, ScenarioResult
from infrastructure.world.benchmark.scenarios import BenchmarkScenario
from infrastructure.world.interface import WorldInterface
from loop.heartbeat import Heartbeat
logger = logging.getLogger(__name__)
# Rough estimate: each heartbeat cycle costs ~1 unit of metabolic cost
# (gather + reason + act phases each touch the LLM router once).
_COST_PER_CYCLE = 3.0 # three phases per cycle
class BenchmarkRunner:
"""Run benchmark scenarios and collect metrics.
Parameters
----------
adapter_factory:
Optional callable that returns a ``WorldInterface`` for a given
scenario. Defaults to building a ``MockWorldAdapter`` from the
scenario's start state.
heartbeat_interval:
Seconds between heartbeat ticks (0 for immediate).
"""
def __init__(
self,
*,
adapter_factory=None,
heartbeat_interval: float = 0.0,
) -> None:
self._adapter_factory = adapter_factory or self._default_adapter
self._interval = heartbeat_interval
# -- public API --------------------------------------------------------
async def run(
self,
scenarios: list[BenchmarkScenario],
) -> BenchmarkMetrics:
"""Execute all *scenarios* and return aggregated metrics."""
metrics = BenchmarkMetrics(
timestamp=datetime.now(UTC).isoformat(),
commit_sha=self._git_sha(),
)
suite_start = time.monotonic()
for scenario in scenarios:
logger.info("Benchmark: starting '%s'", scenario.name)
result = await self._run_scenario(scenario)
metrics.results.append(result)
status = "PASS" if result.success else "FAIL"
logger.info(
"Benchmark: '%s' %s (%d/%d cycles, %d ms)",
scenario.name,
status,
result.cycles_used,
result.max_cycles,
result.wall_time_ms,
)
metrics.total_time_ms = int((time.monotonic() - suite_start) * 1000)
return metrics
# -- internal ----------------------------------------------------------
async def _run_scenario(self, scenario: BenchmarkScenario) -> ScenarioResult:
"""Run a single scenario through the heartbeat loop."""
result = ScenarioResult(
scenario_name=scenario.name,
max_cycles=scenario.max_cycles,
tags=list(scenario.tags),
)
adapter = self._adapter_factory(scenario)
adapter.connect()
hb = Heartbeat(world=adapter, interval=self._interval)
actions: list[dict] = []
start = time.monotonic()
try:
for cycle in range(1, scenario.max_cycles + 1):
record = await hb.run_once()
result.cycles_used = cycle
# Track LLM calls (each cycle has 3 phases that may call LLM)
result.llm_calls += 3
# Accumulate actions for goal predicate
if record.action_taken and record.action_taken != "idle":
actions.append(
{
"action": record.action_taken,
"target": record.observation.get("location", ""),
"status": record.action_status,
}
)
# Update adapter location if scenario simulates movement
current_location = self._get_current_location(adapter)
# Check goal predicate
if scenario.goal_predicate is not None:
if scenario.goal_predicate(actions, current_location):
result.success = True
break
elif cycle == scenario.max_cycles:
# No predicate — success if we survived all cycles
result.success = True
except Exception as exc:
logger.warning("Benchmark scenario '%s' crashed: %s", scenario.name, exc)
result.error = str(exc)
finally:
adapter.disconnect()
result.wall_time_ms = int((time.monotonic() - start) * 1000)
result.metabolic_cost = result.cycles_used * _COST_PER_CYCLE
return result
@staticmethod
def _default_adapter(scenario: BenchmarkScenario) -> WorldInterface:
"""Build a MockWorldAdapter from a scenario's starting state."""
return MockWorldAdapter(
location=scenario.start_location,
entities=list(scenario.entities),
events=list(scenario.events),
)
@staticmethod
def _get_current_location(adapter: WorldInterface) -> str:
"""Read the current location from the adapter."""
try:
perception = adapter.observe()
return perception.location
except Exception:
return ""
@staticmethod
def _git_sha() -> str:
"""Best-effort: return the current git commit SHA."""
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
return result.stdout.strip() if result.returncode == 0 else ""
except (OSError, subprocess.TimeoutExpired):
return ""

View File

@@ -0,0 +1,160 @@
"""Benchmark scenario definitions for Morrowind agent regression testing.
Each scenario specifies a starting location, goal conditions, world state
(entities, events), and maximum cycles allowed. The runner feeds these
into the heartbeat loop and checks completion against the goal predicate.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
@dataclass(frozen=True)
class BenchmarkScenario:
"""A reproducible agent task used to detect performance regressions.
Attributes:
name: Human-readable scenario name.
description: What the scenario tests.
start_location: Where the agent begins.
goal_location: Target location (if navigation scenario).
entities: NPCs / objects present in the world.
events: Game events injected each cycle.
max_cycles: Hard cap on heartbeat cycles before failure.
goal_predicate: Optional callable ``(actions, location) -> bool``
evaluated after each cycle to check early success.
tags: Freeform tags for filtering (e.g. "navigation", "quest").
"""
name: str
description: str
start_location: str
goal_location: str = ""
entities: list[str] = field(default_factory=list)
events: list[str] = field(default_factory=list)
max_cycles: int = 50
goal_predicate: Callable | None = None
tags: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Goal predicates
# ---------------------------------------------------------------------------
def _reached_location(target: str) -> Callable:
"""Return a predicate that checks whether the agent reached *target*."""
def predicate(actions: list[dict], current_location: str) -> bool:
return current_location.lower() == target.lower()
return predicate
def _interacted_with(npc: str) -> Callable:
"""Return a predicate that checks for a speak/interact action with *npc*."""
def predicate(actions: list[dict], current_location: str) -> bool:
for act in actions:
if act.get("action") in ("speak", "interact", "talk"):
if act.get("target", "").lower() == npc.lower():
return True
return False
return predicate
# ---------------------------------------------------------------------------
# Built-in scenarios
# ---------------------------------------------------------------------------
BUILTIN_SCENARIOS: list[BenchmarkScenario] = [
BenchmarkScenario(
name="Walk Seyda Neen to Balmora",
description=(
"Navigate from the starting village to Balmora via the road. "
"Tests basic navigation and pathfinding."
),
start_location="Seyda Neen",
goal_location="Balmora",
entities=["Silt Strider", "Road Sign", "Mudcrab"],
events=["player_spawned"],
max_cycles=30,
goal_predicate=_reached_location("Balmora"),
tags=["navigation", "basic"],
),
BenchmarkScenario(
name="Fargoth's Ring",
description=(
"Complete the Fargoth quest: find Fargoth, receive the ring, "
"and return it. Tests NPC interaction and quest logic."
),
start_location="Seyda Neen",
goal_location="Seyda Neen",
entities=["Fargoth", "Arrille", "Guard"],
events=["quest_available:fargoth_ring"],
max_cycles=40,
goal_predicate=_interacted_with("Fargoth"),
tags=["quest", "npc_interaction"],
),
BenchmarkScenario(
name="Balmora Guild Navigation",
description=(
"Walk from Balmora South Wall Corner Club to the Fighters Guild. "
"Tests intra-city navigation with multiple NPCs present."
),
start_location="Balmora, South Wall Corner Club",
goal_location="Balmora, Fighters Guild",
entities=["Guard", "Merchant", "Caius Cosades"],
events=["player_entered"],
max_cycles=20,
goal_predicate=_reached_location("Balmora, Fighters Guild"),
tags=["navigation", "city"],
),
BenchmarkScenario(
name="Combat Encounter — Mudcrab",
description=(
"Engage and defeat a single Mudcrab on the road between "
"Seyda Neen and Balmora. Tests combat action selection."
),
start_location="Bitter Coast Road",
goal_location="Bitter Coast Road",
entities=["Mudcrab"],
events=["hostile_entity_nearby"],
max_cycles=15,
goal_predicate=None, # Success = survived max_cycles without crash
tags=["combat", "basic"],
),
BenchmarkScenario(
name="Passive Observation — Balmora Market",
description=(
"Observe the Balmora market for 10 cycles without acting. "
"Tests that the agent can reason without unnecessary actions."
),
start_location="Balmora, Market Square",
goal_location="",
entities=["Merchant", "Guard", "Pilgrim", "Trader"],
events=["market_day"],
max_cycles=10,
tags=["observation", "passive"],
),
]
def load_scenarios(
tags: list[str] | None = None,
) -> list[BenchmarkScenario]:
"""Return built-in scenarios, optionally filtered by tags.
Args:
tags: If provided, only return scenarios whose tags overlap.
Returns:
List of matching ``BenchmarkScenario`` instances.
"""
if tags is None:
return list(BUILTIN_SCENARIOS)
tag_set = set(tags)
return [s for s in BUILTIN_SCENARIOS if tag_set & set(s.tags)]

View File

@@ -0,0 +1,64 @@
"""Abstract WorldInterface — the contract every game-world adapter must fulfil.
Follows a Gymnasium-inspired pattern: observe → act → speak, with each
method returning strongly-typed data structures.
Any future engine (TES3MP, Luanti, Godot, …) plugs in by subclassing
``WorldInterface`` and implementing the three methods.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from infrastructure.world.types import ActionResult, CommandInput, PerceptionOutput
class WorldInterface(ABC):
"""Engine-agnostic base class for world adapters.
Subclasses must implement:
- ``observe()`` — gather structured perception from the world
- ``act()`` — dispatch a command and return the outcome
- ``speak()`` — send a message to an NPC / player / broadcast
Lifecycle hooks ``connect()`` and ``disconnect()`` are optional.
"""
# -- lifecycle (optional overrides) ------------------------------------
def connect(self) -> None: # noqa: B027
"""Establish connection to the game world.
Default implementation is a no-op. Override to open sockets,
authenticate, etc.
"""
def disconnect(self) -> None: # noqa: B027
"""Tear down the connection.
Default implementation is a no-op.
"""
@property
def is_connected(self) -> bool:
"""Return ``True`` if the adapter has an active connection.
Default returns ``True``. Override for adapters that maintain
persistent connections.
"""
return True
# -- core contract (must implement) ------------------------------------
@abstractmethod
def observe(self) -> PerceptionOutput:
"""Return a structured snapshot of the current world state."""
@abstractmethod
def act(self, command: CommandInput) -> ActionResult:
"""Execute *command* in the world and return the result."""
@abstractmethod
def speak(self, message: str, target: str | None = None) -> None:
"""Send *message* in the world, optionally directed at *target*."""

View File

@@ -0,0 +1,54 @@
"""Adapter registry — register and instantiate world adapters by name.
Usage::
registry = AdapterRegistry()
registry.register("mock", MockWorldAdapter)
adapter = registry.get("mock", some_kwarg="value")
"""
from __future__ import annotations
import logging
from typing import Any
from infrastructure.world.interface import WorldInterface
logger = logging.getLogger(__name__)
class AdapterRegistry:
"""Name → WorldInterface class registry with instantiation."""
def __init__(self) -> None:
self._adapters: dict[str, type[WorldInterface]] = {}
def register(self, name: str, cls: type[WorldInterface]) -> None:
"""Register an adapter class under *name*.
Raises ``TypeError`` if *cls* is not a ``WorldInterface`` subclass.
"""
if not (isinstance(cls, type) and issubclass(cls, WorldInterface)):
raise TypeError(f"{cls!r} is not a WorldInterface subclass")
if name in self._adapters:
logger.warning("Overwriting adapter %r (was %r)", name, self._adapters[name])
self._adapters[name] = cls
logger.info("Registered world adapter: %s%s", name, cls.__name__)
def get(self, name: str, **kwargs: Any) -> WorldInterface:
"""Instantiate and return the adapter registered as *name*.
Raises ``KeyError`` if *name* is not registered.
"""
cls = self._adapters[name]
return cls(**kwargs)
def list_adapters(self) -> list[str]:
"""Return sorted list of registered adapter names."""
return sorted(self._adapters)
def __contains__(self, name: str) -> bool:
return name in self._adapters
def __len__(self) -> int:
return len(self._adapters)

View File

@@ -0,0 +1,71 @@
"""Canonical data types for world interaction.
These mirror the PerceptionOutput / CommandInput types from PR #864's
``morrowind/schemas.py``. When that PR merges, these can be replaced
with re-exports — but until then they serve as the stable contract for
every WorldInterface adapter.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
class ActionStatus(StrEnum):
"""Outcome of an action dispatched to the world."""
SUCCESS = "success"
FAILURE = "failure"
PENDING = "pending"
NOOP = "noop"
@dataclass
class PerceptionOutput:
"""Structured world state returned by ``WorldInterface.observe()``.
Attributes:
timestamp: When the observation was captured.
location: Free-form location descriptor (e.g. "Balmora, Fighters Guild").
entities: List of nearby entity descriptions.
events: Recent game events since last observation.
raw: Optional raw / engine-specific payload for advanced consumers.
"""
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
location: str = ""
entities: list[str] = field(default_factory=list)
events: list[str] = field(default_factory=list)
raw: dict = field(default_factory=dict)
@dataclass
class CommandInput:
"""Action command sent via ``WorldInterface.act()``.
Attributes:
action: Verb / action name (e.g. "move", "attack", "use_item").
target: Optional target identifier.
parameters: Arbitrary key-value payload for engine-specific params.
"""
action: str
target: str | None = None
parameters: dict = field(default_factory=dict)
@dataclass
class ActionResult:
"""Outcome returned by ``WorldInterface.act()``.
Attributes:
status: Whether the action succeeded, failed, etc.
message: Human-readable description of the outcome.
data: Arbitrary engine-specific result payload.
"""
status: ActionStatus = ActionStatus.SUCCESS
message: str = ""
data: dict = field(default_factory=dict)

286
src/loop/heartbeat.py Normal file
View File

@@ -0,0 +1,286 @@
"""Heartbeat v2 — WorldInterface-driven cognitive loop.
Drives real observe → reason → act → reflect cycles through whatever
``WorldInterface`` adapter is connected. When no adapter is present,
gracefully falls back to the existing ``run_cycle()`` behaviour.
Usage::
heartbeat = Heartbeat(world=adapter, interval=30.0)
await heartbeat.run_once() # single cycle
await heartbeat.start() # background loop
heartbeat.stop() # graceful shutdown
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from loop.phase1_gather import gather
from loop.phase2_reason import reason
from loop.phase3_act import act
from loop.schema import ContextPayload
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Cycle log entry
# ---------------------------------------------------------------------------
@dataclass
class CycleRecord:
"""One observe → reason → act → reflect cycle."""
cycle_id: int
timestamp: str
observation: dict = field(default_factory=dict)
reasoning_summary: str = ""
action_taken: str = ""
action_status: str = ""
reflect_notes: str = ""
duration_ms: int = 0
# ---------------------------------------------------------------------------
# Heartbeat
# ---------------------------------------------------------------------------
class Heartbeat:
"""Manages the recurring cognitive loop with optional world adapter.
Parameters
----------
world:
A ``WorldInterface`` instance (or ``None`` for passive mode).
interval:
Seconds between heartbeat ticks. 30 s for embodied mode,
300 s (5 min) for passive thinking.
on_cycle:
Optional async callback invoked after each cycle with the
``CycleRecord``.
"""
def __init__(
self,
*,
world=None, # WorldInterface | None
interval: float = 30.0,
on_cycle=None, # Callable[[CycleRecord], Awaitable[None]] | None
) -> None:
self._world = world
self._interval = interval
self._on_cycle = on_cycle
self._cycle_count: int = 0
self._running = False
self._task: asyncio.Task | None = None
self.history: list[CycleRecord] = []
# -- properties --------------------------------------------------------
@property
def world(self):
return self._world
@world.setter
def world(self, adapter) -> None:
self._world = adapter
@property
def interval(self) -> float:
return self._interval
@interval.setter
def interval(self, value: float) -> None:
self._interval = max(1.0, value)
@property
def is_running(self) -> bool:
return self._running
@property
def cycle_count(self) -> int:
return self._cycle_count
# -- single cycle ------------------------------------------------------
async def run_once(self) -> CycleRecord:
"""Execute one full heartbeat cycle.
If a world adapter is present:
1. Observe — ``world.observe()``
2. Gather + Reason + Act via the three-phase loop, with the
observation injected into the payload
3. Dispatch the decided action back to ``world.act()``
4. Reflect — log the cycle
Without an adapter the existing loop runs on a timer-sourced
payload (passive thinking).
"""
self._cycle_count += 1
start = time.monotonic()
record = CycleRecord(
cycle_id=self._cycle_count,
timestamp=datetime.now(UTC).isoformat(),
)
if self._world is not None:
record = await self._embodied_cycle(record)
else:
record = await self._passive_cycle(record)
record.duration_ms = int((time.monotonic() - start) * 1000)
self.history.append(record)
# Broadcast via WebSocket (best-effort)
await self._broadcast(record)
if self._on_cycle:
await self._on_cycle(record)
logger.info(
"Heartbeat cycle #%d complete (%d ms) — action=%s status=%s",
record.cycle_id,
record.duration_ms,
record.action_taken or "(passive)",
record.action_status or "n/a",
)
return record
# -- background loop ---------------------------------------------------
async def start(self) -> None:
"""Start the recurring heartbeat loop as a background task."""
if self._running:
logger.warning("Heartbeat already running")
return
self._running = True
self._task = asyncio.current_task() or asyncio.ensure_future(self._loop())
if self._task is not asyncio.current_task():
return
await self._loop()
async def _loop(self) -> None:
logger.info(
"Heartbeat loop started (interval=%.1fs, adapter=%s)",
self._interval,
type(self._world).__name__ if self._world else "None",
)
while self._running:
try:
await self.run_once()
except Exception:
logger.exception("Heartbeat cycle failed")
await asyncio.sleep(self._interval)
def stop(self) -> None:
"""Signal the heartbeat loop to stop after the current cycle."""
self._running = False
logger.info("Heartbeat stop requested")
# -- internal: embodied cycle ------------------------------------------
async def _embodied_cycle(self, record: CycleRecord) -> CycleRecord:
"""Cycle with a live world adapter: observe → reason → act → reflect."""
from infrastructure.world.types import ActionStatus, CommandInput
# 1. Observe
perception = self._world.observe()
record.observation = {
"location": perception.location,
"entities": perception.entities,
"events": perception.events,
}
# 2. Feed observation into the three-phase loop
obs_content = (
f"Location: {perception.location}\n"
f"Entities: {', '.join(perception.entities)}\n"
f"Events: {', '.join(perception.events)}"
)
payload = ContextPayload(
source="world",
content=obs_content,
metadata={"perception": record.observation},
)
gathered = gather(payload)
reasoned = reason(gathered)
acted = act(reasoned)
# Extract action decision from the acted payload
action_name = acted.metadata.get("action", "idle")
action_target = acted.metadata.get("action_target")
action_params = acted.metadata.get("action_params", {})
record.reasoning_summary = acted.metadata.get("reasoning", acted.content[:200])
# 3. Dispatch action to world
if action_name != "idle":
cmd = CommandInput(
action=action_name,
target=action_target,
parameters=action_params,
)
result = self._world.act(cmd)
record.action_taken = action_name
record.action_status = result.status.value
else:
record.action_taken = "idle"
record.action_status = ActionStatus.NOOP.value
# 4. Reflect
record.reflect_notes = (
f"Observed {len(perception.entities)} entities at {perception.location}. "
f"Action: {record.action_taken}{record.action_status}."
)
return record
# -- internal: passive cycle -------------------------------------------
async def _passive_cycle(self, record: CycleRecord) -> CycleRecord:
"""Cycle without a world adapter — existing think_once() behaviour."""
payload = ContextPayload(
source="timer",
content="heartbeat",
metadata={"mode": "passive"},
)
gathered = gather(payload)
reasoned = reason(gathered)
acted = act(reasoned)
record.reasoning_summary = acted.content[:200]
record.action_taken = "think"
record.action_status = "noop"
record.reflect_notes = "Passive thinking cycle — no world adapter connected."
return record
# -- broadcast ---------------------------------------------------------
async def _broadcast(self, record: CycleRecord) -> None:
"""Emit heartbeat cycle data via WebSocket (best-effort)."""
try:
from infrastructure.ws_manager.handler import ws_manager
await ws_manager.broadcast(
"heartbeat.cycle",
{
"cycle_id": record.cycle_id,
"timestamp": record.timestamp,
"action": record.action_taken,
"action_status": record.action_status,
"reasoning_summary": record.reasoning_summary[:300],
"observation": record.observation,
"duration_ms": record.duration_ms,
},
)
except (ImportError, AttributeError, ConnectionError, RuntimeError) as exc:
logger.debug("Heartbeat broadcast skipped: %s", exc)

View File

@@ -17,9 +17,9 @@ logger = logging.getLogger(__name__)
def gather(payload: ContextPayload) -> ContextPayload:
"""Accept raw input and return structured context for reasoning.
Stub: tags the payload with phase=gather and logs transit.
Timmy will flesh this out with context selection, memory lookup,
adapter polling, and attention-residual weighting.
When the payload carries a ``perception`` dict in metadata (injected by
the heartbeat loop from a WorldInterface adapter), that observation is
folded into the gathered context. Otherwise behaves as before.
"""
logger.info(
"Phase 1 (Gather) received: source=%s content_len=%d tokens=%d",
@@ -28,7 +28,20 @@ def gather(payload: ContextPayload) -> ContextPayload:
payload.token_count,
)
result = payload.with_metadata(phase="gather", gathered=True)
extra: dict = {"phase": "gather", "gathered": True}
# Enrich with world observation when present
perception = payload.metadata.get("perception")
if perception:
extra["world_observation"] = perception
logger.info(
"Phase 1 (Gather) world observation: location=%s entities=%d events=%d",
perception.get("location", "?"),
len(perception.get("entities", [])),
len(perception.get("events", [])),
)
result = payload.with_metadata(**extra)
logger.info(
"Phase 1 (Gather) produced: metadata_keys=%s",

View File

@@ -215,6 +215,119 @@ def _summarize(result: AgenticResult, total_steps: int, was_truncated: bool) ->
result.status = "completed"
# ---------------------------------------------------------------------------
# Execution orchestrator
# ---------------------------------------------------------------------------
async def _execute_all_steps(
agent,
task: str,
task_id: str,
steps: list[str],
total_steps: int,
session_id: str,
result: AgenticResult,
on_progress: Callable | None,
) -> list[str]:
"""Execute all planned steps, handling failures with adaptation.
Appends AgenticStep objects to *result.steps* and returns the list
of completed-result strings (used as context for later steps).
"""
completed_results: list[str] = []
for i, step_desc in enumerate(steps, 1):
step_start = time.monotonic()
try:
step = await _execute_step(
agent,
task,
step_desc,
i,
total_steps,
completed_results,
session_id,
)
result.steps.append(step)
completed_results.append(f"Step {i}: {step.result[:200]}")
await _broadcast_progress(
"agentic.step_complete",
{
"task_id": task_id,
"step": i,
"total": total_steps,
"description": step_desc,
"result": step.result[:200],
},
)
if on_progress:
await on_progress(step_desc, i, total_steps)
except Exception as exc: # broad catch intentional: agent.run can raise any error
logger.warning("Agentic loop step %d failed: %s", i, exc)
step = await _handle_step_failure(
agent,
step_desc,
i,
total_steps,
task_id,
exc,
step_start,
session_id,
result,
completed_results,
on_progress,
)
return completed_results
async def _handle_step_failure(
agent,
step_desc: str,
step_num: int,
total_steps: int,
task_id: str,
exc: Exception,
step_start: float,
session_id: str,
result: AgenticResult,
completed_results: list[str],
on_progress: Callable | None,
) -> None:
"""Try to adapt a failed step; record a hard failure if adaptation also fails."""
try:
step = await _adapt_step(agent, step_desc, step_num, exc, step_start, session_id)
result.steps.append(step)
completed_results.append(f"Step {step_num} (adapted): {step.result[:200]}")
await _broadcast_progress(
"agentic.step_adapted",
{
"task_id": task_id,
"step": step_num,
"total": total_steps,
"description": step_desc,
"error": str(exc),
"adaptation": step.result[:200],
},
)
if on_progress:
await on_progress(f"[Adapted] {step_desc}", step_num, total_steps)
except Exception as adapt_exc: # broad catch intentional
logger.error("Agentic loop adaptation also failed: %s", adapt_exc)
result.steps.append(
AgenticStep(
step_num=step_num,
description=step_desc,
result=f"Failed: {exc}; Adaptation also failed: {adapt_exc}",
status="failed",
duration_ms=int((time.monotonic() - step_start) * 1000),
)
)
completed_results.append(f"Step {step_num}: FAILED")
# ---------------------------------------------------------------------------
# Core loop
# ---------------------------------------------------------------------------
@@ -265,65 +378,9 @@ async def run_agentic_loop(
)
# Phase 2: Execution
completed_results: list[str] = []
for i, step_desc in enumerate(steps, 1):
step_start = time.monotonic()
try:
step = await _execute_step(
agent,
task,
step_desc,
i,
total_steps,
completed_results,
session_id,
)
result.steps.append(step)
completed_results.append(f"Step {i}: {step.result[:200]}")
await _broadcast_progress(
"agentic.step_complete",
{
"task_id": task_id,
"step": i,
"total": total_steps,
"description": step_desc,
"result": step.result[:200],
},
)
if on_progress:
await on_progress(step_desc, i, total_steps)
except Exception as exc: # broad catch intentional: agent.run can raise any error
logger.warning("Agentic loop step %d failed: %s", i, exc)
try:
step = await _adapt_step(agent, step_desc, i, exc, step_start, session_id)
result.steps.append(step)
completed_results.append(f"Step {i} (adapted): {step.result[:200]}")
await _broadcast_progress(
"agentic.step_adapted",
{
"task_id": task_id,
"step": i,
"total": total_steps,
"description": step_desc,
"error": str(exc),
"adaptation": step.result[:200],
},
)
if on_progress:
await on_progress(f"[Adapted] {step_desc}", i, total_steps)
except Exception as adapt_exc: # broad catch intentional
logger.error("Agentic loop adaptation also failed: %s", adapt_exc)
result.steps.append(
AgenticStep(
step_num=i,
description=step_desc,
result=f"Failed: {exc}; Adaptation also failed: {adapt_exc}",
status="failed",
duration_ms=int((time.monotonic() - step_start) * 1000),
)
)
completed_results.append(f"Step {i}: FAILED")
await _execute_all_steps(
agent, task, task_id, steps, total_steps, session_id, result, on_progress
)
# Phase 3: Summary
_summarize(result, total_steps, was_truncated)

View File

@@ -99,11 +99,11 @@ class GrokBackend:
def _get_client(self):
"""Create OpenAI client configured for xAI endpoint."""
from config import settings
import httpx
from openai import OpenAI
from config import settings
return OpenAI(
api_key=self._api_key,
base_url=settings.xai_base_url,
@@ -112,11 +112,11 @@ class GrokBackend:
async def _get_async_client(self):
"""Create async OpenAI client configured for xAI endpoint."""
from config import settings
import httpx
from openai import AsyncOpenAI
from config import settings
return AsyncOpenAI(
api_key=self._api_key,
base_url=settings.xai_base_url,
@@ -264,6 +264,7 @@ class GrokBackend:
},
}
except Exception as exc:
logger.exception("Grok health check failed")
return {
"ok": False,
"error": str(exc),
@@ -430,6 +431,7 @@ class ClaudeBackend:
)
return {"ok": True, "error": None, "backend": "claude", "model": self._model}
except Exception as exc:
logger.exception("Claude health check failed")
return {"ok": False, "error": str(exc), "backend": "claude", "model": self._model}
# ── Private helpers ───────────────────────────────────────────────────

View File

@@ -37,6 +37,39 @@ def _is_interactive() -> bool:
return hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
def _read_message_input(message: list[str]) -> str:
"""Join CLI args into a message, reading from stdin when requested.
Returns the final message string. Raises ``typer.Exit(1)`` when
stdin is explicitly requested (``-``) but empty.
"""
message_str = " ".join(message)
if message_str == "-" or not _is_interactive():
try:
stdin_content = sys.stdin.read().strip()
except (KeyboardInterrupt, EOFError):
stdin_content = ""
if stdin_content:
message_str = stdin_content
elif message_str == "-":
typer.echo("No input provided via stdin.", err=True)
raise typer.Exit(1)
return message_str
def _resolve_session_id(session_id: str | None, new_session: bool) -> str:
"""Return the effective session ID for a chat invocation."""
import uuid
if session_id is not None:
return session_id
if new_session:
return str(uuid.uuid4())
return _CLI_SESSION_ID
def _prompt_interactive(req, tool_name: str, tool_args: dict) -> None:
"""Display tool details and prompt the human for approval."""
description = format_action_description(tool_name, tool_args)
@@ -143,6 +176,35 @@ def think(
timmy.print_response(f"Think carefully about: {topic}", stream=True, session_id=_CLI_SESSION_ID)
def _read_message_input(message: list[str]) -> str:
"""Join CLI arguments and read from stdin when appropriate."""
message_str = " ".join(message)
if message_str == "-" or not _is_interactive():
try:
stdin_content = sys.stdin.read().strip()
except (KeyboardInterrupt, EOFError):
stdin_content = ""
if stdin_content:
message_str = stdin_content
elif message_str == "-":
typer.echo("No input provided via stdin.", err=True)
raise typer.Exit(1)
return message_str
def _resolve_session_id(session_id: str | None, new_session: bool) -> str:
"""Return the effective session ID based on CLI flags."""
import uuid
if session_id is not None:
return session_id
if new_session:
return str(uuid.uuid4())
return _CLI_SESSION_ID
@app.command()
def chat(
message: list[str] = typer.Argument(
@@ -179,38 +241,13 @@ def chat(
Read from stdin by passing "-" as the message or piping input.
"""
import uuid
# Join multiple arguments into a single message string
message_str = " ".join(message)
# Handle stdin input if "-" is passed or stdin is not a tty
if message_str == "-" or not _is_interactive():
try:
stdin_content = sys.stdin.read().strip()
except (KeyboardInterrupt, EOFError):
stdin_content = ""
if stdin_content:
message_str = stdin_content
elif message_str == "-":
typer.echo("No input provided via stdin.", err=True)
raise typer.Exit(1)
if session_id is not None:
pass # use the provided value
elif new_session:
session_id = str(uuid.uuid4())
else:
session_id = _CLI_SESSION_ID
message_str = _read_message_input(message)
session_id = _resolve_session_id(session_id, new_session)
timmy = create_timmy(backend=backend, session_id=session_id)
# Use agent.run() so we can intercept paused runs for tool confirmation.
run_output = timmy.run(message_str, stream=False, session_id=session_id)
# Handle paused runs — dangerous tools need user approval
run_output = _handle_tool_confirmation(timmy, run_output, session_id, autonomous=autonomous)
# Print the final response
content = run_output.content if hasattr(run_output, "content") else str(run_output)
if content:
from timmy.session import _clean_response
@@ -452,5 +489,43 @@ def focus(
typer.echo("No active focus (broad mode).")
@app.command(name="healthcheck")
def healthcheck(
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
verbose: bool = typer.Option(
False, "--verbose", "-v", help="Show verbose output including issue details"
),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Only show status line (no details)"),
):
"""Quick health snapshot before coding.
Shows CI status, critical issues (P0/P1), test flakiness, and token economy.
Fast execution (< 5 seconds) for pre-work checks.
Refs: #710
"""
import subprocess
import sys
from pathlib import Path
script_path = (
Path(__file__).resolve().parent.parent.parent
/ "timmy_automations"
/ "daily_run"
/ "health_snapshot.py"
)
cmd = [sys.executable, str(script_path)]
if json_output:
cmd.append("--json")
if verbose:
cmd.append("--verbose")
if quiet:
cmd.append("--quiet")
result = subprocess.run(cmd)
raise typer.Exit(result.returncode)
def main():
app()

View File

@@ -0,0 +1,488 @@
"""Kimi delegation for heavy research via Gitea labels.
When research exceeds local + Groq capacity, Timmy delegates to Kimi by:
1. Filling a research template with full context
2. Creating a Gitea issue labeled `kimi-ready`
3. Monitoring for Kimi's completion (issue closed + artifact committed)
4. Indexing Kimi's artifact into semantic memory
5. Extracting action items and creating follow-up issues
Delegation flow:
Timmy detects capacity exceeded
→ Fills template with context
→ Creates `kimi-ready` Gitea issue
→ Kimi picks up, executes, commits artifact, closes issue
→ Timmy indexes artifact + creates follow-ups
"""
import asyncio
import logging
import re
from typing import Any
logger = logging.getLogger(__name__)
# Label applied to issues that Kimi should pick up
KIMI_READY_LABEL = "kimi-ready"
# Label colour for the kimi-ready label (dark teal)
KIMI_LABEL_COLOR = "#006b75"
# Keywords that suggest a task exceeds local capacity
_HEAVY_RESEARCH_KEYWORDS = frozenset(
{
"comprehensive",
"exhaustive",
"systematic review",
"literature review",
"benchmark",
"comparative analysis",
"large-scale",
"survey",
"meta-analysis",
"deep research",
"extensive",
}
)
# Minimum word count that hints at a heavy task
_HEAVY_WORD_THRESHOLD = 50
def exceeds_local_capacity(task_description: str) -> bool:
"""Heuristic: does this research task exceed local + Groq capacity?
Returns True when the task description signals heavy or broad research
that benefits from Kimi's 262K context and long-running processing.
Args:
task_description: Free-text description of the research task.
Returns:
True if the task should be delegated to Kimi.
"""
lower = task_description.lower()
word_count = len(task_description.split())
has_heavy_keyword = any(kw in lower for kw in _HEAVY_RESEARCH_KEYWORDS)
is_long_task = word_count >= _HEAVY_WORD_THRESHOLD
return has_heavy_keyword or is_long_task
def _build_research_template(
task: str,
context: str,
question: str,
priority: str = "normal",
) -> str:
"""Fill the standard Kimi research template with task context.
Args:
task: Short title for the research task.
context: Background information and relevant project context.
question: The specific research question to answer.
priority: Task priority — "low", "normal", or "high".
Returns:
Markdown-formatted issue body ready for Gitea.
"""
return f"""\
## Research Request
**Priority:** {priority}
### Research Question
{question}
### Background / Context
{context}
### Scope
Please produce a thorough, well-structured research report covering:
- Direct answer to the research question above
- Supporting evidence and sources where applicable
- Trade-offs, limitations, or caveats
- Concrete recommendations or next steps
### Deliverables
Commit your findings as a markdown artifact (e.g. `memory/research/{_slugify(task)}.md`)
and close this issue when complete.
### Task
{task}
---
*Delegated by Timmy via Kimi delegation pipeline. Label: `{KIMI_READY_LABEL}`*
"""
def _slugify(text: str) -> str:
"""Convert text to a safe filename slug."""
slug = re.sub(r"[^\w\s-]", "", text.lower())
slug = re.sub(r"[\s_]+", "-", slug)
return slug[:60].strip("-")
async def _get_or_create_label(
client: Any,
base_url: str,
headers: dict[str, str],
repo: str,
) -> int | None:
"""Ensure the `kimi-ready` label exists; return its ID or None on error.
Args:
client: httpx.AsyncClient instance.
base_url: Gitea API base URL.
headers: Auth headers.
repo: owner/repo string.
Returns:
Label ID, or None if the operation failed.
"""
labels_url = f"{base_url}/repos/{repo}/labels"
# Check for existing label
try:
resp = await client.get(labels_url, headers=headers)
if resp.status_code == 200:
for label in resp.json():
if label.get("name") == KIMI_READY_LABEL:
return label["id"]
except Exception as exc:
logger.warning("Failed to list Gitea labels: %s", exc)
return None
# Create the label
try:
resp = await client.post(
labels_url,
headers=headers,
json={"name": KIMI_READY_LABEL, "color": KIMI_LABEL_COLOR},
)
if resp.status_code in (200, 201):
return resp.json().get("id")
logger.warning("Label creation returned %s: %s", resp.status_code, resp.text[:200])
except Exception as exc:
logger.warning("Failed to create Gitea label: %s", exc)
return None
async def create_kimi_research_issue(
task: str,
context: str,
question: str,
priority: str = "normal",
) -> dict[str, Any]:
"""Create a Gitea issue labeled `kimi-ready` for Kimi to pick up.
Args:
task: Short title for the research task (used as issue title).
context: Background information and project context.
question: The specific research question.
priority: Task priority — "low", "normal", or "high".
Returns:
Dict with `success`, `issue_number`, `issue_url`, and `error` keys.
"""
try:
import httpx
from config import settings
except ImportError as exc:
return {"success": False, "error": f"Missing dependency: {exc}"}
if not settings.gitea_enabled or not settings.gitea_token:
return {
"success": False,
"error": "Gitea integration not configured (no token or disabled).",
}
base_url = f"{settings.gitea_url}/api/v1"
repo = settings.gitea_repo
headers = {
"Authorization": f"token {settings.gitea_token}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=15) as client:
label_id = await _get_or_create_label(client, base_url, headers, repo)
body = _build_research_template(task, context, question, priority)
issue_payload: dict[str, Any] = {"title": task, "body": body}
if label_id is not None:
issue_payload["labels"] = [label_id]
resp = await client.post(
f"{base_url}/repos/{repo}/issues",
headers=headers,
json=issue_payload,
)
if resp.status_code in (200, 201):
data = resp.json()
number = data.get("number")
url = data.get("html_url", "")
logger.info("Created kimi-ready issue #%s: %s", number, task[:60])
return {
"success": True,
"issue_number": number,
"issue_url": url,
"error": None,
}
logger.warning("Issue creation failed (%s): %s", resp.status_code, resp.text[:200])
return {
"success": False,
"error": f"Gitea API error {resp.status_code}: {resp.text[:200]}",
}
except Exception as exc:
logger.warning("create_kimi_research_issue failed: %s", exc)
return {"success": False, "error": str(exc)}
async def poll_kimi_issue(
issue_number: int,
poll_interval: int = 60,
max_wait: int = 3600,
) -> dict[str, Any]:
"""Poll a Gitea issue until it is closed (Kimi completed) or timeout.
Args:
issue_number: The Gitea issue number to watch.
poll_interval: Seconds between polls. Default 60.
max_wait: Maximum total seconds to wait. Default 3600 (1 hour).
Returns:
Dict with `completed` bool, `state`, `body`, and `error` keys.
"""
try:
import httpx
from config import settings
except ImportError as exc:
return {"completed": False, "error": f"Missing dependency: {exc}"}
if not settings.gitea_enabled or not settings.gitea_token:
return {"completed": False, "error": "Gitea not configured."}
base_url = f"{settings.gitea_url}/api/v1"
repo = settings.gitea_repo
headers = {"Authorization": f"token {settings.gitea_token}"}
issue_url = f"{base_url}/repos/{repo}/issues/{issue_number}"
elapsed = 0
while elapsed < max_wait:
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(issue_url, headers=headers)
if resp.status_code == 200:
data = resp.json()
state = data.get("state", "open")
if state == "closed":
logger.info("Kimi completed issue #%s", issue_number)
return {
"completed": True,
"state": state,
"body": data.get("body", ""),
"error": None,
}
else:
logger.warning("Poll issue #%s returned %s", issue_number, resp.status_code)
except Exception as exc:
logger.warning("Poll error for issue #%s: %s", issue_number, exc)
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return {
"completed": False,
"state": "timeout",
"body": "",
"error": f"Timed out after {max_wait}s waiting for issue #{issue_number}",
}
def _extract_action_items(text: str) -> list[str]:
"""Extract action items from markdown text.
Looks for lines that start with checklist markers, numbered items,
or explicit "Action:" / "TODO:" prefixes.
Args:
text: Markdown text from Kimi's artifact.
Returns:
List of action item strings (deduplicated, whitespace-stripped).
"""
items: list[str] = []
patterns = [
re.compile(r"^[-*]\s+\[ \]\s+(.+)", re.MULTILINE), # - [ ] checkbox
re.compile(r"^\d+\.\s+(.+)", re.MULTILINE), # 1. numbered list
re.compile(r"^(?:Action|TODO|Next step):\s*(.+)", re.MULTILINE | re.IGNORECASE),
]
seen: set[str] = set()
for pat in patterns:
for m in pat.finditer(text):
item = m.group(1).strip()
if item and item not in seen:
items.append(item)
seen.add(item)
return items
async def index_kimi_artifact(
issue_number: int,
title: str,
artifact_content: str,
) -> dict[str, Any]:
"""Index Kimi's research artifact into Timmy's semantic memory.
Args:
issue_number: Source Gitea issue number (used as task_id).
title: Human-readable title for the memory entry.
artifact_content: The research artifact text to index.
Returns:
Dict with `success` bool and `memory_id` or `error`.
"""
if not artifact_content.strip():
return {"success": False, "error": "Empty artifact — nothing to index."}
try:
import asyncio
from timmy.memory_system import store_memory
# store_memory is synchronous — wrap in thread to avoid blocking event loop
entry = await asyncio.to_thread(
store_memory,
content=artifact_content,
source="kimi",
context_type="document",
task_id=str(issue_number),
metadata={"issue_number": issue_number, "title": title},
)
logger.info("Indexed Kimi artifact for issue #%s (id=%s)", issue_number, entry.id)
return {"success": True, "memory_id": entry.id}
except Exception as exc:
logger.warning("Failed to index Kimi artifact for issue #%s: %s", issue_number, exc)
return {"success": False, "error": str(exc)}
async def extract_and_create_followups(
artifact_content: str,
source_issue_number: int,
) -> dict[str, Any]:
"""Extract action items from artifact and create follow-up Gitea issues.
Args:
artifact_content: Text of Kimi's research artifact.
source_issue_number: Issue number that produced the artifact (for cross-links).
Returns:
Dict with `success`, `created` (list of issue numbers), and `error`.
"""
items = _extract_action_items(artifact_content)
if not items:
logger.info("No action items found in artifact for issue #%s", source_issue_number)
return {"success": True, "created": [], "error": None}
try:
import httpx
from config import settings
except ImportError as exc:
return {"success": False, "created": [], "error": str(exc)}
if not settings.gitea_enabled or not settings.gitea_token:
return {
"success": False,
"created": [],
"error": "Gitea not configured.",
}
base_url = f"{settings.gitea_url}/api/v1"
repo = settings.gitea_repo
headers = {
"Authorization": f"token {settings.gitea_token}",
"Content-Type": "application/json",
}
created: list[int] = []
for item in items:
body = (
f"Follow-up from Kimi research artifact in #{source_issue_number}.\n\n"
f"**Action item:** {item}"
)
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{base_url}/repos/{repo}/issues",
headers=headers,
json={"title": item[:120], "body": body},
)
if resp.status_code in (200, 201):
num = resp.json().get("number")
if num:
created.append(num)
logger.info(
"Created follow-up issue #%s from kimi artifact #%s",
num,
source_issue_number,
)
else:
logger.warning(
"Follow-up issue creation returned %s for item: %s",
resp.status_code,
item[:60],
)
except Exception as exc:
logger.warning("Failed to create follow-up for item '%s': %s", item[:60], exc)
return {"success": True, "created": created, "error": None}
async def delegate_research_to_kimi(
task: str,
context: str,
question: str,
priority: str = "normal",
) -> dict[str, Any]:
"""Top-level entry point: delegate a heavy research task to Kimi.
Creates the `kimi-ready` Gitea issue and returns immediately.
Monitoring, artifact indexing, and follow-up creation happen
separately via `poll_kimi_issue`, `index_kimi_artifact`, and
`extract_and_create_followups`.
Args:
task: Short title (becomes the issue title).
context: Background / project context.
question: The specific research question Kimi should answer.
priority: "low", "normal", or "high".
Returns:
Dict with `success`, `issue_number`, `issue_url`, and `error`.
"""
if not task.strip() or not question.strip():
return {
"success": False,
"error": "Both `task` and `question` are required.",
}
logger.info("Delegating research to Kimi: %s", task[:80])
return await create_kimi_research_issue(task, context, question, priority)

View File

@@ -97,6 +97,7 @@ async def probe_tool_use() -> dict:
"error_type": "empty_result",
}
except Exception as exc:
logger.exception("Tool use probe failed")
return {
"success": False,
"capability": cap,
@@ -129,6 +130,7 @@ async def probe_multistep_planning() -> dict:
"error_type": "verification_failed",
}
except Exception as exc:
logger.exception("Multistep planning probe failed")
return {
"success": False,
"capability": cap,
@@ -151,6 +153,7 @@ async def probe_memory_write() -> dict:
"error_type": None,
}
except Exception as exc:
logger.exception("Memory write probe failed")
return {
"success": False,
"capability": cap,
@@ -179,6 +182,7 @@ async def probe_memory_read() -> dict:
"error_type": "empty_result",
}
except Exception as exc:
logger.exception("Memory read probe failed")
return {
"success": False,
"capability": cap,
@@ -214,6 +218,7 @@ async def probe_self_coding() -> dict:
"error_type": "verification_failed",
}
except Exception as exc:
logger.exception("Self-coding probe failed")
return {
"success": False,
"capability": cap,
@@ -325,6 +330,7 @@ class LoopQAOrchestrator:
result = await probe_fn()
except Exception as exc:
# Probe itself crashed — record failure and report
logger.exception("Loop QA probe %s crashed", cap.value)
capture_error(exc, source="loop_qa", context={"capability": cap.value})
result = {
"success": False,

540
src/timmy/mcp_bridge.py Normal file
View File

@@ -0,0 +1,540 @@
"""MCP Bridge for Qwen3 via Ollama.
Provides a lightweight bridge between Ollama's native tool-calling API
and MCP tool servers (Gitea, Filesystem, Shell). Unlike the Agno-based
agent loop, this bridge talks directly to the Ollama ``/api/chat``
endpoint, translating MCP tool schemas into Ollama tool definitions and
executing tool calls in a loop until the model produces a final response.
Designed for Qwen3 models which have first-class tool-calling support.
Usage::
from timmy.mcp_bridge import MCPBridge
bridge = MCPBridge()
async with bridge:
result = await bridge.run("List open issues in Timmy-time-dashboard")
print(result.content)
The bridge evaluates available options in order of preference:
1. Direct Ollama /api/chat with native tool_calls (selected — best fit)
2. qwen-agent MCP (requires separate qwen-agent install)
3. ollmcp / mcphost / ollama-mcp-bridge (external binaries)
Option 1 was selected because:
- Zero additional dependencies (uses httpx already in the project)
- Native Qwen3 tool-calling support via Ollama's OpenAI-compatible API
- Full control over the tool-call loop and error handling
- Consistent with the project's graceful-degradation pattern
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
from config import settings
logger = logging.getLogger(__name__)
# Maximum tool-call round-trips before aborting (safety valve).
_MAX_TOOL_ROUNDS = 10
@dataclass
class BridgeResult:
"""Result from an MCP bridge run."""
content: str
tool_calls_made: list[dict] = field(default_factory=list)
rounds: int = 0
latency_ms: float = 0.0
model: str = ""
error: str = ""
@dataclass
class MCPToolDef:
"""An MCP tool definition translated for Ollama."""
name: str
description: str
parameters: dict[str, Any]
handler: Any # async callable(**kwargs) -> str
def _mcp_schema_to_ollama_tool(tool: MCPToolDef) -> dict:
"""Convert an MCPToolDef into Ollama's tool format.
Ollama uses OpenAI-compatible tool definitions::
{
"type": "function",
"function": {
"name": "...",
"description": "...",
"parameters": { "type": "object", "properties": {...}, "required": [...] }
}
}
"""
# Normalise parameters — ensure it has "type": "object" wrapper.
params = tool.parameters
if params.get("type") != "object":
params = {
"type": "object",
"properties": params,
"required": list(params.keys()),
}
return {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": params,
},
}
def _build_shell_tool() -> MCPToolDef | None:
"""Build the shell execution tool using the local ShellHand."""
try:
from infrastructure.hands.shell import shell_hand
async def _handle_shell(**kwargs: Any) -> str:
command = kwargs.get("command", "")
timeout = kwargs.get("timeout")
result = await shell_hand.run(command, timeout=timeout)
if result.success:
return result.stdout or "(no output)"
return f"[error] exit={result.exit_code} {result.error or result.stderr}"
return MCPToolDef(
name="shell_exec",
description=(
"Execute a shell command in a sandboxed environment. "
"Commands are validated against an allow-list. "
"Returns stdout, stderr, and exit code."
),
parameters={
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute (must match allow-list)",
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default 60)",
},
},
"required": ["command"],
},
handler=_handle_shell,
)
except Exception as exc:
logger.debug("Shell tool unavailable: %s", exc)
return None
def _build_gitea_tools() -> list[MCPToolDef]:
"""Build Gitea MCP tool definitions for direct Ollama bridge use.
These tools call the Gitea REST API directly via httpx rather than
spawning an MCP server subprocess, keeping the bridge lightweight.
"""
if not settings.gitea_enabled or not settings.gitea_token:
return []
base_url = settings.gitea_url
token = settings.gitea_token
owner, repo = settings.gitea_repo.split("/", 1)
async def _list_issues(**kwargs: Any) -> str:
state = kwargs.get("state", "open")
limit = kwargs.get("limit", 10)
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{base_url}/api/v1/repos/{owner}/{repo}/issues",
headers={"Authorization": f"token {token}"},
params={"state": state, "limit": limit, "type": "issues"},
)
resp.raise_for_status()
issues = resp.json()
if not issues:
return f"No {state} issues found."
lines = []
for issue in issues:
labels = ", ".join(lb["name"] for lb in issue.get("labels", []))
label_str = f" [{labels}]" if labels else ""
lines.append(f"#{issue['number']}: {issue['title']}{label_str}")
return "\n".join(lines)
except Exception as exc:
return f"Error listing issues: {exc}"
async def _create_issue(**kwargs: Any) -> str:
title = kwargs.get("title", "")
body = kwargs.get("body", "")
if not title:
return "Error: title is required"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{base_url}/api/v1/repos/{owner}/{repo}/issues",
headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
},
json={"title": title, "body": body},
)
resp.raise_for_status()
data = resp.json()
return f"Created issue #{data['number']}: {data['title']}"
except Exception as exc:
return f"Error creating issue: {exc}"
async def _read_issue(**kwargs: Any) -> str:
number = kwargs.get("number")
if not number:
return "Error: issue number is required"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{base_url}/api/v1/repos/{owner}/{repo}/issues/{number}",
headers={"Authorization": f"token {token}"},
)
resp.raise_for_status()
issue = resp.json()
labels = ", ".join(lb["name"] for lb in issue.get("labels", []))
parts = [
f"#{issue['number']}: {issue['title']}",
f"State: {issue['state']}",
]
if labels:
parts.append(f"Labels: {labels}")
if issue.get("body"):
parts.append(f"\n{issue['body']}")
return "\n".join(parts)
except Exception as exc:
return f"Error reading issue: {exc}"
return [
MCPToolDef(
name="list_issues",
description="List issues in the Gitea repository. Returns issue numbers and titles.",
parameters={
"type": "object",
"properties": {
"state": {
"type": "string",
"description": "Filter by state: open, closed, or all (default: open)",
},
"limit": {
"type": "integer",
"description": "Maximum number of issues to return (default: 10)",
},
},
"required": [],
},
handler=_list_issues,
),
MCPToolDef(
name="create_issue",
description="Create a new issue in the Gitea repository.",
parameters={
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Issue title (required)",
},
"body": {
"type": "string",
"description": "Issue body in markdown (optional)",
},
},
"required": ["title"],
},
handler=_create_issue,
),
MCPToolDef(
name="read_issue",
description="Read details of a specific issue by number.",
parameters={
"type": "object",
"properties": {
"number": {
"type": "integer",
"description": "Issue number to read",
},
},
"required": ["number"],
},
handler=_read_issue,
),
]
class MCPBridge:
"""Bridge between Ollama's tool-calling API and MCP tools.
Manages a set of tool definitions and executes a chat loop with
tool calling against a Qwen3 model via Ollama.
The bridge:
1. Registers available tools (Gitea, shell, custom)
2. Sends prompts to Ollama with tool definitions
3. Executes tool calls when the model requests them
4. Returns tool results to the model for the next round
5. Repeats until the model produces a final text response
Attributes:
model: Ollama model name (default from settings).
ollama_url: Ollama API base URL (default from settings).
tools: Registered tool definitions.
"""
def __init__(
self,
model: str | None = None,
ollama_url: str | None = None,
*,
include_gitea: bool = True,
include_shell: bool = True,
extra_tools: list[MCPToolDef] | None = None,
max_rounds: int = _MAX_TOOL_ROUNDS,
) -> None:
self.model = model or settings.ollama_model
self.ollama_url = ollama_url or settings.normalized_ollama_url
self.max_rounds = max_rounds
self._tools: dict[str, MCPToolDef] = {}
self._client: httpx.AsyncClient | None = None
# Register built-in tools
if include_gitea:
for tool in _build_gitea_tools():
self._tools[tool.name] = tool
if include_shell:
shell = _build_shell_tool()
if shell:
self._tools[shell.name] = shell
# Register extra tools
if extra_tools:
for tool in extra_tools:
self._tools[tool.name] = tool
logger.info(
"MCPBridge initialised: model=%s, tools=%s",
self.model,
list(self._tools.keys()),
)
async def __aenter__(self) -> MCPBridge:
self._client = httpx.AsyncClient(timeout=settings.mcp_bridge_timeout)
return self
async def __aexit__(self, *exc: Any) -> None:
if self._client:
await self._client.aclose()
self._client = None
@property
def tool_names(self) -> list[str]:
"""Return names of all registered tools."""
return list(self._tools.keys())
def _build_ollama_tools(self) -> list[dict]:
"""Convert registered tools to Ollama tool format."""
return [_mcp_schema_to_ollama_tool(t) for t in self._tools.values()]
async def _chat(self, messages: list[dict], tools: list[dict]) -> dict:
"""Send a chat request to Ollama and return the response.
Uses the ``/api/chat`` endpoint with tool definitions.
"""
if not self._client:
raise RuntimeError("MCPBridge must be used as async context manager")
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": False,
}
if tools:
payload["tools"] = tools
# Set num_ctx if configured
if settings.ollama_num_ctx > 0:
payload["options"] = {"num_ctx": settings.ollama_num_ctx}
resp = await self._client.post(
f"{self.ollama_url}/api/chat",
json=payload,
)
resp.raise_for_status()
return resp.json()
async def _execute_tool_call(self, tool_call: dict) -> str:
"""Execute a single tool call and return the result string."""
func = tool_call.get("function", {})
name = func.get("name", "")
arguments = func.get("arguments", {})
tool = self._tools.get(name)
if not tool:
return f"Error: unknown tool '{name}'"
try:
result = await tool.handler(**arguments)
return str(result)
except Exception as exc:
logger.warning("Tool '%s' execution failed: %s", name, exc)
return f"Error executing {name}: {exc}"
async def run(
self,
prompt: str,
*,
system_prompt: str | None = None,
) -> BridgeResult:
"""Run a prompt through the MCP bridge with tool calling.
Sends the prompt to the Ollama model with tool definitions.
If the model requests tool calls, executes them and feeds
results back until the model produces a final text response.
Args:
prompt: User message to send.
system_prompt: Optional system prompt override.
Returns:
BridgeResult with the final response and tool call history.
"""
start = time.time()
messages: list[dict] = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
tools = self._build_ollama_tools()
tool_calls_made: list[dict] = []
rounds = 0
try:
for round_num in range(self.max_rounds):
rounds = round_num + 1
response = await self._chat(messages, tools)
msg = response.get("message", {})
# Check if model made tool calls
model_tool_calls = msg.get("tool_calls", [])
if not model_tool_calls:
# Final text response — done.
content = msg.get("content", "")
latency = (time.time() - start) * 1000
return BridgeResult(
content=content,
tool_calls_made=tool_calls_made,
rounds=rounds,
latency_ms=latency,
model=self.model,
)
# Append the assistant message (with tool_calls) to history
messages.append(msg)
# Execute each tool call and add results
for tc in model_tool_calls:
func = tc.get("function", {})
tool_name = func.get("name", "unknown")
tool_args = func.get("arguments", {})
logger.info(
"Bridge tool call [round %d]: %s(%s)",
rounds,
tool_name,
tool_args,
)
result = await self._execute_tool_call(tc)
tool_calls_made.append(
{
"round": rounds,
"tool": tool_name,
"arguments": tool_args,
"result": result[:500], # Truncate for logging
}
)
# Add tool result to message history
messages.append(
{
"role": "tool",
"content": result,
}
)
# Hit max rounds
latency = (time.time() - start) * 1000
return BridgeResult(
content="(max tool-call rounds reached)",
tool_calls_made=tool_calls_made,
rounds=rounds,
latency_ms=latency,
model=self.model,
error=f"Exceeded maximum of {self.max_rounds} tool-call rounds",
)
except httpx.ConnectError as exc:
latency = (time.time() - start) * 1000
logger.warning("Ollama connection failed: %s", exc)
return BridgeResult(
content="",
tool_calls_made=tool_calls_made,
rounds=rounds,
latency_ms=latency,
model=self.model,
error=f"Ollama connection failed: {exc}",
)
except httpx.HTTPStatusError as exc:
latency = (time.time() - start) * 1000
logger.warning("Ollama HTTP error: %s", exc)
return BridgeResult(
content="",
tool_calls_made=tool_calls_made,
rounds=rounds,
latency_ms=latency,
model=self.model,
error=f"Ollama HTTP error: {exc.response.status_code}",
)
except Exception as exc:
latency = (time.time() - start) * 1000
logger.error("MCPBridge run failed: %s", exc)
return BridgeResult(
content="",
tool_calls_made=tool_calls_made,
rounds=rounds,
latency_ms=latency,
model=self.model,
error=str(exc),
)
def status(self) -> dict:
"""Return bridge status for the dashboard."""
return {
"model": self.model,
"ollama_url": self.ollama_url,
"tools": self.tool_names,
"max_rounds": self.max_rounds,
"connected": self._client is not None,
}

View File

@@ -9,35 +9,81 @@ Also includes vector similarity utilities (cosine similarity, keyword overlap).
import hashlib
import logging
import math
import json
import httpx # Import httpx for Ollama API calls
from config import settings
logger = logging.getLogger(__name__)
# Embedding model - small, fast, local
EMBEDDING_MODEL = None
EMBEDDING_DIM = 384 # MiniLM dimension
EMBEDDING_DIM = 384 # MiniLM dimension, will be overridden if Ollama model has different dim
class OllamaEmbedder:
"""Mimics SentenceTransformer interface for Ollama."""
def __init__(self, model_name: str, ollama_url: str):
self.model_name = model_name
self.ollama_url = ollama_url
self.dimension = 0 # Will be updated after first call
def encode(self, sentences: str | list[str], convert_to_numpy: bool = False, normalize_embeddings: bool = True) -> list[list[float]] | list[float]:
"""Generate embeddings using Ollama."""
if isinstance(sentences, str):
sentences = [sentences]
all_embeddings = []
for sentence in sentences:
try:
response = httpx.post(
f"{self.ollama_url}/api/embeddings",
json={"model": self.model_name, "prompt": sentence},
timeout=settings.mcp_bridge_timeout,
)
response.raise_for_status()
embedding = response.json()["embedding"]
if not self.dimension:
self.dimension = len(embedding) # Set dimension on first successful call
global EMBEDDING_DIM
EMBEDDING_DIM = self.dimension # Update global EMBEDDING_DIM
all_embeddings.append(embedding)
except httpx.RequestError as exc:
logger.error("Ollama embeddings request failed: %s", exc)
# Fallback to simple hash embedding on Ollama error
return _simple_hash_embedding(sentence)
except json.JSONDecodeError as exc:
logger.error("Failed to decode Ollama embeddings response: %s", exc)
return _simple_hash_embedding(sentence)
if len(all_embeddings) == 1 and isinstance(sentences, str):
return all_embeddings[0]
return all_embeddings
def _get_embedding_model():
"""Lazy-load embedding model."""
"""Lazy-load embedding model, preferring Ollama if configured."""
global EMBEDDING_MODEL
global EMBEDDING_DIM
if EMBEDDING_MODEL is None:
try:
from config import settings
if settings.timmy_skip_embeddings:
EMBEDDING_MODEL = False
return EMBEDDING_MODEL
if settings.timmy_skip_embeddings:
EMBEDDING_MODEL = False
return EMBEDDING_MODEL
except ImportError:
pass
if settings.timmy_embedding_backend == "ollama":
logger.info("MemorySystem: Using Ollama for embeddings with model %s", settings.ollama_embedding_model)
EMBEDDING_MODEL = OllamaEmbedder(settings.ollama_embedding_model, settings.normalized_ollama_url)
# We don't know the dimension until after the first call, so keep it default for now.
# It will be updated dynamically in OllamaEmbedder.encode
return EMBEDDING_MODEL
else:
try:
from sentence_transformers import SentenceTransformer
try:
from sentence_transformers import SentenceTransformer
EMBEDDING_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
logger.info("MemorySystem: Loaded embedding model")
except ImportError:
logger.warning("MemorySystem: sentence-transformers not installed, using fallback")
EMBEDDING_MODEL = False # Use fallback
EMBEDDING_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
EMBEDDING_DIM = 384 # Reset to MiniLM dimension
logger.info("MemorySystem: Loaded local embedding model (all-MiniLM-L6-v2)")
except ImportError:
logger.warning("MemorySystem: sentence-transformers not installed, using fallback")
EMBEDDING_MODEL = False # Use fallback
return EMBEDDING_MODEL
@@ -60,10 +106,14 @@ def embed_text(text: str) -> list[float]:
model = _get_embedding_model()
if model and model is not False:
embedding = model.encode(text)
return embedding.tolist()
# Ensure it's a list of floats, not numpy array
if hasattr(embedding, 'tolist'):
return embedding.tolist()
return embedding
return _simple_hash_embedding(text)
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Calculate cosine similarity between two vectors."""
dot = sum(x * y for x, y in zip(a, b, strict=False))

View File

@@ -14,6 +14,8 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
# Paths
@@ -28,7 +30,7 @@ def get_connection() -> Generator[sqlite3.Connection, None, None]:
with closing(sqlite3.connect(str(DB_PATH))) as conn:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(f"PRAGMA busy_timeout={settings.db_busy_timeout_ms}")
_ensure_schema(conn)
yield conn

View File

@@ -20,6 +20,7 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from config import settings
from timmy.memory.embeddings import (
EMBEDDING_DIM,
EMBEDDING_MODEL, # noqa: F401 — re-exported for backward compatibility
@@ -111,7 +112,7 @@ def get_connection() -> Generator[sqlite3.Connection, None, None]:
with closing(sqlite3.connect(str(DB_PATH))) as conn:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(f"PRAGMA busy_timeout={settings.db_busy_timeout_ms}")
_ensure_schema(conn)
yield conn
@@ -949,7 +950,7 @@ class SemanticMemory:
with closing(sqlite3.connect(str(self.db_path))) as conn:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(f"PRAGMA busy_timeout={settings.db_busy_timeout_ms}")
# Ensure schema exists
conn.execute("""
CREATE TABLE IF NOT EXISTS memories (
@@ -1205,7 +1206,7 @@ memory_searcher = MemorySearcher()
# ───────────────────────────────────────────────────────────────────────────────
def memory_search(query: str, top_k: int = 5) -> str:
def memory_search(query: str, limit: int = 10) -> str:
"""Search past conversations, notes, and stored facts for relevant context.
Searches across both the vault (indexed markdown files) and the
@@ -1214,19 +1215,19 @@ def memory_search(query: str, top_k: int = 5) -> str:
Args:
query: What to search for (e.g. "Bitcoin strategy", "server setup").
top_k: Number of results to return (default 5).
limit: Number of results to return (default 10).
Returns:
Formatted string of relevant memory results.
"""
# Guard: model sometimes passes None for top_k
if top_k is None:
top_k = 5
# Guard: model sometimes passes None for limit
if limit is None:
limit = 10
parts: list[str] = []
# 1. Search semantic vault (indexed markdown files)
vault_results = semantic_memory.search(query, top_k)
vault_results = semantic_memory.search(query, limit)
for content, score in vault_results:
if score < 0.2:
continue
@@ -1234,7 +1235,7 @@ def memory_search(query: str, top_k: int = 5) -> str:
# 2. Search runtime vector store (stored facts/conversations)
try:
runtime_results = search_memories(query, limit=top_k, min_relevance=0.2)
runtime_results = search_memories(query, limit=limit, min_relevance=0.2)
for entry in runtime_results:
label = entry.context_type or "memory"
parts.append(f"[{label}] {entry.content[:300]}")
@@ -1288,45 +1289,48 @@ def memory_read(query: str = "", top_k: int = 5) -> str:
return "\n".join(parts)
def memory_write(content: str, context_type: str = "fact") -> str:
"""Store a piece of information in persistent memory.
def memory_store(topic: str, report: str, type: str = "research") -> str:
"""Store a piece of information in persistent memory, particularly for research outputs.
Use this tool when the user explicitly asks you to remember something.
Stored memories are searchable via memory_search across all channels
(web GUI, Discord, Telegram, etc.).
Use this tool to store structured research findings or other important documents.
Stored memories are searchable via memory_search across all channels.
Args:
content: The information to remember (e.g. a phrase, fact, or note).
context_type: Type of memory — "fact" for permanent facts,
"conversation" for conversation context,
"document" for document fragments.
topic: A concise title or topic for the research output.
report: The detailed content of the research output or document.
type: Type of memory — "research" for research outputs (default),
"fact" for permanent facts, "conversation" for conversation context,
"document" for other document fragments.
Returns:
Confirmation that the memory was stored.
"""
if not content or not content.strip():
return "Nothing to store — content is empty."
if not report or not report.strip():
return "Nothing to store — report is empty."
valid_types = ("fact", "conversation", "document")
if context_type not in valid_types:
context_type = "fact"
# Combine topic and report for embedding and storage content
full_content = f"Topic: {topic.strip()}\n\nReport: {report.strip()}"
valid_types = ("fact", "conversation", "document", "research")
if type not in valid_types:
type = "research"
try:
# Dedup check for facts — skip if a similar fact already exists
# Threshold 0.75 catches paraphrases (was 0.9 which only caught near-exact)
if context_type == "fact":
# Dedup check for facts and research — skip if similar exists
if type in ("fact", "research"):
existing = search_memories(
content.strip(), limit=3, context_type="fact", min_relevance=0.75
full_content, limit=3, context_type=type, min_relevance=0.75
)
if existing:
return f"Similar fact already stored (id={existing[0].id[:8]}). Skipping duplicate."
return f"Similar {type} already stored (id={existing[0].id[:8]}). Skipping duplicate."
entry = store_memory(
content=content.strip(),
content=full_content,
source="agent",
context_type=context_type,
context_type=type,
metadata={"topic": topic},
)
return f"Stored in memory (type={context_type}, id={entry.id[:8]}). This is now searchable across all channels."
return f"Stored in memory (type={type}, id={entry.id[:8]}). This is now searchable across all channels."
except Exception as exc:
logger.error("Failed to write memory: %s", exc)
return f"Failed to store memory: {exc}"

175
src/timmy/paperclip.py Normal file
View File

@@ -0,0 +1,175 @@
"""Paperclip integration for Timmy.
This module provides a client for the Paperclip API, and a poller for
running research tasks.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
import httpx
from config import settings
from timmy.research_triage import triage_research_report
from timmy.research_tools import google_web_search, get_llm_client
logger = logging.getLogger(__name__)
@dataclass
class PaperclipTask:
"""A task from the Paperclip API."""
id: str
kind: str
context: dict
class PaperclipClient:
"""A client for the Paperclip API."""
def __init__(self) -> None:
self.base_url = settings.paperclip_url
self.api_key = settings.paperclip_api_key
self.agent_id = settings.paperclip_agent_id
self.company_id = settings.paperclip_company_id
self.timeout = settings.paperclip_timeout
async def get_tasks(self) -> list[PaperclipTask]:
"""Get a list of tasks from the Paperclip API."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.get(
f"{self.base_url}/api/tasks",
headers={"Authorization": f"Bearer {self.api_key}"},
params={
"agent_id": self.agent_id,
"company_id": self.company_id,
"status": "queued",
},
)
resp.raise_for_status()
tasks = resp.json()
return [
PaperclipTask(id=t["id"], kind=t["kind"], context=t["context"])
for t in tasks
]
async def update_task_status(
self, task_id: str, status: str, result: str | None = None
) -> None:
"""Update the status of a task."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
await client.patch(
f"{self.base_url}/api/tasks/{task_id}",
headers={"Authorization": f"Bearer {self.api_key}"},
json={"status": status, "result": result},
)
class ResearchOrchestrator:
"""Orchestrates research tasks."""
async def get_gitea_issue(self, issue_number: int) -> dict:
"""Get a Gitea issue by its number."""
owner, repo = settings.gitea_repo.split("/", 1)
api_url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}"
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
api_url,
headers={"Authorization": f"token {settings.gitea_token}"},
)
resp.raise_for_status()
return resp.json()
async def post_gitea_comment(self, issue_number: int, comment: str) -> None:
"""Post a comment to a Gitea issue."""
owner, repo = settings.gitea_repo.split("/", 1)
api_url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments"
async with httpx.AsyncClient(timeout=15) as client:
await client.post(
api_url,
headers={"Authorization": f"token {settings.gitea_token}"},
json={"body": comment},
)
async def run_research_pipeline(self, issue_title: str) -> str:
"""Run the research pipeline."""
search_results = await google_web_search(issue_title)
llm_client = get_llm_client()
response = await llm_client.completion(
f"Summarize the following search results and generate a research report:\\n\\n{search_results}",
max_tokens=2048,
)
return response.text
async def run(self, context: dict) -> str:
"""Run a research task."""
issue_number = context.get("issue_number")
if not issue_number:
return "Missing issue_number in task context"
issue = await self.get_gitea_issue(issue_number)
report = await self.run_research_pipeline(issue["title"])
triage_results = await triage_research_report(report, source_issue=issue_number)
comment = f"Research complete for issue #{issue_number}.\\n\\n"
if triage_results:
comment += "Created the following issues:\\n"
for result in triage_results:
if result["gitea_issue"]:
comment += f"- #{result['gitea_issue']['number']}: {result['action_item'].title}\\n"
else:
comment += "No new issues were created.\\n"
await self.post_gitea_comment(issue_number, comment)
return f"Research complete for issue #{issue_number}"
class PaperclipPoller:
"""Polls the Paperclip API for new tasks."""
def __init__(self) -> None:
self.client = PaperclipClient()
self.orchestrator = ResearchOrchestrator()
self.poll_interval = settings.paperclip_poll_interval
async def poll(self) -> None:
"""Poll the Paperclip API for new tasks."""
if self.poll_interval == 0:
return
while True:
try:
tasks = await self.client.get_tasks()
for task in tasks:
if task.kind == "research":
await self.run_research_task(task)
except httpx.HTTPError as exc:
logger.warning("Error polling Paperclip: %s", exc)
await asyncio.sleep(self.poll_interval)
async def run_research_task(self, task: PaperclipTask) -> None:
"""Run a research task."""
await self.client.update_task_status(task.id, "running")
try:
result = await self.orchestrator.run(task.context)
await self.client.update_task_status(task.id, "completed", result)
except Exception as exc:
logger.error("Error running research task: %s", exc, exc_info=True)
await self.client.update_task_status(task.id, "failed", str(exc))
async def start_paperclip_poller() -> None:
"""Start the Paperclip poller."""
if settings.paperclip_enabled:
poller = PaperclipPoller()
asyncio.create_task(poller.poll())

581
src/timmy/quest_system.py Normal file
View File

@@ -0,0 +1,581 @@
"""Token Quest System for agent rewards.
Provides quest definitions, progress tracking, completion detection,
and token awards for agent accomplishments.
Quests are defined in config/quests.yaml and loaded at runtime.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from pathlib import Path
from typing import Any
import yaml
from config import settings
logger = logging.getLogger(__name__)
# Path to quest configuration
QUEST_CONFIG_PATH = Path(settings.repo_root) / "config" / "quests.yaml"
class QuestType(StrEnum):
"""Types of quests supported by the system."""
ISSUE_COUNT = "issue_count"
ISSUE_REDUCE = "issue_reduce"
DOCS_UPDATE = "docs_update"
TEST_IMPROVE = "test_improve"
DAILY_RUN = "daily_run"
CUSTOM = "custom"
class QuestStatus(StrEnum):
"""Status of a quest for an agent."""
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CLAIMED = "claimed"
EXPIRED = "expired"
@dataclass
class QuestDefinition:
"""Definition of a quest from configuration."""
id: str
name: str
description: str
reward_tokens: int
quest_type: QuestType
enabled: bool
repeatable: bool
cooldown_hours: int
criteria: dict[str, Any]
notification_message: str
@classmethod
def from_dict(cls, data: dict[str, Any]) -> QuestDefinition:
"""Create a QuestDefinition from a dictionary."""
return cls(
id=data["id"],
name=data.get("name", "Unnamed Quest"),
description=data.get("description", ""),
reward_tokens=data.get("reward_tokens", 0),
quest_type=QuestType(data.get("type", "custom")),
enabled=data.get("enabled", True),
repeatable=data.get("repeatable", False),
cooldown_hours=data.get("cooldown_hours", 0),
criteria=data.get("criteria", {}),
notification_message=data.get(
"notification_message", "Quest Complete! You earned {tokens} tokens."
),
)
@dataclass
class QuestProgress:
"""Progress of a quest for a specific agent."""
quest_id: str
agent_id: str
status: QuestStatus
current_value: int = 0
target_value: int = 0
started_at: str = ""
completed_at: str = ""
claimed_at: str = ""
completion_count: int = 0
last_completed_at: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"quest_id": self.quest_id,
"agent_id": self.agent_id,
"status": self.status.value,
"current_value": self.current_value,
"target_value": self.target_value,
"started_at": self.started_at,
"completed_at": self.completed_at,
"claimed_at": self.claimed_at,
"completion_count": self.completion_count,
"last_completed_at": self.last_completed_at,
"metadata": self.metadata,
}
# In-memory storage for quest progress
_quest_progress: dict[str, QuestProgress] = {}
_quest_definitions: dict[str, QuestDefinition] = {}
_quest_settings: dict[str, Any] = {}
def _get_progress_key(quest_id: str, agent_id: str) -> str:
"""Generate a unique key for quest progress."""
return f"{agent_id}:{quest_id}"
def load_quest_config() -> tuple[dict[str, QuestDefinition], dict[str, Any]]:
"""Load quest definitions from quests.yaml.
Returns:
Tuple of (quest definitions dict, settings dict)
"""
global _quest_definitions, _quest_settings
if not QUEST_CONFIG_PATH.exists():
logger.warning("Quest config not found at %s", QUEST_CONFIG_PATH)
return {}, {}
try:
raw = QUEST_CONFIG_PATH.read_text()
config = yaml.safe_load(raw)
if not isinstance(config, dict):
logger.warning("Invalid quest config format")
return {}, {}
# Load quest definitions
quests_data = config.get("quests", {})
definitions = {}
for quest_id, quest_data in quests_data.items():
quest_data["id"] = quest_id
try:
definition = QuestDefinition.from_dict(quest_data)
definitions[quest_id] = definition
except (ValueError, KeyError) as exc:
logger.warning("Failed to load quest %s: %s", quest_id, exc)
# Load settings
_quest_settings = config.get("settings", {})
_quest_definitions = definitions
logger.debug("Loaded %d quest definitions", len(definitions))
return definitions, _quest_settings
except (OSError, yaml.YAMLError) as exc:
logger.warning("Failed to load quest config: %s", exc)
return {}, {}
def get_quest_definitions() -> dict[str, QuestDefinition]:
"""Get all quest definitions, loading if necessary."""
global _quest_definitions
if not _quest_definitions:
_quest_definitions, _ = load_quest_config()
return _quest_definitions
def get_quest_definition(quest_id: str) -> QuestDefinition | None:
"""Get a specific quest definition by ID."""
definitions = get_quest_definitions()
return definitions.get(quest_id)
def get_active_quests() -> list[QuestDefinition]:
"""Get all enabled quest definitions."""
definitions = get_quest_definitions()
return [q for q in definitions.values() if q.enabled]
def get_quest_progress(quest_id: str, agent_id: str) -> QuestProgress | None:
"""Get progress for a specific quest and agent."""
key = _get_progress_key(quest_id, agent_id)
return _quest_progress.get(key)
def get_or_create_progress(quest_id: str, agent_id: str) -> QuestProgress:
"""Get existing progress or create new for quest/agent."""
key = _get_progress_key(quest_id, agent_id)
if key not in _quest_progress:
quest = get_quest_definition(quest_id)
if not quest:
raise ValueError(f"Quest {quest_id} not found")
target = _get_target_value(quest)
_quest_progress[key] = QuestProgress(
quest_id=quest_id,
agent_id=agent_id,
status=QuestStatus.NOT_STARTED,
current_value=0,
target_value=target,
started_at=datetime.now(UTC).isoformat(),
)
return _quest_progress[key]
def _get_target_value(quest: QuestDefinition) -> int:
"""Extract target value from quest criteria."""
criteria = quest.criteria
if quest.quest_type == QuestType.ISSUE_COUNT:
return criteria.get("target_count", 1)
elif quest.quest_type == QuestType.ISSUE_REDUCE:
return criteria.get("target_reduction", 1)
elif quest.quest_type == QuestType.DAILY_RUN:
return criteria.get("min_sessions", 1)
elif quest.quest_type == QuestType.DOCS_UPDATE:
return criteria.get("min_files_changed", 1)
elif quest.quest_type == QuestType.TEST_IMPROVE:
return criteria.get("min_new_tests", 1)
return 1
def update_quest_progress(
quest_id: str,
agent_id: str,
current_value: int,
metadata: dict[str, Any] | None = None,
) -> QuestProgress:
"""Update progress for a quest."""
progress = get_or_create_progress(quest_id, agent_id)
progress.current_value = current_value
if metadata:
progress.metadata.update(metadata)
# Check if quest is now complete
if progress.current_value >= progress.target_value:
if progress.status not in (QuestStatus.COMPLETED, QuestStatus.CLAIMED):
progress.status = QuestStatus.COMPLETED
progress.completed_at = datetime.now(UTC).isoformat()
logger.info("Quest %s completed for agent %s", quest_id, agent_id)
return progress
def _is_on_cooldown(progress: QuestProgress, quest: QuestDefinition) -> bool:
"""Check if a repeatable quest is on cooldown."""
if not quest.repeatable or not progress.last_completed_at:
return False
if quest.cooldown_hours <= 0:
return False
try:
last_completed = datetime.fromisoformat(progress.last_completed_at)
cooldown_end = last_completed + timedelta(hours=quest.cooldown_hours)
return datetime.now(UTC) < cooldown_end
except (ValueError, TypeError):
return False
def claim_quest_reward(quest_id: str, agent_id: str) -> dict[str, Any] | None:
"""Claim the token reward for a completed quest.
Returns:
Reward info dict if successful, None if not claimable
"""
progress = get_quest_progress(quest_id, agent_id)
if not progress:
return None
quest = get_quest_definition(quest_id)
if not quest:
return None
# Check if quest is completed but not yet claimed
if progress.status != QuestStatus.COMPLETED:
return None
# Check cooldown for repeatable quests
if _is_on_cooldown(progress, quest):
return None
try:
# Award tokens via ledger
from lightning.ledger import create_invoice_entry, mark_settled
# Create a mock invoice for the reward
invoice_entry = create_invoice_entry(
payment_hash=f"quest_{quest_id}_{agent_id}_{int(time.time())}",
amount_sats=quest.reward_tokens,
memo=f"Quest reward: {quest.name}",
source="quest_reward",
agent_id=agent_id,
)
# Mark as settled immediately (quest rewards are auto-settled)
mark_settled(invoice_entry.payment_hash, preimage=f"quest_{quest_id}")
# Update progress
progress.status = QuestStatus.CLAIMED
progress.claimed_at = datetime.now(UTC).isoformat()
progress.completion_count += 1
progress.last_completed_at = progress.claimed_at
# Reset for repeatable quests
if quest.repeatable:
progress.status = QuestStatus.NOT_STARTED
progress.current_value = 0
progress.completed_at = ""
progress.claimed_at = ""
notification = quest.notification_message.format(tokens=quest.reward_tokens)
return {
"quest_id": quest_id,
"agent_id": agent_id,
"tokens_awarded": quest.reward_tokens,
"notification": notification,
"completion_count": progress.completion_count,
}
except Exception as exc:
logger.error("Failed to award quest reward: %s", exc)
return None
def check_issue_count_quest(
quest: QuestDefinition,
agent_id: str,
closed_issues: list[dict],
) -> QuestProgress | None:
"""Check progress for issue_count type quest."""
criteria = quest.criteria
target_labels = set(criteria.get("issue_labels", []))
# target_count is available in criteria but not used directly here
# Count matching issues
matching_count = 0
for issue in closed_issues:
issue_labels = {label.get("name", "") for label in issue.get("labels", [])}
if target_labels.issubset(issue_labels) or (not target_labels and issue_labels):
matching_count += 1
progress = update_quest_progress(
quest.id, agent_id, matching_count, {"matching_issues": matching_count}
)
return progress
def check_issue_reduce_quest(
quest: QuestDefinition,
agent_id: str,
previous_count: int,
current_count: int,
) -> QuestProgress | None:
"""Check progress for issue_reduce type quest."""
# target_reduction available in quest.criteria but we track actual reduction
reduction = max(0, previous_count - current_count)
progress = update_quest_progress(quest.id, agent_id, reduction, {"reduction": reduction})
return progress
def check_daily_run_quest(
quest: QuestDefinition,
agent_id: str,
sessions_completed: int,
) -> QuestProgress | None:
"""Check progress for daily_run type quest."""
# min_sessions available in quest.criteria but we track actual sessions
progress = update_quest_progress(
quest.id, agent_id, sessions_completed, {"sessions": sessions_completed}
)
return progress
def evaluate_quest_progress(
quest_id: str,
agent_id: str,
context: dict[str, Any],
) -> QuestProgress | None:
"""Evaluate quest progress based on quest type and context.
Args:
quest_id: The quest to evaluate
agent_id: The agent to evaluate for
context: Context data for evaluation (issues, metrics, etc.)
Returns:
Updated QuestProgress or None if evaluation failed
"""
quest = get_quest_definition(quest_id)
if not quest or not quest.enabled:
return None
progress = get_quest_progress(quest_id, agent_id)
# Check cooldown for repeatable quests
if progress and _is_on_cooldown(progress, quest):
return progress
try:
if quest.quest_type == QuestType.ISSUE_COUNT:
closed_issues = context.get("closed_issues", [])
return check_issue_count_quest(quest, agent_id, closed_issues)
elif quest.quest_type == QuestType.ISSUE_REDUCE:
prev_count = context.get("previous_issue_count", 0)
curr_count = context.get("current_issue_count", 0)
return check_issue_reduce_quest(quest, agent_id, prev_count, curr_count)
elif quest.quest_type == QuestType.DAILY_RUN:
sessions = context.get("sessions_completed", 0)
return check_daily_run_quest(quest, agent_id, sessions)
elif quest.quest_type == QuestType.CUSTOM:
# Custom quests require manual completion
return progress
else:
logger.debug("Quest type %s not yet implemented", quest.quest_type)
return progress
except Exception as exc:
logger.warning("Quest evaluation failed for %s: %s", quest_id, exc)
return progress
def auto_evaluate_all_quests(agent_id: str, context: dict[str, Any]) -> list[dict]:
"""Evaluate all active quests for an agent and award rewards.
Returns:
List of reward info for newly completed quests
"""
rewards = []
active_quests = get_active_quests()
for quest in active_quests:
progress = evaluate_quest_progress(quest.id, agent_id, context)
if progress and progress.status == QuestStatus.COMPLETED:
# Auto-claim the reward
reward = claim_quest_reward(quest.id, agent_id)
if reward:
rewards.append(reward)
return rewards
def get_agent_quests_status(agent_id: str) -> dict[str, Any]:
"""Get complete quest status for an agent."""
definitions = get_quest_definitions()
quests_status = []
total_rewards = 0
completed_count = 0
for quest_id, quest in definitions.items():
progress = get_quest_progress(quest_id, agent_id)
if not progress:
progress = get_or_create_progress(quest_id, agent_id)
is_on_cooldown = _is_on_cooldown(progress, quest) if quest.repeatable else False
quest_info = {
"quest_id": quest_id,
"name": quest.name,
"description": quest.description,
"reward_tokens": quest.reward_tokens,
"type": quest.quest_type.value,
"enabled": quest.enabled,
"repeatable": quest.repeatable,
"status": progress.status.value,
"current_value": progress.current_value,
"target_value": progress.target_value,
"completion_count": progress.completion_count,
"on_cooldown": is_on_cooldown,
"cooldown_hours_remaining": 0,
}
if is_on_cooldown and progress.last_completed_at:
try:
last = datetime.fromisoformat(progress.last_completed_at)
cooldown_end = last + timedelta(hours=quest.cooldown_hours)
hours_remaining = (cooldown_end - datetime.now(UTC)).total_seconds() / 3600
quest_info["cooldown_hours_remaining"] = round(max(0, hours_remaining), 1)
except (ValueError, TypeError):
pass
quests_status.append(quest_info)
total_rewards += progress.completion_count * quest.reward_tokens
completed_count += progress.completion_count
return {
"agent_id": agent_id,
"quests": quests_status,
"total_tokens_earned": total_rewards,
"total_quests_completed": completed_count,
"active_quests_count": len([q for q in quests_status if q["enabled"]]),
}
def reset_quest_progress(quest_id: str | None = None, agent_id: str | None = None) -> int:
"""Reset quest progress. Useful for testing.
Args:
quest_id: Specific quest to reset, or None for all
agent_id: Specific agent to reset, or None for all
Returns:
Number of progress entries reset
"""
global _quest_progress
count = 0
keys_to_reset = []
for key, _progress in _quest_progress.items():
key_agent, key_quest = key.split(":", 1)
if (quest_id is None or key_quest == quest_id) and (
agent_id is None or key_agent == agent_id
):
keys_to_reset.append(key)
for key in keys_to_reset:
del _quest_progress[key]
count += 1
return count
def get_quest_leaderboard() -> list[dict[str, Any]]:
"""Get a leaderboard of agents by quest completion."""
agent_stats: dict[str, dict[str, Any]] = {}
for _key, progress in _quest_progress.items():
agent_id = progress.agent_id
if agent_id not in agent_stats:
agent_stats[agent_id] = {
"agent_id": agent_id,
"total_completions": 0,
"total_tokens": 0,
"quests_completed": set(),
}
quest = get_quest_definition(progress.quest_id)
if quest:
agent_stats[agent_id]["total_completions"] += progress.completion_count
agent_stats[agent_id]["total_tokens"] += progress.completion_count * quest.reward_tokens
if progress.completion_count > 0:
agent_stats[agent_id]["quests_completed"].add(quest.id)
leaderboard = []
for stats in agent_stats.values():
leaderboard.append(
{
"agent_id": stats["agent_id"],
"total_completions": stats["total_completions"],
"total_tokens": stats["total_tokens"],
"unique_quests_completed": len(stats["quests_completed"]),
}
)
# Sort by total tokens (descending)
leaderboard.sort(key=lambda x: x["total_tokens"], reverse=True)
return leaderboard
# Initialize on module load
load_quest_config()

View File

@@ -0,0 +1,42 @@
"""Tools for the research pipeline."""
from __future__ import annotations
import logging
import os
from typing import Any
from config import settings
from serpapi import GoogleSearch
logger = logging.getLogger(__name__)
async def google_web_search(query: str) -> str:
"""Perform a Google search and return the results."""
if "SERPAPI_API_KEY" not in os.environ:
logger.warning("SERPAPI_API_KEY not set, skipping web search")
return ""
params = {
"q": query,
"api_key": os.environ["SERPAPI_API_KEY"],
}
search = GoogleSearch(params)
results = search.get_dict()
return str(results)
def get_llm_client() -> Any:
"""Get an LLM client."""
# This is a placeholder. In a real application, this would return
# a client for an LLM service like OpenAI, Anthropic, or a local
# model.
class MockLLMClient:
async def completion(self, prompt: str, max_tokens: int) -> Any:
class MockCompletion:
def __init__(self, text: str) -> None:
self.text = text
return MockCompletion(f"This is a summary of the search results for '{prompt}'.")
return MockLLMClient()

View File

@@ -0,0 +1,367 @@
"""Research triage — extract action items from research reports and file Gitea issues.
Closes the loop: research → knowledge → actionable engineering work.
The LLM extracts action items during synthesis (not post-processed), then
each item is filed as a Gitea issue with appropriate labels, source links,
and evidence from the original research.
Usage::
from timmy.research_triage import triage_research_report
results = await triage_research_report(
report="## Findings\\n...",
source_issue=946,
)
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any
import httpx
from config import settings
logger = logging.getLogger(__name__)
# Regex to strip markdown code fences from LLM output
_FENCE_RE = re.compile(r"^```(?:json)?\s*\n?", re.MULTILINE)
@dataclass
class ActionItem:
"""A single actionable item extracted from a research report."""
title: str
body: str
labels: list[str] = field(default_factory=list)
priority: str = "medium"
source_urls: list[str] = field(default_factory=list)
def to_issue_body(self, source_issue: int | None = None) -> str:
"""Format for a Gitea issue body with source attribution."""
parts = [self.body]
if self.source_urls:
parts.append("\n### Source Evidence")
for url in self.source_urls:
parts.append(f"- {url}")
if source_issue:
parts.append(f"\n### Origin\nExtracted from research in #{source_issue}")
parts.append("\n---\n*Auto-triaged from research findings by Timmy*")
return "\n".join(parts)
def _build_extraction_prompt(report: str) -> str:
"""Build the LLM prompt for extracting action items from a research report."""
return (
"You are triaging a research report for actionable engineering work.\n"
"Extract 0-5 CONCRETE action items — bugs to fix, features to build,\n"
"infrastructure to set up, or investigations to run.\n\n"
"Rules:\n"
"- Only include items that map to real engineering tasks\n"
"- Skip vague recommendations or philosophical observations\n"
"- Each item should be specific enough to become a Gitea issue\n"
"- Include evidence/URLs from the report in source_urls\n"
"- Priority: high (blocking or critical), medium (important), low (nice-to-have)\n"
"- Labels: pick from [actionable, research, bug, feature, infrastructure, "
"performance, security, kimi-ready]\n"
" - 'kimi-ready' means a well-scoped task suitable for an AI agent\n"
" - 'actionable' should be on every item (these are all actionable)\n\n"
"For each item return:\n"
'- "title": Clear, specific title with area prefix '
'(e.g. "[MCP] Restore tool server with FastMCP")\n'
'- "body": Detailed markdown body with:\n'
" **What:** What needs to be done\n"
" **Why:** Why this matters (link to research finding)\n"
" **Suggested approach:** How to implement\n"
" **Acceptance criteria:** How to verify\n"
'- "labels": Array of label strings\n'
'- "priority": One of high, medium, low\n'
'- "source_urls": Array of URLs referenced in the research\n\n'
"Return ONLY a JSON array of objects. Return [] if nothing is actionable.\n\n"
f"Research report:\n{report}\n\nJSON array:"
)
def _parse_llm_response(raw: str) -> list[dict[str, Any]]:
"""Parse LLM JSON response, stripping code fences if present."""
cleaned = raw.strip()
# Strip markdown code fences
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
items = json.loads(cleaned)
if not isinstance(items, list):
return []
return items
def _validate_action_item(raw_item: dict[str, Any]) -> ActionItem | None:
"""Validate and convert a raw dict to an ActionItem, or None if invalid."""
if not isinstance(raw_item, dict):
return None
title = raw_item.get("title", "").strip()
body = raw_item.get("body", "").strip()
if not title or len(title) < 10:
return None
if not body or len(body) < 20:
return None
labels = raw_item.get("labels", [])
if isinstance(labels, str):
labels = [lbl.strip() for lbl in labels.split(",") if lbl.strip()]
if not isinstance(labels, list):
labels = []
# Ensure 'actionable' label is always present
if "actionable" not in labels:
labels.insert(0, "actionable")
priority = raw_item.get("priority", "medium").strip().lower()
if priority not in ("high", "medium", "low"):
priority = "medium"
source_urls = raw_item.get("source_urls", [])
if not isinstance(source_urls, list):
source_urls = []
return ActionItem(
title=title,
body=body,
labels=labels,
priority=priority,
source_urls=source_urls,
)
async def extract_action_items(
report: str,
llm_caller: Any | None = None,
) -> list[ActionItem]:
"""Extract actionable engineering items from a research report.
Uses the LLM to identify concrete tasks, bugs, features, and
infrastructure work from structured research output.
Args:
report: The research report text (markdown).
llm_caller: Optional async callable(prompt) -> str for LLM.
Falls back to the cascade router.
Returns:
List of validated ActionItem objects (0-5 items).
"""
if not report or not report.strip():
return []
prompt = _build_extraction_prompt(report)
try:
if llm_caller is not None:
raw = await llm_caller(prompt)
else:
raw = await _call_llm(prompt)
except Exception as exc:
logger.warning("LLM extraction failed: %s", exc)
return []
if not raw or not raw.strip():
return []
try:
raw_items = _parse_llm_response(raw)
except (json.JSONDecodeError, ValueError) as exc:
logger.warning("Failed to parse LLM action items: %s", exc)
return []
items = []
for raw_item in raw_items[:5]: # Safety cap
item = _validate_action_item(raw_item)
if item is not None:
items.append(item)
logger.info("Extracted %d action items from research report", len(items))
return items
async def _call_llm(prompt: str) -> str:
"""Call the cascade router for LLM completion.
Falls back gracefully if the router is unavailable.
"""
from infrastructure.router import get_router
router = get_router()
messages = [{"role": "user", "content": prompt}]
result = await router.complete(messages=messages, temperature=0.1)
return result.get("content", "") if isinstance(result, dict) else str(result)
async def create_gitea_issue(
item: ActionItem,
source_issue: int | None = None,
) -> dict[str, Any] | None:
"""Create a Gitea issue from an ActionItem via the REST API.
Args:
item: The action item to file.
source_issue: Parent research issue number to link back to.
Returns:
The created issue dict from Gitea API, or None on failure.
"""
if not settings.gitea_enabled or not settings.gitea_token:
logger.debug("Gitea not configured — skipping issue creation")
return None
owner, repo = settings.gitea_repo.split("/", 1)
api_url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/issues"
body = item.to_issue_body(source_issue=source_issue)
payload: dict[str, Any] = {
"title": item.title,
"body": body,
}
# Resolve label names to IDs
label_ids = await _resolve_label_ids(item.labels, owner, repo)
if label_ids:
payload["labels"] = label_ids
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
api_url,
headers={
"Authorization": f"token {settings.gitea_token}",
"Content-Type": "application/json",
},
json=payload,
)
if resp.status_code in (200, 201):
issue_data = resp.json()
logger.info(
"Created Gitea issue #%s: %s",
issue_data.get("number", "?"),
item.title[:60],
)
return issue_data
logger.warning(
"Gitea issue creation failed (HTTP %s): %s",
resp.status_code,
resp.text[:200],
)
return None
except (httpx.ConnectError, httpx.ReadError, ConnectionError) as exc:
logger.warning("Gitea connection failed: %s", exc)
return None
except Exception as exc:
logger.error("Unexpected error creating Gitea issue: %s", exc)
return None
async def _resolve_label_ids(
label_names: list[str],
owner: str,
repo: str,
) -> list[int]:
"""Resolve label names to Gitea label IDs, creating missing labels.
Returns a list of integer label IDs for the issue payload.
"""
if not label_names:
return []
labels_url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/labels"
headers = {
"Authorization": f"token {settings.gitea_token}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=10) as client:
# Fetch existing labels
resp = await client.get(labels_url, headers=headers)
if resp.status_code != 200:
return []
existing = {lbl["name"]: lbl["id"] for lbl in resp.json()}
label_ids = []
for name in label_names:
if name in existing:
label_ids.append(existing[name])
else:
# Auto-create missing labels with a default color
create_resp = await client.post(
labels_url,
headers=headers,
json={"name": name, "color": "#0075ca"},
)
if create_resp.status_code in (200, 201):
label_ids.append(create_resp.json()["id"])
return label_ids
except Exception as exc:
logger.debug("Label resolution failed: %s", exc)
return []
async def triage_research_report(
report: str,
source_issue: int | None = None,
llm_caller: Any | None = None,
dry_run: bool = False,
) -> list[dict[str, Any]]:
"""End-to-end: extract action items from research and file Gitea issues.
This is the main entry point that closes the research → backlog loop.
Args:
report: Research report text (markdown).
source_issue: The Gitea issue number that produced this research.
llm_caller: Optional async callable(prompt) -> str for LLM calls.
dry_run: If True, extract items but don't create issues.
Returns:
List of dicts with 'action_item' and 'gitea_issue' (or None) keys.
"""
items = await extract_action_items(report, llm_caller=llm_caller)
if not items:
logger.info("No action items extracted from research report")
return []
results = []
for item in items:
if dry_run:
results.append({"action_item": item, "gitea_issue": None})
continue
issue_data = await create_gitea_issue(item, source_issue=source_issue)
results.append({"action_item": item, "gitea_issue": issue_data})
created_count = sum(1 for r in results if r["gitea_issue"] is not None)
logger.info(
"Research triage complete: %d items extracted, %d issues created",
len(results),
created_count,
)
return results

View File

@@ -24,6 +24,9 @@ from config import settings
logger = logging.getLogger(__name__)
# Max characters of user query included in Lightning invoice memo
_INVOICE_MEMO_MAX_LEN = 50
# Lazy imports to handle test mocking
_ImportError = None
try:
@@ -447,7 +450,6 @@ def consult_grok(query: str) -> str:
)
except (ImportError, AttributeError) as exc:
logger.warning("Tool execution failed (consult_grok logging): %s", exc)
pass
# Generate Lightning invoice for monetization (unless free mode)
invoice_info = ""
@@ -456,12 +458,11 @@ def consult_grok(query: str) -> str:
from lightning.factory import get_backend as get_ln_backend
ln = get_ln_backend()
sats = min(settings.grok_max_sats_per_query, 100)
inv = ln.create_invoice(sats, f"Grok query: {query[:50]}")
sats = min(settings.grok_max_sats_per_query, settings.grok_sats_hard_cap)
inv = ln.create_invoice(sats, f"Grok query: {query[:_INVOICE_MEMO_MAX_LEN]}")
invoice_info = f"\n[Lightning invoice: {sats} sats — {inv.payment_request[:40]}...]"
except (ImportError, OSError, ValueError) as exc:
logger.warning("Tool execution failed (Lightning invoice): %s", exc)
pass
result = backend.run(query)
@@ -472,6 +473,69 @@ def consult_grok(query: str) -> str:
return response
def web_fetch(url: str, max_tokens: int = 4000) -> str:
"""Fetch a web page and return its main text content.
Downloads the URL, extracts readable text using trafilatura, and
truncates to a token budget. Use this to read full articles, docs,
or blog posts that web_search only returns snippets for.
Args:
url: The URL to fetch (must start with http:// or https://).
max_tokens: Maximum approximate token budget (default 4000).
Text is truncated to max_tokens * 4 characters.
Returns:
Extracted text content, or an error message on failure.
"""
if not url or not url.startswith(("http://", "https://")):
return f"Error: invalid URL — must start with http:// or https://: {url!r}"
try:
import requests as _requests
except ImportError:
return "Error: 'requests' package is not installed. Install with: pip install requests"
try:
import trafilatura
except ImportError:
return (
"Error: 'trafilatura' package is not installed. Install with: pip install trafilatura"
)
try:
resp = _requests.get(
url,
timeout=15,
headers={"User-Agent": "TimmyResearchBot/1.0"},
)
resp.raise_for_status()
except _requests.exceptions.Timeout:
return f"Error: request timed out after 15 seconds for {url}"
except _requests.exceptions.HTTPError as exc:
return f"Error: HTTP {exc.response.status_code} for {url}"
except _requests.exceptions.RequestException as exc:
return f"Error: failed to fetch {url}{exc}"
text = trafilatura.extract(resp.text, include_tables=True, include_links=True)
if not text:
return f"Error: could not extract readable content from {url}"
char_budget = max_tokens * 4
if len(text) > char_budget:
text = text[:char_budget] + f"\n\n[…truncated to ~{max_tokens} tokens]"
return text
def _register_web_fetch_tool(toolkit: Toolkit) -> None:
"""Register the web_fetch tool for full-page content extraction."""
try:
toolkit.register(web_fetch, name="web_fetch")
except Exception as exc:
logger.warning("Tool execution failed (web_fetch registration): %s", exc)
def _register_core_tools(toolkit: Toolkit, base_path: Path) -> None:
"""Register core execution and file tools."""
# Python execution
@@ -671,6 +735,7 @@ def create_full_toolkit(base_dir: str | Path | None = None):
base_path = Path(base_dir) if base_dir else Path(settings.repo_root)
_register_core_tools(toolkit, base_path)
_register_web_fetch_tool(toolkit)
_register_grok_tool(toolkit)
_register_memory_tools(toolkit)
_register_agentic_loop_tool(toolkit)
@@ -828,6 +893,11 @@ def _analysis_tool_catalog() -> dict:
"description": "Evaluate mathematical expressions with exact results",
"available_in": ["orchestrator"],
},
"web_fetch": {
"name": "Web Fetch",
"description": "Fetch a web page and extract clean readable text (trafilatura)",
"available_in": ["orchestrator"],
},
}
@@ -940,7 +1010,7 @@ def _merge_catalog(
"available_in": available_in,
}
except ImportError:
pass
logger.debug("Optional catalog %s.%s not available", module_path, attr_name)
def get_all_available_tools() -> dict[str, dict]:

View File

@@ -139,6 +139,7 @@ def _run_kimi(cmd: list[str], workdir: str) -> dict[str, Any]:
"error": "Kimi timed out after 300s. Task may be too broad — try breaking it into smaller pieces.",
}
except Exception as exc:
logger.exception("Failed to run Kimi subprocess")
return {
"success": False,
"error": f"Failed to run Kimi: {exc}",

View File

@@ -122,6 +122,7 @@ def check_ollama_health() -> dict[str, Any]:
models = response.json().get("models", [])
result["available_models"] = [m.get("name", "") for m in models]
except Exception as e:
logger.exception("Ollama health check failed")
result["error"] = str(e)
return result
@@ -289,6 +290,7 @@ def get_live_system_status() -> dict[str, Any]:
try:
result["system"] = get_system_info()
except Exception as exc:
logger.exception("Failed to get system info")
result["system"] = {"error": str(exc)}
# Task queue
@@ -301,6 +303,7 @@ def get_live_system_status() -> dict[str, Any]:
try:
result["memory"] = get_memory_status()
except Exception as exc:
logger.exception("Failed to get memory status")
result["memory"] = {"error": str(exc)}
# Uptime
@@ -406,4 +409,5 @@ def run_self_tests(scope: str = "fast", _repo_root: str | None = None) -> dict[s
except subprocess.TimeoutExpired:
return {"success": False, "error": "Test run timed out (120s limit)"}
except Exception as exc:
logger.exception("Self-test run failed")
return {"success": False, "error": str(exc)}

View File

@@ -14,10 +14,17 @@ app = typer.Typer(help="Timmy Serve — sovereign AI agent API")
def start(
port: int = typer.Option(8402, "--port", "-p", help="Port for the serve API"),
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to"),
price: int = typer.Option(100, "--price", help="Price per request in sats"),
price: int = typer.Option(
None, "--price", help="Price per request in sats (default: from config)"
),
dry_run: bool = typer.Option(False, "--dry-run", help="Print config and exit (for testing)"),
):
"""Start Timmy in serve mode."""
from config import settings
if price is None:
price = settings.grok_sats_hard_cap
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")

Some files were not shown because too many files have changed in this diff Show More